File size: 10,028 Bytes
e2d95ed | 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 231 | #!/usr/bin/env python3
"""
PHASE 4 -- reusable preprocessing pipeline.
VIDEO / KEYPOINTS
-> landmark packing (75 landmarks x 3 coords)
-> geometry units fix (measured; see backend/preprocessing/geometry.py)
-> per-frame normalisation (origin = mid-shoulder, scale = shoulder width)
-> fixed-length sequence (SEQUENCE_LENGTH frames, linear resample)
-> optional landmark mask (config.MASK_ENABLED)
-> .npz tensors + class mapping
This module is the *only* place that turns dataset rows into model input, so the
three models provably receive identical data. It is also used by
``training/robustness.py`` and by the live pipeline (through
``backend.preprocessing.landmarks`` / ``backend.preprocessing.sequence``), which is why the
geometry and normalisation code lives in ``backend/preprocessing/`` and not here.
Outputs (datasets/processed/)
----------------------------
sequences_<protocol>.npz X_train/X_val/X_test + y_* + clip metadata
preprocess_summary.json sizes, timings, skipped clips, label mapping hash
Usage
-----
python training/preprocess.py # primary protocol
python training/preprocess.py --protocol official # comparability split
python training/preprocess.py --augment-copies 2 # extra train copies
python training/preprocess.py --limit 50 # smoke test
"""
from __future__ import annotations
import argparse
import hashlib
import json
import logging
import sys
import time
import traceback
from pathlib import Path
from typing import Dict, List, Optional, Sequence, Tuple
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import config # noqa: E402
from backend.preprocessing import include50, landmarks as lm, sequence as seq_ops # noqa: E402
LOG = logging.getLogger("preprocess")
# --------------------------------------------------------------------------
# Core
# --------------------------------------------------------------------------
def clip_to_sequence(coords: np.ndarray, mask: np.ndarray,
length: Optional[int] = None) -> Optional[np.ndarray]:
"""(T,75,3) + (T,75) -> (length, 225) normalised feature sequence.
Returns ``None`` when the clip carries fewer than ``config.MIN_VALID_FRAMES``
frames that contain any landmark at all (such clips are unusable and are
counted in the summary rather than silently zero-padded).
"""
length = int(length or config.SEQUENCE_LENGTH)
if coords.ndim != 3 or coords.shape[0] == 0:
return None
usable = (np.asarray(mask) > 0).any(axis=1)
if int(usable.sum()) < config.MIN_VALID_FRAMES:
return None
feats, _ = lm.normalise_sequence(coords.astype(np.float32), np.asarray(mask, dtype=np.float32))
feats = seq_ops.resample_sequence(feats, length, method=config.SEQ_RESAMPLE_METHOD)
if feats.shape != (length, config.MODEL_INPUT_DIM if config.MASK_ENABLED else config.FEATURE_DIM):
return None
return feats.astype(np.float32)
def build_arrays(index, split: str, augment_copies: int = 0, seed: int = config.RANDOM_SEED,
limit: Optional[int] = None) -> Tuple[np.ndarray, np.ndarray, List[Dict], List[Dict]]:
"""Build (X, y) arrays for one split plus per-clip metadata.
Augmentation is applied *offline* to the training split only, with a seeded
RNG, so that every model sees byte-identical inputs. Validation and test are
never augmented.
"""
rows = index[index["split"] == split].to_dict("records")
if limit:
rows = rows[:limit]
classes = {c: i for i, c in enumerate(include50.load_vocabulary())}
X: List[np.ndarray] = []
y: List[int] = []
meta: List[Dict] = []
skipped: List[Dict] = []
rng = np.random.default_rng(seed + {"train": 0, "val": 1, "test": 2}.get(split, 3))
for row in rows:
try:
coords, mask, info = include50.read_keypoints_for(row["FilePath"])
except Exception as exc:
skipped.append({"filepath": row["FilePath"], "reason": f"{type(exc).__name__}: {exc}"})
continue
seq = clip_to_sequence(coords, mask)
if seq is None:
skipped.append({"filepath": row["FilePath"], "reason": "too few usable frames"})
continue
label = classes[row["label"]]
copies = [seq] + [seq_ops.augment(seq, rng) for _ in range(augment_copies)] if split == "train" else [seq]
for k, s in enumerate(copies):
X.append(s)
y.append(label)
meta.append({
"filepath": row["FilePath"], "label": row["label"], "class_index": label,
"split": split, "copy": k, "frames_source": int(coords.shape[0]),
"x_axis_scale": float(info.get("x_axis_scale", 1.0)),
})
if not X:
return (np.zeros((0, config.SEQUENCE_LENGTH, config.FEATURE_DIM), dtype=np.float32),
np.zeros((0,), dtype=np.int64), meta, skipped)
return np.stack(X).astype(np.float32), np.array(y, dtype=np.int64), meta, skipped
def label_mapping() -> Dict[str, object]:
vocab = include50.load_vocabulary()
payload = {"classes": vocab, "index": {c: i for i, c in enumerate(vocab)},
"num_classes": len(vocab)}
payload["sha1"] = hashlib.sha1("|".join(vocab).encode()).hexdigest()
return payload
def run(protocol: str, augment_copies: int, seed: int, limit: Optional[int],
out_dir: Optional[Path] = None) -> Path:
out_dir = Path(out_dir or config.PROCESSED_DIR)
out_dir.mkdir(parents=True, exist_ok=True)
index, split_summary = include50.build_index(protocol=protocol)
mapping = label_mapping()
LOG.info("protocol=%s train=%d val=%d test=%d classes=%d",
protocol, split_summary.train, split_summary.val, split_summary.test, mapping["num_classes"])
t0 = time.time()
arrays: Dict[str, np.ndarray] = {}
all_meta: Dict[str, List[Dict]] = {}
all_skipped: Dict[str, List[Dict]] = {}
for split, copies in (("train", augment_copies), ("val", 0), ("test", 0)):
X, y, meta, skipped = build_arrays(index, split, augment_copies=copies, seed=seed, limit=limit)
arrays[f"X_{split}"] = X
arrays[f"y_{split}"] = y
all_meta[split] = meta
all_skipped[split] = skipped
LOG.info(" %-5s X=%s y=%s (skipped %d)", split, X.shape, y.shape, len(skipped))
npz_path = out_dir / f"sequences_{protocol}.npz"
np.savez_compressed(
npz_path,
**arrays,
classes=np.array(mapping["classes"], dtype=object),
meta_json=np.array(json.dumps(all_meta, default=str)),
skipped_json=np.array(json.dumps(all_skipped, default=str)),
)
summary = {
"protocol": protocol,
"sequence_length": config.SEQUENCE_LENGTH,
"feature_dim": config.FEATURE_DIM,
"model_input_dim": config.MODEL_INPUT_DIM,
"mask_enabled": config.MASK_ENABLED,
"augment_copies": augment_copies,
"seed": seed,
"label_sha1": mapping["sha1"],
"num_classes": mapping["num_classes"],
"shapes": {k: list(v.shape) for k, v in arrays.items()},
"skipped": {k: len(v) for k, v in all_skipped.items()},
"skipped_detail": all_skipped,
"seconds": round(time.time() - t0, 2),
"normalisation": {
"origin": "mid-shoulder (fallback: mid-hip, then landmark centroid)",
"scale": "shoulder width (fallback: hip width, then 1.0)",
"x_axis_scale": config.X_AXIS_SCALE,
"axis_correction_applied": config.APPLY_KEYPOINT_AXIS_CORRECTION,
},
"sequence": {"resample": config.SEQ_RESAMPLE_METHOD, "pad_mode": config.SEQ_PAD_MODE,
"min_valid_frames": config.MIN_VALID_FRAMES},
"augmentation": dict(config.AUGMENTATION),
"split_summary": split_summary.as_dict(),
}
(out_dir / f"preprocess_summary_{protocol}.json").write_text(json.dumps(summary, indent=2))
(out_dir / "label_mapping.json").write_text(json.dumps(mapping, indent=2))
LOG.info("wrote %s (%.1f MB)", npz_path, npz_path.stat().st_size / 1e6)
return npz_path
def load_processed(protocol: Optional[str] = None, path: Optional[Path] = None) -> Dict[str, np.ndarray]:
protocol = protocol or config.SPLIT_PROTOCOL
path = Path(path or (Path(config.PROCESSED_DIR) / f"sequences_{protocol}.npz"))
if not path.exists():
raise FileNotFoundError(f"{path} not found - run: python training/preprocess.py --protocol {protocol}")
data = np.load(path, allow_pickle=True)
return {k: data[k] for k in data.files}
def main(argv: Optional[Sequence[str]] = None) -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--protocol", default=config.SPLIT_PROTOCOL, choices=["session_disjoint", "official", "hf"])
ap.add_argument("--augment-copies", type=int, default=_def_copies() if False else None,
help="extra augmented copies of each training clip (default from config)")
ap.add_argument("--seed", type=int, default=config.RANDOM_SEED)
ap.add_argument("--limit", type=int, default=None, help="limit clips per split (smoke test)")
ap.add_argument("--out-dir", default=None)
args = ap.parse_args(argv)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
copies = args.augment_copies
if copies is None:
copies = int(config.AUGMENTATION.get("augment_copies", 1)) if config.AUGMENTATION.get("enabled", True) else 0
try:
run(args.protocol, copies, args.seed, args.limit, Path(args.out_dir) if args.out_dir else None)
except Exception:
traceback.print_exc()
return 1
return 0
def _def_copies(): # pragma: no cover - helper for argparse default
return None
if __name__ == "__main__":
raise SystemExit(main())
|