@@ -52,19 +52,30 @@ def _compute_output_grid(source_bounds, source_shape, source_crs, target_crs,
5252 if src_bottom >= src_top :
5353 src_bottom , src_top = source_bounds [1 ], source_bounds [3 ]
5454
55- n_edge = 21 # sample points along each edge
56- xs = np .concatenate ([
55+ # Sample edges densely plus an interior grid so that
56+ # projections with curvature (e.g. UTM near zone edges)
57+ # don't underestimate the output bounding box.
58+ n_edge = 101
59+ n_interior = 21
60+ edge_xs = np .concatenate ([
5761 np .linspace (src_left , src_right , n_edge ), # top edge
5862 np .linspace (src_left , src_right , n_edge ), # bottom edge
5963 np .full (n_edge , src_left ), # left edge
6064 np .full (n_edge , src_right ), # right edge
6165 ])
62- ys = np .concatenate ([
66+ edge_ys = np .concatenate ([
6367 np .full (n_edge , src_top ),
6468 np .full (n_edge , src_bottom ),
6569 np .linspace (src_bottom , src_top , n_edge ),
6670 np .linspace (src_bottom , src_top , n_edge ),
6771 ])
72+ # Interior grid catches cases where the projected extent
73+ # bulges beyond the edges (e.g. Mercator near the poles).
74+ ix = np .linspace (src_left , src_right , n_interior )
75+ iy = np .linspace (src_bottom , src_top , n_interior )
76+ ixx , iyy = np .meshgrid (ix , iy )
77+ xs = np .concatenate ([edge_xs , ixx .ravel ()])
78+ ys = np .concatenate ([edge_ys , iyy .ravel ()])
6879 tx , ty = transformer .transform (xs , ys )
6980 tx = np .asarray (tx )
7081 ty = np .asarray (ty )
@@ -131,29 +142,35 @@ def _compute_output_grid(source_bounds, source_shape, source_crs, target_crs,
131142 res_x = (right - left ) / width
132143 res_y = (top - bottom ) / height
133144 else :
134- # Estimate from source resolution
145+ # Estimate from source resolution by transforming each axis
146+ # independently, then taking the geometric mean for a square pixel.
135147 src_h , src_w = source_shape
136148 src_left , src_bottom , src_right , src_top = source_bounds
137149 src_res_x = (src_right - src_left ) / src_w
138150 src_res_y = (src_top - src_bottom ) / src_h
139- # Use the geometric mean of transformed pixel sizes
140151 center_x = (src_left + src_right ) / 2
141152 center_y = (src_bottom + src_top ) / 2
142- tx1 , ty1 = transformer .transform (center_x , center_y )
143- tx2 , ty2 = transformer .transform (
144- center_x + src_res_x , center_y + src_res_y
145- )
146- res_x = abs (float (tx2 ) - float (tx1 ))
147- res_y = abs (float (ty2 ) - float (ty1 ))
148- if res_x == 0 or res_y == 0 :
153+ tc_x , tc_y = transformer .transform (center_x , center_y )
154+ # Step along x only
155+ tx_x , tx_y = transformer .transform (center_x + src_res_x , center_y )
156+ dx = np .hypot (float (tx_x ) - float (tc_x ), float (tx_y ) - float (tc_y ))
157+ # Step along y only
158+ ty_x , ty_y = transformer .transform (center_x , center_y + src_res_y )
159+ dy = np .hypot (float (ty_x ) - float (tc_x ), float (ty_y ) - float (tc_y ))
160+ if dx == 0 or dy == 0 :
149161 res_x = (right - left ) / src_w
150162 res_y = (top - bottom ) / src_h
163+ else :
164+ # Geometric mean for square pixels
165+ res_x = res_y = np .sqrt (dx * dy )
151166
152- # Compute dimensions
167+ # Compute dimensions. Use round() instead of ceil() so that
168+ # floating-point noise (e.g. 677.0000000000001) does not add a
169+ # spurious extra row/column.
153170 if width is None :
154- width = max (1 , int (np . ceil ((right - left ) / res_x )))
171+ width = max (1 , int (round ((right - left ) / res_x )))
155172 if height is None :
156- height = max (1 , int (np . ceil ((top - bottom ) / res_y )))
173+ height = max (1 , int (round ((top - bottom ) / res_y )))
157174
158175 # Adjust bounds to be exact multiples of resolution
159176 right = left + width * res_x
0 commit comments