Private
Public Access
kamera geht
This commit is contained in:
1 parent
bfecfd0640
commit
d370f992ce
19 files changed
+1365
-1128
No files matched your search
@@ -14,3 +14,13 @@ Automatische Tests prüfen die aktuelle Software ohne Pi-Hardware.
|
||||
aus einem grünen Host-Test ableiten; diese Prüfungen getrennt berichten.
|
||||
- Tests für neue Sicherheits- oder Zustandsregeln an beobachtbarem Verhalten
|
||||
ausrichten, nicht bloß interne Implementierung spiegeln.
|
||||
|
||||
- `test_detection.py` prüft Linienrichtung, Bestätigung, Flattern, Segmentgrenzen,
|
||||
Gruppenfreigabe, Sperrzeit, Neustart/Änderungen, Fehler-Watchdog, synchrone
|
||||
Frames und Konfigurations-API mit kontrollierter Uhr ohne Modell/Pi.
|
||||
- Echte NanoDet-Inferenz zusätzlich mit ausdrücklich installiertem Modell
|
||||
prüfen; ein grüner Geometrietest bestätigt keine Erkennungsqualität.
|
||||
|
||||
- `test_detection_model.py` prüft atomare/identische Installation und Ablehnung
|
||||
falscher Downloads ohne Netzwerk. Optional `HALLOWEEN_TEST_MODEL` auf eine
|
||||
geprüfte ONNX-Datei setzen: Dann wird die echte Inferenz auf leerem Bild geprüft.
|
||||
@@ -0,0 +1,300 @@
|
||||
import asyncio
|
||||
from io import BytesIO
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
from PIL import Image
|
||||
import httpx
|
||||
|
||||
from halloween.controller import Controller
|
||||
from halloween.detection import DEFAULT_CONFIG, DetectionService, LineTracker, NanoDet, validate_config
|
||||
from halloween.event_store import EventStore
|
||||
from halloween.web import create_app
|
||||
|
||||
|
||||
def person(x=0.5, y=0.4):
|
||||
return {"box": [x-0.03, y-0.1, x+0.03, y], "score": 0.9}
|
||||
|
||||
|
||||
class TrackerTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tracker = LineTracker()
|
||||
self.config = json.loads(json.dumps(DEFAULT_CONFIG))
|
||||
self.time = 0
|
||||
|
||||
def step(self, people):
|
||||
self.time += 0.2
|
||||
return self.tracker.update(people, self.config, self.time)[1]
|
||||
|
||||
def test_forward_and_reverse(self):
|
||||
for _ in range(3):
|
||||
self.assertFalse(self.step([person()]))
|
||||
self.assertTrue(self.step([person(y=0.65)]))
|
||||
self.assertFalse(self.step([person(y=0.4)]))
|
||||
self.assertFalse(self.step([person(y=0.4)]))
|
||||
|
||||
def test_reverse_direction_configuration(self):
|
||||
self.config['direction'] = -1
|
||||
for _ in range(3):
|
||||
self.assertFalse(self.step([person(y=0.65)]))
|
||||
self.assertTrue(self.step([person(y=0.4)]))
|
||||
|
||||
def test_appearance_behind_line(self):
|
||||
for _ in range(5):
|
||||
self.assertFalse(self.step([person(y=0.65)]))
|
||||
|
||||
def test_jitter_and_parallel_motion(self):
|
||||
for y in [0.54, 0.56, 0.54, 0.56, 0.54, 0.56]:
|
||||
self.assertFalse(self.step([person(y=y)]))
|
||||
for x in [0.45, 0.5, 0.55, 0.6]:
|
||||
self.assertFalse(self.step([person(x=x, y=0.4)]))
|
||||
|
||||
def test_flicker_and_outside_segment(self):
|
||||
for _ in range(3):
|
||||
self.step([person()])
|
||||
self.step([])
|
||||
self.assertFalse(self.step([person(y=0.65)]))
|
||||
self.setUp()
|
||||
for _ in range(3):
|
||||
self.step([person(x=0.15)])
|
||||
self.assertFalse(self.step([person(x=0.15, y=0.65)]))
|
||||
|
||||
|
||||
class ServiceTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.folder = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.folder.cleanup)
|
||||
self.directory = Path(self.folder.name)
|
||||
self.time = 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({**DEFAULT_CONFIG, 'enabled': True})
|
||||
buffer = BytesIO()
|
||||
Image.new('RGB', (640, 480)).save(buffer, format='JPEG')
|
||||
self.jpeg = buffer.getvalue()
|
||||
self.number = 0
|
||||
|
||||
def step(self, people=(), dt=0.2):
|
||||
self.time += dt
|
||||
self.number += 1
|
||||
self.service.process(list(people), self.number, self.jpeg, self.time, self.service.revision)
|
||||
|
||||
def free(self):
|
||||
for _ in range(12):
|
||||
self.step()
|
||||
|
||||
def cross(self, x=0.5):
|
||||
for _ in range(3):
|
||||
self.step([person(x=x)])
|
||||
self.step([person(x=x, y=0.65)])
|
||||
|
||||
def test_group_cooldown_and_new_group(self):
|
||||
self.free()
|
||||
self.assertTrue(self.service.status()['armed'])
|
||||
self.cross()
|
||||
self.assertEqual(len(self.events.recent()), 1)
|
||||
event = self.events.recent()[0]
|
||||
self.assertEqual(event['kind'], 'person_line_crossing')
|
||||
self.assertIsNone(event['profile_id'])
|
||||
self.cross(x=0.65)
|
||||
self.assertEqual(len(self.events.recent()), 1)
|
||||
for _ in range(60):
|
||||
self.step([person(y=0.65)])
|
||||
self.cross(x=0.65)
|
||||
self.assertEqual(len(self.events.recent()), 1)
|
||||
self.free()
|
||||
self.cross()
|
||||
self.assertEqual(len(self.events.recent()), 2)
|
||||
|
||||
def test_no_delayed_crossing(self):
|
||||
self.free()
|
||||
self.cross()
|
||||
self.free()
|
||||
self.cross(x=0.65)
|
||||
self.assertEqual(len(self.events.recent()), 1)
|
||||
for _ in range(60):
|
||||
self.step([person(x=0.65, y=0.65)])
|
||||
self.assertEqual(len(self.events.recent()), 1)
|
||||
|
||||
def test_start_stop_reset_and_configuration(self):
|
||||
self.cross()
|
||||
self.assertEqual(self.events.recent(), [])
|
||||
self.free()
|
||||
self.controller.stop()
|
||||
self.cross()
|
||||
self.assertEqual(self.events.recent(), [])
|
||||
self.controller.reset()
|
||||
self.service.reset()
|
||||
self.cross()
|
||||
self.assertEqual(self.events.recent(), [])
|
||||
self.free()
|
||||
old_revision = self.service.revision
|
||||
self.service.configure(self.service.get_config())
|
||||
self.service.process([], 99, self.jpeg, self.time, old_revision)
|
||||
self.assertIsNone(self.service.last_valid)
|
||||
self.cross()
|
||||
self.assertEqual(self.events.recent(), [])
|
||||
self.free()
|
||||
self.cross()
|
||||
self.assertEqual(len(self.events.recent()), 1)
|
||||
|
||||
def test_watchdog_and_manual_bypass(self):
|
||||
self.free()
|
||||
self.time += 2.01
|
||||
self.service.watchdog()
|
||||
self.assertTrue(self.controller.stopped)
|
||||
self.assertTrue(self.service.status()['reset_required'])
|
||||
self.controller.zone_clear()
|
||||
self.assertFalse(self.controller.trigger()[0])
|
||||
self.free()
|
||||
self.assertTrue(self.service.status()['reset_required'])
|
||||
self.controller.reset()
|
||||
self.service.reset()
|
||||
self.free()
|
||||
self.cross()
|
||||
self.assertEqual(len(self.events.recent()), 1)
|
||||
|
||||
def test_camera_error_and_stale_results(self):
|
||||
self.free()
|
||||
self.camera.error = 'camera disconnected'
|
||||
self.service.watchdog()
|
||||
self.assertTrue(self.service.fault)
|
||||
self.assertTrue(self.controller.stopped)
|
||||
self.time += 3
|
||||
result = self.service.snapshot()
|
||||
self.assertEqual(result['people'], [])
|
||||
self.assertIsNone(result['image_base64'])
|
||||
|
||||
def test_disabled_detection_does_not_stop_manual_tests(self):
|
||||
self.service.configure(DEFAULT_CONFIG)
|
||||
self.service.fail('missing model')
|
||||
self.assertFalse(self.controller.stopped)
|
||||
self.controller.zone_clear()
|
||||
self.assertTrue(self.controller.trigger()[0])
|
||||
|
||||
def test_inference_stall_is_stopped_by_independent_watchdog(self):
|
||||
entered, release = threading.Event(), threading.Event()
|
||||
class BlockingDetector:
|
||||
def detect(inner, jpeg):
|
||||
entered.set()
|
||||
release.wait(3)
|
||||
return []
|
||||
self.camera.latest_timed = lambda: (1, self.jpeg, self.time)
|
||||
self.service.factory = lambda path: BlockingDetector()
|
||||
self.service.start()
|
||||
try:
|
||||
self.assertTrue(entered.wait(1))
|
||||
self.time += 2.1
|
||||
self.service.watchdog()
|
||||
self.assertTrue(self.controller.stopped)
|
||||
self.assertTrue(self.service.fault)
|
||||
finally:
|
||||
release.set()
|
||||
self.service.stop()
|
||||
|
||||
def test_database_stall_does_not_block_watchdog(self):
|
||||
entered, release = threading.Event(), threading.Event()
|
||||
def blocked_record(mode):
|
||||
entered.set()
|
||||
release.wait(3)
|
||||
self.events.record_detection_trigger = blocked_record
|
||||
self.free()
|
||||
for _ in range(3):
|
||||
self.step([person()])
|
||||
worker = threading.Thread(target=lambda: self.step([person(y=0.65)]))
|
||||
worker.start()
|
||||
try:
|
||||
self.assertTrue(entered.wait(1))
|
||||
self.time += 2.1
|
||||
self.service.watchdog()
|
||||
self.assertTrue(self.controller.stopped)
|
||||
finally:
|
||||
release.set()
|
||||
worker.join(timeout=1)
|
||||
self.assertFalse(worker.is_alive())
|
||||
|
||||
def test_database_failure_stops_simulation(self):
|
||||
def broken_record(mode):
|
||||
raise OSError('disk full')
|
||||
self.events.record_detection_trigger = broken_record
|
||||
self.free()
|
||||
self.cross()
|
||||
self.assertTrue(self.controller.stopped)
|
||||
self.assertTrue(self.service.fault)
|
||||
self.assertIn('disk full', self.service.status()['error'])
|
||||
|
||||
def test_shutdown_drops_inflight_frame(self):
|
||||
self.free()
|
||||
version = self.service.revision
|
||||
self.service.stop()
|
||||
self.service.process([], 99, self.jpeg, self.time, version)
|
||||
self.assertTrue(self.controller.stopped)
|
||||
self.assertEqual(self.events.recent(), [])
|
||||
|
||||
def test_persistence_and_corrupt_settings(self):
|
||||
other = DetectionService(self.camera, self.controller, self.events, self.directory)
|
||||
self.assertTrue(other.get_config()['enabled'])
|
||||
self.assertFalse(other.status()['armed'])
|
||||
self.service.path.write_text('{')
|
||||
other = DetectionService(self.camera, self.controller, self.events, self.directory)
|
||||
self.assertFalse(other.get_config()['enabled'])
|
||||
self.assertIsNotNone(other.status()['error'])
|
||||
|
||||
def test_unconfirmed_person_prevents_clear(self):
|
||||
for _ in range(30):
|
||||
self.step([person()])
|
||||
self.assertFalse(self.service.status()['armed'])
|
||||
|
||||
def test_snapshot_matches_number_and_image(self):
|
||||
self.step([person()])
|
||||
snapshot = self.service.snapshot()
|
||||
self.assertEqual(snapshot['frame_number'], self.number)
|
||||
self.assertTrue(snapshot['image_base64'])
|
||||
self.assertEqual(len(snapshot['people']), 1)
|
||||
|
||||
|
||||
class ApiTest(unittest.TestCase):
|
||||
def test_config_validation_persistence_and_missing_model(self):
|
||||
async def scenario():
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
app = create_app(demo=True, state_dir=Path(folder))
|
||||
async with app.router.lifespan_context(app):
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as client:
|
||||
headers = {'X-Halloween-UI': '1'}
|
||||
config = (await client.get('/api/detection/config')).json()
|
||||
self.assertFalse(config['enabled'])
|
||||
self.assertEqual((await client.post('/api/detection/config', json=config)).status_code, 403)
|
||||
bad = {**config, 'region': [0.8,0.2,0.3,0.9]}
|
||||
self.assertEqual((await client.post('/api/detection/config', json=bad, headers=headers)).status_code, 422)
|
||||
config['enabled'] = True
|
||||
self.assertEqual((await client.post('/api/detection/config', json=config, headers=headers)).status_code, 200)
|
||||
for _ in range(30):
|
||||
await asyncio.sleep(0.05)
|
||||
status = (await client.get('/api/status')).json()
|
||||
if status['detection']['reset_required']:
|
||||
break
|
||||
self.assertTrue(status['detection']['reset_required'])
|
||||
self.assertTrue(status['controller']['stopped'])
|
||||
self.assertIn('NanoDet fehlt', status['detection']['error'])
|
||||
result = (await client.get('/api/detection')).json()
|
||||
self.assertIsNone(result['image_base64'])
|
||||
self.assertTrue(create_app(demo=True, state_dir=Path(folder)).state.detection.get_config()['enabled'])
|
||||
asyncio.run(scenario())
|
||||
|
||||
def test_invalid_values(self):
|
||||
for config in [ {**DEFAULT_CONFIG, 'direction': True},
|
||||
{**DEFAULT_CONFIG, 'line': [[0.5,0.5],[0.5,0.5]]},
|
||||
{**DEFAULT_CONFIG, 'region': [0,0,float('nan'),1]},
|
||||
{**DEFAULT_CONFIG, 'enabled': 'true'}]:
|
||||
with self.assertRaises(ValueError):
|
||||
validate_config(config)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Installer ohne Netzwerk; optionaler Smoke-Test mit echten Modellgewichten."""
|
||||
from contextlib import redirect_stdout
|
||||
import hashlib
|
||||
import importlib.util
|
||||
from io import BytesIO, StringIO
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from halloween.detection import NanoDet
|
||||
|
||||
|
||||
class InstallerTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
spec = importlib.util.spec_from_file_location('model_installer', Path(__file__).parents[1] / 'tools/install_detection_model.py')
|
||||
self.installer = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(self.installer)
|
||||
self.folder = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.folder.cleanup)
|
||||
self.data = b'test-model'
|
||||
self.manifest = {'file': 'model.onnx', 'sha256': hashlib.sha256(self.data).hexdigest(),
|
||||
'size': len(self.data), 'url': 'https://example.invalid/model'}
|
||||
self.target = Path(self.folder.name) / 'models/model.onnx'
|
||||
|
||||
def install(self, data):
|
||||
with patch.object(self.installer.json, 'loads', return_value=self.manifest), \
|
||||
patch.object(self.installer.urllib.request, 'urlopen', return_value=BytesIO(data)) as download, \
|
||||
patch('sys.argv', ['installer', '--state-dir', self.folder.name]), redirect_stdout(StringIO()):
|
||||
self.installer.main()
|
||||
return download
|
||||
|
||||
def test_verified_download_is_atomic_and_idempotent(self):
|
||||
self.assertEqual(self.install(self.data).call_count, 1)
|
||||
self.assertEqual(self.target.read_bytes(), self.data)
|
||||
self.assertEqual(self.install(self.data).call_count, 0)
|
||||
self.assertEqual(list(self.target.parent.iterdir()), [self.target])
|
||||
|
||||
def test_bad_download_keeps_existing_file(self):
|
||||
self.target.parent.mkdir()
|
||||
self.target.write_bytes(b'old-corrupt-model')
|
||||
with self.assertRaises(ValueError):
|
||||
self.install(b'bad-model!')
|
||||
self.assertEqual(self.target.read_bytes(), b'old-corrupt-model')
|
||||
self.assertEqual(list(self.target.parent.iterdir()), [self.target])
|
||||
|
||||
def test_oversized_download_is_rejected_and_cleaned(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self.install(self.data+b'extra')
|
||||
self.assertEqual(list(self.target.parent.iterdir()), [])
|
||||
|
||||
|
||||
@unittest.skipUnless(os.environ.get('HALLOWEEN_TEST_MODEL'), 'Optionales Modell nicht angegeben')
|
||||
class ModelSmokeTest(unittest.TestCase):
|
||||
def test_real_model_outputs_and_empty_frame(self):
|
||||
model = NanoDet(Path(os.environ['HALLOWEEN_TEST_MODEL']))
|
||||
frame = BytesIO()
|
||||
Image.new('RGB', (640,480)).save(frame, format='JPEG')
|
||||
self.assertEqual(model.detect(frame.getvalue()), [])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user