2026-05-18-19-56-47 重构双视频同步与单帧对比

This commit is contained in:
2026-05-18 20:11:52 +08:00
parent 72c96828d5
commit 88cbcc65c2
9 changed files with 727 additions and 32 deletions

View File

@@ -18,6 +18,9 @@ const videoPreview = document.querySelector("#videoPreview");
const imagePreview = document.querySelector("#imagePreview");
const progressWrap = document.querySelector("#progressWrap");
const progressText = document.querySelector("#progressText");
const progressBar = document.querySelector("#progressBar");
const resultVideoPreview = document.querySelector("#resultVideoPreview");
const resultVideoEmpty = document.querySelector("#resultVideoEmpty");
const summaryStrip = document.querySelector("#summaryStrip");
const summaryJob = document.querySelector("#summaryJob");
const summaryFrames = document.querySelector("#summaryFrames");
@@ -34,23 +37,67 @@ const closeSourceDialog = document.querySelector("#closeSourceDialog");
const sourceTitle = document.querySelector("#sourceTitle");
const sourceVideo = document.querySelector("#sourceVideo");
const sourceImage = document.querySelector("#sourceImage");
const openCompareButton = document.querySelector("#openCompareButton");
const controlCompareButton = document.querySelector("#controlCompareButton");
const compareDialog = document.querySelector("#compareDialog");
const closeCompareDialog = document.querySelector("#closeCompareDialog");
const compareGrid = document.querySelector("#compareGrid");
const compareTitle = document.querySelector("#compareTitle");
const methodLabels = new Map();
const methodDescriptions = new Map();
let selectedFile = null;
let currentObjectUrl = null;
let lastFrames = [];
let lastResult = null;
let currentFrame = null;
let syncLock = false;
let progressTimers = [];
function setBusy(isBusy, text = "运行分割") {
const button = form.querySelector(".primary");
button.disabled = isBusy;
button.querySelector("span").textContent = isBusy ? "分割中" : text;
progressWrap.hidden = !isBusy;
if (isBusy) {
progressText.textContent = "正在上传、抽帧并执行导丝分割";
progressWrap.hidden = false;
setProgress(8, "正在上传、抽帧并执行导丝分割");
}
}
function setProgress(percent, text) {
progressBar.style.width = `${Math.max(0, Math.min(100, percent))}%`;
progressText.textContent = text;
}
function scheduleProgress() {
progressTimers.forEach((timer) => clearTimeout(timer));
progressTimers = [
setTimeout(() => setProgress(28, "正在读取视频并抽取关键帧"), 400),
setTimeout(() => setProgress(58, "正在生成导丝掩膜与叠加视频"), 1400),
setTimeout(() => setProgress(82, "正在整理结果帧与指标"), 2800),
];
}
function finishProgress() {
progressTimers.forEach((timer) => clearTimeout(timer));
progressTimers = [];
setProgress(100, "分割完成");
setTimeout(() => {
progressWrap.hidden = true;
progressBar.style.width = "0%";
}, 650);
}
function failProgress(message) {
progressTimers.forEach((timer) => clearTimeout(timer));
progressTimers = [];
setProgress(100, message || "分割失败");
setTimeout(() => {
progressWrap.hidden = true;
progressBar.style.width = "0%";
}, 900);
}
function setFile(file) {
selectedFile = file;
if (typeof DataTransfer !== "undefined") {
@@ -60,6 +107,7 @@ function setFile(file) {
}
fileName.textContent = `${file.name} · ${(file.size / 1024 / 1024).toFixed(2)} MB`;
renderPreview(file);
resetResultsForNewInput();
}
function revokePreview() {
@@ -125,6 +173,7 @@ async function loadMethods() {
methodSelect.innerHTML = "";
methodGrid.innerHTML = "";
Object.entries(data.methods).forEach(([key, value]) => {
if (key === "compare") return;
methodLabels.set(key, value.label);
methodDescriptions.set(key, value.description);
@@ -154,7 +203,22 @@ function updateSummary(data) {
summarySkeleton.textContent = Math.round(skeleton);
summaryStrip.hidden = false;
resultCount.textContent = `${frames.length} 个结果`;
jobMeta.textContent = `${data.kind === "video" ? "视频" : "图像"} · ${methodLabels.get(data.method) || data.method}`;
}
function resetResultsForNewInput() {
lastFrames = [];
lastResult = null;
currentFrame = null;
resultGrid.innerHTML = "";
resultVideoPreview.hidden = true;
resultVideoPreview.removeAttribute("src");
resultVideoEmpty.hidden = false;
emptyState.hidden = false;
emptyState.textContent = "已加载输入。点击左侧“运行分割”生成导丝掩膜和叠加结果。";
summaryStrip.hidden = true;
videoLink.hidden = true;
resultCount.textContent = "0 个结果";
setCompareEnabled(false);
}
function openDetail(frame) {
@@ -174,6 +238,7 @@ function openDetail(frame) {
}
function renderResults(data) {
lastResult = data;
lastFrames = data.frames || [];
resultGrid.innerHTML = "";
emptyState.hidden = lastFrames.length > 0;
@@ -181,10 +246,19 @@ function renderResults(data) {
if (data.video_url) {
videoLink.href = data.video_url;
videoLink.setAttribute("download", "");
resultVideoPreview.preload = "metadata";
resultVideoPreview.src = data.video_url;
resultVideoPreview.load();
resultVideoPreview.hidden = false;
resultVideoEmpty.hidden = true;
} else {
resultVideoPreview.hidden = true;
resultVideoPreview.removeAttribute("src");
resultVideoEmpty.hidden = false;
}
updateSummary(data);
lastFrames.forEach((frame) => {
lastFrames.forEach((frame, index) => {
const node = template.content.firstElementChild.cloneNode(true);
node.querySelector(".method").textContent = methodLabels.get(frame.method) || frame.method;
node.querySelector(".frame-index").textContent = `${frame.frame_index}`;
@@ -192,12 +266,16 @@ function renderResults(data) {
node.querySelector(".coverage").textContent = `${(frame.metrics.coverage * 100).toFixed(3)}%`;
node.querySelector(".skeleton").textContent = frame.metrics.skeleton_length;
node.querySelector(".components").textContent = frame.metrics.components;
node.addEventListener("click", () => openDetail(frame));
node.dataset.frameIndex = frame.frame_index;
node.addEventListener("click", () => selectFrame(frame, index));
node.addEventListener("keydown", (event) => {
if (event.key === "Enter") openDetail(frame);
if (event.key === "Enter") selectFrame(frame, index);
});
resultGrid.appendChild(node);
});
if (lastFrames.length) {
selectFrame(lastFrames[0], 0);
}
}
async function loadSample() {
@@ -240,7 +318,11 @@ function clearAll() {
sourceImage.hidden = true;
sourceVideo.removeAttribute("src");
sourceImage.removeAttribute("src");
resultVideoPreview.hidden = true;
resultVideoPreview.removeAttribute("src");
resultVideoEmpty.hidden = false;
openSourceButton.hidden = true;
setCompareEnabled(false);
sourcePaneTitle.textContent = "查看原始视频";
if (sourceDialog.open) sourceDialog.close();
previewEmpty.hidden = false;
@@ -250,6 +332,146 @@ function clearAll() {
videoLink.hidden = true;
resultCount.textContent = "0 个结果";
jobMeta.textContent = "等待输入";
lastFrames = [];
lastResult = null;
currentFrame = null;
}
function setCompareEnabled(enabled) {
openCompareButton.disabled = !enabled;
controlCompareButton.disabled = !enabled;
}
function selectFrame(frame, index = 0) {
currentFrame = frame;
[...resultGrid.querySelectorAll(".result-card")].forEach((node) => {
node.classList.toggle("is-selected", Number(node.dataset.frameIndex) === Number(frame.frame_index));
});
syncLock = true;
seekMedia(videoPreview, frame.source_time);
seekMedia(resultVideoPreview, frame.result_time);
syncLock = false;
setCompareEnabled(Boolean(selectedFile && frame));
resultCount.textContent = `${lastFrames.length} 个结果 · 当前帧 ${frame.frame_index}`;
}
function seekMedia(media, time) {
if (!media || media.hidden || !Number.isFinite(time)) return;
if (Math.abs(media.currentTime - time) > 0.04) {
media.currentTime = Math.max(0, time);
}
}
function nearestFrameBy(source, time) {
if (!lastFrames.length) return null;
const key = source === "result" ? "result_time" : "source_time";
return lastFrames.reduce((best, frame) => {
const bestValue = Number.isFinite(best[key]) ? best[key] : 0;
const value = Number.isFinite(frame[key]) ? frame[key] : 0;
const bestDistance = Math.abs(bestValue - time);
const distance = Math.abs(value - time);
return distance < bestDistance ? frame : best;
}, lastFrames[0]);
}
function syncVideos(source, time) {
if (syncLock || !lastFrames.length) return;
const frame = nearestFrameBy(source, time);
if (!frame) return;
currentFrame = frame;
syncLock = true;
if (source === "source" && !resultVideoPreview.hidden && Number.isFinite(frame.result_time)) {
seekMedia(resultVideoPreview, frame.result_time);
}
if (source === "result" && !videoPreview.hidden && Number.isFinite(frame.source_time)) {
seekMedia(videoPreview, frame.source_time);
}
syncLock = false;
[...resultGrid.querySelectorAll(".result-card")].forEach((node) => {
node.classList.toggle("is-selected", Number(node.dataset.frameIndex) === Number(frame.frame_index));
});
setCompareEnabled(Boolean(selectedFile && frame));
resultCount.textContent = `${lastFrames.length} 个结果 · 当前帧 ${frame.frame_index}`;
}
function createMetricBlock(frame) {
const metrics = document.createElement("dl");
metrics.className = "metrics";
[
["覆盖率", `${(frame.metrics.coverage * 100).toFixed(3)}%`],
["骨架", frame.metrics.skeleton_length],
["连通域", frame.metrics.components],
].forEach(([label, value]) => {
const item = document.createElement("div");
const dt = document.createElement("dt");
const dd = document.createElement("dd");
dt.textContent = label;
dd.textContent = value;
item.append(dt, dd);
metrics.appendChild(item);
});
return metrics;
}
function renderCompareFrames(frames) {
compareGrid.innerHTML = "";
frames.forEach((frame) => {
const card = document.createElement("article");
card.className = "compare-card";
const top = document.createElement("div");
top.className = "card-top";
const method = document.createElement("span");
method.className = "method";
method.textContent = methodLabels.get(frame.method) || frame.method;
const index = document.createElement("span");
index.className = "frame-index";
index.textContent = `${frame.frame_index}`;
top.append(method, index);
const figure = document.createElement("figure");
const image = document.createElement("img");
image.src = frame.overlay_url;
image.alt = `${method.textContent} 叠加结果`;
const caption = document.createElement("figcaption");
caption.textContent = "当前帧叠加视图";
figure.append(image, caption);
card.append(top, figure, createMetricBlock(frame));
compareGrid.appendChild(card);
});
}
async function openCompareForCurrentFrame() {
if (!selectedFile || !currentFrame) return;
setCompareEnabled(false);
compareTitle.textContent = `当前帧 ${currentFrame.frame_index} 多方法对比`;
compareGrid.innerHTML = '<div class="compare-loading">正在生成当前帧多方法对比。</div>';
if (!compareDialog.open) compareDialog.showModal();
try {
const payload = new FormData();
payload.set("file", selectedFile);
payload.set("frame_index", currentFrame.frame_index);
payload.set("sensitivity", sensitivity.value);
const response = await fetch("/api/compare-frame", {
method: "POST",
body: payload,
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || "多方法对比失败");
}
renderCompareFrames(data.frames || []);
} catch (error) {
compareGrid.innerHTML = "";
const message = document.createElement("div");
message.className = "compare-loading";
message.textContent = error.message || "多方法对比失败";
compareGrid.appendChild(message);
} finally {
setCompareEnabled(Boolean(selectedFile && currentFrame));
}
}
sensitivity.addEventListener("input", () => {
@@ -284,6 +506,25 @@ sampleButton.addEventListener("click", loadSample);
clearButton.addEventListener("click", clearAll);
closeDialog.addEventListener("click", () => detailDialog.close());
closeSourceDialog.addEventListener("click", () => sourceDialog.close());
closeCompareDialog.addEventListener("click", () => compareDialog.close());
openCompareButton.addEventListener("click", openCompareForCurrentFrame);
controlCompareButton.addEventListener("click", openCompareForCurrentFrame);
videoPreview.addEventListener("seeked", () => syncVideos("source", videoPreview.currentTime));
resultVideoPreview.addEventListener("seeked", () => syncVideos("result", resultVideoPreview.currentTime));
videoPreview.addEventListener("play", () => {
if (!syncLock && !resultVideoPreview.hidden) resultVideoPreview.play().catch(() => {});
});
resultVideoPreview.addEventListener("play", () => {
if (!syncLock && !videoPreview.hidden) videoPreview.play().catch(() => {});
});
videoPreview.addEventListener("pause", () => {
if (!syncLock && !resultVideoPreview.hidden) resultVideoPreview.pause();
});
resultVideoPreview.addEventListener("pause", () => {
if (!syncLock && !videoPreview.hidden) videoPreview.pause();
});
openSourceButton.addEventListener("click", () => {
if (!selectedFile || !currentObjectUrl) return;
sourceVideo.hidden = true;
@@ -315,9 +556,15 @@ form.addEventListener("submit", async (event) => {
}
setBusy(true);
scheduleProgress();
setCompareEnabled(false);
currentFrame = null;
emptyState.hidden = false;
emptyState.textContent = "正在抽帧和分割,请稍候。";
resultGrid.innerHTML = "";
resultVideoPreview.hidden = true;
resultVideoPreview.removeAttribute("src");
resultVideoEmpty.hidden = false;
videoLink.hidden = true;
try {
@@ -332,9 +579,11 @@ form.addEventListener("submit", async (event) => {
throw new Error(data.detail || "分割失败");
}
renderResults(data);
finishProgress();
} catch (error) {
emptyState.hidden = false;
emptyState.textContent = error.message;
failProgress(error.message || "分割失败");
} finally {
setBusy(false);
}