Compare commits

...

3 Commits

Author SHA1 Message Date
Josh Hawkins
a9f139e062
Fixes (#17961)
* Fix i18n page titles

* fix frontend crash in npu stats

* return empty object for gpu_usages if null

* fix gpu info i18n keys
2025-04-29 17:03:44 -05:00
Josh Hawkins
c91c6970de
Autotracking improvements (#17955)
* add zoom time to movement predictions

* config migrator

* add space to face rename regex

* more debug

* only calculate zoom time of relative move

* fix test

* make migrated movement weight a zero

* check for str and bool for movestatus support
2025-04-29 10:17:56 -06:00
Nicolas Mowen
e57dde7bb0
Support automatic language selection based on system language (#17953)
* Support automatic language selection

* Handle non-matching keys

* Cleanup

* Handle region specific language codes from browser

* Fix passing in requestor
2025-04-29 10:02:50 -05:00
14 changed files with 181 additions and 55 deletions

View File

@ -63,9 +63,9 @@ class PtzAutotrackConfig(FrigateBaseModel):
else:
raise ValueError("Invalid type for movement_weights")
if len(weights) != 5:
if len(weights) != 6:
raise ValueError(
"movement_weights must have exactly 5 floats, remove this line from your config and run autotracking calibration"
"movement_weights must have exactly 6 floats, remove this line from your config and run autotracking calibration"
)
return weights

View File

@ -243,7 +243,7 @@ class EmbeddingsContext:
)
def rename_face(self, old_name: str, new_name: str) -> None:
valid_name_pattern = r"^[a-zA-Z0-9_-]{1,50}$"
valid_name_pattern = r"^[a-zA-Z0-9\s_-]{1,50}$"
try:
sanitized_old_name = sanitize_filename(old_name, replacement_text="_")

View File

@ -120,7 +120,7 @@ class EmbeddingMaintainer(threading.Thread):
if self.config.face_recognition.enabled:
self.realtime_processors.append(
FaceRealTimeProcessor(
self.config, self.event_metadata_publisher, metrics
self.config, self.requestor, self.event_metadata_publisher, metrics
)
)

View File

@ -206,6 +206,7 @@ class PtzAutoTracker:
self.calibrating: dict[str, object] = {}
self.intercept: dict[str, object] = {}
self.move_coefficients: dict[str, object] = {}
self.zoom_time: dict[str, float] = {}
self.zoom_factor: dict[str, object] = {}
# if cam is set to autotrack, onvif should be set up
@ -272,7 +273,12 @@ class PtzAutoTracker:
move_status_supported = self.onvif.get_service_capabilities(camera)
if move_status_supported is None or move_status_supported.lower() != "true":
if not (
isinstance(move_status_supported, bool) and move_status_supported
) and not (
isinstance(move_status_supported, str)
and move_status_supported.lower() == "true"
):
logger.warning(
f"Disabling autotracking for {camera}: ONVIF MoveStatus not supported"
)
@ -292,7 +298,7 @@ class PtzAutoTracker:
self.move_threads[camera].start()
if camera_config.onvif.autotracking.movement_weights:
if len(camera_config.onvif.autotracking.movement_weights) == 5:
if len(camera_config.onvif.autotracking.movement_weights) == 6:
camera_config.onvif.autotracking.movement_weights = [
float(val)
for val in camera_config.onvif.autotracking.movement_weights
@ -311,7 +317,10 @@ class PtzAutoTracker:
camera_config.onvif.autotracking.movement_weights[2]
)
self.move_coefficients[camera] = (
camera_config.onvif.autotracking.movement_weights[3:]
camera_config.onvif.autotracking.movement_weights[3:5]
)
self.zoom_time[camera] = (
camera_config.onvif.autotracking.movement_weights[5]
)
else:
camera_config.onvif.autotracking.enabled = False
@ -360,6 +369,7 @@ class PtzAutoTracker:
!= ZoomingModeEnum.disabled
):
logger.info(f"Calibration for {camera} in progress: 0% complete")
self.zoom_time[camera] = 0
for i in range(2):
# absolute move to 0 - fully zoomed out
@ -403,6 +413,7 @@ class PtzAutoTracker:
zoom_out_values.append(self.ptz_metrics[camera].zoom_level.value)
zoom_start_time = time.time()
# relative move to 0.01
self.onvif._move_relative(
camera,
@ -415,13 +426,45 @@ class PtzAutoTracker:
while not self.ptz_metrics[camera].motor_stopped.is_set():
self.onvif.get_camera_status(camera)
zoom_stop_time = time.time()
full_relative_start_time = time.time()
self.onvif._move_relative(
camera,
-1,
-1,
-1e-2,
1,
)
while not self.ptz_metrics[camera].motor_stopped.is_set():
self.onvif.get_camera_status(camera)
full_relative_stop_time = time.time()
self.onvif._move_relative(
camera,
1,
1,
1e-2,
1,
)
while not self.ptz_metrics[camera].motor_stopped.is_set():
self.onvif.get_camera_status(camera)
self.zoom_time[camera] = (
full_relative_stop_time - full_relative_start_time
) - (zoom_stop_time - zoom_start_time)
zoom_in_values.append(self.ptz_metrics[camera].zoom_level.value)
self.ptz_metrics[camera].max_zoom.value = max(zoom_in_values)
self.ptz_metrics[camera].min_zoom.value = min(zoom_out_values)
logger.debug(
f"{camera}: Calibration values: max zoom: {self.ptz_metrics[camera].max_zoom.value}, min zoom: {self.ptz_metrics[camera].min_zoom.value}"
f"{camera}: Calibration values: max zoom: {self.ptz_metrics[camera].max_zoom.value}, min zoom: {self.ptz_metrics[camera].min_zoom.value}, zoom time: {self.zoom_time[camera]}"
)
else:
@ -537,6 +580,7 @@ class PtzAutoTracker:
self.ptz_metrics[camera].max_zoom.value,
self.intercept[camera],
*self.move_coefficients[camera],
self.zoom_time[camera],
]
)
@ -1061,6 +1105,7 @@ class PtzAutoTracker:
average_velocity = np.zeros((4,))
predicted_box = obj.obj_data["box"]
zoom_predicted_box = obj.obj_data["box"]
centroid_x = obj.obj_data["centroid"][0]
centroid_y = obj.obj_data["centroid"][1]
@ -1069,20 +1114,20 @@ class PtzAutoTracker:
pan = ((centroid_x / camera_width) - 0.5) * 2
tilt = (0.5 - (centroid_y / camera_height)) * 2
_, average_velocity = (
self._get_valid_velocity(camera, obj)
if "velocity" not in self.tracked_object_metrics[camera]
else (
self.tracked_object_metrics[camera]["valid_velocity"],
self.tracked_object_metrics[camera]["velocity"],
)
)
if (
camera_config.onvif.autotracking.movement_weights
): # use estimates if we have available coefficients
predicted_movement_time = self._predict_movement_time(camera, pan, tilt)
_, average_velocity = (
self._get_valid_velocity(camera, obj)
if "velocity" not in self.tracked_object_metrics[camera]
else (
self.tracked_object_metrics[camera]["valid_velocity"],
self.tracked_object_metrics[camera]["velocity"],
)
)
if np.any(average_velocity):
# this box could exceed the frame boundaries if velocity is high
# but we'll handle that in _enqueue_move() as two separate moves
@ -1111,6 +1156,34 @@ class PtzAutoTracker:
camera, obj, predicted_box, predicted_movement_time, debug_zoom=True
)
if (
camera_config.onvif.autotracking.movement_weights
and camera_config.onvif.autotracking.zooming == ZoomingModeEnum.relative
and zoom != 0
):
zoom_predicted_movement_time = 0
if np.any(average_velocity):
zoom_predicted_movement_time = abs(zoom) * self.zoom_time[camera]
zoom_predicted_box = (
predicted_box
+ camera_fps * zoom_predicted_movement_time * average_velocity
)
zoom_predicted_box = np.round(zoom_predicted_box).astype(int)
centroid_x = round((zoom_predicted_box[0] + zoom_predicted_box[2]) / 2)
centroid_y = round((zoom_predicted_box[1] + zoom_predicted_box[3]) / 2)
# recalculate pan and tilt with new centroid
pan = ((centroid_x / camera_width) - 0.5) * 2
tilt = (0.5 - (centroid_y / camera_height)) * 2
logger.debug(
f"{camera}: Zoom amount: {zoom}, zoom predicted time: {zoom_predicted_movement_time}, zoom predicted box: {tuple(zoom_predicted_box)}"
)
self._enqueue_move(camera, obj.obj_data["frame_time"], pan, tilt, zoom)
def _autotrack_move_zoom_only(self, camera, obj):
@ -1242,7 +1315,7 @@ class PtzAutoTracker:
return
# this is a brand new object that's on our camera, has our label, entered the zone,
# is not a false positive, and is not initially motionless
# is not a false positive, and is active
if (
# new object
self.tracked_object[camera] is None
@ -1252,7 +1325,7 @@ class PtzAutoTracker:
and not obj.previous["false_positive"]
and not obj.false_positive
and not self.tracked_object_history[camera]
and obj.obj_data["motionless_count"] == 0
and obj.active
):
logger.debug(
f"{camera}: New object: {obj.obj_data['id']} {obj.obj_data['box']} {obj.obj_data['frame_time']}"

View File

@ -1491,7 +1491,9 @@ class TestConfig(unittest.TestCase):
"fps": 5,
},
"onvif": {
"autotracking": {"movement_weights": "0, 1, 1.23, 2.34, 0.50"}
"autotracking": {
"movement_weights": "0, 1, 1.23, 2.34, 0.50, 1"
}
},
}
},
@ -1504,6 +1506,7 @@ class TestConfig(unittest.TestCase):
"1.23",
"2.34",
"0.5",
"1.0",
]
def test_fails_invalid_movement_weights(self):

View File

@ -319,6 +319,21 @@ def migrate_016_0(config: dict[str, dict[str, any]]) -> dict[str, dict[str, any]
camera_config["live"] = live_config
# add another value to movement_weights for autotracking cams
onvif_config = camera_config.get("onvif", {})
if "autotracking" in onvif_config:
movement_weights = (
camera_config.get("onvif", {})
.get("autotracking")
.get("movement_weights", {})
)
if movement_weights and len(movement_weights.split(",")) == 5:
onvif_config["autotracking"]["movement_weights"] = (
movement_weights + ", 0"
)
camera_config["onvif"] = onvif_config
new_config["cameras"][name] = camera_config
new_config["version"] = "0.16-0"

View File

@ -6,9 +6,10 @@
"classification": "Classification Settings - Frigate",
"masksAndZones": "Mask and Zone Editor - Frigate",
"motionTuner": "Motion Tuner - Frigate",
"object": "Object Settings - Frigate",
"object": "Debug - Frigate",
"general": "General Settings - Frigate",
"frigatePlus": "Frigate+ Settings - Frigate"
"frigatePlus": "Frigate+ Settings - Frigate",
"notifications": "Notification Settings - Frigate"
},
"menu": {
"ui": "UI",

View File

@ -34,7 +34,7 @@ import {
useTheme,
} from "@/context/theme-provider";
import { IoColorPalette } from "react-icons/io5";
import { useState } from "react";
import { useMemo, useState } from "react";
import { useRestart } from "@/api/ws";
import {
Tooltip,
@ -62,6 +62,7 @@ import { toast } from "sonner";
import axios from "axios";
import { FrigateConfig } from "@/types/frigateConfig";
import { useTranslation } from "react-i18next";
import { supportedLanguageKeys } from "@/lib/const";
type GeneralSettingsProps = {
className?: string;
@ -75,20 +76,21 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
// languages
const languages = [
{ code: "en", label: t("menu.language.en") },
{ code: "es", label: t("menu.language.es") },
{ code: "fr", label: t("menu.language.fr") },
{ code: "de", label: t("menu.language.de") },
{ code: "it", label: t("menu.language.it") },
{ code: "nl", label: t("menu.language.nl") },
{ code: "nb-NO", label: t("menu.language.nb") },
{ code: "tr", label: t("menu.language.tr") },
{ code: "pl", label: t("menu.language.pl") },
{ code: "zh-CN", label: t("menu.language.zhCN") },
{ code: "yue-Hant", label: t("menu.language.yue") },
{ code: "ru", label: t("menu.language.ru") },
];
const languages = useMemo(() => {
// Handle language keys that aren't directly used for translation key
const specialKeyMap: { [key: string]: string } = {
"nb-NO": "nb",
"yue-Hant": "yue",
"zh-CN": "zhCN",
};
return supportedLanguageKeys.map((key) => {
return {
code: key,
label: t(`menu.language.${specialKeyMap[key] || key}`),
};
});
}, [t]);
// settings

View File

@ -108,7 +108,7 @@ export default function GPUInfoDialog({
<br />
<div>
{t("general.hardwareInfo.gpuInfo.nvidiaSMIOutput.driver", {
name: nvinfo["0"].driver,
driver: nvinfo["0"].driver,
})}
</div>
<br />
@ -116,14 +116,14 @@ export default function GPUInfoDialog({
{t(
"general.hardwareInfo.gpuInfo.nvidiaSMIOutput.cudaComputerCapability",
{
name: nvinfo["0"].cuda_compute,
cuda_compute: nvinfo["0"].cuda_compute,
},
)}
</div>
<br />
<div>
{t("general.hardwareInfo.gpuInfo.nvidiaSMIOutput.vbios", {
name: nvinfo["0"].vbios,
vbios: nvinfo["0"].vbios,
})}
</div>
</div>

View File

@ -1,15 +1,14 @@
import { createContext, useContext, useState, useEffect, useMemo } from "react";
import i18next from "i18next";
import { supportedLanguageKeys } from "@/lib/const";
type LanguageProviderState = {
language: string;
systemLanguage: string;
setLanguage: (language: string) => void;
};
const initialState: LanguageProviderState = {
language: i18next.language || "en",
systemLanguage: "en",
setLanguage: () => null,
};
@ -26,10 +25,31 @@ export function LanguageProvider({
defaultLanguage?: string;
storageKey?: string;
}) {
const systemLanguage = useMemo<string>(() => {
if (typeof window === "undefined") return defaultLanguage;
const systemLanguage = window.navigator.language;
if (supportedLanguageKeys.includes(systemLanguage)) {
return systemLanguage;
}
// browser languages may include a -REGION (ex: en-US)
if (systemLanguage.includes("-")) {
const shortenedSystemLanguage = systemLanguage.split("-")[0];
if (supportedLanguageKeys.includes(shortenedSystemLanguage)) {
return shortenedSystemLanguage;
}
}
return defaultLanguage;
}, [defaultLanguage]);
const [language, setLanguage] = useState<string>(() => {
try {
const storedData = localStorage.getItem(storageKey);
const newLanguage = storedData || defaultLanguage;
const newLanguage = storedData || systemLanguage;
i18next.changeLanguage(newLanguage);
return newLanguage;
} catch (error) {
@ -39,11 +59,6 @@ export function LanguageProvider({
}
});
const systemLanguage = useMemo<string>(() => {
if (typeof window === "undefined") return "en";
return window.navigator.language;
}, []);
useEffect(() => {
// set document lang for smart capitalization
document.documentElement.lang = language;
@ -54,7 +69,6 @@ export function LanguageProvider({
const value = {
language,
systemLanguage,
setLanguage: (language: string) => {
localStorage.setItem(storageKey, language);
setLanguage(language);

14
web/src/lib/const.ts Normal file
View File

@ -0,0 +1,14 @@
export const supportedLanguageKeys = [
"en",
"es",
"fr",
"de",
"it",
"nl",
"nb-NO",
"tr",
"pl",
"zh-CN",
"yue-Hant",
"ru",
];

View File

@ -251,8 +251,8 @@ export default function CameraSettingsView({
}
useEffect(() => {
document.title = "Camera Settings - Frigate";
}, []);
document.title = t("documentTitle.camera");
}, [t]);
if (!cameraConfig && !selectedCamera) {
return <ActivityIndicator />;

View File

@ -299,6 +299,10 @@ export default function NotificationView({
saveToConfig(values as NotificationSettingsValueType);
}
useEffect(() => {
document.title = t("documentTitle.notifications");
}, [t]);
if (!("Notification" in window) || !window.isSecureContext) {
return (
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0">

View File

@ -240,7 +240,7 @@ export default function GeneralMetrics({
return;
}
Object.entries(stats.gpu_usages || []).forEach(([key, stats]) => {
Object.entries(stats.gpu_usages || {}).forEach(([key, stats]) => {
if (!(key in series)) {
series[key] = { name: key, data: [] };
}
@ -316,7 +316,7 @@ export default function GeneralMetrics({
return;
}
Object.entries(stats.gpu_usages || []).forEach(([key, stats]) => {
Object.entries(stats.gpu_usages || {}).forEach(([key, stats]) => {
if (!(key in series)) {
series[key] = { name: key, data: [] };
}
@ -350,7 +350,7 @@ export default function GeneralMetrics({
return;
}
Object.entries(stats.gpu_usages || []).forEach(([key, stats]) => {
Object.entries(stats.gpu_usages || {}).forEach(([key, stats]) => {
if (!(key in series)) {
series[key] = { name: key, data: [] };
}
@ -386,7 +386,7 @@ export default function GeneralMetrics({
return;
}
Object.entries(stats.npu_usages || []).forEach(([key, stats]) => {
Object.entries(stats.npu_usages || {}).forEach(([key, stats]) => {
if (!(key in series)) {
series[key] = { name: key, data: [] };
}