diff --git a/src/lerobot/configs/__init__.py b/src/lerobot/configs/__init__.py index 168b367db..c32e3368b 100644 --- a/src/lerobot/configs/__init__.py +++ b/src/lerobot/configs/__init__.py @@ -34,6 +34,8 @@ from .types import ( ) from .video import ( DEFAULT_DEPTH_UNIT, + DEPTH_METER_UNIT, + DEPTH_MILLIMETER_UNIT, VALID_VIDEO_CODECS, VIDEO_ENCODER_INFO_KEYS, DepthEncoderConfig, @@ -41,6 +43,7 @@ from .video import ( VideoEncoderConfig, depth_encoder_defaults, encoder_config_from_video_info, + infer_depth_unit, rgb_encoder_defaults, ) @@ -70,8 +73,11 @@ __all__ = [ "depth_encoder_defaults", # Factories "encoder_config_from_video_info", + "infer_depth_unit", # Constants "DEFAULT_DEPTH_UNIT", + "DEPTH_METER_UNIT", + "DEPTH_MILLIMETER_UNIT", "VALID_VIDEO_CODECS", "VIDEO_ENCODER_INFO_KEYS", ] diff --git a/src/lerobot/configs/video.py b/src/lerobot/configs/video.py index e253526f6..20e46a387 100644 --- a/src/lerobot/configs/video.py +++ b/src/lerobot/configs/video.py @@ -22,6 +22,8 @@ import logging from dataclasses import dataclass, field from typing import Any, ClassVar, Self +import numpy as np + from lerobot.utils.import_utils import require_package logger = logging.getLogger(__name__) @@ -67,6 +69,15 @@ DEPTH_METER_UNIT: str = "m" DEPTH_MILLIMETER_UNIT: str = "mm" DEFAULT_DEPTH_UNIT: str = DEPTH_MILLIMETER_UNIT + +def infer_depth_unit(dtype: np.dtype | type) -> str: + """Infer the physical unit of raw depth frames from their dtype. + + Floating-point frames are assumed to be in metres, integer frames in millimetres. + """ + return DEPTH_METER_UNIT if np.issubdtype(np.dtype(dtype), np.floating) else DEPTH_MILLIMETER_UNIT + + # Depth-specific tuning fields persisted under ``features[*]["info"]`` as ``video.``. DEPTH_ENCODER_INFO_FIELD_NAMES: frozenset[str] = frozenset({"depth_min", "depth_max", "shift", "use_log"}) diff --git a/src/lerobot/datasets/compute_stats.py b/src/lerobot/datasets/compute_stats.py index 88f7ea226..02ecd81a4 100644 --- a/src/lerobot/datasets/compute_stats.py +++ b/src/lerobot/datasets/compute_stats.py @@ -509,7 +509,7 @@ def compute_episode_stats( For 'image'/'video' features, stats are computed per channel and kept with a leading channel axis (e.g. shape (3, 1, 1) for RGB). RGB stats are divided by 255 to land in [0, 1]; depth maps (features flagged with ``is_depth_map``) skip - this rescaling and remain in their stored units. + this rescaling and remain in their stored units (stored in ``depth_unit``). """ if quantile_list is None: quantile_list = DEFAULT_QUANTILES diff --git a/src/lerobot/datasets/dataset_metadata.py b/src/lerobot/datasets/dataset_metadata.py index ea329668c..6e19d14fb 100644 --- a/src/lerobot/datasets/dataset_metadata.py +++ b/src/lerobot/datasets/dataset_metadata.py @@ -26,12 +26,13 @@ import pyarrow as pa import pyarrow.parquet as pq from huggingface_hub import snapshot_download -from lerobot.configs import VideoEncoderConfig +from lerobot.configs import DEPTH_METER_UNIT, VideoEncoderConfig from lerobot.utils.constants import DEFAULT_FEATURES, HF_LEROBOT_HOME, HF_LEROBOT_HUB_CACHE from lerobot.utils.feature_utils import _validate_feature_names from lerobot.utils.utils import flatten_dict from .compute_stats import aggregate_stats +from .depth_utils import MM_PER_METRE from .feature_utils import create_empty_dataset_info from .io_utils import ( get_file_size_in_mb, @@ -358,6 +359,35 @@ class LeRobotDatasetMetadata: return [key for key, ft in self.features.items() if _is_depth(ft)] + def rescale_depth_stats(self, output_unit: str) -> None: + """Rescale depth feature stats in place from their recorded unit to ``output_unit``. + + Depth stats are stored in the unit the frames were recorded in + (``features[key]["info"]["depth_unit"]``), while frames are returned in + ``output_unit`` on read. This converts the unit-bearing stat entries so + stats match the frames consumers see. + """ + missing_unit_keys = [ + key for key in self.depth_keys if (self.features[key].get("info") or {}).get("depth_unit") is None + ] + if missing_unit_keys: + logging.warning( + f"Depth feature(s) {missing_unit_keys} have no recorded 'depth_unit' in their info. " + f"Depth maps and stats for these keys will be returned AS IS, with no unit conversion " + f"to the requested output unit {output_unit!r}. Re-record the dataset or set 'depth_unit' " + f"in the feature info (meta/info.json) to enable conversion." + ) + if self.stats is None: + return + for key in self.depth_keys: + stored_unit = (self.features[key].get("info") or {}).get("depth_unit") + if stored_unit is None or stored_unit == output_unit or key not in self.stats: + continue + factor = MM_PER_METRE if stored_unit == DEPTH_METER_UNIT else 1.0 / MM_PER_METRE + self.stats[key] = { + stat: value if stat == "count" else value * factor for stat, value in self.stats[key].items() + } + @property def camera_keys(self) -> list[str]: """Keys to access visual modalities (regardless of their storage method).""" diff --git a/src/lerobot/datasets/dataset_reader.py b/src/lerobot/datasets/dataset_reader.py index e8e07301e..f4e1f6a31 100644 --- a/src/lerobot/datasets/dataset_reader.py +++ b/src/lerobot/datasets/dataset_reader.py @@ -22,10 +22,14 @@ from pathlib import Path import datasets import torch -from lerobot.configs import DEFAULT_DEPTH_UNIT, DepthEncoderConfig +from lerobot.configs import ( + DEFAULT_DEPTH_UNIT, + DEPTH_METER_UNIT, + DepthEncoderConfig, +) from .dataset_metadata import LeRobotDatasetMetadata -from .depth_utils import dequantize_depth +from .depth_utils import MM_PER_METRE, dequantize_depth from .feature_utils import ( check_delta_timestamps, get_delta_indices, @@ -102,6 +106,13 @@ class DatasetReader: for vid_key in self._meta.depth_keys } + # Get the input unit of each depth feature stored as raw images. + self._image_depth_units: dict[str, str | None] = { + key: (self._meta.features[key].get("info") or {}).get("depth_unit") + for key in self._meta.depth_keys + if key in self._meta.image_keys + } + def set_image_transforms(self, image_transforms: Callable | None) -> None: """Replace the transform applied to visual observations.""" if image_transforms is not None and not callable(image_transforms): @@ -329,6 +340,13 @@ class DatasetReader: continue item[cam] = self._image_transforms(item[cam]) + # Convert depth features to the output unit. + for key, stored_unit in self._image_depth_units.items(): + if key in item and stored_unit is not None and stored_unit != self._depth_output_unit: + item[key] = ( + item[key] * MM_PER_METRE if stored_unit == DEPTH_METER_UNIT else item[key] / MM_PER_METRE + ) + # Add task as a string task_idx = item["task_index"].item() item["task"] = self._meta.tasks.iloc[task_idx].name diff --git a/src/lerobot/datasets/dataset_writer.py b/src/lerobot/datasets/dataset_writer.py index 1aee1497c..a6049312f 100644 --- a/src/lerobot/datasets/dataset_writer.py +++ b/src/lerobot/datasets/dataset_writer.py @@ -36,6 +36,7 @@ from lerobot.configs import ( RGBEncoderConfig, VideoEncoderConfig, depth_encoder_defaults, + infer_depth_unit, rgb_encoder_defaults, ) @@ -209,6 +210,15 @@ class DatasetWriter: self.episode_buffer["timestamp"].append(timestamp) self.episode_buffer["task"].append(frame.pop("task")) + # Record each depth feature's input unit once, inferred from the first frame's dtype. + if frame_index == 0: + for depth_key in self._meta.depth_keys: + if depth_key not in frame: + continue + info = self._meta.features[depth_key].setdefault("info", {}) + if info.get("depth_unit") is None: + info["depth_unit"] = infer_depth_unit(np.asarray(frame[depth_key]).dtype) + # Start streaming encoder on first frame of episode if frame_index == 0 and self._streaming_encoder is not None: self._streaming_encoder.start_episode( diff --git a/src/lerobot/datasets/depth_utils.py b/src/lerobot/datasets/depth_utils.py index 801c86a09..a4e187eb4 100644 --- a/src/lerobot/datasets/depth_utils.py +++ b/src/lerobot/datasets/depth_utils.py @@ -34,12 +34,13 @@ from lerobot.configs.video import ( DEPTH_METER_UNIT, DEPTH_MILLIMETER_UNIT, DEPTH_QMAX, + infer_depth_unit, ) from .image_writer import squeeze_single_channel from .pyav_utils import write_u16_plane -_MM_PER_METRE = 1000.0 +MM_PER_METRE = 1000.0 _UINT16_MAX = 65535 @@ -57,11 +58,7 @@ def _depth_input_to_float32_and_unit( input_unit: Literal["auto", DEPTH_METER_UNIT, DEPTH_MILLIMETER_UNIT], ) -> tuple[NDArray[np.float32], Literal[DEPTH_METER_UNIT, DEPTH_MILLIMETER_UNIT]]: """Convert depth to float32 in the chosen unit, and return the resolved unit.""" - resolved_unit = ( - (DEPTH_METER_UNIT if np.issubdtype(depth.dtype, np.floating) else DEPTH_MILLIMETER_UNIT) - if input_unit == "auto" - else input_unit - ) + resolved_unit = infer_depth_unit(depth.dtype) if input_unit == "auto" else input_unit return depth.astype(np.float32, order="K"), resolved_unit @@ -126,12 +123,12 @@ def quantize_depth( # Convert depth_min, depth_max, and shift to the resolved input unit. depth_min_u = ( - np.float32(depth_min) if resolved_unit == DEPTH_METER_UNIT else np.float32(depth_min * _MM_PER_METRE) + np.float32(depth_min) if resolved_unit == DEPTH_METER_UNIT else np.float32(depth_min * MM_PER_METRE) ) depth_max_u = ( - np.float32(depth_max) if resolved_unit == DEPTH_METER_UNIT else np.float32(depth_max * _MM_PER_METRE) + np.float32(depth_max) if resolved_unit == DEPTH_METER_UNIT else np.float32(depth_max * MM_PER_METRE) ) - shift_u = np.float32(shift) if resolved_unit == DEPTH_METER_UNIT else np.float32(shift * _MM_PER_METRE) + shift_u = np.float32(shift) if resolved_unit == DEPTH_METER_UNIT else np.float32(shift * MM_PER_METRE) # Normalization and quantization is performed in the resolved input unit. if use_log: @@ -236,7 +233,7 @@ def dequantize_depth( # mm path: round + clamp in float32, skipping the uint16 round-trip # when returning a tensor (torch.uint16 is poorly supported). - buf.mul_(_MM_PER_METRE).round_().clamp_(0.0, _UINT16_MAX) + buf.mul_(MM_PER_METRE).round_().clamp_(0.0, _UINT16_MAX) if output_tensor: return buf return buf.cpu().numpy().astype(np.uint16, copy=False) @@ -259,7 +256,7 @@ def dequantize_depth( if output_unit == DEPTH_METER_UNIT: return torch.from_numpy(buf) if output_tensor else buf - np.multiply(buf, _MM_PER_METRE, out=buf) + np.multiply(buf, MM_PER_METRE, out=buf) np.rint(buf, out=buf) np.clip(buf, 0.0, _UINT16_MAX, out=buf) if output_tensor: diff --git a/src/lerobot/datasets/lerobot_dataset.py b/src/lerobot/datasets/lerobot_dataset.py index f600f1804..aba86efe3 100644 --- a/src/lerobot/datasets/lerobot_dataset.py +++ b/src/lerobot/datasets/lerobot_dataset.py @@ -224,6 +224,7 @@ class LeRobotDataset(torch.utils.data.Dataset): ) self.root = self.meta.root self.revision = self.meta.revision + self.meta.rescale_depth_stats(self._depth_output_unit) if episodes is not None and any( episode >= self.meta.total_episodes or episode < 0 for episode in episodes @@ -350,6 +351,11 @@ class LeRobotDataset(torch.utils.data.Dataset): """Frames per second used during data collection.""" return self.meta.fps + @property + def depth_output_unit(self) -> str: + """Physical unit (``"m"`` or ``"mm"``) depth maps and statistics are returned in on read.""" + return self._depth_output_unit + @property def num_frames(self) -> int: """Number of frames in selected episodes.""" diff --git a/src/lerobot/datasets/streaming_dataset.py b/src/lerobot/datasets/streaming_dataset.py index 4c4ae59bf..14d4a52a4 100644 --- a/src/lerobot/datasets/streaming_dataset.py +++ b/src/lerobot/datasets/streaming_dataset.py @@ -22,11 +22,11 @@ import numpy as np import torch from datasets import load_dataset -from lerobot.configs import DEFAULT_DEPTH_UNIT, DepthEncoderConfig +from lerobot.configs import DEFAULT_DEPTH_UNIT, DEPTH_METER_UNIT, DepthEncoderConfig from lerobot.utils.constants import HF_LEROBOT_HOME, LOOKAHEAD_BACKTRACKTABLE, LOOKBACK_BACKTRACKTABLE from .dataset_metadata import CODEBASE_VERSION, LeRobotDatasetMetadata -from .depth_utils import dequantize_depth +from .depth_utils import MM_PER_METRE, dequantize_depth from .feature_utils import get_delta_indices from .io_utils import item_to_torch from .utils import ( @@ -310,6 +310,7 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset): ) self.root = self.meta.root self.revision = self.meta.revision + self.meta.rescale_depth_stats(self._depth_output_unit) # Check version check_version_compatibility(self.repo_id, self.meta._version, CODEBASE_VERSION) @@ -318,6 +319,13 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset): for vid_key in self.meta.depth_keys } + # Input unit of each depth feature stored as raw images (dequantized separately from videos). + self._image_depth_units: dict[str, str | None] = { + key: (self.meta.features[key].get("info") or {}).get("depth_unit") + for key in self.meta.depth_keys + if key in self.meta.image_keys + } + self.delta_timestamps = None self.delta_indices = None @@ -348,6 +356,11 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset): def fps(self): return self.meta.fps + @property + def depth_output_unit(self) -> str: + """Physical unit (``"m"`` or ``"mm"``) depth maps are returned in on read.""" + return self._depth_output_unit + @staticmethod def _iter_random_indices( rng: np.random.Generator, buffer_size: int, random_batch_size=100 @@ -530,6 +543,15 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset): for update in updates: result.update(update) + # Convert raw-image depth features to the output unit (video depth is already converted). + for key, stored_unit in self._image_depth_units.items(): + if key in result and stored_unit is not None and stored_unit != self._depth_output_unit: + result[key] = ( + result[key] * MM_PER_METRE + if stored_unit == DEPTH_METER_UNIT + else result[key] / MM_PER_METRE + ) + result["task"] = self.meta.tasks.iloc[item["task_index"]].name yield result diff --git a/src/lerobot/scripts/lerobot_dataset_viz.py b/src/lerobot/scripts/lerobot_dataset_viz.py index ee25583a0..f4be878ad 100644 --- a/src/lerobot/scripts/lerobot_dataset_viz.py +++ b/src/lerobot/scripts/lerobot_dataset_viz.py @@ -84,6 +84,7 @@ import torch import torch.utils.data import tqdm +from lerobot.configs import DEPTH_MILLIMETER_UNIT from lerobot.datasets import LeRobotDataset from lerobot.utils.constants import ACTION, DONE, OBS_STATE, REWARD, SUCCESS from lerobot.utils.utils import init_logging @@ -228,6 +229,9 @@ def visualize_dataset( logging.info("Logging to Rerun") + # Depth frames and stats are dequantized to the dataset's depth_output_unit on load. + depth_meter = 1000.0 if dataset.depth_output_unit == DEPTH_MILLIMETER_UNIT else 1.0 + # Use the dataset's q01/q99 depth statistics for robust depth range bounds depth_ranges = {} for key in dataset.meta.depth_keys: @@ -254,6 +258,7 @@ def visualize_dataset( depth = to_hwc_float32_numpy(batch[key][i]) depth_entity = rr.DepthImage( depth, + meter=depth_meter, colormap=rr.components.Colormap.Viridis, depth_range=depth_ranges.get(key), ) diff --git a/src/lerobot/utils/rerun_visualization.py b/src/lerobot/utils/rerun_visualization.py index af04b18f7..46f2c0b4b 100644 --- a/src/lerobot/utils/rerun_visualization.py +++ b/src/lerobot/utils/rerun_visualization.py @@ -24,6 +24,7 @@ import os import numpy as np +from lerobot.configs import DEPTH_MILLIMETER_UNIT, infer_depth_unit from lerobot.types import RobotAction, RobotObservation from .constants import ACTION, ACTION_PREFIX, OBS_PREFIX, OBS_STR @@ -161,7 +162,13 @@ def log_rerun_data( observation_paths.add(key) else: if arr.shape[-1] == 1: - img_entity = rr.DepthImage(arr, colormap=rr.components.Colormap.Viridis) + # At record time, the depth unit is inferred from the frame type. + depth_unit = infer_depth_unit(arr.dtype) + img_entity = rr.DepthImage( + arr, + meter=1000.0 if depth_unit == DEPTH_MILLIMETER_UNIT else 1.0, + colormap=rr.components.Colormap.Viridis, + ) else: img_entity = rr.Image(arr).compress() if compress_images else rr.Image(arr) rr.log(key, entity=img_entity, static=True) diff --git a/tests/datasets/test_depth.py b/tests/datasets/test_depth.py index a075fa6b5..5391cc558 100644 --- a/tests/datasets/test_depth.py +++ b/tests/datasets/test_depth.py @@ -32,6 +32,7 @@ from lerobot.configs.video import ( ) from lerobot.datasets.depth_utils import dequantize_depth, quantize_depth from lerobot.datasets.image_writer import image_array_to_pil_image, write_image +from lerobot.utils.constants import DEFAULT_FEATURES from tests.fixtures.constants import ( DEFAULT_FPS, DUMMY_CAMERA_FEATURES, @@ -245,3 +246,91 @@ class TestFeatureFileRouting: dataset.save_episode() dataset.finalize() + + +class TestDepthUnitMetadata: + """The depth unit is inferred once from dtype, stored in ``info``, and drives stats + reads.""" + + NUM_FRAMES = 4 + + def _record(self, root, features_factory, depth_dtype, value, use_videos): + from lerobot.datasets.lerobot_dataset import LeRobotDataset + + features = features_factory(camera_features=DUMMY_CAMERA_FEATURES_WITH_DEPTH, use_videos=use_videos) + dataset = LeRobotDataset.create( + repo_id=DUMMY_REPO_ID, + fps=DEFAULT_FPS, + features=features, + root=root, + use_videos=use_videos, + streaming_encoding=use_videos, + ) + for _ in range(self.NUM_FRAMES): + frame: dict = {"task": "test"} + for key, ft in dataset.meta.features.items(): + if key in DEFAULT_FEATURES: + continue + if key in dataset.meta.depth_keys: + frame[key] = np.full(ft["shape"], value, dtype=depth_dtype) + elif key in dataset.meta.camera_keys: + frame[key] = np.random.randint(0, 256, ft["shape"], dtype=np.uint8) + else: + frame[key] = np.zeros(ft["shape"], dtype=np.float32) + dataset.add_frame(frame) + return dataset + + @pytest.mark.parametrize("use_videos", [False, True]) + @pytest.mark.parametrize( + ("depth_dtype", "value", "expected_unit"), + [(np.float32, 2.0, DEPTH_METER_UNIT), (np.uint16, 2000, DEPTH_MILLIMETER_UNIT)], + ) + def test_recorded_unit_inferred_persisted_and_kept_in_stats( + self, tmp_path, features_factory, use_videos, depth_dtype, value, expected_unit + ): + """Unit is inferred from the first frame's dtype, drives stats (raw, never canonicalized), and survives a reload.""" + from lerobot.datasets.lerobot_dataset import LeRobotDataset + + dataset = self._record(tmp_path / "ds", features_factory, depth_dtype, value, use_videos) + assert dataset.meta.features[DEPTH_KEY]["info"]["depth_unit"] == expected_unit + dataset.save_episode() + mean = float(np.asarray(dataset.meta.stats[DEPTH_KEY]["mean"]).reshape(-1)[0]) + np.testing.assert_allclose(mean, value, rtol=0.05) + dataset.finalize() + + reloaded = LeRobotDataset(repo_id=DUMMY_REPO_ID, root=tmp_path / "ds") + assert reloaded.meta.features[DEPTH_KEY]["info"]["depth_unit"] == expected_unit + + @pytest.mark.parametrize("use_videos", [False, True]) + @pytest.mark.parametrize( + ("output_unit", "expected"), + [(DEPTH_MILLIMETER_UNIT, 2000.0), (DEPTH_METER_UNIT, 2.0)], + ) + def test_read_honors_output_unit_for_frames_and_stats( + self, tmp_path, features_factory, use_videos, output_unit, expected + ): + """Reloading with a ``depth_output_unit`` converts metre frames (image mode) and rescales stats while preserving count.""" + from lerobot.datasets.lerobot_dataset import LeRobotDataset + + dataset = self._record(tmp_path / "ds", features_factory, np.float32, 2.0, use_videos=use_videos) + dataset.save_episode() + count = float(np.asarray(dataset.meta.stats[DEPTH_KEY]["count"]).reshape(-1)[0]) + dataset.finalize() + + read_dataset = LeRobotDataset( + repo_id=DUMMY_REPO_ID, root=tmp_path / "ds", depth_output_unit=output_unit + ) + stats = read_dataset.meta.stats[DEPTH_KEY] + np.testing.assert_allclose(float(np.asarray(stats["mean"]).reshape(-1)[0]), expected, rtol=0.05) + np.testing.assert_allclose(float(np.asarray(stats["count"]).reshape(-1)[0]), count) + + if not use_videos: + depth = read_dataset[0][DEPTH_KEY] + assert torch.allclose(depth, torch.full_like(depth, expected)) + + from lerobot.datasets.streaming_dataset import StreamingLeRobotDataset + + stream_dataset = StreamingLeRobotDataset( + repo_id=DUMMY_REPO_ID, root=tmp_path / "ds", depth_output_unit=output_unit + ) + stream_depth = next(iter(stream_dataset))[DEPTH_KEY] + assert torch.allclose(stream_depth, torch.full_like(stream_depth, expected)) diff --git a/tests/fixtures/dataset_factories.py b/tests/fixtures/dataset_factories.py index 100922f9c..5c0b0f524 100644 --- a/tests/fixtures/dataset_factories.py +++ b/tests/fixtures/dataset_factories.py @@ -26,6 +26,7 @@ import pytest import torch from datasets import Dataset +from lerobot.configs.video import infer_depth_unit from lerobot.datasets.dataset_metadata import CODEBASE_VERSION, LeRobotDatasetMetadata from lerobot.datasets.feature_utils import get_hf_features_from_features from lerobot.datasets.io_utils import flatten_dict, hf_transform_to_torch @@ -535,6 +536,13 @@ def lerobot_dataset_factory( chunks_size=chunks_size, **info_kwargs, ) + # This synthetic path skips add_frame, so record the depth unit the writer would + # have stored (dummy depth is uint16) to keep ``depth_unit`` present in info.json. + # Reassign a fresh info dict to avoid mutating the shared feature constants. + for ft in info.features.values(): + ft_info = ft.get("info") + if ft_info is not None and ft_info.get("is_depth_map") and "depth_unit" not in ft_info: + ft["info"] = {**ft_info, "depth_unit": infer_depth_unit(np.uint16)} if stats is None: stats = stats_factory(features=info.features) if tasks is None: diff --git a/tests/utils/test_rerun_visualization.py b/tests/utils/test_rerun_visualization.py index e3d205dee..d4c3e6999 100644 --- a/tests/utils/test_rerun_visualization.py +++ b/tests/utils/test_rerun_visualization.py @@ -50,8 +50,9 @@ def mock_rerun(monkeypatch): return self class DummyDepthImage: - def __init__(self, arr, colormap=None): + def __init__(self, arr, meter=None, colormap=None): self.arr = arr + self.meter = meter self.colormap = colormap def dummy_log(key, obj=None, **kwargs):