Merge branch 'dev' into dev

This commit is contained in:
GuoQing Liu 2025-03-13 20:29:40 +08:00 committed by GitHub
commit b9149931d0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 553 additions and 173 deletions

View File

@ -55,7 +55,7 @@ RUN --mount=type=tmpfs,target=/tmp --mount=type=tmpfs,target=/var/cache/apt \
FROM scratch AS go2rtc FROM scratch AS go2rtc
ARG TARGETARCH ARG TARGETARCH
WORKDIR /rootfs/usr/local/go2rtc/bin WORKDIR /rootfs/usr/local/go2rtc/bin
ADD --link --chmod=755 "https://github.com/AlexxIT/go2rtc/releases/download/v1.9.2/go2rtc_linux_${TARGETARCH}" go2rtc ADD --link --chmod=755 "https://github.com/AlexxIT/go2rtc/releases/download/v1.9.9/go2rtc_linux_${TARGETARCH}" go2rtc
FROM wget AS tempio FROM wget AS tempio
ARG TARGETARCH ARG TARGETARCH

View File

@ -69,10 +69,6 @@ elif go2rtc_config["log"].get("format") is None:
if go2rtc_config.get("webrtc") is None: if go2rtc_config.get("webrtc") is None:
go2rtc_config["webrtc"] = {} go2rtc_config["webrtc"] = {}
# go2rtc should listen on 8555 tcp & udp by default
if go2rtc_config["webrtc"].get("listen") is None:
go2rtc_config["webrtc"]["listen"] = ":8555"
if go2rtc_config["webrtc"].get("candidates") is None: if go2rtc_config["webrtc"].get("candidates") is None:
default_candidates = [] default_candidates = []
# use internal candidate if it was discovered when running through the add-on # use internal candidate if it was discovered when running through the add-on
@ -84,33 +80,15 @@ if go2rtc_config["webrtc"].get("candidates") is None:
go2rtc_config["webrtc"]["candidates"] = default_candidates go2rtc_config["webrtc"]["candidates"] = default_candidates
# This prevents WebRTC from attempting to establish a connection to the internal if go2rtc_config.get("rtsp", {}).get("username") is not None:
# docker IPs which are not accessible from outside the container itself and just go2rtc_config["rtsp"]["username"] = go2rtc_config["rtsp"]["username"].format(
# wastes time during negotiation. Note that this is only necessary because **FRIGATE_ENV_VARS
# Frigate container doesn't run in host network mode. )
if go2rtc_config["webrtc"].get("filter") is None:
go2rtc_config["webrtc"]["filter"] = {"candidates": []}
elif go2rtc_config["webrtc"]["filter"].get("candidates") is None:
go2rtc_config["webrtc"]["filter"]["candidates"] = []
# sets default RTSP response to be equivalent to ?video=h264,h265&audio=aac if go2rtc_config.get("rtsp", {}).get("password") is not None:
# this means user does not need to specify audio codec when using restream go2rtc_config["rtsp"]["password"] = go2rtc_config["rtsp"]["password"].format(
# as source for frigate and the integration supports HLS playback **FRIGATE_ENV_VARS
if go2rtc_config.get("rtsp") is None: )
go2rtc_config["rtsp"] = {"default_query": "mp4"}
else:
if go2rtc_config["rtsp"].get("default_query") is None:
go2rtc_config["rtsp"]["default_query"] = "mp4"
if go2rtc_config["rtsp"].get("username") is not None:
go2rtc_config["rtsp"]["username"] = go2rtc_config["rtsp"]["username"].format(
**FRIGATE_ENV_VARS
)
if go2rtc_config["rtsp"].get("password") is not None:
go2rtc_config["rtsp"]["password"] = go2rtc_config["rtsp"]["password"].format(
**FRIGATE_ENV_VARS
)
# ensure ffmpeg path is set correctly # ensure ffmpeg path is set correctly
path = config.get("ffmpeg", {}).get("path", "default") path = config.get("ffmpeg", {}).get("path", "default")

View File

@ -186,7 +186,7 @@ To do this:
### Custom go2rtc version ### Custom go2rtc version
Frigate currently includes go2rtc v1.9.2, there may be certain cases where you want to run a different version of go2rtc. Frigate currently includes go2rtc v1.9.9, there may be certain cases where you want to run a different version of go2rtc.
To do this: To do this:

View File

@ -219,7 +219,7 @@ go2rtc:
- rtspx://192.168.1.1:7441/abcdefghijk - rtspx://192.168.1.1:7441/abcdefghijk
``` ```
[See the go2rtc docs for more information](https://github.com/AlexxIT/go2rtc/tree/v1.9.2#source-rtsp) [See the go2rtc docs for more information](https://github.com/AlexxIT/go2rtc/tree/v1.9.9#source-rtsp)
In the Unifi 2.0 update Unifi Protect Cameras had a change in audio sample rate which causes issues for ffmpeg. The input rate needs to be set for record if used directly with unifi protect. In the Unifi 2.0 update Unifi Protect Cameras had a change in audio sample rate which causes issues for ffmpeg. The input rate needs to be set for record if used directly with unifi protect.

View File

@ -3,16 +3,16 @@ id: license_plate_recognition
title: License Plate Recognition (LPR) title: License Plate Recognition (LPR)
--- ---
Frigate can recognize license plates on vehicles and automatically add the detected characters or recognized name as a `sub_label` to objects that are of type `car`. A common use case may be to read the license plates of cars pulling into a driveway or cars passing by on a street. Frigate can recognize license plates on vehicles and automatically add the detected characters to the `recognized_license_plate` field or a known name as a `sub_label` to objects that are of type `car`. A common use case may be to read the license plates of cars pulling into a driveway or cars passing by on a street.
LPR works best when the license plate is clearly visible to the camera. For moving vehicles, Frigate continuously refines the recognition process, keeping the most confident result. However, LPR does not run on stationary vehicles. LPR works best when the license plate is clearly visible to the camera. For moving vehicles, Frigate continuously refines the recognition process, keeping the most confident result. However, LPR does not run on stationary vehicles.
When a plate is recognized, the detected characters or recognized name is: When a plate is recognized, the recognized name is:
- Added as a `sub_label` to the `car` tracked object. - Added to the `car` tracked object as a `sub_label` (if known) or the `recognized_license_plate` field (if unknown)
- Viewable in the Review Item Details pane in Review and the Tracked Object Details pane in Explore. - Viewable in the Review Item Details pane in Review and the Tracked Object Details pane in Explore.
- Filterable through the More Filters menu in Explore. - Filterable through the More Filters menu in Explore.
- Published via the `frigate/events` MQTT topic as a `sub_label` for the tracked object. - Published via the `frigate/events` MQTT topic as a `sub_label` (known) or `recognized_license_plate` (unknown) for the tracked object.
## Model Requirements ## Model Requirements
@ -71,6 +71,7 @@ Fine-tune the LPR feature using these optional parameters:
- **`known_plates`**: List of strings or regular expressions that assign custom a `sub_label` to `car` objects when a recognized plate matches a known value. - **`known_plates`**: List of strings or regular expressions that assign custom a `sub_label` to `car` objects when a recognized plate matches a known value.
- These labels appear in the UI, filters, and notifications. - These labels appear in the UI, filters, and notifications.
- Unknown plates are still saved but are added to the `recognized_license_plate` field rather than the `sub_label`.
- **`match_distance`**: Allows for minor variations (missing/incorrect characters) when matching a detected plate to a known plate. - **`match_distance`**: Allows for minor variations (missing/incorrect characters) when matching a detected plate to a known plate.
- For example, setting `match_distance: 1` allows a plate `ABCDE` to match `ABCBE` or `ABCD`. - For example, setting `match_distance: 1` allows a plate `ABCDE` to match `ABCBE` or `ABCD`.
- This parameter will _not_ operate on known plates that are defined as regular expressions. You should define the full string of your plate in `known_plates` in order to use `match_distance`. - This parameter will _not_ operate on known plates that are defined as regular expressions. You should define the full string of your plate in `known_plates` in order to use `match_distance`.

View File

@ -591,7 +591,7 @@ genai:
person: "My special person prompt." person: "My special person prompt."
# Optional: Restream configuration # Optional: Restream configuration
# Uses https://github.com/AlexxIT/go2rtc (v1.9.2) # Uses https://github.com/AlexxIT/go2rtc (v1.9.9)
# NOTE: The default go2rtc API port (1984) must be used, # NOTE: The default go2rtc API port (1984) must be used,
# changing this port for the integrated go2rtc instance is not supported. # changing this port for the integrated go2rtc instance is not supported.
go2rtc: go2rtc:

View File

@ -7,7 +7,7 @@ title: Restream
Frigate can restream your video feed as an RTSP feed for other applications such as Home Assistant to utilize it at `rtsp://<frigate_host>:8554/<camera_name>`. Port 8554 must be open. [This allows you to use a video feed for detection in Frigate and Home Assistant live view at the same time without having to make two separate connections to the camera](#reduce-connections-to-camera). The video feed is copied from the original video feed directly to avoid re-encoding. This feed does not include any annotation by Frigate. Frigate can restream your video feed as an RTSP feed for other applications such as Home Assistant to utilize it at `rtsp://<frigate_host>:8554/<camera_name>`. Port 8554 must be open. [This allows you to use a video feed for detection in Frigate and Home Assistant live view at the same time without having to make two separate connections to the camera](#reduce-connections-to-camera). The video feed is copied from the original video feed directly to avoid re-encoding. This feed does not include any annotation by Frigate.
Frigate uses [go2rtc](https://github.com/AlexxIT/go2rtc/tree/v1.9.2) to provide its restream and MSE/WebRTC capabilities. The go2rtc config is hosted at the `go2rtc` in the config, see [go2rtc docs](https://github.com/AlexxIT/go2rtc/tree/v1.9.2#configuration) for more advanced configurations and features. Frigate uses [go2rtc](https://github.com/AlexxIT/go2rtc/tree/v1.9.9) to provide its restream and MSE/WebRTC capabilities. The go2rtc config is hosted at the `go2rtc` in the config, see [go2rtc docs](https://github.com/AlexxIT/go2rtc/tree/v1.9.9#configuration) for more advanced configurations and features.
:::note :::note
@ -134,7 +134,7 @@ cameras:
## Handling Complex Passwords ## Handling Complex Passwords
go2rtc expects URL-encoded passwords in the config, [urlencoder.org](https://urlencoder.org) can be used for this purpose. go2rtc expects URL-encoded passwords in the config, [urlencoder.org](https://urlencoder.org) can be used for this purpose.
For example: For example:
@ -156,7 +156,7 @@ See [this comment(https://github.com/AlexxIT/go2rtc/issues/1217#issuecomment-224
## Advanced Restream Configurations ## Advanced Restream Configurations
The [exec](https://github.com/AlexxIT/go2rtc/tree/v1.9.2#source-exec) source in go2rtc can be used for custom ffmpeg commands. An example is below: The [exec](https://github.com/AlexxIT/go2rtc/tree/v1.9.9#source-exec) source in go2rtc can be used for custom ffmpeg commands. An example is below:
NOTE: The output will need to be passed with two curly braces `{{output}}` NOTE: The output will need to be passed with two curly braces `{{output}}`

View File

@ -13,7 +13,7 @@ Use of the bundled go2rtc is optional. You can still configure FFmpeg to connect
# Setup a go2rtc stream # Setup a go2rtc stream
First, you will want to configure go2rtc to connect to your camera stream by adding the stream you want to use for live view in your Frigate config file. Avoid changing any other parts of your config at this step. Note that go2rtc supports [many different stream types](https://github.com/AlexxIT/go2rtc/tree/v1.9.2#module-streams), not just rtsp. First, you will want to configure go2rtc to connect to your camera stream by adding the stream you want to use for live view in your Frigate config file. Avoid changing any other parts of your config at this step. Note that go2rtc supports [many different stream types](https://github.com/AlexxIT/go2rtc/tree/v1.9.9#module-streams), not just rtsp.
:::tip :::tip
@ -32,69 +32,74 @@ go2rtc:
After adding this to the config, restart Frigate and try to watch the live stream for a single camera by clicking on it from the dashboard. It should look much clearer and more fluent than the original jsmpeg stream. After adding this to the config, restart Frigate and try to watch the live stream for a single camera by clicking on it from the dashboard. It should look much clearer and more fluent than the original jsmpeg stream.
### What if my video doesn't play? ### What if my video doesn't play?
- Check Logs: - Check Logs:
- Access the go2rtc logs in the Frigate UI under Logs in the sidebar.
- If go2rtc is having difficulty connecting to your camera, you should see some error messages in the log. - Access the go2rtc logs in the Frigate UI under Logs in the sidebar.
- If go2rtc is having difficulty connecting to your camera, you should see some error messages in the log.
- Check go2rtc Web Interface: if you don't see any errors in the logs, try viewing the camera through go2rtc's web interface. - Check go2rtc Web Interface: if you don't see any errors in the logs, try viewing the camera through go2rtc's web interface.
- Navigate to port 1984 in your browser to access go2rtc's web interface.
- If using Frigate through Home Assistant, enable the web interface at port 1984. - Navigate to port 1984 in your browser to access go2rtc's web interface.
- If using Docker, forward port 1984 before accessing the web interface. - If using Frigate through Home Assistant, enable the web interface at port 1984.
- Click `stream` for the specific camera to see if the camera's stream is being received. - If using Docker, forward port 1984 before accessing the web interface.
- Click `stream` for the specific camera to see if the camera's stream is being received.
- Check Video Codec: - Check Video Codec:
- If the camera stream works in go2rtc but not in your browser, the video codec might be unsupported.
- If using H265, switch to H264. Refer to [video codec compatibility](https://github.com/AlexxIT/go2rtc/tree/v1.9.2#codecs-madness) in go2rtc documentation.
- If unable to switch from H265 to H264, or if the stream format is different (e.g., MJPEG), re-encode the video using [FFmpeg parameters](https://github.com/AlexxIT/go2rtc/tree/v1.9.2#source-ffmpeg). It supports rotating and resizing video feeds and hardware acceleration. Keep in mind that transcoding video from one format to another is a resource intensive task and you may be better off using the built-in jsmpeg view.
```yaml
go2rtc:
streams:
back:
- rtsp://user:password@10.0.10.10:554/cam/realmonitor?channel=1&subtype=2
- "ffmpeg:back#video=h264#hardware"
```
- Switch to FFmpeg if needed: - If the camera stream works in go2rtc but not in your browser, the video codec might be unsupported.
- Some camera streams may need to use the ffmpeg module in go2rtc. This has the downside of slower startup times, but has compatibility with more stream types. - If using H265, switch to H264. Refer to [video codec compatibility](https://github.com/AlexxIT/go2rtc/tree/v1.9.9#codecs-madness) in go2rtc documentation.
```yaml - If unable to switch from H265 to H264, or if the stream format is different (e.g., MJPEG), re-encode the video using [FFmpeg parameters](https://github.com/AlexxIT/go2rtc/tree/v1.9.9#source-ffmpeg). It supports rotating and resizing video feeds and hardware acceleration. Keep in mind that transcoding video from one format to another is a resource intensive task and you may be better off using the built-in jsmpeg view.
go2rtc: ```yaml
streams: go2rtc:
back: streams:
- ffmpeg:rtsp://user:password@10.0.10.10:554/cam/realmonitor?channel=1&subtype=2 back:
``` - rtsp://user:password@10.0.10.10:554/cam/realmonitor?channel=1&subtype=2
- "ffmpeg:back#video=h264#hardware"
```
- If you can see the video but do not have audio, this is most likely because your camera's audio stream codec is not AAC. - Switch to FFmpeg if needed:
- If possible, update your camera's audio settings to AAC in your camera's firmware.
- If your cameras do not support AAC audio, you will need to tell go2rtc to re-encode the audio to AAC on demand if you want audio. This will use additional CPU and add some latency. To add AAC audio on demand, you can update your go2rtc config as follows:
```yaml
go2rtc:
streams:
back:
- rtsp://user:password@10.0.10.10:554/cam/realmonitor?channel=1&subtype=2
- "ffmpeg:back#audio=aac"
```
If you need to convert **both** the audio and video streams, you can use the following: - Some camera streams may need to use the ffmpeg module in go2rtc. This has the downside of slower startup times, but has compatibility with more stream types.
```yaml ```yaml
go2rtc: go2rtc:
streams: streams:
back: back:
- rtsp://user:password@10.0.10.10:554/cam/realmonitor?channel=1&subtype=2 - ffmpeg:rtsp://user:password@10.0.10.10:554/cam/realmonitor?channel=1&subtype=2
- "ffmpeg:back#video=h264#audio=aac#hardware" ```
```
When using the ffmpeg module, you would add AAC audio like this: - If you can see the video but do not have audio, this is most likely because your camera's audio stream codec is not AAC.
- If possible, update your camera's audio settings to AAC in your camera's firmware.
- If your cameras do not support AAC audio, you will need to tell go2rtc to re-encode the audio to AAC on demand if you want audio. This will use additional CPU and add some latency. To add AAC audio on demand, you can update your go2rtc config as follows:
```yaml ```yaml
go2rtc: go2rtc:
streams: streams:
back: back:
- "ffmpeg:rtsp://user:password@10.0.10.10:554/cam/realmonitor?channel=1&subtype=2#video=copy#audio=copy#audio=aac#hardware" - rtsp://user:password@10.0.10.10:554/cam/realmonitor?channel=1&subtype=2
``` - "ffmpeg:back#audio=aac"
```
If you need to convert **both** the audio and video streams, you can use the following:
```yaml
go2rtc:
streams:
back:
- rtsp://user:password@10.0.10.10:554/cam/realmonitor?channel=1&subtype=2
- "ffmpeg:back#video=h264#audio=aac#hardware"
```
When using the ffmpeg module, you would add AAC audio like this:
```yaml
go2rtc:
streams:
back:
- "ffmpeg:rtsp://user:password@10.0.10.10:554/cam/realmonitor?channel=1&subtype=2#video=copy#audio=copy#audio=aac#hardware"
```
:::warning :::warning

View File

@ -54,7 +54,9 @@ Message published for each changed tracked object. The first message is publishe
}, // attributes with top score that have been identified on the object at any point }, // attributes with top score that have been identified on the object at any point
"current_attributes": [], // detailed data about the current attributes in this frame "current_attributes": [], // detailed data about the current attributes in this frame
"current_estimated_speed": 0.71, // current estimated speed (mph or kph) for objects moving through zones with speed estimation enabled "current_estimated_speed": 0.71, // current estimated speed (mph or kph) for objects moving through zones with speed estimation enabled
"velocity_angle": 180 // direction of travel relative to the frame for objects moving through zones with speed estimation enabled "velocity_angle": 180, // direction of travel relative to the frame for objects moving through zones with speed estimation enabled
"recognized_license_plate": "ABC12345", // a recognized license plate for car objects
"recognized_license_plate_score": 0.933451
}, },
"after": { "after": {
"id": "1607123955.475377-mxklsc", "id": "1607123955.475377-mxklsc",
@ -93,7 +95,9 @@ Message published for each changed tracked object. The first message is publishe
} }
], ],
"current_estimated_speed": 0.77, // current estimated speed (mph or kph) for objects moving through zones with speed estimation enabled "current_estimated_speed": 0.77, // current estimated speed (mph or kph) for objects moving through zones with speed estimation enabled
"velocity_angle": 180 // direction of travel relative to the frame for objects moving through zones with speed estimation enabled "velocity_angle": 180, // direction of travel relative to the frame for objects moving through zones with speed estimation enabled
"recognized_license_plate": "ABC12345", // a recognized license plate for car objects
"recognized_license_plate_score": 0.933451
} }
} }
``` ```

View File

@ -1,106 +1,106 @@
import type { SidebarsConfig, } from '@docusaurus/plugin-content-docs'; import type { SidebarsConfig } from "@docusaurus/plugin-content-docs";
import { PropSidebarItemLink } from '@docusaurus/plugin-content-docs'; import { PropSidebarItemLink } from "@docusaurus/plugin-content-docs";
import frigateHttpApiSidebar from './docs/integrations/api/sidebar'; import frigateHttpApiSidebar from "./docs/integrations/api/sidebar";
const sidebars: SidebarsConfig = { const sidebars: SidebarsConfig = {
docs: { docs: {
Frigate: [ Frigate: [
'frigate/index', "frigate/index",
'frigate/hardware', "frigate/hardware",
'frigate/installation', "frigate/installation",
'frigate/camera_setup', "frigate/camera_setup",
'frigate/video_pipeline', "frigate/video_pipeline",
'frigate/glossary', "frigate/glossary",
], ],
Guides: [ Guides: [
'guides/getting_started', "guides/getting_started",
'guides/configuring_go2rtc', "guides/configuring_go2rtc",
'guides/ha_notifications', "guides/ha_notifications",
'guides/ha_network_storage', "guides/ha_network_storage",
'guides/reverse_proxy', "guides/reverse_proxy",
], ],
Configuration: { Configuration: {
'Configuration Files': [ "Configuration Files": [
'configuration/index', "configuration/index",
'configuration/reference', "configuration/reference",
{ {
type: 'link', type: "link",
label: 'Go2RTC Configuration Reference', label: "Go2RTC Configuration Reference",
href: 'https://github.com/AlexxIT/go2rtc/tree/v1.9.2#configuration', href: "https://github.com/AlexxIT/go2rtc/tree/v1.9.9#configuration",
} as PropSidebarItemLink, } as PropSidebarItemLink,
], ],
Detectors: [ Detectors: [
'configuration/object_detectors', "configuration/object_detectors",
'configuration/audio_detectors', "configuration/audio_detectors",
], ],
Classifiers: [ Classifiers: [
'configuration/semantic_search', "configuration/semantic_search",
'configuration/genai', "configuration/genai",
'configuration/face_recognition', "configuration/face_recognition",
'configuration/license_plate_recognition', "configuration/license_plate_recognition",
], ],
Cameras: [ Cameras: [
'configuration/cameras', "configuration/cameras",
'configuration/review', "configuration/review",
'configuration/record', "configuration/record",
'configuration/snapshots', "configuration/snapshots",
'configuration/motion_detection', "configuration/motion_detection",
'configuration/birdseye', "configuration/birdseye",
'configuration/live', "configuration/live",
'configuration/restream', "configuration/restream",
'configuration/autotracking', "configuration/autotracking",
'configuration/camera_specific', "configuration/camera_specific",
], ],
Objects: [ Objects: [
'configuration/object_filters', "configuration/object_filters",
'configuration/masks', "configuration/masks",
'configuration/zones', "configuration/zones",
'configuration/objects', "configuration/objects",
'configuration/stationary_objects', "configuration/stationary_objects",
], ],
'Extra Configuration': [ "Extra Configuration": [
'configuration/authentication', "configuration/authentication",
'configuration/notifications', "configuration/notifications",
'configuration/hardware_acceleration', "configuration/hardware_acceleration",
'configuration/ffmpeg_presets', "configuration/ffmpeg_presets",
"configuration/pwa", "configuration/pwa",
'configuration/tls', "configuration/tls",
'configuration/advanced', "configuration/advanced",
], ],
}, },
Integrations: [ Integrations: [
'integrations/plus', "integrations/plus",
'integrations/home-assistant', "integrations/home-assistant",
// This is the HTTP API generated by OpenAPI // This is the HTTP API generated by OpenAPI
{ {
type: 'category', type: "category",
label: 'HTTP API', label: "HTTP API",
link: { link: {
type: 'generated-index', type: "generated-index",
title: 'Frigate HTTP API', title: "Frigate HTTP API",
description: 'HTTP API', description: "HTTP API",
slug: '/integrations/api/frigate-http-api', slug: "/integrations/api/frigate-http-api",
}, },
items: frigateHttpApiSidebar, items: frigateHttpApiSidebar,
}, },
'integrations/mqtt', "integrations/mqtt",
'configuration/metrics', "configuration/metrics",
'integrations/third_party_extensions', "integrations/third_party_extensions",
], ],
'Frigate+': [ "Frigate+": [
'plus/index', "plus/index",
'plus/first_model', "plus/first_model",
'plus/improving_model', "plus/improving_model",
'plus/faq', "plus/faq",
], ],
Troubleshooting: [ Troubleshooting: [
'troubleshooting/faqs', "troubleshooting/faqs",
'troubleshooting/recordings', "troubleshooting/recordings",
'troubleshooting/edgetpu', "troubleshooting/edgetpu",
], ],
Development: [ Development: [
'development/contributing', "development/contributing",
'development/contributing-boards', "development/contributing-boards",
], ],
}, },
}; };

View File

@ -619,6 +619,41 @@ def get_sub_labels(split_joined: Optional[int] = None):
return JSONResponse(content=sub_labels) return JSONResponse(content=sub_labels)
@router.get("/recognized_license_plates")
def get_recognized_license_plates(split_joined: Optional[int] = None):
try:
events = Event.select(Event.data).distinct()
except Exception:
return JSONResponse(
content=(
{"success": False, "message": "Failed to get recognized license plates"}
),
status_code=404,
)
recognized_license_plates = []
for e in events:
if e.data is not None and "recognized_license_plate" in e.data:
recognized_license_plates.append(e.data["recognized_license_plate"])
while None in recognized_license_plates:
recognized_license_plates.remove(None)
if split_joined:
original_recognized_license_plates = recognized_license_plates.copy()
for recognized_license_plate in original_recognized_license_plates:
if recognized_license_plate and "," in recognized_license_plate:
recognized_license_plates.remove(recognized_license_plate)
parts = recognized_license_plate.split(",")
for part in parts:
if part.strip() not in recognized_license_plates:
recognized_license_plates.append(part.strip())
recognized_license_plates = list(set(recognized_license_plates))
recognized_license_plates.sort()
return JSONResponse(content=recognized_license_plates)
@router.get("/timeline") @router.get("/timeline")
def timeline(camera: str = "all", limit: int = 100, source_id: Optional[str] = None): def timeline(camera: str = "all", limit: int = 100, source_id: Optional[str] = None):
clauses = [] clauses = []

View File

@ -27,6 +27,7 @@ class EventsQueryParams(BaseModel):
max_score: Optional[float] = None max_score: Optional[float] = None
min_speed: Optional[float] = None min_speed: Optional[float] = None
max_speed: Optional[float] = None max_speed: Optional[float] = None
recognized_license_plate: Optional[str] = "all"
is_submitted: Optional[int] = None is_submitted: Optional[int] = None
min_length: Optional[float] = None min_length: Optional[float] = None
max_length: Optional[float] = None max_length: Optional[float] = None
@ -55,6 +56,7 @@ class EventsSearchQueryParams(BaseModel):
max_score: Optional[float] = None max_score: Optional[float] = None
min_speed: Optional[float] = None min_speed: Optional[float] = None
max_speed: Optional[float] = None max_speed: Optional[float] = None
recognized_license_plate: Optional[str] = "all"
sort: Optional[str] = None sort: Optional[str] = None

View File

@ -101,6 +101,7 @@ def events(params: EventsQueryParams = Depends()):
min_length = params.min_length min_length = params.min_length
max_length = params.max_length max_length = params.max_length
event_id = params.event_id event_id = params.event_id
recognized_license_plate = params.recognized_license_plate
sort = params.sort sort = params.sort
@ -158,6 +159,45 @@ def events(params: EventsQueryParams = Depends()):
sub_label_clause = reduce(operator.or_, sub_label_clauses) sub_label_clause = reduce(operator.or_, sub_label_clauses)
clauses.append((sub_label_clause)) clauses.append((sub_label_clause))
if recognized_license_plate != "all":
# use matching so joined recognized_license_plates are included
# for example a recognized license plate 'ABC123' would get events
# with recognized license plates 'ABC123' and 'ABC123, XYZ789'
recognized_license_plate_clauses = []
filtered_recognized_license_plates = recognized_license_plate.split(",")
if "None" in filtered_recognized_license_plates:
filtered_recognized_license_plates.remove("None")
recognized_license_plate_clauses.append(
(Event.data["recognized_license_plate"].is_null())
)
for recognized_license_plate in filtered_recognized_license_plates:
# Exact matching plus list inclusion
recognized_license_plate_clauses.append(
(
Event.data["recognized_license_plate"].cast("text")
== recognized_license_plate
)
)
recognized_license_plate_clauses.append(
(
Event.data["recognized_license_plate"].cast("text")
% f"*{recognized_license_plate},*"
)
)
recognized_license_plate_clauses.append(
(
Event.data["recognized_license_plate"].cast("text")
% f"*, {recognized_license_plate}*"
)
)
recognized_license_plate_clause = reduce(
operator.or_, recognized_license_plate_clauses
)
clauses.append((recognized_license_plate_clause))
if zones != "all": if zones != "all":
# use matching so events with multiple zones # use matching so events with multiple zones
# still match on a search where any zone matches # still match on a search where any zone matches
@ -340,6 +380,8 @@ def events_explore(limit: int = 10):
"average_estimated_speed", "average_estimated_speed",
"velocity_angle", "velocity_angle",
"path_data", "path_data",
"recognized_license_plate",
"recognized_license_plate_score",
] ]
}, },
"event_count": label_counts[event.label], "event_count": label_counts[event.label],
@ -397,6 +439,7 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
has_clip = params.has_clip has_clip = params.has_clip
has_snapshot = params.has_snapshot has_snapshot = params.has_snapshot
is_submitted = params.is_submitted is_submitted = params.is_submitted
recognized_license_plate = params.recognized_license_plate
# for similarity search # for similarity search
event_id = params.event_id event_id = params.event_id
@ -466,6 +509,45 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
event_filters.append((reduce(operator.or_, zone_clauses))) event_filters.append((reduce(operator.or_, zone_clauses)))
if recognized_license_plate != "all":
# use matching so joined recognized_license_plates are included
# for example an recognized_license_plate 'ABC123' would get events
# with recognized_license_plates 'ABC123' and 'ABC123, XYZ789'
recognized_license_plate_clauses = []
filtered_recognized_license_plates = recognized_license_plate.split(",")
if "None" in filtered_recognized_license_plates:
filtered_recognized_license_plates.remove("None")
recognized_license_plate_clauses.append(
(Event.data["recognized_license_plate"].is_null())
)
for recognized_license_plate in filtered_recognized_license_plates:
# Exact matching plus list inclusion
recognized_license_plate_clauses.append(
(
Event.data["recognized_license_plate"].cast("text")
== recognized_license_plate
)
)
recognized_license_plate_clauses.append(
(
Event.data["recognized_license_plate"].cast("text")
% f"*{recognized_license_plate},*"
)
)
recognized_license_plate_clauses.append(
(
Event.data["recognized_license_plate"].cast("text")
% f"*, {recognized_license_plate}*"
)
)
recognized_license_plate_clause = reduce(
operator.or_, recognized_license_plate_clauses
)
event_filters.append((recognized_license_plate_clause))
if after: if after:
event_filters.append((Event.start_time > after)) event_filters.append((Event.start_time > after))
@ -627,6 +709,8 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
"average_estimated_speed", "average_estimated_speed",
"velocity_angle", "velocity_angle",
"path_data", "path_data",
"recognized_license_plate",
"recognized_license_plate_score",
] ]
} }
@ -681,6 +765,7 @@ def events_summary(params: EventsSummaryQueryParams = Depends()):
Event.camera, Event.camera,
Event.label, Event.label,
Event.sub_label, Event.sub_label,
Event.data,
fn.strftime( fn.strftime(
"%Y-%m-%d", "%Y-%m-%d",
fn.datetime( fn.datetime(
@ -695,6 +780,7 @@ def events_summary(params: EventsSummaryQueryParams = Depends()):
Event.camera, Event.camera,
Event.label, Event.label,
Event.sub_label, Event.sub_label,
Event.data,
(Event.start_time + seconds_offset).cast("int") / (3600 * 24), (Event.start_time + seconds_offset).cast("int") / (3600 * 24),
Event.zones, Event.zones,
) )

View File

@ -137,12 +137,16 @@ class CameraState:
# draw the bounding boxes on the frame # draw the bounding boxes on the frame
box = obj["box"] box = obj["box"]
text = ( text = (
obj["label"] obj["sub_label"][0]
if ( if (
not obj.get("sub_label") obj.get("sub_label") and is_label_printable(obj["sub_label"][0])
or not is_label_printable(obj["sub_label"][0])
) )
else obj["sub_label"][0] else obj.get("recognized_license_plate", [None])[0]
if (
obj.get("recognized_license_plate")
and obj["recognized_license_plate"][0]
)
else obj["label"]
) )
draw_box_with_label( draw_box_with_label(
frame_copy, frame_copy,

View File

@ -14,6 +14,7 @@ class EventMetadataTypeEnum(str, Enum):
manual_event_end = "manual_event_end" manual_event_end = "manual_event_end"
regenerate_description = "regenerate_description" regenerate_description = "regenerate_description"
sub_label = "sub_label" sub_label = "sub_label"
recognized_license_plate = "recognized_license_plate"
class EventMetadataPublisher(Publisher): class EventMetadataPublisher(Publisher):

View File

@ -21,7 +21,7 @@ __all__ = [
FFMPEG_GLOBAL_ARGS_DEFAULT = ["-hide_banner", "-loglevel", "warning", "-threads", "2"] FFMPEG_GLOBAL_ARGS_DEFAULT = ["-hide_banner", "-loglevel", "warning", "-threads", "2"]
FFMPEG_INPUT_ARGS_DEFAULT = "preset-rtsp-generic" FFMPEG_INPUT_ARGS_DEFAULT = "preset-rtsp-generic"
RECORD_FFMPEG_OUTPUT_ARGS_DEFAULT = "preset-record-generic" RECORD_FFMPEG_OUTPUT_ARGS_DEFAULT = "preset-record-generic-audio-aac"
DETECT_FFMPEG_OUTPUT_ARGS_DEFAULT = [ DETECT_FFMPEG_OUTPUT_ARGS_DEFAULT = [
"-threads", "-threads",
"2", "2",

View File

@ -1054,13 +1054,20 @@ class LicensePlateProcessingMixin:
for plate in plates for plate in plates
) )
), ),
top_plate, None,
) )
# Send the result to the API # If it's a known plate, publish to sub_label
if sub_label is not None:
self.sub_label_publisher.publish(
EventMetadataTypeEnum.sub_label, (id, sub_label, avg_confidence)
)
self.sub_label_publisher.publish( self.sub_label_publisher.publish(
EventMetadataTypeEnum.sub_label, (id, sub_label, avg_confidence) EventMetadataTypeEnum.recognized_license_plate,
(id, top_plate, avg_confidence),
) )
self.detected_license_plates[id] = { self.detected_license_plates[id] = {
"plate": top_plate, "plate": top_plate,
"char_confidences": top_char_confidences, "char_confidences": top_char_confidences,

View File

@ -27,6 +27,8 @@ def should_update_db(prev_event: Event, current_event: Event) -> bool:
or prev_event["average_estimated_speed"] or prev_event["average_estimated_speed"]
!= current_event["average_estimated_speed"] != current_event["average_estimated_speed"]
or prev_event["velocity_angle"] != current_event["velocity_angle"] or prev_event["velocity_angle"] != current_event["velocity_angle"]
or prev_event["recognized_license_plate"]
!= current_event["recognized_license_plate"]
or prev_event["path_data"] != current_event["path_data"] or prev_event["path_data"] != current_event["path_data"]
): ):
return True return True
@ -226,6 +228,15 @@ class EventProcessor(threading.Thread):
event[Event.sub_label] = event_data["sub_label"][0] event[Event.sub_label] = event_data["sub_label"][0]
event[Event.data]["sub_label_score"] = event_data["sub_label"][1] event[Event.data]["sub_label_score"] = event_data["sub_label"][1]
# only overwrite the recognized_license_plate in the database if it's set
if event_data.get("recognized_license_plate") is not None:
event[Event.data]["recognized_license_plate"] = event_data[
"recognized_license_plate"
][0]
event[Event.data]["recognized_license_plate_score"] = event_data[
"recognized_license_plate"
][1]
( (
Event.insert(event) Event.insert(event)
.on_conflict( .on_conflict(

View File

@ -346,6 +346,44 @@ class TrackedObjectProcessor(threading.Thread):
return True return True
def set_recognized_license_plate(
self, event_id: str, recognized_license_plate: str | None, score: float | None
) -> None:
"""Update recognized license plate for given event id."""
tracked_obj: TrackedObject = None
for state in self.camera_states.values():
tracked_obj = state.tracked_objects.get(event_id)
if tracked_obj is not None:
break
try:
event: Event = Event.get(Event.id == event_id)
except DoesNotExist:
event = None
if not tracked_obj and not event:
return
if tracked_obj:
tracked_obj.obj_data["recognized_license_plate"] = (
recognized_license_plate,
score,
)
if event:
data = event.data
data["recognized_license_plate"] = recognized_license_plate
if recognized_license_plate is None:
data["recognized_license_plate_score"] = None
elif score is not None:
data["recognized_license_plate_score"] = score
event.data = data
event.save()
return True
def create_manual_event(self, payload: tuple) -> None: def create_manual_event(self, payload: tuple) -> None:
( (
frame_time, frame_time,
@ -507,6 +545,11 @@ class TrackedObjectProcessor(threading.Thread):
if topic.endswith(EventMetadataTypeEnum.sub_label.value): if topic.endswith(EventMetadataTypeEnum.sub_label.value):
(event_id, sub_label, score) = payload (event_id, sub_label, score) = payload
self.set_sub_label(event_id, sub_label, score) self.set_sub_label(event_id, sub_label, score)
if topic.endswith(EventMetadataTypeEnum.recognized_license_plate.value):
(event_id, recognized_license_plate, score) = payload
self.set_recognized_license_plate(
event_id, recognized_license_plate, score
)
elif topic.endswith(EventMetadataTypeEnum.manual_event_create.value): elif topic.endswith(EventMetadataTypeEnum.manual_event_create.value):
self.create_manual_event(payload) self.create_manual_event(payload)
elif topic.endswith(EventMetadataTypeEnum.manual_event_end.value): elif topic.endswith(EventMetadataTypeEnum.manual_event_end.value):

View File

@ -153,6 +153,12 @@ class TrackedObject:
"current_estimated_speed": self.current_estimated_speed, "current_estimated_speed": self.current_estimated_speed,
"velocity_angle": self.velocity_angle, "velocity_angle": self.velocity_angle,
"path_data": self.path_data, "path_data": self.path_data,
"recognized_license_plate": obj_data.get(
"recognized_license_plate"
),
"recognized_license_plate_score": obj_data.get(
"recognized_license_plate_score"
),
} }
thumb_update = True thumb_update = True
@ -365,6 +371,7 @@ class TrackedObject:
"average_estimated_speed": self.average_estimated_speed, "average_estimated_speed": self.average_estimated_speed,
"velocity_angle": self.velocity_angle, "velocity_angle": self.velocity_angle,
"path_data": self.path_data, "path_data": self.path_data,
"recognized_license_plate": self.obj_data.get("recognized_license_plate"),
} }
return event return event

View File

@ -337,6 +337,23 @@ function ObjectDetailsTab({
} }
}, [search]); }, [search]);
const recognizedLicensePlateScore = useMemo(() => {
if (!search) {
return undefined;
}
if (
search.data.recognized_license_plate &&
search.data?.recognized_license_plate_score
) {
return Math.round(
(search.data?.recognized_license_plate_score ?? 0) * 100,
);
} else {
return undefined;
}
}, [search]);
const averageEstimatedSpeed = useMemo(() => { const averageEstimatedSpeed = useMemo(() => {
if (!search || !search.data?.average_estimated_speed) { if (!search || !search.data?.average_estimated_speed) {
return undefined; return undefined;
@ -547,6 +564,20 @@ function ObjectDetailsTab({
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
{search?.data.recognized_license_plate && (
<div className="flex flex-col gap-1.5">
<div className="text-sm text-primary/40">
Recognized License Plate
</div>
<div className="flex flex-col space-y-0.5 text-sm">
<div className="flex flex-row items-center gap-2">
{search.data.recognized_license_plate}{" "}
{recognizedLicensePlateScore &&
` (${recognizedLicensePlateScore}%)`}
</div>
</div>
</div>
)}
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<div className="text-sm text-primary/40"> <div className="text-sm text-primary/40">
<div className="flex flex-row items-center gap-1"> <div className="flex flex-row items-center gap-1">

View File

@ -34,6 +34,14 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { LuCheck } from "react-icons/lu";
type SearchFilterDialogProps = { type SearchFilterDialogProps = {
config?: FrigateConfig; config?: FrigateConfig;
@ -78,7 +86,8 @@ export default function SearchFilterDialog({
(currentFilter.max_score ?? 1) < 1 || (currentFilter.max_score ?? 1) < 1 ||
(currentFilter.max_speed ?? 150) < 150 || (currentFilter.max_speed ?? 150) < 150 ||
(currentFilter.zones?.length ?? 0) > 0 || (currentFilter.zones?.length ?? 0) > 0 ||
(currentFilter.sub_labels?.length ?? 0) > 0), (currentFilter.sub_labels?.length ?? 0) > 0 ||
(currentFilter.recognized_license_plate?.length ?? 0) > 0),
[currentFilter], [currentFilter],
); );
@ -120,6 +129,15 @@ export default function SearchFilterDialog({
setCurrentFilter({ ...currentFilter, sub_labels: newSubLabels }) setCurrentFilter({ ...currentFilter, sub_labels: newSubLabels })
} }
/> />
<RecognizedLicensePlatesFilterContent
recognizedLicensePlates={currentFilter.recognized_license_plate}
setRecognizedLicensePlates={(plate) =>
setCurrentFilter({
...currentFilter,
recognized_license_plate: plate,
})
}
/>
<ScoreFilterContent <ScoreFilterContent
minScore={currentFilter.min_score} minScore={currentFilter.min_score}
maxScore={currentFilter.max_score} maxScore={currentFilter.max_score}
@ -193,6 +211,7 @@ export default function SearchFilterDialog({
max_speed: undefined, max_speed: undefined,
has_snapshot: undefined, has_snapshot: undefined,
has_clip: undefined, has_clip: undefined,
recognized_license_plate: undefined,
})); }));
}} }}
> >
@ -843,3 +862,130 @@ export function SnapshotClipFilterContent({
</div> </div>
); );
} }
type RecognizedLicensePlatesFilterContentProps = {
recognizedLicensePlates: string[] | undefined;
setRecognizedLicensePlates: (
recognizedLicensePlates: string[] | undefined,
) => void;
};
export function RecognizedLicensePlatesFilterContent({
recognizedLicensePlates,
setRecognizedLicensePlates,
}: RecognizedLicensePlatesFilterContentProps) {
const { data: allRecognizedLicensePlates, error } = useSWR<string[]>(
"recognized_license_plates",
{
revalidateOnFocus: false,
},
);
const [selectedRecognizedLicensePlates, setSelectedRecognizedLicensePlates] =
useState<string[]>(recognizedLicensePlates || []);
const [inputValue, setInputValue] = useState("");
useEffect(() => {
if (recognizedLicensePlates) {
setSelectedRecognizedLicensePlates(recognizedLicensePlates);
} else {
setSelectedRecognizedLicensePlates([]);
}
}, [recognizedLicensePlates]);
const handleSelect = (recognizedLicensePlate: string) => {
const newSelected = selectedRecognizedLicensePlates.includes(
recognizedLicensePlate,
)
? selectedRecognizedLicensePlates.filter(
(id) => id !== recognizedLicensePlate,
) // Deselect
: [...selectedRecognizedLicensePlates, recognizedLicensePlate]; // Select
setSelectedRecognizedLicensePlates(newSelected);
if (newSelected.length === 0) {
setRecognizedLicensePlates(undefined); // Clear filter if no plates selected
} else {
setRecognizedLicensePlates(newSelected);
}
};
if (!allRecognizedLicensePlates || allRecognizedLicensePlates.length === 0) {
return null;
}
const filteredRecognizedLicensePlates =
allRecognizedLicensePlates?.filter((id) =>
id.toLowerCase().includes(inputValue.toLowerCase()),
) || [];
return (
<div className="overflow-x-hidden">
<DropdownMenuSeparator className="mb-3" />
<div className="mb-3 text-lg">Recognized License Plates</div>
{error ? (
<p className="text-sm text-red-500">
Failed to load recognized license plates.
</p>
) : !allRecognizedLicensePlates ? (
<p className="text-sm text-muted-foreground">
Loading recognized license plates...
</p>
) : (
<>
<Command className="border border-input bg-background">
<CommandInput
placeholder="Type to search license plates..."
value={inputValue}
onValueChange={setInputValue}
/>
<CommandList className="max-h-[200px] overflow-auto">
{filteredRecognizedLicensePlates.length === 0 && inputValue && (
<CommandEmpty>No license plates found.</CommandEmpty>
)}
{filteredRecognizedLicensePlates.map((plate) => (
<CommandItem
key={plate}
value={plate}
onSelect={() => handleSelect(plate)}
className="cursor-pointer"
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
selectedRecognizedLicensePlates.includes(plate)
? "opacity-100"
: "opacity-0",
)}
/>
{plate}
</CommandItem>
))}
</CommandList>
</Command>
{selectedRecognizedLicensePlates.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2">
{selectedRecognizedLicensePlates.map((id) => (
<span
key={id}
className="inline-flex items-center rounded bg-selected px-2 py-1 text-sm text-white"
>
{id}
<button
onClick={() => handleSelect(id)}
className="ml-1 text-white hover:text-gray-200"
>
×
</button>
</span>
))}
</div>
)}
</>
)}
<p className="mt-1 text-sm text-muted-foreground">
Select one or more plates from the list.
</p>
</div>
);
}

View File

@ -108,6 +108,8 @@ export default function Explore() {
cameras: searchSearchParams["cameras"], cameras: searchSearchParams["cameras"],
labels: searchSearchParams["labels"], labels: searchSearchParams["labels"],
sub_labels: searchSearchParams["sub_labels"], sub_labels: searchSearchParams["sub_labels"],
recognized_license_plate:
searchSearchParams["recognized_license_plate"],
zones: searchSearchParams["zones"], zones: searchSearchParams["zones"],
before: searchSearchParams["before"], before: searchSearchParams["before"],
after: searchSearchParams["after"], after: searchSearchParams["after"],
@ -143,6 +145,8 @@ export default function Explore() {
cameras: searchSearchParams["cameras"], cameras: searchSearchParams["cameras"],
labels: searchSearchParams["labels"], labels: searchSearchParams["labels"],
sub_labels: searchSearchParams["sub_labels"], sub_labels: searchSearchParams["sub_labels"],
recognized_license_plate:
searchSearchParams["recognized_license_plate"],
zones: searchSearchParams["zones"], zones: searchSearchParams["zones"],
before: searchSearchParams["before"], before: searchSearchParams["before"],
after: searchSearchParams["after"], after: searchSearchParams["after"],

View File

@ -58,6 +58,8 @@ export type SearchResult = {
average_estimated_speed: number; average_estimated_speed: number;
velocity_angle: number; velocity_angle: number;
path_data: [number[], number][]; path_data: [number[], number][];
recognized_license_plate?: string;
recognized_license_plate_score?: number;
}; };
}; };
@ -66,6 +68,7 @@ export type SearchFilter = {
cameras?: string[]; cameras?: string[];
labels?: string[]; labels?: string[];
sub_labels?: string[]; sub_labels?: string[];
recognized_license_plate?: string[];
zones?: string[]; zones?: string[];
before?: number; before?: number;
after?: number; after?: number;
@ -89,6 +92,7 @@ export type SearchQueryParams = {
cameras?: string[]; cameras?: string[];
labels?: string[]; labels?: string[];
sub_labels?: string[]; sub_labels?: string[];
recognized_license_plate?: string[];
zones?: string[]; zones?: string[];
before?: string; before?: string;
after?: string; after?: string;

View File

@ -123,6 +123,9 @@ export default function SearchView({
}, [config, searchFilter]); }, [config, searchFilter]);
const { data: allSubLabels } = useSWR("sub_labels"); const { data: allSubLabels } = useSWR("sub_labels");
const { data: allRecognizedLicensePlates } = useSWR(
"recognized_license_plates",
);
const allZones = useMemo<string[]>(() => { const allZones = useMemo<string[]>(() => {
if (!config) { if (!config) {
@ -162,12 +165,20 @@ export default function SearchView({
max_score: ["100"], max_score: ["100"],
min_speed: ["1"], min_speed: ["1"],
max_speed: ["150"], max_speed: ["150"],
recognized_license_plate: allRecognizedLicensePlates,
has_clip: ["yes", "no"], has_clip: ["yes", "no"],
has_snapshot: ["yes", "no"], has_snapshot: ["yes", "no"],
...(config?.plus?.enabled && ...(config?.plus?.enabled &&
searchFilter?.has_snapshot && { is_submitted: ["yes", "no"] }), searchFilter?.has_snapshot && { is_submitted: ["yes", "no"] }),
}), }),
[config, allLabels, allZones, allSubLabels, searchFilter], [
config,
allLabels,
allZones,
allSubLabels,
allRecognizedLicensePlates,
searchFilter,
],
); );
// remove duplicate event ids // remove duplicate event ids