kamera geht

This commit is contained in:
marcinator_2 committed 2026-10-14 05:07:07 +02:00
1 parent bfecfd0640
commit d370f992ce
19 files changed
+1365 -1128

No files matched your search

+4
View File
@@ -99,6 +99,10 @@ class CameraStream:
with self._condition:
return self._number, self._frame
def latest_timed(self):
with self._condition:
return self._number, self._frame, self._time
def wait_next(self, number: int, timeout: float = 10):
with self._condition:
self._condition.wait_for(
+4
View File
@@ -25,6 +25,7 @@ class Controller:
self.started_at = None
self.cooldown_until = 0.0
self.last_event = None
self.generation = 0
def _load_mode(self):
if not self._path.exists():
@@ -59,6 +60,7 @@ class Controller:
with self._lock:
self._save_mode(mode)
if mode != self.mode:
self.generation += 1
self.started_at = None
self.zone_clear_seen = False
self.mode = mode
@@ -94,12 +96,14 @@ class Controller:
def stop(self):
with self._lock:
self.stopped = True
self.generation += 1
self.started_at = None
self.zone_clear_seen = False
def reset(self):
with self._lock:
self.stopped = False
self.generation += 1
self.started_at = None
self.zone_clear_seen = False
+351
View File
@@ -0,0 +1,351 @@
"""Lokale Personendetektion und zeitlich begrenzte Linienverfolgung."""
import base64
import hashlib
import json
import math
import os
from pathlib import Path
import tempfile
import threading
import time
from io import BytesIO
from PIL import Image
DEFAULT_CONFIG = {"enabled": False, "line": [[0.2, 0.55], [0.8, 0.55]],
"direction": 1, "region": [0.1, 0.15, 0.9, 0.95]}
def validate_config(value):
if set(value) != set(DEFAULT_CONFIG) or type(value["enabled"]) is not bool:
raise ValueError("Ungültige Erkennungseinstellungen")
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"]
if len(line) != 2 or any(len(p) != 2 for p in line) or len(region) != 4:
raise ValueError("Linie und Bereich benötigen gültige Koordinaten")
coords = [*line[0], *line[1], *region]
if any(type(x) not in (int, float) or not math.isfinite(x) or not 0 <= x <= 1 for x in coords):
raise ValueError("Koordinaten müssen zwischen 0 und 1 liegen")
x1, y1, x2, y2 = region
if x2 - x1 < 0.05 or y2 - y1 < 0.05:
raise ValueError("Auslösebereich ist zu klein")
if math.dist(*line) < 0.05 or any(not (x1 <= x <= x2 and y1 <= y <= y2) for x, y in line):
raise ValueError("Linie muss im Auslösebereich liegen und ausreichend lang sein")
return json.loads(json.dumps(value))
class NanoDet:
"""ONNX-Decoder für OpenCV Zoo NanoDet-m-plus-1.5x_416 (November 2022)."""
def __init__(self, path: Path):
manifest = json.loads((Path(__file__).parent / "nanodet-model.json").read_text())
if not path.is_file():
raise FileNotFoundError("NanoDet fehlt; tools/install_detection_model.py ausführen")
if hashlib.sha256(path.read_bytes()).hexdigest() != manifest["sha256"]:
raise ValueError("NanoDet-Prüfsumme stimmt nicht")
import cv2
import numpy as np
self.cv, self.np = cv2, np
self.net = cv2.dnn.readNetFromONNX(str(path))
self.net.setPreferableBackend(cv2.dnn.DNN_BACKEND_OPENCV)
self.net.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU)
def detect(self, jpeg):
cv, np = self.cv, self.np
rgb = np.asarray(Image.open(BytesIO(jpeg)).convert("RGB"))
h, w = rgb.shape[:2]
scale = min(416 / w, 416 / h)
nw, nh = round(w * scale), round(h * scale)
left, top = (416 - nw) // 2, (416 - nh) // 2
image = np.zeros((416, 416, 3), dtype=np.float32)
image[:] = [103.53, 116.28, 123.675]
image[top:top+nh, left:left+nw] = cv.resize(rgb[:, :, ::-1], (nw, nh))
image = (image - np.array([103.53, 116.28, 123.675], np.float32)) / np.array([57.375, 57.12, 58.395], np.float32)
self.net.setInput(cv.dnn.blobFromImage(image))
outputs = self.net.forward(self.net.getUnconnectedOutLayersNames())
if len(outputs) != 6:
raise ValueError("Unerwartete NanoDet-Ausgänge")
boxes, scores = [], []
for level, stride in enumerate((8, 16, 32)):
classes = outputs[2 * level].reshape(-1, 80)
logits = outputs[2 * level + 1].reshape(-1, 4, 8)
side = 416 // stride
if len(classes) != side * side or len(logits) != len(classes):
raise ValueError("Unerwartete NanoDet-Dimensionen")
candidates = np.flatnonzero((classes[:, 0] >= 0.6) & (classes.argmax(axis=1) == 0))
for index in candidates:
values = logits[index]
weights = np.exp(values - values.max(axis=1, keepdims=True))
distances = (weights / weights.sum(axis=1, keepdims=True)) @ np.arange(8) * stride
cx = (index % side) * stride + (stride - 1) / 2
cy = (index // side) * stride + (stride - 1) / 2
x1 = max(0., min(1., (cx - distances[0] - left) / (scale * w)))
y1 = max(0., min(1., (cy - distances[1] - top) / (scale * h)))
x2 = max(0., min(1., (cx + distances[2] - left) / (scale * w)))
y2 = max(0., min(1., (cy + distances[3] - top) / (scale * h)))
if x2 > x1 and y2 > y1:
boxes.append([float(x1), float(y1), float(x2 - x1), float(y2 - y1)])
scores.append(float(classes[index, 0]))
indices = cv.dnn.NMSBoxes(boxes, scores, 0.6, 0.6)
return [{"box": [boxes[i][0], boxes[i][1], boxes[i][0]+boxes[i][2], boxes[i][1]+boxes[i][3]],
"score": scores[i]} for i in np.asarray(indices).reshape(-1)]
class LineTracker:
def __init__(self):
self.tracks = {}
self.next_id = 1
def update(self, people, config, now, aspect=4/3):
# Fehlende Frames unterbrechen die Bestätigung; kurze Lücken behalten
# lediglich die räumliche Identität, niemals einen alten Linienübergang.
old = {k: v for k, v in self.tracks.items() if now - v["seen"] <= 0.8}
available = set(old)
result, crossed = [], False
a, b = config["line"]
ax, ay, bx, by = a[0]*aspect, a[1], b[0]*aspect, b[1]
dx, dy = bx-ax, by-ay
length = math.hypot(dx, dy)
for person in people:
x1, y1, x2, y2 = person["box"]
point = ((x1+x2)/2, y2)
def distance(key):
p = old[key]["point"]
return math.hypot((point[0]-p[0])*aspect, point[1]-p[1])
key = min(available, key=distance) if available else None
if key is None or distance(key) > 0.25:
key = self.next_id
self.next_id += 1
track = {"count": 0, "side": None, "point": point}
else:
available.remove(key)
track = old[key]
previous = track["point"]
track["count"] += 1
px, py = point[0]*aspect, point[1]
signed = ((dx*(py-ay)-dy*(px-ax))/length) * config["direction"]
side = -1 if signed < -0.03 else (1 if signed > 0.03 else 0)
rx1, ry1, rx2, ry2 = config["region"]
inside = rx1 <= point[0] <= rx2 and ry1 <= point[1] <= ry2
if track["count"] >= 3 and inside:
if side == 1 and track["side"] == -1:
# Schnitt der Bewegung mit dem endlichen Liniensegment.
start = track.get("arrival", previous)
sx, sy = start[0]*aspect, start[1]
vx, vy = px-sx, py-sy
den = vx*dy-vy*dx
if abs(den) > 1e-9:
t = ((ax-sx)*dy-(ay-sy)*dx)/den
u = ((ax-sx)*vy-(ay-sy)*vx)/den
crossed |= 0 <= t <= 1 and 0 <= u <= 1
if side:
track["side"] = side
if side == -1:
track["arrival"] = point
elif not inside:
track["side"] = None
track.update(point=point, seen=now)
result.append({**person, "track_id": key, "confirmed": track["count"] >= 3})
old[key] = track
active = {p["track_id"] for p in result}
for key in set(old)-active:
old[key].update(count=0, side=None)
self.tracks = old
return result, crossed
class DetectionService:
def __init__(self, camera, controller, events, directory, detector_factory=NanoDet, clock=time.monotonic):
self.camera, self.controller, self.events = camera, controller, events
self.clock, self.factory = clock, detector_factory
self.path = directory / "detection.json"
self.model_path = directory / "models" / "object_detection_nanodet_2022nov.onnx"
self.lock = threading.RLock()
self.stop_event = threading.Event()
self.thread = None
self.error = None
self.fault = False
self.config_error = None
self.config = json.loads(json.dumps(DEFAULT_CONFIG))
try:
if self.path.exists():
self.config = validate_config(json.loads(self.path.read_text()))
except (ValueError, TypeError, KeyError, OSError) as exc:
self.config_error = f"Erkennungseinstellungen beschädigt: {exc}"
self.revision = 0
self.reset_state()
self.last_valid = None
self.started = self.clock()
self.fps = 0.
self.result = {"frame_number": 0, "people": [], "image_base64": None}
def reset_state(self):
self.revision += 1
self.tracker = LineTracker()
self.free_since = None
self.armed = False
self.generation = self.controller.generation
def configure(self, value):
config = validate_config(value)
with self.lock:
self.path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(mode="w", dir=self.path.parent, delete=False) as file:
temp = Path(file.name)
try:
json.dump(config, file)
file.flush()
os.fsync(file.fileno())
except Exception:
temp.unlink(missing_ok=True)
raise
try:
os.replace(temp, self.path)
finally:
temp.unlink(missing_ok=True)
self.config = config
self.config_error = None
self.reset_state()
self.started = self.clock()
self.last_valid = None
self.result = {"frame_number": 0, "people": [], "image_base64": None}
return self.get_config()
def get_config(self):
with self.lock:
return json.loads(json.dumps(self.config))
def reset(self):
with self.lock:
self.fault = False
self.error = None
self.reset_state()
self.started = self.clock()
self.last_valid = None
def fail(self, message):
with self.lock:
self.error = message
self.armed = False
self.free_since = None
if self.config["enabled"]:
self.fault = True
self.controller.stop()
def watchdog(self):
with self.lock:
if self.config["enabled"] and not self.fault:
if self.camera.error:
self.fail(self.camera.error)
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):
event_mode = None
with self.lock:
now = self.clock()
if self.stop_event.is_set():
return
if self.controller.generation != self.generation:
self.reset_state()
return
if version != self.revision:
return
if now - captured_at > 2:
self.fail("Erkennungsbild ist veraltet; zurücksetzen")
return
self.last_valid = now
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,
"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)
if occupied:
self.free_since = None
else:
if self.free_since is None:
self.free_since = now
if now-self.free_since >= 2 and self.controller.status()["cooldown_seconds"] == 0:
self.armed = True
if crossing and self.armed:
# Controller bleibt die gemeinsame letzte Schranke, unabhängig
# von manueller Bereichsfreigabe oder parallelem Browser-Stopp.
self.controller.zone_clear()
accepted, _ = self.controller.trigger()
self.armed = False
self.free_since = None
if accepted:
event_mode = self.controller.last_event["mode"]
# SQLite darf UI und unabhängigen Watchdog nicht blockieren.
if event_mode is not None:
try:
self.events.record_detection_trigger(event_mode)
except Exception as exc:
self.fail(f"Ereignis 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,
"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
and self.controller.status()["cooldown_seconds"] == 0,
"fps": round(self.fps, 2), "result_age_seconds": age}
def snapshot(self):
with self.lock:
status = self.status()
fresh = status["ready"]
return {**self.result, "people": self.result["people"] if fresh else [],
"image_base64": self.result["image_base64"] if fresh else None,
"status": status}
def start(self):
self.started = self.clock()
self.thread = threading.Thread(target=self._run, name="person-detection", daemon=True)
self.thread.start()
def stop(self):
self.stop_event.set()
self.controller.stop()
if self.thread:
self.thread.join(timeout=3)
def _run(self):
detector, last_number, last_completed = None, 0, None
while not self.stop_event.is_set():
began = self.clock()
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
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)
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}")
detector = None
self.stop_event.wait(1)
self.stop_event.wait(max(0, 0.2-(self.clock()-began)))
+7 -1
View File
@@ -96,12 +96,18 @@ class EventStore:
return connection
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_trigger(self, mode: str, kind: str) -> int:
occurred_at = _timestamp(_utc_now())
with closing(self._connect()) as connection:
with connection:
cursor = connection.execute(
"INSERT INTO events (occurred_at, kind, mode) VALUES (?, ?, ?)",
(occurred_at, "test_trigger", mode),
(occurred_at, kind, mode),
)
return cursor.lastrowid
+123 -8
View File
@@ -15,6 +15,9 @@
section { background: #1a2424; border: 1px solid #394846; border-radius: 12px; padding: 1rem; }
h2 { margin: 0 0 .7rem; font-size: 1.1rem; }
.camera { width: 100%; aspect-ratio: 4/3; object-fit: contain; background: #050808; border-radius: 8px; }
.camera-wrap { position: relative; aspect-ratio: 4/3; }
.camera-wrap .camera { position: absolute; inset: 0; height: 100%; }
#detection-overlay { position: absolute; inset: 0; width: 100%; height: 100%; touch-action: none; cursor: crosshair; }
.row { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; }
button, select, input, a.download { border: 1px solid #5c6c67; border-radius: 7px; background: #293938; color: #f4f5ed; font: inherit; padding: .65rem .8rem; }
button, select, a.download { cursor: pointer; }
@@ -46,10 +49,21 @@
<main>
<section>
<h2>Livebild</h2>
<img class="camera" src="/stream.mjpg" alt="Livebild der Kamera">
<div class="camera-wrap">
<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>
<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>
<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 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 und Personenerkennung sind noch nicht aktiviert. Der Aufnahmebereich muss vor dem Außeneinsatz festgelegt werden.</p>
<p class="notice">Aufnahmen bleiben deaktiviert. Den Auslösebereich vor dem Test auf den privaten Bereich begrenzen. Personenmarkierungen sind keine Besucherprofile.</p>
</section>
<section>
<h2>Betrieb</h2>
@@ -57,7 +71,7 @@
<dt>Modus</dt><dd class="value" id="mode-label">Lädt …</dd>
<dt>Zustand</dt><dd id="phase">Lädt …</dd>
<dt>Sperrzeit</dt><dd id="cooldown">–</dd>
<dt>Letzter Test</dt><dd id="last-event">–</dd>
<dt>Letzte Auslösung</dt><dd id="last-event">–</dd>
</dl>
<label for="mode">Modus wählen</label>
<div class="row" style="margin: .5rem 0 1rem">
@@ -69,7 +83,7 @@
</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. Es gibt noch keine automatische Personenauslösung.</p>
<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>
<div class="row">
<button id="zone-clear">Bereich frei (Test)</button>
<button id="test-trigger">Testauslösung</button>
@@ -81,8 +95,8 @@
<p id="message" role="status" aria-live="polite"></p>
</section>
<section class="events">
<h2>Gespeicherte Testereignisse</h2>
<p class="muted">Erfolgreiche Testauslösungen bleiben nach einem Neustart erhalten. Eine Profilzuordnung kann hier zum Testen oder Korrigieren gesetzt werden.</p>
<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>
<ul id="events-list"><li>Lädt …</li></ul>
</section>
<section class="events">
@@ -171,13 +185,13 @@
list.replaceChildren();
if (!data.events.length) {
const item = document.createElement('li');
item.textContent = 'Noch keine Testauslösung gespeichert';
item.textContent = 'Noch keine Auslösung gespeichert';
list.append(item);
}
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') + ' · ' + (names[event.mode] || event.mode) + (event.profile_label ? ' · ' + event.profile_label : '');
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 : '');
const tools = document.createElement('div');
tools.className = 'row event-tools';
const select = document.createElement('select');
@@ -220,6 +234,107 @@
await refreshDb();
} catch (error) { $('message').textContent = error.message; }
};
let detectionConfig = null, people = [], drag = null, processed = false, lastImageAt = 0;
let detectionEpoch = 0;
const overlay = $('detection-overlay'), context = overlay.getContext('2d');
function drawDetection() {
context.clearRect(0, 0, 640, 480);
if (!detectionConfig) return;
const c = detectionConfig, [x1,y1,x2,y2] = c.region;
context.lineWidth = 3; context.strokeStyle = '#78d9a2';
context.strokeRect(x1*640,y1*480,(x2-x1)*640,(y2-y1)*480);
const [a,b] = c.line, dx = (b[0]-a[0])*640, dy = (b[1]-a[1])*480;
context.strokeStyle = '#ffb44f'; context.beginPath();
context.moveTo(a[0]*640,a[1]*480); context.lineTo(b[0]*640,b[1]*480); context.stroke();
const length = Math.hypot(dx,dy), mx = (a[0]+b[0])*320, my = (a[1]+b[1])*240;
const nx = -dy/length*c.direction, ny = dx/length*c.direction;
context.beginPath(); context.moveTo(mx,my); context.lineTo(mx+nx*40,my+ny*40);
context.lineTo(mx+nx*27+ny*9,my+ny*27-nx*9); context.moveTo(mx+nx*40,my+ny*40);
context.lineTo(mx+nx*27-ny*9,my+ny*27+nx*9); context.stroke();
for (const [x,y,color] of [[...a,'#ffb44f'],[...b,'#ffb44f'],[x1,y1,'#78d9a2'],[x2,y2,'#78d9a2']]) {
context.fillStyle = color; context.beginPath(); context.arc(x*640,y*480,8,0,Math.PI*2); context.fill();
}
if (performance.now()-lastImageAt > 2000) return;
for (const person of people) {
const [l,t,r,bottom] = person.box;
context.strokeStyle = person.confirmed ? '#6ad8ff' : '#c6c6c6';
context.strokeRect(l*640,t*480,(r-l)*640,(bottom-t)*480);
context.fillStyle = context.strokeStyle; context.font = '16px sans-serif';
context.fillText('Person #'+person.track_id, l*640, Math.max(18,t*480-5));
}
}
function pointer(event) {
const rect = overlay.getBoundingClientRect();
return [Math.max(0,Math.min(1,(event.clientX-rect.left)/rect.width)),
Math.max(0,Math.min(1,(event.clientY-rect.top)/rect.height))];
}
overlay.onpointerdown = event => {
if (!detectionConfig) return;
const p = pointer(event), c = detectionConfig;
const handles = [...c.line, c.region.slice(0,2), c.region.slice(2,4)];
const distances = handles.map(q => Math.hypot((q[0]-p[0])*640,(q[1]-p[1])*480));
const index = distances.indexOf(Math.min(...distances));
if (distances[index] > 30) return;
drag = index; overlay.setPointerCapture(event.pointerId);
};
overlay.onpointermove = event => {
if (drag === null) return;
const p = pointer(event), c = detectionConfig;
if (drag < 2) c.line[drag] = p;
else c.region.splice((drag-2)*2,2,...p);
drawDetection();
};
overlay.onpointerup = overlay.onpointercancel = () => { drag = null; };
$('reverse-direction').onclick = () => {
if (detectionConfig) { detectionConfig.direction *= -1; drawDetection(); }
};
$('save-detection').onclick = async () => {
if (!detectionConfig) return;
try {
detectionConfig.enabled = $('detection-enabled').checked;
detectionConfig = await api('/api/detection/config', detectionConfig);
detectionEpoch++; people = []; drawDetection();
$('message').textContent = 'Gespeichert; freien Auslösebereich abwarten';
} catch (error) { $('message').textContent = error.message; }
};
async function refreshDetection() {
const epoch = detectionEpoch, requestedAt = performance.now();
try {
const response = await fetch('/api/detection', {cache:'no-store'});
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';
if (data.image_base64) {
const image = new Image();
image.src = 'data:image/jpeg;base64,'+data.image_base64;
await image.decode();
if (epoch !== detectionEpoch) { setTimeout(refreshDetection, 200); return; }
$('live-image').src = image.src;
await $('live-image').decode();
if (epoch !== detectionEpoch) { setTimeout(refreshDetection, 200); return; }
processed = true; people = data.people;
// Alter des Ergebnisses berücksichtigen, auch bei wiederholten Antworten.
lastImageAt = requestedAt-(status.result_age_seconds || 0)*1000;
} else {
people = [];
if (processed) { $('live-image').src = '/stream.mjpg'; processed = false; }
}
drawDetection();
} catch (error) {
people = []; drawDetection();
if (processed) { $('live-image').src = '/stream.mjpg'; processed = false; }
$('detection-status').textContent = error.message;
}
setTimeout(refreshDetection, 200);
}
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();
}).catch(error => { $('detection-status').textContent = error.message; });
refreshDetection();
setInterval(drawDetection, 250);
refresh();
refreshDb();
setInterval(refresh, 1000);
+18
View File
@@ -35,3 +35,21 @@ Hier liegt die erste Pi-WebUI. `run.py` im Projektstamm startet sie.
Bei Änderungen an Zustandsübergängen oder Endpunkten die zugehörigen Tests
unter `tests/` und die Bedienhinweise in `SOFTWARE.md` prüfen.
## Personendetektion
- `detection.py` wertet den gemeinsamen Kamerastream mit OpenCV DNN/NanoDet aus.
Optionales Modell und OpenCV fehlen im Demo-Grundsetup bewusst. Kein Download
im Server; geprüfte Modellmetadaten in `nanodet-model.json`.
- Nur neue, zeitlich gültige Frames auswerten. Hintergrundinferenz hält keine
UI-/Zustandssperre; separater Watchdog sperrt aktivierte Automatik nach zwei
Sekunden ohne gültiges Ergebnis. Fehler verlangen bewusstes Rücksetzen.
- Linienüberquerung nutzt bestätigte flüchtige Tracks und Fußpunkte, Richtung,
endliches Segment und 3-%-Hysterese. Gruppen verlangen freien Bereich und
Sperrzeit; Konfigurations-/Controllerwechsel verwerfen alte Beobachtungen.
- `/api/detection` liefert synchrones Bild und Rahmen; `/api/detection/config`
speichert normierte Geometrie und Aktivierung unter `data/`. Erste Aktivierung
ist bewusst, nach Neustart immer neue Bereichsfreigabe. UI zeichnet/editiert
mit Canvas; keine Tracking-ID als Besucherprofil verwenden.
- Automatische Ereignisse tragen `person_line_crossing`, ohne Profilzuordnung.
Die vorhandene Steuerung bleibt ausschließlich simuliert.
+9
View File
@@ -0,0 +1,9 @@
{
"name": "NanoDet-m-plus-1.5x_416",
"file": "object_detection_nanodet_2022nov.onnx",
"revision": "47534e27c9851bb1128ccc0102f1145e27f23f98",
"sha256": "4b82da9944b88577175ee23a459dce2e26e6e4be573def65b1055dc2d9720186",
"size": 3800954,
"url": "https://media.githubusercontent.com/media/opencv/opencv_zoo/47534e27c9851bb1128ccc0102f1145e27f23f98/models/object_detection_nanodet/object_detection_nanodet_2022nov.onnx",
"source": "https://github.com/opencv/opencv_zoo/tree/47534e27c9851bb1128ccc0102f1145e27f23f98/models/object_detection_nanodet"
}
+35 -1
View File
@@ -14,6 +14,7 @@ from pydantic import BaseModel, Field
from .camera import CameraStream
from .controller import Controller, MODES
from .event_store import EventStore
from .detection import DetectionService
PAGE = (Path(__file__).parent / "index.html").read_text(encoding="utf-8")
@@ -43,6 +44,7 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
camera = CameraStream(demo=demo)
controller = Controller(directory)
events = EventStore(directory)
detection = DetectionService(camera, controller, events, directory)
@asynccontextmanager
async def lifespan(app: FastAPI):
@@ -54,11 +56,23 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
except sqlite3.Error:
LOGGER.exception("Abgelaufene Profile konnten nicht gelöscht werden")
async def detection_watchdog():
while True:
detection.watchdog()
await asyncio.sleep(0.1)
camera.start()
detection.start()
watchdog_task = asyncio.create_task(detection_watchdog())
cleanup_task = asyncio.create_task(periodic_profile_cleanup())
try:
yield
finally:
controller.stop()
watchdog_task.cancel()
with suppress(asyncio.CancelledError):
await watchdog_task
detection.stop()
cleanup_task.cancel()
with suppress(asyncio.CancelledError):
await cleanup_task
@@ -68,6 +82,7 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
app.state.camera = camera
app.state.controller = controller
app.state.events = events
app.state.detection = detection
@app.middleware("http")
async def security_headers(request: Request, call_next):
@@ -84,7 +99,25 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
@app.get("/api/status")
async def status():
return {"controller": controller.status(), "camera": camera.status()}
return {"controller": controller.status(), "camera": camera.status(),
"detection": detection.status()}
@app.get("/api/detection")
async def detection_result():
return detection.snapshot()
@app.get("/api/detection/config")
async def detection_config():
return detection.get_config()
@app.post("/api/detection/config", dependencies=[Depends(require_ui)])
async def configure_detection(payload: dict):
try:
return detection.configure(payload)
except (ValueError, TypeError, KeyError, IndexError) as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except OSError as exc:
raise HTTPException(status_code=500, detail=f"Einstellung nicht gespeichert: {exc}") from exc
@app.get("/api/events")
async def recent_events(limit: int = Query(20, ge=1, le=100)):
@@ -165,6 +198,7 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
@app.post("/api/reset", dependencies=[Depends(require_ui)])
async def reset():
controller.reset()
detection.reset()
return controller.status()
@app.get("/snapshot.jpg")