feat(ceus,segment): 任务书 02 第二阶段——分割和后半段算法

- ceus 迁入 polygons、dark_region、reference_region、tic_fitting、tic、anchor_frames、
  perfusion_metrics、trajectory、tic_curves、tic_plot、overlay,旧测试照搬(改 import 与配置读取)。
- 对外两段接口:prepare_segmentation(第 1–2 步)与 quantify_case(第 4–9 步)。
- 第 3 条:删掉 D1 取医生标注时刻的模式,D1 只从拟合曲线算。
- 第 4 条:F1/F2 取视频第 20−X、60−X 秒(X 为视频第 0 秒时已推注的秒数),落在视频外记未定位。
- 绿框融合权重、紫圈颜色阈值照搬;关键帧容差窗口的出处写进 ceus.yaml 注释。
- segment:同一进程调用 MedSAM2,设置写进 ceus.yaml 的 segment 段,全部必填;gpu 测试真跑通过。
- 依赖加 scipy、matplotlib、medsam2(可编辑安装,不编译 CUDA 扩展);uv 只解析 linux x86_64。
- 导入契约:只有 segment 引用 torch、sam2;segment 不引用其他 cadx 模块。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 05:07:02 -04:00
parent 9a916c65a5
commit 150eb5f6ae
35 changed files with 10655 additions and 505 deletions
+142 -3
View File
@@ -1,10 +1,23 @@
"""ceus:CEUS 定量的全部算法和参数,纯计算,不引用其他 cadx 模块。
目前有第 1、2 步(视频解码、分屏、找绿框、选提示帧、判断造影画面在哪一半)和两半之间的坐标换算;
第 3 步以后随任务书 02 第二阶段加入。
某一例不满足算法前提时抛 `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`。
"""
from cadx.ceus._anchor_frames import (
ANCHOR_SLOTS,
STATUS_ANCHOR_UNDEFINED,
STATUS_NO_FRAME_IN_WINDOW,
STATUS_NO_ORDERED_FRAME,
STATUS_NOT_COVERED,
STATUS_OK,
AnchorFrameLocation,
locate_anchor_frames,
)
from cadx.ceus._config import (
AnchorArrivalConfig,
AnchorFramesConfig,
@@ -25,6 +38,15 @@ from cadx.ceus._config import (
ViewsConfig,
)
from cadx.ceus._contrast_side import ContrastSide, ContrastSideError, locate_contrast_side
from cadx.ceus._dark_region import (
apply_dark_morphology,
dark_candidate_mask,
extract_dark_region_masks,
extract_dark_region_polygons,
fill_binary_holes,
score_dark_candidate,
split_dark_components,
)
from cadx.ceus._errors import CeusCaseError
from cadx.ceus._geometry import (
ViewRect,
@@ -49,7 +71,33 @@ from cadx.ceus._green_prompt import (
prompt_color_mask,
)
from cadx.ceus._mapping import map_source_mask_to_target, target_polygons_to_source, view_offset
from cadx.ceus._overlay import OverlayConfig, OverlayLayers, draw_polygon_overlay, render_overlay
from cadx.ceus._perfusion_metrics import (
LognormalPerfusionMetrics,
compute_lognormal_perfusion_metrics,
)
from cadx.ceus._pipeline import (
LESION_CURVES,
QuantResult,
SegmentationInput,
keyframe_images,
prepare_segmentation,
quant_summary,
quantify_case,
render_case_overlay,
tic_plot_markers,
)
from cadx.ceus._polygons import (
NoValidContourError,
align_polygon_to_previous,
apply_prompt_frame_position_constraints,
fuse_prompt_prior_with_medsam,
largest_contour,
mask_center,
masks_to_smoothed_polygons,
)
from cadx.ceus._prompt_frames import PromptFrame, select_prompt_frames
from cadx.ceus._reference_region import select_reference_region
from cadx.ceus._split_screen import (
SplitDetectionError,
detect_split_x,
@@ -57,12 +105,54 @@ from cadx.ceus._split_screen import (
split_views,
view_rect,
)
from cadx.ceus._tic import (
CURVE_NAMES,
BaselineResult,
TicResult,
compute_tic_for_video,
detect_baseline,
mean_or_nan,
)
from cadx.ceus._tic_curves import (
CURVES_SCHEMA_VERSION,
TicCurves,
TicCurveSeries,
from_tic_result,
pack_tic_curves,
unpack_tic_curves,
)
from cadx.ceus._tic_fitting import (
LognormalFitResult,
LognormalParameters,
evaluate_lognormal,
evaluate_lognormal_derivative,
fit_lognormal_curve,
)
from cadx.ceus._tic_plot import TicPlotMarker, render_tic_png
from cadx.ceus._trajectory import (
POLYGON_NUM_POINTS,
Trajectory,
pack_trajectory,
unpack_trajectory,
)
from cadx.ceus._video import VideoFile
__all__ = [
"ANCHOR_SLOTS",
"CURVES_SCHEMA_VERSION",
"CURVE_NAMES",
"LESION_CURVES",
"POLYGON_NUM_POINTS",
"STATUS_ANCHOR_UNDEFINED",
"STATUS_NOT_COVERED",
"STATUS_NO_FRAME_IN_WINDOW",
"STATUS_NO_ORDERED_FRAME",
"STATUS_OK",
"AnchorArrivalConfig",
"AnchorFrameLocation",
"AnchorFramesConfig",
"AnchorLatePhaseConfig",
"BaselineResult",
"CeusCaseError",
"CeusConfig",
"ColorPrompt",
@@ -72,42 +162,91 @@ __all__ = [
"InteriorDarkRegionConfig",
"KeyframePositionConstraint",
"KeyframesConfig",
"LognormalFitResult",
"LognormalParameters",
"LognormalPerfusionMetrics",
"NoValidContourError",
"OverlayConfig",
"OverlayLayers",
"PanSourceConfig",
"PromptFrame",
"PromptObservation",
"PromptSourceConfig",
"QuantResult",
"ReferenceRegionConfig",
"RoiShapeConfig",
"SegmentationInput",
"SmoothingConfig",
"SplitDetectionError",
"TicAnalysisConfig",
"TicBaselineConfig",
"TicCurveSeries",
"TicCurves",
"TicFittingConfig",
"TicPlotMarker",
"TicResult",
"Trajectory",
"VideoFile",
"ViewRect",
"ViewsConfig",
"align_polygon_to_previous",
"apply_dark_morphology",
"apply_prompt_frame_position_constraints",
"collect_prompt_observations",
"compute_lognormal_perfusion_metrics",
"compute_tic_for_video",
"crop_frame",
"dark_candidate_mask",
"detect_baseline",
"detect_split_x",
"draw_polygon_overlay",
"evaluate_lognormal",
"evaluate_lognormal_derivative",
"extract_color_prompt",
"extract_dark_region_masks",
"extract_dark_region_polygons",
"fill_binary_holes",
"fit_lognormal_curve",
"from_tic_result",
"fuse_prompt_prior_with_medsam",
"keyframe_images",
"largest_contour",
"local_rect_within",
"locate_anchor_frames",
"locate_contrast_side",
"map_source_mask_to_target",
"mask_center",
"mask_iou",
"masks_to_smoothed_polygons",
"mean_or_nan",
"odd_window",
"optional_positive_int",
"pack_tic_curves",
"pack_trajectory",
"polygon_area",
"polygon_center",
"polygon_to_mask",
"polygons_to_mask",
"prepare_segmentation",
"prompt_color_mask",
"quant_summary",
"quantify_case",
"rect_from_values",
"render_case_overlay",
"render_overlay",
"render_tic_png",
"resample_closed_contour",
"sample_frames_for_split",
"score_dark_candidate",
"select_prompt_frames",
"select_reference_region",
"split_dark_components",
"split_views",
"target_polygons_to_source",
"tic_plot_markers",
"triangular_smooth",
"unpack_tic_curves",
"unpack_trajectory",
"view_offset",
"view_rect",
]
+419
View File
@@ -0,0 +1,419 @@
"""第 8 步:把关键帧时刻映射到真实、有效且不重复的视频帧,共 7 个槽位。
- B0、D1、D2、D3、D4:从 `roi_perf`(病灶去暗区)曲线的 Lognormal 拟合上取。B0 是到达时刻再往前
退 `baseline_frame_backoff_s` 秒,只在观测到的基线段里、到达之前取帧;D1 是拟合曲线上的到达
时刻(design 0002 决定 6:不用医生标注的造影剂到达时间);D2、D3、D4 是洗入拐点、峰值、
洗出拐点。病灶两条曲线(`roi_all`、`roi_perf`)的拟合没有都过质量门时,这 5 个槽位全部未定位。
D1–D4 在各自的容差窗口里找帧,并保持时间先后(不会出现 D2 的帧早于 D1 的帧)。
- F1、F2:推注后第 `f1_anchor_s`、`late_phase.preferred_time_s` 秒。`injection_offset_s`(下称 X)
是超声报告表的「推注开始时间s」,含义是视频第 0 秒时已经推注了 X 秒(design 0002 决定 5),
所以推注后第 T 秒在视频第 T − X 秒。落在视频有效帧范围之外(早于第一帧或晚于最后一帧),
这一槽位记为未定位,不拿别的帧补。
每个槽位交出帧号、视频时间、状态和未定位的原因;个别槽位未定位不算这一例失败。
照搬旧项目 `anchor_frames.py`,两处改动(任务书 02「四处要改的地方」第 3、4 条):删掉 D1 取医生
标注时刻的 `excel_video` 模式;推注时间的符号按上面的含义(旧 `excel_video` 模式按「推注发生在
视频第 X 秒」算成了 T + X)。
"""
import math
from dataclasses import dataclass
import numpy as np
from cadx.ceus._config import AnchorFramesConfig
from cadx.ceus._geometry import AnyArray
from cadx.ceus._tic_fitting import LognormalFitResult
ANCHOR_SLOTS = ("B0", "D1", "D2", "D3", "D4", "F1", "F2")
STATUS_OK = "ok"
STATUS_ANCHOR_UNDEFINED = "anchor_undefined"
STATUS_NO_FRAME_IN_WINDOW = "no_frame_in_window"
STATUS_NO_ORDERED_FRAME = "no_ordered_frame"
STATUS_NOT_COVERED = "not_covered"
@dataclass(frozen=True)
class AnchorFrameLocation:
"""一个关键帧槽位的定位结果。
- `frame_index`、`frame_time_video_s`:定位到的帧号和它的视频时间,未定位为 None;
- `time_s`:这一帧在推注时间轴上的时刻(视频时间 + X),没有推注时间或未定位为 None;
- `status`:`ok` 或未定位的类别(`anchor_undefined`、`not_covered`、`no_frame_in_window`、
`no_ordered_frame`);`reason`:未定位的原因(中文),定位到时为 None;
- `anchor_time_video_s`:要找的那个时刻(视频时间),没有时为 None;
- `time_error_s`:帧时间减锚点时刻;`source_curve`:时刻来自哪条曲线或推注时钟。
"""
slot: str
frame_index: int | None
time_s: float | None
status: str
reason: str | None
anchor_time_video_s: float | None
frame_time_video_s: float | None
time_error_s: float | None
source_curve: str | None
def _finite_offset(injection_offset_s: float | None) -> float | None:
"""推注时间 X:None 表示没有;有就必须是有限非负秒数,否则报错。"""
if injection_offset_s is None:
return None
parsed = float(injection_offset_s)
if not math.isfinite(parsed) or parsed < 0.0:
raise ValueError(f"injection_offset_s 必须是有限非负秒数,收到 {injection_offset_s}")
return parsed
def _validate_axis(frame_indices: AnyArray, time_video_s: AnyArray) -> tuple[AnyArray, AnyArray]:
"""帧号与视频时间必须等长、非空、严格递增。"""
indices = np.asarray(frame_indices, dtype=np.int32)
times = np.asarray(time_video_s, dtype=np.float64)
if indices.ndim != 1 or times.shape != indices.shape or indices.size == 0:
raise ValueError("frame_indices 与 time_video_s 必须是等长非空一维数组")
if np.any(np.diff(indices) <= 0):
raise ValueError("frame_indices 必须严格递增")
if not np.isfinite(times).all() or np.any(np.diff(times) <= 0.0):
raise ValueError("time_video_s 必须有限且严格递增")
return indices, times
def _nearest_frame(
anchor_time_s: float,
window_before_s: float,
window_after_s: float,
frame_indices: AnyArray,
time_video_s: AnyArray,
eligible_mask: AnyArray,
) -> int | None:
"""窗口 `[锚点 − before, 锚点 + after]` 内离锚点最近的可用帧;距离相同取更早的。"""
eligible = (
np.asarray(eligible_mask, dtype=bool)
& (time_video_s >= anchor_time_s - window_before_s)
& (time_video_s <= anchor_time_s + window_after_s)
)
positions = np.flatnonzero(eligible)
if positions.size == 0:
return None
distances = np.abs(time_video_s[positions] - anchor_time_s)
order = np.lexsort((time_video_s[positions], distances))
return int(frame_indices[positions[int(order[0])]])
type _State = tuple[int, float, tuple[int | None, ...]]
def _ordered_anchor_frames(
anchor_specs: tuple[tuple[str, float, float, float], ...],
frame_indices: AnyArray,
time_video_s: AnyArray,
eligible_mask: AnyArray,
) -> dict[str, int]:
"""给 D1–D4 选帧:先保留最多的槽位,再让严格递增映射的总时间误差最小(动态规划)。"""
eligible = np.asarray(eligible_mask, dtype=bool)
states: dict[int, _State] = {-1: (0, 0.0, ())}
for _slot, anchor_time_s, before_s, after_s in anchor_specs:
positions = np.flatnonzero(
eligible
& (time_video_s >= anchor_time_s - before_s)
& (time_video_s <= anchor_time_s + after_s)
)
next_states: dict[int, _State] = {}
for previous, (count, cost, path) in states.items():
skipped: _State = (count, cost, (*path, None))
incumbent = next_states.get(previous)
if incumbent is None or _state_key(skipped) < _state_key(incumbent):
next_states[previous] = skipped
for position_value in positions.tolist():
position = int(position_value)
if position <= previous:
continue
assigned: _State = (
count + 1,
cost + abs(float(time_video_s[position]) - anchor_time_s),
(*path, position),
)
incumbent = next_states.get(position)
if incumbent is None or _state_key(assigned) < _state_key(incumbent):
next_states[position] = assigned
states = next_states
_count, _cost, best_path = min(states.values(), key=_state_key)
return {
spec[0]: int(frame_indices[position])
for spec, position in zip(anchor_specs, best_path, strict=True)
if position is not None
}
def _state_key(state: _State) -> tuple[int, float, tuple[int, ...]]:
"""动态规划的比较键:槽位多者优先,其次总误差小者,再按路径定序。"""
count, cost, path = state
sortable_path = tuple(-1 if position is None else position for position in path)
return -count, cost, sortable_path
def _video_time_for_frame(
frame_index: int | None,
frame_indices: AnyArray,
time_video_s: AnyArray,
) -> float | None:
"""帧号对应的视频时间;帧号必须在时间轴里恰好出现一次。"""
if frame_index is None:
return None
positions = np.flatnonzero(frame_indices == frame_index)
if positions.size != 1:
raise ValueError(f"帧号 {frame_index} 在时间轴中不是唯一值")
return float(time_video_s[int(positions[0])])
def _fit_eligible(fit: LognormalFitResult) -> AnyArray:
"""拟合用到的有效且不重复的样本。"""
return fit.valid_sample_mask & ~fit.duplicate_sample_mask
def _span_text(start_s: float, end_s: float) -> str:
return f"[{start_s:.2f}, {end_s:.2f}] 秒"
def locate_anchor_frames(
*,
perf_fit: LognormalFitResult,
perf_baseline_end_index: int,
lesion_fit_ready: bool,
frame_indices: AnyArray,
time_video_s: AnyArray,
injection_offset_s: float | None,
cfg: AnchorFramesConfig,
) -> dict[str, AnchorFrameLocation]:
"""定位 7 个槽位,按 `ANCHOR_SLOTS` 的顺序交出。
`lesion_fit_ready` 是病灶两条曲线的拟合是否都过了质量门;`injection_offset_s` 是推注时间 X
(秒,视频第 0 秒时已推注的时长),None 时 F1、F2 未定位。
"""
indices, times = _validate_axis(frame_indices, time_video_s)
if perf_fit.valid_sample_mask.shape != times.shape:
raise ValueError("perf_fit 的样本掩膜长度与时间轴不一致")
injection_axis_offset_s = _finite_offset(injection_offset_s)
arrival_time = perf_fit.d1_time_s
locations: dict[str, AnchorFrameLocation] = {}
def record(
slot: str,
frame_index: int | None,
status: str,
reason: str | None,
*,
anchor_time_video_s: float | None,
source_curve: str | None,
) -> None:
frame_time_video_s = _video_time_for_frame(frame_index, indices, times)
locations[slot] = AnchorFrameLocation(
slot=slot,
frame_index=frame_index,
time_s=(
None
if frame_time_video_s is None or injection_axis_offset_s is None
else frame_time_video_s + injection_axis_offset_s
),
status=status,
reason=None if status == STATUS_OK else reason,
anchor_time_video_s=anchor_time_video_s,
frame_time_video_s=frame_time_video_s,
time_error_s=(
None
if frame_time_video_s is None or anchor_time_video_s is None
else frame_time_video_s - anchor_time_video_s
),
source_curve=source_curve,
)
model_ready = lesion_fit_ready and perf_fit.success
if not lesion_fit_ready:
fit_failure = "病灶两条曲线(roi_all、roi_perf)的 Lognormal 拟合没有都过质量门"
elif not perf_fit.success:
fit_failure = f"roi_perf 曲线的 Lognormal 拟合没过质量门({perf_fit.failure_reason})"
else:
fit_failure = ""
perf_eligible = _fit_eligible(perf_fit)
b0_ready = arrival_time is not None and perf_fit.baseline_source == "observed" and model_ready
if b0_ready and arrival_time is not None:
b0_time = float(arrival_time) - cfg.baseline_frame_backoff_s
frame = _nearest_frame(
b0_time,
cfg.b0_window_s,
cfg.b0_window_s,
indices,
times,
perf_eligible
& (np.arange(times.size) < perf_baseline_end_index)
& (times < float(arrival_time)),
)
record(
"B0",
frame,
STATUS_OK if frame is not None else STATUS_NO_FRAME_IN_WINDOW,
f"B0 锚点 {b0_time:.2f} 秒的容差窗口 "
f"{_span_text(b0_time - cfg.b0_window_s, b0_time + cfg.b0_window_s)} 内,"
"观测到的基线段里、到达时刻之前没有可用帧",
anchor_time_video_s=b0_time,
source_curve="roi_perf",
)
else:
if not model_ready:
b0_reason = fit_failure
elif arrival_time is None:
b0_reason = "拟合曲线升不过到达门槛,没有到达时刻"
else:
b0_reason = "视频开头没有观测到稳定的增强前基线(基线是估计的),B0 不定义"
record(
"B0",
None,
STATUS_ANCHOR_UNDEFINED,
b0_reason,
anchor_time_video_s=None,
source_curve="roi_perf",
)
raw_specs = (
("D1", arrival_time, 0.0, cfg.d1_window_after_s),
("D2", perf_fit.d2_time_s, cfg.d2_window_s, cfg.d2_window_s),
("D3", perf_fit.d3_time_s, cfg.d3_window_s, cfg.d3_window_s),
("D4", perf_fit.d4_time_s, cfg.d4_window_s, cfg.d4_window_s),
)
eligible_times = times[perf_eligible]
video_start_s = float(eligible_times[0]) if eligible_times.size else math.inf
video_end_s = float(eligible_times[-1]) if eligible_times.size else -math.inf
covered_specs = tuple(
(slot, float(anchor_time), before, after)
for slot, anchor_time, before, after in raw_specs
if model_ready
and anchor_time is not None
and video_start_s <= float(anchor_time) <= video_end_s
)
dynamic_eligible = perf_eligible
if model_ready:
dynamic_eligible &= times < perf_fit.fit_end_s
ordered = _ordered_anchor_frames(covered_specs, indices, times, dynamic_eligible)
for slot, anchor_time, before, after in raw_specs:
if not model_ready:
record(
slot,
None,
STATUS_ANCHOR_UNDEFINED,
fit_failure,
anchor_time_video_s=anchor_time,
source_curve="roi_perf",
)
elif anchor_time is None:
record(
slot,
None,
STATUS_NOT_COVERED if slot == "D4" else STATUS_ANCHOR_UNDEFINED,
"峰后观测不够长,或洗出拐点落在观测范围之外,拟合曲线上没有 D4"
if slot == "D4"
else f"拟合曲线上没有 {slot} 时刻(升不过到达门槛)",
anchor_time_video_s=None,
source_curve="roi_perf",
)
elif not video_start_s <= float(anchor_time) <= video_end_s:
record(
slot,
None,
STATUS_NOT_COVERED,
f"{slot} 时刻 {float(anchor_time):.2f} 秒落在视频有效帧范围 "
f"{_span_text(video_start_s, video_end_s)} 之外",
anchor_time_video_s=float(anchor_time),
source_curve="roi_perf",
)
elif slot in ordered:
record(
slot,
ordered[slot],
STATUS_OK,
None,
anchor_time_video_s=float(anchor_time),
source_curve="roi_perf",
)
else:
window = _span_text(float(anchor_time) - before, float(anchor_time) + after)
has_candidate = bool(
np.any(
dynamic_eligible
& (times >= float(anchor_time) - before)
& (times <= float(anchor_time) + after)
)
)
record(
slot,
None,
STATUS_NO_ORDERED_FRAME if has_candidate else STATUS_NO_FRAME_IN_WINDOW,
f"{slot} 容差窗口 {window} 内的候选帧都被时间更早的槽位占用,无法保持先后顺序"
if has_candidate
else f"{slot} 容差窗口 {window} 内没有可用帧(拟合区间内、不重复)",
anchor_time_video_s=float(anchor_time),
source_curve="roi_perf",
)
globally_eligible = ~perf_fit.duplicate_sample_mask
for slot, injection_time_s, tolerance_s in (
("F1", cfg.f1_anchor_s, None),
("F2", cfg.late_phase.preferred_time_s, cfg.late_phase.tolerance_s),
):
if injection_axis_offset_s is None:
record(
slot,
None,
STATUS_NOT_COVERED,
"没有推注时间,推注后时刻换算不到视频时间",
anchor_time_video_s=None,
source_curve="injection_clock",
)
continue
video_time_s = injection_time_s - injection_axis_offset_s
fixed_eligible_times = times[globally_eligible]
if (
fixed_eligible_times.size == 0
or video_time_s < float(fixed_eligible_times[0])
or video_time_s > float(fixed_eligible_times[-1])
):
span = (
"没有不重复的帧"
if fixed_eligible_times.size == 0
else f"视频有效帧范围 "
f"{_span_text(float(fixed_eligible_times[0]), float(fixed_eligible_times[-1]))}"
)
record(
slot,
None,
STATUS_NOT_COVERED,
f"推注后第 {injection_time_s:g} 秒在视频第 {video_time_s:.2f} 秒"
f"(推注时间 X={injection_axis_offset_s:g} 秒),落在{span}之外",
anchor_time_video_s=video_time_s,
source_curve="injection_clock",
)
continue
window = float("inf") if tolerance_s is None else tolerance_s
frame = _nearest_frame(
video_time_s,
window,
window,
indices,
times,
globally_eligible,
)
record(
slot,
frame,
STATUS_OK if frame is not None else STATUS_NO_FRAME_IN_WINDOW,
f"视频第 {video_time_s:.2f} 秒 ±{window:g} 秒内没有不重复的帧",
anchor_time_video_s=video_time_s,
source_curve="injection_clock",
)
return {slot: locations[slot] for slot in ANCHOR_SLOTS}
+23
View File
@@ -211,6 +211,10 @@ class InteriorDarkRegionConfig(BaseModel):
threshold_offset: int
min_area: int
min_area_ratio: float
max_regions: int | None
max_contours_per_region: int | None
preferred_area_ratio_range: list[float] | None
prompt_exclude_dilate: int
preserve_holes: bool
fill_holes: bool
morphology_kernel: int
@@ -235,6 +239,25 @@ class InteriorDarkRegionConfig(BaseModel):
raise ValueError(f"interior_dark_region.adaptive_block_size 必须 ≥3,收到 {validated}")
return validated
@field_validator("max_regions", "max_contours_per_region")
@classmethod
def _optional_count_positive(cls, value: int | None) -> int | None:
"""截断个数为 null(不限)或正整数;0 会把暗区全部截掉而不报错。"""
if value is not None and value < 1:
raise ValueError(f"interior_dark_region 的截断个数必须为 null 或 ≥1,收到 {value}")
return value
@field_validator("preferred_area_ratio_range")
@classmethod
def _area_range_pair(cls, value: list[float] | None) -> list[float] | None:
"""偏好面积比区间为 null 或 [lo, hi],0 ≤ lo ≤ hi ≤ 1。"""
if value is not None and not (len(value) == 2 and 0.0 <= value[0] <= value[1] <= 1.0):
raise ValueError(
"interior_dark_region.preferred_area_ratio_range 必须为 null 或 "
f"0≤lo≤hi≤1 的 [lo, hi],收到 {value}"
)
return value
@field_validator("percentile")
@classmethod
def _percentile_within_bounds(cls, value: float) -> float:
+670
View File
@@ -0,0 +1,670 @@
"""第 5 步:在病灶 ROI 内找暗区(不进造影剂、一直发黑的囊液部分)。
哪种阈值法在当前帧最可信事先定不了,所以 `threshold_method="auto_ensemble"` 时对
`candidate_methods` 里的每种阈值法各出一份暗块候选,用统一评分(明暗对比 + 面积分
− 碎片惩罚 + 与上一帧的时间一致性)择优。
- `dark_candidate_mask`:按方法在 ROI 灰度内出候选暗掩膜(otsu / percentile / absolute /
adaptive / kmeans / clahe_otsu);`threshold_offset` 只对前三种生效;末尾过形态学;
- `apply_dark_morphology`:闭、开运算各一次(`morphology_kernel` 方核,≤1 跳过);
`fill_holes` 且不 `preserve_holes` 时填洞;最后与采样掩膜取交;
- `split_dark_components`:连通域拆分,面积门槛 `max(min_area, ROI 面积 × min_area_ratio)`;
`preserve_holes` 时把洞挖回;按面积降序,`max_regions` 截断;
- `score_dark_candidate`:评分(三个常量 64.0 / 4.0 / 2.0);
- `extract_dark_region_masks`:单帧——剔绿框色 → 各候选法择优 → 拆分;
- `extract_dark_region_polygons`:逐帧编排,`enabled=False` 时每帧空列表(仍保留帧对齐);
否则各暗块取显著轮廓、等弧长重采样到 `contour_points` 点。
输入是造影那一半画面(`analysis_view: source`)的逐帧 BGR 与 ROI 多边形,输出同一坐标系的暗区
多边形。照搬旧项目 `dark_region.py`,去掉产品层;旧代码用 `getattr(..., 默认值)` 读的四个旧 schema
里没有的键(`max_regions`、`max_contours_per_region`、`preferred_area_ratio_range`、
`prompt_exclude_dilate`)改成配置字段、值照旧;剔绿用的 HSV 阈值只取 `prompt_source`,
旧代码在没传时回退的写死阈值删掉。
"""
from collections.abc import Iterable, Sequence
from typing import Any, Protocol, cast
import cv2
import numpy as np
from cadx.ceus._geometry import (
AnyArray,
Mask,
mask_iou,
polygon_to_mask,
resample_closed_contour,
)
# 评分公式的三个常量(照参考组件 P:1296 / 1308 / 1310)。
_CONTRAST_NORMALIZER = 64.0 # 明暗对比归一化除数
_AREA_SLOPE = 4.0 # preferred_area_ratio_range 之外面积分的线性衰减斜率
_AREA_SCORE_GAIN = 2.0 # 没有 preferred_area_ratio_range 时面积分的系数
_EMPTY_SCORE = -1e9 # 空候选的哨兵分:择优时永远不会被选中
class _DarkRegionConfigLike(Protocol):
"""暗区配置(`InteriorDarkRegionConfig` 符合)。"""
@property
def enabled(self) -> bool: ...
@property
def exclude_prompt_color(self) -> bool: ...
@property
def prompt_exclude_dilate(self) -> int: ...
@property
def threshold_method(self) -> str: ...
@property
def candidate_methods(self) -> Sequence[str]: ...
@property
def threshold_offset(self) -> int: ...
@property
def min_area(self) -> int: ...
@property
def min_area_ratio(self) -> float: ...
@property
def max_regions(self) -> int | None: ...
@property
def max_contours_per_region(self) -> int | None: ...
@property
def preferred_area_ratio_range(self) -> list[float] | None: ...
@property
def preserve_holes(self) -> bool: ...
@property
def fill_holes(self) -> bool: ...
@property
def morphology_kernel(self) -> int: ...
@property
def fragment_penalty_weight(self) -> float: ...
@property
def temporal_consistency_weight(self) -> float: ...
@property
def contour_points(self) -> int: ...
@property
def percentile(self) -> float: ...
@property
def threshold(self) -> int: ...
@property
def adaptive_block_size(self) -> int: ...
@property
def adaptive_c(self) -> float: ...
@property
def kmeans_max_iter(self) -> int: ...
@property
def kmeans_attempts(self) -> int: ...
@property
def clahe_clip_limit(self) -> float: ...
@property
def clahe_tile_grid_size(self) -> int: ...
class _PromptHsvConfigLike(Protocol):
"""剔绿框色用的 HSV 阈值(`PromptSourceConfig` 符合)。"""
@property
def hsv_lower(self) -> list[int]: ...
@property
def hsv_upper(self) -> list[int]: ...
def fill_binary_holes(mask: AnyArray) -> AnyArray:
"""对二值 mask 填充内部空洞(参考 P:1112-1119)。
参数:
mask: ``(h, w)`` 的二值数组(bool 或非零即真)。
返回:
填洞后的 ``dtype=bool`` mask(外接背景之外的所有像素均置 True)。
关键实现:
从角点 ``(0, 0)`` 泛洪标记"可从外部到达的背景",取反即得"被前景包围的洞",
与原前景按位或合并。要求 ``(0, 0)`` 像素为背景(暗区不会触达画面角点,成立)。
"""
filled = np.asarray(mask, dtype=np.uint8) * np.uint8(255)
flood = filled.copy()
height, width = flood.shape
flood_mask = np.zeros((height + 2, width + 2), dtype=np.uint8)
cv2.floodFill(flood, flood_mask, (0, 0), 255)
holes = np.asarray(cv2.bitwise_not(flood), dtype=np.uint8)
return (filled | holes) > 0
def apply_dark_morphology(
dark: AnyArray,
sample_mask: AnyArray,
dark_cfg: _DarkRegionConfigLike,
) -> AnyArray:
"""对候选暗 mask 做形态学闭 / 开运算并约束回 sample_mask(参考 P:1122-1136)。
参数:
dark: 候选暗 mask(bool)。
sample_mask: 分析采样掩膜(ROI 内、已剔绿的有效区);结果与其取交。
dark_cfg: 暗区配置(取 ``morphology_kernel`` / ``fill_holes`` / ``preserve_holes``)。
返回:
形态学处理后、且**仅落在 sample_mask 内**的 ``dtype=bool`` mask。
关键实现:
方核大小 ``> 1`` 时先 CLOSE(补小缺口)再 OPEN(去毛刺)各一次;
仅当 ``fill_holes=True 且 preserve_holes=False`` 才填洞(保洞优先于填洞);
末尾与 sample_mask 取交,杜绝形态学膨胀外溢 ROI。
"""
kernel_size = int(dark_cfg.morphology_kernel)
if kernel_size > 1:
kernel = np.ones((kernel_size, kernel_size), dtype=np.uint8)
dark_u8 = dark.astype(np.uint8) * 255
dark_u8 = cv2.morphologyEx(dark_u8, cv2.MORPH_CLOSE, kernel, iterations=1)
dark_u8 = cv2.morphologyEx(dark_u8, cv2.MORPH_OPEN, kernel, iterations=1)
dark = dark_u8 > 0
if bool(dark_cfg.fill_holes) and not bool(dark_cfg.preserve_holes):
dark = fill_binary_holes(dark)
return dark & sample_mask
def _otsu_threshold(values: AnyArray) -> float:
"""对一维灰度样本求 Otsu 阈值(参考 P:1150-1155 / 1206-1211)。"""
threshold, _ = cv2.threshold(values.reshape(-1, 1), 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
return float(threshold)
def _candidate_otsu(gray: AnyArray, roi_values: AnyArray) -> AnyArray:
"""Otsu 法候选:ROI 灰度 Otsu 阈值,``gray <= 阈值`` 为暗(P:1149-1156)。"""
return gray <= _otsu_threshold(roi_values)
def _candidate_percentile(
gray: AnyArray, roi_values: AnyArray, dark_cfg: _DarkRegionConfigLike
) -> AnyArray:
"""分位法候选:ROI 灰度的 ``percentile`` 分位作阈值(P:1157-1159)。"""
threshold = float(np.percentile(roi_values, float(dark_cfg.percentile)))
return gray <= threshold
def _candidate_absolute(gray: AnyArray, dark_cfg: _DarkRegionConfigLike) -> AnyArray:
"""绝对阈法候选:固定灰度阈 ``threshold``(P:1160-1162)。"""
return gray <= float(dark_cfg.threshold)
def _candidate_adaptive(gray: AnyArray, dark_cfg: _DarkRegionConfigLike) -> AnyArray:
"""自适应阈法候选:高斯邻域自适应 + ``THRESH_BINARY_INV``(P:1163-1176)。
block_size 强制奇数且 ``>= 3``(偶数加 1);inverse 二值化使暗部为前景。
"""
block_size = int(dark_cfg.adaptive_block_size)
if block_size % 2 == 0:
block_size += 1
block_size = max(3, block_size)
adaptive = cv2.adaptiveThreshold(
gray,
255,
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY_INV,
block_size,
float(dark_cfg.adaptive_c),
)
return adaptive > 0
def _candidate_kmeans(
sample_mask: AnyArray, roi_values: AnyArray, dark_cfg: _DarkRegionConfigLike
) -> AnyArray:
"""K-means 二聚类候选:ROI 灰度聚成两簇,低均值簇为暗(P:1177-1196)。
EPS=0.5 硬编码(与参考一致);样本不足 2 个时返回全 False。结果仅写回 sample_mask 内。
"""
values = roi_values.reshape(-1, 1).astype(np.float32)
if len(values) < 2:
return np.zeros_like(sample_mask, dtype=bool)
criteria = (
cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER,
int(dark_cfg.kmeans_max_iter),
0.5,
)
# cv2 的类型存根不接受 bestLabels=None(运行时接受,表示不给初始标签)。
kmeans = cast(Any, cv2.kmeans)
_, labels, centers = cast(
tuple[float, AnyArray, AnyArray],
kmeans(
values,
2,
None,
criteria,
int(dark_cfg.kmeans_attempts),
cv2.KMEANS_PP_CENTERS,
),
)
dark_label = int(np.argmin(centers.reshape(-1)))
dark = np.zeros_like(sample_mask, dtype=bool)
dark[sample_mask] = labels.reshape(-1) == dark_label
return dark
def _candidate_clahe_otsu(
gray: AnyArray, sample_mask: AnyArray, dark_cfg: _DarkRegionConfigLike
) -> AnyArray:
"""CLAHE 增强后 Otsu 候选:先局部直方图均衡再在 ROI 内 Otsu(P:1197-1212)。"""
clahe = cv2.createCLAHE(
clipLimit=float(dark_cfg.clahe_clip_limit),
tileGridSize=(
int(dark_cfg.clahe_tile_grid_size),
int(dark_cfg.clahe_tile_grid_size),
),
)
enhanced = clahe.apply(gray)
threshold = _otsu_threshold(enhanced[sample_mask])
return enhanced <= threshold
def _apply_threshold_offset(
dark: AnyArray,
gray: AnyArray,
sample_mask: AnyArray,
method: str,
dark_cfg: _DarkRegionConfigLike,
) -> AnyArray:
"""对 otsu / percentile / absolute 三法施加 ``threshold_offset``(参考 P:1216-1233)。
其余方法(adaptive / kmeans / clahe_otsu)不支持偏移,原样返回。偏移为 0 时也原样返回。
重新以"基准阈值 + 偏移"二值化(不是在已二值化结果上平移),与参考严格等价。
"""
threshold_offset = float(dark_cfg.threshold_offset)
if threshold_offset == 0 or method not in {"otsu", "percentile", "absolute"}:
return dark
base_values = gray[sample_mask]
if method == "otsu":
return gray <= (_otsu_threshold(base_values) + threshold_offset)
if method == "percentile":
base = float(np.percentile(base_values, float(dark_cfg.percentile)))
return gray <= (base + threshold_offset)
# absolute
return gray <= (float(dark_cfg.threshold) + threshold_offset)
def dark_candidate_mask(
gray: AnyArray,
sample_mask: AnyArray,
method: str,
dark_cfg: _DarkRegionConfigLike,
) -> AnyArray:
"""按指定阈值法在 ROI 灰度内出候选暗 mask(参考 P:1139-1235)。
参数:
gray: ``(h, w)`` 单通道 uint8 灰度图(整帧)。
sample_mask: 分析采样掩膜(ROI 内、已剔绿);阈值统计只在其内取灰度。
method: 阈值法名(otsu / percentile / absolute / adaptive / kmeans / clahe_otsu)。
dark_cfg: 暗区配置(提供各法子参与 ``threshold_offset``)。
返回:
过形态学并约束回 sample_mask 后的 ``dtype=bool`` 暗 mask;
sample_mask 为空时返回全 False。
关键实现:
各法分派到独立 helper(控复杂度);除 adaptive/clahe 在全图取阈外,统计量
均取自 ``gray[sample_mask]``。``threshold_offset`` 仅对 otsu/percentile/absolute
生效(见 ``_apply_threshold_offset``)。末尾 ``(dark & sample_mask)`` 再过
``apply_dark_morphology``(其内会再次与 sample_mask 取交)。未知方法直接报错(不兜底)。
"""
roi_values = gray[sample_mask]
if len(roi_values) == 0:
return np.zeros_like(sample_mask, dtype=bool)
if method == "otsu":
dark = _candidate_otsu(gray, roi_values)
elif method == "percentile":
dark = _candidate_percentile(gray, roi_values, dark_cfg)
elif method == "absolute":
dark = _candidate_absolute(gray, dark_cfg)
elif method == "adaptive":
dark = _candidate_adaptive(gray, dark_cfg)
elif method == "kmeans":
dark = _candidate_kmeans(sample_mask, roi_values, dark_cfg)
elif method == "clahe_otsu":
dark = _candidate_clahe_otsu(gray, sample_mask, dark_cfg)
else:
raise RuntimeError(f"不支持的 interior_dark_region.threshold_method: {method}")
dark = _apply_threshold_offset(dark, gray, sample_mask, method, dark_cfg)
return apply_dark_morphology(dark & sample_mask, sample_mask, dark_cfg)
def split_dark_components(
dark: AnyArray,
roi_mask: AnyArray,
dark_cfg: _DarkRegionConfigLike,
) -> list[AnyArray]:
"""把候选暗 mask 拆成达标连通域列表(参考 P:1238-1274)。
参数:
dark: 候选暗 mask(bool)。
roi_mask: ROI 掩膜(**未剔绿的原始 ROI**,用于面积阈与组件裁剪)。
dark_cfg: 暗区配置(``min_area`` / ``min_area_ratio`` / ``preserve_holes`` /
``max_regions``)。
返回:
按面积降序排列的连通域 ``dtype=bool`` mask 列表;
每个面积须 ``>= max(min_area, ROI面积×min_area_ratio)``;``max_regions`` 截断。
关键实现:
组件阈 ``max(min_area, roi_mask.sum()×min_area_ratio)``(``min_area_ratio>0`` 才生效)。
``findContours(RETR_CCOMP)`` 取外 / 内两层;仅遍历外轮廓(``hierarchy[3]<0``),
实心填充后——若 ``preserve_holes``——再把其子轮廓(洞)挖回 0,最后与 roi_mask 取交。
``max_regions`` 为 None 时不限。
"""
min_area = float(dark_cfg.min_area)
min_area_ratio = float(dark_cfg.min_area_ratio)
if min_area_ratio > 0:
min_area = max(min_area, float(roi_mask.sum()) * min_area_ratio)
contours, hierarchy = cv2.findContours(
dark.astype(np.uint8), cv2.RETR_CCOMP, cv2.CHAIN_APPROX_SIMPLE
)
# 没有轮廓时 cv2 交回的 hierarchy 是 None。
if not contours:
return []
hierarchy = np.asarray(hierarchy)[0]
preserve_holes = bool(dark_cfg.preserve_holes)
components: list[tuple[int, AnyArray]] = []
for idx, _contour in enumerate(contours):
if hierarchy[idx][3] >= 0:
continue
out = np.zeros_like(dark, dtype=np.uint8)
cv2.drawContours(out, contours, idx, 255, thickness=-1)
if preserve_holes:
child = int(hierarchy[idx][2])
while child >= 0:
cv2.drawContours(out, contours, child, 0, thickness=-1)
child = int(hierarchy[child][0])
component = (out > 0) & roi_mask
area = int(component.sum())
if area >= min_area:
components.append((area, component))
components.sort(key=lambda item: item[0], reverse=True)
max_regions = dark_cfg.max_regions
selected = components if max_regions is None else components[:max_regions]
return [component for _, component in selected]
def _area_score(area_ratio: float, dark_cfg: _DarkRegionConfigLike) -> float:
"""面积分项(参考 P:1298-1310,含斜率 4.0 / 系数 2.0 两个硬编码常量)。
无 ``preferred_area_ratio_range`` 时 ``min(r, 1−r) × 2.0``(偏好"占一半"的暗块);
给定区间则命中得 1.0,越界按 ``max(0, 1 − 与最近边距×4.0)`` 线性衰减。
"""
preferred_range = dark_cfg.preferred_area_ratio_range
if preferred_range is None:
return min(area_ratio, 1.0 - area_ratio) * _AREA_SCORE_GAIN
preferred_low = float(preferred_range[0])
preferred_high = float(preferred_range[1])
if preferred_low <= area_ratio <= preferred_high:
return 1.0
nearest = preferred_low if area_ratio < preferred_low else preferred_high
return max(0.0, 1.0 - abs(area_ratio - nearest) * _AREA_SLOPE)
def score_dark_candidate(
gray: AnyArray,
sample_mask: AnyArray,
component_masks: list[AnyArray],
previous_mask: AnyArray | None,
dark_cfg: _DarkRegionConfigLike,
) -> float:
"""对一份候选暗块集打分,用于 auto_ensemble 择优(参考 P:1277-1319)。
参数:
gray: 整帧灰度图。
sample_mask: 分析采样掩膜(ROI 内、已剔绿)。
component_masks: 候选暗块 mask 列表(``split_dark_components`` 的输出)。
previous_mask: 上一帧选中暗部并集(无则 None),用于时间一致性。
dark_cfg: 暗区配置(``fragment_penalty_weight`` / ``temporal_consistency_weight``
/ 可选 ``preferred_area_ratio_range``)。
返回:
``对比分 + 面积分 + 时间一致性×权重 − 碎片惩罚``;空候选 / 暗或亮像素为空
时返回哨兵 ``-1e9``(择优时永不胜出)。
关键实现(四项逐行核对参考):
- 对比分:``max(0, (median(亮) − median(暗)) / 64.0)``(除数 64.0 硬编码)。
- 面积分:见 ``_area_score``(斜率 4.0 / 系数 2.0)。
- 碎片惩罚:``fragment_penalty_weight × max(0, 块数 − 1)``(块数 ≤1 不罚)。
- 时间一致性:仅当 ``previous_mask`` 同形且非空,取 ``IoU(union, previous_mask)``
再乘 ``temporal_consistency_weight``。
"""
if not component_masks:
return _EMPTY_SCORE
union = np.zeros_like(sample_mask, dtype=bool)
for component in component_masks:
union |= component
dark_values = gray[union & sample_mask]
bright_values = gray[(~union) & sample_mask]
if len(dark_values) == 0 or len(bright_values) == 0:
return _EMPTY_SCORE
contrast_score = max(
0.0,
(float(np.median(bright_values)) - float(np.median(dark_values))) / _CONTRAST_NORMALIZER,
)
area_ratio = float(union.sum()) / max(1.0, float(sample_mask.sum()))
area_score = _area_score(area_ratio, dark_cfg)
fragment_penalty = float(dark_cfg.fragment_penalty_weight) * max(0, len(component_masks) - 1)
temporal_score = 0.0
if previous_mask is not None and previous_mask.shape == union.shape and previous_mask.any():
temporal_score = mask_iou(union, previous_mask)
temporal_weight = float(dark_cfg.temporal_consistency_weight)
return contrast_score + area_score + temporal_score * temporal_weight - fragment_penalty
def _prompt_exclusion_mask(
target_bgr: AnyArray,
dark_cfg: _DarkRegionConfigLike,
prompt_cfg: _PromptHsvConfigLike,
) -> Mask:
"""要从分析中剔除的绿框色像素(参考 P:1334-1345)。
HSV 阈值取 `prompt_cfg`(与找绿框共用同一组阈值);`prompt_exclude_dilate` 大于 1 时按方核
膨胀一次。
"""
lower = np.asarray(prompt_cfg.hsv_lower, dtype=np.uint8)
upper = np.asarray(prompt_cfg.hsv_upper, dtype=np.uint8)
hsv = cv2.cvtColor(target_bgr, cv2.COLOR_BGR2HSV)
prompt_pixels: Mask = np.asarray(cv2.inRange(hsv, lower, upper)) > 0
kernel_size = int(dark_cfg.prompt_exclude_dilate)
if kernel_size > 1:
kernel = np.ones((kernel_size, kernel_size), dtype=np.uint8)
prompt_pixels = cv2.dilate(prompt_pixels.astype(np.uint8) * 255, kernel, iterations=1) > 0
return prompt_pixels
def extract_dark_region_masks(
target_bgr: AnyArray,
roi_polygon: AnyArray,
dark_cfg: _DarkRegionConfigLike,
previous_mask: AnyArray | None,
prompt_cfg: _PromptHsvConfigLike,
) -> tuple[list[AnyArray], AnyArray | None]:
"""单帧暗区提取:剔绿 → 候选法(auto_ensemble 择优)→ 拆分(参考 P:1322-1373)。
参数:
target_bgr: 分析坐标系下的整帧 BGR uint8 图(``analysis_view='source'`` 即左视图局部)。
roi_polygon: 该帧 ROI 多边形 ``(N, 2)``(分析坐标系)。
dark_cfg: 暗区配置。
previous_mask: 上一帧选中暗部并集(供时间一致性评分),首帧传 None。
prompt_cfg: 剔绿用 HSV 阈值(``exclude_prompt_color=True`` 时使用)。
返回:
``(选中暗块 mask 列表, 暗块并集 mask)``;无有效 ROI / 无暗块时返回 ``([], None)``。
关键实现:
``sample_mask`` 由 ROI mask 剔除绿框像素得到(评分统计在其内),但
``split_dark_components`` 的面积阈与裁剪仍用**原始 roi_mask**(与参考 P:1363 一致)。
``threshold_method='auto_ensemble'`` 时遍历 ``candidate_methods`` 各出候选 → 拆分 →
``score_dark_candidate`` 取最高分者;否则单法。返回的并集供下一帧 previous_mask 用。
"""
roi_mask = polygon_to_mask(roi_polygon, target_bgr.shape[0], target_bgr.shape[1])
if not roi_mask.any():
return [], None
gray = cv2.cvtColor(target_bgr, cv2.COLOR_BGR2GRAY)
sample_mask = roi_mask.copy()
if bool(dark_cfg.exclude_prompt_color):
sample_mask &= ~_prompt_exclusion_mask(target_bgr, dark_cfg, prompt_cfg)
if len(gray[sample_mask]) == 0:
return [], None
method = dark_cfg.threshold_method
candidate_methods = list(dark_cfg.candidate_methods) if method == "auto_ensemble" else [method]
components, union = _select_best_candidate(
gray, sample_mask, roi_mask, candidate_methods, previous_mask, dark_cfg
)
if not components:
return [], None
return components, union
def _select_best_candidate(
gray: AnyArray,
sample_mask: AnyArray,
roi_mask: AnyArray,
candidate_methods: list[str],
previous_mask: AnyArray | None,
dark_cfg: _DarkRegionConfigLike,
) -> tuple[list[AnyArray], AnyArray | None]:
"""对每个候选阈值法出暗块 + 评分,取最高分者(参考 P:1360-1373)。
参数:
gray: 整帧灰度图。
sample_mask: 分析采样掩膜(ROI 内、已剔绿)。
roi_mask: 原始 ROI 掩膜(供 ``split_dark_components`` 面积阈与裁剪)。
candidate_methods: 待评估的阈值法名列表(auto_ensemble 即全部,否则单元素)。
previous_mask: 上一帧选中暗部并集(时间一致性评分用)。
dark_cfg: 暗区配置。
返回:
``(最优候选的暗块列表, 其并集 mask)``;无任何候选(列表空)时并集为 None。
关键实现:
逐候选 ``dark_candidate_mask → split_dark_components → score_dark_candidate``,
``score`` 严格大于现任最优才更换(首个候选无条件入选),并随即缓存其并集,
避免对落选候选白算并集。空候选的评分哨兵 ``-1e9`` 保证其永不胜出。
"""
best: tuple[float, list[AnyArray], AnyArray] | None = None
for candidate_method in candidate_methods:
dark = dark_candidate_mask(gray, sample_mask, candidate_method, dark_cfg)
components = split_dark_components(dark, roi_mask, dark_cfg)
score = score_dark_candidate(gray, sample_mask, components, previous_mask, dark_cfg)
if best is None or score > best[0]:
union = np.zeros_like(sample_mask, dtype=bool)
for component in components:
union |= component
best = (score, components, union)
if best is None:
return [], None
return best[1], best[2]
def extract_dark_region_polygons(
analysis_frames: Iterable[AnyArray],
roi_polygons: Sequence[AnyArray],
dark_cfg: _DarkRegionConfigLike,
prompt_cfg: _PromptHsvConfigLike,
) -> list[list[AnyArray]]:
"""逐帧暗区多边形提取编排(参考 P:1376-1466,按本项目可测性裁剪视图映射)。
参数:
analysis_frames: 分析坐标系下的逐帧整帧 BGR uint8 可迭代对象(``analysis_view='source'``
即造影那一半的局部坐标;调用方已解码并切好)。**逐帧消费、不留**,
所以可以直接喂一个解码器的迭代器。
roi_polygons: 与帧序列等长的逐帧 ROI 多边形(同一坐标系)。
dark_cfg: 暗区配置。
prompt_cfg: 剔绿用 HSV 阈值(透传给 ``extract_dark_region_masks``)。
返回:
与帧序列**等长**的逐帧暗部多边形列表(外层帧对齐,内层变长 0~N 个):
每个多边形为 ``(contour_points, 2)`` float32(等弧长重采样)。
``enabled=False`` 时每帧返回空列表 ``[]``(**仍保留帧对齐**,N 帧进 N 帧出)。
关键实现:
逐帧 ``extract_dark_region_masks`` 取选中暗块,其并集作为下一帧时间一致性的
``previous_mask`` 串起来;对每个暗块用 ``significant_contours``(轮廓面积阈
``min_area×0.1``,与参考 P:1437 一致)取显著外轮廓,再
``resample_closed_contour`` 重采样到 ``contour_points`` 点,重采样退化(None)则跳过。
``max_contours_per_region`` 为 None 时不限。
校验帧数与多边形数一致,越界即配置缺陷直接报错(不静默对齐)——流式之后这条校验
由 ``zip(..., strict=True)`` 执行,因为帧数要遍历完才知道。
"""
if not bool(dark_cfg.enabled):
# 关掉也要保持帧对齐(N 帧进 N 帧出),所以仍旧把帧走一遍,按帧数产空列表。
return [[] for _, _ in zip(analysis_frames, roi_polygons, strict=True)]
num_points = int(dark_cfg.contour_points)
contour_area_floor = float(dark_cfg.min_area) * 0.1
max_contours = dark_cfg.max_contours_per_region
dark_polygons_by_frame: list[list[AnyArray]] = []
previous_mask: AnyArray | None = None
for frame, polygon in zip(analysis_frames, roi_polygons, strict=True):
dark_masks, selected_union = extract_dark_region_masks(
frame, polygon, dark_cfg, previous_mask, prompt_cfg
)
previous_mask = selected_union
frame_polygons: list[AnyArray] = []
for dark_mask in dark_masks:
for contour in _significant_contours(dark_mask, contour_area_floor, max_contours):
poly = resample_closed_contour(contour, num_points)
if poly is not None:
frame_polygons.append(poly)
dark_polygons_by_frame.append(frame_polygons)
return dark_polygons_by_frame
def _significant_contours(
mask: AnyArray,
min_area: float,
max_regions: int | None,
) -> list[AnyArray]:
"""取 mask 的显著外轮廓(面积降序,``min_area`` 过滤,可截断;参考 P:665-690)。
参数:
mask: 单个暗块的 ``dtype=bool`` mask。
min_area: 轮廓面积下限(``cv2.contourArea``)。
max_regions: 保留的外轮廓数上限(None 不限)。
返回:
显著轮廓点列表(每个 ``(M, 2)`` float32,OpenCV ``CHAIN_APPROX_NONE`` 原始稠密点)。
含外轮廓与其下的子轮廓(洞)——外轮廓阈 ``min_area``、子轮廓阈 ``min_area×0.1``。
关键实现(严格对照参考 ``significant_contours`` P:665-690):
``RETR_CCOMP`` 取外 / 内两层;外轮廓(``hierarchy[3]<0``)面积 ``>= min_area``,
按面积降序、``max_regions`` 截断;对每个入选外轮廓,沿 ``hierarchy`` 的
first_child(``[idx][2]``)+ next_sibling(``[child][0]``)遍历其洞,面积
``>= min_area×0.1`` 的子轮廓一并追加。``split_dark_components`` 已按
``preserve_holes`` 决定 mask 是否含洞,故洞是否出现由 mask 自然决定。
"""
contours, hierarchy = cv2.findContours(
mask.astype(np.uint8), cv2.RETR_CCOMP, cv2.CHAIN_APPROX_NONE
)
# 没有轮廓时 cv2 交回的 hierarchy 是 None。
if not contours:
return []
hierarchy = np.asarray(hierarchy)[0]
external = [
(cv2.contourArea(contour), idx, contour)
for idx, contour in enumerate(contours)
if hierarchy[idx][3] < 0 and cv2.contourArea(contour) >= min_area
]
external.sort(key=lambda item: item[0], reverse=True)
selected_external = external if max_regions is None else external[:max_regions]
selected: list[AnyArray] = []
for _, idx, contour in selected_external:
selected.append(np.asarray(contour).reshape(-1, 2).astype(np.float32))
child = int(hierarchy[idx][2])
while child >= 0:
child_contour = np.asarray(contours[child])
if cv2.contourArea(child_contour) >= min_area * 0.1:
selected.append(child_contour.reshape(-1, 2).astype(np.float32))
child = int(hierarchy[child][0])
return selected
+322
View File
@@ -0,0 +1,322 @@
"""overlay.mp4:把病灶轮廓、暗区、背景圆叠回整幅画面逐帧渲染,供人目视检查。
各视图局部坐标的多边形加上所属视图的左上角偏移,画到整帧坐标上。颜色(BGR)和线宽照参考组件:
造影那一半(source)的 ROI 黄 (0,255,255)、分割那一半(target)的 ROI 绿 (0,255,0)、暗区橙红
(255,80,20);背景圆画青 (255,255,0) 而不照参考组件画紫,理由见 `_BACKGROUND_CIRCLE_CYAN`。
ROI 半透明填充 alpha=0.18;线条是黑色外描边加彩色内线。
`render_overlay` 接收已解码的逐帧整帧图像(可以是迭代器)和逐帧图层,返回 mp4 字节:
`cv2.VideoWriter` 不能直写内存,所以写临时文件、读回、在 finally 里删掉。fps 由调用方给,非正报错,
不回退 25。
照搬旧项目 `overlay.py`,去掉产品层;`OverlayConfig`、`OverlayLayers` 的字段默认值去掉,调用方全部
显式给出。
"""
import tempfile
from collections.abc import Iterable, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import cv2
import numpy as np
from cadx.ceus._geometry import AnyArray, ViewRect
# 各层颜色(BGR,照旧项目 findings §3.10 / 参考组件 P:1469-1544)。
_OUTLINE_BLACK = (0, 0, 0)
_SOURCE_ROI_YELLOW = (0, 255, 255)
_TARGET_ROI_GREEN = (0, 255, 0)
_DARK_ORANGE_RED = (255, 80, 20)
_BACKGROUND_CIRCLE_CYAN = (255, 255, 0)
"""自动挑的那个背景圆画成青色。**不能是紫**——紫色是医生画在同一画面上的 ``ROI_pan``
轮廓(邻近正常胰腺实质,也就是病变旁边那块没病的胰腺腺体组织)的颜色,而这个圆是程序
自己找的一小块干净背景,两者是不同的东西(旧项目 design/0030 决策四)。参考实现
把它画成品红 (255,0,255),而紫色轮廓已经在算了(``tic.py`` 的 ``CURVE_NAMES`` 四条里就有
``roi_pan``),两圈都是紫的话叠加在视频上人眼分不出哪个是参照区、哪个是医生画的——
而**看 overlay 是第一步唯一的人工验收手段**,分不出就等于没验。
青色和绿框、紫色轮廓在色相上都隔得开。"""
# ROI 半透明填充透明度(P:1500 硬编码 0.18)。
_FILL_ALPHA = 0.18
@dataclass(frozen=True)
class OverlayConfig:
"""各画层开关与填充:画不画造影那一半的 ROI(黄)、分割那一半的 ROI(绿)、分割那一半的暗区、
造影那一半的暗区、背景圆(青),以及要不要对分割那一半的 ROI 做 alpha=0.18 半透明填充。"""
draw_source_roi: bool
draw_target_polygon: bool
draw_dark_region: bool
draw_source_dark_region: bool
draw_reference_region: bool
fill_polygon: bool
@dataclass(frozen=True)
class OverlayLayers:
"""一帧要画的各层几何(多边形都是各自视图的局部坐标)。
- `target_polygon`、`target_rect`:分割那一半的 ROI 和它在整帧中的矩形;
- `dark_polygons`:分割那一半的暗区多边形(可为空列表);
- `source_roi_polygon`、`source_rect`:造影那一半的 ROI 和矩形(不画时可为 None);
- `source_dark_polygons`:造影那一半的暗区多边形(可为空列表);
- `reference_region`:背景圆元数据(`enabled`、`center_xy`、`radius_px`,圆心是造影那一半的
局部坐标),没有时为 None。
"""
target_polygon: AnyArray
target_rect: ViewRect
dark_polygons: list[AnyArray]
source_roi_polygon: AnyArray | None
source_rect: ViewRect | None
source_dark_polygons: list[AnyArray]
reference_region: dict[str, Any] | None
def _to_global_points(polygon: AnyArray, rect: ViewRect) -> AnyArray:
"""把视图局部坐标多边形抬到整帧坐标,并量化为 cv2 折线点 ``(M, 1, 2)`` int32。
参数:
polygon: 形如 ``(N, 2)`` 的局部坐标顶点。
rect: 该多边形所属视图在整帧中的矩形(提供左上角偏移)。
返回:
``cv2.polylines`` / ``cv2.fillPoly`` 直接可用的 ``(N, 1, 2)`` int32 点阵。
关键实现:
加 ``rect.x1`` / ``rect.y1`` 偏移后四舍五入取整(参考 P:1487-1489),先 copy
避免就地污染入参多边形。
"""
global_poly = polygon.copy().astype(np.float64)
global_poly[:, 0] += rect.x1
global_poly[:, 1] += rect.y1
return np.round(global_poly).astype(np.int32).reshape(-1, 1, 2)
def _draw_outlined_polygon(
frame: AnyArray,
points: AnyArray,
color: tuple[int, int, int],
outline_thickness: int,
color_thickness: int,
) -> None:
"""就地画"黑色外描边 + 彩色内线"的闭合多边形(参考 P:1490-1491 等的双描边手法)。
参数:
frame: 待绘制的整帧(就地修改)。
points: ``(N, 1, 2)`` int32 折线点阵(整帧坐标)。
color: 彩色内线 BGR。
outline_thickness: 黑色外描边线宽(> 彩色线宽,作描边效果)。
color_thickness: 彩色内线线宽。
"""
cv2.polylines(
frame,
[points],
isClosed=True,
color=_OUTLINE_BLACK,
thickness=outline_thickness,
)
cv2.polylines(frame, [points], isClosed=True, color=color, thickness=color_thickness)
def _fill_target_polygon(frame: AnyArray, points: AnyArray) -> AnyArray:
"""对 target ROI 内部做 alpha=0.18 绿色半透明填充(参考 P:1497-1500)。
参数:
frame: 当前帧。
points: target ROI 整帧坐标折线点阵。
返回:
混合后的新帧(``addWeighted(fill, 0.18, frame, 0.82, 0)``)。
关键实现:
在帧副本上实心填充绿色后与原帧按 0.18 / 0.82 加权混合,得到半透明效果。
"""
fill = frame.copy()
cv2.fillPoly(fill, [points], _TARGET_ROI_GREEN)
return cv2.addWeighted(fill, _FILL_ALPHA, frame, 1.0 - _FILL_ALPHA, 0)
def _draw_source_roi_layer(frame: AnyArray, layers: OverlayLayers) -> None:
"""画左 / source ROI 轮廓(黄,线宽 5→2,参考 P:1481-1491)。需 source_rect。"""
if layers.source_roi_polygon is None or layers.source_rect is None:
return
points = _to_global_points(layers.source_roi_polygon, layers.source_rect)
_draw_outlined_polygon(frame, points, _SOURCE_ROI_YELLOW, 5, 2)
def _draw_dark_layer(
frame: AnyArray,
polygons: Sequence[AnyArray],
rect: ViewRect,
outline_thickness: int,
color_thickness: int,
) -> None:
"""画一组暗区轮廓(橙红,参考 P:1504-1526);逐多边形抬到整帧再双描边。
参数:
frame: 当前帧(就地修改)。
polygons: 暗区局部坐标多边形列表。
rect: 这些暗区所属视图矩形(target 或 source)。
outline_thickness: 黑色外描边线宽(target 6 / source 5,参考 P:1510/1525)。
color_thickness: 橙红内线线宽(target 3 / source 2,参考 P:1511/1526)。
"""
for polygon in polygons:
points = _to_global_points(polygon, rect)
_draw_outlined_polygon(frame, points, _DARK_ORANGE_RED, outline_thickness, color_thickness)
def _draw_reference_layer(frame: AnyArray, layers: OverlayLayers) -> None:
"""画 source 视图那个自动挑的背景圆(青,参考 P:1528-1543 画的是紫)。
关键实现:
仅当 ``reference_region.enabled`` 且 ``source_rect`` / ``center_xy`` /
``radius_px`` 齐备时绘制;圆心由 source 局部坐标加 ``source_rect`` 偏移抬到整帧。
黑色外描边线宽 6、青色内线线宽 3(参考 P:1542-1543)。
"""
region = layers.reference_region
if region is None or layers.source_rect is None or not region.get("enabled", False):
return
center = region.get("center_xy")
radius = region.get("radius_px")
if center is None or radius is None:
return
center_global = (
round(float(center[0]) + layers.source_rect.x1),
round(float(center[1]) + layers.source_rect.y1),
)
radius_int = round(float(radius))
cv2.circle(frame, center_global, radius_int, color=_OUTLINE_BLACK, thickness=6)
cv2.circle(frame, center_global, radius_int, color=_BACKGROUND_CIRCLE_CYAN, thickness=3)
def draw_polygon_overlay(
frame: AnyArray,
layers: OverlayLayers,
cfg: OverlayConfig,
) -> AnyArray:
"""在整帧坐标系上叠加 ROI / 暗区 / reference 各层,返回画好的新帧(不改入参)。
参数:
frame: 整帧 BGR 图像 ``(H, W, 3)``。
layers: 单帧各层几何(局部坐标多边形 + 视图矩形)。
cfg: 各画层开关与填充配置。
返回:
叠加后的整帧副本(尺寸 / dtype 与入参一致)。
关键实现:
先 copy 帧避免污染入参;按参考 P:1469-1544 的层序绘制——source ROI(黄)→
target ROI 填充 + 轮廓(绿)→ target 暗区(橙红)→ source 暗区(橙红)→
背景圆(青)。各层的局部→整帧坐标变换在 ``_to_global_points`` 内统一完成。
填充层(``fill_polygon``)须先于轮廓绘制,否则填充会盖住轮廓。
"""
out = frame.copy()
# Phase 1: source ROI 轮廓(黄)。
if cfg.draw_source_roi:
_draw_source_roi_layer(out, layers)
# Phase 2: target ROI——先半透明填充,再画绿色轮廓(主产物)。
target_points = _to_global_points(layers.target_polygon, layers.target_rect)
if cfg.fill_polygon:
out = _fill_target_polygon(out, target_points)
if cfg.draw_target_polygon:
_draw_outlined_polygon(out, target_points, _TARGET_ROI_GREEN, 7, 3)
# Phase 3: target 暗区轮廓(橙红)。
if cfg.draw_dark_region and layers.dark_polygons:
_draw_dark_layer(out, layers.dark_polygons, layers.target_rect, 6, 3)
# Phase 4: source 暗区轮廓(橙红)。
if (
cfg.draw_source_dark_region
and layers.source_dark_polygons
and layers.source_rect is not None
):
_draw_dark_layer(out, layers.source_dark_polygons, layers.source_rect, 5, 2)
# Phase 5: 背景圆(青)。
if cfg.draw_reference_region:
_draw_reference_layer(out, layers)
return out
def render_overlay(
frames: Iterable[AnyArray],
per_frame_layers: Sequence[OverlayLayers],
fps: float,
cfg: OverlayConfig,
*,
frame_size: tuple[int, int],
) -> bytes:
"""逐帧渲染 overlay 并编码为 mp4 字节(fourcc mp4v、原帧尺寸、调用方 fps)。
参数:
frames: 已解码的逐帧整帧 BGR 图像,非空且尺寸一致。**逐帧消费、不留**,
所以可以直接喂一个解码器的迭代器。
per_frame_layers: 与 ``frames`` 一一对应的逐帧图层;长度须等于帧数。
fps: 视频帧率,**必须 > 0**(调用方已探测);非正直接报错,不静默回退 25.0。
cfg: 各画层开关与填充配置。
frame_size: 输出视频的 ``(width, height)``。**由调用方给,不再从首帧读**——
编码器要在写第一帧之前就建好,而流式之后「先看一眼首帧再决定尺寸」意味着
要么把首帧留住、要么把迭代器消费掉一帧。调用方本来就从探测拿到了这个尺寸。
返回:
mp4 视频字节(mp4v 编码),由调用方写文件。
异常:
ValueError: ``frames`` 为空、``frames`` 与 ``per_frame_layers`` 长度不一致
(由 ``zip`` 的 ``strict`` 执行——流式之后帧数要遍历完才知道)、
``fps`` 非正,或 ``frame_size`` 非正。
RuntimeError: ``cv2.VideoWriter`` 无法打开(编码器不可用 / 尺寸非法)。
关键实现:
``cv2.VideoWriter`` 不能直写内存,故写到临时 ``.mp4`` 文件、读回字节、``finally``
``unlink`` 删除(仿 ``roi_inference/frames.py`` 临时文件纪律,确保异常路径也清理);
本模块不落本地、不碰对象存储。逐帧 ``draw_polygon_overlay`` 保持帧尺寸不变,
故无需逐帧重校尺寸。
"""
# Phase 1: 防御校验(不静默兜底,缺陷直接报错)。
if float(fps) <= 0.0:
raise ValueError(f"fps 必须为正(调用方应已探测),得到 {fps}。")
width, height = frame_size
if width <= 0 or height <= 0:
raise ValueError(f"frame_size 必须为正,得到 {frame_size}。")
if not per_frame_layers:
raise ValueError("per_frame_layers 不能为空:一帧都不写产不出视频。")
# Phase 2: 写临时 mp4,结束(含异常)后必删。
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as handle:
temp_path = Path(handle.name)
try:
writer = cv2.VideoWriter(
str(temp_path),
cv2.VideoWriter.fourcc(*"mp4v"),
float(fps),
(width, height),
)
if not writer.isOpened():
raise RuntimeError(f"cv2.VideoWriter 无法打开(尺寸 {width}x{height}、fps {fps})。")
try:
# strict:帧数要遍历完才知道,长度不一致在这里当场抛,不静默按较短的截断。
for index, (frame, layers) in enumerate(zip(frames, per_frame_layers, strict=True)):
if index == 0 and frame.shape[:2] != (height, width):
# **尺寸不符的帧会被 VideoWriter 静默丢掉**:实测写 6 帧读回 3 帧,
# 只在 stderr 留一行警告,不抛异常也不改返回值。结果是 overlay 视频
# 短一截甚至是空的,而它照样有字节、照样被写成文件。
# 尺寸以前由首帧决定所以结构上不可能不符,现在由调用方给,这条得自己守。
raise ValueError(
f"帧尺寸 {frame.shape[:2]} 与 frame_size {(width, height)} 不符;"
"尺寸不符的帧会被编码器静默丢掉"
)
writer.write(draw_polygon_overlay(frame, layers, cfg))
finally:
writer.release()
return temp_path.read_bytes()
finally:
temp_path.unlink(missing_ok=True)
+151
View File
@@ -0,0 +1,151 @@
"""第 9 步:从过了质量门的 Lognormal 拟合曲线算 11 个灌注指标(口径仿照商用造影定量软件 VueBOX)。
指标在 MP4 显示灰度域上算(不是线性化的回声功率),时间以视频开头为零点,面积用「拟合曲线减
基线」。依赖观测范围的指标(如洗出段面积)在观测不够时为 None,不外推。
- PE:峰值增强(峰值减基线);TTP:达峰时间(D3);WiR:最大洗入斜率(D2 处导数);
- TI:洗入切线与基线的交点;RT:上升时间 TTP − TI;WiAUC:TI 到 TTP 的面积;WiPI:WiAUC / RT;
- WoR:最大洗出斜率(D4 处导数);TO:洗出切线与基线的交点;FT:下降时间 TO − TTP;
- WoAUC:TTP 到 TO 的面积;WiWoAUC:两段面积之和;mTT:TI 到 TO 之间的平均渡越时刻;mTTl:mTT − TI。
照搬旧项目 `perfusion_metrics.py`,去掉产品层。
"""
import math
from dataclasses import dataclass
import numpy as np
from cadx.ceus._tic_fitting import (
LognormalFitResult,
LognormalParameters,
evaluate_lognormal,
evaluate_lognormal_derivative,
)
@dataclass(frozen=True)
class LognormalPerfusionMetrics:
"""11 项指标及三个支撑时刻(TI、mTT、TO),单位见字段名后缀;观测不够的为 None。"""
pe_au: float
wi_auc_au_s: float | None
rt_s: float | None
mttl_s: float | None
ttp_s: float
wir_au_per_s: float
wipi_au: float | None
wo_auc_au_s: float | None
wiwo_auc_au_s: float | None
ft_s: float | None
wor_au_per_s: float | None
ti_s: float | None
mtt_s: float | None
to_s: float | None
def _normal_cdf(value: float) -> float:
return 0.5 * (1.0 + math.erf(value / math.sqrt(2.0)))
def _elapsed_cdf(time_s: float, parameters: LognormalParameters) -> float:
elapsed_s = time_s - parameters.arrival_offset_s
if elapsed_s <= 0.0:
return 0.0
return _normal_cdf((math.log(elapsed_s) - parameters.mu) / parameters.sigma)
def _elapsed_first_moment_cdf(time_s: float, parameters: LognormalParameters) -> float:
elapsed_s = time_s - parameters.arrival_offset_s
if elapsed_s <= 0.0:
return 0.0
standardized = (math.log(elapsed_s) - parameters.mu - parameters.sigma**2) / parameters.sigma
return math.exp(parameters.mu + 0.5 * parameters.sigma**2) * _normal_cdf(standardized)
def _pulse_auc(start_s: float, end_s: float, parameters: LognormalParameters) -> float:
if end_s <= start_s:
raise ValueError("Lognormal 面积终点必须晚于起点")
return parameters.area * (_elapsed_cdf(end_s, parameters) - _elapsed_cdf(start_s, parameters))
def _pulse_first_moment(start_s: float, end_s: float, parameters: LognormalParameters) -> float:
probability = _elapsed_cdf(end_s, parameters) - _elapsed_cdf(start_s, parameters)
elapsed_moment = _elapsed_first_moment_cdf(end_s, parameters) - _elapsed_first_moment_cdf(
start_s, parameters
)
return parameters.area * (parameters.arrival_offset_s * probability + elapsed_moment)
def _observed_time(value: float, lower: float, upper: float) -> float | None:
return value if lower <= value <= upper else None
def compute_lognormal_perfusion_metrics(
fit: LognormalFitResult,
) -> LognormalPerfusionMetrics | None:
"""拟合没过质量门时返回 None;过了就算 11 项指标,观测范围不够的那几项为 None。"""
if not fit.success:
return None
parameters = fit.parameters
if parameters is None or fit.d2_time_s is None or fit.d3_time_s is None:
raise ValueError("成功 Lognormal 拟合缺少参数或洗入/峰值时刻")
d2_time_s = float(fit.d2_time_s)
ttp_s = float(fit.d3_time_s)
peak_value = float(evaluate_lognormal(np.asarray([ttp_s]), parameters)[0])
pe_au = peak_value - parameters.baseline
wir_au_per_s = evaluate_lognormal_derivative(d2_time_s, parameters)
if not math.isfinite(pe_au) or pe_au <= 0.0:
raise ValueError("成功 Lognormal 拟合的峰值增强必须为有限正数")
if not math.isfinite(wir_au_per_s) or wir_au_per_s <= 0.0:
raise ValueError("成功 Lognormal 拟合的最大洗入斜率必须为有限正数")
d2_value = float(evaluate_lognormal(np.asarray([d2_time_s]), parameters)[0])
ti_candidate_s = d2_time_s - (d2_value - parameters.baseline) / wir_au_per_s
ti_s = _observed_time(ti_candidate_s, fit.fit_start_s, d2_time_s)
rt_s = None if ti_s is None else ttp_s - ti_s
wi_auc_au_s = None if ti_s is None else _pulse_auc(ti_s, ttp_s, parameters)
wipi_au = None if wi_auc_au_s is None or rt_s is None else wi_auc_au_s / rt_s
wor_au_per_s: float | None = None
to_s: float | None = None
ft_s: float | None = None
wo_auc_au_s: float | None = None
if fit.d4_time_s is not None:
d4_time_s = float(fit.d4_time_s)
wor_au_per_s = evaluate_lognormal_derivative(d4_time_s, parameters)
if not math.isfinite(wor_au_per_s) or wor_au_per_s >= 0.0:
raise ValueError("成功 Lognormal 拟合的最大洗出斜率必须为有限负数")
d4_value = float(evaluate_lognormal(np.asarray([d4_time_s]), parameters)[0])
to_candidate_s = d4_time_s - (d4_value - parameters.baseline) / wor_au_per_s
to_s = _observed_time(to_candidate_s, d4_time_s, fit.fit_end_s)
if to_s is not None:
ft_s = to_s - ttp_s
wo_auc_au_s = _pulse_auc(ttp_s, to_s, parameters)
wiwo_auc_au_s = (
None if wi_auc_au_s is None or wo_auc_au_s is None else wi_auc_au_s + wo_auc_au_s
)
mtt_s: float | None = None
mttl_s: float | None = None
if ti_s is not None and to_s is not None and wiwo_auc_au_s is not None:
mtt_s = _pulse_first_moment(ti_s, to_s, parameters) / wiwo_auc_au_s
mttl_s = mtt_s - ti_s
return LognormalPerfusionMetrics(
pe_au=pe_au,
wi_auc_au_s=wi_auc_au_s,
rt_s=rt_s,
mttl_s=mttl_s,
ttp_s=ttp_s,
wir_au_per_s=wir_au_per_s,
wipi_au=wipi_au,
wo_auc_au_s=wo_auc_au_s,
wiwo_auc_au_s=wiwo_auc_au_s,
ft_s=ft_s,
wor_au_per_s=wor_au_per_s,
ti_s=ti_s,
mtt_s=mtt_s,
to_s=to_s,
)
+480
View File
@@ -0,0 +1,480 @@
"""ceus 对外的两段接口,中间由 `scripts/ceus_quant.py` 调 segment 做第 3 步分割。
- `prepare_segmentation`(第 1–2 步):读视频、找分屏线、判断造影画面在哪一半、选提示帧,交出
要交给分割的那一半(灰阶那一半,`target_view`)的逐帧图像和提示帧掩膜(`target_view` 局部坐标)。
- `quantify_case`(第 4–9 步):拿分割得到的逐帧掩膜(`target_view` 局部坐标)整理成平滑轮廓并与
绿框融合、挪回造影那一半,找暗区、选背景圆、逐帧采 TIC、拟合、定位 7 个关键帧、算灌注指标。
ceus 不引用 segment(`结构.md` 第三部分),所以分割夹在两段中间。编排顺序照旧 debug 脚本
`t1_local_debug.py`(第 1–6 步)和 `tic_lognormal_replay.py`(第 6–9 步):旧 replay 读回轨迹后
重新从视频采了一遍 TIC,两遍输入相同,这里只采一遍。
另有给第三阶段脚本写产物用的几个函数:`tic_plot_markers`(tic.png 上 D1–D4 的竖线)、
`render_case_overlay`(overlay.mp4 的字节)、`keyframe_images`(定位到的关键帧整幅原图)、
`quant_summary`(summary.json 里拟合、指标、关键帧那几段)。
"""
import math
from collections.abc import Iterator
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import numpy as np
from cadx.ceus._anchor_frames import (
ANCHOR_SLOTS,
STATUS_OK,
AnchorFrameLocation,
locate_anchor_frames,
)
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._overlay import OverlayConfig, OverlayLayers, render_overlay
from cadx.ceus._perfusion_metrics import (
LognormalPerfusionMetrics,
compute_lognormal_perfusion_metrics,
)
from cadx.ceus._polygons import (
apply_prompt_frame_position_constraints,
fuse_prompt_prior_with_medsam,
masks_to_smoothed_polygons,
)
from cadx.ceus._reference_region import select_reference_region
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
from cadx.ceus._tic_fitting import LognormalFitResult, evaluate_lognormal
from cadx.ceus._tic_plot import TicPlotMarker
from cadx.ceus._trajectory import Trajectory
from cadx.ceus._video import VideoFile
# 灌注指标只对病灶两条曲线算(同旧 replay 的 `_LESION_CURVES`)。
LESION_CURVES = ("roi_all", "roi_perf")
# tic.png 上 D1–D4 竖线的颜色(BGR,同旧 replay)。
_ANCHOR_COLORS = {
"D1": (30, 30, 220),
"D2": (40, 150, 40),
"D3": (180, 60, 160),
"D4": (180, 130, 30),
}
# overlay.mp4 的画层(同旧 debug 脚本 `render_overlay_bytes`):两半都画 ROI,暗区只画在造影那一半。
_OVERLAY_CONFIG = OverlayConfig(
draw_source_roi=True,
draw_target_polygon=True,
draw_dark_region=False,
draw_source_dark_region=True,
draw_reference_region=True,
fill_polygon=False,
)
@dataclass(frozen=True)
class SegmentationInput:
"""第一段(第 1–2 步)的结果。
- `video_path`、`fps`、`frame_count`、`frame_size`(宽, 高)、`frame_times_s`:视频本身;
- `split_x`:分屏线的列;`contrast`:哪一半是造影画面、两个视图矩形(整帧坐标)、提示帧
(造影那一半局部坐标的绿框掩膜);
- `target_frames`:要交给分割的那一半(`contrast.target_view`,灰阶画面)的逐帧 BGR 图像,
与帧号一一对应;
- `prompt_masks`:提示帧帧号 → 这一帧绿框挪到 `target_view` 局部坐标后的布尔掩膜;
- `first_contrast_frame`:造影那一半的第一帧(选背景圆用)。
"""
video_path: Path
fps: float
frame_count: int
frame_size: tuple[int, int]
frame_times_s: AnyArray
split_x: int
contrast: ContrastSide
target_frames: list[Frame]
prompt_masks: dict[int, Mask]
first_contrast_frame: Frame
@dataclass(frozen=True)
class QuantResult:
"""第二段(第 4–9 步)的结果。
- `trajectory`:逐帧轮廓、暗区、背景圆和几何(写 trajectory.npz 用 `pack_trajectory`);
- `tic`:逐帧采样和四条曲线的基线、拟合;`curves`:同一份数据的 npz 载体(`pack_tic_curves`);
- `injection_offset_s`:推注时间 X(秒,视频第 0 秒时已推注的时长);
- `lesion_fit_ready`:病灶两条曲线的拟合是否都过了质量门;
- `anchors`:7 个关键帧槽位的定位(按 `ANCHOR_SLOTS` 顺序);
- `perfusion`:`roi_all`、`roi_perf` 各自的灌注指标,拟合没过质量门为 None;
- `pan_detected_fraction`:紫圈检出率(非重复帧里认出紫圈的帧占比)。
"""
trajectory: Trajectory
tic: TicResult
curves: TicCurves
injection_offset_s: float
lesion_fit_ready: bool
anchors: dict[str, AnchorFrameLocation]
perfusion: dict[str, LognormalPerfusionMetrics | None]
pan_detected_fraction: float
def prepare_segmentation(video_path: Path, cfg: CeusConfig) -> SegmentationInput:
"""第 1–2 步:解码、分屏、找绿框定造影那一半、选提示帧,交出分割的输入。
找不到可信分屏线抛 `SplitDetectionError`(第 1 步),两半都有或都没有绿框抛 `ContrastSideError`
(第 2 步),两者都是 `CeusCaseError`。
"""
video = VideoFile(video_path)
fps, frame_count, width, height = video.probe()
frame_times_s = video.frame_times_s()
full_frames = [frame for _, frame in video.iter_frames()]
if len(full_frames) != frame_count:
raise RuntimeError(
f"解码帧数 {len(full_frames)} 与容器记录的帧数 {frame_count} 不一致:{video_path}"
)
split_x, left_view, right_view = split_views(full_frames, cfg.views)
contrast = locate_contrast_side(
full_frames, left_view, right_view, cfg.prompt_source, cfg.keyframes
)
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
)
for prompt in contrast.prompt_frames
}
return SegmentationInput(
video_path=video_path,
fps=fps,
frame_count=frame_count,
frame_size=(width, height),
frame_times_s=frame_times_s,
split_x=split_x,
contrast=contrast,
target_frames=target_frames,
prompt_masks=prompt_masks,
first_contrast_frame=first_contrast_frame,
)
def _contrast_frames(prepared: SegmentationInput) -> Iterator[Frame]:
"""重新解码,逐帧交出造影那一半(不把整段留在内存里)。"""
for _, frame in VideoFile(prepared.video_path).iter_frames():
yield crop_frame(frame, prepared.contrast.contrast_view)
def _validate_masks(prepared: SegmentationInput, target_masks: AnyArray) -> None:
"""分割结果必须是 `(帧数, target 高, target 宽)` 的布尔数组。"""
view = prepared.contrast.target_view
expected = (prepared.frame_count, view.height, view.width)
if target_masks.dtype != np.bool_ or target_masks.shape != expected:
raise ValueError(
f"分割掩膜应为形状 {expected} 的布尔数组,"
f"收到 {target_masks.shape} {target_masks.dtype}"
)
def quantify_case(
prepared: SegmentationInput,
target_masks: AnyArray,
injection_offset_s: float,
cfg: CeusConfig,
) -> QuantResult:
"""第 4–9 步:轮廓、暗区、背景圆、TIC、拟合、关键帧、灌注指标。
`target_masks` 是分割在 `target_view` 局部坐标上的逐帧掩膜 `(N, H, W)` 布尔数组;
`injection_offset_s` 是超声报告表的「推注开始时间s」X。分割掩膜一帧有效轮廓都没有时抛
`NoValidContourError`(第 4 步,`CeusCaseError`)。拟合没过质量门不算失败:D1–D4 未定位、
灌注指标为 None。
"""
_validate_masks(prepared, target_masks)
contrast = prepared.contrast
frame_count = prepared.frame_count
prompt_frame_dicts: list[dict[str, Any]] = [
{"frame_index": prompt.frame_index, "mask": prompt.mask}
for prompt in contrast.prompt_frames
]
# 第 4 步:掩膜 → 平滑多边形 → 位置约束 → 与绿框融合 → 挪回造影那一半。
masks_by_frame = {index: target_masks[index] for index in range(frame_count)}
target_polygons = masks_to_smoothed_polygons(masks_by_frame, frame_count, cfg.smoothing)
target_polygons = apply_prompt_frame_position_constraints(
target_polygons,
prompt_frame_dicts,
contrast.contrast_view,
contrast.target_view,
cfg.constraints.keyframe_position,
)
target_polygons = fuse_prompt_prior_with_medsam(
target_polygons,
prompt_frame_dicts,
contrast.contrast_view,
contrast.target_view,
cfg.roi_shape,
cfg.smoothing,
)
source_polygons = target_polygons_to_source(
target_polygons, contrast.contrast_view, contrast.target_view
)
# 第 5 步:暗区(在造影那一半上找)。
dark_polygons = extract_dark_region_polygons(
_contrast_frames(prepared),
source_polygons,
cfg.interior_dark_region,
cfg.prompt_source,
)
# 第 6 步:背景圆、四条 TIC;第 7 步拟合在 compute_tic_for_video 里。
source_polygon_array = np.asarray(source_polygons, dtype=np.float32)
reference_mask, reference_meta = select_reference_region(
prepared.first_contrast_frame,
source_polygon_array,
cfg.prompt_source,
cfg.pan_source,
cfg.tic_analysis,
)
tic = compute_tic_for_video(
_contrast_frames(prepared),
source_polygons,
dark_polygons,
reference_mask,
prepared.fps,
cfg.prompt_source,
cfg.pan_source,
cfg.tic_analysis,
prepared.frame_times_s,
)
# 第 8 步:关键帧。
lesion_fit_ready = all(tic.fits[name].success for name in LESION_CURVES)
frame_indices = np.arange(frame_count, dtype=np.int32)
anchors = locate_anchor_frames(
perf_fit=tic.fits["roi_perf"],
perf_baseline_end_index=tic.baselines["roi_perf"].frame_end,
lesion_fit_ready=lesion_fit_ready,
frame_indices=frame_indices,
time_video_s=prepared.frame_times_s,
injection_offset_s=injection_offset_s,
cfg=cfg.tic_analysis.anchor_frames,
)
# 第 9 步:灌注指标。
perfusion = {
name: compute_lognormal_perfusion_metrics(tic.fits[name]) for name in LESION_CURVES
}
trajectory = Trajectory(
frame_indices=frame_indices,
fps=float(prepared.fps),
target_polygons=np.asarray(target_polygons, dtype=np.float32),
source_polygons=source_polygon_array,
source_view_rect=contrast.contrast_view,
target_view_rect=contrast.target_view,
source_effective_rect=contrast.contrast_view,
target_effective_rect=contrast.target_view,
source_polygon_coordinate_space="source_view_local",
target_polygon_coordinate_space="target_effective_view_local",
reference_mask=reference_mask.astype(np.uint8),
reference_region=reference_meta,
keyframe_indices=np.asarray(
[prompt.frame_index for prompt in contrast.prompt_frames], dtype=np.int32
),
dark_polygons=dark_polygons,
calibration={
"split_x": prepared.split_x,
"source": "auto_detected",
"contrast_side": contrast.side,
},
)
curves = from_tic_result(
tic,
fps=prepared.fps,
injection_offset_s=injection_offset_s,
config_version=cfg.config_version,
)
return QuantResult(
trajectory=trajectory,
tic=tic,
curves=curves,
injection_offset_s=injection_offset_s,
lesion_fit_ready=lesion_fit_ready,
anchors=anchors,
perfusion=perfusion,
pan_detected_fraction=tic.pan_detected_fraction,
)
def tic_plot_markers(anchors: dict[str, AnchorFrameLocation]) -> tuple[TicPlotMarker, ...]:
"""tic.png 上 D1–D4 的竖线:有锚点时刻的画在锚点时刻,没定位到帧的在标签后注明状态。"""
markers: list[TicPlotMarker] = []
for slot in ("D1", "D2", "D3", "D4"):
anchor = anchors[slot]
if anchor.anchor_time_video_s is None:
continue
status = "" if anchor.status == STATUS_OK else f" [{anchor.status}]"
markers.append(
TicPlotMarker(f"{slot} model{status}", anchor.anchor_time_video_s, _ANCHOR_COLORS[slot])
)
return tuple(markers)
def render_case_overlay(prepared: SegmentationInput, result: QuantResult) -> bytes:
"""overlay.mp4 的字节:重新解码整段视频,两半都画 ROI,造影那一半画暗区和背景圆。"""
trajectory = result.trajectory
layers = [
OverlayLayers(
target_polygon=trajectory.target_polygons[index],
target_rect=trajectory.target_view_rect,
dark_polygons=[],
source_roi_polygon=trajectory.source_polygons[index],
source_rect=trajectory.source_view_rect,
source_dark_polygons=list(trajectory.dark_polygons[index]),
reference_region=trajectory.reference_region,
)
for index in range(prepared.frame_count)
]
frames = (frame for _, frame in VideoFile(prepared.video_path).iter_frames())
return render_overlay(
frames, layers, prepared.fps, _OVERLAY_CONFIG, frame_size=prepared.frame_size
)
def keyframe_images(
prepared: SegmentationInput, anchors: dict[str, AnchorFrameLocation]
) -> dict[str, Frame]:
"""定位到的关键帧的整幅原始画面(左右两半都在,不裁剪),槽位 → BGR 图像;未定位的槽位没有。"""
wanted: dict[int, list[str]] = {}
for slot, anchor in anchors.items():
if anchor.frame_index is not None:
wanted.setdefault(anchor.frame_index, []).append(slot)
images: dict[str, Frame] = {}
for index, frame in VideoFile(prepared.video_path).iter_frames():
for slot in wanted.get(index, []):
images[slot] = frame.copy()
missing = sorted(slot for slots in wanted.values() for slot in slots if slot not in images)
if missing:
raise RuntimeError(f"重新解码时没读到关键帧 {missing}:{prepared.video_path}")
return {slot: images[slot] for slot in ANCHOR_SLOTS if slot in images}
def _finite_or_none(value: float | None) -> float | None:
"""非有限数(NaN、inf)在 JSON 里写 null。"""
if value is None or not math.isfinite(float(value)):
return None
return float(value)
def _fit_summary(fit: LognormalFitResult) -> dict[str, Any]:
"""一条曲线的拟合摘要(同旧 replay 的 `_fit_summary`)。"""
parameters = fit.parameters
anchor_times = {
"D2": _finite_or_none(fit.d2_time_s),
"D3": _finite_or_none(fit.d3_time_s),
"D4": _finite_or_none(fit.d4_time_s),
}
anchor_values = {
slot: None
if parameters is None or time_s is None
else float(evaluate_lognormal(np.asarray([time_s]), parameters)[0])
for slot, time_s in anchor_times.items()
}
return {
"success": fit.success,
"failure_reason": fit.failure_reason or None,
"curve_model": fit.curve_model,
"baseline_source": fit.baseline_source,
"valid_samples": int(fit.valid_sample_mask.sum()),
"duplicate_samples": int(fit.duplicate_sample_mask.sum()),
"parameters": None
if parameters is None
else {
"area": parameters.area,
"mu": parameters.mu,
"sigma": parameters.sigma,
"arrival_offset_s": parameters.arrival_offset_s,
"baseline": parameters.baseline,
},
"quality": {
"r2": _finite_or_none(fit.r2),
"nrmse": _finite_or_none(fit.nrmse),
"residual_noise_ratio": _finite_or_none(fit.residual_noise_ratio),
"multi_peak_flag": fit.multi_peak_flag,
},
"model_arrival_time_video_s": _finite_or_none(fit.d1_time_s),
"model_landmark_times_video_s": anchor_times,
"model_landmark_intensities": anchor_values,
}
def _perfusion_summary(metrics: LognormalPerfusionMetrics | None) -> dict[str, Any] | None:
"""灌注指标摘要(同旧 replay 的 `_perfusion_metrics_summary`),拟合没过质量门为 None。"""
if metrics is None:
return None
return {
"signal_domain": "db_display",
"time_origin": "video_start",
"area_curve": "fitted_minus_baseline",
"values": {
"PE_au": metrics.pe_au,
"WiAUC_au_s": metrics.wi_auc_au_s,
"RT_s": metrics.rt_s,
"mTTl_s": metrics.mttl_s,
"TTP_s": metrics.ttp_s,
"WiR_au_per_s": metrics.wir_au_per_s,
"WiPI_au": metrics.wipi_au,
"WoAUC_au_s": metrics.wo_auc_au_s,
"WiWoAUC_au_s": metrics.wiwo_auc_au_s,
"FT_s": metrics.ft_s,
"WoR_au_per_s": metrics.wor_au_per_s,
},
"supporting_times_s": {
"TI": metrics.ti_s,
"mTT": metrics.mtt_s,
"TO": metrics.to_s,
},
}
def _anchor_summary(anchor: AnchorFrameLocation) -> dict[str, Any]:
"""一个关键帧槽位的定位:帧号、视频时间、推注后时刻、状态、未定位原因。"""
return {
"frame_index": anchor.frame_index,
"frame_time_video_s": anchor.frame_time_video_s,
"time_since_injection_s": anchor.time_s,
"status": anchor.status,
"reason": anchor.reason,
"anchor_time_video_s": anchor.anchor_time_video_s,
"time_error_s": anchor.time_error_s,
"source_curve": anchor.source_curve,
}
def quant_summary(result: QuantResult) -> dict[str, Any]:
"""一例的定量摘要(可直接写成 JSON):拟合、灌注指标、紫圈检出率、7 个关键帧的定位。
不含视频号、路径等病例标识,由脚本按需另加。
"""
tic = result.tic
dynamic_slots = ("B0", "D1", "D2", "D3", "D4")
return {
"injection_offset_s": result.injection_offset_s,
"reference_valid": tic.reference_valid,
"pan_detected_fraction": result.pan_detected_fraction,
"early_truncation_flag": tic.early_truncation_flag,
"lesion_fit_ready": result.lesion_fit_ready,
"fits": {name: _fit_summary(tic.fits[name]) for name in CURVE_NAMES},
"perfusion_metrics": {
name: _perfusion_summary(result.perfusion[name]) for name in LESION_CURVES
},
"anchor_frames": {slot: _anchor_summary(item) for slot, item in result.anchors.items()},
"available_dynamic_slots": [
slot for slot in dynamic_slots if result.anchors[slot].status == STATUS_OK
],
}
+357
View File
@@ -0,0 +1,357 @@
"""第 4 步:逐帧分割掩膜 → 平滑多边形 → 位置约束 → 与绿框融合。
分割在灰度那一半(target_view)上做,交来的是这一半局部坐标的逐帧二值掩膜。这里把它们变成
顶点数统一的平滑多边形轨迹,再用提示帧上的绿框(挪到 target_view 后)施加两类约束:
- `masks_to_smoothed_polygons`:逐帧取最大外轮廓 → 等弧长重采样到 `contour_points` 个顶点 →
与上一帧对齐起点和朝向 → 空掩膜帧由相邻帧沿时间线性插值补齐(N 帧进 N 帧出,不剔除帧)→
沿时间轴三角平滑;
- `apply_prompt_frame_position_constraints`:提示帧绿框质心之间线性插值出逐帧中心轨迹,把每帧
多边形按 `strength` 整体平移过去(不改形状和面积);
- `fuse_prompt_prior_with_medsam`:`roi_shape.source` 是 `prompt_prior` 时,由提示帧绿框推出逐帧
先验多边形,逐顶点混合 `多边形 = 分割 × (1 − s) + 先验 × s`(提示帧用 `keyframe_shape_strength`,
其余帧用 `propagation_shape_strength`),再把面积收紧到先验面积的 `[min, max]` 倍
(绿框在最终轮廓里占多少,见 `待定问题/01`)。
照搬旧项目 `polygons.py`,去掉产品层;坐标换算改用两个视图矩形左上角之差(`_mapping.py`),
不再需要旧的有效区矩形和映射锚点。
"""
from typing import Any, Protocol
import cv2
import numpy as np
from cadx.ceus._errors import CeusCaseError
from cadx.ceus._geometry import (
AnyArray,
Points,
ViewRect,
polygon_area,
polygon_center,
resample_closed_contour,
triangular_smooth,
)
from cadx.ceus._mapping import map_source_mask_to_target
class _SmoothingConfigLike(Protocol):
"""平滑配置(`SmoothingConfig` 符合)。"""
@property
def contour_points(self) -> int: ...
@property
def temporal_window(self) -> int: ...
class _RoiShapeConfigLike(Protocol):
"""绿框融合配置(`RoiShapeConfig` 符合)。"""
@property
def source(self) -> str: ...
@property
def keyframe_shape_strength(self) -> float: ...
@property
def propagation_shape_strength(self) -> float: ...
@property
def min_area_ratio_to_prompt(self) -> float: ...
@property
def max_area_ratio_to_prompt(self) -> float: ...
class _KeyframePositionConstraintLike(Protocol):
"""提示帧位置约束配置(`KeyframePositionConstraint` 符合)。"""
@property
def enabled(self) -> bool: ...
@property
def mode(self) -> str: ...
@property
def strength(self) -> float: ...
_SUPPORTED_CONSTRAINT_MODE = "center_interpolation"
POLYGON_STEP = 4
class NoValidContourError(CeusCaseError, RuntimeError):
"""这一例的掩膜序列(分割结果或挪过来的绿框)一帧有效轮廓都没有,第 4 步做不下去。
同时是 `RuntimeError`:旧测试按 `RuntimeError` 断言,语义不变。
"""
def __init__(self, reason: str) -> None:
super().__init__(POLYGON_STEP, reason)
def largest_contour(mask: AnyArray) -> Points | None:
"""二值掩膜面积最大的外轮廓顶点 `(M, 2)` float32;没有轮廓返回 None。
取全部轮廓点(`CHAIN_APPROX_NONE`),方便后面等弧长重采样。
"""
contours, _ = cv2.findContours(mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
if not contours:
return None
best = max(contours, key=cv2.contourArea)
return np.asarray(best).reshape(-1, 2).astype(np.float32)
def mask_center(mask: AnyArray) -> Points:
"""二值掩膜前景像素的质心 `[cx, cy]`;掩膜全空时返回 (0, 0)。"""
ys, xs = np.where(mask)
if len(xs) == 0:
return np.array([0.0, 0.0], dtype=np.float32)
return np.array([float(xs.mean()), float(ys.mean())], dtype=np.float32)
def align_polygon_to_previous(poly: AnyArray, previous: AnyArray | None) -> AnyArray:
"""把多边形的起点和朝向对齐到上一帧,让逐顶点的时间序列连续。
枚举正序、逆序两种朝向 × 全部循环移位,取与上一帧逐顶点平方差最小者;不对齐的话,时间
平滑会把不同位置的顶点混在一起。`previous` 为 None(首帧)时原样返回。
"""
if previous is None:
return poly
candidates = [poly, poly[::-1].copy()]
best: tuple[float, AnyArray] | None = None
for candidate in candidates:
for shift in range(len(candidate)):
shifted = np.roll(candidate, shift, axis=0)
score = float(((shifted - previous) ** 2).sum())
if best is None or score < best[0]:
best = (score, shifted)
if best is None:
raise ValueError("多边形没有顶点,无法对齐。")
return best[1]
def _interpolate_polygons(polygons: list[AnyArray | None], num_points: int) -> AnyArray:
"""含 None 的多边形序列沿帧轴线性插值,补齐成 `(N, num_points, 2)`。
一帧有效轮廓都没有时无从插值,报 `RuntimeError`。
"""
valid_indices = np.asarray(
[idx for idx, poly in enumerate(polygons) if poly is not None], dtype=np.int32
)
if len(valid_indices) == 0:
raise NoValidContourError("逐帧 mask 未产出任何有效 ROI 轮廓,无从时间插值。")
valid_values = np.asarray(
[poly.reshape(-1) for poly in polygons if poly is not None], dtype=np.float32
)
frame_indices = np.arange(len(polygons), dtype=np.float32)
interpolated = np.zeros((len(polygons), num_points * 2), dtype=np.float32)
for col in range(num_points * 2):
interpolated[:, col] = np.interp(
frame_indices, valid_indices.astype(np.float32), valid_values[:, col]
)
return interpolated.reshape(len(polygons), num_points, 2)
def _interpolate_centers(centers_by_frame: dict[int, AnyArray], frame_count: int) -> AnyArray:
"""稀疏的提示帧中心沿帧轴线性插值成逐帧中心轨迹 `(N, 2)`,两端保持端值。"""
frame_ids = np.asarray(sorted(centers_by_frame), dtype=np.float32)
centers = np.asarray(
[centers_by_frame[int(frame_id)] for frame_id in frame_ids.tolist()], dtype=np.float32
)
all_frames = np.arange(frame_count, dtype=np.float32)
interpolated = np.zeros((frame_count, 2), dtype=np.float32)
for col in range(2):
interpolated[:, col] = np.interp(all_frames, frame_ids, centers[:, col])
return interpolated
def _clamp_polygon_to_view(polygon: AnyArray, target_rect: ViewRect) -> AnyArray:
"""顶点裁到视图边界 `[0, w-1] × [0, h-1]`(返回副本)。"""
out = polygon.copy()
out[:, 0] = np.clip(out[:, 0], 0, target_rect.width - 1)
out[:, 1] = np.clip(out[:, 1], 0, target_rect.height - 1)
return out
def _scale_polygon_about_center(polygon: AnyArray, center: AnyArray, scale: float) -> AnyArray:
"""绕给定中心等比缩放多边形。"""
return center[None, :] + (polygon - center[None, :]) * float(scale)
def masks_to_smoothed_polygons(
masks_by_frame: dict[int, AnyArray],
frame_count: int,
smoothing_cfg: _SmoothingConfigLike,
) -> list[AnyArray]:
"""逐帧掩膜 → 顶点统一、沿时间平滑的多边形序列,长度 `frame_count`。
缺帧或空掩膜视为退化帧:保留位置,由时间插值补齐。一帧有效轮廓都没有时报 `NoValidContourError`。
"""
num_points = int(smoothing_cfg.contour_points)
temporal_window = int(smoothing_cfg.temporal_window)
aligned: list[AnyArray | None] = []
previous: AnyArray | None = None
for frame_idx in range(frame_count):
mask = masks_by_frame.get(frame_idx)
poly: AnyArray | None = None
if mask is not None:
poly = resample_closed_contour(largest_contour(mask), num_points)
if poly is not None:
poly = align_polygon_to_previous(poly, previous)
previous = poly
aligned.append(poly)
interpolated = _interpolate_polygons(aligned, num_points)
smoothed = triangular_smooth(interpolated, temporal_window)
return [smoothed[idx] for idx in range(frame_count)]
def _prompt_frame_target_centers(
prompt_frames: list[dict[str, Any]],
source_view_rect: ViewRect,
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
)
centers[int(prompt_frame["frame_index"])] = mask_center(target_mask)
return centers
def apply_prompt_frame_position_constraints(
polygons: list[AnyArray],
prompt_frames: list[dict[str, Any]],
source_view_rect: ViewRect,
target_view_rect: ViewRect,
constraint_cfg: _KeyframePositionConstraintLike,
) -> list[AnyArray]:
"""按提示帧中心插值轨迹整体平移逐帧多边形(不改形状和面积),再裁到 target_view 内。
`prompt_frames` 每项含 `frame_index` 和造影那一半局部坐标的绿框 `mask`。每帧位移
= (插值中心 − 当前质心) × strength,strength 截到 [0, 1]。`enabled=False` 时原对象返回;
`mode` 不是 `center_interpolation` 报 `RuntimeError`。
"""
if not constraint_cfg.enabled:
return polygons
if constraint_cfg.mode != _SUPPORTED_CONSTRAINT_MODE:
raise RuntimeError(
f"constraints.keyframe_position.mode 仅支持 '{_SUPPORTED_CONSTRAINT_MODE}',"
f"得到 '{constraint_cfg.mode}'。"
)
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)
center_track = _interpolate_centers(anchor_centers, len(polygons))
corrected: list[AnyArray] = []
for frame_idx, polygon in enumerate(polygons):
shift = (center_track[frame_idx] - polygon_center(polygon)) * strength
corrected.append(_clamp_polygon_to_view(polygon + shift[None, :], target_view_rect))
return corrected
def _prompt_prior_polygons(
prompt_frames: list[dict[str, Any]],
frame_count: int,
source_view_rect: ViewRect,
target_view_rect: ViewRect,
smoothing_cfg: _SmoothingConfigLike,
) -> list[AnyArray]:
"""由提示帧绿框推出逐帧先验多边形(target_view 局部坐标)。
每个提示帧的绿框挪到 target_view,取最大外轮廓重采样、对齐上一帧,放在它的帧号上;
其余帧沿时间插值、平滑;最后提示帧那几帧用原始绿框多边形覆盖(不被平滑改动)。
"""
num_points = int(smoothing_cfg.contour_points)
temporal_window = int(smoothing_cfg.temporal_window)
sparse: list[AnyArray | None] = [None] * frame_count
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
)
resampled = resample_closed_contour(largest_contour(target_mask), num_points)
if resampled is None:
continue
poly = align_polygon_to_previous(resampled, previous)
previous = poly
frame_idx = int(prompt_frame["frame_index"])
sparse[frame_idx] = poly
raw_key_polygons[frame_idx] = poly
interpolated = _interpolate_polygons(sparse, num_points)
if temporal_window > 1:
interpolated = triangular_smooth(interpolated, temporal_window)
for frame_idx, polygon in raw_key_polygons.items():
interpolated[frame_idx] = polygon
return [interpolated[idx] for idx in range(frame_count)]
def _tighten_area_to_prior_band(
polygon: AnyArray,
prior_poly: AnyArray,
min_area_ratio: float,
max_area_ratio: float,
) -> AnyArray:
"""把多边形面积收紧到先验面积的 `[min, max]` 倍之内(绕先验质心按 sqrt(目标/当前) 缩放)。
先验面积或当前面积不超过 1 像素时不收紧(避免对退化面积做伪缩放);已在带内原样返回。
"""
prior_area = polygon_area(prior_poly)
current_area = polygon_area(polygon)
if prior_area <= 1.0 or current_area <= 1.0:
return polygon
min_area = prior_area * min_area_ratio
max_area = prior_area * max_area_ratio
target_area = float(np.clip(current_area, min_area, max_area))
if np.isclose(target_area, current_area):
return polygon
scale = float(np.sqrt(target_area / current_area))
return _scale_polygon_about_center(polygon, polygon_center(prior_poly), scale)
def fuse_prompt_prior_with_medsam(
medsam_polygons: list[AnyArray],
prompt_frames: list[dict[str, Any]],
source_view_rect: ViewRect,
target_view_rect: ViewRect,
shape_cfg: _RoiShapeConfigLike,
smoothing_cfg: _SmoothingConfigLike,
) -> list[AnyArray]:
"""分割多边形与绿框先验逐顶点融合并收紧面积,裁到 target_view 内。
`shape_cfg.source` 不是 `prompt_prior` 时原对象返回(不融合)。每帧先把分割多边形对齐到先验的
起点和朝向,再按权重 s(截到 [0, 1])混合。
"""
if shape_cfg.source != "prompt_prior":
return medsam_polygons
prior_polygons = _prompt_prior_polygons(
prompt_frames,
len(medsam_polygons),
source_view_rect,
target_view_rect,
smoothing_cfg,
)
prompt_frame_indices = {int(prompt_frame["frame_index"]) for prompt_frame in prompt_frames}
prompt_frame_strength = float(shape_cfg.keyframe_shape_strength)
propagation_strength = float(shape_cfg.propagation_shape_strength)
min_area_ratio = float(shape_cfg.min_area_ratio_to_prompt)
max_area_ratio = float(shape_cfg.max_area_ratio_to_prompt)
fused: list[AnyArray] = []
for frame_idx, (medsam_poly, prior_poly) in enumerate(
zip(medsam_polygons, prior_polygons, strict=True)
):
raw_strength = (
prompt_frame_strength if frame_idx in prompt_frame_indices else propagation_strength
)
strength = float(np.clip(raw_strength, 0.0, 1.0))
aligned_medsam = align_polygon_to_previous(medsam_poly, prior_poly)
polygon = aligned_medsam * (1.0 - strength) + prior_poly * strength
polygon = _tighten_area_to_prior_band(polygon, prior_poly, min_area_ratio, max_area_ratio)
fused.append(_clamp_polygon_to_view(polygon, target_view_rect))
return fused
+408
View File
@@ -0,0 +1,408 @@
"""第 6 步之前:在造影那一半的第一帧上选背景圆(`background_circle` 曲线的采样区)。
背景圆是 ROI 之外一小块干净背景,用来看整机增益漂移:病灶有了 ROI 曲线还不够,还要一块同帧
的背景作对照,才能区分「造影剂真增强」和「全局亮度漂移」。它不是紫圈(`roi_pan`):紫圈是医生
画的正常胰腺对照,由 `_tic.py` 逐帧直接读颜色;背景圆是程序自动挑的,不喂任何口径字段。
`select_reference_region` 按模式分派:
- `enabled=False`:空掩膜 + `{"enabled": False}`;
- `manual`:以配置 `center_xy`(局部像素坐标)和 `radius_px` 直接画圆;
- `auto_near_roi`:从 ROI 质心沿 8 个偏好方向(左下优先)× 若干搜索半径放候选圆心,避开 ROI
膨胀 `min_distance_px` 的禁区,候选圆内「有效背景」(非黑、不是绿框色或紫圈色)占比须 ≥
`min_valid_fraction`,按「有效占比 − 离 ROI 远近惩罚」择优;找不到时返回空掩膜和失败原因,
背景曲线保持 NaN。
搜索半径倍率和偏好方向是本模块的常量,不在配置里。照搬旧项目 `reference_region.py`,去掉产品层。
"""
from typing import Any, Protocol, cast
import cv2
import numpy as np
from cadx.ceus._config import TicAnalysisConfig
from cadx.ceus._geometry import AnyArray, polygon_to_mask
from cadx.ceus._green_prompt import prompt_color_mask
# auto 模式搜索半径(None 时)相对 ROI 等效尺度的倍率(参考 P:1058)。
_SEARCH_RADIUS_FACTORS = (0.65, 0.85, 1.05, 1.25, 1.5)
# 8 个偏好方向(左下优先,参考 P:1062):左下 / 左 / 下 / 右下 / 右 / 左上 / 上 / 右上。
_PREFERRED_DIRECTIONS = (
(-1.0, 1.0),
(-1.0, 0.0),
(0.0, 1.0),
(1.0, 1.0),
(1.0, 0.0),
(-1.0, -1.0),
(0.0, -1.0),
(1.0, -1.0),
)
# 评分中"离 ROI 距离"惩罚系数(参考 P:1091,硬编码不可改)。
_DISTANCE_PENALTY_WEIGHT = 0.05
class _ReferenceRegionConfigLike(Protocol):
"""背景圆配置(`ReferenceRegionConfig` 符合)。"""
@property
def enabled(self) -> bool: ...
@property
def mode(self) -> str: ...
@property
def radius_px(self) -> int: ...
@property
def min_distance_px(self) -> int: ...
@property
def nonblack_threshold(self) -> int: ...
@property
def min_valid_fraction(self) -> float: ...
@property
def center_xy(self) -> list[float] | None: ...
class _PromptHsvConfigLike(Protocol):
"""一组颜色的 HSV 阈值(`PromptSourceConfig`、`PanSourceConfig` 都符合)。"""
@property
def hsv_lower(self) -> list[int]: ...
@property
def hsv_upper(self) -> list[int]: ...
def _circle_mask(shape: tuple[int, int], center_xy: AnyArray, radius: float) -> AnyArray:
"""以 ``center_xy`` 为圆心、``radius`` 为半径栅格化实心圆 mask(参考 P:975-979)。
参数:
shape: 目标 mask 形状 ``(height, width)``。
center_xy: 圆心 ``[cx, cy]`` 像素坐标(可为浮点)。
radius: 圆半径(像素)。
返回:
``dtype=bool`` 的 ``(height, width)`` mask,圆内(含边界,``<= r²``)为 ``True``。
关键实现:
用 ``np.ogrid`` 广播算每像素到圆心的平方距离,与 ``radius²`` 比较——闭区间
(``<=``)与参考一致,避免边界像素被漏。
"""
height, width = shape
yy, xx = np.ogrid[:height, :width]
cx, cy = float(center_xy[0]), float(center_xy[1])
return (xx - cx) ** 2 + (yy - cy) ** 2 <= float(radius) ** 2
def _polygon_union_mask(polygons: AnyArray, height: int, width: int) -> AnyArray:
"""逐帧 ROI 多边形并集 mask(参考 P:982-986)。
参数:
polygons: 逐帧 ROI 多边形序列(每帧 ``(N, 2)``)。
height: mask 高度。
width: mask 宽度。
返回:
所有帧 ROI 多边形按位或的 ``dtype=bool`` 并集 mask——禁区取并集而非单帧,
保证参考圆在整段视频内都不与任何帧的 ROI 重叠。
"""
union = np.zeros((height, width), dtype=bool)
for polygon in polygons:
union |= polygon_to_mask(polygon, height, width)
return union
def _forbidden_roi_mask(roi_union: AnyArray, min_distance_px: int) -> AnyArray:
"""把 ROI 并集按 ``min_distance_px`` 膨胀为参考圆禁区(参考 P:1040-1045)。
参数:
roi_union: ROI 并集 mask(bool)。
min_distance_px: 参考圆与 ROI 的最小间距(像素);``<= 0`` 时不膨胀。
返回:
膨胀后的 ``dtype=bool`` 禁区 mask——参考圆不得触及其内任何像素。
关键实现:
膨胀核边长 ``2·min_distance_px + 1``(方核),与参考一致;``<= 0`` 直接返回原并集
(禁区即 ROI 本身)。
"""
if min_distance_px <= 0:
return roi_union
kernel = np.ones((min_distance_px * 2 + 1, min_distance_px * 2 + 1), dtype=np.uint8)
return cv2.dilate(roi_union.astype(np.uint8) * 255, kernel, iterations=1) > 0
def _search_radii(roi_scale: float) -> list[float]:
"""求 auto 模式的候选搜索半径列表(参考 P:1056-1058)。
参数:
roi_scale: ROI 等效尺度(bbox 长 / 宽较大者,下限 1.0)。
返回:
候选圆心到 ROI 质心的距离列表:``roi_scale × _SEARCH_RADIUS_FACTORS``。
关键实现:
倍率不从配置读——``ReferenceRegionConfig`` 里没有这一项,而它是 ``extra="forbid"``,
往 yaml 里写 ``search_radii_px`` 会在加载时被拒。要做成可配就得先进 schema。
"""
return [roi_scale * factor for factor in _SEARCH_RADIUS_FACTORS]
def _preferred_directions() -> AnyArray:
"""auto 模式的偏好方向数组(参考 P:1059-1065)。
返回:
``(8, 2)`` float32 方向数组(左下优先排序)——评分相同则靠前方向先入选。
关键实现:
方向表不从配置读,同 ``_search_radii``:``preferred_directions`` 不在 schema 里,
写进 yaml 会被 ``extra="forbid"`` 拒掉。
"""
return np.asarray(_PREFERRED_DIRECTIONS, dtype=np.float32)
def _candidate_centers(
roi_center: AnyArray,
search_radii: list[float],
directions: AnyArray,
) -> list[AnyArray]:
"""生成 auto 模式的候选圆心列表(参考 P:1068-1073 的外两层循环)。
参数:
roi_center: ROI 质心 ``[cx, cy]``(float32)。
search_radii: 候选距离列表。
directions: ``(K, 2)`` 偏好方向数组。
返回:
候选圆心 ``[cx, cy]`` 列表——半径 × 方向(单位化)双重笛卡尔积;零向量跳过。
外层遍历距离、内层遍历方向,保持与参考一致的先小半径、左下优先的枚举顺序。
"""
centers: list[AnyArray] = []
for distance in search_radii:
for row in range(directions.shape[0]):
direction = directions[row]
norm = float(cast(float, np.linalg.norm(direction)))
if norm <= 1e-6:
continue
centers.append(roi_center + direction / norm * float(distance))
return centers
def _circle_within_bounds(center: AnyArray, radius: float, height: int, width: int) -> bool:
"""判断以 ``center`` 为心、``radius`` 为半径的圆是否完整落在画面内(参考 P:1074-1079)。"""
return not (
center[0] - radius < 0
or center[1] - radius < 0
or center[0] + radius >= width
or center[1] + radius >= height
)
def _valid_fraction(mask: AnyArray, valid_background: AnyArray) -> float:
"""候选圆内"有效背景"像素占比(参考 P:1085)。
参数:
mask: 候选圆 mask(bool)。
valid_background: 有效背景 mask(非黑、非医生画的线、非禁区,bool)。
返回:
``(圆 ∩ 有效背景) / 圆面积``;圆面积为 0 时返回 0.0(不会被选中)。
"""
area = int(mask.sum())
if area == 0:
return 0.0
return float((mask & valid_background).sum()) / float(area)
def _score_candidate(
center: AnyArray,
valid_fraction: float,
first_polygon: AnyArray,
roi_scale: float,
) -> float:
"""候选圆评分:有效占比越高越好,离 ROI 越远略减分(参考 P:1088-1091)。
参数:
center: 候选圆心 ``[cx, cy]``。
valid_fraction: 该候选圆内有效背景占比。
first_polygon: 第一帧 ROI 多边形(``(N, 2)``),用于算到 ROI 的最近点距离。
roi_scale: ROI 等效尺度(归一化距离用,下限 1.0)。
返回:
``valid_fraction − 到ROI最近距离 / roi_scale × 0.05``——倾向"贴近 ROI 的干净背景"
(同帧、就近,光照 / 探头条件最一致),但有效占比仍是主导项。
"""
distance_to_roi = float(np.sqrt(((center[None, :] - first_polygon) ** 2).sum(axis=1)).min())
return valid_fraction - distance_to_roi / max(roi_scale, 1.0) * _DISTANCE_PENALTY_WEIGHT
def _roi_scale(first_polygon: AnyArray) -> float:
"""ROI 等效尺度:bbox 长 / 宽中较大者,下限 1.0(参考 P:1053-1055)。"""
bbox_min = first_polygon.min(axis=0)
bbox_max = first_polygon.max(axis=0)
return max(float(bbox_max[0] - bbox_min[0]), float(bbox_max[1] - bbox_min[1]), 1.0)
def _manual_reference(
shape: tuple[int, int], ref_cfg: _ReferenceRegionConfigLike, radius: float
) -> tuple[AnyArray, dict[str, Any]]:
"""manual 模式:以配置 ``center_xy`` + ``radius`` 直接出圆 + metadata(参考 P:1021-1033)。
参数:
shape: 第一帧 ``(height, width)``。
ref_cfg: 参考区配置(取 ``center_xy``)。
radius: 圆半径(像素)。
返回:
``(圆 mask, metadata dict)``;metadata 含 enabled/mode/shape/center_xy/radius_px/area_px。
关键实现:
``center_xy`` 形状必须为 ``(2,)``,否则视为配置缺陷直接报错(不静默兜底)。schema
的 ``model_validator`` 已强制 manual 模式 center_xy 非空,这里再校验维度做纵深防御。
"""
center = np.asarray(ref_cfg.center_xy, dtype=np.float32)
if center.shape != (2,):
raise RuntimeError("tic_analysis.reference_region.center_xy 必须是 [x, y]。")
mask = _circle_mask(shape, center, radius)
return mask, {
"enabled": True,
"mode": "manual",
"shape": "circle",
"center_xy": [float(center[0]), float(center[1])],
"radius_px": radius,
"area_px": int(mask.sum()),
}
def _auto_reference(
first_source_bgr: AnyArray,
source_polygons: AnyArray,
ref_cfg: _ReferenceRegionConfigLike,
prompt_cfg: _PromptHsvConfigLike,
pan_cfg: _PromptHsvConfigLike,
tic_cfg: TicAnalysisConfig,
radius: float,
) -> tuple[AnyArray, dict[str, Any]]:
"""auto_near_roi 模式:ROI 外搜索一块有效背景圆择优(参考 P:1038-1109)。
参数:
first_source_bgr: 第一帧 source 视图 BGR uint8 ``(h, w, 3)``(已是 source 局部坐标)。
source_polygons: 逐帧 ROI 多边形序列(禁区取并集,避开整段 ROI)。
ref_cfg: 参考区配置。
prompt_cfg: 剔绿 HSV 阈值(透传给 ``prompt_color_mask``)。
pan_cfg: 剔紫 HSV 阈值(同一路传法,见「关键实现」为什么紫色也要排)。
tic_cfg: TIC 配置(剔绿开关 / 膨胀核,透传给 ``prompt_color_mask``)。
radius: 圆半径(像素)。
返回:
``(选中圆 mask, metadata dict)``;metadata 额外含 ``valid_fraction_first_frame``。
关键实现:
有效背景 = 非黑(灰度 > ``nonblack_threshold``)且不是医生画上去的线(绿框色与
紫色轮廓色都排掉)且非禁区。逐候选圆需满足:完整在画面内、不触禁区、有效占比
≥ ``min_valid_fraction``,再按 ``_score_candidate`` 取最高分。无合规候选返回空掩膜和
``failure_reason=no_valid_candidate``;背景曲线据此保持 NaN,不阻断病灶拟合。
**紫色和绿色一起排。** 紫色轮廓是医生画的邻近正常胰腺实质参照区,TIC 采样时把
这些像素从掩膜里扣掉(``tic._sample_frame_row`` 的排除色 = 绿 ∪ 紫)。这里若只
排绿,挑中的背景圆可能压在那圈紫色上,``valid_background_circle_px`` 被悄悄削掉
一块,而没有任何一处会报出来——紫色的灰度不低,``nonblack_threshold`` 也拦不住它。
"""
height, width = first_source_bgr.shape[:2]
roi_union = _polygon_union_mask(source_polygons, height, width)
forbidden_roi = _forbidden_roi_mask(roi_union, int(ref_cfg.min_distance_px))
ink_mask = prompt_color_mask(first_source_bgr, prompt_cfg, tic_cfg) | prompt_color_mask(
first_source_bgr, pan_cfg, tic_cfg
)
gray = cv2.cvtColor(first_source_bgr, cv2.COLOR_BGR2GRAY)
nonblack = gray > int(ref_cfg.nonblack_threshold)
valid_background = nonblack & ~ink_mask & ~forbidden_roi
first_polygon = source_polygons[0]
roi_center = first_polygon.mean(axis=0).astype(np.float32)
roi_scale = _roi_scale(first_polygon)
search_radii = _search_radii(roi_scale)
directions = _preferred_directions()
min_valid_fraction = float(ref_cfg.min_valid_fraction)
best: tuple[float, AnyArray, AnyArray, float] | None = None
for candidate_center in _candidate_centers(roi_center, search_radii, directions):
if not _circle_within_bounds(candidate_center, radius, height, width):
continue
mask = _circle_mask((height, width), candidate_center, radius)
if int(mask.sum()) == 0 or np.any(mask & forbidden_roi):
continue
valid_fraction = _valid_fraction(mask, valid_background)
if valid_fraction < min_valid_fraction:
continue
score = _score_candidate(candidate_center, valid_fraction, first_polygon, roi_scale)
if best is None or score > best[0]:
best = (score, candidate_center, mask, valid_fraction)
if best is None:
return np.zeros((height, width), dtype=bool), {
"enabled": True,
"mode": "auto_near_roi",
"shape": "circle",
"available": False,
"failure_reason": "no_valid_candidate",
}
_, selected_center, selected_mask, selected_fraction = best
return selected_mask, {
"enabled": True,
"mode": "auto_near_roi",
"shape": "circle",
"center_xy": [float(selected_center[0]), float(selected_center[1])],
"radius_px": radius,
"area_px": int(selected_mask.sum()),
"valid_fraction_first_frame": float(selected_fraction),
}
def select_reference_region(
first_source_bgr: AnyArray,
source_polygons: AnyArray,
prompt_cfg: _PromptHsvConfigLike,
pan_cfg: _PromptHsvConfigLike,
tic_cfg: TicAnalysisConfig,
) -> tuple[AnyArray, dict[str, Any]]:
"""在第一帧 source 视图上选定 TIC 参考区圆,出 mask + 元数据(参考 P:1007-1109)。
参数:
first_source_bgr: 第一帧 source 视图 BGR uint8 ``(h, w, 3)``(已是 source 局部坐标)。
source_polygons: 逐帧 ROI 多边形序列 ``(T, N, 2)``(参考区只看第一帧定位,禁区取整段并集)。
prompt_cfg: 绿框 HSV 阈值配置(剔绿用,透传 ``prompt_color_mask``)。
pan_cfg: 紫色轮廓 HSV 阈值配置(剔紫用,同一路传法)。
tic_cfg: TIC 分析配置;从中取 ``reference_region`` 子配置与剔绿开关 / 膨胀核。
返回:
``(reference mask, metadata dict)``——
mask:``dtype=bool`` 的 ``(h, w)`` 圆 mask(TIC 据此采样 ``background_circle`` 曲线);
metadata:写进 trajectory.npz 的 ``reference_region_json``。各模式的键集:
- enabled=False → ``{"enabled": False}``(空 mask);
- manual → ``{enabled, mode, shape, center_xy, radius_px, area_px}``;
- auto_near_roi → 额外含 ``valid_fraction_first_frame``。
``shape`` 恒为 ``"circle"``(出自旧项目 findings §2B:输出死键,不从配置读)。
异常:
RuntimeError: ``mode`` 非法,或 manual 的 center_xy 维度非法。
关键实现:
三模式分派——关闭直接空返;manual 直接画圆;auto 走 ``_auto_reference`` 搜索择优。
参考区配置经 ``tic_cfg.reference_region`` 取出,不在本模块写默认值。
"""
ref_cfg = tic_cfg.reference_region
if not bool(ref_cfg.enabled):
empty = np.zeros(first_source_bgr.shape[:2], dtype=bool)
return empty, {"enabled": False}
radius = float(ref_cfg.radius_px)
mode = ref_cfg.mode
if mode == "manual":
return _manual_reference(first_source_bgr.shape[:2], ref_cfg, radius)
if mode != "auto_near_roi":
raise RuntimeError(f"不支持的 tic_analysis.reference_region.mode: {mode}")
return _auto_reference(
first_source_bgr, source_polygons, ref_cfg, prompt_cfg, pan_cfg, tic_cfg, radius
)
+438
View File
@@ -0,0 +1,438 @@
"""第 6 步:TIC 四条曲线的逐帧采样、增强前基线,以及(调第 7 步)Lognormal 拟合。
TIC(时间-强度曲线)是病灶亮度随时间的变化。在造影那一半画面(局部坐标)上逐帧采四条
灰度均值曲线:
- `roi_all`:病灶 ROI 内(剔除绿框色与紫圈色后)的灰度均值;
- `roi_perf`:在 `roi_all` 基础上再剔除暗区后的灰度均值(perf 指灌注,见 `_tic_curves.py`);
- `roi_pan`:医生画的紫圈内部(一块正常胰腺,作对照);
- `background_circle`:程序自动挑的一小块背景圆,只用来看整机增益漂移,不是 `roi_pan`。
掩膜构成:
exclude = 绿框色 ∪ 紫圈色
valid_roi_all = roi_mask & ~exclude
valid_roi_perf = valid_roi_all & ~dark_union
valid_pan = 紫圈填充区 & ~exclude
valid_background = background_circle_mask & ~exclude
四条原始曲线采好后,每条各自识别增强前基线(`detect_baseline`),再交给 `_tic_fitting` 拟合。
曲线最前面不插「时间 0、值 0」的合成点:那个点不是量出来的,会在它与首个真实帧之间造出
一段最陡的假上升,把「变亮最快」的时刻钉在视频开头。
照搬旧项目 `tic.py`,去掉产品层;另加 `pan_detected_fraction`(紫圈检出率,任务书 02 要报)。
"""
from collections.abc import Iterable, Sequence
from dataclasses import dataclass
from typing import Any
import cv2
import numpy as np
from cadx.ceus._config import (
PanSourceConfig,
PromptSourceConfig,
TicAnalysisConfig,
TicBaselineConfig,
)
from cadx.ceus._geometry import AnyArray, Frame, Mask, polygon_to_mask, polygons_to_mask
from cadx.ceus._green_prompt import extract_color_prompt, prompt_color_mask
from cadx.ceus._tic_fitting import LognormalFitResult, fit_lognormal_curve
__all__ = [
"CURVE_NAMES",
"BaselineResult",
"TicResult",
"compute_tic_for_video",
"detect_baseline",
"mean_or_nan",
]
# 四条曲线的名字;逐帧行键按 `{name}_raw` / `{name}_processed` 等派生。
CURVE_NAMES = ("roi_all", "roi_perf", "roi_pan", "background_circle")
# 临床口径认得的三条。背景圆不在里面:它不喂任何口径字段,基线被截短的质控位也不由它置位。
_SPEC_CURVE_NAMES = ("roi_all", "roi_perf", "roi_pan")
@dataclass(frozen=True)
class BaselineResult:
"""一条曲线的增强前基线区间(还没被造影剂点亮的那一段)。
`mean`、`sd` 是区间内的稳健中心和噪声(中位数、1.4826×MAD),`valid=False` 时为 None;
`frame_start` 含、`frame_end` 不含,无效时都是 -1。无效时用 None 而不用 0.0:0.0 能一路
算下去把下游悄悄算错,None 会在第一个用它的地方报错。
"""
mean: float | None
sd: float | None
frame_start: int
frame_end: int
valid: bool
@dataclass(frozen=True)
class TicResult:
"""TIC 采样与拟合的结果。
- `rows`:逐帧一行(只有真实帧),键见 `compute_tic_for_video`;
- `parameters`:定量参数,这里恒为 None(灌注指标由 `_perfusion_metrics` 从拟合算);
- `baselines`、`fits`:曲线名 → 基线 / 拟合,键集恒为 `CURVE_NAMES`;
- `early_truncation_flag`:口径三条曲线里有一条采到过值、却凑不出足够长的基线;
- `reference_valid`:紫圈检出率达标,`roi_pan` 可用;为 False 时 `roi_pan` 整条 NaN;
- `pan_detected_fraction`:非重复帧里真正采到紫圈像素的帧占比(紫圈检出率)。
"""
rows: list[dict[str, Any]]
parameters: dict[str, Any] | None
baselines: dict[str, BaselineResult]
fits: dict[str, LognormalFitResult]
early_truncation_flag: bool
reference_valid: bool
pan_detected_fraction: float
def mean_or_nan(gray: AnyArray, mask: AnyArray) -> float:
"""掩膜内灰度均值;掩膜全空时返回 NaN(这一帧这条曲线没采到,不当作亮度 0)。"""
values = gray[mask]
if len(values) == 0:
return float("nan")
return float(values.mean())
_INVALID_BASELINE = BaselineResult(mean=None, sd=None, frame_start=-1, frame_end=-1, valid=False)
def _robust_center_noise(values: AnyArray) -> tuple[float, float]:
"""中位数与 1.4826×MAD(正态下等于标准差的稳健估计)。"""
center = float(np.median(values))
noise = 1.4826 * float(np.median(np.abs(values - center)))
return center, noise
def _window_is_stable(window: AnyArray, sd_multiplier: float) -> bool | None:
"""候选窗对半分,后半中位数有没有明显高过前半。
稳定返回 True、已在上涨返回 False;任一半没有有效采样、判不出来时返回 None。
"""
half = len(window) // 2
first = window[:half]
second = window[half:]
first = first[np.isfinite(first)]
second = second[np.isfinite(second)]
if first.size == 0 or second.size == 0:
return None
first_center, first_noise = _robust_center_noise(first)
second_center = float(np.median(second))
first_differences = np.diff(first)
if first_differences.size:
difference_center = float(np.median(first_differences))
difference_noise = 1.4826 * float(np.median(np.abs(first_differences - difference_center)))
else:
difference_noise = first_noise
return bool(
second_center - first_center
<= sd_multiplier * max(difference_noise, float(np.finfo(np.float64).eps))
)
def detect_baseline(
values: AnyArray,
time_s: AnyArray,
cfg: TicBaselineConfig,
sample_mask: AnyArray | None,
) -> BaselineResult:
"""在曲线开头按真实时间找增强前基线。
候选窗从视频开头覆盖 `preferred_duration_s` 秒;对半分后后半中位数比前半高出超过
`stability_sd_multiplier × 前半差分噪声`,就从右侧逐个样本缩窗;有效样本覆盖的时长不足
`minimum_duration_s` 时基线无效。NaN 帧和 `sample_mask` 为 False 的帧不算基线样本
(`sample_mask` 为 None 表示都算)。
这一步只出基线均值和噪声,不出到达时刻和峰值:到达门槛含峰高,要先找峰,所以到达时刻
在拟合那一步回头定。
"""
series = np.asarray(values, dtype=np.float64)
times = np.asarray(time_s, dtype=np.float64)
mask = (
np.ones(series.shape, dtype=bool)
if sample_mask is None
else np.asarray(sample_mask, dtype=bool)
)
if series.ndim != 1 or times.shape != series.shape or mask.shape != series.shape:
raise ValueError("values、time_s 与 sample_mask 必须是等长一维数组。")
if series.size == 0 or not np.isfinite(times).all() or np.any(np.diff(times) <= 0):
raise ValueError("time_s 必须非空、有限且严格递增。")
window_end_s = float(times[0]) + cfg.preferred_duration_s
end = int(np.searchsorted(times, window_end_s, side="left"))
end = min(max(end, 1), int(series.size))
cadence = float(np.median(np.diff(times))) if series.size > 1 else 0.0
while end > 0:
window_mask = np.isfinite(series[:end]) & mask[:end]
finite_indices = np.flatnonzero(window_mask)
if finite_indices.size < 2:
return _INVALID_BASELINE
observed_duration = float(times[finite_indices[-1]] - times[finite_indices[0]]) + cadence
if observed_duration + 1e-9 < cfg.minimum_duration_s:
return _INVALID_BASELINE
window = np.where(window_mask, series[:end], np.nan)
stable = _window_is_stable(window, cfg.stability_sd_multiplier)
if stable is None:
return _INVALID_BASELINE
if stable:
finite = series[:end][window_mask]
center, noise = _robust_center_noise(finite)
return BaselineResult(
mean=center,
sd=noise,
frame_start=0,
frame_end=end,
valid=True,
)
end -= 1
return _INVALID_BASELINE
def _sample_frame_row(
frame_index: int,
time_s: float,
source_bgr: Frame,
roi_polygon: AnyArray,
frame_dark_polygons: Sequence[AnyArray],
background_circle_mask: Mask,
prompt_cfg: PromptSourceConfig,
pan_cfg: PanSourceConfig,
tic_cfg: TicAnalysisConfig,
) -> tuple[dict[str, Any], AnyArray]:
"""一帧的四掩膜采样,返回这一帧的原始行和判断相邻重复帧用的 ROI 灰度签名。
紫圈的线本身是医生画的,不是组织,所以排除色是「绿 ∪ 紫」。`roi_pan` 取紫圈实心填充
再减排除色(轮廓线本身不算参照区内部),这一帧检不到紫圈时 `pan_area_px=0`、
`roi_pan_raw` 为 NaN;紫圈不走分割、不做传播,画面上每帧都有,直接读。
`pan_area_px` 是紫圈填充面积(未扣排除色),`valid_pan_px` 是扣完后真正参与统计的像素数;
检出率数后者。暗区多边形一次奇偶填充成掩膜(洞不计回暗区),再收口到 `valid_roi_all`;
收口后暗区面积为 0 时暗块计数也记 0。
"""
height, width = source_bgr.shape[:2]
gray = np.asarray(cv2.cvtColor(source_bgr, cv2.COLOR_BGR2GRAY))
exclude_mask = prompt_color_mask(source_bgr, prompt_cfg, tic_cfg) | prompt_color_mask(
source_bgr, pan_cfg, tic_cfg
)
roi_mask = polygon_to_mask(roi_polygon, height, width)
valid_roi_all_mask = roi_mask & ~exclude_mask
valid_background_mask = background_circle_mask & ~exclude_mask
dark_union = polygons_to_mask(frame_dark_polygons, height, width)
dark_union &= valid_roi_all_mask
valid_roi_perf_mask = valid_roi_all_mask & ~dark_union
pan_prompt = extract_color_prompt(source_bgr, pan_cfg)
if pan_prompt is None:
pan_mask = np.zeros((height, width), dtype=bool)
pan_area_px = 0
else:
pan_mask = pan_prompt.mask & ~exclude_mask
pan_area_px = int(pan_prompt.mask.sum())
dark_area_px = int(dark_union.sum())
dark_component_count = len(frame_dark_polygons) if dark_area_px > 0 else 0
x_coord, y_coord, box_width, box_height = cv2.boundingRect(
np.asarray(roi_polygon, dtype=np.float32)
)
roi_gray = gray[y_coord : y_coord + box_height, x_coord : x_coord + box_width]
roi_crop_mask = roi_mask[y_coord : y_coord + box_height, x_coord : x_coord + box_width]
signature_gray = np.asarray(cv2.resize(roi_gray, (32, 32), interpolation=cv2.INTER_AREA))
signature_mask = np.asarray(
cv2.resize(roi_crop_mask.astype(np.uint8), (32, 32), interpolation=cv2.INTER_NEAREST)
).astype(bool)
signature = np.where(signature_mask, signature_gray, 0).astype(np.float32)
row: dict[str, Any] = {
"frame_index": frame_index,
"time_sec": float(time_s),
"roi_all_raw": mean_or_nan(gray, valid_roi_all_mask),
"roi_perf_raw": mean_or_nan(gray, valid_roi_perf_mask),
"roi_pan_raw": mean_or_nan(gray, pan_mask),
"background_circle_raw": mean_or_nan(gray, valid_background_mask),
"roi_area_px": int(roi_mask.sum()),
"dark_area_px": dark_area_px,
"pan_area_px": pan_area_px,
"background_circle_area_px": int(background_circle_mask.sum()),
"valid_roi_all_px": int(valid_roi_all_mask.sum()),
"valid_roi_perf_px": int(valid_roi_perf_mask.sum()),
"valid_pan_px": int(pan_mask.sum()),
"valid_background_circle_px": int(valid_background_mask.sum()),
"dark_component_count": dark_component_count,
}
return row, signature
def _attach_baselines_and_fits(
rows: list[dict[str, Any]], tic_cfg: TicAnalysisConfig
) -> tuple[dict[str, BaselineResult], dict[str, LognormalFitResult], bool]:
"""逐条曲线识别基线并拟合,把候选、拟合、正式曲线原地写回逐帧行。
每条曲线各算各的基线窗:胰腺比病灶早增强,共用一个窗会把 `roi_pan` 的窗拖进已在上涨的区间。
`roi_perf` 只用「去暗区面积占比与上一帧相比变化不超过 `maximum_perf_area_step_fraction`」
的帧(`perf_mask_stable`),暗区掩膜跳变的帧不进基线和拟合。
`early_truncation_flag` 只看口径三条:某条采到过实测值、却凑不出足够长的基线就置位;
整条 NaN 的曲线不算(那是没检出,由 `reference_valid` 管);背景圆不参与。
"""
baselines: dict[str, BaselineResult] = {}
fits: dict[str, LognormalFitResult] = {}
early_truncation_flag = False
times = np.asarray([row["time_sec"] for row in rows], dtype=np.float64)
duplicates = np.asarray([row["duplicate_frame"] for row in rows], dtype=bool)
roi_areas = np.asarray([row["roi_area_px"] for row in rows], dtype=np.float64)
perf_areas = np.asarray([row["valid_roi_perf_px"] for row in rows], dtype=np.float64)
perf_fractions = np.divide(
perf_areas,
roi_areas,
out=np.zeros_like(perf_areas),
where=roi_areas > 0.0,
)
perf_mask_stable = roi_areas > 0.0
if len(rows) > 1:
stable_steps = (
np.abs(np.diff(perf_fractions)) <= tic_cfg.fitting.maximum_perf_area_step_fraction
)
perf_mask_stable[1:] &= stable_steps
for index, row in enumerate(rows):
row["perf_mask_stable"] = bool(perf_mask_stable[index])
for name in CURVE_NAMES:
raw = np.asarray([row[f"{name}_raw"] for row in rows], dtype=np.float64)
curve_mask = perf_mask_stable if name == "roi_perf" else np.ones(len(rows), dtype=bool)
baseline = detect_baseline(raw, times, tic_cfg.baseline, ~duplicates & curve_mask)
baselines[name] = baseline
if name in _SPEC_CURVE_NAMES and not baseline.valid and bool(np.isfinite(raw).any()):
early_truncation_flag = True
fit = fit_lognormal_curve(
times,
raw,
baseline=baseline.mean,
baseline_noise=baseline.sd,
baseline_end_index=baseline.frame_end,
duplicate_mask=duplicates,
sample_mask=curve_mask,
cfg=tic_cfg.fitting,
anchor_cfg=tic_cfg.anchor_frames,
)
fits[name] = fit
for idx, row in enumerate(rows):
row[f"{name}_candidate"] = float(fit.candidate[idx])
row[f"{name}_fitted"] = float(fit.fitted[idx])
row[f"{name}_processed"] = float(fit.processed[idx])
return baselines, fits, early_truncation_flag
def compute_tic_for_video(
source_frames: Iterable[Frame],
roi_polygons: Sequence[AnyArray],
dark_polygons_per_frame: Sequence[Sequence[AnyArray]],
reference_mask: AnyArray,
fps: float,
prompt_cfg: PromptSourceConfig,
pan_cfg: PanSourceConfig,
tic_cfg: TicAnalysisConfig,
frame_times_s: Sequence[float] | AnyArray | None,
) -> TicResult:
"""逐帧采四掩膜灰度 → 标记重复帧 → 基线与 Lognormal 拟合,交出 `TicResult`。
- `source_frames`:造影那一半的逐帧 BGR(局部坐标),逐帧消费、不留,可以直接喂解码迭代器;
- `roi_polygons`、`dark_polygons_per_frame`:与帧等长的逐帧 ROI 多边形和暗区多边形列表;
- `reference_mask`:背景圆掩膜(第一帧定位、整段沿用);
- `frame_times_s`:逐帧显示时间戳(秒);为 None 时按 `fps` 构造等间隔时间轴(只给合成测试用)。
`rows` 每行键:`frame_index`、`time_sec`、`duplicate_frame`、`perf_mask_stable`,四条曲线各自的
`_raw`、`_candidate`、`_fitted`、`_processed`,以及各掩膜面积和有效像素数、暗块计数。
紫圈检出率 = 非重复帧里 `valid_pan_px > 0` 的帧占比(数的是真正参与统计的像素,不是填充面积:
一整块实心紫色会让填充面积过门槛而扣完排除色一个像素不剩)。达到
`pan_cfg.min_detected_frame_fraction` 才算紫圈可用;不达标时 `roi_pan` 整条记 NaN、基线无效,
不拿背景圆去填(两者不是同一种东西)。
"""
if len(roi_polygons) != len(dark_polygons_per_frame):
raise ValueError(
"roi_polygons 与 dark_polygons_per_frame 长度必须一致,得到 "
f"{len(roi_polygons)} / {len(dark_polygons_per_frame)}。"
)
if float(fps) <= 0.0:
raise ValueError(f"fps 必须为正,得到 {fps}。")
if tic_cfg.brightness_metric != "grayscale_mean":
raise ValueError(
f"仅支持 brightness_metric=grayscale_mean,得到 {tic_cfg.brightness_metric}。"
)
background_circle_mask = reference_mask.astype(bool)
if frame_times_s is None:
times = np.arange(len(roi_polygons), dtype=np.float64) / float(fps)
else:
times = np.asarray(frame_times_s, dtype=np.float64)
if times.shape != (len(roi_polygons),):
raise ValueError(
"frame_times_s 必须与 roi_polygons 等长,得到 "
f"{times.shape} / {len(roi_polygons)}。"
)
if not np.isfinite(times).all() or np.any(np.diff(times) <= 0.0):
raise ValueError("frame_times_s 必须有限且严格递增。")
# 帧是流式的,长度要遍历完才知道:zip(strict=True) 保证三个序列等长,多了少了当场报错。
rows: list[dict[str, Any]] = []
previous_unique_signature: AnyArray | None = None
for frame_index, (frame, roi_polygon, dark_polygons) in enumerate(
zip(source_frames, roi_polygons, dark_polygons_per_frame, strict=True)
):
if frame_index == 0 and background_circle_mask.shape != frame.shape[:2]:
raise ValueError(
f"reference_mask 形状 {background_circle_mask.shape} 与 source 帧 "
f"{frame.shape[:2]} 不符。"
)
row, signature = _sample_frame_row(
frame_index,
float(times[frame_index]),
frame,
roi_polygon,
dark_polygons,
background_circle_mask,
prompt_cfg,
pan_cfg,
tic_cfg,
)
duplicate = False
if tic_cfg.fitting.detect_duplicate_frames and previous_unique_signature is not None:
difference = float(np.mean(np.abs(signature - previous_unique_signature)))
duplicate = difference <= tic_cfg.fitting.duplicate_mean_absdiff_threshold
row["duplicate_frame"] = duplicate
rows.append(row)
if not duplicate:
previous_unique_signature = signature
if not rows:
raise ValueError("source_frames 不能为空。")
unique_rows = [row for row in rows if not row["duplicate_frame"]]
detected_frames = sum(1 for row in unique_rows if row["valid_pan_px"] > 0)
pan_detected_fraction = detected_frames / len(unique_rows) if unique_rows else 0.0
reference_valid = bool(unique_rows) and (
pan_detected_fraction >= pan_cfg.min_detected_frame_fraction
)
if not reference_valid:
for row in rows:
row["roi_pan_raw"] = float("nan")
baselines, fits, early_truncation_flag = _attach_baselines_and_fits(rows, tic_cfg)
return TicResult(
rows=rows,
parameters=None,
baselines=baselines,
fits=fits,
early_truncation_flag=early_truncation_flag,
reference_valid=reference_valid,
pan_detected_fraction=pan_detected_fraction,
)
+436
View File
@@ -0,0 +1,436 @@
"""tic_curves.npz 的打包和解包:四条曲线连同基线、拟合、面积、有效像素数(格式同旧项目)。
曲线数值单独存一份,问「第 37 秒那条曲线是多少」不必重跑整段视频。
- 解包用 `np.load(..., allow_pickle=False)`,坏字节直接报错;
- 打包前校验各逐帧数组长度一致;
- 字符串存 0 维 str 数组,不写 object 数组;
- NaN 原样写入:它是「这一帧没采到」的编码,本模块不用 `np.nan_to_num`,填 0 会把
「没采到」变成「亮度是 0」而不报错。
照搬旧项目 `tic_curves.py`,去掉产品层。
"""
import io
from dataclasses import dataclass
from typing import Any, cast
import numpy as np
from cadx.ceus._geometry import AnyArray
from cadx.ceus._tic import CURVE_NAMES, TicResult
# 这个文件自己的布局版本。键集、dtype、形状任一处变了就要换它。
CURVES_SCHEMA_VERSION = "3"
# 逐帧整数列的键名(面积 / 有效像素数 / 暗块计数),行键与工件键同名。
_COUNT_KEYS = (
"roi_area_px",
"dark_area_px",
"pan_area_px",
"background_circle_area_px",
"valid_roi_all_px",
"valid_roi_perf_px",
"valid_pan_px",
"valid_background_circle_px",
"dark_component_count",
)
@dataclass(frozen=True)
class TicCurveSeries:
"""一条曲线的原始样本、候选曲线、Lognormal 结果与拟合质量。
``fitted`` 保留优化器得到但可能未过质量门的曲线,便于复核失败;``processed`` 只在
质量门通过时等于 ``fitted``,失败时整条为 NaN。这样候选或失败拟合不会冒充正式曲线。
"""
raw: AnyArray
candidate: AnyArray
fitted: AnyArray
processed: AnyArray
valid_sample_mask: AnyArray
duplicate_sample_mask: AnyArray
baseline_mean: float
baseline_sd: float
baseline_frames: tuple[int, int]
baseline_valid: bool
fit_success: bool
fit_failure_reason: str
curve_model: str
smoothing_method: str
baseline_source: str
fit_area: float
fit_mu: float
fit_sigma: float
fit_arrival_offset_s: float
fit_baseline: float
fit_r2: float
fit_nrmse: float
residual_noise_ratio: float
fit_interval_s: tuple[float, float]
multi_peak_flag: bool
anchor_times_s: tuple[float, float, float, float]
@dataclass(frozen=True)
class TicCurves:
"""TIC 曲线工件的内存表示(npz pack/unpack 的双向载体)。
属性:
frame_indices: ``(T,)`` int32,逐帧下标。``T`` 是真实帧数,**不含任何合成点**。
time_video_s: ``(T,)`` float32,来自 MP4 presentation timestamp。
injection_offset_s: 推注偏移(秒);取不到时为 ``float("nan")``,**不是 0**
——填 0 等于断言「视频和推注同时开始」,而那是我们没有的信息。
time_origin: ``"injection"`` 或 ``"video_start"``,说明绝对时间轴以谁为零点。
fps: 视频帧率。
curves_schema_version: 这个文件自己的布局版本。
curves: 曲线名 → ``TicCurveSeries``,键集恒为 ``tic.CURVE_NAMES``。
roi_area_px / dark_area_px / pan_area_px / background_circle_area_px:
``(T,)`` int32,逐帧各掩膜栅格面积。
valid_roi_all_px / valid_roi_perf_px / valid_pan_px /
valid_background_circle_px: ``(T,)`` int32,逐帧真正参与统计的像素数。
dark_component_count: ``(T,)`` int32,逐帧暗块个数。
reference_valid: 紫色轮廓检出率达标,``roi_pan`` 那条可用。
early_truncation_flag: 有曲线没能凑出足够长的基线区间。
**``roi_perf`` 这个名字断言了「暗区 = 无灌注区」这个等号。** 「灌注」指血液连同
造影剂真正流进一块组织,没有灌注的地方(囊液、坏死、出血)不会被点亮;``_perf``
后缀在规范里指「去掉无灌注区之后剩下的可灌注组织」。而我们量到的是**暗区**——
病变轮廓内部灰度明显偏低的那一块,它不必然等于无灌注区,**伪影**(超声成像自身
产生的假象,比如前方有气体或钙化挡住声波、后方整片变暗的声影)也会让有灌注的组织
显得暗。**这个等号是工科方在 2026-08-12 会上提出的,临床方带着「肯定会有被伪影干扰」
这个保留意见暂时认下**(旧项目会议转录 475 与 477 行;旧 design/0030 决策七据此写成)。
2026-08-16 项目负责人拍板先按这个等号做、暂不为伪影做任何区分,将来它被推翻时,要回头
重算的正是所有带 ``_perf`` 后缀的量。
**三样东西这里不存**,各有各的理由:
``S(t) = raw − B`` 不存。``B`` 已经在文件里了,再存一遍就是同一个数放两份,
迟早有一份被改、另一份没改。
``t_injection = time_video_s + injection_offset_s`` 不存。同上,两个加数都在。
无灌注比例不存。临床方原规范 §6 给了公式 ``(A_all − A_perf) / A_all``,但没给它的时间下标
——取哪个时点还挂在临床方名下。逐帧的分子分母(``roi_area_px`` 与 ``dark_area_px``)
都在文件里,时点定下来之后是一次派生,不必重跑视频。
"""
frame_indices: AnyArray
time_video_s: AnyArray
injection_offset_s: float
time_origin: str
fps: float
config_version: str
algorithm_version: str
curves_schema_version: str
curves: dict[str, TicCurveSeries]
roi_area_px: AnyArray
dark_area_px: AnyArray
pan_area_px: AnyArray
background_circle_area_px: AnyArray
valid_roi_all_px: AnyArray
valid_roi_perf_px: AnyArray
valid_pan_px: AnyArray
valid_background_circle_px: AnyArray
dark_component_count: AnyArray
perf_mask_stable: AnyArray
reference_valid: bool
early_truncation_flag: bool
def _nan_if_none(value: float | None) -> float:
"""缺失的时刻在 npz 里记 NaN(float 数组没有 null)。"""
return float("nan") if value is None else float(value)
def from_tic_result(
result: TicResult,
fps: float,
injection_offset_s: float | None,
config_version: str,
) -> TicCurves:
"""把 ``TicResult`` 转成 ``TicCurves``,调用方不必知道行键名。
参数:
result: ``tic.compute_tic_for_video`` 的产物。
fps: 视频帧率(>0)。
injection_offset_s: 推注偏移(秒);源资料里没有就传 ``None``。
config_version: 生成本工件的科研配置版本。
返回:
``TicCurves``。
异常:
ValueError: ``result.rows`` 为空,或 ``fps`` 非正。
``time_origin`` 由 ``injection_offset_s`` 派生,不单独作为入参:两者只有一种自洽的
搭配(有偏移才谈得上以推注为零点),拆成两个参数只是多一个能填错的地方。
"""
if float(fps) <= 0.0:
raise ValueError(f"fps 必须为正,得到 {fps}。")
rows = result.rows
if not rows:
raise ValueError("TicResult.rows 不能为空。")
if not config_version:
raise ValueError("config_version 不能为空。")
algorithm_versions = {result.fits[name].algorithm_version for name in CURVE_NAMES}
if len(algorithm_versions) != 1:
raise ValueError(f"四条曲线的 algorithm_version 不一致:{algorithm_versions}")
curves: dict[str, TicCurveSeries] = {}
for name in CURVE_NAMES:
baseline = result.baselines[name]
fit = result.fits[name]
parameters = fit.parameters
curves[name] = TicCurveSeries(
raw=np.asarray([row[f"{name}_raw"] for row in rows], dtype=np.float32),
candidate=np.asarray([row[f"{name}_candidate"] for row in rows], dtype=np.float32),
fitted=np.asarray([row[f"{name}_fitted"] for row in rows], dtype=np.float32),
processed=np.asarray([row[f"{name}_processed"] for row in rows], dtype=np.float32),
valid_sample_mask=fit.valid_sample_mask.astype(bool),
duplicate_sample_mask=fit.duplicate_sample_mask.astype(bool),
baseline_mean=float("nan") if baseline.mean is None else baseline.mean,
baseline_sd=float("nan") if baseline.sd is None else baseline.sd,
baseline_frames=(baseline.frame_start, baseline.frame_end),
baseline_valid=baseline.valid,
fit_success=fit.success,
fit_failure_reason=fit.failure_reason,
curve_model=fit.curve_model,
smoothing_method=fit.smoothing_method,
baseline_source=fit.baseline_source,
fit_area=float("nan") if parameters is None else parameters.area,
fit_mu=float("nan") if parameters is None else parameters.mu,
fit_sigma=float("nan") if parameters is None else parameters.sigma,
fit_arrival_offset_s=(
float("nan") if parameters is None else parameters.arrival_offset_s
),
fit_baseline=float("nan") if parameters is None else parameters.baseline,
fit_r2=fit.r2,
fit_nrmse=fit.nrmse,
residual_noise_ratio=fit.residual_noise_ratio,
fit_interval_s=(fit.fit_start_s, fit.fit_end_s),
multi_peak_flag=fit.multi_peak_flag,
anchor_times_s=(
_nan_if_none(fit.d1_time_s),
_nan_if_none(fit.d2_time_s),
_nan_if_none(fit.d3_time_s),
_nan_if_none(fit.d4_time_s),
),
)
counts = {key: np.asarray([row[key] for row in rows], dtype=np.int32) for key in _COUNT_KEYS}
frame_indices = np.asarray([row["frame_index"] for row in rows], dtype=np.int32)
return TicCurves(
frame_indices=frame_indices,
time_video_s=np.asarray([row["time_sec"] for row in rows], dtype=np.float32),
injection_offset_s=(
float("nan") if injection_offset_s is None else float(injection_offset_s)
),
time_origin="video_start" if injection_offset_s is None else "injection",
fps=float(fps),
config_version=config_version,
algorithm_version=algorithm_versions.pop(),
curves_schema_version=CURVES_SCHEMA_VERSION,
curves=curves,
perf_mask_stable=np.asarray([row["perf_mask_stable"] for row in rows], dtype=bool),
reference_valid=result.reference_valid,
early_truncation_flag=result.early_truncation_flag,
**counts,
)
def _validate_lengths(curves: TicCurves) -> int:
"""校验逐帧列长度一致,返回帧数 T;任何不一致直接 raise。"""
if curves.frame_indices.ndim != 1:
raise ValueError(f"frame_indices 必须一维,得到 {curves.frame_indices.shape}。")
num_frames = int(curves.frame_indices.shape[0])
curve_names = set(curves.curves)
expected_names = set(CURVE_NAMES)
if curve_names != expected_names:
raise ValueError(f"曲线键集必须为 {sorted(expected_names)},得到 {sorted(curve_names)}。")
if curves.curves_schema_version != CURVES_SCHEMA_VERSION:
raise ValueError(
f"只支持 curves schema {CURVES_SCHEMA_VERSION},得到 {curves.curves_schema_version}。"
)
if not curves.config_version or not curves.algorithm_version:
raise ValueError("config_version 与 algorithm_version 不能为空。")
if num_frames == 0 or np.any(np.diff(curves.frame_indices) <= 0):
raise ValueError("frame_indices 必须非空且严格递增。")
if not np.isfinite(curves.time_video_s).all() or np.any(np.diff(curves.time_video_s) <= 0.0):
raise ValueError("time_video_s 必须有限且严格递增。")
named: list[tuple[str, AnyArray]] = [("time_video_s", curves.time_video_s)]
named += [(key, getattr(curves, key)) for key in _COUNT_KEYS]
named.append(("perf_mask_stable", curves.perf_mask_stable))
for name in CURVE_NAMES:
series = curves.curves[name]
named.append((f"{name}_raw", series.raw))
named.append((f"{name}_candidate", series.candidate))
named.append((f"{name}_fitted", series.fitted))
named.append((f"{name}_processed", series.processed))
named.append((f"{name}_valid_sample_mask", series.valid_sample_mask))
named.append((f"{name}_duplicate_sample_mask", series.duplicate_sample_mask))
for key, array in named:
if array.shape != (num_frames,):
raise ValueError(f"{key} 必须形如 ({num_frames},),得到 {array.shape}。")
return num_frames
def pack_tic_curves(curves: TicCurves) -> bytes:
"""把 ``TicCurves`` 校验并打包为压缩 npz 字节(产物工件)。
参数:
curves: 待打包的曲线载体。
返回:
压缩 npz 的原始字节。
异常:
ValueError: 逐帧列长度不一致,或曲线名缺失。
关键实现:
先 ``_validate_lengths`` 钉死长度(不一致即 raise,不静默对齐);字符串键存为
0 维 str 数组,标量存 ``(1,)`` 数组,布尔存 ``int8``,全程不写入任何 object
数组(禁 pickle)。NaN 原样写入 float32——它是「这一帧没采到」的编码,
任何形式的填零都会把这个事实抹掉。
"""
_validate_lengths(curves)
payload: dict[str, AnyArray] = {
"frame_indices": curves.frame_indices.astype(np.int32),
"time_video_s": curves.time_video_s.astype(np.float32),
"injection_offset_s": np.asarray([curves.injection_offset_s], dtype=np.float32),
"time_origin": np.array(curves.time_origin),
"fps": np.asarray([curves.fps], dtype=np.float32),
"config_version": np.array(curves.config_version),
"algorithm_version": np.array(curves.algorithm_version),
"curves_schema_version": np.array(curves.curves_schema_version),
"reference_valid": np.asarray([int(curves.reference_valid)], dtype=np.int8),
"early_truncation_flag": np.asarray([int(curves.early_truncation_flag)], dtype=np.int8),
"perf_mask_stable": curves.perf_mask_stable.astype(np.int8),
}
for key in _COUNT_KEYS:
payload[key] = np.asarray(getattr(curves, key), dtype=np.int32)
for name in CURVE_NAMES:
series = curves.curves[name]
payload[f"{name}_raw"] = series.raw.astype(np.float32)
payload[f"{name}_candidate"] = series.candidate.astype(np.float32)
payload[f"{name}_fitted"] = series.fitted.astype(np.float32)
payload[f"{name}_processed"] = series.processed.astype(np.float32)
payload[f"{name}_valid_sample_mask"] = series.valid_sample_mask.astype(np.int8)
payload[f"{name}_duplicate_sample_mask"] = series.duplicate_sample_mask.astype(np.int8)
payload[f"{name}_baseline_mean"] = np.asarray([series.baseline_mean], dtype=np.float32)
payload[f"{name}_baseline_sd"] = np.asarray([series.baseline_sd], dtype=np.float32)
payload[f"{name}_baseline_frames"] = np.asarray(series.baseline_frames, dtype=np.int32)
payload[f"{name}_baseline_valid"] = np.asarray([int(series.baseline_valid)], dtype=np.int8)
payload[f"{name}_fit_success"] = np.asarray([int(series.fit_success)], dtype=np.int8)
payload[f"{name}_fit_failure_reason"] = np.array(series.fit_failure_reason)
payload[f"{name}_curve_model"] = np.array(series.curve_model)
payload[f"{name}_smoothing_method"] = np.array(series.smoothing_method)
payload[f"{name}_baseline_source"] = np.array(series.baseline_source)
payload[f"{name}_fit_parameters"] = np.asarray(
[
series.fit_area,
series.fit_mu,
series.fit_sigma,
series.fit_arrival_offset_s,
series.fit_baseline,
],
dtype=np.float64,
)
payload[f"{name}_fit_quality"] = np.asarray(
[series.fit_r2, series.fit_nrmse, series.residual_noise_ratio],
dtype=np.float64,
)
payload[f"{name}_fit_interval_s"] = np.asarray(series.fit_interval_s, dtype=np.float64)
payload[f"{name}_multi_peak_flag"] = np.asarray(
[int(series.multi_peak_flag)], dtype=np.int8
)
payload[f"{name}_anchor_times_s"] = np.asarray(series.anchor_times_s, dtype=np.float64)
buffer = io.BytesIO()
# 存根把 **payload 也当成可能填进 allow_pickle 等关键字参数,这里只有数组键。
cast(Any, np.savez_compressed)(buffer, **payload)
return buffer.getvalue()
def unpack_tic_curves(raw: bytes) -> TicCurves:
"""把 npz 字节解包还原为 ``TicCurves``(禁 pickle,坏字节 raise)。
参数:
raw: ``pack_tic_curves`` 产出的 npz 原始字节。
返回:
还原后的 ``TicCurves``,dtype 与 pack 时一致。
异常:
ValueError / OSError / KeyError: 非法 npz、含 object pickle 成员、或缺键。
关键实现:
``np.load(..., allow_pickle=False)`` 硬禁 pickle——任何含 object pickle 的恶意
字节都会在访问该成员时抛错而非被反序列化执行。NaN 原样取回,不做任何替换。
"""
with np.load(io.BytesIO(raw), allow_pickle=False) as archive:
schema_version = str(archive["curves_schema_version"].item())
if schema_version != CURVES_SCHEMA_VERSION:
raise ValueError(
f"只支持 curves schema {CURVES_SCHEMA_VERSION},得到 {schema_version}。"
)
curves: dict[str, TicCurveSeries] = {}
for name in CURVE_NAMES:
frames = archive[f"{name}_baseline_frames"].astype(np.int32)
fit_parameters = archive[f"{name}_fit_parameters"].astype(np.float64)
fit_quality = archive[f"{name}_fit_quality"].astype(np.float64)
fit_interval = archive[f"{name}_fit_interval_s"].astype(np.float64)
anchor_times = archive[f"{name}_anchor_times_s"].astype(np.float64)
curves[name] = TicCurveSeries(
raw=archive[f"{name}_raw"].astype(np.float32),
candidate=archive[f"{name}_candidate"].astype(np.float32),
fitted=archive[f"{name}_fitted"].astype(np.float32),
processed=archive[f"{name}_processed"].astype(np.float32),
valid_sample_mask=archive[f"{name}_valid_sample_mask"].astype(bool),
duplicate_sample_mask=archive[f"{name}_duplicate_sample_mask"].astype(bool),
baseline_mean=float(archive[f"{name}_baseline_mean"][0]),
baseline_sd=float(archive[f"{name}_baseline_sd"][0]),
baseline_frames=(int(frames[0]), int(frames[1])),
baseline_valid=bool(archive[f"{name}_baseline_valid"][0]),
fit_success=bool(archive[f"{name}_fit_success"][0]),
fit_failure_reason=str(archive[f"{name}_fit_failure_reason"].item()),
curve_model=str(archive[f"{name}_curve_model"].item()),
smoothing_method=str(archive[f"{name}_smoothing_method"].item()),
baseline_source=str(archive[f"{name}_baseline_source"].item()),
fit_area=float(fit_parameters[0]),
fit_mu=float(fit_parameters[1]),
fit_sigma=float(fit_parameters[2]),
fit_arrival_offset_s=float(fit_parameters[3]),
fit_baseline=float(fit_parameters[4]),
fit_r2=float(fit_quality[0]),
fit_nrmse=float(fit_quality[1]),
residual_noise_ratio=float(fit_quality[2]),
fit_interval_s=(float(fit_interval[0]), float(fit_interval[1])),
multi_peak_flag=bool(archive[f"{name}_multi_peak_flag"][0]),
anchor_times_s=(
float(anchor_times[0]),
float(anchor_times[1]),
float(anchor_times[2]),
float(anchor_times[3]),
),
)
counts = {key: archive[key].astype(np.int32) for key in _COUNT_KEYS}
return TicCurves(
frame_indices=archive["frame_indices"].astype(np.int32),
time_video_s=archive["time_video_s"].astype(np.float32),
injection_offset_s=float(archive["injection_offset_s"][0]),
time_origin=str(archive["time_origin"].item()),
fps=float(archive["fps"][0]),
config_version=str(archive["config_version"].item()),
algorithm_version=str(archive["algorithm_version"].item()),
curves_schema_version=schema_version,
curves=curves,
perf_mask_stable=archive["perf_mask_stable"].astype(bool),
reference_valid=bool(archive["reference_valid"][0]),
early_truncation_flag=bool(archive["early_truncation_flag"][0]),
**counts,
)
+928
View File
@@ -0,0 +1,928 @@
"""第 7 步:TIC 的约束 Lognormal 拟合,以及拟合曲线上的连续时刻 D1–D4。
正式模型直接拟合清洗后的原始灰度样本。二阶 Savitzky-Golay 平滑只用来找候选峰、圈出第一遍
团注(造影剂第一次流过)的时间段和给优化器初值;质量门不通过时,不把候选曲线顶替成正式的
`processed` 曲线(`processed` 整条为 NaN),免得一条没拟合好的曲线冒充正式结果。
照搬旧项目 `tic_fitting.py`,去掉产品层;旧 `_empty_result` 与 `fit_lognormal_curve` 的参数
默认值改成调用处显式传入。
"""
import itertools
import math
from dataclasses import dataclass
from typing import Any, cast
import numpy as np
from scipy.optimize import ( # pyright: ignore[reportMissingTypeStubs]
brentq, # pyright: ignore[reportUnknownVariableType]
least_squares, # pyright: ignore[reportUnknownVariableType]
)
from scipy.signal import ( # pyright: ignore[reportMissingTypeStubs]
find_peaks, # pyright: ignore[reportUnknownVariableType]
savgol_filter, # pyright: ignore[reportUnknownVariableType]
)
from cadx.ceus._config import AnchorFramesConfig, TicFittingConfig
from cadx.ceus._geometry import AnyArray
@dataclass(frozen=True)
class LognormalParameters:
"""Lognormal 团注曲线的五个参数:面积、对数均值、对数标准差、到达偏移(秒)、基线。"""
area: float
mu: float
sigma: float
arrival_offset_s: float
baseline: float
@dataclass(frozen=True)
class LognormalFitResult:
"""一条曲线的拟合结果。
`candidate` 是候选平滑曲线(只作检测用);`fitted` 是优化器给出的曲线(可能没过质量门);
`processed` 只在 `success` 时等于 `fitted`,否则整条 NaN。`d1_time_s`–`d4_time_s` 是拟合
曲线上的到达、洗入拐点、峰值、洗出拐点时刻(视频时间,秒),失败时为 None。
"""
success: bool
failure_reason: str
algorithm_version: str
curve_model: str
smoothing_method: str
baseline_source: str
candidate: AnyArray
fitted: AnyArray
processed: AnyArray
valid_sample_mask: AnyArray
duplicate_sample_mask: AnyArray
parameters: LognormalParameters | None
r2: float
nrmse: float
residual_noise_ratio: float
fit_start_s: float
fit_end_s: float
multi_peak_flag: bool
d1_time_s: float | None
d2_time_s: float | None
d3_time_s: float | None
d4_time_s: float | None
def evaluate_lognormal(time_s: AnyArray | float, parameters: LognormalParameters) -> AnyArray:
"""带固定基线与到达偏移的 Lognormal 团注曲线在给定时刻的值。"""
times = np.asarray(time_s, dtype=np.float64)
values = np.full(times.shape, parameters.baseline, dtype=np.float64)
shifted = times - parameters.arrival_offset_s
positive = shifted > 0.0
if not positive.any():
return values
x_values = shifted[positive]
exponent = -((np.log(x_values) - parameters.mu) ** 2 / (2.0 * parameters.sigma**2))
values[positive] += (
parameters.area
/ (x_values * parameters.sigma * math.sqrt(2.0 * math.pi))
* np.exp(exponent)
)
return values
def _empty_result(
size: int,
cfg: TicFittingConfig,
reason: str,
*,
candidate: AnyArray | None,
fitted: AnyArray | None,
valid_mask: AnyArray | None,
duplicate_mask: AnyArray | None,
parameters: LognormalParameters | None,
r2: float,
nrmse: float,
residual_noise_ratio: float,
fit_start_s: float,
fit_end_s: float,
multi_peak_flag: bool,
baseline_source: str,
) -> LognormalFitResult:
"""拟合被拒时的结果:`success=False`、`processed` 全 NaN、四个时刻为 None。"""
nan_curve = np.full(size, np.nan, dtype=np.float64)
return LognormalFitResult(
success=False,
failure_reason=reason,
algorithm_version=cfg.algorithm_version,
curve_model="lognormal",
smoothing_method="",
baseline_source=baseline_source,
candidate=nan_curve.copy() if candidate is None else candidate,
fitted=nan_curve.copy() if fitted is None else fitted,
processed=nan_curve.copy(),
valid_sample_mask=(np.zeros(size, dtype=bool) if valid_mask is None else valid_mask),
duplicate_sample_mask=(
np.zeros(size, dtype=bool) if duplicate_mask is None else duplicate_mask
),
parameters=parameters,
r2=r2,
nrmse=nrmse,
residual_noise_ratio=residual_noise_ratio,
fit_start_s=fit_start_s,
fit_end_s=fit_end_s,
multi_peak_flag=multi_peak_flag,
d1_time_s=None,
d2_time_s=None,
d3_time_s=None,
d4_time_s=None,
)
def _finite_runs(mask: AnyArray) -> list[tuple[int, int]]:
"""布尔序列里连续为真的段,每段 `[start, stop)`。"""
runs: list[tuple[int, int]] = []
start: int | None = None
for index, value in enumerate(mask):
if value and start is None:
start = index
elif not value and start is not None:
runs.append((start, index))
start = None
if start is not None:
runs.append((start, len(mask)))
return runs
def _odd_window(sample_count: int, desired: int, polynomial_order: int) -> int:
"""Savitzky-Golay 窗口:奇数,不超过样本数,不小于多项式阶数 + 2。"""
maximum = sample_count if sample_count % 2 == 1 else sample_count - 1
minimum = polynomial_order + 2
if minimum % 2 == 0:
minimum += 1
window = desired if desired % 2 == 1 else desired + 1
return min(maximum, max(minimum, window))
def _savgol(segment: AnyArray, window: int, polyorder: int) -> AnyArray:
"""二阶 Savitzky-Golay 平滑(`mode="interp"`)。"""
return cast(
AnyArray,
savgol_filter(segment, window_length=window, polyorder=polyorder, mode="interp"),
)
def _candidate_curve(
times: AnyArray,
values: AnyArray,
valid_mask: AnyArray,
cfg: TicFittingConfig,
) -> tuple[AnyArray, AnyArray, AnyArray, float]:
"""把有效样本插值到等间隔网格,短窗与长窗两次平滑按 1:3 混合,得到候选曲线。
相邻有效样本间隔超过 `max_candidate_gap_s` 的地方不插值(记 NaN),分段各自平滑。
返回(采样时刻上的候选值、网格、网格上的候选值、网格步长)。
"""
valid_times = times[valid_mask]
intervals = np.diff(valid_times)
positive_intervals = intervals[intervals > 0.0]
if positive_intervals.size == 0:
raise ValueError("有效时间样本不足以建立候选网格")
grid_step = float(np.median(positive_intervals))
grid = np.arange(valid_times[0], valid_times[-1] + grid_step * 0.5, grid_step, dtype=np.float64)
interpolated = np.interp(grid, valid_times, values[valid_mask])
for left, right in itertools.pairwise(valid_times):
if right - left - grid_step > cfg.max_candidate_gap_s:
interpolated[(grid > left) & (grid < right)] = np.nan
smoothed = np.full(grid.shape, np.nan, dtype=np.float64)
finite = np.isfinite(interpolated)
short_window = max(1, round(cfg.candidate_window_s / grid_step))
long_window = max(1, round(cfg.candidate_long_window_s / grid_step))
for start, stop in _finite_runs(finite):
segment = interpolated[start:stop]
if segment.size <= cfg.candidate_polyorder + 1:
continue
short = _odd_window(int(segment.size), short_window, cfg.candidate_polyorder)
long = _odd_window(int(segment.size), long_window, cfg.candidate_polyorder)
if short <= cfg.candidate_polyorder or long <= cfg.candidate_polyorder:
continue
short_values = _savgol(segment, short, cfg.candidate_polyorder)
long_values = _savgol(segment, long, cfg.candidate_polyorder)
smoothed[start:stop] = 0.25 * short_values + 0.75 * long_values
at_samples = np.full(times.shape, np.nan, dtype=np.float64)
for start, stop in _finite_runs(np.isfinite(smoothed)):
in_run = (times >= grid[start]) & (times <= grid[stop - 1])
at_samples[in_run] = np.interp(times[in_run], grid[start:stop], smoothed[start:stop])
return at_samples, grid, smoothed, grid_step
def _candidate_peaks(
grid: AnyArray,
candidate: AnyArray,
baseline: float,
baseline_noise: float,
baseline_end_s: float,
grid_step: float,
cfg: TicFittingConfig,
anchor_cfg: AnchorFramesConfig,
) -> list[int]:
"""基线段之后的候选峰(网格下标):高度与突出度都须过「峰高比例」与「基线抖动倍数」的高者。"""
enhancement = candidate - baseline
eligible = np.isfinite(enhancement) & (grid >= baseline_end_s)
if not eligible.any():
return []
amplitude = float(np.nanmax(enhancement[eligible]))
if not math.isfinite(amplitude) or amplitude <= 0.0:
return []
minimum_height = max(
cfg.minimum_relative_peak_height * amplitude,
anchor_cfg.peak_prominence_baseline_sd_multiplier * baseline_noise,
)
distance = max(1, math.ceil(cfg.minimum_peak_distance_s / grid_step))
peaks: list[int] = []
for start, stop in _finite_runs(eligible):
segment = enhancement[start:stop]
if segment.size < 3:
continue
found = cast(
tuple[AnyArray, object],
find_peaks(
segment, height=minimum_height, prominence=minimum_height, distance=distance
),
)
local = found[0]
peaks.extend(int(value) + start for value in local.tolist())
return sorted(peaks)
def _estimate_baseline(
values: AnyArray,
valid_mask: AnyArray,
cfg: TicFittingConfig,
) -> tuple[float, float]:
"""视频开头没有稳定基线时,用低分位数估一个有界的基线和噪声。"""
finite = values[valid_mask]
baseline = float(np.percentile(finite, cfg.estimated_baseline_percentile))
low_limit = float(np.percentile(finite, min(cfg.estimated_baseline_percentile * 2.0, 45.0)))
low_values = finite[finite <= low_limit]
center = float(np.median(low_values)) if low_values.size else baseline
noise = 1.4826 * float(np.median(np.abs(low_values - center)))
if not math.isfinite(noise) or noise <= 0.0:
differences = np.diff(finite)
difference_center = float(np.median(differences)) if differences.size else 0.0
noise = 1.4826 * float(np.median(np.abs(differences - difference_center)))
amplitude = max(float(np.percentile(finite, 95.0) - baseline), 1e-3)
return baseline, max(noise, amplitude * 0.01, 1e-3)
def _candidate_detection_noise(
grid: AnyArray,
candidate: AnyArray,
baseline_end_s: float,
baseline_noise: float,
) -> float:
"""检测用的噪声:基线段候选曲线一阶差分的稳健离散度,不超过基线噪声。"""
baseline_values = candidate[np.isfinite(candidate) & (grid <= baseline_end_s)]
differences = np.diff(baseline_values)
if differences.size < 2:
return baseline_noise
center = float(np.median(differences))
difference_noise = 1.4826 * float(np.median(np.abs(differences - center)))
return min(baseline_noise, max(difference_noise / math.sqrt(2.0), 1e-3))
def _select_primary_peak(
peaks: list[int],
grid: AnyArray,
candidate: AnyArray,
baseline: float,
) -> int | None:
"""主峰:按「升降完整度 × 半高宽」打分,够强(≥ 最强者 1/4)的里取最早的一个。"""
if not peaks:
return None
finite_runs = _finite_runs(np.isfinite(candidate))
scored: list[tuple[float, float, int]] = []
for peak in peaks:
containing_run = next(
((start, stop) for start, stop in finite_runs if start < peak < stop - 1),
None,
)
if containing_run is None:
continue
first, stop = containing_run
last = stop - 1
peak_height = float(candidate[peak] - baseline)
left = candidate[first : peak + 1]
right = candidate[peak : last + 1]
if peak_height <= 0.0:
continue
rise = float(candidate[peak] - np.min(left))
decline = float(candidate[peak] - np.min(right))
completeness = min(rise, decline)
half_prominence = float(candidate[peak] - 0.5 * completeness)
width_left = peak
while width_left > first and candidate[width_left - 1] >= half_prominence:
width_left -= 1
width_right = peak
while width_right < last and candidate[width_right + 1] >= half_prominence:
width_right += 1
width = float(grid[width_right] - grid[width_left])
if width <= 0.0:
continue
scored.append((completeness * max(width, 1e-6), peak_height, peak))
if not scored:
return None
complete = [item for item in scored if item[0] > 0.0 and item[1] > 0.0]
if complete:
strongest = max(item[0] for item in complete)
credible = [item for item in complete if item[0] >= strongest * 0.25]
if credible:
return min(credible, key=lambda item: item[2])[2]
return max(scored, key=lambda item: (item[0], item[1], -item[2]))[2]
def _persistent_arrival(
grid: AnyArray,
enhancement: AnyArray,
peak_index: int,
threshold: float,
persistence_s: float,
) -> float | None:
"""主峰之前第一次越过门槛并持续 `persistence_s` 秒的时刻;没有返回 None。"""
above = enhancement >= threshold
run_start: int | None = None
for index in range(peak_index + 1):
if not np.isfinite(enhancement[index]) or not above[index]:
run_start = None
continue
if run_start is None:
run_start = index
if grid[index] - grid[run_start] >= persistence_s:
return float(grid[run_start])
return None
def _first_bolus_end(
grid: AnyArray,
candidate: AnyArray,
baseline: float,
peaks: list[int],
primary_peak: int,
cfg: TicFittingConfig,
) -> tuple[float, bool]:
"""第一遍团注到哪里结束:只把相隔足够远、谷值足够深的后峰视为第二遍团注。"""
first_peak = primary_peak
first_height = float(candidate[first_peak] - baseline)
for second_peak in (peak for peak in peaks if peak > first_peak):
if grid[second_peak] - grid[first_peak] < cfg.multi_peak_minimum_separation_s:
continue
second_height = float(candidate[second_peak] - baseline)
if second_height < cfg.multi_peak_minimum_relative_height * first_height:
continue
valley_slice = candidate[first_peak : second_peak + 1]
if not np.isfinite(valley_slice).all() or valley_slice.size < 3:
continue
valley_offset = int(np.argmin(valley_slice))
valley_height = max(float(valley_slice[valley_offset] - baseline), 0.0)
reference_height = max(min(first_height, second_height), float(np.finfo(float).eps))
if valley_height / reference_height <= cfg.multi_peak_maximum_valley_fraction:
return float(grid[first_peak + valley_offset]), True
return float(grid[-1]), False
def _unpack_optimizer_parameters(packed: AnyArray) -> LognormalParameters:
"""优化器的参数化(对数峰高、峰时、对数 σ、到达偏移、基线)→ Lognormal 五参数。"""
peak_height = float(math.exp(float(packed[0])))
peak_time_s = float(packed[1])
sigma = float(math.exp(float(packed[2])))
arrival_offset_s = float(packed[3])
baseline = float(packed[4])
mode_x = peak_time_s - arrival_offset_s
mu = math.log(mode_x) + sigma**2
area = peak_height * mode_x * sigma * math.sqrt(2.0 * math.pi) * math.exp(0.5 * sigma**2)
return LognormalParameters(
area=area,
mu=mu,
sigma=sigma,
arrival_offset_s=arrival_offset_s,
baseline=baseline,
)
def _initial_guesses(
peak_time_s: float,
peak_height: float,
t0_lower: float,
t0_upper: float,
baseline: float,
sigma_min: float,
sigma_max: float,
) -> list[AnyArray]:
"""多组初值:到达偏移取区间两端与中点,σ 取四档,峰时取候选峰 ±0.25 秒。"""
guesses: list[AnyArray] = []
t0_values = np.unique(
np.clip(
np.asarray([t0_lower, (t0_lower + t0_upper) / 2.0, t0_upper], dtype=np.float64),
t0_lower,
t0_upper,
)
)
peak_time_values = np.asarray(
[peak_time_s - 0.25, peak_time_s, peak_time_s + 0.25], dtype=np.float64
)
sigma_values = np.clip(
np.asarray([0.25, 0.5, 0.9, 1.3], dtype=np.float64),
sigma_min,
sigma_max,
)
for t0_value in t0_values.tolist():
for sigma in np.unique(sigma_values).tolist():
for peak_time_value in peak_time_values.tolist():
if peak_time_value <= t0_value:
continue
guesses.append(
np.asarray(
[
math.log(max(peak_height, 1e-8)),
peak_time_value,
math.log(float(sigma)),
t0_value,
baseline,
],
dtype=np.float64,
)
)
return guesses
def _optimizer_bounds(
values: AnyArray,
baseline: float,
baseline_noise: float,
peak_height: float,
t0_lower: float,
t0_upper: float,
peak_lower: float,
peak_upper: float,
baseline_observed: bool,
cfg: TicFittingConfig,
) -> tuple[AnyArray, AnyArray, float]:
"""优化器参数的上下界和基线惩罚的尺度;观测到基线时基线只许在其附近小幅浮动。"""
amplitude = max(float(np.max(values) - baseline), peak_height, 1e-3)
height_upper = max(amplitude * 10.0, 1.0)
baseline_span = max(baseline_noise * 3.0, amplitude * 0.05, 1e-3)
if baseline_observed:
baseline_lower = baseline - baseline_span
baseline_upper = baseline + baseline_span
baseline_scale = baseline_span
else:
baseline_lower = max(0.0, float(np.percentile(values, 1.0)) - amplitude * 0.25)
baseline_upper = min(float(np.percentile(values, 45.0)), baseline + amplitude * 0.35)
baseline_upper = max(baseline_upper, baseline_lower + 1e-3)
baseline_scale = max(amplitude * 0.2, baseline_noise, 1e-3)
lower = np.asarray(
[
math.log(1e-8),
peak_lower,
math.log(cfg.sigma_min),
t0_lower,
baseline_lower,
],
dtype=np.float64,
)
upper = np.asarray(
[
math.log(height_upper),
peak_upper,
math.log(cfg.sigma_max),
t0_upper,
baseline_upper,
],
dtype=np.float64,
)
return lower, upper, baseline_scale
def _fit_parameters(
times: AnyArray,
values: AnyArray,
baseline: float,
baseline_noise: float,
peak_time_s: float,
peak_height: float,
t0_lower: float,
t0_upper: float,
peak_lower: float,
peak_upper: float,
baseline_observed: bool,
cfg: TicFittingConfig,
) -> LognormalParameters | None:
"""从每组初值各跑一次有界稳健最小二乘,取平方误差最小的那组;全部失败返回 None。"""
lower, upper, baseline_scale = _optimizer_bounds(
values,
baseline,
baseline_noise,
peak_height,
t0_lower,
t0_upper,
peak_lower,
peak_upper,
baseline_observed,
cfg,
)
amplitude = max(float(np.max(values) - baseline), peak_height, 1e-3)
residual_scale = max(baseline_noise, amplitude * 0.05, 1e-3)
def residuals(packed: AnyArray) -> AnyArray:
if packed[1] <= packed[3]:
return np.full(values.size + 1, 1e6, dtype=np.float64)
parameters = _unpack_optimizer_parameters(packed)
data = (evaluate_lognormal(times, parameters) - values) / residual_scale
penalty = np.asarray([(parameters.baseline - baseline) / baseline_scale])
return np.concatenate((data, penalty))
best: tuple[float, LognormalParameters] | None = None
for guess in _initial_guesses(
peak_time_s,
peak_height,
t0_lower,
t0_upper,
baseline,
cfg.sigma_min,
cfg.sigma_max,
):
clipped = np.clip(guess, lower + 1e-9, upper - 1e-9)
result = cast(
Any,
least_squares(
residuals,
clipped,
bounds=(lower, upper),
loss=cfg.robust_loss,
f_scale=1.0,
max_nfev=cfg.max_nfev,
),
)
solution = np.asarray(cast(AnyArray, result.x), dtype=np.float64)
if not bool(result.success) or not np.isfinite(solution).all():
continue
if solution[1] <= solution[3]:
continue
parameters = _unpack_optimizer_parameters(solution)
squared_error = float(np.sum((evaluate_lognormal(times, parameters) - values) ** 2))
if best is None or squared_error < best[0]:
best = (squared_error, parameters)
return None if best is None else best[1]
def _parameters_on_boundary(
parameters: LognormalParameters,
lower: AnyArray,
upper: AnyArray,
fraction: float,
) -> bool:
"""拟合参数(换回优化器参数化后)是否贴着上下界(区间宽度的 `fraction` 以内)。"""
peak_time_s = parameters.arrival_offset_s + math.exp(parameters.mu - parameters.sigma**2)
peak_height = float(
evaluate_lognormal(np.asarray([peak_time_s]), parameters)[0] - parameters.baseline
)
packed = np.asarray(
[
math.log(max(peak_height, 1e-8)),
peak_time_s,
math.log(parameters.sigma),
parameters.arrival_offset_s,
parameters.baseline,
],
dtype=np.float64,
)
margin = (upper - lower) * fraction
return bool(np.any(packed <= lower + margin) or np.any(packed >= upper - margin))
def _quality_metrics(
observed: AnyArray,
fitted: AnyArray,
baseline_noise: float,
) -> tuple[float, float, float]:
"""拟合优度:R²、按观测幅度归一的 RMSE、RMSE 与基线噪声之比。"""
residual = observed - fitted
residual_sum = float(np.sum(residual**2))
centered_sum = float(np.sum((observed - float(np.mean(observed))) ** 2))
r2 = float("nan") if centered_sum <= 0.0 else 1.0 - residual_sum / centered_sum
rmse = math.sqrt(residual_sum / max(int(observed.size), 1))
amplitude = max(float(np.max(observed) - np.min(observed)), 1e-6)
nrmse = rmse / amplitude
residual_noise_ratio = rmse / max(baseline_noise, 1e-6)
return r2, nrmse, residual_noise_ratio
def evaluate_lognormal_derivative(time_s: float, parameters: LognormalParameters) -> float:
"""Lognormal 曲线在某一时刻的一阶导数(到达偏移之前为 0)。"""
shifted = time_s - parameters.arrival_offset_s
if shifted <= 0.0:
return 0.0
value = float(evaluate_lognormal(np.asarray([time_s]), parameters)[0])
pulse = value - parameters.baseline
return (
pulse
* (parameters.mu - parameters.sigma**2 - math.log(shifted))
/ (parameters.sigma**2 * shifted)
)
def _arrival_root(
parameters: LognormalParameters, threshold: float, left: float, right: float
) -> float:
"""拟合曲线在 `[left, right]` 内升过「基线 + 门槛」的时刻(Brent 求根)。"""
def above_threshold(value: float) -> float:
return float(
evaluate_lognormal(np.asarray([value]), parameters)[0] - parameters.baseline - threshold
)
return float(cast(float, brentq(above_threshold, left, right)))
def _continuous_anchors(
parameters: LognormalParameters,
baseline_noise: float,
observed_end_s: float,
anchor_cfg: AnchorFramesConfig,
boundary_margin_s: float,
) -> tuple[float | None, float | None, float, float | None]:
"""拟合曲线上的四个连续时刻:到达 D1、洗入拐点 D2、峰值 D3、洗出拐点 D4。
到达门槛取「基线抖动 × 倍数」与「峰高 × 比例」的高者(`anchor_frames.arrival`)。D4 只在
峰后还有足够观测(`boundary_margin_s`)、且拐点落在观测范围内、导数为负时给出。
"""
peak_time = parameters.arrival_offset_s + math.exp(parameters.mu - parameters.sigma**2)
peak_value = float(evaluate_lognormal(np.asarray([peak_time]), parameters)[0])
peak_height = peak_value - parameters.baseline
threshold = max(
anchor_cfg.arrival.baseline_sd_multiplier * baseline_noise,
anchor_cfg.arrival.relative_peak_threshold * peak_height,
)
left = parameters.arrival_offset_s + 1e-9
arrival: float | None = None
if threshold > 0.0 and peak_height >= threshold:
arrival = _arrival_root(parameters, threshold, left, peak_time)
sigma_squared = parameters.sigma**2
extrema_discriminant = math.sqrt(sigma_squared**2 + 4.0 * sigma_squared)
wash_in_factor = (sigma_squared + extrema_discriminant) / 2.0
wash_out_factor = (sigma_squared - extrema_discriminant) / 2.0
wash_in_candidate = parameters.arrival_offset_s + math.exp(
parameters.mu - sigma_squared - wash_in_factor
)
wash_in_start = arrival if arrival is not None else left
d2_time = max(wash_in_start, wash_in_candidate)
d4_time: float | None = None
if observed_end_s - peak_time >= boundary_margin_s:
candidate_d4 = parameters.arrival_offset_s + math.exp(
parameters.mu - sigma_squared - wash_out_factor
)
if (
candidate_d4 < observed_end_s - boundary_margin_s
and evaluate_lognormal_derivative(candidate_d4, parameters) < 0.0
):
d4_time = candidate_d4
return arrival, d2_time, peak_time, d4_time
def fit_lognormal_curve(
time_s: AnyArray,
raw_values: AnyArray,
*,
baseline: float | None,
baseline_noise: float | None,
baseline_end_index: int,
duplicate_mask: AnyArray,
sample_mask: AnyArray | None,
cfg: TicFittingConfig,
anchor_cfg: AnchorFramesConfig,
) -> LognormalFitResult:
"""拟合一条原始灰度 TIC,并给出连续的 D1–D4 时刻。
`baseline`/`baseline_noise` 为 None 或 `baseline_end_index <= 0` 表示视频开头没有观测到
稳定基线,改用低分位数估计(`baseline_source="estimated"`)。`sample_mask` 为 None 表示
全部样本可用。任何一道门不过都返回 `success=False`,原因写在 `failure_reason`。
"""
times = np.asarray(time_s, dtype=np.float64)
values = np.asarray(raw_values, dtype=np.float64)
duplicates = np.asarray(duplicate_mask, dtype=bool)
if times.ndim != 1 or values.shape != times.shape or duplicates.shape != times.shape:
raise ValueError("time_s、raw_values 与 duplicate_mask 必须是等长一维数组")
if times.size == 0 or not np.isfinite(times).all() or np.any(np.diff(times) <= 0.0):
raise ValueError("time_s 必须是非空、有限且严格递增的一维数组")
eligibility = (
np.ones(times.shape, dtype=bool)
if sample_mask is None
else np.asarray(sample_mask, dtype=bool)
)
if eligibility.shape != times.shape:
raise ValueError("sample_mask 必须与 time_s 等长")
valid_mask = np.isfinite(values) & ~duplicates & eligibility
candidate_at_samples: AnyArray | None = None
fitted: AnyArray | None = None
parameters: LognormalParameters | None = None
r2 = nrmse = residual_noise_ratio = float("nan")
fit_start_s = fit_end_s = float("nan")
multi_peak = False
baseline_source = "unavailable"
def reject(reason: str) -> LognormalFitResult:
return _empty_result(
len(times),
cfg,
reason,
candidate=candidate_at_samples,
fitted=fitted,
valid_mask=valid_mask,
duplicate_mask=duplicates,
parameters=parameters,
r2=r2,
nrmse=nrmse,
residual_noise_ratio=residual_noise_ratio,
fit_start_s=fit_start_s,
fit_end_s=fit_end_s,
multi_peak_flag=multi_peak,
baseline_source=baseline_source,
)
if int(valid_mask.sum()) < cfg.minimum_valid_samples:
return reject("insufficient_valid_samples")
# 有效样本时刻全部相同时(理论上严格递增已排除)建不出候选网格,记为拟合失败。
try:
candidate_at_samples, grid, candidate_grid, grid_step = _candidate_curve(
times, values, valid_mask, cfg
)
except ValueError:
return reject("candidate_grid_unavailable")
baseline_observed = (
baseline is not None and baseline_noise is not None and baseline_end_index > 0
)
if baseline is not None and baseline_noise is not None and baseline_end_index > 0:
fit_baseline = float(baseline)
fit_baseline_noise = max(float(baseline_noise), 1e-3)
baseline_end_s = float(times[min(baseline_end_index - 1, len(times) - 1)])
baseline_source = "observed"
else:
fit_baseline, fit_baseline_noise = _estimate_baseline(values, valid_mask, cfg)
baseline_end_s = float(times[valid_mask][0])
baseline_source = "estimated"
detection_noise = _candidate_detection_noise(
grid, candidate_grid, baseline_end_s, fit_baseline_noise
)
peaks = _candidate_peaks(
grid,
candidate_grid,
fit_baseline,
detection_noise,
baseline_end_s,
grid_step,
cfg,
anchor_cfg,
)
first_peak = _select_primary_peak(peaks, grid, candidate_grid, fit_baseline)
if first_peak is None:
return reject("credible_peak_not_found")
first_peak_height = float(candidate_grid[first_peak] - fit_baseline)
arrival_threshold = max(
anchor_cfg.arrival.baseline_sd_multiplier * detection_noise,
anchor_cfg.arrival.relative_peak_threshold * first_peak_height,
)
if (
_persistent_arrival(
grid,
candidate_grid - fit_baseline,
first_peak,
arrival_threshold,
anchor_cfg.arrival.persistence_s,
)
is None
):
return reject("candidate_arrival_not_found")
fit_end_s, multi_peak = _first_bolus_end(
grid, candidate_grid, fit_baseline, peaks, first_peak, cfg
)
fit_mask = valid_mask & (times <= fit_end_s)
fit_times = times[fit_mask]
fit_values = values[fit_mask]
if fit_times.size < cfg.minimum_valid_samples:
return reject("insufficient_bolus_samples")
fit_start_s = float(fit_times[0])
long_gaps = np.diff(fit_times)
pre_peak = fit_times[:-1] < float(grid[first_peak])
if np.any(long_gaps[pre_peak] - grid_step > cfg.max_candidate_gap_s):
return reject("long_gap_in_wash_in")
observed_start_s = fit_start_s
peak_lower = max(
observed_start_s + cfg.boundary_margin_s,
float(grid[first_peak]) - cfg.candidate_long_window_s,
)
peak_upper = min(
fit_end_s - cfg.boundary_margin_s,
float(grid[first_peak]) + cfg.candidate_long_window_s,
)
t0_lower = (
observed_start_s
if baseline_observed
else observed_start_s - cfg.estimated_baseline_lookback_s
)
t0_upper = peak_lower - max(grid_step * 0.25, 1e-4)
if t0_upper <= t0_lower or peak_upper <= peak_lower:
return reject("arrival_bounds_invalid")
parameters = _fit_parameters(
fit_times,
fit_values,
fit_baseline,
fit_baseline_noise,
float(grid[first_peak]),
first_peak_height,
t0_lower,
t0_upper,
peak_lower,
peak_upper,
baseline_observed,
cfg,
)
if parameters is None:
return reject("optimizer_failed")
fitted = evaluate_lognormal(times, parameters)
fit_predictions = evaluate_lognormal(fit_times, parameters)
r2, nrmse, residual_noise_ratio = _quality_metrics(
fit_values, fit_predictions, fit_baseline_noise
)
peak_time_s = parameters.arrival_offset_s + math.exp(parameters.mu - parameters.sigma**2)
lower, upper, _ = _optimizer_bounds(
fit_values,
fit_baseline,
fit_baseline_noise,
first_peak_height,
t0_lower,
t0_upper,
peak_lower,
peak_upper,
baseline_observed,
cfg,
)
if _parameters_on_boundary(parameters, lower, upper, cfg.parameter_boundary_fraction):
return reject("parameter_on_boundary")
if (
not observed_start_s + cfg.boundary_margin_s
< peak_time_s
< fit_end_s - cfg.boundary_margin_s
):
return reject("model_peak_on_boundary")
residual_supported = (
math.isfinite(residual_noise_ratio)
and residual_noise_ratio <= cfg.maximum_residual_noise_ratio
)
variance_supported = math.isfinite(r2) and r2 >= cfg.minimum_r2
if nrmse > cfg.maximum_nrmse or not (variance_supported or residual_supported):
return reject("fit_quality_failed")
d1_time, d2_time, d3_time, d4_time = _continuous_anchors(
parameters,
fit_baseline_noise,
fit_end_s,
anchor_cfg,
cfg.boundary_margin_s,
)
return LognormalFitResult(
success=True,
failure_reason="",
algorithm_version=cfg.algorithm_version,
curve_model="lognormal",
smoothing_method="",
baseline_source=baseline_source,
candidate=candidate_at_samples,
fitted=fitted,
processed=fitted.copy(),
valid_sample_mask=valid_mask,
duplicate_sample_mask=duplicates,
parameters=parameters,
r2=r2,
nrmse=nrmse,
residual_noise_ratio=residual_noise_ratio,
fit_start_s=observed_start_s,
fit_end_s=fit_end_s,
multi_peak_flag=multi_peak,
d1_time_s=d1_time,
d2_time_s=d2_time,
d3_time_s=d3_time,
d4_time_s=d4_time,
)
+181
View File
@@ -0,0 +1,181 @@
# matplotlib 的类型存根把绘图方法的 **kwargs 标成 Unknown,这一个文件里关掉这条规则。
# pyright: reportUnknownMemberType=false
"""tic.png:TIC 原始样本、过了质量门的 Lognormal 拟合曲线和关键帧时刻竖线。
紫圈曲线拟合没过质量门时仍画出拟合(虚线、标 QC rejected),便于复核;病灶曲线拟合没过就不画。
照搬旧项目 `tic_plot.py`,去掉产品层。
"""
from collections.abc import Sequence
from dataclasses import dataclass
from io import BytesIO
import numpy as np
from matplotlib.axes import Axes
from matplotlib.backends.backend_agg import FigureCanvasAgg
from matplotlib.figure import Figure
from cadx.ceus._geometry import AnyArray
from cadx.ceus._tic import TicResult
_FIGURE_SIZE = (16.0, 8.0)
_FIGURE_DPI = 100
_CURVE_STYLES = {
"roi_all": ("roi_all", "#2563B8"),
"roi_perf": ("roi_perf (dark removed)", "#D46700"),
"roi_pan": ("roi_pan reference", "#7C3AED"),
}
@dataclass(frozen=True)
class TicPlotMarker:
"""复核时间标记;颜色沿用 OpenCV 的 BGR 顺序。"""
label: str
time_s: float
color: tuple[int, int, int]
def render_tic_png(tic: TicResult, *, markers: Sequence[TicPlotMarker]) -> bytes:
"""把病灶与紫圈(正常胰腺对照)的 TIC 画成 PNG 字节;`markers` 是要画的时刻竖线。"""
times, raw_series, processed_series = _curve_arrays(tic)
figure = Figure(
figsize=_FIGURE_SIZE,
dpi=_FIGURE_DPI,
facecolor="white",
layout="constrained",
)
canvas = FigureCanvasAgg(figure)
axes = figure.subplots()
_style_axes(axes)
plotted_values: list[AnyArray] = []
for name, (label, color) in _CURVE_STYLES.items():
values = raw_series[name]
if np.isfinite(values).any():
axes.plot(
times,
values,
color=color,
alpha=0.24,
linewidth=1.2,
marker=".",
markersize=2.2,
label=f"{label} raw",
)
plotted_values.append(values)
for name, (label, color) in _CURVE_STYLES.items():
fit = tic.fits[name]
if fit.success:
values = processed_series[name]
fit_label = f"{label} Lognormal fit"
linestyle = "-"
elif name == "roi_pan":
values = np.asarray(fit.fitted, dtype=np.float64)
fit_label = f"{label} Lognormal fit [QC rejected]"
linestyle = "--"
else:
continue
if np.isfinite(values).any():
axes.plot(
times,
values,
color=color,
linewidth=3.0,
linestyle=linestyle,
label=fit_label,
)
plotted_values.append(values)
_draw_markers(axes, markers, times)
_set_limits(axes, times, plotted_values)
if not plotted_values:
axes.text(
0.5,
0.5,
"No plottable TIC data",
ha="center",
va="center",
transform=axes.transAxes,
color="#555555",
fontsize=14,
)
if axes.lines:
axes.legend(loc="lower center", ncol=3, framealpha=0.92, fontsize=11)
with BytesIO() as output:
canvas.print_png(output)
return output.getvalue()
def _curve_arrays(
tic: TicResult,
) -> tuple[AnyArray, dict[str, AnyArray], dict[str, AnyArray]]:
times = np.asarray([row["time_sec"] for row in tic.rows], dtype=np.float64)
raw_series = {
name: np.asarray([row[f"{name}_raw"] for row in tic.rows], dtype=np.float64)
for name in _CURVE_STYLES
}
processed_series = {
name: np.asarray([row[f"{name}_processed"] for row in tic.rows], dtype=np.float64)
for name in _CURVE_STYLES
}
return times, raw_series, processed_series
def _style_axes(axes: Axes) -> None:
axes.set_title("TIC: Lognormal fit", fontsize=20, pad=12)
axes.set_xlabel("Time (s)", fontsize=15)
axes.set_ylabel("Grayscale intensity", fontsize=15)
axes.tick_params(axis="both", labelsize=12)
axes.grid(True, color="#B8B8B8", linewidth=0.8, alpha=0.32)
axes.set_axisbelow(True)
def _draw_markers(
axes: Axes,
markers: Sequence[TicPlotMarker],
times: AnyArray,
) -> None:
finite_times = times[np.isfinite(times)]
if not finite_times.size:
return
time_min = float(finite_times.min())
time_max = float(finite_times.max())
for marker in markers:
if not np.isfinite(marker.time_s) or not time_min <= marker.time_s <= time_max:
continue
axes.axvline(
marker.time_s,
color=tuple(channel / 255.0 for channel in reversed(marker.color)),
linestyle="--",
linewidth=1.5,
alpha=0.9,
label=marker.label,
)
def _set_limits(
axes: Axes,
times: AnyArray,
series: Sequence[AnyArray],
) -> None:
finite_times = times[np.isfinite(times)]
if finite_times.size:
time_min = float(finite_times.min())
time_max = float(finite_times.max())
time_padding = max((time_max - time_min) * 0.025, 0.1)
axes.set_xlim(time_min - time_padding, time_max + time_padding)
finite_values = [values[np.isfinite(values)] for values in series]
finite_values = [values for values in finite_values if values.size]
if not finite_values:
return
value_min = min(float(values.min()) for values in finite_values)
value_max = max(float(values.max()) for values in finite_values)
value_span = max(value_max - min(0.0, value_min), 1.0)
axes.set_ylim(
min(0.0, value_min) - 0.05 * value_span,
value_max + 0.08 * value_span,
)
+245
View File
@@ -0,0 +1,245 @@
"""trajectory.npz 的打包和解包:逐帧 ROI 多边形、暗区、背景圆和几何元数据(格式同旧项目)。
键集对齐参考组件 `write_roi_trajectory_npz`,另有 `keyframe_indices`(提示帧帧号)、
`dark_polygons`、`calibration_json` 三键。
- 解包用 `np.load(..., allow_pickle=False)`:变长和字符串数据一律存 JSON,不存 object 数组,
读取时不会执行任意代码;
- 坏字节(不是合法 npz)解包直接报错;
- 打包前校验形状(多边形 `(N, 64, 2)`、`frame_indices` 长 N、`reference_mask` 二维等),不一致报错。
`source_effective_rect` / `target_effective_rect` 两键保留是为了格式同旧项目:旧 debug 路径不裁
黑边,它们恒等于对应的视图矩形。照搬旧项目 `trajectory.py`,去掉产品层。
"""
import io
import json
from dataclasses import dataclass
from typing import Any, cast
import numpy as np
from cadx.ceus._geometry import AnyArray, ViewRect
# 重采样多边形的固定顶点数(与 geometry.resample_closed_contour 的下游约定一致)。
POLYGON_NUM_POINTS = 64
@dataclass
class Trajectory:
"""逐帧 ROI 轨迹工件的内存表示(npz pack/unpack 的双向载体)。
属性:
frame_indices: ``(N,)`` int32,逐帧索引(与多边形帧数一致)。
fps: 视频帧率(浮点标量)。npz 存档内 fps 存为 float32(schema 保证,见
``pack_trajectory``);in-memory ``Trajectory.fps`` 有意规范化为 Python
float(与领域实体 ``CeusRoiTracking.fps`` 的 float 一致,便于消费)。
target_polygons: ``(N, 64, 2)`` float32,目标视图逐帧 ROI 多边形。
source_polygons: ``(N, 64, 2)`` float32,源视图逐帧 ROI 多边形。
source_view_rect / target_view_rect: 源 / 目标视图整屏裁剪矩形。
source_effective_rect / target_effective_rect: 源 / 目标有效成像区矩形。
source_polygon_coordinate_space / target_polygon_coordinate_space: 多边形所处
坐标系名(如 ``source_view_local`` / ``target_effective_view_local``)。
reference_mask: ``(H, W)`` uint8,参考区二值掩膜。
reference_region: 参考区元信息字典(pack 时 JSON 序列化)。
keyframe_indices: ``(K,)`` int32,被选作提示的关键帧索引(变长一维)。
dark_polygons: ``list[list[AnyArray]]``,逐帧 0~N 个暗区多边形(变长嵌套,
pack 时整体 JSON 序列化为单个字符串,规避 npz 变长 / pickle)。
calibration: 标定溯源字典(标定来源 / hash / 是否 CLI 临时覆盖 / 检测 split_x)。
"""
frame_indices: AnyArray
fps: float
target_polygons: AnyArray
source_polygons: AnyArray
source_view_rect: ViewRect
target_view_rect: ViewRect
source_effective_rect: ViewRect
target_effective_rect: ViewRect
source_polygon_coordinate_space: str
target_polygon_coordinate_space: str
reference_mask: AnyArray
reference_region: dict[str, Any]
keyframe_indices: AnyArray
dark_polygons: list[list[AnyArray]]
calibration: dict[str, Any]
def _rect_to_array(rect: ViewRect) -> AnyArray:
"""把 ``ViewRect`` 编码为 ``(4,)`` int32 数组 ``[x1, y1, x2, y2]``(对齐参考 P:1676)。"""
return np.asarray([rect.x1, rect.y1, rect.x2, rect.y2], dtype=np.int32)
def _rect_from_array(values: AnyArray) -> ViewRect:
"""把 ``(4,)`` 整型数组解码回 ``ViewRect``;不变量校验委托给 ViewRect 构造。"""
if values.shape != (4,):
raise ValueError(f"矩形数组必须形如 (4,),得到 {values.shape}。")
x1, y1, x2, y2 = (int(v) for v in values.tolist())
return ViewRect(x1, y1, x2, y2)
def _serialize_dark_polygons(dark_polygons: list[list[AnyArray]]) -> str:
"""把逐帧暗区多边形(变长嵌套 ndarray)序列化为 JSON 字符串。
每个多边形 ``(M, 2)`` 转为嵌套 list;整体为 ``list[list[list[list[float]]]]``
(帧 → 多边形 → 顶点 → 坐标)。走 JSON 而非 npz object 数组,从源头禁 pickle。
"""
serializable: list[list[list[list[float]]]] = []
for frame_polygons in dark_polygons:
frame_repr: list[list[list[float]]] = []
for polygon in frame_polygons:
array = np.asarray(polygon, dtype=np.float32)
if array.ndim != 2 or array.shape[1] != 2:
raise ValueError(f"暗区多边形必须形如 (M, 2),得到 {array.shape}。")
frame_repr.append(array.tolist())
serializable.append(frame_repr)
return json.dumps(serializable, ensure_ascii=False)
def _deserialize_dark_polygons(payload: str) -> list[list[AnyArray]]:
"""把暗区多边形 JSON 字符串还原为 ``list[list[AnyArray]]``(float32)。
严格按 JSON 结构还原,不接受任何 pickle / object 输入;结构非法直接 raise。
"""
parsed: object = json.loads(payload)
if not isinstance(parsed, list):
raise ValueError("暗区多边形 JSON 顶层必须是逐帧列表。")
dark_polygons: list[list[AnyArray]] = []
for frame_repr in cast(list[object], parsed):
if not isinstance(frame_repr, list):
raise ValueError("暗区多边形每帧必须是多边形列表。")
frame_polygons: list[AnyArray] = []
for polygon_repr in cast(list[object], frame_repr):
array = np.asarray(polygon_repr, dtype=np.float32)
if array.ndim != 2 or array.shape[1] != 2:
raise ValueError(f"暗区多边形必须形如 (M, 2),得到 {array.shape}。")
frame_polygons.append(array)
dark_polygons.append(frame_polygons)
return dark_polygons
def _validate_geometry(trajectory: Trajectory) -> int:
"""校验轨迹核心几何形状的一致性,返回帧数 N;任何不一致直接 raise。
校验项:多边形 ``(N, 64, 2)`` 且源 / 目标帧数一致;``frame_indices`` 一维且长 N;
``reference_mask`` 二维;``keyframe_indices`` 一维;``dark_polygons`` 帧数为 N。
"""
target = trajectory.target_polygons
source = trajectory.source_polygons
if target.ndim != 3 or target.shape[1:] != (POLYGON_NUM_POINTS, 2):
raise ValueError(
f"target_polygons 必须形如 (N, {POLYGON_NUM_POINTS}, 2),得到 {target.shape}。"
)
if source.shape != target.shape:
raise ValueError(
f"source_polygons {source.shape} 与 target_polygons {target.shape} 形状不一致。"
)
num_frames = int(target.shape[0])
if trajectory.frame_indices.ndim != 1 or trajectory.frame_indices.shape[0] != num_frames:
raise ValueError(
f"frame_indices 必须一维且长度 {num_frames},得到 {trajectory.frame_indices.shape}。"
)
if trajectory.reference_mask.ndim != 2:
raise ValueError(
f"reference_mask 必须二维 (H, W),得到 {trajectory.reference_mask.shape}。"
)
if trajectory.keyframe_indices.ndim != 1:
raise ValueError(f"keyframe_indices 必须一维,得到 {trajectory.keyframe_indices.shape}。")
if len(trajectory.dark_polygons) != num_frames:
raise ValueError(
f"dark_polygons 帧数 {len(trajectory.dark_polygons)} 与多边形帧数 {num_frames} 不一致。"
)
return num_frames
def pack_trajectory(trajectory: Trajectory) -> bytes:
"""把 ``Trajectory`` 校验并打包为压缩 npz 字节(产物工件)。
参数:
trajectory: 待打包的逐帧轨迹载体。
返回:
压缩 npz 的原始字节(``np.savez_compressed`` 写入内存缓冲)。
关键实现:
先 ``_validate_geometry`` 钉死核心形状(不一致即 raise,不静默对齐);
字符串键存为 0 维 str 数组,``fps`` 存 ``(1,)`` float32,变长 dark / 字典键
统一 JSON 序列化为字符串,全程不写入任何 object 数组(禁 pickle)。
键名对齐参考 ``write_roi_trajectory_npz`` 并新增 ``keyframe_indices`` /
``dark_polygons`` / ``calibration_json``。
"""
_validate_geometry(trajectory)
buffer = io.BytesIO()
np.savez_compressed(
buffer,
frame_indices=trajectory.frame_indices.astype(np.int32),
fps=np.asarray([trajectory.fps], dtype=np.float32),
target_polygons=trajectory.target_polygons.astype(np.float32),
source_polygons=trajectory.source_polygons.astype(np.float32),
source_view_rect=_rect_to_array(trajectory.source_view_rect),
target_view_rect=_rect_to_array(trajectory.target_view_rect),
source_effective_rect=_rect_to_array(trajectory.source_effective_rect),
target_effective_rect=_rect_to_array(trajectory.target_effective_rect),
source_polygon_coordinate_space=np.array(trajectory.source_polygon_coordinate_space),
target_polygon_coordinate_space=np.array(trajectory.target_polygon_coordinate_space),
reference_mask=trajectory.reference_mask.astype(np.uint8),
reference_region_json=np.array(json.dumps(trajectory.reference_region, ensure_ascii=False)),
keyframe_indices=trajectory.keyframe_indices.astype(np.int32),
dark_polygons=np.array(_serialize_dark_polygons(trajectory.dark_polygons)),
calibration_json=np.array(json.dumps(trajectory.calibration, ensure_ascii=False)),
)
return buffer.getvalue()
def unpack_trajectory(raw: bytes) -> Trajectory:
"""把 npz 字节解包还原为 ``Trajectory``(禁 pickle,坏字节 raise)。
参数:
raw: ``pack_trajectory`` 产出的 npz 原始字节。
返回:
还原后的 ``Trajectory``,dtype 与 pack 时一致。
关键实现:
``np.load(..., allow_pickle=False)`` 硬禁 pickle —— 任何含 object pickle 的
恶意字节都会在 load 阶段抛错而非被反序列化执行;非法 npz / 缺键同样直接 raise
(``ValueError`` / ``OSError`` / ``KeyError``),不静默返回空。字符串键经 0 维
数组 ``.item()`` 取回,变长 / 字典键经 JSON 反序列化还原。
``fps`` 存档内为 float32(schema 保证),此处有意还原为 Python float
(领域消费侧统一用 float,不改此行为)。
"""
with np.load(io.BytesIO(raw), allow_pickle=False) as archive:
frame_indices = archive["frame_indices"].astype(np.int32)
fps = float(archive["fps"][0])
target_polygons = archive["target_polygons"].astype(np.float32)
source_polygons = archive["source_polygons"].astype(np.float32)
source_view_rect = _rect_from_array(archive["source_view_rect"])
target_view_rect = _rect_from_array(archive["target_view_rect"])
source_effective_rect = _rect_from_array(archive["source_effective_rect"])
target_effective_rect = _rect_from_array(archive["target_effective_rect"])
source_polygon_coordinate_space = str(archive["source_polygon_coordinate_space"].item())
target_polygon_coordinate_space = str(archive["target_polygon_coordinate_space"].item())
reference_mask = archive["reference_mask"].astype(np.uint8)
reference_region = json.loads(str(archive["reference_region_json"].item()))
keyframe_indices = archive["keyframe_indices"].astype(np.int32)
dark_polygons = _deserialize_dark_polygons(str(archive["dark_polygons"].item()))
calibration = json.loads(str(archive["calibration_json"].item()))
return Trajectory(
frame_indices=frame_indices,
fps=fps,
target_polygons=target_polygons,
source_polygons=source_polygons,
source_view_rect=source_view_rect,
target_view_rect=target_view_rect,
source_effective_rect=source_effective_rect,
target_effective_rect=target_effective_rect,
source_polygon_coordinate_space=source_polygon_coordinate_space,
target_polygon_coordinate_space=target_polygon_coordinate_space,
reference_mask=reference_mask,
reference_region=reference_region,
keyframe_indices=keyframe_indices,
dark_polygons=dark_polygons,
calibration=calibration,
)
+10
View File
@@ -0,0 +1,10 @@
"""segment:用 MedSAM2 把提示帧的病灶掩码传播成逐帧掩码。
不认识分屏和绿框:输入只是要分割的那一半画面和那一半局部坐标上的提示掩码。`cadx` 里只有这里
引用 `torch` 和 `sam2`。
"""
from cadx.segment._config import SegmentConfig
from cadx.segment._medsam2 import medsam2_commit, segment_video
__all__ = ["SegmentConfig", "medsam2_commit", "segment_video"]
+44
View File
@@ -0,0 +1,44 @@
"""分割设置:`config/ceus.yaml` 里 `segment` 段的字段定义和校验。
每个字段必填;多写或拼错的键报错;读入后不能修改(`AGENTS.md`「不设默认值」)。取值与出处写在
`config/ceus.yaml` 的注释里。路径字段原样使用,相对路径由调用者(`scripts/ceus_quant.py`)
先换成仓库根目录下的绝对路径。
"""
from pathlib import Path
from typing import Annotated
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field
def _reject_empty_path(value: object) -> object:
# Path("") 会变成 "."(当前目录),空串在转换前就拦下。
if value == "":
raise ValueError("路径不能是空串")
return value
# YAML 里的路径是字符串;路径字段是唯一允许从字符串转换类型的地方。
type _PathField = Annotated[Path, Field(strict=False), BeforeValidator(_reject_empty_path)]
class SegmentConfig(BaseModel):
"""MedSAM2 分割设置。
- `medsam2_dir`:MedSAM2 代码目录(含 `.git`,提交号从这里读;装进环境的 `sam2` 包必须来自
这个目录下的 `sam2/`);
- `checkpoint`:权重文件;
- `sam2_config`:模型配置的 Hydra 名,相对 `sam2` 包,如 `configs/sam2.1_hiera_t512.yaml`;
- `device`:推理设备,如 `cuda`、`cuda:0`、`cpu`;
- `offload_video_to_cpu`、`offload_state_to_cpu`:MedSAM2 `init_state` 的两个开关,把帧或推理
状态放在内存而不是显存,省显存、略慢,不改变结果。
"""
model_config = ConfigDict(strict=True, extra="forbid", frozen=True)
medsam2_dir: _PathField
checkpoint: _PathField
sam2_config: str
device: str
offload_video_to_cpu: bool
offload_state_to_cpu: bool
+166
View File
@@ -0,0 +1,166 @@
"""MedSAM2 逐帧分割:把若干提示帧的病灶掩码传播到整段视频的每一帧。
改写自旧项目 `debugs/medsam2_local_track.py`(当时在 conda 子进程里跑),行为照旧:帧以
`000000.jpg` 起的 jpg 序列(质量 95)写进一个目录交给 `init_state`;每个提示帧用 `add_new_mask`
加进同一个对象;从最早的提示帧向后传播到末帧,再从它向前传播到第 0 帧;logits > 0 为病灶。
现在在同一进程里调用,jpg 临时目录由这里建、用完即删。
"""
import os
import shutil
import subprocess
import tempfile
from collections.abc import Mapping, Sequence
from pathlib import Path
from typing import Any
import cv2
import numpy as np
import numpy.typing as npt
import torch
from cadx.segment._config import SegmentConfig
type Frame = npt.NDArray[np.uint8]
type Mask = npt.NDArray[np.bool_]
# 只跟踪一个病灶,对象编号照旧脚本取 1。
_OBJECT_ID = 1
_JPEG_QUALITY = 95
def medsam2_commit(medsam2_dir: Path) -> str:
"""读 `medsam2_dir/.git` 的 HEAD 提交号(完整 40 位)。
用 `--git-dir` 指定仓库:只用 `-C` 时,目录里没有 `.git` 的话 git 会往上找到 CADx-4 自己的仓库。
"""
git = shutil.which("git")
if git is None:
raise RuntimeError("找不到 git,无法读 MedSAM2 的提交号")
completed = subprocess.run( # noqa: S603 参数是写死的 git 子命令和配置里的目录
[git, "--git-dir", str(medsam2_dir / ".git"), "rev-parse", "HEAD"],
check=True,
capture_output=True,
)
return completed.stdout.decode().strip()
def _check_inputs(
frames: Sequence[Frame], prompt_masks: Mapping[int, Mask], cfg: SegmentConfig
) -> tuple[int, int]:
"""校验输入并返回帧的 (高, 宽)。"""
for path in (cfg.medsam2_dir / "sam2" / cfg.sam2_config, cfg.checkpoint):
if not path.is_file():
raise FileNotFoundError(f"MedSAM2 缺少文件:{path}")
if len(frames) == 0:
raise ValueError("没有要分割的帧")
height, width = frames[0].shape[:2]
for index, frame in enumerate(frames):
if frame.dtype != np.uint8 or frame.shape != (height, width, 3):
raise ValueError(
f"第 {index} 帧应为 ({height}, {width}, 3) 的 uint8 BGR 图像,"
f"收到 {frame.shape} {frame.dtype}"
)
if len(prompt_masks) == 0:
raise ValueError("至少需要一个提示帧")
for index, mask in prompt_masks.items():
if not 0 <= index < len(frames):
raise ValueError(f"提示帧帧号 {index} 不在 [0, {len(frames)}) 内")
if mask.dtype != np.bool_ or mask.shape != (height, width):
raise ValueError(
f"提示帧 {index} 的掩码应为 ({height}, {width}) 的布尔数组,"
f"收到 {mask.shape} {mask.dtype}"
)
return height, width
def _check_device(device: str) -> None:
"""加载权重前确认请求的设备可见,提前暴露显卡可见性问题。"""
if not device.startswith("cuda"):
return
if not torch.cuda.is_available():
visible = os.environ.get("CUDA_VISIBLE_DEVICES", "<unset>")
raise RuntimeError(
f"请求用 CUDA 运行 MedSAM2,但当前进程看不到显卡。CUDA_VISIBLE_DEVICES={visible}"
)
if ":" in device and int(device.split(":", 1)[1]) >= torch.cuda.device_count():
raise RuntimeError(f"请求设备 {device},但只看得到 {torch.cuda.device_count()} 张显卡")
def _check_sam2_source(medsam2_dir: Path) -> None:
"""装进环境的 sam2 必须就是 `medsam2_dir` 里那份代码,否则记下的提交号对不上实际代码。"""
import sam2
loaded = Path(sam2.__file__).resolve().parent
expected = (medsam2_dir / "sam2").resolve()
if loaded != expected:
raise RuntimeError(f"环境里的 sam2 来自 {loaded},与配置的 {expected} 不一致")
def _write_jpgs(frames: Sequence[Frame], frames_dir: Path) -> None:
for index, frame in enumerate(frames):
path = frames_dir / f"{index:06d}.jpg"
if not cv2.imwrite(str(path), frame, [cv2.IMWRITE_JPEG_QUALITY, _JPEG_QUALITY]):
raise RuntimeError(f"写分割用的 jpg 失败:{path}")
def _to_mask(mask_logits: torch.Tensor) -> Mask:
return np.squeeze((mask_logits > 0.0).detach().cpu().numpy()).astype(np.bool_)
def _propagate(predictor: Any, state: Any, first_prompt: int) -> dict[int, Mask]:
"""从最早的提示帧向后传播到末帧,再向前传播到第 0 帧。"""
propagated: dict[int, Mask] = {}
with torch.inference_mode():
for frame_idx, obj_ids, mask_logits in predictor.propagate_in_video(
state, start_frame_idx=first_prompt
):
propagated[int(frame_idx)] = _to_mask(mask_logits[list(obj_ids).index(_OBJECT_ID)])
for frame_idx, obj_ids, mask_logits in predictor.propagate_in_video(
state,
start_frame_idx=first_prompt,
max_frame_num_to_track=first_prompt + 1,
reverse=True,
):
propagated[int(frame_idx)] = _to_mask(mask_logits[list(obj_ids).index(_OBJECT_ID)])
return propagated
def segment_video(
frames: Sequence[Frame], prompt_masks: Mapping[int, Mask], cfg: SegmentConfig
) -> Mask:
"""把提示帧的病灶掩码传播到每一帧。
`frames` 是逐帧 BGR 图像(同一尺寸),`prompt_masks` 是提示帧帧号 → 与帧同尺寸的布尔掩码。
返回 `(帧数, 高, 宽)` 的布尔数组,与输入帧一一对应。
"""
height, width = _check_inputs(frames, prompt_masks, cfg)
_check_device(cfg.device)
_check_sam2_source(cfg.medsam2_dir)
# sam2 没有类型标注,参数类型是 Unknown;返回的 predictor 按 Any 用。
from sam2.build_sam import build_sam2_video_predictor # pyright: ignore[reportUnknownVariableType]
predictor: Any = build_sam2_video_predictor(
config_file=cfg.sam2_config, ckpt_path=str(cfg.checkpoint), device=cfg.device
)
with tempfile.TemporaryDirectory(prefix="cadx-medsam2-") as tmp:
frames_dir = Path(tmp)
_write_jpgs(frames, frames_dir)
state: Any = predictor.init_state(
str(frames_dir),
offload_video_to_cpu=cfg.offload_video_to_cpu,
offload_state_to_cpu=cfg.offload_state_to_cpu,
)
for index in sorted(prompt_masks):
predictor.add_new_mask(state, frame_idx=index, obj_id=_OBJECT_ID, mask=prompt_masks[index])
propagated = _propagate(predictor, state, min(prompt_masks))
missing = [index for index in range(len(frames)) if index not in propagated]
if missing:
raise RuntimeError(f"MedSAM2 传播后缺 {len(missing)} 帧的掩码,前几帧:{missing[:5]}")
stack = np.stack([propagated[index] for index in range(len(frames))], axis=0)
if stack.shape != (len(frames), height, width):
raise RuntimeError(
f"MedSAM2 输出掩码形状 {stack.shape},应为 {(len(frames), height, width)}"
)
return stack