cyberai-1 commited on
Commit
acd405a
Β·
1 Parent(s): 7aeefb9
Files changed (3) hide show
  1. backend/main.py +21 -0
  2. backend/tracker.py +66 -51
  3. frontend/index.html +5 -0
backend/main.py CHANGED
@@ -308,3 +308,24 @@ async def download_log(filename: str):
308
  if not p.exists():
309
  raise HTTPException(404)
310
  return FileResponse(str(p), filename=filename)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
308
  if not p.exists():
309
  raise HTTPException(404)
310
  return FileResponse(str(p), filename=filename)
311
+
312
+
313
+ @app.get("/download/video/{sid}")
314
+ async def download_annotated_video(sid: str):
315
+ """
316
+ Download the annotated output video (.mp4) for a completed session.
317
+ Only available after status == 'done'.
318
+ """
319
+ if sid not in sessions:
320
+ raise HTTPException(404, "Session not found")
321
+ s = sessions[sid]
322
+ if s["status"] != "done":
323
+ raise HTTPException(202, "Processing not complete yet")
324
+ video_path = (s.get("summary") or {}).get("annotated_video", "")
325
+ if not video_path or not Path(video_path).exists():
326
+ raise HTTPException(404, "Annotated video not found")
327
+ return FileResponse(
328
+ str(video_path),
329
+ media_type="video/mp4",
330
+ filename=Path(video_path).name,
331
+ )
backend/tracker.py CHANGED
@@ -1,14 +1,16 @@
1
  """
2
  tracker.py β€” Core detection + tracking engine
3
  Uses YOLOv8 (ultralytics) with ByteTrack built-in tracker.
4
- Supports: cars, buses, trucks, motorbikes, bicycles, pedestrians
5
 
6
  Counting strategy: FIRST-SEEN β€” each unique track_id is counted exactly
7
- once, on the first frame it appears. No counting line needed.
8
- Untracked detections (track_id == -1) are drawn but never counted.
 
 
9
  """
10
 
11
  import cv2
 
12
  import json
13
  import uuid
14
  import numpy as np
@@ -39,17 +41,22 @@ CLASS_COLORS = {
39
  DEFAULT_CLASSES = list(TRAFFIC_CLASSES.values())
40
 
41
 
42
- # ─── TrafficTracker ────────────────────────────────────────────────────────────
43
  class TrafficTracker:
44
  def __init__(
45
  self,
46
  model_path: str = "yolov8n.pt",
47
- selected_classes: list[str] = None,
48
  conf_threshold: float = 0.35,
49
  scene_name: str = "scene_01",
50
  output_dir: str = "logs",
51
  ):
 
 
 
 
 
52
  self.model = YOLO(model_path)
 
53
  self.selected_classes = selected_classes or DEFAULT_CLASSES
54
  self.conf = conf_threshold
55
  self.scene_name = scene_name
@@ -63,16 +70,18 @@ class TrafficTracker:
63
  self.frame_width = 0
64
  self.frame_height = 0
65
 
66
- # Tracking state
67
- # track_history: id -> list of (cx, cy) centres (for trail drawing)
68
- self.track_history: dict[int, list] = defaultdict(list)
69
- # counted_ids: set of track_ids already counted (never count twice)
70
- self.counted_ids: set[int] = set()
71
- self.count_per_class: dict[str, int] = defaultdict(int)
72
 
73
- # Per-frame logs
74
- self.detection_log: list[dict] = []
75
- self.frame_stats: list[dict] = []
 
 
 
 
76
 
77
  # COCO class-id filter
78
  self._class_ids = [
@@ -81,13 +90,23 @@ class TrafficTracker:
81
  ]
82
 
83
  # ── Setup ──────────────────────────────────────────────────────────────────
84
- def setup_video(self, cap: cv2.VideoCapture):
85
  self.fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
86
  self.frame_width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
87
  self.frame_height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
88
 
 
 
 
 
 
 
 
 
 
 
89
  # ── Process one frame ──────────────────────────────────────────────────────
90
- def process_frame(self, frame: np.ndarray) -> tuple[np.ndarray, dict]:
91
  self.frame_index += 1
92
  timestamp = self.frame_index / self.fps
93
 
@@ -122,21 +141,16 @@ class TrafficTracker:
122
  any_object_visible = True
123
  per_class_in_frame[cls_name] += 1
124
 
125
- # ── First-seen counting ───────────────────────────────────────
126
- # Count each tracked object exactly once, the first frame it
127
- # appears. No line crossing required β€” every object in scene
128
- # is counted as soon as it gets a stable track ID.
129
  is_new = False
130
  if track_id != -1 and track_id not in self.counted_ids:
131
  self.counted_ids.add(track_id)
132
  self.count_per_class[cls_name] += 1
133
  is_new = True
134
 
135
- # Update trail history (tracked objects only)
136
  if track_id != -1:
137
  self.track_history[track_id].append((cx, cy))
138
 
139
- # ── Log ───────────────────────────────────────────────────────
140
  frame_detections.append({
141
  "frame": self.frame_index,
142
  "timestamp": round(timestamp, 3),
@@ -150,9 +164,8 @@ class TrafficTracker:
150
  })
151
  self.detection_log.append(frame_detections[-1])
152
 
153
- # ── Draw bounding box ─────────────────────────────────────────
154
- color = CLASS_COLORS.get(cls_name, (255, 255, 255))
155
- # Thicker border for newly counted objects (flash effect)
156
  thickness = 3 if is_new else 2
157
  cv2.rectangle(annotated, (x1, y1), (x2, y2), color, thickness)
158
 
@@ -171,10 +184,12 @@ class TrafficTracker:
171
  tc = tuple(int(c * alpha) for c in color)
172
  cv2.line(annotated, trail[i - 1], trail[i], tc, 2)
173
 
174
- # ── Overlay counters (no counting line drawn) ─────────────────────────
175
  self._draw_counters(annotated, any_object_visible)
176
 
177
- # ── Frame stats ───────────────────────────────────────────────────────
 
 
 
178
  frame_stat = {
179
  "frame": self.frame_index,
180
  "timestamp": round(timestamp, 3),
@@ -188,7 +203,7 @@ class TrafficTracker:
188
 
189
  return annotated, frame_stat
190
 
191
- # ── Overlay ────────────────────────────────────────────────────────────────
192
  def _draw_counters(self, frame: np.ndarray, any_visible: bool):
193
  overlay = frame.copy()
194
  cv2.rectangle(overlay, (0, 0),
@@ -198,13 +213,11 @@ class TrafficTracker:
198
 
199
  cv2.putText(frame, "TRAFFIC COUNTER", (10, 20),
200
  cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 255), 2)
201
-
202
  y = 42
203
  for cls_name in self.selected_classes:
204
  count = self.count_per_class.get(cls_name, 0)
205
  color = CLASS_COLORS.get(cls_name, (255, 255, 255))
206
- label = f" {cls_name:<12} {count:>4}"
207
- cv2.putText(frame, label, (8, y),
208
  cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1)
209
  y += 22
210
 
@@ -212,25 +225,26 @@ class TrafficTracker:
212
  cv2.putText(frame, "NO OBJECTS DETECTED", (10, y + 10),
213
  cv2.FONT_HERSHEY_SIMPLEX, 0.5, (80, 80, 80), 1)
214
 
215
- # ── Save logs ───────────────────────────────────────────────────────────────
216
  def save_logs(self) -> dict:
 
 
 
 
 
217
  ts = datetime.now().strftime("%Y%m%d_%H%M%S")
218
  prefix = f"{self.scene_name}_{ts}"
219
 
220
- # Detection log (JSONL)
221
  log_path = self.output_dir / f"{prefix}_detections.jsonl"
222
  with open(log_path, "w") as f:
223
  for entry in self.detection_log:
224
  f.write(json.dumps(entry) + "\n")
225
 
226
- # Summary JSON
227
  summary = self.get_summary()
228
  sum_path = self.output_dir / f"{prefix}_summary.json"
229
  with open(sum_path, "w") as f:
230
  json.dump(summary, f, indent=2)
231
 
232
- # Frame stats CSV
233
- import csv
234
  csv_path = self.output_dir / f"{prefix}_frame_stats.csv"
235
  if self.frame_stats:
236
  with open(csv_path, "w", newline="") as f:
@@ -239,9 +253,10 @@ class TrafficTracker:
239
  writer.writerows(self.frame_stats)
240
 
241
  return {
242
- "detection_log": str(log_path),
243
- "summary": str(sum_path),
244
- "frame_stats": str(csv_path),
 
245
  }
246
 
247
  # ── Summary ─────────────────────────────────────────────────────────────────
@@ -249,23 +264,23 @@ class TrafficTracker:
249
  total_duration = self.frame_index / self.fps
250
  total_objects = sum(self.count_per_class.values())
251
 
252
- # Traffic intensity over time (10-second buckets)
253
- buckets: dict[int, int] = defaultdict(int)
254
  for stat in self.frame_stats:
255
  bucket = int(stat["timestamp"] // 10)
256
  buckets[bucket] += stat["detections"]
257
  temporal = [{"bucket_10s": k, "detections": v} for k, v in sorted(buckets.items())]
258
 
259
  return {
260
- "scene": self.scene_name,
261
- "session_id": self.session_id,
262
- "processed_at": datetime.now().isoformat(),
263
- "total_frames": self.frame_index,
264
- "duration_sec": round(total_duration, 2),
265
- "fps": round(self.fps, 2),
266
- "resolution": [self.frame_width, self.frame_height],
267
- "selected_classes": self.selected_classes,
268
- "total_unique_objects": total_objects,
269
- "count_per_class": dict(self.count_per_class),
 
270
  "temporal_distribution": temporal,
271
  }
 
1
  """
2
  tracker.py β€” Core detection + tracking engine
3
  Uses YOLOv8 (ultralytics) with ByteTrack built-in tracker.
 
4
 
5
  Counting strategy: FIRST-SEEN β€” each unique track_id is counted exactly
6
+ once, on the first frame it appears. No counting line.
7
+
8
+ Video output: every processed frame (with bounding boxes) is written to
9
+ an .mp4 file in the logs/ directory via cv2.VideoWriter.
10
  """
11
 
12
  import cv2
13
+ import csv
14
  import json
15
  import uuid
16
  import numpy as np
 
41
  DEFAULT_CLASSES = list(TRAFFIC_CLASSES.values())
42
 
43
 
 
44
  class TrafficTracker:
45
  def __init__(
46
  self,
47
  model_path: str = "yolov8n.pt",
48
+ selected_classes: list = None,
49
  conf_threshold: float = 0.35,
50
  scene_name: str = "scene_01",
51
  output_dir: str = "logs",
52
  ):
53
+ # ── Model ─────────────────────────────────────────────────────────────
54
+ # model_path is selected in the frontend dropdown (main.py receives it
55
+ # as a form field and passes it here).
56
+ # To use a fine-tuned model, pass the path to your best.pt here, e.g.:
57
+ # model_path = "runs/detect/traffic_finetune/weights/best.pt"
58
  self.model = YOLO(model_path)
59
+
60
  self.selected_classes = selected_classes or DEFAULT_CLASSES
61
  self.conf = conf_threshold
62
  self.scene_name = scene_name
 
70
  self.frame_width = 0
71
  self.frame_height = 0
72
 
73
+ # Tracking
74
+ self.track_history: dict = defaultdict(list)
75
+ self.counted_ids: set = set()
76
+ self.count_per_class: dict = defaultdict(int)
 
 
77
 
78
+ # Logs
79
+ self.detection_log: list = []
80
+ self.frame_stats: list = []
81
+
82
+ # Video writer (set in setup_video)
83
+ self.video_writer = None
84
+ self.output_video_path = None
85
 
86
  # COCO class-id filter
87
  self._class_ids = [
 
90
  ]
91
 
92
  # ── Setup ──────────────────────────────────────────────────────────────────
93
+ def setup_video(self, cap: cv2.VideoCapture, save_output: bool = True):
94
  self.fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
95
  self.frame_width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
96
  self.frame_height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
97
 
98
+ if save_output:
99
+ ts = datetime.now().strftime("%Y%m%d_%H%M%S")
100
+ out_name = f"{self.scene_name}_{ts}_annotated.mp4"
101
+ self.output_video_path = str(self.output_dir / out_name)
102
+ fourcc = cv2.VideoWriter_fourcc(*"mp4v")
103
+ self.video_writer = cv2.VideoWriter(
104
+ self.output_video_path, fourcc,
105
+ self.fps, (self.frame_width, self.frame_height)
106
+ )
107
+
108
  # ── Process one frame ──────────────────────────────────────────────────────
109
+ def process_frame(self, frame: np.ndarray) -> tuple:
110
  self.frame_index += 1
111
  timestamp = self.frame_index / self.fps
112
 
 
141
  any_object_visible = True
142
  per_class_in_frame[cls_name] += 1
143
 
144
+ # First-seen counting
 
 
 
145
  is_new = False
146
  if track_id != -1 and track_id not in self.counted_ids:
147
  self.counted_ids.add(track_id)
148
  self.count_per_class[cls_name] += 1
149
  is_new = True
150
 
 
151
  if track_id != -1:
152
  self.track_history[track_id].append((cx, cy))
153
 
 
154
  frame_detections.append({
155
  "frame": self.frame_index,
156
  "timestamp": round(timestamp, 3),
 
164
  })
165
  self.detection_log.append(frame_detections[-1])
166
 
167
+ # Draw bounding box
168
+ color = CLASS_COLORS.get(cls_name, (255, 255, 255))
 
169
  thickness = 3 if is_new else 2
170
  cv2.rectangle(annotated, (x1, y1), (x2, y2), color, thickness)
171
 
 
184
  tc = tuple(int(c * alpha) for c in color)
185
  cv2.line(annotated, trail[i - 1], trail[i], tc, 2)
186
 
 
187
  self._draw_counters(annotated, any_object_visible)
188
 
189
+ # Write annotated frame to output video
190
+ if self.video_writer is not None:
191
+ self.video_writer.write(annotated)
192
+
193
  frame_stat = {
194
  "frame": self.frame_index,
195
  "timestamp": round(timestamp, 3),
 
203
 
204
  return annotated, frame_stat
205
 
206
+ # ── Counters overlay ───────────────────────────────────────────────────────
207
  def _draw_counters(self, frame: np.ndarray, any_visible: bool):
208
  overlay = frame.copy()
209
  cv2.rectangle(overlay, (0, 0),
 
213
 
214
  cv2.putText(frame, "TRAFFIC COUNTER", (10, 20),
215
  cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 255), 2)
 
216
  y = 42
217
  for cls_name in self.selected_classes:
218
  count = self.count_per_class.get(cls_name, 0)
219
  color = CLASS_COLORS.get(cls_name, (255, 255, 255))
220
+ cv2.putText(frame, f" {cls_name:<12} {count:>4}", (8, y),
 
221
  cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1)
222
  y += 22
223
 
 
225
  cv2.putText(frame, "NO OBJECTS DETECTED", (10, y + 10),
226
  cv2.FONT_HERSHEY_SIMPLEX, 0.5, (80, 80, 80), 1)
227
 
228
+ # ── Finalise and save all logs ─────────────────────────────────────────────
229
  def save_logs(self) -> dict:
230
+ # Release video writer so the .mp4 is properly closed/flushed
231
+ if self.video_writer is not None:
232
+ self.video_writer.release()
233
+ self.video_writer = None
234
+
235
  ts = datetime.now().strftime("%Y%m%d_%H%M%S")
236
  prefix = f"{self.scene_name}_{ts}"
237
 
 
238
  log_path = self.output_dir / f"{prefix}_detections.jsonl"
239
  with open(log_path, "w") as f:
240
  for entry in self.detection_log:
241
  f.write(json.dumps(entry) + "\n")
242
 
 
243
  summary = self.get_summary()
244
  sum_path = self.output_dir / f"{prefix}_summary.json"
245
  with open(sum_path, "w") as f:
246
  json.dump(summary, f, indent=2)
247
 
 
 
248
  csv_path = self.output_dir / f"{prefix}_frame_stats.csv"
249
  if self.frame_stats:
250
  with open(csv_path, "w", newline="") as f:
 
253
  writer.writerows(self.frame_stats)
254
 
255
  return {
256
+ "detection_log": str(log_path),
257
+ "summary": str(sum_path),
258
+ "frame_stats": str(csv_path),
259
+ "annotated_video": self.output_video_path or "",
260
  }
261
 
262
  # ── Summary ─────────────────────────────────────────────────────────────────
 
264
  total_duration = self.frame_index / self.fps
265
  total_objects = sum(self.count_per_class.values())
266
 
267
+ buckets: dict = defaultdict(int)
 
268
  for stat in self.frame_stats:
269
  bucket = int(stat["timestamp"] // 10)
270
  buckets[bucket] += stat["detections"]
271
  temporal = [{"bucket_10s": k, "detections": v} for k, v in sorted(buckets.items())]
272
 
273
  return {
274
+ "scene": self.scene_name,
275
+ "session_id": self.session_id,
276
+ "processed_at": datetime.now().isoformat(),
277
+ "total_frames": self.frame_index,
278
+ "duration_sec": round(total_duration, 2),
279
+ "fps": round(self.fps, 2),
280
+ "resolution": [self.frame_width, self.frame_height],
281
+ "selected_classes": self.selected_classes,
282
+ "total_unique_objects": total_objects,
283
+ "count_per_class": dict(self.count_per_class),
284
+ "annotated_video": self.output_video_path or "",
285
  "temporal_distribution": temporal,
286
  }
frontend/index.html CHANGED
@@ -351,6 +351,7 @@
351
  <button class="btn btn-secondary" id="stopBtn" style="display:none" onclick="stopProcessing()">β–  STOP</button>
352
  <div class="prog-bar"><div class="prog-fill" id="progFill"></div></div>
353
  <div class="prog-label" id="progLabel"></div>
 
354
  </div>
355
 
356
  <!-- Total unique -->
@@ -639,6 +640,10 @@ function startSSE(sid) {
639
  setConn('live', 'DONE');
640
  document.getElementById('startBtn').disabled = false;
641
  document.getElementById('stopBtn').style.display = 'none';
 
 
 
 
642
  return;
643
  }
644
 
 
351
  <button class="btn btn-secondary" id="stopBtn" style="display:none" onclick="stopProcessing()">β–  STOP</button>
352
  <div class="prog-bar"><div class="prog-fill" id="progFill"></div></div>
353
  <div class="prog-label" id="progLabel"></div>
354
+ <a class="btn btn-secondary" id="downloadVideoBtn" style="display:none;text-align:center;text-decoration:none;margin-top:7px" download>⬇ DOWNLOAD ANNOTATED VIDEO</a>
355
  </div>
356
 
357
  <!-- Total unique -->
 
640
  setConn('live', 'DONE');
641
  document.getElementById('startBtn').disabled = false;
642
  document.getElementById('stopBtn').style.display = 'none';
643
+ // Show annotated video download button
644
+ const dlBtn = document.getElementById('downloadVideoBtn');
645
+ dlBtn.href = `${API_BASE}/download/video/${currentSid}`;
646
+ dlBtn.style.display = 'block';
647
  return;
648
  }
649