Webcam: Zaehler-Box als verschiebbares HTML-Overlay (per Maus)

Statt ins Bild gebrannt ist der Zaehler jetzt ein HTML-Overlay, das frei
mit der Maus positioniert werden kann; Position wird in localStorage
gemerkt. Werte kommen ueber /api/counts (Live-Polling).

- draw_overlay/render_static/process_frame: draw_counter-Flag
  (Webcam laesst Box weg, Video-Upload behaelt sie eingebrannt)
- Grabber-State auf self.state -> /api/counts liest live mit
- Drag-Logik + Counts-Polling im Frontend

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 17:15:54 +02:00
parent 07c0e44cd8
commit 7a522f8927
2 changed files with 110 additions and 15 deletions

48
app.py
View File

@@ -297,8 +297,10 @@ def _recently_counted(recent_counts, cx, cy) -> bool:
return False
def draw_overlay(frame, line_start, line_end, state):
"""Zeichnet Zaehllinie (gestrichelt) + Zaehler-Box. Kein YOLO."""
def draw_overlay(frame, line_start, line_end, state, draw_counter=True):
"""Zeichnet Zaehllinie (gestrichelt) + optional die Zaehler-Box. Kein YOLO.
draw_counter=False -> Box wird im Webcam-Pfad als verschiebbares HTML-
Overlay angezeigt statt ins Bild gezeichnet."""
types = state["types"]
cv2.line(frame, line_start, line_end, (0, 255, 255), 3, cv2.LINE_AA)
@@ -313,23 +315,24 @@ def draw_overlay(frame, line_start, line_end, state):
y2_dash = int(line_start[1] + t2 * (line_end[1] - line_start[1]))
cv2.line(frame, (x1_dash, y1_dash), (x2_dash, y2_dash), (0, 0, 0), 3)
cv2.rectangle(frame, (10, 10), (350, 140), (0, 0, 0), -1)
cv2.putText(frame, f"Gesamt: {state['count']}", (20, 35), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2)
cv2.putText(frame, f"Autos: {types.get('car', 0)}", (20, 65), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (200, 200, 200), 1)
cv2.putText(frame, f"LKW: {types.get('truck', 0)}", (20, 90), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (200, 200, 200), 1)
cv2.putText(frame, f"Busse: {types.get('bus', 0)}", (20, 115), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (200, 200, 200), 1)
cv2.putText(frame, f"Motorraeder: {types.get('motorcycle', 0)}", (20, 135), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (200, 200, 200), 1)
if draw_counter:
cv2.rectangle(frame, (10, 10), (350, 140), (0, 0, 0), -1)
cv2.putText(frame, f"Gesamt: {state['count']}", (20, 35), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2)
cv2.putText(frame, f"Autos: {types.get('car', 0)}", (20, 65), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (200, 200, 200), 1)
cv2.putText(frame, f"LKW: {types.get('truck', 0)}", (20, 90), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (200, 200, 200), 1)
cv2.putText(frame, f"Busse: {types.get('bus', 0)}", (20, 115), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (200, 200, 200), 1)
cv2.putText(frame, f"Motorraeder: {types.get('motorcycle', 0)}", (20, 135), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (200, 200, 200), 1)
return frame
def render_static(img, line_start, line_end, state):
def render_static(img, line_start, line_end, state, draw_counter=True):
"""Nur Overlay auf den Rohframe -> wird genutzt, wenn nichts in Bewegung
ist (Motion-Gate). Laeuft kein YOLO -> GPU bleibt idle."""
frame = cv2.resize(img, FRAME_SIZE)
return draw_overlay(frame, line_start, line_end, state)
return draw_overlay(frame, line_start, line_end, state, draw_counter=draw_counter)
def process_frame(frame, det_model, det_names, line_start, line_end, state, source="video"):
def process_frame(frame, det_model, det_names, line_start, line_end, state, source="video", draw_counter=True):
"""
Skaliert den Frame, fuehrt YOLO-Tracking aus, zaehlt Linienueberquerungen
und zeichnet alle Overlays. Mutiert `state` in-place und gibt den
@@ -436,7 +439,7 @@ def process_frame(frame, det_model, det_names, line_start, line_end, state, sour
flush=True,
)
return draw_overlay(frame, line_start, line_end, state)
return draw_overlay(frame, line_start, line_end, state, draw_counter=draw_counter)
# ---------------------------------------------------------------------------
@@ -470,6 +473,7 @@ class WebcamGrabber:
self.viewers = 0
self._last_active_ts = 0.0 # letzter Zeitpunkt mit Zuschauer (Karenzzeit)
self.paused = False # manuell freigegeben -> ESP32-Slot freigeben
self.state = new_state() # Zaehl-/Tracking-Zustand (auch fuer /api/counts)
self.reset_flag = Event()
self.line = dict(SAVED_LINE)
@@ -589,7 +593,7 @@ class WebcamGrabber:
# -- Hintergrund-Thread (laeuft die ganze Prozess-Lebensdauer) ----------
def _run(self):
state = new_state()
state = self.state # selbe Instanz -> /api/counts liest live mit
while True:
# Ohne Always-on: nur verbinden, wenn jemand zuschaut (mit Karenzzeit).
@@ -647,9 +651,9 @@ class WebcamGrabber:
line_start, line_end = self._current_line()
if moving:
frame = process_frame(img, self.model, self.names, line_start, line_end, state, source="webcam")
frame = process_frame(img, self.model, self.names, line_start, line_end, state, source="webcam", draw_counter=False)
else:
frame = render_static(img, line_start, line_end, state)
frame = render_static(img, line_start, line_end, state, draw_counter=False)
ok, out = cv2.imencode(".jpg", frame)
if ok:
@@ -832,6 +836,20 @@ def webcam_status():
return jsonify({"online": webcam.is_online(), "paused": webcam.paused})
@app.route("/api/counts", methods=["GET"])
def api_counts():
"""Aktuelle Zaehlerstaende des Webcam-Grabbers (fuer das HTML-Overlay)."""
st = webcam.state
types = st["types"]
return jsonify({
"total": st["count"],
"car": types.get("car", 0),
"truck": types.get("truck", 0),
"bus": types.get("bus", 0),
"motorcycle": types.get("motorcycle", 0),
})
@app.route("/api/grabber_toggle", methods=["POST"])
def grabber_toggle():
"""Grabber pausieren/aufnehmen: pausiert gibt den ESP32-Slot frei