mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-16 19:52:49 +03:00
Fixes (#24631)
* fix classification wizard finding no sample images on large databases The wizard grouped tracked objects by camera and 6 hour block, oldest first, then kept the first 100. With more than 100 groups that was always the oldest objects, which often have no snapshot or thumbnail left on disk, so no examples were generated. Shuffle the selection before truncating, and keep extracting from the remaining tracked objects until 100 usable images are found. * add notice and system ui message when a camera isn't using go2rtc * add docs note about privacy masks * match index-less device strings to hardware units in the detection models picker A config with `edgetpu:usb` was reported as hardware that wasn't found, because the picker compared device strings exactly and the probe reports the unit as `edgetpu:usb:0`. A device without an index now resolves to the first unit of that kind, so the hardware dropdown, the unit checkboxes, and the model summary all recognize it. * don't treat unknown audio as an audio-bearing stream A recording row with NULL has_audio (ffprobe failed and the cv2 fallback can't report audio) marked the whole stream as audio-bearing, so every confirmed video-only row on it was dropped as a glitch. A stream now counts as audio-bearing only when a row is known to carry audio. * stage main+sub exports on disk instead of /tmp/cache A main+sub export stages a full copy of itself before the final file is written. That copy went to /tmp/cache, so a long export outgrew the tmpfs and failed with no space left on device. Staged runs are now written to the exports directory, and startup removes any left behind by a killed export. * address review feedback - stage main+sub export runs in a staging subfolder of the exports directory, so media sync can't delete them mid-export and startup cleanup can't remove a finished export with a matching name or fail on an unremovable file - run object classification example collection off the event loop - prefer an exact hardware unit match over an index-less one, so an AMD GPU's "onnx" no longer selects the NVIDIA entry - add tests for the classification fallback and index-less hardware matching * serialize example collection and clean up exports when staging dir creation fails - overlapping object example requests could delete each other's images in the shared temp and train directories, so collection now runs under a lock - a failed makedirs for the staging directory raised past the failed-export cleanup and left the export spinning until restart, so it now fails the run through the normal cleanup path * revert
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user