| 
 | 1 | +"""  | 
 | 2 | +This is a developer utility to help analyze and triage image  | 
 | 3 | +comparison failures.  | 
 | 4 | +
  | 
 | 5 | +It allows the failures to be quickly compared against the expected  | 
 | 6 | +results, and the new results to be either accepted (by copying the new  | 
 | 7 | +results to the source tree) or rejected (by copying the original  | 
 | 8 | +expected result to the source tree).  | 
 | 9 | +
  | 
 | 10 | +To start:  | 
 | 11 | +
  | 
 | 12 | +    If you ran the tests from the top-level of a source checkout, simply run:  | 
 | 13 | +
  | 
 | 14 | +        python tools/test_triage.py  | 
 | 15 | +
  | 
 | 16 | +    Otherwise, you can manually select the location of `result_images`  | 
 | 17 | +    on the commandline.  | 
 | 18 | +
  | 
 | 19 | +Keys:  | 
 | 20 | +
  | 
 | 21 | +    left/right: Move between test, expected and diff images  | 
 | 22 | +    up/down:    Move between tests  | 
 | 23 | +    A:          Accept test.  Copy the test result to the source tree.  | 
 | 24 | +    R:          Reject test.  Copy the expected result to the source tree.  | 
 | 25 | +"""  | 
 | 26 | + | 
 | 27 | +import os  | 
 | 28 | +import shutil  | 
 | 29 | +import sys  | 
 | 30 | + | 
 | 31 | +from PyQt4 import QtCore, QtGui  | 
 | 32 | + | 
 | 33 | + | 
 | 34 | +# matplotlib stores the baseline images under two separate subtrees,  | 
 | 35 | +# but these are all flattened in the result_images directory.  In  | 
 | 36 | +# order to find the source, we need to search for a match in one of  | 
 | 37 | +# these two places.  | 
 | 38 | + | 
 | 39 | +BASELINE_IMAGES = [  | 
 | 40 | +    os.path.join('lib', 'matplotlib', 'tests', 'baseline_images'),  | 
 | 41 | +    os.path.join('lib', 'mpl_toolkits', 'tests', 'baseline_images')  | 
 | 42 | +    ]  | 
 | 43 | + | 
 | 44 | + | 
 | 45 | +# Non-png image extensions  | 
 | 46 | + | 
 | 47 | +exts = ['pdf', 'svg']  | 
 | 48 | + | 
 | 49 | + | 
 | 50 | +class Thumbnail(QtGui.QFrame):  | 
 | 51 | +    """  | 
 | 52 | +    Represents one of the three thumbnails at the top of the window.  | 
 | 53 | +    """  | 
 | 54 | +    def __init__(self, parent, index, name):  | 
 | 55 | +        super(Thumbnail, self).__init__()  | 
 | 56 | + | 
 | 57 | +        self.parent = parent  | 
 | 58 | +        self.index = index  | 
 | 59 | + | 
 | 60 | +        layout = QtGui.QVBoxLayout()  | 
 | 61 | + | 
 | 62 | +        label = QtGui.QLabel(name)  | 
 | 63 | +        label.setAlignment(QtCore.Qt.AlignHCenter |  | 
 | 64 | +                           QtCore.Qt.AlignVCenter)  | 
 | 65 | +        layout.addWidget(label, 0)  | 
 | 66 | + | 
 | 67 | +        self.image = QtGui.QLabel()  | 
 | 68 | +        self.image.setAlignment(QtCore.Qt.AlignHCenter |  | 
 | 69 | +                                QtCore.Qt.AlignVCenter)  | 
 | 70 | +        self.image.setMinimumSize(800/3, 600/3)  | 
 | 71 | +        layout.addWidget(self.image)  | 
 | 72 | +        self.setLayout(layout)  | 
 | 73 | + | 
 | 74 | +    def mousePressEvent(self, ev):  | 
 | 75 | +        self.parent.set_large_image(self.index)  | 
 | 76 | + | 
 | 77 | + | 
 | 78 | +class ListWidget(QtGui.QListWidget):  | 
 | 79 | +    """  | 
 | 80 | +    The list of files on the left-hand side  | 
 | 81 | +    """  | 
 | 82 | +    def __init__(self, parent):  | 
 | 83 | +        super(ListWidget, self).__init__()  | 
 | 84 | +        self.parent = parent  | 
 | 85 | +        self.currentRowChanged.connect(self.change_row)  | 
 | 86 | + | 
 | 87 | +    def change_row(self, i):  | 
 | 88 | +        self.parent.set_entry(i)  | 
 | 89 | + | 
 | 90 | + | 
 | 91 | +class EventFilter(QtCore.QObject):  | 
 | 92 | +    # A hack keypresses can be handled globally and aren't swallowed  | 
 | 93 | +    # by the individual widgets  | 
 | 94 | + | 
 | 95 | +    def __init__(self, window):  | 
 | 96 | +        super(EventFilter, self).__init__()  | 
 | 97 | +        self.window = window  | 
 | 98 | + | 
 | 99 | +    def eventFilter(self, receiver, event):  | 
 | 100 | +        if event.type() == QtCore.QEvent.KeyPress:  | 
 | 101 | +            self.window.keyPressEvent(event)  | 
 | 102 | +            return True  | 
 | 103 | +        else:  | 
 | 104 | +            return False  | 
 | 105 | +            return super(EventFilter, self).eventFilter(receiver, event)  | 
 | 106 | + | 
 | 107 | + | 
 | 108 | +class Dialog(QtGui.QDialog):  | 
 | 109 | +    """  | 
 | 110 | +    The main dialog window.  | 
 | 111 | +    """  | 
 | 112 | +    def __init__(self, entries):  | 
 | 113 | +        super(Dialog, self).__init__()  | 
 | 114 | + | 
 | 115 | +        self.entries = entries  | 
 | 116 | +        self.current_entry = -1  | 
 | 117 | +        self.current_thumbnail = -1  | 
 | 118 | + | 
 | 119 | +        event_filter = EventFilter(self)  | 
 | 120 | +        self.installEventFilter(event_filter)  | 
 | 121 | + | 
 | 122 | +        self.filelist = ListWidget(self)  | 
 | 123 | +        self.filelist.setMinimumWidth(400)  | 
 | 124 | +        for entry in entries:  | 
 | 125 | +            self.filelist.addItem(entry.display)  | 
 | 126 | + | 
 | 127 | +        images_box = QtGui.QWidget()  | 
 | 128 | +        images_layout = QtGui.QVBoxLayout()  | 
 | 129 | +        thumbnails_box = QtGui.QWidget()  | 
 | 130 | +        thumbnails_layout = QtGui.QHBoxLayout()  | 
 | 131 | +        self.thumbnails = []  | 
 | 132 | +        for i, name in enumerate(('test', 'expected', 'diff')):  | 
 | 133 | +            thumbnail = Thumbnail(self, i, name)  | 
 | 134 | +            thumbnails_layout.addWidget(thumbnail)  | 
 | 135 | +            self.thumbnails.append(thumbnail)  | 
 | 136 | +        thumbnails_box.setLayout(thumbnails_layout)  | 
 | 137 | +        self.image_display = QtGui.QLabel()  | 
 | 138 | +        self.image_display.setAlignment(QtCore.Qt.AlignHCenter |  | 
 | 139 | +                                        QtCore.Qt.AlignVCenter)  | 
 | 140 | +        self.image_display.setMinimumSize(800, 600)  | 
 | 141 | +        images_layout.addWidget(thumbnails_box, 3)  | 
 | 142 | +        images_layout.addWidget(self.image_display, 6)  | 
 | 143 | +        images_box.setLayout(images_layout)  | 
 | 144 | + | 
 | 145 | +        buttons_box = QtGui.QWidget()  | 
 | 146 | +        buttons_layout = QtGui.QHBoxLayout()  | 
 | 147 | +        accept_button = QtGui.QPushButton("Accept (A)")  | 
 | 148 | +        accept_button.clicked.connect(self.accept_test)  | 
 | 149 | +        buttons_layout.addWidget(accept_button)  | 
 | 150 | +        reject_button = QtGui.QPushButton("Reject (R)")  | 
 | 151 | +        reject_button.clicked.connect(self.reject_test)  | 
 | 152 | +        buttons_layout.addWidget(reject_button)  | 
 | 153 | +        buttons_box.setLayout(buttons_layout)  | 
 | 154 | +        images_layout.addWidget(buttons_box)  | 
 | 155 | + | 
 | 156 | +        main_layout = QtGui.QHBoxLayout()  | 
 | 157 | +        main_layout.addWidget(self.filelist, 3)  | 
 | 158 | +        main_layout.addWidget(images_box, 6)  | 
 | 159 | + | 
 | 160 | +        self.setLayout(main_layout)  | 
 | 161 | + | 
 | 162 | +        self.setWindowTitle("matplotlib test triager")  | 
 | 163 | + | 
 | 164 | +        self.set_entry(0)  | 
 | 165 | + | 
 | 166 | +    def set_entry(self, index):  | 
 | 167 | +        if self.current_entry == index:  | 
 | 168 | +            return  | 
 | 169 | + | 
 | 170 | +        self.current_entry = index  | 
 | 171 | +        entry = self.entries[index]  | 
 | 172 | + | 
 | 173 | +        self.pixmaps = []  | 
 | 174 | +        for fname, thumbnail in zip(entry.thumbnails, self.thumbnails):  | 
 | 175 | +            pixmap = QtGui.QPixmap(fname)  | 
 | 176 | +            scaled_pixmap = pixmap.scaled(  | 
 | 177 | +                thumbnail.size(), QtCore.Qt.KeepAspectRatio,  | 
 | 178 | +                QtCore.Qt.SmoothTransformation)  | 
 | 179 | +            thumbnail.image.setPixmap(scaled_pixmap)  | 
 | 180 | +            self.pixmaps.append(scaled_pixmap)  | 
 | 181 | + | 
 | 182 | +        self.set_large_image(0)  | 
 | 183 | +        self.filelist.setCurrentRow(self.current_entry)  | 
 | 184 | + | 
 | 185 | +    def set_large_image(self, index):  | 
 | 186 | +        self.thumbnails[self.current_thumbnail].setFrameShape(0)  | 
 | 187 | +        self.current_thumbnail = index  | 
 | 188 | +        pixmap = QtGui.QPixmap(  | 
 | 189 | +            self.entries[self.current_entry].thumbnails[self.current_thumbnail])  | 
 | 190 | +        self.image_display.setPixmap(pixmap)  | 
 | 191 | +        self.thumbnails[self.current_thumbnail].setFrameShape(1)  | 
 | 192 | + | 
 | 193 | +    def accept_test(self):  | 
 | 194 | +        self.entries[self.current_entry].accept()  | 
 | 195 | +        self.filelist.currentItem().setText(  | 
 | 196 | +            self.entries[self.current_entry].display)  | 
 | 197 | +        # Auto-move to the next entry  | 
 | 198 | +        self.set_entry(min((self.current_entry + 1), len(self.entries) - 1))  | 
 | 199 | + | 
 | 200 | +    def reject_test(self):  | 
 | 201 | +        self.entries[self.current_entry].reject()  | 
 | 202 | +        self.filelist.currentItem().setText(  | 
 | 203 | +            self.entries[self.current_entry].display)  | 
 | 204 | +        # Auto-move to the next entry  | 
 | 205 | +        self.set_entry(min((self.current_entry + 1), len(self.entries) - 1))  | 
 | 206 | + | 
 | 207 | +    def keyPressEvent(self, e):  | 
 | 208 | +        if e.key() == QtCore.Qt.Key_Left:  | 
 | 209 | +            self.set_large_image((self.current_thumbnail - 1) % 3)  | 
 | 210 | +        elif e.key() == QtCore.Qt.Key_Right:  | 
 | 211 | +            self.set_large_image((self.current_thumbnail + 1) % 3)  | 
 | 212 | +        elif e.key() == QtCore.Qt.Key_Up:  | 
 | 213 | +            self.set_entry(max((self.current_entry - 1), 0))  | 
 | 214 | +        elif e.key() == QtCore.Qt.Key_Down:  | 
 | 215 | +            self.set_entry(min((self.current_entry + 1), len(self.entries) - 1))  | 
 | 216 | +        elif e.key() == QtCore.Qt.Key_A:  | 
 | 217 | +            self.accept_test()  | 
 | 218 | +        elif e.key() == QtCore.Qt.Key_R:  | 
 | 219 | +            self.reject_test()  | 
 | 220 | +        else:  | 
 | 221 | +            super(Dialog, self).keyPressEvent(e)  | 
 | 222 | + | 
 | 223 | + | 
 | 224 | +class Entry(object):  | 
 | 225 | +    """  | 
 | 226 | +    A model for a single image comparison test.  | 
 | 227 | +    """  | 
 | 228 | +    def __init__(self, path, root, source):  | 
 | 229 | +        self.source = source  | 
 | 230 | +        self.root = root  | 
 | 231 | +        self.dir, fname = os.path.split(path)  | 
 | 232 | +        self.reldir = os.path.relpath(self.dir, self.root)  | 
 | 233 | +        self.diff = fname  | 
 | 234 | + | 
 | 235 | +        basename = fname[:-len('-failed-diff.png')]  | 
 | 236 | +        for ext in exts:  | 
 | 237 | +            if basename.endswith('_' + ext):  | 
 | 238 | +                display_extension = '_' + ext  | 
 | 239 | +                extension = ext  | 
 | 240 | +                basename = basename[:-4]  | 
 | 241 | +                break  | 
 | 242 | +        else:  | 
 | 243 | +            display_extension = ''  | 
 | 244 | +            extension = 'png'  | 
 | 245 | + | 
 | 246 | +        self.basename = basename  | 
 | 247 | +        self.extension = extension  | 
 | 248 | +        self.generated = basename + '.' + extension  | 
 | 249 | +        self.expected = basename + '-expected.' + extension  | 
 | 250 | +        self.expected_display = basename + '-expected' + display_extension + '.png'  | 
 | 251 | +        self.generated_display = basename + display_extension + '.png'  | 
 | 252 | +        self.name = os.path.join(self.reldir, self.basename)  | 
 | 253 | +        self.destdir = self.get_dest_dir(self.reldir)  | 
 | 254 | + | 
 | 255 | +        self.thumbnails = [  | 
 | 256 | +            self.generated_display,  | 
 | 257 | +            self.expected_display,  | 
 | 258 | +            self.diff  | 
 | 259 | +            ]  | 
 | 260 | +        self.thumbnails = [os.path.join(self.dir, x) for x in self.thumbnails]  | 
 | 261 | + | 
 | 262 | +        self.status = 'unknown'  | 
 | 263 | + | 
 | 264 | +        if self.same(os.path.join(self.dir, self.generated),  | 
 | 265 | +                     os.path.join(self.destdir, self.generated)):  | 
 | 266 | +            self.status = 'accept'  | 
 | 267 | + | 
 | 268 | +    def same(self, a, b):  | 
 | 269 | +        """  | 
 | 270 | +        Returns True if two files have the same content.  | 
 | 271 | +        """  | 
 | 272 | +        with open(a, 'rb') as fd:  | 
 | 273 | +            a_content = fd.read()  | 
 | 274 | +        with open(b, 'rb') as fd:  | 
 | 275 | +            b_content = fd.read()  | 
 | 276 | +        return a_content == b_content  | 
 | 277 | + | 
 | 278 | +    def copy_file(self, a, b):  | 
 | 279 | +        """  | 
 | 280 | +        Copy file from a to b.  | 
 | 281 | +        """  | 
 | 282 | +        print("copying: {} to {}".format(a, b))  | 
 | 283 | +        shutil.copyfile(a, b)  | 
 | 284 | + | 
 | 285 | +    def get_dest_dir(self, reldir):  | 
 | 286 | +        """  | 
 | 287 | +        Find the source tree directory corresponding to the given  | 
 | 288 | +        result_images subdirectory.  | 
 | 289 | +        """  | 
 | 290 | +        for baseline_dir in BASELINE_IMAGES:  | 
 | 291 | +            path = os.path.join(self.source, baseline_dir, reldir)  | 
 | 292 | +            if os.path.isdir(path):  | 
 | 293 | +                return path  | 
 | 294 | +        raise ValueError("Can't find baseline dir for {}".format(reldir))  | 
 | 295 | + | 
 | 296 | +    @property  | 
 | 297 | +    def display(self):  | 
 | 298 | +        """  | 
 | 299 | +        Get the display string for this entry.  This is the text that  | 
 | 300 | +        appears in the list widget.  | 
 | 301 | +        """  | 
 | 302 | +        status_map = {  | 
 | 303 | +            'unknown': '\u2610',  | 
 | 304 | +            'accept':  '\u2611',  | 
 | 305 | +            'reject':  '\u2612'  | 
 | 306 | +            }  | 
 | 307 | +        box = status_map[self.status]  | 
 | 308 | +        return '{} {} [{}]'.format(  | 
 | 309 | +            box, self.name, self.extension)  | 
 | 310 | + | 
 | 311 | +    def accept(self):  | 
 | 312 | +        """  | 
 | 313 | +        Accept this test by copying the generated result to the  | 
 | 314 | +        source tree.  | 
 | 315 | +        """  | 
 | 316 | +        a = os.path.join(self.dir, self.generated)  | 
 | 317 | +        b = os.path.join(self.destdir, self.generated)  | 
 | 318 | +        self.copy_file(a, b)  | 
 | 319 | +        self.status = 'accept'  | 
 | 320 | + | 
 | 321 | +    def reject(self):  | 
 | 322 | +        """  | 
 | 323 | +        Reject this test by copying the expected result to the  | 
 | 324 | +        source tree.  | 
 | 325 | +        """  | 
 | 326 | +        a = os.path.join(self.dir, self.expected)  | 
 | 327 | +        b = os.path.join(self.destdir, self.generated)  | 
 | 328 | +        self.copy_file(a, b)  | 
 | 329 | +        self.status = 'reject'  | 
 | 330 | + | 
 | 331 | + | 
 | 332 | +def find_failing_tests(result_images, source):  | 
 | 333 | +    """  | 
 | 334 | +    Find all of the failing tests by looking for files with  | 
 | 335 | +    `-failed-diff` at the end of the basename.  | 
 | 336 | +    """  | 
 | 337 | +    entries = []  | 
 | 338 | +    for root, dirs, files in os.walk(result_images):  | 
 | 339 | +        for fname in files:  | 
 | 340 | +            basename, ext = os.path.splitext(fname)  | 
 | 341 | +            if basename.endswith('-failed-diff'):  | 
 | 342 | +                path = os.path.join(root, fname)  | 
 | 343 | +                entry = Entry(path, result_images, source)  | 
 | 344 | +                entries.append(entry)  | 
 | 345 | +    entries.sort(key=lambda x: x.name)  | 
 | 346 | +    return entries  | 
 | 347 | + | 
 | 348 | + | 
 | 349 | +def launch(result_images, source):  | 
 | 350 | +    """  | 
 | 351 | +    Launch the GUI.  | 
 | 352 | +    """  | 
 | 353 | +    entries = find_failing_tests(result_images, source)  | 
 | 354 | + | 
 | 355 | +    if len(entries) == 0:  | 
 | 356 | +        print("No failed tests")  | 
 | 357 | +        sys.exit(0)  | 
 | 358 | + | 
 | 359 | +    app = QtGui.QApplication(sys.argv)  | 
 | 360 | +    dialog = Dialog(entries)  | 
 | 361 | +    dialog.show()  | 
 | 362 | +    filter = EventFilter(dialog)  | 
 | 363 | +    app.installEventFilter(filter)  | 
 | 364 | +    sys.exit(app.exec_())  | 
 | 365 | + | 
 | 366 | + | 
 | 367 | +if __name__ == '__main__':  | 
 | 368 | +    import argparse  | 
 | 369 | + | 
 | 370 | +    source_dir = os.path.join(os.path.dirname(__file__), '..')  | 
 | 371 | + | 
 | 372 | +    parser = argparse.ArgumentParser(  | 
 | 373 | +        formatter_class=argparse.RawDescriptionHelpFormatter,  | 
 | 374 | +        description="""  | 
 | 375 | +Triage image comparison test failures.  | 
 | 376 | +
  | 
 | 377 | +If no arguments are provided, it assumes you ran the tests at the  | 
 | 378 | +top-level of a source checkout as `python tests.py`.  | 
 | 379 | +
  | 
 | 380 | +Keys:  | 
 | 381 | +    left/right: Move between test, expected and diff images  | 
 | 382 | +    up/down:    Move between tests  | 
 | 383 | +    A:          Accept test.  Copy the test result to the source tree.  | 
 | 384 | +    R:          Reject test.  Copy the expected result to the source tree.  | 
 | 385 | +""")  | 
 | 386 | +    parser.add_argument("result_images", type=str, nargs='?',  | 
 | 387 | +                        default=os.path.join(source_dir, 'result_images'),  | 
 | 388 | +                        help="The location of the result_images directory")  | 
 | 389 | +    parser.add_argument("source", type=str, nargs='?', default=source_dir,  | 
 | 390 | +                        help="The location of the matplotlib source tree")  | 
 | 391 | +    args = parser.parse_args()  | 
 | 392 | + | 
 | 393 | +    launch(args.result_images, args.source)  | 
0 commit comments