Description
When projecting Natural Earth land (or ocean) features into an ObliqueMercator projection whose cut line passes through large land masses, some polygons are returned as their own complement — a shape covering ~80–95% of the projection domain with a small hole where the land should be. The result is that ocean areas render as land color (tan).
Reproducer
import matplotlib.pyplot as plt
import cartopy.crs as ccrs
import cartopy.feature as cfeature
# ObliqueMercator along the Seattle–Tokyo great-circle arc.
# The cut line (antipodal great circle) passes through Alaska and
# eastern Russia, so several large land polygons cross it.
projection = ccrs.ObliqueMercator(
central_longitude=-161.07,
central_latitude=54.55,
azimuth=90.0,
scale_factor=1,
)
fig, axes = plt.subplots(1, 2, subplot_kw={'projection': projection},
figsize=(14, 4))
fig.suptitle('Inside-out polygon bug — ObliqueMercator over North Pacific')
for ax, label, x_half, y_half in [
(axes[0], 'larger extent (less affected)', 6_460_000, 1_960_000),
(axes[1], 'smaller extent (ocean = tan bug)', 5_070_000, 1_170_000),
]:
ax.set_facecolor(cfeature.COLORS['water'])
ax.add_feature(cfeature.LAND)
ax.add_feature(cfeature.COASTLINE)
ax.set_extent([-x_half, x_half, -y_half, y_half], crs=projection)
ax.set_title(label)
plt.tight_layout()
plt.show()
The right-hand axes shows the ocean rendered as tan (land color): 
The bug is triggered when AdaptiveScaler selects 50m data (which happens when the map extent is small enough — roughly < 50° in the shortest dimension). It does not affect 110m data.
Root cause
_attach_lines_to_boundary in crs.py correctly connects projected line segments to the projection boundary, but when a ring crosses the cut line the resulting ring traces the complement of the intended shape. Crucially, the ring retains the correct CCW/CW winding flag, so _rings_to_multi_polygon classifies it as an exterior ring and sgeom.Polygon(exterior_ring) produces a polygon with area ~80–95% of the full projection domain — the inside-out version of the land mass.
This can be detected reliably: a genuine land or ocean polygon projected into a bounded CRS cannot have area greater than half the projection domain. Any exterior ring whose polygon exceeds that threshold has been inverted by the cut-line crossing.
Fix
In _rings_to_multi_polygon, before the normal hole-slurping logic, check each exterior ring's polygon area against 0.5 * self.domain.area. If it exceeds that threshold, replace it with boundary_poly.difference(ring_polygon) to recover the correct shape.
I have a branch with the fix and a regression test ready
Note
The source of this bug was diagnosed, and the fix was written, with the assistance of Claude Code.
Environment
- cartopy version: 0.25.1
- shapely version: 2.0.2
- OS: macOS
Description
When projecting Natural Earth land (or ocean) features into an ObliqueMercator projection whose cut line passes through large land masses, some polygons are returned as their own complement — a shape covering ~80–95% of the projection domain with a small hole where the land should be. The result is that ocean areas render as land color (tan).
Reproducer
The right-hand axes shows the ocean rendered as tan (land color):
The bug is triggered when AdaptiveScaler selects 50m data (which happens when the map extent is small enough — roughly < 50° in the shortest dimension). It does not affect 110m data.
Root cause
_attach_lines_to_boundaryincrs.pycorrectly connects projected line segments to the projection boundary, but when a ring crosses the cut line the resulting ring traces the complement of the intended shape. Crucially, the ring retains the correct CCW/CW winding flag, so_rings_to_multi_polygonclassifies it as an exterior ring andsgeom.Polygon(exterior_ring)produces a polygon with area ~80–95% of the full projection domain — the inside-out version of the land mass.This can be detected reliably: a genuine land or ocean polygon projected into a bounded CRS cannot have area greater than half the projection domain. Any exterior ring whose polygon exceeds that threshold has been inverted by the cut-line crossing.
Fix
In
_rings_to_multi_polygon, before the normal hole-slurping logic, check each exterior ring's polygon area against0.5 * self.domain.area. If it exceeds that threshold, replace it withboundary_poly.difference(ring_polygon)to recover the correct shape.I have a branch with the fix and a regression test ready
Note
The source of this bug was diagnosed, and the fix was written, with the assistance of Claude Code.
Environment