Skip to content

Commit 8ad2881

Browse files
Add view saving and loading functionality, enhance plot item management, and implement view snapshot serialization
- Updated visualizer UI to include actions for saving and loading views. - Enhanced PlotItem class with methods to clear the canvas, capture, and apply canvas state. - Introduced view_snapshot.py to define data structures for serializing UI state to/from JSON.
1 parent 7f3a88f commit 8ad2881

File tree

10 files changed

+1467
-61
lines changed

10 files changed

+1467
-61
lines changed

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,25 @@ Once you have installed the `robot-log-visualizer` you can run it from the termi
5555
You can navigate the dataset thanks to the slider or by pressing `Ctrl-f` and `Ctrl-b` to move
5656
forward and backward.
5757

58+
To pre-load a dataset on startup, pass it as an argument:
59+
60+
```bash
61+
robot-log-visualizer /path/to/dataset.mat
62+
```
63+
64+
If you saved a snapshot of your favourite layout, you can restore it right away:
65+
66+
```bash
67+
robot-log-visualizer --snapshot /path/to/view.json
68+
```
69+
70+
You can also combine both parameters; in that case the dataset argument is loaded first and then
71+
the snapshot is applied:
72+
73+
```bash
74+
robot-log-visualizer /path/to/dataset.mat --snapshot /path/to/view.json
75+
```
76+
5877
> [!IMPORTANT]
5978
> `robot-log-visualizer` only supports reading `.mat` file [version 7.3 or newer](https://www.mathworks.com/help/matlab/import_export/mat-file-versions.html).
6079

robot_log_visualizer/__main__.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44
# This software may be modified and distributed under the terms of the
55
# Released under the terms of the BSD 3-Clause License
66

7+
import argparse
8+
import pathlib
79
import sys
10+
from typing import Optional, Sequence
811

912
# GUI
1013
from qtpy.QtWidgets import QApplication
@@ -14,7 +17,28 @@
1417
from robot_log_visualizer.robot_visualizer.meshcat_provider import MeshcatProvider
1518

1619

17-
def main():
20+
def _parse_arguments(argv: Sequence[str]) -> argparse.Namespace:
21+
parser = argparse.ArgumentParser(
22+
description="Robot Log Visualizer",
23+
)
24+
parser.add_argument(
25+
"dataset",
26+
nargs="?",
27+
help="Path to a MAT dataset to load on startup.",
28+
)
29+
parser.add_argument(
30+
"-s",
31+
"--snapshot",
32+
help="Path to a view snapshot (.json) to restore on startup.",
33+
)
34+
35+
return parser.parse_args(argv)
36+
37+
38+
def main(argv: Optional[Sequence[str]] = None):
39+
parsed_argv = list(argv) if argv is not None else sys.argv[1:]
40+
args = _parse_arguments(parsed_argv)
41+
1842
thread_periods = {
1943
"meshcat_provider": 0.03,
2044
"signal_provider": 0.03,
@@ -36,6 +60,16 @@ def main():
3660
# show the main window
3761
gui.show()
3862

63+
if args.dataset:
64+
dataset_path = pathlib.Path(args.dataset).expanduser()
65+
if not gui._load_mat_file(str(dataset_path), quiet=False): # noqa: SLF001
66+
print(f"Failed to load dataset '{dataset_path}'.", file=sys.stderr)
67+
68+
if args.snapshot:
69+
snapshot_path = pathlib.Path(args.snapshot).expanduser()
70+
if not gui.load_view_snapshot_from_path(snapshot_path):
71+
print(f"Failed to load snapshot '{snapshot_path}'.", file=sys.stderr)
72+
3973
exec_method = getattr(app, "exec", None)
4074
if exec_method is None:
4175
exec_method = app.exec_

robot_log_visualizer/plotter/pyqtgraph_viewer_canvas.py

Lines changed: 191 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from __future__ import annotations
66

7-
from typing import Dict, Iterable, Sequence, Tuple
7+
from typing import Any, Dict, Iterable, List, Sequence, Tuple
88

99
import numpy as np
1010
import pyqtgraph as pg # type: ignore
@@ -57,6 +57,7 @@ def __init__(
5757
self._curves: Dict[str, pg.PlotDataItem] = {}
5858
self._annotations: Dict[Point, pg.TextItem] = {}
5959
self._markers: Dict[Point, pg.ScatterPlotItem] = {}
60+
self._annotation_sources: Dict[Point, str] = {}
6061
self._palette: Iterable = ColorPalette()
6162

6263
# UI set‑up
@@ -85,21 +86,23 @@ def set_signal_provider(self, signal_provider) -> None:
8586
self._update_realtime_curves
8687
)
8788

88-
def update_plots(self, paths: Sequence[Path], legends: Sequence[Legend]) -> None:
89+
def update_plots(
90+
self, paths: Sequence[Path], legends: Sequence[Legend]
91+
) -> List[str]:
8992
"""Synchronise plots with the *paths* list.
9093
9194
New items are added, disappeared items removed. Existing ones are
9295
left untouched to avoid flicker.
9396
"""
9497
if self._signal_provider is None:
95-
return
98+
return []
9699

97100
# For real-time provider, update the set of selected signals to buffer
98101
if self._signal_provider.provider_type == ProviderType.REALTIME:
99102
selected_keys = ["::".join(path) for path in paths]
100103
self._signal_provider.add_signals_to_buffer(selected_keys)
101104

102-
self._add_missing_curves(paths, legends)
105+
missing_paths = self._add_missing_curves(paths, legends)
103106
self._remove_obsolete_curves(paths)
104107

105108
# Set the X axis range based on the provider type
@@ -117,6 +120,8 @@ def update_plots(self, paths: Sequence[Path], legends: Sequence[Legend]) -> None
117120
self._signal_provider.end_time - self._signal_provider.initial_time,
118121
)
119122

123+
return missing_paths
124+
120125
# The following trio is wired to whoever controls the replay/stream
121126
def pause_animation(self) -> None: # noqa: D401
122127
"""Pause the vertical‑line animation."""
@@ -173,23 +178,46 @@ def _connect_signals(self) -> None:
173178

174179
def _add_missing_curves(
175180
self, paths: Sequence[Path], legends: Sequence[Legend]
176-
) -> None:
181+
) -> List[str]:
177182
"""Plot curves that are present in *paths* but not yet on screen."""
183+
184+
missing: List[str] = []
178185
for path, legend in zip(paths, legends):
179186
key = "/".join(path)
180187
if key in self._curves:
181188
continue
182189

183190
# Drill down to the data array
184-
data = self._signal_provider.data
185-
for subkey in path[:-1]:
186-
data = data[subkey]
187191
try:
188-
y = data["data"][:, int(path[-1])]
189-
except (IndexError, ValueError): # scalar variable
190-
y = data["data"][:]
192+
data = self._signal_provider.data
193+
for subkey in path[:-1]:
194+
data = data[subkey]
195+
196+
data_array = data["data"]
197+
timestamps = data["timestamps"]
198+
except (KeyError, TypeError):
199+
missing.append(key)
200+
continue
201+
202+
try:
203+
y = data_array[:, int(path[-1])]
204+
except (
205+
IndexError,
206+
ValueError,
207+
TypeError,
208+
): # scalar variable or invalid index
209+
try:
210+
y = data_array[:]
211+
except Exception:
212+
missing.append(key)
213+
continue
214+
215+
try:
216+
x = timestamps - self._signal_provider.initial_time
217+
except Exception:
218+
missing.append(key)
219+
continue
191220

192-
x = data["timestamps"] - self._signal_provider.initial_time
193221
palette_color = next(self._palette)
194222
pen = pg.mkPen(palette_color.as_hex(), width=2)
195223

@@ -201,12 +229,21 @@ def _add_missing_curves(
201229
symbol=None,
202230
)
203231

232+
return missing
233+
204234
def _remove_obsolete_curves(self, paths: Sequence[Path]) -> None:
205235
"""Delete curves that disappeared from *paths*."""
206236
valid = {"/".join(p) for p in paths}
207237
for key in [k for k in self._curves if k not in valid]:
208238
self._plot.removeItem(self._curves.pop(key))
209239

240+
# Remove annotations associated to the deleted curve
241+
orphan_points = [
242+
pt for pt, src in self._annotation_sources.items() if src == key
243+
]
244+
for point in orphan_points:
245+
self._deselect(point)
246+
210247
def _update_vline(self) -> None:
211248
"""Move the vertical line to ``current_time``."""
212249
if self._signal_provider is None:
@@ -279,10 +316,11 @@ def _on_mouse_click(self, event) -> None: # noqa: N802
279316
self._deselect(candidate)
280317
else:
281318
assert nearest_curve is not None # mypy‑friendly
282-
self._select(candidate, nearest_curve.opts["pen"])
319+
self._select(candidate, nearest_curve)
283320

284-
def _select(self, pt: Point, pen: pg.QtGui.QPen) -> None:
321+
def _select(self, pt: Point, curve: pg.PlotDataItem) -> None:
285322
"""Add label + circle marker on *pt*."""
323+
pen = curve.opts["pen"]
286324
x_span = np.diff(self._plot.viewRange()[0])[0]
287325
y_span = np.diff(self._plot.viewRange()[1])[0]
288326
x_prec = max(0, int(-np.log10(max(x_span, 1e-12))) + 2)
@@ -313,14 +351,153 @@ def _select(self, pt: Point, pen: pg.QtGui.QPen) -> None:
313351
self._plot.addItem(marker)
314352
self._markers[pt] = marker
315353

354+
curve_key = self._curve_key(curve)
355+
if curve_key is not None:
356+
self._annotation_sources[pt] = curve_key
357+
316358
def _deselect(self, pt: Point) -> None:
317359
"""Remove annotation + marker on *pt*."""
318360
self._plot.removeItem(self._annotations.pop(pt))
319361
self._plot.removeItem(self._markers.pop(pt))
362+
self._annotation_sources.pop(pt, None)
320363

321364
def clear_selections(self) -> None: # noqa: D401
322365
"""Remove **all** annotations and markers."""
323366
for item in (*self._annotations.values(), *self._markers.values()):
324367
self._plot.removeItem(item)
325368
self._annotations.clear()
326369
self._markers.clear()
370+
self._annotation_sources.clear()
371+
372+
def clear_curves(self) -> None:
373+
"""Remove every plotted curve and related markers."""
374+
for key in list(self._curves.keys()):
375+
self._plot.removeItem(self._curves.pop(key))
376+
self.clear_selections()
377+
378+
def capture_state(self) -> Dict[str, Any]:
379+
"""Return a snapshot of the current canvas configuration."""
380+
381+
annotations: List[Dict[str, Any]] = []
382+
for pt, label in self._annotations.items():
383+
source = self._annotation_sources.get(pt)
384+
if source is None:
385+
continue
386+
annotations.append(
387+
{
388+
"path": source,
389+
"point": [float(pt[0]), float(pt[1])],
390+
"label": label.toPlainText(),
391+
}
392+
)
393+
394+
curve_meta: Dict[str, Dict[str, Any]] = {}
395+
for key, curve in self._curves.items():
396+
pen_info: Dict[str, Any] = {}
397+
pen = curve.opts.get("pen")
398+
if pen is not None:
399+
qcol = pen.color()
400+
pen_info["color"] = (
401+
f"#{qcol.red():02x}{qcol.green():02x}{qcol.blue():02x}"
402+
)
403+
pen_info["width"] = pen.width()
404+
curve_meta[key] = {
405+
"label": curve.opts.get("name", ""),
406+
"pen": pen_info,
407+
}
408+
409+
return {
410+
"curves": curve_meta,
411+
"view_range": self._plot.viewRange(),
412+
"legend_visible": bool(self._plot.plotItem.legend.isVisible()),
413+
"annotations": annotations,
414+
}
415+
416+
def apply_view_range(self, view_range: Sequence[Sequence[float]]) -> None:
417+
"""Restore the axes limits from a snapshot."""
418+
419+
if len(view_range) != 2:
420+
return
421+
422+
x_range, y_range = view_range
423+
if len(x_range) == 2:
424+
self._plot.setXRange(float(x_range[0]), float(x_range[1]), padding=0)
425+
if len(y_range) == 2:
426+
self._plot.setYRange(float(y_range[0]), float(y_range[1]), padding=0)
427+
428+
def set_legend_visible(self, visible: bool) -> None:
429+
"""Toggle legend visibility."""
430+
431+
legend = getattr(self._plot.plotItem, "legend", None)
432+
if legend is not None:
433+
legend.setVisible(bool(visible))
434+
435+
def restore_annotations(self, annotations: Sequence[Dict[str, Any]]) -> List[str]:
436+
"""Re-create selection markers from saved data.
437+
438+
Returns:
439+
List of curve identifiers that could not be restored.
440+
"""
441+
442+
missing: List[str] = []
443+
for ann in annotations:
444+
key = ann.get("path")
445+
point = ann.get("point")
446+
if key is None or point is None:
447+
continue
448+
curve = self._curves.get(str(key))
449+
if curve is None:
450+
missing.append(str(key))
451+
continue
452+
try:
453+
pt_tuple: Point = (float(point[0]), float(point[1]))
454+
except (TypeError, ValueError, IndexError):
455+
missing.append(str(key))
456+
continue
457+
self._select(pt_tuple, curve)
458+
return missing
459+
460+
def _curve_key(self, curve: pg.PlotDataItem) -> str | None:
461+
for key, item in self._curves.items():
462+
if item is curve:
463+
return key
464+
return None
465+
466+
def apply_curve_metadata(self, metadata: Dict[str, Dict[str, Any]]) -> None:
467+
for key, info in metadata.items():
468+
curve = self._curves.get(key)
469+
if curve is None:
470+
continue
471+
472+
label = info.get("label")
473+
if label is not None:
474+
label_text = str(label)
475+
if hasattr(curve, "setName"):
476+
curve.setName(label_text)
477+
else:
478+
curve.opts["name"] = label_text
479+
legend = getattr(self._plot.plotItem, "legend", None)
480+
if legend is not None:
481+
if hasattr(legend, "itemChanged"):
482+
legend.itemChanged(curve)
483+
else: # compatibility fallback for older pyqtgraph releases
484+
try:
485+
legend.removeItem(curve)
486+
except Exception:
487+
pass
488+
legend.addItem(curve, label_text)
489+
490+
pen_info = info.get("pen", {})
491+
color = pen_info.get("color")
492+
width = pen_info.get("width")
493+
if color is not None or width is not None:
494+
kwargs: Dict[str, Any] = {}
495+
if color is not None:
496+
kwargs["color"] = color
497+
if width is not None:
498+
try:
499+
kwargs["width"] = float(width)
500+
except (TypeError, ValueError):
501+
pass
502+
if kwargs:
503+
curve.setPen(pg.mkPen(**kwargs))

0 commit comments

Comments
 (0)