Use static lerobot.Scalars schema for Foxglove state topics

Replace the per-topic JSON schema derived from feature names with a single
static lerobot.Scalars schema: a scalars array of {label, value} objects. The
same schema fits any robot regardless of which observation/action features it
reports, and the label field lets Foxglove name each series automatically so
one filtered path plots every feature.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Roman Shtylman
2026-06-17 12:52:26 -07:00
committed by CarolinePascal
parent 60c0dcf33d
commit 23817e0ab0
+41 -21
View File
@@ -148,12 +148,34 @@ def _foxglove_safe_name(name: str) -> str:
return name.replace(".", "_")
def _log_foxglove_scalars(topic: str, schema_name: str, values: dict[str, float]) -> None:
"""Log a flat dict of scalars on a typed JSON channel, building the schema on first use.
# Static schema shared by all scalar topics. Each message carries a flat list of ``{label, value}``
# pairs rather than one field per feature, so the same schema fits any robot regardless of which
# observation/action features it reports. The ``label`` field name is what Foxglove looks for to name
# each series automatically, so a single filtered path plots every feature, e.g.
# ``/observation/state.scalars[:].value``.
_SCALARS_SCHEMA = {
"type": "object",
"title": "lerobot.Scalars",
"properties": {
"scalars": {
"type": "array",
"items": {
"type": "object",
"properties": {
"label": {"type": "string"},
"value": {"type": "number"},
},
},
}
},
}
The schema is derived from the keys of the first message (stable for a given robot/session) so
Foxglove offers message-path autocomplete. ``additionalProperties`` keeps it permissive if a later
message carries extra keys.
def _log_foxglove_scalars(topic: str, values: dict[str, float]) -> None:
"""Log scalars on a typed JSON channel using the static :data:`_SCALARS_SCHEMA`.
``values`` is an ordered mapping of feature name to value; it is emitted as a ``scalars`` array of
``{label, value}`` objects. Insertion order is preserved so series stay stable across messages.
"""
if not values:
@@ -163,14 +185,10 @@ def _log_foxglove_scalars(topic: str, schema_name: str, values: dict[str, float]
channel = _foxglove_channels.get(topic)
if channel is None:
schema = {
"type": "object",
"title": schema_name,
"properties": {name: {"type": "number"} for name in values},
"additionalProperties": {"type": "number"},
}
channel = _foxglove_channels[topic] = foxglove.Channel(topic, schema=schema, message_encoding="json")
channel.log(values)
channel = _foxglove_channels[topic] = foxglove.Channel(
topic, schema=_SCALARS_SCHEMA, message_encoding="json"
)
channel.log({"scalars": [{"label": label, "value": value} for label, value in values.items()]})
def log_rerun_data(
@@ -261,9 +279,10 @@ def log_foxglove_data(
Mirrors :func:`log_rerun_data` but emits Foxglove messages over the server started by
:func:`init_foxglove`. Data is mapped as follows:
- Scalars (and elements of 1D arrays) are accumulated per source and logged on the
``/observation/state`` and ``/action/state`` topics as typed JSON messages. Each topic gets a
schema generated from its field names so Foxglove provides message-path autocomplete. Field names
are sanitized (``.`` -> ``_``) so they don't need quoting when plotting.
``/observation/state`` and ``/action/state`` topics as typed JSON messages using the static
``lerobot.Scalars`` schema: a ``scalars`` array of ``{label, value}`` objects (see
:data:`_SCALARS_SCHEMA`). The ``label`` field lets Foxglove name each series automatically, so
``/observation/state.scalars[:].value`` plots every feature at once.
- 3D NumPy arrays that resemble images are transposed from CHW to HWC when needed and logged on a
per-source topic (e.g. ``/observation/images/front``) as a ``RawImage`` (or a JPEG
``CompressedImage`` when ``compress_images`` is True).
@@ -329,7 +348,7 @@ def log_foxglove_data(
for k, v in observation.items():
if v is None:
continue
key = _foxglove_safe_name(k[len(OBS_PREFIX) :] if str(k).startswith(OBS_PREFIX) else str(k))
key = k[len(OBS_PREFIX) :] if str(k).startswith(OBS_PREFIX) else str(k)
if _is_scalar(v):
obs_scalars[key] = float(v)
elif isinstance(v, np.ndarray):
@@ -337,21 +356,22 @@ def log_foxglove_data(
for i, vi in enumerate(v):
obs_scalars[f"{key}_{i}"] = float(vi)
else:
log_image(f"/{OBS_STR}/images/{key}", key, v)
_log_foxglove_scalars(f"/{OBS_STR}/state", "lerobot.Observation", obs_scalars)
# Image topics still sanitize the name since it's used as a topic-path segment.
log_image(f"/{OBS_STR}/images/{_foxglove_safe_name(key)}", key, v)
_log_foxglove_scalars(f"/{OBS_STR}/state", obs_scalars)
if action:
action_scalars: dict[str, float] = {}
for k, v in action.items():
if v is None:
continue
key = _foxglove_safe_name(k[len(ACTION_PREFIX) :] if str(k).startswith(ACTION_PREFIX) else str(k))
key = k[len(ACTION_PREFIX) :] if str(k).startswith(ACTION_PREFIX) else str(k)
if _is_scalar(v):
action_scalars[key] = float(v)
elif isinstance(v, np.ndarray):
for i, vi in enumerate(v.flatten()):
action_scalars[f"{key}_{i}"] = float(vi)
_log_foxglove_scalars(f"/{ACTION}/state", "lerobot.Action", action_scalars)
_log_foxglove_scalars(f"/{ACTION}/state", action_scalars)
# ── Backend-agnostic dispatch ─────────────────────────────────────────────