"""vidbox: annotate a video with per-frame bounding boxes and masks. The video counterpart of ``pilbox.annotate``. It ties together the three lite modules — frame I/O (:mod:`ffmpret`), bbox format conversion (:mod:`boxer`), and drawing (:mod:`pilbox`) — to turn a video plus a flat list of per-frame detections into an annotated video. Detections are a flat list of dicts, each carrying a frame index, the box (in one of the :data:`boxer.BBOX_FORMATS` conventions, read from configurable coordinate keys) and, optionally, a label/color value and a base64-PNG mask. Every detection for a frame is drawn onto that frame; a shared color map keeps each ``color_key`` value (e.g. a track id) one stable color across the whole video. CLI (typer + loguru), kept separate from the lite modules it drives: ```bash uv run python vidbox.py annotate-video-file in.mp4 dets.json out.mp4 \\ --bbox-format coco_normalized ``` """ import json import sys from collections import defaultdict import typer from loguru import logger from PIL import Image, ImageColor from typing import Literal, Optional import boxer import ffmpret import pilbox CROP_MODES = ("window", "box_fit") GAP_BEHAVIORS = ("jump", "carry_forward") MASK_GAP_BEHAVIORS = ("skip", "fill") logger.remove() logger.add( sys.stderr, format="{time:YYYY-MM-DD ddd HH:mm:ss} | {level} | {message}", ) app = typer.Typer(pretty_exceptions_show_locals=False) @app.callback() def _main(): """Annotate videos with per-frame bounding boxes and masks.""" def annotate_video( video_path: str, detections, out_path: str, *, bbox_format: str = "coco_normalized", coord_keys=("x", "y", "w", "h"), frame_key: str = "frame", label_key: str = "track_id", color_key: str = "track_id", mask_key: str = "mask_b64", mask_alpha: float = 0.5, width: int = 3, font_size: int = 20, text_overlay: Optional[str] = None, text_font_size: int = 20, text_y_position: str = "bottom", ) -> str: """Draw per-frame boxes and masks over a video and write the annotated result. Frames are extracted at native fps/resolution (so frame index ``i`` matches a detection's ``frame_key`` value, and full-frame masks line up), annotated with :func:`pilbox.annotate`, then re-encoded to a silent video at the source fps. An optional ``text_overlay`` label is burned in at extraction time, so it renders beneath the box/mask annotations. Args: video_path: Path to the input video. detections: List of detection dicts. Each holds a frame index under ``frame_key``, box coordinates under ``coord_keys`` (interpreted per ``bbox_format``) and optionally ``label_key`` / ``color_key`` / ``mask_key`` values. out_path: Destination path for the annotated video. bbox_format: One of :data:`boxer.BBOX_FORMATS` describing the box values. coord_keys: The four object keys holding the box values, in order. frame_key: Object key holding the (0-based) frame index. label_key: Object key whose value is drawn as each box's label. color_key: Object key used to color-group boxes/masks (stable across frames). mask_key: Object key holding a base64-PNG mask (same size as the frame); pass ``""`` to disable masks. mask_alpha: Mask overlay opacity in ``[0, 1]``. width: Box outline width in pixels. font_size: Label font size in points (the pilbox box-label font). text_overlay: Optional literal text burned into every frame (beneath the annotations); ``None``/empty disables it. text_font_size: Font size of the ``text_overlay`` (the ffmpeg drawtext font, distinct from ``font_size``). Only used when ``text_overlay`` is set. text_y_position: Vertical placement of the ``text_overlay``; one of :data:`ffmpret.TEXT_Y_POSITIONS` (``top``/``middle``/``bottom``). Only used when ``text_overlay`` is set. Returns: ``out_path``. Raises: ValueError: If ``bbox_format`` is unknown or no frames are decoded. """ if bbox_format not in boxer.BBOX_FORMATS: raise ValueError( f"unknown bbox_format {bbox_format!r}; expected one of {list(boxer.BBOX_FORMATS)}" ) vmeta = ffmpret.get_video_metadata(video_path, bverbose=False) org_w, org_h, fps = vmeta["width"], vmeta["height"], vmeta["fps"] # Extract every native frame so frame indices align with the detections. frames = ffmpret.extract_frames( video_path, fps=None, write_timestamp=False, text_overlay=text_overlay, text_font_size=text_font_size, text_y_position=text_y_position, ) if not frames: raise ValueError(f"no frames decoded from {video_path}") # Group detections by frame index. by_frame = defaultdict(list) for det in detections: by_frame[int(det[frame_key])].append(det) # Pre-seed a shared color map over all color_key values (sorted for # determinism) so each value keeps one color across every frame. color_map: dict = {} for cid in sorted({det.get(color_key) for det in detections}, key=repr): pilbox.color_for(cid, color_map) font = pilbox.load_pil_font(size=font_size) n_drawn = 0 annotated = [] for i, frame in enumerate(frames): objects = [] for det in by_frame.get(i, []): coords = [det[k] for k in coord_keys] objects.append( { **det, "boundingBox": boxer.to_pascal_voc( coords, bbox_format, org_w, org_h ), } ) n_drawn += len(objects) annotated.append( pilbox.annotate( frame, objects, label_key=label_key, color_key=color_key, bbox_key="boundingBox", mask_key=mask_key, mask_alpha=mask_alpha, width=width, font=font, color_map=color_map, ) ) # Warn if detections reference frames beyond what was decoded. max_frame = max(by_frame) if by_frame else -1 if max_frame >= len(frames): logger.warning( f"{sum(len(v) for k, v in by_frame.items() if k >= len(frames))} detection(s) " f"reference frames >= {len(frames)} (decoded {len(frames)}); they were skipped" ) logger.info( f"annotated {n_drawn} detection(s) across {len(frames)} frames " f"({len(color_map)} distinct {color_key!r})" ) return ffmpret.frames_to_video(annotated, out_path, fps=fps) def _even(n) -> int: """Round ``n`` down to the nearest even int >= 2 (yuv420p needs even dims).""" n = int(n) return max(2, n - (n % 2)) def _pad_clamp_box(box: dict, padding: float, im_w: int, im_h: int) -> dict: """Expand a pascal_voc box about its center by ``padding``, clamped to the frame.""" cx = (box["x0"] + box["x1"]) / 2 cy = (box["y0"] + box["y1"]) / 2 bw = (box["x1"] - box["x0"]) * padding bh = (box["y1"] - box["y0"]) * padding x0 = max(0, int(round(cx - bw / 2))) y0 = max(0, int(round(cy - bh / 2))) x1 = min(im_w, int(round(cx + bw / 2))) y1 = min(im_h, int(round(cy + bh / 2))) # guarantee a non-empty box x1 = max(x1, x0 + 1) y1 = max(y1, y0 + 1) return {"x0": x0, "y0": y0, "x1": x1, "y1": y1} def _crop_window(frame: Image.Image, box: dict, w: int, h: int) -> Image.Image: """Crop a fixed ``w`` x ``h`` window centered on ``box``, clamped inside ``frame``.""" fw, fh = frame.size cx = (box["x0"] + box["x1"]) // 2 cy = (box["y0"] + box["y1"]) // 2 x0 = min(max(0, cx - w // 2), fw - w) y0 = min(max(0, cy - h // 2), fh - h) return pilbox.crop(frame, x0, y0, x0 + w, y0 + h) def _gap_frame(frame: Image.Image, w: int, h: int, mode: str) -> Image.Image: """Frame for a gap (no detection) under ``jump``: centered window / black canvas.""" if mode == "window": fw, fh = frame.size x0 = max(0, (fw - w) // 2) y0 = max(0, (fh - h) // 2) return pilbox.crop(frame, x0, y0, x0 + w, y0 + h) return Image.new("RGB", (w, h), (0, 0, 0)) def crop_video( video_path: str, detections, out_path: str, *, bbox_format: str = "coco_normalized", coord_keys=("x", "y", "w", "h"), frame_key: str = "frame", mode: Literal["window", "box_fit"] = "window", padding: float = 1.0, gap_behavior: Literal["jump", "carry_forward"] = "jump", ) -> str: """Crop a video to a subject that moves frame-to-frame, and write the result. The crop is a fixed-size **tracking window** whose size is the per-axis max of all per-frame boxes (times ``padding``, rounded even, clamped to the frame) so every box fits. Frames are extracted at native fps/resolution, cropped, and re-encoded to a silent video at the source fps. Masks (if any) in the detections are ignored — only boxes matter. Each frame must carry **at most one** box: exact-duplicate detection rows collapse to one, but a frame with two *different* boxes raises ``ValueError``. Frames with no detection are gaps, handled per ``gap_behavior``. Args: video_path: Path to the input video. detections: List of detection dicts, each with a frame index under ``frame_key`` and box values under ``coord_keys`` (interpreted per ``bbox_format``). out_path: Destination path for the cropped video. bbox_format: One of :data:`boxer.BBOX_FORMATS` describing the box values. coord_keys: The four object keys holding the box values, in order. frame_key: Object key holding the (0-based) frame index. mode: ``"window"`` (crop a fixed window from the frame, keeping surrounding scene, the window pans with the subject) or ``"box_fit"`` (crop exactly to the box, black-pad to the output aspect ratio, resize to fill — subject only, no scene). padding: Multiplier expanding each box about its center before sizing/cropping. gap_behavior: For frames with no detection — ``"jump"`` (window: center on the frame; box_fit: a black frame) or ``"carry_forward"`` (reuse the previous output frame). Returns: ``out_path``. Raises: ValueError: On an unknown ``mode`` / ``gap_behavior`` / ``bbox_format``, a frame with conflicting boxes, no usable detections, or no decoded frames. """ if mode not in CROP_MODES: raise ValueError(f"unknown mode {mode!r}; expected one of {list(CROP_MODES)}") if gap_behavior not in GAP_BEHAVIORS: raise ValueError( f"unknown gap_behavior {gap_behavior!r}; expected one of {list(GAP_BEHAVIORS)}" ) if bbox_format not in boxer.BBOX_FORMATS: raise ValueError( f"unknown bbox_format {bbox_format!r}; expected one of {list(boxer.BBOX_FORMATS)}" ) # Group by frame; collapse exact-duplicate boxes; error on conflicting boxes. # Validate the JSON before touching ffmpeg so bad input fails fast. grouped = defaultdict(list) for det in detections: grouped[int(det[frame_key])].append(det) coords_by_frame = {} # frame index -> the frame's single (deduped) coord tuple conflicts = [] for f, dets in grouped.items(): distinct = {tuple(det[k] for k in coord_keys) for det in dets} if len(distinct) > 1: conflicts.append(f) continue coords_by_frame[f] = next(iter(distinct)) if conflicts: raise ValueError( "expected at most one box per frame; frames with conflicting boxes: " f"{sorted(conflicts)[:20]}" ) if not coords_by_frame: raise ValueError("no detections to crop from") vmeta = ffmpret.get_video_metadata(video_path, bverbose=False) org_w, org_h, fps = vmeta["width"], vmeta["height"], vmeta["fps"] by_frame = { # frame index -> padded, clamped pascal_voc box f: _pad_clamp_box( boxer.to_pascal_voc(coords, bbox_format, org_w, org_h), padding, org_w, org_h, ) for f, coords in coords_by_frame.items() } # Fixed output size = per-axis max box, even, clamped to the frame. out_w = _even(min(org_w, max(b["x1"] - b["x0"] for b in by_frame.values()))) out_h = _even(min(org_h, max(b["y1"] - b["y0"] for b in by_frame.values()))) frames = ffmpret.extract_frames(video_path, fps=None, write_timestamp=False) if not frames: raise ValueError(f"no frames decoded from {video_path}") cropped = [] last = None for i, frame in enumerate(frames): box = by_frame.get(i) if box is not None: if mode == "window": out = _crop_window(frame, box, out_w, out_h) else: # box_fit sub = pilbox.crop(frame, box["x0"], box["y0"], box["x1"], box["y1"]) out = pilbox.letterbox(sub, out_w, out_h) last = out elif gap_behavior == "carry_forward" and last is not None: out = last else: # jump, or carry_forward before any boxed frame out = _gap_frame(frame, out_w, out_h, mode) cropped.append(out) logger.info( f"cropped {len(by_frame)} boxed frame(s) of {len(frames)} to {out_w}x{out_h} " f"(mode={mode}, gap={gap_behavior})" ) return ffmpret.frames_to_video(cropped, out_path, fps=fps) def mask_video( video_path: str, detections, out_path: str, *, mask_key: str = "mask_b64", frame_key: str = "frame", bg_rgb_tup=(0, 0, 0), gap_behavior: str = "skip", ) -> str: """Mask a video per-frame — keep each frame's foreground over a solid background color. The video counterpart of :func:`pilbox.apply_mask`: every native frame with a mask keeps its masked foreground and has the background replaced by ``bg_rgb_tup``; the result is re-encoded to a silent video at the source fps. Any bounding boxes in the detections are ignored — only the mask matters. Each frame must carry **at most one** mask: exact-duplicate mask rows collapse to one, but a frame with two *different* masks raises ``ValueError``. Frames with no mask are gaps. Args: video_path: Path to the input video. detections: List of detection dicts, each with a frame index under ``frame_key`` and a base64-PNG mask (same size as the frame) under ``mask_key``. out_path: Destination path for the masked video. mask_key: Object key holding the base64-encoded PNG mask. frame_key: Object key holding the (0-based) frame index. bg_rgb_tup: Background fill color as an ``(r, g, b)`` tuple — used both behind the mask and to fill gap frames under ``gap_behavior="fill"``. gap_behavior: For frames with no mask — ``"skip"`` (drop them, yielding a shorter video) or ``"fill"`` (keep the frame, painted entirely with ``bg_rgb_tup``). Returns: ``out_path``. Raises: ValueError: On an unknown ``gap_behavior``, a frame with conflicting masks, no masks at all, or no decoded frames. """ if gap_behavior not in MASK_GAP_BEHAVIORS: raise ValueError( f"unknown gap_behavior {gap_behavior!r}; expected one of {list(MASK_GAP_BEHAVIORS)}" ) # Group by frame; collapse exact-duplicate masks; error on conflicting masks. # Validate the JSON before touching ffmpeg so bad input fails fast. grouped = defaultdict(list) for det in detections: grouped[int(det[frame_key])].append(det) mask_by_frame = {} # frame index -> the frame's single (deduped) mask conflicts = [] for f, dets in grouped.items(): distinct = {det[mask_key] for det in dets if det.get(mask_key)} if len(distinct) > 1: conflicts.append(f) elif distinct: mask_by_frame[f] = next(iter(distinct)) if conflicts: raise ValueError( "expected at most one mask per frame; frames with conflicting masks: " f"{sorted(conflicts)[:20]}" ) if not mask_by_frame: raise ValueError("no masks to apply") vmeta = ffmpret.get_video_metadata(video_path, bverbose=False) fps = vmeta["fps"] frames = ffmpret.extract_frames(video_path, fps=None, write_timestamp=False) if not frames: raise ValueError(f"no frames decoded from {video_path}") bg = tuple(bg_rgb_tup) masked = [] for i, frame in enumerate(frames): mask = mask_by_frame.get(i) if mask is not None: masked.append(pilbox.apply_mask(frame, mask, bg_rgb_tup=bg)) elif gap_behavior == "fill": masked.append(Image.new("RGB", frame.size, bg)) # else "skip": omit the frame if not masked: raise ValueError("no frames to encode (all frames were gaps and skipped)") logger.info( f"masked {len(mask_by_frame)} of {len(frames)} frames " f"(gap={gap_behavior}, kept {len(masked)})" ) return ffmpret.frames_to_video(masked, out_path, fps=fps) @app.command() def annotate_video_file( video_path: str, json_path: str, out_path: str, bbox_format: str = "coco_normalized", coord_keys: str = "x,y,w,h", frame_key: str = "frame", label_key: str = "track_id", color_key: str = "track_id", mask_key: str = "mask_b64", mask_alpha: float = 0.5, width: int = 3, font_size: int = 20, text_overlay: Optional[str] = None, text_font_size: int = 20, text_y_position: str = "bottom", ) -> str: """Annotate ``video_path`` using detections from a JSON file; save to ``out_path``. ``json_path`` holds a JSON list of detection dicts (see :func:`annotate_video`). ``coord_keys`` is a comma-separated list of the four coordinate keys. """ with open(json_path) as f: detections = json.load(f) return annotate_video( video_path, detections, out_path, bbox_format=bbox_format, coord_keys=tuple(k.strip() for k in coord_keys.split(",")), frame_key=frame_key, label_key=label_key, color_key=color_key, mask_key=mask_key, mask_alpha=mask_alpha, width=width, font_size=font_size, text_overlay=text_overlay, text_font_size=text_font_size, text_y_position=text_y_position, ) @app.command() def crop_video_file( video_path: str, json_path: str, out_path: str, bbox_format: str = "coco_normalized", coord_keys: str = "x,y,w,h", frame_key: str = "frame", mode: str = "window", padding: float = 1.0, gap_behavior: str = "jump", ) -> str: """Crop ``video_path`` to a moving subject using boxes from a JSON file; save to ``out_path``. ``json_path`` holds a JSON list of detection dicts — one box per frame (see :func:`crop_video`; any masks in the JSON are ignored). The output is a constant-size, silent video sized to the largest box, so the crop follows the subject across frames. Args: video_path: Path to the input video. json_path: Path to the detections JSON (a flat list of per-frame box dicts). out_path: Destination path for the cropped video. bbox_format: Box convention in the JSON — one of pascal_voc / albumentations / coco / coco_normalized. coord_keys: Comma-separated names of the four coordinate keys, in order (e.g. "x,y,w,h"). frame_key: Object key holding the 0-based frame index. mode: How each frame is cropped. "window" keeps the surrounding scene — a fixed-size window is cropped from the frame and re-centered on the box, panning to follow the subject. "box_fit" shows the subject only — crop exactly to the box, black-pad to the output aspect ratio (no stretching), then resize to fill. padding: Multiplier that expands each box about its center before sizing/cropping. 1.0 = box as-is; e.g. 1.2 adds ~20% margin around the subject; larger values zoom out more. gap_behavior: What to show on frames with no detection. "jump" centers the window on the frame ("window") or emits a black frame ("box_fit"); "carry_forward" repeats the previous output frame. """ with open(json_path) as f: detections = json.load(f) return crop_video( video_path, detections, out_path, bbox_format=bbox_format, coord_keys=tuple(k.strip() for k in coord_keys.split(",")), frame_key=frame_key, mode=mode, padding=padding, gap_behavior=gap_behavior, ) @app.command() def mask_video_file( video_path: str, json_path: str, out_path: str, mask_key: str = "mask_b64", frame_key: str = "frame", bg_color: str = "#000000", gap_behavior: str = "skip", ) -> str: """Mask ``video_path`` per-frame using masks from a JSON file; save to ``out_path``. ``json_path`` holds a JSON list of detection dicts — one mask per frame (see :func:`mask_video`; any bounding boxes are ignored). Each frame's masked foreground is kept over the ``bg_color`` background. Args: video_path: Path to the input video. json_path: Path to the detections JSON (a flat list of per-frame mask dicts). out_path: Destination path for the masked video. mask_key: Object key holding the base64-encoded PNG mask. frame_key: Object key holding the 0-based frame index. bg_color: Background color as a CSS string (e.g. "#000000") behind the mask and for fill. gap_behavior: For frames with no mask — "skip" (drop them) or "fill" (paint the whole frame with bg_color). """ with open(json_path) as f: detections = json.load(f) return mask_video( video_path, detections, out_path, mask_key=mask_key, frame_key=frame_key, bg_rgb_tup=ImageColor.getrgb(bg_color), gap_behavior=gap_behavior, ) if __name__ == "__main__": app()