Buckets:

Rishik001's picture
download
raw
15.7 kB
"""GPU pose extractors that match pipeline.extract_skeleton_from_video's output schema.
Both models below output standard COCO-17 keypoints in the same joint order that
pipeline.py's MediaPipe extractor already remaps to (nose, l/r eye, l/r ear,
l/r shoulder, l/r elbow, l/r wrist, l/r hip, l/r knee, l/r ankle), so no joint
reordering is needed -- only pixel -> [0, 1] normalization (x / width, y / height)
to match what preprocessing.correct_aspect_ratio expects.
"""
from __future__ import annotations
import os
import pickle
import sys
from pathlib import Path
from typing import Iterable
import numpy as np
def _ensure_onnxruntime_cuda_libs() -> None:
"""Re-exec the process with cuDNN/cuBLAS dirs on LD_LIBRARY_PATH.
onnxruntime-gpu's CUDA execution provider dlopen()s libcudnn/libcublasLt at
runtime. torch's pip-installed nvidia/*/lib wheels already contain these
.so files but aren't on the loader path. The dynamic linker only reads
LD_LIBRARY_PATH at process start, so mutating os.environ after the fact
(e.g. in Python before importing onnxruntime) has no effect -- the process
must be restarted with the variable already set.
"""
if os.environ.get("_GPU_POSE_LIBS_PATCHED"):
return
if not (sys.argv and os.path.isfile(sys.argv[0])):
return # not a real script (e.g. -c/-m); can't safely re-exec
try:
import nvidia # noqa: F401
except ImportError:
return
nvidia_root = Path(nvidia.__file__).resolve().parent
lib_dirs = [
str(nvidia_root / name / "lib")
for name in ("cudnn", "cublas", "cuda_runtime", "cuda_nvrtc")
]
lib_dirs = [p for p in lib_dirs if os.path.isdir(p)]
if not lib_dirs:
return
existing = os.environ.get("LD_LIBRARY_PATH", "")
if all(d in existing for d in lib_dirs):
return
os.environ["LD_LIBRARY_PATH"] = ":".join(lib_dirs + ([existing] if existing else []))
os.environ["_GPU_POSE_LIBS_PATCHED"] = "1"
os.execv(sys.executable, [sys.executable] + sys.argv)
_ensure_onnxruntime_cuda_libs()
def _grab_kept_frames(video_path: Path, frame_stride: int, max_video_frames: int | None):
"""Decode a video, skipping non-kept frames cheaply with grab() instead of a full decode."""
import cv2
cap = cv2.VideoCapture(str(video_path))
if not cap.isOpened():
return None
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
width = cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 16.0
height = cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 9.0
frames = []
decoded_frames = 0
processed_frames = 0
try:
while True:
if max_video_frames is not None and processed_frames >= max_video_frames:
break
ok = cap.grab()
if not ok:
break
decoded_frames += 1
if (decoded_frames - 1) % frame_stride != 0:
continue
ok, frame = cap.retrieve()
if not ok:
break
frames.append(frame)
processed_frames += 1
finally:
cap.release()
return {
"frames": frames,
"fps": float(fps),
"width": float(width),
"height": float(height),
"decoded_frames": decoded_frames,
"processed_frames": processed_frames,
}
def _yolo_cache_path(
cache_dir: Path,
video_path: Path,
weights: str,
imgsz: int,
half: bool,
frame_stride: int,
max_video_frames: int | None,
) -> Path:
weights_tag = Path(weights).stem
cap_tag = max_video_frames if max_video_frames is not None else "all"
cache_suffix = f"yolo_{weights_tag}_i{imgsz}_h{int(half)}_s{frame_stride}_m{cap_tag}"
return cache_dir / f"{video_path.stem}_{cache_suffix}.pkl"
def _load_cached_sample(cache_path: Path) -> dict | None:
if not cache_path.exists():
return None
with cache_path.open("rb") as f:
return pickle.load(f)
def _save_cached_sample(sample: dict, cache_path: Path) -> None:
"""Write via a temp file + atomic rename so a killed process never leaves a torn pickle
behind -- resumable runs depend on every file at cache_path being either absent or valid."""
cache_path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = cache_path.with_suffix(cache_path.suffix + ".tmp")
with tmp_path.open("wb") as f:
pickle.dump(sample, f)
os.replace(tmp_path, cache_path)
def load_yolo_model(weights: str = "yolo11n-pose.pt", device: str = "cuda:0"):
from ultralytics import YOLO
model = YOLO(weights)
model.to(device)
return model
def _predict_with_oom_retry(model, chunk, imgsz: int, half: bool) -> list:
"""Run model.predict on a frame chunk, degrading gracefully on CUDA OOM.
Even with a capped batch, a worker whose CUDA reservation has ratcheted up (long videos +
PyTorch's non-releasing caching allocator) can still OOM on a chunk. Rather than dropping the
whole video, empty the cache and retry the chunk one frame at a time; any single frame that
still OOMs contributes a None (treated downstream as a no-detection frame) instead of killing
the sample. This is issue #6 from the review -- production must not silently lose videos to OOM.
"""
import torch
def _is_oom(exc: Exception) -> bool:
return isinstance(exc, torch.cuda.OutOfMemoryError) or (
isinstance(exc, RuntimeError) and "out of memory" in str(exc).lower()
)
try:
return list(model.predict(chunk, device=0, verbose=False, imgsz=imgsz, half=half, batch=len(chunk)))
except Exception as exc: # noqa: BLE001 -- re-raised below unless it's an OOM
if not _is_oom(exc):
raise
torch.cuda.empty_cache()
results = []
for frame in chunk:
try:
results.extend(model.predict([frame], device=0, verbose=False, imgsz=imgsz, half=half, batch=1))
except Exception as exc: # noqa: BLE001
if not _is_oom(exc):
raise
torch.cuda.empty_cache()
results.append(None) # no-detection sentinel for this frame
return results
def _results_to_skeleton(results: list, width: float, height: float) -> np.ndarray:
"""Convert Ultralytics results (best detection per frame) to a normalized (F, 17, 3) array."""
joints_per_frame = []
for result in results:
if result is None or result.keypoints is None or result.boxes is None or len(result.boxes) == 0:
joints_per_frame.append(np.zeros((17, 3), dtype=np.float64))
continue
best_idx = int(result.boxes.conf.argmax().item())
kpts = result.keypoints.data[best_idx].detach().cpu().numpy() # (17, 3) pixel x, y, conf
kpts = kpts.astype(np.float64)
kpts[:, 0] /= width
kpts[:, 1] /= height
joints_per_frame.append(kpts)
return np.asarray(joints_per_frame, dtype=np.float64)
def _decoded_to_sample_yolo(
decoded: dict,
video_path: Path,
model,
imgsz: int,
half: bool,
max_batch: int,
frame_stride: int,
max_video_frames: int | None,
weights: str,
) -> dict:
"""Run YOLO on already-decoded frames and build a pipeline-compatible sample dict."""
frames = decoded["frames"]
results = []
for i in range(0, len(frames), max_batch):
results.extend(_predict_with_oom_retry(model, frames[i : i + max_batch], imgsz, half))
width, height = decoded["width"], decoded["height"]
return {
"skeleton": _results_to_skeleton(results, width, height),
"fps": decoded["fps"],
"width": width,
"height": height,
"source_file": str(video_path),
"extract_every_n_frames": frame_stride,
"max_video_frames": max_video_frames,
"decoded_frames": decoded["decoded_frames"],
"processed_frames": decoded["processed_frames"],
"pose_estimator": "yolo_pose_gpu",
"yolo_weights": str(weights),
}
def extract_skeleton_from_video_yolo(
video_path: Path,
config: dict,
model,
imgsz: int = 384,
max_batch: int = 64,
) -> dict | None:
"""GPU pose extraction with Ultralytics YOLO-pose, batched over one video's frames.
max_batch caps frames per predict() call. Under multiprocess deployment, PyTorch's CUDA
caching allocator never releases reserved memory between calls, so per-worker reservations
ratchet up unevenly as long (near max_video_frames) videos land on some workers and not
others -- an unbounded batch=len(frames) call (up to max_video_frames, e.g. 160) then
reliably OOMs at process counts that would otherwise fit in VRAM. Measured: uncapped batches
with yolo11m-pose at 8 processes / imgsz=640 dropped 92/300 videos to silent CUDA OOM;
capping at 64 cut that to 2/300. Any residual OOM is absorbed by _predict_with_oom_retry.
"""
frame_stride = max(1, int(config.get("extract_every_n_frames", 1)))
max_video_frames = config.get("max_video_frames")
max_video_frames = None if max_video_frames is None else max(1, int(max_video_frames))
half = bool(config.get("yolo_half", False))
weights = config.get("yolo_weights", "")
cache_dir = config.get("skeleton_cache_dir")
cache_path = None
if cache_dir is not None:
cache_path = _yolo_cache_path(
Path(cache_dir), video_path, weights, imgsz, half, frame_stride, max_video_frames
)
cached = _load_cached_sample(cache_path)
if cached is not None:
return cached
decoded = _grab_kept_frames(video_path, frame_stride, max_video_frames)
if decoded is None or not decoded["frames"]:
return None
sample = _decoded_to_sample_yolo(
decoded, video_path, model, imgsz, half, max_batch, frame_stride, max_video_frames, weights
)
if cache_path is not None:
_save_cached_sample(sample, cache_path)
return sample
def extract_all_videos_yolo(video_samples, config: dict):
"""Extract skeletons for many videos: threaded decode feeding one main-thread GPU consumer.
`video_samples` is an iterable of dicts each with 'video_path' and 'label' (as produced by
pipeline.discover_video_samples). Yields extracted sample dicts (same schema as
pipeline.extract_skeleton_from_video) with 'label' attached, ready for process_sample.
Runs a single CUDA context in this process (safe, simple, one model load). Decode threads
release the GIL in cv2, so they parallelize; the GPU is fed serially from the calling thread.
For higher throughput, run several of these processes over disjoint video shards (the
benchmark's multiprocess pattern) -- the per-process VRAM footprint is ~1.1 GB (nano) /
~1.85 GB (small) / ~3.2 GB (medium) at imgsz=640, fp16, max_batch=64.
Each extracted sample is written to config["skeleton_cache_dir"] immediately (mirroring
pipeline.extract_skeleton_from_video's on-disk cache) instead of only living in the returned
generator. A killed/interrupted run can restart and pick up where it left off -- already-cached
videos are served straight from disk without redoing decode or GPU work.
"""
from concurrent.futures import ThreadPoolExecutor, as_completed
weights = config.get("yolo_weights", "yolo11n-pose.pt")
imgsz = int(config.get("yolo_imgsz", 640))
half = bool(config.get("yolo_half", True))
max_batch = int(config.get("yolo_max_batch", 64))
decode_workers = max(1, int(config.get("yolo_decode_workers", 8)))
frame_stride = max(1, int(config.get("extract_every_n_frames", 1)))
max_video_frames = config.get("max_video_frames")
max_video_frames = None if max_video_frames is None else max(1, int(max_video_frames))
cache_dir = config.get("skeleton_cache_dir")
cache_dir = Path(cache_dir) if cache_dir is not None else None
video_samples = list(video_samples)
to_extract = []
for vs in video_samples:
cache_path = (
_yolo_cache_path(cache_dir, vs["video_path"], weights, imgsz, half, frame_stride, max_video_frames)
if cache_dir is not None
else None
)
cached = _load_cached_sample(cache_path) if cache_path is not None else None
if cached is not None:
yield {**cached, "label": vs["label"]}
else:
to_extract.append((vs, cache_path))
if not to_extract:
return
model = load_yolo_model(weights)
prefetch = max(decode_workers * 2, 32)
with ThreadPoolExecutor(max_workers=decode_workers) as ex:
pending: dict = {}
cursor = 0
def _fill():
nonlocal cursor
while cursor < len(to_extract) and len(pending) < prefetch:
vs, cache_path = to_extract[cursor]
fut = ex.submit(_grab_kept_frames, vs["video_path"], frame_stride, max_video_frames)
pending[fut] = (vs, cache_path)
cursor += 1
_fill()
while pending:
done = next(as_completed(pending))
vs, cache_path = pending.pop(done)
_fill()
try:
decoded = done.result()
except Exception as exc: # noqa: BLE001
print(f" skipped video {vs.get('source_file', vs['video_path'])}: {exc}")
continue
if decoded is None or not decoded["frames"]:
continue
sample = _decoded_to_sample_yolo(
decoded, vs["video_path"], model, imgsz, half, max_batch,
frame_stride, max_video_frames, weights,
)
if cache_path is not None:
_save_cached_sample(sample, cache_path)
yield {**sample, "label": vs["label"]}
def load_rtmpose_model(mode: str = "lightweight", device: str = "cuda"):
from rtmlib import Body
return Body(mode=mode, to_openpose=False, backend="onnxruntime", device=device)
def extract_skeleton_from_video_rtmpose(
video_path: Path,
config: dict,
body_model,
) -> dict | None:
"""GPU pose extraction with RTMPose (rtmlib), per-frame (detector + pose each call)."""
frame_stride = max(1, int(config.get("extract_every_n_frames", 1)))
max_video_frames = config.get("max_video_frames")
max_video_frames = None if max_video_frames is None else max(1, int(max_video_frames))
decoded = _grab_kept_frames(video_path, frame_stride, max_video_frames)
if decoded is None or not decoded["frames"]:
return None
width, height = decoded["width"], decoded["height"]
joints_per_frame = []
for frame in decoded["frames"]:
keypoints, scores = body_model(frame)
if keypoints is None or len(keypoints) == 0:
joints_per_frame.append(np.zeros((17, 3), dtype=np.float64))
continue
best_idx = int(np.argmax(scores.mean(axis=1)))
kpts = keypoints[best_idx].astype(np.float64) # (17, 2) pixel x, y
conf = scores[best_idx].astype(np.float64) # (17,)
joint = np.concatenate([kpts, conf[:, None]], axis=1)
joint[:, 0] /= width
joint[:, 1] /= height
joints_per_frame.append(joint)
return {
"skeleton": np.asarray(joints_per_frame, dtype=np.float64),
"fps": decoded["fps"],
"width": width,
"height": height,
"source_file": str(video_path),
"extract_every_n_frames": frame_stride,
"max_video_frames": max_video_frames,
"decoded_frames": decoded["decoded_frames"],
"processed_frames": decoded["processed_frames"],
"pose_estimator": "rtmpose_gpu",
}

Xet Storage Details

Size:
15.7 kB
·
Xet hash:
d9bbb589dc1176810638f43bd388b33aff0e71c9795514c516b0398ad3ab25c4

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.