cartopy icon indicating copy to clipboard operation
cartopy copied to clipboard

fix treatment of spine paths with explicit path-codes

Open raphaelquast opened this issue 10 months ago • 10 comments

Rationale

The current implementation of the adjust_location method of GeoSpines assumes that the spine-path is a single connected path and potential path-codes are ignored.

This causes problems if multi-paths are used as bounaries to clip the map (e.g. with ax.set_boundary().... for example:

image

Implications

With the proposed changes, path-codes are preserved and multi-path clipping works as expected

image

raphaelquast avatar Mar 28 '24 14:03 raphaelquast

CLA assistant check
All committers have signed the CLA.

CLAassistant avatar Mar 28 '24 15:03 CLAassistant

@greglucas I saw that you initially introduced this change a few months ago to avoid

"... a small gap at the corner of closure due to the capstyle of the lines being "butt" by default".

To keep your changes for single closed paths, I've implemented the fix so that it only triggers if the path actually represents a multi-path (e.g. if it contains more than 1 MOVETO code).

raphaelquast avatar Mar 28 '24 15:03 raphaelquast

Tests fail with HTTP Error 502: Bad Gateway... I guess they require a re-run?

raphaelquast avatar Mar 28 '24 16:03 raphaelquast

Is there a way to do something like: if not path.closed(): close_the_path

One other thought is that perhaps the previous fix was actually a non-ideal one where self._path = mpath.Path(self._path.vertices, closed=True) is going to remove the final vertex from the path, but I think we actually might want to append the beginning vertex to the end and then close so we don't drop a point?

This should get a test somehow, can you modify your example figure output to count moveto/close before/after this change?

greglucas avatar Mar 29 '24 17:03 greglucas

Hey, @greglucas, thanks for the quick response!

One other thought is that perhaps the previous fix was actually a non-ideal one where self._path = mpath.Path(self._path.vertices, closed=True) is going to remove the final vertex from the path, but I think we actually might want to append the beginning vertex to the end and then close so we don't drop a point?

I had a quick look... it seems that using Path(..., closed=True) actually just replaces all path-codes with this:

...
elif closed and len(vertices):
    codes = np.empty(len(vertices), dtype=self.code_type)
    codes[0] = self.MOVETO
    codes[1:-1] = self.LINETO
    codes[-1] = self.CLOSEPOLY

I agree that this might cause problems if the last vertex and the first should actually be connected. CLOSEPOLY is described in the docs as:

- CLOSEPOLY: 1 vertex (ignored)
   Draw a line segment to the start point of the current polyline.

I've now updated the PR to perform a more complete check and make sure that the path (and all potential sub-paths) are properly closed with the start-vertex.


This should get a test somehow, can you modify your example figure output to count moveto/close before/after this change?

For the currently implemented method I don't think it's necessary to count since Path(..., closed=True) will simply replace all codes as indicated above.

If you agree with my proposed solution, I can write a test for GeoSpine._ensure_path_closed() that checks some potential use-cases (e.g. treatment of single closed path, single open path, multi-paths etc.).

raphaelquast avatar Apr 02 '24 08:04 raphaelquast

Test-failure is again just a download-issue.

raphaelquast avatar Apr 02 '24 09:04 raphaelquast

@raphaelquast , some tests would be good regardless of the solution I think! You've definitely found some issues, so codifying those with tests would be great.

I think this is good. I do have some concern that this is in a potential hot-loop when zooming/panning around, but the fix looks like the "proper" solution to account for the various cases.

greglucas avatar Apr 03 '24 22:04 greglucas

@greglucas I've now added some basic unittests to check proper handling of single- and multi-path boundaries (based on image-comparison)

raphaelquast avatar Apr 30 '24 08:04 raphaelquast

Test failures are again unrelated but I don't seem to have the rights to trigger a manual re-run.

raphaelquast avatar Apr 30 '24 08:04 raphaelquast

Hey, just checking in to see what's missing to get this merged?

  • Test failures after re-run still don't relate to this PR but to WFS connection issues)
  • I've contributed to cartopy before... as far as I remember I've already signed the CLA back then... do you need me to re-sign it?

raphaelquast avatar May 09 '24 11:05 raphaelquast

@raphaelquast friendly ping in case you had a chance to look into to_polygons() and had any thoughts on the other comments.

greglucas avatar Jun 08 '24 20:06 greglucas

Hey @greglucas I have not forgotten about this, but life has its way to get between me and my open-source endeavors 😅

I had a quick look so far but nothing concrete. In general to_polygons() looks like a good idea, but I wanted to do some testing first to make sure it does what we want it to do. I hope that I'll find some time in the next few weeks to look into this a bit further.

Concerning the tests:
I went for an image-comparison test since I thought that it will also catch any changes result in connection-lines between multi-polygon spines. Since there is no text involved, I expected the image comparisons to be stable.

Its of course possible to check if the updated polygon-representation contains enough CLOSEPOLY codes (or something similar). However, I would argue that such a test should be applied to the to_polygons function directly and not to some derived cartopy functionality like ax.set_boundary... what I wanted to test here is if the associated spines are drawn correctly.

raphaelquast avatar Jun 10 '24 09:06 raphaelquast

Hey @greglucas I finally managed to do a quick check using the following implementation of the Path.to_polygons() method:

    @staticmethod
    def _ensure_path_closed(path):
        """Method to ensure that a path contains only closed sub-paths."""
        polygons = path.to_polygons()
        codes, vertices = [], []
        for p in polygons:
            vertices.extend([p[0], *p])
            codes.extend([
                mpath.Path.MOVETO, 
                *[mpath.Path.LINETO]*(len(p) - 1),
                mpath.Path.CLOSEPOLY])
                
        return mpath.Path(vertices, codes)

However I realized that even though the docs claim that no path simplification is performed, the resulting output suggests otherwise... (I did not look in the corresponding c-code)

Here's a gif showing the above to_polygons implementation (LEFT) and the current implementation (RIGHT):
(both use the exact same geometry as input)

The docs of to_polygons claim that

"If width and height are both non-zero then the lines will be simplified so that vertices outside of (0, 0), (width, height) will be clipped."

but the default (width=0, height=0) still seems to apply some kind of simplification (or maybe floating-point rounding erors?).

I'm not sure if this is a bug or if this is the intended behavior but in both cases it does not look like it's doing what we want it to do.

raphaelquast avatar Jun 19 '24 22:06 raphaelquast

It looks like the path is allowed to simplify still as it enters that method. If you put in path.should_simplify = False, then I think it might work as expected.

greglucas avatar Jun 20 '24 03:06 greglucas

Hey, indeed that did the job! I think this behavior should definitely be mentioned somewhere in the to_polygons method as well...
There was no indication that an additional simplification of the path is triggered if should_simplify is True.

My final question now is if we should "reset" the should_simplify property to its initial value after conversion to avoid changing the properties of the path instance, e.g. something like this (or maybe with a contextmanager):

        initial_should_simplify = path.should_simplify
        path.should_simplify = False
        polygons = path.to_polygons(closed_only=False)
        path.should_simplify = initial_should_simplify

raphaelquast avatar Jun 20 '24 06:06 raphaelquast

My final question now is if we should "reset" the should_simplify property to its initial value after conversion to avoid changing the properties of the path instance, e.g. something like this (or maybe with a contextmanager):

:+1: The context manager sounds like a good idea to me.

greglucas avatar Jun 20 '24 14:06 greglucas

@greglucas I now pushed a new implementation using to_polygons() and a contextmanager to temporarily disable path simplification.

raphaelquast avatar Jun 21 '24 10:06 raphaelquast

@greglucas OK, I've updated the code to apply _ensure_path_closed already (and only) on ax.set_boundary() because in my opinion this is actually the proper way to do it.

  • It completely avoids the hot-loop of checking for polygon closure during pan/zoom
  • It is actually required to ensure that path.clip_to_bbox (used during pan/zoom) works as expected

However, this unfortunately causes a lot of other image tests to fail. I'm not 100% sure why this has such broad implications, I guess it might be that a lot of the boundary-paths are actually not properly closed...

While checking the resulting changes, I believe that this has in fact a mostly positive impact on the exported images!

To clarify I've selected some of the most important changes I've found so far:

[click to show] some image comparison results

(LEFT: baseline, RIGHT: new result)

  • With the new results it became visible that some of the baseline images actually have quite distorted text!
    (this is True for a lot of baseline images)
  • The gridliner seems affected...
    • we lose +-180° labels for WebMercator (which is actually somewhat OK)
  • but in other cases initially missing labels are now properly plotted (like the 0° label in the top right map)

Its quite unfortunate that this has such broad effects, but it seems like it's worth investigating what's going on here... What do you think about that?

raphaelquast avatar Jun 21 '24 16:06 raphaelquast

We just keep going deeper here :(

It looks like the path is cleaned to remove duplicates with the to_polygons which must push some of the image checks just over the tolerances. I don't quite understand why that would affect any of the calculations though...

Original path input:

Path(array([[-180.,  -90.],
       [-180.,  -90.],
       [-180.,   90.],
       [ 180.,   90.],
       [ 180.,  -90.],
       [-180.,  -90.]]), array([ 1,  2,  2,  2,  2, 79], dtype=uint8))

After your new method:

Path(array([[-180.,  -90.],
       [-180.,   90.],
       [ 180.,   90.],
       [ 180.,  -90.],
       [-180.,  -90.]]), array([ 1,  2,  2,  2, 79], dtype=uint8))

Some of the original images do look better to me because they do a better job at clipping and not "leaking" the colors into the boundary. So maybe the best course of action is to just bump the tolerances ever so slightly up 0.02-0.03 for most of them it looks like?

greglucas avatar Jun 22 '24 14:06 greglucas

Yes I know... I really didn't expect that this final change would result in such a wide range of effects...

I did some more tests to get to the bottom of this:
It seems that path.clip_to_bbox does not return only closed polygons as I initially expected, so making sure that they are closed during pan/zoom seems still necessary...

If we run _ensure_path_closed both on spine._adjust_location and ax.set_boundary then all "leaking" is gone and (locally on Windows) test-fails are only related to:

  • Fonts in exported images are no longer distorted
  • Some Grid-label differences
    • -180/180 are removed in WebMercator
    • Some initially missing labels are now properly displayed

On GitHub tests seem to succeed

raphaelquast avatar Jun 22 '24 18:06 raphaelquast

Awesome! One final thought... Should we apply the same treatment also to the _ViewClippedPathPatch? (E.g. putting _ensure_path_closed outside the class and then using it for both the spine and the patch)

https://github.com/SciTools/cartopy/blob/2d4cef279fae2cea5389105a1d6f5bb7589bd911/lib/cartopy/mpl/geoaxes.py#L231

raphaelquast avatar Jun 23 '24 07:06 raphaelquast

Should we apply the same treatment also to the _ViewClippedPathPatch? (E.g. putting _ensure_path_closed outside the class and then using it for both the spine and the patch)

That also seems reasonable to add this capability there. Do you want to do that here or in a follow-up PR? (I'm not sure where we'd want the _ensure_closed_poly to live, maybe in cartopy.mpl.patch as a private method there rather than on either of the classes?)

greglucas avatar Jun 23 '24 12:06 greglucas

Since at this stage it's just about agreeing on the right place for _ensure_path_closed, I'm fine with including it in this PR.

I definitely agree that _ensure_path_closed should be put outside the casses. My suggestion would have been to just make it a private method in cartopy.mpl.geoaxes since both affected classes are defined there anyways.

However, If you think it's better to put it in cartopy.mpl.path I have no objections... just let me know what you prefer!

raphaelquast avatar Jun 23 '24 18:06 raphaelquast

I don't have a major preference either way. It just seemed like it is similar to the other path handling in that location, but we are only using it in this file so that is fine too. Either one seems reasonable.

greglucas avatar Jun 23 '24 19:06 greglucas

OK, all done!

In the end I decided to go with your suggestion... it seems like a better place for the method in case it is used somewhere else as well in the future.

raphaelquast avatar Jun 23 '24 19:06 raphaelquast

I guess the one failing test needs a re-run.. (fails with ERROR tests/mpl - PermissionError: [Errno 13] Permission denied: 'C:\\Users\\runneradmin\\.matplotlib\\fontlist-v390.json.matplotlib-lock')

raphaelquast avatar Jun 23 '24 19:06 raphaelquast

There is a branch protection rule on the Contributor License Agreement, can you sign that as well? https://cla-assistant.io/SciTools/cartopy?pullRequest=2362

greglucas avatar Jun 23 '24 20:06 greglucas

Oh, I totally forgot that I need to re-sign this... done!

raphaelquast avatar Jun 23 '24 20:06 raphaelquast

Awesome, thank you @raphaelquast for the persistence and nice addition!

greglucas avatar Jun 23 '24 20:06 greglucas

Sure! Thanks for your valuable inputs!

raphaelquast avatar Jun 23 '24 20:06 raphaelquast