cyberai-1 commited on
Commit
9c09c1a
Β·
1 Parent(s): 760008e
backend/__pycache__/tracker.cpython-311.pyc CHANGED
Binary files a/backend/__pycache__/tracker.cpython-311.pyc and b/backend/__pycache__/tracker.cpython-311.pyc differ
 
backend/main.py CHANGED
@@ -6,6 +6,7 @@ Processes frames as fast as possible; frontend controls playback timing.
6
  import asyncio
7
  import base64
8
  import json
 
9
  import shutil
10
  import subprocess
11
  import time
@@ -17,6 +18,7 @@ from collections import defaultdict, deque
17
  from pathlib import Path
18
 
19
  import cv2
 
20
  from fastapi import FastAPI, File, Form, UploadFile, HTTPException, Request
21
  from fastapi.middleware.cors import CORSMiddleware
22
  from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, StreamingResponse
@@ -45,6 +47,8 @@ if STATIC_DIR.exists():
45
 
46
  # ─── Session store ─────────────────────────────────────────────────────────────
47
  sessions: dict[str, dict] = {}
 
 
48
 
49
 
50
  def _safe_log_path(filename: str) -> Path:
@@ -57,6 +61,8 @@ def _safe_log_path(filename: str) -> Path:
57
  def _get_summary_for_session(sid: str) -> dict | None:
58
  if sid in sessions:
59
  return sessions[sid].get("summary")
 
 
60
 
61
  for summary_path in LOG_DIR.glob("*_summary.json"):
62
  try:
@@ -80,6 +86,41 @@ def _get_completed_session_summary(sid: str) -> dict:
80
  return summary
81
 
82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  def _ensure_browser_compatible_video(video_path: str) -> str:
84
  """
85
  OpenCV's mp4v output downloads fine, but many browsers will not play it in
@@ -176,20 +217,22 @@ async def upload_video(
176
 
177
  selected = [c.strip() for c in classes.split(",") if c.strip()] or DEFAULT_CLASSES
178
 
179
- sessions[sid] = {
180
- "status": "processing",
181
- "frames": [],
182
- "frames_lock": threading.Lock(),
183
- "stats": {},
184
- "summary": None,
185
- "error": None,
186
- "scene_name": scene_name,
187
- "group_id": group_id,
188
- "video_name": video_name,
189
- "progress": 0,
190
- "total_frames": 0,
191
- "fps": 30.0,
192
- }
 
 
193
 
194
  threading.Thread(
195
  target=_process_video_thread,
@@ -200,6 +243,114 @@ async def upload_video(
200
  return {"session_id": sid, "scene_name": scene_name, "group_id": group_id, "status": "processing"}
201
 
202
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  def _process_video_thread(sid, video_path, scene_name, group_id, video_name, selected_classes, conf, model_name):
204
  """
205
  Processes video as fast as possible (no artificial sleep).
@@ -397,6 +548,10 @@ async def dashboard_data():
397
  s["summary"] for s in sessions.values()
398
  if s["status"] == "done" and s["summary"]
399
  ]
 
 
 
 
400
  for p in sorted(LOG_DIR.glob("*_summary.json")):
401
  try:
402
  data = json.loads(p.read_text())
 
6
  import asyncio
7
  import base64
8
  import json
9
+ import re
10
  import shutil
11
  import subprocess
12
  import time
 
18
  from pathlib import Path
19
 
20
  import cv2
21
+ import numpy as np
22
  from fastapi import FastAPI, File, Form, UploadFile, HTTPException, Request
23
  from fastapi.middleware.cors import CORSMiddleware
24
  from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, StreamingResponse
 
47
 
48
  # ─── Session store ─────────────────────────────────────────────────────────────
49
  sessions: dict[str, dict] = {}
50
+ webcam_sessions: dict[str, dict] = {}
51
+ scene_lock = threading.Lock()
52
 
53
 
54
  def _safe_log_path(filename: str) -> Path:
 
61
  def _get_summary_for_session(sid: str) -> dict | None:
62
  if sid in sessions:
63
  return sessions[sid].get("summary")
64
+ if sid in webcam_sessions:
65
+ return webcam_sessions[sid].get("summary")
66
 
67
  for summary_path in LOG_DIR.glob("*_summary.json"):
68
  try:
 
86
  return summary
87
 
88
 
89
+ def _iter_known_scene_names(group_id: str):
90
+ for session in sessions.values():
91
+ if session.get("group_id") == group_id and session.get("scene_name"):
92
+ yield session["scene_name"]
93
+ for session in webcam_sessions.values():
94
+ if session.get("group_id") == group_id and session.get("scene_name"):
95
+ yield session["scene_name"]
96
+ for summary_path in LOG_DIR.glob("*_summary.json"):
97
+ try:
98
+ summary = json.loads(summary_path.read_text())
99
+ except Exception:
100
+ continue
101
+ if summary.get("group_id") == group_id and summary.get("scene"):
102
+ yield summary["scene"]
103
+
104
+
105
+ def _next_scene_name(requested_scene: str, group_id: str) -> str:
106
+ requested = (requested_scene or "scene").strip() or "scene"
107
+ exact_exists = any(scene_name == requested for scene_name in _iter_known_scene_names(group_id))
108
+ suffix_match = re.match(r"^(.+?)_(\d{2,})$", requested)
109
+ base = suffix_match.group(1) if exact_exists and suffix_match else requested
110
+ scene_pattern = re.compile(rf"^{re.escape(base)}(?:_(\d{{2,}}))?$")
111
+ order_numbers = []
112
+
113
+ for scene_name in _iter_known_scene_names(group_id):
114
+ match = scene_pattern.match(scene_name)
115
+ if not match:
116
+ continue
117
+ order_numbers.append(int(match.group(1) or 0))
118
+
119
+ if not order_numbers:
120
+ return base
121
+ return f"{base}_{max(order_numbers) + 1:02d}"
122
+
123
+
124
  def _ensure_browser_compatible_video(video_path: str) -> str:
125
  """
126
  OpenCV's mp4v output downloads fine, but many browsers will not play it in
 
217
 
218
  selected = [c.strip() for c in classes.split(",") if c.strip()] or DEFAULT_CLASSES
219
 
220
+ with scene_lock:
221
+ scene_name = _next_scene_name(scene_name, group_id)
222
+ sessions[sid] = {
223
+ "status": "processing",
224
+ "frames": [],
225
+ "frames_lock": threading.Lock(),
226
+ "stats": {},
227
+ "summary": None,
228
+ "error": None,
229
+ "scene_name": scene_name,
230
+ "group_id": group_id,
231
+ "video_name": video_name,
232
+ "progress": 0,
233
+ "total_frames": 0,
234
+ "fps": 30.0,
235
+ }
236
 
237
  threading.Thread(
238
  target=_process_video_thread,
 
243
  return {"session_id": sid, "scene_name": scene_name, "group_id": group_id, "status": "processing"}
244
 
245
 
246
+ @app.post("/webcam/start")
247
+ async def start_webcam_detection(
248
+ scene_name: str = Form("webcam"),
249
+ group_id: str = Form("Group_05"),
250
+ classes: str = Form(""),
251
+ conf: float = Form(0.5),
252
+ model: str = Form("best.pt"),
253
+ ):
254
+ sid = str(uuid.uuid4())[:12]
255
+ with scene_lock:
256
+ scene_name = _next_scene_name(scene_name, group_id)
257
+ webcam_sessions[sid] = {
258
+ "status": "loading",
259
+ "tracker": None,
260
+ "tracker_lock": threading.Lock(),
261
+ "summary": None,
262
+ "error": None,
263
+ "scene_name": scene_name,
264
+ "group_id": group_id,
265
+ }
266
+
267
+ selected = [c.strip() for c in classes.split(",") if c.strip()] or DEFAULT_CLASSES
268
+
269
+ try:
270
+ webcam_sessions[sid]["tracker"] = TrafficTracker(
271
+ model_path=model,
272
+ selected_classes=selected,
273
+ conf_threshold=conf,
274
+ scene_name=scene_name,
275
+ group_id=group_id,
276
+ video_name="webcam",
277
+ output_dir=str(LOG_DIR),
278
+ )
279
+ webcam_sessions[sid]["status"] = "processing"
280
+ except Exception as exc:
281
+ webcam_sessions[sid]["status"] = "error"
282
+ webcam_sessions[sid]["error"] = str(exc)
283
+ raise HTTPException(500, f"Could not start webcam detection: {exc}") from exc
284
+
285
+ return {"session_id": sid, "scene_name": scene_name, "group_id": group_id, "status": "processing"}
286
+
287
+
288
+ @app.post("/webcam/frame/{sid}")
289
+ async def detect_webcam_frame(sid: str, file: UploadFile = File(...)):
290
+ session = webcam_sessions.get(sid)
291
+ if not session:
292
+ raise HTTPException(404, "Webcam session not found")
293
+ if session["status"] == "error":
294
+ raise HTTPException(500, session.get("error") or "Webcam session failed")
295
+ tracker = session.get("tracker")
296
+ if tracker is None:
297
+ raise HTTPException(202, "Webcam model is still loading")
298
+
299
+ raw = await file.read()
300
+ np_buf = np.frombuffer(raw, dtype=np.uint8)
301
+ frame = cv2.imdecode(np_buf, cv2.IMREAD_COLOR)
302
+ if frame is None:
303
+ raise HTTPException(400, "Could not decode webcam frame")
304
+
305
+ with session["tracker_lock"]:
306
+ if tracker.frame_width == 0 or tracker.frame_height == 0:
307
+ tracker.setup_frame_source(frame.shape[1], frame.shape[0], fps=5.0)
308
+
309
+ annotated, frame_stat = tracker.process_frame(frame)
310
+
311
+ _, buf = cv2.imencode(".jpg", annotated, [cv2.IMWRITE_JPEG_QUALITY, 80])
312
+ cumulative = frame_stat.get("cumulative") or {}
313
+ stats_payload = {
314
+ "frame": frame_stat.get("frame", 0),
315
+ "timestamp": round(frame_stat.get("timestamp", 0.0), 2),
316
+ "scene": session["scene_name"],
317
+ "detections": frame_stat.get("detections", 0),
318
+ "any_visible": frame_stat.get("any_visible", False),
319
+ "cumulative": cumulative,
320
+ "total_unique": int(sum(cumulative.values())),
321
+ }
322
+
323
+ return {
324
+ "frame": base64.b64encode(buf).decode(),
325
+ "stats": stats_payload,
326
+ }
327
+
328
+
329
+ @app.post("/webcam/stop/{sid}")
330
+ async def stop_webcam_detection(sid: str):
331
+ session = webcam_sessions.get(sid)
332
+ if not session:
333
+ raise HTTPException(404, "Webcam session not found")
334
+ tracker = session.get("tracker")
335
+ if tracker is None:
336
+ webcam_sessions.pop(sid, None)
337
+ return {"status": "stopped"}
338
+
339
+ with session["tracker_lock"]:
340
+ log_paths = tracker.save_logs()
341
+ summary = tracker.get_summary()
342
+ summary["session_id"] = sid
343
+ summary["log_files"] = log_paths
344
+ summary["total_unique"] = int(sum(summary.get("count_per_class", {}).values()))
345
+ summary_path = Path(log_paths.get("summary", ""))
346
+ if summary_path.exists():
347
+ summary_path.write_text(json.dumps(summary, indent=2), encoding="utf-8")
348
+
349
+ session["summary"] = summary
350
+ session["status"] = "done"
351
+ return {"status": "stopped", "summary": summary}
352
+
353
+
354
  def _process_video_thread(sid, video_path, scene_name, group_id, video_name, selected_classes, conf, model_name):
355
  """
356
  Processes video as fast as possible (no artificial sleep).
 
548
  s["summary"] for s in sessions.values()
549
  if s["status"] == "done" and s["summary"]
550
  ]
551
+ completed.extend(
552
+ s["summary"] for s in webcam_sessions.values()
553
+ if s["status"] == "done" and s["summary"]
554
+ )
555
  for p in sorted(LOG_DIR.glob("*_summary.json")):
556
  try:
557
  data = json.loads(p.read_text())
backend/tracker.py CHANGED
@@ -11,6 +11,7 @@ import cv2
11
  import csv
12
  import json
13
  import math
 
14
  import uuid
15
  import numpy as np
16
  from pathlib import Path
@@ -106,24 +107,26 @@ class TrafficTracker:
106
  Cherche les fichiers existants avec le pattern Group_X_Y_NNN_*.
107
  """
108
  try:
109
- pattern = f"{self.group_id}_{self.scene_name}_*"
 
 
110
  existing = list(self.output_dir.glob(pattern))
111
  if not existing:
112
  return 1
113
- # Extraire les numΓ©ros d'ordre des fichiers existants
114
  numbers = []
115
  for f in existing:
116
- parts = f.name.split("_")
117
- if len(parts) >= 3:
118
- try:
119
- num = int(parts[2])
120
- numbers.append(num)
121
- except ValueError:
122
- pass
123
  return max(numbers, default=0) + 1
124
  except Exception:
125
  return 1
126
 
 
 
 
 
 
127
  # ── Setup ──────────────────────────────────────────────────────────────────
128
  def setup_video(self, cap: cv2.VideoCapture, save_output: bool = True):
129
  self.fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
 
11
  import csv
12
  import json
13
  import math
14
+ import re
15
  import uuid
16
  import numpy as np
17
  from pathlib import Path
 
107
  Cherche les fichiers existants avec le pattern Group_X_Y_NNN_*.
108
  """
109
  try:
110
+ prefix = f"{self.group_id}_{self.scene_name}"
111
+ pattern = f"{prefix}_*"
112
+ order_pattern = re.compile(rf"^{re.escape(prefix)}_(\d{{3}})(?:_|\.|$)")
113
  existing = list(self.output_dir.glob(pattern))
114
  if not existing:
115
  return 1
 
116
  numbers = []
117
  for f in existing:
118
+ match = order_pattern.match(f.name)
119
+ if match:
120
+ numbers.append(int(match.group(1)))
 
 
 
 
121
  return max(numbers, default=0) + 1
122
  except Exception:
123
  return 1
124
 
125
+ def setup_frame_source(self, width: int, height: int, fps: float = 5.0):
126
+ self.fps = fps or 5.0
127
+ self.frame_width = width
128
+ self.frame_height = height
129
+
130
  # ── Setup ──────────────────────────────────────────────────────────────────
131
  def setup_video(self, cap: cv2.VideoCapture, save_output: bool = True):
132
  self.fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
frontend/index.html CHANGED
@@ -510,6 +510,7 @@
510
  <div class="no-signal-sub">Upload a video and click START</div>
511
  </div>
512
  <canvas id="liveCanvas"></canvas>
 
513
  <div id="bufferInfo">BUF: 0</div>
514
  </div>
515
 
@@ -558,6 +559,7 @@
558
  <div class="s-sect">
559
  <div class="s-label">Frame Info</div>
560
  <button class="btn btn-primary" id="startBtn" onclick="startProcessing()">β–Ά START ANALYSIS</button>
 
561
  <button class="btn btn-secondary" id="stopBtn" style="display:none" onclick="stopProcessing()">β–  STOP</button>
562
  <button class="btn btn-secondary" id="watchVideoBtn" style="display:none;text-align:center;margin-top:7px" onclick="openVideoPlayer()">β–Ά WATCH VIDEO</button>
563
  <div class="prog-bar"><div class="prog-fill" id="progFill"></div></div>
@@ -606,6 +608,10 @@ let sseSource = null;
606
  let selectedFile = null;
607
  let pollTimer = null;
608
  let dashboardCache = null;
 
 
 
 
609
 
610
  // Canvas
611
  const canvas = document.getElementById('liveCanvas');
@@ -700,10 +706,12 @@ function switchTab(tab, btn) {
700
 
701
  // ══ PROCESSING ════════════════════════════════════════════════════════════════
702
  async function startProcessing() {
 
 
703
  const videoUrl = document.getElementById('videoUrl').value.trim();
704
  if (!selectedFile && !videoUrl) { toast('Upload a video file or enter a video URL.', 'err'); return; }
705
 
706
- const selected = [...document.querySelectorAll('#classGrid input:checked')].map(i => i.value);
707
  if (!selected.length) { toast('Select at least one class.', 'err'); return; }
708
 
709
  const fd = new FormData();
@@ -739,6 +747,7 @@ async function startProcessing() {
739
  try {
740
  const data = JSON.parse(xhr.responseText);
741
  currentSid = data.session_id;
 
742
  localStorage.setItem('trafficTracker:lastSessionId', currentSid);
743
  toast(`Session: ${currentSid}`, 'ok');
744
  startSSE(currentSid);
@@ -775,7 +784,15 @@ async function startProcessing() {
775
  }
776
  }
777
 
778
- function stopProcessing() {
 
 
 
 
 
 
 
 
779
  if (sseSource) { sseSource.close(); sseSource = null; }
780
  if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
781
  if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
@@ -784,18 +801,129 @@ function stopProcessing() {
784
  videoStart = null;
785
  lastRenderedPts = -1;
786
  setUI('idle');
787
- toast('Stopped.', '');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
788
  }
789
 
790
  function setUI(state) {
791
  const start = document.getElementById('startBtn');
792
  const stop = document.getElementById('stopBtn');
 
793
  if (state === 'processing') {
794
  start.disabled = true;
 
795
  stop.style.display = 'block';
796
  setConn('proc', 'PROCESSING');
797
  } else {
798
  start.disabled = false;
 
799
  stop.style.display = 'none';
800
  document.getElementById('progFill').style.width = '0%';
801
  document.getElementById('progLabel').textContent = '';
 
510
  <div class="no-signal-sub">Upload a video and click START</div>
511
  </div>
512
  <canvas id="liveCanvas"></canvas>
513
+ <video id="webcamVideo" autoplay muted playsinline style="display:none"></video>
514
  <div id="bufferInfo">BUF: 0</div>
515
  </div>
516
 
 
559
  <div class="s-sect">
560
  <div class="s-label">Frame Info</div>
561
  <button class="btn btn-primary" id="startBtn" onclick="startProcessing()">β–Ά START ANALYSIS</button>
562
+ <button class="btn btn-secondary" id="webcamBtn" onclick="startWebcam()">β–£ START WEBCAM</button>
563
  <button class="btn btn-secondary" id="stopBtn" style="display:none" onclick="stopProcessing()">β–  STOP</button>
564
  <button class="btn btn-secondary" id="watchVideoBtn" style="display:none;text-align:center;margin-top:7px" onclick="openVideoPlayer()">β–Ά WATCH VIDEO</button>
565
  <div class="prog-bar"><div class="prog-fill" id="progFill"></div></div>
 
608
  let selectedFile = null;
609
  let pollTimer = null;
610
  let dashboardCache = null;
611
+ let webcamStream = null;
612
+ let webcamSid = null;
613
+ let webcamTimer = null;
614
+ let webcamBusy = false;
615
 
616
  // Canvas
617
  const canvas = document.getElementById('liveCanvas');
 
706
 
707
  // ══ PROCESSING ════════════════════════════════════════════════════════════════
708
  async function startProcessing() {
709
+ if (webcamSid || webcamStream) await stopWebcam(false);
710
+
711
  const videoUrl = document.getElementById('videoUrl').value.trim();
712
  if (!selectedFile && !videoUrl) { toast('Upload a video file or enter a video URL.', 'err'); return; }
713
 
714
+ const selected = getSelectedClasses();
715
  if (!selected.length) { toast('Select at least one class.', 'err'); return; }
716
 
717
  const fd = new FormData();
 
747
  try {
748
  const data = JSON.parse(xhr.responseText);
749
  currentSid = data.session_id;
750
+ document.getElementById('sceneName').value = data.scene_name;
751
  localStorage.setItem('trafficTracker:lastSessionId', currentSid);
752
  toast(`Session: ${currentSid}`, 'ok');
753
  startSSE(currentSid);
 
784
  }
785
  }
786
 
787
+ function getSelectedClasses() {
788
+ return [...document.querySelectorAll('#classGrid input:checked')].map(i => i.value);
789
+ }
790
+
791
+ function stopProcessing(showToast = true) {
792
+ if (webcamSid || webcamStream) {
793
+ stopWebcam(showToast);
794
+ return;
795
+ }
796
  if (sseSource) { sseSource.close(); sseSource = null; }
797
  if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
798
  if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
 
801
  videoStart = null;
802
  lastRenderedPts = -1;
803
  setUI('idle');
804
+ if (showToast) toast('Stopped.', '');
805
+ }
806
+
807
+ async function startWebcam() {
808
+ const selected = getSelectedClasses();
809
+ if (!selected.length) { toast('Select at least one class.', 'err'); return; }
810
+
811
+ stopProcessing(false);
812
+ resetCounters();
813
+ setUI('processing');
814
+ setConn('proc', 'WEBCAM');
815
+
816
+ try {
817
+ webcamStream = await navigator.mediaDevices.getUserMedia({
818
+ video: { facingMode: 'environment' },
819
+ audio: false,
820
+ });
821
+
822
+ const video = document.getElementById('webcamVideo');
823
+ video.srcObject = webcamStream;
824
+ await video.play();
825
+
826
+ const fd = new FormData();
827
+ fd.append('scene_name', document.getElementById('sceneName').value || 'webcam');
828
+ fd.append('group_id', document.getElementById('groupId').value || 'group_01');
829
+ fd.append('classes', selected.join(','));
830
+ fd.append('conf', document.getElementById('confVal').value);
831
+ fd.append('model', document.getElementById('modelSel').value);
832
+
833
+ const r = await fetch(`${API_BASE}/webcam/start`, { method: 'POST', body: fd });
834
+ if (!r.ok) throw new Error(await r.text());
835
+ const data = await r.json();
836
+ webcamSid = data.session_id;
837
+ currentSid = webcamSid;
838
+ document.getElementById('sceneName').value = data.scene_name;
839
+
840
+ document.getElementById('noSignal').style.display = 'none';
841
+ canvas.style.display = 'block';
842
+ document.getElementById('bufferInfo').style.display = 'block';
843
+ document.getElementById('bufferInfo').textContent = 'WEBCAM';
844
+ toast(`Webcam session: ${webcamSid}`, 'ok');
845
+
846
+ webcamTimer = setInterval(captureWebcamFrame, 220);
847
+ } catch (e) {
848
+ toast('Webcam failed: ' + e.message, 'err');
849
+ await stopWebcam(false);
850
+ }
851
+ }
852
+
853
+ async function captureWebcamFrame() {
854
+ if (!webcamSid || webcamBusy) return;
855
+ const video = document.getElementById('webcamVideo');
856
+ if (!video.videoWidth || !video.videoHeight) return;
857
+
858
+ webcamBusy = true;
859
+ try {
860
+ const capture = document.createElement('canvas');
861
+ capture.width = video.videoWidth;
862
+ capture.height = video.videoHeight;
863
+ capture.getContext('2d').drawImage(video, 0, 0, capture.width, capture.height);
864
+
865
+ const blob = await new Promise(resolve => capture.toBlob(resolve, 'image/jpeg', 0.75));
866
+ if (!blob) return;
867
+
868
+ const fd = new FormData();
869
+ fd.append('file', blob, 'webcam.jpg');
870
+ const r = await fetch(`${API_BASE}/webcam/frame/${encodeURIComponent(webcamSid)}`, {
871
+ method: 'POST',
872
+ body: fd,
873
+ });
874
+ if (!r.ok) return;
875
+
876
+ const data = await r.json();
877
+ const img = new Image();
878
+ img.onload = () => {
879
+ if (canvas.width !== img.naturalWidth) canvas.width = img.naturalWidth;
880
+ if (canvas.height !== img.naturalHeight) canvas.height = img.naturalHeight;
881
+ ctx.drawImage(img, 0, 0);
882
+ };
883
+ img.src = 'data:image/jpeg;base64,' + data.frame;
884
+ applyStats(data.stats);
885
+ document.getElementById('bufferInfo').textContent = 'WEBCAM LIVE';
886
+ } finally {
887
+ webcamBusy = false;
888
+ }
889
+ }
890
+
891
+ async function stopWebcam(showToast = true) {
892
+ if (webcamTimer) {
893
+ clearInterval(webcamTimer);
894
+ webcamTimer = null;
895
+ }
896
+ if (webcamStream) {
897
+ webcamStream.getTracks().forEach(track => track.stop());
898
+ webcamStream = null;
899
+ }
900
+ const sid = webcamSid;
901
+ webcamSid = null;
902
+ webcamBusy = false;
903
+
904
+ if (sid) {
905
+ try {
906
+ await fetch(`${API_BASE}/webcam/stop/${encodeURIComponent(sid)}`, { method: 'POST' });
907
+ loadDashboard();
908
+ } catch {}
909
+ }
910
+
911
+ setUI('idle');
912
+ if (showToast) toast('Webcam stopped.', '');
913
  }
914
 
915
  function setUI(state) {
916
  const start = document.getElementById('startBtn');
917
  const stop = document.getElementById('stopBtn');
918
+ const webcam = document.getElementById('webcamBtn');
919
  if (state === 'processing') {
920
  start.disabled = true;
921
+ webcam.disabled = true;
922
  stop.style.display = 'block';
923
  setConn('proc', 'PROCESSING');
924
  } else {
925
  start.disabled = false;
926
+ webcam.disabled = false;
927
  stop.style.display = 'none';
928
  document.getElementById('progFill').style.width = '0%';
929
  document.getElementById('progLabel').textContent = '';