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:
@@ -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)
|
||||
Reference in New Issue
Block a user