Files
CADx-4/tests/test_ceus_pipeline.py
iomgaa 7e7f32a13d feat(review): 核对包改成医生读得懂;资料库超声一路只留给 agent 的参数
- ceus:灌注指标改为对病灶去暗区(roi_perf)和紫圈(roi_pan)两组算,roi_all 指标删除;
  D1–D4 仍要 roi_all、roi_perf 两条都过才定位(负责人 2026-10-17:医生要正常区与病灶对比看)
- build/library:tic.json 只出两组 11 个指标与 7 个槽位的视频时间;医生截图挪进
  ultrasound/keyframes/key.png,UltrasoundRoute 去掉 key_image、keyframes 多一个 key
- review:TIC参数.csv 取代 TIC结果.csv;问题清单按原因对照表写成大白话,对照表外的原因报错;
  程序原文进 snapshot/问题清单_原文.csv;说明.pdf 整份重写为合作者口吻
- 文档:结构.md、医生核对包、装配两个库、CEUS定量

任务书 04。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 10:22:15 -04:00

263 lines
12 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.
"""ceus 对外两段接口的端到端测试:合成一段分屏造影视频,第二段喂合成掩膜(不调分割模型)。
视频 400×200、10 fps、40 秒(400 帧),分屏线在 x=200(这一列全黑)。造影画面在右半:病灶是整帧
[250, 369] × [60, 159] 的方块,医生的绿框是它的空心描边;病灶内部亮度按 Lognormal 团注曲线从
基线 40 升到约 170 再回落,内部另有一块一直发黑的囊(暗区);每帧加一点随机噪声,免得相邻帧
被当成重复帧。两半各有一块位置相同、带纹理、随时间轻微起伏的扇形区(`fake_split_scene`,
配准要靠它),左半是灰阶画面,右半(造影)调暗。
手算的预期:
- 绿框在右半 → 造影画面是右半,分割那一半是左半 [0, 200);
- 绿框挪到左半局部坐标:x = 250 − 200 = 50 到 369 − 200 = 169;
- 推注时间 X = 5 秒 → F1 在视频第 20 − 5 = 15 秒(第 150 帧),F2 在第 60 − 5 = 55 秒,
超过 40 秒的视频末尾,未定位。
"""
import dataclasses
import json
import math
import numpy as np
import pytest
from cadx.ceus import (
ANCHOR_SLOTS,
CeusConfig,
LognormalFitResult,
LognormalParameters,
NoValidContourError,
SegmentationInput,
TicResult,
VideoFile,
compute_lognormal_perfusion_metrics,
compute_tic_for_video,
crop_frame,
evaluate_lognormal,
fit_lognormal_curve,
keyframe_images,
locate_contrast_side,
pack_tic_curves,
pack_trajectory,
prepare_segmentation,
quant_summary,
quantify_case,
register_halves,
render_case_overlay,
render_tic_png,
split_views,
tic_plot_markers,
unpack_tic_curves,
unpack_trajectory,
)
from fake_ceus_video import BOLUS, FPS, FRAMES, HEIGHT, LESION, SPLIT, WIDTH, write_video
_INJECTION_OFFSET_S = 5.0
@pytest.fixture(scope="module")
def config() -> CeusConfig:
from conftest import load_ceus_section
return CeusConfig.model_validate(load_ceus_section())
@pytest.fixture(scope="module")
def prepared(tmp_path_factory: pytest.TempPathFactory, config: CeusConfig) -> SegmentationInput:
path = tmp_path_factory.mktemp("ceus") / "synthetic.mp4"
write_video(path, green_box=True)
return prepare_segmentation(path, config)
def _lesion_masks(prepared: SegmentationInput) -> np.ndarray:
"""合成「分割结果」:每帧都是病灶方块挪到左半局部坐标后的位置(略往里收 3 像素)。"""
view = prepared.contrast.target_view
masks = np.zeros((prepared.frame_count, view.height, view.width), dtype=bool)
x1, y1, x2, y2 = LESION
masks[:, y1 + 3 : y2 - 2, x1 - SPLIT + 3 : x2 - SPLIT - 2] = True
return masks
def test_prepare_segmentation_finds_contrast_half_and_maps_prompts(
prepared: SegmentationInput,
) -> None:
assert prepared.frame_count == FRAMES
assert prepared.frame_size == (WIDTH, HEIGHT)
assert prepared.contrast.side == "right"
# 两半扇形对齐:两种配准都估出 (0, 0);造影在右半,平移取反后仍是 (0, 0)。
assert prepared.registration.correlation == (0, 0)
assert prepared.registration.disagreement_px <= 1
assert prepared.offset == (0, 0)
target = prepared.contrast.target_view
assert (target.x1, target.x2) == (0, prepared.split_x)
assert abs(prepared.split_x - SPLIT) <= 1
target_frames = list(prepared.target_frames())
assert len(target_frames) == FRAMES
assert all(frame.shape == (target.height, target.width, 3) for frame in target_frames)
assert sorted(prepared.prompt_masks) == [p.frame_index for p in prepared.contrast.prompt_frames]
assert 0 in prepared.prompt_masks
xs = np.where(prepared.prompt_masks[0])[1]
# 描边 3 像素宽、找绿框时再膨胀 2 像素、mp4 压缩会糊开边缘,容差放到 5 像素。
assert abs(int(xs.min()) - 50) <= 5
assert abs(int(xs.max()) - 169) <= 5
def test_streaming_prepare_matches_whole_video_in_memory(
prepared: SegmentationInput, config: CeusConfig
) -> None:
"""逐帧解码的第 1–2 步与「整段帧读进内存再交给各步」逐像素相同(改成流式之前就是后一种)。"""
frames = [frame for _, frame in VideoFile(prepared.video_path).iter_frames()]
split_x, left, right = split_views(frames, config.views)
registration = register_halves(
frames, left, right, config.registration, config.prompt_source, config.pan_source
)
contrast = locate_contrast_side(frames, left, right, config.prompt_source, config.keyframes)
assert prepared.split_x == split_x
assert prepared.registration == registration
assert prepared.contrast.side == contrast.side
assert (prepared.contrast.contrast_view, prepared.contrast.target_view) == (
contrast.contrast_view,
contrast.target_view,
)
assert [p.frame_index for p in prepared.contrast.prompt_frames] == [
p.frame_index for p in contrast.prompt_frames
]
for ours, whole in zip(prepared.contrast.prompt_frames, contrast.prompt_frames, strict=True):
assert np.array_equal(ours.mask, whole.mask)
target_frames = list(prepared.target_frames())
assert len(target_frames) == len(frames)
for ours, frame in zip(target_frames, frames, strict=True):
assert np.array_equal(ours, crop_frame(frame, contrast.target_view))
assert np.array_equal(
prepared.first_contrast_frame, crop_frame(frames[0], contrast.contrast_view)
)
def test_quantify_case_end_to_end(prepared: SegmentationInput, config: CeusConfig) -> None:
result = quantify_case(prepared, _lesion_masks(prepared), _INJECTION_OFFSET_S, config)
assert result.trajectory.source_polygons.shape == (FRAMES, 64, 2)
assert result.lesion_fit_ready is True
assert list(result.anchors) == list(ANCHOR_SLOTS)
for slot in ("D1", "D2", "D3"):
assert result.anchors[slot].status == "ok", result.anchors[slot].reason
assert result.anchors["F1"].frame_index == 150
assert result.anchors["F1"].frame_time_video_s == pytest.approx(15.0, abs=0.06)
assert result.anchors["F2"].status == "not_covered"
assert result.anchors["F2"].reason is not None
assert result.perfusion["roi_perf"] is not None
peak_time = BOLUS.arrival_offset_s + math.exp(BOLUS.mu - BOLUS.sigma**2)
assert result.perfusion["roi_perf"].ttp_s == pytest.approx(peak_time, abs=0.5)
# 画面上没有紫圈。
assert result.pan_detected_fraction == 0.0
assert result.tic.reference_valid is False
# 暗区(囊)被找出来,去暗区曲线比病灶整体亮。
peak_row = result.tic.rows[round(peak_time * FPS)]
assert peak_row["dark_area_px"] > 0
assert peak_row["roi_perf_raw"] > peak_row["roi_all_raw"]
restored = unpack_trajectory(pack_trajectory(result.trajectory))
assert restored.calibration["contrast_side"] == "right"
curves = unpack_tic_curves(pack_tic_curves(result.curves))
assert curves.injection_offset_s == pytest.approx(_INJECTION_OFFSET_S)
summary = quant_summary(result)
json.dumps(summary, allow_nan=False)
assert summary["anchor_frames"]["F1"]["frame_index"] == 150
assert summary["perfusion_metrics"]["roi_perf"] is not None
# 没有紫圈:紫圈那一组整组为 null;病灶整体不算指标。
assert set(summary["perfusion_metrics"]) == {"roi_perf", "roi_pan"}
assert summary["perfusion_metrics"]["roi_pan"] is None
markers = tic_plot_markers(result.anchors)
assert [marker.label.split()[0] for marker in markers][:3] == ["D1", "D2", "D3"]
assert render_tic_png(result.tic, markers=markers).startswith(b"\x89PNG")
assert len(render_case_overlay(prepared, result)) > 0
images = keyframe_images(prepared, result.anchors)
assert "F1" in images and "F2" not in images
assert all(image.shape == (HEIGHT, WIDTH, 3) for image in images.values())
def test_empty_segmentation_fails_case_at_step_4(
prepared: SegmentationInput, config: CeusConfig
) -> None:
empty = np.zeros_like(_lesion_masks(prepared))
with pytest.raises(NoValidContourError) as raised:
quantify_case(prepared, empty, _INJECTION_OFFSET_S, config)
assert raised.value.step == 4
def test_mask_shape_mismatch_is_rejected(prepared: SegmentationInput, config: CeusConfig) -> None:
masks = _lesion_masks(prepared)[:-1]
with pytest.raises(ValueError, match="分割掩膜"):
quantify_case(prepared, masks, _INJECTION_OFFSET_S, config)
def _clean_fit(values: np.ndarray, times: np.ndarray, config: CeusConfig) -> LognormalFitResult:
"""直接调拟合函数拟一条曲线(基线 40、噪声 0.2,前 20 帧当基线段)。"""
return fit_lognormal_curve(
times,
values,
baseline=40.0,
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,
)
def test_pan_metrics_computed_and_lesion_gate_unchanged(
prepared: SegmentationInput, config: CeusConfig, monkeypatch: pytest.MonkeyPatch
) -> None:
"""紫圈拟合过、病灶整体拟合没过:紫圈与病灶去暗区各算一组指标,病灶整体不算;
D1–D4 照「病灶两条曲线都过才定位」不定位。
合成视频里没有紫圈,所以把第 6–7 步交出的拟合换掉:紫圈换成一条干净 Lognormal 曲线的拟合
(峰与病灶不同),病灶整体换成一条平线的拟合(没有可信的峰,拟合不过)。
"""
times = np.arange(FRAMES) / FPS
pan_truth = LognormalParameters(
area=600.0, mu=2.0, sigma=0.5, arrival_offset_s=3.0, baseline=40.0
)
pan_fit = _clean_fit(evaluate_lognormal(times, pan_truth), times, config)
flat_fit = _clean_fit(np.full(times.size, 40.0), times, config)
assert pan_fit.success is True and flat_fit.success is False
def patched(*args: object, **kwargs: object) -> TicResult:
tic = compute_tic_for_video(*args, **kwargs) # pyright: ignore[reportArgumentType]
return dataclasses.replace(tic, fits={**tic.fits, "roi_pan": pan_fit, "roi_all": flat_fit})
monkeypatch.setattr("cadx.ceus._pipeline.compute_tic_for_video", patched)
result = quantify_case(prepared, _lesion_masks(prepared), _INJECTION_OFFSET_S, config)
assert result.tic.fits["roi_perf"].success is True
assert result.lesion_fit_ready is False
for slot in ("B0", "D1", "D2", "D3", "D4"):
assert result.anchors[slot].status != "ok"
assert result.anchors[slot].frame_index is None
assert set(result.perfusion) == {"roi_perf", "roi_pan"}
assert result.perfusion["roi_pan"] == compute_lognormal_perfusion_metrics(pan_fit)
summary = quant_summary(result)
assert set(summary["perfusion_metrics"]) == {"roi_perf", "roi_pan"}
expected = compute_lognormal_perfusion_metrics(pan_fit)
assert expected is not None
# 11 项指标的键名与 LognormalPerfusionMetrics 字段的对应,照任务书 04 列的指标逐个手写。
assert summary["perfusion_metrics"]["roi_pan"]["values"] == {
"PE_au": expected.pe_au,
"WiAUC_au_s": expected.wi_auc_au_s,
"RT_s": expected.rt_s,
"mTTl_s": expected.mttl_s,
"TTP_s": expected.ttp_s,
"WiR_au_per_s": expected.wir_au_per_s,
"WiPI_au": expected.wipi_au,
"WoAUC_au_s": expected.wo_auc_au_s,
"WiWoAUC_au_s": expected.wiwo_auc_au_s,
"FT_s": expected.ft_s,
"WoR_au_per_s": expected.wor_au_per_s,
}
# 紫圈的峰与病灶不同:两组指标不是同一组数。
assert summary["perfusion_metrics"]["roi_pan"] != summary["perfusion_metrics"]["roi_perf"]
assert summary["lesion_fit_ready"] is False