Zhen Ye Claude Opus 4.6 (1M context) commited on
Commit
d46e61c
·
1 Parent(s): 675eb04

feat(inspection): quad view redesign — 2x2 simultaneous seg/edge/depth/3D

Browse files

Replace single-viewport tab-switched inspection panel with simultaneous
2x2 quad view. All four analysis layers (segmentation, edge detection,
depth map, 3D mesh) render in parallel when a track is selected.

- New quad grid CSS with per-quadrant styling, corner brackets, expand states
- Per-quadrant loading/error overlays instead of panel-wide overlay
- Click any quadrant to expand full-size, Escape to collapse
- Header shows object name, confidence, mission status badge
- Bottom metrics strip: velocity, depth, area, tracked frames
- Remove attention heatmap and super-resolution modes
- Clean up dead code: dispatch(), inferno colormap, fetchAttention, fetchSuperRes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

frontend/index.html CHANGED
@@ -175,34 +175,51 @@
175
  </div>
176
 
177
  <div class="panel panel-inspection" id="inspectionPanel" style="display:none">
178
- <h3>
179
- <span>Object Inspection</span>
180
- <div style="display: flex; gap: 8px; align-items: center;">
181
- <span class="rightnote" id="inspectionTarget">No object selected</span>
182
- <button class="collapse-btn" id="btnCloseInspection">Close</button>
 
 
 
 
183
  </div>
184
- </h3>
185
-
186
- <div class="inspection-toolbar" id="inspectionToolbar">
187
- <button class="insp-mode-btn active" data-mode="seg" title="Segmentation mask overlay">Seg</button>
188
- <button class="insp-mode-btn" data-mode="edge" title="Edge detection (Sobel)">Edge</button>
189
- <button class="insp-mode-btn" data-mode="depth" title="Depth colormap">Depth</button>
190
- <button class="insp-mode-btn" data-mode="attention" title="Attention heatmap">Attention</button>
191
- <button class="insp-mode-btn" data-mode="superres" title="Super-resolution enhancement">Hi-Res</button>
192
- <button class="insp-mode-btn" data-mode="3d" title="3D point cloud (rotatable)">3D</button>
193
  </div>
194
 
195
- <div class="inspection-viewport" id="inspectionViewport">
196
- <canvas id="inspectionCanvas"></canvas>
197
- <div id="inspection3dContainer" style="display:none"></div>
198
- <div class="inspection-loading" id="inspectionLoading" style="display:none">
199
- <div class="inspection-spinner"></div>
200
- <span>Loading...</span>
 
201
  </div>
202
- <div class="inspection-error" id="inspectionError" style="display:none"></div>
203
- <div class="inspection-empty" id="inspectionEmpty">
204
- Select an object from the track cards or click a bounding box to inspect it.
 
205
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  </div>
207
  </div>
208
 
 
175
  </div>
176
 
177
  <div class="panel panel-inspection" id="inspectionPanel" style="display:none">
178
+ <div class="inspection-header">
179
+ <div class="inspection-header-left">
180
+ <span class="inspection-obj-name" id="inspectionObjName">--</span>
181
+ <span class="inspection-conf" id="inspectionConf">--</span>
182
+ <span class="inspection-status pending" id="inspectionStatus">PENDING</span>
183
+ </div>
184
+ <div class="inspection-header-right">
185
+ <span class="inspection-frame" id="inspectionFrame">FRM -- / --</span>
186
+ <button class="collapse-btn" id="btnCloseInspection">CLOSE</button>
187
  </div>
 
 
 
 
 
 
 
 
 
188
  </div>
189
 
190
+ <div class="inspection-quad" id="inspectionQuad">
191
+ <div class="inspection-quadrant" data-mode="seg" id="quadSeg">
192
+ <div class="quad-label"><span class="quad-dot quad-dot-seg"></span>SEGMENTATION</div>
193
+ <canvas class="quad-canvas" id="quadCanvasSeg"></canvas>
194
+ <div class="quad-metric" id="quadMetricSeg"></div>
195
+ <div class="quad-loading" style="display:none"><div class="inspection-spinner"></div></div>
196
+ <div class="quad-error" style="display:none"></div>
197
  </div>
198
+ <div class="inspection-quadrant" data-mode="edge" id="quadEdge">
199
+ <div class="quad-label"><span class="quad-dot quad-dot-edge"></span>EDGE</div>
200
+ <canvas class="quad-canvas" id="quadCanvasEdge"></canvas>
201
+ <div class="quad-metric" id="quadMetricEdge">SOBEL</div>
202
  </div>
203
+ <div class="inspection-quadrant" data-mode="depth" id="quadDepth">
204
+ <div class="quad-label"><span class="quad-dot quad-dot-depth"></span>DEPTH</div>
205
+ <canvas class="quad-canvas" id="quadCanvasDepth"></canvas>
206
+ <div class="quad-metric" id="quadMetricDepth"></div>
207
+ <div class="quad-loading" style="display:none"><div class="inspection-spinner"></div></div>
208
+ <div class="quad-error" style="display:none"></div>
209
+ </div>
210
+ <div class="inspection-quadrant" data-mode="3d" id="quad3d">
211
+ <div class="quad-label"><span class="quad-dot quad-dot-3d"></span>3D MESH</div>
212
+ <div class="quad-3d-container" id="quad3dContainer"></div>
213
+ <div class="quad-metric" id="quadMetric3d"></div>
214
+ <div class="quad-loading" style="display:none"><div class="inspection-spinner"></div></div>
215
+ <div class="quad-error" style="display:none"></div>
216
+ </div>
217
+ </div>
218
+
219
+ <div class="inspection-metrics" id="inspectionMetrics"></div>
220
+
221
+ <div class="inspection-empty" id="inspectionEmpty">
222
+ Select an object from the track cards or click a bounding box to inspect it.
223
  </div>
224
  </div>
225
 
frontend/js/api/inspection-api.js CHANGED
@@ -151,75 +151,6 @@ APP.api.inspection._decodeDepthJson = function (json) {
151
  };
152
  };
153
 
154
- /**
155
- * Fetch attention heatmap for a specific track on a specific frame.
156
- * @param {string} jobId
157
- * @param {number} frameIdx
158
- * @param {string} trackId
159
- * @returns {Promise<Object>} { width, height, data: Float32Array }
160
- */
161
- APP.api.inspection.fetchAttention = async function (jobId, frameIdx, trackId) {
162
- const base = APP.core.state.hf.baseUrl;
163
- const url = `${base}/inspect/attention/${jobId}/${frameIdx}/${encodeURIComponent(trackId)}?format=json`;
164
- const resp = await fetch(url);
165
- if (!resp.ok) throw new Error(`Attention fetch failed: ${resp.status}`);
166
-
167
- const json = await resp.json();
168
- const raw = atob(json.data_b64);
169
- const buf = new ArrayBuffer(raw.length);
170
- const view = new Uint8Array(buf);
171
- for (let i = 0; i < raw.length; i++) view[i] = raw.charCodeAt(i);
172
-
173
- return {
174
- width: json.width,
175
- height: json.height,
176
- data: new Float32Array(buf),
177
- trackId: json.track_id || trackId,
178
- frameIdx: json.frame_idx || frameIdx
179
- };
180
- };
181
-
182
- /**
183
- * Request super-resolution enhancement of a cropped region.
184
- * @param {string} jobId
185
- * @param {number} frameIdx
186
- * @param {string} trackId
187
- * @param {number} scale upscale factor (default 4)
188
- * @returns {Promise<HTMLImageElement>}
189
- */
190
- APP.api.inspection.fetchSuperRes = async function (jobId, frameIdx, trackId, scale) {
191
- const base = APP.core.state.hf.baseUrl;
192
- scale = scale || 4;
193
-
194
- // Poll loop: backend may return 202 while processing
195
- const MAX_POLLS = 20;
196
- for (let attempt = 0; attempt < MAX_POLLS; attempt++) {
197
- const resp = await fetch(`${base}/inspect/super-resolve/${jobId}/${frameIdx}`, {
198
- method: "POST",
199
- headers: { "Content-Type": "application/json" },
200
- body: JSON.stringify({ track_id: trackId, scale: scale, padding: 0.20 })
201
- });
202
-
203
- if (resp.status === 202) {
204
- await new Promise(r => setTimeout(r, 2000));
205
- continue;
206
- }
207
- if (!resp.ok) throw new Error(`Super-resolve failed: ${resp.status}`);
208
-
209
- const blob = await resp.blob();
210
- const blobUrl = URL.createObjectURL(blob);
211
- return new Promise((resolve, reject) => {
212
- const img = new Image();
213
- img.onload = () => resolve(img);
214
- img.onerror = () => reject(new Error("Failed to decode super-res image"));
215
- img.src = blobUrl;
216
- img._blobUrl = blobUrl;
217
- });
218
- }
219
-
220
- throw new Error("Super-resolve timed out after polling");
221
- };
222
-
223
  /**
224
  * Fetch 3D mesh or point cloud data for a specific object.
225
  * @param {string} jobId
 
151
  };
152
  };
153
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  /**
155
  * Fetch 3D mesh or point cloud data for a specific object.
156
  * @param {string} jobId
frontend/js/core/state.js CHANGED
@@ -71,22 +71,19 @@ APP.core.state = {
71
  },
72
 
73
  inspection: {
74
- visible: false, // panel open/closed
75
- trackId: null, // currently inspected track ID (e.g., "T01")
76
- mode: "seg", // active viz: "seg"|"edge"|"depth"|"attention"|"superres"|"3d"
77
- frameIdx: null, // frame index being inspected
78
- frameImageUrl: null, // blob URL of the raw frame JPEG
79
- loading: false, // true while fetching data
80
- error: null, // error message string or null
81
 
82
- // Per-mode cached data (cleared on track/frame change)
83
  cache: {
84
- seg: null, // { mask, color, bbox } from API
85
- edge: null, // ImageData from client-side Sobel
86
- depth: null, // { width, height, data (Float32Array), min, max }
87
- attention: null, // { width, height, data (Float32Array) }
88
- superres: null, // blob URL of enhanced image
89
- pointcloud: null // { positions, colors, numPoints, bbox3d }
90
  }
91
  }
92
  };
 
71
  },
72
 
73
  inspection: {
74
+ visible: false,
75
+ trackId: null,
76
+ frameIdx: null,
77
+ frameImageUrl: null,
78
+ loading: false,
79
+ error: null,
80
+ expandedQuadrant: null, // null | "seg" | "edge" | "depth" | "3d"
81
 
 
82
  cache: {
83
+ seg: null,
84
+ edge: null,
85
+ depth: null,
86
+ pointcloud: null
 
 
87
  }
88
  }
89
  };
frontend/js/ui/inspection-3d.js CHANGED
@@ -29,20 +29,16 @@ APP.ui.inspection3d.render = async function (data) {
29
  if (!data || !data.positions || !data.colors) return;
30
 
31
  const { $ } = APP.core.utils;
32
- const container = $("#inspection3dContainer");
33
  if (!container) return;
34
 
35
- APP.ui.inspection._setLoading(true, "3d");
36
-
37
  try {
38
  await APP.ui.inspection3d._ensureThreeJs();
39
  } catch (err) {
40
- APP.ui.inspection._setLoading(false);
41
- APP.ui.inspection._setError("Failed to load 3D libraries: " + err.message);
42
  return;
43
  }
44
 
45
- APP.ui.inspection._setLoading(false);
46
  APP.ui.inspection3d.dispose();
47
  APP.ui.inspection3d._disposed = false;
48
 
 
29
  if (!data || !data.positions || !data.colors) return;
30
 
31
  const { $ } = APP.core.utils;
32
+ const container = $("#quad3dContainer");
33
  if (!container) return;
34
 
 
 
35
  try {
36
  await APP.ui.inspection3d._ensureThreeJs();
37
  } catch (err) {
38
+ console.error("Failed to load 3D libraries:", err);
 
39
  return;
40
  }
41
 
 
42
  APP.ui.inspection3d.dispose();
43
  APP.ui.inspection3d._disposed = false;
44
 
frontend/js/ui/inspection-renders.js CHANGED
@@ -1,37 +1,6 @@
1
  // Inspection Renderers — per-mode canvas drawing functions
2
  APP.ui.inspectionRenders = {};
3
 
4
- /**
5
- * Dispatch rendering to the correct mode handler.
6
- * @param {string} mode
7
- * @param {HTMLImageElement} frameImg — the base video frame
8
- * @param {Object} cache — state.inspection.cache
9
- * @param {Object} track — the selected track object with .bbox
10
- */
11
- APP.ui.inspectionRenders.dispatch = function (mode, frameImg, cache, track) {
12
- const { $ } = APP.core.utils;
13
- const canvas = $("#inspectionCanvas");
14
- if (!canvas) return;
15
-
16
- switch (mode) {
17
- case "seg":
18
- APP.ui.inspectionRenders._renderSeg(canvas, frameImg, cache.seg, track);
19
- break;
20
- case "edge":
21
- APP.ui.inspectionRenders._renderEdge(canvas, frameImg, cache.edge, track);
22
- break;
23
- case "depth":
24
- APP.ui.inspectionRenders._renderDepth(canvas, frameImg, cache.depth, track);
25
- break;
26
- case "attention":
27
- APP.ui.inspectionRenders._renderAttention(canvas, frameImg, cache.attention, track);
28
- break;
29
- case "superres":
30
- APP.ui.inspectionRenders._renderSuperRes(canvas, cache.superres, track);
31
- break;
32
- }
33
- };
34
-
35
  /**
36
  * Render segmentation mask overlay on the frame.
37
  * Shows the full frame dimmed, with the selected object's mask highlighted.
@@ -427,173 +396,6 @@ APP.ui.inspectionRenders._drawTrackDepthStats = function (ctx, track, canvasW, c
427
  ctx.fillText(`Depth: ${avgDepth.toFixed(1)}m (${localMin.toFixed(1)}-${localMax.toFixed(1)})`, bx + 4, by + 16);
428
  };
429
 
430
- /**
431
- * Render attention heatmap (GradCAM) overlaid on the base frame.
432
- * Uses inferno colormap with semi-transparent blending.
433
- */
434
- APP.ui.inspectionRenders._renderAttention = function (canvas, frameImg, attentionData, track) {
435
- if (!frameImg) return;
436
-
437
- const w = frameImg.naturalWidth || frameImg.width;
438
- const h = frameImg.naturalHeight || frameImg.height;
439
- canvas.width = w;
440
- canvas.height = h;
441
- const ctx = canvas.getContext("2d");
442
-
443
- // Draw base frame (slightly dimmed to make heatmap more visible)
444
- ctx.drawImage(frameImg, 0, 0);
445
- ctx.fillStyle = "rgba(0, 0, 0, 0.2)";
446
- ctx.fillRect(0, 0, w, h);
447
-
448
- if (!attentionData || !attentionData.data) {
449
- ctx.fillStyle = "rgba(0,0,0,0.5)";
450
- ctx.fillRect(0, 0, w, h);
451
- ctx.fillStyle = "#aaa";
452
- ctx.font = "14px monospace";
453
- ctx.textAlign = "center";
454
- ctx.fillText("Attention data not available", w / 2, h / 2);
455
- return;
456
- }
457
-
458
- // Render attention as inferno colormap overlay
459
- const aw = attentionData.width;
460
- const ah = attentionData.height;
461
- const ad = attentionData.data;
462
-
463
- const heatCanvas = document.createElement("canvas");
464
- heatCanvas.width = aw;
465
- heatCanvas.height = ah;
466
- const hctx = heatCanvas.getContext("2d");
467
- const img = hctx.createImageData(aw, ah);
468
- const id = img.data;
469
-
470
- // Track peak attention value for stats
471
- let peakVal = 0;
472
- let sumVal = 0;
473
-
474
- for (let i = 0; i < ad.length; i++) {
475
- const t = APP.core.utils.clamp(ad[i], 0, 1);
476
- if (t > peakVal) peakVal = t;
477
- sumVal += t;
478
- const rgb = APP.ui.inspectionRenders._inferno(t);
479
- const oi = i * 4;
480
- id[oi] = rgb[0];
481
- id[oi + 1] = rgb[1];
482
- id[oi + 2] = rgb[2];
483
- // Alpha: low values are very transparent, high values are semi-opaque
484
- // Use a power curve for better visual contrast
485
- id[oi + 3] = Math.round(Math.pow(t, 0.7) * 200);
486
- }
487
-
488
- hctx.putImageData(img, 0, 0);
489
-
490
- // Use bilinear upscaling for smooth heatmap (attention resolution is typically low)
491
- ctx.imageSmoothingEnabled = true;
492
- ctx.imageSmoothingQuality = "high";
493
- ctx.drawImage(heatCanvas, 0, 0, w, h);
494
-
495
- // Draw bbox highlight
496
- APP.ui.inspectionRenders._drawBBoxHighlight(ctx, track, w, h);
497
-
498
- // Draw attention legend and stats
499
- const avgVal = ad.length > 0 ? sumVal / ad.length : 0;
500
- APP.ui.inspectionRenders._drawAttentionLegend(ctx, w, h, peakVal, avgVal);
501
- };
502
-
503
- /**
504
- * Draw an attention intensity legend with inferno colormap bar.
505
- */
506
- APP.ui.inspectionRenders._drawAttentionLegend = function (ctx, canvasW, canvasH, peakVal, avgVal) {
507
- const barW = 16;
508
- const barH = Math.min(140, canvasH - 60);
509
- const x = canvasW - barW - 30;
510
- const y = 30;
511
-
512
- // Background panel
513
- ctx.fillStyle = "rgba(0, 0, 0, 0.55)";
514
- ctx.fillRect(x - 6, y - 20, barW + 60, barH + 56);
515
- ctx.strokeStyle = "rgba(255, 255, 255, 0.15)";
516
- ctx.lineWidth = 1;
517
- ctx.strokeRect(x - 6, y - 20, barW + 60, barH + 56);
518
-
519
- // Draw gradient bar (top = high attention, bottom = low)
520
- for (let py = 0; py < barH; py++) {
521
- const t = 1 - (py / (barH - 1)); // 0 = bottom (low), 1 = top (high)
522
- const rgb = APP.ui.inspectionRenders._inferno(t);
523
- ctx.fillStyle = `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
524
- ctx.fillRect(x, y + py, barW, 1);
525
- }
526
-
527
- // Border around bar
528
- ctx.strokeStyle = "rgba(255, 255, 255, 0.3)";
529
- ctx.lineWidth = 1;
530
- ctx.strokeRect(x, y, barW, barH);
531
-
532
- // Labels
533
- ctx.fillStyle = "rgba(255, 255, 255, 0.8)";
534
- ctx.font = "10px monospace";
535
- ctx.textAlign = "left";
536
- ctx.fillText("High", x + barW + 4, y + 10);
537
- ctx.fillText("Low", x + barW + 4, y + barH);
538
-
539
- // Title
540
- ctx.fillStyle = "rgba(255, 255, 255, 0.6)";
541
- ctx.font = "9px monospace";
542
- ctx.textAlign = "center";
543
- ctx.fillText("ATTENTION", x + barW / 2, y - 6);
544
-
545
- // Stats
546
- ctx.fillStyle = "rgba(252, 255, 164, 0.8)"; // inferno yellow
547
- ctx.font = "10px monospace";
548
- ctx.textAlign = "left";
549
- ctx.fillText(`Peak: ${(peakVal * 100).toFixed(0)}%`, x - 2, y + barH + 18);
550
- ctx.fillText(`Avg: ${(avgVal * 100).toFixed(0)}%`, x - 2, y + barH + 32);
551
- };
552
-
553
- /**
554
- * Render super-resolved image centered in the viewport.
555
- */
556
- APP.ui.inspectionRenders._renderSuperRes = function (canvas, srImg, track) {
557
- if (!srImg) return;
558
-
559
- const iw = srImg.naturalWidth || srImg.width;
560
- const ih = srImg.naturalHeight || srImg.height;
561
-
562
- // Size canvas to fit the super-res image with padding
563
- const maxW = canvas.parentElement ? canvas.parentElement.clientWidth : 800;
564
- const maxH = canvas.parentElement ? canvas.parentElement.clientHeight : 400;
565
-
566
- const scale = Math.min(maxW / iw, maxH / ih, 1);
567
- const drawW = Math.round(iw * scale);
568
- const drawH = Math.round(ih * scale);
569
-
570
- canvas.width = maxW;
571
- canvas.height = maxH;
572
- const ctx = canvas.getContext("2d");
573
-
574
- // Black background
575
- ctx.fillStyle = "#000";
576
- ctx.fillRect(0, 0, maxW, maxH);
577
-
578
- // Center the image
579
- const x = Math.round((maxW - drawW) / 2);
580
- const y = Math.round((maxH - drawH) / 2);
581
- ctx.drawImage(srImg, x, y, drawW, drawH);
582
-
583
- // Label showing original vs upscaled dimensions
584
- const origW = Math.round(iw / 4);
585
- const origH = Math.round(ih / 4);
586
- ctx.fillStyle = "rgba(0,0,0,0.6)";
587
- ctx.fillRect(x, y, 260, 32);
588
- ctx.fillStyle = "rgba(255,255,255,0.7)";
589
- ctx.font = "11px monospace";
590
- ctx.textAlign = "left";
591
- ctx.fillText(`SUPER-RES ${origW}x${origH} -> ${iw}x${ih} (4x)`, x + 6, y + 14);
592
- ctx.fillStyle = "rgba(255,255,255,0.45)";
593
- ctx.font = "9px monospace";
594
- ctx.fillText(`Enhanced resolution via deep upscaling`, x + 6, y + 26);
595
- };
596
-
597
  /**
598
  * Draw a highlighted bounding box for the selected track.
599
  */
@@ -616,20 +418,6 @@ APP.ui.inspectionRenders._viridis = function (t) {
616
  return APP.ui.inspectionRenders._interpolateColormap(t, stops);
617
  };
618
 
619
- /**
620
- * Inferno-like colormap: t in [0,1] -> [R, G, B] in [0,255].
621
- */
622
- APP.ui.inspectionRenders._inferno = function (t) {
623
- const stops = [
624
- [0.0, 0, 0, 4],
625
- [0.25, 87, 16, 110],
626
- [0.5, 188, 55, 84],
627
- [0.75, 249, 142, 9],
628
- [1.0, 252, 255, 164]
629
- ];
630
- return APP.ui.inspectionRenders._interpolateColormap(t, stops);
631
- };
632
-
633
  /**
634
  * Linearly interpolate a colormap defined by stops.
635
  * @param {number} t — value in [0,1]
 
1
  // Inspection Renderers — per-mode canvas drawing functions
2
  APP.ui.inspectionRenders = {};
3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  /**
5
  * Render segmentation mask overlay on the frame.
6
  * Shows the full frame dimmed, with the selected object's mask highlighted.
 
396
  ctx.fillText(`Depth: ${avgDepth.toFixed(1)}m (${localMin.toFixed(1)}-${localMax.toFixed(1)})`, bx + 4, by + 16);
397
  };
398
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
399
  /**
400
  * Draw a highlighted bounding box for the selected track.
401
  */
 
418
  return APP.ui.inspectionRenders._interpolateColormap(t, stops);
419
  };
420
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
421
  /**
422
  * Linearly interpolate a colormap defined by stops.
423
  * @param {number} t — value in [0,1]
frontend/js/ui/inspection.js CHANGED
@@ -1,159 +1,41 @@
1
- // Inspection Panel Controller — manages panel visibility, mode switching, data loading
2
  APP.ui.inspection = {};
3
 
4
  /**
5
- * Initialize: wire toolbar buttons, close button.
6
  * Called once from main.js init().
7
  */
8
  APP.ui.inspection.init = function () {
9
  const { $ } = APP.core.utils;
10
 
11
- // Mode toolbar buttons
12
- const toolbar = $("#inspectionToolbar");
13
- if (toolbar) {
14
- toolbar.addEventListener("click", (e) => {
15
- const btn = e.target.closest(".insp-mode-btn");
16
- if (!btn) return;
17
- const mode = btn.getAttribute("data-mode");
18
- APP.ui.inspection.setMode(mode);
19
- });
20
- }
21
-
22
  // Close button
23
  const closeBtn = $("#btnCloseInspection");
24
  if (closeBtn) {
25
- closeBtn.addEventListener("click", () => {
26
- APP.ui.inspection.close();
27
- });
28
  }
29
 
30
- // --- 360° spin interaction on inspection canvas ---
31
- APP.ui.inspection._initSpin();
32
- };
33
-
34
- /**
35
- * Wire drag-to-spin 3D rotation on the inspection canvas.
36
- * Click + drag rotates the inspected object crop in 3D space.
37
- * Double-click resets to front view.
38
- */
39
- APP.ui.inspection._initSpin = function () {
40
- const { $ } = APP.core.utils;
41
- const canvas = $("#inspectionCanvas");
42
- if (!canvas) return;
43
-
44
- let dragging = false;
45
- let lastX = 0, lastY = 0;
46
- let rotX = 0, rotY = 0; // current rotation in degrees
47
- let velX = 0, velY = 0; // inertia velocity
48
- let animFrame = null;
49
-
50
- function applyTransform() {
51
- canvas.style.transform = `rotateY(${rotY}deg) rotateX(${rotX}deg)`;
52
  }
53
 
54
- canvas.addEventListener("mousedown", (e) => {
55
- if (e.button !== 0) return; // left click only
56
- dragging = true;
57
- lastX = e.clientX;
58
- lastY = e.clientY;
59
- velX = 0;
60
- velY = 0;
61
- canvas.classList.remove("spinning");
62
- if (animFrame) { cancelAnimationFrame(animFrame); animFrame = null; }
63
- e.preventDefault();
64
- });
65
-
66
- window.addEventListener("mousemove", (e) => {
67
- if (!dragging) return;
68
- const dx = e.clientX - lastX;
69
- const dy = e.clientY - lastY;
70
- rotY += dx * 0.5;
71
- rotX -= dy * 0.5;
72
- rotX = Math.max(-80, Math.min(80, rotX)); // clamp pitch
73
- velX = dx * 0.5;
74
- velY = -dy * 0.5;
75
- lastX = e.clientX;
76
- lastY = e.clientY;
77
- applyTransform();
78
- });
79
-
80
- window.addEventListener("mouseup", () => {
81
- if (!dragging) return;
82
- dragging = false;
83
- // Inertia spin
84
- if (Math.abs(velX) > 1 || Math.abs(velY) > 1) {
85
- canvas.classList.remove("spinning");
86
- const decay = () => {
87
- velX *= 0.95;
88
- velY *= 0.95;
89
- rotY += velX;
90
- rotX += velY;
91
- rotX = Math.max(-80, Math.min(80, rotX));
92
- applyTransform();
93
- if (Math.abs(velX) > 0.1 || Math.abs(velY) > 0.1) {
94
- animFrame = requestAnimationFrame(decay);
95
- }
96
- };
97
- animFrame = requestAnimationFrame(decay);
98
  }
99
  });
100
-
101
- // Double-click to reset rotation smoothly
102
- canvas.addEventListener("dblclick", () => {
103
- if (animFrame) { cancelAnimationFrame(animFrame); animFrame = null; }
104
- rotX = 0;
105
- rotY = 0;
106
- canvas.classList.add("spinning");
107
- applyTransform();
108
- setTimeout(() => canvas.classList.remove("spinning"), 800);
109
- });
110
-
111
- // Touch support
112
- canvas.addEventListener("touchstart", (e) => {
113
- if (e.touches.length !== 1) return;
114
- dragging = true;
115
- lastX = e.touches[0].clientX;
116
- lastY = e.touches[0].clientY;
117
- velX = 0;
118
- velY = 0;
119
- canvas.classList.remove("spinning");
120
- if (animFrame) { cancelAnimationFrame(animFrame); animFrame = null; }
121
- }, { passive: true });
122
-
123
- canvas.addEventListener("touchmove", (e) => {
124
- if (!dragging || e.touches.length !== 1) return;
125
- const dx = e.touches[0].clientX - lastX;
126
- const dy = e.touches[0].clientY - lastY;
127
- rotY += dx * 0.5;
128
- rotX -= dy * 0.5;
129
- rotX = Math.max(-80, Math.min(80, rotX));
130
- velX = dx * 0.5;
131
- velY = -dy * 0.5;
132
- lastX = e.touches[0].clientX;
133
- lastY = e.touches[0].clientY;
134
- applyTransform();
135
- }, { passive: true });
136
-
137
- canvas.addEventListener("touchend", () => {
138
- if (!dragging) return;
139
- dragging = false;
140
- });
141
-
142
- // Store reset function for use when switching tracks
143
- APP.ui.inspection._resetSpin = function () {
144
- if (animFrame) { cancelAnimationFrame(animFrame); animFrame = null; }
145
- rotX = 0;
146
- rotY = 0;
147
- velX = 0;
148
- velY = 0;
149
- canvas.style.transform = "";
150
- canvas.classList.remove("spinning");
151
- };
152
  };
153
 
154
  /**
155
  * Open the inspection panel for a specific track.
156
- * @param {string} trackId — e.g., "T01"
157
  */
158
  APP.ui.inspection.open = function (trackId) {
159
  const { state } = APP.core;
@@ -174,36 +56,31 @@ APP.ui.inspection.open = function (trackId) {
174
  }
175
  if (!isFinite(frameIdx) || frameIdx < 0) frameIdx = 0;
176
 
 
 
 
177
  // Update state
178
  state.inspection.trackId = trackId;
179
  state.inspection.frameIdx = frameIdx;
180
  state.inspection.visible = true;
181
  state.inspection.error = null;
182
 
183
- // Clear caches if track or frame changed
184
  APP.ui.inspection._clearCaches();
185
 
186
- // Reset 3D spin rotation
187
- if (APP.ui.inspection._resetSpin) APP.ui.inspection._resetSpin();
188
-
189
  // Show panel
190
  panel.style.display = "flex";
191
 
192
- // Update header
193
- const target = $("#inspectionTarget");
194
- const track = (state.detections || []).find(d => d.id === trackId);
195
- if (target) {
196
- target.textContent = track
197
- ? `${track.label} / Frame ${frameIdx}`
198
- : `Frame ${frameIdx}`;
199
- }
200
-
201
  // Hide empty state
202
  const empty = $("#inspectionEmpty");
203
  if (empty) empty.style.display = "none";
204
 
205
- // Load data for current mode
206
- APP.ui.inspection._loadAndRender();
 
 
 
 
207
  };
208
 
209
  /**
@@ -218,60 +95,18 @@ APP.ui.inspection.close = function () {
218
  state.inspection.loading = false;
219
  state.inspection.error = null;
220
 
 
221
  APP.ui.inspection._clearCaches();
222
 
223
- // Reset 3D spin rotation
224
- if (APP.ui.inspection._resetSpin) APP.ui.inspection._resetSpin();
225
-
226
  const panel = $("#inspectionPanel");
227
  if (panel) panel.style.display = "none";
228
 
229
- // Clean up 3D scene if active
230
  if (APP.ui.inspection3d && APP.ui.inspection3d.dispose) {
231
  APP.ui.inspection3d.dispose();
232
  }
233
  };
234
 
235
- /**
236
- * Switch visualization mode.
237
- * @param {string} mode — "seg"|"edge"|"depth"|"attention"|"superres"|"3d"
238
- */
239
- APP.ui.inspection.setMode = function (mode) {
240
- const { state } = APP.core;
241
- const { $, $$ } = APP.core.utils;
242
-
243
- const validModes = ["seg", "edge", "depth", "attention", "superres", "3d"];
244
- if (!validModes.includes(mode)) return;
245
-
246
- state.inspection.mode = mode;
247
-
248
- // Update toolbar active state
249
- const buttons = $$(".insp-mode-btn");
250
- buttons.forEach(btn => {
251
- btn.classList.toggle("active", btn.getAttribute("data-mode") === mode);
252
- });
253
-
254
- // Toggle canvas vs 3D container visibility
255
- const canvas = $("#inspectionCanvas");
256
- const container3d = $("#inspection3dContainer");
257
- if (mode === "3d") {
258
- if (canvas) canvas.style.display = "none";
259
- if (container3d) container3d.style.display = "block";
260
- } else {
261
- if (canvas) canvas.style.display = "block";
262
- if (container3d) container3d.style.display = "none";
263
- // Dispose 3D scene when switching away
264
- if (APP.ui.inspection3d && APP.ui.inspection3d.dispose) {
265
- APP.ui.inspection3d.dispose();
266
- }
267
- }
268
-
269
- // Load data if panel is open
270
- if (state.inspection.visible && state.inspection.trackId) {
271
- APP.ui.inspection._loadAndRender();
272
- }
273
- };
274
-
275
  /**
276
  * Refresh inspection for the current video time (called when user seeks).
277
  */
@@ -289,37 +124,57 @@ APP.ui.inspection.refreshFrame = function () {
289
  }
290
  if (!isFinite(frameIdx) || frameIdx < 0) frameIdx = 0;
291
 
292
- // Only refresh if frame actually changed
293
  if (frameIdx === state.inspection.frameIdx) return;
294
 
295
  state.inspection.frameIdx = frameIdx;
296
  APP.ui.inspection._clearCaches();
297
 
298
- // Update header
299
- const target = $("#inspectionTarget");
300
  const track = (state.detections || []).find(d => d.id === state.inspection.trackId);
301
- if (target) {
302
- target.textContent = track
303
- ? `${track.label} / Frame ${frameIdx}`
304
- : `Frame ${frameIdx}`;
305
- }
306
 
307
- APP.ui.inspection._loadAndRender();
308
  };
309
 
310
  /**
311
- * Internal: clear all cached visualization data.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
  */
313
  APP.ui.inspection._clearCaches = function () {
314
  const { state } = APP.core;
315
  const cache = state.inspection.cache;
316
 
317
- // Revoke any blob URLs
318
- if (cache.superres && cache.superres._blobUrl) {
319
- URL.revokeObjectURL(cache.superres._blobUrl);
320
- }
321
-
322
- // Revoke frame image URL
323
  if (state.inspection._frameImg && state.inspection._frameImg._blobUrl) {
324
  URL.revokeObjectURL(state.inspection._frameImg._blobUrl);
325
  }
@@ -329,137 +184,227 @@ APP.ui.inspection._clearCaches = function () {
329
  cache.seg = null;
330
  cache.edge = null;
331
  cache.depth = null;
332
- cache.attention = null;
333
- cache.superres = null;
334
  cache.pointcloud = null;
335
  };
336
 
337
  /**
338
- * Internal: show/hide loading indicator with optional mode-specific message.
339
- * @param {boolean} loading
340
- * @param {string} [mode] — if provided, shows a mode-specific message
341
- */
342
- APP.ui.inspection._setLoading = function (loading, mode) {
343
- const { $ } = APP.core.utils;
344
- const el = $("#inspectionLoading");
345
- if (el) {
346
- el.style.display = loading ? "flex" : "none";
347
- // Update loading message based on mode
348
- const msgEl = el.querySelector("span");
349
- if (msgEl && loading && mode) {
350
- const modeMessages = {
351
- seg: "Computing segmentation mask...",
352
- edge: "Computing edges...",
353
- depth: "Computing depth map...",
354
- attention: "Generating attention heatmap...",
355
- superres: "Enhancing resolution...",
356
- "3d": "Generating point cloud..."
357
- };
358
- msgEl.textContent = modeMessages[mode] || "Loading...";
359
- } else if (msgEl && !loading) {
360
- msgEl.textContent = "Loading...";
361
- }
362
- }
363
- APP.core.state.inspection.loading = loading;
364
- };
365
-
366
- /**
367
- * Internal: show error message in the viewport.
368
- */
369
- APP.ui.inspection._setError = function (msg) {
370
- const { $ } = APP.core.utils;
371
- const el = $("#inspectionError");
372
- if (el) {
373
- el.textContent = msg || "";
374
- el.style.display = msg ? "flex" : "none";
375
- }
376
- APP.core.state.inspection.error = msg;
377
- };
378
-
379
- /**
380
- * Internal: load data for the current mode, then render.
381
- * Caches results so repeated mode switches don't refetch.
382
  */
383
- APP.ui.inspection._loadAndRender = async function () {
384
  const { state } = APP.core;
385
- const { log } = APP.ui.logging;
386
  const api = APP.api.inspection;
387
  const renders = APP.ui.inspectionRenders;
388
- const { trackId, frameIdx, mode, cache } = state.inspection;
389
 
390
  const jobId = state.hf.asyncJobId || state.hf.completedJobId;
391
  if (!jobId || !trackId || frameIdx == null) return;
392
 
393
- APP.ui.inspection._setError(null);
394
-
395
- // Get the track object for bbox info
396
  const track = (state.detections || []).find(d => d.id === trackId);
397
  if (!track || !track.bbox) {
398
- APP.ui.inspection._setError("Track not found in current frame");
399
  return;
400
  }
401
 
402
  try {
403
- // --- Step 1: Ensure we have the base frame image (shared by most modes) ---
404
- // Fetch cropped to the selected track's bbox with 20% padding
405
- if (!state.inspection._frameImg && mode !== "3d") {
406
- APP.ui.inspection._setLoading(true, mode);
407
  const frameImg = await api.fetchFrame(jobId, frameIdx, trackId, 0.20);
408
- state.inspection.frameImageUrl = frameImg.src;
409
  state.inspection._frameImg = frameImg;
 
410
  }
 
 
 
 
 
411
 
412
- // --- Step 2: Fetch mode-specific data if not cached ---
413
- if (!cache[mode]) {
414
- APP.ui.inspection._setLoading(true, mode);
415
-
416
- switch (mode) {
417
- case "seg":
418
- cache.seg = await api.fetchMask(jobId, frameIdx, trackId);
419
- break;
420
-
421
- case "edge":
422
- // Pure frontend — computed from the frame image
423
- if (state.inspection._frameImg && renders) {
424
- cache.edge = renders.computeEdge(state.inspection._frameImg);
425
- }
426
- break;
427
-
428
- case "depth":
429
- cache.depth = await api.fetchDepth(jobId, frameIdx, trackId);
430
- break;
431
-
432
- case "attention":
433
- cache.attention = await api.fetchAttention(jobId, frameIdx, trackId);
434
- break;
435
-
436
- case "superres": {
437
- const srImg = await api.fetchSuperRes(jobId, frameIdx, trackId, 4);
438
- cache.superres = srImg;
439
- break;
440
- }
441
-
442
- case "3d":
443
- cache.pointcloud = await api.fetchPointCloud(jobId, frameIdx, trackId);
444
- break;
445
- }
446
  }
 
 
447
 
448
- APP.ui.inspection._setLoading(false);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
449
 
450
- // --- Step 3: Render ---
451
- if (mode === "3d") {
 
 
452
  if (APP.ui.inspection3d && APP.ui.inspection3d.render && cache.pointcloud) {
453
  APP.ui.inspection3d.render(cache.pointcloud);
454
  }
455
- } else if (renders) {
456
- renders.dispatch(mode, state.inspection._frameImg, cache, track);
 
457
  }
 
 
458
 
459
- } catch (err) {
460
- APP.ui.inspection._setLoading(false);
461
- APP.ui.inspection._setError(`Failed to load ${mode}: ${err.message}`);
462
- log(`Inspection error (${mode}): ${err.message}`, "e");
463
- console.error("Inspection load error:", err);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
464
  }
465
  };
 
1
+ // Inspection Panel Controller — quad view: seg, edge, depth, 3D simultaneously
2
  APP.ui.inspection = {};
3
 
4
  /**
5
+ * Initialize: wire close button, quad expand/collapse, Escape key.
6
  * Called once from main.js init().
7
  */
8
  APP.ui.inspection.init = function () {
9
  const { $ } = APP.core.utils;
10
 
 
 
 
 
 
 
 
 
 
 
 
11
  // Close button
12
  const closeBtn = $("#btnCloseInspection");
13
  if (closeBtn) {
14
+ closeBtn.addEventListener("click", () => APP.ui.inspection.close());
 
 
15
  }
16
 
17
+ // Quad expand/collapse (event delegation)
18
+ const quad = $("#inspectionQuad");
19
+ if (quad) {
20
+ quad.addEventListener("click", (e) => {
21
+ const quadrant = e.target.closest(".inspection-quadrant");
22
+ if (!quadrant) return;
23
+ const mode = quadrant.getAttribute("data-mode");
24
+ APP.ui.inspection._toggleExpand(mode);
25
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  }
27
 
28
+ // Escape to collapse
29
+ document.addEventListener("keydown", (e) => {
30
+ if (e.key === "Escape" && APP.core.state.inspection.expandedQuadrant) {
31
+ APP.ui.inspection._collapseExpanded();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  }
33
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  };
35
 
36
  /**
37
  * Open the inspection panel for a specific track.
38
+ * @param {string} trackId
39
  */
40
  APP.ui.inspection.open = function (trackId) {
41
  const { state } = APP.core;
 
56
  }
57
  if (!isFinite(frameIdx) || frameIdx < 0) frameIdx = 0;
58
 
59
+ // Collapse any expanded quadrant
60
+ APP.ui.inspection._collapseExpanded();
61
+
62
  // Update state
63
  state.inspection.trackId = trackId;
64
  state.inspection.frameIdx = frameIdx;
65
  state.inspection.visible = true;
66
  state.inspection.error = null;
67
 
68
+ // Clear caches
69
  APP.ui.inspection._clearCaches();
70
 
 
 
 
71
  // Show panel
72
  panel.style.display = "flex";
73
 
 
 
 
 
 
 
 
 
 
74
  // Hide empty state
75
  const empty = $("#inspectionEmpty");
76
  if (empty) empty.style.display = "none";
77
 
78
+ // Update header
79
+ const track = (state.detections || []).find(d => d.id === trackId);
80
+ APP.ui.inspection._updateHeader(track, frameIdx);
81
+
82
+ // Load all quadrants
83
+ APP.ui.inspection._loadAllQuadrants();
84
  };
85
 
86
  /**
 
95
  state.inspection.loading = false;
96
  state.inspection.error = null;
97
 
98
+ APP.ui.inspection._collapseExpanded();
99
  APP.ui.inspection._clearCaches();
100
 
 
 
 
101
  const panel = $("#inspectionPanel");
102
  if (panel) panel.style.display = "none";
103
 
104
+ // Clean up 3D scene
105
  if (APP.ui.inspection3d && APP.ui.inspection3d.dispose) {
106
  APP.ui.inspection3d.dispose();
107
  }
108
  };
109
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  /**
111
  * Refresh inspection for the current video time (called when user seeks).
112
  */
 
124
  }
125
  if (!isFinite(frameIdx) || frameIdx < 0) frameIdx = 0;
126
 
 
127
  if (frameIdx === state.inspection.frameIdx) return;
128
 
129
  state.inspection.frameIdx = frameIdx;
130
  APP.ui.inspection._clearCaches();
131
 
132
+ // Update header frame counter
 
133
  const track = (state.detections || []).find(d => d.id === state.inspection.trackId);
134
+ APP.ui.inspection._updateHeader(track, frameIdx);
 
 
 
 
135
 
136
+ APP.ui.inspection._loadAllQuadrants();
137
  };
138
 
139
  /**
140
+ * Update the inspection header bar.
141
+ */
142
+ APP.ui.inspection._updateHeader = function (track, frameIdx) {
143
+ const { $ } = APP.core.utils;
144
+ const { state } = APP.core;
145
+ const totalFrames = state.hf.totalFrames || "--";
146
+
147
+ const nameEl = $("#inspectionObjName");
148
+ const confEl = $("#inspectionConf");
149
+ const statusEl = $("#inspectionStatus");
150
+ const frameEl = $("#inspectionFrame");
151
+
152
+ if (nameEl) nameEl.textContent = (track && track.label || "UNKNOWN").toUpperCase();
153
+ if (confEl) confEl.textContent = track ? (track.score || 0).toFixed(2) : "--";
154
+ if (frameEl) frameEl.textContent = `FRM ${frameIdx} / ${totalFrames}`;
155
+
156
+ if (statusEl) {
157
+ statusEl.className = "inspection-status";
158
+ if (track && track.satisfies === true) {
159
+ statusEl.textContent = "MISSION MATCH";
160
+ statusEl.classList.add("match");
161
+ } else if (track && track.satisfies === false) {
162
+ statusEl.textContent = "NO MATCH";
163
+ statusEl.classList.add("no-match");
164
+ } else {
165
+ statusEl.textContent = "PENDING";
166
+ statusEl.classList.add("pending");
167
+ }
168
+ }
169
+ };
170
+
171
+ /**
172
+ * Clear all cached visualization data.
173
  */
174
  APP.ui.inspection._clearCaches = function () {
175
  const { state } = APP.core;
176
  const cache = state.inspection.cache;
177
 
 
 
 
 
 
 
178
  if (state.inspection._frameImg && state.inspection._frameImg._blobUrl) {
179
  URL.revokeObjectURL(state.inspection._frameImg._blobUrl);
180
  }
 
184
  cache.seg = null;
185
  cache.edge = null;
186
  cache.depth = null;
 
 
187
  cache.pointcloud = null;
188
  };
189
 
190
  /**
191
+ * Load and render all four quadrants in parallel.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
  */
193
+ APP.ui.inspection._loadAllQuadrants = async function () {
194
  const { state } = APP.core;
195
+ const { $ } = APP.core.utils;
196
  const api = APP.api.inspection;
197
  const renders = APP.ui.inspectionRenders;
198
+ const { trackId, frameIdx, cache } = state.inspection;
199
 
200
  const jobId = state.hf.asyncJobId || state.hf.completedJobId;
201
  if (!jobId || !trackId || frameIdx == null) return;
202
 
 
 
 
203
  const track = (state.detections || []).find(d => d.id === trackId);
204
  if (!track || !track.bbox) {
205
+ APP.ui.inspection._showQuadError("seg", "Track not found");
206
  return;
207
  }
208
 
209
  try {
210
+ // Step 1: Fetch shared frame image
211
+ if (!state.inspection._frameImg) {
 
 
212
  const frameImg = await api.fetchFrame(jobId, frameIdx, trackId, 0.20);
 
213
  state.inspection._frameImg = frameImg;
214
+ state.inspection.frameImageUrl = frameImg.src;
215
  }
216
+ } catch (err) {
217
+ APP.ui.inspection._showQuadError("seg", "Frame load failed");
218
+ APP.ui.inspection._showQuadError("depth", "Frame load failed");
219
+ return;
220
+ }
221
 
222
+ const frameImg = state.inspection._frameImg;
223
+
224
+ // Step 2: Fire all four in parallel
225
+ const segPromise = (async () => {
226
+ APP.ui.inspection._showQuadLoading("seg", true);
227
+ try {
228
+ if (!cache.seg) cache.seg = await api.fetchMask(jobId, frameIdx, trackId);
229
+ const canvas = $("#quadCanvasSeg");
230
+ if (canvas && renders) renders._renderSeg(canvas, frameImg, cache.seg, track);
231
+ APP.ui.inspection._updateQuadMetric("seg", cache.seg);
232
+ } catch (e) {
233
+ APP.ui.inspection._showQuadError("seg", e.message);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
234
  }
235
+ APP.ui.inspection._showQuadLoading("seg", false);
236
+ })();
237
 
238
+ const edgePromise = (async () => {
239
+ try {
240
+ if (!cache.edge && frameImg && renders) {
241
+ cache.edge = renders.computeEdge(frameImg);
242
+ }
243
+ const canvas = $("#quadCanvasEdge");
244
+ if (canvas && renders) renders._renderEdge(canvas, frameImg, cache.edge, track);
245
+ } catch (e) {
246
+ // Edge is client-side only, unlikely to fail
247
+ console.error("Edge render error:", e);
248
+ }
249
+ })();
250
+
251
+ const depthPromise = (async () => {
252
+ APP.ui.inspection._showQuadLoading("depth", true);
253
+ try {
254
+ if (!cache.depth) cache.depth = await api.fetchDepth(jobId, frameIdx, trackId);
255
+ const canvas = $("#quadCanvasDepth");
256
+ if (canvas && renders) renders._renderDepth(canvas, frameImg, cache.depth, track);
257
+ APP.ui.inspection._updateQuadMetric("depth", cache.depth);
258
+ } catch (e) {
259
+ APP.ui.inspection._showQuadError("depth", e.message);
260
+ }
261
+ APP.ui.inspection._showQuadLoading("depth", false);
262
+ })();
263
 
264
+ const threeDPromise = (async () => {
265
+ APP.ui.inspection._showQuadLoading("3d", true);
266
+ try {
267
+ if (!cache.pointcloud) cache.pointcloud = await api.fetchPointCloud(jobId, frameIdx, trackId);
268
  if (APP.ui.inspection3d && APP.ui.inspection3d.render && cache.pointcloud) {
269
  APP.ui.inspection3d.render(cache.pointcloud);
270
  }
271
+ APP.ui.inspection._updateQuadMetric("3d", cache.pointcloud);
272
+ } catch (e) {
273
+ APP.ui.inspection._showQuadError("3d", e.message);
274
  }
275
+ APP.ui.inspection._showQuadLoading("3d", false);
276
+ })();
277
 
278
+ await Promise.allSettled([segPromise, edgePromise, depthPromise, threeDPromise]);
279
+
280
+ // Step 3: Update bottom metrics
281
+ APP.ui.inspection._updateBottomMetrics(track, cache);
282
+ };
283
+
284
+ /**
285
+ * Toggle per-quadrant loading spinner.
286
+ */
287
+ APP.ui.inspection._showQuadLoading = function (mode, show) {
288
+ const modeToId = { seg: "quadSeg", edge: "quadEdge", depth: "quadDepth", "3d": "quad3d" };
289
+ const el = document.getElementById(modeToId[mode]);
290
+ if (!el) return;
291
+ const loader = el.querySelector(".quad-loading");
292
+ if (loader) loader.style.display = show ? "flex" : "none";
293
+ };
294
+
295
+ /**
296
+ * Show per-quadrant error message.
297
+ */
298
+ APP.ui.inspection._showQuadError = function (mode, msg) {
299
+ const modeToId = { seg: "quadSeg", edge: "quadEdge", depth: "quadDepth", "3d": "quad3d" };
300
+ const el = document.getElementById(modeToId[mode]);
301
+ if (!el) return;
302
+ const errEl = el.querySelector(".quad-error");
303
+ if (errEl) {
304
+ errEl.textContent = msg || "";
305
+ errEl.style.display = msg ? "flex" : "none";
306
+ }
307
+ };
308
+
309
+ /**
310
+ * Update per-quadrant metric overlay text.
311
+ */
312
+ APP.ui.inspection._updateQuadMetric = function (mode, data) {
313
+ const { $ } = APP.core.utils;
314
+ if (!data) return;
315
+
316
+ if (mode === "seg") {
317
+ // Count mask pixels from RLE or mask image
318
+ let area = 0;
319
+ if (data.rle && data.rle.counts) {
320
+ let val = 0;
321
+ for (const count of data.rle.counts) {
322
+ if (val) area += count;
323
+ val = 1 - val;
324
+ }
325
+ }
326
+ const el = $("#quadMetricSeg");
327
+ if (el) el.textContent = area > 0 ? `AREA ${area.toLocaleString()} px` : "";
328
+ } else if (mode === "depth") {
329
+ const el = $("#quadMetricDepth");
330
+ if (el && data.min != null && data.max != null) {
331
+ const avg = ((data.min + data.max) / 2).toFixed(1);
332
+ el.textContent = `${avg}m`;
333
+ }
334
+ } else if (mode === "3d") {
335
+ const el = $("#quadMetric3d");
336
+ if (el) {
337
+ const n = data.numVertices || (data.positions ? data.positions.length / 3 : 0);
338
+ const label = data.renderMode === "mesh" ? "verts" : "pts";
339
+ el.textContent = `${n.toLocaleString()} ${label}`;
340
+ }
341
+ }
342
+ };
343
+
344
+ /**
345
+ * Update the bottom metrics strip.
346
+ */
347
+ APP.ui.inspection._updateBottomMetrics = function (track, cache) {
348
+ const { $ } = APP.core.utils;
349
+ const el = $("#inspectionMetrics");
350
+ if (!el || !track) return;
351
+
352
+ const speed = track.speed_kph != null ? `${Math.round(track.speed_kph)} kph` : "N/A";
353
+ const depth = track.depth_est_m != null ? `${track.depth_est_m.toFixed(1)}m` : "N/A";
354
+
355
+ let area = "N/A";
356
+ if (cache.seg && cache.seg.rle && cache.seg.rle.counts) {
357
+ let px = 0, val = 0;
358
+ for (const count of cache.seg.rle.counts) {
359
+ if (val) px += count;
360
+ val = 1 - val;
361
+ }
362
+ if (px > 0) area = `${px.toLocaleString()} px`;
363
+ }
364
+
365
+ const frames = track.frameCount || "--";
366
+
367
+ el.innerHTML =
368
+ `VEL <span>${speed}</span>` +
369
+ `DEPTH <span>${depth}</span>` +
370
+ `AREA <span>${area}</span>` +
371
+ `TRACKED <span>${frames} frm</span>`;
372
+ };
373
+
374
+ /**
375
+ * Toggle expand/collapse of a quadrant.
376
+ */
377
+ APP.ui.inspection._toggleExpand = function (mode) {
378
+ const { state } = APP.core;
379
+ if (state.inspection.expandedQuadrant === mode) {
380
+ APP.ui.inspection._collapseExpanded();
381
+ } else {
382
+ APP.ui.inspection._collapseExpanded();
383
+ const modeToId = { seg: "quadSeg", edge: "quadEdge", depth: "quadDepth", "3d": "quad3d" };
384
+ const el = document.getElementById(modeToId[mode]);
385
+ if (el) {
386
+ el.classList.add("expanded");
387
+ state.inspection.expandedQuadrant = mode;
388
+ // Trigger resize for 3D renderer
389
+ if (mode === "3d") {
390
+ setTimeout(() => window.dispatchEvent(new Event("resize")), 50);
391
+ }
392
+ }
393
+ }
394
+ };
395
+
396
+ /**
397
+ * Collapse any currently expanded quadrant.
398
+ */
399
+ APP.ui.inspection._collapseExpanded = function () {
400
+ const { state } = APP.core;
401
+ const was3d = state.inspection.expandedQuadrant === "3d";
402
+
403
+ const expanded = document.querySelector(".inspection-quadrant.expanded");
404
+ if (expanded) expanded.classList.remove("expanded");
405
+ state.inspection.expandedQuadrant = null;
406
+
407
+ if (was3d) {
408
+ setTimeout(() => window.dispatchEvent(new Event("resize")), 50);
409
  }
410
  };
frontend/style.css CHANGED
@@ -1336,14 +1336,14 @@ body.state-complete .viewbox {
1336
  }
1337
 
1338
  /* =========================================
1339
- Inspection Panel
1340
  ========================================= */
1341
 
1342
  .panel-inspection {
1343
  grid-column: 1;
1344
  grid-row: 2;
1345
- max-height: 480px;
1346
- min-height: 320px;
1347
  display: flex;
1348
  flex-direction: column;
1349
  animation: panelSlideUp 0.3s cubic-bezier(.4, 0, .2, 1);
@@ -1354,83 +1354,250 @@ body.state-complete .viewbox {
1354
  to { opacity: 1; transform: translateY(0); }
1355
  }
1356
 
1357
- .inspection-toolbar {
 
 
1358
  display: flex;
1359
- gap: 3px;
1360
- margin-bottom: 10px;
1361
- background: rgba(255,255,255,.02);
1362
- border-radius: var(--radius-sm);
1363
- padding: 3px;
1364
- border: 1px solid var(--border);
1365
  }
1366
 
1367
- .insp-mode-btn {
1368
- cursor: pointer;
1369
- border: 1px solid transparent;
1370
- border-radius: 6px;
1371
- padding: 5px 12px;
1372
- font-size: 11px;
1373
- font-weight: 600;
 
 
 
 
1374
  font-family: var(--mono);
1375
- color: var(--text3);
1376
- background: transparent;
1377
- transition: all 0.15s ease;
1378
- flex: 1;
1379
- text-align: center;
1380
  }
1381
 
1382
- .insp-mode-btn:hover {
1383
- background: rgba(255,255,255,.04);
1384
- color: var(--text2);
 
1385
  }
1386
 
1387
- .insp-mode-btn.active {
1388
- color: var(--text);
1389
- background: rgba(59, 130, 246, .1);
1390
- border-color: rgba(59, 130, 246, .2);
1391
- box-shadow: 0 0 12px rgba(59, 130, 246, .06);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1392
  }
1393
 
1394
- .inspection-viewport {
1395
- position: relative;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1396
  flex: 1;
1397
- min-height: 280px;
1398
- border-radius: var(--radius-sm);
 
 
 
 
 
 
 
 
1399
  overflow: hidden;
1400
- background: #000;
1401
- border: 1px solid var(--border);
1402
- perspective: 1200px;
1403
  }
1404
 
1405
- .inspection-viewport canvas {
1406
- width: 100%; height: 100%;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1407
  display: block;
1408
  object-fit: contain;
1409
- transform-style: preserve-3d;
1410
- transition: transform 0.05s linear;
1411
- cursor: grab;
1412
  }
1413
 
1414
- .inspection-viewport canvas:active { cursor: grabbing; }
1415
- .inspection-viewport canvas.spinning {
1416
- transition: transform 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94);
 
1417
  }
1418
 
1419
- #inspection3dContainer { position: absolute; inset: 0; z-index: 2; }
1420
- #inspection3dContainer canvas { width: 100% !important; height: 100% !important; }
 
 
1421
 
1422
- .inspection-loading {
1423
- position: absolute; inset: 0;
1424
- display: flex; flex-direction: column;
1425
- align-items: center; justify-content: center;
1426
- gap: 10px;
1427
- background: rgba(0,0,0,.6);
1428
- backdrop-filter: blur(4px);
 
 
 
1429
  z-index: 5;
1430
- color: var(--text3);
1431
- font-size: 12px;
1432
  }
1433
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1434
  .inspection-spinner {
1435
  width: 20px; height: 20px;
1436
  border: 2px solid rgba(255,255,255,.08);
@@ -1439,17 +1606,6 @@ body.state-complete .viewbox {
1439
  animation: spin 0.7s linear infinite;
1440
  }
1441
 
1442
- .inspection-error {
1443
- position: absolute; inset: 0;
1444
- display: flex; align-items: center; justify-content: center;
1445
- background: rgba(0,0,0,.6);
1446
- z-index: 5;
1447
- color: var(--danger);
1448
- font-size: 12px;
1449
- padding: 20px;
1450
- text-align: center;
1451
- }
1452
-
1453
  .inspection-empty {
1454
  position: absolute; inset: 0;
1455
  display: flex; align-items: center; justify-content: center;
 
1336
  }
1337
 
1338
  /* =========================================
1339
+ Inspection Panel — Quad View
1340
  ========================================= */
1341
 
1342
  .panel-inspection {
1343
  grid-column: 1;
1344
  grid-row: 2;
1345
+ max-height: 520px;
1346
+ min-height: 360px;
1347
  display: flex;
1348
  flex-direction: column;
1349
  animation: panelSlideUp 0.3s cubic-bezier(.4, 0, .2, 1);
 
1354
  to { opacity: 1; transform: translateY(0); }
1355
  }
1356
 
1357
+ /* --- Header --- */
1358
+
1359
+ .inspection-header {
1360
  display: flex;
1361
+ justify-content: space-between;
1362
+ align-items: center;
1363
+ padding: 10px 12px;
1364
+ border-bottom: 1px solid rgba(255,255,255,0.04);
 
 
1365
  }
1366
 
1367
+ .inspection-header-left {
1368
+ display: flex;
1369
+ align-items: center;
1370
+ gap: 12px;
1371
+ }
1372
+
1373
+ .inspection-obj-name {
1374
+ font-size: 14px;
1375
+ font-weight: 700;
1376
+ color: #e2e8f0;
1377
+ letter-spacing: 0.5px;
1378
  font-family: var(--mono);
 
 
 
 
 
1379
  }
1380
 
1381
+ .inspection-conf {
1382
+ font-size: 10px;
1383
+ color: #64748b;
1384
+ font-family: var(--mono);
1385
  }
1386
 
1387
+ .inspection-status {
1388
+ font-size: 8px;
1389
+ padding: 2px 8px;
1390
+ border-radius: 2px;
1391
+ letter-spacing: 0.5px;
1392
+ font-family: var(--mono);
1393
+ font-weight: 600;
1394
+ }
1395
+ .inspection-status.match {
1396
+ background: rgba(34,197,94,0.12);
1397
+ border: 1px solid rgba(34,197,94,0.2);
1398
+ color: #22c55e;
1399
+ }
1400
+ .inspection-status.no-match {
1401
+ background: rgba(239,68,68,0.12);
1402
+ border: 1px solid rgba(239,68,68,0.2);
1403
+ color: #ef4444;
1404
+ }
1405
+ .inspection-status.pending {
1406
+ background: rgba(255,255,255,0.05);
1407
+ border: 1px solid rgba(255,255,255,0.08);
1408
+ color: #64748b;
1409
  }
1410
 
1411
+ .inspection-header-right {
1412
+ display: flex;
1413
+ align-items: center;
1414
+ gap: 10px;
1415
+ }
1416
+
1417
+ .inspection-frame {
1418
+ font-size: 9px;
1419
+ color: #64748b;
1420
+ letter-spacing: 1px;
1421
+ font-family: var(--mono);
1422
+ }
1423
+
1424
+ /* --- Quad Grid --- */
1425
+
1426
+ .inspection-quad {
1427
+ display: grid;
1428
+ grid-template-columns: 1fr 1fr;
1429
+ grid-template-rows: 1fr 1fr;
1430
+ gap: 8px;
1431
  flex: 1;
1432
+ min-height: 300px;
1433
+ position: relative;
1434
+ padding: 8px 12px;
1435
+ }
1436
+
1437
+ .inspection-quadrant {
1438
+ background: rgba(0,0,0,0.5);
1439
+ border: 1px solid rgba(74,158,255,0.1);
1440
+ border-radius: 5px;
1441
+ position: relative;
1442
  overflow: hidden;
1443
+ cursor: pointer;
1444
+ transition: all 0.25s ease;
 
1445
  }
1446
 
1447
+ .inspection-quadrant:hover {
1448
+ border-color: rgba(74,158,255,0.2);
1449
+ }
1450
+
1451
+ .inspection-quadrant.expanded {
1452
+ position: absolute;
1453
+ inset: 8px;
1454
+ z-index: 10;
1455
+ border-radius: 5px;
1456
+ }
1457
+
1458
+ /* --- Corner Brackets --- */
1459
+
1460
+ .inspection-quadrant::before {
1461
+ content: '';
1462
+ position: absolute;
1463
+ top: 6px;
1464
+ right: 6px;
1465
+ width: 14px;
1466
+ height: 14px;
1467
+ border-top: 1.5px solid rgba(74,158,255,0.25);
1468
+ border-right: 1.5px solid rgba(74,158,255,0.25);
1469
+ pointer-events: none;
1470
+ z-index: 1;
1471
+ }
1472
+
1473
+ .inspection-quadrant::after {
1474
+ content: '';
1475
+ position: absolute;
1476
+ bottom: 6px;
1477
+ left: 6px;
1478
+ width: 14px;
1479
+ height: 14px;
1480
+ border-bottom: 1.5px solid rgba(74,158,255,0.25);
1481
+ border-left: 1.5px solid rgba(74,158,255,0.25);
1482
+ pointer-events: none;
1483
+ z-index: 1;
1484
+ }
1485
+
1486
+ #quadSeg::before, #quadSeg::after { border-color: rgba(74,158,255,0.25); }
1487
+ #quadEdge::before, #quadEdge::after { border-color: rgba(148,163,184,0.2); }
1488
+ #quadDepth::before, #quadDepth::after { border-color: rgba(245,158,11,0.2); }
1489
+ #quad3d::before, #quad3d::after { border-color: rgba(34,197,94,0.2); }
1490
+
1491
+ /* --- Quadrant Labels & Metrics --- */
1492
+
1493
+ .quad-label {
1494
+ position: absolute;
1495
+ top: 8px;
1496
+ left: 10px;
1497
+ z-index: 1;
1498
+ display: flex;
1499
+ align-items: center;
1500
+ gap: 6px;
1501
+ font-size: 8px;
1502
+ letter-spacing: 2px;
1503
+ font-family: var(--mono);
1504
+ pointer-events: none;
1505
+ }
1506
+
1507
+ .quad-dot {
1508
+ width: 5px;
1509
+ height: 5px;
1510
+ border-radius: 50%;
1511
+ }
1512
+
1513
+ .quad-dot-seg { background: #4a9eff; box-shadow: 0 0 4px rgba(74,158,255,0.5); }
1514
+ .quad-dot-edge { background: #94a3b8; box-shadow: 0 0 4px rgba(148,163,184,0.5); }
1515
+ .quad-dot-depth { background: #f59e0b; box-shadow: 0 0 4px rgba(245,158,11,0.5); }
1516
+ .quad-dot-3d { background: #22c55e; box-shadow: 0 0 4px rgba(34,197,94,0.5); }
1517
+
1518
+ #quadSeg .quad-label { color: rgba(74,158,255,0.6); }
1519
+ #quadEdge .quad-label { color: rgba(148,163,184,0.6); }
1520
+ #quadDepth .quad-label { color: rgba(245,158,11,0.6); }
1521
+ #quad3d .quad-label { color: rgba(34,197,94,0.6); }
1522
+
1523
+ .quad-metric {
1524
+ position: absolute;
1525
+ bottom: 8px;
1526
+ right: 10px;
1527
+ font-size: 8px;
1528
+ font-family: var(--mono);
1529
+ pointer-events: none;
1530
+ }
1531
+
1532
+ #quadSeg .quad-metric { color: rgba(74,158,255,0.35); }
1533
+ #quadEdge .quad-metric { color: rgba(148,163,184,0.35); }
1534
+ #quadDepth .quad-metric { color: rgba(245,158,11,0.35); }
1535
+ #quad3d .quad-metric { color: rgba(34,197,94,0.35); }
1536
+
1537
+ .quad-canvas {
1538
+ width: 100%;
1539
+ height: 100%;
1540
  display: block;
1541
  object-fit: contain;
 
 
 
1542
  }
1543
 
1544
+ .quad-3d-container {
1545
+ position: absolute;
1546
+ inset: 0;
1547
+ z-index: 2;
1548
  }
1549
 
1550
+ .quad-3d-container canvas {
1551
+ width: 100% !important;
1552
+ height: 100% !important;
1553
+ }
1554
 
1555
+ /* --- Per-Quadrant Loading/Error --- */
1556
+
1557
+ .quad-loading {
1558
+ position: absolute;
1559
+ inset: 0;
1560
+ display: flex;
1561
+ align-items: center;
1562
+ justify-content: center;
1563
+ background: rgba(0,0,0,0.5);
1564
+ backdrop-filter: blur(2px);
1565
  z-index: 5;
 
 
1566
  }
1567
 
1568
+ .quad-error {
1569
+ position: absolute;
1570
+ inset: 0;
1571
+ display: flex;
1572
+ align-items: center;
1573
+ justify-content: center;
1574
+ background: rgba(0,0,0,0.5);
1575
+ z-index: 5;
1576
+ color: var(--danger);
1577
+ font-size: 10px;
1578
+ font-family: var(--mono);
1579
+ padding: 10px;
1580
+ text-align: center;
1581
+ }
1582
+
1583
+ /* --- Bottom Metrics Strip --- */
1584
+
1585
+ .inspection-metrics {
1586
+ display: flex;
1587
+ gap: 24px;
1588
+ padding: 8px 12px;
1589
+ border-top: 1px solid rgba(255,255,255,0.04);
1590
+ font-size: 9px;
1591
+ color: #64748b;
1592
+ font-family: var(--mono);
1593
+ }
1594
+
1595
+ .inspection-metrics span {
1596
+ color: #e2e8f0;
1597
+ }
1598
+
1599
+ /* --- Retained --- */
1600
+
1601
  .inspection-spinner {
1602
  width: 20px; height: 20px;
1603
  border: 2px solid rgba(255,255,255,.08);
 
1606
  animation: spin 0.7s linear infinite;
1607
  }
1608
 
 
 
 
 
 
 
 
 
 
 
 
1609
  .inspection-empty {
1610
  position: absolute; inset: 0;
1611
  display: flex; align-items: center; justify-content: center;