plot_view.py
Go to the documentation of this file.
1 # Software License Agreement (BSD License)
2 #
3 # Copyright (c) 2014, Austin Hendrix, Stanford University
4 # All rights reserved.
5 #
6 # Redistribution and use in source and binary forms, with or without
7 # modification, are permitted provided that the following conditions
8 # are met:
9 #
10 # * Redistributions of source code must retain the above copyright
11 # notice, this list of conditions and the following disclaimer.
12 # * Redistributions in binary form must reproduce the above
13 # copyright notice, this list of conditions and the following
14 # disclaimer in the documentation and/or other materials provided
15 # with the distribution.
16 # * Neither the name of Willow Garage, Inc. nor the names of its
17 # contributors may be used to endorse or promote products derived
18 # from this software without specific prior written permission.
19 #
20 # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
21 # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
22 # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
23 # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
24 # COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
25 # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
26 # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
27 # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
28 # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
29 # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
30 # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
31 # POSSIBILITY OF SUCH DAMAGE.
32 
33 # Design notes:
34 #
35 # The original rxbag plot widget displays the plot
36 #
37 # It has a settings menu which allows the user to add subplots
38 # and assign arbitrary fields to subplots. possibly many fields in a single
39 # subplot, or one field per subplot, or any othe rcombination
40 # in particular, it is possible to add the same field to multiple subplots
41 # It doesn't appear able to add fields from different topics to the same plot
42 # Since rqt_plot can do this, and it's useful for our application, it's worth
43 # thinking about. If it makes the UI too cluttered, it may not be worth it
44 # If it isn't possible due to the architecture of rqt_bag, it may not be
45 # worth it
46 #
47 # Thoughts on new design:
48 # a plottable field is anything which is either numeric, or contains
49 # at least one plottable subfield (this is useful for enabling/disabling all
50 # of the fields of a complex type)
51 #
52 # for simple messages, we could display a tree view of the message fields
53 # on top of the plot, along with the color for each plottable field. This gets
54 # unweildy for large messages, because they'll use up too much screen space
55 # displaying the topic list
56 #
57 # It may be better to display the topic list for a plot as a dockable widget,
58 # and be able to dismiss it when it isn't actively in use, similar to the
59 # existing rxbag plot config window
60 #
61 # The plot should be dockable and viewable. If it's a separate window, it
62 # doesn't make sense to make it align with the timeline. This could be done
63 # if someone wanted to implement a separate timeline view
64 
65 import os
66 import math
67 import codecs
68 import threading
69 import rospkg
70 from rqt_bag import MessageView
71 
72 from python_qt_binding import loadUi
73 from python_qt_binding.QtCore import Qt, qWarning, Signal
74 from python_qt_binding.QtGui import QDoubleValidator, QIcon
75 from python_qt_binding.QtWidgets import QWidget, QPushButton, QTreeWidget, QTreeWidgetItem, QSizePolicy, QApplication, QAbstractItemView
76 
77 from rqt_plot.data_plot import DataPlot
78 
79 # rospy used for Time and Duration objects, for interacting with rosbag
80 import rospy
81 
82 # compatibility fix for python2/3
83 try:
84  long
85 except NameError:
86  long = int
87 
88 class PlotView(MessageView):
89 
90  """
91  Popup plot viewer
92  """
93  name = 'Plot'
94 
95  def __init__(self, timeline, parent, topic):
96  super(PlotView, self).__init__(timeline, topic)
97 
98  self.plot_widget = PlotWidget(timeline, parent, topic)
99 
100  parent.layout().addWidget(self.plot_widget)
101 
102  def message_viewed(self, bag, msg_details):
103  """
104  refreshes the plot
105  """
106  _, msg, t = msg_details[:3]
107 
108  if t is None:
109  self.message_cleared()
110  else:
111  self.plot_widget.message_tree.set_message(msg)
112  self.plot_widget.set_cursor((t - self.plot_widget.start_stamp).to_sec())
113 
114  def message_cleared(self):
115  pass
116 
117 
118 class PlotWidget(QWidget):
119 
120  def __init__(self, timeline, parent, topic):
121  super(PlotWidget, self).__init__(parent)
122  self.setObjectName('PlotWidget')
123 
124  self.timeline = timeline
125  msg_type = self.timeline.get_datatype(topic)
126  self.msgtopic = topic
127  self.start_stamp = self.timeline._get_start_stamp()
128  self.end_stamp = self.timeline._get_end_stamp()
129 
130  # the current region-of-interest for our bag file
131  # all resampling and plotting is done with these limits
132  self.limits = [0, (self.end_stamp - self.start_stamp).to_sec()]
133 
134  rp = rospkg.RosPack()
135  ui_file = os.path.join(rp.get_path('rqt_bag_plugins'), 'resource', 'plot.ui')
136  loadUi(ui_file, self)
137  self.message_tree = MessageTree(msg_type, self)
138  self.data_tree_layout.addWidget(self.message_tree)
139  # TODO: make this a dropdown with choices for "Auto", "Full" and
140  # "Custom"
141  # I continue to want a "Full" option here
142  self.auto_res.stateChanged.connect(self.autoChanged)
143 
144  self.resolution.editingFinished.connect(self.settingsChanged)
145  self.resolution.setValidator(QDoubleValidator(0.0, 1000.0, 6, self.resolution))
146 
147  self.timeline.selected_region_changed.connect(self.region_changed)
148 
149  self.recompute_timestep()
150 
151  self.plot = DataPlot(self)
152  self.plot.set_autoscale(x=False)
153  self.plot.set_autoscale(y=DataPlot.SCALE_VISIBLE)
154  self.plot.autoscroll(False)
155  self.plot.set_xlim(self.limits)
156  self.data_plot_layout.addWidget(self.plot)
157 
158  self._home_button = QPushButton()
159  self._home_button.setToolTip("Reset View")
160  self._home_button.setIcon(QIcon.fromTheme('go-home'))
161  self._home_button.clicked.connect(self.home)
162  self.plot_toolbar_layout.addWidget(self._home_button)
163 
164  self._config_button = QPushButton("Configure Plot")
165  self._config_button.clicked.connect(self.plot.doSettingsDialog)
166  self.plot_toolbar_layout.addWidget(self._config_button)
167 
168  self.set_cursor(0)
169 
170  self.paths_on = set()
171  self._lines = None
172 
173  # get bag from timeline
174  bag = None
175  start_time = self.start_stamp
176  while bag is None:
177  bag, entry = self.timeline.get_entry(start_time, topic)
178  if bag is None:
179  start_time = self.timeline.get_entry_after(start_time)[1].time
180 
181  self.bag = bag
182  # get first message from bag
183  msg = bag._read_message(entry.position)
184  self.message_tree.set_message(msg[1])
185 
186  # state used by threaded resampling
187  self.resampling_active = False
188  self.resample_thread = None
189  self.resample_fields = set()
190 
191  def set_cursor(self, position):
192  self.plot.vline(position, color=DataPlot.RED)
193  self.plot.redraw()
194 
195  def add_plot(self, path):
196  self.resample_data([path])
197 
198  def update_plot(self):
199  if len(self.paths_on) > 0:
200  self.resample_data(self.paths_on)
201 
202  def remove_plot(self, path):
203  self.plot.remove_curve(path)
204  self.paths_on.remove(path)
205  self.plot.redraw()
206 
207  def load_data(self):
208  """get a generator for the specified time range on our bag"""
209  return self.bag.read_messages(self.msgtopic,
210  self.start_stamp + rospy.Duration.from_sec(self.limits[0]),
211  self.start_stamp + rospy.Duration.from_sec(self.limits[1]))
212 
213  def resample_data(self, fields):
214  if self.resample_thread:
215  # cancel existing thread and join
216  self.resampling_active = False
217  self.resample_thread.join()
218 
219  for f in fields:
220  self.resample_fields.add(f)
221 
222  # start resampling thread
223  self.resampling_active = True
224  self.resample_thread = threading.Thread(target=self._resample_thread)
225  # explicitly mark our resampling thread as a daemon, because we don't
226  # want to block program exit on a long resampling operation
227  self.resample_thread.setDaemon(True)
228  self.resample_thread.start()
229 
230  def _resample_thread(self):
231  # TODO:
232  # * look into doing partial display updates for long resampling
233  # operations
234  # * add a progress bar for resampling operations
235  x = {}
236  y = {}
237  for path in self.resample_fields:
238  x[path] = []
239  y[path] = []
240 
241  # bag object is not thread-safe; lock it while we resample
242  with self.timeline._bag_lock:
243  try:
244  msgdata = self.load_data()
245  except ValueError:
246  # bag is closed or invalid; we're done here
247  self.resampling_active = False
248  return
249 
250  for entry in msgdata:
251  # detect if we're cancelled and return early
252  if not self.resampling_active:
253  return
254 
255  for path in self.resample_fields:
256  # this resampling method is very unstable, because it picks
257  # representative points rather than explicitly representing
258  # the minimum and maximum values present within a sample
259  # If the data has spikes, this is particularly bad because they
260  # will be missed entirely at some resolutions and offsets
261  if x[path] == [] or (entry[2] - self.start_stamp).to_sec() - x[path][-1] >= self.timestep:
262  y_value = entry[1]
263  for field in path.split('.'):
264  index = None
265  if field.endswith(']'):
266  field = field[:-1]
267  field, _, index = field.rpartition('[')
268  y_value = getattr(y_value, field)
269  if index:
270  index = int(index)
271  y_value = y_value[index]
272  y[path].append(y_value)
273  x[path].append((entry[2] - self.start_stamp).to_sec())
274 
275  # TODO: incremental plot updates would go here...
276  # we should probably do incremental updates based on time;
277  # that is, push new data to the plot maybe every .5 or .1
278  # seconds
279  # time is a more useful metric than, say, messages loaded or
280  # percentage, because it will give a reasonable refresh rate
281  # without overloading the computer
282  # if we had a progress bar, we could emit a signal to update it here
283 
284  # update the plot with final resampled data
285  for path in self.resample_fields:
286  if len(x[path]) < 1:
287  qWarning("Resampling resulted in 0 data points for %s" % path)
288  else:
289  if path in self.paths_on:
290  self.plot.clear_values(path)
291  self.plot.update_values(path, x[path], y[path])
292  else:
293  self.plot.add_curve(path, path, x[path], y[path])
294  self.paths_on.add(path)
295 
296  self.plot.redraw()
297 
298  self.resample_fields.clear()
299  self.resampling_active = False
300 
302  # this is only called if we think the timestep has changed; either
303  # by changing the limits or by editing the resolution
304  limits = self.limits
305  if self.auto_res.isChecked():
306  timestep = round((limits[1] - limits[0]) / 200.0, 5)
307  else:
308  timestep = float(self.resolution.text())
309  self.resolution.setText(str(timestep))
310  self.timestep = timestep
311 
312  def region_changed(self, start, end):
313  # this is the only place where self.limits is set
314  limits = [(start - self.start_stamp).to_sec(),
315  (end - self.start_stamp).to_sec()]
316 
317  # cap the limits to the start and end of our bag file
318  if limits[0] < 0:
319  limits = [0.0, limits[1]]
320  if limits[1] > (self.end_stamp - self.start_stamp).to_sec():
321  limits = [limits[0], (self.end_stamp - self.start_stamp).to_sec()]
322 
323  self.limits = limits
324 
325  self.recompute_timestep()
326  self.plot.set_xlim(limits)
327  self.plot.redraw()
328  self.update_plot()
329 
330  def settingsChanged(self):
331  # resolution changed. recompute the timestep and resample
332  self.recompute_timestep()
333  self.update_plot()
334 
335  def autoChanged(self, state):
336  if state == 2:
337  # auto mode enabled. recompute the timestep and resample
338  self.resolution.setDisabled(True)
339  self.recompute_timestep()
340  self.update_plot()
341  else:
342  # auto mode disabled. enable the resolution text box
343  # no change to resolution yet, so no need to redraw
344  self.resolution.setDisabled(False)
345 
346  def home(self):
347  # TODO: re-add the button for this. It's useful for restoring the
348  # X and Y limits so that we can see all of the data
349  # effectively a "zoom all" button
350 
351  # reset the plot to our current limits
352  self.plot.set_xlim(self.limits)
353  # redraw the plot; this forces a Y autoscaling
354  self.plot.redraw()
355 
356 
357 class MessageTree(QTreeWidget):
358 
359  def __init__(self, msg_type, parent):
360  super(MessageTree, self).__init__(parent)
361  self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
362  self.setHeaderHidden(False)
363  self.setHeaderLabel('')
364  self.setSelectionMode(QAbstractItemView.ExtendedSelection)
365  self.itemChanged.connect(self.handleChanged)
366  self._msg_type = msg_type
367  self._msg = None
368 
369  self._expanded_paths = None
370  self._checked_states = set()
371  self.plot_list = set()
373 
374  # populate the tree from the message type
375 
376  @property
377  def msg(self):
378  return self._msg
379 
380  def set_message(self, msg):
381  # Remember whether items were expanded or not before deleting
382  if self._msg:
383  for item in self.get_all_items():
384  path = self.get_item_path(item)
385  if item.isExpanded():
386  self._expanded_paths.add(path)
387  elif path in self._expanded_paths:
388  self._expanded_paths.remove(path)
389  if item.checkState(0) == Qt.Checked:
390  self._checked_states.add(path)
391  elif path in self._checked_states:
392  self._checked_states.remove(path)
393  self.clear()
394  if msg:
395  self.setHeaderLabel(msg._type)
396 
397  # Populate the tree
398  self._add_msg_object(None, '', '', msg, msg._type)
399 
400  if self._expanded_paths is None:
401  self._expanded_paths = set()
402  else:
403  # Expand those that were previously expanded, and collapse any paths that
404  # we've seen for the first time
405  for item in self.get_all_items():
406  path = self.get_item_path(item)
407  if path in self._expanded_paths:
408  item.setExpanded(True)
409  else:
410  item.setExpanded(False)
411  self._msg = msg
412  self.update()
413 
414  def get_item_path(self, item):
415  return item.data(0, Qt.UserRole)[0].replace(' ', '') # remove spaces that may get introduced in indexing, e.g. [ 3] is [3]
416 
417  def get_all_items(self):
418  items = []
419  try:
420  root = self.invisibleRootItem()
421  self.traverse(root, items.append)
422  except Exception:
423  # TODO: very large messages can cause a stack overflow due to recursion
424  pass
425  return items
426 
427  def traverse(self, root, function):
428  for i in range(root.childCount()):
429  child = root.child(i)
430  function(child)
431  self.traverse(child, function)
432 
433  def _add_msg_object(self, parent, path, name, obj, obj_type):
434  label = name
435 
436  if hasattr(obj, '__slots__'):
437  subobjs = [(slot, getattr(obj, slot)) for slot in obj.__slots__]
438  elif type(obj) in [list, tuple]:
439  len_obj = len(obj)
440  if len_obj == 0:
441  subobjs = []
442  else:
443  w = int(math.ceil(math.log10(len_obj)))
444  subobjs = [('[%*d]' % (w, i), subobj) for (i, subobj) in enumerate(obj)]
445  else:
446  subobjs = []
447 
448  plotitem = False
449  if type(obj) in [int, long, float]:
450  plotitem = True
451  if type(obj) == float:
452  obj_repr = '%.6f' % obj
453  else:
454  obj_repr = str(obj)
455 
456  if obj_repr[0] == '-':
457  label += ': %s' % obj_repr
458  else:
459  label += ': %s' % obj_repr
460 
461  elif type(obj) in [str, bool, int, long, float, complex, rospy.Time]:
462  # Ignore any binary data
463  obj_repr = codecs.utf_8_decode(str(obj).encode(), 'ignore')[0]
464 
465  # Truncate long representations
466  if len(obj_repr) >= 50:
467  obj_repr = obj_repr[:50] + '...'
468 
469  label += ': ' + obj_repr
470  item = QTreeWidgetItem([label])
471  if name == '':
472  pass
473  elif path.find('.') == -1 and path.find('[') == -1:
474  self.addTopLevelItem(item)
475  else:
476  parent.addChild(item)
477  if plotitem == True:
478  if path.replace(' ', '') in self._checked_states:
479  item.setCheckState(0, Qt.Checked)
480  else:
481  item.setCheckState(0, Qt.Unchecked)
482  item.setData(0, Qt.UserRole, (path, obj_type))
483 
484  for subobj_name, subobj in subobjs:
485  if subobj is None:
486  continue
487 
488  if path == '':
489  subpath = subobj_name # root field
490  elif subobj_name.startswith('['):
491  subpath = '%s%s' % (path, subobj_name) # list, dict, or tuple
492  else:
493  subpath = '%s.%s' % (path, subobj_name) # attribute (prefix with '.')
494 
495  if hasattr(subobj, '_type'):
496  subobj_type = subobj._type
497  else:
498  subobj_type = type(subobj).__name__
499 
500  self._add_msg_object(item, subpath, subobj_name, subobj, subobj_type)
501 
502  def handleChanged(self, item, column):
503  if item.data(0, Qt.UserRole) == None:
504  pass
505  else:
506  path = self.get_item_path(item)
507  if item.checkState(column) == Qt.Checked:
508  if path not in self.plot_list:
509  self.plot_list.add(path)
510  self.parent().parent().parent().add_plot(path)
511  if item.checkState(column) == Qt.Unchecked:
512  if path in self.plot_list:
513  self.plot_list.remove(path)
514  self.parent().parent().parent().remove_plot(path)
515 
516  def on_key_press(self, event):
517  if event.modifiers() & Qt.ControlModifier:
518  key = event.key()
519  if key == ord('C') or key == ord('c'):
520  # Ctrl-C: copy text from selected items to clipboard
522  event.accept()
523  elif key == ord('A') or key == ord('a'):
524  # Ctrl-A: expand the tree and select all items
525  self.expandAll()
526  self.selectAll()
527 
529  # Get tab indented text for all selected items
530  def get_distance(item, ancestor, distance=0):
531  parent = item.parent()
532  if parent is None:
533  return distance
534  else:
535  return get_distance(parent, ancestor, distance + 1)
536  text = ''
537  for i in self.get_all_items():
538  if i in self.selectedItems():
539  text += ('\t' * (get_distance(i, None))) + i.text(0) + '\n'
540  # Copy the text to the clipboard
541  clipboard = QApplication.clipboard()
542  clipboard.setText(text)
def region_changed(self, start, end)
Definition: plot_view.py:312
def set_cursor(self, position)
Definition: plot_view.py:191
def __init__(self, msg_type, parent)
Definition: plot_view.py:359
def traverse(self, root, function)
Definition: plot_view.py:427
def __init__(self, timeline, parent, topic)
Definition: plot_view.py:120
def _add_msg_object(self, parent, path, name, obj, obj_type)
Definition: plot_view.py:433
def __init__(self, timeline, parent, topic)
Definition: plot_view.py:95
def handleChanged(self, item, column)
Definition: plot_view.py:502
def message_viewed(self, bag, msg_details)
Definition: plot_view.py:102


rqt_bag_plugins
Author(s): Dirk Thomas , Aaron Blasdel , Austin Hendrix , Tim Field
autogenerated on Fri Feb 19 2021 03:14:15