explanation.md
This note explains how the Mosaico cloud download path can present rich objects
without using parser_ingest, and how those downloaded objects are decoded,
decompressed, and visualised in PlotJuggler.
The important idea is this:
Mosaico server typed object data
-> Mosaico toolbox maps it to PJ canonical SDK structs
-> toolbox serializes those structs with pj_base canonical codecs
-> toolbox pushes canonical bytes into ObjectStore
-> viewer deserializes canonical bytes directly, with no MessageParser
-> viewer renders the canonical object
parser_ingest is only needed when the producer gives the host raw source
messages, for example ROS CDR or protobuf payloads, and asks PlotJuggler to run a
MessageParser over them. The Mosaico path described here does not do that. It
downloads typed Mosaico records, converts them to PlotJuggler canonical objects
in the toolbox, and then writes those canonical object bytes straight into the
host ObjectStore.
There are two object ingest models in this codebase.
Parser-backed ingest is used by file or stream sources that still carry a source-specific wire format.
Example:
ROS / CDR / protobuf bytes
-> parser_ingest / MessageParser
-> sdk::PointCloud, sdk::Image, sdk::FrameTransforms, ...
-> ObjectStore entry
In this path, the ObjectStore entry often stores the original source message
bytes, and a render-time parser binding exists for the object topic. Consumers
ask SessionManager::parserBindingForObjectTopic(...) for that parser binding,
then call parseLocked(...).
Relevant code:
pj_scene3D/widgets/include/pj_scene3d_widgets/parse_locked.hpj_runtime/src/DataSourceRuntimeHost.cpp, cbEnsureParserBinding(...)pj_runtime/src/DataSourceRuntimeHost.cpp, cbPushMessage(...)The Mosaico download path is the other model.
Example:
Mosaico point_cloud2 / pose / motion_state ontology
-> toolbox builds an SDK canonical object
-> serializePointCloud / serializePosesInFrame / serializeFrameTransforms / ...
-> ObjectStore entry stores that serialized canonical blob
There is no MessageParser registered for these topics. That is deliberate.
The bytes in the store are already a PlotJuggler canonical object wire blob, not
a ROS/CDR/protobuf source message.
Relevant code:
plotjuggler_sdk/pj_base/include/pj_base/sdk/plugin_data_api.hpp,
ToolboxHostView::registerObjectTopic(...)plotjuggler_sdk/pj_base/include/pj_base/sdk/plugin_data_api.hpp,
ToolboxHostView::pushOwnedObject(...)pj_datastore/src/plugin_data_host.cpp, toolboxRegisterObjectTopic(...)pj_datastore/src/plugin_data_host.cpp, toolboxPushOwnedObject(...)The Mosaico server implementation is not in this worktree, so the exact database tables or cloud API DTOs are outside this repository. What this worktree does show is the client-side contract: the Mosaico toolbox treats the server output as typed object ontologies such as:
point_cloud2posemotion_stateserialization_format=imageAt the boundary with PlotJuggler, those server ontologies are transformed into
canonical pj_base builtins:
| Mosaico-level data | PJ canonical object |
|---|---|
| point cloud records | PJ::sdk::PointCloud |
| compressed point cloud records | PJ::sdk::CompressedPointCloud |
| pose arrays / object poses | PJ::sdk::PosesInFrame |
| frame relationships / motion-state transforms | PJ::sdk::FrameTransforms |
| raster images and depth images | PJ::sdk::Image |
| markers / scene primitives, when applicable | PJ::sdk::SceneEntities |
The PJ canonical objects are defined by the SDK in plotjuggler_sdk/pj_base.
Their wire format is protobuf-based and documented in:
plotjuggler_sdk/pj_base/proto/pj/README.mdplotjuggler_sdk/pj_base/proto/pj/Image.protoplotjuggler_sdk/pj_base/proto/pj/PointCloud.protoplotjuggler_sdk/pj_base/proto/pj/CompressedPointCloud.protoplotjuggler_sdk/pj_base/proto/pj/FrameTransforms.protoplotjuggler_sdk/pj_base/proto/pj/PosesInFrame.protoFor example, a canonical PointCloud contains:
timestampwidthheightpoint_steprow_stepis_bigendianis_densefields: channel descriptors such as x, y, z, intensity, rgbadata: packed point bytesframe_idA canonical PosesInFrame contains:
timestampframe_idPose valuesA canonical FrameTransforms contains repeated transform edges:
The critical point is that by the time the payload is pushed into PlotJuggler, the source-specific Mosaico ontology has already been normalised into one of these PJ canonical shapes.
ObjectStore stores timestamped opaque object payloads. It does not parse them.
It only knows:
metadata_jsonSee:
pj_datastore/include/pj_datastore/object_store.hpppj_datastore/src/object_store.cppAn object topic descriptor looks like this:
struct ObjectTopicDescriptor {
DatasetId dataset_id;
std::string topic_name;
std::string metadata_json;
};
For parser-less canonical topics, metadata_json must advertise the canonical
object type. Typical examples:
{"builtin_object_type":"kPointCloud"}
{"builtin_object_type":"kCompressedPointCloud"}
{"builtin_object_type":"kPosesInFrame"}
{"builtin_object_type":"kFrameTransforms"}
For canonical image topics consumed by the 2D image path, the existing convention also includes the canonical image codec marker:
{"builtin_object_type":"kImage","image_codec":"pj_image_v1"}
The catalog and scene docks inspect this metadata to know which viewer family can handle a topic. The payload bytes are still opaque to the store.
parser_ingest is requiredparser_ingest solves this problem:
I have source-format bytes and need a parser plugin to turn them into a PJ object.
Mosaico canonical download solves a different problem:
I already know the source object structure and can build the PJ object myself.
So the toolbox performs the normalisation itself:
ObjectStore topic with builtin_object_type metadata.pushOwnedObject(...).No parser binding is created. Later, when a scene layer asks
SessionManager::parserBindingForObjectTopic(topic_id), it receives an empty
binding. That empty binding is now meaningful: it tells the viewer that this is
a parser-less canonical object topic.
The new 3D decode seam is:
pj_scene3D/widgets/include/pj_scene3d_widgets/resolve_object.hpj_scene3D/widgets/src/resolve_object.cppresolveObject(...) implements the rule:
if parser binding exists:
parse source bytes with MessageParser under parseLocked(...)
else:
treat payload as serialized PJ canonical bytes
choose the canonical deserializer from builtin_object_type
Supported parser-less 3D canonical codecs include:
kPointCloud -> deserializePointCloud(...)kCompressedPointCloud -> deserializeCompressedPointCloud(...)kFrameTransforms -> deserializeFrameTransforms(...)kPosesInFrame -> deserializePosesInFrame(...)kOccupancyGrid -> deserializeOccupancyGrid(...)kOccupancyGridUpdate -> deserializeOccupancyGridUpdate(...)kVoxelGrid -> deserializeVoxelGrid(...)kSceneEntities -> deserializeSceneEntities(...)That is the main change that lets a Mosaico canonical object render without
parser_ingest.
The catalog extracts builtin_object_type from metadata_json:
pj_runtime/src/CatalogModel.cpp, objectTypeFromMetadata(...)The scene routing code then decides which dock can handle the type:
pj_app/src/scene_object_classification.hpj_scene3D/widgets/src/Scene3DDockWidget.cpp,
Scene3DDockWidget::handlesObjectType(...)The 3D dock registers factories for:
kImagekFrameTransforms is handled as a scene configuration topic rather than a
visible render layer.
Every 3D consumer now goes through resolveObject(...) or the equivalent
canonical path.
For a Mosaico parser-less topic:
ObjectStore latestAt(...)
-> payload bytes
-> no parser binding
-> builtin_object_type metadata
-> deserialize* canonical codec
-> sdk::BuiltinObject
That returned sdk::BuiltinObject is a std::any holding the concrete SDK type.
The layer then uses std::any_cast to get the type it expects.
Example from PointCloudLayer:
resolveObject(...)
-> any_cast<CompressedPointCloud>
-> async compressed-cloud decode
-> PointCloud
-> render
or
resolveObject(...)
-> any_cast<PointCloud>
-> render
Example from PosesInFrameLayer:
resolveObject(...)
-> any_cast<PosesInFrame>
-> build pose triad instances
-> render
Example from TransformService:
resolveObject(...)
-> any_cast<FrameTransforms>
-> insert edges into TransformBuffer
There are two different meanings of "decode" in this feature.
This is the step that turns a pj_base canonical wire blob back into an SDK
struct.
Examples:
serialized PJ.PointCloud bytes
-> deserializePointCloud(...)
-> sdk::PointCloud
serialized PJ.PosesInFrame bytes
-> deserializePosesInFrame(...)
-> sdk::PosesInFrame
This is not parser_ingest. It is not CDR parsing. It is simply the canonical
object's own serialization format.
Some canonical objects contain compressed media inside them.
That is a second-stage decode:
sdk::CompressedPointCloud
-> Cloudini or Draco decoder
-> sdk::PointCloud
sdk::Image with PNG/JPEG data
-> PNG/JPEG decoder
-> pixels or flat raw samples
This is allowed in the viewer because the canonical object is already known. The viewer is not parsing source transport messages; it is decoding self-describing media payloads inside a canonical object.
Point clouds use one dual-mode layer:
pj_scene3D/widgets/src/layers/pointcloud_layer.cppThe 3D dock registers both kPointCloud and kCompressedPointCloud to the same
PointCloudLayer.
PointCloudThe canonical object already contains packed point bytes and a fields schema.
The viewer converts it through:
pj_scene3D/core/src/pointcloud_convert.cppconvertCanonical(...)That function:
x, y, z fields.intensity.DecodedPointCloud for the render pass.There is also a zero-copy GPU fast path for common packed layouts:
x/y/zWhen the fast path is available, the layer can upload the canonical wire buffer directly as a GL vertex buffer instead of rebuilding a CPU-side render buffer.
CompressedPointCloudA CompressedPointCloud contains:
frame_idformatdataThe supported formats are:
cloudinidracoDecode dispatch lives in:
pj_scene3D/core/src/pointcloud_codecs.cppdecodeCompressedPointCloud(...)For Cloudini:
PointField::Datatype.sdk::PointCloud.For Draco:
sdk::PointCloud.The layer runs this compressed-cloud decode off the UI thread with
QtConcurrent and QFutureWatcher. When the result arrives, it is cached and
then sent through the same pushCloud(...) / convertCanonical(...) path as a
raw PointCloud.
That means the renderer only has one final point-cloud path.
Mosaico compressed cloud
-> sdk::CompressedPointCloud canonical bytes
-> deserializeCompressedPointCloud(...)
-> decodeCloudini/decodeDraco(...)
-> sdk::PointCloud
-> convertCanonical(...) or fast path
-> PointcloudRenderPass
Pose-style Mosaico objects are represented as sdk::PosesInFrame when they are
to be shown as object poses / pose arrays.
The canonical object contains:
frame_idThe layer is:
pj_scene3D/widgets/src/layers/poses_in_frame_layer.cppThe render expansion is:
pj_scene3D/core/src/poses_in_frame_render.cppThe path is:
ObjectStore sample
-> resolveObject(..., kPosesInFrame, ...)
-> sdk::PosesInFrame
-> buildPoseTriadInstances(...)
-> PosesRenderPass
buildPoseTriadInstances(...) turns each pose into either:
The styling is viewer-side:
The data schema carries pose geometry, not rendering style.
Frame transforms are consumed by TransformService, not shown as ordinary object
layers.
Relevant code:
pj_scene3D/widgets/src/transform_service.cpppj_scene3D/core/include/pj_scene3d_core/tf/tf_buffer.hFor parser-less Mosaico canonical topics, TransformService classifies the
topic by metadata:
metadata_json builtin_object_type == kFrameTransforms
It then reads new ObjectStore entries, decodes each entry with resolveObject,
casts to sdk::FrameTransforms, and inserts every transform edge into the
TransformBuffer.
This is what lets point clouds, depth clouds, poses, and other spatial objects be placed in the selected fixed frame.
Images are handled mainly by pj_scene2D, and depth images are also consumed by
pj_scene3D for back-projected depth clouds.
The canonical image object is sdk::Image.
It contains:
The canonical image resolver is:
pj_scene2D/core/src/image_resolve.cppThe 2D image path recognizes parser-less canonical image blobs through the topic metadata:
{"builtin_object_type":"kImage","image_codec":"pj_image_v1"}
Then it calls deserializeImage(...) and decodes the resulting sdk::Image.
serialization_format=imageThe branch adds a special recovery path for Mosaico-style image wrapping.
The observed shape is:
sdk::Image.encoding = "rgb8" or "16UC1" or another raw logical encoding
sdk::Image.data = PNG/JPEG container containing the flat raw byte buffer
For example, a 16UC1 depth frame may be stored as an 8-bit grayscale PNG whose
width is the byte stride. The pixels of that PNG are not the final RGB image.
They are the raw byte stream reshaped into an image container.
So the viewer must not do this:
encoding says 16UC1
read PNG container bytes as uint16 depth samples
That produces black or garbage depth.
The correct path is:
detect PNG/JPEG signature
decompress container
recover flat bytes
reinterpret flat bytes using sdk::Image.encoding and geometry
For 2D images, this logic lives in:
pj_scene2D/core/src/image_pipeline_source.cpp,
decodeRawOrBayerImage(...)For 2D depth display, the reusable helper is:
pj_scene2D/core/src/codecs.cpp,
recoverContainerRawSamples(...)pj_scene2D/core/src/depth_pipeline_source.cpp,
toDepthImage(...)For 3D depth clouds, similar recovery is in:
pj_scene3D/widgets/src/layers/depth_cloud_layer.cpp,
DepthCloudLayer::toDepthView(...)Depth clouds are built from sdk::Image objects whose encoding is a depth
encoding:
16UC132FC1compressedDepthThe 3D dock accepts kImage, but gates it by peeking the first sample's
encoding, because color images and depth images share the same canonical
object type.
The depth cloud layer path is:
ObjectStore sample
-> parser/canonical image resolution
-> sdk::Image
-> toDepthView(...)
-> resolve CameraInfo intrinsics by frame_id
-> depthToPoints(...)
-> DecodedPointCloud
-> PointcloudRenderPass
Back-projection is in:
pj_scene3D/core/src/depth_backproject.cppdepthToPoints(...):
16UC1 millimetres and 32FC1 metres.The resulting cloud is rendered by the same PointcloudRenderPass used for
normal point clouds.
After canonicalization, rendering is type-specific but straightforward.
PointCloudsdk::PointCloud
-> fast path or convertCanonical(...)
-> PointcloudRenderPass
-> GL_POINTS or sphere/cube imposters
The shader can color by:
CompressedPointCloudsdk::CompressedPointCloud
-> decodeCloudini/decodeDraco
-> sdk::PointCloud
-> normal point-cloud path
PosesInFramesdk::PosesInFrame
-> buildPoseTriadInstances(...)
-> PosesRenderPass
-> instanced arrows
FrameTransformssdk::FrameTransforms
-> TransformService
-> TransformBuffer
-> used by layers to transform source frame into fixed frame
Imagesdk::Image with depth encoding
-> recover wrapped raw samples if needed
-> depthToPoints(...)
-> DecodedPointCloud
-> PointcloudRenderPass
When a Mosaico object does not show up, debug the pipeline in this order.
Check the topic exists in ObjectStore and has entries.
The topic should have useful metadata_json, especially:
{"builtin_object_type":"kPointCloud"}
or the appropriate builtin type.
For Mosaico canonical topics, no parser binding is expected.
An empty parser binding is only a problem if the payload is raw source bytes. If the payload is serialized canonical PJ bytes, an empty binding is correct.
resolveObject(...) support the type?hasCanonical3DCodec(...) must return true for 3D parser-less topics.
If the type is unsupported there, the layer will reject it or resolveObject
will return an error.
If metadata says:
{"builtin_object_type":"kPosesInFrame"}
then the payload must be the output of serializePosesInFrame(...).
If the payload is still a Mosaico API response, JSON blob, CDR message, or any other non-canonical format, parser-less rendering will fail. In that case either the toolbox must convert it to a canonical object first, or the topic must use a proper parser-backed ingest path.
CompressedPointCloud.format must be one of:
cloudinidracoOther formats are rejected by decodeCompressedPointCloud(...).
For serialization_format=image, the container unwrap only recovers flat bytes.
The sdk::Image.encoding, width, height, and row_step must still describe
how those recovered bytes should be interpreted.
For example:
encoding = "16UC1"
data = grayscale PNG wrapping the little-endian uint16 byte stream
After PNG decompression, the recovered bytes are interpreted as 16UC1.
We can present Mosaico canonical objects without parser_ingest because the
toolbox converts Mosaico's typed cloud records into PlotJuggler canonical SDK
objects before writing them; the host then deserializes those canonical object
bytes directly from ObjectStore, decompresses only self-describing media
payloads such as PNG/JPEG, Cloudini, or Draco, and renders the resulting SDK
objects through the existing 2D and 3D scene layers.