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
+27
View File
@@ -0,0 +1,27 @@
"""CEUS 测试共用的夹具:读 `config/ceus.yaml` 的 `ceus` 段。
配置不设默认值,旧测试里靠默认值构造配置的地方改用这里的取值;需要不同取值的测试用
`model_copy(update=...)` 改那一项。
"""
from pathlib import Path
from typing import Any, cast
import pytest
import yaml
from cadx.ceus import CeusConfig
CEUS_YAML = Path(__file__).resolve().parents[1] / "config" / "ceus.yaml"
def load_ceus_section() -> dict[str, Any]:
"""`config/ceus.yaml` 的 `ceus` 段原样读成字典(每次新读一份,调用方可以改)。"""
document = cast(dict[str, Any], yaml.safe_load(CEUS_YAML.read_text(encoding="utf-8")))
return cast(dict[str, Any], document["ceus"])
@pytest.fixture
def ceus_config() -> CeusConfig:
"""校验过的 `ceus` 段。"""
return CeusConfig.model_validate(load_ceus_section())
+49 -12
View File
@@ -12,11 +12,13 @@ from PIL import Image
from cadx.answers import read_aliases, read_diagnoses
from cadx.build import make_aliases, run_build
from cadx.library import (
LibraryCase,
list_aliases,
read_clinical,
read_mr,
read_ultrasound,
scan_for_leaks,
write_library,
)
from fake_delivery import (
CLINICAL_HEADER,
@@ -218,24 +220,59 @@ def _build_with_key_image(tmp_path: Path, image: Image.Image) -> None:
run_build(write_config(tmp_path, root, 1, None), REPO_ROOT, NOW)
def test_key_image_16_bit_source_stops_build(tmp_path: Path) -> None:
image = Image.new("I;16", (4, 4))
with pytest.raises(ValueError, match=r"U002_key\.png"):
_build_with_key_image(tmp_path, image)
assert not (tmp_path / "library" / f"{NOW:%Y%m%d-%H%M%S}").exists()
assert [p for p in (tmp_path / "library").iterdir() if not p.name.endswith(".partial")] == []
def _assert_key_image_skipped(tmp_path: Path, reason: str) -> None:
"""002 这一例记 C14 跳过、涉及值是原因;001 照常进库。"""
(library_dir,) = list((tmp_path / "library").iterdir())
answers_dir = tmp_path / "answers" / library_dir.name
assert [r.case_no for r in read_aliases(answers_dir)] == ["001"]
assert len(list_aliases(library_dir)) == 1
skipped = _csv_rows(answers_dir / "skipped.csv")
assert [row[:4] for row in skipped[1:]] == [
["002", "C14", "关键图能原样重编码", "CEUS_agent/002/U002_key.png"]
]
assert json.loads(skipped[1][5]) == reason
def test_key_image_with_transparency_stops_build(tmp_path: Path) -> None:
def test_key_image_16_bit_source_skips_case(tmp_path: Path) -> None:
_build_with_key_image(tmp_path, Image.new("I;16", (4, 4)))
_assert_key_image_skipped(tmp_path, "位深 16、颜色类型 0 不能逐像素保真重编码")
def test_key_image_with_transparency_skips_case(tmp_path: Path) -> None:
image = Image.new("RGB", (4, 4))
image.info["transparency"] = (0, 0, 0)
with pytest.raises(ValueError, match=r"U002_key\.png"):
_build_with_key_image(tmp_path, image)
_build_with_key_image(tmp_path, image)
_assert_key_image_skipped(tmp_path, "带 tRNS 透明色,重编码会丢透明")
def test_key_image_palette_source_stops_build(tmp_path: Path) -> None:
with pytest.raises(ValueError, match=r"U002_key\.png"):
_build_with_key_image(tmp_path, Image.new("P", (4, 4)))
def test_key_image_palette_source_skips_case(tmp_path: Path) -> None:
_build_with_key_image(tmp_path, Image.new("P", (4, 4)))
# 只有一种颜色的调色板图,Pillow 存成 1 位。
_assert_key_image_skipped(tmp_path, "位深 1、颜色类型 3 不能逐像素保真重编码")
def test_key_image_not_png_skips_case(tmp_path: Path) -> None:
root = make_delivery(2).write(tmp_path / "delivery")
Image.new("RGB", (4, 4)).save(root / "CEUS_agent" / "002" / "U002_key.png", format="JPEG")
run_build(write_config(tmp_path, root, 1, None), REPO_ROOT, NOW)
_assert_key_image_skipped(tmp_path, "不是 PNG")
def test_write_library_rejects_unchecked_key_image(tmp_path: Path) -> None:
"""build 漏了 C14 检查时,library 写图那一步报错停下。"""
source = tmp_path / "key.png"
Image.new("P", (4, 4)).save(source, format="PNG")
target = tmp_path / "out"
target.mkdir()
case = LibraryCase(
alias="abcd",
clinical_fields={},
ultrasound_report="",
key_image_source=source,
mr_report="",
)
with pytest.raises(ValueError, match="check_key_image"):
write_library(target, "b", "c", True, {}, False, (), None, [case])
def test_key_image_one_bit_kept_as_is(tmp_path: Path) -> None:
+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]
+93
View File
@@ -0,0 +1,93 @@
"""造影画面在哪一半:两半各自选提示帧,恰好一半有才定(任务书 02 第一阶段第 2 条)。
合成 400×200 的分屏视频,分屏线在 x=200;绿框是 120×100 的实心框(外接框面积 12000,
大于 config/ceus.yaml 的 min_area 10000),画在顶部文字带之下。预期值按画框位置手算。
"""
import cv2
import numpy as np
import pytest
from cadx.ceus import (
CeusCaseError,
CeusConfig,
ContrastSideError,
ViewRect,
locate_contrast_side,
map_source_mask_to_target,
split_views,
)
from cadx.ceus._geometry import Frame
_WIDTH, _HEIGHT, _SPLIT = 400, 200, 200
_GREEN = (0, 255, 0)
def _frames(green_boxes_x1: list[int]) -> list[Frame]:
"""10 帧分屏画面;每个 x1 画一个整帧坐标 [x1, x1+119] × [60, 159] 的绿框。"""
frame = np.zeros((_HEIGHT, _WIDTH, 3), dtype=np.uint8)
frame[:, :_SPLIT] = 120
frame[:, _SPLIT + 1 :] = 90
for x1 in green_boxes_x1:
cv2.rectangle(frame, (x1, 60), (x1 + 119, 159), _GREEN, thickness=-1)
return [frame.copy() for _ in range(10)]
def test_green_box_in_left_half(ceus_config: CeusConfig) -> None:
frames = _frames([40])
_, left, right = split_views(frames, ceus_config.views)
assert (left, right) == (ViewRect(0, 0, 200, 200), ViewRect(200, 0, 400, 200))
side = locate_contrast_side(
frames, left, right, ceus_config.prompt_source, ceus_config.keyframes
)
assert side.side == "left"
assert side.contrast_view == left
assert side.target_view == right
# 10 帧绿框不变:只有第一帧是提示帧。
assert [p.frame_index for p in side.prompt_frames] == [0]
x1, y1, _, _ = side.prompt_frames[0].bbox_xyxy
assert abs(x1 - 40) <= 3 and abs(y1 - 60) <= 3
def test_green_box_in_right_half(ceus_config: CeusConfig) -> None:
frames = _frames([250])
_, left, right = split_views(frames, ceus_config.views)
side = locate_contrast_side(
frames, left, right, ceus_config.prompt_source, ceus_config.keyframes
)
assert side.side == "right"
assert side.contrast_view == right
assert side.target_view == left
# 整帧 x=250 在右半局部是 250 − 200 = 50。
prompt = side.prompt_frames[0]
assert abs(prompt.bbox_xyxy[0] - 50) <= 3
# 挪到左半(灰阶那一半):整帧 x = 250 − 200 = 50,局部也是 50。
mapped = map_source_mask_to_target(prompt.mask, side.contrast_view, side.target_view)
xs = np.where(mapped)[1]
assert abs(int(xs.min()) - 50) <= 3
assert abs(int(xs.max()) - 169) <= 3
def test_green_box_in_both_halves_fails_case(ceus_config: CeusConfig) -> None:
frames = _frames([40, 250])
_, left, right = split_views(frames, ceus_config.views)
with pytest.raises(ContrastSideError, match="两半都找到绿框") as raised:
locate_contrast_side(frames, left, right, ceus_config.prompt_source, ceus_config.keyframes)
assert isinstance(raised.value, CeusCaseError)
assert raised.value.step == 2
def test_no_green_box_fails_case(ceus_config: CeusConfig) -> None:
frames = _frames([])
_, left, right = split_views(frames, ceus_config.views)
with pytest.raises(ContrastSideError, match="两半都没有找到绿框") as raised:
locate_contrast_side(frames, left, right, ceus_config.prompt_source, ceus_config.keyframes)
assert raised.value.step == 2
+278
View File
@@ -0,0 +1,278 @@
"""B1 geometry 纯函数库单元测试。
钉死几何/重采样/平滑工具的关键性质:IoU 边界、等弧长重采样闭合性、
三角平滑的恒等与偶窗拒绝、奇窗化与可选正整数解析、多边形栅格化 dtype,
以及 ViewRect 的不变量校验。测试用真实数据的二次构造(合成 mask / 正方形轮廓 /
常量轨迹),边缘场景靠实现里的 assertion/raise 保证。
"""
import numpy as np
import pytest
from cadx.ceus import (
ViewRect,
crop_frame,
local_rect_within,
mask_iou,
odd_window,
optional_positive_int,
polygon_area,
polygon_center,
polygon_to_mask,
polygons_to_mask,
rect_from_values,
resample_closed_contour,
triangular_smooth,
)
# Phase 1: mask_iou —— 交并比边界
def test_mask_iou_identical_is_one() -> None:
mask = np.zeros((10, 10), dtype=bool)
mask[2:6, 3:7] = True
assert mask_iou(mask, mask) == 1.0
def test_mask_iou_disjoint_is_zero() -> None:
a = np.zeros((10, 10), dtype=bool)
a[0:3, 0:3] = True
b = np.zeros((10, 10), dtype=bool)
b[6:9, 6:9] = True
assert mask_iou(a, b) == 0.0
def test_mask_iou_empty_union_is_zero() -> None:
empty = np.zeros((5, 5), dtype=bool)
assert mask_iou(empty, empty) == 0.0
def test_mask_iou_partial_overlap() -> None:
a = np.zeros((10, 10), dtype=bool)
a[0:4, 0:4] = True # 16 像素
b = np.zeros((10, 10), dtype=bool)
b[2:6, 2:6] = True # 16 像素,交集 2x2=4,并集 28
assert mask_iou(a, b) == pytest.approx(4 / 28)
# Phase 2: resample_closed_contour —— 等弧长重采样
def _unit_square() -> np.ndarray:
return np.array([[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]], dtype=np.float32)
def test_resample_shape_is_num_points_by_two() -> None:
out = resample_closed_contour(_unit_square(), 64)
assert out is not None
assert out.shape == (64, 2)
def test_resample_equal_arc_length_spacing() -> None:
out = resample_closed_contour(_unit_square(), 64)
assert out is not None
closed = np.vstack([out, out[0]])
seg = np.sqrt((np.diff(closed, axis=0) ** 2).sum(axis=1))
# 等弧长:所有相邻采样段长应近似一致
assert seg.std() < 1e-3
def test_resample_too_few_points_returns_none() -> None:
assert resample_closed_contour(np.array([[0.0, 0.0], [1.0, 1.0]]), 64) is None
assert resample_closed_contour(None, 64) is None
# Phase 3: triangular_smooth —— 时间轴三角加权
def test_triangular_smooth_even_window_raises() -> None:
values = np.zeros((5, 4, 2), dtype=np.float32)
with pytest.raises(ValueError):
triangular_smooth(values, 4)
def test_triangular_smooth_window_one_is_identity() -> None:
rng = np.random.default_rng(0)
values = rng.standard_normal((6, 8, 2)).astype(np.float32)
out = triangular_smooth(values, 1)
assert np.array_equal(out, values)
def test_triangular_smooth_constant_track_unchanged() -> None:
const = np.full((7, 5, 2), 3.5, dtype=np.float32)
out = triangular_smooth(const, 3)
assert np.allclose(out, 3.5)
# Phase 4: odd_window —— 奇窗化
def test_odd_window_even_increments() -> None:
assert odd_window(10) == 11
def test_odd_window_odd_unchanged() -> None:
assert odd_window(7) == 7
def test_odd_window_floor_at_one() -> None:
assert odd_window(0) == 1
assert odd_window(-2) == 1
# Phase 5: optional_positive_int —— 可选正整数解析
def test_optional_positive_int_auto_is_none() -> None:
assert optional_positive_int("auto") is None
def test_optional_positive_int_none_is_none() -> None:
assert optional_positive_int(None) is None
def test_optional_positive_int_negative_is_none() -> None:
assert optional_positive_int(-3) is None
assert optional_positive_int(0) is None
def test_optional_positive_int_positive_parsed() -> None:
assert optional_positive_int(5) == 5
assert optional_positive_int("8") == 8
# Phase 6: polygon_to_mask —— 栅格化
def test_polygon_to_mask_dtype_is_bool() -> None:
poly = _unit_square()
mask = polygon_to_mask(poly, 12, 12)
assert mask.dtype == bool
assert mask.shape == (12, 12)
assert mask.any()
# Phase 6a: polygons_to_mask —— even-odd 一次填充挖洞
def test_polygons_to_mask_carves_hole_via_even_odd() -> None:
"""外框 + 内洞一次 fillPoly:洞中心为 False、外框环上为 True(even-odd 挖洞)。"""
outer = np.array([[5.0, 5.0], [45.0, 5.0], [45.0, 45.0], [5.0, 45.0]])
hole = np.array([[20.0, 20.0], [30.0, 20.0], [30.0, 30.0], [20.0, 30.0]])
mask = polygons_to_mask([outer, hole], 50, 50)
assert mask.dtype == bool
assert not mask[25, 25] # 洞中心:被挖空
assert mask[10, 10] # 外框环上:填充
# 甜甜圈面积 = 外框 40x40 - 洞 10x10(栅格边界差几像素,放宽)
assert mask.sum() == pytest.approx(40 * 40 - 10 * 10, abs=200)
def test_polygons_to_mask_empty_is_all_false() -> None:
"""空列表 / 全退化多边形:返回全 False 掩膜(不报错)。"""
assert not polygons_to_mask([], 20, 20).any()
degenerate = np.array([[1.0, 1.0], [2.0, 2.0]]) # < 3 顶点,跳过
assert not polygons_to_mask([degenerate], 20, 20).any()
# Phase 6b: polygon_area —— 鞋带公式精确面积
def test_polygon_area_square_exact() -> None:
"""边长 10 的正方形面积精确为 100.0(鞋带浮点精确,不取整)。"""
square = np.array([[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]], dtype=np.float32)
assert polygon_area(square) == pytest.approx(100.0, abs=1e-6)
def test_polygon_area_triangle_exact() -> None:
"""直角三角形(底 8 高 6)面积精确为 24.0。"""
triangle = np.array([[0.0, 0.0], [8.0, 0.0], [0.0, 6.0]], dtype=np.float32)
assert polygon_area(triangle) == pytest.approx(24.0, abs=1e-6)
def test_polygon_area_orientation_independent() -> None:
"""顺时针 / 逆时针同一多边形面积一致(取绝对值消朝向符号)。"""
ccw = np.array([[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]], dtype=np.float32)
cw = ccw[::-1].copy()
assert polygon_area(ccw) == pytest.approx(polygon_area(cw), abs=1e-6)
assert polygon_area(cw) == pytest.approx(100.0, abs=1e-6)
def test_polygon_area_non_integer_vertices_not_quantized() -> None:
"""半整数顶点不被取整:边长 10.5 正方形面积 = 110.25(不会被 round 量化)。"""
square = np.array([[0.0, 0.0], [10.5, 0.0], [10.5, 10.5], [0.0, 10.5]], dtype=np.float32)
assert polygon_area(square) == pytest.approx(110.25, abs=1e-4)
# Phase 6c: polygon_center —— 质心(退化时回退顶点均值)
def test_polygon_center_square_is_geometric_center() -> None:
"""正方形质心在几何中心 (5, 5)。"""
square = np.array([[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]], dtype=np.float32)
center = polygon_center(square)
assert center == pytest.approx(np.array([5.0, 5.0]), abs=1e-3)
def test_polygon_center_rectangle_is_geometric_center() -> None:
"""矩形质心在几何中心 (10, 3)。"""
rect = np.array([[0.0, 0.0], [20.0, 0.0], [20.0, 6.0], [0.0, 6.0]], dtype=np.float32)
center = polygon_center(rect)
assert center == pytest.approx(np.array([10.0, 3.0]), abs=1e-3)
def test_polygon_center_degenerate_collinear_falls_back_to_vertex_mean() -> None:
"""共线顶点 m00≈0:退化路径回退到顶点算术均值,不崩、不除零。"""
collinear = np.array([[0.0, 0.0], [2.0, 2.0], [4.0, 4.0], [6.0, 6.0]], dtype=np.float32)
center = polygon_center(collinear)
assert center == pytest.approx(collinear.mean(axis=0), abs=1e-3)
def test_polygon_center_degenerate_coincident_falls_back_to_vertex_mean() -> None:
"""所有点重合 m00≈0:退化路径回退到该点本身(顶点均值),不除零。"""
coincident = np.full((5, 2), 7.0, dtype=np.float32)
center = polygon_center(coincident)
assert center == pytest.approx(np.array([7.0, 7.0]), abs=1e-3)
# Phase 7: ViewRect / crop_frame / local_rect_within / rect_from_values
def test_view_rect_valid() -> None:
rect = ViewRect(0, 0, 4, 6)
assert (rect.x1, rect.y1, rect.x2, rect.y2) == (0, 0, 4, 6)
def test_view_rect_rejects_inverted() -> None:
with pytest.raises(ValueError):
ViewRect(5, 0, 3, 6)
with pytest.raises(ValueError):
ViewRect(0, 6, 4, 2)
with pytest.raises(ValueError):
ViewRect(-1, 0, 4, 6)
def test_crop_frame_slices_subimage() -> None:
frame = np.arange(100).reshape(10, 10)
rect = ViewRect(2, 1, 5, 4)
sub = crop_frame(frame, rect)
assert sub.shape == (3, 3)
assert np.array_equal(sub, frame[1:4, 2:5])
def test_local_rect_within_translates_to_parent_origin() -> None:
parent = ViewRect(10, 20, 110, 220)
child = ViewRect(30, 50, 60, 90)
local = local_rect_within(parent, child)
assert (local.x1, local.y1, local.x2, local.y2) == (20, 30, 50, 70)
def test_rect_from_values_pixel() -> None:
rect = rect_from_values([3, 4, 8, 9], width=20, height=20)
assert (rect.x1, rect.y1, rect.x2, rect.y2) == (3, 4, 8, 9)
def test_rect_from_values_normalized() -> None:
rect = rect_from_values([0.0, 0.0, 0.5, 0.5], width=100, height=80)
assert (rect.x1, rect.y1, rect.x2, rect.y2) == (0, 0, 50, 40)
def test_rect_from_values_clamps_and_keeps_min_extent() -> None:
# x2==x1 时应被推到 x1+1,避免退化
rect = rect_from_values([5, 5, 5, 5], width=20, height=20)
assert rect.x2 >= rect.x1 + 1
assert rect.y2 >= rect.y1 + 1
def test_rect_from_values_wrong_length_raises() -> None:
with pytest.raises(ValueError):
rect_from_values([1, 2, 3], width=10, height=10)
def test_viewrect_width_height():
"""ViewRect.width/height 与参考接口对齐,供下游消费。"""
from cadx.ceus import ViewRect
r = ViewRect(10, 20, 110, 220)
assert r.width == 100
assert r.height == 200
+291
View File
@@ -0,0 +1,291 @@
"""B3 green_prompt 绿框提示检测单元测试。
钉死四件事的关键性质(参考 ``references/ROI/roi_tracking_pipeline.py`` P:241-332/989-1004):
- ``extract_color_prompt``:合成在已知位置画绿色多边形的 BGR 左视图帧 →
返回该绿框 bbox(含端点 ``x2=x1+w-1`` / ``y2=y1+h-1``)+ 充满轮廓的布尔 mask;
纯灰度帧(无绿)→ ``None``。
- ``ignore_regions``:把绿框画在顶部文字带内,配置抹除顶部带后该绿不计 → ``None``;
绿框在带外则正常返回。
- ``min_area``:按 **bbox 面积 w×h**(findings §3.2,非轮廓面积)过滤——
bbox 面积 < min_area 的候选被丢弃。
- ``collect_prompt_observations``:对多帧序列返回逐帧观测列表,
无绿帧以 ``None`` 占位(``observation.prompt is None``)。
- ``prompt_color_mask``:TIC 端"排除绿框色"掩膜——``exclude_prompt_color=False`` 全 False;
``prompt_exclude_dilate=1`` 不膨胀(mask == 原始 inRange);增大膨胀核则 mask 更大。
测试用真实数据的二次构造(合成定位绿框 / 纯灰度帧),边缘场景靠实现 raise/None 保证。
"""
import cv2
import numpy as np
from cadx.ceus import (
CeusConfig,
ColorPrompt,
PanSourceConfig,
PromptObservation,
PromptSourceConfig,
TicAnalysisConfig,
collect_prompt_observations,
extract_color_prompt,
prompt_color_mask,
)
from conftest import load_ceus_section
# 配置不设默认值:旧测试靠默认值构造的配置,改为在 config/ceus.yaml 的取值上 model_copy。
_CONFIG = CeusConfig.model_validate(load_ceus_section())
# 纯绿(BGR):H≈60、S=255、V=255,落在 inRange [35,60,60]~[90,255,255] 内。
_GREEN_BGR = (0, 255, 0)
_GRAY_BGR = 120
# 紫(BGR):H≈145、S=255、V=255,落在 PanSourceConfig 默认阈 [125,60,60]~[165,255,255] 内。
_PURPLE_BGR = (255, 0, 212)
def _gray_frame(height: int, width: int) -> np.ndarray:
"""合成一帧纯灰度 BGR 图(三通道等值),不含任何绿色像素。"""
return np.full((height, width, 3), _GRAY_BGR, dtype=np.uint8)
def _frame_with_green_rect(
height: int,
width: int,
rect_xyxy: tuple[int, int, int, int],
*,
thickness: int = -1,
) -> np.ndarray:
"""在灰底帧上画一个绿色矩形(默认实心填充)。
``rect_xyxy`` 为 ``(x1, y1, x2, y2)`` 含端点像素坐标。
"""
frame = _gray_frame(height, width)
x1, y1, x2, y2 = rect_xyxy
cv2.rectangle(frame, (x1, y1), (x2, y2), _GREEN_BGR, thickness=thickness)
return frame
def _cfg(**overrides: object) -> PromptSourceConfig:
"""构造 PromptSourceConfig,默认无 ignore_regions、min_area=1,便于钉死定位。"""
base: dict[str, object] = {"ignore_regions": [], "min_area": 1}
base.update(overrides)
return _CONFIG.prompt_source.model_copy(update=base)
def _tic_cfg(**overrides: object) -> TicAnalysisConfig:
"""构造 TicAnalysisConfig,默认 exclude_prompt_color=True、prompt_exclude_dilate=1。
把 dilate 默认置 1 便于钉死"原始 inRange == mask"(无膨胀外扩),
各测试再按需覆盖以验证条件膨胀语义。
"""
base: dict[str, object] = {"prompt_exclude_dilate": 1}
base.update(overrides)
return _CONFIG.tic_analysis.model_copy(update=base)
def _pan_cfg() -> PanSourceConfig:
"""紫圈检测配置:config/ceus.yaml 的取值,去掉忽略区、min_area=1。"""
return _CONFIG.pan_source.model_copy(update={"ignore_regions": [], "min_area": 1})
# Phase 1: extract_color_prompt —— 定位绿框 / 无绿返回 None
def test_extract_returns_bbox_with_inclusive_endpoints() -> None:
# 在 (40,30)-(120,90) 画实心绿框(含端点)。
rect = (40, 30, 120, 90)
frame = _frame_with_green_rect(120, 200, rect)
prompt = extract_color_prompt(frame, _cfg())
assert isinstance(prompt, ColorPrompt)
x1, y1, x2, y2 = prompt.bbox_xyxy
# 形态学 CLOSE+dilate 会外扩几像素,允许 ±3 容差,钉死端点语义。
assert abs(x1 - 40) <= 3 and abs(y1 - 30) <= 3
assert abs(x2 - 120) <= 3 and abs(y2 - 90) <= 3
# 含端点:width = x2 - x1 + 1。
assert prompt.width == x2 - x1 + 1
assert prompt.height == y2 - y1 + 1
# mask 是布尔、与帧同高宽、绿框区域为 True。
assert prompt.mask.dtype == np.bool_
assert prompt.mask.shape == (120, 200)
assert bool(prompt.mask[60, 80]) # 框中心点在 mask 内
assert prompt.candidate_count >= 1
def test_extract_gray_frame_returns_none() -> None:
assert extract_color_prompt(_gray_frame(120, 200), _cfg()) is None
# Phase 2: ignore_regions —— 顶部文字带抹除
def test_ignore_regions_suppresses_top_band_green() -> None:
# 绿框完全落在顶部 8.34% 带内(120*0.0834≈10px),抹除后不计。
top_rect = (40, 1, 120, 8)
frame = _frame_with_green_rect(120, 200, top_rect)
cfg = _cfg(ignore_regions=[[0.0, 0.0, 1.0, 0.0834]])
assert extract_color_prompt(frame, cfg) is None
def test_ignore_regions_keeps_below_band_green() -> None:
# 绿框在顶部带之下,抹除顶部带不影响它。
body_rect = (40, 40, 120, 90)
frame = _frame_with_green_rect(120, 200, body_rect)
cfg = _cfg(ignore_regions=[[0.0, 0.0, 1.0, 0.0834]])
prompt = extract_color_prompt(frame, cfg)
assert prompt is not None
assert abs(prompt.bbox_xyxy[1] - 40) <= 3
# Phase 3: min_area —— 按 bbox 面积 w×h 过滤
def test_min_area_filters_by_bbox_area() -> None:
# 绿框 bbox≈ 21×21=441;min_area=2000 应过滤掉 → None。
small_rect = (50, 50, 70, 70)
frame = _frame_with_green_rect(120, 200, small_rect)
assert extract_color_prompt(frame, _cfg(min_area=2000)) is None
# 放宽 min_area 后能检出。
assert extract_color_prompt(frame, _cfg(min_area=100)) is not None
def test_min_area_boundary_equal_passes() -> None:
# 钉死边界:bbox 面积"恰等于"min_area 应通过(实现是 `< min_area` 过滤,
# 非 `<= min_area`;与参考 P:270 一致)。用细绿框确保 bbox 面积可精确预测,
# 不被形态学外扩干扰(CLOSE+dilate 只让外轮廓更大、bbox 不会缩小)。
# 实心绿框 (50,50)-(70,70):bbox = (70-50+1)*(70-50+1) = 21*21 = 441。
rect = (50, 50, 70, 70)
expected_area = (70 - 50 + 1) * (70 - 50 + 1)
assert expected_area == 441
frame = _frame_with_green_rect(120, 200, rect)
# 形态学会外扩,故实际 bbox 面积 >= 441;取一个明显小于实际面积、
# 但用于证伪 `<=` 误写的下界即可——这里用恰等于"检出框的真实面积"。
detected = extract_color_prompt(frame, _cfg(min_area=1))
assert detected is not None
area_eq = detected.bbox_area
# min_area 恰等于检出 bbox 面积 → 应通过(area >= min_area);
# 若误写成 `<= min_area` 过滤,则此处会被丢弃返回 None,从而证伪。
on_boundary = extract_color_prompt(frame, _cfg(min_area=area_eq))
assert on_boundary is not None
assert on_boundary.bbox_area == area_eq
# 反向:min_area 比真实面积大 1 → 被过滤为 None(确认过滤确实生效)。
assert extract_color_prompt(frame, _cfg(min_area=area_eq + 1)) is None
def test_hollow_green_rect_closes_to_single_filled_contour() -> None:
# 覆盖参考 P:261-288 真实服务路径:医生画的是空心绿框(细描边,非实心)。
# 用 thickness=3 的空心矩形,验证 CLOSE+dilate 后 findContours 连通成单个外轮廓,
# 且 drawContours 实心填充使 mask 覆盖框内区域(含框中心,原图中心是灰底非绿)。
rect = (40, 30, 140, 100)
frame = _frame_with_green_rect(120, 200, rect, thickness=3)
# 前置:中心点在原图是灰底(空心框内部无绿),证明确为空心框。
cx, cy = (40 + 140) // 2, (30 + 100) // 2
assert tuple(int(c) for c in frame[cy, cx]) == (_GRAY_BGR, _GRAY_BGR, _GRAY_BGR)
prompt = extract_color_prompt(frame, _cfg(min_area=100))
assert prompt is not None
# 单个外轮廓:候选数为 1(空心框经形态学连通后只剩一条外轮廓)。
assert prompt.candidate_count == 1
# 实心填充 mask 覆盖框内中心区域(findContours 外轮廓 + drawContours thickness=-1)。
assert bool(prompt.mask[cy, cx])
# bbox 端点贴合空心框外缘(±5 容差吸收核 (5,5) CLOSE+dilate 的外扩)。
assert abs(prompt.bbox_xyxy[0] - 40) <= 5 and abs(prompt.bbox_xyxy[1] - 30) <= 5
assert abs(prompt.bbox_xyxy[2] - 140) <= 5 and abs(prompt.bbox_xyxy[3] - 100) <= 5
def test_min_area_picks_largest_bbox_candidate() -> None:
# 两个绿框:小框 (10,10)-(30,30),大框 (80,60)-(170,110);选大框。
frame = _frame_with_green_rect(120, 200, (10, 10, 30, 30))
cv2.rectangle(frame, (80, 60), (170, 110), _GREEN_BGR, thickness=-1)
prompt = extract_color_prompt(frame, _cfg(min_area=1))
assert prompt is not None
assert prompt.candidate_count == 2
# 选中的应是大框(左上角接近 80,60)。
assert abs(prompt.bbox_xyxy[0] - 80) <= 3
assert abs(prompt.bbox_xyxy[1] - 60) <= 3
# Phase 4: collect_prompt_observations —— 多帧观测列表(None 占位)
def test_collect_observations_none_placeholder_for_no_green_frames() -> None:
frames = [
_frame_with_green_rect(120, 200, (40, 40, 120, 90)), # 有绿
_gray_frame(120, 200), # 无绿 → None 占位
_frame_with_green_rect(120, 200, (50, 40, 130, 95)), # 有绿
]
observations = collect_prompt_observations(frames, _cfg())
assert len(observations) == 3
assert all(isinstance(o, PromptObservation) for o in observations)
assert [o.frame_index for o in observations] == [0, 1, 2]
assert observations[0].prompt is not None
assert observations[1].prompt is None # 无绿帧占位
assert observations[2].prompt is not None
assert observations[1].detected is False
assert observations[0].detected is True
# Phase 5: prompt_color_mask —— TIC 端"排除绿框色"掩膜(受 tic_cfg 门控)
def test_prompt_color_mask_marks_green_pixels() -> None:
frame = _frame_with_green_rect(120, 200, (40, 30, 120, 90))
mask = prompt_color_mask(frame, _cfg(), _tic_cfg())
assert mask.dtype == np.bool_
assert mask.shape == (120, 200)
assert bool(mask[60, 80]) # 绿框内为 True
assert not bool(mask[5, 5]) # 灰底为 False
def test_prompt_color_mask_all_gray_is_empty() -> None:
mask = prompt_color_mask(_gray_frame(120, 200), _cfg(), _tic_cfg())
assert not mask.any()
def test_prompt_color_mask_exclude_disabled_returns_all_false() -> None:
# exclude_prompt_color=False → 直接返回全 False(语义:"不排除任何绿框色像素")。
frame = _frame_with_green_rect(120, 200, (40, 30, 120, 90))
mask = prompt_color_mask(frame, _cfg(), _tic_cfg(exclude_prompt_color=False))
assert mask.dtype == np.bool_
assert mask.shape == (120, 200)
assert not mask.any()
def test_prompt_color_mask_dilate_one_equals_raw_inrange() -> None:
# prompt_exclude_dilate=1 → 不膨胀,mask 应恰等于原始 inRange 二值。
frame = _frame_with_green_rect(120, 200, (40, 30, 120, 90))
mask = prompt_color_mask(frame, _cfg(), _tic_cfg(prompt_exclude_dilate=1))
lower = np.asarray(_cfg().hsv_lower, dtype=np.uint8)
upper = np.asarray(_cfg().hsv_upper, dtype=np.uint8)
hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
raw = cv2.inRange(hsv, lower, upper) > 0
assert np.array_equal(mask, raw)
def test_prompt_color_mask_larger_dilate_grows_mask() -> None:
# prompt_exclude_dilate=5 应比 =1 时膨胀出更多 True 像素(外扩绿框边缘)。
frame = _frame_with_green_rect(120, 200, (40, 30, 120, 90))
mask_one = prompt_color_mask(frame, _cfg(), _tic_cfg(prompt_exclude_dilate=1))
mask_five = prompt_color_mask(frame, _cfg(), _tic_cfg(prompt_exclude_dilate=5))
assert int(mask_five.sum()) > int(mask_one.sum())
# 膨胀只增不减:dilate=1 的 True 像素应全被 dilate=5 覆盖。
assert bool((mask_five | mask_one == mask_five).all())
# Phase 5: 换一组颜色范围就是另一个检测器(design/0030 决策四)
def test_extract_color_prompt_detects_purple_with_pan_config() -> None:
"""同一个 ``extract_color_prompt``,喂 ``PanSourceConfig`` 检的是紫色轮廓。
紫色轮廓是医生画的邻近正常胰腺实质(``ROI_pan``),和绿框走同一套 HSV 机制、
同一个函数——所以这里断言的是「函数认颜色配置,不认死绿色」这个行为。
"""
rect = (40, 30, 120, 90)
frame = _gray_frame(120, 200)
cv2.rectangle(frame, rect[:2], rect[2:], _PURPLE_BGR, thickness=-1)
pan_cfg = _pan_cfg()
prompt = extract_color_prompt(frame, pan_cfg)
assert isinstance(prompt, ColorPrompt)
assert bool(prompt.mask[60, 80])
# 同一帧喂绿框那组配置一个候选都没有——两组颜色范围互不命中。
assert extract_color_prompt(frame, _cfg()) is None
def test_prompt_color_mask_takes_hsv_from_the_config_it_is_given() -> None:
"""排除色掩膜的 HSV 来源是入参:喂紫色配置就出紫色掩膜,喂绿框配置全空。"""
frame = _gray_frame(120, 200)
cv2.rectangle(frame, (40, 30), (120, 90), _PURPLE_BGR, thickness=-1)
purple_mask = prompt_color_mask(frame, _pan_cfg(), _tic_cfg())
green_mask = prompt_color_mask(frame, _cfg(), _tic_cfg())
assert bool(purple_mask[60, 80])
assert not green_mask.any()
+179
View File
@@ -0,0 +1,179 @@
"""两半画面之间的坐标换算:平移量 = 目标视图左上角 − 造影视图左上角(任务书 02 第一阶段第 1 条)。
后两条照搬旧 test_ceus_mapping(去掉锚点配置参数,断言不变);其余是新写的手算测试。
旧文件里依赖锚点、有效区或缩放的用例不再成立,已删(见任务书 02 执行报告)。
"""
import cv2
import numpy as np
import pytest
from cadx.ceus import (
CeusConfig,
ViewRect,
map_source_mask_to_target,
polygon_to_mask,
split_views,
target_polygons_to_source,
view_offset,
)
from conftest import load_ceus_section
def _largest_external_contour(mask: np.ndarray) -> np.ndarray:
"""从二值 mask 取面积最大的外轮廓,返回 ``(N, 2)`` 的 ``float32`` 顶点序列。
参数:
mask: ``dtype=bool`` 的二值 mask。
返回:
最大外轮廓顶点 ``(x, y)``(``float32``);用于真往返中把 target mask 还原成多边形喂逆向。
"""
contours, _ = cv2.findContours(
mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
)
assert contours, "target mask 应非空,才能提取外轮廓做逆向往返。"
largest = max(contours, key=cv2.contourArea)
return largest.reshape(-1, 2).astype(np.float32)
def _bbox(points: np.ndarray) -> tuple[float, float, float, float]:
"""返回顶点序列的包围盒 ``(x_min, x_max, y_min, y_max)``(往返形状比对用)。"""
return (
float(points[:, 0].min()),
float(points[:, 0].max()),
float(points[:, 1].min()),
float(points[:, 1].max()),
)
def test_map_source_mask_to_target_translates_into_target_effective() -> None:
"""source 视图局部 mask 经映射落入 target 有效区局部坐标,形状为 target_eff 尺寸。"""
# source view = 整个左半区 [0,100)×[0,100);有效区同 view(无黑边)。
source_view = ViewRect(0, 0, 100, 100)
target_eff = ViewRect(100, 0, 200, 100)
source_mask = np.zeros((100, 100), dtype=bool)
source_mask[30:50, 20:40] = True # source 局部一个方块
target_mask = map_source_mask_to_target(source_mask, source_view, target_eff)
assert target_mask.dtype == bool
assert target_mask.shape == (target_eff.height, target_eff.width)
# 同尺寸有效区 + 无偏移:target 局部方块应与 source 局部方块同位置。
ys, xs = np.where(target_mask)
assert int(xs.min()) == 20 and int(xs.max()) == 39
assert int(ys.min()) == 30 and int(ys.max()) == 49
def test_real_roundtrip_same_size_no_offset_restores_polygon() -> None:
"""真往返(同尺寸有效区、无 offset):多边形→栅格化→正向→提轮廓→逆向,近似还原。
不再假造 target 多边形:从正向 ``map_source_mask_to_target`` 的真实输出 mask 里
用 ``cv2.findContours`` 提最大外轮廓喂逆向,端到端串起正向(mask)→逆向(polygon)。
栅格化 + 轮廓提取带量化误差,容差放宽到 ~2px,但 assert 包围盒近似还原。
"""
source_view = ViewRect(0, 0, 120, 100)
target_eff = ViewRect(120, 0, 240, 100)
# 已知 source 视图局部多边形(远离边界,避免 clip 干扰往返)。
source_polygon = np.array(
[[20.0, 30.0], [60.0, 25.0], [70.0, 70.0], [25.0, 65.0]], dtype=np.float32
)
# 正向:source 局部多边形 → 栅格化 mask → 映射到 target 有效区局部 mask。
source_mask = polygon_to_mask(source_polygon, source_view.height, source_view.width)
target_mask = map_source_mask_to_target(source_mask, source_view, target_eff)
assert target_mask.sum() > 0
# 从真实 target mask 提最大外轮廓得 target 多边形,再逆向映回 source 视图局部。
target_polygon = _largest_external_contour(target_mask)
[back_to_source] = target_polygons_to_source([target_polygon], source_view, target_eff)
# 形状/包围盒近似还原(栅格化+轮廓量化误差 ≤2px)。
np.testing.assert_allclose(_bbox(back_to_source), _bbox(source_polygon), atol=2.0)
def _seam_frames(width: int, height: int, split_x: int) -> list[np.ndarray]:
"""合成几帧左右分屏画面:两半是不同的常量灰度,`split_x` 处一列纯黑缝。"""
frame = np.zeros((height, width, 3), dtype=np.uint8)
frame[:, :split_x] = 180
frame[:, split_x + 1 :] = 120
return [frame.copy() for _ in range(5)]
# 三种宽度(40 例里有 5 种),分屏线在正中:右半左上角 x = W/2,平移量手算为 (W/2, 0)。
@pytest.mark.parametrize(
("width", "expected_dx"),
[(1856, 928), (1280, 640), (1024, 512)],
)
def test_view_offset_is_difference_of_view_top_left_corners(width: int, expected_dx: int) -> None:
config = CeusConfig.model_validate(load_ceus_section())
_, left, right = split_views(_seam_frames(width, 200, expected_dx), config.views)
assert view_offset(left, right) == (expected_dx, 0)
assert view_offset(right, left) == (-expected_dx, 0)
@pytest.mark.parametrize(
("width", "split_x", "box_x1", "expected_target_x1"),
[
# 造影在左半:整帧 x=300 的框挪到右半,整帧 x = 300 + 928 = 1228。
(1856, 928, 300, 1228),
# 造影在左半:整帧 x=300 → 300 + 640 = 940。
(1280, 640, 300, 940),
# 造影在左半:整帧 x=100 → 100 + 512 = 612。
(1024, 512, 100, 612),
],
)
def test_mask_moves_by_view_offset_in_frame_coordinates(
width: int, split_x: int, box_x1: int, expected_target_x1: int
) -> None:
left = ViewRect(0, 0, split_x, 200)
right = ViewRect(split_x, 0, width, 200)
source_mask = np.zeros((left.height, left.width), dtype=bool)
source_mask[50:80, box_x1 - left.x1 : box_x1 - left.x1 + 40] = True
target_mask = map_source_mask_to_target(source_mask, left, right)
ys, xs = np.where(target_mask)
assert int(xs.min()) + right.x1 == expected_target_x1
assert int(xs.max()) + right.x1 == expected_target_x1 + 39
assert (int(ys.min()), int(ys.max())) == (50, 79)
def test_mask_moves_from_right_half_to_left_half() -> None:
"""造影在右半:1856 宽、分屏线 928,整帧 x=1000 的框挪到左半,整帧 x = 1000 − 928 = 72。"""
left = ViewRect(0, 0, 928, 200)
right = ViewRect(928, 0, 1856, 200)
source_mask = np.zeros((right.height, right.width), dtype=bool)
source_mask[10:20, 1000 - 928 : 1040 - 928] = True
target_mask = map_source_mask_to_target(source_mask, right, left)
ys, xs = np.where(target_mask)
assert (int(xs.min()), int(xs.max())) == (72, 111)
assert (int(ys.min()), int(ys.max())) == (10, 19)
def test_polygons_move_back_by_negative_view_offset() -> None:
"""右半局部 (40, 30) 的点挪回左半:整帧 (968, 30) − 928 = (40, 30);超出左半的点裁到边界。"""
left = ViewRect(0, 0, 928, 200)
right = ViewRect(928, 0, 1856, 200)
polygon = np.array([[40.0, 30.0], [60.0, 30.0], [927.0, 250.0]], dtype=np.float32)
[back] = target_polygons_to_source([polygon], left, right)
np.testing.assert_allclose(back, [[40.0, 30.0], [60.0, 30.0], [927.0, 199.0]])
def test_mask_crops_when_target_half_is_narrower() -> None:
"""宽度为奇数时右半比左半窄 1 像素:不缩放,超出右半的那一列裁掉。"""
left = ViewRect(0, 0, 101, 50)
right = ViewRect(101, 0, 201, 50)
source_mask = np.zeros((50, 101), dtype=bool)
source_mask[:, 99:101] = True
target_mask = map_source_mask_to_target(source_mask, left, right)
assert target_mask.shape == (50, 100)
assert np.where(target_mask)[1].tolist() == [99] * 50
+138
View File
@@ -0,0 +1,138 @@
"""B4 关键帧选择单元测试。
钉死 findings §3.3 / 参考 P:335-372 的关键帧语义:首帧强制(change_iou=1.0)、
形状不变不新增、``change_iou < iou_threshold``(严格小于)才记新 prompt_frame、
``min_gap_frames`` 在 IoU 判定之前抑制过密、无绿帧跳过、返回结构字段齐全。
测试用真实算法路径(``mask_iou``)而非 mock:构造形状渐变的布尔 mask 绿框观测序列,
让 IoU 在阈值上下精确落点(含恰好等于阈值的边界帧)。
"""
import numpy as np
from cadx.ceus import (
CeusConfig,
ColorPrompt,
KeyframesConfig,
PromptFrame,
PromptObservation,
select_prompt_frames,
)
from conftest import load_ceus_section
def _keyframes_cfg(**overrides: object) -> KeyframesConfig:
"""提示帧配置:config/ceus.yaml 的取值(旧测试用的是同值的默认值),按需改某一项。"""
config = CeusConfig.model_validate(load_ceus_section())
return config.keyframes.model_copy(update=overrides)
def _square_prompt(side: int, canvas: int = 100) -> ColorPrompt:
"""构造一个以原点为左上角、边长 ``side`` 的实心方块绿框 ``ColorPrompt``。
mask 为 ``(canvas, canvas)`` 布尔图,``[0:side, 0:side]`` 为 True;
其余几何字段按方块几何自洽填充,仅 mask 参与关键帧 IoU 判定。
"""
mask = np.zeros((canvas, canvas), dtype=bool)
mask[0:side, 0:side] = True
contour = np.array(
[[[0, 0]], [[side - 1, 0]], [[side - 1, side - 1]], [[0, side - 1]]],
dtype=np.int32,
)
return ColorPrompt(
mask=mask,
contour=contour,
bbox_xyxy=(0, 0, side - 1, side - 1),
bbox_area=side * side,
contour_area=float((side - 1) * (side - 1)),
candidate_count=1,
)
def _obs(frame_index: int, side: int | None) -> PromptObservation:
"""构造单帧观测:``side`` 为 None 表示无绿帧(prompt=None)。"""
prompt = None if side is None else _square_prompt(side)
return PromptObservation(frame_index=frame_index, prompt=prompt)
def test_first_detected_frame_is_forced_prompt_frame_with_change_iou_one() -> None:
"""首个有绿框的观测必为 prompt_frame,change_iou 恒记 1.0。"""
observations = [_obs(0, 40)]
prompt_frames = select_prompt_frames(observations, _keyframes_cfg())
assert len(prompt_frames) == 1
assert prompt_frames[0].frame_index == 0
assert prompt_frames[0].change_iou == 1.0
def test_unchanged_shape_does_not_add_prompt_frame() -> None:
"""绿框形状完全不变(IoU=1.0 ≥ 阈值)的连续帧不新增 prompt_frame。"""
observations = [_obs(i, 40) for i in range(5)]
prompt_frames = select_prompt_frames(observations, _keyframes_cfg())
assert len(prompt_frames) == 1
assert prompt_frames[0].frame_index == 0
def test_iou_below_threshold_records_new_prompt_frame() -> None:
"""IoU 跌破阈值的帧记为新 prompt_frame,change_iou 等于实测 IoU。
边长 40→20 的方块:IoU = 20² / 40² = 0.25 < 0.92。
"""
observations = [_obs(0, 40), _obs(1, 20)]
prompt_frames = select_prompt_frames(observations, _keyframes_cfg())
assert [kf.frame_index for kf in prompt_frames] == [0, 1]
assert prompt_frames[1].change_iou == 0.25
def test_iou_equal_to_threshold_does_not_record_boundary() -> None:
"""恰好 IoU == 阈值不记新 prompt_frame(严格 ``<``,钉死 < 非 <=)。
取 iou_threshold = IoU(40, 20) = 0.25。第二帧 IoU 恰等于 0.25,
``0.25 < 0.25`` 为 False,故不新增。
"""
config = _keyframes_cfg(iou_threshold=0.25)
observations = [_obs(0, 40), _obs(1, 20)]
prompt_frames = select_prompt_frames(observations, config)
assert [kf.frame_index for kf in prompt_frames] == [0]
def test_min_gap_frames_suppresses_dense_prompt_frames() -> None:
"""``min_gap_frames`` 在 IoU 判定前门控,抑制过密 prompt_frame。
三帧 40→20→40:默认 min_gap=1 时帧 1、帧 2 都会被记(IoU 均 0.25<0.92);
设 min_gap=2 后,帧 1 距首帧仅 1 < 2 被跳过,帧 2 距首帧 2 不小于 2 才比较,
其 IoU(40,40)=1.0 ≥ 0.92 故也不记,最终仅保留首帧。
"""
observations = [_obs(0, 40), _obs(1, 20), _obs(2, 40)]
dense = select_prompt_frames(observations, _keyframes_cfg())
assert [kf.frame_index for kf in dense] == [0, 1, 2]
sparse = select_prompt_frames(observations, _keyframes_cfg(min_gap_frames=2))
assert [kf.frame_index for kf in sparse] == [0]
def test_no_green_frames_are_skipped() -> None:
"""无绿帧(prompt=None)跳过,不占用首帧强制位也不影响 frame_index 对齐。"""
observations = [_obs(0, None), _obs(1, None), _obs(2, 40), _obs(3, 20)]
prompt_frames = select_prompt_frames(observations, _keyframes_cfg())
assert [kf.frame_index for kf in prompt_frames] == [2, 3]
# 首个有绿帧(frame 2)才是强制 prompt_frame,change_iou=1.0
assert prompt_frames[0].change_iou == 1.0
def test_prompt_frame_result_structure_fields() -> None:
"""返回项含 frame_index / bbox_xyxy / change_iou,并保留几何溯源字段。"""
observations = [_obs(7, 40)]
prompt_frames = select_prompt_frames(observations, _keyframes_cfg())
prompt_frame = prompt_frames[0]
assert isinstance(prompt_frame, PromptFrame)
assert prompt_frame.frame_index == 7
assert prompt_frame.bbox_xyxy == (0, 0, 39, 39)
assert prompt_frame.change_iou == 1.0
assert prompt_frame.bbox_area == 1600
assert prompt_frame.mask.dtype == bool
def test_empty_observations_returns_empty() -> None:
"""空观测序列返回空 prompt_frame 列表(无绿帧可选)。"""
assert select_prompt_frames([], _keyframes_cfg()) == []
+130
View File
@@ -0,0 +1,130 @@
"""B2 split_screen 分屏检测单元测试(旧文件的黑边剔除部分随裁黑边功能删去)。
钉死两件事的关键性质:
- ``detect_split_x``:合成左右两半(中间一列黑缝)→ 切分线落在中缝 ±2px;
全黑 / 无缝 / 切分偏出中部合理范围 → ``SplitDetectionError``(不兜底)。
- ``view_rect``:horizontal_split 用检测到的 ``split_x`` 而非 ``W//2``;
vertical_split / full / manual_crop 分支与参考一致;非法组合 ``RuntimeError``。
测试用真实数据的二次构造(合成左右分屏帧),边缘场景靠实现 raise 保证。
"""
import numpy as np
import pytest
from cadx.ceus import (
SplitDetectionError,
ViewRect,
detect_split_x,
view_rect,
)
from conftest import load_ceus_section
# 旧 detect_split_x 的参数默认值;新代码不设默认值,从 config/ceus.yaml 读。
_LO, _HI = load_ceus_section()["views"]["split_search_band"]
BAND = (float(_LO), float(_HI))
def _split_frame(
height: int,
width: int,
split_x: int,
*,
left_gray: int = 180,
right_gray: int = 120,
) -> np.ndarray:
"""合成一帧左右分屏 BGR 图:左右两半各为常量灰度,``split_x`` 处一列纯黑缝。"""
frame = np.zeros((height, width, 3), dtype=np.uint8)
frame[:, :split_x] = left_gray
frame[:, split_x + 1 :] = right_gray
frame[:, split_x] = 0
return frame
# Phase 1: detect_split_x —— 中缝定位与防御
def test_detect_split_x_finds_centered_seam() -> None:
frames = [_split_frame(120, 200, 100) for _ in range(5)]
detected = detect_split_x(frames, search_band=BAND)
assert abs(detected - 100) <= 2
def test_detect_split_x_finds_offcenter_seam_within_band() -> None:
# 缝在 0.42*W 处,仍落在 band=(0.35,0.65) 内、左右宽度比合法。
frames = [_split_frame(120, 200, 84) for _ in range(5)]
detected = detect_split_x(frames, search_band=BAND)
assert abs(detected - 84) <= 2
def test_detect_split_x_all_black_raises() -> None:
frames = [np.zeros((120, 200, 3), dtype=np.uint8) for _ in range(3)]
with pytest.raises(SplitDetectionError):
detect_split_x(frames, search_band=BAND)
def test_detect_split_x_no_seam_raises() -> None:
# 整幅均匀灰度、无暗缝:中部 band 内列均值平坦,找不到稳定低谷。
frames = [np.full((120, 200, 3), 150, dtype=np.uint8) for _ in range(3)]
with pytest.raises(SplitDetectionError):
detect_split_x(frames, search_band=BAND)
def test_detect_split_x_empty_frames_raises() -> None:
with pytest.raises(SplitDetectionError):
detect_split_x([], search_band=BAND)
def test_detect_split_x_reversed_band_raises() -> None:
# 逆序 band(lo>=hi)非法:要求 0<=lo<hi<=1,应直接报错而非兜底。
frames = [_split_frame(120, 200, 100) for _ in range(3)]
with pytest.raises(SplitDetectionError):
detect_split_x(frames, search_band=(0.6, 0.4))
def test_detect_split_x_out_of_range_band_raises() -> None:
# 越界 band(hi>1)非法。
frames = [_split_frame(120, 200, 100) for _ in range(3)]
with pytest.raises(SplitDetectionError):
detect_split_x(frames, search_band=(0.35, 1.5))
def test_detect_split_x_width_ratio_violation_raises() -> None:
# 把 band 放宽到几乎整宽,缝置于极偏位置(仍在 band 内),使左右宽度比越界。
# split_x=20、W=200 → 左 20 / 右 180,比值 0.11 < 0.5 → SplitDetectionError。
frames = [_split_frame(120, 200, 20) for _ in range(3)]
with pytest.raises(SplitDetectionError):
detect_split_x(frames, search_band=(0.05, 0.95))
# Phase 2: view_rect —— horizontal_split 用 split_x,而非 W//2
def test_view_rect_horizontal_split_uses_split_x() -> None:
width, height, split_x = 200, 120, 84
left = view_rect({"layout": "horizontal_split"}, "left", width, height, split_x=split_x)
right = view_rect({"layout": "horizontal_split"}, "right", width, height, split_x=split_x)
assert left == ViewRect(0, 0, split_x, height)
assert right == ViewRect(split_x, 0, width, height)
# 与朴素 W//2 不同,证明用了检测值。
assert split_x != width // 2
def test_view_rect_vertical_split() -> None:
width, height = 200, 120
top = view_rect({"layout": "vertical_split"}, "top", width, height, split_x=None)
bottom = view_rect({"layout": "vertical_split"}, "bottom", width, height, split_x=None)
assert top == ViewRect(0, 0, width, height // 2)
assert bottom == ViewRect(0, height // 2, width, height)
def test_view_rect_full() -> None:
rect = view_rect({"layout": "full"}, "left", 200, 120, split_x=None)
assert rect == ViewRect(0, 0, 200, 120)
def test_view_rect_manual_crop() -> None:
cfg = {"layout": "manual_crop", "crops": {"left": [0.0, 0.0, 0.5, 1.0]}}
rect = view_rect(cfg, "left", 200, 120, split_x=None)
assert rect == ViewRect(0, 0, 100, 120)
def test_view_rect_unsupported_pair_raises() -> None:
with pytest.raises(RuntimeError):
view_rect({"layout": "horizontal_split"}, "top", 200, 120, split_x=100)
+154
View File
@@ -0,0 +1,154 @@
"""VideoFile 单元测试:probe / iter_frames / frame_times_s / 打不开报错。
旧测试读一段真实样例视频的字节;这里在测试里用 cv2 现造一段几十帧的小视频,按路径读。
旧版先把字节落临时文件,那三条「临时文件有没有删」的测试随之不再适用,已删。
"""
from pathlib import Path
import cv2
import numpy as np
import pytest
from cadx.ceus import VideoFile
_FRAMES = 40
_WIDTH, _HEIGHT = 64, 48
@pytest.fixture(scope="module")
def sample_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""用 cv2 现造一段 40 帧、64×48、25 fps 的 mp4,每帧亮度不同。"""
path = tmp_path_factory.mktemp("video") / "sample.mp4"
writer = cv2.VideoWriter(str(path), cv2.VideoWriter.fourcc(*"mp4v"), 25.0, (_WIDTH, _HEIGHT))
for index in range(_FRAMES):
frame = np.full((_HEIGHT, _WIDTH, 3), (index * 6) % 256, dtype=np.uint8)
frame[index % _HEIGHT, :, 2] = 255
writer.write(frame)
writer.release()
return path
def test_probe_returns_positive_metadata(sample_path: Path) -> None:
"""probe 返回 (fps, frame_count, width, height):字段顺序/类型/语义范围均须正确。"""
video = VideoFile(sample_path)
result = video.probe()
assert isinstance(result, tuple) and len(result) == 4
fps, frame_count, width, height = result
# 字段类型契约(app/ports.py 的 DecodedVideo):fps 为 float,其余为 int
assert isinstance(fps, float)
assert isinstance(frame_count, int)
assert isinstance(width, int)
assert isinstance(height, int)
# 语义合理范围——并钉死字段顺序未颠倒(fps 是小帧率、尺寸是较大像素值)
assert 0 < fps < 1000 # 帧率不会是几百几千;若与 frame_count(881) 颠倒会触发
assert frame_count > 0
assert width > 1 and height > 1
def test_probe_field_order_matches_iter(sample_path: Path) -> None:
"""probe 的 (width, height) 须与 iter_frames 实际帧的 (W, H) 一致(防字段顺序颠倒)。"""
video = VideoFile(sample_path)
_fps, _frame_count, width, height = video.probe()
_idx, frame = next(video.iter_frames())
assert frame.shape == (height, width, 3)
def test_iter_frames_indices_contiguous_and_count_matches_probe(
sample_path: Path,
) -> None:
"""iter_frames 产 (idx, frame):idx 从 0 连续递增,且帧数 == probe 的 frame_count。"""
video = VideoFile(sample_path)
_, frame_count, _, _ = video.probe()
indices = [idx for idx, _frame in video.iter_frames()]
assert indices == list(range(len(indices)))
assert len(indices) == frame_count
def test_iter_frames_yields_bgr_uint8_hwc(sample_path: Path) -> None:
"""每帧为 (H, W, 3) uint8 的 ndarray,且 H/W 与 probe 一致。"""
video = VideoFile(sample_path)
_, _, width, height = video.probe()
idx, frame = next(video.iter_frames())
assert idx == 0
assert isinstance(frame, np.ndarray)
assert frame.dtype == np.uint8
assert frame.shape == (height, width, 3)
def test_frame_times_are_strictly_increasing_and_match_frame_count(
sample_path: Path,
) -> None:
video = VideoFile(sample_path)
_fps, frame_count, _width, _height = video.probe()
times = video.frame_times_s()
assert times.shape == (frame_count,)
assert times[0] >= 0.0
assert np.all(np.diff(times) > 0.0)
def test_解两遍逐帧像素完全相同(sample_path: Path) -> None:
"""**这条是接缝改成「打开一次」的全部理由**(design/0024)。
CEUS 那条线要分几趟过同一段视频,每趟只留这一趟的小结果。第二趟必须从第 0 帧重新开始,
而且看到的像素要和第一趟一模一样——多趟方案整个建立在这个前提上。它今天成立是因为
每趟各开一个 VideoCapture 顺序读同一个文件,但**这件事此前没有任何一处验证过**。
**比的是全片,而且是流式比。** 只比前几帧的话,可变帧率或者 seek 行为导致的漂移多半
出现在后面而不是开头。两趟各自逐帧过、逐帧喂进哈希,峰值内存是两帧——**这条测试自己
也不许把视频攒起来**,它守的正是这件事。
"""
import hashlib
def _digest(video: VideoFile) -> tuple[str, int]:
h = hashlib.blake2b(digest_size=16)
count = 0
for _, frame in video.iter_frames():
h.update(np.ascontiguousarray(frame).tobytes())
count += 1
return h.hexdigest(), count
video = VideoFile(sample_path)
first = _digest(video)
second = _digest(video)
assert first[1] > 0
assert first == second, "同一段视频解两遍,逐帧像素必须完全相同"
def test_两趟各自从第零帧开始互不干扰(sample_path: Path) -> None:
"""两趟同时在飞时各读各的游标。
**断言比的是像素,不是 iter_frames 自己报的下标**——那个下标是本模块里一个 Python
计数器,就算内层真把外层的读位置搅乱了,它照样从 0 数上去。
"""
video = VideoFile(sample_path)
outer = video.iter_frames()
_, outer_frame_0 = next(outer)
# 内层整个跑起来,中途丢掉——它不该动到外层的游标
inner = video.iter_frames()
_, inner_frame_0 = next(inner)
_, inner_frame_1 = next(inner)
del inner
_, outer_frame_1 = next(outer)
reference = [frame for _, frame in zip(video.iter_frames(), range(2), strict=False)]
assert np.array_equal(inner_frame_0, outer_frame_0), "内层没有从第 0 帧开始"
assert np.array_equal(outer_frame_1, inner_frame_1), "外层被内层带跑了:它接着读到的不是第 1 帧"
assert len(reference) == 2
def test_bad_bytes_raise_not_silent(tmp_path: Path) -> None:
"""坏文件无法打开 → 报错(不静默返回空 probe / 空迭代器)。"""
garbage = tmp_path / "garbage.mp4"
garbage.write_bytes(b"this is not a video file" * 64)
video = VideoFile(garbage)
with pytest.raises(RuntimeError):
video.probe()
with pytest.raises(RuntimeError):
list(video.iter_frames())