Skip to content

Commit df3a8fe

Browse files
authored
Merge pull request #194 from astrofrog/vector-comparison
Added support for EPS, PDF, and SVG image comparison
2 parents dfe07f9 + 70b5d55 commit df3a8fe

15 files changed

+1442
-34
lines changed

Diff for: .github/workflows/test_and_publish.yml

+4
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ jobs:
1717
test:
1818
uses: OpenAstronomy/github-actions-workflows/.github/workflows/tox.yml@v1
1919
with:
20+
libraries: |
21+
apt:
22+
- ghostscript
23+
- inkscape
2024
envs: |
2125
# Test the oldest and newest configuration on Mac and Windows
2226
- macos: py36-test-mpl20

Diff for: README.rst

+75-1
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,6 @@ When generating a hash library, the tests will also be run as usual against the
9090
existing hash library specified by ``--mpl-hash-library`` or the keyword argument.
9191
However, generating baseline images will always result in the tests being skipped.
9292

93-
9493
Hybrid Mode: Hashes and Images
9594
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
9695

@@ -278,6 +277,81 @@ decorator:
278277
This will make the test insensitive to changes in e.g. the freetype
279278
library.
280279

280+
Supported formats and deterministic output
281+
------------------------------------------
282+
283+
By default, pytest-mpl will save and compare figures in PNG format. However,
284+
it is possible to set the format to use by setting e.g. ``savefig_kwargs={'format': 'pdf'}``
285+
in ``mpl_image_compare``. Supported formats are ``'eps'``, ``'pdf'``, ``'png'``, and ``'svg'``.
286+
Note that Ghostscript is required to be installed for comparing PDF and EPS figures, while
287+
Inkscape is required for SVG comparison.
288+
289+
By default, Matplotlib does not produce deterministic output that will have a
290+
consistent hash every time it is run, or over different Matplotlib versions. In
291+
order to enforce that the output is deterministic, you will need to set metadata
292+
as described in the following subsections.
293+
294+
PNG
295+
^^^
296+
297+
For PNG files, the output can be made deterministic by setting:
298+
299+
.. code:: python
300+
301+
@pytest.mark.mpl_image_compare(savefig_kwargs={'metadata': {"Software": None}})
302+
303+
PDF
304+
^^^
305+
306+
For PDF files, the output can be made deterministic by setting:
307+
308+
.. code:: python
309+
310+
@pytest.mark.mpl_image_compare(savefig_kwargs={'format': 'pdf',
311+
'metadata': {"Creator": None,
312+
"Producer": None,
313+
"CreationDate": None}})
314+
315+
Note that deterministic PDF output can only be achieved with Matplotlib 2.1 and above
316+
317+
EPS
318+
^^^
319+
320+
For PDF files, the output can be made deterministic by setting:
321+
322+
.. code:: python
323+
324+
@pytest.mark.mpl_image_compare(savefig_kwargs={'format': 'pdf',
325+
'metadata': {"Creator": "test"})
326+
327+
and in addition you will need to set the SOURCE_DATE_EPOCH environment variable to
328+
a constant value (this is a unit timestamp):
329+
330+
.. code:: python
331+
332+
os.environ['SOURCE_DATE_EPOCH'] = '1680254601'
333+
334+
You could do this inside the test.
335+
336+
Note that deterministic PDF output can only be achieved with Matplotlib 2.1 and above
337+
338+
SVG
339+
^^^
340+
341+
For SVG files, the output can be made deterministic by setting:
342+
343+
.. code:: python
344+
345+
@pytest.mark.mpl_image_compare(savefig_kwargs={'metadata': '{"Date": None}})
346+
347+
and in addition, you should make sure the following rcParam is set to a constant string:
348+
349+
.. code:: python
350+
351+
plt.rcParams['svg.hashsalt'] = 'test'
352+
353+
Note that SVG files can only be used in pytest-mpl with Matplotlib 3.3 and above.
354+
281355
Test failure example
282356
--------------------
283357

Diff for: pytest_mpl/plugin.py

+54-26
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@
5252
Actual shape: {actual_shape}
5353
{actual_path}"""
5454

55+
# The following are the subsets of formats supported by the Matplotlib image
56+
# comparison machinery
57+
RASTER_IMAGE_FORMATS = ['png']
58+
VECTOR_IMAGE_FORMATS = ['eps', 'pdf', 'svg']
59+
ALL_IMAGE_FORMATS = RASTER_IMAGE_FORMATS + VECTOR_IMAGE_FORMATS
60+
5561

5662
def _hash_file(in_stream):
5763
"""
@@ -70,8 +76,8 @@ def pathify(path):
7076
"""
7177
path = Path(path)
7278
ext = ''
73-
if path.suffixes[-1] == '.png':
74-
ext = '.png'
79+
if path.suffixes[-1][1:] in ALL_IMAGE_FORMATS:
80+
ext = path.suffixes[-1]
7581
path = str(path).split(ext)[0]
7682
path = str(path)
7783
path = path.replace('[', '_').replace(']', '_')
@@ -315,18 +321,24 @@ def __init__(self,
315321
self.logger.setLevel(level)
316322
self.logger.addHandler(handler)
317323

324+
def _file_extension(self, item):
325+
compare = get_compare(item)
326+
savefig_kwargs = compare.kwargs.get('savefig_kwargs', {})
327+
return savefig_kwargs.get('format', 'png')
328+
318329
def generate_filename(self, item):
319330
"""
320331
Given a pytest item, generate the figure filename.
321332
"""
333+
ext = self._file_extension(item)
322334
if self.config.getini('mpl-use-full-test-name'):
323-
filename = generate_test_name(item) + '.png'
335+
filename = generate_test_name(item) + f'.{ext}'
324336
else:
325337
compare = get_compare(item)
326338
# Find test name to use as plot name
327339
filename = compare.kwargs.get('filename', None)
328340
if filename is None:
329-
filename = item.name + '.png'
341+
filename = item.name + f'.{ext}'
330342

331343
filename = str(pathify(filename))
332344
return filename
@@ -441,10 +453,10 @@ def generate_image_hash(self, item, fig):
441453
compare = get_compare(item)
442454
savefig_kwargs = compare.kwargs.get('savefig_kwargs', {})
443455

444-
imgdata = io.BytesIO()
456+
ext = self._file_extension(item)
445457

458+
imgdata = io.BytesIO()
446459
fig.savefig(imgdata, **savefig_kwargs)
447-
448460
out = _hash_file(imgdata)
449461
imgdata.close()
450462

@@ -465,11 +477,17 @@ def compare_image_to_baseline(self, item, fig, result_dir, summary=None):
465477
tolerance = compare.kwargs.get('tolerance', 2)
466478
savefig_kwargs = compare.kwargs.get('savefig_kwargs', {})
467479

480+
ext = self._file_extension(item)
481+
468482
baseline_image_ref = self.obtain_baseline_image(item, result_dir)
469483

470-
test_image = (result_dir / "result.png").absolute()
484+
test_image = (result_dir / f"result.{ext}").absolute()
471485
fig.savefig(str(test_image), **savefig_kwargs)
472-
summary['result_image'] = test_image.relative_to(self.results_dir).as_posix()
486+
487+
if ext in ['png', 'svg']: # Use original file
488+
summary['result_image'] = test_image.relative_to(self.results_dir).as_posix()
489+
else:
490+
summary['result_image'] = (result_dir / f"result_{ext}.png").relative_to(self.results_dir).as_posix()
473491

474492
if not os.path.exists(baseline_image_ref):
475493
summary['status'] = 'failed'
@@ -484,26 +502,33 @@ def compare_image_to_baseline(self, item, fig, result_dir, summary=None):
484502

485503
# setuptools may put the baseline images in non-accessible places,
486504
# copy to our tmpdir to be sure to keep them in case of failure
487-
baseline_image = (result_dir / "baseline.png").absolute()
505+
baseline_image = (result_dir / f"baseline.{ext}").absolute()
488506
shutil.copyfile(baseline_image_ref, baseline_image)
489-
summary['baseline_image'] = baseline_image.relative_to(self.results_dir).as_posix()
507+
508+
if ext in ['png', 'svg']: # Use original file
509+
summary['baseline_image'] = baseline_image.relative_to(self.results_dir).as_posix()
510+
else:
511+
summary['baseline_image'] = (result_dir / f"baseline_{ext}.png").relative_to(self.results_dir).as_posix()
490512

491513
# Compare image size ourselves since the Matplotlib
492514
# exception is a bit cryptic in this case and doesn't show
493-
# the filenames
494-
expected_shape = imread(str(baseline_image)).shape[:2]
495-
actual_shape = imread(str(test_image)).shape[:2]
496-
if expected_shape != actual_shape:
497-
summary['status'] = 'failed'
498-
summary['image_status'] = 'diff'
499-
error_message = SHAPE_MISMATCH_ERROR.format(expected_path=baseline_image,
500-
expected_shape=expected_shape,
501-
actual_path=test_image,
502-
actual_shape=actual_shape)
503-
summary['status_msg'] = error_message
504-
return error_message
515+
# the filenames. However imread won't work for vector graphics so we
516+
# only do this for raster files.
517+
if ext in RASTER_IMAGE_FORMATS:
518+
expected_shape = imread(str(baseline_image)).shape[:2]
519+
actual_shape = imread(str(test_image)).shape[:2]
520+
if expected_shape != actual_shape:
521+
summary['status'] = 'failed'
522+
summary['image_status'] = 'diff'
523+
error_message = SHAPE_MISMATCH_ERROR.format(expected_path=baseline_image,
524+
expected_shape=expected_shape,
525+
actual_path=test_image,
526+
actual_shape=actual_shape)
527+
summary['status_msg'] = error_message
528+
return error_message
505529

506530
results = compare_images(str(baseline_image), str(test_image), tol=tolerance, in_decorator=True)
531+
507532
summary['tolerance'] = tolerance
508533
if results is None:
509534
summary['status'] = 'passed'
@@ -514,8 +539,7 @@ def compare_image_to_baseline(self, item, fig, result_dir, summary=None):
514539
summary['status'] = 'failed'
515540
summary['image_status'] = 'diff'
516541
summary['rms'] = results['rms']
517-
diff_image = (result_dir / 'result-failed-diff.png').absolute()
518-
summary['diff_image'] = diff_image.relative_to(self.results_dir).as_posix()
542+
summary['diff_image'] = Path(results['diff']).relative_to(self.results_dir).as_posix()
519543
template = ['Error: Image files did not match.',
520544
'RMS Value: {rms}',
521545
'Expected: \n {expected}',
@@ -538,6 +562,8 @@ def compare_image_to_hash_library(self, item, fig, result_dir, summary=None):
538562
compare = get_compare(item)
539563
savefig_kwargs = compare.kwargs.get('savefig_kwargs', {})
540564

565+
ext = self._file_extension(item)
566+
541567
if not self.results_hash_library_name:
542568
# Use hash library name of current test as results hash library name
543569
self.results_hash_library_name = Path(compare.kwargs.get("hash_library", "")).name
@@ -574,7 +600,7 @@ def compare_image_to_hash_library(self, item, fig, result_dir, summary=None):
574600
f"{hash_library_filename} for test {hash_name}.")
575601

576602
# Save the figure for later summary (will be removed later if not needed)
577-
test_image = (result_dir / "result.png").absolute()
603+
test_image = (result_dir / f"result.{ext}").absolute()
578604
fig.savefig(str(test_image), **savefig_kwargs)
579605
summary['result_image'] = test_image.relative_to(self.results_dir).as_posix()
580606

@@ -627,6 +653,8 @@ def pytest_runtest_call(self, item): # noqa
627653
remove_text = compare.kwargs.get('remove_text', False)
628654
backend = compare.kwargs.get('backend', 'agg')
629655

656+
ext = self._file_extension(item)
657+
630658
with plt.style.context(style, after_reset=True), switch_backend(backend):
631659

632660
# Run test and get figure object
@@ -665,7 +693,7 @@ def pytest_runtest_call(self, item): # noqa
665693
summary['status_msg'] = 'Skipped test, since generating image.'
666694
generate_image = self.generate_baseline_image(item, fig)
667695
if self.results_always: # Make baseline image available in HTML
668-
result_image = (result_dir / "baseline.png").absolute()
696+
result_image = (result_dir / f"baseline.{ext}").absolute()
669697
shutil.copy(generate_image, result_image)
670698
summary['baseline_image'] = \
671699
result_image.relative_to(self.results_dir).as_posix()

0 commit comments

Comments
 (0)