Files
CADx-4/tests/test_ceus_video.py
T
iomgaa 9a916c65a5 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>
2026-10-16 03:46:56 -04:00

155 lines
6.1 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.
"""VideoFile 单元测试:probe / iter_frames / frame_times_s / 打不开报错。
旧测试读一段真实样例视频的字节;这里在测试里用 cv2 现造一段几十帧的小视频,按路径读。
旧版先把字节落临时文件,那三条「临时文件有没有删」的测试随之不再适用,已删。
"""
from pathlib import Path
import cv2
import numpy as np
import pytest
from cadx.ceus import VideoFile
_FRAMES = 40
_WIDTH, _HEIGHT = 64, 48
@pytest.fixture(scope="module")
def sample_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""用 cv2 现造一段 40 帧、64×48、25 fps 的 mp4,每帧亮度不同。"""
path = tmp_path_factory.mktemp("video") / "sample.mp4"
writer = cv2.VideoWriter(str(path), cv2.VideoWriter.fourcc(*"mp4v"), 25.0, (_WIDTH, _HEIGHT))
for index in range(_FRAMES):
frame = np.full((_HEIGHT, _WIDTH, 3), (index * 6) % 256, dtype=np.uint8)
frame[index % _HEIGHT, :, 2] = 255
writer.write(frame)
writer.release()
return path
def test_probe_returns_positive_metadata(sample_path: Path) -> None:
"""probe 返回 (fps, frame_count, width, height):字段顺序/类型/语义范围均须正确。"""
video = VideoFile(sample_path)
result = video.probe()
assert isinstance(result, tuple) and len(result) == 4
fps, frame_count, width, height = result
# 字段类型契约(app/ports.py 的 DecodedVideo):fps 为 float,其余为 int
assert isinstance(fps, float)
assert isinstance(frame_count, int)
assert isinstance(width, int)
assert isinstance(height, int)
# 语义合理范围——并钉死字段顺序未颠倒(fps 是小帧率、尺寸是较大像素值)
assert 0 < fps < 1000 # 帧率不会是几百几千;若与 frame_count(881) 颠倒会触发
assert frame_count > 0
assert width > 1 and height > 1
def test_probe_field_order_matches_iter(sample_path: Path) -> None:
"""probe 的 (width, height) 须与 iter_frames 实际帧的 (W, H) 一致(防字段顺序颠倒)。"""
video = VideoFile(sample_path)
_fps, _frame_count, width, height = video.probe()
_idx, frame = next(video.iter_frames())
assert frame.shape == (height, width, 3)
def test_iter_frames_indices_contiguous_and_count_matches_probe(
sample_path: Path,
) -> None:
"""iter_frames 产 (idx, frame):idx 从 0 连续递增,且帧数 == probe 的 frame_count。"""
video = VideoFile(sample_path)
_, frame_count, _, _ = video.probe()
indices = [idx for idx, _frame in video.iter_frames()]
assert indices == list(range(len(indices)))
assert len(indices) == frame_count
def test_iter_frames_yields_bgr_uint8_hwc(sample_path: Path) -> None:
"""每帧为 (H, W, 3) uint8 的 ndarray,且 H/W 与 probe 一致。"""
video = VideoFile(sample_path)
_, _, width, height = video.probe()
idx, frame = next(video.iter_frames())
assert idx == 0
assert isinstance(frame, np.ndarray)
assert frame.dtype == np.uint8
assert frame.shape == (height, width, 3)
def test_frame_times_are_strictly_increasing_and_match_frame_count(
sample_path: Path,
) -> None:
video = VideoFile(sample_path)
_fps, frame_count, _width, _height = video.probe()
times = video.frame_times_s()
assert times.shape == (frame_count,)
assert times[0] >= 0.0
assert np.all(np.diff(times) > 0.0)
def test_解两遍逐帧像素完全相同(sample_path: Path) -> None:
"""**这条是接缝改成「打开一次」的全部理由**(design/0024)。
CEUS 那条线要分几趟过同一段视频,每趟只留这一趟的小结果。第二趟必须从第 0 帧重新开始,
而且看到的像素要和第一趟一模一样——多趟方案整个建立在这个前提上。它今天成立是因为
每趟各开一个 VideoCapture 顺序读同一个文件,但**这件事此前没有任何一处验证过**。
**比的是全片,而且是流式比。** 只比前几帧的话,可变帧率或者 seek 行为导致的漂移多半
出现在后面而不是开头。两趟各自逐帧过、逐帧喂进哈希,峰值内存是两帧——**这条测试自己
也不许把视频攒起来**,它守的正是这件事。
"""
import hashlib
def _digest(video: VideoFile) -> tuple[str, int]:
h = hashlib.blake2b(digest_size=16)
count = 0
for _, frame in video.iter_frames():
h.update(np.ascontiguousarray(frame).tobytes())
count += 1
return h.hexdigest(), count
video = VideoFile(sample_path)
first = _digest(video)
second = _digest(video)
assert first[1] > 0
assert first == second, "同一段视频解两遍,逐帧像素必须完全相同"
def test_两趟各自从第零帧开始互不干扰(sample_path: Path) -> None:
"""两趟同时在飞时各读各的游标。
**断言比的是像素,不是 iter_frames 自己报的下标**——那个下标是本模块里一个 Python
计数器,就算内层真把外层的读位置搅乱了,它照样从 0 数上去。
"""
video = VideoFile(sample_path)
outer = video.iter_frames()
_, outer_frame_0 = next(outer)
# 内层整个跑起来,中途丢掉——它不该动到外层的游标
inner = video.iter_frames()
_, inner_frame_0 = next(inner)
_, inner_frame_1 = next(inner)
del inner
_, outer_frame_1 = next(outer)
reference = [frame for _, frame in zip(video.iter_frames(), range(2), strict=False)]
assert np.array_equal(inner_frame_0, outer_frame_0), "内层没有从第 0 帧开始"
assert np.array_equal(outer_frame_1, inner_frame_1), "外层被内层带跑了:它接着读到的不是第 1 帧"
assert len(reference) == 2
def test_bad_bytes_raise_not_silent(tmp_path: Path) -> None:
"""坏文件无法打开 → 报错(不静默返回空 probe / 空迭代器)。"""
garbage = tmp_path / "garbage.mp4"
garbage.write_bytes(b"this is not a video file" * 64)
video = VideoFile(garbage)
with pytest.raises(RuntimeError):
video.probe()
with pytest.raises(RuntimeError):
list(video.iter_frames())