2517 lines
112 KiB
HTML
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">×</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 %} |