diff --git a/robot_log_visualizer/__main__.py b/robot_log_visualizer/__main__.py
index 03aeb76..7027c7a 100755
--- a/robot_log_visualizer/__main__.py
+++ b/robot_log_visualizer/__main__.py
@@ -5,14 +5,11 @@
# Released under the terms of the BSD 3-Clause License
import sys
-import os
# GUI
from robot_log_visualizer.ui.gui import RobotViewerMainWindow
from PyQt5.QtWidgets import QApplication
-from robot_log_visualizer.file_reader.signal_provider import SignalProvider
-
# Meshcat
from robot_log_visualizer.robot_visualizer.meshcat_provider import MeshcatProvider
@@ -24,19 +21,14 @@ def main():
"plot_animation": 0.03,
}
- # instantiate device_manager
- signal_provider = SignalProvider(period=thread_periods["signal_provider"])
-
- meshcat_provider = MeshcatProvider(
- period=thread_periods["meshcat_provider"], signal_provider=signal_provider
- )
+ meshcat_provider = MeshcatProvider(period=thread_periods["meshcat_provider"])
# instantiate a QApplication
app = QApplication(sys.argv)
# instantiate the main window
gui = RobotViewerMainWindow(
- signal_provider=signal_provider,
+ signal_provider_period=thread_periods["signal_provider"],
meshcat_provider=meshcat_provider,
animation_period=thread_periods["plot_animation"],
)
@@ -44,9 +36,6 @@ def main():
# show the main window
gui.show()
- signal_provider.start()
- meshcat_provider.start()
-
return app.exec_()
diff --git a/robot_log_visualizer/plotter/matplotlib_viewer_canvas.py b/robot_log_visualizer/plotter/matplotlib_viewer_canvas.py
index 84fd49f..90c0147 100644
--- a/robot_log_visualizer/plotter/matplotlib_viewer_canvas.py
+++ b/robot_log_visualizer/plotter/matplotlib_viewer_canvas.py
@@ -1,25 +1,25 @@
# Copyright (C) 2022 Istituto Italiano di Tecnologia (IIT). All rights reserved.
-# This software may be modified and distributed under the terms of the
# Released under the terms of the BSD 3-Clause License
-# PyQt
+import numpy as np
+from matplotlib.figure import Figure
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar
-from matplotlib.figure import Figure
import matplotlib.animation as animation
-from robot_log_visualizer.plotter.color_palette import ColorPalette
-import numpy as np
-import matplotlib.pyplot as plt
+from robot_log_visualizer.plotter.color_palette import ColorPalette
+from robot_log_visualizer.signal_provider.signal_provider import ProviderType
class MatplotlibViewerCanvas(FigureCanvas):
"""
- Inherits from FigureCanvasQTAgg in order to integrate with PyQt.
+ A FigureCanvas that shows the plot with two (mutually exclusive) animations:
+ - In offline mode, a vertical line is animated (sweeping over the plot).
+ - In realtime (online) mode, the plotted data is updated “online”.
"""
- def __init__(self, parent, signal_provider, period):
- # create a new figure
+ def __init__(self, parent, period):
+ # Create the figure
self.fig = Figure(dpi=100)
# call FigureCanvas constructor
@@ -28,144 +28,201 @@ def __init__(self, parent, signal_provider, period):
# set the parent of this FigureCanvas
self.setParent(parent)
- # set signal provider
- self.signal_provider = signal_provider
+ # Initially no signal provider
+ self.signal_provider = None
- # setup the plot and the animations
- self.index = 0
- # add plot to the figure
+ # Basic plot setup
self.axes = self.fig.add_subplot()
- # set axes labels
self.axes.set_xlabel("time [s]")
self.axes.set_ylabel("value")
self.axes.grid(True)
+ # Data structures to store annotation and selected points
self.annotations = {}
self.selected_points = {}
self.frame_legend = None
- # start the vertical line animation
+ # Plot the vertical line (we will update its position)
(self.vertical_line,) = self.axes.plot([], [], "-", lw=1, c="k")
self.period_in_ms = int(period * 1000)
- # active paths
+ # Dictionary of active plotted lines
self.active_paths = {}
- self.vertical_line_anim = animation.FuncAnimation(
- self.fig,
- self.update_vertical_line,
- init_func=self.init_vertical_line,
- interval=self.period_in_ms,
- blit=True,
- )
+ # Here we hold the two possible animations – only one will run.
+ self.vertical_line_anim = None
+ self.online_plot_anim = None
- # add plot toolbar from matplotlib
+ # Add the toolbar (for zoom/pan etc.)
self.toolbar = NavigationToolbar(self, self)
- # connect an event on click
+ # Connect the pick event (for selecting points)
self.fig.canvas.mpl_connect("pick_event", self.on_pick)
self.color_palette = ColorPalette()
- def quit_animation(self):
- # https://stackoverflow.com/questions/32280140/cannot-delete-matplotlib-animation-funcanimation-objects
- # this is to close the event associated to the animation
+ def start_animation(self):
+ """
+ Create (or restart) the proper animation depending on the signal provider type.
+ Only one of self.vertical_line_anim or self.online_plot_anim will be active.
+ """
+ if self.signal_provider is None:
+ return
+
+ if self.signal_provider.provider_type == ProviderType.REALTIME:
+ self.online_plot_anim = animation.FuncAnimation(
+ self.fig,
+ self.update_online_plot,
+ interval=self.period_in_ms,
+ blit=True,
+ )
+ elif self.signal_provider.provider_type == ProviderType.OFFLINE:
+ self.vertical_line_anim = animation.FuncAnimation(
+ self.fig,
+ self.update_vertical_line,
+ init_func=self.init_vertical_line,
+ interval=self.period_in_ms,
+ blit=True,
+ )
+ else:
+ raise ValueError("Unknown provider type")
+
+ def _current_animation(self):
+ """Return the animation object that is currently active."""
+ if self.signal_provider is None:
+ return None
+ if self.signal_provider.provider_type == ProviderType.REALTIME:
+ return self.online_plot_anim
+ elif self.signal_provider.provider_type == ProviderType.OFFLINE:
+ return self.vertical_line_anim
+ else:
+ raise ValueError("Unknown provider type")
+
+ def set_signal_provider(self, signal_provider):
+ """
+ Store the signal provider and start the proper animation.
+ The signal provider is assumed to have a member 'provider_type'
+ (which is an enum with REALTIME or OFFLINE).
+ """
+ if signal_provider is None:
+ return
+ self.signal_provider = signal_provider
+ self.start_animation()
- # this is required with matplotlib 3.1.2 but not with 3.5.1.
- # However this code will run with both version of matplotlib
- if self.vertical_line_anim:
- self.vertical_line_anim._stop()
+ def quit_animation(self):
+ """Stop the currently running animation."""
+ anim = self._current_animation()
+ if anim:
+ anim._stop()
def pause_animation(self):
- self.vertical_line_anim.pause()
+ """Pause the current animation."""
+ anim = self._current_animation()
+ if anim:
+ anim.pause()
def resume_animation(self):
- self.vertical_line_anim.resume()
+ """Resume the current animation (by restarting it)."""
+ anim = self._current_animation()
+ if anim:
+ anim.resume()
def on_pick(self, event):
+ """Handle a pick event to add or remove an annotation."""
if isinstance(event.artist, plt.Line2D):
- # get the color of the line
color = event.artist.get_color()
-
line_xdata = event.artist.get_xdata()
line_ydata = event.artist.get_ydata()
index = event.ind[0]
x_data = line_xdata[index]
y_data = line_ydata[index]
- # find the nearest annotated point to the clicked point if yes we assume the user want to remove it
+ # Find the closest annotated point, if any.
should_remove = False
min_distance = float("inf")
nearest_point = (float("inf"), float("inf"))
-
- # The radius of an annotation is 1% of the axis range
radius_x = 0.01 * (self.axes.get_xlim()[1] - self.axes.get_xlim()[0])
radius_y = 0.01 * (self.axes.get_ylim()[1] - self.axes.get_ylim()[0])
- ellipsis_center = (x_data, y_data)
- for x, y in self.annotations.keys():
- distance = np.sqrt((x - x_data) ** 2 + (y - y_data) ** 2)
+ for ax, ay in self.annotations.keys():
+ distance = np.sqrt((ax - x_data) ** 2 + (ay - y_data) ** 2)
if distance < min_distance:
min_distance = distance
- nearest_point = (x, y)
-
+ nearest_point = (ax, ay)
if (
- np.abs(x_data - nearest_point[0]) < radius_x
- and np.abs(y_data - nearest_point[1]) < radius_y
+ abs(x_data - nearest_point[0]) < radius_x
+ and abs(y_data - nearest_point[1]) < radius_y
):
x_data, y_data = nearest_point
should_remove = True
- # Stop the animation
- self.vertical_line_anim._stop()
+ # Stop the current animation while we process the pick
+ anim = self._current_animation()
+ if anim:
+ anim.event_source.stop()
if should_remove:
- # Remove the annotation
+ # Remove annotation and selected point.
self.annotations[(x_data, y_data)].remove()
del self.annotations[(x_data, y_data)]
-
- # Remove the point
self.selected_points[(x_data, y_data)].remove()
del self.selected_points[(x_data, y_data)]
else:
- # Otherwise, create a new annotation and change color of the point
- # The annotation represents the value of the point with a precision equal to the grid precision
- x_grid_precision = int(
- np.ceil(
- -np.log10(self.axes.get_xlim()[1] - self.axes.get_xlim()[0])
+ # Add an annotation at the picked point.
+ # (Grid precision is computed from axis limits.)
+ x_grid_precision = max(
+ 0,
+ int(
+ np.ceil(
+ -np.log10(self.axes.get_xlim()[1] - self.axes.get_xlim()[0])
+ )
)
+ + 2,
)
- x_grid_precision = max(0, x_grid_precision) + 2
- y_grid_precision = int(
- np.ceil(
- -np.log10(self.axes.get_ylim()[1] - self.axes.get_ylim()[0])
+ y_grid_precision = max(
+ 0,
+ int(
+ np.ceil(
+ -np.log10(self.axes.get_ylim()[1] - self.axes.get_ylim()[0])
+ )
)
+ + 2,
)
- y_grid_precision = max(0, y_grid_precision) + 2
-
format_string_x = "{:." + str(x_grid_precision) + "f}"
format_string_y = "{:." + str(y_grid_precision) + "f}"
-
- x_data_str = format_string_x.format(x_data)
- y_data_str = format_string_y.format(y_data)
-
+ annotation_text = (
+ format_string_x.format(x_data)
+ + ", "
+ + format_string_y.format(y_data)
+ )
annotation = self.axes.annotate(
- x_data_str + ", " + y_data_str,
+ annotation_text,
xy=(x_data, y_data),
xytext=(5, 5),
textcoords="offset points",
fontsize=10,
bbox=dict(
boxstyle="round,pad=0.3",
- facecolor=self.frame_legend.get_facecolor(),
- edgecolor=self.frame_legend.get_edgecolor(),
- linewidth=self.frame_legend.get_linewidth(),
+ facecolor=(
+ self.frame_legend.get_facecolor()
+ if self.frame_legend
+ else "w"
+ ),
+ edgecolor=(
+ self.frame_legend.get_edgecolor()
+ if self.frame_legend
+ else "k"
+ ),
+ linewidth=(
+ self.frame_legend.get_linewidth()
+ if self.frame_legend
+ else 1
+ ),
),
color="black",
)
-
self.annotations[(x_data, y_data)] = annotation
- selected_point = self.axes.plot(
+ (selected_point,) = self.axes.plot(
x_data,
y_data,
"o",
@@ -173,18 +230,13 @@ def on_pick(self, event):
markerfacecolor=color,
markeredgecolor="k",
)
- self.selected_points[(x_data, y_data)] = selected_point[0]
+ self.selected_points[(x_data, y_data)] = selected_point
- # Restart the animation
- self.vertical_line_anim = animation.FuncAnimation(
- self.fig,
- self.update_vertical_line,
- init_func=self.init_vertical_line,
- interval=self.period_in_ms,
- blit=True,
- )
+ # Restart the proper animation after handling the pick.
+ self.start_animation()
def update_plots(self, paths, legends):
+ self.quit_animation()
for path, legend in zip(paths, legends):
path_string = "/".join(path)
legend_string = "/".join(legend[1:])
@@ -201,13 +253,25 @@ def update_plots(self, paths, legends):
timestamps = data["timestamps"] - self.signal_provider.initial_time
- (self.active_paths[path_string],) = self.axes.plot(
- timestamps,
- datapoints,
- label=legend_string,
- picker=True,
- color=next(self.color_palette),
- )
+ if timestamps.size > 1:
+ (self.active_paths[path_string],) = self.axes.plot(
+ timestamps,
+ datapoints,
+ label=legend_string,
+ picker=True,
+ color=next(self.color_palette),
+ animated=True,
+ )
+ else:
+ (self.active_paths[path_string],) = self.axes.plot(
+ timestamps,
+ datapoints,
+ label=legend_string,
+ picker=True,
+ color=next(self.color_palette),
+ marker="o",
+ animated=True,
+ )
paths_to_be_canceled = []
for active_path in self.active_paths.keys():
@@ -220,40 +284,32 @@ def update_plots(self, paths, legends):
self.active_paths[path].remove()
self.active_paths.pop(path)
- self.axes.set_xlim(
- 0, self.signal_provider.end_time - self.signal_provider.initial_time
- )
+ try:
+ self.axes.set_xlim(0, self.signal_provider.realtime_fixed_plot_window)
+ except AttributeError:
+ self.axes.set_xlim(
+ 0, self.signal_provider.end_time - self.signal_provider.initial_time)
- # Since a new plot has been added/removed we delete the old animation and we create a new one
- # TODO: this part could be optimized
- self.vertical_line_anim._stop()
self.axes.legend()
if not self.frame_legend:
self.frame_legend = self.axes.legend().get_frame()
- self.vertical_line_anim = animation.FuncAnimation(
- self.fig,
- self.update_vertical_line,
- init_func=self.init_vertical_line,
- interval=self.period_in_ms,
- blit=True,
- )
+ self.start_animation()
- def update_index(self, index):
- self.index = index
+ # ------ Vertical Line Animation (for OFFLINE mode) ------
def init_vertical_line(self):
+ """Initializes the vertical line (offline mode)."""
self.vertical_line.set_data([], [])
return self.vertical_line, *(self.active_paths.values())
def update_vertical_line(self, _):
"""
- Update the vertical line
+ In offline mode, update the vertical line position.
+ Assumes that self.signal_provider.current_time is available.
"""
current_time = self.signal_provider.current_time
- # Draw vertical line at current index
-
self.vertical_line.set_data([current_time, current_time], self.axes.get_ylim())
return (
self.vertical_line,
@@ -261,3 +317,35 @@ def update_vertical_line(self, _):
*(self.selected_points.values()),
*(self.annotations.values()),
)
+
+ # ------ Online Plot Animation (for REALTIME mode) ------
+
+ def update_online_plot(self, frame):
+ """
+ In realtime mode, update the plotted lines with the latest data.
+ (You will need to modify this method so that it retrieves updated data from your
+ signal_provider. Here we show one example approach.)
+ """
+ for path_string, line in self.active_paths.items():
+ # Example: re-read data from the signal provider.
+ # (For efficiency you might wish to cache the “path” list when you create the line.)
+ data = self.signal_provider.data.copy()
+ # Assume the original path was stored as a string with "/" separators.
+ path_keys = path_string.split("/")
+ for key in path_keys[:-1]:
+ data = data[key]
+ try:
+ datapoints = data["data"][:, int(path_keys[-1])]
+ except IndexError:
+ datapoints = data["data"][:]
+ timestamps = data["timestamps"] - self.signal_provider.initial_time
+ line.set_data(timestamps, datapoints)
+
+ self.axes.relim()
+ self.axes.autoscale_view()
+
+ return (
+ *(self.active_paths.values()),
+ *(self.selected_points.values()),
+ *(self.annotations.values()),
+ )
diff --git a/robot_log_visualizer/robot_visualizer/meshcat_provider.py b/robot_log_visualizer/robot_visualizer/meshcat_provider.py
index 3288943..b537958 100644
--- a/robot_log_visualizer/robot_visualizer/meshcat_provider.py
+++ b/robot_log_visualizer/robot_visualizer/meshcat_provider.py
@@ -18,7 +18,7 @@
class MeshcatProvider(QThread):
- def __init__(self, signal_provider, period):
+ def __init__(self, period):
QThread.__init__(self)
self._state = PeriodicThreadState.pause
@@ -29,7 +29,7 @@ def __init__(self, signal_provider, period):
self.meshcat_visualizer_mutex = QMutex()
self._is_model_loaded = False
- self._signal_provider = signal_provider
+ self._signal_provider = None
self.custom_model_path = ""
self.custom_package_dir = ""
@@ -70,6 +70,9 @@ def unregister_3d_trajectory(self, trajectory_path):
self._registered_3d_trajectories.pop(trajectory_path, None)
self._meshcat_visualizer.delete(shape_name=trajectory_path)
+ def set_signal_provider(self, signal_provider):
+ self._signal_provider = signal_provider
+
def load_model(self, considered_joints, model_name):
def get_model_path_from_envs(env_list):
return [
@@ -174,6 +177,9 @@ def run(self):
index = self._signal_provider.index
if self.state == PeriodicThreadState.running and self._is_model_loaded:
+ if len(self._signal_provider) == 0:
+ time.sleep(self._period)
+ continue
robot_state = self._signal_provider.get_robot_state_at_index(index)
self.meshcat_visualizer_mutex.lock()
# These are the robot measured joint positions in radians
@@ -224,3 +230,4 @@ def run(self):
if self.state == PeriodicThreadState.closed:
return
+
diff --git a/robot_log_visualizer/rt_selector.xml b/robot_log_visualizer/rt_selector.xml
new file mode 100644
index 0000000..ed16a52
--- /dev/null
+++ b/robot_log_visualizer/rt_selector.xml
@@ -0,0 +1,12 @@
+
+
+
+
+ - f_x
+ - f_y
+ - f_z
+
+
+
+
+
diff --git a/robot_log_visualizer/file_reader/__init__.py b/robot_log_visualizer/signal_provider/__init__.py
similarity index 100%
rename from robot_log_visualizer/file_reader/__init__.py
rename to robot_log_visualizer/signal_provider/__init__.py
diff --git a/robot_log_visualizer/signal_provider/matfile_signal_provider.py b/robot_log_visualizer/signal_provider/matfile_signal_provider.py
new file mode 100644
index 0000000..508c5c1
--- /dev/null
+++ b/robot_log_visualizer/signal_provider/matfile_signal_provider.py
@@ -0,0 +1,167 @@
+# Copyright (C) 2022 Istituto Italiano di Tecnologia (IIT). All rights reserved.
+# This software may be modified and distributed under the terms of the
+# Released under the terms of the BSD 3-Clause License
+
+import time
+import h5py
+import numpy as np
+from robot_log_visualizer.signal_provider.signal_provider import (
+ SignalProvider,
+ ProviderType,
+ TextLoggingMsg,
+)
+from robot_log_visualizer.utils.utils import PeriodicThreadState
+
+
+class MatfileSignalProvider(SignalProvider):
+ def __init__(self, period: float, signal_root_name: str):
+ super().__init__(period, signal_root_name, ProviderType.OFFLINE)
+
+ def __len__(self):
+ return len(self.timestamps)
+
+ def __populate_text_logging_data(self, file_object):
+ data = {}
+ for key, value in file_object.items():
+ if not isinstance(value, h5py._hl.group.Group):
+ continue
+ if key == "#refs#":
+ continue
+ if "data" in value.keys():
+ data[key] = {}
+ level_ref = value["data"]["level"]
+ text_ref = value["data"]["text"]
+
+ data[key]["timestamps"] = np.squeeze(np.array(value["timestamps"]))
+
+ # New way to store the struct array in robometry https://github.com/robotology/robometry/pull/175
+ if text_ref.shape[0] == len(data[key]["timestamps"]):
+ # If len(value[text[0]].shape) == 2 then the text contains a string, otherwise it is empty
+ # We need to manually check the shape to handle the case in which the text is empty
+ data[key]["data"] = [
+ (
+ TextLoggingMsg(
+ text="".join(chr(c[0]) for c in value[text[0]]),
+ level="".join(chr(c[0]) for c in value[level[0]]),
+ )
+ if len(value[text[0]].shape) == 2
+ else TextLoggingMsg(
+ text="",
+ level="".join(chr(c[0]) for c in value[level[0]]),
+ )
+ )
+ for text, level in zip(text_ref, level_ref)
+ ]
+
+ # Old approach (before https://github.com/robotology/robometry/pull/175)
+ else:
+ data[key]["data"] = [
+ TextLoggingMsg(
+ text="".join(chr(c[0]) for c in value[text]),
+ level="".join(chr(c[0]) for c in value[level]),
+ )
+ for text, level in zip(text_ref[0], level_ref[0])
+ ]
+
+ else:
+ data[key] = self.__populate_text_logging_data(file_object=value)
+
+ return data
+
+ def __populate_numerical_data(self, file_object):
+ data = {}
+ for key, value in file_object.items():
+ if not isinstance(value, h5py._hl.group.Group):
+ continue
+ if key == "#refs#":
+ continue
+ if key == "log":
+ continue
+ if "data" in value.keys():
+ data[key] = {}
+ data[key]["data"] = np.squeeze(np.array(value["data"]))
+ data[key]["timestamps"] = np.squeeze(np.array(value["timestamps"]))
+
+ # if the initial or end time has been updated we can also update the entire timestamps dataset
+ if data[key]["timestamps"][0] < self.initial_time:
+ self.timestamps = data[key]["timestamps"]
+ self.initial_time = self.timestamps[0]
+
+ if data[key]["timestamps"][-1] > self.end_time:
+ self.timestamps = data[key]["timestamps"]
+ self.end_time = self.timestamps[-1]
+
+ # In yarp telemetry v0.4.0 the elements_names was saved.
+ if "elements_names" in value.keys():
+ elements_names_ref = value["elements_names"]
+ data[key]["elements_names"] = [
+ "".join(chr(c[0]) for c in value[ref])
+ for ref in elements_names_ref[0]
+ ]
+
+ else:
+ data[key] = self.__populate_numerical_data(file_object=value)
+
+ return data
+
+ def open(self, source: str) -> bool:
+ with h5py.File(source, "r") as file:
+ root_variable = file.get(self.root_name)
+ self.data = self.__populate_numerical_data(file)
+
+ if "log" in root_variable.keys():
+ self.text_logging_data["log"] = self.__populate_text_logging_data(
+ root_variable["log"]
+ )
+
+ for name in file.keys():
+ if "description_list" in file[name].keys():
+ self.root_name = name
+ break
+
+ joint_ref = root_variable["description_list"]
+ self.joints_name = [
+ "".join(chr(c[0]) for c in file[ref]) for ref in joint_ref[0]
+ ]
+ if "yarp_robot_name" in root_variable.keys():
+ robot_name_ref = root_variable["yarp_robot_name"]
+ try:
+ self.robot_name = "".join(chr(c[0]) for c in robot_name_ref)
+ except:
+ pass
+ self.index = 0
+
+ return True
+
+ def run(self):
+ while True:
+ start = time.time()
+ if self.state == PeriodicThreadState.running:
+ self.index_lock.lock()
+ tmp_index = self._index
+ self._current_time += self.period
+ self._current_time = min(
+ self._current_time, self.timestamps[-1] - self.initial_time
+ )
+
+ # find the index associated to the current time in self.timestamps
+ # this is valid since self.timestamps is sorted and self._current_time is increasing
+ while (
+ self._current_time > self.timestamps[tmp_index] - self.initial_time
+ ):
+ tmp_index += 1
+ if tmp_index > len(self.timestamps):
+ break
+
+ self._index = tmp_index
+
+ self.index_lock.unlock()
+
+ self.update_index_signal.emit()
+
+ sleep_time = self.period - (time.time() - start)
+ if sleep_time > 0:
+ time.sleep(sleep_time)
+
+ if self.state == PeriodicThreadState.closed:
+ return
diff --git a/robot_log_visualizer/signal_provider/realtime_signal_provider.py b/robot_log_visualizer/signal_provider/realtime_signal_provider.py
new file mode 100644
index 0000000..0ca7d41
--- /dev/null
+++ b/robot_log_visualizer/signal_provider/realtime_signal_provider.py
@@ -0,0 +1,269 @@
+# Copyright (C) 2022 Istituto Italiano di Tecnologia (IIT). All rights reserved.
+# This software may be modified and distributed under the terms of the
+# BSD 3-Clause License
+
+import time
+import traceback
+from collections import deque
+
+import numpy as np
+
+from robot_log_visualizer.signal_provider.signal_provider import (
+ ProviderType,
+ SignalProvider,
+)
+from robot_log_visualizer.utils.utils import PeriodicThreadState
+
+
+def are_deps_installed():
+ try:
+ import bipedal_locomotion_framework.bindings
+ import yarp
+ except ImportError as e:
+ print("Missing dependencies for RealtimeSignalProvider:", e)
+ traceback.print_exc()
+ return False
+ return True
+
+class DequeToNumpyLeaf(dict):
+ """
+ A dictionary-like object that internally stores "data" and "timestamps"
+ as deques for efficient appends/popleft, but returns them as NumPy arrays
+ when accessed via dict["data"] or dict["timestamps"].
+ """
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ def append(self, key, value):
+ super().__getitem__(key).append(value)
+
+ def get_raw(self, key):
+ return super().__getitem__(key)
+
+ def __getitem__(self, key):
+ """
+ Intercept requests for 'data' or 'timestamps'.
+ Return them as NumPy arrays constructed from the internal deques.
+ For any other key, return as a normal dictionary would.
+ """
+ if key == "data":
+ return np.array(super().__getitem__("data"))
+ elif key == "timestamps":
+ return np.array(super().__getitem__("timestamps"))
+ else:
+ return super().__getitem__(key)
+
+ def __setitem__(self, key, value):
+ """
+ If you (or code) try to do leaf["data"] = something, you might
+ want to do a custom setter. For simplicity, we just set it normally
+ for non-'data_deque'/'timestamps_deque' keys.
+ """
+ # Example logic if you want to intercept "data":
+ # if key == "data":
+ # # Convert to deque
+ # self["data_deque"] = deque(value)
+ # else:
+ super().__setitem__(key, value)
+
+class RealtimeSignalProvider(SignalProvider):
+ def __init__(self, period: float, signal_root_name: str):
+ """
+ Initialize the realtime signal provider.
+
+ - Initializes the network client.
+ - Sets up an internal buffer (using deques) to store data within a fixed time window.
+ """
+ super().__init__(period, signal_root_name, ProviderType.REALTIME)
+
+ if not are_deps_installed():
+ raise ImportError(
+ "The realtime signal provider requires the bipedal_locomotion_framework and yarp packages."
+ )
+
+ # Import realtime-related bindings.
+ import bipedal_locomotion_framework.bindings as blf
+
+ self.vector_collections_client = blf.yarp_utilities.VectorsCollectionClient()
+
+ # Time window (in seconds) for the online plot buffer.
+ self.realtime_fixed_plot_window = 20
+ self.realtime_network_init = False
+
+ # Dictionary for metadata retrieved once from the remote logger.
+ self.rt_metadata_dict = {}
+
+ # Global data buffers:
+ # - self.data holds the hierarchical data coming from the logger.
+ # - self.timestamps holds the timestamps (as a deque) for the global buffer.
+ self.data = DequeToNumpyLeaf()
+ self._timestamps = deque()
+
+ def __len__(self):
+ return len(self._timestamps)
+
+ def _update_data_buffer(
+ self, raw_data: dict, keys: list, value, recent_timestamp: float
+ ):
+ """
+ Recursively update the data buffers in the raw_data dictionary.
+ At a leaf node, the buffer is maintained as two deques:
+ - raw_data[key]["data"]
+ - raw_data[key]["timestamps"]
+ Any sample older than the fixed time window is removed.
+ """
+ if keys[0] not in raw_data:
+ raw_data[keys[0]] = DequeToNumpyLeaf()
+
+ if len(keys) == 1:
+ # Leaf node: initialize deques if needed.
+ if "data" not in raw_data[keys[0]]:
+ raw_data[keys[0]]["data"] = deque()
+ raw_data[keys[0]]["timestamps"] = deque()
+ raw_data[keys[0]].append("data" ,value)
+ raw_data[keys[0]].append("timestamps", recent_timestamp)
+ # Remove old data outside the time window.
+ while raw_data[keys[0]].get_raw("timestamps") and (
+ recent_timestamp - raw_data[keys[0]].get_raw("timestamps")[0]
+ > self.realtime_fixed_plot_window
+ ):
+ raw_data[keys[0]].get_raw("data").popleft()
+ raw_data[keys[0]].get_raw("timestamps").popleft()
+ else:
+ # Recursive call for nested dictionaries.
+ self._update_data_buffer(
+ raw_data[keys[0]], keys[1:], value, recent_timestamp
+ )
+
+ def _populate_realtime_logger_metadata(self, raw_data: dict, keys: list, value):
+ """
+ Recursively populate metadata into raw_data.
+ Here we simply store metadata (e.g. elements names) into a list.
+ """
+ if keys[0] == "timestamps":
+ return
+ if keys[0] not in raw_data:
+ raw_data[keys[0]] = DequeToNumpyLeaf()
+ if len(keys) == 1:
+ if not value:
+ if keys[0] in raw_data:
+ del raw_data[keys[0]]
+ return
+ if "elements_names" not in raw_data[keys[0]]:
+ raw_data[keys[0]]["elements_names"] = []
+ # Also create empty buffers (which will later be updated in run())
+ raw_data[keys[0]]["data"] = deque()
+ raw_data[keys[0]]["timestamps"] = deque()
+
+ raw_data[keys[0]]["elements_names"] = value
+ else:
+ self._populate_realtime_logger_metadata(raw_data[keys[0]], keys[1:], value)
+
+ def open(self, source: str) -> bool:
+ """
+ Initializes the connection with the remote realtime logger.
+ This method retrieves metadata (e.g. joint names, robot name, etc.)
+ but does not yet read the realtime data.
+ """
+ if not self.realtime_network_init:
+ # Initialize YARP network and parameters.
+ import bipedal_locomotion_framework.bindings as blf
+ import yarp
+
+ yarp.Network.init()
+
+ param_handler = blf.parameters_handler.YarpParametersHandler()
+ param_handler.set_parameter_string("remote", source)
+ param_handler.set_parameter_string("local", "/visualizerInput")
+ param_handler.set_parameter_string("carrier", "udp")
+ self.vector_collections_client.initialize(param_handler)
+ self.vector_collections_client.connect()
+
+ try:
+ self.rt_metadata_dict = (
+ self.vector_collections_client.get_metadata().vectors
+ )
+ except ValueError:
+ print(
+ "Error retrieving metadata from the logger. "
+ "Ensure the logger is running and configured for realtime connection."
+ )
+ return False
+
+ self.realtime_network_init = True
+ self.joints_name = self.rt_metadata_dict["robot_realtime::description_list"]
+ self.robot_name = self.rt_metadata_dict["robot_realtime::yarp_robot_name"][
+ 0
+ ]
+
+ # Populate metadata into self.data recursively.
+ for key_string, value in self.rt_metadata_dict.items():
+ keys = key_string.split("::")
+ self._populate_realtime_logger_metadata(self.data, keys, value)
+
+ # Remove keys that are not needed for the realtime plotting.
+ if self.root_name in self.data:
+ self.data[self.root_name].pop("description_list", None)
+ self.data[self.root_name].pop("yarp_robot_name", None)
+
+ return True
+
+ @property
+ def timestamps(self):
+ return np.array(self._timestamps)
+
+ def run(self):
+ """
+ This is the periodic thread that reads data from the remote realtime logger.
+ It:
+ - Reads new data.
+ - Updates the global timestamp buffer.
+ - For each key (except for the timestamps key), updates the data buffers.
+ - Emits an update signal to inform the visualizer that new data are available.
+ """
+ while True:
+ start = time.time()
+ if self.state == PeriodicThreadState.running:
+ # Read the latest data from the realtime logger.
+ vc_input = self.vector_collections_client.read_data(True).vectors
+
+ if vc_input:
+ self.index_lock.lock()
+ # Retrieve the most recent timestamp from the input.
+ recent_timestamp = vc_input["robot_realtime::timestamps"][0]
+ self._timestamps.append(recent_timestamp)
+ # Keep the global timestamps within the fixed plot window.
+ while self._timestamps and (
+ recent_timestamp - self._timestamps[0]
+ > self.realtime_fixed_plot_window
+ ):
+ self._timestamps.popleft()
+
+ # Update initial and end times.
+ if self._timestamps:
+ self.initial_time = self._timestamps[0]
+ self.end_time = self._timestamps[-1]
+
+ # For each key in the received data (except timestamps),
+ # update the appropriate buffer.
+ for key_string, value in vc_input.items():
+ if key_string == "robot_realtime::timestamps":
+ continue
+ keys = key_string.split("::")
+ self._update_data_buffer(
+ self.data, keys, value, recent_timestamp
+ )
+
+ self._index = len(self._timestamps) - 1
+ self.index_lock.unlock()
+
+ # Signal that new data are available.
+ self.update_index_signal.emit()
+
+ # Sleep until the next period.
+ sleep_time = self.period - (time.time() - start)
+ if sleep_time > 0:
+ time.sleep(sleep_time)
+
+ if self.state == PeriodicThreadState.closed:
+ return
diff --git a/robot_log_visualizer/file_reader/signal_provider.py b/robot_log_visualizer/signal_provider/signal_provider.py
similarity index 57%
rename from robot_log_visualizer/file_reader/signal_provider.py
rename to robot_log_visualizer/signal_provider/signal_provider.py
index 6d92ea2..aa82fa2 100644
--- a/robot_log_visualizer/file_reader/signal_provider.py
+++ b/robot_log_visualizer/signal_provider/signal_provider.py
@@ -2,13 +2,19 @@
# This software may be modified and distributed under the terms of the
# Released under the terms of the BSD 3-Clause License
-import time
import math
-import h5py
import numpy as np
from PyQt5.QtCore import pyqtSignal, QThread, QMutex, QMutexLocker
from robot_log_visualizer.utils.utils import PeriodicThreadState, RobotStatePath
import idyntree.swig as idyn
+import abc
+from enum import Enum
+
+
+class ProviderType(Enum):
+ OFFLINE = 0
+ REALTIME = 1
+ NOT_DEFINED = 2
class TextLoggingMsg:
@@ -32,7 +38,9 @@ def color(self):
class SignalProvider(QThread):
update_index_signal = pyqtSignal()
- def __init__(self, period: float):
+ def __init__(
+ self, period: float, signal_root_name: str, provider_type: ProviderType
+ ):
QThread.__init__(self)
# set device state
@@ -54,7 +62,7 @@ def __init__(self, period: float):
self.period = period
self.data = {}
- self.timestamps = np.array([])
+ # self.timestamps = np.array([])
self.text_logging_data = {}
self.initial_time = math.inf
@@ -63,122 +71,21 @@ def __init__(self, period: float):
self.joints_name = []
self.robot_name = ""
- self.root_name = "robot_logger_device"
+ self.root_name = signal_root_name
self._current_time = 0
self.trajectory_span = 200
- def __populate_text_logging_data(self, file_object):
- data = {}
- for key, value in file_object.items():
- if not isinstance(value, h5py._hl.group.Group):
- continue
- if key == "#refs#":
- continue
- if "data" in value.keys():
- data[key] = {}
- level_ref = value["data"]["level"]
- text_ref = value["data"]["text"]
-
- data[key]["timestamps"] = np.squeeze(np.array(value["timestamps"]))
-
- # New way to store the struct array in robometry https://github.com/robotology/robometry/pull/175
- if text_ref.shape[0] == len(data[key]["timestamps"]):
- # If len(value[text[0]].shape) == 2 then the text contains a string, otherwise it is empty
- # We need to manually check the shape to handle the case in which the text is empty
- data[key]["data"] = [
- TextLoggingMsg(
- text="".join(chr(c[0]) for c in value[text[0]]),
- level="".join(chr(c[0]) for c in value[level[0]]),
- )
- if len(value[text[0]].shape) == 2
- else TextLoggingMsg(
- text="",
- level="".join(chr(c[0]) for c in value[level[0]]),
- )
- for text, level in zip(text_ref, level_ref)
- ]
-
- # Old approach (before https://github.com/robotology/robometry/pull/175)
- else:
- data[key]["data"] = [
- TextLoggingMsg(
- text="".join(chr(c[0]) for c in value[text]),
- level="".join(chr(c[0]) for c in value[level]),
- )
- for text, level in zip(text_ref[0], level_ref[0])
- ]
-
- else:
- data[key] = self.__populate_text_logging_data(file_object=value)
-
- return data
-
- def __populate_numerical_data(self, file_object):
- data = {}
- for key, value in file_object.items():
- if not isinstance(value, h5py._hl.group.Group):
- continue
- if key == "#refs#":
- continue
- if key == "log":
- continue
- if "data" in value.keys():
- data[key] = {}
- data[key]["data"] = np.squeeze(np.array(value["data"]))
- data[key]["timestamps"] = np.squeeze(np.array(value["timestamps"]))
-
- # if the initial or end time has been updated we can also update the entire timestamps dataset
- if data[key]["timestamps"][0] < self.initial_time:
- self.timestamps = data[key]["timestamps"]
- self.initial_time = self.timestamps[0]
-
- if data[key]["timestamps"][-1] > self.end_time:
- self.timestamps = data[key]["timestamps"]
- self.end_time = self.timestamps[-1]
-
- # In yarp telemetry v0.4.0 the elements_names was saved.
- if "elements_names" in value.keys():
- elements_names_ref = value["elements_names"]
- data[key]["elements_names"] = [
- "".join(chr(c[0]) for c in value[ref])
- for ref in elements_names_ref[0]
- ]
- else:
- data[key] = self.__populate_numerical_data(file_object=value)
-
- return data
-
- def open_mat_file(self, file_name: str):
- with h5py.File(file_name, "r") as file:
- root_variable = file.get(self.root_name)
- self.data = self.__populate_numerical_data(file)
-
- if "log" in root_variable.keys():
- self.text_logging_data["log"] = self.__populate_text_logging_data(
- root_variable["log"]
- )
+ self.provider_type = provider_type
- for name in file.keys():
- if "description_list" in file[name].keys():
- self.root_name = name
- break
-
- joint_ref = root_variable["description_list"]
- self.joints_name = [
- "".join(chr(c[0]) for c in file[ref]) for ref in joint_ref[0]
- ]
- if "yarp_robot_name" in root_variable.keys():
- robot_name_ref = root_variable["yarp_robot_name"]
- try:
- self.robot_name = "".join(chr(c[0]) for c in robot_name_ref)
- except:
- pass
- self.index = 0
+ @abc.abstractmethod
+ def open(self, source: str) -> bool:
+ return False
+ @abc.abstractmethod
def __len__(self):
- return self.timestamps.shape[0]
+ pass
@property
def state(self):
@@ -364,35 +271,6 @@ def unregister_3d_trajectory(self, key):
del self._3d_trajectories_path[key]
self._3d_trajectories_path_lock.unlock()
+ @abc.abstractmethod
def run(self):
- while True:
- start = time.time()
- if self.state == PeriodicThreadState.running:
- self.index_lock.lock()
- tmp_index = self._index
- self._current_time += self.period
- self._current_time = min(
- self._current_time, self.timestamps[-1] - self.initial_time
- )
-
- # find the index associated to the current time in self.timestamps
- # this is valid since self.timestamps is sorted and self._current_time is increasing
- while (
- self._current_time > self.timestamps[tmp_index] - self.initial_time
- ):
- tmp_index += 1
- if tmp_index > len(self.timestamps):
- break
-
- self._index = tmp_index
-
- self.index_lock.unlock()
-
- self.update_index_signal.emit()
-
- sleep_time = self.period - (time.time() - start)
- if sleep_time > 0:
- time.sleep(sleep_time)
-
- if self.state == PeriodicThreadState.closed:
- return
+ return
diff --git a/robot_log_visualizer/ui/autogenerated/about.py b/robot_log_visualizer/ui/autogenerated/about.py
index fedcf29..61a927f 100644
--- a/robot_log_visualizer/ui/autogenerated/about.py
+++ b/robot_log_visualizer/ui/autogenerated/about.py
@@ -2,9 +2,10 @@
# Form implementation generated from reading ui file 'robot_log_visualizer/ui/misc/about.ui'
#
-# Created by: PyQt5 UI code generator 5.14.1
+# Created by: PyQt5 UI code generator 5.15.9
#
-# WARNING! All changes made in this file will be lost!
+# WARNING: Any manual changes made to this file will be lost when pyuic5 is
+# run again. Do not edit this file unless you know what you are doing.
from PyQt5 import QtCore, QtGui, QtWidgets
diff --git a/robot_log_visualizer/ui/autogenerated/plot_tab.py b/robot_log_visualizer/ui/autogenerated/plot_tab.py
index dc3fd67..1b1d496 100644
--- a/robot_log_visualizer/ui/autogenerated/plot_tab.py
+++ b/robot_log_visualizer/ui/autogenerated/plot_tab.py
@@ -2,9 +2,10 @@
# Form implementation generated from reading ui file 'robot_log_visualizer/ui/misc/plot_tab.ui'
#
-# Created by: PyQt5 UI code generator 5.14.1
+# Created by: PyQt5 UI code generator 5.15.9
#
-# WARNING! All changes made in this file will be lost!
+# WARNING: Any manual changes made to this file will be lost when pyuic5 is
+# run again. Do not edit this file unless you know what you are doing.
from PyQt5 import QtCore, QtGui, QtWidgets
diff --git a/robot_log_visualizer/ui/autogenerated/set_robot_model.py b/robot_log_visualizer/ui/autogenerated/set_robot_model.py
index f49c134..6100115 100644
--- a/robot_log_visualizer/ui/autogenerated/set_robot_model.py
+++ b/robot_log_visualizer/ui/autogenerated/set_robot_model.py
@@ -2,9 +2,10 @@
# Form implementation generated from reading ui file 'robot_log_visualizer/ui/misc/set_robot_model.ui'
#
-# Created by: PyQt5 UI code generator 5.14.1
+# Created by: PyQt5 UI code generator 5.15.9
#
-# WARNING! All changes made in this file will be lost!
+# WARNING: Any manual changes made to this file will be lost when pyuic5 is
+# run again. Do not edit this file unless you know what you are doing.
from PyQt5 import QtCore, QtGui, QtWidgets
@@ -61,8 +62,8 @@ def setupUi(self, setRobotModelDialog):
self.gridLayout.addItem(spacerItem, 2, 0, 1, 1)
self.retranslateUi(setRobotModelDialog)
- self.buttonBox.accepted.connect(setRobotModelDialog.accept)
- self.buttonBox.rejected.connect(setRobotModelDialog.reject)
+ self.buttonBox.accepted.connect(setRobotModelDialog.accept) # type: ignore
+ self.buttonBox.rejected.connect(setRobotModelDialog.reject) # type: ignore
QtCore.QMetaObject.connectSlotsByName(setRobotModelDialog)
def retranslateUi(self, setRobotModelDialog):
diff --git a/robot_log_visualizer/ui/autogenerated/video_tab.py b/robot_log_visualizer/ui/autogenerated/video_tab.py
index 0a0c3c0..f627445 100644
--- a/robot_log_visualizer/ui/autogenerated/video_tab.py
+++ b/robot_log_visualizer/ui/autogenerated/video_tab.py
@@ -2,9 +2,10 @@
# Form implementation generated from reading ui file 'robot_log_visualizer/ui/misc/video_tab.ui'
#
-# Created by: PyQt5 UI code generator 5.14.1
+# Created by: PyQt5 UI code generator 5.15.9
#
-# WARNING! All changes made in this file will be lost!
+# WARNING: Any manual changes made to this file will be lost when pyuic5 is
+# run again. Do not edit this file unless you know what you are doing.
from PyQt5 import QtCore, QtGui, QtWidgets
diff --git a/robot_log_visualizer/ui/autogenerated/visualizer.py b/robot_log_visualizer/ui/autogenerated/visualizer.py
index 7e3c9be..373bfaa 100644
--- a/robot_log_visualizer/ui/autogenerated/visualizer.py
+++ b/robot_log_visualizer/ui/autogenerated/visualizer.py
@@ -2,9 +2,10 @@
# Form implementation generated from reading ui file 'robot_log_visualizer/ui/misc/visualizer.ui'
#
-# Created by: PyQt5 UI code generator 5.14.1
+# Created by: PyQt5 UI code generator 5.15.9
#
-# WARNING! All changes made in this file will be lost!
+# WARNING: Any manual changes made to this file will be lost when pyuic5 is
+# run again. Do not edit this file unless you know what you are doing.
from PyQt5 import QtCore, QtGui, QtWidgets
@@ -247,7 +248,10 @@ def setupUi(self, MainWindow):
self.actionAbout.setObjectName("actionAbout")
self.actionSet_Robot_Model = QtWidgets.QAction(MainWindow)
self.actionSet_Robot_Model.setObjectName("actionSet_Robot_Model")
+ self.actionRealtime_Connect = QtWidgets.QAction(MainWindow)
+ self.actionRealtime_Connect.setObjectName("actionRealtime_Connect")
self.menuFile.addAction(self.actionOpen)
+ self.menuFile.addAction(self.actionRealtime_Connect)
self.menuFile.addSeparator()
self.menuFile.addAction(self.actionQuit)
self.menuHelp.addAction(self.actionAbout)
@@ -278,4 +282,6 @@ def retranslateUi(self, MainWindow):
self.actionOpen.setShortcut(_translate("MainWindow", "Ctrl+O"))
self.actionAbout.setText(_translate("MainWindow", "About"))
self.actionSet_Robot_Model.setText(_translate("MainWindow", "Set Robot Model"))
+ self.actionRealtime_Connect.setText(_translate("MainWindow", "Realtime Connect"))
+ self.actionRealtime_Connect.setShortcut(_translate("MainWindow", "Ctrl+R"))
from PyQt5 import QtWebEngineWidgets
diff --git a/robot_log_visualizer/ui/gui.py b/robot_log_visualizer/ui/gui.py
index fdc77a7..bbc353b 100644
--- a/robot_log_visualizer/ui/gui.py
+++ b/robot_log_visualizer/ui/gui.py
@@ -14,9 +14,14 @@
QVBoxLayout,
QLineEdit,
QDialogButtonBox,
- QTableWidgetItem,
)
-from PyQt5.QtMultimedia import QMediaContent, QMediaPlayer
+from robot_log_visualizer.signal_provider.realtime_signal_provider import (
+ RealtimeSignalProvider,
+ are_deps_installed,
+)
+from robot_log_visualizer.signal_provider.matfile_signal_provider import (
+ MatfileSignalProvider,
+)
from robot_log_visualizer.ui.plot_item import PlotItem
from robot_log_visualizer.ui.video_item import VideoItem
@@ -26,7 +31,6 @@
PeriodicThreadState,
RobotStatePath,
ColorPalette,
- Color,
)
import sys
@@ -49,6 +53,7 @@
import pyqtconsole.highlighter as hl
+
class SetRobotModelDialog(QtWidgets.QDialog):
def __init__(
self, parent=None, model_path=None, package_dir=None, model_modificable=True
@@ -122,10 +127,18 @@ def get_icon(icon_name):
class RobotViewerMainWindow(QtWidgets.QMainWindow):
- def __init__(self, signal_provider, meshcat_provider, animation_period):
+ def __init__(self, signal_provider_period, meshcat_provider, animation_period):
# call QMainWindow constructor
super().__init__()
+ self.signal_provider_period = signal_provider_period
+
+ # for realtime logging
+ self.realtimePlotUpdaterThreadActive = False
+ self.plotData = {}
+ self.realtime_connection_enabled = False
+ self.timeoutAttempts = 20
+
self.animation_period = animation_period
# set up the user interface
@@ -151,9 +164,7 @@ def __init__(self, signal_provider, meshcat_provider, animation_period):
self.about = About()
- self.signal_provider = signal_provider
- self.signal_size = len(self.signal_provider)
- self.signal_provider.register_update_index(self.update_index)
+ self.signal_provider = None
self.meshcat_provider = meshcat_provider
@@ -185,6 +196,14 @@ def __init__(self, signal_provider, meshcat_provider, animation_period):
# connect action
self.ui.actionQuit.triggered.connect(self.close)
self.ui.actionOpen.triggered.connect(self.open_mat_file)
+
+ if are_deps_installed():
+ self.ui.actionRealtime_Connect.triggered.connect(
+ self.connect_realtime_logger
+ )
+ else:
+ self.ui.actionRealtime_Connect.setEnabled(False)
+
self.ui.actionAbout.triggered.connect(self.open_about)
self.ui.actionSet_Robot_Model.triggered.connect(self.open_set_robot_model)
@@ -304,9 +323,8 @@ def keyPressEvent(self, event):
self.slider_pressed = False
def toolButton_on_click(self):
- self.plot_items.append(
- PlotItem(signal_provider=self.signal_provider, period=self.animation_period)
- )
+ self.plot_items.append(PlotItem(period=self.animation_period))
+ self.plot_items[-1].set_signal_provider(self.signal_provider)
self.ui.tabPlotWidget.addTab(self.plot_items[-1], "Plot")
if self.ui.tabPlotWidget.count() == 1:
@@ -354,7 +372,7 @@ def startButton_on_click(self):
self.ui.startButton.setEnabled(False)
self.ui.pauseButton.setEnabled(True)
self.signal_provider.state = PeriodicThreadState.running
- # self.meshcat_provider.state = PeriodicThreadState.running
+ self.meshcat_provider.state = PeriodicThreadState.running
self.logger.write_to_log("Dataset started.")
@@ -367,17 +385,16 @@ def pauseButton_on_click(self):
video_item.media_player.pause()
self.signal_provider.state = PeriodicThreadState.pause
- # self.meshcat_provider.state = PeriodicThreadState.pause
+ self.meshcat_provider.state = PeriodicThreadState.pause
self.logger.write_to_log("Dataset paused.")
def plotTabCloseButton_on_click(self, index):
- self.ui.tabPlotWidget.removeTab(index)
self.plot_items[index].canvas.quit_animation()
del self.plot_items[index]
-
if self.ui.tabPlotWidget.count() == 1:
self.ui.tabPlotWidget.setTabsClosable(False)
+ self.ui.tabPlotWidget.removeTab(index)
def plotTabBar_on_doubleClick(self, index):
dlg, plot_title = build_plot_title_box_dialog()
@@ -410,6 +427,11 @@ def variableTreeWidget_on_click(self):
if not paths:
return
+ self.plotData[self.ui.tabPlotWidget.currentIndex()] = {
+ "paths": paths,
+ "legends": legends,
+ }
+
self.plot_items[self.ui.tabPlotWidget.currentIndex()].canvas.update_plots(
paths, legends
)
@@ -531,10 +553,13 @@ def closeEvent(self, event):
self.meshcat_provider.state = PeriodicThreadState.closed
self.meshcat_provider.wait()
- self.signal_provider.state = PeriodicThreadState.closed
- self.signal_provider.wait()
+ if self.signal_provider is not None:
+ self.signal_provider.state = PeriodicThreadState.closed
+ self.signal_provider.wait()
event.accept()
+ if self.realtime_connection_enabled:
+ self.realtime_connection_enabled = False
def __populate_variable_tree_widget(self, obj, parent) -> QTreeWidgetItem:
if not isinstance(obj, dict):
@@ -580,10 +605,20 @@ def __populate_text_logging_tree_widget(self, obj, parent) -> QTreeWidgetItem:
return parent
def __load_mat_file(self, file_name):
- self.signal_provider.open_mat_file(file_name)
+ self.signal_provider = MatfileSignalProvider(
+ self.signal_provider_period, "robot_logger_device"
+ )
+ self.signal_provider.open(file_name)
self.signal_size = len(self.signal_provider)
+ self.signal_provider.start()
+
+ self.signal_provider.register_update_index(self.update_index)
+
+ # add signal provider to the plot items
+ self.plot_items[-1].set_signal_provider(self.signal_provider)
- # load the model
+ # load the model and load the provider
+ self.meshcat_provider.set_signal_provider(self.signal_provider)
if not self.meshcat_provider.load_model(
self.signal_provider.joints_name, self.signal_provider.robot_name
):
@@ -596,6 +631,8 @@ def __load_mat_file(self, file_name):
self.logger.write_to_log(msg)
+ self.meshcat_provider.start()
+
# populate tree
root = list(self.signal_provider.data.keys())[0]
root_item = QTreeWidgetItem([root])
@@ -648,6 +685,7 @@ def __load_mat_file(self, file_name):
video_item.media_player.pause()
self.meshcat_provider.state = PeriodicThreadState.running
+ self.ui.actionRealtime_Connect.setEnabled(False)
self.dataset_loaded = True
@@ -663,6 +701,54 @@ def open_mat_file(self):
)
if file_name:
self.__load_mat_file(file_name)
+ return True
+ return False
+
+ def connect_realtime_logger(self):
+ self.signal_provider = RealtimeSignalProvider(
+ self.signal_provider_period, "robot_realtime"
+ )
+ self.realtime_connection_enabled = True
+
+ # Do initial connection to populate the necessary data
+ if not self.signal_provider.open("/yarp-robot-logger/rt_logging"):
+ self.logger.write_to_log("Could not connect to the real-time logger.")
+ self.realtime_connection_enabled = False
+ self.signal_provider = None
+ return
+ # only display one root in the gui
+ root = list(self.signal_provider.data.keys())[0]
+ root_item = QTreeWidgetItem([root])
+ root_item.setFlags(root_item.flags() & ~Qt.ItemIsSelectable)
+ items = self.__populate_variable_tree_widget(
+ self.signal_provider.data[root], root_item
+ )
+ self.ui.variableTreeWidget.insertTopLevelItems(0, [items])
+
+ if not self.meshcat_provider.load_model(
+ self.signal_provider.joints_name, self.signal_provider.robot_name
+ ):
+ # if not loaded we print an error but we continue
+ msg = "Unable to load the model: "
+ if self.meshcat_provider.custom_model_path:
+ msg = msg + self.meshcat_provider.custom_model_path
+ else:
+ msg = msg + self.signal_provider.robot_name
+
+ self.logger.write_to_log(msg)
+
+ # Disable these buttons for RT communication
+ self.ui.startButton.setEnabled(False)
+ self.ui.timeSlider.setEnabled(False)
+
+ # start the threads accordingly
+ self.signal_provider.state = PeriodicThreadState.running
+ self.signal_provider.start()
+ self.meshcat_provider.set_signal_provider(self.signal_provider)
+ self.meshcat_provider.state = PeriodicThreadState.running
+ self.meshcat_provider.start()
+ for plot in self.plot_items:
+ plot.set_signal_provider(self.signal_provider)
def open_about(self):
self.about.show()
diff --git a/robot_log_visualizer/ui/misc/visualizer.ui b/robot_log_visualizer/ui/misc/visualizer.ui
index c3dff71..9696a61 100644
--- a/robot_log_visualizer/ui/misc/visualizer.ui
+++ b/robot_log_visualizer/ui/misc/visualizer.ui
@@ -503,6 +503,7 @@
&File
+
@@ -556,12 +557,20 @@
Set Robot Model
+
+
+ Realtime Connect
+
+
+ Ctrl+R
+
+
QWebEngineView
QWidget
-
+ QtWebEngineWidgets/QWebEngineView
1
diff --git a/robot_log_visualizer/ui/plot_item.py b/robot_log_visualizer/ui/plot_item.py
index b9ada3e..dbc93e0 100644
--- a/robot_log_visualizer/ui/plot_item.py
+++ b/robot_log_visualizer/ui/plot_item.py
@@ -9,12 +9,13 @@
class PlotItem(QFrame):
- def __init__(self, signal_provider, period):
+ def __init__(self, period):
super().__init__(None)
self.ui = Ui_PlotTab()
self.ui.setupUi(self)
- self.canvas = MatplotlibViewerCanvas(
- parent=self, period=period, signal_provider=signal_provider
- )
+ self.canvas = MatplotlibViewerCanvas(parent=self, period=period)
self.ui.plotLayout.addWidget(self.canvas)
+
+ def set_signal_provider(self, signal_provider):
+ self.canvas.set_signal_provider(signal_provider)
diff --git a/robot_log_visualizer/ui/text_logging.py b/robot_log_visualizer/ui/text_logging.py
index 2ab0eeb..477b4b8 100644
--- a/robot_log_visualizer/ui/text_logging.py
+++ b/robot_log_visualizer/ui/text_logging.py
@@ -3,7 +3,7 @@
QTableWidgetItem,
)
-from PyQt5.QtGui import QPainter, QColor, QFont, QBrush
+from PyQt5.QtGui import QColor, QBrush
class TextLoggingItem: