Files
CADx-4/tests/test_ceus_mapping.py
iomgaa 00ebbffb28 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>
2026-10-16 06:47:27 -04:00

173 lines
7.2 KiB
Python
Raw Permalink 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.
"""两半画面之间的坐标换算:造影那一半局部坐标 + 配准平移 (dx, dy) = 另一半局部坐标。
前两条照搬旧 test_ceus_mapping(去掉锚点配置参数,平移取 (0, 0),断言不变);其余是新写的手算
测试,平移量直接给定(怎么估平移见 test_ceus_registration)。旧文件里依赖锚点、有效区或缩放的
用例不再成立,已删(见任务书 02 执行报告)。
"""
import cv2
import numpy as np
import pytest
from cadx.ceus import (
ViewRect,
map_source_mask_to_target,
polygon_to_mask,
target_polygons_to_source,
)
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(无黑边),平移 (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, (0, 0), 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, (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], (0, 0), source_view)
# 形状/包围盒近似还原(栅格化+轮廓量化误差 ≤2px)。
np.testing.assert_allclose(_bbox(back_to_source), _bbox(source_polygon), atol=2.0)
@pytest.mark.parametrize(
("offset", "expected_x", "expected_y"),
[
# 局部方块 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_offset(
offset: tuple[int, int], expected_x: tuple[int, int], expected_y: tuple[int, int]
) -> None:
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, 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()), int(xs.max())) == (0, 29)
assert (int(ys.min()), int(ys.max())) == (10, 19)
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], (25, 4), source)
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 像素:平移 (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, (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)