Transform Frame System

TransformFrame is HORUS's coordinate frame management system — a real-time-safe replacement for ROS2 TF2. It manages the spatial relationships between coordinate frames (e.g., "where is the camera relative to the robot base?") with lock-free lookups and sub-microsecond performance.

Why Transform Frame?

Problem with TF2Transform Frame Solution
Mutex-based lockingLock-free seqlock protocol
Unbounded latency spikesPredictable sub-microsecond latency
String-only frame lookupDual API: Integer IDs + Names
No hard real-time supportReal-time safe, no allocations in hot path

Performance

OperationTransform FrameROS2 TF2Speedup
Lookup by ID~50nsN/A-
Lookup by name~200ns~2us10x
Chain resolution (depth 3)~150ns~5us33x
Chain resolution (depth 10)~380nsN/A-
Concurrent reads (4 threads)~115nsN/A-

The depth-10 and concurrent-read rows are measured on an Intel i7-10750H (12 cores) with the crate's own benchmark suite (cargo test --release -- --ignored transform_frame_benchmark); no ROS2 TF2 comparison was run for those two, so they carry no speedup figure.


Basic Usage

Transform frames live in their own crate — the horus umbrella crate does not pull it in, so add it to your Cargo.toml. horus-tf declares horus_core and horus_macros as relative path dependencies that do not exist inside its git checkout, so a [patch] section pointing at your local HORUS checkout is required alongside it:

[dependencies]
horus-tf = { git = "https://github.com/softmata/horus-tf.git", rev = "d525dfcf58b99ee9c015ed5b0b3f20450b7c1e0c" }

# Required — without this, cargo fails with "no matching package named `horus_core` found".
[patch."https://github.com/softmata/horus-tf.git"]
horus_core = { path = "/path/to/horus/horus_core" }
horus_macros = { path = "/path/to/horus/horus_macros" }
use horus_tf::prelude::*; // TransformFrame, TransformFrameConfig, Transform, FrameId, timestamp_now

// Create with default config (256 frames)
let hf = TransformFrame::new();

// Register frame hierarchy: world → base_link → camera
hf.register_frame("world", None)?;
hf.register_frame("base_link", Some("world"))?;
hf.register_frame("camera", Some("base_link"))?;

// Update transform (camera position relative to base_link)
let tf = Transform::new(
    [0.1, 0.0, 0.3],         // translation [x, y, z] in meters
    [0.0, 0.0, 0.0, 1.0],    // quaternion [x, y, z, w]
);
hf.update_transform("camera", &tf, timestamp_now())?;

// Query: where is camera relative to world?
let camera_to_world = hf.tf("camera", "world")?;
let point_in_world = camera_to_world.transform_point([1.0, 0.0, 0.0]);

Transform Type

pub struct Transform {
    pub translation: [f64; 3],  // [x, y, z] in meters
    pub rotation: [f64; 4],     // quaternion [x, y, z, w] (Hamilton convention)
}

Creating Transforms

// From translation + quaternion
let tf = Transform::new([1.0, 2.0, 3.0], [0.0, 0.0, 0.0, 1.0]);

// Translation only (identity rotation)
let tf = Transform::from_translation([1.0, 0.0, 0.0]);

// Rotation only (no translation)
let tf = Transform::from_rotation([0.0, 0.0, 0.0, 1.0]);

// Translation + Euler angles (translation, [roll, pitch, yaw])
let tf = Transform::from_euler([1.0, 0.0, 0.0], [0.1, 0.2, 0.3]);

// Rotation only, about Z by 90 degrees (see also `roll`, `pitch`)
let tf = Transform::yaw(std::f64::consts::PI / 2.0);

// Rotation only, from roll/pitch/yaw — `from_euler` with no translation
let tf = Transform::rpy(0.0, 0.0, std::f64::consts::PI / 2.0);

// Identity transform
let tf = Transform::identity();

Transform Operations

// Compose two transforms
let composed = tf1.compose(&tf2);

// Invert a transform
let inverse = tf.inverse();

// Apply to a point (translation + rotation)
let point_world = tf.transform_point([1.0, 0.0, 0.0]);

// Apply to a vector (rotation only)
let vector_world = tf.transform_vector([1.0, 0.0, 0.0]);

// Interpolate between transforms (t=0.0 to 1.0)
// Uses linear interpolation for translation, SLERP for rotation
let tf_mid = tf1.interpolate(&tf2, 0.5);

// Convert to/from 4x4 matrix
let matrix = tf.to_matrix();
let tf = Transform::from_matrix(matrix);

// Extract Euler angles [roll, pitch, yaw]
let euler = tf.to_euler();

Frame Registration

Dynamic Frames

Dynamic frames have transforms that change over time (e.g., robot joints, camera tracking):

// Register with parent relationship
let camera_id = hf.register_frame("camera", Some("base_link"))?;

// Root frame (no parent)
hf.register_frame("world", None)?;

// Update transform over time
hf.update_transform("camera", &new_tf, timestamp_now())?;

// Or use cached frame ID for faster updates
hf.update_transform_by_id(camera_id, &new_tf, timestamp_now())?;

Static Frames

Static frames have transforms that never change (e.g., sensor mounts, fixed offsets):

// Register with initial transform
let mount_tf = Transform::from_translation([0.1, 0.0, 0.2]);
hf.register_static_frame("lidar_mount", Some("base_link"), &mount_tf)?;

// Update static transform (rare, e.g., recalibration)
hf.set_static_transform("lidar_mount", &new_tf)?;

Static frames use less memory since they don't maintain a history buffer.

Frame Queries

// Get frame ID (cache this for hot paths!)
let id: FrameId = hf.frame_id("camera").unwrap();

// Get frame name by ID
let name: Option<String> = hf.frame_name(id);

// Check if frame exists
if hf.has_frame("camera") { /* ... */ }

// List all frames
let all: Vec<String> = hf.all_frames();

// Get parent/children
let parent: Option<String> = hf.parent("camera");
let children: Vec<String> = hf.children("base_link");

Transform Lookups

By Name

// Get transform from source frame to destination frame
let tf = hf.tf("camera", "world")?;

// Check if transform path exists
if hf.can_transform("camera", "world") {
    let tf = hf.tf("camera", "world")?;
}

By ID (Hot Path)

For control loops running at 1kHz+, cache frame IDs and use the ID-based API:

// Cache IDs once at startup
let camera_id = hf.frame_id("camera").unwrap();
let world_id = hf.frame_id("world").unwrap();

// Use IDs in hot loop (~50ns vs ~200ns for name-based)
loop {
    let tf = hf.tf_by_id(camera_id, world_id);
    if let Some(tf) = tf {
        let target_in_world = tf.transform_point(target_in_camera);
        // Control logic...
    }
}

Time-Travel Queries

Transform Frame maintains a history buffer of past transforms, enabling queries at past timestamps with interpolation:

// Get transform at a specific past time
let past = timestamp_now() - 100_000_000; // 100ms ago
let tf = hf.tf_at("camera", "world", past)?;

// ID-based version
let tf = hf.tf_at_by_id(camera_id, world_id, past);

If the exact timestamp isn't available, Transform Frame interpolates between the two nearest samples:

  • Translation: Linear interpolation
  • Rotation: SLERP (Spherical Linear Interpolation)

Interpolation only applies when the requested time falls between two stored samples. A timestamp outside the history window is not an error: the nearest stored sample is returned unchanged, and it looks exactly like a successful lookup. Ask for a pose from an hour ago and you get the oldest sample you still have, silently.

The window is history_len samples deep (32 by default), so at 100 Hz it covers about 320 ms. If you query further back than your update rate covers, either raise history_len or compare the returned transform's own timestamp against what you asked for.


Configuration

Presets

// Default / Small (256 frames, ~550KB)
let hf = TransformFrame::new();       // Same as TransformFrame::small()
let hf = TransformFrame::small();

// Medium (1024 frames, ~2.2MB)
let hf = TransformFrame::medium();

// Large (4096 frames, ~9MB)
let hf = TransformFrame::large();
PresetFramesStaticHistoryCacheMemory
small()2561283264~550KB
medium()102451232128~2.2MB
large()4096204832256~9MB

Additional presets for simulation:

// Massive (16384 frames, ~35MB) - 100+ robot simulations
let hf = TransformFrame::with_config(TransformFrameConfig::massive());

// Grow-on-demand: pre-allocates 4x the configured frame count and lets the
// limit grow into it, up to a hard ceiling of 65536 frames
let hf = TransformFrame::with_config(TransformFrameConfig::unlimited());

Custom Configuration

let hf = TransformFrame::with_config(TransformFrameConfig {
    max_frames: 2048,           // Total frames (16-65536)
    max_static_frames: 1024,    // Static frames (less memory, faster)
    history_len: 64,            // Past transforms per dynamic frame (4-256)
    enable_overflow: false,     // Allow HashMap for unlimited frames
    chain_cache_size: 256,      // LRU cache for chain lookups
});

Or use the builder. build() validates the settings and reports a rejection as a plain String — its signature is Result<TransformFrameConfig, String>, not a HorusError — so ? alone will not lift it into a function returning horus's Result. Convert it with HorusError::config, or .expect() it at startup:

// Inside a function returning horus's `Result` — map the String into a HorusError.
let config = TransformFrameConfig::custom()
    .max_frames(512)
    .history_len(64)
    .build()
    .map_err(HorusError::config)?;

let hf = TransformFrame::with_config(config);

At startup, where a hard-coded config that fails validation is a programmer error rather than a runtime condition, expect says the same thing in one expression:

let hf = TransformFrame::with_config(
    TransformFrameConfig::custom()
        .max_frames(512)
        .history_len(64)
        .build()
        .expect("transform frame config is valid"),
);

CLI Tools

HORUS provides CLI equivalents of ROS2 tf2 tools:

# List all coordinate frames
horus frame list
horus frame list --json     # JSON output

# Echo transform between frames (like ros2 run tf2_ros tf2_echo)
horus frame echo camera base_link
horus frame echo camera world --rate 10  # 10 Hz

# Show frame tree (like ros2 run tf2_tools view_frames)
horus frame tree

# Get detailed info about a specific frame
horus frame info camera

# Check if transform exists between two frames
horus frame can camera world

# Monitor transform update rates
horus frame hz

Diagnostics

// Get usage statistics
let stats = hf.stats();
println!("Frames: {}/{}", stats.total_frames, stats.max_frames);
println!("Static: {}, Dynamic: {}", stats.static_frames, stats.dynamic_frames);

// Validate frame tree integrity
hf.validate()?;

See Also