Rerun chunk processing
The pipeline layer between raw data and an RRD: readers produce Chunks,
streams transform them, terminal calls execute. This skill is the generic
mechanics only. Decide the data model first (rerun-data-model), then pick the
importer skill for each source:
| Source |
Reader |
Skill |
| MCAP file (ROS2, protobuf, Foxglove) |
McapReader(path).stream() |
rerun-mcap |
| URDF robot model (+ joint states → FK) |
UrdfTree.from_file_path(...).stream() |
rerun-urdf |
| Parquet table (trajectories, sensor logs) |
ParquetReader(path).stream() |
rerun-parquet |
| mp4 camera video |
Mp4Reader(path).stream() |
rerun-mp4 |
| LeRobot dataset directory |
built-in importer, then RrdReader |
rerun-lerobot |
| Existing RRD |
RrdReader(path) |
here, below |
| Sidecar files (JSON calib, metadata) |
Chunk.from_columns + from_iter |
here, below |
The API is rerun.experimental; when
behavior matters, check the installed surface:
python -c "from rerun.chunk import LazyChunkStream; help(LazyChunkStream)".
Decision rule: where does each component come from?
Default: a reader produces the chunks; lenses shape them. Walk this before
writing any conversion code — most "build it by hand" instincts are wrong here:
- Source a reader supports? Use the reader's
.stream(); never hand-parse
and re-log. MCAP→McapReader, URDF→UrdfTree, parquet→ParquetReader,
mp4→Mp4Reader, RRD→RrdReader, LeRobot dir→log_file_from_path.
- A decoder already emits the archetype? Foxglove gives
Transform3D,
Pinhole, VideoStream (real sample bytes) ready-made — pass it through,
do not re-derive. Only custom-protobuf topics arrive as <Name>:message and
need a lens (see rerun-mcap).
- Fix an existing component in place (swapped resolution, recolor, unit
convert)?
MutateLens, output_mode="forward_unmatched".
- Derive a new component/entity (FK→
/tf, scalars from a message)?
DeriveLens. To scatter one row into N (a joint batch → per-joint /tf),
use the two-lens pair: derive the batch with output_mode="forward_all"
(keeps the originals, e.g. the joint states), then a second
DeriveLens with scatter=True and output_mode="drop_unmatched" (emits
only the scattered rows). See the robot_data_preprocessing example.
- Genuine sidecar no reader or lens can produce (JSON calibration offsets,
hand-measured extrinsics, external metadata)?
Chunk.from_columns + from_iter.
- Finish with
LazyChunkStream.merge(...) →
.collect(optimize=OptimizationProfile.OBJECT_STORE) →
write_rrd(application_id, recording_id).
Why this order: the pipeline stays lazy, columnar, multithreaded, and
OBJECT_STORE-optimizable. A hand-built row loop or an out-of-lens pa.array
throws all of that away — that is the path we are deliberately avoiding.
Anti-patterns (use a reader + lens instead)
If you are writing the left, stop and use the right:
for-loop building rows/components → a lens with a Selector(...).pipe(...)
PyArrow-compute callback.
rr.init + rr.log per message for conversion → that is live logging;
for ingestion, read with a reader and write_rrd.
chunk.to_record_batch() + pc.filter then rebuilding via
Chunk.from_columns (row-thinning by hand) → stream.drop(content=...),
.split(...), or a MutateLens returning a filtered pa.array.
pa.array / pa.RecordBatch / np.frombuffer assembled OUTSIDE a lens →
move the transform inside a MutateLens/DeriveLens selector callback.
rr.send_columns hand-assembled from a custom parser → use the matching
reader; it produces chunks directly.
- Parsing MCAP/URDF with a non-Rerun library then re-logging →
McapReader
/ UrdfTree.
Chunk.from_columns for data a reader already decodes (Pinhole
intrinsics, VideoStream, Transform3D from a transforms topic) → keep it in
the reader stream; fix with a MutateLens if needed.
A wall of pyarrow.compute "missing-attribute" type errors (pc.filter,
pc.list_element) usually means pc.* calls sit in module-level helpers instead
of inside Selector.pipe lens callbacks. Refactor into a lens before suppressing
the checker — the errors are a smell that the hand-building should not exist.
Porting an existing converter? Hand-built converters predate decoder
improvements and are not ground truth. Re-verify the decoder output (step 2) and
check every Chunk.from_columns / for-loop against this list before copying.
Core model
LazyChunkStream is a lazy pipeline DAG, not a collection. Building
filters, lenses, maps, splits, and merges reads no source data.
- Execution starts at terminal calls:
write_rrd(...), collect(),
to_chunks(), or iterating the stream.
- Execution is streaming, multithreaded, and mostly GIL-free. Prefer
stream/lens operations and PyArrow compute over Python row loops.
- Move semantics: builder calls (
filter, drop, lenses, map,
flat_map) consume the input stream; reusing a consumed stream raises.
Reassign after each step. Terminal calls do not consume, but each terminal
call re-executes the whole pipeline; collect() once if that is too costly.
ChunkStore is materialized in memory (stream.collect(),
ChunkStore.from_chunks). LazyStore is manifest-indexed, loads chunks on
demand (RrdReader(path).store(), catalog segment stores). Both have
schema(), summary(), stream(), and write_rrd(...).
Stream composition
from rerun.chunk import Chunk, LazyChunkStream, OptimizationProfile
stream.filter(content=, has_timeline=, is_static=, components=) keeps the
matching portion of each chunk; stream.drop(...) is its complement, same
keyword filters. content takes an entity-path glob or a list of them.
stream.map(fn) applies Chunk -> Chunk; stream.flat_map(fn) applies
Chunk -> Iterable[Chunk]. Escape hatches for chunk-level Python logic;
prefer lenses for columnar work.
stream.split(content=, ...) returns (matching, non_matching); both
branches share the same upstream.
LazyChunkStream.merge(*streams) fans in any number of sources.
LazyChunkStream.from_iter(chunks) wraps hand-built chunks.
stream = source_stream() # any importer skill
stream = stream.drop(content="/video_raw/**")
stream = stream.lenses(fix_lens, content="/cam/**", output_mode="forward_unmatched")
merged = LazyChunkStream.merge(stream, sidecar_stream)
merged.write_rrd(out_path, application_id="my_app", recording_id=recording_id)
Hand-built chunks — sidecar only
Use Chunk.from_columns ONLY for data no reader or lens can emit — JSON/CSV
calibration, frame offsets, external metadata. If a reader
(McapReader/UrdfTree/ParquetReader) decodes the topic or a lens can derive
it, that is the idiomatic path; do not hand-assemble it here. In the
robot_data_preprocessing example the only hand-built chunk is the JSON
offsets sidecar; the camera fix, FK→/tf, meshes, and recolor are all
readers + lenses.
Chunk.from_columns(entity_path, indexes, columns) mirrors
rr.send_columns(...) and accepts the same archetype .columns(...) helpers.
Empty indexes means static.
chunk = Chunk.from_columns(
"/tf_static/robot_offsets",
indexes=[], # static
columns=rr.Transform3D.columns(
translation=translations,
quaternion=quaternions_xyzw,
parent_frame=parents,
child_frame=children,
),
)
sidecar_stream = LazyChunkStream.from_iter([chunk])
rr.AnyValues.columns(...) covers non-standard metadata fields. For
inspection, a Chunk exposes entity_path, num_rows, is_static,
timeline_names, to_record_batch(), and format() (human-readable table).
Lenses
Lenses reshape, fix, or derive components without iterating rows. Apply with
stream.lenses(lenses, output_mode=..., content=...).
MutateLens(component, selector, keep_row_ids=False) modifies an existing
component in place.
DeriveLens(component, output_entity=None, scatter=False) creates new
columns, optionally at another entity. Chain .to_component(descriptor, selector) per output; .to_timeline(name, "sequence" | "duration_ns" | "timestamp_ns", selector) extracts a time column from the data itself.
scatter=True explodes one input row into N output rows (one per list
element).
- Scope with
content= whenever the same component name exists under multiple
entities.
Output modes, and the default is drop_unmatched:
drop_unmatched (default): only lens outputs survive. Right for derive-only
intermediate streams; silently discards everything else if applied broadly.
forward_unmatched: lens outputs plus the original components no lens
consumed. Right for targeted fixes that preserve the rest of the stream.
forward_all: lens outputs plus all originals, including consumed ones. Can
duplicate data.
In-place fix (keep Arrow type and length intact):
stream = stream.lenses(
MutateLens(
"Pinhole:resolution",
Selector(".").pipe(
lambda res: pa.array(
[(h, w) for w, h in res.to_pylist()],
type=res.type,
)
),
),
content=["/external/cam_low", "/external/cam_high"],
output_mode="forward_unmatched",
)
Derive with unit conversion (PyArrow compute, no Python loop):
DeriveLens("schemas.proto.JointState:message", output_entity="/joints_deg/waist").to_component(
rr.Scalars.descriptor_scalars(),
Selector(".joint_positions").pipe(lambda arr: pc.multiply(pc.list_element(arr, 0), 180.0 / math.pi)),
)
Selector grammar
Selector("<query>") navigates nested Arrow data, jq-style:
. current value; .field struct field
[] iterate list elements; [N] index a list
? suppress errors / skip missing optionals; ! assert non-null
| pipe one expression into another
.pipe(fn) chains a Python/PyArrow transform (or another Selector).
.execute(array) runs it eagerly; .execute_per_row(array) guarantees the
output row count matches the input (use inside lens callbacks that must stay
row-aligned).
Writing RRDs
stream.write_rrd(path, application_id=..., recording_id=...) executes and
writes in one streaming pass.
stream.collect(optimize=OptimizationProfile.OBJECT_STORE).write_rrd(...)
materializes, optimizes chunk layout, then writes. Memory scales with the
materialized chunks.
- Profiles:
OBJECT_STORE (large chunks, for storage/query/catalog) and
LIVE (small chunks, low-latency viewer).
- Multiple physical RRDs form one logical recording when they share a
recording_id; use this to separate base data, model/URDF data, and layers.
Always use OptimizationProfile.OBJECT_STORE when the RRD is headed for a
Rerun catalog or Hub, unless explicitly asked otherwise.
Chunk API vs logging API
- Logging (
rr.log, rr.send_columns, RecordingStream) is for live logging
from user code; chunk processing is for ingestion, conversion, and
postprocessing existing recordings.
- Logging → chunks: write an RRD, read it back with
RrdReader.
RrdReader(path) lists recordings() / blueprints() (each a StoreEntry
with kind, application_id, recording_id); .stream(store=entry) for
sequential passes, .store(store=entry) for indexed access.
- Chunks → logging:
rerun.send_chunks(chunks, recording=...)
accepts a Chunk, LazyChunkStream, LazyStore, ChunkStore, or any
iterable of chunks. The source store's application_id/recording_id are
not preserved; the active recording's identity wins.
Common gotchas
- The default lens
output_mode is drop_unmatched; forgetting to set
forward_unmatched on a targeted fix silently drops the rest of the stream.
- Do not reuse a consumed
LazyChunkStream; reassign or split deliberately.
- Scope lenses with
content=; the same component name often exists under
many entities.
- Preserve Arrow array type and length in
MutateLens transforms.
- For catalog layers, the layer
recording_id must equal the segment id.
- This is
rerun.experimental; pin-check signatures when upgrading.
References
- End-to-end example (MCAP + URDF + JSON sidecar, lenses, merge, optimize):
https://github.com/rerun-io/rerun/tree/main/examples/python/robot_data_preprocessing
- Docs:
https://rerun.io/docs/concepts/logging-and-ingestion/chunk-processing-api,
https://rerun.io/docs/concepts/query-and-transform/lenses
1---2name: rerun-chunk-processing3description: Core mechanics of the Rerun Chunk Processing API (rerun.experimental) — LazyChunkStream pipelines, Chunk, lenses (MutateLens/DeriveLens/Selector), RrdReader, writing optimized RRDs. Read BEFORE writing any ingestion/conversion/preprocessing code (convert an MCAP, build a recording from a dataset, preprocess an .rrd, port an old converter): it mandates reader+lens pipelines and steers away from hand-built chunks — no Chunk.from_columns for data a reader/lens can produce, no per-message rr.log, no manual pa.array assembly. Source-specific knowledge lives in the importer skills (rerun-mcap, rerun-urdf, rerun-parquet, rerun-mp4, rerun-lerobot); read rerun-data-model first to decide what the data should become.4---56# Rerun chunk processing78The pipeline layer between raw data and an RRD: readers produce `Chunk`s,9streams transform them, terminal calls execute. This skill is the generic10mechanics only. Decide the data model first (`rerun-data-model`), then pick the11importer skill for each source:1213| Source | Reader | Skill |14| ----------------------------------------- | --------------------------------------- | --------------- |15| MCAP file (ROS2, protobuf, Foxglove) | `McapReader(path).stream()` | `rerun-mcap` |16| URDF robot model (+ joint states → FK) | `UrdfTree.from_file_path(...).stream()` | `rerun-urdf` |17| Parquet table (trajectories, sensor logs) | `ParquetReader(path).stream()` | `rerun-parquet` |18| mp4 camera video | `Mp4Reader(path).stream()` | `rerun-mp4` |19| LeRobot dataset directory | built-in importer, then `RrdReader` | `rerun-lerobot` |20| Existing RRD | `RrdReader(path)` | here, below |21| Sidecar files (JSON calib, metadata) | `Chunk.from_columns` + `from_iter` | here, below |2223The API is `rerun.experimental`; when24behavior matters, check the installed surface:25`python -c "from rerun.chunk import LazyChunkStream; help(LazyChunkStream)"`.2627## Decision rule: where does each component come from?2829Default: **a reader produces the chunks; lenses shape them.** Walk this before30writing any conversion code — most "build it by hand" instincts are wrong here:31321. **Source a reader supports?** Use the reader's `.stream()`; never hand-parse33 and re-log. MCAP→`McapReader`, URDF→`UrdfTree`, parquet→`ParquetReader`,34 mp4→`Mp4Reader`, RRD→`RrdReader`, LeRobot dir→`log_file_from_path`.352. **A decoder already emits the archetype?** Foxglove gives `Transform3D`,36 `Pinhole`, `VideoStream` (real sample bytes) ready-made — **pass it through**,37 do not re-derive. Only custom-protobuf topics arrive as `<Name>:message` and38 need a lens (see `rerun-mcap`).393. **Fix an existing component in place** (swapped resolution, recolor, unit40 convert)? `MutateLens`, `output_mode="forward_unmatched"`.414. **Derive a new component/entity** (FK→`/tf`, scalars from a message)?42 `DeriveLens`. To scatter one row into N (a joint batch → per-joint `/tf`),43 use the **two-lens pair**: derive the batch with `output_mode="forward_all"`44 (keeps the originals, e.g. the joint states), then a second45 `DeriveLens` with `scatter=True` and `output_mode="drop_unmatched"` (emits46 only the scattered rows). See the `robot_data_preprocessing` example.475. **Genuine sidecar** no reader or lens can produce (JSON calibration offsets,48 hand-measured extrinsics, external metadata)? `Chunk.from_columns` + `from_iter`.496. Finish with `LazyChunkStream.merge(...)` →50 `.collect(optimize=OptimizationProfile.OBJECT_STORE)` →51 `write_rrd(application_id, recording_id)`.5253Why this order: the pipeline stays lazy, columnar, multithreaded, and54`OBJECT_STORE`-optimizable. A hand-built row loop or an out-of-lens `pa.array`55throws all of that away — that is the path we are deliberately avoiding.5657## Anti-patterns (use a reader + lens instead)5859If you are writing the left, stop and use the right:6061- **`for`-loop building rows/components** → a lens with a `Selector(...).pipe(...)`62 PyArrow-compute callback.63- **`rr.init` + `rr.log` per message for conversion** → that is _live_ logging;64 for ingestion, read with a reader and `write_rrd`.65- **`chunk.to_record_batch()` + `pc.filter` then rebuilding via66 `Chunk.from_columns`** (row-thinning by hand) → `stream.drop(content=...)`,67 `.split(...)`, or a `MutateLens` returning a filtered `pa.array`.68- **`pa.array` / `pa.RecordBatch` / `np.frombuffer` assembled OUTSIDE a lens** →69 move the transform inside a `MutateLens`/`DeriveLens` selector callback.70- **`rr.send_columns` hand-assembled from a custom parser** → use the matching71 reader; it produces chunks directly.72- **Parsing MCAP/URDF with a non-Rerun library then re-logging** → `McapReader`73 / `UrdfTree`.74- **`Chunk.from_columns` for data a reader already decodes** (`Pinhole`75 intrinsics, `VideoStream`, `Transform3D` from a transforms topic) → keep it in76 the reader stream; fix with a `MutateLens` if needed.7778A wall of `pyarrow.compute` "missing-attribute" type errors (`pc.filter`,79`pc.list_element`) usually means `pc.*` calls sit in module-level helpers instead80of inside `Selector.pipe` lens callbacks. Refactor into a lens before suppressing81the checker — the errors are a smell that the hand-building should not exist.8283**Porting an existing converter?** Hand-built converters predate decoder84improvements and are not ground truth. Re-verify the decoder output (step 2) and85check every `Chunk.from_columns` / for-loop against this list before copying.8687## Core model8889- `LazyChunkStream` is a lazy pipeline DAG, not a collection. Building90 filters, lenses, maps, splits, and merges reads no source data.91- Execution starts at terminal calls: `write_rrd(...)`, `collect()`,92 `to_chunks()`, or iterating the stream.93- Execution is streaming, multithreaded, and mostly GIL-free. Prefer94 stream/lens operations and PyArrow compute over Python row loops.95- **Move semantics**: builder calls (`filter`, `drop`, `lenses`, `map`,96 `flat_map`) consume the input stream; reusing a consumed stream raises.97 Reassign after each step. Terminal calls do not consume, but each terminal98 call re-executes the whole pipeline; `collect()` once if that is too costly.99- `ChunkStore` is materialized in memory (`stream.collect()`,100 `ChunkStore.from_chunks`). `LazyStore` is manifest-indexed, loads chunks on101 demand (`RrdReader(path).store()`, catalog segment stores). Both have102 `schema()`, `summary()`, `stream()`, and `write_rrd(...)`.103104## Stream composition105106```python107from rerun.chunk import Chunk, LazyChunkStream, OptimizationProfile108```109110- `stream.filter(content=, has_timeline=, is_static=, components=)` keeps the111 matching portion of each chunk; `stream.drop(...)` is its complement, same112 keyword filters. `content` takes an entity-path glob or a list of them.113- `stream.map(fn)` applies `Chunk -> Chunk`; `stream.flat_map(fn)` applies114 `Chunk -> Iterable[Chunk]`. Escape hatches for chunk-level Python logic;115 prefer lenses for columnar work.116- `stream.split(content=, ...)` returns `(matching, non_matching)`; both117 branches share the same upstream.118- `LazyChunkStream.merge(*streams)` fans in any number of sources.119- `LazyChunkStream.from_iter(chunks)` wraps hand-built chunks.120121```python122stream = source_stream() # any importer skill123stream = stream.drop(content="/video_raw/**")124stream = stream.lenses(fix_lens, content="/cam/**", output_mode="forward_unmatched")125merged = LazyChunkStream.merge(stream, sidecar_stream)126merged.write_rrd(out_path, application_id="my_app", recording_id=recording_id)127```128129## Hand-built chunks — sidecar only130131Use `Chunk.from_columns` ONLY for data no reader or lens can emit — JSON/CSV132calibration, frame offsets, external metadata. If a reader133(`McapReader`/`UrdfTree`/`ParquetReader`) decodes the topic or a lens can derive134it, that is the idiomatic path; do not hand-assemble it here. In the135`robot_data_preprocessing` example the _only_ hand-built chunk is the JSON136offsets sidecar; the camera fix, FK→`/tf`, meshes, and recolor are all137readers + lenses.138139`Chunk.from_columns(entity_path, indexes, columns)` mirrors140`rr.send_columns(...)` and accepts the same archetype `.columns(...)` helpers.141Empty `indexes` means static.142143```python144chunk = Chunk.from_columns(145 "/tf_static/robot_offsets",146 indexes=[], # static147 columns=rr.Transform3D.columns(148 translation=translations,149 quaternion=quaternions_xyzw,150 parent_frame=parents,151 child_frame=children,152 ),153)154sidecar_stream = LazyChunkStream.from_iter([chunk])155```156157`rr.AnyValues.columns(...)` covers non-standard metadata fields. For158inspection, a `Chunk` exposes `entity_path`, `num_rows`, `is_static`,159`timeline_names`, `to_record_batch()`, and `format()` (human-readable table).160161## Lenses162163Lenses reshape, fix, or derive components without iterating rows. Apply with164`stream.lenses(lenses, output_mode=..., content=...)`.165166- `MutateLens(component, selector, keep_row_ids=False)` modifies an existing167 component in place.168- `DeriveLens(component, output_entity=None, scatter=False)` creates new169 columns, optionally at another entity. Chain `.to_component(descriptor,170selector)` per output; `.to_timeline(name, "sequence" | "duration_ns" |171"timestamp_ns", selector)` extracts a time column from the data itself.172 `scatter=True` explodes one input row into N output rows (one per list173 element).174- Scope with `content=` whenever the same component name exists under multiple175 entities.176177Output modes, and **the default is `drop_unmatched`**:178179- `drop_unmatched` (default): only lens outputs survive. Right for derive-only180 intermediate streams; silently discards everything else if applied broadly.181- `forward_unmatched`: lens outputs plus the original components no lens182 consumed. Right for targeted fixes that preserve the rest of the stream.183- `forward_all`: lens outputs plus all originals, including consumed ones. Can184 duplicate data.185186In-place fix (keep Arrow type and length intact):187188```python189stream = stream.lenses(190 MutateLens(191 "Pinhole:resolution",192 Selector(".").pipe(193 lambda res: pa.array(194 [(h, w) for w, h in res.to_pylist()],195 type=res.type,196 )197 ),198 ),199 content=["/external/cam_low", "/external/cam_high"],200 output_mode="forward_unmatched",201)202```203204Derive with unit conversion (PyArrow compute, no Python loop):205206```python207DeriveLens("schemas.proto.JointState:message", output_entity="/joints_deg/waist").to_component(208 rr.Scalars.descriptor_scalars(),209 Selector(".joint_positions").pipe(lambda arr: pc.multiply(pc.list_element(arr, 0), 180.0 / math.pi)),210)211```212213## Selector grammar214215`Selector("<query>")` navigates nested Arrow data, jq-style:216217- `.` current value; `.field` struct field218- `[]` iterate list elements; `[N]` index a list219- `?` suppress errors / skip missing optionals; `!` assert non-null220- `|` pipe one expression into another221222`.pipe(fn)` chains a Python/PyArrow transform (or another Selector).223`.execute(array)` runs it eagerly; `.execute_per_row(array)` guarantees the224output row count matches the input (use inside lens callbacks that must stay225row-aligned).226227## Writing RRDs228229- `stream.write_rrd(path, application_id=..., recording_id=...)` executes and230 writes in one streaming pass.231- `stream.collect(optimize=OptimizationProfile.OBJECT_STORE).write_rrd(...)`232 materializes, optimizes chunk layout, then writes. Memory scales with the233 materialized chunks.234- Profiles: `OBJECT_STORE` (large chunks, for storage/query/catalog) and235 `LIVE` (small chunks, low-latency viewer).236- Multiple physical RRDs form one logical recording when they share a237 `recording_id`; use this to separate base data, model/URDF data, and layers.238239**Always use `OptimizationProfile.OBJECT_STORE`** when the RRD is headed for a240Rerun catalog or Hub, unless explicitly asked otherwise.241242## Chunk API vs logging API243244- Logging (`rr.log`, `rr.send_columns`, `RecordingStream`) is for live logging245 from user code; chunk processing is for ingestion, conversion, and246 postprocessing existing recordings.247- Logging → chunks: write an RRD, read it back with `RrdReader`.248 `RrdReader(path)` lists `recordings()` / `blueprints()` (each a `StoreEntry`249 with `kind`, `application_id`, `recording_id`); `.stream(store=entry)` for250 sequential passes, `.store(store=entry)` for indexed access.251- Chunks → logging: `rerun.send_chunks(chunks, recording=...)`252 accepts a `Chunk`, `LazyChunkStream`, `LazyStore`, `ChunkStore`, or any253 iterable of chunks. The source store's `application_id`/`recording_id` are254 **not** preserved; the active recording's identity wins.255256## Common gotchas257258- The default lens `output_mode` is `drop_unmatched`; forgetting to set259 `forward_unmatched` on a targeted fix silently drops the rest of the stream.260- Do not reuse a consumed `LazyChunkStream`; reassign or `split` deliberately.261- Scope lenses with `content=`; the same component name often exists under262 many entities.263- Preserve Arrow array type and length in `MutateLens` transforms.264- For catalog layers, the layer `recording_id` must equal the segment id.265- This is `rerun.experimental`; pin-check signatures when upgrading.266267## References268269- End-to-end example (MCAP + URDF + JSON sidecar, lenses, merge, optimize):270 `https://github.com/rerun-io/rerun/tree/main/examples/python/robot_data_preprocessing`271- Docs: `https://rerun.io/docs/concepts/logging-and-ingestion/chunk-processing-api`,272 `https://rerun.io/docs/concepts/query-and-transform/lenses`