Download tools/extract_frames.py from Affaad/birdie: direct link, hf CLI and curl.
- Browser
- Download file 9.63 kB
-
https://huggingface.co/spaces/Affaad/birdie/resolve/main/tools/extract_frames.py
- Command line
-
hf download hf://spaces/Affaad/birdie/tools/extract_frames.py
-
curl -L -o extract_frames.py https://huggingface.co/spaces/Affaad/birdie/resolve/main/tools/extract_frames.py
9.63 kB
| # /// script | |
| # requires-python = ">=3.11,<3.14" | |
| # dependencies = [ | |
| # "torch", | |
| # "torchvision", | |
| # "transformers>=5.0,<6", | |
| # "pillow", | |
| # "numpy", | |
| # "shapely>=2.0", | |
| # "pydantic>=2.6", | |
| # "opencv-python-headless", | |
| # ] | |
| # | |
| # [[tool.uv.index]] | |
| # name = "pytorch-cpu" | |
| # url = "https://download.pytorch.org/whl/cpu" | |
| # explicit = true | |
| # | |
| # [tool.uv.sources] | |
| # torch = { index = "pytorch-cpu" } | |
| # torchvision = { index = "pytorch-cpu" } | |
| # /// | |
| """Extract evaluation frames from a clip, with people pixelated. | |
| For every sampled frame (one every 2 seconds by default) the Birdie detector runs on the ORIGINAL frame in | |
| memory. Only a copy with people pixelated is written, plus the detections (normalised boxes) and the | |
| timestamp. Original frames are never saved. | |
| uv run tools/extract_frames.py eval_clips/table1.mp4 --seats eval_data/table1/seats.json | |
| uv run tools/extract_frames.py eval_clips/table1.mp4 # reuses eval_data/<clip_id>/seats.json | |
| Output: eval_data/<clip_id>/{meta.json, seats.json, frames/000000.jpg, frames/000000.json, ...} | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import math | |
| import shutil | |
| import sys | |
| from collections.abc import Callable, Iterator | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| ROOT = Path(__file__).resolve().parent.parent | |
| sys.path.insert(0, str(ROOT)) | |
| import cv2 # noqa: E402 | |
| import numpy as np # noqa: E402 | |
| from birdie.config import DETECTOR_MODEL_ID # noqa: E402 | |
| from birdie.evaluation import clip_id_from_path, frame_id, load_seats, save_seats # noqa: E402 | |
| from birdie.models import Seat # noqa: E402 | |
| from birdie.vision.clip import iter_clip_frames # noqa: E402 | |
| from birdie.vision.render import blur_people # noqa: E402 | |
| EVAL_ROOT = ROOT / "eval_data" | |
| CLIPS_DIR = ROOT / "eval_clips" | |
| SAVE_THRESHOLD = 0.15 # detections kept in the JSON (evaluation re-thresholds at the app's 0.4) | |
| PIXELATE_THRESHOLD = 0.15 # people pixelated from this low score, to err on the side of privacy | |
| PIXELATE_PAD = 0.2 # extra margin around each person box | |
| DEFAULT_EVERY = 2.0 | |
| DEFAULT_MAX_SIDE = 960 | |
| def _resize(frame: np.ndarray, max_side: int) -> np.ndarray: | |
| h, w = frame.shape[:2] | |
| scale = max_side / max(h, w) | |
| if scale >= 1.0: | |
| return frame | |
| return cv2.resize(frame, (max(1, round(w * scale)), max(1, round(h * scale))), interpolation=cv2.INTER_AREA) | |
| def pixelated_copy(frame: np.ndarray, detections_px: list[dict], extra_boxes_norm: list[list[float]] | None = None, | |
| max_side: int = DEFAULT_MAX_SIDE) -> tuple[np.ndarray, list[list[float]]]: | |
| """Resize the original frame and pixelate every person (current boxes plus `extra_boxes_norm`). | |
| Returns the pixelated RGB copy and the normalised person boxes that were pixelated. | |
| """ | |
| h, w = frame.shape[:2] | |
| persons = [ | |
| [d["box"][0] / w, d["box"][1] / h, d["box"][2] / w, d["box"][3] / h] | |
| for d in detections_px if d["label"] == "person" and d["score"] >= PIXELATE_THRESHOLD | |
| ] | |
| small = _resize(frame, max_side) | |
| sh, sw = small.shape[:2] | |
| boxes = persons + list(extra_boxes_norm or []) | |
| px = [[b[0] * sw, b[1] * sh, b[2] * sw, b[3] * sh] for b in boxes] | |
| return blur_people(small, px, pad=PIXELATE_PAD), persons | |
| def detect_original(frame: np.ndarray) -> list[dict]: | |
| """Run the Birdie detector on the original frame (in memory). Boxes in pixels of that frame.""" | |
| from birdie.vision.detector import detect | |
| return detect(frame, threshold=SAVE_THRESHOLD) | |
| def preview_frame(clip_path: str | Path, max_side: int = DEFAULT_MAX_SIDE) -> np.ndarray: | |
| """The clip's first frame with people pixelated, in memory only (used to draw seats).""" | |
| for _, frame in iter_clip_frames(str(clip_path), fps=1.0): | |
| pixelated, _ = pixelated_copy(frame, detect_original(frame), max_side=max_side) | |
| return pixelated | |
| raise ValueError("the clip has no frames") | |
| def estimate_samples(clip_path: str | Path, every: float) -> int: | |
| cap = cv2.VideoCapture(str(clip_path)) | |
| try: | |
| fps = cap.get(cv2.CAP_PROP_FPS) or 25.0 | |
| count = cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0 | |
| finally: | |
| cap.release() | |
| return max(1, math.ceil(count / fps / every)) if count else 0 | |
| def iter_extract( | |
| clip_path: str | Path, | |
| out_root: str | Path = EVAL_ROOT, | |
| clip_id: str | None = None, | |
| seats: list[Seat] | None = None, | |
| every: float = DEFAULT_EVERY, | |
| max_side: int = DEFAULT_MAX_SIDE, | |
| force: bool = False, | |
| ) -> Iterator[tuple[int, int]]: | |
| """Extract frames, yielding (frames_done, estimated_total) as it goes.""" | |
| clip_path = Path(clip_path) | |
| clip_id = clip_id or clip_id_from_path(clip_path) | |
| out = Path(out_root) / clip_id | |
| frames_dir = out / "frames" | |
| meta_path = out / "meta.json" | |
| if frames_dir.exists() and any(frames_dir.glob("*.json")): | |
| old = json.loads(meta_path.read_text(encoding="utf-8")) if meta_path.exists() else {} | |
| if not force: | |
| if old.get("every_seconds") not in (None, every): | |
| raise ValueError(f"{clip_id} was extracted every {old.get('every_seconds')} s; use force to redo it " | |
| "(labels are matched by frame number, so they would no longer line up)") | |
| if old.get("complete"): | |
| return # already extracted | |
| shutil.rmtree(frames_dir) | |
| frames_dir.mkdir(parents=True, exist_ok=True) | |
| if seats: | |
| save_seats(out, clip_id, seats) | |
| total = estimate_samples(clip_path, every) | |
| meta = { | |
| "clip_id": clip_id, | |
| "source_name": clip_path.name, | |
| "every_seconds": every, | |
| "max_side": max_side, | |
| "detector": DETECTOR_MODEL_ID, | |
| "saved_detection_threshold": SAVE_THRESHOLD, | |
| "pixelate_threshold": PIXELATE_THRESHOLD, | |
| "originals_saved": False, | |
| "created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), | |
| "complete": False, | |
| } | |
| meta_path.write_text(json.dumps(meta, indent=2) + "\n", encoding="utf-8") | |
| prev_persons: list[list[float]] = [] | |
| n = 0 | |
| for t, frame in iter_clip_frames(str(clip_path), fps=1.0 / every): | |
| h, w = frame.shape[:2] | |
| detections = detect_original(frame) # original frame, in memory only | |
| # Also pixelate where people were in the previous sample, in case the detector misses someone now. | |
| pixelated, persons = pixelated_copy(frame, detections, prev_persons, max_side=max_side) | |
| prev_persons = persons | |
| del frame # the original is never written anywhere | |
| fid = frame_id(n) | |
| ok, buf = cv2.imencode(".jpg", cv2.cvtColor(pixelated, cv2.COLOR_RGB2BGR), [int(cv2.IMWRITE_JPEG_QUALITY), 85]) | |
| if not ok: | |
| raise RuntimeError("could not encode frame") | |
| (frames_dir / f"{fid}.jpg").write_bytes(buf.tobytes()) | |
| record = { | |
| "frame_id": fid, | |
| "t": round(float(t), 3), | |
| "width": int(pixelated.shape[1]), | |
| "height": int(pixelated.shape[0]), | |
| "detections": [ | |
| {"label": d["label"], "score": d["score"], | |
| "box": [round(d["box"][0] / w, 5), round(d["box"][1] / h, 5), | |
| round(d["box"][2] / w, 5), round(d["box"][3] / h, 5)]} | |
| for d in detections | |
| ], | |
| } | |
| (frames_dir / f"{fid}.json").write_text(json.dumps(record) + "\n", encoding="utf-8") | |
| n += 1 | |
| yield n, max(total, n) | |
| meta.update({"frames": n, "complete": True}) | |
| meta_path.write_text(json.dumps(meta, indent=2) + "\n", encoding="utf-8") | |
| def extract_clip(clip_path, out_root=EVAL_ROOT, clip_id=None, seats=None, every=DEFAULT_EVERY, | |
| max_side=DEFAULT_MAX_SIDE, force=False, on_progress: Callable[[int, int], None] | None = None) -> Path: | |
| clip_id = clip_id or clip_id_from_path(clip_path) | |
| for done, total in iter_extract(clip_path, out_root, clip_id, seats, every, max_side, force): | |
| if on_progress: | |
| on_progress(done, total) | |
| return Path(out_root) / clip_id | |
| def main() -> None: | |
| p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) | |
| p.add_argument("clip", help="video file (mp4, mov, ...)") | |
| p.add_argument("--seats", help="seat layout JSON (default: eval_data/<clip_id>/seats.json)") | |
| p.add_argument("--clip-id", help="folder name (default: from the file name)") | |
| p.add_argument("--every", type=float, default=DEFAULT_EVERY, help="seconds between frames (default 2)") | |
| p.add_argument("--max-side", type=int, default=DEFAULT_MAX_SIDE, help="long side of saved frames (default 960)") | |
| p.add_argument("--out", default=str(EVAL_ROOT), help="output root (default eval_data/)") | |
| p.add_argument("--force", action="store_true", help="re-extract even if frames exist") | |
| args = p.parse_args() | |
| clip_id = args.clip_id or clip_id_from_path(args.clip) | |
| out = Path(args.out) / clip_id | |
| if args.seats: | |
| data = json.loads(Path(args.seats).read_text(encoding="utf-8")) | |
| seats = [Seat.model_validate(s | {"venue_id": clip_id}) for s in data.get("seats", data)] | |
| else: | |
| seats = load_seats(out) | |
| if not seats: | |
| sys.exit(f"No seat layout: pass --seats or draw the seats in the label app first ({out / 'seats.json'})") | |
| extract_clip(args.clip, args.out, clip_id, seats, args.every, args.max_side, args.force, | |
| on_progress=lambda d, t: print(f"\r{clip_id}: {d}/{t} frames", end="", flush=True)) | |
| print(f"\nWrote {out} (people pixelated; originals not saved)") | |
| if __name__ == "__main__": | |
| main() | |