mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-03 06:50:58 +00:00
Compare commits
11 Commits
6fa35c3bf7
...
b3f423e197
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3f423e197 | ||
|
|
99a363c047 | ||
|
|
a374a60756 | ||
|
|
d41ee4ff88 | ||
|
|
c99ada8f6a | ||
|
|
3620ef27db | ||
|
|
01452e4c51 | ||
|
|
5cf2ae0121 | ||
|
|
17d2bc240a | ||
|
|
6fd7f862f5 | ||
|
|
5d038b5c75 |
@ -12,7 +12,7 @@
|
||||
|
||||
A complete and local NVR designed for [Home Assistant](https://www.home-assistant.io) with AI object detection. Uses OpenCV and Tensorflow to perform realtime object detection locally for IP cameras.
|
||||
|
||||
Use of a GPU or AI accelerator such as a [Google Coral](https://coral.ai/products/) or [Hailo](https://hailo.ai/) is highly recommended. AI accelerators will outperform even the best CPUs with very little overhead.
|
||||
Use of a GPU, Integrated GPU, or AI accelerator such as a [Hailo](https://hailo.ai/) is highly recommended. Dedicated hardware will outperform even the best CPUs with very little overhead.
|
||||
|
||||
- Tight integration with Home Assistant via a [custom component](https://github.com/blakeblackshear/frigate-hass-integration)
|
||||
- Designed to minimize resource use and maximize performance by only looking for objects when and where it is necessary
|
||||
|
||||
@ -68,36 +68,6 @@ The mere presence of an unidentified person in private areas during late night h
|
||||
|
||||
</details>
|
||||
|
||||
### Camera Spatial Context
|
||||
|
||||
In addition to defining activity patterns, you can provide spatial context for specific cameras to help the LLM generate more accurate and descriptive titles and scene descriptions. The `camera_context` field allows you to describe physical features and locations that are outside the camera's field of view but are relevant for understanding the scene.
|
||||
|
||||
**Important Guidelines:**
|
||||
|
||||
- This context is used **only for descriptive purposes** to help the LLM write better titles and scene descriptions
|
||||
- It should describe **physical features and spatial relationships** (e.g., "front door is to the right", "driveway on the left")
|
||||
- It should **NOT** include subjective assessments or threat evaluations (e.g., "high-crime area")
|
||||
- Threat level determination remains based solely on observable actions defined in the activity patterns
|
||||
|
||||
Example configuration:
|
||||
|
||||
```yaml
|
||||
cameras:
|
||||
front_door:
|
||||
review:
|
||||
genai:
|
||||
enabled: true
|
||||
camera_context: |
|
||||
- Front door entrance is to the right of the frame
|
||||
- Driveway and street are to the left
|
||||
- Steps in the center lead from the sidewalk to the front door
|
||||
- Garage is located beyond the left edge of the frame
|
||||
```
|
||||
|
||||
This helps the LLM generate more natural descriptions like "Person approaching front door" instead of "Person walking toward right side of frame".
|
||||
|
||||
The `camera_context` can be defined globally under `genai.review` and overridden per camera for specific spatial details.
|
||||
|
||||
### Image Source
|
||||
|
||||
By default, review summaries use preview images (cached preview frames) which have a lower resolution but use fewer tokens per image. For better image quality and more detailed analysis, you can configure Frigate to extract frames directly from recordings at a higher resolution:
|
||||
|
||||
@ -5,7 +5,7 @@ title: Enrichments
|
||||
|
||||
# Enrichments
|
||||
|
||||
Some of Frigate's enrichments can use a discrete GPU / NPU for accelerated processing.
|
||||
Some of Frigate's enrichments can use a discrete GPU or integrated GPU for accelerated processing.
|
||||
|
||||
## Requirements
|
||||
|
||||
@ -18,8 +18,10 @@ Object detection and enrichments (like Semantic Search, Face Recognition, and Li
|
||||
- **Intel**
|
||||
|
||||
- OpenVINO will automatically be detected and used for enrichments in the default Frigate image.
|
||||
- **Note:** Intel NPUs have limited model support for enrichments. GPU is recommended for enrichments when available.
|
||||
|
||||
- **Nvidia**
|
||||
|
||||
- Nvidia GPUs will automatically be detected and used for enrichments in the `-tensorrt` Frigate image.
|
||||
- Jetson devices will automatically be detected and used for enrichments in the `-tensorrt-jp6` Frigate image.
|
||||
|
||||
|
||||
@ -15,7 +15,7 @@ The jsmpeg live view will use more browser and client GPU resources. Using go2rt
|
||||
| ------ | ------------------------------------- | ---------- | ---------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| jsmpeg | same as `detect -> fps`, capped at 10 | 720p | no | no | Resolution is configurable, but go2rtc is recommended if you want higher resolutions and better frame rates. jsmpeg is Frigate's default without go2rtc configured. |
|
||||
| mse | native | native | yes (depends on audio codec) | yes | iPhone requires iOS 17.1+, Firefox is h.264 only. This is Frigate's default when go2rtc is configured. |
|
||||
| webrtc | native | native | yes (depends on audio codec) | yes | Requires extra configuration, doesn't support h.265. Frigate attempts to use WebRTC when MSE fails or when using a camera's two-way talk feature. |
|
||||
| webrtc | native | native | yes (depends on audio codec) | yes | Requires extra configuration. Frigate attempts to use WebRTC when MSE fails or when using a camera's two-way talk feature. |
|
||||
|
||||
### Camera Settings Recommendations
|
||||
|
||||
@ -127,7 +127,8 @@ WebRTC works by creating a TCP or UDP connection on port `8555`. However, it req
|
||||
```
|
||||
|
||||
- For access through Tailscale, the Frigate system's Tailscale IP must be added as a WebRTC candidate. Tailscale IPs all start with `100.`, and are reserved within the `100.64.0.0/10` CIDR block.
|
||||
- Note that WebRTC does not support H.265.
|
||||
|
||||
- Note that some browsers may not support H.265 (HEVC). You can check your browser's current version for H.265 compatibility [here](https://github.com/AlexxIT/go2rtc?tab=readme-ov-file#codecs-madness).
|
||||
|
||||
:::tip
|
||||
|
||||
|
||||
@ -261,6 +261,8 @@ OpenVINO is supported on 6th Gen Intel platforms (Skylake) and newer. It will al
|
||||
|
||||
:::tip
|
||||
|
||||
**NPU + GPU Systems:** If you have both NPU and GPU available (Intel Core Ultra processors), use NPU for object detection and GPU for enrichments (semantic search, face recognition, etc.) for best performance and compatibility.
|
||||
|
||||
When using many cameras one detector may not be enough to keep up. Multiple detectors can be defined assuming GPU resources are available. An example configuration would be:
|
||||
|
||||
```yaml
|
||||
@ -283,7 +285,7 @@ detectors:
|
||||
| [RF-DETR](#rf-detr) | ✅ | ✅ | Requires XE iGPU or Arc |
|
||||
| [YOLO-NAS](#yolo-nas) | ✅ | ✅ | |
|
||||
| [MobileNet v2](#ssdlite-mobilenet-v2) | ✅ | ✅ | Fast and lightweight model, less accurate than larger models |
|
||||
| [YOLOX](#yolox) | ✅ | ? | |
|
||||
| [YOLOX](#yolox) | ✅ | ? | |
|
||||
| [D-FINE](#d-fine) | ❌ | ❌ | |
|
||||
|
||||
#### SSDLite MobileNet v2
|
||||
|
||||
@ -11,7 +11,7 @@ This adds features including the ability to deep link directly into the app.
|
||||
|
||||
In order to install Frigate as a PWA, the following requirements must be met:
|
||||
|
||||
- Frigate must be accessed via a secure context (localhost, secure https, etc.)
|
||||
- Frigate must be accessed via a secure context (localhost, secure https, VPN, etc.)
|
||||
- On Android, Firefox, Chrome, Edge, Opera, and Samsung Internet Browser all support installing PWAs.
|
||||
- On iOS 16.4 and later, PWAs can be installed from the Share menu in Safari, Chrome, Edge, Firefox, and Orion.
|
||||
|
||||
@ -22,3 +22,7 @@ Installation varies slightly based on the device that is being used:
|
||||
- Desktop: Use the install button typically found in right edge of the address bar
|
||||
- Android: Use the `Install as App` button in the more options menu for Chrome, and the `Add app to Home screen` button for Firefox
|
||||
- iOS: Use the `Add to Homescreen` button in the share menu
|
||||
|
||||
## Usage
|
||||
|
||||
Once setup, the Frigate app can be used wherever it has access to Frigate. This means it can be setup as local-only, VPN-only, or fully accessible depending on your needs.
|
||||
|
||||
@ -78,7 +78,7 @@ Switching between V1 and V2 requires reindexing your embeddings. The embeddings
|
||||
|
||||
### GPU Acceleration
|
||||
|
||||
The CLIP models are downloaded in ONNX format, and the `large` model can be accelerated using GPU / NPU 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.
|
||||
|
||||
```yaml
|
||||
semantic_search:
|
||||
@ -90,7 +90,7 @@ semantic_search:
|
||||
|
||||
:::info
|
||||
|
||||
If the correct build is used for your GPU / NPU and the `large` model is configured, then the GPU / NPU will be detected and used automatically.
|
||||
If the correct build is used for your GPU / NPU and the `large` model is configured, then the GPU will be detected and used automatically.
|
||||
Specify the `device` option to target a specific GPU in a multi-GPU system (see [onnxruntime's provider options](https://onnxruntime.ai/docs/execution-providers/)).
|
||||
If you do not specify a device, the first available GPU will be used.
|
||||
|
||||
|
||||
@ -36,9 +36,11 @@ If the EQ13 is out of stock, the link below may take you to a suggested alternat
|
||||
|
||||
:::
|
||||
|
||||
| Name | Coral Inference Speed | Coral Compatibility | Notes |
|
||||
| ------------------------------------------------------------------------------------------------------------- | --------------------- | ------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| Beelink EQ13 (<a href="https://amzn.to/4jn2qVr" target="_blank" rel="nofollow noopener sponsored">Amazon</a>) | 5-10ms | USB | Dual gigabit NICs for easy isolated camera network. Easily handles several 1080p cameras. |
|
||||
| Name | Capabilities | Notes |
|
||||
| ------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | --------------------------------------------------- |
|
||||
| Beelink EQ13 (<a href="https://amzn.to/4jn2qVr" target="_blank" rel="nofollow noopener sponsored">Amazon</a>) | Can run object detection on several 1080p cameras with low-medium activity | Dual gigabit NICs for easy isolated camera network. |
|
||||
| Intel 1120p ([Amazon](https://www.amazon.com/Beelink-i3-1220P-Computer-Display-Gigabit/dp/B0DDCKT9YP) | Can handle a large number of 1080p cameras with high activity | |
|
||||
| Intel 125H ([Amazon](https://www.amazon.com/MINISFORUM-Pro-125H-Barebone-Computer-HDMI2-1/dp/B0FH21FSZM) | Can handle a significant number of 1080p cameras with high activity | Includes NPU for more efficient detection in 0.17+ |
|
||||
|
||||
## Detectors
|
||||
|
||||
@ -129,10 +131,16 @@ In real-world deployments, even with multiple cameras running concurrently, Frig
|
||||
|
||||
### Google Coral TPU
|
||||
|
||||
:::warning
|
||||
|
||||
The Coral is no longer recommended for new Frigate installations, except in deployments with particularly low power requirements or hardware incapable of utilizing alternative AI accelerators for object detection. Instead, we suggest using one of the numerous other supported object detectors. Frigate will continue to provide support for the Coral TPU for as long as practicably possible given its still one of the most power-efficient devices for executing object detection models.
|
||||
|
||||
:::
|
||||
|
||||
Frigate supports both the USB and M.2 versions of the Google Coral.
|
||||
|
||||
- The USB version is compatible with the widest variety of hardware and does not require a driver on the host machine. However, it does lack the automatic throttling features of the other versions.
|
||||
- The PCIe and M.2 versions require installation of a driver on the host. Follow the instructions for your version from https://coral.ai
|
||||
- The PCIe and M.2 versions require installation of a driver on the host. https://github.com/jnicolson/gasket-builder should be used.
|
||||
|
||||
A single Coral can handle many cameras using the default model and will be sufficient for the majority of users. You can calculate the maximum performance of your Coral based on the inference speed reported by Frigate. With an inference speed of 10, your Coral will top out at `1000/10=100`, or 100 frames per second. If your detection fps is regularly getting close to that, you should first consider tuning motion masks. If those are already properly configured, a second Coral may be needed.
|
||||
|
||||
|
||||
@ -94,6 +94,10 @@ $ python -c 'print("{:.2f}MB".format(((1280 * 720 * 1.5 * 20 + 270480) / 1048576
|
||||
|
||||
The shm size cannot be set per container for Home Assistant add-ons. However, this is probably not required since by default Home Assistant Supervisor allocates `/dev/shm` with half the size of your total memory. If your machine has 8GB of memory, chances are that Frigate will have access to up to 4GB without any additional configuration.
|
||||
|
||||
## Extra Steps for Specific Hardware
|
||||
|
||||
The following sections contain additional setup steps that are only required if you are using specific hardware. If you are not using any of these hardware types, you can skip to the [Docker](#docker) installation section.
|
||||
|
||||
### Raspberry Pi 3/4
|
||||
|
||||
By default, the Raspberry Pi limits the amount of memory available to the GPU. In order to use ffmpeg hardware acceleration, you must increase the available memory by setting `gpu_mem` to the maximum recommended value in `config.txt` as described in the [official docs](https://www.raspberrypi.org/documentation/computers/config_txt.html#memory-options).
|
||||
@ -106,14 +110,107 @@ The Hailo-8 and Hailo-8L AI accelerators are available in both M.2 and HAT form
|
||||
|
||||
#### Installation
|
||||
|
||||
For Raspberry Pi 5 users with the AI Kit, installation is straightforward. Simply follow this [guide](https://www.raspberrypi.com/documentation/accessories/ai-kit.html#ai-kit-installation) to install the driver and software.
|
||||
:::warning
|
||||
|
||||
For other installations, follow these steps for installation:
|
||||
The Raspberry Pi kernel includes an older version of the Hailo driver that is incompatible with Frigate. You **must** follow the installation steps below to install the correct driver version, and you **must** disable the built-in kernel driver as described in step 1.
|
||||
|
||||
1. Install the driver from the [Hailo GitHub repository](https://github.com/hailo-ai/hailort-drivers). A convenient script for Linux is available to clone the repository, build the driver, and install it.
|
||||
2. Copy or download [this script](https://github.com/blakeblackshear/frigate/blob/dev/docker/hailo8l/user_installation.sh).
|
||||
3. Ensure it has execution permissions with `sudo chmod +x user_installation.sh`
|
||||
4. Run the script with `./user_installation.sh`
|
||||
:::
|
||||
|
||||
1. **Disable the built-in Hailo driver (Raspberry Pi only)**:
|
||||
|
||||
:::note
|
||||
|
||||
If you are **not** using a Raspberry Pi, skip this step and proceed directly to step 2.
|
||||
|
||||
:::
|
||||
|
||||
If you are using a Raspberry Pi, you need to blacklist the built-in kernel Hailo driver to prevent conflicts. First, check if the driver is currently loaded:
|
||||
|
||||
```bash
|
||||
lsmod | grep hailo
|
||||
```
|
||||
|
||||
If it shows `hailo_pci`, unload it:
|
||||
|
||||
```bash
|
||||
sudo rmmod hailo_pci
|
||||
```
|
||||
|
||||
Now blacklist the driver to prevent it from loading on boot:
|
||||
|
||||
```bash
|
||||
echo "blacklist hailo_pci" | sudo tee /etc/modprobe.d/blacklist-hailo_pci.conf
|
||||
```
|
||||
|
||||
Update initramfs to ensure the blacklist takes effect:
|
||||
|
||||
```bash
|
||||
sudo update-initramfs -u
|
||||
```
|
||||
|
||||
Reboot your Raspberry Pi:
|
||||
|
||||
```bash
|
||||
sudo reboot
|
||||
```
|
||||
|
||||
After rebooting, verify the built-in driver is not loaded:
|
||||
|
||||
```bash
|
||||
lsmod | grep hailo
|
||||
```
|
||||
|
||||
This command should return no results. If it still shows `hailo_pci`, the blacklist did not take effect properly and you may need to check for other Hailo packages installed via apt that are loading the driver.
|
||||
|
||||
2. **Run the installation script**:
|
||||
|
||||
Download the installation script:
|
||||
|
||||
```bash
|
||||
wget https://raw.githubusercontent.com/blakeblackshear/frigate/dev/docker/hailo8l/user_installation.sh
|
||||
```
|
||||
|
||||
Make it executable:
|
||||
|
||||
```bash
|
||||
sudo chmod +x user_installation.sh
|
||||
```
|
||||
|
||||
Run the script:
|
||||
|
||||
```bash
|
||||
./user_installation.sh
|
||||
```
|
||||
|
||||
The script will:
|
||||
|
||||
- Install necessary build dependencies
|
||||
- Clone and build the Hailo driver from the official repository
|
||||
- Install the driver
|
||||
- Download and install the required firmware
|
||||
- Set up udev rules
|
||||
|
||||
3. **Reboot your system**:
|
||||
|
||||
After the script completes successfully, reboot to load the firmware:
|
||||
|
||||
```bash
|
||||
sudo reboot
|
||||
```
|
||||
|
||||
4. **Verify the installation**:
|
||||
|
||||
After rebooting, verify that the Hailo device is available:
|
||||
|
||||
```bash
|
||||
ls -l /dev/hailo0
|
||||
```
|
||||
|
||||
You should see the device listed. You can also verify the driver is loaded:
|
||||
|
||||
```bash
|
||||
lsmod | grep hailo_pci
|
||||
```
|
||||
|
||||
#### Setup
|
||||
|
||||
@ -302,7 +399,7 @@ services:
|
||||
shm_size: "512mb" # update for your cameras based on calculation above
|
||||
devices:
|
||||
- /dev/bus/usb:/dev/bus/usb # Passes the USB Coral, needs to be modified for other versions
|
||||
- /dev/apex_0:/dev/apex_0 # Passes a PCIe Coral, follow driver instructions here https://coral.ai/docs/m2/get-started/#2a-on-linux
|
||||
- /dev/apex_0:/dev/apex_0 # Passes a PCIe Coral, follow driver instructions here https://github.com/jnicolson/gasket-builder
|
||||
- /dev/video11:/dev/video11 # For Raspberry Pi 4B
|
||||
- /dev/dri/renderD128:/dev/dri/renderD128 # AMD / Intel GPU, needs to be updated for your hardware
|
||||
- /dev/accel:/dev/accel # Intel NPU
|
||||
|
||||
@ -202,7 +202,7 @@ services:
|
||||
...
|
||||
devices:
|
||||
- /dev/bus/usb:/dev/bus/usb # passes the USB Coral, needs to be modified for other versions
|
||||
- /dev/apex_0:/dev/apex_0 # passes a PCIe Coral, follow driver instructions here https://coral.ai/docs/m2/get-started/#2a-on-linux
|
||||
- /dev/apex_0:/dev/apex_0 # passes a PCIe Coral, follow driver instructions here https://github.com/jnicolson/gasket-builder
|
||||
...
|
||||
```
|
||||
|
||||
|
||||
@ -68,8 +68,7 @@ The USB Coral can become stuck and need to be restarted, this can happen for a n
|
||||
|
||||
The most common reason for the PCIe Coral not being detected is that the driver has not been installed. This process varies based on what OS and kernel that is being run.
|
||||
|
||||
- In most cases [the Coral docs](https://coral.ai/docs/m2/get-started/#2-install-the-pcie-driver-and-edge-tpu-runtime) show how to install the driver for the PCIe based Coral.
|
||||
- For some newer Linux distros (for example, Ubuntu 22.04+), https://github.com/jnicolson/gasket-builder can be used to build and install the latest version of the driver.
|
||||
- In most cases https://github.com/jnicolson/gasket-builder can be used to build and install the latest version of the driver.
|
||||
|
||||
## Attempting to load TPU as pci & Fatal Python error: Illegal instruction
|
||||
|
||||
|
||||
@ -37,6 +37,8 @@ from frigate.models import Event
|
||||
from frigate.util.classification import (
|
||||
collect_object_classification_examples,
|
||||
collect_state_classification_examples,
|
||||
get_dataset_image_count,
|
||||
read_training_metadata,
|
||||
)
|
||||
from frigate.util.file import get_event_snapshot
|
||||
|
||||
@ -112,9 +114,18 @@ def reclassify_face(request: Request, body: dict = None):
|
||||
context: EmbeddingsContext = request.app.embeddings
|
||||
response = context.reprocess_face(training_file)
|
||||
|
||||
if not isinstance(response, dict):
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"success": False,
|
||||
"message": "Could not process request.",
|
||||
},
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=200 if response.get("success", True) else 400,
|
||||
content=response,
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
|
||||
@ -555,23 +566,54 @@ def get_classification_dataset(name: str):
|
||||
dataset_dir = os.path.join(CLIPS_DIR, sanitize_filename(name), "dataset")
|
||||
|
||||
if not os.path.exists(dataset_dir):
|
||||
return JSONResponse(status_code=200, content={})
|
||||
return JSONResponse(
|
||||
status_code=200, content={"categories": {}, "training_metadata": None}
|
||||
)
|
||||
|
||||
for name in os.listdir(dataset_dir):
|
||||
category_dir = os.path.join(dataset_dir, name)
|
||||
for category_name in os.listdir(dataset_dir):
|
||||
category_dir = os.path.join(dataset_dir, category_name)
|
||||
|
||||
if not os.path.isdir(category_dir):
|
||||
continue
|
||||
|
||||
dataset_dict[name] = []
|
||||
dataset_dict[category_name] = []
|
||||
|
||||
for file in filter(
|
||||
lambda f: (f.lower().endswith((".webp", ".png", ".jpg", ".jpeg"))),
|
||||
os.listdir(category_dir),
|
||||
):
|
||||
dataset_dict[name].append(file)
|
||||
dataset_dict[category_name].append(file)
|
||||
|
||||
return JSONResponse(status_code=200, content=dataset_dict)
|
||||
# Get training metadata
|
||||
metadata = read_training_metadata(sanitize_filename(name))
|
||||
current_image_count = get_dataset_image_count(sanitize_filename(name))
|
||||
|
||||
if metadata is None:
|
||||
training_metadata = {
|
||||
"has_trained": False,
|
||||
"last_training_date": None,
|
||||
"last_training_image_count": 0,
|
||||
"current_image_count": current_image_count,
|
||||
"new_images_count": current_image_count,
|
||||
}
|
||||
else:
|
||||
last_training_count = metadata.get("last_training_image_count", 0)
|
||||
new_images_count = max(0, current_image_count - last_training_count)
|
||||
training_metadata = {
|
||||
"has_trained": True,
|
||||
"last_training_date": metadata.get("last_training_date"),
|
||||
"last_training_image_count": last_training_count,
|
||||
"current_image_count": current_image_count,
|
||||
"new_images_count": new_images_count,
|
||||
}
|
||||
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"categories": dataset_dict,
|
||||
"training_metadata": training_metadata,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
@ -671,6 +713,97 @@ def delete_classification_dataset_images(
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/classification/{name}/dataset/{old_category}/rename",
|
||||
response_model=GenericResponse,
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Rename a classification category",
|
||||
description="""Renames a classification category for a given classification model.
|
||||
The old category must exist and the new name must be valid. Returns a success message or an error if the name is invalid.""",
|
||||
)
|
||||
def rename_classification_category(
|
||||
request: Request, name: str, old_category: str, body: dict = None
|
||||
):
|
||||
config: FrigateConfig = request.app.frigate_config
|
||||
|
||||
if name not in config.classification.custom:
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"{name} is not a known classification model.",
|
||||
}
|
||||
),
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
json: dict[str, Any] = body or {}
|
||||
new_category = sanitize_filename(json.get("new_category", ""))
|
||||
|
||||
if not new_category:
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{
|
||||
"success": False,
|
||||
"message": "New category name is required.",
|
||||
}
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
old_folder = os.path.join(
|
||||
CLIPS_DIR, sanitize_filename(name), "dataset", sanitize_filename(old_category)
|
||||
)
|
||||
new_folder = os.path.join(
|
||||
CLIPS_DIR, sanitize_filename(name), "dataset", new_category
|
||||
)
|
||||
|
||||
if not os.path.exists(old_folder):
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"Category {old_category} does not exist.",
|
||||
}
|
||||
),
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
if os.path.exists(new_folder):
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"Category {new_category} already exists.",
|
||||
}
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
try:
|
||||
os.rename(old_folder, new_folder)
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"Successfully renamed category to {new_category}.",
|
||||
}
|
||||
),
|
||||
status_code=200,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error renaming category: {e}")
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{
|
||||
"success": False,
|
||||
"message": "Failed to rename category",
|
||||
}
|
||||
),
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/classification/{name}/dataset/categorize",
|
||||
response_model=GenericResponse,
|
||||
|
||||
@ -140,10 +140,6 @@ Evaluate in this order:
|
||||
The mere presence of an unidentified person in private areas during late night hours is inherently suspicious and warrants human review, regardless of what activity they appear to be doing or how brief the sequence is.""",
|
||||
title="Custom activity context prompt defining normal and suspicious activity patterns for this property.",
|
||||
)
|
||||
camera_context: str = Field(
|
||||
default="",
|
||||
title="Spatial context about the camera's field of view to help with descriptive accuracy. Should describe physical features and locations outside the frame.",
|
||||
)
|
||||
|
||||
|
||||
class ReviewConfig(FrigateBaseModel):
|
||||
|
||||
@ -90,7 +90,8 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
pixels_per_image = width * height
|
||||
tokens_per_image = pixels_per_image / 1250
|
||||
prompt_tokens = 3500
|
||||
available_tokens = context_size * 0.98 - prompt_tokens
|
||||
response_tokens = 300
|
||||
available_tokens = context_size - prompt_tokens - response_tokens
|
||||
max_frames = int(available_tokens / tokens_per_image)
|
||||
|
||||
return min(max(max_frames, 3), 20)
|
||||
@ -458,7 +459,6 @@ def run_analysis(
|
||||
genai_config.preferred_language,
|
||||
genai_config.debug_save_thumbnails,
|
||||
genai_config.activity_context_prompt,
|
||||
genai_config.camera_context,
|
||||
)
|
||||
review_inference_speed.update(datetime.datetime.now().timestamp() - start)
|
||||
|
||||
|
||||
@ -227,6 +227,9 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
|
||||
self.tensor_output_details[0]["index"]
|
||||
)[0]
|
||||
probs = res / res.sum(axis=0)
|
||||
logger.debug(
|
||||
f"{self.model_config.name} Ran state classification with probabilities: {probs}"
|
||||
)
|
||||
best_id = np.argmax(probs)
|
||||
score = round(probs[best_id], 2)
|
||||
self.__update_metrics(datetime.datetime.now().timestamp() - now)
|
||||
@ -418,8 +421,8 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
|
||||
obj_data["box"][2],
|
||||
obj_data["box"][3],
|
||||
max(
|
||||
obj_data["box"][1] - obj_data["box"][0],
|
||||
obj_data["box"][3] - obj_data["box"][2],
|
||||
obj_data["box"][2] - obj_data["box"][0],
|
||||
obj_data["box"][3] - obj_data["box"][1],
|
||||
),
|
||||
1.0,
|
||||
)
|
||||
@ -455,6 +458,9 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
|
||||
self.tensor_output_details[0]["index"]
|
||||
)[0]
|
||||
probs = res / res.sum(axis=0)
|
||||
logger.debug(
|
||||
f"{self.model_config.name} Ran object classification with probabilities: {probs}"
|
||||
)
|
||||
best_id = np.argmax(probs)
|
||||
score = round(probs[best_id], 2)
|
||||
self.__update_metrics(datetime.datetime.now().timestamp() - now)
|
||||
@ -546,5 +552,8 @@ def write_classification_attempt(
|
||||
)
|
||||
|
||||
# delete oldest face image if maximum is reached
|
||||
if len(files) > max_files:
|
||||
os.unlink(os.path.join(folder, files[-1]))
|
||||
try:
|
||||
if len(files) > max_files:
|
||||
os.unlink(os.path.join(folder, files[-1]))
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
@ -423,7 +423,10 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
res = self.recognizer.classify(img)
|
||||
|
||||
if not res:
|
||||
return
|
||||
return {
|
||||
"message": "No face was recognized.",
|
||||
"success": False,
|
||||
}
|
||||
|
||||
sub_label, score = res
|
||||
|
||||
@ -442,6 +445,13 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
)
|
||||
shutil.move(current_file, new_file)
|
||||
|
||||
return {
|
||||
"message": f"Successfully reprocessed face. Result: {sub_label} (score: {score:.2f})",
|
||||
"success": True,
|
||||
"face_name": sub_label,
|
||||
"score": score,
|
||||
}
|
||||
|
||||
def expire_object(self, object_id: str, camera: str):
|
||||
if object_id in self.person_face_history:
|
||||
self.person_face_history.pop(object_id)
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import threading
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
@ -161,12 +162,12 @@ class CudaGraphRunner(BaseModelRunner):
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def is_complex_model(model_type: str) -> bool:
|
||||
def is_model_supported(model_type: str) -> bool:
|
||||
# Import here to avoid circular imports
|
||||
from frigate.detectors.detector_config import ModelTypeEnum
|
||||
from frigate.embeddings.types import EnrichmentModelTypeEnum
|
||||
|
||||
return model_type in [
|
||||
return model_type not in [
|
||||
ModelTypeEnum.yolonas.value,
|
||||
EnrichmentModelTypeEnum.paddleocr.value,
|
||||
EnrichmentModelTypeEnum.jina_v1.value,
|
||||
@ -239,9 +240,30 @@ class OpenVINOModelRunner(BaseModelRunner):
|
||||
EnrichmentModelTypeEnum.jina_v2.value,
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def is_model_npu_supported(model_type: str) -> bool:
|
||||
# Import here to avoid circular imports
|
||||
from frigate.embeddings.types import EnrichmentModelTypeEnum
|
||||
|
||||
return model_type not in [
|
||||
EnrichmentModelTypeEnum.paddleocr.value,
|
||||
EnrichmentModelTypeEnum.jina_v1.value,
|
||||
EnrichmentModelTypeEnum.jina_v2.value,
|
||||
EnrichmentModelTypeEnum.arcface.value,
|
||||
]
|
||||
|
||||
def __init__(self, model_path: str, device: str, model_type: str, **kwargs):
|
||||
self.model_path = model_path
|
||||
self.device = device
|
||||
|
||||
if device == "NPU" and not OpenVINOModelRunner.is_model_npu_supported(
|
||||
model_type
|
||||
):
|
||||
logger.warning(
|
||||
f"OpenVINO model {model_type} is not supported on NPU, using GPU instead"
|
||||
)
|
||||
device = "GPU"
|
||||
|
||||
self.complex_model = OpenVINOModelRunner.is_complex_model(model_type)
|
||||
|
||||
if not os.path.isfile(model_path):
|
||||
@ -269,6 +291,10 @@ class OpenVINOModelRunner(BaseModelRunner):
|
||||
self.infer_request = self.compiled_model.create_infer_request()
|
||||
self.input_tensor: ov.Tensor | None = None
|
||||
|
||||
# Thread lock to prevent concurrent inference (needed for JinaV2 which shares
|
||||
# one runner between text and vision embeddings called from different threads)
|
||||
self._inference_lock = threading.Lock()
|
||||
|
||||
if not self.complex_model:
|
||||
try:
|
||||
input_shape = self.compiled_model.inputs[0].get_shape()
|
||||
@ -312,67 +338,70 @@ class OpenVINOModelRunner(BaseModelRunner):
|
||||
Returns:
|
||||
List of output tensors
|
||||
"""
|
||||
# Handle single input case for backward compatibility
|
||||
if (
|
||||
len(inputs) == 1
|
||||
and len(self.compiled_model.inputs) == 1
|
||||
and self.input_tensor is not None
|
||||
):
|
||||
# Single input case - use the pre-allocated tensor for efficiency
|
||||
input_data = list(inputs.values())[0]
|
||||
np.copyto(self.input_tensor.data, input_data)
|
||||
self.infer_request.infer(self.input_tensor)
|
||||
else:
|
||||
if self.complex_model:
|
||||
try:
|
||||
# This ensures the model starts with a clean state for each sequence
|
||||
# Important for RNN models like PaddleOCR recognition
|
||||
self.infer_request.reset_state()
|
||||
except Exception:
|
||||
# this will raise an exception for models with AUTO set as the device
|
||||
pass
|
||||
# Lock prevents concurrent access to infer_request
|
||||
# Needed for JinaV2: genai thread (text) + embeddings thread (vision)
|
||||
with self._inference_lock:
|
||||
# Handle single input case for backward compatibility
|
||||
if (
|
||||
len(inputs) == 1
|
||||
and len(self.compiled_model.inputs) == 1
|
||||
and self.input_tensor is not None
|
||||
):
|
||||
# Single input case - use the pre-allocated tensor for efficiency
|
||||
input_data = list(inputs.values())[0]
|
||||
np.copyto(self.input_tensor.data, input_data)
|
||||
self.infer_request.infer(self.input_tensor)
|
||||
else:
|
||||
if self.complex_model:
|
||||
try:
|
||||
# This ensures the model starts with a clean state for each sequence
|
||||
# Important for RNN models like PaddleOCR recognition
|
||||
self.infer_request.reset_state()
|
||||
except Exception:
|
||||
# this will raise an exception for models with AUTO set as the device
|
||||
pass
|
||||
|
||||
# Multiple inputs case - set each input by name
|
||||
for input_name, input_data in inputs.items():
|
||||
# Find the input by name and its index
|
||||
input_port = None
|
||||
input_index = None
|
||||
for idx, port in enumerate(self.compiled_model.inputs):
|
||||
if port.get_any_name() == input_name:
|
||||
input_port = port
|
||||
input_index = idx
|
||||
break
|
||||
# Multiple inputs case - set each input by name
|
||||
for input_name, input_data in inputs.items():
|
||||
# Find the input by name and its index
|
||||
input_port = None
|
||||
input_index = None
|
||||
for idx, port in enumerate(self.compiled_model.inputs):
|
||||
if port.get_any_name() == input_name:
|
||||
input_port = port
|
||||
input_index = idx
|
||||
break
|
||||
|
||||
if input_port is None:
|
||||
raise ValueError(f"Input '{input_name}' not found in model")
|
||||
if input_port is None:
|
||||
raise ValueError(f"Input '{input_name}' not found in model")
|
||||
|
||||
# Create tensor with the correct element type
|
||||
input_element_type = input_port.get_element_type()
|
||||
# Create tensor with the correct element type
|
||||
input_element_type = input_port.get_element_type()
|
||||
|
||||
# Ensure input data matches the expected dtype to prevent type mismatches
|
||||
# that can occur with models like Jina-CLIP v2 running on OpenVINO
|
||||
expected_dtype = input_element_type.to_dtype()
|
||||
if input_data.dtype != expected_dtype:
|
||||
logger.debug(
|
||||
f"Converting input '{input_name}' from {input_data.dtype} to {expected_dtype}"
|
||||
)
|
||||
input_data = input_data.astype(expected_dtype)
|
||||
# Ensure input data matches the expected dtype to prevent type mismatches
|
||||
# that can occur with models like Jina-CLIP v2 running on OpenVINO
|
||||
expected_dtype = input_element_type.to_dtype()
|
||||
if input_data.dtype != expected_dtype:
|
||||
logger.debug(
|
||||
f"Converting input '{input_name}' from {input_data.dtype} to {expected_dtype}"
|
||||
)
|
||||
input_data = input_data.astype(expected_dtype)
|
||||
|
||||
input_tensor = ov.Tensor(input_element_type, input_data.shape)
|
||||
np.copyto(input_tensor.data, input_data)
|
||||
input_tensor = ov.Tensor(input_element_type, input_data.shape)
|
||||
np.copyto(input_tensor.data, input_data)
|
||||
|
||||
# Set the input tensor for the specific port index
|
||||
self.infer_request.set_input_tensor(input_index, input_tensor)
|
||||
# Set the input tensor for the specific port index
|
||||
self.infer_request.set_input_tensor(input_index, input_tensor)
|
||||
|
||||
# Run inference
|
||||
self.infer_request.infer()
|
||||
# Run inference
|
||||
self.infer_request.infer()
|
||||
|
||||
# Get all output tensors
|
||||
outputs = []
|
||||
for i in range(len(self.compiled_model.outputs)):
|
||||
outputs.append(self.infer_request.get_output_tensor(i).data)
|
||||
# Get all output tensors
|
||||
outputs = []
|
||||
for i in range(len(self.compiled_model.outputs)):
|
||||
outputs.append(self.infer_request.get_output_tensor(i).data)
|
||||
|
||||
return outputs
|
||||
return outputs
|
||||
|
||||
|
||||
class RKNNModelRunner(BaseModelRunner):
|
||||
@ -500,7 +529,7 @@ def get_optimized_runner(
|
||||
return OpenVINOModelRunner(model_path, device, model_type, **kwargs)
|
||||
|
||||
if (
|
||||
not CudaGraphRunner.is_complex_model(model_type)
|
||||
CudaGraphRunner.is_model_supported(model_type)
|
||||
and providers[0] == "CUDAExecutionProvider"
|
||||
):
|
||||
options[0] = {
|
||||
|
||||
@ -45,7 +45,6 @@ class GenAIClient:
|
||||
preferred_language: str | None,
|
||||
debug_save: bool,
|
||||
activity_context_prompt: str,
|
||||
camera_context: str = "",
|
||||
) -> ReviewMetadata | None:
|
||||
"""Generate a description for the review item activity."""
|
||||
|
||||
@ -70,16 +69,6 @@ class GenAIClient:
|
||||
else:
|
||||
return "\n- (No objects detected)"
|
||||
|
||||
def get_camera_context_section() -> str:
|
||||
if camera_context:
|
||||
return f"""## Camera Spatial Context
|
||||
|
||||
Use this spatial information when writing the title and scene description to provide more accurate context about where activity is occurring or where people/objects are moving to/from.
|
||||
|
||||
{camera_context}"""
|
||||
return ""
|
||||
|
||||
camera_context_section = get_camera_context_section()
|
||||
context_prompt = f"""
|
||||
Your task is to analyze the sequence of images ({len(thumbnails)} total) taken in chronological order from the perspective of the {review_data["camera"].replace("_", " ")} security camera.
|
||||
|
||||
@ -87,8 +76,6 @@ Your task is to analyze the sequence of images ({len(thumbnails)} total) taken i
|
||||
|
||||
{activity_context_prompt}
|
||||
|
||||
{camera_context_section}
|
||||
|
||||
## Task Instructions
|
||||
|
||||
Your task is to provide a clear, accurate description of the scene that:
|
||||
@ -113,8 +100,8 @@ When forming your description:
|
||||
## Response Format
|
||||
|
||||
Your response MUST be a flat JSON object with:
|
||||
- `title` (string): A concise, direct title that describes the purpose or overall action, not just what you literally see. {"Use spatial context when available to make titles more meaningful." if camera_context_section else ""} Use names from "Objects in Scene" based on what you visually observe. If you see both a name and an unidentified object of the same type but visually observe only one person/object, use ONLY the name. Examples: "Joe walking dog", "Person taking out trash", "Joe accessing vehicle", "Person leaving porch for driveway", "Joe and person on front porch".
|
||||
- `scene` (string): A narrative description of what happens across the sequence from start to finish. **Only describe actions you can actually observe happening in the frames provided.** Do not infer or assume actions that aren't visible (e.g., if you see someone walking but never see them sit, don't say they sat down). Include setting, detected objects, and their observable actions. Avoid speculation or filling in assumed behaviors. Your description should align with and support the threat level you assign.
|
||||
- `title` (string): A concise, direct title that describes the primary action or event in the sequence, not just what you literally see. Use spatial context when available to make titles more meaningful. When multiple objects/actions are present, prioritize whichever is most prominent or occurs first. Use names from "Objects in Scene" based on what you visually observe. If you see both a name and an unidentified object of the same type but visually observe only one person/object, use ONLY the name. Examples: "Joe walking dog", "Person taking out trash", "Vehicle arriving in driveway", "Joe accessing vehicle", "Person leaving porch for driveway".
|
||||
- `scene` (string): A narrative description of what happens across the sequence from start to finish, in chronological order. Start by describing how the sequence begins, then describe the progression of events. **Describe all significant movements and actions in the order they occur.** For example, if a vehicle arrives and then a person exits, describe both actions sequentially. **Only describe actions you can actually observe happening in the frames provided.** Do not infer or assume actions that aren't visible (e.g., if you see someone walking but never see them sit, don't say they sat down). Include setting, detected objects, and their observable actions. Avoid speculation or filling in assumed behaviors. Your description should align with and support the threat level you assign.
|
||||
- `confidence` (float): 0-1 confidence in your analysis. Higher confidence when objects/actions are clearly visible and context is unambiguous. Lower confidence when the sequence is unclear, objects are partially obscured, or context is ambiguous.
|
||||
- `potential_threat_level` (integer): 0, 1, or 2 as defined in "Normal Activity Patterns for This Property" above. Your threat level must be consistent with your scene description and the guidance above.
|
||||
{get_concern_prompt()}
|
||||
|
||||
@ -407,6 +407,19 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
segment.last_detection_time = frame_time
|
||||
|
||||
for object in activity.get_all_objects():
|
||||
# Alert-level objects should always be added (they extend/upgrade the segment)
|
||||
# Detection-level objects should only be added if:
|
||||
# - The segment is a detection segment (matching severity), OR
|
||||
# - The segment is an alert AND the object started before the alert ended
|
||||
# (objects starting after will be in the new detection segment)
|
||||
is_alert_object = object in activity.categorized_objects["alerts"]
|
||||
|
||||
if not is_alert_object and segment.severity == SeverityEnum.alert:
|
||||
# This is a detection-level object
|
||||
# Only add if it started during the alert's active period
|
||||
if object["start_time"] > segment.last_alert_time:
|
||||
continue
|
||||
|
||||
if not object["sub_label"]:
|
||||
segment.detections[object["id"]] = object["label"]
|
||||
elif object["sub_label"][0] in self.config.model.all_attributes:
|
||||
|
||||
@ -23,6 +23,7 @@ class ModelStatusTypesEnum(str, Enum):
|
||||
error = "error"
|
||||
training = "training"
|
||||
complete = "complete"
|
||||
failed = "failed"
|
||||
|
||||
|
||||
class TrackedObjectUpdateTypesEnum(str, Enum):
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
"""Util for classification models."""
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
@ -27,10 +29,96 @@ from frigate.util.process import FrigateProcess
|
||||
BATCH_SIZE = 16
|
||||
EPOCHS = 50
|
||||
LEARNING_RATE = 0.001
|
||||
TRAINING_METADATA_FILE = ".training_metadata.json"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def write_training_metadata(model_name: str, image_count: int) -> None:
|
||||
"""
|
||||
Write training metadata to a hidden file in the model's clips directory.
|
||||
|
||||
Args:
|
||||
model_name: Name of the classification model
|
||||
image_count: Number of images used in training
|
||||
"""
|
||||
clips_model_dir = os.path.join(CLIPS_DIR, model_name)
|
||||
os.makedirs(clips_model_dir, exist_ok=True)
|
||||
|
||||
metadata_path = os.path.join(clips_model_dir, TRAINING_METADATA_FILE)
|
||||
metadata = {
|
||||
"last_training_date": datetime.datetime.now().isoformat(),
|
||||
"last_training_image_count": image_count,
|
||||
}
|
||||
|
||||
try:
|
||||
with open(metadata_path, "w") as f:
|
||||
json.dump(metadata, f, indent=2)
|
||||
logger.info(f"Wrote training metadata for {model_name}: {image_count} images")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to write training metadata for {model_name}: {e}")
|
||||
|
||||
|
||||
def read_training_metadata(model_name: str) -> dict[str, any] | None:
|
||||
"""
|
||||
Read training metadata from the hidden file in the model's clips directory.
|
||||
|
||||
Args:
|
||||
model_name: Name of the classification model
|
||||
|
||||
Returns:
|
||||
Dictionary with last_training_date and last_training_image_count, or None if not found
|
||||
"""
|
||||
clips_model_dir = os.path.join(CLIPS_DIR, model_name)
|
||||
metadata_path = os.path.join(clips_model_dir, TRAINING_METADATA_FILE)
|
||||
|
||||
if not os.path.exists(metadata_path):
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(metadata_path, "r") as f:
|
||||
metadata = json.load(f)
|
||||
return metadata
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to read training metadata for {model_name}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_dataset_image_count(model_name: str) -> int:
|
||||
"""
|
||||
Count the total number of images in the model's dataset directory.
|
||||
|
||||
Args:
|
||||
model_name: Name of the classification model
|
||||
|
||||
Returns:
|
||||
Total count of images across all categories
|
||||
"""
|
||||
dataset_dir = os.path.join(CLIPS_DIR, model_name, "dataset")
|
||||
|
||||
if not os.path.exists(dataset_dir):
|
||||
return 0
|
||||
|
||||
total_count = 0
|
||||
try:
|
||||
for category in os.listdir(dataset_dir):
|
||||
category_dir = os.path.join(dataset_dir, category)
|
||||
if not os.path.isdir(category_dir):
|
||||
continue
|
||||
|
||||
image_files = [
|
||||
f
|
||||
for f in os.listdir(category_dir)
|
||||
if f.lower().endswith((".webp", ".png", ".jpg", ".jpeg"))
|
||||
]
|
||||
total_count += len(image_files)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to count dataset images for {model_name}: {e}")
|
||||
return 0
|
||||
|
||||
return total_count
|
||||
|
||||
|
||||
class ClassificationTrainingProcess(FrigateProcess):
|
||||
def __init__(self, model_name: str) -> None:
|
||||
super().__init__(
|
||||
@ -42,7 +130,8 @@ class ClassificationTrainingProcess(FrigateProcess):
|
||||
|
||||
def run(self) -> None:
|
||||
self.pre_run_setup()
|
||||
self.__train_classification_model()
|
||||
success = self.__train_classification_model()
|
||||
exit(0 if success else 1)
|
||||
|
||||
def __generate_representative_dataset_factory(self, dataset_dir: str):
|
||||
def generate_representative_dataset():
|
||||
@ -65,85 +154,117 @@ class ClassificationTrainingProcess(FrigateProcess):
|
||||
@redirect_output_to_logger(logger, logging.DEBUG)
|
||||
def __train_classification_model(self) -> bool:
|
||||
"""Train a classification model."""
|
||||
try:
|
||||
# import in the function so that tensorflow is not initialized multiple times
|
||||
import tensorflow as tf
|
||||
from tensorflow.keras import layers, models, optimizers
|
||||
from tensorflow.keras.applications import MobileNetV2
|
||||
from tensorflow.keras.preprocessing.image import ImageDataGenerator
|
||||
|
||||
# import in the function so that tensorflow is not initialized multiple times
|
||||
import tensorflow as tf
|
||||
from tensorflow.keras import layers, models, optimizers
|
||||
from tensorflow.keras.applications import MobileNetV2
|
||||
from tensorflow.keras.preprocessing.image import ImageDataGenerator
|
||||
dataset_dir = os.path.join(CLIPS_DIR, self.model_name, "dataset")
|
||||
model_dir = os.path.join(MODEL_CACHE_DIR, self.model_name)
|
||||
os.makedirs(model_dir, exist_ok=True)
|
||||
|
||||
logger.info(f"Kicking off classification training for {self.model_name}.")
|
||||
dataset_dir = os.path.join(CLIPS_DIR, self.model_name, "dataset")
|
||||
model_dir = os.path.join(MODEL_CACHE_DIR, self.model_name)
|
||||
os.makedirs(model_dir, exist_ok=True)
|
||||
num_classes = len(
|
||||
[
|
||||
d
|
||||
for d in os.listdir(dataset_dir)
|
||||
if os.path.isdir(os.path.join(dataset_dir, d))
|
||||
]
|
||||
)
|
||||
num_classes = len(
|
||||
[
|
||||
d
|
||||
for d in os.listdir(dataset_dir)
|
||||
if os.path.isdir(os.path.join(dataset_dir, d))
|
||||
]
|
||||
)
|
||||
|
||||
# Start with imagenet base model with 35% of channels in each layer
|
||||
base_model = MobileNetV2(
|
||||
input_shape=(224, 224, 3),
|
||||
include_top=False,
|
||||
weights="imagenet",
|
||||
alpha=0.35,
|
||||
)
|
||||
base_model.trainable = False # Freeze pre-trained layers
|
||||
if num_classes < 2:
|
||||
logger.error(
|
||||
f"Training failed for {self.model_name}: Need at least 2 classes, found {num_classes}"
|
||||
)
|
||||
return False
|
||||
|
||||
model = models.Sequential(
|
||||
[
|
||||
base_model,
|
||||
layers.GlobalAveragePooling2D(),
|
||||
layers.Dense(128, activation="relu"),
|
||||
layers.Dropout(0.3),
|
||||
layers.Dense(num_classes, activation="softmax"),
|
||||
]
|
||||
)
|
||||
# Start with imagenet base model with 35% of channels in each layer
|
||||
base_model = MobileNetV2(
|
||||
input_shape=(224, 224, 3),
|
||||
include_top=False,
|
||||
weights="imagenet",
|
||||
alpha=0.35,
|
||||
)
|
||||
base_model.trainable = False # Freeze pre-trained layers
|
||||
|
||||
model.compile(
|
||||
optimizer=optimizers.Adam(learning_rate=LEARNING_RATE),
|
||||
loss="categorical_crossentropy",
|
||||
metrics=["accuracy"],
|
||||
)
|
||||
model = models.Sequential(
|
||||
[
|
||||
base_model,
|
||||
layers.GlobalAveragePooling2D(),
|
||||
layers.Dense(128, activation="relu"),
|
||||
layers.Dropout(0.3),
|
||||
layers.Dense(num_classes, activation="softmax"),
|
||||
]
|
||||
)
|
||||
|
||||
# create training set
|
||||
datagen = ImageDataGenerator(rescale=1.0 / 255, validation_split=0.2)
|
||||
train_gen = datagen.flow_from_directory(
|
||||
dataset_dir,
|
||||
target_size=(224, 224),
|
||||
batch_size=BATCH_SIZE,
|
||||
class_mode="categorical",
|
||||
subset="training",
|
||||
)
|
||||
model.compile(
|
||||
optimizer=optimizers.Adam(learning_rate=LEARNING_RATE),
|
||||
loss="categorical_crossentropy",
|
||||
metrics=["accuracy"],
|
||||
)
|
||||
|
||||
# write labelmap
|
||||
class_indices = train_gen.class_indices
|
||||
index_to_class = {v: k for k, v in class_indices.items()}
|
||||
sorted_classes = [index_to_class[i] for i in range(len(index_to_class))]
|
||||
with open(os.path.join(model_dir, "labelmap.txt"), "w") as f:
|
||||
for class_name in sorted_classes:
|
||||
f.write(f"{class_name}\n")
|
||||
# create training set
|
||||
datagen = ImageDataGenerator(rescale=1.0 / 255, validation_split=0.2)
|
||||
train_gen = datagen.flow_from_directory(
|
||||
dataset_dir,
|
||||
target_size=(224, 224),
|
||||
batch_size=BATCH_SIZE,
|
||||
class_mode="categorical",
|
||||
subset="training",
|
||||
)
|
||||
|
||||
# train the model
|
||||
model.fit(train_gen, epochs=EPOCHS, verbose=0)
|
||||
total_images = train_gen.samples
|
||||
logger.debug(
|
||||
f"Training {self.model_name}: {total_images} images across {num_classes} classes"
|
||||
)
|
||||
|
||||
# convert model to tflite
|
||||
converter = tf.lite.TFLiteConverter.from_keras_model(model)
|
||||
converter.optimizations = [tf.lite.Optimize.DEFAULT]
|
||||
converter.representative_dataset = (
|
||||
self.__generate_representative_dataset_factory(dataset_dir)
|
||||
)
|
||||
converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8]
|
||||
converter.inference_input_type = tf.uint8
|
||||
converter.inference_output_type = tf.uint8
|
||||
tflite_model = converter.convert()
|
||||
# write labelmap
|
||||
class_indices = train_gen.class_indices
|
||||
index_to_class = {v: k for k, v in class_indices.items()}
|
||||
sorted_classes = [index_to_class[i] for i in range(len(index_to_class))]
|
||||
with open(os.path.join(model_dir, "labelmap.txt"), "w") as f:
|
||||
for class_name in sorted_classes:
|
||||
f.write(f"{class_name}\n")
|
||||
|
||||
# write model
|
||||
with open(os.path.join(model_dir, "model.tflite"), "wb") as f:
|
||||
f.write(tflite_model)
|
||||
# train the model
|
||||
logger.debug(f"Training {self.model_name} for {EPOCHS} epochs...")
|
||||
model.fit(train_gen, epochs=EPOCHS, verbose=0)
|
||||
logger.debug(f"Converting {self.model_name} to TFLite...")
|
||||
|
||||
# convert model to tflite
|
||||
converter = tf.lite.TFLiteConverter.from_keras_model(model)
|
||||
converter.optimizations = [tf.lite.Optimize.DEFAULT]
|
||||
converter.representative_dataset = (
|
||||
self.__generate_representative_dataset_factory(dataset_dir)
|
||||
)
|
||||
converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8]
|
||||
converter.inference_input_type = tf.uint8
|
||||
converter.inference_output_type = tf.uint8
|
||||
tflite_model = converter.convert()
|
||||
|
||||
# write model
|
||||
model_path = os.path.join(model_dir, "model.tflite")
|
||||
with open(model_path, "wb") as f:
|
||||
f.write(tflite_model)
|
||||
|
||||
# verify model file was written successfully
|
||||
if not os.path.exists(model_path) or os.path.getsize(model_path) == 0:
|
||||
logger.error(
|
||||
f"Training failed for {self.model_name}: Model file was not created or is empty"
|
||||
)
|
||||
return False
|
||||
|
||||
# write training metadata with image count
|
||||
dataset_image_count = get_dataset_image_count(self.model_name)
|
||||
write_training_metadata(self.model_name, dataset_image_count)
|
||||
|
||||
logger.info(f"Finished training {self.model_name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Training failed for {self.model_name}: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def kickoff_model_training(
|
||||
@ -165,18 +286,36 @@ def kickoff_model_training(
|
||||
training_process.start()
|
||||
training_process.join()
|
||||
|
||||
# reload model and mark training as complete
|
||||
embeddingRequestor.send_data(
|
||||
EmbeddingsRequestEnum.reload_classification_model.value,
|
||||
{"model_name": model_name},
|
||||
)
|
||||
requestor.send_data(
|
||||
UPDATE_MODEL_STATE,
|
||||
{
|
||||
"model": model_name,
|
||||
"state": ModelStatusTypesEnum.complete,
|
||||
},
|
||||
)
|
||||
# check if training succeeded by examining the exit code
|
||||
training_success = training_process.exitcode == 0
|
||||
|
||||
if training_success:
|
||||
# reload model and mark training as complete
|
||||
embeddingRequestor.send_data(
|
||||
EmbeddingsRequestEnum.reload_classification_model.value,
|
||||
{"model_name": model_name},
|
||||
)
|
||||
requestor.send_data(
|
||||
UPDATE_MODEL_STATE,
|
||||
{
|
||||
"model": model_name,
|
||||
"state": ModelStatusTypesEnum.complete,
|
||||
},
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
f"Training subprocess failed for {model_name} (exit code: {training_process.exitcode})"
|
||||
)
|
||||
# mark training as failed so UI shows error state
|
||||
# don't reload the model since it failed
|
||||
requestor.send_data(
|
||||
UPDATE_MODEL_STATE,
|
||||
{
|
||||
"model": model_name,
|
||||
"state": ModelStatusTypesEnum.failed,
|
||||
},
|
||||
)
|
||||
|
||||
requestor.stop()
|
||||
|
||||
|
||||
|
||||
@ -96,7 +96,9 @@
|
||||
"back": "Go back",
|
||||
"hide": "Hide {{item}}",
|
||||
"show": "Show {{item}}",
|
||||
"ID": "ID"
|
||||
"ID": "ID",
|
||||
"none": "None",
|
||||
"all": "All"
|
||||
},
|
||||
"list": {
|
||||
"two": "{{0}} and {{1}}",
|
||||
|
||||
@ -67,9 +67,6 @@
|
||||
},
|
||||
"activity_context_prompt": {
|
||||
"label": "Custom activity context prompt defining normal activity patterns for this property."
|
||||
},
|
||||
"camera_context": {
|
||||
"label": "Spatial context about the camera's field of view to help with descriptive accuracy. Should describe physical features and locations outside the frame. This is for spatial reference only and should NOT include subjective assessments."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,6 +13,11 @@
|
||||
"deleteModels": "Delete Models",
|
||||
"editModel": "Edit Model"
|
||||
},
|
||||
"tooltip": {
|
||||
"trainingInProgress": "Model is currently training",
|
||||
"noNewImages": "No new images to train. Classify more images in the dataset first.",
|
||||
"modelNotReady": "Model is not ready for training"
|
||||
},
|
||||
"toast": {
|
||||
"success": {
|
||||
"deletedCategory": "Deleted Class",
|
||||
@ -22,15 +27,18 @@
|
||||
"categorizedImage": "Successfully Classified Image",
|
||||
"trainedModel": "Successfully trained model.",
|
||||
"trainingModel": "Successfully started model training.",
|
||||
"updatedModel": "Successfully updated model configuration"
|
||||
"updatedModel": "Successfully updated model configuration",
|
||||
"renamedCategory": "Successfully renamed class to {{name}}"
|
||||
},
|
||||
"error": {
|
||||
"deleteImageFailed": "Failed to delete: {{errorMessage}}",
|
||||
"deleteCategoryFailed": "Failed to delete class: {{errorMessage}}",
|
||||
"deleteModelFailed": "Failed to delete model: {{errorMessage}}",
|
||||
"categorizeFailed": "Failed to categorize image: {{errorMessage}}",
|
||||
"trainingFailed": "Failed to start model training: {{errorMessage}}",
|
||||
"updateModelFailed": "Failed to update model: {{errorMessage}}"
|
||||
"trainingFailed": "Model training failed. Check Frigate logs for details.",
|
||||
"trainingFailedToStart": "Failed to start model training: {{errorMessage}}",
|
||||
"updateModelFailed": "Failed to update model: {{errorMessage}}",
|
||||
"renameCategoryFailed": "Failed to rename class: {{errorMessage}}"
|
||||
}
|
||||
},
|
||||
"deleteCategory": {
|
||||
@ -141,6 +149,8 @@
|
||||
"step3": {
|
||||
"selectImagesPrompt": "Select all images with: {{className}}",
|
||||
"selectImagesDescription": "Click on images to select them. Click Continue when you're done with this class.",
|
||||
"allImagesRequired_one": "Please classify all images. {{count}} image remaining.",
|
||||
"allImagesRequired_other": "Please classify all images. {{count}} images remaining.",
|
||||
"generating": {
|
||||
"title": "Generating Sample Images",
|
||||
"description": "Frigate is pulling representative images from your recordings. This may take a moment..."
|
||||
|
||||
@ -75,7 +75,7 @@
|
||||
"deletedName_other": "{{count}} faces have been successfully deleted.",
|
||||
"renamedFace": "Successfully renamed face to {{name}}",
|
||||
"trainedFace": "Successfully trained face.",
|
||||
"updatedFaceScore": "Successfully updated face score."
|
||||
"updatedFaceScore": "Successfully updated face score to {{name}} ({{score}})."
|
||||
},
|
||||
"error": {
|
||||
"uploadingImageFailed": "Failed to upload image: {{errorMessage}}",
|
||||
|
||||
@ -148,13 +148,13 @@ export const ClassificationCard = forwardRef<
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-start text-white",
|
||||
data.score ? "text-xs" : "text-sm",
|
||||
data.score != undefined ? "text-xs" : "text-sm",
|
||||
)}
|
||||
>
|
||||
<div className="smart-capitalize">
|
||||
{data.name == "unknown" ? t("details.unknown") : data.name}
|
||||
</div>
|
||||
{data.score && (
|
||||
{data.score != undefined && (
|
||||
<div
|
||||
className={cn(
|
||||
"",
|
||||
|
||||
@ -10,6 +10,12 @@ import useSWR from "swr";
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
|
||||
export type Step3FormData = {
|
||||
examplesGenerated: boolean;
|
||||
@ -317,6 +323,19 @@ export default function Step3ChooseExamples({
|
||||
return unclassifiedImages.length === 0;
|
||||
}, [unclassifiedImages]);
|
||||
|
||||
// For state models on the last class, require all images to be classified
|
||||
const isLastClass = currentClassIndex === allClasses.length - 1;
|
||||
const canProceed = useMemo(() => {
|
||||
if (
|
||||
step1Data.modelType === "state" &&
|
||||
isLastClass &&
|
||||
!allImagesClassified
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}, [step1Data.modelType, isLastClass, allImagesClassified]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
if (currentClassIndex > 0) {
|
||||
const previousClass = allClasses[currentClassIndex - 1];
|
||||
@ -438,20 +457,35 @@ export default function Step3ChooseExamples({
|
||||
<Button type="button" onClick={handleBack} className="sm:flex-1">
|
||||
{t("button.back", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={
|
||||
allImagesClassified
|
||||
? handleContinue
|
||||
: handleContinueClassification
|
||||
}
|
||||
variant="select"
|
||||
className="flex items-center justify-center gap-2 sm:flex-1"
|
||||
disabled={!hasGenerated || isGenerating || isProcessing}
|
||||
>
|
||||
{isProcessing && <ActivityIndicator className="size-4" />}
|
||||
{t("button.continue", { ns: "common" })}
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={
|
||||
allImagesClassified
|
||||
? handleContinue
|
||||
: handleContinueClassification
|
||||
}
|
||||
variant="select"
|
||||
className="flex items-center justify-center gap-2 sm:flex-1"
|
||||
disabled={
|
||||
!hasGenerated || isGenerating || isProcessing || !canProceed
|
||||
}
|
||||
>
|
||||
{isProcessing && <ActivityIndicator className="size-4" />}
|
||||
{t("button.continue", { ns: "common" })}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
{!canProceed && (
|
||||
<TooltipPortal>
|
||||
<TooltipContent>
|
||||
{t("wizard.step3.allImagesRequired", {
|
||||
count: unclassifiedImages.length,
|
||||
})}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
)}
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -159,7 +159,7 @@ export default function CreateTriggerDialog({
|
||||
});
|
||||
|
||||
const onSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||
if (trigger) {
|
||||
if (trigger && existingTriggerNames.includes(trigger.name)) {
|
||||
onEdit({ ...values });
|
||||
} else {
|
||||
onCreate(
|
||||
|
||||
@ -55,29 +55,32 @@ export default function DetailActionsMenu({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>
|
||||
<a
|
||||
className="w-full"
|
||||
href={`${baseUrl}api/events/${search.id}/snapshot.jpg?bbox=1`}
|
||||
download={`${search.camera}_${search.label}.jpg`}
|
||||
>
|
||||
<div className="flex cursor-pointer items-center gap-2">
|
||||
<span>{t("itemMenu.downloadSnapshot.label")}</span>
|
||||
</div>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem>
|
||||
<a
|
||||
className="w-full"
|
||||
href={`${baseUrl}api/${search.camera}/${clipTimeRange}/clip.mp4`}
|
||||
download
|
||||
>
|
||||
<div className="flex cursor-pointer items-center gap-2">
|
||||
<span>{t("itemMenu.downloadVideo.label")}</span>
|
||||
</div>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
{search.has_snapshot && (
|
||||
<DropdownMenuItem>
|
||||
<a
|
||||
className="w-full"
|
||||
href={`${baseUrl}api/events/${search.id}/snapshot.jpg?bbox=1`}
|
||||
download={`${search.camera}_${search.label}.jpg`}
|
||||
>
|
||||
<div className="flex cursor-pointer items-center gap-2">
|
||||
<span>{t("itemMenu.downloadSnapshot.label")}</span>
|
||||
</div>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{search.has_clip && (
|
||||
<DropdownMenuItem>
|
||||
<a
|
||||
className="w-full"
|
||||
href={`${baseUrl}api/${search.camera}/${clipTimeRange}/clip.mp4`}
|
||||
download
|
||||
>
|
||||
<div className="flex cursor-pointer items-center gap-2">
|
||||
<span>{t("itemMenu.downloadVideo.label")}</span>
|
||||
</div>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{config?.semantic_search.enabled &&
|
||||
setSimilarity != undefined &&
|
||||
|
||||
@ -34,9 +34,11 @@ import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import {
|
||||
FaArrowRight,
|
||||
FaCheckCircle,
|
||||
FaChevronDown,
|
||||
FaChevronLeft,
|
||||
FaChevronRight,
|
||||
FaMicrophone,
|
||||
FaCheck,
|
||||
FaTimes,
|
||||
} from "react-icons/fa";
|
||||
import { TrackingDetails } from "./TrackingDetails";
|
||||
import { AnnotationSettingsPane } from "./AnnotationSettingsPane";
|
||||
@ -72,7 +74,12 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/components/ui/drawer";
|
||||
import { LuInfo } from "react-icons/lu";
|
||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
import { FaPencilAlt } from "react-icons/fa";
|
||||
@ -84,6 +91,7 @@ import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||
import { DialogPortal } from "@radix-ui/react-dialog";
|
||||
import { useDetailStream } from "@/context/detail-stream-context";
|
||||
import { PiSlidersHorizontalBold } from "react-icons/pi";
|
||||
import { HiSparkles } from "react-icons/hi";
|
||||
|
||||
const SEARCH_TABS = ["snapshot", "tracking_details"] as const;
|
||||
export type SearchTab = (typeof SEARCH_TABS)[number];
|
||||
@ -126,7 +134,7 @@ function TabsWithActions({
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<ScrollArea className="flex-1 whitespace-nowrap">
|
||||
<div className="mb-2 flex flex-row md:mb-0">
|
||||
<div className="mb-2 flex flex-row">
|
||||
<ToggleGroup
|
||||
className="*:rounded-md *:px-3 *:py-4"
|
||||
type="single"
|
||||
@ -224,6 +232,7 @@ function AnnotationSettings({
|
||||
const Overlay = isDesktop ? Popover : Drawer;
|
||||
const Trigger = isDesktop ? PopoverTrigger : DrawerTrigger;
|
||||
const Content = isDesktop ? PopoverContent : DrawerContent;
|
||||
const Title = isDesktop ? "div" : DrawerTitle;
|
||||
const contentProps = isDesktop
|
||||
? { align: "end" as const, container: container ?? undefined }
|
||||
: {};
|
||||
@ -248,7 +257,9 @@ function AnnotationSettings({
|
||||
<PiSlidersHorizontalBold className="size-5" />
|
||||
</Button>
|
||||
</Trigger>
|
||||
|
||||
<Title className="sr-only">
|
||||
{t("trackingDetails.adjustAnnotationSettings")}
|
||||
</Title>
|
||||
<Content
|
||||
className={
|
||||
isDesktop
|
||||
@ -306,7 +317,7 @@ function DialogContentComponent({
|
||||
if (page === "tracking_details") {
|
||||
return (
|
||||
<TrackingDetails
|
||||
className={cn("size-full", !isDesktop && "flex flex-col gap-4")}
|
||||
className={cn(isDesktop ? "size-full" : "flex flex-col gap-4")}
|
||||
event={search as unknown as Event}
|
||||
tabs={
|
||||
isDesktop ? (
|
||||
@ -340,7 +351,12 @@ function DialogContentComponent({
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className={cn(!isDesktop ? "mb-4 w-full" : "size-full")}>
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-lg",
|
||||
!isDesktop ? "mb-4 w-full" : "mx-auto size-full",
|
||||
)}
|
||||
>
|
||||
<img
|
||||
className="w-full select-none rounded-lg object-contain transition-opacity"
|
||||
style={
|
||||
@ -359,16 +375,11 @@ function DialogContentComponent({
|
||||
|
||||
if (isDesktop) {
|
||||
return (
|
||||
<div className="flex h-full gap-4 overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"scrollbar-container flex-[3] overflow-y-hidden",
|
||||
!search.has_snapshot && "flex-[2]",
|
||||
)}
|
||||
>
|
||||
<div className="grid h-full w-full grid-cols-[60%_40%] gap-4">
|
||||
<div className="scrollbar-container min-w-0 overflow-y-auto overflow-x-hidden">
|
||||
{snapshotElement}
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 overflow-hidden md:basis-2/5">
|
||||
<div className="flex min-w-0 flex-col gap-4 pr-2">
|
||||
<TabsWithActions
|
||||
search={search}
|
||||
searchTabs={searchTabs}
|
||||
@ -381,7 +392,7 @@ function DialogContentComponent({
|
||||
setIsPopoverOpen={setIsPopoverOpen}
|
||||
dialogContainer={dialogContainer}
|
||||
/>
|
||||
<div className="scrollbar-container flex-1 overflow-y-auto">
|
||||
<div className="scrollbar-container min-w-0 flex-1 overflow-y-auto overflow-x-hidden px-4">
|
||||
<ObjectDetailsTab
|
||||
search={search}
|
||||
config={config}
|
||||
@ -584,8 +595,13 @@ export default function SearchDetailDialog({
|
||||
"scrollbar-container overflow-y-auto",
|
||||
isDesktop &&
|
||||
"max-h-[95dvh] sm:max-w-xl md:max-w-4xl lg:max-w-[70%]",
|
||||
isMobile && "px-4",
|
||||
isMobile && "flex h-full flex-col px-4",
|
||||
)}
|
||||
onEscapeKeyDown={(event) => {
|
||||
if (isPopoverOpen) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
onInteractOutside={(e) => {
|
||||
if (isPopoverOpen) {
|
||||
e.preventDefault();
|
||||
@ -596,7 +612,7 @@ export default function SearchDetailDialog({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Header>
|
||||
<Header className={cn(!isDesktop && "top-0 z-[60] mb-0")}>
|
||||
<Title>{t("trackedObjectDetails")}</Title>
|
||||
<Description className="sr-only">
|
||||
{t("trackedObjectDetails")}
|
||||
@ -676,6 +692,8 @@ function ObjectDetailsTab({
|
||||
const [desc, setDesc] = useState(search?.data.description);
|
||||
const [isSubLabelDialogOpen, setIsSubLabelDialogOpen] = useState(false);
|
||||
const [isLPRDialogOpen, setIsLPRDialogOpen] = useState(false);
|
||||
const [isEditingDesc, setIsEditingDesc] = useState(false);
|
||||
const originalDescRef = useRef<string | null>(null);
|
||||
|
||||
const handleDescriptionFocus = useCallback(() => {
|
||||
setInputFocused(true);
|
||||
@ -1078,15 +1096,51 @@ function ObjectDetailsTab({
|
||||
});
|
||||
|
||||
setState("submitted");
|
||||
setSearch({
|
||||
...search,
|
||||
plus_id: "new_upload",
|
||||
});
|
||||
mutate(
|
||||
(key) =>
|
||||
typeof key === "string" &&
|
||||
(key.includes("events") ||
|
||||
key.includes("events/search") ||
|
||||
key.includes("events/explore")),
|
||||
(currentData: SearchResult[][] | SearchResult[] | undefined) => {
|
||||
if (!currentData) return currentData;
|
||||
// optimistic update
|
||||
return currentData
|
||||
.flat()
|
||||
.map((event) =>
|
||||
event.id === search.id
|
||||
? { ...event, plus_id: "new_upload" }
|
||||
: event,
|
||||
);
|
||||
},
|
||||
{
|
||||
optimisticData: true,
|
||||
rollbackOnError: true,
|
||||
revalidate: false,
|
||||
},
|
||||
);
|
||||
},
|
||||
[search, setSearch],
|
||||
[search, mutate],
|
||||
);
|
||||
|
||||
const popoverContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const canRegenerate = !!(
|
||||
config?.cameras[search.camera].objects.genai.enabled && search.end_time
|
||||
);
|
||||
const showGenAIPlaceholder = !!(
|
||||
config?.cameras[search.camera].objects.genai.enabled &&
|
||||
!search.end_time &&
|
||||
(config.cameras[search.camera].objects.genai.required_zones.length === 0 ||
|
||||
search.zones.some((zone) =>
|
||||
config.cameras[search.camera].objects.genai.required_zones.includes(
|
||||
zone,
|
||||
),
|
||||
)) &&
|
||||
(config.cameras[search.camera].objects.genai.objects.length === 0 ||
|
||||
config.cameras[search.camera].objects.genai.objects.includes(
|
||||
search.label,
|
||||
))
|
||||
);
|
||||
return (
|
||||
<div ref={popoverContainerRef} className="flex flex-col gap-5">
|
||||
<div className="flex w-full flex-row">
|
||||
@ -1243,8 +1297,8 @@ function ObjectDetailsTab({
|
||||
</div>
|
||||
|
||||
{search.data.type === "object" &&
|
||||
!search.plus_id &&
|
||||
config?.plus?.enabled && (
|
||||
config?.plus?.enabled &&
|
||||
search.has_snapshot && (
|
||||
<div
|
||||
className={cn(
|
||||
"my-2 flex w-full flex-col justify-between gap-1.5",
|
||||
@ -1347,75 +1401,68 @@ function ObjectDetailsTab({
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{config?.cameras[search.camera].objects.genai.enabled &&
|
||||
!search.end_time &&
|
||||
(config.cameras[search.camera].objects.genai.required_zones.length ===
|
||||
0 ||
|
||||
search.zones.some((zone) =>
|
||||
config.cameras[search.camera].objects.genai.required_zones.includes(
|
||||
zone,
|
||||
),
|
||||
)) &&
|
||||
(config.cameras[search.camera].objects.genai.objects.length === 0 ||
|
||||
config.cameras[search.camera].objects.genai.objects.includes(
|
||||
search.label,
|
||||
)) ? (
|
||||
<>
|
||||
<div className="text-sm text-primary/40">
|
||||
{t("details.description.label")}
|
||||
</div>
|
||||
<div className="flex h-64 flex-col items-center justify-center gap-3 border p-4 text-sm text-primary/40">
|
||||
<div className="flex">
|
||||
<ActivityIndicator />
|
||||
</div>
|
||||
<div className="flex">{t("details.description.aiTips")}</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-sm text-primary/40"></div>
|
||||
<Textarea
|
||||
className="text-md h-64"
|
||||
placeholder={t("details.description.placeholder")}
|
||||
value={desc}
|
||||
onChange={(e) => setDesc(e.target.value)}
|
||||
onFocus={handleDescriptionFocus}
|
||||
onBlur={handleDescriptionBlur}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex w-full flex-row justify-end gap-2">
|
||||
{config?.cameras[search?.camera].audio_transcription.enabled &&
|
||||
search?.label == "speech" &&
|
||||
search?.end_time && (
|
||||
<Button onClick={onTranscribe}>
|
||||
<div className="flex gap-1">
|
||||
{t("itemMenu.audioTranscription.label")}
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
{config?.cameras[search.camera].objects.genai.enabled &&
|
||||
search.end_time && (
|
||||
<div className="flex items-start">
|
||||
<Button
|
||||
className="rounded-r-none border-r-0"
|
||||
aria-label={t("details.button.regenerate.label")}
|
||||
onClick={() => regenerateDescription("thumbnails")}
|
||||
<div className="flex items-center justify-start gap-3">
|
||||
<div className="text-sm text-primary/40">
|
||||
{t("details.description.label")}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
aria-label={t("button.edit", { ns: "common" })}
|
||||
className="text-primary/40 hover:text-primary/80"
|
||||
onClick={() => {
|
||||
originalDescRef.current = desc ?? "";
|
||||
setIsEditingDesc(true);
|
||||
}}
|
||||
>
|
||||
{t("details.button.regenerate.title")}
|
||||
</Button>
|
||||
{search.has_snapshot && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="rounded-l-none border-l-0 px-2"
|
||||
aria-label={t("details.expandRegenerationMenu")}
|
||||
>
|
||||
<FaChevronDown className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<FaPencilAlt className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("button.edit", { ns: "common" })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{config?.cameras[search?.camera].audio_transcription.enabled &&
|
||||
search?.label == "speech" &&
|
||||
search?.end_time && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
aria-label={t("itemMenu.audioTranscription.label")}
|
||||
className="text-primary/40 hover:text-primary/80"
|
||||
onClick={onTranscribe}
|
||||
>
|
||||
<FaMicrophone className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("itemMenu.audioTranscription.label")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{canRegenerate && (
|
||||
<div className="relative">
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
aria-label={t("details.button.regenerate.label")}
|
||||
className="text-primary/40 hover:text-primary/80"
|
||||
>
|
||||
<HiSparkles className="size-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("details.button.regenerate.title")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent>
|
||||
{search.has_snapshot && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
aria-label={t("details.regenerateFromSnapshot")}
|
||||
@ -1423,61 +1470,115 @@ function ObjectDetailsTab({
|
||||
>
|
||||
{t("details.regenerateFromSnapshot")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
aria-label={t("details.regenerateFromThumbnails")}
|
||||
onClick={() => regenerateDescription("thumbnails")}
|
||||
>
|
||||
{t("details.regenerateFromThumbnails")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
aria-label={t("details.regenerateFromThumbnails")}
|
||||
onClick={() => regenerateDescription("thumbnails")}
|
||||
>
|
||||
{t("details.regenerateFromThumbnails")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
{((config?.cameras[search.camera].objects.genai.enabled &&
|
||||
search.end_time) ||
|
||||
!config?.cameras[search.camera].objects.genai.enabled) && (
|
||||
<Button
|
||||
variant="select"
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
onClick={updateDescription}
|
||||
>
|
||||
{t("button.save", { ns: "common" })}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<TextEntryDialog
|
||||
open={isSubLabelDialogOpen}
|
||||
setOpen={setIsSubLabelDialogOpen}
|
||||
title={t("details.editSubLabel.title")}
|
||||
description={
|
||||
search.label
|
||||
? t("details.editSubLabel.desc", {
|
||||
label: search.label,
|
||||
})
|
||||
: t("details.editSubLabel.descNoLabel")
|
||||
}
|
||||
onSave={handleSubLabelSave}
|
||||
defaultValue={search?.sub_label || ""}
|
||||
allowEmpty={true}
|
||||
/>
|
||||
<TextEntryDialog
|
||||
open={isLPRDialogOpen}
|
||||
setOpen={setIsLPRDialogOpen}
|
||||
title={t("details.editLPR.title")}
|
||||
description={
|
||||
search.label
|
||||
? t("details.editLPR.desc", {
|
||||
label: search.label,
|
||||
})
|
||||
: t("details.editLPR.descNoLabel")
|
||||
}
|
||||
onSave={handleLPRSave}
|
||||
defaultValue={search?.data.recognized_license_plate || ""}
|
||||
allowEmpty={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isEditingDesc ? (
|
||||
showGenAIPlaceholder ? (
|
||||
<div className="flex h-32 flex-col items-center justify-center gap-3 border p-4 text-sm text-primary/40">
|
||||
<div className="flex">
|
||||
<ActivityIndicator />
|
||||
</div>
|
||||
<div className="flex">{t("details.description.aiTips")}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-auto text-sm text-primary">
|
||||
{desc || t("label.none", { ns: "common" })}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Textarea
|
||||
className="text-md h-32"
|
||||
placeholder={t("details.description.placeholder")}
|
||||
value={desc}
|
||||
onChange={(e) => setDesc(e.target.value)}
|
||||
onFocus={handleDescriptionFocus}
|
||||
onBlur={handleDescriptionBlur}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex flex-row justify-end gap-4">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
aria-label={t("button.save", { ns: "common" })}
|
||||
className="text-primary/40 hover:text-primary/80"
|
||||
onClick={() => {
|
||||
setIsEditingDesc(false);
|
||||
updateDescription();
|
||||
}}
|
||||
>
|
||||
<FaCheck className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("button.save", { ns: "common" })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
className="text-primary/40 hover:text-primary"
|
||||
onClick={() => {
|
||||
setIsEditingDesc(false);
|
||||
setDesc(originalDescRef.current ?? "");
|
||||
}}
|
||||
>
|
||||
<FaTimes className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TextEntryDialog
|
||||
open={isSubLabelDialogOpen}
|
||||
setOpen={setIsSubLabelDialogOpen}
|
||||
title={t("details.editSubLabel.title")}
|
||||
description={
|
||||
search.label
|
||||
? t("details.editSubLabel.desc", {
|
||||
label: search.label,
|
||||
})
|
||||
: t("details.editSubLabel.descNoLabel")
|
||||
}
|
||||
onSave={handleSubLabelSave}
|
||||
defaultValue={search?.sub_label || ""}
|
||||
allowEmpty={true}
|
||||
/>
|
||||
<TextEntryDialog
|
||||
open={isLPRDialogOpen}
|
||||
setOpen={setIsLPRDialogOpen}
|
||||
title={t("details.editLPR.title")}
|
||||
description={
|
||||
search.label
|
||||
? t("details.editLPR.desc", {
|
||||
label: search.label,
|
||||
})
|
||||
: t("details.editLPR.descNoLabel")
|
||||
}
|
||||
onSave={handleLPRSave}
|
||||
defaultValue={search?.data.recognized_license_plate || ""}
|
||||
allowEmpty={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -352,7 +352,8 @@ export function TrackingDetails({
|
||||
className={cn(
|
||||
isDesktop
|
||||
? "flex size-full justify-evenly gap-4 overflow-hidden"
|
||||
: "flex size-full flex-col gap-2",
|
||||
: "flex flex-col gap-2",
|
||||
!isDesktop && cameraAspect === "tall" && "size-full",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@ -453,7 +454,7 @@ export function TrackingDetails({
|
||||
)}
|
||||
>
|
||||
{isDesktop && tabs && (
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex-1">{tabs}</div>
|
||||
</div>
|
||||
)}
|
||||
@ -719,9 +720,13 @@ function LifecycleIconRow({
|
||||
backgroundColor: `rgb(${color})`,
|
||||
}}
|
||||
/>
|
||||
<span className="smart-capitalize">
|
||||
{item.data?.zones_friendly_names?.[zidx] ??
|
||||
zone.replaceAll("_", " ")}
|
||||
<span
|
||||
className={cn(
|
||||
item.data?.zones_friendly_names?.[zidx] === zone &&
|
||||
"smart-capitalize",
|
||||
)}
|
||||
>
|
||||
{item.data?.zones_friendly_names?.[zidx]}
|
||||
</span>
|
||||
</Badge>
|
||||
);
|
||||
|
||||
@ -576,6 +576,7 @@ export default function ZoneEditPane({
|
||||
control={form.control}
|
||||
nameField="friendly_name"
|
||||
idField="name"
|
||||
idVisible={(polygon && polygon.name.length > 0) ?? false}
|
||||
nameLabel={t("masksAndZones.zones.name.title")}
|
||||
nameDescription={t("masksAndZones.zones.name.tips")}
|
||||
placeholderName={t("masksAndZones.zones.name.inputPlaceHolder")}
|
||||
|
||||
@ -15,7 +15,7 @@ import useSWR from "swr";
|
||||
import ActivityIndicator from "../indicators/activity-indicator";
|
||||
import { Event } from "@/types/event";
|
||||
import { getIconForLabel } from "@/utils/iconUtil";
|
||||
import { ReviewSegment } from "@/types/review";
|
||||
import { REVIEW_PADDING, ReviewSegment } from "@/types/review";
|
||||
import { LuChevronDown, LuCircle, LuChevronRight } from "react-icons/lu";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
import EventMenu from "@/components/timeline/EventMenu";
|
||||
@ -391,8 +391,8 @@ function ReviewGroup({
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mr-3 flex w-full justify-between">
|
||||
<div className="ml-1 flex flex-col items-start gap-1.5">
|
||||
<div className="mr-3 grid w-full grid-cols-[1fr_auto] gap-2">
|
||||
<div className="ml-1 flex min-w-0 flex-col gap-1.5">
|
||||
<div className="flex flex-row gap-3">
|
||||
<div className="text-sm font-medium">{displayTime}</div>
|
||||
<div className="relative flex items-center gap-2 text-white">
|
||||
@ -408,7 +408,7 @@ function ReviewGroup({
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{review.data.metadata?.title && (
|
||||
<div className="mb-1 flex items-center gap-1 text-sm text-primary-variant">
|
||||
<div className="mb-1 flex min-w-0 items-center gap-1 text-sm text-primary-variant">
|
||||
<MdAutoAwesome className="size-3 shrink-0" />
|
||||
<span className="truncate">{review.data.metadata.title}</span>
|
||||
</div>
|
||||
@ -432,7 +432,7 @@ function ReviewGroup({
|
||||
e.stopPropagation();
|
||||
setOpen((v) => !v);
|
||||
}}
|
||||
className="ml-2 inline-flex items-center justify-center rounded p-1 hover:bg-secondary/10"
|
||||
className="inline-flex items-center justify-center self-center rounded p-1 hover:bg-secondary/10"
|
||||
>
|
||||
{open ? (
|
||||
<LuChevronDown className="size-4 text-primary-variant" />
|
||||
@ -803,8 +803,9 @@ function ObjectTimeline({
|
||||
return fullTimeline
|
||||
.filter(
|
||||
(t) =>
|
||||
t.timestamp >= review.start_time &&
|
||||
(review.end_time == undefined || t.timestamp <= review.end_time),
|
||||
t.timestamp >= review.start_time - REVIEW_PADDING &&
|
||||
(review.end_time == undefined ||
|
||||
t.timestamp <= review.end_time + REVIEW_PADDING),
|
||||
)
|
||||
.map((event) => ({
|
||||
...event,
|
||||
|
||||
@ -515,7 +515,7 @@ export function ReviewTimeline({
|
||||
<div
|
||||
className={`absolute z-30 flex gap-2 ${
|
||||
isMobile
|
||||
? "bottom-4 right-1 flex-col gap-3"
|
||||
? "bottom-4 right-1 flex-col-reverse gap-3"
|
||||
: "bottom-2 left-1/2 -translate-x-1/2"
|
||||
}`}
|
||||
>
|
||||
|
||||
@ -622,7 +622,15 @@ type TrainingGridProps = {
|
||||
faceNames: string[];
|
||||
selectedFaces: string[];
|
||||
onClickFaces: (images: string[], ctrl: boolean) => void;
|
||||
onRefresh: () => void;
|
||||
onRefresh: (
|
||||
data?:
|
||||
| FaceLibraryData
|
||||
| Promise<FaceLibraryData>
|
||||
| ((
|
||||
currentData: FaceLibraryData | undefined,
|
||||
) => FaceLibraryData | undefined),
|
||||
opts?: boolean | { revalidate?: boolean },
|
||||
) => Promise<FaceLibraryData | undefined>;
|
||||
};
|
||||
function TrainingGrid({
|
||||
config,
|
||||
@ -726,7 +734,15 @@ type FaceAttemptGroupProps = {
|
||||
faceNames: string[];
|
||||
selectedFaces: string[];
|
||||
onClickFaces: (image: string[], ctrl: boolean) => void;
|
||||
onRefresh: () => void;
|
||||
onRefresh: (
|
||||
data?:
|
||||
| FaceLibraryData
|
||||
| Promise<FaceLibraryData>
|
||||
| ((
|
||||
currentData: FaceLibraryData | undefined,
|
||||
) => FaceLibraryData | undefined),
|
||||
opts?: boolean | { revalidate?: boolean },
|
||||
) => Promise<FaceLibraryData | undefined>;
|
||||
};
|
||||
function FaceAttemptGroup({
|
||||
config,
|
||||
@ -814,11 +830,44 @@ function FaceAttemptGroup({
|
||||
axios
|
||||
.post(`/faces/reprocess`, { training_file: data.filename })
|
||||
.then((resp) => {
|
||||
if (resp.status == 200) {
|
||||
toast.success(t("toast.success.updatedFaceScore"), {
|
||||
position: "top-center",
|
||||
});
|
||||
onRefresh();
|
||||
if (resp.status == 200 && resp.data?.success) {
|
||||
const { face_name, score } = resp.data;
|
||||
const oldFilename = data.filename;
|
||||
const parts = oldFilename.split("-");
|
||||
const newFilename = `${parts[0]}-${parts[1]}-${parts[2]}-${face_name}-${score}.webp`;
|
||||
|
||||
onRefresh(
|
||||
(currentData: FaceLibraryData | undefined) => {
|
||||
if (!currentData?.train) return currentData;
|
||||
|
||||
return {
|
||||
...currentData,
|
||||
train: currentData.train.map((filename: string) =>
|
||||
filename === oldFilename ? newFilename : filename,
|
||||
),
|
||||
};
|
||||
},
|
||||
{ revalidate: true },
|
||||
);
|
||||
|
||||
toast.success(
|
||||
t("toast.success.updatedFaceScore", {
|
||||
name: face_name,
|
||||
score: score.toFixed(2),
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
} else if (resp.data?.success === false) {
|
||||
// Handle case where API returns success: false
|
||||
const errorMessage = resp.data?.message || "Unknown error";
|
||||
toast.error(
|
||||
t("toast.error.updateFaceScoreFailed", { errorMessage }),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
@ -87,7 +87,8 @@ export type ModelState =
|
||||
| "downloaded"
|
||||
| "error"
|
||||
| "training"
|
||||
| "complete";
|
||||
| "complete"
|
||||
| "failed";
|
||||
|
||||
export type EmbeddingsReindexProgressType = {
|
||||
thumbnails: number;
|
||||
|
||||
@ -21,20 +21,30 @@ export const capitalizeAll = (text: string): string => {
|
||||
* @returns A valid camera identifier (lowercase, alphanumeric, max 8 chars)
|
||||
*/
|
||||
export function generateFixedHash(name: string, prefix: string = "id"): string {
|
||||
// Safely encode Unicode as UTF-8 bytes
|
||||
// Use the full UTF-8 bytes of the name and compute an FNV-1a 32-bit hash.
|
||||
// This is deterministic, fast, works with Unicode and avoids collisions from
|
||||
// simple truncation of base64 output.
|
||||
const utf8Bytes = new TextEncoder().encode(name);
|
||||
|
||||
// Convert to base64 manually
|
||||
let binary = "";
|
||||
for (const byte of utf8Bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
// FNV-1a 32-bit hash algorithm
|
||||
let hash = 0x811c9dc5; // FNV offset basis
|
||||
for (let i = 0; i < utf8Bytes.length; i++) {
|
||||
hash ^= utf8Bytes[i];
|
||||
// Multiply by FNV prime (0x01000193) with 32-bit overflow
|
||||
hash = (hash >>> 0) * 0x01000193;
|
||||
// Ensure 32-bit unsigned integer
|
||||
hash >>>= 0;
|
||||
}
|
||||
const base64 = btoa(binary);
|
||||
|
||||
// Strip out non-alphanumeric characters and truncate
|
||||
const cleanHash = base64.replace(/[^a-zA-Z0-9]/g, "").substring(0, 8);
|
||||
// Convert to an 8-character lowercase hex string
|
||||
const hashHex = (hash >>> 0).toString(16).padStart(8, "0").toLowerCase();
|
||||
|
||||
return `${prefix}_${cleanHash.toLowerCase()}`;
|
||||
// Ensure the first character is a letter to avoid an identifier that's purely
|
||||
// numeric (isValidId forbids all-digit IDs). If it starts with a digit,
|
||||
// replace with 'a'. This is extremely unlikely but a simple safeguard.
|
||||
const safeHash = /^[0-9]/.test(hashHex[0]) ? `a${hashHex.slice(1)}` : hashHex;
|
||||
|
||||
return `${prefix}_${safeHash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -102,6 +102,12 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
|
||||
position: "top-center",
|
||||
});
|
||||
setWasTraining(false);
|
||||
refreshDataset();
|
||||
} else if (modelState == "failed") {
|
||||
toast.error(t("toast.error.trainingFailed"), {
|
||||
position: "top-center",
|
||||
});
|
||||
setWasTraining(false);
|
||||
}
|
||||
// only refresh when modelState changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@ -112,10 +118,20 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
|
||||
const { data: trainImages, mutate: refreshTrain } = useSWR<string[]>(
|
||||
`classification/${model.name}/train`,
|
||||
);
|
||||
const { data: dataset, mutate: refreshDataset } = useSWR<{
|
||||
[id: string]: string[];
|
||||
const { data: datasetResponse, mutate: refreshDataset } = useSWR<{
|
||||
categories: { [id: string]: string[] };
|
||||
training_metadata: {
|
||||
has_trained: boolean;
|
||||
last_training_date: string | null;
|
||||
last_training_image_count: number;
|
||||
current_image_count: number;
|
||||
new_images_count: number;
|
||||
} | null;
|
||||
}>(`classification/${model.name}/dataset`);
|
||||
|
||||
const dataset = datasetResponse?.categories || {};
|
||||
const trainingMetadata = datasetResponse?.training_metadata;
|
||||
|
||||
const [trainFilter, setTrainFilter] = useApiFilter<TrainFilter>();
|
||||
|
||||
const refreshAll = useCallback(() => {
|
||||
@ -177,7 +193,7 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
|
||||
toast.error(t("toast.error.trainingFailed", { errorMessage }), {
|
||||
toast.error(t("toast.error.trainingFailedToStart", { errorMessage }), {
|
||||
position: "top-center",
|
||||
});
|
||||
});
|
||||
@ -187,6 +203,37 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
|
||||
null,
|
||||
);
|
||||
|
||||
const onRename = useCallback(
|
||||
(old_name: string, new_name: string) => {
|
||||
axios
|
||||
.put(`/classification/${model.name}/dataset/${old_name}/rename`, {
|
||||
new_category: new_name,
|
||||
})
|
||||
.then((resp) => {
|
||||
if (resp.status == 200) {
|
||||
toast.success(
|
||||
t("toast.success.renamedCategory", { name: new_name }),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
setPageToggle(new_name);
|
||||
refreshDataset();
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(t("toast.error.renameCategoryFailed", { errorMessage }), {
|
||||
position: "top-center",
|
||||
});
|
||||
});
|
||||
},
|
||||
[model, setPageToggle, refreshDataset, t],
|
||||
);
|
||||
|
||||
const onDelete = useCallback(
|
||||
(ids: string[], isName: boolean = false, category?: string) => {
|
||||
const targetCategory = category || pageToggle;
|
||||
@ -354,7 +401,7 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
|
||||
trainImages={trainImages || []}
|
||||
setPageToggle={setPageToggle}
|
||||
onDelete={onDelete}
|
||||
onRename={() => {}}
|
||||
onRename={onRename}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@ -390,19 +437,48 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
|
||||
filterValues={{ classes: Object.keys(dataset || {}) }}
|
||||
onUpdateFilter={setTrainFilter}
|
||||
/>
|
||||
<Button
|
||||
className="flex justify-center gap-2"
|
||||
onClick={trainModel}
|
||||
variant="select"
|
||||
disabled={modelState != "complete"}
|
||||
>
|
||||
{modelState == "training" ? (
|
||||
<ActivityIndicator size={20} />
|
||||
) : (
|
||||
<HiSparkles className="text-white" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="flex justify-center gap-2"
|
||||
onClick={trainModel}
|
||||
variant={modelState == "failed" ? "destructive" : "select"}
|
||||
disabled={
|
||||
(modelState != "complete" && modelState != "failed") ||
|
||||
(trainingMetadata?.new_images_count ?? 0) === 0
|
||||
}
|
||||
>
|
||||
{modelState == "training" ? (
|
||||
<ActivityIndicator size={20} />
|
||||
) : (
|
||||
<HiSparkles className="text-white" />
|
||||
)}
|
||||
{isDesktop && (
|
||||
<>
|
||||
{t("button.trainModel")}
|
||||
{trainingMetadata?.new_images_count !== undefined &&
|
||||
trainingMetadata.new_images_count > 0 && (
|
||||
<span className="text-sm text-selected-foreground">
|
||||
({trainingMetadata.new_images_count})
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
{((trainingMetadata?.new_images_count ?? 0) === 0 ||
|
||||
(modelState != "complete" && modelState != "failed")) && (
|
||||
<TooltipPortal>
|
||||
<TooltipContent>
|
||||
{modelState == "training"
|
||||
? t("tooltip.trainingInProgress")
|
||||
: trainingMetadata?.new_images_count === 0
|
||||
? t("tooltip.noNewImages")
|
||||
: t("tooltip.modelNotReady")}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
)}
|
||||
{isDesktop && t("button.trainModel")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -534,7 +610,7 @@ function LibrarySelector({
|
||||
regexErrorMessage={t("description.invalidName")}
|
||||
/>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="flex justify-between smart-capitalize">
|
||||
{pageTitle}
|
||||
@ -585,48 +661,50 @@ function LibrarySelector({
|
||||
({dataset?.[id].length})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-0.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 lg:opacity-0 lg:transition-opacity lg:group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setRenameClass(id);
|
||||
}}
|
||||
>
|
||||
<LuPencil className="size-4 text-primary" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>
|
||||
{t("button.renameCategory")}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 lg:opacity-0 lg:transition-opacity lg:group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmDelete(id);
|
||||
}}
|
||||
>
|
||||
<LuTrash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>
|
||||
{t("button.deleteCategory")}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{id != "none" && (
|
||||
<div className="flex gap-0.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 lg:opacity-0 lg:transition-opacity lg:group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setRenameClass(id);
|
||||
}}
|
||||
>
|
||||
<LuPencil className="size-4 text-primary" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>
|
||||
{t("button.renameCategory")}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 lg:opacity-0 lg:transition-opacity lg:group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmDelete(id);
|
||||
}}
|
||||
>
|
||||
<LuTrash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>
|
||||
{t("button.deleteCategory")}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
@ -745,17 +823,11 @@ function TrainGrid({
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
trainFilter.min_score &&
|
||||
trainFilter.min_score > data.score / 100.0
|
||||
) {
|
||||
if (trainFilter.min_score && trainFilter.min_score > data.score) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
trainFilter.max_score &&
|
||||
trainFilter.max_score < data.score / 100.0
|
||||
) {
|
||||
if (trainFilter.max_score && trainFilter.max_score < data.score) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@ -98,12 +98,12 @@ export default function CameraSettingsView({
|
||||
return Object.entries(cameraConfig.zones).map(([name, zoneData]) => ({
|
||||
camera: cameraConfig.name,
|
||||
name,
|
||||
friendly_name: getZoneName(name, cameraConfig.name),
|
||||
friendly_name: cameraConfig.zones[name].friendly_name,
|
||||
objects: zoneData.objects,
|
||||
color: zoneData.color,
|
||||
}));
|
||||
}
|
||||
}, [cameraConfig, getZoneName]);
|
||||
}, [cameraConfig]);
|
||||
|
||||
const alertsLabels = useMemo(() => {
|
||||
return cameraConfig?.review.alerts.labels
|
||||
@ -533,8 +533,14 @@ export default function CameraSettingsView({
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal smart-capitalize">
|
||||
{zone.friendly_name}
|
||||
<FormLabel
|
||||
className={cn(
|
||||
"font-normal",
|
||||
!zone.friendly_name &&
|
||||
"smart-capitalize",
|
||||
)}
|
||||
>
|
||||
{zone.friendly_name || zone.name}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
@ -632,8 +638,14 @@ export default function CameraSettingsView({
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal smart-capitalize">
|
||||
{zone.friendly_name}
|
||||
<FormLabel
|
||||
className={cn(
|
||||
"font-normal",
|
||||
!zone.friendly_name &&
|
||||
"smart-capitalize",
|
||||
)}
|
||||
>
|
||||
{zone.friendly_name || zone.name}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user