commit 6f735a22d797e5340f5e5dd3b93959b5c495501b Author: Jakob Lechner Date: Wed Jul 16 14:23:13 2025 +0200 initial commit diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..46731c5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +result* +*.qcow2 +.pre-commit-config.yaml +.direnv +__pycache__ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..744896c --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2025 Jakob Lechner + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..54d2d95 --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# People Counter with YOLOv8, ByteTrack, and Prometheus + +This project implements a real-time people counting system based on object detection and tracking using [YOLOv8](https://github.com/ultralytics/ultralytics) and [ByteTrack](https://github.com/ifzhang/ByteTrack). It supports **RTSP input**, **in/out counting** using a vertical line, and exposes **Prometheus metrics** for integration with monitoring systems. + +## 🚀 Features + +- 🎥 RTSP camera stream input +- 🧠 YOLOv8 person detection +- 🔁 ByteTrack object tracking +- ➕➖ In/Out counting via virtual vertical line +- 📊 Prometheus metrics at `http://localhost:9100/` +- 🌐 Streamlit UI for live video and statistics + +## Start mediamtx + +``` +mediamtx +``` + +## Stream webcam + +``` +ffmpeg \ + -vaapi_device /dev/dri/renderD128 \ + -y \ + -f v4l2 \ + -framerate 30 \ + -video_size 1280x720 \ + -input_format mjpeg \ + -i /dev/video0 \ + -vf 'format=nv12,hwupload' \ + -c:v h264_vaapi \ + -f rtsp \ + rtsp://localhost:8554/cam +``` + +## Run application +``` +streamlit run streamlit_app.py +``` diff --git a/bytetrack.yaml b/bytetrack.yaml new file mode 100644 index 0000000..b316fd9 --- /dev/null +++ b/bytetrack.yaml @@ -0,0 +1,56 @@ +tracker_type: bytetrack + +track_buffer: 30 +match_thresh: 0.8 +proximity_thresh: 0.5 +max_unmatched_frames: 5 + +conf_thres: 0.25 +low_conf_thres: 0.1 +high_conf_thres: 0.7 +iou_thres: 0.7 + +# Was ist das? +# Ein Schwellenwert für die Verfolgung von Objekten, der angibt, wie sicher die Detektion sein muss, damit sie als „hochqualitative“ Spur (Track) behandelt wird. Objekte mit Scores über diesem Wert werden als zuverlässig angesehen und aktiv verfolgt. + +# Wirkung: +# Ein höherer Wert bedeutet, dass nur sehr sichere Detektionen verfolgt werden. Das reduziert False Positives, kann aber dazu führen, dass echte Objekte bei geringerer Confidence „verloren“ gehen. + +# Typischer Bereich: +# 0.5 bis 0.9 (Confidence-Wert zwischen 0 und 1) +track_high_thresh: 0.7 + +# Was ist das? +# Ein niedrigerer Schwellenwert, der auch weniger sichere Detektionen zulässt, um diese in die Verfolgung einzubeziehen. Kann helfen, Objekte wiederzufinden, die kurzzeitig schlecht erkannt wurden. + +# Wirkung: +# Erlaubt das „Reaktivieren“ von Tracks mit etwas schlechterer Confidence. Zu niedrig kann jedoch mehr Rauschen reinbringen. + +# Typischer Bereich: +# 0.1 bis 0.4 +track_low_thresh: 0.3 + +# Was ist das? +# Gewichtungsfaktor für das Kombinieren (Fusionieren) der Scores von detektierten Bounding Boxes und der Tracking-Informationen. Dadurch wird der endgültige Score für eine Box beim Tracking berechnet. + +# Wirkung: +# Höhere Werte geben den Tracker-Informationen mehr Gewicht. Das kann die Stabilität der Verfolgung verbessern, besonders bei schwankenden Detektionen. + +# Typischer Bereich: +# 0.7 bis 0.95 +fuse_score: 0.85 + +# Was ist das? +# new_track_thresh ist der Schwellenwert (Confidence-Threshold), ab dem eine neue Spur (Track) für eine frisch erkannte Detektion gestartet wird. + +# Es bestimmt, wie sicher eine Detektion sein muss, damit sie überhaupt als neuer Track initialisiert wird. + +# Wirkung + +# Höherer Wert: +# Neue Tracks werden nur für sehr sichere Detektionen gestartet. Das verringert Fehlstarts (False Positives), aber es kann auch dazu führen, dass echte Personen, die kurz schwach erkannt werden, gar nicht als neue Tracks gestartet werden. + +# Niedriger Wert: +# Es werden auch Tracks aus weniger sicheren Detektionen gestartet, was mehr Objekte erfassen kann, aber gleichzeitig die Wahrscheinlichkeit für falsche Tracks erhöht. + +new_track_thresh: 0.6 diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..dfa985e --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1752436162, + "narHash": "sha256-Kt1UIPi7kZqkSc5HVj6UY5YLHHEzPBkgpNUByuyxtlw=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "dfcd5b901dbab46c9c6e80b265648481aafb01f8", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..5b60419 --- /dev/null +++ b/flake.nix @@ -0,0 +1,40 @@ +{ + description = "People Counter with YOLOv8, ByteTrack and Streamlit"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { nixpkgs, flake-utils, ... }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + + pythonEnv = pkgs.python313.withPackages (ps: with ps; [ + numpy + opencv-python + streamlit + pyyaml + pillow + ultralytics + torch + torchvision + prometheus-client + ]); + in { + devShell = pkgs.mkShell { + buildInputs = [ + pythonEnv + pkgs.ffmpeg + pkgs.mediamtx + ]; + + shellHook = '' + export PYTHONUNBUFFERED=1 + export MTX_PATHS_ALL_SOURCE=publisher + echo "People Counter DevShell ready" + ''; + }; + }); +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6826106 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +ultralytics==8.3.130 +opencv-python-headless +numpy +filterpy +scikit-image +supervision +streamlit diff --git a/streamlit_app.py b/streamlit_app.py new file mode 100644 index 0000000..6b446db --- /dev/null +++ b/streamlit_app.py @@ -0,0 +1,82 @@ +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://:@:/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() diff --git a/utils/counter.py b/utils/counter.py new file mode 100644 index 0000000..f0544a9 --- /dev/null +++ b/utils/counter.py @@ -0,0 +1,31 @@ +class Counter: + def __init__(self, line_orientation): + self.in_count = 0 + self.out_count = 0 + self.track_memory = {} + self.line_orientation = line_orientation + + 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': + center = int(y + h / 2) + elif self.line_orientation == 'vertical': + center = int(x + w / 2) + else: + raise NotImplementedError(f'Line orientation {self.line_orientation} is invalid!') + + if track_id not in self.track_memory: + self.track_memory[track_id] = center + continue + + prev = self.track_memory[track_id] + self.track_memory[track_id] = center + + if prev < line_position <= center: + self.in_count += 1 + elif prev > line_position >= center: + self.out_count += 1 diff --git a/utils/zones.py b/utils/zones.py new file mode 100644 index 0000000..4878b21 --- /dev/null +++ b/utils/zones.py @@ -0,0 +1,11 @@ +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 + cv2.line(frame, (x, 0), (x, frame.shape[0]), color, thickness) diff --git a/yolo_weights/yolo11n.pt b/yolo_weights/yolo11n.pt new file mode 100644 index 0000000..45b273b Binary files /dev/null and b/yolo_weights/yolo11n.pt differ