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
+58
-19
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user