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

180 lines
7.5 KiB
Python
Raw 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.
"""两半画面之间的坐标换算:平移量 = 目标视图左上角 − 造影视图左上角(任务书 02 第一阶段第 1 条)。
后两条照搬旧 test_ceus_mapping(去掉锚点配置参数,断言不变);其余是新写的手算测试。
旧文件里依赖锚点、有效区或缩放的用例不再成立,已删(见任务书 02 执行报告)。
"""
import cv2
import numpy as np
import pytest
from cadx.ceus import (
CeusConfig,
ViewRect,
map_source_mask_to_target,
polygon_to_mask,
split_views,
target_polygons_to_source,
view_offset,
)
from conftest import load_ceus_section
def _largest_external_contour(mask: np.ndarray) -> np.ndarray:
"""从二值 mask 取面积最大的外轮廓,返回 ``(N, 2)`` 的 ``float32`` 顶点序列。
参数:
mask: ``dtype=bool`` 的二值 mask。
返回:
最大外轮廓顶点 ``(x, y)``(``float32``);用于真往返中把 target mask 还原成多边形喂逆向。
"""
contours, _ = cv2.findContours(
mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
)
assert contours, "target mask 应非空,才能提取外轮廓做逆向往返。"
largest = max(contours, key=cv2.contourArea)
return largest.reshape(-1, 2).astype(np.float32)
def _bbox(points: np.ndarray) -> tuple[float, float, float, float]:
"""返回顶点序列的包围盒 ``(x_min, x_max, y_min, y_max)``(往返形状比对用)。"""
return (
float(points[:, 0].min()),
float(points[:, 0].max()),
float(points[:, 1].min()),
float(points[:, 1].max()),
)
def test_map_source_mask_to_target_translates_into_target_effective() -> None:
"""source 视图局部 mask 经映射落入 target 有效区局部坐标,形状为 target_eff 尺寸。"""
# source view = 整个左半区 [0,100)×[0,100);有效区同 view(无黑边)。
source_view = ViewRect(0, 0, 100, 100)
target_eff = ViewRect(100, 0, 200, 100)
source_mask = np.zeros((100, 100), dtype=bool)
source_mask[30:50, 20:40] = True # source 局部一个方块
target_mask = map_source_mask_to_target(source_mask, source_view, target_eff)
assert target_mask.dtype == bool
assert target_mask.shape == (target_eff.height, target_eff.width)
# 同尺寸有效区 + 无偏移:target 局部方块应与 source 局部方块同位置。
ys, xs = np.where(target_mask)
assert int(xs.min()) == 20 and int(xs.max()) == 39
assert int(ys.min()) == 30 and int(ys.max()) == 49
def test_real_roundtrip_same_size_no_offset_restores_polygon() -> None:
"""真往返(同尺寸有效区、无 offset):多边形→栅格化→正向→提轮廓→逆向,近似还原。
不再假造 target 多边形:从正向 ``map_source_mask_to_target`` 的真实输出 mask 里
用 ``cv2.findContours`` 提最大外轮廓喂逆向,端到端串起正向(mask)→逆向(polygon)。
栅格化 + 轮廓提取带量化误差,容差放宽到 ~2px,但 assert 包围盒近似还原。
"""
source_view = ViewRect(0, 0, 120, 100)
target_eff = ViewRect(120, 0, 240, 100)
# 已知 source 视图局部多边形(远离边界,避免 clip 干扰往返)。
source_polygon = np.array(
[[20.0, 30.0], [60.0, 25.0], [70.0, 70.0], [25.0, 65.0]], dtype=np.float32
)
# 正向:source 局部多边形 → 栅格化 mask → 映射到 target 有效区局部 mask。
source_mask = polygon_to_mask(source_polygon, source_view.height, source_view.width)
target_mask = map_source_mask_to_target(source_mask, source_view, target_eff)
assert target_mask.sum() > 0
# 从真实 target mask 提最大外轮廓得 target 多边形,再逆向映回 source 视图局部。
target_polygon = _largest_external_contour(target_mask)
[back_to_source] = target_polygons_to_source([target_polygon], source_view, target_eff)
# 形状/包围盒近似还原(栅格化+轮廓量化误差 ≤2px)。
np.testing.assert_allclose(_bbox(back_to_source), _bbox(source_polygon), atol=2.0)
def _seam_frames(width: int, height: int, split_x: int) -> list[np.ndarray]:
"""合成几帧左右分屏画面:两半是不同的常量灰度,`split_x` 处一列纯黑缝。"""
frame = np.zeros((height, width, 3), dtype=np.uint8)
frame[:, :split_x] = 180
frame[:, split_x + 1 :] = 120
return [frame.copy() for _ in range(5)]
# 三种宽度(40 例里有 5 种),分屏线在正中:右半左上角 x = W/2,平移量手算为 (W/2, 0)。
@pytest.mark.parametrize(
("width", "expected_dx"),
[(1856, 928), (1280, 640), (1024, 512)],
)
def test_view_offset_is_difference_of_view_top_left_corners(width: int, expected_dx: int) -> None:
config = CeusConfig.model_validate(load_ceus_section())
_, left, right = split_views(_seam_frames(width, 200, expected_dx), config.views)
assert view_offset(left, right) == (expected_dx, 0)
assert view_offset(right, left) == (-expected_dx, 0)
@pytest.mark.parametrize(
("width", "split_x", "box_x1", "expected_target_x1"),
[
# 造影在左半:整帧 x=300 的框挪到右半,整帧 x = 300 + 928 = 1228。
(1856, 928, 300, 1228),
# 造影在左半:整帧 x=300 → 300 + 640 = 940。
(1280, 640, 300, 940),
# 造影在左半:整帧 x=100 → 100 + 512 = 612。
(1024, 512, 100, 612),
],
)
def test_mask_moves_by_view_offset_in_frame_coordinates(
width: int, split_x: int, box_x1: int, expected_target_x1: int
) -> None:
left = ViewRect(0, 0, split_x, 200)
right = ViewRect(split_x, 0, width, 200)
source_mask = np.zeros((left.height, left.width), dtype=bool)
source_mask[50:80, box_x1 - left.x1 : box_x1 - left.x1 + 40] = True
target_mask = map_source_mask_to_target(source_mask, left, right)
ys, xs = np.where(target_mask)
assert int(xs.min()) + right.x1 == expected_target_x1
assert int(xs.max()) + right.x1 == expected_target_x1 + 39
assert (int(ys.min()), int(ys.max())) == (50, 79)
def test_mask_moves_from_right_half_to_left_half() -> None:
"""造影在右半:1856 宽、分屏线 928,整帧 x=1000 的框挪到左半,整帧 x = 1000 − 928 = 72。"""
left = ViewRect(0, 0, 928, 200)
right = ViewRect(928, 0, 1856, 200)
source_mask = np.zeros((right.height, right.width), dtype=bool)
source_mask[10:20, 1000 - 928 : 1040 - 928] = True
target_mask = map_source_mask_to_target(source_mask, right, left)
ys, xs = np.where(target_mask)
assert (int(xs.min()), int(xs.max())) == (72, 111)
assert (int(ys.min()), int(ys.max())) == (10, 19)
def test_polygons_move_back_by_negative_view_offset() -> None:
"""右半局部 (40, 30) 的点挪回左半:整帧 (968, 30) − 928 = (40, 30);超出左半的点裁到边界。"""
left = ViewRect(0, 0, 928, 200)
right = ViewRect(928, 0, 1856, 200)
polygon = np.array([[40.0, 30.0], [60.0, 30.0], [927.0, 250.0]], dtype=np.float32)
[back] = target_polygons_to_source([polygon], left, right)
np.testing.assert_allclose(back, [[40.0, 30.0], [60.0, 30.0], [927.0, 199.0]])
def test_mask_crops_when_target_half_is_narrower() -> None:
"""宽度为奇数时右半比左半窄 1 像素:不缩放,超出右半的那一列裁掉。"""
left = ViewRect(0, 0, 101, 50)
right = ViewRect(101, 0, 201, 50)
source_mask = np.zeros((50, 101), dtype=bool)
source_mask[:, 99:101] = True
target_mask = map_source_mask_to_target(source_mask, left, right)
assert target_mask.shape == (50, 100)
assert np.where(target_mask)[1].tolist() == [99] * 50