Vehicle-Counter: MQTT-Crossing-Events, Motion-Gating & ESP32-Cam

- Linienueberquerungen werden als MQTT-Event publiziert (fuer n8n -> NocoDB)
- Motion-Gate: YOLO laeuft nur bei Bewegung -> weniger CPU-Last
- Always-on-Grabber (24/7-Zaehlung unabhaengig von Zuschauern)
- Konfigurierbare Inferenz per Env (Modell, imgsz, FP16, Kamera-URL)
- .gitignore ergaenzt (.env, uploads/, Modelle, __pycache__)
- Aufraeumen: alte app2/app3/app4/sudo-Dateien entfernt

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 14:09:04 +02:00
parent fd1de272a6
commit c09fd8948b
7 changed files with 556 additions and 2045 deletions

14
.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
# Lokale Konfiguration / Secrets (Kamera-URL, MQTT-Zugangsdaten)
.env
# Hochgeladene Videos
uploads/
# Python
__pycache__/
*.py[cod]
venv/
.venv/
# YOLO-Modelle werden nicht versioniert (per Download/Setup bereitgestellt)
yolo11n.pt

734
app.py
View File

@@ -1,8 +1,16 @@
import os
import json
import uuid
import time
import threading
from datetime import datetime
from zoneinfo import ZoneInfo
from threading import Event, Lock, Condition
import cv2
import numpy as np
from threading import Event, Lock
import requests
import paho.mqtt.client as mqtt
from flask import (
Flask,
render_template,
@@ -18,26 +26,100 @@ from flask import (
from werkzeug.utils import secure_filename
from ultralytics import YOLO
app = Flask(__name__)
app.secret_key = os.environ.get('SECRET_KEY', 'vehicle_dev_secret')
app.config['MAX_CONTENT_LENGTH'] = 200 * 1024 * 1024 # 200MB upload cap
# ---------------------------------------------------------------------------
# Konfiguration
# ---------------------------------------------------------------------------
# Kamera-URL ueber Env ueberschreibbar (ESP32-CAM Default: Port 81, /stream).
CAMERA_URL = os.environ.get("CAMERA_URL", "http://CAMERA-IP:81/stream")
# Load the YOLOv8 model
# --- Inferenz-Tuning (alles per Env ueberschreibbar) -----------------------
# Webcam nutzt das leichte Nano-Modell; Video-Upload bleibt auf yolo11s.
WEBCAM_MODEL = os.environ.get("WEBCAM_MODEL", "yolo11n.pt")
# Kleinere imgsz = weniger Rechenarbeit pro Frame (Default YOLO waere 640).
YOLO_IMGSZ = int(os.environ.get("YOLO_IMGSZ", "480"))
# Motion-Gate: ab so vielen veraenderten Pixeln (320x180-Graubild) gilt
# die Szene als "in Bewegung" und YOLO laeuft. Sonst kein Inferenz-Call.
MOTION_PIXELS = int(os.environ.get("MOTION_PIXELS", "500"))
# Always-on: Grabber laeuft unabhaengig von Zuschauern durch (24/7-Counter).
# 0 = nur zaehlen/streamen, wenn ein Browser zuschaut (gibt ESP32-Slot frei).
# 1 = dauerhaft verbinden + zaehlen, egal ob jemand zuschaut.
GRABBER_ALWAYS_ON = os.environ.get("GRABBER_ALWAYS_ON", "0") == "1"
# FP16 nur auf CUDA sinnvoll -> automatisch erkennen, per Env erzwingbar.
try:
import torch
_CUDA = torch.cuda.is_available()
except Exception:
_CUDA = False
YOLO_HALF = os.environ.get("YOLO_HALF", "1" if _CUDA else "0") == "1"
# COCO-Klassen: 2=car, 3=motorcycle, 5=bus, 7=truck. Filtert Person/Couch etc.
VEHICLE_CLASS_IDS = [2, 3, 5, 7]
# --- MQTT: ein Event pro Linienueberquerung (fuer n8n -> NocoDB) -----------
MQTT_HOST = os.environ.get("MQTT_HOST", "127.0.0.1")
MQTT_PORT = int(os.environ.get("MQTT_PORT", "1883"))
MQTT_USER = os.environ.get("MQTT_USER")
MQTT_PASS = os.environ.get("MQTT_PASS")
MQTT_TOPIC = os.environ.get("MQTT_TOPIC", "vehiclecounter/cam1")
CAMERA_ID = os.environ.get("CAMERA_ID", "cam1")
# Zeitzone fuer den Zeitstempel (DST-aware). Standard Europe/Berlin.
LOCAL_TZ = ZoneInfo(os.environ.get("TZ_NAME", "Europe/Berlin"))
# paho-mqtt 2.x verlangt die CallbackAPIVersion, 1.x kennt sie nicht.
try:
_mqtt = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
except AttributeError:
_mqtt = mqtt.Client()
if MQTT_USER:
_mqtt.username_pw_set(MQTT_USER, MQTT_PASS)
try:
# async + loop_start -> blockiert den App-Start nicht, wenn der Broker weg ist
_mqtt.connect_async(MQTT_HOST, MQTT_PORT, keepalive=60)
_mqtt.loop_start()
except Exception as exc:
print(f"[mqtt] init failed: {exc}", flush=True)
def publish_crossing(vehicle_type, track_id, source):
"""Crossing-Event auf {MQTT_TOPIC}/crossing (QoS 1, nicht retained)."""
payload = {
"event": "crossing",
"camera": CAMERA_ID,
"source": source,
"type": vehicle_type,
"track_id": int(track_id),
"ts": datetime.now(LOCAL_TZ).isoformat(),
}
try:
_mqtt.publish(f"{MQTT_TOPIC}/crossing", json.dumps(payload), qos=1, retain=False)
except Exception as exc:
print(f"[mqtt] publish failed: {exc}", flush=True)
app = Flask(__name__)
app.secret_key = os.environ.get("SECRET_KEY", "vehicle_dev_secret")
app.config["MAX_CONTENT_LENGTH"] = 200 * 1024 * 1024 # 200MB Upload-Limit
# Globales Modell fuer den Video-Upload-Pfad (per Request).
model = YOLO("yolo11s.pt")
names = model.model.names
# Vehicle classes to count
VEHICLE_CLASSES = {'car', 'truck', 'bus', 'motorcycle'}
ALLOWED_EXTENSIONS = {'mp4', 'mov', 'avi', 'mkv'}
UPLOAD_DIR = 'uploads'
VEHICLE_CLASSES = {"car", "truck", "bus", "motorcycle"}
ALLOWED_EXTENSIONS = {"mp4", "mov", "avi", "mkv"}
UPLOAD_DIR = "uploads"
# Track reset events per stream (webcam/video per session)
reset_events: dict[str, Event] = {}
reset_lock = Lock()
DEFAULT_LINE = {"x1": 0, "y1": 300, "x2": 1020, "y2": 300}
FRAME_SIZE = (1020, 600)
# ---------------------------------------------------------------------------
# Hilfsfunktionen
# ---------------------------------------------------------------------------
def allowed_file(filename: str) -> bool:
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
def ensure_upload_dir() -> None:
@@ -46,15 +128,362 @@ def ensure_upload_dir() -> None:
def get_line_from_session():
if 'counting_line' not in session:
session['counting_line'] = {'x1': 0, 'y1': 300, 'x2': 1020, 'y2': 300}
return session['counting_line']
if "counting_line" not in session:
session["counting_line"] = dict(DEFAULT_LINE)
return session["counting_line"]
def fresh_vehicle_counts() -> dict[str, int]:
return {vehicle: 0 for vehicle in VEHICLE_CLASSES}
def new_state() -> dict:
"""Frischer Zaehl-/Tracking-Zustand fuer einen Stream."""
return {
"track_positions": {},
"counted_ids": set(),
"count": 0,
"types": fresh_vehicle_counts(),
}
def reset_state(state: dict) -> None:
state["track_positions"].clear()
state["counted_ids"].clear()
state["count"] = 0
state["types"] = fresh_vehicle_counts()
def line_intersect(p1, p2, p3, p4) -> bool:
"""True, wenn sich die Strecken p1-p2 und p3-p4 schneiden."""
x1, y1 = p1
x2, y2 = p2
x3, y3 = p3
x4, y4 = p4
denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4)
if abs(denom) < 1e-10:
return False
t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denom
u = -((x1 - x2) * (y1 - y3) - (y1 - y2) * (x1 - x3)) / denom
return 0 <= t <= 1 and 0 <= u <= 1
def crossed_line(prev_pos, curr_pos, line_start, line_end) -> bool:
return line_intersect(prev_pos, curr_pos, line_start, line_end)
def draw_overlay(frame, line_start, line_end, state):
"""Zeichnet Zaehllinie (gestrichelt) + Zaehler-Box. Kein YOLO."""
types = state["types"]
cv2.line(frame, line_start, line_end, (0, 255, 255), 3, cv2.LINE_AA)
line_length = int(np.sqrt((line_end[0] - line_start[0]) ** 2 + (line_end[1] - line_start[1]) ** 2))
dash_length = 20
for i in range(0, max(line_length, 1), dash_length * 2):
t1 = i / line_length if line_length else 0
t2 = min((i + dash_length) / line_length, 1.0) if line_length else 0
x1_dash = int(line_start[0] + t1 * (line_end[0] - line_start[0]))
y1_dash = int(line_start[1] + t1 * (line_end[1] - line_start[1]))
x2_dash = int(line_start[0] + t2 * (line_end[0] - line_start[0]))
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)
return frame
def render_static(img, line_start, line_end, state):
"""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)
def process_frame(frame, det_model, det_names, line_start, line_end, state, source="video"):
"""
Skaliert den Frame, fuehrt YOLO-Tracking aus, zaehlt Linienueberquerungen
und zeichnet alle Overlays. Mutiert `state` in-place und gibt den
annotierten Frame zurueck. Wird von Webcam-Grabber UND Video-Pfad genutzt.
"""
frame = cv2.resize(frame, FRAME_SIZE)
results = det_model.track(
frame,
persist=True,
imgsz=YOLO_IMGSZ,
half=YOLO_HALF,
classes=VEHICLE_CLASS_IDS,
verbose=False,
)
track_positions = state["track_positions"]
counted_ids = state["counted_ids"]
types = state["types"]
if results and results[0].boxes is not None and results[0].boxes.id is not None:
boxes = results[0].boxes.xyxy.int().cpu().tolist()
class_ids = results[0].boxes.cls.int().cpu().tolist()
track_ids = results[0].boxes.id.int().cpu().tolist()
for box, class_id, track_id in zip(boxes, class_ids, track_ids):
label_name = det_names[class_id]
x1, y1, x2, y2 = box
center_x = (x1 + x2) // 2
center_y = (y1 + y2) // 2
if label_name in VEHICLE_CLASSES:
if track_id in track_positions and track_id not in counted_ids:
prev_x, prev_y = track_positions[track_id]
cv2.line(frame, (prev_x, prev_y), (center_x, center_y), (255, 100, 0), 2)
if crossed_line((prev_x, prev_y), (center_x, center_y), line_start, line_end):
counted_ids.add(track_id)
state["count"] += 1
types[label_name] += 1
publish_crossing(label_name, track_id, source)
cv2.circle(frame, (center_x, center_y), 25, (0, 255, 0), 5)
track_positions[track_id] = (center_x, center_y)
box_color = (0, 255, 0) if label_name in VEHICLE_CLASSES else (255, 0, 0)
cv2.rectangle(frame, (x1, y1), (x2, y2), box_color, 2)
label = f"{track_id} - {label_name}"
if track_id in counted_ids:
label += " \u2713"
cv2.putText(frame, label, (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 0, 255), 1)
cv2.circle(frame, (center_x, center_y), 3, (0, 255, 255), -1)
return draw_overlay(frame, line_start, line_end, state)
# ---------------------------------------------------------------------------
# Webcam-Grabber: EINE Verbindung zur (ESP32-)Cam, Fan-out an viele Viewer
# ---------------------------------------------------------------------------
class WebcamGrabber:
"""
Haelt genau eine Verbindung zur Netzwerk-Kamera. Ein Hintergrund-Thread
liest Frames, laeuft YOLO drauf und legt das jeweils neueste annotierte
JPEG in einen gemeinsamen Slot. Jeder /webcam_feed-Request konsumiert nur
diesen Slot -> beliebig viele Zuschauer, aber nur EINE Cam-Verbindung.
Liest den MJPEG-Stream MANUELL ueber requests (SOI/EOI-Parsing) statt ueber
cv2.VideoCapture -> umgeht den FFmpeg-Demuxer, der bei ESP32-CAM scheitert.
Motion-Gate: YOLO laeuft nur, wenn sich im Bild genug aendert. Steht die
Szene still (z.B. nur parkende Autos), wird kein Inferenz-Call gemacht und
die GPU bleibt idle.
"""
MAX_BUFFER = 4 * 1024 * 1024 # Schutz gegen unbegrenztes Puffer-Wachstum
def __init__(self, url: str):
self.url = url
self.lock = Lock()
self.frame_cond = Condition()
self.latest_jpeg: bytes | None = None
self.frame_seq = 0
self.viewers = 0
self.reset_flag = Event()
self.line = dict(DEFAULT_LINE)
# Eigenes Modell -> isolierter Tracker, getrennt vom Video-Pfad.
# Lazy: wird erst beim ersten aktiven Stream geladen.
self.model = None
self.names = None
# Motion-Gate-Referenzbild (Graustufen, herunterskaliert).
self._prev_gray = None
self.thread = None
# -- Steuerung -----------------------------------------------------------
def start(self):
if self.thread is None:
self.thread = threading.Thread(target=self._run, daemon=True)
self.thread.start()
def set_line(self, line: dict):
with self.lock:
self.line = dict(line)
def reset(self):
self.reset_flag.set()
def add_viewer(self):
with self.lock:
self.viewers += 1
def remove_viewer(self):
with self.lock:
self.viewers = max(0, self.viewers - 1)
def _viewer_count(self) -> int:
with self.lock:
return self.viewers
def _current_line(self):
with self.lock:
line = dict(self.line)
return (line["x1"], line["y1"]), (line["x2"], line["y2"])
@staticmethod
def _extract_latest(buf: bytes):
"""
Zieht den ZULETZT vollstaendigen JPEG aus dem Puffer und verwirft
aeltere -> haelt die Latenz niedrig (Backlog wird uebersprungen).
Gibt (jpeg_or_None, rest_buffer) zurueck.
"""
latest = None
while True:
start = buf.find(b"\xff\xd8") # SOI
if start == -1:
buf = b""
break
end = buf.find(b"\xff\xd9", start + 2) # EOI
if end == -1:
buf = buf[start:] # unvollstaendig -> Tail behalten
break
latest = buf[start:end + 2]
buf = buf[end + 2:]
return latest, buf
def _ensure_model(self):
if self.model is None:
self.model = YOLO(WEBCAM_MODEL)
self.names = self.model.model.names
def _publish(self, jpeg: bytes):
with self.frame_cond:
self.latest_jpeg = jpeg
self.frame_seq += 1
self.frame_cond.notify_all()
def _clear(self):
with self.frame_cond:
self.latest_jpeg = None
self.frame_seq += 1
self.frame_cond.notify_all()
# -- Hintergrund-Thread (laeuft die ganze Prozess-Lebensdauer) ----------
def _run(self):
state = new_state()
while True:
# Ohne Always-on: nur verbinden, wenn jemand zuschaut (gibt ESP32-Slot frei).
if not GRABBER_ALWAYS_ON and self._viewer_count() == 0:
if self.latest_jpeg is not None:
self._clear()
time.sleep(0.3)
continue
resp = None
try:
self._ensure_model()
resp = requests.get(self.url, stream=True, timeout=(5, 10))
resp.raise_for_status()
# Frische Verbindung -> Track-IDs neu, Gesamtzaehler bleibt.
state["track_positions"].clear()
state["counted_ids"].clear()
self._prev_gray = None
buf = b""
for chunk in resp.iter_content(chunk_size=8192):
if not GRABBER_ALWAYS_ON and self._viewer_count() == 0:
break # letzter Viewer weg -> Verbindung freigeben
if not chunk:
continue
buf += chunk
if len(buf) > self.MAX_BUFFER:
buf = buf[-self.MAX_BUFFER:]
jpeg, buf = self._extract_latest(buf)
if jpeg is None:
continue
img = cv2.imdecode(np.frombuffer(jpeg, np.uint8), cv2.IMREAD_COLOR)
if img is None:
continue
if self.reset_flag.is_set():
reset_state(state)
self.reset_flag.clear()
# --- Motion-Gate: YOLO nur bei Bewegung (spart GPU) ---
gray = cv2.resize(cv2.cvtColor(img, cv2.COLOR_BGR2GRAY), (320, 180))
moving = True
if self._prev_gray is not None:
diff = cv2.absdiff(gray, self._prev_gray)
_, mask = cv2.threshold(diff, 25, 255, cv2.THRESH_BINARY)
moving = cv2.countNonZero(mask) > MOTION_PIXELS
self._prev_gray = gray
line_start, line_end = self._current_line()
if moving:
frame = process_frame(img, self.model, self.names, line_start, line_end, state, source=CAMERA_ID)
else:
frame = render_static(img, line_start, line_end, state)
ok, out = cv2.imencode(".jpg", frame)
if ok:
self._publish(out.tobytes())
except Exception as exc:
# Timeout / Verbindungsabbruch / HTTP-/Modell-Fehler -> sichtbar + Backoff
print(f"[webcam-grabber] {type(exc).__name__}: {exc}", flush=True)
time.sleep(1.0)
finally:
if resp is not None:
resp.close()
# -- Pro-Viewer-Generator ------------------------------------------------
def frames(self):
self.add_viewer()
last_seq = -1
got_any = False
try:
while True:
with self.frame_cond:
ok = self.frame_cond.wait_for(
lambda: self.latest_jpeg is not None and self.frame_seq != last_seq,
timeout=20,
)
jpeg = self.latest_jpeg
seq = self.frame_seq
if not ok:
if got_any:
break # hatten Frames, jetzt 20s nichts -> Cam weg
continue # noch nie ein Frame (Modell laedt / Connect) -> weiter warten
if jpeg is None:
continue
got_any = True
last_seq = seq
yield (
b"--frame\r\n"
b"Content-Type: image/jpeg\r\n\r\n" + jpeg + b"\r\n"
)
finally:
self.remove_viewer()
webcam = WebcamGrabber(CAMERA_URL)
webcam.start()
# ---------------------------------------------------------------------------
# Reset-Events fuer den Video-Pfad (per Stream-ID)
# ---------------------------------------------------------------------------
reset_events: dict[str, Event] = {}
reset_lock = Lock()
def get_reset_event(stream_id: str) -> Event:
with reset_lock:
event = reset_events.get(stream_id)
@@ -70,64 +499,30 @@ def release_reset_event(stream_id: str) -> None:
def get_webcam_stream_id() -> str:
stream_id = session.get('webcam_stream_id')
stream_id = session.get("webcam_stream_id")
if not stream_id:
stream_id = f'webcam-{uuid.uuid4().hex}'
session['webcam_stream_id'] = stream_id
stream_id = f"webcam-{uuid.uuid4().hex}"
session["webcam_stream_id"] = stream_id
return stream_id
def get_video_stream_id(filename: str) -> str:
video_streams = session.get('video_stream_ids', {})
video_streams = session.get("video_stream_ids", {})
stream_id = video_streams.get(filename)
if not stream_id:
stream_id = f'video-{uuid.uuid4().hex}'
stream_id = f"video-{uuid.uuid4().hex}"
video_streams[filename] = stream_id
session['video_stream_ids'] = video_streams
session["video_stream_ids"] = video_streams
return stream_id
# Helper function to check if two line segments intersect
def line_intersect(p1, p2, p3, p4):
"""
Check if line segment p1-p2 intersects with line segment p3-p4
Returns True if they intersect, False otherwise
"""
x1, y1 = p1
x2, y2 = p2
x3, y3 = p3
x4, y4 = p4
denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4)
if abs(denom) < 1e-10:
return False
t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denom
u = -((x1 - x2) * (y1 - y3) - (y1 - y2) * (x1 - x3)) / denom
return 0 <= t <= 1 and 0 <= u <= 1
def crossed_line(prev_pos, curr_pos, line_start, line_end):
"""
Check if movement from prev_pos to curr_pos crossed the line.
Uses orientation check - more robust for frame skipping.
"""
# Check if the two line segments intersect
if line_intersect(prev_pos, curr_pos, line_start, line_end):
return True
return False
# ---------------------------------------------------------------------------
# Video-Pfad (per Request, unveraendert in der Logik)
# ---------------------------------------------------------------------------
def generate_frames(capture, line_data, stream_id: str):
"""
Shared frame generator for webcam and uploaded videos.
Handles detection, drawing overlays, and reset events.
"""
track_positions = {}
counted_ids = set()
vehicle_count = 0
vehicle_type_counts = fresh_vehicle_counts()
line_start = (line_data['x1'], line_data['y1'])
line_end = (line_data['x2'], line_data['y2'])
state = new_state()
line_start = (line_data["x1"], line_data["y1"])
line_end = (line_data["x2"], line_data["y2"])
frame_idx = 0
reset_event = get_reset_event(stream_id)
@@ -142,154 +537,113 @@ def generate_frames(capture, line_data, stream_id: str):
continue
if reset_event.is_set():
track_positions.clear()
counted_ids.clear()
vehicle_count = 0
vehicle_type_counts = fresh_vehicle_counts()
reset_state(state)
reset_event.clear()
frame = cv2.resize(frame, (1020, 600))
results = model.track(frame, persist=True)
frame = process_frame(frame, model, names, line_start, line_end, state, source="video")
if results and results[0].boxes is not None and results[0].boxes.id is not None:
boxes = results[0].boxes.xyxy.int().cpu().tolist()
class_ids = results[0].boxes.cls.int().cpu().tolist()
track_ids = results[0].boxes.id.int().cpu().tolist()
for box, class_id, track_id in zip(boxes, class_ids, track_ids):
label_name = names[class_id]
x1, y1, x2, y2 = box
center_x = (x1 + x2) // 2
center_y = (y1 + y2) // 2
if label_name in VEHICLE_CLASSES:
if track_id in track_positions and track_id not in counted_ids:
prev_x, prev_y = track_positions[track_id]
cv2.line(frame, (prev_x, prev_y), (center_x, center_y), (255, 100, 0), 2)
if crossed_line((prev_x, prev_y), (center_x, center_y), line_start, line_end):
counted_ids.add(track_id)
vehicle_count += 1
vehicle_type_counts[label_name] += 1
cv2.circle(frame, (center_x, center_y), 25, (0, 255, 0), 5)
track_positions[track_id] = (center_x, center_y)
box_color = (0, 255, 0) if label_name in VEHICLE_CLASSES else (255, 0, 0)
cv2.rectangle(frame, (x1, y1), (x2, y2), box_color, 2)
label = f'{track_id} - {label_name}'
if track_id in counted_ids:
label += ''
cv2.putText(frame, label, (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 0, 255), 1)
cv2.circle(frame, (center_x, center_y), 3, (0, 255, 255), -1)
cv2.line(frame, line_start, line_end, (0, 255, 255), 3, cv2.LINE_AA)
line_length = int(np.sqrt((line_end[0] - line_start[0]) ** 2 + (line_end[1] - line_start[1]) ** 2))
dash_length = 20
for i in range(0, max(line_length, 1), dash_length * 2):
t1 = i / line_length if line_length else 0
t2 = min((i + dash_length) / line_length, 1.0) if line_length else 0
x1_dash = int(line_start[0] + t1 * (line_end[0] - line_start[0]))
y1_dash = int(line_start[1] + t1 * (line_end[1] - line_start[1]))
x2_dash = int(line_start[0] + t2 * (line_end[0] - line_start[0]))
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: {vehicle_count}', (20, 35), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2)
cv2.putText(frame, f"Autos: {vehicle_type_counts.get('car', 0)}", (20, 65), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (200, 200, 200), 1)
cv2.putText(frame, f"LKW: {vehicle_type_counts.get('truck', 0)}", (20, 90), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (200, 200, 200), 1)
cv2.putText(frame, f"Busse: {vehicle_type_counts.get('bus', 0)}", (20, 115), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (200, 200, 200), 1)
cv2.putText(frame, f"Motorraeder: {vehicle_type_counts.get('motorcycle', 0)}", (20, 135), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (200, 200, 200), 1)
_, buffer = cv2.imencode('.jpg', frame)
frame_bytes = buffer.tobytes()
yield (b'--frame\r\n'
b'Content-Type: image/jpeg\r\n\r\n' + frame_bytes + b'\r\n')
ok, buffer = cv2.imencode(".jpg", frame)
if not ok:
continue
yield (
b"--frame\r\n"
b"Content-Type: image/jpeg\r\n\r\n" + buffer.tobytes() + b"\r\n"
)
finally:
capture.release()
release_reset_event(stream_id)
@app.route('/')
def index():
return render_template('index.html')
@app.route('/start_webcam')
def detect_objects_from_video(video_path, line_data, stream_id):
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
cap.release()
raise RuntimeError("Video konnte nicht geoeffnet werden")
return generate_frames(cap, line_data, stream_id)
# ---------------------------------------------------------------------------
# Routen
# ---------------------------------------------------------------------------
@app.route("/")
def index():
return render_template("index.html")
@app.route("/start_webcam")
def start_webcam():
get_line_from_session()
stream_id = get_webcam_stream_id()
return render_template('webcam.html', stream_id=stream_id)
return render_template("webcam.html", stream_id=stream_id)
@app.route('/api/set_line', methods=['POST'])
@app.route("/webcam_feed")
def webcam_feed():
# Keine eigene Cam-Verbindung mehr pro Request -> Fan-out vom Grabber.
return Response(
webcam.frames(),
mimetype="multipart/x-mixed-replace; boundary=frame",
)
@app.route("/api/set_line", methods=["POST"])
def set_counting_line():
"""API endpoint to set the counting line coordinates"""
"""Setzt die Zaehllinie (gilt fuer Video-Session UND Webcam-Grabber)."""
data = request.get_json(silent=True) or {}
try:
session['counting_line'] = {
'x1': int(data['x1']),
'y1': int(data['y1']),
'x2': int(data['x2']),
'y2': int(data['y2'])
line = {
"x1": int(data["x1"]),
"y1": int(data["y1"]),
"x2": int(data["x2"]),
"y2": int(data["y2"]),
}
except (KeyError, ValueError, TypeError):
abort(400, description='Ungültige Linienkoordinaten')
return jsonify({'status': 'success', 'line': session['counting_line']})
abort(400, description="Ungueltige Linienkoordinaten")
@app.route('/api/get_line', methods=['GET'])
session["counting_line"] = line
webcam.set_line(line) # Webcam nutzt eine globale Linie (eine Kamera)
return jsonify({"status": "success", "line": line})
@app.route("/api/get_line", methods=["GET"])
def get_counting_line():
"""API endpoint to get the current counting line coordinates"""
return jsonify(get_line_from_session())
@app.route('/api/reset_count', methods=['POST'])
@app.route("/api/reset_count", methods=["POST"])
def reset_count():
"""API endpoint to reset the vehicle count for a stream"""
data = request.get_json(silent=True) or {}
stream_id = data.get('stream_id')
stream_id = data.get("stream_id")
if not stream_id:
abort(400, description='stream_id ist erforderlich')
valid_ids = {session.get('webcam_stream_id')}
valid_ids.update(session.get('video_stream_ids', {}).values())
abort(400, description="stream_id ist erforderlich")
valid_ids = {session.get("webcam_stream_id")}
valid_ids.update(session.get("video_stream_ids", {}).values())
valid_ids.discard(None)
if stream_id not in valid_ids:
abort(403, description='Stream gehört nicht zur aktuellen Sitzung')
event = get_reset_event(stream_id)
event.set()
return jsonify({'status': 'success', 'message': 'Zähler wird zurückgesetzt'})
abort(403, description="Stream gehoert nicht zur aktuellen Sitzung")
def detect_objects_from_webcam(line_data, stream_id):
cap = cv2.VideoCapture(0) # 0 for the default webcam
if not cap.isOpened():
cap.release()
raise RuntimeError('Webcam konnte nicht geöffnet werden')
return generate_frames(cap, line_data, stream_id)
if stream_id == session.get("webcam_stream_id"):
webcam.reset()
else:
get_reset_event(stream_id).set()
return jsonify({"status": "success", "message": "Zaehler wird zurueckgesetzt"})
@app.route('/webcam_feed')
def webcam_feed():
line_data = get_line_from_session()
stream_id = get_webcam_stream_id()
try:
generator = detect_objects_from_webcam(line_data, stream_id)
except RuntimeError as exc:
abort(503, description=str(exc))
return Response(generator,
mimetype='multipart/x-mixed-replace; boundary=frame')
@app.route('/upload', methods=['POST'])
@app.route("/upload", methods=["POST"])
def upload_video():
if 'file' not in request.files:
abort(400, description='Keine Datei erhalten')
if "file" not in request.files:
abort(400, description="Keine Datei erhalten")
file = request.files['file']
if not file or file.filename == '':
abort(400, description='Keine Datei ausgewählt')
file = request.files["file"]
if not file or file.filename == "":
abort(400, description="Keine Datei ausgewaehlt")
filename = secure_filename(file.filename)
if not filename:
abort(400, description='Ungültiger Dateiname')
abort(400, description="Ungueltiger Dateiname")
if not allowed_file(filename):
abort(400, description='Ungültiger Dateityp')
abort(400, description="Ungueltiger Dateityp")
ensure_upload_dir()
name, ext = os.path.splitext(filename)
@@ -297,36 +651,32 @@ def upload_video():
file_path = os.path.join(UPLOAD_DIR, stored_filename)
file.save(file_path)
return redirect(url_for('play_video', filename=stored_filename))
return redirect(url_for("play_video", filename=stored_filename))
@app.route('/uploads/<filename>')
@app.route("/uploads/<filename>")
def play_video(filename):
safe_filename = os.path.basename(filename)
if safe_filename != filename:
abort(400, description='Ungültiger Dateiname')
abort(400, description="Ungueltiger Dateiname")
file_path = os.path.join(UPLOAD_DIR, safe_filename)
if not os.path.isfile(file_path):
abort(404)
get_line_from_session()
stream_id = get_video_stream_id(safe_filename)
return render_template('play_video.html', filename=safe_filename, stream_id=stream_id)
return render_template("play_video.html", filename=safe_filename, stream_id=stream_id)
@app.route('/video/<path:filename>')
@app.route("/video/<path:filename>")
def send_video(filename):
return send_from_directory(UPLOAD_DIR, filename)
def detect_objects_from_video(video_path, line_data, stream_id):
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
cap.release()
raise RuntimeError('Video konnte nicht geöffnet werden')
return generate_frames(cap, line_data, stream_id)
@app.route('/video_feed/<filename>')
@app.route("/video_feed/<filename>")
def video_feed(filename):
safe_filename = os.path.basename(filename)
if safe_filename != filename:
abort(400, description='Ungültiger Dateiname')
abort(400, description="Ungueltiger Dateiname")
video_path = os.path.join(UPLOAD_DIR, safe_filename)
if not os.path.isfile(video_path):
abort(404)
@@ -336,8 +686,8 @@ def video_feed(filename):
generator = detect_objects_from_video(video_path, line_data, stream_id)
except RuntimeError as exc:
abort(503, description=str(exc))
return Response(generator,
mimetype='multipart/x-mixed-replace; boundary=frame')
return Response(generator, mimetype="multipart/x-mixed-replace; boundary=frame")
if __name__ == '__main__':
app.run('0.0.0.0',debug=False, port=8080)
if __name__ == "__main__":
app.run("0.0.0.0", debug=False, port=8080)

343
app2.app
View File

@@ -1,343 +0,0 @@
import os
import uuid
import cv2
import numpy as np
from threading import Event, Lock
from flask import (
Flask,
render_template,
Response,
request,
redirect,
url_for,
send_from_directory,
session,
jsonify,
abort,
)
from werkzeug.utils import secure_filename
from ultralytics import YOLO
app = Flask(__name__)
app.secret_key = os.environ.get('SECRET_KEY', 'vehicle_dev_secret')
app.config['MAX_CONTENT_LENGTH'] = 200 * 1024 * 1024 # 200MB upload cap
# Load the YOLOv8 model
model = YOLO("yolo11s.pt")
names = model.model.names
# Vehicle classes to count
VEHICLE_CLASSES = {'car', 'truck', 'bus', 'motorcycle'}
ALLOWED_EXTENSIONS = {'mp4', 'mov', 'avi', 'mkv'}
UPLOAD_DIR = 'uploads'
# Track reset events per stream (webcam/video per session)
reset_events: dict[str, Event] = {}
reset_lock = Lock()
def allowed_file(filename: str) -> bool:
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
def ensure_upload_dir() -> None:
if not os.path.exists(UPLOAD_DIR):
os.makedirs(UPLOAD_DIR)
def get_line_from_session():
if 'counting_line' not in session:
session['counting_line'] = {'x1': 0, 'y1': 300, 'x2': 1020, 'y2': 300}
return session['counting_line']
def fresh_vehicle_counts() -> dict[str, int]:
return {vehicle: 0 for vehicle in VEHICLE_CLASSES}
def get_reset_event(stream_id: str) -> Event:
with reset_lock:
event = reset_events.get(stream_id)
if event is None:
event = Event()
reset_events[stream_id] = event
return event
def release_reset_event(stream_id: str) -> None:
with reset_lock:
reset_events.pop(stream_id, None)
def get_webcam_stream_id() -> str:
stream_id = session.get('webcam_stream_id')
if not stream_id:
stream_id = f'webcam-{uuid.uuid4().hex}'
session['webcam_stream_id'] = stream_id
return stream_id
def get_video_stream_id(filename: str) -> str:
video_streams = session.get('video_stream_ids', {})
stream_id = video_streams.get(filename)
if not stream_id:
stream_id = f'video-{uuid.uuid4().hex}'
video_streams[filename] = stream_id
session['video_stream_ids'] = video_streams
return stream_id
# Helper function to check if two line segments intersect
def line_intersect(p1, p2, p3, p4):
"""
Check if line segment p1-p2 intersects with line segment p3-p4
Returns True if they intersect, False otherwise
"""
x1, y1 = p1
x2, y2 = p2
x3, y3 = p3
x4, y4 = p4
denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4)
if abs(denom) < 1e-10:
return False
t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denom
u = -((x1 - x2) * (y1 - y3) - (y1 - y2) * (x1 - x3)) / denom
return 0 <= t <= 1 and 0 <= u <= 1
def crossed_line(prev_pos, curr_pos, line_start, line_end):
"""
Check if movement from prev_pos to curr_pos crossed the line.
Uses orientation check - more robust for frame skipping.
"""
# Check if the two line segments intersect
if line_intersect(prev_pos, curr_pos, line_start, line_end):
return True
return False
def generate_frames(capture, line_data, stream_id: str):
"""
Shared frame generator for webcam and uploaded videos.
Handles detection, drawing overlays, and reset events.
"""
track_positions = {}
counted_ids = set()
vehicle_count = 0
vehicle_type_counts = fresh_vehicle_counts()
line_start = (line_data['x1'], line_data['y1'])
line_end = (line_data['x2'], line_data['y2'])
frame_idx = 0
reset_event = get_reset_event(stream_id)
try:
while True:
ret, frame = capture.read()
if not ret:
break
frame_idx += 1
if frame_idx % 2 != 0:
continue
if reset_event.is_set():
track_positions.clear()
counted_ids.clear()
vehicle_count = 0
vehicle_type_counts = fresh_vehicle_counts()
reset_event.clear()
frame = cv2.resize(frame, (1020, 600))
results = model.track(frame, persist=True)
if results and results[0].boxes is not None and results[0].boxes.id is not None:
boxes = results[0].boxes.xyxy.int().cpu().tolist()
class_ids = results[0].boxes.cls.int().cpu().tolist()
track_ids = results[0].boxes.id.int().cpu().tolist()
for box, class_id, track_id in zip(boxes, class_ids, track_ids):
label_name = names[class_id]
x1, y1, x2, y2 = box
center_x = (x1 + x2) // 2
center_y = (y1 + y2) // 2
if label_name in VEHICLE_CLASSES:
if track_id in track_positions and track_id not in counted_ids:
prev_x, prev_y = track_positions[track_id]
cv2.line(frame, (prev_x, prev_y), (center_x, center_y), (255, 100, 0), 2)
if crossed_line((prev_x, prev_y), (center_x, center_y), line_start, line_end):
counted_ids.add(track_id)
vehicle_count += 1
vehicle_type_counts[label_name] += 1
cv2.circle(frame, (center_x, center_y), 25, (0, 255, 0), 5)
track_positions[track_id] = (center_x, center_y)
box_color = (0, 255, 0) if label_name in VEHICLE_CLASSES else (255, 0, 0)
cv2.rectangle(frame, (x1, y1), (x2, y2), box_color, 2)
label = f'{track_id} - {label_name}'
if track_id in counted_ids:
label += ' ✓'
cv2.putText(frame, label, (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 0, 255), 1)
cv2.circle(frame, (center_x, center_y), 3, (0, 255, 255), -1)
cv2.line(frame, line_start, line_end, (0, 255, 255), 3, cv2.LINE_AA)
line_length = int(np.sqrt((line_end[0] - line_start[0]) ** 2 + (line_end[1] - line_start[1]) ** 2))
dash_length = 20
for i in range(0, max(line_length, 1), dash_length * 2):
t1 = i / line_length if line_length else 0
t2 = min((i + dash_length) / line_length, 1.0) if line_length else 0
x1_dash = int(line_start[0] + t1 * (line_end[0] - line_start[0]))
y1_dash = int(line_start[1] + t1 * (line_end[1] - line_start[1]))
x2_dash = int(line_start[0] + t2 * (line_end[0] - line_start[0]))
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: {vehicle_count}', (20, 35), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2)
cv2.putText(frame, f"Autos: {vehicle_type_counts.get('car', 0)}", (20, 65), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (200, 200, 200), 1)
cv2.putText(frame, f"LKW: {vehicle_type_counts.get('truck', 0)}", (20, 90), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (200, 200, 200), 1)
cv2.putText(frame, f"Busse: {vehicle_type_counts.get('bus', 0)}", (20, 115), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (200, 200, 200), 1)
cv2.putText(frame, f"Motorraeder: {vehicle_type_counts.get('motorcycle', 0)}", (20, 135), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (200, 200, 200), 1)
_, buffer = cv2.imencode('.jpg', frame)
frame_bytes = buffer.tobytes()
yield (b'--frame\r\n'
b'Content-Type: image/jpeg\r\n\r\n' + frame_bytes + b'\r\n')
finally:
capture.release()
release_reset_event(stream_id)
@app.route('/')
def index():
return render_template('index.html')
@app.route('/start_webcam')
def start_webcam():
get_line_from_session()
stream_id = get_webcam_stream_id()
return render_template('webcam.html', stream_id=stream_id)
@app.route('/api/set_line', methods=['POST'])
def set_counting_line():
"""API endpoint to set the counting line coordinates"""
data = request.get_json(silent=True) or {}
try:
session['counting_line'] = {
'x1': int(data['x1']),
'y1': int(data['y1']),
'x2': int(data['x2']),
'y2': int(data['y2'])
}
except (KeyError, ValueError, TypeError):
abort(400, description='Ungültige Linienkoordinaten')
return jsonify({'status': 'success', 'line': session['counting_line']})
@app.route('/api/get_line', methods=['GET'])
def get_counting_line():
"""API endpoint to get the current counting line coordinates"""
return jsonify(get_line_from_session())
@app.route('/api/reset_count', methods=['POST'])
def reset_count():
"""API endpoint to reset the vehicle count for a stream"""
data = request.get_json(silent=True) or {}
stream_id = data.get('stream_id')
if not stream_id:
abort(400, description='stream_id ist erforderlich')
valid_ids = {session.get('webcam_stream_id')}
valid_ids.update(session.get('video_stream_ids', {}).values())
valid_ids.discard(None)
if stream_id not in valid_ids:
abort(403, description='Stream gehört nicht zur aktuellen Sitzung')
event = get_reset_event(stream_id)
event.set()
return jsonify({'status': 'success', 'message': 'Zähler wird zurückgesetzt'})
def detect_objects_from_webcam(line_data, stream_id):
cap = cv2.VideoCapture("http://CAMERA-IP:81/stream")
if not cap.isOpened():
cap.release()
raise RuntimeError('Webcam konnte nicht geöffnet werden')
return generate_frames(cap, line_data, stream_id)
@app.route('/webcam_feed')
def webcam_feed():
line_data = get_line_from_session()
stream_id = get_webcam_stream_id()
try:
generator = detect_objects_from_webcam(line_data, stream_id)
except RuntimeError as exc:
abort(503, description=str(exc))
return Response(generator,
mimetype='multipart/x-mixed-replace; boundary=frame')
@app.route('/upload', methods=['POST'])
def upload_video():
if 'file' not in request.files:
abort(400, description='Keine Datei erhalten')
file = request.files['file']
if not file or file.filename == '':
abort(400, description='Keine Datei ausgewählt')
filename = secure_filename(file.filename)
if not filename:
abort(400, description='Ungültiger Dateiname')
if not allowed_file(filename):
abort(400, description='Ungültiger Dateityp')
ensure_upload_dir()
name, ext = os.path.splitext(filename)
stored_filename = f"{name}_{uuid.uuid4().hex}{ext.lower()}"
file_path = os.path.join(UPLOAD_DIR, stored_filename)
file.save(file_path)
return redirect(url_for('play_video', filename=stored_filename))
@app.route('/uploads/<filename>')
def play_video(filename):
safe_filename = os.path.basename(filename)
if safe_filename != filename:
abort(400, description='Ungültiger Dateiname')
file_path = os.path.join(UPLOAD_DIR, safe_filename)
if not os.path.isfile(file_path):
abort(404)
get_line_from_session()
stream_id = get_video_stream_id(safe_filename)
return render_template('play_video.html', filename=safe_filename, stream_id=stream_id)
@app.route('/video/<path:filename>')
def send_video(filename):
return send_from_directory(UPLOAD_DIR, filename)
def detect_objects_from_video(video_path, line_data, stream_id):
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
cap.release()
raise RuntimeError('Video konnte nicht geöffnet werden')
return generate_frames(cap, line_data, stream_id)
@app.route('/video_feed/<filename>')
def video_feed(filename):
safe_filename = os.path.basename(filename)
if safe_filename != filename:
abort(400, description='Ungültiger Dateiname')
video_path = os.path.join(UPLOAD_DIR, safe_filename)
if not os.path.isfile(video_path):
abort(404)
line_data = get_line_from_session()
stream_id = get_video_stream_id(safe_filename)
try:
generator = detect_objects_from_video(video_path, line_data, stream_id)
except RuntimeError as exc:
abort(503, description=str(exc))
return Response(generator,
mimetype='multipart/x-mixed-replace; boundary=frame')
if __name__ == '__main__':
app.run('0.0.0.0',debug=False, port=8080)

343
app2.py
View File

@@ -1,343 +0,0 @@
import os
import uuid
import cv2
import numpy as np
from threading import Event, Lock
from flask import (
Flask,
render_template,
Response,
request,
redirect,
url_for,
send_from_directory,
session,
jsonify,
abort,
)
from werkzeug.utils import secure_filename
from ultralytics import YOLO
app = Flask(__name__)
app.secret_key = os.environ.get('SECRET_KEY', 'vehicle_dev_secret')
app.config['MAX_CONTENT_LENGTH'] = 200 * 1024 * 1024 # 200MB upload cap
# Load the YOLOv8 model
model = YOLO("yolo11s.pt")
names = model.model.names
# Vehicle classes to count
VEHICLE_CLASSES = {'car', 'truck', 'bus', 'motorcycle'}
ALLOWED_EXTENSIONS = {'mp4', 'mov', 'avi', 'mkv'}
UPLOAD_DIR = 'uploads'
# Track reset events per stream (webcam/video per session)
reset_events: dict[str, Event] = {}
reset_lock = Lock()
def allowed_file(filename: str) -> bool:
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
def ensure_upload_dir() -> None:
if not os.path.exists(UPLOAD_DIR):
os.makedirs(UPLOAD_DIR)
def get_line_from_session():
if 'counting_line' not in session:
session['counting_line'] = {'x1': 0, 'y1': 300, 'x2': 1020, 'y2': 300}
return session['counting_line']
def fresh_vehicle_counts() -> dict[str, int]:
return {vehicle: 0 for vehicle in VEHICLE_CLASSES}
def get_reset_event(stream_id: str) -> Event:
with reset_lock:
event = reset_events.get(stream_id)
if event is None:
event = Event()
reset_events[stream_id] = event
return event
def release_reset_event(stream_id: str) -> None:
with reset_lock:
reset_events.pop(stream_id, None)
def get_webcam_stream_id() -> str:
stream_id = session.get('webcam_stream_id')
if not stream_id:
stream_id = f'webcam-{uuid.uuid4().hex}'
session['webcam_stream_id'] = stream_id
return stream_id
def get_video_stream_id(filename: str) -> str:
video_streams = session.get('video_stream_ids', {})
stream_id = video_streams.get(filename)
if not stream_id:
stream_id = f'video-{uuid.uuid4().hex}'
video_streams[filename] = stream_id
session['video_stream_ids'] = video_streams
return stream_id
# Helper function to check if two line segments intersect
def line_intersect(p1, p2, p3, p4):
"""
Check if line segment p1-p2 intersects with line segment p3-p4
Returns True if they intersect, False otherwise
"""
x1, y1 = p1
x2, y2 = p2
x3, y3 = p3
x4, y4 = p4
denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4)
if abs(denom) < 1e-10:
return False
t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denom
u = -((x1 - x2) * (y1 - y3) - (y1 - y2) * (x1 - x3)) / denom
return 0 <= t <= 1 and 0 <= u <= 1
def crossed_line(prev_pos, curr_pos, line_start, line_end):
"""
Check if movement from prev_pos to curr_pos crossed the line.
Uses orientation check - more robust for frame skipping.
"""
# Check if the two line segments intersect
if line_intersect(prev_pos, curr_pos, line_start, line_end):
return True
return False
def generate_frames(capture, line_data, stream_id: str):
"""
Shared frame generator for webcam and uploaded videos.
Handles detection, drawing overlays, and reset events.
"""
track_positions = {}
counted_ids = set()
vehicle_count = 0
vehicle_type_counts = fresh_vehicle_counts()
line_start = (line_data['x1'], line_data['y1'])
line_end = (line_data['x2'], line_data['y2'])
frame_idx = 0
reset_event = get_reset_event(stream_id)
try:
while True:
ret, frame = capture.read()
if not ret:
break
frame_idx += 1
if frame_idx % 2 != 0:
continue
if reset_event.is_set():
track_positions.clear()
counted_ids.clear()
vehicle_count = 0
vehicle_type_counts = fresh_vehicle_counts()
reset_event.clear()
frame = cv2.resize(frame, (1020, 600))
results = model.track(frame, persist=True)
if results and results[0].boxes is not None and results[0].boxes.id is not None:
boxes = results[0].boxes.xyxy.int().cpu().tolist()
class_ids = results[0].boxes.cls.int().cpu().tolist()
track_ids = results[0].boxes.id.int().cpu().tolist()
for box, class_id, track_id in zip(boxes, class_ids, track_ids):
label_name = names[class_id]
x1, y1, x2, y2 = box
center_x = (x1 + x2) // 2
center_y = (y1 + y2) // 2
if label_name in VEHICLE_CLASSES:
if track_id in track_positions and track_id not in counted_ids:
prev_x, prev_y = track_positions[track_id]
cv2.line(frame, (prev_x, prev_y), (center_x, center_y), (255, 100, 0), 2)
if crossed_line((prev_x, prev_y), (center_x, center_y), line_start, line_end):
counted_ids.add(track_id)
vehicle_count += 1
vehicle_type_counts[label_name] += 1
cv2.circle(frame, (center_x, center_y), 25, (0, 255, 0), 5)
track_positions[track_id] = (center_x, center_y)
box_color = (0, 255, 0) if label_name in VEHICLE_CLASSES else (255, 0, 0)
cv2.rectangle(frame, (x1, y1), (x2, y2), box_color, 2)
label = f'{track_id} - {label_name}'
if track_id in counted_ids:
label += ''
cv2.putText(frame, label, (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 0, 255), 1)
cv2.circle(frame, (center_x, center_y), 3, (0, 255, 255), -1)
cv2.line(frame, line_start, line_end, (0, 255, 255), 3, cv2.LINE_AA)
line_length = int(np.sqrt((line_end[0] - line_start[0]) ** 2 + (line_end[1] - line_start[1]) ** 2))
dash_length = 20
for i in range(0, max(line_length, 1), dash_length * 2):
t1 = i / line_length if line_length else 0
t2 = min((i + dash_length) / line_length, 1.0) if line_length else 0
x1_dash = int(line_start[0] + t1 * (line_end[0] - line_start[0]))
y1_dash = int(line_start[1] + t1 * (line_end[1] - line_start[1]))
x2_dash = int(line_start[0] + t2 * (line_end[0] - line_start[0]))
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: {vehicle_count}', (20, 35), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2)
cv2.putText(frame, f"Autos: {vehicle_type_counts.get('car', 0)}", (20, 65), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (200, 200, 200), 1)
cv2.putText(frame, f"LKW: {vehicle_type_counts.get('truck', 0)}", (20, 90), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (200, 200, 200), 1)
cv2.putText(frame, f"Busse: {vehicle_type_counts.get('bus', 0)}", (20, 115), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (200, 200, 200), 1)
cv2.putText(frame, f"Motorraeder: {vehicle_type_counts.get('motorcycle', 0)}", (20, 135), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (200, 200, 200), 1)
_, buffer = cv2.imencode('.jpg', frame)
frame_bytes = buffer.tobytes()
yield (b'--frame\r\n'
b'Content-Type: image/jpeg\r\n\r\n' + frame_bytes + b'\r\n')
finally:
capture.release()
release_reset_event(stream_id)
@app.route('/')
def index():
return render_template('index.html')
@app.route('/start_webcam')
def start_webcam():
get_line_from_session()
stream_id = get_webcam_stream_id()
return render_template('webcam.html', stream_id=stream_id)
@app.route('/api/set_line', methods=['POST'])
def set_counting_line():
"""API endpoint to set the counting line coordinates"""
data = request.get_json(silent=True) or {}
try:
session['counting_line'] = {
'x1': int(data['x1']),
'y1': int(data['y1']),
'x2': int(data['x2']),
'y2': int(data['y2'])
}
except (KeyError, ValueError, TypeError):
abort(400, description='Ungültige Linienkoordinaten')
return jsonify({'status': 'success', 'line': session['counting_line']})
@app.route('/api/get_line', methods=['GET'])
def get_counting_line():
"""API endpoint to get the current counting line coordinates"""
return jsonify(get_line_from_session())
@app.route('/api/reset_count', methods=['POST'])
def reset_count():
"""API endpoint to reset the vehicle count for a stream"""
data = request.get_json(silent=True) or {}
stream_id = data.get('stream_id')
if not stream_id:
abort(400, description='stream_id ist erforderlich')
valid_ids = {session.get('webcam_stream_id')}
valid_ids.update(session.get('video_stream_ids', {}).values())
valid_ids.discard(None)
if stream_id not in valid_ids:
abort(403, description='Stream gehört nicht zur aktuellen Sitzung')
event = get_reset_event(stream_id)
event.set()
return jsonify({'status': 'success', 'message': 'Zähler wird zurückgesetzt'})
def detect_objects_from_webcam(line_data, stream_id):
cap = cv2.VideoCapture("http://CAMERA-IP:81/stream")
if not cap.isOpened():
cap.release()
raise RuntimeError('Webcam konnte nicht geöffnet werden')
return generate_frames(cap, line_data, stream_id)
@app.route('/webcam_feed')
def webcam_feed():
line_data = get_line_from_session()
stream_id = get_webcam_stream_id()
try:
generator = detect_objects_from_webcam(line_data, stream_id)
except RuntimeError as exc:
abort(503, description=str(exc))
return Response(generator,
mimetype='multipart/x-mixed-replace; boundary=frame')
@app.route('/upload', methods=['POST'])
def upload_video():
if 'file' not in request.files:
abort(400, description='Keine Datei erhalten')
file = request.files['file']
if not file or file.filename == '':
abort(400, description='Keine Datei ausgewählt')
filename = secure_filename(file.filename)
if not filename:
abort(400, description='Ungültiger Dateiname')
if not allowed_file(filename):
abort(400, description='Ungültiger Dateityp')
ensure_upload_dir()
name, ext = os.path.splitext(filename)
stored_filename = f"{name}_{uuid.uuid4().hex}{ext.lower()}"
file_path = os.path.join(UPLOAD_DIR, stored_filename)
file.save(file_path)
return redirect(url_for('play_video', filename=stored_filename))
@app.route('/uploads/<filename>')
def play_video(filename):
safe_filename = os.path.basename(filename)
if safe_filename != filename:
abort(400, description='Ungültiger Dateiname')
file_path = os.path.join(UPLOAD_DIR, safe_filename)
if not os.path.isfile(file_path):
abort(404)
get_line_from_session()
stream_id = get_video_stream_id(safe_filename)
return render_template('play_video.html', filename=safe_filename, stream_id=stream_id)
@app.route('/video/<path:filename>')
def send_video(filename):
return send_from_directory(UPLOAD_DIR, filename)
def detect_objects_from_video(video_path, line_data, stream_id):
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
cap.release()
raise RuntimeError('Video konnte nicht geöffnet werden')
return generate_frames(cap, line_data, stream_id)
@app.route('/video_feed/<filename>')
def video_feed(filename):
safe_filename = os.path.basename(filename)
if safe_filename != filename:
abort(400, description='Ungültiger Dateiname')
video_path = os.path.join(UPLOAD_DIR, safe_filename)
if not os.path.isfile(video_path):
abort(404)
line_data = get_line_from_session()
stream_id = get_video_stream_id(safe_filename)
try:
generator = detect_objects_from_video(video_path, line_data, stream_id)
except RuntimeError as exc:
abort(503, description=str(exc))
return Response(generator,
mimetype='multipart/x-mixed-replace; boundary=frame')
if __name__ == '__main__':
app.run('0.0.0.0',debug=False, port=8080)

565
app3.py
View File

@@ -1,565 +0,0 @@
import os
import uuid
import time
import threading
from threading import Event, Lock, Condition
import cv2
import numpy as np
from flask import (
Flask,
render_template,
Response,
request,
redirect,
url_for,
send_from_directory,
session,
jsonify,
abort,
)
from werkzeug.utils import secure_filename
from ultralytics import YOLO
# ---------------------------------------------------------------------------
# Konfiguration
# ---------------------------------------------------------------------------
# Kamera-URL ueber Env ueberschreibbar (ESP32-CAM Default: Port 81, /stream).
CAMERA_URL = os.environ.get("CAMERA_URL", "http://CAMERA-IP:81/stream")
# FFmpeg-Optionen fuer den Netzwerk-Stream: Socket-Timeout + Auto-Reconnect.
# Muss VOR dem ersten VideoCapture gesetzt sein.
os.environ.setdefault(
"OPENCV_FFMPEG_CAPTURE_OPTIONS",
"timeout;5000000|reconnect;1|reconnect_streamed;1|reconnect_delay_max;2",
)
app = Flask(__name__)
app.secret_key = os.environ.get("SECRET_KEY", "vehicle_dev_secret")
app.config["MAX_CONTENT_LENGTH"] = 200 * 1024 * 1024 # 200MB Upload-Limit
# Globales Modell fuer den Video-Upload-Pfad (per Request).
model = YOLO("yolo11s.pt")
names = model.model.names
VEHICLE_CLASSES = {"car", "truck", "bus", "motorcycle"}
ALLOWED_EXTENSIONS = {"mp4", "mov", "avi", "mkv"}
UPLOAD_DIR = "uploads"
DEFAULT_LINE = {"x1": 0, "y1": 300, "x2": 1020, "y2": 300}
FRAME_SIZE = (1020, 600)
# ---------------------------------------------------------------------------
# Hilfsfunktionen
# ---------------------------------------------------------------------------
def allowed_file(filename: str) -> bool:
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
def ensure_upload_dir() -> None:
if not os.path.exists(UPLOAD_DIR):
os.makedirs(UPLOAD_DIR)
def get_line_from_session():
if "counting_line" not in session:
session["counting_line"] = dict(DEFAULT_LINE)
return session["counting_line"]
def fresh_vehicle_counts() -> dict[str, int]:
return {vehicle: 0 for vehicle in VEHICLE_CLASSES}
def new_state() -> dict:
"""Frischer Zaehl-/Tracking-Zustand fuer einen Stream."""
return {
"track_positions": {},
"counted_ids": set(),
"count": 0,
"types": fresh_vehicle_counts(),
}
def reset_state(state: dict) -> None:
state["track_positions"].clear()
state["counted_ids"].clear()
state["count"] = 0
state["types"] = fresh_vehicle_counts()
def line_intersect(p1, p2, p3, p4) -> bool:
"""True, wenn sich die Strecken p1-p2 und p3-p4 schneiden."""
x1, y1 = p1
x2, y2 = p2
x3, y3 = p3
x4, y4 = p4
denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4)
if abs(denom) < 1e-10:
return False
t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denom
u = -((x1 - x2) * (y1 - y3) - (y1 - y2) * (x1 - x3)) / denom
return 0 <= t <= 1 and 0 <= u <= 1
def crossed_line(prev_pos, curr_pos, line_start, line_end) -> bool:
return line_intersect(prev_pos, curr_pos, line_start, line_end)
def process_frame(frame, det_model, det_names, line_start, line_end, state):
"""
Skaliert den Frame, fuehrt YOLO-Tracking aus, zaehlt Linienueberquerungen
und zeichnet alle Overlays. Mutiert `state` in-place und gibt den
annotierten Frame zurueck. Wird von Webcam-Grabber UND Video-Pfad genutzt.
"""
frame = cv2.resize(frame, FRAME_SIZE)
results = det_model.track(frame, persist=True)
track_positions = state["track_positions"]
counted_ids = state["counted_ids"]
types = state["types"]
if results and results[0].boxes is not None and results[0].boxes.id is not None:
boxes = results[0].boxes.xyxy.int().cpu().tolist()
class_ids = results[0].boxes.cls.int().cpu().tolist()
track_ids = results[0].boxes.id.int().cpu().tolist()
for box, class_id, track_id in zip(boxes, class_ids, track_ids):
label_name = det_names[class_id]
x1, y1, x2, y2 = box
center_x = (x1 + x2) // 2
center_y = (y1 + y2) // 2
if label_name in VEHICLE_CLASSES:
if track_id in track_positions and track_id not in counted_ids:
prev_x, prev_y = track_positions[track_id]
cv2.line(frame, (prev_x, prev_y), (center_x, center_y), (255, 100, 0), 2)
if crossed_line((prev_x, prev_y), (center_x, center_y), line_start, line_end):
counted_ids.add(track_id)
state["count"] += 1
types[label_name] += 1
cv2.circle(frame, (center_x, center_y), 25, (0, 255, 0), 5)
track_positions[track_id] = (center_x, center_y)
box_color = (0, 255, 0) if label_name in VEHICLE_CLASSES else (255, 0, 0)
cv2.rectangle(frame, (x1, y1), (x2, y2), box_color, 2)
label = f"{track_id} - {label_name}"
if track_id in counted_ids:
label += " \u2713"
cv2.putText(frame, label, (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 0, 255), 1)
cv2.circle(frame, (center_x, center_y), 3, (0, 255, 255), -1)
# Zaehllinie (gestrichelt)
cv2.line(frame, line_start, line_end, (0, 255, 255), 3, cv2.LINE_AA)
line_length = int(np.sqrt((line_end[0] - line_start[0]) ** 2 + (line_end[1] - line_start[1]) ** 2))
dash_length = 20
for i in range(0, max(line_length, 1), dash_length * 2):
t1 = i / line_length if line_length else 0
t2 = min((i + dash_length) / line_length, 1.0) if line_length else 0
x1_dash = int(line_start[0] + t1 * (line_end[0] - line_start[0]))
y1_dash = int(line_start[1] + t1 * (line_end[1] - line_start[1]))
x2_dash = int(line_start[0] + t2 * (line_end[0] - line_start[0]))
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)
# Zaehler-Box
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
# ---------------------------------------------------------------------------
# Webcam-Grabber: EINE Verbindung zur (ESP32-)Cam, Fan-out an viele Viewer
# ---------------------------------------------------------------------------
class WebcamGrabber:
"""
Haelt genau eine Verbindung zur Netzwerk-Kamera. Ein Hintergrund-Thread
liest Frames, laeuft YOLO drauf und legt das jeweils neueste annotierte
JPEG in einen gemeinsamen Slot. Jeder /webcam_feed-Request konsumiert nur
diesen Slot -> beliebig viele Zuschauer, aber nur EINE Cam-Verbindung.
"""
def __init__(self, url: str):
self.url = url
self.lock = Lock()
self.frame_cond = Condition()
self.latest_jpeg: bytes | None = None
self.frame_seq = 0
self.viewers = 0
self.reset_flag = Event()
self.line = dict(DEFAULT_LINE)
# Eigenes Modell -> isolierter Tracker, getrennt vom Video-Pfad.
# Lazy: wird erst beim ersten aktiven Stream geladen.
self.model = None
self.names = None
self.thread = None
# -- Steuerung -----------------------------------------------------------
def start(self):
if self.thread is None:
self.thread = threading.Thread(target=self._run, daemon=True)
self.thread.start()
def set_line(self, line: dict):
with self.lock:
self.line = dict(line)
def reset(self):
self.reset_flag.set()
def add_viewer(self):
with self.lock:
self.viewers += 1
def remove_viewer(self):
with self.lock:
self.viewers = max(0, self.viewers - 1)
def _viewer_count(self) -> int:
with self.lock:
return self.viewers
def _current_line(self):
with self.lock:
line = dict(self.line)
return (line["x1"], line["y1"]), (line["x2"], line["y2"])
def _open(self):
cap = cv2.VideoCapture(self.url, cv2.CAP_FFMPEG)
# Timeouts sind versions-/backend-abhaengig -> defensiv setzen.
try:
cap.set(cv2.CAP_PROP_OPEN_TIMEOUT_MSEC, 5000)
cap.set(cv2.CAP_PROP_READ_TIMEOUT_MSEC, 5000)
except Exception:
pass
return cap
def _ensure_model(self):
if self.model is None:
self.model = YOLO("yolo11s.pt")
self.names = self.model.model.names
def _publish(self, jpeg: bytes):
with self.frame_cond:
self.latest_jpeg = jpeg
self.frame_seq += 1
self.frame_cond.notify_all()
def _clear(self):
with self.frame_cond:
self.latest_jpeg = None
self.frame_seq += 1
self.frame_cond.notify_all()
# -- Hintergrund-Thread (laeuft die ganze Prozess-Lebensdauer) ----------
def _run(self):
cap = None
state = new_state()
frame_idx = 0
while True:
# Niemand schaut zu -> Cam-Verbindung freigeben, idlen.
if self._viewer_count() == 0:
if cap is not None:
cap.release()
cap = None
self._clear()
time.sleep(0.3)
continue
self._ensure_model()
# (Re-)Connect mit Backoff.
if cap is None or not cap.isOpened():
if cap is not None:
cap.release()
cap = self._open()
if not cap.isOpened():
cap = None
time.sleep(2.0)
continue
# Frische Verbindung -> Track-IDs starten neu, Gesamtzaehler bleibt.
state["track_positions"].clear()
state["counted_ids"].clear()
ret, frame = cap.read()
if not ret:
cap.release()
cap = None
time.sleep(1.0)
continue
frame_idx += 1
if frame_idx % 2 != 0:
continue
if self.reset_flag.is_set():
reset_state(state)
self.reset_flag.clear()
line_start, line_end = self._current_line()
frame = process_frame(frame, self.model, self.names, line_start, line_end, state)
ok, buffer = cv2.imencode(".jpg", frame)
if not ok:
continue
self._publish(buffer.tobytes())
# -- Pro-Viewer-Generator ------------------------------------------------
def frames(self):
self.add_viewer()
last_seq = -1
try:
while True:
with self.frame_cond:
got = self.frame_cond.wait_for(
lambda: self.frame_seq != last_seq, timeout=15
)
if not got:
# 15s kein neuer Frame -> Cam vermutlich tot, Viewer beenden.
break
jpeg = self.latest_jpeg
last_seq = self.frame_seq
if jpeg is None:
# Grabber hat Verbindung freigegeben.
break
yield (
b"--frame\r\n"
b"Content-Type: image/jpeg\r\n\r\n" + jpeg + b"\r\n"
)
finally:
self.remove_viewer()
webcam = WebcamGrabber(CAMERA_URL)
webcam.start()
# ---------------------------------------------------------------------------
# Reset-Events fuer den Video-Pfad (per Stream-ID)
# ---------------------------------------------------------------------------
reset_events: dict[str, Event] = {}
reset_lock = Lock()
def get_reset_event(stream_id: str) -> Event:
with reset_lock:
event = reset_events.get(stream_id)
if event is None:
event = Event()
reset_events[stream_id] = event
return event
def release_reset_event(stream_id: str) -> None:
with reset_lock:
reset_events.pop(stream_id, None)
def get_webcam_stream_id() -> str:
stream_id = session.get("webcam_stream_id")
if not stream_id:
stream_id = f"webcam-{uuid.uuid4().hex}"
session["webcam_stream_id"] = stream_id
return stream_id
def get_video_stream_id(filename: str) -> str:
video_streams = session.get("video_stream_ids", {})
stream_id = video_streams.get(filename)
if not stream_id:
stream_id = f"video-{uuid.uuid4().hex}"
video_streams[filename] = stream_id
session["video_stream_ids"] = video_streams
return stream_id
# ---------------------------------------------------------------------------
# Video-Pfad (per Request, unveraendert in der Logik)
# ---------------------------------------------------------------------------
def generate_frames(capture, line_data, stream_id: str):
state = new_state()
line_start = (line_data["x1"], line_data["y1"])
line_end = (line_data["x2"], line_data["y2"])
frame_idx = 0
reset_event = get_reset_event(stream_id)
try:
while True:
ret, frame = capture.read()
if not ret:
break
frame_idx += 1
if frame_idx % 2 != 0:
continue
if reset_event.is_set():
reset_state(state)
reset_event.clear()
frame = process_frame(frame, model, names, line_start, line_end, state)
ok, buffer = cv2.imencode(".jpg", frame)
if not ok:
continue
yield (
b"--frame\r\n"
b"Content-Type: image/jpeg\r\n\r\n" + buffer.tobytes() + b"\r\n"
)
finally:
capture.release()
release_reset_event(stream_id)
def detect_objects_from_video(video_path, line_data, stream_id):
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
cap.release()
raise RuntimeError("Video konnte nicht geoeffnet werden")
return generate_frames(cap, line_data, stream_id)
# ---------------------------------------------------------------------------
# Routen
# ---------------------------------------------------------------------------
@app.route("/")
def index():
return render_template("index.html")
@app.route("/start_webcam")
def start_webcam():
get_line_from_session()
stream_id = get_webcam_stream_id()
return render_template("webcam.html", stream_id=stream_id)
@app.route("/webcam_feed")
def webcam_feed():
# Keine eigene Cam-Verbindung mehr pro Request -> Fan-out vom Grabber.
return Response(
webcam.frames(),
mimetype="multipart/x-mixed-replace; boundary=frame",
)
@app.route("/api/set_line", methods=["POST"])
def set_counting_line():
"""Setzt die Zaehllinie (gilt fuer Video-Session UND Webcam-Grabber)."""
data = request.get_json(silent=True) or {}
try:
line = {
"x1": int(data["x1"]),
"y1": int(data["y1"]),
"x2": int(data["x2"]),
"y2": int(data["y2"]),
}
except (KeyError, ValueError, TypeError):
abort(400, description="Ungueltige Linienkoordinaten")
session["counting_line"] = line
webcam.set_line(line) # Webcam nutzt eine globale Linie (eine Kamera)
return jsonify({"status": "success", "line": line})
@app.route("/api/get_line", methods=["GET"])
def get_counting_line():
return jsonify(get_line_from_session())
@app.route("/api/reset_count", methods=["POST"])
def reset_count():
data = request.get_json(silent=True) or {}
stream_id = data.get("stream_id")
if not stream_id:
abort(400, description="stream_id ist erforderlich")
valid_ids = {session.get("webcam_stream_id")}
valid_ids.update(session.get("video_stream_ids", {}).values())
valid_ids.discard(None)
if stream_id not in valid_ids:
abort(403, description="Stream gehoert nicht zur aktuellen Sitzung")
if stream_id == session.get("webcam_stream_id"):
webcam.reset()
else:
get_reset_event(stream_id).set()
return jsonify({"status": "success", "message": "Zaehler wird zurueckgesetzt"})
@app.route("/upload", methods=["POST"])
def upload_video():
if "file" not in request.files:
abort(400, description="Keine Datei erhalten")
file = request.files["file"]
if not file or file.filename == "":
abort(400, description="Keine Datei ausgewaehlt")
filename = secure_filename(file.filename)
if not filename:
abort(400, description="Ungueltiger Dateiname")
if not allowed_file(filename):
abort(400, description="Ungueltiger Dateityp")
ensure_upload_dir()
name, ext = os.path.splitext(filename)
stored_filename = f"{name}_{uuid.uuid4().hex}{ext.lower()}"
file_path = os.path.join(UPLOAD_DIR, stored_filename)
file.save(file_path)
return redirect(url_for("play_video", filename=stored_filename))
@app.route("/uploads/<filename>")
def play_video(filename):
safe_filename = os.path.basename(filename)
if safe_filename != filename:
abort(400, description="Ungueltiger Dateiname")
file_path = os.path.join(UPLOAD_DIR, safe_filename)
if not os.path.isfile(file_path):
abort(404)
get_line_from_session()
stream_id = get_video_stream_id(safe_filename)
return render_template("play_video.html", filename=safe_filename, stream_id=stream_id)
@app.route("/video/<path:filename>")
def send_video(filename):
return send_from_directory(UPLOAD_DIR, filename)
@app.route("/video_feed/<filename>")
def video_feed(filename):
safe_filename = os.path.basename(filename)
if safe_filename != filename:
abort(400, description="Ungueltiger Dateiname")
video_path = os.path.join(UPLOAD_DIR, safe_filename)
if not os.path.isfile(video_path):
abort(404)
line_data = get_line_from_session()
stream_id = get_video_stream_id(safe_filename)
try:
generator = detect_objects_from_video(video_path, line_data, stream_id)
except RuntimeError as exc:
abort(503, description=str(exc))
return Response(generator, mimetype="multipart/x-mixed-replace; boundary=frame")
if __name__ == "__main__":
app.run("0.0.0.0", debug=False, port=8080)

584
app4.py
View File

@@ -1,584 +0,0 @@
import os
import uuid
import time
import threading
from threading import Event, Lock, Condition
import cv2
import numpy as np
import requests
from flask import (
Flask,
render_template,
Response,
request,
redirect,
url_for,
send_from_directory,
session,
jsonify,
abort,
)
from werkzeug.utils import secure_filename
from ultralytics import YOLO
# ---------------------------------------------------------------------------
# Konfiguration
# ---------------------------------------------------------------------------
# Kamera-URL ueber Env ueberschreibbar (ESP32-CAM Default: Port 81, /stream).
CAMERA_URL = os.environ.get("CAMERA_URL", "http://CAMERA-IP:81/stream")
app = Flask(__name__)
app.secret_key = os.environ.get("SECRET_KEY", "vehicle_dev_secret")
app.config["MAX_CONTENT_LENGTH"] = 200 * 1024 * 1024 # 200MB Upload-Limit
# Globales Modell fuer den Video-Upload-Pfad (per Request).
model = YOLO("yolo11s.pt")
names = model.model.names
VEHICLE_CLASSES = {"car", "truck", "bus", "motorcycle"}
ALLOWED_EXTENSIONS = {"mp4", "mov", "avi", "mkv"}
UPLOAD_DIR = "uploads"
DEFAULT_LINE = {"x1": 0, "y1": 300, "x2": 1020, "y2": 300}
FRAME_SIZE = (1020, 600)
# ---------------------------------------------------------------------------
# Hilfsfunktionen
# ---------------------------------------------------------------------------
def allowed_file(filename: str) -> bool:
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
def ensure_upload_dir() -> None:
if not os.path.exists(UPLOAD_DIR):
os.makedirs(UPLOAD_DIR)
def get_line_from_session():
if "counting_line" not in session:
session["counting_line"] = dict(DEFAULT_LINE)
return session["counting_line"]
def fresh_vehicle_counts() -> dict[str, int]:
return {vehicle: 0 for vehicle in VEHICLE_CLASSES}
def new_state() -> dict:
"""Frischer Zaehl-/Tracking-Zustand fuer einen Stream."""
return {
"track_positions": {},
"counted_ids": set(),
"count": 0,
"types": fresh_vehicle_counts(),
}
def reset_state(state: dict) -> None:
state["track_positions"].clear()
state["counted_ids"].clear()
state["count"] = 0
state["types"] = fresh_vehicle_counts()
def line_intersect(p1, p2, p3, p4) -> bool:
"""True, wenn sich die Strecken p1-p2 und p3-p4 schneiden."""
x1, y1 = p1
x2, y2 = p2
x3, y3 = p3
x4, y4 = p4
denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4)
if abs(denom) < 1e-10:
return False
t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denom
u = -((x1 - x2) * (y1 - y3) - (y1 - y2) * (x1 - x3)) / denom
return 0 <= t <= 1 and 0 <= u <= 1
def crossed_line(prev_pos, curr_pos, line_start, line_end) -> bool:
return line_intersect(prev_pos, curr_pos, line_start, line_end)
def process_frame(frame, det_model, det_names, line_start, line_end, state):
"""
Skaliert den Frame, fuehrt YOLO-Tracking aus, zaehlt Linienueberquerungen
und zeichnet alle Overlays. Mutiert `state` in-place und gibt den
annotierten Frame zurueck. Wird von Webcam-Grabber UND Video-Pfad genutzt.
"""
frame = cv2.resize(frame, FRAME_SIZE)
results = det_model.track(frame, persist=True)
track_positions = state["track_positions"]
counted_ids = state["counted_ids"]
types = state["types"]
if results and results[0].boxes is not None and results[0].boxes.id is not None:
boxes = results[0].boxes.xyxy.int().cpu().tolist()
class_ids = results[0].boxes.cls.int().cpu().tolist()
track_ids = results[0].boxes.id.int().cpu().tolist()
for box, class_id, track_id in zip(boxes, class_ids, track_ids):
label_name = det_names[class_id]
x1, y1, x2, y2 = box
center_x = (x1 + x2) // 2
center_y = (y1 + y2) // 2
if label_name in VEHICLE_CLASSES:
if track_id in track_positions and track_id not in counted_ids:
prev_x, prev_y = track_positions[track_id]
cv2.line(frame, (prev_x, prev_y), (center_x, center_y), (255, 100, 0), 2)
if crossed_line((prev_x, prev_y), (center_x, center_y), line_start, line_end):
counted_ids.add(track_id)
state["count"] += 1
types[label_name] += 1
cv2.circle(frame, (center_x, center_y), 25, (0, 255, 0), 5)
track_positions[track_id] = (center_x, center_y)
box_color = (0, 255, 0) if label_name in VEHICLE_CLASSES else (255, 0, 0)
cv2.rectangle(frame, (x1, y1), (x2, y2), box_color, 2)
label = f"{track_id} - {label_name}"
if track_id in counted_ids:
label += " \u2713"
cv2.putText(frame, label, (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 0, 255), 1)
cv2.circle(frame, (center_x, center_y), 3, (0, 255, 255), -1)
# Zaehllinie (gestrichelt)
cv2.line(frame, line_start, line_end, (0, 255, 255), 3, cv2.LINE_AA)
line_length = int(np.sqrt((line_end[0] - line_start[0]) ** 2 + (line_end[1] - line_start[1]) ** 2))
dash_length = 20
for i in range(0, max(line_length, 1), dash_length * 2):
t1 = i / line_length if line_length else 0
t2 = min((i + dash_length) / line_length, 1.0) if line_length else 0
x1_dash = int(line_start[0] + t1 * (line_end[0] - line_start[0]))
y1_dash = int(line_start[1] + t1 * (line_end[1] - line_start[1]))
x2_dash = int(line_start[0] + t2 * (line_end[0] - line_start[0]))
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)
# Zaehler-Box
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
# ---------------------------------------------------------------------------
# Webcam-Grabber: EINE Verbindung zur (ESP32-)Cam, Fan-out an viele Viewer
# ---------------------------------------------------------------------------
class WebcamGrabber:
"""
Haelt genau eine Verbindung zur Netzwerk-Kamera. Ein Hintergrund-Thread
liest Frames, laeuft YOLO drauf und legt das jeweils neueste annotierte
JPEG in einen gemeinsamen Slot. Jeder /webcam_feed-Request konsumiert nur
diesen Slot -> beliebig viele Zuschauer, aber nur EINE Cam-Verbindung.
Liest den MJPEG-Stream MANUELL ueber requests (SOI/EOI-Parsing) statt ueber
cv2.VideoCapture -> umgeht den FFmpeg-Demuxer, der bei ESP32-CAM scheitert.
"""
MAX_BUFFER = 4 * 1024 * 1024 # Schutz gegen unbegrenztes Puffer-Wachstum
def __init__(self, url: str):
self.url = url
self.lock = Lock()
self.frame_cond = Condition()
self.latest_jpeg: bytes | None = None
self.frame_seq = 0
self.viewers = 0
self.reset_flag = Event()
self.line = dict(DEFAULT_LINE)
# Eigenes Modell -> isolierter Tracker, getrennt vom Video-Pfad.
# Lazy: wird erst beim ersten aktiven Stream geladen.
self.model = None
self.names = None
self.thread = None
# -- Steuerung -----------------------------------------------------------
def start(self):
if self.thread is None:
self.thread = threading.Thread(target=self._run, daemon=True)
self.thread.start()
def set_line(self, line: dict):
with self.lock:
self.line = dict(line)
def reset(self):
self.reset_flag.set()
def add_viewer(self):
with self.lock:
self.viewers += 1
def remove_viewer(self):
with self.lock:
self.viewers = max(0, self.viewers - 1)
def _viewer_count(self) -> int:
with self.lock:
return self.viewers
def _current_line(self):
with self.lock:
line = dict(self.line)
return (line["x1"], line["y1"]), (line["x2"], line["y2"])
@staticmethod
def _extract_latest(buf: bytes):
"""
Zieht den ZULETZT vollstaendigen JPEG aus dem Puffer und verwirft
aeltere -> haelt die Latenz niedrig (Backlog wird uebersprungen).
Gibt (jpeg_or_None, rest_buffer) zurueck.
"""
latest = None
while True:
start = buf.find(b"\xff\xd8") # SOI
if start == -1:
buf = b""
break
end = buf.find(b"\xff\xd9", start + 2) # EOI
if end == -1:
buf = buf[start:] # unvollstaendig -> Tail behalten
break
latest = buf[start:end + 2]
buf = buf[end + 2:]
return latest, buf
def _ensure_model(self):
if self.model is None:
self.model = YOLO("yolo11s.pt")
self.names = self.model.model.names
def _publish(self, jpeg: bytes):
with self.frame_cond:
self.latest_jpeg = jpeg
self.frame_seq += 1
self.frame_cond.notify_all()
def _clear(self):
with self.frame_cond:
self.latest_jpeg = None
self.frame_seq += 1
self.frame_cond.notify_all()
# -- Hintergrund-Thread (laeuft die ganze Prozess-Lebensdauer) ----------
def _run(self):
state = new_state()
while True:
# Niemand schaut zu -> keine Cam-Verbindung, idlen.
if self._viewer_count() == 0:
if self.latest_jpeg is not None:
self._clear()
time.sleep(0.3)
continue
resp = None
try:
self._ensure_model()
resp = requests.get(self.url, stream=True, timeout=(5, 10))
resp.raise_for_status()
# Frische Verbindung -> Track-IDs neu, Gesamtzaehler bleibt.
state["track_positions"].clear()
state["counted_ids"].clear()
buf = b""
for chunk in resp.iter_content(chunk_size=8192):
if self._viewer_count() == 0:
break # letzter Viewer weg -> Verbindung freigeben
if not chunk:
continue
buf += chunk
if len(buf) > self.MAX_BUFFER:
buf = buf[-self.MAX_BUFFER:]
jpeg, buf = self._extract_latest(buf)
if jpeg is None:
continue
img = cv2.imdecode(np.frombuffer(jpeg, np.uint8), cv2.IMREAD_COLOR)
if img is None:
continue
if self.reset_flag.is_set():
reset_state(state)
self.reset_flag.clear()
line_start, line_end = self._current_line()
frame = process_frame(img, self.model, self.names, line_start, line_end, state)
ok, out = cv2.imencode(".jpg", frame)
if ok:
self._publish(out.tobytes())
except Exception as exc:
# Timeout / Verbindungsabbruch / HTTP-/Modell-Fehler -> sichtbar + Backoff
print(f"[webcam-grabber] {type(exc).__name__}: {exc}", flush=True)
time.sleep(1.0)
finally:
if resp is not None:
resp.close()
# -- Pro-Viewer-Generator ------------------------------------------------
def frames(self):
self.add_viewer()
last_seq = -1
got_any = False
try:
while True:
with self.frame_cond:
ok = self.frame_cond.wait_for(
lambda: self.latest_jpeg is not None and self.frame_seq != last_seq,
timeout=20,
)
jpeg = self.latest_jpeg
seq = self.frame_seq
if not ok:
if got_any:
break # hatten Frames, jetzt 20s nichts -> Cam weg
continue # noch nie ein Frame (Modell laedt / Connect) -> weiter warten
if jpeg is None:
continue
got_any = True
last_seq = seq
yield (
b"--frame\r\n"
b"Content-Type: image/jpeg\r\n\r\n" + jpeg + b"\r\n"
)
finally:
self.remove_viewer()
webcam = WebcamGrabber(CAMERA_URL)
webcam.start()
# ---------------------------------------------------------------------------
# Reset-Events fuer den Video-Pfad (per Stream-ID)
# ---------------------------------------------------------------------------
reset_events: dict[str, Event] = {}
reset_lock = Lock()
def get_reset_event(stream_id: str) -> Event:
with reset_lock:
event = reset_events.get(stream_id)
if event is None:
event = Event()
reset_events[stream_id] = event
return event
def release_reset_event(stream_id: str) -> None:
with reset_lock:
reset_events.pop(stream_id, None)
def get_webcam_stream_id() -> str:
stream_id = session.get("webcam_stream_id")
if not stream_id:
stream_id = f"webcam-{uuid.uuid4().hex}"
session["webcam_stream_id"] = stream_id
return stream_id
def get_video_stream_id(filename: str) -> str:
video_streams = session.get("video_stream_ids", {})
stream_id = video_streams.get(filename)
if not stream_id:
stream_id = f"video-{uuid.uuid4().hex}"
video_streams[filename] = stream_id
session["video_stream_ids"] = video_streams
return stream_id
# ---------------------------------------------------------------------------
# Video-Pfad (per Request, unveraendert in der Logik)
# ---------------------------------------------------------------------------
def generate_frames(capture, line_data, stream_id: str):
state = new_state()
line_start = (line_data["x1"], line_data["y1"])
line_end = (line_data["x2"], line_data["y2"])
frame_idx = 0
reset_event = get_reset_event(stream_id)
try:
while True:
ret, frame = capture.read()
if not ret:
break
frame_idx += 1
if frame_idx % 2 != 0:
continue
if reset_event.is_set():
reset_state(state)
reset_event.clear()
frame = process_frame(frame, model, names, line_start, line_end, state)
ok, buffer = cv2.imencode(".jpg", frame)
if not ok:
continue
yield (
b"--frame\r\n"
b"Content-Type: image/jpeg\r\n\r\n" + buffer.tobytes() + b"\r\n"
)
finally:
capture.release()
release_reset_event(stream_id)
def detect_objects_from_video(video_path, line_data, stream_id):
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
cap.release()
raise RuntimeError("Video konnte nicht geoeffnet werden")
return generate_frames(cap, line_data, stream_id)
# ---------------------------------------------------------------------------
# Routen
# ---------------------------------------------------------------------------
@app.route("/")
def index():
return render_template("index.html")
@app.route("/start_webcam")
def start_webcam():
get_line_from_session()
stream_id = get_webcam_stream_id()
return render_template("webcam.html", stream_id=stream_id)
@app.route("/webcam_feed")
def webcam_feed():
# Keine eigene Cam-Verbindung mehr pro Request -> Fan-out vom Grabber.
return Response(
webcam.frames(),
mimetype="multipart/x-mixed-replace; boundary=frame",
)
@app.route("/api/set_line", methods=["POST"])
def set_counting_line():
"""Setzt die Zaehllinie (gilt fuer Video-Session UND Webcam-Grabber)."""
data = request.get_json(silent=True) or {}
try:
line = {
"x1": int(data["x1"]),
"y1": int(data["y1"]),
"x2": int(data["x2"]),
"y2": int(data["y2"]),
}
except (KeyError, ValueError, TypeError):
abort(400, description="Ungueltige Linienkoordinaten")
session["counting_line"] = line
webcam.set_line(line) # Webcam nutzt eine globale Linie (eine Kamera)
return jsonify({"status": "success", "line": line})
@app.route("/api/get_line", methods=["GET"])
def get_counting_line():
return jsonify(get_line_from_session())
@app.route("/api/reset_count", methods=["POST"])
def reset_count():
data = request.get_json(silent=True) or {}
stream_id = data.get("stream_id")
if not stream_id:
abort(400, description="stream_id ist erforderlich")
valid_ids = {session.get("webcam_stream_id")}
valid_ids.update(session.get("video_stream_ids", {}).values())
valid_ids.discard(None)
if stream_id not in valid_ids:
abort(403, description="Stream gehoert nicht zur aktuellen Sitzung")
if stream_id == session.get("webcam_stream_id"):
webcam.reset()
else:
get_reset_event(stream_id).set()
return jsonify({"status": "success", "message": "Zaehler wird zurueckgesetzt"})
@app.route("/upload", methods=["POST"])
def upload_video():
if "file" not in request.files:
abort(400, description="Keine Datei erhalten")
file = request.files["file"]
if not file or file.filename == "":
abort(400, description="Keine Datei ausgewaehlt")
filename = secure_filename(file.filename)
if not filename:
abort(400, description="Ungueltiger Dateiname")
if not allowed_file(filename):
abort(400, description="Ungueltiger Dateityp")
ensure_upload_dir()
name, ext = os.path.splitext(filename)
stored_filename = f"{name}_{uuid.uuid4().hex}{ext.lower()}"
file_path = os.path.join(UPLOAD_DIR, stored_filename)
file.save(file_path)
return redirect(url_for("play_video", filename=stored_filename))
@app.route("/uploads/<filename>")
def play_video(filename):
safe_filename = os.path.basename(filename)
if safe_filename != filename:
abort(400, description="Ungueltiger Dateiname")
file_path = os.path.join(UPLOAD_DIR, safe_filename)
if not os.path.isfile(file_path):
abort(404)
get_line_from_session()
stream_id = get_video_stream_id(safe_filename)
return render_template("play_video.html", filename=safe_filename, stream_id=stream_id)
@app.route("/video/<path:filename>")
def send_video(filename):
return send_from_directory(UPLOAD_DIR, filename)
@app.route("/video_feed/<filename>")
def video_feed(filename):
safe_filename = os.path.basename(filename)
if safe_filename != filename:
abort(400, description="Ungueltiger Dateiname")
video_path = os.path.join(UPLOAD_DIR, safe_filename)
if not os.path.isfile(video_path):
abort(404)
line_data = get_line_from_session()
stream_id = get_video_stream_id(safe_filename)
try:
generator = detect_objects_from_video(video_path, line_data, stream_id)
except RuntimeError as exc:
abort(503, description=str(exc))
return Response(generator, mimetype="multipart/x-mixed-replace; boundary=frame")
if __name__ == "__main__":
app.run("0.0.0.0", debug=False, port=8080)

18
sudo
View File

@@ -1,18 +0,0 @@
[program:vehicle-counter]
directory=/home/joachim/git/vehicle-counter
command=/home/joachim/anaconda3/bin/python app.py
user=joachim
autostart=true
autorestart=true
startsecs=5
stopwaitsecs=10
stdout_logfile=/var/log/vehicle-counter.log
stderr_logfile=/var/log/vehicle-counter-error.log
stdout_logfile_maxbytes=20MB
stderr_logfile_maxbytes=20MB
stdout_logfile_backups=5
stderr_logfile_backups=5
environment=PYTHONUNBUFFERED="1"