Skip to content

Issue(?): head size of FancyArrowPatch changes between interactive figure and picture export #6035

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
afvincent opened this issue Feb 20, 2016 · 10 comments

Comments

@afvincent
Copy link
Contributor

Issue (?)

When plotting a arrow (with FancyArrowPatch from matplotlib.patches), and exporting it to a picture, the size of its head is not consistent with what is displayed in the interactive figure. (The linewidth seems to remain OK between interactive and exported file, whatever format is used.)

As far as I understand, it seems to be DPI-related:

  • If the DPI value is the same between the interactive figure and the export made with savefig, then everything is fine.
  • However, if I want to export with DPI 3 times bigger than in the interactive figure (for example 300 DPI instead of 100 DPI), one workaround I found is to use a mutation_scale 3 times bigger than before when plotting the interactive figure. Unfortunately, it is neither really convenient nor satisfying as it affects what is displayed in the interactive figure (the arrow head is 3 times bigger...)

I don't know if it is a bug or the expected behavior, but I find it disturbing to have an export that is (that) different from the interactive display.

Environment infos

  • Linux, Python 2.7 (from Anaconda2), Matplotlib v1.5.0
  • Interactive backend is Qt4Agg

Example script

Here is a script that generates a figure with 2 arrows (FancyArrowPatch), and exports it to different formats:

import matplotlib.pyplot as plt
from matplotlib.patches import FancyArrowPatch
plt.ion() # My interactive backend is Qt4Agg, with default @ 80 DPI

""" Prepare and plot 2 FancyArrowPatch instances, the 2nd one with a 
    mutation_scale parameter 3 times bigger than the 1st one. 
"""
common_opts = dict(arrowstyle=u'->', lw=3)
arrow_patch_0 = FancyArrowPatch(posA=(0.2, 0.8), posB=(0.8, 0.65),
                                mutation_scale=50, **common_opts)
arrow_patch_1 = FancyArrowPatch(posA=(0.2, 0.2), posB=(0.8, 0.45),
                                mutation_scale=150, **common_opts)

fig, ax = plt.subplots(figsize=(8, 6), dpi=100)
ax.text(0.2, 0.85, "mutation_scale = 50", ha='left', va='bottom')
ax.text(0.2, 0.15, "mutation_scale = 150", ha='left', va='top')
for arrow_patch in [arrow_patch_0, arrow_patch_1]:
    ax.add_patch(arrow_patch)

""" Export to different formats, with different DPI values if rasterized.
"""
common_prefix = 'FancyArrowPatch_testfile'
fig.savefig(common_prefix + '.eps')
fig.savefig(common_prefix + '.pdf')
fig.savefig(common_prefix + '_300dpi.png', dpi=300)
fig.savefig(common_prefix + '_100dpi.png', dpi=100)
fig.savefig(common_prefix + '_300dpi.jpg', dpi=300)
fig.savefig(common_prefix + '_100dpi.jpg', dpi=100)``

And below is a picture that compares the 6 different output files (2 x 3 bottom panels) with a screenshot of the interactive figure (top center panel).
fancyarrowpatch_export_issue

Observations about the example

One can see that with the raster formats (PNG and JPG):

  • @ 100 DPI (blue labels), the exports are the same as in the screenshot of the interactive display with 100 DPI.
  • @ 300 DPI (red labels), the exports differ from the interactive display. Besides, the arrow head with mutation_scale = 150 seems to be as big as the arrow head with mutation_scale = 50 @ 100 DPI.

Concerning the (at least partially) vector formats that I tested (PDF and EPS, with purple labels), one can observe that the arrow heads do not have exactly the same size as in the interactive display either (they are slightly bigger).

@efiring
Copy link
Member

efiring commented Feb 20, 2016

Thank you for your clear presentation of this problem. I think it is a combination of a confusing API plus one or more genuine bugs, and it is in parts of the codebase that most of us find difficult to work with. You are correct in thinking that changing the output dpi or the file format should not change the relative dimensions of plot elements, so we will need to track down whatever is violating this rule.

@efiring efiring added this to the 2.0.1 (next bug fix release) milestone Feb 20, 2016
@efiring
Copy link
Member

efiring commented Feb 20, 2016

After a quick scan of patches.py, I see a mysterious dpi_cor kwarg, and a 'FIXME' comment suggesting "There could be room for improvement." Perhaps an understatement. Either the behavior reported here is a consequence of recent changes, or it is an inherent limitation of the present implementation. I suspect the latter. One of the problems is that there is very little in the way of hints as to which units the different variables and kwargs are using. My guess is that some things are being calculated a little bit too early based on the present figure dpi and are not being automatically recalculated to reflect the actual renderer dpi; maybe dpi_cor was a hack added to allow specification of the expected renderer dpi, or the ratio of that to the present dpi, or...

@afvincent
Copy link
Contributor Author

I played a bit with the FancyArrowPatch class, and I found some kind of workaround (described below), that does seem to achieve DPI-independent head size of the arrow.

This idea is basically to update the mutation_scale value each time the _dpi_cor attribute is set (through the ad-hoc setter). I derived a tweaked class from the vanilla FancyArrowPatch, to demonstrate this (see the following script).

set_dpi_cor is essentially called in the draw method: self.set_dpi_cor(renderer.points_to_pixels(1.)). As the only other use of the setter is in the __init__, I don't understand why one can pass a dpi_cor value as an argument when creating an instance: the value will be overwritten when rendering the figure (i.e. when calling the draw method) anyway, won't it be?

# -*- coding: utf8 -*-

from __future__ import (absolute_import, division, print_function,
                        unicode_literals)

import matplotlib.pyplot as plt
from matplotlib.patches import FancyArrowPatch as VanillaFancyArrowPatch

class TweakedFancyArrowPatch(VanillaFancyArrowPatch):
    """ 
    Tweaked FancyArrowPatch class that tries to correct the issue of relative
    head size of the arrow changing with the DPI chosen for export. 

    A self._genuine_mutation_scale attribute is added to the class. 
    It aims at storing the value of mutation_scale given as an argument 
    during the instance creation. The set_dpi_cor method is then overloaded
    to enforce update of self._mutation_scale each time self._dpi_cor is 
    updated, based on the initial value of mutation_scale 
    (self._genuine_mutation_scale).
    """    

    def __init__(self, **kwargs):
        # The '_genuine_mutation_scale' attribute is added to  the class to
        # to provide a fixed reference when updating '_mutation_scale' accordingly
        # to the value of '_dpi_cor'. NB: default mutation_scale is 1.0 in the 
        # signature of the vanilla FancyArrowPatch.
        self._genuine_mutation_scale = kwargs.get("mutation_scale", 1.0)
        VanillaFancyArrowPatch.__init__(self, **kwargs)

    def set_dpi_cor(self, dpi_cor):
        """ 
        Overloading the setter of '_dpi_cor'.
        dpi_cor is currently used for linewidth-related things and shrink factor. 
        Mutation scale *IS NOW* affected by this (it was not the the case before).
        """
        self._dpi_cor = dpi_cor
        self.stale = True
        # Update of the '_mutation_scale' attribute is added to the method
        self._mutation_scale = self._genuine_mutation_scale * dpi_cor

def add_scale_marker(ax):
    """
    Add a marker that does not depend on the DPI value. Coordinates come from
    a former manual ginput (with mutation_scale = 50).
    """
    ax.plot([0.66, 0.80, 0.80], 
            [0.64, 0.64, 0.49], color='Crimson', lw=2, ls='-')
    # Force the same limits for all axes.
    ax.set_xlim([0, 1])    
    ax.set_ylim([0, 1])    
    return ax

def get_export_filepath(prefix, output_format, dpi):
    """
    Return a filename that includes the dpi value in some way.
    """    
    if dpi is not None:
        return prefix + "_{}".format(int(dpi)) + '.' + output_format 
    else:
        return prefix + '.' + output_format 

if __name__ == '__main__':
    plt.ion() # My interactive backend is Qt4Agg, with default @ 80 DPI

    """
    Build a figure comparing a vanilla FancyArrowPatch instance with a 
    tweaked FancyArrowPatch one. The same parameters are given to the two
    instances.

    Note to myself: if mutation_aspect is not None but let's say a float, 
    an error raises in patches.py (even with the vanilla FancyArrowPatch):
    Line 3219: AttributeError: 'tuple' object has no attribute 'vertices'
    """
    common_opts = dict(posA=(0.3, 0.4), posB=(0.8, 0.6),
                       arrowstyle=u'->', lw=3, 
                       mutation_scale=50, mutation_aspect=None)

    fig, (axV, axT) = plt.subplots(ncols=2, figsize=(8, 4), dpi=150)
    for ax, ArrowPatch, label in [(axV, VanillaFancyArrowPatch, "Vanilla"), 
                                  (axT, TweakedFancyArrowPatch, "Tweaked")]:
        ax.set_title(label)
        ax.add_patch(ArrowPatch(**common_opts))
        ax = add_scale_marker(ax) # For easier comparison

    """ 
    Export to different formats, with different DPI values if rasterized.
    """
    prefix = './Comparing_FancyArrowPatches'
    for output_format, dpi in [('pdf', None), ('png', 300), ('png', 100)]:
        output_filepath = get_export_filepath(prefix, output_format, dpi)
        fig.savefig(output_filepath, dpi=dpi)

I don't really know how to test if tweaking FancyArrowPatch like above has border effects. I looked into test_patches.py but it seems to me that there is currently no unit test related to FancyArrowPatch (which seems a bit weird to me).

Here is a summary of the output pictures. The two top panels are screenshots of the interactive figure, with two different DPI values (50 and 150). The other panels correspond to the output figures (with different backends and different DPI values), that are produced when exporting the interactive top panels (one column per top panel). The tweaked FancyArrowPatch version seems to result in identical arrows whatever the DPI value is, which the vanilla class does not (I added a small red scale marker to better demonstrate this).
tweak_on_dpi_cor_qtagg_50_vs_150_dpi

As a conclusion, I have to admit that I don't fully understand how FancyArrowPatch works under the hood, so there may be a cleaner way to achieve this.

@tacaswell
Copy link
Member

That seem relatively clean to me.

Very nice analysis.

Could you open a pull request with those changes to FancyArrowPatch?

afvincent added a commit to afvincent/matplotlib that referenced this issue Feb 23, 2016
…it tests. Does break old unit tests of fancyarrow (test_arrow_patches.py)!
@afvincent
Copy link
Contributor Author

I have started to work on a branch that includes the proposed workaround in FancyArrowPatch.

Besides I tried to write some @image_comparison unit test (I still don't fully understand the workflow, but well everything-- even git & Co-- is quite new for me, so have to start somewhere anyway). I finally discovered that contrary to what I wrote before, there are some unit tests, in test_arrow_patches.py (and not in test_patches.py as I expected). Unfortunately, it seems that tweaking set_dpi_cor as I suggested breaks the former unit tests :(. A wild guess is that I only tested the arrowstyle '->', and something breaks in the other available styles (that are indeed tested in the former unit tests).

@tacaswell
Copy link
Member

It is not impossible that we have tests that are ensuring wrong behavior.

@afvincent
Copy link
Contributor Author

That was my first hope. But definitively, the proposed workaround breaks test_fancyarrow, that makes use of annotate. I finally managed to find in the class Annotation (in text.py) something that seems similar to the aforementioned fix:

# lines 2130-2132 in text.py
ms = d.pop("mutation_scale", self.get_size())
ms = renderer.points_to_pixels(ms)
self.arrow_patch.set_mutation_scale(ms)

It updates mutation_scale based on the conversion from points to pixels too, but from outside of the draw method of the FancyArrowPatch instance (contrary to the tweaked set_dpi_cor, essentially called in draw). And indeed, the arrows produced through annotate are already (i.e. in mpl 1.5) dpi-independent. Here are some exports from a function more or less directly copy-pasted from test_fancyarrow:
dpi-independent_annotate_arrows

So the good news is that there seems to be some convergence toward the same solution :). The bad news is that for the moment I still didn't manage to merge the two approaches into one single solution that both wouldn't break annotate and would fix the raw class FancyArrowPatch.

PS: I guess there is a way to cite some lines from a source code file without copy-pasting them like I did… Where can I find infos on how to do it?

@efiring
Copy link
Member

efiring commented Feb 23, 2016

On 2016/02/23 8:23 AM, afvincent wrote:

That was my first hope. But definitively, the proposed workaround breaks
|test_fancyarrow|, that makes use of |annotate|. I finally managed to
find in the class |Annotation| (in |text.py|) something that seems
similar to the aforementioned fix:

lines 2130-2132 in text.py

ms= d.pop("mutation_scale",self.get_size())
ms= renderer.points_to_pixels(ms)
self.arrow_patch.set_mutation_scale(ms)

Quick thought, tentative, since I haven't had time to look into this
yet, and won't for a little while: it seems wrong to be changing
mutation_scale, which is an input argument. There should instead be
some internal, private variable that takes into account mutation scale
and dpi to control the rendered result. Correct?

(Longer term, I hope we can make the whole API much clearer and easier
to understand and remember.)

Eric

@afvincent
Copy link
Contributor Author

Based on what you wrote Eric, I think I finally found a way to correct this bug without altering the attribute mutation_scale or breaking the arrows produced with annotate (which were fine). Details are given in PR #6054.

afvincent added a commit to afvincent/matplotlib that referenced this issue Apr 3, 2016
…it tests. Does break old unit tests of fancyarrow (test_arrow_patches.py)!
afvincent added a commit to afvincent/matplotlib that referenced this issue May 14, 2016
…it tests. Does break old unit tests of fancyarrow (test_arrow_patches.py)!
jenshnielsen pushed a commit to jenshnielsen/matplotlib that referenced this issue May 30, 2016
…it tests. Does break old unit tests of fancyarrow (test_arrow_patches.py)!
@QuLogic
Copy link
Member

QuLogic commented May 30, 2016

Fixed by #6504.

@QuLogic QuLogic closed this as completed May 30, 2016
@QuLogic QuLogic modified the milestones: 2.0 (style change major release), 2.0.1 (next bug fix release) May 30, 2016
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

5 participants