feat(ceus,segment): 任务书 02 第二阶段——分割和后半段算法

- ceus 迁入 polygons、dark_region、reference_region、tic_fitting、tic、anchor_frames、
  perfusion_metrics、trajectory、tic_curves、tic_plot、overlay,旧测试照搬(改 import 与配置读取)。
- 对外两段接口:prepare_segmentation(第 1–2 步)与 quantify_case(第 4–9 步)。
- 第 3 条:删掉 D1 取医生标注时刻的模式,D1 只从拟合曲线算。
- 第 4 条:F1/F2 取视频第 20−X、60−X 秒(X 为视频第 0 秒时已推注的秒数),落在视频外记未定位。
- 绿框融合权重、紫圈颜色阈值照搬;关键帧容差窗口的出处写进 ceus.yaml 注释。
- segment:同一进程调用 MedSAM2,设置写进 ceus.yaml 的 segment 段,全部必填;gpu 测试真跑通过。
- 依赖加 scipy、matplotlib、medsam2(可编辑安装,不编译 CUDA 扩展);uv 只解析 linux x86_64。
- 导入契约:只有 segment 引用 torch、sam2;segment 不引用其他 cadx 模块。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 05:07:02 -04:00
parent 9a916c65a5
commit 150eb5f6ae
35 changed files with 10655 additions and 505 deletions
+358
View File
@@ -0,0 +1,358 @@
"""overlay 的单元测试(照搬旧项目 test_ceus_overlay.py;画层配置与图层的默认值移到测试里)。
测试哲学(CLAUDE.md §4.6):用已知几何构造真实可断言的场景,验证
- ``draw_polygon_overlay`` 在整帧坐标系画各层(左 ROI 黄 / 右 ROI 绿 / 暗区橙红 /
背景圆青),输出帧尺寸不变、对应位置出现对应 BGR 颜色像素、fill 做 alpha 混合;
- ``render_overlay`` 返回非空 mp4 字节,cv2 读回后帧数 / 尺寸与输入一致;
- 临时文件 finally 清理(渲染前后 tempfile 目录无 ``.mp4`` 残留);
- 无效 fps 直接报错(不静默兜底,呼应 findings §2E 不走参考的 25.0 回退)。
不做逐像素全图比对——只在已知轮廓位置抽样断言颜色,符合任务要求。
"""
import tempfile
from pathlib import Path
from typing import Any
import cv2
import numpy as np
import pytest
from cadx.ceus import (
OverlayConfig,
OverlayLayers,
ViewRect,
draw_polygon_overlay,
render_overlay,
)
# 整帧尺寸:左右两半各 100 宽,便于把 source / target 多边形放进各自视图。
_FRAME_HEIGHT = 120
_FRAME_WIDTH = 200
_LEFT_RECT = ViewRect(0, 0, 100, _FRAME_HEIGHT)
_RIGHT_RECT = ViewRect(100, 0, _FRAME_WIDTH, _FRAME_HEIGHT)
def _overlay_cfg(**overrides: bool) -> OverlayConfig:
"""旧 `OverlayConfig` 的默认值(findings §3.10),按需改几项;新代码里这些字段没有默认值。"""
values = {
"draw_source_roi": False,
"draw_target_polygon": True,
"draw_dark_region": True,
"draw_source_dark_region": False,
"draw_reference_region": True,
"fill_polygon": False,
}
values.update(overrides)
return OverlayConfig(**values)
def _layers(**fields: Any) -> OverlayLayers:
"""旧 `OverlayLayers` 的默认值(可选层为空),按需给几项;新代码里这些字段没有默认值。"""
values: dict[str, Any] = {
"dark_polygons": [],
"source_roi_polygon": None,
"source_rect": None,
"source_dark_polygons": [],
"reference_region": None,
}
values.update(fields)
return OverlayLayers(**values)
def _black_frame() -> np.ndarray:
"""构造全黑 BGR 整帧,作为干净画布(背景与所有彩色层都可区分)。"""
return np.zeros((_FRAME_HEIGHT, _FRAME_WIDTH, 3), dtype=np.uint8)
def _square_polygon(x: int, y: int, side: int) -> np.ndarray:
"""构造左上角 ``(x, y)``、边长 ``side`` 的局部坐标方形多边形 ``(4, 2)``。"""
return np.array(
[[x, y], [x + side, y], [x + side, y + side], [x, y + side]],
dtype=np.float32,
)
def _has_color(frame: np.ndarray, bgr: tuple[int, int, int]) -> bool:
"""整帧内是否存在与给定 BGR 完全相等的像素(用于断言某层已被画出)。"""
return bool(np.any(np.all(frame == np.array(bgr, dtype=np.uint8), axis=-1)))
def _count_mp4_in(directory: Path) -> int:
"""统计目录下 ``.mp4`` 文件数量(用于断言临时产物已清理)。"""
return len(list(directory.glob("*.mp4")))
def test_draw_keeps_frame_shape_and_does_not_mutate_input() -> None:
"""绘制后输出帧尺寸 / dtype 不变,且不就地修改入参(返回副本)。"""
frame = _black_frame()
original = frame.copy()
layers = _layers(
target_polygon=_square_polygon(20, 20, 40),
target_rect=_RIGHT_RECT,
)
out = draw_polygon_overlay(frame, layers, _overlay_cfg())
assert out.shape == frame.shape
assert out.dtype == frame.dtype
assert np.array_equal(frame, original) # 入参未被污染
def test_target_polygon_drawn_green() -> None:
"""右侧 target ROI 轮廓默认画绿 (0,255,0),且坐标抬到整帧右半区。"""
layers = _layers(
target_polygon=_square_polygon(20, 20, 40),
target_rect=_RIGHT_RECT,
)
out = draw_polygon_overlay(_black_frame(), layers, _overlay_cfg())
assert _has_color(out, (0, 255, 0))
# 绿色像素应落在右半区(x >= 100),证明做了 target_rect 偏移。
green = np.all(out == np.array((0, 255, 0), dtype=np.uint8), axis=-1)
xs = np.where(green)[1]
assert xs.min() >= 100
def test_source_roi_drawn_yellow_when_enabled() -> None:
"""开启 draw_source_roi 时左侧 ROI 轮廓画黄 (0,255,255) 并落在左半区。"""
layers = _layers(
target_polygon=_square_polygon(20, 20, 40),
target_rect=_RIGHT_RECT,
source_roi_polygon=_square_polygon(20, 20, 40),
source_rect=_LEFT_RECT,
)
out = draw_polygon_overlay(_black_frame(), layers, _overlay_cfg(draw_source_roi=True))
assert _has_color(out, (0, 255, 255))
yellow = np.all(out == np.array((0, 255, 255), dtype=np.uint8), axis=-1)
assert np.where(yellow)[1].max() < 100
def test_source_roi_not_drawn_when_disabled() -> None:
"""draw_source_roi 默认 False(findings §3.10),不应出现黄色像素。"""
layers = _layers(
target_polygon=_square_polygon(20, 20, 40),
target_rect=_RIGHT_RECT,
source_roi_polygon=_square_polygon(20, 20, 40),
source_rect=_LEFT_RECT,
)
out = draw_polygon_overlay(_black_frame(), layers, _overlay_cfg())
assert not _has_color(out, (0, 255, 255))
def test_dark_region_drawn_orange_red() -> None:
"""暗区轮廓默认画橙红 (255,80,20),落在右半区(与 target 共 rect)。"""
layers = _layers(
target_polygon=_square_polygon(20, 20, 60),
target_rect=_RIGHT_RECT,
dark_polygons=[_square_polygon(30, 30, 20)],
)
out = draw_polygon_overlay(_black_frame(), layers, _overlay_cfg())
assert _has_color(out, (255, 80, 20))
def test_reference_region_drawn_cyan_when_enabled() -> None:
"""背景圆启用时左侧画青色小圆 (255,255,0),落在左半区。
**颜色不能是紫 (255,0,255)**:紫色是医生画的 ``ROI_pan`` 轮廓的颜色,两圈紫叠在
同一画面上人眼分不出哪个是哪个(design/0030 决策四)。
"""
layers = _layers(
target_polygon=_square_polygon(20, 20, 40),
target_rect=_RIGHT_RECT,
source_rect=_LEFT_RECT,
reference_region={"enabled": True, "center_xy": [40.0, 40.0], "radius_px": 12},
)
out = draw_polygon_overlay(_black_frame(), layers, _overlay_cfg())
assert _has_color(out, (255, 255, 0))
assert not _has_color(out, (255, 0, 255)), "背景圆不许和医生的紫色轮廓撞色"
cyan = np.all(out == np.array((255, 255, 0), dtype=np.uint8), axis=-1)
assert np.where(cyan)[1].max() < 100
def test_reference_region_not_drawn_when_disabled() -> None:
"""reference enabled=False 时不画那个圆。"""
layers = _layers(
target_polygon=_square_polygon(20, 20, 40),
target_rect=_RIGHT_RECT,
source_rect=_LEFT_RECT,
reference_region={"enabled": False, "center_xy": [40.0, 40.0], "radius_px": 12},
)
out = draw_polygon_overlay(_black_frame(), layers, _overlay_cfg())
assert not _has_color(out, (255, 255, 0))
def test_fill_polygon_alpha_blends_target() -> None:
"""开启 fill_polygon 时 ROI 内部做 alpha=0.18 混合:黑底上出现暗绿填充,
且其值约等于 round(255*0.18)=46(绿通道),区别于不填充时的纯黑内部。"""
layers = _layers(
target_polygon=_square_polygon(20, 20, 50),
target_rect=_RIGHT_RECT,
)
filled = draw_polygon_overlay(_black_frame(), layers, _overlay_cfg(fill_polygon=True))
# 取多边形内部一点(局部 (40,40) -> 整帧 (140,40)),应为混合后的暗绿。
inner = filled[40, 140]
assert inner[0] == 0 # B 通道仍为 0
assert 40 <= int(inner[1]) <= 52 # G 通道 ≈ 255*0.18
assert inner[2] == 0 # R 通道仍为 0
def test_render_overlay_returns_decodable_mp4_bytes() -> None:
"""render_overlay 返回非空 mp4 字节;cv2 读回帧数 / 尺寸与输入一致。"""
num_frames = 5
frames = [_black_frame() for _ in range(num_frames)]
per_frame_layers = [
_layers(
target_polygon=_square_polygon(20, 20, 40),
target_rect=_RIGHT_RECT,
)
for _ in range(num_frames)
]
data = render_overlay(
frames,
per_frame_layers,
fps=25.0,
cfg=_overlay_cfg(),
frame_size=(_FRAME_WIDTH, _FRAME_HEIGHT),
)
assert isinstance(data, bytes)
assert len(data) > 0
# 写回临时文件用 cv2 读回校验(mp4v 编码无法直接 in-memory 解码)。
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as handle:
handle.write(data)
probe_path = Path(handle.name)
try:
cap = cv2.VideoCapture(str(probe_path))
try:
assert cap.isOpened()
assert int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) == _FRAME_WIDTH
assert int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) == _FRAME_HEIGHT
decoded = 0
while True:
ok, _ = cap.read()
if not ok:
break
decoded += 1
assert decoded == num_frames
finally:
cap.release()
finally:
probe_path.unlink(missing_ok=True)
def test_render_overlay_cleans_temp_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""渲染走临时文件再读回,结束后临时目录不留 .mp4 残留。"""
monkeypatch.setattr(tempfile, "tempdir", str(tmp_path))
assert _count_mp4_in(tmp_path) == 0
frames = [_black_frame() for _ in range(3)]
per_frame_layers = [
_layers(
target_polygon=_square_polygon(20, 20, 40),
target_rect=_RIGHT_RECT,
)
for _ in range(3)
]
render_overlay(
frames,
per_frame_layers,
fps=25.0,
cfg=_overlay_cfg(),
frame_size=(_FRAME_WIDTH, _FRAME_HEIGHT),
)
assert _count_mp4_in(tmp_path) == 0
def test_render_overlay_rejects_non_positive_fps() -> None:
"""fps 非正应直接报错(不静默回退 25.0,见 findings §2E / 项目防御原则)。"""
frames = [_black_frame()]
per_frame_layers = [
_layers(target_polygon=_square_polygon(20, 20, 40), target_rect=_RIGHT_RECT)
]
with pytest.raises(ValueError, match="fps"):
render_overlay(
frames,
per_frame_layers,
fps=0.0,
cfg=_overlay_cfg(),
frame_size=(_FRAME_WIDTH, _FRAME_HEIGHT),
)
def test_render_overlay_rejects_length_mismatch() -> None:
"""帧序列与逐帧图层序列长度不一致是调用方缺陷,直接报错。"""
frames = [_black_frame(), _black_frame()]
per_frame_layers = [
_layers(target_polygon=_square_polygon(20, 20, 40), target_rect=_RIGHT_RECT)
]
# 流式之后这条由 zip(strict=True) 执行。**仍旧钉住是哪一条报的**:这个函数里有四条
# 路径抛 ValueError(fps / frame_size / 图层为空 / 长度不一致),裸 raises 会让其中
# 任何一条被写错都从这里溜过去。
with pytest.raises(ValueError, match=r"zip\(\)"):
render_overlay(
frames,
per_frame_layers,
fps=25.0,
cfg=_overlay_cfg(),
frame_size=(_FRAME_WIDTH, _FRAME_HEIGHT),
)
def test_render_overlay_rejects_empty_frames() -> None:
"""一帧都没有就产不出视频,直接报错(不静默产出空视频)。
判据从「帧序列为空」改成「逐帧图层为空」:流式之后帧数要遍历完才知道,而图层那一侧
仍旧是个列表,长度当场可查。两者必须等长(由 zip 的 strict 执行),所以查哪一侧等价。
"""
with pytest.raises(ValueError, match="不能为空"):
render_overlay(
[],
[],
fps=25.0,
cfg=_overlay_cfg(),
frame_size=(_FRAME_WIDTH, _FRAME_HEIGHT),
)
def test_render_overlay_rejects_non_positive_frame_size() -> None:
"""输出尺寸由调用方给,给错了当场报错——编码器拿非法尺寸只会打不开,消息里看不出是谁的错。"""
frames = [_black_frame()]
layers = [_layers(target_polygon=_square_polygon(20, 20, 40), target_rect=_RIGHT_RECT)]
with pytest.raises(ValueError, match="frame_size"):
render_overlay(frames, layers, fps=25.0, cfg=_overlay_cfg(), frame_size=(0, 10))
def test_render_overlay_拒绝尺寸和_frame_size_对不上的帧() -> None:
"""**尺寸不符的帧会被编码器静默丢掉**——实测写 6 帧读回 3 帧,只在 stderr 留一行警告。
后果是 overlay 视频短一截甚至是空的,而它照样有字节、照样被上传、台账照样记完成。
输出尺寸以前取自首帧,结构上不可能不符;改成由调用方给之后这条得自己守
(design/0024 落地后的独立评审报出)。
"""
layers = [_layers(target_polygon=_square_polygon(20, 20, 40), target_rect=_RIGHT_RECT)]
with pytest.raises(ValueError, match="帧尺寸"):
render_overlay(
[_black_frame()],
layers,
fps=25.0,
cfg=_overlay_cfg(),
# 宽高对调:合法的正尺寸,但和实际帧对不上
frame_size=(_FRAME_HEIGHT, _FRAME_WIDTH),
)
def test_render_overlay_接受迭代器而不只是列表() -> None:
"""**这条守的是流式本身**:服务喂进来的是解码器的生成器,不是列表(design/0024)。
改回 ``Sequence`` 的话,服务那边要么先把全片物化成列表(4.33 GB,正是要消灭的东西),
要么当场报 TypeError;后者会被别的测试抓到,前者不会。
"""
layers = [
_layers(target_polygon=_square_polygon(20, 20, 40), target_rect=_RIGHT_RECT)
for _ in range(3)
]
data = render_overlay(
(_black_frame() for _ in range(3)),
layers,
fps=25.0,
cfg=_overlay_cfg(),
frame_size=(_FRAME_WIDTH, _FRAME_HEIGHT),
)
assert isinstance(data, bytes) and len(data) > 0