0xdivin3 commited on
Commit
72534cf
·
verified ·
1 Parent(s): ac8e300

Upload 8 files

Browse files
Files changed (8) hide show
  1. .streamlit/New Text Document.txt +0 -0
  2. Dockerfile +26 -0
  3. README.md +14 -5
  4. app.py +146 -0
  5. detector.py +233 -0
  6. requirements.txt +5 -0
  7. theme.py +293 -0
  8. 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: Detection
3
- emoji: 🌍
4
- colorFrom: blue
5
- colorTo: yellow
6
  sdk: docker
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
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> &nbsp;{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()