Files
CADx-4/tests/test_ceus_green_prompt.py
T
iomgaa 9a916c65a5 feat(ceus): 任务书 02 第一阶段——底层和分割之前的算法;修复 C14
任务书 01 遗留修复(C14):
- library 新增 check_key_image(path):关键图能逐像素保真重编码时返回 None,否则返回原因;
  _write_clean_png 的判断移过去,自身保留断言性质报错(走到这里说明 build 漏了检查)。
- build 写库前对每例调用,返回原因的整例跳过,记 C14「关键图能原样重编码」,涉及值写原因。
- 原来三条「关键图格式不对整次构建停下」的测试改为整例跳过,另加非 PNG、library 漏检报错两条。
- 结构.md 第八部分、guides/装配两个库.md 相应几行改掉。

第一阶段(src/cadx/ceus/):
- 照搬旧 geometry、video_decoder、research_config、split_screen、green_prompt、prompt_frames、
  mapping,去掉产品层,注释改中文。
- 配置类去掉全部默认值,strict、extra=forbid、frozen;新建 config/ceus.yaml(paths、ceus 两段),
  数值照抄旧 yaml,逐项注明出处。删去 source_view/target_view、effective_region、mapping 锚点;
  旧代码写死的分屏抽帧数 25 和搜索区间 (0.35, 0.65) 改成配置,值不变。
- 视频解码改为按路径用 cv2 逐帧读。
- 坐标换算改为两个视图矩形左上角之差,不再用按某种宽度写死的锚点。
- 造影画面在哪一半:两半各自选提示帧,恰好一半有才定,否则抛 ContrastSideError(CeusCaseError
  子类,带步号和原因,供第三阶段记 failed.csv)。
- 旧测试照搬(改 import 与配置读取方式),因上述改动不再成立的删去;新增两处修改的手算测试。
- import-linter 加「ceus 不引用其他 cadx 模块」。依赖加 numpy、opencv-python-headless。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 03:46:56 -04:00

292 lines
13 KiB
Python
Raw 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.
"""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 的候选被丢弃。
- ``collect_prompt_observations``:对多帧序列返回逐帧观测列表,
无绿帧以 ``None`` 占位(``observation.prompt is None``)。
- ``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,
PromptObservation,
PromptSourceConfig,
TicAnalysisConfig,
collect_prompt_observations,
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 4: collect_prompt_observations —— 多帧观测列表(None 占位)
def test_collect_observations_none_placeholder_for_no_green_frames() -> None:
frames = [
_frame_with_green_rect(120, 200, (40, 40, 120, 90)), # 有绿
_gray_frame(120, 200), # 无绿 → None 占位
_frame_with_green_rect(120, 200, (50, 40, 130, 95)), # 有绿
]
observations = collect_prompt_observations(frames, _cfg())
assert len(observations) == 3
assert all(isinstance(o, PromptObservation) for o in observations)
assert [o.frame_index for o in observations] == [0, 1, 2]
assert observations[0].prompt is not None
assert observations[1].prompt is None # 无绿帧占位
assert observations[2].prompt is not None
assert observations[1].detected is False
assert observations[0].detected is True
# 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()