Files
Halloween/halloween/motion.py
T
2026-10-14 17:40:31 +02:00

79 lines
3.6 KiB
Python

"""Optischer Bewegungs-Test, ohne Personenmodell oder Besucherzuordnung."""
from io import BytesIO
from PIL import Image
class MotionDetector:
"""Bildbewegung im Bereich entlang der sichtbaren Pfeilnormalen prüfen.
Feste, konservative Startwerte für den Nahbereichstest, keine Zusage für
Außenbetrieb. Nur zwei aufeinanderfolgende gerichtete Beobachtungen lösen
aus. Ein ruhiges Bild bedeutet hier keine Personen-/Bereichsfreiheit.
"""
def __init__(self):
import cv2
import numpy as np
self.cv, self.np = cv2, np
self.previous = None
self.previous_at = None
self.directed_count = 0
def detect(self, jpeg, config, captured_at):
cv, np = self.cv, self.np
image = Image.open(BytesIO(jpeg)).convert('L')
scale = min(1., 240 / max(image.size))
size = (max(1, round(image.width * scale)), max(1, round(image.height * scale)))
gray = np.asarray(image.resize(size, Image.Resampling.BILINEAR))
height, width = gray.shape
x1, y1, x2, y2 = config['region']
region = gray[round(y1*height):round(y2*height), round(x1*width):round(x2*width)]
if min(region.shape) < 8:
raise ValueError('Bewegungs-Test: Bereich im Kamerabild zu klein')
previous, previous_at = self.previous, self.previous_at
self.previous = region.copy()
self.previous_at = captured_at
result = {'ready': False, 'moving': False, 'directed': False,
'trigger': False, 'vector': [0., 0.], 'changed_fraction': 0.,
'confirmed_frames': 0, 'note': 'Neue Bildbasis; kurz ruhig halten'}
if (previous is None or previous.shape != region.shape or
captured_at <= previous_at or captured_at - previous_at > 0.8):
self.directed_count = 0
return result
result['ready'] = True
changed = cv.absdiff(previous, region) > 12
changed_fraction = float(changed.mean())
result['changed_fraction'] = round(changed_fraction, 5)
# Small near-camera motions must not be diluted by a large quiet ROI.
result['moving'] = changed_fraction >= 0.001
if not result['moving']:
result['note'] = 'Bild ruhig / Änderung zu klein'
self.directed_count = 0
return result
flow = cv.calcOpticalFlowFarneback(previous, region, None,
0.5, 3, 15, 3, 5, 1.2, 0)
magnitude = np.linalg.norm(flow, axis=2)
# Only changed pixels with meaningful, bounded displacement vote.
mask = changed & (magnitude >= 0.35) & (magnitude <= 30)
vectors = flow[mask]
if len(vectors) < 6:
result['note'] = 'Bildänderung erkannt; Richtung noch nicht messbar'
self.directed_count = 0
return result
a, b = config['line']
dx, dy = (b[0]-a[0])*width, (b[1]-a[1])*height
normal = np.array([-dy, dx], dtype=np.float32)
normal *= config['direction'] / np.linalg.norm(normal)
projection = vectors @ normal
aligned = (projection >= 0.35) & (projection / np.linalg.norm(vectors, axis=1) >= 0.65)
result['vector'] = [round(float(v), 2) for v in np.median(vectors, axis=0)]
result['directed'] = bool(aligned.mean() >= 0.65)
self.directed_count = self.directed_count + 1 if result['directed'] else 0
result['confirmed_frames'] = min(2, self.directed_count)
result['note'] = ('Bewegung in Pfeilrichtung' if result['directed']
else 'Bewegung nicht in Pfeilrichtung')
result['trigger'] = self.directed_count >= 2
return result