Skip to content

Commit e78b75f

Browse files
Enhance playback speed control and performance optimizations (#122)
1 parent 3549b8a commit e78b75f

7 files changed

Lines changed: 216 additions & 60 deletions

File tree

robot_log_visualizer/__main__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
def main():
1818
thread_periods = {
19-
"meshcat_provider": 0.03,
19+
"meshcat_provider": 0.05,
2020
"signal_provider": 0.03,
2121
"plot_animation": 0.03,
2222
}

robot_log_visualizer/plotter/pyqtgraph_viewer_canvas.py

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
import pyqtgraph as pg # type: ignore
1111
from qtpy import QtCore, QtWidgets # type: ignore
1212

13+
# --- Global pyqtgraph performance tuning ---
14+
pg.setConfigOptions(antialias=False, useOpenGL=True)
15+
1316
from robot_log_visualizer.signal_provider.signal_provider import ProviderType
1417
from robot_log_visualizer.utils.utils import ColorPalette
1518

@@ -55,6 +58,7 @@ def __init__(
5558

5659
# data structures
5760
self._curves: Dict[str, pg.PlotDataItem] = {}
61+
self._curve_fulldata: Dict[str, tuple] = {} # key -> (x, y) full-res
5862
self._annotations: Dict[Point, pg.TextItem] = {}
5963
self._markers: Dict[Point, pg.ScatterPlotItem] = {}
6064
self._palette: Iterable = ColorPalette()
@@ -129,7 +133,12 @@ def resume_animation(self) -> None: # noqa: D401
129133
def quit_animation(self) -> None: # noqa: D401
130134
"""Permanently stop the animation (e.g. when closing the tab)."""
131135
self._timer.stop()
132-
136+
def update_vline_value(self, value: float) -> None:
137+
"""Directly set the vline position (called from update_index)."""
138+
if value == getattr(self, '_last_vline_val', None):
139+
return
140+
self._last_vline_val = value
141+
self._vline.setValue(value)
133142
# -------------------------------------------------------------#
134143
# Qt event handlers #
135144
# -------------------------------------------------------------#
@@ -147,6 +156,11 @@ def _init_ui(self) -> None:
147156
self._plot: pg.PlotWidget = pg.PlotWidget(background="w")
148157
self._layout.addWidget(self._plot)
149158

159+
# --- Performance: only render points visible in the current
160+
# view-port and auto-downsample when zoomed out.
161+
self._plot.setClipToView(True)
162+
self._plot.setDownsampling(mode="peak")
163+
150164
label_style = {"color": "#000000", "font-size": "12pt"}
151165
self._plot.setLabel("bottom", "Time [s]", **label_style)
152166
self._plot.setLabel("left", "Value", **label_style)
@@ -193,13 +207,18 @@ def _add_missing_curves(
193207
palette_color = next(self._palette)
194208
pen = pg.mkPen(palette_color.as_hex(), width=2)
195209

210+
# Pre-downsample for fast rendering (207k → ~4000 points)
211+
x_ds, y_ds = self._downsample_peak(x, y)
212+
196213
self._curves[key] = self._plot.plot(
197-
x,
198-
y,
214+
x_ds,
215+
y_ds,
199216
pen=pen,
200217
name="/".join(legend[1:]),
201218
symbol=None,
219+
clipToView=True,
202220
)
221+
self._curve_fulldata[key] = (x, y)
203222

204223
# For real-time mode, disable curve clickability to avoid interfering with live updates
205224
if self._signal_provider.provider_type == ProviderType.REALTIME:
@@ -211,13 +230,46 @@ def _remove_obsolete_curves(self, paths: Sequence[Path]) -> None:
211230
valid = {"/".join(p) for p in paths}
212231
for key in [k for k in self._curves if k not in valid]:
213232
self._plot.removeItem(self._curves.pop(key))
233+
self._curve_fulldata.pop(key, None)
234+
235+
@staticmethod
236+
def _downsample_peak(x, y, max_points: int = 4000):
237+
"""Peak-preserving downsample: keep min/max per window."""
238+
n = len(x)
239+
if n <= max_points:
240+
return x, y
241+
factor = max(1, n // (max_points // 2))
242+
trim = (n // factor) * factor
243+
yr = y[:trim].reshape(-1, factor)
244+
xr = x[:trim].reshape(-1, factor)
245+
stx = factor // 2
246+
x_mid = xr[:, stx]
247+
y_max_v = yr.max(axis=1)
248+
y_min_v = yr.min(axis=1)
249+
out_n = len(x_mid) * 2
250+
x_out = np.empty(out_n, dtype=x.dtype)
251+
y_out = np.empty(out_n, dtype=y.dtype)
252+
x_out[0::2] = x_mid
253+
x_out[1::2] = x_mid
254+
y_out[0::2] = y_max_v
255+
y_out[1::2] = y_min_v
256+
if trim < n:
257+
x_out = np.concatenate([x_out, x[trim:]])
258+
y_out = np.concatenate([y_out, y[trim:]])
259+
return x_out, y_out
214260

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

220-
self._vline.setValue(self._signal_provider.current_time)
266+
new_val = self._signal_provider.current_time
267+
# Skip the update (and the expensive repaint it triggers) when the
268+
# position has not actually changed.
269+
if new_val == getattr(self, '_last_vline_val', None):
270+
return
271+
self._last_vline_val = new_val
272+
self._vline.setValue(new_val)
221273

222274
def _update_realtime_curves(self):
223275
"""Update all curves with the latest data from the signal provider."""

robot_log_visualizer/robot_visualizer/meshcat_provider.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,15 +237,23 @@ def robot_frames(self):
237237

238238
def run(self):
239239
identity = np.eye(3)
240+
last_index = -1
240241

241242
while True:
242243
start = time.time()
243244

244-
index = self._signal_provider.index
245245
if self.state == PeriodicThreadState.running and self._is_model_loaded:
246+
index = self._signal_provider.index
246247
if len(self._signal_provider) == 0:
247248
time.sleep(self._period)
248249
continue
250+
# Skip if data hasn't advanced (avoids redundant websocket sends)
251+
if index == last_index:
252+
sleep_time = self._period - (time.time() - start)
253+
if sleep_time > 0:
254+
time.sleep(sleep_time)
255+
continue
256+
last_index = index
249257
robot_state = self._signal_provider.get_robot_state_at_index(index)
250258
self.meshcat_visualizer_mutex.lock()
251259
# These are the robot measured joint positions in radians

robot_log_visualizer/signal_provider/matfile_signal_provider.py

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ def __populate_text_logging_data(self, file_object):
7070

7171
def __populate_numerical_data(self, file_object):
7272
data = {}
73+
# Cache for timestamp deduplication: share identical arrays
74+
ts_cache = getattr(self, '_ts_cache', {})
75+
self._ts_cache = ts_cache
76+
7377
for key, value in file_object.items():
7478
if not isinstance(value, h5py._hl.group.Group):
7579
continue
@@ -80,7 +84,15 @@ def __populate_numerical_data(self, file_object):
8084
if "data" in value.keys():
8185
data[key] = {}
8286
data[key]["data"] = np.squeeze(np.array(value["data"]))
83-
data[key]["timestamps"] = np.squeeze(np.array(value["timestamps"]))
87+
raw_ts = np.squeeze(np.array(value["timestamps"]))
88+
89+
# Deduplicate: reuse an existing identical timestamp array
90+
ts_key = (len(raw_ts), raw_ts[0], raw_ts[-1])
91+
if ts_key in ts_cache and np.array_equal(ts_cache[ts_key], raw_ts):
92+
data[key]["timestamps"] = ts_cache[ts_key]
93+
else:
94+
ts_cache[ts_key] = raw_ts
95+
data[key]["timestamps"] = raw_ts
8496

8597
# if the initial or end time has been updated we can also update the entire timestamps dataset
8698
if data[key]["timestamps"][0] < self.initial_time:
@@ -131,33 +143,40 @@ def open(self, source: str) -> bool:
131143
pass
132144
self.index = 0
133145

146+
# Pre-compute relative timestamps for binary search in run()
147+
self._relative_timestamps = self.timestamps - self.initial_time
148+
134149
return True
135150

136151
def run(self):
152+
prev_wall = time.time()
153+
was_running = False
137154
while True:
138155
start = time.time()
139156
if self.state == PeriodicThreadState.running:
157+
now = time.time()
158+
if not was_running:
159+
# Just resumed — ignore stale elapsed time
160+
prev_wall = now
161+
wall_elapsed = now - prev_wall
162+
prev_wall = now
163+
was_running = True
140164
self.index_lock.lock()
141-
tmp_index = self._index
142-
self._current_time += self.period
165+
self._current_time += wall_elapsed * self.playback_speed
143166
self._current_time = min(
144-
self._current_time, self.timestamps[-1] - self.initial_time
167+
self._current_time, self._relative_timestamps[-1]
145168
)
146169

147-
# find the index associated to the current time in self.timestamps
148-
# this is valid since self.timestamps is sorted and self._current_time is increasing
149-
while (
150-
self._current_time > self.timestamps[tmp_index] - self.initial_time
151-
):
152-
tmp_index += 1
153-
if tmp_index > len(self.timestamps):
154-
break
155-
156-
self._index = tmp_index
170+
# Binary search (O(log n)) instead of linear scan
171+
self._index = int(
172+
np.searchsorted(self._relative_timestamps, self._current_time)
173+
)
157174

158175
self.index_lock.unlock()
159176

160177
self.update_index_signal.emit()
178+
else:
179+
was_running = False
161180

162181
sleep_time = self.period - (time.time() - start)
163182
if sleep_time > 0:

robot_log_visualizer/signal_provider/signal_provider.py

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@ def __init__(
8787

8888
self._current_time = 0
8989

90+
self._playback_speed = 1.0
91+
self._playback_speed_lock = QMutex()
92+
9093
self.trajectory_span = 200
9194

9295
self.provider_type = provider_type
@@ -143,6 +146,10 @@ def __populate_text_logging_data(self, file_object):
143146

144147
def __populate_numerical_data(self, file_object):
145148
data = {}
149+
# Cache for timestamp deduplication: share identical arrays
150+
ts_cache = getattr(self, '_ts_cache', {})
151+
self._ts_cache = ts_cache
152+
146153
for key, value in file_object.items():
147154
if not isinstance(value, h5py._hl.group.Group):
148155
continue
@@ -155,10 +162,18 @@ def __populate_numerical_data(self, file_object):
155162
if "data" in value.keys():
156163
data[key] = {}
157164
data[key]["data"] = np.atleast_1d(np.squeeze(np.array(value["data"])))
158-
data[key]["timestamps"] = np.atleast_1d(
165+
raw_ts = np.atleast_1d(
159166
np.squeeze(np.array(value["timestamps"]))
160167
)
161168

169+
# Deduplicate: reuse an existing identical timestamp array
170+
ts_key = (len(raw_ts), raw_ts[0], raw_ts[-1])
171+
if ts_key in ts_cache and np.array_equal(ts_cache[ts_key], raw_ts):
172+
data[key]["timestamps"] = ts_cache[ts_key]
173+
else:
174+
ts_cache[ts_key] = raw_ts
175+
data[key]["timestamps"] = raw_ts
176+
162177
# if the initial or end time has been updated we can also update the entire timestamps dataset
163178
if data[key]["timestamps"][0] < self.initial_time:
164179
self.timestamps = data[key]["timestamps"]
@@ -275,6 +290,16 @@ def current_time(self):
275290
value = self._current_time
276291
return value
277292

293+
@property
294+
def playback_speed(self):
295+
locker = QMutexLocker(self._playback_speed_lock)
296+
return self._playback_speed
297+
298+
@playback_speed.setter
299+
def playback_speed(self, value):
300+
locker = QMutexLocker(self._playback_speed_lock)
301+
self._playback_speed = value
302+
278303
def get_item_from_path(self, path, default_path=None):
279304
data = self.data[self.root_name]
280305

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

307-
closest_index = np.argmin(np.abs(timestamps - self.timestamps[index]))
332+
target = self.timestamps[index]
333+
# Use binary search (O(log n)) instead of linear scan (O(n))
334+
closest_index = np.searchsorted(timestamps, target)
335+
# Clamp and pick the nearer of the two neighbors
336+
if closest_index >= len(timestamps):
337+
closest_index = len(timestamps) - 1
338+
elif closest_index > 0 and (
339+
abs(timestamps[closest_index - 1] - target)
340+
<= abs(timestamps[closest_index] - target)
341+
):
342+
closest_index -= 1
308343

309344
if neighbor == 0:
310345
return data[closest_index, :]

0 commit comments

Comments
 (0)