Files
CADx-4/tests/test_ceus_reference_region.py
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

329 lines
11 KiB
Python
Raw Permalink 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.
"""背景圆选择(reference_region)单元测试(照搬旧项目 test_ceus_reference_region.py)。
旧标题:B8 参考区选择(reference_region)单元测试。
参考区是 ROI 外一块"干净背景"小圆,用于 TIC 的 ``background_circle`` 曲线。本测试
用真实 OpenCV 像素管线(非 mock)合成"亮 ROI + 四周可控背景"图像,钉死
``select_reference_region`` 的真实行为,覆盖:
- ``enabled=False`` → 返回空 mask + ``{"enabled": False}``;
- ``manual`` 模式:用 ``center_xy`` + ``radius_px`` 出圆 mask,metadata 键齐全且 shape=="circle";
- ``auto_near_roi`` 找到合规背景圆(ROI 外有干净非黑非绿背景);
- ``auto_near_roi`` 四周全黑 / 全绿无合规位 → 明确报错(不返回失真位);
- ``auto_near_roi`` 的干净背景判据把医生画的紫色轮廓也排掉(TIC 会扣掉这些像素)。
"""
import numpy as np
from cadx.ceus import (
CeusConfig,
ReferenceRegionConfig,
TicAnalysisConfig,
polygon_to_mask,
select_reference_region,
)
from conftest import load_ceus_section
# 配置不设默认值:旧测试靠默认值构造的配置,改为在 config/ceus.yaml 的取值上 model_copy。
_CONFIG = CeusConfig.model_validate(load_ceus_section())
def _ref_cfg(**overrides: object) -> ReferenceRegionConfig:
"""ceus.yaml 的背景圆段,按需改几项(代替旧的 `ReferenceRegionConfig(...)`)。"""
return _CONFIG.tic_analysis.reference_region.model_copy(update=overrides)
# ---------- 合成样本 ----------
_H, _W = 160, 200
# ROI 多边形:画面中央偏右上的方块(留出左下大片背景给 auto 模式落圆)。
_ROI_POLYGON = np.array(
[[110.0, 20.0], [170.0, 20.0], [170.0, 70.0], [110.0, 70.0]], dtype=np.float32
)
# 紫色(BGR):H≈145、S=255、V=255,落在 PanSourceConfig 默认阈 [125,60,60]~[165,255,255] 内。
_PURPLE_BGR = (255, 0, 212)
def _source_polygons() -> np.ndarray:
"""逐帧 ROI 多边形序列(参考区只看第一帧,单帧即可)。"""
return np.array([_ROI_POLYGON])
def _bright_background_frame() -> np.ndarray:
"""整幅亮背景(灰度 180)的 BGR 帧——四周都是干净非黑非绿背景。"""
return np.full((_H, _W, 3), 180, dtype=np.uint8)
def _black_frame_with_bright_roi() -> np.ndarray:
"""ROI 区域亮(180),ROI 之外全黑——auto 模式四周无合规背景圆。"""
frame = np.zeros((_H, _W, 3), dtype=np.uint8)
frame[20:70, 110:170] = 180
return frame
def _green_background_frame() -> np.ndarray:
"""ROI 区亮、四周全是医生绿框色(BGR 纯绿)——auto 模式四周被剔绿后无合规位。"""
frame = np.zeros((_H, _W, 3), dtype=np.uint8)
frame[:, :] = (0, 255, 0) # BGR 纯绿,落在默认 HSV [35,60,60]-[90,255,255] 内
frame[20:70, 110:170] = 180
return frame
def _tic_cfg(ref: ReferenceRegionConfig) -> TicAnalysisConfig:
"""构造带指定 reference_region 子配置的 TicAnalysisConfig。"""
return _CONFIG.tic_analysis.model_copy(update={"reference_region": ref})
# ---------- enabled=False ----------
def test_disabled_returns_empty_mask_and_flag() -> None:
"""enabled=False:返回全 False mask 且 metadata 仅含 {"enabled": False}。"""
ref = _ref_cfg(enabled=False)
frame = _bright_background_frame()
mask, meta = select_reference_region(
frame,
_source_polygons(),
_CONFIG.prompt_source,
_CONFIG.pan_source,
_tic_cfg(ref),
)
assert mask.shape == (_H, _W)
assert mask.dtype == bool
assert not mask.any()
assert meta == {"enabled": False}
# ---------- manual 模式 ----------
def test_manual_mode_circle_at_center() -> None:
"""manual:以 center_xy + radius_px 出圆 mask,metadata 键齐全且 shape=='circle'。"""
radius = 24
center = [40.0, 120.0]
ref = _ref_cfg(mode="manual", center_xy=center, radius_px=radius)
frame = _bright_background_frame()
mask, meta = select_reference_region(
frame,
_source_polygons(),
_CONFIG.prompt_source,
_CONFIG.pan_source,
_tic_cfg(ref),
)
assert mask.dtype == bool
assert mask[int(center[1]), int(center[0])] # 圆心被标记
assert not mask[0, 0] # 远处角点不在圆内
assert meta["enabled"] is True
assert meta["mode"] == "manual"
assert meta["shape"] == "circle"
assert meta["center_xy"] == center
assert meta["radius_px"] == float(radius)
assert meta["area_px"] == int(mask.sum())
assert meta["area_px"] > 0
def test_manual_circle_area_matches_radius() -> None:
"""manual 圆面积应接近 π r²(栅格化误差范围内)。"""
radius = 20
ref = _ref_cfg(mode="manual", center_xy=[40.0, 120.0], radius_px=radius)
frame = _bright_background_frame()
mask, _ = select_reference_region(
frame,
_source_polygons(),
_CONFIG.prompt_source,
_CONFIG.pan_source,
_tic_cfg(ref),
)
expected = np.pi * radius * radius
assert abs(int(mask.sum()) - expected) / expected < 0.1
# ---------- auto_near_roi 找到 ----------
def test_auto_finds_clean_background_circle() -> None:
"""auto:四周干净亮背景时应找到合规圆,metadata 键齐全且 valid_fraction 达标。"""
ref = _ref_cfg(mode="auto_near_roi")
frame = _bright_background_frame()
mask, meta = select_reference_region(
frame,
_source_polygons(),
_CONFIG.prompt_source,
_CONFIG.pan_source,
_tic_cfg(ref),
)
assert mask.dtype == bool
assert mask.any()
assert meta["enabled"] is True
assert meta["mode"] == "auto_near_roi"
assert meta["shape"] == "circle"
assert len(meta["center_xy"]) == 2
assert meta["radius_px"] == float(ref.radius_px)
assert meta["area_px"] == int(mask.sum())
assert meta["valid_fraction_first_frame"] >= ref.min_valid_fraction
def test_auto_reference_circle_outside_roi() -> None:
"""auto:选中的参考圆不应与 ROI(含禁区膨胀)重叠。"""
ref = _ref_cfg(mode="auto_near_roi")
frame = _bright_background_frame()
mask, _ = select_reference_region(
frame,
_source_polygons(),
_CONFIG.prompt_source,
_CONFIG.pan_source,
_tic_cfg(ref),
)
roi_mask = polygon_to_mask(_ROI_POLYGON, _H, _W)
assert not np.any(mask & roi_mask)
# ---------- auto_near_roi 找不到 → 空掩膜与显式原因 ----------
def test_auto_returns_unavailable_when_background_all_black() -> None:
"""auto:ROI 外全黑时不伪造背景圆,也不阻断病灶拟合。"""
ref = _ref_cfg(mode="auto_near_roi")
frame = _black_frame_with_bright_roi()
mask, meta = select_reference_region(
frame,
_source_polygons(),
_CONFIG.prompt_source,
_CONFIG.pan_source,
_tic_cfg(ref),
)
assert not mask.any()
assert meta["available"] is False
assert meta["failure_reason"] == "no_valid_candidate"
def test_auto_returns_unavailable_when_background_all_green() -> None:
"""auto:ROI 外全绿时返回空掩膜,不返回失真位置。"""
ref = _ref_cfg(mode="auto_near_roi")
frame = _green_background_frame()
mask, meta = select_reference_region(
frame,
_source_polygons(),
_CONFIG.prompt_source,
_CONFIG.pan_source,
_tic_cfg(ref),
)
assert not mask.any()
assert meta["available"] is False
# ---------- 补强:精确键集 + 多帧 ROI 并集禁区 ----------
def test_manual_metadata_exact_key_set() -> None:
"""manual 分支 metadata 键集须精确(无缺键、无误增键)。"""
ref = _ref_cfg(mode="manual", center_xy=[40.0, 120.0], radius_px=18)
_, meta = select_reference_region(
_bright_background_frame(),
_source_polygons(),
_CONFIG.prompt_source,
_CONFIG.pan_source,
_tic_cfg(ref),
)
assert set(meta.keys()) == {
"enabled",
"mode",
"shape",
"center_xy",
"radius_px",
"area_px",
}
def test_auto_metadata_exact_key_set() -> None:
"""auto 分支 metadata 键集须精确,含 valid_fraction_first_frame。"""
ref = _ref_cfg(mode="auto_near_roi")
_, meta = select_reference_region(
_bright_background_frame(),
_source_polygons(),
_CONFIG.prompt_source,
_CONFIG.pan_source,
_tic_cfg(ref),
)
assert set(meta.keys()) == {
"enabled",
"mode",
"shape",
"center_xy",
"radius_px",
"area_px",
"valid_fraction_first_frame",
}
def test_auto_avoids_doctor_purple_contour() -> None:
"""auto:干净背景的判据要把医生画的紫色一起排掉,选中的圆不许压在紫色上。
紫色轮廓是医生画上去的线,TIC 把这些像素从掩膜里扣掉——背景圆压在它上面的话,
``valid_background_circle_px`` 会被悄悄削掉一块,而没有任何一处会说出来。紫色的灰度
不低(``nonblack_threshold`` 拦不住它),所以只排绿的判据会把它当成干净背景。
先在全亮画面上选一次,把选中的那个圆整个涂成紫色再选第二次:``min_valid_fraction``
设成 1.0,于是只要圆里沾上一个紫色像素就出局,第二次必须换个位置。
"""
ref = _ref_cfg(mode="auto_near_roi", min_valid_fraction=1.0)
frame = _bright_background_frame()
first_mask, _ = select_reference_region(
frame,
_source_polygons(),
_CONFIG.prompt_source,
_CONFIG.pan_source,
_tic_cfg(ref),
)
painted = frame.copy()
painted[first_mask] = _PURPLE_BGR
second_mask, _ = select_reference_region(
painted,
_source_polygons(),
_CONFIG.prompt_source,
_CONFIG.pan_source,
_tic_cfg(ref),
)
purple = np.all(painted == np.asarray(_PURPLE_BGR, dtype=np.uint8), axis=2)
assert second_mask.any(), "涂了一块紫色之后连一个合规位置都找不到了"
assert not np.any(second_mask & purple), "选中的背景圆压在紫色轮廓上"
def test_auto_keepout_uses_multiframe_roi_union() -> None:
"""auto 禁区取逐帧 ROI 并集:选中圆须避开所有帧 ROI 的并集(非仅首帧)。"""
ref = _ref_cfg(mode="auto_near_roi")
# 两帧不同位置的 ROI:首帧右上、次帧偏左下,构成更大的并集禁区
roi_a = _ROI_POLYGON
roi_b = np.array([[60.0, 90.0], [120.0, 90.0], [120.0, 140.0], [60.0, 140.0]], dtype=np.float32)
polys = np.array([roi_a, roi_b])
mask, _ = select_reference_region(
_bright_background_frame(),
polys,
_CONFIG.prompt_source,
_CONFIG.pan_source,
_tic_cfg(ref),
)
union = polygon_to_mask(roi_a, _H, _W) | polygon_to_mask(roi_b, _H, _W)
assert not np.any(mask & union), "参考圆须避开逐帧 ROI 并集,而非仅首帧 ROI"