From 19acfe0f7c5e766e073d2950d0c1056259c5e24d Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Fri, 6 Feb 2026 07:56:30 -0600 Subject: [PATCH] clean up detectors section and i18n --- generate_config_translations.py | 27 +- web/public/locales/en/config/global.json | 856 ++++++++++++++--- web/public/locales/en/views/settings.json | 8 + .../config-form/section-configs/detectors.ts | 23 +- .../theme/fields/DetectorHardwareField.tsx | 869 ++++++++++++++++++ .../config-form/theme/fields/index.ts | 1 + .../config-form/theme/frigateTheme.ts | 2 + .../theme/templates/FieldTemplate.tsx | 6 +- .../theme/templates/ObjectFieldTemplate.tsx | 6 +- .../config-form/theme/utils/i18n.ts | 75 +- 10 files changed, 1740 insertions(+), 133 deletions(-) create mode 100644 web/src/components/config-form/theme/fields/DetectorHardwareField.tsx diff --git a/generate_config_translations.py b/generate_config_translations.py index 207940926..f41957561 100644 --- a/generate_config_translations.py +++ b/generate_config_translations.py @@ -190,15 +190,15 @@ def generate_section_translation(config_class: type) -> Dict[str, Any]: def get_detector_translations( config_schema: Dict[str, Any], -) -> tuple[Dict[str, Any], Dict[str, Any]]: - """Build detector field and type translations based on schema definitions.""" +) -> tuple[Dict[str, Any], set[str]]: + """Build detector type translations with nested fields based on schema definitions.""" defs = config_schema.get("$defs", {}) detector_schema = defs.get("DetectorConfig", {}) discriminator = detector_schema.get("discriminator", {}) mapping = discriminator.get("mapping", {}) type_translations: Dict[str, Any] = {} - field_translations: Dict[str, Any] = {} + nested_field_keys: set[str] = set() for detector_type, ref in mapping.items(): if not isinstance(ref, str): continue @@ -219,16 +219,18 @@ def get_detector_translations( if description: type_entry["description"] = description - if type_entry: - type_translations[detector_type] = type_entry - nested = extract_translations_from_schema(ref_schema, defs=defs) nested_without_root = { k: v for k, v in nested.items() if k not in ("label", "description") } - field_translations.update(nested_without_root) + if nested_without_root: + type_entry.update(nested_without_root) + nested_field_keys.update(nested_without_root.keys()) - return field_translations, type_translations + if type_entry: + type_translations[detector_type] = type_entry + + return type_translations, nested_field_keys def main(): @@ -301,9 +303,14 @@ def main(): section_data.update(nested_without_root) if field_name == "detectors": - detector_fields, detector_types = get_detector_translations(config_schema) - section_data.update(detector_fields) + detector_types, detector_field_keys = get_detector_translations( + config_schema + ) section_data.update(detector_types) + for key in detector_field_keys: + if key == "type": + continue + section_data.pop(key, None) if not section_data: logger.warning(f"No translations found for section: {field_name}") diff --git a/web/public/locales/en/config/global.json b/web/public/locales/en/config/global.json index 8001c4858..acc039c7e 100644 --- a/web/public/locales/en/config/global.json +++ b/web/public/locales/en/config/global.json @@ -287,155 +287,791 @@ "label": "Detector hardware", "description": "Configuration for object detectors (CPU, GPU, ONNX backends) and any detector-specific model settings.", "type": { - "label": "Type" - }, - "model": { - "label": "Detector specific model configuration", - "description": "Detector-specific model configuration options (path, input size, etc.).", - "path": { - "label": "Custom Object detection model path", - "description": "Path to a custom detection model file (or plus:// for Frigate+ models)." - }, - "labelmap_path": { - "label": "Label map for custom object detector", - "description": "Path to a labelmap file that maps numeric classes to string labels for the detector." - }, - "width": { - "label": "Object detection model input width", - "description": "Width of the model input tensor in pixels." - }, - "height": { - "label": "Object detection model input height", - "description": "Height of the model input tensor in pixels." - }, - "labelmap": { - "label": "Labelmap customization", - "description": "Overrides or remapping entries to merge into the standard labelmap." - }, - "attributes_map": { - "label": "Map of object labels to their attribute labels", - "description": "Mapping from object labels to attribute labels used to attach metadata (for example 'car' -> ['license_plate'])." - }, - "input_tensor": { - "label": "Model Input Tensor Shape", - "description": "Tensor format expected by the model: 'nhwc' or 'nchw'." - }, - "input_pixel_format": { - "label": "Model Input Pixel Color Format", - "description": "Pixel colorspace expected by the model: 'rgb', 'bgr', or 'yuv'." - }, - "input_dtype": { - "label": "Model Input D Type", - "description": "Data type of the model input tensor (for example 'float32')." - }, - "model_type": { - "label": "Object Detection Model Type", - "description": "Detector model architecture type (ssd, yolox, yolonas) used by some detectors for optimization." - } - }, - "model_path": { - "label": "Detector specific model path", - "description": "File path to the detector model binary if required by the chosen detector." - }, - "num_threads": { - "label": "Number of detection threads", - "description": "The number of threads used for CPU-based inference." - }, - "api_url": { - "label": "DeepStack API URL", - "description": "The URL of the DeepStack API." - }, - "api_timeout": { - "label": "DeepStack API timeout (in seconds)", - "description": "Maximum time allowed for a DeepStack API request." - }, - "api_key": { - "label": "DeepStack API key (if required)", - "description": "Optional API key for authenticated DeepStack services." - }, - "location": { - "label": "Inference Location", - "description": "Location of the DeGirim inference engine (e.g. '@cloud', '127.0.0.1')." - }, - "zoo": { - "label": "Model Zoo", - "description": "Path or URL to the DeGirum model zoo." - }, - "token": { - "label": "DeGirum Cloud Token", - "description": "Token for DeGirum Cloud access." - }, - "device": { - "label": "GPU Device Index", - "description": "The GPU device index to use." - }, - "num_cores": { - "label": "Number of NPU cores to use.", - "description": "The number of NPU cores to use (0 for auto)." - }, - "endpoint": { - "label": "ZMQ IPC endpoint", - "description": "The ZMQ endpoint to connect to." - }, - "request_timeout_ms": { - "label": "ZMQ request timeout in milliseconds", - "description": "Timeout for ZMQ requests in milliseconds." - }, - "linger_ms": { - "label": "ZMQ socket linger in milliseconds", - "description": "Socket linger period in milliseconds." + "label": "Detector Type", + "description": "Type of detector to use for object detection (for example 'cpu', 'edgetpu', 'openvino')." }, "cpu": { "label": "CPU", - "description": "CPU TFLite detector that runs TensorFlow Lite models on the host CPU without hardware acceleration. Not recommended." + "description": "CPU TFLite detector that runs TensorFlow Lite models on the host CPU without hardware acceleration. Not recommended.", + "type": { + "label": "Type" + }, + "model": { + "label": "Detector specific model configuration", + "description": "Detector-specific model configuration options (path, input size, etc.).", + "path": { + "label": "Custom Object detection model path", + "description": "Path to a custom detection model file (or plus:// for Frigate+ models)." + }, + "labelmap_path": { + "label": "Label map for custom object detector", + "description": "Path to a labelmap file that maps numeric classes to string labels for the detector." + }, + "width": { + "label": "Object detection model input width", + "description": "Width of the model input tensor in pixels." + }, + "height": { + "label": "Object detection model input height", + "description": "Height of the model input tensor in pixels." + }, + "labelmap": { + "label": "Labelmap customization", + "description": "Overrides or remapping entries to merge into the standard labelmap." + }, + "attributes_map": { + "label": "Map of object labels to their attribute labels", + "description": "Mapping from object labels to attribute labels used to attach metadata (for example 'car' -> ['license_plate'])." + }, + "input_tensor": { + "label": "Model Input Tensor Shape", + "description": "Tensor format expected by the model: 'nhwc' or 'nchw'." + }, + "input_pixel_format": { + "label": "Model Input Pixel Color Format", + "description": "Pixel colorspace expected by the model: 'rgb', 'bgr', or 'yuv'." + }, + "input_dtype": { + "label": "Model Input D Type", + "description": "Data type of the model input tensor (for example 'float32')." + }, + "model_type": { + "label": "Object Detection Model Type", + "description": "Detector model architecture type (ssd, yolox, yolonas) used by some detectors for optimization." + } + }, + "model_path": { + "label": "Detector specific model path", + "description": "File path to the detector model binary if required by the chosen detector." + }, + "num_threads": { + "label": "Number of detection threads", + "description": "The number of threads used for CPU-based inference." + } }, "deepstack": { "label": "DeepStack", - "description": "DeepStack/CodeProject.AI detector that sends images to a remote DeepStack HTTP API for inference. Not recommended." + "description": "DeepStack/CodeProject.AI detector that sends images to a remote DeepStack HTTP API for inference. Not recommended.", + "type": { + "label": "Type" + }, + "model": { + "label": "Detector specific model configuration", + "description": "Detector-specific model configuration options (path, input size, etc.).", + "path": { + "label": "Custom Object detection model path", + "description": "Path to a custom detection model file (or plus:// for Frigate+ models)." + }, + "labelmap_path": { + "label": "Label map for custom object detector", + "description": "Path to a labelmap file that maps numeric classes to string labels for the detector." + }, + "width": { + "label": "Object detection model input width", + "description": "Width of the model input tensor in pixels." + }, + "height": { + "label": "Object detection model input height", + "description": "Height of the model input tensor in pixels." + }, + "labelmap": { + "label": "Labelmap customization", + "description": "Overrides or remapping entries to merge into the standard labelmap." + }, + "attributes_map": { + "label": "Map of object labels to their attribute labels", + "description": "Mapping from object labels to attribute labels used to attach metadata (for example 'car' -> ['license_plate'])." + }, + "input_tensor": { + "label": "Model Input Tensor Shape", + "description": "Tensor format expected by the model: 'nhwc' or 'nchw'." + }, + "input_pixel_format": { + "label": "Model Input Pixel Color Format", + "description": "Pixel colorspace expected by the model: 'rgb', 'bgr', or 'yuv'." + }, + "input_dtype": { + "label": "Model Input D Type", + "description": "Data type of the model input tensor (for example 'float32')." + }, + "model_type": { + "label": "Object Detection Model Type", + "description": "Detector model architecture type (ssd, yolox, yolonas) used by some detectors for optimization." + } + }, + "model_path": { + "label": "Detector specific model path", + "description": "File path to the detector model binary if required by the chosen detector." + }, + "api_url": { + "label": "DeepStack API URL", + "description": "The URL of the DeepStack API." + }, + "api_timeout": { + "label": "DeepStack API timeout (in seconds)", + "description": "Maximum time allowed for a DeepStack API request." + }, + "api_key": { + "label": "DeepStack API key (if required)", + "description": "Optional API key for authenticated DeepStack services." + } }, "degirum": { "label": "DeGirum", - "description": "DeGirum detector for running models via DeGirum cloud or local inference services." + "description": "DeGirum detector for running models via DeGirum cloud or local inference services.", + "type": { + "label": "Type" + }, + "model": { + "label": "Detector specific model configuration", + "description": "Detector-specific model configuration options (path, input size, etc.).", + "path": { + "label": "Custom Object detection model path", + "description": "Path to a custom detection model file (or plus:// for Frigate+ models)." + }, + "labelmap_path": { + "label": "Label map for custom object detector", + "description": "Path to a labelmap file that maps numeric classes to string labels for the detector." + }, + "width": { + "label": "Object detection model input width", + "description": "Width of the model input tensor in pixels." + }, + "height": { + "label": "Object detection model input height", + "description": "Height of the model input tensor in pixels." + }, + "labelmap": { + "label": "Labelmap customization", + "description": "Overrides or remapping entries to merge into the standard labelmap." + }, + "attributes_map": { + "label": "Map of object labels to their attribute labels", + "description": "Mapping from object labels to attribute labels used to attach metadata (for example 'car' -> ['license_plate'])." + }, + "input_tensor": { + "label": "Model Input Tensor Shape", + "description": "Tensor format expected by the model: 'nhwc' or 'nchw'." + }, + "input_pixel_format": { + "label": "Model Input Pixel Color Format", + "description": "Pixel colorspace expected by the model: 'rgb', 'bgr', or 'yuv'." + }, + "input_dtype": { + "label": "Model Input D Type", + "description": "Data type of the model input tensor (for example 'float32')." + }, + "model_type": { + "label": "Object Detection Model Type", + "description": "Detector model architecture type (ssd, yolox, yolonas) used by some detectors for optimization." + } + }, + "model_path": { + "label": "Detector specific model path", + "description": "File path to the detector model binary if required by the chosen detector." + }, + "location": { + "label": "Inference Location", + "description": "Location of the DeGirim inference engine (e.g. '@cloud', '127.0.0.1')." + }, + "zoo": { + "label": "Model Zoo", + "description": "Path or URL to the DeGirum model zoo." + }, + "token": { + "label": "DeGirum Cloud Token", + "description": "Token for DeGirum Cloud access." + } }, "edgetpu": { "label": "EdgeTPU", - "description": "EdgeTPU detector that runs TensorFlow Lite models compiled for Coral EdgeTPU using the EdgeTPU delegate." + "description": "EdgeTPU detector that runs TensorFlow Lite models compiled for Coral EdgeTPU using the EdgeTPU delegate.", + "type": { + "label": "Type" + }, + "model": { + "label": "Detector specific model configuration", + "description": "Detector-specific model configuration options (path, input size, etc.).", + "path": { + "label": "Custom Object detection model path", + "description": "Path to a custom detection model file (or plus:// for Frigate+ models)." + }, + "labelmap_path": { + "label": "Label map for custom object detector", + "description": "Path to a labelmap file that maps numeric classes to string labels for the detector." + }, + "width": { + "label": "Object detection model input width", + "description": "Width of the model input tensor in pixels." + }, + "height": { + "label": "Object detection model input height", + "description": "Height of the model input tensor in pixels." + }, + "labelmap": { + "label": "Labelmap customization", + "description": "Overrides or remapping entries to merge into the standard labelmap." + }, + "attributes_map": { + "label": "Map of object labels to their attribute labels", + "description": "Mapping from object labels to attribute labels used to attach metadata (for example 'car' -> ['license_plate'])." + }, + "input_tensor": { + "label": "Model Input Tensor Shape", + "description": "Tensor format expected by the model: 'nhwc' or 'nchw'." + }, + "input_pixel_format": { + "label": "Model Input Pixel Color Format", + "description": "Pixel colorspace expected by the model: 'rgb', 'bgr', or 'yuv'." + }, + "input_dtype": { + "label": "Model Input D Type", + "description": "Data type of the model input tensor (for example 'float32')." + }, + "model_type": { + "label": "Object Detection Model Type", + "description": "Detector model architecture type (ssd, yolox, yolonas) used by some detectors for optimization." + } + }, + "model_path": { + "label": "Detector specific model path", + "description": "File path to the detector model binary if required by the chosen detector." + }, + "device": { + "label": "Device Type", + "description": "The device to use for EdgeTPU inference (e.g. 'usb', 'pci')." + } }, "hailo8l": { "label": "Hailo-8/Hailo-8L", - "description": "Hailo-8/Hailo-8L detector using HEF models and the HailoRT SDK for inference on Hailo hardware." + "description": "Hailo-8/Hailo-8L detector using HEF models and the HailoRT SDK for inference on Hailo hardware.", + "type": { + "label": "Type" + }, + "model": { + "label": "Detector specific model configuration", + "description": "Detector-specific model configuration options (path, input size, etc.).", + "path": { + "label": "Custom Object detection model path", + "description": "Path to a custom detection model file (or plus:// for Frigate+ models)." + }, + "labelmap_path": { + "label": "Label map for custom object detector", + "description": "Path to a labelmap file that maps numeric classes to string labels for the detector." + }, + "width": { + "label": "Object detection model input width", + "description": "Width of the model input tensor in pixels." + }, + "height": { + "label": "Object detection model input height", + "description": "Height of the model input tensor in pixels." + }, + "labelmap": { + "label": "Labelmap customization", + "description": "Overrides or remapping entries to merge into the standard labelmap." + }, + "attributes_map": { + "label": "Map of object labels to their attribute labels", + "description": "Mapping from object labels to attribute labels used to attach metadata (for example 'car' -> ['license_plate'])." + }, + "input_tensor": { + "label": "Model Input Tensor Shape", + "description": "Tensor format expected by the model: 'nhwc' or 'nchw'." + }, + "input_pixel_format": { + "label": "Model Input Pixel Color Format", + "description": "Pixel colorspace expected by the model: 'rgb', 'bgr', or 'yuv'." + }, + "input_dtype": { + "label": "Model Input D Type", + "description": "Data type of the model input tensor (for example 'float32')." + }, + "model_type": { + "label": "Object Detection Model Type", + "description": "Detector model architecture type (ssd, yolox, yolonas) used by some detectors for optimization." + } + }, + "model_path": { + "label": "Detector specific model path", + "description": "File path to the detector model binary if required by the chosen detector." + }, + "device": { + "label": "Device Type", + "description": "The device to use for Hailo inference (e.g. 'PCIe', 'M.2')." + } }, "memryx": { "label": "MemryX", - "description": "MemryX MX3 detector that runs compiled DFP models on MemryX accelerators." + "description": "MemryX MX3 detector that runs compiled DFP models on MemryX accelerators.", + "type": { + "label": "Type" + }, + "model": { + "label": "Detector specific model configuration", + "description": "Detector-specific model configuration options (path, input size, etc.).", + "path": { + "label": "Custom Object detection model path", + "description": "Path to a custom detection model file (or plus:// for Frigate+ models)." + }, + "labelmap_path": { + "label": "Label map for custom object detector", + "description": "Path to a labelmap file that maps numeric classes to string labels for the detector." + }, + "width": { + "label": "Object detection model input width", + "description": "Width of the model input tensor in pixels." + }, + "height": { + "label": "Object detection model input height", + "description": "Height of the model input tensor in pixels." + }, + "labelmap": { + "label": "Labelmap customization", + "description": "Overrides or remapping entries to merge into the standard labelmap." + }, + "attributes_map": { + "label": "Map of object labels to their attribute labels", + "description": "Mapping from object labels to attribute labels used to attach metadata (for example 'car' -> ['license_plate'])." + }, + "input_tensor": { + "label": "Model Input Tensor Shape", + "description": "Tensor format expected by the model: 'nhwc' or 'nchw'." + }, + "input_pixel_format": { + "label": "Model Input Pixel Color Format", + "description": "Pixel colorspace expected by the model: 'rgb', 'bgr', or 'yuv'." + }, + "input_dtype": { + "label": "Model Input D Type", + "description": "Data type of the model input tensor (for example 'float32')." + }, + "model_type": { + "label": "Object Detection Model Type", + "description": "Detector model architecture type (ssd, yolox, yolonas) used by some detectors for optimization." + } + }, + "model_path": { + "label": "Detector specific model path", + "description": "File path to the detector model binary if required by the chosen detector." + }, + "device": { + "label": "Device Path", + "description": "The device to use for MemryX inference (e.g. 'PCIe')." + } }, "onnx": { "label": "ONNX", - "description": "ONNX detector for running ONNX models; will use available acceleration backends (CUDA/ROCm/OpenVINO) when available." + "description": "ONNX detector for running ONNX models; will use available acceleration backends (CUDA/ROCm/OpenVINO) when available.", + "type": { + "label": "Type" + }, + "model": { + "label": "Detector specific model configuration", + "description": "Detector-specific model configuration options (path, input size, etc.).", + "path": { + "label": "Custom Object detection model path", + "description": "Path to a custom detection model file (or plus:// for Frigate+ models)." + }, + "labelmap_path": { + "label": "Label map for custom object detector", + "description": "Path to a labelmap file that maps numeric classes to string labels for the detector." + }, + "width": { + "label": "Object detection model input width", + "description": "Width of the model input tensor in pixels." + }, + "height": { + "label": "Object detection model input height", + "description": "Height of the model input tensor in pixels." + }, + "labelmap": { + "label": "Labelmap customization", + "description": "Overrides or remapping entries to merge into the standard labelmap." + }, + "attributes_map": { + "label": "Map of object labels to their attribute labels", + "description": "Mapping from object labels to attribute labels used to attach metadata (for example 'car' -> ['license_plate'])." + }, + "input_tensor": { + "label": "Model Input Tensor Shape", + "description": "Tensor format expected by the model: 'nhwc' or 'nchw'." + }, + "input_pixel_format": { + "label": "Model Input Pixel Color Format", + "description": "Pixel colorspace expected by the model: 'rgb', 'bgr', or 'yuv'." + }, + "input_dtype": { + "label": "Model Input D Type", + "description": "Data type of the model input tensor (for example 'float32')." + }, + "model_type": { + "label": "Object Detection Model Type", + "description": "Detector model architecture type (ssd, yolox, yolonas) used by some detectors for optimization." + } + }, + "model_path": { + "label": "Detector specific model path", + "description": "File path to the detector model binary if required by the chosen detector." + }, + "device": { + "label": "Device Type", + "description": "The device to use for ONNX inference (e.g. 'AUTO', 'CPU', 'GPU')." + } }, "openvino": { "label": "OpenVINO", - "description": "OpenVINO detector for AMD and Intel CPUs, Intel GPUs and Intel VPU hardware." + "description": "OpenVINO detector for AMD and Intel CPUs, Intel GPUs and Intel VPU hardware.", + "type": { + "label": "Type" + }, + "model": { + "label": "Detector specific model configuration", + "description": "Detector-specific model configuration options (path, input size, etc.).", + "path": { + "label": "Custom Object detection model path", + "description": "Path to a custom detection model file (or plus:// for Frigate+ models)." + }, + "labelmap_path": { + "label": "Label map for custom object detector", + "description": "Path to a labelmap file that maps numeric classes to string labels for the detector." + }, + "width": { + "label": "Object detection model input width", + "description": "Width of the model input tensor in pixels." + }, + "height": { + "label": "Object detection model input height", + "description": "Height of the model input tensor in pixels." + }, + "labelmap": { + "label": "Labelmap customization", + "description": "Overrides or remapping entries to merge into the standard labelmap." + }, + "attributes_map": { + "label": "Map of object labels to their attribute labels", + "description": "Mapping from object labels to attribute labels used to attach metadata (for example 'car' -> ['license_plate'])." + }, + "input_tensor": { + "label": "Model Input Tensor Shape", + "description": "Tensor format expected by the model: 'nhwc' or 'nchw'." + }, + "input_pixel_format": { + "label": "Model Input Pixel Color Format", + "description": "Pixel colorspace expected by the model: 'rgb', 'bgr', or 'yuv'." + }, + "input_dtype": { + "label": "Model Input D Type", + "description": "Data type of the model input tensor (for example 'float32')." + }, + "model_type": { + "label": "Object Detection Model Type", + "description": "Detector model architecture type (ssd, yolox, yolonas) used by some detectors for optimization." + } + }, + "model_path": { + "label": "Detector specific model path", + "description": "File path to the detector model binary if required by the chosen detector." + }, + "device": { + "label": "Device Type", + "description": "The device to use for OpenVINO inference (e.g. 'CPU', 'GPU', 'NPU')." + } }, "rknn": { "label": "RKNN", - "description": "RKNN detector for Rockchip NPUs; runs compiled RKNN models on Rockchip hardware." + "description": "RKNN detector for Rockchip NPUs; runs compiled RKNN models on Rockchip hardware.", + "type": { + "label": "Type" + }, + "model": { + "label": "Detector specific model configuration", + "description": "Detector-specific model configuration options (path, input size, etc.).", + "path": { + "label": "Custom Object detection model path", + "description": "Path to a custom detection model file (or plus:// for Frigate+ models)." + }, + "labelmap_path": { + "label": "Label map for custom object detector", + "description": "Path to a labelmap file that maps numeric classes to string labels for the detector." + }, + "width": { + "label": "Object detection model input width", + "description": "Width of the model input tensor in pixels." + }, + "height": { + "label": "Object detection model input height", + "description": "Height of the model input tensor in pixels." + }, + "labelmap": { + "label": "Labelmap customization", + "description": "Overrides or remapping entries to merge into the standard labelmap." + }, + "attributes_map": { + "label": "Map of object labels to their attribute labels", + "description": "Mapping from object labels to attribute labels used to attach metadata (for example 'car' -> ['license_plate'])." + }, + "input_tensor": { + "label": "Model Input Tensor Shape", + "description": "Tensor format expected by the model: 'nhwc' or 'nchw'." + }, + "input_pixel_format": { + "label": "Model Input Pixel Color Format", + "description": "Pixel colorspace expected by the model: 'rgb', 'bgr', or 'yuv'." + }, + "input_dtype": { + "label": "Model Input D Type", + "description": "Data type of the model input tensor (for example 'float32')." + }, + "model_type": { + "label": "Object Detection Model Type", + "description": "Detector model architecture type (ssd, yolox, yolonas) used by some detectors for optimization." + } + }, + "model_path": { + "label": "Detector specific model path", + "description": "File path to the detector model binary if required by the chosen detector." + }, + "num_cores": { + "label": "Number of NPU cores to use.", + "description": "The number of NPU cores to use (0 for auto)." + } }, "synaptics": { "label": "Synaptics", - "description": "Synaptics NPU detector for models in .synap format using the Synap SDK on Synaptics hardware." + "description": "Synaptics NPU detector for models in .synap format using the Synap SDK on Synaptics hardware.", + "type": { + "label": "Type" + }, + "model": { + "label": "Detector specific model configuration", + "description": "Detector-specific model configuration options (path, input size, etc.).", + "path": { + "label": "Custom Object detection model path", + "description": "Path to a custom detection model file (or plus:// for Frigate+ models)." + }, + "labelmap_path": { + "label": "Label map for custom object detector", + "description": "Path to a labelmap file that maps numeric classes to string labels for the detector." + }, + "width": { + "label": "Object detection model input width", + "description": "Width of the model input tensor in pixels." + }, + "height": { + "label": "Object detection model input height", + "description": "Height of the model input tensor in pixels." + }, + "labelmap": { + "label": "Labelmap customization", + "description": "Overrides or remapping entries to merge into the standard labelmap." + }, + "attributes_map": { + "label": "Map of object labels to their attribute labels", + "description": "Mapping from object labels to attribute labels used to attach metadata (for example 'car' -> ['license_plate'])." + }, + "input_tensor": { + "label": "Model Input Tensor Shape", + "description": "Tensor format expected by the model: 'nhwc' or 'nchw'." + }, + "input_pixel_format": { + "label": "Model Input Pixel Color Format", + "description": "Pixel colorspace expected by the model: 'rgb', 'bgr', or 'yuv'." + }, + "input_dtype": { + "label": "Model Input D Type", + "description": "Data type of the model input tensor (for example 'float32')." + }, + "model_type": { + "label": "Object Detection Model Type", + "description": "Detector model architecture type (ssd, yolox, yolonas) used by some detectors for optimization." + } + }, + "model_path": { + "label": "Detector specific model path", + "description": "File path to the detector model binary if required by the chosen detector." + } }, "teflon_tfl": { "label": "Teflon", - "description": "Teflon delegate detector for TFLite using Mesa Teflon delegate library to accelerate inference on supported GPUs." + "description": "Teflon delegate detector for TFLite using Mesa Teflon delegate library to accelerate inference on supported GPUs.", + "type": { + "label": "Type" + }, + "model": { + "label": "Detector specific model configuration", + "description": "Detector-specific model configuration options (path, input size, etc.).", + "path": { + "label": "Custom Object detection model path", + "description": "Path to a custom detection model file (or plus:// for Frigate+ models)." + }, + "labelmap_path": { + "label": "Label map for custom object detector", + "description": "Path to a labelmap file that maps numeric classes to string labels for the detector." + }, + "width": { + "label": "Object detection model input width", + "description": "Width of the model input tensor in pixels." + }, + "height": { + "label": "Object detection model input height", + "description": "Height of the model input tensor in pixels." + }, + "labelmap": { + "label": "Labelmap customization", + "description": "Overrides or remapping entries to merge into the standard labelmap." + }, + "attributes_map": { + "label": "Map of object labels to their attribute labels", + "description": "Mapping from object labels to attribute labels used to attach metadata (for example 'car' -> ['license_plate'])." + }, + "input_tensor": { + "label": "Model Input Tensor Shape", + "description": "Tensor format expected by the model: 'nhwc' or 'nchw'." + }, + "input_pixel_format": { + "label": "Model Input Pixel Color Format", + "description": "Pixel colorspace expected by the model: 'rgb', 'bgr', or 'yuv'." + }, + "input_dtype": { + "label": "Model Input D Type", + "description": "Data type of the model input tensor (for example 'float32')." + }, + "model_type": { + "label": "Object Detection Model Type", + "description": "Detector model architecture type (ssd, yolox, yolonas) used by some detectors for optimization." + } + }, + "model_path": { + "label": "Detector specific model path", + "description": "File path to the detector model binary if required by the chosen detector." + } }, "tensorrt": { "label": "TensorRT", - "description": "TensorRT detector for Nvidia Jetson devices using serialized TensorRT engines for accelerated inference." + "description": "TensorRT detector for Nvidia Jetson devices using serialized TensorRT engines for accelerated inference.", + "type": { + "label": "Type" + }, + "model": { + "label": "Detector specific model configuration", + "description": "Detector-specific model configuration options (path, input size, etc.).", + "path": { + "label": "Custom Object detection model path", + "description": "Path to a custom detection model file (or plus:// for Frigate+ models)." + }, + "labelmap_path": { + "label": "Label map for custom object detector", + "description": "Path to a labelmap file that maps numeric classes to string labels for the detector." + }, + "width": { + "label": "Object detection model input width", + "description": "Width of the model input tensor in pixels." + }, + "height": { + "label": "Object detection model input height", + "description": "Height of the model input tensor in pixels." + }, + "labelmap": { + "label": "Labelmap customization", + "description": "Overrides or remapping entries to merge into the standard labelmap." + }, + "attributes_map": { + "label": "Map of object labels to their attribute labels", + "description": "Mapping from object labels to attribute labels used to attach metadata (for example 'car' -> ['license_plate'])." + }, + "input_tensor": { + "label": "Model Input Tensor Shape", + "description": "Tensor format expected by the model: 'nhwc' or 'nchw'." + }, + "input_pixel_format": { + "label": "Model Input Pixel Color Format", + "description": "Pixel colorspace expected by the model: 'rgb', 'bgr', or 'yuv'." + }, + "input_dtype": { + "label": "Model Input D Type", + "description": "Data type of the model input tensor (for example 'float32')." + }, + "model_type": { + "label": "Object Detection Model Type", + "description": "Detector model architecture type (ssd, yolox, yolonas) used by some detectors for optimization." + } + }, + "model_path": { + "label": "Detector specific model path", + "description": "File path to the detector model binary if required by the chosen detector." + }, + "device": { + "label": "GPU Device Index", + "description": "The GPU device index to use." + } }, "zmq": { "label": "ZMQ IPC", - "description": "ZMQ IPC detector that offloads inference to an external process via a ZeroMQ IPC endpoint." + "description": "ZMQ IPC detector that offloads inference to an external process via a ZeroMQ IPC endpoint.", + "type": { + "label": "Type" + }, + "model": { + "label": "Detector specific model configuration", + "description": "Detector-specific model configuration options (path, input size, etc.).", + "path": { + "label": "Custom Object detection model path", + "description": "Path to a custom detection model file (or plus:// for Frigate+ models)." + }, + "labelmap_path": { + "label": "Label map for custom object detector", + "description": "Path to a labelmap file that maps numeric classes to string labels for the detector." + }, + "width": { + "label": "Object detection model input width", + "description": "Width of the model input tensor in pixels." + }, + "height": { + "label": "Object detection model input height", + "description": "Height of the model input tensor in pixels." + }, + "labelmap": { + "label": "Labelmap customization", + "description": "Overrides or remapping entries to merge into the standard labelmap." + }, + "attributes_map": { + "label": "Map of object labels to their attribute labels", + "description": "Mapping from object labels to attribute labels used to attach metadata (for example 'car' -> ['license_plate'])." + }, + "input_tensor": { + "label": "Model Input Tensor Shape", + "description": "Tensor format expected by the model: 'nhwc' or 'nchw'." + }, + "input_pixel_format": { + "label": "Model Input Pixel Color Format", + "description": "Pixel colorspace expected by the model: 'rgb', 'bgr', or 'yuv'." + }, + "input_dtype": { + "label": "Model Input D Type", + "description": "Data type of the model input tensor (for example 'float32')." + }, + "model_type": { + "label": "Object Detection Model Type", + "description": "Detector model architecture type (ssd, yolox, yolonas) used by some detectors for optimization." + } + }, + "model_path": { + "label": "Detector specific model path", + "description": "File path to the detector model binary if required by the chosen detector." + }, + "endpoint": { + "label": "ZMQ IPC endpoint", + "description": "The ZMQ endpoint to connect to." + }, + "request_timeout_ms": { + "label": "ZMQ request timeout in milliseconds", + "description": "Timeout for ZMQ requests in milliseconds." + }, + "linger_ms": { + "label": "ZMQ socket linger in milliseconds", + "description": "Socket linger period in milliseconds." + } } }, "model": { diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 3025f1c44..7c6475668 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1234,6 +1234,14 @@ "detect": { "title": "Detection Settings" }, + "detectors": { + "title": "Detector Settings", + "singleType": "Only one {{type}} detector is allowed.", + "keyRequired": "Detector name is required.", + "keyDuplicate": "Detector name already exists.", + "noSchema": "No detector schemas are available.", + "none": "No detector instances configured." + }, "record": { "title": "Recording Settings" }, diff --git a/web/src/components/config-form/section-configs/detectors.ts b/web/src/components/config-form/section-configs/detectors.ts index e7301c75a..02bdecd95 100644 --- a/web/src/components/config-form/section-configs/detectors.ts +++ b/web/src/components/config-form/section-configs/detectors.ts @@ -1,17 +1,28 @@ import type { SectionConfigOverrides } from "./types"; +const detectorHiddenFields = [ + "*.model.labelmap", + "*.model.attributes_map", + "*.model", + "*.model_path", +]; + const detectors: SectionConfigOverrides = { base: { sectionDocs: "/configuration/object_detectors", restartRequired: [], fieldOrder: [], advancedFields: [], - hiddenFields: [ - "*.model.labelmap", - "*.model.attributes_map", - "*.model", - "*.model_path", - ], + hiddenFields: detectorHiddenFields, + uiSchema: { + "ui:field": "DetectorHardwareField", + "ui:options": { + multiInstanceTypes: ["cpu", "onnx", "openvino"], + typeOrder: ["onnx", "openvino", "edgetpu"], + hiddenByType: {}, + hiddenFields: detectorHiddenFields, + }, + }, }, }; diff --git a/web/src/components/config-form/theme/fields/DetectorHardwareField.tsx b/web/src/components/config-form/theme/fields/DetectorHardwareField.tsx new file mode 100644 index 000000000..2a38c70e6 --- /dev/null +++ b/web/src/components/config-form/theme/fields/DetectorHardwareField.tsx @@ -0,0 +1,869 @@ +import type { + ErrorSchema, + FieldPathList, + FieldProps, + RJSFSchema, + UiSchema, +} from "@rjsf/utils"; +import { toFieldPathId } from "@rjsf/utils"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + LuChevronDown, + LuChevronRight, + LuPlus, + LuTrash2, +} from "react-icons/lu"; +import { applySchemaDefaults } from "@/lib/config-schema"; +import { cn, isJsonObject, mergeUiSchema } from "@/lib/utils"; +import { ConfigFormContext, JsonObject } from "@/types/configForm"; +import { Button } from "@/components/ui/button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { humanizeKey } from "../utils/i18n"; + +type DetectorHardwareFieldOptions = { + multiInstanceTypes?: string[]; + hiddenByType?: Record; + hiddenFields?: string[]; + typeOrder?: string[]; +}; + +type DetectorSchemaEntry = { + type: string; + schema: RJSFSchema; +}; + +const DEFAULT_MULTI_INSTANCE_TYPES = ["cpu", "onnx", "openvino"]; +const EMPTY_HIDDEN_BY_TYPE: Record = {}; +const EMPTY_HIDDEN_FIELDS: string[] = []; +const EMPTY_TYPE_ORDER: string[] = []; + +const isSchemaObject = (schema: unknown): schema is RJSFSchema => + typeof schema === "object" && schema !== null; + +const getUnionSchemas = (schema?: RJSFSchema): RJSFSchema[] => { + if (!schema) { + return []; + } + + const schemaObj = schema as Record; + const union = schemaObj.oneOf ?? schemaObj.anyOf; + if (Array.isArray(union)) { + return union.filter(isSchemaObject) as RJSFSchema[]; + } + + return [schema]; +}; + +const getTypeValues = (schema: RJSFSchema): string[] => { + const schemaObj = schema as Record; + const properties = schemaObj.properties as + | Record + | undefined; + const typeSchema = properties?.type as Record | undefined; + const values: string[] = []; + + if (typeof typeSchema?.const === "string") { + values.push(typeSchema.const); + } + + if (Array.isArray(typeSchema?.enum)) { + typeSchema.enum.forEach((value) => { + if (typeof value === "string") { + values.push(value); + } + }); + } + + return values; +}; + +const buildHiddenUiSchema = (paths: string[]): UiSchema => { + const result: UiSchema = {}; + + paths.forEach((path) => { + if (!path) { + return; + } + + const segments = path.split(".").filter(Boolean); + if (segments.length === 0) { + return; + } + + let cursor = result; + segments.forEach((segment, index) => { + if (index === segments.length - 1) { + cursor[segment] = { + ...(cursor[segment] as UiSchema | undefined), + "ui:widget": "hidden", + } as UiSchema; + return; + } + + const existing = (cursor[segment] as UiSchema | undefined) ?? {}; + cursor[segment] = existing; + cursor = existing; + }); + }); + + return result; +}; + +const getInstanceType = (value: unknown): string | undefined => { + if (!isJsonObject(value)) { + return undefined; + } + + const typeValue = value.type; + return typeof typeValue === "string" && typeValue.length > 0 + ? typeValue + : undefined; +}; + +export function DetectorHardwareField(props: FieldProps) { + const { + schema, + uiSchema, + registry, + fieldPathId, + formData: rawFormData, + errorSchema, + disabled, + readonly, + hideError, + onBlur, + onFocus, + onChange, + } = props; + + const formContext = registry.formContext as ConfigFormContext | undefined; + const configNamespace = + formContext?.i18nNamespace ?? + (formContext?.level === "camera" ? "config/cameras" : "config/global"); + const { t: fallbackT } = useTranslation(["common", configNamespace]); + const t = formContext?.t ?? fallbackT; + const sectionPrefix = formContext?.sectionI18nPrefix ?? "detectors"; + + const options = + (uiSchema?.["ui:options"] as DetectorHardwareFieldOptions | undefined) ?? + {}; + const multiInstanceTypes = + options.multiInstanceTypes ?? DEFAULT_MULTI_INSTANCE_TYPES; + const hiddenByType = options.hiddenByType ?? EMPTY_HIDDEN_BY_TYPE; + const hiddenFields = options.hiddenFields ?? EMPTY_HIDDEN_FIELDS; + const typeOrder = options.typeOrder ?? EMPTY_TYPE_ORDER; + const multiInstanceSet = useMemo( + () => new Set(multiInstanceTypes), + [multiInstanceTypes], + ); + const globalHiddenFields = useMemo( + () => + hiddenFields + .map((path) => (path.startsWith("*.") ? path.slice(2) : path)) + .filter((path) => path.length > 0), + [hiddenFields], + ); + + const detectorConfigSchema = useMemo(() => { + const additional = (schema as RJSFSchema | undefined)?.additionalProperties; + if (isSchemaObject(additional)) { + return additional as RJSFSchema; + } + + const rootSchema = registry.rootSchema as Record; + const defs = + (rootSchema?.$defs as Record | undefined) ?? + (rootSchema?.definitions as Record | undefined); + const fallback = defs?.DetectorConfig; + + return isSchemaObject(fallback) ? (fallback as RJSFSchema) : undefined; + }, [schema, registry.rootSchema]); + + const detectorSchemas = useMemo(() => { + const entries: DetectorSchemaEntry[] = []; + getUnionSchemas(detectorConfigSchema).forEach((schema) => { + const types = getTypeValues(schema); + types.forEach((type) => { + entries.push({ type, schema }); + }); + }); + return entries; + }, [detectorConfigSchema]); + + const detectorSchemaByType = useMemo(() => { + const map = new Map(); + detectorSchemas.forEach(({ type, schema }) => { + if (!map.has(type)) { + map.set(type, schema); + } + }); + return map; + }, [detectorSchemas]); + + const availableTypes = useMemo( + () => detectorSchemas.map((entry) => entry.type), + [detectorSchemas], + ); + + const orderedTypes = useMemo(() => { + if (!typeOrder.length) { + return availableTypes; + } + + const availableSet = new Set(availableTypes); + const ordered = typeOrder.filter((type) => availableSet.has(type)); + const orderedSet = new Set(ordered); + const remaining = availableTypes.filter((type) => !orderedSet.has(type)); + return [...ordered, ...remaining]; + }, [availableTypes, typeOrder]); + + const formData = isJsonObject(rawFormData) ? rawFormData : {}; + const detectors = formData as JsonObject; + + const [addType, setAddType] = useState(orderedTypes[0]); + const [addError, setAddError] = useState(); + const [renameDrafts, setRenameDrafts] = useState>({}); + const [renameErrors, setRenameErrors] = useState>({}); + const [typeErrors, setTypeErrors] = useState>({}); + const [openKeys, setOpenKeys] = useState>( + () => new Set(Object.keys(detectors)), + ); + + useEffect(() => { + if (!orderedTypes.length) { + setAddType(undefined); + return; + } + + if (!addType || !orderedTypes.includes(addType)) { + setAddType(orderedTypes[0]); + } + }, [orderedTypes, addType]); + + useEffect(() => { + setOpenKeys((prev) => { + const next = new Set(); + Object.keys(detectors).forEach((key) => { + if (prev.has(key)) { + next.add(key); + } + }); + return next; + }); + + setRenameDrafts((prev) => { + const next: Record = {}; + Object.keys(detectors).forEach((key) => { + if (prev[key] !== undefined) { + next[key] = prev[key]; + } + }); + return next; + }); + + setRenameErrors((prev) => { + const next: Record = {}; + Object.keys(detectors).forEach((key) => { + if (prev[key] !== undefined) { + next[key] = prev[key]; + } + }); + return next; + }); + + setTypeErrors((prev) => { + const next: Record = {}; + Object.keys(detectors).forEach((key) => { + if (prev[key] !== undefined) { + next[key] = prev[key]; + } + }); + return next; + }); + }, [detectors]); + + const updateDetectors = useCallback( + (nextDetectors: JsonObject) => { + onChange(nextDetectors as unknown, [] as FieldPathList); + }, + [onChange], + ); + + const getTypeLabel = useCallback( + (type: string) => + t(`${sectionPrefix}.${type}.label`, { + ns: configNamespace, + defaultValue: humanizeKey(type), + }), + [t, sectionPrefix, configNamespace], + ); + + const getTypeDescription = useCallback( + (type: string) => + t(`${sectionPrefix}.${type}.description`, { + ns: configNamespace, + defaultValue: "", + }), + [t, sectionPrefix, configNamespace], + ); + + const isSingleInstanceType = useCallback( + (type: string) => !multiInstanceSet.has(type), + [multiInstanceSet], + ); + + const getDetectorDefaults = useCallback( + (type: string) => { + const schema = detectorSchemaByType.get(type); + if (!schema) { + return { type }; + } + + const base = { type } as Record; + const withDefaults = applySchemaDefaults(schema, base); + return { ...withDefaults, type } as Record; + }, + [detectorSchemaByType], + ); + + const resolveDuplicateType = useCallback( + (targetType: string, excludeKey?: string) => { + return Object.entries(detectors).some(([key, value]) => { + if (excludeKey && key === excludeKey) { + return false; + } + return getInstanceType(value) === targetType; + }); + }, + [detectors], + ); + + const handleAdd = useCallback(() => { + if (!addType) { + setAddError( + t("selectItem", { + ns: "common", + defaultValue: "Select {{item}}", + item: t("detectors.type.label", { + ns: configNamespace, + defaultValue: "Type", + }), + }), + ); + return; + } + + if (isSingleInstanceType(addType) && resolveDuplicateType(addType)) { + setAddError( + t("configForm.detectors.singleType", { + ns: "views/settings", + defaultValue: "Only one {{type}} detector is allowed.", + type: getTypeLabel(addType), + }), + ); + return; + } + + const baseKey = addType; + let nextKey = baseKey; + let index = 2; + while (Object.prototype.hasOwnProperty.call(detectors, nextKey)) { + nextKey = `${baseKey}${index}`; + index += 1; + } + + const nextDetectors = { + ...detectors, + [nextKey]: getDetectorDefaults(addType), + } as JsonObject; + + setAddError(undefined); + setOpenKeys((prev) => { + const next = new Set(prev); + next.add(nextKey); + return next; + }); + + updateDetectors(nextDetectors); + }, [ + addType, + t, + configNamespace, + detectors, + getDetectorDefaults, + getTypeLabel, + isSingleInstanceType, + resolveDuplicateType, + updateDetectors, + ]); + + const handleRemove = useCallback( + (key: string) => { + const { [key]: _, ...rest } = detectors; + updateDetectors(rest as JsonObject); + setOpenKeys((prev) => { + const next = new Set(prev); + next.delete(key); + return next; + }); + }, + [detectors, updateDetectors], + ); + + const commitRename = useCallback( + (key: string, nextKey: string) => { + const trimmed = nextKey.trim(); + if (!trimmed) { + setRenameErrors((prev) => ({ + ...prev, + [key]: t("configForm.detectors.keyRequired", { + ns: "views/settings", + defaultValue: "Detector name is required.", + }), + })); + return; + } + + if (trimmed !== key && detectors[trimmed] !== undefined) { + setRenameErrors((prev) => ({ + ...prev, + [key]: t("configForm.detectors.keyDuplicate", { + ns: "views/settings", + defaultValue: "Detector name already exists.", + }), + })); + return; + } + + setRenameErrors((prev) => { + const { [key]: _, ...rest } = prev; + return rest; + }); + + setRenameDrafts((prev) => { + const { [key]: _, ...rest } = prev; + return rest; + }); + + if (trimmed === key) { + return; + } + + const { [key]: value, ...rest } = detectors; + const nextDetectors = { ...rest, [trimmed]: value } as JsonObject; + + setOpenKeys((prev) => { + const next = new Set(prev); + if (next.delete(key)) { + next.add(trimmed); + } + return next; + }); + + updateDetectors(nextDetectors); + }, + [detectors, t, updateDetectors], + ); + + const handleTypeChange = useCallback( + (key: string, nextType: string) => { + const currentType = getInstanceType(detectors[key]); + if (!nextType || nextType === currentType) { + return; + } + + if ( + isSingleInstanceType(nextType) && + resolveDuplicateType(nextType, key) + ) { + setTypeErrors((prev) => ({ + ...prev, + [key]: t("configForm.detectors.singleType", { + ns: "views/settings", + defaultValue: "Only one {{type}} detector is allowed.", + type: getTypeLabel(nextType), + }), + })); + return; + } + + setTypeErrors((prev) => { + const { [key]: _, ...rest } = prev; + return rest; + }); + + const nextDetectors = { + ...detectors, + [key]: getDetectorDefaults(nextType), + } as JsonObject; + + updateDetectors(nextDetectors); + }, + [ + detectors, + getDetectorDefaults, + getTypeLabel, + isSingleInstanceType, + resolveDuplicateType, + t, + updateDetectors, + ], + ); + + const getInstanceUiSchema = useCallback( + (type: string) => { + const baseUiSchema = + (uiSchema?.additionalProperties as UiSchema | undefined) ?? {}; + const globalHidden = buildHiddenUiSchema(globalHiddenFields); + const hiddenOverrides = buildHiddenUiSchema(hiddenByType[type] ?? []); + const typeHidden = { type: { "ui:widget": "hidden" } } as UiSchema; + + const withGlobalHidden = mergeUiSchema(baseUiSchema, globalHidden); + const withTypeHidden = mergeUiSchema(withGlobalHidden, hiddenOverrides); + return mergeUiSchema(withTypeHidden, typeHidden); + }, + [globalHiddenFields, hiddenByType, uiSchema?.additionalProperties], + ); + + const renderInstanceForm = useCallback( + (key: string, value: unknown) => { + const SchemaField = registry.fields.SchemaField; + const type = getInstanceType(value); + const schema = type ? detectorSchemaByType.get(type) : undefined; + + if (!SchemaField || !schema || !type) { + return null; + } + + const instanceUiSchema = getInstanceUiSchema(type); + const instanceFieldPathId = toFieldPathId( + key, + registry.globalFormOptions, + fieldPathId.path, + ); + + const instanceErrorSchema = ( + errorSchema as Record | undefined + )?.[key]; + + const handleInstanceChange = ( + nextValue: unknown, + _path: FieldPathList, + _errors?: ErrorSchema, + _id?: string, + ) => { + const nextDetectors = { + ...detectors, + [key]: nextValue ?? {}, + } as JsonObject; + updateDetectors(nextDetectors); + }; + + return ( + + ); + }, + [ + detectorSchemaByType, + detectors, + getInstanceUiSchema, + disabled, + errorSchema, + fieldPathId, + hideError, + onBlur, + onFocus, + readonly, + registry, + updateDetectors, + ], + ); + + if (!availableTypes.length) { + return ( +

+ {t("configForm.detectors.noSchema", { + ns: "views/settings", + defaultValue: "No detector schemas are available.", + })} +

+ ); + } + + const detectorEntries = Object.entries(detectors); + const isDisabled = Boolean(disabled || readonly); + const addLabel = `${t("button.add", { + ns: "common", + defaultValue: "Add", + })} ${t("detectors.label", { + ns: configNamespace, + defaultValue: "Detector hardware", + })}`; + + return ( +
+ {detectorEntries.length === 0 ? ( +

+ {t("configForm.detectors.none", { + ns: "views/settings", + defaultValue: "No detector instances configured.", + })} +

+ ) : ( +
+ {detectorEntries.map(([key, value]) => { + const type = getInstanceType(value) ?? ""; + const typeLabel = type ? getTypeLabel(type) : key; + const typeDescription = type ? getTypeDescription(type) : ""; + const isOpen = openKeys.has(key); + const renameDraft = renameDrafts[key] ?? key; + + return ( +
+ { + setOpenKeys((prev) => { + const next = new Set(prev); + if (open) { + next.add(key); + } else { + next.delete(key); + } + return next; + }); + }} + > +
+
+ + + +
+
+ {typeLabel} + + {key} + +
+ {typeDescription && ( +
+ {typeDescription} +
+ )} +
+
+ +
+ +
+
+
+ + { + setRenameDrafts((prev) => ({ + ...prev, + [key]: event.target.value, + })); + }} + onBlur={(event) => + commitRename(key, event.target.value) + } + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + commitRename(key, renameDraft); + } + }} + /> +

+ {t("field.internalID", { + ns: "common", + defaultValue: + "The Internal ID Frigate uses in the configuration and database", + })} +

+ {renameErrors[key] && ( +

+ {renameErrors[key]} +

+ )} +
+
+ + + {typeErrors[key] && ( +

+ {typeErrors[key]} +

+ )} +
+
+ +
+ {renderInstanceForm(key, value)} +
+
+
+
+
+ ); + })} +
+ )} + +
+
+
+ {addLabel} +
+
+
+ + + {addError &&

{addError}

} +
+
+ +
+
+
+
+
+ ); +} diff --git a/web/src/components/config-form/theme/fields/index.ts b/web/src/components/config-form/theme/fields/index.ts index 1533764f6..27b34acb0 100644 --- a/web/src/components/config-form/theme/fields/index.ts +++ b/web/src/components/config-form/theme/fields/index.ts @@ -1,2 +1,3 @@ // Custom RJSF Fields export { LayoutGridField } from "./LayoutGridField"; +export { DetectorHardwareField } from "./DetectorHardwareField"; diff --git a/web/src/components/config-form/theme/frigateTheme.ts b/web/src/components/config-form/theme/frigateTheme.ts index ebe1f144d..8bd7ea72b 100644 --- a/web/src/components/config-form/theme/frigateTheme.ts +++ b/web/src/components/config-form/theme/frigateTheme.ts @@ -36,6 +36,7 @@ import { MultiSchemaFieldTemplate } from "./templates/MultiSchemaFieldTemplate"; import { WrapIfAdditionalTemplate } from "./templates/WrapIfAdditionalTemplate"; import { LayoutGridField } from "./fields/LayoutGridField"; +import { DetectorHardwareField } from "./fields/DetectorHardwareField"; export interface FrigateTheme { widgets: RegistryWidgetsType; @@ -79,5 +80,6 @@ export const frigateTheme: FrigateTheme = { }, fields: { LayoutGridField: LayoutGridField, + DetectorHardwareField: DetectorHardwareField, }, }; diff --git a/web/src/components/config-form/theme/templates/FieldTemplate.tsx b/web/src/components/config-form/theme/templates/FieldTemplate.tsx index 15bad53a3..099aca8d6 100644 --- a/web/src/components/config-form/theme/templates/FieldTemplate.tsx +++ b/web/src/components/config-form/theme/templates/FieldTemplate.tsx @@ -126,7 +126,11 @@ export function FieldTemplate(props: FieldTemplateProps) { !isAdditionalProperty && !isArrayItemInAdditionalProp; - const translationPath = buildTranslationPath(pathSegments, sectionI18nPrefix); + const translationPath = buildTranslationPath( + pathSegments, + sectionI18nPrefix, + formContext, + ); const filterObjectLabel = getFilterObjectLabel(pathSegments); const translatedFilterObjectLabel = filterObjectLabel ? getTranslatedLabel(filterObjectLabel, "object") diff --git a/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx b/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx index 82547dba2..bd06e4a29 100644 --- a/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx +++ b/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx @@ -88,7 +88,11 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) { ? getTranslatedLabel(filterObjectLabel, "object") : undefined; if (path) { - translationPath = buildTranslationPath(path); + translationPath = buildTranslationPath( + path, + sectionI18nPrefix, + formContext, + ); // Also get the last property name for fallback label generation for (let i = path.length - 1; i >= 0; i -= 1) { const segment = path[i]; diff --git a/web/src/components/config-form/theme/utils/i18n.ts b/web/src/components/config-form/theme/utils/i18n.ts index a104ccb41..5de8ba506 100644 --- a/web/src/components/config-form/theme/utils/i18n.ts +++ b/web/src/components/config-form/theme/utils/i18n.ts @@ -5,6 +5,46 @@ * for RJSF form fields. */ +import type { ConfigFormContext } from "@/types/configForm"; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +const resolveDetectorType = ( + detectorConfig: unknown, + detectorKey?: string, +): string | undefined => { + if (!detectorKey || !isRecord(detectorConfig)) { + return undefined; + } + + const entry = detectorConfig[detectorKey]; + if (!isRecord(entry)) { + return undefined; + } + + const typeValue = entry.type; + return typeof typeValue === "string" && typeValue.length > 0 + ? typeValue + : undefined; +}; + +const resolveDetectorTypeFromContext = ( + formContext: ConfigFormContext | undefined, + detectorKey?: string, +): string | undefined => { + const formData = formContext?.formData; + if (!detectorKey || !isRecord(formData)) { + return undefined; + } + + const detectorConfig = isRecord(formData.detectors) + ? formData.detectors + : formData; + + return resolveDetectorType(detectorConfig, detectorKey); +}; + /** * Build the i18n translation key path for nested fields using the field path * provided by RJSF. This avoids ambiguity with underscores in field names and @@ -12,16 +52,18 @@ * * @param segments Array of path segments (strings and/or numbers) * @param sectionI18nPrefix Optional section prefix for specialized sections + * @param formContext Optional form context for resolving detector types * @returns Normalized translation key path as a dot-separated string * * @example * buildTranslationPath(["filters", "person", "threshold"]) => "filters.threshold" - * buildTranslationPath(["detectors", "ov1", "type"]) => "detectors.type" - * buildTranslationPath(["model", "type"], "detectors") => "type" + * buildTranslationPath(["detectors", "ov1", "type"]) => "detectors.openvino.type" + * buildTranslationPath(["ov1", "type"], "detectors") => "openvino.type" */ export function buildTranslationPath( segments: Array, sectionI18nPrefix?: string, + formContext?: ConfigFormContext, ): string { // Filter out numeric indices to get string segments only const stringSegments = segments.filter( @@ -39,10 +81,24 @@ export function buildTranslationPath( return normalized.join("."); } - // Handle detectors section - skip the dynamic detector name - // Example: detectors.ov1.type -> detectors.type + // Handle detectors section - resolve the detector type when available + // Example: detectors.ov1.type -> detectors.openvino.type const detectorsIndex = stringSegments.indexOf("detectors"); if (detectorsIndex !== -1 && stringSegments.length > detectorsIndex + 2) { + const detectorKey = stringSegments[detectorsIndex + 1]; + const detectorType = resolveDetectorTypeFromContext( + formContext, + detectorKey, + ); + if (detectorType) { + const normalized = [ + ...stringSegments.slice(0, detectorsIndex + 1), + detectorType, + ...stringSegments.slice(detectorsIndex + 2), + ]; + return normalized.join("."); + } + const normalized = [ ...stringSegments.slice(0, detectorsIndex + 1), ...stringSegments.slice(detectorsIndex + 2), @@ -51,8 +107,17 @@ export function buildTranslationPath( } // Handle specialized sections like detectors where the first segment is dynamic - // Example: (sectionI18nPrefix="detectors") "ov1.type" -> "type" + // Example: (sectionI18nPrefix="detectors") "ov1.type" -> "openvino.type" if (sectionI18nPrefix === "detectors" && stringSegments.length > 1) { + const detectorKey = stringSegments[0]; + const detectorType = resolveDetectorTypeFromContext( + formContext, + detectorKey, + ); + if (detectorType) { + return [detectorType, ...stringSegments.slice(1)].join("."); + } + return stringSegments.slice(1).join("."); }