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
+392
View File
@@ -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