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>
329 lines
11 KiB
Python
329 lines
11 KiB
Python
"""背景圆选择(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"
|