zye0616 Claude Opus 4.6 (1M context) commited on
Commit
1f50fb2
·
1 Parent(s): ecfb6e4

feat(demo): add AI Postprocessing toggle for vision-only mode

Browse files

When toggled off, the pipeline runs strictly stage 1 (detection/tracking)
with no GPT assessment, no verdict fetching, and label-based colors.
Topbar reflects mode: VISION ONLY / DETECTING / COMPLETE.

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

demo/index.html CHANGED
@@ -126,6 +126,7 @@
126
  <input type="text" id="queriesInput" class="config-input" value="person,car,truck" placeholder="e.g., person,car or Monitor unauthorized personnel...">
127
  </div>
128
  <div class="config-row">
 
129
  <label class="config-checkbox"><input type="checkbox" id="depthToggle"> Enable Depth</label>
130
  </div>
131
  </div>
 
126
  <input type="text" id="queriesInput" class="config-input" value="person,car,truck" placeholder="e.g., person,car or Monitor unauthorized personnel...">
127
  </div>
128
  <div class="config-row">
129
+ <label class="config-checkbox"><input type="checkbox" id="aiToggle" checked> AI Postprocessing</label>
130
  <label class="config-checkbox"><input type="checkbox" id="depthToggle"> Enable Depth</label>
131
  </div>
132
  </div>
demo/js/init.js CHANGED
@@ -52,13 +52,14 @@ document.addEventListener('DOMContentLoaded', () => {
52
  // Gather config from controls
53
  const activeMode = document.querySelector('#modeToggle .config-toggle-btn.active');
54
  const mode = activeMode ? activeMode.dataset.mode : 'object_detection';
 
55
  const config = {
56
  mode: mode,
57
  queries: document.getElementById('queriesInput').value || 'person,car,truck',
58
  detector: document.getElementById('detectorSelect').value,
59
  segmenter: document.getElementById('segmenterSelect').value,
60
  enableDepth: document.getElementById('depthToggle').checked,
61
- mission: document.getElementById('queriesInput').value || null,
62
  };
63
 
64
  STATE.jobConfig = config;
 
52
  // Gather config from controls
53
  const activeMode = document.querySelector('#modeToggle .config-toggle-btn.active');
54
  const mode = activeMode ? activeMode.dataset.mode : 'object_detection';
55
+ const aiEnabled = document.getElementById('aiToggle').checked;
56
  const config = {
57
  mode: mode,
58
  queries: document.getElementById('queriesInput').value || 'person,car,truck',
59
  detector: document.getElementById('detectorSelect').value,
60
  segmenter: document.getElementById('segmenterSelect').value,
61
  enableDepth: document.getElementById('depthToggle').checked,
62
+ mission: aiEnabled ? (document.getElementById('queriesInput').value || null) : null,
63
  };
64
 
65
  STATE.jobConfig = config;
demo/js/real-backend.js CHANGED
@@ -277,8 +277,7 @@ async function enterRealAnalysis(jobId, status) {
277
  if (res.status === 410) { ISR.showToast('Job was cancelled.', 6000); resetToReady(); return; }
278
  if (res.status === 202) { await new Promise(r => setTimeout(r, 1000)); continue; }
279
  if (res.ok) {
280
- const rawBlob = await res.blob();
281
- const blob = new Blob([rawBlob], { type: 'video/mp4' });
282
  videoBlobUrl = URL.createObjectURL(blob);
283
  ISR.STATE._videoBlobUrl = videoBlobUrl;
284
  break;
@@ -301,7 +300,7 @@ async function enterRealAnalysis(jobId, status) {
301
  processedVideo.classList.remove('hidden');
302
  processedVideo.style.display = 'block';
303
  await new Promise(resolve => {
304
- const timeout = setTimeout(resolve, 30000); // safety timeout — large videos need time
305
  processedVideo.addEventListener('canplay', () => { clearTimeout(timeout); resolve(); }, { once: true });
306
  processedVideo.load();
307
  });
@@ -385,6 +384,7 @@ async function enterRealAnalysis(jobId, status) {
385
  // Background work — runs after analysis state is active and interactive
386
  async function deferredAnalysisWork(jobId, status) {
387
  // Build full track list from sampled keyframes (reuses cache)
 
388
  try {
389
  await renderRealTrackList(jobId);
390
  } catch (err) { console.warn('[ISR] Deferred track list failed:', err); }
@@ -614,6 +614,11 @@ async function renderRealTrackList(jobId) {
614
  }
615
  }
616
 
 
 
 
 
 
617
  ISR.STATE._realTracks = Array.from(trackMap.values());
618
  ISR.STATE._realTrackIndex = null; // force rebuild on next merge
619
  renderTrackListFromData(ISR.STATE._realTracks);
@@ -694,88 +699,54 @@ function _deduplicateTracksByBbox(tracks) {
694
  return kept;
695
  }
696
 
697
- /**
698
- * Merge fragmented track IDs that represent the same physical object.
699
- *
700
- * When the tracker loses and re-acquires an object (common in aerial/SAR footage
701
- * with low-confidence detections), multiple track IDs get created for the same
702
- * physical entity. This groups tracks by label + satisfies verdict and keeps
703
- * only the best representative per group, merging subsidiary track IDs so
704
- * inspect/selection still works.
705
- *
706
- * Grouping key: (label, satisfies). Within each group, keep the track with the
707
- * highest score as the representative. Attach merged track IDs so the SVG overlay
708
- * and click handlers can still resolve any constituent ID to the representative.
709
- */
710
- function _mergeFragmentedTracks(tracks) {
711
- if (tracks.length <= 1) return tracks;
712
-
713
- // Group by (label, satisfies) — only merge assessed tracks with same verdict
714
- const groups = new Map(); // key -> [tracks]
715
- const ungrouped = [];
716
-
717
- for (const t of tracks) {
718
- // Only merge tracks that have been assessed with a definite verdict
719
- if (t.assessment_status === 'ASSESSED' && t.satisfies !== undefined && t.satisfies !== null) {
720
- const key = `${(t.label || '').toLowerCase()}::${t.satisfies}`;
721
- if (!groups.has(key)) groups.set(key, []);
722
- groups.get(key).push(t);
723
- } else {
724
- ungrouped.push(t);
725
- }
726
- }
727
-
728
- const merged = [];
729
- for (const [, group] of groups) {
730
- // Sort by score descending — best representative first
731
- group.sort((a, b) => (b.score || 0) - (a.score || 0));
732
- const best = { ...group[0] };
733
- // Collect all subsidiary track IDs
734
- if (group.length > 1) {
735
- best._merged_track_ids = group.map(t => t.track_id);
736
- }
737
- merged.push(best);
738
- }
739
-
740
- return merged.concat(ungrouped);
741
- }
742
-
743
  function renderTrackListFromData(tracks) {
744
  const panel = document.getElementById('tracksPanel');
745
  panel.innerHTML = '';
746
 
747
- // Apply assessment cache to all tracks
748
- const enriched = tracks.map(t => ISR.applyAssessmentCache({...t}));
749
-
750
- // Filtering logic aligned with frontend/js/ui/cards.js:
751
- // If ANY track has a GPT verdict (satisfies === true/false), show ONLY confirmed matches.
752
- // If no verdicts yet, show all tracks (assessment still in progress).
753
- const hasVerdicts = enriched.some(t => t.satisfies === true || t.satisfies === false);
754
- const relevant = hasVerdicts
755
- ? enriched.filter(t => t.satisfies === true)
756
- : enriched;
757
- const spatialDeduped = _deduplicateTracksByBbox(relevant);
758
- const deduped = _mergeFragmentedTracks(spatialDeduped);
759
- const filtered = ISR.STATE.trackFilter ? deduped.filter(t => t.type === ISR.STATE.trackFilter) : deduped;
760
-
761
- if (filtered.length === 0) {
762
- panel.innerHTML = hasVerdicts
763
- ? '<div style="color: var(--text-tertiary); font-size: 10px; text-align: center; padding: 20px;">No objects match the mission criteria.</div>'
764
- : '<div style="color: var(--text-tertiary); font-size: 10px; text-align: center; padding: 20px;">Awaiting mission assessment...</div>';
765
- return;
766
- }
 
767
 
768
- // Sort: mission-relevant first, then assessed before unassessed before stale, then by confidence descending
769
- const _statusOrder = { 'ASSESSED': 0, 'UNASSESSED': 1, 'STALE': 2 };
770
- filtered.sort((a, b) => {
771
- const aMR = a.mission_relevant === true ? 0 : 1;
772
- const bMR = b.mission_relevant === true ? 0 : 1;
773
- if (aMR !== bMR) return aMR - bMR;
774
- const aStatus = _statusOrder[a.assessment_status] ?? 1;
775
- const bStatus = _statusOrder[b.assessment_status] ?? 1;
776
- if (aStatus !== bStatus) return aStatus - bStatus;
777
- return (b.score || 0) - (a.score || 0);
778
- });
 
 
 
 
 
 
 
 
 
 
 
779
 
780
  // If there's a filter, add a clear filter button
781
  if (ISR.STATE.trackFilter) {
@@ -801,8 +772,7 @@ function renderTrackListFromData(tracks) {
801
 
802
  const speedStr = t.speed_kph ? `${t.speed_kph.toFixed(1)} kph` : '--';
803
  const dirStr = t.direction_clock ? `${t.direction_clock}h` : '';
804
- const mergedIds = t._merged_track_ids;
805
- const idStr = mergedIds ? `IDs:${mergedIds.join(',')}` : `ID:${t.track_id}`;
806
 
807
  // Status badge
808
  const badge = ISR.getStatusBadgeHTML(t);
@@ -823,7 +793,7 @@ function renderTrackListFromData(tracks) {
823
  '</div>';
824
  }
825
 
826
- const mc = ISR.getMissionColor(t);
827
  card.innerHTML = `
828
  <div class="track-dot" style="background: ${mc.solid}; box-shadow: 0 0 4px ${mc.solid};"></div>
829
  <div class="track-info">
@@ -993,9 +963,9 @@ function renderSvgOverlay(tracks) {
993
  const nw = nx2 - nx1;
994
  const nh = ny2 - ny1;
995
 
996
- // Determine color by mission status uses unified MISSION_COLORS
997
- const _mc = ISR.getMissionColor(t);
998
- const strokeColor = _mc.stroke;
999
 
1000
  const isInspected = ISR.STATE.selectedTrackId === t.track_id;
1001
  const isInSelection = ISR.STATE.selectedTrackIds.includes(t.track_id);
@@ -1195,24 +1165,17 @@ async function deriveTimelineEvents(jobId, summary, frameCounts) {
1195
  const label = t.label_name || t.label || 'object';
1196
  const time = ISR.formatTime(frameIdx / fps);
1197
 
1198
- // Skip non-mission-relevant tracks for event generation.
1199
- // Objects like "dog", "cat", "mouse" clutter the timeline when they
1200
- // have no bearing on the mission objective.
1201
- const isMissionRelevant = t.mission_relevant !== false;
1202
-
1203
- // First detection of this track ID (emit once, only for mission-relevant)
1204
  if (!seenIds.has(tid)) {
1205
  seenIds.add(tid);
1206
- if (isMissionRelevant) {
1207
- events.push({
1208
- frame: frameIdx, time, type: 'detection',
1209
- label: `New ${label}`, description: `Track ${tid} first detected.`,
1210
- priority: 'normal',
1211
- });
1212
- }
1213
  }
1214
 
1215
- // Track reappeared after being lost (only mission-relevant tracks are in lostTracks)
1216
  if (lostTracks.has(tid)) {
1217
  lostTracks.delete(tid);
1218
  events.push({
@@ -1233,8 +1196,8 @@ async function deriveTimelineEvents(jobId, summary, frameCounts) {
1233
  });
1234
  }
1235
 
1236
- // Assessed as mission-relevant but does not satisfy — emit ONCE per track
1237
- if (t.satisfies === false && isMissionRelevant && !assessedIds.has(tid)) {
1238
  assessedIds.add(tid);
1239
  events.push({
1240
  frame: frameIdx, time, type: 'system',
 
277
  if (res.status === 410) { ISR.showToast('Job was cancelled.', 6000); resetToReady(); return; }
278
  if (res.status === 202) { await new Promise(r => setTimeout(r, 1000)); continue; }
279
  if (res.ok) {
280
+ const blob = await res.blob();
 
281
  videoBlobUrl = URL.createObjectURL(blob);
282
  ISR.STATE._videoBlobUrl = videoBlobUrl;
283
  break;
 
300
  processedVideo.classList.remove('hidden');
301
  processedVideo.style.display = 'block';
302
  await new Promise(resolve => {
303
+ const timeout = setTimeout(resolve, 5000); // safety timeout
304
  processedVideo.addEventListener('canplay', () => { clearTimeout(timeout); resolve(); }, { once: true });
305
  processedVideo.load();
306
  });
 
384
  // Background work — runs after analysis state is active and interactive
385
  async function deferredAnalysisWork(jobId, status) {
386
  // Build full track list from sampled keyframes (reuses cache)
387
+ // (renderRealTrackList calls fetchVerdicts internally)
388
  try {
389
  await renderRealTrackList(jobId);
390
  } catch (err) { console.warn('[ISR] Deferred track list failed:', err); }
 
614
  }
615
  }
616
 
617
+ // Bulk-load all verdicts into assessment cache (skip if no mission)
618
+ if (ISR.STATE.mission) {
619
+ await ISR.fetchVerdicts(jobId);
620
+ }
621
+
622
  ISR.STATE._realTracks = Array.from(trackMap.values());
623
  ISR.STATE._realTrackIndex = null; // force rebuild on next merge
624
  renderTrackListFromData(ISR.STATE._realTracks);
 
699
  return kept;
700
  }
701
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
702
  function renderTrackListFromData(tracks) {
703
  const panel = document.getElementById('tracksPanel');
704
  panel.innerHTML = '';
705
 
706
+ // Apply assessment cache to all tracks (only when AI postprocessing is active)
707
+ const enriched = ISR.STATE.mission
708
+ ? tracks.map(t => ISR.applyAssessmentCache({...t}))
709
+ : tracks.map(t => ({...t}));
710
+
711
+ let filtered;
712
+ if (ISR.STATE.mission) {
713
+ // Verdict-based filtering: show only matches if verdicts exist
714
+ const hasVerdicts = enriched.some(t => t.satisfies === true || t.satisfies === false);
715
+ const relevant = hasVerdicts
716
+ ? enriched.filter(t => t.satisfies === true)
717
+ : enriched;
718
+ const deduped = _deduplicateTracksByBbox(relevant);
719
+ filtered = ISR.STATE.trackFilter ? deduped.filter(t => t.type === ISR.STATE.trackFilter) : deduped;
720
+
721
+ if (filtered.length === 0) {
722
+ panel.innerHTML = hasVerdicts
723
+ ? '<div style="color: var(--text-tertiary); font-size: 10px; text-align: center; padding: 20px;">No objects match the mission criteria.</div>'
724
+ : '<div style="color: var(--text-tertiary); font-size: 10px; text-align: center; padding: 20px;">Awaiting mission assessment...</div>';
725
+ return;
726
+ }
727
 
728
+ // Sort: mission-relevant first, then assessed before unassessed, then by confidence
729
+ const _statusOrder = { 'ASSESSED': 0, 'UNASSESSED': 1, 'STALE': 2 };
730
+ filtered.sort((a, b) => {
731
+ const aMR = a.mission_relevant === true ? 0 : 1;
732
+ const bMR = b.mission_relevant === true ? 0 : 1;
733
+ if (aMR !== bMR) return aMR - bMR;
734
+ const aStatus = _statusOrder[a.assessment_status] ?? 1;
735
+ const bStatus = _statusOrder[b.assessment_status] ?? 1;
736
+ if (aStatus !== bStatus) return aStatus - bStatus;
737
+ return (b.score || 0) - (a.score || 0);
738
+ });
739
+ } else {
740
+ // Vision-only mode: show all tracks sorted by confidence
741
+ const deduped = _deduplicateTracksByBbox(enriched);
742
+ filtered = ISR.STATE.trackFilter ? deduped.filter(t => t.type === ISR.STATE.trackFilter) : deduped;
743
+ filtered.sort((a, b) => (b.score || 0) - (a.score || 0));
744
+
745
+ if (filtered.length === 0) {
746
+ panel.innerHTML = '<div style="color: var(--text-tertiary); font-size: 10px; text-align: center; padding: 20px;">No detections found.</div>';
747
+ return;
748
+ }
749
+ }
750
 
751
  // If there's a filter, add a clear filter button
752
  if (ISR.STATE.trackFilter) {
 
772
 
773
  const speedStr = t.speed_kph ? `${t.speed_kph.toFixed(1)} kph` : '--';
774
  const dirStr = t.direction_clock ? `${t.direction_clock}h` : '';
775
+ const idStr = `ID:${t.track_id}`;
 
776
 
777
  // Status badge
778
  const badge = ISR.getStatusBadgeHTML(t);
 
793
  '</div>';
794
  }
795
 
796
+ const mc = ISR.STATE.mission ? ISR.getMissionColor(t) : { solid: t.color || '#64748b' };
797
  card.innerHTML = `
798
  <div class="track-dot" style="background: ${mc.solid}; box-shadow: 0 0 4px ${mc.solid};"></div>
799
  <div class="track-info">
 
963
  const nw = nx2 - nx1;
964
  const nh = ny2 - ny1;
965
 
966
+ // Determine color: mission-based if AI postprocessing enabled, label-based otherwise
967
+ const _mc = ISR.STATE.mission ? ISR.getMissionColor(t) : null;
968
+ const strokeColor = _mc ? _mc.stroke : (t.color || 'rgba(100, 116, 139, 0.5)');
969
 
970
  const isInspected = ISR.STATE.selectedTrackId === t.track_id;
971
  const isInSelection = ISR.STATE.selectedTrackIds.includes(t.track_id);
 
1165
  const label = t.label_name || t.label || 'object';
1166
  const time = ISR.formatTime(frameIdx / fps);
1167
 
1168
+ // First detection of this track ID (emit once)
 
 
 
 
 
1169
  if (!seenIds.has(tid)) {
1170
  seenIds.add(tid);
1171
+ events.push({
1172
+ frame: frameIdx, time, type: 'detection',
1173
+ label: `New ${label}`, description: `Track ${tid} first detected.`,
1174
+ priority: 'normal',
1175
+ });
 
 
1176
  }
1177
 
1178
+ // Track reappeared after being lost
1179
  if (lostTracks.has(tid)) {
1180
  lostTracks.delete(tid);
1181
  events.push({
 
1196
  });
1197
  }
1198
 
1199
+ // Assessed as not relevant — emit ONCE per track
1200
+ if (t.satisfies === false && !assessedIds.has(tid)) {
1201
  assessedIds.add(tid);
1202
  events.push({
1203
  frame: frameIdx, time, type: 'system',
demo/js/state-machine.js CHANGED
@@ -47,7 +47,7 @@ function onStateChange(prev, newState) {
47
  topDot.style.background = 'var(--accent)';
48
  topDot.classList.remove('topbar-dot-processing');
49
  }
50
- if (missionLabel) missionLabel.textContent = 'MISSION ACTIVE';
51
  } else if (newState === 'processing') {
52
  if (topDot) {
53
  topDot.style.color = 'var(--warning)';
@@ -56,7 +56,7 @@ function onStateChange(prev, newState) {
56
  }
57
  if (missionLabel) {
58
  missionLabel.style.color = 'var(--warning)';
59
- missionLabel.textContent = 'PROCESSING';
60
  }
61
  } else if (newState === 'analysis' || newState === 'playing') {
62
  if (topDot) {
@@ -66,7 +66,7 @@ function onStateChange(prev, newState) {
66
  }
67
  if (missionLabel) {
68
  missionLabel.style.color = 'var(--success)';
69
- missionLabel.textContent = 'ANALYSIS';
70
  }
71
  } else if (newState === 'inspect') {
72
  if (topDot) {
 
47
  topDot.style.background = 'var(--accent)';
48
  topDot.classList.remove('topbar-dot-processing');
49
  }
50
+ if (missionLabel) missionLabel.textContent = ISR.STATE.mission ? 'MISSION ACTIVE' : 'VISION ONLY';
51
  } else if (newState === 'processing') {
52
  if (topDot) {
53
  topDot.style.color = 'var(--warning)';
 
56
  }
57
  if (missionLabel) {
58
  missionLabel.style.color = 'var(--warning)';
59
+ missionLabel.textContent = ISR.STATE.mission ? 'PROCESSING' : 'DETECTING';
60
  }
61
  } else if (newState === 'analysis' || newState === 'playing') {
62
  if (topDot) {
 
66
  }
67
  if (missionLabel) {
68
  missionLabel.style.color = 'var(--success)';
69
+ missionLabel.textContent = ISR.STATE.mission ? 'ANALYSIS' : 'COMPLETE';
70
  }
71
  } else if (newState === 'inspect') {
72
  if (topDot) {