Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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