"""Regression coverage for camera restart, response routing and inference health.""" import multiprocessing as mp import queue import threading import time import unittest from types import SimpleNamespace from unittest.mock import patch import numpy as np from app.analysis.manager import AnalysisManager from app.analysis.pipeline import CameraPipeline from app.analysis.process_worker import pipeline_process_main from app.analysis.remote_detector import RemoteDetector from app.analysis.tracker import IoUTracker from app.analysis.preview_sessions import PreviewSessionRegistry class CommandPipeline: """Delay run() so control threads must survive the pre-start window.""" def __init__(self, **kwargs): self._running = False self._started_event = threading.Event() self.zones = [] def run(self): time.sleep(0.2) self._running = True self._started_event.set() deadline = time.monotonic() + 3 while self._running and time.monotonic() < deadline: time.sleep(0.01) assert not self._running, 'stop command was lost before run()' assert self.zones == [{'id': 12}], 'reload command was lost' def status(self): return {'running': self._running} def set_zone_polygons(self, zones): self.zones = zones def stop(self): self._running = False def command_process(commands): with patch('app.analysis.pipeline.CameraPipeline', CommandPipeline): pipeline_process_main({'stream_id': 1, 'rtsp_url': 'test'}, queue.Queue(), commands, {}) def wait_response(responses, entered): entered.set() responses.get(timeout=60) class AnalysisRuntimeTests(unittest.TestCase): def make_router(self): manager = object.__new__(AnalysisManager) manager._infer_routes = {} manager._infer_routes_lock = threading.Lock() return manager def test_stop_and_reload_commands_survive_startup(self): ctx = mp.get_context('spawn') commands = ctx.Queue() commands.put({'cmd': 'reload_zones', 'zones': [{'id': 12}]}) commands.put({'cmd': 'stop'}) proc = ctx.Process(target=command_process, args=(commands,)) proc.start() try: proc.join(10) self.assertEqual(proc.exitcode, 0) finally: if proc.is_alive(): proc.terminate() proc.join(2) commands.cancel_join_thread() commands.close() def test_restart_uses_new_channel_after_consumer_is_killed(self): ctx = mp.get_context('spawn') router = self.make_router() old, fresh, other = ctx.Queue(), ctx.Queue(), ctx.Queue() router._infer_routes.update(old=old, fresh=fresh, other=other) entered = ctx.Event() proc = ctx.Process(target=wait_response, args=(old, entered)) proc.start() try: self.assertTrue(entered.wait(10)) deadline = time.monotonic() + 3 while time.monotonic() < deadline: if not old._rlock.acquire(False): break old._rlock.release() time.sleep(0.01) else: self.fail('consumer did not acquire response queue lock') proc.terminate() proc.join(3) old.put({'req_id': 'unreadable'}) with self.assertRaises(queue.Empty): old.get(timeout=0.1) router._close_inference_channel({'response_channel': 'old'}) router._send_inference_response('old', {'req_id': 'late'}) router._send_inference_response('fresh', {'req_id': 'new'}) router._send_inference_response('other', {'req_id': 'camera-2'}) self.assertEqual(fresh.get(timeout=2)['req_id'], 'new') self.assertEqual(other.get(timeout=2)['req_id'], 'camera-2') finally: if proc.is_alive(): proc.terminate() proc.join(2) for key in list(router._infer_routes): router._close_inference_channel({'response_channel': key}) def test_remote_timeout_is_not_an_empty_success(self): detector = RemoteDetector({'id': 5}, queue.Queue(), queue.Queue(), timeout=0.03) with self.assertRaises(TimeoutError): detector.detect(np.zeros((32, 32, 3), dtype=np.uint8)) def test_downscaled_inference_coordinates_are_restored(self): restored = RemoteDetector._restore_coordinates([{ 'box': [10, 20, 100, 200], 'keypoints': [[15, 25, .9]], 'polygon': [[10, 20], [100, 200]], }], 2.0, 2.0)[0] self.assertEqual(restored['box'], [20.0, 40.0, 200.0, 400.0]) self.assertEqual(restored['keypoints'][0][:2], [30.0, 50.0]) self.assertEqual(restored['polygon'][1], [200.0, 400.0]) def test_tracker_is_one_to_one_and_confirms_medium_score_twice(self): tracker = IoUTracker() detections = [ {'label': 'person', 'score': .45, 'box': [0, 0, 20, 40]}, {'label': 'person', 'score': .8, 'box': [30, 0, 50, 40]}, ] first, _, _, _ = tracker.update(detections, 1, timestamp=1.0) self.assertEqual(len({t['track_id'] for t in first}), 2) self.assertFalse(next(t for t in first if t['score'] == .45)['confirmed']) second, _, new_ids, _ = tracker.update([ {'label': 'person', 'score': .5, 'box': [2, 0, 22, 40]}, {'label': 'person', 'score': .82, 'box': [28, 0, 48, 40]}, ], 2, timestamp=4.0) self.assertEqual(new_ids, []) self.assertEqual(len({t['track_id'] for t in second}), 2) self.assertTrue(all(t['confirmed'] for t in second)) def test_low_fps_track_survives_short_miss(self): tracker = IoUTracker() active, _, _, _ = tracker.update([ {'label': 'person', 'score': .8, 'box': [0, 0, 20, 40]}, ], 1, timestamp=1.0) tid = active[0]['track_id'] tracker.update([], 2, timestamp=4.0) active, ended, new_ids, _ = tracker.update([ {'label': 'person', 'score': .8, 'box': [2, 0, 22, 40]}, ], 3, timestamp=7.0) self.assertNotIn(tid, ended) self.assertEqual(new_ids, []) self.assertEqual(active[0]['track_id'], tid) def test_preview_uses_capture_timestamp_and_normalized_velocity(self): snapshots = [] pipe = CameraPipeline(1, 'test', 'test', on_preview=snapshots.append) pipe._last_w, pipe._last_h = 100, 200 pipe._frame_index = 2 pipe._tracker.update([ {'label': 'person', 'score': .8, 'box': [10, 20, 30, 80]}, ], 1, timestamp=10.0) pipe._tracker.update([ {'label': 'person', 'score': .8, 'box': [20, 30, 40, 90]}, ], 2, timestamp=11.0) pipe._publish_preview(11.0, processed_ts=11.25) snapshot = snapshots[-1] self.assertEqual(snapshot['timestamp'], 11.0) self.assertEqual(snapshot['processed_timestamp'], 11.25) self.assertAlmostEqual(snapshot['inference_latency'], .25) self.assertEqual(snapshot['tracks'][0]['box'], [.2, .15, .4, .45]) self.assertEqual(snapshot['tracks'][0]['velocity'], [.05, .025, .05, .025]) def test_formal_preview_temporarily_boosts_and_restores_analysis_fps(self): class Handle: def __init__(self): self.calls = [] def set_analyze_fps(self, fps): self.calls.append(float(fps)) class Process: @staticmethod def is_alive(): return True handle = Handle() manager = object.__new__(AnalysisManager) manager._lock = threading.RLock() manager._pipelines = {87: { 'running': True, 'mode': 'process', 'process': Process(), 'handle': handle, 'preview_only': False, 'analyze_fps': .67, }} zone = SimpleNamespace(stream=SimpleNamespace(id=87)) with patch('monitor_runtime.licensing.require_license'): self.assertEqual(manager.start_preview(zone), (True, 'formal')) self.assertEqual(handle.calls, [5.0]) self.assertEqual(manager._pipelines[87]['preview_restore_fps'], .67) self.assertFalse(manager.stop_preview(87)) self.assertEqual(handle.calls, [5.0, .67]) self.assertNotIn('preview_restore_fps', manager._pipelines[87]) def test_medium_score_confirmation_requires_consecutive_hits(self): tracker = IoUTracker() tracker.update([{'label': 'person', 'score': .4, 'box': [0, 0, 20, 40]}], 1, timestamp=1) tracker.update([], 2, timestamp=2) active, _, _, _ = tracker.update([ {'label': 'person', 'score': .5, 'box': [1, 0, 21, 40]}, ], 3, timestamp=3) self.assertFalse(active[0]['confirmed']) def test_person_foot_point_and_two_outside_hits(self): events = [] zone = {'id': 12, 'name': 'office', 'coords': [[0, .8], [1, .8], [1, 1], [0, 1]], 'alarm_repeat_sec': 30, 'biz_algorithms': [{ 'id': 1, 'name': 'person intrusion', 'flow_type': 1, 'small_model_id': 5, 'target_labels': ['person'], 'post_process': 'AREA'}]} pipe = CameraPipeline(1, 'test', 'test', zone_polygons=[zone], on_event=events.append) pipe._last_w, pipe._last_h = 100, 100 inside = {'track_id': 7, 'label': 'person', 'score': .8, 'box': [40, 40, 60, 90], 'algorithm_id': 5, 'confirmed': True, 'observed': True} with patch.object(pipe, '_emit_biz_alarm', return_value=True) as alarm: pipe._check_zones([inside], 1, np.zeros((100, 100, 3), dtype=np.uint8)) self.assertTrue(alarm.called) # 中心 y=65 在外,脚点 y=87.5 在区域内 outside = dict(inside, box=[40, 20, 60, 50]) pipe._check_zones([outside], 2, np.zeros((100, 100, 3), dtype=np.uint8)) self.assertEqual(pipe._last_zone_state[7], {12}) pipe._check_zones([outside], 3, np.zeros((100, 100, 3), dtype=np.uint8)) self.assertEqual(pipe._last_zone_state[7], set()) def test_unobserved_retained_track_does_not_leave_or_reenter(self): pipe = CameraPipeline(1, 'test', 'test') active, _, _, _ = pipe._tracker.update([ {'label': 'person', 'score': .8, 'box': [10, 10, 20, 30]}, ], 1, timestamp=time.time()) tid = active[0]['track_id'] pipe._last_zone_state = {tid: {12}} pipe._track_enter_ts = {(tid, 12): 10.0} pipe._check_zones([], 2, np.zeros((20, 20, 3), dtype=np.uint8)) self.assertEqual(pipe._last_zone_state[tid], {12}) self.assertEqual(pipe._track_enter_ts[(tid, 12)], 10.0) def test_area_alarm_repeat_is_independent_from_detection_rate(self): pipe = CameraPipeline(1, 'test', 'test') rule = {'id': 1, 'post_process': 'AREA', 'target_labels': ['person'], 'flow_type': 1, 'small_model_id': 5} zone = {'id': 12, 'detect_interval_sec': 1, 'alarm_repeat_sec': 30, 'biz_algorithms': [rule]} track = {'track_id': 7, 'label': 'person', 'score': .8, 'algorithm_id': 5, 'box': [0, 0, 10, 20]} with patch.object(pipe, '_emit_biz_alarm', return_value=True) as alarm: pipe._fire_area_alarms(track, 12, zone, set(), None, track['box'], 100.0, 7) pipe._fire_area_alarms(track, 12, zone, {12}, None, track['box'], 101.0, 7) pipe._fire_area_alarms(track, 12, zone, {12}, None, track['box'], 130.0, 7) self.assertEqual(alarm.call_count, 2) def test_preview_session_owner_and_last_viewer_cleanup(self): class Related(list): def filter(self, **_kwargs): return self def select_related(self, *_args): return self class Model: id, name, state = 5, 'YOLO26x', 1 class Rule: flow_type, detector_model, small_model = 1, None, Model() target_labels = '["person"]' class Stream: id, app, name = 87, 'live', 'camera01' class Zone: id, stream_id, stream, name, color = 12, 87, Stream(), 'office', '#169F85' coordinates = '[[0,0],[1,0],[1,1]]' algorithms = Related([Rule()]) registry = PreviewSessionRegistry() with patch('app.analysis.preview_sessions.AnalysisManager') as manager_cls: manager = manager_cls.return_value manager.start_preview.return_value = (True, 'preview') manager.preview_mode.return_value = 'preview' first = registry.start('owner-a', Zone()) second = registry.start('owner-b', Zone()) with self.assertRaises(PermissionError): registry.data('owner-b', first['session_id']) self.assertTrue(registry.stop('owner-a', first['session_id'])) manager.stop_preview.assert_not_called() self.assertTrue(registry.stop('owner-b', second['session_id'])) manager.stop_preview.assert_called_once_with(87) registry._running = False def test_worker_errors_are_preserved(self): requests, responses = queue.Queue(), queue.Queue() detector = RemoteDetector({'id': 5}, requests, responses, timeout=2, response_channel='camera-1') def reply(): msg = requests.get(timeout=2) self.assertEqual(msg['response_channel'], 'camera-1') responses.put({'req_id': msg['req_id'], 'ok': False, 'error': 'engine load failed'}) worker = threading.Thread(target=reply) worker.start() try: with self.assertRaisesRegex(RuntimeError, 'engine load failed'): detector.detect(np.zeros((32, 32, 3), dtype=np.uint8)) finally: worker.join(3) def test_failed_frame_is_not_counted_and_success_recovers(self): pipe = CameraPipeline(1, 'test', 'test') frame = np.zeros((32, 32, 3), dtype=np.uint8) def process(_): pipe.stop() raise TimeoutError('response timeout') with patch.object(pipe, '_decode_loop'), \ patch.object(pipe, '_pop_latest_frame', return_value=frame), \ patch.object(pipe, '_process_frame', side_effect=process): pipe.run() self.assertEqual(pipe.status()['analyzed_count'], 0) self.assertEqual(pipe.status()['analysis_health'], 'error') pipe._last_analyze_ts = 0 with patch.object(pipe, '_decode_loop'), \ patch.object(pipe, '_pop_latest_frame', return_value=frame), \ patch.object(pipe, '_process_frame', side_effect=lambda _: pipe.stop()): pipe.run() self.assertEqual(pipe.status()['analyzed_count'], 1) self.assertEqual(pipe.status()['analysis_health'], 'running') self.assertEqual(pipe.status()['analysis_error'], '') def test_low_frequency_success_keeps_nonzero_fps(self): pipe = CameraPipeline(1, 'test', 'test', analyze_fps=1 / 60) pipe._last_analysis_ts = time.time() pipe._analysis_fps = 1 / 60 self.assertEqual(pipe.status()['analysis_health'], 'running') self.assertGreater(pipe.status()['analysis_fps'], 0) if __name__ == '__main__': unittest.main()