Buckets:

Rishik001's picture
download
raw
40.5 kB
"""Main entry point for the rebuilt figure-skating data pipeline."""
from __future__ import annotations
import json
import os
import pickle
import random
import time
from collections import Counter
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import Iterable
import numpy as np
from tqdm import tqdm
try:
from . import labels, preprocessing
except ImportError:
import labels
import preprocessing
# Imported at module load (not lazily) on purpose: gpu_pose runs a one-time LD_LIBRARY_PATH
# fix that may re-exec the process. Doing it here means any re-exec happens during startup,
# before run_pipeline does any work; a lazy import mid-run would restart and lose progress.
# Kept optional so the MediaPipe path still works on boxes without torch/ultralytics installed.
try:
from . import gpu_pose
except ImportError:
try:
import gpu_pose
except Exception: # noqa: BLE001 -- gpu extractors simply unavailable
gpu_pose = None
PROJECT_ROOT = Path(__file__).resolve().parents[1]
os.environ.setdefault("GLOG_minloglevel", "2")
os.environ.setdefault("TF_CPP_MIN_LOG_LEVEL", "2")
os.environ.setdefault("ABSL_LOGGING_MIN_LOG_LEVEL", "2")
CONFIG = {
"datasets": {
"skatingverse": PROJECT_ROOT / "data" / "skatingverse",
"current": PROJECT_ROOT / "data" / "current",
"mmfs": PROJECT_ROOT / "data" / "mmfs",
},
"pose_estimator": "yolo", # "mediapipe" (CPU) or "yolo" (GPU, see yolo_* keys below)
"yolo_weights": "yolo11n-pose.pt", # or "yolo11s-pose.pt" / "yolo11m-pose.pt"
"yolo_imgsz": 384, # 640 needed for reliable skater detection on wide rink shots (256 underdetects)
"yolo_half": True, # fp16 inference
"yolo_max_batch": 128, # cap frames per predict() call to bound VRAM under parallelism
"yolo_decode_workers": 16, # GIL-releasing cv2 decode threads feeding the single GPU consumer
"target_sequence_length": 128,
"confidence_threshold": 0.3,
"smoothing_window": 7,
"smoothing_polyorder": 3,
"fps": 30.0,
"max_videos": 10000,
"default_width": 16.0,
"default_height": 9.0,
"train_ratio": 0.8,
"val_ratio": 0.1,
"test_ratio": 0.1,
"random_seed": 42,
"output_dir": PROJECT_ROOT / "data" / "processed_new",
"skeleton_cache_dir": PROJECT_ROOT / "data" / "processed_new" / "skeleton_cache",
"num_workers": 12,
"video_num_workers": 1,
"suppress_mediapipe_logs": True,
"mediapipe_model_complexity": 0,
"extract_every_n_frames": 1,
"max_video_frames": 300,
"show_frame_progress": False,
}
# Hardcoded run controls. Edit these values before running this file.
DRY_RUN = False
DRY_RUN_SOURCE = "skatingverse"
NUM_DRY_RUN_SAMPLES = 1
SOURCES = None # Example: ["current"] or ["skatingverse", "current"]; None means all sources.
POSE_ESTIMATOR = "yolo" # "yolo" (GPU) or "mediapipe" (CPU) for video-derived sources
YOLO_WEIGHTS = "yolo11n-pose.pt" # "yolo11n-pose.pt" / "yolo11s-pose.pt" / "yolo11m-pose.pt"
OUTPUT_DIR = CONFIG["output_dir"]
TARGET_SEQUENCE_LENGTH = CONFIG["target_sequence_length"]
NUM_WORKERS = CONFIG["num_workers"]
VIDEO_NUM_WORKERS = CONFIG["video_num_workers"]
MEDIAPIPE_MODEL_COMPLEXITY = CONFIG["mediapipe_model_complexity"]
EXTRACT_EVERY_N_FRAMES = CONFIG["extract_every_n_frames"]
MAX_VIDEO_FRAMES = CONFIG["max_video_frames"]
VIDEO_EXTENSIONS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v"}
SKELETON_EXTENSIONS = {".pkl", ".pickle", ".npz", ".npy"}
def load_pickle(path: Path):
with path.open("rb") as f:
return pickle.load(f)
def save_pickle(obj, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("wb") as f:
pickle.dump(obj, f)
class suppress_stderr:
"""Temporarily redirect process stderr to os.devnull for noisy native libraries."""
def __init__(self, enabled: bool):
self.enabled = enabled
self._saved_fd = None
self._devnull_fd = None
def __enter__(self):
if not self.enabled:
return self
self._saved_fd = os.dup(2)
self._devnull_fd = os.open(os.devnull, os.O_WRONLY)
os.dup2(self._devnull_fd, 2)
return self
def __exit__(self, exc_type, exc, tb):
if not self.enabled:
return False
os.dup2(self._saved_fd, 2)
os.close(self._saved_fd)
os.close(self._devnull_fd)
return False
def _first_existing_key(mapping, keys):
for key in keys:
if key in mapping:
return mapping[key]
return None
def load_skeleton_file(path: Path) -> list[dict]:
"""Load skeleton samples from pickle, npy, or npz files.
Returns a list of dicts containing at least "skeleton" and optional "label", "fps",
"width", and "height".
"""
if path.suffix in {".pkl", ".pickle"}:
data = load_pickle(path)
elif path.suffix == ".npy":
data = np.load(path, allow_pickle=True)
elif path.suffix == ".npz":
data = dict(np.load(path, allow_pickle=True))
else:
raise ValueError(f"Unsupported skeleton file: {path}")
if isinstance(data, np.ndarray) and data.dtype == object and data.shape == ():
data = data.item()
if isinstance(data, dict):
skeletons = _first_existing_key(data, ["skeletons", "data", "x", "X", "features"])
labels_arr = _first_existing_key(data, ["labels", "y", "Y", "targets"])
fps_arr = _first_existing_key(data, ["fps", "frame_rates"])
widths = _first_existing_key(data, ["widths", "video_widths", "width"])
heights = _first_existing_key(data, ["heights", "video_heights", "height"])
if skeletons is None and "skeleton" in data:
skeletons = [data["skeleton"]]
if skeletons is None:
return []
samples = []
for idx, skeleton in enumerate(list(skeletons)):
sample = {"skeleton": skeleton, "source_file": str(path), "label": path.parent.name}
if labels_arr is not None and len(labels_arr) > idx:
sample["label"] = labels_arr[idx].item() if hasattr(labels_arr[idx], "item") else labels_arr[idx]
if fps_arr is not None:
sample["fps"] = fps_arr[idx] if np.ndim(fps_arr) else fps_arr
if widths is not None:
sample["width"] = widths[idx] if np.ndim(widths) else widths
if heights is not None:
sample["height"] = heights[idx] if np.ndim(heights) else heights
samples.append(sample)
return samples
if isinstance(data, (list, tuple)) and data and isinstance(data[0], dict):
return [dict({"label": path.parent.name, "source_file": str(path)}, **item) for item in data]
arr = np.asarray(data, dtype=object if isinstance(data, list) else None)
if arr.ndim == 2 and arr.shape[1] in (34, 51):
return [{"skeleton": arr, "source_file": str(path), "label": path.parent.name}]
if arr.ndim == 3 and arr.shape[1] == 17 and arr.shape[2] in (2, 3):
return [{"skeleton": arr, "source_file": str(path), "label": path.parent.name}]
if arr.ndim >= 3:
return [{"skeleton": skeleton, "source_file": str(path), "label": path.parent.name} for skeleton in arr]
return []
def discover_skeleton_samples(dataset_dir: Path) -> list[dict]:
samples = []
if not dataset_dir.exists():
return samples
for path in dataset_dir.rglob("*"):
if path.suffix.lower() in SKELETON_EXTENSIONS:
samples.extend(load_skeleton_file(path))
return samples
def iter_skeleton_files(dataset_dir: Path):
if not dataset_dir.exists():
return
for path in dataset_dir.rglob("*"):
if path.suffix.lower() in SKELETON_EXTENSIONS:
yield path
def iter_video_files(dataset_dir: Path):
if not dataset_dir.exists():
return
for path in dataset_dir.rglob("*"):
if path.suffix.lower() in VIDEO_EXTENSIONS:
yield path
def load_first_skeleton_sample(dataset_dir: Path) -> dict | None:
for path in iter_skeleton_files(dataset_dir):
samples = load_skeleton_file(path)
if samples:
return samples[0]
return None
def iter_skeleton_samples(dataset_dir: Path):
for path in iter_skeleton_files(dataset_dir):
for sample in load_skeleton_file(path):
yield sample
def load_skatingverse_labels(dataset_dir: Path) -> dict[str, int]:
"""Load video-stem -> class-index labels from SkatingVerse's train.txt.
Only the train split has public ground truth. train.txt/mapping.txt/answer.txt come from
the dataset's ModelScope repo (awei2003/1st_SkatingVerse_Dataset), not the Kaggle video-only
mirror `download_datasets.py` pulls from, so they're copied in separately alongside
train_videos/ and test_videos/. mapping.txt's class-index order matches labels.TAXONOMY
exactly, so these indices can be used as-is. test_videos has no labels here: answer.txt is
only the submission-order template for the original challenge (filenames, no classes) -- its
ground truth was withheld by the organizers.
"""
labels_path = dataset_dir / "train.txt"
mapping: dict[str, int] = {}
if not labels_path.exists():
return mapping
with labels_path.open("r", encoding="utf-8") as f:
for line in f:
parts = line.split()
if len(parts) != 2:
continue
stem, idx = parts
mapping[stem] = int(idx)
return mapping
def discover_video_samples(dataset_dir: Path, label_lookup: dict[str, int] | None = None) -> list[dict]:
if not dataset_dir.exists():
return []
samples = []
for path in iter_video_files(dataset_dir):
label = label_lookup.get(path.stem) if label_lookup is not None else path.parent.name
samples.append({"video_path": path, "label": label, "source_file": str(path)})
return samples
def discover_one_video_sample(dataset_dir: Path, label_lookup: dict[str, int] | None = None) -> dict | None:
for path in iter_video_files(dataset_dir):
label = label_lookup.get(path.stem) if label_lookup is not None else path.parent.name
return {"video_path": path, "label": label, "source_file": str(path)}
return None
def iter_video_samples(dataset_dir: Path, label_lookup: dict[str, int] | None = None):
for path in iter_video_files(dataset_dir):
label = label_lookup.get(path.stem) if label_lookup is not None else path.parent.name
yield {"video_path": path, "label": label, "source_file": str(path)}
def extract_skeleton_from_video(video_path: Path, config: dict) -> dict | None:
"""Extract a COCO-17 skeleton from one video with MediaPipe and cache it."""
cache_dir = Path(config["skeleton_cache_dir"])
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))
model_complexity = int(config.get("mediapipe_model_complexity", 1))
cache_suffix = f"mc{model_complexity}_s{frame_stride}_m{max_video_frames or 'all'}"
cache_path = cache_dir / f"{video_path.stem}_{cache_suffix}.pkl"
if cache_path.exists():
return load_pickle(cache_path)
suppress_logs = bool(config.get("suppress_mediapipe_logs", True))
with suppress_stderr(suppress_logs):
try:
import cv2
try:
from mediapipe.python.solutions import pose as mp_pose
except ImportError:
import mediapipe as mp
if not hasattr(mp, "solutions") or not hasattr(mp.solutions, "pose"):
raise RuntimeError(
"The installed mediapipe package does not expose the legacy Pose API "
"(mediapipe.solutions.pose). Install a MediaPipe version that includes "
"the solutions package, or use pre-extracted skeleton files for this run."
)
mp_pose = mp.solutions.pose
except ImportError as exc:
raise RuntimeError("Video extraction requires opencv-python and mediapipe") from exc
coco_indices = [0, 2, 5, 7, 8, 11, 12, 13, 14, 15, 16, 23, 24, 25, 26, 27, 28]
pose = mp_pose.Pose(
static_image_mode=False,
model_complexity=model_complexity,
smooth_landmarks=True,
min_detection_confidence=config["confidence_threshold"],
min_tracking_confidence=config["confidence_threshold"],
)
cap = cv2.VideoCapture(str(video_path))
if not cap.isOpened():
pose.close()
return None
fps = cap.get(cv2.CAP_PROP_FPS) or config["fps"]
width = cap.get(cv2.CAP_PROP_FRAME_WIDTH) or config["default_width"]
height = cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or config["default_height"]
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
frames = []
decoded_frames = 0
processed_frames = 0
show_frame_progress = bool(config.get("show_frame_progress", False))
frame_iter = tqdm(
total=total_frames if total_frames > 0 else None,
desc=f"Extracting {video_path.name}",
unit="frame",
leave=False,
disable=not show_frame_progress,
)
try:
while True:
ok, frame = cap.read()
if not ok:
break
decoded_frames += 1
if (decoded_frames - 1) % frame_stride != 0:
frame_iter.update(1)
continue
if max_video_frames is not None and processed_frames >= max_video_frames:
break
result = pose.process(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
if result.pose_landmarks:
joints = []
for idx in coco_indices:
landmark = result.pose_landmarks.landmark[idx]
joints.append([landmark.x, landmark.y, landmark.visibility])
frames.append(joints)
else:
frames.append(np.zeros((17, 3), dtype=np.float64))
processed_frames += 1
frame_iter.update(1)
finally:
frame_iter.close()
cap.release()
pose.close()
if not frames:
return None
sample = {
"skeleton": np.asarray(frames, dtype=np.float64),
"fps": float(fps),
"width": float(width),
"height": float(height),
"source_file": str(video_path),
"extract_every_n_frames": frame_stride,
"max_video_frames": max_video_frames,
"mediapipe_model_complexity": model_complexity,
"decoded_frames": decoded_frames,
"processed_frames": processed_frames,
}
save_pickle(sample, cache_path)
return sample
def map_label_for_source(source: str, raw_label) -> tuple[str, int] | None:
if raw_label is None:
return None
if source == "skatingverse":
try:
return labels.map_skatingverse_label(raw_label)
except ValueError:
return None
if source == "mmfs":
if isinstance(raw_label, (list, tuple)) and len(raw_label) >= 2:
return labels.map_mmfs_label(str(raw_label[0]), str(raw_label[1]))
return labels.map_mmfs_label(str(raw_label), "")
return labels.map_current_dataset_label(str(raw_label))
def process_sample(sample: dict, source: str, config: dict) -> tuple[np.ndarray, int, str] | None:
mapped = map_label_for_source(source, sample.get("label"))
if mapped is None:
return None
label_name, label_idx = mapped
width = float(sample.get("width", config["default_width"]))
height = float(sample.get("height", config["default_height"]))
fps = float(sample.get("fps", config["fps"]))
# The stored skeleton keeps only every extract_every_n_frames-th frame, so consecutive
# kept frames are `stride` original frames apart in real time. Velocities/accelerations use
# the *effective* frame rate (fps / stride); otherwise their magnitudes are inflated by the
# stride factor and, worse, differ in scale between strided video sources and un-strided
# pre-extracted skeleton sources (mmfs). (Review issue #3.)
stride = max(1, int(sample.get("extract_every_n_frames", 1) or 1))
effective_fps = fps / stride
skeleton = preprocessing.correct_aspect_ratio(sample["skeleton"], width, height)
skeleton = preprocessing.filter_low_confidence(skeleton, config["confidence_threshold"])
skeleton = preprocessing.interpolate_missing(skeleton)
skeleton = preprocessing.smooth_skeleton(
skeleton,
config["smoothing_window"],
config["smoothing_polyorder"],
)
skeleton = preprocessing.center_on_hips(skeleton)
skeleton = preprocessing.normalize_scale(skeleton)
features = preprocessing.build_feature_tensor(skeleton, effective_fps)
features = preprocessing.resample_to_length(features, config["target_sequence_length"])
return features.astype(np.float32), int(label_idx), label_name
def _array_stats(arr: np.ndarray) -> str:
values = np.asarray(arr)
finite = np.isfinite(values)
if not finite.any():
return f"shape={values.shape}, finite=0/{values.size}"
finite_values = values[finite]
return (
f"shape={values.shape}, finite={finite.sum()}/{values.size}, "
f"min={finite_values.min():.6f}, max={finite_values.max():.6f}, "
f"mean={finite_values.mean():.6f}, std={finite_values.std():.6f}"
)
def dry_run_sample(sample: dict, source: str, config: dict) -> bool:
"""Process one sample with detailed print logging for each important stage."""
print("\nDry run: one-sample pipeline walkthrough")
print("=" * 72)
print(f"Source dataset: {source}")
print(f"Source file: {sample.get('source_file', '<memory>')}")
print(f"Raw label: {sample.get('label', '<missing>')}")
mapped = map_label_for_source(source, sample.get("label"))
print("\n1. Label mapping")
print(" Function: map_label_for_source")
print(" Purpose: convert the source dataset label into the canonical 28-class taxonomy.")
if mapped is None:
label_name = "UNMAPPED"
label_idx = -1
print(" Result: unmapped label. A full run would discard this sample.")
print(" Dry-run behavior: continuing anyway so preprocessing and feature extraction can be inspected.")
else:
label_name, label_idx = mapped
print(f" Result: {sample.get('label')!r} -> {label_name!r} (index {label_idx})")
width = float(sample.get("width", config["default_width"]))
height = float(sample.get("height", config["default_height"]))
fps = float(sample.get("fps", config["fps"]))
print("\n2. Metadata defaults")
print(" Purpose: choose geometry and timing values used by normalization and derivatives.")
print(f" width={width}, height={height}, fps={fps}")
print(f" target_sequence_length={config['target_sequence_length']}")
skeleton = preprocessing.as_skeleton_array(sample["skeleton"])
print("\n3. Input skeleton coercion")
print(" Function: preprocessing.as_skeleton_array")
print(" Purpose: accept flattened COCO-17 arrays such as (F, 51) and convert to (F, 17, 3).")
print(f" Output: {_array_stats(skeleton)}")
print("\n4. Aspect ratio correction")
print(" Function: preprocessing.correct_aspect_ratio")
print(" Purpose: MediaPipe x and y are normalized by different image dimensions; multiply x by width/height before geometry.")
skeleton = preprocessing.correct_aspect_ratio(skeleton, width, height)
print(f" Applied x multiplier: {width / height:.6f}")
print(f" Output: {_array_stats(skeleton)}")
print("\n5. Confidence filtering")
print(" Function: preprocessing.filter_low_confidence")
print(" Purpose: mark low-confidence joint coordinates as NaN so interpolation treats them as missing observations.")
if skeleton.shape[2] >= 3:
low_conf = int(np.sum(skeleton[..., 2] < config["confidence_threshold"]))
total_joints = int(np.prod(skeleton[..., 2].shape))
print(f" Low-confidence joints before filtering: {low_conf}/{total_joints}")
skeleton = preprocessing.filter_low_confidence(skeleton, config["confidence_threshold"])
print(f" Output: {_array_stats(skeleton)}")
print("\n6. Missing-data interpolation")
print(" Function: preprocessing.interpolate_missing")
print(" Purpose: linearly fill NaN coordinate gaps across time for each joint and coordinate.")
nan_before = int(np.isnan(skeleton[..., :2]).sum())
skeleton = preprocessing.interpolate_missing(skeleton)
nan_after = int(np.isnan(skeleton[..., :2]).sum())
print(f" Coordinate NaNs before: {nan_before}; after: {nan_after}")
print(f" Output: {_array_stats(skeleton)}")
print("\n7. Skeleton smoothing")
print(" Function: preprocessing.smooth_skeleton")
print(" Purpose: apply Savitzky-Golay smoothing to x/y only, preserving sharper motion changes better than a moving average.")
print(f" window={config['smoothing_window']}, polyorder={config['smoothing_polyorder']}")
skeleton = preprocessing.smooth_skeleton(
skeleton,
config["smoothing_window"],
config["smoothing_polyorder"],
)
print(f" Output: {_array_stats(skeleton)}")
print("\n8. Hip-centering")
print(" Function: preprocessing.center_on_hips")
print(" Purpose: subtract the per-frame midpoint between left and right hips from every joint to remove camera translation.")
skeleton_xy = preprocessing.center_on_hips(skeleton)
hip_mid = 0.5 * (
skeleton_xy[:, preprocessing.LEFT_HIP, :] + skeleton_xy[:, preprocessing.RIGHT_HIP, :]
)
print(f" Hip midpoint after centering: mean_abs={np.mean(np.abs(hip_mid)):.8f}")
print(f" Output: {_array_stats(skeleton_xy)}")
print("\n9. Torso-length scale normalization")
print(" Function: preprocessing.normalize_scale")
print(" Purpose: divide each frame by torso length so coordinates are less sensitive to camera distance and resolution.")
skeleton_xy = preprocessing.normalize_scale(skeleton_xy)
print(f" Output: {_array_stats(skeleton_xy)}")
print("\n10. Joint-angle features")
print(" Function: preprocessing.compute_joint_angles")
print(" Purpose: compute 12 cosine angles for anatomical triplets such as elbow, shoulder, hip, knee, and ankle bends.")
angles = preprocessing.compute_joint_angles(skeleton_xy)
print(f" Output: {_array_stats(angles)}")
print("\n11. Bone-vector features")
print(" Function: preprocessing.compute_bone_vectors")
print(" Purpose: compute 10 parent-to-child displacement vectors, flattened to 20 values per frame.")
bone_vectors = preprocessing.compute_bone_vectors(skeleton_xy)
print(f" Output: {_array_stats(bone_vectors)}")
print("\n12. Velocity features")
print(" Function: preprocessing.compute_velocities")
print(" Purpose: compute first temporal derivatives with central differences; the final tensor keeps 8 key joints, 16 values.")
velocities = preprocessing.compute_velocities(skeleton_xy, fps)
key_velocities = velocities[:, preprocessing.KEY_JOINTS, :].reshape(velocities.shape[0], -1)
print(f" All-joint velocities: {_array_stats(velocities)}")
print(f" Key-joint velocities used: {_array_stats(key_velocities)}")
print("\n13. Angular-velocity features")
print(" Function: preprocessing.compute_angular_velocities")
print(" Purpose: compute first temporal derivatives of the 12 angle-cosine features.")
angular_velocities = preprocessing.compute_angular_velocities(angles, fps)
print(f" Output: {_array_stats(angular_velocities)}")
print("\n14. Feature tensor assembly")
print(" Function: preprocessing.build_feature_tensor")
print(" Purpose: concatenate 34 coords + 12 angles + 20 bone vectors + 16 keypoint velocities + 12 angular velocities.")
features = preprocessing.build_feature_tensor(skeleton_xy, fps)
print(f" Output: {_array_stats(features)}")
print(" Expected per-frame feature dimension: 94")
print("\n15. Sequence resampling")
print(" Function: preprocessing.resample_to_length")
print(" Purpose: linearly interpolate every feature dimension over normalized time to the fixed model length.")
resized = preprocessing.resample_to_length(features, config["target_sequence_length"])
print(f" Output: {_array_stats(resized)}")
print("\nDry run complete")
print("=" * 72)
print(f"Final sample label: {label_name} ({label_idx})")
print(f"Final model input shape for one sample: {resized.astype(np.float32).shape}")
print("No files were written.")
return True
def load_source_samples(source: str, config: dict) -> list[dict]:
dataset_dir = Path(config["datasets"][source])
samples = discover_skeleton_samples(dataset_dir)
if source == "skatingverse":
label_lookup = load_skatingverse_labels(dataset_dir)
video_samples = discover_video_samples(dataset_dir, label_lookup)
unlabeled = sum(1 for vs in video_samples if vs["label"] is None)
if unlabeled:
video_samples = [vs for vs in video_samples if vs["label"] is not None]
print(
f" skipping {unlabeled} skatingverse video(s) with no known label "
"(test_videos ground truth was never made public -- see train.txt vs answer.txt)"
)
max_videos = config.get("max_videos")
if max_videos is not None and len(video_samples) > int(max_videos):
rng = random.Random(int(config.get("random_seed", 0)))
rng.shuffle(video_samples) # representative subset, not filesystem-order-biased
video_samples = video_samples[: int(max_videos)]
print(f" limiting to {len(video_samples)} videos (max_videos={max_videos}, "
f"seed={config.get('random_seed', 0)})")
video_workers = max(1, int(config.get("video_num_workers", 1)))
pose_estimator = str(config.get("pose_estimator", "mediapipe")).lower()
if video_samples and pose_estimator not in ("yolo", "yolo_pose", "yolo_pose_gpu"):
print(
" video extraction settings: "
f"model_complexity={config.get('mediapipe_model_complexity')}, "
f"stride={config.get('extract_every_n_frames')}, "
f"max_processed_frames={config.get('max_video_frames')}, "
f"workers={config.get('video_num_workers')}"
)
if pose_estimator in ("yolo", "yolo_pose", "yolo_pose_gpu"):
if gpu_pose is None:
raise RuntimeError(
"pose_estimator='yolo' requires the GPU extractor (torch + ultralytics), "
"but gpu_pose failed to import."
)
print(
" using YOLO-pose GPU extractor: "
f"weights={config.get('yolo_weights')}, imgsz={config.get('yolo_imgsz')}, "
f"half={config.get('yolo_half')}, max_batch={config.get('yolo_max_batch')}, "
f"decode_workers={config.get('yolo_decode_workers')}"
)
for extracted in tqdm(
gpu_pose.extract_all_videos_yolo(video_samples, config),
total=len(video_samples),
desc=f"{source} video extraction (yolo)",
unit="video",
):
samples.append(extracted) # label already attached by extract_all_videos_yolo
elif video_workers == 1:
for video_sample in tqdm(video_samples, desc=f"{source} video extraction", unit="video"):
extracted = extract_skeleton_from_video(video_sample["video_path"], config)
if extracted is not None:
samples.append({**extracted, "label": video_sample["label"]})
else:
print(f" extracting {len(video_samples)} video skeleton(s) with {video_workers} worker(s)")
with ThreadPoolExecutor(max_workers=video_workers) as executor:
futures = {
executor.submit(extract_skeleton_from_video, video_sample["video_path"], config): video_sample
for video_sample in video_samples
}
for future in tqdm(
as_completed(futures),
total=len(futures),
desc=f"{source} video extraction",
unit="video",
):
video_sample = futures[future]
try:
extracted = future.result()
except Exception as exc:
print(f" skipped video {video_sample['source_file']}: {exc}")
continue
if extracted is not None:
samples.append({**extracted, "label": video_sample["label"]})
return samples
def load_one_source_sample(source: str, config: dict) -> dict | None:
"""Load a single sample for dry-run mode without scanning/extracting every video."""
dataset_dir = Path(config["datasets"][source])
skeleton_sample = load_first_skeleton_sample(dataset_dir)
if skeleton_sample is not None:
return skeleton_sample
if source == "skatingverse":
video_sample = discover_one_video_sample(dataset_dir, load_skatingverse_labels(dataset_dir))
if video_sample is not None:
extracted = extract_skeleton_from_video(video_sample["video_path"], config)
if extracted is not None:
return {**extracted, "label": video_sample["label"]}
return None
def iter_dry_run_samples(source: str, config: dict, limit: int):
"""Yield up to limit dry-run samples from exactly one source."""
if limit <= 0:
return
dataset_dir = Path(config["datasets"][source])
yielded = 0
for sample in iter_skeleton_samples(dataset_dir):
yield sample
yielded += 1
if yielded >= limit:
return
if source == "skatingverse":
for video_sample in iter_video_samples(dataset_dir, load_skatingverse_labels(dataset_dir)):
if video_sample["label"] is None:
continue
extracted = extract_skeleton_from_video(video_sample["video_path"], config)
if extracted is None:
continue
yield {**extracted, "label": video_sample["label"]}
yielded += 1
if yielded >= limit:
return
def stratified_split_indices(
labels_arr: np.ndarray, train_ratio: float, val_ratio: float, test_ratio: float, seed: int
) -> dict[str, np.ndarray]:
total = train_ratio + val_ratio + test_ratio
if not np.isclose(total, 1.0):
raise ValueError("train_ratio + val_ratio + test_ratio must equal 1.0")
rng = random.Random(seed)
by_class: dict[int, list[int]] = {}
for idx, label in enumerate(labels_arr):
by_class.setdefault(int(label), []).append(idx)
splits = {"train": [], "val": [], "test": []}
for indices in by_class.values():
rng.shuffle(indices)
n = len(indices)
# Guarantee val/test coverage for rare classes. Plain rounding sends every class with
# <=2 samples entirely to train (round(1*0.1)=0), so rare jump classes end up with no
# val/test examples and can never be evaluated. Instead: n<=1 -> train only (nothing to
# split); n==2 -> one train, one val; n>=3 -> at least one sample in each split.
# (Review issue #4.)
if n <= 1:
n_train, n_val, n_test = n, 0, 0
elif n == 2:
n_train, n_val, n_test = 1, 1, 0
else:
n_train = int(round(n * train_ratio))
n_train = min(max(n_train, 1), n - 2) # leave >=1 each for val and test
n_val = int(round(n * val_ratio))
n_val = min(max(n_val, 1), n - n_train - 1) # leave >=1 for test
n_test = n - n_train - n_val
splits["train"].extend(indices[:n_train])
splits["val"].extend(indices[n_train : n_train + n_val])
splits["test"].extend(indices[n_train + n_val :])
np_rng = np.random.default_rng(seed)
return {name: np_rng.permutation(values) for name, values in splits.items()}
def save_outputs(features: np.ndarray, y: np.ndarray, label_names: list[str], config: dict) -> None:
output_dir = Path(config["output_dir"])
output_dir.mkdir(parents=True, exist_ok=True)
split_indices = stratified_split_indices(
y,
config["train_ratio"],
config["val_ratio"],
config["test_ratio"],
config["random_seed"],
)
for split, indices in split_indices.items():
save_pickle(features[indices], output_dir / f"{split}_features.pkl")
save_pickle(y[indices], output_dir / f"{split}_labels.pkl")
metadata = {
"feature_shape": [config["target_sequence_length"], int(features.shape[-1])],
"num_classes": len(labels.TAXONOMY),
"taxonomy": labels.TAXONOMY,
"label_to_idx": labels.LABEL_TO_IDX,
"class_distribution": {labels.TAXONOMY[int(k)]: int(v) for k, v in Counter(y).items()},
"splits": {name: int(len(indices)) for name, indices in split_indices.items()},
"label_names": label_names,
"config": {k: str(v) if isinstance(v, Path) else v for k, v in config.items() if k != "datasets"},
"datasets": {k: str(v) for k, v in config["datasets"].items()},
}
with (output_dir / "metadata.json").open("w", encoding="utf-8") as f:
json.dump(metadata, f, indent=2)
def process_source_sample(job: tuple[dict, str, dict]) -> tuple[str, tuple[np.ndarray, int, str] | None, str | None]:
sample, source, config = job
try:
return sample.get("source_file", "<memory>"), process_sample(sample, source, config), None
except Exception as exc:
return sample.get("source_file", "<memory>"), None, str(exc)
def run_pipeline(config: dict = CONFIG, sources: Iterable[str] | None = None) -> bool:
pipeline_start = time.perf_counter()
selected_sources = list(sources or config["datasets"].keys())
processed_features = []
processed_labels = []
processed_label_names = []
skipped = Counter()
for source in selected_sources:
source_start = time.perf_counter()
print(f"Loading {source} from {config['datasets'][source]}")
source_samples = load_source_samples(source, config)
print(f" discovered {len(source_samples)} sample(s)")
workers = max(1, int(config.get("num_workers", 1)))
if workers == 1:
for sample in tqdm(source_samples, desc=f"{source} preprocessing", unit="sample"):
source_file, processed, error = process_source_sample((sample, source, config))
if error is not None:
skipped[f"{source}:error"] += 1
if skipped[f"{source}:error"] <= 5:
print(f" skipped {source_file}: {error}")
continue
if processed is None:
skipped[f"{source}:unmapped"] += 1
continue
features, label_idx, label_name = processed
processed_features.append(features)
processed_labels.append(label_idx)
processed_label_names.append(label_name)
else:
print(f" processing samples with {workers} worker(s)")
jobs = [(sample, source, config) for sample in source_samples]
with ProcessPoolExecutor(max_workers=workers) as executor:
futures = [executor.submit(process_source_sample, job) for job in jobs]
for future in tqdm(
as_completed(futures),
total=len(futures),
desc=f"{source} preprocessing",
unit="sample",
):
source_file, processed, error = future.result()
if error is not None:
skipped[f"{source}:error"] += 1
if skipped[f"{source}:error"] <= 5:
print(f" skipped {source_file}: {error}")
continue
if processed is None:
skipped[f"{source}:unmapped"] += 1
continue
features, label_idx, label_name = processed
processed_features.append(features)
processed_labels.append(label_idx)
processed_label_names.append(label_name)
elapsed = time.perf_counter() - source_start
print(f" finished {source} in {elapsed:.1f}s")
if not processed_features:
print("No labeled samples were processed. Check dataset paths and label files.")
if skipped:
print(f"Skipped summary: {dict(skipped)}")
return False
features = np.stack(processed_features, axis=0)
y = np.asarray(processed_labels, dtype=np.int64)
save_outputs(features, y, processed_label_names, config)
total_elapsed = time.perf_counter() - pipeline_start
print(f"Saved {features.shape[0]} samples to {Path(config['output_dir']).resolve()}")
print(f"Feature tensor shape: {features.shape}")
print(f"Class distribution: {dict(Counter(processed_label_names))}")
print(f"Total pipeline time: {total_elapsed:.1f}s")
if skipped:
print(f"Skipped summary: {dict(skipped)}")
return True
def run_dry_run(config: dict = CONFIG, sources: Iterable[str] | None = None) -> bool:
if sources is None:
source = DRY_RUN_SOURCE
else:
selected_sources = list(sources)
if not selected_sources:
print("Dry run needs exactly one source. Edit DRY_RUN_SOURCE or SOURCES.")
return False
source = selected_sources[0]
sample_limit = int(config.get("num_dry_run_samples", NUM_DRY_RUN_SAMPLES))
if sample_limit <= 0:
print("NUM_DRY_RUN_SAMPLES must be at least 1.")
return False
dry_start = time.perf_counter()
print(f"Searching for dry-run samples in {source}: {config['datasets'][source]}")
print(f"Dry run sample limit: {sample_limit}")
processed = 0
for sample in iter_dry_run_samples(source, config, sample_limit):
if "skeleton" not in sample:
continue
processed += 1
print(f" selected sample {processed}/{sample_limit}")
print(f" sample selection elapsed: {time.perf_counter() - dry_start:.1f}s")
ok = dry_run_sample(sample, source, config)
if not ok:
return False
if processed > 0:
print(f"Dry run processed {processed} sample(s) in {time.perf_counter() - dry_start:.1f}s.")
return True
print(" no usable sample found")
print("No sample with skeleton data was found for dry run. Check dataset paths or edit DRY_RUN_SOURCE.")
return False
def main() -> int:
config = dict(CONFIG)
config["datasets"] = dict(CONFIG["datasets"])
config["output_dir"] = Path(OUTPUT_DIR)
config["skeleton_cache_dir"] = Path(OUTPUT_DIR) / "skeleton_cache"
config["target_sequence_length"] = TARGET_SEQUENCE_LENGTH
config["num_workers"] = NUM_WORKERS
config["video_num_workers"] = VIDEO_NUM_WORKERS
config["num_dry_run_samples"] = NUM_DRY_RUN_SAMPLES
config["mediapipe_model_complexity"] = MEDIAPIPE_MODEL_COMPLEXITY
config["extract_every_n_frames"] = EXTRACT_EVERY_N_FRAMES
config["max_video_frames"] = MAX_VIDEO_FRAMES
config["pose_estimator"] = POSE_ESTIMATOR
config["yolo_weights"] = YOLO_WEIGHTS
if DRY_RUN:
return 0 if run_dry_run(config, SOURCES) else 1
return 0 if run_pipeline(config, SOURCES) else 1
if __name__ == "__main__":
raise SystemExit(main())

Xet Storage Details

Size:
40.5 kB
·
Xet hash:
1907a4a90bbd388b22a96b2e2a51d14565bc1b0b5aedc915bba72dd1ef50f735

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