fix(ceus): 降低长视频内存峰值,快照开跑即写,build 核对 CEUS 运行与交付包
- 内存:prepare_segmentation 改为流式解码,只留分屏和配准的抽样帧,绿框逐帧在两半上找; 分割那一半由 SegmentationInput.target_frames() 到用时再解码,segment_video 边读边写 jpg; MedSAM2 init_state 的读帧换成直接在存放设备上建张量(不改 MedSAM2 源码,结果逐位相同); 传播结果写进预分配数组。帧数最多的一段视频常驻内存峰值 11.2 GiB → 2.3 GiB,分辨率最大的 一段原代码被 OOM 杀掉、现为 4.2 GiB;两段的掩码和全部产物与改前逐字节相同,33 段视频的 第 1–2 步结果与改前一致,29 例配准与 review.csv 一致。 - 快照:ceus_quant 开跑时写 snapshot/(git、ceus.yaml、review.csv、uv.lock、segment.json), 配置和核对表从快照副本读;收尾由新函数 runs.finish_snapshot 只新建 finished.json。 build 与 ceus_align_review 同样改用 finish_snapshot 记结束时间。 - build:summary.json 加 video_sha256;装 CEUS 结果时核对快照里的 delivery_dir、逐例核对 视频 SHA-256 和推注时间,不同就整次停下;新字段做类型检查,不进 tic.json。 来源:主会话任务(问题 1–3 及补充的 delivery_dir 比对)。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+12
-3
@@ -1,17 +1,21 @@
|
||||
"""测试用假 CEUS 运行目录:布局照 `scripts/ceus_quant.py` 写出的(failed.csv、snapshot/、
|
||||
`<视频号>/summary.json`、`<视频号>/keyframes/<槽位>.png`),数值全部编造,不调显卡。
|
||||
|
||||
summary.json 故意带上 tic.json 不该有的字段(视频号、分割缓存键、帧号、拟合参数、失败原因代码、
|
||||
推注时间、紫圈检出率),用来测 build 只按白名单挑字段。关键帧 PNG 带文本块和 EXIF。
|
||||
summary.json 故意带上 tic.json 不该有的字段(视频号、视频 SHA-256、分割缓存键、帧号、拟合参数、
|
||||
失败原因代码、推注时间、紫圈检出率),用来测 build 只按白名单挑字段。视频 SHA-256 和推注时间照
|
||||
`fake_delivery` 的假交付包(视频内容 `VIDEO_BYTES`、推注 5 秒)。关键帧 PNG 带文本块和 EXIF。
|
||||
"""
|
||||
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from PIL import Image, PngImagePlugin
|
||||
|
||||
from fake_delivery import VIDEO_BYTES
|
||||
|
||||
SLOTS = ("B0", "D1", "D2", "D3", "D4", "F1", "F2")
|
||||
CURVES = ("roi_all", "roi_perf", "roi_pan", "background_circle")
|
||||
UNLOCATED_REASON = "拟合曲线上没有 D4 时刻(编造的原因)"
|
||||
@@ -68,6 +72,7 @@ def summary(video_id: str, located: tuple[str, ...]) -> dict[str, Any]:
|
||||
}
|
||||
return {
|
||||
"video_id": video_id,
|
||||
"video_sha256": hashlib.sha256(VIDEO_BYTES).hexdigest(),
|
||||
"segment_cache_key": "f" * 64,
|
||||
"injection_offset_s": 5.0,
|
||||
"reference_valid": False,
|
||||
@@ -99,12 +104,16 @@ def keyframe_png(path: Path, shade: int) -> None:
|
||||
|
||||
def write_run(
|
||||
run_dir: Path,
|
||||
delivery_dir: Path,
|
||||
results: dict[str, tuple[str, ...]],
|
||||
failed: list[tuple[str, int, str]],
|
||||
) -> Path:
|
||||
"""写一个已完成的运行目录:`results` 是视频号 → 定位到的槽位,`failed` 是 failed.csv 的行。"""
|
||||
"""写一个已完成的运行目录:`delivery_dir` 记进快照里的 ceus.yaml,`results` 是视频号 → 定位到的
|
||||
槽位,`failed` 是 failed.csv 的行。"""
|
||||
(run_dir / "snapshot").mkdir(parents=True)
|
||||
(run_dir / "snapshot" / "snapshot.json").write_text("{}", encoding="utf-8")
|
||||
ceus_yaml = f"paths:\n delivery_dir: {json.dumps(str(delivery_dir), ensure_ascii=False)}\n"
|
||||
(run_dir / "snapshot" / "ceus.yaml").write_text(ceus_yaml, 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"))
|
||||
|
||||
@@ -62,6 +62,8 @@ MR_PATH = "MR_agent/MR报告文本_40例.xlsx"
|
||||
|
||||
# 编造的报告文字:不含亚型名、结论段标题、性别字、「数字岁」和复核措辞。
|
||||
ULTRASOUND_TEXT = "胰体部见一无回声区,边界清晰,壁薄。造影后囊壁呈等增强,内部未见增强。"
|
||||
# 假交付包里每段 CEUS 视频文件的内容(不是真视频)。
|
||||
VIDEO_BYTES = b"not a real video"
|
||||
MR_TEXT = "胰体部见一类圆形长T2信号灶,边界清楚,内见细分隔;增强后分隔轻度强化。"
|
||||
|
||||
# 测试用的样例映射表:含义是编的,只用来验证解码路径。
|
||||
@@ -204,7 +206,7 @@ class FakeDelivery:
|
||||
rows = case.case_report_rows or [list(ULTRASOUND_HEADER), row]
|
||||
_write_workbook(folder / name, case.case_report_sheet, rows)
|
||||
else:
|
||||
(folder / name).write_bytes(b"not a real video")
|
||||
(folder / name).write_bytes(VIDEO_BYTES)
|
||||
(root / "交付说明_40例_20260908.md").write_text("说明不读", encoding="utf-8")
|
||||
return root
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ def _key_tree(node: Any) -> Any:
|
||||
def test_c15_skips_failed_and_missing_cases(tmp_path: Path) -> None:
|
||||
run = write_run(
|
||||
tmp_path / "runs" / RUN_ID,
|
||||
tmp_path / "delivery",
|
||||
{"U001": LOCATED, "U004": LOCATED},
|
||||
[("U002", 2, "两半都没有绿框")],
|
||||
)
|
||||
@@ -95,7 +96,11 @@ def test_c15_skips_failed_and_missing_cases(tmp_path: Path) -> None:
|
||||
|
||||
|
||||
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}, []))
|
||||
result = _build(
|
||||
tmp_path,
|
||||
1,
|
||||
write_run(tmp_path / "runs" / RUN_ID, tmp_path / "delivery", {"U001": LOCATED}, []),
|
||||
)
|
||||
(alias,) = list_aliases(result.library_dir)
|
||||
tic = read_ultrasound(result.library_dir, alias).tic
|
||||
perfusion = {
|
||||
@@ -126,7 +131,7 @@ def test_tic_json_has_exactly_the_listed_fields(tmp_path: Path) -> None:
|
||||
|
||||
|
||||
def test_keyframes_metadata_dropped_and_pixels_kept(tmp_path: Path) -> None:
|
||||
run = write_run(tmp_path / "runs" / RUN_ID, {"U001": LOCATED}, [])
|
||||
run = write_run(tmp_path / "runs" / RUN_ID, tmp_path / "delivery", {"U001": LOCATED}, [])
|
||||
result = _build(tmp_path, 1, run)
|
||||
(alias,) = list_aliases(result.library_dir)
|
||||
route = read_ultrasound(result.library_dir, alias)
|
||||
@@ -159,7 +164,9 @@ def test_null_ceus_run_has_no_ceus_files(tmp_path: Path) -> None:
|
||||
|
||||
|
||||
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}, [])
|
||||
run = write_run(
|
||||
tmp_path / "runs" / RUN_ID, tmp_path / "delivery", {"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",)) == []
|
||||
@@ -173,7 +180,7 @@ def test_scan_finds_nothing_then_hits_video_id_in_tic_json(tmp_path: Path) -> No
|
||||
|
||||
|
||||
def test_reason_with_video_id_stops_build(tmp_path: Path) -> None:
|
||||
run = write_run(tmp_path / "runs" / RUN_ID, {"U001": LOCATED}, [])
|
||||
run = write_run(tmp_path / "runs" / RUN_ID, tmp_path / "delivery", {"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")
|
||||
@@ -183,7 +190,9 @@ def test_reason_with_video_id_stops_build(tmp_path: Path) -> None:
|
||||
|
||||
|
||||
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}, [])
|
||||
run = write_run(
|
||||
tmp_path / "runs" / RUN_ID, tmp_path / "delivery", {"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"]
|
||||
@@ -199,22 +208,72 @@ def _assert_stops(tmp_path: Path, run: Path, error: type[Exception]) -> None:
|
||||
assert not (tmp_path / "library").exists() and not (tmp_path / "answers").exists()
|
||||
|
||||
|
||||
def test_video_changed_since_run_stops(tmp_path: Path) -> None:
|
||||
"""交付包里的视频内容和 CEUS 运行时记的 SHA-256 不同:运行不是在这份交付包上跑的,整次停下。"""
|
||||
run = write_run(tmp_path / "runs" / RUN_ID, tmp_path / "delivery", {"U001": LOCATED}, [])
|
||||
root = make_delivery(1).write(tmp_path / "delivery")
|
||||
(root / "CEUS_agent" / "001" / "U001_roi2.mp4").write_bytes(b"another video")
|
||||
with pytest.raises(ValueError, match=r"U001_roi2\.mp4 的 SHA-256"):
|
||||
run_build(write_config(tmp_path, root, 1, None, run), REPO_ROOT, NOW)
|
||||
assert not (tmp_path / "library").exists() and not (tmp_path / "answers").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("delivery_bolus", "summary_offset", "message"),
|
||||
[
|
||||
(7, 5.0, "推注时间 7 秒"), # 交付包的推注时间改了
|
||||
(5, 5, "应为 float"), # summary 里的推注时间不是浮点数
|
||||
],
|
||||
)
|
||||
def test_injection_offset_mismatch_stops(
|
||||
tmp_path: Path, delivery_bolus: int, summary_offset: object, message: str
|
||||
) -> None:
|
||||
run = write_run(tmp_path / "runs" / RUN_ID, tmp_path / "delivery", {"U001": LOCATED}, [])
|
||||
content = summary("U001", LOCATED)
|
||||
content["injection_offset_s"] = summary_offset
|
||||
(run / "U001" / "summary.json").write_text(json.dumps(content), encoding="utf-8")
|
||||
delivery = make_delivery(1)
|
||||
delivery.case(1).ultrasound["推注开始时间s"] = delivery_bolus
|
||||
root = delivery.write(tmp_path / "delivery")
|
||||
with pytest.raises(ValueError, match=message):
|
||||
run_build(write_config(tmp_path, root, 1, None, run), REPO_ROOT, NOW)
|
||||
assert not (tmp_path / "library").exists() and not (tmp_path / "answers").exists()
|
||||
|
||||
|
||||
def test_run_on_other_delivery_dir_stops(tmp_path: Path) -> None:
|
||||
"""快照里记的交付包路径与 build 的不同(规范化后比):整次停下;写法不同但同一路径照常装。"""
|
||||
root = make_delivery(1).write(tmp_path / "delivery")
|
||||
same = write_run(
|
||||
tmp_path / "runs" / RUN_ID, tmp_path / "x" / ".." / "delivery", {"U001": LOCATED}, []
|
||||
)
|
||||
result = run_build(write_config(tmp_path, root, 1, None, same), REPO_ROOT, NOW)
|
||||
assert len(list_aliases(result.library_dir)) == 1
|
||||
other = write_run(tmp_path / "runs" / "other", tmp_path / "delivery_old", {"U001": LOCATED}, [])
|
||||
build_dir = tmp_path / "second"
|
||||
build_dir.mkdir()
|
||||
with pytest.raises(ValueError, match=r"paths\.delivery_dir"):
|
||||
run_build(write_config(build_dir, root, 1, None, other), REPO_ROOT, NOW)
|
||||
assert not (build_dir / "library").exists() and not (build_dir / "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}, [])
|
||||
run = write_run(
|
||||
tmp_path / "runs" / f"{RUN_ID}.partial", tmp_path / "delivery", {"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 = write_run(tmp_path / "runs" / RUN_ID, tmp_path / "delivery", {"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 = write_run(tmp_path / "runs" / RUN_ID, tmp_path / "delivery", {"U001": LOCATED}, [])
|
||||
(run / "snapshot" / "snapshot.json").unlink()
|
||||
_assert_stops(tmp_path, run, FileNotFoundError)
|
||||
|
||||
@@ -365,6 +365,7 @@ def test_answers_contents(tmp_path: Path) -> None:
|
||||
"review.csv",
|
||||
"skipped.csv",
|
||||
"snapshot/build.yaml",
|
||||
"snapshot/finished.json",
|
||||
"snapshot/snapshot.json",
|
||||
]
|
||||
|
||||
|
||||
@@ -72,6 +72,21 @@ def test_green_box_in_right_half(ceus_config: CeusConfig) -> None:
|
||||
assert abs(int(xs.max()) - 169) <= 3
|
||||
|
||||
|
||||
def test_frames_are_iterated_once(ceus_config: CeusConfig) -> None:
|
||||
"""帧只遍历一遍:传逐帧交出的生成器,结果与传列表相同。"""
|
||||
frames = _frames([250])
|
||||
_, left, right = split_views(frames, ceus_config.views)
|
||||
from_list = locate_contrast_side(
|
||||
frames, left, right, ceus_config.prompt_source, ceus_config.keyframes
|
||||
)
|
||||
from_generator = locate_contrast_side(
|
||||
(frame for frame in frames), left, right, ceus_config.prompt_source, ceus_config.keyframes
|
||||
)
|
||||
assert from_generator.side == from_list.side == "right"
|
||||
assert [p.frame_index for p in from_generator.prompt_frames] == [0]
|
||||
assert np.array_equal(from_generator.prompt_frames[0].mask, from_list.prompt_frames[0].mask)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""B3 green_prompt 绿框提示检测单元测试。
|
||||
|
||||
钉死四件事的关键性质(参考 ``references/ROI/roi_tracking_pipeline.py`` P:241-332/989-1004):
|
||||
钉死这几件事的关键性质(参考 ``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;
|
||||
@@ -9,8 +9,6 @@
|
||||
绿框在带外则正常返回。
|
||||
- ``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 更大。
|
||||
|
||||
@@ -24,10 +22,8 @@ from cadx.ceus import (
|
||||
CeusConfig,
|
||||
ColorPrompt,
|
||||
PanSourceConfig,
|
||||
PromptObservation,
|
||||
PromptSourceConfig,
|
||||
TicAnalysisConfig,
|
||||
collect_prompt_observations,
|
||||
extract_color_prompt,
|
||||
prompt_color_mask,
|
||||
)
|
||||
@@ -198,24 +194,6 @@ def test_min_area_picks_largest_bbox_candidate() -> None:
|
||||
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))
|
||||
|
||||
@@ -24,14 +24,19 @@ from cadx.ceus import (
|
||||
CeusConfig,
|
||||
NoValidContourError,
|
||||
SegmentationInput,
|
||||
VideoFile,
|
||||
crop_frame,
|
||||
keyframe_images,
|
||||
locate_contrast_side,
|
||||
pack_tic_curves,
|
||||
pack_trajectory,
|
||||
prepare_segmentation,
|
||||
quant_summary,
|
||||
quantify_case,
|
||||
register_halves,
|
||||
render_case_overlay,
|
||||
render_tic_png,
|
||||
split_views,
|
||||
tic_plot_markers,
|
||||
unpack_tic_curves,
|
||||
unpack_trajectory,
|
||||
@@ -77,8 +82,9 @@ def test_prepare_segmentation_finds_contrast_half_and_maps_prompts(
|
||||
target = prepared.contrast.target_view
|
||||
assert (target.x1, target.x2) == (0, prepared.split_x)
|
||||
assert abs(prepared.split_x - SPLIT) <= 1
|
||||
assert len(prepared.target_frames) == FRAMES
|
||||
assert all(frame.shape == (target.height, target.width, 3) for frame in prepared.target_frames)
|
||||
target_frames = list(prepared.target_frames())
|
||||
assert len(target_frames) == FRAMES
|
||||
assert all(frame.shape == (target.height, target.width, 3) for frame in target_frames)
|
||||
assert sorted(prepared.prompt_masks) == [p.frame_index for p in prepared.contrast.prompt_frames]
|
||||
assert 0 in prepared.prompt_masks
|
||||
xs = np.where(prepared.prompt_masks[0])[1]
|
||||
@@ -87,6 +93,37 @@ def test_prepare_segmentation_finds_contrast_half_and_maps_prompts(
|
||||
assert abs(int(xs.max()) - 169) <= 5
|
||||
|
||||
|
||||
def test_streaming_prepare_matches_whole_video_in_memory(
|
||||
prepared: SegmentationInput, config: CeusConfig
|
||||
) -> None:
|
||||
"""逐帧解码的第 1–2 步与「整段帧读进内存再交给各步」逐像素相同(改成流式之前就是后一种)。"""
|
||||
frames = [frame for _, frame in VideoFile(prepared.video_path).iter_frames()]
|
||||
split_x, left, right = split_views(frames, config.views)
|
||||
registration = register_halves(
|
||||
frames, left, right, config.registration, config.prompt_source, config.pan_source
|
||||
)
|
||||
contrast = locate_contrast_side(frames, left, right, config.prompt_source, config.keyframes)
|
||||
assert prepared.split_x == split_x
|
||||
assert prepared.registration == registration
|
||||
assert prepared.contrast.side == contrast.side
|
||||
assert (prepared.contrast.contrast_view, prepared.contrast.target_view) == (
|
||||
contrast.contrast_view,
|
||||
contrast.target_view,
|
||||
)
|
||||
assert [p.frame_index for p in prepared.contrast.prompt_frames] == [
|
||||
p.frame_index for p in contrast.prompt_frames
|
||||
]
|
||||
for ours, whole in zip(prepared.contrast.prompt_frames, contrast.prompt_frames, strict=True):
|
||||
assert np.array_equal(ours.mask, whole.mask)
|
||||
target_frames = list(prepared.target_frames())
|
||||
assert len(target_frames) == len(frames)
|
||||
for ours, frame in zip(target_frames, frames, strict=True):
|
||||
assert np.array_equal(ours, crop_frame(frame, contrast.target_view))
|
||||
assert np.array_equal(
|
||||
prepared.first_contrast_frame, crop_frame(frames[0], contrast.contrast_view)
|
||||
)
|
||||
|
||||
|
||||
def test_quantify_case_end_to_end(prepared: SegmentationInput, config: CeusConfig) -> None:
|
||||
result = quantify_case(prepared, _lesion_masks(prepared), _INJECTION_OFFSET_S, config)
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import csv
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from collections.abc import Callable, Iterable, Mapping
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
@@ -88,11 +88,12 @@ class _CountingSegment:
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
frames: Sequence[npt.NDArray[np.uint8]],
|
||||
frames: Iterable[npt.NDArray[np.uint8]],
|
||||
prompt_masks: Mapping[int, npt.NDArray[np.bool_]],
|
||||
cfg: object,
|
||||
) -> npt.NDArray[np.bool_]:
|
||||
self.calls += 1
|
||||
frames = list(frames)
|
||||
height, width = frames[0].shape[:2]
|
||||
masks = np.zeros((len(frames), height, width), dtype=bool)
|
||||
x1, y1, x2, y2 = LESION
|
||||
@@ -170,7 +171,11 @@ def test_run_writes_outputs_and_failed_case(
|
||||
assert (snapshot / "align_review.csv").read_bytes() == (tmp_path / "review.csv").read_bytes()
|
||||
segment_record = json.loads((snapshot / "segment.json").read_text(encoding="utf-8"))
|
||||
assert segment_record["medsam2_commit"] == "c" * 40
|
||||
assert segment_record["cuda_peak_memory_bytes"] is None # device 是 cpu
|
||||
assert segment_record["checkpoint_sha256"] == hashlib.sha256(b"fake weights").hexdigest()
|
||||
finished = json.loads((snapshot / "finished.json").read_text(encoding="utf-8"))
|
||||
assert finished["cuda_peak_memory_bytes"] is None # device 是 cpu
|
||||
assert finished["segment_cache_hits"] == 0
|
||||
assert "finished_at" in finished
|
||||
assert (snapshot / "snapshot.json").is_file()
|
||||
|
||||
out = capsys.readouterr().out
|
||||
@@ -178,6 +183,37 @@ def test_run_writes_outputs_and_failed_case(
|
||||
assert "U001" not in out and "U002" not in out
|
||||
|
||||
|
||||
def test_snapshot_written_before_cases_run(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
setup: tuple[ModuleType, _CountingSegment, dict[str, Any], Callable[[str], None]],
|
||||
) -> None:
|
||||
"""跑病例时快照已在 .partial 里;运行中途改了 ceus.yaml 和 review.csv,快照仍是开跑时那份。"""
|
||||
module, segment, document, next_run_id = setup
|
||||
config = _write_config(tmp_path, document, "ceus.yaml")
|
||||
review = Path(document["paths"]["align_review"])
|
||||
original_config, original_review = config.read_bytes(), review.read_bytes()
|
||||
snapshot = tmp_path / "runs" / "run-a.partial" / "snapshot"
|
||||
seen: list[bool] = []
|
||||
|
||||
def editing_segment(*args: Any) -> npt.NDArray[np.bool_]:
|
||||
seen.append(all((snapshot / name).is_file() for name in ("snapshot.json", "segment.json")))
|
||||
config.write_text("# 运行中途改了\n", encoding="utf-8")
|
||||
review.write_text("改了\n", encoding="utf-8")
|
||||
return segment(*args)
|
||||
|
||||
monkeypatch.setattr(module, "segment_video", editing_segment)
|
||||
next_run_id("run-a")
|
||||
module.main(["--config", str(config)])
|
||||
|
||||
assert seen == [True]
|
||||
done = tmp_path / "runs" / "run-a" / "snapshot"
|
||||
assert (done / "ceus.yaml").read_bytes() == original_config
|
||||
assert (done / "align_review.csv").read_bytes() == original_review
|
||||
snapshot_json = json.loads((done / "snapshot.json").read_text(encoding="utf-8"))
|
||||
assert "finished_at" not in snapshot_json and "started_at" in snapshot_json
|
||||
|
||||
|
||||
def test_build_reads_real_run_layout(
|
||||
tmp_path: Path,
|
||||
setup: tuple[ModuleType, _CountingSegment, dict[str, Any], Callable[[str], None]],
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""runs 的快照收尾:`finish_snapshot` 只新建 finished.json,不改开跑时写的快照,也不收两次尾。"""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from cadx.runs import GitState, finish_snapshot, write_snapshot
|
||||
|
||||
_GIT = GitState(commit="a" * 40, short_commit="aaaaaaa", clean=True, diff_sha256="b" * 64)
|
||||
|
||||
|
||||
def _snapshot(tmp_path: Path) -> Path:
|
||||
lock = tmp_path / "uv.lock"
|
||||
lock.write_text("lock", encoding="utf-8")
|
||||
config = tmp_path / "x.yaml"
|
||||
config.write_text("a: 1\n", encoding="utf-8")
|
||||
snapshot_dir = tmp_path / "snapshot"
|
||||
started = datetime(2026, 10, 16, 9, 0, 0).astimezone()
|
||||
write_snapshot(snapshot_dir, "run-1", _GIT, started, lock, {"x.yaml": config})
|
||||
return snapshot_dir
|
||||
|
||||
|
||||
def test_finish_writes_separate_file_and_keeps_snapshot(tmp_path: Path) -> None:
|
||||
snapshot_dir = _snapshot(tmp_path)
|
||||
before = (snapshot_dir / "snapshot.json").read_bytes()
|
||||
finished = datetime(2026, 10, 16, 10, 0, 0).astimezone()
|
||||
finish_snapshot(snapshot_dir, finished, {"segment_cache_hits": 3})
|
||||
assert (snapshot_dir / "snapshot.json").read_bytes() == before
|
||||
record = json.loads((snapshot_dir / "finished.json").read_text(encoding="utf-8"))
|
||||
assert record == {"finished_at": finished.isoformat(), "segment_cache_hits": 3}
|
||||
|
||||
|
||||
def test_finish_twice_or_with_finished_at_in_record_raises(tmp_path: Path) -> None:
|
||||
snapshot_dir = _snapshot(tmp_path)
|
||||
now = datetime(2026, 10, 16, 10, 0, 0).astimezone()
|
||||
with pytest.raises(ValueError, match="finished_at"):
|
||||
finish_snapshot(snapshot_dir, now, {"finished_at": "x"})
|
||||
assert not (snapshot_dir / "finished.json").exists()
|
||||
finish_snapshot(snapshot_dir, now, {})
|
||||
first = (snapshot_dir / "finished.json").read_bytes()
|
||||
with pytest.raises(FileExistsError):
|
||||
finish_snapshot(snapshot_dir, now, {"segment_cache_hits": 1})
|
||||
assert (snapshot_dir / "finished.json").read_bytes() == first
|
||||
@@ -93,3 +93,36 @@ def test_segment_video_on_synthetic_clip() -> None:
|
||||
assert masks.shape == (frame_count, height, width)
|
||||
assert masks.dtype == np.bool_
|
||||
assert len(medsam2_commit(cfg.medsam2_dir)) == 40
|
||||
|
||||
|
||||
@pytest.mark.gpu
|
||||
@pytest.mark.parametrize("offload_video_to_cpu", [False, True])
|
||||
def test_jpg_loader_matches_medsam2_loader_bitwise(
|
||||
tmp_path: Path, offload_video_to_cpu: bool
|
||||
) -> None:
|
||||
"""换上的读帧函数与 MedSAM2 原来的 `load_video_frames` 交出逐位相同的张量和原始尺寸。"""
|
||||
import torch
|
||||
from sam2.utils.misc import load_video_frames # pyright: ignore[reportUnknownVariableType]
|
||||
|
||||
from cadx.segment._medsam2 import (
|
||||
_load_jpg_frames, # pyright: ignore[reportPrivateUsage]
|
||||
_write_jpgs, # pyright: ignore[reportPrivateUsage]
|
||||
)
|
||||
|
||||
frames, _ = _moving_disc_video(12, 120, 200, radius=18)
|
||||
noise = np.random.default_rng(0).integers(0, 40, size=(12, 120, 200, 3), dtype=np.uint8)
|
||||
_write_jpgs([frame + n for frame, n in zip(frames, noise, strict=True)], tmp_path)
|
||||
kwargs: dict[str, Any] = {
|
||||
"video_path": str(tmp_path),
|
||||
"image_size": 512,
|
||||
"offload_video_to_cpu": offload_video_to_cpu,
|
||||
"async_loading_frames": False,
|
||||
"compute_device": torch.device("cuda"),
|
||||
}
|
||||
expected, expected_h, expected_w = load_video_frames(**kwargs) # pyright: ignore[reportUnknownVariableType]
|
||||
assert isinstance(expected, torch.Tensor) # 不异步读时原函数交出整段张量
|
||||
images, height, width = _load_jpg_frames(**kwargs)
|
||||
assert (height, width) == (expected_h, expected_w) == (120, 200)
|
||||
assert images.device == expected.device
|
||||
assert images.dtype == torch.float32
|
||||
assert torch.equal(images, expected)
|
||||
|
||||
Reference in New Issue
Block a user