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 class PlotView(MessageView):
00083     """
00084     Popup plot viewer
00085     """
00086     name = 'Plot'
00087 
00088     def __init__(self, timeline, parent, topic):
00089         super(PlotView, self).__init__(timeline, topic)
00090 
00091         self.plot_widget = PlotWidget(timeline, parent, topic)
00092 
00093         parent.layout().addWidget(self.plot_widget)
00094 
00095     def message_viewed(self, bag, msg_details):
00096         """
00097         refreshes the plot
00098         """
00099         _, msg, t = msg_details[:3]
00100 
00101         if t is None:
00102             self.message_cleared()
00103         else:
00104             self.plot_widget.message_tree.set_message(msg)
00105             self.plot_widget.set_cursor((t-self.plot_widget.start_stamp).to_sec())
00106 
00107     def message_cleared(self):
00108         pass
00109 
00110 class PlotWidget(QWidget):
00111 
00112     def __init__(self, timeline, parent, topic):
00113         super(PlotWidget, self).__init__(parent)
00114         self.setObjectName('PlotWidget')
00115 
00116         self.timeline = timeline
00117         msg_type = self.timeline.get_datatype(topic)
00118         self.msgtopic = topic
00119         self.start_stamp = self.timeline._get_start_stamp()
00120         self.end_stamp = self.timeline._get_end_stamp()
00121 
00122         # the current region-of-interest for our bag file
00123         # all resampling and plotting is done with these limits
00124         self.limits = [0,(self.end_stamp-self.start_stamp).to_sec()]
00125 
00126         rp = rospkg.RosPack()
00127         ui_file = os.path.join(rp.get_path('rqt_bag_plugins'), 'resource', 'plot.ui')
00128         loadUi(ui_file, self)
00129         self.message_tree = MessageTree(msg_type, self)
00130         self.data_tree_layout.addWidget(self.message_tree)
00131         # TODO: make this a dropdown with choices for "Auto", "Full" and
00132         #       "Custom"
00133         #       I continue to want a "Full" option here
00134         self.auto_res.stateChanged.connect(self.autoChanged)
00135 
00136         self.resolution.editingFinished.connect(self.settingsChanged)
00137         self.resolution.setValidator(QDoubleValidator(0.0,1000.0,6,self.resolution))
00138 
00139 
00140         self.timeline.selected_region_changed.connect(self.region_changed)
00141 
00142         self.recompute_timestep()
00143 
00144         self.plot = DataPlot(self)
00145         self.plot.set_autoscale(x=False)
00146         self.plot.set_autoscale(y=DataPlot.SCALE_VISIBLE)
00147         self.plot.autoscroll(False)
00148         self.plot.set_xlim(self.limits)
00149         self.data_plot_layout.addWidget(self.plot)
00150 
00151         self._home_button = QPushButton()
00152         self._home_button.setToolTip("Reset View")
00153         self._home_button.setIcon(QIcon.fromTheme('go-home'))
00154         self._home_button.clicked.connect(self.home)
00155         self.plot_toolbar_layout.addWidget(self._home_button)
00156 
00157         self._config_button = QPushButton("Configure Plot")
00158         self._config_button.clicked.connect(self.plot.doSettingsDialog)
00159         self.plot_toolbar_layout.addWidget(self._config_button)
00160 
00161         self.set_cursor(0)
00162 
00163         self.paths_on = set()
00164         self._lines = None
00165 
00166         # get bag from timeline
00167         bag = None
00168         start_time = self.start_stamp
00169         while bag is None:
00170             bag,entry = self.timeline.get_entry(start_time, topic)
00171             if bag is None:
00172                 start_time = self.timeline.get_entry_after(start_time)[1].time
00173 
00174         self.bag = bag
00175         # get first message from bag
00176         msg = bag._read_message(entry.position)
00177         self.message_tree.set_message(msg[1])
00178 
00179         # state used by threaded resampling
00180         self.resampling_active = False
00181         self.resample_thread = None
00182         self.resample_fields = set()
00183 
00184     def set_cursor(self, position):
00185         self.plot.vline(position, color=DataPlot.RED)
00186         self.plot.redraw()
00187 
00188     def add_plot(self, path):
00189         self.resample_data([path])
00190 
00191     def update_plot(self):
00192         if len(self.paths_on)>0:
00193             self.resample_data(self.paths_on)
00194 
00195     def remove_plot(self, path):
00196         self.plot.remove_curve(path)
00197         self.paths_on.remove(path)
00198         self.plot.redraw()
00199 
00200     def load_data(self):
00201         """get a generator for the specified time range on our bag"""
00202         return self.bag.read_messages(self.msgtopic,
00203                 self.start_stamp+rospy.Duration.from_sec(self.limits[0]),
00204                 self.start_stamp+rospy.Duration.from_sec(self.limits[1]))
00205 
00206     def resample_data(self, fields):
00207         if self.resample_thread:
00208             # cancel existing thread and join
00209             self.resampling_active = False
00210             self.resample_thread.join()
00211 
00212         for f in fields:
00213             self.resample_fields.add(f)
00214 
00215         # start resampling thread
00216         self.resampling_active = True
00217         self.resample_thread = threading.Thread(target=self._resample_thread)
00218         # explicitly mark our resampling thread as a daemon, because we don't
00219         # want to block program exit on a long resampling operation
00220         self.resample_thread.setDaemon(True)
00221         self.resample_thread.start()
00222 
00223     def _resample_thread(self):
00224         # TODO:
00225         # * look into doing partial display updates for long resampling 
00226         #   operations
00227         # * add a progress bar for resampling operations
00228         x = {}
00229         y = {}
00230         for path in self.resample_fields:
00231             x[path] = []
00232             y[path] = []
00233 
00234         # bag object is not thread-safe; lock it while we resample
00235         with self.timeline._bag_lock:
00236             try:
00237                 msgdata = self.load_data()
00238             except ValueError:
00239                 # bag is closed or invalid; we're done here
00240                 self.resampling_active = False
00241                 return
00242 
00243             for entry in msgdata:
00244                 # detect if we're cancelled and return early
00245                 if not self.resampling_active:
00246                     return
00247 
00248                 for path in self.resample_fields:
00249                     # this resampling method is very unstable, because it picks
00250                     # representative points rather than explicitly representing
00251                     # the minimum and maximum values present within a sample
00252                     # If the data has spikes, this is particularly bad because they
00253                     # will be missed entirely at some resolutions and offsets
00254                     if x[path]==[] or (entry[2]-self.start_stamp).to_sec()-x[path][-1] >= self.timestep:
00255                         y_value = entry[1]
00256                         for field in path.split('.'):
00257                             index = None
00258                             if field.endswith(']'):
00259                                 field = field[:-1]
00260                                 field, _, index = field.rpartition('[')
00261                             y_value = getattr(y_value, field)
00262                             if index:
00263                                 index = int(index)
00264                                 y_value = y_value[index]
00265                         y[path].append(y_value)
00266                         x[path].append((entry[2]-self.start_stamp).to_sec())
00267 
00268                 # TODO: incremental plot updates would go here...
00269                 #       we should probably do incremental updates based on time;
00270                 #       that is, push new data to the plot maybe every .5 or .1
00271                 #       seconds
00272                 #       time is a more useful metric than, say, messages loaded or
00273                 #       percentage, because it will give a reasonable refresh rate
00274                 #       without overloading the computer
00275                 # if we had a progress bar, we could emit a signal to update it here
00276 
00277         # update the plot with final resampled data
00278         for path in self.resample_fields:
00279             if len(x[path]) < 1:
00280                 qWarning("Resampling resulted in 0 data points for %s" % path)
00281             else:
00282                 if path in self.paths_on:
00283                     self.plot.clear_values(path)
00284                     self.plot.update_values(path, x[path], y[path])
00285                 else:
00286                     self.plot.add_curve(path, path, x[path], y[path])
00287                     self.paths_on.add(path)
00288 
00289         self.plot.redraw()
00290 
00291         self.resample_fields.clear()
00292         self.resampling_active = False
00293 
00294     def recompute_timestep(self):
00295         # this is only called if we think the timestep has changed; either
00296         # by changing the limits or by editing the resolution
00297         limits = self.limits
00298         if self.auto_res.isChecked():
00299             timestep = round((limits[1]-limits[0])/200.0,5)
00300         else:
00301             timestep = float(self.resolution.text())
00302         self.resolution.setText(str(timestep))
00303         self.timestep = timestep
00304 
00305     def region_changed(self, start, end):
00306         # this is the only place where self.limits is set
00307         limits = [ (start - self.start_stamp).to_sec(),
00308                    (end - self.start_stamp).to_sec() ]
00309 
00310         # cap the limits to the start and end of our bag file
00311         if limits[0]<0:
00312             limits = [0.0,limits[1]]
00313         if limits[1]>(self.end_stamp-self.start_stamp).to_sec():
00314             limits = [limits[0],(self.end_stamp-self.start_stamp).to_sec()]
00315 
00316         self.limits = limits
00317 
00318         self.recompute_timestep()
00319         self.plot.set_xlim(limits)
00320         self.plot.redraw()
00321         self.update_plot()
00322 
00323     def settingsChanged(self):
00324         # resolution changed. recompute the timestep and resample
00325         self.recompute_timestep()
00326         self.update_plot()
00327 
00328     def autoChanged(self, state):
00329         if state==2:
00330             # auto mode enabled. recompute the timestep and resample
00331             self.resolution.setDisabled(True) 
00332             self.recompute_timestep()
00333             self.update_plot()   
00334         else:
00335             # auto mode disabled. enable the resolution text box
00336             # no change to resolution yet, so no need to redraw
00337             self.resolution.setDisabled(False)
00338 
00339     def home(self):
00340         # TODO: re-add the button for this. It's useful for restoring the
00341         #       X and Y limits so that we can see all of the data
00342         #       effectively a "zoom all" button
00343 
00344         # reset the plot to our current limits
00345         self.plot.set_xlim(self.limits)
00346         # redraw the plot; this forces a Y autoscaling
00347         self.plot.redraw()
00348 
00349 
00350 
00351 class MessageTree(QTreeWidget):
00352     def __init__(self, msg_type, parent):
00353         super(MessageTree, self).__init__(parent)
00354         self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
00355         self.setHeaderHidden(True)
00356         self.itemChanged.connect(self.handleChanged)
00357         self._msg_type = msg_type
00358         self._msg = None
00359 
00360         self._expanded_paths = None
00361         self._checked_states = set()
00362         self.plot_list = set()
00363 
00364         # populate the tree from the message type
00365 
00366 
00367     @property
00368     def msg(self):
00369         return self._msg
00370 
00371     def set_message(self, msg):
00372         # Remember whether items were expanded or not before deleting
00373         if self._msg:
00374             for item in self.get_all_items():
00375                 path = self.get_item_path(item)
00376                 if item.isExpanded():
00377                     self._expanded_paths.add(path)
00378                 elif path in self._expanded_paths:
00379                     self._expanded_paths.remove(path)
00380                 if item.checkState(0)==Qt.Checked:
00381                     self._checked_states.add(path)
00382                 elif path in self._checked_states:
00383                     self._checked_states.remove(path)
00384             self.clear()
00385         if msg:
00386             # Populate the tree
00387             self._add_msg_object(None, '', '', msg, msg._type)
00388 
00389             if self._expanded_paths is None:
00390                 self._expanded_paths = set()
00391             else:
00392                 # Expand those that were previously expanded, and collapse any paths that we've seen for the first time
00393                 for item in self.get_all_items():
00394                     path = self.get_item_path(item)
00395                     if path in self._expanded_paths:
00396                         item.setExpanded(True)
00397                     else:
00398                         item.setExpanded(False)
00399         self._msg = msg
00400         self.update()
00401 
00402     def get_item_path(self, item):
00403         return item.data(0, Qt.UserRole)[0].replace(' ', '')  # remove spaces that may get introduced in indexing, e.g. [  3] is [3]
00404 
00405     def get_all_items(self):
00406         items = []
00407         try:
00408             root = self.invisibleRootItem()
00409             self.traverse(root, items.append)
00410         except Exception:
00411             # TODO: very large messages can cause a stack overflow due to recursion
00412             pass
00413         return items
00414 
00415     def traverse(self, root, function):
00416         for i in range(root.childCount()):
00417             child = root.child(i)
00418             function(child)
00419             self.traverse(child, function)
00420 
00421     def _add_msg_object(self, parent, path, name, obj, obj_type):
00422         label = name
00423 
00424         if hasattr(obj, '__slots__'):
00425             subobjs = [(slot, getattr(obj, slot)) for slot in obj.__slots__]
00426         elif type(obj) in [list, tuple]:
00427             len_obj = len(obj)
00428             if len_obj == 0:
00429                 subobjs = []
00430             else:
00431                 w = int(math.ceil(math.log10(len_obj)))
00432                 subobjs = [('[%*d]' % (w, i), subobj) for (i, subobj) in enumerate(obj)]
00433         else:
00434             subobjs = []
00435 
00436         plotitem=False
00437         if type(obj) in [int, long, float]:
00438             plotitem=True
00439             if type(obj) == float:
00440                 obj_repr = '%.6f' % obj
00441             else:
00442                 obj_repr = str(obj)
00443 
00444             if obj_repr[0] == '-':
00445                 label += ': %s' % obj_repr
00446             else:
00447                 label += ':  %s' % obj_repr
00448 
00449         elif type(obj) in [str, bool, int, long, float, complex, rospy.Time]:
00450             # Ignore any binary data
00451             obj_repr = codecs.utf_8_decode(str(obj), 'ignore')[0]
00452 
00453             # Truncate long representations
00454             if len(obj_repr) >= 50:
00455                 obj_repr = obj_repr[:50] + '...'
00456 
00457             label += ': ' + obj_repr
00458         item = QTreeWidgetItem([label])
00459         if name == '':
00460             pass
00461         elif path.find('.') == -1 and path.find('[') == -1:
00462             self.addTopLevelItem(item)
00463         else:
00464             parent.addChild(item)
00465         if plotitem == True:
00466             if path.replace(' ', '') in self._checked_states:
00467                 item.setCheckState (0, Qt.Checked)
00468             else:
00469                 item.setCheckState (0, Qt.Unchecked)
00470         item.setData(0, Qt.UserRole, (path, obj_type))
00471 
00472 
00473         for subobj_name, subobj in subobjs:
00474             if subobj is None:
00475                 continue
00476 
00477             if path == '':
00478                 subpath = subobj_name  # root field
00479             elif subobj_name.startswith('['):
00480                 subpath = '%s%s' % (path, subobj_name)  # list, dict, or tuple
00481             else:
00482                 subpath = '%s.%s' % (path, subobj_name)  # attribute (prefix with '.')
00483 
00484             if hasattr(subobj, '_type'):
00485                 subobj_type = subobj._type
00486             else:
00487                 subobj_type = type(subobj).__name__
00488 
00489             self._add_msg_object(item, subpath, subobj_name, subobj, subobj_type)
00490 
00491     def handleChanged(self, item, column):
00492         if item.data(0, Qt.UserRole)==None:
00493             pass
00494         else:
00495             path = self.get_item_path(item)
00496             if item.checkState(column) == Qt.Checked:
00497                 if path not in self.plot_list:
00498                     self.plot_list.add(path)
00499                     self.parent().parent().parent().add_plot(path)
00500             if item.checkState(column) == Qt.Unchecked:
00501                 if path in self.plot_list:
00502                     self.plot_list.remove(path)
00503                     self.parent().parent().parent().remove_plot(path)


rqt_bag_plugins
Author(s): Aaron Blasdel, Tim Field
autogenerated on Thu Aug 17 2017 02:19:34