Files
fish_monitor/Zero2YoloYard-main/templates/labelVideo.html
T
2025-12-25 17:41:18 +08:00

2517 lines
112 KiB
HTML

<!-- START OF FILE labelVideo.html -->
{% extends "layout.html" %}
{% block head %}
<style>
/* 覆盖默认布局 padding,实现全屏 IDE 模式 */
main { padding: 0 !important; overflow: hidden; }
/* 画布交互样式 */
#canvas-container {
position: relative;
cursor: crosshair;
display: inline-block;
box-shadow: 0 0 30px rgba(0,0,0,0.2);
}
#canvas-container.sam-active,
#canvas-container.lam-active {
cursor: copy !important;
}
#canvas-container.sam-loading {
cursor: wait !important;
}
#canvas-container canvas {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
}
#drawing-canvas {
pointer-events: auto;
z-index: 10;
}
#crosshair-canvas {
z-index: 5;
}
#frame-image {
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
display: block;
}
/* 侧边栏列表样式微调 */
#class-list .list-group-item,
#bbox-list .list-group-item {
cursor: pointer;
padding: 0.5rem 0.75rem;
font-size: 0.9rem;
border-left-width: 3px;
}
.color-swatch {
display: inline-block;
width: 12px;
height: 12px;
margin-right: 8px;
border: 1px solid rgba(0,0,0,0.1);
vertical-align: middle;
border-radius: 50%;
}
.shortcut-keys-container {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.5rem;
justify-content: center;
}
#drawing-canvas.cursor-move { cursor: move; }
#drawing-canvas.cursor-nwse-resize { cursor: nwse-resize; }
#drawing-canvas.cursor-nesw-resize { cursor: nesw-resize; }
#drawing-canvas.cursor-ns-resize { cursor: ns-resize; }
#drawing-canvas.cursor-ew-resize { cursor: ew-resize; }
.bbox-controls { opacity: 0; transition: opacity 0.2s; }
#bbox-list .list-group-item:hover .bbox-controls { opacity: 1; }
.tool-section-title {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
font-weight: 700;
margin-bottom: 0.5rem;
margin-top: 1rem;
}
.tool-section-title:first-child { margin-top: 0; }
</style>
{% endblock %}
{% block content %}
<div class="ide-layout fade-in">
<!-- 左侧:主画布区域 -->
<div class="ide-canvas-area">
<!-- 1. 顶部工具栏 -->
<div class="ide-toolbar justify-content-between">
<div class="d-flex align-items-center" style="min-width: 0;">
<a href="/" class="btn btn-sm btn-link text-secondary mr-2 pl-0"><i class="bi bi-arrow-left"></i> Back</a>
<div class="border-left pl-3" style="overflow: hidden; white-space: nowrap; text-overflow: ellipsis;">
<h6 class="mb-0 font-weight-bold text-truncate">{{ video_entity.description }}</h6>
<small class="text-muted">Task: {{ task_entity.assigned_to }} (Frames {{ task_entity.start_frame }}-{{ task_entity.end_frame }})</small>
</div>
</div>
<!-- 帧导航控制器 -->
<div class="d-flex align-items-center bg-white border rounded px-2 py-1 shadow-sm mx-2">
<button class="btn btn-sm btn-link text-dark p-0 mr-2" onclick="document.getElementById('frame-slider').stepDown(); document.getElementById('frame-slider').dispatchEvent(new Event('input'));"><i class="bi bi-chevron-left"></i></button>
<span class="font-weight-bold mx-2 text-monospace" style="min-width: 80px; text-align: center;">
<span id="frame-number-display">{{ task_entity.start_frame }}</span> / {{ task_entity.end_frame }}
</span>
<button class="btn btn-sm btn-link text-dark p-0 ml-2" onclick="document.getElementById('frame-slider').stepUp(); document.getElementById('frame-slider').dispatchEvent(new Event('input'));"><i class="bi bi-chevron-right"></i></button>
</div>
<div>
<button id="save-bboxes" class="btn btn-sm btn-primary shadow-sm"><i class="bi bi-cloud-arrow-up-fill mr-1"></i> Save (S)</button>
</div>
</div>
<!-- 2. 画布滚动容器 -->
<div class="ide-canvas-wrapper">
<div id="interpolation-banner" class="position-absolute shadow-sm" style="top: 15px; z-index: 100; min-width: 300px;">
<i class="bi bi-bezier"></i> <strong>INTERPOLATION ACTIVE</strong><br>
<small>Move to target frame, adjust box, click 'Confirm' (Esc to cancel)</small>
</div>
<div id="canvas-container">
<img id="frame-image" src="{{ first_frame_url }}" class="img-fluid" alt="Video Frame" draggable="false" />
<canvas id="crosshair-canvas"></canvas>
<canvas id="drawing-canvas"></canvas>
</div>
</div>
<!-- 3. 底部进度条与快捷键 -->
<div class="px-4 py-3 bg-surface border-top">
<input type="range" class="custom-range" id="frame-slider" min="{{ task_entity.start_frame }}" max="{{ task_entity.end_frame }}" value="{{ task_entity.start_frame }}">
<div class="shortcut-keys-container mt-2">
<span class="shortcut-key">S: Save</span>
<span class="shortcut-key">A/D: Prev/Next</span>
<span class="shortcut-key">C: Cycle Overlap</span>
<span class="shortcut-key">Esc: Cancel</span>
<span class="shortcut-key">Ctrl+Z: Undo</span>
<span class="shortcut-key">Del: Delete</span>
</div>
</div>
</div>
<!-- 右侧:侧边工具栏 -->
<div class="ide-sidebar custom-scrollbar">
<!-- SECTION: Classes -->
<div class="tool-section-title">Classes</div>
<div class="card mb-3 border-0 bg-transparent">
<div class="input-group input-group-sm mb-2">
<input type="text" id="new-class-name" class="form-control" placeholder="New class...">
<div class="input-group-append">
<button id="add-class-btn" class="btn btn-secondary" type="button"><i class="bi bi-plus"></i></button>
</div>
</div>
<ul id="class-list" class="list-group list-group-flush rounded border" style="max-height: 200px; overflow-y: auto;"></ul>
<div class="mt-2 d-flex justify-content-between">
<button id="find-from-dataset-btn" class="btn btn-xs btn-outline-info" title="Find objects using dataset examples"><i class="bi bi-journal-album"></i> Find Similar</button>
<button id="rebuild-prototypes-btn" class="btn btn-xs btn-outline-warning" title="Rebuild AI Models"><i class="bi bi-arrow-clockwise"></i> Rebuild AI</button>
</div>
</div>
<hr class="border-light my-2">
<!-- SECTION: Tools -->
<div class="tool-section-title">AI Tools</div>
<div class="row no-gutters mb-3">
<div class="col-6 pr-1 mb-2">
<button id="sam-toggle-btn" class="btn btn-sm btn-outline-primary w-100 text-truncate" title="Segment Anything"><i class="bi bi-magic"></i> SAM Point</button>
</div>
<div class="col-6 pl-1 mb-2">
<button id="lam-toggle-btn" class="btn btn-sm btn-outline-primary w-100 text-truncate" title="Label Anything"><i class="bi bi-bullseye"></i> LAM Click</button>
</div>
<div class="col-12 mb-2">
<button id="interactive-mode-toggle" class="btn btn-sm btn-outline-info w-100"><i class="bi bi-stars"></i> Smart Select (One-Shot)</button>
</div>
<div class="col-12">
<button id="sam2-track-btn" class="btn btn-sm btn-outline-success w-100"><i class="bi bi-play-circle"></i> Video Tracking</button>
</div>
</div>
<!-- 默认隐藏的控制面板 (添加 style=display:none 确保初始状态) -->
<div id="interactive-segment-controls" style="display: none;">
<h6 class="small font-weight-bold mb-2 text-info"><i class="bi bi-stars"></i> Smart Select Active</h6>
<p class="text-muted small mb-2" style="font-size: 0.75rem;">Draw a box around one object to find others.</p>
<button id="find-similar-btn" class="btn btn-sm btn-primary w-100 mb-2" style="display: none;"><i class="bi bi-search"></i> Find Similar</button>
<button id="clear-samples-btn" class="btn btn-sm btn-secondary w-100 mb-2"><i class="bi bi-eraser"></i> Clear</button>
<div id="interactive-results-controls" style="display: none;" class="mt-2 border-top pt-2">
<div class="form-group mb-2">
<label class="small mb-1 d-flex justify-content-between"><span>Confidence</span> <span id="threshold-value" class="font-weight-bold">0.50</span></label>
<input type="range" class="custom-range" id="result-threshold" min="0.1" max="0.99" step="0.01" value="0.50">
</div>
<button id="accept-visible-btn" class="btn btn-sm btn-success w-100 mb-1"><i class="bi bi-check2-all"></i> Accept All</button>
<button id="finish-interactive-btn" class="btn btn-sm btn-light border w-100">Finish</button>
</div>
</div>
<div id="suggestion-review-controls" style="display: none;">
<h6 class="small font-weight-bold mb-2 text-warning"><i class="bi bi-lightbulb"></i> Review Suggestions</h6>
<div class="form-group mb-2">
<label class="small mb-1 d-flex justify-content-between"><span>Confidence</span> <span id="suggestion-threshold-value" class="font-weight-bold">0.50</span></label>
<input type="range" class="custom-range" id="suggestion-threshold" min="0.1" max="0.99" step="0.01" value="0.50">
</div>
<button id="accept-all-suggestions-btn" class="btn btn-sm btn-warning w-100 text-white"><i class="bi bi-check2-all"></i> Accept All Visible</button>
</div>
<div id="track-controls" style="display: none;">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="small font-weight-bold mb-0 text-primary" id="track-status-header">Tracking...</h6>
</div>
<div class="progress mb-2" style="height: 10px;">
<div id="track-progress-bar" class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%;"></div>
</div>
<button id="stop-sam2-track-btn" class="btn btn-sm btn-danger w-100 mb-2"><i class="bi bi-stop-fill"></i> Stop</button>
<div class="border-top pt-2 mt-2">
<small class="text-muted d-block mb-2">Review Mode (A/D to nav)</small>
<button id="save-all-tracked-btn" class="btn btn-sm btn-success w-100 mb-1" disabled><i class="bi bi-save"></i> Save All Results</button>
<button id="exit-track-mode-btn" class="btn btn-sm btn-secondary w-100">Exit</button>
</div>
</div>
<hr class="border-light my-2">
<!-- SECTION: Objects List (使用 flex-grow 自动填充剩余空间) -->
<div class="tool-section-title d-flex justify-content-between">
<span>Objects</span>
</div>
<div class="flex-grow-1 position-relative" style="min-height: 100px;">
<ul id="bbox-list" class="list-group list-group-flush h-100 custom-scrollbar" style="position: absolute; width: 100%; overflow-y: auto; border: 1px solid var(--border-color); border-radius: 4px;">
<!-- Populated by JS -->
</ul>
</div>
</div>
</div>
<!-- ==================== TEMPLATES & MODALS ==================== -->
<div id="toast-notification"></div>
<template id="bbox-item-template">
<li class="list-group-item d-flex justify-content-between align-items-center action-item">
<div class="d-flex align-items-center text-truncate" style="max-width: 75%;">
<span class="color-swatch flex-shrink-0"></span>
<span class="bbox-label font-weight-bold text-truncate ml-1"></span>
<small class="bbox-id text-muted ml-2 text-monospace" style="font-size: 0.75em;"></small>
</div>
<div class="bbox-controls flex-shrink-0">
<button class="btn btn-xs btn-link text-info p-0 interpolate-btn mr-2" title="Interpolate"><i class="bi bi-bezier"></i></button>
<button class="btn btn-xs btn-link text-danger p-0 delete-bbox-btn" title="Delete"><i class="bi bi-x"></i></button>
</div>
</li>
</template>
<template id="lam-suggestion-popup-template">
<div id="lam-suggestion-popup" class="card shadow border-0" style="position: absolute; z-index: 1000; width: 200px;">
<div class="list-group list-group-flush"></div>
</div>
</template>
<!-- Tracking Options Modal -->
<div class="modal fade" id="tracking-options-modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title font-weight-bold">Select Tracking Mode</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p class="text-muted">Choose how you want to track this object.</p>
<div class="list-group">
<label class="list-group-item list-group-item-action cursor-pointer">
<div class="d-flex w-100 justify-content-between align-items-center">
<h6 class="mb-0"><input type="radio" name="tracking-mode" value="interactive" checked class="mr-2"> Interactive Mode</h6>
<span class="badge badge-primary">Recommended</span>
</div>
<p class="mb-0 mt-1 small text-muted ml-4">Real-time feedback. You can stop and correct errors anytime. Best for complex scenes.</p>
</label>
<label class="list-group-item list-group-item-action cursor-pointer">
<div class="d-flex w-100 justify-content-between align-items-center">
<h6 class="mb-0"><input type="radio" name="tracking-mode" value="batch" class="mr-2"> Batch Mode</h6>
<span class="badge badge-success">High Quality</span>
</div>
<p class="mb-0 mt-1 small text-muted ml-4">Uses the official `SAM2VideoPredictor`. Higher accuracy but cannot be interrupted. Good for clear clips.</p>
</label>
</div>
<div id="batch-mode-options" class="mt-3 p-3 bg-light rounded border" style="display: none;">
<div class="form-group mb-0">
<label for="batch-end-frame" class="font-weight-bold small">Process until frame:</label>
<input type="number" class="form-control" id="batch-end-frame" min="0">
<small class="form-text text-muted">Tracking runs from current frame to this frame.</small>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button>
<button type="button" id="confirm-tracking-start" class="btn btn-primary"><i class="bi bi-play-fill"></i> Start Tracking</button>
</div>
</div>
</div>
</div>
<!-- Negative Sampling Modal -->
{% from "_macros.html" import render_modal %}
{% call render_modal('negative-sampling-modal', 'Apply Model to Video') %}
<div class="mb-3 p-3 border rounded bg-light">
<h6 class="font-weight-bold">Step 1: Set Confidence Threshold</h6>
<p class="text-muted small mb-2">Only suggestions with a score above this value will be generated.</p>
<label class="d-flex justify-content-between small font-weight-bold">
<span>Threshold</span>
<span id="apply-confidence-value" class="text-primary">0.50</span>
</label>
<input type="range" class="custom-range" id="apply-confidence-threshold" min="0.1" max="0.99" step="0.01" value="0.50">
</div>
<div id="neg-sampling-body">
<h6 class="mt-4 font-weight-bold">Step 2: Refine with Negative Samples (Optional)</h6>
<p class="text-info small"><i class="bi bi-info-circle"></i> Draw boxes around areas to <strong>avoid</strong> (background, wrong objects).</p>
<div class="text-center mb-3 bg-dark rounded p-1">
<div id="neg-canvas-container" style="position: relative; display: inline-block; max-width: 100%;">
<img id="neg-frame-image" src="" class="img-fluid rounded" alt="Negative Sample Frame" draggable="false" />
<canvas id="neg-drawing-canvas" style="position: absolute; top: 0; left: 0; cursor: crosshair;"></canvas>
</div>
</div>
<div class="d-flex justify-content-between align-items-center mt-3 bg-white p-2 border rounded">
<button id="neg-prev-btn" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i></button>
<strong id="neg-frame-counter" class="small">Frame 1 / 10</strong>
<button id="neg-next-btn" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-right"></i></button>
</div>
<p class="text-muted small mt-2 text-center">Ctrl+Z: Undo last box | Delete: Clear frame</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button>
<button type="button" id="finish-neg-sampling-btn" class="btn btn-primary"><i class="bi bi-check2-circle"></i> Apply to Video</button>
</div>
{% endcall %}
{% endblock %}
{% block scripts %}
<!-- 引用 JS,原逻辑保持不变 -->
<script>
$(document).ready(function() {
const settings = {{ settings | tojson }};
// ... (Keep the rest of the JavaScript logic from your original file) ...
const customClassColors = settings.class_colors || {};
const videoUuid = "{{ video_entity.video_uuid }}";
const task = {{ task_entity | tojson }};
const frameCount = task.end_frame + 1;
// --- 性能优化:防抖计时器变量 ---
let preprocessTimeout = null;
const themeColors = {
light: {
selectionShadow: 'black',
handleFill: '#29537c',
labelText: 'white',
crosshair: 'rgba(10,80,161,0.7)',
interpolationGhost: 'rgba(216,163,36,0.62)',
interpolationConfirm: '#068526',
suggestionStroke: 'rgba(29,78,117,0.8)',
suggestionFill: 'rgba(19,68,122,0.8)',
suggestionHoverStroke: 'rgb(158,127,31)',
suggestionHoverFill: 'rgb(158,123,19)',
positiveExampleStroke: 'rgba(68,167,40,0.9)',
negativeSampleStroke: 'rgba(191,30,30,0.8)'
},
dark: {
selectionShadow: 'rgba(255, 255, 255, 0.7)',
handleFill: '#5cadff',
labelText: '#1a1a1a',
crosshair: 'rgba(130, 180, 255, 0.7)',
interpolationGhost: 'rgba(255, 193, 7, 0.6)',
interpolationConfirm: '#35a728',
suggestionStroke: 'rgba(68,128,189,0.8)',
suggestionFill: 'rgba(58,115,172,0.8)',
suggestionHoverStroke: 'rgba(255, 193, 7, 1.0)',
suggestionHoverFill: 'rgba(255, 193, 7, 1.0)',
positiveExampleStroke: 'rgba(82,209,62,0.9)',
negativeSampleStroke: 'rgba(216,67,67,0.8)'
}
};
function getColor(key) {
const isDarkMode = document.body.classList.contains('dark-mode');
return isDarkMode ? themeColors.dark[key] : themeColors.light[key];
}
const themeObserver = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
redrawCanvas();
redrawNegCanvas();
}
}
});
themeObserver.observe(document.body, { attributes: true });
let availableClasses = [];
let activeClass = null;
let bboxes = [];
let selectedBboxIndex = -1;
let repeatMode = {
isActive: false,
width: 0,
height: 0,
label: null
};
let history = [];
let historyIndex = -1;
let isDrawing = false;
let startX, startY;
let isSamModeActive = false;
let isLamModeActive = false;
let sam2TrackerUuid = null;
let sam2EventSource = null;
let isReviewMode = false;
let trackedBboxCache = {};
let isInteractiveMode = false;
let positiveExampleBboxes = [];
let interactivePreviewBboxes = [];
let suggestionBboxes = [];
let currentCacheKey = null;
let lastProcessedFrame = -1;
let lastDatasetClassName = null;
let hoveredPreviewIndex = -1;
let overlappingCycleIndex = 0;
let lastClickPosForCycle = null;
let isCKeyPressed = false;
let frameConfidence = {};
let interpolationState = {
isActive: false,
objectId: null,
startFrameData: null
};
let editMode = null;
let draggedHandle = null;
let draggedBboxInitialState = null;
let isDraggingBbox = false;
let negSamplingFrames = [];
let currentNegSampleIndex = 0;
let negativeSamplesStore = {};
let isDrawingNeg = false;
let negStartX, negStartY;
let toastTimeout;
const canvasContainer = $('#canvas-container');
const drawingCanvas = document.getElementById('drawing-canvas');
const drawingCtx = drawingCanvas.getContext('2d');
const crosshairCanvas = document.getElementById('crosshair-canvas');
const crosshairCtx = crosshairCanvas.getContext('2d');
const frameImage = document.getElementById('frame-image');
const frameSlider = $('#frame-slider');
const negCanvas = document.getElementById('neg-drawing-canvas');
const negCtx = negCanvas.getContext('2d');
const negImage = document.getElementById('neg-frame-image');
// --- 性能优化:智能触发预处理函数 ---
// 只有在启用 Smart Select (Interactive Mode) 或 LAM 模式时才触发后台预处理
// 并且带有防抖功能,避免快速拖动进度条时堵塞后台
function smartTriggerPreprocess(frameNumber) {
// 1. 状态感知:如果没开这些高级功能,完全不请求后台,节省 GPU
if (!isInteractiveMode && !isLamModeActive) {
return;
}
// 2. 防抖:如果之前的请求还没发出,取消它
if (preprocessTimeout) {
clearTimeout(preprocessTimeout);
}
// 3. 延迟执行:用户停止操作 500ms 后才发送请求
preprocessTimeout = setTimeout(() => {
triggerBackgroundPreprocess(frameNumber);
}, 500);
}
function resizeCanvas() {
const dpr = window.devicePixelRatio || 1;
const rect = frameImage.getBoundingClientRect();
[drawingCanvas, crosshairCanvas].forEach(canvas => {
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${rect.height}px`;
canvas.width = Math.round(rect.width * dpr);
canvas.height = Math.round(rect.height * dpr);
});
drawingCtx.scale(dpr, dpr);
crosshairCtx.scale(dpr, dpr);
redrawCanvas();
}
function redrawCanvas() {
if (!frameImage.complete || frameImage.naturalWidth === 0) return;
const dpr = window.devicePixelRatio || 1;
drawingCtx.clearRect(0, 0, drawingCanvas.width / dpr, drawingCanvas.height / dpr);
const scaleX = frameImage.clientWidth / frameImage.naturalWidth;
const scaleY = frameImage.clientHeight / frameImage.naturalHeight;
if (interpolationState.isActive) {
const startBox = interpolationState.startFrameData.bbox;
const x = startBox.x1 * scaleX,
y = startBox.y1 * scaleY,
w = (startBox.x2 - startBox.x1) * scaleX,
h = (startBox.y2 - startBox.y1) * scaleY;
drawingCtx.strokeStyle = getColor('interpolationGhost');
drawingCtx.lineWidth = 2 / dpr;
drawingCtx.setLineDash([4 / dpr, 4 / dpr]);
drawingCtx.strokeRect(x, y, w, h);
drawingCtx.setLineDash([]);
}
bboxes.forEach((box, index) => {
const color = stringToColor(box.label);
drawingCtx.strokeStyle = color;
drawingCtx.lineWidth = (index === selectedBboxIndex) ? 4 / dpr : 2 / dpr;
drawingCtx.shadowColor = (index === selectedBboxIndex) ? getColor('selectionShadow') : 'transparent';
drawingCtx.shadowBlur = (index === selectedBboxIndex) ? 6 / dpr : 0;
if (interpolationState.isActive && box.id === interpolationState.objectId) {
drawingCtx.strokeStyle = getColor('interpolationConfirm');
drawingCtx.shadowColor = getColor('selectionShadow');
drawingCtx.shadowBlur = 8 / dpr;
drawingCtx.setLineDash([5 / dpr, 3 / dpr]);
}
const x = box.x1 * scaleX,
y = box.y1 * scaleY,
w = (box.x2 - box.x1) * scaleX,
h = (box.y2 - box.y1) * scaleY;
drawingCtx.strokeRect(x, y, w, h);
drawingCtx.setLineDash([]);
drawingCtx.shadowBlur = 0;
drawingCtx.fillStyle = interpolationState.isActive && box.id === interpolationState.objectId ? getColor('interpolationConfirm') : color;
drawingCtx.font = `${12/dpr}px Arial`;
const textMetrics = drawingCtx.measureText(box.label);
const textWidth = textMetrics.width;
const textHeight = 14 / dpr;
drawingCtx.fillRect(x, y - textHeight, textWidth + (4 / dpr), textHeight);
drawingCtx.fillStyle = getColor('labelText');
drawingCtx.fillText(box.label, x + (2 / dpr), y - (2 / dpr));
if (index === selectedBboxIndex && !isInteractiveMode) {
const handleSize = 8 / dpr;
const halfHandle = handleSize / 2;
const handles = {
'top-left': { x: x, y: y },
'top-middle': { x: x + w / 2, y: y },
'top-right': { x: x + w, y: y },
'middle-left': { x: x, y: y + h / 2 },
'middle-right': { x: x + w, y: y + h / 2 },
'bottom-left': { x: x, y: y + h },
'bottom-middle': { x: x + w / 2, y: y + h },
'bottom-right': { x: x + w, y: y + h }
};
drawingCtx.fillStyle = getColor('handleFill');
for (const key in handles) {
const pos = handles[key];
drawingCtx.fillRect(pos.x - halfHandle, pos.y - halfHandle, handleSize, handleSize);
}
}
});
if (suggestionBboxes.length > 0) {
const threshold = parseFloat($('#suggestion-threshold').val());
drawingCtx.strokeStyle = getColor('suggestionStroke');
drawingCtx.lineWidth = 2 / dpr;
drawingCtx.setLineDash([5 / dpr, 3 / dpr]);
suggestionBboxes.forEach(sug => {
if (sug.score >= threshold) {
const box = sug.box;
const x = box[0] * scaleX, y = box[1] * scaleY;
const w = (box[2] - box[0]) * scaleX, h = (box[3] - box[1]) * scaleY;
drawingCtx.strokeRect(x, y, w, h);
const scoreText = sug.score.toFixed(2);
drawingCtx.fillStyle = getColor('suggestionFill');
drawingCtx.font = `${10 / dpr}px Arial`;
drawingCtx.fillText(scoreText, x + 2 / dpr, y + 10 / dpr);
}
});
drawingCtx.setLineDash([]);
}
if ((isInteractiveMode || interactivePreviewBboxes.length > 0) && interactivePreviewBboxes.length > 0) {
const threshold = parseFloat($('#result-threshold').val());
interactivePreviewBboxes.forEach((res, index) => {
if (res.score >= threshold) {
const box = res.box;
if (index === hoveredPreviewIndex) {
drawingCtx.strokeStyle = getColor('suggestionHoverStroke');
drawingCtx.lineWidth = 3 / dpr;
} else {
drawingCtx.strokeStyle = getColor('suggestionStroke');
drawingCtx.lineWidth = 2 / dpr;
}
const x = box[0] * scaleX,
y = box[1] * scaleY,
w = (box[2] - box[0]) * scaleX,
h = (box[3] - box[1]) * scaleY;
drawingCtx.strokeRect(x, y, w, h);
const scoreText = res.score.toFixed(2);
drawingCtx.fillStyle = (index === hoveredPreviewIndex) ? getColor('suggestionHoverFill') : getColor('suggestionFill');
drawingCtx.font = `${10/dpr}px Arial`;
drawingCtx.fillText(scoreText, x + 2 / dpr, y + 10 / dpr);
}
});
}
if (isInteractiveMode) {
positiveExampleBboxes.forEach(box => {
drawingCtx.strokeStyle = getColor('positiveExampleStroke');
drawingCtx.lineWidth = 2 / dpr;
drawingCtx.setLineDash([5 / dpr, 3 / dpr]);
const x = box.x1 * scaleX,
y = box.y1 * scaleY,
w = (box.x2 - box.x1) * scaleX,
h = (box.y2 - box.y1) * scaleY;
drawingCtx.strokeRect(x, y, w, h);
drawingCtx.setLineDash([]);
});
}
}
function renderBboxList() {
const list = $("#bbox-list");
list.empty();
bboxes.forEach((box, index) => {
const template = document.getElementById("bbox-item-template").content.cloneNode(true);
const color = stringToColor(box.label);
const listItem = $(template).find("li");
listItem.attr("data-index", index);
if (index === selectedBboxIndex) {
listItem.addClass("selected");
}
$(template).find(".color-swatch").css("background-color", color);
$(template).find(".bbox-label").text(box.label);
$(template).find(".bbox-id").text(box.id ? `ID: ${box.id.substring(0, 4)}` : 'No ID');
$(template).find(".delete-bbox-btn").attr("data-index", index);
const interpolateBtn = $(template).find(".interpolate-btn");
if (interpolationState.isActive) {
if (interpolationState.objectId === box.id) {
interpolateBtn.removeClass('btn-info').addClass('btn-success')
.html('<i class="bi bi-check-circle"></i> Confirm')
.attr('title', `Confirm interpolation from frame ${interpolationState.startFrameData.frame_number}`)
.prop('disabled', false);
} else {
interpolateBtn.prop('disabled', true);
}
}
list.append(template);
});
}
function renderClassList() {
const list = $("#class-list");
list.empty();
availableClasses.forEach(className => {
const color = stringToColor(className);
const colorSwatch = `<span class="color-swatch" style="background-color: ${color};"></span>`;
const listItem = $(`<li class="list-group-item">${colorSwatch}${className}</li>`);
listItem.attr("data-class-name", className);
if (className === activeClass) {
listItem.addClass("active").css({
"background-color": color,
"border-color": color
});
}
list.append(listItem);
});
}
function drawCrosshairs(x, y) {
if (isDrawing || isReviewMode || isDraggingBbox) return;
const dpr = window.devicePixelRatio || 1;
crosshairCtx.clearRect(0, 0, crosshairCanvas.width / dpr, crosshairCanvas.height / dpr);
crosshairCtx.strokeStyle = getColor('crosshair');
crosshairCtx.lineWidth = 1 / dpr;
crosshairCtx.setLineDash([4 / dpr, 4 / dpr]);
crosshairCtx.beginPath();
crosshairCtx.moveTo(0, y);
crosshairCtx.lineTo(crosshairCanvas.width / dpr, y);
crosshairCtx.stroke();
crosshairCtx.beginPath();
crosshairCtx.moveTo(x, 0);
crosshairCtx.lineTo(x, crosshairCanvas.height / dpr);
crosshairCtx.stroke();
}
function clearCrosshairs() {
const dpr = window.devicePixelRatio || 1;
crosshairCtx.clearRect(0, 0, crosshairCanvas.width / dpr, crosshairCanvas.height / dpr);
}
function showToast(message, duration = 3000) {
const notification = $("#toast-notification");
notification.text(message).addClass("show");
if (toastTimeout) {
clearTimeout(toastTimeout);
}
toastTimeout = setTimeout(() => {
notification.removeClass("show");
}, duration);
}
function saveStateToHistory() {
history = history.slice(0, historyIndex + 1);
history.push(JSON.parse(JSON.stringify(bboxes)));
historyIndex = history.length - 1;
}
function undo() {
if (historyIndex > 0) {
historyIndex--;
bboxes = JSON.parse(JSON.stringify(history[historyIndex]));
selectedBboxIndex = -1;
redrawCanvas();
renderBboxList();
}
}
function redo() {
if (historyIndex < history.length - 1) {
historyIndex++;
bboxes = JSON.parse(JSON.stringify(history[historyIndex]));
selectedBboxIndex = -1;
redrawCanvas();
renderBboxList();
}
}
function getMousePos(canvas, event) {
const rect = canvas.getBoundingClientRect();
return {
x: event.clientX - rect.left,
y: event.clientY - rect.top
};
}
function getHandleAtPos(x, y) {
if (selectedBboxIndex === -1) return null;
const scaleX = frameImage.clientWidth / frameImage.naturalWidth;
const scaleY = frameImage.clientHeight / frameImage.naturalHeight;
const box = bboxes[selectedBboxIndex];
const bx = box.x1 * scaleX,
by = box.y1 * scaleY,
bw = (box.x2 - box.x1) * scaleX,
bh = (box.y2 - box.y1) * scaleY;
const handleSize = 8;
const handles = {
'top-left': { x: bx, y: by },
'top-middle': { x: bx + bw / 2, y: by },
'top-right': { x: bx + bw, y: by },
'middle-left': { x: bx, y: by + bh / 2 },
'middle-right': { x: bx + bw, y: by + bh / 2 },
'bottom-left': { x: bx, y: by + bh },
'bottom-middle': { x: bx + bw / 2, y: by + bh },
'bottom-right': { x: bx + bw, y: by + bh }
};
for (const key in handles) {
const pos = handles[key];
if (x >= pos.x - handleSize && x <= pos.x + handleSize && y >= pos.y - handleSize && y <= pos.y + handleSize) {
return key;
}
}
return null;
}
function stringToColor(str) {
if (customClassColors && customClassColors[str]) {
return customClassColors[str];
}
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
return `hsl(${(hash % 360 + 360) % 360}, 80%, 50%)`;
}
function formatBboxesToString() {
return bboxes
.filter(b => b.x1 != null && b.y1 != null && b.x2 != null && b.y2 != null && !isNaN(b.x1) && !isNaN(b.y1) && !isNaN(b.x2) && !isNaN(b.y2))
.map(b => {
const objectId = b.id || self.crypto.randomUUID();
b.id = objectId;
return `${b.x1},${b.y1},${b.x2},${b.y2},${b.label},${objectId}`;
}).join("\n");
}
function parseBboxesFromString(text) {
if (!text) return [];
return text.split("\n")
.filter(line => line.trim() !== "")
.map(line => {
const parts = line.split(",");
if (parts.length < 5) return null;
const hasId = parts.length > 5 && (parts[parts.length - 1].length === 36 || parts[parts.length - 1].length === 32);
const label = parts.slice(4, parts.length - (hasId ? 1 : 0)).join(',');
const id = hasId ? parts[parts.length - 1] : null;
return {
x1: parseInt(parts[0]),
y1: parseInt(parts[1]),
x2: parseInt(parts[2]),
y2: parseInt(parts[3]),
label: label,
id: id
};
}).filter(b => b !== null);
}
function parseSuggestionsFromString(text) {
if (!text) return [];
return text.split("\n")
.filter(line => line.trim() !== "")
.map(line => {
const parts = line.split(",");
if (parts.length < 6) return null;
return {
box: [parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]), parseInt(parts[3])],
label: parts[4],
score: parseFloat(parts[5])
};
}).filter(b => b !== null);
}
function loadBboxesForFrame(frameNumber) {
if (isReviewMode) {
const bboxesText = trackedBboxCache[frameNumber] || "";
processFrameDataAndUpdateUI({
bboxes_text: bboxesText
});
return;
}
if (window.cachedFrames) {
const frameData = window.cachedFrames.find(f => f.frame_number == frameNumber);
processFrameDataAndUpdateUI(frameData);
} else {
$.ajax({
url: "/retrieveVideoFrames",
type: "POST",
contentType: "application/json",
data: JSON.stringify({
video_uuid: videoUuid
}),
success: function(response) {
if (response.success) {
window.cachedFrames = response.frames;
const frameData = window.cachedFrames.find(f => f.frame_number == frameNumber);
processFrameDataAndUpdateUI(frameData);
}
}
});
}
}
function processFrameDataAndUpdateUI(frameData) {
const currentFrameNum = parseInt(frameSlider.val());
bboxes = parseBboxesFromString(frameData ? frameData.bboxes_text : '');
suggestionBboxes = parseSuggestionsFromString(frameData ? frameData.suggested_bboxes_text : '');
if (suggestionBboxes.length > 0) {
$('#suggestion-review-controls').slideDown();
} else {
$('#suggestion-review-controls').slideUp();
}
clearInteractiveSession(false);
if (interpolationState.isActive && currentFrameNum !== interpolationState.startFrameData.frame_number) {
const existingBox = bboxes.find(b => b.id === interpolationState.objectId);
if (!existingBox) {
const ghostBox = JSON.parse(JSON.stringify(interpolationState.startFrameData.bbox));
bboxes.push(ghostBox);
selectedBboxIndex = bboxes.length - 1;
} else {
selectedBboxIndex = bboxes.findIndex(b => b.id === interpolationState.objectId);
}
} else if (!interpolationState.isActive) {
selectedBboxIndex = -1;
}
history = [JSON.parse(JSON.stringify(bboxes))];
historyIndex = 0;
redrawCanvas();
renderBboxList();
}
function addClass(className) {
if (className && !availableClasses.includes(className)) {
$.ajax({
url: "/addClass",
type: "POST",
contentType: "application/json",
data: JSON.stringify({
label_name: className
}),
success: function() {
availableClasses.push(className);
renderClassList();
},
error: function() {
showToast("Error: Failed to add new class.", 3000);
}
});
}
}
function updateSamButtonText() {
const button = $("#sam-toggle-btn");
if (isSamModeActive) {
const text = isInteractiveMode ? "SAM ON (for Sample)" : "SAM ON (for Annotation)";
button.html(`<i class="bi bi-magic"></i> ${text}`);
} else {
button.html('<i class="bi bi-magic"></i> Enable SAM (Point)');
}
}
function setTrackingUiState(isReview) {
isReviewMode = isReview;
$("#track-controls").slideToggle(isReview);
$("#save-bboxes, #sam-toggle-btn, #sam2-track-btn, #interactive-mode-toggle, #lam-toggle-btn").slideToggle(!isReview);
if (!isReview) {
$("#track-progress-bar").css("width", "0%").text("0%").removeClass("bg-success bg-danger").addClass("progress-bar-animated");
$("#track-status-header").text("Tracking...");
$("#stop-sam2-track-btn").prop("disabled", false).text("Stop Tracking");
$("#save-all-tracked-btn").prop("disabled", true);
sam2TrackerUuid = null;
if (sam2EventSource) {
sam2EventSource.close();
sam2EventSource = null;
}
}
}
function exitReviewMode() {
setTrackingUiState(false);
trackedBboxCache = {};
showToast("Exited tracking/review mode.", 2000);
frameSlider.trigger("input");
}
function handleSseEvent(eventData) {
switch (eventData.event) {
case "update":
trackedBboxCache[eventData.frame_number] = eventData.bboxes_text;
const percentage = eventData.total > 0 ? Math.round(eventData.progress / eventData.total * 100) : 0;
$("#track-progress-bar").css("width", percentage + "%").text(percentage + "%");
const frameNumber = eventData.frame_number;
if (frameNumber <= parseInt(frameSlider.attr("max"))) {
frameSlider.val(frameNumber);
$("#frame-number-display").text(frameNumber);
const imageUrl = `/media/frames/${videoUuid}/frame_${String(frameNumber).padStart(5, "0")}.jpg`;
if (!frameImage.src.endsWith(imageUrl)) {
frameImage.src = imageUrl;
}
}
break;
case "batch_update":
const batch_percentage = eventData.total > 0 ? Math.round(eventData.progress / eventData.total * 100) : 0;
$("#track-progress-bar").css("width", batch_percentage + "%").text(batch_percentage + "%");
$("#track-status-header").text(eventData.message || `Batch Tracking...`);
break;
case "completed":
case "stopped":
case "failed":
if(eventData.results) {
Object.assign(trackedBboxCache, eventData.results);
}
handleTrackingEnd(eventData.event.toUpperCase(), eventData.message);
break;
case "error":
alert("Tracking Stream Error: " + eventData.message);
handleTrackingEnd("FAILED", eventData.message);
break;
}
}
function handleTrackingEnd(status, message) {
if (sam2EventSource) {
sam2EventSource.close();
sam2EventSource = null;
}
$("#sam2-track-btn").prop("disabled", false);
$("#stop-sam2-track-btn").prop("disabled", true);
const progressBar = $("#track-progress-bar");
progressBar.removeClass("progress-bar-animated");
let statusText = "Review Results";
if (status === "COMPLETED") {
statusText = "Tracking Completed. Review Results.";
progressBar.css("width", "100%").text("100%").addClass("bg-success");
} else if (status === "STOPPED") {
statusText = "Tracking Stopped. Review Results.";
} else if (status === "FAILED") {
statusText = "Tracking Failed. Review Partial Results.";
progressBar.addClass("bg-danger");
}
$("#track-status-header").text(statusText);
$("#save-all-tracked-btn").prop("disabled", Object.keys(trackedBboxCache).length === 0);
showToast(statusText + (message ? ` (${message})` : ""), 4000);
const firstFrame = Math.min(...Object.keys(trackedBboxCache).map(Number));
if (isFinite(firstFrame)) {
frameSlider.val(firstFrame).trigger("input");
}
}
function clearInteractiveSession(shouldRedraw = true) {
positiveExampleBboxes = [];
interactivePreviewBboxes = [];
lastDatasetClassName = null;
hoveredPreviewIndex = -1;
overlappingCycleIndex = 0;
lastClickPosForCycle = null;
$('#interactive-results-controls').slideUp();
if (shouldRedraw) {
redrawCanvas();
}
}
function triggerFindSimilar() {
if (positiveExampleBboxes.length === 0) {
showToast("Please provide one positive sample.", 2000);
return;
}
lastDatasetClassName = null;
const frameNumber = parseInt(frameSlider.val());
const $button = $('#find-similar-btn');
$button.prop('disabled', true);
const runPrediction = () => {
showToast('Finding similar objects...', 4000);
const payload = {
video_uuid: videoUuid,
frame_number: frameNumber,
prompt_boxes: positiveExampleBboxes
};
$.ajax({
url: '/interactive_segment/predict',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify(payload),
success: res => {
if (res.success) {
interactivePreviewBboxes = res.results;
$('#interactive-results-controls').slideDown();
$('#result-threshold').trigger('input');
redrawCanvas();
} else {
alert('Prediction failed: ' + res.message);
}
},
error: () => alert('Server error during prediction.'),
complete: () => $button.prop('disabled', false)
});
};
if (lastProcessedFrame !== frameNumber) {
showToast('Preprocessing frame for Smart Select...', 10000);
const payload = { video_uuid: videoUuid, frame_number: frameNumber };
$.ajax({
url: '/interactive_segment/preprocess',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify(payload),
success: res => {
if (res.success) {
currentCacheKey = res.cache_key;
lastProcessedFrame = frameNumber;
runPrediction();
} else {
alert('Preprocessing failed: ' + res.message);
$button.prop('disabled', false);
}
},
error: () => {
alert('Server error during preprocessing.');
$button.prop('disabled', false);
}
});
} else {
runPrediction();
}
}
function startNegativeSampling(count) {
$.ajax({
url: '/api/get_random_frames_for_neg_sampling',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ video_uuid: videoUuid, count: count }),
success: function(res) {
if (res.success && res.frames.length > 0) {
negSamplingFrames = res.frames;
currentNegSampleIndex = 0;
negativeSamplesStore = {};
loadNegativeSampleFrame();
$('#negative-sampling-modal').modal({ backdrop: 'static', keyboard: false });
} else {
alert("Failed to fetch random frames: " + (res.message || "No frames returned."));
}
},
error: function() {
alert("Server error while fetching random frames.");
}
});
}
function resizeNegCanvas() {
if (!negImage.complete || negImage.naturalWidth === 0 || !$('#negative-sampling-modal').is(':visible')) {
return;
}
const dpr = window.devicePixelRatio || 1;
const rect = negImage.getBoundingClientRect();
negCanvas.style.width = `${rect.width}px`;
negCanvas.style.height = `${rect.height}px`;
negCanvas.width = Math.round(rect.width * dpr);
negCanvas.height = Math.round(rect.height * dpr);
negCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
redrawNegCanvas();
}
function loadNegativeSampleFrame() {
if (currentNegSampleIndex < 0 || currentNegSampleIndex >= negSamplingFrames.length) return;
const frameData = negSamplingFrames[currentNegSampleIndex];
$('#neg-frame-counter').text(`Frame ${currentNegSampleIndex + 1} / ${negSamplingFrames.length}`);
negImage.onload = () => {
resizeNegCanvas();
};
negImage.src = frameData.image_url;
}
function redrawNegCanvas() {
const dpr = window.devicePixelRatio || 1;
negCtx.clearRect(0, 0, negCanvas.width / dpr, negCanvas.height / dpr);
if (negSamplingFrames.length === 0) return;
const frameData = negSamplingFrames[currentNegSampleIndex];
const frameKey = `${frameData.video_uuid};${frameData.frame_number}`;
const boxes = negativeSamplesStore[frameKey] || [];
if (!negImage.naturalWidth) return;
const scaleX = negImage.clientWidth / negImage.naturalWidth;
const scaleY = negImage.clientHeight / negImage.naturalHeight;
boxes.forEach(box => {
negCtx.strokeStyle = getColor('negativeSampleStroke');
negCtx.lineWidth = 2 / dpr;
negCtx.setLineDash([5 / dpr, 3 / dpr]);
const x = box[0] * scaleX, y = box[1] * scaleY, w = (box[2] - box[0]) * scaleX, h = (box[3] - box[1]) * scaleY;
negCtx.strokeRect(x, y, w, h);
negCtx.setLineDash([]);
});
}
function triggerApplyToVideo(negativeSamples) {
if (!lastDatasetClassName) {
alert("An error occurred. The class to apply is unknown.");
return;
}
const confirmMessage = negativeSamples && Object.keys(negativeSamples).length > 0
? `This will start a background task to apply the REFINED model for '${lastDatasetClassName}' to all unlabeled frames. Continue?`
: `This will start a background task to apply the learned features for '${lastDatasetClassName}' to all unlabeled frames. Continue?`;
if (confirm(confirmMessage)) {
const payload = {
video_uuid: videoUuid,
class_name: lastDatasetClassName,
negative_samples: negativeSamples,
confidence_threshold: parseFloat($('#apply-confidence-threshold').val())
};
$.ajax({
url: '/apply_prototypes_to_video',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify(payload),
success: function(res) {
if (res.success) {
alert("Task started successfully! You can now close this page. The video status will update on the main page.");
window.location.href = "/";
} else {
alert("Failed to start task: " + res.message);
}
},
error: function() {
alert("Server error when trying to start the task.");
}
});
}
}
function undoNegativeSample() {
if (currentNegSampleIndex < 0 || currentNegSampleIndex >= negSamplingFrames.length) return;
const frameData = negSamplingFrames[currentNegSampleIndex];
const frameKey = `${frameData.video_uuid};${frameData.frame_number}`;
if (negativeSamplesStore[frameKey] && negativeSamplesStore[frameKey].length > 0) {
negativeSamplesStore[frameKey].pop();
redrawNegCanvas();
showToast('Last negative sample removed.', 1500);
} else {
showToast('Nothing to undo on this frame.', 1500);
}
}
function clearCurrentNegativeSamples() {
if (currentNegSampleIndex < 0 || currentNegSampleIndex >= negSamplingFrames.length) return;
const frameData = negSamplingFrames[currentNegSampleIndex];
const frameKey = `${frameData.video_uuid};${frameData.frame_number}`;
if (negativeSamplesStore[frameKey] && negativeSamplesStore[frameKey].length > 0) {
negativeSamplesStore[frameKey] = [];
redrawNegCanvas();
showToast('All negative samples cleared for this frame.', 1500);
}
}
function resetInterpolationState(reloadFrame = false) {
interpolationState = {
isActive: false,
objectId: null,
startFrameData: null
};
$('#interpolation-banner').slideUp();
if (reloadFrame) {
frameSlider.trigger("input");
} else {
renderBboxList();
redrawCanvas();
}
}
function showLamSuggestions(bbox, suggestions, clickX, clickY) {
const tempBbox = { ...bbox, label: 'suggestion', id: 'temp' };
bboxes.push(tempBbox);
redrawCanvas();
bboxes.pop();
const template = document.getElementById('lam-suggestion-popup-template').content.cloneNode(true);
const popup = $(template).find('#lam-suggestion-popup');
const suggestionList = popup.find('.list-group');
if (suggestions.length === 0) {
suggestionList.append('<div class="list-group-item">No suggestions, please label manually.</div>');
} else {
suggestions.forEach(sug => {
const color = stringToColor(sug.label);
const scorePercent = (sug.score * 100).toFixed(1);
const item = $(`
<button type="button" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center" data-label="${sug.label}">
<div>
<span class="color-swatch" style="background-color: ${color};"></span>
${sug.label}
</div>
<span class="badge badge-primary badge-pill">${scorePercent}%</span>
</button>
`);
suggestionList.append(item);
});
}
$('body').append(popup);
popup.css({ top: `${clickY + 5}px`, left: `${clickX + 5}px` });
popup.on('click', 'button', function() {
const selectedLabel = $(this).data('label');
if (selectedLabel) {
const newBox = {
...bbox,
label: selectedLabel,
id: self.crypto.randomUUID()
};
bboxes.push(newBox);
saveStateToHistory();
selectedBboxIndex = bboxes.length - 1;
renderBboxList();
}
popup.remove();
redrawCanvas();
});
setTimeout(() => {
$(document).one('mousedown', function(e) {
if (!$(e.target).closest('#lam-suggestion-popup').length) {
popup.remove();
redrawCanvas();
}
});
}, 100);
}
frameImage.onload = function() {
bboxes = [];
selectedBboxIndex = -1;
resizeCanvas();
const frameNumber = frameSlider.val();
loadBboxesForFrame(frameNumber);
// --- 修改:使用智能触发器替代直接调用 ---
smartTriggerPreprocess(frameNumber);
const defaultThreshold = 0.50;
const currentFrameThreshold = frameConfidence[frameNumber] ?? defaultThreshold;
$('#result-threshold').val(currentFrameThreshold);
$('#result-threshold').trigger('input');
};
frameImage.addEventListener('dragstart', (e) => e.preventDefault());
window.onresize = resizeCanvas;
frameSlider.on("input", function() {
const frameNumber = $(this).val();
$("#frame-number-display").text(frameNumber);
const imageUrl = `/media/frames/${videoUuid}/frame_${String(frameNumber).padStart(5, "0")}.jpg`;
if (frameImage.src.endsWith(imageUrl) === false) {
frameImage.src = imageUrl;
} else if (frameImage.complete) {
loadBboxesForFrame(frameNumber);
// --- 修改:使用智能触发器替代直接调用 ---
smartTriggerPreprocess(frameNumber);
}
});
canvasContainer.on('mousedown', function(e) {
if (repeatMode.isActive) {
e.preventDefault();
const pos = getMousePos(drawingCanvas, e);
const scaleX = frameImage.naturalWidth / frameImage.clientWidth;
const scaleY = frameImage.naturalHeight / frameImage.clientHeight;
const halfW = (repeatMode.width / 2);
const halfH = (repeatMode.height / 2);
const newBox = {
x1: Math.round((pos.x * scaleX) - halfW),
y1: Math.round((pos.y * scaleY) - halfH),
x2: Math.round((pos.x * scaleX) + halfW),
y2: Math.round((pos.y * scaleY) + halfH),
label: repeatMode.label,
id: self.crypto.randomUUID()
};
bboxes.push(newBox);
saveStateToHistory();
selectedBboxIndex = bboxes.length - 1;
renderBboxList();
redrawCanvas();
return;
}
if (isLamModeActive) {
e.preventDefault();
$('#lam-suggestion-popup').remove();
const pos = getMousePos(drawingCanvas, e);
const scaleX = frameImage.naturalWidth / frameImage.clientWidth;
const scaleY = frameImage.naturalHeight / frameImage.clientHeight;
canvasContainer.addClass('sam-loading');
showToast('LAM is thinking...', 5000);
$.ajax({
url: "/lam_predict",
type: "POST",
contentType: "application/json",
data: JSON.stringify({
video_uuid: videoUuid,
frame_number: frameSlider.val(),
point: {
x: Math.round(pos.x * scaleX),
y: Math.round(pos.y * scaleY)
}
}),
success: function(response) {
if (response.success) {
showLamSuggestions(response.bbox, response.suggestions, e.clientX, e.clientY);
showToast('Success! Please select a class.', 3000);
} else {
showToast(response.message || "LAM failed to identify an object.", 3000);
}
},
error: function(xhr) {
showToast('Server Error: ' + (xhr.responseJSON ? xhr.responseJSON.message : 'Unknown Error'), 4000);
},
complete: function() {
canvasContainer.removeClass('sam-loading');
}
});
return;
}
if (isSamModeActive) {
e.preventDefault();
const pos = getMousePos(drawingCanvas, e);
const scaleX = frameImage.naturalWidth / frameImage.clientWidth;
const scaleY = frameImage.naturalHeight / frameImage.clientHeight;
canvasContainer.addClass('sam-loading');
showToast('SAM is thinking...', 5000);
$.ajax({
url: "/samPredict",
type: "POST",
contentType: "application/json",
data: JSON.stringify({
video_uuid: videoUuid,
frame_number: frameSlider.val(),
point: {
x: Math.round(pos.x * scaleX),
y: Math.round(pos.y * scaleY)
}
}),
success: function(response) {
if (!response.success) {
showToast(response.message || "SAM failed to find an object.", 3000);
return;
}
if (isInteractiveMode) {
positiveExampleBboxes = [response.bbox];
redrawCanvas();
showToast('Positive SAM sample added. Auto-finding similar objects...', 2000);
triggerFindSimilar();
} else {
if (activeClass) {
const newBox = { ...response.bbox,
label: activeClass,
id: self.crypto.randomUUID()
};
bboxes.push(newBox);
saveStateToHistory();
selectedBboxIndex = bboxes.length - 1;
renderBboxList();
redrawCanvas();
showToast('SAM found an object!', 2000);
} else {
showToast('Please select a class before using SAM.', 3000);
}
}
},
error: function(xhr) {
const errorMsg = xhr.responseJSON ? xhr.responseJSON.message : 'Unknown error';
showToast('Server error during SAM prediction: ' + errorMsg, 4000);
},
complete: function() {
canvasContainer.removeClass('sam-loading');
}
});
return;
}
if (isReviewMode) return;
const pos = getMousePos(drawingCanvas, e);
if (suggestionBboxes.length > 0) {
const threshold = parseFloat($('#suggestion-threshold').val());
const scaleX = frameImage.clientWidth / frameImage.naturalWidth;
const scaleY = frameImage.clientHeight / frameImage.naturalHeight;
let clickedSuggestionIndex = -1;
for (let i = suggestionBboxes.length - 1; i >= 0; i--) {
const sug = suggestionBboxes[i];
if (sug.score >= threshold) {
const b = sug.box;
if (pos.x > b[0] * scaleX && pos.x < b[2] * scaleX && pos.y > b[1] * scaleY && pos.y < b[3] * scaleY) {
clickedSuggestionIndex = i;
break;
}
}
}
if (clickedSuggestionIndex !== -1) {
e.preventDefault();
const acceptedSuggestion = suggestionBboxes.splice(clickedSuggestionIndex, 1)[0];
bboxes.push({
x1: acceptedSuggestion.box[0],
y1: acceptedSuggestion.box[1],
x2: acceptedSuggestion.box[2],
y2: acceptedSuggestion.box[3],
label: acceptedSuggestion.label,
id: self.crypto.randomUUID()
});
saveStateToHistory();
selectedBboxIndex = bboxes.length - 1;
redrawCanvas();
renderBboxList();
showToast(`Suggestion for '${acceptedSuggestion.label}' accepted!`, 2000);
return;
}
}
if ((isInteractiveMode || interactivePreviewBboxes.length > 0) && interactivePreviewBboxes.length > 0) {
if (!activeClass) {
alert("Please select a class from the list to assign it.");
return;
}
const threshold = parseFloat($('#result-threshold').val());
const scaleX = frameImage.clientWidth / frameImage.naturalWidth;
const scaleY = frameImage.clientHeight / frameImage.naturalHeight;
let targetBoxIndex = -1;
const overlappingIndices = [];
interactivePreviewBboxes.forEach((res, index) => {
if (res.score >= threshold) {
const b = res.box;
if (pos.x > b[0] * scaleX && pos.x < b[2] * scaleX && pos.y > b[1] * scaleY && pos.y < b[3] * scaleY) {
overlappingIndices.unshift(index);
}
}
});
if (overlappingIndices.length > 0) {
e.preventDefault();
if (isCKeyPressed) {
const isSameClick = lastClickPosForCycle && Math.abs(lastClickPosForCycle.x - pos.x) < 5 && Math.abs(lastClickPosForCycle.y - pos.y) < 5;
overlappingCycleIndex = isSameClick ? (overlappingCycleIndex + 1) % overlappingIndices.length : 0;
targetBoxIndex = overlappingIndices[overlappingCycleIndex];
lastClickPosForCycle = pos;
} else {
targetBoxIndex = overlappingIndices[0];
lastClickPosForCycle = null;
}
const res = interactivePreviewBboxes[targetBoxIndex];
const b = res.box;
bboxes.push({
x1: b[0],
y1: b[1],
x2: b[2],
y2: b[3],
label: res.label || activeClass
});
interactivePreviewBboxes.splice(targetBoxIndex, 1);
saveStateToHistory();
renderBboxList();
hoveredPreviewIndex = -1;
redrawCanvas();
return;
}
}
if (isInteractiveMode) {
isDrawing = true;
startX = pos.x;
startY = pos.y;
clearCrosshairs();
return;
}
draggedHandle = getHandleAtPos(pos.x, pos.y);
if (draggedHandle) {
isDraggingBbox = true;
editMode = 'resize';
draggedBboxInitialState = JSON.parse(JSON.stringify(bboxes[selectedBboxIndex]));
return;
}
const scaleX = frameImage.clientWidth / frameImage.naturalWidth;
const scaleY = frameImage.clientHeight / frameImage.naturalHeight;
let clickedOnBoxIndex = -1;
for (let i = bboxes.length - 1; i >= 0; i--) {
const box = bboxes[i];
const bx = box.x1 * scaleX,
by = box.y1 * scaleY,
bw = (box.x2 - box.x1) * scaleX,
bh = (box.y2 - box.y1) * scaleY;
if (pos.x > bx && pos.x < bx + bw && pos.y > by && pos.y < by + bh) {
clickedOnBoxIndex = i;
break;
}
}
if (clickedOnBoxIndex !== -1) {
selectedBboxIndex = clickedOnBoxIndex;
isDraggingBbox = true;
editMode = 'move';
draggedBboxInitialState = JSON.parse(JSON.stringify(bboxes[selectedBboxIndex]));
startX = pos.x;
startY = pos.y;
renderBboxList();
redrawCanvas();
} else {
selectedBboxIndex = -1;
renderBboxList();
redrawCanvas();
if (activeClass) {
isDrawing = true;
startX = pos.x;
startY = pos.y;
clearCrosshairs();
}
}
});
$(document).on('mousemove', function(e) {
if (!$(e.target).closest('#canvas-container').length) {
if (hoveredPreviewIndex !== -1) {
hoveredPreviewIndex = -1;
redrawCanvas();
}
$(drawingCanvas).removeClass((index, className) => (className.match(/\bcursor-\S+/g) || []).join(' '));
return;
}
const pos = getMousePos(drawingCanvas, e);
if (isDrawing) {
redrawCanvas();
const dpr = window.devicePixelRatio || 1;
let currentX = Math.max(0, Math.min(pos.x, frameImage.clientWidth));
let currentY = Math.max(0, Math.min(pos.y, frameImage.clientHeight));
drawingCtx.lineWidth = 2 / dpr;
drawingCtx.strokeStyle = isInteractiveMode ? getColor('positiveExampleStroke') : stringToColor(activeClass || 'blue');
drawingCtx.setLineDash(isInteractiveMode ? [5 / dpr, 3 / dpr] : []);
drawingCtx.strokeRect(startX, startY, currentX - startX, currentY - startY);
drawingCtx.setLineDash([]);
} else if (isDraggingBbox) {
const scaleX = frameImage.naturalWidth / frameImage.clientWidth;
const scaleY = frameImage.naturalHeight / frameImage.clientHeight;
let currentBox = bboxes[selectedBboxIndex];
if (editMode === 'move') {
const dx = (pos.x - startX) * scaleX;
const dy = (pos.y - startY) * scaleY;
currentBox.x1 = draggedBboxInitialState.x1 + dx;
currentBox.y1 = draggedBboxInitialState.y1 + dy;
currentBox.x2 = draggedBboxInitialState.x2 + dx;
currentBox.y2 = draggedBboxInitialState.y2 + dy;
} else if (editMode === 'resize') {
const newX = pos.x * scaleX;
const newY = pos.y * scaleY;
const initialState = draggedBboxInitialState;
if (draggedHandle.includes('left')) currentBox.x1 = Math.min(newX, initialState.x2 - 1);
if (draggedHandle.includes('right')) currentBox.x2 = Math.max(newX, initialState.x1 + 1);
if (draggedHandle.includes('top')) currentBox.y1 = Math.min(newY, initialState.y2 - 1);
if (draggedHandle.includes('bottom')) currentBox.y2 = Math.max(newY, initialState.y1 + 1);
}
redrawCanvas();
} else {
drawCrosshairs(pos.x, pos.y);
const handle = getHandleAtPos(pos.x, pos.y);
$(drawingCanvas).removeClass((index, className) => (className.match(/\bcursor-\S+/g) || []).join(' '));
if (handle) {
if (handle.includes('top-left') || handle.includes('bottom-right')) $(drawingCanvas).addClass('cursor-nwse-resize');
else if (handle.includes('top-right') || handle.includes('bottom-left')) $(drawingCanvas).addClass('cursor-nesw-resize');
else if (handle.includes('top') || handle.includes('bottom')) $(drawingCanvas).addClass('cursor-ns-resize');
else if (handle.includes('left') || handle.includes('right')) $(drawingCanvas).addClass('cursor-ew-resize');
} else {
const scaleX = frameImage.clientWidth / frameImage.naturalWidth;
const scaleY = frameImage.clientHeight / frameImage.naturalHeight;
let onBox = false;
for (let i = 0; i < bboxes.length; i++) {
const box = bboxes[i];
if (pos.x > box.x1 * scaleX && pos.x < box.x2 * scaleX && pos.y > box.y1 * scaleY && pos.y < box.y2 * scaleY) {
onBox = true;
break;
}
}
if (onBox) $(drawingCanvas).addClass('cursor-move');
}
if ((isInteractiveMode || interactivePreviewBboxes.length > 0) && interactivePreviewBboxes.length > 0) {
const scaleX = frameImage.clientWidth / frameImage.naturalWidth;
const scaleY = frameImage.clientHeight / frameImage.naturalHeight;
const threshold = parseFloat($('#result-threshold').val());
let foundIndex = -1;
for (let i = interactivePreviewBboxes.length - 1; i >= 0; i--) {
const res = interactivePreviewBboxes[i];
if (res.score >= threshold) {
const b = res.box;
if (pos.x > b[0] * scaleX && pos.x < b[2] * scaleX && pos.y > b[1] * scaleY && pos.y < b[3] * scaleY) {
foundIndex = i;
break;
}
}
}
if (foundIndex !== hoveredPreviewIndex) {
hoveredPreviewIndex = foundIndex;
redrawCanvas();
}
}
}
}).on('mouseup', function(e) {
if (isDrawing) {
isDrawing = false;
const pos = getMousePos(drawingCanvas, e);
let endX = Math.max(0, Math.min(pos.x, frameImage.clientWidth));
let endY = Math.max(0, Math.min(pos.y, frameImage.clientHeight));
if (Math.abs(startX - endX) < 5 || Math.abs(startY - endY) < 5) {
redrawCanvas();
return;
}
const scaleX = frameImage.naturalWidth / frameImage.clientWidth;
const scaleY = frameImage.naturalHeight / frameImage.clientHeight;
const newBoxRaw = {
x1: Math.round(Math.min(startX, endX) * scaleX),
y1: Math.round(Math.min(startY, endY) * scaleY),
x2: Math.round(Math.max(startX, endX) * scaleX),
y2: Math.round(Math.max(startY, endY) * scaleY)
};
if (isInteractiveMode) {
positiveExampleBboxes = [newBoxRaw];
redrawCanvas();
triggerFindSimilar();
} else if (activeClass) {
const newBox = { ...newBoxRaw,
label: activeClass,
id: self.crypto.randomUUID()
};
bboxes.push(newBox);
saveStateToHistory();
selectedBboxIndex = bboxes.length - 1;
renderBboxList();
}
redrawCanvas();
} else if (isDraggingBbox) {
let finalBox = bboxes[selectedBboxIndex];
finalBox.x1 = Math.round(finalBox.x1);
finalBox.y1 = Math.round(finalBox.y1);
finalBox.x2 = Math.round(finalBox.x2);
finalBox.y2 = Math.round(finalBox.y2);
if (finalBox.x1 > finalBox.x2) [finalBox.x1, finalBox.x2] = [finalBox.x2, finalBox.x1];
if (finalBox.y1 > finalBox.y2) [finalBox.y1, finalBox.y2] = [finalBox.y2, finalBox.y1];
saveStateToHistory();
isDraggingBbox = false;
editMode = null;
draggedHandle = null;
draggedBboxInitialState = null;
redrawCanvas();
}
});
$('#canvas-container').on('mouseleave', function() {
clearCrosshairs();
$(drawingCanvas).removeClass((index, className) => (className.match(/\bcursor-\S+/g) || []).join(' '));
});
$('#add-class-btn').on('click', function() {
const className = $('#new-class-name').val().trim();
if (className) {
addClass(className);
$('#new-class-name').val('');
}
});
$('#new-class-name').on("keydown", function(e) {
if (e.key === 'Enter') {
e.preventDefault();
$('#add-class-btn').click();
}
});
$("#class-list").on("click", ".list-group-item", function() {
const clickedClass = $(this).data("class-name");
activeClass = activeClass === clickedClass ? null : clickedClass;
renderClassList();
});
$('#bbox-list').on("click", ".list-group-item", function() {
selectedBboxIndex = parseInt($(this).data("index"));
redrawCanvas();
renderBboxList();
});
$("#bbox-list").on("click", ".delete-bbox-btn", function(e) {
e.stopPropagation();
const index = parseInt($(this).data("index"));
bboxes.splice(index, 1);
saveStateToHistory();
selectedBboxIndex = -1;
redrawCanvas();
renderBboxList();
});
$("#save-bboxes").on("click", function() {
const frameNumber = frameSlider.val();
if (isReviewMode) {
trackedBboxCache[frameNumber] = formatBboxesToString();
showToast(`Frame ${frameNumber} updated in review cache.`, 1500);
} else {
$.ajax({
url: "/storeVideoFrameBboxesText",
type: "POST",
contentType: "application/json",
data: JSON.stringify({
video_uuid: videoUuid,
frame_number: frameNumber,
bboxes_text: formatBboxesToString()
}),
success: function(response) {
if (response.success) {
showToast("Frame " + frameSlider.val() + " saved successfully!");
window.cachedFrames = null;
if (window.cachedFrames) {
const frameData = window.cachedFrames.find(f => f.frame_number == frameNumber);
if (frameData) frameData.suggested_bboxes_text = "";
}
suggestionBboxes = [];
$('#suggestion-review-controls').slideUp();
redrawCanvas();
}
},
error: function() {
showToast("Error: Failed to save bounding boxes.", 3000);
}
});
}
});
$('#rebuild-prototypes-btn').on('click', function() {
if (confirm('This will start a background task to update the AI prototypes for all classes based on the latest labels. This may take a moment. Continue?')) {
const $btn = $(this);
$btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Rebuilding...');
$.ajax({
url: '/api/rebuild_prototypes',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({}),
success: function(res) {
if(res.success) {
showToast(res.message, 4000);
} else {
showToast('Error: ' + res.message, 5000);
}
},
error: function() {
showToast('Error: Failed to start prototype rebuild task.', 5000);
},
complete: function() {
setTimeout(() => {
$btn.prop('disabled', false).html('<i class="bi bi-arrow-clockwise"></i> Rebuild AI Models');
}, 2000);
}
});
}
});
$('#lam-toggle-btn').on('click', function() {
isLamModeActive = !isLamModeActive;
$(this).toggleClass('active', isLamModeActive);
canvasContainer.toggleClass('lam-active', isLamModeActive);
if (isLamModeActive) {
$(this).html('<i class="bi bi-bullseye"></i> LAM Mode ON');
if (isSamModeActive) $('#sam-toggle-btn').click();
if (isInteractiveMode) $('#interactive-mode-toggle').click();
showToast('LAM Mode Activated: Click an object to get class suggestions.', 3000);
// --- 修改:手动开启时立即触发预处理 ---
triggerBackgroundPreprocess(frameSlider.val());
} else {
$(this).html('<i class="bi bi-bullseye"></i> Enable LAM (Click to Label)');
$('#lam-suggestion-popup').remove();
}
});
$('#sam-toggle-btn').on('click', function() {
isSamModeActive = !isSamModeActive;
$(this).toggleClass('active', isSamModeActive);
canvasContainer.toggleClass('sam-active', isSamModeActive);
updateSamButtonText();
if (isSamModeActive) {
if(isLamModeActive) $('#lam-toggle-btn').click();
clearCrosshairs();
}
});
$('#sam2-track-btn').on('click', function() {
if (bboxes.length === 0) {
alert("Please label at least one object on the current frame to start tracking.");
return;
}
const startFrame = parseInt(frameSlider.val());
const maxFrame = parseInt(frameSlider.attr("max"));
$('#batch-end-frame').val(maxFrame).attr('min', startFrame + 1).attr('max', maxFrame);
$('#tracking-options-modal').modal('show');
});
$('input[name="tracking-mode"]').on('change', function() {
$('#batch-mode-options').toggle($(this).val() === 'batch');
});
$('#confirm-tracking-start').on('click', function() {
const mode = $('input[name="tracking-mode"]:checked').val();
const startFrame = parseInt(frameSlider.val());
const initBboxesText = formatBboxesToString();
$('#tracking-options-modal').modal('hide');
$(this).prop("disabled", true);
setTrackingUiState(true);
trackedBboxCache = {};
trackedBboxCache[startFrame] = initBboxesText;
if (mode === 'interactive') {
const endFrame = parseInt(frameSlider.attr("max"));
startInteractiveTracking(startFrame, endFrame, initBboxesText);
} else {
const endFrameBatch = parseInt($('#batch-end-frame').val());
if (endFrameBatch <= startFrame) {
alert("End frame must be after the start frame.");
exitReviewMode();
return;
}
$("#track-status-header").text("Batch Tracking Initializing...");
startBatchTracking(startFrame, endFrameBatch, initBboxesText);
}
});
function startInteractiveTracking(startFrame, endFrame, initBboxesText) {
$.ajax({
url: "/startSam2Tracking",
type: "POST",
contentType: "application/json",
data: JSON.stringify({
video_uuid: videoUuid,
start_frame: startFrame,
end_frame: endFrame,
init_bboxes_text: initBboxesText
}),
success: (res) => {
if (res.success) {
sam2TrackerUuid = res.tracker_uuid;
sam2EventSource = new EventSource(`/streamSam2Tracking/${sam2TrackerUuid}`);
sam2EventSource.onmessage = function(event) {
const data = JSON.parse(event.data);
handleSseEvent(data);
};
sam2EventSource.onerror = function(err) {
showToast("Connection to tracking stream lost.", 3000);
handleTrackingEnd("FAILED", "Connection lost");
if (sam2EventSource) sam2EventSource.close();
};
} else {
alert("Error starting interactive tracking: " + res.message);
exitReviewMode();
}
},
error: () => {
alert("Server error starting interactive tracking.");
exitReviewMode();
}
});
}
function triggerBackgroundPreprocess(frameNumber) {
if (isReviewMode) {
return;
}
$.ajax({
url: "/api/background_preprocess_frame",
type: "POST",
contentType: "application/json",
data: JSON.stringify({
video_uuid: videoUuid,
frame_number: parseInt(frameNumber)
}),
});
}
function startBatchTracking(startFrame, endFrame, initBboxesText) {
$.ajax({
url: "/startSam2BatchTracking",
type: "POST",
contentType: "application/json",
data: JSON.stringify({
video_uuid: videoUuid,
start_frame: startFrame,
end_frame: endFrame,
init_bboxes_text: initBboxesText
}),
success: (res) => {
if (res.success) {
sam2TrackerUuid = res.tracker_uuid;
sam2EventSource = new EventSource(`/streamSam2Tracking/${sam2TrackerUuid}`);
sam2EventSource.onmessage = function(event) {
const data = JSON.parse(event.data);
handleSseEvent(data);
};
sam2EventSource.onerror = function(err) {
showToast("Connection to tracking stream lost.", 3000);
handleTrackingEnd("FAILED", "Connection lost");
if (sam2EventSource) sam2EventSource.close();
};
} else {
alert("Error starting batch tracking: " + res.message);
exitReviewMode();
}
},
error: () => {
alert("Server error starting batch tracking.");
exitReviewMode();
}
});
}
$('#stop-sam2-track-btn').on('click', function() {
if (sam2TrackerUuid) {
$(this).prop("disabled", true).text("Stopping...");
$.ajax({
url: "/stopSam2Tracking",
type: "POST",
contentType: "application/json",
data: JSON.stringify({
tracker_uuid: sam2TrackerUuid
})
});
}
});
$('#exit-track-mode-btn').on('click', exitReviewMode);
$('#save-all-tracked-btn').on('click', function() {
const frameCount = Object.keys(trackedBboxCache).length;
if (confirm(`This will save all ${frameCount} reviewed annotations to the database. Are you sure?`)) {
const savePromises = Object.entries(trackedBboxCache).map(([frame_number, bboxes_text]) =>
$.ajax({
url: "/storeVideoFrameBboxesText",
type: "POST",
contentType: "application/json",
data: JSON.stringify({
video_uuid: videoUuid,
frame_number: parseInt(frame_number),
bboxes_text: bboxes_text
})
})
);
showToast(`Saving ${savePromises.length} frames...`, 5000);
Promise.all(savePromises).then(() => {
showToast("All tracked frames saved successfully!", 3000);
window.cachedFrames = null;
exitReviewMode();
}).catch(err => {
alert("An error occurred while saving some frames.");
});
}
});
$('#bbox-list').on('click', '.interpolate-btn', function(e) {
e.stopPropagation();
const boxIndex = parseInt($(this).closest('li').data('index'));
const clickedBox = bboxes[boxIndex];
const currentFrame = parseInt(frameSlider.val());
if (!clickedBox.id) {
showToast("Error: This object has no ID. Please save first.", 3000);
return;
}
if (!interpolationState.isActive) {
interpolationState.isActive = true;
interpolationState.objectId = clickedBox.id;
interpolationState.startFrameData = {
frame_number: currentFrame,
bbox: JSON.parse(JSON.stringify(clickedBox))
};
$('#interpolation-banner').slideDown();
showToast(`Keyframe 1 set for object ${clickedBox.id.substring(0,4)}. Go to another frame, adjust the box, and click the green 'Confirm' button.`, 5000);
} else if (interpolationState.objectId === clickedBox.id) {
if (currentFrame === interpolationState.startFrameData.frame_number) {
showToast("You are on the same frame. Move to a different frame to set the second keyframe.", 3000);
return;
}
const endFrameData = {
frame_number: currentFrame,
bbox: JSON.parse(JSON.stringify(clickedBox))
};
const $btn = $(this);
$btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm"></span>');
$.ajax({
url: "/api/interpolateBboxes",
type: "POST",
contentType: "application/json",
data: JSON.stringify({
video_uuid: videoUuid,
object_id: interpolationState.objectId,
start_frame: interpolationState.startFrameData,
end_frame: endFrameData
}),
success: function(response) {
if (response.success) {
showToast(response.message, 4000);
window.cachedFrames = null;
} else {
showToast("Error: " + response.message, 5000);
}
},
error: function() {
showToast("Server error during interpolation.", 5000);
},
complete: function() {
const nextFrame = Math.min(parseInt(interpolationState.startFrameData.frame_number), parseInt(endFrameData.frame_number)) + 1;
resetInterpolationState(false);
if (nextFrame < frameCount && nextFrame > 0) {
frameSlider.val(nextFrame).trigger("input");
} else {
frameSlider.trigger("input");
}
}
});
}
renderBboxList();
redrawCanvas();
});
$('#interactive-mode-toggle').on('click', function() {
isInteractiveMode = !isInteractiveMode;
$(this).toggleClass('active', isInteractiveMode);
$('#interactive-segment-controls').slideToggle(isInteractiveMode);
if (isInteractiveMode) {
$(this).html('<i class="bi bi-stars"></i> Smart Select ON');
if(isLamModeActive) $('#lam-toggle-btn').click();
clearCrosshairs();
// --- 修改:手动开启时立即触发预处理 ---
triggerBackgroundPreprocess(frameSlider.val());
} else {
$(this).html('<i class="bi bi-stars"></i> Enable Smart Select');
clearInteractiveSession();
}
updateSamButtonText();
});
$('#clear-samples-btn').on('click', function() {
clearInteractiveSession();
showToast('Sample box and previews cleared.', 2000);
});
$('#find-from-dataset-btn').on('click', function() {
if (!activeClass) {
alert('Please select a class first!');
return;
}
const frameNumber = parseInt(frameSlider.val());
const $button = $(this);
$button.prop('disabled', true);
showToast(`Learning from all '${activeClass}' examples in the dataset...`, 10000);
lastDatasetClassName = activeClass;
const payload = { video_uuid: videoUuid, frame_number: frameNumber, class_name: activeClass };
$.ajax({
url: '/interactive_segment/predict_from_dataset',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify(payload),
success: function(res) {
if (res.success) {
interactivePreviewBboxes = res.results;
$('#interactive-results-controls').slideDown();
$('#result-threshold').trigger('input');
redrawCanvas();
showToast(`Found ${res.results.length} potential '${activeClass}' objects on this frame.`, 4000);
setTimeout(() => {
if (confirm(`Preview successful.\n\nDo you want to apply the '${activeClass}' model to all unlabeled frames in this video?`)) {
startNegativeSampling(10);
}
}, 500);
} else {
alert('Dataset-driven prediction failed: ' + res.message);
}
},
error: function() { alert('Server error during dataset-driven prediction.'); },
complete: function() { $button.prop('disabled', false); }
});
});
$('#neg-canvas-container').on('mousedown', function(e) {
isDrawingNeg = true;
const pos = getMousePos(negCanvas, e);
negStartX = pos.x;
negStartY = pos.y;
}).on('mousemove', function(e) {
if (!isDrawingNeg) return;
redrawNegCanvas();
const pos = getMousePos(negCanvas, e);
const dpr = window.devicePixelRatio || 1;
negCtx.strokeStyle = getColor('negativeSampleStroke');
negCtx.lineWidth = 2 / dpr;
negCtx.setLineDash([5 / dpr, 3 / dpr]);
negCtx.strokeRect(negStartX, negStartY, pos.x - negStartX, pos.y - negStartY);
negCtx.setLineDash([]);
}).on('mouseup', function(e) {
if (!isDrawingNeg) return;
isDrawingNeg = false;
const pos = getMousePos(negCanvas, e);
const scaleX = negImage.naturalWidth / negImage.clientWidth;
const scaleY = negImage.naturalHeight / negImage.clientHeight;
const x1 = Math.round(Math.min(negStartX, pos.x) * scaleX);
const y1 = Math.round(Math.min(negStartY, pos.y) * scaleY);
const x2 = Math.round(Math.max(negStartX, pos.x) * scaleX);
const y2 = Math.round(Math.max(negStartY, pos.y) * scaleY);
if (Math.abs(x1 - x2) < 5 || Math.abs(y1 - y2) < 5) {
redrawNegCanvas();
return;
}
const frameData = negSamplingFrames[currentNegSampleIndex];
const frameKey = `${frameData.video_uuid};${frameData.frame_number}`;
if (!negativeSamplesStore[frameKey]) {
negativeSamplesStore[frameKey] = [];
}
negativeSamplesStore[frameKey].push([x1, y1, x2, y2]);
redrawNegCanvas();
});
$('#neg-prev-btn').on('click', function() { if (currentNegSampleIndex > 0) { currentNegSampleIndex--; loadNegativeSampleFrame(); } });
$('#neg-next-btn').on('click', function() { if (currentNegSampleIndex < negSamplingFrames.length - 1) { currentNegSampleIndex++; loadNegativeSampleFrame(); } });
$('#finish-neg-sampling-btn').on('click', function() {
$('#negative-sampling-modal').modal('hide');
const finalNegativeSamples = Object.fromEntries(
Object.entries(negativeSamplesStore).filter(([key, value]) => value.length > 0)
);
const message = Object.keys(finalNegativeSamples).length > 0
? `Refining model with ${Object.keys(finalNegativeSamples).length} frames of negative samples.`
: "No negative samples were added. Applying original model.";
showToast(message);
triggerApplyToVideo(finalNegativeSamples);
});
$('#result-threshold').on('input', function() {
const value = parseFloat($(this).val());
$('#threshold-value').text(value.toFixed(2));
const currentFrame = frameSlider.val();
frameConfidence[currentFrame] = value;
redrawCanvas();
});
$('#suggestion-threshold').on('input', function() {
const value = parseFloat($(this).val());
$('#suggestion-threshold-value').text(value.toFixed(2));
redrawCanvas();
});
$('#accept-all-suggestions-btn').on('click', function() {
if (suggestionBboxes.length === 0) return;
const threshold = parseFloat($('#suggestion-threshold').val());
let acceptedCount = 0;
const remainingSuggestions = [];
suggestionBboxes.forEach(sug => {
if (sug.score >= threshold) {
bboxes.push({
x1: sug.box[0], y1: sug.box[1], x2: sug.box[2], y2: sug.box[3],
label: sug.label,
id: self.crypto.randomUUID()
});
acceptedCount++;
} else {
remainingSuggestions.push(sug);
}
});
if (acceptedCount > 0) {
suggestionBboxes = remainingSuggestions;
saveStateToHistory();
selectedBboxIndex = -1;
redrawCanvas();
renderBboxList();
showToast(`Accepted ${acceptedCount} visible suggestions.`, 2000);
} else {
showToast('No visible suggestions to accept.', 2000);
}
});
$('#apply-confidence-threshold').on('input', function() {
$('#apply-confidence-value').text(parseFloat($(this).val()).toFixed(2));
});
$('#accept-visible-btn').on('click', function() {
if (!activeClass) {
alert('Please select a class before accepting results.');
return
}
const threshold = parseFloat($('#result-threshold').val());
let addedCount = 0;
const remainingPreviews = [];
interactivePreviewBboxes.forEach(res => {
if (res.score >= threshold) {
const b = res.box;
bboxes.push({
x1: b[0],
y1: b[1],
x2: b[2],
y2: b[3],
label: res.label || activeClass
});
addedCount++;
} else {
remainingPreviews.push(res);
}
});
if (addedCount > 0) {
interactivePreviewBboxes = remainingPreviews;
saveStateToHistory();
renderBboxList();
redrawCanvas();
showToast(`${addedCount} new bounding boxes added.`, 2000);
} else {
showToast('No boxes above the current threshold to accept.', 2000);
}
});
$('#finish-interactive-btn').on('click', clearInteractiveSession);
$(document).on("keydown", function(e) {
if ($(e.target).is("input, textarea")) return;
const isNegModalVisible = $('#negative-sampling-modal').is(':visible');
const key = e.key.toLowerCase();
if (key === 'c') {
e.preventDefault();
isCKeyPressed = true;
}
if (e.ctrlKey && key === "z") {
e.preventDefault();
if (isNegModalVisible) {
undoNegativeSample();
} else {
undo();
}
} else if (e.ctrlKey && (key === "y" || (e.shiftKey && key === "z"))) {
if (!isNegModalVisible) {
e.preventDefault();
redo();
}
} else if (e.key === "Delete" || e.key === "Backspace") {
e.preventDefault();
if (isNegModalVisible) {
clearCurrentNegativeSamples();
} else if (selectedBboxIndex !== -1) {
bboxes.splice(selectedBboxIndex, 1);
saveStateToHistory();
selectedBboxIndex = -1;
redrawCanvas();
renderBboxList();
}
} else if (e.key === "Escape") {
e.preventDefault();
if (interpolationState.isActive) {
resetInterpolationState(true);
showToast("插值操作已取消。", 2000);
}
if (repeatMode.isActive) {
repeatMode.isActive = false;
showToast("重复标注模式已关闭。", 2000);
canvasContainer.css('cursor', 'crosshair');
}
} else {
switch (key) {
case "r":
e.preventDefault();
if (repeatMode.isActive) {
repeatMode.isActive = false;
showToast("重复标注模式已关闭。", 2000);
canvasContainer.css('cursor', 'crosshair');
} else if (selectedBboxIndex !== -1) {
const sourceBox = bboxes[selectedBboxIndex];
repeatMode = {
isActive: true,
width: sourceBox.x2 - sourceBox.x1,
height: sourceBox.y2 - sourceBox.y1,
label: sourceBox.label
};
showToast(`重复模式已激活: ${repeatMode.label} (${repeatMode.width}x${repeatMode.height})`, 3000);
canvasContainer.css('cursor', 'copy');
} else if (bboxes.length > 0) {
const sourceBox = bboxes[bboxes.length - 1];
repeatMode = {
isActive: true,
width: sourceBox.x2 - sourceBox.x1,
height: sourceBox.y2 - sourceBox.y1,
label: sourceBox.label
};
showToast(`重复模式已激活 (基于最后一个框): ${repeatMode.label} (${repeatMode.width}x${repeatMode.height})`, 3000);
canvasContainer.css('cursor', 'copy');
} else {
showToast("请先创建或选中一个标注框以激活重复模式。", 3000);
}
break;
case "s":
if (!isNegModalVisible) {
e.preventDefault();
$("#save-bboxes").click();
}
break;
case "a":
e.preventDefault();
if (isNegModalVisible) {
$('#neg-prev-btn').click();
} else {
let prevFrame = parseInt(frameSlider.val()) - 1;
if (prevFrame >= task.start_frame) {
frameSlider.val(prevFrame).trigger("input");
}
}
break;
case "d":
e.preventDefault();
if (isNegModalVisible) {
$('#neg-next-btn').click();
} else {
let nextFrame = parseInt(frameSlider.val()) + 1;
if (nextFrame < frameCount) {
frameSlider.val(nextFrame).trigger("input");
}
}
break;
}
}
}).on("keyup", function(e) {
if (e.key.toLowerCase() === 'c') {
isCKeyPressed = false;
}
});
function performInitialLoad() {
$.get('/listClasses', function(response) {
if (response.success) {
response.labels.forEach(label => {
if (!availableClasses.includes(label)) {
availableClasses.push(label);
}
});
renderClassList();
const urlParams = new URLSearchParams(window.location.search);
const frameFromUrl = urlParams.get('frame');
if (frameFromUrl) {
const frameNumber = parseInt(frameFromUrl);
if (frameNumber >= parseInt(frameSlider.attr('min')) && frameNumber <= parseInt(frameSlider.attr('max'))) {
frameSlider.val(frameNumber);
}
}
resizeCanvas();
frameSlider.trigger('input');
}
});
}
$('#negative-sampling-modal').on('shown.bs.modal', function () {
resizeNegCanvas();
});
$(window).on('resize', function() {
if ($('#negative-sampling-modal').is(':visible')) {
resizeNegCanvas();
}
});
if (window.cachedFrames) {
delete window.cachedFrames;
}
if (frameImage.complete && frameImage.naturalWidth > 0) {
performInitialLoad();
} else {
$(frameImage).one('load', performInitialLoad);
}
});
</script>
{% endblock %}