Zhen Ye Claude Opus 4.6 commited on
Commit
f2f129a
·
1 Parent(s): d35f19d

feat: add timeline heatmap with detection highlights for Tab 2 video player

Browse files

Add a visual progress bar beneath the video in Track & Monitor tab that
shows which frames have detected objects. Backend summary endpoint returns
per-frame detection counts in one call, frontend renders a color-coded
heatmap (amber for few tracks, red for 5+) with a scrubable playhead.

- Backend: GET /detect/tracks/{job_id}/summary endpoint + JobStorage.get_track_summary()
- Frontend: timeline.js canvas bar with heatmap render + click-to-seek
- Frontend: loadSummary() fetches heatmap data when job completes
- CLAUDE.md: document parallel execution with team mode pattern

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

CLAUDE.md CHANGED
@@ -220,6 +220,32 @@ _schedule_cleanup(background_tasks, input_path)
220
  _schedule_cleanup(background_tasks, output_path)
221
  ```
222
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
  ## Performance Notes
224
 
225
  - **Detector Caching**: Models are loaded once and cached via `@lru_cache`
 
220
  _schedule_cleanup(background_tasks, output_path)
221
  ```
222
 
223
+ ## Parallel Execution with Team Mode
224
+
225
+ When implementing features that touch independent subsystems, **use team mode (parallel agents with worktree isolation)** for maximum efficiency.
226
+
227
+ ### When to Parallelize
228
+ - Backend (Python) + Frontend (JS) changes — always parallelizable
229
+ - Independent API endpoints or UI components
230
+ - Test writing + implementation when in different files
231
+ - Any 2+ tasks that don't modify the same files
232
+
233
+ ### How to Parallelize
234
+ 1. Identify independent task domains (e.g., backend vs frontend)
235
+ 2. Dispatch one agent per domain using `isolation: "worktree"`
236
+ 3. Each agent works in its own git worktree — no conflicts
237
+ 4. Merge results back: `git checkout <worktree-branch> -- <files>`
238
+ 5. Clean up worktrees after merge
239
+
240
+ ### Example
241
+ ```
242
+ Agent 1 (worktree): Backend — app.py, jobs/storage.py
243
+ Agent 2 (worktree): Frontend — timeline.js, client.js
244
+ → Both run simultaneously, merge when done
245
+ ```
246
+
247
+ **Default to parallel** when tasks are independent. Sequential only when one task's output is the other's input.
248
+
249
  ## Performance Notes
250
 
251
  - **Detector Caching**: Models are loaded once and cached via `@lru_cache`
app.py CHANGED
@@ -593,6 +593,38 @@ async def detect_status(job_id: str):
593
  }
594
 
595
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
596
  @app.get("/detect/tracks/{job_id}/{frame_idx}")
597
  async def get_frame_tracks(job_id: str, frame_idx: int):
598
  """Retrieve detections (with tracking info) for a specific frame."""
 
593
  }
594
 
595
 
596
+ @app.get("/detect/tracks/{job_id}/summary")
597
+ async def get_track_summary_endpoint(job_id: str):
598
+ """Return per-frame detection counts for timeline heatmap."""
599
+ from jobs.storage import get_track_summary, get_job_storage
600
+ import cv2
601
+
602
+ job = get_job_storage().get(job_id)
603
+ if not job:
604
+ raise HTTPException(status_code=404, detail="Job not found")
605
+
606
+ summary = get_track_summary(job_id)
607
+
608
+ total_frames = 0
609
+ fps = 30.0
610
+ video_path = job.output_video_path
611
+ if video_path:
612
+ cap = cv2.VideoCapture(video_path)
613
+ if cap.isOpened():
614
+ total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
615
+ fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
616
+ cap.release()
617
+
618
+ if total_frames == 0 and summary:
619
+ total_frames = max(summary.keys()) + 1
620
+
621
+ return {
622
+ "total_frames": total_frames,
623
+ "fps": fps,
624
+ "frames": summary,
625
+ }
626
+
627
+
628
  @app.get("/detect/tracks/{job_id}/{frame_idx}")
629
  async def get_frame_tracks(job_id: str, frame_idx: int):
630
  """Retrieve detections (with tracking info) for a specific frame."""
frontend/index.html CHANGED
@@ -222,6 +222,10 @@
222
  </div>
223
  </div>
224
 
 
 
 
 
225
  <div class="btnrow mt-md">
226
  <button id="btnEngage" class="btn">Track</button>
227
  <button id="btnPause" class="btn secondary">Pause</button>
@@ -287,6 +291,7 @@
287
  <script src="./js/ui/logging.js"></script>
288
  <script src="./js/core/gptMapping.js"></script>
289
  <script src="./js/core/tracker.js"></script>
 
290
  <script src="./js/api/client.js"></script>
291
  <script src="./js/ui/overlays.js"></script>
292
  <script src="./js/ui/cards.js"></script>
 
222
  </div>
223
  </div>
224
 
225
+ <div id="timelineWrap" class="timeline-wrap">
226
+ <canvas id="timelineBar"></canvas>
227
+ </div>
228
+
229
  <div class="btnrow mt-md">
230
  <button id="btnEngage" class="btn">Track</button>
231
  <button id="btnPause" class="btn secondary">Pause</button>
 
291
  <script src="./js/ui/logging.js"></script>
292
  <script src="./js/core/gptMapping.js"></script>
293
  <script src="./js/core/tracker.js"></script>
294
+ <script src="./js/core/timeline.js"></script>
295
  <script src="./js/api/client.js"></script>
296
  <script src="./js/ui/overlays.js"></script>
297
  <script src="./js/ui/cards.js"></script>
frontend/js/api/client.js CHANGED
@@ -199,6 +199,8 @@ APP.api.client.pollAsyncJob = async function () {
199
 
200
  clearInterval(state.hf.asyncPollInterval);
201
  state.hf.completedJobId = state.hf.asyncJobId; // preserve for post-completion sync
 
 
202
  state.hf.asyncJobId = null;
203
  setHfStatus("ready");
204
  resolve();
 
199
 
200
  clearInterval(state.hf.asyncPollInterval);
201
  state.hf.completedJobId = state.hf.asyncJobId; // preserve for post-completion sync
202
+ // Fetch timeline summary for heatmap
203
+ APP.core.timeline.loadSummary();
204
  state.hf.asyncJobId = null;
205
  setHfStatus("ready");
206
  resolve();
frontend/js/core/state.js CHANGED
@@ -59,7 +59,8 @@ APP.core.state = {
59
  lastFrameTime: 0,
60
  frameCount: 0,
61
  _lastCardRenderFrame: 0, // Frame count at last card render
62
- _gptBusy: false // Prevent overlapping GPT calls
 
63
  },
64
 
65
  frame: {
 
59
  lastFrameTime: 0,
60
  frameCount: 0,
61
  _lastCardRenderFrame: 0, // Frame count at last card render
62
+ _gptBusy: false, // Prevent overlapping GPT calls
63
+ heatmap: {} // { frameIdx: trackCount } for timeline heatmap
64
  },
65
 
66
  frame: {
frontend/js/core/timeline.js ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * timeline.js — Lazy heatmap timeline bar for Tab 2 (Track & Monitor).
3
+ *
4
+ * Renders a thin canvas bar showing:
5
+ * - Detection density heatmap (colored by track count per frame)
6
+ * - White playhead at current video position
7
+ * - Click-to-seek interaction
8
+ *
9
+ * Heatmap data is populated lazily by tracker.syncWithBackend() writing
10
+ * track counts into state.tracker.heatmap[frameIdx].
11
+ */
12
+ APP.core.timeline = {};
13
+
14
+ (function () {
15
+ const COLORS = {
16
+ bg: "#1a1a2e", // unvisited
17
+ empty: "#2a2a3e", // visited, 0 tracks
18
+ low: "rgba(245, 158, 11, 0.4)", // 1-2 tracks
19
+ mid: "rgba(245, 158, 11, 0.7)", // 3-4 tracks
20
+ high: "rgba(239, 68, 68, 0.9)", // 5+ tracks
21
+ playhead: "#ffffff",
22
+ };
23
+
24
+ function colorForCount(count) {
25
+ if (count === undefined) return COLORS.bg;
26
+ if (count === 0) return COLORS.empty;
27
+ if (count <= 2) return COLORS.low;
28
+ if (count <= 4) return COLORS.mid;
29
+ return COLORS.high;
30
+ }
31
+
32
+ /** Render the timeline heatmap + playhead. Called every RAF frame. */
33
+ APP.core.timeline.render = function () {
34
+ const { state } = APP.core;
35
+ const { $ } = APP.core.utils;
36
+ const canvas = $("#timelineBar");
37
+ const video = $("#videoEngage");
38
+ if (!canvas || !video) return;
39
+
40
+ // Cache duration once metadata loads (video.duration is NaN before that)
41
+ if (video.duration && isFinite(video.duration)) {
42
+ APP.core.timeline._cachedDuration = video.duration;
43
+ }
44
+ const duration = APP.core.timeline._cachedDuration;
45
+ if (!duration) return;
46
+
47
+ const wrap = canvas.parentElement;
48
+ const rect = wrap.getBoundingClientRect();
49
+ const dpr = window.devicePixelRatio || 1;
50
+ const w = Math.floor(rect.width);
51
+ const h = 14;
52
+
53
+ if (canvas.width !== w * dpr || canvas.height !== h * dpr) {
54
+ canvas.width = w * dpr;
55
+ canvas.height = h * dpr;
56
+ canvas.style.width = w + "px";
57
+ canvas.style.height = h + "px";
58
+ }
59
+
60
+ const ctx = canvas.getContext("2d");
61
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
62
+
63
+ const fps = 30;
64
+ const totalFrames = Math.ceil(duration * fps);
65
+ if (totalFrames <= 0) return;
66
+
67
+ const heatmap = state.tracker.heatmap;
68
+
69
+ // Draw heatmap segments
70
+ // Group consecutive pixels mapping to same color to reduce draw calls
71
+ const pxPerFrame = w / totalFrames;
72
+
73
+ if (pxPerFrame >= 1) {
74
+ // Enough room to draw per-frame
75
+ for (let f = 0; f < totalFrames; f++) {
76
+ const x = Math.floor(f * pxPerFrame);
77
+ const nx = Math.floor((f + 1) * pxPerFrame);
78
+ ctx.fillStyle = colorForCount(heatmap[f]);
79
+ ctx.fillRect(x, 0, Math.max(nx - x, 1), h);
80
+ }
81
+ } else {
82
+ // More frames than pixels — bucket frames per pixel
83
+ for (let px = 0; px < w; px++) {
84
+ const fStart = Math.floor((px / w) * totalFrames);
85
+ const fEnd = Math.floor(((px + 1) / w) * totalFrames);
86
+ let maxCount;
87
+ for (let f = fStart; f < fEnd; f++) {
88
+ const c = heatmap[f];
89
+ if (c !== undefined && (maxCount === undefined || c > maxCount)) {
90
+ maxCount = c;
91
+ }
92
+ }
93
+ ctx.fillStyle = colorForCount(maxCount);
94
+ ctx.fillRect(px, 0, 1, h);
95
+ }
96
+ }
97
+
98
+ // Draw playhead
99
+ const progress = video.currentTime / duration;
100
+ const px = Math.round(progress * w);
101
+ ctx.fillStyle = COLORS.playhead;
102
+ ctx.fillRect(px - 1, 0, 2, h);
103
+ };
104
+
105
+ /** Initialize click-to-seek on the timeline canvas. */
106
+ APP.core.timeline.init = function () {
107
+ const { $ } = APP.core.utils;
108
+ const canvas = $("#timelineBar");
109
+ const video = $("#videoEngage");
110
+ if (!canvas || !video) return;
111
+
112
+ let dragging = false;
113
+
114
+ function seek(e) {
115
+ const rect = canvas.getBoundingClientRect();
116
+ const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
117
+ const ratio = x / rect.width;
118
+ const dur = APP.core.timeline._cachedDuration || video.duration;
119
+ if (dur && isFinite(dur)) {
120
+ video.currentTime = ratio * dur;
121
+ }
122
+ }
123
+
124
+ canvas.addEventListener("mousedown", function (e) {
125
+ dragging = true;
126
+ seek(e);
127
+ });
128
+ window.addEventListener("mousemove", function (e) {
129
+ if (dragging) seek(e);
130
+ });
131
+ window.addEventListener("mouseup", function () {
132
+ dragging = false;
133
+ });
134
+ };
135
+
136
+ /** Fetch track summary from backend and populate heatmap + duration. */
137
+ APP.core.timeline.loadSummary = async function () {
138
+ const { state } = APP.core;
139
+ const jobId = state.hf.asyncJobId || state.hf.completedJobId;
140
+ if (!jobId || !state.hf.baseUrl) return;
141
+
142
+ try {
143
+ const resp = await fetch(`${state.hf.baseUrl}/detect/tracks/${jobId}/summary`);
144
+ if (!resp.ok) return;
145
+
146
+ const data = await resp.json();
147
+ const frames = data.frames || {};
148
+
149
+ // Populate heatmap — keys come as strings from JSON, convert to int
150
+ state.tracker.heatmap = {};
151
+ for (const [idx, count] of Object.entries(frames)) {
152
+ state.tracker.heatmap[parseInt(idx, 10)] = count;
153
+ }
154
+
155
+ // Cache duration from backend metadata (bypass video.duration dependency)
156
+ if (data.total_frames > 0 && data.fps > 0) {
157
+ APP.core.timeline._cachedDuration = data.total_frames / data.fps;
158
+ }
159
+
160
+ console.log(`[timeline] Loaded summary: ${Object.keys(frames).length} frames, duration=${APP.core.timeline._cachedDuration}s`);
161
+ } catch (e) {
162
+ console.warn("[timeline] Failed to load summary", e);
163
+ }
164
+ };
165
+ })();
frontend/js/core/tracker.js CHANGED
@@ -250,6 +250,9 @@ APP.core.tracker.syncWithBackend = async function (frameIdx) {
250
  APP.ui.logging.log(`New objects: ${brandNew.map(t => t.id).join(", ")}`, "t");
251
  }
252
 
 
 
 
253
  // Update state
254
  state.tracker.tracks = newTracks;
255
  state.detections = newTracks; // Keep synced
 
250
  APP.ui.logging.log(`New objects: ${brandNew.map(t => t.id).join(", ")}`, "t");
251
  }
252
 
253
+ // Cache track count for timeline heatmap
254
+ state.tracker.heatmap[frameIdx] = newTracks.length;
255
+
256
  // Update state
257
  state.tracker.tracks = newTracks;
258
  state.detections = newTracks; // Keep synced
frontend/js/main.js CHANGED
@@ -63,6 +63,9 @@ document.addEventListener("DOMContentLoaded", () => {
63
  // Enable click-to-select on engage overlay
64
  initClickHandler();
65
 
 
 
 
66
  // Start main loop
67
  requestAnimationFrame(loop);
68
 
@@ -192,6 +195,8 @@ document.addEventListener("DOMContentLoaded", () => {
192
  state.tracker.tracks = [];
193
  state.tracker.running = false;
194
  state.tracker.nextId = 1;
 
 
195
  renderFrameTrackList();
196
  log("Tracking reset.", "t");
197
  });
@@ -725,7 +730,9 @@ document.addEventListener("DOMContentLoaded", () => {
725
  const jobId = state.hf.asyncJobId || state.hf.completedJobId;
726
  if (jobId && (t - state.tracker.lastHFSync > 333)) {
727
  const frameIdx = Math.floor(videoEngage.currentTime * 30);
728
- APP.core.tracker.syncWithBackend(frameIdx);
 
 
729
  state.tracker.lastHFSync = t;
730
  }
731
  }
@@ -773,6 +780,7 @@ document.addEventListener("DOMContentLoaded", () => {
773
  if (renderFrameOverlay) renderFrameOverlay();
774
  if (renderEngageOverlay) renderEngageOverlay();
775
  if (tickAgentCursor) tickAgentCursor();
 
776
 
777
  requestAnimationFrame(loop);
778
  }
 
63
  // Enable click-to-select on engage overlay
64
  initClickHandler();
65
 
66
+ // Initialize timeline seek interaction
67
+ APP.core.timeline.init();
68
+
69
  // Start main loop
70
  requestAnimationFrame(loop);
71
 
 
195
  state.tracker.tracks = [];
196
  state.tracker.running = false;
197
  state.tracker.nextId = 1;
198
+ state.tracker.heatmap = {};
199
+ APP.core.timeline._cachedDuration = null;
200
  renderFrameTrackList();
201
  log("Tracking reset.", "t");
202
  });
 
730
  const jobId = state.hf.asyncJobId || state.hf.completedJobId;
731
  if (jobId && (t - state.tracker.lastHFSync > 333)) {
732
  const frameIdx = Math.floor(videoEngage.currentTime * 30);
733
+ if (isFinite(frameIdx) && frameIdx >= 0) {
734
+ APP.core.tracker.syncWithBackend(frameIdx);
735
+ }
736
  state.tracker.lastHFSync = t;
737
  }
738
  }
 
780
  if (renderFrameOverlay) renderFrameOverlay();
781
  if (renderEngageOverlay) renderEngageOverlay();
782
  if (tickAgentCursor) tickAgentCursor();
783
+ APP.core.timeline.render();
784
 
785
  requestAnimationFrame(loop);
786
  }
frontend/style.css CHANGED
@@ -545,6 +545,25 @@ input[type="number"]:focus {
545
  line-height: 1.4;
546
  }
547
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
548
  /* =========================================
549
  Lists & Tables
550
  ========================================= */
 
545
  line-height: 1.4;
546
  }
547
 
548
+ /* =========================================
549
+ Timeline Heatmap Bar
550
+ ========================================= */
551
+
552
+ .timeline-wrap {
553
+ width: 100%;
554
+ margin-top: 6px;
555
+ border-radius: 4px;
556
+ overflow: hidden;
557
+ background: #1a1a2e;
558
+ cursor: pointer;
559
+ }
560
+
561
+ .timeline-wrap canvas {
562
+ display: block;
563
+ width: 100%;
564
+ height: 14px;
565
+ }
566
+
567
  /* =========================================
568
  Lists & Tables
569
  ========================================= */
jobs/storage.py CHANGED
@@ -55,6 +55,12 @@ class JobStorage:
55
  with self._lock:
56
  return self._tracks.get(job_id, {}).get(frame_idx, [])
57
 
 
 
 
 
 
 
58
  def get(self, job_id: str) -> Optional[JobInfo]:
59
  with self._lock:
60
  return self._jobs.get(job_id)
@@ -98,3 +104,6 @@ def get_track_data(job_id: str, frame_idx: int) -> list:
98
 
99
  def set_track_data(job_id: str, frame_idx: int, tracks: list) -> None:
100
  get_job_storage().set_track_data(job_id, frame_idx, tracks)
 
 
 
 
55
  with self._lock:
56
  return self._tracks.get(job_id, {}).get(frame_idx, [])
57
 
58
+ def get_track_summary(self, job_id: str) -> dict:
59
+ """Return {frame_idx: track_count} for all stored frames."""
60
+ with self._lock:
61
+ frames = self._tracks.get(job_id, {})
62
+ return {idx: len(tracks) for idx, tracks in frames.items()}
63
+
64
  def get(self, job_id: str) -> Optional[JobInfo]:
65
  with self._lock:
66
  return self._jobs.get(job_id)
 
104
 
105
  def set_track_data(job_id: str, frame_idx: int, tracks: list) -> None:
106
  get_job_storage().set_track_data(job_id, frame_idx, tracks)
107
+
108
+ def get_track_summary(job_id: str) -> dict:
109
+ return get_job_storage().get_track_summary(job_id)