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 -
PyQt5.QtWebEngineWidgets
+
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: