Files
CADx-4/tests/test_ceus_config.py
T
iomgaa 9a916c65a5 feat(ceus): 任务书 02 第一阶段——底层和分割之前的算法;修复 C14
任务书 01 遗留修复(C14):
- library 新增 check_key_image(path):关键图能逐像素保真重编码时返回 None,否则返回原因;
  _write_clean_png 的判断移过去,自身保留断言性质报错(走到这里说明 build 漏了检查)。
- build 写库前对每例调用,返回原因的整例跳过,记 C14「关键图能原样重编码」,涉及值写原因。
- 原来三条「关键图格式不对整次构建停下」的测试改为整例跳过,另加非 PNG、library 漏检报错两条。
- 结构.md 第八部分、guides/装配两个库.md 相应几行改掉。

第一阶段(src/cadx/ceus/):
- 照搬旧 geometry、video_decoder、research_config、split_screen、green_prompt、prompt_frames、
  mapping,去掉产品层,注释改中文。
- 配置类去掉全部默认值,strict、extra=forbid、frozen;新建 config/ceus.yaml(paths、ceus 两段),
  数值照抄旧 yaml,逐项注明出处。删去 source_view/target_view、effective_region、mapping 锚点;
  旧代码写死的分屏抽帧数 25 和搜索区间 (0.35, 0.65) 改成配置,值不变。
- 视频解码改为按路径用 cv2 逐帧读。
- 坐标换算改为两个视图矩形左上角之差,不再用按某种宽度写死的锚点。
- 造影画面在哪一半:两半各自选提示帧,恰好一半有才定,否则抛 ContrastSideError(CeusCaseError
  子类,带步号和原因,供第三阶段记 failed.csv)。
- 旧测试照搬(改 import 与配置读取方式),因上述改动不再成立的删去;新增两处修改的手算测试。
- import-linter 加「ceus 不引用其他 cadx 模块」。依赖加 numpy、opencv-python-headless。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 03:46:56 -04:00

397 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""CEUS ROI 科研配置 schema + loader 单元测试。
验证目标:
1. `load_tracking_config` 读默认 yaml 后返回 `TrackingConfig`,且关键默认值
等于 findings §2A/§3 的「yaml 生产值」(而非参考代码的保守默认)。
2. findings §5 硬约束生效(偶数 temporal_window / 面积比逆序 / percentile 越界报错)。
3. 子模型 `extra="forbid"` 拒绝未知键(防 typo 静默退化)。
4. findings §2B 死键不在 schema 内(混入即被 extra="forbid" 拒绝)。
5. design/0030 那几项:合成零点四键已删且残留即报错、`config_version` 必填、
`pan_source` 校验器、基线默认值等于规范 §3.3。
"""
from typing import Any
import pytest
from pydantic import ValidationError
from cadx.ceus import CeusConfig as TrackingConfig
from conftest import load_ceus_section
def _merge(base: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]:
"""把 payload 逐层盖到 base 上(只盖 payload 里写到的键)。"""
merged = dict(base)
for key, value in payload.items():
if isinstance(value, dict) and isinstance(merged.get(key), dict):
merged[key] = _merge(merged[key], value) # pyright: ignore[reportUnknownArgumentType]
else:
merged[key] = value
return merged
def _validate(payload: dict[str, Any]) -> TrackingConfig:
"""在 config/ceus.yaml 的 ceus 段上盖上 payload 再校验。
配置不设默认值、每个字段必填;旧测试只写自己关心的那几个键、其余靠默认值,
这里改为其余键取 config/ceus.yaml 的值。
"""
return TrackingConfig.model_validate(_merge(load_ceus_section(), payload))
def load_tracking_config() -> TrackingConfig:
"""读 config/ceus.yaml 的 ceus 段(旧测试读的是旧 yaml 整份文件)。"""
return TrackingConfig.model_validate(load_ceus_section())
def test_load_default_config_returns_tracking_config() -> None:
"""默认 yaml 应能加载为 TrackingConfig 实例。"""
cfg = load_tracking_config()
assert isinstance(cfg, TrackingConfig)
def test_parameter_boundary_fraction_cannot_overlap_both_sides() -> None:
with pytest.raises(ValidationError, match="parameter_boundary_fraction"):
_validate({"tic_analysis": {"fitting": {"parameter_boundary_fraction": 0.5}}})
def test_default_values_match_findings_production_values() -> None:
"""关键默认值须等于 findings §2A/§3 的 yaml 生产值,而非代码保守默认。"""
cfg = load_tracking_config()
# findings §2A:删键即静默退化的 14 项中的代表项
assert cfg.interior_dark_region.enabled is True # 非代码默认 False
assert cfg.interior_dark_region.analysis_view == "source" # 非 target
assert cfg.interior_dark_region.exclude_prompt_color is True # 非 False
assert cfg.interior_dark_region.threshold_method == "auto_ensemble" # 非 otsu
assert cfg.interior_dark_region.min_area == 300 # 非代码默认 64
assert cfg.interior_dark_region.min_area_ratio == 0.015 # 非 0.0
assert cfg.roi_shape.source == "prompt_prior" # 非代码默认 medsam2
assert cfg.constraints.keyframe_position.enabled is True # 非 False
# findings §3:其余生产值
assert cfg.keyframes.iou_threshold == 0.92
assert cfg.keyframes.min_gap_frames == 1
assert cfg.smoothing.temporal_window == 15
assert cfg.smoothing.contour_points == 64
assert cfg.roi_shape.min_area_ratio_to_prompt == 0.70
assert cfg.roi_shape.max_area_ratio_to_prompt == 1.25
# findings §2C/§3.8:暗部方法子参(yaml 未列、代码默认)
assert cfg.interior_dark_region.percentile == 45
assert cfg.interior_dark_region.adaptive_block_size == 31
# design/0044:两条正式病灶曲线使用 Lognormal v2;SG 只做候选检测。
assert cfg.tic_analysis.fitting.algorithm_version == "lognormal-v2"
assert cfg.tic_analysis.fitting.candidate_polyorder == 2
assert cfg.tic_analysis.fitting.multi_peak_minimum_separation_s == 5.0
assert cfg.tic_analysis.fitting.multi_peak_minimum_relative_height == 0.50
assert cfg.tic_analysis.fitting.multi_peak_maximum_valley_fraction == 0.25
assert cfg.tic_analysis.fitting.candidate_window_s == 1.5
assert cfg.tic_analysis.fitting.detect_duplicate_frames is True
def test_temporal_window_must_be_odd() -> None:
"""findings §5:smoothing.temporal_window 须为正奇数,偶数报错。"""
with pytest.raises(ValidationError):
_validate({"smoothing": {"temporal_window": 14}})
def test_adaptive_block_size_must_be_odd() -> None:
"""findings §5:adaptive_block_size 须为正奇数,偶数报错。"""
with pytest.raises(ValidationError):
_validate({"interior_dark_region": {"adaptive_block_size": 30}})
def test_area_ratio_min_must_be_less_than_max() -> None:
"""findings §5:min_area_ratio_to_prompt 须 < max,逆序报错。"""
with pytest.raises(ValidationError):
_validate(
{
"roi_shape": {
"min_area_ratio_to_prompt": 1.3,
"max_area_ratio_to_prompt": 1.25,
}
}
)
def test_percentile_must_be_within_bounds() -> None:
"""findings §5:暗部 percentile 须 ∈ [0, 100],越界报错。"""
with pytest.raises(ValidationError):
_validate({"interior_dark_region": {"percentile": 150}})
def test_keyframe_position_strength_clipped_to_unit_interval() -> None:
"""findings §5:constraints.keyframe_position.strength clip 到 [0, 1]。"""
cfg = _validate({"constraints": {"keyframe_position": {"strength": 1.8}}})
assert cfg.constraints.keyframe_position.strength == 1.0
cfg_neg = _validate({"constraints": {"keyframe_position": {"strength": -0.5}}})
assert cfg_neg.constraints.keyframe_position.strength == 0.0
def test_extra_keys_forbidden() -> None:
"""子模型 extra='forbid':未知键(含 typo / 死键)报错。"""
with pytest.raises(ValidationError):
_validate(
{"interior_dark_region": {"temporal_window": 9}} # findings §2B 死键
)
with pytest.raises(ValidationError):
_validate({"unknown_top_level_key": 1})
def test_reference_region_manual_requires_center_xy() -> None:
"""findings §3.11:reference_region.mode=manual 时 center_xy 必填,缺则报错。"""
with pytest.raises(ValidationError):
_validate({"tic_analysis": {"reference_region": {"mode": "manual"}}})
def test_reference_region_manual_with_center_xy_passes() -> None:
"""findings §3.11:manual 模式提供 center_xy 即合法。"""
cfg = _validate(
{"tic_analysis": {"reference_region": {"mode": "manual", "center_xy": [100.0, 200.0]}}}
)
assert cfg.tic_analysis.reference_region.mode == "manual"
assert cfg.tic_analysis.reference_region.center_xy == [100.0, 200.0]
def test_invalid_layout_rejected() -> None:
"""闭集枚举:非法 views.layout 应被 Literal 拒绝(防 typo 穿透)。"""
with pytest.raises(ValidationError):
_validate({"views": {"layout": "diagonal"}})
def test_invalid_threshold_method_rejected() -> None:
"""闭集枚举:非法 interior_dark_region.threshold_method 应被 Literal 拒绝。"""
with pytest.raises(ValidationError):
_validate({"interior_dark_region": {"threshold_method": "foobar"}})
def test_invalid_reference_region_mode_rejected() -> None:
"""闭集枚举:非法 reference_region.mode 应被 Literal 拒绝。"""
with pytest.raises(ValidationError):
_validate({"tic_analysis": {"reference_region": {"mode": "random"}}})
# ---------- design/0030 决策二:合成零点那四个键已删,残留即报错 ----------
@pytest.mark.parametrize(
"payload",
[
{"add_zero_timepoint": True},
{"shift_frame_times_after_zero": True},
{"zero_time_sec": 0.0},
{"smoothing": {"force_zero_start": True}},
],
)
def test_zero_timepoint_keys_are_gone(payload: dict[str, Any]) -> None:
"""design/0030 决策二:合成零点那四个键已删,残留即被 extra='forbid' 拒掉。
理由写在 `TicAnalysisConfig` 的 docstring 上(CLAUDE.md §1.6)。
这条守的只是名字,换个名字把同一个概念加回来它照样绿,所以那两段 docstring 是不可省
的另一半。
"""
with pytest.raises(ValidationError):
_validate({"tic_analysis": payload})
def test_anchor_frames_defaults_mirror_contract() -> None:
"""anchor_frames 段的默认值镜像自《临床口径契约》(2026-08-25 版,同步 2026-09-01)。
这批数不来自 findings 也不来自工程直觉——动任何一个之前先去 Linear 那边核对。
钉在测试里是为了防手滑改配置时漏看注释:改了数这条就红,红的含义是「抄本动过了,
去核对契约」。
"""
cfg = load_tracking_config()
a = cfg.tic_analysis.anchor_frames
assert a.arrival.baseline_sd_multiplier == 3.0
assert a.arrival.relative_peak_threshold == 0.10
assert a.arrival.persistence_s == 0.5
# 突出度倍数与到达判据倍数是两个独立的键(契约明写改一个不应连带改另一个)。
assert a.peak_prominence_baseline_sd_multiplier == 3.0
assert a.baseline_frame_backoff_s == 0.3
assert a.d1_window_after_s == 0.5
assert a.d2_window_s == 0.3
assert a.d3_window_s == 0.5
# D4/B0 两档契约空着、第 12 题委托工程侧,取曲线锚点档里较宽的一档。
assert a.d4_window_s == 0.5
assert a.b0_window_s == 0.5
assert a.late_phase.preferred_time_s == 60.0
assert a.late_phase.tolerance_s == 2.0
assert a.f1_anchor_s == 20.0
def test_anchor_frames_unknown_key_rejected() -> None:
"""schema 拒绝未知键:照抄契约时拼错键名在加载配置这一步就报错,不进流水线。"""
with pytest.raises(ValidationError):
_validate({"tic_analysis": {"anchor_frames": {"arrival_threshold": 3.0}}})
def test_yaml_with_leftover_zero_key_fails_to_load() -> None:
"""yaml 里留着 `add_zero_timepoint` 时加载当场失败,不留兼容路径。"""
section = load_ceus_section()
section["tic_analysis"]["add_zero_timepoint"] = True
with pytest.raises(ValidationError):
TrackingConfig.model_validate(section)
# ---------- design/0030 决策十:config_version ----------
def test_config_version_is_required() -> None:
"""design/0030 决策十:`config_version` 无默认,缺了直接报错。
有默认值等于「yaml 里删掉这行也能起来」,那时两批不同参数跑出来的结果落在库里
长得一模一样,没有任何东西能把它们分开。
"""
section = load_ceus_section()
del section["config_version"]
with pytest.raises(ValidationError, match="config_version"):
TrackingConfig.model_validate(section)
def test_config_version_rejects_empty_string() -> None:
"""空串在**加载配置这一步**就得被拒,不能等到整条流水线跑完才炸。
实体 ``CeusRoiTracking`` 上有同一道判据,但那是落库前的最后一关:解码、分割、六趟
遍历全跑完,走到写库才报错,而这一趟是白跑的。
"""
with pytest.raises(ValidationError, match="config_version"):
_validate({"config_version": ""})
def test_default_yaml_declares_config_version() -> None:
"""默认 yaml 顶层给了 `config_version`。
"4" 起于双曲线 Lognormal v2(design/0044):拟合与锚点语义发生变化,
两批产物靠这个号分开(design/0030 决策十)。
"""
cfg = load_tracking_config()
assert cfg.config_version == "4"
# ---------- design/0030 决策二:基线区间默认值 ----------
def test_baseline_defaults_match_spec_section_3_3() -> None:
"""基线两个时长的默认值等于规范 §3.3 给的首选 2 秒 / 最短 1 秒。"""
cfg = load_tracking_config()
assert cfg.tic_analysis.baseline.preferred_duration_s == 2.0
assert cfg.tic_analysis.baseline.minimum_duration_s == 1.0
# 规范 §3.3 没给稳定性判据,这个数是我们定的占位。
assert cfg.tic_analysis.baseline.stability_sd_multiplier == 3.0
def test_baseline_minimum_must_not_exceed_preferred() -> None:
"""最短时长超过首选时长即报错——那样「退到最短」比首选还长,等于没有退路。"""
with pytest.raises(ValidationError):
_validate(
{
"tic_analysis": {
"baseline": {
"preferred_duration_s": 1.0,
"minimum_duration_s": 2.0,
}
}
}
)
def test_baseline_durations_must_be_positive() -> None:
"""非正时长即报错(0 秒的基线段算不出 B 与 σ0)。"""
with pytest.raises(ValidationError):
_validate({"tic_analysis": {"baseline": {"minimum_duration_s": 0.0}}})
# ---------- design/0030 决策四:pan_source(紫色轮廓检测器) ----------
def test_pan_source_defaults_match_default_yaml() -> None:
"""默认 yaml 里的紫色占位值原样加载(这一组待真实素材标定)。"""
cfg = load_tracking_config()
assert cfg.pan_source.hsv_lower == [125, 60, 60]
assert cfg.pan_source.hsv_upper == [165, 255, 255]
assert cfg.pan_source.min_area == 2000
assert cfg.pan_source.min_detected_frame_fraction == 0.9
# 顶部文字带同绿框那条:紫色轮廓和绿框画在同一画面上。
assert cfg.pan_source.ignore_regions == cfg.prompt_source.ignore_regions
@pytest.mark.parametrize("bound", ["hsv_lower", "hsv_upper"])
def test_pan_source_hsv_must_have_three_components(bound: str) -> None:
"""HSV 阈值分量数不对即报错(否则要到 cv2.inRange 里才炸)。"""
with pytest.raises(ValidationError):
_validate({"pan_source": {bound: [125, 60]}})
def test_pan_source_hsv_upper_must_not_be_below_lower() -> None:
"""上下界写反即报错——inRange 不会报错,只会恒返回空掩膜,静默变成「检不到紫色」。"""
with pytest.raises(ValidationError):
_validate({"pan_source": {"hsv_lower": [125, 60, 60], "hsv_upper": [120, 255, 255]}})
def test_pan_source_min_area_must_be_positive() -> None:
"""min_area 须 ≥1。"""
with pytest.raises(ValidationError):
_validate({"pan_source": {"min_area": 0}})
@pytest.mark.parametrize("fraction", [0.0, -0.1, 1.5])
def test_pan_source_detected_fraction_out_of_bounds_rejected(fraction: float) -> None:
"""检出帧占比须 ∈ (0, 1]:取 0 这道门形同虚设,>1 则永远判不出有效。"""
with pytest.raises(ValidationError):
_validate({"pan_source": {"min_detected_frame_fraction": fraction}})
def test_pan_source_detected_fraction_accepts_one() -> None:
"""1.0 合法(虽然默认不取它——一帧漏检就整段作废太脆)。"""
cfg = _validate({"pan_source": {"min_detected_frame_fraction": 1.0}})
assert cfg.pan_source.min_detected_frame_fraction == 1.0
# ---------- 任务书 02:配置不设默认值,删掉的旧字段不再接受 ----------
def _leaf_paths(node: dict[str, Any], prefix: tuple[str, ...]) -> list[tuple[str, ...]]:
paths: list[tuple[str, ...]] = []
for key, value in node.items():
if isinstance(value, dict):
paths.extend(_leaf_paths(value, (*prefix, key))) # pyright: ignore[reportUnknownArgumentType]
paths.append((*prefix, key))
return paths
@pytest.mark.parametrize("path", _leaf_paths(load_ceus_section(), ()), ids="/".join)
def test_every_field_is_required(path: tuple[str, ...]) -> None:
"""ceus 段删掉任何一个键(含整个子段)都报错:没有一个字段有默认值。"""
section = load_ceus_section()
node = section
for key in path[:-1]:
node = node[key]
del node[path[-1]]
with pytest.raises(ValidationError):
TrackingConfig.model_validate(section)
@pytest.mark.parametrize(
"payload",
[
{"views": {"source_view": "left"}},
{"views": {"target_view": "right"}},
{"views": {"effective_region": {"type": "full"}}},
{"mapping": {"type": "normalized_view_coordinates", "anchor_points": []}},
],
)
def test_removed_fields_are_rejected(payload: dict[str, Any]) -> None:
"""造影在哪一半、裁黑边、映射锚点这几个旧字段已删,写了就报错。"""
with pytest.raises(ValidationError):
_validate(payload)
def test_config_is_frozen() -> None:
cfg = load_tracking_config()
with pytest.raises(ValidationError):
cfg.keyframes.iou_threshold = 0.5 # pyright: ignore[reportAttributeAccessIssue]