addet test set

This commit is contained in:
marcinator_2 committed 2026-10-14 17:40:31 +02:00
1 parent d370f992ce
commit b0f35bf3cc
21 files changed
+1796 -183

No files matched your search

+283
View File
@@ -0,0 +1,283 @@
"""Directional motion with synthetic images; no persons, model or Pi hardware."""
import asyncio
import importlib.util
from io import BytesIO
import json
from pathlib import Path
import tempfile
import threading
import unittest
import httpx
from PIL import Image
from halloween.controller import Controller
from halloween.detection import DEFAULT_CONFIG, DetectionService, validate_config
from halloween.event_store import EventStore
from halloween.motion import MotionDetector
from halloween.web import create_app
HAS_OPENCV = importlib.util.find_spec('cv2') is not None
CONFIG = {**DEFAULT_CONFIG, 'enabled': True, 'method': 'motion_test'}
def scene(x=85, y=35, brightness=0):
import numpy as np
rng = np.random.default_rng(91)
frame = np.full((180, 240), 45 + brightness, dtype=np.uint8)
texture = rng.integers(70, 210, size=(45, 50), dtype=np.uint8)
frame[y:y+45, x:x+50] = texture + brightness
stream = BytesIO()
Image.fromarray(frame).save(stream, format='JPEG', quality=95)
return stream.getvalue()
def small_scene(x=210):
"""Small near-camera patch moving slowly in a full 640x480 frame."""
import numpy as np
rng = np.random.default_rng(121)
frame = np.full((480, 640), 95, dtype=np.uint8)
texture = rng.integers(110, 165, size=(60, 60), dtype=np.uint8)
frame[150:210, x:x+60] = texture
stream = BytesIO()
Image.fromarray(frame).save(stream, format='JPEG', quality=95)
return stream.getvalue()
class ConfigurationTest(unittest.TestCase):
def test_old_config_retains_enabled_geometry_and_person_method(self):
old = {k: v for k, v in DEFAULT_CONFIG.items() if k != 'method'}
old['enabled'] = True
loaded = validate_config(old)
self.assertEqual(loaded['method'], 'person_line')
self.assertTrue(loaded['enabled'])
self.assertEqual(loaded['line'], old['line'])
self.assertNotIn('method', old)
def test_unknown_method_rejected(self):
with self.assertRaises(ValueError):
validate_config({**CONFIG, 'method': 'face'})
@unittest.skipUnless(HAS_OPENCV, 'Optical flow tests need optional OpenCV')
class OpticalMotionTest(unittest.TestCase):
def detect_sequence(self, frames, config=CONFIG):
detector = MotionDetector()
return [detector.detect(frame, config, index*0.2) for index, frame in enumerate(frames)]
def test_forward_motion_without_line_crossing(self):
# Box stays entirely above the horizontal line at y=.55.
result = self.detect_sequence([scene(y=y) for y in (32, 36, 40, 44)])
self.assertFalse(result[0]['ready'])
self.assertFalse(result[1]['trigger'])
self.assertTrue(result[-1]['trigger'], result)
def test_reverse_and_parallel_do_not_trigger(self):
for frames in ([scene(y=y) for y in (44, 40, 36, 32)],
[scene(x=x) for x in (70, 74, 78, 82)]):
with self.subTest():
result = self.detect_sequence(frames)
self.assertFalse(any(r['trigger'] for r in result), result)
self.assertTrue(result[-1]['moving'])
def test_arrow_can_be_reversed_or_rotated(self):
reverse = self.detect_sequence([scene(y=y) for y in (44, 40, 36, 32)],
{**CONFIG, 'direction': -1})
self.assertTrue(reverse[-1]['trigger'], reverse)
# Vertical line from bottom to top gives a rightward arrow.
rotated = self.detect_sequence([scene(x=x) for x in (70, 74, 78, 82)],
{**CONFIG, 'line': [[.55, .8], [.55, .2]]})
self.assertTrue(rotated[-1]['trigger'], rotated)
def test_static_scene_and_light_change_do_not_trigger(self):
for frames in ([scene()]*6, [scene(brightness=b) for b in (0, 25, 0, 25, 0)]):
with self.subTest():
result = self.detect_sequence(frames)
self.assertFalse(any(r['trigger'] for r in result), result)
def test_motion_outside_region_is_ignored(self):
result = self.detect_sequence([scene(x=5, y=y) for y in (35, 39, 43, 47)],
{**CONFIG, 'region': [.3, .15, .9, .95]})
self.assertFalse(any(r['moving'] or r['trigger'] for r in result), result)
def test_small_slow_rightward_change_in_large_region(self):
result = self.detect_sequence([small_scene(x=x) for x in (210, 212, 214, 216, 218)],
{**CONFIG, 'line': [[.45, .8], [.45, .2]],
'region': [.04, .02, .94, .98]})
self.assertTrue(result[-1]['trigger'], result)
def test_small_slow_reverse_change_does_not_trigger(self):
result = self.detect_sequence([small_scene(x=x) for x in (218, 216, 214, 212, 210)],
{**CONFIG, 'line': [[.45, .8], [.45, .2]],
'region': [.04, .02, .94, .98]})
self.assertFalse(any(r['trigger'] for r in result), result)
self.assertTrue(result[-1]['moving'])
def test_single_frame_or_camera_gap_cannot_trigger(self):
result = self.detect_sequence([scene(y=32), scene(y=36), scene(y=36)])
self.assertFalse(any(r['trigger'] for r in result), result)
detector = MotionDetector()
detector.detect(scene(y=32), CONFIG, 0)
detector.detect(scene(y=36), CONFIG, .2)
result = detector.detect(scene(y=40), CONFIG, 1.2)
self.assertFalse(result['ready'])
self.assertFalse(result['trigger'])
@unittest.skipUnless(HAS_OPENCV, 'Optical flow tests need optional OpenCV')
class MotionServiceTest(unittest.TestCase):
def setUp(self):
self.folder = tempfile.TemporaryDirectory()
self.addCleanup(self.folder.cleanup)
self.directory = Path(self.folder.name)
self.time = 0
self.number = 0
self.controller = Controller(self.directory, clock=lambda: self.time)
self.events = EventStore(self.directory)
self.camera = type('Camera', (), {'error': None})()
self.service = DetectionService(self.camera, self.controller, self.events,
self.directory, clock=lambda: self.time)
self.service.configure(CONFIG)
self.detector = MotionDetector()
def step(self, x=85, y=35, dt=.2):
self.time += dt
self.number += 1
jpeg = scene(x, y)
motion = self.detector.detect(jpeg, self.service.get_config(), self.time)
self.service.process([], self.number, jpeg, self.time, self.service.revision, motion)
def quiet(self, y=35):
for _ in range(13):
self.step(y=y)
def move(self):
for y in (35, 39, 43, 47):
self.step(y=y)
def test_initial_quiet_cooldown_and_distinct_event(self):
self.move()
self.assertEqual(self.events.recent(), [])
self.quiet()
self.assertTrue(self.service.status()['armed'])
self.move()
event = self.events.recent()[0]
self.assertEqual(event['kind'], 'motion_direction_test')
self.assertIsNone(event['profile_id'])
self.assertTrue(event['image_url'])
with Image.open(self.events.image_path(event['id'])) as saved:
self.assertEqual(saved.size, (192, 144))
expected = Image.open(BytesIO(scene(y=43))).crop((24, 27, 216, 171)).convert('RGB')
self.assertEqual(saved.size, expected.size)
encoded = BytesIO()
expected.save(encoded, format='JPEG', quality=90)
self.assertEqual(self.events.image_path(event['id']).read_bytes(), encoded.getvalue())
self.assertEqual(self.service.snapshot()['people'], [])
self.assertTrue(self.service.snapshot()['motion']['directed'])
self.quiet()
self.move()
self.assertEqual(len(self.events.recent()), 1)
self.quiet()
# Wait out remaining cooldown with valid quiet frames.
for _ in range(30):
self.step()
self.move()
self.assertEqual(len(self.events.recent()), 2)
def test_small_motion_triggers_without_manual_clear(self):
self.service.configure({**CONFIG, 'line': [[.45, .8], [.45, .2]],
'region': [.04, .02, .94, .98]})
self.detector = MotionDetector()
def step(x):
self.time += .2
self.number += 1
jpeg = small_scene(x)
motion = self.detector.detect(jpeg, self.service.get_config(), self.time)
self.service.process([], self.number, jpeg, self.time, self.service.revision, motion)
for _ in range(13):
step(210)
self.assertTrue(self.service.status()['armed'])
self.assertFalse(self.controller.zone_clear_seen)
for x in (212, 214, 216):
step(x)
self.assertEqual(len(self.events.recent()), 1)
self.assertEqual(self.events.recent()[0]['kind'], 'motion_direction_test')
self.assertEqual(self.service.snapshot()['motion']['confirmed_frames'], 2)
def test_stop_watchdog_and_stale_inflight_frame(self):
self.quiet()
self.controller.stop()
self.move()
self.assertEqual(self.events.recent(), [])
self.controller.reset()
self.service.reset()
self.detector = MotionDetector()
self.move()
self.assertEqual(self.events.recent(), [])
self.quiet()
old_revision = self.service.revision
self.service.configure(CONFIG)
self.service.process([], 99, scene(), self.time, old_revision,
{'ready': True, 'moving': True, 'trigger': True})
self.assertIsNone(self.service.last_valid)
self.time += 2.1
self.service.watchdog()
self.assertTrue(self.controller.stopped)
self.assertTrue(self.service.status()['reset_required'])
def test_restart_preserves_method_but_not_arming(self):
self.quiet()
other = DetectionService(self.camera, self.controller, self.events, self.directory)
self.assertEqual(other.get_config()['method'], 'motion_test')
self.assertTrue(other.get_config()['enabled'])
self.assertFalse(other.status()['armed'])
def test_switch_discards_old_model_failure(self):
entered, release = threading.Event(), threading.Event()
self.service.configure({**DEFAULT_CONFIG, 'enabled': False})
def old_model(path):
entered.set()
release.wait(3)
raise OSError('old model failure')
self.service.factory = old_model
self.camera.latest_timed = lambda: (1, scene(), self.time)
self.service.start()
try:
self.assertTrue(entered.wait(1))
self.service.configure(CONFIG)
release.set()
for _ in range(40):
if self.service.last_valid is not None:
break
threading.Event().wait(.05)
self.assertIsNotNone(self.service.last_valid)
self.assertFalse(self.service.fault)
self.assertFalse(self.controller.stopped)
finally:
release.set()
self.service.stop()
def test_worker_uses_motion_without_person_model(self):
async def scenario():
app = create_app(demo=True, state_dir=self.directory)
app.state.detection.configure(CONFIG)
def no_person_model(path):
raise AssertionError('Person model must not be loaded for motion test')
app.state.detection.factory = no_person_model
async with app.router.lifespan_context(app):
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as client:
for _ in range(30):
await asyncio.sleep(.05)
result = (await client.get('/api/detection')).json()
if result['motion'] and result['motion']['ready']:
break
self.assertTrue(result['status']['ready'], result)
self.assertEqual(result['status']['method'], 'motion_test')
self.assertFalse(result['status']['reset_required'], result)
self.assertIsNotNone(result['motion'])
self.assertEqual(result['people'], [])
asyncio.run(scenario())
if __name__ == '__main__':
unittest.main()