"""连续 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