Spaces:
Sleeping
Sleeping
Upload 8 files
Browse files- .streamlit/New Text Document.txt +0 -0
- Dockerfile +26 -0
- README.md +14 -5
- app.py +146 -0
- detector.py +233 -0
- requirements.txt +5 -0
- theme.py +293 -0
- webcam_stub.py +56 -0
.streamlit/New Text Document.txt
ADDED
|
File without changes
|
Dockerfile
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dockerfile for Hugging Face Spaces (Docker SDK).
|
| 2 |
+
# Hugging Face Spaces no longer supports Streamlit as a native built-in SDK,
|
| 3 |
+
# so Streamlit apps run inside a small Docker container instead. This file
|
| 4 |
+
# does exactly what "streamlit run app.py" does locally, just packaged so
|
| 5 |
+
# Hugging Face knows how to build and start it.
|
| 6 |
+
|
| 7 |
+
FROM python:3.10-slim
|
| 8 |
+
|
| 9 |
+
WORKDIR /app
|
| 10 |
+
|
| 11 |
+
# System libraries OpenCV sometimes needs even in "headless" mode.
|
| 12 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 13 |
+
libgl1 \
|
| 14 |
+
libglib2.0-0 \
|
| 15 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 16 |
+
|
| 17 |
+
COPY requirements.txt .
|
| 18 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 19 |
+
|
| 20 |
+
COPY . .
|
| 21 |
+
|
| 22 |
+
# Hugging Face Spaces expects the app to listen on port 7860 by default
|
| 23 |
+
# for Docker-based Spaces (set via app_port in the Space's README.md).
|
| 24 |
+
EXPOSE 7860
|
| 25 |
+
|
| 26 |
+
CMD ["streamlit", "run", "app.py", "--server.port=7860", "--server.address=0.0.0.0"]
|
README.md
CHANGED
|
@@ -1,10 +1,19 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Traffic Scene Interpretation System
|
| 3 |
+
emoji: 🚦
|
| 4 |
+
colorFrom: yellow
|
| 5 |
+
colorTo: gray
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# Traffic Scene Interpretation System
|
| 12 |
+
|
| 13 |
+
A Streamlit application that uses YOLOv8 to detect vehicles and pedestrians
|
| 14 |
+
in traffic images and video, and reports a basic interpretation of the scene
|
| 15 |
+
(free flowing / moderate / congested).
|
| 16 |
+
|
| 17 |
+
Upload an image or a short video clip in the tabs above to try it.
|
| 18 |
+
|
| 19 |
+
Built with Streamlit, Ultralytics YOLOv8, and OpenCV.
|
app.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
app.py
|
| 3 |
+
------
|
| 4 |
+
Traffic Scene Interpretation System — Streamlit front end.
|
| 5 |
+
|
| 6 |
+
Scope for this build (agreed scope):
|
| 7 |
+
- Image upload -> detection
|
| 8 |
+
- Video upload -> detection
|
| 9 |
+
- Vehicle counting + basic scene interpretation (congestion label)
|
| 10 |
+
- Download annotated result
|
| 11 |
+
|
| 12 |
+
Webcam mode is intentionally left out of this version — image + video
|
| 13 |
+
upload was confirmed to be sufficient for the supervisor's requirements.
|
| 14 |
+
A `webcam_stub.py` stub is included separately with notes on how to add it
|
| 15 |
+
later if needed, so it doesn't block delivery of the core system.
|
| 16 |
+
|
| 17 |
+
Run with:
|
| 18 |
+
streamlit run app.py
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
import os
|
| 22 |
+
import tempfile
|
| 23 |
+
|
| 24 |
+
import cv2
|
| 25 |
+
import numpy as np
|
| 26 |
+
import streamlit as st
|
| 27 |
+
from PIL import Image
|
| 28 |
+
|
| 29 |
+
import theme
|
| 30 |
+
from detector import TrafficDetector
|
| 31 |
+
|
| 32 |
+
st.set_page_config(page_title="Traffic Scene Interpretation System", page_icon="🚦", layout="wide")
|
| 33 |
+
theme.inject()
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@st.cache_resource(show_spinner="Loading YOLO model (first run only)...")
|
| 37 |
+
def load_detector() -> TrafficDetector:
|
| 38 |
+
return TrafficDetector()
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def render_stats(stats, cam_tag: str):
|
| 42 |
+
avg_counts = stats.per_frame_average()
|
| 43 |
+
theme.chip_row(avg_counts)
|
| 44 |
+
|
| 45 |
+
col1, col2 = st.columns([1, 1])
|
| 46 |
+
with col1:
|
| 47 |
+
theme.congestion_badge(stats.congestion_label())
|
| 48 |
+
with col2:
|
| 49 |
+
if stats.fps:
|
| 50 |
+
st.caption(f"PROCESSING SPEED · {stats.fps} FPS")
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def image_tab(detector: TrafficDetector):
|
| 54 |
+
uploaded = st.file_uploader("Upload an image", type=["jpg", "jpeg", "png"], key="img")
|
| 55 |
+
|
| 56 |
+
if uploaded is not None:
|
| 57 |
+
pil_image = Image.open(uploaded).convert("RGB")
|
| 58 |
+
bgr_image = cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR)
|
| 59 |
+
|
| 60 |
+
with st.spinner("Running detection..."):
|
| 61 |
+
annotated, stats = detector.detect_image(bgr_image)
|
| 62 |
+
|
| 63 |
+
annotated_rgb = cv2.cvtColor(annotated, cv2.COLOR_BGR2RGB)
|
| 64 |
+
|
| 65 |
+
col1, col2 = st.columns(2)
|
| 66 |
+
with col1:
|
| 67 |
+
with st.container(border=True):
|
| 68 |
+
theme.feed_caption("CAM 01", "RAW FEED")
|
| 69 |
+
st.image(pil_image, use_container_width=True)
|
| 70 |
+
with col2:
|
| 71 |
+
with st.container(border=True):
|
| 72 |
+
theme.feed_caption("CAM 01", "DETECTION OVERLAY")
|
| 73 |
+
st.image(annotated_rgb, use_container_width=True)
|
| 74 |
+
|
| 75 |
+
st.markdown('<hr class="tsis-hr">', unsafe_allow_html=True)
|
| 76 |
+
|
| 77 |
+
with st.container(border=True):
|
| 78 |
+
theme.feed_caption("SUMMARY", "DETECTION READOUT")
|
| 79 |
+
render_stats(stats, "CAM 01")
|
| 80 |
+
|
| 81 |
+
result_pil = Image.fromarray(annotated_rgb)
|
| 82 |
+
buf_path = os.path.join(tempfile.gettempdir(), "annotated_result.png")
|
| 83 |
+
result_pil.save(buf_path)
|
| 84 |
+
with open(buf_path, "rb") as f:
|
| 85 |
+
st.download_button("Download result image", f, file_name="detection_result.png")
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def video_tab(detector: TrafficDetector):
|
| 89 |
+
uploaded = st.file_uploader("Upload a video", type=["mp4", "avi", "mov", "mkv"], key="vid")
|
| 90 |
+
|
| 91 |
+
if uploaded is not None:
|
| 92 |
+
# Save upload to a temp file since OpenCV needs a real file path
|
| 93 |
+
in_path = os.path.join(tempfile.gettempdir(), f"input_{uploaded.name}")
|
| 94 |
+
out_path = os.path.join(tempfile.gettempdir(), "annotated_output.mp4")
|
| 95 |
+
with open(in_path, "wb") as f:
|
| 96 |
+
f.write(uploaded.read())
|
| 97 |
+
|
| 98 |
+
progress_bar = st.progress(0, text="Starting...")
|
| 99 |
+
|
| 100 |
+
def update_progress(current, total):
|
| 101 |
+
if total:
|
| 102 |
+
progress_bar.progress(min(current / total, 1.0), text=f"Processing frame {current}/{total}")
|
| 103 |
+
else:
|
| 104 |
+
progress_bar.progress(0, text=f"Processing frame {current}")
|
| 105 |
+
|
| 106 |
+
with st.spinner("Running detection on video... this can take a while for longer clips."):
|
| 107 |
+
stats = detector.detect_video(in_path, out_path, progress_callback=update_progress)
|
| 108 |
+
|
| 109 |
+
progress_bar.empty()
|
| 110 |
+
|
| 111 |
+
with st.container(border=True):
|
| 112 |
+
theme.feed_caption("CAM 02", "DETECTION OVERLAY")
|
| 113 |
+
st.video(out_path)
|
| 114 |
+
|
| 115 |
+
st.markdown('<hr class="tsis-hr">', unsafe_allow_html=True)
|
| 116 |
+
|
| 117 |
+
with st.container(border=True):
|
| 118 |
+
theme.feed_caption("SUMMARY", "DETECTION READOUT")
|
| 119 |
+
render_stats(stats, "CAM 02")
|
| 120 |
+
|
| 121 |
+
with open(out_path, "rb") as f:
|
| 122 |
+
st.download_button("Download result video", f, file_name="detection_result.mp4")
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def main():
|
| 126 |
+
theme.masthead()
|
| 127 |
+
theme.hero(
|
| 128 |
+
eyebrow="VEHICLE DETECTION · SCENE ANALYSIS",
|
| 129 |
+
title="Traffic Scene Interpretation System",
|
| 130 |
+
subtitle=(
|
| 131 |
+
"Upload footage from a traffic camera to detect vehicles and pedestrians, "
|
| 132 |
+
"count them by class, and read the overall state of the scene."
|
| 133 |
+
),
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
detector = load_detector()
|
| 137 |
+
|
| 138 |
+
tab1, tab2 = st.tabs(["Image feed", "Video feed"])
|
| 139 |
+
with tab1:
|
| 140 |
+
image_tab(detector)
|
| 141 |
+
with tab2:
|
| 142 |
+
video_tab(detector)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
if __name__ == "__main__":
|
| 146 |
+
main()
|
detector.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
detector.py
|
| 3 |
+
-----------
|
| 4 |
+
Core AI module for the Traffic Scene Interpretation System.
|
| 5 |
+
|
| 6 |
+
Responsibilities:
|
| 7 |
+
- Load a YOLO model (Ultralytics) once and reuse it.
|
| 8 |
+
- Run detection on a single image (numpy array / PIL image).
|
| 9 |
+
- Run detection on a video file, frame by frame, and write an annotated
|
| 10 |
+
output video.
|
| 11 |
+
- Aggregate per-frame detections into simple traffic-scene statistics
|
| 12 |
+
(vehicle counts, congestion level) — this is the "scene interpretation"
|
| 13 |
+
layer on top of raw object detection.
|
| 14 |
+
|
| 15 |
+
Kept deliberately simple and well-commented so it's easy to explain
|
| 16 |
+
during a project defense.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import time
|
| 22 |
+
from collections import Counter
|
| 23 |
+
from dataclasses import dataclass, field
|
| 24 |
+
|
| 25 |
+
import cv2
|
| 26 |
+
import numpy as np
|
| 27 |
+
from ultralytics import YOLO
|
| 28 |
+
|
| 29 |
+
# ---------------------------------------------------------------------------
|
| 30 |
+
# Config
|
| 31 |
+
# ---------------------------------------------------------------------------
|
| 32 |
+
|
| 33 |
+
# Classes from the COCO dataset (what pretrained YOLO already knows) that are
|
| 34 |
+
# relevant to a traffic scene. No custom training needed for the MVP.
|
| 35 |
+
VEHICLE_CLASSES = {
|
| 36 |
+
"car": "Cars",
|
| 37 |
+
"bus": "Buses",
|
| 38 |
+
"truck": "Trucks",
|
| 39 |
+
"motorcycle": "Motorcycles",
|
| 40 |
+
"bicycle": "Bicycles",
|
| 41 |
+
"person": "Pedestrians",
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
# Thresholds used to translate a raw vehicle count into a human-readable
|
| 45 |
+
# "scene interpretation" label. Tune these once you see real results.
|
| 46 |
+
CONGESTION_THRESHOLDS = {
|
| 47 |
+
"free": 5, # 0-5 vehicles -> Free flowing
|
| 48 |
+
"moderate": 15, # 6-15 vehicles -> Moderate traffic
|
| 49 |
+
# >15 vehicles -> Congested
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
IMAGE_MODEL = "yolov8m.pt" # Used for single-image detection. Accuracy matters more than
|
| 53 |
+
# speed here since it only runs once per upload.
|
| 54 |
+
VIDEO_MODEL = "yolov8m.pt" # Bumped up from yolov8s for better accuracy, per testing feedback.
|
| 55 |
+
# This is noticeably slower (roughly 2-3x the compute of yolov8s).
|
| 56 |
+
# Frame skipping + reduced imgsz below help offset that cost.
|
| 57 |
+
# If processing time becomes uncomfortable, drop back to "yolov8s.pt".
|
| 58 |
+
VIDEO_IMGSZ = 480 # Shrinking the frame before detection speeds video up further.
|
| 59 |
+
# Lower = faster but less accurate on small/distant objects.
|
| 60 |
+
# Try 384 if still too slow; try 640 (native) if you have room to spare.
|
| 61 |
+
VIDEO_FRAME_SKIP = 2 # Run detection on 1 out of every N frames; reuse the previous
|
| 62 |
+
# frame's boxes for the skipped ones. 2 = run detection on half
|
| 63 |
+
# the frames (~2x faster). Set to 1 to disable (detect every frame).
|
| 64 |
+
CONFIDENCE_THRESHOLD = 0.25 # Lowered from 0.35 to catch smaller/more distant vehicles.
|
| 65 |
+
# If you start seeing false detections (boxes on things that aren't
|
| 66 |
+
# vehicles), raise this back up toward 0.35-0.4.
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
@dataclass
|
| 70 |
+
class SceneStats:
|
| 71 |
+
"""Aggregated statistics for one image or one video."""
|
| 72 |
+
counts: Counter = field(default_factory=Counter)
|
| 73 |
+
total_frames: int = 1
|
| 74 |
+
fps: float = 0.0
|
| 75 |
+
|
| 76 |
+
def per_frame_average(self) -> Counter:
|
| 77 |
+
if self.total_frames <= 0:
|
| 78 |
+
return self.counts
|
| 79 |
+
return Counter({k: round(v / self.total_frames, 1) for k, v in self.counts.items()})
|
| 80 |
+
|
| 81 |
+
def congestion_label(self) -> str:
|
| 82 |
+
vehicle_count = sum(
|
| 83 |
+
v for k, v in self.per_frame_average().items() if k != "Pedestrians"
|
| 84 |
+
)
|
| 85 |
+
if vehicle_count <= CONGESTION_THRESHOLDS["free"]:
|
| 86 |
+
return "Free flowing"
|
| 87 |
+
elif vehicle_count <= CONGESTION_THRESHOLDS["moderate"]:
|
| 88 |
+
return "Moderate traffic"
|
| 89 |
+
else:
|
| 90 |
+
return "Congested"
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
class TrafficDetector:
|
| 94 |
+
"""
|
| 95 |
+
Wraps two YOLO models:
|
| 96 |
+
- self.image_model: larger/more accurate, used for single-image detection.
|
| 97 |
+
- self.video_model: smaller/faster, used for video (and would be used for
|
| 98 |
+
webcam too, if that's added later) since it runs once per frame.
|
| 99 |
+
Both are loaded once at startup and reused.
|
| 100 |
+
"""
|
| 101 |
+
|
| 102 |
+
def __init__(
|
| 103 |
+
self,
|
| 104 |
+
image_model_path: str = IMAGE_MODEL,
|
| 105 |
+
video_model_path: str = VIDEO_MODEL,
|
| 106 |
+
conf: float = CONFIDENCE_THRESHOLD,
|
| 107 |
+
):
|
| 108 |
+
self.image_model = YOLO(image_model_path)
|
| 109 |
+
# Avoid loading the same weights twice if someone sets both to the same file.
|
| 110 |
+
self.video_model = (
|
| 111 |
+
self.image_model if video_model_path == image_model_path else YOLO(video_model_path)
|
| 112 |
+
)
|
| 113 |
+
self.conf = conf
|
| 114 |
+
|
| 115 |
+
# -- Image -------------------------------------------------------------
|
| 116 |
+
|
| 117 |
+
def detect_image(self, image: np.ndarray) -> tuple[np.ndarray, SceneStats]:
|
| 118 |
+
"""
|
| 119 |
+
Run detection on a single BGR image (as read by cv2).
|
| 120 |
+
Returns (annotated_image, stats).
|
| 121 |
+
"""
|
| 122 |
+
results = self.image_model.predict(image, conf=self.conf, verbose=False)
|
| 123 |
+
result = results[0]
|
| 124 |
+
|
| 125 |
+
counts = self._count_from_result(result)
|
| 126 |
+
annotated = result.plot() # draws boxes + labels + confidence
|
| 127 |
+
|
| 128 |
+
stats = SceneStats(counts=counts, total_frames=1)
|
| 129 |
+
return annotated, stats
|
| 130 |
+
|
| 131 |
+
# -- Video ---------------------------------------------------------------
|
| 132 |
+
|
| 133 |
+
def detect_video(self, input_path: str, output_path: str, progress_callback=None) -> SceneStats:
|
| 134 |
+
"""
|
| 135 |
+
Process a video file frame-by-frame:
|
| 136 |
+
read frame -> YOLO detect -> draw boxes -> write frame to output.
|
| 137 |
+
Uses the faster video_model + a reduced inference size (VIDEO_IMGSZ) to
|
| 138 |
+
keep processing time reasonable without a GPU.
|
| 139 |
+
|
| 140 |
+
Frame skipping (VIDEO_FRAME_SKIP): to save time, detection only runs on
|
| 141 |
+
every Nth frame. For the frames in between, we reuse the last detected
|
| 142 |
+
boxes and re-draw them onto the new frame. Since consecutive frames are
|
| 143 |
+
1/25th-1/30th of a second apart, objects barely move between them, so
|
| 144 |
+
this looks smooth while cutting detection calls (the expensive part)
|
| 145 |
+
roughly in half.
|
| 146 |
+
|
| 147 |
+
progress_callback(current_frame, total_frames) is called after each
|
| 148 |
+
frame if provided, so a Streamlit progress bar can be updated.
|
| 149 |
+
"""
|
| 150 |
+
cap = cv2.VideoCapture(input_path)
|
| 151 |
+
if not cap.isOpened():
|
| 152 |
+
raise RuntimeError(f"Could not open video: {input_path}")
|
| 153 |
+
|
| 154 |
+
fps = cap.get(cv2.CAP_PROP_FPS) or 25.0
|
| 155 |
+
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
| 156 |
+
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
| 157 |
+
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) or None
|
| 158 |
+
|
| 159 |
+
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
|
| 160 |
+
writer = cv2.VideoWriter(output_path, fourcc, fps, (width, height))
|
| 161 |
+
|
| 162 |
+
total_counts: Counter = Counter()
|
| 163 |
+
frame_idx = 0
|
| 164 |
+
detected_frame_count = 0 # frames actually run through YOLO (for stats averaging)
|
| 165 |
+
start_time = time.time()
|
| 166 |
+
|
| 167 |
+
last_result = None # cached YOLO result, reused on skipped frames
|
| 168 |
+
|
| 169 |
+
while True:
|
| 170 |
+
ok, frame = cap.read()
|
| 171 |
+
if not ok:
|
| 172 |
+
break
|
| 173 |
+
|
| 174 |
+
run_detection = (frame_idx % VIDEO_FRAME_SKIP == 0) or (last_result is None)
|
| 175 |
+
|
| 176 |
+
if run_detection:
|
| 177 |
+
results = self.video_model.predict(frame, conf=self.conf, imgsz=VIDEO_IMGSZ, verbose=False)
|
| 178 |
+
last_result = results[0]
|
| 179 |
+
total_counts.update(self._count_from_result(last_result))
|
| 180 |
+
detected_frame_count += 1
|
| 181 |
+
annotated = last_result.plot()
|
| 182 |
+
else:
|
| 183 |
+
# Re-draw the previous frame's boxes onto the current frame.
|
| 184 |
+
# plot(img=...) lets us reuse a YOLO result's boxes on a new image.
|
| 185 |
+
annotated = last_result.plot(img=frame)
|
| 186 |
+
|
| 187 |
+
writer.write(annotated)
|
| 188 |
+
|
| 189 |
+
frame_idx += 1
|
| 190 |
+
if progress_callback:
|
| 191 |
+
progress_callback(frame_idx, total_frames)
|
| 192 |
+
|
| 193 |
+
cap.release()
|
| 194 |
+
writer.release()
|
| 195 |
+
|
| 196 |
+
elapsed = max(time.time() - start_time, 1e-6)
|
| 197 |
+
processing_fps = frame_idx / elapsed
|
| 198 |
+
|
| 199 |
+
return SceneStats(
|
| 200 |
+
counts=total_counts,
|
| 201 |
+
total_frames=max(detected_frame_count, 1),
|
| 202 |
+
fps=round(processing_fps, 1),
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
# -- Webcam (single-frame step, called repeatedly by the UI layer) -----
|
| 206 |
+
|
| 207 |
+
def detect_frame(self, frame: np.ndarray) -> tuple[np.ndarray, Counter]:
|
| 208 |
+
"""
|
| 209 |
+
Used for live webcam mode: process exactly one frame and return it
|
| 210 |
+
annotated, plus its own counts. Uses the fast video_model, same
|
| 211 |
+
reasoning as detect_video above. The caller (app.py) is responsible
|
| 212 |
+
for the capture loop, since Streamlit needs to own that loop to
|
| 213 |
+
keep the UI responsive.
|
| 214 |
+
"""
|
| 215 |
+
results = self.video_model.predict(frame, conf=self.conf, imgsz=VIDEO_IMGSZ, verbose=False)
|
| 216 |
+
result = results[0]
|
| 217 |
+
counts = self._count_from_result(result)
|
| 218 |
+
return result.plot(), counts
|
| 219 |
+
|
| 220 |
+
# -- Helpers -------------------------------------------------------------
|
| 221 |
+
|
| 222 |
+
def _count_from_result(self, result) -> Counter:
|
| 223 |
+
"""Turn one YOLO result into a Counter of {readable_label: count}."""
|
| 224 |
+
counts: Counter = Counter()
|
| 225 |
+
names = result.names
|
| 226 |
+
if result.boxes is None:
|
| 227 |
+
return counts
|
| 228 |
+
for cls_id in result.boxes.cls.tolist():
|
| 229 |
+
raw_name = names[int(cls_id)]
|
| 230 |
+
label = VEHICLE_CLASSES.get(raw_name)
|
| 231 |
+
if label:
|
| 232 |
+
counts[label] += 1
|
| 233 |
+
return counts
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
streamlit>=1.35
|
| 2 |
+
ultralytics>=8.2
|
| 3 |
+
opencv-python-headless>=4.9
|
| 4 |
+
numpy>=1.26
|
| 5 |
+
Pillow>=10.0
|
theme.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
theme.py
|
| 3 |
+
--------
|
| 4 |
+
Visual design system for the Traffic Scene Interpretation System UI.
|
| 5 |
+
|
| 6 |
+
Design direction: a dark traffic-monitoring "control room" console rather
|
| 7 |
+
than a generic AI demo page. Colors are drawn from real traffic signals
|
| 8 |
+
(green/amber/red) so the congestion badge means something, not just
|
| 9 |
+
decorates. Stat readouts use a monospace face to evoke CCTV on-screen
|
| 10 |
+
text. Panels are captioned like camera feeds (e.g. "CAM 01 · RAW").
|
| 11 |
+
|
| 12 |
+
Token system:
|
| 13 |
+
Color bg-primary #0B0E13, bg-panel #141A22, border #232B35,
|
| 14 |
+
accent-amber #FFB020, signal-green #35D399, signal-red #FF5C5C,
|
| 15 |
+
text-primary #E8ECF1, text-muted #8B95A3
|
| 16 |
+
Type Display: Space Grotesk · Body: Inter · Data/mono: IBM Plex Mono
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import streamlit as st
|
| 20 |
+
|
| 21 |
+
CSS = """
|
| 22 |
+
<style>
|
| 23 |
+
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500;600&display=swap');
|
| 24 |
+
|
| 25 |
+
:root {
|
| 26 |
+
--bg-primary: #0B0E13;
|
| 27 |
+
--bg-panel: #141A22;
|
| 28 |
+
--bg-panel-alt: #10151C;
|
| 29 |
+
--border-hairline: #232B35;
|
| 30 |
+
--accent-amber: #FFB020;
|
| 31 |
+
--signal-green: #35D399;
|
| 32 |
+
--signal-red: #FF5C5C;
|
| 33 |
+
--text-primary: #E8ECF1;
|
| 34 |
+
--text-muted: #8B95A3;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
/* Base typography */
|
| 38 |
+
html, body, [class*="css"] {
|
| 39 |
+
font-family: 'Inter', sans-serif;
|
| 40 |
+
color: var(--text-primary);
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
/* Tighten default Streamlit top padding, but leave room for Streamlit's own
|
| 44 |
+
fixed toolbar (Deploy button etc.) so our masthead doesn't sit underneath it */
|
| 45 |
+
.block-container {
|
| 46 |
+
padding-top: 3.5rem;
|
| 47 |
+
max-width: 1100px;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
/* ---------------- Masthead ---------------- */
|
| 51 |
+
.tsis-masthead {
|
| 52 |
+
display: flex;
|
| 53 |
+
align-items: center;
|
| 54 |
+
padding: 0.6rem 1rem;
|
| 55 |
+
background: var(--bg-panel-alt);
|
| 56 |
+
border: 1px solid var(--border-hairline);
|
| 57 |
+
border-radius: 4px;
|
| 58 |
+
font-family: 'IBM Plex Mono', monospace;
|
| 59 |
+
font-size: 0.75rem;
|
| 60 |
+
letter-spacing: 0.04em;
|
| 61 |
+
color: var(--text-muted);
|
| 62 |
+
margin-bottom: 1.75rem;
|
| 63 |
+
}
|
| 64 |
+
.tsis-masthead .tsis-status {
|
| 65 |
+
color: var(--signal-green);
|
| 66 |
+
display: flex;
|
| 67 |
+
align-items: center;
|
| 68 |
+
gap: 0.4rem;
|
| 69 |
+
}
|
| 70 |
+
.tsis-status-dot {
|
| 71 |
+
width: 7px;
|
| 72 |
+
height: 7px;
|
| 73 |
+
border-radius: 50%;
|
| 74 |
+
background: var(--signal-green);
|
| 75 |
+
box-shadow: 0 0 6px var(--signal-green);
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
/* ---------------- Hero ---------------- */
|
| 79 |
+
.tsis-eyebrow {
|
| 80 |
+
font-family: 'IBM Plex Mono', monospace;
|
| 81 |
+
font-size: 0.72rem;
|
| 82 |
+
letter-spacing: 0.12em;
|
| 83 |
+
text-transform: uppercase;
|
| 84 |
+
color: var(--accent-amber);
|
| 85 |
+
margin-bottom: 0.4rem;
|
| 86 |
+
}
|
| 87 |
+
.tsis-title {
|
| 88 |
+
font-family: 'Space Grotesk', sans-serif;
|
| 89 |
+
font-weight: 700;
|
| 90 |
+
font-size: 2.1rem;
|
| 91 |
+
line-height: 1.15;
|
| 92 |
+
color: var(--text-primary);
|
| 93 |
+
margin-bottom: 0.5rem;
|
| 94 |
+
}
|
| 95 |
+
.tsis-subtitle {
|
| 96 |
+
font-family: 'Inter', sans-serif;
|
| 97 |
+
font-size: 0.95rem;
|
| 98 |
+
color: var(--text-muted);
|
| 99 |
+
max-width: 640px;
|
| 100 |
+
margin-bottom: 2rem;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
/* ---------------- Feed panel captions ---------------- */
|
| 104 |
+
.tsis-feed-caption {
|
| 105 |
+
font-family: 'IBM Plex Mono', monospace;
|
| 106 |
+
font-size: 0.7rem;
|
| 107 |
+
letter-spacing: 0.08em;
|
| 108 |
+
text-transform: uppercase;
|
| 109 |
+
color: var(--text-muted);
|
| 110 |
+
border-bottom: 1px solid var(--border-hairline);
|
| 111 |
+
padding-bottom: 0.4rem;
|
| 112 |
+
margin-bottom: 0.6rem;
|
| 113 |
+
}
|
| 114 |
+
.tsis-feed-caption .tsis-tag {
|
| 115 |
+
color: var(--accent-amber);
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
/* ---------------- Bordered containers (image/summary panels) ---------------- */
|
| 119 |
+
[data-testid="stVerticalBlockBorderWrapper"] {
|
| 120 |
+
background: var(--bg-panel);
|
| 121 |
+
border: 1px solid var(--border-hairline) !important;
|
| 122 |
+
border-radius: 6px !important;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
/* ---------------- Tabs styled like a camera/source selector ---------------- */
|
| 126 |
+
[data-testid="stTabs"] button [data-testid="stMarkdownContainer"] p {
|
| 127 |
+
font-family: 'IBM Plex Mono', monospace;
|
| 128 |
+
font-size: 0.8rem;
|
| 129 |
+
letter-spacing: 0.06em;
|
| 130 |
+
text-transform: uppercase;
|
| 131 |
+
}
|
| 132 |
+
[data-testid="stTabs"] [data-baseweb="tab-list"] {
|
| 133 |
+
gap: 1.75rem;
|
| 134 |
+
border-bottom: 1px solid var(--border-hairline);
|
| 135 |
+
}
|
| 136 |
+
[data-testid="stTabs"] button {
|
| 137 |
+
padding: 0.5rem 0.15rem !important;
|
| 138 |
+
}
|
| 139 |
+
[data-testid="stTabs"] [aria-selected="true"] {
|
| 140 |
+
color: var(--accent-amber) !important;
|
| 141 |
+
}
|
| 142 |
+
/* The active-tab underline is a separate element from the tab button itself;
|
| 143 |
+
force its color explicitly instead of relying on the theme's primaryColor
|
| 144 |
+
cascading down (it doesn't reliably). */
|
| 145 |
+
[data-testid="stTabs"] [data-baseweb="tab-highlight"] {
|
| 146 |
+
background-color: var(--accent-amber) !important;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
/* ---------------- File uploader ---------------- */
|
| 150 |
+
[data-testid="stFileUploaderDropzone"] {
|
| 151 |
+
background: var(--bg-panel-alt);
|
| 152 |
+
border: 1px dashed var(--border-hairline) !important;
|
| 153 |
+
border-radius: 6px;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
/* ---------------- Metrics as instrument readouts ---------------- */
|
| 157 |
+
[data-testid="stMetric"] {
|
| 158 |
+
background: var(--bg-panel-alt);
|
| 159 |
+
border: 1px solid var(--border-hairline);
|
| 160 |
+
border-radius: 6px;
|
| 161 |
+
padding: 0.75rem 1rem;
|
| 162 |
+
}
|
| 163 |
+
[data-testid="stMetricLabel"] {
|
| 164 |
+
font-family: 'IBM Plex Mono', monospace;
|
| 165 |
+
font-size: 0.68rem;
|
| 166 |
+
letter-spacing: 0.08em;
|
| 167 |
+
text-transform: uppercase;
|
| 168 |
+
color: var(--text-muted) !important;
|
| 169 |
+
}
|
| 170 |
+
[data-testid="stMetricValue"] {
|
| 171 |
+
font-family: 'IBM Plex Mono', monospace;
|
| 172 |
+
color: var(--text-primary) !important;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
/* ---------------- Stat chips (vehicle counts) ---------------- */
|
| 176 |
+
.tsis-chip-row { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0.75rem; }
|
| 177 |
+
.tsis-chip {
|
| 178 |
+
font-family: 'IBM Plex Mono', monospace;
|
| 179 |
+
font-size: 0.78rem;
|
| 180 |
+
background: var(--bg-panel-alt);
|
| 181 |
+
border: 1px solid var(--border-hairline);
|
| 182 |
+
border-radius: 4px;
|
| 183 |
+
padding: 0.3rem 0.65rem;
|
| 184 |
+
color: var(--text-primary);
|
| 185 |
+
}
|
| 186 |
+
.tsis-chip .tsis-chip-label { color: var(--text-muted); margin-right: 0.35rem; }
|
| 187 |
+
|
| 188 |
+
/* ---------------- Congestion badge (traffic-signal colors) ---------------- */
|
| 189 |
+
.tsis-badge {
|
| 190 |
+
display: inline-flex;
|
| 191 |
+
align-items: center;
|
| 192 |
+
gap: 0.5rem;
|
| 193 |
+
font-family: 'IBM Plex Mono', monospace;
|
| 194 |
+
font-size: 0.85rem;
|
| 195 |
+
font-weight: 500;
|
| 196 |
+
padding: 0.45rem 0.85rem;
|
| 197 |
+
border-radius: 4px;
|
| 198 |
+
border: 1px solid var(--border-hairline);
|
| 199 |
+
background: var(--bg-panel-alt);
|
| 200 |
+
}
|
| 201 |
+
.tsis-badge-dot { width: 9px; height: 9px; border-radius: 50%; }
|
| 202 |
+
.tsis-badge-free .tsis-badge-dot { background: var(--signal-green); box-shadow: 0 0 6px var(--signal-green); }
|
| 203 |
+
.tsis-badge-free { color: var(--signal-green); }
|
| 204 |
+
.tsis-badge-moderate .tsis-badge-dot { background: var(--accent-amber); box-shadow: 0 0 6px var(--accent-amber); }
|
| 205 |
+
.tsis-badge-moderate { color: var(--accent-amber); }
|
| 206 |
+
.tsis-badge-congested .tsis-badge-dot { background: var(--signal-red); box-shadow: 0 0 6px var(--signal-red); }
|
| 207 |
+
.tsis-badge-congested { color: var(--signal-red); }
|
| 208 |
+
|
| 209 |
+
/* ---------------- Buttons ---------------- */
|
| 210 |
+
.stButton > button, .stDownloadButton > button {
|
| 211 |
+
font-family: 'IBM Plex Mono', monospace;
|
| 212 |
+
font-size: 0.78rem;
|
| 213 |
+
letter-spacing: 0.04em;
|
| 214 |
+
text-transform: uppercase;
|
| 215 |
+
background: transparent;
|
| 216 |
+
border: 1px solid var(--accent-amber);
|
| 217 |
+
color: var(--accent-amber);
|
| 218 |
+
border-radius: 4px;
|
| 219 |
+
}
|
| 220 |
+
.stButton > button:hover, .stDownloadButton > button:hover {
|
| 221 |
+
background: var(--accent-amber);
|
| 222 |
+
color: var(--bg-primary);
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
/* Section divider */
|
| 226 |
+
.tsis-hr {
|
| 227 |
+
border: none;
|
| 228 |
+
border-top: 1px solid var(--border-hairline);
|
| 229 |
+
margin: 1.75rem 0 1.25rem 0;
|
| 230 |
+
}
|
| 231 |
+
</style>
|
| 232 |
+
"""
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def inject():
|
| 236 |
+
st.markdown(CSS, unsafe_allow_html=True)
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def masthead():
|
| 240 |
+
st.markdown(
|
| 241 |
+
"""
|
| 242 |
+
<div class="tsis-masthead">
|
| 243 |
+
<div class="tsis-status"><span class="tsis-status-dot"></span> SYSTEM ONLINE</div>
|
| 244 |
+
</div>
|
| 245 |
+
""",
|
| 246 |
+
unsafe_allow_html=True,
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def hero(eyebrow: str, title: str, subtitle: str):
|
| 251 |
+
st.markdown(
|
| 252 |
+
f"""
|
| 253 |
+
<div class="tsis-eyebrow">{eyebrow}</div>
|
| 254 |
+
<div class="tsis-title">{title}</div>
|
| 255 |
+
<div class="tsis-subtitle">{subtitle}</div>
|
| 256 |
+
""",
|
| 257 |
+
unsafe_allow_html=True,
|
| 258 |
+
)
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def feed_caption(tag: str, label: str):
|
| 262 |
+
st.markdown(
|
| 263 |
+
f'<div class="tsis-feed-caption"><span class="tsis-tag">{tag}</span> {label}</div>',
|
| 264 |
+
unsafe_allow_html=True,
|
| 265 |
+
)
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
def chip_row(counts: dict):
|
| 269 |
+
if not counts:
|
| 270 |
+
st.markdown(
|
| 271 |
+
'<div class="tsis-chip-row"><div class="tsis-chip">'
|
| 272 |
+
'<span class="tsis-chip-label">STATUS</span>NO OBJECTS DETECTED</div></div>',
|
| 273 |
+
unsafe_allow_html=True,
|
| 274 |
+
)
|
| 275 |
+
return
|
| 276 |
+
chips = "".join(
|
| 277 |
+
f'<div class="tsis-chip"><span class="tsis-chip-label">{label.upper()}</span>{count}</div>'
|
| 278 |
+
for label, count in sorted(counts.items(), key=lambda x: -x[1])
|
| 279 |
+
)
|
| 280 |
+
st.markdown(f'<div class="tsis-chip-row">{chips}</div>', unsafe_allow_html=True)
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def congestion_badge(label: str):
|
| 284 |
+
variant_map = {
|
| 285 |
+
"Free flowing": ("tsis-badge-free", "FREE FLOWING"),
|
| 286 |
+
"Moderate traffic": ("tsis-badge-moderate", "MODERATE TRAFFIC"),
|
| 287 |
+
"Congested": ("tsis-badge-congested", "CONGESTED"),
|
| 288 |
+
}
|
| 289 |
+
variant_class, display_text = variant_map.get(label, ("tsis-badge-moderate", label.upper()))
|
| 290 |
+
st.markdown(
|
| 291 |
+
f'<div class="tsis-badge {variant_class}"><span class="tsis-badge-dot"></span>{display_text}</div>',
|
| 292 |
+
unsafe_allow_html=True,
|
| 293 |
+
)
|
webcam_stub.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
webcam_stub.py
|
| 3 |
+
--------------
|
| 4 |
+
NOT wired into app.py — this is a reference/starting point in case the
|
| 5 |
+
supervisor later asks for live webcam detection.
|
| 6 |
+
|
| 7 |
+
Streamlit does not have a built-in "video loop" the way a desktop GUI does,
|
| 8 |
+
so live webcam detection needs either:
|
| 9 |
+
|
| 10 |
+
Option A (simplest, local machine only):
|
| 11 |
+
Use OpenCV directly to open the webcam (cv2.VideoCapture(0)) inside
|
| 12 |
+
a `while st.session_state.running:` loop, calling
|
| 13 |
+
st.image(...) repeatedly to refresh a placeholder. Works, but is a bit
|
| 14 |
+
choppy and only works when Streamlit runs on the SAME machine as the
|
| 15 |
+
webcam (fine for a local project demo / defense).
|
| 16 |
+
|
| 17 |
+
Option B (proper, works in a real browser/deployed app):
|
| 18 |
+
Use the `streamlit-webrtc` package, which streams frames from the
|
| 19 |
+
BROWSER's webcam to the Python backend over WebRTC. This is the
|
| 20 |
+
correct approach if the app will be accessed remotely (e.g. deployed
|
| 21 |
+
to Streamlit Cloud) rather than run locally during a defense.
|
| 22 |
+
pip install streamlit-webrtc
|
| 23 |
+
|
| 24 |
+
Below is a minimal Option A example, since most project defenses happen
|
| 25 |
+
on the student's own laptop.
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
import cv2
|
| 29 |
+
import streamlit as st
|
| 30 |
+
|
| 31 |
+
from detector import TrafficDetector
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def webcam_tab(detector: TrafficDetector):
|
| 35 |
+
st.header("Live Webcam Detection (experimental)")
|
| 36 |
+
st.caption("Runs locally using your machine's webcam. Click Stop to end the session.")
|
| 37 |
+
|
| 38 |
+
run = st.checkbox("Start Webcam")
|
| 39 |
+
frame_placeholder = st.empty()
|
| 40 |
+
|
| 41 |
+
if run:
|
| 42 |
+
cap = cv2.VideoCapture(0)
|
| 43 |
+
while run and cap.isOpened():
|
| 44 |
+
ok, frame = cap.read()
|
| 45 |
+
if not ok:
|
| 46 |
+
st.warning("Could not read from webcam.")
|
| 47 |
+
break
|
| 48 |
+
|
| 49 |
+
annotated, _counts = detector.detect_frame(frame)
|
| 50 |
+
annotated_rgb = cv2.cvtColor(annotated, cv2.COLOR_BGR2RGB)
|
| 51 |
+
frame_placeholder.image(annotated_rgb, use_container_width=True)
|
| 52 |
+
|
| 53 |
+
# Re-check the checkbox each loop so "Stop" actually stops it.
|
| 54 |
+
run = st.session_state.get("Start Webcam", run)
|
| 55 |
+
|
| 56 |
+
cap.release()
|