Compare commits

..

12 Commits

Author SHA1 Message Date
Gavin Mogan
ad82e0d730
Merge 89b794705afc5f76f3da096cc957ddf2cb73d729 into b75122847668d6c500c95178e5fc767328c2945e 2025-11-05 09:42:29 +01:00
Nicolas Mowen
b751228476
Various Tweaks (#20800)
* Fix incorrectly picking start time when date was selected

* Implement shared file locking utility

* Cleanup
2025-11-04 17:06:14 -06:00
Nicolas Mowen
3b2d136665
UI Tweaks (#20791)
* Add tooltip for classification group

* Don't portal upload dialog when not in fullscreen
2025-11-04 10:54:05 -06:00
Josh Hawkins
e7394d0dc1
Form validation tweaks (#20790)
* ensure id field is expanded on form errors

* only validate id field when name field has no errors

* use ref instead

* all numeric is an invalid name
2025-11-04 08:57:47 -06:00
Josh Hawkins
2e288109f4
Review tweaks (#20789)
* use alerts/detections colors for dots and add back blue border

* add alerts/detections colored dot next to event icons

* add margin for border
2025-11-04 08:45:45 -06:00
Josh Hawkins
256817d5c2
Make events summary endpoint DST-aware (#20786) 2025-11-03 17:54:33 -07:00
Nicolas Mowen
84409eab7e
Various fixes (#20785)
* Catch case where detector overflows

* Add more debug logs

* Cleanup

* Adjust no class wording

* Adjustments
2025-11-03 18:42:59 -06:00
Josh Hawkins
9e83888133
Fix recordings summary for DST (#20784)
* make recordings summary endpoints DST aware

* remove unused

* clean up
2025-11-03 17:30:56 -07:00
Abinila Siva
85f7138361
update installation code to hold SDK 2.1 version (#20781) 2025-11-03 13:23:51 -07:00
Nicolas Mowen
fc1cad2872
Adjust LPR packages for licensing (#20780) 2025-11-03 14:11:02 -06:00
Nicolas Mowen
5529432856
Various fixes (#20774)
* Change order of deletion

* Add debug log for camera enabled

* Add more face debug logs

* Set jetson numpy version
2025-11-03 10:05:03 -06:00
Josh Hawkins
59963fc47e
Camera Wizard tweaks (#20773)
* add switch to use go2rtc ffmpeg mode

* i18n

* move testing state outside of button
2025-11-03 08:42:38 -07:00
40 changed files with 777 additions and 440 deletions

View File

@ -2,9 +2,9 @@
set -e set -e
# Download the MxAccl for Frigate github release # Download the MxAccl for Frigate github release
wget https://github.com/memryx/mx_accl_frigate/archive/refs/heads/main.zip -O /tmp/mxaccl.zip wget https://github.com/memryx/mx_accl_frigate/archive/refs/tags/v2.1.0.zip -O /tmp/mxaccl.zip
unzip /tmp/mxaccl.zip -d /tmp unzip /tmp/mxaccl.zip -d /tmp
mv /tmp/mx_accl_frigate-main /opt/mx_accl_frigate mv /tmp/mx_accl_frigate-2.1.0 /opt/mx_accl_frigate
rm /tmp/mxaccl.zip rm /tmp/mxaccl.zip
# Install Python dependencies # Install Python dependencies

View File

@ -56,7 +56,7 @@ pywebpush == 2.0.*
# alpr # alpr
pyclipper == 1.3.* pyclipper == 1.3.*
shapely == 2.0.* shapely == 2.0.*
Levenshtein==0.26.* rapidfuzz==3.12.*
# HailoRT Wheels # HailoRT Wheels
appdirs==1.4.* appdirs==1.4.*
argcomplete==2.0.* argcomplete==2.0.*

View File

@ -24,10 +24,13 @@ echo "Adding MemryX GPG key and repository..."
wget -qO- https://developer.memryx.com/deb/memryx.asc | sudo tee /etc/apt/trusted.gpg.d/memryx.asc >/dev/null wget -qO- https://developer.memryx.com/deb/memryx.asc | sudo tee /etc/apt/trusted.gpg.d/memryx.asc >/dev/null
echo 'deb https://developer.memryx.com/deb stable main' | sudo tee /etc/apt/sources.list.d/memryx.list >/dev/null echo 'deb https://developer.memryx.com/deb stable main' | sudo tee /etc/apt/sources.list.d/memryx.list >/dev/null
# Update and install memx-drivers # Update and install specific SDK 2.1 packages
echo "Installing memx-drivers..." echo "Installing MemryX SDK 2.1 packages..."
sudo apt update sudo apt update
sudo apt install -y memx-drivers sudo apt install -y memx-drivers=2.1.* memx-accl=2.1.* mxa-manager=2.1.*
# Hold packages to prevent automatic upgrades
sudo apt-mark hold memx-drivers memx-accl mxa-manager
# ARM-specific board setup # ARM-specific board setup
if [[ "$arch" == "aarch64" || "$arch" == "arm64" ]]; then if [[ "$arch" == "aarch64" || "$arch" == "arm64" ]]; then
@ -37,11 +40,5 @@ fi
echo -e "\n\n\033[1;31mYOU MUST RESTART YOUR COMPUTER NOW\033[0m\n\n" echo -e "\n\n\033[1;31mYOU MUST RESTART YOUR COMPUTER NOW\033[0m\n\n"
# Install other runtime packages echo "MemryX SDK 2.1 installation complete!"
packages=("memx-accl" "mxa-manager")
for pkg in "${packages[@]}"; do
echo "Installing $pkg..."
sudo apt install -y "$pkg"
done
echo "MemryX installation complete!"

View File

@ -1 +1,2 @@
cuda-python == 12.6.*; platform_machine == 'aarch64' cuda-python == 12.6.*; platform_machine == 'aarch64'
numpy == 1.26.*; platform_machine == 'aarch64'

View File

@ -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__)

View File

@ -2,6 +2,7 @@
import base64 import base64
import datetime import datetime
import json
import logging import logging
import os import os
import random import random
@ -57,8 +58,8 @@ 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_tz_modifiers from frigate.util.time import get_dst_transitions, get_tz_modifiers
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -813,7 +814,6 @@ def events_summary(
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
): ):
tz_name = params.timezone tz_name = params.timezone
hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(tz_name)
has_clip = params.has_clip has_clip = params.has_clip
has_snapshot = params.has_snapshot has_snapshot = params.has_snapshot
@ -828,33 +828,91 @@ def events_summary(
if len(clauses) == 0: if len(clauses) == 0:
clauses.append((True)) clauses.append((True))
groups = ( time_range_query = (
Event.select( Event.select(
Event.camera, fn.MIN(Event.start_time).alias("min_time"),
Event.label, fn.MAX(Event.start_time).alias("max_time"),
Event.sub_label,
Event.data,
fn.strftime(
"%Y-%m-%d",
fn.datetime(
Event.start_time, "unixepoch", hour_modifier, minute_modifier
),
).alias("day"),
Event.zones,
fn.COUNT(Event.id).alias("count"),
) )
.where(reduce(operator.and_, clauses) & (Event.camera << allowed_cameras)) .where(reduce(operator.and_, clauses) & (Event.camera << allowed_cameras))
.group_by( .dicts()
Event.camera, .get()
Event.label,
Event.sub_label,
Event.data,
(Event.start_time + seconds_offset).cast("int") / (3600 * 24),
Event.zones,
)
) )
return JSONResponse(content=[e for e in groups.dicts()]) min_time = time_range_query.get("min_time")
max_time = time_range_query.get("max_time")
if min_time is None or max_time is None:
return JSONResponse(content=[])
dst_periods = get_dst_transitions(tz_name, min_time, max_time)
grouped: dict[tuple, dict] = {}
for period_start, period_end, period_offset in dst_periods:
hours_offset = int(period_offset / 60 / 60)
minutes_offset = int(period_offset / 60 - hours_offset * 60)
period_hour_modifier = f"{hours_offset} hour"
period_minute_modifier = f"{minutes_offset} minute"
period_groups = (
Event.select(
Event.camera,
Event.label,
Event.sub_label,
Event.data,
fn.strftime(
"%Y-%m-%d",
fn.datetime(
Event.start_time,
"unixepoch",
period_hour_modifier,
period_minute_modifier,
),
).alias("day"),
Event.zones,
fn.COUNT(Event.id).alias("count"),
)
.where(
reduce(operator.and_, clauses)
& (Event.camera << allowed_cameras)
& (Event.start_time >= period_start)
& (Event.start_time <= period_end)
)
.group_by(
Event.camera,
Event.label,
Event.sub_label,
Event.data,
(Event.start_time + period_offset).cast("int") / (3600 * 24),
Event.zones,
)
.namedtuples()
)
for g in period_groups:
key = (
g.camera,
g.label,
g.sub_label,
json.dumps(g.data, sort_keys=True) if g.data is not None else None,
g.day,
json.dumps(g.zones, sort_keys=True) if g.zones is not None else None,
)
if key in grouped:
grouped[key]["count"] += int(g.count or 0)
else:
grouped[key] = {
"camera": g.camera,
"label": g.label,
"sub_label": g.sub_label,
"data": g.data,
"day": g.day,
"zones": g.zones,
"count": int(g.count or 0),
}
return JSONResponse(content=list(grouped.values()))
@router.get( @router.get(

View File

@ -44,9 +44,9 @@ 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_tz_modifiers
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -424,7 +424,6 @@ def all_recordings_summary(
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
): ):
"""Returns true/false by day indicating if recordings exist""" """Returns true/false by day indicating if recordings exist"""
hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(params.timezone)
cameras = params.cameras cameras = params.cameras
if cameras != "all": if cameras != "all":
@ -432,41 +431,70 @@ def all_recordings_summary(
filtered = requested.intersection(allowed_cameras) filtered = requested.intersection(allowed_cameras)
if not filtered: if not filtered:
return JSONResponse(content={}) return JSONResponse(content={})
cameras = ",".join(filtered) camera_list = list(filtered)
else: else:
cameras = allowed_cameras camera_list = allowed_cameras
query = ( time_range_query = (
Recordings.select( Recordings.select(
fn.strftime( fn.MIN(Recordings.start_time).alias("min_time"),
"%Y-%m-%d", fn.MAX(Recordings.start_time).alias("max_time"),
fn.datetime(
Recordings.start_time + seconds_offset,
"unixepoch",
hour_modifier,
minute_modifier,
),
).alias("day")
) )
.group_by( .where(Recordings.camera << camera_list)
fn.strftime( .dicts()
"%Y-%m-%d", .get()
fn.datetime(
Recordings.start_time + seconds_offset,
"unixepoch",
hour_modifier,
minute_modifier,
),
)
)
.order_by(Recordings.start_time.desc())
) )
if params.cameras != "all": min_time = time_range_query.get("min_time")
query = query.where(Recordings.camera << cameras.split(",")) max_time = time_range_query.get("max_time")
recording_days = query.namedtuples() if min_time is None or max_time is None:
days = {day.day: True for day in recording_days} return JSONResponse(content={})
dst_periods = get_dst_transitions(params.timezone, min_time, max_time)
days: dict[str, bool] = {}
for period_start, period_end, period_offset in dst_periods:
hours_offset = int(period_offset / 60 / 60)
minutes_offset = int(period_offset / 60 - hours_offset * 60)
period_hour_modifier = f"{hours_offset} hour"
period_minute_modifier = f"{minutes_offset} minute"
period_query = (
Recordings.select(
fn.strftime(
"%Y-%m-%d",
fn.datetime(
Recordings.start_time,
"unixepoch",
period_hour_modifier,
period_minute_modifier,
),
).alias("day")
)
.where(
(Recordings.camera << camera_list)
& (Recordings.end_time >= period_start)
& (Recordings.start_time <= period_end)
)
.group_by(
fn.strftime(
"%Y-%m-%d",
fn.datetime(
Recordings.start_time,
"unixepoch",
period_hour_modifier,
period_minute_modifier,
),
)
)
.order_by(Recordings.start_time.desc())
.namedtuples()
)
for g in period_query:
days[g.day] = True
return JSONResponse(content=days) return JSONResponse(content=days)
@ -476,61 +504,103 @@ def all_recordings_summary(
) )
async def recordings_summary(camera_name: str, timezone: str = "utc"): async def recordings_summary(camera_name: str, timezone: str = "utc"):
"""Returns hourly summary for recordings of given camera""" """Returns hourly summary for recordings of given camera"""
hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(timezone)
recording_groups = ( time_range_query = (
Recordings.select( Recordings.select(
fn.strftime( fn.MIN(Recordings.start_time).alias("min_time"),
"%Y-%m-%d %H", fn.MAX(Recordings.start_time).alias("max_time"),
fn.datetime(
Recordings.start_time, "unixepoch", hour_modifier, minute_modifier
),
).alias("hour"),
fn.SUM(Recordings.duration).alias("duration"),
fn.SUM(Recordings.motion).alias("motion"),
fn.SUM(Recordings.objects).alias("objects"),
) )
.where(Recordings.camera == camera_name) .where(Recordings.camera == camera_name)
.group_by((Recordings.start_time + seconds_offset).cast("int") / 3600) .dicts()
.order_by(Recordings.start_time.desc()) .get()
.namedtuples()
) )
event_groups = ( min_time = time_range_query.get("min_time")
Event.select( max_time = time_range_query.get("max_time")
fn.strftime(
"%Y-%m-%d %H", days: dict[str, dict] = {}
fn.datetime(
Event.start_time, "unixepoch", hour_modifier, minute_modifier if min_time is None or max_time is None:
), return JSONResponse(content=list(days.values()))
).alias("hour"),
fn.COUNT(Event.id).alias("count"), dst_periods = get_dst_transitions(timezone, min_time, max_time)
for period_start, period_end, period_offset in dst_periods:
hours_offset = int(period_offset / 60 / 60)
minutes_offset = int(period_offset / 60 - hours_offset * 60)
period_hour_modifier = f"{hours_offset} hour"
period_minute_modifier = f"{minutes_offset} minute"
recording_groups = (
Recordings.select(
fn.strftime(
"%Y-%m-%d %H",
fn.datetime(
Recordings.start_time,
"unixepoch",
period_hour_modifier,
period_minute_modifier,
),
).alias("hour"),
fn.SUM(Recordings.duration).alias("duration"),
fn.SUM(Recordings.motion).alias("motion"),
fn.SUM(Recordings.objects).alias("objects"),
)
.where(
(Recordings.camera == camera_name)
& (Recordings.end_time >= period_start)
& (Recordings.start_time <= period_end)
)
.group_by((Recordings.start_time + period_offset).cast("int") / 3600)
.order_by(Recordings.start_time.desc())
.namedtuples()
) )
.where(Event.camera == camera_name, Event.has_clip)
.group_by((Event.start_time + seconds_offset).cast("int") / 3600)
.namedtuples()
)
event_map = {g.hour: g.count for g in event_groups} event_groups = (
Event.select(
fn.strftime(
"%Y-%m-%d %H",
fn.datetime(
Event.start_time,
"unixepoch",
period_hour_modifier,
period_minute_modifier,
),
).alias("hour"),
fn.COUNT(Event.id).alias("count"),
)
.where(Event.camera == camera_name, Event.has_clip)
.where(
(Event.start_time >= period_start) & (Event.start_time <= period_end)
)
.group_by((Event.start_time + period_offset).cast("int") / 3600)
.namedtuples()
)
days = {} event_map = {g.hour: g.count for g in event_groups}
for recording_group in recording_groups: for recording_group in recording_groups:
parts = recording_group.hour.split() parts = recording_group.hour.split()
hour = parts[1] hour = parts[1]
day = parts[0] day = parts[0]
events_count = event_map.get(recording_group.hour, 0) events_count = event_map.get(recording_group.hour, 0)
hour_data = { hour_data = {
"hour": hour, "hour": hour,
"events": events_count, "events": events_count,
"motion": recording_group.motion, "motion": recording_group.motion,
"objects": recording_group.objects, "objects": recording_group.objects,
"duration": round(recording_group.duration), "duration": round(recording_group.duration),
} }
if day not in days: if day in days:
days[day] = {"events": events_count, "hours": [hour_data], "day": day} # merge counts if already present (edge-case at DST boundary)
else: days[day]["events"] += events_count or 0
days[day]["events"] += events_count days[day]["hours"].append(hour_data)
days[day]["hours"].append(hour_data) else:
days[day] = {
"events": events_count or 0,
"hours": [hour_data],
"day": day,
}
return JSONResponse(content=list(days.values())) return JSONResponse(content=list(days.values()))

View File

@ -36,7 +36,7 @@ from frigate.config import FrigateConfig
from frigate.embeddings import EmbeddingsContext from frigate.embeddings import EmbeddingsContext
from frigate.models import Recordings, ReviewSegment, UserReviewStatus from frigate.models import Recordings, ReviewSegment, UserReviewStatus
from frigate.review.types import SeverityEnum from frigate.review.types import SeverityEnum
from frigate.util.time import get_dst_transitions, get_tz_modifiers from frigate.util.time import get_dst_transitions
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -197,7 +197,6 @@ async def review_summary(
user_id = current_user["username"] user_id = current_user["username"]
hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(params.timezone)
day_ago = (datetime.datetime.now() - datetime.timedelta(hours=24)).timestamp() day_ago = (datetime.datetime.now() - datetime.timedelta(hours=24)).timestamp()
cameras = params.cameras cameras = params.cameras

View File

@ -14,8 +14,8 @@ from typing import Any, List, Optional, Tuple
import cv2 import cv2
import numpy as np import numpy as np
from Levenshtein import distance, jaro_winkler
from pyclipper import ET_CLOSEDPOLYGON, JT_ROUND, PyclipperOffset from pyclipper import ET_CLOSEDPOLYGON, JT_ROUND, PyclipperOffset
from rapidfuzz.distance import JaroWinkler, Levenshtein
from shapely.geometry import Polygon from shapely.geometry import Polygon
from frigate.comms.event_metadata_updater import ( from frigate.comms.event_metadata_updater import (
@ -1123,7 +1123,9 @@ class LicensePlateProcessingMixin:
for i, plate in enumerate(plates): for i, plate in enumerate(plates):
merged = False merged = False
for j, cluster in enumerate(clusters): for j, cluster in enumerate(clusters):
sims = [jaro_winkler(plate["plate"], v["plate"]) for v in cluster] sims = [
JaroWinkler.similarity(plate["plate"], v["plate"]) for v in cluster
]
if len(sims) > 0: if len(sims) > 0:
avg_sim = sum(sims) / len(sims) avg_sim = sum(sims) / len(sims)
if avg_sim >= self.cluster_threshold: if avg_sim >= self.cluster_threshold:
@ -1500,7 +1502,7 @@ class LicensePlateProcessingMixin:
and current_time - data["last_seen"] and current_time - data["last_seen"]
<= self.config.cameras[camera].lpr.expire_time <= self.config.cameras[camera].lpr.expire_time
): ):
similarity = jaro_winkler(data["plate"], top_plate) similarity = JaroWinkler.similarity(data["plate"], top_plate)
if similarity >= self.similarity_threshold: if similarity >= self.similarity_threshold:
plate_id = existing_id plate_id = existing_id
logger.debug( logger.debug(
@ -1580,7 +1582,8 @@ class LicensePlateProcessingMixin:
for label, plates_list in self.lpr_config.known_plates.items() for label, plates_list in self.lpr_config.known_plates.items()
if any( if any(
re.match(f"^{plate}$", rep_plate) re.match(f"^{plate}$", rep_plate)
or distance(plate, rep_plate) <= self.lpr_config.match_distance or Levenshtein.distance(plate, rep_plate)
<= self.lpr_config.match_distance
for plate in plates_list for plate in plates_list
) )
), ),

View File

@ -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

View File

@ -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

View File

@ -166,6 +166,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
camera = obj_data["camera"] camera = obj_data["camera"]
if not self.config.cameras[camera].face_recognition.enabled: if not self.config.cameras[camera].face_recognition.enabled:
logger.debug(f"Face recognition disabled for camera {camera}, skipping")
return return
start = datetime.datetime.now().timestamp() start = datetime.datetime.now().timestamp()
@ -208,6 +209,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
person_box = obj_data.get("box") person_box = obj_data.get("box")
if not person_box: if not person_box:
logger.debug(f"No person box available for {id}")
return return
rgb = cv2.cvtColor(frame, cv2.COLOR_YUV2RGB_I420) rgb = cv2.cvtColor(frame, cv2.COLOR_YUV2RGB_I420)
@ -233,7 +235,8 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
try: try:
face_frame = cv2.cvtColor(face_frame, cv2.COLOR_RGB2BGR) face_frame = cv2.cvtColor(face_frame, cv2.COLOR_RGB2BGR)
except Exception: except Exception as e:
logger.debug(f"Failed to convert face frame color for {id}: {e}")
return return
else: else:
# don't run for object without attributes # don't run for object without attributes
@ -251,6 +254,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
# no faces detected in this frame # no faces detected in this frame
if not face: if not face:
logger.debug(f"No face attributes found for {id}")
return return
face_box = face.get("box") face_box = face.get("box")
@ -274,6 +278,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
res = self.recognizer.classify(face_frame) res = self.recognizer.classify(face_frame)
if not res: if not res:
logger.debug(f"Face recognizer returned no result for {id}")
self.__update_metrics(datetime.datetime.now().timestamp() - start) self.__update_metrics(datetime.datetime.now().timestamp() - start)
return return

View File

@ -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:

View File

@ -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

View File

@ -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
@ -158,11 +158,13 @@ class EmbeddingMaintainer(threading.Thread):
self.realtime_processors: list[RealTimeProcessorApi] = [] self.realtime_processors: list[RealTimeProcessorApi] = []
if self.config.face_recognition.enabled: if self.config.face_recognition.enabled:
logger.debug("Face recognition enabled, initializing FaceRealTimeProcessor")
self.realtime_processors.append( self.realtime_processors.append(
FaceRealTimeProcessor( FaceRealTimeProcessor(
self.config, self.requestor, self.event_metadata_publisher, metrics self.config, self.requestor, self.event_metadata_publisher, metrics
) )
) )
logger.debug("FaceRealTimeProcessor initialized successfully")
if self.config.classification.bird.enabled: if self.config.classification.bird.enabled:
self.realtime_processors.append( self.realtime_processors.append(
@ -395,7 +397,14 @@ class EmbeddingMaintainer(threading.Thread):
source_type, _, camera, frame_name, data = update source_type, _, camera, frame_name, data = update
logger.debug(
f"Received update - source_type: {source_type}, camera: {camera}, data label: {data.get('label') if data else 'None'}"
)
if not camera or source_type != EventTypeEnum.tracked_object: if not camera or source_type != EventTypeEnum.tracked_object:
logger.debug(
f"Skipping update - camera: {camera}, source_type: {source_type}"
)
return return
if self.config.semantic_search.enabled: if self.config.semantic_search.enabled:
@ -405,6 +414,9 @@ class EmbeddingMaintainer(threading.Thread):
# no need to process updated objects if no processors are active # no need to process updated objects if no processors are active
if len(self.realtime_processors) == 0 and len(self.post_processors) == 0: if len(self.realtime_processors) == 0 and len(self.post_processors) == 0:
logger.debug(
f"No processors active - realtime: {len(self.realtime_processors)}, post: {len(self.post_processors)}"
)
return return
# Create our own thumbnail based on the bounding box and the frame time # Create our own thumbnail based on the bounding box and the frame time
@ -413,6 +425,7 @@ class EmbeddingMaintainer(threading.Thread):
frame_name, camera_config.frame_shape_yuv frame_name, camera_config.frame_shape_yuv
) )
except FileNotFoundError: except FileNotFoundError:
logger.debug(f"Frame {frame_name} not found for camera {camera}")
pass pass
if yuv_frame is None: if yuv_frame is None:
@ -421,7 +434,11 @@ class EmbeddingMaintainer(threading.Thread):
) )
return return
logger.debug(
f"Processing {len(self.realtime_processors)} realtime processors for object {data.get('id')} (label: {data.get('label')})"
)
for processor in self.realtime_processors: for processor in self.realtime_processors:
logger.debug(f"Calling process_frame on {processor.__class__.__name__}")
processor.process_frame(data, yuv_frame) processor.process_frame(data, yuv_frame)
for processor in self.post_processors: for processor in self.post_processors:

View File

@ -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__)

View File

@ -9,6 +9,7 @@ from multiprocessing import Queue, Value
from multiprocessing.synchronize import Event as MpEvent from multiprocessing.synchronize import Event as MpEvent
import numpy as np import numpy as np
import zmq
from frigate.comms.object_detector_signaler import ( from frigate.comms.object_detector_signaler import (
ObjectDetectorPublisher, ObjectDetectorPublisher,
@ -377,6 +378,15 @@ class RemoteObjectDetector:
if self.stop_event.is_set(): if self.stop_event.is_set():
return detections return detections
# Drain any stale detection results from the ZMQ buffer before making a new request
# This prevents reading detection results from a previous request
# NOTE: This should never happen, but can in some rare cases
while True:
try:
self.detector_subscriber.socket.recv_string(flags=zmq.NOBLOCK)
except zmq.Again:
break
# copy input to shared memory # copy input to shared memory
self.np_shm[:] = tensor_input[:] self.np_shm[:] = tensor_input[:]
self.detection_queue.put(self.name) self.detection_queue.put(self.name)

View File

@ -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

View File

@ -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
View 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()

View File

@ -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

View File

@ -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..."

View File

@ -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",

View File

@ -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": {

View File

@ -271,6 +271,8 @@
"disconnectStream": "Disconnect", "disconnectStream": "Disconnect",
"estimatedBandwidth": "Estimated Bandwidth", "estimatedBandwidth": "Estimated Bandwidth",
"roles": "Roles", "roles": "Roles",
"ffmpegModule": "Use stream compatibility mode",
"ffmpegModuleDescription": "If the stream does not load after several attempts, try enabling this. When enabled, Frigate will use the ffmpeg module with go2rtc. This may provide better compatibility with some camera streams.",
"none": "None", "none": "None",
"error": "Error", "error": "Error",
"streamValidated": "Stream {{number}} validated successfully", "streamValidated": "Stream {{number}} validated successfully",

View File

@ -11,7 +11,8 @@ import { isDesktop, isMobile } 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";
@ -181,6 +182,7 @@ type GroupedClassificationCardProps = {
selectedItems: string[]; selectedItems: string[];
i18nLibrary: string; i18nLibrary: string;
objectType: string; objectType: string;
noClassificationLabel?: string;
onClick: (data: ClassificationItemData | undefined) => void; onClick: (data: ClassificationItemData | undefined) => void;
children?: (data: ClassificationItemData) => React.ReactNode; children?: (data: ClassificationItemData) => React.ReactNode;
}; };
@ -190,6 +192,7 @@ export function GroupedClassificationCard({
threshold, threshold,
selectedItems, selectedItems,
i18nLibrary, i18nLibrary,
noClassificationLabel = "details.none",
onClick, onClick,
children, children,
}: GroupedClassificationCardProps) { }: GroupedClassificationCardProps) {
@ -222,10 +225,14 @@ export function GroupedClassificationCard({
const bestTyped: ClassificationItemData = best; const bestTyped: ClassificationItemData = best;
return { return {
...bestTyped, ...bestTyped,
name: event ? (event.sub_label ?? t("details.unknown")) : bestTyped.name, name: event
? event.sub_label && event.sub_label !== "none"
? event.sub_label
: t(noClassificationLabel)
: bestTyped.name,
score: event?.data?.sub_label_score || bestTyped.score, score: event?.data?.sub_label_score || bestTyped.score,
}; };
}, [group, event, t]); }, [group, event, noClassificationLabel, t]);
const bestScoreStatus = useMemo(() => { const bestScoreStatus = useMemo(() => {
if (!bestItem?.score || !threshold) { if (!bestItem?.score || !threshold) {
@ -311,16 +318,35 @@ export function GroupedClassificationCard({
isMobile && "px-2", isMobile && "px-2",
)} )}
> >
{event?.sub_label ? event.sub_label : t("details.unknown")} {event?.sub_label && event.sub_label !== "none"
{event?.sub_label && ( ? event.sub_label
<div : t(noClassificationLabel)}
className={cn( {event?.sub_label && event.sub_label !== "none" && (
"", <div className="flex items-center gap-1">
bestScoreStatus == "match" && "text-success", <div
bestScoreStatus == "potential" && "text-orange-400", className={cn(
bestScoreStatus == "unknown" && "text-danger", "",
)} bestScoreStatus == "match" && "text-success",
>{`${Math.round((event.data.sub_label_score || 0) * 100)}%`}</div> bestScoreStatus == "potential" && "text-orange-400",
bestScoreStatus == "unknown" && "text-danger",
)}
>{`${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")}>

View File

@ -37,6 +37,7 @@ 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";
type ReviewCardProps = { type ReviewCardProps = {
event: ReviewSegment; event: ReviewSegment;
@ -142,7 +143,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/", "")}`}
@ -165,6 +166,14 @@ export default function ReviewCard({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div className="flex items-center justify-evenly gap-1"> <div className="flex items-center justify-evenly gap-1">
<> <>
<LuCircle
className={cn(
"size-2",
event.severity == "alert"
? "fill-severity_alert text-severity_alert"
: "fill-severity_detection text-severity_detection",
)}
/>
{event.data.objects.map((object) => { {event.data.objects.map((object) => {
return getIconForLabel( return getIconForLabel(
object, object,

View File

@ -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";
@ -41,8 +41,9 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
placeholderId, placeholderId,
}: 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(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 +59,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 +68,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

View File

@ -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>

View File

@ -174,9 +174,7 @@ export default function CameraWizardDialog({
...(friendlyName && { friendly_name: friendlyName }), ...(friendlyName && { friendly_name: friendlyName }),
ffmpeg: { ffmpeg: {
inputs: wizardData.streams.map((stream, index) => { inputs: wizardData.streams.map((stream, index) => {
const isRestreamed = if (stream.restream) {
wizardData.restreamIds?.includes(stream.id) ?? false;
if (isRestreamed) {
const go2rtcStreamName = const go2rtcStreamName =
wizardData.streams!.length === 1 wizardData.streams!.length === 1
? finalCameraName ? finalCameraName
@ -234,7 +232,11 @@ export default function CameraWizardDialog({
wizardData.streams!.length === 1 wizardData.streams!.length === 1
? finalCameraName ? finalCameraName
: `${finalCameraName}_${index + 1}`; : `${finalCameraName}_${index + 1}`;
go2rtcStreams[streamName] = [stream.url];
const streamUrl = stream.useFfmpeg
? `ffmpeg:${stream.url}`
: stream.url;
go2rtcStreams[streamName] = [streamUrl];
}); });
if (Object.keys(go2rtcStreams).length > 0) { if (Object.keys(go2rtcStreams).length > 0) {

View File

@ -608,6 +608,12 @@ export default function Step1NameCamera({
</div> </div>
)} )}
{isTesting && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<ActivityIndicator className="size-4" />
{testStatus}
</div>
)}
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4"> <div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
<Button <Button
type="button" type="button"
@ -635,10 +641,7 @@ export default function Step1NameCamera({
variant="select" variant="select"
className="flex items-center justify-center gap-2 sm:flex-1" className="flex items-center justify-center gap-2 sm:flex-1"
> >
{isTesting && <ActivityIndicator className="size-4" />} {t("cameraWizard.step1.testConnection")}
{isTesting && testStatus
? testStatus
: t("cameraWizard.step1.testConnection")}
</Button> </Button>
)} )}
</div> </div>

View File

@ -201,16 +201,12 @@ export default function Step2StreamConfig({
const setRestream = useCallback( const setRestream = useCallback(
(streamId: string) => { (streamId: string) => {
const currentIds = wizardData.restreamIds || []; const stream = streams.find((s) => s.id === streamId);
const isSelected = currentIds.includes(streamId); if (!stream) return;
const newIds = isSelected
? currentIds.filter((id) => id !== streamId) updateStream(streamId, { restream: !stream.restream });
: [...currentIds, streamId];
onUpdate({
restreamIds: newIds,
});
}, },
[wizardData.restreamIds, onUpdate], [streams, updateStream],
); );
const hasDetectRole = streams.some((s) => s.roles.includes("detect")); const hasDetectRole = streams.some((s) => s.roles.includes("detect"));
@ -435,9 +431,7 @@ export default function Step2StreamConfig({
{t("cameraWizard.step2.go2rtc")} {t("cameraWizard.step2.go2rtc")}
</span> </span>
<Switch <Switch
checked={(wizardData.restreamIds || []).includes( checked={stream.restream || false}
stream.id,
)}
onCheckedChange={() => setRestream(stream.id)} onCheckedChange={() => setRestream(stream.id)}
/> />
</div> </div>

View File

@ -1,7 +1,13 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { LuRotateCcw } from "react-icons/lu"; import { LuRotateCcw, LuInfo } from "react-icons/lu";
import { useState, useCallback, useMemo, useEffect } from "react"; import { useState, useCallback, useMemo, useEffect } from "react";
import ActivityIndicator from "@/components/indicators/activity-indicator"; import ActivityIndicator from "@/components/indicators/activity-indicator";
import axios from "axios"; import axios from "axios";
@ -216,7 +222,6 @@ export default function Step3Validation({
brandTemplate: wizardData.brandTemplate, brandTemplate: wizardData.brandTemplate,
customUrl: wizardData.customUrl, customUrl: wizardData.customUrl,
streams: wizardData.streams, streams: wizardData.streams,
restreamIds: wizardData.restreamIds,
}; };
onSave(configData); onSave(configData);
@ -322,6 +327,51 @@ export default function Step3Validation({
</div> </div>
)} )}
{result?.success && (
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm">
{t("cameraWizard.step3.ffmpegModule")}
</span>
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-4 w-4 p-0"
>
<LuInfo className="size-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="pointer-events-auto w-80 text-xs">
<div className="space-y-2">
<div className="font-medium">
{t("cameraWizard.step3.ffmpegModule")}
</div>
<div className="text-muted-foreground">
{t(
"cameraWizard.step3.ffmpegModuleDescription",
)}
</div>
</div>
</PopoverContent>
</Popover>
</div>
<Switch
checked={stream.useFfmpeg || false}
onCheckedChange={(checked) => {
onUpdate({
streams: streams.map((s) =>
s.id === stream.id
? { ...s, useFfmpeg: checked }
: s,
),
});
}}
/>
</div>
)}
<div className="mb-2 flex flex-col justify-between gap-1 md:flex-row md:items-center"> <div className="mb-2 flex flex-col justify-between gap-1 md:flex-row md:items-center">
<span className="break-all text-sm text-muted-foreground"> <span className="break-all text-sm text-muted-foreground">
{stream.url} {stream.url}
@ -491,8 +541,7 @@ function StreamIssues({
// Restreaming check // Restreaming check
if (stream.roles.includes("record")) { if (stream.roles.includes("record")) {
const restreamIds = wizardData.restreamIds || []; if (stream.restream) {
if (restreamIds.includes(stream.id)) {
result.push({ result.push({
type: "warning", type: "warning",
message: t("cameraWizard.step3.issues.restreamingWarning"), message: t("cameraWizard.step3.issues.restreamingWarning"),
@ -660,9 +709,10 @@ function StreamPreview({ stream, onBandwidthUpdate }: StreamPreviewProps) {
useEffect(() => { useEffect(() => {
// Register stream with go2rtc // Register stream with go2rtc
const streamUrl = stream.useFfmpeg ? `ffmpeg:${stream.url}` : stream.url;
axios axios
.put(`go2rtc/streams/${streamId}`, null, { .put(`go2rtc/streams/${streamId}`, null, {
params: { src: stream.url }, params: { src: streamUrl },
}) })
.then(() => { .then(() => {
// Add small delay to allow go2rtc api to run and initialize the stream // Add small delay to allow go2rtc api to run and initialize the stream
@ -680,7 +730,7 @@ function StreamPreview({ stream, onBandwidthUpdate }: StreamPreviewProps) {
// do nothing on cleanup errors - go2rtc won't consume the streams // do nothing on cleanup errors - go2rtc won't consume the streams
}); });
}; };
}, [stream.url, streamId]); }, [stream.url, stream.useFfmpeg, streamId]);
const resolution = stream.testResult?.resolution; const resolution = stream.testResult?.resolution;
let aspectRatio = "16/9"; let aspectRatio = "16/9";

View File

@ -367,7 +367,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 +386,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>

View File

@ -845,6 +845,7 @@ function FaceAttemptGroup({
selectedItems={selectedFaces} selectedItems={selectedFaces}
i18nLibrary="views/faceLibrary" i18nLibrary="views/faceLibrary"
objectType="person" objectType="person"
noClassificationLabel="details.unknown"
onClick={(data) => { onClick={(data) => {
if (data) { if (data) {
onClickFaces([data.filename], true); onClickFaces([data.filename], true);

View File

@ -85,6 +85,8 @@ export type StreamConfig = {
quality?: string; quality?: string;
testResult?: TestResult; testResult?: TestResult;
userTested?: boolean; userTested?: boolean;
useFfmpeg?: boolean;
restream?: boolean;
}; };
export type TestResult = { export type TestResult = {
@ -105,7 +107,6 @@ export type WizardFormData = {
brandTemplate?: CameraBrand; brandTemplate?: CameraBrand;
customUrl?: string; customUrl?: string;
streams?: StreamConfig[]; streams?: StreamConfig[];
restreamIds?: string[];
}; };
// API Response Types // API Response Types
@ -146,6 +147,7 @@ export type CameraConfigData = {
inputs: { inputs: {
path: string; path: string;
roles: string[]; roles: string[];
input_args?: string;
}[]; }[];
}; };
live?: { live?: {

View File

@ -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);
} }

View File

@ -214,7 +214,7 @@ function ModelCard({ config, onClick, onDelete }: ModelCardProps) {
const handleDelete = useCallback(async () => { const handleDelete = useCallback(async () => {
try { try {
// First, remove from config to stop the processor await axios.delete(`classification/${config.name}`);
await axios.put("/config/set", { await axios.put("/config/set", {
requires_restart: 0, requires_restart: 0,
update_topic: `config/classification/custom/${config.name}`, update_topic: `config/classification/custom/${config.name}`,
@ -227,9 +227,6 @@ function ModelCard({ config, onClick, onDelete }: ModelCardProps) {
}, },
}); });
// Then, delete the model data and files
await axios.delete(`classification/${config.name}`);
toast.success(t("toast.success.deletedModel", { count: 1 }), { toast.success(t("toast.success.deletedModel", { count: 1 }), {
position: "top-center", position: "top-center",
}); });

View File

@ -961,6 +961,7 @@ function ObjectTrainGrid({
selectedItems={selectedImages} selectedItems={selectedImages}
i18nLibrary="views/classificationModel" i18nLibrary="views/classificationModel"
objectType={model.object_config?.objects?.at(0) ?? "Object"} objectType={model.object_config?.objects?.at(0) ?? "Object"}
noClassificationLabel="details.none"
onClick={(data) => { onClick={(data) => {
if (data) { if (data) {
onClickImages([data.filename], true); onClickImages([data.filename], true);

View File

@ -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>