feat(ceus,segment): 任务书 02 第二阶段——分割和后半段算法
- ceus 迁入 polygons、dark_region、reference_region、tic_fitting、tic、anchor_frames、 perfusion_metrics、trajectory、tic_curves、tic_plot、overlay,旧测试照搬(改 import 与配置读取)。 - 对外两段接口:prepare_segmentation(第 1–2 步)与 quantify_case(第 4–9 步)。 - 第 3 条:删掉 D1 取医生标注时刻的模式,D1 只从拟合曲线算。 - 第 4 条:F1/F2 取视频第 20−X、60−X 秒(X 为视频第 0 秒时已推注的秒数),落在视频外记未定位。 - 绿框融合权重、紫圈颜色阈值照搬;关键帧容差窗口的出处写进 ceus.yaml 注释。 - segment:同一进程调用 MedSAM2,设置写进 ceus.yaml 的 segment 段,全部必填;gpu 测试真跑通过。 - 依赖加 scipy、matplotlib、medsam2(可编辑安装,不编译 CUDA 扩展);uv 只解析 linux x86_64。 - 导入契约:只有 segment 引用 torch、sam2;segment 不引用其他 cadx 模块。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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
|
||||
@@ -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], "洞多边形面积应显著小于外轮廓"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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。"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"}))
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user