Spaces:
Runtime error
Runtime error
refactor: anonymize LLM labels, merge fragmented tracks, and tune defaults
Browse filesReplace GPT-4o/Claude/Gemini branding with generic Analyst 1/2/3 labels
in both frontend and backend. Add track merging to deduplicate fragmented
IDs for the same physical object. Filter non-mission-relevant objects from
timeline events. Bump frame store budget to 24 GiB, increase default
segmentation step to 12, auto-detect API base URL, and fix video playback
reliability (blob MIME type, canplay wait, timeout increase).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- .gitignore +2 -1
- app.py +1 -1
- demo/js/api.js +4 -2
- demo/js/explain.js +28 -18
- demo/js/inspect.js +2 -2
- demo/js/real-backend.js +69 -13
- demo/js/ui.js +20 -4
- models/isr/explainer.py +4 -4
- utils/frame_store.py +1 -1
.gitignore
CHANGED
|
@@ -10,4 +10,5 @@ checkpoints/
|
|
| 10 |
*.md
|
| 11 |
.agent
|
| 12 |
.claude/
|
| 13 |
-
.superpowers/
|
|
|
|
|
|
| 10 |
*.md
|
| 11 |
.agent
|
| 12 |
.claude/
|
| 13 |
+
.superpowers/
|
| 14 |
+
videos/
|
app.py
CHANGED
|
@@ -409,7 +409,7 @@ async def detect_async_endpoint(
|
|
| 409 |
depth_estimator: str = Form("depth"),
|
| 410 |
depth_scale: float = Form(25.0),
|
| 411 |
enable_depth: bool = Form(False),
|
| 412 |
-
step: int = Form(
|
| 413 |
mission: str = Form(None),
|
| 414 |
):
|
| 415 |
_ttfs_t0 = time.perf_counter()
|
|
|
|
| 409 |
depth_estimator: str = Form("depth"),
|
| 410 |
depth_scale: float = Form(25.0),
|
| 411 |
enable_depth: bool = Form(False),
|
| 412 |
+
step: int = Form(12),
|
| 413 |
mission: str = Form(None),
|
| 414 |
):
|
| 415 |
_ttfs_t0 = time.perf_counter()
|
demo/js/api.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
| 1 |
window.ISR = window.ISR || {};
|
| 2 |
|
| 3 |
// ── API Base URL ──────────────────────────────────────────────────
|
| 4 |
-
//
|
| 5 |
-
const API_BASE = '
|
|
|
|
|
|
|
| 6 |
|
| 7 |
// ── Async Data Functions (real backend API) ───────────────────────
|
| 8 |
|
|
|
|
| 1 |
window.ISR = window.ISR || {};
|
| 2 |
|
| 3 |
// ── API Base URL ──────────────────────────────────────────────────
|
| 4 |
+
// Auto-detect: same-origin in dev, HuggingFace Space in production demo
|
| 5 |
+
const API_BASE = window.location.hostname === 'biaslab2025-isr.hf.space'
|
| 6 |
+
? ''
|
| 7 |
+
: (localStorage.getItem('ISR_API_BASE') || '');
|
| 8 |
|
| 9 |
// ── Async Data Functions (real backend API) ───────────────────────
|
| 10 |
|
demo/js/explain.js
CHANGED
|
@@ -44,7 +44,7 @@ async function loadExplainability(jobId, trackId) {
|
|
| 44 |
if (_explainAbort) _explainAbort.abort();
|
| 45 |
_explainAbort = new AbortController();
|
| 46 |
|
| 47 |
-
panel.innerHTML = '<div class="explain-loading"><div class="explain-spinner"></div><span>
|
| 48 |
|
| 49 |
try {
|
| 50 |
const resp = await fetch(`${ISR.API_BASE}/inspect/explain/${jobId}/${encodeURIComponent(trackId)}`, { signal: _explainAbort.signal });
|
|
@@ -97,9 +97,9 @@ function renderExplainGraph(data, container) {
|
|
| 97 |
+ '</div></div>';
|
| 98 |
|
| 99 |
var llmHtml = '<div class="ex-llm-strip">'
|
| 100 |
-
+ '<span class="ex-llm-tag active">
|
| 101 |
-
+ '<span class="ex-llm-tag' + (data.consensus_bar && data.consensus_bar.validators_available >= 1 ? ' active' : '') + '">
|
| 102 |
-
+ '<span class="ex-llm-tag' + (data.consensus_bar && data.consensus_bar.validators_available >= 2 ? ' active' : '') + '">
|
| 103 |
+ '</div>';
|
| 104 |
|
| 105 |
// Trunk line
|
|
@@ -239,11 +239,11 @@ function render3DGraph(data, wrap) {
|
|
| 239 |
cats.forEach(function(c) { allFeatures = allFeatures.concat(c.features || []); });
|
| 240 |
|
| 241 |
// Aggregate validator counts for tooltips
|
| 242 |
-
var
|
| 243 |
allFeatures.forEach(function(f) {
|
| 244 |
var vals = f.validators || {};
|
| 245 |
-
if (vals.
|
| 246 |
-
if (vals.
|
| 247 |
});
|
| 248 |
var validatorsAvailable = (data.consensus_bar && data.consensus_bar.validators_available) || 0;
|
| 249 |
|
|
@@ -348,20 +348,21 @@ function render3DGraph(data, wrap) {
|
|
| 348 |
html += '<strong style="color:' + node.color + ';font-size:12px">' + ISR.escHtml(node.label) + '</strong>';
|
| 349 |
if (node.nodeType === 'feature' && node.nodeData.feat) {
|
| 350 |
var f = node.nodeData.feat;
|
| 351 |
-
if (f.reasoning) html += '<div style="margin-top:5px;padding-top:5px;border-top:1px solid rgba(255,255,255,0.1);color:#cbd5e1"><span style="color:#10b981;font-weight:600">
|
| 352 |
var vals = f.validators || {};
|
| 353 |
Object.keys(vals).forEach(function(name) {
|
| 354 |
var v = vals[name];
|
| 355 |
var ic = v.agree ? '#4ade80' : '#f87171';
|
| 356 |
var icon = v.agree ? '\u2713' : '\u2717';
|
| 357 |
-
|
|
|
|
| 358 |
});
|
| 359 |
} else if (node.nodeType === 'category' && node.nodeData.features) {
|
| 360 |
html += '<div style="margin-top:4px;color:#94a3b8">' + node.nodeData.features.length + ' features analyzed</div>';
|
| 361 |
-
if (validatorsAvailable > 0) html += '<div style="color:#94a3b8">Validated by
|
| 362 |
} else if (node.nodeType === 'verdict') {
|
| 363 |
if (node.nodeData.reasoning_summary) html += '<div style="margin-top:4px;color:#cbd5e1">' + ISR.escHtml(node.nodeData.reasoning_summary) + '</div>';
|
| 364 |
-
if (validatorsAvailable > 0) html += '<div style="margin-top:3px;color:#94a3b8">
|
| 365 |
} else if (node.nodeType === 'input') {
|
| 366 |
if (data.reasoning_summary) html += '<div style="margin-top:4px;color:#cbd5e1">' + ISR.escHtml(data.reasoning_summary) + '</div>';
|
| 367 |
html += '<div style="margin-top:3px;color:#94a3b8">Mission: ' + ISR.escHtml((ISR.STATE.mission || 'N/A').slice(0, 50)) + '</div>';
|
|
@@ -420,18 +421,27 @@ function render3DGraph(data, wrap) {
|
|
| 420 |
graph.d3Force('charge').strength(-180);
|
| 421 |
graph.d3Force('link').distance(75);
|
| 422 |
|
| 423 |
-
//
|
| 424 |
-
// The
|
| 425 |
-
|
| 426 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 427 |
setTimeout(function() {
|
| 428 |
-
graph.zoomToFit(
|
| 429 |
}, ms);
|
| 430 |
});
|
| 431 |
|
| 432 |
-
//
|
| 433 |
graph.onEngineStop(function() {
|
| 434 |
-
graph.zoomToFit(
|
| 435 |
});
|
| 436 |
|
| 437 |
// Handle container resize
|
|
|
|
| 44 |
if (_explainAbort) _explainAbort.abort();
|
| 45 |
_explainAbort = new AbortController();
|
| 46 |
|
| 47 |
+
panel.innerHTML = '<div class="explain-loading"><div class="explain-spinner"></div><span>Running multi-analyst consensus pipeline...</span></div>';
|
| 48 |
|
| 49 |
try {
|
| 50 |
const resp = await fetch(`${ISR.API_BASE}/inspect/explain/${jobId}/${encodeURIComponent(trackId)}`, { signal: _explainAbort.signal });
|
|
|
|
| 97 |
+ '</div></div>';
|
| 98 |
|
| 99 |
var llmHtml = '<div class="ex-llm-strip">'
|
| 100 |
+
+ '<span class="ex-llm-tag active">Analyst 1</span>'
|
| 101 |
+
+ '<span class="ex-llm-tag' + (data.consensus_bar && data.consensus_bar.validators_available >= 1 ? ' active' : '') + '">Analyst 2</span>'
|
| 102 |
+
+ '<span class="ex-llm-tag' + (data.consensus_bar && data.consensus_bar.validators_available >= 2 ? ' active' : '') + '">Analyst 3</span>'
|
| 103 |
+ '</div>';
|
| 104 |
|
| 105 |
// Trunk line
|
|
|
|
| 239 |
cats.forEach(function(c) { allFeatures = allFeatures.concat(c.features || []); });
|
| 240 |
|
| 241 |
// Aggregate validator counts for tooltips
|
| 242 |
+
var analyst2Agreed = 0, analyst2Total = 0, analyst3Agreed = 0, analyst3Total = 0;
|
| 243 |
allFeatures.forEach(function(f) {
|
| 244 |
var vals = f.validators || {};
|
| 245 |
+
if (vals.analyst_2) { analyst2Total++; if (vals.analyst_2.agree) analyst2Agreed++; }
|
| 246 |
+
if (vals.analyst_3) { analyst3Total++; if (vals.analyst_3.agree) analyst3Agreed++; }
|
| 247 |
});
|
| 248 |
var validatorsAvailable = (data.consensus_bar && data.consensus_bar.validators_available) || 0;
|
| 249 |
|
|
|
|
| 348 |
html += '<strong style="color:' + node.color + ';font-size:12px">' + ISR.escHtml(node.label) + '</strong>';
|
| 349 |
if (node.nodeType === 'feature' && node.nodeData.feat) {
|
| 350 |
var f = node.nodeData.feat;
|
| 351 |
+
if (f.reasoning) html += '<div style="margin-top:5px;padding-top:5px;border-top:1px solid rgba(255,255,255,0.1);color:#cbd5e1"><span style="color:#10b981;font-weight:600">Analyst 1:</span> ' + ISR.escHtml(f.reasoning) + '</div>';
|
| 352 |
var vals = f.validators || {};
|
| 353 |
Object.keys(vals).forEach(function(name) {
|
| 354 |
var v = vals[name];
|
| 355 |
var ic = v.agree ? '#4ade80' : '#f87171';
|
| 356 |
var icon = v.agree ? '\u2713' : '\u2717';
|
| 357 |
+
var displayName = {analyst_2: 'Analyst 2', analyst_3: 'Analyst 3'}[name] || name;
|
| 358 |
+
html += '<div style="margin-top:3px;color:' + ic + '"><span style="font-weight:600">' + icon + ' ' + ISR.escHtml(displayName) + ':</span> ' + ISR.escHtml(v.note || '') + '</div>';
|
| 359 |
});
|
| 360 |
} else if (node.nodeType === 'category' && node.nodeData.features) {
|
| 361 |
html += '<div style="margin-top:4px;color:#94a3b8">' + node.nodeData.features.length + ' features analyzed</div>';
|
| 362 |
+
if (validatorsAvailable > 0) html += '<div style="color:#94a3b8">Validated by Analyst 2 + Analyst 3</div>';
|
| 363 |
} else if (node.nodeType === 'verdict') {
|
| 364 |
if (node.nodeData.reasoning_summary) html += '<div style="margin-top:4px;color:#cbd5e1">' + ISR.escHtml(node.nodeData.reasoning_summary) + '</div>';
|
| 365 |
+
if (validatorsAvailable > 0) html += '<div style="margin-top:3px;color:#94a3b8">Analyst 2: ' + analyst2Agreed + '/' + analyst2Total + ' \u00b7 Analyst 3: ' + analyst3Agreed + '/' + analyst3Total + '</div>';
|
| 366 |
} else if (node.nodeType === 'input') {
|
| 367 |
if (data.reasoning_summary) html += '<div style="margin-top:4px;color:#cbd5e1">' + ISR.escHtml(data.reasoning_summary) + '</div>';
|
| 368 |
html += '<div style="margin-top:3px;color:#94a3b8">Mission: ' + ISR.escHtml((ISR.STATE.mission || 'N/A').slice(0, 50)) + '</div>';
|
|
|
|
| 421 |
graph.d3Force('charge').strength(-180);
|
| 422 |
graph.d3Force('link').distance(75);
|
| 423 |
|
| 424 |
+
// Set initial camera to look down at the top-down DAG from a centered position.
|
| 425 |
+
// The DAG grows downward (td mode), so offset Y slightly negative to center vertically.
|
| 426 |
+
var nLevels = 4; // object, categories, features, verdict
|
| 427 |
+
var dagSpanY = (nLevels - 1) * 100; // dagLevelDistance=100
|
| 428 |
+
var centerY = dagSpanY / 2;
|
| 429 |
+
graph.cameraPosition({ x: 0, y: -centerY, z: dagSpanY * 1.2 }, { x: 0, y: -centerY, z: 0 });
|
| 430 |
+
|
| 431 |
+
// Immediate fit after warmup ticks (run synchronously), then progressive fits
|
| 432 |
+
// as the async cooldown ticks settle the layout.
|
| 433 |
+
requestAnimationFrame(function() {
|
| 434 |
+
graph.zoomToFit(0, 40);
|
| 435 |
+
});
|
| 436 |
+
[400, 1000, 2000].forEach(function(ms) {
|
| 437 |
setTimeout(function() {
|
| 438 |
+
graph.zoomToFit(400, 40);
|
| 439 |
}, ms);
|
| 440 |
});
|
| 441 |
|
| 442 |
+
// Final fit when simulation finishes (cooldown complete)
|
| 443 |
graph.onEngineStop(function() {
|
| 444 |
+
graph.zoomToFit(400, 40);
|
| 445 |
});
|
| 446 |
|
| 447 |
// Handle container resize
|
demo/js/inspect.js
CHANGED
|
@@ -156,8 +156,8 @@ function enterInspectState(trackId) {
|
|
| 156 |
if (hasRealData && ISR.STATE.jobId) {
|
| 157 |
const tid = realTrack ? realTrack.track_id : trackId;
|
| 158 |
ISR.loadExplainability(ISR.STATE.jobId, tid);
|
| 159 |
-
// Auto-switch to
|
| 160 |
-
setTimeout(() => ISR.switchDrawerTab('
|
| 161 |
}
|
| 162 |
|
| 163 |
// Wire back button
|
|
|
|
| 156 |
if (hasRealData && ISR.STATE.jobId) {
|
| 157 |
const tid = realTrack ? realTrack.track_id : trackId;
|
| 158 |
ISR.loadExplainability(ISR.STATE.jobId, tid);
|
| 159 |
+
// Auto-switch to REASONING tab after a brief delay so user sees it loading
|
| 160 |
+
setTimeout(() => ISR.switchDrawerTab('reasoning'), 300);
|
| 161 |
}
|
| 162 |
|
| 163 |
// Wire back button
|
demo/js/real-backend.js
CHANGED
|
@@ -277,7 +277,8 @@ 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
|
|
|
|
| 281 |
videoBlobUrl = URL.createObjectURL(blob);
|
| 282 |
ISR.STATE._videoBlobUrl = videoBlobUrl;
|
| 283 |
break;
|
|
@@ -300,7 +301,7 @@ async function enterRealAnalysis(jobId, status) {
|
|
| 300 |
processedVideo.classList.remove('hidden');
|
| 301 |
processedVideo.style.display = 'block';
|
| 302 |
await new Promise(resolve => {
|
| 303 |
-
const timeout = setTimeout(resolve,
|
| 304 |
processedVideo.addEventListener('canplay', () => { clearTimeout(timeout); resolve(); }, { once: true });
|
| 305 |
processedVideo.load();
|
| 306 |
});
|
|
@@ -693,6 +694,52 @@ function _deduplicateTracksByBbox(tracks) {
|
|
| 693 |
return kept;
|
| 694 |
}
|
| 695 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 696 |
function renderTrackListFromData(tracks) {
|
| 697 |
const panel = document.getElementById('tracksPanel');
|
| 698 |
panel.innerHTML = '';
|
|
@@ -707,7 +754,8 @@ function renderTrackListFromData(tracks) {
|
|
| 707 |
const relevant = hasVerdicts
|
| 708 |
? enriched.filter(t => t.satisfies === true)
|
| 709 |
: enriched;
|
| 710 |
-
const
|
|
|
|
| 711 |
const filtered = ISR.STATE.trackFilter ? deduped.filter(t => t.type === ISR.STATE.trackFilter) : deduped;
|
| 712 |
|
| 713 |
if (filtered.length === 0) {
|
|
@@ -753,7 +801,8 @@ function renderTrackListFromData(tracks) {
|
|
| 753 |
|
| 754 |
const speedStr = t.speed_kph ? `${t.speed_kph.toFixed(1)} kph` : '--';
|
| 755 |
const dirStr = t.direction_clock ? `${t.direction_clock}h` : '';
|
| 756 |
-
const
|
|
|
|
| 757 |
|
| 758 |
// Status badge
|
| 759 |
const badge = ISR.getStatusBadgeHTML(t);
|
|
@@ -1146,17 +1195,24 @@ async function deriveTimelineEvents(jobId, summary, frameCounts) {
|
|
| 1146 |
const label = t.label_name || t.label || 'object';
|
| 1147 |
const time = ISR.formatTime(frameIdx / fps);
|
| 1148 |
|
| 1149 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1150 |
if (!seenIds.has(tid)) {
|
| 1151 |
seenIds.add(tid);
|
| 1152 |
-
|
| 1153 |
-
|
| 1154 |
-
|
| 1155 |
-
|
| 1156 |
-
|
|
|
|
|
|
|
| 1157 |
}
|
| 1158 |
|
| 1159 |
-
// Track reappeared after being lost
|
| 1160 |
if (lostTracks.has(tid)) {
|
| 1161 |
lostTracks.delete(tid);
|
| 1162 |
events.push({
|
|
@@ -1177,8 +1233,8 @@ async function deriveTimelineEvents(jobId, summary, frameCounts) {
|
|
| 1177 |
});
|
| 1178 |
}
|
| 1179 |
|
| 1180 |
-
// Assessed as not
|
| 1181 |
-
if (t.satisfies === false && !assessedIds.has(tid)) {
|
| 1182 |
assessedIds.add(tid);
|
| 1183 |
events.push({
|
| 1184 |
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 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 |
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 |
});
|
|
|
|
| 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 = '';
|
|
|
|
| 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) {
|
|
|
|
| 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);
|
|
|
|
| 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 |
});
|
| 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',
|
demo/js/ui.js
CHANGED
|
@@ -17,10 +17,26 @@ function togglePlayPause() {
|
|
| 17 |
const processedVideo = document.getElementById('processedVideo');
|
| 18 |
if (processedVideo) {
|
| 19 |
if (ISR.STATE.isPlaying) {
|
| 20 |
-
//
|
| 21 |
-
if (processedVideo.readyState < 2) {
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
ISR.STATE.isPlaying = false;
|
| 25 |
btn.innerHTML = '▶';
|
| 26 |
return;
|
|
|
|
| 17 |
const processedVideo = document.getElementById('processedVideo');
|
| 18 |
if (processedVideo) {
|
| 19 |
if (ISR.STATE.isPlaying) {
|
| 20 |
+
// If video has a source but isn't ready yet, wait for it
|
| 21 |
+
if (processedVideo.readyState < 2 && processedVideo.src) {
|
| 22 |
+
ISR.showToast('Video loading...', 3000);
|
| 23 |
+
processedVideo.addEventListener('canplay', function onReady() {
|
| 24 |
+
processedVideo.removeEventListener('canplay', onReady);
|
| 25 |
+
if (!ISR.STATE.isPlaying) return; // user may have toggled off while waiting
|
| 26 |
+
processedVideo.playbackRate = ISR.playbackSpeed;
|
| 27 |
+
if (processedVideo.currentTime >= processedVideo.duration - 0.1) {
|
| 28 |
+
processedVideo.currentTime = 0;
|
| 29 |
+
}
|
| 30 |
+
processedVideo.play().catch(err => {
|
| 31 |
+
console.error('[ISR] Video play failed:', err);
|
| 32 |
+
ISR.showToast('Video playback failed: ' + err.message, 5000);
|
| 33 |
+
ISR.STATE.isPlaying = false;
|
| 34 |
+
btn.innerHTML = '▶';
|
| 35 |
+
});
|
| 36 |
+
});
|
| 37 |
+
return;
|
| 38 |
+
} else if (processedVideo.readyState < 2) {
|
| 39 |
+
console.warn('[ISR] Video not ready and no source set');
|
| 40 |
ISR.STATE.isPlaying = false;
|
| 41 |
btn.innerHTML = '▶';
|
| 42 |
return;
|
models/isr/explainer.py
CHANGED
|
@@ -250,9 +250,9 @@ class ISRExplainer:
|
|
| 250 |
model="gemini-2.0-flash",
|
| 251 |
contents=[
|
| 252 |
types.Content(role="user", parts=[
|
| 253 |
-
types.Part.from_text(_VALIDATOR_SYSTEM_PROMPT + "\n\n" + user_text),
|
| 254 |
types.Part.from_bytes(data=crop_bytes, mime_type="image/jpeg"),
|
| 255 |
-
types.Part.from_text("\n[Full frame context]:"),
|
| 256 |
types.Part.from_bytes(data=frame_bytes, mime_type="image/jpeg"),
|
| 257 |
]),
|
| 258 |
],
|
|
@@ -309,14 +309,14 @@ class ISRExplainer:
|
|
| 309 |
if claude and "feature_validations" in claude:
|
| 310 |
cv = self._find_validation(claude["feature_validations"], feat_key, feat["name"])
|
| 311 |
if cv:
|
| 312 |
-
validators["
|
| 313 |
if cv.get("agree"):
|
| 314 |
feat_agreed += 1
|
| 315 |
|
| 316 |
if gemini and "feature_validations" in gemini:
|
| 317 |
gv = self._find_validation(gemini["feature_validations"], feat_key, feat["name"])
|
| 318 |
if gv:
|
| 319 |
-
validators["
|
| 320 |
if gv.get("agree"):
|
| 321 |
feat_agreed += 1
|
| 322 |
|
|
|
|
| 250 |
model="gemini-2.0-flash",
|
| 251 |
contents=[
|
| 252 |
types.Content(role="user", parts=[
|
| 253 |
+
types.Part.from_text(text=_VALIDATOR_SYSTEM_PROMPT + "\n\n" + user_text),
|
| 254 |
types.Part.from_bytes(data=crop_bytes, mime_type="image/jpeg"),
|
| 255 |
+
types.Part.from_text(text="\n[Full frame context]:"),
|
| 256 |
types.Part.from_bytes(data=frame_bytes, mime_type="image/jpeg"),
|
| 257 |
]),
|
| 258 |
],
|
|
|
|
| 309 |
if claude and "feature_validations" in claude:
|
| 310 |
cv = self._find_validation(claude["feature_validations"], feat_key, feat["name"])
|
| 311 |
if cv:
|
| 312 |
+
validators["analyst_2"] = cv
|
| 313 |
if cv.get("agree"):
|
| 314 |
feat_agreed += 1
|
| 315 |
|
| 316 |
if gemini and "feature_validations" in gemini:
|
| 317 |
gv = self._find_validation(gemini["feature_validations"], feat_key, feat["name"])
|
| 318 |
if gv:
|
| 319 |
+
validators["analyst_3"] = gv
|
| 320 |
if gv.get("agree"):
|
| 321 |
feat_agreed += 1
|
| 322 |
|
utils/frame_store.py
CHANGED
|
@@ -35,7 +35,7 @@ class SharedFrameStore:
|
|
| 35 |
the budget ceiling, giving callers a chance to fall back to JPEG path.
|
| 36 |
"""
|
| 37 |
|
| 38 |
-
MAX_BUDGET_BYTES =
|
| 39 |
|
| 40 |
def __init__(self, video_path: str, max_frames: Optional[int] = None):
|
| 41 |
cap = cv2.VideoCapture(video_path)
|
|
|
|
| 35 |
the budget ceiling, giving callers a chance to fall back to JPEG path.
|
| 36 |
"""
|
| 37 |
|
| 38 |
+
MAX_BUDGET_BYTES = 24 * 1024**3 # 24 GiB ceiling
|
| 39 |
|
| 40 |
def __init__(self, video_path: str, max_frames: Optional[int] = None):
|
| 41 |
cap = cv2.VideoCapture(video_path)
|