Augmentation Pipeline¶
pipeline
¶
Composable augmentation pipeline for ML training.
Designed to be called inside a PyTorch Dataset's __getitem__
or any data loading loop.
AugmentationStep
¶
Bases: NamedTuple
One configured pipeline step.
A :class:tuple subclass, so a step still unpacks as
(fn, prob, kwargs) and indexes as step[2] — the named
fields are what reads at the call site when introspecting a
pipeline (pipeline.augmentations[0].kwargs["angle"]).
AugmentationPipeline(augmentations: list[tuple[Callable, float, dict]], cache_quats: bool = True, *, representation: str | None = None, euler_orders: list[str] | None = None)
¶
Composable sequence of augmentations with per-step probabilities.
Each augmentation is a tuple of (fn, probability, kwargs) where
fn has signature fn(arrays, **kwargs) -> MotionArrays —
:class:~pybvh_ml.MotionArrays in, a new one out. Custom steps
read arrays.root_pos / arrays.joint_rot and typically return
arrays.replace(joint_rot=...).
Kwargs values may be callables of the form lambda rng: value,
which are resolved at each invocation using the pipeline's rng.
This enables random parameter sampling per sample (e.g., random
rotation angles). Pass return_params=True to get back what each
call actually drew — which steps fired and with which sampled
values (see __call__).
The pipeline automatically forwards its rng to augmentation
functions that accept an rng parameter (detected via
signature inspection). This ensures reproducibility without
requiring "rng": lambda rng: rng in kwargs.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
augmentations
|
list of (callable, float, dict)
|
Each entry is |
required |
representation
|
str
|
Pipeline-level default for the Built-in steps must agree on the resulting representation: each step's output is the next one's input, so two built-ins declaring different tokens with nothing between them to convert raises at construction. A custom step in between lifts the restriction, since it may legitimately convert. |
None
|
euler_orders
|
list of str
|
Pipeline-level default for the |
None
|
cache_quats
|
bool
|
Share a quaternion cache across pybvh-ml's built-in
augmentations. Functions like :func: |
True
|
Notes
Composition order matters. Steps run left-to-right on the output of the previous step. The mathematically interesting interactions to be aware of:
- Mirror vs. vertical rotation.
mirror_*reflects the lateral axis;rotate_*_verticalrotates around the up axis. The two commute up to a sign flip on the rotation angle (mirror ∘ rotate(θ) == rotate(-θ) ∘ mirror). Either order is correct, but if you stack them with probabilities and expect the resulting distribution to be symmetric, keep that sign flip in mind. - Speed perturbation changes F.
speed_perturbation_arraysresamples time, so downstream steps receive arrays with a differentF. Frame-count-sensitive steps (e.g.dropout_arrayswith a fixed keep-mask) should run before speed perturbation or be written to tolerate variable lengths. - Noise + re-augmentation.
add_joint_rotation_noiseperturbs rotations in place (via quaternion space); following it with a second rotation-space step is fine, but a subsequent deterministic check (e.g. equality to the input) will naturally fail. - A re-derivation discards the position stream's own history.
On a sample carrying positions,
add_joint_rotation_noisereplaces them with forward kinematics of the noised rotations rather than transforming the incoming ones (see :func:~pybvh_ml.handles_streams). So on a rig with asymmetric rest offsets,[mirror, add_joint_rotation_noise]ends with FK of locally-mirrored rotations — throwing away the world-exact reflection the position stream held — while[add_joint_rotation_noise, mirror]keeps it. Both are defensible and neither is a bug, but the two do not produce the same positions.
Every step must handle every stream the sample carries. The
check runs once at __call__ entry, for every configured step,
before any of them fires — a p=0.1 step with the wrong stream
support or a missing fk_topology would otherwise raise on one
sample in ten. A custom step that declares nothing is assumed to
handle {"root_pos", "joint_rot"}; decorate it with
:func:~pybvh_ml.handles_streams once it transforms positions too.
Examples:
>>> from pybvh_ml.augmentation import rotate_vertical, mirror
>>> pipeline = AugmentationPipeline([
... (rotate_vertical, 1.0, {
... "angle": lambda rng: rng.uniform(-np.pi, np.pi),
... "up_axis": bvh.world_up,
... }),
... (mirror, 0.5, {
... "lr_joint_pairs": pairs,
... "lateral_axis": "+x",
... }),
... ], representation="6d")
>>> out = pipeline(MotionArrays(root_pos=root_pos,
... joint_rot=joint_rot6d), rng=rng)
>>> out.joint_rot.shape
(120, 31, 6)
standard(skeleton_info: dict, *, representation: str | None = '6d', up_axis: str = '+y', lateral_axis: str = '+x', rotate_angle_range: tuple[float, float] | None = (-np.pi, np.pi), mirror_prob: float = 0.5, noise_sigma: float | None = np.radians(1.0), position_noise_sigma: float | None = None, position_space: str | None = None, speed_factor_range: tuple[float, float] | None = (0.8, 1.2), degrees: bool = False, cache_quats: bool = True) -> 'AugmentationPipeline'
classmethod
¶
Build the canonical rotate + mirror + noise + speed pipeline.
Convenience factory that wires the four common augmentation
steps from a skeleton_info dict (as returned by
:func:pybvh_ml.skeleton.get_skeleton_info or
:func:pybvh_ml.preprocessing.load_preprocessed) so callers
don't reassemble the boilerplate for every project.
Each step is optional: pass None (or 0 for
mirror_prob) to skip it. For anything beyond what these
kwargs expose, build the pipeline directly with the
(fn, prob, kwargs) constructor — this factory is the
opinionated common case, not a wrapper around every knob.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
skeleton_info
|
dict
|
Supplies |
required |
representation
|
str or None
|
Rotation representation threaded through every step.
One of |
'6d'
|
up_axis
|
str
|
Signed-axis strings (e.g. |
'+y'
|
lateral_axis
|
str
|
Signed-axis strings (e.g. |
'+y'
|
rotate_angle_range
|
(float, float) or None
|
Random yaw range in radians (degrees when |
(-pi, pi)
|
degrees
|
bool
|
Interpret |
False
|
mirror_prob
|
float
|
Probability of left/right mirror. |
0.5
|
noise_sigma
|
float or None
|
Per-joint rotation noise standard deviation in radians
(default one degree); |
radians(1.0)
|
position_noise_sigma
|
float or None
|
Per-vertex keypoint jitter, in the data's positional units;
|
None
|
position_space
|
('joint', 'node')
|
Explicit override for that resolution. |
"joint"
|
speed_factor_range
|
(float, float) or None
|
Random speed factor range; |
(0.8, 1.2)
|
cache_quats
|
bool
|
Passed through to the pipeline constructor. |
True
|
__call__(arrays: MotionArrays, *, rng: np.random.Generator | None = None, return_params: bool = False) -> MotionArrays | tuple[MotionArrays, list[dict]]
¶
Apply augmentations with their configured probabilities.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
arrays
|
MotionArrays
|
The clip to augment. Positional because it is a distinct type — every other argument stays keyword-only. |
required |
rng
|
numpy Generator
|
Random number generator. Defaults to a new unseeded one. |
None
|
return_params
|
bool
|
Also return what this call drew (see params below).
Purely additive: the random stream is untouched, so a given
|
False
|
Returns:
| Name | Type | Description |
|---|---|---|
MotionArrays
|
Always freshly allocated — the outputs never share storage with the input arrays, even when no augmentation fires. (The container's fields are read-only either way; take |
|
params |
list of dict
|
Only when |
Examples: