Files
CADx-4/tests/test_ceus_overlay.py
T
iomgaa 150eb5f6ae 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>
2026-10-16 05:07:02 -04:00

359 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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