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:
@@ -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()
|
||||
Reference in New Issue
Block a user