commit debd0bfd50fad75885b97ad63c87a5c08aae81df Author: admin <572701190@qq.com> Date: Mon May 18 17:49:30 2026 +0800 2026-05-18-17-40-02 构建导丝分割Web系统 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a343022 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.venv/ +venv/ +storage/jobs/ +storage/uploads/ +*.log diff --git a/2026_5_18_介入手术导丝分割方案.docx b/2026_5_18_介入手术导丝分割方案.docx new file mode 100644 index 0000000..b824402 Binary files /dev/null and b/2026_5_18_介入手术导丝分割方案.docx differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..2e1db88 --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# ISISeg + +介入导丝视频分割 Web 系统第一版。系统支持上传 X 射线透视图片或视频,并使用多种无需训练数据的细线分割方法生成导丝掩膜、叠加图和结果视频。 + +## 功能 + +- Web 上传图片或视频。 +- 支持 `hessian_ridge`、`edge_morphology`、`temporal_difference`、`fusion`、`compare` 五种模式。 +- 显示原图、导丝叠加结果、掩膜、覆盖率、骨架长度和连通域数量。 +- 提供合成导丝视频生成脚本,便于快速验证。 + +## 启动 + +```bash +python3 -m pip install -r requirements.txt +bash scripts/generate_sample.sh +bash scripts/run_dev.sh +``` + +访问: + +```text +http://127.0.0.1:8000 +``` + +## 测试 + +```bash +pytest -q +``` + +## 目录 + +- `backend/`:FastAPI 服务与分割算法。 +- `frontend/`:Web 操作台。 +- `scripts/`:启动与样例生成脚本。 +- `tests/`:自动化测试。 +- `工程分析/`:需求、实现、测试与经验记录。 diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..3fa4f7d --- /dev/null +++ b/backend/__init__.py @@ -0,0 +1 @@ +"""ISISeg backend package.""" diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..7230905 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,210 @@ +from __future__ import annotations + +import shutil +import uuid +from pathlib import Path +from typing import Any + +import cv2 +from fastapi import FastAPI, File, Form, HTTPException, UploadFile +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles + +from backend.segmentation import METHOD_DESCRIPTIONS, compare_frame, segment_frame + + +ROOT = Path(__file__).resolve().parents[1] +FRONTEND_DIR = ROOT / "frontend" +STORAGE_DIR = ROOT / "storage" +UPLOAD_DIR = STORAGE_DIR / "uploads" +JOB_DIR = STORAGE_DIR / "jobs" +SAMPLE_DIR = STORAGE_DIR / "samples" + +IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff"} +VIDEO_SUFFIXES = {".mp4", ".avi", ".mov", ".mkv", ".webm"} + +app = FastAPI(title="ISISeg Guidewire Segmentation", version="0.1.0") +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + + +def ensure_dirs() -> None: + for directory in (UPLOAD_DIR, JOB_DIR, SAMPLE_DIR): + directory.mkdir(parents=True, exist_ok=True) + + +ensure_dirs() +app.mount("/storage", StaticFiles(directory=STORAGE_DIR), name="storage") + + +@app.get("/api/health") +def health() -> dict[str, str]: + return {"status": "ok", "service": "ISISeg", "version": app.version} + + +@app.get("/api/methods") +def methods() -> dict[str, Any]: + return {"methods": METHOD_DESCRIPTIONS} + + +def _public(path: Path) -> str: + return "/" + path.relative_to(ROOT).as_posix() + + +def _save_upload(file: UploadFile, job_path: Path) -> Path: + suffix = Path(file.filename or "").suffix.lower() + if suffix not in IMAGE_SUFFIXES | VIDEO_SUFFIXES: + raise HTTPException(status_code=400, detail="仅支持图片或视频文件") + destination = job_path / f"upload{suffix}" + with destination.open("wb") as buffer: + shutil.copyfileobj(file.file, buffer) + return destination + + +def _save_frame_outputs( + job_path: Path, + frame_index: int, + method: str, + frame, + output, +) -> dict[str, Any]: + method_path = job_path / method + method_path.mkdir(exist_ok=True) + original_path = method_path / f"frame_{frame_index:04d}_original.png" + mask_path = method_path / f"frame_{frame_index:04d}_mask.png" + overlay_path = method_path / f"frame_{frame_index:04d}_overlay.png" + cv2.imwrite(str(original_path), frame) + cv2.imwrite(str(mask_path), output.mask) + cv2.imwrite(str(overlay_path), output.overlay) + return { + "frame_index": frame_index, + "method": method, + "original_url": _public(original_path), + "mask_url": _public(mask_path), + "overlay_url": _public(overlay_path), + "metrics": output.metrics, + } + + +def _process_image(job_path: Path, source: Path, method: str, sensitivity: float) -> dict[str, Any]: + frame = cv2.imread(str(source), cv2.IMREAD_COLOR) + if frame is None: + raise HTTPException(status_code=400, detail="无法读取图片") + if method == "compare": + results = [ + _save_frame_outputs(job_path, 0, item.method, frame, item) + 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} + + +def _selected_frame(index: int, stride: int, selected_count: int, max_frames: int) -> bool: + return index % max(1, stride) == 0 and selected_count < max_frames + + +def _process_video( + job_path: Path, + source: Path, + method: str, + sensitivity: float, + frame_stride: int, + max_frames: int, +) -> dict[str, Any]: + capture = cv2.VideoCapture(str(source)) + if not capture.isOpened(): + raise HTTPException(status_code=400, detail="无法读取视频") + + frames: list[dict[str, Any]] = [] + previous = None + frame_index = 0 + selected_count = 0 + writer = None + video_path = job_path / f"{method}_overlay.mp4" + + try: + while True: + ok, frame = capture.read() + if not ok: + break + if not _selected_frame(frame_index, frame_stride, selected_count, max_frames): + previous = frame + frame_index += 1 + continue + + 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)) + 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)) + + 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.write(video_output.overlay) + + selected_count += 1 + previous = frame + frame_index += 1 + finally: + capture.release() + if writer is not None: + writer.release() + + 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} + + +@app.post("/api/segment") +def segment( + file: UploadFile = File(...), + method: str = Form("fusion"), + sensitivity: float = Form(0.56), + frame_stride: int = Form(5), + max_frames: int = Form(12), +) -> dict[str, Any]: + ensure_dirs() + if method not in METHOD_DESCRIPTIONS: + raise HTTPException(status_code=400, detail="未知分割方法") + sensitivity = max(0.05, min(float(sensitivity), 0.95)) + frame_stride = max(1, min(int(frame_stride), 90)) + max_frames = max(1, min(int(max_frames), 80)) + 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: + result = _process_image(job_path, source, method, sensitivity) + elif suffix in VIDEO_SUFFIXES: + result = _process_video(job_path, source, method, sensitivity, frame_stride, max_frames) + else: + raise HTTPException(status_code=400, detail="不支持的文件类型") + return { + "job_id": job_id, + "method": method, + "sensitivity": sensitivity, + "frame_stride": frame_stride, + "max_frames": max_frames, + **result, + } + + +@app.get("/") +def index() -> FileResponse: + return FileResponse(FRONTEND_DIR / "index.html") + + +app.mount("/", StaticFiles(directory=FRONTEND_DIR), name="frontend") diff --git a/backend/segmentation.py b/backend/segmentation.py new file mode 100644 index 0000000..60f196f --- /dev/null +++ b/backend/segmentation.py @@ -0,0 +1,232 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable + +import cv2 +import numpy as np +from skimage.filters import frangi, threshold_otsu +from skimage.morphology import remove_small_objects, skeletonize + + +METHOD_DESCRIPTIONS = { + "hessian_ridge": { + "label": "Hessian / Frangi 细线增强", + "description": "多尺度 Hessian 管状结构响应,适合低对比细导丝候选提取。", + "uses_temporal": False, + }, + "edge_morphology": { + "label": "边缘 + 形态学", + "description": "CLAHE、黑帽增强、Canny 边缘与线性形态学连接。", + "uses_temporal": False, + }, + "temporal_difference": { + "label": "视频时序差分", + "description": "利用相邻帧运动候选抑制静态骨骼和背景结构。", + "uses_temporal": True, + }, + "fusion": { + "label": "融合模式", + "description": "融合 Hessian、边缘形态学和时序差分,作为默认稳健输出。", + "uses_temporal": True, + }, + "compare": { + "label": "多方法对比", + "description": "对同一帧同时运行多种方法,便于调参和方案比较。", + "uses_temporal": True, + }, +} + + +@dataclass(frozen=True) +class SegmentationOutput: + method: str + mask: np.ndarray + overlay: np.ndarray + metrics: dict[str, float | int] + + +def normalize01(image: np.ndarray) -> np.ndarray: + image = image.astype(np.float32) + low = float(np.percentile(image, 1)) + high = float(np.percentile(image, 99)) + if high <= low: + return np.zeros_like(image, dtype=np.float32) + return np.clip((image - low) / (high - low), 0.0, 1.0) + + +def to_gray(frame: np.ndarray) -> np.ndarray: + if frame.ndim == 2: + return frame + return cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + + +def clahe_gray(frame: np.ndarray) -> np.ndarray: + gray = to_gray(frame) + clahe = cv2.createCLAHE(clipLimit=2.2, tileGridSize=(8, 8)) + return clahe.apply(gray) + + +def _adaptive_cutoff(response: np.ndarray, sensitivity: float) -> float: + response = normalize01(response) + nonzero = response[response > 0] + if nonzero.size < 16: + return 1.0 + sensitivity = float(np.clip(sensitivity, 0.05, 0.95)) + percentile = 99.2 - sensitivity * 22.0 + percentile_cut = float(np.percentile(nonzero, percentile)) + try: + otsu_cut = float(threshold_otsu(nonzero)) + except ValueError: + otsu_cut = percentile_cut + return max(min(percentile_cut, 0.98), otsu_cut * 0.72) + + +def clean_mask(mask: np.ndarray, min_area: int = 12) -> np.ndarray: + binary = mask.astype(bool) + binary = remove_small_objects(binary, max_size=max(1, int(min_area))) + cleaned = binary.astype(np.uint8) * 255 + kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) + cleaned = cv2.morphologyEx(cleaned, cv2.MORPH_CLOSE, kernel, iterations=1) + return cleaned + + +def hessian_ridge_mask(frame: np.ndarray, sensitivity: float = 0.56) -> np.ndarray: + enhanced = clahe_gray(frame) + inverted = 255 - enhanced + normalized = normalize01(inverted) + response = frangi( + normalized, + sigmas=(0.7, 1.1, 1.7, 2.3), + alpha=0.55, + beta=0.55, + gamma=12, + black_ridges=False, + ) + response = normalize01(response) + cutoff = _adaptive_cutoff(response, sensitivity) + mask = response >= cutoff + return clean_mask(mask, min_area=10) + + +def edge_morphology_mask(frame: np.ndarray, sensitivity: float = 0.56) -> np.ndarray: + enhanced = clahe_gray(frame) + blur = cv2.GaussianBlur(enhanced, (3, 3), 0) + kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 15)) + blackhat = cv2.morphologyEx(blur, cv2.MORPH_BLACKHAT, kernel) + dark_line = normalize01(blackhat) + cutoff = _adaptive_cutoff(dark_line, min(0.95, sensitivity + 0.1)) + candidate = (dark_line >= cutoff).astype(np.uint8) * 255 + low = int(20 + (1.0 - sensitivity) * 65) + high = int(70 + (1.0 - sensitivity) * 120) + edges = cv2.Canny(blur, low, high) + candidate = cv2.dilate(candidate, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)), iterations=1) + edges = cv2.bitwise_and(edges, candidate) + line_h = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 1)) + line_v = cv2.getStructuringElement(cv2.MORPH_RECT, (1, 5)) + connected = cv2.morphologyEx(edges, cv2.MORPH_CLOSE, line_h, iterations=1) + connected = cv2.morphologyEx(connected, cv2.MORPH_CLOSE, line_v, iterations=1) + connected = cv2.bitwise_or(connected, cv2.erode(candidate, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2, 2)))) + return clean_mask(connected, min_area=8) + + +def temporal_difference_mask( + frame: np.ndarray, + previous_frame: np.ndarray | None, + sensitivity: float = 0.56, +) -> np.ndarray: + ridge = hessian_ridge_mask(frame, sensitivity=sensitivity) + if previous_frame is None: + return ridge + current = cv2.GaussianBlur(clahe_gray(frame), (5, 5), 0) + previous = cv2.GaussianBlur(clahe_gray(previous_frame), (5, 5), 0) + diff = cv2.absdiff(current, previous) + diff = normalize01(diff) + cutoff = _adaptive_cutoff(diff, min(0.92, sensitivity + 0.12)) + moving = (diff >= cutoff).astype(np.uint8) * 255 + moving = cv2.dilate(moving, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)), iterations=1) + blended = cv2.bitwise_or(cv2.bitwise_and(ridge, moving), cv2.bitwise_and(ridge, cv2.dilate(moving, None))) + if int(np.count_nonzero(blended)) < 8: + blended = cv2.bitwise_or(ridge, moving) + return clean_mask(blended, min_area=8) + + +def fusion_mask( + frame: np.ndarray, + previous_frame: np.ndarray | None = None, + sensitivity: float = 0.56, +) -> np.ndarray: + ridge = hessian_ridge_mask(frame, sensitivity=sensitivity) + edge = edge_morphology_mask(frame, sensitivity=sensitivity) + temporal = temporal_difference_mask(frame, previous_frame, sensitivity=sensitivity) + votes = ( + (ridge > 0).astype(np.uint8) + + (edge > 0).astype(np.uint8) + + (temporal > 0).astype(np.uint8) + ) + fused = votes >= 2 + if int(np.count_nonzero(fused)) < 8: + fused = votes >= 1 + return clean_mask(fused, min_area=10) + + +def overlay_mask(frame: np.ndarray, mask: np.ndarray, color: tuple[int, int, int] = (0, 220, 255)) -> np.ndarray: + if frame.ndim == 2: + base = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR) + else: + base = frame.copy() + color_layer = np.zeros_like(base) + color_layer[mask > 0] = color + return cv2.addWeighted(base, 0.78, color_layer, 0.72, 0) + + +def mask_metrics(mask: np.ndarray) -> dict[str, float | int]: + binary = mask > 0 + coverage = float(np.count_nonzero(binary) / binary.size) if binary.size else 0.0 + skeleton = skeletonize(binary) + component_count, _ = cv2.connectedComponents(binary.astype(np.uint8)) + return { + "coverage": round(coverage, 6), + "mask_pixels": int(np.count_nonzero(binary)), + "skeleton_length": int(np.count_nonzero(skeleton)), + "components": max(0, int(component_count) - 1), + } + + +def segment_frame( + frame: np.ndarray, + method: str = "fusion", + previous_frame: np.ndarray | None = None, + sensitivity: float = 0.56, +) -> SegmentationOutput: + method_map: dict[str, Callable[..., np.ndarray]] = { + "hessian_ridge": hessian_ridge_mask, + "edge_morphology": edge_morphology_mask, + "temporal_difference": temporal_difference_mask, + "fusion": fusion_mask, + } + if method not in method_map: + raise ValueError(f"Unknown segmentation method: {method}") + if method in {"temporal_difference", "fusion"}: + mask = method_map[method](frame, previous_frame, sensitivity) + else: + mask = method_map[method](frame, sensitivity) + return SegmentationOutput( + method=method, + mask=mask, + overlay=overlay_mask(frame, mask), + metrics=mask_metrics(mask), + ) + + +def compare_frame( + frame: np.ndarray, + previous_frame: np.ndarray | None = None, + sensitivity: float = 0.56, +) -> list[SegmentationOutput]: + return [ + segment_frame(frame, "hessian_ridge", previous_frame, sensitivity), + segment_frame(frame, "edge_morphology", previous_frame, sensitivity), + segment_frame(frame, "temporal_difference", previous_frame, sensitivity), + segment_frame(frame, "fusion", previous_frame, sensitivity), + ] diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..5ca09ee --- /dev/null +++ b/frontend/app.js @@ -0,0 +1,107 @@ +const form = document.querySelector("#segmentForm"); +const methodSelect = document.querySelector("#method"); +const sensitivity = document.querySelector("#sensitivity"); +const sensitivityValue = document.querySelector("#sensitivityValue"); +const fileInput = document.querySelector("#file"); +const fileName = document.querySelector("#fileName"); +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 methodLabels = new Map(); + +function setBusy(isBusy) { + const button = form.querySelector("button"); + button.disabled = isBusy; + button.querySelector("span").textContent = isBusy ? "分割中" : "开始分割"; +} + +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"); + } +} + +async function loadMethods() { + const response = await fetch("/api/methods"); + const data = await response.json(); + methodSelect.innerHTML = ""; + Object.entries(data.methods).forEach(([key, value]) => { + methodLabels.set(key, value.label); + const option = document.createElement("option"); + option.value = key; + option.textContent = value.label; + if (key === "fusion") option.selected = true; + methodSelect.appendChild(option); + }); +} + +function renderResults(data) { + resultGrid.innerHTML = ""; + emptyState.hidden = true; + videoLink.hidden = !data.video_url; + if (data.video_url) { + videoLink.href = data.video_url; + videoLink.setAttribute("download", ""); + } + + data.frames.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; + resultGrid.appendChild(node); + }); +} + +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"; +}); + +form.addEventListener("submit", async (event) => { + event.preventDefault(); + setBusy(true); + emptyState.hidden = false; + emptyState.textContent = "正在抽帧和分割,请稍候。"; + resultGrid.innerHTML = ""; + videoLink.hidden = true; + + try { + const payload = new FormData(form); + 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); + } catch (error) { + emptyState.hidden = false; + emptyState.textContent = error.message; + } finally { + setBusy(false); + } +}); + +loadHealth(); +loadMethods(); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..3a593f8 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,96 @@ + + + + + + ISISeg 导丝分割工作台 + + + +
+
+
+

ISISeg

+

介入导丝视频分割工作台

+
+
服务检查中
+
+ +
+
+ + +
+ + +
+ +
+
+ + 0.56 +
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+

Result

+

分割结果

+
+ +
+
上传文件后,这里会显示原帧、叠加图和导丝掩膜。
+
+
+
+
+ + + + + + diff --git a/frontend/styles.css b/frontend/styles.css new file mode 100644 index 0000000..07c11e0 --- /dev/null +++ b/frontend/styles.css @@ -0,0 +1,328 @@ +:root { + --bg: #111412; + --panel: #181d1b; + --panel-2: #202622; + --line: #344038; + --text: #f0f5ef; + --muted: #9aa89c; + --accent: #ffd166; + --accent-2: #3dd6b5; + --danger: #ff6b6b; + --shadow: 0 24px 80px rgba(0, 0, 0, 0.34); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: + linear-gradient(135deg, rgba(61, 214, 181, 0.08), transparent 38%), + radial-gradient(circle at 85% 8%, rgba(255, 209, 102, 0.12), transparent 28%), + var(--bg); + color: var(--text); + font-family: "Aptos", "Segoe UI", sans-serif; +} + +button, +input, +select { + font: inherit; +} + +.shell { + width: min(1440px, calc(100vw - 32px)); + margin: 0 auto; + padding: 28px 0 42px; +} + +.mast { + display: flex; + align-items: end; + justify-content: space-between; + gap: 24px; + padding: 16px 0 24px; +} + +.eyebrow { + margin: 0 0 8px; + color: var(--accent-2); + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 12px; + font-weight: 800; +} + +h1, +h2 { + margin: 0; + letter-spacing: 0; +} + +h1 { + font-size: clamp(34px, 5vw, 68px); + line-height: 0.92; + max-width: 820px; +} + +h2 { + font-size: 28px; +} + +.status, +.download { + border: 1px solid var(--line); + background: rgba(24, 29, 27, 0.75); + color: var(--muted); + padding: 10px 14px; + border-radius: 8px; + text-decoration: none; + white-space: nowrap; +} + +.status.ok { + color: var(--accent-2); + border-color: rgba(61, 214, 181, 0.5); +} + +.status.bad { + color: var(--danger); + border-color: rgba(255, 107, 107, 0.55); +} + +.workspace { + display: grid; + grid-template-columns: 360px 1fr; + gap: 18px; + align-items: start; +} + +.control-panel, +.results { + background: rgba(24, 29, 27, 0.92); + border: 1px solid var(--line); + box-shadow: var(--shadow); + border-radius: 8px; +} + +.control-panel { + position: sticky; + top: 18px; + display: grid; + gap: 18px; + padding: 18px; +} + +.drop-zone { + display: grid; + place-items: center; + gap: 8px; + min-height: 170px; + border: 1px dashed rgba(61, 214, 181, 0.55); + border-radius: 8px; + background: linear-gradient(145deg, rgba(61, 214, 181, 0.08), rgba(255, 209, 102, 0.06)); + cursor: pointer; + text-align: center; + padding: 18px; +} + +.drop-zone input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.drop-title { + font-size: 20px; + font-weight: 800; +} + +.drop-subtitle { + color: var(--muted); + max-width: 280px; + overflow-wrap: anywhere; +} + +.field { + display: grid; + gap: 8px; +} + +.field-head, +.results-head, +.card-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +label { + color: var(--muted); + font-size: 13px; + font-weight: 700; +} + +select, +input[type="number"] { + width: 100%; + min-height: 42px; + color: var(--text); + background: var(--panel-2); + border: 1px solid var(--line); + border-radius: 8px; + padding: 0 12px; +} + +input[type="range"] { + accent-color: var(--accent); +} + +.compact-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.primary { + min-height: 48px; + border: 0; + border-radius: 8px; + background: linear-gradient(90deg, var(--accent), #ff9f5a); + color: #17140d; + font-weight: 900; + cursor: pointer; +} + +.primary:disabled { + cursor: wait; + filter: grayscale(0.8); + opacity: 0.65; +} + +.results { + min-height: 620px; + padding: 18px; +} + +.results-head { + margin-bottom: 18px; +} + +.empty { + min-height: 500px; + display: grid; + place-items: center; + color: var(--muted); + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(17, 20, 18, 0.55); + text-align: center; + padding: 24px; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 14px; +} + +.result-card { + border: 1px solid var(--line); + background: #121614; + border-radius: 8px; + overflow: hidden; +} + +.card-top { + padding: 12px; + border-bottom: 1px solid var(--line); +} + +.method { + font-weight: 900; + color: var(--accent); +} + +.frame-index { + color: var(--muted); + font-size: 13px; +} + +.image-pair { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1px; + background: var(--line); +} + +figure { + margin: 0; + background: #050605; +} + +img { + display: block; + width: 100%; + aspect-ratio: 4 / 3; + object-fit: contain; +} + +figcaption { + padding: 7px 10px; + color: var(--muted); + font-size: 12px; + border-top: 1px solid var(--line); +} + +.metrics { + display: grid; + grid-template-columns: repeat(3, 1fr); + margin: 0; + border-top: 1px solid var(--line); +} + +.metrics div { + padding: 10px; + border-right: 1px solid var(--line); +} + +.metrics div:last-child { + border-right: 0; +} + +dt { + color: var(--muted); + font-size: 11px; + margin-bottom: 4px; +} + +dd { + margin: 0; + font-weight: 850; +} + +@media (max-width: 900px) { + .shell { + width: min(100vw - 20px, 720px); + } + + .mast, + .workspace { + display: grid; + } + + .workspace { + grid-template-columns: 1fr; + } + + .control-panel { + position: static; + } + + .status { + justify-self: start; + } +} diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..c7b23ec --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +pythonpath = . +testpaths = tests diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..dee92ba --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +fastapi==0.116.1 +uvicorn[standard]==0.35.0 +python-multipart==0.0.20 +httpx==0.28.1 +pytest==8.4.1 +opencv-python==4.13.0.92 +numpy==2.4.4 +scikit-image==0.26.0 +pillow==12.2.0 diff --git a/scripts/generate_sample.py b/scripts/generate_sample.py new file mode 100644 index 0000000..e089c73 --- /dev/null +++ b/scripts/generate_sample.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from pathlib import Path + +import cv2 +import numpy as np + + +ROOT = Path(__file__).resolve().parents[1] +SAMPLE_DIR = ROOT / "storage" / "samples" + + +def draw_curve(frame: np.ndarray, t: int) -> None: + height, width = frame.shape[:2] + points = [] + for x in np.linspace(48, width - 54, 110): + y = height * 0.55 + 48 * np.sin((x / width) * 2.8 * np.pi + t * 0.11) + y += 22 * np.sin((x / width) * 7.2 * np.pi + t * 0.035) + points.append([int(x), int(y)]) + pts = np.array(points, dtype=np.int32).reshape((-1, 1, 2)) + cv2.polylines(frame, [pts], False, (36, 36, 36), 2, cv2.LINE_AA) + cv2.circle(frame, tuple(points[-1]), 5, (24, 24, 24), -1, cv2.LINE_AA) + + +def make_frame(index: int, width: int = 640, height: int = 420) -> np.ndarray: + rng = np.random.default_rng(index) + base = np.full((height, width), 156, dtype=np.uint8) + gradient = np.linspace(-22, 18, width, dtype=np.float32) + base = np.clip(base.astype(np.float32) + gradient[None, :], 0, 255).astype(np.uint8) + noise = rng.normal(0, 7, (height, width)).astype(np.float32) + base = np.clip(base.astype(np.float32) + noise, 0, 255).astype(np.uint8) + frame = cv2.cvtColor(base, cv2.COLOR_GRAY2BGR) + + for offset in range(-160, width, 130): + center = (offset + index * 2, height // 2) + cv2.ellipse(frame, center, (90, 180), 8, 0, 360, (176, 176, 176), 8, cv2.LINE_AA) + + cv2.line(frame, (80, 60), (560, 86 + index % 18), (90, 90, 90), 4, cv2.LINE_AA) + draw_curve(frame, index) + frame = cv2.GaussianBlur(frame, (3, 3), 0) + return frame + + +def main() -> None: + SAMPLE_DIR.mkdir(parents=True, exist_ok=True) + video_path = SAMPLE_DIR / "synthetic_guidewire.mp4" + image_path = SAMPLE_DIR / "synthetic_guidewire.png" + width, height = 640, 420 + writer = cv2.VideoWriter(str(video_path), cv2.VideoWriter_fourcc(*"mp4v"), 12.0, (width, height)) + for index in range(72): + frame = make_frame(index, width, height) + if index == 12: + cv2.imwrite(str(image_path), frame) + writer.write(frame) + writer.release() + print(video_path) + print(image_path) + + +if __name__ == "__main__": + main() diff --git a/scripts/generate_sample.sh b/scripts/generate_sample.sh new file mode 100755 index 0000000..174102b --- /dev/null +++ b/scripts/generate_sample.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail + +python3 scripts/generate_sample.py diff --git a/scripts/run_dev.sh b/scripts/run_dev.sh new file mode 100755 index 0000000..9cf1dbd --- /dev/null +++ b/scripts/run_dev.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail + +python3 -m uvicorn backend.main:app --host 0.0.0.0 --port "${PORT:-8000}" diff --git a/storage/samples/synthetic_guidewire.mp4 b/storage/samples/synthetic_guidewire.mp4 new file mode 100644 index 0000000..3612147 Binary files /dev/null and b/storage/samples/synthetic_guidewire.mp4 differ diff --git a/storage/samples/synthetic_guidewire.png b/storage/samples/synthetic_guidewire.png new file mode 100644 index 0000000..7a31b06 Binary files /dev/null and b/storage/samples/synthetic_guidewire.png differ diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..0757e2e --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,35 @@ +from pathlib import Path + +from fastapi.testclient import TestClient + +from backend.main import app +from scripts.generate_sample import make_frame + +import cv2 + + +client = TestClient(app) + + +def test_health_and_methods(): + health = client.get("/api/health") + assert health.status_code == 200 + assert health.json()["status"] == "ok" + methods = client.get("/api/methods") + assert methods.status_code == 200 + assert "fusion" in methods.json()["methods"] + + +def test_segment_image(tmp_path: Path): + image_path = tmp_path / "sample.png" + cv2.imwrite(str(image_path), make_frame(8, 320, 220)) + with image_path.open("rb") as handle: + response = client.post( + "/api/segment", + files={"file": ("sample.png", handle, "image/png")}, + data={"method": "fusion", "sensitivity": "0.68"}, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["kind"] == "image" + assert payload["frames"][0]["metrics"]["mask_pixels"] > 0 diff --git a/tests/test_segmentation.py b/tests/test_segmentation.py new file mode 100644 index 0000000..99fe67d --- /dev/null +++ b/tests/test_segmentation.py @@ -0,0 +1,39 @@ +import cv2 +import numpy as np + +from backend.segmentation import compare_frame, segment_frame + + +def synthetic_frame(shift: int = 0) -> np.ndarray: + frame = np.full((220, 320, 3), 158, dtype=np.uint8) + noise = np.random.default_rng(42 + shift).normal(0, 6, frame.shape[:2]).astype(np.int16) + gray = np.clip(frame[:, :, 0].astype(np.int16) + noise, 0, 255).astype(np.uint8) + frame = cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR) + points = np.array([[28, 142 + shift], [92, 108 + shift], [174, 124 + shift], [286, 80 + shift]], dtype=np.int32) + cv2.polylines(frame, [points.reshape((-1, 1, 2))], False, (32, 32, 32), 2, cv2.LINE_AA) + return frame + + +def assert_output(output): + assert output.mask.shape[:2] == (220, 320) + assert output.overlay.shape == (220, 320, 3) + assert output.metrics["mask_pixels"] > 0 + assert 0 <= output.metrics["coverage"] <= 1 + + +def test_segmentation_methods_return_non_empty_masks(): + previous = synthetic_frame(0) + current = synthetic_frame(2) + for method in ["hessian_ridge", "edge_morphology", "temporal_difference", "fusion"]: + assert_output(segment_frame(current, method=method, previous_frame=previous, sensitivity=0.7)) + + +def test_compare_frame_returns_all_methods(): + outputs = compare_frame(synthetic_frame(1), synthetic_frame(0), sensitivity=0.65) + assert [item.method for item in outputs] == [ + "hessian_ridge", + "edge_morphology", + "temporal_difference", + "fusion", + ] + assert all(item.metrics["mask_pixels"] > 0 for item in outputs) diff --git a/工程分析/实现方案-2026-05-18-17-40-02.md b/工程分析/实现方案-2026-05-18-17-40-02.md new file mode 100644 index 0000000..99e1070 --- /dev/null +++ b/工程分析/实现方案-2026-05-18-17-40-02.md @@ -0,0 +1,54 @@ +# 实现方案 + +开始时间:2026-05-18-17-40-02 + +## 总体架构 + +- `backend/`:Python FastAPI 服务,负责文件上传、帧抽取、导丝分割、结果落盘和静态文件服务。 +- `frontend/`:单页 Web 操作台,使用原生 HTML/CSS/JS,避免额外前端构建依赖。 +- `storage/`:运行时存放上传文件、处理帧、掩膜、叠加图和输出视频。 +- `scripts/`:生成演示视频、启动服务和测试辅助脚本。 +- `tests/`:算法与 API 的基础回归测试。 + +## 分割方法 + +1. `hessian_ridge` + - 对灰度图做 CLAHE 增强。 + - 使用 `skimage.filters.frangi` 进行多尺度细线/管状结构增强。 + - 自适应阈值与形态学清理后输出掩膜。 + +2. `edge_morphology` + - 对反相增强图执行黑帽/顶帽和 Canny 边缘提取。 + - 结合细长形态学核连接断裂线段。 + - 用连通域面积过滤去除噪声。 + +3. `temporal_difference` + - 视频场景下利用相邻帧差分提取移动细线候选。 + - 与 `hessian_ridge` 的结构响应相交/相并,降低静态骨骼假阳性。 + - 图片场景无前一帧时自动退化为 `hessian_ridge`。 + +4. `fusion` + - 将 Hessian、边缘形态学、时序差分结果按权重融合。 + - 提供更稳定的第一版默认结果。 + +5. `compare` + - 对同一批帧运行多种方法,前端以矩阵方式对比。 + +## Web 交互 + +- 上传图片区和算法参数面板置于首屏,面向工程调试而非营销页。 +- 结果区显示原帧、叠加图、掩膜覆盖率、细线骨架长度等指标。 +- 提供输出视频与单帧结果链接。 +- 页面风格采用冷静的医学影像工作台:深色背景、清晰边界、克制高亮。 + +## 部署方式 + +- 使用 Python 虚拟环境安装依赖。 +- `scripts/run_dev.sh` 启动 FastAPI,默认监听 `0.0.0.0:8000`。 +- FastAPI 直接服务前端静态文件,访问 `http://127.0.0.1:8000`。 + +## 后续扩展路径 + +- 增加 PyTorch/ONNX 模型适配器,接入训练好的 U-Net/FRA-Net/MSLNet 类模型。 +- 接入 CathAction 或自有标注数据训练流程。 +- 增加人工修正、导丝中心线导出、端点定位和时序跟踪。 diff --git a/工程分析/测试方案-2026-05-18-17-40-02.md b/工程分析/测试方案-2026-05-18-17-40-02.md new file mode 100644 index 0000000..e6d5c1f --- /dev/null +++ b/工程分析/测试方案-2026-05-18-17-40-02.md @@ -0,0 +1,41 @@ +# 测试方案 + +开始时间:2026-05-18-17-40-02 + +## 自动化测试 + +1. 算法单元测试 + - 使用合成含导丝图像验证每种方法能输出非空掩膜。 + - 验证 `compare` 返回多方法结果。 + - 验证指标字段存在且数值范围合理。 + +2. API 测试 + - `GET /api/health` 返回运行状态。 + - `GET /api/methods` 返回算法清单。 + - `POST /api/segment` 上传合成图片并获得结果 URL。 + +3. 启动验证 + - 启动服务后访问首页。 + - 调用健康检查接口。 + - 使用合成演示视频跑一次分割。 + +## 手工验证 + +- 在浏览器打开 `http://127.0.0.1:8000`。 +- 上传 `storage/samples/synthetic_guidewire.mp4`。 +- 分别选择 `fusion` 和 `compare`,检查叠加结果、掩膜和视频下载链接。 + +## 验收标准 + +- 项目可通过启动脚本运行。 +- Web 页面可打开并完成上传分割流程。 +- 至少三种分割方式可选择,融合和对比模式可用。 +- 测试命令通过,或明确记录未通过原因与处理方式。 + +## 执行结果 + +- `bash scripts/generate_sample.sh`:通过,已生成 `storage/samples/synthetic_guidewire.mp4` 和 `storage/samples/synthetic_guidewire.png`。 +- `pytest -q`:通过,4 个测试全部通过。 +- `curl http://127.0.0.1:8001/api/health`:通过,返回 `status=ok`。 +- `curl http://127.0.0.1:8001/`:通过,首页返回 HTTP 200。 +- 使用合成视频调用 `POST /api/segment`:通过,返回 3 帧分割结果与叠加视频链接。 diff --git a/工程分析/经验记录.md b/工程分析/经验记录.md new file mode 100644 index 0000000..63ea16d --- /dev/null +++ b/工程分析/经验记录.md @@ -0,0 +1,35 @@ +# 经验记录 + +本文件用于记录每次执行中出现的关键问题和解决方案。 + +## 2026-05-18-17-40-02 Web 导丝分割系统第一版 + +### 1. pytest 无法导入本地 backend 包 + +A. 具体问题:执行 `pytest -q` 时,`tests/test_api.py` 和 `tests/test_segmentation.py` 报 `ModuleNotFoundError: No module named 'backend'`。 + +B. 产生问题原因:项目是从空目录新建,尚未安装为 Python 包;pytest 启动时没有稳定地把项目根目录放入 `pythonpath`。 + +C. 解决问题方案:新增 `pytest.ini`,配置 `pythonpath = .` 和 `testpaths = tests`。 + +D. 后续如何避免问题:从零构建 Python 项目时同步添加 pytest 配置,或使用标准包管理配置声明测试路径。 + +### 2. scikit-image 0.26 参数变更警告 + +A. 具体问题:`remove_small_objects` 使用 `min_size` 时测试出现 FutureWarning。 + +B. 产生问题原因:当前环境的 scikit-image 版本将 `min_size` 标记为废弃参数,并引入 `max_size` 表达“移除小于等于该面积的对象”。 + +C. 解决问题方案:将 `remove_small_objects(binary, min_size=...)` 改为 `remove_small_objects(binary, max_size=...)`。 + +D. 后续如何避免问题:固定依赖版本后仍要关注测试警告;图像处理库升级时优先查看函数签名和文档。 + +### 3. 融合模式首帧过分割 + +A. 具体问题:视频第 0 帧没有前帧,融合模式覆盖率一度达到约 22%,明显偏宽。 + +B. 产生问题原因:时序差分在无前帧时退化为 Hessian,融合投票逻辑对无前帧使用了一票通过,导致边缘形态学噪声被并入。 + +C. 解决问题方案:融合模式统一使用两票通过,低响应时才回退到一票;同时将边缘形态学改成暗线候选约束下的边缘检测。 + +D. 后续如何避免问题:视频算法要单独检查首帧、丢帧和单图退化路径;融合策略必须记录每个子方法的置信约束。 diff --git a/工程分析/需求分析-2026-05-18-17-40-02.md b/工程分析/需求分析-2026-05-18-17-40-02.md new file mode 100644 index 0000000..32c9590 --- /dev/null +++ b/工程分析/需求分析-2026-05-18-17-40-02.md @@ -0,0 +1,42 @@ +# 需求分析 + +开始时间:2026-05-18-17-40-02 + +## 用户目标 + +构建一个用于介入导丝视频中导丝分割的 Web 系统,支持多种分割方式、上传视频或图像、展示原图/掩膜/叠加结果,并可持续迭代到系统可运行。 + +## 工作流要求 + +1. 每次需求开始前记录时间戳。 +2. 阅读或创建 `工程分析` 文件夹。 +3. 本次需求分析写入当前文档。 +4. 实现方案写入 `工程分析/实现方案-2026-05-18-17-40-02.md`。 +5. 测试方案写入 `工程分析/测试方案-2026-05-18-17-40-02.md`。 +6. 执行前阅读 `工程分析/经验记录.md`;执行后将关键问题按四段式写入。 +7. 完成后使用 Gitea 备份 commit,并重新部署项目。 + +## 第一版可运行范围 + +- 后端提供导丝分割 API,支持图片和视频文件上传。 +- 前端提供 Web 操作台,支持选择算法、参数调整、结果预览和下载。 +- 分割方法至少包含: + - Hessian/Frangi 类细线增强。 + - 边缘与形态学细线检测。 + - 视频时序差分增强。 + - 多方法融合与对比模式。 +- 提供合成示例视频生成脚本,便于无真实介入视频时验证系统。 +- 提供基础自动化测试和启动脚本。 + +## 技术检索摘要 + +- 近期导丝分割研究普遍强调 X 射线透视下低信噪比、导丝像素占比极低、骨骼/器械干扰和实时性要求。 +- 深度学习方向包括多帧输入 CNN、轻量注意力网络、U-Net/Transformer 变体、拓扑保持损失和 sim-to-real 自适应。 +- 当前工程第一版优先实现无需训练数据即可运行的传统图像处理与融合路线,并保留深度学习模型接入接口。 + +## 参考来源 + +- MSLNet and Perceptual Grouping for Guidewire Segmentation and Localization: https://www.mdpi.com/1424-8220/25/20/6426 +- CathAction segmentation task: https://airvlab.github.io/cathaction/docs/segmentation/ +- Fully Automatic and Real-Time Catheter Segmentation in X-Ray Fluoroscopy: https://arxiv.org/abs/1707.05137 +- Lightweight attention network for guidewire segmentation and localization: https://qims.amegroups.org/article/view/136604/html