Skip to content

Align robot camera frames with state and actions

Join sampled camera frames to the state and action records that accompany them. This example uses the first three episodes of the simulated PushT task. The episodes share one MP4, so episode metadata supplies the video offsets. The output includes frame dimensions, a pixel checksum, the matched state/action coordinates and the timestamp error.

Run it

Run all commands below from the repository root (sail-examples/):

uv run multimodal/align-robot-camera-frames/main.py

The first run downloads about 7.7 MB into multimodal/align-robot-camera-frames/.data/. The dataset revision and every downloaded file's SHA-256 are pinned. No key, model, GPU or robot is needed.

Output

The newest capture appears first. Older output stays here when a result changes.

Current output · Sail 0.7.3

Captured on Sail 0.7.3. Latest capture: 2026-11-05, Python 3.12.14.

+-------------+--------------+--------------+----------------+----------------------+
|episode_index|sampled_frames|matched_frames|unmatched_frames|max_time_error_seconds|
+-------------+--------------+--------------+----------------+----------------------+
|0            |17            |17            |0               |0.0                   |
|1            |12            |12            |0               |0.0                   |
|2            |15            |15            |0               |3.552713678800501E-15 |
+-------------+--------------+--------------+----------------+----------------------+

+-------------+------------+---------------+-----------------+-----+------+-----------+---------------+------------------+------------------+------------------+--------+--------+-------+
|episode_index|sample_index|video_timestamp|episode_timestamp|width|height|frame_index|state_timestamp|time_error_seconds|state_x           |state_y           |action_x|action_y|matched|
+-------------+------------+---------------+-----------------+-----+------+-----------+---------------+------------------+------------------+------------------+--------+--------+-------+
|0            |0           |0.0            |0.0              |96   |96    |0          |0.0            |0.0               |222.0             |97.0              |233.0   |71.0    |true   |
|0            |1           |1.0            |1.0              |96   |96    |10         |1.0            |0.0               |278.6406555175781 |114.0329360961914 |302.0   |137.0   |true   |
|0            |2           |2.0            |2.0              |96   |96    |20         |2.0            |0.0               |348.5765380859375 |190.74172973632812|360.0   |221.0   |true   |
|0            |3           |3.0            |3.0              |96   |96    |30         |3.0            |0.0               |372.2610778808594 |345.48870849609375|363.0   |384.0   |true   |
|0            |4           |4.0            |4.0              |96   |96    |40         |4.0            |0.0               |279.98089599609375|446.0788879394531 |262.0   |449.0   |true   |
|0            |5           |5.0            |5.0              |96   |96    |50         |5.0            |0.0               |256.5018615722656 |395.1748352050781 |259.0   |383.0   |true   |
|0            |6           |6.0            |6.0              |96   |96    |60         |6.0            |0.0               |266.73590087890625|332.57342529296875|268.0   |317.0   |true   |
|0            |7           |7.0            |7.0              |96   |96    |70         |7.0            |0.0               |262.0218200683594 |263.86041259765625|256.0   |257.0   |true   |
|0            |8           |8.0            |8.0              |96   |96    |80         |8.0            |0.0               |254.119140625     |216.7681884765625 |260.0   |191.0   |true   |
|0            |9           |9.0            |9.0              |96   |96    |90         |9.0            |0.0               |162.17274475097656|142.693115234375  |137.0   |158.0   |true   |
+-------------+------------+---------------+-----------------+-----+------+-----------+---------------+------------------+------------------+------------------+--------+--------+-------+
only showing top 10 rows

Python code

main.py306 lines
# /// script
# requires-python = ">=3.12"
# dependencies = [
#   "pysail==0.7.3",
#   "pyspark-client==4.1.3",
#   "pyarrow==26.0.0",
#   "pandas==3.0.6",
#   "av==18.0.0",
#   "numpy==2.5.4",
# ]
# ///
"""Align sampled simulation camera frames with robot state and action records."""

import hashlib
import json
import math
import os
import tempfile
import urllib.request
from pathlib import Path

import pyarrow as pa
from pyspark.sql import SparkSession, Window
from pyspark.sql import functions as F

REVISION = "7628202a2180972f291ba1bc6723834921e72c19"
SOURCE = "https://huggingface.co/datasets/lerobot/pusht"
DATA = "data/chunk-000/file-000.parquet"
EPISODES = "meta/episodes/chunk-000/file-000.parquet"
TASKS = "meta/tasks.parquet"
HASHES = {
    "meta/info.json": "0b46becc21ad92b117da1e95daf44110bb21d0bde75638b4ad167c8ccd959d38",
    DATA: "9abc0431f12d10c33b5b37b1bf1e6e557c8ae1c8c29fb86658969e48fcbbbf01",
    EPISODES: "bc1226f33d3d1635ec1954f9942073709be8e106fde5f4f16b9d52edc4e0ebc4",
    TASKS: "95535f5152771bc0a6ab0fd93d23d7c1fecd9b7a13dc117852dfdd94f5b2c711",
}
VIDEO = "videos/observation.image/chunk-000/file-000.mp4"
HASHES[VIDEO] = "f58d11857651dad5983c021b56a50a68a6ce19068834c1fb5cac099219fb3a78"
EPISODE_LIMIT = 3
TIME_TOLERANCE = 1e-4  # seconds, accommodates this dataset's float32 timestamps


def dataset_file(name):
    """Fetch only allowlisted files; verify downloads and cached copies."""
    expected = HASHES[name]
    path = Path(__file__).resolve().parent / ".data" / REVISION / name
    path.parent.mkdir(parents=True, exist_ok=True)
    if not path.exists():
        temporary = None
        try:
            with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as target:
                temporary = Path(target.name)
                with urllib.request.urlopen(f"{SOURCE}/resolve/{REVISION}/{name}", timeout=60) as response:
                    size = 0
                    while chunk := response.read(1024 * 1024):
                        size += len(chunk)
                        if size > 8_000_000:
                            raise ValueError(f"download exceeds size limit: {name}")
                        target.write(chunk)
            if hashlib.sha256(temporary.read_bytes()).hexdigest() != expected:
                raise ValueError(f"SHA-256 mismatch: {name}")
            os.replace(temporary, path)
        finally:
            if temporary is not None:
                temporary.unlink(missing_ok=True)
    if hashlib.sha256(path.read_bytes()).hexdigest() != expected:
        raise ValueError(f"SHA-256 mismatch: {name}")
    return path


SAMPLE_COLUMNS = (
    "episode_index long, sample_index long, video_timestamp double, "
    "episode_timestamp double, width int, height int, rgb_sha256 string"
)
SAMPLE_SCHEMA = pa.schema(
    [
        ("episode_index", pa.int64()),
        ("sample_index", pa.int64()),
        ("video_timestamp", pa.float64()),
        ("episode_timestamp", pa.float64()),
        ("width", pa.int32()),
        ("height", pa.int32()),
        ("rgb_sha256", pa.string()),
    ]
)


def sample_camera_frames(batches):
    """Decode a path manifest, emitting at most one small row per episode second."""
    import av

    # Fetch where the task runs. Only paths/metadata and small outputs cross stages.
    path = dataset_file(VIDEO)
    for batch in batches:
        for episode in batch.to_pylist():
            start, end = episode["video_start"], episode["video_end"]
            with av.open(str(path)) as container:
                stream = container.streams.video[0]
                container.seek(int(start / stream.time_base), stream=stream, backward=True)
                next_second = 0
                for frame in container.decode(stream):
                    if frame.pts is None:
                        raise ValueError("video frame has no presentation timestamp")
                    video_time = float(frame.pts * stream.time_base)
                    if video_time + 1e-8 < start:
                        continue
                    if video_time >= end - 1e-8:
                        break
                    episode_time = video_time - start
                    if episode_time + 1e-8 < next_second:
                        continue
                    rgb = frame.to_ndarray(format="rgb24")
                    row = {
                        "episode_index": episode["episode_index"],
                        "sample_index": next_second,
                        "video_timestamp": video_time,
                        "episode_timestamp": episode_time,
                        "width": frame.width,
                        "height": frame.height,
                        "rgb_sha256": hashlib.sha256(rgb.tobytes()).hexdigest(),
                    }
                    yield pa.RecordBatch.from_pylist([row], schema=SAMPLE_SCHEMA)
                    next_second = math.floor(episode_time + 1e-8) + 1


def align(frames, samples, fps):
    """Nearest state within half a sample period; ties choose the earlier frame."""
    states = frames.select(
        F.col("episode_index").alias("state_episode"),
        "frame_index",
        F.col("timestamp").cast("double").alias("state_timestamp"),
        F.get(F.col("`observation.state`"), 0).cast("double").alias("state_x"),
        F.get(F.col("`observation.state`"), 1).cast("double").alias("state_y"),
        F.get("action", 0).cast("double").alias("action_x"),
        F.get("action", 1).cast("double").alias("action_y"),
    )
    error = F.abs(F.col("episode_timestamp") - F.col("state_timestamp"))
    candidates = samples.join(
        states,
        (F.col("episode_index") == F.col("state_episode")) & (error <= 0.5 / fps + TIME_TOLERANCE),
        "left",
    ).withColumn("time_error_seconds", error)
    nearest = Window.partitionBy("episode_index", "sample_index").orderBy(
        F.col("time_error_seconds").asc_nulls_last(), F.col("frame_index").asc_nulls_last()
    )
    alignment = (
        candidates.withColumn("rank", F.row_number().over(nearest))
        .filter("rank = 1")
        .select(
            "episode_index",
            "sample_index",
            "video_timestamp",
            "episode_timestamp",
            "width",
            "height",
            "rgb_sha256",
            "frame_index",
            "state_timestamp",
            "time_error_seconds",
            "state_x",
            "state_y",
            "action_x",
            "action_y",
            F.col("frame_index").isNotNull().alias("matched"),
        )
    )
    coverage = alignment.groupBy("episode_index").agg(
        F.count("*").alias("sampled_frames"),
        F.sum(F.col("matched").cast("long")).alias("matched_frames"),
        F.sum((~F.col("matched")).cast("long")).alias("unmatched_frames"),
        F.max("time_error_seconds").alias("max_time_error_seconds"),
    )
    return {
        "alignment": alignment.orderBy("episode_index", "sample_index"),
        "coverage": coverage.orderBy("episode_index"),
    }


def run(spark):
    info = json.loads(dataset_file("meta/info.json").read_text())
    assert info["codebase_version"] == "v3.0" and info["fps"] == 10
    # This small example deliberately handles one named video shard and camera.
    episodes = spark.read.parquet(str(dataset_file(EPISODES))).filter(F.col("episode_index") < EPISODE_LIMIT)
    manifest = episodes.select(
        "episode_index",
        F.col("`videos/observation.image/from_timestamp`").alias("video_start"),
        F.col("`videos/observation.image/to_timestamp`").alias("video_end"),
    ).repartition(2)
    frames = spark.read.parquet(str(dataset_file(DATA))).filter(F.col("episode_index") < EPISODE_LIMIT)
    # Materialize decoded rows once: both outputs reuse them without decoding again.
    import atexit
    import shutil

    temporary = tempfile.mkdtemp(prefix="robot-camera-")
    atexit.register(shutil.rmtree, temporary, ignore_errors=True)
    manifest.mapInArrow(sample_camera_frames, SAMPLE_COLUMNS).write.mode("overwrite").parquet(temporary)
    return align(frames, spark.read.parquet(temporary), info["fps"])


def reference():
    """Sequential decode and a Python nearest-neighbor scan, independent of SQL."""
    import av
    import pyarrow.parquet as pq

    fps = json.loads(dataset_file("meta/info.json").read_text())["fps"]
    episodes = [
        r for r in pq.read_table(dataset_file(EPISODES)).to_pylist() if r["episode_index"] < EPISODE_LIMIT
    ]
    states = pq.read_table(dataset_file(DATA)).to_pylist()
    # Decode the beginning once without seeking or using the worker sampler.
    boundaries = {
        r["episode_index"]: (
            r["videos/observation.image/from_timestamp"],
            r["videos/observation.image/to_timestamp"],
        )
        for r in episodes
    }
    alignment = []
    seen = set()
    with av.open(str(dataset_file(VIDEO))) as container:
        for frame in container.decode(video=0):
            t = float(frame.pts * frame.time_base)
            if t >= max(end for _, end in boundaries.values()) - 1e-8:
                break
            for episode, (start, end) in boundaries.items():
                local = t - start
                if t + 1e-8 < start or t >= end - 1e-8:
                    continue
                second = math.floor(local + 1e-8)
                if (episode, second) in seen:
                    continue
                seen.add((episode, second))
                candidates = [
                    r
                    for r in states
                    if r["episode_index"] == episode
                    and abs(r["timestamp"] - local) <= 0.5 / fps + TIME_TOLERANCE
                ]
                best = (
                    min(candidates, key=lambda r: (abs(r["timestamp"] - local), r["frame_index"]))
                    if candidates
                    else None
                )
                alignment.append(
                    {
                        "episode_index": episode,
                        "sample_index": second,
                        "video_timestamp": t,
                        "episode_timestamp": local,
                        "width": frame.width,
                        "height": frame.height,
                        "rgb_sha256": hashlib.sha256(frame.to_ndarray(format="rgb24").tobytes()).hexdigest(),
                        "frame_index": best["frame_index"] if best else None,
                        "state_timestamp": best["timestamp"] if best else None,
                        "time_error_seconds": abs(best["timestamp"] - local) if best else None,
                        "state_x": best["observation.state"][0] if best else None,
                        "state_y": best["observation.state"][1] if best else None,
                        "action_x": best["action"][0] if best else None,
                        "action_y": best["action"][1] if best else None,
                        "matched": best is not None,
                    }
                )
    alignment.sort(key=lambda r: (r["episode_index"], r["sample_index"]))
    coverage = []
    for episode in sorted(boundaries):
        rows = [r for r in alignment if r["episode_index"] == episode]
        errors = [r["time_error_seconds"] for r in rows if r["matched"]]
        coverage.append(
            {
                "episode_index": episode,
                "sampled_frames": len(rows),
                "matched_frames": len(errors),
                "unmatched_frames": len(rows) - len(errors),
                "max_time_error_seconds": max(errors) if errors else None,
            }
        )
    schema = pa.schema(
        [
            *SAMPLE_SCHEMA,
            ("frame_index", pa.int64()),
            ("state_timestamp", pa.float64()),
            ("time_error_seconds", pa.float64()),
            ("state_x", pa.float64()),
            ("state_y", pa.float64()),
            ("action_x", pa.float64()),
            ("action_y", pa.float64()),
            ("matched", pa.bool_()),
        ]
    )
    return {"alignment": pa.Table.from_pylist(alignment, schema=schema), "coverage": coverage}


if __name__ == "__main__":
    from pysail.spark import SparkConnectServer

    server = SparkConnectServer()
    server.start()
    _, port = server.listening_address
    spark = SparkSession.builder.remote(f"sc://localhost:{port}").getOrCreate()
    try:
        results = run(spark)
        results["coverage"].show(truncate=False)
        results["alignment"].drop("rgb_sha256").show(10, truncate=False)
    finally:
        spark.stop()
        server.stop()

How it works

  1. Read the state/action Parquet and the first three episodes' metadata.
  2. Pass episode IDs and their video start/end offsets to mapInArrow. Each task fetches the pinned MP4, seeks to its episode and samples the first frame at or after each whole episode second. PyAV decodes the video on CPU.
  3. Emit one small record per sample, including presentation time, dimensions and a SHA-256 of the decoded RGB pixels. Video bytes and pixel arrays stay inside the task. Save these records to temporary Parquet once, so downstream outputs reuse them without decoding again.
  4. Subtract the episode's video offset to obtain episode-relative timestamps. Join to state rows in that episode within half a 10 Hz state period, plus 0.0001 seconds for float timestamp rounding. Rank candidates by absolute time error, breaking ties with the earlier frame index.

alignment preserves samples with no qualifying state as unmatched rows, with null state/action values. coverage reports sampled, matched and unmatched counts and the largest matched timestamp error for each sampled episode. A decoding failure fails the job; an episode that yields no sampled frames contributes no rows. This is not a completeness check for all camera data in the dataset.

The half-period bound is an example matching rule for this synchronized dataset. It does not estimate clock drift, interpolate state, calibrate sensors or prove that independently recorded sensors are synchronized. A checksum identifies the decoded pixels; it is not a visual quality score. The example prepares offline data and does not control a robot.

See curate robot episodes for structural checks and a whole-episode selection manifest.

Data and verification

The PushT dataset, credited to the Diffusion Policy project, declares the MIT license in its dataset card. This example pins revision 7628202a2180972f291ba1bc6723834921e72c19 and handles its one named video shard, one camera and first three episodes.

Per the LeRobot v3 format documentation, multiple episodes can share a video file. Use metadata offsets rather than treating a file as an episode. The download cache is local to this example; multi-machine use needs an appropriate worker-accessible cache or object store.

The independent reference decodes the beginning of the video sequentially, without the worker sampler's seeks, and matches states with a Python scan. The maintenance harness checks the returned rows, pixel checksums and coverage against that reference.