Guides

Episode export

Bundle a session into rgb.mp4 + mesh.ply + thumbnail + annotation.hdf5 + calibrations + visualization.rrd in one call.

The call

session.export(out_dir, visualizer=None, **kwargs) -> dict

Writes a complete episode directory and returns a {"saved": [...], "skipped": [...]} manifest. The same manifest is logged at INFO level so you can see what landed even when ignoring the return value.

session.export("episodes/run_01", visualizer=viz)

What it writes

rgb.mp4
mesh.ply
thumbnail.jpg
visualization.rrd
annotation.hdf5
rgb_K.npy
rgb_D.npy
depth_K.npy
depth_D.npy
R_optical_to_link.npy
meta.json

See Episode layout for the file-by-file breakdown and HDF5 schema for the annotation.hdf5 reference.

How frames + annotations get into the export

The export pulls from two buffers populated during your loop:

for frame in session.frames():
    blurred = blur.blur(frame)
    hands   = tracker.detect_hands(frame)

    session.add_rgb_frame(frame.index, blurred)   # → rgb.mp4
    session.add_hand_pose(frame.index, hands)     # → annotation.hdf5:/hand-pose
  • add_rgb_frame, lazily opens an internal H.264 writer; the resulting mp4 is finalised and moved into the episode directory at export time. Use this when you want a post-processed (e.g. blurred) video instead of the raw RGB stream from the mcap.
  • add_hand_pose, accumulates per-frame HandPose lists keyed by frame index. Skip this if you don't run hand detection.

Neither is mandatory. If you don't call add_rgb_frame, the export does its own pass over session.frames() to write the raw rgb.mp4. If you don't call add_hand_pose, /hand-pose is simply absent from the HDF5 (logged as skipped).

Stage logs

The export emits one INFO log per stage, plus a tqdm progress bar on the frame pass:

INFO  Exporting episode to episodes/run_01 (frames=13647, duration=911.3s, fps=14.97)
INFO  Opening rgb.mp4 writer (1280x720 @ 14.97 fps)
INFO  Opening annotation.hdf5
INFO  Streaming frames to rgb.mp4 / depth / thumbnail (n=13647)
Export frames: 100%|█████████| 13647/13647 [01:14<00:00, 184.21fr/s]
INFO  Finalizing rgb.mp4 (waiting for ffmpeg)
INFO  Writing thumbnail.jpg
INFO  Writing mesh.ply
INFO  Writing calibrations/
INFO  Writing /cam-pose
INFO  Writing /imu
INFO  Writing /metadata
INFO  Episode written to episodes/run_01
INFO    saved (8): rgb.mp4, annotation.hdf5:/depth, thumbnail.jpg, ...
INFO    skipped (1): annotation.hdf5:/hand-pose (no session.add_hand_pose calls)

Enable logs once at the top of your script:

import stera
stera.setup_logging()

Kwargs

session.export(
    out_dir,
    visualizer=None,
    skip_rgb_mp4=False,
    skip_thumbnail=False,
    thumbnail_rgb=None,
)
KwargDefaultNotes
visualizerNoneAn already-populated Visualizer. Its .rrd is promoted to <out_dir>/visualization.rrd.
skip_rgb_mp4FalseSet if you wrote rgb.mp4 yourself somewhere else.
skip_thumbnailFalseSkip the JPEG thumbnail.
thumbnail_rgbNonePre-computed thumbnail (RGB ndarray); used when skip_thumbnail=False. Overrides the mid-frame default.

When you streamed frames via session.add_rgb_frame, the export auto-detects this and skips its own rgb.mp4 pass. You don't need skip_rgb_mp4=True in that case.

Patterns

Minimal, no hands, no viz

session = MCAPReader("recording.mcap")
session.export("episodes/run_01")

Produces rgb.mp4, mesh.ply (if available), thumbnail.jpg, annotation.hdf5 (with /depth /cam-pose /imu /metadata), and calibrations/.

Full pipeline

import stera
from stera.data import MCAPReader
from stera.models import HandTracker, FaceBlurrer, UpperBodyEstimator
from stera.viz import Visualizer

stera.setup_logging()

session    = MCAPReader("recording.mcap")
tracker    = HandTracker(model="wilor", model_path="/opt/WiLoR")
blur       = FaceBlurrer(model="egoblur", model_path="/opt/EgoBlur")
skeleton   = UpperBodyEstimator(session=session)
visualizer = Visualizer(session, map_3d="auto")

for frame in session.frames():
    blurred  = blur.blur(frame)
    hands    = tracker.detect_hands(frame)
    body     = skeleton.estimate(frame, hands=hands)

    session.add_rgb_frame(frame.index, blurred)
    session.add_hand_pose(frame.index, hands)
    visualizer.log_frame(frame, hands=hands, skeleton=body)

session.export("episodes/run_01", visualizer=visualizer)

Use a custom thumbnail

import cv2
custom = cv2.cvtColor(cv2.imread("title-card.png"), cv2.COLOR_BGR2RGB)
session.export("episodes/run_01", thumbnail_rgb=custom)

Append your own data after export

The HDF5 file is closed cleanly by session.export. Re-open it and add your own groups:

import h5py
session.export("episodes/run_01")

with h5py.File("episodes/run_01/annotation.hdf5", "a") as f:
    grp = f.create_group("my_annotations")
    grp.create_dataset("foo", data=...)

session.export never throws for missing upstream data. It writes what it can and skips the rest, with reasons in the manifest. Treat the return dict as the source of truth for what made it into the directory.

Pair with Evaluate

Evaluate reads from the same session and is the natural companion to session.export — one writes the training-ready bundle, the other writes the QC report you'd attach to it. The most common end-of-loop in practice:

session.export("episodes/run_01", visualizer=viz)
Evaluate(session).show()                # opens an HTML report in the browser
# or: Evaluate(session).export("episodes/run_01/report.html")

Evaluate picks up hand poses from session.add_hand_pose automatically and produces a 0–100 health score plus charts for trajectory / IMU / depth / sync / hands. See the Evaluate landing page for the report layout and EvaluateConfig for tuning thresholds.

See also