diff --git a/.gitignore b/.gitignore index a343022..1e99008 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ __pycache__/ venv/ storage/jobs/ storage/uploads/ +storage/demos/ *.log diff --git a/README.md b/README.md index 2e1db88..98fe224 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,32 @@ bash scripts/run_dev.sh http://127.0.0.1:8000 ``` +如 8000 端口已被占用,可使用: + +```bash +PORT=8001 bash scripts/run_dev.sh +``` + ## 测试 ```bash pytest -q ``` +## 录制演示视频 + +服务运行后执行: + +```bash +python3 scripts/record_demo.py +``` + +输出文件: + +```text +storage/demos/isiseg_usage_demo.mp4 +``` + ## 目录 - `backend/`:FastAPI 服务与分割算法。 diff --git a/scripts/record_demo.py b/scripts/record_demo.py new file mode 100644 index 0000000..45b6a4d --- /dev/null +++ b/scripts/record_demo.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +import json +import shutil +import subprocess +import textwrap +import time +import urllib.request +from pathlib import Path + +import cv2 +import numpy as np + + +ROOT = Path(__file__).resolve().parents[1] +DEMO_DIR = ROOT / "storage" / "demos" +SAMPLE_VIDEO = ROOT / "storage" / "samples" / "synthetic_guidewire.mp4" +HOME_SCREENSHOT = DEMO_DIR / "01_home.png" +RESPONSE_JSON = DEMO_DIR / "latest_demo_response.json" +OUTPUT_VIDEO = DEMO_DIR / "isiseg_usage_demo.mp4" +BASE_URL = "http://127.0.0.1:8001" + + +def run(command: list[str], timeout: int = 120) -> subprocess.CompletedProcess[str]: + return subprocess.run( + command, + cwd=ROOT, + check=True, + text=True, + capture_output=True, + timeout=timeout, + ) + + +def wait_for_service() -> None: + deadline = time.time() + 20 + while time.time() < deadline: + try: + with urllib.request.urlopen(f"{BASE_URL}/api/health", timeout=2) as response: + if response.status == 200: + return + except OSError: + time.sleep(0.5) + raise RuntimeError(f"ISISeg service is not reachable at {BASE_URL}") + + +def ensure_sample() -> None: + if not SAMPLE_VIDEO.exists(): + run(["bash", "scripts/generate_sample.sh"]) + + +def capture_homepage() -> None: + chrome = shutil.which("google-chrome") or shutil.which("chromium") or shutil.which("chromium-browser") + if not chrome: + raise RuntimeError("Chrome/Chromium is required to capture the UI screenshot") + run( + [ + chrome, + "--headless=new", + "--no-sandbox", + "--disable-gpu", + "--hide-scrollbars", + "--window-size=1440,1000", + f"--screenshot={HOME_SCREENSHOT}", + BASE_URL, + ], + timeout=60, + ) + + +def call_segmentation() -> dict: + command = [ + "curl", + "-s", + "-X", + "POST", + "-F", + f"file=@{SAMPLE_VIDEO}", + "-F", + "method=fusion", + "-F", + "sensitivity=0.68", + "-F", + "frame_stride=12", + "-F", + "max_frames=4", + f"{BASE_URL}/api/segment", + ] + completed = run(command, timeout=180) + payload = json.loads(completed.stdout) + RESPONSE_JSON.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + return payload + + +def fit_image(image: np.ndarray, box_w: int, box_h: int) -> np.ndarray: + h, w = image.shape[:2] + scale = min(box_w / w, box_h / h) + resized = cv2.resize(image, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA) + canvas = np.full((box_h, box_w, 3), 18, dtype=np.uint8) + y = (box_h - resized.shape[0]) // 2 + x = (box_w - resized.shape[1]) // 2 + canvas[y : y + resized.shape[0], x : x + resized.shape[1]] = resized + return canvas + + +def put_lines(frame: np.ndarray, lines: list[str], x: int, y: int, size: float = 0.82, color=(238, 245, 239)) -> None: + for line in lines: + cv2.putText(frame, line, (x, y), cv2.FONT_HERSHEY_SIMPLEX, size, color, 2, cv2.LINE_AA) + y += int(34 * size / 0.82) + + +def wrap(text: str, width: int = 44) -> list[str]: + return textwrap.wrap(text, width=width, break_long_words=False) + + +def slate(title: str, subtitle: str, width: int = 1280, height: int = 720) -> np.ndarray: + frame = np.zeros((height, width, 3), dtype=np.uint8) + frame[:, :] = (18, 20, 18) + cv2.rectangle(frame, (0, 0), (width, height), (48, 64, 56), 18) + cv2.line(frame, (70, 90), (1210, 90), (102, 209, 185), 3) + cv2.putText(frame, title, (70, 185), cv2.FONT_HERSHEY_SIMPLEX, 1.35, (255, 209, 102), 3, cv2.LINE_AA) + put_lines(frame, wrap(subtitle, 58), 74, 260, size=0.82, color=(226, 234, 226)) + cv2.putText(frame, BASE_URL, (74, 640), cv2.FONT_HERSHEY_SIMPLEX, 0.78, (102, 209, 185), 2, cv2.LINE_AA) + return frame + + +def screenshot_slide() -> np.ndarray: + image = cv2.imread(str(HOME_SCREENSHOT), cv2.IMREAD_COLOR) + frame = slate("1. Open Web Console", "Browser opens the ISISeg guidewire segmentation workspace.") + fitted = fit_image(image, 1120, 500) + frame[160:660, 80:1200] = fitted + return frame + + +def result_slide(payload: dict, frame_payload: dict, index: int) -> np.ndarray: + frame = slate( + f"2.{index} Segmentation Result", + "The demo uploads the synthetic guidewire video and renders real fusion masks returned by the API.", + ) + overlay = cv2.imread(str(ROOT / frame_payload["overlay_url"].lstrip("/")), cv2.IMREAD_COLOR) + mask = cv2.imread(str(ROOT / frame_payload["mask_url"].lstrip("/")), cv2.IMREAD_GRAYSCALE) + mask_color = cv2.cvtColor(mask, cv2.COLOR_GRAY2BGR) + frame[178:598, 75:635] = fit_image(overlay, 560, 420) + frame[178:598, 645:1205] = fit_image(mask_color, 560, 420) + metrics = frame_payload["metrics"] + lines = [ + f"job: {payload['job_id']} frame: {frame_payload['frame_index']}", + f"coverage: {metrics['coverage'] * 100:.3f}% skeleton: {metrics['skeleton_length']} components: {metrics['components']}", + ] + put_lines(frame, lines, 75, 650, size=0.62, color=(238, 245, 239)) + return frame + + +def write_video(slides: list[np.ndarray]) -> None: + writer = cv2.VideoWriter(str(OUTPUT_VIDEO), cv2.VideoWriter_fourcc(*"mp4v"), 24.0, (1280, 720)) + for slide in slides: + for _ in range(72): + writer.write(slide) + writer.release() + + +def main() -> None: + DEMO_DIR.mkdir(parents=True, exist_ok=True) + wait_for_service() + ensure_sample() + capture_homepage() + payload = call_segmentation() + slides = [ + slate( + "ISISeg Usage Demo", + "A runnable Web system for guidewire segmentation: open the console, upload a fluoroscopy-like video, choose fusion mode, and inspect overlay/mask outputs.", + ), + screenshot_slide(), + ] + for index, frame_payload in enumerate(payload["frames"][:4], start=1): + slides.append(result_slide(payload, frame_payload, index)) + slides.append( + slate( + "Ready to Use", + f"Demo output video and API artifacts are stored under storage/demos and storage/jobs. Overlay video URL: {payload.get('video_url')}", + ) + ) + write_video(slides) + print(OUTPUT_VIDEO) + print(RESPONSE_JSON) + + +if __name__ == "__main__": + main() diff --git a/工程分析/实现方案-2026-05-18-17-55-29.md b/工程分析/实现方案-2026-05-18-17-55-29.md new file mode 100644 index 0000000..14db417 --- /dev/null +++ b/工程分析/实现方案-2026-05-18-17-55-29.md @@ -0,0 +1,24 @@ +# 实现方案 + +开始时间:2026-05-18-17-55-29 + +## 方案 + +新增 `scripts/record_demo.py`,在不引入额外大型依赖的情况下生成演示视频: + +1. 等待并检查 `http://127.0.0.1:8001/api/health`。 +2. 如样例视频不存在,则调用 `scripts/generate_sample.sh` 生成合成导丝视频。 +3. 使用本机 Chrome headless 对 Web 首页截图。 +4. 使用 `curl` 调用 `POST /api/segment` 上传 `storage/samples/synthetic_guidewire.mp4`。 +5. 读取 API 返回的真实叠加图和掩膜图。 +6. 使用 OpenCV 将首页截图、分割结果和指标合成为 mp4。 + +## 输出路径 + +- 演示视频:`storage/demos/isiseg_usage_demo.mp4` +- API 响应记录:`storage/demos/latest_demo_response.json` + +## 设计选择 + +- 当前环境没有显示器会话,也没有可立即使用的浏览器自动化库;Playwright 下载耗时异常,因此改用系统已有 Chrome headless 和 OpenCV 合成可复现演示。 +- 演示视频不提交到 git,避免仓库体积快速膨胀;脚本提交,任何时候都能重新录制。 diff --git a/工程分析/测试方案-2026-05-18-17-55-29.md b/工程分析/测试方案-2026-05-18-17-55-29.md new file mode 100644 index 0000000..f38075d --- /dev/null +++ b/工程分析/测试方案-2026-05-18-17-55-29.md @@ -0,0 +1,25 @@ +# 测试方案 + +开始时间:2026-05-18-17-55-29 + +## 测试步骤 + +1. 服务健康检查: + - `curl -s http://127.0.0.1:8001/api/health` + +2. 演示视频录制: + - `python3 scripts/record_demo.py` + +3. 视频文件校验: + - `ls -lh storage/demos/isiseg_usage_demo.mp4` + - `ffprobe -v error -show_entries format=duration,size -of default=noprint_wrappers=1 storage/demos/isiseg_usage_demo.mp4` + +4. 自动化回归: + - `pytest -q` + +## 执行结果 + +- 健康检查:通过,返回 `{"status":"ok","service":"ISISeg","version":"0.1.0"}`。 +- 演示视频录制:通过,生成 `storage/demos/isiseg_usage_demo.mp4`。 +- 视频校验:通过,文件大小约 3.4 MB,时长 21 秒。 +- 自动化回归:通过,4 个测试全部通过。 diff --git a/工程分析/经验记录.md b/工程分析/经验记录.md index 63ea16d..4a021c6 100644 --- a/工程分析/经验记录.md +++ b/工程分析/经验记录.md @@ -33,3 +33,15 @@ B. 产生问题原因:时序差分在无前帧时退化为 Hessian,融合投 C. 解决问题方案:融合模式统一使用两票通过,低响应时才回退到一票;同时将边缘形态学改成暗线候选约束下的边缘检测。 D. 后续如何避免问题:视频算法要单独检查首帧、丢帧和单图退化路径;融合策略必须记录每个子方法的置信约束。 + +## 2026-05-18-17-55-29 运行确认与演示视频录制 + +### 1. Playwright 安装下载卡住 + +A. 具体问题:尝试安装 `playwright==1.55.0` 用于浏览器录屏时,pip 长时间停留在大型 wheel 下载阶段,没有继续输出。 + +B. 产生问题原因:Playwright 包体积较大,当前网络下载速度或连接稳定性不足;继续等待会影响本次“确保可运行”的收口。 + +C. 解决问题方案:终止该安装进程,改用系统已有 `google-chrome --headless` 截取首页,并使用当前已安装的 OpenCV 将真实页面截图和真实 API 分割结果合成为演示 mp4。 + +D. 后续如何避免问题:演示录制优先复用本机已有工具;只有确实需要完整浏览器交互录屏时,再单独安装 Playwright 并预留下载时间。 diff --git a/工程分析/需求分析-2026-05-18-17-55-29.md b/工程分析/需求分析-2026-05-18-17-55-29.md new file mode 100644 index 0000000..2c77265 --- /dev/null +++ b/工程分析/需求分析-2026-05-18-17-55-29.md @@ -0,0 +1,21 @@ +# 需求分析 + +开始时间:2026-05-18-17-55-29 + +## 用户目标 + +在第一版导丝分割 Web 系统完成后,进一步确认系统最终一定能够运行,并生成一段使用演示视频,方便后续查看或展示系统操作流程。 + +## 需求范围 + +- 复核当前服务是否仍在运行。 +- 保留可重复执行的演示视频录制方式。 +- 录制一段本地演示视频,素材必须来自当前系统真实页面和真实 API 分割结果。 +- 完成后再次测试系统可用性,并提交到 Gitea 备份。 + +## 验收点 + +- `http://127.0.0.1:8001/api/health` 返回 `ok`。 +- `pytest -q` 通过。 +- 存在演示视频 `storage/demos/isiseg_usage_demo.mp4`。 +- 演示脚本路径写入 README,后续可重复录制。