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
+92
View File
@@ -0,0 +1,92 @@
"""测试用合成分屏画面:左右两半各有一块带纹理的扇形区,右半内容是左半平移 `(dx, dy)` 后的样子。
模仿真实录像里配准要用到的几样东西:扇形区里的组织纹理随时间轻微起伏(扇形外的界面是静止的),
两半的成像亮度不同(右半扇形内做一次线性变换 `gain × 灰度 + bias`),各帧噪声独立。
- `right_local(x, y) = left_local(x − dx, y − dy)`:右半局部坐标 = 左半局部坐标 + (dx, dy);
- `interior_shift` 不为 None 时,右半扇形的轮廓仍按 `offset` 平移,里面的纹理却按 `interior_shift`
平移,用来造「扇形轮廓和组织纹理给出不同平移」的画面。
"""
from dataclasses import dataclass
import cv2
import numpy as np
import numpy.typing as npt
type Gray = npt.NDArray[np.float32]
OUTSIDE = 20.0 # 扇形外静止界面的灰度
BASE = 100.0 # 扇形内的平均灰度
@dataclass(frozen=True)
class SplitScene:
"""一幅分屏画面的静止部分:两半的灰度内容和扇形掩膜(各自局部坐标)。"""
left: Gray
right: Gray
left_fan: npt.NDArray[np.bool_]
right_fan: npt.NDArray[np.bool_]
def _fan(shape: tuple[int, int], polygon: list[tuple[int, int]], shift: tuple[int, int]) -> Gray:
points = np.asarray(polygon, dtype=np.int32) + np.asarray(shift, dtype=np.int32)[None, :]
mask = np.zeros(shape, dtype=np.uint8)
cv2.fillConvexPoly(mask, points, 1)
return mask.astype(np.float32)
def make_scene(
*,
height: int,
half_width: int,
fan_polygon: list[tuple[int, int]],
offset: tuple[int, int],
interior_shift: tuple[int, int] | None,
texture_std: float,
right_gain: float,
right_bias: float,
seed: int,
) -> SplitScene:
"""左半扇形取 `fan_polygon`(左半局部坐标),右半按 `offset` 平移;纹理是平滑过的随机场。"""
rng = np.random.default_rng(seed)
margin = 64
canvas = cv2.GaussianBlur(
rng.normal(0.0, 1.0, (height + 2 * margin, half_width + 2 * margin)).astype(np.float32),
(0, 0),
4.0,
)
canvas *= texture_std / float(canvas.std())
shape = (height, half_width)
left_texture = canvas[margin : margin + height, margin : margin + half_width]
tdx, tdy = offset if interior_shift is None else interior_shift
right_texture = canvas[
margin - tdy : margin - tdy + height, margin - tdx : margin - tdx + half_width
]
left_fan = _fan(shape, fan_polygon, (0, 0))
right_fan = _fan(shape, fan_polygon, offset)
left = OUTSIDE + left_fan * (BASE - OUTSIDE + left_texture)
# 右半是另一种成像:同一组织,亮度做一次线性变换。
right = OUTSIDE + right_fan * (right_gain * (BASE - OUTSIDE + right_texture) + right_bias)
return SplitScene(
left=left.astype(np.float32),
right=right.astype(np.float32),
left_fan=left_fan > 0,
right_fan=right_fan > 0,
)
def scene_gray(scene: SplitScene, index: int, rng: np.random.Generator) -> Gray:
"""第 `index` 帧的整帧灰度(左右拼接):扇形内加缓慢起伏和独立噪声,扇形外不动。"""
wave = 10.0 * np.sin(2 * np.pi * index / 7.0)
halves: list[Gray] = []
for content, fan in ((scene.left, scene.left_fan), (scene.right, scene.right_fan)):
noise = rng.normal(0.0, 3.0, content.shape).astype(np.float32)
halves.append(content + fan * (wave + noise))
return np.hstack(halves)
def to_bgr(gray: Gray) -> npt.NDArray[np.uint8]:
"""灰度裁到 0–255 转成三通道 uint8。"""
return np.repeat(np.clip(gray, 0, 255).astype(np.uint8)[:, :, None], 3, axis=2)