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:
@@ -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
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()) == []
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user