Files
CADx-4/tests/test_ceus_anchor_frames.py
T
iomgaa 150eb5f6ae 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>
2026-10-16 05:07:02 -04:00

393 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""连续 Lognormal 锚点时刻到真实视频帧的映射测试(照搬旧项目 test_anchor_frames.py)。
旧测试里 `excel_video` 模式(D1 取医生标注的到达时间、推注时间按「推注发生在视频第 X 秒」换算)
的几条已删,见任务书 02「四处要改的地方」第 3、4 条;新语义的手算测试在文件末尾。
"""
import numpy as np
import pytest
from cadx.ceus import (
ANCHOR_SLOTS,
AnchorFrameLocation,
CeusConfig,
LognormalFitResult,
locate_anchor_frames,
)
from conftest import load_ceus_section
# 配置不设默认值:旧测试靠默认值构造的配置,改为在 config/ceus.yaml 的取值上 model_copy。
_CONFIG = CeusConfig.model_validate(load_ceus_section())
_ANCHOR_CFG = _CONFIG.tic_analysis.anchor_frames
def _fit(
times: np.ndarray,
*,
success: bool = True,
d1: float | None = 5.0,
d2: float | None = 6.0,
d3: float | None = 8.0,
d4: float | None = 11.0,
duplicate_indices: tuple[int, ...] = (),
baseline_source: str = "observed",
) -> LognormalFitResult:
duplicates = np.zeros(times.size, dtype=bool)
duplicates[list(duplicate_indices)] = True
values = np.ones(times.size, dtype=np.float64)
return LognormalFitResult(
success=success,
failure_reason="" if success else "fit_quality_failed",
algorithm_version="lognormal-v2",
curve_model="lognormal",
smoothing_method="",
baseline_source=baseline_source,
candidate=values.copy(),
fitted=values.copy(),
processed=values.copy() if success else np.full(times.size, np.nan),
valid_sample_mask=np.ones(times.size, dtype=bool),
duplicate_sample_mask=duplicates,
parameters=None,
r2=0.9,
nrmse=0.1,
residual_noise_ratio=1.0,
fit_start_s=float(times[0]),
fit_end_s=float(times[-1]),
multi_peak_flag=False,
d1_time_s=d1,
d2_time_s=d2,
d3_time_s=d3,
d4_time_s=d4,
)
def _locate(
*,
perf: LognormalFitResult | None = None,
offset: float | None = None,
lesion_fit_ready: bool = True,
baseline_end_index: int = 50,
) -> dict[str, AnchorFrameLocation]:
times = np.arange(0.0, 70.0, 0.1)
return locate_anchor_frames(
perf_fit=perf or _fit(times),
perf_baseline_end_index=baseline_end_index,
lesion_fit_ready=lesion_fit_ready,
frame_indices=np.arange(times.size, dtype=np.int32),
time_video_s=times,
injection_offset_s=offset,
cfg=_ANCHOR_CFG,
)
def test_model_times_map_to_expected_frames() -> None:
locations = _locate(offset=2.0)
assert set(locations) == set(ANCHOR_SLOTS)
assert locations["B0"].frame_index == 47
assert locations["D1"].frame_index == 50
assert locations["D2"].frame_index == 60
assert locations["D3"].frame_index == 80
assert locations["D4"].frame_index == 110
assert locations["D3"].time_s == pytest.approx(10.0)
assert locations["D3"].anchor_time_video_s == pytest.approx(8.0)
assert locations["D3"].frame_time_video_s == pytest.approx(8.0)
assert locations["D3"].time_error_s == pytest.approx(0.0)
assert locations["D3"].source_curve == "roi_perf"
assert locations["F1"].frame_index == 180
assert locations["F2"].frame_index == 580
def test_mapping_skips_duplicate_frame_and_chooses_earlier_tie() -> None:
times = np.arange(0.0, 20.0, 0.1)
perf = _fit(times, d1=5.05, duplicate_indices=(50,))
locations = locate_anchor_frames(
perf_fit=perf,
perf_baseline_end_index=50,
lesion_fit_ready=True,
frame_indices=np.arange(times.size, dtype=np.int32),
time_video_s=times,
injection_offset_s=None,
cfg=_ANCHOR_CFG,
)
assert locations["D1"].frame_index == 51
assert locations["D1"].time_s is None
def test_discrete_mapping_does_not_reverse_anchor_order() -> None:
times = np.arange(0.0, 20.0, 0.1)
perf = _fit(times, d1=5.0, d2=5.01, d3=8.0, d4=11.0)
locations = locate_anchor_frames(
perf_fit=perf,
perf_baseline_end_index=50,
lesion_fit_ready=True,
frame_indices=np.arange(times.size, dtype=np.int32),
time_video_s=times,
injection_offset_s=None,
cfg=_ANCHOR_CFG,
)
assert locations["D1"].frame_index == 50
assert locations["D2"].frame_index == 51
assert locations["D2"].status == "ok"
assert locations["D3"].frame_index == 80
def test_failed_fit_does_not_fall_back_to_candidate_curve() -> None:
times = np.arange(0.0, 70.0, 0.1)
locations = _locate(perf=_fit(times, success=False), offset=2.0)
for slot in ("D1", "D2", "D3", "D4"):
assert locations[slot].status == "anchor_undefined"
assert locations[slot].frame_index is None
def test_missing_washout_keeps_d4_undefined() -> None:
times = np.arange(0.0, 70.0, 0.1)
locations = _locate(perf=_fit(times, d4=None), offset=2.0)
assert locations["D1"].status == "ok"
assert locations["D4"].status == "not_covered"
def test_b0_uses_perf_arrival_without_pan_reference() -> None:
locations = _locate(offset=2.0)
assert locations["B0"].status == "ok"
assert locations["B0"].frame_index == 47
assert locations["D1"].status == "ok"
def test_estimated_baseline_keeps_b0_undefined_but_maps_dynamic_anchors() -> None:
times = np.arange(0.0, 70.0, 0.1)
locations = _locate(perf=_fit(times, baseline_source="estimated"), offset=2.0)
assert locations["B0"].status == "anchor_undefined"
assert locations["D1"].status == "ok"
assert locations["D4"].status == "ok"
def test_b0_cannot_map_after_arrival_or_outside_observed_baseline() -> None:
times = np.asarray([0.0, 5.1, 6.0, 8.0, 11.0])
locations = locate_anchor_frames(
perf_fit=_fit(times, d1=5.0, d2=6.0, d3=8.0, d4=11.0),
perf_baseline_end_index=1,
lesion_fit_ready=True,
frame_indices=np.arange(times.size, dtype=np.int32),
time_video_s=times,
injection_offset_s=None,
cfg=_ANCHOR_CFG,
)
assert locations["B0"].frame_index is None
assert locations["B0"].status == "no_frame_in_window"
def test_failed_second_lesion_curve_blocks_dynamic_screenshots() -> None:
locations = _locate(lesion_fit_ready=False, offset=2.0)
for slot in ("B0", "D1", "D2", "D3", "D4"):
assert locations[slot].status == "anchor_undefined"
assert locations[slot].frame_index is None
def test_uncovered_d4_does_not_erase_available_earlier_slots() -> None:
times = np.arange(0.0, 10.1, 0.1)
locations = locate_anchor_frames(
perf_fit=_fit(times, d1=5.0, d2=6.0, d3=8.0, d4=20.0),
perf_baseline_end_index=50,
lesion_fit_ready=True,
frame_indices=np.arange(times.size, dtype=np.int32),
time_video_s=times,
injection_offset_s=None,
cfg=_ANCHOR_CFG,
)
assert locations["D1"].status == "ok"
assert locations["D2"].status == "ok"
assert locations["D3"].status == "ok"
assert locations["D4"].status == "not_covered"
def test_pre_video_d1_does_not_attach_to_first_frame() -> None:
times = np.arange(0.0, 10.1, 0.1)
locations = locate_anchor_frames(
perf_fit=_fit(times, d1=-0.1, d2=1.0, d3=4.0, d4=8.0),
perf_baseline_end_index=-1,
lesion_fit_ready=True,
frame_indices=np.arange(times.size, dtype=np.int32),
time_video_s=times,
injection_offset_s=None,
cfg=_ANCHOR_CFG,
)
assert locations["D1"].frame_index is None
assert locations["D1"].status == "not_covered"
assert locations["D2"].status == "ok"
def test_d4_does_not_map_to_fit_end_frame() -> None:
times = np.arange(0.0, 11.0, 1.0)
perf = _fit(times, d1=2.0, d2=3.0, d3=6.0, d4=9.7)
locations = locate_anchor_frames(
perf_fit=perf,
perf_baseline_end_index=2,
lesion_fit_ready=True,
frame_indices=np.arange(times.size, dtype=np.int32),
time_video_s=times,
injection_offset_s=None,
cfg=_ANCHOR_CFG,
)
assert perf.fit_end_s == pytest.approx(10.0)
assert locations["D4"].frame_index is None
assert locations["D4"].status == "no_frame_in_window"
def test_d4_reselects_eligible_frame_before_fit_end() -> None:
times = np.arange(0.0, 11.0, 1.0)
perf = _fit(times, d1=2.0, d2=3.0, d3=6.0, d4=9.6)
locations = locate_anchor_frames(
perf_fit=perf,
perf_baseline_end_index=2,
lesion_fit_ready=True,
frame_indices=np.arange(times.size, dtype=np.int32),
time_video_s=times,
injection_offset_s=None,
cfg=_ANCHOR_CFG.model_copy(update={"d4_window_s": 1.0}),
)
assert locations["D4"].frame_index == 9
assert locations["D4"].status == "ok"
def test_order_collision_has_distinct_status() -> None:
times = np.asarray([0.0, 2.0, 4.0])
locations = locate_anchor_frames(
perf_fit=_fit(times, d1=2.0, d2=2.0, d3=None, d4=None),
perf_baseline_end_index=1,
lesion_fit_ready=True,
frame_indices=np.arange(times.size, dtype=np.int32),
time_video_s=times,
injection_offset_s=None,
cfg=_ANCHOR_CFG,
)
assert {locations["D1"].status, locations["D2"].status} == {
"ok",
"no_ordered_frame",
}
def test_missing_injection_offset_only_disables_fixed_time_frames() -> None:
locations = _locate(offset=None)
assert locations["D1"].status == "ok"
assert locations["D1"].frame_index == 50
assert locations["D1"].time_s is None
assert locations["F1"].status == "not_covered"
assert locations["F2"].status == "not_covered"
def test_frame_indices_must_be_strictly_increasing() -> None:
times = np.arange(0.0, 20.0, 0.1)
indices = np.arange(times.size, dtype=np.int32)
indices[51] = indices[50]
with pytest.raises(ValueError, match="frame_indices 必须严格递增"):
locate_anchor_frames(
perf_fit=_fit(times),
perf_baseline_end_index=50,
lesion_fit_ready=True,
frame_indices=indices,
time_video_s=times,
injection_offset_s=None,
cfg=_ANCHOR_CFG,
)
# ---------- 任务书 02 第 4 条:推注时间 X 是视频第 0 秒时已推注的秒数 ----------
# 推注后第 T 秒在视频第 T − X 秒。时间轴每 0.1 秒一帧,帧号 = 视频时间 × 10。
def test_bolus_x7_puts_f1_at_13s_and_f2_at_53s() -> None:
"""X=7:F1(推注后 20 秒)在视频第 13 秒,F2(推注后 60 秒)在视频第 53 秒。"""
times = np.arange(0.0, 70.0, 0.1)
locations = _locate(perf=_fit(times), offset=7.0)
assert locations["F1"].status == "ok"
assert locations["F1"].anchor_time_video_s == pytest.approx(13.0)
assert locations["F1"].frame_index == 130
assert locations["F1"].frame_time_video_s == pytest.approx(13.0)
assert locations["F1"].time_s == pytest.approx(20.0)
assert locations["F1"].reason is None
assert locations["F2"].status == "ok"
assert locations["F2"].anchor_time_video_s == pytest.approx(53.0)
assert locations["F2"].frame_index == 530
assert locations["F2"].time_s == pytest.approx(60.0)
def test_bolus_x25_leaves_f1_before_video_unlocated() -> None:
"""X=25:F1 在视频第 −5 秒,落在视频之外记未定位、写原因,不拿第 0 帧补;F2 在第 35 秒照常。"""
times = np.arange(0.0, 70.0, 0.1)
locations = _locate(perf=_fit(times), offset=25.0)
assert locations["F1"].status == "not_covered"
assert locations["F1"].frame_index is None
assert locations["F1"].anchor_time_video_s == pytest.approx(-5.0)
assert locations["F1"].reason is not None
assert "-5.00" in locations["F1"].reason
assert locations["F2"].status == "ok"
assert locations["F2"].frame_index == 350
def test_bolus_time_past_video_end_is_unlocated() -> None:
"""X=0、视频只有 50 秒:F2(视频第 60 秒)超过视频末尾,记未定位。"""
times = np.arange(0.0, 50.0, 0.1)
locations = locate_anchor_frames(
perf_fit=_fit(times),
perf_baseline_end_index=50,
lesion_fit_ready=True,
frame_indices=np.arange(times.size, dtype=np.int32),
time_video_s=times,
injection_offset_s=0.0,
cfg=_ANCHOR_CFG,
)
assert locations["F1"].frame_index == 200
assert locations["F2"].status == "not_covered"
assert locations["F2"].frame_index is None
assert locations["F2"].anchor_time_video_s == pytest.approx(60.0)
@pytest.mark.parametrize("value", [float("nan"), -1.0])
def test_invalid_bolus_time_fails_explicitly(value: float) -> None:
"""推注时间不是有限非负秒数时直接报错,不当作「没有推注时间」。"""
with pytest.raises(ValueError, match="injection_offset_s"):
_locate(offset=value)
# ---------- 任务书 02 第 3 条:D1 只从拟合曲线算 ----------
def test_d1_comes_from_fitted_arrival_time() -> None:
"""D1 取拟合曲线上的到达时刻(这里 d1=5.0 秒 → 第 50 帧),来源记 roi_perf。"""
times = np.arange(0.0, 70.0, 0.1)
locations = _locate(perf=_fit(times, d1=5.0), offset=0.0)
assert locations["D1"].frame_index == 50
assert locations["D1"].anchor_time_video_s == pytest.approx(5.0)
assert locations["D1"].source_curve == "roi_perf"
def test_failed_fit_unlocates_b0_to_d4_with_reason_and_keeps_f1_f2() -> None:
"""拟合没过质量门:B0–D4 未定位并写明原因,F1、F2 照常按推注时间取。"""
times = np.arange(0.0, 70.0, 0.1)
locations = _locate(perf=_fit(times, success=False), offset=7.0, lesion_fit_ready=False)
for slot in ("B0", "D1", "D2", "D3", "D4"):
assert locations[slot].status == "anchor_undefined"
assert locations[slot].frame_index is None
reason = locations[slot].reason
assert reason is not None
assert "质量门" in reason
assert locations["F1"].frame_index == 130
assert locations["F2"].frame_index == 530