Articulate Arena

Usage documentation for articulatearena, the reference implementation of the quotient metric E on one-degree-of-freedom articulated joints and its object-level lifts.

1Overview

articulatearena implements a principled distance between articulated-kinematics predictions and ground truth. A one-DOF joint J = (ξ, [l⁻, l⁺]) with unit screw twist ξ ∈ se(3) and limits l⁻, l⁺ is represented by its unordered pair of endpoint twists {z⁻, z⁺} = {l⁻ξ, l⁺ξ}. The joint metric is the quotient distance of flat se(3) × se(3) under the swap:

E(J₁, J₂) = min{ ‖z₁⁻ − z₂⁻‖² + ‖z₁⁺ − z₂⁺‖², ‖z₁⁻ − z₂⁺‖² + ‖z₁⁺ − z₂⁻‖² }1/2,

where ‖·‖ is an injected se(3) norm. A subscript names that choice, Eα under the split norm and EB under the kinetic-energy norm, and superscripts mark the extensions Eϕ (compactified) and Etree (tree lift). The package provides the metric itself, a smooth training surrogate, the radial compactification that extends the metric continuously to unbounded (continuous) joints, and two object-level lifts: an optimal-assignment skeleton distance and a motion-aware tree edit distance with a certified assignment relaxation.

All functions are pure: norms, weights, and tolerances are passed in by the caller and never hardcoded. The library depends only on numpy, scipy, and pyyaml.

2Installation

articulatearena will be published to PyPI once the source is available. It requires Python ≥ 3.10 and depends only on numpy, scipy, and pyyaml. The following will be the install command:

$ pip install articulatearena

To work from a source checkout instead, run pip install -e . at the repository root. The editable install registers the same import name, and the bundled scripts also run without installation, as they place the package directory on sys.path themselves.

3Quickstart

Build endpoint pairs for two joints, choose an se(3) norm, and evaluate joint_metric. The distance is 0 exactly when the two joints induce the same motion:

import numpy as np
from articulatearena.new_equation.representation import make_endpoint_pair
from articulatearena.new_equation.inner_product import make_split_norm
from articulatearena.new_equation.E import joint_metric

xi = np.array([0., 0., 1., 0., 0., 0.])   # unit screw axis (omega; nu)
gt   = make_endpoint_pair(xi, 0.0, 1.0)    # joint limits [a, b]
pred = make_endpoint_pair(xi, 0.0, 1.0)

norm = make_split_norm(alpha=1.0)          # the norm is injected, never hardcoded
E = joint_metric(gt.u.data, gt.v.data, pred.u.data, pred.v.data, norm)
print(E)   # 0.0 when prediction == ground truth

To score real URDF joints, load them through the reader layer instead of constructing twists by hand:

from articulatearena.read.urdf_loader import load_joint
from articulatearena.new_equation.representation import joint_to_endpoint_pair

joint = load_joint("sample_data/45168/mobility.urdf", "joint_0")
pair  = joint_to_endpoint_pair(joint)      # unordered {a*xi, b*xi}

4Core concepts, as a tutorial

This section is the long version of the paper's Section 3, written for ourselves. Each concept gets its own figure, drawn by scripts/make_tutorial_figures.py with every number computed by the library, and a short list of the confusions it is meant to clear up. The paper's notation is kept throughout.

4.0 Notation cheat sheet

SymbolMeaningIn code
ξ = (ω, v)A twist, an element of se(3) ≅ ℝ⁶: rotational part ω, translational part v. Joints use a unit screw: ‖ω‖ = 1 if it rotates, else ‖v‖ = 1.Twist, Joint.xi_hat
a, o, hAxis direction, a point on the axis (the origin), and the pitch (meters per radian). These are the URDF parameters that the twist absorbs.screw_twist(a, o, type, h)
qThe joint coordinate: radians for revolute and helical, meters for prismatic. q = 0 is the stored URDF state (the anchor g₀).sweep parameter
l⁻, l⁺The motion limits, values of q. ±∞ for a continuous joint.Joint.a, Joint.b
J = (ξ, [l⁻, l⁺])A one-DOF joint: what it moves along and how far.Joint
z± = l± ξThe two endpoint twists. Together they form the unordered pair {z⁻, z⁺} that the metric compares.EndpointPair.u/.v
J₀ = {0, 0}The fixed (welded) joint, both endpoints at the origin. E(J, J₀) is the motion a joint carries.cost_to_fixed
EThe quotient metric on endpoint pairs. A subscript names the norm inside it, a superscript names an extension.joint_metric
‖·‖α, EαSplit norm ‖Δω‖² + α²‖Δv‖² with an inverse length α. Dimensionless. Benchmark default α = 1.make_split_norm(alpha)
‖·‖B, EBKinetic-energy norm of the moving link B (mass m, center of mass c, inertia I). In meters.make_kinetic_energy_norm(inertia)
ϕ, κ, EϕRadial compactification into the unit ball with scale κ (default π), and E evaluated after it. Handles continuous joints.radial_compactify, compactified_joint_metric
EtreeThe metric lifted to whole kinematic trees as an edit distance whose costs are all E values.exact_tree_distance, assignment_tree_distance

4.1 A joint is a twist

The unit screw twist of a revolute, a prismatic, and a helical joint
How the URDF parameters (type, axis a, origin o, pitch h) become one six-vector. Blue is the rotational part ω, red the translational part v drawn at the world origin.

A URDF describes a joint with four separate things: a type, an axis direction a, an origin o, and limits. The first three are really one object, an infinitesimal rigid motion, and that object is the twist ξ = (ω, v). A revolute joint rotates about the line through o with direction a, which is ξrev = (a, o × a). A prismatic joint slides along a and has no rotation at all, ξpris = (0, a). A helical joint adds a translation of h meters per radian along the axis, ξhel = (a, o × a + h a). Flowing along ξ for q units gives the placement exp(q ξ̂) g₀ of the child link, where g₀ is the placement stored in the file.

Why the moment instead of the origin. The term o × a is the same for every point o on the axis line, so the twist encodes the line itself and not an arbitrary point picked on it. That is why two annotations of the same hinge with different origin points on the same line produce identical twists.
Why "unit". The twist is normalized so that q carries the physical unit: radians for rotation, meters for translation. The limits are values of q and inherit that unit.
The anchor is shared data. Prediction and ground truth describe the same object in the same stored state, so g₀ is not something the kinematic metric judges. Re-zeroing q is not free because it moves the anchor, which the stored meshes pin down.

4.2 The endpoint pair

Two joints drawn as segments through the origin of twist space
A 2-D slice of twist space. Each joint is the segment from z⁻ = l⁻ξ to z⁺ = l⁺ξ. The origin is the fixed joint J₀, and the distance from J₀ to a joint is the motion that joint carries.

Multiply the unit twist by the two limits and the joint becomes a pair of points in se(3), {z⁻, z⁺} = {l⁻ ξ, l⁺ ξ}. The direction of the segment is the screw, its length is the range, its position along the line is where the range sits relative to the anchor. Nothing about the joint is lost and nothing about it is duplicated. The segment passes through the origin whenever l⁻ ≤ 0 ≤ l⁺, that is, whenever the stored state is inside the range. The fixed joint is the degenerate segment {0, 0}.

Limits are first-class data. The prior template folds the limits into the axis through a motion vector a (l⁺ − l⁻), which keeps the range width but forgets where the range sits and which way it points. The endpoint pair keeps both.
Why an unordered pair. Nothing physical distinguishes the two ends of a segment. Ordering them would make the metric depend on which limit is called lower, which is exactly the sign convention the next section removes.
E(J, J₀) is the "size" of a joint. Under the split norm it is √((l⁻)² + (l⁺)²) times a fixed factor, and under the kinetic-energy norm it is the RMS material displacement over the range. The tree metric prices structural edits with it.

4.3 The metric E and the ℤ₂ swap

The direct and the swapped pairing of two endpoint pairs, and the axis flip
E takes the cheaper of the two ways to pair up the endpoints (left, middle). Flipping the screw and negating the limits redraws the same segment (right), so the quotient makes the axis sign irrelevant.

Two unordered pairs can be matched in two ways, direct (z₁⁻ ↔ z₂⁻, z₁⁺ ↔ z₂⁺) or swapped. Each way has a flat distance in se(3) × se(3), the square root of the two squared endpoint differences, and

E(J₁, J₂) = min{ ‖z₁⁻ − z₂⁻‖² + ‖z₁⁺ − z₂⁺‖², ‖z₁⁻ − z₂⁺‖² + ‖z₁⁺ − z₂⁻‖² }1/2.

This is the quotient distance of a flat space by the group ℤ₂ acting as the swap, and a quotient by a finite group of isometries is again a metric. That single fact gives the triangle inequality, which no hand-built combination of component errors has.

What the swap buys. Reversing the screw orientation maps (ξ, [l⁻, l⁺]) to (−ξ, [−l⁺, −l⁻]). The two endpoint twists are unchanged as a set, only their labels trade places, so the swapped pairing scores zero against the original. The axis-sign ambiguity that breaks the prior limit score is removed structurally, not patched.
Squares then a root, not a sum of norms. The two endpoint differences are combined in quadrature. This is what makes E the flat distance of the product space and what makes EB an RMS quantity later.
Where E is not smooth. Only on the tie locus where the two pairings cost the same. For training, training_loss replaces the min by a smooth swap-invariant surrogate.

4.4 Wrap and gauge symmetries

The 2 pi orbit of revolute limits and E against a shift of both limits
Left: every interval on the 2π orbit is the same motion. The optional wrap keeps the one whose midpoint lies in [−π, π). Right: shifting both limits of a GT [0, π/2] by θ. The default (raw limits) keeps growing. With the wrap switched on, a full turn returns E to zero, and the seam at θ = 135° is where the midpoint crosses π.

A revolute exponential is 2π-periodic, so shifting both limits by 2πk rewrites the same motion over the same anchor. The definition of E uses the limits exactly as stored, so such a pair of encodings is charged the shift. As an optional preprocessing step, wrap_revolute_limits shifts a revolute joint's limits by the common 2πk that places the midpoint ½(l⁻ + l⁺) in [−π, π), and joint_to_endpoint_pair(joint, wrap_revolute=True) applies it before forming the pair. Only revolute joints can be wrapped: a prismatic shift translates and a helical shift also translates, so for them k = 0 is forced.

Off by default. Nothing in the library or the benchmark wraps unless asked. The stored limits are the data the metric judges, and the stored state pins the anchor, so a full-turn shift is treated like any other re-encoding the file could have avoided.
The wrap has a seam. Picking one representative per orbit is a choice of fundamental domain, and at its boundary (midpoint exactly ±π) the representative jumps. With the wrap on, E is invariant under the full-turn shift but is discontinuous there, which is the other reason it stays optional.
Two prismatic joints with different drawn origins have identical twists
Left: a slider drawn from two different origins is the same twist, E = 0. Right: the same construction for a hinge changes the moment o × a, so the pivot line moved and E is not zero.

A prismatic twist is (0, a) and contains no origin at all, so moving the drawn origin is pure gauge and the metric returns zero. The prior origin score charges ‖o₁ − o₂‖ for it. For a revolute joint the origin matters through the moment, which is the physically right behavior: a hinge on a different line moves the door differently.

4.5 Which norm: Eα versus EB

The formula for E only asks for a norm on twist differences Δz = (Δω, Δv) that comes from an inner product. Two are offered, and the subscript on E says which one is inside.

A fifteen degree axis error scored on rods of different length under both norms
The same 15° axis error on a short and a long link. The split norm scores it identically for every link. The kinetic-energy norm scores it by the material that actually moves.

The split norm ‖Δz‖α² = ‖Δω‖² + α²‖Δv‖² weighs rotation against translation through one inverse length α. It needs no geometry, is dimensionless, and compares every joint of every object by one rule. The kinetic-energy norm ‖Δz‖B² = (1/m) Δωᵀ I Δω + ‖Δv + Δω × c‖² lets the moving link set the scale: m, c, and I are the mass, center of mass, and inertia of the ground-truth child link. Under it EB is in meters and equals the RMS linearized motion of the link's material.

Displacement field of a door under the true hinge and a shifted hinge, and E_B against the hinge offset
Left: a door opened by 60° about the true hinge and about a hinge offset by 0.18 m. The red arrows are where each material point ends up differently. Right: EB tracks the RMS of those displacements as the offset grows. The small gap is the linearization.
When the two norms agree. On purely translational differences (Δω = 0) and α = 1 they coincide exactly, which is why the prismatic panels of the ablations overlap. They separate where rotation enters.
α is a size assumption. For a centered link with isotropic inertia and gyration radius r, the kinetic norm equals r² times the split norm at α = 1/r. A global α therefore assumes every link has the same size 1/α. The object-scale ablation shows the consequence: EB(s) = s EB(1) under a uniform scaling s, while Eα is flat on angular errors.
EB is still a metric. The body B is fixed by the ground truth before any prediction is seen and is the same for every method on that joint, so the norm is one fixed inner-product norm and the quotient argument applies verbatim. Different ground-truth joints carry different norms, which is the intended physical weighting.
Units. Eα is a pure number, EB is meters. Never compare their heights, only their shapes.

4.6 Continuous joints and the compactification Eϕ

The tanh radial map, the unit ball with finite and continuous joints, and E phi against a growing range
Left: the radius after ϕ as a function of the endpoint norm, for three κ. Middle: inside the ball a finite joint [−L, L]ξ grows toward the boundary, where the continuous joint {±ξ/‖ξ‖} lives. Right: Eϕ between a finite prediction and a continuous ground truth decays to zero as the range grows, matching the closed form.

A continuous joint has l± = ±∞, so its endpoint twists are at infinity and the flat distance is undefined. The radial map ϕ(z) = tanh(‖z‖/κ) z/‖z‖ pulls all of se(3) into the open unit ball and sends the continuous joint to the antipodal boundary pair {±ξ/‖ξ‖}, the axis direction up to sign. Then Eϕ(J₁, J₂) = E({ϕ(z₁⁻), ϕ(z₁⁺)}, {ϕ(z₂⁻), ϕ(z₂⁺)}). Against a continuous ground truth with unit screw, a finite prediction [−L, L] on the same screw scores √2 (1 − tanh(L‖ξ‖/κ)): it starts at √2, the distance from the welded joint, and decays to zero. A continuous joint is literally the limit of a growing range, with no formula switch.

ϕ uses the same norm as E. The ‖z‖ inside ϕ is whichever norm the subscript names, so there is an Eαϕ and an EBϕ. Both are dimensionless because the division by ‖z‖ inside ϕ cancels the units and κ carries the unit of the norm. EBϕ keeps the body-aware weighting but loses the meter reading.
What κ does. It is a pure horizontal dilation of every curve, with half decay near L‖ξ‖ ≈ 0.55 κ. The default κ = π keeps ranges up to a full turn in the responsive part of tanh, and on the benchmark's finite ground-truth joints none saturate (95th percentile radius 0.78 at worst).
Finite joints may also be compactified. The map is defined on all of se(3), so Eϕ is the one score that covers every joint type at once. That is why the benchmark's common column is Eαϕ. Raw EB is reported only on pairs whose limits are both finite.

4.7 Trees: Etree and the assignment relaxation

A storyboard of the three edit operations turning a predicted tree into the ground truth
The three edits: contract an edge (merge its two links), substitute the joint on an edge, expand a link into two joined by a new joint. Every cost is an E value, and the welded joint J₀ is the reference for contraction and expansion.

An object is a tree with links as nodes and joints on the edges, and the same object admits many trees: parts can be relabeled, the base re-chosen, a handle welded to a drawer or split off. The tree metric is the cheapest sequence of edits from one tree to the other, Etree(T₁, T₂) = infπ Σo∈π c(o), with substitution costing E(J, J′) and contraction or expansion costing E(J, J₀). A spurious joint that barely moves costs ε‖ξ‖, not a constant penalty, so topology errors and joint errors are priced in the same motion currency and the metric is continuous through the welded boundary. Exactly fixed edges are contracted first (contract_small_edges) because they are representation noise.

A path and a star with the same joint multiset, where the relaxation and the exact distance differ
The two trees carry the same three joints, so the assignment relaxation pairs them one-to-one at zero cost. No tree isomorphism realizes that pairing, so the exact edit distance is positive.

Any edit sequence can be reordered into contract, then substitute along a tree isomorphism, then expand. The exact distance is therefore a minimum over matchings of the edge sets that extend to an isomorphism of the contracted trees. Dropping that realizability condition gives the assignment relaxation: a Hungarian matching over the joint multisets with E(J, J₀) as the unmatched cost (assignment_tree_distance). It is always a lower bound, it is polynomial for any input, and it needs only the multiset of predicted joints. Whenever its optimal matching happens to be realizable, checked in linear time by matching_realizable, the relaxation certifies the exact value. exact_tree_distance returns (distance, certified) and falls back to a small constrained search otherwise.

Exact versus relaxed, in numbers. On the tree ablation the certificate settles 91 % of random corruptions, exact and relaxed agree on 92 %, they agree on every structured sweep, and their rankings correlate at τ = 0.994. The paper states the protocol with the relaxation because unordered tree edit distance is NP-hard in the worst case and because some methods emit joints without a usable tree. The reported tree score is the exact one wherever a tree is available.
Why not a constant topology penalty λ. It jumps by λ the moment a joint appears, is capped at 2λ once unmatching becomes cheaper than matching, and the forced-matching variant escapes the cap only by breaking the triangle inequality. Motion-priced edits have no knob and none of these failures.
The per-link weighting and the metric proof. With one fixed inner product Etree is a metric on representation-equivalence classes. With the kinetic-energy norm instantiated per ground-truth link, contraction and expansion change which body weighs which joint, so that variant is used as a score without the metric claim.

4.8 Reading the table headers

ColumnNormExtensionDefined on
EBkinetic-energy (meters)none, raw endpointsmatched pairs with finite limits on both sides
Eαϕsplit, α = 1compactified, κ = πevery matched pair, continuous joints included
Eαϕ,treesplit, α = 1compactified and lifted to the treewhole objects, the primary score

The rule is simple once seen: the subscript is the inner product inside the norm, the superscript is what was done to the endpoints before or after E. The design space is the product of the two choices, and any cell of it is a legitimate score.

5API reference

Public functions and classes, by module. All array arguments are NumPy float64, and twists are shape (6,) in the paper's convention ξ = (ω, v).

Types · articulatearena.types

TwistImmutable shape-(6,) twist wrapper, raw array via .data.
EndpointPairUnordered pair {u, v} of endpoint twists.
JointUnit screw twist xi_hat, limits a, b (±inf for continuous), and joint_type.
LinkInertiaMass, center of mass, and 3×3 rotational inertia of a link.
InnerProductProtocol: callable from a shape-(6,) twist to its squared norm.

Representation · new_equation.representation

screw_twist(axis_dir, point, joint_type, pitch=0) Unit screw twist of a one-DOF joint from a world-frame axis and a point on it.
make_endpoint_pair(xi_hat, a, b) The unordered endpoint pair {a·ξ, b·ξ} (code names the limits a, b = paper's l⁻, l⁺).
joint_to_endpoint_pair(joint) Endpoint pair of a typed Joint.

Joint metric · new_equation.E

joint_metric(u1, v1, u2, v2, inner_product) The quotient metric E, the ℤ₂-swap minimum over endpoint pairings.
training_loss(u1, v1, u2, v2, inner_product, epsilon=1e-9) Smooth swap-invariant surrogate for E² (differentiable objective).

Norms · new_equation.inner_product

make_split_norm(alpha)Split norm ‖Δω‖² + α²‖Δv‖².
make_kinetic_energy_norm(inertia)Kinetic-energy norm from a LinkInertia.
make_riemannian_norm(metric)Norm from a user-supplied SPD 6×6 matrix.

Continuous joints · new_equation.continuous

radial_compactify(xi, inner_product, kappa)Radial map ϕ into the open unit ball.
continuous_joint_repr(xi_hat, inner_product, kappa)Boundary endpoint pair of an unbounded joint.
compactified_joint_metric(u1, v1, u2, v2, inner_product, kappa)E∘ϕ, the compactified metric.
interval_term(a1, a2)Extended-real distance between limit values.

Tree metric · new_equation.tree

cost_to_fixed(pair, inner_product)Contraction/expansion cost E(J, J₀).
assignment_tree_distance(pairs1, pairs2, inner_product, unmatched_cost=None) Assignment relaxation over joint multisets (Hungarian, padded matrix).
contract_small_edges(edges, inner_product, tol=1e-12) Contract exactly-fixed (zero-motion) edges of a labeled tree.
assignment_matching(pairs1, pairs2, inner_product) Relaxation value together with its optimal matching.
matching_realizable(edges1, edges2, matching) Linear-time check that a matching extends to an isomorphism of the contracted trees.
exact_tree_distance(edges1, edges2, inner_product) Exact motion-aware tree edit distance. Returns (distance, certified).

Skeleton distance · new_equation.skeleton

topology_penalty(num_unmatched, num_relation_mismatches, lambda_topology, relation_weight=1) Constant-λ penalty for surplus joints and relation mismatches.
skeleton_distance(cost_matrix, lambda_topology, ...) Assigned E cost plus topology penalty. Returns (row_ind, col_ind, total).

Material motion · new_equation.material_motion

se3_exp(xi_hat, theta)Rigid transform exp(θ ξ̂).
config_discrepancy(...), finite_joint_discrepancy(...) RMS displacement discrepancy of material points swept along two joints (used to calibrate E against physical motion).

Background math · general_equation

exp_map.exp_map(xi_hat, q=1)se(3) exponential map.
assignment.assign(cost_matrix)Hungarian optimal assignment.
tree_edit.tree_edit_distance(...)Classical ordered tree edit distance (Zhang-Shasha) on TreeNodes.
configuration.configuration_difference(...)L² configuration difference (Park 1995).

I/O · read and write

read.config_loader.load_config(path=None) Flatten a grouped YAML config into the Config dataclass. Defaults to configs/default.yaml.
read.urdf_loader.load_joint(urdf_path, joint_name) Typed Joint from a URDF, with the axis resolved to the world frame by forward kinematics.
read.inertia_loader.load_link_inertia, load_joint_child_inertia LinkInertia of a link / of a joint's child link, for the kinetic-energy norm.
read.case_loader.load_case, load_case_joints Prediction/ground-truth evaluation case referencing two URDF joints.
write.report.Report_Generator JSON / Markdown evaluation reports.

Baselines · previous_metrics.component_scores

Component scores of prior evaluation templates (type, axis, origin, and limit errors with thresholded joint_success), kept for reproducing §3-style baseline numbers.

6Command-line usage

The repository entry point evaluates a prediction/ground-truth case end to end. It loads the config, runs the pure metric functions, and writes a report:

$ python scripts/run.py --config configs/default.yaml
# → Report written to out/metrics_report.json

configs/default.yaml groups the knobs that the paper treats as caller-supplied:

GroupKeys
inner_productform (split / kinetic / riemannian), split_norm_alpha, riemannian_metric.
compactificationkappa (benchmark: π), phi_at_zero.
skeletonlambda_topology, topology_penalty, tree_edit_distance.
aggregation, geometryAggregation form, smoothing ε, geometric weighting.
prediction, ground_truthURDF path + joint name of each side of the case.

Auxiliary scripts: scripts/generate_error_cases.py synthesizes controlled error cases from a URDF, scripts/run_previous_metrics.py scores the same cases with the baseline component metrics, and examples/run_real_cases.py evaluates real PartNet-Mobility objects.