"""overlay 的单元测试(照搬旧项目 test_ceus_overlay.py;画层配置与图层的默认值移到测试里)。 测试哲学(CLAUDE.md §4.6):用已知几何构造真实可断言的场景,验证 - ``draw_polygon_overlay`` 在整帧坐标系画各层(左 ROI 黄 / 右 ROI 绿 / 暗区橙红 / 背景圆青),输出帧尺寸不变、对应位置出现对应 BGR 颜色像素、fill 做 alpha 混合; - ``render_overlay`` 返回非空 mp4 字节,cv2 读回后帧数 / 尺寸与输入一致; - 临时文件 finally 清理(渲染前后 tempfile 目录无 ``.mp4`` 残留); - 无效 fps 直接报错(不静默兜底,呼应 findings §2E 不走参考的 25.0 回退)。 不做逐像素全图比对——只在已知轮廓位置抽样断言颜色,符合任务要求。 """ import tempfile from pathlib import Path from typing import Any import cv2 import numpy as np import pytest from cadx.ceus import ( OverlayConfig, OverlayLayers, ViewRect, draw_polygon_overlay, render_overlay, ) # 整帧尺寸:左右两半各 100 宽,便于把 source / target 多边形放进各自视图。 _FRAME_HEIGHT = 120 _FRAME_WIDTH = 200 _LEFT_RECT = ViewRect(0, 0, 100, _FRAME_HEIGHT) _RIGHT_RECT = ViewRect(100, 0, _FRAME_WIDTH, _FRAME_HEIGHT) def _overlay_cfg(**overrides: bool) -> OverlayConfig: """旧 `OverlayConfig` 的默认值(findings §3.10),按需改几项;新代码里这些字段没有默认值。""" values = { "draw_source_roi": False, "draw_target_polygon": True, "draw_dark_region": True, "draw_source_dark_region": False, "draw_reference_region": True, "fill_polygon": False, } values.update(overrides) return OverlayConfig(**values) def _layers(**fields: Any) -> OverlayLayers: """旧 `OverlayLayers` 的默认值(可选层为空),按需给几项;新代码里这些字段没有默认值。""" values: dict[str, Any] = { "dark_polygons": [], "source_roi_polygon": None, "source_rect": None, "source_dark_polygons": [], "reference_region": None, } values.update(fields) return OverlayLayers(**values) def _black_frame() -> np.ndarray: """构造全黑 BGR 整帧,作为干净画布(背景与所有彩色层都可区分)。""" return np.zeros((_FRAME_HEIGHT, _FRAME_WIDTH, 3), dtype=np.uint8) def _square_polygon(x: int, y: int, side: int) -> np.ndarray: """构造左上角 ``(x, y)``、边长 ``side`` 的局部坐标方形多边形 ``(4, 2)``。""" return np.array( [[x, y], [x + side, y], [x + side, y + side], [x, y + side]], dtype=np.float32, ) def _has_color(frame: np.ndarray, bgr: tuple[int, int, int]) -> bool: """整帧内是否存在与给定 BGR 完全相等的像素(用于断言某层已被画出)。""" return bool(np.any(np.all(frame == np.array(bgr, dtype=np.uint8), axis=-1))) def _count_mp4_in(directory: Path) -> int: """统计目录下 ``.mp4`` 文件数量(用于断言临时产物已清理)。""" return len(list(directory.glob("*.mp4"))) def test_draw_keeps_frame_shape_and_does_not_mutate_input() -> None: """绘制后输出帧尺寸 / dtype 不变,且不就地修改入参(返回副本)。""" frame = _black_frame() original = frame.copy() layers = _layers( target_polygon=_square_polygon(20, 20, 40), target_rect=_RIGHT_RECT, ) out = draw_polygon_overlay(frame, layers, _overlay_cfg()) assert out.shape == frame.shape assert out.dtype == frame.dtype assert np.array_equal(frame, original) # 入参未被污染 def test_target_polygon_drawn_green() -> None: """右侧 target ROI 轮廓默认画绿 (0,255,0),且坐标抬到整帧右半区。""" layers = _layers( target_polygon=_square_polygon(20, 20, 40), target_rect=_RIGHT_RECT, ) out = draw_polygon_overlay(_black_frame(), layers, _overlay_cfg()) assert _has_color(out, (0, 255, 0)) # 绿色像素应落在右半区(x >= 100),证明做了 target_rect 偏移。 green = np.all(out == np.array((0, 255, 0), dtype=np.uint8), axis=-1) xs = np.where(green)[1] assert xs.min() >= 100 def test_source_roi_drawn_yellow_when_enabled() -> None: """开启 draw_source_roi 时左侧 ROI 轮廓画黄 (0,255,255) 并落在左半区。""" layers = _layers( target_polygon=_square_polygon(20, 20, 40), target_rect=_RIGHT_RECT, source_roi_polygon=_square_polygon(20, 20, 40), source_rect=_LEFT_RECT, ) out = draw_polygon_overlay(_black_frame(), layers, _overlay_cfg(draw_source_roi=True)) assert _has_color(out, (0, 255, 255)) yellow = np.all(out == np.array((0, 255, 255), dtype=np.uint8), axis=-1) assert np.where(yellow)[1].max() < 100 def test_source_roi_not_drawn_when_disabled() -> None: """draw_source_roi 默认 False(findings §3.10),不应出现黄色像素。""" layers = _layers( target_polygon=_square_polygon(20, 20, 40), target_rect=_RIGHT_RECT, source_roi_polygon=_square_polygon(20, 20, 40), source_rect=_LEFT_RECT, ) out = draw_polygon_overlay(_black_frame(), layers, _overlay_cfg()) assert not _has_color(out, (0, 255, 255)) def test_dark_region_drawn_orange_red() -> None: """暗区轮廓默认画橙红 (255,80,20),落在右半区(与 target 共 rect)。""" layers = _layers( target_polygon=_square_polygon(20, 20, 60), target_rect=_RIGHT_RECT, dark_polygons=[_square_polygon(30, 30, 20)], ) out = draw_polygon_overlay(_black_frame(), layers, _overlay_cfg()) assert _has_color(out, (255, 80, 20)) def test_reference_region_drawn_cyan_when_enabled() -> None: """背景圆启用时左侧画青色小圆 (255,255,0),落在左半区。 **颜色不能是紫 (255,0,255)**:紫色是医生画的 ``ROI_pan`` 轮廓的颜色,两圈紫叠在 同一画面上人眼分不出哪个是哪个(design/0030 决策四)。 """ layers = _layers( target_polygon=_square_polygon(20, 20, 40), target_rect=_RIGHT_RECT, source_rect=_LEFT_RECT, reference_region={"enabled": True, "center_xy": [40.0, 40.0], "radius_px": 12}, ) out = draw_polygon_overlay(_black_frame(), layers, _overlay_cfg()) assert _has_color(out, (255, 255, 0)) assert not _has_color(out, (255, 0, 255)), "背景圆不许和医生的紫色轮廓撞色" cyan = np.all(out == np.array((255, 255, 0), dtype=np.uint8), axis=-1) assert np.where(cyan)[1].max() < 100 def test_reference_region_not_drawn_when_disabled() -> None: """reference enabled=False 时不画那个圆。""" layers = _layers( target_polygon=_square_polygon(20, 20, 40), target_rect=_RIGHT_RECT, source_rect=_LEFT_RECT, reference_region={"enabled": False, "center_xy": [40.0, 40.0], "radius_px": 12}, ) out = draw_polygon_overlay(_black_frame(), layers, _overlay_cfg()) assert not _has_color(out, (255, 255, 0)) def test_fill_polygon_alpha_blends_target() -> None: """开启 fill_polygon 时 ROI 内部做 alpha=0.18 混合:黑底上出现暗绿填充, 且其值约等于 round(255*0.18)=46(绿通道),区别于不填充时的纯黑内部。""" layers = _layers( target_polygon=_square_polygon(20, 20, 50), target_rect=_RIGHT_RECT, ) filled = draw_polygon_overlay(_black_frame(), layers, _overlay_cfg(fill_polygon=True)) # 取多边形内部一点(局部 (40,40) -> 整帧 (140,40)),应为混合后的暗绿。 inner = filled[40, 140] assert inner[0] == 0 # B 通道仍为 0 assert 40 <= int(inner[1]) <= 52 # G 通道 ≈ 255*0.18 assert inner[2] == 0 # R 通道仍为 0 def test_render_overlay_returns_decodable_mp4_bytes() -> None: """render_overlay 返回非空 mp4 字节;cv2 读回帧数 / 尺寸与输入一致。""" num_frames = 5 frames = [_black_frame() for _ in range(num_frames)] per_frame_layers = [ _layers( target_polygon=_square_polygon(20, 20, 40), target_rect=_RIGHT_RECT, ) for _ in range(num_frames) ] data = render_overlay( frames, per_frame_layers, fps=25.0, cfg=_overlay_cfg(), frame_size=(_FRAME_WIDTH, _FRAME_HEIGHT), ) assert isinstance(data, bytes) assert len(data) > 0 # 写回临时文件用 cv2 读回校验(mp4v 编码无法直接 in-memory 解码)。 with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as handle: handle.write(data) probe_path = Path(handle.name) try: cap = cv2.VideoCapture(str(probe_path)) try: assert cap.isOpened() assert int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) == _FRAME_WIDTH assert int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) == _FRAME_HEIGHT decoded = 0 while True: ok, _ = cap.read() if not ok: break decoded += 1 assert decoded == num_frames finally: cap.release() finally: probe_path.unlink(missing_ok=True) def test_render_overlay_cleans_temp_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """渲染走临时文件再读回,结束后临时目录不留 .mp4 残留。""" monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) assert _count_mp4_in(tmp_path) == 0 frames = [_black_frame() for _ in range(3)] per_frame_layers = [ _layers( target_polygon=_square_polygon(20, 20, 40), target_rect=_RIGHT_RECT, ) for _ in range(3) ] render_overlay( frames, per_frame_layers, fps=25.0, cfg=_overlay_cfg(), frame_size=(_FRAME_WIDTH, _FRAME_HEIGHT), ) assert _count_mp4_in(tmp_path) == 0 def test_render_overlay_rejects_non_positive_fps() -> None: """fps 非正应直接报错(不静默回退 25.0,见 findings §2E / 项目防御原则)。""" frames = [_black_frame()] per_frame_layers = [ _layers(target_polygon=_square_polygon(20, 20, 40), target_rect=_RIGHT_RECT) ] with pytest.raises(ValueError, match="fps"): render_overlay( frames, per_frame_layers, fps=0.0, cfg=_overlay_cfg(), frame_size=(_FRAME_WIDTH, _FRAME_HEIGHT), ) def test_render_overlay_rejects_length_mismatch() -> None: """帧序列与逐帧图层序列长度不一致是调用方缺陷,直接报错。""" frames = [_black_frame(), _black_frame()] per_frame_layers = [ _layers(target_polygon=_square_polygon(20, 20, 40), target_rect=_RIGHT_RECT) ] # 流式之后这条由 zip(strict=True) 执行。**仍旧钉住是哪一条报的**:这个函数里有四条 # 路径抛 ValueError(fps / frame_size / 图层为空 / 长度不一致),裸 raises 会让其中 # 任何一条被写错都从这里溜过去。 with pytest.raises(ValueError, match=r"zip\(\)"): render_overlay( frames, per_frame_layers, fps=25.0, cfg=_overlay_cfg(), frame_size=(_FRAME_WIDTH, _FRAME_HEIGHT), ) def test_render_overlay_rejects_empty_frames() -> None: """一帧都没有就产不出视频,直接报错(不静默产出空视频)。 判据从「帧序列为空」改成「逐帧图层为空」:流式之后帧数要遍历完才知道,而图层那一侧 仍旧是个列表,长度当场可查。两者必须等长(由 zip 的 strict 执行),所以查哪一侧等价。 """ with pytest.raises(ValueError, match="不能为空"): render_overlay( [], [], fps=25.0, cfg=_overlay_cfg(), frame_size=(_FRAME_WIDTH, _FRAME_HEIGHT), ) def test_render_overlay_rejects_non_positive_frame_size() -> None: """输出尺寸由调用方给,给错了当场报错——编码器拿非法尺寸只会打不开,消息里看不出是谁的错。""" frames = [_black_frame()] layers = [_layers(target_polygon=_square_polygon(20, 20, 40), target_rect=_RIGHT_RECT)] with pytest.raises(ValueError, match="frame_size"): render_overlay(frames, layers, fps=25.0, cfg=_overlay_cfg(), frame_size=(0, 10)) def test_render_overlay_拒绝尺寸和_frame_size_对不上的帧() -> None: """**尺寸不符的帧会被编码器静默丢掉**——实测写 6 帧读回 3 帧,只在 stderr 留一行警告。 后果是 overlay 视频短一截甚至是空的,而它照样有字节、照样被上传、台账照样记完成。 输出尺寸以前取自首帧,结构上不可能不符;改成由调用方给之后这条得自己守 (design/0024 落地后的独立评审报出)。 """ layers = [_layers(target_polygon=_square_polygon(20, 20, 40), target_rect=_RIGHT_RECT)] with pytest.raises(ValueError, match="帧尺寸"): render_overlay( [_black_frame()], layers, fps=25.0, cfg=_overlay_cfg(), # 宽高对调:合法的正尺寸,但和实际帧对不上 frame_size=(_FRAME_HEIGHT, _FRAME_WIDTH), ) def test_render_overlay_接受迭代器而不只是列表() -> None: """**这条守的是流式本身**:服务喂进来的是解码器的生成器,不是列表(design/0024)。 改回 ``Sequence`` 的话,服务那边要么先把全片物化成列表(4.33 GB,正是要消灭的东西), 要么当场报 TypeError;后者会被别的测试抓到,前者不会。 """ layers = [ _layers(target_polygon=_square_polygon(20, 20, 40), target_rect=_RIGHT_RECT) for _ in range(3) ] data = render_overlay( (_black_frame() for _ in range(3)), layers, fps=25.0, cfg=_overlay_cfg(), frame_size=(_FRAME_WIDTH, _FRAME_HEIGHT), ) assert isinstance(data, bytes) and len(data) > 0