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:
@@ -0,0 +1,179 @@
|
||||
"""两半画面之间的坐标换算:平移量 = 目标视图左上角 − 造影视图左上角(任务书 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
|
||||
Reference in New Issue
Block a user