"""B3 green_prompt 绿框提示检测单元测试。 钉死这几件事的关键性质(参考 ``references/ROI/roi_tracking_pipeline.py`` P:241-332/989-1004): - ``extract_color_prompt``:合成在已知位置画绿色多边形的 BGR 左视图帧 → 返回该绿框 bbox(含端点 ``x2=x1+w-1`` / ``y2=y1+h-1``)+ 充满轮廓的布尔 mask; 纯灰度帧(无绿)→ ``None``。 - ``ignore_regions``:把绿框画在顶部文字带内,配置抹除顶部带后该绿不计 → ``None``; 绿框在带外则正常返回。 - ``min_area``:按 **bbox 面积 w×h**(findings §3.2,非轮廓面积)过滤—— bbox 面积 < min_area 的候选被丢弃。 - ``prompt_color_mask``:TIC 端"排除绿框色"掩膜——``exclude_prompt_color=False`` 全 False; ``prompt_exclude_dilate=1`` 不膨胀(mask == 原始 inRange);增大膨胀核则 mask 更大。 测试用真实数据的二次构造(合成定位绿框 / 纯灰度帧),边缘场景靠实现 raise/None 保证。 """ import cv2 import numpy as np from cadx.ceus import ( CeusConfig, ColorPrompt, PanSourceConfig, PromptSourceConfig, TicAnalysisConfig, extract_color_prompt, prompt_color_mask, ) from conftest import load_ceus_section # 配置不设默认值:旧测试靠默认值构造的配置,改为在 config/ceus.yaml 的取值上 model_copy。 _CONFIG = CeusConfig.model_validate(load_ceus_section()) # 纯绿(BGR):H≈60、S=255、V=255,落在 inRange [35,60,60]~[90,255,255] 内。 _GREEN_BGR = (0, 255, 0) _GRAY_BGR = 120 # 紫(BGR):H≈145、S=255、V=255,落在 PanSourceConfig 默认阈 [125,60,60]~[165,255,255] 内。 _PURPLE_BGR = (255, 0, 212) def _gray_frame(height: int, width: int) -> np.ndarray: """合成一帧纯灰度 BGR 图(三通道等值),不含任何绿色像素。""" return np.full((height, width, 3), _GRAY_BGR, dtype=np.uint8) def _frame_with_green_rect( height: int, width: int, rect_xyxy: tuple[int, int, int, int], *, thickness: int = -1, ) -> np.ndarray: """在灰底帧上画一个绿色矩形(默认实心填充)。 ``rect_xyxy`` 为 ``(x1, y1, x2, y2)`` 含端点像素坐标。 """ frame = _gray_frame(height, width) x1, y1, x2, y2 = rect_xyxy cv2.rectangle(frame, (x1, y1), (x2, y2), _GREEN_BGR, thickness=thickness) return frame def _cfg(**overrides: object) -> PromptSourceConfig: """构造 PromptSourceConfig,默认无 ignore_regions、min_area=1,便于钉死定位。""" base: dict[str, object] = {"ignore_regions": [], "min_area": 1} base.update(overrides) return _CONFIG.prompt_source.model_copy(update=base) def _tic_cfg(**overrides: object) -> TicAnalysisConfig: """构造 TicAnalysisConfig,默认 exclude_prompt_color=True、prompt_exclude_dilate=1。 把 dilate 默认置 1 便于钉死"原始 inRange == mask"(无膨胀外扩), 各测试再按需覆盖以验证条件膨胀语义。 """ base: dict[str, object] = {"prompt_exclude_dilate": 1} base.update(overrides) return _CONFIG.tic_analysis.model_copy(update=base) def _pan_cfg() -> PanSourceConfig: """紫圈检测配置:config/ceus.yaml 的取值,去掉忽略区、min_area=1。""" return _CONFIG.pan_source.model_copy(update={"ignore_regions": [], "min_area": 1}) # Phase 1: extract_color_prompt —— 定位绿框 / 无绿返回 None def test_extract_returns_bbox_with_inclusive_endpoints() -> None: # 在 (40,30)-(120,90) 画实心绿框(含端点)。 rect = (40, 30, 120, 90) frame = _frame_with_green_rect(120, 200, rect) prompt = extract_color_prompt(frame, _cfg()) assert isinstance(prompt, ColorPrompt) x1, y1, x2, y2 = prompt.bbox_xyxy # 形态学 CLOSE+dilate 会外扩几像素,允许 ±3 容差,钉死端点语义。 assert abs(x1 - 40) <= 3 and abs(y1 - 30) <= 3 assert abs(x2 - 120) <= 3 and abs(y2 - 90) <= 3 # 含端点:width = x2 - x1 + 1。 assert prompt.width == x2 - x1 + 1 assert prompt.height == y2 - y1 + 1 # mask 是布尔、与帧同高宽、绿框区域为 True。 assert prompt.mask.dtype == np.bool_ assert prompt.mask.shape == (120, 200) assert bool(prompt.mask[60, 80]) # 框中心点在 mask 内 assert prompt.candidate_count >= 1 def test_extract_gray_frame_returns_none() -> None: assert extract_color_prompt(_gray_frame(120, 200), _cfg()) is None # Phase 2: ignore_regions —— 顶部文字带抹除 def test_ignore_regions_suppresses_top_band_green() -> None: # 绿框完全落在顶部 8.34% 带内(120*0.0834≈10px),抹除后不计。 top_rect = (40, 1, 120, 8) frame = _frame_with_green_rect(120, 200, top_rect) cfg = _cfg(ignore_regions=[[0.0, 0.0, 1.0, 0.0834]]) assert extract_color_prompt(frame, cfg) is None def test_ignore_regions_keeps_below_band_green() -> None: # 绿框在顶部带之下,抹除顶部带不影响它。 body_rect = (40, 40, 120, 90) frame = _frame_with_green_rect(120, 200, body_rect) cfg = _cfg(ignore_regions=[[0.0, 0.0, 1.0, 0.0834]]) prompt = extract_color_prompt(frame, cfg) assert prompt is not None assert abs(prompt.bbox_xyxy[1] - 40) <= 3 # Phase 3: min_area —— 按 bbox 面积 w×h 过滤 def test_min_area_filters_by_bbox_area() -> None: # 绿框 bbox≈ 21×21=441;min_area=2000 应过滤掉 → None。 small_rect = (50, 50, 70, 70) frame = _frame_with_green_rect(120, 200, small_rect) assert extract_color_prompt(frame, _cfg(min_area=2000)) is None # 放宽 min_area 后能检出。 assert extract_color_prompt(frame, _cfg(min_area=100)) is not None def test_min_area_boundary_equal_passes() -> None: # 钉死边界:bbox 面积"恰等于"min_area 应通过(实现是 `< min_area` 过滤, # 非 `<= min_area`;与参考 P:270 一致)。用细绿框确保 bbox 面积可精确预测, # 不被形态学外扩干扰(CLOSE+dilate 只让外轮廓更大、bbox 不会缩小)。 # 实心绿框 (50,50)-(70,70):bbox = (70-50+1)*(70-50+1) = 21*21 = 441。 rect = (50, 50, 70, 70) expected_area = (70 - 50 + 1) * (70 - 50 + 1) assert expected_area == 441 frame = _frame_with_green_rect(120, 200, rect) # 形态学会外扩,故实际 bbox 面积 >= 441;取一个明显小于实际面积、 # 但用于证伪 `<=` 误写的下界即可——这里用恰等于"检出框的真实面积"。 detected = extract_color_prompt(frame, _cfg(min_area=1)) assert detected is not None area_eq = detected.bbox_area # min_area 恰等于检出 bbox 面积 → 应通过(area >= min_area); # 若误写成 `<= min_area` 过滤,则此处会被丢弃返回 None,从而证伪。 on_boundary = extract_color_prompt(frame, _cfg(min_area=area_eq)) assert on_boundary is not None assert on_boundary.bbox_area == area_eq # 反向:min_area 比真实面积大 1 → 被过滤为 None(确认过滤确实生效)。 assert extract_color_prompt(frame, _cfg(min_area=area_eq + 1)) is None def test_hollow_green_rect_closes_to_single_filled_contour() -> None: # 覆盖参考 P:261-288 真实服务路径:医生画的是空心绿框(细描边,非实心)。 # 用 thickness=3 的空心矩形,验证 CLOSE+dilate 后 findContours 连通成单个外轮廓, # 且 drawContours 实心填充使 mask 覆盖框内区域(含框中心,原图中心是灰底非绿)。 rect = (40, 30, 140, 100) frame = _frame_with_green_rect(120, 200, rect, thickness=3) # 前置:中心点在原图是灰底(空心框内部无绿),证明确为空心框。 cx, cy = (40 + 140) // 2, (30 + 100) // 2 assert tuple(int(c) for c in frame[cy, cx]) == (_GRAY_BGR, _GRAY_BGR, _GRAY_BGR) prompt = extract_color_prompt(frame, _cfg(min_area=100)) assert prompt is not None # 单个外轮廓:候选数为 1(空心框经形态学连通后只剩一条外轮廓)。 assert prompt.candidate_count == 1 # 实心填充 mask 覆盖框内中心区域(findContours 外轮廓 + drawContours thickness=-1)。 assert bool(prompt.mask[cy, cx]) # bbox 端点贴合空心框外缘(±5 容差吸收核 (5,5) CLOSE+dilate 的外扩)。 assert abs(prompt.bbox_xyxy[0] - 40) <= 5 and abs(prompt.bbox_xyxy[1] - 30) <= 5 assert abs(prompt.bbox_xyxy[2] - 140) <= 5 and abs(prompt.bbox_xyxy[3] - 100) <= 5 def test_min_area_picks_largest_bbox_candidate() -> None: # 两个绿框:小框 (10,10)-(30,30),大框 (80,60)-(170,110);选大框。 frame = _frame_with_green_rect(120, 200, (10, 10, 30, 30)) cv2.rectangle(frame, (80, 60), (170, 110), _GREEN_BGR, thickness=-1) prompt = extract_color_prompt(frame, _cfg(min_area=1)) assert prompt is not None assert prompt.candidate_count == 2 # 选中的应是大框(左上角接近 80,60)。 assert abs(prompt.bbox_xyxy[0] - 80) <= 3 assert abs(prompt.bbox_xyxy[1] - 60) <= 3 # Phase 5: prompt_color_mask —— TIC 端"排除绿框色"掩膜(受 tic_cfg 门控) def test_prompt_color_mask_marks_green_pixels() -> None: frame = _frame_with_green_rect(120, 200, (40, 30, 120, 90)) mask = prompt_color_mask(frame, _cfg(), _tic_cfg()) assert mask.dtype == np.bool_ assert mask.shape == (120, 200) assert bool(mask[60, 80]) # 绿框内为 True assert not bool(mask[5, 5]) # 灰底为 False def test_prompt_color_mask_all_gray_is_empty() -> None: mask = prompt_color_mask(_gray_frame(120, 200), _cfg(), _tic_cfg()) assert not mask.any() def test_prompt_color_mask_exclude_disabled_returns_all_false() -> None: # exclude_prompt_color=False → 直接返回全 False(语义:"不排除任何绿框色像素")。 frame = _frame_with_green_rect(120, 200, (40, 30, 120, 90)) mask = prompt_color_mask(frame, _cfg(), _tic_cfg(exclude_prompt_color=False)) assert mask.dtype == np.bool_ assert mask.shape == (120, 200) assert not mask.any() def test_prompt_color_mask_dilate_one_equals_raw_inrange() -> None: # prompt_exclude_dilate=1 → 不膨胀,mask 应恰等于原始 inRange 二值。 frame = _frame_with_green_rect(120, 200, (40, 30, 120, 90)) mask = prompt_color_mask(frame, _cfg(), _tic_cfg(prompt_exclude_dilate=1)) lower = np.asarray(_cfg().hsv_lower, dtype=np.uint8) upper = np.asarray(_cfg().hsv_upper, dtype=np.uint8) hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) raw = cv2.inRange(hsv, lower, upper) > 0 assert np.array_equal(mask, raw) def test_prompt_color_mask_larger_dilate_grows_mask() -> None: # prompt_exclude_dilate=5 应比 =1 时膨胀出更多 True 像素(外扩绿框边缘)。 frame = _frame_with_green_rect(120, 200, (40, 30, 120, 90)) mask_one = prompt_color_mask(frame, _cfg(), _tic_cfg(prompt_exclude_dilate=1)) mask_five = prompt_color_mask(frame, _cfg(), _tic_cfg(prompt_exclude_dilate=5)) assert int(mask_five.sum()) > int(mask_one.sum()) # 膨胀只增不减:dilate=1 的 True 像素应全被 dilate=5 覆盖。 assert bool((mask_five | mask_one == mask_five).all()) # Phase 5: 换一组颜色范围就是另一个检测器(design/0030 决策四) def test_extract_color_prompt_detects_purple_with_pan_config() -> None: """同一个 ``extract_color_prompt``,喂 ``PanSourceConfig`` 检的是紫色轮廓。 紫色轮廓是医生画的邻近正常胰腺实质(``ROI_pan``),和绿框走同一套 HSV 机制、 同一个函数——所以这里断言的是「函数认颜色配置,不认死绿色」这个行为。 """ rect = (40, 30, 120, 90) frame = _gray_frame(120, 200) cv2.rectangle(frame, rect[:2], rect[2:], _PURPLE_BGR, thickness=-1) pan_cfg = _pan_cfg() prompt = extract_color_prompt(frame, pan_cfg) assert isinstance(prompt, ColorPrompt) assert bool(prompt.mask[60, 80]) # 同一帧喂绿框那组配置一个候选都没有——两组颜色范围互不命中。 assert extract_color_prompt(frame, _cfg()) is None def test_prompt_color_mask_takes_hsv_from_the_config_it_is_given() -> None: """排除色掩膜的 HSV 来源是入参:喂紫色配置就出紫色掩膜,喂绿框配置全空。""" frame = _gray_frame(120, 200) cv2.rectangle(frame, (40, 30), (120, 90), _PURPLE_BGR, thickness=-1) purple_mask = prompt_color_mask(frame, _pan_cfg(), _tic_cfg()) green_mask = prompt_color_mask(frame, _cfg(), _tic_cfg()) assert bool(purple_mask[60, 80]) assert not green_mask.any()