mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-11 17:22:49 +03:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c32af3c7f | ||
|
|
acc84d7658 |
@@ -53,6 +53,14 @@ go2rtc:
|
||||
|
||||
Point the camera's inputs at the restream as described in the [restream docs](/configuration/restream.md), and swap `detect -> width` and `detect -> height` to match the rotated resolution.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Object classification APIs."""
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
@@ -48,6 +49,9 @@ 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."""
|
||||
@@ -1290,7 +1294,10 @@ async def generate_object_examples(request: Request, body: GenerateObjectExample
|
||||
if model_name is None:
|
||||
return invalid_name_response(body.model_name)
|
||||
|
||||
collect_object_classification_examples(model_name, body.label)
|
||||
async with example_collection_lock:
|
||||
await asyncio.to_thread(
|
||||
collect_object_classification_examples, model_name, body.label
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content={"success": True, "message": "Example generation completed"},
|
||||
|
||||
@@ -8,6 +8,7 @@ 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"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
@@ -15,7 +16,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 UPDATE_JOB_STATE
|
||||
from frigate.const import EXPORT_STAGING_DIR, UPDATE_JOB_STATE
|
||||
from frigate.jobs.job import Job
|
||||
from frigate.models import Export
|
||||
from frigate.record.export import (
|
||||
@@ -415,6 +416,9 @@ def reap_stale_exports() -> None:
|
||||
this in a try/except. A failure on a single row will not stop the rest
|
||||
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:
|
||||
|
||||
@@ -26,6 +26,7 @@ from frigate.const import (
|
||||
CACHE_DIR,
|
||||
CLIPS_DIR,
|
||||
EXPORT_DIR,
|
||||
EXPORT_STAGING_DIR,
|
||||
MAX_PLAYLIST_SECONDS,
|
||||
PREVIEW_FRAME_TYPE,
|
||||
STREAM_TYPE_MAIN,
|
||||
@@ -559,7 +560,7 @@ class RecordingExporter(threading.Thread):
|
||||
)
|
||||
|
||||
def _staged_run_path(self, index: int) -> str:
|
||||
return os.path.join(CACHE_DIR, f"export_stage_{self.export_id}_{index}.mp4")
|
||||
return os.path.join(EXPORT_STAGING_DIR, f"{self.export_id}_{index}.mp4")
|
||||
|
||||
def _probe_stream_resolution(self, run: StreamRun) -> tuple[int, int] | None:
|
||||
"""Probe one recording from a run for its resolution.
|
||||
@@ -714,6 +715,17 @@ 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
|
||||
|
||||
|
||||
@@ -453,6 +453,23 @@ 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")
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Tests for object classification example extraction."""
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
|
||||
from frigate.util.classification import _extract_event_thumbnails
|
||||
|
||||
|
||||
class TestExtractEventThumbnails(unittest.TestCase):
|
||||
def test_falls_back_past_missing_images_and_stops_at_target(self):
|
||||
events = [SimpleNamespace(id=f"event_{i}") for i in range(10)]
|
||||
missing = {"event_0", "event_1", "event_2"}
|
||||
image = np.zeros((32, 32, 3), dtype=np.uint8)
|
||||
|
||||
with (
|
||||
tempfile.TemporaryDirectory() as tmpdir,
|
||||
patch(
|
||||
"frigate.util.classification._load_event_classification_crop",
|
||||
side_effect=lambda event: None if event.id in missing else image,
|
||||
) as load,
|
||||
):
|
||||
paths = _extract_event_thumbnails(events, tmpdir, target_count=4)
|
||||
|
||||
self.assertEqual(len(paths), 4)
|
||||
self.assertEqual(load.call_count, 7)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -653,6 +653,17 @@ 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:
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import unittest
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from frigate.util.image import get_histogram
|
||||
|
||||
|
||||
def reference_histogram(image, x_min, y_min, x_max, y_max):
|
||||
bgr = cv2.cvtColor(image, cv2.COLOR_YUV2BGR_I420)[y_min:y_max, x_min:x_max]
|
||||
hist = cv2.calcHist([bgr], [0, 1, 2], None, [8, 8, 8], [0, 256, 0, 256, 0, 256])
|
||||
return cv2.normalize(hist, hist).flatten()
|
||||
|
||||
|
||||
class TestGetHistogram(unittest.TestCase):
|
||||
def setUp(self):
|
||||
rng = np.random.default_rng(0)
|
||||
self.frame = rng.integers(0, 255, (720 * 3 // 2, 1280), np.uint8)
|
||||
|
||||
def test_matches_full_frame_conversion_on_even_box(self):
|
||||
box = (100, 200, 400, 600)
|
||||
np.testing.assert_array_equal(
|
||||
get_histogram(self.frame, *box), reference_histogram(self.frame, *box)
|
||||
)
|
||||
|
||||
def test_odd_box_widens_to_even_edges(self):
|
||||
np.testing.assert_array_equal(
|
||||
get_histogram(self.frame, 101, 201, 399, 599),
|
||||
reference_histogram(self.frame, 100, 200, 400, 600),
|
||||
)
|
||||
|
||||
def test_box_is_clamped_to_frame(self):
|
||||
np.testing.assert_array_equal(
|
||||
get_histogram(self.frame, -10, -10, 5000, 5000),
|
||||
reference_histogram(self.frame, 0, 0, 1280, 720),
|
||||
)
|
||||
|
||||
def test_empty_box_returns_zeros(self):
|
||||
hist = get_histogram(self.frame, 50, 50, 50, 50)
|
||||
self.assertEqual(hist.shape, (512,))
|
||||
self.assertEqual(hist.sum(), 0)
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Tracker selection when autotracking config changes at runtime."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import numpy as np
|
||||
|
||||
from frigate.camera import PTZMetrics
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.track.norfair_tracker import NorfairTracker
|
||||
|
||||
CAMERA = "ptz_cam"
|
||||
BOX = (400, 200, 500, 500)
|
||||
|
||||
|
||||
def _config(enabled: bool, track: list[str] | None = None) -> FrigateConfig:
|
||||
autotracking: dict = {"enabled": enabled, "required_zones": ["zone"]}
|
||||
|
||||
if track is not None:
|
||||
autotracking["track"] = track
|
||||
|
||||
return FrigateConfig(
|
||||
**{
|
||||
"mqtt": {"enabled": False},
|
||||
"cameras": {
|
||||
CAMERA: {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||
]
|
||||
},
|
||||
"detect": {"width": 1280, "height": 720},
|
||||
"zones": {"zone": {"coordinates": "0,0,1,0,1,1,0,1"}},
|
||||
"onvif": {"host": "10.0.0.1", "autotracking": autotracking},
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TestTrackerSelection(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.frame_time = 1000.0
|
||||
|
||||
def make_tracker(self, enabled: bool) -> NorfairTracker:
|
||||
camera_config = _config(enabled).cameras[CAMERA]
|
||||
tracker = NorfairTracker(camera_config, PTZMetrics())
|
||||
tracker.frame_manager = MagicMock()
|
||||
tracker.frame_manager.get.return_value = np.zeros(
|
||||
camera_config.frame_shape_yuv, dtype=np.uint8
|
||||
)
|
||||
tracker.ptz_motion_estimator = MagicMock()
|
||||
tracker.ptz_motion_estimator.motion_estimator.return_value = None
|
||||
return tracker
|
||||
|
||||
def apply_onvif_update(self, tracker: NorfairTracker, config: FrigateConfig):
|
||||
"""Apply an onvif config update the way the camera process does."""
|
||||
tracker.camera_config.onvif = config.cameras[CAMERA].onvif
|
||||
tracker.sync_trackers()
|
||||
|
||||
def run_frames(self, tracker: NorfairTracker, count: int, label="person"):
|
||||
for _ in range(count):
|
||||
self.frame_time += 0.2
|
||||
tracker.match_and_update(
|
||||
"frame",
|
||||
self.frame_time,
|
||||
[(label, 0.9, BOX, 30000, 0.33, (0, 0, 640, 640))],
|
||||
)
|
||||
|
||||
def test_disabling_autotracking_falls_back_to_static_tracker(self):
|
||||
tracker = self.make_tracker(enabled=True)
|
||||
self.run_frames(tracker, 10)
|
||||
self.assertIs(tracker.get_tracker("person"), tracker.trackers["person"]["ptz"])
|
||||
|
||||
self.apply_onvif_update(tracker, _config(enabled=False))
|
||||
self.run_frames(tracker, 10)
|
||||
|
||||
self.assertIs(tracker.get_tracker("person"), tracker.default_tracker["static"])
|
||||
self.assertEqual(len(tracker.tracked_objects), 1)
|
||||
|
||||
def test_enabling_autotracking_uses_ptz_tracker(self):
|
||||
tracker = self.make_tracker(enabled=False)
|
||||
self.run_frames(tracker, 10)
|
||||
self.assertIs(tracker.get_tracker("person"), tracker.default_tracker["static"])
|
||||
|
||||
self.apply_onvif_update(tracker, _config(enabled=True))
|
||||
self.run_frames(tracker, 10)
|
||||
|
||||
self.assertIs(tracker.get_tracker("person"), tracker.trackers["person"]["ptz"])
|
||||
self.assertEqual(len(tracker.tracked_objects), 1)
|
||||
|
||||
def test_removing_label_from_autotracking_keeps_tracking(self):
|
||||
tracker = self.make_tracker(enabled=True)
|
||||
self.run_frames(tracker, 10)
|
||||
|
||||
self.apply_onvif_update(tracker, _config(enabled=True, track=["car"]))
|
||||
self.run_frames(tracker, 10)
|
||||
|
||||
self.assertNotIn("person", tracker.trackers)
|
||||
self.assertIs(tracker.get_tracker("person"), tracker.default_tracker["ptz"])
|
||||
self.assertEqual(len(tracker.tracked_objects), 1)
|
||||
|
||||
def test_unrelated_onvif_update_keeps_objects(self):
|
||||
tracker = self.make_tracker(enabled=True)
|
||||
self.run_frames(tracker, 10)
|
||||
ids = set(tracker.tracked_objects)
|
||||
|
||||
config = _config(enabled=True)
|
||||
config.cameras[CAMERA].onvif.password = "changed"
|
||||
self.apply_onvif_update(tracker, config)
|
||||
self.run_frames(tracker, 5)
|
||||
|
||||
self.assertEqual(set(tracker.tracked_objects), ids)
|
||||
|
||||
def test_track_list_edit_while_disabled_keeps_objects(self):
|
||||
tracker = self.make_tracker(enabled=False)
|
||||
self.run_frames(tracker, 10)
|
||||
ids = set(tracker.tracked_objects)
|
||||
|
||||
self.apply_onvif_update(tracker, _config(enabled=False, track=["car"]))
|
||||
self.run_frames(tracker, 5)
|
||||
|
||||
self.assertEqual(set(tracker.tracked_objects), ids)
|
||||
|
||||
def test_runtime_toggle_keeps_objects(self):
|
||||
# MQTT and the autotracker only flip enabled, never enabled_in_config
|
||||
tracker = self.make_tracker(enabled=True)
|
||||
self.run_frames(tracker, 10)
|
||||
ids = set(tracker.tracked_objects)
|
||||
|
||||
tracker.camera_config.onvif.autotracking.enabled = False
|
||||
tracker.sync_trackers()
|
||||
self.run_frames(tracker, 5)
|
||||
|
||||
self.assertEqual(set(tracker.tracked_objects), ids)
|
||||
|
||||
def test_unlisted_label_uses_the_default_tracker_that_holds_it(self):
|
||||
tracker = self.make_tracker(enabled=True)
|
||||
self.run_frames(tracker, 10, label="dog")
|
||||
|
||||
default = tracker.get_tracker("dog")
|
||||
self.assertIs(default, tracker.default_tracker["ptz"])
|
||||
self.assertEqual(
|
||||
{str(o.global_id) for o in default.tracked_objects},
|
||||
set(tracker.track_id_map),
|
||||
)
|
||||
@@ -13,6 +13,7 @@ from frigate.models import Recordings
|
||||
from frigate.util.recording_coverage import (
|
||||
_rows_query,
|
||||
coverage_spans,
|
||||
null_audio_glitches,
|
||||
plan_clip,
|
||||
realized_timeline,
|
||||
resolve_coverage,
|
||||
@@ -166,6 +167,22 @@ 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")
|
||||
|
||||
@@ -180,48 +180,9 @@ class NorfairTracker(ObjectTracker):
|
||||
}
|
||||
|
||||
self.trackers: dict[str, dict[str, Tracker]] = {}
|
||||
# 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]
|
||||
),
|
||||
}
|
||||
self.default_tracker: dict[str, Tracker] = {}
|
||||
self.tracker_selection: tuple[bool | None, tuple[str, ...]] | None = None
|
||||
self.sync_trackers()
|
||||
|
||||
if self.camera_config.onvif.autotracking.enabled:
|
||||
self.ptz_motion_estimator = PtzMotionEstimator(
|
||||
@@ -257,18 +218,91 @@ class NorfairTracker(ObjectTracker):
|
||||
|
||||
return Tracker(**tracker_params)
|
||||
|
||||
def get_tracker(self, object_type: str) -> Tracker:
|
||||
"""Get the appropriate tracker based on object type and camera mode."""
|
||||
mode = (
|
||||
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 (
|
||||
"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"
|
||||
)
|
||||
if object_type in self.trackers:
|
||||
return self.trackers[object_type][mode]
|
||||
return self.default_tracker[mode]
|
||||
|
||||
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"]
|
||||
|
||||
def register(self, track_id: str, obj: dict[str, Any]) -> None:
|
||||
rand_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
|
||||
@@ -534,7 +568,7 @@ class NorfairTracker(ObjectTracker):
|
||||
points = np.array([[obj[2][0], obj[2][1]], [obj[2][2], obj[2][3]]])
|
||||
|
||||
embedding = None
|
||||
if self.camera_config.onvif.autotracking.enabled:
|
||||
if self.camera_config.onvif.autotracking.enabled and yuv_frame is not None:
|
||||
embedding = get_histogram(
|
||||
yuv_frame, obj[2][0], obj[2][1], obj[2][2], obj[2][3]
|
||||
)
|
||||
@@ -587,12 +621,7 @@ class NorfairTracker(ObjectTracker):
|
||||
default_detections.extend(dets)
|
||||
|
||||
# Update default tracker with untracked detections
|
||||
mode = (
|
||||
"ptz"
|
||||
if self.camera_config.onvif.autotracking.enabled_in_config
|
||||
else "static"
|
||||
)
|
||||
tracked_objects = self.default_tracker[mode].update(
|
||||
tracked_objects = self.default_tracker[self._default_mode()].update(
|
||||
detections=default_detections, coord_transformations=coord_transformations
|
||||
)
|
||||
all_tracked_objects.extend(tracked_objects)
|
||||
|
||||
@@ -747,8 +747,14 @@ 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
|
||||
thumbnails = _extract_event_thumbnails(selected_events, temp_dir)
|
||||
# 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
|
||||
)
|
||||
logger.debug(f"Successfully extracted {len(thumbnails)} thumbnails")
|
||||
|
||||
# Step 4: Select 24 most visually distinct thumbnails
|
||||
@@ -833,10 +839,16 @@ 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) -> list[str]:
|
||||
def _extract_event_thumbnails(
|
||||
events: list[Event], output_dir: str, target_count: int = 100
|
||||
) -> list[str]:
|
||||
"""
|
||||
Extract a training image for each event.
|
||||
|
||||
@@ -850,8 +862,9 @@ def _extract_event_thumbnails(events: list[Event], output_dir: str) -> list[str]
|
||||
using a step ladder sized from the box/region area ratio.
|
||||
|
||||
Args:
|
||||
events: List of Event objects
|
||||
events: List of Event objects, in order of preference
|
||||
output_dir: Directory to save crops
|
||||
target_count: Number of images to extract before stopping
|
||||
|
||||
Returns:
|
||||
List of paths to successfully extracted images
|
||||
@@ -859,6 +872,9 @@ def _extract_event_thumbnails(events: list[Event], output_dir: str) -> list[str]
|
||||
image_paths = []
|
||||
|
||||
for idx, event in enumerate(events):
|
||||
if len(image_paths) >= target_count:
|
||||
break
|
||||
|
||||
try:
|
||||
img = _load_event_classification_crop(event)
|
||||
if img is None:
|
||||
|
||||
+36
-3
@@ -1241,9 +1241,42 @@ def get_image_from_recording(
|
||||
return image_data
|
||||
|
||||
|
||||
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]
|
||||
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)
|
||||
|
||||
hist = cv2.calcHist(
|
||||
[image_bgr], [0, 1, 2], None, [8, 8, 8], [0, 256, 0, 256, 0, 256]
|
||||
|
||||
@@ -204,11 +204,12 @@ 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 unless EVERY one of its rows reports
|
||||
has_audio False; NULL (legacy or undetermined) counts as audio.
|
||||
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.
|
||||
"""
|
||||
return any(
|
||||
row is not None and row.has_audio is not False
|
||||
row is not None and row.has_audio is True
|
||||
for row in ((interval.main if main else interval.sub) for interval in intervals)
|
||||
)
|
||||
|
||||
|
||||
@@ -302,6 +302,7 @@ def process_frames(
|
||||
motion_detector.autotracking_enabled = (
|
||||
camera_config.onvif.autotracking.enabled
|
||||
)
|
||||
object_tracker.sync_trackers()
|
||||
|
||||
if (
|
||||
not camera_enabled
|
||||
|
||||
@@ -66,6 +66,27 @@ const HAILO_PLUS_MODELS = [
|
||||
},
|
||||
];
|
||||
|
||||
// a migrated `type: onnx` detector is the bare "onnx", and a Ryzen iGPU is
|
||||
// always probed alongside an NVIDIA card
|
||||
const ONNX_GPU_HARDWARE = [
|
||||
{
|
||||
key: "onnx:nvidia",
|
||||
detector: "onnx",
|
||||
name: "NVIDIA GeForce RTX 3060",
|
||||
units: [{ device: "onnx:0", label: "NVIDIA GeForce RTX 3060" }],
|
||||
count: 1,
|
||||
unlimited: true,
|
||||
},
|
||||
{
|
||||
key: "onnx:amd",
|
||||
detector: "onnx",
|
||||
name: "AMD GPU",
|
||||
units: [{ device: "onnx", label: "renderD128" }],
|
||||
count: 1,
|
||||
unlimited: true,
|
||||
},
|
||||
];
|
||||
|
||||
const HAILO_HARDWARE = [
|
||||
{
|
||||
key: "hailo",
|
||||
@@ -84,7 +105,7 @@ async function installRoutes(
|
||||
models: Model[],
|
||||
plusEnabled = false,
|
||||
plusModels: unknown[] = [PLUS_MODEL],
|
||||
hailoHardware = false,
|
||||
hardware?: unknown[],
|
||||
) {
|
||||
const config = configFactory({
|
||||
models,
|
||||
@@ -107,9 +128,9 @@ async function installRoutes(
|
||||
route.fulfill({ json: plusModels }),
|
||||
);
|
||||
|
||||
if (hailoHardware) {
|
||||
if (hardware) {
|
||||
await page.route("**/api/hardware/probe**", (route) =>
|
||||
route.fulfill({ json: HAILO_HARDWARE }),
|
||||
route.fulfill({ json: hardware }),
|
||||
);
|
||||
}
|
||||
await page.route("**/api/config/set", async (route) => {
|
||||
@@ -202,6 +223,39 @@ 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,
|
||||
}) => {
|
||||
@@ -510,7 +564,7 @@ test.describe("Detection models settings @high", () => {
|
||||
],
|
||||
true,
|
||||
HAILO_PLUS_MODELS,
|
||||
true,
|
||||
HAILO_HARDWARE,
|
||||
);
|
||||
await openPage(frigateApp);
|
||||
|
||||
|
||||
@@ -14,6 +14,17 @@ 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",
|
||||
@@ -51,6 +62,7 @@ test.describe("System — Health tab @medium", () => {
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
config: RESTREAMED,
|
||||
stats: QUIET_STATS,
|
||||
notices: [ERROR_NOTICE, EVENT_NOTICE],
|
||||
});
|
||||
@@ -84,6 +96,7 @@ 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],
|
||||
});
|
||||
@@ -126,7 +139,10 @@ test.describe("System — Health tab @medium", () => {
|
||||
}
|
||||
|
||||
test("empty state with no notices", async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults({ stats: QUIET_STATS });
|
||||
await frigateApp.installDefaults({
|
||||
config: RESTREAMED,
|
||||
stats: QUIET_STATS,
|
||||
});
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
await expect(
|
||||
@@ -329,6 +345,7 @@ test.describe("System — Health tab @medium", () => {
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
config: RESTREAMED,
|
||||
stats: QUIET_STATS,
|
||||
notices: [ERROR_NOTICE, EVENT_NOTICE],
|
||||
});
|
||||
@@ -765,6 +782,7 @@ test.describe("System — Health notices sources @medium", () => {
|
||||
test("status bar problems stay out of the list", async ({ frigateApp }) => {
|
||||
test.skip(frigateApp.isMobile, "Status bar is desktop-only");
|
||||
await frigateApp.installDefaults({
|
||||
config: RESTREAMED,
|
||||
stats: {
|
||||
service: { retention_unmet: true },
|
||||
cameras: { front_door: { camera_fps: 0 } },
|
||||
@@ -829,6 +847,28 @@ 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,
|
||||
}) => {
|
||||
@@ -893,7 +933,10 @@ test.describe("System — Health notices sources @medium", () => {
|
||||
test("empty state when stats, config, and registry are clean", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({ stats: QUIET_STATS });
|
||||
await frigateApp.installDefaults({
|
||||
config: RESTREAMED,
|
||||
stats: QUIET_STATS,
|
||||
});
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
await expect(
|
||||
@@ -1130,6 +1173,7 @@ 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],
|
||||
});
|
||||
|
||||
@@ -1997,6 +1997,9 @@
|
||||
"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,8 +1,24 @@
|
||||
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,6 +15,7 @@ import {
|
||||
hardwareForDevices,
|
||||
MAX_DETECTORS,
|
||||
recommendedDetectorCount,
|
||||
resolveUnitDevice,
|
||||
} from "@/utils/detectionHardware";
|
||||
|
||||
type HardwarePickerProps = {
|
||||
@@ -60,9 +61,13 @@ export function HardwarePicker({
|
||||
return [];
|
||||
}
|
||||
|
||||
const assigned = devices.map((device) =>
|
||||
resolveUnitDevice(selected, device),
|
||||
);
|
||||
|
||||
return selected.units
|
||||
.map((unit) => unit.device)
|
||||
.filter((device) => devices.includes(device));
|
||||
.filter((device) => assigned.includes(device));
|
||||
}, [selected, devices]);
|
||||
|
||||
/** Spread `count` detectors round robin over the selected units. */
|
||||
@@ -200,7 +205,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={devices.includes(unit.device)}
|
||||
checked={selectedUnits.includes(unit.device)}
|
||||
disabled={disabled || Boolean(claimedBy)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleUnitToggle(unit.device, checked === true)
|
||||
|
||||
@@ -21,10 +21,31 @@ export function hardwareForDevices(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return hardware.find((entry) => {
|
||||
const known = new Set(entry.units.map((unit) => unit.device));
|
||||
return devices.every((device) => known.has(device));
|
||||
});
|
||||
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}.`),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user