Compare commits

...
Author SHA1 Message Date
Josh Hawkins c50085a8af add last time to active notices 2026-10-13 17:28:23 -05:00
Josh HawkinsandGitHub b79e043976 Reduce idle CPU from thread pools (#24652)
CI / AMD64 Build (push) Waiting to run
CI / AMD64 Smoke Test (push) Blocked by required conditions
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* reduce idle cpu from onnxruntime and opencv thread pools

ONNX Runtime's intra-op threads kept spinning for about 1 s after every run, so a CPU enrichment called once a second or more kept most cores busy. On a 16 thread machine, arcface at one call per second used 9.4 s of CPU per call with ~360 ms latency. Enrichment sessions on the CPU execution provider now set `session.intra_op.allow_spinning` to 0, which drops that to 1.9 s with no latency change. Detector sessions and GPU providers are unchanged, since disabling spinning doubled yolov9-t latency on CPU (12 to 23 ms).

The output and embeddings processes now run OpenCV single threaded, like the camera tracking process. A 4 camera birdseye redraw at 1080p drops from 13.7 to 4.0 ms of CPU, and the full frame conversion in the enrichment processors from 23 to 2.7 ms, each adding about 1 ms of wall time.

* test that only cpu enrichment sessions disable ort spinning

* patch out the neural engine in the spinning test
2026-10-13 07:46:55 -06:00
Josh HawkinsandGitHub e61ad52b67 move env helpers out of the config package (#24648)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
go2rtc's create_config.py imported frigate.config.env, which ran the frigate.config package init and loaded every detector plugin. Since the onnxruntime 1.30 bump, importing onnxruntime takes about 5s on some hosts, so go2rtc started after Frigate's cameras and every ffmpeg process failed to connect to the restream until it came up. The env helpers now live in frigate.util.env, so go2rtc's config script no longer imports onnxruntime.
2026-10-12 14:54:50 -05:00
Josh HawkinsandGitHub ff9eb8f184 Keep frame shared memory mapped (#24644)
* keep frame shm open and skip overwritten frames

Every reader closed and reopened a frame's shared memory segment on each frame, so each process paid a page fault on every page of a fresh mapping (about 760 for a 1080p frame) per frame. Readers now keep segments mapped. Capture stamps each slot's frame time in a per-camera `{camera}_frame_times` segment, and `get()` returns None when the slot no longer holds the requested frame, reopening once by name so a mapping left over from a same-size camera recreate heals itself. Capture also reuses a slot when the frame queue is full, since no reader was told about it.

* verify the frame for embeddings object updates

Tracked object events only carried the object's own frame_time, which stays at its last detection when the object isn't detected in a frame, so embeddings couldn't check the slot it read and could crop face or plate data from a frame that had already been overwritten. Events now carry the time of the frame they name, and embeddings passes it to `get()`.

* log when a frame slot was overwritten before it was read

A lapped read and a missing segment ended up as the same debug line at every caller, so there was no way to tell a slow reader from a missing frame.

* bring the FrameManager interface in line with its implementation

The abstract get() still had a timeout_ms signature from an older implementation and frame_times() wasn't declared, though capture and detect are typed against the interface. get() also didn't say how long the array it returns stays valid.

* drop a camera's cached frame mappings when it is removed or recreated

A reader that kept its mappings open stayed on a removed camera's segments, which pinned their shared memory until restart, and after a same-size recreate the ledger reopen only refreshed the slot being read while the other cached slots still returned the old camera's pixels. Readers now drop every cached slot for a camera on its remove update, and get() does the same when a ledger mismatch turns out to be a replaced ledger rather than a lapped slot.

* match a camera's frame names exactly in close_camera

The prefix match on `{camera}_frame` also caught another camera whose name starts with it, so removing `cam` dropped the cached ring of a camera named `cam_frame`.
2026-10-12 10:21:08 -06:00
Josh HawkinsandGitHub bc06b8c753 run opencv single threaded in camera tracking processes (#24640)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
OpenCV's default thread pool is sized to every core, and its idle threads spin while it works on the small motion frames and detection regions in each camera tracking process. On a 16 thread machine replaying 720p clips at 5 fps, tracking CPU dropped from 27-50 ms to 5-8 ms per frame with identical output and no change in wall time. Frames from ffmpeg are now also read directly into shared memory with readinto(), which skips one copy per frame.
2026-10-11 21:01:58 -06:00
e7164882f5 Return to the requested URL after logging in (#24637)
When an unauthenticated user opens a deep link, carry the requested
path through the login page in a redirect query param and return to it
after a successful login instead of always going to the home page.
Only same-origin targets are honored.


Claude-Session: https://claude.ai/code/session_011CFGjgYURSRTpXBaxp1Dq7

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:59:13 -06:00
Martin WeineltandGitHub 135a95d8f2 Fix various warnings and deprecations (#23641)
* Migrate to fastapi lifespan for startup events

The `on_event` style decorator is deprecated

https://fastapi.tiangolo.com/advanced/events/

* Migrate to pydantic TypeAdapter

https://pydantic.dev/docs/validation/2.12/get-started/migration/#introduction-of-typeadapter

* Replace pytz with native tz-aware objects and zoneinfo library

Supported from Python 3.9, see https://peps.python.org/pep-0615/. Also
https://blog.ganssle.io/articles/2019/11/utcnow.html.

Also provide tzdata as new dependency to supplement systems without local
tzdata information.

* Provide lookup for normalized timezone names

This provides a helper to look up the correct timezone name independent
of the casing of the given timezone name.

* Test timezone naming normalization and error handling
2026-10-11 17:49:33 -05:00
Josh HawkinsandGitHub 186a730790 use CLIPImageProcessorPil to silence transformers torchvision warning (#24636)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
2026-10-11 13:21:21 -06:00
Josh HawkinsandGitHub 5c32af3c7f Autotracking improvements and fixes (#24633)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
* fix autotracking tracker selection and crop before histogram conversion

- Rebuild the norfair trackers when an onvif save changes autotracking enabled_in_config or the PTZ-tracked labels. The trackers were only built at startup, so disabling autotracking or removing person from its track list in the UI made get_tracker pick a tracker that didn't exist, and the KeyError killed the camera process until restart. Saves that leave the selection alone, and runtime MQTT toggles, don't touch tracking.
- get_tracker now returns the default tracker that match_and_update actually feeds. On autotracking cameras it returned the static default for labels without their own tracker, so register missed the norfair object and lost the pre-initialization score history, and deregister pruned the wrong tracker.
- get_histogram crops the box out of each I420 plane before converting to BGR instead of converting the whole frame. It runs per detection per frame on autotracking cameras and now costs about 0.2 ms at any resolution, down from 1 ms at 1080p and 5 ms at 4K.

* don't rebuild trackers for track list edits while autotracking is disabled
2026-10-11 07:02:42 -06:00
Josh HawkinsandGitHub acc84d7658 Fixes (#24631)
* fix classification wizard finding no sample images on large databases

The wizard grouped tracked objects by camera and 6 hour block, oldest first, then kept the first 100. With more than 100 groups that was always the oldest objects, which often have no snapshot or thumbnail left on disk, so no examples were generated. Shuffle the selection before truncating, and keep extracting from the remaining tracked objects until 100 usable images are found.

* add notice and system ui message when a camera isn't using go2rtc

* add docs note about privacy masks

* match index-less device strings to hardware units in the detection models picker

A config with `edgetpu:usb` was reported as hardware that wasn't found, because the picker compared device strings exactly and the probe reports the unit as `edgetpu:usb:0`. A device without an index now resolves to the first unit of that kind, so the hardware dropdown, the unit checkboxes, and the model summary all recognize it.

* don't treat unknown audio as an audio-bearing stream

A recording row with NULL has_audio (ffprobe failed and the cv2 fallback can't report audio) marked the whole stream as audio-bearing, so every confirmed video-only row on it was dropped as a glitch. A stream now counts as audio-bearing only when a row is known to carry audio.

* stage main+sub exports on disk instead of /tmp/cache

A main+sub export stages a full copy of itself before the final file is written. That copy went to /tmp/cache, so a long export outgrew the tmpfs and failed with no space left on device. Staged runs are now written to the exports directory, and startup removes any left behind by a killed export.

* address review feedback

- stage main+sub export runs in a staging subfolder of the exports directory, so media sync can't delete them mid-export and startup cleanup can't remove a finished export with a matching name or fail on an unremovable file
- run object classification example collection off the event loop
- prefer an exact hardware unit match over an index-less one, so an AMD GPU's "onnx" no longer selects the NVIDIA entry
- add tests for the classification fallback and index-less hardware matching

* serialize example collection and clean up exports when staging dir creation fails

- overlapping object example requests could delete each other's images in the shared temp and train directories, so collection now runs under a lock
- a failed makedirs for the staging directory raised past the failed-export cleanup and left the export spinning until restart, so it now fails the run through the normal cleanup path

* revert
2026-10-11 07:01:39 -06:00
76 changed files with 1460 additions and 270 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ peewee_migrate == 2.3.*
psutil == 7.2.* psutil == 7.2.*
pydantic == 2.13.* pydantic == 2.13.*
git+https://github.com/fbcotter/py3nvml#egg=py3nvml git+https://github.com/fbcotter/py3nvml#egg=py3nvml
pytz == 2026.* tzdata == 2026.5
pyzmq == 27.2.* pyzmq == 27.2.*
ruamel.yaml == 0.19.* ruamel.yaml == 0.19.*
tzlocal == 5.4.4 tzlocal == 5.4.4
@@ -8,13 +8,13 @@ from typing import Any
from ruamel.yaml import YAML from ruamel.yaml import YAML
sys.path.insert(0, "/opt/frigate") sys.path.insert(0, "/opt/frigate")
from frigate.config.env import apply_config_env_vars, substitute_frigate_vars
from frigate.const import ( from frigate.const import (
BIRDSEYE_PIPE, BIRDSEYE_PIPE,
LIBAVFORMAT_VERSION_MAJOR, LIBAVFORMAT_VERSION_MAJOR,
) )
from frigate.ffmpeg_presets import parse_preset_hardware_acceleration_encode from frigate.ffmpeg_presets import parse_preset_hardware_acceleration_encode
from frigate.util.config import find_config_file, resolve_ffmpeg_path from frigate.util.config import find_config_file, resolve_ffmpeg_path
from frigate.util.env import apply_config_env_vars, substitute_frigate_vars
from frigate.util.live_streams import raw_transcode_streams from frigate.util.live_streams import raw_transcode_streams
from frigate.util.services import ( from frigate.util.services import (
is_go2rtc_arbitrary_exec_allowed, is_go2rtc_arbitrary_exec_allowed,
+8
View File
@@ -53,6 +53,14 @@ go2rtc:
Point the camera's inputs at the restream as described in the [restream docs](/configuration/restream.md), and swap `detect -> width` and `detect -> height` to match the rotated resolution. Point the camera's inputs at the restream as described in the [restream docs](/configuration/restream.md), and swap `detect -> width` and `detect -> height` to match the rotated resolution.
### Can I add a privacy mask to hide part of my camera's view?
Frigate does not have privacy masks. [Motion masks and object filter masks](../configuration/masks.md) only affect detection, they don't hide anything in live view, recordings, or snapshots.
Privacy masks are best configured in the camera's firmware settings so the area is blacked out before the video ever leaves the camera and no extra processing is needed. Check there first.
If your camera does not support privacy masks, there is no efficient alternative. Frigate copies the camera's video into recordings and live view without decoding it, so part of the image can't be hidden without transcoding (re-encoding) the stream. This can be done with a custom ffmpeg filter in go2rtc, but it is not recommended. Every masked camera needs a continuous re-encode, which significantly increases CPU usage, especially for high resolution streams.
### My mjpeg stream or snapshots look green and crazy ### My mjpeg stream or snapshots look green and crazy
This almost always means that the width/height defined for your camera are not correct. Double check the resolution with VLC or another player. Also make sure you don't have the width and height values backwards. This almost always means that the width/height defined for your camera are not correct. Double check the resolution with VLC or another player. Also make sure you don't have the width and height values backwards.
+7 -7
View File
@@ -2131,7 +2131,7 @@ paths:
required: false required: false
schema: schema:
type: string type: string
default: utc default: UTC
title: Timezone title: Timezone
responses: responses:
'200': '200':
@@ -3266,7 +3266,7 @@ paths:
anyOf: anyOf:
- type: string - type: string
- type: 'null' - type: 'null'
default: utc default: UTC
title: Timezone title: Timezone
responses: responses:
'200': '200':
@@ -4666,7 +4666,7 @@ paths:
anyOf: anyOf:
- type: string - type: string
- type: 'null' - type: 'null'
default: utc default: UTC
title: Timezone title: Timezone
responses: responses:
'200': '200':
@@ -4928,7 +4928,7 @@ paths:
anyOf: anyOf:
- type: string - type: string
- type: 'null' - type: 'null'
default: utc default: UTC
title: Timezone title: Timezone
- name: min_score - name: min_score
in: query in: query
@@ -5008,7 +5008,7 @@ paths:
anyOf: anyOf:
- type: string - type: string
- type: 'null' - type: 'null'
default: utc default: UTC
title: Timezone title: Timezone
- name: has_clip - name: has_clip
in: query in: query
@@ -7367,7 +7367,7 @@ paths:
required: false required: false
schema: schema:
type: string type: string
default: utc default: UTC
title: Timezone title: Timezone
- name: cameras - name: cameras
in: query in: query
@@ -7417,7 +7417,7 @@ paths:
required: false required: false
schema: schema:
type: string type: string
default: utc default: UTC
title: Timezone title: Timezone
responses: responses:
'200': '200':
+1 -1
View File
@@ -33,11 +33,11 @@ from frigate.config.camera.updater import (
CameraConfigUpdateEnum, CameraConfigUpdateEnum,
CameraConfigUpdateTopic, CameraConfigUpdateTopic,
) )
from frigate.config.env import UnknownVariableError, substitute_frigate_vars
from frigate.models import User from frigate.models import User
from frigate.util.builtin import clean_camera_user_pass, get_record_segment_time from frigate.util.builtin import clean_camera_user_pass, get_record_segment_time
from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files
from frigate.util.config import find_config_file from frigate.util.config import find_config_file
from frigate.util.env import UnknownVariableError, substitute_frigate_vars
from frigate.util.image import run_ffmpeg_snapshot from frigate.util.image import run_ffmpeg_snapshot
from frigate.util.live_streams import ( from frigate.util.live_streams import (
generated_transcode_streams, generated_transcode_streams,
+8 -1
View File
@@ -1,5 +1,6 @@
"""Object classification APIs.""" """Object classification APIs."""
import asyncio
import datetime import datetime
import logging import logging
import os import os
@@ -48,6 +49,9 @@ logger = logging.getLogger(__name__)
router = APIRouter(tags=[Tags.classification]) router = APIRouter(tags=[Tags.classification])
# concurrent collections for one model share its temp and train directories
example_collection_lock = asyncio.Lock()
def invalid_name_response(value: str) -> JSONResponse: def invalid_name_response(value: str) -> JSONResponse:
"""Response for a name that cannot be used as a path component.""" """Response for a name that cannot be used as a path component."""
@@ -1290,7 +1294,10 @@ async def generate_object_examples(request: Request, body: GenerateObjectExample
if model_name is None: if model_name is None:
return invalid_name_response(body.model_name) return invalid_name_response(body.model_name)
collect_object_classification_examples(model_name, body.label) async with example_collection_lock:
await asyncio.to_thread(
collect_object_classification_examples, model_name, body.label
)
return JSONResponse( return JSONResponse(
content={"success": True, "message": "Example generation completed"}, content={"success": True, "message": "Example generation completed"},
@@ -7,4 +7,4 @@ class AppTimelineHourlyQueryParameters(BaseModel):
after: float | None = None after: float | None = None
before: float | None = None before: float | None = None
limit: int | None = 200 limit: int | None = 200
timezone: str | None = "utc" timezone: str | None = "UTC"
@@ -40,7 +40,7 @@ class EventsQueryParams(BaseModel):
max_length: float | None = None max_length: float | None = None
event_id: str | None = None event_id: str | None = None
sort: str | None = None sort: str | None = None
timezone: str | None = "utc" timezone: str | None = "UTC"
class EventsSearchQueryParams(BaseModel): class EventsSearchQueryParams(BaseModel):
@@ -68,7 +68,7 @@ class EventsSearchQueryParams(BaseModel):
has_clip: bool | None = None has_clip: bool | None = None
has_snapshot: bool | None = None has_snapshot: bool | None = None
is_submitted: bool | None = None is_submitted: bool | None = None
timezone: str | None = "utc" timezone: str | None = "UTC"
min_score: float | None = None min_score: float | None = None
max_score: float | None = None max_score: float | None = None
min_speed: float | None = None min_speed: float | None = None
@@ -78,6 +78,6 @@ class EventsSearchQueryParams(BaseModel):
class EventsSummaryQueryParams(BaseModel): class EventsSummaryQueryParams(BaseModel):
timezone: str | None = "utc" timezone: str | None = "UTC"
has_clip: int | None = None has_clip: int | None = None
has_snapshot: int | None = None has_snapshot: int | None = None
@@ -3,7 +3,7 @@ from pydantic.json_schema import SkipJsonSchema
class MediaRecordingsSummaryQueryParams(BaseModel): class MediaRecordingsSummaryQueryParams(BaseModel):
timezone: str = "utc" timezone: str = "UTC"
cameras: str | None = "all" cameras: str | None = "all"
@@ -19,7 +19,7 @@ class ReviewSummaryQueryParams(BaseModel):
cameras: str = "all" cameras: str = "all"
labels: str = "all" labels: str = "all"
zones: str = "all" zones: str = "all"
timezone: str = "utc" timezone: str = "UTC"
class ReviewActivityMotionQueryParams(BaseModel): class ReviewActivityMotionQueryParams(BaseModel):
+12 -9
View File
@@ -1,6 +1,7 @@
import asyncio import asyncio
import logging import logging
import re import re
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI, Request from fastapi import Depends, FastAPI, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
@@ -81,9 +82,20 @@ def create_fastapi_app(
config_holder: ConfigHolder | None = None, config_holder: ConfigHolder | None = None,
notice_registry: NoticeRegistry | None = None, notice_registry: NoticeRegistry | None = None,
): ):
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("FastAPI started")
asyncio.create_task(
debug_replay_auto_stop_watchdog(
replay_manager, frigate_config, config_publisher
)
)
yield
logger.info("Starting FastAPI app") logger.info("Starting FastAPI app")
app = FastAPI( app = FastAPI(
debug=False, debug=False,
lifespan=lifespan,
swagger_ui_parameters={"apisSorter": "alpha", "operationsSorter": "alpha"}, swagger_ui_parameters={"apisSorter": "alpha", "operationsSorter": "alpha"},
dependencies=[Depends(require_admin_by_default())] dependencies=[Depends(require_admin_by_default())]
if enforce_default_admin if enforce_default_admin
@@ -119,15 +131,6 @@ def create_fastapi_app(
database.close() database.close()
return response return response
@app.on_event("startup")
async def startup():
logger.info("FastAPI started")
asyncio.create_task(
debug_replay_auto_stop_watchdog(
replay_manager, frigate_config, config_publisher
)
)
# Rate limiter (used for login endpoint) # Rate limiter (used for login endpoint)
if frigate_config.auth.failed_login_rate_limit is None: if frigate_config.auth.failed_login_rate_limit is None:
limiter.enabled = False limiter.enabled = False
+5 -2
View File
@@ -14,10 +14,10 @@ from enum import Enum
from pathlib import Path as FilePath from pathlib import Path as FilePath
from typing import IO, Any from typing import IO, Any
from urllib.parse import unquote from urllib.parse import unquote
from zoneinfo import ZoneInfo
import cv2 import cv2
import numpy as np import numpy as np
import pytz
from fastapi import APIRouter, Depends, Path, Query, Request, Response from fastapi import APIRouter, Depends, Path, Query, Request, Response
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from pathvalidate import sanitize_filename from pathvalidate import sanitize_filename
@@ -64,6 +64,7 @@ from frigate.util.recording_coverage import (
plan_clip, plan_clip,
resolve_coverage, resolve_coverage,
) )
from frigate.util.time import get_normalized_tz_name
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -837,7 +838,9 @@ async def vod_hour(
parts = year_month.split("-") parts = year_month.split("-")
start_date = ( start_date = (
datetime(int(parts[0]), int(parts[1]), day, hour, tzinfo=UTC) datetime(int(parts[0]), int(parts[1]), day, hour, tzinfo=UTC)
- datetime.now(pytz.timezone(tz_name.replace(",", "/"))).utcoffset() - datetime.now(
ZoneInfo(get_normalized_tz_name(tz_name.replace(",", "/")))
).utcoffset()
) )
end_date = start_date + timedelta(hours=1) - timedelta(milliseconds=1) end_date = start_date + timedelta(hours=1) - timedelta(milliseconds=1)
start_ts = start_date.timestamp() start_ts = start_date.timestamp()
+5 -2
View File
@@ -5,8 +5,8 @@ import logging
import os import os
import threading import threading
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from zoneinfo import ZoneInfo
import pytz
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
@@ -22,6 +22,7 @@ from frigate.api.defs.response.preview_response import (
from frigate.api.defs.tags import Tags from frigate.api.defs.tags import Tags
from frigate.const import BASE_DIR, CACHE_DIR, PREVIEW_FRAME_TYPE from frigate.const import BASE_DIR, CACHE_DIR, PREVIEW_FRAME_TYPE
from frigate.models import Previews from frigate.models import Previews
from frigate.util.time import get_normalized_tz_name
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -126,7 +127,9 @@ def preview_hour(
parts = year_month.split("-") parts = year_month.split("-")
start_date = ( start_date = (
datetime(int(parts[0]), int(parts[1]), int(day), int(hour), tzinfo=UTC) datetime(int(parts[0]), int(parts[1]), int(day), int(hour), tzinfo=UTC)
- datetime.now(pytz.timezone(tz_name.replace(",", "/"))).utcoffset() - datetime.now(
ZoneInfo(get_normalized_tz_name(tz_name.replace(",", "/")))
).utcoffset()
) )
end_date = start_date + timedelta(hours=1) - timedelta(milliseconds=1) end_date = start_date + timedelta(hours=1) - timedelta(milliseconds=1)
start_ts = start_date.timestamp() start_ts = start_date.timestamp()
+1 -1
View File
@@ -175,7 +175,7 @@ async def all_recordings_summary(
@router.get( @router.get(
"/{camera_name}/recordings/summary", dependencies=[Depends(require_camera_access)] "/{camera_name}/recordings/summary", dependencies=[Depends(require_camera_access)]
) )
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"""
time_range_query = ( time_range_query = (
+4
View File
@@ -169,6 +169,10 @@ class CameraMaintainer(threading.Thread):
frame_size = config.frame_shape_yuv[0] * config.frame_shape_yuv[1] frame_size = config.frame_shape_yuv[0] * config.frame_shape_yuv[1]
self.frame_manager.create(f"{config.name}_frame{i}", frame_size) self.frame_manager.create(f"{config.name}_frame{i}", frame_size)
# a leftover ledger from an unclean exit may be the wrong length
self.frame_manager.delete(f"{config.name}_frame_times")
self.frame_manager.create(f"{config.name}_frame_times", max(count, 1) * 8)
capture_process = CameraCapture( capture_process = CameraCapture(
config, config,
count, count,
+5 -8
View File
@@ -349,7 +349,7 @@ class CameraState:
return return
current_frame = self.frame_manager.get( current_frame = self.frame_manager.get(
frame_name, self.camera_config.frame_shape_yuv frame_name, self.camera_config.frame_shape_yuv, frame_time
) )
tracked_objects = self.tracked_objects.copy() tracked_objects = self.tracked_objects.copy()
@@ -404,7 +404,7 @@ class CameraState:
self.send_mqtt_snapshot(new_obj, object_type) self.send_mqtt_snapshot(new_obj, object_type)
for c in self.callbacks["start"]: for c in self.callbacks["start"]:
c(self.name, new_obj, frame_name) c(self.name, new_obj, frame_name, frame_time)
for id in updated_ids: for id in updated_ids:
updated_obj = tracked_objects[id] updated_obj = tracked_objects[id]
@@ -416,7 +416,7 @@ class CameraState:
if autotracker_update or significant_update: if autotracker_update or significant_update:
for c in self.callbacks["autotrack"]: for c in self.callbacks["autotrack"]:
c(self.name, updated_obj, frame_name) c(self.name, updated_obj, frame_name, frame_time)
if thumb_update and current_frame is not None: if thumb_update and current_frame is not None:
# ensure this frame is stored in the cache # ensure this frame is stored in the cache
@@ -466,7 +466,7 @@ class CameraState:
): ):
# call event handlers # call event handlers
for c in self.callbacks["update"]: for c in self.callbacks["update"]:
c(self.name, updated_obj, frame_name) c(self.name, updated_obj, frame_name, frame_time)
updated_obj.last_published = frame_time updated_obj.last_published = frame_time
# send MQTT snapshot when object first enters a required zone, # send MQTT snapshot when object first enters a required zone,
@@ -488,7 +488,7 @@ class CameraState:
removed_obj.obj_data["end_time"] = frame_time removed_obj.obj_data["end_time"] = frame_time
logger.debug(f"{self.name}: end callback for object {id}") logger.debug(f"{self.name}: end callback for object {id}")
for c in self.callbacks["end"]: for c in self.callbacks["end"]:
c(self.name, removed_obj, frame_name) c(self.name, removed_obj, frame_name, frame_time)
# TODO: can i switch to looking this up and only changing when an event ends? # TODO: can i switch to looking this up and only changing when an event ends?
# maintain best objects # maintain best objects
@@ -603,9 +603,6 @@ class CameraState:
self.current_frame_time = frame_time self.current_frame_time = frame_time
self._current_frame = np.copy(current_frame) self._current_frame = np.copy(current_frame)
if self.previous_frame_id is not None:
self.frame_manager.close(self.previous_frame_id)
self.previous_frame_id = frame_name self.previous_frame_id = frame_name
def send_mqtt_snapshot(self, new_obj: TrackedObject, object_type: str) -> None: def send_mqtt_snapshot(self, new_obj: TrackedObject, object_type: str) -> None:
+18 -2
View File
@@ -8,7 +8,16 @@ from .zmq_proxy import Publisher, Subscriber
class EventUpdatePublisher( class EventUpdatePublisher(
Publisher[tuple[EventTypeEnum, EventStateEnum, str | None, str, dict[str, Any]]] Publisher[
tuple[
EventTypeEnum,
EventStateEnum,
str | None,
str,
dict[str, Any],
float | None,
]
]
): ):
"""Publishes events (objects, audio, manual).""" """Publishes events (objects, audio, manual)."""
@@ -19,7 +28,14 @@ class EventUpdatePublisher(
def publish( def publish(
self, self,
payload: tuple[EventTypeEnum, EventStateEnum, str | None, str, dict[str, Any]], payload: tuple[
EventTypeEnum,
EventStateEnum,
str | None,
str,
dict[str, Any],
float | None,
],
sub_topic: str = "", sub_topic: str = "",
) -> None: ) -> None:
super().publish(payload, sub_topic) super().publish(payload, sub_topic)
+1 -1
View File
@@ -3,9 +3,9 @@ from enum import Enum
from pydantic import Field, field_validator from pydantic import Field, field_validator
from frigate.util.config import resolve_ffmpeg_path from frigate.util.config import resolve_ffmpeg_path
from frigate.util.env import EnvString
from ..base import FrigateBaseModel from ..base import FrigateBaseModel
from ..env import EnvString
__all__ = [ __all__ = [
"CameraFfmpegConfig", "CameraFfmpegConfig",
+2 -1
View File
@@ -3,8 +3,9 @@ from typing import Any, Self
from pydantic import Field, model_validator from pydantic import Field, model_validator
from frigate.util.env import EnvString
from ..base import FrigateBaseModel from ..base import FrigateBaseModel
from ..env import EnvString
__all__ = ["GenAIConfig", "GenAIProviderEnum", "GenAIRoleEnum"] __all__ = ["GenAIConfig", "GenAIProviderEnum", "GenAIRoleEnum"]
+2 -1
View File
@@ -1,7 +1,8 @@
from pydantic import Field from pydantic import Field
from frigate.util.env import EnvString
from ..base import FrigateBaseModel from ..base import FrigateBaseModel
from ..env import EnvString
__all__ = ["NotificationConfig"] __all__ = ["NotificationConfig"]
+2 -1
View File
@@ -2,8 +2,9 @@ from enum import Enum
from pydantic import Field, field_validator from pydantic import Field, field_validator
from frigate.util.env import EnvString
from ..base import FrigateBaseModel from ..base import FrigateBaseModel
from ..env import EnvString
from .objects import DEFAULT_TRACKED_OBJECTS from .objects import DEFAULT_TRACKED_OBJECTS
__all__ = ["OnvifConfig", "PtzAutotrackConfig", "ZoomingModeEnum"] __all__ = ["OnvifConfig", "PtzAutotrackConfig", "ZoomingModeEnum"]
+1 -1
View File
@@ -34,6 +34,7 @@ from frigate.util.config import (
get_relative_coordinates, get_relative_coordinates,
migrate_frigate_config, migrate_frigate_config,
) )
from frigate.util.env import EnvVars, reload_sources
from frigate.util.image import create_mask from frigate.util.image import create_mask
from frigate.util.live_streams import ( from frigate.util.live_streams import (
default_transcode_source, default_transcode_source,
@@ -71,7 +72,6 @@ from .classification import (
SemanticSearchModelEnum, SemanticSearchModelEnum,
) )
from .database import DatabaseConfig from .database import DatabaseConfig
from .env import EnvVars, reload_sources
from .logger import LoggerConfig from .logger import LoggerConfig
from .mqtt import MqttConfig from .mqtt import MqttConfig
from .network import NetworkingConfig from .network import NetworkingConfig
+1 -1
View File
@@ -3,9 +3,9 @@ from typing import Self
from pydantic import Field, ValidationInfo, model_validator from pydantic import Field, ValidationInfo, model_validator
from frigate.const import FREQUENCY_STATS_POINTS from frigate.const import FREQUENCY_STATS_POINTS
from frigate.util.env import EnvString
from .base import FrigateBaseModel from .base import FrigateBaseModel
from .env import EnvString
__all__ = ["MqttConfig"] __all__ = ["MqttConfig"]
+2 -1
View File
@@ -1,7 +1,8 @@
from pydantic import Field, field_validator from pydantic import Field, field_validator
from frigate.util.env import EnvString
from .base import FrigateBaseModel from .base import FrigateBaseModel
from .env import EnvString
__all__ = ["ProxyConfig", "HeaderMappingConfig"] __all__ = ["ProxyConfig", "HeaderMappingConfig"]
+1
View File
@@ -8,6 +8,7 @@ MODEL_CACHE_DIR = f"{CONFIG_DIR}/model_cache"
BASE_DIR = "/media/frigate" BASE_DIR = "/media/frigate"
CLIPS_DIR = f"{BASE_DIR}/clips" CLIPS_DIR = f"{BASE_DIR}/clips"
EXPORT_DIR = f"{BASE_DIR}/exports" EXPORT_DIR = f"{BASE_DIR}/exports"
EXPORT_STAGING_DIR = f"{EXPORT_DIR}/staging"
FACE_DIR = f"{CLIPS_DIR}/faces" FACE_DIR = f"{CLIPS_DIR}/faces"
THUMB_DIR = f"{CLIPS_DIR}/thumbs" THUMB_DIR = f"{CLIPS_DIR}/thumbs"
RECORD_DIR = f"{BASE_DIR}/recordings" RECORD_DIR = f"{BASE_DIR}/recordings"
+28 -10
View File
@@ -89,30 +89,46 @@ def is_arm64_platform() -> bool:
return machine in ("aarch64", "arm64", "armv8", "armv7l") return machine in ("aarch64", "arm64", "armv8", "armv7l")
def get_ort_session_options(model_type: str | None = None) -> ort.SessionOptions | None: def get_ort_session_options(
model_type: str | None = None, cpu: bool = False
) -> ort.SessionOptions | None:
"""Get ONNX Runtime session options with appropriate settings. """Get ONNX Runtime session options with appropriate settings.
Args: Args:
model_type: Model being loaded, used to pin its graph optimization level. model_type: Model being loaded, used to pin its graph optimization level.
cpu: Whether the session runs on the CPU execution provider.
Returns: Returns:
SessionOptions with a pinned optimization level, or None for default settings. SessionOptions for enrichment models, or None for default settings.
""" """
# Import here to avoid circular imports # Import here to avoid circular imports
from frigate.embeddings.types import EnrichmentModelTypeEnum from frigate.embeddings.types import (
ENRICHMENT_FOR_MODEL_TYPE,
EnrichmentModelTypeEnum,
)
if model_type not in ENRICHMENT_FOR_MODEL_TYPE:
return None
sess_options = ort.SessionOptions()
if cpu:
# idle intra-op threads otherwise spin for ~1s after every run, keeping
# most cores busy while an enrichment runs even once a second
sess_options.add_session_config_entry("session.intra_op.allow_spinning", "0")
if model_type == EnrichmentModelTypeEnum.jina_v2.value: if model_type == EnrichmentModelTypeEnum.jina_v2.value:
# below EXTENDED the CUDA EP returns an identical vector for every image, # below EXTENDED the CUDA EP returns an identical vector for every image,
# and ORT_ENABLE_ALL fails to build on CPU with a SimplifiedLayerNormFusion error # and ORT_ENABLE_ALL fails to build on CPU with a SimplifiedLayerNormFusion error
level = ort.GraphOptimizationLevel.ORT_ENABLE_EXTENDED sess_options.graph_optimization_level = (
ort.GraphOptimizationLevel.ORT_ENABLE_EXTENDED
)
elif model_type == EnrichmentModelTypeEnum.jina_v1.value: elif model_type == EnrichmentModelTypeEnum.jina_v1.value:
# aggressive optimizations create or expect nodes that don't exist # aggressive optimizations create or expect nodes that don't exist
level = ort.GraphOptimizationLevel.ORT_ENABLE_BASIC sess_options.graph_optimization_level = (
else: ort.GraphOptimizationLevel.ORT_ENABLE_BASIC
return None )
sess_options = ort.SessionOptions()
sess_options.graph_optimization_level = level
return sess_options return sess_options
@@ -789,7 +805,9 @@ def get_optimized_runner(
ONNXModelRunner( ONNXModelRunner(
ort.InferenceSession( ort.InferenceSession(
model_path, model_path,
sess_options=get_ort_session_options(model_type), sess_options=get_ort_session_options(
model_type, cpu=providers[0] == "CPUExecutionProvider"
),
providers=providers, providers=providers,
provider_options=options, provider_options=options,
), ),
+6
View File
@@ -10,6 +10,7 @@ from json.decoder import JSONDecodeError
from multiprocessing.synchronize import Event as MpEvent from multiprocessing.synchronize import Event as MpEvent
from typing import Any from typing import Any
import cv2
import regex import regex
from pathvalidate import ValidationError, sanitize_filename from pathvalidate import ValidationError, sanitize_filename
@@ -51,6 +52,11 @@ class EmbeddingProcess(FrigateProcess):
from .maintainer import EmbeddingMaintainer from .maintainer import EmbeddingMaintainer
self.pre_run_setup(self.config.logger) self.pre_run_setup(self.config.logger)
# frame conversions and crops here are one-off calls, and OpenCV's
# per-core thread pool burns ~8x the CPU of running them inline
cv2.setNumThreads(1)
maintainer = EmbeddingMaintainer( maintainer = EmbeddingMaintainer(
self.config, self.config,
self.metrics, self.metrics,
+7 -8
View File
@@ -332,6 +332,9 @@ class EmbeddingMaintainer(threading.Thread):
"""Apply camera config updates and register newly enabled processors.""" """Apply camera config updates and register newly enabled processors."""
updated_topics = self.config_updater.check_for_updates() updated_topics = self.config_updater.check_for_updates()
for camera in updated_topics.get("remove", []):
self.frame_manager.close_camera(camera)
if updated_topics.keys() & GENAI_UPDATE_TOPICS: if updated_topics.keys() & GENAI_UPDATE_TOPICS:
self._sync_genai_processors() self._sync_genai_processors()
@@ -517,7 +520,7 @@ class EmbeddingMaintainer(threading.Thread):
if update is None: if update is None:
return return
source_type, event_type, camera, frame_name, data = update source_type, event_type, camera, frame_name, data, frame_time = update
logger.debug( logger.debug(
f"Received update - source_type: {source_type}, camera: {camera}, data label: {data.get('label') if data else 'None'}" f"Received update - source_type: {source_type}, camera: {camera}, data label: {data.get('label') if data else 'None'}"
@@ -546,7 +549,7 @@ class EmbeddingMaintainer(threading.Thread):
# 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
try: try:
yuv_frame = self.frame_manager.get( yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv frame_name, camera_config.frame_shape_yuv, frame_time
) )
except FileNotFoundError: except FileNotFoundError:
logger.debug(f"Frame {frame_name} not found for camera {camera}") logger.debug(f"Frame {frame_name} not found for camera {camera}")
@@ -583,8 +586,6 @@ class EmbeddingMaintainer(threading.Thread):
PostProcessDataEnum.tracked_object, PostProcessDataEnum.tracked_object,
) )
self.frame_manager.close(frame_name)
def _process_finalized(self) -> None: def _process_finalized(self) -> None:
"""Process the end of an event.""" """Process the end of an event."""
while True: while True:
@@ -793,7 +794,7 @@ class EmbeddingMaintainer(threading.Thread):
if topic is None: if topic is None:
return return
camera, frame_name, _, _, motion_boxes, _ = data camera, frame_name, frame_time, _, motion_boxes, _ = data
if not camera or camera not in self.config.cameras: if not camera or camera not in self.config.cameras:
return return
@@ -817,7 +818,7 @@ class EmbeddingMaintainer(threading.Thread):
try: try:
yuv_frame = self.frame_manager.get( yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv frame_name, camera_config.frame_shape_yuv, frame_time
) )
except FileNotFoundError: except FileNotFoundError:
pass pass
@@ -841,8 +842,6 @@ class EmbeddingMaintainer(threading.Thread):
{"camera": camera, "motion": motion_boxes}, yuv_frame {"camera": camera, "motion": motion_boxes}, yuv_frame
) )
self.frame_manager.close(frame_name)
def _process_deferred_results(self) -> None: def _process_deferred_results(self) -> None:
"""Drain results from deferred processors and perform IPC side-effects.""" """Drain results from deferred processors and perform IPC side-effects."""
for processor in self.realtime_processors: for processor in self.realtime_processors:
+2 -2
View File
@@ -5,7 +5,7 @@ import os
import shutil import shutil
import threading import threading
from transformers import AutoTokenizer, CLIPImageProcessor from transformers import AutoTokenizer, CLIPImageProcessorPil
from transformers.utils.logging import disable_progress_bar from transformers.utils.logging import disable_progress_bar
from frigate.comms.inter_process import InterProcessRequestor from frigate.comms.inter_process import InterProcessRequestor
@@ -209,7 +209,7 @@ class JinaV1ImageEmbedding(BaseEmbedding):
if self.downloader: if self.downloader:
self.downloader.wait_for_download() self.downloader.wait_for_download()
self.feature_extractor = CLIPImageProcessor.from_pretrained( self.feature_extractor = CLIPImageProcessorPil.from_pretrained(
f"{MODEL_CACHE_DIR}/{self.model_name}", f"{MODEL_CACHE_DIR}/{self.model_name}",
) )
+1 -1
View File
@@ -94,7 +94,7 @@ class EventProcessor(threading.Thread):
if update == None: if update == None:
continue continue
source_type, event_type, camera, _, event_data = update source_type, event_type, camera, _, event_data, _ = update
logger.debug( logger.debug(
f"Event received: {source_type} {event_type} {camera} {event_data['id']}" f"Event received: {source_type} {event_type} {camera} {event_data['id']}"
+5 -1
View File
@@ -2,6 +2,7 @@
import logging import logging
import os import os
import shutil
import threading import threading
import time import time
from collections.abc import Callable from collections.abc import Callable
@@ -15,7 +16,7 @@ from peewee import DoesNotExist
from frigate.comms.inter_process import InterProcessRequestor from frigate.comms.inter_process import InterProcessRequestor
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.config.camera.record import ChaptersEnum from frigate.config.camera.record import ChaptersEnum
from frigate.const import UPDATE_JOB_STATE from frigate.const import EXPORT_STAGING_DIR, UPDATE_JOB_STATE
from frigate.jobs.job import Job from frigate.jobs.job import Job
from frigate.models import Export from frigate.models import Export
from frigate.record.export import ( from frigate.record.export import (
@@ -415,6 +416,9 @@ def reap_stale_exports() -> None:
this in a try/except. A failure on a single row will not stop the rest this in a try/except. A failure on a single row will not stop the rest
of the sweep, and a failure in the top-level query will log and return. of the sweep, and a failure in the top-level query will log and return.
""" """
# staged stream runs live on disk, so a killed export leaves them behind
shutil.rmtree(EXPORT_STAGING_DIR, ignore_errors=True)
try: try:
stale_exports = list(Export.select().where(Export.in_progress == True)) # noqa: E712 stale_exports = list(Export.select().where(Export.in_progress == True)) # noqa: E712
except Exception: except Exception:
-5
View File
@@ -170,7 +170,6 @@ class DetectorRunner(FrigateProcess):
mono_start = time.monotonic() mono_start = time.monotonic()
detections = object_detector.detect_raw(input_frame) detections = object_detector.detect_raw(input_frame)
duration = time.monotonic() - mono_start duration = time.monotonic() - mono_start
frame_manager.close(connection_id)
if connection_id not in self.outputs: if connection_id not in self.outputs:
self.create_output_shm(connection_id) self.create_output_shm(connection_id)
@@ -259,10 +258,6 @@ class AsyncDetectorRunner(FrigateProcess):
ts = self.send_times.popleft() ts = self.send_times.popleft()
duration = time.perf_counter() - ts duration = time.perf_counter() - ts
# release input buffer
assert self._frame_manager is not None
self._frame_manager.close(connection_id)
if connection_id not in self.outputs: if connection_id not in self.outputs:
self.create_output_shm(connection_id) self.create_output_shm(connection_id)
+11 -18
View File
@@ -8,6 +8,7 @@ import threading
from multiprocessing.synchronize import Event as MpEvent from multiprocessing.synchronize import Event as MpEvent
from wsgiref.simple_server import make_server from wsgiref.simple_server import make_server
import cv2
from ws4py.server.wsgirefserver import ( from ws4py.server.wsgirefserver import (
WebSocketWSGIHandler, WebSocketWSGIHandler,
WebSocketWSGIRequestHandler, WebSocketWSGIRequestHandler,
@@ -122,6 +123,10 @@ class OutputProcess(FrigateProcess):
def run(self) -> None: def run(self) -> None:
self.pre_run_setup(self.config.logger) self.pre_run_setup(self.config.logger)
# birdseye resizes are small, and OpenCV's per-core thread pool burns
# ~3x the CPU of running them inline
cv2.setNumThreads(1)
frame_manager = SharedMemoryFrameManager() frame_manager = SharedMemoryFrameManager()
# start a websocket server on 8082 # start a websocket server on 8082
@@ -145,6 +150,7 @@ class OutputProcess(FrigateProcess):
CameraConfigUpdateEnum.birdseye, CameraConfigUpdateEnum.birdseye,
CameraConfigUpdateEnum.enabled, CameraConfigUpdateEnum.enabled,
CameraConfigUpdateEnum.record, CameraConfigUpdateEnum.record,
CameraConfigUpdateEnum.remove,
], ],
) )
birdseye_config_subscriber = ConfigSubscriber("config/birdseye", exact=True) birdseye_config_subscriber = ConfigSubscriber("config/birdseye", exact=True)
@@ -208,6 +214,10 @@ class OutputProcess(FrigateProcess):
birdseye, birdseye,
) )
if CameraConfigUpdateEnum.remove in updates:
for camera in updates["remove"]:
frame_manager.close_camera(camera)
_result = detection_subscriber.check_for_update(timeout=1) _result = detection_subscriber.check_for_update(timeout=1)
if _result is None: if _result is None:
continue continue
@@ -241,7 +251,7 @@ class OutputProcess(FrigateProcess):
continue continue
frame = frame_manager.get( frame = frame_manager.get(
frame_name, self.config.cameras[camera].frame_shape_yuv frame_name, self.config.cameras[camera].frame_shape_yuv, frame_time
) )
if frame is None: if frame is None:
@@ -296,8 +306,6 @@ class OutputProcess(FrigateProcess):
frame, frame,
) )
frame_manager.close(frame_name)
move_preview_frames("clips") move_preview_frames("clips")
while True: while True:
@@ -309,21 +317,6 @@ class OutputProcess(FrigateProcess):
if not topic or data is None: if not topic or data is None:
break break
(
camera,
frame_name,
frame_time,
current_tracked_objects,
motion_boxes,
regions,
) = data
camera_config = self.config.cameras.get(camera)
if camera_config is not None:
frame_manager.get(frame_name, camera_config.frame_shape_yuv)
frame_manager.close(frame_name)
detection_subscriber.stop() detection_subscriber.stop()
for jsmpeg in jsmpeg_cameras.values(): for jsmpeg in jsmpeg_cameras.values():
+1 -3
View File
@@ -124,7 +124,7 @@ class PtzMotionEstimator:
) )
yuv_frame = self.frame_manager.get( yuv_frame = self.frame_manager.get(
frame_name, self.camera_config.frame_shape_yuv frame_name, self.camera_config.frame_shape_yuv, frame_time
) )
if yuv_frame is None: if yuv_frame is None:
@@ -181,8 +181,6 @@ class PtzMotionEstimator:
except Exception: except Exception:
pass pass
self.frame_manager.close(frame_name)
return self.coord_transformations return self.coord_transformations
+19 -7
View File
@@ -15,8 +15,8 @@ from dataclasses import dataclass
from enum import Enum from enum import Enum
from pathlib import Path from pathlib import Path
from typing import Any, cast from typing import Any, cast
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
import pytz
from pathvalidate import sanitize_filename from pathvalidate import sanitize_filename
from peewee import DoesNotExist from peewee import DoesNotExist
@@ -26,6 +26,7 @@ from frigate.const import (
CACHE_DIR, CACHE_DIR,
CLIPS_DIR, CLIPS_DIR,
EXPORT_DIR, EXPORT_DIR,
EXPORT_STAGING_DIR,
MAX_PLAYLIST_SECONDS, MAX_PLAYLIST_SECONDS,
PREVIEW_FRAME_TYPE, PREVIEW_FRAME_TYPE,
STREAM_TYPE_MAIN, STREAM_TYPE_MAIN,
@@ -48,7 +49,7 @@ from frigate.util.recording_coverage import (
stream_media_summary, stream_media_summary,
) )
from frigate.util.services import get_video_properties from frigate.util.services import get_video_properties
from frigate.util.time import is_current_hour from frigate.util.time import get_normalized_tz_name, is_current_hour
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -559,7 +560,7 @@ class RecordingExporter(threading.Thread):
) )
def _staged_run_path(self, index: int) -> str: def _staged_run_path(self, index: int) -> str:
return os.path.join(CACHE_DIR, f"export_stage_{self.export_id}_{index}.mp4") return os.path.join(EXPORT_STAGING_DIR, f"{self.export_id}_{index}.mp4")
def _probe_stream_resolution(self, run: StreamRun) -> tuple[int, int] | None: def _probe_stream_resolution(self, run: StreamRun) -> tuple[int, int] | None:
"""Probe one recording from a run for its resolution. """Probe one recording from a run for its resolution.
@@ -714,6 +715,17 @@ class RecordingExporter(threading.Thread):
for index, run in enumerate(runs): for index, run in enumerate(runs):
dest = self._staged_run_path(index) dest = self._staged_run_path(index)
try:
os.makedirs(os.path.dirname(dest), exist_ok=True)
except OSError:
logger.exception(
"Failed to create staging directory for export %s",
self.export_id,
)
self._cleanup_staged_runs()
return False
weight = 100.0 * run.duration / total_duration weight = 100.0 * run.duration / total_duration
base = 100.0 * completed / total_duration base = 100.0 * completed / total_duration
@@ -862,8 +874,8 @@ class RecordingExporter(threading.Thread):
tz_name = self.config.ui.timezone tz_name = self.config.ui.timezone
if tz_name: if tz_name:
try: try:
tz = pytz.timezone(tz_name) tz = ZoneInfo(get_normalized_tz_name(tz_name))
except pytz.UnknownTimeZoneError: except (ValueError, ZoneInfoNotFoundError):
tz = None tz = None
if tz is not None: if tz is not None:
return datetime.datetime.fromtimestamp(timestamp, tz=tz).strftime( return datetime.datetime.fromtimestamp(timestamp, tz=tz).strftime(
@@ -1026,8 +1038,8 @@ class RecordingExporter(threading.Thread):
tz: datetime.tzinfo | None = None tz: datetime.tzinfo | None = None
if tz_name: if tz_name:
try: try:
tz = pytz.timezone(tz_name) tz = ZoneInfo(get_normalized_tz_name(tz_name))
except pytz.UnknownTimeZoneError: except (ValueError, ZoneInfoNotFoundError):
tz = None tz = None
if tz is None: if tz is None:
tz = datetime.UTC tz = datetime.UTC
+5 -8
View File
@@ -504,6 +504,7 @@ class ReviewSegmentMaintainer(threading.Thread):
def _handle_camera_removed(self, camera: str) -> None: def _handle_camera_removed(self, camera: str) -> None:
"""Close out a deleted camera's segment so a reused name cannot inherit it.""" """Close out a deleted camera's segment so a reused name cannot inherit it."""
self.forcibly_end_segment(camera) self.forcibly_end_segment(camera)
self.frame_manager.close_camera(camera)
self.indefinite_events.pop(camera, None) self.indefinite_events.pop(camera, None)
self.recent_classification_state_changes.pop(camera, None) self.recent_classification_state_changes.pop(camera, None)
@@ -544,7 +545,7 @@ class ReviewSegmentMaintainer(threading.Thread):
try: try:
yuv_frame = self.frame_manager.get( yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv frame_name, camera_config.frame_shape_yuv, frame_time
) )
except FileNotFoundError: except FileNotFoundError:
return return
@@ -554,7 +555,6 @@ class ReviewSegmentMaintainer(threading.Thread):
return return
pending.update_frame(camera_config, yuv_frame, objects) pending.update_frame(camera_config, yuv_frame, objects)
self.frame_manager.close(frame_name)
def update_existing_segment( def update_existing_segment(
self, self,
@@ -653,7 +653,7 @@ class ReviewSegmentMaintainer(threading.Thread):
try: try:
if should_update_image: if should_update_image:
yuv_frame = self.frame_manager.get( yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv frame_name, camera_config.frame_shape_yuv, frame_time
) )
if yuv_frame is None: if yuv_frame is None:
@@ -669,7 +669,6 @@ class ReviewSegmentMaintainer(threading.Thread):
activity.get_all_objects(), activity.get_all_objects(),
prev_data, prev_data,
) )
self.frame_manager.close(frame_name)
except FileNotFoundError: except FileNotFoundError:
return return
@@ -677,7 +676,7 @@ class ReviewSegmentMaintainer(threading.Thread):
if not segment.has_frame: if not segment.has_frame:
try: try:
yuv_frame = self.frame_manager.get( yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv frame_name, camera_config.frame_shape_yuv, frame_time
) )
if yuv_frame is None: if yuv_frame is None:
@@ -685,7 +684,6 @@ class ReviewSegmentMaintainer(threading.Thread):
return return
segment.save_full_frame(camera_config, yuv_frame) segment.save_full_frame(camera_config, yuv_frame)
self.frame_manager.close(frame_name)
self._publish_segment_update( self._publish_segment_update(
segment, camera_config, None, [], prev_data segment, camera_config, None, [], prev_data
) )
@@ -770,7 +768,7 @@ class ReviewSegmentMaintainer(threading.Thread):
try: try:
yuv_frame = self.frame_manager.get( yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv frame_name, camera_config.frame_shape_yuv, frame_time
) )
if yuv_frame is None: if yuv_frame is None:
@@ -780,7 +778,6 @@ class ReviewSegmentMaintainer(threading.Thread):
new_segment.update_frame( new_segment.update_frame(
camera_config, yuv_frame, activity.get_all_objects() camera_config, yuv_frame, activity.get_all_objects()
) )
self.frame_manager.close(frame_name)
self._publish_segment_start(new_segment) self._publish_segment_start(new_segment)
except FileNotFoundError: except FileNotFoundError:
return return
+1 -1
View File
@@ -4,12 +4,12 @@ from unittest.mock import Mock, patch
import frigate.genai import frigate.genai
from frigate.config import GenAIProviderEnum from frigate.config import GenAIProviderEnum
from frigate.config.env import FRIGATE_ENV_VARS
from frigate.const import MODEL_CACHE_DIR, REDACTED_CREDENTIAL_SENTINEL from frigate.const import MODEL_CACHE_DIR, REDACTED_CREDENTIAL_SENTINEL
from frigate.genai import GenAIClient from frigate.genai import GenAIClient
from frigate.models import Event, Recordings, ReviewSegment from frigate.models import Event, Recordings, ReviewSegment
from frigate.stats.emitter import StatsEmitter from frigate.stats.emitter import StatsEmitter
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
from frigate.util.env import FRIGATE_ENV_VARS
class TestHttpApp(BaseTestHttp): class TestHttpApp(BaseTestHttp):
+17
View File
@@ -453,6 +453,23 @@ class TestHttpExport(BaseTestHttp):
assert unchanged.name == "front door export" assert unchanged.name == "front door export"
assert unchanged.video_path == video assert unchanged.video_path == video
def test_reap_stale_exports_removes_staged_runs(self):
with tempfile.TemporaryDirectory() as tmpdir:
staging_dir = os.path.join(tmpdir, "staging")
os.makedirs(staging_dir)
staged = os.path.join(staging_dir, "front_door_abc_0.mp4")
finished = os.path.join(tmpdir, "export_stage_saved_abc.mp4")
for path in (staged, finished):
with open(path, "w") as handle:
handle.write("video")
with patch("frigate.jobs.export.EXPORT_STAGING_DIR", staging_dir):
reap_stale_exports()
assert not os.path.exists(staging_dir)
assert os.path.exists(finished)
def test_reap_stale_exports_deletes_rows_with_no_file(self): def test_reap_stale_exports_deletes_rows_with_no_file(self):
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
stale_video = os.path.join(tmpdir, "stale.mp4") stale_video = os.path.join(tmpdir, "stale.mp4")
+18 -21
View File
@@ -3,8 +3,8 @@
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from unittest.mock import patch from unittest.mock import patch
from zoneinfo import ZoneInfo
import pytz
from fastapi import Request from fastapi import Request
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
@@ -128,16 +128,16 @@ class TestHttpMedia(BaseTestHttp):
In 2024, DST in America/New_York transitions on March 10, 2024 at 2:00 AM In 2024, DST in America/New_York transitions on March 10, 2024 at 2:00 AM
Clocks spring forward from 2:00 AM to 3:00 AM (EST to EDT) Clocks spring forward from 2:00 AM to 3:00 AM (EST to EDT)
""" """
tz = pytz.timezone("America/New_York") tz = ZoneInfo("America/New_York")
# March 9, 2024 at 12:00 PM EST (before DST) # March 9, 2024 at 12:00 PM EST (before DST)
march_9_noon = tz.localize(datetime(2024, 3, 9, 12, 0, 0)).timestamp() march_9_noon = datetime(2024, 3, 9, 12, 0, 0, tzinfo=tz).timestamp()
# March 10, 2024 at 12:00 PM EDT (after DST transition) # March 10, 2024 at 12:00 PM EDT (after DST transition)
march_10_noon = tz.localize(datetime(2024, 3, 10, 12, 0, 0)).timestamp() march_10_noon = datetime(2024, 3, 10, 12, 0, 0, tzinfo=tz).timestamp()
# March 11, 2024 at 12:00 PM EDT (after DST) # March 11, 2024 at 12:00 PM EDT (after DST)
march_11_noon = tz.localize(datetime(2024, 3, 11, 12, 0, 0)).timestamp() march_11_noon = datetime(2024, 3, 11, 12, 0, 0, tzinfo=tz).timestamp()
with AuthTestClient(self.app) as client: with AuthTestClient(self.app) as client:
# Insert recordings for each day # Insert recordings for each day
@@ -201,19 +201,16 @@ class TestHttpMedia(BaseTestHttp):
In 2024, DST in America/New_York transitions on November 3, 2024 at 2:00 AM In 2024, DST in America/New_York transitions on November 3, 2024 at 2:00 AM
Clocks fall back from 2:00 AM to 1:00 AM (EDT to EST) Clocks fall back from 2:00 AM to 1:00 AM (EDT to EST)
""" """
tz = pytz.timezone("America/New_York") tz = ZoneInfo("America/New_York")
# November 2, 2024 at 12:00 PM EDT (before DST transition) # November 2, 2024 at 12:00 PM EDT (before DST transition)
nov_2_noon = tz.localize(datetime(2024, 11, 2, 12, 0, 0)).timestamp() nov_2_noon = datetime(2024, 11, 2, 12, 0, 0, tzinfo=tz).timestamp()
# November 3, 2024 at 12:00 PM EST (after DST transition) # November 3, 2024 at 12:00 PM EST (after DST transition)
# Need to specify is_dst=False to get the time after fall back nov_3_noon = datetime(2024, 11, 3, 12, 0, 0, tzinfo=tz).timestamp()
nov_3_noon = tz.localize(
datetime(2024, 11, 3, 12, 0, 0), is_dst=False
).timestamp()
# November 4, 2024 at 12:00 PM EST (after DST) # November 4, 2024 at 12:00 PM EST (after DST)
nov_4_noon = tz.localize(datetime(2024, 11, 4, 12, 0, 0)).timestamp() nov_4_noon = datetime(2024, 11, 4, 12, 0, 0, tzinfo=tz).timestamp()
with AuthTestClient(self.app) as client: with AuthTestClient(self.app) as client:
# Insert recordings for each day # Insert recordings for each day
@@ -274,13 +271,13 @@ class TestHttpMedia(BaseTestHttp):
""" """
Test recordings summary with multiple cameras across DST boundary. Test recordings summary with multiple cameras across DST boundary.
""" """
tz = pytz.timezone("America/New_York") tz = ZoneInfo("America/New_York")
# March 9, 2024 at 10:00 AM EST (before DST) # March 9, 2024 at 10:00 AM EST (before DST)
march_9_morning = tz.localize(datetime(2024, 3, 9, 10, 0, 0)).timestamp() march_9_morning = datetime(2024, 3, 9, 10, 0, 0, tzinfo=tz).timestamp()
# March 10, 2024 at 3:00 PM EDT (after DST transition) # March 10, 2024 at 3:00 PM EDT (after DST transition)
march_10_afternoon = tz.localize(datetime(2024, 3, 10, 15, 0, 0)).timestamp() march_10_afternoon = datetime(2024, 3, 10, 15, 0, 0, tzinfo=tz).timestamp()
with AuthTestClient(self.app) as client: with AuthTestClient(self.app) as client:
# Override allowed cameras for this test to include both # Override allowed cameras for this test to include both
@@ -343,15 +340,15 @@ class TestHttpMedia(BaseTestHttp):
""" """
Test recordings that span the exact DST transition time. Test recordings that span the exact DST transition time.
""" """
tz = pytz.timezone("America/New_York") tz = ZoneInfo("America/New_York")
# March 10, 2024 at 1:00 AM EST (1 hour before DST transition) # March 10, 2024 at 1:00 AM EST (1 hour before DST transition)
# At 2:00 AM, clocks jump to 3:00 AM # At 2:00 AM, clocks jump to 3:00 AM
before_transition = tz.localize(datetime(2024, 3, 10, 1, 0, 0)).timestamp() before_transition = datetime(2024, 3, 10, 1, 0, 0, tzinfo=tz).timestamp()
# Recording that spans the transition (1:00 AM to 3:30 AM EDT) # Recording that spans the transition (1:00 AM to 3:30 AM EDT)
# This is 1.5 hours of actual time but spans the "missing" hour # This is 1.5 hours of actual time but spans the "missing" hour
after_transition = tz.localize(datetime(2024, 3, 10, 3, 30, 0)).timestamp() after_transition = datetime(2024, 3, 10, 3, 30, 0, tzinfo=tz).timestamp()
with AuthTestClient(self.app) as client: with AuthTestClient(self.app) as client:
Recordings.insert( Recordings.insert(
@@ -411,7 +408,7 @@ class TestHttpMedia(BaseTestHttp):
# Test with UTC timezone # Test with UTC timezone
response = client.get( response = client.get(
"/recordings/summary", params={"timezone": "utc", "cameras": "all"} "/recordings/summary", params={"timezone": "UTC", "cameras": "all"}
) )
assert response.status_code == 200 assert response.status_code == 200
@@ -442,8 +439,8 @@ class TestHttpMedia(BaseTestHttp):
""" """
Test recordings summary filtered to a single camera. Test recordings summary filtered to a single camera.
""" """
tz = pytz.timezone("America/New_York") tz = ZoneInfo("America/New_York")
march_10_noon = tz.localize(datetime(2024, 3, 10, 12, 0, 0)).timestamp() march_10_noon = datetime(2024, 3, 10, 12, 0, 0, tzinfo=tz).timestamp()
with AuthTestClient(self.app) as client: with AuthTestClient(self.app) as client:
# Insert recordings for both cameras # Insert recordings for both cameras
+1 -1
View File
@@ -342,7 +342,7 @@ class TestHttpReview(BaseTestHttp):
"cameras": "front_door", "cameras": "front_door",
"labels": "all", "labels": "all",
"zones": "all", "zones": "all",
"timezone": "utc", "timezone": "UTC",
} }
response = client.get("/review/summary", params=params) response = client.get("/review/summary", params=params)
assert response.status_code == 200 assert response.status_code == 200
+2
View File
@@ -183,10 +183,12 @@ class TestReviewMaintainerRemoval(unittest.TestCase):
"deleted_cam": [{"model": "gate", "from": "a", "to": "b", "timestamp": 1.0}] "deleted_cam": [{"model": "gate", "from": "a", "to": "b", "timestamp": 1.0}]
} }
maintainer.forcibly_end_segment = MagicMock() maintainer.forcibly_end_segment = MagicMock()
maintainer.frame_manager = MagicMock()
maintainer._handle_camera_removed("deleted_cam") maintainer._handle_camera_removed("deleted_cam")
maintainer.forcibly_end_segment.assert_called_once_with("deleted_cam") maintainer.forcibly_end_segment.assert_called_once_with("deleted_cam")
maintainer.frame_manager.close_camera.assert_called_once_with("deleted_cam")
self.assertNotIn("deleted_cam", maintainer.indefinite_events) self.assertNotIn("deleted_cam", maintainer.indefinite_events)
self.assertNotIn("deleted_cam", maintainer.recent_classification_state_changes) self.assertNotIn("deleted_cam", maintainer.recent_classification_state_changes)
@@ -0,0 +1,33 @@
"""Tests for object classification example extraction."""
import tempfile
import unittest
from types import SimpleNamespace
from unittest.mock import patch
import numpy as np
from frigate.util.classification import _extract_event_thumbnails
class TestExtractEventThumbnails(unittest.TestCase):
def test_falls_back_past_missing_images_and_stops_at_target(self):
events = [SimpleNamespace(id=f"event_{i}") for i in range(10)]
missing = {"event_0", "event_1", "event_2"}
image = np.zeros((32, 32, 3), dtype=np.uint8)
with (
tempfile.TemporaryDirectory() as tmpdir,
patch(
"frigate.util.classification._load_event_classification_crop",
side_effect=lambda event: None if event.id in missing else image,
) as load,
):
paths = _extract_event_thumbnails(events, tmpdir, target_count=4)
self.assertEqual(len(paths), 4)
self.assertEqual(load.call_count, 7)
if __name__ == "__main__":
unittest.main()
+27 -8
View File
@@ -35,15 +35,34 @@ class TestGetOrtSessionOptions(unittest.TestCase):
ort.GraphOptimizationLevel.ORT_ENABLE_BASIC, ort.GraphOptimizationLevel.ORT_ENABLE_BASIC,
) )
def test_other_models_use_defaults(self): def test_cpu_enrichment_models_disable_spinning(self):
for model_type in [ for model_type in EnrichmentModelTypeEnum:
None,
EnrichmentModelTypeEnum.paddleocr.value,
EnrichmentModelTypeEnum.arcface.value,
ModelTypeEnum.rfdetr.value,
]:
with self.subTest(model_type=model_type): with self.subTest(model_type=model_type):
self.assertIsNone(get_ort_session_options(model_type)) options = get_ort_session_options(model_type.value, cpu=True)
self.assertEqual(
options.get_session_config_entry("session.intra_op.allow_spinning"),
"0",
)
def test_gpu_enrichment_models_keep_spinning(self):
options = get_ort_session_options(EnrichmentModelTypeEnum.arcface.value)
with self.assertRaises(RuntimeError):
options.get_session_config_entry("session.intra_op.allow_spinning")
def test_arcface_keeps_default_optimization(self):
options = get_ort_session_options(EnrichmentModelTypeEnum.arcface.value)
self.assertEqual(
options.graph_optimization_level,
ort.GraphOptimizationLevel.ORT_ENABLE_ALL,
)
def test_detector_models_use_defaults(self):
for model_type in [None, ModelTypeEnum.rfdetr.value]:
with self.subTest(model_type=model_type):
self.assertIsNone(get_ort_session_options(model_type, cpu=True))
class TestCudaGraphRunner(unittest.TestCase): class TestCudaGraphRunner(unittest.TestCase):
+11 -10
View File
@@ -7,8 +7,9 @@ from unittest.mock import MagicMock, patch
from pydantic import ValidationError from pydantic import ValidationError
from frigate.config import FrigateConfig, env from frigate.config import FrigateConfig
from frigate.config.env import ( from frigate.util import env
from frigate.util.env import (
FRIGATE_ENV_VARS, FRIGATE_ENV_VARS,
validate_env_string, validate_env_string,
validate_env_vars, validate_env_vars,
@@ -301,14 +302,14 @@ class TestVariableSources(unittest.TestCase):
def test_collision_warns_once_naming_the_winner(self): def test_collision_warns_once_naming_the_winner(self):
"""A key from two sources logs one warning naming the source that won.""" """A key from two sources logs one warning naming the source that won."""
with patch.dict(env._CONTAINER_ENV, {"FRIGATE_MQTT_HOST": "from_env"}): with patch.dict(env._CONTAINER_ENV, {"FRIGATE_MQTT_HOST": "from_env"}):
with self.assertLogs("frigate.config.env", level="WARNING") as logs: with self.assertLogs("frigate.util.env", level="WARNING") as logs:
env.apply_config_env_vars({"FRIGATE_MQTT_HOST": "from_config"}) env.apply_config_env_vars({"FRIGATE_MQTT_HOST": "from_config"})
self.assertEqual(len(logs.output), 1) self.assertEqual(len(logs.output), 1)
self.assertIn("FRIGATE_MQTT_HOST", logs.output[0]) self.assertIn("FRIGATE_MQTT_HOST", logs.output[0])
self.assertIn("using the value from container environment", logs.output[0]) self.assertIn("using the value from container environment", logs.output[0])
self.assertNotIn("environment_vars", logs.output[0]) self.assertNotIn("environment_vars", logs.output[0])
with self.assertNoLogs("frigate.config.env", level="WARNING"): with self.assertNoLogs("frigate.util.env", level="WARNING"):
env._rebuild() env._rebuild()
@@ -360,7 +361,7 @@ class TestSecretsFile(unittest.TestCase):
def test_unprefixed_key_is_ignored_with_a_warning(self): def test_unprefixed_key_is_ignored_with_a_warning(self):
"""Names must start with FRIGATE_, matching the credentials directory.""" """Names must start with FRIGATE_, matching the credentials directory."""
self._write("cam_pass: hunter2\nFRIGATE_CAM_PASS: hunter2\n") self._write("cam_pass: hunter2\nFRIGATE_CAM_PASS: hunter2\n")
with self.assertLogs("frigate.config.env", level="WARNING") as logs: with self.assertLogs("frigate.util.env", level="WARNING") as logs:
values = env._load_secrets_file() values = env._load_secrets_file()
self.assertEqual(values, {"FRIGATE_CAM_PASS": "hunter2"}) self.assertEqual(values, {"FRIGATE_CAM_PASS": "hunter2"})
self.assertIn("cam_pass", logs.output[0]) self.assertIn("cam_pass", logs.output[0])
@@ -483,10 +484,10 @@ class TestSourceRobustness(unittest.TestCase):
env.reload_sources() env.reload_sources()
env._CONFIG_ENV_VARS["FRIGATE_DUPE"] = "from_config" env._CONFIG_ENV_VARS["FRIGATE_DUPE"] = "from_config"
with self.assertNoLogs("frigate.config.env", level="WARNING"): with self.assertNoLogs("frigate.util.env", level="WARNING"):
env._rebuild(warn=False) env._rebuild(warn=False)
with self.assertLogs("frigate.config.env", level="WARNING") as logs: with self.assertLogs("frigate.util.env", level="WARNING") as logs:
env._rebuild() env._rebuild()
self.assertIn("FRIGATE_DUPE", logs.output[0]) self.assertIn("FRIGATE_DUPE", logs.output[0])
@@ -496,7 +497,7 @@ class TestSourceRobustness(unittest.TestCase):
env.reload_sources() env.reload_sources()
self._write_secrets("FRIGATE_CAMS:\n alley: hunter2\n") self._write_secrets("FRIGATE_CAMS:\n alley: hunter2\n")
with self.assertLogs("frigate.config.env", level="ERROR") as logs: with self.assertLogs("frigate.util.env", level="ERROR") as logs:
env.reload_sources() env.reload_sources()
self.assertIn("FRIGATE_CAMS", logs.output[0]) self.assertIn("FRIGATE_CAMS", logs.output[0])
@@ -505,7 +506,7 @@ class TestSourceRobustness(unittest.TestCase):
def test_duplicate_key_error_does_not_log_the_values(self): def test_duplicate_key_error_does_not_log_the_values(self):
"""ruamel's duplicate key message quotes both values; the log must not.""" """ruamel's duplicate key message quotes both values; the log must not."""
self._write_secrets("FRIGATE_CAM_PASS: hunter2\nFRIGATE_CAM_PASS: hunter3\n") self._write_secrets("FRIGATE_CAM_PASS: hunter2\nFRIGATE_CAM_PASS: hunter3\n")
with self.assertLogs("frigate.config.env", level="ERROR") as logs: with self.assertLogs("frigate.util.env", level="ERROR") as logs:
env.reload_sources() env.reload_sources()
self.assertNotIn("hunter2", logs.output[0]) self.assertNotIn("hunter2", logs.output[0])
@@ -535,7 +536,7 @@ class TestSourceRobustness(unittest.TestCase):
with open(os.path.join(self._creds.name, "FRIGATE_CRED"), "w") as f: with open(os.path.join(self._creds.name, "FRIGATE_CRED"), "w") as f:
f.write("from_creds\n") f.write("from_creds\n")
with self.assertLogs("frigate.config.env", level="WARNING") as logs: with self.assertLogs("frigate.util.env", level="WARNING") as logs:
env.reload_sources() env.reload_sources()
self.assertIn("FRIGATE_NOT_A_FILE", logs.output[0]) self.assertIn("FRIGATE_NOT_A_FILE", logs.output[0])
+11
View File
@@ -653,6 +653,17 @@ class TestStagedFileCleanup(unittest.TestCase):
self.assertEqual(os.listdir(tmpdir), []) self.assertEqual(os.listdir(tmpdir), [])
def test_unwritable_staging_directory_fails_the_run(self) -> None:
"""A raise here would skip the failed-export cleanup."""
with tempfile.TemporaryDirectory() as tmpdir:
exporter = self._exporter(tmpdir)
runs = [StreamRun("main", 1_000, 1_020, "/m1.mp4")]
with patch(
"frigate.record.export.os.makedirs", side_effect=OSError("no space")
):
self.assertFalse(exporter._stage_stream_runs(runs, {"h264"}, False))
class TestStagingFailure(unittest.TestCase): class TestStagingFailure(unittest.TestCase):
def test_failed_staging_aborts_rather_than_falling_back(self) -> None: def test_failed_staging_aborts_rather_than_falling_back(self) -> None:
+41
View File
@@ -0,0 +1,41 @@
import unittest
import cv2
import numpy as np
from frigate.util.image import get_histogram
def reference_histogram(image, x_min, y_min, x_max, y_max):
bgr = cv2.cvtColor(image, cv2.COLOR_YUV2BGR_I420)[y_min:y_max, x_min:x_max]
hist = cv2.calcHist([bgr], [0, 1, 2], None, [8, 8, 8], [0, 256, 0, 256, 0, 256])
return cv2.normalize(hist, hist).flatten()
class TestGetHistogram(unittest.TestCase):
def setUp(self):
rng = np.random.default_rng(0)
self.frame = rng.integers(0, 255, (720 * 3 // 2, 1280), np.uint8)
def test_matches_full_frame_conversion_on_even_box(self):
box = (100, 200, 400, 600)
np.testing.assert_array_equal(
get_histogram(self.frame, *box), reference_histogram(self.frame, *box)
)
def test_odd_box_widens_to_even_edges(self):
np.testing.assert_array_equal(
get_histogram(self.frame, 101, 201, 399, 599),
reference_histogram(self.frame, 100, 200, 400, 600),
)
def test_box_is_clamped_to_frame(self):
np.testing.assert_array_equal(
get_histogram(self.frame, -10, -10, 5000, 5000),
reference_histogram(self.frame, 0, 0, 1280, 720),
)
def test_empty_box_returns_zeros(self):
hist = get_histogram(self.frame, 50, 50, 50, 50)
self.assertEqual(hist.shape, (512,))
self.assertEqual(hist.sum(), 0)
+146
View File
@@ -0,0 +1,146 @@
"""Tracker selection when autotracking config changes at runtime."""
import unittest
from unittest.mock import MagicMock
import numpy as np
from frigate.camera import PTZMetrics
from frigate.config import FrigateConfig
from frigate.track.norfair_tracker import NorfairTracker
CAMERA = "ptz_cam"
BOX = (400, 200, 500, 500)
def _config(enabled: bool, track: list[str] | None = None) -> FrigateConfig:
autotracking: dict = {"enabled": enabled, "required_zones": ["zone"]}
if track is not None:
autotracking["track"] = track
return FrigateConfig(
**{
"mqtt": {"enabled": False},
"cameras": {
CAMERA: {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {"width": 1280, "height": 720},
"zones": {"zone": {"coordinates": "0,0,1,0,1,1,0,1"}},
"onvif": {"host": "10.0.0.1", "autotracking": autotracking},
}
},
}
)
class TestTrackerSelection(unittest.TestCase):
def setUp(self) -> None:
self.frame_time = 1000.0
def make_tracker(self, enabled: bool) -> NorfairTracker:
camera_config = _config(enabled).cameras[CAMERA]
tracker = NorfairTracker(camera_config, PTZMetrics())
tracker.frame_manager = MagicMock()
tracker.frame_manager.get.return_value = np.zeros(
camera_config.frame_shape_yuv, dtype=np.uint8
)
tracker.ptz_motion_estimator = MagicMock()
tracker.ptz_motion_estimator.motion_estimator.return_value = None
return tracker
def apply_onvif_update(self, tracker: NorfairTracker, config: FrigateConfig):
"""Apply an onvif config update the way the camera process does."""
tracker.camera_config.onvif = config.cameras[CAMERA].onvif
tracker.sync_trackers()
def run_frames(self, tracker: NorfairTracker, count: int, label="person"):
for _ in range(count):
self.frame_time += 0.2
tracker.match_and_update(
"frame",
self.frame_time,
[(label, 0.9, BOX, 30000, 0.33, (0, 0, 640, 640))],
)
def test_disabling_autotracking_falls_back_to_static_tracker(self):
tracker = self.make_tracker(enabled=True)
self.run_frames(tracker, 10)
self.assertIs(tracker.get_tracker("person"), tracker.trackers["person"]["ptz"])
self.apply_onvif_update(tracker, _config(enabled=False))
self.run_frames(tracker, 10)
self.assertIs(tracker.get_tracker("person"), tracker.default_tracker["static"])
self.assertEqual(len(tracker.tracked_objects), 1)
def test_enabling_autotracking_uses_ptz_tracker(self):
tracker = self.make_tracker(enabled=False)
self.run_frames(tracker, 10)
self.assertIs(tracker.get_tracker("person"), tracker.default_tracker["static"])
self.apply_onvif_update(tracker, _config(enabled=True))
self.run_frames(tracker, 10)
self.assertIs(tracker.get_tracker("person"), tracker.trackers["person"]["ptz"])
self.assertEqual(len(tracker.tracked_objects), 1)
def test_removing_label_from_autotracking_keeps_tracking(self):
tracker = self.make_tracker(enabled=True)
self.run_frames(tracker, 10)
self.apply_onvif_update(tracker, _config(enabled=True, track=["car"]))
self.run_frames(tracker, 10)
self.assertNotIn("person", tracker.trackers)
self.assertIs(tracker.get_tracker("person"), tracker.default_tracker["ptz"])
self.assertEqual(len(tracker.tracked_objects), 1)
def test_unrelated_onvif_update_keeps_objects(self):
tracker = self.make_tracker(enabled=True)
self.run_frames(tracker, 10)
ids = set(tracker.tracked_objects)
config = _config(enabled=True)
config.cameras[CAMERA].onvif.password = "changed"
self.apply_onvif_update(tracker, config)
self.run_frames(tracker, 5)
self.assertEqual(set(tracker.tracked_objects), ids)
def test_track_list_edit_while_disabled_keeps_objects(self):
tracker = self.make_tracker(enabled=False)
self.run_frames(tracker, 10)
ids = set(tracker.tracked_objects)
self.apply_onvif_update(tracker, _config(enabled=False, track=["car"]))
self.run_frames(tracker, 5)
self.assertEqual(set(tracker.tracked_objects), ids)
def test_runtime_toggle_keeps_objects(self):
# MQTT and the autotracker only flip enabled, never enabled_in_config
tracker = self.make_tracker(enabled=True)
self.run_frames(tracker, 10)
ids = set(tracker.tracked_objects)
tracker.camera_config.onvif.autotracking.enabled = False
tracker.sync_trackers()
self.run_frames(tracker, 5)
self.assertEqual(set(tracker.tracked_objects), ids)
def test_unlisted_label_uses_the_default_tracker_that_holds_it(self):
tracker = self.make_tracker(enabled=True)
self.run_frames(tracker, 10, label="dog")
default = tracker.get_tracker("dog")
self.assertIs(default, tracker.default_tracker["ptz"])
self.assertEqual(
{str(o.global_id) for o in default.tracked_objects},
set(tracker.track_id_map),
)
+12 -6
View File
@@ -3,7 +3,7 @@ from unittest.mock import MagicMock, Mock, patch
import numpy as np import numpy as np
import zmq import zmq
from pydantic import parse_obj_as from pydantic import TypeAdapter
import frigate.detectors as detectors import frigate.detectors as detectors
import frigate.object_detection.base import frigate.object_detection.base
@@ -20,8 +20,8 @@ class TestLocalObjectDetector(unittest.TestCase):
"frigate.detectors.api_types", "frigate.detectors.api_types",
{det_type: Mock() for det_type in DetectorTypeEnum}, {det_type: Mock() for det_type in DetectorTypeEnum},
): ):
test_cfg = parse_obj_as( test_cfg = TypeAdapter(DetectorConfig).validate_python(
DetectorConfig, ({"type": det_type, "model": {}}) {"type": det_type, "model": {}}
) )
test_cfg.model.path = "/test/modelpath" test_cfg.model.path = "/test/modelpath"
test_obj = frigate.object_detection.base.LocalObjectDetector( test_obj = frigate.object_detection.base.LocalObjectDetector(
@@ -45,7 +45,9 @@ class TestLocalObjectDetector(unittest.TestCase):
TEST_DATA = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] TEST_DATA = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
TEST_DETECT_RESULT = np.ndarray([1, 2, 4, 8, 16, 32]) TEST_DETECT_RESULT = np.ndarray([1, 2, 4, 8, 16, 32])
test_obj_detect = frigate.object_detection.base.LocalObjectDetector( test_obj_detect = frigate.object_detection.base.LocalObjectDetector(
detector_config=parse_obj_as(DetectorConfig, {"type": "cpu", "model": {}}) detector_config=TypeAdapter(DetectorConfig).validate_python(
{"type": "cpu", "model": {}}
)
) )
mock_det_api = mock_cputfl.return_value mock_det_api = mock_cputfl.return_value
@@ -68,7 +70,9 @@ class TestLocalObjectDetector(unittest.TestCase):
TEST_DATA = np.zeros((1, 32, 32, 3), np.uint8) TEST_DATA = np.zeros((1, 32, 32, 3), np.uint8)
TEST_DETECT_RESULT = np.ndarray([1, 2, 4, 8, 16, 32]) TEST_DETECT_RESULT = np.ndarray([1, 2, 4, 8, 16, 32])
test_cfg = parse_obj_as(DetectorConfig, {"type": "cpu", "model": {}}) test_cfg = TypeAdapter(DetectorConfig).validate_python(
{"type": "cpu", "model": {}}
)
test_cfg.model.input_tensor = InputTensorEnum.nchw test_cfg.model.input_tensor = InputTensorEnum.nchw
test_obj_detect = frigate.object_detection.base.LocalObjectDetector( test_obj_detect = frigate.object_detection.base.LocalObjectDetector(
@@ -117,7 +121,9 @@ class TestLocalObjectDetector(unittest.TestCase):
4: "label-5", 4: "label-5",
} }
test_cfg = parse_obj_as(DetectorConfig, {"type": "cpu", "model": {}}) test_cfg = TypeAdapter(DetectorConfig).validate_python(
{"type": "cpu", "model": {}}
)
test_cfg.model = ModelConfig() test_cfg.model = ModelConfig()
test_obj_detect = frigate.object_detection.base.LocalObjectDetector( test_obj_detect = frigate.object_detection.base.LocalObjectDetector(
detector_config=test_cfg, detector_config=test_cfg,
+1 -1
View File
@@ -4,7 +4,7 @@ from pydantic import ValidationError
from frigate.api.auth import resolve_role from frigate.api.auth import resolve_role
from frigate.config import AuthConfig, HeaderMappingConfig, ProxyConfig from frigate.config import AuthConfig, HeaderMappingConfig, ProxyConfig
from frigate.config.env import FRIGATE_ENV_VARS from frigate.util.env import FRIGATE_ENV_VARS
class TestProxyRoleResolution(unittest.TestCase): class TestProxyRoleResolution(unittest.TestCase):
+17
View File
@@ -13,6 +13,7 @@ from frigate.models import Recordings
from frigate.util.recording_coverage import ( from frigate.util.recording_coverage import (
_rows_query, _rows_query,
coverage_spans, coverage_spans,
null_audio_glitches,
plan_clip, plan_clip,
realized_timeline, realized_timeline,
resolve_coverage, resolve_coverage,
@@ -166,6 +167,22 @@ class TestRecordingCoverage(CoverageDbTestCase):
expected = int(5 * 1024 * 1024 * 8 / 10) expected = int(5 * 1024 * 1024 * 8 / 10)
assert summary["main"]["bitrate"] == expected assert summary["main"]["bitrate"] == expected
def test_unknown_audio_row_keeps_video_only_stream(self):
self._insert("s1", 1000.0, 1010.0, "sub", has_audio=False)
self._insert("s2", 1010.0, 1020.0, "sub", has_audio=None)
self._insert("s3", 1020.0, 1030.0, "sub", has_audio=False)
kept = null_audio_glitches(resolve_coverage("front_door", 1000.0, 1030.0))
self.assertEqual(
[i.sub.path for i in kept], [f"/tmp/s{n}.mp4" for n in (1, 2, 3)]
)
def test_video_only_glitch_dropped_on_audio_stream(self):
self._insert("s1", 1000.0, 1010.0, "sub", has_audio=True)
self._insert("s2", 1010.0, 1020.0, "sub", has_audio=False)
self._insert("s3", 1020.0, 1030.0, "sub", has_audio=None)
kept = null_audio_glitches(resolve_coverage("front_door", 1000.0, 1030.0))
self.assertEqual([i.sub.path for i in kept], ["/tmp/s1.mp4", "/tmp/s3.mp4"])
def test_other_camera_rows_excluded(self): def test_other_camera_rows_excluded(self):
self._insert("m1", 1000.0, 1010.0, "main") self._insert("m1", 1000.0, 1010.0, "main")
self._insert("o1", 1000.0, 1010.0, "main", camera="back_yard") self._insert("o1", 1000.0, 1010.0, "main", camera="back_yard")
+2 -2
View File
@@ -74,8 +74,8 @@ class ReviewFlowTestCase(unittest.TestCase):
maintainer.config_subscriber.check_for_updates.return_value = {} maintainer.config_subscriber.check_for_updates.return_value = {}
maintainer.detection_subscriber = MagicMock() maintainer.detection_subscriber = MagicMock()
maintainer.frame_manager = MagicMock() maintainer.frame_manager = MagicMock()
maintainer.frame_manager.get.side_effect = lambda _name, shape: np.zeros( maintainer.frame_manager.get.side_effect = lambda _name, shape, _t=None: (
shape, np.uint8 np.zeros(shape, np.uint8)
) )
return maintainer return maintainer
+41
View File
@@ -98,6 +98,47 @@ class TestLoadRegistry(unittest.TestCase):
self.assertIsInstance(runner, ONNXModelRunner) self.assertIsInstance(runner, ONNXModelRunner)
self.assertEqual(loaded_devices["/models/arcface.onnx"], ("arcface", "CPU")) self.assertEqual(loaded_devices["/models/arcface.onnx"], ("arcface", "CPU"))
def test_only_cpu_enrichment_sessions_disable_spinning(self):
for provider, expected in [
("CPUExecutionProvider", "0"),
("CUDAExecutionProvider", None),
]:
with (
self.subTest(provider=provider),
patch.object(
detection_runners, "is_rknn_compatible", return_value=False
),
patch.object(
detection_runners,
"get_ort_providers",
return_value=([provider], [{"device_id": 0}]),
),
patch.object(
detection_runners,
"is_openvino_gpu_npu_available",
return_value=False,
),
patch.object(
detection_runners, "get_lighter_ane_devices", return_value=[]
),
patch.object(detection_runners.ort, "InferenceSession") as session,
):
session.return_value.get_providers.return_value = [provider]
# paddleocr skips the CUDA graph runner, which takes no options
get_optimized_runner("/models/paddleocr.onnx", "GPU", "paddleocr")
options = session.call_args.kwargs["sess_options"]
try:
spinning = options.get_session_config_entry(
"session.intra_op.allow_spinning"
)
except RuntimeError:
spinning = None
self.assertEqual(spinning, expected)
class TestLoadedDeviceSnapshot(unittest.TestCase): class TestLoadedDeviceSnapshot(unittest.TestCase):
def setUp(self): def setUp(self):
@@ -6,13 +6,20 @@ in-process cache (e.g. TrackedObjectProcessor) still holds a ref to
the old, smaller segment. the old, smaller segment.
""" """
import queue
import threading
import unittest import unittest
from multiprocessing import Event, Value
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import MagicMock, patch
import numpy as np import numpy as np
from frigate.embeddings.maintainer import EmbeddingMaintainer
from frigate.events.types import EventStateEnum, EventTypeEnum
from frigate.track.object_processing import TrackedObjectProcessor
from frigate.util.image import SharedMemoryFrameManager from frigate.util.image import SharedMemoryFrameManager
from frigate.video.ffmpeg import capture_frames
def _fake_shm(size: int) -> SimpleNamespace: def _fake_shm(size: int) -> SimpleNamespace:
@@ -152,5 +159,319 @@ class TestSharedMemoryFrameManagerGetRecreatesLargerSegment(unittest.TestCase):
self.assertEqual(arr_large.shape, (1620, 1920)) self.assertEqual(arr_large.shape, (1620, 1920))
def _segments(**sizes: int) -> dict[str, SimpleNamespace]:
return {name: _fake_shm(size) for name, size in sizes.items()}
def _open_from(segments: dict[str, SimpleNamespace]):
def open_segment(name: str, **_kwargs):
if name not in segments:
raise FileNotFoundError(name)
return segments[name]
return open_segment
class TestSharedMemoryFrameManagerFrameTimes(unittest.TestCase):
def test_frame_times_is_float64_view_over_ledger_segment(self) -> None:
manager = SharedMemoryFrameManager()
segments = _segments(cam_frame_times=8 * 3)
with patch(
"frigate.util.image.UntrackedSharedMemory",
side_effect=_open_from(segments),
):
times = manager.frame_times("cam")
self.assertEqual(times.dtype, np.float64)
self.assertEqual(times.shape, (3,))
times[1] = 12.5
self.assertEqual(
np.frombuffer(segments["cam_frame_times"].buf, dtype=np.float64)[1], 12.5
)
def test_frame_times_returns_none_when_ledger_missing(self) -> None:
manager = SharedMemoryFrameManager()
with patch(
"frigate.util.image.UntrackedSharedMemory", side_effect=FileNotFoundError
):
self.assertIsNone(manager.frame_times("cam"))
def test_get_returns_frame_when_ledger_matches(self) -> None:
manager = SharedMemoryFrameManager()
segments = _segments(cam_frame0=2_500, cam_frame_times=8 * 2)
np.frombuffer(segments["cam_frame_times"].buf, dtype=np.float64)[0] = 100.25
with patch(
"frigate.util.image.UntrackedSharedMemory",
side_effect=_open_from(segments),
):
arr = manager.get("cam_frame0", (50, 50), frame_time=100.25)
self.assertIsNotNone(arr)
self.assertEqual(arr.shape, (50, 50))
def test_get_returns_none_when_slot_was_overwritten(self) -> None:
manager = SharedMemoryFrameManager()
segments = _segments(cam_frame0=2_500, cam_frame_times=8 * 2)
np.frombuffer(segments["cam_frame_times"].buf, dtype=np.float64)[0] = 200.0
with (
patch(
"frigate.util.image.UntrackedSharedMemory",
side_effect=_open_from(segments),
),
self.assertLogs("frigate.util.image", "DEBUG") as logs,
):
arr = manager.get("cam_frame0", (50, 50), frame_time=100.25)
self.assertIsNone(arr)
self.assertIn(
"cam_frame0 no longer holds the frame from 100.25", logs.output[0]
)
def test_get_returns_none_when_slot_index_is_beyond_ledger(self) -> None:
manager = SharedMemoryFrameManager()
segments = _segments(cam_frame5=2_500, cam_frame_times=8 * 2)
with patch(
"frigate.util.image.UntrackedSharedMemory",
side_effect=_open_from(segments),
):
arr = manager.get("cam_frame5", (50, 50), frame_time=100.25)
self.assertIsNone(arr)
def test_get_reopens_every_cached_slot_when_ledger_is_stale(self) -> None:
"""A camera recreated at the same resolution gets new segments under
the old names. A long-lived reader still mapped to the unlinked ledger
sees a mismatch, which must drop every cached slot of that camera, not
only the one being read, or later reads return the old pixels."""
manager = SharedMemoryFrameManager()
stale = _segments(cam_frame0=2_500, cam_frame1=2_500, cam_frame_times=8 * 2)
manager.shm_store.update(stale)
current = _segments(cam_frame0=2_500, cam_frame1=2_500, cam_frame_times=8 * 2)
current["cam_frame1"].buf[0] = 7
np.frombuffer(current["cam_frame_times"].buf, dtype=np.float64)[:] = (
100.25,
100.5,
)
with patch(
"frigate.util.image.UntrackedSharedMemory",
side_effect=_open_from(current),
):
first = manager.get("cam_frame0", (50, 50), frame_time=100.25)
second = manager.get("cam_frame1", (50, 50), frame_time=100.5)
self.assertIsNotNone(first)
self.assertEqual(second[0, 0], 7)
self.assertIs(manager.shm_store["cam_frame1"], current["cam_frame1"])
def test_get_keeps_cached_slots_when_only_lapped(self) -> None:
manager = SharedMemoryFrameManager()
segments = _segments(cam_frame0=2_500, cam_frame1=2_500, cam_frame_times=8 * 2)
manager.shm_store.update(segments)
np.frombuffer(segments["cam_frame_times"].buf, dtype=np.float64)[0] = 200.0
with patch(
"frigate.util.image.UntrackedSharedMemory",
side_effect=_open_from(segments),
):
arr = manager.get("cam_frame0", (50, 50), frame_time=100.25)
self.assertIsNone(arr)
self.assertIs(manager.shm_store["cam_frame1"], segments["cam_frame1"])
def test_close_camera_drops_only_that_camera(self) -> None:
manager = SharedMemoryFrameManager()
manager.shm_store.update(
_segments(cam_frame0=1, cam_frame1=1, cam_frame_times=8, other_frame0=1)
)
# a camera named cam_frame shares the cam_frame prefix
manager.shm_store.update(
{"cam_frame_frame0": _fake_shm(1), "cam_frame_frame_times": _fake_shm(8)}
)
manager.close_camera("cam")
self.assertEqual(
set(manager.shm_store),
{"other_frame0", "cam_frame_frame0", "cam_frame_frame_times"},
)
def test_get_without_frame_time_skips_ledger(self) -> None:
manager = SharedMemoryFrameManager()
segments = _segments(cam_frame0=2_500)
with patch(
"frigate.util.image.UntrackedSharedMemory",
side_effect=_open_from(segments),
):
arr = manager.get("cam_frame0", (50, 50))
self.assertIsNotNone(arr)
self.assertNotIn("cam_frame_times", manager.shm_store)
class TestCaptureFrames(unittest.TestCase):
"""capture_frames stamps the ledger and only advances the slot index
once a frame has been handed to the queue."""
def _run(self, frames: list[bytes], put_results: list[Exception | None]):
frame_size = 4
slots = {f"cam_frame{i}": bytearray(frame_size) for i in range(3)}
times = np.zeros(3, dtype=np.float64)
frame_manager = MagicMock()
frame_manager.write.side_effect = lambda name: memoryview(slots[name])
frame_manager.frame_times.return_value = times
stop_event = Event()
reads = iter(frames)
self.times_during_read: list[np.ndarray] = []
def readinto(buffer):
self.times_during_read.append(times.copy())
try:
frame = next(reads)
except StopIteration:
stop_event.set()
return 0
buffer[:] = frame
return len(frame)
ffmpeg_process = MagicMock()
ffmpeg_process.stdout.readinto.side_effect = readinto
ffmpeg_process.poll.return_value = None
frame_queue = MagicMock()
frame_queue.put.side_effect = put_results
config = SimpleNamespace(name="cam", enabled=True)
capture_frames(
ffmpeg_process,
config,
3,
0,
(2, 2),
frame_manager,
frame_queue,
Value("d", 0.0),
Value("d", 0.0),
Value("d", 0.0),
stop_event,
)
return slots, times, [c.args[0] for c in frame_queue.put.call_args_list]
def test_advances_slot_and_stamps_ledger_on_success(self) -> None:
slots, times, puts = self._run([b"aaaa", b"bbbb"], [None, None])
self.assertEqual([name for name, _ in puts], ["cam_frame0", "cam_frame1"])
self.assertEqual(bytes(slots["cam_frame0"]), b"aaaa")
self.assertEqual(bytes(slots["cam_frame1"]), b"bbbb")
self.assertEqual(times[0], puts[0][1])
self.assertEqual(times[1], puts[1][1])
self.assertGreater(times[0], 0)
def test_reuses_slot_when_queue_is_full(self) -> None:
slots, times, puts = self._run(
[b"aaaa", b"bbbb", b"cccc"], [None, queue.Full(), None]
)
self.assertEqual(
[name for name, _ in puts], ["cam_frame0", "cam_frame1", "cam_frame1"]
)
self.assertEqual(bytes(slots["cam_frame1"]), b"cccc")
self.assertEqual(bytes(slots["cam_frame2"]), bytes(4))
self.assertEqual(times[1], puts[2][1])
self.assertEqual(times[2], 0)
def test_slot_never_matches_while_it_is_being_filled(self) -> None:
_, _, puts = self._run([b"aaaa", b"bbbb"], [queue.Full(), None])
# the second read refills slot 0, which the first frame had stamped
self.assertEqual([name for name, _ in puts], ["cam_frame0", "cam_frame0"])
self.assertEqual(self.times_during_read[1][0], 0)
class TestTrackedObjectEventsCarryFrameTime(unittest.TestCase):
"""Tracked object events carry the time of the frame they name, which can
differ from the object's own frame_time when it wasn't detected in that
frame."""
def _callbacks(self) -> tuple[dict, MagicMock]:
processor = TrackedObjectProcessor.__new__(TrackedObjectProcessor)
processor.config = SimpleNamespace(cameras={"cam": SimpleNamespace(name="cam")})
processor.event_sender = MagicMock()
processor.dispatcher = MagicMock()
processor.ptz_autotracker_thread = MagicMock()
processor.camera_states = {}
processor.camera_states_lock = threading.Lock()
processor.frame_manager = MagicMock()
processor.should_save_snapshot = MagicMock(return_value=False)
processor.should_retain_recording = MagicMock(return_value=False)
callbacks: dict = {}
with patch("frigate.track.object_processing.CameraState") as state_cls:
state_cls.return_value.on.side_effect = lambda name, fn: callbacks.update(
{name: fn}
)
processor.create_camera_state("cam")
return callbacks, processor.event_sender
def test_start_update_end_publish_frame_time(self) -> None:
callbacks, sender = self._callbacks()
obj = MagicMock()
obj.to_dict.return_value = {"id": "1", "frame_time": 10.0}
obj.obj_data = {"frame_time": 10.0}
obj.previous = {"false_positive": True}
obj.false_positive = True
callbacks["start"]("cam", obj, "cam_frame3", 12.5)
callbacks["update"]("cam", obj, "cam_frame4", 13.0)
callbacks["end"]("cam", obj, "cam_frame5", 13.5)
payloads = [c.args[0] for c in sender.publish.call_args_list]
self.assertEqual(
[(p[1], p[3], p[5]) for p in payloads],
[
(EventStateEnum.start, "cam_frame3", 12.5),
(EventStateEnum.update, "cam_frame4", 13.0),
(EventStateEnum.end, "cam_frame5", 13.5),
],
)
class TestEmbeddingsObjectUpdateVerifiesFrame(unittest.TestCase):
def test_object_update_reads_frame_at_event_frame_time(self) -> None:
maintainer = EmbeddingMaintainer.__new__(EmbeddingMaintainer)
maintainer.config = SimpleNamespace(
cameras={"cam": SimpleNamespace(frame_shape_yuv=(6, 4))},
semantic_search=SimpleNamespace(enabled=False),
)
maintainer.event_subscriber = MagicMock()
maintainer.event_subscriber.check_for_update.return_value = (
EventTypeEnum.tracked_object,
EventStateEnum.update,
"cam",
"cam_frame4",
{"id": "1", "label": "person", "frame_time": 10.0},
13.0,
)
maintainer.realtime_processors = [MagicMock()]
maintainer.post_processors = []
maintainer.frame_manager = MagicMock()
maintainer._process_updates()
maintainer.frame_manager.get.assert_called_once_with("cam_frame4", (6, 4), 13.0)
maintainer.frame_manager.close.assert_not_called()
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+20
View File
@@ -0,0 +1,20 @@
import unittest
from frigate.util.time import get_normalized_tz_name
class TestGetNormalizedTzName(unittest.TestCase):
def test_valid(self):
cases = [
("utc", "UTC"),
("america/new_york", "America/New_York"),
("us/eastern", "US/Eastern"),
]
for tz_name, expected in cases:
with self.subTest(tz_name=tz_name):
self.assertEqual(get_normalized_tz_name(tz_name), expected)
def test_invalid(self):
with self.assertRaisesRegex(ValueError, r"Unknown timezone: foo/bar"):
get_normalized_tz_name("foo/bar")
+86 -57
View File
@@ -180,48 +180,9 @@ class NorfairTracker(ObjectTracker):
} }
self.trackers: dict[str, dict[str, Tracker]] = {} self.trackers: dict[str, dict[str, Tracker]] = {}
# Handle static trackers self.default_tracker: dict[str, Tracker] = {}
for obj_type, tracker_config in self.object_type_configs.items(): self.tracker_selection: tuple[bool | None, tuple[str, ...]] | None = None
if obj_type in self.camera_config.objects.track: self.sync_trackers()
if obj_type not in self.trackers:
self.trackers[obj_type] = {}
self.trackers[obj_type]["static"] = self._create_tracker(
obj_type, tracker_config
)
# Handle PTZ trackers
for obj_type, tracker_config in self.ptz_object_type_configs.items():
if (
obj_type in self.camera_config.onvif.autotracking.track
and self.camera_config.onvif.autotracking.enabled_in_config
):
if obj_type not in self.trackers:
self.trackers[obj_type] = {}
self.trackers[obj_type]["ptz"] = self._create_tracker(
obj_type, tracker_config
)
# Initialize default trackers
self.default_tracker = {
"static": Tracker(
distance_function=frigate_distance,
distance_threshold=self.default_tracker_config[ # type: ignore[arg-type]
"distance_threshold"
],
initialization_delay=self.detect_config.min_initialized,
hit_counter_max=self.detect_config.max_disappeared, # type: ignore[arg-type]
filter_factory=self.default_tracker_config["filter_factory"], # type: ignore[arg-type]
),
"ptz": Tracker(
distance_function=frigate_distance,
distance_threshold=self.default_ptz_tracker_config[
"distance_threshold"
], # type: ignore[arg-type]
initialization_delay=self.detect_config.min_initialized,
hit_counter_max=self.detect_config.max_disappeared, # type: ignore[arg-type]
filter_factory=self.default_ptz_tracker_config["filter_factory"], # type: ignore[arg-type]
),
}
if self.camera_config.onvif.autotracking.enabled: if self.camera_config.onvif.autotracking.enabled:
self.ptz_motion_estimator = PtzMotionEstimator( self.ptz_motion_estimator = PtzMotionEstimator(
@@ -257,18 +218,91 @@ class NorfairTracker(ObjectTracker):
return Tracker(**tracker_params) return Tracker(**tracker_params)
def get_tracker(self, object_type: str) -> Tracker: def _tracker_selection(self) -> tuple[bool | None, tuple[str, ...]]:
"""Get the appropriate tracker based on object type and camera mode.""" autotracking = self.camera_config.onvif.autotracking
mode = (
if not autotracking.enabled_in_config:
return (False, ())
ptz_labels = tuple(
label
for label in self.ptz_object_type_configs
if label in autotracking.track
)
return (True, ptz_labels)
def sync_trackers(self) -> None:
"""Rebuild the trackers when the config that selects them has changed.
The camera process receives onvif config updates at runtime, so which
labels use a PTZ tracker can change after startup. Rebuilding drops
norfair state, so an update that leaves the selection alone is a no-op.
"""
selection = self._tracker_selection()
if selection == self.tracker_selection:
return
if self.tracker_selection is not None:
logger.debug(
"%s: autotracking changed, rebuilding trackers", self.camera_name
)
self.tracker_selection = selection
ptz_enabled, ptz_labels = selection
self.trackers = {}
for obj_type, tracker_config in self.object_type_configs.items():
if obj_type in self.camera_config.objects.track:
self.trackers.setdefault(obj_type, {})["static"] = self._create_tracker(
obj_type, tracker_config
)
if ptz_enabled:
for obj_type, tracker_config in self.ptz_object_type_configs.items():
if obj_type in ptz_labels:
self.trackers.setdefault(obj_type, {})["ptz"] = (
self._create_tracker(obj_type, tracker_config)
)
self.default_tracker = {
"static": Tracker(
distance_function=frigate_distance,
distance_threshold=self.default_tracker_config[ # type: ignore[arg-type]
"distance_threshold"
],
initialization_delay=self.detect_config.min_initialized,
hit_counter_max=self.detect_config.max_disappeared, # type: ignore[arg-type]
filter_factory=self.default_tracker_config["filter_factory"], # type: ignore[arg-type]
),
"ptz": Tracker(
distance_function=frigate_distance,
distance_threshold=self.default_ptz_tracker_config[
"distance_threshold"
], # type: ignore[arg-type]
initialization_delay=self.detect_config.min_initialized,
hit_counter_max=self.detect_config.max_disappeared, # type: ignore[arg-type]
filter_factory=self.default_ptz_tracker_config["filter_factory"], # type: ignore[arg-type]
),
}
def _default_mode(self) -> str:
"""Pick the default tracker from saved config, not the runtime toggle."""
return (
"ptz" "ptz"
if self.camera_config.onvif.autotracking.enabled_in_config if self.camera_config.onvif.autotracking.enabled_in_config
and object_type in self.camera_config.onvif.autotracking.track
and object_type in self.ptz_object_type_configs.keys()
else "static" else "static"
) )
if object_type in self.trackers:
return self.trackers[object_type][mode] def get_tracker(self, object_type: str) -> Tracker:
return self.default_tracker[mode] """Get the tracker that match_and_update feeds this label's detections to."""
trackers = self.trackers.get(object_type)
if trackers is None:
return self.default_tracker[self._default_mode()]
# sync_trackers only creates a ptz tracker for labels the config selects
return trackers["ptz"] if "ptz" in trackers else trackers["static"]
def register(self, track_id: str, obj: dict[str, Any]) -> None: def register(self, track_id: str, obj: dict[str, Any]) -> None:
rand_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6)) rand_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
@@ -534,7 +568,7 @@ class NorfairTracker(ObjectTracker):
points = np.array([[obj[2][0], obj[2][1]], [obj[2][2], obj[2][3]]]) points = np.array([[obj[2][0], obj[2][1]], [obj[2][2], obj[2][3]]])
embedding = None embedding = None
if self.camera_config.onvif.autotracking.enabled: if self.camera_config.onvif.autotracking.enabled and yuv_frame is not None:
embedding = get_histogram( embedding = get_histogram(
yuv_frame, obj[2][0], obj[2][1], obj[2][2], obj[2][3] yuv_frame, obj[2][0], obj[2][1], obj[2][2], obj[2][3]
) )
@@ -587,12 +621,7 @@ class NorfairTracker(ObjectTracker):
default_detections.extend(dets) default_detections.extend(dets)
# Update default tracker with untracked detections # Update default tracker with untracked detections
mode = ( tracked_objects = self.default_tracker[self._default_mode()].update(
"ptz"
if self.camera_config.onvif.autotracking.enabled_in_config
else "static"
)
tracked_objects = self.default_tracker[mode].update(
detections=default_detections, coord_transformations=coord_transformations detections=default_detections, coord_transformations=coord_transformations
) )
all_tracked_objects.extend(tracked_objects) all_tracked_objects.extend(tracked_objects)
+21 -5
View File
@@ -118,7 +118,9 @@ class TrackedObjectProcessor(threading.Thread):
def create_camera_state(self, camera: str) -> None: def create_camera_state(self, camera: str) -> None:
"""Creates a new camera state.""" """Creates a new camera state."""
def start(camera: str, obj: TrackedObject, frame_name: str) -> None: def start(
camera: str, obj: TrackedObject, frame_name: str, frame_time: float
) -> None:
self.event_sender.publish( self.event_sender.publish(
( (
EventTypeEnum.tracked_object, EventTypeEnum.tracked_object,
@@ -126,10 +128,13 @@ class TrackedObjectProcessor(threading.Thread):
camera, camera,
frame_name, frame_name,
obj.to_dict(), obj.to_dict(),
frame_time,
) )
) )
def update(camera: str, obj: TrackedObject, frame_name: str) -> None: def update(
camera: str, obj: TrackedObject, frame_name: str, frame_time: float
) -> None:
obj.has_snapshot = self.should_save_snapshot( obj.has_snapshot = self.should_save_snapshot(
camera_state.camera_config, obj camera_state.camera_config, obj
) )
@@ -149,13 +154,18 @@ class TrackedObjectProcessor(threading.Thread):
camera, camera,
frame_name, frame_name,
obj.to_dict(), obj.to_dict(),
frame_time,
) )
) )
def autotrack(camera: str, obj: TrackedObject, frame_name: str) -> None: def autotrack(
camera: str, obj: TrackedObject, frame_name: str, frame_time: float
) -> None:
self.ptz_autotracker_thread.autotrack_object(camera, obj) self.ptz_autotracker_thread.autotrack_object(camera, obj)
def end(camera: str, obj: TrackedObject, frame_name: str) -> None: def end(
camera: str, obj: TrackedObject, frame_name: str, frame_time: float
) -> None:
# populate has_snapshot # populate has_snapshot
obj.has_snapshot = self.should_save_snapshot( obj.has_snapshot = self.should_save_snapshot(
camera_state.camera_config, obj camera_state.camera_config, obj
@@ -186,6 +196,7 @@ class TrackedObjectProcessor(threading.Thread):
camera, camera,
frame_name, frame_name,
obj.to_dict(), obj.to_dict(),
frame_time,
) )
) )
@@ -599,6 +610,7 @@ class TrackedObjectProcessor(threading.Thread):
"type": source_type, "type": source_type,
"draw": draw, "draw": draw,
}, },
None,
) )
) )
@@ -664,6 +676,7 @@ class TrackedObjectProcessor(threading.Thread):
"recognized_license_plate": plate, "recognized_license_plate": plate,
"recognized_license_plate_score": score, "recognized_license_plate_score": score,
}, },
None,
) )
) )
@@ -692,6 +705,7 @@ class TrackedObjectProcessor(threading.Thread):
None, None,
"", "",
{"id": event_id, "end_time": end_time}, {"id": event_id, "end_time": end_time},
None,
) )
) )
@@ -713,13 +727,14 @@ class TrackedObjectProcessor(threading.Thread):
def force_end_all_events(self, camera: str, camera_state: CameraState) -> None: def force_end_all_events(self, camera: str, camera_state: CameraState) -> None:
"""Ends all active events on camera when disabling.""" """Ends all active events on camera when disabling."""
last_frame_name = camera_state.previous_frame_id last_frame_name = camera_state.previous_frame_id
last_frame_time = camera_state.current_frame_time
for obj_id, obj in list(camera_state.tracked_objects.items()): for obj_id, obj in list(camera_state.tracked_objects.items()):
if "end_time" not in obj.obj_data: if "end_time" not in obj.obj_data:
logger.debug(f"Camera {camera} disabled, ending active event {obj_id}") logger.debug(f"Camera {camera} disabled, ending active event {obj_id}")
obj.obj_data["end_time"] = datetime.datetime.now().timestamp() obj.obj_data["end_time"] = datetime.datetime.now().timestamp()
# end callbacks # end callbacks
for callback in camera_state.callbacks["end"]: for callback in camera_state.callbacks["end"]:
callback(camera, obj, last_frame_name) callback(camera, obj, last_frame_name, last_frame_time)
# camera activity callbacks # camera activity callbacks
for callback in camera_state.callbacks["camera_activity"]: for callback in camera_state.callbacks["camera_activity"]:
@@ -748,6 +763,7 @@ class TrackedObjectProcessor(threading.Thread):
continue continue
camera_state.shutdown() camera_state.shutdown()
self.frame_manager.close_camera(camera)
with self.camera_states_lock: with self.camera_states_lock:
self.camera_states.pop(camera) self.camera_states.pop(camera)
+20 -4
View File
@@ -747,8 +747,14 @@ def collect_object_classification_examples(
selected_events = _select_balanced_events(events, target_count=100) selected_events = _select_balanced_events(events, target_count=100)
logger.debug(f"Selected {len(selected_events)} events") logger.debug(f"Selected {len(selected_events)} events")
# Step 3: Extract thumbnails from events # Step 3: Extract thumbnails from events, falling back to the remaining
thumbnails = _extract_event_thumbnails(selected_events, temp_dir) # events when the selected ones have no image on disk
selected_ids = {e.id for e in selected_events}
remaining_events = [e for e in events if e.id not in selected_ids]
random.shuffle(remaining_events)
thumbnails = _extract_event_thumbnails(
selected_events + remaining_events, temp_dir, target_count=100
)
logger.debug(f"Successfully extracted {len(thumbnails)} thumbnails") logger.debug(f"Successfully extracted {len(thumbnails)} thumbnails")
# Step 4: Select 24 most visually distinct thumbnails # Step 4: Select 24 most visually distinct thumbnails
@@ -833,10 +839,16 @@ def _select_balanced_events(
else: else:
selected.extend(remaining) selected.extend(remaining)
# groups are ordered oldest first, so truncating unshuffled keeps only the
# oldest events, which are the least likely to still have images on disk
random.shuffle(selected)
return selected[:target_count] return selected[:target_count]
def _extract_event_thumbnails(events: list[Event], output_dir: str) -> list[str]: def _extract_event_thumbnails(
events: list[Event], output_dir: str, target_count: int = 100
) -> list[str]:
""" """
Extract a training image for each event. Extract a training image for each event.
@@ -850,8 +862,9 @@ def _extract_event_thumbnails(events: list[Event], output_dir: str) -> list[str]
using a step ladder sized from the box/region area ratio. using a step ladder sized from the box/region area ratio.
Args: Args:
events: List of Event objects events: List of Event objects, in order of preference
output_dir: Directory to save crops output_dir: Directory to save crops
target_count: Number of images to extract before stopping
Returns: Returns:
List of paths to successfully extracted images List of paths to successfully extracted images
@@ -859,6 +872,9 @@ def _extract_event_thumbnails(events: list[Event], output_dir: str) -> list[str]
image_paths = [] image_paths = []
for idx, event in enumerate(events): for idx, event in enumerate(events):
if len(image_paths) >= target_count:
break
try: try:
img = _load_event_classification_crop(event) img = _load_event_classification_crop(event)
if img is None: if img is None:
+105 -5
View File
@@ -990,13 +990,23 @@ class FrameManager(ABC):
pass pass
@abstractmethod @abstractmethod
def get(self, name: str, timeout_ms: int = 0): def get(
self, name: str, shape: tuple[int, ...], frame_time: float | None = None
) -> np.ndarray | None:
pass
@abstractmethod
def frame_times(self, camera: str) -> np.ndarray | None:
pass pass
@abstractmethod @abstractmethod
def close(self, name: str): def close(self, name: str):
pass pass
@abstractmethod
def close_camera(self, camera: str) -> None:
pass
@abstractmethod @abstractmethod
def delete(self, name: str): def delete(self, name: str):
pass pass
@@ -1079,7 +1089,56 @@ class SharedMemoryFrameManager(FrameManager):
logger.info(f"the file {name} not found") logger.info(f"the file {name} not found")
return None return None
def get(self, name: str, shape) -> np.ndarray | None: def frame_times(self, camera: str) -> np.ndarray | None:
"""Per-slot write times for a camera's frame ring, stamped by capture."""
buf = self.write(f"{camera}_frame_times")
if buf is None:
return None
return np.ndarray((len(buf) // 8,), dtype=np.float64, buffer=buf)
def _holds(self, name: str, frame_time: float) -> bool:
camera, _, index = name.rpartition("_frame")
times = self.frame_times(camera)
return (
times is not None
and index.isdigit()
and int(index) < len(times)
and times[int(index)] == frame_time
)
def get(
self, name: str, shape: tuple[int, ...], frame_time: float | None = None
) -> np.ndarray | None:
"""Map a frame slot as an array without copying it.
The array points into shared memory, so it is only valid until the next
close(), delete(), or get() of the same name, and capture overwrites its
pixels once the ring wraps back to this slot. Copy it to keep it longer.
Args:
name: Slot name, such as front_door_frame3
shape: Array shape, which must match the slot's size
frame_time: Time of the frame the caller was told is in the slot
Returns:
The frame, or None when the slot is missing, the wrong size, or holds
a different frame than frame_time
"""
if frame_time is not None and not self._holds(name, frame_time):
# a ledger left over from a same-size recreate of the camera looks
# the same as a lapped slot, so reopen it by name once
camera = name.rpartition("_frame")[0]
self.close(f"{camera}_frame_times")
if not self._holds(name, frame_time):
logger.debug("%s no longer holds the frame from %s", name, frame_time)
return None
# the ledger was replaced, so every cached slot maps the old segments
self.close_camera(camera)
try: try:
required = int(np.prod(shape)) required = int(np.prod(shape))
shm = self.shm_store.get(name) shm = self.shm_store.get(name)
@@ -1110,6 +1169,14 @@ class SharedMemoryFrameManager(FrameManager):
self.shm_store[name].close() self.shm_store[name].close()
del self.shm_store[name] del self.shm_store[name]
def close_camera(self, camera: str) -> None:
"""Drop every cached mapping for a camera's frame ring and ledger."""
for name in list(self.shm_store):
owner, _, suffix = name.rpartition("_frame")
if owner == camera and (suffix.isdigit() or suffix == "_times"):
self.close(name)
def delete(self, name: str): def delete(self, name: str):
if name in self.shm_store: if name in self.shm_store:
self.shm_store[name].close() self.shm_store[name].close()
@@ -1241,9 +1308,42 @@ def get_image_from_recording(
return image_data return image_data
def get_histogram(image, x_min, y_min, x_max, y_max): def get_histogram(
image_bgr = cv2.cvtColor(image, cv2.COLOR_YUV2BGR_I420) image: np.ndarray, x_min: int, y_min: int, x_max: int, y_max: int
image_bgr = image_bgr[y_min:y_max, x_min:x_max] ) -> np.ndarray:
"""Return a normalized 8x8x8 BGR histogram of a box in an I420 frame.
The box is cropped from each YUV plane before color conversion, so the
cost depends on the box size rather than the frame size. Box edges are
widened to even coordinates to keep chroma alignment.
"""
height = image.shape[0] * 2 // 3
width = image.shape[1]
x_min = max(0, x_min // 2 * 2)
y_min = max(0, y_min // 2 * 2)
x_max = min(width, (x_max + 1) // 2 * 2)
y_max = min(height, (y_max + 1) // 2 * 2)
if x_max - x_min < 2 or y_max - y_min < 2:
return np.zeros(512, np.float32)
flat = image.reshape(-1)
y_size = height * width
uv_size = y_size // 4
y_plane = flat[:y_size].reshape(height, width)
u_plane = flat[y_size : y_size + uv_size].reshape(height // 2, width // 2)
v_plane = flat[y_size + uv_size : y_size + 2 * uv_size].reshape(
height // 2, width // 2
)
crop = np.concatenate(
(
y_plane[y_min:y_max, x_min:x_max].ravel(),
u_plane[y_min // 2 : y_max // 2, x_min // 2 : x_max // 2].ravel(),
v_plane[y_min // 2 : y_max // 2, x_min // 2 : x_max // 2].ravel(),
)
).reshape((y_max - y_min) * 3 // 2, x_max - x_min)
image_bgr = cv2.cvtColor(crop, cv2.COLOR_YUV2BGR_I420)
hist = cv2.calcHist( hist = cv2.calcHist(
[image_bgr], [0, 1, 2], None, [8, 8, 8], [0, 256, 0, 256, 0, 256] [image_bgr], [0, 1, 2], None, [8, 8, 8], [0, 256, 0, 256, 0, 256]
+4 -3
View File
@@ -204,11 +204,12 @@ def coverage_spans(intervals: list[CoverageInterval]) -> list[dict[str, Any]]:
def stream_has_audio(intervals: list[CoverageInterval], main: bool) -> bool: def stream_has_audio(intervals: list[CoverageInterval], main: bool) -> bool:
"""Whether a stream is audio-bearing over a coverage window. """Whether a stream is audio-bearing over a coverage window.
A stream counts as audio-bearing unless EVERY one of its rows reports A stream counts as audio-bearing only when one of its rows is known
has_audio False; NULL (legacy or undetermined) counts as audio. to carry audio. A NULL row (legacy, or a segment ffprobe could not
read) proves nothing either way.
""" """
return any( return any(
row is not None and row.has_audio is not False row is not None and row.has_audio is True
for row in ((interval.main if main else interval.sub) for interval in intervals) for row in ((interval.main if main else interval.sub) for interval in intervals)
) )
+21 -6
View File
@@ -3,17 +3,32 @@
import datetime import datetime
import logging import logging
import math import math
from zoneinfo import ZoneInfoNotFoundError from typing import Final
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError, available_timezones
import pytz
from tzlocal import get_localzone from tzlocal import get_localzone
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
TIMEZONE_CASEFOLD_INDEX: Final = {
timezone.casefold(): timezone for timezone in available_timezones()
}
def get_normalized_tz_name(tz_name: str) -> str:
"""Return the canonical name for a case-insensitive IANA timezone."""
tz = TIMEZONE_CASEFOLD_INDEX.get(tz_name.casefold())
if tz:
return tz
raise ValueError(f"Unknown timezone: {tz_name}")
def get_tz_modifiers(tz_name: str) -> tuple[str, str, float]: def get_tz_modifiers(tz_name: str) -> tuple[str, str, float]:
seconds_offset = ( seconds_offset = (
datetime.datetime.now(pytz.timezone(tz_name)).utcoffset().total_seconds() datetime.datetime.now(ZoneInfo(get_normalized_tz_name(tz_name)))
.utcoffset()
.total_seconds()
) )
hours_offset = int(seconds_offset / 60 / 60) hours_offset = int(seconds_offset / 60 / 60)
minutes_offset = int(seconds_offset / 60 - hours_offset * 60) minutes_offset = int(seconds_offset / 60 - hours_offset * 60)
@@ -26,7 +41,7 @@ def get_tomorrow_at_time(hour: int) -> datetime.datetime:
"""Returns the datetime of the following day at 2am.""" """Returns the datetime of the following day at 2am."""
try: try:
tomorrow = datetime.datetime.now(get_localzone()) + datetime.timedelta(days=1) tomorrow = datetime.datetime.now(get_localzone()) + datetime.timedelta(days=1)
except ZoneInfoNotFoundError: except (ValueError, ZoneInfoNotFoundError):
tomorrow = datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=1) tomorrow = datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=1)
logger.warning( logger.warning(
"Using utc for maintenance due to missing or incorrect timezone set" "Using utc for maintenance due to missing or incorrect timezone set"
@@ -84,8 +99,8 @@ def get_dst_transitions(
continuous periods with the same UTC offset continuous periods with the same UTC offset
""" """
try: try:
tz = pytz.timezone(tz_name) tz = ZoneInfo(get_normalized_tz_name(tz_name))
except pytz.UnknownTimeZoneError: except (ValueError, ZoneInfoNotFoundError):
# If timezone is invalid, return single period with no offset # If timezone is invalid, return single period with no offset
return [(start_time, end_time, 0)] return [(start_time, end_time, 0)]
+6 -2
View File
@@ -144,6 +144,11 @@ class CameraTracker(FrigateProcess):
def run(self) -> None: def run(self) -> None:
self.pre_run_setup(self.log_config) self.pre_run_setup(self.log_config)
# OpenCV only sees small images here, and its default per-core thread
# pool burns ~5x the CPU of running them inline
cv2.setNumThreads(1)
frame_queue = self.camera_metrics.frame_queue frame_queue = self.camera_metrics.frame_queue
frame_shape = self.config.frame_shape frame_shape = self.config.frame_shape
@@ -302,6 +307,7 @@ def process_frames(
motion_detector.autotracking_enabled = ( motion_detector.autotracking_enabled = (
camera_config.onvif.autotracking.enabled camera_config.onvif.autotracking.enabled
) )
object_tracker.sync_trackers()
if ( if (
not camera_enabled not camera_enabled
@@ -601,7 +607,6 @@ def process_frames(
) )
# add to the queue if not full # add to the queue if not full
if detected_objects_queue.full(): if detected_objects_queue.full():
frame_manager.close(frame_name)
dropped_frames.dropped(time.monotonic()) dropped_frames.dropped(time.monotonic())
continue continue
else: else:
@@ -618,7 +623,6 @@ def process_frames(
) )
) )
camera_metrics.detection_fps.value = object_detector.fps.eps() camera_metrics.detection_fps.value = object_detector.fps.eps()
frame_manager.close(frame_name)
motion_detector.stop() motion_detector.stop()
requestor.stop() requestor.stop()
+11 -3
View File
@@ -64,6 +64,7 @@ def capture_frames(
frame_rate.start() frame_rate.start()
skipped_eps = EventsPerSecond() skipped_eps = EventsPerSecond()
skipped_eps.start() skipped_eps.start()
frame_times = frame_manager.frame_times(config.name)
while not stop_event.is_set(): while not stop_event.is_set():
# CameraWatchdog applies enabled updates onto this same CameraConfig # CameraWatchdog applies enabled updates onto this same CameraConfig
@@ -78,8 +79,14 @@ def capture_frames(
current_frame.value = datetime.now().timestamp() current_frame.value = datetime.now().timestamp()
frame_name = f"{config.name}_frame{frame_index}" frame_name = f"{config.name}_frame{frame_index}"
frame_buffer = frame_manager.write(frame_name) frame_buffer = frame_manager.write(frame_name)
# readinto fills the slot for the whole pipe wait, so readers must not
# match it until the frame is complete
frame_times[frame_index] = 0
try: try:
frame_buffer[:] = ffmpeg_process.stdout.read(frame_size) if ffmpeg_process.stdout.readinto(frame_buffer) != frame_size:
raise EOFError("incomplete frame")
except Exception: except Exception:
# shutdown has been initiated # shutdown has been initiated
if stop_event.is_set(): if stop_event.is_set():
@@ -95,16 +102,17 @@ def capture_frames(
continue continue
frame_times[frame_index] = current_frame.value
frame_rate.update() frame_rate.update()
# don't lock the queue to check, just try since it should rarely be full # don't lock the queue to check, just try since it should rarely be full
try: try:
# add to the queue # add to the queue
frame_queue.put((frame_name, current_frame.value), False) frame_queue.put((frame_name, current_frame.value), False)
frame_manager.close(frame_name)
except queue.Full: except queue.Full:
# if the queue is full, skip this frame # nobody was told about this slot, so the next frame reuses it
skipped_eps.update() skipped_eps.update()
continue
frame_index = 0 if frame_index == shm_frame_count - 1 else frame_index + 1 frame_index = 0 if frame_index == shm_frame_count - 1 else frame_index + 1
+70 -2
View File
@@ -6,9 +6,9 @@
* viewer nav restrictions; all-routes smoke. * viewer nav restrictions; all-routes smoke.
*/ */
import { test, expect } from "../fixtures/frigate-test"; import { test, expect, type FrigateApp } from "../fixtures/frigate-test";
import { systemTab } from "../helpers/system-tabs"; import { systemTab } from "../helpers/system-tabs";
import { viewerProfile } from "../fixtures/mock-data/profile"; import { adminProfile, viewerProfile } from "../fixtures/mock-data/profile";
test.describe("Auth — admin access @high", () => { test.describe("Auth — admin access @high", () => {
test("admin /system renders general tab", async ({ frigateApp }) => { test("admin /system renders general tab", async ({ frigateApp }) => {
@@ -109,3 +109,71 @@ test.describe("Auth — all routes smoke @high @mobile", () => {
} }
}); });
}); });
test.describe("Auth: login redirect @high", () => {
// 401 on /profile is how the app learns that a login is required
test.use({ expectedErrors: [/401/] });
async function requireLogin(frigateApp: FrigateApp) {
let loggedIn = false;
await frigateApp.page.route("**/api/profile", (route) =>
loggedIn
? route.fulfill({ json: adminProfile() })
: route.fulfill({ status: 401, body: "" }),
);
await frigateApp.page.route("**/api/login", (route) => {
loggedIn = true;
return route.fulfill({ json: {} });
});
await frigateApp.page.route("**/api/auth/first_time_login", (route) =>
route.fulfill({ json: { admin_first_time_login: false } }),
);
}
async function logIn(frigateApp: FrigateApp) {
await frigateApp.page.getByLabel("Username").fill("admin");
await frigateApp.page.getByLabel("Password").fill("password");
await frigateApp.page.getByRole("button", { name: "Login" }).click();
}
test("returns to the requested url after logging in", async ({
frigateApp,
}) => {
await requireLogin(frigateApp);
await frigateApp.page.goto("/explore?labels=person");
await expect(frigateApp.page).toHaveURL(
`/login?redirect=${encodeURIComponent("/explore?labels=person")}`,
);
await logIn(frigateApp);
await expect(frigateApp.page).toHaveURL("/explore?labels=person");
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible({
timeout: 10_000,
});
});
test("goes home after logging in from the home page", async ({
frigateApp,
}) => {
await requireLogin(frigateApp);
await frigateApp.page.goto("/");
await expect(frigateApp.page).toHaveURL("/login");
await logIn(frigateApp);
await expect(frigateApp.page).toHaveURL("/");
});
for (const target of ["https://example.com/", "//example.com/"]) {
test(`ignores a redirect to another site (${target})`, async ({
frigateApp,
}) => {
await requireLogin(frigateApp);
await frigateApp.page.goto(
`/login?redirect=${encodeURIComponent(target)}`,
);
await logIn(frigateApp);
await expect(frigateApp.page).toHaveURL("/");
});
}
});
@@ -66,6 +66,27 @@ const HAILO_PLUS_MODELS = [
}, },
]; ];
// a migrated `type: onnx` detector is the bare "onnx", and a Ryzen iGPU is
// always probed alongside an NVIDIA card
const ONNX_GPU_HARDWARE = [
{
key: "onnx:nvidia",
detector: "onnx",
name: "NVIDIA GeForce RTX 3060",
units: [{ device: "onnx:0", label: "NVIDIA GeForce RTX 3060" }],
count: 1,
unlimited: true,
},
{
key: "onnx:amd",
detector: "onnx",
name: "AMD GPU",
units: [{ device: "onnx", label: "renderD128" }],
count: 1,
unlimited: true,
},
];
const HAILO_HARDWARE = [ const HAILO_HARDWARE = [
{ {
key: "hailo", key: "hailo",
@@ -84,7 +105,7 @@ async function installRoutes(
models: Model[], models: Model[],
plusEnabled = false, plusEnabled = false,
plusModels: unknown[] = [PLUS_MODEL], plusModels: unknown[] = [PLUS_MODEL],
hailoHardware = false, hardware?: unknown[],
) { ) {
const config = configFactory({ const config = configFactory({
models, models,
@@ -107,9 +128,9 @@ async function installRoutes(
route.fulfill({ json: plusModels }), route.fulfill({ json: plusModels }),
); );
if (hailoHardware) { if (hardware) {
await page.route("**/api/hardware/probe**", (route) => await page.route("**/api/hardware/probe**", (route) =>
route.fulfill({ json: HAILO_HARDWARE }), route.fulfill({ json: hardware }),
); );
} }
await page.route("**/api/config/set", async (route) => { await page.route("**/api/config/set", async (route) => {
@@ -202,6 +223,39 @@ test.describe("Detection models settings @high", () => {
).not.toBeChecked(); ).not.toBeChecked();
}); });
test("a device without an index selects the first unit", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page, [
{ scene: "default", devices: ["edgetpu:pci"] },
]);
await openPage(frigateApp);
await expect(
frigateApp.page.locator("#models-0-edgetpu\\:pci\\:0"),
).toBeChecked();
await expect(
frigateApp.page.locator("#models-0-edgetpu\\:pci\\:1"),
).not.toBeChecked();
});
test("a migrated onnx device resolves to the NVIDIA GPU", async ({
frigateApp,
}) => {
await installRoutes(
frigateApp.page,
[{ scene: "default", devices: ["onnx"] }],
false,
[PLUS_MODEL],
ONNX_GPU_HARDWARE,
);
await openPage(frigateApp);
await expect(frigateApp.page.locator("#pageRoot")).toContainText(
"NVIDIA GeForce RTX 3060 \u2022 3 cameras",
);
});
test("a unit claimed by another model cannot be picked", async ({ test("a unit claimed by another model cannot be picked", async ({
frigateApp, frigateApp,
}) => { }) => {
@@ -510,7 +564,7 @@ test.describe("Detection models settings @high", () => {
], ],
true, true,
HAILO_PLUS_MODELS, HAILO_PLUS_MODELS,
true, HAILO_HARDWARE,
); );
await openPage(frigateApp); await openPage(frigateApp);
+46 -2
View File
@@ -14,6 +14,17 @@ const NOW = Math.floor(Date.now() / 1000);
// the fixture detector runs at 75.5 ms, above the live warning threshold // the fixture detector runs at 75.5 ms, above the live warning threshold
const QUIET_STATS = { detectors: { cpu: { inference_speed: 10 } } }; const QUIET_STATS = { detectors: { cpu: { inference_speed: 10 } } };
// the fixture has no go2rtc streams, which gives every camera a live view hint
const RESTREAMED = {
go2rtc: {
streams: {
front_door: ["rtsp://x"],
backyard: ["rtsp://x"],
garage: ["rtsp://x"],
},
},
};
const ERROR_NOTICE = { const ERROR_NOTICE = {
id: "model_download_failed:yolo/model.onnx", id: "model_download_failed:yolo/model.onnx",
kind: "model_download_failed", kind: "model_download_failed",
@@ -51,6 +62,7 @@ test.describe("System — Health tab @medium", () => {
frigateApp, frigateApp,
}) => { }) => {
await frigateApp.installDefaults({ await frigateApp.installDefaults({
config: RESTREAMED,
stats: QUIET_STATS, stats: QUIET_STATS,
notices: [ERROR_NOTICE, EVENT_NOTICE], notices: [ERROR_NOTICE, EVENT_NOTICE],
}); });
@@ -84,6 +96,7 @@ test.describe("System — Health tab @medium", () => {
for (const action of ["acknowledge", "mute"] as const) { for (const action of ["acknowledge", "mute"] as const) {
test(`${action} posts and removes the row`, async ({ frigateApp }) => { test(`${action} posts and removes the row`, async ({ frigateApp }) => {
await frigateApp.installDefaults({ await frigateApp.installDefaults({
config: RESTREAMED,
stats: QUIET_STATS, stats: QUIET_STATS,
notices: [EVENT_NOTICE], notices: [EVENT_NOTICE],
}); });
@@ -126,7 +139,10 @@ test.describe("System — Health tab @medium", () => {
} }
test("empty state with no notices", async ({ frigateApp }) => { test("empty state with no notices", async ({ frigateApp }) => {
await frigateApp.installDefaults({ stats: QUIET_STATS }); await frigateApp.installDefaults({
config: RESTREAMED,
stats: QUIET_STATS,
});
await frigateApp.goto("/system#health"); await frigateApp.goto("/system#health");
await expect( await expect(
@@ -329,6 +345,7 @@ test.describe("System — Health tab @medium", () => {
frigateApp, frigateApp,
}) => { }) => {
await frigateApp.installDefaults({ await frigateApp.installDefaults({
config: RESTREAMED,
stats: QUIET_STATS, stats: QUIET_STATS,
notices: [ERROR_NOTICE, EVENT_NOTICE], notices: [ERROR_NOTICE, EVENT_NOTICE],
}); });
@@ -765,6 +782,7 @@ test.describe("System — Health notices sources @medium", () => {
test("status bar problems stay out of the list", async ({ frigateApp }) => { test("status bar problems stay out of the list", async ({ frigateApp }) => {
test.skip(frigateApp.isMobile, "Status bar is desktop-only"); test.skip(frigateApp.isMobile, "Status bar is desktop-only");
await frigateApp.installDefaults({ await frigateApp.installDefaults({
config: RESTREAMED,
stats: { stats: {
service: { retention_unmet: true }, service: { retention_unmet: true },
cameras: { front_door: { camera_fps: 0 } }, cameras: { front_door: { camera_fps: 0 } },
@@ -829,6 +847,28 @@ test.describe("System — Health notices sources @medium", () => {
).toBeVisible({ timeout: 15_000 }); ).toBeVisible({ timeout: 15_000 });
}); });
test("a camera without a go2rtc stream gets a live view hint", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({
config: { go2rtc: { streams: { front_door: ["rtsp://x"] } } },
stats: QUIET_STATS,
});
await frigateApp.goto("/system#health");
const row = frigateApp.page.getByTestId(
"health-problem-config:live:no-go2rtc-stream:camera.backyard",
);
await expect(row).toBeVisible({ timeout: 15_000 });
await expect(row).toHaveAttribute("data-severity", "info");
await expect(row).toContainText("lower frame rate and no audio");
await expect(
frigateApp.page.getByTestId(
"health-problem-config:live:no-go2rtc-stream:camera.front_door",
),
).toHaveCount(0);
});
test("a global config problem is not repeated per camera", async ({ test("a global config problem is not repeated per camera", async ({
frigateApp, frigateApp,
}) => { }) => {
@@ -893,7 +933,10 @@ test.describe("System — Health notices sources @medium", () => {
test("empty state when stats, config, and registry are clean", async ({ test("empty state when stats, config, and registry are clean", async ({
frigateApp, frigateApp,
}) => { }) => {
await frigateApp.installDefaults({ stats: QUIET_STATS }); await frigateApp.installDefaults({
config: RESTREAMED,
stats: QUIET_STATS,
});
await frigateApp.goto("/system#health"); await frigateApp.goto("/system#health");
await expect( await expect(
@@ -1130,6 +1173,7 @@ test.describe("System — Health notices sources @medium", () => {
}) => { }) => {
test.skip(frigateApp.isMobile, "Status bar is desktop-only"); test.skip(frigateApp.isMobile, "Status bar is desktop-only");
await frigateApp.installDefaults({ await frigateApp.installDefaults({
config: RESTREAMED,
stats: QUIET_STATS, stats: QUIET_STATS,
notices: [EVENT_NOTICE], notices: [EVENT_NOTICE],
}); });
@@ -1997,6 +1997,9 @@
"genaiImageSourceRecordingsRecordDisabled": "Image source is set to 'recordings', but recording is disabled. Frigate will fall back to preview images.", "genaiImageSourceRecordingsRecordDisabled": "Image source is set to 'recordings', but recording is disabled. Frigate will fall back to preview images.",
"genaiImageSourceRecordingsRecordRuntimeDisabled": "Image source is set to 'recordings', but recording is currently turned off for this camera even though your config enables it. Frigate will fall back to preview images." "genaiImageSourceRecordingsRecordRuntimeDisabled": "Image source is set to 'recordings', but recording is currently turned off for this camera even though your config enables it. Frigate will fall back to preview images."
}, },
"live": {
"noGo2rtcStream": "Live view for this camera is using a basic player with a lower frame rate and no audio. Set up a go2rtc stream for this camera to get smoother video and audio."
},
"audio": { "audio": {
"noAudioRole": "No streams have the audio role defined. You must enable the audio role for audio detection to function." "noAudioRole": "No streams have the audio role defined. You must enable the audio role for audio detection to function."
}, },
+2 -2
View File
@@ -35,8 +35,8 @@
"showAll": "Show all again", "showAll": "Show all again",
"showAllTitle": "Show all hidden notices?", "showAllTitle": "Show all hidden notices?",
"showAllDesc": "Every acknowledged and muted notice returns to the Notices list, including config and stream checks.", "showAllDesc": "Every acknowledged and muted notice returns to the Notices list, including config and stream checks.",
"firstSeen_one": "First seen {{time}}", "occurred_one": "Occurred {{time}}",
"firstSeen_other": "First seen {{time}} · {{count}} times", "occurred_other": "Last occurred {{lastTime}} · {{count}} times since {{time}}",
"acknowledgedAt": "Acknowledged {{time}}", "acknowledgedAt": "Acknowledged {{time}}",
"mutedAt": "Muted {{time}}", "mutedAt": "Muted {{time}}",
"filter": { "filter": {
+45
View File
@@ -1,3 +1,5 @@
import { baseUrl } from "./baseUrl";
// Module-level flag to prevent multiple simultaneous redirects // Module-level flag to prevent multiple simultaneous redirects
// (eg, when multiple SWR queries fail with 401 at once, or when // (eg, when multiple SWR queries fail with 401 at once, or when
// both ApiProvider and ProtectedRoute try to redirect) // both ApiProvider and ProtectedRoute try to redirect)
@@ -10,3 +12,46 @@ export function isRedirectingToLogin(): boolean {
export function setRedirectingToLogin(value: boolean): void { export function setRedirectingToLogin(value: boolean): void {
_isRedirectingToLogin = value; _isRedirectingToLogin = value;
} }
const REDIRECT_PARAM = "redirect";
// Build the login page url, carrying the current location along
// so the user can be returned to it after logging in
export function getLoginUrl(loginPage: string = `${baseUrl}login`): string {
const url = new URL(loginPage, window.location.href);
// an external login page (eg, an auth proxy) handles its own return url
if (url.origin !== window.location.origin) {
return loginPage;
}
const { pathname, search, hash } = window.location;
const current = `${pathname}${search}${hash}`;
// no need to carry the home page, it is the default after logging in
if (current !== new URL(baseUrl).pathname) {
url.searchParams.set(REDIRECT_PARAM, current);
}
return url.href;
}
// Resolve where to send the user after a successful login
export function getPostLoginUrl(): string {
const target = new URLSearchParams(window.location.search).get(
REDIRECT_PARAM,
);
if (!target) {
return baseUrl;
}
// only return to urls on this origin so the param can't be used
// to send the user to another site
try {
const url = new URL(target, window.location.origin);
return url.origin === window.location.origin ? url.href : baseUrl;
} catch {
return baseUrl;
}
}
+6 -2
View File
@@ -3,7 +3,11 @@ import { SWRConfig } from "swr";
import { WsProvider } from "./WsProvider"; import { WsProvider } from "./WsProvider";
import axios from "axios"; import axios from "axios";
import { ReactNode } from "react"; import { ReactNode } from "react";
import { isRedirectingToLogin, setRedirectingToLogin } from "./auth-redirect"; import {
getLoginUrl,
isRedirectingToLogin,
setRedirectingToLogin,
} from "./auth-redirect";
axios.defaults.baseURL = `${baseUrl}api/`; axios.defaults.baseURL = `${baseUrl}api/`;
@@ -34,7 +38,7 @@ export function ApiProvider({ children, options }: ApiProviderType) {
const loginPage = error.response.headers.get("location") ?? "login"; const loginPage = error.response.headers.get("location") ?? "login";
if (window.location.href !== loginPage && !isRedirectingToLogin()) { if (window.location.href !== loginPage && !isRedirectingToLogin()) {
setRedirectingToLogin(true); setRedirectingToLogin(true);
window.location.href = loginPage; window.location.href = getLoginUrl(loginPage);
} }
} }
}, },
+2 -2
View File
@@ -2,7 +2,7 @@
import * as React from "react"; import * as React from "react";
import { baseUrl } from "../../api/baseUrl"; import { getPostLoginUrl } from "@/api/auth-redirect";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -69,7 +69,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
username: profileRes.data.username, username: profileRes.data.username,
role: profileRes.data.role || "viewer", role: profileRes.data.role || "viewer",
}); });
window.location.href = baseUrl; window.location.href = getPostLoginUrl();
} catch (error) { } catch (error) {
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {
const err = error as AxiosError; const err = error as AxiosError;
+2 -2
View File
@@ -3,10 +3,10 @@ import { Navigate, Outlet } from "react-router-dom";
import { AuthContext } from "@/context/auth-context"; import { AuthContext } from "@/context/auth-context";
import ActivityIndicator from "../indicators/activity-indicator"; import ActivityIndicator from "../indicators/activity-indicator";
import { import {
getLoginUrl,
isRedirectingToLogin, isRedirectingToLogin,
setRedirectingToLogin, setRedirectingToLogin,
} from "@/api/auth-redirect"; } from "@/api/auth-redirect";
import { baseUrl } from "@/api/baseUrl";
export default function ProtectedRoute({ export default function ProtectedRoute({
requiredRoles, requiredRoles,
@@ -25,7 +25,7 @@ export default function ProtectedRoute({
!isRedirectingToLogin() !isRedirectingToLogin()
) { ) {
setRedirectingToLogin(true); setRedirectingToLogin(true);
window.location.href = `${baseUrl}login`; window.location.href = getLoginUrl();
} }
}, [auth.isLoading, auth.isAuthenticated, auth.user]); }, [auth.isLoading, auth.isAuthenticated, auth.user]);
@@ -1,8 +1,24 @@
import { isRestreamedStream } from "@/utils/liveTranscode";
import type { SectionConfigOverrides } from "./types"; import type { SectionConfigOverrides } from "./types";
const live: SectionConfigOverrides = { const live: SectionConfigOverrides = {
base: { base: {
sectionDocs: "/configuration/live", sectionDocs: "/configuration/live",
messages: [
{
key: "no-go2rtc-stream",
health: true,
messageKey: "configMessages.live.noGo2rtcStream",
severity: "info",
docLink: "/configuration/live",
condition: (ctx) => {
if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false;
return !Object.values(ctx.fullCameraConfig.live.streams).some(
(name) => isRestreamedStream(ctx.fullConfig, name),
);
},
},
],
restartRequired: [], restartRequired: [],
fieldOrder: ["streams", "transcode", "height", "quality"], fieldOrder: ["streams", "transcode", "height", "quality"],
fieldGroups: {}, fieldGroups: {},
@@ -15,6 +15,7 @@ import {
hardwareForDevices, hardwareForDevices,
MAX_DETECTORS, MAX_DETECTORS,
recommendedDetectorCount, recommendedDetectorCount,
resolveUnitDevice,
} from "@/utils/detectionHardware"; } from "@/utils/detectionHardware";
type HardwarePickerProps = { type HardwarePickerProps = {
@@ -60,9 +61,13 @@ export function HardwarePicker({
return []; return [];
} }
const assigned = devices.map((device) =>
resolveUnitDevice(selected, device),
);
return selected.units return selected.units
.map((unit) => unit.device) .map((unit) => unit.device)
.filter((device) => devices.includes(device)); .filter((device) => assigned.includes(device));
}, [selected, devices]); }, [selected, devices]);
/** Spread `count` detectors round robin over the selected units. */ /** Spread `count` detectors round robin over the selected units. */
@@ -200,7 +205,7 @@ export function HardwarePicker({
<Checkbox <Checkbox
id={`${idPrefix}-${unit.device}`} id={`${idPrefix}-${unit.device}`}
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white" className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
checked={devices.includes(unit.device)} checked={selectedUnits.includes(unit.device)}
disabled={disabled || Boolean(claimedBy)} disabled={disabled || Boolean(claimedBy)}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
handleUnitToggle(unit.device, checked === true) handleUnitToggle(unit.device, checked === true)
+2 -1
View File
@@ -118,9 +118,10 @@ export function useHealthProblems(
ns: "views/system", ns: "views/system",
time: formatTime(notice.acknowledged_at), time: formatTime(notice.acknowledged_at),
}) })
: t("health.notices.firstSeen", { : t("health.notices.occurred", {
ns: "views/system", ns: "views/system",
time: formatTime(notice.first_seen), time: formatTime(notice.first_seen),
lastTime: formatTime(notice.last_seen),
count: notice.count, count: notice.count,
}), }),
link: external ? undefined : link, link: external ? undefined : link,
+25 -4
View File
@@ -21,10 +21,31 @@ export function hardwareForDevices(
return undefined; return undefined;
} }
return hardware.find((entry) => { return hardware.find((entry) =>
const known = new Set(entry.units.map((unit) => unit.device)); devices.every((device) => resolveUnitDevice(entry, device)),
return devices.every((device) => known.has(device)); );
}); }
/**
* The unit device string a configured device refers to.
*
* A config may leave the index off, such as "edgetpu:usb", which the detector
* resolves to the first unit of that kind.
*/
export function resolveUnitDevice(
entry: DetectionHardware,
device: string,
): string | undefined {
const unitDevices = entry.units.map((unit) => unit.device);
return (
unitDevices.find((candidate) => candidate === device) ??
unitDevices.find(
(candidate) =>
candidate.startsWith(`${device}:`) ||
candidate.startsWith(`${device}.`),
)
);
} }
/** /**