mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-11-13 09:03:01 +03:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa8086393b | ||
|
|
4e679ba17a | ||
|
|
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`.
|
||||
|
||||
|
||||
+17
-1
@@ -45,7 +45,7 @@ from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateTopic,
|
||||
)
|
||||
from frigate.const import REDACTED_CREDENTIAL_SENTINEL
|
||||
from frigate.const import REDACTED_CREDENTIAL_SENTINEL, REPLAY_CAMERA_PREFIX
|
||||
from frigate.ffmpeg_presets import FFMPEG_HWACCEL_VAAPI, _gpu_selector
|
||||
from frigate.genai import PROVIDERS, load_providers
|
||||
from frigate.jobs.media_sync import (
|
||||
@@ -979,6 +979,17 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# an unresolvable scene would crash the threads that look up the model
|
||||
detect_update = sections.get("detect")
|
||||
if isinstance(detect_update, dict) and "scene" in detect_update:
|
||||
try:
|
||||
config._resolve_camera_model(cam_name, detect_update["scene"])
|
||||
except (TypeError, ValueError):
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Invalid detect scene"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
for section_name, update in sections.items():
|
||||
err = apply_section_update(camera_config, section_name, update)
|
||||
if err is not None:
|
||||
@@ -987,6 +998,11 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# the model is resolved again from the scene when the replay
|
||||
# camera is recycled by the refresh below
|
||||
if "detect" in sections and cam_name.startswith(REPLAY_CAMERA_PREFIX):
|
||||
config.drop_camera_model(cam_name)
|
||||
|
||||
# Publish ZMQ updates so processing threads pick up changes
|
||||
if body.update_topic and body.update_topic.startswith("config/cameras/"):
|
||||
_, _, camera, field = body.update_topic.split("/")
|
||||
|
||||
+2
-9
@@ -76,7 +76,7 @@ from frigate.models import (
|
||||
from frigate.notices import install_registry
|
||||
from frigate.notices.registry import NoticeRegistry
|
||||
from frigate.object_detection.base import ObjectDetectProcess
|
||||
from frigate.object_detection.util import detection_frame_size
|
||||
from frigate.object_detection.util import create_detection_input_shm
|
||||
from frigate.output.output import OutputProcess
|
||||
from frigate.ptz.autotrack import PtzAutoTracker
|
||||
from frigate.ptz.onvif import OnvifController
|
||||
@@ -399,14 +399,7 @@ class FrigateApp:
|
||||
model = self.config.model_for_camera(name)
|
||||
model_cameras[model.scene].append(name)
|
||||
|
||||
try:
|
||||
shm_in = UntrackedSharedMemory(
|
||||
name=name,
|
||||
create=True,
|
||||
size=detection_frame_size(model),
|
||||
)
|
||||
except FileExistsError:
|
||||
shm_in = UntrackedSharedMemory(name=name)
|
||||
shm_in = create_detection_input_shm(name, self.config.models)
|
||||
|
||||
try:
|
||||
shm_out = UntrackedSharedMemory(
|
||||
|
||||
@@ -16,7 +16,7 @@ from frigate.config.camera.updater import (
|
||||
)
|
||||
from frigate.const import REPLAY_CAMERA_PREFIX
|
||||
from frigate.models import Regions
|
||||
from frigate.object_detection.util import detection_frame_size
|
||||
from frigate.object_detection.util import create_detection_input_shm
|
||||
from frigate.util.builtin import empty_and_close_queue
|
||||
from frigate.util.image import SharedMemoryFrameManager, UntrackedSharedMemory
|
||||
from frigate.util.object import get_camera_regions_grid
|
||||
@@ -127,13 +127,10 @@ class CameraMaintainer(threading.Thread):
|
||||
max(model.width, model.height),
|
||||
)
|
||||
|
||||
create_detection_input_shm(name, self.config.models)
|
||||
|
||||
try:
|
||||
UntrackedSharedMemory(name=f"out-{name}", create=True, size=20 * 6 * 4)
|
||||
UntrackedSharedMemory(
|
||||
name=name,
|
||||
create=True,
|
||||
size=detection_frame_size(model),
|
||||
)
|
||||
except FileExistsError:
|
||||
pass
|
||||
|
||||
|
||||
@@ -296,7 +296,8 @@ class CameraState:
|
||||
return frame_copy
|
||||
|
||||
def finished(self, obj_id: str) -> None:
|
||||
del self.tracked_objects[obj_id]
|
||||
# state rebuilt after a resolution or scene change no longer has the object
|
||||
self.tracked_objects.pop(obj_id, None)
|
||||
|
||||
def on(self, event_type: str, callback: Callable[..., Any]) -> None:
|
||||
self.callbacks[event_type].append(callback)
|
||||
|
||||
@@ -113,6 +113,23 @@ class AsyncLocalObjectDetector(BaseLocalDetector):
|
||||
return self.detect_api.receive_output()
|
||||
|
||||
|
||||
def map_input_frame(
|
||||
frame_manager: SharedMemoryFrameManager, camera: str, model: ModelConfig
|
||||
) -> np.ndarray | None:
|
||||
"""Map a camera's detection input segment as this model's input tensor.
|
||||
|
||||
The segment is sized for the largest configured model, so it can be larger
|
||||
than this model's input.
|
||||
"""
|
||||
buf = frame_manager.write(camera)
|
||||
shape = (1, model.height, model.width, 3)
|
||||
|
||||
if buf is None or len(buf) < model.height * model.width * 3:
|
||||
return None
|
||||
|
||||
return np.ndarray(shape, dtype=np.uint8, buffer=buf)
|
||||
|
||||
|
||||
class DetectorRunner(FrigateProcess):
|
||||
def __init__(
|
||||
self,
|
||||
@@ -154,14 +171,10 @@ class DetectorRunner(FrigateProcess):
|
||||
connection_id = self.detection_queue.get(timeout=1)
|
||||
except queue.Empty:
|
||||
continue
|
||||
input_frame = frame_manager.get(
|
||||
input_frame = map_input_frame(
|
||||
frame_manager,
|
||||
connection_id,
|
||||
(
|
||||
1,
|
||||
self.detector_config.model.height, # type: ignore[union-attr]
|
||||
self.detector_config.model.width, # type: ignore[union-attr]
|
||||
3,
|
||||
),
|
||||
self.detector_config.model, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
if input_frame is None:
|
||||
@@ -227,14 +240,10 @@ class AsyncDetectorRunner(FrigateProcess):
|
||||
continue
|
||||
|
||||
assert self._frame_manager is not None
|
||||
input_frame = self._frame_manager.get(
|
||||
input_frame = map_input_frame(
|
||||
self._frame_manager,
|
||||
connection_id,
|
||||
(
|
||||
1,
|
||||
self.detector_config.model.height, # type: ignore[union-attr]
|
||||
self.detector_config.model.width, # type: ignore[union-attr]
|
||||
3,
|
||||
),
|
||||
self.detector_config.model, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
if input_frame is None:
|
||||
|
||||
@@ -6,18 +6,39 @@ import threading
|
||||
from numpy import ndarray
|
||||
|
||||
from frigate.detectors.detector_config import InputTensorEnum, ModelConfig
|
||||
from frigate.util.image import UntrackedSharedMemory
|
||||
|
||||
|
||||
def detection_frame_size(model: ModelConfig) -> int:
|
||||
"""Get the shared memory size a camera needs to hand frames to a model.
|
||||
def create_detection_input_shm(
|
||||
camera: str, models: list[ModelConfig]
|
||||
) -> UntrackedSharedMemory:
|
||||
"""Create the shared memory a camera hands detection frames through.
|
||||
|
||||
It is sized for the largest model so a camera re-added under the same name
|
||||
with another scene can reuse it. Detectors keep mappings of it, so it is
|
||||
never unlinked while they run.
|
||||
|
||||
Args:
|
||||
model: The model the camera runs on
|
||||
camera: Name of the camera
|
||||
models: Every configured model
|
||||
|
||||
Returns:
|
||||
Size in bytes of one model input frame
|
||||
The camera's detection input segment
|
||||
"""
|
||||
return model.height * model.width * 3
|
||||
size = max(model.height * model.width * 3 for model in models)
|
||||
|
||||
try:
|
||||
return UntrackedSharedMemory(name=camera, create=True, size=size)
|
||||
except FileExistsError:
|
||||
shm = UntrackedSharedMemory(name=camera)
|
||||
|
||||
if shm.size >= size:
|
||||
return shm
|
||||
|
||||
# left by an earlier run with smaller models, so no detector maps it yet
|
||||
shm.close()
|
||||
shm.unlink()
|
||||
return UntrackedSharedMemory(name=camera, create=True, size=size)
|
||||
|
||||
|
||||
class RequestStore:
|
||||
|
||||
+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),
|
||||
]
|
||||
|
||||
@@ -98,6 +98,46 @@ class TestHttpApp(BaseTestHttp):
|
||||
assert response.status_code == 200
|
||||
assert app.frigate_config.cameras["front_door"].objects.track == ["person"]
|
||||
|
||||
@patch("frigate.detectors.detector_config.load_labels")
|
||||
def test_config_set_in_memory_rebinds_replay_model_on_scene_change(
|
||||
self, mock_labels
|
||||
):
|
||||
mock_labels.return_value = {}
|
||||
replay = "_replay_front_door"
|
||||
self.minimal_config["models"] = [
|
||||
{"devices": ["cpu"]},
|
||||
{"scene": "thermal", "devices": ["openvino:CPU"], "width": 300},
|
||||
]
|
||||
self.minimal_config["cameras"][replay] = self.minimal_config["cameras"].pop(
|
||||
"front_door"
|
||||
)
|
||||
app = super().create_app()
|
||||
app.config_publisher = Mock()
|
||||
assert app.frigate_config.model_for_camera(replay).scene == "default"
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
response = client.put(
|
||||
"/config/set",
|
||||
json={
|
||||
"requires_restart": 0,
|
||||
"skip_save": True,
|
||||
"update_topic": f"config/cameras/{replay}/detect",
|
||||
"config_data": {
|
||||
"cameras": {replay: {"detect": {"scene": "thermal"}}}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert app.frigate_config.cameras[replay].detect.scene == "thermal"
|
||||
assert app.frigate_config.model_for_camera(replay).scene == "thermal"
|
||||
topics = [
|
||||
call.args[0].update_type.name
|
||||
for call in app.config_publisher.publish_update.call_args_list
|
||||
]
|
||||
assert topics[0] == "detect"
|
||||
assert topics[-1] == "refresh"
|
||||
|
||||
####################################################################################################################
|
||||
################################### Credential redaction sentinel ################################################
|
||||
####################################################################################################################
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -91,6 +91,7 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
CameraConfigUpdateEnum.enabled,
|
||||
CameraConfigUpdateEnum.motion,
|
||||
CameraConfigUpdateEnum.objects,
|
||||
CameraConfigUpdateEnum.refresh,
|
||||
CameraConfigUpdateEnum.remove,
|
||||
CameraConfigUpdateEnum.timestamp_style,
|
||||
CameraConfigUpdateEnum.zones,
|
||||
@@ -784,6 +785,21 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
|
||||
self.requestor.send_data(UPDATE_CAMERA_ACTIVITY, self.camera_activity)
|
||||
|
||||
# a replay camera recycled onto another scene's model needs new state
|
||||
for camera in updated_topics.get("refresh", []):
|
||||
camera_state = self.camera_states.get(camera)
|
||||
|
||||
if (
|
||||
camera_state is None
|
||||
or camera_state.model is self.config.model_for_camera(camera)
|
||||
):
|
||||
continue
|
||||
|
||||
self.force_end_all_events(camera, camera_state)
|
||||
camera_state.shutdown()
|
||||
self.frame_manager.close_camera(camera)
|
||||
self.create_camera_state(camera)
|
||||
|
||||
# manage camera disabled state
|
||||
for camera, config in self.config.cameras.items():
|
||||
if not config.enabled_in_config:
|
||||
|
||||
@@ -240,14 +240,14 @@ const detect: SectionConfigOverrides = {
|
||||
},
|
||||
replay: {
|
||||
restartRequired: [],
|
||||
fieldOrder: ["width", "height", "fps"],
|
||||
fieldOrder: ["scene", "width", "height", "fps"],
|
||||
fieldGroups: {
|
||||
resolution: ["width", "height", "fps"],
|
||||
},
|
||||
hiddenFields: [
|
||||
"enabled",
|
||||
"enabled_in_config",
|
||||
"scene",
|
||||
hideSceneWithoutChoice,
|
||||
"min_initialized",
|
||||
"max_disappeared",
|
||||
"annotation_offset",
|
||||
|
||||
@@ -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