File size: 9,628 Bytes
76ecfee | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 | # /// 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()
|