mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-12 17:52:48 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a13464ebd |
@@ -25,7 +25,7 @@ peewee_migrate == 2.3.*
|
||||
psutil == 7.2.*
|
||||
pydantic == 2.13.*
|
||||
git+https://github.com/fbcotter/py3nvml#egg=py3nvml
|
||||
tzdata == 2026.5
|
||||
pytz == 2026.*
|
||||
pyzmq == 27.2.*
|
||||
ruamel.yaml == 0.19.*
|
||||
tzlocal == 5.4.4
|
||||
|
||||
@@ -53,14 +53,6 @@ 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.
|
||||
|
||||
### 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
|
||||
|
||||
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.
|
||||
|
||||
Vendored
+7
-7
@@ -2131,7 +2131,7 @@ paths:
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
default: UTC
|
||||
default: utc
|
||||
title: Timezone
|
||||
responses:
|
||||
'200':
|
||||
@@ -3266,7 +3266,7 @@ paths:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
default: UTC
|
||||
default: utc
|
||||
title: Timezone
|
||||
responses:
|
||||
'200':
|
||||
@@ -4666,7 +4666,7 @@ paths:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
default: UTC
|
||||
default: utc
|
||||
title: Timezone
|
||||
responses:
|
||||
'200':
|
||||
@@ -4928,7 +4928,7 @@ paths:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
default: UTC
|
||||
default: utc
|
||||
title: Timezone
|
||||
- name: min_score
|
||||
in: query
|
||||
@@ -5008,7 +5008,7 @@ paths:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
default: UTC
|
||||
default: utc
|
||||
title: Timezone
|
||||
- name: has_clip
|
||||
in: query
|
||||
@@ -7367,7 +7367,7 @@ paths:
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
default: UTC
|
||||
default: utc
|
||||
title: Timezone
|
||||
- name: cameras
|
||||
in: query
|
||||
@@ -7417,7 +7417,7 @@ paths:
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
default: UTC
|
||||
default: utc
|
||||
title: Timezone
|
||||
responses:
|
||||
'200':
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Object classification APIs."""
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
@@ -49,9 +48,6 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
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:
|
||||
"""Response for a name that cannot be used as a path component."""
|
||||
@@ -1294,10 +1290,7 @@ async def generate_object_examples(request: Request, body: GenerateObjectExample
|
||||
if model_name is None:
|
||||
return invalid_name_response(body.model_name)
|
||||
|
||||
async with example_collection_lock:
|
||||
await asyncio.to_thread(
|
||||
collect_object_classification_examples, model_name, body.label
|
||||
)
|
||||
collect_object_classification_examples(model_name, body.label)
|
||||
|
||||
return JSONResponse(
|
||||
content={"success": True, "message": "Example generation completed"},
|
||||
|
||||
@@ -7,4 +7,4 @@ class AppTimelineHourlyQueryParameters(BaseModel):
|
||||
after: float | None = None
|
||||
before: float | None = None
|
||||
limit: int | None = 200
|
||||
timezone: str | None = "UTC"
|
||||
timezone: str | None = "utc"
|
||||
|
||||
@@ -40,7 +40,7 @@ class EventsQueryParams(BaseModel):
|
||||
max_length: float | None = None
|
||||
event_id: str | None = None
|
||||
sort: str | None = None
|
||||
timezone: str | None = "UTC"
|
||||
timezone: str | None = "utc"
|
||||
|
||||
|
||||
class EventsSearchQueryParams(BaseModel):
|
||||
@@ -68,7 +68,7 @@ class EventsSearchQueryParams(BaseModel):
|
||||
has_clip: bool | None = None
|
||||
has_snapshot: bool | None = None
|
||||
is_submitted: bool | None = None
|
||||
timezone: str | None = "UTC"
|
||||
timezone: str | None = "utc"
|
||||
min_score: float | None = None
|
||||
max_score: float | None = None
|
||||
min_speed: float | None = None
|
||||
@@ -78,6 +78,6 @@ class EventsSearchQueryParams(BaseModel):
|
||||
|
||||
|
||||
class EventsSummaryQueryParams(BaseModel):
|
||||
timezone: str | None = "UTC"
|
||||
timezone: str | None = "utc"
|
||||
has_clip: int | None = None
|
||||
has_snapshot: int | None = None
|
||||
|
||||
@@ -3,7 +3,7 @@ from pydantic.json_schema import SkipJsonSchema
|
||||
|
||||
|
||||
class MediaRecordingsSummaryQueryParams(BaseModel):
|
||||
timezone: str = "UTC"
|
||||
timezone: str = "utc"
|
||||
cameras: str | None = "all"
|
||||
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ class ReviewSummaryQueryParams(BaseModel):
|
||||
cameras: str = "all"
|
||||
labels: str = "all"
|
||||
zones: str = "all"
|
||||
timezone: str = "UTC"
|
||||
timezone: str = "utc"
|
||||
|
||||
|
||||
class ReviewActivityMotionQueryParams(BaseModel):
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import Depends, FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
@@ -82,20 +81,9 @@ def create_fastapi_app(
|
||||
config_holder: ConfigHolder | 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")
|
||||
app = FastAPI(
|
||||
debug=False,
|
||||
lifespan=lifespan,
|
||||
swagger_ui_parameters={"apisSorter": "alpha", "operationsSorter": "alpha"},
|
||||
dependencies=[Depends(require_admin_by_default())]
|
||||
if enforce_default_admin
|
||||
@@ -131,6 +119,15 @@ def create_fastapi_app(
|
||||
database.close()
|
||||
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)
|
||||
if frigate_config.auth.failed_login_rate_limit is None:
|
||||
limiter.enabled = False
|
||||
|
||||
@@ -14,10 +14,10 @@ from enum import Enum
|
||||
from pathlib import Path as FilePath
|
||||
from typing import IO, Any
|
||||
from urllib.parse import unquote
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytz
|
||||
from fastapi import APIRouter, Depends, Path, Query, Request, Response
|
||||
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
||||
from pathvalidate import sanitize_filename
|
||||
@@ -64,7 +64,6 @@ from frigate.util.recording_coverage import (
|
||||
plan_clip,
|
||||
resolve_coverage,
|
||||
)
|
||||
from frigate.util.time import get_normalized_tz_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -838,9 +837,7 @@ async def vod_hour(
|
||||
parts = year_month.split("-")
|
||||
start_date = (
|
||||
datetime(int(parts[0]), int(parts[1]), day, hour, tzinfo=UTC)
|
||||
- datetime.now(
|
||||
ZoneInfo(get_normalized_tz_name(tz_name.replace(",", "/")))
|
||||
).utcoffset()
|
||||
- datetime.now(pytz.timezone(tz_name.replace(",", "/"))).utcoffset()
|
||||
)
|
||||
end_date = start_date + timedelta(hours=1) - timedelta(milliseconds=1)
|
||||
start_ts = start_date.timestamp()
|
||||
|
||||
@@ -5,8 +5,8 @@ import logging
|
||||
import os
|
||||
import threading
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytz
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
@@ -22,7 +22,6 @@ from frigate.api.defs.response.preview_response import (
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.const import BASE_DIR, CACHE_DIR, PREVIEW_FRAME_TYPE
|
||||
from frigate.models import Previews
|
||||
from frigate.util.time import get_normalized_tz_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -127,9 +126,7 @@ def preview_hour(
|
||||
parts = year_month.split("-")
|
||||
start_date = (
|
||||
datetime(int(parts[0]), int(parts[1]), int(day), int(hour), tzinfo=UTC)
|
||||
- datetime.now(
|
||||
ZoneInfo(get_normalized_tz_name(tz_name.replace(",", "/")))
|
||||
).utcoffset()
|
||||
- datetime.now(pytz.timezone(tz_name.replace(",", "/"))).utcoffset()
|
||||
)
|
||||
end_date = start_date + timedelta(hours=1) - timedelta(milliseconds=1)
|
||||
start_ts = start_date.timestamp()
|
||||
|
||||
@@ -175,7 +175,7 @@ async def all_recordings_summary(
|
||||
@router.get(
|
||||
"/{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"""
|
||||
|
||||
time_range_query = (
|
||||
|
||||
@@ -169,10 +169,6 @@ class CameraMaintainer(threading.Thread):
|
||||
frame_size = config.frame_shape_yuv[0] * config.frame_shape_yuv[1]
|
||||
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(
|
||||
config,
|
||||
count,
|
||||
|
||||
@@ -349,7 +349,7 @@ class CameraState:
|
||||
return
|
||||
|
||||
current_frame = self.frame_manager.get(
|
||||
frame_name, self.camera_config.frame_shape_yuv, frame_time
|
||||
frame_name, self.camera_config.frame_shape_yuv
|
||||
)
|
||||
|
||||
tracked_objects = self.tracked_objects.copy()
|
||||
@@ -404,7 +404,7 @@ class CameraState:
|
||||
self.send_mqtt_snapshot(new_obj, object_type)
|
||||
|
||||
for c in self.callbacks["start"]:
|
||||
c(self.name, new_obj, frame_name, frame_time)
|
||||
c(self.name, new_obj, frame_name)
|
||||
|
||||
for id in updated_ids:
|
||||
updated_obj = tracked_objects[id]
|
||||
@@ -416,7 +416,7 @@ class CameraState:
|
||||
|
||||
if autotracker_update or significant_update:
|
||||
for c in self.callbacks["autotrack"]:
|
||||
c(self.name, updated_obj, frame_name, frame_time)
|
||||
c(self.name, updated_obj, frame_name)
|
||||
|
||||
if thumb_update and current_frame is not None:
|
||||
# ensure this frame is stored in the cache
|
||||
@@ -466,7 +466,7 @@ class CameraState:
|
||||
):
|
||||
# call event handlers
|
||||
for c in self.callbacks["update"]:
|
||||
c(self.name, updated_obj, frame_name, frame_time)
|
||||
c(self.name, updated_obj, frame_name)
|
||||
updated_obj.last_published = frame_time
|
||||
|
||||
# send MQTT snapshot when object first enters a required zone,
|
||||
@@ -488,7 +488,7 @@ class CameraState:
|
||||
removed_obj.obj_data["end_time"] = frame_time
|
||||
logger.debug(f"{self.name}: end callback for object {id}")
|
||||
for c in self.callbacks["end"]:
|
||||
c(self.name, removed_obj, frame_name, frame_time)
|
||||
c(self.name, removed_obj, frame_name)
|
||||
|
||||
# TODO: can i switch to looking this up and only changing when an event ends?
|
||||
# maintain best objects
|
||||
@@ -603,6 +603,9 @@ class CameraState:
|
||||
self.current_frame_time = frame_time
|
||||
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
|
||||
|
||||
def send_mqtt_snapshot(self, new_obj: TrackedObject, object_type: str) -> None:
|
||||
|
||||
@@ -8,16 +8,7 @@ from .zmq_proxy import Publisher, Subscriber
|
||||
|
||||
|
||||
class EventUpdatePublisher(
|
||||
Publisher[
|
||||
tuple[
|
||||
EventTypeEnum,
|
||||
EventStateEnum,
|
||||
str | None,
|
||||
str,
|
||||
dict[str, Any],
|
||||
float | None,
|
||||
]
|
||||
]
|
||||
Publisher[tuple[EventTypeEnum, EventStateEnum, str | None, str, dict[str, Any]]]
|
||||
):
|
||||
"""Publishes events (objects, audio, manual)."""
|
||||
|
||||
@@ -28,14 +19,7 @@ class EventUpdatePublisher(
|
||||
|
||||
def publish(
|
||||
self,
|
||||
payload: tuple[
|
||||
EventTypeEnum,
|
||||
EventStateEnum,
|
||||
str | None,
|
||||
str,
|
||||
dict[str, Any],
|
||||
float | None,
|
||||
],
|
||||
payload: tuple[EventTypeEnum, EventStateEnum, str | None, str, dict[str, Any]],
|
||||
sub_topic: str = "",
|
||||
) -> None:
|
||||
super().publish(payload, sub_topic)
|
||||
|
||||
@@ -8,7 +8,6 @@ MODEL_CACHE_DIR = f"{CONFIG_DIR}/model_cache"
|
||||
BASE_DIR = "/media/frigate"
|
||||
CLIPS_DIR = f"{BASE_DIR}/clips"
|
||||
EXPORT_DIR = f"{BASE_DIR}/exports"
|
||||
EXPORT_STAGING_DIR = f"{EXPORT_DIR}/staging"
|
||||
FACE_DIR = f"{CLIPS_DIR}/faces"
|
||||
THUMB_DIR = f"{CLIPS_DIR}/thumbs"
|
||||
RECORD_DIR = f"{BASE_DIR}/recordings"
|
||||
|
||||
@@ -332,9 +332,6 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
"""Apply camera config updates and register newly enabled processors."""
|
||||
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:
|
||||
self._sync_genai_processors()
|
||||
|
||||
@@ -520,7 +517,7 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
if update is None:
|
||||
return
|
||||
|
||||
source_type, event_type, camera, frame_name, data, frame_time = update
|
||||
source_type, event_type, camera, frame_name, data = update
|
||||
|
||||
logger.debug(
|
||||
f"Received update - source_type: {source_type}, camera: {camera}, data label: {data.get('label') if data else 'None'}"
|
||||
@@ -549,7 +546,7 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
# Create our own thumbnail based on the bounding box and the frame time
|
||||
try:
|
||||
yuv_frame = self.frame_manager.get(
|
||||
frame_name, camera_config.frame_shape_yuv, frame_time
|
||||
frame_name, camera_config.frame_shape_yuv
|
||||
)
|
||||
except FileNotFoundError:
|
||||
logger.debug(f"Frame {frame_name} not found for camera {camera}")
|
||||
@@ -586,6 +583,8 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
PostProcessDataEnum.tracked_object,
|
||||
)
|
||||
|
||||
self.frame_manager.close(frame_name)
|
||||
|
||||
def _process_finalized(self) -> None:
|
||||
"""Process the end of an event."""
|
||||
while True:
|
||||
@@ -794,7 +793,7 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
if topic is None:
|
||||
return
|
||||
|
||||
camera, frame_name, frame_time, _, motion_boxes, _ = data
|
||||
camera, frame_name, _, _, motion_boxes, _ = data
|
||||
|
||||
if not camera or camera not in self.config.cameras:
|
||||
return
|
||||
@@ -818,7 +817,7 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
|
||||
try:
|
||||
yuv_frame = self.frame_manager.get(
|
||||
frame_name, camera_config.frame_shape_yuv, frame_time
|
||||
frame_name, camera_config.frame_shape_yuv
|
||||
)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
@@ -842,6 +841,8 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
{"camera": camera, "motion": motion_boxes}, yuv_frame
|
||||
)
|
||||
|
||||
self.frame_manager.close(frame_name)
|
||||
|
||||
def _process_deferred_results(self) -> None:
|
||||
"""Drain results from deferred processors and perform IPC side-effects."""
|
||||
for processor in self.realtime_processors:
|
||||
|
||||
@@ -5,7 +5,7 @@ import os
|
||||
import shutil
|
||||
import threading
|
||||
|
||||
from transformers import AutoTokenizer, CLIPImageProcessorPil
|
||||
from transformers import AutoTokenizer, CLIPImageProcessor
|
||||
from transformers.utils.logging import disable_progress_bar
|
||||
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
@@ -209,7 +209,7 @@ class JinaV1ImageEmbedding(BaseEmbedding):
|
||||
if self.downloader:
|
||||
self.downloader.wait_for_download()
|
||||
|
||||
self.feature_extractor = CLIPImageProcessorPil.from_pretrained(
|
||||
self.feature_extractor = CLIPImageProcessor.from_pretrained(
|
||||
f"{MODEL_CACHE_DIR}/{self.model_name}",
|
||||
)
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ class EventProcessor(threading.Thread):
|
||||
if update == None:
|
||||
continue
|
||||
|
||||
source_type, event_type, camera, _, event_data, _ = update
|
||||
source_type, event_type, camera, _, event_data = update
|
||||
|
||||
logger.debug(
|
||||
f"Event received: {source_type} {event_type} {camera} {event_data['id']}"
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
@@ -16,7 +15,7 @@ from peewee import DoesNotExist
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.camera.record import ChaptersEnum
|
||||
from frigate.const import EXPORT_STAGING_DIR, UPDATE_JOB_STATE
|
||||
from frigate.const import UPDATE_JOB_STATE
|
||||
from frigate.jobs.job import Job
|
||||
from frigate.models import Export
|
||||
from frigate.record.export import (
|
||||
@@ -416,9 +415,6 @@ def reap_stale_exports() -> None:
|
||||
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.
|
||||
"""
|
||||
# staged stream runs live on disk, so a killed export leaves them behind
|
||||
shutil.rmtree(EXPORT_STAGING_DIR, ignore_errors=True)
|
||||
|
||||
try:
|
||||
stale_exports = list(Export.select().where(Export.in_progress == True)) # noqa: E712
|
||||
except Exception:
|
||||
|
||||
@@ -170,6 +170,7 @@ class DetectorRunner(FrigateProcess):
|
||||
mono_start = time.monotonic()
|
||||
detections = object_detector.detect_raw(input_frame)
|
||||
duration = time.monotonic() - mono_start
|
||||
frame_manager.close(connection_id)
|
||||
|
||||
if connection_id not in self.outputs:
|
||||
self.create_output_shm(connection_id)
|
||||
@@ -258,6 +259,10 @@ class AsyncDetectorRunner(FrigateProcess):
|
||||
ts = self.send_times.popleft()
|
||||
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:
|
||||
self.create_output_shm(connection_id)
|
||||
|
||||
|
||||
@@ -145,7 +145,6 @@ class OutputProcess(FrigateProcess):
|
||||
CameraConfigUpdateEnum.birdseye,
|
||||
CameraConfigUpdateEnum.enabled,
|
||||
CameraConfigUpdateEnum.record,
|
||||
CameraConfigUpdateEnum.remove,
|
||||
],
|
||||
)
|
||||
birdseye_config_subscriber = ConfigSubscriber("config/birdseye", exact=True)
|
||||
@@ -209,10 +208,6 @@ class OutputProcess(FrigateProcess):
|
||||
birdseye,
|
||||
)
|
||||
|
||||
if CameraConfigUpdateEnum.remove in updates:
|
||||
for camera in updates["remove"]:
|
||||
frame_manager.close_camera(camera)
|
||||
|
||||
_result = detection_subscriber.check_for_update(timeout=1)
|
||||
if _result is None:
|
||||
continue
|
||||
@@ -246,7 +241,7 @@ class OutputProcess(FrigateProcess):
|
||||
continue
|
||||
|
||||
frame = frame_manager.get(
|
||||
frame_name, self.config.cameras[camera].frame_shape_yuv, frame_time
|
||||
frame_name, self.config.cameras[camera].frame_shape_yuv
|
||||
)
|
||||
|
||||
if frame is None:
|
||||
@@ -301,6 +296,8 @@ class OutputProcess(FrigateProcess):
|
||||
frame,
|
||||
)
|
||||
|
||||
frame_manager.close(frame_name)
|
||||
|
||||
move_preview_frames("clips")
|
||||
|
||||
while True:
|
||||
@@ -312,6 +309,21 @@ class OutputProcess(FrigateProcess):
|
||||
if not topic or data is None:
|
||||
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()
|
||||
|
||||
for jsmpeg in jsmpeg_cameras.values():
|
||||
|
||||
@@ -124,7 +124,7 @@ class PtzMotionEstimator:
|
||||
)
|
||||
|
||||
yuv_frame = self.frame_manager.get(
|
||||
frame_name, self.camera_config.frame_shape_yuv, frame_time
|
||||
frame_name, self.camera_config.frame_shape_yuv
|
||||
)
|
||||
|
||||
if yuv_frame is None:
|
||||
@@ -181,6 +181,8 @@ class PtzMotionEstimator:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.frame_manager.close(frame_name)
|
||||
|
||||
return self.coord_transformations
|
||||
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
import pytz
|
||||
from pathvalidate import sanitize_filename
|
||||
from peewee import DoesNotExist
|
||||
|
||||
@@ -26,7 +26,6 @@ from frigate.const import (
|
||||
CACHE_DIR,
|
||||
CLIPS_DIR,
|
||||
EXPORT_DIR,
|
||||
EXPORT_STAGING_DIR,
|
||||
MAX_PLAYLIST_SECONDS,
|
||||
PREVIEW_FRAME_TYPE,
|
||||
STREAM_TYPE_MAIN,
|
||||
@@ -49,7 +48,7 @@ from frigate.util.recording_coverage import (
|
||||
stream_media_summary,
|
||||
)
|
||||
from frigate.util.services import get_video_properties
|
||||
from frigate.util.time import get_normalized_tz_name, is_current_hour
|
||||
from frigate.util.time import is_current_hour
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -560,7 +559,7 @@ class RecordingExporter(threading.Thread):
|
||||
)
|
||||
|
||||
def _staged_run_path(self, index: int) -> str:
|
||||
return os.path.join(EXPORT_STAGING_DIR, f"{self.export_id}_{index}.mp4")
|
||||
return os.path.join(CACHE_DIR, f"export_stage_{self.export_id}_{index}.mp4")
|
||||
|
||||
def _probe_stream_resolution(self, run: StreamRun) -> tuple[int, int] | None:
|
||||
"""Probe one recording from a run for its resolution.
|
||||
@@ -715,17 +714,6 @@ class RecordingExporter(threading.Thread):
|
||||
|
||||
for index, run in enumerate(runs):
|
||||
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
|
||||
base = 100.0 * completed / total_duration
|
||||
|
||||
@@ -874,8 +862,8 @@ class RecordingExporter(threading.Thread):
|
||||
tz_name = self.config.ui.timezone
|
||||
if tz_name:
|
||||
try:
|
||||
tz = ZoneInfo(get_normalized_tz_name(tz_name))
|
||||
except (ValueError, ZoneInfoNotFoundError):
|
||||
tz = pytz.timezone(tz_name)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
tz = None
|
||||
if tz is not None:
|
||||
return datetime.datetime.fromtimestamp(timestamp, tz=tz).strftime(
|
||||
@@ -1038,8 +1026,8 @@ class RecordingExporter(threading.Thread):
|
||||
tz: datetime.tzinfo | None = None
|
||||
if tz_name:
|
||||
try:
|
||||
tz = ZoneInfo(get_normalized_tz_name(tz_name))
|
||||
except (ValueError, ZoneInfoNotFoundError):
|
||||
tz = pytz.timezone(tz_name)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
tz = None
|
||||
if tz is None:
|
||||
tz = datetime.UTC
|
||||
|
||||
@@ -504,7 +504,6 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
def _handle_camera_removed(self, camera: str) -> None:
|
||||
"""Close out a deleted camera's segment so a reused name cannot inherit it."""
|
||||
self.forcibly_end_segment(camera)
|
||||
self.frame_manager.close_camera(camera)
|
||||
self.indefinite_events.pop(camera, None)
|
||||
self.recent_classification_state_changes.pop(camera, None)
|
||||
|
||||
@@ -545,7 +544,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
|
||||
try:
|
||||
yuv_frame = self.frame_manager.get(
|
||||
frame_name, camera_config.frame_shape_yuv, frame_time
|
||||
frame_name, camera_config.frame_shape_yuv
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
@@ -555,6 +554,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
return
|
||||
|
||||
pending.update_frame(camera_config, yuv_frame, objects)
|
||||
self.frame_manager.close(frame_name)
|
||||
|
||||
def update_existing_segment(
|
||||
self,
|
||||
@@ -653,7 +653,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
try:
|
||||
if should_update_image:
|
||||
yuv_frame = self.frame_manager.get(
|
||||
frame_name, camera_config.frame_shape_yuv, frame_time
|
||||
frame_name, camera_config.frame_shape_yuv
|
||||
)
|
||||
|
||||
if yuv_frame is None:
|
||||
@@ -669,6 +669,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
activity.get_all_objects(),
|
||||
prev_data,
|
||||
)
|
||||
self.frame_manager.close(frame_name)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
|
||||
@@ -676,7 +677,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
if not segment.has_frame:
|
||||
try:
|
||||
yuv_frame = self.frame_manager.get(
|
||||
frame_name, camera_config.frame_shape_yuv, frame_time
|
||||
frame_name, camera_config.frame_shape_yuv
|
||||
)
|
||||
|
||||
if yuv_frame is None:
|
||||
@@ -684,6 +685,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
return
|
||||
|
||||
segment.save_full_frame(camera_config, yuv_frame)
|
||||
self.frame_manager.close(frame_name)
|
||||
self._publish_segment_update(
|
||||
segment, camera_config, None, [], prev_data
|
||||
)
|
||||
@@ -768,7 +770,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
|
||||
try:
|
||||
yuv_frame = self.frame_manager.get(
|
||||
frame_name, camera_config.frame_shape_yuv, frame_time
|
||||
frame_name, camera_config.frame_shape_yuv
|
||||
)
|
||||
|
||||
if yuv_frame is None:
|
||||
@@ -778,6 +780,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
new_segment.update_frame(
|
||||
camera_config, yuv_frame, activity.get_all_objects()
|
||||
)
|
||||
self.frame_manager.close(frame_name)
|
||||
self._publish_segment_start(new_segment)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
|
||||
@@ -453,23 +453,6 @@ class TestHttpExport(BaseTestHttp):
|
||||
assert unchanged.name == "front door export"
|
||||
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):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
stale_video = os.path.join(tmpdir, "stale.mp4")
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import patch
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytz
|
||||
from fastapi import Request
|
||||
|
||||
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
|
||||
Clocks spring forward from 2:00 AM to 3:00 AM (EST to EDT)
|
||||
"""
|
||||
tz = ZoneInfo("America/New_York")
|
||||
tz = pytz.timezone("America/New_York")
|
||||
|
||||
# March 9, 2024 at 12:00 PM EST (before DST)
|
||||
march_9_noon = datetime(2024, 3, 9, 12, 0, 0, tzinfo=tz).timestamp()
|
||||
march_9_noon = tz.localize(datetime(2024, 3, 9, 12, 0, 0)).timestamp()
|
||||
|
||||
# March 10, 2024 at 12:00 PM EDT (after DST transition)
|
||||
march_10_noon = datetime(2024, 3, 10, 12, 0, 0, tzinfo=tz).timestamp()
|
||||
march_10_noon = tz.localize(datetime(2024, 3, 10, 12, 0, 0)).timestamp()
|
||||
|
||||
# March 11, 2024 at 12:00 PM EDT (after DST)
|
||||
march_11_noon = datetime(2024, 3, 11, 12, 0, 0, tzinfo=tz).timestamp()
|
||||
march_11_noon = tz.localize(datetime(2024, 3, 11, 12, 0, 0)).timestamp()
|
||||
|
||||
with AuthTestClient(self.app) as client:
|
||||
# Insert recordings for each day
|
||||
@@ -201,16 +201,19 @@ class TestHttpMedia(BaseTestHttp):
|
||||
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)
|
||||
"""
|
||||
tz = ZoneInfo("America/New_York")
|
||||
tz = pytz.timezone("America/New_York")
|
||||
|
||||
# November 2, 2024 at 12:00 PM EDT (before DST transition)
|
||||
nov_2_noon = datetime(2024, 11, 2, 12, 0, 0, tzinfo=tz).timestamp()
|
||||
nov_2_noon = tz.localize(datetime(2024, 11, 2, 12, 0, 0)).timestamp()
|
||||
|
||||
# November 3, 2024 at 12:00 PM EST (after DST transition)
|
||||
nov_3_noon = datetime(2024, 11, 3, 12, 0, 0, tzinfo=tz).timestamp()
|
||||
# Need to specify is_dst=False to get the time after fall back
|
||||
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)
|
||||
nov_4_noon = datetime(2024, 11, 4, 12, 0, 0, tzinfo=tz).timestamp()
|
||||
nov_4_noon = tz.localize(datetime(2024, 11, 4, 12, 0, 0)).timestamp()
|
||||
|
||||
with AuthTestClient(self.app) as client:
|
||||
# Insert recordings for each day
|
||||
@@ -271,13 +274,13 @@ class TestHttpMedia(BaseTestHttp):
|
||||
"""
|
||||
Test recordings summary with multiple cameras across DST boundary.
|
||||
"""
|
||||
tz = ZoneInfo("America/New_York")
|
||||
tz = pytz.timezone("America/New_York")
|
||||
|
||||
# March 9, 2024 at 10:00 AM EST (before DST)
|
||||
march_9_morning = datetime(2024, 3, 9, 10, 0, 0, tzinfo=tz).timestamp()
|
||||
march_9_morning = tz.localize(datetime(2024, 3, 9, 10, 0, 0)).timestamp()
|
||||
|
||||
# March 10, 2024 at 3:00 PM EDT (after DST transition)
|
||||
march_10_afternoon = datetime(2024, 3, 10, 15, 0, 0, tzinfo=tz).timestamp()
|
||||
march_10_afternoon = tz.localize(datetime(2024, 3, 10, 15, 0, 0)).timestamp()
|
||||
|
||||
with AuthTestClient(self.app) as client:
|
||||
# Override allowed cameras for this test to include both
|
||||
@@ -340,15 +343,15 @@ class TestHttpMedia(BaseTestHttp):
|
||||
"""
|
||||
Test recordings that span the exact DST transition time.
|
||||
"""
|
||||
tz = ZoneInfo("America/New_York")
|
||||
tz = pytz.timezone("America/New_York")
|
||||
|
||||
# March 10, 2024 at 1:00 AM EST (1 hour before DST transition)
|
||||
# At 2:00 AM, clocks jump to 3:00 AM
|
||||
before_transition = datetime(2024, 3, 10, 1, 0, 0, tzinfo=tz).timestamp()
|
||||
before_transition = tz.localize(datetime(2024, 3, 10, 1, 0, 0)).timestamp()
|
||||
|
||||
# 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
|
||||
after_transition = datetime(2024, 3, 10, 3, 30, 0, tzinfo=tz).timestamp()
|
||||
after_transition = tz.localize(datetime(2024, 3, 10, 3, 30, 0)).timestamp()
|
||||
|
||||
with AuthTestClient(self.app) as client:
|
||||
Recordings.insert(
|
||||
@@ -408,7 +411,7 @@ class TestHttpMedia(BaseTestHttp):
|
||||
|
||||
# Test with UTC timezone
|
||||
response = client.get(
|
||||
"/recordings/summary", params={"timezone": "UTC", "cameras": "all"}
|
||||
"/recordings/summary", params={"timezone": "utc", "cameras": "all"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -439,8 +442,8 @@ class TestHttpMedia(BaseTestHttp):
|
||||
"""
|
||||
Test recordings summary filtered to a single camera.
|
||||
"""
|
||||
tz = ZoneInfo("America/New_York")
|
||||
march_10_noon = datetime(2024, 3, 10, 12, 0, 0, tzinfo=tz).timestamp()
|
||||
tz = pytz.timezone("America/New_York")
|
||||
march_10_noon = tz.localize(datetime(2024, 3, 10, 12, 0, 0)).timestamp()
|
||||
|
||||
with AuthTestClient(self.app) as client:
|
||||
# Insert recordings for both cameras
|
||||
|
||||
@@ -342,7 +342,7 @@ class TestHttpReview(BaseTestHttp):
|
||||
"cameras": "front_door",
|
||||
"labels": "all",
|
||||
"zones": "all",
|
||||
"timezone": "UTC",
|
||||
"timezone": "utc",
|
||||
}
|
||||
response = client.get("/review/summary", params=params)
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -183,12 +183,10 @@ class TestReviewMaintainerRemoval(unittest.TestCase):
|
||||
"deleted_cam": [{"model": "gate", "from": "a", "to": "b", "timestamp": 1.0}]
|
||||
}
|
||||
maintainer.forcibly_end_segment = MagicMock()
|
||||
maintainer.frame_manager = MagicMock()
|
||||
|
||||
maintainer._handle_camera_removed("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.recent_classification_state_changes)
|
||||
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
"""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()
|
||||
@@ -653,17 +653,6 @@ class TestStagedFileCleanup(unittest.TestCase):
|
||||
|
||||
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):
|
||||
def test_failed_staging_aborts_rather_than_falling_back(self) -> None:
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
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)
|
||||
@@ -1,146 +0,0 @@
|
||||
"""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),
|
||||
)
|
||||
@@ -3,7 +3,7 @@ from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import numpy as np
|
||||
import zmq
|
||||
from pydantic import TypeAdapter
|
||||
from pydantic import parse_obj_as
|
||||
|
||||
import frigate.detectors as detectors
|
||||
import frigate.object_detection.base
|
||||
@@ -20,8 +20,8 @@ class TestLocalObjectDetector(unittest.TestCase):
|
||||
"frigate.detectors.api_types",
|
||||
{det_type: Mock() for det_type in DetectorTypeEnum},
|
||||
):
|
||||
test_cfg = TypeAdapter(DetectorConfig).validate_python(
|
||||
{"type": det_type, "model": {}}
|
||||
test_cfg = parse_obj_as(
|
||||
DetectorConfig, ({"type": det_type, "model": {}})
|
||||
)
|
||||
test_cfg.model.path = "/test/modelpath"
|
||||
test_obj = frigate.object_detection.base.LocalObjectDetector(
|
||||
@@ -45,9 +45,7 @@ class TestLocalObjectDetector(unittest.TestCase):
|
||||
TEST_DATA = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
|
||||
TEST_DETECT_RESULT = np.ndarray([1, 2, 4, 8, 16, 32])
|
||||
test_obj_detect = frigate.object_detection.base.LocalObjectDetector(
|
||||
detector_config=TypeAdapter(DetectorConfig).validate_python(
|
||||
{"type": "cpu", "model": {}}
|
||||
)
|
||||
detector_config=parse_obj_as(DetectorConfig, {"type": "cpu", "model": {}})
|
||||
)
|
||||
|
||||
mock_det_api = mock_cputfl.return_value
|
||||
@@ -70,9 +68,7 @@ class TestLocalObjectDetector(unittest.TestCase):
|
||||
TEST_DATA = np.zeros((1, 32, 32, 3), np.uint8)
|
||||
TEST_DETECT_RESULT = np.ndarray([1, 2, 4, 8, 16, 32])
|
||||
|
||||
test_cfg = TypeAdapter(DetectorConfig).validate_python(
|
||||
{"type": "cpu", "model": {}}
|
||||
)
|
||||
test_cfg = parse_obj_as(DetectorConfig, {"type": "cpu", "model": {}})
|
||||
test_cfg.model.input_tensor = InputTensorEnum.nchw
|
||||
|
||||
test_obj_detect = frigate.object_detection.base.LocalObjectDetector(
|
||||
@@ -121,9 +117,7 @@ class TestLocalObjectDetector(unittest.TestCase):
|
||||
4: "label-5",
|
||||
}
|
||||
|
||||
test_cfg = TypeAdapter(DetectorConfig).validate_python(
|
||||
{"type": "cpu", "model": {}}
|
||||
)
|
||||
test_cfg = parse_obj_as(DetectorConfig, {"type": "cpu", "model": {}})
|
||||
test_cfg.model = ModelConfig()
|
||||
test_obj_detect = frigate.object_detection.base.LocalObjectDetector(
|
||||
detector_config=test_cfg,
|
||||
|
||||
@@ -13,7 +13,6 @@ from frigate.models import Recordings
|
||||
from frigate.util.recording_coverage import (
|
||||
_rows_query,
|
||||
coverage_spans,
|
||||
null_audio_glitches,
|
||||
plan_clip,
|
||||
realized_timeline,
|
||||
resolve_coverage,
|
||||
@@ -167,22 +166,6 @@ class TestRecordingCoverage(CoverageDbTestCase):
|
||||
expected = int(5 * 1024 * 1024 * 8 / 10)
|
||||
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):
|
||||
self._insert("m1", 1000.0, 1010.0, "main")
|
||||
self._insert("o1", 1000.0, 1010.0, "main", camera="back_yard")
|
||||
|
||||
@@ -74,8 +74,8 @@ class ReviewFlowTestCase(unittest.TestCase):
|
||||
maintainer.config_subscriber.check_for_updates.return_value = {}
|
||||
maintainer.detection_subscriber = MagicMock()
|
||||
maintainer.frame_manager = MagicMock()
|
||||
maintainer.frame_manager.get.side_effect = lambda _name, shape, _t=None: (
|
||||
np.zeros(shape, np.uint8)
|
||||
maintainer.frame_manager.get.side_effect = lambda _name, shape: np.zeros(
|
||||
shape, np.uint8
|
||||
)
|
||||
return maintainer
|
||||
|
||||
|
||||
@@ -6,20 +6,13 @@ in-process cache (e.g. TrackedObjectProcessor) still holds a ref to
|
||||
the old, smaller segment.
|
||||
"""
|
||||
|
||||
import queue
|
||||
import threading
|
||||
import unittest
|
||||
from multiprocessing import Event, Value
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
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.video.ffmpeg import capture_frames
|
||||
|
||||
|
||||
def _fake_shm(size: int) -> SimpleNamespace:
|
||||
@@ -159,319 +152,5 @@ class TestSharedMemoryFrameManagerGetRecreatesLargerSegment(unittest.TestCase):
|
||||
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__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
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")
|
||||
@@ -180,9 +180,48 @@ class NorfairTracker(ObjectTracker):
|
||||
}
|
||||
|
||||
self.trackers: dict[str, dict[str, Tracker]] = {}
|
||||
self.default_tracker: dict[str, Tracker] = {}
|
||||
self.tracker_selection: tuple[bool | None, tuple[str, ...]] | None = None
|
||||
self.sync_trackers()
|
||||
# Handle static trackers
|
||||
for obj_type, tracker_config in self.object_type_configs.items():
|
||||
if obj_type in self.camera_config.objects.track:
|
||||
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:
|
||||
self.ptz_motion_estimator = PtzMotionEstimator(
|
||||
@@ -218,91 +257,18 @@ class NorfairTracker(ObjectTracker):
|
||||
|
||||
return Tracker(**tracker_params)
|
||||
|
||||
def _tracker_selection(self) -> tuple[bool | None, tuple[str, ...]]:
|
||||
autotracking = self.camera_config.onvif.autotracking
|
||||
|
||||
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 (
|
||||
def get_tracker(self, object_type: str) -> Tracker:
|
||||
"""Get the appropriate tracker based on object type and camera mode."""
|
||||
mode = (
|
||||
"ptz"
|
||||
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"
|
||||
)
|
||||
|
||||
def get_tracker(self, object_type: str) -> Tracker:
|
||||
"""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"]
|
||||
if object_type in self.trackers:
|
||||
return self.trackers[object_type][mode]
|
||||
return self.default_tracker[mode]
|
||||
|
||||
def register(self, track_id: str, obj: dict[str, Any]) -> None:
|
||||
rand_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
|
||||
@@ -568,7 +534,7 @@ class NorfairTracker(ObjectTracker):
|
||||
points = np.array([[obj[2][0], obj[2][1]], [obj[2][2], obj[2][3]]])
|
||||
|
||||
embedding = None
|
||||
if self.camera_config.onvif.autotracking.enabled and yuv_frame is not None:
|
||||
if self.camera_config.onvif.autotracking.enabled:
|
||||
embedding = get_histogram(
|
||||
yuv_frame, obj[2][0], obj[2][1], obj[2][2], obj[2][3]
|
||||
)
|
||||
@@ -621,7 +587,12 @@ class NorfairTracker(ObjectTracker):
|
||||
default_detections.extend(dets)
|
||||
|
||||
# Update default tracker with untracked detections
|
||||
tracked_objects = self.default_tracker[self._default_mode()].update(
|
||||
mode = (
|
||||
"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
|
||||
)
|
||||
all_tracked_objects.extend(tracked_objects)
|
||||
|
||||
@@ -118,9 +118,7 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
def create_camera_state(self, camera: str) -> None:
|
||||
"""Creates a new camera state."""
|
||||
|
||||
def start(
|
||||
camera: str, obj: TrackedObject, frame_name: str, frame_time: float
|
||||
) -> None:
|
||||
def start(camera: str, obj: TrackedObject, frame_name: str) -> None:
|
||||
self.event_sender.publish(
|
||||
(
|
||||
EventTypeEnum.tracked_object,
|
||||
@@ -128,13 +126,10 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
camera,
|
||||
frame_name,
|
||||
obj.to_dict(),
|
||||
frame_time,
|
||||
)
|
||||
)
|
||||
|
||||
def update(
|
||||
camera: str, obj: TrackedObject, frame_name: str, frame_time: float
|
||||
) -> None:
|
||||
def update(camera: str, obj: TrackedObject, frame_name: str) -> None:
|
||||
obj.has_snapshot = self.should_save_snapshot(
|
||||
camera_state.camera_config, obj
|
||||
)
|
||||
@@ -154,18 +149,13 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
camera,
|
||||
frame_name,
|
||||
obj.to_dict(),
|
||||
frame_time,
|
||||
)
|
||||
)
|
||||
|
||||
def autotrack(
|
||||
camera: str, obj: TrackedObject, frame_name: str, frame_time: float
|
||||
) -> None:
|
||||
def autotrack(camera: str, obj: TrackedObject, frame_name: str) -> None:
|
||||
self.ptz_autotracker_thread.autotrack_object(camera, obj)
|
||||
|
||||
def end(
|
||||
camera: str, obj: TrackedObject, frame_name: str, frame_time: float
|
||||
) -> None:
|
||||
def end(camera: str, obj: TrackedObject, frame_name: str) -> None:
|
||||
# populate has_snapshot
|
||||
obj.has_snapshot = self.should_save_snapshot(
|
||||
camera_state.camera_config, obj
|
||||
@@ -196,7 +186,6 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
camera,
|
||||
frame_name,
|
||||
obj.to_dict(),
|
||||
frame_time,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -610,7 +599,6 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
"type": source_type,
|
||||
"draw": draw,
|
||||
},
|
||||
None,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -676,7 +664,6 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
"recognized_license_plate": plate,
|
||||
"recognized_license_plate_score": score,
|
||||
},
|
||||
None,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -705,7 +692,6 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
None,
|
||||
"",
|
||||
{"id": event_id, "end_time": end_time},
|
||||
None,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -727,14 +713,13 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
def force_end_all_events(self, camera: str, camera_state: CameraState) -> None:
|
||||
"""Ends all active events on camera when disabling."""
|
||||
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()):
|
||||
if "end_time" not in obj.obj_data:
|
||||
logger.debug(f"Camera {camera} disabled, ending active event {obj_id}")
|
||||
obj.obj_data["end_time"] = datetime.datetime.now().timestamp()
|
||||
# end callbacks
|
||||
for callback in camera_state.callbacks["end"]:
|
||||
callback(camera, obj, last_frame_name, last_frame_time)
|
||||
callback(camera, obj, last_frame_name)
|
||||
|
||||
# camera activity callbacks
|
||||
for callback in camera_state.callbacks["camera_activity"]:
|
||||
@@ -763,7 +748,6 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
continue
|
||||
|
||||
camera_state.shutdown()
|
||||
self.frame_manager.close_camera(camera)
|
||||
|
||||
with self.camera_states_lock:
|
||||
self.camera_states.pop(camera)
|
||||
|
||||
@@ -747,14 +747,8 @@ def collect_object_classification_examples(
|
||||
selected_events = _select_balanced_events(events, target_count=100)
|
||||
logger.debug(f"Selected {len(selected_events)} events")
|
||||
|
||||
# Step 3: Extract thumbnails from events, falling back to the remaining
|
||||
# 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
|
||||
)
|
||||
# Step 3: Extract thumbnails from events
|
||||
thumbnails = _extract_event_thumbnails(selected_events, temp_dir)
|
||||
logger.debug(f"Successfully extracted {len(thumbnails)} thumbnails")
|
||||
|
||||
# Step 4: Select 24 most visually distinct thumbnails
|
||||
@@ -839,16 +833,10 @@ def _select_balanced_events(
|
||||
else:
|
||||
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]
|
||||
|
||||
|
||||
def _extract_event_thumbnails(
|
||||
events: list[Event], output_dir: str, target_count: int = 100
|
||||
) -> list[str]:
|
||||
def _extract_event_thumbnails(events: list[Event], output_dir: str) -> list[str]:
|
||||
"""
|
||||
Extract a training image for each event.
|
||||
|
||||
@@ -862,9 +850,8 @@ def _extract_event_thumbnails(
|
||||
using a step ladder sized from the box/region area ratio.
|
||||
|
||||
Args:
|
||||
events: List of Event objects, in order of preference
|
||||
events: List of Event objects
|
||||
output_dir: Directory to save crops
|
||||
target_count: Number of images to extract before stopping
|
||||
|
||||
Returns:
|
||||
List of paths to successfully extracted images
|
||||
@@ -872,9 +859,6 @@ def _extract_event_thumbnails(
|
||||
image_paths = []
|
||||
|
||||
for idx, event in enumerate(events):
|
||||
if len(image_paths) >= target_count:
|
||||
break
|
||||
|
||||
try:
|
||||
img = _load_event_classification_crop(event)
|
||||
if img is None:
|
||||
|
||||
+5
-105
@@ -990,23 +990,13 @@ class FrameManager(ABC):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
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:
|
||||
def get(self, name: str, timeout_ms: int = 0):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def close(self, name: str):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def close_camera(self, camera: str) -> None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete(self, name: str):
|
||||
pass
|
||||
@@ -1089,56 +1079,7 @@ class SharedMemoryFrameManager(FrameManager):
|
||||
logger.info(f"the file {name} not found")
|
||||
return 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)
|
||||
|
||||
def get(self, name: str, shape) -> np.ndarray | None:
|
||||
try:
|
||||
required = int(np.prod(shape))
|
||||
shm = self.shm_store.get(name)
|
||||
@@ -1169,14 +1110,6 @@ class SharedMemoryFrameManager(FrameManager):
|
||||
self.shm_store[name].close()
|
||||
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):
|
||||
if name in self.shm_store:
|
||||
self.shm_store[name].close()
|
||||
@@ -1308,42 +1241,9 @@ def get_image_from_recording(
|
||||
return image_data
|
||||
|
||||
|
||||
def get_histogram(
|
||||
image: np.ndarray, x_min: int, y_min: int, x_max: int, y_max: int
|
||||
) -> 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)
|
||||
def get_histogram(image, x_min, y_min, x_max, y_max):
|
||||
image_bgr = cv2.cvtColor(image, cv2.COLOR_YUV2BGR_I420)
|
||||
image_bgr = image_bgr[y_min:y_max, x_min:x_max]
|
||||
|
||||
hist = cv2.calcHist(
|
||||
[image_bgr], [0, 1, 2], None, [8, 8, 8], [0, 256, 0, 256, 0, 256]
|
||||
|
||||
@@ -204,12 +204,11 @@ def coverage_spans(intervals: list[CoverageInterval]) -> list[dict[str, Any]]:
|
||||
def stream_has_audio(intervals: list[CoverageInterval], main: bool) -> bool:
|
||||
"""Whether a stream is audio-bearing over a coverage window.
|
||||
|
||||
A stream counts as audio-bearing only when one of its rows is known
|
||||
to carry audio. A NULL row (legacy, or a segment ffprobe could not
|
||||
read) proves nothing either way.
|
||||
A stream counts as audio-bearing unless EVERY one of its rows reports
|
||||
has_audio False; NULL (legacy or undetermined) counts as audio.
|
||||
"""
|
||||
return any(
|
||||
row is not None and row.has_audio is True
|
||||
row is not None and row.has_audio is not False
|
||||
for row in ((interval.main if main else interval.sub) for interval in intervals)
|
||||
)
|
||||
|
||||
|
||||
+6
-21
@@ -3,32 +3,17 @@
|
||||
import datetime
|
||||
import logging
|
||||
import math
|
||||
from typing import Final
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError, available_timezones
|
||||
from zoneinfo import ZoneInfoNotFoundError
|
||||
|
||||
import pytz
|
||||
from tzlocal import get_localzone
|
||||
|
||||
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]:
|
||||
seconds_offset = (
|
||||
datetime.datetime.now(ZoneInfo(get_normalized_tz_name(tz_name)))
|
||||
.utcoffset()
|
||||
.total_seconds()
|
||||
datetime.datetime.now(pytz.timezone(tz_name)).utcoffset().total_seconds()
|
||||
)
|
||||
hours_offset = int(seconds_offset / 60 / 60)
|
||||
minutes_offset = int(seconds_offset / 60 - hours_offset * 60)
|
||||
@@ -41,7 +26,7 @@ def get_tomorrow_at_time(hour: int) -> datetime.datetime:
|
||||
"""Returns the datetime of the following day at 2am."""
|
||||
try:
|
||||
tomorrow = datetime.datetime.now(get_localzone()) + datetime.timedelta(days=1)
|
||||
except (ValueError, ZoneInfoNotFoundError):
|
||||
except ZoneInfoNotFoundError:
|
||||
tomorrow = datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=1)
|
||||
logger.warning(
|
||||
"Using utc for maintenance due to missing or incorrect timezone set"
|
||||
@@ -99,8 +84,8 @@ def get_dst_transitions(
|
||||
continuous periods with the same UTC offset
|
||||
"""
|
||||
try:
|
||||
tz = ZoneInfo(get_normalized_tz_name(tz_name))
|
||||
except (ValueError, ZoneInfoNotFoundError):
|
||||
tz = pytz.timezone(tz_name)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
# If timezone is invalid, return single period with no offset
|
||||
return [(start_time, end_time, 0)]
|
||||
|
||||
|
||||
@@ -144,11 +144,6 @@ class CameraTracker(FrigateProcess):
|
||||
|
||||
def run(self) -> None:
|
||||
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_shape = self.config.frame_shape
|
||||
|
||||
@@ -307,7 +302,6 @@ def process_frames(
|
||||
motion_detector.autotracking_enabled = (
|
||||
camera_config.onvif.autotracking.enabled
|
||||
)
|
||||
object_tracker.sync_trackers()
|
||||
|
||||
if (
|
||||
not camera_enabled
|
||||
@@ -607,6 +601,7 @@ def process_frames(
|
||||
)
|
||||
# add to the queue if not full
|
||||
if detected_objects_queue.full():
|
||||
frame_manager.close(frame_name)
|
||||
dropped_frames.dropped(time.monotonic())
|
||||
continue
|
||||
else:
|
||||
@@ -623,6 +618,7 @@ def process_frames(
|
||||
)
|
||||
)
|
||||
camera_metrics.detection_fps.value = object_detector.fps.eps()
|
||||
frame_manager.close(frame_name)
|
||||
|
||||
motion_detector.stop()
|
||||
requestor.stop()
|
||||
|
||||
+3
-11
@@ -64,7 +64,6 @@ def capture_frames(
|
||||
frame_rate.start()
|
||||
skipped_eps = EventsPerSecond()
|
||||
skipped_eps.start()
|
||||
frame_times = frame_manager.frame_times(config.name)
|
||||
|
||||
while not stop_event.is_set():
|
||||
# CameraWatchdog applies enabled updates onto this same CameraConfig
|
||||
@@ -79,14 +78,8 @@ def capture_frames(
|
||||
current_frame.value = datetime.now().timestamp()
|
||||
frame_name = f"{config.name}_frame{frame_index}"
|
||||
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:
|
||||
if ffmpeg_process.stdout.readinto(frame_buffer) != frame_size:
|
||||
raise EOFError("incomplete frame")
|
||||
frame_buffer[:] = ffmpeg_process.stdout.read(frame_size)
|
||||
except Exception:
|
||||
# shutdown has been initiated
|
||||
if stop_event.is_set():
|
||||
@@ -102,17 +95,16 @@ def capture_frames(
|
||||
|
||||
continue
|
||||
|
||||
frame_times[frame_index] = current_frame.value
|
||||
frame_rate.update()
|
||||
|
||||
# don't lock the queue to check, just try since it should rarely be full
|
||||
try:
|
||||
# add to the queue
|
||||
frame_queue.put((frame_name, current_frame.value), False)
|
||||
frame_manager.close(frame_name)
|
||||
except queue.Full:
|
||||
# nobody was told about this slot, so the next frame reuses it
|
||||
# if the queue is full, skip this frame
|
||||
skipped_eps.update()
|
||||
continue
|
||||
|
||||
frame_index = 0 if frame_index == shm_frame_count - 1 else frame_index + 1
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
* viewer nav restrictions; all-routes smoke.
|
||||
*/
|
||||
|
||||
import { test, expect, type FrigateApp } from "../fixtures/frigate-test";
|
||||
import { test, expect } from "../fixtures/frigate-test";
|
||||
import { systemTab } from "../helpers/system-tabs";
|
||||
import { adminProfile, viewerProfile } from "../fixtures/mock-data/profile";
|
||||
import { viewerProfile } from "../fixtures/mock-data/profile";
|
||||
|
||||
test.describe("Auth — admin access @high", () => {
|
||||
test("admin /system renders general tab", async ({ frigateApp }) => {
|
||||
@@ -109,71 +109,3 @@ 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,27 +66,6 @@ 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 = [
|
||||
{
|
||||
key: "hailo",
|
||||
@@ -105,7 +84,7 @@ async function installRoutes(
|
||||
models: Model[],
|
||||
plusEnabled = false,
|
||||
plusModels: unknown[] = [PLUS_MODEL],
|
||||
hardware?: unknown[],
|
||||
hailoHardware = false,
|
||||
) {
|
||||
const config = configFactory({
|
||||
models,
|
||||
@@ -128,9 +107,9 @@ async function installRoutes(
|
||||
route.fulfill({ json: plusModels }),
|
||||
);
|
||||
|
||||
if (hardware) {
|
||||
if (hailoHardware) {
|
||||
await page.route("**/api/hardware/probe**", (route) =>
|
||||
route.fulfill({ json: hardware }),
|
||||
route.fulfill({ json: HAILO_HARDWARE }),
|
||||
);
|
||||
}
|
||||
await page.route("**/api/config/set", async (route) => {
|
||||
@@ -223,39 +202,6 @@ test.describe("Detection models settings @high", () => {
|
||||
).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 ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
@@ -564,7 +510,7 @@ test.describe("Detection models settings @high", () => {
|
||||
],
|
||||
true,
|
||||
HAILO_PLUS_MODELS,
|
||||
HAILO_HARDWARE,
|
||||
true,
|
||||
);
|
||||
await openPage(frigateApp);
|
||||
|
||||
|
||||
@@ -14,17 +14,6 @@ const NOW = Math.floor(Date.now() / 1000);
|
||||
// the fixture detector runs at 75.5 ms, above the live warning threshold
|
||||
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 = {
|
||||
id: "model_download_failed:yolo/model.onnx",
|
||||
kind: "model_download_failed",
|
||||
@@ -62,7 +51,6 @@ test.describe("System — Health tab @medium", () => {
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
config: RESTREAMED,
|
||||
stats: QUIET_STATS,
|
||||
notices: [ERROR_NOTICE, EVENT_NOTICE],
|
||||
});
|
||||
@@ -96,7 +84,6 @@ test.describe("System — Health tab @medium", () => {
|
||||
for (const action of ["acknowledge", "mute"] as const) {
|
||||
test(`${action} posts and removes the row`, async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults({
|
||||
config: RESTREAMED,
|
||||
stats: QUIET_STATS,
|
||||
notices: [EVENT_NOTICE],
|
||||
});
|
||||
@@ -139,10 +126,7 @@ test.describe("System — Health tab @medium", () => {
|
||||
}
|
||||
|
||||
test("empty state with no notices", async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults({
|
||||
config: RESTREAMED,
|
||||
stats: QUIET_STATS,
|
||||
});
|
||||
await frigateApp.installDefaults({ stats: QUIET_STATS });
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
await expect(
|
||||
@@ -345,7 +329,6 @@ test.describe("System — Health tab @medium", () => {
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
config: RESTREAMED,
|
||||
stats: QUIET_STATS,
|
||||
notices: [ERROR_NOTICE, EVENT_NOTICE],
|
||||
});
|
||||
@@ -782,7 +765,6 @@ test.describe("System — Health notices sources @medium", () => {
|
||||
test("status bar problems stay out of the list", async ({ frigateApp }) => {
|
||||
test.skip(frigateApp.isMobile, "Status bar is desktop-only");
|
||||
await frigateApp.installDefaults({
|
||||
config: RESTREAMED,
|
||||
stats: {
|
||||
service: { retention_unmet: true },
|
||||
cameras: { front_door: { camera_fps: 0 } },
|
||||
@@ -847,28 +829,6 @@ test.describe("System — Health notices sources @medium", () => {
|
||||
).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 ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
@@ -933,10 +893,7 @@ test.describe("System — Health notices sources @medium", () => {
|
||||
test("empty state when stats, config, and registry are clean", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
config: RESTREAMED,
|
||||
stats: QUIET_STATS,
|
||||
});
|
||||
await frigateApp.installDefaults({ stats: QUIET_STATS });
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
await expect(
|
||||
@@ -1173,7 +1130,6 @@ test.describe("System — Health notices sources @medium", () => {
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "Status bar is desktop-only");
|
||||
await frigateApp.installDefaults({
|
||||
config: RESTREAMED,
|
||||
stats: QUIET_STATS,
|
||||
notices: [EVENT_NOTICE],
|
||||
});
|
||||
|
||||
Generated
+39
-17
@@ -53,7 +53,7 @@
|
||||
"js-yaml": "^5.4.3",
|
||||
"konva": "^10.7.1",
|
||||
"lodash": "^4.18.1",
|
||||
"lucide-react": "^1.52.0",
|
||||
"lucide-react": "^1.53.0",
|
||||
"monaco-yaml": "^5.5.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"nosleep.js": "^0.12.0",
|
||||
@@ -71,12 +71,12 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.18.4",
|
||||
"react-swipeable": "^7.0.2",
|
||||
"react-zoom-pan-pinch": "3.4.4",
|
||||
"react-zoom-pan-pinch": "3.7.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"scroll-into-view-if-needed": "^3.1.0",
|
||||
"sonner": "^2.0.8",
|
||||
"swr": "^2.5.1",
|
||||
"tailwind-merge": "^2.4.0",
|
||||
"tailwind-merge": "^2.6.1",
|
||||
"tailwind-scrollbar": "^3.1.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"use-long-press": "^3.3.0",
|
||||
@@ -103,7 +103,7 @@
|
||||
"eslint-plugin-react-refresh": "^0.5.7",
|
||||
"globals": "^17.13.0",
|
||||
"i18next-cli": "^1.5.11",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"monaco-editor": "^0.54.0",
|
||||
"patch-package": "^8.0.1",
|
||||
"postcss": "^8.5.29",
|
||||
"prettier": "^3.9.9",
|
||||
@@ -4929,6 +4929,12 @@
|
||||
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
||||
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.7.tgz",
|
||||
"integrity": "sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)"
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -7082,9 +7088,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.52.0.tgz",
|
||||
"integrity": "sha512-TUgPtl5ZI9WgxOcbu4ckaC5B3l1qxPQrCMgb6OfUB9owx/OvSJfcf93SMgUWAxRw5/1XDfh9uI5F5iNGXCcGDQ==",
|
||||
"version": "1.53.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.53.0.tgz",
|
||||
"integrity": "sha512-OxJ7elNku+iqVzOolk5caxq2LM8Mck6lhA6uZx3g2PlZ53+FvBnT6PlJxGCukCa6oF5wQwgSatn9cH1b4olWmQ==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
@@ -7134,6 +7140,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
|
||||
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -8078,10 +8096,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/monaco-editor": {
|
||||
"version": "0.52.2",
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz",
|
||||
"integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==",
|
||||
"license": "MIT"
|
||||
"version": "0.54.0",
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.54.0.tgz",
|
||||
"integrity": "sha512-hx45SEUoLatgWxHKCmlLJH81xBo0uXP4sRkESUpmDQevfi+e7K1VuiSprK6UpQ8u4zOcKNiH0pMvHvlMWA/4cw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dompurify": "3.1.7",
|
||||
"marked": "14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/monaco-languageserver-types": {
|
||||
"version": "0.4.0",
|
||||
@@ -9405,9 +9427,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-zoom-pan-pinch": {
|
||||
"version": "3.4.4",
|
||||
"resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-3.4.4.tgz",
|
||||
"integrity": "sha512-lGTu7D9lQpYEQ6sH+NSlLA7gicgKRW8j+D/4HO1AbSV2POvKRFzdWQ8eI0r3xmOsl4dYQcY+teV6MhULeg1xBw==",
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-3.7.0.tgz",
|
||||
"integrity": "sha512-UmReVZ0TxlKzxSbYiAj+LeGRW8s8LraAFTXRAxzMYnNRgGPsxCudwZKVkjvGmjtx7SW/hZamt69NUmGf4xrkXA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8",
|
||||
@@ -9937,9 +9959,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tailwind-merge": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.4.0.tgz",
|
||||
"integrity": "sha512-49AwoOQNKdqKPd9CViyH5wJoSKsCDjUlzL8DxuGp3P1FsGY36NJDAa18jLZcaHAUUuTj+JB8IAo8zWgBNvBF7A==",
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz",
|
||||
"integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
|
||||
+4
-4
@@ -65,7 +65,7 @@
|
||||
"js-yaml": "^5.4.3",
|
||||
"konva": "^10.7.1",
|
||||
"lodash": "^4.18.1",
|
||||
"lucide-react": "^1.52.0",
|
||||
"lucide-react": "^1.53.0",
|
||||
"monaco-yaml": "^5.5.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"nosleep.js": "^0.12.0",
|
||||
@@ -83,12 +83,12 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.18.4",
|
||||
"react-swipeable": "^7.0.2",
|
||||
"react-zoom-pan-pinch": "3.4.4",
|
||||
"react-zoom-pan-pinch": "3.7.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"scroll-into-view-if-needed": "^3.1.0",
|
||||
"sonner": "^2.0.8",
|
||||
"swr": "^2.5.1",
|
||||
"tailwind-merge": "^2.4.0",
|
||||
"tailwind-merge": "^2.6.1",
|
||||
"tailwind-scrollbar": "^3.1.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"use-long-press": "^3.3.0",
|
||||
@@ -115,7 +115,7 @@
|
||||
"eslint-plugin-react-refresh": "^0.5.7",
|
||||
"globals": "^17.13.0",
|
||||
"i18next-cli": "^1.5.11",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"monaco-editor": "^0.54.0",
|
||||
"patch-package": "^8.0.1",
|
||||
"postcss": "^8.5.29",
|
||||
"prettier": "^3.9.9",
|
||||
|
||||
@@ -1997,9 +1997,6 @@
|
||||
"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."
|
||||
},
|
||||
"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": {
|
||||
"noAudioRole": "No streams have the audio role defined. You must enable the audio role for audio detection to function."
|
||||
},
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { baseUrl } from "./baseUrl";
|
||||
|
||||
// Module-level flag to prevent multiple simultaneous redirects
|
||||
// (eg, when multiple SWR queries fail with 401 at once, or when
|
||||
// both ApiProvider and ProtectedRoute try to redirect)
|
||||
@@ -12,46 +10,3 @@ export function isRedirectingToLogin(): boolean {
|
||||
export function setRedirectingToLogin(value: boolean): void {
|
||||
_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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,7 @@ import { SWRConfig } from "swr";
|
||||
import { WsProvider } from "./WsProvider";
|
||||
import axios from "axios";
|
||||
import { ReactNode } from "react";
|
||||
import {
|
||||
getLoginUrl,
|
||||
isRedirectingToLogin,
|
||||
setRedirectingToLogin,
|
||||
} from "./auth-redirect";
|
||||
import { isRedirectingToLogin, setRedirectingToLogin } from "./auth-redirect";
|
||||
|
||||
axios.defaults.baseURL = `${baseUrl}api/`;
|
||||
|
||||
@@ -38,7 +34,7 @@ export function ApiProvider({ children, options }: ApiProviderType) {
|
||||
const loginPage = error.response.headers.get("location") ?? "login";
|
||||
if (window.location.href !== loginPage && !isRedirectingToLogin()) {
|
||||
setRedirectingToLogin(true);
|
||||
window.location.href = getLoginUrl(loginPage);
|
||||
window.location.href = loginPage;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { getPostLoginUrl } from "@/api/auth-redirect";
|
||||
import { baseUrl } from "../../api/baseUrl";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -69,7 +69,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
|
||||
username: profileRes.data.username,
|
||||
role: profileRes.data.role || "viewer",
|
||||
});
|
||||
window.location.href = getPostLoginUrl();
|
||||
window.location.href = baseUrl;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const err = error as AxiosError;
|
||||
|
||||
@@ -3,10 +3,10 @@ import { Navigate, Outlet } from "react-router-dom";
|
||||
import { AuthContext } from "@/context/auth-context";
|
||||
import ActivityIndicator from "../indicators/activity-indicator";
|
||||
import {
|
||||
getLoginUrl,
|
||||
isRedirectingToLogin,
|
||||
setRedirectingToLogin,
|
||||
} from "@/api/auth-redirect";
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
|
||||
export default function ProtectedRoute({
|
||||
requiredRoles,
|
||||
@@ -25,7 +25,7 @@ export default function ProtectedRoute({
|
||||
!isRedirectingToLogin()
|
||||
) {
|
||||
setRedirectingToLogin(true);
|
||||
window.location.href = getLoginUrl();
|
||||
window.location.href = `${baseUrl}login`;
|
||||
}
|
||||
}, [auth.isLoading, auth.isAuthenticated, auth.user]);
|
||||
|
||||
|
||||
@@ -1,24 +1,8 @@
|
||||
import { isRestreamedStream } from "@/utils/liveTranscode";
|
||||
import type { SectionConfigOverrides } from "./types";
|
||||
|
||||
const live: SectionConfigOverrides = {
|
||||
base: {
|
||||
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: [],
|
||||
fieldOrder: ["streams", "transcode", "height", "quality"],
|
||||
fieldGroups: {},
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
hardwareForDevices,
|
||||
MAX_DETECTORS,
|
||||
recommendedDetectorCount,
|
||||
resolveUnitDevice,
|
||||
} from "@/utils/detectionHardware";
|
||||
|
||||
type HardwarePickerProps = {
|
||||
@@ -61,13 +60,9 @@ export function HardwarePicker({
|
||||
return [];
|
||||
}
|
||||
|
||||
const assigned = devices.map((device) =>
|
||||
resolveUnitDevice(selected, device),
|
||||
);
|
||||
|
||||
return selected.units
|
||||
.map((unit) => unit.device)
|
||||
.filter((device) => assigned.includes(device));
|
||||
.filter((device) => devices.includes(device));
|
||||
}, [selected, devices]);
|
||||
|
||||
/** Spread `count` detectors round robin over the selected units. */
|
||||
@@ -205,7 +200,7 @@ export function HardwarePicker({
|
||||
<Checkbox
|
||||
id={`${idPrefix}-${unit.device}`}
|
||||
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
|
||||
checked={selectedUnits.includes(unit.device)}
|
||||
checked={devices.includes(unit.device)}
|
||||
disabled={disabled || Boolean(claimedBy)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleUnitToggle(unit.device, checked === true)
|
||||
|
||||
@@ -21,31 +21,10 @@ export function hardwareForDevices(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return hardware.find((entry) =>
|
||||
devices.every((device) => resolveUnitDevice(entry, 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}.`),
|
||||
)
|
||||
);
|
||||
return hardware.find((entry) => {
|
||||
const known = new Set(entry.units.map((unit) => unit.device));
|
||||
return devices.every((device) => known.has(device));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user