keep predicted target box positive and add regression tests

A shrinking object's predicted size could reach zero, so the relative zoom ratio divided by zero and sent a NaN zoom to the camera. The predicted size is now floored at 1 px. Tests also cover the movement time intercept and the pan/tilt timestamps that absolute zoom overwrites.
This commit is contained in:
Josh Hawkins
2026-11-10 15:51:14 -06:00
parent 3fb819c025
commit a8e41e22b6
2 changed files with 72 additions and 4 deletions
+1 -1
View File
@@ -609,7 +609,7 @@ class PtzAutoTracker(threading.Thread):
predicted_size = max(
right - left + frames * (velocity[2] - velocity[0]),
bottom - top + frames * (velocity[3] - velocity[1]),
0,
1,
)
# target_box is the squared size raised to the zoom factor
+71 -3
View File
@@ -11,13 +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
@@ -126,6 +127,20 @@ class TestAutotrackerDisable(unittest.TestCase):
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()
@@ -148,8 +163,16 @@ class TestAutotrackerPrediction(unittest.TestCase):
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": (900, 400, 1000, 600)}]}
tracker.tracked_object_history = {CAMERA: [{"box": box}]}
tracker.tracked_object_metrics = {
CAMERA: {"target_box": 0.2, "velocity": np.array(velocity)}
}
@@ -170,6 +193,51 @@ class TestAutotrackerPrediction(unittest.TestCase):
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)