1de88a5d0a
- 内存:prepare_segmentation 改为流式解码,只留分屏和配准的抽样帧,绿框逐帧在两半上找; 分割那一半由 SegmentationInput.target_frames() 到用时再解码,segment_video 边读边写 jpg; MedSAM2 init_state 的读帧换成直接在存放设备上建张量(不改 MedSAM2 源码,结果逐位相同); 传播结果写进预分配数组。帧数最多的一段视频常驻内存峰值 11.2 GiB → 2.3 GiB,分辨率最大的 一段原代码被 OOM 杀掉、现为 4.2 GiB;两段的掩码和全部产物与改前逐字节相同,33 段视频的 第 1–2 步结果与改前一致,29 例配准与 review.csv 一致。 - 快照:ceus_quant 开跑时写 snapshot/(git、ceus.yaml、review.csv、uv.lock、segment.json), 配置和核对表从快照副本读;收尾由新函数 runs.finish_snapshot 只新建 finished.json。 build 与 ceus_align_review 同样改用 finish_snapshot 记结束时间。 - build:summary.json 加 video_sha256;装 CEUS 结果时核对快照里的 delivery_dir、逐例核对 视频 SHA-256 和推注时间,不同就整次停下;新字段做类型检查,不进 tic.json。 来源:主会话任务(问题 1–3 及补充的 delivery_dir 比对)。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
270 lines
12 KiB
Python
270 lines
12 KiB
Python
"""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()
|