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>
This commit is contained in:
2026-10-16 03:46:56 -04:00
parent de1d6b2d51
commit 9a916c65a5
29 changed files with 3634 additions and 45 deletions
+396
View File
@@ -0,0 +1,396 @@
"""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]