Knonik Ingest
This guide explains how to use Knonik ingest from the wheel-based Knonik package.
You can use ingest in two ways:
- ▸Terminal:
knonik ingest ...andknonik pack ... - ▸Python:
knonik.ingest.run(...)andknonik.ingest.pack(...)
1. What Ingest Does
Knonik ingest converts raw robot datasets into Knonik compressed episode directories. Those episode directories can then be packed into KHLP shards for the Knonik multidataloader.
Supported source families include:
- ▸HDF5
- ▸NPY / NPZ
- ▸Zarr (per-episode or consolidated)
- ▸LeRobot v2
- ▸LeRobot v3
- ▸ROS bags — ROS1
.bagand ROS2 MCAP / SQLite3 (.mcap,.db3, or a ROS2 bag directory) - ▸RLDS / TFRecord, when the required optional dependencies are available
- ▸D3IL
Unsupported format? You do not have to wait for a built-in reader. You can ingest any format into Knonik using one of two generic adapters — an offline one (
dir_type: "npy_separate") and a streaming one (live ingest). See section 7.
The normal workflow is:
- ▸Install the Knonik wheel.
- ▸Log in once with an account entitled for
ingest. - ▸Run offline ingest.
- ▸Pack the ingested episodes into KHLP shards.
- ▸Train with the Knonik multidataloader.
Need to go the other way? The Export guide turns Knonik data back into HDF5, Zarr, LeRobot, ROS bag / MCAP, and more.
2. Install
Create or activate the Python environment you want to use:
python -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
Install the wheel provided by Knonik:
python -m pip install /path/to/knonik-0.1.0-cp311-cp311-manylinux*.whl
3. Log In
Log in once before using ingest:
knonik login --product ingest
On headless Linux machines without an OS keyring, use the explicit file-key fallback:
knonik login --product ingest --allow-file-key-store
Check status:
knonik status --product ingest
Log out:
knonik logout --product ingest
4. Offline Ingest From Terminal
Run offline ingest with:
knonik ingest \
--config /path/to/ingest_config.json \
--input /path/to/raw_dataset \
--output /path/to/output_dir
The --config file describes your dataset — its on-disk layout, which keys hold
each camera/state/action stream, and how to compress them. See
section 6 for how to write one.
config files usually have data_dir in them, the --input flag overrides this. This helps in using the same config across multiple machines without any local dir and path issues.
Example:
knonik ingest \
--config /home/me/configs/config_lerobot_v3.json \
--input /home/me/data/thread_velcro \
--output /home/me/data/out/knonik_thread_velcro
The dataset config ingest leaves behind
Every run finishes by writing a dataset config to
<output>/config.json — a different file from the ingest config you passed in,
describing the episodes that came out rather than the raw data that went in. It
is what the quality agent, the visualizer, and auto-segmentation read. Ingest
fills in everything it can prove (stream names, widths, codecs, frame rate,
timestamp semantics, and per-channel joint names where the source declares
them) and marks the semantic parts — which stream is the action, where the arms
are — with <FILL:...> placeholders for you to complete. See
Processing §6.
An existing config.json in the output directory is never overwritten. Add
--force-config to regenerate it:
knonik ingest --config ingest.json --input /raw --output /out --force-config
5. Pack Into KHLP From Terminal
Default packing: After ingest writes episode directories and you have processed it (quality check, visualize and annotation), pack them for the dataloader:
knonik pack \
--input-dir /path/to/output_dir/knonik_dataset \
--output-dir /path/to/output_dir \
--dataset-name my_dataset \
--shard-size 45 \
--window-size 64 \
--fps 30
Example:
knonik pack \
--input-dir /home/me/data/out/knonik_thread_velcro \
--output-dir /home/me/data/out \
--dataset-name thread_velcro \
--shard-size 45 \
--window-size 64 \
--fps 50
This writes KHLP files and hlp_manifest.json into a packed output directory.
The manifest path is the main input to ShardedDataLoader.
FOR ADVANCED PACKERS (OPTIMIZED FOR SPECIFIC USE CASES, SEE THE CONFIGS BELOW)
Storage Profiles
The packer can store RGB differently depending on the training workload. This is a pack-time transcode: you do not need to re-ingest the raw data to try a different storage profile.
storage_profile | Best for | Tradeoff |
|---|---|---|
compact_video | Small storage and sequential access | Sparse random access may decode extra frames around the requested one. |
training_fast | High-throughput random training | Larger on disk than the other profiles. |
training_compressed | Balanced storage and sparse random access | Smaller on disk; see the dataloader guide for the matching loader settings. |
Use compact_video (the default) for traditional video-shard behavior. Use
training_fast for the fastest random-access training reads. Use
training_compressed when frames are redundant and you want a smaller artifact
with good sparse-access throughput.
Selecting a Storage Profile
knonik pack always uses the default compact_video profile. To
pack with training_fast or training_compressed, use the packer module (which
exposes --storage-profile) or the Python API (section 9):
python -m knonik_ingest pack \
--input-dir /datasets/my_dataset_ingested \
--output-dir /datasets/my_dataset_fast \
--dataset-name my_dataset \
--shard-size 30 \
--window-size 64 \
--fps 50 \
--pack-mode shard_batch \
--storage-profile training_fast # or training_compressed
Quick Manifest Check
You can inspect the packed manifest without loading the dataset:
python - <<'PY'
import json
from pathlib import Path
data = json.loads(Path("/datasets/my_dataset_fast/hlp_manifest.json").read_text())
for key in ("dataset", "shard_size", "fps", "pack_mode", "storage_profile", "num_hlps"):
if key in data:
print(f"{key}: {data[key]}")
PY
A packed manifest looks like:
{
"dataset": "my_dataset",
"shard_size": 30,
"fps": 50,
"pack_mode": "shard_batch",
"storage_profile": "training_fast",
"num_hlps": 12,
"hlps": [ "..." ]
}
Packer options:
| Option | Default | Meaning |
|---|---|---|
--pack-mode | shard_batch | Recommended packing mode for training. standard is also available. |
--shard-size | 45 | Frames per shard. Choose at least as large as the longest training window you need. |
--window-size | 64 | Episode shuffle window used while packing. |
--dataset-name | "" | Name stored in the manifest and batch metadata. |
--fps | 30 | FPS stored in the manifest. |
--seed | 0 | RNG seed for deterministic packing. |
--storage-profile | compact_video | compact_video, training_fast, or training_compressed. Codec/quality/format internals are chosen automatically per profile. Available on python -m knonik_ingest pack and the Python API. |
6. Ingest Config
The config tells ingest what your raw dataset looks like: its on-disk
layout, which keys hold each camera / state / action stream, and how aggressively
to compress them. Pass it with --config (terminal) or as the config=
argument (Python). Config files may be written as JSON or YAML.
6.1 Writing a Config For Your Own Dataset
A config is made of a few top-level blocks. At a glance:
| Block | Required | What it does |
|---|---|---|
dir_type | yes | How episodes are laid out on disk (selects the reader). |
data_type | for episodic | The file format inside each episode directory. |
data_dir | yes¹ | Path to your raw dataset. |
fps | no | Default frame rate for image/video streams. |
metadata | no | Free-text dataset name, robot type, and description. |
layout | yes | Maps source keys → output streams (cameras, state, action, …). |
preset | no | Compression settings (defaults to medium). |
¹ data_dir is optional if you always pass --input, which overrides it.
Step 1 — Pick dir_type
dir_type selects the reader for your dataset's on-disk layout:
dir_type | Use for |
|---|---|
lerobot_v2 / lerobot_v3 | LeRobot datasets. |
zarr | Zarr stores (per-episode or consolidated). |
rosbag / mcap | ROS bag recordings — ROS1 .bag, ROS2 .mcap / .db3, or a ROS2 bag directory. mcap is an alias for the same reader. |
rlds / tfrecord | RLDS / TFRecord (needs optional dependencies). |
d3il | D3IL datasets. |
episodic | A directory of per-episode files — also set data_type below. |
npy_separate | Generic offline adapter for any other format (section 7). |
For episodic, add data_type to say what is inside each episode directory:
data_type | Meaning |
|---|---|
hdf5 / npy / zarr | Read that file format directly. |
lerobot_v2 / lerobot_v3 / rosbag / mcap / rlds | Route to that format reader. |
episodicfile naming. When reading a format directly, ingest globsdata_dirfor a fixed pattern — anything named differently is invisible:
data_typeExpects, directly inside data_dirhdf5episode_*.h5orepisode_*.hdf5npyepisode_*.npz(one npz archive per episode, holding the arrays)zarrepisode_<n>/directories, with an integer<n>— sorted numericallyEpisodes are ordered by that glob, so
episode_0…episode_9sort beforeepisode_10only forzarr; zero-pad your names (episode_000007) if the order matters for HDF5/NPZ.
Step 2 — Map your streams with layout
layout is the heart of the config: it tells ingest which keys in your source
data become which output streams. The entry name is the output stream name you
choose; the value is the source path in your raw data.
layout key | Shape | Maps |
|---|---|---|
images | "<name>": {"key": "<source path>", "fps": N} | One entry per RGB camera. |
numeric | "<name>": "<source path>" | State / action / qpos / … streams. |
depth | "<name>": {"key": "<source path>", "fps": N} | Depth streams (optional). |
timestamps | "<source path>" | Timestamp stream (optional; omit to use frame index). |
Source paths use dot notation to address nested keys:
| Source path | Config key |
|---|---|
HDF5 observations/images/top | "observations.images.top" |
Zarr camera_0/rgb | "camera_0.rgb" |
Top-level action | "action" |
ROS topic /camera/rgb/image_raw | "/camera/rgb/image_raw" |
LeRobot naming. For
lerobot_v2/lerobot_v3, image keys are the bare camera name (e.g."cam_high", not"observation.images.cam_high"). These readers expose exactly two numeric streams — the primary proprioception column as"state"and the primary command column as"action"— so the values inlayout.numericmust be"state"or"action"(the names on the left are yours to choose). Omitlayoutentirely to take every camera plusstate/actionas-is.
How much layout each reader uses
layout means slightly different things depending on which reader dir_type
selects. This is the single most common source of confusion, so check your row
before writing the block:
dir_type | Source keys are… | layout behaviour |
|---|---|---|
episodic + hdf5 / npy / zarr | dot-paths into the file (observations.images.top) | Required. Nothing is ingested unless you map it. Depth key is layout.depth. |
zarr (whole-store) | dot-paths into the store (camera_0.rgb) | Optional but recommended — with a layout the reader addresses named arrays directly and supports any number of numeric streams. |
lerobot_v2 / lerobot_v3 | bare camera names; state / action | Optional. Filters and renames. Omit → all cameras + state + action. |
rosbag / mcap | auto-derived names from ROS topics | Cameras only (layout.images, depth under layout.depths). layout.numeric is ignored — see section 6.2. |
rlds / tfrecord / d3il | camera names from the dataset spec | Optional. Filters and renames cameras; numeric is state / action. |
npy_separate | staged file paths | Not used — use a streams block instead (section 7). |
Unknown names are skipped, not errors. For the format readers (everything except
episodicandnpy_separate), alayout.imagesentry whosekeyis not present in the data is silently dropped — you get an episode without that camera rather than a failure. If a stream is missing from the output, check the spelling againstepisode_000000/metadata.json(see below) before anything else.
Check what you actually got
Every ingested episode records the stream names it wrote. After a short trial run
(add "episode_limit": 1 if your dir_type is a format reader, or just point
--input at a copy holding one episode), read them back:
python -c "import json;m=json.load(open('OUT/episode_000000/metadata.json'));print(m['rgb_streams'],m['depth_streams'],m['numeric_streams'])"
Those exact names are what you feed to the processing dataset config and what the dataloader requests, so it is worth doing before you ingest a thousand episodes.
Step 3 — Set compression with preset
Either name a built-in preset ("high", "medium", …) or give an explicit
{rgb, depth, numeric} dict. It defaults to medium. See
section 8 for every knob.
Putting it together
A minimal episodic-HDF5 config using all of the above:
{
"dir_type": "episodic",
"data_type": "hdf5",
"data_dir": "/home/me/data/raw_bimanual",
"fps": 50,
"metadata": {
"dataset": "bimanual_transfer",
"robot_type": "bimanual",
"layout": "two camera streams plus state/action"
},
"preset": {
"rgb": {"codec": "vp9", "quality": 18, "gop": 3},
"depth": {"quantization_mm": 1},
"numeric": {"level": 1}
},
"layout": {
"images": {
"cam_high": {"key": "observations.images.cam_high", "fps": 50},
"cam_left": {"key": "observations.images.cam_left", "fps": 50}
},
"numeric": {
"state": "observations.qpos",
"action": "action"
},
"timestamps": "timestamps"
}
}
Full field reference
| Field | Required | Meaning |
|---|---|---|
dir_type | yes | Dataset layout: episodic, lerobot_v2, lerobot_v3, zarr, rosbag, mcap, rlds, tfrecord, d3il, or npy_separate. |
data_type | for episodic | Source format inside an episodic directory: hdf5, npy, or zarr (read directly), or lerobot_v2 / lerobot_v3 / rosbag / mcap / rlds (routed to that format reader). |
data_dir | yes unless --input is used | Source dataset path on the local machine. |
preset | no (default medium) | Compression preset as a dict or a named preset. |
preset_overrides | no | Per-section overrides deep-merged over a named preset, e.g. {"rgb": {"gop": 15}}. |
fps | no | Default FPS for image/video streams. |
episode_limit | no | Stop after N episodes — for smoke tests. Honoured by the format readers (lerobot_*, zarr, rosbag/mcap, rlds, tfrecord, d3il); ignored by episodic direct reads and npy_separate. |
metadata.dataset | no | Dataset name written into metadata. |
metadata.robot_type | no | Free-text robot identifier. |
metadata.layout | no | Free-text description of the streams. |
layout.images | no | RGB image streams. |
layout.depth | no | Depth streams. |
layout.numeric | no | Numeric streams such as state/action. |
layout.timestamps | no | Timestamp stream path if available. |
language | no | Language-annotation control (null=auto, false=skip, or a dict). |
6.2 ROS Bags and MCAP
ROS recordings are the one family where you mostly do not write a layout:
the reader walks the bag's topics and derives the streams itself. One reader
handles all three container formats — ROS1 .bag, ROS2 .mcap, ROS2 .db3 —
and auto-detects which it is. dir_type: "mcap" is an explicit alias for
dir_type: "rosbag"; they behave identically. ROS does not need to be installed
(the wheel bundles rosbags).
Step 1 — Point data_dir at the right level
data_dir may be either a single bag (one episode) or a parent directory
holding many bags (one episode each). Both of these are single bags:
recording.bag ← ROS1 bag file
my_ros2_bag/ ← ROS2 bag directory
├── metadata.yaml
└── my_ros2_bag_0.mcap
And both of these are multi-episode parents — point data_dir at the top:
airoa-moma-raw/ ← one subdirectory per episode
├── episode_0001/
│ └── data.bag
├── episode_0002/
│ └── data.bag
└── …
loose_bags/ ← or loose bag files side by side
├── run_01.mcap
├── run_02.mcap
└── run_03.mcap
The episode id is the subdirectory name (episode_0001) or the file stem
(run_01). Discovery order is: single bag → one bag per subdirectory → loose
.bag / .mcap / .db3 files in the directory. If none of those match, ingest
raises No ROS bags found at ….
Step 2 — Know how topics become streams
Every connection in the bag is classified by message type first, topic name second:
| Message type | Becomes | Stream name |
|---|---|---|
sensor_msgs/Image, CompressedImage with a colour encoding (rgb8, bgr8, rgba8, mono8, JPEG/PNG) | RGB stream | camera name derived from the topic |
The same types with a depth encoding (16UC1, 32FC1, mono16, compressedDepth) | Depth stream, converted to metres float32 | camera name derived from the topic |
JointState, Imu, Odometry, Pose, PoseStamped, Twist, Wrench, WrenchStamped, JointTrajectory, ServoState, LaserScan | One numeric stream per topic | sanitized full topic path |
CameraInfo | Episode metadata (camera_info intrinsics), not a stream | — |
PointCloud2, CustomMsg (e.g. Livox), TFMessage, DiagnosticArray, Clock, String | Skipped — variable-length or non-numeric | — |
| Anything else | Skipped | — |
Naming rules:
- ▸Cameras drop the boilerplate path parts (
image_raw,image,image_rect,image_rect_color,rgb,color,rect,depth,depth_registered,compressed,msg) and join what is left with_:/camera/color/image_raw→camera,/left_cam/rgb/image_raw→left_cam,/head/zed/left/image_rect_color→head_zed_left. - ▸Numeric streams keep the whole topic path, with non-alphanumeric runs
collapsed to
_:/joint_states→joint_states,/arm_controller/command→arm_controller_command,/robot/gripper/target→robot_gripper_target.
A stream whose first message cannot be decoded (RVL-compressed depth, for example) is skipped rather than failing the run, so a missing depth stream in the output usually means "not decodable", not "misconfigured".
Each numeric topic stays its own stream — nothing is concatenated into one
opaque state vector, so you can pick out exactly the topic you want later. The
vector width is fixed from the first message on that topic (later messages are
padded or truncated to match), and the extracted fields are the obvious ones:
JointState → position, Imu → linear acceleration + angular velocity +
orientation quaternion, Odometry → position + orientation + twist,
Pose/PoseStamped → position + quaternion, Twist → linear + angular,
Wrench → force + torque, JointTrajectory → the first point's positions.
Step 3 — Understand the frame clock
ROS topics are asynchronous; Knonik episodes are frame-aligned. The reader picks the camera with the most messages as the clock, then samples every other stream at its nearest timestamp for each of those frames. Consequences worth knowing before you ingest:
- ▸A bag with no decodable camera produces no episode and is skipped. Numeric topics alone are not enough to anchor frames.
- ▸Frame timestamps are the bag's own nanosecond stamps, in seconds — you do not
need
layout.timestamps. - ▸A slow topic (say 10 Hz odometry against a 30 Hz camera) is held at its last value between updates rather than interpolated.
- ▸
fpsin the config is metadata for the video encoder, not resampling. Set it near your real camera rate.
For a first pass over a huge bag, cap frames per episode with an environment variable:
KNONIK_ROSBAG_MAX_FRAMES=200 knonik ingest --config config_rosbag.json --input /bags --output /out
Step 4 — Write the config
A ROS config is short, because there is nothing to map:
{
"dir_type": "rosbag",
"data_dir": "/data/airoa-moma-raw",
"preset": "medium",
"fps": 15,
"metadata": {
"dataset": "moma_pick_place",
"robot_type": "mobile_manipulator"
}
}
The MCAP alias, pointed at a single ROS2 bag directory, with a cheaper preset for a first look:
{
"dir_type": "mcap",
"data_dir": "/data/ros2_bags/session_04",
"preset": "low",
"fps": 30,
"episode_limit": 2,
"metadata": {
"dataset": "session_04_smoke_test"
}
}
Keeping only two of six cameras — the one thing layout is good for here. The
key is the derived camera name from step 2, and the name on the left is
what the stream is called in the output:
{
"dir_type": "rosbag",
"data_dir": "/data/bags",
"preset": "high",
"fps": 30,
"metadata": {
"dataset": "two_cam_subset",
"robot_type": "ur5e"
},
"layout": {
"images": {
"cam_head": {"key": "head_zed_left", "fps": 30},
"cam_wrist": {"key": "wrist_cam", "fps": 30}
},
"depths": {
"cam_head_depth": {"key": "head_zed", "fps": 30}
}
}
}
Two traps in that example:
- ▸Depth is filtered under
layout.depths(plural) for ROS and the other format readers —layout.depth(singular) is theepisodicspelling. - ▸
layout.numericdoes nothing for ROS bags. Every numeric topic is ingested under its sanitized topic name whether you list it or not. To use only some of them, select the streams you want downstream (in the processing dataset config and in the dataloader), or record a narrower bag.
Step 5 — Read back the names ROS gave you
Because the names are derived rather than declared, always check them after the first episode. ROS ingest additionally records the raw mapping in the episode metadata:
python -c "import json;m=json.load(open('/out/episode_000000/metadata.json'));print(m['ros_cameras'],m['ros_depth_streams'],m['ros_numeric_streams'],m['source_bag'])"
ros_numeric_streams maps each stream name to its width — exactly what you need
to fill in arms.indices in the processing
dataset config. camera_info holds the
intrinsics captured from any CameraInfo topics.
6.3 Examples
Complete, working configs for the common source families.
Episodic HDF5 — ALOHA bimanual. A directory of per-episode HDF5 files: one
top camera, plus qpos, qvel, and action numeric streams nested under
observations. Note how each layout.numeric entry renames a source path to a
clean output stream name:
{
"dir_type": "episodic",
"data_type": "hdf5",
"preset": {
"rgb": {"codec": "vp9", "quality": 15, "gop": 3},
"depth": {"quantization_mm": 1},
"numeric": {"level": 1}
},
"fps": 50,
"data_dir": "/data/input",
"metadata": {
"dataset": "bimanual_transfer",
"robot_type": "aloha_bimanual",
"layout": "qpos(14) + qvel(14) + action(14) + cam_top"
},
"layout": {
"images": {
"top": {"key": "observations.images.top", "fps": 50}
},
"numeric": {
"qpos": "observations.qpos",
"qvel": "observations.qvel",
"actions": "action"
}
}
}
LeRobot v3 — four cameras. A LeRobot v3 dataset with four cameras and
state/action. Because this is a LeRobot reader, the image keys are the bare
camera names and the primary proprioception/command columns use the logical
state / action names; a timestamp stream is also mapped. A dataset carrying
extra columns would name them directly — "qvel": "observation.qvel" — or drop
layout.numeric to take every numeric column as-is:
{
"dir_type": "lerobot_v3",
"preset": {
"rgb": {"codec": "vp9", "quality": 15, "gop": 30},
"depth": {"quantization_mm": 1},
"numeric": {"level": 1}
},
"fps": 50,
"data_dir": "/data/input",
"metadata": {
"dataset": "thread_velcro",
"robot_type": "unknown",
"layout": "state(14) + action(14) + cam_high + cam_low + cam_left_wrist + cam_right_wrist"
},
"layout": {
"images": {
"cam_high": {"key": "cam_high", "fps": 50},
"cam_low": {"key": "cam_low", "fps": 50},
"cam_left_wrist": {"key": "cam_left_wrist", "fps": 50},
"cam_right_wrist": {"key": "cam_right_wrist", "fps": 50}
},
"numeric": {
"state": "state",
"action": "action"
},
"timestamps": "timestamp"
}
}
Episodic Zarr — per-episode stores. A directory of per-episode Zarr stores.
Source paths are dot-separated group paths inside the store, so the nested array
camera_0/rgb is written "camera_0.rgb". Four numeric arrays are mapped
one-to-one:
{
"dir_type": "episodic",
"data_type": "zarr",
"data_dir": "/data/pickNplace",
"preset": "high",
"metadata": {
"dataset": "pickNplace",
"robot_type": "dexterous_hand",
"layout": "hand_action + pose + proprioception + fsr + camera_0"
},
"layout": {
"images": {
"camera_0": {"key": "camera_0.rgb", "fps": 30}
},
"numeric": {
"hand_action": "hand_action",
"pose": "pose",
"proprioception": "proprioception",
"fsr": "fsr"
}
}
}
Episodic NPY — CALVIN-style, with depth. Per-episode .npy files, two RGB
cameras and two depth cameras. Note that the episodic reader spells depth
layout.depth (singular), and that preset: "ultra_low" is a near-lossless
choice for iterating on a small slice:
{
"dir_type": "episodic",
"data_type": "npy",
"data_dir": "/data/calvin",
"preset": "ultra_low",
"metadata": {
"dataset": "calvin_debug",
"layout": "actions + rel_actions + robot_obs + scene_obs, 2 rgb + 2 depth"
},
"layout": {
"images": {
"rgb_static": {"key": "rgb_static", "fps": 30},
"rgb_gripper": {"key": "rgb_gripper", "fps": 30}
},
"depth": {
"depth_static": {"key": "depth_static", "fps": 30},
"depth_gripper": {"key": "depth_gripper", "fps": 30}
},
"numeric": {
"actions": "actions",
"rel_actions": "rel_actions",
"robot_obs": "robot_obs",
"scene_obs": "scene_obs"
}
}
}
ROS bag / MCAP. See section 6.2 — these need no
layout at all.
6.4 Troubleshooting A Config
| Symptom | Cause | Fix |
|---|---|---|
| A camera or numeric stream is missing from the output | For format readers, a layout key that doesn't exist is silently skipped | Compare against episode_000000/metadata.json (rgb_streams / numeric_streams) from a 1-episode run |
episodic run produces empty episodes | layout is required for episodic — nothing is ingested unless mapped | Add layout.images / layout.numeric with dot-path keys |
| No episodes at all from a ROS bag | The bag has no decodable camera topic, so there is no frame clock | Check the bag has an Image / CompressedImage topic in a supported encoding |
No ROS bags found at … | data_dir is one level off | Point it at the bag, or at the parent holding one bag per subdirectory |
| Depth ingested but empty | Depth filter key is layout.depth for episodic, layout.depths for format readers | Use the spelling for your dir_type |
| Streams present but frame counts differ | Episode length is the minimum across streams | Align your streams, or accept the truncation |
An episodic dataset ingests zero episodes | Files don't match the expected episode_* glob for that data_type | Rename to episode_*.h5 / episode_*.npz / episode_<n>/ |
| Ingest is slow on a first look | Full-quality preset over every episode | Use "preset": "low", plus "episode_limit": 2 on the format readers |
7. Converting Any Format Into Knonik
When no built-in reader recognizes your dataset, you convert it yourself with one of two generic adapters. Both feed the same ingest engine as the built-in readers, so the compressed episodes and KHLP shards they produce are identical in every way — you are only responsible for handing Knonik the raw arrays.
- ▸Offline adapter (
dir_type: "npy_separate") — you first write each stream to disk as.npyfiles, then run a normal ingest over that staged directory. Best when the data already fits on disk and you want a simple, re-runnable step. - ▸Streaming adapter (live ingest) — you push frames into ingest one at a time as plain numpy arrays, with no staging. Best when data arrives serially, when you have many episodes, or when staging the whole dataset first is impractical.
Whichever you use, the frame contract is the same:
| Stream | dtype | Shape (per frame) | Notes |
|---|---|---|---|
| RGB | uint8 | (H, W, 3) | One image per frame. |
| Depth | float32 | (H, W) | Metres. Optional. |
| Numeric | float32 | (D,) | State / action / qpos / … one vector per frame. |
| Timestamps | float64 | scalar per frame | Epoch seconds. Optional — omit to use the frame index. |
The number of frames ingested for an episode is the minimum length across all of its streams, so make sure your streams are aligned before you ingest.
Offline adapter — stage streams as .npy
Stage each stream on disk, then point an npy_separate config at the staged
files. RGB and depth streams are a directory of per-frame files named with a
printf-style pattern (indices from 0); numeric streams are a single
(T, D) array; timestamps are a single (T,) array.
The script below reads one episode from your own format, stages it, and ingests
it. Replace read_my_episode with your loader — everything else is generic:
import numpy as np
from pathlib import Path
from knonik.ingest import run
STAGE = Path("/home/me/staged/episode_0")
OUT = "/home/me/data/out/my_custom_dataset"
FPS = 30
# 1. Read one episode from your own format. Return aligned arrays:
# rgb: uint8 (T, H, W, 3)
# state: float32 (T, state_dim)
# action: float32 (T, action_dim)
# ts: float64 (T,) epoch seconds (optional)
def read_my_episode(src):
... # your code here
return {"rgb": rgb, "state": state, "action": action, "ts": ts}
ep = read_my_episode("/raw/episode_0")
# 2. Stage each stream as .npy.
(STAGE / "cam_top").mkdir(parents=True, exist_ok=True)
for i, frame in enumerate(ep["rgb"]): # RGB: one file per frame
np.save(STAGE / "cam_top" / f"frame_{i:06d}.npy", frame.astype(np.uint8))
np.save(STAGE / "state.npy", ep["state"].astype(np.float32)) # numeric: (T, D)
np.save(STAGE / "action.npy", ep["action"].astype(np.float32))
np.save(STAGE / "timestamps.npy", ep["ts"].astype(np.float64)) # optional
# 3. Describe the staged layout with an npy_separate config.
config = {
"dir_type": "npy_separate",
"metadata": {"dataset": "my_custom_dataset"},
"timestamps": str(STAGE / "timestamps.npy"), # optional
"streams": {
"rgb": {
"cam_top": {"dir": str(STAGE / "cam_top"),
"pattern": "frame_%06d.npy", "fps": FPS},
},
"numeric": {
"state": {"path": str(STAGE / "state.npy")},
"action": {"path": str(STAGE / "action.npy")},
},
# "depth": {"cam_top_depth": {"dir": ..., "pattern": ..., "fps": FPS}},
},
}
# 4. Ingest the staged episode.
run(config=config, output=OUT)
Notes:
- ▸Use a
streamsblock here, notlayout—npy_separatereads staged files directly rather than mapping source keys. - ▸
numericentries take an optionalchunk_len(e.g.{"path": ..., "chunk_len": 128}) to control on-disk chunking of the array. - ▸Each
run(...)ingests one episode. For a multi-episode dataset, prefer the streaming adapter below — it writes many episodes into a single output directory in one pass, with no staging.
Streaming adapter — push frames live (no staging)
When you have many episodes, or data that arrives serially, skip staging and push
frames straight in with the in-process live_session API. You
register each stream once, then, for every episode, open an episode(...) block
and push frames in order. This uses the same engine as offline ingest.
import numpy as np
from pathlib import Path
from knonik.ingest import live_session
OUT = "/home/me/data/out/my_custom_dataset"
FPS = 30
# Your own reader: yield one episode at a time from your format.
def my_episodes():
for path in sorted(Path("/raw").glob("*.myfmt")):
yield load_my_episode(path) # -> {"task", "rgb", "state", "action", "ts"}
with live_session(output=OUT, preset="high") as ses:
# Register every stream once, before the first episode.
ses.register_rgb("cam_top", fps=FPS)
ses.register_numeric("state", dim=14)
ses.register_numeric("action", dim=14)
for ep in my_episodes():
with ses.episode(metadata={"task": ep["task"]}):
for t in range(len(ep["action"])):
ts = ep["ts"][t] # epoch seconds
ses.rgb("cam_top", ep["rgb"][t], timestamp=ts) # uint8 (H, W, 3)
ses.numeric("state", ep["state"][t], timestamp=ts) # float32 (14,)
ses.numeric("action", ep["action"][t], timestamp=ts) # float32 (14,)
- ▸
ses.episode(...)finalizes the episode on a clean exit and discards it on an exception, so a crashed episode is never half-written (and is not charged). - ▸Depth works the same way:
ses.register_depth("cam_top_depth", fps=FPS)thenses.depth("cam_top_depth", depth_m, timestamp=ts)(metres,float32). - ▸Arrays are coerced to the expected dtype and contiguity for you.
- ▸
presettakes the same forms as offline ingest (a named preset, an inline{rgb, depth, numeric}dict, or omitted →medium); see section 8.
If your producer is not Python (for example a C++/ROS controller), drive the same streaming path over the cross-process stdin protocol instead — see section 10.
8. Compression Presets
Recommended explicit preset:
{
"rgb": {"codec": "vp9", "quality": 18, "gop": 3},
"depth": {"quantization_mm": 1},
"numeric": {"level": 1}
}
Common knobs:
| Block | Field | Meaning |
|---|---|---|
rgb | codec | vp9, av1, or ffv1. |
rgb | quality | Lower means higher quality and larger files. |
rgb | gop | Smaller GOP improves random access but increases size. |
depth | quantization_mm | Depth quantization in millimetres. 0 means lossless. |
numeric | level | Numeric compression level. |
Pack-time profiles are separate from ingest presets. The ingest preset controls
the intermediate episode files. storage_profile controls how those ingested
episodes are rewritten into KHLP shards for training.
Named presets
Instead of a dict you can name a built-in preset (preset defaults to medium
if omitted):
"preset": "high"
| Name | RGB codec | RGB CRF | GOP | Depth | Numeric | Use when |
|---|---|---|---|---|---|---|
ultra | av1 | 40 | 60 | 5 mm | 5 | Final archive, smallest size. |
high | vp9 | 36 | 45 | 2 mm | 4 | Standard production (recommended). |
medium | vp9 | 32 | 30 | 1 mm | 3 | Balanced; default, and the default for live. |
low | vp9 | 28 | 15 | 1 mm | 1 | Fast iteration during development. |
ultra_low | vp9 | 15 | 3 | 1 mm | 1 | Near-lossless, very large. |
lossless | ffv1 | — | 30 | 0 mm | 1 | Strictly lossless, very large. |
decode_friendly | vp9 | 32 | 1 | 1 mm | 3 | Every frame is a keyframe: ~3–5× faster RGB decode at training time for ~2–3× more storage. Good for read-heavy training on cheap local NVMe. |
Add preset_overrides to tweak a named preset, e.g.:
"preset": "high",
"preset_overrides": { "rgb": { "gop": 15 } }
Named presets, inline preset dicts, and
preset_overridesall work for both offline and live ingest (see section 10).
9. Python API
Use the public wrapper API from knonik.ingest.
Offline ingest
from knonik.ingest import run
run(
config="/home/me/configs/config_lerobot_v3.json",
input="/home/me/data/thread_velcro",
output="/home/me/data/out/knonik_thread_velcro",
force_config=False, # True to overwrite an existing dataset config.json
)
The run writes <output>/config.json — the dataset config described in
Processing §6 — when it finishes.
force_config is the API equivalent of --force-config; leave it at False
and an existing config is kept.
You can also pass a config dictionary:
from knonik.ingest import run
config = {
"dir_type": "lerobot_v3",
"data_dir": "/home/me/data/thread_velcro",
"preset": {
"rgb": {"codec": "vp9", "quality": 18, "gop": 3},
"depth": {"quantization_mm": 1},
"numeric": {"level": 1}
},
"layout": {
"images": {
# LeRobot camera keys are the bare name; the reader strips the
# "observation.images." prefix automatically.
"cam_high": {"key": "cam_high", "fps": 30}
},
"numeric": {
# The main proprioception/command columns are exposed as
# "state" / "action"; any other numeric column keeps its own
# name, e.g. "qvel": "observation.qvel".
"state": "state",
"action": "action"
}
}
}
run(config=config, output="/home/me/data/out/knonik_thread_velcro")
Pack
from knonik.ingest import pack
paths = pack(
input_dir="/home/me/data/out/knonik_thread_velcro",
output_dir="/home/me/data/out",
dataset_name="thread_velcro",
shard_size=45,
window_size=64,
fps=50,
seed=0,
pack_mode="shard_batch",
)
print(paths)
Pack with a storage profile
pack(...) takes an optional storage_profile ("compact_video" default,
"training_fast", or "training_compressed"). The codec/quality/format
internals for each profile are chosen automatically — there is nothing else to
configure:
from knonik.ingest import pack
paths = pack(
input_dir="/datasets/my_dataset_ingested",
output_dir="/datasets/my_dataset_fast",
dataset_name="my_dataset",
shard_size=30,
window_size=64,
fps=50,
seed=0,
pack_mode="shard_batch",
storage_profile="training_fast", # or "training_compressed"
)
Live ingest
For a Python producer, drive ingest directly with live_session. This uses the
same engine as offline ingest, fed frame-by-frame as numpy arrays — no stdin
protocol and no .npy staging:
from knonik.ingest import live_session
with live_session(output="/home/me/data/live_out", preset="high") as ses:
ses.register_rgb("cam_top", fps=30) # register each stream once,
ses.register_numeric("action", dim=7) # before the first episode
for _ in range(num_episodes):
with ses.episode(metadata={"task": "pick_and_place"}):
for t in range(episode_length):
ses.rgb("cam_top", capture_rgb(), timestamp=t / 30) # uint8 (H, W, 3)
ses.numeric("action", get_action(), timestamp=t / 30) # float32 (7,)
- ▸
presetaccepts the same forms as offline ingest: a named preset, an inline{rgb, depth, numeric}dict, or omitted (→medium); passpreset_overrides=...to tweak a named preset. - ▸
register_numericalso takesrole=("action"or"state") andchannel_names=[...]. You are the source of truth for a live capture, so anything you declare here is written straight into the dataset config at the end of the run instead of coming back to you as a question. Pass them only when they are true of the data you push:ses.register_numeric("action", dim=7, role="action", channel_names=["j1","j2","j3","j4","j5","j6","gripper"]) - ▸
live_session(..., force_config=True)overwrites an existing datasetconfig.jsonin the output directory; the default leaves it alone. - ▸
ses.episode(...)callsstarton enter,endon success, andaborton error (a discarded episode is not written and charges nothing). - ▸
Depth works the same way:
ses.register_depth("cam_top_depth", fps=30)thenses.depth("cam_top_depth", depth_m, timestamp=...)(metres, float32). - ▸
Arrays are coerced to the expected dtype/contiguity for you.
Live ingest (stdin protocol)
run_live runs the cross-process JSON protocol, reading commands from the
current process's stdin. Use it when a non-Python program drives ingest;
otherwise prefer live_session above.
from knonik.ingest import run_live
run_live(
config="/home/me/configs/live_config.json",
output="/home/me/data/live_out",
)
10. Live Ingest From Terminal (stdin protocol)
Live mode reads the Knonik live protocol from stdin:
knonik ingest live \
--config /path/to/live_config.json \
--output /path/to/live_output
This is the cross-process path: a separate producer (often non-Python, e.g.
a C++/ROS controller) writes newline-delimited JSON commands to stdin, and frame
payloads are staged as .npy files and referenced by path. If your producer is
Python, prefer the in-process live_session API instead — it
avoids the JSON protocol and the .npy staging entirely.
A minimal live config:
{
"output_dir": "/path/to/live_output",
"preset": "medium"
}
Important points:
- ▸Keep stdin open while streaming. Closing stdin ends the session (any open episode is finalized first).
- ▸Register streams after
start, at the beginning of each episode (streams are bound to the episode being written), then push frames, thenend. Repeat thestart → register → push → endcycle for each episode. - ▸Write each frame's
.npyfile to disk before sending the command that references it. - ▸Use local filesystem paths, not Docker mount paths.
- ▸
presetaccepts the same forms as offline ingest: a named string ("high","medium", …), an inline{rgb, depth, numeric}dict, or omitted (defaults tomedium), plus optionalpreset_overrides. - ▸
output_diris required in the config;--outputoverrides it.
Live ingest is also the streaming generic adapter for unrecognized formats: read your data one frame at a time in your own script and push each frame in. From Python, the in-process
live_sessionAPI does this with plain numpy arrays; non-Python producers use the protocol below. See section 7.
Live Protocol Reference
The agent reads one JSON object per line from stdin and writes one JSON
response per line to stdout. Every response is either {"ok": true} or
{"ok": false, "error": "<message>"}.
Commands
| Command | Required fields | Description |
|---|---|---|
register_rgb | cmd, name | Register an RGB stream. |
register_depth | cmd, name | Register a depth stream. |
register_numeric | cmd, name, dim | Register a numeric stream. dim is an int, or a list for multi-dim vectors. |
start | cmd | Begin a new episode. |
rgb | cmd, name, path | Submit one RGB frame (path → a .npy file). |
depth | cmd, name, path | Submit one depth frame. |
numeric | cmd, name, path | Submit one numeric vector. |
end | cmd | Finalize and persist the current episode. |
exit | cmd | Finalize any open episode and shut down cleanly. |
If timestamp is omitted, the frame index is used as the timestamp.
Lifecycle
start → register_* (each stream) → rgb / depth / numeric × N → end
↓
start → register_* → … → end (repeat for more episodes)
↓
exit
Driving live ingest from Python
import json, os, subprocess
import numpy as np
os.makedirs("/tmp/knonik_frames", exist_ok=True)
proc = subprocess.Popen(
["knonik", "ingest", "live",
"--config", "/path/to/live_config.json",
"--output", "/path/to/live_output"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True,
)
def send(cmd):
proc.stdin.write(json.dumps(cmd) + "\n")
proc.stdin.flush()
return json.loads(proc.stdout.readline())
for ep in range(num_episodes):
assert send({"cmd": "start", "metadata": {"task": "pick_and_place"}})["ok"]
# Register streams after start, at the beginning of each episode.
assert send({"cmd": "register_rgb", "name": "cam_top", "fps": 30})["ok"]
assert send({"cmd": "register_numeric", "name": "action", "dim": 7})["ok"]
for t in range(episode_length):
ts = t / 30.0
p = f"/tmp/knonik_frames/rgb_{t:04d}.npy"
np.save(p, capture_rgb()) # uint8 (H, W, 3)
send({"cmd": "rgb", "name": "cam_top", "path": p, "timestamp": ts})
p = f"/tmp/knonik_frames/action_{t:04d}.npy"
np.save(p, get_action()) # float32 (7,)
send({"cmd": "numeric", "name": "action", "path": p, "timestamp": ts})
assert send({"cmd": "end"})["ok"]
send({"cmd": "exit"})
proc.stdin.close()
proc.wait()
The in-process variant
knonik.ingest.run_live(config, output)runs the same protocol but reads from the current process's stdin — handy when another program pipes commands into your Python process.
11. Output Layout
Offline ingest writes one directory per episode:
knonik_thread_velcro/
config.json (dataset config — see Processing §6)
episode_000000/
metadata.json
rgb_<stream>.webm (one per RGB stream)
depth_<stream>.hdf5 (one per depth stream, if any)
<numeric_stream>/ (Zarr v2 array, shape [T, D], float32)
<numeric_stream>_time/ (Zarr v2 array, shape [T], float64 epoch seconds)
annotations/ (language annotations, if enabled — see below)
episode_000001/
...
metadata.json records the dataset name, robot type, source format, episode id,
the RGB/depth/numeric stream names, start/end time, and episode_length. It also
carries what the dataset config generator needs: stream_params (the encoder and
compressor settings each stream was written with), stream_roles when the source
format declared which stream is the action, channel_names when the source
declared per-channel labels, and urdf_joints when a ROS bag recorded
/robot_description. Read a numeric stream with Zarr:
import zarr
action = zarr.open("knonik_thread_velcro/episode_000000/action", mode="r")
print(action.shape, action[:5])
Language annotations. Format-reader ingests also write per-episode files
under episode_<n>/annotations/language/ (episode.yaml, rephrased.yaml,
timestamps.yaml). The instruction comes from the dataset, the language
config block, or metadata.language_instruction. Set language: false to skip,
or pass a dict like {"instruction": "pick up the cube", "rephrases": [...]} to
supply one when the dataset has none.
Packing writes KHLP shards and a manifest:
knonik_thread_velcro_hlp/
hlp_000000.khlp
hlp_000001.khlp
hlp_manifest.json
Use hlp_manifest.json with the Knonik multidataloader.