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
+392
View File
@@ -0,0 +1,392 @@
"""连续 Lognormal 锚点时刻到真实视频帧的映射测试(照搬旧项目 test_anchor_frames.py)。
旧测试里 `excel_video` 模式(D1 取医生标注的到达时间、推注时间按「推注发生在视频第 X 秒」换算)
的几条已删,见任务书 02「四处要改的地方」第 3、4 条;新语义的手算测试在文件末尾。
"""
import numpy as np
import pytest
from cadx.ceus import (
ANCHOR_SLOTS,
AnchorFrameLocation,
CeusConfig,
LognormalFitResult,
locate_anchor_frames,
)
from conftest import load_ceus_section
# 配置不设默认值:旧测试靠默认值构造的配置,改为在 config/ceus.yaml 的取值上 model_copy。
_CONFIG = CeusConfig.model_validate(load_ceus_section())
_ANCHOR_CFG = _CONFIG.tic_analysis.anchor_frames
def _fit(
times: np.ndarray,
*,
success: bool = True,
d1: float | None = 5.0,
d2: float | None = 6.0,
d3: float | None = 8.0,
d4: float | None = 11.0,
duplicate_indices: tuple[int, ...] = (),
baseline_source: str = "observed",
) -> LognormalFitResult:
duplicates = np.zeros(times.size, dtype=bool)
duplicates[list(duplicate_indices)] = True
values = np.ones(times.size, dtype=np.float64)
return LognormalFitResult(
success=success,
failure_reason="" if success else "fit_quality_failed",
algorithm_version="lognormal-v2",
curve_model="lognormal",
smoothing_method="",
baseline_source=baseline_source,
candidate=values.copy(),
fitted=values.copy(),
processed=values.copy() if success else np.full(times.size, np.nan),
valid_sample_mask=np.ones(times.size, dtype=bool),
duplicate_sample_mask=duplicates,
parameters=None,
r2=0.9,
nrmse=0.1,
residual_noise_ratio=1.0,
fit_start_s=float(times[0]),
fit_end_s=float(times[-1]),
multi_peak_flag=False,
d1_time_s=d1,
d2_time_s=d2,
d3_time_s=d3,
d4_time_s=d4,
)
def _locate(
*,
perf: LognormalFitResult | None = None,
offset: float | None = None,
lesion_fit_ready: bool = True,
baseline_end_index: int = 50,
) -> dict[str, AnchorFrameLocation]:
times = np.arange(0.0, 70.0, 0.1)
return locate_anchor_frames(
perf_fit=perf or _fit(times),
perf_baseline_end_index=baseline_end_index,
lesion_fit_ready=lesion_fit_ready,
frame_indices=np.arange(times.size, dtype=np.int32),
time_video_s=times,
injection_offset_s=offset,
cfg=_ANCHOR_CFG,
)
def test_model_times_map_to_expected_frames() -> None:
locations = _locate(offset=2.0)
assert set(locations) == set(ANCHOR_SLOTS)
assert locations["B0"].frame_index == 47
assert locations["D1"].frame_index == 50
assert locations["D2"].frame_index == 60
assert locations["D3"].frame_index == 80
assert locations["D4"].frame_index == 110
assert locations["D3"].time_s == pytest.approx(10.0)
assert locations["D3"].anchor_time_video_s == pytest.approx(8.0)
assert locations["D3"].frame_time_video_s == pytest.approx(8.0)
assert locations["D3"].time_error_s == pytest.approx(0.0)
assert locations["D3"].source_curve == "roi_perf"
assert locations["F1"].frame_index == 180
assert locations["F2"].frame_index == 580
def test_mapping_skips_duplicate_frame_and_chooses_earlier_tie() -> None:
times = np.arange(0.0, 20.0, 0.1)
perf = _fit(times, d1=5.05, duplicate_indices=(50,))
locations = locate_anchor_frames(
perf_fit=perf,
perf_baseline_end_index=50,
lesion_fit_ready=True,
frame_indices=np.arange(times.size, dtype=np.int32),
time_video_s=times,
injection_offset_s=None,
cfg=_ANCHOR_CFG,
)
assert locations["D1"].frame_index == 51
assert locations["D1"].time_s is None
def test_discrete_mapping_does_not_reverse_anchor_order() -> None:
times = np.arange(0.0, 20.0, 0.1)
perf = _fit(times, d1=5.0, d2=5.01, d3=8.0, d4=11.0)
locations = locate_anchor_frames(
perf_fit=perf,
perf_baseline_end_index=50,
lesion_fit_ready=True,
frame_indices=np.arange(times.size, dtype=np.int32),
time_video_s=times,
injection_offset_s=None,
cfg=_ANCHOR_CFG,
)
assert locations["D1"].frame_index == 50
assert locations["D2"].frame_index == 51
assert locations["D2"].status == "ok"
assert locations["D3"].frame_index == 80
def test_failed_fit_does_not_fall_back_to_candidate_curve() -> None:
times = np.arange(0.0, 70.0, 0.1)
locations = _locate(perf=_fit(times, success=False), offset=2.0)
for slot in ("D1", "D2", "D3", "D4"):
assert locations[slot].status == "anchor_undefined"
assert locations[slot].frame_index is None
def test_missing_washout_keeps_d4_undefined() -> None:
times = np.arange(0.0, 70.0, 0.1)
locations = _locate(perf=_fit(times, d4=None), offset=2.0)
assert locations["D1"].status == "ok"
assert locations["D4"].status == "not_covered"
def test_b0_uses_perf_arrival_without_pan_reference() -> None:
locations = _locate(offset=2.0)
assert locations["B0"].status == "ok"
assert locations["B0"].frame_index == 47
assert locations["D1"].status == "ok"
def test_estimated_baseline_keeps_b0_undefined_but_maps_dynamic_anchors() -> None:
times = np.arange(0.0, 70.0, 0.1)
locations = _locate(perf=_fit(times, baseline_source="estimated"), offset=2.0)
assert locations["B0"].status == "anchor_undefined"
assert locations["D1"].status == "ok"
assert locations["D4"].status == "ok"
def test_b0_cannot_map_after_arrival_or_outside_observed_baseline() -> None:
times = np.asarray([0.0, 5.1, 6.0, 8.0, 11.0])
locations = locate_anchor_frames(
perf_fit=_fit(times, d1=5.0, d2=6.0, d3=8.0, d4=11.0),
perf_baseline_end_index=1,
lesion_fit_ready=True,
frame_indices=np.arange(times.size, dtype=np.int32),
time_video_s=times,
injection_offset_s=None,
cfg=_ANCHOR_CFG,
)
assert locations["B0"].frame_index is None
assert locations["B0"].status == "no_frame_in_window"
def test_failed_second_lesion_curve_blocks_dynamic_screenshots() -> None:
locations = _locate(lesion_fit_ready=False, offset=2.0)
for slot in ("B0", "D1", "D2", "D3", "D4"):
assert locations[slot].status == "anchor_undefined"
assert locations[slot].frame_index is None
def test_uncovered_d4_does_not_erase_available_earlier_slots() -> None:
times = np.arange(0.0, 10.1, 0.1)
locations = locate_anchor_frames(
perf_fit=_fit(times, d1=5.0, d2=6.0, d3=8.0, d4=20.0),
perf_baseline_end_index=50,
lesion_fit_ready=True,
frame_indices=np.arange(times.size, dtype=np.int32),
time_video_s=times,
injection_offset_s=None,
cfg=_ANCHOR_CFG,
)
assert locations["D1"].status == "ok"
assert locations["D2"].status == "ok"
assert locations["D3"].status == "ok"
assert locations["D4"].status == "not_covered"
def test_pre_video_d1_does_not_attach_to_first_frame() -> None:
times = np.arange(0.0, 10.1, 0.1)
locations = locate_anchor_frames(
perf_fit=_fit(times, d1=-0.1, d2=1.0, d3=4.0, d4=8.0),
perf_baseline_end_index=-1,
lesion_fit_ready=True,
frame_indices=np.arange(times.size, dtype=np.int32),
time_video_s=times,
injection_offset_s=None,
cfg=_ANCHOR_CFG,
)
assert locations["D1"].frame_index is None
assert locations["D1"].status == "not_covered"
assert locations["D2"].status == "ok"
def test_d4_does_not_map_to_fit_end_frame() -> None:
times = np.arange(0.0, 11.0, 1.0)
perf = _fit(times, d1=2.0, d2=3.0, d3=6.0, d4=9.7)
locations = locate_anchor_frames(
perf_fit=perf,
perf_baseline_end_index=2,
lesion_fit_ready=True,
frame_indices=np.arange(times.size, dtype=np.int32),
time_video_s=times,
injection_offset_s=None,
cfg=_ANCHOR_CFG,
)
assert perf.fit_end_s == pytest.approx(10.0)
assert locations["D4"].frame_index is None
assert locations["D4"].status == "no_frame_in_window"
def test_d4_reselects_eligible_frame_before_fit_end() -> None:
times = np.arange(0.0, 11.0, 1.0)
perf = _fit(times, d1=2.0, d2=3.0, d3=6.0, d4=9.6)
locations = locate_anchor_frames(
perf_fit=perf,
perf_baseline_end_index=2,
lesion_fit_ready=True,
frame_indices=np.arange(times.size, dtype=np.int32),
time_video_s=times,
injection_offset_s=None,
cfg=_ANCHOR_CFG.model_copy(update={"d4_window_s": 1.0}),
)
assert locations["D4"].frame_index == 9
assert locations["D4"].status == "ok"
def test_order_collision_has_distinct_status() -> None:
times = np.asarray([0.0, 2.0, 4.0])
locations = locate_anchor_frames(
perf_fit=_fit(times, d1=2.0, d2=2.0, d3=None, d4=None),
perf_baseline_end_index=1,
lesion_fit_ready=True,
frame_indices=np.arange(times.size, dtype=np.int32),
time_video_s=times,
injection_offset_s=None,
cfg=_ANCHOR_CFG,
)
assert {locations["D1"].status, locations["D2"].status} == {
"ok",
"no_ordered_frame",
}
def test_missing_injection_offset_only_disables_fixed_time_frames() -> None:
locations = _locate(offset=None)
assert locations["D1"].status == "ok"
assert locations["D1"].frame_index == 50
assert locations["D1"].time_s is None
assert locations["F1"].status == "not_covered"
assert locations["F2"].status == "not_covered"
def test_frame_indices_must_be_strictly_increasing() -> None:
times = np.arange(0.0, 20.0, 0.1)
indices = np.arange(times.size, dtype=np.int32)
indices[51] = indices[50]
with pytest.raises(ValueError, match="frame_indices 必须严格递增"):
locate_anchor_frames(
perf_fit=_fit(times),
perf_baseline_end_index=50,
lesion_fit_ready=True,
frame_indices=indices,
time_video_s=times,
injection_offset_s=None,
cfg=_ANCHOR_CFG,
)
# ---------- 任务书 02 第 4 条:推注时间 X 是视频第 0 秒时已推注的秒数 ----------
# 推注后第 T 秒在视频第 T − X 秒。时间轴每 0.1 秒一帧,帧号 = 视频时间 × 10。
def test_bolus_x7_puts_f1_at_13s_and_f2_at_53s() -> None:
"""X=7:F1(推注后 20 秒)在视频第 13 秒,F2(推注后 60 秒)在视频第 53 秒。"""
times = np.arange(0.0, 70.0, 0.1)
locations = _locate(perf=_fit(times), offset=7.0)
assert locations["F1"].status == "ok"
assert locations["F1"].anchor_time_video_s == pytest.approx(13.0)
assert locations["F1"].frame_index == 130
assert locations["F1"].frame_time_video_s == pytest.approx(13.0)
assert locations["F1"].time_s == pytest.approx(20.0)
assert locations["F1"].reason is None
assert locations["F2"].status == "ok"
assert locations["F2"].anchor_time_video_s == pytest.approx(53.0)
assert locations["F2"].frame_index == 530
assert locations["F2"].time_s == pytest.approx(60.0)
def test_bolus_x25_leaves_f1_before_video_unlocated() -> None:
"""X=25:F1 在视频第 −5 秒,落在视频之外记未定位、写原因,不拿第 0 帧补;F2 在第 35 秒照常。"""
times = np.arange(0.0, 70.0, 0.1)
locations = _locate(perf=_fit(times), offset=25.0)
assert locations["F1"].status == "not_covered"
assert locations["F1"].frame_index is None
assert locations["F1"].anchor_time_video_s == pytest.approx(-5.0)
assert locations["F1"].reason is not None
assert "-5.00" in locations["F1"].reason
assert locations["F2"].status == "ok"
assert locations["F2"].frame_index == 350
def test_bolus_time_past_video_end_is_unlocated() -> None:
"""X=0、视频只有 50 秒:F2(视频第 60 秒)超过视频末尾,记未定位。"""
times = np.arange(0.0, 50.0, 0.1)
locations = locate_anchor_frames(
perf_fit=_fit(times),
perf_baseline_end_index=50,
lesion_fit_ready=True,
frame_indices=np.arange(times.size, dtype=np.int32),
time_video_s=times,
injection_offset_s=0.0,
cfg=_ANCHOR_CFG,
)
assert locations["F1"].frame_index == 200
assert locations["F2"].status == "not_covered"
assert locations["F2"].frame_index is None
assert locations["F2"].anchor_time_video_s == pytest.approx(60.0)
@pytest.mark.parametrize("value", [float("nan"), -1.0])
def test_invalid_bolus_time_fails_explicitly(value: float) -> None:
"""推注时间不是有限非负秒数时直接报错,不当作「没有推注时间」。"""
with pytest.raises(ValueError, match="injection_offset_s"):
_locate(offset=value)
# ---------- 任务书 02 第 3 条:D1 只从拟合曲线算 ----------
def test_d1_comes_from_fitted_arrival_time() -> None:
"""D1 取拟合曲线上的到达时刻(这里 d1=5.0 秒 → 第 50 帧),来源记 roi_perf。"""
times = np.arange(0.0, 70.0, 0.1)
locations = _locate(perf=_fit(times, d1=5.0), offset=0.0)
assert locations["D1"].frame_index == 50
assert locations["D1"].anchor_time_video_s == pytest.approx(5.0)
assert locations["D1"].source_curve == "roi_perf"
def test_failed_fit_unlocates_b0_to_d4_with_reason_and_keeps_f1_f2() -> None:
"""拟合没过质量门:B0–D4 未定位并写明原因,F1、F2 照常按推注时间取。"""
times = np.arange(0.0, 70.0, 0.1)
locations = _locate(perf=_fit(times, success=False), offset=7.0, lesion_fit_ready=False)
for slot in ("B0", "D1", "D2", "D3", "D4"):
assert locations[slot].status == "anchor_undefined"
assert locations[slot].frame_index is None
reason = locations[slot].reason
assert reason is not None
assert "质量门" in reason
assert locations["F1"].frame_index == 130
assert locations["F2"].frame_index == 530
+308
View File
@@ -0,0 +1,308 @@
"""暗区识别(dark_region)单元测试(照搬旧项目 test_ceus_dark_region.py)。
旧标题:B7 暗区识别(dark_region)单元测试。
合成"亮 ROI 内嵌暗斑"图像钉死六个 port 的真实行为,并核对评分公式的三个硬编码
常量(64.0 / 4.0 / 2.0)与时间一致性 IoU。测试用真实 OpenCV 像素管线(非 mock),
覆盖:otsu 检出暗斑、auto_ensemble 择优、min_area/min_area_ratio 过滤碎片、
exclude_prompt_color 剔绿、enabled=False 返回空且保留帧对齐、逐帧轮廓重采样 64 点。
"""
import numpy as np
from cadx.ceus import (
CeusConfig,
InteriorDarkRegionConfig,
apply_dark_morphology,
dark_candidate_mask,
extract_dark_region_masks,
extract_dark_region_polygons,
score_dark_candidate,
split_dark_components,
)
from conftest import load_ceus_section
# 配置不设默认值:旧测试靠默认值构造的配置,改为在 config/ceus.yaml 的取值上 model_copy。
_CONFIG = CeusConfig.model_validate(load_ceus_section())
# 剔绿框色用的 HSV 阈值:旧代码没传时回退的写死阈值与 prompt_source 相同,这里显式传入。
_PROMPT = _CONFIG.prompt_source
def _dark_cfg(**overrides: object) -> InteriorDarkRegionConfig:
"""ceus.yaml 的暗区段,按需改几项(代替旧的 `InteriorDarkRegionConfig(...)`)。"""
return _CONFIG.interior_dark_region.model_copy(update=overrides)
# ---------- 合成样本 ----------
_H, _W = 120, 160
# 整幅 ROI 多边形:占据画面中央的大矩形。
_ROI_POLYGON = np.array(
[[20.0, 20.0], [140.0, 20.0], [140.0, 100.0], [20.0, 100.0]], dtype=np.float32
)
# ROI 内的暗斑位置(一个明确的低灰度方块)。
_DARK_BOX = (slice(45, 75), slice(60, 100)) # (y, x),30×40=1200 px
def _bright_roi_with_dark_box() -> np.ndarray:
"""构造一帧 BGR:全黑画布上铺亮 ROI(灰度 200),其内嵌暗斑(灰度 40)。"""
frame = np.zeros((_H, _W, 3), dtype=np.uint8)
# 亮 ROI 区
frame[20:100, 20:140] = 200
# 暗斑
frame[_DARK_BOX] = 40
return frame
def _roi_mask() -> np.ndarray:
from cadx.ceus import polygon_to_mask
return polygon_to_mask(_ROI_POLYGON, _H, _W)
def _gray(frame: np.ndarray) -> np.ndarray:
import cv2
return cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
# ---------- dark_candidate_mask / split / morphology ----------
def test_otsu_candidate_detects_dark_box() -> None:
"""otsu 法应在 ROI 内检出暗斑(暗斑像素被标记,亮区不被标记)。"""
cfg = _dark_cfg()
frame = _bright_roi_with_dark_box()
gray = _gray(frame)
sample = _roi_mask()
dark = dark_candidate_mask(gray, sample, "otsu", cfg)
assert dark.dtype == bool
# 暗斑中心应被标记
assert dark[60, 80]
# 亮区中心不应被标记
assert not dark[30, 30]
# 暗斑覆盖率应显著(形态学后仍是大块)
assert dark.sum() > 500
def test_split_components_filters_small_fragments() -> None:
"""min_area / min_area_ratio 联合过滤:组件阈 = max(min_area, ROI面积×ratio)。"""
sample = _roi_mask()
roi_area = float(sample.sum())
# 造一个 ~1200px 暗块 + 一个 ~16px 碎片
dark = np.zeros((_H, _W), dtype=bool)
dark[45:75, 60:100] = True # 1200 px
dark[10:14, 10:14] = True # 16 px 碎片(且在 ROI 外,会被 roi_mask 裁掉)
dark[50:54, 30:34] = True # 16 px 碎片(ROI 内)
cfg = _dark_cfg(min_area=300, min_area_ratio=0.015)
# 阈值 = max(300, roi_area*0.015)
threshold = max(300.0, roi_area * 0.015)
assert threshold >= 300.0
components = split_dark_components(dark, sample, cfg)
# 只剩大暗块
assert len(components) == 1
assert components[0].sum() >= 300
def test_morphology_intersects_sample_mask() -> None:
"""形态学结果须与 sample_mask 取交(不外溢 ROI)。"""
cfg = _dark_cfg(morphology_kernel=5)
sample = _roi_mask()
dark = np.zeros((_H, _W), dtype=bool)
dark[45:75, 60:100] = True
dark[0:5, 0:5] = True # ROI 外的块
out = apply_dark_morphology(dark, sample, cfg)
assert not out[2, 2] # ROI 外被裁掉
assert (out & ~sample).sum() == 0
# ---------- score_dark_candidate(三硬编码常量) ----------
def test_score_contrast_constant_64() -> None:
"""明暗对比项 = max(0, (median_bright - median_dark) / 64.0)。
用无碎片惩罚 + 无时间项 + None preferred_range 的配置隔离对比项贡献。
"""
cfg = _dark_cfg(fragment_penalty_weight=0.0, temporal_consistency_weight=0.0)
frame = _bright_roi_with_dark_box()
gray = _gray(frame)
sample = _roi_mask()
component = np.zeros((_H, _W), dtype=bool)
component[_DARK_BOX] = True
score = score_dark_candidate(gray, sample, [component], None, cfg)
union = component & sample
median_bright = float(np.median(gray[(~union) & sample]))
median_dark = float(np.median(gray[union & sample]))
contrast = max(0.0, (median_bright - median_dark) / 64.0)
area_ratio = float(union.sum()) / max(1.0, float(sample.sum()))
area_score = min(area_ratio, 1.0 - area_ratio) * 2.0
expected = contrast + area_score
assert score == expected
def test_score_empty_components_returns_sentinel() -> None:
"""空组件列表返回极小值哨兵(择优时永远不会被选)。"""
cfg = _dark_cfg()
gray = _gray(_bright_roi_with_dark_box())
sample = _roi_mask()
assert score_dark_candidate(gray, sample, [], None, cfg) == -1e9
def test_score_fragment_penalty_and_temporal_iou() -> None:
"""碎片惩罚 = w×max(0, 块数−1);时间一致性 = temporal_weight × IoU(union, prev)。"""
cfg = _dark_cfg(fragment_penalty_weight=0.08, temporal_consistency_weight=0.35)
frame = _bright_roi_with_dark_box()
gray = _gray(frame)
sample = _roi_mask()
comp_a = np.zeros((_H, _W), dtype=bool)
comp_a[45:75, 60:80] = True
comp_b = np.zeros((_H, _W), dtype=bool)
comp_b[45:75, 80:100] = True
components = [comp_a, comp_b]
union = (comp_a | comp_b) & sample
prev = comp_a.copy() # 上一帧暗部 = comp_a
score = score_dark_candidate(gray, sample, components, prev, cfg)
from cadx.ceus import mask_iou
median_bright = float(np.median(gray[(~union) & sample]))
median_dark = float(np.median(gray[union & sample]))
contrast = max(0.0, (median_bright - median_dark) / 64.0)
area_ratio = float(union.sum()) / max(1.0, float(sample.sum()))
area_score = min(area_ratio, 1.0 - area_ratio) * 2.0
fragment_penalty = 0.08 * 1 # 2 块 → max(0, 2-1)=1
temporal = mask_iou(union, prev) * 0.35
expected = contrast + area_score + temporal - fragment_penalty
assert abs(score - expected) < 1e-9
def test_score_preferred_range_slope_4() -> None:
"""preferred_area_ratio_range 命中时 area_score=1,否则 max(0, 1 - dist×4.0)。"""
# 这个键旧 schema 里没有(旧测试用 object.__setattr__ 注入),现在是配置字段。
cfg = _dark_cfg(
fragment_penalty_weight=0.0,
temporal_consistency_weight=0.0,
preferred_area_ratio_range=[0.001, 0.005],
)
frame = _bright_roi_with_dark_box()
gray = _gray(frame)
sample = _roi_mask()
component = np.zeros((_H, _W), dtype=bool)
component[_DARK_BOX] = True # 1200/roi_area
score = score_dark_candidate(gray, sample, [component], None, cfg)
union = component & sample
area_ratio = float(union.sum()) / max(1.0, float(sample.sum()))
median_bright = float(np.median(gray[(~union) & sample]))
median_dark = float(np.median(gray[union & sample]))
contrast = max(0.0, (median_bright - median_dark) / 64.0)
low, high = 0.001, 0.005
if low <= area_ratio <= high:
area_score = 1.0
else:
nearest = low if area_ratio < low else high
area_score = max(0.0, 1.0 - abs(area_ratio - nearest) * 4.0)
assert abs(score - (contrast + area_score)) < 1e-9
# ---------- extract_dark_region_masks ----------
def test_auto_ensemble_selects_dark_box() -> None:
"""auto_ensemble 跑 candidate_methods 择优,应选出覆盖暗斑的暗块。"""
cfg = _dark_cfg(threshold_method="auto_ensemble")
frame = _bright_roi_with_dark_box()
masks, union = extract_dark_region_masks(frame, _ROI_POLYGON, cfg, None, _PROMPT)
assert masks, "auto_ensemble 应检出暗块"
assert union is not None
# 暗斑中心被选中暗块覆盖
assert union[60, 80]
# 不外溢 ROI
assert (union & ~_roi_mask()).sum() == 0
def test_exclude_prompt_color_removes_green() -> None:
"""exclude_prompt_color=True 时 ROI 内绿像素从分析中剔除(不计入暗 / 亮统计)。"""
frame = _bright_roi_with_dark_box()
# 在 ROI 内画一块纯绿(BGR),它在 HSV 绿区间内,灰度也偏低
frame[80:95, 30:60] = (0, 255, 0)
cfg_on = _dark_cfg(exclude_prompt_color=True)
_masks_on, union_on = extract_dark_region_masks(frame, _ROI_POLYGON, cfg_on, None, _PROMPT)
# 绿块不应进入选中暗块
assert union_on is not None
assert not union_on[87, 45], "绿像素应被剔除,不计入暗块"
def test_disabled_returns_empty_polys_but_keeps_alignment() -> None:
"""enabled=False:每帧返回空多边形列表,但帧数与输入对齐(§5 不变量)。"""
cfg = _dark_cfg(enabled=False)
frames = [_bright_roi_with_dark_box() for _ in range(3)]
polys = [_ROI_POLYGON for _ in range(3)]
result = extract_dark_region_polygons(frames, polys, cfg, _PROMPT)
assert len(result) == 3
assert all(frame_polys == [] for frame_polys in result)
def test_polygons_resampled_to_64_points_and_aligned() -> None:
"""逐帧暗块轮廓重采样到 contour_points=64 点;帧对齐保留(N 帧进 N 帧出)。"""
cfg = _dark_cfg(contour_points=64)
frames = [_bright_roi_with_dark_box() for _ in range(4)]
polys = [_ROI_POLYGON for _ in range(4)]
result = extract_dark_region_polygons(frames, polys, cfg, _PROMPT)
assert len(result) == 4
# 每帧至少一个暗块多边形(暗斑稳定)
for frame_polys in result:
assert frame_polys, "每帧应检出暗斑多边形"
for poly in frame_polys:
assert poly.shape == (64, 2)
assert poly.dtype == np.float32
def _bright_roi_with_donut_dark() -> np.ndarray:
"""构造一帧 BGR:亮 ROI(200)内嵌暗斑(40),暗斑中心再挖一个亮洞(200)→ 甜甜圈暗区。"""
frame = np.zeros((_H, _W, 3), dtype=np.uint8)
frame[20:100, 20:140] = 200 # 亮 ROI
frame[45:75, 60:100] = 40 # 暗斑(1200 px)
frame[55:65, 72:88] = 200 # 中心亮洞(10×16=160 px)→ 暗区成环
return frame
def test_preserve_holes_keeps_hole_contour() -> None:
"""preserve_holes=True:甜甜圈暗区的多边形列表须含外轮廓 + 洞轮廓(≥2 个)。
回归 Codex 发现的 Critical——洞由 split_dark_components 挖回 mask,
_significant_contours 须沿 hierarchy 追加子轮廓(参考 P:685-692),否则洞被丢弃。
"""
cfg = _dark_cfg(preserve_holes=True, contour_points=64)
frames = [_bright_roi_with_donut_dark()]
polys = [_ROI_POLYGON]
result = extract_dark_region_polygons(frames, polys, cfg, _PROMPT)
assert len(result) == 1
frame_polys = result[0]
# 外环 + 中心洞 → 至少两个多边形
assert len(frame_polys) >= 2, f"应含外轮廓 + 洞,实得 {len(frame_polys)} 个"
# 每个多边形均重采样到 64 点
assert all(p.shape == (64, 2) for p in frame_polys)
# 存在一个明显小于外环的多边形(洞)——按面积区分
from cadx.ceus import polygon_area
areas = sorted(polygon_area(p) for p in frame_polys)
assert areas[0] < areas[-1], "洞多边形面积应显著小于外轮廓"
+358
View File
@@ -0,0 +1,358 @@
"""overlay 的单元测试(照搬旧项目 test_ceus_overlay.py;画层配置与图层的默认值移到测试里)。
测试哲学(CLAUDE.md §4.6):用已知几何构造真实可断言的场景,验证
- ``draw_polygon_overlay`` 在整帧坐标系画各层(左 ROI 黄 / 右 ROI 绿 / 暗区橙红 /
背景圆青),输出帧尺寸不变、对应位置出现对应 BGR 颜色像素、fill 做 alpha 混合;
- ``render_overlay`` 返回非空 mp4 字节,cv2 读回后帧数 / 尺寸与输入一致;
- 临时文件 finally 清理(渲染前后 tempfile 目录无 ``.mp4`` 残留);
- 无效 fps 直接报错(不静默兜底,呼应 findings §2E 不走参考的 25.0 回退)。
不做逐像素全图比对——只在已知轮廓位置抽样断言颜色,符合任务要求。
"""
import tempfile
from pathlib import Path
from typing import Any
import cv2
import numpy as np
import pytest
from cadx.ceus import (
OverlayConfig,
OverlayLayers,
ViewRect,
draw_polygon_overlay,
render_overlay,
)
# 整帧尺寸:左右两半各 100 宽,便于把 source / target 多边形放进各自视图。
_FRAME_HEIGHT = 120
_FRAME_WIDTH = 200
_LEFT_RECT = ViewRect(0, 0, 100, _FRAME_HEIGHT)
_RIGHT_RECT = ViewRect(100, 0, _FRAME_WIDTH, _FRAME_HEIGHT)
def _overlay_cfg(**overrides: bool) -> OverlayConfig:
"""旧 `OverlayConfig` 的默认值(findings §3.10),按需改几项;新代码里这些字段没有默认值。"""
values = {
"draw_source_roi": False,
"draw_target_polygon": True,
"draw_dark_region": True,
"draw_source_dark_region": False,
"draw_reference_region": True,
"fill_polygon": False,
}
values.update(overrides)
return OverlayConfig(**values)
def _layers(**fields: Any) -> OverlayLayers:
"""旧 `OverlayLayers` 的默认值(可选层为空),按需给几项;新代码里这些字段没有默认值。"""
values: dict[str, Any] = {
"dark_polygons": [],
"source_roi_polygon": None,
"source_rect": None,
"source_dark_polygons": [],
"reference_region": None,
}
values.update(fields)
return OverlayLayers(**values)
def _black_frame() -> np.ndarray:
"""构造全黑 BGR 整帧,作为干净画布(背景与所有彩色层都可区分)。"""
return np.zeros((_FRAME_HEIGHT, _FRAME_WIDTH, 3), dtype=np.uint8)
def _square_polygon(x: int, y: int, side: int) -> np.ndarray:
"""构造左上角 ``(x, y)``、边长 ``side`` 的局部坐标方形多边形 ``(4, 2)``。"""
return np.array(
[[x, y], [x + side, y], [x + side, y + side], [x, y + side]],
dtype=np.float32,
)
def _has_color(frame: np.ndarray, bgr: tuple[int, int, int]) -> bool:
"""整帧内是否存在与给定 BGR 完全相等的像素(用于断言某层已被画出)。"""
return bool(np.any(np.all(frame == np.array(bgr, dtype=np.uint8), axis=-1)))
def _count_mp4_in(directory: Path) -> int:
"""统计目录下 ``.mp4`` 文件数量(用于断言临时产物已清理)。"""
return len(list(directory.glob("*.mp4")))
def test_draw_keeps_frame_shape_and_does_not_mutate_input() -> None:
"""绘制后输出帧尺寸 / dtype 不变,且不就地修改入参(返回副本)。"""
frame = _black_frame()
original = frame.copy()
layers = _layers(
target_polygon=_square_polygon(20, 20, 40),
target_rect=_RIGHT_RECT,
)
out = draw_polygon_overlay(frame, layers, _overlay_cfg())
assert out.shape == frame.shape
assert out.dtype == frame.dtype
assert np.array_equal(frame, original) # 入参未被污染
def test_target_polygon_drawn_green() -> None:
"""右侧 target ROI 轮廓默认画绿 (0,255,0),且坐标抬到整帧右半区。"""
layers = _layers(
target_polygon=_square_polygon(20, 20, 40),
target_rect=_RIGHT_RECT,
)
out = draw_polygon_overlay(_black_frame(), layers, _overlay_cfg())
assert _has_color(out, (0, 255, 0))
# 绿色像素应落在右半区(x >= 100),证明做了 target_rect 偏移。
green = np.all(out == np.array((0, 255, 0), dtype=np.uint8), axis=-1)
xs = np.where(green)[1]
assert xs.min() >= 100
def test_source_roi_drawn_yellow_when_enabled() -> None:
"""开启 draw_source_roi 时左侧 ROI 轮廓画黄 (0,255,255) 并落在左半区。"""
layers = _layers(
target_polygon=_square_polygon(20, 20, 40),
target_rect=_RIGHT_RECT,
source_roi_polygon=_square_polygon(20, 20, 40),
source_rect=_LEFT_RECT,
)
out = draw_polygon_overlay(_black_frame(), layers, _overlay_cfg(draw_source_roi=True))
assert _has_color(out, (0, 255, 255))
yellow = np.all(out == np.array((0, 255, 255), dtype=np.uint8), axis=-1)
assert np.where(yellow)[1].max() < 100
def test_source_roi_not_drawn_when_disabled() -> None:
"""draw_source_roi 默认 False(findings §3.10),不应出现黄色像素。"""
layers = _layers(
target_polygon=_square_polygon(20, 20, 40),
target_rect=_RIGHT_RECT,
source_roi_polygon=_square_polygon(20, 20, 40),
source_rect=_LEFT_RECT,
)
out = draw_polygon_overlay(_black_frame(), layers, _overlay_cfg())
assert not _has_color(out, (0, 255, 255))
def test_dark_region_drawn_orange_red() -> None:
"""暗区轮廓默认画橙红 (255,80,20),落在右半区(与 target 共 rect)。"""
layers = _layers(
target_polygon=_square_polygon(20, 20, 60),
target_rect=_RIGHT_RECT,
dark_polygons=[_square_polygon(30, 30, 20)],
)
out = draw_polygon_overlay(_black_frame(), layers, _overlay_cfg())
assert _has_color(out, (255, 80, 20))
def test_reference_region_drawn_cyan_when_enabled() -> None:
"""背景圆启用时左侧画青色小圆 (255,255,0),落在左半区。
**颜色不能是紫 (255,0,255)**:紫色是医生画的 ``ROI_pan`` 轮廓的颜色,两圈紫叠在
同一画面上人眼分不出哪个是哪个(design/0030 决策四)。
"""
layers = _layers(
target_polygon=_square_polygon(20, 20, 40),
target_rect=_RIGHT_RECT,
source_rect=_LEFT_RECT,
reference_region={"enabled": True, "center_xy": [40.0, 40.0], "radius_px": 12},
)
out = draw_polygon_overlay(_black_frame(), layers, _overlay_cfg())
assert _has_color(out, (255, 255, 0))
assert not _has_color(out, (255, 0, 255)), "背景圆不许和医生的紫色轮廓撞色"
cyan = np.all(out == np.array((255, 255, 0), dtype=np.uint8), axis=-1)
assert np.where(cyan)[1].max() < 100
def test_reference_region_not_drawn_when_disabled() -> None:
"""reference enabled=False 时不画那个圆。"""
layers = _layers(
target_polygon=_square_polygon(20, 20, 40),
target_rect=_RIGHT_RECT,
source_rect=_LEFT_RECT,
reference_region={"enabled": False, "center_xy": [40.0, 40.0], "radius_px": 12},
)
out = draw_polygon_overlay(_black_frame(), layers, _overlay_cfg())
assert not _has_color(out, (255, 255, 0))
def test_fill_polygon_alpha_blends_target() -> None:
"""开启 fill_polygon 时 ROI 内部做 alpha=0.18 混合:黑底上出现暗绿填充,
且其值约等于 round(255*0.18)=46(绿通道),区别于不填充时的纯黑内部。"""
layers = _layers(
target_polygon=_square_polygon(20, 20, 50),
target_rect=_RIGHT_RECT,
)
filled = draw_polygon_overlay(_black_frame(), layers, _overlay_cfg(fill_polygon=True))
# 取多边形内部一点(局部 (40,40) -> 整帧 (140,40)),应为混合后的暗绿。
inner = filled[40, 140]
assert inner[0] == 0 # B 通道仍为 0
assert 40 <= int(inner[1]) <= 52 # G 通道 ≈ 255*0.18
assert inner[2] == 0 # R 通道仍为 0
def test_render_overlay_returns_decodable_mp4_bytes() -> None:
"""render_overlay 返回非空 mp4 字节;cv2 读回帧数 / 尺寸与输入一致。"""
num_frames = 5
frames = [_black_frame() for _ in range(num_frames)]
per_frame_layers = [
_layers(
target_polygon=_square_polygon(20, 20, 40),
target_rect=_RIGHT_RECT,
)
for _ in range(num_frames)
]
data = render_overlay(
frames,
per_frame_layers,
fps=25.0,
cfg=_overlay_cfg(),
frame_size=(_FRAME_WIDTH, _FRAME_HEIGHT),
)
assert isinstance(data, bytes)
assert len(data) > 0
# 写回临时文件用 cv2 读回校验(mp4v 编码无法直接 in-memory 解码)。
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as handle:
handle.write(data)
probe_path = Path(handle.name)
try:
cap = cv2.VideoCapture(str(probe_path))
try:
assert cap.isOpened()
assert int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) == _FRAME_WIDTH
assert int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) == _FRAME_HEIGHT
decoded = 0
while True:
ok, _ = cap.read()
if not ok:
break
decoded += 1
assert decoded == num_frames
finally:
cap.release()
finally:
probe_path.unlink(missing_ok=True)
def test_render_overlay_cleans_temp_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""渲染走临时文件再读回,结束后临时目录不留 .mp4 残留。"""
monkeypatch.setattr(tempfile, "tempdir", str(tmp_path))
assert _count_mp4_in(tmp_path) == 0
frames = [_black_frame() for _ in range(3)]
per_frame_layers = [
_layers(
target_polygon=_square_polygon(20, 20, 40),
target_rect=_RIGHT_RECT,
)
for _ in range(3)
]
render_overlay(
frames,
per_frame_layers,
fps=25.0,
cfg=_overlay_cfg(),
frame_size=(_FRAME_WIDTH, _FRAME_HEIGHT),
)
assert _count_mp4_in(tmp_path) == 0
def test_render_overlay_rejects_non_positive_fps() -> None:
"""fps 非正应直接报错(不静默回退 25.0,见 findings §2E / 项目防御原则)。"""
frames = [_black_frame()]
per_frame_layers = [
_layers(target_polygon=_square_polygon(20, 20, 40), target_rect=_RIGHT_RECT)
]
with pytest.raises(ValueError, match="fps"):
render_overlay(
frames,
per_frame_layers,
fps=0.0,
cfg=_overlay_cfg(),
frame_size=(_FRAME_WIDTH, _FRAME_HEIGHT),
)
def test_render_overlay_rejects_length_mismatch() -> None:
"""帧序列与逐帧图层序列长度不一致是调用方缺陷,直接报错。"""
frames = [_black_frame(), _black_frame()]
per_frame_layers = [
_layers(target_polygon=_square_polygon(20, 20, 40), target_rect=_RIGHT_RECT)
]
# 流式之后这条由 zip(strict=True) 执行。**仍旧钉住是哪一条报的**:这个函数里有四条
# 路径抛 ValueError(fps / frame_size / 图层为空 / 长度不一致),裸 raises 会让其中
# 任何一条被写错都从这里溜过去。
with pytest.raises(ValueError, match=r"zip\(\)"):
render_overlay(
frames,
per_frame_layers,
fps=25.0,
cfg=_overlay_cfg(),
frame_size=(_FRAME_WIDTH, _FRAME_HEIGHT),
)
def test_render_overlay_rejects_empty_frames() -> None:
"""一帧都没有就产不出视频,直接报错(不静默产出空视频)。
判据从「帧序列为空」改成「逐帧图层为空」:流式之后帧数要遍历完才知道,而图层那一侧
仍旧是个列表,长度当场可查。两者必须等长(由 zip 的 strict 执行),所以查哪一侧等价。
"""
with pytest.raises(ValueError, match="不能为空"):
render_overlay(
[],
[],
fps=25.0,
cfg=_overlay_cfg(),
frame_size=(_FRAME_WIDTH, _FRAME_HEIGHT),
)
def test_render_overlay_rejects_non_positive_frame_size() -> None:
"""输出尺寸由调用方给,给错了当场报错——编码器拿非法尺寸只会打不开,消息里看不出是谁的错。"""
frames = [_black_frame()]
layers = [_layers(target_polygon=_square_polygon(20, 20, 40), target_rect=_RIGHT_RECT)]
with pytest.raises(ValueError, match="frame_size"):
render_overlay(frames, layers, fps=25.0, cfg=_overlay_cfg(), frame_size=(0, 10))
def test_render_overlay_拒绝尺寸和_frame_size_对不上的帧() -> None:
"""**尺寸不符的帧会被编码器静默丢掉**——实测写 6 帧读回 3 帧,只在 stderr 留一行警告。
后果是 overlay 视频短一截甚至是空的,而它照样有字节、照样被上传、台账照样记完成。
输出尺寸以前取自首帧,结构上不可能不符;改成由调用方给之后这条得自己守
(design/0024 落地后的独立评审报出)。
"""
layers = [_layers(target_polygon=_square_polygon(20, 20, 40), target_rect=_RIGHT_RECT)]
with pytest.raises(ValueError, match="帧尺寸"):
render_overlay(
[_black_frame()],
layers,
fps=25.0,
cfg=_overlay_cfg(),
# 宽高对调:合法的正尺寸,但和实际帧对不上
frame_size=(_FRAME_HEIGHT, _FRAME_WIDTH),
)
def test_render_overlay_接受迭代器而不只是列表() -> None:
"""**这条守的是流式本身**:服务喂进来的是解码器的生成器,不是列表(design/0024)。
改回 ``Sequence`` 的话,服务那边要么先把全片物化成列表(4.33 GB,正是要消灭的东西),
要么当场报 TypeError;后者会被别的测试抓到,前者不会。
"""
layers = [
_layers(target_polygon=_square_polygon(20, 20, 40), target_rect=_RIGHT_RECT)
for _ in range(3)
]
data = render_overlay(
(_black_frame() for _ in range(3)),
layers,
fps=25.0,
cfg=_overlay_cfg(),
frame_size=(_FRAME_WIDTH, _FRAME_HEIGHT),
)
assert isinstance(data, bytes) and len(data) > 0
+161
View File
@@ -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
+168
View File
@@ -0,0 +1,168 @@
"""ceus 对外两段接口的端到端测试:合成一段分屏造影视频,第二段喂合成掩膜(不调分割模型)。
视频 400×200、10 fps、40 秒(400 帧),分屏线在 x=200(这一列全黑)。造影画面在右半:病灶是整帧
[250, 369] × [60, 159] 的方块,医生的绿框是它的空心描边;病灶内部亮度按 Lognormal 团注曲线从
基线 40 升到约 170 再回落,内部另有一块一直发黑的囊(暗区);每帧加一点随机噪声,免得相邻帧
被当成重复帧。左半是不变的灰阶画面。
手算的预期:
- 绿框在右半 → 造影画面是右半,分割那一半是左半 [0, 200);
- 绿框挪到左半局部坐标:x = 250 − 200 = 50 到 369 − 200 = 169;
- 推注时间 X = 5 秒 → F1 在视频第 20 − 5 = 15 秒(第 150 帧),F2 在第 60 − 5 = 55 秒,
超过 40 秒的视频末尾,未定位。
"""
import json
import math
from pathlib import Path
import cv2
import numpy as np
import pytest
from cadx.ceus import (
ANCHOR_SLOTS,
CeusConfig,
LognormalParameters,
NoValidContourError,
SegmentationInput,
evaluate_lognormal,
keyframe_images,
pack_tic_curves,
pack_trajectory,
prepare_segmentation,
quant_summary,
quantify_case,
render_case_overlay,
render_tic_png,
tic_plot_markers,
unpack_tic_curves,
unpack_trajectory,
)
_WIDTH, _HEIGHT, _SPLIT = 400, 200, 200
_FPS, _FRAMES = 10.0, 400
_LESION = (250, 60, 369, 159) # 整帧坐标 x1, y1, x2, y2(含端点)
_CYST = (290, 95, 320, 125)
_BOLUS = LognormalParameters(area=1300.0, mu=2.4, sigma=0.45, arrival_offset_s=4.0, baseline=40.0)
_INJECTION_OFFSET_S = 5.0
def _write_video(path: Path) -> None:
rng = np.random.default_rng(3)
times = np.arange(_FRAMES) / _FPS
intensity = evaluate_lognormal(times, _BOLUS)
writer = cv2.VideoWriter(str(path), cv2.VideoWriter.fourcc(*"mp4v"), _FPS, (_WIDTH, _HEIGHT))
x1, y1, x2, y2 = _LESION
for index in range(_FRAMES):
frame = np.zeros((_HEIGHT, _WIDTH, 3), dtype=np.uint8)
frame[:, :_SPLIT] = 120
frame[:, _SPLIT + 1 :] = 30
lesion = np.clip(
intensity[index] + rng.normal(0.0, 6.0, (y2 - y1 + 1, x2 - x1 + 1)), 0, 255
)
frame[y1 : y2 + 1, x1 : x2 + 1] = lesion.astype(np.uint8)[:, :, None]
cx1, cy1, cx2, cy2 = _CYST
frame[cy1 : cy2 + 1, cx1 : cx2 + 1] = 10
cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), thickness=3)
writer.write(frame)
writer.release()
@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)
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"
target = prepared.contrast.target_view
assert (target.x1, target.x2) == (0, prepared.split_x)
assert abs(prepared.split_x - _SPLIT) <= 1
assert len(prepared.target_frames) == _FRAMES
assert all(frame.shape == (target.height, target.width, 3) for frame in prepared.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_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
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)
+508
View File
@@ -0,0 +1,508 @@
"""polygons 的单元测试(照搬旧项目 test_ceus_polygons.py;坐标换算改为两个视图矩形,
去掉旧的有效区矩形和映射锚点参数)。
旧标题:B6 polygons.py 的单元测试:mask→平滑多边形 + 位置约束 + 绿框先验融合。
参考 ``references/ROI/roi_tracking_pipeline.py`` P:649-965。覆盖核心真实场景
(边缘场景靠实现内 assertion / 守卫,不在此堆假数据):
- ``masks_to_smoothed_polygons``:圆形 mask → (N, 64, 2) 平滑多边形;**空 mask 帧
保留在序列**(§5 帧对齐不变量,N 帧进 N 帧出),且其多边形由时间插值得到(非空)。
- ``apply_prompt_frame_position_constraints``(strength=1.0):把每帧质心拉到 keyframe
中心插值轨迹上(整体平移),**不改面积**;``enabled=False`` 原样返回。
- ``fuse_prompt_prior_with_medsam``:``source != "prompt_prior"`` 门关 → 直接返回纯
medsam;门开时 keyframe 帧用 ``keyframe_shape_strength`` 逐顶点混合,且面积被
收紧到 ``[min, max] × prior_area``(findings §3.7)。
"""
from dataclasses import dataclass
import cv2
import numpy as np
import pytest
from cadx.ceus import (
CeusConfig,
ViewRect,
apply_prompt_frame_position_constraints,
fuse_prompt_prior_with_medsam,
masks_to_smoothed_polygons,
polygon_area,
)
from conftest import load_ceus_section
# 配置不设默认值:旧测试靠默认值构造的配置,改为在 config/ceus.yaml 的取值上 model_copy。
_CONFIG = CeusConfig.model_validate(load_ceus_section())
@dataclass
class _RawShapeCfg:
"""绕过 RoiShapeConfig 校验器的鸭子接口(直接喂超界 strength,验证函数内部 clip)。"""
source: str = "prompt_prior"
keyframe_shape_strength: float = 1.0
propagation_shape_strength: float = 0.75
min_area_ratio_to_prompt: float = 0.70
max_area_ratio_to_prompt: float = 1.25
@dataclass
class _RawConstraintCfg:
"""绕过 KeyframePositionConstraint 校验器(Literal mode + strength clip)的鸭子接口。"""
enabled: bool = True
mode: str = "center_interpolation"
strength: float = 1.0
def _disk_mask(height: int, width: int, center_xy: tuple[int, int], radius: int) -> np.ndarray:
"""生成 ``(height, width)`` 的圆盘二值 mask(前景为 True),供轮廓提取测试用。"""
canvas = np.zeros((height, width), dtype=np.uint8)
cv2.circle(canvas, (int(center_xy[0]), int(center_xy[1])), int(radius), 255, -1)
return canvas > 0
def _circle_polygon(center_xy: tuple[float, float], radius: float, num_points: int) -> np.ndarray:
"""生成绕指定中心的规则圆多边形 ``(num_points, 2)``(用作 medsam / prior 测试输入)。"""
angles = np.linspace(0.0, 2.0 * np.pi, num_points, endpoint=False)
poly = np.zeros((num_points, 2), dtype=np.float32)
poly[:, 0] = center_xy[0] + radius * np.cos(angles)
poly[:, 1] = center_xy[1] + radius * np.sin(angles)
return poly
def test_masks_to_smoothed_polygons_shape_and_frame_alignment() -> None:
"""圆盘 mask → (N, 64, 2);中间空 mask 帧保留在序列(§5 帧对齐不变量)。"""
frame_count = 5
contour_points = _CONFIG.smoothing.contour_points # 64
masks_by_frame = {
0: _disk_mask(120, 120, (60, 60), 20),
1: _disk_mask(120, 120, (62, 60), 20),
# frame 2 空(退化 / 漏检)——不得剔除,靠时间插值填补
3: _disk_mask(120, 120, (64, 60), 20),
4: _disk_mask(120, 120, (66, 60), 20),
}
polygons = masks_to_smoothed_polygons(masks_by_frame, frame_count, _CONFIG.smoothing)
assert len(polygons) == frame_count, "N 帧进必须 N 帧出(§5 帧对齐,不剔除空帧)。"
for poly in polygons:
assert poly.shape == (contour_points, 2)
assert poly.dtype == np.float32
# 空帧(index 2)由相邻帧时间插值得到,应为有效非退化多边形
assert polygon_area(polygons[2]) > 1.0, "空 mask 帧应被时间插值填补为有效多边形。"
def test_masks_to_smoothed_polygons_all_empty_raises() -> None:
"""全部帧无有效轮廓时无从插值,直接 RuntimeError(不静默兜底)。"""
import pytest
with pytest.raises(RuntimeError):
masks_to_smoothed_polygons({}, 3, _CONFIG.smoothing)
def test_position_constraint_pulls_center_to_keyframe_track_without_area_change() -> None:
"""strength=1.0 把每帧质心拉到 keyframe 中心插值轨迹,且整体平移不改面积。"""
target_eff = ViewRect(0, 0, 200, 200)
source_view = ViewRect(0, 0, 200, 200)
# 两个 keyframe:source mask 中心分别在 (40, 100) 与 (160, 100)
keyframes = [
{"frame_index": 0, "mask": _disk_mask(200, 200, (40, 100), 15)},
{"frame_index": 4, "mask": _disk_mask(200, 200, (160, 100), 15)},
]
# 当前多边形都偏在 (100, 30),期望被拉到插值中心轨迹(x 从 40 线性到 160)
polygons = [_circle_polygon((100.0, 30.0), 12.0, 64) for _ in range(5)]
areas_before = [polygon_area(poly) for poly in polygons]
corrected = apply_prompt_frame_position_constraints(
polygons,
keyframes,
source_view,
target_eff,
_CONFIG.constraints.keyframe_position, # enabled、center_interpolation、strength=1.0
)
assert len(corrected) == len(polygons)
# frame 0 质心 ≈ (40, 100),frame 4 ≈ (160, 100),frame 2 ≈ (100, 100)(线性插值)
expected_x = [40.0, 70.0, 100.0, 130.0, 160.0]
for frame_idx, poly in enumerate(corrected):
center = poly.mean(axis=0)
assert abs(float(center[0]) - expected_x[frame_idx]) < 3.0
assert abs(float(center[1]) - 100.0) < 3.0
# 整体平移:面积不变
for before, poly in zip(areas_before, corrected, strict=True):
assert abs(polygon_area(poly) - before) < 2.0
def test_position_constraint_disabled_returns_input() -> None:
"""enabled=False 时原样返回输入(门关)。"""
target_eff = ViewRect(0, 0, 100, 100)
source_view = ViewRect(0, 0, 100, 100)
polygons = [_circle_polygon((50.0, 50.0), 10.0, 64)]
keyframes = [{"frame_index": 0, "mask": _disk_mask(100, 100, (50, 50), 10)}]
out = apply_prompt_frame_position_constraints(
polygons,
keyframes,
source_view,
target_eff,
_CONFIG.constraints.keyframe_position.model_copy(update={"enabled": False}),
)
assert out is polygons
def test_fusion_gate_closed_returns_pure_medsam() -> None:
"""roi_shape.source != 'prompt_prior' → 直接返回纯 medsam(门关,findings §3.7)。"""
target_eff = ViewRect(0, 0, 100, 100)
source_view = ViewRect(0, 0, 100, 100)
medsam = [_circle_polygon((50.0, 50.0), 10.0, 64)]
keyframes = [{"frame_index": 0, "mask": _disk_mask(100, 100, (50, 50), 10)}]
out = fuse_prompt_prior_with_medsam(
medsam,
keyframes,
source_view,
target_eff,
_CONFIG.roi_shape.model_copy(update={"source": "medsam2"}),
_CONFIG.smoothing,
)
assert out is medsam
def test_fusion_keyframe_strength_one_yields_prior_shape() -> None:
"""keyframe 帧 strength=1.0:融合结果几何上贴近绿框先验(逐顶点混合 s=1)。"""
target_eff = ViewRect(0, 0, 200, 200)
source_view = ViewRect(0, 0, 200, 200)
# 绿框先验中心 (100, 100) 半径 30;medsam 偏移到 (130, 130) 半径 30
keyframes = [{"frame_index": 0, "mask": _disk_mask(200, 200, (100, 100), 30)}]
medsam = [_circle_polygon((130.0, 130.0), 30.0, 64)]
fused = fuse_prompt_prior_with_medsam(
medsam,
keyframes,
source_view,
target_eff,
_CONFIG.roi_shape.model_copy(update={"keyframe_shape_strength": 1.0}),
_CONFIG.smoothing,
)
assert len(fused) == 1
center = fused[0].mean(axis=0)
# s=1 → 结果应贴近先验中心 (100, 100) 而非 medsam 中心 (130, 130)
assert abs(float(center[0]) - 100.0) < 8.0
assert abs(float(center[1]) - 100.0) < 8.0
def test_fusion_area_clamped_to_prior_band() -> None:
"""融合面积超过 max_area_ratio_to_prompt 时按 sqrt 绕先验质心缩放回界(findings §3.7)。"""
target_eff = ViewRect(0, 0, 400, 400)
source_view = ViewRect(0, 0, 400, 400)
# 关键帧只在 frame 0;frame 1 为传播帧(非 keyframe),用 propagation_shape_strength=0
# 让 medsam 形状主导,从而触发面积收紧。先验半径 30,medsam 半径 80(面积远超 1.25×)。
keyframes = [{"frame_index": 0, "mask": _disk_mask(400, 400, (200, 200), 30)}]
medsam = [
_circle_polygon((200.0, 200.0), 30.0, 64), # frame 0(keyframe,对照)
_circle_polygon((200.0, 200.0), 80.0, 64), # frame 1(propagation,测收紧)
]
shape_cfg = _CONFIG.roi_shape.model_copy(
update={
"keyframe_shape_strength": 1.0,
"propagation_shape_strength": 0.0, # frame 1 形状 = 纯 medsam(半径80,超界)
"min_area_ratio_to_prompt": 0.70,
"max_area_ratio_to_prompt": 1.25,
}
)
fused = fuse_prompt_prior_with_medsam(
medsam,
keyframes,
source_view,
target_eff,
shape_cfg,
_CONFIG.smoothing,
)
# 真实先验面积取自 frame 0(keyframe,s=1 → 形状=离散化绿框先验),避免用解析圆面积失真
prior_area = polygon_area(fused[0])
fused_area = polygon_area(fused[1])
# frame 1 s=0 → 形状=medsam(半径80,面积远超界),应被收紧到 ≈ max_ratio × prior_area
assert fused_area <= prior_area * 1.25 + 50.0, "超界面积应被收紧到 max_area_ratio。"
assert fused_area >= prior_area * 1.25 - 100.0, "应恰好收到上界附近,而非过度收缩。"
# Phase 4b: fuse_prompt_prior_with_medsam —— 关键证伪路径
def test_fusion_propagation_frame_linear_blend_without_tightening() -> None:
"""非 keyframe 帧 + 0<s<1:未触发面积收紧时逐顶点 ≈ medsam·(1−s) + prior·s。
构造 medsam 与 prior 半径相近(面积比落在 [min, max] 带内,不触发收紧),中心同位,
使逐顶点线性混合的几何结果可被精确预测(绕开起点对齐 / 收紧的扰动)。
"""
target_eff = ViewRect(0, 0, 400, 400)
source_view = ViewRect(0, 0, 400, 400)
# 单关键帧(frame 0),frame 1 为传播帧;先验半径 50,medsam 半径 60(面积比≈1.44,
# 但 max_ratio 放宽到 2.0 → 不触发收紧),中心同在 (200, 200)
keyframes = [{"frame_index": 0, "mask": _disk_mask(400, 400, (200, 200), 50)}]
medsam = [
_circle_polygon((200.0, 200.0), 50.0, 64), # frame 0(keyframe,对照)
_circle_polygon((200.0, 200.0), 60.0, 64), # frame 1(传播帧,测线性混合)
]
strength = 0.5
shape_cfg = _RawShapeCfg(
keyframe_shape_strength=1.0,
propagation_shape_strength=strength,
min_area_ratio_to_prompt=0.10,
max_area_ratio_to_prompt=2.00, # 放宽上界,确保 frame 1 不触发面积收紧
)
fused = fuse_prompt_prior_with_medsam(
medsam,
keyframes,
source_view,
target_eff,
shape_cfg,
_CONFIG.smoothing,
)
# frame 1 半径 ≈ medsam·(1−s) + prior·s。prior 取自 frame 0(离散化绿框,s=1)。
# 用面积反推等效半径,校验落在 medsam(60) 与 prior(≈50) 之间且约为 0.5 混合。
prior_area = polygon_area(fused[0])
prior_radius = float(np.sqrt(prior_area / np.pi))
fused_radius = float(np.sqrt(polygon_area(fused[1]) / np.pi))
expected_radius = 60.0 * (1.0 - strength) + prior_radius * strength
assert abs(fused_radius - expected_radius) < 3.0, (
f"传播帧应为线性混合半径 ≈ {expected_radius:.1f},实得 {fused_radius:.1f}。"
)
# 逐顶点直接证伪(非聚合面积):每个融合顶点到圆心的半径都应 ≈ 混合半径,
# 而不仅是整体面积匹配——bbox 整体插值或部分混合会在个别顶点暴露偏差。
center = np.array([200.0, 200.0], dtype=np.float64)
radii = np.linalg.norm(fused[1].astype(np.float64) - center, axis=1)
assert radii.shape == (64,)
assert np.all(np.abs(radii - expected_radius) < 3.0), (
f"逐顶点半径应均 ≈ {expected_radius:.1f}(顶点级线性混合),"
f"实得范围 [{radii.min():.1f}, {radii.max():.1f}]。"
)
def test_fusion_lower_bound_tightening_expands_area() -> None:
"""下界收紧:medsam 面积 < min_ratio × prior 面积 → 被放大到 ≈ min_ratio × prior。"""
target_eff = ViewRect(0, 0, 400, 400)
source_view = ViewRect(0, 0, 400, 400)
# 先验半径 60;medsam 传播帧半径 20(面积比≈0.11 < min_ratio 0.70 → 触发下界放大)
keyframes = [{"frame_index": 0, "mask": _disk_mask(400, 400, (200, 200), 60)}]
medsam = [
_circle_polygon((200.0, 200.0), 60.0, 64), # frame 0(keyframe,对照)
_circle_polygon((200.0, 200.0), 20.0, 64), # frame 1(传播帧,面积过小)
]
shape_cfg = _RawShapeCfg(
keyframe_shape_strength=1.0,
propagation_shape_strength=0.0, # frame 1 形状 = 纯 medsam(半径20,欠界)
min_area_ratio_to_prompt=0.70,
max_area_ratio_to_prompt=1.25,
)
fused = fuse_prompt_prior_with_medsam(
medsam,
keyframes,
source_view,
target_eff,
shape_cfg,
_CONFIG.smoothing,
)
prior_area = polygon_area(fused[0])
fused_area = polygon_area(fused[1])
# 欠界面积应被放大到 ≈ min_ratio × prior_area(绕先验质心 sqrt 缩放)
assert fused_area >= prior_area * 0.70 - 100.0, "欠界面积应被放大到 min_area_ratio。"
assert fused_area <= prior_area * 0.70 + 200.0, "应恰好放大到下界附近,而非过度膨胀。"
def test_fusion_start_point_misalignment_does_not_distort_shape() -> None:
"""medsam 与 prior 起点错位但形状相同:融合后不因起点错位畸变(输出仍贴近该形状)。
把 medsam 顶点循环移位(起点错位)但形状 / 中心与 prior 完全一致,s=0.5:
若不先对齐起点,逐顶点混合会把不同角度的点平均出来导致半径塌缩;align 后应保形。
"""
target_eff = ViewRect(0, 0, 400, 400)
source_view = ViewRect(0, 0, 400, 400)
radius = 50.0
keyframes = [{"frame_index": 0, "mask": _disk_mask(400, 400, (200, 200), int(radius))}]
base_circle = _circle_polygon((200.0, 200.0), radius, 64)
shifted_circle = np.roll(base_circle, 17, axis=0) # 起点错位 17 个顶点
medsam = [
_circle_polygon((200.0, 200.0), radius, 64), # frame 0(keyframe,对照)
shifted_circle, # frame 1(传播帧,起点错位)
]
shape_cfg = _RawShapeCfg(
keyframe_shape_strength=1.0,
propagation_shape_strength=0.5,
min_area_ratio_to_prompt=0.10,
max_area_ratio_to_prompt=2.00, # 放宽上界,孤立起点对齐效应
)
fused = fuse_prompt_prior_with_medsam(
medsam,
keyframes,
source_view,
target_eff,
shape_cfg,
_CONFIG.smoothing,
)
prior_area = polygon_area(fused[0])
fused_area = polygon_area(fused[1])
# 起点对齐后形状不塌缩:融合面积应与先验面积相当(错位若未对齐会显著缩小)
assert fused_area == pytest.approx(prior_area, rel=0.10), (
f"起点错位经 align 后应保形(≈{prior_area:.0f}),实得 {fused_area:.0f}。"
)
def test_fusion_propagation_strength_clipped_to_unit_interval() -> None:
"""传播权重越界(1.5 / −0.5)经函数内部 np.clip → 行为等价 1.0 / 0.0。"""
target_eff = ViewRect(0, 0, 400, 400)
source_view = ViewRect(0, 0, 400, 400)
keyframes = [{"frame_index": 0, "mask": _disk_mask(400, 400, (200, 200), 40)}]
medsam = [
_circle_polygon((200.0, 200.0), 40.0, 64), # frame 0(keyframe)
_circle_polygon((260.0, 260.0), 40.0, 64), # frame 1(传播帧,偏移到右下)
]
def _fuse(propagation_strength: float) -> np.ndarray:
shape_cfg = _RawShapeCfg(
keyframe_shape_strength=1.0,
propagation_shape_strength=propagation_strength,
min_area_ratio_to_prompt=0.10,
max_area_ratio_to_prompt=2.00,
)
return fuse_prompt_prior_with_medsam(
medsam,
keyframes,
source_view,
target_eff,
shape_cfg,
_CONFIG.smoothing,
)[1]
# s=1.5 被 clip 到 1.0 → 形状=纯 prior(中心贴近 (200,200));与显式 1.0 等价
over = _fuse(1.5).mean(axis=0)
one = _fuse(1.0).mean(axis=0)
assert np.allclose(over, one, atol=1.0), "s=1.5 应被 clip 到 1.0(行为等价)。"
# s=−0.5 被 clip 到 0.0 → 形状=纯 medsam(中心贴近 (260,260));与显式 0.0 等价
neg = _fuse(-0.5).mean(axis=0)
zero = _fuse(0.0).mean(axis=0)
assert np.allclose(neg, zero, atol=1.0), "s=−0.5 应被 clip 到 0.0(行为等价)。"
# Phase 5: 时间平滑 + 位置约束补测
def test_temporal_smoothing_damps_per_vertex_jitter_along_time() -> None:
"""逐帧抖动的顶点序列经时间轴三角平滑后抖动被削弱、形状趋稳。
构造一段「真值恒定 + 逐帧高频抖动」的多边形序列(顶点已逐点对应),
masks_to_smoothed_polygons 内的时间平滑应显著降低逐帧位移幅度。
"""
frame_count = 9
base_center = (200, 200)
radius = 30
rng = np.random.default_rng(7)
# 每帧在固定圆心附近抖动 ±4px(mask 级别抖动 → 平滑后中心轨迹应更平稳)
masks_by_frame: dict[int, np.ndarray] = {}
for frame_idx in range(frame_count):
jitter = rng.integers(-4, 5, size=2)
center = (int(base_center[0] + jitter[0]), int(base_center[1] + jitter[1]))
masks_by_frame[frame_idx] = _disk_mask(400, 400, center, radius)
polygons = masks_to_smoothed_polygons(
masks_by_frame, frame_count, _CONFIG.smoothing.model_copy(update={"temporal_window": 5})
)
# 平滑后逐帧质心位移幅度应小于原始抖动尺度(抖动被削弱)
centers = np.array([poly.mean(axis=0) for poly in polygons])
frame_to_frame_shift = np.linalg.norm(np.diff(centers, axis=0), axis=1)
assert frame_to_frame_shift.max() < 4.0, "时间平滑应把逐帧质心抖动削弱到原始 ±4px 尺度以下。"
# 形状趋稳:各帧面积方差小(半径恒定 → 平滑不应制造面积剧烈起伏)
areas = np.array([polygon_area(poly) for poly in polygons])
assert areas.std() / areas.mean() < 0.10, "恒定半径下平滑后面积应稳定。"
def test_temporal_smoothing_no_vertex_crossing_distortion() -> None:
"""相邻帧轮廓起点对齐使逐点对应,时间平滑不产生顶点错位畸变(形状保真)。
两帧同形同位的圆(仅起点天然可能错位),平滑后每帧仍是完整圆而非塌缩折线。
"""
frame_count = 3
masks_by_frame = {idx: _disk_mask(400, 400, (200, 200), 40) for idx in range(frame_count)}
polygons = masks_to_smoothed_polygons(
masks_by_frame, frame_count, _CONFIG.smoothing.model_copy(update={"temporal_window": 3})
)
# 起点对齐 + 平滑后:每帧面积应接近真实圆面积,未因顶点错位塌缩
analytic_area = np.pi * 40.0**2
for poly in polygons:
assert polygon_area(poly) == pytest.approx(analytic_area, rel=0.10), (
"起点对齐后时间平滑应保形,不出现顶点错位导致的面积塌缩。"
)
def test_position_constraint_invalid_mode_raises() -> None:
"""mode != 'center_interpolation' → RuntimeError(当前仅支持该模式,纵深防御)。"""
target_eff = ViewRect(0, 0, 200, 200)
source_view = ViewRect(0, 0, 200, 200)
polygons = [_circle_polygon((100.0, 100.0), 20.0, 64)]
keyframes = [{"frame_index": 0, "mask": _disk_mask(200, 200, (100, 100), 20)}]
with pytest.raises(RuntimeError):
apply_prompt_frame_position_constraints(
polygons,
keyframes,
source_view,
target_eff,
_RawConstraintCfg(mode="affine"),
)
def test_position_constraint_strength_clipped_to_unit_interval() -> None:
"""strength 越界(1.5 / −0.5)经函数内部 np.clip → 行为等价 1.0 / 0.0。"""
target_eff = ViewRect(0, 0, 200, 200)
source_view = ViewRect(0, 0, 200, 200)
keyframes = [
{"frame_index": 0, "mask": _disk_mask(200, 200, (40, 100), 15)},
{"frame_index": 2, "mask": _disk_mask(200, 200, (160, 100), 15)},
]
base_polys = [_circle_polygon((100.0, 30.0), 12.0, 64) for _ in range(3)]
def _correct(strength: float) -> np.ndarray:
corrected = apply_prompt_frame_position_constraints(
[poly.copy() for poly in base_polys],
keyframes,
source_view,
target_eff,
_RawConstraintCfg(strength=strength),
)
return np.array([poly.mean(axis=0) for poly in corrected])
# s=1.5 → clip 到 1.0(全量拉到 keyframe 轨迹);与显式 1.0 等价
assert np.allclose(_correct(1.5), _correct(1.0), atol=0.5)
# s=−0.5 → clip 到 0.0(不平移,质心保持原 (100, 30));与显式 0.0 等价
over_zero = _correct(-0.5)
explicit_zero = _correct(0.0)
assert np.allclose(over_zero, explicit_zero, atol=0.5)
assert np.allclose(over_zero[:, 1], 30.0, atol=2.0), "s=0 不平移,质心 y 应保持 30。"
+328
View File
@@ -0,0 +1,328 @@
"""背景圆选择(reference_region)单元测试(照搬旧项目 test_ceus_reference_region.py)。
旧标题:B8 参考区选择(reference_region)单元测试。
参考区是 ROI 外一块"干净背景"小圆,用于 TIC 的 ``background_circle`` 曲线。本测试
用真实 OpenCV 像素管线(非 mock)合成"亮 ROI + 四周可控背景"图像,钉死
``select_reference_region`` 的真实行为,覆盖:
- ``enabled=False`` → 返回空 mask + ``{"enabled": False}``;
- ``manual`` 模式:用 ``center_xy`` + ``radius_px`` 出圆 mask,metadata 键齐全且 shape=="circle";
- ``auto_near_roi`` 找到合规背景圆(ROI 外有干净非黑非绿背景);
- ``auto_near_roi`` 四周全黑 / 全绿无合规位 → 明确报错(不返回失真位);
- ``auto_near_roi`` 的干净背景判据把医生画的紫色轮廓也排掉(TIC 会扣掉这些像素)。
"""
import numpy as np
from cadx.ceus import (
CeusConfig,
ReferenceRegionConfig,
TicAnalysisConfig,
polygon_to_mask,
select_reference_region,
)
from conftest import load_ceus_section
# 配置不设默认值:旧测试靠默认值构造的配置,改为在 config/ceus.yaml 的取值上 model_copy。
_CONFIG = CeusConfig.model_validate(load_ceus_section())
def _ref_cfg(**overrides: object) -> ReferenceRegionConfig:
"""ceus.yaml 的背景圆段,按需改几项(代替旧的 `ReferenceRegionConfig(...)`)。"""
return _CONFIG.tic_analysis.reference_region.model_copy(update=overrides)
# ---------- 合成样本 ----------
_H, _W = 160, 200
# ROI 多边形:画面中央偏右上的方块(留出左下大片背景给 auto 模式落圆)。
_ROI_POLYGON = np.array(
[[110.0, 20.0], [170.0, 20.0], [170.0, 70.0], [110.0, 70.0]], dtype=np.float32
)
# 紫色(BGR):H≈145、S=255、V=255,落在 PanSourceConfig 默认阈 [125,60,60]~[165,255,255] 内。
_PURPLE_BGR = (255, 0, 212)
def _source_polygons() -> np.ndarray:
"""逐帧 ROI 多边形序列(参考区只看第一帧,单帧即可)。"""
return np.array([_ROI_POLYGON])
def _bright_background_frame() -> np.ndarray:
"""整幅亮背景(灰度 180)的 BGR 帧——四周都是干净非黑非绿背景。"""
return np.full((_H, _W, 3), 180, dtype=np.uint8)
def _black_frame_with_bright_roi() -> np.ndarray:
"""ROI 区域亮(180),ROI 之外全黑——auto 模式四周无合规背景圆。"""
frame = np.zeros((_H, _W, 3), dtype=np.uint8)
frame[20:70, 110:170] = 180
return frame
def _green_background_frame() -> np.ndarray:
"""ROI 区亮、四周全是医生绿框色(BGR 纯绿)——auto 模式四周被剔绿后无合规位。"""
frame = np.zeros((_H, _W, 3), dtype=np.uint8)
frame[:, :] = (0, 255, 0) # BGR 纯绿,落在默认 HSV [35,60,60]-[90,255,255] 内
frame[20:70, 110:170] = 180
return frame
def _tic_cfg(ref: ReferenceRegionConfig) -> TicAnalysisConfig:
"""构造带指定 reference_region 子配置的 TicAnalysisConfig。"""
return _CONFIG.tic_analysis.model_copy(update={"reference_region": ref})
# ---------- enabled=False ----------
def test_disabled_returns_empty_mask_and_flag() -> None:
"""enabled=False:返回全 False mask 且 metadata 仅含 {"enabled": False}。"""
ref = _ref_cfg(enabled=False)
frame = _bright_background_frame()
mask, meta = select_reference_region(
frame,
_source_polygons(),
_CONFIG.prompt_source,
_CONFIG.pan_source,
_tic_cfg(ref),
)
assert mask.shape == (_H, _W)
assert mask.dtype == bool
assert not mask.any()
assert meta == {"enabled": False}
# ---------- manual 模式 ----------
def test_manual_mode_circle_at_center() -> None:
"""manual:以 center_xy + radius_px 出圆 mask,metadata 键齐全且 shape=='circle'。"""
radius = 24
center = [40.0, 120.0]
ref = _ref_cfg(mode="manual", center_xy=center, radius_px=radius)
frame = _bright_background_frame()
mask, meta = select_reference_region(
frame,
_source_polygons(),
_CONFIG.prompt_source,
_CONFIG.pan_source,
_tic_cfg(ref),
)
assert mask.dtype == bool
assert mask[int(center[1]), int(center[0])] # 圆心被标记
assert not mask[0, 0] # 远处角点不在圆内
assert meta["enabled"] is True
assert meta["mode"] == "manual"
assert meta["shape"] == "circle"
assert meta["center_xy"] == center
assert meta["radius_px"] == float(radius)
assert meta["area_px"] == int(mask.sum())
assert meta["area_px"] > 0
def test_manual_circle_area_matches_radius() -> None:
"""manual 圆面积应接近 π r²(栅格化误差范围内)。"""
radius = 20
ref = _ref_cfg(mode="manual", center_xy=[40.0, 120.0], radius_px=radius)
frame = _bright_background_frame()
mask, _ = select_reference_region(
frame,
_source_polygons(),
_CONFIG.prompt_source,
_CONFIG.pan_source,
_tic_cfg(ref),
)
expected = np.pi * radius * radius
assert abs(int(mask.sum()) - expected) / expected < 0.1
# ---------- auto_near_roi 找到 ----------
def test_auto_finds_clean_background_circle() -> None:
"""auto:四周干净亮背景时应找到合规圆,metadata 键齐全且 valid_fraction 达标。"""
ref = _ref_cfg(mode="auto_near_roi")
frame = _bright_background_frame()
mask, meta = select_reference_region(
frame,
_source_polygons(),
_CONFIG.prompt_source,
_CONFIG.pan_source,
_tic_cfg(ref),
)
assert mask.dtype == bool
assert mask.any()
assert meta["enabled"] is True
assert meta["mode"] == "auto_near_roi"
assert meta["shape"] == "circle"
assert len(meta["center_xy"]) == 2
assert meta["radius_px"] == float(ref.radius_px)
assert meta["area_px"] == int(mask.sum())
assert meta["valid_fraction_first_frame"] >= ref.min_valid_fraction
def test_auto_reference_circle_outside_roi() -> None:
"""auto:选中的参考圆不应与 ROI(含禁区膨胀)重叠。"""
ref = _ref_cfg(mode="auto_near_roi")
frame = _bright_background_frame()
mask, _ = select_reference_region(
frame,
_source_polygons(),
_CONFIG.prompt_source,
_CONFIG.pan_source,
_tic_cfg(ref),
)
roi_mask = polygon_to_mask(_ROI_POLYGON, _H, _W)
assert not np.any(mask & roi_mask)
# ---------- auto_near_roi 找不到 → 空掩膜与显式原因 ----------
def test_auto_returns_unavailable_when_background_all_black() -> None:
"""auto:ROI 外全黑时不伪造背景圆,也不阻断病灶拟合。"""
ref = _ref_cfg(mode="auto_near_roi")
frame = _black_frame_with_bright_roi()
mask, meta = select_reference_region(
frame,
_source_polygons(),
_CONFIG.prompt_source,
_CONFIG.pan_source,
_tic_cfg(ref),
)
assert not mask.any()
assert meta["available"] is False
assert meta["failure_reason"] == "no_valid_candidate"
def test_auto_returns_unavailable_when_background_all_green() -> None:
"""auto:ROI 外全绿时返回空掩膜,不返回失真位置。"""
ref = _ref_cfg(mode="auto_near_roi")
frame = _green_background_frame()
mask, meta = select_reference_region(
frame,
_source_polygons(),
_CONFIG.prompt_source,
_CONFIG.pan_source,
_tic_cfg(ref),
)
assert not mask.any()
assert meta["available"] is False
# ---------- 补强:精确键集 + 多帧 ROI 并集禁区 ----------
def test_manual_metadata_exact_key_set() -> None:
"""manual 分支 metadata 键集须精确(无缺键、无误增键)。"""
ref = _ref_cfg(mode="manual", center_xy=[40.0, 120.0], radius_px=18)
_, meta = select_reference_region(
_bright_background_frame(),
_source_polygons(),
_CONFIG.prompt_source,
_CONFIG.pan_source,
_tic_cfg(ref),
)
assert set(meta.keys()) == {
"enabled",
"mode",
"shape",
"center_xy",
"radius_px",
"area_px",
}
def test_auto_metadata_exact_key_set() -> None:
"""auto 分支 metadata 键集须精确,含 valid_fraction_first_frame。"""
ref = _ref_cfg(mode="auto_near_roi")
_, meta = select_reference_region(
_bright_background_frame(),
_source_polygons(),
_CONFIG.prompt_source,
_CONFIG.pan_source,
_tic_cfg(ref),
)
assert set(meta.keys()) == {
"enabled",
"mode",
"shape",
"center_xy",
"radius_px",
"area_px",
"valid_fraction_first_frame",
}
def test_auto_avoids_doctor_purple_contour() -> None:
"""auto:干净背景的判据要把医生画的紫色一起排掉,选中的圆不许压在紫色上。
紫色轮廓是医生画上去的线,TIC 把这些像素从掩膜里扣掉——背景圆压在它上面的话,
``valid_background_circle_px`` 会被悄悄削掉一块,而没有任何一处会说出来。紫色的灰度
不低(``nonblack_threshold`` 拦不住它),所以只排绿的判据会把它当成干净背景。
先在全亮画面上选一次,把选中的那个圆整个涂成紫色再选第二次:``min_valid_fraction``
设成 1.0,于是只要圆里沾上一个紫色像素就出局,第二次必须换个位置。
"""
ref = _ref_cfg(mode="auto_near_roi", min_valid_fraction=1.0)
frame = _bright_background_frame()
first_mask, _ = select_reference_region(
frame,
_source_polygons(),
_CONFIG.prompt_source,
_CONFIG.pan_source,
_tic_cfg(ref),
)
painted = frame.copy()
painted[first_mask] = _PURPLE_BGR
second_mask, _ = select_reference_region(
painted,
_source_polygons(),
_CONFIG.prompt_source,
_CONFIG.pan_source,
_tic_cfg(ref),
)
purple = np.all(painted == np.asarray(_PURPLE_BGR, dtype=np.uint8), axis=2)
assert second_mask.any(), "涂了一块紫色之后连一个合规位置都找不到了"
assert not np.any(second_mask & purple), "选中的背景圆压在紫色轮廓上"
def test_auto_keepout_uses_multiframe_roi_union() -> None:
"""auto 禁区取逐帧 ROI 并集:选中圆须避开所有帧 ROI 的并集(非仅首帧)。"""
ref = _ref_cfg(mode="auto_near_roi")
# 两帧不同位置的 ROI:首帧右上、次帧偏左下,构成更大的并集禁区
roi_a = _ROI_POLYGON
roi_b = np.array([[60.0, 90.0], [120.0, 90.0], [120.0, 140.0], [60.0, 140.0]], dtype=np.float32)
polys = np.array([roi_a, roi_b])
mask, _ = select_reference_region(
_bright_background_frame(),
polys,
_CONFIG.prompt_source,
_CONFIG.pan_source,
_tic_cfg(ref),
)
union = polygon_to_mask(roi_a, _H, _W) | polygon_to_mask(roi_b, _H, _W)
assert not np.any(mask & union), "参考圆须避开逐帧 ROI 并集,而非仅首帧 ROI"
+607
View File
@@ -0,0 +1,607 @@
"""TIC 四曲线采样与基线(tic)单元测试(照搬旧项目 test_ceus_tic.py)。
旧标题:B9 TIC 四曲线 + 基线识别 + 平滑(tic.py)单元测试。
TIC(time-intensity curve,时间-强度曲线)刻画病灶亮度随时间变化。本模块在 source
视图上逐帧采四条灰度均值曲线——病灶整体、去掉暗区、紫色轮廓圈的邻近正常胰腺实质
(``ROI_pan``)、程序自动挑的背景圆——每条各自识别增强前基线、扣掉基线均值再平滑。
测试用真实 OpenCV 像素管线(非 mock)合成可控帧序列,钉死 ``tic.py`` 的真实行为:
- 掩膜构成:``valid_roi_all = roi & ~(绿 ∪ 紫)``、``valid_roi_perf = valid_roi_all &
~dark_union``、``valid_pan = 紫轮廓填充 & ~(绿 ∪ 紫)``、
``valid_background_circle = 背景圆 & ~(绿 ∪ 紫)``;
- ``mean_or_nan`` 空掩膜返回 NaN(不报错);
- 平滑链顺序 median→triangular,默认窗 5/15/1(以代码为准非 README);
- 基线识别:候选窗、稳定性缩窗、窗太短即无效(``mean`` / ``sd`` 为 ``None``);
- 撤掉合成零点之后,涨得最快的时刻落在真实陡升段而不是视频开头;
- 紫色检不到时 ``ROI_pan`` 整条 NaN,**不回退背景圆**;
- ``compute_tic_for_video`` 行集列名齐全,且本轮定量参数为 None。
"""
import math
import numpy as np
import pytest
from cadx.ceus import (
CeusConfig,
PanSourceConfig,
TicAnalysisConfig,
compute_tic_for_video,
detect_baseline,
mean_or_nan,
)
from cadx.ceus import _tic as tic_module # pyright: ignore[reportPrivateUsage]
from conftest import load_ceus_section
# 配置不设默认值:旧测试靠默认值构造的配置,改为在 config/ceus.yaml 的取值上 model_copy。
_CONFIG = CeusConfig.model_validate(load_ceus_section())
_TIC_NO_DUPLICATES = _CONFIG.tic_analysis.model_copy(
update={
"fitting": _CONFIG.tic_analysis.fitting.model_copy(
update={"detect_duplicate_frames": False}
)
}
)
# ---------- 合成样本 ----------
_H, _W = 80, 120
# ROI:画面中央偏右的方块(左下留干净背景给背景圆 / 掩膜对照)。
_ROI_POLYGON = np.array(
[[60.0, 10.0], [110.0, 10.0], [110.0, 50.0], [60.0, 50.0]], dtype=np.float32
)
# 暗区:ROI 内部一小块(用于验证 roi_perf = valid_roi_all & ~dark)。
_DARK_POLYGON = np.array([[70.0, 20.0], [90.0, 20.0], [90.0, 40.0], [70.0, 40.0]], dtype=np.float32)
# 紫色(BGR):H≈145、S=255、V=255,落在 PanSourceConfig 默认阈 [125,60,60]~[165,255,255] 内。
_PURPLE_BGR = (255, 0, 212)
def _frame(roi_gray: int, dark_gray: int, ref_gray: int) -> np.ndarray:
"""合成一帧 source BGR:ROI 区灰度 roi_gray、其内暗块 dark_gray、左下背景块 ref_gray。
其余背景为 0(黑),避免被背景圆误采。灰度对 BGR 三通道相等填充,cvtColor 后即该灰度值。
"""
frame = np.zeros((_H, _W, 3), dtype=np.uint8)
frame[10:50, 60:110] = roi_gray
frame[20:40, 70:90] = dark_gray
frame[10:40, 5:35] = ref_gray # 左下背景块
return frame
def _background_circle_mask() -> np.ndarray:
"""背景圆掩膜(左下背景块的子区域),与 _frame 的 ref 区重叠。"""
mask = np.zeros((_H, _W), dtype=bool)
mask[15:35, 10:30] = True
return mask
def _build_inputs(
frame_count: int = 3,
) -> tuple[list[np.ndarray], list[np.ndarray], list[list[np.ndarray]], np.ndarray]:
"""构造逐帧 source 帧 / ROI 多边形 / 逐帧暗区多边形列表 + 背景圆 mask。
暗区入参对齐 B7 ``extract_dark_region_polygons`` 输出(``list[list[np.ndarray]]``:
外层帧对齐、内层每帧 0~N 个暗区多边形),此处每帧给单个暗块 ``[[_DARK_POLYGON]]``。
这批帧里**一个紫色像素都没有**,所以 ``ROI_pan`` 整条无效——这正是「检不到紫色
不回退背景圆」那条测试要的场景。
"""
frames = [_frame(roi_gray=120, dark_gray=20, ref_gray=60) for _ in range(frame_count)]
roi_polygons = [_ROI_POLYGON for _ in range(frame_count)]
dark_polygons_per_frame = [[_DARK_POLYGON] for _ in range(frame_count)]
return frames, roi_polygons, dark_polygons_per_frame, _background_circle_mask()
def _run(
frames: list[np.ndarray],
roi_polygons: list[np.ndarray],
dark_polygons_per_frame: list[list[np.ndarray]],
background_circle_mask: np.ndarray,
*,
fps: float = 2.0,
pan_cfg: PanSourceConfig | None = None,
tic_cfg: TicAnalysisConfig | None = None,
) -> tic_module.TicResult:
"""按默认配置跑一次 ``compute_tic_for_video``(省掉各测试里重复的样板)。"""
return compute_tic_for_video(
source_frames=frames,
roi_polygons=roi_polygons,
dark_polygons_per_frame=dark_polygons_per_frame,
reference_mask=background_circle_mask,
fps=fps,
prompt_cfg=_CONFIG.prompt_source,
pan_cfg=pan_cfg or _CONFIG.pan_source,
tic_cfg=tic_cfg or _TIC_NO_DUPLICATES,
frame_times_s=None,
)
def _column(result: tic_module.TicResult, key: str) -> np.ndarray:
"""取一列逐帧值。"""
return np.asarray([row[key] for row in result.rows], dtype=np.float32)
# ---------- mean_or_nan:NaN 语义 ----------
def test_mean_or_nan_empty_mask_returns_nan() -> None:
"""空掩膜(无 True 像素)返回 NaN,而非报错或 0。"""
gray = np.full((_H, _W), 120, dtype=np.uint8)
empty = np.zeros((_H, _W), dtype=bool)
result = mean_or_nan(gray, empty)
assert math.isnan(result)
def test_mean_or_nan_returns_masked_mean() -> None:
"""非空掩膜返回该掩膜内灰度均值。"""
gray = np.zeros((_H, _W), dtype=np.uint8)
gray[0:10, 0:10] = 200
mask = np.zeros((_H, _W), dtype=bool)
mask[0:10, 0:10] = True
assert mean_or_nan(gray, mask) == pytest.approx(200.0)
# ---------- detect_baseline:纯函数层 ----------
def test_detect_baseline_flat_prefix_gives_mean_and_sd() -> None:
"""开头两秒平坦时,基线窗取满首选时长,``B`` 与 ``σ0`` 从这一段算出来。"""
values = np.concatenate(
[
np.full(20, 40.0, dtype=np.float32),
np.linspace(40, 200, 40, dtype=np.float32),
]
)
baseline = detect_baseline(
values, np.arange(values.size) / 10.0, _CONFIG.tic_analysis.baseline, None
)
assert baseline.valid is True
assert baseline.frame_start == 0
assert baseline.frame_end == 20
assert baseline.mean == pytest.approx(40.0)
assert baseline.sd == pytest.approx(0.0)
def test_detect_baseline_ignores_nan_samples() -> None:
"""NaN 帧不算基线采样:既不进均值,也不被当成 0 把基线拉低。"""
values = np.full(20, 40.0, dtype=np.float32)
values[3] = np.nan
values[11] = np.nan
baseline = detect_baseline(
values, np.arange(values.size) / 10.0, _CONFIG.tic_analysis.baseline, None
)
assert baseline.valid is True
assert baseline.mean == pytest.approx(40.0)
def test_detect_baseline_all_nan_is_invalid() -> None:
"""整条都是 NaN 时拿不到基线(不是拿到一个由零个样本算出来的数)。"""
baseline = detect_baseline(
np.full(30, np.nan, dtype=np.float32),
np.arange(30) / 10.0,
_CONFIG.tic_analysis.baseline,
None,
)
assert baseline.valid is False
assert baseline.mean is None
assert baseline.sd is None
assert baseline.frame_start == -1
assert baseline.frame_end == -1
def test_detect_baseline_rejects_non_1d() -> None:
"""二维输入直接报错,不静默摊平。"""
with pytest.raises(ValueError):
detect_baseline(
np.zeros((3, 4), dtype=np.float32),
np.arange(12).reshape(3, 4),
_CONFIG.tic_analysis.baseline,
None,
)
def test_detect_baseline_rejects_non_increasing_timestamps() -> None:
"""时间戳不严格递增直接报错。"""
with pytest.raises(ValueError):
detect_baseline(
np.zeros(30, dtype=np.float32), np.zeros(30), _CONFIG.tic_analysis.baseline, None
)
# ---------- 撤掉合成零点之后:最大流入斜率不落视频开头 ----------
# ---------- 基线窗不足最短时长:两种不足 ----------
def _assert_baseline_unusable(result: tic_module.TicResult, expected_reason: str) -> None:
"""观察基线拿不到时保留截断标志,失败拟合不回填 processed。"""
baseline = result.baselines["roi_all"]
assert baseline.valid is False
assert baseline.mean is None
assert baseline.sd is None
assert result.early_truncation_flag is True
assert result.fits["roi_all"].failure_reason == expected_reason
assert np.isnan(_column(result, "roi_all_processed")).all()
def test_baseline_invalid_when_video_shorter_than_minimum() -> None:
"""视频太短(0.8 秒)凑不出 1 秒基线:不合成、不外推,标 early_truncation。"""
frame_count = 8
frames = [_frame(roi_gray=70, dark_gray=70, ref_gray=60) for _ in range(frame_count)]
result = _run(
frames,
[_ROI_POLYGON for _ in range(frame_count)],
[[] for _ in range(frame_count)],
_background_circle_mask(),
fps=10.0,
)
_assert_baseline_unusable(result, "insufficient_valid_samples")
def test_baseline_invalid_when_enhancement_already_rising_at_frame_zero() -> None:
"""视频够长但录晚了、开头就在涨:稳定性检查把窗缩到不足 1 秒,同样判无效。"""
frame_count = 60
frames = [
_frame(roi_gray=40 + 2 * index, dark_gray=40 + 2 * index, ref_gray=60)
for index in range(frame_count)
]
result = _run(
frames,
[_ROI_POLYGON for _ in range(frame_count)],
[[] for _ in range(frame_count)],
_background_circle_mask(),
fps=10.0,
)
_assert_baseline_unusable(result, "credible_peak_not_found")
# ---------- ROI_pan:检不到紫色不回退背景圆 ----------
def test_pan_curve_not_backfilled_from_background_circle() -> None:
"""一个紫色像素都没有时,``ROI_pan`` 整条 NaN,而背景圆那条照常算出来。
只断言「pan 无效」不够——一个「检不到就复制背景圆」的实现照样能让前三条过。
第四条(两条曲线不逐点相等)才是要害。
"""
frames, roi_polygons, dark_polygons_per_frame, background = _build_inputs()
result = _run(frames, roi_polygons, dark_polygons_per_frame, background)
assert result.reference_valid is False
pan_raw = _column(result, "roi_pan_raw")
background_raw = _column(result, "background_circle_raw")
assert np.isnan(pan_raw).all()
assert result.baselines["roi_pan"].valid is False
assert result.baselines["roi_pan"].mean is None
assert np.isfinite(background_raw).all()
assert not np.array_equal(pan_raw, background_raw, equal_nan=True)
def _frame_with_purple_contour(interior_gray: int) -> np.ndarray:
"""在基础帧的左下角画一圈紫色轮廓,圈内填 ``interior_gray``。
轮廓画成空心矩形(医生画的是一条线,不是一块面),所以填充后的参照区内部是灰度、
只有那圈线是紫色。轮廓外面也铺同一片灰度:``extract_color_prompt`` 的形态学膨胀会
让填充区比画上去的那圈线略大一点,外面若是纯黑就会把均值拉低——真实画面上轮廓外
也是组织,这里照着来。
"""
frame = _frame(roi_gray=120, dark_gray=20, ref_gray=60)
frame[46:80, 1:50] = interior_gray
frame[52:55, 5:46] = _PURPLE_BGR
frame[74:77, 5:46] = _PURPLE_BGR
frame[52:77, 5:8] = _PURPLE_BGR
frame[52:77, 43:46] = _PURPLE_BGR
return frame
def test_pan_curve_reads_purple_contour_interior() -> None:
"""画面上有紫色轮廓时,``ROI_pan`` 采的是轮廓**内部**的灰度,轮廓线本身被剔掉。"""
interior_gray = 90
frame_count = 3
frames = [_frame_with_purple_contour(interior_gray) for _ in range(frame_count)]
result = _run(
frames,
[_ROI_POLYGON for _ in range(frame_count)],
[[_DARK_POLYGON] for _ in range(frame_count)],
_background_circle_mask(),
pan_cfg=_CONFIG.pan_source.model_copy(update={"min_area": 500}),
)
assert result.reference_valid is True
pan_raw = _column(result, "roi_pan_raw")
assert np.isfinite(pan_raw).all()
# 轮廓线(紫)被排除色掩膜剔掉,剩下的全是内部那块灰度。
np.testing.assert_allclose(pan_raw, interior_gray, atol=1.0)
assert result.rows[0]["valid_pan_px"] > 0
assert result.rows[0]["pan_area_px"] > result.rows[0]["valid_pan_px"]
def test_reference_valid_counts_sampled_frames_not_purple_area() -> None:
"""一整块实心紫色:紫色本身有面积,但排除色把它整块吞掉,一个像素都没采到。
界面上的实心紫色标记就长这样:``extract_color_prompt`` 填充出来的面积过得了
``min_area``,而排除掩膜把这块紫色连同外扩的一圈整个盖住,``valid_pan`` 是空的。
检出率若按填充面积数,台账上会写着「参照区没问题」,底下压着一条整段 NaN 的曲线,
而库上那条「``reference_valid`` 为假时 ``_pan`` 基线必须记 null」的 CHECK 只在
``reference_valid`` 为假时才管,此时一声不响。
``prompt_exclude_dilate`` 调到 5:检测那一路的形态学会把填充区外扩两像素,默认的
3 像素核只外扩一像素,边上会剩一圈采得到的像素——那不是本条要造的场景。
"""
frame_count = 3
frames: list[np.ndarray] = []
for _ in range(frame_count):
frame = _frame(roi_gray=120, dark_gray=20, ref_gray=60)
frame[50:78, 5:45] = _PURPLE_BGR # 实心紫块,不是一圈线
frames.append(frame)
result = _run(
frames,
[_ROI_POLYGON for _ in range(frame_count)],
[[_DARK_POLYGON] for _ in range(frame_count)],
_background_circle_mask(),
pan_cfg=_CONFIG.pan_source.model_copy(update={"min_area": 500}),
tic_cfg=_CONFIG.tic_analysis.model_copy(update={"prompt_exclude_dilate": 5}),
)
assert result.rows[0]["pan_area_px"] > 0, "紫色轮廓本身确实有面积——前提没造对"
assert all(row["valid_pan_px"] == 0 for row in result.rows)
assert np.isnan(_column(result, "roi_pan_raw")).all()
assert result.reference_valid is False
def test_early_truncation_ignores_background_circle_baseline() -> None:
"""三条规范曲线基线都好、只有背景圆那条被缩短:质控位不许置起来。
背景圆是我们自己的整机增益漂移曲线,不喂任何规范字段(design/0030 决策四第三条),
而 ``early_truncation_flag`` 是规范 §14 的质控位。自动挑中的圆落在一块两秒内就开始
增强的组织上时,它的基线窗会被缩到不足最短时长——那和「这段视频被截短了」是两回事。
"""
frame_count = 6
frames: list[np.ndarray] = []
for index in range(frame_count):
frame = _frame(roi_gray=120, dark_gray=20, ref_gray=60 + 30 * index)
# 紫色轮廓(空心矩形)画在左下,和背景圆那块不重叠。
frame[46:80, 1:50] = 90
frame[52:55, 5:46] = _PURPLE_BGR
frame[74:77, 5:46] = _PURPLE_BGR
frame[52:77, 5:8] = _PURPLE_BGR
frame[52:77, 43:46] = _PURPLE_BGR
frames.append(frame)
result = _run(
frames,
[_ROI_POLYGON for _ in range(frame_count)],
[[_DARK_POLYGON] for _ in range(frame_count)],
_background_circle_mask(),
fps=2.0,
pan_cfg=_CONFIG.pan_source.model_copy(update={"min_area": 500}),
)
for name in ("roi_all", "roi_perf", "roi_pan"):
assert result.baselines[name].valid is True, f"{name} 的基线该是好的——前提没造对"
assert result.baselines["background_circle"].valid is False, (
"背景圆那条该拿不到基线——前提没造对"
)
assert result.early_truncation_flag is False
def test_purple_pixels_are_excluded_from_roi_statistics() -> None:
"""紫色像素落进 ROI 时同样被剔除(排除色 = 绿 ∪ 紫,不再只排绿)。"""
frames, roi_polygons, dark_polygons_per_frame, background = _build_inputs(frame_count=1)
baseline_result = _run(frames, roi_polygons, dark_polygons_per_frame, background)
baseline_valid_px = baseline_result.rows[0]["valid_roi_all_px"]
frames, roi_polygons, dark_polygons_per_frame, background = _build_inputs(frame_count=1)
frames[0][12:18, 65:105] = _PURPLE_BGR
result = _run(frames, roi_polygons, dark_polygons_per_frame, background)
assert result.rows[0]["valid_roi_all_px"] < baseline_valid_px
# ---------- 删掉的名字不许回来 ----------
def test_synthetic_zero_timepoint_names_are_gone() -> None:
"""合成零点那一套的名字全部不存在,行集里也不再有 ``frame_index=-1`` 的合成行。
守的是 design/0030 决策二那次删除。理由写在 ``tic.TicResult`` 的 docstring 上:
人造点到首个真实帧那一跳是全曲线最大斜率,最大流入斜率的时刻会被永远钉在视频开头。
"""
assert not hasattr(tic_module, "prepend_zero_timepoint")
assert not hasattr(tic_module, "_zero_timepoint_row")
assert "prepend_zero_timepoint" not in tic_module.__all__
frames, roi_polygons, dark_polygons_per_frame, background = _build_inputs()
result = _run(frames, roi_polygons, dark_polygons_per_frame, background)
assert len(result.rows) == len(frames)
for index, row in enumerate(result.rows):
assert row["frame_index"] == index
assert "is_zero_timepoint" not in row
# ---------- compute_tic_for_video:掩膜构成 + 列名 + 参数 None ----------
def test_compute_tic_curve_mask_composition() -> None:
"""掩膜构成正确:ROI 区灰度 120、去暗区后仍 120(暗块被剔)、背景圆 60。
暗块灰度 20 远低于 ROI 主体 120,若 roi_perf 未剔暗块,其均值会被拉低;
剔除后 roi_perf 应等于 ROI 主体灰度 120。
"""
frames, roi_polygons, dark_polygons_per_frame, background = _build_inputs()
sample = _run(frames, roi_polygons, dark_polygons_per_frame, background).rows[0]
# roi_all_raw 含暗块(120 主体混 20 暗块)→ 被拉低到 120 与 20 之间。
assert 20.0 < sample["roi_all_raw"] < 120.0
# 暗块(20)被剔后 roi_perf 应接近纯 ROI 主体 120(栅格边界残留少量暗像素,放宽),
# 且严格高于含暗的 roi_all(剔暗确实抬升了均值)。
assert sample["roi_perf_raw"] == pytest.approx(120.0, abs=8.0)
assert sample["roi_perf_raw"] > sample["roi_all_raw"]
assert sample["background_circle_raw"] == pytest.approx(60.0, abs=1.0)
# 去暗后有效像素数 < 含暗的 valid_roi_all 像素数。
assert sample["valid_roi_perf_px"] < sample["valid_roi_all_px"]
def test_compute_tic_prompt_excluded_from_masks() -> None:
"""绿框像素从掩膜中剔除:在 ROI 内画绿条后,valid_roi_all 像素数应减少。"""
frames, roi_polygons, dark_polygons_per_frame, background = _build_inputs(frame_count=1)
# 在 ROI 内画一条绿色(BGR 绿,HSV 落在默认阈内)。
frames[0][12:18, 65:105] = (0, 255, 0)
sample = _run(frames, roi_polygons, dark_polygons_per_frame, background).rows[0]
# roi 区栅格面积固定,但剔绿后 valid_roi_all 有效像素严格少于 roi_area。
assert sample["valid_roi_all_px"] < sample["roi_area_px"]
def test_compute_tic_row_column_names_complete() -> None:
"""行集含四曲线 raw/candidate/fitted/processed 与样本质控列。"""
frames, roi_polygons, dark_polygons_per_frame, background = _build_inputs()
result = _run(frames, roi_polygons, dark_polygons_per_frame, background)
expected_columns = {
"frame_index",
"time_sec",
"duplicate_frame",
"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",
"perf_mask_stable",
}
for name in tic_module.CURVE_NAMES:
expected_columns |= {
f"{name}_raw",
f"{name}_candidate",
f"{name}_fitted",
f"{name}_processed",
}
for row in result.rows:
assert set(row.keys()) == expected_columns
def test_compute_tic_uses_explicit_presentation_timestamps() -> None:
frames, roi_polygons, dark_polygons_per_frame, background = _build_inputs()
times = [0.0, 0.11, 0.24]
result = compute_tic_for_video(
frames,
roi_polygons,
dark_polygons_per_frame,
background,
10.0,
_CONFIG.prompt_source,
_CONFIG.pan_source,
_TIC_NO_DUPLICATES,
times,
)
np.testing.assert_allclose(_column(result, "time_sec"), times)
def test_compute_tic_marks_consecutive_duplicate_roi_frames() -> None:
frames, roi_polygons, dark_polygons_per_frame, background = _build_inputs()
result = compute_tic_for_video(
frames,
roi_polygons,
dark_polygons_per_frame,
background,
2.0,
_CONFIG.prompt_source,
_CONFIG.pan_source,
_CONFIG.tic_analysis,
None,
)
assert [row["duplicate_frame"] for row in result.rows] == [False, True, True]
def test_compute_tic_parameters_none_this_round() -> None:
"""本轮不算定量参数,``parameters`` 为 None。"""
frames, roi_polygons, dark_polygons_per_frame, background = _build_inputs()
assert _run(frames, roi_polygons, dark_polygons_per_frame, background).parameters is None
def test_compute_tic_background_circle_excludes_green_pixels() -> None:
"""证伪 ``valid_background = 背景圆 & ~exclude``:圆内画绿后剔绿,统计须随之变化。
在背景圆内填入一片绿色(HSV 落默认阈内)。若掩膜未剔绿,绿像素会计入
``valid_background_circle_px`` 且把 ``background_circle_raw`` 拉向绿灰度;
剔绿后两者均应改变:有效像素数严格少于圆面积,均值贴近纯背景灰度而非被绿污染。
"""
frames, roi_polygons, dark_polygons_per_frame, background = _build_inputs(frame_count=1)
# 背景圆掩膜为 [15:35, 10:30],背景块 ref_gray=60;在其内画一片绿。
frames[0][16:24, 12:28] = (0, 255, 0)
sample = _run(frames, roi_polygons, dark_polygons_per_frame, background).rows[0]
assert sample["valid_background_circle_px"] < sample["background_circle_area_px"]
# 均值未被绿(cvtColor 后灰度约 150)污染,仍贴近背景 60。
assert sample["background_circle_raw"] == pytest.approx(60.0, abs=2.0)
def test_compute_tic_dark_region_clipped_to_roi() -> None:
"""证伪 ``dark_union &= valid_roi_all``:越出 ROI 的暗区多边形,越界部分不计入统计。
给某帧一个跨越 ROI 右边界(x=110)外延的暗区多边形。若未收口到 ROI 内,
``dark_area_px`` 会含 ROI 外像素;收口后暗区面积应等于该暗块与 ROI 的交集。
"""
frames, roi_polygons, _, background = _build_inputs(frame_count=1)
# 暗块横跨 ROI 右界 x∈[100,140](ROI 右界 110),y∈[20,40] 落在 ROI 内 y 范围。
out_of_roi_dark = np.array(
[[100.0, 20.0], [140.0, 20.0], [140.0, 40.0], [100.0, 40.0]], dtype=np.float32
)
sample = _run(frames, roi_polygons, [[out_of_roi_dark]], background).rows[0]
# 暗块多边形宽 40,但仅 x∈[100,110] 落在 ROI 内(宽 10);收口后面积须 < 多边形整块。
full_dark_area = 40 * 20 # 多边形自身栅格面积上界
assert 0 < sample["dark_area_px"] < full_dark_area
# 收口后暗区不超过有效 ROI 像素数(不会从 ROI 外多剔像素)。
assert sample["dark_area_px"] <= sample["valid_roi_all_px"]
# roi_perf = valid_roi_all - 收口暗区,二者之差恰为收口后暗区面积。
assert sample["valid_roi_all_px"] - sample["valid_roi_perf_px"] == sample["dark_area_px"]
def test_compute_tic_dark_polygon_hole_not_filled_back() -> None:
"""证伪 even-odd 挖洞:暗区甜甜圈(外暗环 + 内亮洞)的洞不被计回暗区。
暗区多边形列表为 ``[外多边形, 洞多边形]``(B7 preserve_holes 扁平输出语义)。
若按「逐多边形栅格化再 OR」,洞会被当作独立暗块填回 → ``dark_area_px`` 含洞、
``roi_perf`` 偏小;正确的 even-odd 一次填充应把洞挖空。
"""
frames, roi_polygons, _, background = _build_inputs(frame_count=1)
# 外暗框 [65,15]-[105,45](全落 ROI x∈[60,110] / y∈[10,50] 内),内亮洞 [80,25]-[95,38]。
outer_dark = np.array(
[[65.0, 15.0], [105.0, 15.0], [105.0, 45.0], [65.0, 45.0]], dtype=np.float32
)
inner_hole = np.array(
[[80.0, 25.0], [95.0, 25.0], [95.0, 38.0], [80.0, 38.0]], dtype=np.float32
)
sample = _run(frames, roi_polygons, [[outer_dark, inner_hole]], background).rows[0]
outer_area = 40 * 30 # 外框栅格面积 1200
hole_area = 15 * 13 # 洞栅格面积 195
# 暗区不含洞:≈ 外框 − 洞(栅格边界放宽),严格小于把洞填回的「外框整块」。
assert sample["dark_area_px"] == pytest.approx(outer_area - hole_area, abs=120)
assert sample["dark_area_px"] < outer_area
# 洞内 ROI 像素仍算有效 ROI:roi_perf = valid_roi_all − 暗区(不含洞)。
assert sample["valid_roi_all_px"] - sample["valid_roi_perf_px"] == sample["dark_area_px"]
# 洞内像素确实回到 roi_perf:去暗后剩余像素 > 仅外框实心被剔时的剩余。
assert sample["valid_roi_perf_px"] > sample["valid_roi_all_px"] - outer_area
def test_compute_tic_dark_component_count_self_consistent() -> None:
"""暗块计数与暗区面积自洽:有暗块时计数=多边形数;面积为 0 时计数强制归 0。
第 0 帧给两个 ROI 内暗块 → count=2;第 1 帧暗块完全落在 ROI 外(收口后面积 0)
→ count 必须归 0(修复 area=0 却 count>0 的不自洽)。
"""
frames, roi_polygons, _, background = _build_inputs(frame_count=2)
dark_a = np.array([[65.0, 15.0], [78.0, 15.0], [78.0, 30.0], [65.0, 30.0]], dtype=np.float32)
dark_b = np.array([[82.0, 32.0], [95.0, 32.0], [95.0, 45.0], [82.0, 45.0]], dtype=np.float32)
# 完全落在 ROI(x∈[60,110])右侧之外,收口后面积为 0。
fully_outside = np.array(
[[115.0, 15.0], [119.0, 15.0], [119.0, 30.0], [115.0, 30.0]], dtype=np.float32
)
rows = _run(frames, roi_polygons, [[dark_a, dark_b], [fully_outside]], background).rows
# 第 0 帧:两个 ROI 内暗块 → 面积 > 0、计数 = 2。
assert rows[0]["dark_area_px"] > 0
assert rows[0]["dark_component_count"] == 2
# 第 1 帧:暗块全在 ROI 外 → 收口面积 0、计数强制归 0(自洽)。
assert rows[1]["dark_area_px"] == 0
assert rows[1]["dark_component_count"] == 0
+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"}))
+230
View File
@@ -0,0 +1,230 @@
"""约束 Lognormal TIC 拟合的纯算法单元测试(照搬旧项目 test_tic_fitting.py)。"""
import numpy as np
import pytest
from cadx.ceus import (
CeusConfig,
LognormalParameters,
TicFittingConfig,
evaluate_lognormal,
fit_lognormal_curve,
)
from cadx.ceus._tic_fitting import _select_primary_peak # pyright: ignore[reportPrivateUsage]
from conftest import load_ceus_section
# 配置不设默认值:旧测试靠默认值构造的配置,改为在 config/ceus.yaml 的取值上 model_copy。
_CONFIG = CeusConfig.model_validate(load_ceus_section())
def _fit(
times: np.ndarray,
values: np.ndarray,
*,
duplicates: np.ndarray | None = None,
baseline: float | None = 30.0,
noise: float | None = 0.2,
cfg: TicFittingConfig | None = None,
):
return fit_lognormal_curve(
times,
values,
baseline=baseline,
baseline_noise=noise,
baseline_end_index=20,
duplicate_mask=(np.zeros(times.size, dtype=bool) if duplicates is None else duplicates),
sample_mask=None,
cfg=cfg or _CONFIG.tic_analysis.fitting,
anchor_cfg=_CONFIG.tic_analysis.anchor_frames,
)
def test_recovers_clean_lognormal_and_continuous_anchors() -> None:
times = np.linspace(0.0, 60.0, 601)
truth = LognormalParameters(
area=400.0,
mu=2.2,
sigma=0.5,
arrival_offset_s=8.0,
baseline=30.0,
)
result = _fit(times, evaluate_lognormal(times, truth))
assert result.success is True
assert result.parameters is not None
assert result.r2 == pytest.approx(1.0, abs=1e-8)
assert result.parameters.arrival_offset_s == pytest.approx(8.0, abs=0.05)
assert result.parameters.sigma == pytest.approx(0.5, abs=0.02)
assert result.d1_time_s is not None
assert result.d1_time_s < result.d2_time_s < result.d3_time_s < result.d4_time_s # pyright: ignore[reportOperatorIssue]
np.testing.assert_allclose(result.processed, result.fitted)
def test_soft_l1_fit_survives_single_compression_spike() -> None:
times = np.linspace(0.0, 60.0, 601)
truth = LognormalParameters(400.0, 2.2, 0.5, 8.0, 30.0)
values = evaluate_lognormal(times, truth)
values[180] += 80.0
result = _fit(times, values)
assert result.success is True
assert result.parameters is not None
assert result.d3_time_s == pytest.approx(
truth.arrival_offset_s + np.exp(truth.mu - truth.sigma**2), abs=0.25
)
def test_duplicate_samples_are_excluded_but_keep_original_mapping() -> None:
times = np.linspace(0.0, 60.0, 601)
truth = LognormalParameters(400.0, 2.2, 0.5, 8.0, 30.0)
values = evaluate_lognormal(times, truth)
duplicates = np.zeros(times.size, dtype=bool)
duplicates[100:102] = True
result = _fit(times, values, duplicates=duplicates)
assert result.success is True
np.testing.assert_array_equal(result.duplicate_sample_mask, duplicates)
assert not result.valid_sample_mask[100:102].any()
assert result.processed.shape == values.shape
def test_missing_observed_baseline_estimates_prior_but_rejects_flat_curve() -> None:
times = np.linspace(0.0, 60.0, 601)
values = np.full(times.size, 30.0)
result = _fit(times, values, baseline=None, noise=None)
assert result.success is False
assert result.failure_reason == "credible_peak_not_found"
assert result.baseline_source == "estimated"
assert np.isnan(result.processed).all()
assert np.isnan(result.fitted).all()
def test_left_truncated_curve_uses_estimated_baseline_and_still_fits() -> None:
times = np.linspace(0.0, 50.0, 501)
truth = LognormalParameters(700.0, 2.7, 0.65, -4.0, 25.0)
result = _fit(
times,
evaluate_lognormal(times, truth),
baseline=None,
noise=None,
)
assert result.success is True
assert result.baseline_source == "estimated"
assert result.parameters is not None
assert result.d3_time_s is not None
def test_explicit_sample_mask_excludes_unstable_perf_frame() -> None:
times = np.linspace(0.0, 60.0, 601)
truth = LognormalParameters(400.0, 2.2, 0.5, 8.0, 30.0)
values = evaluate_lognormal(times, truth)
values[180] += 100.0
sample_mask = np.ones(times.size, dtype=bool)
sample_mask[180] = False
result = fit_lognormal_curve(
times,
values,
baseline=30.0,
baseline_noise=0.2,
baseline_end_index=20,
duplicate_mask=np.zeros(times.size, dtype=bool),
sample_mask=sample_mask,
cfg=_CONFIG.tic_analysis.fitting,
anchor_cfg=_CONFIG.tic_analysis.anchor_frames,
)
assert result.success is True
assert result.valid_sample_mask[180] is np.False_
def test_quality_failure_keeps_rejected_fit_separate_from_processed() -> None:
rng = np.random.default_rng(9)
times = np.linspace(0.0, 60.0, 601)
truth = LognormalParameters(160.0, 2.0, 0.55, 8.0, 30.0)
values = evaluate_lognormal(times, truth) + rng.normal(0.0, 18.0, times.size)
cfg = _CONFIG.tic_analysis.fitting.model_copy(update={"minimum_r2": 0.95, "maximum_nrmse": 0.1})
result = _fit(times, values, noise=1.0, cfg=cfg)
assert result.success is False
assert result.failure_reason in {
"fit_quality_failed",
"credible_peak_not_found",
"model_peak_on_boundary",
"parameter_on_boundary",
}
assert np.isnan(result.processed).all()
if result.failure_reason in {
"fit_quality_failed",
"model_peak_on_boundary",
"parameter_on_boundary",
}:
assert result.parameters is not None
assert np.isfinite(result.fitted).all()
assert np.isfinite(result.r2)
assert np.isfinite(result.nrmse)
def test_primary_peak_uses_local_finite_run_and_rejects_single_sample_spike() -> None:
grid = np.arange(12.0)
candidate = np.asarray([0.0, 0.0, 10.0, 0.0, np.nan, np.nan, 0.0, 2.5, 5.0, 2.5, 0.0, 0.0])
selected = _select_primary_peak([2, 8], grid, candidate, baseline=0.0)
assert selected == 8
def test_two_boluses_mark_multi_peak_and_fit_first_pass() -> None:
times = np.linspace(0.0, 70.0, 701)
first = evaluate_lognormal(times, LognormalParameters(300.0, 2.0, 0.4, 5.0, 30.0))
second = evaluate_lognormal(times, LognormalParameters(260.0, 1.8, 0.4, 28.0, 0.0))
result = _fit(times, first + second)
assert result.multi_peak_flag is True
assert result.fit_end_s < 34.0
def test_repeated_shallow_motion_peaks_do_not_truncate_first_bolus() -> None:
times = np.linspace(0.0, 70.0, 701)
truth = LognormalParameters(400.0, 2.4, 0.75, 5.0, 30.0)
broad_bolus = evaluate_lognormal(times, truth)
motion = np.where(times > 8.0, 2.0 * np.sin(times * 2.5), 0.0)
result = _fit(times, broad_bolus + motion)
assert result.multi_peak_flag is False
assert result.fit_end_s == pytest.approx(times[-1])
assert result.success is True
def test_truncated_washout_does_not_use_fit_boundary_as_d4() -> None:
full_times = np.linspace(0.0, 60.0, 601)
truth = LognormalParameters(400.0, 2.2, 0.5, 8.0, 30.0)
full_values = evaluate_lognormal(full_times, truth)
keep = full_times <= 17.0
result = _fit(full_times[keep], full_values[keep])
assert result.success is True
assert result.d3_time_s is not None
assert result.d4_time_s is None
def test_narrow_bolus_keeps_analytic_d4_in_long_video() -> None:
times = np.linspace(0.0, 90.0, 901)
truth = LognormalParameters(400.0, 2.2, 0.15, 8.0, 30.0)
result = _fit(times, evaluate_lognormal(times, truth))
sigma_squared = truth.sigma**2
wash_out_factor = (sigma_squared - np.sqrt(sigma_squared**2 + 4.0 * sigma_squared)) / 2.0
expected_d4 = truth.arrival_offset_s + np.exp(truth.mu - sigma_squared - wash_out_factor)
assert result.success is True
assert result.d4_time_s == pytest.approx(expected_d4, abs=0.05)
+313
View File
@@ -0,0 +1,313 @@
"""TIC 折线图单元测试:缺测不许画成零(照搬旧项目 test_ceus_tic_plot.py)。"""
from typing import Any
import cv2
import numpy as np
import pytest
from matplotlib.axes import Axes
from cadx.ceus import (
CURVE_NAMES,
BaselineResult,
LognormalFitResult,
TicPlotMarker,
TicResult,
render_tic_png,
)
_ROI_ALL_BGR = (184, 99, 37)
_ROI_PERF_BGR = (0, 103, 212)
_ROI_PERF_RAW_BGR = (194, 219, 245)
_ROI_PAN_BGR = (237, 58, 124)
_ROI_PAN_RAW_BGR = (251, 208, 224)
_MANUAL_AT_BGR = (30, 30, 220)
_FRAME_COUNT = 21
# 去暗区曲线中间这一段没采到(NaN),落在时间轴 35%~65% 之间。
_GAP = range(7, 14)
def _invalid_baselines() -> dict[str, BaselineResult]:
"""四条曲线都给一个无效基线——这张图只读 ``rows``,基线怎么样它不关心。"""
return {
name: BaselineResult(mean=None, sd=None, frame_start=-1, frame_end=-1, valid=False)
for name in CURVE_NAMES
}
def _result(
*,
perf_all_nan: bool,
perf_gap: bool,
pan_available: bool = False,
alternate_background: bool = False,
perf_fit_success: bool | None = None,
pan_fit_success: bool | None = None,
pan_fitted_all_nan: bool = False,
perf_processed_value: float | None = None,
perf_singleton: bool = False,
) -> TicResult:
"""造一份 ``roi_perf`` 可缺失或断开的 ``TicResult``。"""
rows: list[dict[str, Any]] = []
for index in range(_FRAME_COUNT):
perf = float("nan") if perf_all_nan else 90.0 + 10.0 * (index % 2)
if perf_singleton:
perf = 95.0 if index == _FRAME_COUNT // 2 else float("nan")
if perf_gap and index in _GAP:
perf = float("nan")
values = {
"roi_all": 10.0 * index,
"roi_perf": perf,
"roi_pan": 180.0 + index if pan_available else float("nan"),
"background_circle": (150.0 - index if alternate_background else 40.0 + index),
}
row: dict[str, Any] = {"frame_index": index, "time_sec": float(index)}
for name, value in values.items():
row[f"{name}_raw"] = value
row[f"{name}_processed"] = (
perf_processed_value
if name == "roi_perf" and perf_processed_value is not None
else value
)
rows.append(row)
fits: dict[str, LognormalFitResult] = {}
for name in CURVE_NAMES:
values = np.asarray([row[f"{name}_processed"] for row in rows], dtype=float)
success = bool(np.isfinite(values).any())
if name == "roi_perf" and perf_fit_success is not None:
success = perf_fit_success
if name == "roi_pan" and pan_fit_success is not None:
success = pan_fit_success
fitted = values.copy()
if name == "roi_pan" and pan_fitted_all_nan:
fitted[:] = np.nan
fits[name] = LognormalFitResult(
success=success,
failure_reason="" if success else "no_data",
algorithm_version="lognormal-v2",
curve_model="lognormal",
smoothing_method="",
baseline_source="observed",
candidate=values.copy(),
fitted=fitted,
processed=values.copy(),
valid_sample_mask=np.isfinite(values),
duplicate_sample_mask=np.zeros(_FRAME_COUNT, dtype=bool),
parameters=None,
r2=float("nan"),
nrmse=float("nan"),
residual_noise_ratio=float("nan"),
fit_start_s=0.0,
fit_end_s=float(_FRAME_COUNT - 1),
multi_peak_flag=False,
d1_time_s=None,
d2_time_s=None,
d3_time_s=None,
d4_time_s=None,
)
return TicResult(
rows=rows,
parameters=None,
baselines=_invalid_baselines(),
fits=fits,
early_truncation_flag=False,
reference_valid=pan_available,
pan_detected_fraction=1.0 if pan_available else 0.0,
)
def _decode(png: bytes) -> np.ndarray:
canvas = cv2.imdecode(np.frombuffer(png, dtype=np.uint8), cv2.IMREAD_COLOR)
assert canvas is not None, "渲染出来的字节解不回一张图"
return canvas
def _render(*, perf_all_nan: bool, perf_gap: bool) -> np.ndarray:
return _decode(
render_tic_png(_result(perf_all_nan=perf_all_nan, perf_gap=perf_gap), markers=())
)
def _color_pixels(canvas: np.ndarray, color: tuple[int, int, int]) -> np.ndarray:
difference = np.abs(canvas.astype(np.int16) - np.asarray(color, dtype=np.int16))
return np.all(difference <= 8, axis=2)
def test_all_nan_curve_is_not_drawn_at_zero() -> None:
"""``roi_perf`` 整条 NaN 时不画;同一条有数据时画得出来。
第二句是要害:只断言「没画」的话,一个「什么都不画」的实现照样能过。
"""
absent = _color_pixels(_render(perf_all_nan=True, perf_gap=False), _ROI_PERF_BGR)
present = _color_pixels(_render(perf_all_nan=False, perf_gap=False), _ROI_PERF_BGR)
assert int(absent.sum()) == 0, "整条 NaN 的曲线被画出来了——多半是被当成了 0"
assert int(present.sum()) > 0, "同一条曲线有数据时反而没画出来"
def test_nan_gap_breaks_the_polyline_instead_of_dipping_to_zero() -> None:
canvas = _render(perf_all_nan=False, perf_gap=True)
perf_pixels = _color_pixels(canvas, _ROI_PERF_BGR)
height, width = canvas.shape[:2]
inside_gap = perf_pixels[
int(0.08 * height) : int(0.62 * height),
int(0.45 * width) : int(0.55 * width),
]
assert int(inside_gap.sum()) == 0, "缺口那一段仍有去暗区曲线像素——折线没有断开"
assert int(perf_pixels.sum()) > 0, "整条曲线都没画出来——断开断过头了"
def test_curves_with_data_are_still_drawn() -> None:
"""有数据的曲线照画不误(防止「遇 NaN 就整条放弃」)。"""
canvas = _render(perf_all_nan=True, perf_gap=True)
assert int(_color_pixels(canvas, _ROI_ALL_BGR).sum()) > 0
def test_isolated_raw_sample_is_visible() -> None:
result = _result(
perf_all_nan=True,
perf_gap=False,
perf_fit_success=False,
perf_singleton=True,
)
canvas = _decode(render_tic_png(result, markers=()))
height, width = canvas.shape[:2]
plot_center = _color_pixels(canvas, _ROI_PERF_RAW_BGR)[
int(0.2 * height) : int(0.65 * height),
int(0.45 * width) : int(0.55 * width),
]
assert int(plot_center.sum()) > 0
def test_rejected_fit_keeps_raw_but_hides_processed_curve() -> None:
result = _result(
perf_all_nan=False,
perf_gap=False,
perf_fit_success=False,
perf_processed_value=170.0,
)
canvas = _decode(render_tic_png(result, markers=()))
assert int(_color_pixels(canvas, _ROI_PERF_RAW_BGR).sum()) > 0
assert int(_color_pixels(canvas, _ROI_PERF_BGR).sum()) == 0
def test_pan_reference_raw_and_fit_are_drawn_when_available() -> None:
result = _result(
perf_all_nan=False,
perf_gap=False,
pan_available=True,
)
canvas = _decode(render_tic_png(result, markers=()))
assert int(_color_pixels(canvas, _ROI_PAN_RAW_BGR).sum()) > 0
assert int(_color_pixels(canvas, _ROI_PAN_BGR).sum()) > 0
def test_missing_pan_reference_is_not_drawn_at_zero() -> None:
canvas = _decode(render_tic_png(_result(perf_all_nan=False, perf_gap=False), markers=()))
assert int(_color_pixels(canvas, _ROI_PAN_RAW_BGR).sum()) == 0
assert int(_color_pixels(canvas, _ROI_PAN_BGR).sum()) == 0
def test_rejected_pan_fit_draws_diagnostic_fitted_curve() -> None:
result = _result(
perf_all_nan=False,
perf_gap=False,
pan_available=True,
pan_fit_success=False,
)
canvas = _decode(render_tic_png(result, markers=()))
assert int(_color_pixels(canvas, _ROI_PAN_RAW_BGR).sum()) > 0
assert int(_color_pixels(canvas, _ROI_PAN_BGR).sum()) > 0
def test_rejected_pan_fit_is_dashed_and_labeled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
plotted_styles: list[dict[str, Any]] = []
original_plot = Axes.plot # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType]
def capture_plot(self: Axes, *args: Any, **kwargs: Any) -> Any:
plotted_styles.append(kwargs)
return original_plot(self, *args, **kwargs)
monkeypatch.setattr(Axes, "plot", capture_plot)
render_tic_png(
_result(
perf_all_nan=False,
perf_gap=False,
pan_available=True,
pan_fit_success=False,
),
markers=(),
)
reference_fit = next(
style
for style in plotted_styles
if style.get("label") == "roi_pan reference Lognormal fit [QC rejected]"
)
assert reference_fit["linestyle"] == "--"
def test_rejected_pan_fit_without_finite_model_does_not_fabricate_line() -> None:
result = _result(
perf_all_nan=False,
perf_gap=False,
pan_available=True,
pan_fit_success=False,
pan_fitted_all_nan=True,
)
canvas = _decode(render_tic_png(result, markers=()))
assert int(_color_pixels(canvas, _ROI_PAN_RAW_BGR).sum()) > 0
assert int(_color_pixels(canvas, _ROI_PAN_BGR).sum()) == 0
def test_background_does_not_change_tic_plot() -> None:
regular = _result(perf_all_nan=False, perf_gap=False)
changed = _result(
perf_all_nan=False,
perf_gap=False,
alternate_background=True,
)
regular_canvas = _decode(render_tic_png(regular, markers=()))
changed_canvas = _decode(render_tic_png(changed, markers=()))
np.testing.assert_array_equal(regular_canvas, changed_canvas)
def test_plot_uses_review_ready_canvas_size() -> None:
canvas = _render(perf_all_nan=False, perf_gap=False)
assert canvas.shape == (800, 1600, 3)
def test_review_marker_is_drawn_without_changing_curve_data() -> None:
result = _result(perf_all_nan=False, perf_gap=False)
png = render_tic_png(
result,
markers=(TicPlotMarker("manual AT", 10.0, _MANUAL_AT_BGR),),
)
canvas = _decode(png)
assert int(_color_pixels(canvas, _MANUAL_AT_BGR).sum()) > 0
assert int(_color_pixels(canvas, _ROI_PERF_BGR).sum()) > 0
def test_marker_outside_observed_time_is_not_attached_to_boundary() -> None:
result = _result(perf_all_nan=False, perf_gap=False)
png = render_tic_png(
result,
markers=(TicPlotMarker("manual AT", 100.0, _MANUAL_AT_BGR),),
)
canvas = _decode(png)
assert int(_color_pixels(canvas, _MANUAL_AT_BGR).sum()) == 0
+260
View File
@@ -0,0 +1,260 @@
"""trajectory npz 打包/解包单元测试(照搬旧项目 test_ceus_trajectory.py)。
旧标题:B11 trajectory npz 打包/解包单元测试。
钉死轨迹工件的单一 schema:完整往返还原全部键(沿用参考 ``write_roi_trajectory_npz``
键集 + 新增 ``keyframe_indices`` / ``dark_polygons`` / ``calibration_json``)、
各键 dtype 精确(int32 / float32 / uint8 / str)、变长 ``dark_polygons``(逐帧
不同数量多边形)与 ``keyframe_indices`` 往返、``reference_region`` / ``calibration``
dict 往返,以及防御:坏字节解包 raise、形状不一致 pack raise、禁 pickle
(含 object pickle 的恶意 npz 解包被拒)。测试用真实数据的二次构造(合成多边形 /
掩膜),边缘场景靠实现里的 assertion/raise 保证。
"""
import io
import pickle
import numpy as np
import pytest
from cadx.ceus import (
Trajectory,
ViewRect,
pack_trajectory,
unpack_trajectory,
)
# Phase 1: 构造一个真实形态的 Trajectory(二次构造的合成几何)
def _make_polygon(offset: float) -> np.ndarray:
"""构造 (64, 2) float32 闭合多边形(单位圆平移),与重采样输出形态一致。"""
angles = np.linspace(0.0, 2.0 * np.pi, 64, endpoint=False)
points = np.stack([np.cos(angles), np.sin(angles)], axis=1) * 10.0 + offset
return points.astype(np.float32)
def _make_trajectory() -> Trajectory:
num_frames = 3
target_polygons = [_make_polygon(float(i)) for i in range(num_frames)]
source_polygons = [_make_polygon(float(i) + 100.0) for i in range(num_frames)]
# 逐帧暗区多边形数量不同:0 个 / 1 个 / 2 个(变长嵌套)
dark_polygons: list[list[np.ndarray]] = [
[],
[_make_polygon(5.0)],
[_make_polygon(6.0), _make_polygon(7.0)],
]
reference_mask = np.zeros((20, 30), dtype=np.uint8)
reference_mask[5:10, 5:15] = 1
return Trajectory(
frame_indices=np.arange(num_frames, dtype=np.int32),
fps=29.97,
target_polygons=np.asarray(target_polygons, dtype=np.float32),
source_polygons=np.asarray(source_polygons, dtype=np.float32),
source_view_rect=ViewRect(0, 0, 100, 200),
target_view_rect=ViewRect(100, 0, 200, 200),
source_effective_rect=ViewRect(5, 5, 95, 195),
target_effective_rect=ViewRect(105, 5, 195, 195),
source_polygon_coordinate_space="source_view_local",
target_polygon_coordinate_space="target_effective_view_local",
reference_mask=reference_mask,
reference_region={"frame_index": 1, "area": 50.0, "source": "auto"},
keyframe_indices=np.asarray([1, 2], dtype=np.int32),
dark_polygons=dark_polygons,
calibration={
"source": "config/ceus.yaml",
"config_hash": "abc123",
"cli_override": False,
"detected_split_x": 100,
},
)
# Phase 2: 完整往返 —— 全部键还原
def test_roundtrip_preserves_all_keys() -> None:
trajectory = _make_trajectory()
restored = unpack_trajectory(pack_trajectory(trajectory))
np.testing.assert_array_equal(restored.frame_indices, trajectory.frame_indices)
assert restored.fps == pytest.approx(trajectory.fps)
np.testing.assert_allclose(restored.target_polygons, trajectory.target_polygons)
np.testing.assert_allclose(restored.source_polygons, trajectory.source_polygons)
assert restored.source_view_rect == trajectory.source_view_rect
assert restored.target_view_rect == trajectory.target_view_rect
assert restored.source_effective_rect == trajectory.source_effective_rect
assert restored.target_effective_rect == trajectory.target_effective_rect
assert restored.source_polygon_coordinate_space == trajectory.source_polygon_coordinate_space
assert restored.target_polygon_coordinate_space == trajectory.target_polygon_coordinate_space
np.testing.assert_array_equal(restored.reference_mask, trajectory.reference_mask)
assert restored.reference_region == trajectory.reference_region
np.testing.assert_array_equal(restored.keyframe_indices, trajectory.keyframe_indices)
assert restored.calibration == trajectory.calibration
# Phase 3: dtype 精确
def test_roundtrip_dtypes_are_exact() -> None:
restored = unpack_trajectory(pack_trajectory(_make_trajectory()))
assert restored.frame_indices.dtype == np.int32
assert restored.target_polygons.dtype == np.float32
assert restored.source_polygons.dtype == np.float32
assert restored.reference_mask.dtype == np.uint8
assert restored.keyframe_indices.dtype == np.int32
assert isinstance(restored.fps, float)
assert isinstance(restored.source_polygon_coordinate_space, str)
assert isinstance(restored.target_polygon_coordinate_space, str)
def test_polygon_shapes_are_n_64_2() -> None:
restored = unpack_trajectory(pack_trajectory(_make_trajectory()))
assert restored.target_polygons.shape == (3, 64, 2)
assert restored.source_polygons.shape == (3, 64, 2)
# Phase 3b: 存档层 schema —— 直接在 npz 原始存档上断言键名 / dtype / shape
# (对象层往返不足以守住 pack 的存储 schema;pack 若 schema 漂移须在此被发现)
def test_archive_schema_keys_dtypes_shapes() -> None:
"""打开 pack 产出的原始 npz 存档,逐一断言键集 / 存储 dtype / shape。
与对象层往返互补:往返只验 unpack(pack(x))==x,无法发现 pack 写入存档时的
dtype / shape 漂移(如 fps 被写成 float64、polygons 退化)。此处用
``allow_pickle=False`` 直接读取存档,钉死存储 schema。
"""
archive_bytes = pack_trajectory(_make_trajectory())
with np.load(io.BytesIO(archive_bytes), allow_pickle=False) as archive:
# 键集合 == 全部必要键(无多无少)
expected_keys = {
"frame_indices",
"fps",
"target_polygons",
"source_polygons",
"source_view_rect",
"target_view_rect",
"source_effective_rect",
"target_effective_rect",
"source_polygon_coordinate_space",
"target_polygon_coordinate_space",
"reference_mask",
"reference_region_json",
"keyframe_indices",
"dark_polygons",
"calibration_json",
}
assert set(archive.files) == expected_keys
# 存档内 dtype 精确(存储 dtype 是 schema 的一部分)
assert archive["frame_indices"].dtype == np.int32
assert archive["keyframe_indices"].dtype == np.int32
assert archive["target_polygons"].dtype == np.float32
assert archive["source_polygons"].dtype == np.float32
# fps 存档 dtype 必须 float32(即使 in-memory unpack 还原为 Python float)
assert archive["fps"].dtype == np.float32
for rect_key in (
"source_view_rect",
"target_view_rect",
"source_effective_rect",
"target_effective_rect",
):
assert archive[rect_key].dtype == np.int32
assert archive["reference_mask"].dtype == np.uint8
# shape 精确
assert archive["target_polygons"].shape == (3, 64, 2)
assert archive["source_polygons"].shape == (3, 64, 2)
assert archive["frame_indices"].shape == (3,)
for rect_key in (
"source_view_rect",
"target_view_rect",
"source_effective_rect",
"target_effective_rect",
):
assert archive[rect_key].shape == (4,)
assert archive["reference_mask"].ndim == 2
# Phase 4: 变长 dark_polygons 往返
def test_roundtrip_variable_length_dark_polygons() -> None:
trajectory = _make_trajectory()
restored = unpack_trajectory(pack_trajectory(trajectory))
assert len(restored.dark_polygons) == 3
assert len(restored.dark_polygons[0]) == 0
assert len(restored.dark_polygons[1]) == 1
assert len(restored.dark_polygons[2]) == 2
for original_frame, restored_frame in zip(
trajectory.dark_polygons, restored.dark_polygons, strict=True
):
for original_poly, restored_poly in zip(original_frame, restored_frame, strict=True):
assert isinstance(restored_poly, np.ndarray)
assert restored_poly.dtype == np.float32
np.testing.assert_allclose(restored_poly, original_poly)
def test_roundtrip_empty_dark_polygons() -> None:
trajectory = _make_trajectory()
trajectory.dark_polygons = [[], [], []]
restored = unpack_trajectory(pack_trajectory(trajectory))
assert restored.dark_polygons == [[], [], []]
# Phase 5: 防御 —— 坏字节 / 形状不一致 / 禁 pickle
def test_unpack_bad_bytes_raises() -> None:
with pytest.raises((ValueError, OSError)):
unpack_trajectory(b"this-is-not-a-valid-npz-archive")
def test_pack_rejects_mismatched_frame_count() -> None:
trajectory = _make_trajectory()
# frame_indices 长度与多边形帧数不一致
trajectory.frame_indices = np.arange(5, dtype=np.int32)
with pytest.raises(ValueError):
pack_trajectory(trajectory)
def test_pack_rejects_wrong_polygon_shape() -> None:
trajectory = _make_trajectory()
# 把 target_polygons 改成 (N, 32, 2),违反 (N, 64, 2)
trajectory.target_polygons = np.zeros((3, 32, 2), dtype=np.float32)
with pytest.raises(ValueError):
pack_trajectory(trajectory)
def test_pack_rejects_non_2d_reference_mask() -> None:
trajectory = _make_trajectory()
trajectory.reference_mask = np.zeros((20, 30, 3), dtype=np.uint8)
with pytest.raises(ValueError):
pack_trajectory(trajectory)
def test_unpack_rejects_pickled_object_payload() -> None:
"""构造真实含 object pickle 的恶意 .npz(须 allow_pickle 才能 save/load),解包须被拒。
关键:用 ``np.savez_compressed`` 生成真正的 .npz 存档(而非 .npy),其中含一个
object dtype 数组(背后是 pickle payload)。这覆盖了「合法 .npz 容器、但成员需
pickle 反序列化」的注入面——``unpack_trajectory`` 内部 ``allow_pickle=False`` 必须
在访问该成员时 raise,绝不静默反序列化执行。
"""
buffer = io.BytesIO()
# 把 object dtype 数组放进 unpack 首先访问的必要键 frame_indices,确保命中
# allow_pickle=False 对 object 成员的拒绝路径(而非仅缺键 KeyError)。
# object dtype 数组在 savez 时即以 pickle 存储,构造真实 .npz 字节。
np.savez_compressed(buffer, frame_indices=np.array([{"evil": 1}], dtype=object))
raw = buffer.getvalue()
# 访问 frame_indices 这一 object 成员时,allow_pickle=False 必须 raise(ValueError)
with pytest.raises((ValueError, OSError, KeyError)):
unpack_trajectory(raw)
def test_pickle_payload_cannot_smuggle_through_unpack() -> None:
"""即便 pickle 字节伪装,unpack 内部 allow_pickle=False 也不得执行反序列化。"""
payload = pickle.dumps({"evil": "code"})
with pytest.raises((ValueError, OSError, KeyError)):
unpack_trajectory(payload)
+95
View File
@@ -0,0 +1,95 @@
"""segment:分割设置的校验,以及在合成小视频上真跑一次 MedSAM2(`gpu`)。"""
from pathlib import Path
from typing import Any, cast
import cv2
import numpy as np
import numpy.typing as npt
import pytest
import yaml
from pydantic import ValidationError
from cadx.segment import SegmentConfig, medsam2_commit, segment_video
REPO_ROOT = Path(__file__).resolve().parents[1]
CEUS_YAML = REPO_ROOT / "config" / "ceus.yaml"
def _segment_section() -> dict[str, Any]:
document = cast(dict[str, Any], yaml.safe_load(CEUS_YAML.read_text(encoding="utf-8")))
return cast(dict[str, Any], document["segment"])
def _absolute(section: dict[str, Any]) -> dict[str, Any]:
"""路径字段换成仓库根目录下的绝对路径(脚本也这样做)。"""
return {
**section,
"medsam2_dir": str(REPO_ROOT / section["medsam2_dir"]),
"checkpoint": str(REPO_ROOT / section["checkpoint"]),
}
def test_config_yaml_section_validates() -> None:
cfg = SegmentConfig.model_validate(_segment_section())
assert cfg.sam2_config == "configs/sam2.1_hiera_t512.yaml"
@pytest.mark.parametrize(
"field",
[
"medsam2_dir",
"checkpoint",
"sam2_config",
"device",
"offload_video_to_cpu",
"offload_state_to_cpu",
],
)
def test_config_missing_field_raises(field: str) -> None:
section = _segment_section()
del section[field]
with pytest.raises(ValidationError, match=field):
SegmentConfig.model_validate(section)
def test_config_extra_field_raises() -> None:
with pytest.raises(ValidationError, match="object_id"):
SegmentConfig.model_validate({**_segment_section(), "object_id": 1})
def test_config_bool_not_coerced() -> None:
with pytest.raises(ValidationError, match="offload_video_to_cpu"):
SegmentConfig.model_validate({**_segment_section(), "offload_video_to_cpu": "false"})
def _moving_disc_video(
frame_count: int, height: int, width: int, radius: int
) -> tuple[list[npt.NDArray[np.uint8]], list[npt.NDArray[np.bool_]]]:
"""暗背景上一个亮圆从左往右匀速移动;交出逐帧 BGR 图像和每帧圆的真值掩码。"""
frames: list[npt.NDArray[np.uint8]] = []
truths: list[npt.NDArray[np.bool_]] = []
yy, xx = np.mgrid[0:height, 0:width]
for index in range(frame_count):
cx = radius + 10 + index * 2
cy = height // 2
frame = np.full((height, width, 3), 30, dtype=np.uint8)
cv2.circle(frame, (cx, cy), radius, (220, 220, 220), thickness=-1)
frames.append(frame)
truths.append((xx - cx) ** 2 + (yy - cy) ** 2 <= radius**2)
return frames, truths
@pytest.mark.gpu
def test_segment_video_on_synthetic_clip() -> None:
cfg = SegmentConfig.model_validate(_absolute(_segment_section()))
frame_count, height, width = 40, 120, 200
frames, truths = _moving_disc_video(frame_count, height, width, radius=18)
# 两个提示帧,最早的不在第 0 帧,前向和反向传播都要走到。
prompt_masks = {5: truths[5], 25: truths[25]}
masks = segment_video(frames, prompt_masks, cfg)
assert masks.shape == (frame_count, height, width)
assert masks.dtype == np.bool_
assert len(medsam2_commit(cfg.medsam2_dir)) == 40