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

421 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""TIC 曲线 npz 打包 / 解包(tic_curves)单元测试(照搬旧项目 test_ceus_tic_curves.py)。
钉死曲线工件的单一 schema:完整往返还原全部键、存档内各键 dtype / shape 精确、
NaN 原样往返(**不许被填成 0**——那会把「这一帧没采到」变成「这一帧亮度是 0」)、
基线无效时三个基线键的编码(NaN / NaN / (-1,-1) / 0),以及防御:坏字节解包 raise、
长度不一致 pack raise、禁 pickle(含 object pickle 的恶意 npz 解包被拒)。
另有一条从 ``TicResult`` 构造的往返,确认 ``from_tic_result`` 认得行键。
结构照 ``tests/unit/test_ceus_trajectory.py``。
"""
import io
import pickle
from typing import Any
import numpy as np
import pytest
from cadx.ceus import (
CURVE_NAMES,
CURVES_SCHEMA_VERSION,
CeusConfig,
TicCurves,
TicCurveSeries,
compute_tic_for_video,
from_tic_result,
pack_tic_curves,
unpack_tic_curves,
)
from conftest import load_ceus_section
# 配置不设默认值:旧测试靠默认值构造的配置,改为读 config/ceus.yaml 的取值。
_CONFIG = CeusConfig.model_validate(load_ceus_section())
_T = 5
# Phase 1: 构造一个真实形态的 TicCurves(合成数值)
def _series(offset: float, *, valid: bool) -> TicCurveSeries:
"""一条曲线:中间插一个 NaN(该帧没采到),基线按 valid 给或不给。"""
raw = np.asarray([10.0, 20.0, np.nan, 40.0, 50.0], dtype=np.float32) + offset
processed = np.asarray([11.0, 21.0, 31.0, 41.0, 51.0], dtype=np.float32) + offset
common: dict[str, Any] = {
"candidate": processed.copy(),
"fitted": processed.copy(),
"processed": processed.copy() if valid else np.full(_T, np.nan, np.float32),
"valid_sample_mask": np.isfinite(raw),
"duplicate_sample_mask": np.asarray([False, False, True, False, False]),
"fit_success": valid,
"fit_failure_reason": "" if valid else "baseline_invalid",
"curve_model": "lognormal",
"smoothing_method": "",
"baseline_source": "observed" if valid else "unavailable",
"fit_area": 123.0 if valid else float("nan"),
"fit_mu": 2.0 if valid else float("nan"),
"fit_sigma": 0.5 if valid else float("nan"),
"fit_arrival_offset_s": 1.0 if valid else float("nan"),
"fit_baseline": 14.5 + offset if valid else float("nan"),
"fit_r2": 0.9 if valid else float("nan"),
"fit_nrmse": 0.1 if valid else float("nan"),
"residual_noise_ratio": 1.2 if valid else float("nan"),
"fit_interval_s": (0.0, 4.0) if valid else (float("nan"), float("nan")),
"multi_peak_flag": False,
"anchor_times_s": (1.0, 2.0, 3.0, 4.0) if valid else (float("nan"),) * 4,
}
if valid:
return TicCurveSeries(
raw=raw,
baseline_mean=15.0 + offset,
baseline_sd=2.5,
baseline_frames=(0, 2),
baseline_valid=True,
**common,
)
return TicCurveSeries(
raw=raw,
baseline_mean=float("nan"),
baseline_sd=float("nan"),
baseline_frames=(-1, -1),
baseline_valid=False,
**common,
)
def _make_curves(*, injection_offset_s: float = 3.5) -> TicCurves:
counts = {
key: np.arange(_T, dtype=np.int32) + index
for index, key in enumerate(
(
"roi_area_px",
"dark_area_px",
"pan_area_px",
"background_circle_area_px",
"valid_roi_all_px",
"valid_roi_perf_px",
"valid_pan_px",
"valid_background_circle_px",
"dark_component_count",
)
)
}
frame_indices = np.arange(_T, dtype=np.int32)
return TicCurves(
frame_indices=frame_indices,
time_video_s=(frame_indices / np.float32(10.0)).astype(np.float32),
injection_offset_s=injection_offset_s,
time_origin="injection",
fps=10.0,
config_version="test-v1",
algorithm_version="lognormal-v2",
curves_schema_version=CURVES_SCHEMA_VERSION,
curves={
# roi_pan 那条给成基线无效,覆盖「紫色没检出」那一路的编码。
name: _series(float(index) * 100.0, valid=(name != "roi_pan"))
for index, name in enumerate(CURVE_NAMES)
},
perf_mask_stable=np.ones(_T, dtype=bool),
reference_valid=False,
early_truncation_flag=True,
**counts,
)
# Phase 2: 完整往返 —— 全部键还原
def test_roundtrip_preserves_all_keys() -> None:
curves = _make_curves()
restored = unpack_tic_curves(pack_tic_curves(curves))
np.testing.assert_array_equal(restored.frame_indices, curves.frame_indices)
np.testing.assert_allclose(restored.time_video_s, curves.time_video_s)
assert restored.injection_offset_s == pytest.approx(curves.injection_offset_s)
assert restored.time_origin == curves.time_origin
assert restored.fps == pytest.approx(curves.fps)
assert restored.curves_schema_version == curves.curves_schema_version
assert restored.reference_valid is curves.reference_valid
assert restored.early_truncation_flag is curves.early_truncation_flag
np.testing.assert_array_equal(restored.perf_mask_stable, curves.perf_mask_stable)
for key in (
"roi_area_px",
"dark_area_px",
"pan_area_px",
"background_circle_area_px",
"valid_roi_all_px",
"valid_roi_perf_px",
"valid_pan_px",
"valid_background_circle_px",
"dark_component_count",
):
np.testing.assert_array_equal(getattr(restored, key), getattr(curves, key))
assert set(restored.curves) == set(CURVE_NAMES)
for name in CURVE_NAMES:
original = curves.curves[name]
back = restored.curves[name]
np.testing.assert_allclose(back.raw, original.raw, equal_nan=True)
np.testing.assert_allclose(back.candidate, original.candidate, equal_nan=True)
np.testing.assert_allclose(back.fitted, original.fitted, equal_nan=True)
np.testing.assert_allclose(back.processed, original.processed, equal_nan=True)
np.testing.assert_array_equal(back.valid_sample_mask, original.valid_sample_mask)
assert back.baseline_frames == original.baseline_frames
assert back.baseline_valid is original.baseline_valid
assert back.baseline_source == original.baseline_source
assert back.fit_baseline == pytest.approx(original.fit_baseline, nan_ok=True)
def test_roundtrip_keeps_nan_as_nan() -> None:
"""NaN 原样往返:既不被填成 0,也不被插值掉。"""
restored = unpack_tic_curves(pack_tic_curves(_make_curves()))
for name in CURVE_NAMES:
assert np.isnan(restored.curves[name].raw[2])
def test_invalid_baseline_roundtrips_as_nan_and_minus_one() -> None:
"""基线无效那条:均值 / 标准差是 NaN,帧区间 ``(-1, -1)``,valid 为 False。
``0.0`` 是一个能一路算下去、把下游全部算错的合法数值;NaN 不是。
"""
restored = unpack_tic_curves(pack_tic_curves(_make_curves()))
pan = restored.curves["roi_pan"]
assert np.isnan(pan.baseline_mean)
assert np.isnan(pan.baseline_sd)
assert pan.baseline_frames == (-1, -1)
assert pan.baseline_valid is False
# Phase 3: 存档层 schema —— 直接在 npz 原始存档上断言键名 / dtype / shape
def test_archive_schema_keys_dtypes_shapes() -> None:
"""打开 pack 产出的原始 npz 存档,逐一断言键集 / 存储 dtype / shape。
与对象层往返互补:往返只验 ``unpack(pack(x)) == x``,发现不了 pack 写入存档时的
dtype / shape 漂移。
"""
archive_bytes = pack_tic_curves(_make_curves())
with np.load(io.BytesIO(archive_bytes), allow_pickle=False) as archive:
expected_keys = {
"frame_indices",
"time_video_s",
"injection_offset_s",
"time_origin",
"fps",
"config_version",
"algorithm_version",
"curves_schema_version",
"reference_valid",
"early_truncation_flag",
"perf_mask_stable",
"roi_area_px",
"dark_area_px",
"pan_area_px",
"background_circle_area_px",
"valid_roi_all_px",
"valid_roi_perf_px",
"valid_pan_px",
"valid_background_circle_px",
"dark_component_count",
}
for name in CURVE_NAMES:
expected_keys |= {
f"{name}_raw",
f"{name}_candidate",
f"{name}_fitted",
f"{name}_processed",
f"{name}_valid_sample_mask",
f"{name}_duplicate_sample_mask",
f"{name}_baseline_mean",
f"{name}_baseline_sd",
f"{name}_baseline_frames",
f"{name}_baseline_valid",
f"{name}_fit_success",
f"{name}_fit_failure_reason",
f"{name}_curve_model",
f"{name}_smoothing_method",
f"{name}_baseline_source",
f"{name}_fit_parameters",
f"{name}_fit_quality",
f"{name}_fit_interval_s",
f"{name}_multi_peak_flag",
f"{name}_anchor_times_s",
}
assert set(archive.files) == expected_keys
assert archive["frame_indices"].dtype == np.int32
assert archive["frame_indices"].shape == (_T,)
assert archive["time_video_s"].dtype == np.float32
assert archive["time_video_s"].shape == (_T,)
assert archive["injection_offset_s"].dtype == np.float32
assert archive["injection_offset_s"].shape == (1,)
assert archive["fps"].dtype == np.float32
assert archive["fps"].shape == (1,)
assert archive["reference_valid"].dtype == np.int8
assert archive["early_truncation_flag"].dtype == np.int8
for key in ("roi_area_px", "pan_area_px", "dark_component_count"):
assert archive[key].dtype == np.int32
assert archive[key].shape == (_T,)
for name in CURVE_NAMES:
assert archive[f"{name}_raw"].dtype == np.float32
assert archive[f"{name}_raw"].shape == (_T,)
assert archive[f"{name}_candidate"].dtype == np.float32
assert archive[f"{name}_fitted"].dtype == np.float32
assert archive[f"{name}_processed"].dtype == np.float32
assert archive[f"{name}_baseline_mean"].dtype == np.float32
assert archive[f"{name}_baseline_mean"].shape == (1,)
assert archive[f"{name}_baseline_sd"].shape == (1,)
assert archive[f"{name}_baseline_frames"].dtype == np.int32
assert archive[f"{name}_baseline_frames"].shape == (2,)
assert archive[f"{name}_baseline_valid"].dtype == np.int8
assert archive[f"{name}_fit_parameters"].dtype == np.float64
assert archive[f"{name}_fit_parameters"].shape == (5,)
# Phase 4: 不存的那三样确实不在文件里
def test_derivable_quantities_are_not_stored() -> None:
"""``S(t)``、``t_injection``、无灌注比例都不进文件(各自的理由见 TicCurves docstring)。"""
with np.load(io.BytesIO(pack_tic_curves(_make_curves())), allow_pickle=False) as a:
keys = set(a.files)
assert not {key for key in keys if "baseline_subtracted" in key}
assert "time_injection_s" not in keys
assert not {key for key in keys if "non_perfused" in key}
# Phase 5: 从 TicResult 构造
def _tic_result():
"""跑一次真实的 ``compute_tic_for_video``(合成帧),拿一个 TicResult。"""
height, width, frame_count = 60, 80, 4
roi_polygon = np.array(
[[30.0, 10.0], [70.0, 10.0], [70.0, 40.0], [30.0, 40.0]], dtype=np.float32
)
frames: list[np.ndarray] = []
for _ in range(frame_count):
frame = np.zeros((height, width, 3), dtype=np.uint8)
frame[10:40, 30:70] = 100
frame[10:30, 5:25] = 50
frames.append(frame)
background = np.zeros((height, width), dtype=bool)
background[12:28, 8:22] = True
return compute_tic_for_video(
source_frames=frames,
roi_polygons=[roi_polygon for _ in range(frame_count)],
dark_polygons_per_frame=[[] for _ in range(frame_count)],
reference_mask=background,
fps=4.0,
prompt_cfg=_CONFIG.prompt_source,
pan_cfg=_CONFIG.pan_source,
tic_cfg=_CONFIG.tic_analysis,
frame_times_s=None,
)
def test_from_tic_result_roundtrips() -> None:
"""``from_tic_result`` 认得行键,产物能原样打包再解回来。"""
result = _tic_result()
curves = from_tic_result(result, fps=4.0, injection_offset_s=1.25, config_version="test-v1")
restored = unpack_tic_curves(pack_tic_curves(curves))
assert restored.time_origin == "injection"
assert restored.injection_offset_s == pytest.approx(1.25)
assert restored.frame_indices.tolist() == [0, 1, 2, 3]
np.testing.assert_allclose(restored.time_video_s, [0.0, 0.25, 0.5, 0.75], atol=1e-6)
assert set(restored.curves) == set(CURVE_NAMES)
assert restored.reference_valid is result.reference_valid
np.testing.assert_allclose(
restored.curves["roi_all"].raw,
[row["roi_all_raw"] for row in result.rows],
equal_nan=True,
)
def test_from_tic_result_without_injection_offset_writes_nan_not_zero() -> None:
"""推注偏移取不到时写 NaN、时间零点记「视频起点」——**不填 0**。
填 0 等于断言「视频和推注同时开始」,而那是我们没有的信息(design/0030 决策三)。
"""
curves = from_tic_result(
_tic_result(), fps=4.0, injection_offset_s=None, config_version="test-v1"
)
restored = unpack_tic_curves(pack_tic_curves(curves))
assert np.isnan(restored.injection_offset_s)
assert restored.time_origin == "video_start"
def test_from_tic_result_rejects_non_positive_fps() -> None:
with pytest.raises(ValueError):
from_tic_result(
_tic_result(),
fps=0.0,
injection_offset_s=None,
config_version="test-v1",
)
# Phase 6: 防御 —— 坏字节 / 长度不一致 / 禁 pickle
def test_unpack_bad_bytes_raises() -> None:
with pytest.raises((ValueError, OSError)):
unpack_tic_curves(b"this-is-not-a-valid-npz-archive")
def test_unpack_rejects_old_schema_before_reading_new_keys() -> None:
packed = pack_tic_curves(_make_curves())
with np.load(io.BytesIO(packed), allow_pickle=False) as archive:
payload = {key: archive[key] for key in archive.files}
payload["curves_schema_version"] = np.array("1")
buffer = io.BytesIO()
np.savez_compressed(buffer, **payload)
with pytest.raises(ValueError, match="只支持 curves schema 3"):
unpack_tic_curves(buffer.getvalue())
def test_pack_rejects_non_monotonic_time_axis() -> None:
curves = _make_curves()
object.__setattr__(
curves,
"time_video_s",
np.asarray([0.0, 0.1, 0.1, 0.3, 0.4], dtype=np.float32),
)
with pytest.raises(ValueError, match="time_video_s"):
pack_tic_curves(curves)
def test_pack_rejects_mismatched_column_length() -> None:
curves = _make_curves()
object.__setattr__(curves, "roi_area_px", np.arange(_T + 2, dtype=np.int32))
with pytest.raises(ValueError):
pack_tic_curves(curves)
def test_pack_rejects_mismatched_curve_length() -> None:
curves = _make_curves()
short = _series(0.0, valid=False)
object.__setattr__(short, "raw", np.zeros(2, dtype=np.float32))
curves.curves["roi_all"] = short
with pytest.raises(ValueError):
pack_tic_curves(curves)
def test_unpack_rejects_pickled_object_payload() -> None:
"""构造真实含 object pickle 的恶意 .npz,解包须被拒(``allow_pickle=False``)。"""
buffer = io.BytesIO()
np.savez_compressed(buffer, roi_all_raw=np.array([{"evil": 1}], dtype=object))
with pytest.raises((ValueError, OSError, KeyError)):
unpack_tic_curves(buffer.getvalue())
def test_pickle_payload_cannot_smuggle_through_unpack() -> None:
"""即便 pickle 字节伪装,unpack 内部 ``allow_pickle=False`` 也不得执行反序列化。"""
with pytest.raises((ValueError, OSError, KeyError)):
unpack_tic_curves(pickle.dumps({"evil": "code"}))