From 77b8ecdfbe4165d19e9c544f6fffe58010062df2 Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Mon, 18 May 2026 19:11:58 +0800 Subject: [PATCH] =?UTF-8?q?2026-05-18-19-06-50=20=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E7=BD=91=E9=A1=B5=E7=AB=AF=E5=88=86=E5=89=B2=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8F=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + backend/main.py | 18 + frontend/app.js | 193 +++++++- frontend/index.html | 175 +++++--- frontend/styles.css | 535 ++++++++++++++++++----- tests/test_api.py | 3 + 工程分析/实现方案-2026-05-18-19-06-50.md | 33 ++ 工程分析/测试方案-2026-05-18-19-06-50.md | 34 ++ 工程分析/经验记录.md | 12 + 工程分析/需求分析-2026-05-18-19-06-50.md | 22 + 10 files changed, 851 insertions(+), 175 deletions(-) create mode 100644 工程分析/实现方案-2026-05-18-19-06-50.md create mode 100644 工程分析/测试方案-2026-05-18-19-06-50.md create mode 100644 工程分析/需求分析-2026-05-18-19-06-50.md diff --git a/README.md b/README.md index 98fe224..d985673 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ ## 功能 - Web 上传图片或视频。 +- Web 端支持拖拽上传、样例视频一键加载、算法卡片选择、参数调节、输入预览和结果详情弹窗。 - 支持 `hessian_ridge`、`edge_morphology`、`temporal_difference`、`fusion`、`compare` 五种模式。 - 显示原图、导丝叠加结果、掩膜、覆盖率、骨架长度和连通域数量。 - 提供合成导丝视频生成脚本,便于快速验证。 diff --git a/backend/main.py b/backend/main.py index 7230905..38f8db8 100644 --- a/backend/main.py +++ b/backend/main.py @@ -52,6 +52,24 @@ def methods() -> dict[str, Any]: return {"methods": METHOD_DESCRIPTIONS} +@app.get("/api/samples") +def samples() -> dict[str, Any]: + ensure_dirs() + items = [] + for path in sorted(SAMPLE_DIR.glob("*")): + suffix = path.suffix.lower() + if suffix in IMAGE_SUFFIXES | VIDEO_SUFFIXES: + items.append( + { + "name": path.name, + "url": _public(path), + "kind": "image" if suffix in IMAGE_SUFFIXES else "video", + "size": path.stat().st_size, + } + ) + return {"samples": items} + + def _public(path: Path) -> str: return "/" + path.relative_to(ROOT).as_posix() diff --git a/frontend/app.js b/frontend/app.js index 5ca09ee..2c1dc5a 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1,21 +1,79 @@ 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 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 methodLabels = new Map(); +const methodDescriptions = new Map(); +let selectedFile = null; +let currentObjectUrl = null; +let lastFrames = []; -function setBusy(isBusy) { - const button = form.querySelector("button"); +function setBusy(isBusy, text = "运行分割") { + const button = form.querySelector(".primary"); button.disabled = isBusy; - button.querySelector("span").textContent = isBusy ? "分割中" : "开始分割"; + button.querySelector("span").textContent = isBusy ? "分割中" : text; + progressWrap.hidden = !isBusy; + if (isBusy) { + progressText.textContent = "正在上传、抽帧并执行导丝分割"; + } +} + +function setFile(file) { + selectedFile = file; + 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); +} + +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; + if (file.type.startsWith("video/")) { + videoPreview.src = currentObjectUrl; + videoPreview.hidden = false; + } else { + imagePreview.src = currentObjectUrl; + imagePreview.hidden = false; + } + jobMeta.textContent = `已选择 ${file.name}`; } async function loadHealth() { @@ -31,53 +89,171 @@ async function loadHealth() { } } +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]) => { methodLabels.set(key, value.label); + methodDescriptions.set(key, value.description); + const option = document.createElement("option"); option.value = key; option.textContent = value.label; - if (key === "fusion") option.selected = true; 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} 个结果`; + jobMeta.textContent = `${data.kind === "video" ? "视频" : "图像"} · ${methodLabels.get(data.method) || data.method}`; +} + +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) { + lastFrames = data.frames || []; resultGrid.innerHTML = ""; - emptyState.hidden = true; + emptyState.hidden = lastFrames.length > 0; videoLink.hidden = !data.video_url; if (data.video_url) { videoLink.href = data.video_url; videoLink.setAttribute("download", ""); } + updateSummary(data); - data.frames.forEach((frame) => { + lastFrames.forEach((frame) => { 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(".mask").src = frame.mask_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.addEventListener("click", () => openDetail(frame)); + node.addEventListener("keydown", (event) => { + if (event.key === "Enter") openDetail(frame); + }); resultGrid.appendChild(node); }); } +async function loadSample() { + sampleButton.disabled = true; + sampleButton.textContent = "加载中"; + try { + const response = await fetch("/api/samples"); + const data = await response.json(); + const sample = data.samples.find((item) => item.kind === "video") || data.samples[0]; + if (!sample) throw new Error("未找到样例文件"); + const blobResponse = await fetch(sample.url); + const blob = await blobResponse.blob(); + const file = new File([blob], sample.name, { type: sample.kind === "video" ? "video/mp4" : "image/png" }); + setFile(file); + } 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; + previewEmpty.hidden = false; + resultGrid.innerHTML = ""; + emptyState.hidden = false; + summaryStrip.hidden = true; + videoLink.hidden = true; + resultCount.textContent = "0 个结果"; + jobMeta.textContent = "等待输入"; +} + sensitivity.addEventListener("input", () => { sensitivityValue.textContent = Number(sensitivity.value).toFixed(2); }); fileInput.addEventListener("change", () => { const file = fileInput.files[0]; - fileName.textContent = file ? file.name : "支持 mp4、avi、png、jpg、tiff"; + 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()); + form.addEventListener("submit", async (event) => { event.preventDefault(); + const file = fileInput.files[0] || selectedFile; + if (!file) { + emptyState.hidden = false; + emptyState.textContent = "请先选择文件或加载样例。"; + return; + } + setBusy(true); emptyState.hidden = false; emptyState.textContent = "正在抽帧和分割,请稍候。"; @@ -86,6 +262,7 @@ form.addEventListener("submit", async (event) => { try { const payload = new FormData(form); + payload.set("file", file); const response = await fetch("/api/segment", { method: "POST", body: payload, diff --git a/frontend/index.html b/frontend/index.html index 3a593f8..03042ea 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -7,85 +7,156 @@ -
-
-
-

ISISeg

-

介入导丝视频分割工作台

+
+
+
+ IS +
+

ISISeg Console

+

介入导丝视频分割工作台

+
-
服务检查中
-
+
+ 服务检查中 + +
+ -
+
- - -
- - -
- -
-
- - 0.56 +
+
+ 01 +

输入

- -
+ +
+ + +
+
-
-
- - +
+
+ 02 +

方法

+
+ +
+
+ +
+
+ 03 +

参数

- - +
+ + 0.56 +
+
-
+
+
+ + +
+
+ + +
+
+
-
-
+
+
-

Result

-

分割结果

+

Live Workspace

+

预览与结果

- +
等待输入
-
上传文件后,这里会显示原帧、叠加图和导丝掩膜。
-
+ +
+
选择文件或加载样例后开始
+ + +
+ + + + + +
+
+

Result Frames

+

分割帧

+
+ 0 个结果 +
+
运行分割后,这里会显示原帧、叠加图、掩膜和指标。
+
+ +
+
+

Method

+

帧详情

+
+ +
+
+
+ 原始帧 +
原始帧
+
+
+ 叠加结果 +
叠加结果
+
+
+ 导丝掩膜 +
导丝掩膜
+
+
+
+
+