Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 6062c2f

Browse files
jenshnielsenmdboom
authored andcommitted
Merge pull request #5521 from mdboom/test-triage-tool
Add test triage tool
1 parent 911f349 commit 6062c2f

File tree

1 file changed

+393
-0
lines changed

1 file changed

+393
-0
lines changed

tools/test_triage.py

Lines changed: 393 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,393 @@
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

Comments
 (0)