Files
CADx-4/tests/test_segment.py
T
iomgaa 1de88a5d0a 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>
2026-10-16 09:01:44 -04:00

129 lines
4.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""segment:分割设置的校验,以及在合成小视频上真跑一次 MedSAM2(`gpu`)。"""
from pathlib import Path
from typing import Any, cast
import cv2
import numpy as np
import numpy.typing as npt
import pytest
import yaml
from pydantic import ValidationError
from cadx.segment import SegmentConfig, medsam2_commit, segment_video
REPO_ROOT = Path(__file__).resolve().parents[1]
CEUS_YAML = REPO_ROOT / "config" / "ceus.yaml"
def _segment_section() -> dict[str, Any]:
document = cast(dict[str, Any], yaml.safe_load(CEUS_YAML.read_text(encoding="utf-8")))
return cast(dict[str, Any], document["segment"])
def _absolute(section: dict[str, Any]) -> dict[str, Any]:
"""路径字段换成仓库根目录下的绝对路径(脚本也这样做)。"""
return {
**section,
"medsam2_dir": str(REPO_ROOT / section["medsam2_dir"]),
"checkpoint": str(REPO_ROOT / section["checkpoint"]),
}
def test_config_yaml_section_validates() -> None:
cfg = SegmentConfig.model_validate(_segment_section())
assert cfg.sam2_config == "configs/sam2.1_hiera_t512.yaml"
@pytest.mark.parametrize(
"field",
[
"medsam2_dir",
"checkpoint",
"sam2_config",
"device",
"offload_video_to_cpu",
"offload_state_to_cpu",
],
)
def test_config_missing_field_raises(field: str) -> None:
section = _segment_section()
del section[field]
with pytest.raises(ValidationError, match=field):
SegmentConfig.model_validate(section)
def test_config_extra_field_raises() -> None:
with pytest.raises(ValidationError, match="object_id"):
SegmentConfig.model_validate({**_segment_section(), "object_id": 1})
def test_config_bool_not_coerced() -> None:
with pytest.raises(ValidationError, match="offload_video_to_cpu"):
SegmentConfig.model_validate({**_segment_section(), "offload_video_to_cpu": "false"})
def _moving_disc_video(
frame_count: int, height: int, width: int, radius: int
) -> tuple[list[npt.NDArray[np.uint8]], list[npt.NDArray[np.bool_]]]:
"""暗背景上一个亮圆从左往右匀速移动;交出逐帧 BGR 图像和每帧圆的真值掩码。"""
frames: list[npt.NDArray[np.uint8]] = []
truths: list[npt.NDArray[np.bool_]] = []
yy, xx = np.mgrid[0:height, 0:width]
for index in range(frame_count):
cx = radius + 10 + index * 2
cy = height // 2
frame = np.full((height, width, 3), 30, dtype=np.uint8)
cv2.circle(frame, (cx, cy), radius, (220, 220, 220), thickness=-1)
frames.append(frame)
truths.append((xx - cx) ** 2 + (yy - cy) ** 2 <= radius**2)
return frames, truths
@pytest.mark.gpu
def test_segment_video_on_synthetic_clip() -> None:
cfg = SegmentConfig.model_validate(_absolute(_segment_section()))
frame_count, height, width = 40, 120, 200
frames, truths = _moving_disc_video(frame_count, height, width, radius=18)
# 两个提示帧,最早的不在第 0 帧,前向和反向传播都要走到。
prompt_masks = {5: truths[5], 25: truths[25]}
masks = segment_video(frames, prompt_masks, cfg)
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)