Files
CADx-4/src/cadx/ceus/_registration.py
T
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

278 lines
13 KiB
Python
Raw 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.
"""第 1 步的两半配准:估出左半局部坐标到右半局部坐标的平移 `(dx, dy)`。
两半是同一个探头视野的两路成像(造影和灰阶),大小相同,但扇形在各自那一半里的位置不同:
造影那一半的扇形左边常有一段黑边,灰阶那一半的扇形贴着分屏线,而且同一帧宽的两段视频错位也
可能不同。所以每段视频单独配准,不写死、也不用两个视图左上角相减(任务书 02 第一阶段第 1 条)。
做法:均匀抽若干帧,两半各自求逐像素的时间平均灰度和时间标准差。
1. 扇形区:时间标准差高于阈值的像素(画面在动的地方;文字、色标、刻度这些静止的界面元素
标准差近 0),去掉顶部信息栏那几行,开运算断开细连接,取最大连通域的凸包。
2. 绿框、紫圈的颜色(`prompt_source`、`pan_source` 的 HSV 范围)在任一抽样帧出现过的像素
膨胀后用周围像素修补掉:它们只画在一半上,会被当成边缘或纹理去匹配。
3. 方法一(互相关,结果取它):两半的边缘图(高斯平滑后的梯度幅值,只留扇形区和它的边)做
归一化互相关,在搜索范围内穷举平移取最大。扇形的轮廓和组织边界都参与。
4. 方法二(模板匹配,只用来核对):在左半扇形内按网格定若干块,每一抽样帧上各切一次平滑后的
灰度,在右半搜索范围内、块中心落在右半扇形内的位置里找最相似的(归一化相关系数),全部
匹配的平移各分量取中位数。它用的是逐帧灰度纹理、局部块和中位数,不用边缘和扇形轮廓,和
方法一相互独立。
5. 两种估计各分量之差的最大值(切比雪夫距离:水平、竖直各自都不许错开超过门槛)超过门槛,
这一例第 1 步失败(`RegistrationError`)。
只做平移,不做缩放和旋转。
"""
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Literal
import cv2
import numpy as np
import numpy.typing as npt
from cadx.ceus._config import PanSourceConfig, PromptSourceConfig, RegistrationConfig
from cadx.ceus._errors import CeusCaseError
from cadx.ceus._geometry import Frame, Mask, ViewRect, crop_frame
from cadx.ceus._mapping import Offset
from cadx.ceus._split_screen import sample_frames_for_split
REGISTRATION_STEP = 1
type _Gray = npt.NDArray[np.float32]
class RegistrationError(CeusCaseError):
"""两半配准失败:找不到扇形区、可用的模板块不够,或两种方法估出的平移不一致。"""
def __init__(self, reason: str) -> None:
super().__init__(REGISTRATION_STEP, reason)
@dataclass(frozen=True)
class TemplatePatch:
"""方法二的一次匹配:模板块在左半局部坐标的左上角、第几张抽样帧、在右半找到的平移、相关系数。"""
x: int
y: int
frame: int
offset: Offset
score: float
@dataclass(frozen=True)
class HalfRegistration:
"""左半局部坐标 → 右半局部坐标的平移:`右半局部 = 左半局部 + (dx, dy)`。
`correlation` 是方法一(边缘图互相关)的估计,采用它;`correlation_score` 是它的峰值;
`template` 是方法二(多块模板逐帧匹配取中位数)的估计,`patches` 是每次匹配的明细;
`disagreement_px` 是两者各分量之差的最大值。
"""
correlation: Offset
correlation_score: float
template: Offset
patches: tuple[TemplatePatch, ...]
disagreement_px: int
def contrast_to_target(self, contrast_side: Literal["left", "right"]) -> Offset:
"""造影那一半局部坐标 → 另一半局部坐标的平移。"""
dx, dy = self.correlation
if contrast_side == "left":
return (dx, dy)
return (-dx, -dy)
@dataclass(frozen=True)
class _Half:
"""一半画面的配准素材:扇形区掩膜,修补绿框紫圈并平滑后的时间平均灰度和逐帧灰度。"""
fan: Mask
mean: _Gray
frames: list[_Gray]
def color_marks(
frames: Sequence[Frame], prompt_cfg: PromptSourceConfig, pan_cfg: PanSourceConfig, dilate: int
) -> Mask:
"""任一帧里落在绿框或紫圈 HSV 范围内的像素,膨胀 `dilate` 像素。"""
marks = np.zeros(frames[0].shape[:2], dtype=np.uint8)
for frame in frames:
hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
for cfg in (prompt_cfg, pan_cfg):
lower = np.asarray(cfg.hsv_lower, dtype=np.uint8)
upper = np.asarray(cfg.hsv_upper, dtype=np.uint8)
marks |= np.asarray(cv2.inRange(hsv, lower, upper), dtype=np.uint8)
if dilate > 0:
kernel = np.ones((2 * dilate + 1, 2 * dilate + 1), dtype=np.uint8)
marks = np.asarray(cv2.dilate(marks, kernel), dtype=np.uint8)
return marks > 0
def fan_mask(std: _Gray, cfg: RegistrationConfig) -> Mask:
"""扇形区:时间标准差高于阈值、去掉顶部信息栏、开运算后最大连通域的凸包。"""
moving = (std > cfg.fan_std_threshold).astype(np.uint8)
moving[: round(cfg.top_ignore_ratio * std.shape[0])] = 0
size = cfg.fan_open_kernel
moving = cv2.morphologyEx(moving, cv2.MORPH_OPEN, np.ones((size, size), dtype=np.uint8))
count, labels, stats, _ = cv2.connectedComponentsWithStats(moving, connectivity=8)
if count < 2:
raise RegistrationError("这一半画面里找不到扇形区(没有随时间变化的区域)")
largest = 1 + int(np.argmax(stats[1:, cv2.CC_STAT_AREA]))
component = np.asarray(labels, dtype=np.int32) == largest
points = np.argwhere(component)[:, ::-1].astype(np.int32) # (x, y)
hull = np.asarray(cv2.convexHull(points), dtype=np.int32)
filled = np.zeros(std.shape, dtype=np.uint8)
cv2.fillConvexPoly(filled, hull, 1)
return filled > 0
def _repair_smooth(gray: _Gray, marks: Mask, sigma: float) -> _Gray:
"""绿框紫圈像素用周围像素修补(Telea 法,半径 3),再高斯平滑。"""
repaired = cv2.inpaint(
np.clip(gray, 0, 255).astype(np.uint8), marks.astype(np.uint8), 3, cv2.INPAINT_TELEA
)
return np.asarray(cv2.GaussianBlur(repaired.astype(np.float32), (0, 0), sigma), np.float32)
def _prepare_half(gray: _Gray, view: ViewRect, marks: Mask, cfg: RegistrationConfig) -> _Half:
"""从整帧灰度栈 `(N, H, W)` 切出一半,求扇形区、平均灰度和逐帧灰度。"""
stack = gray[:, view.y1 : view.y2, view.x1 : view.x2]
local_marks = crop_frame(marks, view)
return _Half(
fan=fan_mask(stack.std(axis=0), cfg),
mean=_repair_smooth(stack.mean(axis=0), local_marks, cfg.blur_sigma),
frames=[_repair_smooth(frame, local_marks, cfg.blur_sigma) for frame in stack],
)
def _edge_map(half: _Half, sigma: float) -> _Gray:
"""时间平均灰度的梯度幅值,只留扇形区和它的边(扇形掩膜外扩 3σ,轮廓上的梯度也算进来)。"""
gx = cv2.Sobel(half.mean, cv2.CV_32F, 1, 0)
gy = cv2.Sobel(half.mean, cv2.CV_32F, 0, 1)
margin = int(np.ceil(3 * sigma))
kernel = np.ones((2 * margin + 1, 2 * margin + 1), dtype=np.uint8)
support = cv2.dilate(half.fan.astype(np.uint8), kernel) > 0
edges = np.hypot(gx, gy).astype(np.float32)
edges[~support] = 0
return edges
def _bbox(mask: Mask) -> tuple[int, int, int, int]:
"""掩膜的外接框 `(x1, y1, x2, y2)`,右下不含。"""
ys, xs = np.nonzero(mask)
return int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1
def _correlate_edges(
left: _Half, right: _Half, search: Offset, sigma: float
) -> tuple[Offset, float]:
"""方法一:左半边缘图(裁到扇形外接框)在补零后的右半边缘图上做归一化互相关。"""
sx, sy = search
left_edges = _edge_map(left, sigma)
right_edges = _edge_map(right, sigma)
x1, y1, x2, y2 = _bbox(left_edges > 0)
template = left_edges[y1:y2, x1:x2]
height, width = left_edges.shape
# 右半四周补零,使平移 dx ∈ [−sx, sx]、dy ∈ [−sy, sy] 都落在结果图里;右、下多补一个左半大小,
# 右半比左半窄几个像素时也够。
padded = cv2.copyMakeBorder(
right_edges, sy, sy + height, sx, sx + width, cv2.BORDER_CONSTANT, value=0.0
)
scores = cv2.matchTemplate(padded, template, cv2.TM_CCORR_NORMED)
# 结果图里位置 (u, v) 对应平移 dx = u − sx − x1、dy = v − sy − y1。
window = scores[y1 : y1 + 2 * sy + 1, x1 : x1 + 2 * sx + 1]
_, best, _, (u, v) = cv2.minMaxLoc(window)
return (u - sx, v - sy), float(best)
def _match_templates(
left: _Half, right: _Half, search: Offset, cfg: RegistrationConfig
) -> tuple[TemplatePatch, ...]:
"""方法二:左半扇形外接框上按网格切块,整块都在扇形内的才用;每块在每一抽样帧上各找一次。
逐帧匹配而不是在时间平均图上匹配:探头在视频里移动时,平均图上的组织纹理糊掉了。
"""
sx, sy = search
rows, cols = cfg.template_grid
side = round(cfg.template_size_ratio * left.fan.shape[1])
x1, y1, x2, y2 = _bbox(left.fan)
right_h, right_w = right.fan.shape
patches: list[TemplatePatch] = []
for row in range(rows):
for col in range(cols):
px = round(x1 + (col + 0.5) * (x2 - x1) / cols - side / 2)
py = round(y1 + (row + 0.5) * (y2 - y1) / rows - side / 2)
inside_view = px >= 0 and py >= 0 and px + side <= left.fan.shape[1]
inside_view = inside_view and py + side <= left.fan.shape[0]
if not inside_view or not left.fan[py : py + side, px : px + side].all():
continue
wx1, wy1 = max(0, px - sx), max(0, py - sy)
wx2, wy2 = min(right_w, px + side + sx), min(right_h, py + side + sy)
if wx2 - wx1 < side or wy2 - wy1 < side:
continue
# 只认块中心落在右半扇形内的位置:扇形外是纯黑,零方差窗口的相关系数 OpenCV 记为 1。
half = side // 2
centers = right.fan[
wy1 + half : wy2 - side + half + 1, wx1 + half : wx2 - side + half + 1
]
if not centers.any():
continue
for index, (left_frame, right_frame) in enumerate(
zip(left.frames, right.frames, strict=True)
):
template = left_frame[py : py + side, px : px + side]
scores = cv2.matchTemplate(
right_frame[wy1:wy2, wx1:wx2], template, cv2.TM_CCOEFF_NORMED
)
scores[~centers] = -1.0
_, best, _, (u, v) = cv2.minMaxLoc(scores)
offset = (wx1 + u - px, wy1 + v - py)
patches.append(TemplatePatch(px, py, index, offset, float(best)))
return tuple(patches)
def register_halves(
frames: Sequence[Frame],
left_view: ViewRect,
right_view: ViewRect,
cfg: RegistrationConfig,
prompt_cfg: PromptSourceConfig,
pan_cfg: PanSourceConfig,
) -> HalfRegistration:
"""两种方法各估一次左半 → 右半的平移,相差超过门槛抛 `RegistrationError`。"""
sampled = sample_frames_for_split(frames, cfg.sample_frames)
gray = np.stack([cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) for frame in sampled])
gray = gray.astype(np.float32)
marks = color_marks(sampled, prompt_cfg, pan_cfg, cfg.mark_dilate)
left = _prepare_half(gray, left_view, marks, cfg)
right = _prepare_half(gray, right_view, marks, cfg)
search = (
round(cfg.search_x_ratio * left_view.width),
round(cfg.search_y_ratio * left_view.height),
)
correlation, correlation_score = _correlate_edges(left, right, search, cfg.blur_sigma)
patches = _match_templates(left, right, search, cfg)
blocks = len({(patch.x, patch.y) for patch in patches})
if blocks < cfg.template_min_patches:
raise RegistrationError(
f"左半扇形内可用的模板块只有 {blocks} 块,少于 {cfg.template_min_patches} 块"
)
template = (
round(float(np.median([patch.offset[0] for patch in patches]))),
round(float(np.median([patch.offset[1] for patch in patches]))),
)
disagreement = max(abs(correlation[0] - template[0]), abs(correlation[1] - template[1]))
if disagreement > cfg.max_disagreement_px:
raise RegistrationError(
f"两半配准不一致:互相关估计 (dx, dy) = {correlation},模板匹配估计 {template},"
f"相差 {disagreement} 像素,超过门槛 {cfg.max_disagreement_px}"
)
return HalfRegistration(
correlation=correlation,
correlation_score=correlation_score,
template=template,
patches=patches,
disagreement_px=disagreement,
)