"""背景圆选择(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"