feat(ceus): 两半坐标换算改为每段视频配准,加目视核对脚本
- 第 1 步分屏后在两半扇形区内用边缘图归一化互相关估 (dx, dy),再用逐帧多块模板匹配的中位数独立核对; 两者各分量之差超过门槛(15 像素)这一例第 1 步失败。删掉「两个视图左上角相减」的实现与测试。 - 门槛依据:34 例中走完第 1 步的 29 例,差值中位 2、95 分位 6.2、最大 13;参数与门槛在同一批视频上调出。 - scripts/ceus_align_review.py:只做第 1 步,出扇形区棋盘图与边缘叠加图,混入错开 15–30 像素的 阴性对照、随机编号;计分时任一对照未被判「没对齐」即作废。 - ceus_quant 读 review.csv:不通过的病例第 1 步失败;表里没有这段视频或平移对不上即停。 - 配准与紫圈颜色参数进分割缓存键。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
"""`scripts/ceus_align_review.py`:配准目视核对的出图和计分(任务书 02「配准的目视核对」)。
|
||||
|
||||
出图:假交付包两例合成分屏视频(`fake_ceus_video`,两半扇形对齐,配准 (0, 0)),对照取 1 例。
|
||||
计分:key.csv 和两份判读手写,预期按任务书的规则手算。表头按任务书和协调者给的列名独立写。
|
||||
"""
|
||||
|
||||
import csv
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import math
|
||||
import re
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from conftest import CEUS_YAML
|
||||
from fake_ceus_video import write_video
|
||||
from fake_delivery import make_delivery
|
||||
|
||||
_SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "ceus_align_review.py"
|
||||
_KEY_HEADER = ["id", "video_id", "is_control", "dx", "dy", "video_sha256"]
|
||||
_VERDICT_HEADER = ["编号", "判定", "依据"]
|
||||
_REVIEW_HEADER = ["video_sha256", "dx", "dy", "passed", "verdict_a", "verdict_b"]
|
||||
|
||||
|
||||
def _load_script() -> ModuleType:
|
||||
spec = importlib.util.spec_from_file_location("ceus_align_review", _SCRIPT)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _read(path: Path) -> list[list[str]]:
|
||||
with path.open(encoding="utf-8", newline="") as handle:
|
||||
return list(csv.reader(handle))
|
||||
|
||||
|
||||
def _write(path: Path, rows: list[list[str]]) -> None:
|
||||
with path.open("w", encoding="utf-8", newline="") as handle:
|
||||
csv.writer(handle).writerows(rows)
|
||||
|
||||
|
||||
def test_render_writes_shuffled_images_controls_and_key(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
delivery_dir = make_delivery(2).write(tmp_path / "delivery")
|
||||
videos = {
|
||||
"U001": delivery_dir / "CEUS_agent" / "001" / "U001_roi2.mp4",
|
||||
"U002": delivery_dir / "CEUS_agent" / "002" / "U002_roi2.mp4",
|
||||
}
|
||||
write_video(videos["U001"], green_box=True)
|
||||
write_video(videos["U002"], green_box=False) # 第 2 步才会失败,第 1 步照常出图
|
||||
document: dict[str, Any] = yaml.safe_load(CEUS_YAML.read_text(encoding="utf-8"))
|
||||
document["paths"].update(
|
||||
delivery_dir=str(delivery_dir),
|
||||
align_runs_root=str(tmp_path / "align"),
|
||||
align_review=str(tmp_path / "unused_review.csv"), # 出图用不到,文件不存在也行
|
||||
)
|
||||
document["align_check"]["control_count"] = 1
|
||||
config = tmp_path / "ceus.yaml"
|
||||
config.write_text(yaml.safe_dump(document, allow_unicode=True), encoding="utf-8")
|
||||
module = _load_script()
|
||||
|
||||
def fake_run_id(now: object, git: object) -> str:
|
||||
return "run-a"
|
||||
|
||||
monkeypatch.setattr(module, "make_build_id", fake_run_id)
|
||||
module.main(["render", "--config", str(config)])
|
||||
|
||||
run_dir = tmp_path / "align" / "run-a"
|
||||
assert not (tmp_path / "align" / "run-a.partial").exists()
|
||||
key = _read(run_dir / "key.csv")
|
||||
assert key[0] == _KEY_HEADER
|
||||
rows = key[1:]
|
||||
# 2 例真实 + 1 例对照,编号是 001–003 的一个排列,每个编号两张图。
|
||||
assert sorted(row[0] for row in rows) == ["001", "002", "003"]
|
||||
names = sorted(p.name for p in (run_dir / "images").iterdir())
|
||||
assert names == sorted(f"{row[0]}_{kind}.png" for row in rows for kind in ("checker", "edges"))
|
||||
assert all(re.fullmatch(r"\d{3}_(checker|edges)\.png", name) for name in names)
|
||||
real = [row for row in rows if row[2] == "false"]
|
||||
controls = [row for row in rows if row[2] == "true"]
|
||||
assert sorted(row[1] for row in real) == ["U001", "U002"]
|
||||
assert [(row[3], row[4]) for row in real] == [("0", "0"), ("0", "0")]
|
||||
(control,) = controls
|
||||
# 对照的平移 = 真实配准 (0, 0) + 长度 15–30 像素的随机错开(取整后容差 1 像素)。
|
||||
assert 14 <= math.hypot(int(control[3]), int(control[4])) <= 31
|
||||
for row in rows:
|
||||
assert row[5] == hashlib.sha256(videos[row[1]].read_bytes()).hexdigest()
|
||||
checker = cv2.imread(str(run_dir / "images" / f"{control[0]}_checker.png"))
|
||||
edges = cv2.imread(str(run_dir / "images" / f"{control[0]}_edges.png"))
|
||||
assert checker is not None and edges is not None and checker.shape == edges.shape
|
||||
assert _read(run_dir / "failed.csv") == [["video_id", "step", "reason"]]
|
||||
assert (run_dir / "snapshot" / "ceus.yaml").read_bytes() == config.read_bytes()
|
||||
out = capsys.readouterr().out
|
||||
assert "U001" not in out and "U002" not in out
|
||||
|
||||
|
||||
# 手写的 key.csv:两例真实(001、003)、一例对照(002)。
|
||||
_KEY = [
|
||||
_KEY_HEADER,
|
||||
["001", "V1", "false", "-24", "0", "a" * 64],
|
||||
["002", "V1", "true", "-4", "17", "a" * 64],
|
||||
["003", "V2", "false", "183", "-2", "b" * 64],
|
||||
]
|
||||
|
||||
|
||||
def _score(module: ModuleType, run_dir: Path, a: list[list[str]], b: list[list[str]]) -> None:
|
||||
_write(run_dir / "key.csv", _KEY)
|
||||
_write(run_dir / "verdicts_a.csv", [_VERDICT_HEADER, *a])
|
||||
_write(run_dir / "verdicts_b.csv", [_VERDICT_HEADER, *b])
|
||||
module.main(["score", "--run-dir", str(run_dir)])
|
||||
|
||||
|
||||
def test_score_writes_review_when_controls_caught(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""对照两人都判「没对齐」;001 两人都判对齐 → 通过,003 有一人看不清 → 不通过。"""
|
||||
module = _load_script()
|
||||
a = [["001", "对齐", "血管连续"], ["002", "没对齐", "边缘错开"], ["003", "对齐", "扇形重合"]]
|
||||
b = [["001", "对齐", "血管连续"], ["002", "没对齐", "边缘错开"], ["003", "看不清", "太暗"]]
|
||||
_score(module, tmp_path, a, b)
|
||||
|
||||
assert _read(tmp_path / "review.csv") == [
|
||||
_REVIEW_HEADER,
|
||||
["a" * 64, "-24", "0", "true", "对齐", "对齐"],
|
||||
["b" * 64, "183", "-2", "false", "对齐", "看不清"],
|
||||
]
|
||||
out = capsys.readouterr().out
|
||||
assert "通过 1,不通过 1" in out
|
||||
assert "「看不清」1 例" in out and "不一致 1 例" in out
|
||||
# ceus_quant 读得回这份表。
|
||||
review = _load_quant().read_review(tmp_path / "review.csv")
|
||||
assert review["a" * 64].offset == (-24, 0) and review["a" * 64].passed is True
|
||||
assert review["b" * 64].passed is False
|
||||
|
||||
|
||||
def _load_quant() -> ModuleType:
|
||||
spec = importlib.util.spec_from_file_location("ceus_quant", _SCRIPT.parent / "ceus_quant.py")
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
@pytest.mark.parametrize("control_verdict", ["对齐", "看不清"])
|
||||
def test_score_is_void_when_a_control_is_missed(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str], control_verdict: str
|
||||
) -> None:
|
||||
module = _load_script()
|
||||
a = [["001", "对齐", "x"], ["002", "没对齐", "x"], ["003", "对齐", "x"]]
|
||||
b = [["001", "对齐", "x"], ["002", control_verdict, "x"], ["003", "对齐", "x"]]
|
||||
with pytest.raises(SystemExit) as raised:
|
||||
_score(module, tmp_path, a, b)
|
||||
assert raised.value.code == 1
|
||||
assert not (tmp_path / "review.csv").exists()
|
||||
assert "作废" in capsys.readouterr().out
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("rows", "message"),
|
||||
[
|
||||
([["001", "对齐", "x"], ["002", "没对齐", "x"]], "缺"), # 缺 003
|
||||
(
|
||||
[
|
||||
["001", "对齐", "x"],
|
||||
["002", "没对齐", "x"],
|
||||
["003", "对齐", "x"],
|
||||
["004", "对齐", "x"],
|
||||
],
|
||||
"多",
|
||||
),
|
||||
([["001", "对齐", "x"], ["002", "没对齐", "x"], ["003", "差不多", "x"]], "判定只能是"),
|
||||
(
|
||||
[
|
||||
["001", "对齐", "x"],
|
||||
["001", "对齐", "x"],
|
||||
["002", "没对齐", "x"],
|
||||
["003", "对齐", "x"],
|
||||
],
|
||||
"重复",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_score_rejects_malformed_verdicts(
|
||||
tmp_path: Path, rows: list[list[str]], message: str
|
||||
) -> None:
|
||||
module = _load_script()
|
||||
good = [["001", "对齐", "x"], ["002", "没对齐", "x"], ["003", "对齐", "x"]]
|
||||
with pytest.raises(ValueError, match=message):
|
||||
_score(module, tmp_path, rows, good)
|
||||
assert not (tmp_path / "review.csv").exists()
|
||||
Reference in New Issue
Block a user