mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-03 06:50:58 +00:00
Compare commits
13 Commits
b5c93c7d9a
...
9a20d6771e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a20d6771e | ||
|
|
a510ea9036 | ||
|
|
e1bc7360ad | ||
|
|
4638c22c16 | ||
|
|
81faa8899d | ||
|
|
043bd9e6ee | ||
|
|
9f0b6004f2 | ||
|
|
b751228476 | ||
|
|
3b2d136665 | ||
|
|
e7394d0dc1 | ||
|
|
2e288109f4 | ||
|
|
bb45483e9e | ||
|
|
7b4eaf2d10 |
26
.github/workflows/ci.yml
vendored
26
.github/workflows/ci.yml
vendored
@ -225,3 +225,29 @@ jobs:
|
|||||||
sources: |
|
sources: |
|
||||||
ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}:${{ env.SHORT_SHA }}-amd64
|
ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}:${{ env.SHORT_SHA }}-amd64
|
||||||
ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}:${{ env.SHORT_SHA }}-rpi
|
ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}:${{ env.SHORT_SHA }}-rpi
|
||||||
|
axera_build:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
name: AXERA Build
|
||||||
|
needs:
|
||||||
|
- amd64_build
|
||||||
|
- arm64_build
|
||||||
|
steps:
|
||||||
|
- name: Check out code
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Set up QEMU and Buildx
|
||||||
|
id: setup
|
||||||
|
uses: ./.github/actions/setup
|
||||||
|
with:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: Build and push Axera build
|
||||||
|
uses: docker/bake-action@v6
|
||||||
|
with:
|
||||||
|
source: .
|
||||||
|
push: true
|
||||||
|
targets: axcl
|
||||||
|
files: docker/axcl/axcl.hcl
|
||||||
|
set: |
|
||||||
|
axcl.tags=${{ steps.setup.outputs.image-name }}-axcl
|
||||||
|
*.cache-from=type=gha
|
||||||
59
docker/axcl/Dockerfile
Normal file
59
docker/axcl/Dockerfile
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
# syntax=docker/dockerfile:1.6
|
||||||
|
|
||||||
|
# https://askubuntu.com/questions/972516/debian-frontend-environment-variable
|
||||||
|
ARG DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# Globally set pip break-system-packages option to avoid having to specify it every time
|
||||||
|
ARG PIP_BREAK_SYSTEM_PACKAGES=1
|
||||||
|
|
||||||
|
|
||||||
|
FROM frigate AS frigate-axcl
|
||||||
|
ARG TARGETARCH
|
||||||
|
ARG PIP_BREAK_SYSTEM_PACKAGES
|
||||||
|
|
||||||
|
# Install axmodels
|
||||||
|
RUN mkdir -p /axmodels \
|
||||||
|
&& wget https://github.com/ivanshi1108/assets/releases/download/v0.16.2/yolov5s_320.axmodel -O /axmodels/yolov5s_320.axmodel
|
||||||
|
|
||||||
|
# Install axpyengine
|
||||||
|
RUN wget https://github.com/AXERA-TECH/pyaxengine/releases/download/0.1.3.rc1/axengine-0.1.3-py3-none-any.whl -O /axengine-0.1.3-py3-none-any.whl
|
||||||
|
RUN pip3 install -i https://mirrors.aliyun.com/pypi/simple/ /axengine-0.1.3-py3-none-any.whl \
|
||||||
|
&& rm /axengine-0.1.3-py3-none-any.whl
|
||||||
|
|
||||||
|
# Install axcl
|
||||||
|
RUN if [ "$TARGETARCH" = "amd64" ]; then \
|
||||||
|
echo "Installing x86_64 version of axcl"; \
|
||||||
|
wget https://github.com/ivanshi1108/assets/releases/download/v0.16.2/axcl_host_x86_64_V3.6.5_20250908154509_NO4973.deb -O /axcl.deb; \
|
||||||
|
else \
|
||||||
|
echo "Installing aarch64 version of axcl"; \
|
||||||
|
wget https://github.com/ivanshi1108/assets/releases/download/v0.16.2/axcl_host_aarch64_V3.6.5_20250908154509_NO4973.deb -O /axcl.deb; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
RUN mkdir /unpack_axcl && \
|
||||||
|
dpkg-deb -x /axcl.deb /unpack_axcl && \
|
||||||
|
cp -R /unpack_axcl/usr/bin/axcl /usr/bin/ && \
|
||||||
|
cp -R /unpack_axcl/usr/lib/axcl /usr/lib/ && \
|
||||||
|
rm -rf /unpack_axcl /axcl.deb
|
||||||
|
|
||||||
|
|
||||||
|
# Install axcl ffmpeg
|
||||||
|
RUN mkdir -p /usr/lib/ffmpeg/axcl
|
||||||
|
|
||||||
|
RUN if [ "$TARGETARCH" = "amd64" ]; then \
|
||||||
|
wget https://github.com/ivanshi1108/assets/releases/download/v0.16.2/ffmpeg-x64 -O /usr/lib/ffmpeg/axcl/ffmpeg && \
|
||||||
|
wget https://github.com/ivanshi1108/assets/releases/download/v0.16.2/ffprobe-x64 -O /usr/lib/ffmpeg/axcl/ffprobe; \
|
||||||
|
else \
|
||||||
|
wget https://github.com/ivanshi1108/assets/releases/download/v0.16.2/ffmpeg-aarch64 -O /usr/lib/ffmpeg/axcl/ffmpeg && \
|
||||||
|
wget https://github.com/ivanshi1108/assets/releases/download/v0.16.2/ffprobe-aarch64 -O /usr/lib/ffmpeg/axcl/ffprobe; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
RUN chmod +x /usr/lib/ffmpeg/axcl/ffmpeg /usr/lib/ffmpeg/axcl/ffprobe
|
||||||
|
|
||||||
|
# Set ldconfig path
|
||||||
|
RUN echo "/usr/lib/axcl" > /etc/ld.so.conf.d/ax.conf
|
||||||
|
|
||||||
|
# Set env
|
||||||
|
ENV PATH="$PATH:/usr/bin/axcl"
|
||||||
|
ENV LD_LIBRARY_PATH="$LD_LIBRARY_PATH:/usr/lib/axcl"
|
||||||
|
|
||||||
|
ENTRYPOINT ["sh", "-c", "ldconfig && exec /init"]
|
||||||
13
docker/axcl/axcl.hcl
Normal file
13
docker/axcl/axcl.hcl
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
target frigate {
|
||||||
|
dockerfile = "docker/main/Dockerfile"
|
||||||
|
platforms = ["linux/amd64", "linux/arm64"]
|
||||||
|
target = "frigate"
|
||||||
|
}
|
||||||
|
|
||||||
|
target axcl {
|
||||||
|
dockerfile = "docker/axcl/Dockerfile"
|
||||||
|
contexts = {
|
||||||
|
frigate = "target:frigate",
|
||||||
|
}
|
||||||
|
platforms = ["linux/amd64", "linux/arm64"]
|
||||||
|
}
|
||||||
15
docker/axcl/axcl.mk
Normal file
15
docker/axcl/axcl.mk
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
BOARDS += axcl
|
||||||
|
|
||||||
|
local-axcl: version
|
||||||
|
docker buildx bake --file=docker/axcl/axcl.hcl axcl \
|
||||||
|
--set axcl.tags=frigate:latest-axcl \
|
||||||
|
--load
|
||||||
|
|
||||||
|
build-axcl: version
|
||||||
|
docker buildx bake --file=docker/axcl/axcl.hcl axcl \
|
||||||
|
--set axcl.tags=$(IMAGE_REPO):${GITHUB_REF_NAME}-$(COMMIT_HASH)-axcl
|
||||||
|
|
||||||
|
push-axcl: build-axcl
|
||||||
|
docker buildx bake --file=docker/axcl/axcl.hcl axcl \
|
||||||
|
--set axcl.tags=$(IMAGE_REPO):${GITHUB_REF_NAME}-$(COMMIT_HASH)-axcl \
|
||||||
|
--push
|
||||||
83
docker/axcl/user_installation.sh
Executable file
83
docker/axcl/user_installation.sh
Executable file
@ -0,0 +1,83 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Update package list and install dependencies
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y build-essential cmake git wget pciutils kmod udev
|
||||||
|
|
||||||
|
# Check if gcc-12 is needed
|
||||||
|
current_gcc_version=$(gcc --version | head -n1 | awk '{print $NF}')
|
||||||
|
gcc_major_version=$(echo $current_gcc_version | cut -d'.' -f1)
|
||||||
|
|
||||||
|
if [[ $gcc_major_version -lt 12 ]]; then
|
||||||
|
echo "Current GCC version ($current_gcc_version) is lower than 12, installing gcc-12..."
|
||||||
|
sudo apt-get install -y gcc-12
|
||||||
|
sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-12 12
|
||||||
|
echo "GCC-12 installed and set as default"
|
||||||
|
else
|
||||||
|
echo "Current GCC version ($current_gcc_version) is sufficient, skipping GCC installation"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Determine architecture
|
||||||
|
arch=$(uname -m)
|
||||||
|
download_url=""
|
||||||
|
|
||||||
|
if [[ $arch == "x86_64" ]]; then
|
||||||
|
download_url="https://github.com/ivanshi1108/assets/releases/download/v0.16.2/axcl_host_x86_64_V3.6.5_20250908154509_NO4973.deb"
|
||||||
|
deb_file="axcl_host_x86_64_V3.6.5_20250908154509_NO4973.deb"
|
||||||
|
elif [[ $arch == "aarch64" ]]; then
|
||||||
|
download_url="https://github.com/ivanshi1108/assets/releases/download/v0.16.2/axcl_host_aarch64_V3.6.5_20250908154509_NO4973.deb"
|
||||||
|
deb_file="axcl_host_aarch64_V3.6.5_20250908154509_NO4973.deb"
|
||||||
|
else
|
||||||
|
echo "Unsupported architecture: $arch"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Download AXCL driver
|
||||||
|
echo "Downloading AXCL driver for $arch..."
|
||||||
|
wget "$download_url" -O "$deb_file"
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "Failed to download AXCL driver"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install AXCL driver
|
||||||
|
echo "Installing AXCL driver..."
|
||||||
|
sudo dpkg -i "$deb_file"
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "Failed to install AXCL driver, attempting to fix dependencies..."
|
||||||
|
sudo apt-get install -f -y
|
||||||
|
sudo dpkg -i "$deb_file"
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "AXCL driver installation failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Update environment
|
||||||
|
echo "Updating environment..."
|
||||||
|
source /etc/profile
|
||||||
|
|
||||||
|
# Verify installation
|
||||||
|
echo "Verifying AXCL installation..."
|
||||||
|
if command -v axcl-smi &> /dev/null; then
|
||||||
|
echo "AXCL driver detected, checking AI accelerator status..."
|
||||||
|
|
||||||
|
axcl_output=$(axcl-smi 2>&1)
|
||||||
|
axcl_exit_code=$?
|
||||||
|
|
||||||
|
echo "$axcl_output"
|
||||||
|
|
||||||
|
if [ $axcl_exit_code -eq 0 ]; then
|
||||||
|
echo "AXCL driver installation completed successfully!"
|
||||||
|
else
|
||||||
|
echo "AXCL driver installed but no AI accelerator detected or communication failed."
|
||||||
|
echo "Please check if the AI accelerator is properly connected and powered on."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "axcl-smi command not found. AXCL driver installation may have failed."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@ -5,21 +5,27 @@ set -euxo pipefail
|
|||||||
SQLITE3_VERSION="3.46.1"
|
SQLITE3_VERSION="3.46.1"
|
||||||
PYSQLITE3_VERSION="0.5.3"
|
PYSQLITE3_VERSION="0.5.3"
|
||||||
|
|
||||||
|
# Install libsqlite3-dev if not present (needed for some base images like NVIDIA TensorRT)
|
||||||
|
if ! dpkg -l | grep -q libsqlite3-dev; then
|
||||||
|
echo "Installing libsqlite3-dev for compilation..."
|
||||||
|
apt-get update && apt-get install -y libsqlite3-dev && rm -rf /var/lib/apt/lists/*
|
||||||
|
fi
|
||||||
|
|
||||||
# Fetch the pre-built sqlite amalgamation instead of building from source
|
# Fetch the pre-built sqlite amalgamation instead of building from source
|
||||||
if [[ ! -d "sqlite" ]]; then
|
if [[ ! -d "sqlite" ]]; then
|
||||||
mkdir sqlite
|
mkdir sqlite
|
||||||
cd sqlite
|
cd sqlite
|
||||||
|
|
||||||
# Download the pre-built amalgamation from sqlite.org
|
# Download the pre-built amalgamation from sqlite.org
|
||||||
# For SQLite 3.46.1, the amalgamation version is 3460100
|
# For SQLite 3.46.1, the amalgamation version is 3460100
|
||||||
SQLITE_AMALGAMATION_VERSION="3460100"
|
SQLITE_AMALGAMATION_VERSION="3460100"
|
||||||
|
|
||||||
wget https://www.sqlite.org/2024/sqlite-amalgamation-${SQLITE_AMALGAMATION_VERSION}.zip -O sqlite-amalgamation.zip
|
wget https://www.sqlite.org/2024/sqlite-amalgamation-${SQLITE_AMALGAMATION_VERSION}.zip -O sqlite-amalgamation.zip
|
||||||
unzip sqlite-amalgamation.zip
|
unzip sqlite-amalgamation.zip
|
||||||
mv sqlite-amalgamation-${SQLITE_AMALGAMATION_VERSION}/* .
|
mv sqlite-amalgamation-${SQLITE_AMALGAMATION_VERSION}/* .
|
||||||
rmdir sqlite-amalgamation-${SQLITE_AMALGAMATION_VERSION}
|
rmdir sqlite-amalgamation-${SQLITE_AMALGAMATION_VERSION}
|
||||||
rm sqlite-amalgamation.zip
|
rm sqlite-amalgamation.zip
|
||||||
|
|
||||||
cd ../
|
cd ../
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@ -112,7 +112,7 @@ RUN apt-get update \
|
|||||||
&& apt-get install -y protobuf-compiler libprotobuf-dev \
|
&& apt-get install -y protobuf-compiler libprotobuf-dev \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
RUN --mount=type=bind,source=docker/tensorrt/requirements-models-arm64.txt,target=/requirements-tensorrt-models.txt \
|
RUN --mount=type=bind,source=docker/tensorrt/requirements-models-arm64.txt,target=/requirements-tensorrt-models.txt \
|
||||||
pip3 wheel --wheel-dir=/trt-model-wheels -r /requirements-tensorrt-models.txt
|
pip3 wheel --wheel-dir=/trt-model-wheels --no-deps -r /requirements-tensorrt-models.txt
|
||||||
|
|
||||||
FROM wget AS jetson-ffmpeg
|
FROM wget AS jetson-ffmpeg
|
||||||
ARG DEBIAN_FRONTEND
|
ARG DEBIAN_FRONTEND
|
||||||
@ -145,7 +145,8 @@ COPY --from=trt-wheels /etc/TENSORRT_VER /etc/TENSORRT_VER
|
|||||||
RUN --mount=type=bind,from=trt-wheels,source=/trt-wheels,target=/deps/trt-wheels \
|
RUN --mount=type=bind,from=trt-wheels,source=/trt-wheels,target=/deps/trt-wheels \
|
||||||
--mount=type=bind,from=trt-model-wheels,source=/trt-model-wheels,target=/deps/trt-model-wheels \
|
--mount=type=bind,from=trt-model-wheels,source=/trt-model-wheels,target=/deps/trt-model-wheels \
|
||||||
pip3 uninstall -y onnxruntime \
|
pip3 uninstall -y onnxruntime \
|
||||||
&& pip3 install -U /deps/trt-wheels/*.whl /deps/trt-model-wheels/*.whl \
|
&& pip3 install -U /deps/trt-wheels/*.whl \
|
||||||
|
&& pip3 install -U /deps/trt-model-wheels/*.whl \
|
||||||
&& ldconfig
|
&& ldconfig
|
||||||
|
|
||||||
WORKDIR /opt/frigate/
|
WORKDIR /opt/frigate/
|
||||||
|
|||||||
@ -47,6 +47,11 @@ Frigate supports multiple different detectors that work on different types of ha
|
|||||||
|
|
||||||
- [Synaptics](#synaptics): synap models can run on Synaptics devices(e.g astra machina) with included NPUs.
|
- [Synaptics](#synaptics): synap models can run on Synaptics devices(e.g astra machina) with included NPUs.
|
||||||
|
|
||||||
|
**AXERA**
|
||||||
|
|
||||||
|
- [AXEngine](#axera): axmodels can run on AXERA AI acceleration.
|
||||||
|
|
||||||
|
|
||||||
**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.
|
||||||
@ -1167,6 +1172,40 @@ model: # required
|
|||||||
labelmap_path: /labelmap/coco-80.txt # required
|
labelmap_path: /labelmap/coco-80.txt # required
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## AXERA
|
||||||
|
|
||||||
|
Hardware accelerated object detection is supported on the following SoCs:
|
||||||
|
|
||||||
|
- AX650N
|
||||||
|
- AX8850N
|
||||||
|
|
||||||
|
This implementation uses the [AXera Pulsar2 Toolchain](https://huggingface.co/AXERA-TECH/Pulsar2).
|
||||||
|
|
||||||
|
See the [installation docs](../frigate/installation.md#axera) for information on configuring the AXEngine hardware.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
When configuring the AXEngine detector, you have to specify the model name.
|
||||||
|
|
||||||
|
#### yolov5s
|
||||||
|
|
||||||
|
A yolov5s model is provided in the container at /axmodels and is used by this detector type by default.
|
||||||
|
|
||||||
|
Use the model configuration shown below when using the axengine detector with the default axmodel:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
detectors: # required
|
||||||
|
axengine: # required
|
||||||
|
type: axengine # required
|
||||||
|
|
||||||
|
model: # required
|
||||||
|
path: yolov5s_320 # required
|
||||||
|
width: 320 # required
|
||||||
|
height: 320 # required
|
||||||
|
tensor_format: bgr # required
|
||||||
|
labelmap_path: /labelmap/coco-80.txt # required
|
||||||
|
```
|
||||||
|
|
||||||
## Rockchip platform
|
## Rockchip platform
|
||||||
|
|
||||||
Hardware accelerated object detection is supported on the following SoCs:
|
Hardware accelerated object detection is supported on the following SoCs:
|
||||||
|
|||||||
@ -110,6 +110,14 @@ Frigate supports multiple different detectors that work on different types of ha
|
|||||||
| ssd mobilenet | ~ 25 ms |
|
| ssd mobilenet | ~ 25 ms |
|
||||||
| yolov5m | ~ 118 ms |
|
| yolov5m | ~ 118 ms |
|
||||||
|
|
||||||
|
### AXERA
|
||||||
|
|
||||||
|
- **AXEngine** Default model is **yolov5s_320**
|
||||||
|
|
||||||
|
| Name | AXERA AX650N/AX8850N Inference Time |
|
||||||
|
| ---------------- | ----------------------------------- |
|
||||||
|
| yolov5s_320 | ~ 1.676 ms |
|
||||||
|
|
||||||
### Hailo-8
|
### Hailo-8
|
||||||
|
|
||||||
Frigate supports both the Hailo-8 and Hailo-8L AI Acceleration Modules on compatible hardware platforms—including the Raspberry Pi 5 with the PCIe hat from the AI kit. The Hailo detector integration in Frigate automatically identifies your hardware type and selects the appropriate default model when a custom model isn’t provided.
|
Frigate supports both the Hailo-8 and Hailo-8L AI Acceleration Modules on compatible hardware platforms—including the Raspberry Pi 5 with the PCIe hat from the AI kit. The Hailo detector integration in Frigate automatically identifies your hardware type and selects the appropriate default model when a custom model isn’t provided.
|
||||||
|
|||||||
@ -287,6 +287,40 @@ or add these options to your `docker run` command:
|
|||||||
|
|
||||||
Next, you should configure [hardware object detection](/configuration/object_detectors#synaptics) and [hardware video processing](/configuration/hardware_acceleration_video#synaptics).
|
Next, you should configure [hardware object detection](/configuration/object_detectors#synaptics) and [hardware video processing](/configuration/hardware_acceleration_video#synaptics).
|
||||||
|
|
||||||
|
### AXERA
|
||||||
|
|
||||||
|
AXERA accelerators are available in an M.2 form factor, compatible with both Raspberry Pi and Orange Pi. This form factor has also been successfully tested on x86 platforms, making it a versatile choice for various computing environments.
|
||||||
|
|
||||||
|
#### Installation
|
||||||
|
|
||||||
|
Using AXERA accelerators requires the installation of the AXCL driver. We provide a convenient Linux script to complete this installation.
|
||||||
|
|
||||||
|
Follow these steps for installation:
|
||||||
|
|
||||||
|
1. Copy or download [this script](https://github.com/ivanshi1108/assets/releases/download/v0.16.2/user_installation.sh).
|
||||||
|
2. Ensure it has execution permissions with `sudo chmod +x user_installation.sh`
|
||||||
|
3. Run the script with `./user_installation.sh`
|
||||||
|
|
||||||
|
#### Setup
|
||||||
|
|
||||||
|
To set up Frigate, follow the default installation instructions, for example: `ghcr.io/blakeblackshear/frigate:stable`
|
||||||
|
|
||||||
|
Next, grant Docker permissions to access your hardware by adding the following lines to your `docker-compose.yml` file:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
devices:
|
||||||
|
- /dev/axcl_host
|
||||||
|
- /dev/ax_mmb_dev
|
||||||
|
- /dev/msg_userdev
|
||||||
|
```
|
||||||
|
|
||||||
|
If you are using `docker run`, add this option to your command `--device /dev/axcl_host --device /dev/ax_mmb_dev --device /dev/msg_userdev`
|
||||||
|
|
||||||
|
#### Configuration
|
||||||
|
|
||||||
|
Finally, configure [hardware object detection](/configuration/object_detectors#axera) to complete the setup.
|
||||||
|
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
Running through Docker with Docker Compose is the recommended install method.
|
Running through Docker with Docker Compose is the recommended install method.
|
||||||
|
|||||||
@ -38,7 +38,7 @@ from frigate.util.classification import (
|
|||||||
collect_object_classification_examples,
|
collect_object_classification_examples,
|
||||||
collect_state_classification_examples,
|
collect_state_classification_examples,
|
||||||
)
|
)
|
||||||
from frigate.util.path import get_event_snapshot
|
from frigate.util.file import get_event_snapshot
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@ -58,7 +58,7 @@ from frigate.const import CLIPS_DIR, TRIGGER_DIR
|
|||||||
from frigate.embeddings import EmbeddingsContext
|
from frigate.embeddings import EmbeddingsContext
|
||||||
from frigate.models import Event, ReviewSegment, Timeline, Trigger
|
from frigate.models import Event, ReviewSegment, Timeline, Trigger
|
||||||
from frigate.track.object_processing import TrackedObject
|
from frigate.track.object_processing import TrackedObject
|
||||||
from frigate.util.path import get_event_thumbnail_bytes
|
from frigate.util.file import get_event_thumbnail_bytes
|
||||||
from frigate.util.time import get_dst_transitions, get_tz_modifiers
|
from frigate.util.time import get_dst_transitions, get_tz_modifiers
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|||||||
@ -44,8 +44,8 @@ from frigate.const import (
|
|||||||
)
|
)
|
||||||
from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment
|
from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment
|
||||||
from frigate.track.object_processing import TrackedObjectProcessor
|
from frigate.track.object_processing import TrackedObjectProcessor
|
||||||
|
from frigate.util.file import get_event_thumbnail_bytes
|
||||||
from frigate.util.image import get_image_from_recording
|
from frigate.util.image import get_image_from_recording
|
||||||
from frigate.util.path import get_event_thumbnail_bytes
|
|
||||||
from frigate.util.time import get_dst_transitions
|
from frigate.util.time import get_dst_transitions
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|||||||
@ -20,8 +20,8 @@ from frigate.genai import GenAIClient
|
|||||||
from frigate.models import Event
|
from frigate.models import Event
|
||||||
from frigate.types import TrackedObjectUpdateTypesEnum
|
from frigate.types import TrackedObjectUpdateTypesEnum
|
||||||
from frigate.util.builtin import EventsPerSecond, InferenceSpeed
|
from frigate.util.builtin import EventsPerSecond, InferenceSpeed
|
||||||
|
from frigate.util.file import get_event_thumbnail_bytes
|
||||||
from frigate.util.image import create_thumbnail, ensure_jpeg_bytes
|
from frigate.util.image import create_thumbnail, ensure_jpeg_bytes
|
||||||
from frigate.util.path import get_event_thumbnail_bytes
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from frigate.embeddings import Embeddings
|
from frigate.embeddings import Embeddings
|
||||||
|
|||||||
@ -22,7 +22,7 @@ from frigate.db.sqlitevecq import SqliteVecQueueDatabase
|
|||||||
from frigate.embeddings.util import ZScoreNormalization
|
from frigate.embeddings.util import ZScoreNormalization
|
||||||
from frigate.models import Event, Trigger
|
from frigate.models import Event, Trigger
|
||||||
from frigate.util.builtin import cosine_distance
|
from frigate.util.builtin import cosine_distance
|
||||||
from frigate.util.path import get_event_thumbnail_bytes
|
from frigate.util.file import get_event_thumbnail_bytes
|
||||||
|
|
||||||
from ..post.api import PostProcessorApi
|
from ..post.api import PostProcessorApi
|
||||||
from ..types import DataProcessorMetrics
|
from ..types import DataProcessorMetrics
|
||||||
|
|||||||
@ -466,6 +466,7 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
|
|||||||
now,
|
now,
|
||||||
self.labelmap[best_id],
|
self.labelmap[best_id],
|
||||||
score,
|
score,
|
||||||
|
max_files=200,
|
||||||
)
|
)
|
||||||
|
|
||||||
if score < self.model_config.threshold:
|
if score < self.model_config.threshold:
|
||||||
@ -529,6 +530,7 @@ def write_classification_attempt(
|
|||||||
timestamp: float,
|
timestamp: float,
|
||||||
label: str,
|
label: str,
|
||||||
score: float,
|
score: float,
|
||||||
|
max_files: int = 100,
|
||||||
) -> None:
|
) -> None:
|
||||||
if "-" in label:
|
if "-" in label:
|
||||||
label = label.replace("-", "_")
|
label = label.replace("-", "_")
|
||||||
@ -544,5 +546,5 @@ def write_classification_attempt(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# delete oldest face image if maximum is reached
|
# delete oldest face image if maximum is reached
|
||||||
if len(files) > 100:
|
if len(files) > max_files:
|
||||||
os.unlink(os.path.join(folder, files[-1]))
|
os.unlink(os.path.join(folder, files[-1]))
|
||||||
|
|||||||
201
frigate/detectors/plugins/axengine.py
Normal file
201
frigate/detectors/plugins/axengine.py
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
import logging
|
||||||
|
import os.path
|
||||||
|
import re
|
||||||
|
import urllib.request
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from frigate.const import MODEL_CACHE_DIR
|
||||||
|
from frigate.detectors.detection_api import DetectionApi
|
||||||
|
from frigate.detectors.detector_config import BaseDetectorConfig, ModelTypeEnum
|
||||||
|
from frigate.util.model import post_process_yolo
|
||||||
|
|
||||||
|
import axengine as axe
|
||||||
|
from axengine import axclrt_provider_name, axengine_provider_name
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DETECTOR_KEY = "axengine"
|
||||||
|
|
||||||
|
CONF_THRESH = 0.65
|
||||||
|
IOU_THRESH = 0.45
|
||||||
|
STRIDES = [8, 16, 32]
|
||||||
|
ANCHORS = [
|
||||||
|
[10, 13, 16, 30, 33, 23],
|
||||||
|
[30, 61, 62, 45, 59, 119],
|
||||||
|
[116, 90, 156, 198, 373, 326],
|
||||||
|
]
|
||||||
|
|
||||||
|
class AxengineDetectorConfig(BaseDetectorConfig):
|
||||||
|
type: Literal[DETECTOR_KEY]
|
||||||
|
|
||||||
|
class Axengine(DetectionApi):
|
||||||
|
type_key = DETECTOR_KEY
|
||||||
|
def __init__(self, config: AxengineDetectorConfig):
|
||||||
|
logger.info("__init__ axengine")
|
||||||
|
super().__init__(config)
|
||||||
|
self.height = config.model.height
|
||||||
|
self.width = config.model.width
|
||||||
|
model_path = config.model.path or "yolov5s_320"
|
||||||
|
self.session = axe.InferenceSession(f"/axmodels/{model_path}.axmodel")
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def xywh2xyxy(self, x):
|
||||||
|
# Convert nx4 boxes from [x, y, w, h] to [x1, y1, x2, y2] where xy1=top-left, xy2=bottom-right
|
||||||
|
y = np.copy(x)
|
||||||
|
y[:, 0] = x[:, 0] - x[:, 2] / 2 # top left x
|
||||||
|
y[:, 1] = x[:, 1] - x[:, 3] / 2 # top left y
|
||||||
|
y[:, 2] = x[:, 0] + x[:, 2] / 2 # bottom right x
|
||||||
|
y[:, 3] = x[:, 1] + x[:, 3] / 2 # bottom right y
|
||||||
|
return y
|
||||||
|
|
||||||
|
def bboxes_iou(self, boxes1, boxes2):
|
||||||
|
"""calculate the Intersection Over Union value"""
|
||||||
|
boxes1 = np.array(boxes1)
|
||||||
|
boxes2 = np.array(boxes2)
|
||||||
|
|
||||||
|
boxes1_area = (boxes1[..., 2] - boxes1[..., 0]) * (
|
||||||
|
boxes1[..., 3] - boxes1[..., 1]
|
||||||
|
)
|
||||||
|
boxes2_area = (boxes2[..., 2] - boxes2[..., 0]) * (
|
||||||
|
boxes2[..., 3] - boxes2[..., 1]
|
||||||
|
)
|
||||||
|
|
||||||
|
left_up = np.maximum(boxes1[..., :2], boxes2[..., :2])
|
||||||
|
right_down = np.minimum(boxes1[..., 2:], boxes2[..., 2:])
|
||||||
|
|
||||||
|
inter_section = np.maximum(right_down - left_up, 0.0)
|
||||||
|
inter_area = inter_section[..., 0] * inter_section[..., 1]
|
||||||
|
union_area = boxes1_area + boxes2_area - inter_area
|
||||||
|
ious = np.maximum(1.0 * inter_area / union_area, np.finfo(np.float32).eps)
|
||||||
|
|
||||||
|
return ious
|
||||||
|
|
||||||
|
def nms(self, proposals, iou_threshold, conf_threshold, multi_label=False):
|
||||||
|
"""
|
||||||
|
:param bboxes: (xmin, ymin, xmax, ymax, score, class)
|
||||||
|
|
||||||
|
Note: soft-nms, https://arxiv.org/pdf/1704.04503.pdf
|
||||||
|
https://github.com/bharatsingh430/soft-nms
|
||||||
|
"""
|
||||||
|
xc = proposals[..., 4] > conf_threshold
|
||||||
|
proposals = proposals[xc]
|
||||||
|
proposals[:, 5:] *= proposals[:, 4:5]
|
||||||
|
bboxes = self.xywh2xyxy(proposals[:, :4])
|
||||||
|
if multi_label:
|
||||||
|
mask = proposals[:, 5:] > conf_threshold
|
||||||
|
nonzero_indices = np.argwhere(mask)
|
||||||
|
if nonzero_indices.size < 0:
|
||||||
|
return
|
||||||
|
i, j = nonzero_indices.T
|
||||||
|
bboxes = np.hstack(
|
||||||
|
(bboxes[i], proposals[i, j + 5][:, None], j[:, None].astype(float))
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
confidences = proposals[:, 5:]
|
||||||
|
conf = confidences.max(axis=1, keepdims=True)
|
||||||
|
j = confidences.argmax(axis=1)[:, None]
|
||||||
|
|
||||||
|
new_x_parts = [bboxes, conf, j.astype(float)]
|
||||||
|
bboxes = np.hstack(new_x_parts)
|
||||||
|
|
||||||
|
mask = conf.reshape(-1) > conf_threshold
|
||||||
|
bboxes = bboxes[mask]
|
||||||
|
|
||||||
|
classes_in_img = list(set(bboxes[:, 5]))
|
||||||
|
bboxes = bboxes[bboxes[:, 4].argsort()[::-1][:300]]
|
||||||
|
best_bboxes = []
|
||||||
|
|
||||||
|
for cls in classes_in_img:
|
||||||
|
cls_mask = bboxes[:, 5] == cls
|
||||||
|
cls_bboxes = bboxes[cls_mask]
|
||||||
|
|
||||||
|
while len(cls_bboxes) > 0:
|
||||||
|
max_ind = np.argmax(cls_bboxes[:, 4])
|
||||||
|
best_bbox = cls_bboxes[max_ind]
|
||||||
|
best_bboxes.append(best_bbox)
|
||||||
|
cls_bboxes = np.concatenate(
|
||||||
|
[cls_bboxes[:max_ind], cls_bboxes[max_ind + 1 :]]
|
||||||
|
)
|
||||||
|
iou = self.bboxes_iou(best_bbox[np.newaxis, :4], cls_bboxes[:, :4])
|
||||||
|
weight = np.ones((len(iou),), dtype=np.float32)
|
||||||
|
|
||||||
|
iou_mask = iou > iou_threshold
|
||||||
|
weight[iou_mask] = 0.0
|
||||||
|
|
||||||
|
cls_bboxes[:, 4] = cls_bboxes[:, 4] * weight
|
||||||
|
score_mask = cls_bboxes[:, 4] > 0.0
|
||||||
|
cls_bboxes = cls_bboxes[score_mask]
|
||||||
|
|
||||||
|
if len(best_bboxes) == 0:
|
||||||
|
return np.empty((0, 6))
|
||||||
|
|
||||||
|
best_bboxes = np.vstack(best_bboxes)
|
||||||
|
best_bboxes = best_bboxes[best_bboxes[:, 4].argsort()[::-1]]
|
||||||
|
return best_bboxes
|
||||||
|
|
||||||
|
def sigmoid(self, x):
|
||||||
|
return np.clip(0.2 * x + 0.5, 0, 1)
|
||||||
|
|
||||||
|
def gen_proposals(self, outputs):
|
||||||
|
new_pred = []
|
||||||
|
anchor_grid = np.array(ANCHORS).reshape(-1, 1, 1, 3, 2)
|
||||||
|
for i, pred in enumerate(outputs):
|
||||||
|
pred = self.sigmoid(pred)
|
||||||
|
n, h, w, c = pred.shape
|
||||||
|
|
||||||
|
pred = pred.reshape(n, h, w, 3, 85)
|
||||||
|
conv_shape = pred.shape
|
||||||
|
output_size = conv_shape[1]
|
||||||
|
conv_raw_dxdy = pred[..., 0:2]
|
||||||
|
conv_raw_dwdh = pred[..., 2:4]
|
||||||
|
xy_grid = np.meshgrid(np.arange(output_size), np.arange(output_size))
|
||||||
|
xy_grid = np.expand_dims(np.stack(xy_grid, axis=-1), axis=2)
|
||||||
|
|
||||||
|
xy_grid = np.tile(np.expand_dims(xy_grid, axis=0), [1, 1, 1, 3, 1])
|
||||||
|
xy_grid = xy_grid.astype(np.float32)
|
||||||
|
pred_xy = (conv_raw_dxdy * 2.0 - 0.5 + xy_grid) * STRIDES[i]
|
||||||
|
pred_wh = (conv_raw_dwdh * 2) ** 2 * anchor_grid[i]
|
||||||
|
pred[:, :, :, :, 0:4] = np.concatenate([pred_xy, pred_wh], axis=-1)
|
||||||
|
|
||||||
|
new_pred.append(np.reshape(pred, (-1, np.shape(pred)[-1])))
|
||||||
|
|
||||||
|
return np.concatenate(new_pred, axis=0)
|
||||||
|
|
||||||
|
def post_processing(self, outputs, input_shape, threshold=0.3):
|
||||||
|
proposals = self.gen_proposals(outputs)
|
||||||
|
bboxes = self.nms(proposals, IOU_THRESH, CONF_THRESH, multi_label=True)
|
||||||
|
|
||||||
|
"""
|
||||||
|
bboxes: [x_min, y_min, x_max, y_max, probability, cls_id] format coordinates.
|
||||||
|
"""
|
||||||
|
|
||||||
|
results = np.zeros((20, 6), np.float32)
|
||||||
|
|
||||||
|
for i, bbox in enumerate(bboxes):
|
||||||
|
if i >= 20:
|
||||||
|
break
|
||||||
|
coor = np.array(bbox[:4], dtype=np.int32)
|
||||||
|
score = bbox[4]
|
||||||
|
if score < threshold:
|
||||||
|
continue
|
||||||
|
class_ind = int(bbox[5])
|
||||||
|
results[i] = [
|
||||||
|
class_ind,
|
||||||
|
score,
|
||||||
|
max(0, bbox[1]) / input_shape[1],
|
||||||
|
max(0, bbox[0]) / input_shape[0],
|
||||||
|
min(1, bbox[3] / input_shape[1]),
|
||||||
|
min(1, bbox[2] / input_shape[0]),
|
||||||
|
]
|
||||||
|
return results
|
||||||
|
|
||||||
|
def detect_raw(self, tensor_input):
|
||||||
|
results = None
|
||||||
|
results = self.session.run(None, {"images": tensor_input})
|
||||||
|
return self.post_processing(results, (self.width, self.height))
|
||||||
@ -17,6 +17,7 @@ from frigate.detectors.detector_config import (
|
|||||||
BaseDetectorConfig,
|
BaseDetectorConfig,
|
||||||
ModelTypeEnum,
|
ModelTypeEnum,
|
||||||
)
|
)
|
||||||
|
from frigate.util.file import FileLock
|
||||||
from frigate.util.model import post_process_yolo
|
from frigate.util.model import post_process_yolo
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -177,29 +178,6 @@ class MemryXDetector(DetectionApi):
|
|||||||
logger.error(f"Failed to initialize MemryX model: {e}")
|
logger.error(f"Failed to initialize MemryX model: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def _acquire_file_lock(self, lock_path: str, timeout: int = 60, poll: float = 0.2):
|
|
||||||
"""
|
|
||||||
Create an exclusive lock file. Blocks (with polling) until it can acquire,
|
|
||||||
or raises TimeoutError. Uses only stdlib (os.O_EXCL).
|
|
||||||
"""
|
|
||||||
start = time.time()
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_RDWR)
|
|
||||||
os.close(fd)
|
|
||||||
return
|
|
||||||
except FileExistsError:
|
|
||||||
if time.time() - start > timeout:
|
|
||||||
raise TimeoutError(f"Timeout waiting for lock: {lock_path}")
|
|
||||||
time.sleep(poll)
|
|
||||||
|
|
||||||
def _release_file_lock(self, lock_path: str):
|
|
||||||
"""Best-effort removal of the lock file."""
|
|
||||||
try:
|
|
||||||
os.remove(lock_path)
|
|
||||||
except FileNotFoundError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def load_yolo_constants(self):
|
def load_yolo_constants(self):
|
||||||
base = f"{self.cache_dir}/{self.model_folder}"
|
base = f"{self.cache_dir}/{self.model_folder}"
|
||||||
# constants for yolov9 post-processing
|
# constants for yolov9 post-processing
|
||||||
@ -212,9 +190,9 @@ class MemryXDetector(DetectionApi):
|
|||||||
os.makedirs(self.cache_dir, exist_ok=True)
|
os.makedirs(self.cache_dir, exist_ok=True)
|
||||||
|
|
||||||
lock_path = os.path.join(self.cache_dir, f".{self.model_folder}.lock")
|
lock_path = os.path.join(self.cache_dir, f".{self.model_folder}.lock")
|
||||||
self._acquire_file_lock(lock_path)
|
lock = FileLock(lock_path, timeout=60)
|
||||||
|
|
||||||
try:
|
with lock:
|
||||||
# ---------- CASE 1: user provided a custom model path ----------
|
# ---------- CASE 1: user provided a custom model path ----------
|
||||||
if self.memx_model_path:
|
if self.memx_model_path:
|
||||||
if not self.memx_model_path.endswith(".zip"):
|
if not self.memx_model_path.endswith(".zip"):
|
||||||
@ -338,9 +316,6 @@ class MemryXDetector(DetectionApi):
|
|||||||
f"Failed to remove downloaded zip {zip_path}: {e}"
|
f"Failed to remove downloaded zip {zip_path}: {e}"
|
||||||
)
|
)
|
||||||
|
|
||||||
finally:
|
|
||||||
self._release_file_lock(lock_path)
|
|
||||||
|
|
||||||
def send_input(self, connection_id, tensor_input: np.ndarray):
|
def send_input(self, connection_id, tensor_input: np.ndarray):
|
||||||
"""Pre-process (if needed) and send frame to MemryX input queue"""
|
"""Pre-process (if needed) and send frame to MemryX input queue"""
|
||||||
if tensor_input is None:
|
if tensor_input is None:
|
||||||
|
|||||||
@ -29,7 +29,7 @@ from frigate.db.sqlitevecq import SqliteVecQueueDatabase
|
|||||||
from frigate.models import Event, Trigger
|
from frigate.models import Event, Trigger
|
||||||
from frigate.types import ModelStatusTypesEnum
|
from frigate.types import ModelStatusTypesEnum
|
||||||
from frigate.util.builtin import EventsPerSecond, InferenceSpeed, serialize
|
from frigate.util.builtin import EventsPerSecond, InferenceSpeed, serialize
|
||||||
from frigate.util.path import get_event_thumbnail_bytes
|
from frigate.util.file import get_event_thumbnail_bytes
|
||||||
|
|
||||||
from .onnx.jina_v1_embedding import JinaV1ImageEmbedding, JinaV1TextEmbedding
|
from .onnx.jina_v1_embedding import JinaV1ImageEmbedding, JinaV1TextEmbedding
|
||||||
from .onnx.jina_v2_embedding import JinaV2Embedding
|
from .onnx.jina_v2_embedding import JinaV2Embedding
|
||||||
|
|||||||
@ -62,8 +62,8 @@ from frigate.events.types import EventTypeEnum, RegenerateDescriptionEnum
|
|||||||
from frigate.genai import get_genai_client
|
from frigate.genai import get_genai_client
|
||||||
from frigate.models import Event, Recordings, ReviewSegment, Trigger
|
from frigate.models import Event, Recordings, ReviewSegment, Trigger
|
||||||
from frigate.util.builtin import serialize
|
from frigate.util.builtin import serialize
|
||||||
|
from frigate.util.file import get_event_thumbnail_bytes
|
||||||
from frigate.util.image import SharedMemoryFrameManager
|
from frigate.util.image import SharedMemoryFrameManager
|
||||||
from frigate.util.path import get_event_thumbnail_bytes
|
|
||||||
|
|
||||||
from .embeddings import Embeddings
|
from .embeddings import Embeddings
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,7 @@ from frigate.config import FrigateConfig
|
|||||||
from frigate.const import CLIPS_DIR
|
from frigate.const import CLIPS_DIR
|
||||||
from frigate.db.sqlitevecq import SqliteVecQueueDatabase
|
from frigate.db.sqlitevecq import SqliteVecQueueDatabase
|
||||||
from frigate.models import Event, Timeline
|
from frigate.models import Event, Timeline
|
||||||
from frigate.util.path import delete_event_snapshot, delete_event_thumbnail
|
from frigate.util.file import delete_event_snapshot, delete_event_thumbnail
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@ -20,8 +20,8 @@ from frigate.const import (
|
|||||||
from frigate.log import redirect_output_to_logger
|
from frigate.log import redirect_output_to_logger
|
||||||
from frigate.models import Event, Recordings, ReviewSegment
|
from frigate.models import Event, Recordings, ReviewSegment
|
||||||
from frigate.types import ModelStatusTypesEnum
|
from frigate.types import ModelStatusTypesEnum
|
||||||
|
from frigate.util.file import get_event_thumbnail_bytes
|
||||||
from frigate.util.image import get_image_from_recording
|
from frigate.util.image import get_image_from_recording
|
||||||
from frigate.util.path import get_event_thumbnail_bytes
|
|
||||||
from frigate.util.process import FrigateProcess
|
from frigate.util.process import FrigateProcess
|
||||||
|
|
||||||
BATCH_SIZE = 16
|
BATCH_SIZE = 16
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
import time
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, List
|
from typing import Callable, List
|
||||||
|
|
||||||
@ -10,40 +9,11 @@ import requests
|
|||||||
from frigate.comms.inter_process import InterProcessRequestor
|
from frigate.comms.inter_process import InterProcessRequestor
|
||||||
from frigate.const import UPDATE_MODEL_STATE
|
from frigate.const import UPDATE_MODEL_STATE
|
||||||
from frigate.types import ModelStatusTypesEnum
|
from frigate.types import ModelStatusTypesEnum
|
||||||
|
from frigate.util.file import FileLock
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class FileLock:
|
|
||||||
def __init__(self, path):
|
|
||||||
self.path = path
|
|
||||||
self.lock_file = f"{path}.lock"
|
|
||||||
|
|
||||||
# we have not acquired the lock yet so it should not exist
|
|
||||||
if os.path.exists(self.lock_file):
|
|
||||||
try:
|
|
||||||
os.remove(self.lock_file)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def acquire(self):
|
|
||||||
parent_dir = os.path.dirname(self.lock_file)
|
|
||||||
os.makedirs(parent_dir, exist_ok=True)
|
|
||||||
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
with open(self.lock_file, "x"):
|
|
||||||
return
|
|
||||||
except FileExistsError:
|
|
||||||
time.sleep(0.1)
|
|
||||||
|
|
||||||
def release(self):
|
|
||||||
try:
|
|
||||||
os.remove(self.lock_file)
|
|
||||||
except FileNotFoundError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class ModelDownloader:
|
class ModelDownloader:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@ -81,15 +51,13 @@ class ModelDownloader:
|
|||||||
def _download_models(self):
|
def _download_models(self):
|
||||||
for file_name in self.file_names:
|
for file_name in self.file_names:
|
||||||
path = os.path.join(self.download_path, file_name)
|
path = os.path.join(self.download_path, file_name)
|
||||||
lock = FileLock(path)
|
lock_path = f"{path}.lock"
|
||||||
|
lock = FileLock(lock_path, cleanup_stale_on_init=True)
|
||||||
|
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
lock.acquire()
|
with lock:
|
||||||
try:
|
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
self.download_func(path)
|
self.download_func(path)
|
||||||
finally:
|
|
||||||
lock.release()
|
|
||||||
|
|
||||||
self.requestor.send_data(
|
self.requestor.send_data(
|
||||||
UPDATE_MODEL_STATE,
|
UPDATE_MODEL_STATE,
|
||||||
|
|||||||
276
frigate/util/file.py
Normal file
276
frigate/util/file.py
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
"""Path and file utilities."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import fcntl
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
from numpy import ndarray
|
||||||
|
|
||||||
|
from frigate.const import CLIPS_DIR, THUMB_DIR
|
||||||
|
from frigate.models import Event
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_event_thumbnail_bytes(event: Event) -> bytes | None:
|
||||||
|
if event.thumbnail:
|
||||||
|
return base64.b64decode(event.thumbnail)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
with open(
|
||||||
|
os.path.join(THUMB_DIR, event.camera, f"{event.id}.webp"), "rb"
|
||||||
|
) as f:
|
||||||
|
return f.read()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_event_snapshot(event: Event) -> ndarray:
|
||||||
|
media_name = f"{event.camera}-{event.id}"
|
||||||
|
return cv2.imread(f"{os.path.join(CLIPS_DIR, media_name)}.jpg")
|
||||||
|
|
||||||
|
|
||||||
|
### Deletion
|
||||||
|
|
||||||
|
|
||||||
|
def delete_event_images(event: Event) -> bool:
|
||||||
|
return delete_event_snapshot(event) and delete_event_thumbnail(event)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_event_snapshot(event: Event) -> bool:
|
||||||
|
media_name = f"{event.camera}-{event.id}"
|
||||||
|
media_path = Path(f"{os.path.join(CLIPS_DIR, media_name)}.jpg")
|
||||||
|
|
||||||
|
try:
|
||||||
|
media_path.unlink(missing_ok=True)
|
||||||
|
media_path = Path(f"{os.path.join(CLIPS_DIR, media_name)}-clean.webp")
|
||||||
|
media_path.unlink(missing_ok=True)
|
||||||
|
# also delete clean.png (legacy) for backward compatibility
|
||||||
|
media_path = Path(f"{os.path.join(CLIPS_DIR, media_name)}-clean.png")
|
||||||
|
media_path.unlink(missing_ok=True)
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def delete_event_thumbnail(event: Event) -> bool:
|
||||||
|
if event.thumbnail:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
Path(os.path.join(THUMB_DIR, event.camera, f"{event.id}.webp")).unlink(
|
||||||
|
missing_ok=True
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
### File Locking
|
||||||
|
|
||||||
|
|
||||||
|
class FileLock:
|
||||||
|
"""
|
||||||
|
A file-based lock for coordinating access to resources across processes.
|
||||||
|
|
||||||
|
Uses fcntl.flock() for proper POSIX file locking on Linux. Supports timeouts,
|
||||||
|
stale lock detection, and can be used as a context manager.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
# Using as a context manager (recommended)
|
||||||
|
with FileLock("/path/to/resource.lock", timeout=60):
|
||||||
|
# Critical section
|
||||||
|
do_something()
|
||||||
|
|
||||||
|
# Manual acquisition and release
|
||||||
|
lock = FileLock("/path/to/resource.lock")
|
||||||
|
if lock.acquire(timeout=60):
|
||||||
|
try:
|
||||||
|
do_something()
|
||||||
|
finally:
|
||||||
|
lock.release()
|
||||||
|
```
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
lock_path: Path to the lock file
|
||||||
|
timeout: Maximum time to wait for lock acquisition (seconds)
|
||||||
|
poll_interval: Time to wait between lock acquisition attempts (seconds)
|
||||||
|
stale_timeout: Time after which a lock is considered stale (seconds)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
lock_path: str | Path,
|
||||||
|
timeout: int = 300,
|
||||||
|
poll_interval: float = 1.0,
|
||||||
|
stale_timeout: int = 600,
|
||||||
|
cleanup_stale_on_init: bool = False,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize a FileLock.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
lock_path: Path to the lock file
|
||||||
|
timeout: Maximum time to wait for lock acquisition in seconds (default: 300)
|
||||||
|
poll_interval: Time to wait between lock attempts in seconds (default: 1.0)
|
||||||
|
stale_timeout: Time after which a lock is considered stale in seconds (default: 600)
|
||||||
|
cleanup_stale_on_init: Whether to clean up stale locks on initialization (default: False)
|
||||||
|
"""
|
||||||
|
self.lock_path = Path(lock_path)
|
||||||
|
self.timeout = timeout
|
||||||
|
self.poll_interval = poll_interval
|
||||||
|
self.stale_timeout = stale_timeout
|
||||||
|
self._fd: Optional[int] = None
|
||||||
|
self._acquired = False
|
||||||
|
|
||||||
|
if cleanup_stale_on_init:
|
||||||
|
self._cleanup_stale_lock()
|
||||||
|
|
||||||
|
def _cleanup_stale_lock(self) -> bool:
|
||||||
|
"""
|
||||||
|
Clean up a stale lock file if it exists and is old.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if lock was cleaned up, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if self.lock_path.exists():
|
||||||
|
# Check if lock file is older than stale_timeout
|
||||||
|
lock_age = time.time() - self.lock_path.stat().st_mtime
|
||||||
|
if lock_age > self.stale_timeout:
|
||||||
|
logger.warning(
|
||||||
|
f"Removing stale lock file: {self.lock_path} (age: {lock_age:.1f}s)"
|
||||||
|
)
|
||||||
|
self.lock_path.unlink()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error cleaning up stale lock: {e}")
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def is_stale(self) -> bool:
|
||||||
|
"""
|
||||||
|
Check if the lock file is stale (older than stale_timeout).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if lock is stale, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if self.lock_path.exists():
|
||||||
|
lock_age = time.time() - self.lock_path.stat().st_mtime
|
||||||
|
return lock_age > self.stale_timeout
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def acquire(self, timeout: Optional[int] = None) -> bool:
|
||||||
|
"""
|
||||||
|
Acquire the file lock using fcntl.flock().
|
||||||
|
|
||||||
|
Args:
|
||||||
|
timeout: Maximum time to wait for lock in seconds (uses instance timeout if None)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if lock acquired, False if timeout or error
|
||||||
|
"""
|
||||||
|
if self._acquired:
|
||||||
|
logger.warning(f"Lock already acquired: {self.lock_path}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
if timeout is None:
|
||||||
|
timeout = self.timeout
|
||||||
|
|
||||||
|
# Ensure parent directory exists
|
||||||
|
self.lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Clean up stale lock before attempting to acquire
|
||||||
|
self._cleanup_stale_lock()
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._fd = os.open(self.lock_path, os.O_CREAT | os.O_RDWR)
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
while time.time() - start_time < timeout:
|
||||||
|
try:
|
||||||
|
fcntl.flock(self._fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
self._acquired = True
|
||||||
|
logger.debug(f"Acquired lock: {self.lock_path}")
|
||||||
|
return True
|
||||||
|
except (OSError, IOError):
|
||||||
|
# Lock is held by another process
|
||||||
|
if time.time() - start_time >= timeout:
|
||||||
|
logger.warning(f"Timeout waiting for lock: {self.lock_path}")
|
||||||
|
os.close(self._fd)
|
||||||
|
self._fd = None
|
||||||
|
return False
|
||||||
|
|
||||||
|
time.sleep(self.poll_interval)
|
||||||
|
|
||||||
|
# Timeout reached
|
||||||
|
if self._fd is not None:
|
||||||
|
os.close(self._fd)
|
||||||
|
self._fd = None
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error acquiring lock: {e}")
|
||||||
|
if self._fd is not None:
|
||||||
|
try:
|
||||||
|
os.close(self._fd)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._fd = None
|
||||||
|
return False
|
||||||
|
|
||||||
|
def release(self) -> None:
|
||||||
|
"""
|
||||||
|
Release the file lock.
|
||||||
|
|
||||||
|
This closes the file descriptor and removes the lock file.
|
||||||
|
"""
|
||||||
|
if not self._acquired:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Close file descriptor and release fcntl lock
|
||||||
|
if self._fd is not None:
|
||||||
|
try:
|
||||||
|
fcntl.flock(self._fd, fcntl.LOCK_UN)
|
||||||
|
os.close(self._fd)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error closing lock file descriptor: {e}")
|
||||||
|
finally:
|
||||||
|
self._fd = None
|
||||||
|
|
||||||
|
# Remove lock file
|
||||||
|
if self.lock_path.exists():
|
||||||
|
self.lock_path.unlink()
|
||||||
|
logger.debug(f"Released lock: {self.lock_path}")
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
# Lock file already removed, that's fine
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error releasing lock: {e}")
|
||||||
|
finally:
|
||||||
|
self._acquired = False
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
"""Context manager entry - acquire the lock."""
|
||||||
|
if not self.acquire():
|
||||||
|
raise TimeoutError(f"Failed to acquire lock: {self.lock_path}")
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
"""Context manager exit - release the lock."""
|
||||||
|
self.release()
|
||||||
|
return False
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
"""Destructor - ensure lock is released."""
|
||||||
|
if self._acquired:
|
||||||
|
self.release()
|
||||||
@ -1,62 +0,0 @@
|
|||||||
"""Path utilities."""
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import cv2
|
|
||||||
from numpy import ndarray
|
|
||||||
|
|
||||||
from frigate.const import CLIPS_DIR, THUMB_DIR
|
|
||||||
from frigate.models import Event
|
|
||||||
|
|
||||||
|
|
||||||
def get_event_thumbnail_bytes(event: Event) -> bytes | None:
|
|
||||||
if event.thumbnail:
|
|
||||||
return base64.b64decode(event.thumbnail)
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
with open(
|
|
||||||
os.path.join(THUMB_DIR, event.camera, f"{event.id}.webp"), "rb"
|
|
||||||
) as f:
|
|
||||||
return f.read()
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def get_event_snapshot(event: Event) -> ndarray:
|
|
||||||
media_name = f"{event.camera}-{event.id}"
|
|
||||||
return cv2.imread(f"{os.path.join(CLIPS_DIR, media_name)}.jpg")
|
|
||||||
|
|
||||||
|
|
||||||
### Deletion
|
|
||||||
|
|
||||||
|
|
||||||
def delete_event_images(event: Event) -> bool:
|
|
||||||
return delete_event_snapshot(event) and delete_event_thumbnail(event)
|
|
||||||
|
|
||||||
|
|
||||||
def delete_event_snapshot(event: Event) -> bool:
|
|
||||||
media_name = f"{event.camera}-{event.id}"
|
|
||||||
media_path = Path(f"{os.path.join(CLIPS_DIR, media_name)}.jpg")
|
|
||||||
|
|
||||||
try:
|
|
||||||
media_path.unlink(missing_ok=True)
|
|
||||||
media_path = Path(f"{os.path.join(CLIPS_DIR, media_name)}-clean.webp")
|
|
||||||
media_path.unlink(missing_ok=True)
|
|
||||||
# also delete clean.png (legacy) for backward compatibility
|
|
||||||
media_path = Path(f"{os.path.join(CLIPS_DIR, media_name)}-clean.png")
|
|
||||||
media_path.unlink(missing_ok=True)
|
|
||||||
return True
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def delete_event_thumbnail(event: Event) -> bool:
|
|
||||||
if event.thumbnail:
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
Path(os.path.join(THUMB_DIR, event.camera, f"{event.id}.webp")).unlink(
|
|
||||||
missing_ok=True
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
@ -1,6 +1,5 @@
|
|||||||
"""RKNN model conversion utility for Frigate."""
|
"""RKNN model conversion utility for Frigate."""
|
||||||
|
|
||||||
import fcntl
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
@ -9,6 +8,8 @@ import time
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from frigate.util.file import FileLock
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
MODEL_TYPE_CONFIGS = {
|
MODEL_TYPE_CONFIGS = {
|
||||||
@ -245,112 +246,6 @@ def convert_onnx_to_rknn(
|
|||||||
logger.warning(f"Failed to remove temporary ONNX file: {e}")
|
logger.warning(f"Failed to remove temporary ONNX file: {e}")
|
||||||
|
|
||||||
|
|
||||||
def cleanup_stale_lock(lock_file_path: Path) -> bool:
|
|
||||||
"""
|
|
||||||
Clean up a stale lock file if it exists and is old.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
lock_file_path: Path to the lock file
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if lock was cleaned up, False otherwise
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if lock_file_path.exists():
|
|
||||||
# Check if lock file is older than 10 minutes (stale)
|
|
||||||
lock_age = time.time() - lock_file_path.stat().st_mtime
|
|
||||||
if lock_age > 600: # 10 minutes
|
|
||||||
logger.warning(
|
|
||||||
f"Removing stale lock file: {lock_file_path} (age: {lock_age:.1f}s)"
|
|
||||||
)
|
|
||||||
lock_file_path.unlink()
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error cleaning up stale lock: {e}")
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def acquire_conversion_lock(lock_file_path: Path, timeout: int = 300) -> bool:
|
|
||||||
"""
|
|
||||||
Acquire a file-based lock for model conversion.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
lock_file_path: Path to the lock file
|
|
||||||
timeout: Maximum time to wait for lock in seconds
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if lock acquired, False if timeout or error
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
lock_file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
cleanup_stale_lock(lock_file_path)
|
|
||||||
lock_fd = os.open(lock_file_path, os.O_CREAT | os.O_RDWR)
|
|
||||||
|
|
||||||
# Try to acquire exclusive lock
|
|
||||||
start_time = time.time()
|
|
||||||
while time.time() - start_time < timeout:
|
|
||||||
try:
|
|
||||||
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
||||||
# Lock acquired successfully
|
|
||||||
logger.debug(f"Acquired conversion lock: {lock_file_path}")
|
|
||||||
return True
|
|
||||||
except (OSError, IOError):
|
|
||||||
# Lock is held by another process, wait and retry
|
|
||||||
if time.time() - start_time >= timeout:
|
|
||||||
logger.warning(
|
|
||||||
f"Timeout waiting for conversion lock: {lock_file_path}"
|
|
||||||
)
|
|
||||||
os.close(lock_fd)
|
|
||||||
return False
|
|
||||||
|
|
||||||
logger.debug("Waiting for conversion lock to be released...")
|
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
os.close(lock_fd)
|
|
||||||
return False
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error acquiring conversion lock: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def release_conversion_lock(lock_file_path: Path) -> None:
|
|
||||||
"""
|
|
||||||
Release the conversion lock.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
lock_file_path: Path to the lock file
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if lock_file_path.exists():
|
|
||||||
lock_file_path.unlink()
|
|
||||||
logger.debug(f"Released conversion lock: {lock_file_path}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error releasing conversion lock: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
def is_lock_stale(lock_file_path: Path, max_age: int = 600) -> bool:
|
|
||||||
"""
|
|
||||||
Check if a lock file is stale (older than max_age seconds).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
lock_file_path: Path to the lock file
|
|
||||||
max_age: Maximum age in seconds before considering lock stale
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if lock is stale, False otherwise
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if lock_file_path.exists():
|
|
||||||
lock_age = time.time() - lock_file_path.stat().st_mtime
|
|
||||||
return lock_age > max_age
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def wait_for_conversion_completion(
|
def wait_for_conversion_completion(
|
||||||
model_type: str, rknn_path: Path, lock_file_path: Path, timeout: int = 300
|
model_type: str, rknn_path: Path, lock_file_path: Path, timeout: int = 300
|
||||||
) -> bool:
|
) -> bool:
|
||||||
@ -358,6 +253,7 @@ def wait_for_conversion_completion(
|
|||||||
Wait for another process to complete the conversion.
|
Wait for another process to complete the conversion.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
model_type: Type of model being converted
|
||||||
rknn_path: Path to the expected RKNN model
|
rknn_path: Path to the expected RKNN model
|
||||||
lock_file_path: Path to the lock file to monitor
|
lock_file_path: Path to the lock file to monitor
|
||||||
timeout: Maximum time to wait in seconds
|
timeout: Maximum time to wait in seconds
|
||||||
@ -366,6 +262,8 @@ def wait_for_conversion_completion(
|
|||||||
True if RKNN model appears, False if timeout
|
True if RKNN model appears, False if timeout
|
||||||
"""
|
"""
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
lock = FileLock(lock_file_path, stale_timeout=600)
|
||||||
|
|
||||||
while time.time() - start_time < timeout:
|
while time.time() - start_time < timeout:
|
||||||
# Check if RKNN model appeared
|
# Check if RKNN model appeared
|
||||||
if rknn_path.exists():
|
if rknn_path.exists():
|
||||||
@ -385,11 +283,14 @@ def wait_for_conversion_completion(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
# Check if lock is stale
|
# Check if lock is stale
|
||||||
if is_lock_stale(lock_file_path):
|
if lock.is_stale():
|
||||||
logger.warning("Lock file is stale, attempting to clean up and retry...")
|
logger.warning("Lock file is stale, attempting to clean up and retry...")
|
||||||
cleanup_stale_lock(lock_file_path)
|
lock._cleanup_stale_lock()
|
||||||
# Try to acquire lock again
|
# Try to acquire lock again
|
||||||
if acquire_conversion_lock(lock_file_path, timeout=60):
|
retry_lock = FileLock(
|
||||||
|
lock_file_path, timeout=60, cleanup_stale_on_init=True
|
||||||
|
)
|
||||||
|
if retry_lock.acquire():
|
||||||
try:
|
try:
|
||||||
# Check if RKNN file appeared while waiting
|
# Check if RKNN file appeared while waiting
|
||||||
if rknn_path.exists():
|
if rknn_path.exists():
|
||||||
@ -415,7 +316,7 @@ def wait_for_conversion_completion(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
release_conversion_lock(lock_file_path)
|
retry_lock.release()
|
||||||
|
|
||||||
logger.debug("Waiting for RKNN model to appear...")
|
logger.debug("Waiting for RKNN model to appear...")
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
@ -452,8 +353,9 @@ def auto_convert_model(
|
|||||||
return str(rknn_path)
|
return str(rknn_path)
|
||||||
|
|
||||||
lock_file_path = base_path.parent / f"{base_name}.conversion.lock"
|
lock_file_path = base_path.parent / f"{base_name}.conversion.lock"
|
||||||
|
lock = FileLock(lock_file_path, timeout=300, cleanup_stale_on_init=True)
|
||||||
|
|
||||||
if acquire_conversion_lock(lock_file_path):
|
if lock.acquire():
|
||||||
try:
|
try:
|
||||||
if rknn_path.exists():
|
if rknn_path.exists():
|
||||||
logger.info(
|
logger.info(
|
||||||
@ -476,7 +378,7 @@ def auto_convert_model(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
release_conversion_lock(lock_file_path)
|
lock.release()
|
||||||
else:
|
else:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Another process is converting {model_path}, waiting for completion..."
|
f"Another process is converting {model_path}, waiting for completion..."
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
{
|
{
|
||||||
"documentTitle": "Classification Models",
|
"documentTitle": "Classification Models",
|
||||||
|
"details": {
|
||||||
|
"scoreInfo": "Score represents the average classification confidence across all detections of this object."
|
||||||
|
},
|
||||||
"button": {
|
"button": {
|
||||||
"deleteClassificationAttempts": "Delete Classification Images",
|
"deleteClassificationAttempts": "Delete Classification Images",
|
||||||
"renameCategory": "Rename Class",
|
"renameCategory": "Rename Class",
|
||||||
@ -7,23 +10,27 @@
|
|||||||
"deleteImages": "Delete Images",
|
"deleteImages": "Delete Images",
|
||||||
"trainModel": "Train Model",
|
"trainModel": "Train Model",
|
||||||
"addClassification": "Add Classification",
|
"addClassification": "Add Classification",
|
||||||
"deleteModels": "Delete Models"
|
"deleteModels": "Delete Models",
|
||||||
|
"editModel": "Edit Model"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"success": {
|
"success": {
|
||||||
"deletedCategory": "Deleted Class",
|
"deletedCategory": "Deleted Class",
|
||||||
"deletedImage": "Deleted Images",
|
"deletedImage": "Deleted Images",
|
||||||
"deletedModel": "Successfully deleted {{count}} model(s)",
|
"deletedModel_one": "Successfully deleted {{count}} model",
|
||||||
|
"deletedModel_other": "Successfully deleted {{count}} models",
|
||||||
"categorizedImage": "Successfully Classified Image",
|
"categorizedImage": "Successfully Classified Image",
|
||||||
"trainedModel": "Successfully trained model.",
|
"trainedModel": "Successfully trained model.",
|
||||||
"trainingModel": "Successfully started model training."
|
"trainingModel": "Successfully started model training.",
|
||||||
|
"updatedModel": "Successfully updated model configuration"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"deleteImageFailed": "Failed to delete: {{errorMessage}}",
|
"deleteImageFailed": "Failed to delete: {{errorMessage}}",
|
||||||
"deleteCategoryFailed": "Failed to delete class: {{errorMessage}}",
|
"deleteCategoryFailed": "Failed to delete class: {{errorMessage}}",
|
||||||
"deleteModelFailed": "Failed to delete model: {{errorMessage}}",
|
"deleteModelFailed": "Failed to delete model: {{errorMessage}}",
|
||||||
"categorizeFailed": "Failed to categorize image: {{errorMessage}}",
|
"categorizeFailed": "Failed to categorize image: {{errorMessage}}",
|
||||||
"trainingFailed": "Failed to start model training: {{errorMessage}}"
|
"trainingFailed": "Failed to start model training: {{errorMessage}}",
|
||||||
|
"updateModelFailed": "Failed to update model: {{errorMessage}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"deleteCategory": {
|
"deleteCategory": {
|
||||||
@ -35,6 +42,12 @@
|
|||||||
"single": "Are you sure you want to delete {{name}}? This will permanently delete all associated data including images and training data. This action cannot be undone.",
|
"single": "Are you sure you want to delete {{name}}? This will permanently delete all associated data including images and training data. This action cannot be undone.",
|
||||||
"desc": "Are you sure you want to delete {{count}} model(s)? This will permanently delete all associated data including images and training data. This action cannot be undone."
|
"desc": "Are you sure you want to delete {{count}} model(s)? This will permanently delete all associated data including images and training data. This action cannot be undone."
|
||||||
},
|
},
|
||||||
|
"edit": {
|
||||||
|
"title": "Edit Classification Model",
|
||||||
|
"descriptionState": "Edit the classes for this state classification model. Changes will require retraining the model.",
|
||||||
|
"descriptionObject": "Edit the object type and classification type for this object classification model.",
|
||||||
|
"stateClassesInfo": "Note: Changing state classes requires retraining the model with the updated classes."
|
||||||
|
},
|
||||||
"deleteDatasetImages": {
|
"deleteDatasetImages": {
|
||||||
"title": "Delete Dataset Images",
|
"title": "Delete Dataset Images",
|
||||||
"desc": "Are you sure you want to delete {{count}} images from {{dataset}}? This action cannot be undone and will require re-training the model."
|
"desc": "Are you sure you want to delete {{count}} images from {{dataset}}? This action cannot be undone and will require re-training the model."
|
||||||
|
|||||||
@ -6,7 +6,8 @@
|
|||||||
},
|
},
|
||||||
"details": {
|
"details": {
|
||||||
"timestamp": "Timestamp",
|
"timestamp": "Timestamp",
|
||||||
"unknown": "Unknown"
|
"unknown": "Unknown",
|
||||||
|
"scoreInfo": "Score is a weighted average of all face scores, weighted by the size of the face in each image."
|
||||||
},
|
},
|
||||||
"documentTitle": "Face Library - Frigate",
|
"documentTitle": "Face Library - Frigate",
|
||||||
"uploadFaceImage": {
|
"uploadFaceImage": {
|
||||||
|
|||||||
@ -7,11 +7,12 @@ import {
|
|||||||
} from "@/types/classification";
|
} from "@/types/classification";
|
||||||
import { Event } from "@/types/event";
|
import { Event } from "@/types/event";
|
||||||
import { forwardRef, useMemo, useRef, useState } from "react";
|
import { forwardRef, useMemo, useRef, useState } from "react";
|
||||||
import { isDesktop, isMobile } from "react-device-detect";
|
import { isDesktop, isMobile, isMobileOnly } from "react-device-detect";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import TimeAgo from "../dynamic/TimeAgo";
|
import TimeAgo from "../dynamic/TimeAgo";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||||
import { LuSearch } from "react-icons/lu";
|
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||||
|
import { LuSearch, LuInfo } from "react-icons/lu";
|
||||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { HiSquare2Stack } from "react-icons/hi2";
|
import { HiSquare2Stack } from "react-icons/hi2";
|
||||||
@ -263,8 +264,8 @@ export function GroupedClassificationCard({
|
|||||||
|
|
||||||
const Overlay = isDesktop ? Dialog : MobilePage;
|
const Overlay = isDesktop ? Dialog : MobilePage;
|
||||||
const Trigger = isDesktop ? DialogTrigger : MobilePageTrigger;
|
const Trigger = isDesktop ? DialogTrigger : MobilePageTrigger;
|
||||||
const Header = isDesktop ? DialogHeader : MobilePageHeader;
|
|
||||||
const Content = isDesktop ? DialogContent : MobilePageContent;
|
const Content = isDesktop ? DialogContent : MobilePageContent;
|
||||||
|
const Header = isDesktop ? DialogHeader : MobilePageHeader;
|
||||||
const ContentTitle = isDesktop ? DialogTitle : MobilePageTitle;
|
const ContentTitle = isDesktop ? DialogTitle : MobilePageTitle;
|
||||||
const ContentDescription = isDesktop
|
const ContentDescription = isDesktop
|
||||||
? DialogDescription
|
? DialogDescription
|
||||||
@ -297,9 +298,9 @@ export function GroupedClassificationCard({
|
|||||||
<Trigger asChild></Trigger>
|
<Trigger asChild></Trigger>
|
||||||
<Content
|
<Content
|
||||||
className={cn(
|
className={cn(
|
||||||
"",
|
"scrollbar-container",
|
||||||
isDesktop && "min-w-[50%] max-w-[65%]",
|
isDesktop && "min-w-[50%] max-w-[65%]",
|
||||||
isMobile && "flex flex-col",
|
isMobile && "overflow-y-auto",
|
||||||
)}
|
)}
|
||||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
@ -307,28 +308,45 @@ export function GroupedClassificationCard({
|
|||||||
<Header
|
<Header
|
||||||
className={cn(
|
className={cn(
|
||||||
"mx-2 flex flex-row items-center gap-4",
|
"mx-2 flex flex-row items-center gap-4",
|
||||||
isMobile && "flex-shrink-0",
|
isMobileOnly && "top-0 mx-4",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div>
|
<div
|
||||||
<ContentTitle
|
className={cn(
|
||||||
className={cn(
|
"",
|
||||||
"flex items-center gap-2 font-normal capitalize",
|
isMobile && "flex flex-col items-center justify-center",
|
||||||
isMobile && "px-2",
|
)}
|
||||||
)}
|
>
|
||||||
>
|
<ContentTitle className="flex items-center gap-2 font-normal capitalize">
|
||||||
{event?.sub_label && event.sub_label !== "none"
|
{event?.sub_label && event.sub_label !== "none"
|
||||||
? event.sub_label
|
? event.sub_label
|
||||||
: t(noClassificationLabel)}
|
: t(noClassificationLabel)}
|
||||||
{event?.sub_label && event.sub_label !== "none" && (
|
{event?.sub_label && event.sub_label !== "none" && (
|
||||||
<div
|
<div className="flex items-center gap-1">
|
||||||
className={cn(
|
<div
|
||||||
"",
|
className={cn(
|
||||||
bestScoreStatus == "match" && "text-success",
|
"",
|
||||||
bestScoreStatus == "potential" && "text-orange-400",
|
bestScoreStatus == "match" && "text-success",
|
||||||
bestScoreStatus == "unknown" && "text-danger",
|
bestScoreStatus == "potential" && "text-orange-400",
|
||||||
)}
|
bestScoreStatus == "unknown" && "text-danger",
|
||||||
>{`${Math.round((event.data.sub_label_score || 0) * 100)}%`}</div>
|
)}
|
||||||
|
>{`${Math.round((event.data.sub_label_score || 0) * 100)}%`}</div>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
className="focus:outline-none"
|
||||||
|
aria-label={t("details.scoreInfo", {
|
||||||
|
ns: i18nLibrary,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<LuInfo className="size-3" />
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-80 text-sm">
|
||||||
|
{t("details.scoreInfo", { ns: i18nLibrary })}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</ContentTitle>
|
</ContentTitle>
|
||||||
<ContentDescription className={cn("", isMobile && "px-2")}>
|
<ContentDescription className={cn("", isMobile && "px-2")}>
|
||||||
@ -372,7 +390,7 @@ export function GroupedClassificationCard({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"grid w-full auto-rows-min grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-6 2xl:grid-cols-8",
|
"grid w-full auto-rows-min grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-6 2xl:grid-cols-8",
|
||||||
isDesktop && "p-2",
|
isDesktop && "p-2",
|
||||||
isMobile && "scrollbar-container flex-1 overflow-y-auto",
|
isMobile && "px-4 pb-4",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{group.map((data: ClassificationItemData) => (
|
{group.map((data: ClassificationItemData) => (
|
||||||
|
|||||||
@ -37,6 +37,8 @@ import { capitalizeFirstLetter } from "@/utils/stringUtil";
|
|||||||
import { Button, buttonVariants } from "../ui/button";
|
import { Button, buttonVariants } from "../ui/button";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { LuCircle } from "react-icons/lu";
|
||||||
|
import { MdAutoAwesome } from "react-icons/md";
|
||||||
|
|
||||||
type ReviewCardProps = {
|
type ReviewCardProps = {
|
||||||
event: ReviewSegment;
|
event: ReviewSegment;
|
||||||
@ -142,7 +144,7 @@ export default function ReviewCard({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"size-full rounded-lg",
|
"size-full rounded-lg",
|
||||||
activeReviewItem?.id == event.id &&
|
activeReviewItem?.id == event.id &&
|
||||||
"outline outline-[3px] outline-offset-1 outline-selected",
|
"outline outline-[3px] -outline-offset-[2.8px] outline-selected duration-200",
|
||||||
imgLoaded ? "visible" : "invisible",
|
imgLoaded ? "visible" : "invisible",
|
||||||
)}
|
)}
|
||||||
src={`${baseUrl}${event.thumb_path.replace("/media/frigate/", "")}`}
|
src={`${baseUrl}${event.thumb_path.replace("/media/frigate/", "")}`}
|
||||||
@ -163,21 +165,33 @@ export default function ReviewCard({
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div className="flex items-center justify-evenly gap-1">
|
<div className="flex items-center gap-2">
|
||||||
<>
|
<LuCircle
|
||||||
{event.data.objects.map((object) => {
|
className={cn(
|
||||||
return getIconForLabel(
|
"size-2",
|
||||||
object,
|
event.severity == "alert"
|
||||||
"size-3 text-primary dark:text-white",
|
? "fill-severity_alert text-severity_alert"
|
||||||
);
|
: "fill-severity_detection text-severity_detection",
|
||||||
})}
|
)}
|
||||||
{event.data.audio.map((audio) => {
|
/>
|
||||||
return getIconForLabel(
|
<div className="flex items-center gap-1">
|
||||||
audio,
|
{event.data.objects.map((object, idx) => (
|
||||||
"size-3 text-primary dark:text-white",
|
<div
|
||||||
);
|
key={`${object}-${idx}`}
|
||||||
})}
|
className="rounded-full bg-muted-foreground p-1"
|
||||||
</>
|
>
|
||||||
|
{getIconForLabel(object, "size-3 text-white")}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{event.data.audio.map((audio, idx) => (
|
||||||
|
<div
|
||||||
|
key={`${audio}-${idx}`}
|
||||||
|
className="rounded-full bg-muted-foreground p-1"
|
||||||
|
>
|
||||||
|
{getIconForLabel(audio, "size-3 text-white")}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<div className="font-extra-light text-xs">{formattedDate}</div>
|
<div className="font-extra-light text-xs">{formattedDate}</div>
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@ -204,6 +218,14 @@ export default function ReviewCard({
|
|||||||
dense
|
dense
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{event.data.metadata?.title && (
|
||||||
|
<div className="flex items-center gap-1.5 rounded bg-secondary/50">
|
||||||
|
<MdAutoAwesome className="size-3 shrink-0 text-primary" />
|
||||||
|
<span className="truncate text-xs text-primary">
|
||||||
|
{event.data.metadata.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,477 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
CustomClassificationModelConfig,
|
||||||
|
FrigateConfig,
|
||||||
|
} from "@/types/frigateConfig";
|
||||||
|
import { getTranslatedLabel } from "@/utils/i18n";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import axios from "axios";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { LuPlus, LuX } from "react-icons/lu";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
type ClassificationModelEditDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
model: CustomClassificationModelConfig;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ObjectClassificationType = "sub_label" | "attribute";
|
||||||
|
|
||||||
|
type ObjectFormData = {
|
||||||
|
objectLabel: string;
|
||||||
|
objectType: ObjectClassificationType;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StateFormData = {
|
||||||
|
classes: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ClassificationModelEditDialog({
|
||||||
|
open,
|
||||||
|
model,
|
||||||
|
onClose,
|
||||||
|
onSuccess,
|
||||||
|
}: ClassificationModelEditDialogProps) {
|
||||||
|
const { t } = useTranslation(["views/classificationModel"]);
|
||||||
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
const isStateModel = model.state_config !== undefined;
|
||||||
|
const isObjectModel = model.object_config !== undefined;
|
||||||
|
|
||||||
|
const objectLabels = useMemo(() => {
|
||||||
|
if (!config) return [];
|
||||||
|
|
||||||
|
const labels = new Set<string>();
|
||||||
|
|
||||||
|
Object.values(config.cameras).forEach((cameraConfig) => {
|
||||||
|
if (!cameraConfig.enabled || !cameraConfig.enabled_in_config) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cameraConfig.objects.track.forEach((label) => {
|
||||||
|
if (!config.model.all_attributes.includes(label)) {
|
||||||
|
labels.add(label);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...labels].sort();
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
// Define form schema based on model type
|
||||||
|
const formSchema = useMemo(() => {
|
||||||
|
if (isObjectModel) {
|
||||||
|
return z.object({
|
||||||
|
objectLabel: z
|
||||||
|
.string()
|
||||||
|
.min(1, t("wizard.step1.errors.objectLabelRequired")),
|
||||||
|
objectType: z.enum(["sub_label", "attribute"]),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// State model
|
||||||
|
return z.object({
|
||||||
|
classes: z
|
||||||
|
.array(z.string())
|
||||||
|
.min(1, t("wizard.step1.errors.classRequired"))
|
||||||
|
.refine(
|
||||||
|
(classes) => {
|
||||||
|
const nonEmpty = classes.filter((c) => c.trim().length > 0);
|
||||||
|
return nonEmpty.length >= 2;
|
||||||
|
},
|
||||||
|
{ message: t("wizard.step1.errors.stateRequiresTwoClasses") },
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(classes) => {
|
||||||
|
const nonEmpty = classes.filter((c) => c.trim().length > 0);
|
||||||
|
const unique = new Set(nonEmpty.map((c) => c.toLowerCase()));
|
||||||
|
return unique.size === nonEmpty.length;
|
||||||
|
},
|
||||||
|
{ message: t("wizard.step1.errors.classesUnique") },
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isObjectModel, t]);
|
||||||
|
|
||||||
|
const form = useForm<ObjectFormData | StateFormData>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: isObjectModel
|
||||||
|
? ({
|
||||||
|
objectLabel: model.object_config?.objects?.[0] || "",
|
||||||
|
objectType:
|
||||||
|
(model.object_config
|
||||||
|
?.classification_type as ObjectClassificationType) || "sub_label",
|
||||||
|
} as ObjectFormData)
|
||||||
|
: ({
|
||||||
|
classes: [""], // Will be populated from dataset
|
||||||
|
} as StateFormData),
|
||||||
|
mode: "onChange",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch dataset to get current classes for state models
|
||||||
|
const { data: dataset } = useSWR<{
|
||||||
|
[id: string]: string[];
|
||||||
|
}>(isStateModel ? `classification/${model.name}/dataset` : null, {
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update form with classes from dataset when loaded
|
||||||
|
useEffect(() => {
|
||||||
|
if (isStateModel && dataset) {
|
||||||
|
const classes = Object.keys(dataset).filter((key) => key !== "none");
|
||||||
|
if (classes.length > 0) {
|
||||||
|
(form as ReturnType<typeof useForm<StateFormData>>).setValue(
|
||||||
|
"classes",
|
||||||
|
classes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [dataset, isStateModel, form]);
|
||||||
|
|
||||||
|
const watchedClasses = isStateModel
|
||||||
|
? (form as ReturnType<typeof useForm<StateFormData>>).watch("classes")
|
||||||
|
: undefined;
|
||||||
|
const watchedObjectType = isObjectModel
|
||||||
|
? (form as ReturnType<typeof useForm<ObjectFormData>>).watch("objectType")
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const handleAddClass = useCallback(() => {
|
||||||
|
const currentClasses = (
|
||||||
|
form as ReturnType<typeof useForm<StateFormData>>
|
||||||
|
).getValues("classes");
|
||||||
|
(form as ReturnType<typeof useForm<StateFormData>>).setValue(
|
||||||
|
"classes",
|
||||||
|
[...currentClasses, ""],
|
||||||
|
{
|
||||||
|
shouldValidate: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, [form]);
|
||||||
|
|
||||||
|
const handleRemoveClass = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
const currentClasses = (
|
||||||
|
form as ReturnType<typeof useForm<StateFormData>>
|
||||||
|
).getValues("classes");
|
||||||
|
const newClasses = currentClasses.filter((_, i) => i !== index);
|
||||||
|
|
||||||
|
// Ensure at least one field remains (even if empty)
|
||||||
|
if (newClasses.length === 0) {
|
||||||
|
(form as ReturnType<typeof useForm<StateFormData>>).setValue(
|
||||||
|
"classes",
|
||||||
|
[""],
|
||||||
|
{ shouldValidate: true },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
(form as ReturnType<typeof useForm<StateFormData>>).setValue(
|
||||||
|
"classes",
|
||||||
|
newClasses,
|
||||||
|
{ shouldValidate: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[form],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onSubmit = useCallback(
|
||||||
|
async (data: ObjectFormData | StateFormData) => {
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
if (isObjectModel) {
|
||||||
|
const objectData = data as ObjectFormData;
|
||||||
|
|
||||||
|
// Update the config
|
||||||
|
await axios.put("/config/set", {
|
||||||
|
requires_restart: 0,
|
||||||
|
update_topic: `config/classification/custom/${model.name}`,
|
||||||
|
config_data: {
|
||||||
|
classification: {
|
||||||
|
custom: {
|
||||||
|
[model.name]: {
|
||||||
|
enabled: model.enabled,
|
||||||
|
name: model.name,
|
||||||
|
threshold: model.threshold,
|
||||||
|
object_config: {
|
||||||
|
objects: [objectData.objectLabel],
|
||||||
|
classification_type: objectData.objectType,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success(t("toast.success.updatedModel"), {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// State model - update classes
|
||||||
|
// Note: For state models, updating classes requires renaming categories
|
||||||
|
// which is handled through the dataset API, not the config API
|
||||||
|
// We'll need to implement this by calling the rename endpoint for each class
|
||||||
|
// For now, we just show a message that this requires retraining
|
||||||
|
|
||||||
|
toast.info(t("edit.stateClassesInfo"), {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onSuccess();
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
const error = err as {
|
||||||
|
response?: { data?: { message?: string; detail?: string } };
|
||||||
|
};
|
||||||
|
const errorMessage =
|
||||||
|
error.response?.data?.message ||
|
||||||
|
error.response?.data?.detail ||
|
||||||
|
"Unknown error";
|
||||||
|
toast.error(t("toast.error.updateModelFailed", { errorMessage }), {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isObjectModel, model, t, onSuccess, onClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCancel = useCallback(() => {
|
||||||
|
form.reset();
|
||||||
|
onClose();
|
||||||
|
}, [form, onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(open) => !open && handleCancel()}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("edit.title")}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{isStateModel
|
||||||
|
? t("edit.descriptionState")
|
||||||
|
: t("edit.descriptionObject")}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
{isObjectModel && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="objectLabel"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="text-primary-variant">
|
||||||
|
{t("wizard.step1.objectLabel")}
|
||||||
|
</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger className="h-8">
|
||||||
|
<SelectValue
|
||||||
|
placeholder={t(
|
||||||
|
"wizard.step1.objectLabelPlaceholder",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{objectLabels.map((label) => (
|
||||||
|
<SelectItem
|
||||||
|
key={label}
|
||||||
|
value={label}
|
||||||
|
className="cursor-pointer hover:bg-secondary-highlight"
|
||||||
|
>
|
||||||
|
{getTranslatedLabel(label)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="objectType"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="text-primary-variant">
|
||||||
|
{t("wizard.step1.classificationType")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<RadioGroup
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
className="flex flex-col gap-4 pt-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RadioGroupItem
|
||||||
|
className={
|
||||||
|
watchedObjectType === "sub_label"
|
||||||
|
? "bg-selected from-selected/50 to-selected/90 text-selected"
|
||||||
|
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
|
||||||
|
}
|
||||||
|
id="sub_label"
|
||||||
|
value="sub_label"
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
className="cursor-pointer"
|
||||||
|
htmlFor="sub_label"
|
||||||
|
>
|
||||||
|
{t("wizard.step1.classificationSubLabel")}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RadioGroupItem
|
||||||
|
className={
|
||||||
|
watchedObjectType === "attribute"
|
||||||
|
? "bg-selected from-selected/50 to-selected/90 text-selected"
|
||||||
|
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
|
||||||
|
}
|
||||||
|
id="attribute"
|
||||||
|
value="attribute"
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
className="cursor-pointer"
|
||||||
|
htmlFor="attribute"
|
||||||
|
>
|
||||||
|
{t("wizard.step1.classificationAttribute")}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isStateModel && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<FormLabel className="text-primary-variant">
|
||||||
|
{t("wizard.step1.states")}
|
||||||
|
</FormLabel>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
className="size-6 rounded-md bg-secondary-foreground p-1 text-background"
|
||||||
|
onClick={handleAddClass}
|
||||||
|
>
|
||||||
|
<LuPlus />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{watchedClasses?.map((_: string, index: number) => (
|
||||||
|
<FormField
|
||||||
|
key={index}
|
||||||
|
control={
|
||||||
|
(form as ReturnType<typeof useForm<StateFormData>>)
|
||||||
|
.control
|
||||||
|
}
|
||||||
|
name={`classes.${index}` as const}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
className="text-md h-8"
|
||||||
|
placeholder={t(
|
||||||
|
"wizard.step1.classPlaceholder",
|
||||||
|
)}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
{watchedClasses &&
|
||||||
|
watchedClasses.length > 1 && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => handleRemoveClass(index)}
|
||||||
|
>
|
||||||
|
<LuX className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{isStateModel &&
|
||||||
|
"classes" in form.formState.errors &&
|
||||||
|
form.formState.errors.classes && (
|
||||||
|
<p className="text-sm font-medium text-destructive">
|
||||||
|
{form.formState.errors.classes.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="sm:flex-1"
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
{t("button.cancel", { ns: "common" })}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="select"
|
||||||
|
className="flex items-center justify-center gap-2 sm:flex-1"
|
||||||
|
disabled={!form.formState.isValid || isSaving}
|
||||||
|
>
|
||||||
|
{isSaving
|
||||||
|
? t("button.saving", { ns: "common" })
|
||||||
|
: t("button.save", { ns: "common" })}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -8,7 +8,7 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { useFormContext } from "react-hook-form";
|
import { useFormContext } from "react-hook-form";
|
||||||
import { generateFixedHash, isValidId } from "@/utils/stringUtil";
|
import { generateFixedHash, isValidId } from "@/utils/stringUtil";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -25,6 +25,7 @@ type NameAndIdFieldsProps<T extends FieldValues = FieldValues> = {
|
|||||||
processId?: (name: string) => string;
|
processId?: (name: string) => string;
|
||||||
placeholderName?: string;
|
placeholderName?: string;
|
||||||
placeholderId?: string;
|
placeholderId?: string;
|
||||||
|
idVisible?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function NameAndIdFields<T extends FieldValues = FieldValues>({
|
export default function NameAndIdFields<T extends FieldValues = FieldValues>({
|
||||||
@ -39,10 +40,12 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
|
|||||||
processId,
|
processId,
|
||||||
placeholderName,
|
placeholderName,
|
||||||
placeholderId,
|
placeholderId,
|
||||||
|
idVisible,
|
||||||
}: NameAndIdFieldsProps<T>) {
|
}: NameAndIdFieldsProps<T>) {
|
||||||
const { t } = useTranslation(["common"]);
|
const { t } = useTranslation(["common"]);
|
||||||
const { watch, setValue, trigger } = useFormContext<T>();
|
const { watch, setValue, trigger, formState } = useFormContext<T>();
|
||||||
const [isIdVisible, setIsIdVisible] = useState(false);
|
const [isIdVisible, setIsIdVisible] = useState(idVisible ?? false);
|
||||||
|
const hasUserTypedRef = useRef(false);
|
||||||
|
|
||||||
const defaultProcessId = (name: string) => {
|
const defaultProcessId = (name: string) => {
|
||||||
const normalized = name.replace(/\s+/g, "_").toLowerCase();
|
const normalized = name.replace(/\s+/g, "_").toLowerCase();
|
||||||
@ -58,6 +61,7 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const subscription = watch((value, { name }) => {
|
const subscription = watch((value, { name }) => {
|
||||||
if (name === nameField) {
|
if (name === nameField) {
|
||||||
|
hasUserTypedRef.current = true;
|
||||||
const processedId = effectiveProcessId(value[nameField] || "");
|
const processedId = effectiveProcessId(value[nameField] || "");
|
||||||
setValue(idField, processedId as PathValue<T, Path<T>>);
|
setValue(idField, processedId as PathValue<T, Path<T>>);
|
||||||
trigger(idField);
|
trigger(idField);
|
||||||
@ -66,6 +70,14 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
|
|||||||
return () => subscription.unsubscribe();
|
return () => subscription.unsubscribe();
|
||||||
}, [watch, setValue, trigger, nameField, idField, effectiveProcessId]);
|
}, [watch, setValue, trigger, nameField, idField, effectiveProcessId]);
|
||||||
|
|
||||||
|
// Auto-expand if there's an error on the ID field after user has typed
|
||||||
|
useEffect(() => {
|
||||||
|
const idError = formState.errors[idField];
|
||||||
|
if (idError && hasUserTypedRef.current && !isIdVisible) {
|
||||||
|
setIsIdVisible(true);
|
||||||
|
}
|
||||||
|
}, [formState.errors, idField, isIdVisible]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FormField
|
<FormField
|
||||||
|
|||||||
@ -258,6 +258,7 @@ export default function CreateTriggerDialog({
|
|||||||
nameLabel={t("triggers.dialog.form.name.title")}
|
nameLabel={t("triggers.dialog.form.name.title")}
|
||||||
nameDescription={t("triggers.dialog.form.name.description")}
|
nameDescription={t("triggers.dialog.form.name.description")}
|
||||||
placeholderName={t("triggers.dialog.form.name.placeholder")}
|
placeholderName={t("triggers.dialog.form.name.placeholder")}
|
||||||
|
idVisible={!!trigger}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
|
|||||||
@ -289,6 +289,7 @@ export default function VideoControls({
|
|||||||
}}
|
}}
|
||||||
onUploadFrame={onUploadFrame}
|
onUploadFrame={onUploadFrame}
|
||||||
containerRef={containerRef}
|
containerRef={containerRef}
|
||||||
|
fullscreen={fullscreen}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{features.fullscreen && toggleFullscreen && (
|
{features.fullscreen && toggleFullscreen && (
|
||||||
@ -306,6 +307,7 @@ type FrigatePlusUploadButtonProps = {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onUploadFrame: () => void;
|
onUploadFrame: () => void;
|
||||||
containerRef?: React.MutableRefObject<HTMLDivElement | null>;
|
containerRef?: React.MutableRefObject<HTMLDivElement | null>;
|
||||||
|
fullscreen?: boolean;
|
||||||
};
|
};
|
||||||
function FrigatePlusUploadButton({
|
function FrigatePlusUploadButton({
|
||||||
video,
|
video,
|
||||||
@ -313,6 +315,7 @@ function FrigatePlusUploadButton({
|
|||||||
onClose,
|
onClose,
|
||||||
onUploadFrame,
|
onUploadFrame,
|
||||||
containerRef,
|
containerRef,
|
||||||
|
fullscreen,
|
||||||
}: FrigatePlusUploadButtonProps) {
|
}: FrigatePlusUploadButtonProps) {
|
||||||
const { t } = useTranslation(["components/player"]);
|
const { t } = useTranslation(["components/player"]);
|
||||||
|
|
||||||
@ -349,7 +352,11 @@ function FrigatePlusUploadButton({
|
|||||||
/>
|
/>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent
|
<AlertDialogContent
|
||||||
portalProps={{ container: containerRef?.current }}
|
portalProps={
|
||||||
|
fullscreen && containerRef?.current
|
||||||
|
? { container: containerRef.current }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
className="md:max-w-2xl lg:max-w-3xl xl:max-w-4xl"
|
className="md:max-w-2xl lg:max-w-3xl xl:max-w-4xl"
|
||||||
>
|
>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
|
|||||||
@ -385,7 +385,7 @@ export default function Step1NameCamera({
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
className="h-8"
|
className="text-md h-8"
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
"cameraWizard.step1.cameraNamePlaceholder",
|
"cameraWizard.step1.cameraNamePlaceholder",
|
||||||
)}
|
)}
|
||||||
@ -475,7 +475,7 @@ export default function Step1NameCamera({
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
className="h-8"
|
className="text-md h-8"
|
||||||
placeholder="192.168.1.100"
|
placeholder="192.168.1.100"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
@ -495,7 +495,7 @@ export default function Step1NameCamera({
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
className="h-8"
|
className="text-md h-8"
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
"cameraWizard.step1.usernamePlaceholder",
|
"cameraWizard.step1.usernamePlaceholder",
|
||||||
)}
|
)}
|
||||||
@ -518,7 +518,7 @@ export default function Step1NameCamera({
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
className="h-8 pr-10"
|
className="text-md h-8 pr-10"
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
"cameraWizard.step1.passwordPlaceholder",
|
"cameraWizard.step1.passwordPlaceholder",
|
||||||
@ -558,7 +558,7 @@ export default function Step1NameCamera({
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
className="h-8"
|
className="text-md h-8"
|
||||||
placeholder="rtsp://username:password@host:port/path"
|
placeholder="rtsp://username:password@host:port/path"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import {
|
|||||||
LuChevronRight,
|
LuChevronRight,
|
||||||
LuSettings,
|
LuSettings,
|
||||||
} from "react-icons/lu";
|
} from "react-icons/lu";
|
||||||
|
import { MdAutoAwesome } from "react-icons/md";
|
||||||
import { getTranslatedLabel } from "@/utils/i18n";
|
import { getTranslatedLabel } from "@/utils/i18n";
|
||||||
import EventMenu from "@/components/timeline/EventMenu";
|
import EventMenu from "@/components/timeline/EventMenu";
|
||||||
import { FrigatePlusDialog } from "@/components/overlay/dialog/FrigatePlusDialog";
|
import { FrigatePlusDialog } from "@/components/overlay/dialog/FrigatePlusDialog";
|
||||||
@ -367,7 +368,11 @@ function ReviewGroup({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-review-id={id}
|
data-review-id={id}
|
||||||
className="cursor-pointer rounded-lg bg-secondary py-3"
|
className={`mx-1 cursor-pointer rounded-lg bg-secondary px-0 py-3 outline outline-[2px] -outline-offset-[1.8px] ${
|
||||||
|
isActive
|
||||||
|
? "shadow-selected outline-selected"
|
||||||
|
: "outline-transparent duration-500"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -382,10 +387,10 @@ function ReviewGroup({
|
|||||||
<div className="ml-4 mr-2 mt-1.5 flex flex-row items-start">
|
<div className="ml-4 mr-2 mt-1.5 flex flex-row items-start">
|
||||||
<LuCircle
|
<LuCircle
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-3",
|
"size-3 duration-500",
|
||||||
isActive
|
review.severity == "alert"
|
||||||
? "fill-selected text-selected"
|
? "fill-severity_alert text-severity_alert"
|
||||||
: "fill-muted duration-500 dark:fill-secondary-highlight dark:text-secondary-highlight",
|
: "fill-severity_detection text-severity_detection",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -406,8 +411,9 @@ function ReviewGroup({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
{review.data.metadata?.title && (
|
{review.data.metadata?.title && (
|
||||||
<div className="mb-1 text-sm text-primary-variant">
|
<div className="mb-1 flex items-center gap-1 text-sm text-primary-variant">
|
||||||
{review.data.metadata.title}
|
<MdAutoAwesome className="size-3 shrink-0" />
|
||||||
|
<span className="truncate">{review.data.metadata.title}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-row items-center gap-1.5">
|
<div className="flex flex-row items-center gap-1.5">
|
||||||
@ -454,6 +460,7 @@ function ReviewGroup({
|
|||||||
<EventList
|
<EventList
|
||||||
key={event.id}
|
key={event.id}
|
||||||
event={event}
|
event={event}
|
||||||
|
review={review}
|
||||||
effectiveTime={effectiveTime}
|
effectiveTime={effectiveTime}
|
||||||
annotationOffset={annotationOffset}
|
annotationOffset={annotationOffset}
|
||||||
onSeek={onSeek}
|
onSeek={onSeek}
|
||||||
@ -488,6 +495,7 @@ function ReviewGroup({
|
|||||||
|
|
||||||
type EventListProps = {
|
type EventListProps = {
|
||||||
event: Event;
|
event: Event;
|
||||||
|
review: ReviewSegment;
|
||||||
effectiveTime?: number;
|
effectiveTime?: number;
|
||||||
annotationOffset: number;
|
annotationOffset: number;
|
||||||
onSeek: (ts: number, play?: boolean) => void;
|
onSeek: (ts: number, play?: boolean) => void;
|
||||||
@ -495,6 +503,7 @@ type EventListProps = {
|
|||||||
};
|
};
|
||||||
function EventList({
|
function EventList({
|
||||||
event,
|
event,
|
||||||
|
review,
|
||||||
effectiveTime,
|
effectiveTime,
|
||||||
annotationOffset,
|
annotationOffset,
|
||||||
onSeek,
|
onSeek,
|
||||||
@ -613,6 +622,7 @@ function EventList({
|
|||||||
|
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<ObjectTimeline
|
<ObjectTimeline
|
||||||
|
review={review}
|
||||||
eventId={event.id}
|
eventId={event.id}
|
||||||
onSeek={handleTimelineClick}
|
onSeek={handleTimelineClick}
|
||||||
effectiveTime={effectiveTime}
|
effectiveTime={effectiveTime}
|
||||||
@ -761,6 +771,7 @@ function LifecycleItem({
|
|||||||
|
|
||||||
// Fetch and render timeline entries for a single event id on demand.
|
// Fetch and render timeline entries for a single event id on demand.
|
||||||
function ObjectTimeline({
|
function ObjectTimeline({
|
||||||
|
review,
|
||||||
eventId,
|
eventId,
|
||||||
onSeek,
|
onSeek,
|
||||||
effectiveTime,
|
effectiveTime,
|
||||||
@ -768,6 +779,7 @@ function ObjectTimeline({
|
|||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
}: {
|
}: {
|
||||||
|
review: ReviewSegment;
|
||||||
eventId: string;
|
eventId: string;
|
||||||
onSeek: (ts: number, play?: boolean) => void;
|
onSeek: (ts: number, play?: boolean) => void;
|
||||||
effectiveTime?: number;
|
effectiveTime?: number;
|
||||||
@ -776,13 +788,27 @@ function ObjectTimeline({
|
|||||||
endTime?: number;
|
endTime?: number;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("views/events");
|
const { t } = useTranslation("views/events");
|
||||||
const { data: timeline, isValidating } = useSWR<TrackingDetailsSequence[]>([
|
const { data: fullTimeline, isValidating } = useSWR<
|
||||||
|
TrackingDetailsSequence[]
|
||||||
|
>([
|
||||||
"timeline",
|
"timeline",
|
||||||
{
|
{
|
||||||
source_id: eventId,
|
source_id: eventId,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const timeline = useMemo(() => {
|
||||||
|
if (!fullTimeline) {
|
||||||
|
return fullTimeline;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fullTimeline.filter(
|
||||||
|
(t) =>
|
||||||
|
t.timestamp >= review.start_time &&
|
||||||
|
(review.end_time == undefined || t.timestamp <= review.end_time),
|
||||||
|
);
|
||||||
|
}, [fullTimeline, review]);
|
||||||
|
|
||||||
if (isValidating && (!timeline || timeline.length === 0)) {
|
if (isValidating && (!timeline || timeline.length === 0)) {
|
||||||
return <ActivityIndicator className="ml-2 size-3" />;
|
return <ActivityIndicator className="ml-2 size-3" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import { useApiHost } from "@/api";
|
|
||||||
import { useTimelineUtils } from "@/hooks/use-timeline-utils";
|
import { useTimelineUtils } from "@/hooks/use-timeline-utils";
|
||||||
import { useEventSegmentUtils } from "@/hooks/use-event-segment-utils";
|
import { useEventSegmentUtils } from "@/hooks/use-event-segment-utils";
|
||||||
import { ReviewSegment, ReviewSeverity } from "@/types/review";
|
import { ReviewSegment, ReviewSeverity } from "@/types/review";
|
||||||
@ -18,6 +17,7 @@ import { HoverCardPortal } from "@radix-ui/react-hover-card";
|
|||||||
import scrollIntoView from "scroll-into-view-if-needed";
|
import scrollIntoView from "scroll-into-view-if-needed";
|
||||||
import { MinimapBounds, Tick, Timestamp } from "./segment-metadata";
|
import { MinimapBounds, Tick, Timestamp } from "./segment-metadata";
|
||||||
import useTapUtils from "@/hooks/use-tap-utils";
|
import useTapUtils from "@/hooks/use-tap-utils";
|
||||||
|
import ReviewCard from "../card/ReviewCard";
|
||||||
|
|
||||||
type EventSegmentProps = {
|
type EventSegmentProps = {
|
||||||
events: ReviewSegment[];
|
events: ReviewSegment[];
|
||||||
@ -54,7 +54,7 @@ export function EventSegment({
|
|||||||
displaySeverityType,
|
displaySeverityType,
|
||||||
shouldShowRoundedCorners,
|
shouldShowRoundedCorners,
|
||||||
getEventStart,
|
getEventStart,
|
||||||
getEventThumbnail,
|
getEvent,
|
||||||
} = useEventSegmentUtils(segmentDuration, events, severityType);
|
} = useEventSegmentUtils(segmentDuration, events, severityType);
|
||||||
|
|
||||||
const { alignStartDateToTimeline, alignEndDateToTimeline } = useTimelineUtils(
|
const { alignStartDateToTimeline, alignEndDateToTimeline } = useTimelineUtils(
|
||||||
@ -87,13 +87,11 @@ export function EventSegment({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [getEventStart, segmentTime]);
|
}, [getEventStart, segmentTime]);
|
||||||
|
|
||||||
const apiHost = useApiHost();
|
|
||||||
|
|
||||||
const { handleTouchStart } = useTapUtils();
|
const { handleTouchStart } = useTapUtils();
|
||||||
|
|
||||||
const eventThumbnail = useMemo(() => {
|
const segmentEvent = useMemo(() => {
|
||||||
return getEventThumbnail(segmentTime);
|
return getEvent(segmentTime);
|
||||||
}, [getEventThumbnail, segmentTime]);
|
}, [getEvent, segmentTime]);
|
||||||
|
|
||||||
const timestamp = useMemo(() => new Date(segmentTime * 1000), [segmentTime]);
|
const timestamp = useMemo(() => new Date(segmentTime * 1000), [segmentTime]);
|
||||||
const segmentKey = useMemo(
|
const segmentKey = useMemo(
|
||||||
@ -252,10 +250,7 @@ export function EventSegment({
|
|||||||
className="w-[250px] rounded-lg p-2 md:rounded-2xl"
|
className="w-[250px] rounded-lg p-2 md:rounded-2xl"
|
||||||
side="left"
|
side="left"
|
||||||
>
|
>
|
||||||
<img
|
{segmentEvent && <ReviewCard event={segmentEvent} />}
|
||||||
className="rounded-lg"
|
|
||||||
src={`${apiHost}${eventThumbnail.replace("/media/frigate/", "")}`}
|
|
||||||
/>
|
|
||||||
</HoverCardContent>
|
</HoverCardContent>
|
||||||
</HoverCardPortal>
|
</HoverCardPortal>
|
||||||
</HoverCard>
|
</HoverCard>
|
||||||
|
|||||||
@ -101,7 +101,7 @@ export default function Step1NameAndType({
|
|||||||
|
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
mode: "onChange",
|
mode: "onBlur",
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
name: initialData?.name ?? trigger?.name ?? "",
|
name: initialData?.name ?? trigger?.name ?? "",
|
||||||
|
|||||||
@ -191,8 +191,8 @@ export const useEventSegmentUtils = (
|
|||||||
[events, getSegmentStart, getSegmentEnd, severityType],
|
[events, getSegmentStart, getSegmentEnd, severityType],
|
||||||
);
|
);
|
||||||
|
|
||||||
const getEventThumbnail = useCallback(
|
const getEvent = useCallback(
|
||||||
(time: number): string => {
|
(time: number): ReviewSegment | undefined => {
|
||||||
const matchingEvent = events.find((event) => {
|
const matchingEvent = events.find((event) => {
|
||||||
return (
|
return (
|
||||||
time >= getSegmentStart(event.start_time) &&
|
time >= getSegmentStart(event.start_time) &&
|
||||||
@ -201,7 +201,7 @@ export const useEventSegmentUtils = (
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return matchingEvent?.thumb_path ?? "";
|
return matchingEvent;
|
||||||
},
|
},
|
||||||
[events, getSegmentStart, getSegmentEnd, severityType],
|
[events, getSegmentStart, getSegmentEnd, severityType],
|
||||||
);
|
);
|
||||||
@ -214,6 +214,6 @@ export const useEventSegmentUtils = (
|
|||||||
getReviewed,
|
getReviewed,
|
||||||
shouldShowRoundedCorners,
|
shouldShowRoundedCorners,
|
||||||
getEventStart,
|
getEventStart,
|
||||||
getEventThumbnail,
|
getEvent,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -157,9 +157,11 @@ function MobileMenuItem({
|
|||||||
const { t } = useTranslation(["views/settings"]);
|
const { t } = useTranslation(["views/settings"]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<div
|
||||||
variant="ghost"
|
className={cn(
|
||||||
className={cn("w-full justify-between pr-2", className)}
|
"inline-flex h-10 w-full cursor-pointer items-center justify-between whitespace-nowrap rounded-md px-4 py-2 pr-2 text-sm font-medium text-primary-variant disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onSelect(item.key);
|
onSelect(item.key);
|
||||||
onClose?.();
|
onClose?.();
|
||||||
@ -167,7 +169,7 @@ function MobileMenuItem({
|
|||||||
>
|
>
|
||||||
<div className="smart-capitalize">{t("menu." + item.key)}</div>
|
<div className="smart-capitalize">{t("menu." + item.key)}</div>
|
||||||
<LuChevronRight className="size-4" />
|
<LuChevronRight className="size-4" />
|
||||||
</Button>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -273,6 +275,9 @@ export default function Settings() {
|
|||||||
} else {
|
} else {
|
||||||
setPageToggle(page as SettingsType);
|
setPageToggle(page as SettingsType);
|
||||||
}
|
}
|
||||||
|
if (isMobile) {
|
||||||
|
setContentMobileOpen(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// don't clear url params if we're creating a new object mask
|
// don't clear url params if we're creating a new object mask
|
||||||
return !(searchParams.has("object_mask") || searchParams.has("event_id"));
|
return !(searchParams.has("object_mask") || searchParams.has("event_id"));
|
||||||
@ -282,6 +287,9 @@ export default function Settings() {
|
|||||||
const cameraNames = cameras.map((c) => c.name);
|
const cameraNames = cameras.map((c) => c.name);
|
||||||
if (cameraNames.includes(camera)) {
|
if (cameraNames.includes(camera)) {
|
||||||
setSelectedCamera(camera);
|
setSelectedCamera(camera);
|
||||||
|
if (isMobile) {
|
||||||
|
setContentMobileOpen(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// don't clear url params if we're creating a new object mask or trigger
|
// don't clear url params if we're creating a new object mask or trigger
|
||||||
return !(searchParams.has("object_mask") || searchParams.has("event_id"));
|
return !(searchParams.has("object_mask") || searchParams.has("event_id"));
|
||||||
|
|||||||
@ -306,6 +306,7 @@ export type CustomClassificationModelConfig = {
|
|||||||
threshold: number;
|
threshold: number;
|
||||||
object_config?: {
|
object_config?: {
|
||||||
objects: string[];
|
objects: string[];
|
||||||
|
classification_type: string;
|
||||||
};
|
};
|
||||||
state_config?: {
|
state_config?: {
|
||||||
cameras: {
|
cameras: {
|
||||||
|
|||||||
@ -43,5 +43,5 @@ export function generateFixedHash(name: string, prefix: string = "id"): string {
|
|||||||
* @returns True if the name is valid, false otherwise
|
* @returns True if the name is valid, false otherwise
|
||||||
*/
|
*/
|
||||||
export function isValidId(name: string): boolean {
|
export function isValidId(name: string): boolean {
|
||||||
return /^[a-zA-Z0-9_-]+$/.test(name);
|
return /^[a-zA-Z0-9_-]+$/.test(name) && !/^\d+$/.test(name);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { baseUrl } from "@/api/baseUrl";
|
import { baseUrl } from "@/api/baseUrl";
|
||||||
import ClassificationModelWizardDialog from "@/components/classification/ClassificationModelWizardDialog";
|
import ClassificationModelWizardDialog from "@/components/classification/ClassificationModelWizardDialog";
|
||||||
|
import ClassificationModelEditDialog from "@/components/classification/ClassificationModelEditDialog";
|
||||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
import { ImageShadowOverlay } from "@/components/overlay/ImageShadowOverlay";
|
import { ImageShadowOverlay } from "@/components/overlay/ImageShadowOverlay";
|
||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
@ -14,7 +15,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FaFolderPlus } from "react-icons/fa";
|
import { FaFolderPlus } from "react-icons/fa";
|
||||||
import { MdModelTraining } from "react-icons/md";
|
import { MdModelTraining } from "react-icons/md";
|
||||||
import { LuTrash2 } from "react-icons/lu";
|
import { LuPencil, LuTrash2 } from "react-icons/lu";
|
||||||
import { FiMoreVertical } from "react-icons/fi";
|
import { FiMoreVertical } from "react-icons/fi";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import Heading from "@/components/ui/heading";
|
import Heading from "@/components/ui/heading";
|
||||||
@ -163,6 +164,7 @@ export default function ModelSelectionView({
|
|||||||
key={config.name}
|
key={config.name}
|
||||||
config={config}
|
config={config}
|
||||||
onClick={() => onClick(config)}
|
onClick={() => onClick(config)}
|
||||||
|
onUpdate={() => refreshConfig()}
|
||||||
onDelete={() => refreshConfig()}
|
onDelete={() => refreshConfig()}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@ -201,9 +203,10 @@ function NoModelsView({
|
|||||||
type ModelCardProps = {
|
type ModelCardProps = {
|
||||||
config: CustomClassificationModelConfig;
|
config: CustomClassificationModelConfig;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
onUpdate: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
};
|
};
|
||||||
function ModelCard({ config, onClick, onDelete }: ModelCardProps) {
|
function ModelCard({ config, onClick, onUpdate, onDelete }: ModelCardProps) {
|
||||||
const { t } = useTranslation(["views/classificationModel"]);
|
const { t } = useTranslation(["views/classificationModel"]);
|
||||||
|
|
||||||
const { data: dataset } = useSWR<{
|
const { data: dataset } = useSWR<{
|
||||||
@ -211,6 +214,7 @@ function ModelCard({ config, onClick, onDelete }: ModelCardProps) {
|
|||||||
}>(`classification/${config.name}/dataset`, { revalidateOnFocus: false });
|
}>(`classification/${config.name}/dataset`, { revalidateOnFocus: false });
|
||||||
|
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||||
|
|
||||||
const handleDelete = useCallback(async () => {
|
const handleDelete = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@ -250,6 +254,11 @@ function ModelCard({ config, onClick, onDelete }: ModelCardProps) {
|
|||||||
setDeleteDialogOpen(true);
|
setDeleteDialogOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleEditClick = useCallback((e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setEditDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const coverImage = useMemo(() => {
|
const coverImage = useMemo(() => {
|
||||||
if (!dataset) {
|
if (!dataset) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@ -270,6 +279,13 @@ function ModelCard({ config, onClick, onDelete }: ModelCardProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<ClassificationModelEditDialog
|
||||||
|
open={editDialogOpen}
|
||||||
|
model={config}
|
||||||
|
onClose={() => setEditDialogOpen(false)}
|
||||||
|
onSuccess={() => onUpdate()}
|
||||||
|
/>
|
||||||
|
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
open={deleteDialogOpen}
|
open={deleteDialogOpen}
|
||||||
onOpenChange={() => setDeleteDialogOpen(!deleteDialogOpen)}
|
onOpenChange={() => setDeleteDialogOpen(!deleteDialogOpen)}
|
||||||
@ -320,6 +336,10 @@ function ModelCard({ config, onClick, onDelete }: ModelCardProps) {
|
|||||||
align="end"
|
align="end"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
|
<DropdownMenuItem onClick={handleEditClick}>
|
||||||
|
<LuPencil className="mr-2 size-4" />
|
||||||
|
<span>{t("button.edit", { ns: "common" })}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={handleDeleteClick}>
|
<DropdownMenuItem onClick={handleDeleteClick}>
|
||||||
<LuTrash2 className="mr-2 size-4" />
|
<LuTrash2 className="mr-2 size-4" />
|
||||||
<span>{t("button.delete", { ns: "common" })}</span>
|
<span>{t("button.delete", { ns: "common" })}</span>
|
||||||
|
|||||||
@ -327,31 +327,39 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
|
|||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
<div className="flex flex-row justify-between gap-2 p-2 align-middle">
|
<div className="flex flex-row justify-between gap-2 p-2 align-middle">
|
||||||
<div className="flex flex-row items-center justify-center gap-2">
|
{(isDesktop || !selectedImages?.length) && (
|
||||||
<Button
|
<div className="flex flex-row items-center justify-center gap-2">
|
||||||
className="flex items-center gap-2.5 rounded-lg"
|
<Button
|
||||||
aria-label={t("label.back", { ns: "common" })}
|
className="flex items-center gap-2.5 rounded-lg"
|
||||||
onClick={() => navigate(-1)}
|
aria-label={t("label.back", { ns: "common" })}
|
||||||
>
|
onClick={() => navigate(-1)}
|
||||||
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
>
|
||||||
{isDesktop && (
|
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
||||||
<div className="text-primary">
|
{isDesktop && (
|
||||||
{t("button.back", { ns: "common" })}
|
<div className="text-primary">
|
||||||
</div>
|
{t("button.back", { ns: "common" })}
|
||||||
)}
|
</div>
|
||||||
</Button>
|
)}
|
||||||
<LibrarySelector
|
</Button>
|
||||||
pageToggle={pageToggle}
|
|
||||||
dataset={dataset || {}}
|
<LibrarySelector
|
||||||
trainImages={trainImages || []}
|
pageToggle={pageToggle}
|
||||||
setPageToggle={setPageToggle}
|
dataset={dataset || {}}
|
||||||
onDelete={onDelete}
|
trainImages={trainImages || []}
|
||||||
onRename={() => {}}
|
setPageToggle={setPageToggle}
|
||||||
/>
|
onDelete={onDelete}
|
||||||
</div>
|
onRename={() => {}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{selectedImages?.length > 0 ? (
|
{selectedImages?.length > 0 ? (
|
||||||
<div className="flex items-center justify-center gap-2">
|
<div
|
||||||
<div className="mx-1 flex w-48 items-center justify-center text-sm text-muted-foreground">
|
className={cn(
|
||||||
|
"flex w-full items-center justify-end gap-2",
|
||||||
|
isMobileOnly && "justify-between",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex w-48 items-center justify-center text-sm text-muted-foreground">
|
||||||
<div className="p-1">{`${selectedImages.length} selected`}</div>
|
<div className="p-1">{`${selectedImages.length} selected`}</div>
|
||||||
<div className="p-1">{"|"}</div>
|
<div className="p-1">{"|"}</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -136,7 +136,7 @@ export default function EventView({
|
|||||||
|
|
||||||
const [selectedReviews, setSelectedReviews] = useState<ReviewSegment[]>([]);
|
const [selectedReviews, setSelectedReviews] = useState<ReviewSegment[]>([]);
|
||||||
const onSelectReview = useCallback(
|
const onSelectReview = useCallback(
|
||||||
(review: ReviewSegment, ctrl: boolean) => {
|
(review: ReviewSegment, ctrl: boolean, detail: boolean) => {
|
||||||
if (selectedReviews.length > 0 || ctrl) {
|
if (selectedReviews.length > 0 || ctrl) {
|
||||||
const index = selectedReviews.findIndex((r) => r.id === review.id);
|
const index = selectedReviews.findIndex((r) => r.id === review.id);
|
||||||
|
|
||||||
@ -156,17 +156,31 @@ export default function EventView({
|
|||||||
setSelectedReviews(copy);
|
setSelectedReviews(copy);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// If a specific date is selected in the calendar and it's after the event start,
|
||||||
|
// use the selected date instead of the event start time
|
||||||
|
const effectiveStartTime =
|
||||||
|
timeRange.after > review.start_time
|
||||||
|
? timeRange.after
|
||||||
|
: review.start_time;
|
||||||
|
|
||||||
onOpenRecording({
|
onOpenRecording({
|
||||||
camera: review.camera,
|
camera: review.camera,
|
||||||
startTime: review.start_time - REVIEW_PADDING,
|
startTime: effectiveStartTime - REVIEW_PADDING,
|
||||||
severity: review.severity,
|
severity: review.severity,
|
||||||
|
timelineType: detail ? "detail" : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
review.has_been_reviewed = true;
|
review.has_been_reviewed = true;
|
||||||
markItemAsReviewed(review);
|
markItemAsReviewed(review);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[selectedReviews, setSelectedReviews, onOpenRecording, markItemAsReviewed],
|
[
|
||||||
|
selectedReviews,
|
||||||
|
setSelectedReviews,
|
||||||
|
onOpenRecording,
|
||||||
|
markItemAsReviewed,
|
||||||
|
timeRange.after,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
const onSelectAllReviews = useCallback(() => {
|
const onSelectAllReviews = useCallback(() => {
|
||||||
if (!currentReviewItems || currentReviewItems.length == 0) {
|
if (!currentReviewItems || currentReviewItems.length == 0) {
|
||||||
@ -402,7 +416,6 @@ export default function EventView({
|
|||||||
onSelectAllReviews={onSelectAllReviews}
|
onSelectAllReviews={onSelectAllReviews}
|
||||||
setSelectedReviews={setSelectedReviews}
|
setSelectedReviews={setSelectedReviews}
|
||||||
pullLatestData={pullLatestData}
|
pullLatestData={pullLatestData}
|
||||||
onOpenRecording={onOpenRecording}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{severity == "significant_motion" && (
|
{severity == "significant_motion" && (
|
||||||
@ -442,11 +455,14 @@ type DetectionReviewProps = {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
markItemAsReviewed: (review: ReviewSegment) => void;
|
markItemAsReviewed: (review: ReviewSegment) => void;
|
||||||
markAllItemsAsReviewed: (currentItems: ReviewSegment[]) => void;
|
markAllItemsAsReviewed: (currentItems: ReviewSegment[]) => void;
|
||||||
onSelectReview: (review: ReviewSegment, ctrl: boolean) => void;
|
onSelectReview: (
|
||||||
|
review: ReviewSegment,
|
||||||
|
ctrl: boolean,
|
||||||
|
detail: boolean,
|
||||||
|
) => void;
|
||||||
onSelectAllReviews: () => void;
|
onSelectAllReviews: () => void;
|
||||||
setSelectedReviews: (reviews: ReviewSegment[]) => void;
|
setSelectedReviews: (reviews: ReviewSegment[]) => void;
|
||||||
pullLatestData: () => void;
|
pullLatestData: () => void;
|
||||||
onOpenRecording: (recordingInfo: RecordingStartingPoint) => void;
|
|
||||||
};
|
};
|
||||||
function DetectionReview({
|
function DetectionReview({
|
||||||
contentRef,
|
contentRef,
|
||||||
@ -466,7 +482,6 @@ function DetectionReview({
|
|||||||
onSelectAllReviews,
|
onSelectAllReviews,
|
||||||
setSelectedReviews,
|
setSelectedReviews,
|
||||||
pullLatestData,
|
pullLatestData,
|
||||||
onOpenRecording,
|
|
||||||
}: DetectionReviewProps) {
|
}: DetectionReviewProps) {
|
||||||
const { t } = useTranslation(["views/events"]);
|
const { t } = useTranslation(["views/events"]);
|
||||||
|
|
||||||
@ -758,16 +773,7 @@ function DetectionReview({
|
|||||||
ctrl: boolean,
|
ctrl: boolean,
|
||||||
detail: boolean,
|
detail: boolean,
|
||||||
) => {
|
) => {
|
||||||
if (detail) {
|
onSelectReview(review, ctrl, detail);
|
||||||
onOpenRecording({
|
|
||||||
camera: review.camera,
|
|
||||||
startTime: review.start_time - REVIEW_PADDING,
|
|
||||||
severity: review.severity,
|
|
||||||
timelineType: "detail",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
onSelectReview(review, ctrl);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -970,12 +970,11 @@ function Timeline({
|
|||||||
"relative overflow-hidden",
|
"relative overflow-hidden",
|
||||||
isDesktop
|
isDesktop
|
||||||
? cn(
|
? cn(
|
||||||
"no-scrollbar overflow-y-auto",
|
|
||||||
timelineType == "timeline"
|
timelineType == "timeline"
|
||||||
? "w-[100px] flex-shrink-0"
|
? "w-[100px] flex-shrink-0"
|
||||||
: timelineType == "detail"
|
: timelineType == "detail"
|
||||||
? "min-w-[20rem] max-w-[30%] flex-shrink-0 flex-grow-0 basis-[30rem] md:min-w-[20rem] md:max-w-[25%] lg:min-w-[30rem] lg:max-w-[33%]"
|
? "min-w-[20rem] max-w-[30%] flex-shrink-0 flex-grow-0 basis-[30rem] md:min-w-[20rem] md:max-w-[25%] lg:min-w-[30rem] lg:max-w-[33%]"
|
||||||
: "w-60 flex-shrink-0",
|
: "w-80 flex-shrink-0",
|
||||||
)
|
)
|
||||||
: cn(
|
: cn(
|
||||||
timelineType == "timeline"
|
timelineType == "timeline"
|
||||||
|
|||||||
@ -717,11 +717,11 @@ export default function CameraSettingsView({
|
|||||||
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[25%]">
|
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[25%]">
|
||||||
<Button
|
<Button
|
||||||
className="flex flex-1"
|
className="flex flex-1"
|
||||||
aria-label={t("button.cancel", { ns: "common" })}
|
aria-label={t("button.reset", { ns: "common" })}
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Trans>button.cancel</Trans>
|
<Trans>button.reset</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="select"
|
variant="select"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user