2026-05-18-17-40-02 构建导丝分割Web系统

This commit is contained in:
2026-05-18 17:49:30 +08:00
commit debd0bfd50
22 changed files with 1349 additions and 0 deletions

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
__pycache__/
*.py[cod]
.pytest_cache/
.mypy_cache/
.ruff_cache/
.venv/
venv/
storage/jobs/
storage/uploads/
*.log

Binary file not shown.

38
README.md Normal file
View File

@@ -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/`:自动化测试。
- `工程分析/`:需求、实现、测试与经验记录。

1
backend/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""ISISeg backend package."""

210
backend/main.py Normal file
View File

@@ -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")

232
backend/segmentation.py Normal file
View File

@@ -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),
]

107
frontend/app.js Normal file
View File

@@ -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();

96
frontend/index.html Normal file
View File

@@ -0,0 +1,96 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ISISeg 导丝分割工作台</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<main class="shell">
<section class="mast">
<div>
<p class="eyebrow">ISISeg</p>
<h1>介入导丝视频分割工作台</h1>
</div>
<div class="status" id="health">服务检查中</div>
</section>
<section class="workspace">
<form class="control-panel" id="segmentForm">
<label class="drop-zone" for="file">
<input id="file" name="file" type="file" accept="image/*,video/*" required />
<span class="drop-title">选择介入视频或图像</span>
<span class="drop-subtitle" id="fileName">支持 mp4、avi、png、jpg、tiff</span>
</label>
<div class="field">
<label for="method">分割方式</label>
<select id="method" name="method"></select>
</div>
<div class="field">
<div class="field-head">
<label for="sensitivity">灵敏度</label>
<output id="sensitivityValue">0.56</output>
</div>
<input id="sensitivity" name="sensitivity" type="range" min="0.05" max="0.95" value="0.56" step="0.01" />
</div>
<div class="compact-grid">
<div class="field">
<label for="frameStride">帧步长</label>
<input id="frameStride" name="frame_stride" type="number" min="1" max="90" value="5" />
</div>
<div class="field">
<label for="maxFrames">最大帧数</label>
<input id="maxFrames" name="max_frames" type="number" min="1" max="80" value="12" />
</div>
</div>
<button class="primary" type="submit">
<span>开始分割</span>
</button>
</form>
<section class="results">
<div class="results-head">
<div>
<p class="eyebrow">Result</p>
<h2>分割结果</h2>
</div>
<a class="download" id="videoLink" hidden>下载叠加视频</a>
</div>
<div class="empty" id="emptyState">上传文件后,这里会显示原帧、叠加图和导丝掩膜。</div>
<div class="grid" id="resultGrid"></div>
</section>
</section>
</main>
<template id="resultCardTemplate">
<article class="result-card">
<div class="card-top">
<span class="method"></span>
<span class="frame-index"></span>
</div>
<div class="image-pair">
<figure>
<img class="overlay" alt="导丝叠加结果" />
<figcaption>叠加</figcaption>
</figure>
<figure>
<img class="mask" alt="导丝掩膜" />
<figcaption>掩膜</figcaption>
</figure>
</div>
<dl class="metrics">
<div><dt>覆盖率</dt><dd class="coverage"></dd></div>
<div><dt>骨架长度</dt><dd class="skeleton"></dd></div>
<div><dt>连通域</dt><dd class="components"></dd></div>
</dl>
</article>
</template>
<script src="/app.js"></script>
</body>
</html>

328
frontend/styles.css Normal file
View File

@@ -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;
}
}

3
pytest.ini Normal file
View File

@@ -0,0 +1,3 @@
[pytest]
pythonpath = .
testpaths = tests

9
requirements.txt Normal file
View File

@@ -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

View File

@@ -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()

4
scripts/generate_sample.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
python3 scripts/generate_sample.py

4
scripts/run_dev.sh Executable file
View File

@@ -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}"

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

35
tests/test_api.py Normal file
View File

@@ -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

View File

@@ -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)

View File

@@ -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 或自有标注数据训练流程。
- 增加人工修正、导丝中心线导出、端点定位和时序跟踪。

View File

@@ -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 帧分割结果与叠加视频链接。

View File

@@ -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. 后续如何避免问题:视频算法要单独检查首帧、丢帧和单图退化路径;融合策略必须记录每个子方法的置信约束。

View File

@@ -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