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,161 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user