feat(build): 任务书 02 第四阶段代码——build 装入 CEUS 结果
- build.yaml 加必填字段 ceus_run(CEUS 运行目录或 null)。 - 不为 null 时按视频号找结果;在 failed.csv 里或缺结果目录的整例跳过,记 C15;关键帧图沿用 C14 检查。 - 资料库超声一路加 keyframes/<槽位>.png(去附加数据重编码)和 tic.json(白名单逐字段构造: 四条曲线的拟合成败与 R²、灌注指标、紫圈曲线是否可用、7 个槽位的定位与视频时间或原因)。 - manifest 的 CEUS 来源写运行号;泄露扫描已覆盖 tic.json,补命中测试。 - 真实构建等配准改完、全量 CEUS 运行之后再做。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
"""测试用假 CEUS 运行目录:布局照 `scripts/ceus_quant.py` 写出的(failed.csv、snapshot/、
|
||||
`<视频号>/summary.json`、`<视频号>/keyframes/<槽位>.png`),数值全部编造,不调显卡。
|
||||
|
||||
summary.json 故意带上 tic.json 不该有的字段(视频号、分割缓存键、帧号、拟合参数、失败原因代码、
|
||||
推注时间、紫圈检出率),用来测 build 只按白名单挑字段。关键帧 PNG 带文本块和 EXIF。
|
||||
"""
|
||||
|
||||
import csv
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from PIL import Image, PngImagePlugin
|
||||
|
||||
SLOTS = ("B0", "D1", "D2", "D3", "D4", "F1", "F2")
|
||||
CURVES = ("roi_all", "roi_perf", "roi_pan", "background_circle")
|
||||
UNLOCATED_REASON = "拟合曲线上没有 D4 时刻(编造的原因)"
|
||||
|
||||
|
||||
def _perfusion(scale: float) -> dict[str, Any]:
|
||||
names = ("PE_au", "WiAUC_au_s", "RT_s", "mTTl_s", "TTP_s", "WiR_au_per_s", "WiPI_au")
|
||||
more = ("WoAUC_au_s", "WiWoAUC_au_s", "FT_s", "WoR_au_per_s")
|
||||
return {
|
||||
"signal_domain": "db_display",
|
||||
"time_origin": "video_start",
|
||||
"area_curve": "fitted_minus_baseline",
|
||||
"values": {name: scale * (i + 1) for i, name in enumerate((*names, *more))},
|
||||
"supporting_times_s": {"TI": 3.0, "mTT": 9.5, "TO": None},
|
||||
}
|
||||
|
||||
|
||||
def _fit(success: bool, r2: float | None) -> dict[str, Any]:
|
||||
return {
|
||||
"success": success,
|
||||
"failure_reason": None if success else "credible_peak_not_found",
|
||||
"curve_model": "lognormal",
|
||||
"baseline_source": "observed",
|
||||
"valid_samples": 120,
|
||||
"duplicate_samples": 3,
|
||||
"parameters": {
|
||||
"area": 1.0,
|
||||
"mu": 2.0,
|
||||
"sigma": 0.5,
|
||||
"arrival_offset_s": 4.0,
|
||||
"baseline": 9,
|
||||
},
|
||||
"quality": {"r2": r2, "nrmse": 0.1, "residual_noise_ratio": 1.2, "multi_peak_flag": False},
|
||||
"model_arrival_time_video_s": 4.0,
|
||||
"model_landmark_times_video_s": {"D2": 6.0, "D3": 9.0, "D4": 15.0},
|
||||
"model_landmark_intensities": {"D2": 1.0, "D3": 2.0, "D4": 1.5},
|
||||
}
|
||||
|
||||
|
||||
def summary(video_id: str, located: tuple[str, ...]) -> dict[str, Any]:
|
||||
"""一例的 summary.json:`located` 里的槽位定位到(视频时间=槽位序号×2.5 秒),余下未定位。"""
|
||||
anchors = {
|
||||
slot: {
|
||||
"frame_index": index * 25 if slot in located else None,
|
||||
"frame_time_video_s": index * 2.5 if slot in located else None,
|
||||
"time_since_injection_s": index * 2.5 + 5.0 if slot in located else None,
|
||||
"status": "ok" if slot in located else "anchor_undefined",
|
||||
"reason": None if slot in located else UNLOCATED_REASON,
|
||||
"anchor_time_video_s": index * 2.5,
|
||||
"time_error_s": 0.0 if slot in located else None,
|
||||
"source_curve": "roi_perf",
|
||||
}
|
||||
for index, slot in enumerate(SLOTS)
|
||||
}
|
||||
return {
|
||||
"video_id": video_id,
|
||||
"segment_cache_key": "f" * 64,
|
||||
"injection_offset_s": 5.0,
|
||||
"reference_valid": False,
|
||||
"pan_detected_fraction": 0.4,
|
||||
"early_truncation_flag": False,
|
||||
"lesion_fit_ready": True,
|
||||
"fits": {
|
||||
"roi_all": _fit(True, 0.93),
|
||||
"roi_perf": _fit(True, 0.91),
|
||||
"roi_pan": _fit(False, None),
|
||||
"background_circle": _fit(False, 0.2),
|
||||
},
|
||||
"perfusion_metrics": {"roi_all": _perfusion(1.0), "roi_perf": None},
|
||||
"anchor_frames": anchors,
|
||||
"available_dynamic_slots": [s for s in ("B0", "D1", "D2", "D3", "D4") if s in located],
|
||||
}
|
||||
|
||||
|
||||
def keyframe_png(path: Path, shade: int) -> None:
|
||||
"""一张 8 位 RGB 关键帧,带文本块和 EXIF(写进资料库前要去掉)。"""
|
||||
pixels = bytes(v for i in range(12) for v in (shade, i * 20, 200 - i * 10))
|
||||
image = Image.frombytes("RGB", (4, 3), pixels)
|
||||
info = PngImagePlugin.PngInfo()
|
||||
info.add_text("Source", "U999_roi2.mp4")
|
||||
exif = Image.Exif()
|
||||
exif[0x010E] = "U999" # ImageDescription
|
||||
image.save(path, format="PNG", pnginfo=info, exif=exif.tobytes())
|
||||
|
||||
|
||||
def write_run(
|
||||
run_dir: Path,
|
||||
results: dict[str, tuple[str, ...]],
|
||||
failed: list[tuple[str, int, str]],
|
||||
) -> Path:
|
||||
"""写一个已完成的运行目录:`results` 是视频号 → 定位到的槽位,`failed` 是 failed.csv 的行。"""
|
||||
(run_dir / "snapshot").mkdir(parents=True)
|
||||
(run_dir / "snapshot" / "snapshot.json").write_text("{}", encoding="utf-8")
|
||||
with (run_dir / "failed.csv").open("w", encoding="utf-8", newline="") as handle:
|
||||
writer = csv.writer(handle)
|
||||
writer.writerow(("video_id", "step", "reason"))
|
||||
writer.writerows(failed)
|
||||
for video_id, located in results.items():
|
||||
(run_dir / video_id / "keyframes").mkdir(parents=True)
|
||||
content = json.dumps(summary(video_id, located), ensure_ascii=False, indent=2)
|
||||
(run_dir / video_id / "summary.json").write_text(content, encoding="utf-8")
|
||||
for shade, slot in enumerate(located):
|
||||
keyframe_png(run_dir / video_id / "keyframes" / f"{slot}.png", shade * 30)
|
||||
return run_dir
|
||||
@@ -235,7 +235,11 @@ def _write_png(path: Path, number: int) -> None:
|
||||
|
||||
|
||||
def write_config(
|
||||
tmp_path: Path, delivery_dir: Path, seed: int, code_maps: dict[str, dict[int, str]] | None
|
||||
tmp_path: Path,
|
||||
delivery_dir: Path,
|
||||
seed: int,
|
||||
code_maps: dict[str, dict[int, str]] | None,
|
||||
ceus_run: Path | None,
|
||||
) -> Path:
|
||||
"""在 tmp_path 写一份 build.yaml(路径都写绝对路径);给了映射表就同时写 code_maps.yaml。"""
|
||||
maps_path = None
|
||||
@@ -249,6 +253,7 @@ def write_config(
|
||||
"alias_seed": seed,
|
||||
"decode_codes": code_maps is not None,
|
||||
"code_maps": None if maps_path is None else str(maps_path),
|
||||
"ceus_run": None if ceus_run is None else str(ceus_run),
|
||||
}
|
||||
path = tmp_path / "build.yaml"
|
||||
path.write_text(yaml.safe_dump(config, allow_unicode=True), encoding="utf-8")
|
||||
@@ -262,6 +267,6 @@ def build(
|
||||
code_maps: dict[str, dict[int, str]] | None,
|
||||
now: datetime,
|
||||
) -> BuildSummary:
|
||||
"""写出假交付包和配置,跑一次构建。"""
|
||||
"""写出假交付包和配置,跑一次不装 CEUS 结果的构建。"""
|
||||
delivery_dir = delivery.write(tmp_path / "delivery")
|
||||
return run_build(write_config(tmp_path, delivery_dir, seed, code_maps), REPO_ROOT, now)
|
||||
return run_build(write_config(tmp_path, delivery_dir, seed, code_maps, None), REPO_ROOT, now)
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
"""build 装入 CEUS 结果:C15 跳过、tic.json 只有白名单字段、关键帧去附加数据、运行目录不合规时停下。
|
||||
|
||||
预期的 tic.json 键集合照任务书 02 第四阶段的文字手写:四条曲线各自拟合是否成功和拟合优度(R²);
|
||||
病灶两条曲线的灌注指标(同旧 replay summary 的 perfusion_metrics 段);紫圈曲线是否可用;
|
||||
7 个槽位各自是否定位到、定位到的视频时间、未定位的原因。
|
||||
"""
|
||||
|
||||
import csv
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from cadx.answers import read_aliases
|
||||
from cadx.build import BuildSummary, run_build
|
||||
from cadx.library import list_aliases, read_ultrasound, scan_for_leaks
|
||||
from fake_ceus_run import UNLOCATED_REASON, summary, write_run
|
||||
from fake_delivery import REPO_ROOT, make_delivery, write_config
|
||||
|
||||
NOW = datetime(2026, 10, 16, 11, 0, 0).astimezone()
|
||||
RUN_ID = "20261016-100000-abcdef0"
|
||||
LOCATED = ("D1", "D2", "F1")
|
||||
|
||||
_PERFUSION_KEYS = {
|
||||
"signal_domain",
|
||||
"time_origin",
|
||||
"area_curve",
|
||||
"values",
|
||||
"supporting_times_s",
|
||||
}
|
||||
_PERFUSION_VALUES = {
|
||||
"PE_au",
|
||||
"WiAUC_au_s",
|
||||
"RT_s",
|
||||
"mTTl_s",
|
||||
"TTP_s",
|
||||
"WiR_au_per_s",
|
||||
"WiPI_au",
|
||||
"WoAUC_au_s",
|
||||
"WiWoAUC_au_s",
|
||||
"FT_s",
|
||||
"WoR_au_per_s",
|
||||
}
|
||||
|
||||
|
||||
def _build(tmp_path: Path, count: int, run_dir: Path) -> BuildSummary:
|
||||
root = make_delivery(count).write(tmp_path / "delivery")
|
||||
return run_build(write_config(tmp_path, root, 1, None, run_dir), REPO_ROOT, NOW)
|
||||
|
||||
|
||||
def _skipped(answers_dir: Path) -> list[list[str]]:
|
||||
with (answers_dir / "skipped.csv").open(encoding="utf-8-sig", newline="") as handle:
|
||||
return list(csv.reader(handle))[1:]
|
||||
|
||||
|
||||
def _png_chunks(data: bytes) -> list[str]:
|
||||
chunks: list[str] = []
|
||||
offset = 8
|
||||
while offset < len(data):
|
||||
length = int.from_bytes(data[offset : offset + 4], "big")
|
||||
chunks.append(data[offset + 4 : offset + 8].decode("ascii"))
|
||||
offset += 12 + length
|
||||
return chunks
|
||||
|
||||
|
||||
def _key_tree(node: Any) -> Any:
|
||||
"""嵌套字典的键结构:字典换成「键 → 子结构」,其余值换成 None。"""
|
||||
if isinstance(node, dict):
|
||||
return {key: _key_tree(value) for key, value in cast(dict[str, Any], node).items()}
|
||||
return None
|
||||
|
||||
|
||||
def test_c15_skips_failed_and_missing_cases(tmp_path: Path) -> None:
|
||||
run = write_run(
|
||||
tmp_path / "runs" / RUN_ID,
|
||||
{"U001": LOCATED, "U004": LOCATED},
|
||||
[("U002", 2, "两半都没有绿框")],
|
||||
)
|
||||
result = _build(tmp_path, 4, run)
|
||||
assert [r.case_no for r in read_aliases(result.answers_dir)] == ["001", "004"]
|
||||
assert len(list_aliases(result.library_dir)) == 2
|
||||
skipped = _skipped(result.answers_dir)
|
||||
assert [row[:4] for row in skipped] == [
|
||||
["002", "C15", "CEUS 定量结果齐全", f"{RUN_ID}/failed.csv"],
|
||||
["003", "C15", "CEUS 定量结果齐全", f"{RUN_ID}/U003"],
|
||||
]
|
||||
assert json.loads(skipped[0][5]) == "第 2 步失败:两半都没有绿框"
|
||||
assert "没有这一例的结果目录" in json.loads(skipped[1][5])
|
||||
assert result.skipped_by_check == {"C15": 2}
|
||||
manifest = json.loads((result.library_dir / "manifest.json").read_text(encoding="utf-8"))
|
||||
assert manifest["ceus_source"] == RUN_ID
|
||||
|
||||
|
||||
def test_tic_json_has_exactly_the_listed_fields(tmp_path: Path) -> None:
|
||||
result = _build(tmp_path, 1, write_run(tmp_path / "runs" / RUN_ID, {"U001": LOCATED}, []))
|
||||
(alias,) = list_aliases(result.library_dir)
|
||||
tic = read_ultrasound(result.library_dir, alias).tic
|
||||
perfusion = {
|
||||
**dict.fromkeys(_PERFUSION_KEYS - {"values", "supporting_times_s"}),
|
||||
"values": dict.fromkeys(_PERFUSION_VALUES),
|
||||
"supporting_times_s": dict.fromkeys(("TI", "mTT", "TO")),
|
||||
}
|
||||
slot = {"located": None, "video_time_s": None, "reason": None}
|
||||
curves = ("roi_all", "roi_perf", "roi_pan", "background_circle")
|
||||
assert _key_tree(tic) == {
|
||||
"fits": {name: {"success": None, "r2": None} for name in curves},
|
||||
"perfusion_metrics": {"roi_all": perfusion, "roi_perf": None},
|
||||
"pan_curve_usable": None,
|
||||
"keyframes": {s: slot for s in ("B0", "D1", "D2", "D3", "D4", "F1", "F2")},
|
||||
}
|
||||
assert tic is not None
|
||||
assert tic["fits"]["roi_all"] == {"success": True, "r2": 0.93}
|
||||
assert tic["fits"]["roi_pan"] == {"success": False, "r2": None}
|
||||
assert tic["pan_curve_usable"] is False
|
||||
assert tic["perfusion_metrics"]["roi_all"]["values"]["PE_au"] == 1.0
|
||||
# 假数据里 D1 是第 2 个槽位:视频时间 1 × 2.5 秒。
|
||||
assert tic["keyframes"]["D1"] == {"located": True, "video_time_s": 2.5, "reason": None}
|
||||
assert tic["keyframes"]["D4"] == {
|
||||
"located": False,
|
||||
"video_time_s": None,
|
||||
"reason": UNLOCATED_REASON,
|
||||
}
|
||||
|
||||
|
||||
def test_keyframes_metadata_dropped_and_pixels_kept(tmp_path: Path) -> None:
|
||||
run = write_run(tmp_path / "runs" / RUN_ID, {"U001": LOCATED}, [])
|
||||
result = _build(tmp_path, 1, run)
|
||||
(alias,) = list_aliases(result.library_dir)
|
||||
route = read_ultrasound(result.library_dir, alias)
|
||||
assert sorted(route.keyframes) == sorted(LOCATED)
|
||||
for slot, copied in route.keyframes.items():
|
||||
assert (
|
||||
copied == result.library_dir / "cases" / alias / "ultrasound/keyframes" / f"{slot}.png"
|
||||
)
|
||||
with Image.open(run / "U001" / "keyframes" / f"{slot}.png") as original:
|
||||
assert "Source" in original.info and "exif" in original.info
|
||||
pixels = original.tobytes()
|
||||
with Image.open(copied) as image:
|
||||
assert image.mode == "RGB" and image.tobytes() == pixels
|
||||
assert "Source" not in image.info and "exif" not in image.info
|
||||
assert _png_chunks(copied.read_bytes()) == ["IHDR", "IDAT", "IEND"]
|
||||
|
||||
|
||||
def test_null_ceus_run_has_no_ceus_files(tmp_path: Path) -> None:
|
||||
root = make_delivery(2).write(tmp_path / "delivery")
|
||||
result = run_build(write_config(tmp_path, root, 1, None, None), REPO_ROOT, NOW)
|
||||
for alias in list_aliases(result.library_dir):
|
||||
route = read_ultrasound(result.library_dir, alias)
|
||||
assert route.tic is None and route.keyframes == {}
|
||||
assert sorted(
|
||||
p.name for p in (result.library_dir / "cases" / alias / "ultrasound").iterdir()
|
||||
) == [
|
||||
"key.png",
|
||||
"report.txt",
|
||||
]
|
||||
|
||||
|
||||
def test_scan_finds_nothing_then_hits_video_id_in_tic_json(tmp_path: Path) -> None:
|
||||
run = write_run(tmp_path / "runs" / RUN_ID, {"U001": LOCATED, "U002": LOCATED}, [])
|
||||
result = _build(tmp_path, 2, run)
|
||||
forbidden = ["P001", "P002", "U001", "U002", "U001_roi2.mp4", "U002_roi2.mp4", "U999"]
|
||||
assert scan_for_leaks(result.library_dir, forbidden, ("name",)) == []
|
||||
alias = list_aliases(result.library_dir)[0]
|
||||
tic_path = result.library_dir / "cases" / alias / "ultrasound" / "tic.json"
|
||||
tic = json.loads(tic_path.read_text(encoding="utf-8"))
|
||||
tic["keyframes"]["D4"]["reason"] = "见 U002 第 3 帧"
|
||||
tic_path.write_text(json.dumps(tic, ensure_ascii=False), encoding="utf-8")
|
||||
hits = scan_for_leaks(result.library_dir, forbidden, ("name",))
|
||||
assert [(Path(h.file).name, h.kind, h.term) for h in hits] == [("tic.json", "字符串", "U002")]
|
||||
|
||||
|
||||
def test_reason_with_video_id_stops_build(tmp_path: Path) -> None:
|
||||
run = write_run(tmp_path / "runs" / RUN_ID, {"U001": LOCATED}, [])
|
||||
content = summary("U001", LOCATED)
|
||||
content["anchor_frames"]["D4"]["reason"] = "U001 的 D4 不在视频里"
|
||||
(run / "U001" / "summary.json").write_text(json.dumps(content), encoding="utf-8")
|
||||
with pytest.raises(ValueError, match="视频号或路径"):
|
||||
_build(tmp_path, 1, run)
|
||||
assert not (tmp_path / "library").exists()
|
||||
|
||||
|
||||
def test_keyframe_that_cannot_be_reencoded_skips_case_as_c14(tmp_path: Path) -> None:
|
||||
run = write_run(tmp_path / "runs" / RUN_ID, {"U001": LOCATED, "U002": LOCATED}, [])
|
||||
Image.new("I;16", (4, 4)).save(run / "U002" / "keyframes" / "D2.png", format="PNG")
|
||||
result = _build(tmp_path, 2, run)
|
||||
assert [r.case_no for r in read_aliases(result.answers_dir)] == ["001"]
|
||||
skipped = _skipped(result.answers_dir)
|
||||
assert [row[:4] for row in skipped] == [
|
||||
["002", "C14", "关键图能原样重编码", f"{RUN_ID}/U002/keyframes/D2.png"]
|
||||
]
|
||||
|
||||
|
||||
def _assert_stops(tmp_path: Path, run: Path, error: type[Exception]) -> None:
|
||||
with pytest.raises(error):
|
||||
_build(tmp_path, 1, run)
|
||||
assert not (tmp_path / "library").exists() and not (tmp_path / "answers").exists()
|
||||
|
||||
|
||||
def test_run_dir_missing_stops(tmp_path: Path) -> None:
|
||||
_assert_stops(tmp_path, tmp_path / "runs" / RUN_ID, FileNotFoundError)
|
||||
|
||||
|
||||
def test_partial_run_dir_stops(tmp_path: Path) -> None:
|
||||
run = write_run(tmp_path / "runs" / f"{RUN_ID}.partial", {"U001": LOCATED}, [])
|
||||
_assert_stops(tmp_path, run, ValueError)
|
||||
|
||||
|
||||
def test_run_dir_without_failed_csv_stops(tmp_path: Path) -> None:
|
||||
run = write_run(tmp_path / "runs" / RUN_ID, {"U001": LOCATED}, [])
|
||||
(run / "failed.csv").unlink()
|
||||
_assert_stops(tmp_path, run, FileNotFoundError)
|
||||
|
||||
|
||||
def test_run_dir_without_snapshot_stops(tmp_path: Path) -> None:
|
||||
run = write_run(tmp_path / "runs" / RUN_ID, {"U001": LOCATED}, [])
|
||||
(run / "snapshot" / "snapshot.json").unlink()
|
||||
_assert_stops(tmp_path, run, FileNotFoundError)
|
||||
@@ -217,7 +217,7 @@ def _png_chunks(data: bytes) -> list[str]:
|
||||
def _build_with_key_image(tmp_path: Path, image: Image.Image) -> None:
|
||||
root = make_delivery(2).write(tmp_path / "delivery")
|
||||
image.save(root / "CEUS_agent" / "002" / "U002_key.png", format="PNG")
|
||||
run_build(write_config(tmp_path, root, 1, None), REPO_ROOT, NOW)
|
||||
run_build(write_config(tmp_path, root, 1, None, None), REPO_ROOT, NOW)
|
||||
|
||||
|
||||
def _assert_key_image_skipped(tmp_path: Path, reason: str) -> None:
|
||||
@@ -254,7 +254,7 @@ def test_key_image_palette_source_skips_case(tmp_path: Path) -> None:
|
||||
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)
|
||||
run_build(write_config(tmp_path, root, 1, None, None), REPO_ROOT, NOW)
|
||||
_assert_key_image_skipped(tmp_path, "不是 PNG")
|
||||
|
||||
|
||||
@@ -270,6 +270,7 @@ def test_write_library_rejects_unchecked_key_image(tmp_path: Path) -> None:
|
||||
ultrasound_report="",
|
||||
key_image_source=source,
|
||||
mr_report="",
|
||||
ceus=None,
|
||||
)
|
||||
with pytest.raises(ValueError, match="check_key_image"):
|
||||
write_library(target, "b", "c", True, {}, False, (), None, [case])
|
||||
|
||||
@@ -28,7 +28,7 @@ def _assert_no_finished_build(tmp_path: Path) -> None:
|
||||
|
||||
|
||||
def _run(tmp_path: Path, root: Path, code_maps: dict[str, dict[int, str]] | None) -> None:
|
||||
run_build(write_config(tmp_path, root, 7, code_maps), REPO_ROOT, NOW)
|
||||
run_build(write_config(tmp_path, root, 7, code_maps, None), REPO_ROOT, NOW)
|
||||
|
||||
|
||||
def test_t1_missing_summary_table(tmp_path: Path) -> None:
|
||||
@@ -111,7 +111,7 @@ def _write_yaml(tmp_path: Path, body: str) -> Path:
|
||||
return path
|
||||
|
||||
|
||||
_BASE = "delivery_dir: d\nlibrary_root: l\nanswers_root: a\nalias_seed: 1\n"
|
||||
_BASE = "delivery_dir: d\nlibrary_root: l\nanswers_root: a\nalias_seed: 1\nceus_run: null\n"
|
||||
|
||||
|
||||
def test_config_decode_true_needs_code_maps(tmp_path: Path) -> None:
|
||||
@@ -152,12 +152,24 @@ def test_config_relative_paths_resolve_against_repo_root(tmp_path: Path) -> None
|
||||
assert config.library_root == Path("/repo/l")
|
||||
assert config.answers_root == Path("/repo/a")
|
||||
assert config.code_maps is None
|
||||
assert config.ceus_run is None
|
||||
body = _BASE.replace("ceus_run: null", "ceus_run: runs/ceus/r")
|
||||
path = _write_yaml(tmp_path, body + "decode_codes: false\ncode_maps: null\n")
|
||||
assert load_build_config(path, Path("/repo")).ceus_run == Path("/repo/runs/ceus/r")
|
||||
|
||||
|
||||
def test_config_ceus_run_missing(tmp_path: Path) -> None:
|
||||
body = _BASE.replace("ceus_run: null\n", "")
|
||||
path = _write_yaml(tmp_path, body + "decode_codes: false\ncode_maps: null\n")
|
||||
with pytest.raises(ValidationError, match="ceus_run"):
|
||||
load_build_config(path, tmp_path)
|
||||
|
||||
|
||||
def test_committed_config_is_valid() -> None:
|
||||
config = load_build_config(REPO_ROOT / "config" / "build.yaml", REPO_ROOT)
|
||||
assert config.decode_codes is False
|
||||
assert config.code_maps is None
|
||||
assert config.ceus_run is None
|
||||
|
||||
|
||||
def test_config_duplicate_key(tmp_path: Path) -> None:
|
||||
|
||||
@@ -13,6 +13,7 @@ import csv
|
||||
import importlib.util
|
||||
import json
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Any
|
||||
@@ -23,9 +24,12 @@ import numpy.typing as npt
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from cadx.answers import read_aliases
|
||||
from cadx.build import run_build
|
||||
from cadx.ceus import ANCHOR_SLOTS, LognormalParameters, evaluate_lognormal
|
||||
from cadx.library import list_aliases, read_ultrasound
|
||||
from conftest import CEUS_YAML
|
||||
from fake_delivery import make_delivery
|
||||
from fake_delivery import REPO_ROOT, make_delivery, write_config
|
||||
|
||||
_SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "ceus_quant.py"
|
||||
_WIDTH, _HEIGHT, _SPLIT = 400, 200, 200
|
||||
@@ -177,6 +181,36 @@ def test_run_writes_outputs_and_failed_case(
|
||||
assert "U001" not in out and "U002" not in out
|
||||
|
||||
|
||||
def test_build_reads_real_run_layout(
|
||||
tmp_path: Path,
|
||||
setup: tuple[ModuleType, _CountingSegment, dict[str, Any], Callable[[str], None]],
|
||||
) -> None:
|
||||
"""build 读这个脚本真实写出的运行目录:第 1 例带 tic.json 和关键帧进库,第 2 例记 C15。"""
|
||||
module, _, document, next_run_id = setup
|
||||
next_run_id("run-a")
|
||||
module.main(["--config", str(_write_config(tmp_path, document, "ceus.yaml"))])
|
||||
run_dir = tmp_path / "runs" / "run-a"
|
||||
delivery_dir = Path(document["paths"]["delivery_dir"])
|
||||
build_dir = tmp_path / "build"
|
||||
build_dir.mkdir()
|
||||
result = run_build(
|
||||
write_config(build_dir, delivery_dir, 1, None, run_dir),
|
||||
REPO_ROOT,
|
||||
datetime(2026, 10, 16, 12, 0, 0).astimezone(),
|
||||
)
|
||||
assert [r.case_no for r in read_aliases(result.answers_dir)] == ["001"]
|
||||
assert result.skipped_by_check == {"C15": 1}
|
||||
(alias,) = list_aliases(result.library_dir)
|
||||
route = read_ultrasound(result.library_dir, alias)
|
||||
summary = json.loads((run_dir / "U001" / "summary.json").read_text(encoding="utf-8"))
|
||||
located = {s for s, a in summary["anchor_frames"].items() if a["status"] == "ok"}
|
||||
assert set(route.keyframes) == located
|
||||
assert route.tic is not None
|
||||
assert set(route.tic) == {"fits", "perfusion_metrics", "pan_curve_usable", "keyframes"}
|
||||
assert route.tic["keyframes"]["F1"]["video_time_s"] == pytest.approx(15.0, abs=0.1)
|
||||
assert route.tic["keyframes"]["F2"]["located"] is False
|
||||
|
||||
|
||||
def test_unreadable_video_fails_at_step_1_and_next_case_runs(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
Reference in New Issue
Block a user