"""Tests for CountingZone validation and double-count suppression.""" from __future__ import annotations import sys import unittest from unittest.mock import MagicMock # counting.py imports cv2; stub it so unit tests run without OpenCV installed. if "cv2" not in sys.modules: cv2_stub = MagicMock() cv2_stub.pointPolygonTest = MagicMock(return_value=1.0) sys.modules["cv2"] = cv2_stub import numpy as np from chicken_counter.config import GateConfig, RoiConfig from chicken_counter.counting import CountingZone from chicken_counter.types import TrackObservation def _zone(**kwargs) -> CountingZone: defaults = dict( roi=RoiConfig( points=[(0, 0), (200, 0), (200, 200), (0, 200)], min_overlap_ratio=0.0, ), gate=GateConfig(), trail_length=10, track_buffer=75, min_box_area_px=0, validate_while_inside=True, dedupe_radius_px=64, dedupe_frames=40, ) defaults.update(kwargs) return CountingZone(**defaults) def _track( track_id: int, centroid: tuple[int, int] = (100, 100), bbox: tuple[int, int, int, int] | None = None, ) -> TrackObservation: cx, cy = centroid if bbox is None: bbox = (cx - 20, cy - 20, cx + 20, cy + 20) return TrackObservation( track_id=track_id, class_id=0, confidence=0.9, bbox_xyxy=bbox, centroid=centroid, ) class CountingDedupeTests(unittest.TestCase): def setUp(self) -> None: # Treat every centroid as inside the ROI polygon for these unit tests. sys.modules["cv2"].pointPolygonTest = MagicMock(return_value=1.0) def test_counts_unique_track_once(self) -> None: zone = _zone() events = zone.update([_track(1)], frame_index=1) self.assertEqual(len(events), 1) self.assertEqual(zone.total_entered_count, 1) events = zone.update([_track(1)], frame_index=2) self.assertEqual(len(events), 0) self.assertEqual(zone.total_entered_count, 1) def test_suppresses_nearby_id_switch_double_count(self) -> None: zone = _zone(dedupe_radius_px=64, dedupe_frames=40) events = zone.update([_track(1, (100, 100))], frame_index=10) self.assertEqual(len(events), 1) self.assertEqual(zone.total_entered_count, 1) # New track ID near the prior count should reuse sequence, not increment. events = zone.update([_track(2, (110, 105))], frame_index=20) self.assertEqual(len(events), 0) self.assertEqual(zone.total_entered_count, 1) self.assertTrue(zone.is_validated(2)) self.assertEqual(zone.sequence_number_for(2), 1) def test_allows_distant_second_bird(self) -> None: zone = _zone(dedupe_radius_px=64, dedupe_frames=40) zone.update([_track(1, (40, 40))], frame_index=10) events = zone.update([_track(2, (160, 160))], frame_index=15) self.assertEqual(len(events), 1) self.assertEqual(zone.total_entered_count, 2) def test_allows_recount_after_dedupe_window(self) -> None: zone = _zone(dedupe_radius_px=64, dedupe_frames=10) zone.update([_track(1, (100, 100))], frame_index=10) events = zone.update([_track(2, (100, 100))], frame_index=30) self.assertEqual(len(events), 1) self.assertEqual(zone.total_entered_count, 2) def test_dedupe_disabled_when_radius_zero(self) -> None: zone = _zone(dedupe_radius_px=0, dedupe_frames=40) zone.update([_track(1, (100, 100))], frame_index=10) events = zone.update([_track(2, (100, 100))], frame_index=12) self.assertEqual(len(events), 1) self.assertEqual(zone.total_entered_count, 2) def test_counting_polygon_is_numpy_array(self) -> None: zone = _zone() self.assertIsInstance(zone._counting_polygon, np.ndarray) if __name__ == "__main__": unittest.main()