Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion robot_log_visualizer/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

def main():
thread_periods = {
"meshcat_provider": 0.03,
"meshcat_provider": 0.05,
"signal_provider": 0.03,
"plot_animation": 0.03,
}
Expand Down
60 changes: 56 additions & 4 deletions robot_log_visualizer/plotter/pyqtgraph_viewer_canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
import pyqtgraph as pg # type: ignore
from qtpy import QtCore, QtWidgets # type: ignore

# --- Global pyqtgraph performance tuning ---
pg.setConfigOptions(antialias=False, useOpenGL=True)

from robot_log_visualizer.signal_provider.signal_provider import ProviderType
from robot_log_visualizer.utils.utils import ColorPalette

Expand Down Expand Up @@ -55,6 +58,7 @@ def __init__(

# data structures
self._curves: Dict[str, pg.PlotDataItem] = {}
self._curve_fulldata: Dict[str, tuple] = {} # key -> (x, y) full-res
self._annotations: Dict[Point, pg.TextItem] = {}
self._markers: Dict[Point, pg.ScatterPlotItem] = {}
self._palette: Iterable = ColorPalette()
Expand Down Expand Up @@ -129,7 +133,12 @@ def resume_animation(self) -> None: # noqa: D401
def quit_animation(self) -> None: # noqa: D401
"""Permanently stop the animation (e.g. when closing the tab)."""
self._timer.stop()

def update_vline_value(self, value: float) -> None:
"""Directly set the vline position (called from update_index)."""
if value == getattr(self, '_last_vline_val', None):
return
self._last_vline_val = value
self._vline.setValue(value)
# -------------------------------------------------------------#
# Qt event handlers #
# -------------------------------------------------------------#
Expand All @@ -147,6 +156,11 @@ def _init_ui(self) -> None:
self._plot: pg.PlotWidget = pg.PlotWidget(background="w")
self._layout.addWidget(self._plot)

# --- Performance: only render points visible in the current
# view-port and auto-downsample when zoomed out.
self._plot.setClipToView(True)
self._plot.setDownsampling(mode="peak")

label_style = {"color": "#000000", "font-size": "12pt"}
self._plot.setLabel("bottom", "Time [s]", **label_style)
self._plot.setLabel("left", "Value", **label_style)
Expand Down Expand Up @@ -193,13 +207,18 @@ def _add_missing_curves(
palette_color = next(self._palette)
pen = pg.mkPen(palette_color.as_hex(), width=2)

# Pre-downsample for fast rendering (207k → ~4000 points)
x_ds, y_ds = self._downsample_peak(x, y)

self._curves[key] = self._plot.plot(
x,
y,
x_ds,
y_ds,
pen=pen,
name="/".join(legend[1:]),
symbol=None,
clipToView=True,
)
self._curve_fulldata[key] = (x, y)
Comment on lines +210 to +221
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curves are now plotted with pre-downsampled (x_ds, y_ds), but point selection (_on_mouse_click) uses curve.getData(), so selections/annotations will snap to the downsampled points rather than the true underlying signal. Since you also store full-res data in _curve_fulldata, consider using that full data for hit-testing/selection (or remove selection support when downsampled) so the displayed coordinates and picked points remain accurate.

Copilot uses AI. Check for mistakes.

# For real-time mode, disable curve clickability to avoid interfering with live updates
if self._signal_provider.provider_type == ProviderType.REALTIME:
Expand All @@ -211,13 +230,46 @@ def _remove_obsolete_curves(self, paths: Sequence[Path]) -> None:
valid = {"/".join(p) for p in paths}
for key in [k for k in self._curves if k not in valid]:
self._plot.removeItem(self._curves.pop(key))
self._curve_fulldata.pop(key, None)

@staticmethod
def _downsample_peak(x, y, max_points: int = 4000):
"""Peak-preserving downsample: keep min/max per window."""
n = len(x)
if n <= max_points:
return x, y
factor = max(1, n // (max_points // 2))
trim = (n // factor) * factor
yr = y[:trim].reshape(-1, factor)
xr = x[:trim].reshape(-1, factor)
stx = factor // 2
x_mid = xr[:, stx]
y_max_v = yr.max(axis=1)
y_min_v = yr.min(axis=1)
out_n = len(x_mid) * 2
x_out = np.empty(out_n, dtype=x.dtype)
y_out = np.empty(out_n, dtype=y.dtype)
x_out[0::2] = x_mid
x_out[1::2] = x_mid
y_out[0::2] = y_max_v
y_out[1::2] = y_min_v
if trim < n:
x_out = np.concatenate([x_out, x[trim:]])
y_out = np.concatenate([y_out, y[trim:]])
return x_out, y_out

def _update_vline(self) -> None:
"""Move the vertical line to ``current_time``."""
if self._signal_provider is None:
return

self._vline.setValue(self._signal_provider.current_time)
new_val = self._signal_provider.current_time
# Skip the update (and the expensive repaint it triggers) when the
# position has not actually changed.
if new_val == getattr(self, '_last_vline_val', None):
return
self._last_vline_val = new_val
self._vline.setValue(new_val)

def _update_realtime_curves(self):
"""Update all curves with the latest data from the signal provider."""
Expand Down
10 changes: 9 additions & 1 deletion robot_log_visualizer/robot_visualizer/meshcat_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,15 +237,23 @@ def robot_frames(self):

def run(self):
identity = np.eye(3)
last_index = -1

while True:
start = time.time()

index = self._signal_provider.index
if self.state == PeriodicThreadState.running and self._is_model_loaded:
index = self._signal_provider.index
if len(self._signal_provider) == 0:
time.sleep(self._period)
continue
# Skip if data hasn't advanced (avoids redundant websocket sends)
if index == last_index:
sleep_time = self._period - (time.time() - start)
if sleep_time > 0:
time.sleep(sleep_time)
continue
last_index = index
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
Expand Down
47 changes: 33 additions & 14 deletions robot_log_visualizer/signal_provider/matfile_signal_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ def __populate_text_logging_data(self, file_object):

def __populate_numerical_data(self, file_object):
data = {}
# Cache for timestamp deduplication: share identical arrays
ts_cache = getattr(self, '_ts_cache', {})
self._ts_cache = ts_cache

for key, value in file_object.items():
if not isinstance(value, h5py._hl.group.Group):
continue
Expand All @@ -80,7 +84,15 @@ def __populate_numerical_data(self, file_object):
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"]))
raw_ts = np.squeeze(np.array(value["timestamps"]))

# Deduplicate: reuse an existing identical timestamp array
ts_key = (len(raw_ts), raw_ts[0], raw_ts[-1])
if ts_key in ts_cache and np.array_equal(ts_cache[ts_key], raw_ts):
data[key]["timestamps"] = ts_cache[ts_key]
else:
ts_cache[ts_key] = raw_ts
data[key]["timestamps"] = raw_ts
Comment thread
GiulioRomualdi marked this conversation as resolved.

# 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:
Expand Down Expand Up @@ -131,33 +143,40 @@ def open(self, source: str) -> bool:
pass
self.index = 0

# Pre-compute relative timestamps for binary search in run()
self._relative_timestamps = self.timestamps - self.initial_time

return True

def run(self):
prev_wall = time.time()
was_running = False
while True:
start = time.time()
if self.state == PeriodicThreadState.running:
now = time.time()
if not was_running:
# Just resumed — ignore stale elapsed time
prev_wall = now
wall_elapsed = now - prev_wall
prev_wall = now
was_running = True
self.index_lock.lock()
tmp_index = self._index
self._current_time += self.period
self._current_time += wall_elapsed * self.playback_speed
self._current_time = min(
self._current_time, self.timestamps[-1] - self.initial_time
self._current_time, self._relative_timestamps[-1]
)

# 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
# Binary search (O(log n)) instead of linear scan
self._index = int(
np.searchsorted(self._relative_timestamps, self._current_time)
)

self.index_lock.unlock()

self.update_index_signal.emit()
else:
was_running = False

sleep_time = self.period - (time.time() - start)
if sleep_time > 0:
Expand Down
39 changes: 37 additions & 2 deletions robot_log_visualizer/signal_provider/signal_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ def __init__(

self._current_time = 0

self._playback_speed = 1.0
self._playback_speed_lock = QMutex()

self.trajectory_span = 200

self.provider_type = provider_type
Expand Down Expand Up @@ -143,6 +146,10 @@ def __populate_text_logging_data(self, file_object):

def __populate_numerical_data(self, file_object):
data = {}
# Cache for timestamp deduplication: share identical arrays
ts_cache = getattr(self, '_ts_cache', {})
self._ts_cache = ts_cache

for key, value in file_object.items():
if not isinstance(value, h5py._hl.group.Group):
continue
Expand All @@ -155,10 +162,18 @@ def __populate_numerical_data(self, file_object):
if "data" in value.keys():
data[key] = {}
data[key]["data"] = np.atleast_1d(np.squeeze(np.array(value["data"])))
data[key]["timestamps"] = np.atleast_1d(
raw_ts = np.atleast_1d(
np.squeeze(np.array(value["timestamps"]))
)

# Deduplicate: reuse an existing identical timestamp array
ts_key = (len(raw_ts), raw_ts[0], raw_ts[-1])
if ts_key in ts_cache and np.array_equal(ts_cache[ts_key], raw_ts):
data[key]["timestamps"] = ts_cache[ts_key]
else:
ts_cache[ts_key] = raw_ts
data[key]["timestamps"] = raw_ts
Comment thread
GiulioRomualdi marked this conversation as resolved.

# 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"]
Expand Down Expand Up @@ -275,6 +290,16 @@ def current_time(self):
value = self._current_time
return value

@property
def playback_speed(self):
locker = QMutexLocker(self._playback_speed_lock)
return self._playback_speed

@playback_speed.setter
def playback_speed(self, value):
locker = QMutexLocker(self._playback_speed_lock)
self._playback_speed = value

def get_item_from_path(self, path, default_path=None):
data = self.data[self.root_name]

Expand Down Expand Up @@ -304,7 +329,17 @@ def get_item_from_path_at_index(self, path, index, default_path=None, neighbor=0
if self.timestamps is None or len(self.timestamps) == 0:
return None

closest_index = np.argmin(np.abs(timestamps - self.timestamps[index]))
target = self.timestamps[index]
# Use binary search (O(log n)) instead of linear scan (O(n))
closest_index = np.searchsorted(timestamps, target)
# Clamp and pick the nearer of the two neighbors
if closest_index >= len(timestamps):
closest_index = len(timestamps) - 1
elif closest_index > 0 and (
abs(timestamps[closest_index - 1] - target)
<= abs(timestamps[closest_index] - target)
):
closest_index -= 1

if neighbor == 0:
return data[closest_index, :]
Expand Down
Loading
Loading