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