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
+420
View File
@@ -0,0 +1,420 @@
"""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"}))