feat: stabilize cold display runtime deployment
This commit is contained in:
@@ -1,13 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from cold_display_guard.vision import (
|
||||
Frame,
|
||||
Region,
|
||||
RegionMetrics,
|
||||
RuntimeVisionSettings,
|
||||
ZoneOccupancyDetector,
|
||||
load_runtime_vision_settings,
|
||||
point_in_polygon,
|
||||
)
|
||||
|
||||
@@ -26,6 +28,16 @@ def patched_frame(width: int, height: int, base: int, patch: tuple[int, int, int
|
||||
return Frame(width=width, height=height, rgb=bytes(pixels))
|
||||
|
||||
|
||||
def multi_patched_frame(width: int, height: int, base: int, patches: list[tuple[int, int, int, int, int]]) -> Frame:
|
||||
pixels = bytearray(bytes([base, base, base]) * width * height)
|
||||
for x1, y1, x2, y2, value in patches:
|
||||
for y in range(y1, y2):
|
||||
for x in range(x1, x2):
|
||||
offset = (y * width + x) * 3
|
||||
pixels[offset : offset + 3] = bytes([value, value, value])
|
||||
return Frame(width=width, height=height, rgb=bytes(pixels))
|
||||
|
||||
|
||||
class VisionTests(unittest.TestCase):
|
||||
def test_point_in_polygon(self) -> None:
|
||||
polygon = ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0))
|
||||
@@ -42,6 +54,8 @@ class VisionTests(unittest.TestCase):
|
||||
sample_stride_pixels=4,
|
||||
occupancy_mean_delta=10,
|
||||
occupancy_texture_delta=10,
|
||||
occupancy_confirm_frames=1,
|
||||
empty_confirm_frames=1,
|
||||
),
|
||||
)
|
||||
now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc)
|
||||
@@ -67,6 +81,196 @@ class VisionTests(unittest.TestCase):
|
||||
self.assertEqual(first_deposit, 0)
|
||||
self.assertEqual(second_deposit, 1)
|
||||
|
||||
def test_detector_reports_sustained_trash_motion_below_single_frame_threshold(self) -> None:
|
||||
trash = Region("trash", ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)))
|
||||
detector = ZoneOccupancyDetector(
|
||||
[],
|
||||
trash_region=trash,
|
||||
settings=RuntimeVisionSettings(
|
||||
sample_stride_pixels=4,
|
||||
trash_motion_delta=18,
|
||||
trash_sustained_motion_delta=8,
|
||||
trash_sustained_motion_frames=2,
|
||||
),
|
||||
)
|
||||
now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc)
|
||||
|
||||
_, first_deposit, _ = detector.observe(solid_frame(32, 32, 30), now)
|
||||
_, second_deposit, second_diagnostics = detector.observe(solid_frame(32, 32, 39), now)
|
||||
_, third_deposit, third_diagnostics = detector.observe(solid_frame(32, 32, 48), now)
|
||||
|
||||
self.assertEqual(first_deposit, 0)
|
||||
self.assertEqual(second_deposit, 0)
|
||||
self.assertEqual(second_diagnostics["trash"]["motion_streak"], 1)
|
||||
self.assertEqual(third_deposit, 1)
|
||||
self.assertEqual(third_diagnostics["trash"]["motion_streak"], 2)
|
||||
|
||||
def test_detector_allows_quick_sequential_strong_trash_motions(self) -> None:
|
||||
trash = Region("trash", ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)))
|
||||
detector = ZoneOccupancyDetector(
|
||||
[],
|
||||
trash_region=trash,
|
||||
settings=RuntimeVisionSettings(sample_stride_pixels=4, trash_motion_delta=18),
|
||||
)
|
||||
now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc)
|
||||
|
||||
_, first_deposit, _ = detector.observe(solid_frame(32, 32, 30), now)
|
||||
_, second_deposit, _ = detector.observe(solid_frame(32, 32, 90), now + timedelta(seconds=1))
|
||||
_, third_deposit, third_diagnostics = detector.observe(solid_frame(32, 32, 30), now + timedelta(seconds=7))
|
||||
|
||||
self.assertEqual(first_deposit, 0)
|
||||
self.assertEqual(second_deposit, 1)
|
||||
self.assertEqual(third_deposit, 1)
|
||||
self.assertFalse(third_diagnostics["trash"]["in_cooldown"])
|
||||
|
||||
def test_detector_requires_consecutive_occupied_frames(self) -> None:
|
||||
detector = ZoneOccupancyDetector(
|
||||
[Region("1", ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)))],
|
||||
trash_region=None,
|
||||
settings=RuntimeVisionSettings(
|
||||
baseline_frames=1,
|
||||
sample_stride_pixels=4,
|
||||
occupancy_mean_delta=10,
|
||||
occupancy_texture_delta=10,
|
||||
occupancy_confirm_frames=2,
|
||||
empty_confirm_frames=2,
|
||||
),
|
||||
)
|
||||
now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc)
|
||||
|
||||
detector.observe(solid_frame(32, 32, 30), now)
|
||||
first_counts, _, first_diagnostics = detector.observe(solid_frame(32, 32, 90), now)
|
||||
second_counts, _, second_diagnostics = detector.observe(solid_frame(32, 32, 90), now)
|
||||
first_empty_counts, _, _ = detector.observe(solid_frame(32, 32, 30), now)
|
||||
second_empty_counts, _, _ = detector.observe(solid_frame(32, 32, 30), now)
|
||||
|
||||
self.assertEqual(first_counts, {"1": 0})
|
||||
self.assertTrue(first_diagnostics["zones"]["1"]["raw_occupied"])
|
||||
self.assertEqual(first_diagnostics["zones"]["1"]["occupied_streak"], 1)
|
||||
self.assertEqual(second_counts, {"1": 1})
|
||||
self.assertTrue(second_diagnostics["zones"]["1"]["occupied"])
|
||||
self.assertEqual(first_empty_counts, {"1": 1})
|
||||
self.assertEqual(second_empty_counts, {"1": 0})
|
||||
|
||||
def test_runtime_vision_defaults_raise_brightness_reflection_threshold(self) -> None:
|
||||
settings = load_runtime_vision_settings({})
|
||||
|
||||
self.assertEqual(settings.sample_stride_pixels, 4)
|
||||
self.assertEqual(settings.occupancy_mean_delta, 55.0)
|
||||
self.assertEqual(settings.occupancy_confirm_frames, 2)
|
||||
self.assertEqual(settings.empty_confirm_frames, 2)
|
||||
self.assertEqual(settings.trash_motion_delta, 18.0)
|
||||
self.assertEqual(settings.trash_sustained_motion_delta, 8.0)
|
||||
self.assertEqual(settings.trash_sustained_motion_frames, 2)
|
||||
self.assertEqual(settings.trash_motion_cooldown_seconds, 3)
|
||||
|
||||
def test_detector_can_seed_previous_baseline_and_occupancy(self) -> None:
|
||||
detector = ZoneOccupancyDetector(
|
||||
[Region("1", ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)))],
|
||||
trash_region=None,
|
||||
settings=RuntimeVisionSettings(
|
||||
baseline_frames=10,
|
||||
sample_stride_pixels=4,
|
||||
occupancy_mean_delta=55,
|
||||
occupancy_texture_delta=18,
|
||||
occupancy_confirm_frames=2,
|
||||
empty_confirm_frames=2,
|
||||
),
|
||||
)
|
||||
detector.seed_baseline({"1": RegionMetrics(mean_luma=30.0, texture=0.0, sample_count=1)})
|
||||
detector.seed_occupancy({"1": 1})
|
||||
|
||||
counts, _, diagnostics = detector.observe(solid_frame(32, 32, 90), datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc))
|
||||
|
||||
self.assertTrue(diagnostics["baseline_ready"])
|
||||
self.assertEqual(counts, {"1": 1})
|
||||
self.assertTrue(diagnostics["zones"]["1"]["occupied"])
|
||||
|
||||
def test_detector_reports_compact_dark_object_as_occupied(self) -> None:
|
||||
detector = ZoneOccupancyDetector(
|
||||
[Region("1", ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)))],
|
||||
trash_region=None,
|
||||
settings=RuntimeVisionSettings(
|
||||
baseline_frames=1,
|
||||
sample_stride_pixels=4,
|
||||
occupancy_mean_delta=55,
|
||||
occupancy_texture_delta=100,
|
||||
occupancy_dark_luma_threshold=80,
|
||||
occupancy_dark_fraction=0.06,
|
||||
occupancy_confirm_frames=1,
|
||||
empty_confirm_frames=1,
|
||||
),
|
||||
)
|
||||
now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc)
|
||||
|
||||
detector.observe(solid_frame(32, 32, 180), now)
|
||||
counts, _, diagnostics = detector.observe(patched_frame(32, 32, 180, (0, 0, 8, 32, 20)), now)
|
||||
|
||||
self.assertEqual(counts, {"1": 1})
|
||||
self.assertGreaterEqual(diagnostics["zones"]["1"]["dark_fraction"], 0.06)
|
||||
|
||||
def test_detector_ignores_bright_reflection_without_dark_object(self) -> None:
|
||||
detector = ZoneOccupancyDetector(
|
||||
[Region("1", ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)))],
|
||||
trash_region=None,
|
||||
settings=RuntimeVisionSettings(
|
||||
baseline_frames=1,
|
||||
sample_stride_pixels=4,
|
||||
occupancy_mean_delta=55,
|
||||
occupancy_texture_delta=10,
|
||||
occupancy_dark_luma_threshold=80,
|
||||
occupancy_dark_fraction=0.06,
|
||||
occupancy_texture_dark_fraction=0.04,
|
||||
occupancy_confirm_frames=1,
|
||||
empty_confirm_frames=1,
|
||||
),
|
||||
)
|
||||
now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc)
|
||||
|
||||
detector.observe(solid_frame(32, 32, 160), now)
|
||||
counts, _, diagnostics = detector.observe(patched_frame(32, 32, 160, (0, 0, 8, 32, 255)), now)
|
||||
|
||||
self.assertEqual(counts, {"1": 0})
|
||||
self.assertGreaterEqual(diagnostics["zones"]["1"]["texture_delta"], 10)
|
||||
self.assertLess(diagnostics["zones"]["1"]["dark_fraction"], 0.04)
|
||||
|
||||
def test_detector_ignores_bright_reflection_with_small_dark_edge(self) -> None:
|
||||
detector = ZoneOccupancyDetector(
|
||||
[Region("1", ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)))],
|
||||
trash_region=None,
|
||||
settings=RuntimeVisionSettings(
|
||||
baseline_frames=1,
|
||||
sample_stride_pixels=4,
|
||||
occupancy_mean_delta=55,
|
||||
occupancy_texture_delta=18,
|
||||
occupancy_dark_luma_threshold=80,
|
||||
occupancy_dark_fraction=0.06,
|
||||
occupancy_texture_dark_fraction=0.04,
|
||||
occupancy_confirm_frames=1,
|
||||
empty_confirm_frames=1,
|
||||
),
|
||||
)
|
||||
now = datetime(2026, 4, 28, 10, 0, tzinfo=timezone.utc)
|
||||
|
||||
detector.observe(solid_frame(40, 40, 180), now)
|
||||
counts, _, diagnostics = detector.observe(
|
||||
multi_patched_frame(
|
||||
40,
|
||||
40,
|
||||
180,
|
||||
[
|
||||
(0, 0, 12, 40, 255),
|
||||
(12, 0, 16, 32, 20),
|
||||
],
|
||||
),
|
||||
now,
|
||||
)
|
||||
|
||||
zone = diagnostics["zones"]["1"]
|
||||
self.assertEqual(counts, {"1": 0})
|
||||
self.assertGreaterEqual(zone["dark_fraction"], 0.06)
|
||||
self.assertGreaterEqual(zone["bright_fraction"], 0.18)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user