Skip to content

Preprocessing & Normalization

preprocessing

Batch preprocessing of BVH directories into ML-ready datasets.

Converts a directory of BVH files into on-disk arrays (npz or hdf5) with skeleton metadata and normalization statistics.

extract_repr(bvh: Bvh, representation: str) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]]

Extract (root_pos, joint_rot) for the given representation.

Thin dispatcher over pybvh's to_* methods; exposed publicly so the PyTorch datasets can reuse the same mapping without reaching into a private symbol.

Parameters:

Name Type Description Default
bvh Bvh
required
representation ('euler', 'quat', '6d', 'axisangle')

"euler" returns bvh.joint_angles — radians, matching pybvh. "rotmat" is not part of the extraction surface (use :func:~pybvh_ml.convert_rotations to derive it from any extracted representation).

"euler"

Returns:

Name Type Description
root_pos (ndarray, shape(F, 3))
joint_rot (ndarray, shape(F, J, C_repr))

compute_normalization_stats(bvh_list: list[Bvh], representation: str = 'euler', include_root_pos: bool = True, center_root: bool = False) -> dict[str, npt.NDArray]

Compute per-channel mean and std across a dataset of BVH objects.

Extracts every clip in the given representation, concatenates all frames, then computes mean and standard deviation per feature channel. Compatible with the Mean.npy / Std.npy convention used by HumanML3D and MDM. The channel layout matches :func:pybvh_ml.pack_to_flat and the arrays saved by :func:preprocess_directory: [root_pos (3), joint_data flattened over (J, C)] per frame.

Parameters:

Name Type Description Default
bvh_list list of Bvh

Dataset of BVH objects. Clips must share the same skeleton graph (joint names + parent indices); bone-length variation across actors is accepted, matching the loose compatibility convention of :func:preprocess_directory. For order-sensitive representations ('euler' / 'axisangle'), per-joint Euler orders must also match.

required
representation str

Rotation representation: 'euler' (default), 'quat', '6d', or 'axisangle'.

'euler'
include_root_pos bool

If True (default), include root position in the features.

True
center_root bool

If True, subtract each clip's first-frame root position before computing the stats — reproducing exactly the mean / std that :func:preprocess_directory stores under its default center_root=True (whose arrays are centered before the stats pass). Default False computes stats on raw root positions.

False

Returns:

Type Description
dict

{"mean": ndarray (D,), "std": ndarray (D,), "constant_channels": ndarray of bool (D,)}.

constant_channels[i] is True when the raw standard deviation for channel i was below 1e-8 and the guard replaced it with 1.0. Normalized values on these channels are identically zero rather than ~N(0, 1) — use this mask to exclude them from per-channel diagnostics.

Raises:

Type Description
ValueError

If bvh_list is empty, skeletons are incompatible, or the representation is unknown.

Notes

Save/load stats with np.savez("stats.npz", **stats) and dict(np.load("stats.npz")). Bool arrays round-trip cleanly through .npz.

normalize_array(data: npt.NDArray[np.float64], stats: dict[str, npt.NDArray[np.float64]]) -> npt.NDArray[np.float64]

Apply z-score normalization: (data - mean) / std.

Parameters:

Name Type Description Default
data ndarray

Data to normalize. Last dimension must match stats["mean"].

required
stats dict

{"mean": ndarray (D,), "std": ndarray (D,)} from :func:compute_normalization_stats.

required

Returns:

Type Description
ndarray

Normalized data, same shape as input.

denormalize_array(data: npt.NDArray[np.float64], stats: dict[str, npt.NDArray[np.float64]]) -> npt.NDArray[np.float64]

Reverse z-score normalization: data * std + mean.

Parameters:

Name Type Description Default
data ndarray

Normalized data to denormalize.

required
stats dict

{"mean": ndarray (D,), "std": ndarray (D,)} from :func:compute_normalization_stats.

required

Returns:

Type Description
ndarray

Denormalized data, same shape as input.

preprocess_directory(bvh_dir: str | Path, output_path: str | Path, representation: str = '6d', center_root: bool = True, include_positions: bool = False, position_space: str = 'joint', position_centering: str = 'world', include_quaternions: bool = False, include_velocities: bool = False, include_foot_contacts: bool = False, foot_joints: list[str] | None = None, label_fn: Callable[[str], int] | None = None, filter_fn: Callable[[str], bool] | None = None, file_pattern: str = '*.bvh', skip_errors: bool = False, world_up: str = 'auto', lr_mapping: dict[str, str] | None = None, harmonize: bool = False, retarget: bool = False, target_world_up: str | None = None, target_rest_forward: str | None = None, target_rest_up: str | None = None, target_euler_order: str | None = None, target_fps: float | None = None, parallel: bool = False, max_workers: int | None = None) -> dict

Convert a directory of BVH files to an on-disk dataset.

Parameters:

Name Type Description Default
bvh_dir path - like

Directory containing BVH files.

required
output_path path - like

Output file path. Extension determines format: .npz (always available) or .hdf5 (requires h5py).

required
representation str

Rotation representation for joint data.

'6d'
center_root bool

If True, subtract first frame's root position per clip. The flag is recorded in the saved dataset's metadata and surfaced by :func:load_preprocessed, so downstream packing knows the arrays are already centered — pass center_root=False to the pack_to_* functions when repacking such clips. (Re-centering a whole already-centered clip is a harmless no-op; the real hazard is windowed sub-clips, where re-centering zeroes the window's first frame and destroys the clip-relative trajectory.)

With include_positions=True and position_centering="world" the same shift is applied to every position vertex, keeping the two streams in one frame; with "skeleton" the positions are already root-relative and are left alone; with "first" the combination is rejected (see position_centering).

True
include_positions bool

If True, also store per-vertex 3-D positions — the stream skeleton action-recognition models consume. Derived from :meth:pybvh.Bvh.joint_positions / :meth:~pybvh.Bvh.node_positions, both backed by pybvh's cached world-frame FK, so requesting them alongside a rotation representation costs one array derivation rather than a second kinematics pass.

Unlike include_velocities and include_foot_contacts, these are not static features: augmentation transforms them with the rest of the clip, and :func:~pybvh_ml.add_joint_rotation_noise re-derives them by forward kinematics.

False
position_space ('joint', 'node')

Which index space to store. "joint" (default) writes joint_pos, index-aligned with joint_rot and with skeleton_info["edges"]; "node" writes node_pos, which includes end sites (fingertips, toe tips, head top) and pairs with node_edges / node_lr_pairs. One flag rather than two include_* booleans, because the two spaces are alternatives — node_pos already contains joint_pos.

Recorded in skeleton_info, not in the dataset metadata: it is a topology fact — which index space, and therefore which V, which edge list, which L/R pair list — sitting beside num_joints / num_nodes / edges, exactly as foot_joints does.

"joint"
position_centering ('world', 'skeleton', 'first')

Which frame the stored positions are in, passed to pybvh's centered= and recorded in the dataset metadata next to center_root, whose analogue it is: a statement about the values rather than about the topology.

"world" (default) keeps positions in the same frame as root_pos, so :func:~pybvh_ml.rotate_vertical acts identically on both and a joint position already contains the root trajectory. "skeleton" puts the root at the origin in every frame — the form most NTU-style pipelines feed a model, with the trajectory then carried only by root_pos. "first" is pybvh's ground-plane centering. The three coincide only for a clip whose root never moves.

Recording it is mandatory for anything this library writes: a position array whose frame convention we failed to record is exactly the case a caller cannot recover from. (A hand-assembled :class:~pybvh_ml.MotionArrays may honestly say None; a dataset we wrote may not.)

"world"
include_quaternions bool

If True, also store pre-computed quaternion arrays per clip (useful for runtime speed perturbation / dropout). When representation="quat" the main joint data already is the quaternion array, so no duplicate is stored on disk — :func:load_preprocessed aliases clip["joint_quats"] to clip["joint_rot"] in that case.

False
include_velocities bool

If True, compute per-joint linear velocities via :meth:pybvh.Bvh.joint_velocities (central stencil, edge padding — shape (F, J, 3) aligned with joint_rot / joint_angles, no end sites) and store them per clip. Static features: not refreshed after augmentation, so use for evaluation / targets, not as augmentation-invariant training inputs.

False
include_foot_contacts bool

If True, compute binary foot-contact labels via :meth:pybvh.Bvh.foot_contacts (default method="combined") and store them per clip along with the foot joint names in skeleton_info["foot_joints"]. Static features, same caveat as include_velocities.

False
foot_joints list of str

Explicit foot joint names for contact detection. None (default) auto-detects from the first clip. Required for footless or nonstandard rigs, where auto-detection finds nothing and pybvh's detector raises. Only used with include_foot_contacts=True.

None
label_fn callable

label_fn(filename_stem) -> int. If provided, stores per-clip integer labels.

None
filter_fn callable

filter_fn(filename_stem) -> bool. If provided, only files for which it returns True are loaded and processed. Applied before loading — skipped files are never parsed.

None
file_pattern str

Glob pattern for BVH files (default "*.bvh").

'*.bvh'
skip_errors bool

If True, files that fail to load emit a UserWarning and are skipped rather than propagating the exception.

False
world_up str

Forwarded to :func:pybvh.read_bvh_file. "auto" (default) auto-detects per file; pass "+y" etc. to override.

'auto'
lr_mapping dict or None

Forwarded to :func:pybvh.read_bvh_file. Explicit left/right joint pair mapping, useful for uniform dataset conventions.

None
harmonize bool

If True, run :func:pybvh.harmonize after loading to unify clips along every axis the dataset disagrees on. Targets are resolved as: explicit target_* kwarg wins; otherwise the majority value from the uniformity audit fills in. For representation in {"euler", "axisangle"}, an Euler-order target is also resolved (the most common per-joint order across all joints of all clips); rotation-invariant representations skip this stage since channel layout is order-agnostic.

Harmonization is pure reorientation/resampling: each actor's bone lengths are preserved (bone-length variation across actors is intrinsic data, see the skeleton-compatibility notes). Pass retarget=True to additionally unify bone offsets. Hierarchy mismatches raise :class:ValueError either way — from the post-harmonize compatibility check by default, or from the harmonize report under retarget=True — rather than silently shipping a smaller dataset. The resolved targets, the retarget choice, and per-stage modification counts land in the returned uniformity dict under uniformity["harmonized_to"] and are persisted in the saved dataset (uniformity_json).

Default False keeps the explicit target_* kwargs as independent uniformization stages (current behavior).

False
retarget bool

Only honored with harmonize=True. If True, pin the first clip (alphabetically first stem) as the harmonize reference: every other clip's bone offsets are retargeted to it, so the whole dataset shares one skeleton geometry — useful when the model should not need to be scale-invariant (e.g. fixed-topology GCNs). Bone offsets only — root translations keep each clip's original scale (pybvh's retarget semantics). Default False preserves each actor's own bone proportions.

False
target_world_up str or None

Signed-axis string ("+y", "-z", ...). When harmonize=False (default): reorient every clip via :meth:pybvh.Bvh.reorient_world_up. When harmonize=True: used as the explicit world-up target for :func:pybvh.harmonize, overriding the audit-majority value. None (default) defers to the dataset majority under harmonize=True, or leaves clips untouched otherwise.

None
target_rest_forward str or None

Same dual semantics as target_world_up for the rest-pose forward direction. Must not be parallel to the (post- target_world_up) up axis.

None
target_rest_up str or None

Same dual semantics as target_world_up for the rest-pose up axis. Typically only needed for the rare single-file case where a file's rest-pose up disagrees with its animation up.

None
target_euler_order str or None

Canonical Euler order ("XYZ", "ZYX", ...) to unify joint angles to. Only honored when harmonize=True and the representation is order-sensitive ("euler" / "axisangle"); silently ignored otherwise. None (default) under harmonize=True picks the majority Euler order across clips.

None
target_fps float or None

Frame rate in Hz to resample every clip to, applied before extraction via :meth:pybvh.Bvh.resample (quaternion SLERP for rotations, linear for root position). Resampling first is what makes it correct: joint_rot, include_velocities and include_foot_contacts are all derived from the resampled clip, so they describe the motion at the target rate. Decimating the saved arrays afterwards cannot reproduce this — velocities in particular are finite differences whose stencil baseline is set by the original frame_time.

Same dual semantics as target_world_up: with harmonize=False (default) each clip is resampled directly; with harmonize=True it becomes the explicit frame-rate target for :func:pybvh.harmonize, overriding the audit majority. None (default) defers to the dataset majority under harmonize=True — a mixed-rate dataset is unified to its most common rate — and leaves clips untouched otherwise.

None
parallel bool

If True, load BVH files using a :class:ThreadPoolExecutor. Speeds up large directories; per-file I/O is the bottleneck.

False
max_workers int

Thread count when parallel=True. None defers to :class:ThreadPoolExecutor's default.

None
Notes

Uniformity warnings. After loading, this function inspects every clip's frame rate, animation-derived world_up, rest-pose forward direction, and rest-pose up axis. It emits one aggregated :class:UserWarning per category when files disagree, plus a separate aggregated warning when any file's rest-pose up axis disagrees with its own animation-derived world_up (pybvh's per-file rest/animation-disagreement warning is suppressed during load in favor of this one batch-level message). Warnings include the distribution of values, the first three example filenames per minority value, and the exact kwarg that would fix it (target_fps, target_world_up, target_rest_forward, target_rest_up). When the corresponding target_* kwarg is explicitly set, that category's check is skipped (the target value becomes the post-reorient ground truth).

Returns:

Type Description
dict

Summary with keys: num_clips, representation, filenames, skeleton_info, center_root, position_centering, uniformity.

This is a report on the run, not the dataset. It carries the decisions that shaped the file plus the heterogeneity audit, and deliberately none of the arrays — mean / std / position_stats and the clips themselves come from :func:load_preprocessed, which is the one place they should be read from. center_root and position_centering are here because they are decisions rather than data: without them the summary described the topology of a positions dataset (skeleton_info["position_space"]) while silently omitting which frame its values were in.

uniformity is a dict of the form::

{
  "fps":          {value: [stems, ...]},
  "world_up":     {value: [stems, ...]},
  "rest_forward": {value: [stems, ...]},
  "rest_up":      {value: [stems, ...]},
  "rest_anim_mismatch": [stems, ...],
  "harmonized_to":   {...},  # only when harmonize=True
  "applied_targets": {...},  # only when harmonize=False
}

The four distributions capture the pre-transform state of the dataset (useful for CI gates that want to fail on heterogeneity); what was then done to it is the other two keys, exactly one of which can be present. rest_anim_mismatch lists files whose rest-pose up axis disagrees with their animation-derived world_up — the condition target_rest_up repairs. Rigs with an unmeasurable rest pose are filed under rest_up key "unknown" and excluded from rest_anim_mismatch.

When harmonize=True, harmonized_to carries the resolved target signature (target_fps, target_world_up, target_rest_up, target_rest_forward, target_euler_order — only those that were resolved), the retarget choice and pinned reference, stage_counts (per-stage count of clips modified, from pybvh's HarmonizeReport.applied_stages), and the serialized report itself (JSON-native dict from dataclasses.asdict).

Otherwise applied_targets records the target_* kwargs this call applied directly, under the same names — absent when none was passed. target_euler_order never appears: it is honored only under harmonize=True, so recording it here would claim a transform that did not run.

load_preprocessed(path: str | Path) -> dict

Load a preprocessed dataset from disk.

Parameters:

Name Type Description Default
path path - like

Path to .npz or .hdf5 file.

required

Returns:

Type Description
dict

Keys: clips (list of dicts with root_pos, joint_rot (named joint_data in datasets written before pybvh-ml 0.5.0; both keys load, the new name is what you read), optionally joint_quats / velocities / foot_contacts / joint_pos / node_pos), labels, mean, std, skeleton_info, representation, filenames, center_root, uniformity, position_centering, position_stats. Also includes constant_channels when the file was written by pybvh-ml >= 0.3 (absent for older files).

position_centering is the frame the stored positions are in (None when the dataset carries none, and for every file written before pybvh-ml 0.6.0). It has to be threaded into every :class:~pybvh_ml.MotionArrays built from these clips — the steps that depend on it only ever see the container, not this dict. :meth:~pybvh_ml.torch.MotionDataset.from_preprocessed does that for you.

position_stats is the positions' own {"mean", "std", "constant_channels"} block over the (F, V*3) flattening, or None. It is deliberately separate from mean / std, whose D = 3 + J*C layout is a public contract. Ignoring it is a legitimate choice: ST-GCN pipelines more commonly root-center or normalize by bone length than z-score raw coordinates.

uniformity is the axis-uniformity audit recorded at preprocessing time: the pre-transform frame-rate / world-up / rest-forward / rest-up distributions, plus a record of what was applied to them — harmonized_to (resolved targets, retarget choice, and the full harmonize report) when the dataset was built with harmonize=True, or applied_targets (the target_* kwargs applied directly) when it was not. Files written before pybvh-ml 0.5.0 load it as None.

center_root is the flag the dataset was preprocessed with (files written before pybvh-ml 0.5.0 don't record it, so it loads as None). When it is True, the stored root_pos arrays are already centered — repack them with pack_to_*(..., center_root=False).

skeleton_info always carries every key :func:~pybvh_ml.skeleton.get_skeleton_info documents, whatever version wrote the file: keys an older dataset never recorded (world_up / rest_forward / rest_up before 0.5.0, the node-space block and fk_topology before 0.6.0) read back as None rather than being absent, so consumers can index them directly. position_space is the exception, and it follows the foot_joints precedent: it is present only when the dataset stores positions, so "not requested" stays distinguishable from "requested and empty".