fix(visualization): no duplicated prefix, consolidated norm + warnings log

This commit is contained in:
Steven Palma
2026-07-01 12:49:44 +02:00
parent f0a251312b
commit 15678219c6
+61 -24
View File
@@ -12,15 +12,27 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
import logging
import numbers import numbers
import os import os
import time import time
import cv2
import numpy as np import numpy as np
from lerobot.types import RobotAction, RobotObservation from lerobot.types import RobotAction, RobotObservation
from .constants import ACTION, ACTION_PREFIX, DONE, OBS_PREFIX, OBS_STATE, OBS_STR, REWARD, SUCCESS from .constants import (
ACTION,
ACTION_PREFIX,
DONE,
OBS_IMAGES,
OBS_PREFIX,
OBS_STATE,
OBS_STR,
REWARD,
SUCCESS,
)
from .import_utils import require_package from .import_utils import require_package
# Visualization backends selectable at runtime via a display-mode string (e.g. a --display_mode flag). # Visualization backends selectable at runtime via a display-mode string (e.g. a --display_mode flag).
@@ -182,7 +194,11 @@ def _foxglove_topic(key: str, *, is_image: bool = False) -> str:
""" """
if is_image: if is_image:
name = key[len(OBS_PREFIX) :] if str(key).startswith(OBS_PREFIX) else str(key) name = str(key)
for prefix in (f"{OBS_IMAGES}.", OBS_PREFIX):
if name.startswith(prefix):
name = name[len(prefix) :]
break
return f"/{OBS_STR}/images/{_foxglove_safe_name(name)}" return f"/{OBS_STR}/images/{_foxglove_safe_name(name)}"
source = ACTION if (str(key).startswith(ACTION_PREFIX) or str(key) == ACTION) else OBS_STR source = ACTION if (str(key).startswith(ACTION_PREFIX) or str(key) == ACTION) else OBS_STR
return f"/{source}/state" return f"/{source}/state"
@@ -218,6 +234,15 @@ def _log_foxglove_scalars(
channel.log(msg, log_time=log_time) channel.log(msg, log_time=log_time)
def _labeled_scalars(name: str, values, labels: list[str] | None = None) -> dict[str, float]:
"""Expand a 1D sequence into ``{label: value}`` entries with a consistent fallback."""
flat = [float(v) for v in values]
if labels is None or len(labels) != len(flat):
labels = [f"{name}_{i}" for i in range(len(flat))]
return dict(zip(labels, flat, strict=True))
def _log_foxglove_image( def _log_foxglove_image(
topic: str, topic: str,
frame_id: str, frame_id: str,
@@ -229,9 +254,12 @@ def _log_foxglove_image(
) -> None: ) -> None:
"""Log an image on a cached per-topic channel. """Log an image on a cached per-topic channel.
``arr`` may be HWC or CHW; CHW is transposed to HWC. ``channels`` is the per-topic channel cache ``arr`` may be HWC or CHW (CHW is transposed to HWC) and any dtype; floating-point images are
to reuse (see :func:`_log_foxglove_scalars`). ``log_time`` is the message time in nanoseconds; when assumed normalized to [0, 1] and scaled to uint8. With ``compress_images`` set, grayscale (1ch)
``None`` the server's receive time is used. It is also written to the message header timestamp. and color (3ch) frames are JPEG-encoded, while 4-channel (RGBA) frames are always sent raw.
``channels`` is the per-topic channel cache to reuse (see :func:`_log_foxglove_scalars`).
``log_time`` is the message time in nanoseconds; when ``None`` the server's receive time is used.
It is also written to the message header timestamp.
""" """
from foxglove.channels import CompressedImageChannel, RawImageChannel from foxglove.channels import CompressedImageChannel, RawImageChannel
@@ -246,14 +274,15 @@ def _log_foxglove_image(
# Convert CHW -> HWC when needed (mirrors log_rerun_data). # Convert CHW -> HWC when needed (mirrors log_rerun_data).
if arr.ndim == 3 and arr.shape[0] in (1, 3, 4) and arr.shape[-1] not in (1, 3, 4): if arr.ndim == 3 and arr.shape[0] in (1, 3, 4) and arr.shape[-1] not in (1, 3, 4):
arr = np.transpose(arr, (1, 2, 0)) arr = np.transpose(arr, (1, 2, 0))
if np.issubdtype(arr.dtype, np.floating):
arr = (arr * 255.0).clip(0, 255)
arr = np.ascontiguousarray(arr, dtype=np.uint8)
height, width = arr.shape[0], arr.shape[1] height, width = arr.shape[0], arr.shape[1]
n_channels = 1 if arr.ndim == 2 else arr.shape[2] n_channels = 1 if arr.ndim == 2 else arr.shape[2]
if compress_images and n_channels == 3: if compress_images and n_channels in (1, 3):
import cv2 buf_src = cv2.cvtColor(arr, cv2.COLOR_RGB2BGR) if n_channels == 3 else arr
_, buf = cv2.imencode(".jpg", buf_src)
# Camera frames are RGB; cv2.imencode assumes BGR, so swap to keep colors correct.
_, buf = cv2.imencode(".jpg", cv2.cvtColor(arr, cv2.COLOR_RGB2BGR))
channel = channels.get(topic) channel = channels.get(topic)
if channel is None: if channel is None:
channel = channels[topic] = CompressedImageChannel(topic=topic) channel = channels[topic] = CompressedImageChannel(topic=topic)
@@ -265,8 +294,14 @@ def _log_foxglove_image(
encoding = {1: "mono8", 3: "rgb8", 4: "rgba8"}.get(n_channels) encoding = {1: "mono8", 3: "rgb8", 4: "rgba8"}.get(n_channels)
if encoding is None: if encoding is None:
logging.warning(
"Foxglove: skipping image on topic '%s' with unsupported shape %s (%d channels); "
"expected 1 (mono8), 3 (rgb8), or 4 (rgba8) channels.",
topic,
tuple(arr.shape),
n_channels,
)
return return
arr = np.ascontiguousarray(arr, dtype=np.uint8)
channel = channels.get(topic) channel = channels.get(topic)
if channel is None: if channel is None:
channel = channels[topic] = RawImageChannel(topic=topic) channel = channels[topic] = RawImageChannel(topic=topic)
@@ -404,8 +439,7 @@ def log_foxglove_data(
obs_scalars[key] = float(v) obs_scalars[key] = float(v)
elif isinstance(v, np.ndarray): elif isinstance(v, np.ndarray):
if v.ndim == 1: if v.ndim == 1:
for i, vi in enumerate(v): obs_scalars.update(_labeled_scalars(key, v))
obs_scalars[f"{key}_{i}"] = float(vi)
else: else:
_log_foxglove_image( _log_foxglove_image(
_foxglove_topic(k, is_image=True), _foxglove_topic(k, is_image=True),
@@ -425,8 +459,7 @@ def log_foxglove_data(
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()): action_scalars.update(_labeled_scalars(key, v.flatten()))
action_scalars[f"{key}_{i}"] = float(vi)
_log_foxglove_scalars(_foxglove_topic(ACTION), action_scalars, log_time=now) _log_foxglove_scalars(_foxglove_topic(ACTION), action_scalars, log_time=now)
@@ -455,7 +488,7 @@ def _feature_dim_names(feature: dict | None) -> list[str] | None:
values = list(names.values()) values = list(names.values())
if values and all(isinstance(v, (list, tuple)) for v in values): if values and all(isinstance(v, (list, tuple)) for v in values):
labels = [str(n) for group in values for n in group] labels = [str(n) for group in values for n in group]
elif values and all(isinstance(v, int) for v in values): elif values and all(isinstance(v, int) and not isinstance(v, bool) for v in values):
labels = [name for name, _ in sorted(names.items(), key=lambda kv: kv[1])] labels = [name for name, _ in sorted(names.items(), key=lambda kv: kv[1])]
elif isinstance(names, (list, tuple)): elif isinstance(names, (list, tuple)):
labels = [str(n) for n in names] labels = [str(n) for n in names]
@@ -468,18 +501,24 @@ def _frame_to_scalars(sample: dict, key: str, labels: list[str] | None = None) -
"""Flatten a frame's vector/scalar feature ``key`` into ``{label: value}`` entries. """Flatten a frame's vector/scalar feature ``key`` into ``{label: value}`` entries.
``labels`` provides one name per dimension (from the dataset's feature metadata); when absent or ``labels`` provides one name per dimension (from the dataset's feature metadata); when absent or
the wrong length, dimensions fall back to their index. A scalar feature becomes a single entry. the wrong length, dimensions fall back to ``{name}_{i}`` (the short feature name), matching the
Missing or ``None`` features yield an empty mapping. live stream so series names agree. A scalar feature becomes a single entry. Missing or ``None``
features yield an empty mapping.
""" """
v = sample.get(key) v = sample.get(key)
if v is None: if v is None:
return {} return {}
arr = v.numpy() if hasattr(v, "numpy") else np.asarray(v) arr = v.numpy() if hasattr(v, "numpy") else np.asarray(v)
flat = [float(arr)] if arr.ndim == 0 else [float(x) for x in arr.flatten()] if key.startswith(OBS_PREFIX):
if labels is None or len(labels) != len(flat): name = key[len(OBS_PREFIX) :]
labels = [str(i) for i in range(len(flat))] elif key.startswith(ACTION_PREFIX):
return dict(zip(labels, flat, strict=True)) name = key[len(ACTION_PREFIX) :]
else:
name = key
if arr.ndim == 0:
return {name: float(arr)}
return _labeled_scalars(name, arr.flatten(), labels)
def serve_foxglove_dataset_playback( def serve_foxglove_dataset_playback(
@@ -543,8 +582,6 @@ def serve_foxglove_dataset_playback(
if arr is None: if arr is None:
continue continue
arr = arr.numpy() if hasattr(arr, "numpy") else np.asarray(arr) arr = arr.numpy() if hasattr(arr, "numpy") else np.asarray(arr)
if np.issubdtype(arr.dtype, np.floating):
arr = (arr * 255.0).clip(0, 255).astype(np.uint8)
_log_foxglove_image( _log_foxglove_image(
_foxglove_topic(key, is_image=True), _foxglove_topic(key, is_image=True),
key, key,