From 88cbcc65c22da1ae9494d724bec27a07195b241f Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Mon, 18 May 2026 20:11:52 +0800 Subject: [PATCH] =?UTF-8?q?2026-05-18-19-56-47=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E5=8F=8C=E8=A7=86=E9=A2=91=E5=90=8C=E6=AD=A5=E4=B8=8E=E5=8D=95?= =?UTF-8?q?=E5=B8=A7=E5=AF=B9=E6=AF=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/main.py | 157 +++++++++++++- frontend/app.js | 261 ++++++++++++++++++++++- frontend/index.html | 39 +++- frontend/styles.css | 119 +++++++++-- tests/test_api.py | 48 +++++ 工程分析/实现方案-2026-05-18-19-56-47.md | 48 +++++ 工程分析/测试方案-2026-05-18-19-56-47.md | 42 ++++ 工程分析/经验记录.md | 22 ++ 工程分析/需求分析-2026-05-18-19-56-47.md | 23 ++ 9 files changed, 727 insertions(+), 32 deletions(-) create mode 100644 工程分析/实现方案-2026-05-18-19-56-47.md create mode 100644 工程分析/测试方案-2026-05-18-19-56-47.md create mode 100644 工程分析/需求分析-2026-05-18-19-56-47.md diff --git a/backend/main.py b/backend/main.py index ae9d61a..3a31eec 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,5 +1,6 @@ from __future__ import annotations +import subprocess import shutil import uuid from pathlib import Path @@ -101,6 +102,9 @@ def _save_frame_outputs( method: str, frame, output, + source_time: float | None = None, + result_time: float | None = None, + result_index: int | None = None, ) -> dict[str, Any]: method_path = job_path / method method_path.mkdir(exist_ok=True) @@ -110,7 +114,7 @@ def _save_frame_outputs( cv2.imwrite(str(original_path), frame) cv2.imwrite(str(mask_path), output.mask) cv2.imwrite(str(overlay_path), output.overlay) - return { + payload = { "frame_index": frame_index, "method": method, "original_url": _public(original_path), @@ -118,6 +122,44 @@ def _save_frame_outputs( "overlay_url": _public(overlay_path), "metrics": output.metrics, } + if source_time is not None: + payload["source_time"] = round(float(source_time), 4) + if result_time is not None: + payload["result_time"] = round(float(result_time), 4) + if result_index is not None: + payload["result_index"] = int(result_index) + return payload + + +def _browser_video(raw_path: Path, final_path: Path) -> Path: + ffmpeg = shutil.which("ffmpeg") + if ffmpeg: + subprocess.run( + [ + ffmpeg, + "-y", + "-i", + str(raw_path), + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + "-preset", + "veryfast", + "-crf", + "20", + str(final_path), + ], + check=True, + capture_output=True, + text=True, + ) + raw_path.unlink(missing_ok=True) + else: + raw_path.replace(final_path) + return final_path def _process_image(job_path: Path, source: Path, method: str, sensitivity: float) -> dict[str, Any]: @@ -126,13 +168,13 @@ def _process_image(job_path: Path, source: Path, method: str, sensitivity: float raise HTTPException(status_code=400, detail="无法读取图片") if method == "compare": results = [ - _save_frame_outputs(job_path, 0, item.method, frame, item) + _save_frame_outputs(job_path, 0, item.method, frame, item, 0.0, 0.0, 0) for item in compare_frame(frame, None, sensitivity) ] else: output = segment_frame(frame, method, None, sensitivity) - results = [_save_frame_outputs(job_path, 0, method, frame, output)] - return {"kind": "image", "frames": results, "video_url": None} + results = [_save_frame_outputs(job_path, 0, method, frame, output, 0.0, 0.0, 0)] + return {"kind": "image", "frames": results, "video_url": None, "source_fps": 1.0, "result_fps": 1.0, "duration": 0.0} def _selected_frame(index: int, stride: int, selected_count: int, max_frames: int) -> bool: @@ -156,7 +198,14 @@ def _process_video( frame_index = 0 selected_count = 0 writer = None + result_fps = 8.0 + raw_video_path = job_path / f"{method}_overlay.raw.mp4" video_path = job_path / f"{method}_overlay.mp4" + source_fps = float(capture.get(cv2.CAP_PROP_FPS) or 0.0) + if source_fps <= 0: + source_fps = 12.0 + frame_count = int(capture.get(cv2.CAP_PROP_FRAME_COUNT) or 0) + duration = round(frame_count / source_fps, 4) if frame_count else 0.0 try: while True: @@ -171,16 +220,38 @@ def _process_video( if method == "compare": outputs = compare_frame(frame, previous, sensitivity) for output in outputs: - frames.append(_save_frame_outputs(job_path, frame_index, output.method, frame, output)) + frames.append( + _save_frame_outputs( + job_path, + frame_index, + output.method, + frame, + output, + frame_index / source_fps, + selected_count / result_fps, + selected_count, + ) + ) video_output = next(item for item in outputs if item.method == "fusion") else: video_output = segment_frame(frame, method, previous, sensitivity) - frames.append(_save_frame_outputs(job_path, frame_index, method, frame, video_output)) + frames.append( + _save_frame_outputs( + job_path, + frame_index, + method, + frame, + video_output, + frame_index / source_fps, + selected_count / result_fps, + selected_count, + ) + ) if writer is None: height, width = video_output.overlay.shape[:2] fourcc = cv2.VideoWriter_fourcc(*"mp4v") - writer = cv2.VideoWriter(str(video_path), fourcc, 8.0, (width, height)) + writer = cv2.VideoWriter(str(raw_video_path), fourcc, result_fps, (width, height)) writer.write(video_output.overlay) selected_count += 1 @@ -193,7 +264,39 @@ def _process_video( if not frames: raise HTTPException(status_code=400, detail="视频没有可处理帧") - return {"kind": "video", "frames": frames, "video_url": _public(video_path) if video_path.exists() else None} + if raw_video_path.exists(): + _browser_video(raw_video_path, video_path) + return { + "kind": "video", + "frames": frames, + "video_url": _public(video_path) if video_path.exists() else None, + "source_fps": round(source_fps, 4), + "result_fps": result_fps, + "duration": duration, + "result_duration": round(len({frame["result_index"] for frame in frames}) / result_fps, 4) if frames else 0.0, + } + + +def _read_video_frame(source: Path, frame_index: int) -> tuple[Any, Any | None, float]: + capture = cv2.VideoCapture(str(source)) + if not capture.isOpened(): + raise HTTPException(status_code=400, detail="无法读取视频") + source_fps = float(capture.get(cv2.CAP_PROP_FPS) or 0.0) + if source_fps <= 0: + source_fps = 12.0 + frame_index = max(0, int(frame_index)) + previous = None + if frame_index > 0: + capture.set(cv2.CAP_PROP_POS_FRAMES, frame_index - 1) + ok_prev, previous = capture.read() + if not ok_prev: + previous = None + capture.set(cv2.CAP_PROP_POS_FRAMES, frame_index) + ok, frame = capture.read() + capture.release() + if not ok: + raise HTTPException(status_code=400, detail="无法读取指定帧") + return frame, previous, source_fps @app.post("/api/segment") @@ -231,6 +334,44 @@ def segment( } +@app.post("/api/compare-frame") +def compare_single_frame( + file: UploadFile = File(...), + frame_index: int = Form(0), + sensitivity: float = Form(0.56), +) -> dict[str, Any]: + ensure_dirs() + sensitivity = max(0.05, min(float(sensitivity), 0.95)) + job_id = uuid.uuid4().hex[:12] + job_path = JOB_DIR / job_id + job_path.mkdir(parents=True, exist_ok=True) + source = _save_upload(file, job_path) + suffix = source.suffix.lower() + if suffix in IMAGE_SUFFIXES: + frame = cv2.imread(str(source), cv2.IMREAD_COLOR) + previous = None + source_time = 0.0 + elif suffix in VIDEO_SUFFIXES: + frame, previous, source_fps = _read_video_frame(source, frame_index) + source_time = int(frame_index) / source_fps + else: + raise HTTPException(status_code=400, detail="不支持的文件类型") + if frame is None: + raise HTTPException(status_code=400, detail="无法读取文件") + outputs = compare_frame(frame, previous, sensitivity) + frames = [ + _save_frame_outputs(job_path, int(frame_index), output.method, frame, output, source_time, 0.0, 0) + for output in outputs + ] + return { + "job_id": job_id, + "kind": "compare", + "frame_index": int(frame_index), + "source_time": round(float(source_time), 4), + "frames": frames, + } + + @app.get("/") def index() -> FileResponse: return FileResponse(FRONTEND_DIR / "index.html") diff --git a/frontend/app.js b/frontend/app.js index 478cc7c..a5ea1aa 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -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 = '
Live Workspace
-准备任务
Frame Review
+