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
+21 -5
View File
@@ -1,8 +1,8 @@
"""ceus:CEUS 定量的全部算法和参数,纯计算,不引用其他 cadx 模块。
对外两段接口:`prepare_segmentation` 做第 1–2 步(解码、分屏、定造影那一半、选提示帧),交出分割的
输入;`quantify_case` 拿分割结果做第 4–9 步(轮廓、暗区、背景圆、TIC、拟合、关键帧、灌注指标)。
第 3 步分割由 segment 做,ceus 不引用它。某一例不满足算法前提时抛 `CeusCaseError`
对外两段接口:`prepare_segmentation` 做第 1–2 步(解码、分屏、配准两半、定造影那一半、选提示帧),
交出分割的输入;`quantify_case` 拿分割结果做第 4–9 步(轮廓、暗区、背景圆、TIC、拟合、关键帧、
灌注指标)。第 3 步分割由 segment 做,ceus 不引用它。某一例不满足算法前提时抛 `CeusCaseError`
(带失败的步号和原因)。
写产物用:`pack_trajectory`、`pack_tic_curves`、`render_tic_png` + `tic_plot_markers`、
`render_case_overlay`、`keyframe_images`、`quant_summary`。
@@ -30,6 +30,7 @@ from cadx.ceus._config import (
PanSourceConfig,
PromptSourceConfig,
ReferenceRegionConfig,
RegistrationConfig,
RoiShapeConfig,
SmoothingConfig,
TicAnalysisConfig,
@@ -70,7 +71,7 @@ from cadx.ceus._green_prompt import (
extract_color_prompt,
prompt_color_mask,
)
from cadx.ceus._mapping import map_source_mask_to_target, target_polygons_to_source, view_offset
from cadx.ceus._mapping import Offset, map_source_mask_to_target, target_polygons_to_source
from cadx.ceus._overlay import OverlayConfig, OverlayLayers, draw_polygon_overlay, render_overlay
from cadx.ceus._perfusion_metrics import (
LognormalPerfusionMetrics,
@@ -98,6 +99,14 @@ from cadx.ceus._polygons import (
)
from cadx.ceus._prompt_frames import PromptFrame, select_prompt_frames
from cadx.ceus._reference_region import select_reference_region
from cadx.ceus._registration import (
HalfRegistration,
RegistrationError,
TemplatePatch,
color_marks,
fan_mask,
register_halves,
)
from cadx.ceus._split_screen import (
SplitDetectionError,
detect_split_x,
@@ -159,6 +168,7 @@ __all__ = [
"ConstraintsConfig",
"ContrastSide",
"ContrastSideError",
"HalfRegistration",
"InteriorDarkRegionConfig",
"KeyframePositionConstraint",
"KeyframesConfig",
@@ -166,6 +176,7 @@ __all__ = [
"LognormalParameters",
"LognormalPerfusionMetrics",
"NoValidContourError",
"Offset",
"OverlayConfig",
"OverlayLayers",
"PanSourceConfig",
@@ -174,10 +185,13 @@ __all__ = [
"PromptSourceConfig",
"QuantResult",
"ReferenceRegionConfig",
"RegistrationConfig",
"RegistrationError",
"RoiShapeConfig",
"SegmentationInput",
"SmoothingConfig",
"SplitDetectionError",
"TemplatePatch",
"TicAnalysisConfig",
"TicBaselineConfig",
"TicCurveSeries",
@@ -194,6 +208,7 @@ __all__ = [
"apply_dark_morphology",
"apply_prompt_frame_position_constraints",
"collect_prompt_observations",
"color_marks",
"compute_lognormal_perfusion_metrics",
"compute_tic_for_video",
"crop_frame",
@@ -206,6 +221,7 @@ __all__ = [
"extract_color_prompt",
"extract_dark_region_masks",
"extract_dark_region_polygons",
"fan_mask",
"fill_binary_holes",
"fit_lognormal_curve",
"from_tic_result",
@@ -233,6 +249,7 @@ __all__ = [
"quant_summary",
"quantify_case",
"rect_from_values",
"register_halves",
"render_case_overlay",
"render_overlay",
"render_tic_png",
@@ -248,6 +265,5 @@ __all__ = [
"triangular_smooth",
"unpack_tic_curves",
"unpack_trajectory",
"view_offset",
"view_rect",
]
+46 -2
View File
@@ -7,8 +7,8 @@ null;多写或拼错的键报错;读入后不能修改(`AGENTS.md`「不
旧配置里以下字段删掉了,不留着不用:
- `views.source_view` / `views.target_view`:造影画面在哪一半改由两半各自找绿框决定(`_views.py`);
- `views.effective_region`:裁画面黑边只有旧 `service.py` 用,40 例结果走的 debug 路径不裁;
- `mapping`(含锚点):两半之间的坐标换算改成两个视图矩形左上角之差(`_mapping.py`),
不再需要按某一种视频宽度写死的锚点。
- `mapping`(含锚点):两半之间的坐标换算改成每段视频单独配准(`registration` 段、
`_registration.py`),不再需要按某一种视频宽度写死的锚点。
`paths` 段和整份文件最外层的模型由 `scripts/ceus_quant.py` 定义,不在这里。
"""
@@ -58,6 +58,49 @@ class ViewsConfig(BaseModel):
return value
class RegistrationConfig(BaseModel):
"""两半配准(`_registration.py`):每段视频单独估左半到右半的平移。
- `sample_frames`:均匀抽几帧求时间平均和时间标准差;
- `top_ignore_ratio`:顶部信息栏占高度的比例,这几行不算扇形区;
- `fan_std_threshold`:时间标准差(8 位灰度)高于它的像素算扇形区;
- `fan_open_kernel`:扇形掩膜开运算的核边长(像素,正奇数);
- `mark_dilate`:绿框、紫圈颜色像素修补前外扩的像素数;
- `blur_sigma`:边缘图和模板灰度共用的高斯平滑 σ(像素);
- `search_x_ratio`、`search_y_ratio`:搜索范围,平移各分量的绝对值上限占左半宽、高的比例;
- `template_grid`:模板块网格 `[行, 列]`;`template_size_ratio`:模板块边长占左半宽度的比例;
- `template_min_patches`:整块落在扇形内、可用的模板块至少几块;
- `max_disagreement_px`:两种方法估计各分量之差的最大值的门槛(像素),超过这一例失败。
"""
model_config = _STRICT
sample_frames: int = Field(ge=2)
top_ignore_ratio: float = Field(ge=0.0, lt=1.0)
fan_std_threshold: float = Field(gt=0.0)
fan_open_kernel: int
mark_dilate: int = Field(ge=0)
blur_sigma: float = Field(gt=0.0)
search_x_ratio: float = Field(gt=0.0, le=1.0)
search_y_ratio: float = Field(gt=0.0, le=1.0)
template_grid: list[int]
template_size_ratio: float = Field(gt=0.0, lt=1.0)
template_min_patches: int = Field(ge=1)
max_disagreement_px: int = Field(ge=0)
@field_validator("fan_open_kernel")
@classmethod
def _open_kernel_odd(cls, value: int) -> int:
return _require_positive_odd(value, "registration.fan_open_kernel")
@field_validator("template_grid")
@classmethod
def _grid_pair(cls, value: list[int]) -> list[int]:
if len(value) != 2 or min(value) < 1:
raise ValueError(f"registration.template_grid 必须是两个正整数 [行, 列],收到 {value}")
return value
class PromptSourceConfig(BaseModel):
"""绿框的 HSV 颜色检测。`min_area` 是候选外接框面积 w×h,不是轮廓面积。"""
@@ -548,6 +591,7 @@ class CeusConfig(BaseModel):
config_version: str = Field(min_length=1)
random_seed: int
views: ViewsConfig
registration: RegistrationConfig
prompt_source: PromptSourceConfig
pan_source: PanSourceConfig
keyframes: KeyframesConfig
+13 -38
View File
@@ -1,63 +1,38 @@
"""两半画面之间的坐标换算。
"""两半画面之间的坐标换算:造影那一半局部坐标 + 平移 `(dx, dy)` = 另一半局部坐标。
绿框画在造影那一半;分割在另一半(灰阶画面,病灶边界不随造影剂进出明暗变化)上做,轮廓再挪回
造影那一半采亮度。两半是同一个探头视野的两路成像,大小相同、只差一个水平平移,所以换算只做平移:
造影那一半采亮度。两半是同一个探头视野的两路成像,大小相同,只差一个平移;平移量由第 1 步
每段视频单独配准得到(`_registration.py`),这里只做加减和裁边,不缩放、不旋转。
平移量 = 目标那一半视图的左上角 − 造影那一半视图的左上角(整帧像素坐标)
两个视图矩形直接来自第 1 步的分屏结果,不依赖视频宽度。旧代码改用按 1856 像素宽的视频写死的
锚点,其余宽度的视频会挪偏一两百像素(design 0002 决定 4),这里不再用锚点。
两个函数都在各自视图的局部坐标里收发掩膜和多边形:局部 → 整帧(加本视图左上角)→ 平移 →
目标视图局部(减目标视图左上角)。两半宽度若差几个像素,超出目标视图的部分裁掉,不缩放。
两个函数都在各自视图的局部坐标里收发掩膜和多边形;超出目标视图的部分裁掉。
"""
import numpy as np
from cadx.ceus._geometry import AnyArray, Mask, Points, ViewRect
def view_offset(source_view_rect: ViewRect, target_view_rect: ViewRect) -> tuple[int, int]:
"""从造影那一半到目标那一半的平移量 `(dx, dy)`:两个视图左上角之差。"""
return (
target_view_rect.x1 - source_view_rect.x1,
target_view_rect.y1 - source_view_rect.y1,
)
type Offset = tuple[int, int]
def map_source_mask_to_target(
source_mask: AnyArray,
source_view_rect: ViewRect,
target_view_rect: ViewRect,
source_mask: AnyArray, offset: Offset, target_view_rect: ViewRect
) -> Mask:
"""把造影那一半局部坐标的掩膜挪到目标那一半的局部坐标,形状为目标视图大小。"""
dx, dy = view_offset(source_view_rect, target_view_rect)
# 源局部 (x, y) → 整帧 (x + sx1, y + sy1) → 平移 → 目标局部 (x + sx1 + dx − tx1, …)。
shift_x = source_view_rect.x1 + dx - target_view_rect.x1
shift_y = source_view_rect.y1 + dy - target_view_rect.y1
"""把造影那一半局部坐标的掩膜平移 `offset`,形状为目标视图大小,出界的像素丢掉。"""
dx, dy = offset
mapped = np.zeros((target_view_rect.height, target_view_rect.width), dtype=bool)
ys, xs = np.nonzero(source_mask)
xs = xs + shift_x
ys = ys + shift_y
xs = xs + dx
ys = ys + dy
inside = (xs >= 0) & (xs < target_view_rect.width) & (ys >= 0) & (ys < target_view_rect.height)
mapped[ys[inside], xs[inside]] = True
return mapped
def target_polygons_to_source(
target_polygons: list[AnyArray],
source_view_rect: ViewRect,
target_view_rect: ViewRect,
target_polygons: list[AnyArray], offset: Offset, source_view_rect: ViewRect
) -> list[Points]:
"""把目标那一半局部坐标的多边形逐个挪回造影那一半的局部坐标,裁到视图边界内。"""
dx, dy = view_offset(source_view_rect, target_view_rect)
shift = np.array(
[
target_view_rect.x1 - dx - source_view_rect.x1,
target_view_rect.y1 - dy - source_view_rect.y1,
],
dtype=np.float32,
)
"""把目标那一半局部坐标的多边形逐个减去 `offset` 挪回造影那一半,裁到视图边界内。"""
shift = np.array([-offset[0], -offset[1]], dtype=np.float32)
mapped: list[Points] = []
for polygon in target_polygons:
source_poly = polygon.astype(np.float32) + shift[None, :]
+26 -15
View File
@@ -1,7 +1,8 @@
"""ceus 对外的两段接口,中间由 `scripts/ceus_quant.py` 调 segment 做第 3 步分割。
- `prepare_segmentation`(第 1–2 步):读视频、找分屏线、判断造影画面在哪一半、选提示帧,交出
要交给分割的那一半(灰阶那一半,`target_view`)的逐帧图像和提示帧掩膜(`target_view` 局部坐标)。
- `prepare_segmentation`(第 1–2 步):读视频、找分屏线、配准两半、判断造影画面在哪一半、选提示帧,
交出要交给分割的那一半(灰阶那一半,`target_view`)的逐帧图像和提示帧掩膜(`target_view`
局部坐标)。
- `quantify_case`(第 4–9 步):拿分割得到的逐帧掩膜(`target_view` 局部坐标)整理成平滑轮廓并与
绿框融合、挪回造影那一半,找暗区、选背景圆、逐帧采 TIC、拟合、定位 7 个关键帧、算灌注指标。
@@ -33,7 +34,7 @@ from cadx.ceus._config import CeusConfig
from cadx.ceus._contrast_side import ContrastSide, locate_contrast_side
from cadx.ceus._dark_region import extract_dark_region_polygons
from cadx.ceus._geometry import AnyArray, Frame, Mask, crop_frame
from cadx.ceus._mapping import map_source_mask_to_target, target_polygons_to_source
from cadx.ceus._mapping import Offset, map_source_mask_to_target, target_polygons_to_source
from cadx.ceus._overlay import OverlayConfig, OverlayLayers, render_overlay
from cadx.ceus._perfusion_metrics import (
LognormalPerfusionMetrics,
@@ -45,6 +46,7 @@ from cadx.ceus._polygons import (
masks_to_smoothed_polygons,
)
from cadx.ceus._reference_region import select_reference_region
from cadx.ceus._registration import HalfRegistration, register_halves
from cadx.ceus._split_screen import split_views
from cadx.ceus._tic import CURVE_NAMES, TicResult, compute_tic_for_video
from cadx.ceus._tic_curves import TicCurves, from_tic_result
@@ -80,8 +82,10 @@ class SegmentationInput:
"""第一段(第 1–2 步)的结果。
- `video_path`、`fps`、`frame_count`、`frame_size`(宽, 高)、`frame_times_s`:视频本身;
- `split_x`:分屏线的列;`contrast`:哪一半是造影画面、两个视图矩形(整帧坐标)、提示帧
(造影那一半局部坐标的绿框掩膜);
- `split_x`:分屏线的列;`registration`:两半配准(左半 → 右半,两种方法各自的估计);
`offset`:造影那一半局部坐标 → `target_view` 局部坐标的平移 `(dx, dy)`,取互相关的估计;
- `contrast`:哪一半是造影画面、两个视图矩形(整帧坐标)、提示帧(造影那一半局部坐标的
绿框掩膜);
- `target_frames`:要交给分割的那一半(`contrast.target_view`,灰阶画面)的逐帧 BGR 图像,
与帧号一一对应;
- `prompt_masks`:提示帧帧号 → 这一帧绿框挪到 `target_view` 局部坐标后的布尔掩膜;
@@ -94,6 +98,8 @@ class SegmentationInput:
frame_size: tuple[int, int]
frame_times_s: AnyArray
split_x: int
registration: HalfRegistration
offset: Offset
contrast: ContrastSide
target_frames: list[Frame]
prompt_masks: dict[int, Mask]
@@ -124,11 +130,11 @@ class QuantResult:
def prepare_segmentation(video_path: Path, cfg: CeusConfig) -> SegmentationInput:
"""第 1–2 步:解码、分屏、找绿框定造影那一半、选提示帧,交出分割的输入。
"""第 1–2 步:解码、分屏、配准两半、找绿框定造影那一半、选提示帧,交出分割的输入。
视频读不了或读出的不合规抛 `VideoDecodeError`、找不到可信分屏线抛 `SplitDetectionError`(都是
第 1 步),两半都有或都没有绿框抛 `ContrastSideError`
(第 2 步),两者都是 `CeusCaseError`。
视频读不了或读出的不合规抛 `VideoDecodeError`、找不到可信分屏线抛 `SplitDetectionError`、两半
配准失败(含两种方法不一致)抛 `RegistrationError`(都是第 1 步),两半都有或都没有绿框抛
`ContrastSideError`(第 2 步),都是 `CeusCaseError`。
"""
video = VideoFile(video_path)
fps, frame_count, width, height = video.probe()
@@ -140,15 +146,17 @@ def prepare_segmentation(video_path: Path, cfg: CeusConfig) -> SegmentationInput
)
split_x, left_view, right_view = split_views(full_frames, cfg.views)
registration = register_halves(
full_frames, left_view, right_view, cfg.registration, cfg.prompt_source, cfg.pan_source
)
contrast = locate_contrast_side(
full_frames, left_view, right_view, cfg.prompt_source, cfg.keyframes
)
offset = registration.contrast_to_target(contrast.side)
target_frames = [crop_frame(frame, contrast.target_view).copy() for frame in full_frames]
first_contrast_frame = crop_frame(full_frames[0], contrast.contrast_view).copy()
prompt_masks = {
prompt.frame_index: map_source_mask_to_target(
prompt.mask, contrast.contrast_view, contrast.target_view
)
prompt.frame_index: map_source_mask_to_target(prompt.mask, offset, contrast.target_view)
for prompt in contrast.prompt_frames
}
return SegmentationInput(
@@ -158,6 +166,8 @@ def prepare_segmentation(video_path: Path, cfg: CeusConfig) -> SegmentationInput
frame_size=(width, height),
frame_times_s=frame_times_s,
split_x=split_x,
registration=registration,
offset=offset,
contrast=contrast,
target_frames=target_frames,
prompt_masks=prompt_masks,
@@ -212,20 +222,20 @@ def quantify_case(
target_polygons = apply_prompt_frame_position_constraints(
target_polygons,
prompt_frame_dicts,
contrast.contrast_view,
prepared.offset,
contrast.target_view,
cfg.constraints.keyframe_position,
)
target_polygons = fuse_prompt_prior_with_medsam(
target_polygons,
prompt_frame_dicts,
contrast.contrast_view,
prepared.offset,
contrast.target_view,
cfg.roi_shape,
cfg.smoothing,
)
source_polygons = target_polygons_to_source(
target_polygons, contrast.contrast_view, contrast.target_view
target_polygons, prepared.offset, contrast.contrast_view
)
# 第 5 步:暗区(在造影那一半上找)。
@@ -296,6 +306,7 @@ def quantify_case(
"split_x": prepared.split_x,
"source": "auto_detected",
"contrast_side": contrast.side,
"contrast_to_target_offset": list(prepared.offset),
},
)
curves = from_tic_result(
+12 -15
View File
@@ -13,8 +13,9 @@
其余帧用 `propagation_shape_strength`),再把面积收紧到先验面积的 `[min, max]` 倍
(绿框在最终轮廓里占多少,见 `待定问题/01`)。
照搬旧项目 `polygons.py`,去掉产品层;坐标换算改用两个视图矩形左上角之差(`_mapping.py`),
不再需要旧的有效区矩形和映射锚点。
照搬旧项目 `polygons.py`,去掉产品层;绿框挪到 target_view 用第 1 步配准得到的平移 `offset`
(造影那一半局部坐标 + offset = target_view 局部坐标,`_mapping.py`),不再需要旧的有效区矩形和
映射锚点。
"""
from typing import Any, Protocol
@@ -32,7 +33,7 @@ from cadx.ceus._geometry import (
resample_closed_contour,
triangular_smooth,
)
from cadx.ceus._mapping import map_source_mask_to_target
from cadx.ceus._mapping import Offset, map_source_mask_to_target
class _SmoothingConfigLike(Protocol):
@@ -205,15 +206,13 @@ def masks_to_smoothed_polygons(
def _prompt_frame_target_centers(
prompt_frames: list[dict[str, Any]],
source_view_rect: ViewRect,
offset: Offset,
target_view_rect: ViewRect,
) -> dict[int, AnyArray]:
"""各提示帧的绿框掩膜挪到 target_view 后的质心,`帧号 → (cx, cy)`。"""
centers: dict[int, AnyArray] = {}
for prompt_frame in prompt_frames:
target_mask = map_source_mask_to_target(
prompt_frame["mask"], source_view_rect, target_view_rect
)
target_mask = map_source_mask_to_target(prompt_frame["mask"], offset, target_view_rect)
centers[int(prompt_frame["frame_index"])] = mask_center(target_mask)
return centers
@@ -221,7 +220,7 @@ def _prompt_frame_target_centers(
def apply_prompt_frame_position_constraints(
polygons: list[AnyArray],
prompt_frames: list[dict[str, Any]],
source_view_rect: ViewRect,
offset: Offset,
target_view_rect: ViewRect,
constraint_cfg: _KeyframePositionConstraintLike,
) -> list[AnyArray]:
@@ -241,7 +240,7 @@ def apply_prompt_frame_position_constraints(
)
strength = float(np.clip(constraint_cfg.strength, 0.0, 1.0))
anchor_centers = _prompt_frame_target_centers(prompt_frames, source_view_rect, target_view_rect)
anchor_centers = _prompt_frame_target_centers(prompt_frames, offset, target_view_rect)
center_track = _interpolate_centers(anchor_centers, len(polygons))
corrected: list[AnyArray] = []
@@ -254,7 +253,7 @@ def apply_prompt_frame_position_constraints(
def _prompt_prior_polygons(
prompt_frames: list[dict[str, Any]],
frame_count: int,
source_view_rect: ViewRect,
offset: Offset,
target_view_rect: ViewRect,
smoothing_cfg: _SmoothingConfigLike,
) -> list[AnyArray]:
@@ -270,9 +269,7 @@ def _prompt_prior_polygons(
raw_key_polygons: dict[int, AnyArray] = {}
previous: AnyArray | None = None
for prompt_frame in prompt_frames:
target_mask = map_source_mask_to_target(
prompt_frame["mask"], source_view_rect, target_view_rect
)
target_mask = map_source_mask_to_target(prompt_frame["mask"], offset, target_view_rect)
resampled = resample_closed_contour(largest_contour(target_mask), num_points)
if resampled is None:
continue
@@ -316,7 +313,7 @@ def _tighten_area_to_prior_band(
def fuse_prompt_prior_with_medsam(
medsam_polygons: list[AnyArray],
prompt_frames: list[dict[str, Any]],
source_view_rect: ViewRect,
offset: Offset,
target_view_rect: ViewRect,
shape_cfg: _RoiShapeConfigLike,
smoothing_cfg: _SmoothingConfigLike,
@@ -332,7 +329,7 @@ def fuse_prompt_prior_with_medsam(
prior_polygons = _prompt_prior_polygons(
prompt_frames,
len(medsam_polygons),
source_view_rect,
offset,
target_view_rect,
smoothing_cfg,
)
+277
View File
@@ -0,0 +1,277 @@
"""第 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,
)