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:
@@ -0,0 +1,392 @@
|
||||
"""连续 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
|
||||
Reference in New Issue
Block a user