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
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 
83 class PlotView(MessageView):
84 
85  """
86  Popup plot viewer
87  """
88  name = 'Plot'
89 
90  def __init__(self, timeline, parent, topic):
91  super(PlotView, self).__init__(timeline, topic)
92 
93  self.plot_widget = PlotWidget(timeline, parent, topic)
94 
95  parent.layout().addWidget(self.plot_widget)
96 
97  def message_viewed(self, bag, msg_details):
98  """
99  refreshes the plot
100  """
101  _, msg, t = msg_details[:3]
102 
103  if t is None:
104  self.message_cleared()
105  else:
106  self.plot_widget.message_tree.set_message(msg)
107  self.plot_widget.set_cursor((t - self.plot_widget.start_stamp).to_sec())
108 
109  def message_cleared(self):
110  pass
111 
112 
113 class PlotWidget(QWidget):
114 
115  def __init__(self, timeline, parent, topic):
116  super(PlotWidget, self).__init__(parent)
117  self.setObjectName('PlotWidget')
118 
119  self.timeline = timeline
120  msg_type = self.timeline.get_datatype(topic)
121  self.msgtopic = topic
122  self.start_stamp = self.timeline._get_start_stamp()
123  self.end_stamp = self.timeline._get_end_stamp()
124 
125  # the current region-of-interest for our bag file
126  # all resampling and plotting is done with these limits
127  self.limits = [0, (self.end_stamp - self.start_stamp).to_sec()]
128 
129  rp = rospkg.RosPack()
130  ui_file = os.path.join(rp.get_path('rqt_bag_plugins'), 'resource', 'plot.ui')
131  loadUi(ui_file, self)
132  self.message_tree = MessageTree(msg_type, self)
133  self.data_tree_layout.addWidget(self.message_tree)
134  # TODO: make this a dropdown with choices for "Auto", "Full" and
135  # "Custom"
136  # I continue to want a "Full" option here
137  self.auto_res.stateChanged.connect(self.autoChanged)
138 
139  self.resolution.editingFinished.connect(self.settingsChanged)
140  self.resolution.setValidator(QDoubleValidator(0.0, 1000.0, 6, self.resolution))
141 
142  self.timeline.selected_region_changed.connect(self.region_changed)
143 
144  self.recompute_timestep()
145 
146  self.plot = DataPlot(self)
147  self.plot.set_autoscale(x=False)
148  self.plot.set_autoscale(y=DataPlot.SCALE_VISIBLE)
149  self.plot.autoscroll(False)
150  self.plot.set_xlim(self.limits)
151  self.data_plot_layout.addWidget(self.plot)
152 
153  self._home_button = QPushButton()
154  self._home_button.setToolTip("Reset View")
155  self._home_button.setIcon(QIcon.fromTheme('go-home'))
156  self._home_button.clicked.connect(self.home)
157  self.plot_toolbar_layout.addWidget(self._home_button)
158 
159  self._config_button = QPushButton("Configure Plot")
160  self._config_button.clicked.connect(self.plot.doSettingsDialog)
161  self.plot_toolbar_layout.addWidget(self._config_button)
162 
163  self.set_cursor(0)
164 
165  self.paths_on = set()
166  self._lines = None
167 
168  # get bag from timeline
169  bag = None
170  start_time = self.start_stamp
171  while bag is None:
172  bag, entry = self.timeline.get_entry(start_time, topic)
173  if bag is None:
174  start_time = self.timeline.get_entry_after(start_time)[1].time
175 
176  self.bag = bag
177  # get first message from bag
178  msg = bag._read_message(entry.position)
179  self.message_tree.set_message(msg[1])
180 
181  # state used by threaded resampling
182  self.resampling_active = False
183  self.resample_thread = None
184  self.resample_fields = set()
185 
186  def set_cursor(self, position):
187  self.plot.vline(position, color=DataPlot.RED)
188  self.plot.redraw()
189 
190  def add_plot(self, path):
191  self.resample_data([path])
192 
193  def update_plot(self):
194  if len(self.paths_on) > 0:
195  self.resample_data(self.paths_on)
196 
197  def remove_plot(self, path):
198  self.plot.remove_curve(path)
199  self.paths_on.remove(path)
200  self.plot.redraw()
201 
202  def load_data(self):
203  """get a generator for the specified time range on our bag"""
204  return self.bag.read_messages(self.msgtopic,
205  self.start_stamp + rospy.Duration.from_sec(self.limits[0]),
206  self.start_stamp + rospy.Duration.from_sec(self.limits[1]))
207 
208  def resample_data(self, fields):
209  if self.resample_thread:
210  # cancel existing thread and join
211  self.resampling_active = False
212  self.resample_thread.join()
213 
214  for f in fields:
215  self.resample_fields.add(f)
216 
217  # start resampling thread
218  self.resampling_active = True
219  self.resample_thread = threading.Thread(target=self._resample_thread)
220  # explicitly mark our resampling thread as a daemon, because we don't
221  # want to block program exit on a long resampling operation
222  self.resample_thread.setDaemon(True)
223  self.resample_thread.start()
224 
225  def _resample_thread(self):
226  # TODO:
227  # * look into doing partial display updates for long resampling
228  # operations
229  # * add a progress bar for resampling operations
230  x = {}
231  y = {}
232  for path in self.resample_fields:
233  x[path] = []
234  y[path] = []
235 
236  # bag object is not thread-safe; lock it while we resample
237  with self.timeline._bag_lock:
238  try:
239  msgdata = self.load_data()
240  except ValueError:
241  # bag is closed or invalid; we're done here
242  self.resampling_active = False
243  return
244 
245  for entry in msgdata:
246  # detect if we're cancelled and return early
247  if not self.resampling_active:
248  return
249 
250  for path in self.resample_fields:
251  # this resampling method is very unstable, because it picks
252  # representative points rather than explicitly representing
253  # the minimum and maximum values present within a sample
254  # If the data has spikes, this is particularly bad because they
255  # will be missed entirely at some resolutions and offsets
256  if x[path] == [] or (entry[2] - self.start_stamp).to_sec() - x[path][-1] >= self.timestep:
257  y_value = entry[1]
258  for field in path.split('.'):
259  index = None
260  if field.endswith(']'):
261  field = field[:-1]
262  field, _, index = field.rpartition('[')
263  y_value = getattr(y_value, field)
264  if index:
265  index = int(index)
266  y_value = y_value[index]
267  y[path].append(y_value)
268  x[path].append((entry[2] - self.start_stamp).to_sec())
269 
270  # TODO: incremental plot updates would go here...
271  # we should probably do incremental updates based on time;
272  # that is, push new data to the plot maybe every .5 or .1
273  # seconds
274  # time is a more useful metric than, say, messages loaded or
275  # percentage, because it will give a reasonable refresh rate
276  # without overloading the computer
277  # if we had a progress bar, we could emit a signal to update it here
278 
279  # update the plot with final resampled data
280  for path in self.resample_fields:
281  if len(x[path]) < 1:
282  qWarning("Resampling resulted in 0 data points for %s" % path)
283  else:
284  if path in self.paths_on:
285  self.plot.clear_values(path)
286  self.plot.update_values(path, x[path], y[path])
287  else:
288  self.plot.add_curve(path, path, x[path], y[path])
289  self.paths_on.add(path)
290 
291  self.plot.redraw()
292 
293  self.resample_fields.clear()
294  self.resampling_active = False
295 
297  # this is only called if we think the timestep has changed; either
298  # by changing the limits or by editing the resolution
299  limits = self.limits
300  if self.auto_res.isChecked():
301  timestep = round((limits[1] - limits[0]) / 200.0, 5)
302  else:
303  timestep = float(self.resolution.text())
304  self.resolution.setText(str(timestep))
305  self.timestep = timestep
306 
307  def region_changed(self, start, end):
308  # this is the only place where self.limits is set
309  limits = [(start - self.start_stamp).to_sec(),
310  (end - self.start_stamp).to_sec()]
311 
312  # cap the limits to the start and end of our bag file
313  if limits[0] < 0:
314  limits = [0.0, limits[1]]
315  if limits[1] > (self.end_stamp - self.start_stamp).to_sec():
316  limits = [limits[0], (self.end_stamp - self.start_stamp).to_sec()]
317 
318  self.limits = limits
319 
320  self.recompute_timestep()
321  self.plot.set_xlim(limits)
322  self.plot.redraw()
323  self.update_plot()
324 
325  def settingsChanged(self):
326  # resolution changed. recompute the timestep and resample
327  self.recompute_timestep()
328  self.update_plot()
329 
330  def autoChanged(self, state):
331  if state == 2:
332  # auto mode enabled. recompute the timestep and resample
333  self.resolution.setDisabled(True)
334  self.recompute_timestep()
335  self.update_plot()
336  else:
337  # auto mode disabled. enable the resolution text box
338  # no change to resolution yet, so no need to redraw
339  self.resolution.setDisabled(False)
340 
341  def home(self):
342  # TODO: re-add the button for this. It's useful for restoring the
343  # X and Y limits so that we can see all of the data
344  # effectively a "zoom all" button
345 
346  # reset the plot to our current limits
347  self.plot.set_xlim(self.limits)
348  # redraw the plot; this forces a Y autoscaling
349  self.plot.redraw()
350 
351 
352 class MessageTree(QTreeWidget):
353 
354  def __init__(self, msg_type, parent):
355  super(MessageTree, self).__init__(parent)
356  self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
357  self.setHeaderHidden(True)
358  self.itemChanged.connect(self.handleChanged)
359  self._msg_type = msg_type
360  self._msg = None
361 
362  self._expanded_paths = None
363  self._checked_states = set()
364  self.plot_list = set()
365 
366  # populate the tree from the message type
367 
368  @property
369  def msg(self):
370  return self._msg
371 
372  def set_message(self, msg):
373  # Remember whether items were expanded or not before deleting
374  if self._msg:
375  for item in self.get_all_items():
376  path = self.get_item_path(item)
377  if item.isExpanded():
378  self._expanded_paths.add(path)
379  elif path in self._expanded_paths:
380  self._expanded_paths.remove(path)
381  if item.checkState(0) == Qt.Checked:
382  self._checked_states.add(path)
383  elif path in self._checked_states:
384  self._checked_states.remove(path)
385  self.clear()
386  if msg:
387  # Populate the tree
388  self._add_msg_object(None, '', '', msg, msg._type)
389 
390  if self._expanded_paths is None:
391  self._expanded_paths = set()
392  else:
393  # Expand those that were previously expanded, and collapse any paths that
394  # we've seen for the first time
395  for item in self.get_all_items():
396  path = self.get_item_path(item)
397  if path in self._expanded_paths:
398  item.setExpanded(True)
399  else:
400  item.setExpanded(False)
401  self._msg = msg
402  self.update()
403 
404  def get_item_path(self, item):
405  return item.data(0, Qt.UserRole)[0].replace(' ', '') # remove spaces that may get introduced in indexing, e.g. [ 3] is [3]
406 
407  def get_all_items(self):
408  items = []
409  try:
410  root = self.invisibleRootItem()
411  self.traverse(root, items.append)
412  except Exception:
413  # TODO: very large messages can cause a stack overflow due to recursion
414  pass
415  return items
416 
417  def traverse(self, root, function):
418  for i in range(root.childCount()):
419  child = root.child(i)
420  function(child)
421  self.traverse(child, function)
422 
423  def _add_msg_object(self, parent, path, name, obj, obj_type):
424  label = name
425 
426  if hasattr(obj, '__slots__'):
427  subobjs = [(slot, getattr(obj, slot)) for slot in obj.__slots__]
428  elif type(obj) in [list, tuple]:
429  len_obj = len(obj)
430  if len_obj == 0:
431  subobjs = []
432  else:
433  w = int(math.ceil(math.log10(len_obj)))
434  subobjs = [('[%*d]' % (w, i), subobj) for (i, subobj) in enumerate(obj)]
435  else:
436  subobjs = []
437 
438  plotitem = False
439  if type(obj) in [int, long, float]:
440  plotitem = True
441  if type(obj) == float:
442  obj_repr = '%.6f' % obj
443  else:
444  obj_repr = str(obj)
445 
446  if obj_repr[0] == '-':
447  label += ': %s' % obj_repr
448  else:
449  label += ': %s' % obj_repr
450 
451  elif type(obj) in [str, bool, int, long, float, complex, rospy.Time]:
452  # Ignore any binary data
453  obj_repr = codecs.utf_8_decode(str(obj), 'ignore')[0]
454 
455  # Truncate long representations
456  if len(obj_repr) >= 50:
457  obj_repr = obj_repr[:50] + '...'
458 
459  label += ': ' + obj_repr
460  item = QTreeWidgetItem([label])
461  if name == '':
462  pass
463  elif path.find('.') == -1 and path.find('[') == -1:
464  self.addTopLevelItem(item)
465  else:
466  parent.addChild(item)
467  if plotitem == True:
468  if path.replace(' ', '') in self._checked_states:
469  item.setCheckState(0, Qt.Checked)
470  else:
471  item.setCheckState(0, Qt.Unchecked)
472  item.setData(0, Qt.UserRole, (path, obj_type))
473 
474  for subobj_name, subobj in subobjs:
475  if subobj is None:
476  continue
477 
478  if path == '':
479  subpath = subobj_name # root field
480  elif subobj_name.startswith('['):
481  subpath = '%s%s' % (path, subobj_name) # list, dict, or tuple
482  else:
483  subpath = '%s.%s' % (path, subobj_name) # attribute (prefix with '.')
484 
485  if hasattr(subobj, '_type'):
486  subobj_type = subobj._type
487  else:
488  subobj_type = type(subobj).__name__
489 
490  self._add_msg_object(item, subpath, subobj_name, subobj, subobj_type)
491 
492  def handleChanged(self, item, column):
493  if item.data(0, Qt.UserRole) == None:
494  pass
495  else:
496  path = self.get_item_path(item)
497  if item.checkState(column) == Qt.Checked:
498  if path not in self.plot_list:
499  self.plot_list.add(path)
500  self.parent().parent().parent().add_plot(path)
501  if item.checkState(column) == Qt.Unchecked:
502  if path in self.plot_list:
503  self.plot_list.remove(path)
504  self.parent().parent().parent().remove_plot(path)
def region_changed(self, start, end)
Definition: plot_view.py:307
def set_cursor(self, position)
Definition: plot_view.py:186
def __init__(self, msg_type, parent)
Definition: plot_view.py:354
def traverse(self, root, function)
Definition: plot_view.py:417
def __init__(self, timeline, parent, topic)
Definition: plot_view.py:115
def _add_msg_object(self, parent, path, name, obj, obj_type)
Definition: plot_view.py:423
def __init__(self, timeline, parent, topic)
Definition: plot_view.py:90
def handleChanged(self, item, column)
Definition: plot_view.py:492
def message_viewed(self, bag, msg_details)
Definition: plot_view.py:97


rqt_bag_plugins
Author(s): Aaron Blasdel, Tim Field
autogenerated on Fri Jun 7 2019 22:05:56