const form = document.querySelector("#segmentForm"); const methodSelect = document.querySelector("#method"); const methodGrid = document.querySelector("#methodGrid"); const sensitivity = document.querySelector("#sensitivity"); const sensitivityValue = document.querySelector("#sensitivityValue"); const fileInput = document.querySelector("#file"); const fileName = document.querySelector("#fileName"); const dropZone = document.querySelector("#dropZone"); const sampleButton = document.querySelector("#sampleButton"); const clearButton = document.querySelector("#clearButton"); const resultGrid = document.querySelector("#resultGrid"); const emptyState = document.querySelector("#emptyState"); const videoLink = document.querySelector("#videoLink"); const health = document.querySelector("#health"); const template = document.querySelector("#resultCardTemplate"); const previewEmpty = document.querySelector("#previewEmpty"); 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"); const summaryCoverage = document.querySelector("#summaryCoverage"); const summarySkeleton = document.querySelector("#summarySkeleton"); const resultCount = document.querySelector("#resultCount"); const jobMeta = document.querySelector("#jobMeta"); const detailDialog = document.querySelector("#detailDialog"); const closeDialog = document.querySelector("#closeDialog"); const openSourceButton = document.querySelector("#openSourceButton"); const sourcePaneTitle = document.querySelector("#sourcePaneTitle"); const sourceDialog = document.querySelector("#sourceDialog"); 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; if (isBusy) { 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") { const transfer = new DataTransfer(); transfer.items.add(file); fileInput.files = transfer.files; } fileName.textContent = `${file.name} · ${(file.size / 1024 / 1024).toFixed(2)} MB`; renderPreview(file); resetResultsForNewInput(); } function revokePreview() { if (currentObjectUrl) { URL.revokeObjectURL(currentObjectUrl); currentObjectUrl = null; } } function renderPreview(file) { revokePreview(); currentObjectUrl = URL.createObjectURL(file); previewEmpty.hidden = true; videoPreview.hidden = true; imagePreview.hidden = true; openSourceButton.hidden = false; if (file.type.startsWith("video/")) { videoPreview.preload = "metadata"; videoPreview.onloadedmetadata = () => { if (Number.isFinite(videoPreview.duration) && videoPreview.duration > 0.2) { videoPreview.currentTime = 0.1; } }; videoPreview.src = currentObjectUrl; videoPreview.load(); videoPreview.hidden = false; openSourceButton.textContent = "放大查看"; sourcePaneTitle.textContent = "查看原始视频"; sourceTitle.textContent = "原始视频"; } else { imagePreview.src = currentObjectUrl; imagePreview.hidden = false; openSourceButton.textContent = "放大查看"; sourcePaneTitle.textContent = "查看原始图像"; sourceTitle.textContent = "原始图像"; } jobMeta.textContent = `已选择 ${file.name}`; } async function loadHealth() { try { const response = await fetch("/api/health"); if (!response.ok) throw new Error("bad health"); const data = await response.json(); health.textContent = `${data.service} ${data.version}`; health.classList.add("ok"); } catch { health.textContent = "服务不可用"; health.classList.add("bad"); } } function activateMethod(key) { methodSelect.value = key; [...methodGrid.querySelectorAll(".method-option")].forEach((node) => { node.classList.toggle("is-active", node.dataset.method === key); }); } async function loadMethods() { const response = await fetch("/api/methods"); const data = await response.json(); 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); const option = document.createElement("option"); option.value = key; option.textContent = value.label; methodSelect.appendChild(option); const card = document.createElement("button"); card.type = "button"; card.className = "method-option"; card.dataset.method = key; card.innerHTML = `${value.label}${value.description}`; card.addEventListener("click", () => activateMethod(key)); methodGrid.appendChild(card); }); activateMethod("fusion"); } function updateSummary(data) { const frames = data.frames || []; const coverage = frames.reduce((sum, frame) => sum + frame.metrics.coverage, 0) / Math.max(1, frames.length); const skeleton = frames.reduce((sum, frame) => sum + frame.metrics.skeleton_length, 0) / Math.max(1, frames.length); summaryJob.textContent = data.job_id; summaryFrames.textContent = frames.length; summaryCoverage.textContent = `${(coverage * 100).toFixed(3)}%`; summarySkeleton.textContent = Math.round(skeleton); summaryStrip.hidden = false; resultCount.textContent = `${frames.length} 个结果`; } 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) { document.querySelector("#detailMethod").textContent = methodLabels.get(frame.method) || frame.method; document.querySelector("#detailTitle").textContent = `帧 ${frame.frame_index} 分割详情`; document.querySelector("#detailOriginal").src = frame.original_url; document.querySelector("#detailOverlay").src = frame.overlay_url; document.querySelector("#detailMask").src = frame.mask_url; const metrics = document.querySelector("#detailMetrics"); metrics.innerHTML = `
覆盖率
${(frame.metrics.coverage * 100).toFixed(3)}%
掩膜像素
${frame.metrics.mask_pixels}
骨架长度
${frame.metrics.skeleton_length}
连通域
${frame.metrics.components}
`; detailDialog.showModal(); } function renderResults(data) { lastResult = data; lastFrames = data.frames || []; resultGrid.innerHTML = ""; emptyState.hidden = lastFrames.length > 0; videoLink.hidden = !data.video_url; 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, 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}`; node.querySelector(".overlay").src = frame.overlay_url; 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.dataset.frameIndex = frame.frame_index; node.addEventListener("click", () => selectFrame(frame, index)); node.addEventListener("keydown", (event) => { if (event.key === "Enter") selectFrame(frame, index); }); resultGrid.appendChild(node); }); if (lastFrames.length) { selectFrame(lastFrames[0], 0); } } async function loadSample() { sampleButton.disabled = true; sampleButton.textContent = "加载中"; emptyState.hidden = false; emptyState.textContent = "正在加载内置样例视频。"; try { const response = await fetch("/api/samples"); if (!response.ok) throw new Error("样例清单读取失败"); const data = await response.json(); const sample = data.samples.find((item) => item.kind === "video") || data.samples[0]; if (!sample) throw new Error("未找到样例文件"); const separator = sample.url.includes("?") ? "&" : "?"; const sampleUrl = `${sample.url}${separator}t=${Date.now()}`; const blobResponse = await fetch(sampleUrl, { cache: "reload" }); if (!blobResponse.ok) throw new Error("样例文件下载失败"); const blob = await blobResponse.blob(); const file = new File([blob], sample.name, { type: sample.kind === "video" ? "video/mp4" : "image/png" }); setFile(file); emptyState.textContent = "样例已加载。点击左侧“运行分割”即可生成导丝掩膜和叠加结果。"; } catch (error) { emptyState.textContent = error.message || "样例加载失败"; } finally { sampleButton.disabled = false; sampleButton.textContent = "加载样例"; } } function clearAll() { selectedFile = null; fileInput.value = ""; fileName.textContent = "支持 mp4、avi、png、jpg、tiff"; revokePreview(); videoPreview.removeAttribute("src"); imagePreview.removeAttribute("src"); videoPreview.hidden = true; imagePreview.hidden = true; sourceVideo.hidden = true; 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; resultGrid.innerHTML = ""; emptyState.hidden = false; summaryStrip.hidden = true; 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) { seekMedia(resultVideoPreview, time); } if (source === "result" && !videoPreview.hidden) { seekMedia(videoPreview, 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 = '
正在生成当前帧多方法对比。
'; 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", () => { sensitivityValue.textContent = Number(sensitivity.value).toFixed(2); }); fileInput.addEventListener("change", () => { const file = fileInput.files[0]; if (file) setFile(file); }); ["dragenter", "dragover"].forEach((eventName) => { dropZone.addEventListener(eventName, (event) => { event.preventDefault(); dropZone.classList.add("is-dragging"); }); }); ["dragleave", "drop"].forEach((eventName) => { dropZone.addEventListener(eventName, (event) => { event.preventDefault(); dropZone.classList.remove("is-dragging"); }); }); dropZone.addEventListener("drop", (event) => { const file = event.dataTransfer.files[0]; if (file) setFile(file); }); 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; sourceImage.hidden = true; if (selectedFile.type.startsWith("video/")) { sourceVideo.preload = "metadata"; sourceVideo.onloadedmetadata = () => { if (Number.isFinite(sourceVideo.duration) && sourceVideo.duration > 0.2) { sourceVideo.currentTime = 0.1; } }; sourceVideo.src = currentObjectUrl; sourceVideo.load(); sourceVideo.hidden = false; } else { sourceImage.src = currentObjectUrl; sourceImage.hidden = false; } sourceDialog.showModal(); }); form.addEventListener("submit", async (event) => { event.preventDefault(); const file = fileInput.files[0] || selectedFile; if (!file) { emptyState.hidden = false; emptyState.textContent = "请先选择文件或加载样例。"; return; } 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 { const payload = new FormData(form); payload.set("file", file); const response = await fetch("/api/segment", { method: "POST", body: payload, }); const data = await response.json(); if (!response.ok) { throw new Error(data.detail || "分割失败"); } renderResults(data); finishProgress(); } catch (error) { emptyState.hidden = false; emptyState.textContent = error.message; failProgress(error.message || "分割失败"); } finally { setBusy(false); } }); loadHealth(); loadMethods();