plot_view.py
Go to the documentation of this file.
00001 # Software License Agreement (BSD License)
00002 #
00003 # Copyright (c) 2014, Austin Hendrix, Stanford University
00004 # All rights reserved.
00005 #
00006 # Redistribution and use in source and binary forms, with or without
00007 # modification, are permitted provided that the following conditions
00008 # are met:
00009 #
00010 #  * Redistributions of source code must retain the above copyright
00011 #    notice, this list of conditions and the following disclaimer.
00012 #  * Redistributions in binary form must reproduce the above
00013 #    copyright notice, this list of conditions and the following
00014 #    disclaimer in the documentation and/or other materials provided
00015 #    with the distribution.
00016 #  * Neither the name of Willow Garage, Inc. nor the names of its
00017 #    contributors may be used to endorse or promote products derived
00018 #    from this software without specific prior written permission.
00019 #
00020 # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
00021 # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
00022 # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
00023 # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
00024 # COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
00025 # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
00026 # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
00027 # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
00028 # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
00029 # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
00030 # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
00031 # POSSIBILITY OF SUCH DAMAGE.
00032 
00033 # Design notes:
00034 #
00035 # The original rxbag plot widget displays the plot
00036 #
00037 # It has a settings menu which allows the user to add subplots
00038 #  and assign arbitrary fields to subplots. possibly many fields in a single
00039 #  subplot, or one field per subplot, or any othe rcombination
00040 #  in particular, it is possible to add the same field to multiple subplots
00041 # It doesn't appear able to add fields from different topics to the same plot
00042 #  Since rqt_plot can do this, and it's useful for our application, it's worth
00043 #  thinking about. If it makes the UI too cluttered, it may not be worth it
00044 #  If it isn't possible due to the architecture of rqt_bag, it may not be
00045 #  worth it
00046 #
00047 # Thoughts on new design:
00048 #  a plottable field is anything which is either numeric, or contains
00049 #  at least one plottable subfield (this is useful for enabling/disabling all
00050 #  of the fields of a complex type)
00051 #
00052 #  for simple messages, we could display a tree view of the message fields
00053 #  on top of the plot, along with the color for each plottable field. This gets
00054 #  unweildy for large messages, because they'll use up too much screen space
00055 #  displaying the topic list
00056 #
00057 #  It may be better to display the topic list for a plot as a dockable widget,
00058 #  and be able to dismiss it when it isn't actively in use, similar to the
00059 #  existing rxbag plot config window
00060 #
00061 #  The plot should be dockable and viewable. If it's a separate window, it
00062 #  doesn't make sense to make it align with the timeline. This could be done
00063 #  if someone wanted to implement a separate timeline view
00064 
00065 import os
00066 import math
00067 import codecs
00068 import threading
00069 import rospkg
00070 from rqt_bag import MessageView
00071 
00072 from python_qt_binding import loadUi
00073 from python_qt_binding.QtCore import Qt, qWarning, Signal
00074 from python_qt_binding.QtGui import QDoubleValidator, QIcon
00075 from python_qt_binding.QtWidgets import QWidget, QPushButton, QTreeWidget, QTreeWidgetItem, QSizePolicy
00076 
00077 from rqt_plot.data_plot import DataPlot
00078 
00079 # rospy used for Time and Duration objects, for interacting with rosbag
00080 import rospy
00081 
00082 
00083 class PlotView(MessageView):
00084 
00085     """
00086     Popup plot viewer
00087     """
00088     name = 'Plot'
00089 
00090     def __init__(self, timeline, parent, topic):
00091         super(PlotView, self).__init__(timeline, topic)
00092 
00093         self.plot_widget = PlotWidget(timeline, parent, topic)
00094 
00095         parent.layout().addWidget(self.plot_widget)
00096 
00097     def message_viewed(self, bag, msg_details):
00098         """
00099         refreshes the plot
00100         """
00101         _, msg, t = msg_details[:3]
00102 
00103         if t is None:
00104             self.message_cleared()
00105         else:
00106             self.plot_widget.message_tree.set_message(msg)
00107             self.plot_widget.set_cursor((t - self.plot_widget.start_stamp).to_sec())
00108 
00109     def message_cleared(self):
00110         pass
00111 
00112 
00113 class PlotWidget(QWidget):
00114 
00115     def __init__(self, timeline, parent, topic):
00116         super(PlotWidget, self).__init__(parent)
00117         self.setObjectName('PlotWidget')
00118 
00119         self.timeline = timeline
00120         msg_type = self.timeline.get_datatype(topic)
00121         self.msgtopic = topic
00122         self.start_stamp = self.timeline._get_start_stamp()
00123         self.end_stamp = self.timeline._get_end_stamp()
00124 
00125         # the current region-of-interest for our bag file
00126         # all resampling and plotting is done with these limits
00127         self.limits = [0, (self.end_stamp - self.start_stamp).to_sec()]
00128 
00129         rp = rospkg.RosPack()
00130         ui_file = os.path.join(rp.get_path('rqt_bag_plugins'), 'resource', 'plot.ui')
00131         loadUi(ui_file, self)
00132         self.message_tree = MessageTree(msg_type, self)
00133         self.data_tree_layout.addWidget(self.message_tree)
00134         # TODO: make this a dropdown with choices for "Auto", "Full" and
00135         #       "Custom"
00136         #       I continue to want a "Full" option here
00137         self.auto_res.stateChanged.connect(self.autoChanged)
00138 
00139         self.resolution.editingFinished.connect(self.settingsChanged)
00140         self.resolution.setValidator(QDoubleValidator(0.0, 1000.0, 6, self.resolution))
00141 
00142         self.timeline.selected_region_changed.connect(self.region_changed)
00143 
00144         self.recompute_timestep()
00145 
00146         self.plot = DataPlot(self)
00147         self.plot.set_autoscale(x=False)
00148         self.plot.set_autoscale(y=DataPlot.SCALE_VISIBLE)
00149         self.plot.autoscroll(False)
00150         self.plot.set_xlim(self.limits)
00151         self.data_plot_layout.addWidget(self.plot)
00152 
00153         self._home_button = QPushButton()
00154         self._home_button.setToolTip("Reset View")
00155         self._home_button.setIcon(QIcon.fromTheme('go-home'))
00156         self._home_button.clicked.connect(self.home)
00157         self.plot_toolbar_layout.addWidget(self._home_button)
00158 
00159         self._config_button = QPushButton("Configure Plot")
00160         self._config_button.clicked.connect(self.plot.doSettingsDialog)
00161         self.plot_toolbar_layout.addWidget(self._config_button)
00162 
00163         self.set_cursor(0)
00164 
00165         self.paths_on = set()
00166         self._lines = None
00167 
00168         # get bag from timeline
00169         bag = None
00170         start_time = self.start_stamp
00171         while bag is None:
00172             bag, entry = self.timeline.get_entry(start_time, topic)
00173             if bag is None:
00174                 start_time = self.timeline.get_entry_after(start_time)[1].time
00175 
00176         self.bag = bag
00177         # get first message from bag
00178         msg = bag._read_message(entry.position)
00179         self.message_tree.set_message(msg[1])
00180 
00181         # state used by threaded resampling
00182         self.resampling_active = False
00183         self.resample_thread = None
00184         self.resample_fields = set()
00185 
00186     def set_cursor(self, position):
00187         self.plot.vline(position, color=DataPlot.RED)
00188         self.plot.redraw()
00189 
00190     def add_plot(self, path):
00191         self.resample_data([path])
00192 
00193     def update_plot(self):
00194         if len(self.paths_on) > 0:
00195             self.resample_data(self.paths_on)
00196 
00197     def remove_plot(self, path):
00198         self.plot.remove_curve(path)
00199         self.paths_on.remove(path)
00200         self.plot.redraw()
00201 
00202     def load_data(self):
00203         """get a generator for the specified time range on our bag"""
00204         return self.bag.read_messages(self.msgtopic,
00205                                       self.start_stamp + rospy.Duration.from_sec(self.limits[0]),
00206                                       self.start_stamp + rospy.Duration.from_sec(self.limits[1]))
00207 
00208     def resample_data(self, fields):
00209         if self.resample_thread:
00210             # cancel existing thread and join
00211             self.resampling_active = False
00212             self.resample_thread.join()
00213 
00214         for f in fields:
00215             self.resample_fields.add(f)
00216 
00217         # start resampling thread
00218         self.resampling_active = True
00219         self.resample_thread = threading.Thread(target=self._resample_thread)
00220         # explicitly mark our resampling thread as a daemon, because we don't
00221         # want to block program exit on a long resampling operation
00222         self.resample_thread.setDaemon(True)
00223         self.resample_thread.start()
00224 
00225     def _resample_thread(self):
00226         # TODO:
00227         # * look into doing partial display updates for long resampling
00228         #   operations
00229         # * add a progress bar for resampling operations
00230         x = {}
00231         y = {}
00232         for path in self.resample_fields:
00233             x[path] = []
00234             y[path] = []
00235 
00236         # bag object is not thread-safe; lock it while we resample
00237         with self.timeline._bag_lock:
00238             try:
00239                 msgdata = self.load_data()
00240             except ValueError:
00241                 # bag is closed or invalid; we're done here
00242                 self.resampling_active = False
00243                 return
00244 
00245             for entry in msgdata:
00246                 # detect if we're cancelled and return early
00247                 if not self.resampling_active:
00248                     return
00249 
00250                 for path in self.resample_fields:
00251                     # this resampling method is very unstable, because it picks
00252                     # representative points rather than explicitly representing
00253                     # the minimum and maximum values present within a sample
00254                     # If the data has spikes, this is particularly bad because they
00255                     # will be missed entirely at some resolutions and offsets
00256                     if x[path] == [] or (entry[2] - self.start_stamp).to_sec() - x[path][-1] >= self.timestep:
00257                         y_value = entry[1]
00258                         for field in path.split('.'):
00259                             index = None
00260                             if field.endswith(']'):
00261                                 field = field[:-1]
00262                                 field, _, index = field.rpartition('[')
00263                             y_value = getattr(y_value, field)
00264                             if index:
00265                                 index = int(index)
00266                                 y_value = y_value[index]
00267                         y[path].append(y_value)
00268                         x[path].append((entry[2] - self.start_stamp).to_sec())
00269 
00270                 # TODO: incremental plot updates would go here...
00271                 #       we should probably do incremental updates based on time;
00272                 #       that is, push new data to the plot maybe every .5 or .1
00273                 #       seconds
00274                 #       time is a more useful metric than, say, messages loaded or
00275                 #       percentage, because it will give a reasonable refresh rate
00276                 #       without overloading the computer
00277                 # if we had a progress bar, we could emit a signal to update it here
00278 
00279         # update the plot with final resampled data
00280         for path in self.resample_fields:
00281             if len(x[path]) < 1:
00282                 qWarning("Resampling resulted in 0 data points for %s" % path)
00283             else:
00284                 if path in self.paths_on:
00285                     self.plot.clear_values(path)
00286                     self.plot.update_values(path, x[path], y[path])
00287                 else:
00288                     self.plot.add_curve(path, path, x[path], y[path])
00289                     self.paths_on.add(path)
00290 
00291         self.plot.redraw()
00292 
00293         self.resample_fields.clear()
00294         self.resampling_active = False
00295 
00296     def recompute_timestep(self):
00297         # this is only called if we think the timestep has changed; either
00298         # by changing the limits or by editing the resolution
00299         limits = self.limits
00300         if self.auto_res.isChecked():
00301             timestep = round((limits[1] - limits[0]) / 200.0, 5)
00302         else:
00303             timestep = float(self.resolution.text())
00304         self.resolution.setText(str(timestep))
00305         self.timestep = timestep
00306 
00307     def region_changed(self, start, end):
00308         # this is the only place where self.limits is set
00309         limits = [(start - self.start_stamp).to_sec(),
00310                   (end - self.start_stamp).to_sec()]
00311 
00312         # cap the limits to the start and end of our bag file
00313         if limits[0] < 0:
00314             limits = [0.0, limits[1]]
00315         if limits[1] > (self.end_stamp - self.start_stamp).to_sec():
00316             limits = [limits[0], (self.end_stamp - self.start_stamp).to_sec()]
00317 
00318         self.limits = limits
00319 
00320         self.recompute_timestep()
00321         self.plot.set_xlim(limits)
00322         self.plot.redraw()
00323         self.update_plot()
00324 
00325     def settingsChanged(self):
00326         # resolution changed. recompute the timestep and resample
00327         self.recompute_timestep()
00328         self.update_plot()
00329 
00330     def autoChanged(self, state):
00331         if state == 2:
00332             # auto mode enabled. recompute the timestep and resample
00333             self.resolution.setDisabled(True)
00334             self.recompute_timestep()
00335             self.update_plot()
00336         else:
00337             # auto mode disabled. enable the resolution text box
00338             # no change to resolution yet, so no need to redraw
00339             self.resolution.setDisabled(False)
00340 
00341     def home(self):
00342         # TODO: re-add the button for this. It's useful for restoring the
00343         #       X and Y limits so that we can see all of the data
00344         #       effectively a "zoom all" button
00345 
00346         # reset the plot to our current limits
00347         self.plot.set_xlim(self.limits)
00348         # redraw the plot; this forces a Y autoscaling
00349         self.plot.redraw()
00350 
00351 
00352 class MessageTree(QTreeWidget):
00353 
00354     def __init__(self, msg_type, parent):
00355         super(MessageTree, self).__init__(parent)
00356         self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
00357         self.setHeaderHidden(True)
00358         self.itemChanged.connect(self.handleChanged)
00359         self._msg_type = msg_type
00360         self._msg = None
00361 
00362         self._expanded_paths = None
00363         self._checked_states = set()
00364         self.plot_list = set()
00365 
00366         # populate the tree from the message type
00367 
00368     @property
00369     def msg(self):
00370         return self._msg
00371 
00372     def set_message(self, msg):
00373         # Remember whether items were expanded or not before deleting
00374         if self._msg:
00375             for item in self.get_all_items():
00376                 path = self.get_item_path(item)
00377                 if item.isExpanded():
00378                     self._expanded_paths.add(path)
00379                 elif path in self._expanded_paths:
00380                     self._expanded_paths.remove(path)
00381                 if item.checkState(0) == Qt.Checked:
00382                     self._checked_states.add(path)
00383                 elif path in self._checked_states:
00384                     self._checked_states.remove(path)
00385             self.clear()
00386         if msg:
00387             # Populate the tree
00388             self._add_msg_object(None, '', '', msg, msg._type)
00389 
00390             if self._expanded_paths is None:
00391                 self._expanded_paths = set()
00392             else:
00393                 # Expand those that were previously expanded, and collapse any paths that
00394                 # we've seen for the first time
00395                 for item in self.get_all_items():
00396                     path = self.get_item_path(item)
00397                     if path in self._expanded_paths:
00398                         item.setExpanded(True)
00399                     else:
00400                         item.setExpanded(False)
00401         self._msg = msg
00402         self.update()
00403 
00404     def get_item_path(self, item):
00405         return item.data(0, Qt.UserRole)[0].replace(' ', '')  # remove spaces that may get introduced in indexing, e.g. [  3] is [3]
00406 
00407     def get_all_items(self):
00408         items = []
00409         try:
00410             root = self.invisibleRootItem()
00411             self.traverse(root, items.append)
00412         except Exception:
00413             # TODO: very large messages can cause a stack overflow due to recursion
00414             pass
00415         return items
00416 
00417     def traverse(self, root, function):
00418         for i in range(root.childCount()):
00419             child = root.child(i)
00420             function(child)
00421             self.traverse(child, function)
00422 
00423     def _add_msg_object(self, parent, path, name, obj, obj_type):
00424         label = name
00425 
00426         if hasattr(obj, '__slots__'):
00427             subobjs = [(slot, getattr(obj, slot)) for slot in obj.__slots__]
00428         elif type(obj) in [list, tuple]:
00429             len_obj = len(obj)
00430             if len_obj == 0:
00431                 subobjs = []
00432             else:
00433                 w = int(math.ceil(math.log10(len_obj)))
00434                 subobjs = [('[%*d]' % (w, i), subobj) for (i, subobj) in enumerate(obj)]
00435         else:
00436             subobjs = []
00437 
00438         plotitem = False
00439         if type(obj) in [int, long, float]:
00440             plotitem = True
00441             if type(obj) == float:
00442                 obj_repr = '%.6f' % obj
00443             else:
00444                 obj_repr = str(obj)
00445 
00446             if obj_repr[0] == '-':
00447                 label += ': %s' % obj_repr
00448             else:
00449                 label += ':  %s' % obj_repr
00450 
00451         elif type(obj) in [str, bool, int, long, float, complex, rospy.Time]:
00452             # Ignore any binary data
00453             obj_repr = codecs.utf_8_decode(str(obj), 'ignore')[0]
00454 
00455             # Truncate long representations
00456             if len(obj_repr) >= 50:
00457                 obj_repr = obj_repr[:50] + '...'
00458 
00459             label += ': ' + obj_repr
00460         item = QTreeWidgetItem([label])
00461         if name == '':
00462             pass
00463         elif path.find('.') == -1 and path.find('[') == -1:
00464             self.addTopLevelItem(item)
00465         else:
00466             parent.addChild(item)
00467         if plotitem == True:
00468             if path.replace(' ', '') in self._checked_states:
00469                 item.setCheckState(0, Qt.Checked)
00470             else:
00471                 item.setCheckState(0, Qt.Unchecked)
00472         item.setData(0, Qt.UserRole, (path, obj_type))
00473 
00474         for subobj_name, subobj in subobjs:
00475             if subobj is None:
00476                 continue
00477 
00478             if path == '':
00479                 subpath = subobj_name  # root field
00480             elif subobj_name.startswith('['):
00481                 subpath = '%s%s' % (path, subobj_name)  # list, dict, or tuple
00482             else:
00483                 subpath = '%s.%s' % (path, subobj_name)  # attribute (prefix with '.')
00484 
00485             if hasattr(subobj, '_type'):
00486                 subobj_type = subobj._type
00487             else:
00488                 subobj_type = type(subobj).__name__
00489 
00490             self._add_msg_object(item, subpath, subobj_name, subobj, subobj_type)
00491 
00492     def handleChanged(self, item, column):
00493         if item.data(0, Qt.UserRole) == None:
00494             pass
00495         else:
00496             path = self.get_item_path(item)
00497             if item.checkState(column) == Qt.Checked:
00498                 if path not in self.plot_list:
00499                     self.plot_list.add(path)
00500                     self.parent().parent().parent().add_plot(path)
00501             if item.checkState(column) == Qt.Unchecked:
00502                 if path in self.plot_list:
00503                     self.plot_list.remove(path)
00504                     self.parent().parent().parent().remove_plot(path)


rqt_bag_plugins
Author(s): Aaron Blasdel, Tim Field
autogenerated on Thu Jun 6 2019 18:52:53