150eb5f6ae
- 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>
359 lines
14 KiB
Python
359 lines
14 KiB
Python
"""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
|