mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-10 02:29:19 +03:00
Compare commits
11 Commits
b5a10ffc1b
...
f4ee9f2d21
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4ee9f2d21 | ||
|
|
dd9497baf2 | ||
|
|
e930492ccc | ||
|
|
b2c7840c29 | ||
|
|
df27e04c0f | ||
|
|
ef07563d0a | ||
|
|
a705f254e5 | ||
|
|
e888dc347a | ||
|
|
aeab5705ea | ||
|
|
dc865d51dc | ||
|
|
a7925e9b9f |
2
.gitignore
vendored
2
.gitignore
vendored
@ -3,6 +3,8 @@ __pycache__
|
|||||||
.mypy_cache
|
.mypy_cache
|
||||||
*.swp
|
*.swp
|
||||||
debug
|
debug
|
||||||
|
.claude/*
|
||||||
|
.mcp.json
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/launch.json
|
!.vscode/launch.json
|
||||||
config/*
|
config/*
|
||||||
|
|||||||
@ -1,18 +1,18 @@
|
|||||||
# NVidia TensorRT Support (amd64 only)
|
# Nvidia ONNX Runtime GPU Support
|
||||||
--extra-index-url 'https://pypi.nvidia.com'
|
--extra-index-url 'https://pypi.nvidia.com'
|
||||||
cython==3.0.*; platform_machine == 'x86_64'
|
cython==3.0.*; platform_machine == 'x86_64'
|
||||||
nvidia_cuda_cupti_cu12==12.5.82; platform_machine == 'x86_64'
|
nvidia-cuda-cupti-cu12==12.9.79; platform_machine == 'x86_64'
|
||||||
nvidia-cublas-cu12==12.5.3.*; platform_machine == 'x86_64'
|
nvidia-cublas-cu12==12.9.1.*; platform_machine == 'x86_64'
|
||||||
nvidia-cudnn-cu12==9.3.0.*; platform_machine == 'x86_64'
|
nvidia-cudnn-cu12==9.19.0.*; platform_machine == 'x86_64'
|
||||||
nvidia-cufft-cu12==11.2.3.*; platform_machine == 'x86_64'
|
nvidia-cufft-cu12==11.4.1.*; platform_machine == 'x86_64'
|
||||||
nvidia-curand-cu12==10.3.6.*; platform_machine == 'x86_64'
|
nvidia-curand-cu12==10.3.10.*; platform_machine == 'x86_64'
|
||||||
nvidia_cuda_nvcc_cu12==12.5.82; platform_machine == 'x86_64'
|
nvidia-cuda-nvcc-cu12==12.9.86; platform_machine == 'x86_64'
|
||||||
nvidia-cuda-nvrtc-cu12==12.5.82; platform_machine == 'x86_64'
|
nvidia-cuda-nvrtc-cu12==12.9.86; platform_machine == 'x86_64'
|
||||||
nvidia_cuda_runtime_cu12==12.5.82; platform_machine == 'x86_64'
|
nvidia-cuda-runtime-cu12==12.9.79; platform_machine == 'x86_64'
|
||||||
nvidia_cusolver_cu12==11.6.3.*; platform_machine == 'x86_64'
|
nvidia-cusolver-cu12==11.7.5.*; platform_machine == 'x86_64'
|
||||||
nvidia_cusparse_cu12==12.5.1.*; platform_machine == 'x86_64'
|
nvidia-cusparse-cu12==12.5.10.*; platform_machine == 'x86_64'
|
||||||
nvidia_nccl_cu12==2.23.4; platform_machine == 'x86_64'
|
nvidia-nccl-cu12==2.29.7; platform_machine == 'x86_64'
|
||||||
nvidia_nvjitlink_cu12==12.5.82; platform_machine == 'x86_64'
|
nvidia-nvjitlink-cu12==12.9.86; platform_machine == 'x86_64'
|
||||||
onnx==1.16.*; platform_machine == 'x86_64'
|
onnx==1.16.*; platform_machine == 'x86_64'
|
||||||
onnxruntime-gpu==1.22.*; platform_machine == 'x86_64'
|
onnxruntime-gpu==1.24.*; platform_machine == 'x86_64'
|
||||||
protobuf==3.20.3; platform_machine == 'x86_64'
|
protobuf==3.20.3; platform_machine == 'x86_64'
|
||||||
|
|||||||
@ -5,6 +5,18 @@ title: Face Recognition
|
|||||||
|
|
||||||
Face recognition identifies known individuals by matching detected faces with previously learned facial data. When a known `person` is recognized, their name will be added as a `sub_label`. This information is included in the UI, filters, as well as in notifications.
|
Face recognition identifies known individuals by matching detected faces with previously learned facial data. When a known `person` is recognized, their name will be added as a `sub_label`. This information is included in the UI, filters, as well as in notifications.
|
||||||
|
|
||||||
|
## Alerts and Notifications
|
||||||
|
|
||||||
|
Face recognition does not affect whether an alert is created — alerts are based on tracked objects like `person` in your `review.alerts.labels` and your [zone requirements](./review). The `face` label is an [attribute label](/plus/#available-label-types), not a tracked object, so it cannot trigger alerts on its own.
|
||||||
|
|
||||||
|
When a face is recognized, the person's name is added as a `sub_label` on the tracked object. This name appears in the Frigate UI, in [built-in notifications](/configuration/notifications), and is published via [MQTT](/integrations/mqtt).
|
||||||
|
|
||||||
|
:::note
|
||||||
|
|
||||||
|
There is no built-in way to only create alerts for specific recognized faces. Neither `face`, `person-verified`, nor specific person names can be used in `review.alerts.labels`. To trigger automations based on face recognition results, use the [official Frigate integration's sensors](/integrations/home-assistant) and/or the [MQTT data](/integrations/mqtt) Frigate publishes.
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
## Model Requirements
|
## Model Requirements
|
||||||
|
|
||||||
### Face Detection
|
### Face Detection
|
||||||
@ -69,9 +81,9 @@ Fine-tune face recognition with these optional parameters at the global level of
|
|||||||
- Default: `0.9`.
|
- Default: `0.9`.
|
||||||
- `min_faces`: Min face recognitions for the sub label to be applied to the person object.
|
- `min_faces`: Min face recognitions for the sub label to be applied to the person object.
|
||||||
- Default: `1`
|
- Default: `1`
|
||||||
- `save_attempts`: Number of images of recognized faces to save for training.
|
- `save_attempts`: Maximum number of face attempt images to keep in the training folder. Frigate saves a face image after each recognition attempt; when the limit is reached, the oldest image is deleted. These images are displayed in the Face Library's Recent Recognitions tab.
|
||||||
- Default: `200`.
|
- Default: `200`.
|
||||||
- `blur_confidence_filter`: Enables a filter that calculates how blurry the face is and adjusts the confidence based on this.
|
- `blur_confidence_filter`: Enables a filter that measures face image blurriness (using Laplacian variance) and reduces the recognition confidence score accordingly. Blurrier images receive a larger penalty (up to -0.06 for very blurry, down to 0 for clear images), making it harder for blurry faces to meet the `recognition_threshold`.
|
||||||
- Default: `True`.
|
- Default: `True`.
|
||||||
- `device`: Target a specific device to run the face recognition model on (multi-GPU installation).
|
- `device`: Target a specific device to run the face recognition model on (multi-GPU installation).
|
||||||
- Default: `None`.
|
- Default: `None`.
|
||||||
@ -118,9 +130,19 @@ When choosing images to include in the face training set it is recommended to al
|
|||||||
|
|
||||||
The Recent Recognitions tab in the face library displays recent face recognition attempts. Detected face images are grouped according to the person they were identified as potentially matching.
|
The Recent Recognitions tab in the face library displays recent face recognition attempts. Detected face images are grouped according to the person they were identified as potentially matching.
|
||||||
|
|
||||||
Each face image is labeled with a name (or `Unknown`) along with the confidence score of the recognition attempt. While each image can be used to train the system for a specific person, not all images are suitable for training.
|
Each face image is labeled with a name (or `Unknown`) along with the confidence score of the recognition attempt. The score is color-coded based on your configured thresholds:
|
||||||
|
|
||||||
Refer to the guidelines below for best practices on selecting images for training.
|
- **Green**: score >= `recognition_threshold` (default `0.9`) — a confident match
|
||||||
|
- **Orange**: score >= `unknown_score` (default `0.8`) — a potential match
|
||||||
|
- **Red**: score < `unknown_score` — unknown or no match
|
||||||
|
|
||||||
|
When an event has multiple recognition attempts, the face cards are displayed within a group. The group shows the recognized person's name if one was identified, or "Unknown" if not. Within the group, each individual face card shows its own recognition score. Frigate uses a weighted average across all attempts for a person object to determine whether to assign a name (`sub_label`) — so a single high-scoring card does not guarantee the person will be identified (see the [FAQ](#i-see-scores-above-the-threshold-in-the-recent-recognitions-tab-but-a-sub-label-wasnt-assigned) for more details).
|
||||||
|
|
||||||
|
If the weighted average did not meet the `recognition_threshold`, there is no place in the UI to see it. The weighted average is published in the `score` field of the [`frigate/tracked_object_update`](/integrations/mqtt.md#face-recognition-update) MQTT topic after each recognition attempt, regardless of whether it meets the threshold. This is the most useful tool for debugging why a sub label was or wasn't assigned.
|
||||||
|
|
||||||
|
Clicking a face card navigates to the Tracked Object Details for the associated event. To select face cards for deletion, right-click (or Ctrl/Cmd+click) individual cards, or use Ctrl+A to select all. A delete button will appear in the toolbar once cards are selected. Removing cards from the Recent Recognitions tab only removes the saved attempt images — it does not affect recognition accuracy or training data.
|
||||||
|
|
||||||
|
While each image can be used to train the system for a specific person, not all images are suitable for training. Refer to the guidelines below for best practices on selecting images for training.
|
||||||
|
|
||||||
### Step 1 - Building a Strong Foundation
|
### Step 1 - Building a Strong Foundation
|
||||||
|
|
||||||
@ -157,6 +179,8 @@ Start with the [Usage](#usage) section and re-read the [Model Requirements](#mod
|
|||||||
- Make sure you have trained at least one face per the recommendations above.
|
- Make sure you have trained at least one face per the recommendations above.
|
||||||
- Adjust `recognition_threshold` settings per the suggestions [above](#advanced-configuration).
|
- Adjust `recognition_threshold` settings per the suggestions [above](#advanced-configuration).
|
||||||
|
|
||||||
|
3. To see recognition scores for an event, check the **Face Library** > **Recent Recognitions** tab. Face cards from the same event are grouped together, with the group header showing the combined result. Each card within the group shows its individual recognition score with [color coding](#understanding-the-recent-recognitions-tab). The **Tracked Object Details** view only shows the final weighted average score (in parentheses next to the top score) if a `sub_label` was assigned.
|
||||||
|
|
||||||
### Detection does not work well with blurry images?
|
### Detection does not work well with blurry images?
|
||||||
|
|
||||||
Accuracy is definitely a going to be improved with higher quality cameras / streams. It is important to look at the DORI (Detection Observation Recognition Identification) range of your camera, if that specification is posted. This specification explains the distance from the camera that a person can be detected, observed, recognized, and identified. The identification range is the most relevant here, and the distance listed by the camera is the furthest that face recognition will realistically work.
|
Accuracy is definitely a going to be improved with higher quality cameras / streams. It is important to look at the DORI (Detection Observation Recognition Identification) range of your camera, if that specification is posted. This specification explains the distance from the camera that a person can be detected, observed, recognized, and identified. The identification range is the most relevant here, and the distance listed by the camera is the furthest that face recognition will realistically work.
|
||||||
@ -190,7 +214,15 @@ For more guidance, refer to the section above on improving recognition accuracy.
|
|||||||
|
|
||||||
### I see scores above the threshold in the Recent Recognitions tab, but a sub label wasn't assigned?
|
### I see scores above the threshold in the Recent Recognitions tab, but a sub label wasn't assigned?
|
||||||
|
|
||||||
The Frigate considers the recognition scores across all recognition attempts for each person object. The scores are continually weighted based on the area of the face, and a sub label will only be assigned to person if a person is confidently recognized consistently. This avoids cases where a single high confidence recognition would throw off the results.
|
Frigate considers recognition scores across all attempts for each person object. The score shown in the UI is the final weighted average across all attempts, while MQTT publishes a running weighted average that updates after each attempt. The weighting favors larger faces (by pixel area, capped at 4000px) and higher-confidence detections. Attempts scored at or below `unknown_score` are excluded from the average.
|
||||||
|
|
||||||
|
A sub label will only be assigned if:
|
||||||
|
|
||||||
|
- At least `min_faces` recognition attempts have been recorded.
|
||||||
|
- A single person name has the most detections (no ties).
|
||||||
|
- The weighted average score meets the `recognition_threshold`.
|
||||||
|
|
||||||
|
This avoids cases where a single high-confidence recognition would throw off the results.
|
||||||
|
|
||||||
### Can I use other face recognition software like DoubleTake at the same time as the built in face recognition?
|
### Can I use other face recognition software like DoubleTake at the same time as the built in face recognition?
|
||||||
|
|
||||||
|
|||||||
@ -49,7 +49,7 @@ Once notifications are enabled, press the `Register for Notifications` button on
|
|||||||
|
|
||||||
## Supported Notifications
|
## Supported Notifications
|
||||||
|
|
||||||
Currently notifications are only supported for review alerts. More notifications will be supported in the future.
|
Currently notifications are only supported for review alerts. More notifications will be supported in the future. If [face recognition](/configuration/face_recognition) identifies a person during the alert, their name will be included in the notification. However, built-in notifications cannot be configured to only fire for specific recognized faces.
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
|
|
||||||
|
|||||||
@ -76,6 +76,40 @@ Switching between V1 and V2 requires reindexing your embeddings. The embeddings
|
|||||||
|
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
### GenAI Provider
|
||||||
|
|
||||||
|
Frigate can use a GenAI provider for semantic search embeddings when that provider has the `embeddings` role. Currently, only **llama.cpp** supports multimodal embeddings (both text and images).
|
||||||
|
|
||||||
|
To use llama.cpp for semantic search:
|
||||||
|
|
||||||
|
1. Configure a GenAI provider in your config with `embeddings` in its `roles`.
|
||||||
|
2. Set `semantic_search.model` to the GenAI config key (e.g. `default`).
|
||||||
|
3. Start the llama.cpp server with `--embeddings` and `--mmproj` for image support:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
genai:
|
||||||
|
default:
|
||||||
|
provider: llamacpp
|
||||||
|
base_url: http://localhost:8080
|
||||||
|
model: your-model-name
|
||||||
|
roles:
|
||||||
|
- embeddings
|
||||||
|
- vision
|
||||||
|
- tools
|
||||||
|
|
||||||
|
semantic_search:
|
||||||
|
enabled: True
|
||||||
|
model: default
|
||||||
|
```
|
||||||
|
|
||||||
|
The llama.cpp server must be started with `--embeddings` for the embeddings API, and a multi-modal embeddings model. See the [llama.cpp server documentation](https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md) for details.
|
||||||
|
|
||||||
|
:::note
|
||||||
|
|
||||||
|
Switching between Jina models and a GenAI provider requires reindexing. Embeddings from different backends are incompatible.
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
### GPU Acceleration
|
### GPU Acceleration
|
||||||
|
|
||||||
The CLIP models are downloaded in ONNX format, and the `large` model can be accelerated using GPU hardware, when available. This depends on the Docker build that is used. You can also target a specific device in a multi-GPU installation.
|
The CLIP models are downloaded in ONNX format, and the `large` model can be accelerated using GPU hardware, when available. This depends on the Docker build that is used. You can also target a specific device in a multi-GPU installation.
|
||||||
|
|||||||
@ -159,7 +159,8 @@ Published when a license plate is recognized on a car object. See the [License P
|
|||||||
"plate": "123ABC",
|
"plate": "123ABC",
|
||||||
"score": 0.95,
|
"score": 0.95,
|
||||||
"camera": "driveway_cam",
|
"camera": "driveway_cam",
|
||||||
"timestamp": 1607123958.748393
|
"timestamp": 1607123958.748393,
|
||||||
|
"plate_box": [917, 487, 1029, 529] // box coordinates of the detected license plate in the frame
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
"""Camera apis."""
|
"""Camera apis."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
@ -11,7 +12,9 @@ import httpx
|
|||||||
import requests
|
import requests
|
||||||
from fastapi import APIRouter, Depends, Query, Request, Response
|
from fastapi import APIRouter, Depends, Query, Request, Response
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
from filelock import FileLock, Timeout
|
||||||
from onvif import ONVIFCamera, ONVIFError
|
from onvif import ONVIFCamera, ONVIFError
|
||||||
|
from ruamel.yaml import YAML
|
||||||
from zeep.exceptions import Fault, TransportError
|
from zeep.exceptions import Fault, TransportError
|
||||||
from zeep.transports import AsyncTransport
|
from zeep.transports import AsyncTransport
|
||||||
|
|
||||||
@ -21,8 +24,14 @@ from frigate.api.auth import (
|
|||||||
require_role,
|
require_role,
|
||||||
)
|
)
|
||||||
from frigate.api.defs.tags import Tags
|
from frigate.api.defs.tags import Tags
|
||||||
from frigate.config.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
|
from frigate.config.camera.updater import (
|
||||||
|
CameraConfigUpdateEnum,
|
||||||
|
CameraConfigUpdateTopic,
|
||||||
|
)
|
||||||
from frigate.util.builtin import clean_camera_user_pass
|
from frigate.util.builtin import clean_camera_user_pass
|
||||||
|
from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files
|
||||||
|
from frigate.util.config import find_config_file
|
||||||
from frigate.util.image import run_ffmpeg_snapshot
|
from frigate.util.image import run_ffmpeg_snapshot
|
||||||
from frigate.util.services import ffprobe_stream
|
from frigate.util.services import ffprobe_stream
|
||||||
|
|
||||||
@ -995,3 +1004,154 @@ async def onvif_probe(
|
|||||||
await onvif_camera.close()
|
await onvif_camera.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Error closing ONVIF camera session: {e}")
|
logger.debug(f"Error closing ONVIF camera session: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/cameras/{camera_name}",
|
||||||
|
dependencies=[Depends(require_role(["admin"]))],
|
||||||
|
)
|
||||||
|
async def delete_camera(
|
||||||
|
request: Request,
|
||||||
|
camera_name: str,
|
||||||
|
delete_exports: bool = Query(default=False),
|
||||||
|
):
|
||||||
|
"""Delete a camera and all its associated data.
|
||||||
|
|
||||||
|
Removes the camera from config, stops processes, and cleans up
|
||||||
|
all database entries and media files.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
camera_name: Name of the camera to delete
|
||||||
|
delete_exports: Whether to also delete exports for this camera
|
||||||
|
"""
|
||||||
|
frigate_config: FrigateConfig = request.app.frigate_config
|
||||||
|
|
||||||
|
if camera_name not in frigate_config.cameras:
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"success": False,
|
||||||
|
"message": f"Camera {camera_name} not found",
|
||||||
|
},
|
||||||
|
status_code=404,
|
||||||
|
)
|
||||||
|
|
||||||
|
old_camera_config = frigate_config.cameras[camera_name]
|
||||||
|
config_file = find_config_file()
|
||||||
|
lock = FileLock(f"{config_file}.lock", timeout=5)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with lock:
|
||||||
|
with open(config_file, "r") as f:
|
||||||
|
old_raw_config = f.read()
|
||||||
|
|
||||||
|
try:
|
||||||
|
yaml = YAML()
|
||||||
|
yaml.indent(mapping=2, sequence=4, offset=2)
|
||||||
|
|
||||||
|
with open(config_file, "r") as f:
|
||||||
|
data = yaml.load(f)
|
||||||
|
|
||||||
|
# Remove camera from config
|
||||||
|
if "cameras" in data and camera_name in data["cameras"]:
|
||||||
|
del data["cameras"][camera_name]
|
||||||
|
|
||||||
|
# Remove camera from auth roles
|
||||||
|
auth = data.get("auth", {})
|
||||||
|
if auth and "roles" in auth:
|
||||||
|
empty_roles = []
|
||||||
|
for role_name, cameras_list in auth["roles"].items():
|
||||||
|
if (
|
||||||
|
isinstance(cameras_list, list)
|
||||||
|
and camera_name in cameras_list
|
||||||
|
):
|
||||||
|
cameras_list.remove(camera_name)
|
||||||
|
# Custom roles can't be empty; mark for removal
|
||||||
|
if not cameras_list and role_name not in (
|
||||||
|
"admin",
|
||||||
|
"viewer",
|
||||||
|
):
|
||||||
|
empty_roles.append(role_name)
|
||||||
|
for role_name in empty_roles:
|
||||||
|
del auth["roles"][role_name]
|
||||||
|
|
||||||
|
with open(config_file, "w") as f:
|
||||||
|
yaml.dump(data, f)
|
||||||
|
|
||||||
|
with open(config_file, "r") as f:
|
||||||
|
new_raw_config = f.read()
|
||||||
|
|
||||||
|
try:
|
||||||
|
config = FrigateConfig.parse(new_raw_config)
|
||||||
|
except Exception:
|
||||||
|
with open(config_file, "w") as f:
|
||||||
|
f.write(old_raw_config)
|
||||||
|
logger.exception(
|
||||||
|
"Config error after removing camera %s",
|
||||||
|
camera_name,
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"success": False,
|
||||||
|
"message": "Error parsing config after camera removal",
|
||||||
|
},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Error updating config to remove camera %s: %s", camera_name, e
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"success": False,
|
||||||
|
"message": "Error updating config",
|
||||||
|
},
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update runtime config
|
||||||
|
request.app.frigate_config = config
|
||||||
|
request.app.genai_manager.update_config(config)
|
||||||
|
|
||||||
|
# Publish removal to stop ffmpeg processes and clean up runtime state
|
||||||
|
request.app.config_publisher.publish_update(
|
||||||
|
CameraConfigUpdateTopic(CameraConfigUpdateEnum.remove, camera_name),
|
||||||
|
old_camera_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Timeout:
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"success": False,
|
||||||
|
"message": "Another process is currently updating the config",
|
||||||
|
},
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Clean up database entries
|
||||||
|
counts, export_paths = await asyncio.to_thread(
|
||||||
|
cleanup_camera_db, camera_name, delete_exports
|
||||||
|
)
|
||||||
|
|
||||||
|
# Clean up media files in background thread
|
||||||
|
await asyncio.to_thread(
|
||||||
|
cleanup_camera_files, camera_name, export_paths if delete_exports else None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Best-effort go2rtc stream removal
|
||||||
|
try:
|
||||||
|
requests.delete(
|
||||||
|
"http://127.0.0.1:1984/api/streams",
|
||||||
|
params={"src": camera_name},
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Failed to remove go2rtc stream for %s", camera_name)
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"success": True,
|
||||||
|
"message": f"Camera {camera_name} has been deleted",
|
||||||
|
"cleanup": counts,
|
||||||
|
},
|
||||||
|
status_code=200,
|
||||||
|
)
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional, Union
|
||||||
|
|
||||||
from pydantic import ConfigDict, Field
|
from pydantic import ConfigDict, Field
|
||||||
|
|
||||||
@ -173,10 +173,10 @@ class SemanticSearchConfig(FrigateBaseModel):
|
|||||||
title="Reindex on startup",
|
title="Reindex on startup",
|
||||||
description="Trigger a full reindex of historical tracked objects into the embeddings database.",
|
description="Trigger a full reindex of historical tracked objects into the embeddings database.",
|
||||||
)
|
)
|
||||||
model: Optional[SemanticSearchModelEnum] = Field(
|
model: Optional[Union[SemanticSearchModelEnum, str]] = Field(
|
||||||
default=SemanticSearchModelEnum.jinav1,
|
default=SemanticSearchModelEnum.jinav1,
|
||||||
title="Semantic search model",
|
title="Semantic search model or GenAI provider name",
|
||||||
description="The embeddings model to use for semantic search (for example 'jinav1').",
|
description="The embeddings model to use for semantic search (for example 'jinav1'), or the name of a GenAI provider with the embeddings role.",
|
||||||
)
|
)
|
||||||
model_size: str = Field(
|
model_size: str = Field(
|
||||||
default="small",
|
default="small",
|
||||||
|
|||||||
@ -61,6 +61,7 @@ from .classification import (
|
|||||||
FaceRecognitionConfig,
|
FaceRecognitionConfig,
|
||||||
LicensePlateRecognitionConfig,
|
LicensePlateRecognitionConfig,
|
||||||
SemanticSearchConfig,
|
SemanticSearchConfig,
|
||||||
|
SemanticSearchModelEnum,
|
||||||
)
|
)
|
||||||
from .database import DatabaseConfig
|
from .database import DatabaseConfig
|
||||||
from .env import EnvVars
|
from .env import EnvVars
|
||||||
@ -592,6 +593,24 @@ class FrigateConfig(FrigateBaseModel):
|
|||||||
)
|
)
|
||||||
role_to_name[role] = name
|
role_to_name[role] = name
|
||||||
|
|
||||||
|
# validate semantic_search.model when it is a GenAI provider name
|
||||||
|
if (
|
||||||
|
self.semantic_search.enabled
|
||||||
|
and isinstance(self.semantic_search.model, str)
|
||||||
|
and not isinstance(self.semantic_search.model, SemanticSearchModelEnum)
|
||||||
|
):
|
||||||
|
if self.semantic_search.model not in self.genai:
|
||||||
|
raise ValueError(
|
||||||
|
f"semantic_search.model '{self.semantic_search.model}' is not a "
|
||||||
|
"valid GenAI config key. Must match a key in genai config."
|
||||||
|
)
|
||||||
|
genai_cfg = self.genai[self.semantic_search.model]
|
||||||
|
if GenAIRoleEnum.embeddings not in genai_cfg.roles:
|
||||||
|
raise ValueError(
|
||||||
|
f"GenAI provider '{self.semantic_search.model}' must have "
|
||||||
|
"'embeddings' in its roles for semantic search."
|
||||||
|
)
|
||||||
|
|
||||||
# set default min_score for object attributes
|
# set default min_score for object attributes
|
||||||
for attribute in self.model.all_attributes:
|
for attribute in self.model.all_attributes:
|
||||||
if not self.objects.filters.get(attribute):
|
if not self.objects.filters.get(attribute):
|
||||||
|
|||||||
@ -1225,6 +1225,8 @@ class LicensePlateProcessingMixin:
|
|||||||
logger.debug(f"{camera}: License plate area below minimum threshold.")
|
logger.debug(f"{camera}: License plate area below minimum threshold.")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
plate_box = license_plate
|
||||||
|
|
||||||
license_plate_frame = rgb[
|
license_plate_frame = rgb[
|
||||||
license_plate[1] : license_plate[3],
|
license_plate[1] : license_plate[3],
|
||||||
license_plate[0] : license_plate[2],
|
license_plate[0] : license_plate[2],
|
||||||
@ -1341,6 +1343,20 @@ class LicensePlateProcessingMixin:
|
|||||||
logger.debug(f"{camera}: License plate is less than min_area")
|
logger.debug(f"{camera}: License plate is less than min_area")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Scale back to original car coordinates and then to frame
|
||||||
|
plate_box_in_car = (
|
||||||
|
license_plate[0] // 2,
|
||||||
|
license_plate[1] // 2,
|
||||||
|
license_plate[2] // 2,
|
||||||
|
license_plate[3] // 2,
|
||||||
|
)
|
||||||
|
plate_box = (
|
||||||
|
left + plate_box_in_car[0],
|
||||||
|
top + plate_box_in_car[1],
|
||||||
|
left + plate_box_in_car[2],
|
||||||
|
top + plate_box_in_car[3],
|
||||||
|
)
|
||||||
|
|
||||||
license_plate_frame = car[
|
license_plate_frame = car[
|
||||||
license_plate[1] : license_plate[3],
|
license_plate[1] : license_plate[3],
|
||||||
license_plate[0] : license_plate[2],
|
license_plate[0] : license_plate[2],
|
||||||
@ -1404,6 +1420,8 @@ class LicensePlateProcessingMixin:
|
|||||||
0, [license_plate_frame.shape[1], license_plate_frame.shape[0]] * 2
|
0, [license_plate_frame.shape[1], license_plate_frame.shape[0]] * 2
|
||||||
)
|
)
|
||||||
|
|
||||||
|
plate_box = tuple(int(x) for x in expanded_box)
|
||||||
|
|
||||||
# Crop using the expanded box
|
# Crop using the expanded box
|
||||||
license_plate_frame = license_plate_frame[
|
license_plate_frame = license_plate_frame[
|
||||||
int(expanded_box[1]) : int(expanded_box[3]),
|
int(expanded_box[1]) : int(expanded_box[3]),
|
||||||
@ -1611,6 +1629,7 @@ class LicensePlateProcessingMixin:
|
|||||||
"id": id,
|
"id": id,
|
||||||
"camera": camera,
|
"camera": camera,
|
||||||
"timestamp": start,
|
"timestamp": start,
|
||||||
|
"plate_box": plate_box,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -50,3 +50,16 @@ class PostProcessorApi(ABC):
|
|||||||
None if request was not handled, otherwise return response.
|
None if request was not handled, otherwise return response.
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def update_config(self, topic: str, payload: Any) -> None:
|
||||||
|
"""Handle a config change notification.
|
||||||
|
|
||||||
|
Called for every config update published under ``config/``.
|
||||||
|
Processors should override this to check the topic and act only
|
||||||
|
on changes relevant to them. Default is a no-op.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
topic: The config topic that changed.
|
||||||
|
payload: The updated configuration object.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|||||||
@ -12,7 +12,6 @@ from frigate.comms.embeddings_updater import EmbeddingsRequestEnum
|
|||||||
from frigate.comms.event_metadata_updater import EventMetadataPublisher
|
from frigate.comms.event_metadata_updater import EventMetadataPublisher
|
||||||
from frigate.comms.inter_process import InterProcessRequestor
|
from frigate.comms.inter_process import InterProcessRequestor
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
from frigate.config.classification import LicensePlateRecognitionConfig
|
|
||||||
from frigate.data_processing.common.license_plate.mixin import (
|
from frigate.data_processing.common.license_plate.mixin import (
|
||||||
WRITE_DEBUG_IMAGES,
|
WRITE_DEBUG_IMAGES,
|
||||||
LicensePlateProcessingMixin,
|
LicensePlateProcessingMixin,
|
||||||
@ -48,10 +47,15 @@ class LicensePlatePostProcessor(LicensePlateProcessingMixin, PostProcessorApi):
|
|||||||
self.sub_label_publisher = sub_label_publisher
|
self.sub_label_publisher = sub_label_publisher
|
||||||
super().__init__(config, metrics, model_runner)
|
super().__init__(config, metrics, model_runner)
|
||||||
|
|
||||||
def update_config(self, lpr_config: LicensePlateRecognitionConfig) -> None:
|
CONFIG_UPDATE_TOPIC = "config/lpr"
|
||||||
|
|
||||||
|
def update_config(self, topic: str, payload: Any) -> None:
|
||||||
"""Update LPR config at runtime."""
|
"""Update LPR config at runtime."""
|
||||||
self.lpr_config = lpr_config
|
if topic != self.CONFIG_UPDATE_TOPIC:
|
||||||
logger.debug("LPR config updated dynamically")
|
return
|
||||||
|
|
||||||
|
self.lpr_config = payload
|
||||||
|
logger.debug("LPR post-processor config updated dynamically")
|
||||||
|
|
||||||
def process_data(
|
def process_data(
|
||||||
self, data: dict[str, Any], data_type: PostProcessDataEnum
|
self, data: dict[str, Any], data_type: PostProcessDataEnum
|
||||||
|
|||||||
@ -61,3 +61,16 @@ class RealTimeProcessorApi(ABC):
|
|||||||
None.
|
None.
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def update_config(self, topic: str, payload: Any) -> None:
|
||||||
|
"""Handle a config change notification.
|
||||||
|
|
||||||
|
Called for every config update published under ``config/``.
|
||||||
|
Processors should override this to check the topic and act only
|
||||||
|
on changes relevant to them. Default is a no-op.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
topic: The config topic that changed.
|
||||||
|
payload: The updated configuration object.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|||||||
@ -169,6 +169,16 @@ class BirdRealTimeProcessor(RealTimeProcessorApi):
|
|||||||
)
|
)
|
||||||
self.detected_birds[obj_data["id"]] = score
|
self.detected_birds[obj_data["id"]] = score
|
||||||
|
|
||||||
|
CONFIG_UPDATE_TOPIC = "config/classification"
|
||||||
|
|
||||||
|
def update_config(self, topic: str, payload: Any) -> None:
|
||||||
|
"""Update bird classification config at runtime."""
|
||||||
|
if topic != self.CONFIG_UPDATE_TOPIC:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.config.classification = payload
|
||||||
|
logger.debug("Bird classification config updated dynamically")
|
||||||
|
|
||||||
def handle_request(self, topic, request_data):
|
def handle_request(self, topic, request_data):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@ -19,7 +19,6 @@ from frigate.comms.event_metadata_updater import (
|
|||||||
)
|
)
|
||||||
from frigate.comms.inter_process import InterProcessRequestor
|
from frigate.comms.inter_process import InterProcessRequestor
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
from frigate.config.classification import FaceRecognitionConfig
|
|
||||||
from frigate.const import FACE_DIR, MODEL_CACHE_DIR
|
from frigate.const import FACE_DIR, MODEL_CACHE_DIR
|
||||||
from frigate.data_processing.common.face.model import (
|
from frigate.data_processing.common.face.model import (
|
||||||
ArcFaceRecognizer,
|
ArcFaceRecognizer,
|
||||||
@ -96,9 +95,21 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
|||||||
|
|
||||||
self.recognizer.build()
|
self.recognizer.build()
|
||||||
|
|
||||||
def update_config(self, face_config: FaceRecognitionConfig) -> None:
|
CONFIG_UPDATE_TOPIC = "config/face_recognition"
|
||||||
|
|
||||||
|
def update_config(self, topic: str, payload: Any) -> None:
|
||||||
"""Update face recognition config at runtime."""
|
"""Update face recognition config at runtime."""
|
||||||
self.face_config = face_config
|
if topic != self.CONFIG_UPDATE_TOPIC:
|
||||||
|
return
|
||||||
|
|
||||||
|
previous_min_area = self.config.face_recognition.min_area
|
||||||
|
self.config.face_recognition = payload
|
||||||
|
self.face_config = payload
|
||||||
|
|
||||||
|
for camera_config in self.config.cameras.values():
|
||||||
|
if camera_config.face_recognition.min_area == previous_min_area:
|
||||||
|
camera_config.face_recognition.min_area = payload.min_area
|
||||||
|
|
||||||
logger.debug("Face recognition config updated dynamically")
|
logger.debug("Face recognition config updated dynamically")
|
||||||
|
|
||||||
def __download_models(self, path: str) -> None:
|
def __download_models(self, path: str) -> None:
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import numpy as np
|
|||||||
from frigate.comms.event_metadata_updater import EventMetadataPublisher
|
from frigate.comms.event_metadata_updater import EventMetadataPublisher
|
||||||
from frigate.comms.inter_process import InterProcessRequestor
|
from frigate.comms.inter_process import InterProcessRequestor
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
from frigate.config.classification import LicensePlateRecognitionConfig
|
|
||||||
from frigate.data_processing.common.license_plate.mixin import (
|
from frigate.data_processing.common.license_plate.mixin import (
|
||||||
LicensePlateProcessingMixin,
|
LicensePlateProcessingMixin,
|
||||||
)
|
)
|
||||||
@ -41,9 +40,21 @@ class LicensePlateRealTimeProcessor(LicensePlateProcessingMixin, RealTimeProcess
|
|||||||
self.camera_current_cars: dict[str, list[str]] = {}
|
self.camera_current_cars: dict[str, list[str]] = {}
|
||||||
super().__init__(config, metrics)
|
super().__init__(config, metrics)
|
||||||
|
|
||||||
def update_config(self, lpr_config: LicensePlateRecognitionConfig) -> None:
|
CONFIG_UPDATE_TOPIC = "config/lpr"
|
||||||
|
|
||||||
|
def update_config(self, topic: str, payload: Any) -> None:
|
||||||
"""Update LPR config at runtime."""
|
"""Update LPR config at runtime."""
|
||||||
self.lpr_config = lpr_config
|
if topic != self.CONFIG_UPDATE_TOPIC:
|
||||||
|
return
|
||||||
|
|
||||||
|
previous_min_area = self.config.lpr.min_area
|
||||||
|
self.config.lpr = payload
|
||||||
|
self.lpr_config = payload
|
||||||
|
|
||||||
|
for camera_config in self.config.cameras.values():
|
||||||
|
if camera_config.lpr.min_area == previous_min_area:
|
||||||
|
camera_config.lpr.min_area = payload.min_area
|
||||||
|
|
||||||
logger.debug("LPR config updated dynamically")
|
logger.debug("LPR config updated dynamically")
|
||||||
|
|
||||||
def process_frame(
|
def process_frame(
|
||||||
|
|||||||
@ -21,7 +21,8 @@ from frigate.const import (
|
|||||||
REPLAY_DIR,
|
REPLAY_DIR,
|
||||||
THUMB_DIR,
|
THUMB_DIR,
|
||||||
)
|
)
|
||||||
from frigate.models import Event, Recordings, ReviewSegment, Timeline
|
from frigate.models import Recordings
|
||||||
|
from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files
|
||||||
from frigate.util.config import find_config_file
|
from frigate.util.config import find_config_file
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -357,43 +358,13 @@ class DebugReplayManager:
|
|||||||
|
|
||||||
def _cleanup_db(self, camera_name: str) -> None:
|
def _cleanup_db(self, camera_name: str) -> None:
|
||||||
"""Defensively remove any database rows for the replay camera."""
|
"""Defensively remove any database rows for the replay camera."""
|
||||||
try:
|
cleanup_camera_db(camera_name)
|
||||||
Event.delete().where(Event.camera == camera_name).execute()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Failed to delete replay events: %s", e)
|
|
||||||
|
|
||||||
try:
|
|
||||||
Timeline.delete().where(Timeline.camera == camera_name).execute()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Failed to delete replay timeline: %s", e)
|
|
||||||
|
|
||||||
try:
|
|
||||||
Recordings.delete().where(Recordings.camera == camera_name).execute()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Failed to delete replay recordings: %s", e)
|
|
||||||
|
|
||||||
try:
|
|
||||||
ReviewSegment.delete().where(ReviewSegment.camera == camera_name).execute()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Failed to delete replay review segments: %s", e)
|
|
||||||
|
|
||||||
def _cleanup_files(self, camera_name: str) -> None:
|
def _cleanup_files(self, camera_name: str) -> None:
|
||||||
"""Remove filesystem artifacts for the replay camera."""
|
"""Remove filesystem artifacts for the replay camera."""
|
||||||
dirs_to_clean = [
|
cleanup_camera_files(camera_name)
|
||||||
os.path.join(RECORD_DIR, camera_name),
|
|
||||||
os.path.join(CLIPS_DIR, camera_name),
|
|
||||||
os.path.join(THUMB_DIR, camera_name),
|
|
||||||
]
|
|
||||||
|
|
||||||
for dir_path in dirs_to_clean:
|
# Remove replay-specific cache directory
|
||||||
if os.path.exists(dir_path):
|
|
||||||
try:
|
|
||||||
shutil.rmtree(dir_path)
|
|
||||||
logger.debug("Removed replay directory: %s", dir_path)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Failed to remove %s: %s", dir_path, e)
|
|
||||||
|
|
||||||
# Remove replay clip and any related files
|
|
||||||
if os.path.exists(REPLAY_DIR):
|
if os.path.exists(REPLAY_DIR):
|
||||||
try:
|
try:
|
||||||
shutil.rmtree(REPLAY_DIR)
|
shutil.rmtree(REPLAY_DIR)
|
||||||
|
|||||||
@ -28,6 +28,7 @@ from frigate.types import ModelStatusTypesEnum
|
|||||||
from frigate.util.builtin import EventsPerSecond, InferenceSpeed, serialize
|
from frigate.util.builtin import EventsPerSecond, InferenceSpeed, serialize
|
||||||
from frigate.util.file import get_event_thumbnail_bytes
|
from frigate.util.file import get_event_thumbnail_bytes
|
||||||
|
|
||||||
|
from .genai_embedding import GenAIEmbedding
|
||||||
from .onnx.jina_v1_embedding import JinaV1ImageEmbedding, JinaV1TextEmbedding
|
from .onnx.jina_v1_embedding import JinaV1ImageEmbedding, JinaV1TextEmbedding
|
||||||
from .onnx.jina_v2_embedding import JinaV2Embedding
|
from .onnx.jina_v2_embedding import JinaV2Embedding
|
||||||
|
|
||||||
@ -73,6 +74,7 @@ class Embeddings:
|
|||||||
config: FrigateConfig,
|
config: FrigateConfig,
|
||||||
db: SqliteVecQueueDatabase,
|
db: SqliteVecQueueDatabase,
|
||||||
metrics: DataProcessorMetrics,
|
metrics: DataProcessorMetrics,
|
||||||
|
genai_manager=None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.config = config
|
self.config = config
|
||||||
self.db = db
|
self.db = db
|
||||||
@ -104,7 +106,27 @@ class Embeddings:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.config.semantic_search.model == SemanticSearchModelEnum.jinav2:
|
model_cfg = self.config.semantic_search.model
|
||||||
|
|
||||||
|
if not isinstance(model_cfg, SemanticSearchModelEnum):
|
||||||
|
# GenAI provider
|
||||||
|
embeddings_client = (
|
||||||
|
genai_manager.embeddings_client if genai_manager else None
|
||||||
|
)
|
||||||
|
if not embeddings_client:
|
||||||
|
raise ValueError(
|
||||||
|
f"semantic_search.model is '{model_cfg}' (GenAI provider) but "
|
||||||
|
"no embeddings client is configured. Ensure the GenAI provider "
|
||||||
|
"has 'embeddings' in its roles."
|
||||||
|
)
|
||||||
|
self.embedding = GenAIEmbedding(embeddings_client)
|
||||||
|
self.text_embedding = lambda input_data: self.embedding(
|
||||||
|
input_data, embedding_type="text"
|
||||||
|
)
|
||||||
|
self.vision_embedding = lambda input_data: self.embedding(
|
||||||
|
input_data, embedding_type="vision"
|
||||||
|
)
|
||||||
|
elif model_cfg == SemanticSearchModelEnum.jinav2:
|
||||||
# Single JinaV2Embedding instance for both text and vision
|
# Single JinaV2Embedding instance for both text and vision
|
||||||
self.embedding = JinaV2Embedding(
|
self.embedding = JinaV2Embedding(
|
||||||
model_size=self.config.semantic_search.model_size,
|
model_size=self.config.semantic_search.model_size,
|
||||||
@ -118,7 +140,8 @@ class Embeddings:
|
|||||||
self.vision_embedding = lambda input_data: self.embedding(
|
self.vision_embedding = lambda input_data: self.embedding(
|
||||||
input_data, embedding_type="vision"
|
input_data, embedding_type="vision"
|
||||||
)
|
)
|
||||||
else: # Default to jinav1
|
else:
|
||||||
|
# Default to jinav1
|
||||||
self.text_embedding = JinaV1TextEmbedding(
|
self.text_embedding = JinaV1TextEmbedding(
|
||||||
model_size=config.semantic_search.model_size,
|
model_size=config.semantic_search.model_size,
|
||||||
requestor=self.requestor,
|
requestor=self.requestor,
|
||||||
@ -136,8 +159,11 @@ class Embeddings:
|
|||||||
self.metrics.text_embeddings_eps.value = self.text_eps.eps()
|
self.metrics.text_embeddings_eps.value = self.text_eps.eps()
|
||||||
|
|
||||||
def get_model_definitions(self):
|
def get_model_definitions(self):
|
||||||
# Version-specific models
|
model_cfg = self.config.semantic_search.model
|
||||||
if self.config.semantic_search.model == SemanticSearchModelEnum.jinav2:
|
if not isinstance(model_cfg, SemanticSearchModelEnum):
|
||||||
|
# GenAI provider: no ONNX models to download
|
||||||
|
models = []
|
||||||
|
elif model_cfg == SemanticSearchModelEnum.jinav2:
|
||||||
models = [
|
models = [
|
||||||
"jinaai/jina-clip-v2-tokenizer",
|
"jinaai/jina-clip-v2-tokenizer",
|
||||||
"jinaai/jina-clip-v2-model_fp16.onnx"
|
"jinaai/jina-clip-v2-model_fp16.onnx"
|
||||||
@ -312,11 +338,12 @@ class Embeddings:
|
|||||||
# Get total count of events to process
|
# Get total count of events to process
|
||||||
total_events = Event.select().count()
|
total_events = Event.select().count()
|
||||||
|
|
||||||
batch_size = (
|
if not isinstance(self.config.semantic_search.model, SemanticSearchModelEnum):
|
||||||
4
|
batch_size = 1
|
||||||
if self.config.semantic_search.model == SemanticSearchModelEnum.jinav2
|
elif self.config.semantic_search.model == SemanticSearchModelEnum.jinav2:
|
||||||
else 32
|
batch_size = 4
|
||||||
)
|
else:
|
||||||
|
batch_size = 32
|
||||||
current_page = 1
|
current_page = 1
|
||||||
|
|
||||||
totals = {
|
totals = {
|
||||||
|
|||||||
89
frigate/embeddings/genai_embedding.py
Normal file
89
frigate/embeddings/genai_embedding.py
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
"""GenAI-backed embeddings for semantic search."""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from frigate.genai import GenAIClient
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
EMBEDDING_DIM = 768
|
||||||
|
|
||||||
|
|
||||||
|
class GenAIEmbedding:
|
||||||
|
"""Embedding adapter that delegates to a GenAI provider's embed API.
|
||||||
|
|
||||||
|
Provides the same interface as JinaV2Embedding for semantic search:
|
||||||
|
__call__(inputs, embedding_type) -> list[np.ndarray]. Output embeddings are
|
||||||
|
normalized to 768 dimensions for Frigate's sqlite-vec schema.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, client: "GenAIClient") -> None:
|
||||||
|
self.client = client
|
||||||
|
|
||||||
|
def __call__(
|
||||||
|
self,
|
||||||
|
inputs: list[str] | list[bytes] | list[Image.Image],
|
||||||
|
embedding_type: str = "text",
|
||||||
|
) -> list[np.ndarray]:
|
||||||
|
"""Generate embeddings for text or images.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
inputs: List of strings (text) or bytes/PIL images (vision).
|
||||||
|
embedding_type: "text" or "vision".
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of 768-dim numpy float32 arrays.
|
||||||
|
"""
|
||||||
|
if not inputs:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if embedding_type == "text":
|
||||||
|
texts = [str(x) for x in inputs]
|
||||||
|
embeddings = self.client.embed(texts=texts)
|
||||||
|
elif embedding_type == "vision":
|
||||||
|
images: list[bytes] = []
|
||||||
|
for inp in inputs:
|
||||||
|
if isinstance(inp, bytes):
|
||||||
|
images.append(inp)
|
||||||
|
elif isinstance(inp, Image.Image):
|
||||||
|
buf = io.BytesIO()
|
||||||
|
inp.convert("RGB").save(buf, format="JPEG")
|
||||||
|
images.append(buf.getvalue())
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"GenAIEmbedding: skipping unsupported vision input type %s",
|
||||||
|
type(inp).__name__,
|
||||||
|
)
|
||||||
|
if not images:
|
||||||
|
return []
|
||||||
|
embeddings = self.client.embed(images=images)
|
||||||
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid embedding_type '{embedding_type}'. Must be 'text' or 'vision'."
|
||||||
|
)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for emb in embeddings:
|
||||||
|
arr = np.asarray(emb, dtype=np.float32)
|
||||||
|
if arr.ndim > 1:
|
||||||
|
# Some providers return token-level embeddings; pool to one vector.
|
||||||
|
arr = arr.mean(axis=0)
|
||||||
|
arr = arr.flatten()
|
||||||
|
if arr.size != EMBEDDING_DIM:
|
||||||
|
if arr.size > EMBEDDING_DIM:
|
||||||
|
arr = arr[:EMBEDDING_DIM]
|
||||||
|
else:
|
||||||
|
arr = np.pad(
|
||||||
|
arr,
|
||||||
|
(0, EMBEDDING_DIM - arr.size),
|
||||||
|
mode="constant",
|
||||||
|
constant_values=0,
|
||||||
|
)
|
||||||
|
result.append(arr)
|
||||||
|
return result
|
||||||
@ -96,16 +96,7 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
CameraConfigUpdateEnum.semantic_search,
|
CameraConfigUpdateEnum.semantic_search,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
self.classification_config_subscriber = ConfigSubscriber(
|
self.enrichment_config_subscriber = ConfigSubscriber("config/")
|
||||||
"config/classification/custom/"
|
|
||||||
)
|
|
||||||
self.bird_classification_config_subscriber = ConfigSubscriber(
|
|
||||||
"config/classification", exact=True
|
|
||||||
)
|
|
||||||
self.face_recognition_config_subscriber = ConfigSubscriber(
|
|
||||||
"config/face_recognition", exact=True
|
|
||||||
)
|
|
||||||
self.lpr_config_subscriber = ConfigSubscriber("config/lpr", exact=True)
|
|
||||||
|
|
||||||
# Configure Frigate DB
|
# Configure Frigate DB
|
||||||
db = SqliteVecQueueDatabase(
|
db = SqliteVecQueueDatabase(
|
||||||
@ -123,8 +114,10 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
models = [Event, Recordings, ReviewSegment, Trigger]
|
models = [Event, Recordings, ReviewSegment, Trigger]
|
||||||
db.bind(models)
|
db.bind(models)
|
||||||
|
|
||||||
|
self.genai_manager = GenAIClientManager(config)
|
||||||
|
|
||||||
if config.semantic_search.enabled:
|
if config.semantic_search.enabled:
|
||||||
self.embeddings = Embeddings(config, db, metrics)
|
self.embeddings = Embeddings(config, db, metrics, self.genai_manager)
|
||||||
|
|
||||||
# Check if we need to re-index events
|
# Check if we need to re-index events
|
||||||
if config.semantic_search.reindex:
|
if config.semantic_search.reindex:
|
||||||
@ -151,7 +144,6 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
self.frame_manager = SharedMemoryFrameManager()
|
self.frame_manager = SharedMemoryFrameManager()
|
||||||
|
|
||||||
self.detected_license_plates: dict[str, dict[str, Any]] = {}
|
self.detected_license_plates: dict[str, dict[str, Any]] = {}
|
||||||
self.genai_manager = GenAIClientManager(config)
|
|
||||||
|
|
||||||
# model runners to share between realtime and post processors
|
# model runners to share between realtime and post processors
|
||||||
if self.config.lpr.enabled:
|
if self.config.lpr.enabled:
|
||||||
@ -279,10 +271,7 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
"""Maintain a SQLite-vec database for semantic search."""
|
"""Maintain a SQLite-vec database for semantic search."""
|
||||||
while not self.stop_event.is_set():
|
while not self.stop_event.is_set():
|
||||||
self.config_updater.check_for_updates()
|
self.config_updater.check_for_updates()
|
||||||
self._check_classification_config_updates()
|
self._check_enrichment_config_updates()
|
||||||
self._check_bird_classification_config_updates()
|
|
||||||
self._check_face_recognition_config_updates()
|
|
||||||
self._check_lpr_config_updates()
|
|
||||||
self._process_requests()
|
self._process_requests()
|
||||||
self._process_updates()
|
self._process_updates()
|
||||||
self._process_recordings_updates()
|
self._process_recordings_updates()
|
||||||
@ -293,10 +282,7 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
self._process_event_metadata()
|
self._process_event_metadata()
|
||||||
|
|
||||||
self.config_updater.stop()
|
self.config_updater.stop()
|
||||||
self.classification_config_subscriber.stop()
|
self.enrichment_config_subscriber.stop()
|
||||||
self.bird_classification_config_subscriber.stop()
|
|
||||||
self.face_recognition_config_subscriber.stop()
|
|
||||||
self.lpr_config_subscriber.stop()
|
|
||||||
self.event_subscriber.stop()
|
self.event_subscriber.stop()
|
||||||
self.event_end_subscriber.stop()
|
self.event_end_subscriber.stop()
|
||||||
self.recordings_subscriber.stop()
|
self.recordings_subscriber.stop()
|
||||||
@ -307,11 +293,29 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
self.requestor.stop()
|
self.requestor.stop()
|
||||||
logger.info("Exiting embeddings maintenance...")
|
logger.info("Exiting embeddings maintenance...")
|
||||||
|
|
||||||
def _check_classification_config_updates(self) -> None:
|
def _check_enrichment_config_updates(self) -> None:
|
||||||
"""Check for classification config updates and add/remove processors."""
|
"""Check for enrichment config updates and delegate to processors."""
|
||||||
topic, model_config = self.classification_config_subscriber.check_for_update()
|
topic, payload = self.enrichment_config_subscriber.check_for_update()
|
||||||
|
|
||||||
if topic:
|
if topic is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Custom classification add/remove requires managing the processor list
|
||||||
|
if topic.startswith("config/classification/custom/"):
|
||||||
|
self._handle_custom_classification_update(topic, payload)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Broadcast to all processors — each decides if the topic is relevant
|
||||||
|
for processor in self.realtime_processors:
|
||||||
|
processor.update_config(topic, payload)
|
||||||
|
|
||||||
|
for processor in self.post_processors:
|
||||||
|
processor.update_config(topic, payload)
|
||||||
|
|
||||||
|
def _handle_custom_classification_update(
|
||||||
|
self, topic: str, model_config: Any
|
||||||
|
) -> None:
|
||||||
|
"""Handle add/remove of custom classification processors."""
|
||||||
model_name = topic.split("/")[-1]
|
model_name = topic.split("/")[-1]
|
||||||
|
|
||||||
if model_config is None:
|
if model_config is None:
|
||||||
@ -333,7 +337,8 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
logger.info(
|
logger.info(
|
||||||
f"Successfully removed classification processor for model: {model_name}"
|
f"Successfully removed classification processor for model: {model_name}"
|
||||||
)
|
)
|
||||||
else:
|
return
|
||||||
|
|
||||||
self.config.classification.custom[model_name] = model_config
|
self.config.classification.custom[model_name] = model_config
|
||||||
|
|
||||||
# Check if processor already exists
|
# Check if processor already exists
|
||||||
@ -369,62 +374,6 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
f"Added classification processor for model: {model_name} (type: {type(processor).__name__})"
|
f"Added classification processor for model: {model_name} (type: {type(processor).__name__})"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _check_bird_classification_config_updates(self) -> None:
|
|
||||||
"""Check for bird classification config updates."""
|
|
||||||
topic, classification_config = (
|
|
||||||
self.bird_classification_config_subscriber.check_for_update()
|
|
||||||
)
|
|
||||||
|
|
||||||
if topic is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
self.config.classification = classification_config
|
|
||||||
logger.debug("Applied dynamic bird classification config update")
|
|
||||||
|
|
||||||
def _check_face_recognition_config_updates(self) -> None:
|
|
||||||
"""Check for face recognition config updates."""
|
|
||||||
topic, face_config = self.face_recognition_config_subscriber.check_for_update()
|
|
||||||
|
|
||||||
if topic is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
previous_min_area = self.config.face_recognition.min_area
|
|
||||||
self.config.face_recognition = face_config
|
|
||||||
|
|
||||||
for camera_config in self.config.cameras.values():
|
|
||||||
if camera_config.face_recognition.min_area == previous_min_area:
|
|
||||||
camera_config.face_recognition.min_area = face_config.min_area
|
|
||||||
|
|
||||||
for processor in self.realtime_processors:
|
|
||||||
if isinstance(processor, FaceRealTimeProcessor):
|
|
||||||
processor.update_config(face_config)
|
|
||||||
|
|
||||||
logger.debug("Applied dynamic face recognition config update")
|
|
||||||
|
|
||||||
def _check_lpr_config_updates(self) -> None:
|
|
||||||
"""Check for LPR config updates."""
|
|
||||||
topic, lpr_config = self.lpr_config_subscriber.check_for_update()
|
|
||||||
|
|
||||||
if topic is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
previous_min_area = self.config.lpr.min_area
|
|
||||||
self.config.lpr = lpr_config
|
|
||||||
|
|
||||||
for camera_config in self.config.cameras.values():
|
|
||||||
if camera_config.lpr.min_area == previous_min_area:
|
|
||||||
camera_config.lpr.min_area = lpr_config.min_area
|
|
||||||
|
|
||||||
for processor in self.realtime_processors:
|
|
||||||
if isinstance(processor, LicensePlateRealTimeProcessor):
|
|
||||||
processor.update_config(lpr_config)
|
|
||||||
|
|
||||||
for processor in self.post_processors:
|
|
||||||
if isinstance(processor, LicensePlatePostProcessor):
|
|
||||||
processor.update_config(lpr_config)
|
|
||||||
|
|
||||||
logger.debug("Applied dynamic LPR config update")
|
|
||||||
|
|
||||||
def _process_requests(self) -> None:
|
def _process_requests(self) -> None:
|
||||||
"""Process embeddings requests"""
|
"""Process embeddings requests"""
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
from playhouse.shortcuts import model_to_dict
|
from playhouse.shortcuts import model_to_dict
|
||||||
|
|
||||||
from frigate.config import CameraConfig, GenAIConfig, GenAIProviderEnum
|
from frigate.config import CameraConfig, GenAIConfig, GenAIProviderEnum
|
||||||
@ -304,6 +305,25 @@ Guidelines:
|
|||||||
"""Get the context window size for this provider in tokens."""
|
"""Get the context window size for this provider in tokens."""
|
||||||
return 4096
|
return 4096
|
||||||
|
|
||||||
|
def embed(
|
||||||
|
self,
|
||||||
|
texts: list[str] | None = None,
|
||||||
|
images: list[bytes] | None = None,
|
||||||
|
) -> list[np.ndarray]:
|
||||||
|
"""Generate embeddings for text and/or images.
|
||||||
|
|
||||||
|
Returns list of numpy arrays (one per input). Expected dimension is 768
|
||||||
|
for Frigate semantic search compatibility.
|
||||||
|
|
||||||
|
Providers that support embeddings should override this method.
|
||||||
|
"""
|
||||||
|
logger.warning(
|
||||||
|
"%s does not support embeddings. "
|
||||||
|
"This method should be overridden by the provider implementation.",
|
||||||
|
self.__class__.__name__,
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
def chat_with_tools(
|
def chat_with_tools(
|
||||||
self,
|
self,
|
||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
|
|||||||
@ -1,12 +1,15 @@
|
|||||||
"""llama.cpp Provider for Frigate AI."""
|
"""llama.cpp Provider for Frigate AI."""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
import numpy as np
|
||||||
import requests
|
import requests
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
from frigate.config import GenAIProviderEnum
|
from frigate.config import GenAIProviderEnum
|
||||||
from frigate.genai import GenAIClient, register_genai_provider
|
from frigate.genai import GenAIClient, register_genai_provider
|
||||||
@ -15,6 +18,20 @@ from frigate.genai.utils import parse_tool_calls_from_message
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_jpeg(img_bytes: bytes) -> bytes | None:
|
||||||
|
"""Convert image bytes to JPEG. llama.cpp/STB does not support WebP."""
|
||||||
|
try:
|
||||||
|
img = Image.open(io.BytesIO(img_bytes))
|
||||||
|
if img.mode != "RGB":
|
||||||
|
img = img.convert("RGB")
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, format="JPEG", quality=85)
|
||||||
|
return buf.getvalue()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to convert image to JPEG: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@register_genai_provider(GenAIProviderEnum.llamacpp)
|
@register_genai_provider(GenAIProviderEnum.llamacpp)
|
||||||
class LlamaCppClient(GenAIClient):
|
class LlamaCppClient(GenAIClient):
|
||||||
"""Generative AI client for Frigate using llama.cpp server."""
|
"""Generative AI client for Frigate using llama.cpp server."""
|
||||||
@ -176,6 +193,110 @@ class LlamaCppClient(GenAIClient):
|
|||||||
)
|
)
|
||||||
return result if result else None
|
return result if result else None
|
||||||
|
|
||||||
|
def embed(
|
||||||
|
self,
|
||||||
|
texts: list[str] | None = None,
|
||||||
|
images: list[bytes] | None = None,
|
||||||
|
) -> list[np.ndarray]:
|
||||||
|
"""Generate embeddings via llama.cpp /embeddings endpoint.
|
||||||
|
|
||||||
|
Supports batch requests. Uses content format with prompt_string and
|
||||||
|
multimodal_data for images (PR #15108). Server must be started with
|
||||||
|
--embeddings and --mmproj for multimodal support.
|
||||||
|
"""
|
||||||
|
if self.provider is None:
|
||||||
|
logger.warning(
|
||||||
|
"llama.cpp provider has not been initialized. Check your llama.cpp configuration."
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
texts = texts or []
|
||||||
|
images = images or []
|
||||||
|
if not texts and not images:
|
||||||
|
return []
|
||||||
|
|
||||||
|
EMBEDDING_DIM = 768
|
||||||
|
|
||||||
|
content = []
|
||||||
|
for text in texts:
|
||||||
|
content.append({"prompt_string": text})
|
||||||
|
for img in images:
|
||||||
|
# llama.cpp uses STB which does not support WebP; convert to JPEG
|
||||||
|
jpeg_bytes = _to_jpeg(img)
|
||||||
|
to_encode = jpeg_bytes if jpeg_bytes is not None else img
|
||||||
|
encoded = base64.b64encode(to_encode).decode("utf-8")
|
||||||
|
# prompt_string must contain <__media__> placeholder for image tokenization
|
||||||
|
content.append(
|
||||||
|
{
|
||||||
|
"prompt_string": "<__media__>\n",
|
||||||
|
"multimodal_data": [encoded],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
f"{self.provider}/embeddings",
|
||||||
|
json={"model": self.genai_config.model, "content": content},
|
||||||
|
timeout=self.timeout,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
result = response.json()
|
||||||
|
|
||||||
|
items = result.get("data", result) if isinstance(result, dict) else result
|
||||||
|
if not isinstance(items, list):
|
||||||
|
logger.warning("llama.cpp embeddings returned unexpected format")
|
||||||
|
return []
|
||||||
|
|
||||||
|
embeddings = []
|
||||||
|
for item in items:
|
||||||
|
emb = item.get("embedding") if isinstance(item, dict) else None
|
||||||
|
if emb is None:
|
||||||
|
logger.warning("llama.cpp embeddings item missing embedding field")
|
||||||
|
continue
|
||||||
|
arr = np.array(emb, dtype=np.float32)
|
||||||
|
if arr.ndim > 1:
|
||||||
|
# llama.cpp can return token-level embeddings; pool per item
|
||||||
|
arr = arr.mean(axis=0)
|
||||||
|
arr = arr.flatten()
|
||||||
|
orig_dim = arr.size
|
||||||
|
if orig_dim != EMBEDDING_DIM:
|
||||||
|
if orig_dim > EMBEDDING_DIM:
|
||||||
|
arr = arr[:EMBEDDING_DIM]
|
||||||
|
logger.debug(
|
||||||
|
"Truncated llama.cpp embedding from %d to %d dimensions",
|
||||||
|
orig_dim,
|
||||||
|
EMBEDDING_DIM,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
arr = np.pad(
|
||||||
|
arr,
|
||||||
|
(0, EMBEDDING_DIM - orig_dim),
|
||||||
|
mode="constant",
|
||||||
|
constant_values=0,
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
"Padded llama.cpp embedding from %d to %d dimensions",
|
||||||
|
orig_dim,
|
||||||
|
EMBEDDING_DIM,
|
||||||
|
)
|
||||||
|
embeddings.append(arr)
|
||||||
|
return embeddings
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
logger.warning("llama.cpp embeddings request timed out")
|
||||||
|
return []
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
error_detail = str(e)
|
||||||
|
if hasattr(e, "response") and e.response is not None:
|
||||||
|
try:
|
||||||
|
error_detail = f"{str(e)} - Response: {e.response.text[:500]}"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
logger.warning("llama.cpp embeddings error: %s", error_detail)
|
||||||
|
return []
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Unexpected error in llama.cpp embeddings: %s", str(e))
|
||||||
|
return []
|
||||||
|
|
||||||
def chat_with_tools(
|
def chat_with_tools(
|
||||||
self,
|
self,
|
||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
|
|||||||
153
frigate/util/camera_cleanup.py
Normal file
153
frigate/util/camera_cleanup.py
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
"""Utilities for cleaning up camera data from database and filesystem."""
|
||||||
|
|
||||||
|
import glob
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
from frigate.const import CLIPS_DIR, RECORD_DIR, THUMB_DIR
|
||||||
|
from frigate.models import (
|
||||||
|
Event,
|
||||||
|
Export,
|
||||||
|
Previews,
|
||||||
|
Recordings,
|
||||||
|
Regions,
|
||||||
|
ReviewSegment,
|
||||||
|
Timeline,
|
||||||
|
Trigger,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_camera_db(
|
||||||
|
camera_name: str, delete_exports: bool = False
|
||||||
|
) -> tuple[dict[str, int], list[str]]:
|
||||||
|
"""Remove all database rows for a camera.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
camera_name: The camera name to clean up
|
||||||
|
delete_exports: Whether to also delete export records
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (deletion counts dict, list of export file paths to remove)
|
||||||
|
"""
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
export_paths: list[str] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
counts["events"] = Event.delete().where(Event.camera == camera_name).execute()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to delete events for camera %s: %s", camera_name, e)
|
||||||
|
|
||||||
|
try:
|
||||||
|
counts["timeline"] = (
|
||||||
|
Timeline.delete().where(Timeline.camera == camera_name).execute()
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to delete timeline for camera %s: %s", camera_name, e)
|
||||||
|
|
||||||
|
try:
|
||||||
|
counts["recordings"] = (
|
||||||
|
Recordings.delete().where(Recordings.camera == camera_name).execute()
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to delete recordings for camera %s: %s", camera_name, e)
|
||||||
|
|
||||||
|
try:
|
||||||
|
counts["review_segments"] = (
|
||||||
|
ReviewSegment.delete().where(ReviewSegment.camera == camera_name).execute()
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Failed to delete review segments for camera %s: %s", camera_name, e
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
counts["previews"] = (
|
||||||
|
Previews.delete().where(Previews.camera == camera_name).execute()
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to delete previews for camera %s: %s", camera_name, e)
|
||||||
|
|
||||||
|
try:
|
||||||
|
counts["regions"] = (
|
||||||
|
Regions.delete().where(Regions.camera == camera_name).execute()
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to delete regions for camera %s: %s", camera_name, e)
|
||||||
|
|
||||||
|
try:
|
||||||
|
counts["triggers"] = (
|
||||||
|
Trigger.delete().where(Trigger.camera == camera_name).execute()
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to delete triggers for camera %s: %s", camera_name, e)
|
||||||
|
|
||||||
|
if delete_exports:
|
||||||
|
try:
|
||||||
|
exports = Export.select(Export.video_path, Export.thumb_path).where(
|
||||||
|
Export.camera == camera_name
|
||||||
|
)
|
||||||
|
for export in exports:
|
||||||
|
export_paths.append(export.video_path)
|
||||||
|
export_paths.append(export.thumb_path)
|
||||||
|
|
||||||
|
counts["exports"] = (
|
||||||
|
Export.delete().where(Export.camera == camera_name).execute()
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to delete exports for camera %s: %s", camera_name, e)
|
||||||
|
|
||||||
|
return counts, export_paths
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_camera_files(
|
||||||
|
camera_name: str, export_paths: list[str] | None = None
|
||||||
|
) -> None:
|
||||||
|
"""Remove filesystem artifacts for a camera.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
camera_name: The camera name to clean up
|
||||||
|
export_paths: Optional list of export file paths to remove
|
||||||
|
"""
|
||||||
|
dirs_to_clean = [
|
||||||
|
os.path.join(RECORD_DIR, camera_name),
|
||||||
|
os.path.join(CLIPS_DIR, camera_name),
|
||||||
|
os.path.join(THUMB_DIR, camera_name),
|
||||||
|
os.path.join(CLIPS_DIR, "previews", camera_name),
|
||||||
|
]
|
||||||
|
|
||||||
|
for dir_path in dirs_to_clean:
|
||||||
|
if os.path.exists(dir_path):
|
||||||
|
try:
|
||||||
|
shutil.rmtree(dir_path)
|
||||||
|
logger.debug("Removed directory: %s", dir_path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to remove %s: %s", dir_path, e)
|
||||||
|
|
||||||
|
# Remove event snapshot files
|
||||||
|
for snapshot in glob.glob(os.path.join(CLIPS_DIR, f"{camera_name}-*.jpg")):
|
||||||
|
try:
|
||||||
|
os.remove(snapshot)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to remove snapshot %s: %s", snapshot, e)
|
||||||
|
|
||||||
|
# Remove review thumbnail files
|
||||||
|
for thumb in glob.glob(
|
||||||
|
os.path.join(CLIPS_DIR, "review", f"thumb-{camera_name}-*.webp")
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
os.remove(thumb)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to remove review thumbnail %s: %s", thumb, e)
|
||||||
|
|
||||||
|
# Remove export files if requested
|
||||||
|
if export_paths:
|
||||||
|
for path in export_paths:
|
||||||
|
if path and os.path.exists(path):
|
||||||
|
try:
|
||||||
|
os.remove(path)
|
||||||
|
logger.debug("Removed export file: %s", path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to remove export file %s: %s", path, e)
|
||||||
@ -422,6 +422,18 @@
|
|||||||
"cameraManagement": {
|
"cameraManagement": {
|
||||||
"title": "Manage Cameras",
|
"title": "Manage Cameras",
|
||||||
"addCamera": "Add New Camera",
|
"addCamera": "Add New Camera",
|
||||||
|
"deleteCamera": "Delete Camera",
|
||||||
|
"deleteCameraDialog": {
|
||||||
|
"title": "Delete Camera",
|
||||||
|
"description": "Deleting a camera will permanently remove all recordings, tracked objects, and configuration for that camera. Any go2rtc streams associated with this camera may still need to be manually removed.",
|
||||||
|
"selectPlaceholder": "Choose camera...",
|
||||||
|
"confirmTitle": "Are you sure?",
|
||||||
|
"confirmWarning": "Deleting <strong>{{cameraName}}</strong> cannot be undone.",
|
||||||
|
"deleteExports": "Also delete exports for this camera",
|
||||||
|
"confirmButton": "Delete Permanently",
|
||||||
|
"success": "Camera {{cameraName}} deleted successfully",
|
||||||
|
"error": "Failed to delete camera {{cameraName}}"
|
||||||
|
},
|
||||||
"editCamera": "Edit Camera:",
|
"editCamera": "Edit Camera:",
|
||||||
"selectCamera": "Select a Camera",
|
"selectCamera": "Select a Camera",
|
||||||
"backToSettings": "Back to Camera Settings",
|
"backToSettings": "Back to Camera Settings",
|
||||||
|
|||||||
@ -30,10 +30,22 @@ const detect: SectionConfigOverrides = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
global: {
|
global: {
|
||||||
restartRequired: ["width", "height", "min_initialized", "max_disappeared"],
|
restartRequired: [
|
||||||
|
"fps",
|
||||||
|
"width",
|
||||||
|
"height",
|
||||||
|
"min_initialized",
|
||||||
|
"max_disappeared",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
camera: {
|
camera: {
|
||||||
restartRequired: ["width", "height", "min_initialized", "max_disappeared"],
|
restartRequired: [
|
||||||
|
"fps",
|
||||||
|
"width",
|
||||||
|
"height",
|
||||||
|
"min_initialized",
|
||||||
|
"max_disappeared",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,12 @@ import type { SectionConfigOverrides } from "./types";
|
|||||||
const motion: SectionConfigOverrides = {
|
const motion: SectionConfigOverrides = {
|
||||||
base: {
|
base: {
|
||||||
sectionDocs: "/configuration/motion_detection",
|
sectionDocs: "/configuration/motion_detection",
|
||||||
|
fieldDocs: {
|
||||||
|
lightning_threshold:
|
||||||
|
"/configuration/motion_detection#lightning_threshold",
|
||||||
|
skip_motion_threshold:
|
||||||
|
"/configuration/motion_detection#skip_motion_on_large_scene_changes",
|
||||||
|
},
|
||||||
restartRequired: [],
|
restartRequired: [],
|
||||||
fieldOrder: [
|
fieldOrder: [
|
||||||
"enabled",
|
"enabled",
|
||||||
@ -20,6 +26,16 @@ const motion: SectionConfigOverrides = {
|
|||||||
sensitivity: ["enabled", "threshold", "contour_area"],
|
sensitivity: ["enabled", "threshold", "contour_area"],
|
||||||
algorithm: ["improve_contrast", "delta_alpha", "frame_alpha"],
|
algorithm: ["improve_contrast", "delta_alpha", "frame_alpha"],
|
||||||
},
|
},
|
||||||
|
uiSchema: {
|
||||||
|
skip_motion_threshold: {
|
||||||
|
"ui:widget": "optionalField",
|
||||||
|
"ui:options": {
|
||||||
|
innerWidget: "range",
|
||||||
|
step: 0.05,
|
||||||
|
suppressMultiSchema: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
hiddenFields: ["enabled_in_config", "mask", "raw_mask"],
|
hiddenFields: ["enabled_in_config", "mask", "raw_mask"],
|
||||||
advancedFields: [
|
advancedFields: [
|
||||||
"lightning_threshold",
|
"lightning_threshold",
|
||||||
@ -58,7 +74,7 @@ const motion: SectionConfigOverrides = {
|
|||||||
"frame_alpha",
|
"frame_alpha",
|
||||||
"frame_height",
|
"frame_height",
|
||||||
],
|
],
|
||||||
advancedFields: ["lightning_threshold"],
|
advancedFields: ["lightning_threshold", "skip_motion_threshold"],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -26,6 +26,7 @@ import { FfmpegArgsWidget } from "./widgets/FfmpegArgsWidget";
|
|||||||
import { InputRolesWidget } from "./widgets/InputRolesWidget";
|
import { InputRolesWidget } from "./widgets/InputRolesWidget";
|
||||||
import { TimezoneSelectWidget } from "./widgets/TimezoneSelectWidget";
|
import { TimezoneSelectWidget } from "./widgets/TimezoneSelectWidget";
|
||||||
import { CameraPathWidget } from "./widgets/CameraPathWidget";
|
import { CameraPathWidget } from "./widgets/CameraPathWidget";
|
||||||
|
import { OptionalFieldWidget } from "./widgets/OptionalFieldWidget";
|
||||||
|
|
||||||
import { FieldTemplate } from "./templates/FieldTemplate";
|
import { FieldTemplate } from "./templates/FieldTemplate";
|
||||||
import { ObjectFieldTemplate } from "./templates/ObjectFieldTemplate";
|
import { ObjectFieldTemplate } from "./templates/ObjectFieldTemplate";
|
||||||
@ -73,6 +74,7 @@ export const frigateTheme: FrigateTheme = {
|
|||||||
audioLabels: AudioLabelSwitchesWidget,
|
audioLabels: AudioLabelSwitchesWidget,
|
||||||
zoneNames: ZoneSwitchesWidget,
|
zoneNames: ZoneSwitchesWidget,
|
||||||
timezoneSelect: TimezoneSelectWidget,
|
timezoneSelect: TimezoneSelectWidget,
|
||||||
|
optionalField: OptionalFieldWidget,
|
||||||
},
|
},
|
||||||
templates: {
|
templates: {
|
||||||
FieldTemplate: FieldTemplate as React.ComponentType<FieldTemplateProps>,
|
FieldTemplate: FieldTemplate as React.ComponentType<FieldTemplateProps>,
|
||||||
|
|||||||
@ -0,0 +1,64 @@
|
|||||||
|
// Optional Field Widget - wraps any inner widget with an enable/disable switch
|
||||||
|
// Used for nullable fields where None means "disabled" (not the same as 0)
|
||||||
|
|
||||||
|
import type { WidgetProps } from "@rjsf/utils";
|
||||||
|
import { getWidget } from "@rjsf/utils";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { getNonNullSchema } from "../fields/nullableUtils";
|
||||||
|
|
||||||
|
export function OptionalFieldWidget(props: WidgetProps) {
|
||||||
|
const { id, value, disabled, readonly, onChange, schema, options, registry } =
|
||||||
|
props;
|
||||||
|
|
||||||
|
const innerWidgetName = (options.innerWidget as string) || undefined;
|
||||||
|
const isEnabled = value !== undefined && value !== null;
|
||||||
|
|
||||||
|
// Extract the non-null branch from anyOf [Type, null]
|
||||||
|
const innerSchema = getNonNullSchema(schema) ?? schema;
|
||||||
|
|
||||||
|
const InnerWidget = getWidget(innerSchema, innerWidgetName, registry.widgets);
|
||||||
|
|
||||||
|
const getDefaultValue = () => {
|
||||||
|
if (innerSchema.default !== undefined && innerSchema.default !== null) {
|
||||||
|
return innerSchema.default;
|
||||||
|
}
|
||||||
|
if (innerSchema.minimum !== undefined) {
|
||||||
|
return innerSchema.minimum;
|
||||||
|
}
|
||||||
|
if (innerSchema.type === "integer" || innerSchema.type === "number") {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (innerSchema.type === "string") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = (checked: boolean) => {
|
||||||
|
onChange(checked ? getDefaultValue() : undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const innerProps: WidgetProps = {
|
||||||
|
...props,
|
||||||
|
schema: innerSchema,
|
||||||
|
disabled: disabled || readonly || !isEnabled,
|
||||||
|
value: isEnabled ? value : getDefaultValue(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Switch
|
||||||
|
id={`${id}-toggle`}
|
||||||
|
checked={isEnabled}
|
||||||
|
disabled={disabled || readonly}
|
||||||
|
onCheckedChange={handleToggle}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={cn("flex-1", !isEnabled && "pointer-events-none opacity-40")}
|
||||||
|
>
|
||||||
|
<InnerWidget {...innerProps} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
215
web/src/components/overlay/dialog/DeleteCameraDialog.tsx
Normal file
215
web/src/components/overlay/dialog/DeleteCameraDialog.tsx
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Trans } from "react-i18next";
|
||||||
|
import axios from "axios";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
|
||||||
|
type DeleteCameraDialogProps = {
|
||||||
|
show: boolean;
|
||||||
|
cameras: string[];
|
||||||
|
onClose: () => void;
|
||||||
|
onDeleted: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DeleteCameraDialog({
|
||||||
|
show,
|
||||||
|
cameras,
|
||||||
|
onClose,
|
||||||
|
onDeleted,
|
||||||
|
}: DeleteCameraDialogProps) {
|
||||||
|
const { t } = useTranslation(["views/settings", "common"]);
|
||||||
|
const [phase, setPhase] = useState<"select" | "confirm">("select");
|
||||||
|
const [selectedCamera, setSelectedCamera] = useState<string>("");
|
||||||
|
const [deleteExports, setDeleteExports] = useState(false);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
if (isDeleting) return;
|
||||||
|
setPhase("select");
|
||||||
|
setSelectedCamera("");
|
||||||
|
setDeleteExports(false);
|
||||||
|
onClose();
|
||||||
|
}, [isDeleting, onClose]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(() => {
|
||||||
|
setPhase("confirm");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
setPhase("select");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleConfirmDelete = useCallback(async () => {
|
||||||
|
if (!selectedCamera || isDeleting) return;
|
||||||
|
|
||||||
|
setIsDeleting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.delete(
|
||||||
|
`cameras/${selectedCamera}?delete_exports=${deleteExports}`,
|
||||||
|
);
|
||||||
|
toast.success(
|
||||||
|
t("cameraManagement.deleteCameraDialog.success", {
|
||||||
|
cameraName: selectedCamera,
|
||||||
|
}),
|
||||||
|
{ position: "top-center" },
|
||||||
|
);
|
||||||
|
setPhase("select");
|
||||||
|
setSelectedCamera("");
|
||||||
|
setDeleteExports(false);
|
||||||
|
onDeleted();
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
axios.isAxiosError(error) &&
|
||||||
|
(error.response?.data?.message || error.response?.data?.detail)
|
||||||
|
? error.response?.data?.message || error.response?.data?.detail
|
||||||
|
: t("cameraManagement.deleteCameraDialog.error", {
|
||||||
|
cameraName: selectedCamera,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.error(errorMessage, { position: "top-center" });
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
}, [selectedCamera, deleteExports, isDeleting, onDeleted, t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={show} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
{phase === "select" ? (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{t("cameraManagement.deleteCameraDialog.title")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t("cameraManagement.deleteCameraDialog.description")}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Select value={selectedCamera} onValueChange={setSelectedCamera}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue
|
||||||
|
placeholder={t(
|
||||||
|
"cameraManagement.deleteCameraDialog.selectPlaceholder",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{cameras.map((camera) => (
|
||||||
|
<SelectItem key={camera} value={camera}>
|
||||||
|
{camera}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<DialogFooter className="flex gap-3 sm:justify-end">
|
||||||
|
<div className="flex flex-1 flex-col justify-end">
|
||||||
|
<div className="flex flex-row gap-2 pt-5">
|
||||||
|
<Button
|
||||||
|
className="flex flex-1"
|
||||||
|
aria-label={t("button.cancel", { ns: "common" })}
|
||||||
|
onClick={handleClose}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{t("button.cancel", { ns: "common" })}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
aria-label={t("button.delete", { ns: "common" })}
|
||||||
|
className="flex flex-1 text-white"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={!selectedCamera}
|
||||||
|
>
|
||||||
|
{t("button.delete", { ns: "common" })}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{t("cameraManagement.deleteCameraDialog.confirmTitle")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans
|
||||||
|
ns="views/settings"
|
||||||
|
values={{ cameraName: selectedCamera }}
|
||||||
|
components={{ strong: <span className="font-medium" /> }}
|
||||||
|
>
|
||||||
|
cameraManagement.deleteCameraDialog.confirmWarning
|
||||||
|
</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
id="delete-exports"
|
||||||
|
checked={deleteExports}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setDeleteExports(checked === true)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="delete-exports" className="cursor-pointer">
|
||||||
|
{t("cameraManagement.deleteCameraDialog.deleteExports")}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="flex gap-3 sm:justify-end">
|
||||||
|
<div className="flex flex-1 flex-col justify-end">
|
||||||
|
<div className="flex flex-row gap-2 pt-5">
|
||||||
|
<Button
|
||||||
|
className="flex flex-1"
|
||||||
|
aria-label={t("button.back", { ns: "common" })}
|
||||||
|
onClick={handleBack}
|
||||||
|
type="button"
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
{t("button.back", { ns: "common" })}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
className="flex flex-1 text-white"
|
||||||
|
onClick={handleConfirmDelete}
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
{isDeleting ? (
|
||||||
|
<div className="flex flex-row items-center gap-2">
|
||||||
|
<ActivityIndicator />
|
||||||
|
<span>
|
||||||
|
{t(
|
||||||
|
"cameraManagement.deleteCameraDialog.confirmButton",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
t("cameraManagement.deleteCameraDialog.confirmButton")
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,3 +1,6 @@
|
|||||||
|
/** ONNX embedding models that require local model downloads. GenAI providers are not in this list. */
|
||||||
|
export const JINA_EMBEDDING_MODELS = ["jinav1", "jinav2"] as const;
|
||||||
|
|
||||||
export const supportedLanguageKeys = [
|
export const supportedLanguageKeys = [
|
||||||
"en",
|
"en",
|
||||||
"es",
|
"es",
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import { toast } from "sonner";
|
|||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import useSWRInfinite from "swr/infinite";
|
import useSWRInfinite from "swr/infinite";
|
||||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||||
|
import { JINA_EMBEDDING_MODELS } from "@/lib/const";
|
||||||
|
|
||||||
const API_LIMIT = 25;
|
const API_LIMIT = 25;
|
||||||
|
|
||||||
@ -293,7 +294,12 @@ export default function Explore() {
|
|||||||
const modelVersion = config?.semantic_search.model || "jinav1";
|
const modelVersion = config?.semantic_search.model || "jinav1";
|
||||||
const modelSize = config?.semantic_search.model_size || "small";
|
const modelSize = config?.semantic_search.model_size || "small";
|
||||||
|
|
||||||
// Text model state
|
// GenAI providers have no local models to download
|
||||||
|
const isGenaiEmbeddings =
|
||||||
|
typeof modelVersion === "string" &&
|
||||||
|
!(JINA_EMBEDDING_MODELS as readonly string[]).includes(modelVersion);
|
||||||
|
|
||||||
|
// Text model state (skipped for GenAI - no local models)
|
||||||
const { payload: textModelState } = useModelState(
|
const { payload: textModelState } = useModelState(
|
||||||
modelVersion === "jinav1"
|
modelVersion === "jinav1"
|
||||||
? "jinaai/jina-clip-v1-text_model_fp16.onnx"
|
? "jinaai/jina-clip-v1-text_model_fp16.onnx"
|
||||||
@ -328,6 +334,10 @@ export default function Explore() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const allModelsLoaded = useMemo(() => {
|
const allModelsLoaded = useMemo(() => {
|
||||||
|
if (isGenaiEmbeddings) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
textModelState === "downloaded" &&
|
textModelState === "downloaded" &&
|
||||||
textTokenizerState === "downloaded" &&
|
textTokenizerState === "downloaded" &&
|
||||||
@ -335,6 +345,7 @@ export default function Explore() {
|
|||||||
visionFeatureExtractorState === "downloaded"
|
visionFeatureExtractorState === "downloaded"
|
||||||
);
|
);
|
||||||
}, [
|
}, [
|
||||||
|
isGenaiEmbeddings,
|
||||||
textModelState,
|
textModelState,
|
||||||
textTokenizerState,
|
textTokenizerState,
|
||||||
visionModelState,
|
visionModelState,
|
||||||
@ -358,10 +369,11 @@ export default function Explore() {
|
|||||||
!defaultViewLoaded ||
|
!defaultViewLoaded ||
|
||||||
(config?.semantic_search.enabled &&
|
(config?.semantic_search.enabled &&
|
||||||
(!reindexState ||
|
(!reindexState ||
|
||||||
!textModelState ||
|
(!isGenaiEmbeddings &&
|
||||||
|
(!textModelState ||
|
||||||
!textTokenizerState ||
|
!textTokenizerState ||
|
||||||
!visionModelState ||
|
!visionModelState ||
|
||||||
!visionFeatureExtractorState))
|
!visionFeatureExtractorState))))
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||||
|
|||||||
@ -1284,7 +1284,7 @@ function MotionReview({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{motionPreviewsCamera && selectedMotionPreviewCamera ? (
|
{selectedMotionPreviewCamera && (
|
||||||
<>
|
<>
|
||||||
<div className="relative mb-2 flex h-11 items-center justify-between pl-2 pr-2 md:px-3">
|
<div className="relative mb-2 flex h-11 items-center justify-between pl-2 pr-2 md:px-3">
|
||||||
<Button
|
<Button
|
||||||
@ -1465,10 +1465,15 @@ function MotionReview({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
)}
|
||||||
<div className="no-scrollbar flex min-w-0 flex-1 flex-wrap content-start gap-2 overflow-y-auto md:gap-4">
|
|
||||||
<div
|
<div
|
||||||
ref={contentRef}
|
className={cn(
|
||||||
|
"no-scrollbar flex min-w-0 flex-1 flex-wrap content-start gap-2 overflow-y-auto md:gap-4",
|
||||||
|
selectedMotionPreviewCamera && "hidden",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={selectedMotionPreviewCamera ? undefined : contentRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"no-scrollbar grid w-full grid-cols-1",
|
"no-scrollbar grid w-full grid-cols-1",
|
||||||
isMobile && "landscape:grid-cols-2",
|
isMobile && "landscape:grid-cols-2",
|
||||||
@ -1562,7 +1567,6 @@ function MotionReview({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
{!selectedMotionPreviewCamera && (
|
{!selectedMotionPreviewCamera && (
|
||||||
<div className="no-scrollbar w-[55px] overflow-y-auto md:w-[100px]">
|
<div className="no-scrollbar w-[55px] overflow-y-auto md:w-[100px]">
|
||||||
{motionData ? (
|
{motionData ? (
|
||||||
|
|||||||
@ -624,6 +624,9 @@ export default function MotionPreviewsPane({
|
|||||||
const [hasVisibilityData, setHasVisibilityData] = useState(false);
|
const [hasVisibilityData, setHasVisibilityData] = useState(false);
|
||||||
const clipObserver = useRef<IntersectionObserver | null>(null);
|
const clipObserver = useRef<IntersectionObserver | null>(null);
|
||||||
|
|
||||||
|
const [mountedClips, setMountedClips] = useState<Set<string>>(new Set());
|
||||||
|
const mountObserver = useRef<IntersectionObserver | null>(null);
|
||||||
|
|
||||||
const recordingTimeRange = useMemo(() => {
|
const recordingTimeRange = useMemo(() => {
|
||||||
if (!motionRanges.length) {
|
if (!motionRanges.length) {
|
||||||
return null;
|
return null;
|
||||||
@ -788,15 +791,56 @@ export default function MotionPreviewsPane({
|
|||||||
};
|
};
|
||||||
}, [scrollContainer]);
|
}, [scrollContainer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scrollContainer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nearClipIds = new Set<string>();
|
||||||
|
mountObserver.current = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
const clipId = (entry.target as HTMLElement).dataset.clipId;
|
||||||
|
|
||||||
|
if (!clipId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
nearClipIds.add(clipId);
|
||||||
|
} else {
|
||||||
|
nearClipIds.delete(clipId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setMountedClips(new Set(nearClipIds));
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: scrollContainer,
|
||||||
|
rootMargin: "200% 0px",
|
||||||
|
threshold: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
scrollContainer
|
||||||
|
.querySelectorAll<HTMLElement>("[data-clip-id]")
|
||||||
|
.forEach((node) => {
|
||||||
|
mountObserver.current?.observe(node);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mountObserver.current?.disconnect();
|
||||||
|
};
|
||||||
|
}, [scrollContainer]);
|
||||||
|
|
||||||
const clipRef = useCallback((node: HTMLElement | null) => {
|
const clipRef = useCallback((node: HTMLElement | null) => {
|
||||||
if (!clipObserver.current) {
|
if (!node) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (node) {
|
clipObserver.current?.observe(node);
|
||||||
clipObserver.current.observe(node);
|
mountObserver.current?.observe(node);
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// no op
|
// no op
|
||||||
}
|
}
|
||||||
@ -864,12 +908,17 @@ export default function MotionPreviewsPane({
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-2 pb-2 sm:grid-cols-2 md:gap-4 xl:grid-cols-4">
|
<div className="grid grid-cols-1 gap-2 pb-2 sm:grid-cols-2 md:gap-4 xl:grid-cols-4">
|
||||||
{clipData.map(
|
{clipData.map(
|
||||||
({ range, preview, fallbackFrameTimes, motionHeatmap }, idx) => (
|
({ range, preview, fallbackFrameTimes, motionHeatmap }, idx) => {
|
||||||
|
const clipId = `${camera.name}-${range.start_time}-${range.end_time}-${idx}`;
|
||||||
|
const isMounted = mountedClips.has(clipId);
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${camera.name}-${range.start_time}-${range.end_time}-${preview?.src ?? "none"}-${idx}`}
|
key={`${camera.name}-${range.start_time}-${range.end_time}-${preview?.src ?? "none"}-${idx}`}
|
||||||
data-clip-id={`${camera.name}-${range.start_time}-${range.end_time}-${idx}`}
|
data-clip-id={clipId}
|
||||||
ref={clipRef}
|
ref={clipRef}
|
||||||
>
|
>
|
||||||
|
{isMounted ? (
|
||||||
<MotionPreviewClip
|
<MotionPreviewClip
|
||||||
cameraName={camera.name}
|
cameraName={camera.name}
|
||||||
range={range}
|
range={range}
|
||||||
@ -880,15 +929,17 @@ export default function MotionPreviewsPane({
|
|||||||
nonMotionAlpha={nonMotionAlpha}
|
nonMotionAlpha={nonMotionAlpha}
|
||||||
isVisible={
|
isVisible={
|
||||||
windowVisible &&
|
windowVisible &&
|
||||||
(visibleClips.includes(
|
(visibleClips.includes(clipId) ||
|
||||||
`${camera.name}-${range.start_time}-${range.end_time}-${idx}`,
|
|
||||||
) ||
|
|
||||||
(!hasVisibilityData && idx < 8))
|
(!hasVisibilityData && idx < 8))
|
||||||
}
|
}
|
||||||
onSeek={onSeek}
|
onSeek={onSeek}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="aspect-video rounded-lg bg-black md:rounded-2xl" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
);
|
||||||
|
},
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -13,7 +13,8 @@ import { FrigateConfig } from "@/types/frigateConfig";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import CameraEditForm from "@/components/settings/CameraEditForm";
|
import CameraEditForm from "@/components/settings/CameraEditForm";
|
||||||
import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
|
import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
|
||||||
import { LuPlus } from "react-icons/lu";
|
import DeleteCameraDialog from "@/components/overlay/dialog/DeleteCameraDialog";
|
||||||
|
import { LuPlus, LuTrash2 } from "react-icons/lu";
|
||||||
import { IoMdArrowRoundBack } from "react-icons/io";
|
import { IoMdArrowRoundBack } from "react-icons/io";
|
||||||
import { isDesktop } from "react-device-detect";
|
import { isDesktop } from "react-device-detect";
|
||||||
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||||
@ -45,6 +46,7 @@ export default function CameraManagementView({
|
|||||||
undefined,
|
undefined,
|
||||||
); // Track camera being edited
|
); // Track camera being edited
|
||||||
const [showWizard, setShowWizard] = useState(false);
|
const [showWizard, setShowWizard] = useState(false);
|
||||||
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
|
|
||||||
// State for restart dialog when enabling a disabled camera
|
// State for restart dialog when enabling a disabled camera
|
||||||
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
|
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
|
||||||
@ -98,6 +100,7 @@ export default function CameraManagementView({
|
|||||||
</Heading>
|
</Heading>
|
||||||
|
|
||||||
<div className="w-full max-w-5xl space-y-6">
|
<div className="w-full max-w-5xl space-y-6">
|
||||||
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="select"
|
variant="select"
|
||||||
onClick={() => setShowWizard(true)}
|
onClick={() => setShowWizard(true)}
|
||||||
@ -106,6 +109,17 @@ export default function CameraManagementView({
|
|||||||
<LuPlus className="h-4 w-4" />
|
<LuPlus className="h-4 w-4" />
|
||||||
{t("cameraManagement.addCamera")}
|
{t("cameraManagement.addCamera")}
|
||||||
</Button>
|
</Button>
|
||||||
|
{enabledCameras.length + disabledCameras.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setShowDeleteDialog(true)}
|
||||||
|
className="mb-2 flex max-w-48 items-center gap-2 text-white"
|
||||||
|
>
|
||||||
|
<LuTrash2 className="h-4 w-4" />
|
||||||
|
{t("cameraManagement.deleteCamera")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{enabledCameras.length > 0 && (
|
{enabledCameras.length > 0 && (
|
||||||
<SettingsGroupCard
|
<SettingsGroupCard
|
||||||
@ -221,6 +235,15 @@ export default function CameraManagementView({
|
|||||||
open={showWizard}
|
open={showWizard}
|
||||||
onClose={() => setShowWizard(false)}
|
onClose={() => setShowWizard(false)}
|
||||||
/>
|
/>
|
||||||
|
<DeleteCameraDialog
|
||||||
|
show={showDeleteDialog}
|
||||||
|
cameras={[...enabledCameras, ...disabledCameras]}
|
||||||
|
onClose={() => setShowDeleteDialog(false)}
|
||||||
|
onDeleted={() => {
|
||||||
|
setShowDeleteDialog(false);
|
||||||
|
updateConfig();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<RestartDialog
|
<RestartDialog
|
||||||
isOpen={restartDialogOpen}
|
isOpen={restartDialogOpen}
|
||||||
onClose={() => setRestartDialogOpen(false)}
|
onClose={() => setRestartDialogOpen(false)}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user