9a916c65a5
任务书 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>
131 lines
5.0 KiB
Python
131 lines
5.0 KiB
Python
"""B2 split_screen 分屏检测单元测试(旧文件的黑边剔除部分随裁黑边功能删去)。
|
||
|
||
钉死两件事的关键性质:
|
||
- ``detect_split_x``:合成左右两半(中间一列黑缝)→ 切分线落在中缝 ±2px;
|
||
全黑 / 无缝 / 切分偏出中部合理范围 → ``SplitDetectionError``(不兜底)。
|
||
- ``view_rect``:horizontal_split 用检测到的 ``split_x`` 而非 ``W//2``;
|
||
vertical_split / full / manual_crop 分支与参考一致;非法组合 ``RuntimeError``。
|
||
|
||
测试用真实数据的二次构造(合成左右分屏帧),边缘场景靠实现 raise 保证。
|
||
"""
|
||
|
||
import numpy as np
|
||
import pytest
|
||
|
||
from cadx.ceus import (
|
||
SplitDetectionError,
|
||
ViewRect,
|
||
detect_split_x,
|
||
view_rect,
|
||
)
|
||
from conftest import load_ceus_section
|
||
|
||
# 旧 detect_split_x 的参数默认值;新代码不设默认值,从 config/ceus.yaml 读。
|
||
_LO, _HI = load_ceus_section()["views"]["split_search_band"]
|
||
BAND = (float(_LO), float(_HI))
|
||
|
||
|
||
def _split_frame(
|
||
height: int,
|
||
width: int,
|
||
split_x: int,
|
||
*,
|
||
left_gray: int = 180,
|
||
right_gray: int = 120,
|
||
) -> np.ndarray:
|
||
"""合成一帧左右分屏 BGR 图:左右两半各为常量灰度,``split_x`` 处一列纯黑缝。"""
|
||
frame = np.zeros((height, width, 3), dtype=np.uint8)
|
||
frame[:, :split_x] = left_gray
|
||
frame[:, split_x + 1 :] = right_gray
|
||
frame[:, split_x] = 0
|
||
return frame
|
||
|
||
|
||
# Phase 1: detect_split_x —— 中缝定位与防御
|
||
def test_detect_split_x_finds_centered_seam() -> None:
|
||
frames = [_split_frame(120, 200, 100) for _ in range(5)]
|
||
detected = detect_split_x(frames, search_band=BAND)
|
||
assert abs(detected - 100) <= 2
|
||
|
||
|
||
def test_detect_split_x_finds_offcenter_seam_within_band() -> None:
|
||
# 缝在 0.42*W 处,仍落在 band=(0.35,0.65) 内、左右宽度比合法。
|
||
frames = [_split_frame(120, 200, 84) for _ in range(5)]
|
||
detected = detect_split_x(frames, search_band=BAND)
|
||
assert abs(detected - 84) <= 2
|
||
|
||
|
||
def test_detect_split_x_all_black_raises() -> None:
|
||
frames = [np.zeros((120, 200, 3), dtype=np.uint8) for _ in range(3)]
|
||
with pytest.raises(SplitDetectionError):
|
||
detect_split_x(frames, search_band=BAND)
|
||
|
||
|
||
def test_detect_split_x_no_seam_raises() -> None:
|
||
# 整幅均匀灰度、无暗缝:中部 band 内列均值平坦,找不到稳定低谷。
|
||
frames = [np.full((120, 200, 3), 150, dtype=np.uint8) for _ in range(3)]
|
||
with pytest.raises(SplitDetectionError):
|
||
detect_split_x(frames, search_band=BAND)
|
||
|
||
|
||
def test_detect_split_x_empty_frames_raises() -> None:
|
||
with pytest.raises(SplitDetectionError):
|
||
detect_split_x([], search_band=BAND)
|
||
|
||
|
||
def test_detect_split_x_reversed_band_raises() -> None:
|
||
# 逆序 band(lo>=hi)非法:要求 0<=lo<hi<=1,应直接报错而非兜底。
|
||
frames = [_split_frame(120, 200, 100) for _ in range(3)]
|
||
with pytest.raises(SplitDetectionError):
|
||
detect_split_x(frames, search_band=(0.6, 0.4))
|
||
|
||
|
||
def test_detect_split_x_out_of_range_band_raises() -> None:
|
||
# 越界 band(hi>1)非法。
|
||
frames = [_split_frame(120, 200, 100) for _ in range(3)]
|
||
with pytest.raises(SplitDetectionError):
|
||
detect_split_x(frames, search_band=(0.35, 1.5))
|
||
|
||
|
||
def test_detect_split_x_width_ratio_violation_raises() -> None:
|
||
# 把 band 放宽到几乎整宽,缝置于极偏位置(仍在 band 内),使左右宽度比越界。
|
||
# split_x=20、W=200 → 左 20 / 右 180,比值 0.11 < 0.5 → SplitDetectionError。
|
||
frames = [_split_frame(120, 200, 20) for _ in range(3)]
|
||
with pytest.raises(SplitDetectionError):
|
||
detect_split_x(frames, search_band=(0.05, 0.95))
|
||
|
||
|
||
# Phase 2: view_rect —— horizontal_split 用 split_x,而非 W//2
|
||
def test_view_rect_horizontal_split_uses_split_x() -> None:
|
||
width, height, split_x = 200, 120, 84
|
||
left = view_rect({"layout": "horizontal_split"}, "left", width, height, split_x=split_x)
|
||
right = view_rect({"layout": "horizontal_split"}, "right", width, height, split_x=split_x)
|
||
assert left == ViewRect(0, 0, split_x, height)
|
||
assert right == ViewRect(split_x, 0, width, height)
|
||
# 与朴素 W//2 不同,证明用了检测值。
|
||
assert split_x != width // 2
|
||
|
||
|
||
def test_view_rect_vertical_split() -> None:
|
||
width, height = 200, 120
|
||
top = view_rect({"layout": "vertical_split"}, "top", width, height, split_x=None)
|
||
bottom = view_rect({"layout": "vertical_split"}, "bottom", width, height, split_x=None)
|
||
assert top == ViewRect(0, 0, width, height // 2)
|
||
assert bottom == ViewRect(0, height // 2, width, height)
|
||
|
||
|
||
def test_view_rect_full() -> None:
|
||
rect = view_rect({"layout": "full"}, "left", 200, 120, split_x=None)
|
||
assert rect == ViewRect(0, 0, 200, 120)
|
||
|
||
|
||
def test_view_rect_manual_crop() -> None:
|
||
cfg = {"layout": "manual_crop", "crops": {"left": [0.0, 0.0, 0.5, 1.0]}}
|
||
rect = view_rect(cfg, "left", 200, 120, split_x=None)
|
||
assert rect == ViewRect(0, 0, 100, 120)
|
||
|
||
|
||
def test_view_rect_unsupported_pair_raises() -> None:
|
||
with pytest.raises(RuntimeError):
|
||
view_rect({"layout": "horizontal_split"}, "top", 200, 120, split_x=100)
|