mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-11-12 16:43:01 +03:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6915172c0 | ||
|
|
3e9cbfbd84 | ||
|
|
06d1365056 | ||
|
|
8a88cefc9a | ||
|
|
fba967c74b |
@@ -105,6 +105,8 @@ if [[ -n "$sentinel" ]]; then
|
||||
mismatch_expr+=(-a ! -path "$sentinel")
|
||||
fi
|
||||
|
||||
exec 3>&1
|
||||
|
||||
for path in "$@"; do
|
||||
# An absent root is an incomplete sweep, not a finished one: /media/frigate
|
||||
# is not in the image, so a boot before the volume is mounted would
|
||||
@@ -121,7 +123,18 @@ for path in "$@"; do
|
||||
# stale mount. Tolerate it rather than aborting under errexit, but never
|
||||
# read a failed scan as "nothing to do": that would record the sweep as
|
||||
# complete without having looked.
|
||||
if ! count=$(find "$path" -name lost+found -prune -o "${mismatch_expr[@]}" -printf '.' 2>/dev/null | wc -c); then
|
||||
# The walk prints nothing until it ends, so report every 30s through fd 3
|
||||
# (stdout) while the count itself comes back through the substitution.
|
||||
if ! count=$(find "$path" -name lost+found -prune -o "(" "${mismatch_expr[@]}" ")" -printf 'm\n' -o -printf '.\n' 2>/dev/null \
|
||||
| awk -v path="$path" '
|
||||
BEGIN { next_report = systime() + 30 }
|
||||
$0 == "m" { mismatched++ }
|
||||
systime() >= next_report {
|
||||
printf "[INFO] fix-ownership: still scanning %s, %d entries checked, %d need changes\n", path, NR, mismatched > "/dev/fd/3"
|
||||
fflush("/dev/fd/3")
|
||||
next_report = systime() + 30
|
||||
}
|
||||
END { print mismatched + 0 }'); then
|
||||
swept_clean=0
|
||||
echo "[WARN] fix-ownership: could not scan ${path}; will retry on next boot"
|
||||
continue
|
||||
|
||||
@@ -71,13 +71,14 @@ Both the script and the boot sweep report progress, so you can tell a slow sweep
|
||||
|
||||
```
|
||||
[INFO] fix-ownership: scanning /media/frigate for ownership mismatches; this may take a while on large filesystems
|
||||
[INFO] fix-ownership: still scanning /media/frigate, 2481000 entries checked, 2390112 need changes
|
||||
[WARN] fix-ownership: adjusting ownership of 4823941 entries under /media/frigate
|
||||
[INFO] fix-ownership: /media/frigate 5% (241197/4823941 entries)
|
||||
[INFO] fix-ownership: /media/frigate 10% (482394/4823941 entries)
|
||||
[INFO] fix-ownership: finished /media/frigate in 12m 4s
|
||||
```
|
||||
|
||||
The scan has no percentage because the total isn't known until it finishes. Watch the boot sweep with `docker logs -f frigate`.
|
||||
The scan has no percentage because the total isn't known until it finishes, so it reports a running count every 30 seconds instead. Watch the boot sweep with `docker logs -f frigate`.
|
||||
|
||||
Once the volumes are aligned, start Frigate normally. A file at `/config/.permissions_version` records what was done, so later boots skip the sweep unless you change `PUID`/`PGID`.
|
||||
|
||||
|
||||
@@ -413,17 +413,18 @@ Avoid removing audio from only one of the two streams (for example with `-an`).
|
||||
|
||||
As a general rule, features that read recordings prefer the main stream and fall back to the sub stream for time ranges where the main recordings have expired. Analytics features use only the main stream.
|
||||
|
||||
| Feature | Stream used |
|
||||
| ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| Recording playback (History and Review) | Both (main preferred with sub fallback by default), or exactly one stream when a quality is selected manually |
|
||||
| Tracking details and Explore clip playback | Main, falling back to sub where the main recordings have expired |
|
||||
| Feature | Stream used |
|
||||
| ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Recording playback (History and Review) | Both (main preferred with sub fallback by default), or exactly one stream when a quality is selected manually |
|
||||
| Tracking details and Explore clip playback | Main, falling back to sub where the main recordings have expired |
|
||||
| Exports | Both (main preferred with sub fallback by default), or exactly one stream when a quality is selected in the export dialog |
|
||||
| Clip downloads | Main; sub is used when no main recordings remain in the range (streams are never mixed in one file) |
|
||||
| Frames grabbed from a recording in History (download snapshot, submit frame to Frigate+) | Main preferred, sub fallback |
|
||||
| Audio extraction (e.g., transcription) | Main preferred, sub fallback |
|
||||
| Motion search | Main only |
|
||||
| Review timeline motion data | Main only |
|
||||
| Storage usage statistics | Both streams counted, and listed separately per camera |
|
||||
| Clip downloads | Main; sub is used when no main recordings remain in the range (streams are never mixed in one file) |
|
||||
| Debug replay | Main; sub is used when no main recordings remain in the range (streams are never mixed in one file) |
|
||||
| Frames grabbed from a recording in History (download snapshot, submit frame to Frigate+) | Main preferred, sub fallback |
|
||||
| Audio extraction (e.g., transcription) | Main preferred, sub fallback |
|
||||
| Motion search | Main only |
|
||||
| Review timeline motion data | Main only |
|
||||
| Storage usage statistics | Both streams counted, and listed separately per camera |
|
||||
|
||||
This table covers only features that read recordings from disk. Tracked object snapshots and thumbnails (the images shown in Explore and sent with notifications, and the images submitted to Frigate+ from a tracked object) are captured live from the `detect` stream as the object is tracked, never from recordings, so sub stream recording does not affect them.
|
||||
|
||||
|
||||
@@ -16,18 +16,21 @@ from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, Optional, cast
|
||||
|
||||
from peewee import ModelSelect
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.camera.updater import CameraConfigUpdatePublisher
|
||||
from frigate.const import REPLAY_CAMERA_PREFIX, REPLAY_DIR
|
||||
from frigate.const import (
|
||||
REPLAY_CAMERA_PREFIX,
|
||||
REPLAY_DIR,
|
||||
STREAM_TYPE_MAIN,
|
||||
STREAM_TYPE_SUB,
|
||||
)
|
||||
from frigate.jobs.export import JobStatePublisher
|
||||
from frigate.jobs.job import Job
|
||||
from frigate.jobs.manager import job_is_running, set_current_job
|
||||
from frigate.models import Export, Recordings
|
||||
from frigate.models import Export
|
||||
from frigate.types import JobStatusTypesEnum
|
||||
from frigate.util.ffmpeg import run_ffmpeg_with_progress
|
||||
from frigate.util.recording_coverage import recording_overlap
|
||||
from frigate.util.recording_coverage import resolve_coverage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frigate.debug_replay import DebugReplayManager
|
||||
@@ -94,24 +97,6 @@ class DebugReplayJob(Job):
|
||||
}
|
||||
|
||||
|
||||
def query_recordings(source_camera: str, start_ts: float, end_ts: float) -> ModelSelect:
|
||||
"""Return the Recordings query for the time range.
|
||||
|
||||
Module-level so tests can patch it without instantiating a runner.
|
||||
"""
|
||||
query = (
|
||||
Recordings.select(
|
||||
Recordings.path,
|
||||
Recordings.start_time,
|
||||
Recordings.end_time,
|
||||
)
|
||||
.where(recording_overlap(start_ts, end_ts))
|
||||
.where(Recordings.camera == source_camera)
|
||||
.order_by(Recordings.start_time.asc())
|
||||
)
|
||||
return cast(ModelSelect, query)
|
||||
|
||||
|
||||
class NoRecordingsError(ValueError):
|
||||
"""Raised when no recordings exist in the requested time range."""
|
||||
|
||||
@@ -156,7 +141,8 @@ class RecordingDebugReplaySource(DebugReplaySource):
|
||||
|
||||
Feeds ffmpeg the internal VOD endpoint so segments with mismatched
|
||||
SPS/PPS (e.g. across day/night transitions) stitch cleanly via HLS
|
||||
discontinuities.
|
||||
discontinuities. The playlist is pinned to one stream because the
|
||||
stream-copied clip cannot change codec or resolution mid-file.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -170,6 +156,7 @@ class RecordingDebugReplaySource(DebugReplaySource):
|
||||
self._start_ts = start_ts
|
||||
self._end_ts = end_ts
|
||||
self._internal_port = internal_port
|
||||
self._stream_type = STREAM_TYPE_MAIN
|
||||
|
||||
@property
|
||||
def source_camera(self) -> str:
|
||||
@@ -187,15 +174,28 @@ class RecordingDebugReplaySource(DebugReplaySource):
|
||||
if self._end_ts <= self._start_ts:
|
||||
raise ValueError("End time must be after start time")
|
||||
|
||||
if not query_recordings(self._camera, self._start_ts, self._end_ts).count():
|
||||
raise NoRecordingsError(
|
||||
f"No recordings found for camera '{self._camera}' in the specified time range"
|
||||
)
|
||||
# the VOD resolver decides what the pinned playlist serves, so it
|
||||
# also decides the stream; a row merely touching the range has no
|
||||
# interval and must not win
|
||||
intervals = resolve_coverage(self._camera, self._start_ts, self._end_ts)
|
||||
|
||||
if any(interval.main for interval in intervals):
|
||||
self._stream_type = STREAM_TYPE_MAIN
|
||||
return
|
||||
|
||||
if any(interval.sub for interval in intervals):
|
||||
self._stream_type = STREAM_TYPE_SUB
|
||||
return
|
||||
|
||||
raise NoRecordingsError(
|
||||
f"No recordings found for camera '{self._camera}' in the specified time range"
|
||||
)
|
||||
|
||||
def ffmpeg_input_args(self, working_dir: str) -> list[str]:
|
||||
playlist_url = (
|
||||
f"http://127.0.0.1:{self._internal_port}/vod/{self._camera}"
|
||||
f"/start/{self._start_ts}/end/{self._end_ts}/index.m3u8"
|
||||
f"/{self._stream_type}/start/{self._start_ts}/end/{self._end_ts}"
|
||||
"/index.m3u8"
|
||||
)
|
||||
return [
|
||||
"-protocol_whitelist",
|
||||
|
||||
+40
-51
@@ -585,26 +585,36 @@ class PtzAutoTracker(threading.Thread):
|
||||
|
||||
def _predict_movement_time(self, camera, pan, tilt):
|
||||
combined_movement = abs(pan) + abs(tilt)
|
||||
input_data = np.array([self.intercept[camera], combined_movement])
|
||||
input_data = np.array([1, combined_movement])
|
||||
|
||||
return np.dot(self.move_coefficients[camera], input_data)
|
||||
|
||||
def _predict_area_after_time(self, camera, time):
|
||||
return np.dot(
|
||||
self.tracked_object_metrics[camera]["area_coefficients"],
|
||||
[self.tracked_object_history[camera][-1]["frame_time"] + time],
|
||||
)
|
||||
|
||||
def _predict_target_box(self, camera, predicted_time):
|
||||
target_box = self.tracked_object_metrics[camera]["target_box"]
|
||||
tom = self.tracked_object_metrics[camera]
|
||||
target_box = tom["target_box"]
|
||||
|
||||
if not predicted_time:
|
||||
return target_box
|
||||
|
||||
frame_shape = self.config.cameras[camera].frame_shape
|
||||
camera_config = self.config.cameras[camera]
|
||||
left, top, right, bottom = self.tracked_object_history[camera][-1]["box"]
|
||||
size = max(right - left, bottom - top)
|
||||
|
||||
return target_box + self._predict_area_after_time(camera, predicted_time) / (
|
||||
frame_shape[0] * frame_shape[1]
|
||||
if size <= 0:
|
||||
return target_box
|
||||
|
||||
# the corner velocities diverge as the object approaches the camera
|
||||
frames = camera_config.detect.fps * predicted_time
|
||||
velocity = tom["velocity"]
|
||||
predicted_size = max(
|
||||
right - left + frames * (velocity[2] - velocity[0]),
|
||||
bottom - top + frames * (velocity[3] - velocity[1]),
|
||||
1,
|
||||
)
|
||||
|
||||
# target_box is the squared size raised to the zoom factor
|
||||
return target_box * (predicted_size / size) ** (
|
||||
2 * camera_config.onvif.autotracking.zoom_factor
|
||||
)
|
||||
|
||||
def _calculate_tracked_object_metrics(self, camera, obj):
|
||||
@@ -660,26 +670,6 @@ class PtzAutoTracker(threading.Thread):
|
||||
|
||||
filtered_areas = remove_outliers(areas) if len(areas) > 3 else areas
|
||||
|
||||
# Filter entries that are not touching the frame edge
|
||||
filtered_areas_not_touching_edge = [
|
||||
entry
|
||||
for entry in filtered_areas
|
||||
if self._touching_frame_edges(camera, entry["box"]) == 0
|
||||
]
|
||||
|
||||
# Calculate regression for area change predictions
|
||||
if len(filtered_areas_not_touching_edge):
|
||||
X = np.array(
|
||||
[item["frame_time"] for item in filtered_areas_not_touching_edge]
|
||||
)
|
||||
y = np.array([item["area"] for item in filtered_areas_not_touching_edge])
|
||||
|
||||
tom["area_coefficients"] = np.linalg.lstsq(X.reshape(-1, 1), y, rcond=None)[
|
||||
0
|
||||
]
|
||||
else:
|
||||
tom["area_coefficients"] = np.array([0])
|
||||
|
||||
weights = np.arange(1, len(filtered_areas) + 1)
|
||||
weighted_area = np.average(
|
||||
[item["area"] for item in filtered_areas], weights=weights
|
||||
@@ -744,6 +734,8 @@ class PtzAutoTracker(threading.Thread):
|
||||
continue
|
||||
|
||||
else:
|
||||
pan_tilt_times = None
|
||||
|
||||
if (
|
||||
camera_config.onvif.autotracking.zooming
|
||||
== ZoomingModeEnum.relative
|
||||
@@ -755,17 +747,28 @@ class PtzAutoTracker(threading.Thread):
|
||||
|
||||
await self._wait_until_stopped(camera, metrics)
|
||||
|
||||
# _zoom_absolute resets the metrics timestamps
|
||||
pan_tilt_times = (
|
||||
metrics.start_time.value,
|
||||
metrics.stop_time.value,
|
||||
)
|
||||
|
||||
if zoom > 0 and metrics.zoom_level.value != zoom:
|
||||
await self.onvif._zoom_absolute(camera, zoom, 1)
|
||||
|
||||
await self._wait_until_stopped(camera, metrics)
|
||||
|
||||
start_time, stop_time = pan_tilt_times or (
|
||||
metrics.start_time.value,
|
||||
metrics.stop_time.value,
|
||||
)
|
||||
|
||||
if camera_config.onvif.autotracking.movement_weights:
|
||||
logger.debug(
|
||||
f"{camera}: Predicted movement time: {self._predict_movement_time(camera, pan, tilt)}"
|
||||
)
|
||||
logger.debug(
|
||||
f"{camera}: Actual movement time: {metrics.stop_time.value - metrics.start_time.value}"
|
||||
f"{camera}: Actual movement time: {stop_time - start_time}"
|
||||
)
|
||||
|
||||
# save metrics for better estimate calculations
|
||||
@@ -781,8 +784,8 @@ class PtzAutoTracker(threading.Thread):
|
||||
{
|
||||
"pan": pan,
|
||||
"tilt": tilt,
|
||||
"start_timestamp": metrics.start_time.value,
|
||||
"end_timestamp": metrics.stop_time.value,
|
||||
"start_timestamp": start_time,
|
||||
"end_timestamp": stop_time,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -845,7 +848,6 @@ class PtzAutoTracker(threading.Thread):
|
||||
# Thresholds
|
||||
x_mags_thresh = camera_width / camera_fps / 2
|
||||
y_mags_thresh = camera_height / camera_fps / 2
|
||||
dir_thresh = 0.93
|
||||
delta_thresh = 20
|
||||
var_thresh = 10
|
||||
|
||||
@@ -863,24 +865,12 @@ class PtzAutoTracker(threading.Thread):
|
||||
stdev_list = np.std(velocities, axis=0)
|
||||
high_variances = np.any(stdev_list > var_thresh)
|
||||
|
||||
# Check direction difference
|
||||
# the corners of an object moving toward or away from the camera move
|
||||
# in different directions, so the delta bounds how far they may differ
|
||||
velocities = np.round(velocities)
|
||||
invalid_dirs = False
|
||||
if np.all(np.linalg.norm(velocities, axis=1)):
|
||||
cosine_sim = np.dot(velocities[0], velocities[1]) / (
|
||||
np.linalg.norm(velocities[0]) * np.linalg.norm(velocities[1])
|
||||
)
|
||||
dir_thresh = 0.6 if np.all(delta < delta_thresh / 2) else dir_thresh
|
||||
invalid_dirs = cosine_sim < dir_thresh
|
||||
|
||||
# Combine
|
||||
invalid = (
|
||||
invalid_x_mags
|
||||
or invalid_y_mags
|
||||
or invalid_dirs
|
||||
or invalid_delta
|
||||
or high_variances
|
||||
)
|
||||
invalid = invalid_x_mags or invalid_y_mags or invalid_delta or high_variances
|
||||
|
||||
if invalid:
|
||||
logger.debug(
|
||||
@@ -891,7 +881,6 @@ class PtzAutoTracker(threading.Thread):
|
||||
for var_name, is_invalid in [
|
||||
("invalid_x_mags", invalid_x_mags),
|
||||
("invalid_y_mags", invalid_y_mags),
|
||||
("invalid_dirs", invalid_dirs),
|
||||
("invalid_delta", invalid_delta),
|
||||
("high_variances", high_variances),
|
||||
]
|
||||
|
||||
@@ -18,6 +18,7 @@ from frigate.jobs.debug_replay import (
|
||||
from frigate.jobs.export import JobStatePublisher
|
||||
from frigate.jobs.manager import _completed_jobs, _current_jobs
|
||||
from frigate.types import JobStatusTypesEnum
|
||||
from frigate.util.recording_coverage import CoverageInterval
|
||||
|
||||
|
||||
def _reset_job_manager() -> None:
|
||||
@@ -77,6 +78,31 @@ class TestDebugReplayJob(unittest.TestCase):
|
||||
self.assertEqual(results["end_ts"], 200.0)
|
||||
|
||||
|
||||
class TestRecordingDebugReplaySource(unittest.TestCase):
|
||||
def _source_url(self, intervals: list[CoverageInterval]) -> str:
|
||||
source = RecordingDebugReplaySource(
|
||||
source_camera="front", start_ts=100.0, end_ts=200.0, internal_port=5000
|
||||
)
|
||||
with patch(
|
||||
"frigate.jobs.debug_replay.resolve_coverage", return_value=intervals
|
||||
):
|
||||
source.validate()
|
||||
return source.ffmpeg_input_args("/tmp")[-1]
|
||||
|
||||
def test_pins_main_when_main_has_recordings(self) -> None:
|
||||
url = self._source_url(
|
||||
[
|
||||
CoverageInterval(100.0, 150.0, None, MagicMock()),
|
||||
CoverageInterval(150.0, 200.0, MagicMock(), MagicMock()),
|
||||
]
|
||||
)
|
||||
self.assertIn("/vod/front/main/start/100.0/end/200.0/", url)
|
||||
|
||||
def test_falls_back_to_sub_without_main_recordings(self) -> None:
|
||||
url = self._source_url([CoverageInterval(100.0, 200.0, None, MagicMock())])
|
||||
self.assertIn("/vod/front/sub/start/100.0/end/200.0/", url)
|
||||
|
||||
|
||||
class TestStartDebugReplayJob(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
_reset_job_manager()
|
||||
@@ -87,9 +113,7 @@ class TestStartDebugReplayJob(unittest.TestCase):
|
||||
self.frigate_config.ffmpeg.ffmpeg_path = "/bin/true"
|
||||
self.publisher = MagicMock()
|
||||
|
||||
self.recordings_qs = MagicMock()
|
||||
self.recordings_qs.count.return_value = 1
|
||||
self.recordings_qs.__iter__.return_value = iter([MagicMock(path="/tmp/r1.mp4")])
|
||||
self.coverage = [CoverageInterval(100.0, 200.0, MagicMock(), None)]
|
||||
|
||||
def tearDown(self) -> None:
|
||||
runner = get_active_runner()
|
||||
@@ -127,9 +151,7 @@ class TestStartDebugReplayJob(unittest.TestCase):
|
||||
)
|
||||
|
||||
def test_rejects_when_no_recordings(self) -> None:
|
||||
empty_qs = MagicMock()
|
||||
empty_qs.count.return_value = 0
|
||||
with patch("frigate.jobs.debug_replay.query_recordings", return_value=empty_qs):
|
||||
with patch("frigate.jobs.debug_replay.resolve_coverage", return_value=[]):
|
||||
with self.assertRaises(NoRecordingsError):
|
||||
start_debug_replay_job(
|
||||
source=RecordingDebugReplaySource(
|
||||
@@ -152,8 +174,8 @@ class TestStartDebugReplayJob(unittest.TestCase):
|
||||
|
||||
with (
|
||||
patch(
|
||||
"frigate.jobs.debug_replay.query_recordings",
|
||||
return_value=self.recordings_qs,
|
||||
"frigate.jobs.debug_replay.resolve_coverage",
|
||||
return_value=self.coverage,
|
||||
),
|
||||
patch(
|
||||
"frigate.jobs.debug_replay.run_ffmpeg_with_progress",
|
||||
@@ -192,8 +214,8 @@ class TestStartDebugReplayJob(unittest.TestCase):
|
||||
|
||||
with (
|
||||
patch(
|
||||
"frigate.jobs.debug_replay.query_recordings",
|
||||
return_value=self.recordings_qs,
|
||||
"frigate.jobs.debug_replay.resolve_coverage",
|
||||
return_value=self.coverage,
|
||||
),
|
||||
patch(
|
||||
"frigate.jobs.debug_replay.run_ffmpeg_with_progress",
|
||||
@@ -242,9 +264,7 @@ class TestRunnerHappyPath(unittest.TestCase):
|
||||
self.frigate_config.ffmpeg.ffmpeg_path = "/bin/true"
|
||||
self.publisher = MagicMock()
|
||||
|
||||
self.recordings_qs = MagicMock()
|
||||
self.recordings_qs.count.return_value = 1
|
||||
self.recordings_qs.__iter__.return_value = iter([MagicMock(path="/tmp/r1.mp4")])
|
||||
self.coverage = [CoverageInterval(100.0, 200.0, MagicMock(), None)]
|
||||
|
||||
def tearDown(self) -> None:
|
||||
runner = get_active_runner()
|
||||
@@ -272,8 +292,8 @@ class TestRunnerHappyPath(unittest.TestCase):
|
||||
|
||||
with (
|
||||
patch(
|
||||
"frigate.jobs.debug_replay.query_recordings",
|
||||
return_value=self.recordings_qs,
|
||||
"frigate.jobs.debug_replay.resolve_coverage",
|
||||
return_value=self.coverage,
|
||||
),
|
||||
patch(
|
||||
"frigate.jobs.debug_replay.run_ffmpeg_with_progress",
|
||||
@@ -325,9 +345,7 @@ class TestRunnerFailurePath(unittest.TestCase):
|
||||
self.frigate_config.cameras = {"front": MagicMock()}
|
||||
self.frigate_config.ffmpeg.ffmpeg_path = "/bin/true"
|
||||
self.publisher = MagicMock()
|
||||
self.recordings_qs = MagicMock()
|
||||
self.recordings_qs.count.return_value = 1
|
||||
self.recordings_qs.__iter__.return_value = iter([MagicMock(path="/tmp/r1.mp4")])
|
||||
self.coverage = [CoverageInterval(100.0, 200.0, MagicMock(), None)]
|
||||
|
||||
def tearDown(self) -> None:
|
||||
runner = get_active_runner()
|
||||
@@ -350,8 +368,8 @@ class TestRunnerFailurePath(unittest.TestCase):
|
||||
|
||||
with (
|
||||
patch(
|
||||
"frigate.jobs.debug_replay.query_recordings",
|
||||
return_value=self.recordings_qs,
|
||||
"frigate.jobs.debug_replay.resolve_coverage",
|
||||
return_value=self.coverage,
|
||||
),
|
||||
patch(
|
||||
"frigate.jobs.debug_replay.run_ffmpeg_with_progress",
|
||||
@@ -399,9 +417,7 @@ class TestRunnerCancellation(unittest.TestCase):
|
||||
self.frigate_config.cameras = {"front": MagicMock()}
|
||||
self.frigate_config.ffmpeg.ffmpeg_path = "/bin/true"
|
||||
self.publisher = MagicMock()
|
||||
self.recordings_qs = MagicMock()
|
||||
self.recordings_qs.count.return_value = 1
|
||||
self.recordings_qs.__iter__.return_value = iter([MagicMock(path="/tmp/r1.mp4")])
|
||||
self.coverage = [CoverageInterval(100.0, 200.0, MagicMock(), None)]
|
||||
|
||||
def tearDown(self) -> None:
|
||||
runner = get_active_runner()
|
||||
@@ -431,8 +447,8 @@ class TestRunnerCancellation(unittest.TestCase):
|
||||
|
||||
with (
|
||||
patch(
|
||||
"frigate.jobs.debug_replay.query_recordings",
|
||||
return_value=self.recordings_qs,
|
||||
"frigate.jobs.debug_replay.resolve_coverage",
|
||||
return_value=self.coverage,
|
||||
),
|
||||
patch(
|
||||
"frigate.jobs.debug_replay.run_ffmpeg_with_progress",
|
||||
|
||||
@@ -11,11 +11,14 @@ KeyError on the autotracker thread or silently keep the wrong state:
|
||||
running its motion estimator for a camera that could not autotrack.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import numpy as np
|
||||
|
||||
from frigate.camera import PTZMetrics
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config import FrigateConfig, ZoomingModeEnum
|
||||
from frigate.config.camera.updater import CameraConfigUpdateEnum
|
||||
from frigate.ptz.autotrack import PtzAutoTracker, calculate_max_target_box
|
||||
|
||||
@@ -123,6 +126,118 @@ class TestAutotrackerDisable(unittest.TestCase):
|
||||
self.assertIs(payload, autotracking)
|
||||
|
||||
|
||||
class TestAutotrackerPrediction(unittest.TestCase):
|
||||
def test_movement_time_uses_the_fitted_intercept_unchanged(self) -> None:
|
||||
tracker = _make_tracker()
|
||||
tracker.intercept = {CAMERA: 0.5}
|
||||
tracker.move_coefficients = {CAMERA: [0.3, 0.8]}
|
||||
|
||||
self.assertAlmostEqual(tracker._predict_movement_time(CAMERA, 0.25, 0.25), 0.7)
|
||||
|
||||
def test_shrinking_box_keeps_a_positive_target_box(self) -> None:
|
||||
# 60 px shrinking 6 px/frame reaches zero after 2 s at 5 fps
|
||||
target_box = self._target_box_for((0, 0, 60, 60), [3, 3, -3, -3], 2.0)
|
||||
|
||||
self.assertGreater(target_box, 0)
|
||||
self.assertTrue(np.isfinite(target_box))
|
||||
|
||||
def test_approaching_object_keeps_its_velocity(self) -> None:
|
||||
# the box grows, so the corners move in opposite directions
|
||||
tracker = _make_tracker()
|
||||
obj = MagicMock()
|
||||
obj.obj_data = {"estimate_velocity": np.array([[-3.0, -4.0], [3.0, 1.0]])}
|
||||
|
||||
valid, velocity = tracker._get_valid_velocity(CAMERA, obj)
|
||||
|
||||
self.assertTrue(valid)
|
||||
self.assertEqual(list(velocity), [-3, -4, 3, 1])
|
||||
|
||||
def test_corners_that_disagree_too_much_are_rejected(self) -> None:
|
||||
tracker = _make_tracker()
|
||||
obj = MagicMock()
|
||||
obj.obj_data = {"estimate_velocity": np.array([[-15.0, 0.0], [15.0, 0.0]])}
|
||||
|
||||
valid, velocity = tracker._get_valid_velocity(CAMERA, obj)
|
||||
|
||||
self.assertFalse(valid)
|
||||
self.assertFalse(np.any(velocity))
|
||||
|
||||
def _target_box(self, velocity: list[float], predicted_time: float) -> float:
|
||||
return self._target_box_for((900, 400, 1000, 600), velocity, predicted_time)
|
||||
|
||||
def _target_box_for(
|
||||
self,
|
||||
box: tuple[int, int, int, int],
|
||||
velocity: list[float],
|
||||
predicted_time: float,
|
||||
) -> float:
|
||||
tracker = _make_tracker()
|
||||
tracker.tracked_object_history = {CAMERA: [{"box": box}]}
|
||||
tracker.tracked_object_metrics = {
|
||||
CAMERA: {"target_box": 0.2, "velocity": np.array(velocity)}
|
||||
}
|
||||
|
||||
return tracker._predict_target_box(CAMERA, predicted_time)
|
||||
|
||||
def test_target_box_grows_with_an_approaching_object(self) -> None:
|
||||
# the 200px height gains 4px per frame at 5 fps for 1 second
|
||||
zoom_factor = 0.3
|
||||
|
||||
self.assertAlmostEqual(
|
||||
self._target_box([-1, -2, 1, 2], 1.0),
|
||||
0.2 * (220 / 200) ** (2 * zoom_factor),
|
||||
)
|
||||
|
||||
def test_target_box_is_unchanged_without_growth_or_time(self) -> None:
|
||||
self.assertAlmostEqual(self._target_box([5, 0, 5, 0], 1.0), 0.2)
|
||||
self.assertEqual(self._target_box([-1, -2, 1, 2], 0), 0.2)
|
||||
|
||||
|
||||
class TestAutotrackerMoveQueue(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_absolute_zoom_does_not_overwrite_the_pan_tilt_sample(self) -> None:
|
||||
tracker = _make_tracker()
|
||||
autotracking = tracker.config.cameras[CAMERA].onvif.autotracking
|
||||
autotracking.zooming = ZoomingModeEnum.absolute
|
||||
autotracking.calibrate_on_startup = True
|
||||
metrics = tracker.ptz_metrics[CAMERA]
|
||||
|
||||
tracker.stop_event = MagicMock()
|
||||
tracker.stop_event.is_set.side_effect = [False, True]
|
||||
tracker.move_queues = {CAMERA: asyncio.Queue()}
|
||||
tracker.move_queue_locks = {CAMERA: asyncio.Lock()}
|
||||
tracker.intercept = {CAMERA: 0.5}
|
||||
tracker.move_metrics = {CAMERA: []}
|
||||
tracker._wait_until_stopped = AsyncMock()
|
||||
tracker._calculate_move_coefficients = MagicMock()
|
||||
|
||||
async def move_relative(*_args) -> None:
|
||||
metrics.start_time.value = 100.0
|
||||
metrics.stop_time.value = 102.0
|
||||
|
||||
async def zoom_absolute(*_args) -> None:
|
||||
metrics.start_time.value = 103.0
|
||||
metrics.stop_time.value = 0
|
||||
|
||||
tracker.onvif._move_relative = AsyncMock(side_effect=move_relative)
|
||||
tracker.onvif._zoom_absolute = AsyncMock(side_effect=zoom_absolute)
|
||||
|
||||
tracker.move_queues[CAMERA].put_nowait((50.0, 0.5, 0.5, 0.5))
|
||||
await tracker._process_move_queue(CAMERA)
|
||||
|
||||
tracker.onvif._zoom_absolute.assert_awaited_once()
|
||||
self.assertEqual(
|
||||
tracker.move_metrics[CAMERA],
|
||||
[
|
||||
{
|
||||
"pan": 0.5,
|
||||
"tilt": 0.5,
|
||||
"start_timestamp": 100.0,
|
||||
"end_timestamp": 102.0,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class TestMaxTargetBox(unittest.TestCase):
|
||||
def test_follows_zoom_factor(self) -> None:
|
||||
self.assertAlmostEqual(calculate_max_target_box(0.5), 0.6**2)
|
||||
|
||||
@@ -150,17 +150,18 @@ export function TrackingDetails({
|
||||
|
||||
// Fetch the VOD manifest JSON to get the actual clipFrom after keyframe
|
||||
// snapping. The backend may snap clipFrom backwards to a keyframe, making
|
||||
// the video start earlier than the requested time.
|
||||
// the video start earlier than the requested time. Uses sourceOffsetRef so
|
||||
// the manifest describes the same clip as videoSource.
|
||||
const vodManifestUrl = useMemo(() => {
|
||||
if (!event.camera) return null;
|
||||
const startTime =
|
||||
event.start_time + annotationOffset / 1000 - REVIEW_PADDING;
|
||||
const sourceOffset = sourceOffsetRef.current;
|
||||
const startTime = event.start_time + sourceOffset / 1000 - REVIEW_PADDING;
|
||||
const endTime =
|
||||
(event.end_time ?? Date.now() / 1000) +
|
||||
annotationOffset / 1000 +
|
||||
sourceOffset / 1000 +
|
||||
REVIEW_PADDING;
|
||||
return `vod/clip/${event.camera}/start/${startTime}/end/${endTime}`;
|
||||
}, [event, annotationOffset]);
|
||||
}, [event]);
|
||||
|
||||
const { data: vodManifest } = useSWR<VodManifest>(vodManifestUrl, null, {
|
||||
revalidateOnFocus: false,
|
||||
|
||||
Reference in New Issue
Block a user