Files
CADx-4/tests/test_ceus_prompt_frames.py
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

139 lines
5.7 KiB
Python
Raw Permalink 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.
"""B4 关键帧选择单元测试。
钉死 findings §3.3 / 参考 P:335-372 的关键帧语义:首帧强制(change_iou=1.0)、
形状不变不新增、``change_iou < iou_threshold``(严格小于)才记新 prompt_frame、
``min_gap_frames`` 在 IoU 判定之前抑制过密、无绿帧跳过、返回结构字段齐全。
测试用真实算法路径(``mask_iou``)而非 mock:构造形状渐变的布尔 mask 绿框观测序列,
让 IoU 在阈值上下精确落点(含恰好等于阈值的边界帧)。
"""
import numpy as np
from cadx.ceus import (
CeusConfig,
ColorPrompt,
KeyframesConfig,
PromptFrame,
PromptObservation,
select_prompt_frames,
)
from conftest import load_ceus_section
def _keyframes_cfg(**overrides: object) -> KeyframesConfig:
"""提示帧配置:config/ceus.yaml 的取值(旧测试用的是同值的默认值),按需改某一项。"""
config = CeusConfig.model_validate(load_ceus_section())
return config.keyframes.model_copy(update=overrides)
def _square_prompt(side: int, canvas: int = 100) -> ColorPrompt:
"""构造一个以原点为左上角、边长 ``side`` 的实心方块绿框 ``ColorPrompt``。
mask 为 ``(canvas, canvas)`` 布尔图,``[0:side, 0:side]`` 为 True;
其余几何字段按方块几何自洽填充,仅 mask 参与关键帧 IoU 判定。
"""
mask = np.zeros((canvas, canvas), dtype=bool)
mask[0:side, 0:side] = True
contour = np.array(
[[[0, 0]], [[side - 1, 0]], [[side - 1, side - 1]], [[0, side - 1]]],
dtype=np.int32,
)
return ColorPrompt(
mask=mask,
contour=contour,
bbox_xyxy=(0, 0, side - 1, side - 1),
bbox_area=side * side,
contour_area=float((side - 1) * (side - 1)),
candidate_count=1,
)
def _obs(frame_index: int, side: int | None) -> PromptObservation:
"""构造单帧观测:``side`` 为 None 表示无绿帧(prompt=None)。"""
prompt = None if side is None else _square_prompt(side)
return PromptObservation(frame_index=frame_index, prompt=prompt)
def test_first_detected_frame_is_forced_prompt_frame_with_change_iou_one() -> None:
"""首个有绿框的观测必为 prompt_frame,change_iou 恒记 1.0。"""
observations = [_obs(0, 40)]
prompt_frames = select_prompt_frames(observations, _keyframes_cfg())
assert len(prompt_frames) == 1
assert prompt_frames[0].frame_index == 0
assert prompt_frames[0].change_iou == 1.0
def test_unchanged_shape_does_not_add_prompt_frame() -> None:
"""绿框形状完全不变(IoU=1.0 ≥ 阈值)的连续帧不新增 prompt_frame。"""
observations = [_obs(i, 40) for i in range(5)]
prompt_frames = select_prompt_frames(observations, _keyframes_cfg())
assert len(prompt_frames) == 1
assert prompt_frames[0].frame_index == 0
def test_iou_below_threshold_records_new_prompt_frame() -> None:
"""IoU 跌破阈值的帧记为新 prompt_frame,change_iou 等于实测 IoU。
边长 40→20 的方块:IoU = 20² / 40² = 0.25 < 0.92。
"""
observations = [_obs(0, 40), _obs(1, 20)]
prompt_frames = select_prompt_frames(observations, _keyframes_cfg())
assert [kf.frame_index for kf in prompt_frames] == [0, 1]
assert prompt_frames[1].change_iou == 0.25
def test_iou_equal_to_threshold_does_not_record_boundary() -> None:
"""恰好 IoU == 阈值不记新 prompt_frame(严格 ``<``,钉死 < 非 <=)。
取 iou_threshold = IoU(40, 20) = 0.25。第二帧 IoU 恰等于 0.25,
``0.25 < 0.25`` 为 False,故不新增。
"""
config = _keyframes_cfg(iou_threshold=0.25)
observations = [_obs(0, 40), _obs(1, 20)]
prompt_frames = select_prompt_frames(observations, config)
assert [kf.frame_index for kf in prompt_frames] == [0]
def test_min_gap_frames_suppresses_dense_prompt_frames() -> None:
"""``min_gap_frames`` 在 IoU 判定前门控,抑制过密 prompt_frame。
三帧 40→20→40:默认 min_gap=1 时帧 1、帧 2 都会被记(IoU 均 0.25<0.92);
设 min_gap=2 后,帧 1 距首帧仅 1 < 2 被跳过,帧 2 距首帧 2 不小于 2 才比较,
其 IoU(40,40)=1.0 ≥ 0.92 故也不记,最终仅保留首帧。
"""
observations = [_obs(0, 40), _obs(1, 20), _obs(2, 40)]
dense = select_prompt_frames(observations, _keyframes_cfg())
assert [kf.frame_index for kf in dense] == [0, 1, 2]
sparse = select_prompt_frames(observations, _keyframes_cfg(min_gap_frames=2))
assert [kf.frame_index for kf in sparse] == [0]
def test_no_green_frames_are_skipped() -> None:
"""无绿帧(prompt=None)跳过,不占用首帧强制位也不影响 frame_index 对齐。"""
observations = [_obs(0, None), _obs(1, None), _obs(2, 40), _obs(3, 20)]
prompt_frames = select_prompt_frames(observations, _keyframes_cfg())
assert [kf.frame_index for kf in prompt_frames] == [2, 3]
# 首个有绿帧(frame 2)才是强制 prompt_frame,change_iou=1.0
assert prompt_frames[0].change_iou == 1.0
def test_prompt_frame_result_structure_fields() -> None:
"""返回项含 frame_index / bbox_xyxy / change_iou,并保留几何溯源字段。"""
observations = [_obs(7, 40)]
prompt_frames = select_prompt_frames(observations, _keyframes_cfg())
prompt_frame = prompt_frames[0]
assert isinstance(prompt_frame, PromptFrame)
assert prompt_frame.frame_index == 7
assert prompt_frame.bbox_xyxy == (0, 0, 39, 39)
assert prompt_frame.change_iou == 1.0
assert prompt_frame.bbox_area == 1600
assert prompt_frame.mask.dtype == bool
def test_empty_observations_returns_empty() -> None:
"""空观测序列返回空 prompt_frame 列表(无绿帧可选)。"""
assert select_prompt_frames([], _keyframes_cfg()) == []