mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-08-02 17:12:16 +03:00
Refactor motion search (#23378)
* refactor motion search * cleanup dead code and tests * tweaks * fix multi-day seeking * start playback a few seconds before the change so the motion is in view
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
"""Tests for motion search batch helpers (runs + timestamp mapping)."""
|
||||
|
||||
import unittest
|
||||
from dataclasses import dataclass
|
||||
|
||||
from frigate.jobs.motion_search_batch import (
|
||||
build_segment_time_map,
|
||||
coalesce_runs,
|
||||
stream_time_to_absolute,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Seg:
|
||||
path: str
|
||||
start_time: float
|
||||
end_time: float
|
||||
|
||||
|
||||
def _run_seconds(run):
|
||||
return float(run[-1].end_time) - float(run[0].start_time)
|
||||
|
||||
|
||||
class TestCoalesceRuns(unittest.TestCase):
|
||||
def test_contiguous_segments_form_one_run(self):
|
||||
segs = [_Seg("a", 0.0, 10.0), _Seg("b", 10.0, 20.0), _Seg("c", 20.0, 30.0)]
|
||||
runs = coalesce_runs(segs, max_seconds=600.0, epsilon=0.5)
|
||||
self.assertEqual(len(runs), 1)
|
||||
self.assertEqual(len(runs[0]), 3)
|
||||
|
||||
def test_time_gap_splits_runs(self):
|
||||
# b ends 20, c starts 25 -> 5s gap > epsilon -> two runs.
|
||||
segs = [_Seg("a", 0.0, 10.0), _Seg("b", 10.0, 20.0), _Seg("c", 25.0, 35.0)]
|
||||
runs = coalesce_runs(segs, max_seconds=600.0, epsilon=0.5)
|
||||
self.assertEqual([len(r) for r in runs], [2, 1])
|
||||
|
||||
def test_max_duration_caps_a_run(self):
|
||||
# Five contiguous 10s segments, cap 25s.
|
||||
segs = [_Seg(str(i), i * 10.0, i * 10.0 + 10.0) for i in range(5)]
|
||||
runs = coalesce_runs(segs, max_seconds=25.0, epsilon=0.5)
|
||||
self.assertTrue(all(_run_seconds(r) <= 30.0 for r in runs))
|
||||
self.assertEqual(sum(len(r) for r in runs), 5)
|
||||
|
||||
def test_empty(self):
|
||||
self.assertEqual(coalesce_runs([], max_seconds=600.0, epsilon=0.5), [])
|
||||
|
||||
|
||||
class TestTimestampMapping(unittest.TestCase):
|
||||
def test_gapfree_run_maps_to_start_plus_pts(self):
|
||||
run = [_Seg("a", 1000.0, 1010.0), _Seg("b", 1010.0, 1020.0)]
|
||||
time_map = build_segment_time_map(run)
|
||||
self.assertAlmostEqual(stream_time_to_absolute(time_map, 3.0), 1003.0)
|
||||
self.assertAlmostEqual(stream_time_to_absolute(time_map, 12.0), 1012.0)
|
||||
|
||||
def test_past_end_clamps(self):
|
||||
run = [_Seg("a", 1000.0, 1010.0)]
|
||||
time_map = build_segment_time_map(run)
|
||||
self.assertAlmostEqual(stream_time_to_absolute(time_map, 9.9), 1009.9)
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Tests for the motion search hardware-accelerated decode helpers."""
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
from frigate.jobs.motion_search_decode import (
|
||||
KEYFRAME_MAX_GAP_SECONDS,
|
||||
build_vod_decode_command,
|
||||
keyframe_sampling_eligible,
|
||||
probe_video_dimensions,
|
||||
probe_vod_keyframe_pts,
|
||||
resolve_motion_decode_args,
|
||||
)
|
||||
|
||||
|
||||
def _fake_camera_config(
|
||||
hwaccel_args, gpu=0, fps=5, width=1280, height=720, ffmpeg_path="ffmpeg"
|
||||
):
|
||||
return SimpleNamespace(
|
||||
ffmpeg=SimpleNamespace(
|
||||
hwaccel_args=hwaccel_args, gpu=gpu, ffmpeg_path=ffmpeg_path
|
||||
),
|
||||
detect=SimpleNamespace(fps=fps, width=width, height=height),
|
||||
)
|
||||
|
||||
|
||||
class TestResolveMotionDecodeArgs(unittest.TestCase):
|
||||
def test_vaapi_preset_is_accelerated(self):
|
||||
args = resolve_motion_decode_args(_fake_camera_config("preset-vaapi"))
|
||||
self.assertIn("-hwaccel", args)
|
||||
self.assertIn("vaapi", args)
|
||||
|
||||
def test_non_nv12_preset_falls_back_to_software(self):
|
||||
# rkmpp produces drm_prime surfaces that do not download to nv12, so it
|
||||
# must resolve to software decode (empty args) rather than risk corrupt
|
||||
# frames.
|
||||
self.assertEqual(
|
||||
resolve_motion_decode_args(_fake_camera_config("preset-rkmpp")), []
|
||||
)
|
||||
|
||||
def test_custom_args_fall_back_to_software(self):
|
||||
# Arbitrary custom hwaccel args (a list, not a preset) decode in software
|
||||
# to preserve byte-identical results.
|
||||
self.assertEqual(
|
||||
resolve_motion_decode_args(_fake_camera_config(["-hwaccel", "vulkan"])),
|
||||
[],
|
||||
)
|
||||
|
||||
def test_nvidia_codec_preset_is_accelerated(self):
|
||||
# Codec-specific nvidia presets resolve to the same cuda decode args as
|
||||
# the bare preset, so eligibility is derived from -hwaccel_output_format
|
||||
# rather than a hardcoded list that omitted these aliases.
|
||||
args = resolve_motion_decode_args(_fake_camera_config("preset-nvidia-h264"))
|
||||
self.assertIn("-hwaccel_output_format", args)
|
||||
self.assertIn("cuda", args)
|
||||
|
||||
def test_software_only_preset_falls_back_to_software(self):
|
||||
# A preset with no -hwaccel_output_format (decoder-based, no GPU surface)
|
||||
# cannot use the nv12 download step, so it decodes in software.
|
||||
self.assertEqual(
|
||||
resolve_motion_decode_args(_fake_camera_config("preset-rpi-64-h264")), []
|
||||
)
|
||||
|
||||
|
||||
class TestKeyframeEligibility(unittest.TestCase):
|
||||
def test_regular_short_gop_is_eligible(self):
|
||||
pts = [0.0, 0.5, 1.0, 1.5, 2.0] # 0.5s gaps
|
||||
self.assertTrue(keyframe_sampling_eligible(pts))
|
||||
|
||||
def test_long_gop_is_ineligible(self):
|
||||
pts = [0.0, 5.0, 10.0] # 5s gaps
|
||||
self.assertFalse(keyframe_sampling_eligible(pts))
|
||||
|
||||
def test_irregular_gop_ineligible_when_a_gap_is_long(self):
|
||||
pts = [0.0, 0.5, 1.0, 8.0] # one 7s gap
|
||||
self.assertFalse(keyframe_sampling_eligible(pts))
|
||||
|
||||
def test_too_few_keyframes_ineligible(self):
|
||||
self.assertFalse(keyframe_sampling_eligible([1.0]))
|
||||
self.assertFalse(keyframe_sampling_eligible([]))
|
||||
|
||||
def test_default_max_gap_constant(self):
|
||||
self.assertEqual(KEYFRAME_MAX_GAP_SECONDS, 2.0)
|
||||
|
||||
|
||||
class TestVodDecodeCommand(unittest.TestCase):
|
||||
URL = "http://127.0.0.1:5000/vod/cam/start/1/end/2/index.m3u8"
|
||||
|
||||
def test_keyframe_command_shape(self):
|
||||
cmd = build_vod_decode_command(
|
||||
"ffmpeg",
|
||||
self.URL,
|
||||
decode_args=[],
|
||||
crop=(100, 80, 10, 20),
|
||||
scale=(50, 40),
|
||||
gray=True,
|
||||
skip_nonkey=True,
|
||||
fps_rate=None,
|
||||
)
|
||||
joined = " ".join(cmd)
|
||||
self.assertIn("-skip_frame nokey", joined)
|
||||
self.assertIn("-protocol_whitelist pipe,file,http,tcp", joined)
|
||||
self.assertIn(f"-i {self.URL}", joined)
|
||||
self.assertIn("crop=100:80:10:20", joined)
|
||||
self.assertIn("scale=50:40", joined)
|
||||
self.assertIn("-pix_fmt gray", joined)
|
||||
self.assertNotIn("fps=", joined)
|
||||
|
||||
def test_fps_command_uses_fps_filter_not_skip_frame(self):
|
||||
cmd = build_vod_decode_command(
|
||||
"ffmpeg",
|
||||
self.URL,
|
||||
decode_args=[],
|
||||
crop=None,
|
||||
scale=None,
|
||||
gray=False,
|
||||
skip_nonkey=False,
|
||||
fps_rate=2.0,
|
||||
)
|
||||
joined = " ".join(cmd)
|
||||
self.assertNotIn("skip_frame", joined)
|
||||
self.assertIn("fps=2.0", joined)
|
||||
self.assertIn("-pix_fmt bgr24", joined)
|
||||
|
||||
def test_hwaccel_inserts_hwdownload(self):
|
||||
cmd = build_vod_decode_command(
|
||||
"ffmpeg",
|
||||
self.URL,
|
||||
decode_args=["-hwaccel", "vaapi"],
|
||||
crop=None,
|
||||
scale=None,
|
||||
gray=True,
|
||||
skip_nonkey=True,
|
||||
fps_rate=None,
|
||||
)
|
||||
joined = " ".join(cmd)
|
||||
self.assertIn("hwdownload", joined)
|
||||
self.assertIn("format=nv12", joined)
|
||||
|
||||
|
||||
class TestProbeVodKeyframePts(unittest.TestCase):
|
||||
def test_parses_keyframe_packets(self):
|
||||
sample = (
|
||||
'{"packets":['
|
||||
'{"pts_time":"0.000000","flags":"K__"},'
|
||||
'{"pts_time":"1.000000","flags":"___"},'
|
||||
'{"pts_time":"2.000000","flags":"K__"}]}'
|
||||
)
|
||||
completed = mock.Mock(stdout=sample, returncode=0)
|
||||
with mock.patch(
|
||||
"frigate.jobs.motion_search_decode.sp.run", return_value=completed
|
||||
):
|
||||
pts = probe_vod_keyframe_pts("ffprobe", "http://x/index.m3u8")
|
||||
self.assertEqual(pts, [0.0, 2.0])
|
||||
|
||||
def test_returns_empty_on_failure(self):
|
||||
with mock.patch(
|
||||
"frigate.jobs.motion_search_decode.sp.run",
|
||||
side_effect=OSError("boom"),
|
||||
):
|
||||
self.assertEqual(probe_vod_keyframe_pts("ffprobe", "http://x"), [])
|
||||
|
||||
|
||||
class TestProbeVideoDimensions(unittest.TestCase):
|
||||
def test_parses_dimensions_and_fps(self):
|
||||
sample = (
|
||||
'{"streams":[{"width":1920,"height":1080,"avg_frame_rate":"30000/1001"}]}'
|
||||
)
|
||||
completed = mock.Mock(stdout=sample, returncode=0)
|
||||
with mock.patch(
|
||||
"frigate.jobs.motion_search_decode.sp.run", return_value=completed
|
||||
):
|
||||
dims = probe_video_dimensions("ffprobe", "/tmp/a.mp4")
|
||||
assert dims is not None
|
||||
width, height, fps = dims
|
||||
self.assertEqual((width, height), (1920, 1080))
|
||||
self.assertAlmostEqual(fps, 29.97, places=2)
|
||||
|
||||
def test_returns_none_on_zero_dimensions(self):
|
||||
sample = '{"streams":[{"width":0,"height":0,"avg_frame_rate":"0/0"}]}'
|
||||
completed = mock.Mock(stdout=sample, returncode=0)
|
||||
with mock.patch(
|
||||
"frigate.jobs.motion_search_decode.sp.run", return_value=completed
|
||||
):
|
||||
self.assertIsNone(probe_video_dimensions("ffprobe", "/tmp/a.mp4"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Tests for motion search spatial (crop/scale/mask) helpers."""
|
||||
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
|
||||
from frigate.jobs.motion_search import (
|
||||
build_scaled_roi_mask,
|
||||
compute_roi_crop_and_scale,
|
||||
detect_motion_scaled,
|
||||
)
|
||||
|
||||
|
||||
class TestComputeRoiCropAndScale(unittest.TestCase):
|
||||
def test_crop_box_in_record_pixels(self):
|
||||
# ROI covering x [0.25, 0.75], y [0.5, 1.0] of a 1000x600 frame.
|
||||
polygon = [[0.25, 0.5], [0.75, 0.5], [0.75, 1.0], [0.25, 1.0]]
|
||||
crop, scaled = compute_roi_crop_and_scale(polygon, 1000, 600, scale_target=125)
|
||||
cw, ch, cx, cy = crop
|
||||
self.assertEqual((cx, cy), (250, 300))
|
||||
self.assertEqual((cw, ch), (500, 300))
|
||||
# longest side 500 -> factor 0.25 -> (125, 75), rounded down to even.
|
||||
self.assertEqual(scaled, (124, 74))
|
||||
|
||||
def test_never_upscales(self):
|
||||
polygon = [[0.0, 0.0], [0.1, 0.0], [0.1, 0.1], [0.0, 0.1]]
|
||||
crop, scaled = compute_roi_crop_and_scale(polygon, 200, 200, scale_target=400)
|
||||
cw, ch, _, _ = crop
|
||||
# crop is 20x20; target 400 would upscale, so scaled == crop size.
|
||||
self.assertEqual(scaled, (cw, ch))
|
||||
|
||||
def test_scaled_dims_are_at_least_one(self):
|
||||
polygon = [[0.0, 0.0], [0.02, 0.0], [0.02, 0.02], [0.0, 0.02]]
|
||||
crop, scaled = compute_roi_crop_and_scale(polygon, 50, 50, scale_target=1)
|
||||
self.assertGreaterEqual(scaled[0], 1)
|
||||
self.assertGreaterEqual(scaled[1], 1)
|
||||
|
||||
def test_all_dims_are_even_for_nv12(self):
|
||||
# Odd-aligned ROI on an odd-ish frame must still yield even crop/scale so
|
||||
# the nv12 hwdownload byte stream matches the expected frame size.
|
||||
polygon = [[0.123, 0.321], [0.777, 0.321], [0.777, 0.901], [0.123, 0.901]]
|
||||
crop, scaled = compute_roi_crop_and_scale(polygon, 1377, 911, scale_target=257)
|
||||
for value in (*crop, *scaled):
|
||||
self.assertEqual(value % 2, 0, f"{value} is not even")
|
||||
|
||||
|
||||
class TestBuildScaledRoiMask(unittest.TestCase):
|
||||
def test_mask_matches_scaled_dims_and_has_coverage(self):
|
||||
polygon = [[0.25, 0.5], [0.75, 0.5], [0.75, 1.0], [0.25, 1.0]]
|
||||
crop, scaled = compute_roi_crop_and_scale(polygon, 1000, 600, scale_target=125)
|
||||
mask = build_scaled_roi_mask(polygon, 1000, 600, crop, scaled)
|
||||
self.assertEqual(mask.shape, (scaled[1], scaled[0]))
|
||||
self.assertEqual(mask.dtype, np.uint8)
|
||||
# A full rectangle ROI fills its whole crop -> mask is all 255.
|
||||
self.assertGreater(np.count_nonzero(mask), 0)
|
||||
self.assertEqual(np.count_nonzero(mask), mask.size)
|
||||
|
||||
|
||||
class TestDetectMotionScaled(unittest.TestCase):
|
||||
def _ts(self, idx):
|
||||
return float(idx)
|
||||
|
||||
def test_finds_change_between_frames(self):
|
||||
mask = np.full((60, 80), 255, dtype=np.uint8)
|
||||
f0 = np.zeros((60, 80), dtype=np.uint8)
|
||||
f1 = np.zeros((60, 80), dtype=np.uint8)
|
||||
f1[10:50, 20:60] = 255 # big bright block appears
|
||||
frames = [(0, f0), (30, f1)]
|
||||
results = detect_motion_scaled(
|
||||
frames, mask, threshold=30, min_area=1.0, timestamp_fn=self._ts
|
||||
)
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertEqual(results[0].timestamp, 30.0)
|
||||
self.assertGreater(results[0].change_percentage, 0.0)
|
||||
|
||||
def test_no_change_yields_nothing(self):
|
||||
mask = np.full((60, 80), 255, dtype=np.uint8)
|
||||
f0 = np.zeros((60, 80), dtype=np.uint8)
|
||||
f1 = np.zeros((60, 80), dtype=np.uint8)
|
||||
results = detect_motion_scaled(
|
||||
[(0, f0), (30, f1)], mask, threshold=30, min_area=1.0, timestamp_fn=self._ts
|
||||
)
|
||||
self.assertEqual(results, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user