Compare commits

...

4 commits

Author SHA1 Message Date
Jakob Lechner
40ce7946d5 Count each person just once
just once and either in or out
2025-08-02 04:19:26 +02:00
Jakob Lechner
91f4d70a77 Replace streamlit with FastAPI 2025-08-02 03:25:54 +02:00
Jakob Lechner
82263557af Fix prometheus metrics
Use a singleton to prevent attempt to rebind socket on page reload.
2025-08-02 00:01:29 +02:00
Jakob Lechner
fcfec4b6a9 Use MJPG stream instead of RTSP
as the webcams already produce MJPG, it wouldn't make sense to re-encode
the stream.
2025-08-02 00:00:39 +02:00
7 changed files with 255 additions and 91 deletions

75
main.py Normal file
View file

@ -0,0 +1,75 @@
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, StreamingResponse, PlainTextResponse
from tracker import start_all_streams, get_metrics, get_latest_frame, STREAMS
from prometheus_client import generate_latest, CONTENT_TYPE_LATEST
import time
app = FastAPI()
@app.on_event("startup")
def startup_event():
start_all_streams()
@app.get("/", response_class=HTMLResponse)
def index(request: Request):
current_stream = request.query_params.get("stream")
stream_options = "\n".join(
[
f'<option value="{stream_id}" {"selected" if stream_id == current_stream else ""}>{stream_id}</option>'
for stream_id in STREAMS.keys()
]
)
return f"""
<html>
<body>
<h1>People Counter Dashboard</h1>
<form action="/" method="get">
<label>Select Stream:</label>
<select name="stream" onchange="this.form.submit()">
{stream_options}
</select>
</form>
<img src="/video_feed/{current_stream}" />
<p><a href="/metrics">Prometheus Metrics</a></p>
</body>
</html>
"""
@app.get("/metrics")
def metrics():
return PlainTextResponse(
generate_latest(get_metrics()), media_type=CONTENT_TYPE_LATEST
)
@app.get("/video_feed/{stream_id}")
def video_feed(stream_id: str):
boundary = "--boundarydonotcross"
def generate():
last_version = time.time()
while True:
frame, version = get_latest_frame(stream_id)
if frame is not None and version > last_version:
last_version = version
print(f"yielding new frame @{version}")
yield (
boundary.encode()
+ b"\r\n"
+ b"Content-Type: image/jpeg\r\n\r\n"
+ frame
+ b"\r\n"
)
else:
# wait for new frame
time.sleep(0.05)
return StreamingResponse(
generate(), media_type=f"multipart/x-mixed-replace; boundary={boundary}"
)

28
metrics.py Normal file
View file

@ -0,0 +1,28 @@
import threading
from prometheus_client import Counter, CollectorRegistry
_lock = threading.Lock()
_registry = None
_metrics = None
def get_metrics():
global _registry, _metrics
with _lock:
if _registry is None:
_registry = CollectorRegistry()
_metrics = {
"people_in": Counter(
"people_in_count",
"Number of people who entered",
["stream"],
registry=_registry,
),
"people_out": Counter(
"people_out_count",
"Number of people who exited",
["stream"],
registry=_registry,
),
}
return _registry, _metrics

View file

@ -4,4 +4,5 @@ numpy
filterpy
scikit-image
supervision
streamlit
uvicorn
fastapi

View file

@ -1,82 +0,0 @@
import streamlit as st
import cv2
from ultralytics import YOLO
from utils.counter import Counter
from utils.zones import draw_count_line_horizontal, draw_count_line_vertical
import tempfile
import time
from prometheus_client import Gauge, start_http_server
# Nur einmal beim ersten Start starten
if "prom" not in st.session_state:
# Starte Prometheus-Metrik-Server (z.B. auf Port 9100)
start_http_server(9100)
# Metriken definieren
st.session_state.prom = {
'people_in': Gauge("people_in_count", "Number of people who entered"),
'people_out': Gauge("people_out_count", "Number of people who exited"),
}
st.set_page_config(layout="wide", page_title="People Counter")
# RTSP-Stream URL
#RTSP_URL = st.text_input("RTSP Stream URL", "rtsp://<benutzer>:<passwort>@<ip>:<port>/pfad")
RTSP_URL = "rtsp://localhost:8554/cam"
start_button = st.button("Start Counter")
FRAME_SKIP = 2 # Reduziert Verarbeitungslast
if start_button and RTSP_URL:
line_orientation = 'vertical'
line_position = 640
stframe = st.empty()
model = YOLO("yolo_weights/yolo11n.pt")
counter = Counter(line_orientation)
cap = cv2.VideoCapture(RTSP_URL)
frame_idx = 0
while cap.isOpened():
ret, frame = cap.read()
if not ret:
st.warning("Kein Bild vom RTSP-Stream.")
break
frame_idx += 1
if frame_idx % FRAME_SKIP != 0:
continue
results = model.track(frame, persist=True, classes=0, tracker="bytetrack.yaml")
boxes = results[0].boxes
if boxes.id is not None:
for box, track_id in zip(boxes.xywh.cpu(), boxes.id.cpu()):
x, y, w, h = map(int, box)
cv2.rectangle(frame, (x-w//2, y-h//2), (x+w//2, y+h//2), (0, 255, 0), 2)
cv2.putText(frame, str(track_id), (x, y - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0,255,0), 2)
tracks = [
type("Track", (), {"id": int(track_id), "bbox": box.numpy()})
for box, track_id in zip(boxes.xywh.cpu(), boxes.id.cpu())
]
counter.update(tracks, line_position)
st.session_state.prom['people_in'].set(counter.in_count)
st.session_state.prom['people_out'].set(counter.out_count)
if line_orientation == 'horizontal':
draw_count_line_horizontal(frame, line_position)
elif line_orientation == 'vertical':
draw_count_line_vertical(frame, line_position)
else:
raise NotImplementedError(f'Line orientation {line_orientation} is invalid!')
cv2.putText(frame, f"In: {counter.in_count}", (10, 40), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 255), 2)
cv2.putText(frame, f"Out: {counter.out_count}", (10, 80), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 255), 2)
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
stframe.image(frame, channels="RGB")
cap.release()

130
tracker.py Normal file
View file

@ -0,0 +1,130 @@
import cv2
import threading
import time
from ultralytics import YOLO
from utils.counter import Counter
from utils.zones import draw_count_line_vertical
from collections import defaultdict
from metrics import get_metrics
registry, metrics = get_metrics()
STREAMS = {
"Kasse 1": "http://192.168.11.76:8080?action=stream",
"Kasse 2": "http://192.168.11.230:8080?action=stream",
}
FRAME_SKIP = 2
model = YOLO("yolo_weights/yolo11n.pt")
latest_frames = {}
def process_stream(stream_id, url):
print(f"PROCESS STREAM {stream_id} from {url}")
line_orientation = "vertical"
counter = Counter(stream_id, line_orientation, metrics)
cap = cv2.VideoCapture(url)
frame_idx = 0
while cap.isOpened():
ret, frame = cap.read()
if not ret:
time.sleep(1)
continue
height, width, _ = frame.shape
frame_idx += 1
if frame_idx % FRAME_SKIP != 0:
continue
try:
results = model.track(
frame, persist=True, classes=0, tracker="bytetrack.yaml"
)
except Exception as e:
print(e)
continue
if line_orientation == "horizontal":
line_position = int(height / 2)
elif line_orientation == "vertical":
line_position = int(width / 2)
else:
raise NotImplementedError(
f"Line orientation {line_orientation} is invalid!"
)
boxes = results[0].boxes
if boxes.id is not None:
for box, track_id in zip(boxes.xywh.cpu(), boxes.id.cpu()):
x, y, w, h = map(int, box)
cv2.rectangle(
frame,
(x - w // 2, y - h // 2),
(x + w // 2, y + h // 2),
(0, 255, 0),
2,
)
cv2.putText(
frame,
str(track_id),
(x, y - 10),
cv2.FONT_HERSHEY_SIMPLEX,
0.6,
(0, 255, 0),
2,
)
tracks = [
type("Track", (), {"id": int(track_id), "bbox": box.numpy()})
for box, track_id in zip(boxes.xywh.cpu(), boxes.id.cpu())
]
counter.update(tracks, line_position)
if line_orientation == "horizontal":
draw_count_line_horizontal(frame, line_position)
elif line_orientation == "vertical":
draw_count_line_vertical(frame, line_position)
cv2.putText(
frame,
f"In: {counter.in_count}",
(10, 40),
cv2.FONT_HERSHEY_SIMPLEX,
1,
(0, 255, 255),
2,
)
cv2.putText(
frame,
f"Out: {counter.out_count}",
(10, 80),
cv2.FONT_HERSHEY_SIMPLEX,
1,
(0, 255, 255),
2,
)
_, jpeg = cv2.imencode(".jpg", frame)
latest_frames[stream_id] = (jpeg.tobytes(), time.time())
print(f"[{stream_id}] Frame captured and stored")
cap.release()
def start_all_streams():
for stream_id, url in STREAMS.items():
threading.Thread(
target=process_stream, args=(stream_id, url), daemon=True
).start()
def get_metrics():
return registry
def get_latest_frame(stream_id):
return latest_frames.get(stream_id, (None, None))

View file

@ -1,31 +1,41 @@
class Counter:
def __init__(self, line_orientation):
def __init__(self, stream_id, line_orientation, metrics):
self.in_count = 0
self.out_count = 0
self.track_memory = {}
self.line_orientation = line_orientation
self.metrics = metrics
self.stream_id = stream_id
def update(self, tracks, line_position):
for track in tracks:
track_id = track.id
x, y, w, h = track.bbox
if self.line_orientation == 'horizontal':
if self.line_orientation == "horizontal":
center = int(y + h / 2)
elif self.line_orientation == 'vertical':
elif self.line_orientation == "vertical":
center = int(x + w / 2)
else:
raise NotImplementedError(f'Line orientation {self.line_orientation} is invalid!')
raise NotImplementedError(
f"Line orientation {self.line_orientation} is invalid!"
)
if track_id not in self.track_memory:
self.track_memory[track_id] = center
self.track_memory[track_id] = [center, False]
continue
prev = self.track_memory[track_id]
self.track_memory[track_id] = center
prev = self.track_memory[track_id][0]
self.track_memory[track_id][0] = center
if self.track_memory[track_id][1]:
continue
if prev < line_position <= center:
self.in_count += 1
self.metrics["people_in"].labels(stream=self.stream_id).inc()
self.track_memory[track_id][1] = True
elif prev > line_position >= center:
self.out_count += 1
self.metrics["people_out"].labels(stream=self.stream_id).inc()
self.track_memory[track_id][1] = True

View file

@ -1,10 +1,12 @@
import cv2
def draw_count_line_horizontal(frame, y):
color = (0, 255, 255)
thickness = 2
cv2.line(frame, (0, y), (frame.shape[1], y), color, thickness)
def draw_count_line_vertical(frame, x):
color = (0, 255, 255)
thickness = 2