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:
2026-10-16 09:01:44 -04:00
parent e165b32b9d
commit 1de88a5d0a
23 changed files with 561 additions and 202 deletions
+12 -3
View File
@@ -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"))
+3 -1
View File
@@ -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
+67 -8
View File
@@ -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)
+1
View File
@@ -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",
]
+15
View File
@@ -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 -23
View File
@@ -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))
+39 -2
View File
@@ -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)
+39 -3
View File
@@ -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]],
+45
View File
@@ -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
+33
View File
@@ -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)