mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-04 20:47:42 +03:00
Merge branch 'dev' of https://github.com/blakeblackshear/frigate into motion_improvements
This commit is contained in:
commit
7d0d5efac5
@ -8,9 +8,25 @@
|
|||||||
"overrideCommand": false,
|
"overrideCommand": false,
|
||||||
"remoteUser": "vscode",
|
"remoteUser": "vscode",
|
||||||
"features": {
|
"features": {
|
||||||
"ghcr.io/devcontainers/features/common-utils:1": {}
|
"ghcr.io/devcontainers/features/common-utils:2": {}
|
||||||
|
// Uncomment the following lines to use ONNX Runtime with CUDA support
|
||||||
|
// "ghcr.io/devcontainers/features/nvidia-cuda:1": {
|
||||||
|
// "installCudnn": true,
|
||||||
|
// "installNvtx": true,
|
||||||
|
// "installToolkit": true,
|
||||||
|
// "cudaVersion": "12.5",
|
||||||
|
// "cudnnVersion": "9.4.0.58"
|
||||||
|
// },
|
||||||
|
// "./features/onnxruntime-gpu": {}
|
||||||
},
|
},
|
||||||
"forwardPorts": [8971, 5000, 5001, 5173, 8554, 8555],
|
"forwardPorts": [
|
||||||
|
8971,
|
||||||
|
5000,
|
||||||
|
5001,
|
||||||
|
5173,
|
||||||
|
8554,
|
||||||
|
8555
|
||||||
|
],
|
||||||
"portsAttributes": {
|
"portsAttributes": {
|
||||||
"8971": {
|
"8971": {
|
||||||
"label": "External NGINX",
|
"label": "External NGINX",
|
||||||
@ -64,10 +80,18 @@
|
|||||||
"editor.formatOnType": true,
|
"editor.formatOnType": true,
|
||||||
"python.testing.pytestEnabled": false,
|
"python.testing.pytestEnabled": false,
|
||||||
"python.testing.unittestEnabled": true,
|
"python.testing.unittestEnabled": true,
|
||||||
"python.testing.unittestArgs": ["-v", "-s", "./frigate/test"],
|
"python.testing.unittestArgs": [
|
||||||
|
"-v",
|
||||||
|
"-s",
|
||||||
|
"./frigate/test"
|
||||||
|
],
|
||||||
"files.trimTrailingWhitespace": true,
|
"files.trimTrailingWhitespace": true,
|
||||||
"eslint.workingDirectories": ["./web"],
|
"eslint.workingDirectories": [
|
||||||
"isort.args": ["--settings-path=./pyproject.toml"],
|
"./web"
|
||||||
|
],
|
||||||
|
"isort.args": [
|
||||||
|
"--settings-path=./pyproject.toml"
|
||||||
|
],
|
||||||
"[python]": {
|
"[python]": {
|
||||||
"editor.defaultFormatter": "charliermarsh.ruff",
|
"editor.defaultFormatter": "charliermarsh.ruff",
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
@ -86,8 +110,15 @@
|
|||||||
],
|
],
|
||||||
"editor.tabSize": 2
|
"editor.tabSize": 2
|
||||||
},
|
},
|
||||||
"cSpell.ignoreWords": ["rtmp"],
|
"cSpell.ignoreWords": [
|
||||||
"cSpell.words": ["preact", "astype", "hwaccel", "mqtt"]
|
"rtmp"
|
||||||
|
],
|
||||||
|
"cSpell.words": [
|
||||||
|
"preact",
|
||||||
|
"astype",
|
||||||
|
"hwaccel",
|
||||||
|
"mqtt"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"id": "onnxruntime-gpu",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"name": "ONNX Runtime GPU (Nvidia)",
|
||||||
|
"description": "Installs ONNX Runtime for Nvidia GPUs.",
|
||||||
|
"documentationURL": "",
|
||||||
|
"options": {
|
||||||
|
"version": {
|
||||||
|
"type": "string",
|
||||||
|
"proposals": [
|
||||||
|
"latest",
|
||||||
|
"1.20.1",
|
||||||
|
"1.20.0"
|
||||||
|
],
|
||||||
|
"default": "latest",
|
||||||
|
"description": "Version of ONNX Runtime to install"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"installsAfter": [
|
||||||
|
"ghcr.io/devcontainers/features/nvidia-cuda"
|
||||||
|
]
|
||||||
|
}
|
||||||
15
.devcontainer/features/onnxruntime-gpu/install.sh
Normal file
15
.devcontainer/features/onnxruntime-gpu/install.sh
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
VERSION=${VERSION}
|
||||||
|
|
||||||
|
python3 -m pip config set global.break-system-packages true
|
||||||
|
# if VERSION == "latest" or VERSION is empty, install the latest version
|
||||||
|
if [ "$VERSION" == "latest" ] || [ -z "$VERSION" ]; then
|
||||||
|
python3 -m pip install onnxruntime-gpu
|
||||||
|
else
|
||||||
|
python3 -m pip install onnxruntime-gpu==$VERSION
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Done!"
|
||||||
@ -41,6 +41,8 @@ lpr:
|
|||||||
|
|
||||||
Ensure that your camera is configured to detect objects of type `car`, and that a car is actually being detected by Frigate. Otherwise, LPR will not run.
|
Ensure that your camera is configured to detect objects of type `car`, and that a car is actually being detected by Frigate. Otherwise, LPR will not run.
|
||||||
|
|
||||||
|
Like the other real-time processors in Frigate, license plate recognition runs on the camera stream defined by the `detect` role in your config. To ensure optimal performance, select a suitable resolution for this stream in your camera's firmware that fits your specific scene and requirements.
|
||||||
|
|
||||||
## Advanced Configuration
|
## Advanced Configuration
|
||||||
|
|
||||||
Fine-tune the LPR feature using these optional parameters:
|
Fine-tune the LPR feature using these optional parameters:
|
||||||
@ -52,7 +54,7 @@ Fine-tune the LPR feature using these optional parameters:
|
|||||||
- Note: If you are using a Frigate+ model and you set the `threshold` in your objects config for `license_plate` higher than this value, recognition will never run. It's best to ensure these values match, or this `detection_threshold` is lower than your object config `threshold`.
|
- Note: If you are using a Frigate+ model and you set the `threshold` in your objects config for `license_plate` higher than this value, recognition will never run. It's best to ensure these values match, or this `detection_threshold` is lower than your object config `threshold`.
|
||||||
- **`min_area`**: Defines the minimum size (in pixels) a license plate must be before recognition runs.
|
- **`min_area`**: Defines the minimum size (in pixels) a license plate must be before recognition runs.
|
||||||
- Default: `1000` pixels.
|
- Default: `1000` pixels.
|
||||||
- Depending on the resolution of your cameras, you can increase this value to ignore small or distant plates.
|
- Depending on the resolution of your camera's `detect` stream, you can increase this value to ignore small or distant plates.
|
||||||
|
|
||||||
### Recognition
|
### Recognition
|
||||||
|
|
||||||
@ -114,7 +116,7 @@ lpr:
|
|||||||
Ensure that:
|
Ensure that:
|
||||||
|
|
||||||
- Your camera has a clear, well-lit view of the plate.
|
- Your camera has a clear, well-lit view of the plate.
|
||||||
- The plate is large enough in the image (try adjusting `min_area`).
|
- The plate is large enough in the image (try adjusting `min_area`) or increasing the resolution of your camera's stream.
|
||||||
- A `car` is detected first, as LPR only runs on recognized vehicles.
|
- A `car` is detected first, as LPR only runs on recognized vehicles.
|
||||||
|
|
||||||
If you are using a Frigate+ model or a custom model that detects license plates, ensure that `license_plate` is added to your list of objects to track.
|
If you are using a Frigate+ model or a custom model that detects license plates, ensure that `license_plate` is added to your list of objects to track.
|
||||||
@ -143,7 +145,7 @@ Use `match_distance` to allow small character mismatches. Alternatively, define
|
|||||||
- View MQTT messages for `frigate/events` to verify detected plates.
|
- View MQTT messages for `frigate/events` to verify detected plates.
|
||||||
- Adjust `detection_threshold` and `recognition_threshold` settings.
|
- Adjust `detection_threshold` and `recognition_threshold` settings.
|
||||||
- If you are using a Frigate+ model or a model that detects license plates, watch the debug view (Settings --> Debug) to ensure that `license_plate` is being detected with a `car`.
|
- If you are using a Frigate+ model or a model that detects license plates, watch the debug view (Settings --> Debug) to ensure that `license_plate` is being detected with a `car`.
|
||||||
- Enable debug logs for LPR by adding `frigate.data_processing.real_time.license_plate_processor: debug` to your `logger` configuration. These logs are _very_ verbose, so only enable this when necessary.
|
- Enable debug logs for LPR by adding `frigate.data_processing.common.license_plate: debug` to your `logger` configuration. These logs are _very_ verbose, so only enable this when necessary.
|
||||||
|
|
||||||
### Will LPR slow down my system?
|
### Will LPR slow down my system?
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,7 @@ In order to use notifications the following requirements must be met:
|
|||||||
- Frigate must be accessed via a secure `https` connection ([see the authorization docs](/configuration/authentication)).
|
- Frigate must be accessed via a secure `https` connection ([see the authorization docs](/configuration/authentication)).
|
||||||
- A supported browser must be used. Currently Chrome, Firefox, and Safari are known to be supported.
|
- A supported browser must be used. Currently Chrome, Firefox, and Safari are known to be supported.
|
||||||
- In order for notifications to be usable externally, Frigate must be accessible externally.
|
- In order for notifications to be usable externally, Frigate must be accessible externally.
|
||||||
|
- For iOS devices, some users have also indicated that the Notifications switch needs to be enabled in iOS Settings --> Apps --> Safari --> Advanced --> Features.
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
|
|||||||
@ -10,25 +10,31 @@ title: Object Detectors
|
|||||||
Frigate supports multiple different detectors that work on different types of hardware:
|
Frigate supports multiple different detectors that work on different types of hardware:
|
||||||
|
|
||||||
**Most Hardware**
|
**Most Hardware**
|
||||||
|
|
||||||
- [Coral EdgeTPU](#edge-tpu-detector): The Google Coral EdgeTPU is available in USB and m.2 format allowing for a wide range of compatibility with devices.
|
- [Coral EdgeTPU](#edge-tpu-detector): The Google Coral EdgeTPU is available in USB and m.2 format allowing for a wide range of compatibility with devices.
|
||||||
- [Hailo](#hailo-8l): The Hailo8 AI Acceleration module is available in m.2 format with a HAT for RPi devices, offering a wide range of compatibility with devices.
|
- [Hailo](#hailo-8l): The Hailo8 AI Acceleration module is available in m.2 format with a HAT for RPi devices, offering a wide range of compatibility with devices.
|
||||||
|
|
||||||
**AMD**
|
**AMD**
|
||||||
|
|
||||||
- [ROCm](#amdrocm-gpu-detector): ROCm can run on AMD Discrete GPUs to provide efficient object detection.
|
- [ROCm](#amdrocm-gpu-detector): ROCm can run on AMD Discrete GPUs to provide efficient object detection.
|
||||||
- [ONNX](#onnx): ROCm will automatically be detected and used as a detector in the `-rocm` Frigate image when a supported ONNX model is configured.
|
- [ONNX](#onnx): ROCm will automatically be detected and used as a detector in the `-rocm` Frigate image when a supported ONNX model is configured.
|
||||||
|
|
||||||
**Intel**
|
**Intel**
|
||||||
|
|
||||||
- [OpenVino](#openvino-detector): OpenVino can run on Intel Arc GPUs, Intel integrated GPUs, and Intel CPUs to provide efficient object detection.
|
- [OpenVino](#openvino-detector): OpenVino can run on Intel Arc GPUs, Intel integrated GPUs, and Intel CPUs to provide efficient object detection.
|
||||||
- [ONNX](#onnx): OpenVINO will automatically be detected and used as a detector in the default Frigate image when a supported ONNX model is configured.
|
- [ONNX](#onnx): OpenVINO will automatically be detected and used as a detector in the default Frigate image when a supported ONNX model is configured.
|
||||||
|
|
||||||
**Nvidia**
|
**Nvidia**
|
||||||
|
|
||||||
- [TensortRT](#nvidia-tensorrt-detector): TensorRT can run on Nvidia GPUs and Jetson devices, using one of many default models.
|
- [TensortRT](#nvidia-tensorrt-detector): TensorRT can run on Nvidia GPUs and Jetson devices, using one of many default models.
|
||||||
- [ONNX](#onnx): TensorRT will automatically be detected and used as a detector in the `-tensorrt` or `-tensorrt-jp(4/5)` Frigate images when a supported ONNX model is configured.
|
- [ONNX](#onnx): TensorRT will automatically be detected and used as a detector in the `-tensorrt` or `-tensorrt-jp(4/5)` Frigate images when a supported ONNX model is configured.
|
||||||
|
|
||||||
**Rockchip**
|
**Rockchip**
|
||||||
|
|
||||||
- [RKNN](#rockchip-platform): RKNN models can run on Rockchip devices with included NPUs.
|
- [RKNN](#rockchip-platform): RKNN models can run on Rockchip devices with included NPUs.
|
||||||
|
|
||||||
**For Testing**
|
**For Testing**
|
||||||
|
|
||||||
- [CPU Detector (not recommended for actual use](#cpu-detector-not-recommended): Use a CPU to run tflite model, this is not recommended and in most cases OpenVINO can be used in CPU mode with better results.
|
- [CPU Detector (not recommended for actual use](#cpu-detector-not-recommended): Use a CPU to run tflite model, this is not recommended and in most cases OpenVINO can be used in CPU mode with better results.
|
||||||
|
|
||||||
:::
|
:::
|
||||||
@ -147,7 +153,6 @@ model:
|
|||||||
path: /config/model_cache/h8l_cache/ssd_mobilenet_v1.hef
|
path: /config/model_cache/h8l_cache/ssd_mobilenet_v1.hef
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
## OpenVINO Detector
|
## OpenVINO Detector
|
||||||
|
|
||||||
The OpenVINO detector type runs an OpenVINO IR model on AMD and Intel CPUs, Intel GPUs and Intel VPU hardware. To configure an OpenVINO detector, set the `"type"` attribute to `"openvino"`.
|
The OpenVINO detector type runs an OpenVINO IR model on AMD and Intel CPUs, Intel GPUs and Intel VPU hardware. To configure an OpenVINO detector, set the `"type"` attribute to `"openvino"`.
|
||||||
@ -412,7 +417,7 @@ When using docker compose:
|
|||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
frigate:
|
frigate:
|
||||||
...
|
|
||||||
environment:
|
environment:
|
||||||
HSA_OVERRIDE_GFX_VERSION: "9.0.0"
|
HSA_OVERRIDE_GFX_VERSION: "9.0.0"
|
||||||
```
|
```
|
||||||
@ -555,6 +560,50 @@ model:
|
|||||||
|
|
||||||
Note that the labelmap uses a subset of the complete COCO label set that has only 80 objects.
|
Note that the labelmap uses a subset of the complete COCO label set that has only 80 objects.
|
||||||
|
|
||||||
|
#### D-FINE
|
||||||
|
|
||||||
|
[D-FINE](https://github.com/Peterande/D-FINE) is the [current state of the art](https://paperswithcode.com/sota/real-time-object-detection-on-coco?p=d-fine-redefine-regression-task-in-detrs-as) at the time of writing. The ONNX exported models are supported, but not included by default.
|
||||||
|
|
||||||
|
To export as ONNX:
|
||||||
|
|
||||||
|
1. Clone: https://github.com/Peterande/D-FINE and install all dependencies.
|
||||||
|
2. Select and download a checkpoint from the [readme](https://github.com/Peterande/D-FINE).
|
||||||
|
3. Modify line 58 of `tools/deployment/export_onnx.py` and change batch size to 1: `data = torch.rand(1, 3, 640, 640)`
|
||||||
|
4. Run the export, making sure you select the right config, for your checkpoint.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```
|
||||||
|
python3 tools/deployment/export_onnx.py -c configs/dfine/objects365/dfine_hgnetv2_m_obj2coco.yml -r output/dfine_m_obj2coco.pth
|
||||||
|
```
|
||||||
|
|
||||||
|
:::tip
|
||||||
|
|
||||||
|
Model export has only been tested on Linux (or WSL2). Not all dependencies are in `requirements.txt`. Some live in the deployment folder, and some are still missing entirely and must be installed manually.
|
||||||
|
|
||||||
|
Make sure you change the batch size to 1 before exporting.
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
|
After placing the downloaded onnx model in your config folder, you can use the following configuration:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
detectors:
|
||||||
|
onnx:
|
||||||
|
type: onnx
|
||||||
|
|
||||||
|
model:
|
||||||
|
model_type: dfine
|
||||||
|
width: 640
|
||||||
|
height: 640
|
||||||
|
input_tensor: nchw
|
||||||
|
input_dtype: float
|
||||||
|
path: /config/model_cache/dfine_m_obj2coco.onnx
|
||||||
|
labelmap_path: /labelmap/coco-80.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Note that the labelmap uses a subset of the complete COCO label set that has only 80 objects.
|
||||||
|
|
||||||
## CPU Detector (not recommended)
|
## CPU Detector (not recommended)
|
||||||
|
|
||||||
The CPU detector type runs a TensorFlow Lite model utilizing the CPU without hardware acceleration. It is recommended to use a hardware accelerated detector type instead for better performance. To configure a CPU based detector, set the `"type"` attribute to `"cpu"`.
|
The CPU detector type runs a TensorFlow Lite model utilizing the CPU without hardware acceleration. It is recommended to use a hardware accelerated detector type instead for better performance. To configure a CPU based detector, set the `"type"` attribute to `"cpu"`.
|
||||||
@ -704,7 +753,7 @@ To convert a onnx model to the rknn format using the [rknn-toolkit2](https://git
|
|||||||
This is an example configuration file that you need to adjust to your specific onnx model:
|
This is an example configuration file that you need to adjust to your specific onnx model:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
soc: ["rk3562","rk3566", "rk3568", "rk3576", "rk3588"]
|
soc: ["rk3562", "rk3566", "rk3568", "rk3576", "rk3588"]
|
||||||
quantization: false
|
quantization: false
|
||||||
|
|
||||||
output_name: "{input_basename}"
|
output_name: "{input_basename}"
|
||||||
|
|||||||
@ -80,12 +80,12 @@ The Frigate container also stores logs in shm, which can take up to **40MB**, so
|
|||||||
You can calculate the **minimum** shm size for each camera with the following formula using the resolution specified for detect:
|
You can calculate the **minimum** shm size for each camera with the following formula using the resolution specified for detect:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
# Replace <width> and <height>
|
# Template for one camera without logs, replace <width> and <height>
|
||||||
$ python -c 'print("{:.2f}MB".format((<width> * <height> * 1.5 * 20 + 270480) / 1048576))'
|
$ python -c 'print("{:.2f}MB".format((<width> * <height> * 1.5 * 20 + 270480) / 1048576))'
|
||||||
|
|
||||||
# Example for 1280x720, including logs
|
# Example for 1280x720, including logs
|
||||||
$ python -c 'print("{:.2f}MB".format((1280 * 720 * 1.5 * 20 + 270480) / 1048576)) + 40'
|
$ python -c 'print("{:.2f}MB".format((1280 * 720 * 1.5 * 20 + 270480) / 1048576 + 40))'
|
||||||
46.63MB
|
66.63MB
|
||||||
|
|
||||||
# Example for eight cameras detecting at 1280x720, including logs
|
# Example for eight cameras detecting at 1280x720, including logs
|
||||||
$ python -c 'print("{:.2f}MB".format(((1280 * 720 * 1.5 * 20 + 270480) / 1048576) * 8 + 40))'
|
$ python -c 'print("{:.2f}MB".format(((1280 * 720 * 1.5 * 20 + 270480) / 1048576) * 8 + 40))'
|
||||||
|
|||||||
@ -9,10 +9,13 @@ import string
|
|||||||
from fastapi import APIRouter, Request, UploadFile
|
from fastapi import APIRouter, Request, UploadFile
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from pathvalidate import sanitize_filename
|
from pathvalidate import sanitize_filename
|
||||||
|
from peewee import DoesNotExist
|
||||||
|
from playhouse.shortcuts import model_to_dict
|
||||||
|
|
||||||
from frigate.api.defs.tags import Tags
|
from frigate.api.defs.tags import Tags
|
||||||
from frigate.const import FACE_DIR
|
from frigate.const import FACE_DIR
|
||||||
from frigate.embeddings import EmbeddingsContext
|
from frigate.embeddings import EmbeddingsContext
|
||||||
|
from frigate.models import Event
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -176,3 +179,36 @@ def deregister_faces(request: Request, name: str, body: dict = None):
|
|||||||
content=({"success": True, "message": "Successfully deleted faces."}),
|
content=({"success": True, "message": "Successfully deleted faces."}),
|
||||||
status_code=200,
|
status_code=200,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/lpr/reprocess")
|
||||||
|
def reprocess_license_plate(request: Request, event_id: str):
|
||||||
|
if not request.app.frigate_config.lpr.enabled:
|
||||||
|
message = "License plate recognition is not enabled."
|
||||||
|
logger.error(message)
|
||||||
|
return JSONResponse(
|
||||||
|
content=(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"message": message,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
event = Event.get(Event.id == event_id)
|
||||||
|
except DoesNotExist:
|
||||||
|
message = f"Event {event_id} not found"
|
||||||
|
logger.error(message)
|
||||||
|
return JSONResponse(
|
||||||
|
content=({"success": False, "message": message}), status_code=404
|
||||||
|
)
|
||||||
|
|
||||||
|
context: EmbeddingsContext = request.app.embeddings
|
||||||
|
response = context.reprocess_plate(model_to_dict(event))
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
content=response,
|
||||||
|
status_code=200,
|
||||||
|
)
|
||||||
|
|||||||
@ -991,6 +991,10 @@ def set_sub_label(
|
|||||||
new_sub_label = body.subLabel
|
new_sub_label = body.subLabel
|
||||||
new_score = body.subLabelScore
|
new_score = body.subLabelScore
|
||||||
|
|
||||||
|
if new_sub_label == "":
|
||||||
|
new_sub_label = None
|
||||||
|
new_score = None
|
||||||
|
|
||||||
if tracked_obj:
|
if tracked_obj:
|
||||||
tracked_obj.obj_data["sub_label"] = (new_sub_label, new_score)
|
tracked_obj.obj_data["sub_label"] = (new_sub_label, new_score)
|
||||||
|
|
||||||
@ -1001,21 +1005,19 @@ def set_sub_label(
|
|||||||
|
|
||||||
if event:
|
if event:
|
||||||
event.sub_label = new_sub_label
|
event.sub_label = new_sub_label
|
||||||
|
data = event.data
|
||||||
if new_score:
|
if new_sub_label is None:
|
||||||
data = event.data
|
data["sub_label_score"] = None
|
||||||
|
elif new_score is not None:
|
||||||
data["sub_label_score"] = new_score
|
data["sub_label_score"] = new_score
|
||||||
event.data = data
|
event.data = data
|
||||||
|
|
||||||
event.save()
|
event.save()
|
||||||
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content=(
|
content={
|
||||||
{
|
"success": True,
|
||||||
"success": True,
|
"message": f"Event {event_id} sub label set to {new_sub_label if new_sub_label is not None else 'None'}",
|
||||||
"message": "Event " + event_id + " sub label set to " + new_sub_label,
|
},
|
||||||
}
|
|
||||||
),
|
|
||||||
status_code=200,
|
status_code=200,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -15,6 +15,7 @@ class EmbeddingsRequestEnum(Enum):
|
|||||||
generate_search = "generate_search"
|
generate_search = "generate_search"
|
||||||
register_face = "register_face"
|
register_face = "register_face"
|
||||||
reprocess_face = "reprocess_face"
|
reprocess_face = "reprocess_face"
|
||||||
|
reprocess_plate = "reprocess_plate"
|
||||||
|
|
||||||
|
|
||||||
class EmbeddingsResponder:
|
class EmbeddingsResponder:
|
||||||
|
|||||||
36
frigate/comms/recordings_updater.py
Normal file
36
frigate/comms/recordings_updater.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
"""Facilitates communication between processes."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from .zmq_proxy import Publisher, Subscriber
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingsDataTypeEnum(str, Enum):
|
||||||
|
all = ""
|
||||||
|
recordings_available_through = "recordings_available_through"
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingsDataPublisher(Publisher):
|
||||||
|
"""Publishes latest recording data."""
|
||||||
|
|
||||||
|
topic_base = "recordings/"
|
||||||
|
|
||||||
|
def __init__(self, topic: RecordingsDataTypeEnum) -> None:
|
||||||
|
topic = topic.value
|
||||||
|
super().__init__(topic)
|
||||||
|
|
||||||
|
def publish(self, payload: tuple[str, float]) -> None:
|
||||||
|
super().publish(payload)
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingsDataSubscriber(Subscriber):
|
||||||
|
"""Receives latest recording data."""
|
||||||
|
|
||||||
|
topic_base = "recordings/"
|
||||||
|
|
||||||
|
def __init__(self, topic: RecordingsDataTypeEnum) -> None:
|
||||||
|
topic = topic.value
|
||||||
|
super().__init__(topic)
|
||||||
@ -13,34 +13,21 @@ from Levenshtein import distance
|
|||||||
from pyclipper import ET_CLOSEDPOLYGON, JT_ROUND, PyclipperOffset
|
from pyclipper import ET_CLOSEDPOLYGON, JT_ROUND, PyclipperOffset
|
||||||
from shapely.geometry import Polygon
|
from shapely.geometry import Polygon
|
||||||
|
|
||||||
from frigate.comms.inter_process import InterProcessRequestor
|
|
||||||
from frigate.config import FrigateConfig
|
|
||||||
from frigate.const import FRIGATE_LOCALHOST
|
from frigate.const import FRIGATE_LOCALHOST
|
||||||
from frigate.embeddings.onnx.lpr_embedding import (
|
|
||||||
LicensePlateDetector,
|
|
||||||
PaddleOCRClassification,
|
|
||||||
PaddleOCRDetection,
|
|
||||||
PaddleOCRRecognition,
|
|
||||||
)
|
|
||||||
from frigate.util.image import area
|
from frigate.util.image import area
|
||||||
|
|
||||||
from ..types import DataProcessorMetrics
|
|
||||||
from .api import RealTimeProcessorApi
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
WRITE_DEBUG_IMAGES = False
|
WRITE_DEBUG_IMAGES = False
|
||||||
|
|
||||||
|
|
||||||
class LicensePlateProcessor(RealTimeProcessorApi):
|
class LicensePlateProcessingMixin:
|
||||||
def __init__(self, config: FrigateConfig, metrics: DataProcessorMetrics):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(config, metrics)
|
super().__init__(*args, **kwargs)
|
||||||
self.requestor = InterProcessRequestor()
|
|
||||||
self.lpr_config = config.lpr
|
|
||||||
self.requires_license_plate_detection = (
|
self.requires_license_plate_detection = (
|
||||||
"license_plate" not in self.config.objects.all_objects
|
"license_plate" not in self.config.objects.all_objects
|
||||||
)
|
)
|
||||||
self.detected_license_plates: dict[str, dict[str, any]] = {}
|
|
||||||
|
|
||||||
self.ctc_decoder = CTCDecoder()
|
self.ctc_decoder = CTCDecoder()
|
||||||
|
|
||||||
@ -52,42 +39,6 @@ class LicensePlateProcessor(RealTimeProcessorApi):
|
|||||||
self.box_thresh = 0.8
|
self.box_thresh = 0.8
|
||||||
self.mask_thresh = 0.8
|
self.mask_thresh = 0.8
|
||||||
|
|
||||||
self.lpr_detection_model = None
|
|
||||||
self.lpr_classification_model = None
|
|
||||||
self.lpr_recognition_model = None
|
|
||||||
|
|
||||||
if self.config.lpr.enabled:
|
|
||||||
self.detection_model = PaddleOCRDetection(
|
|
||||||
model_size="large",
|
|
||||||
requestor=self.requestor,
|
|
||||||
device="CPU",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.classification_model = PaddleOCRClassification(
|
|
||||||
model_size="large",
|
|
||||||
requestor=self.requestor,
|
|
||||||
device="CPU",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.recognition_model = PaddleOCRRecognition(
|
|
||||||
model_size="large",
|
|
||||||
requestor=self.requestor,
|
|
||||||
device="CPU",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.yolov9_detection_model = LicensePlateDetector(
|
|
||||||
model_size="large",
|
|
||||||
requestor=self.requestor,
|
|
||||||
device="CPU",
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.lpr_config.enabled:
|
|
||||||
# all models need to be loaded to run LPR
|
|
||||||
self.detection_model._load_model_and_utils()
|
|
||||||
self.classification_model._load_model_and_utils()
|
|
||||||
self.recognition_model._load_model_and_utils()
|
|
||||||
self.yolov9_detection_model._load_model_and_utils()
|
|
||||||
|
|
||||||
def _detect(self, image: np.ndarray) -> List[np.ndarray]:
|
def _detect(self, image: np.ndarray) -> List[np.ndarray]:
|
||||||
"""
|
"""
|
||||||
Detect possible license plates in the input image by first resizing and normalizing it,
|
Detect possible license plates in the input image by first resizing and normalizing it,
|
||||||
@ -114,7 +65,7 @@ class LicensePlateProcessor(RealTimeProcessorApi):
|
|||||||
resized_image,
|
resized_image,
|
||||||
)
|
)
|
||||||
|
|
||||||
outputs = self.detection_model([normalized_image])[0]
|
outputs = self.model_runner.detection_model([normalized_image])[0]
|
||||||
outputs = outputs[0, :, :]
|
outputs = outputs[0, :, :]
|
||||||
|
|
||||||
boxes, _ = self._boxes_from_bitmap(outputs, outputs > self.mask_thresh, w, h)
|
boxes, _ = self._boxes_from_bitmap(outputs, outputs > self.mask_thresh, w, h)
|
||||||
@ -143,7 +94,7 @@ class LicensePlateProcessor(RealTimeProcessorApi):
|
|||||||
norm_img = norm_img[np.newaxis, :]
|
norm_img = norm_img[np.newaxis, :]
|
||||||
norm_images.append(norm_img)
|
norm_images.append(norm_img)
|
||||||
|
|
||||||
outputs = self.classification_model(norm_images)
|
outputs = self.model_runner.classification_model(norm_images)
|
||||||
|
|
||||||
return self._process_classification_output(images, outputs)
|
return self._process_classification_output(images, outputs)
|
||||||
|
|
||||||
@ -183,7 +134,7 @@ class LicensePlateProcessor(RealTimeProcessorApi):
|
|||||||
norm_image = norm_image[np.newaxis, :]
|
norm_image = norm_image[np.newaxis, :]
|
||||||
norm_images.append(norm_image)
|
norm_images.append(norm_image)
|
||||||
|
|
||||||
outputs = self.recognition_model(norm_images)
|
outputs = self.model_runner.recognition_model(norm_images)
|
||||||
return self.ctc_decoder(outputs)
|
return self.ctc_decoder(outputs)
|
||||||
|
|
||||||
def _process_license_plate(
|
def _process_license_plate(
|
||||||
@ -199,9 +150,9 @@ class LicensePlateProcessor(RealTimeProcessorApi):
|
|||||||
Tuple[List[str], List[float], List[int]]: Detected license plate texts, confidence scores, and areas of the plates.
|
Tuple[List[str], List[float], List[int]]: Detected license plate texts, confidence scores, and areas of the plates.
|
||||||
"""
|
"""
|
||||||
if (
|
if (
|
||||||
self.detection_model.runner is None
|
self.model_runner.detection_model.runner is None
|
||||||
or self.classification_model.runner is None
|
or self.model_runner.classification_model.runner is None
|
||||||
or self.recognition_model.runner is None
|
or self.model_runner.recognition_model.runner is None
|
||||||
):
|
):
|
||||||
# we might still be downloading the models
|
# we might still be downloading the models
|
||||||
logger.debug("Model runners not loaded")
|
logger.debug("Model runners not loaded")
|
||||||
@ -665,7 +616,9 @@ class LicensePlateProcessor(RealTimeProcessorApi):
|
|||||||
input_w = int(input_h * max_wh_ratio)
|
input_w = int(input_h * max_wh_ratio)
|
||||||
|
|
||||||
# check for model-specific input width
|
# check for model-specific input width
|
||||||
model_input_w = self.recognition_model.runner.ort.get_inputs()[0].shape[3]
|
model_input_w = self.model_runner.recognition_model.runner.ort.get_inputs()[
|
||||||
|
0
|
||||||
|
].shape[3]
|
||||||
if isinstance(model_input_w, int) and model_input_w > 0:
|
if isinstance(model_input_w, int) and model_input_w > 0:
|
||||||
input_w = model_input_w
|
input_w = model_input_w
|
||||||
|
|
||||||
@ -732,19 +685,13 @@ class LicensePlateProcessor(RealTimeProcessorApi):
|
|||||||
image = np.rot90(image, k=3)
|
image = np.rot90(image, k=3)
|
||||||
return image
|
return image
|
||||||
|
|
||||||
def __update_metrics(self, duration: float) -> None:
|
|
||||||
"""
|
|
||||||
Update inference metrics.
|
|
||||||
"""
|
|
||||||
self.metrics.alpr_pps.value = (self.metrics.alpr_pps.value * 9 + duration) / 10
|
|
||||||
|
|
||||||
def _detect_license_plate(self, input: np.ndarray) -> tuple[int, int, int, int]:
|
def _detect_license_plate(self, input: np.ndarray) -> tuple[int, int, int, int]:
|
||||||
"""
|
"""
|
||||||
Use a lightweight YOLOv9 model to detect license plates for users without Frigate+
|
Use a lightweight YOLOv9 model to detect license plates for users without Frigate+
|
||||||
|
|
||||||
Return the dimensions of the detected plate as [x1, y1, x2, y2].
|
Return the dimensions of the detected plate as [x1, y1, x2, y2].
|
||||||
"""
|
"""
|
||||||
predictions = self.yolov9_detection_model(input)
|
predictions = self.model_runner.yolov9_detection_model(input)
|
||||||
|
|
||||||
confidence_threshold = self.lpr_config.detection_threshold
|
confidence_threshold = self.lpr_config.detection_threshold
|
||||||
|
|
||||||
@ -770,8 +717,8 @@ class LicensePlateProcessor(RealTimeProcessorApi):
|
|||||||
|
|
||||||
# Return the top scoring bounding box if found
|
# Return the top scoring bounding box if found
|
||||||
if top_box is not None:
|
if top_box is not None:
|
||||||
# expand box by 15% to help with OCR
|
# expand box by 30% to help with OCR
|
||||||
expansion = (top_box[2:] - top_box[:2]) * 0.1
|
expansion = (top_box[2:] - top_box[:2]) * 0.30
|
||||||
|
|
||||||
# Expand box
|
# Expand box
|
||||||
expanded_box = np.array(
|
expanded_box = np.array(
|
||||||
@ -869,9 +816,8 @@ class LicensePlateProcessor(RealTimeProcessorApi):
|
|||||||
# 5. Return True if we should keep the previous plate (i.e., if it scores higher)
|
# 5. Return True if we should keep the previous plate (i.e., if it scores higher)
|
||||||
return prev_score > curr_score
|
return prev_score > curr_score
|
||||||
|
|
||||||
def process_frame(self, obj_data: dict[str, any], frame: np.ndarray):
|
def lpr_process(self, obj_data: dict[str, any], frame: np.ndarray):
|
||||||
"""Look for license plates in image."""
|
"""Look for license plates in image."""
|
||||||
start = datetime.datetime.now().timestamp()
|
|
||||||
|
|
||||||
id = obj_data["id"]
|
id = obj_data["id"]
|
||||||
|
|
||||||
@ -934,7 +880,7 @@ class LicensePlateProcessor(RealTimeProcessorApi):
|
|||||||
|
|
||||||
# check that license plate is valid
|
# check that license plate is valid
|
||||||
# double the value because we've doubled the size of the car
|
# double the value because we've doubled the size of the car
|
||||||
if license_plate_area < self.config.lpr.min_area * 2:
|
if license_plate_area < self.lpr_config.min_area * 2:
|
||||||
logger.debug("License plate is less than min_area")
|
logger.debug("License plate is less than min_area")
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -972,7 +918,7 @@ class LicensePlateProcessor(RealTimeProcessorApi):
|
|||||||
# check that license plate is valid
|
# check that license plate is valid
|
||||||
if (
|
if (
|
||||||
not license_plate_box
|
not license_plate_box
|
||||||
or area(license_plate_box) < self.config.lpr.min_area
|
or area(license_plate_box) < self.lpr_config.min_area
|
||||||
):
|
):
|
||||||
logger.debug(f"Invalid license plate box {license_plate}")
|
logger.debug(f"Invalid license plate box {license_plate}")
|
||||||
return
|
return
|
||||||
@ -1078,10 +1024,9 @@ class LicensePlateProcessor(RealTimeProcessorApi):
|
|||||||
"plate": top_plate,
|
"plate": top_plate,
|
||||||
"char_confidences": top_char_confidences,
|
"char_confidences": top_char_confidences,
|
||||||
"area": top_area,
|
"area": top_area,
|
||||||
|
"obj_data": obj_data,
|
||||||
}
|
}
|
||||||
|
|
||||||
self.__update_metrics(datetime.datetime.now().timestamp() - start)
|
|
||||||
|
|
||||||
def handle_request(self, topic, request_data) -> dict[str, any] | None:
|
def handle_request(self, topic, request_data) -> dict[str, any] | None:
|
||||||
return
|
return
|
||||||
|
|
||||||
31
frigate/data_processing/common/license_plate/model.py
Normal file
31
frigate/data_processing/common/license_plate/model.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
from frigate.embeddings.onnx.lpr_embedding import (
|
||||||
|
LicensePlateDetector,
|
||||||
|
PaddleOCRClassification,
|
||||||
|
PaddleOCRDetection,
|
||||||
|
PaddleOCRRecognition,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ...types import DataProcessorModelRunner
|
||||||
|
|
||||||
|
|
||||||
|
class LicensePlateModelRunner(DataProcessorModelRunner):
|
||||||
|
def __init__(self, requestor, device: str = "CPU", model_size: str = "large"):
|
||||||
|
super().__init__(requestor, device, model_size)
|
||||||
|
self.detection_model = PaddleOCRDetection(
|
||||||
|
model_size=model_size, requestor=requestor, device=device
|
||||||
|
)
|
||||||
|
self.classification_model = PaddleOCRClassification(
|
||||||
|
model_size=model_size, requestor=requestor, device=device
|
||||||
|
)
|
||||||
|
self.recognition_model = PaddleOCRRecognition(
|
||||||
|
model_size=model_size, requestor=requestor, device=device
|
||||||
|
)
|
||||||
|
self.yolov9_detection_model = LicensePlateDetector(
|
||||||
|
model_size=model_size, requestor=requestor, device=device
|
||||||
|
)
|
||||||
|
|
||||||
|
# Load all models once
|
||||||
|
self.detection_model._load_model_and_utils()
|
||||||
|
self.classification_model._load_model_and_utils()
|
||||||
|
self.recognition_model._load_model_and_utils()
|
||||||
|
self.yolov9_detection_model._load_model_and_utils()
|
||||||
@ -5,16 +5,22 @@ from abc import ABC, abstractmethod
|
|||||||
|
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
|
|
||||||
from ..types import DataProcessorMetrics, PostProcessDataEnum
|
from ..types import DataProcessorMetrics, DataProcessorModelRunner, PostProcessDataEnum
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class PostProcessorApi(ABC):
|
class PostProcessorApi(ABC):
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def __init__(self, config: FrigateConfig, metrics: DataProcessorMetrics) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: FrigateConfig,
|
||||||
|
metrics: DataProcessorMetrics,
|
||||||
|
model_runner: DataProcessorModelRunner,
|
||||||
|
) -> None:
|
||||||
self.config = config
|
self.config = config
|
||||||
self.metrics = metrics
|
self.metrics = metrics
|
||||||
|
self.model_runner = model_runner
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
|||||||
231
frigate/data_processing/post/license_plate.py
Normal file
231
frigate/data_processing/post/license_plate.py
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
"""Handle post processing for license plate recognition."""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
from peewee import DoesNotExist
|
||||||
|
|
||||||
|
from frigate.comms.embeddings_updater import EmbeddingsRequestEnum
|
||||||
|
from frigate.config import FrigateConfig
|
||||||
|
from frigate.data_processing.common.license_plate.mixin import (
|
||||||
|
WRITE_DEBUG_IMAGES,
|
||||||
|
LicensePlateProcessingMixin,
|
||||||
|
)
|
||||||
|
from frigate.data_processing.common.license_plate.model import (
|
||||||
|
LicensePlateModelRunner,
|
||||||
|
)
|
||||||
|
from frigate.data_processing.types import PostProcessDataEnum
|
||||||
|
from frigate.models import Recordings
|
||||||
|
from frigate.util.image import get_image_from_recording
|
||||||
|
|
||||||
|
from ..types import DataProcessorMetrics
|
||||||
|
from .api import PostProcessorApi
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LicensePlatePostProcessor(LicensePlateProcessingMixin, PostProcessorApi):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: FrigateConfig,
|
||||||
|
metrics: DataProcessorMetrics,
|
||||||
|
model_runner: LicensePlateModelRunner,
|
||||||
|
detected_license_plates: dict[str, dict[str, any]],
|
||||||
|
):
|
||||||
|
self.detected_license_plates = detected_license_plates
|
||||||
|
self.model_runner = model_runner
|
||||||
|
self.lpr_config = config.lpr
|
||||||
|
self.config = config
|
||||||
|
super().__init__(config, metrics, model_runner)
|
||||||
|
|
||||||
|
def __update_metrics(self, duration: float) -> None:
|
||||||
|
"""
|
||||||
|
Update inference metrics.
|
||||||
|
"""
|
||||||
|
self.metrics.alpr_pps.value = (self.metrics.alpr_pps.value * 9 + duration) / 10
|
||||||
|
|
||||||
|
def process_data(
|
||||||
|
self, data: dict[str, any], data_type: PostProcessDataEnum
|
||||||
|
) -> None:
|
||||||
|
"""Look for license plates in recording stream image
|
||||||
|
Args:
|
||||||
|
data (dict): containing data about the input.
|
||||||
|
data_type (enum): Describing the data that is being processed.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None.
|
||||||
|
"""
|
||||||
|
start = datetime.datetime.now().timestamp()
|
||||||
|
|
||||||
|
event_id = data["event_id"]
|
||||||
|
camera_name = data["camera"]
|
||||||
|
|
||||||
|
if data_type == PostProcessDataEnum.recording:
|
||||||
|
obj_data = data["obj_data"]
|
||||||
|
frame_time = obj_data["frame_time"]
|
||||||
|
recordings_available_through = data["recordings_available"]
|
||||||
|
|
||||||
|
if frame_time > recordings_available_through:
|
||||||
|
logger.debug(
|
||||||
|
f"LPR post processing: No recordings available for this frame time {frame_time}, available through {recordings_available_through}"
|
||||||
|
)
|
||||||
|
|
||||||
|
elif data_type == PostProcessDataEnum.tracked_object:
|
||||||
|
# non-functional, need to think about snapshot time
|
||||||
|
obj_data = data["event"]["data"]
|
||||||
|
obj_data["id"] = data["event"]["id"]
|
||||||
|
obj_data["camera"] = data["event"]["camera"]
|
||||||
|
# TODO: snapshot time?
|
||||||
|
frame_time = data["event"]["start_time"]
|
||||||
|
|
||||||
|
else:
|
||||||
|
logger.error("No data type passed to LPR postprocessing")
|
||||||
|
return
|
||||||
|
|
||||||
|
recording_query = (
|
||||||
|
Recordings.select(
|
||||||
|
Recordings.path,
|
||||||
|
Recordings.start_time,
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
(
|
||||||
|
(frame_time >= Recordings.start_time)
|
||||||
|
& (frame_time <= Recordings.end_time)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(Recordings.camera == camera_name)
|
||||||
|
.order_by(Recordings.start_time.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
recording: Recordings = recording_query.get()
|
||||||
|
time_in_segment = frame_time - recording.start_time
|
||||||
|
codec = "mjpeg"
|
||||||
|
|
||||||
|
image_data = get_image_from_recording(
|
||||||
|
self.config.ffmpeg, recording.path, time_in_segment, codec, None
|
||||||
|
)
|
||||||
|
|
||||||
|
if not image_data:
|
||||||
|
logger.debug(
|
||||||
|
"LPR post processing: Unable to fetch license plate from recording"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert bytes to numpy array
|
||||||
|
image_array = np.frombuffer(image_data, dtype=np.uint8)
|
||||||
|
|
||||||
|
if len(image_array) == 0:
|
||||||
|
logger.debug("LPR post processing: No image")
|
||||||
|
return
|
||||||
|
|
||||||
|
image = cv2.imdecode(image_array, cv2.IMREAD_COLOR)
|
||||||
|
|
||||||
|
except DoesNotExist:
|
||||||
|
logger.debug("Error fetching license plate for postprocessing")
|
||||||
|
return
|
||||||
|
|
||||||
|
if WRITE_DEBUG_IMAGES:
|
||||||
|
cv2.imwrite(f"debug/frames/lpr_post_{start}.jpg", image)
|
||||||
|
|
||||||
|
# convert to yuv for processing
|
||||||
|
frame = cv2.cvtColor(image, cv2.COLOR_BGR2YUV_I420)
|
||||||
|
|
||||||
|
detect_width = self.config.cameras[camera_name].detect.width
|
||||||
|
detect_height = self.config.cameras[camera_name].detect.height
|
||||||
|
|
||||||
|
# Scale the boxes based on detect dimensions
|
||||||
|
scale_x = image.shape[1] / detect_width
|
||||||
|
scale_y = image.shape[0] / detect_height
|
||||||
|
|
||||||
|
# Determine which box to enlarge based on detection mode
|
||||||
|
if self.requires_license_plate_detection:
|
||||||
|
# Scale and enlarge the car box
|
||||||
|
box = obj_data.get("box")
|
||||||
|
if not box:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Scale original car box to detection dimensions
|
||||||
|
left = int(box[0] * scale_x)
|
||||||
|
top = int(box[1] * scale_y)
|
||||||
|
right = int(box[2] * scale_x)
|
||||||
|
bottom = int(box[3] * scale_y)
|
||||||
|
box = [left, top, right, bottom]
|
||||||
|
else:
|
||||||
|
# Get the license plate box from attributes
|
||||||
|
if not obj_data.get("current_attributes"):
|
||||||
|
return
|
||||||
|
|
||||||
|
license_plate = None
|
||||||
|
for attr in obj_data["current_attributes"]:
|
||||||
|
if attr.get("label") != "license_plate":
|
||||||
|
continue
|
||||||
|
if license_plate is None or attr.get("score", 0.0) > license_plate.get(
|
||||||
|
"score", 0.0
|
||||||
|
):
|
||||||
|
license_plate = attr
|
||||||
|
|
||||||
|
if not license_plate or not license_plate.get("box"):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Scale license plate box to detection dimensions
|
||||||
|
orig_box = license_plate["box"]
|
||||||
|
left = int(orig_box[0] * scale_x)
|
||||||
|
top = int(orig_box[1] * scale_y)
|
||||||
|
right = int(orig_box[2] * scale_x)
|
||||||
|
bottom = int(orig_box[3] * scale_y)
|
||||||
|
box = [left, top, right, bottom]
|
||||||
|
|
||||||
|
width_box = right - left
|
||||||
|
height_box = bottom - top
|
||||||
|
|
||||||
|
# Enlarge box slightly to account for drift in detect vs recording stream
|
||||||
|
enlarge_factor = 0.3
|
||||||
|
new_left = max(0, int(left - (width_box * enlarge_factor / 2)))
|
||||||
|
new_top = max(0, int(top - (height_box * enlarge_factor / 2)))
|
||||||
|
new_right = min(image.shape[1], int(right + (width_box * enlarge_factor / 2)))
|
||||||
|
new_bottom = min(
|
||||||
|
image.shape[0], int(bottom + (height_box * enlarge_factor / 2))
|
||||||
|
)
|
||||||
|
|
||||||
|
keyframe_obj_data = obj_data.copy()
|
||||||
|
if self.requires_license_plate_detection:
|
||||||
|
# car box
|
||||||
|
keyframe_obj_data["box"] = [new_left, new_top, new_right, new_bottom]
|
||||||
|
else:
|
||||||
|
# Update the license plate box in the attributes
|
||||||
|
new_attributes = []
|
||||||
|
for attr in obj_data["current_attributes"]:
|
||||||
|
if attr.get("label") == "license_plate":
|
||||||
|
new_attr = attr.copy()
|
||||||
|
new_attr["box"] = [new_left, new_top, new_right, new_bottom]
|
||||||
|
new_attributes.append(new_attr)
|
||||||
|
else:
|
||||||
|
new_attributes.append(attr)
|
||||||
|
keyframe_obj_data["current_attributes"] = new_attributes
|
||||||
|
|
||||||
|
# run the frame through lpr processing
|
||||||
|
logger.debug(f"Post processing plate: {event_id}, {frame_time}")
|
||||||
|
self.lpr_process(keyframe_obj_data, frame)
|
||||||
|
|
||||||
|
self.__update_metrics(datetime.datetime.now().timestamp() - start)
|
||||||
|
|
||||||
|
def handle_request(self, topic, request_data) -> dict[str, any] | None:
|
||||||
|
if topic == EmbeddingsRequestEnum.reprocess_plate.value:
|
||||||
|
event = request_data["event"]
|
||||||
|
|
||||||
|
self.process_data(
|
||||||
|
{
|
||||||
|
"event_id": event["id"],
|
||||||
|
"camera": event["camera"],
|
||||||
|
"event": event,
|
||||||
|
},
|
||||||
|
PostProcessDataEnum.tracked_object,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "Successfully requested reprocessing of license plate.",
|
||||||
|
"success": True,
|
||||||
|
}
|
||||||
@ -14,7 +14,11 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
class RealTimeProcessorApi(ABC):
|
class RealTimeProcessorApi(ABC):
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def __init__(self, config: FrigateConfig, metrics: DataProcessorMetrics) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: FrigateConfig,
|
||||||
|
metrics: DataProcessorMetrics,
|
||||||
|
) -> None:
|
||||||
self.config = config
|
self.config = config
|
||||||
self.metrics = metrics
|
self.metrics = metrics
|
||||||
pass
|
pass
|
||||||
|
|||||||
@ -22,7 +22,7 @@ except ModuleNotFoundError:
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class BirdProcessor(RealTimeProcessorApi):
|
class BirdRealTimeProcessor(RealTimeProcessorApi):
|
||||||
def __init__(self, config: FrigateConfig, metrics: DataProcessorMetrics):
|
def __init__(self, config: FrigateConfig, metrics: DataProcessorMetrics):
|
||||||
super().__init__(config, metrics)
|
super().__init__(config, metrics)
|
||||||
self.interpreter: Interpreter = None
|
self.interpreter: Interpreter = None
|
||||||
@ -27,7 +27,7 @@ logger = logging.getLogger(__name__)
|
|||||||
MIN_MATCHING_FACES = 2
|
MIN_MATCHING_FACES = 2
|
||||||
|
|
||||||
|
|
||||||
class FaceProcessor(RealTimeProcessorApi):
|
class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||||
def __init__(self, config: FrigateConfig, metrics: DataProcessorMetrics):
|
def __init__(self, config: FrigateConfig, metrics: DataProcessorMetrics):
|
||||||
super().__init__(config, metrics)
|
super().__init__(config, metrics)
|
||||||
self.face_config = config.face_recognition
|
self.face_config = config.face_recognition
|
||||||
53
frigate/data_processing/real_time/license_plate.py
Normal file
53
frigate/data_processing/real_time/license_plate.py
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
"""Handle processing images for face detection and recognition."""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from frigate.config import FrigateConfig
|
||||||
|
from frigate.data_processing.common.license_plate.mixin import (
|
||||||
|
LicensePlateProcessingMixin,
|
||||||
|
)
|
||||||
|
from frigate.data_processing.common.license_plate.model import (
|
||||||
|
LicensePlateModelRunner,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..types import DataProcessorMetrics
|
||||||
|
from .api import RealTimeProcessorApi
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LicensePlateRealTimeProcessor(LicensePlateProcessingMixin, RealTimeProcessorApi):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: FrigateConfig,
|
||||||
|
metrics: DataProcessorMetrics,
|
||||||
|
model_runner: LicensePlateModelRunner,
|
||||||
|
detected_license_plates: dict[str, dict[str, any]],
|
||||||
|
):
|
||||||
|
self.detected_license_plates = detected_license_plates
|
||||||
|
self.model_runner = model_runner
|
||||||
|
self.lpr_config = config.lpr
|
||||||
|
self.config = config
|
||||||
|
super().__init__(config, metrics)
|
||||||
|
|
||||||
|
def __update_metrics(self, duration: float) -> None:
|
||||||
|
"""
|
||||||
|
Update inference metrics.
|
||||||
|
"""
|
||||||
|
self.metrics.alpr_pps.value = (self.metrics.alpr_pps.value * 9 + duration) / 10
|
||||||
|
|
||||||
|
def process_frame(self, obj_data: dict[str, any], frame: np.ndarray):
|
||||||
|
"""Look for license plates in image."""
|
||||||
|
start = datetime.datetime.now().timestamp()
|
||||||
|
self.lpr_process(obj_data, frame)
|
||||||
|
self.__update_metrics(datetime.datetime.now().timestamp() - start)
|
||||||
|
|
||||||
|
def handle_request(self, topic, request_data) -> dict[str, any] | None:
|
||||||
|
return
|
||||||
|
|
||||||
|
def expire_object(self, object_id: str):
|
||||||
|
if object_id in self.detected_license_plates:
|
||||||
|
self.detected_license_plates.pop(object_id)
|
||||||
@ -18,6 +18,13 @@ class DataProcessorMetrics:
|
|||||||
self.alpr_pps = mp.Value("d", 0.01)
|
self.alpr_pps = mp.Value("d", 0.01)
|
||||||
|
|
||||||
|
|
||||||
|
class DataProcessorModelRunner:
|
||||||
|
def __init__(self, requestor, device: str = "CPU", model_size: str = "large"):
|
||||||
|
self.requestor = requestor
|
||||||
|
self.device = device
|
||||||
|
self.model_size = model_size
|
||||||
|
|
||||||
|
|
||||||
class PostProcessDataEnum(str, Enum):
|
class PostProcessDataEnum(str, Enum):
|
||||||
recording = "recording"
|
recording = "recording"
|
||||||
review = "review"
|
review = "review"
|
||||||
|
|||||||
@ -37,6 +37,7 @@ class ModelTypeEnum(str, Enum):
|
|||||||
yolox = "yolox"
|
yolox = "yolox"
|
||||||
yolov9 = "yolov9"
|
yolov9 = "yolov9"
|
||||||
yolonas = "yolonas"
|
yolonas = "yolonas"
|
||||||
|
dfine = "dfine"
|
||||||
|
|
||||||
|
|
||||||
class ModelConfig(BaseModel):
|
class ModelConfig(BaseModel):
|
||||||
|
|||||||
@ -9,7 +9,11 @@ from frigate.detectors.detector_config import (
|
|||||||
BaseDetectorConfig,
|
BaseDetectorConfig,
|
||||||
ModelTypeEnum,
|
ModelTypeEnum,
|
||||||
)
|
)
|
||||||
from frigate.util.model import get_ort_providers, post_process_yolov9
|
from frigate.util.model import (
|
||||||
|
get_ort_providers,
|
||||||
|
post_process_dfine,
|
||||||
|
post_process_yolov9,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -41,6 +45,7 @@ class ONNXDetector(DetectionApi):
|
|||||||
providers, options = get_ort_providers(
|
providers, options = get_ort_providers(
|
||||||
detector_config.device == "CPU", detector_config.device
|
detector_config.device == "CPU", detector_config.device
|
||||||
)
|
)
|
||||||
|
|
||||||
self.model = ort.InferenceSession(
|
self.model = ort.InferenceSession(
|
||||||
path, providers=providers, provider_options=options
|
path, providers=providers, provider_options=options
|
||||||
)
|
)
|
||||||
@ -55,6 +60,16 @@ class ONNXDetector(DetectionApi):
|
|||||||
logger.info(f"ONNX: {path} loaded")
|
logger.info(f"ONNX: {path} loaded")
|
||||||
|
|
||||||
def detect_raw(self, tensor_input: np.ndarray):
|
def detect_raw(self, tensor_input: np.ndarray):
|
||||||
|
if self.onnx_model_type == ModelTypeEnum.dfine:
|
||||||
|
tensor_output = self.model.run(
|
||||||
|
None,
|
||||||
|
{
|
||||||
|
"images": tensor_input,
|
||||||
|
"orig_target_sizes": np.array([[self.h, self.w]], dtype=np.int64),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return post_process_dfine(tensor_output, self.w, self.h)
|
||||||
|
|
||||||
model_input_name = self.model.get_inputs()[0].name
|
model_input_name = self.model.get_inputs()[0].name
|
||||||
tensor_output = self.model.run(None, {model_input_name: tensor_input})
|
tensor_output = self.model.run(None, {model_input_name: tensor_input})
|
||||||
|
|
||||||
|
|||||||
@ -17,7 +17,7 @@ from frigate.config import FrigateConfig
|
|||||||
from frigate.const import CONFIG_DIR, FACE_DIR
|
from frigate.const import CONFIG_DIR, FACE_DIR
|
||||||
from frigate.data_processing.types import DataProcessorMetrics
|
from frigate.data_processing.types import DataProcessorMetrics
|
||||||
from frigate.db.sqlitevecq import SqliteVecQueueDatabase
|
from frigate.db.sqlitevecq import SqliteVecQueueDatabase
|
||||||
from frigate.models import Event
|
from frigate.models import Event, Recordings
|
||||||
from frigate.util.builtin import serialize
|
from frigate.util.builtin import serialize
|
||||||
from frigate.util.services import listen
|
from frigate.util.services import listen
|
||||||
|
|
||||||
@ -55,7 +55,7 @@ def manage_embeddings(config: FrigateConfig, metrics: DataProcessorMetrics) -> N
|
|||||||
timeout=max(60, 10 * len([c for c in config.cameras.values() if c.enabled])),
|
timeout=max(60, 10 * len([c for c in config.cameras.values() if c.enabled])),
|
||||||
load_vec_extension=True,
|
load_vec_extension=True,
|
||||||
)
|
)
|
||||||
models = [Event]
|
models = [Event, Recordings]
|
||||||
db.bind(models)
|
db.bind(models)
|
||||||
|
|
||||||
maintainer = EmbeddingMaintainer(
|
maintainer = EmbeddingMaintainer(
|
||||||
@ -234,3 +234,8 @@ class EmbeddingsContext:
|
|||||||
EmbeddingsRequestEnum.embed_description.value,
|
EmbeddingsRequestEnum.embed_description.value,
|
||||||
{"id": event_id, "description": description},
|
{"id": event_id, "description": description},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def reprocess_plate(self, event: dict[str, any]) -> dict[str, any]:
|
||||||
|
return self.requestor.send_data(
|
||||||
|
EmbeddingsRequestEnum.reprocess_plate.value, {"event": event}
|
||||||
|
)
|
||||||
|
|||||||
@ -20,18 +20,29 @@ from frigate.comms.event_metadata_updater import (
|
|||||||
)
|
)
|
||||||
from frigate.comms.events_updater import EventEndSubscriber, EventUpdateSubscriber
|
from frigate.comms.events_updater import EventEndSubscriber, EventUpdateSubscriber
|
||||||
from frigate.comms.inter_process import InterProcessRequestor
|
from frigate.comms.inter_process import InterProcessRequestor
|
||||||
|
from frigate.comms.recordings_updater import (
|
||||||
|
RecordingsDataSubscriber,
|
||||||
|
RecordingsDataTypeEnum,
|
||||||
|
)
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
from frigate.const import (
|
from frigate.const import (
|
||||||
CLIPS_DIR,
|
CLIPS_DIR,
|
||||||
UPDATE_EVENT_DESCRIPTION,
|
UPDATE_EVENT_DESCRIPTION,
|
||||||
)
|
)
|
||||||
from frigate.data_processing.real_time.api import RealTimeProcessorApi
|
from frigate.data_processing.common.license_plate.model import (
|
||||||
from frigate.data_processing.real_time.bird_processor import BirdProcessor
|
LicensePlateModelRunner,
|
||||||
from frigate.data_processing.real_time.face_processor import FaceProcessor
|
|
||||||
from frigate.data_processing.real_time.license_plate_processor import (
|
|
||||||
LicensePlateProcessor,
|
|
||||||
)
|
)
|
||||||
from frigate.data_processing.types import DataProcessorMetrics
|
from frigate.data_processing.post.api import PostProcessorApi
|
||||||
|
from frigate.data_processing.post.license_plate import (
|
||||||
|
LicensePlatePostProcessor,
|
||||||
|
)
|
||||||
|
from frigate.data_processing.real_time.api import RealTimeProcessorApi
|
||||||
|
from frigate.data_processing.real_time.bird import BirdRealTimeProcessor
|
||||||
|
from frigate.data_processing.real_time.face import FaceRealTimeProcessor
|
||||||
|
from frigate.data_processing.real_time.license_plate import (
|
||||||
|
LicensePlateRealTimeProcessor,
|
||||||
|
)
|
||||||
|
from frigate.data_processing.types import DataProcessorMetrics, PostProcessDataEnum
|
||||||
from frigate.events.types import EventTypeEnum
|
from frigate.events.types import EventTypeEnum
|
||||||
from frigate.genai import get_genai_client
|
from frigate.genai import get_genai_client
|
||||||
from frigate.models import Event
|
from frigate.models import Event
|
||||||
@ -66,40 +77,71 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
if config.semantic_search.reindex:
|
if config.semantic_search.reindex:
|
||||||
self.embeddings.reindex()
|
self.embeddings.reindex()
|
||||||
|
|
||||||
|
# create communication for updating event descriptions
|
||||||
|
self.requestor = InterProcessRequestor()
|
||||||
|
|
||||||
self.event_subscriber = EventUpdateSubscriber()
|
self.event_subscriber = EventUpdateSubscriber()
|
||||||
self.event_end_subscriber = EventEndSubscriber()
|
self.event_end_subscriber = EventEndSubscriber()
|
||||||
self.event_metadata_subscriber = EventMetadataSubscriber(
|
self.event_metadata_subscriber = EventMetadataSubscriber(
|
||||||
EventMetadataTypeEnum.regenerate_description
|
EventMetadataTypeEnum.regenerate_description
|
||||||
)
|
)
|
||||||
|
self.recordings_subscriber = RecordingsDataSubscriber(
|
||||||
|
RecordingsDataTypeEnum.recordings_available_through
|
||||||
|
)
|
||||||
self.embeddings_responder = EmbeddingsResponder()
|
self.embeddings_responder = EmbeddingsResponder()
|
||||||
self.frame_manager = SharedMemoryFrameManager()
|
self.frame_manager = SharedMemoryFrameManager()
|
||||||
self.processors: list[RealTimeProcessorApi] = []
|
|
||||||
|
self.detected_license_plates: dict[str, dict[str, any]] = {}
|
||||||
|
|
||||||
|
# model runners to share between realtime and post processors
|
||||||
|
if self.config.lpr.enabled:
|
||||||
|
lpr_model_runner = LicensePlateModelRunner(self.requestor)
|
||||||
|
|
||||||
|
# realtime processors
|
||||||
|
self.realtime_processors: list[RealTimeProcessorApi] = []
|
||||||
|
|
||||||
if self.config.face_recognition.enabled:
|
if self.config.face_recognition.enabled:
|
||||||
self.processors.append(FaceProcessor(self.config, metrics))
|
self.realtime_processors.append(FaceRealTimeProcessor(self.config, metrics))
|
||||||
|
|
||||||
if self.config.classification.bird.enabled:
|
if self.config.classification.bird.enabled:
|
||||||
self.processors.append(BirdProcessor(self.config, metrics))
|
self.realtime_processors.append(BirdRealTimeProcessor(self.config, metrics))
|
||||||
|
|
||||||
if self.config.lpr.enabled:
|
if self.config.lpr.enabled:
|
||||||
self.processors.append(LicensePlateProcessor(self.config, metrics))
|
self.realtime_processors.append(
|
||||||
|
LicensePlateRealTimeProcessor(
|
||||||
|
self.config, metrics, lpr_model_runner, self.detected_license_plates
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# post processors
|
||||||
|
self.post_processors: list[PostProcessorApi] = []
|
||||||
|
|
||||||
|
if self.config.lpr.enabled:
|
||||||
|
self.post_processors.append(
|
||||||
|
LicensePlatePostProcessor(
|
||||||
|
self.config, metrics, lpr_model_runner, self.detected_license_plates
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# create communication for updating event descriptions
|
|
||||||
self.requestor = InterProcessRequestor()
|
|
||||||
self.stop_event = stop_event
|
self.stop_event = stop_event
|
||||||
self.tracked_events: dict[str, list[any]] = {}
|
self.tracked_events: dict[str, list[any]] = {}
|
||||||
self.genai_client = get_genai_client(config)
|
self.genai_client = get_genai_client(config)
|
||||||
|
|
||||||
|
# recordings data
|
||||||
|
self.recordings_available_through: dict[str, float] = {}
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
"""Maintain a SQLite-vec database for semantic search."""
|
"""Maintain a SQLite-vec database for semantic search."""
|
||||||
while not self.stop_event.is_set():
|
while not self.stop_event.is_set():
|
||||||
self._process_requests()
|
self._process_requests()
|
||||||
self._process_updates()
|
self._process_updates()
|
||||||
|
self._process_recordings_updates()
|
||||||
self._process_finalized()
|
self._process_finalized()
|
||||||
self._process_event_metadata()
|
self._process_event_metadata()
|
||||||
|
|
||||||
self.event_subscriber.stop()
|
self.event_subscriber.stop()
|
||||||
self.event_end_subscriber.stop()
|
self.event_end_subscriber.stop()
|
||||||
|
self.recordings_subscriber.stop()
|
||||||
self.event_metadata_subscriber.stop()
|
self.event_metadata_subscriber.stop()
|
||||||
self.embeddings_responder.stop()
|
self.embeddings_responder.stop()
|
||||||
self.requestor.stop()
|
self.requestor.stop()
|
||||||
@ -129,13 +171,15 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
pack=False,
|
pack=False,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
for processor in self.processors:
|
processors = [self.realtime_processors, self.post_processors]
|
||||||
resp = processor.handle_request(topic, data)
|
for processor_list in processors:
|
||||||
|
for processor in processor_list:
|
||||||
|
resp = processor.handle_request(topic, data)
|
||||||
|
|
||||||
if resp is not None:
|
if resp is not None:
|
||||||
return resp
|
return resp
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Unable to handle embeddings request {e}")
|
logger.error(f"Unable to handle embeddings request {e}", exc_info=True)
|
||||||
|
|
||||||
self.embeddings_responder.check_for_request(_handle_request)
|
self.embeddings_responder.check_for_request(_handle_request)
|
||||||
|
|
||||||
@ -154,7 +198,7 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
camera_config = self.config.cameras[camera]
|
camera_config = self.config.cameras[camera]
|
||||||
|
|
||||||
# no need to process updated objects if face recognition, lpr, genai are disabled
|
# no need to process updated objects if face recognition, lpr, genai are disabled
|
||||||
if not camera_config.genai.enabled and len(self.processors) == 0:
|
if not camera_config.genai.enabled and len(self.realtime_processors) == 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Create our own thumbnail based on the bounding box and the frame time
|
# Create our own thumbnail based on the bounding box and the frame time
|
||||||
@ -171,7 +215,7 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
for processor in self.processors:
|
for processor in self.realtime_processors:
|
||||||
processor.process_frame(data, yuv_frame)
|
processor.process_frame(data, yuv_frame)
|
||||||
|
|
||||||
# no need to save our own thumbnails if genai is not enabled
|
# no need to save our own thumbnails if genai is not enabled
|
||||||
@ -202,7 +246,32 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
event_id, camera, updated_db = ended
|
event_id, camera, updated_db = ended
|
||||||
camera_config = self.config.cameras[camera]
|
camera_config = self.config.cameras[camera]
|
||||||
|
|
||||||
for processor in self.processors:
|
# call any defined post processors
|
||||||
|
for processor in self.post_processors:
|
||||||
|
if isinstance(processor, LicensePlatePostProcessor):
|
||||||
|
recordings_available = self.recordings_available_through.get(camera)
|
||||||
|
if (
|
||||||
|
recordings_available is not None
|
||||||
|
and event_id in self.detected_license_plates
|
||||||
|
):
|
||||||
|
processor.process_data(
|
||||||
|
{
|
||||||
|
"event_id": event_id,
|
||||||
|
"camera": camera,
|
||||||
|
"recordings_available": self.recordings_available_through[
|
||||||
|
camera
|
||||||
|
],
|
||||||
|
"obj_data": self.detected_license_plates[event_id][
|
||||||
|
"obj_data"
|
||||||
|
],
|
||||||
|
},
|
||||||
|
PostProcessDataEnum.recording,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
processor.process_data(event_id, PostProcessDataEnum.event_id)
|
||||||
|
|
||||||
|
# expire in realtime processors
|
||||||
|
for processor in self.realtime_processors:
|
||||||
processor.expire_object(event_id)
|
processor.expire_object(event_id)
|
||||||
|
|
||||||
if updated_db:
|
if updated_db:
|
||||||
@ -315,6 +384,24 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
if event_id in self.tracked_events:
|
if event_id in self.tracked_events:
|
||||||
del self.tracked_events[event_id]
|
del self.tracked_events[event_id]
|
||||||
|
|
||||||
|
def _process_recordings_updates(self) -> None:
|
||||||
|
"""Process recordings updates."""
|
||||||
|
while True:
|
||||||
|
recordings_data = self.recordings_subscriber.check_for_update(timeout=0.01)
|
||||||
|
|
||||||
|
if recordings_data == None:
|
||||||
|
break
|
||||||
|
|
||||||
|
camera, recordings_available_through_timestamp = recordings_data
|
||||||
|
|
||||||
|
self.recordings_available_through[camera] = (
|
||||||
|
recordings_available_through_timestamp
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"{camera} now has recordings available through {recordings_available_through_timestamp}"
|
||||||
|
)
|
||||||
|
|
||||||
def _process_event_metadata(self):
|
def _process_event_metadata(self):
|
||||||
# Check for regenerate description requests
|
# Check for regenerate description requests
|
||||||
(topic, event_id, source) = self.event_metadata_subscriber.check_for_update(
|
(topic, event_id, source) = self.event_metadata_subscriber.check_for_update(
|
||||||
|
|||||||
@ -363,10 +363,13 @@ class RecordingExporter(threading.Thread):
|
|||||||
}
|
}
|
||||||
).execute()
|
).execute()
|
||||||
|
|
||||||
if self.playback_source == PlaybackSourceEnum.recordings:
|
try:
|
||||||
ffmpeg_cmd, playlist_lines = self.get_record_export_command(video_path)
|
if self.playback_source == PlaybackSourceEnum.recordings:
|
||||||
else:
|
ffmpeg_cmd, playlist_lines = self.get_record_export_command(video_path)
|
||||||
ffmpeg_cmd, playlist_lines = self.get_preview_export_command(video_path)
|
else:
|
||||||
|
ffmpeg_cmd, playlist_lines = self.get_preview_export_command(video_path)
|
||||||
|
except DoesNotExist:
|
||||||
|
return
|
||||||
|
|
||||||
p = sp.run(
|
p = sp.run(
|
||||||
ffmpeg_cmd,
|
ffmpeg_cmd,
|
||||||
|
|||||||
@ -19,6 +19,10 @@ import psutil
|
|||||||
from frigate.comms.config_updater import ConfigSubscriber
|
from frigate.comms.config_updater import ConfigSubscriber
|
||||||
from frigate.comms.detections_updater import DetectionSubscriber, DetectionTypeEnum
|
from frigate.comms.detections_updater import DetectionSubscriber, DetectionTypeEnum
|
||||||
from frigate.comms.inter_process import InterProcessRequestor
|
from frigate.comms.inter_process import InterProcessRequestor
|
||||||
|
from frigate.comms.recordings_updater import (
|
||||||
|
RecordingsDataPublisher,
|
||||||
|
RecordingsDataTypeEnum,
|
||||||
|
)
|
||||||
from frigate.config import FrigateConfig, RetainModeEnum
|
from frigate.config import FrigateConfig, RetainModeEnum
|
||||||
from frigate.const import (
|
from frigate.const import (
|
||||||
CACHE_DIR,
|
CACHE_DIR,
|
||||||
@ -70,6 +74,9 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
self.requestor = InterProcessRequestor()
|
self.requestor = InterProcessRequestor()
|
||||||
self.config_subscriber = ConfigSubscriber("config/record/")
|
self.config_subscriber = ConfigSubscriber("config/record/")
|
||||||
self.detection_subscriber = DetectionSubscriber(DetectionTypeEnum.all)
|
self.detection_subscriber = DetectionSubscriber(DetectionTypeEnum.all)
|
||||||
|
self.recordings_publisher = RecordingsDataPublisher(
|
||||||
|
RecordingsDataTypeEnum.recordings_available_through
|
||||||
|
)
|
||||||
|
|
||||||
self.stop_event = stop_event
|
self.stop_event = stop_event
|
||||||
self.object_recordings_info: dict[str, list] = defaultdict(list)
|
self.object_recordings_info: dict[str, list] = defaultdict(list)
|
||||||
@ -213,6 +220,16 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
[self.validate_and_move_segment(camera, reviews, r) for r in recordings]
|
[self.validate_and_move_segment(camera, reviews, r) for r in recordings]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# publish most recently available recording time and None if disabled
|
||||||
|
self.recordings_publisher.publish(
|
||||||
|
(
|
||||||
|
camera,
|
||||||
|
recordings[0]["start_time"].timestamp()
|
||||||
|
if self.config.cameras[camera].record.enabled
|
||||||
|
else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
recordings_to_insert: list[Optional[Recordings]] = await asyncio.gather(*tasks)
|
recordings_to_insert: list[Optional[Recordings]] = await asyncio.gather(*tasks)
|
||||||
|
|
||||||
# fire and forget recordings entries
|
# fire and forget recordings entries
|
||||||
@ -582,4 +599,5 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
self.requestor.stop()
|
self.requestor.stop()
|
||||||
self.config_subscriber.stop()
|
self.config_subscriber.stop()
|
||||||
self.detection_subscriber.stop()
|
self.detection_subscriber.stop()
|
||||||
|
self.recordings_publisher.stop()
|
||||||
logger.info("Exiting recording maintenance...")
|
logger.info("Exiting recording maintenance...")
|
||||||
|
|||||||
@ -275,7 +275,7 @@ class TestHttp(unittest.TestCase):
|
|||||||
event = client.get(f"/events/{id}").json()
|
event = client.get(f"/events/{id}").json()
|
||||||
assert event
|
assert event
|
||||||
assert event["id"] == id
|
assert event["id"] == id
|
||||||
assert event["sub_label"] == ""
|
assert event["sub_label"] == None
|
||||||
|
|
||||||
def test_sub_label_list(self):
|
def test_sub_label_list(self):
|
||||||
app = create_fastapi_app(
|
app = create_fastapi_app(
|
||||||
|
|||||||
@ -9,7 +9,34 @@ import onnxruntime as ort
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
### Post Processing
|
### Post Processing
|
||||||
|
def post_process_dfine(tensor_output: np.ndarray, width, height) -> np.ndarray:
|
||||||
|
class_ids = tensor_output[0][tensor_output[2] > 0.4]
|
||||||
|
boxes = tensor_output[1][tensor_output[2] > 0.4]
|
||||||
|
scores = tensor_output[2][tensor_output[2] > 0.4]
|
||||||
|
|
||||||
|
input_shape = np.array([height, width, height, width])
|
||||||
|
boxes = np.divide(boxes, input_shape, dtype=np.float32)
|
||||||
|
indices = cv2.dnn.NMSBoxes(boxes, scores, score_threshold=0.4, nms_threshold=0.4)
|
||||||
|
detections = np.zeros((20, 6), np.float32)
|
||||||
|
|
||||||
|
for i, (bbox, confidence, class_id) in enumerate(
|
||||||
|
zip(boxes[indices], scores[indices], class_ids[indices])
|
||||||
|
):
|
||||||
|
if i == 20:
|
||||||
|
break
|
||||||
|
|
||||||
|
detections[i] = [
|
||||||
|
class_id,
|
||||||
|
confidence,
|
||||||
|
bbox[1],
|
||||||
|
bbox[0],
|
||||||
|
bbox[3],
|
||||||
|
bbox[2],
|
||||||
|
]
|
||||||
|
|
||||||
|
return detections
|
||||||
|
|
||||||
|
|
||||||
def post_process_yolov9(predictions: np.ndarray, width, height) -> np.ndarray:
|
def post_process_yolov9(predictions: np.ndarray, width, height) -> np.ndarray:
|
||||||
|
|||||||
@ -659,25 +659,42 @@ def process_logs(
|
|||||||
if " " not in clean_line:
|
if " " not in clean_line:
|
||||||
clean_line = f"{datetime.now()} {clean_line}"
|
clean_line = f"{datetime.now()} {clean_line}"
|
||||||
|
|
||||||
# Find the position of the first double space to extract timestamp and message
|
try:
|
||||||
date_end = clean_line.index(" ")
|
# Find the position of the first double space to extract timestamp and message
|
||||||
timestamp = clean_line[:date_end]
|
date_end = clean_line.index(" ")
|
||||||
message_part = clean_line[date_end:].strip()
|
timestamp = clean_line[:date_end]
|
||||||
|
full_message = clean_line[date_end:].strip()
|
||||||
|
|
||||||
if message_part == last_message:
|
# For frigate, remove the date part from message comparison
|
||||||
repeat_count += 1
|
if service == "frigate":
|
||||||
continue
|
# Skip the date at the start of the message if it exists
|
||||||
else:
|
date_parts = full_message.split("]", 1)
|
||||||
if repeat_count > 0:
|
if len(date_parts) > 1:
|
||||||
# Insert a deduplication message formatted the same way as logs
|
message_part = date_parts[1].strip()
|
||||||
dedup_message = f"{last_timestamp} [LOGGING] Last message repeated {repeat_count} times"
|
else:
|
||||||
log_lines.append(dedup_message)
|
message_part = full_message
|
||||||
repeat_count = 0
|
else:
|
||||||
|
message_part = full_message
|
||||||
|
|
||||||
|
if message_part == last_message:
|
||||||
|
repeat_count += 1
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
if repeat_count > 0:
|
||||||
|
# Insert a deduplication message formatted the same way as logs
|
||||||
|
dedup_message = f"{last_timestamp} [LOGGING] Last message repeated {repeat_count} times"
|
||||||
|
log_lines.append(dedup_message)
|
||||||
|
repeat_count = 0
|
||||||
|
|
||||||
|
log_lines.append(clean_line)
|
||||||
|
last_timestamp = timestamp
|
||||||
|
|
||||||
|
last_message = message_part
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
# If we can't parse the line properly, just add it as is
|
||||||
log_lines.append(clean_line)
|
log_lines.append(clean_line)
|
||||||
last_timestamp = timestamp
|
continue
|
||||||
|
|
||||||
last_message = message_part
|
|
||||||
|
|
||||||
# If there were repeated messages at the end, log the count
|
# If there were repeated messages at the end, log the count
|
||||||
if repeat_count > 0:
|
if repeat_count > 0:
|
||||||
|
|||||||
@ -46,7 +46,7 @@ function useValue(): useValueReturn {
|
|||||||
|
|
||||||
const cameraActivity: { [key: string]: object } = JSON.parse(activityValue);
|
const cameraActivity: { [key: string]: object } = JSON.parse(activityValue);
|
||||||
|
|
||||||
if (!cameraActivity) {
|
if (Object.keys(cameraActivity).length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -71,6 +71,8 @@ import {
|
|||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import { LuInfo } from "react-icons/lu";
|
import { LuInfo } from "react-icons/lu";
|
||||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||||
|
import { FaPencilAlt } from "react-icons/fa";
|
||||||
|
import TextEntryDialog from "@/components/overlay/dialog/TextEntryDialog";
|
||||||
|
|
||||||
const SEARCH_TABS = [
|
const SEARCH_TABS = [
|
||||||
"details",
|
"details",
|
||||||
@ -288,6 +290,7 @@ function ObjectDetailsTab({
|
|||||||
// data
|
// data
|
||||||
|
|
||||||
const [desc, setDesc] = useState(search?.data.description);
|
const [desc, setDesc] = useState(search?.data.description);
|
||||||
|
const [isSubLabelDialogOpen, setIsSubLabelDialogOpen] = useState(false);
|
||||||
|
|
||||||
const handleDescriptionFocus = useCallback(() => {
|
const handleDescriptionFocus = useCallback(() => {
|
||||||
setInputFocused(true);
|
setInputFocused(true);
|
||||||
@ -430,6 +433,74 @@ function ObjectDetailsTab({
|
|||||||
[search, config],
|
[search, config],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleSubLabelSave = useCallback(
|
||||||
|
(text: string) => {
|
||||||
|
if (!search) return;
|
||||||
|
|
||||||
|
// set score to 1.0 if we're manually entering a sub label
|
||||||
|
const subLabelScore =
|
||||||
|
text === "" ? undefined : search.data?.sub_label_score || 1.0;
|
||||||
|
|
||||||
|
axios
|
||||||
|
.post(`${apiHost}api/events/${search.id}/sub_label`, {
|
||||||
|
camera: search.camera,
|
||||||
|
subLabel: text,
|
||||||
|
subLabelScore: subLabelScore,
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
if (response.status === 200) {
|
||||||
|
toast.success("Successfully updated sub label.", {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
|
||||||
|
mutate(
|
||||||
|
(key) =>
|
||||||
|
typeof key === "string" &&
|
||||||
|
(key.includes("events") ||
|
||||||
|
key.includes("events/search") ||
|
||||||
|
key.includes("events/explore")),
|
||||||
|
(currentData: SearchResult[][] | SearchResult[] | undefined) => {
|
||||||
|
if (!currentData) return currentData;
|
||||||
|
return currentData.flat().map((event) =>
|
||||||
|
event.id === search.id
|
||||||
|
? {
|
||||||
|
...event,
|
||||||
|
sub_label: text,
|
||||||
|
data: {
|
||||||
|
...event.data,
|
||||||
|
sub_label_score: subLabelScore,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: event,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
optimisticData: true,
|
||||||
|
rollbackOnError: true,
|
||||||
|
revalidate: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
setSearch({
|
||||||
|
...search,
|
||||||
|
sub_label: text,
|
||||||
|
data: {
|
||||||
|
...search.data,
|
||||||
|
sub_label_score: subLabelScore,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setIsSubLabelDialogOpen(false);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Failed to update sub label.", {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[search, apiHost, mutate, setSearch],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
<div className="flex w-full flex-row">
|
<div className="flex w-full flex-row">
|
||||||
@ -440,6 +511,21 @@ function ObjectDetailsTab({
|
|||||||
{getIconForLabel(search.label, "size-4 text-primary")}
|
{getIconForLabel(search.label, "size-4 text-primary")}
|
||||||
{search.label}
|
{search.label}
|
||||||
{search.sub_label && ` (${search.sub_label})`}
|
{search.sub_label && ` (${search.sub_label})`}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span>
|
||||||
|
<FaPencilAlt
|
||||||
|
className="size-4 cursor-pointer text-primary/40 hover:text-primary/80"
|
||||||
|
onClick={() => {
|
||||||
|
setIsSubLabelDialogOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipPortal>
|
||||||
|
<TooltipContent>Edit sub label</TooltipContent>
|
||||||
|
</TooltipPortal>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
@ -616,6 +702,15 @@ function ObjectDetailsTab({
|
|||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
<TextEntryDialog
|
||||||
|
open={isSubLabelDialogOpen}
|
||||||
|
setOpen={setIsSubLabelDialogOpen}
|
||||||
|
title="Edit Sub Label"
|
||||||
|
description={`Enter a new sub label for this ${search.label ?? "tracked object"}.`}
|
||||||
|
onSave={handleSubLabelSave}
|
||||||
|
defaultValue={search?.sub_label || ""}
|
||||||
|
allowEmpty={true}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import {
|
|||||||
import { Form, FormControl, FormField, FormItem } from "@/components/ui/form";
|
import { Form, FormControl, FormField, FormItem } from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useCallback } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@ -20,13 +20,18 @@ type TextEntryDialogProps = {
|
|||||||
description?: string;
|
description?: string;
|
||||||
setOpen: (open: boolean) => void;
|
setOpen: (open: boolean) => void;
|
||||||
onSave: (text: string) => void;
|
onSave: (text: string) => void;
|
||||||
|
defaultValue?: string;
|
||||||
|
allowEmpty?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function TextEntryDialog({
|
export default function TextEntryDialog({
|
||||||
open,
|
open,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
setOpen,
|
setOpen,
|
||||||
onSave,
|
onSave,
|
||||||
|
defaultValue = "",
|
||||||
|
allowEmpty = false,
|
||||||
}: TextEntryDialogProps) {
|
}: TextEntryDialogProps) {
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
text: z.string(),
|
text: z.string(),
|
||||||
@ -34,6 +39,7 @@ export default function TextEntryDialog({
|
|||||||
|
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: { text: defaultValue },
|
||||||
});
|
});
|
||||||
const fileRef = form.register("text");
|
const fileRef = form.register("text");
|
||||||
|
|
||||||
@ -41,15 +47,20 @@ export default function TextEntryDialog({
|
|||||||
|
|
||||||
const onSubmit = useCallback(
|
const onSubmit = useCallback(
|
||||||
(data: z.infer<typeof formSchema>) => {
|
(data: z.infer<typeof formSchema>) => {
|
||||||
if (!data["text"]) {
|
if (!allowEmpty && !data["text"]) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
onSave(data["text"]);
|
onSave(data["text"]);
|
||||||
},
|
},
|
||||||
[onSave],
|
[onSave, allowEmpty],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
form.reset({ text: defaultValue });
|
||||||
|
}
|
||||||
|
}, [open, defaultValue, form]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} defaultOpen={false} onOpenChange={setOpen}>
|
<Dialog open={open} defaultOpen={false} onOpenChange={setOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
@ -75,7 +86,9 @@ export default function TextEntryDialog({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<DialogFooter className="pt-4">
|
<DialogFooter className="pt-4">
|
||||||
<Button onClick={() => setOpen(false)}>Cancel</Button>
|
<Button type="button" onClick={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
<Button variant="select" type="submit">
|
<Button variant="select" type="submit">
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user