feat(ceus): 任务书 02 第一阶段——底层和分割之前的算法;修复 C14

任务书 01 遗留修复(C14):
- library 新增 check_key_image(path):关键图能逐像素保真重编码时返回 None,否则返回原因;
  _write_clean_png 的判断移过去,自身保留断言性质报错(走到这里说明 build 漏了检查)。
- build 写库前对每例调用,返回原因的整例跳过,记 C14「关键图能原样重编码」,涉及值写原因。
- 原来三条「关键图格式不对整次构建停下」的测试改为整例跳过,另加非 PNG、library 漏检报错两条。
- 结构.md 第八部分、guides/装配两个库.md 相应几行改掉。

第一阶段(src/cadx/ceus/):
- 照搬旧 geometry、video_decoder、research_config、split_screen、green_prompt、prompt_frames、
  mapping,去掉产品层,注释改中文。
- 配置类去掉全部默认值,strict、extra=forbid、frozen;新建 config/ceus.yaml(paths、ceus 两段),
  数值照抄旧 yaml,逐项注明出处。删去 source_view/target_view、effective_region、mapping 锚点;
  旧代码写死的分屏抽帧数 25 和搜索区间 (0.35, 0.65) 改成配置,值不变。
- 视频解码改为按路径用 cv2 逐帧读。
- 坐标换算改为两个视图矩形左上角之差,不再用按某种宽度写死的锚点。
- 造影画面在哪一半:两半各自选提示帧,恰好一半有才定,否则抛 ContrastSideError(CeusCaseError
  子类,带步号和原因,供第三阶段记 failed.csv)。
- 旧测试照搬(改 import 与配置读取方式),因上述改动不再成立的删去;新增两处修改的手算测试。
- import-linter 加「ceus 不引用其他 cadx 模块」。依赖加 numpy、opencv-python-headless。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 03:46:56 -04:00
parent de1d6b2d51
commit 9a916c65a5
29 changed files with 3634 additions and 45 deletions
+49 -12
View File
@@ -12,11 +12,13 @@ from PIL import Image
from cadx.answers import read_aliases, read_diagnoses
from cadx.build import make_aliases, run_build
from cadx.library import (
LibraryCase,
list_aliases,
read_clinical,
read_mr,
read_ultrasound,
scan_for_leaks,
write_library,
)
from fake_delivery import (
CLINICAL_HEADER,
@@ -218,24 +220,59 @@ def _build_with_key_image(tmp_path: Path, image: Image.Image) -> None:
run_build(write_config(tmp_path, root, 1, None), REPO_ROOT, NOW)
def test_key_image_16_bit_source_stops_build(tmp_path: Path) -> None:
image = Image.new("I;16", (4, 4))
with pytest.raises(ValueError, match=r"U002_key\.png"):
_build_with_key_image(tmp_path, image)
assert not (tmp_path / "library" / f"{NOW:%Y%m%d-%H%M%S}").exists()
assert [p for p in (tmp_path / "library").iterdir() if not p.name.endswith(".partial")] == []
def _assert_key_image_skipped(tmp_path: Path, reason: str) -> None:
"""002 这一例记 C14 跳过、涉及值是原因;001 照常进库。"""
(library_dir,) = list((tmp_path / "library").iterdir())
answers_dir = tmp_path / "answers" / library_dir.name
assert [r.case_no for r in read_aliases(answers_dir)] == ["001"]
assert len(list_aliases(library_dir)) == 1
skipped = _csv_rows(answers_dir / "skipped.csv")
assert [row[:4] for row in skipped[1:]] == [
["002", "C14", "关键图能原样重编码", "CEUS_agent/002/U002_key.png"]
]
assert json.loads(skipped[1][5]) == reason
def test_key_image_with_transparency_stops_build(tmp_path: Path) -> None:
def test_key_image_16_bit_source_skips_case(tmp_path: Path) -> None:
_build_with_key_image(tmp_path, Image.new("I;16", (4, 4)))
_assert_key_image_skipped(tmp_path, "位深 16、颜色类型 0 不能逐像素保真重编码")
def test_key_image_with_transparency_skips_case(tmp_path: Path) -> None:
image = Image.new("RGB", (4, 4))
image.info["transparency"] = (0, 0, 0)
with pytest.raises(ValueError, match=r"U002_key\.png"):
_build_with_key_image(tmp_path, image)
_build_with_key_image(tmp_path, image)
_assert_key_image_skipped(tmp_path, "带 tRNS 透明色,重编码会丢透明")
def test_key_image_palette_source_stops_build(tmp_path: Path) -> None:
with pytest.raises(ValueError, match=r"U002_key\.png"):
_build_with_key_image(tmp_path, Image.new("P", (4, 4)))
def test_key_image_palette_source_skips_case(tmp_path: Path) -> None:
_build_with_key_image(tmp_path, Image.new("P", (4, 4)))
# 只有一种颜色的调色板图,Pillow 存成 1 位。
_assert_key_image_skipped(tmp_path, "位深 1、颜色类型 3 不能逐像素保真重编码")
def test_key_image_not_png_skips_case(tmp_path: Path) -> None:
root = make_delivery(2).write(tmp_path / "delivery")
Image.new("RGB", (4, 4)).save(root / "CEUS_agent" / "002" / "U002_key.png", format="JPEG")
run_build(write_config(tmp_path, root, 1, None), REPO_ROOT, NOW)
_assert_key_image_skipped(tmp_path, "不是 PNG")
def test_write_library_rejects_unchecked_key_image(tmp_path: Path) -> None:
"""build 漏了 C14 检查时,library 写图那一步报错停下。"""
source = tmp_path / "key.png"
Image.new("P", (4, 4)).save(source, format="PNG")
target = tmp_path / "out"
target.mkdir()
case = LibraryCase(
alias="abcd",
clinical_fields={},
ultrasound_report="",
key_image_source=source,
mr_report="",
)
with pytest.raises(ValueError, match="check_key_image"):
write_library(target, "b", "c", True, {}, False, (), None, [case])
def test_key_image_one_bit_kept_as_is(tmp_path: Path) -> None: