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

+58 -19
View File
@@ -13,14 +13,22 @@ from io import BytesIO
from PIL import Image
from .motion import MotionDetector
DEFAULT_CONFIG = {"enabled": False, "line": [[0.2, 0.55], [0.8, 0.55]],
DEFAULT_CONFIG = {"enabled": False, "method": "person_line", "line": [[0.2, 0.55], [0.8, 0.55]],
"direction": 1, "region": [0.1, 0.15, 0.9, 0.95]}
def validate_config(value):
# Migrate previous saved configurations and old clients without changing
# their enabled setting or requiring a new geometry setup.
if isinstance(value, dict) and 'method' not in value:
value = {**value, 'method': 'person_line'}
if set(value) != set(DEFAULT_CONFIG) or type(value["enabled"]) is not bool:
raise ValueError("Ungültige Erkennungseinstellungen")
if value['method'] not in ('person_line', 'motion_test'):
raise ValueError('Unbekannte Erkennungsmethode')
if type(value["direction"]) is not int or value["direction"] not in (-1, 1):
raise ValueError("Richtung muss -1 oder 1 sein")
line, region = value["line"], value["region"]
@@ -180,7 +188,7 @@ class DetectionService:
self.last_valid = None
self.started = self.clock()
self.fps = 0.
self.result = {"frame_number": 0, "people": [], "image_base64": None}
self.result = {"frame_number": 0, "people": [], "image_base64": None, "motion": None}
def reset_state(self):
self.revision += 1
@@ -211,7 +219,7 @@ class DetectionService:
self.reset_state()
self.started = self.clock()
self.last_valid = None
self.result = {"frame_number": 0, "people": [], "image_base64": None}
self.result = {"frame_number": 0, "people": [], "image_base64": None, "motion": None}
return self.get_config()
def get_config(self):
@@ -243,8 +251,10 @@ class DetectionService:
elif self.clock() - (self.last_valid if self.last_valid is not None else self.started) > 2:
self.fail("Keine gültige Erkennung seit mehr als zwei Sekunden; zurücksetzen")
def process(self, people, number, jpeg, captured_at, version):
def process(self, people, number, jpeg, captured_at, version, motion=None):
event_mode = None
event_method = None
event_region = None
with self.lock:
now = self.clock()
if self.stop_event.is_set():
@@ -261,17 +271,26 @@ class DetectionService:
if not self.fault:
self.error = None
image = Image.open(BytesIO(jpeg))
people, crossing = self.tracker.update(people, self.config, now, image.width/image.height)
self.result = {"frame_number": number, "people": people,
if self.config['method'] == 'motion_test':
if motion is None:
self.fail('Bewegungs-Test liefert kein Ergebnis; zurücksetzen')
return
people = []
crossing = motion['trigger']
else:
motion = None
people, crossing = self.tracker.update(people, self.config, now, image.width/image.height)
self.result = {"frame_number": number, "people": people, "motion": motion,
"image_base64": base64.b64encode(jpeg).decode("ascii")}
if not self.config["enabled"] or self.fault or self.controller.stopped:
self.armed = False
self.free_since = None
return
rx1, ry1, rx2, ry2 = self.config["region"]
# Auch unbestätigte Personenrahmen verhindern eine falsche Freigabe.
occupied = any(p["box"][0] <= rx2 and p["box"][2] >= rx1 and
p["box"][1] <= ry2 and p["box"][3] >= ry1 for p in people)
# In motion test mode, a quiet image is NOT proof of no visitors.
occupied = (not motion['ready'] or motion['moving']) if motion is not None else any(
p["box"][0] <= rx2 and p["box"][2] >= rx1 and
p["box"][1] <= ry2 and p["box"][3] >= ry1 for p in people)
if occupied:
self.free_since = None
else:
@@ -288,17 +307,22 @@ class DetectionService:
self.free_since = None
if accepted:
event_mode = self.controller.last_event["mode"]
event_method = self.config['method']
event_region = list(self.config['region'])
# SQLite darf UI und unabhängigen Watchdog nicht blockieren.
if event_mode is not None:
try:
self.events.record_detection_trigger(event_mode)
if event_method == 'motion_test':
self.events.record_motion_trigger(event_mode, jpeg=jpeg, region=event_region)
else:
self.events.record_detection_trigger(event_mode, jpeg=jpeg, region=event_region)
except Exception as exc:
self.fail(f"Ereignis nicht gespeichert: {exc}")
self.fail(f"Ereignis/Bild nicht gespeichert: {exc}")
def status(self):
with self.lock:
age = None if self.last_valid is None else self.clock()-self.last_valid
return {"enabled": self.config["enabled"], "ready": age is not None and age <= 2 and not self.error,
return {"enabled": self.config["enabled"], "method": self.config["method"], "ready": age is not None and age <= 2 and not self.error,
"error": self.config_error or self.error, "reset_required": self.fault,
"armed": self.armed and not self.fault and not self.controller.stopped
and self.controller.generation == self.generation
@@ -309,7 +333,7 @@ class DetectionService:
with self.lock:
status = self.status()
fresh = status["ready"]
return {**self.result, "people": self.result["people"] if fresh else [],
return {**self.result, "motion": self.result["motion"] if fresh else None, "people": self.result["people"] if fresh else [],
"image_base64": self.result["image_base64"] if fresh else None,
"status": status}
@@ -325,27 +349,42 @@ class DetectionService:
self.thread.join(timeout=3)
def _run(self):
detector, last_number, last_completed = None, 0, None
detector, detector_key, last_number, last_completed = None, None, 0, None
while not self.stop_event.is_set():
began = self.clock()
version = None
try:
if detector is None:
detector = self.factory(self.model_path)
with self.lock:
if self.controller.generation != self.generation:
self.reset_state()
version = self.revision
config = self.get_config()
# A fresh motion baseline is required for every reconfiguration,
# controller change and reset. Never reuse pre-change optical flow.
key = (config['method'], version)
if detector is None or detector_key != key:
detector = MotionDetector() if config['method'] == 'motion_test' else self.factory(self.model_path)
detector_key = key
last_completed = None
number, jpeg, captured_at = self.camera.latest_timed()
if jpeg is not None and number != last_number:
last_number = number
people = detector.detect(jpeg)
self.process(people, number, jpeg, captured_at, version)
if config['method'] == 'motion_test':
motion = detector.detect(jpeg, config, captured_at)
self.process([], number, jpeg, captured_at, version, motion=motion)
else:
people = detector.detect(jpeg)
self.process(people, number, jpeg, captured_at, version)
completed = self.clock()
with self.lock:
self.fps = 0. if last_completed is None else 1/max(0.001, completed-last_completed)
last_completed = completed
except Exception as exc:
self.fail(f"Personenerkennung nicht verfügbar: {exc}")
# An old in-flight model failure must not fault a newly selected
# method. A current failure still uses the usual reset latch.
with self.lock:
if version == self.revision and self.controller.generation == self.generation:
self.fail(f"Erkennung nicht verfügbar: {exc}")
detector = None
self.stop_event.wait(1)
self.stop_event.wait(max(0, 0.2-(self.clock()-began)))
+98 -10
View File
@@ -3,6 +3,12 @@
from contextlib import closing
from datetime import datetime, timedelta, timezone
from pathlib import Path
from io import BytesIO
import re
import tempfile
import uuid
from PIL import Image
import math
import os
import sqlite3
@@ -36,6 +42,7 @@ class EventStore:
def __init__(self, state_dir: Path):
state_dir.mkdir(parents=True, exist_ok=True)
self.path = state_dir / "events.sqlite3"
self.image_dir = state_dir / "event-images"
with closing(self._connect()) as connection:
with connection:
connection.execute("""
@@ -75,6 +82,8 @@ class EventStore:
"ALTER TABLE events ADD COLUMN profile_id INTEGER "
"REFERENCES profiles(id) ON DELETE SET NULL"
)
if "image_filename" not in columns:
connection.execute("ALTER TABLE events ADD COLUMN image_filename TEXT")
connection.execute("""
CREATE TABLE IF NOT EXISTS profile_templates (
profile_id INTEGER PRIMARY KEY
@@ -98,18 +107,87 @@ class EventStore:
def record_test_trigger(self, mode: str) -> int:
return self._record_trigger(mode, "test_trigger")
def record_detection_trigger(self, mode: str) -> int:
return self._record_trigger(mode, "person_line_crossing")
def record_detection_trigger(self, mode: str, *, jpeg=None, region=None) -> int:
return self._record_trigger(mode, "person_line_crossing", jpeg, region)
def _record_trigger(self, mode: str, kind: str) -> int:
def record_motion_trigger(self, mode: str, *, jpeg=None, region=None) -> int:
return self._record_trigger(mode, "motion_direction_test", jpeg, region)
def _save_image(self, jpeg: bytes, region) -> str:
if (region is None or len(region) != 4 or
any(not math.isfinite(v) or not 0 <= v <= 1 for v in region) or
region[0] >= region[2] or region[1] >= region[3]):
raise ValueError("Ungültiger Bildbereich")
with Image.open(BytesIO(jpeg)) as image:
width, height = image.size
# Round inward: no pixel outside the chosen private region is saved.
box = (math.ceil(region[0]*width), math.ceil(region[1]*height),
math.floor(region[2]*width), math.floor(region[3]*height))
if box[2] <= box[0] or box[3] <= box[1]:
raise ValueError("Bildbereich ist zu klein")
cropped = image.crop(box).convert('RGB')
self.image_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
os.chmod(self.image_dir, 0o700)
filename = uuid.uuid4().hex + '.jpg'
target = self.image_dir / filename
with tempfile.NamedTemporaryFile(dir=self.image_dir, suffix='.tmp', delete=False) as stream:
temporary = Path(stream.name)
try:
cropped.save(stream, format='JPEG', quality=90)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, target)
finally:
temporary.unlink(missing_ok=True)
return filename
def _record_trigger(self, mode: str, kind: str, jpeg=None, region=None) -> int:
occurred_at = _timestamp(_utc_now())
filename = self._save_image(jpeg, region) if jpeg is not None else None
try:
with closing(self._connect()) as connection:
with connection:
cursor = connection.execute(
"INSERT INTO events (occurred_at, kind, mode, image_filename) VALUES (?, ?, ?, ?)",
(occurred_at, kind, mode, filename),
)
event_id = cursor.lastrowid
return event_id
except Exception:
if filename:
(self.image_dir / filename).unlink(missing_ok=True)
raise
def _image_path(self, filename):
if not isinstance(filename, str) or not re.fullmatch(r'[0-9a-f]{32}\.jpg', filename):
raise KeyError("Kein Bild für dieses Ereignis")
path = self.image_dir / filename
if path.is_symlink():
raise KeyError("Kein Bild für dieses Ereignis")
return path
def image_path(self, event_id: int) -> Path:
with closing(self._connect()) as connection:
row = connection.execute("SELECT image_filename FROM events WHERE id = ?", (event_id,)).fetchone()
if row is None:
raise KeyError("Ereignis nicht gefunden")
path = self._image_path(row['image_filename'])
if not path.is_file():
raise KeyError("Bild nicht vorhanden")
return path
def delete_image(self, event_id: int) -> bool:
with closing(self._connect()) as connection:
with connection:
cursor = connection.execute(
"INSERT INTO events (occurred_at, kind, mode) VALUES (?, ?, ?)",
(occurred_at, kind, mode),
)
return cursor.lastrowid
row = connection.execute("SELECT image_filename FROM events WHERE id = ?", (event_id,)).fetchone()
if row is None:
raise KeyError("Ereignis nicht gefunden")
if row['image_filename'] is None:
return False
path = self._image_path(row['image_filename'])
path.unlink(missing_ok=True)
connection.execute("UPDATE events SET image_filename = NULL WHERE id = ?", (event_id,))
return True
def recent(self, limit: int = 20) -> list[dict]:
if not 1 <= limit <= 100:
@@ -118,11 +196,21 @@ class EventStore:
with closing(self._connect()) as connection:
rows = connection.execute(
"SELECT e.id, e.occurred_at, e.kind, e.mode, "
"e.profile_id, p.label AS profile_label "
"e.profile_id, e.image_filename, p.label AS profile_label "
"FROM events e LEFT JOIN profiles p ON p.id = e.profile_id "
"ORDER BY e.id DESC LIMIT ?", (limit,),
).fetchall()
return [dict(row) for row in rows]
result = []
for row in rows:
event = dict(row)
filename = event.pop('image_filename')
try:
present = self._image_path(filename).is_file()
except KeyError:
present = False
event['image_url'] = f"/api/events/{event['id']}/image" if present else None
result.append(event)
return result
def create_profile(self, label: str) -> dict:
label = label.strip()
+83 -13
View File
@@ -37,6 +37,7 @@
#events-list { margin: .5rem 0 0; padding-left: 1.4rem; }
#events-list li { margin: .35rem 0; }
#profiles-list li { margin: .35rem 0; }
.event-image { display: block; max-width: 200px; max-height: 150px; border-radius: 7px; margin: .5rem 0; }
.event-tools { margin: .3rem 0 .8rem; }
@media (max-width: 760px) { main { grid-template-columns: 1fr; } }
</style>
@@ -53,17 +54,24 @@
<img id="live-image" class="camera" src="/stream.mjpg" alt="Livebild der Kamera">
<canvas id="detection-overlay" width="640" height="480" aria-label="Linie und Auslösebereich einstellen"></canvas>
</div>
<h2 style="margin-top:1rem">Personenerkennung</h2>
<h2 style="margin-top:1rem">Automatische Auslösung</h2>
<p>Endpunkte der orangefarbenen Linie und Ecken des grünen Bereichs ziehen. Der Pfeil zeigt zum Grab. Änderungen werden erst nach dem Speichern aktiv.</p>
<label for="detection-method">Erkennung wählen</label>
<select id="detection-method">
<option value="person_line">Person überquert Linie</option>
<option value="motion_test">Test: Bewegung in Pfeilrichtung</option>
</select>
<p id="detection-help" class="muted"></p>
<div class="row">
<label><input type="checkbox" id="detection-enabled"> Automatisch auslösen</label>
<button id="reverse-direction">Richtung umkehren</button>
<button id="save-detection">Linie und Bereich speichern</button>
</div>
<p id="detection-status" class="muted" role="status">Erkennung lädt …</p>
<p id="motion-details" class="muted" role="status" hidden></p>
<p class="muted" id="camera-status">Kamera wird gestartet …</p>
<a class="download" href="/snapshot.jpg" download="halloween-snapshot.jpg">Aktuelles Bild herunterladen</a>
<p class="notice">Aufnahmen bleiben deaktiviert. Den Auslösebereich vor dem Test auf den privaten Bereich begrenzen. Personenmarkierungen sind keine Besucherprofile.</p>
<p class="notice">Bei automatischer Auslösung wird der grüne Bereich als Bild lokal auf dem Pi gespeichert, bis du es löschst. Den Bereich vor dem Test auf den privaten Bereich begrenzen. Personenmarkierungen sind keine Besucherprofile.</p>
</section>
<section>
<h2>Betrieb</h2>
@@ -83,10 +91,11 @@
</select>
<button id="save-mode">Übernehmen</button>
</div>
<p class="notice"><strong>Aktoren nur simuliert.</strong> Kein Motor, Licht oder Ton ist mit dieser Version verbunden. Die Personenerkennung kann ausschließlich diesen simulierten Ablauf auslösen.</p>
<p class="notice"><strong>Aktoren nur simuliert.</strong> Kein Motor, Licht oder Ton ist mit dieser Version verbunden. Die automatische Erkennung kann ausschließlich diesen simulierten Ablauf auslösen.</p>
<p class="muted">Die folgenden Knöpfe sind für manuelle Tests. Die Automatik gibt sich selbst frei.</p>
<div class="row">
<button id="zone-clear">Bereich frei (Test)</button>
<button id="test-trigger">Testauslösung</button>
<button id="zone-clear">Bereich frei (manueller Test)</button>
<button id="test-trigger">Manuelle Testauslösung</button>
</div>
<div class="row" style="margin-top: 1rem">
<button class="danger" id="stop">Stopp</button>
@@ -97,6 +106,7 @@
<section class="events">
<h2>Gespeicherte Ereignisse</h2>
<p class="muted">Erfolgreiche manuelle und automatische Auslösungen bleiben nach einem Neustart erhalten. Eine Profilzuordnung kann hier zum Testen oder Korrigieren gesetzt werden.</p>
<button id="refresh-events">Ereignisse aktualisieren</button>
<ul id="events-list"><li>Lädt …</li></ul>
</section>
<section class="events">
@@ -129,7 +139,20 @@
const c = data.controller, cam = data.camera;
$('mode-label').textContent = names[c.mode] || c.mode;
if (document.activeElement !== $('mode')) $('mode').value = c.mode;
$('phase').textContent = c.phase;
const detection = data.detection;
let phase = c.phase;
// The manual zone-clear flag is not the automatic detection gate.
// Preserve running sequence, stop and cooldown labels from the controller.
if (detection.enabled && ['Bereit', 'Bereich frei abwarten'].includes(phase)) {
phase = !['sanft', 'voll'].includes(c.mode) ? 'Modus ohne Schreckauslösung'
: detection.reset_required ? 'Erkennung gesperrt; zurücksetzen'
: !detection.ready ? 'Auf Erkennung warten'
: detection.armed ? 'Bereit (Automatik)'
: detection.method === 'motion_test' ? 'Ruhiges Bild abwarten (Automatik)'
: 'Freien Bereich abwarten (Automatik)';
}
$('phase').textContent = phase;
$('zone-clear').title = 'Nur für manuelle Tests. Die Automatik prüft die Freigabe selbst.';
$('cooldown').textContent = c.cooldown_seconds ? c.cooldown_seconds + ' s' : '–';
$('last-event').textContent = c.last_event ? c.last_event.time + ' · ' + names[c.last_event.mode] : '–';
$('camera-status').textContent = cam.error || (cam.ready ? cam.source + ' · Bild aktiv' : cam.source + ' · wartet auf Bild');
@@ -191,7 +214,7 @@
for (const event of data.events) {
const item = document.createElement('li');
const summary = document.createElement('span');
summary.textContent = '#' + event.id + ' · ' + new Date(event.occurred_at).toLocaleString('de-DE') + ' · ' + (event.kind === 'person_line_crossing' ? 'Linienüberquerung' : 'Test') + ' · ' + (names[event.mode] || event.mode) + (event.profile_label ? ' · ' + event.profile_label : '');
summary.textContent = '#' + event.id + ' · ' + new Date(event.occurred_at).toLocaleString('de-DE') + ' · ' + ({person_line_crossing: 'Linienüberquerung', motion_direction_test: 'Bewegungs-Test', test_trigger: 'Manueller Test'}[event.kind] || event.kind) + ' · ' + (names[event.mode] || event.mode) + (event.profile_label ? ' · ' + event.profile_label : '');
const tools = document.createElement('div');
tools.className = 'row event-tools';
const select = document.createElement('select');
@@ -208,7 +231,31 @@
} catch (error) { $('message').textContent = error.message; }
};
tools.append(select, assign);
item.append(summary, tools);
item.append(summary);
if (event.image_url) {
const link = document.createElement('a');
link.href = event.image_url; link.target = '_blank'; link.rel = 'noopener';
link.textContent = 'Bild ansehen';
const preview = document.createElement('img');
preview.className = 'event-image'; preview.loading = 'lazy';
preview.src = event.image_url; preview.alt = 'Auslösebild zu Ereignis #' + event.id;
link.prepend(preview);
const removeImage = document.createElement('button');
removeImage.textContent = 'Bild löschen';
removeImage.onclick = async () => {
if (!confirm('Bild unwiderruflich löschen? Der Ereigniseintrag bleibt erhalten.')) return;
try {
await api(event.image_url, {}, 'DELETE');
await refreshEvents();
} catch (error) { $('message').textContent = error.message; }
};
item.append(link, removeImage);
} else {
const missing = document.createElement('span');
missing.className = 'muted'; missing.textContent = ' · Kein gespeichertes Bild';
item.append(missing);
}
item.append(tools);
list.append(item);
}
} catch (error) { $('events-list').textContent = error.message; }
@@ -222,6 +269,7 @@
$('events-list').textContent = error.message;
}
}
$('refresh-events').onclick = refreshEvents;
$('save-mode').onclick = () => action('/api/mode', { mode: $('mode').value });
$('zone-clear').onclick = () => action('/api/zone-clear');
$('test-trigger').onclick = () => action('/api/test-trigger');
@@ -292,9 +340,12 @@
if (!detectionConfig) return;
try {
detectionConfig.enabled = $('detection-enabled').checked;
detectionConfig.method = $('detection-method').value;
detectionConfig = await api('/api/detection/config', detectionConfig);
detectionEpoch++; people = []; drawDetection();
$('message').textContent = 'Gespeichert; freien Auslösebereich abwarten';
$('message').textContent = detectionConfig.method === 'motion_test'
? 'Testmodus gespeichert; Bild zwei Sekunden ruhig halten. Bei Stopp bewusst zurücksetzen.'
: 'Gespeichert; freien Auslösebereich abwarten';
} catch (error) { $('message').textContent = error.message; }
};
async function refreshDetection() {
@@ -304,9 +355,20 @@
if (!response.ok) throw new Error('Erkennung nicht erreichbar');
const data = await response.json(), status = data.status;
if (epoch !== detectionEpoch) { setTimeout(refreshDetection, 200); return; }
$('detection-status').textContent = status.error ||
(!status.enabled ? 'Automatische Auslösung aus' : status.armed ? 'Bereit für Überquerung zum Grab' : 'Freigabe / Sperrzeit abwarten') +
' · '+status.fps+' Auswertungen/s';
const motionTest = status.method === 'motion_test';
$('motion-details').hidden = !motionTest;
const motion = data.motion;
$('motion-details').textContent = motionTest && motion
? 'Bildänderung: '+(motion.changed_fraction*100).toLocaleString('de-DE', {maximumFractionDigits:2})+
' % · '+motion.note+' · Bestätigung '+motion.confirmed_frames+'/2'
: 'Warte auf Bewegungswerte';
const detail = motionTest && data.motion?.moving
? (data.motion.directed ? ' · Bewegung in Pfeilrichtung' : ' · Bewegung erkannt') : '';
$('detection-status').textContent = (motionTest ? 'Bewegungs-Test · ' : '') + (status.error ||
(!status.enabled ? 'Automatische Auslösung aus' : status.armed
? (motionTest ? 'Bereit für Bewegung in Pfeilrichtung' : 'Bereit für Überquerung zum Grab')
: (motionTest ? 'Ruhiges Bild / Sperrzeit abwarten' : 'Freigabe / Sperrzeit abwarten'))) +
detail+' · '+status.fps+' Auswertungen/s';
if (data.image_base64) {
const image = new Image();
image.src = 'data:image/jpeg;base64,'+data.image_base64;
@@ -330,8 +392,16 @@
}
setTimeout(refreshDetection, 200);
}
function updateDetectionHelp() {
$('detection-help').textContent = $('detection-method').value === 'motion_test'
? 'Nur Test: Hand, Kopf oder Gegenstand im grünen Bereich in Pfeilrichtung bewegen. Keine Linienüberquerung nötig. Zuerst zwei Sekunden ruhig halten; danach zehn Sekunden Sperrzeit und erneut Ruhe. Benötigt OpenCV, kein Personenmodell. Auch Kamerabewegung kann auslösen. Ein ruhiges Bild bedeutet nicht, dass der Bereich menschenfrei ist.'
: 'Eine erkannte Person muss die Linie in Pfeilrichtung überqueren. Zuerst den Bereich zwei Sekunden freihalten. Benötigt OpenCV und das installierte Personenmodell.';
}
$('detection-method').onchange = updateDetectionHelp;
updateDetectionHelp();
fetch('/api/detection/config').then(r => {if (!r.ok) throw new Error('Konfiguration nicht verfügbar'); return r.json();}).then(c => {
detectionConfig = c; $('detection-enabled').checked = c.enabled; drawDetection();
detectionConfig = c; $('detection-enabled').checked = c.enabled;
$('detection-method').value = c.method; updateDetectionHelp(); drawDetection();
}).catch(error => { $('detection-status').textContent = error.message; });
refreshDetection();
setInterval(drawDetection, 250);
+41 -1
View File
@@ -13,7 +13,8 @@ Hier liegt die erste Pi-WebUI. `run.py` im Projektstamm startet sie.
bindet an `127.0.0.1`, Zugriff vom Laptop erfolgt per SSH-Tunnel.
Für den vom Nutzer gewünschten direkten Heimnetzzugriff kann Uvicorn
`halloween.web:create_app` als Factory auf `0.0.0.0:8000` starten;
siehe `SOFTWARE.md`. `run.py` und systemd bleiben bei Loopback.
siehe `SOFTWARE.md`. `run.py` bleibt bei Loopback; systemd nutzt die
beim Upload gewählte Bind-Adresse, initial Loopback.
- `event_store.py` verwaltet `data/events.sqlite3`. Erfolgreiche manuelle
Testauslösungen werden als Ereignisse gespeichert; Profile besitzen
Kennungen und können Ereignissen zugeordnet werden. Profil-Löschen entfernt
@@ -53,3 +54,42 @@ unter `tests/` und die Bedienhinweise in `SOFTWARE.md` prüfen.
mit Canvas; keine Tracking-ID als Besucherprofil verwenden.
- Automatische Ereignisse tragen `person_line_crossing`, ohne Profilzuordnung.
Die vorhandene Steuerung bleibt ausschließlich simuliert.
## Bewegungs-Test
- `motion.py` bietet OpenCV-Optical-Flow im gewählten Bereich ohne Personenmodell.
Zwei aufeinanderfolgende gerichtete Bewegungen entlang der Pfeilnormalen
bestätigen einen Trigger; die Linie muss dabei nicht überquert werden.
- `method=motion_test` ausdrücklich als Test kennzeichnen; `person_line` bleibt
der Default und die Migration für alte gespeicherte Konfigurationen/Clients.
Beide Methoden speichern Geometrie/Aktivierung unter data/detection.json.
- Beim Test bedeutet Bereichsfreigabe zwei Sekunden Bildruhe, KEINE bestätigte
Personenfreiheit. Zehn Sekunden Sperrzeit, Stopp, bewusster Reset und Watchdog
beibehalten. Nach Konfigurations-/Controllerwechsel neue Bildbasis verlangen;
veraltete Inferenzfehler und Resultate dürfen den neuen Modus nicht beeinflussen.
- Ereignisse getrennt als motion_direction_test, ohne Profile; mit Auslösebild
gemäß den folgenden Bildregeln.
Aktoren bleiben simuliert; keine Zusage für Außenbetrieb, Licht oder Kostüme.
- Kleine Nahbereichbewegung nicht durch einen prozentual großen Mindestanteil
am gesamten Rechteck verwerfen; Schwellen mit kleinem/langsamem synthetischem
Stimulus regressionsprüfen, Gegenrichtung/Lichtwechsel weiter ablehnen.
- Bewegungswerte enthalten flüchtige Bildänderung, Hinweis und bestätigte
Frames. UI zeigt diese zur Diagnose und verwendet bei aktivierter Automatik
deren Freigabe für die Bereitschaftsanzeige. Manuelles Bereich-frei ersetzt
diese nicht; Sequenz, Stopp und Sperrzeit behalten ihren Vorrang.
## Auslösebilder
- Automatische akzeptierte Trigger speichern den grünen Bereich des auslösenden
Frames als JPEG unter data/event-images/, ohne EXIF/Originalbild außerhalb
des Rechtecks. Konfiguration beim Trigger kopieren; keine neuere Kameraaufnahme
oder später geänderte Geometrie verwenden. Manuelle Tests speichern kein Bild.
- SQLite migriert image_filename; öffentlicher Abruf nur über Ereignis-ID und
geprüfte generierte Dateinamen. Keine statische Freigabe des data/-Ordners.
Ordner 0700, Dateien 0600; Bilder nicht cachen. Bildspeicherung/SQLite außerhalb
des Detection-Locks, Fehler nutzen die bestehende Fehlersperre.
- Nutzerwahl: bis manuelle Löschung behalten. UI bietet Vorschau, Ansehen und
bestätigtes Bildlöschen; Ereignis/Profilzuordnung bleiben bestehen. Löschen
betrifft den aktiven Bestand, keine Upload-Sicherungen. Keine automatische
Alterslöschung von Bildern und keine Änderung der Auto-Profil-Frist.
+78
View File
@@ -0,0 +1,78 @@
"""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
+21
View File
@@ -126,6 +126,27 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
except sqlite3.Error as exc:
raise HTTPException(status_code=503, detail=f"Datenbank nicht verfügbar: {exc}") from exc
@app.get("/api/events/{event_id}/image")
async def event_image(event_id: int):
try:
content = events.image_path(event_id).read_bytes()
except (KeyError, FileNotFoundError) as exc:
raise HTTPException(status_code=404, detail="Bild nicht vorhanden") from exc
except (OSError, sqlite3.Error) as exc:
raise HTTPException(status_code=503, detail="Bild nicht verfügbar") from exc
return Response(content, media_type="image/jpeg", headers={
"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff",
})
@app.delete("/api/events/{event_id}/image", dependencies=[Depends(require_ui)])
async def delete_event_image(event_id: int):
try:
return {"deleted": events.delete_image(event_id)}
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc.args[0])) from exc
except (OSError, sqlite3.Error) as exc:
raise HTTPException(status_code=503, detail="Bild konnte nicht gelöscht werden") from exc
@app.get("/api/profiles")
async def list_profiles():
try: