Private
Public Access
addet test set
This commit is contained in:
1 parent
d370f992ce
commit
b0f35bf3cc
21 files changed
+1796
-183
No files matched your search
@@ -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()
|
||||
Reference in new issue
Block a user