From 7a522f8927df0aeba11596072c4b0279afce93d9 Mon Sep 17 00:00:00 2001 From: Joachim Hummel Date: Tue, 2 Jun 2026 17:15:54 +0200 Subject: [PATCH] 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) --- app.py | 48 ++++++++++++++++++--------- templates/webcam.html | 77 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 15 deletions(-) diff --git a/app.py b/app.py index 0180ad2..892ab91 100644 --- a/app.py +++ b/app.py @@ -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 diff --git a/templates/webcam.html b/templates/webcam.html index d999c91..3ef3162 100644 --- a/templates/webcam.html +++ b/templates/webcam.html @@ -67,6 +67,24 @@ .offline-overlay .icon { font-size: 54px; } .offline-overlay .msg { font-size: 22px; font-weight: 600; } .offline-overlay .sub { font-size: 14px; opacity: 0.8; } + .counter-box { + position: absolute; + top: 10px; + left: 10px; + background: rgba(0, 0, 0, 0.78); + color: #fff; + padding: 8px 14px 10px; + border-radius: 6px; + font-family: monospace; + font-size: 14px; + line-height: 1.5; + cursor: move; + user-select: none; + z-index: 4; + min-width: 150px; + } + .counter-box .cb-total { font-size: 18px; font-weight: bold; margin-bottom: 2px; } + .counter-box .cb-grip { opacity: 0.5; font-size: 11px; margin-top: 4px; } .video-container { position: relative; width: 1020px; @@ -143,6 +161,14 @@
+
+
Gesamt: 0
+
Autos: 0
+
LKW: 0
+
Busse: 0
+
Motorräder: 0
+
⠿ ziehen
+
📷
Kamera offline
@@ -369,6 +395,57 @@ checkCamStatus(); setInterval(checkCamStatus, 2000); + + // --- Zaehler-Box: Werte pollen --- + const cEl = { + total: document.getElementById('cTotal'), + car: document.getElementById('cCar'), + truck: document.getElementById('cTruck'), + bus: document.getElementById('cBus'), + moto: document.getElementById('cMoto'), + }; + async function updateCounts() { + try { + const d = await (await fetch('/api/counts', { cache: 'no-store' })).json(); + cEl.total.textContent = d.total; + cEl.car.textContent = d.car; + cEl.truck.textContent = d.truck; + cEl.bus.textContent = d.bus; + cEl.moto.textContent = d.motorcycle; + } catch (e) { /* ignorieren */ } + } + updateCounts(); + setInterval(updateCounts, 1000); + + // --- Zaehler-Box: per Maus verschiebbar (Position gemerkt) --- + const counterBox = document.getElementById('counterBox'); + const videoContainer = document.querySelector('.video-container'); + const savedPos = JSON.parse(localStorage.getItem('counterPos') || 'null'); + if (savedPos) { + counterBox.style.left = savedPos.x + 'px'; + counterBox.style.top = savedPos.y + 'px'; + } + let dragging = false, offX = 0, offY = 0; + counterBox.addEventListener('mousedown', (e) => { + dragging = true; + offX = e.clientX - counterBox.offsetLeft; + offY = e.clientY - counterBox.offsetTop; + e.preventDefault(); + }); + document.addEventListener('mousemove', (e) => { + if (!dragging) return; + const maxX = videoContainer.clientWidth - counterBox.offsetWidth; + const maxY = videoContainer.clientHeight - counterBox.offsetHeight; + const x = Math.max(0, Math.min(e.clientX - offX, maxX)); + const y = Math.max(0, Math.min(e.clientY - offY, maxY)); + counterBox.style.left = x + 'px'; + counterBox.style.top = y + 'px'; + }); + document.addEventListener('mouseup', () => { + if (!dragging) return; + dragging = false; + localStorage.setItem('counterPos', JSON.stringify({ x: counterBox.offsetLeft, y: counterBox.offsetTop })); + });