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
+130
View File
@@ -0,0 +1,130 @@
"""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)