diff --git a/robot_log_visualizer/__init__.py b/robot_log_visualizer/__init__.py index 88cb257..5567e4a 100644 --- a/robot_log_visualizer/__init__.py +++ b/robot_log_visualizer/__init__.py @@ -1,4 +1,3 @@ import os -# Prefer the PySide6 backend when QtPy resolves the Qt binding. -os.environ.setdefault("QT_API", "pyside2") +os.environ.setdefault("QT_API", "pyqt5") diff --git a/robot_log_visualizer/signal_provider/realtime_signal_provider.py b/robot_log_visualizer/signal_provider/realtime_signal_provider.py index d41c6c0..a1bdd0c 100644 --- a/robot_log_visualizer/signal_provider/realtime_signal_provider.py +++ b/robot_log_visualizer/signal_provider/realtime_signal_provider.py @@ -10,11 +10,10 @@ import numpy as np from robot_log_visualizer.signal_provider.signal_provider import ( - ProviderType, - SignalProvider, -) + ProviderType, SignalProvider) from robot_log_visualizer.utils.utils import PeriodicThreadState +ROBOT_REALTIME_KEY = "robot_realtime" def are_deps_installed(): try: @@ -115,16 +114,16 @@ def __init__(self, period: float, signal_root_name: str): # Track signals to buffer self.buffered_signals = set() # Always include joints_state - self.buffered_signals.add("robot_realtime::joints_state::positions") - - # TODO: implement a logic to remove signals that are not needed anymore + self.buffered_signals.add( + f"{ROBOT_REALTIME_KEY}::joints_state::positions" + ) # TODO: implement a logic to remove signals that are not needed anymore def add_signals_to_buffer(self, signals: Union[str, Iterable[str]]): """Add signals to the buffer set.""" if isinstance(signals, str): signals = {signals} self.buffered_signals.update(signals) # Always include joints_state - self.buffered_signals.add("robot_realtime::joints_state::positions") + self.buffered_signals.add(f"{ROBOT_REALTIME_KEY}::joints_state::positions") def __len__(self): """ @@ -145,6 +144,9 @@ def _update_data_buffer( Any sample older than the fixed time window is removed. """ + if not keys: + return + if keys[0] not in raw_data: raw_data[keys[0]] = DequeToNumpyLeaf() @@ -171,26 +173,78 @@ def _update_data_buffer( 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. + + - Creates only missing nested nodes. + - At a leaf: initialize buffers if missing and merge elements_names + (do not overwrite existing elements_names). + - Returns True if the call created or extended metadata for the given path, + False otherwise. """ + + if not isinstance(keys, list) or not keys: + raise ValueError( + f"Invalid keys parameter: {keys}. Expected a non-empty list." + ) + if not all(isinstance(k, str) for k in keys): + raise ValueError( + f"Invalid keys elements: {keys}. All elements must be strings." + ) + + if not isinstance(raw_data, (dict, DequeToNumpyLeaf)): + raise ValueError( + f"Invalid raw_data parameter: {raw_data}. Expected a dictionary-like object." + ) + + if not isinstance(value, (list, tuple, str, int, float)): + raise ValueError( + f"Invalid value parameter: {value}. Expected a list, tuple, or scalar." + ) + if keys[0] == "timestamps": - return + return False + + # ensure node exists if keys[0] not in raw_data: raw_data[keys[0]] = DequeToNumpyLeaf() + created = True + else: + created = False + if len(keys) == 1: + # leaf 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() + # do not delete existing node on empty value; just no-op + return created + + node = raw_data[keys[0]] + + # initialize leaf buffers if missing + if "elements_names" not in node: + node["elements_names"] = ( + list(value) if isinstance(value, (list, tuple)) else value + ) + node["data"] = deque() + node["timestamps"] = deque() + return True - raw_data[keys[0]]["elements_names"] = value + # merge element names (append only new entries) + if isinstance(node["elements_names"], list) and isinstance( + value, (list, tuple) + ): + added = False + for v in value: + if v not in node["elements_names"]: + node["elements_names"].append(v) + added = True + return added or created + + # fallback: if elements_names is not a list, don't overwrite + return created else: - self._populate_realtime_logger_metadata(raw_data[keys[0]], keys[1:], value) + # recurse into the subtree + return self._populate_realtime_logger_metadata( + raw_data[keys[0]], keys[1:], value + ) def open(self, source: str) -> bool: """ @@ -224,10 +278,12 @@ def open(self, source: str) -> bool: 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 + self.joints_name = self.rt_metadata_dict[ + f"{ROBOT_REALTIME_KEY}::description_list" ] + self.robot_name = self.rt_metadata_dict[ + f"{ROBOT_REALTIME_KEY}::yarp_robot_name" + ][0] # Populate metadata into self.data recursively. for key_string, value in self.rt_metadata_dict.items(): @@ -254,6 +310,75 @@ def index(self): finally: self.index_lock.unlock() + def check_for_new_metadata(self) -> bool: + """ + Check if new metadata is available using the client's streaming data flag. + This avoids expensive RPC calls. + + Returns: + bool: True if new metadata is available, False otherwise. + """ + client = self.vector_collections_client + if client is None: + return False + + try: + return client.is_new_metadata_available() + except Exception as exc: + print(f"Error checking for new metadata: {exc}") + return False + + def update_metadata(self): + """ + Refresh the metadata from the remote realtime logger. + New metadata items are added to self.rt_metadata_dict and + the corresponding data buffers are created in self.data. + + Returns: + dict: New metadata items added, or None if no new items. + """ + + client = self.vector_collections_client + if client is None: + print("Refresh metadata: realtime client unavailable.") + return + + try: + updated_md = client.get_metadata().vectors + except Exception as exc: + print(f"Error fetching metadata: {exc}") + return + + existing_md = self.rt_metadata_dict or {} + new_items = {k: v for k, v in updated_md.items() if k not in existing_md} + + if not new_items: + return + + existing_md.update(new_items) + self.rt_metadata_dict = existing_md + + desc_key = f"{ROBOT_REALTIME_KEY}::description_list" + yarp_name_key = f"{ROBOT_REALTIME_KEY}::yarp_robot_name" + if desc_key in new_items: + self.joints_name = existing_md[desc_key] + if yarp_name_key in new_items: + names = existing_md.get(yarp_name_key, []) + if names: + self.robot_name = names[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 new_items + def get_item_from_path_at_index(self, path, index, default_path=None, neighbor=0): """ Get the latest data item from the given path at the latest index. @@ -288,49 +413,68 @@ def run(self): """ while True: start = time.time() + if self.state == PeriodicThreadState.closed: + return + if self.state == PeriodicThreadState.running: - # Read the latest data from the realtime logger. - vc_input = self.vector_collections_client.read_data(True).vectors + new_samples_read = False + + while True: + try: + packet = self.vector_collections_client.read_data(False) + except Exception as exc: # noqa: BLE001 - surface runtime issues + print(f"Error reading realtime data: {exc}") + break + + if packet is None: + break + + vc_input = getattr(packet, "vectors", None) + if not vc_input: + break + + timestamps_key = f"{ROBOT_REALTIME_KEY}::timestamps" + if timestamps_key not in vc_input or not vc_input[timestamps_key]: + continue + + recent_timestamp = vc_input[timestamps_key][0] - 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 signal selected from the user that is 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 - - # Check if any selected signal starts with this path - match = any( - sel.startswith(key_string) for sel in self.buffered_signals - ) - if not match: - continue - - keys = key_string.split("::") - self._update_data_buffer( - self.data, keys, value, recent_timestamp - ) - - self.index_lock.unlock() - - # Signal that new data are available. - self.update_index_signal.emit() + try: + self._timestamps.append(recent_timestamp) + + while self._timestamps and ( + recent_timestamp - self._timestamps[0] + > self.realtime_fixed_plot_window + ): + self._timestamps.popleft() + + if self._timestamps: + self.initial_time = self._timestamps[0] + self.end_time = self._timestamps[-1] + + for key_string, value in vc_input.items(): + if key_string == timestamps_key: + continue + + match = any( + sel.startswith(key_string) + for sel in self.buffered_signals + ) + if not match: + continue + + keys = key_string.split("::") + self._update_data_buffer( + self.data, keys, value, recent_timestamp + ) + finally: + self.index_lock.unlock() + + new_samples_read = True + + if new_samples_read: + self.update_index_signal.emit() # Sleep until the next period. sleep_time = self.period - (time.time() - start) diff --git a/robot_log_visualizer/ui/gui.py b/robot_log_visualizer/ui/gui.py index 02ec82a..46a646f 100644 --- a/robot_log_visualizer/ui/gui.py +++ b/robot_log_visualizer/ui/gui.py @@ -2,60 +2,41 @@ # This software may be modified and distributed under the terms of the # Released under the terms of the BSD 3-Clause License -# QtPy abstraction -from qtpy import QtWidgets, QtGui, QtCore -from qtpy import QtWebEngineWidgets # noqa: F401 # Ensure WebEngine is initialised -from qtpy.QtCore import QMutex, QMutexLocker, QUrl, Qt, Slot -from qtpy.QtWidgets import ( - QDialog, - QDialogButtonBox, - QFileDialog, - QLineEdit, - QToolButton, - QTreeWidgetItem, - QVBoxLayout, -) - -pyqtSlot = Slot -from robot_log_visualizer.robot_visualizer.meshcat_provider import MeshcatProvider -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.signal_provider.signal_provider import ( - ProviderType, - SignalProvider, -) -from robot_log_visualizer.ui.plot_item import PlotItem -from robot_log_visualizer.ui.video_item import VideoItem -from robot_log_visualizer.ui.text_logging import TextLoggingItem - -from robot_log_visualizer.utils.utils import ( - PeriodicThreadState, - RobotStatePath, - ColorPalette, -) - -import sys import os import pathlib import re +import sys +# for logging +from time import localtime, strftime import numpy as np - +import pyqtconsole.highlighter as hl +from pyqtconsole.console import PythonConsole +# QtPy abstraction +from qtpy import QtWebEngineWidgets # noqa: F401 +from qtpy import QtGui, QtWidgets +from qtpy.QtCore import QMutex, QMutexLocker, Qt, QTimer, QUrl, Slot +from qtpy.QtWidgets import (QDialog, QDialogButtonBox, QFileDialog, QLineEdit, + QToolButton, QTreeWidgetItem, QVBoxLayout) + +from robot_log_visualizer.robot_visualizer.meshcat_provider import \ + MeshcatProvider +from robot_log_visualizer.signal_provider.matfile_signal_provider import \ + MatfileSignalProvider +from robot_log_visualizer.signal_provider.realtime_signal_provider import ( + ROBOT_REALTIME_KEY, RealtimeSignalProvider, are_deps_installed) +from robot_log_visualizer.signal_provider.signal_provider import ( + ProviderType, SignalProvider) +from robot_log_visualizer.ui.plot_item import PlotItem +from robot_log_visualizer.ui.text_logging import TextLoggingItem # QtDesigner generated classes from robot_log_visualizer.ui.ui_loader import load_ui +from robot_log_visualizer.ui.video_item import VideoItem +from robot_log_visualizer.utils.utils import (ColorPalette, + PeriodicThreadState, + RobotStatePath) -# for logging -from time import localtime, strftime - -# Matplotlib class -from pyqtconsole.console import PythonConsole -import pyqtconsole.highlighter as hl +pyqtSlot = Slot class SetRobotModelDialog(QtWidgets.QDialog): @@ -258,6 +239,25 @@ def __init__(self, signal_provider_period, meshcat_provider, animation_period): self.ui.pauseButton.clicked.connect(self.pauseButton_on_click) self.ui.startButton.clicked.connect(self.startButton_on_click) + self.ui.refreshButton.clicked.connect(self.refreshButton_on_click) + + # by default the refresh button is only relevant for realtime connections + try: + self.ui.refreshButton.setEnabled(False) + except Exception: + pass + + # Setup for refresh button blinking/color change + self.refresh_button_blink_state = False + self.refresh_button_timer = QTimer(self) + self.refresh_button_timer.timeout.connect(self._toggle_refresh_button_style) + self.refresh_button_timer.setInterval(500) # Blink every 500ms + + # Timer to periodically check for new metadata in realtime mode + self.metadata_check_timer = QTimer(self) + self.metadata_check_timer.timeout.connect(self._check_for_new_metadata) + self.metadata_check_timer.setInterval(2000) # Check every 2 seconds + self.ui.timeSlider.sliderReleased.connect(self.timeSlider_on_release) self.ui.timeSlider.sliderPressed.connect(self.timeSlider_on_pressed) self.ui.timeSlider.sliderMoved.connect(self.timeSlider_on_sliderMoved) @@ -461,6 +461,7 @@ def variableTreeWidget_on_click(self): path = [] legend = [] is_leaf = True + self.logger.write_to_log(f"Selected index data: {index.data()}") while index.data() is not None: legend.append(index.data()) if not is_leaf: @@ -476,6 +477,10 @@ def variableTreeWidget_on_click(self): paths.append(path) legends.append(legend) + # Debug logs + self.logger.write_to_log(f"Selected paths: {paths}") + self.logger.write_to_log(f"Selected legends: {legends}") + # if there is no selection we do nothing if not paths: return @@ -617,6 +622,11 @@ def closeEvent(self, event): if self.signal_provider is not None: self.signal_provider.state = PeriodicThreadState.closed self.signal_provider.wait() + # hide/disable refresh on close + try: + self.ui.refreshButton.setEnabled(False) + except Exception: + pass self.signal_provider = None # Stop the meshcat_provider if exists @@ -643,9 +653,18 @@ def closeEvent(self, event): if self.realtime_connection_enabled: self.realtime_connection_enabled = False + # Stop timers + if hasattr(self, "metadata_check_timer"): + self.metadata_check_timer.stop() + if hasattr(self, "refresh_button_timer"): + self.refresh_button_timer.stop() + event.accept() - def __populate_variable_tree_widget(self, obj, parent) -> QTreeWidgetItem: + def __populate_variable_tree_widget( + self, obj: dict, parent: QTreeWidgetItem + ) -> QTreeWidgetItem: + if not isinstance(obj, dict): return parent if "data" in obj.keys() and "timestamps" in obj.keys(): @@ -673,6 +692,49 @@ def __populate_variable_tree_widget(self, obj, parent) -> QTreeWidgetItem: parent.addChild(item) return parent + def _update_variable_tree_widget( + self, obj: dict, parent: QTreeWidgetItem + ) -> QTreeWidgetItem: + """ + Recursively merge new metadata into the variable tree widget. + Only checks for existing items at non-leaf nodes. + At leaf nodes, always adds all children. + """ + if not isinstance(obj, dict): + return parent + + if "data" in obj.keys() and "timestamps" in obj.keys(): + temp_array = obj["data"] + try: + n_cols = temp_array.shape[1] + except Exception: + n_cols = 1 + + # Always add all children at leaf level + if "elements_names" in obj.keys(): + for name in obj["elements_names"]: + item = QTreeWidgetItem([name]) + parent.addChild(item) + else: + for i in range(n_cols): + item = QTreeWidgetItem(["Element " + str(i)]) + parent.addChild(item) + return parent + + for key, value in obj.items(): + # Only check for existing child at non-leaf nodes + child_item = None + for i in range(parent.childCount()): + if parent.child(i).text(0) == key: + child_item = parent.child(i) + break + if child_item is None: + child_item = QTreeWidgetItem([key]) + child_item.setFlags(child_item.flags() & ~Qt.ItemIsSelectable) + parent.addChild(child_item) + self._update_variable_tree_widget(value, child_item) + return parent + def __populate_text_logging_tree_widget(self, obj, parent) -> QTreeWidgetItem: if not isinstance(obj, dict): return parent @@ -742,6 +804,11 @@ def __load_mat_file(self, file_name): self.ui.timeSlider.setMaximum(self.signal_size) self.ui.startButton.setEnabled(True) self.ui.timeSlider.setEnabled(True) + # loading a MAT file: refresh is not relevant + try: + self.ui.refreshButton.setEnabled(False) + except Exception: + pass # get all the video associated to the datase filename_without_path = pathlib.Path(file_name).name @@ -790,7 +857,7 @@ def open_mat_file(self): def connect_realtime_logger(self): self.signal_provider = RealtimeSignalProvider( - self.signal_provider_period, "robot_realtime" + self.signal_provider_period, ROBOT_REALTIME_KEY ) self.realtime_connection_enabled = True @@ -799,6 +866,11 @@ def connect_realtime_logger(self): self.logger.write_to_log("Could not connect to the real-time logger.") self.realtime_connection_enabled = False self.signal_provider = None + # failed to connect: ensure refresh is disabled + try: + self.ui.refreshButton.setEnabled(False) + except Exception: + pass return # only display one root in the gui root = list(self.signal_provider.data.keys())[0] @@ -834,6 +906,16 @@ def connect_realtime_logger(self): for plot in self.plot_items: plot.set_signal_provider(self.signal_provider) + # In realtime mode, the refresh button starts disabled until new metadata is available + try: + self.ui.refreshButton.setEnabled(False) + self.ui.refreshButton.setStyleSheet("") # Reset to normal style + self.refresh_button_blink_state = False + # Start checking for new metadata periodically + self.metadata_check_timer.start() + except Exception: + pass + def open_about(self): self.about.show() @@ -1081,7 +1163,7 @@ def variableTreeWidget_on_right_click(self, item_position): and self.signal_provider.provider_type == ProviderType.REALTIME ): # Convert item_path to signal name string - signal_name = "robot_realtime::" + "::".join(item_path) + signal_name = f"{ROBOT_REALTIME_KEY}::" + "::".join(item_path) self.signal_provider.add_signals_to_buffer([signal_name]) if use_as_base_orientation_str in action.text(): @@ -1096,7 +1178,7 @@ def variableTreeWidget_on_right_click(self, item_position): self.signal_provider and self.signal_provider.provider_type == ProviderType.REALTIME ): - signal_name = "robot_realtime::" + "::".join(item_path) + signal_name = f"{ROBOT_REALTIME_KEY}::" + "::".join(item_path) self.signal_provider.add_signals_to_buffer([signal_name]) if action.text() == dont_use_as_base_position_str: @@ -1132,6 +1214,68 @@ def get_item_path(self, item): path.reverse() return path + def _toggle_refresh_button_style(self): + """Toggle the refresh button style to create a blinking effect.""" + if self.refresh_button_blink_state: + # Normal state + self.ui.refreshButton.setStyleSheet("") + else: + # Highlighted state - use a bright color to draw attention + self.ui.refreshButton.setStyleSheet( + "QPushButton { background-color: #4CAF50; border: 2px solid #45a049; }" + ) + self.refresh_button_blink_state = not self.refresh_button_blink_state + + def _check_for_new_metadata(self): + """Periodically check if new metadata is available in realtime mode.""" + if not isinstance(self.signal_provider, RealtimeSignalProvider): + return + + provider = self.signal_provider + if provider.check_for_new_metadata(): + # New metadata is available - enable and start blinking the button + self.ui.refreshButton.setEnabled(True) + if not self.refresh_button_timer.isActive(): + self.refresh_button_timer.start() + self.logger.write_to_log("New metadata available. Click refresh to update.") + + def refreshButton_on_click(self): + """Fetch fresh realtime metadata, add only new keys, and extend the tree.""" + + if not isinstance(self.signal_provider, RealtimeSignalProvider): + self.logger.write_to_log( + "Refresh metadata: realtime provider not connected." + ) + return + + provider = self.signal_provider + new_items = provider.update_metadata() + if not new_items: + self.logger.write_to_log("Refresh metadata: no new metadata keys found.") + return + + self.logger.write_to_log(f"New metadata keys found: {list(new_items.keys())}") + + # Treat the top-level item as the robot_realtime node + root_item = self.ui.variableTreeWidget.topLevelItem(0) + if root_item is None or root_item.text(0) != ROBOT_REALTIME_KEY: + self.logger.write_to_log( + f"Refresh metadata: '{ROBOT_REALTIME_KEY}' node not found, cannot insert." + ) + return + + # Merge the children of the new subtree into the existing robot_realtime node + with QMutexLocker(provider.index_lock): + self._update_variable_tree_widget( + provider.data[ROBOT_REALTIME_KEY], root_item + ) + + # After refresh, disable the button and stop blinking until new metadata arrives + self.ui.refreshButton.setEnabled(False) + self.refresh_button_timer.stop() + self.ui.refreshButton.setStyleSheet("") # Reset to normal style + self.refresh_button_blink_state = False + class Logger: """ diff --git a/robot_log_visualizer/ui/misc/visualizer.ui b/robot_log_visualizer/ui/misc/visualizer.ui index 32e531f..f7bfd04 100644 --- a/robot_log_visualizer/ui/misc/visualizer.ui +++ b/robot_log_visualizer/ui/misc/visualizer.ui @@ -53,57 +53,6 @@ 9 - - - false - - - - 40 - 16777215 - - - - - - - - ../../../../../.designer/backup../../../../../.designer/backup - - - false - - - false - - - false - - - - - - - false - - - - 0 - 0 - - - - 1 - - - true - - - Qt::Horizontal - - - - false @@ -126,26 +75,7 @@ - - - - - 0 - 0 - - - - - 60 - 0 - - - - 0.0 - - - - + Qt::Horizontal @@ -248,7 +178,7 @@ - + 1 @@ -262,6 +192,101 @@ + + + + false + + + + 0 + 0 + + + + 1 + + + true + + + Qt::Horizontal + + + + + + + + 0 + 0 + + + + + 60 + 0 + + + + 0.0 + + + + + + + false + + + + 40 + 16777215 + + + + + + + + ../../../../../.designer/backup../../../../../.designer/backup + + + false + + + false + + + false + + + + + + + false + + + + 40 + 16777215 + + + + + + + + + + false + + + false + + + @@ -447,7 +472,7 @@ 0 0 - 860 + 858 190 @@ -495,7 +520,7 @@ 0 0 929 - 22 + 24 diff --git a/setup.cfg b/setup.cfg index 1302eae..64d68eb 100644 --- a/setup.cfg +++ b/setup.cfg @@ -43,7 +43,8 @@ install_requires = idyntree >= 10.2.0 meshcat numpy - PySide2 + PyQt5 + PyQtWebEngine qtpy pyqtconsole h5py