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>
This commit is contained in:
2026-10-16 03:46:56 -04:00
parent de1d6b2d51
commit 9a916c65a5
29 changed files with 3634 additions and 45 deletions
+291
View File
@@ -0,0 +1,291 @@
"""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()