Skip to content

fix tightbbox to account for markeredgewidth #16607

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

Conversation

brunobeltran
Copy link
Contributor

@brunobeltran brunobeltran commented Feb 29, 2020

PR Summary

This initial PR is a naive fix for issue #16606; it will be updated as I document what the expected behavior should be for each marker type.

In short, increases the padding added to Line2D.get_window_extent in order to account for markers with non-trivial edge widths.

I also do not know what kind of test would be appropriate to add here? Just code that makes a plot that's composed of one marker with known size and edge width and then check that get_tightbbox returns the right values? Is the goal one test per marker type?

PR Checklist

  • Has Pytest style unit tests
  • Code is Flake 8 compliant
  • New features are documented, with examples if plot related
  • Documentation is sphinx and numpydoc compliant
  • Added an entry to doc/users/next_whats_new/ if major new feature (follow instructions in README.rst there)
  • Documented in doc/api/api_changes.rst if API changed in a backward-incompatible way

Copy link
Member

@timhoffm timhoffm left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just code that makes a plot that's composed of one marker with known size and edge width and then check that get_tightbbox returns the right values?

Sounds good.

I don‘t know if it‘s necessary/worth to distinguish different markers.

@@ -617,7 +617,8 @@ def get_window_extent(self, renderer):
ignore=True)
# correct for marker size, if any
if self._marker:
ms = (self._markersize / 72.0 * self.figure.dpi) * 0.5
extra_pts = self._markersize + self._markeredgewidth
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
extra_pts = self._markersize + self._markeredgewidth
extra_pts = self._markersize + self._markeredgewidth / 2

I think. The edge line is drawn centered on the marker outline. So only half of the width adds to the extent.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, but this is already accounted for in the * 0.5 for both marker size and edge width in the next line. In the more general case, this multiplication is no longer needed, see newest commits.

@brunobeltran
Copy link
Contributor Author

Some design decisions that I didn't know how to make:

  1. I implemented a get_centered_bbox function. Do I implement get_extents, or get_bbox, or something else instead? There doesn't seem to be an agreed-upon convention in the codebase for how to extract the bbox information from an object (lines have get_window_extents, paths have get_extents, etc.)
  2. the information to calculate the PathEndAngles for each glyph can theoretically be extracted from the path itself. I opted to manually tabulate them instead since the number of glyphs is small and they are geometrically straightforward. A more generalized approach that identifies the points on the path that "create" the bbox, then extracts their PathEndAngles from the path itself would be more general and theoretically resilient to future/custom glyph types, but would be quite a bit more work.
  3. more in general, I'm not familiar enough with the mpl codebase as a whole to know if there's already a convention for aggragating data like I do using BoxSides(PathEndAngles(...))
  4. On that note, as long as we don't do something like suggested above in (2), we will now be storing more than two per-glyph-type data things (and if e.g. Sizes of different markers are not perceptually uniform #15703 gets implemented we'll have a couple of more pieces of per-glyph-type information, the area and visual scaling constants, saved as well). This might argue for a simple refactor of the current markers dictionary into a list of GlyphInfo objects, containing (for each glyph) the
    a) list of valid short names (e.g. ['', ' ', 'None', None])
    b) long name (e.g. 'star')
    c) PathEndAngles (or whatever they end up being called)
    d) area of glyph, to scale with
    e) (in the future) effective visual size scaling constant

I'm happy to do that simple refactor here, since I'm adding the information, but wanted to make sure there was a shared feeling that it is necessary in the first place, and that I wouldn't be wasting too much time doing it in a style that would immediately be rejected.

@brunobeltran
Copy link
Contributor Author

Just code that makes a plot that's composed of one marker with known size and edge width and then check that get_tightbbox returns the right values?

I don‘t know if it‘s necessary/worth to distinguish different markers.

Now that it's more obvious that this gets messy on a per-glyph basis, should we include one test per glyph or just test a couple of the nasty ones? (say, 'p', '*', 'o', 'x', to get examples of glyphs with miter joins, round joins, filled and not)?

@brunobeltran
Copy link
Contributor Author

Weird. The tests I just added pass in a Jupyter notebook but not with the Agg backend or on Travis?

Basically, the code

_draw_marker_outlined('*', markeredgewidth=20)

produces a 480x480px image with the box in the wrong place.

marker_bbox_star

The calling the identical function in Jupyter...

test_marker._draw_marker_outlined('*', markeredgewidth=20)
plt.savefig('marker_bbox_star.png')

produces a correct figure, but 345x345px.
marker_bbox_star

I don't think this is a problem in my own code, since the box is incorrect even for the trivial case of markeredgewidth=0, and doing the following in Jupyter produces a 480x480px image that is also correct

test_marker._draw_marker_outlined('*', markeredgewidth=20)
plt.savefig('marker_bbox_star.png', dpi=100)

marker_bbox_star-expected

@brunobeltran
Copy link
Contributor Author

@timhoffm Ready for re-review and need help.

  1. My new tests are passing in Jupyter, but not in the console (see above).
  2. I ended up adding a new public API element MarkerStyle.get_centered_bbox, but don't know where or how to document it appropriately besides its docstring.

@timhoffm
Copy link
Member

timhoffm commented Mar 4, 2020

I think this is a dpi issue. Some calculation does not Takt them into account. The jupyter backend (%matplotlib inline) sets 72dpi by default, everything else uses 100dpi. What happens if you do plt.rcParams["figure.dpi"]?

Sorry for the formatting, I‘m on mobile.

I‘m quite busy right now. Please be patient with a full review. That may take a couple of days.

@brunobeltran
Copy link
Contributor Author

Thanks for the quick reply Tim! Looks like it is a DPI thing, I'll follow that lead.

Of course I assume you're very busy, and I definitely wasn't meaning to rush you. I just wanted to ping so you knew it was safe to start looking without wasting even more of your time.

Thanks in advance for your help!

@ImportanceOfBeingErnest
Copy link
Member

You removed the line ms = (self._markersize / 72.0 * self.figure.dpi) * 0.5 which accounts for dpi. So the box will be wrong for every dpi setting, except 72, where 1/72*dpi==1.

@brunobeltran
Copy link
Contributor Author

brunobeltran commented Mar 4, 2020

Haha, didn't see the "member" when I replied to you earlier @ImportanceOfBeingErnest. Hopefully I didn't come off as presumptuous!

Mostly, thanks for the good eye! I had forgotten that the markers stuff is in points. Fixed the code to make that consistent. Tests should pass now. Had to push up new "baseline_images", since they were generated at different DPI than before for whatever reason, but now look as expected.

@ImportanceOfBeingErnest
Copy link
Member

To have image comparisson look like the default style one can use

@image_comparison(..., style='mpl20')

...just in case that is still relevant. One could also try to use only a single image (image comparison is rather expensive, so if it can be reduced to a minimum, that would sure be nice)

In general I haven't quite understood how wrong adding half the edge width would be and if that really warrants adding this huge code block. In particular, I would have though adding half the edgewidth would rather overestimate the bbox - which would be totally acceptable in my eyes.
Maybe an example case either here, or in the original issue #16606 would be helpful in that respect.

@jklymak
Copy link
Member

jklymak commented Mar 5, 2020

I agree with @ImportanceOfBeingErnest. This is for the rare case that a marker over-spills its axes, and most folks don't have markeredgewsidths that are much larger than a point or two. This is really cool that you sorted through all the geometry, but is it overkill compared to the actual problem?

@anntzer
Copy link
Contributor

anntzer commented Mar 6, 2020

If that's the problem, I'm happy to write a slighly more general solution that computes these properties from the path of the marker. It is possible, just a little difficult in the general case as computing the extents of stroked bezier paths is famously complicated. However, I do know how to do it correctly, and since I use a lot of custom markers, it would greatly benefit me since my fix would then apply in that case as well.

But I don't think we need to cover the general case here, just polygons and circles. I guess polygons would be relatively simpler; for circles, somehow luckily, falling back to 0.5*mew would work :-)

If I do this, will it get mainlined?

Well that's the problem as always: any code that goes in needs to be maintained and is extremely annoying to get rid of, and maintainer time is finite. That's why we're not necessarily so keen on adding a few hundreds of line of code just to handle a relatively edge case :-) (even though it's quite impressive work, as mentioned just above :-))

Conveniently, the currently hardcoded figure properties mean we would have amazing test coverage of this more general solution with no extra work I suppose....

Not a huge fan of playing the coverage numbers game :-)

@brunobeltran
Copy link
Contributor Author

@anntzer , I got a little swamped with paper deadlines, but started working on code for general solution today (written, just needs testing).

However, I ran into a bit of a design issue. Right now bezier.py imports path.py (but only in a couple of functions that feel to me like they should be in path.py anyway...). Obviously a general routine that calculates bbox for a stroked path belongs as a method of Path. But I need helper routines from bezier.py so....

Should I

  1. put my code in bezier.py/markers.py (as in current commit, less elegant, but no API breaking)]
  2. fix the underlying issue, move appropriate code from bezier.py into path.py, do it "right" (internal API break, but that code is really only used in one place internally...)

in order to minimize resistance to this already pretty contentious PR?

@anntzer
Copy link
Contributor

anntzer commented Mar 11, 2020

I would be fine with moving stuff from bezier.py to path.py if that's necessary.
You may consider breaking stuff into multiple consecutive PRs to prevent review from going out of control... ;)

@timhoffm
Copy link
Member

timhoffm commented Mar 12, 2020

Before there‘s going more work into this: Are we positive, that we want to take on the exact solution with the added amount of code vs. an approximate solution? It would be a shame to detail it all out and later decide that the maintenance burden would be too high.

I haven‘t looked into this in detail, but the amount of code needed to special-case the exact solution scares me a little.

Ping @jklymak @anntzer @tacaswell @ImportanceOfBeingErnest I think we should have a champion for this. If nobody is stepping up for it, I fear the PR will have a hard time of getting merged.

@anntzer
Copy link
Contributor

anntzer commented Mar 12, 2020

I think this will depend on the amount of code involved... (I can't guarantee a quick review, but will try to keep a look on this.)
I just realized there may be an easier solution: I think the "true" size of a marker likely always grows as a*markersize+b*markeredgewidth+c? in which case we could just get away with estimating a, b and c by rasterizing the marker at a few ms/mew values (the results can be cached per-marker), instead of doing the complicated geometry calculations.

@brunobeltran
Copy link
Contributor Author

brunobeltran commented Mar 12, 2020

Yeah something like @anntzer's proposal was actually my plan after you guys asked about performance, just didn't want to dump a bunch of work into this if it wasn't going to get accepted, as the current implementation works just fine for my purposes.

The current recommendation is:

  1. Fix Path/Bezier to depend on each other in a way that makes sense, clean up redundant code (separate PR).
  2. Add iter_corners method to compute geometrical properties directly from the Path.
  3. Cache constants on MarkerStyle creation (a/b/c, as above) that describe how marker Bbox scales with markersize/markeredgewidth.
  4. Add MarkerStyle.get_extents(renderer) to get exact bbox without slowdown compared to current method.

@brunobeltran
Copy link
Contributor Author

Superceded by #17119, a cleaner implementation using some of my recent additions to Path.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants