"""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