feat(ceus): 两半坐标换算改为每段视频配准,加目视核对脚本

- 第 1 步分屏后在两半扇形区内用边缘图归一化互相关估 (dx, dy),再用逐帧多块模板匹配的中位数独立核对;
  两者各分量之差超过门槛(15 像素)这一例第 1 步失败。删掉「两个视图左上角相减」的实现与测试。
- 门槛依据:34 例中走完第 1 步的 29 例,差值中位 2、95 分位 6.2、最大 13;参数与门槛在同一批视频上调出。
- scripts/ceus_align_review.py:只做第 1 步,出扇形区棋盘图与边缘叠加图,混入错开 15–30 像素的
  阴性对照、随机编号;计分时任一对照未被判「没对齐」即作废。
- ceus_quant 读 review.csv:不通过的病例第 1 步失败;表里没有这段视频或平移对不上即停。
- 配准与紫圈颜色参数进分割缓存键。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 06:47:27 -04:00
parent c582fe426f
commit 00ebbffb28
20 changed files with 1500 additions and 277 deletions
+65 -72
View File
@@ -1,7 +1,8 @@
"""两半画面之间的坐标换算:平移量 = 目标视图左上角 − 造影视图左上角(任务书 02 第一阶段第 1 条)。
"""两半画面之间的坐标换算:造影那一半局部坐标 + 配准平移 (dx, dy) = 另一半局部坐标。
后两条照搬旧 test_ceus_mapping(去掉锚点配置参数,断言不变);其余是新写的手算测试。
旧文件里依赖锚点、有效区或缩放的用例不再成立,已删(见任务书 02 执行报告)。
前两条照搬旧 test_ceus_mapping(去掉锚点配置参数,平移取 (0, 0),断言不变);其余是新写的手算
测试,平移量直接给定(怎么估平移见 test_ceus_registration)。旧文件里依赖锚点、有效区或缩放的
用例不再成立,已删(见任务书 02 执行报告)。
"""
import cv2
@@ -9,15 +10,11 @@ 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:
@@ -49,14 +46,13 @@ def _bbox(points: np.ndarray) -> tuple[float, float, float, float]:
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)
# source view = 整个左半区 [0,100)×[0,100);有效区同 view(无黑边),平移 (0, 0)。
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)
target_mask = map_source_mask_to_target(source_mask, (0, 0), target_eff)
assert target_mask.dtype == bool
assert target_mask.shape == (target_eff.height, target_eff.width)
@@ -83,97 +79,94 @@ def test_real_roundtrip_same_size_no_offset_restores_polygon() -> None:
# 正向: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)
target_mask = map_source_mask_to_target(source_mask, (0, 0), 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)
[back_to_source] = target_polygons_to_source([target_polygon], (0, 0), source_view)
# 形状/包围盒近似还原(栅格化+轮廓量化误差 ≤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"),
("offset", "expected_x", "expected_y"),
[
# 造影在左半:整帧 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),
# 局部方块 x 300..339、y 50..79。平移 (−37, 4):x 263..302、y 54..83。
((-37, 4), (263, 302), (54, 83)),
# 平移 (25, −6):x 325..364、y 44..73。
((25, -6), (325, 364), (44, 73)),
# 平移 (0, 0):原位不动。
((0, 0), (300, 339), (50, 79)),
],
)
def test_mask_moves_by_view_offset_in_frame_coordinates(
width: int, split_x: int, box_x1: int, expected_target_x1: int
def test_mask_moves_by_offset(
offset: tuple[int, int], expected_x: tuple[int, int], expected_y: tuple[int, 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 = ViewRect(640, 0, 1280, 200)
source_mask = np.zeros((200, 640), dtype=bool)
source_mask[50:80, 300:340] = True
target_mask = map_source_mask_to_target(source_mask, left, right)
target_mask = map_source_mask_to_target(source_mask, offset, target)
assert target_mask.shape == (200, 640)
ys, xs = np.where(target_mask)
assert (int(xs.min()), int(xs.max())) == expected_x
assert (int(ys.min()), int(ys.max())) == expected_y
def test_mask_crops_what_leaves_target_view() -> None:
"""平移 (−310, 0):局部 x 300..339 落到 −10..29,出界的 10 列丢掉,不缩放。"""
target = ViewRect(0, 0, 640, 200)
source_mask = np.zeros((200, 640), dtype=bool)
source_mask[10:20, 300:340] = True
target_mask = map_source_mask_to_target(source_mask, (-310, 0), target)
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(xs.min()), int(xs.max())) == (0, 29)
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)
def test_polygons_move_back_by_negative_offset() -> None:
"""平移 (25, 4):目标局部 (40, 30) 挪回 (15, 26);超出造影视图的点裁到边界。"""
source = ViewRect(0, 0, 928, 200)
polygon = np.array([[40.0, 30.0], [60.0, 30.0], [10.0, 250.0]], dtype=np.float32)
[back] = target_polygons_to_source([polygon], left, right)
[back] = target_polygons_to_source([polygon], (25, 4), source)
np.testing.assert_allclose(back, [[40.0, 30.0], [60.0, 30.0], [927.0, 199.0]])
np.testing.assert_allclose(back, [[15.0, 26.0], [35.0, 26.0], [0.0, 199.0]])
def test_mask_crops_when_target_half_is_narrower() -> None:
"""宽度为奇数时右半比左半窄 1 像素:不缩放,超出右半的那一列裁掉。"""
left = ViewRect(0, 0, 101, 50)
"""宽度为奇数时右半比左半窄 1 像素:平移 (0, 0),不缩放,超出右半的那一列裁掉。"""
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)
target_mask = map_source_mask_to_target(source_mask, (0, 0), right)
assert target_mask.shape == (50, 100)
assert np.where(target_mask)[1].tolist() == [99] * 50
def test_real_roundtrip_with_offset_restores_polygon() -> None:
"""平移 (−37, 4) 下真往返:多边形 → 栅格化 → 正向 → 提轮廓 → 逆向,近似还原(误差 ≤2px)。"""
source_view = ViewRect(0, 0, 200, 150)
target_view = ViewRect(200, 0, 400, 150)
source_polygon = np.array(
[[60.0, 30.0], [120.0, 25.0], [130.0, 90.0], [70.0, 85.0]], dtype=np.float32
)
source_mask = polygon_to_mask(source_polygon, source_view.height, source_view.width)
target_mask = map_source_mask_to_target(source_mask, (-37, 4), target_view)
target_polygon = _largest_external_contour(target_mask)
np.testing.assert_allclose(
_bbox(target_polygon),
np.array(_bbox(source_polygon)) + np.array([-37, -37, 4, 4]),
atol=2.0,
)
[back] = target_polygons_to_source([target_polygon], (-37, 4), source_view)
np.testing.assert_allclose(_bbox(back), _bbox(source_polygon), atol=2.0)