Motion Arrays¶
arrays
¶
The container every array-level function in pybvh-ml takes and returns.
One clip's motion, as the streams a model consumes. Augmentation,
packing and the PyTorch datasets all speak :class:MotionArrays rather
than loose arrays, so adding a stream later is additive instead of
breaking every call site.
STREAM_NAMES: tuple[str, ...] = ('root_pos', 'joint_rot', 'joint_pos', 'node_pos')
module-attribute
¶
Every stream a :class:MotionArrays can carry, in field order.
The vocabulary the augmentation stream declarations are written in —
see :func:~pybvh_ml.handles_streams.
POSITION_CENTERINGS: tuple[str, ...] = ('world', 'skeleton', 'first')
module-attribute
¶
Legal values of :attr:MotionArrays.position_centering.
The three frames :meth:pybvh.Bvh.node_positions produces, named
identically so the two never drift.
MotionArrays(*, root_pos: npt.ArrayLike, joint_rot: npt.ArrayLike | None = None, joint_pos: npt.ArrayLike | None = None, node_pos: npt.ArrayLike | None = None, position_centering: str | None = None)
¶
One clip's motion streams: root translation, joint rotations, joint or node positions.
Deliberately not a tuple and not unpackable. A tuple's arity
is part of its contract, and this container grows — the per-joint and
per-node position streams 0.6.0 added would have turned every
root_pos, joint_rot = ... into a "too many values" error, and a
tuple that silently yielded only its first two fields would drop a
stream instead. Attribute access does neither.
Construction is keyword-only, for the reason the augmentation
functions already refuse positional binding: joint_rot in
euler or axisangle form is (F, J, 3), the same shape
joint_pos is, so no validator could catch a swapped positional
call.
Instances are frozen. Shape and frame-count validation runs
once, in the constructor, and every array-level function in the
package relies on it having run — reassigning a field afterwards
would reintroduce the mismatch with nothing left to catch it. Use
:meth:replace, which revalidates.
Frozen covers the arrays too, but only in one direction, and the distinction matters when the source is a cache. The fields are read-only views: writing through them (arrays.root_pos[0] = ...) raises, so nothing — this package included — can modify a clip through the container. They are views, not copies: the constructor does not duplicate the caller's arrays, so the storage is shared, and mutating the original array still changes what the container reads. A container built over a Dataset's cached arrays therefore needs no defensive copy to protect the cache from the pipeline (pipeline outputs never alias their inputs), but it is not insulated from code that writes to that cache directly — pass np.array(...) copies in if anything does. The alternative, copying in the constructor, was rejected because :meth:replace runs once per augmentation step and would copy every clip on every step.
Consequently a field is not a writable working array: take np.array(arrays.joint_rot) (or .copy()) when you need one, and prefer torch.tensor(...) over torch.from_numpy(...), which warns on read-only input. For a whole container detached from the caller's storage, :func:copy.deepcopy is the one operation the read-only views cannot express, and it works — instances rebuild through the constructor, so they are also picklable.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
root_pos
|
(ndarray, shape(F, 3))
|
Root translation per frame. Always present — see Keypoint-only clips below if your source has no root trajectory. |
required |
joint_rot
|
(ndarray, shape(F, J, C))
|
Per-joint rotations in whatever representation the caller
declares at the call site. The container does not record which
representation that is: a rotation array is meaningless without
the token, and storing an unenforced copy of it here would
invite it to disagree with the |
None
|
joint_pos
|
(ndarray, shape(F, J, 3))
|
Per-joint 3-D positions, index-aligned with |
None
|
node_pos
|
(ndarray, shape(F, N, 3))
|
Per-node 3-D positions, joints and end sites, aligned with
:meth: |
None
|
position_centering
|
('world', 'skeleton', 'first')
|
Which frame the position streams are expressed in — see the
Notes. Must be |
"world"
|
Attributes:
| Name | Type | Description |
|---|---|---|
root_pos, joint_rot, joint_pos, node_pos, position_centering |
As above. |
Notes
Keypoint-only clips: "positions-only" means rotation-free, not
root-free. representation=None and a bare joint_pos is a
supported clip, but root_pos stays mandatory, so a source with
no root trajectory — pose-estimator keypoints, the usual ST-GCN
arrival path — has to supply something. np.zeros((F, 3)) is the
convention, and it is safe only if you then keep "root_pos"
out of streams=. Two things go quietly wrong otherwise:
center_root=True becomes a no-op that looks like it worked (it
subtracts a zero first frame), and any packing that includes
"root_pos" puts a vertex of zeros at index 0, which the model
reads as a joint and which shifts every real joint one place out of
step with skeleton_info["edges"]. So pack
streams=("joint_pos",) — which is the canonical ST-GCN layout
anyway — and the fabricated array never reaches a tensor. If the
keypoint set has a pelvis or hip, using it as root_pos is
strictly better than zeros: the trajectory becomes real and both
hazards disappear.
Why mandatory: root_pos is the container's frame count and the
reference point every centering convention is stated against, so
an optional root would make F and position_centering
conditional on which streams happen to be present. Making it
genuinely optional is a coherent change and a large one — it is
scoped for a later release, not an oversight here.
Pick one position space, not both. joint_pos is a subset of
node_pos — node_pos[:, joint_idx >= 0] is exactly
joint_pos, with joint_idx from
skeleton_info["fk_topology"] — so carrying both is redundant
though harmless. Only joint_pos can be concatenated with
joint_rot on the channel axis; node_pos has a different
V, and the packers refuse to combine the two.
position_centering is a convention, and it travels with the
arrays rather than only with the dataset, because at least one
step's correctness depends on it. "world" leaves positions in
the same frame as root_pos, so 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 by root_pos alone; "first" is
pybvh's ground-plane centering (the first frame's root subtracted in
the two axes perpendicular to world up). The three coincide only
for a clip whose root never moves.
position_centering=None is legal, and fails at use rather than
at construction. The two directions are not symmetric: a
centering value with no positions to describe is meaningless and
raises here, but positions with an undeclared frame are a legitimate
state, because most of the surface does not care.
:func:~pybvh_ml.mirror, :func:~pybvh_ml.speed_perturbation_arrays,
:func:~pybvh_ml.dropout_arrays, the keypoint-jitter functions,
:func:~pybvh_ml.rotate_vertical and pack_to_*(center_root=False)
are all correct without knowing the frame — each is a rigid or
temporal operation applied identically to both streams. The
surfaces that do depend on it — :func:~pybvh_ml.add_root_position_noise,
the FK refresh inside :func:~pybvh_ml.add_joint_rotation_noise, and
pack_to_*(center_root=True) — raise naming the field. Requiring
a declaration at construction would make those raises dead code and,
worse, invite a guessed "world" from a caller who does not
actually know; a confidently wrong convention is worse than an
honest None. Anything this library writes records it (see
:func:~pybvh_ml.preprocess_directory); None is for positions
that came from somewhere else.
A consequence worth knowing: :meth:replace dropping the last
position stream must clear position_centering in the same call,
or the result raises. No pipeline step drops a stream, so this is
rare.
dtype is preserved, not promoted. A floating-point input keeps its dtype — float32 in, float32 out — because the container is what a per-sample Dataset holds, and silently doubling a cached clip's memory and bandwidth is not a decision to make on the caller's behalf. Non-floating input (an integer array, a nested list of ints) is promoted to float64, the package's compute dtype, since rotation math on integers is never what was meant. Every stream is converted independently and they may differ — float32 keypoints beside float64 rotations is the normal case for an ST-GCN pipeline, not an exotic one.
Augmentation preserves it too, without computing in it: every augmentation function and :class:~pybvh_ml.AugmentationPipeline runs the math in float64 — pybvh's dtype, and the only one its conversions are exact in — then returns each stream in the dtype it arrived as. So a float32 clip stays float32 end to end through augmentation while the arithmetic is still done in double, and the result never depends on which probabilistic steps happened to fire.
Where it stops: the packers (:func:~pybvh_ml.pack_to_ctv and friends) and standardize_length(method="resample_linear") produce float64 regardless, so the array handed to a model is float64 either way — the PyTorch datasets then emit torch.float32 tensors. The preservation is about what the container costs to hold and pass around, not a single-precision compute path.
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Examples:
>>> arrays = MotionArrays(root_pos=root_pos, joint_rot=joint_6d)
>>> out = rotate_vertical(arrays, angle=np.pi / 4, up_axis="+y",
... representation="6d")
>>> out.joint_rot.shape
(120, 31, 6)
>>> keypoints = MotionArrays.from_bvh(
... bvh, representation=None, include_positions=True,
... position_centering="skeleton")
>>> pack_to_ctv(keypoints, streams=("joint_pos",)).shape
(3, 120, 31)
See Also
MotionArrays.from_bvh : Build one straight from a pybvh Bvh.
frame_count: int
property
¶
Number of frames, F.
present_streams: frozenset[str]
property
¶
Names of the streams this clip actually carries.
What an augmentation step's declaration is checked against —
see :func:~pybvh_ml.handles_streams. "root_pos" is always
in it.
__iter__()
¶
Refuse iteration, naming the pre-0.5.0 unpack it comes from.
Defined only to raise: root_pos, joint_data = pipeline(...) was the shape every downstream had, and without this it fails as a bare "cannot unpack non-iterable" with nothing pointing at the container's fields.
from_bvh(bvh: 'Bvh', representation: str | None = None, *, center_root: bool = False, include_positions: bool = False, position_space: str = 'joint', position_centering: str = 'world') -> 'MotionArrays'
classmethod
¶
Extract one clip's arrays from a pybvh Bvh.
The producer counterpart to the packers: this is where the extract → augment → pack journey starts, so callers never hand-assemble the container.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
bvh
|
Bvh
|
|
required |
representation
|
str
|
One of |
None
|
center_root
|
bool
|
Subtract the first frame's root position from every frame.
All three components, pybvh-ml's root-relative convention —
not pybvh's Under |
False
|
include_positions
|
bool
|
Also extract positions, via :meth: |
False
|
position_space
|
('joint', 'node')
|
Which index space the positions live in: |
"joint"
|
position_centering
|
('world', 'skeleton', 'first')
|
Frame the positions are extracted in, passed straight to
pybvh's |
"world"
|
Returns:
| Type | Description |
|---|---|
MotionArrays
|
|
replace(*, root_pos: Any = _UNSET, joint_rot: Any = _UNSET, joint_pos: Any = _UNSET, node_pos: Any = _UNSET, position_centering: Any = _UNSET) -> 'MotionArrays'
¶
Return a new instance with the given fields replaced.
The only way to modify a frozen container, and it revalidates —
so a replacement that breaks an invariant raises here rather
than surfacing as corrupt data later. In particular, dropping
the last position stream (replace(joint_pos=None)) must
clear position_centering in the same call, since a centering
with nothing to describe is not a legal state.
__reduce__()
¶
Rebuild through the constructor, for pickle and copy.
Both would otherwise go through setattr on a blank instance and hit the frozen guard. Rebuilding also means :func:copy.deepcopy returns a container whose storage is detached from this one's — the one operation the read-only fields cannot express.
require_joint_rot(arrays: MotionArrays, caller: str) -> npt.NDArray[np.floating]
¶
Return arrays.joint_rot, raising a named error when absent.
Shared by every step that cannot do its job without rotations, so the message is identical wherever it fires.
require_position_centering(arrays: MotionArrays, caller: str) -> str
¶
Return arrays.position_centering, raising when it is unknown.
The shared message for the three surfaces whose correctness depends
on the frame the positions live in — root-position noise, the FK
refresh in :func:~pybvh_ml.add_joint_rotation_noise, and
pack_to_*(center_root=True). Only reachable when a position
stream is present; see :class:MotionArrays for why None is
a legal state everywhere else.
center_root_streams(root_pos: npt.NDArray[np.floating], joint_pos: npt.NDArray[np.floating] | None, node_pos: npt.NDArray[np.floating] | None, position_centering: str | None, caller: str) -> tuple[npt.NDArray[np.floating], npt.NDArray[np.floating] | None, npt.NDArray[np.floating] | None]
¶
Subtract the first frame's root position from every present stream.
pybvh-ml's root-relative convention: all three components, unlike
pybvh's centered="first", which zeroes only the two ground-plane
axes. The shared implementation behind center_root=True on
:meth:MotionArrays.from_bvh, the packers and
:func:~pybvh_ml.preprocess_directory, so the three cannot drift.
What happens to the positions depends on the frame they are in:
"world"— they carry the root trajectory, so the identical shift applies to every vertex and the two streams stay in one frame."skeleton"— they are already root-relative and do not move."first"— the identical shift again, and for a subtler reason: ground-plane centering already puts the positions in a different frame fromroot_pos(offset by the first frame's root in the two non-up axes), so shifting both by the same amount is what leaves that relationship unchanged. Leaving them alone would change it. Note that :func:~pybvh_ml.preprocess_directoryrefuses to write a dataset withcenter_root=Trueand this centering: the recorded flag would suggest a coherence between the two streams that ground-plane centering never established.Nonewith positions present — raises, since guessing would either double-shift or leave the streams inconsistent.
Returns the three streams; the positions are None where they
came in None.