Files
CADx-4/tests/test_ceus_perfusion_metrics.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

162 lines
5.9 KiB
Python

"""Lognormal 拟合派生灌注指标的纯算法单元测试(照搬旧项目 test_perfusion_metrics.py)。"""
from dataclasses import replace
import numpy as np
import pytest
from cadx.ceus import (
CeusConfig,
LognormalFitResult,
LognormalParameters,
compute_lognormal_perfusion_metrics,
evaluate_lognormal,
fit_lognormal_curve,
)
from conftest import load_ceus_section
# 配置不设默认值:旧测试靠默认值构造的配置,改为读 config/ceus.yaml 的取值。
_CONFIG = CeusConfig.model_validate(load_ceus_section())
@pytest.fixture(scope="module")
def complete_fit() -> LognormalFitResult:
times = np.linspace(0.0, 60.0, 601)
parameters = LognormalParameters(400.0, 2.2, 0.5, 8.0, 30.0)
fit = fit_lognormal_curve(
times,
evaluate_lognormal(times, parameters),
baseline=parameters.baseline,
baseline_noise=0.2,
baseline_end_index=20,
duplicate_mask=np.zeros(times.size, dtype=bool),
sample_mask=None,
cfg=_CONFIG.tic_analysis.fitting,
anchor_cfg=_CONFIG.tic_analysis.anchor_frames,
)
assert fit.success is True
assert fit.d4_time_s is not None
return fit
def test_complete_bolus_metrics_match_curve_integrals(
complete_fit: LognormalFitResult,
) -> None:
metrics = compute_lognormal_perfusion_metrics(complete_fit)
assert metrics is not None
assert complete_fit.parameters is not None
assert complete_fit.d2_time_s is not None
assert complete_fit.d3_time_s is not None
assert complete_fit.d4_time_s is not None
assert metrics.ti_s is not None
assert metrics.to_s is not None
assert metrics.mtt_s is not None
assert metrics.wi_auc_au_s is not None
assert metrics.wo_auc_au_s is not None
assert metrics.wiwo_auc_au_s is not None
assert metrics.rt_s is not None
assert metrics.ft_s is not None
assert metrics.mttl_s is not None
assert metrics.wipi_au is not None
assert metrics.wor_au_per_s is not None
assert (
complete_fit.fit_start_s
<= metrics.ti_s
< complete_fit.d2_time_s
< metrics.ttp_s
< complete_fit.d4_time_s
< metrics.to_s
<= complete_fit.fit_end_s
)
assert metrics.pe_au == pytest.approx(
float(
evaluate_lognormal(np.asarray([metrics.ttp_s]), complete_fit.parameters)[0]
- complete_fit.parameters.baseline
)
)
assert metrics.ttp_s == pytest.approx(complete_fit.d3_time_s)
assert metrics.wir_au_per_s > 0.0
assert metrics.wor_au_per_s < 0.0
assert metrics.rt_s == pytest.approx(metrics.ttp_s - metrics.ti_s)
assert metrics.ft_s == pytest.approx(metrics.to_s - metrics.ttp_s)
assert metrics.wiwo_auc_au_s == pytest.approx(metrics.wi_auc_au_s + metrics.wo_auc_au_s)
assert metrics.wipi_au == pytest.approx(metrics.wi_auc_au_s / metrics.rt_s)
assert metrics.mttl_s == pytest.approx(metrics.mtt_s - metrics.ti_s)
assert metrics.ti_s < metrics.mtt_s < metrics.to_s
grid = np.linspace(metrics.ti_s, metrics.to_s, 100_001)
pulse = evaluate_lognormal(grid, complete_fit.parameters) - (complete_fit.parameters.baseline)
split = int(np.searchsorted(grid, metrics.ttp_s))
wi_grid = np.concatenate((grid[:split], np.asarray([metrics.ttp_s])))
wo_grid = np.concatenate((np.asarray([metrics.ttp_s]), grid[split:]))
wi_pulse = evaluate_lognormal(wi_grid, complete_fit.parameters) - (
complete_fit.parameters.baseline
)
wo_pulse = evaluate_lognormal(wo_grid, complete_fit.parameters) - (
complete_fit.parameters.baseline
)
assert metrics.wi_auc_au_s == pytest.approx(np.trapezoid(wi_pulse, wi_grid), rel=1e-7)
assert metrics.wo_auc_au_s == pytest.approx(np.trapezoid(wo_pulse, wo_grid), rel=1e-7)
assert metrics.mtt_s == pytest.approx(
np.trapezoid(grid * pulse, grid) / np.trapezoid(pulse, grid), rel=1e-7
)
def test_missing_washout_keeps_only_wash_in_metrics(
complete_fit: LognormalFitResult,
) -> None:
metrics = compute_lognormal_perfusion_metrics(replace(complete_fit, d4_time_s=None))
assert metrics is not None
assert metrics.pe_au > 0.0
assert metrics.ttp_s > 0.0
assert metrics.wir_au_per_s > 0.0
assert metrics.wi_auc_au_s is not None
assert metrics.rt_s is not None
assert metrics.wipi_au is not None
assert metrics.wor_au_per_s is None
assert metrics.wo_auc_au_s is None
assert metrics.wiwo_auc_au_s is None
assert metrics.ft_s is None
assert metrics.mttl_s is None
assert metrics.to_s is None
assert metrics.mtt_s is None
def test_failed_fit_has_no_metrics(complete_fit: LognormalFitResult) -> None:
assert compute_lognormal_perfusion_metrics(replace(complete_fit, success=False)) is None
def test_tangent_intersections_outside_observation_gate_dependents(
complete_fit: LognormalFitResult,
) -> None:
complete = compute_lognormal_perfusion_metrics(complete_fit)
assert complete is not None
assert complete.ti_s is not None
assert complete.to_s is not None
missing_wash_in = compute_lognormal_perfusion_metrics(
replace(complete_fit, fit_start_s=complete.ti_s + 0.01)
)
assert missing_wash_in is not None
assert missing_wash_in.ti_s is None
assert missing_wash_in.rt_s is None
assert missing_wash_in.wi_auc_au_s is None
assert missing_wash_in.wipi_au is None
assert missing_wash_in.wo_auc_au_s is not None
assert missing_wash_in.wiwo_auc_au_s is None
assert missing_wash_in.mttl_s is None
missing_wash_out = compute_lognormal_perfusion_metrics(
replace(complete_fit, fit_end_s=complete.to_s - 0.01)
)
assert missing_wash_out is not None
assert missing_wash_out.wor_au_per_s is not None
assert missing_wash_out.to_s is None
assert missing_wash_out.ft_s is None
assert missing_wash_out.wo_auc_au_s is None
assert missing_wash_out.wiwo_auc_au_s is None
assert missing_wash_out.mttl_s is None