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(".", "_") return name.replace(".", "_")
def _log_foxglove_scalars(topic: str, schema_name: str, values: dict[str, float]) -> None: # Static schema shared by all scalar topics. Each message carries a flat list of ``{label, value}``
"""Log a flat dict of scalars on a typed JSON channel, building the schema on first use. # 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 def _log_foxglove_scalars(topic: str, values: dict[str, float]) -> None:
message carries extra keys. """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: 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) channel = _foxglove_channels.get(topic)
if channel is None: if channel is None:
schema = { channel = _foxglove_channels[topic] = foxglove.Channel(
"type": "object", topic, schema=_SCALARS_SCHEMA, message_encoding="json"
"title": schema_name, )
"properties": {name: {"type": "number"} for name in values}, channel.log({"scalars": [{"label": label, "value": value} for label, value in values.items()]})
"additionalProperties": {"type": "number"},
}
channel = _foxglove_channels[topic] = foxglove.Channel(topic, schema=schema, message_encoding="json")
channel.log(values)
def log_rerun_data( 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 Mirrors :func:`log_rerun_data` but emits Foxglove messages over the server started by
:func:`init_foxglove`. Data is mapped as follows: :func:`init_foxglove`. Data is mapped as follows:
- Scalars (and elements of 1D arrays) are accumulated per source and logged on the - 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 ``/observation/state`` and ``/action/state`` topics as typed JSON messages using the static
schema generated from its field names so Foxglove provides message-path autocomplete. Field names ``lerobot.Scalars`` schema: a ``scalars`` array of ``{label, value}`` objects (see
are sanitized (``.`` -> ``_``) so they don't need quoting when plotting. :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 - 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 per-source topic (e.g. ``/observation/images/front``) as a ``RawImage`` (or a JPEG
``CompressedImage`` when ``compress_images`` is True). ``CompressedImage`` when ``compress_images`` is True).
@@ -329,7 +348,7 @@ def log_foxglove_data(
for k, v in observation.items(): for k, v in observation.items():
if v is None: if v is None:
continue 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): if _is_scalar(v):
obs_scalars[key] = float(v) obs_scalars[key] = float(v)
elif isinstance(v, np.ndarray): elif isinstance(v, np.ndarray):
@@ -337,21 +356,22 @@ def log_foxglove_data(
for i, vi in enumerate(v): for i, vi in enumerate(v):
obs_scalars[f"{key}_{i}"] = float(vi) obs_scalars[f"{key}_{i}"] = float(vi)
else: else:
log_image(f"/{OBS_STR}/images/{key}", key, v) # Image topics still sanitize the name since it's used as a topic-path segment.
_log_foxglove_scalars(f"/{OBS_STR}/state", "lerobot.Observation", obs_scalars) log_image(f"/{OBS_STR}/images/{_foxglove_safe_name(key)}", key, v)
_log_foxglove_scalars(f"/{OBS_STR}/state", obs_scalars)
if action: if action:
action_scalars: dict[str, float] = {} action_scalars: dict[str, float] = {}
for k, v in action.items(): for k, v in action.items():
if v is None: if v is None:
continue 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): if _is_scalar(v):
action_scalars[key] = float(v) action_scalars[key] = float(v)
elif isinstance(v, np.ndarray): elif isinstance(v, np.ndarray):
for i, vi in enumerate(v.flatten()): for i, vi in enumerate(v.flatten()):
action_scalars[f"{key}_{i}"] = float(vi) 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 ───────────────────────────────────────────── # ── Backend-agnostic dispatch ─────────────────────────────────────────────