Skip to content

Commit d0b5622

Browse files
committed
Fix contour coordinates to use DataArray coordinate space
contours() returned raw array indices, which produced misaligned contour lines when plotted on xarray imshow axes that use the DataArray's coordinate values. Transform output coordinates from array indices to the DataArray's y/x coordinate space via np.interp. Update tests, contour_explorer, and user guide notebook accordingly.
1 parent e0a71df commit d0b5622

File tree

5 files changed

+259
-95
lines changed

5 files changed

+259
-95
lines changed

examples/contour_explorer.py

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -156,8 +156,8 @@ def draw_contours():
156156
fill_cmap = "gist_earth"
157157
# Use matplotlib's contourf for fills (our contour lines overlay on top)
158158
cf = ax.contourf(
159-
elev_vals, levels=levels_list, cmap=fill_cmap,
160-
origin="lower", alpha=0.35, extend="both",
159+
xs, ys, elev_vals, levels=levels_list, cmap=fill_cmap,
160+
alpha=0.35, extend="both",
161161
)
162162
fill_artists.extend(cf.collections)
163163

@@ -252,16 +252,24 @@ def update_status():
252252
for spine in ax.spines.values():
253253
spine.set_color("white")
254254

255+
# Map the image extent to the DataArray's coordinate space so that
256+
# contour coordinates (which are in coordinate space) align correctly.
257+
xs = elevation.coords["x"].values
258+
ys = elevation.coords["y"].values
259+
img_extent = [xs[0], xs[-1], ys[0], ys[-1]]
260+
255261
# Hillshade layer
256262
hillshade_img = ax.imshow(
257263
hillshade_vals, cmap="gray", origin="lower",
258264
aspect="equal", interpolation="bilinear", alpha=1.0,
265+
extent=img_extent,
259266
)
260267

261268
# Terrain colour layer
262269
terrain_img = ax.imshow(
263270
elev_vals, cmap="gist_earth", origin="lower",
264271
aspect="equal", interpolation="bilinear", alpha=0.4,
272+
extent=img_extent,
265273
)
266274

267275
# Elevation readout under cursor
@@ -287,12 +295,18 @@ def update_status():
287295

288296
# -- Event handlers ------------------------------------------------------------
289297

298+
def _coord_to_pixel(xdata, ydata):
299+
"""Convert coordinate-space position to nearest pixel indices."""
300+
col = int(round(np.interp(xdata, xs, np.arange(len(xs)))))
301+
row = int(round(np.interp(ydata, ys, np.arange(len(ys)))))
302+
return row, col
303+
304+
290305
def on_click(event):
291306
"""Left-click: add contour at that elevation. Right-click: remove nearest."""
292307
if event.inaxes != ax:
293308
return
294-
col = int(round(event.xdata))
295-
row = int(round(event.ydata))
309+
row, col = _coord_to_pixel(event.xdata, event.ydata)
296310
if not (0 <= row < GRID_H and 0 <= col < GRID_W):
297311
return
298312

@@ -304,7 +318,7 @@ def on_click(event):
304318
snapped = round(elev / 10) * 10
305319
if snapped not in custom_levels:
306320
custom_levels.append(snapped)
307-
marker_positions.append((col, row))
321+
marker_positions.append((event.xdata, event.ydata))
308322
print(f" Added contour at elevation {snapped:.0f} "
309323
f"(clicked {elev:.1f} at pixel {row}, {col})")
310324
_update_markers()
@@ -353,8 +367,7 @@ def on_motion(event):
353367
elev_text.set_text("")
354368
fig.canvas.draw_idle()
355369
return
356-
col = int(round(event.xdata))
357-
row = int(round(event.ydata))
370+
row, col = _coord_to_pixel(event.xdata, event.ydata)
358371
if 0 <= row < GRID_H and 0 <= col < GRID_W:
359372
e = elev_vals[row, col]
360373
elev_text.set_text(f"elev: {e:.1f} m ({row}, {col})")

examples/user_guide/26_Contour_Lines.ipynb

Lines changed: 188 additions & 74 deletions
Large diffs are not rendered by default.
77.9 KB
Loading

xrspatial/contour.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -546,9 +546,10 @@ def contours(
546546
547547
return_type : str, default "numpy"
548548
Output format. ``"numpy"`` returns a list of ``(level, coords)``
549-
tuples where *coords* is an Nx2 array of ``(row, col)``
550-
coordinates. ``"geopandas"`` returns a GeoDataFrame with
551-
``level`` and ``geometry`` columns (requires geopandas/shapely).
549+
tuples where *coords* is an Nx2 array of ``(y, x)`` coordinates
550+
in the DataArray's coordinate space. ``"geopandas"`` returns a
551+
GeoDataFrame with ``level`` and ``geometry`` columns (requires
552+
geopandas/shapely).
552553
553554
Returns
554555
-------
@@ -608,6 +609,20 @@ def contours(
608609
)
609610
results = mapper(agg)(agg.data, levels)
610611

612+
# Transform from array indices to the DataArray's coordinate values.
613+
y_coords = agg.coords[agg.dims[0]].values
614+
x_coords = agg.coords[agg.dims[1]].values
615+
y_idx = np.arange(len(y_coords), dtype=np.float64)
616+
x_idx = np.arange(len(x_coords), dtype=np.float64)
617+
618+
transformed = []
619+
for level, coords in results:
620+
out = np.empty_like(coords)
621+
out[:, 0] = np.interp(coords[:, 0], y_idx, y_coords)
622+
out[:, 1] = np.interp(coords[:, 1], x_idx, x_coords)
623+
transformed.append((level, out))
624+
results = transformed
625+
611626
if return_type == "numpy":
612627
return results
613628
elif return_type == "geopandas":

xrspatial/tests/test_contour.py

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,12 @@ def test_single_level_ramp(self):
5252
agg = create_test_raster(data, backend='numpy')
5353
result = contours(agg, levels=[2.5])
5454
assert len(result) > 0
55+
# With res=0.5 x_coords = [0, 0.5, 1.0, 1.5, 2.0, 2.5].
56+
# The crossing between col indices 2 and 3 maps to x = 1.25.
57+
expected_x = 1.25
5558
for level, coords in result:
5659
assert level == 2.5
57-
# All points should have col ~= 2.5 (crossing between col 2 and 3).
58-
np.testing.assert_allclose(coords[:, 1], 2.5, atol=1e-10)
60+
np.testing.assert_allclose(coords[:, 1], expected_x, atol=1e-10)
5961

6062
def test_multiple_levels_ramp(self):
6163
"""Multiple levels on a ramp produce one line per level."""
@@ -90,13 +92,17 @@ def test_peak_contour_at_1_5(self):
9092
agg = create_test_raster(data, backend='numpy')
9193
result = contours(agg, levels=[1.5])
9294
assert len(result) >= 1
95+
y_min = float(agg.coords[agg.dims[0]].values.min())
96+
y_max = float(agg.coords[agg.dims[0]].values.max())
97+
x_min = float(agg.coords[agg.dims[1]].values.min())
98+
x_max = float(agg.coords[agg.dims[1]].values.max())
9399
for level, coords in result:
94100
assert level == 1.5
95-
# All points should be inside the raster bounds.
96-
assert np.all(coords[:, 0] >= 0)
97-
assert np.all(coords[:, 0] <= 4)
98-
assert np.all(coords[:, 1] >= 0)
99-
assert np.all(coords[:, 1] <= 4)
101+
# All points should be inside the raster coordinate bounds.
102+
assert np.all(coords[:, 0] >= y_min - 1e-10)
103+
assert np.all(coords[:, 0] <= y_max + 1e-10)
104+
assert np.all(coords[:, 1] >= x_min - 1e-10)
105+
assert np.all(coords[:, 1] <= x_max + 1e-10)
100106

101107

102108
# ---------------------------------------------------------------------------
@@ -119,9 +125,11 @@ def test_partial_nan(self):
119125
agg = create_test_raster(data, backend='numpy')
120126
result = contours(agg, levels=[2.5])
121127
assert len(result) > 0
128+
# y_coords = [2.0, 1.5, 1.0, 0.5, 0.0] (decreasing with res=0.5).
129+
# NaN row is row 0 (y=2.0). All contour points must stay at y <= 1.5.
130+
nan_row_y = agg.coords[agg.dims[0]].values[0]
122131
for level, coords in result:
123-
# No contour point should be in the NaN row.
124-
assert np.all(coords[:, 0] >= 1.0 - 1e-10)
132+
assert np.all(coords[:, 0] < nan_row_y + 1e-10)
125133

126134

127135
# ---------------------------------------------------------------------------
@@ -367,8 +375,22 @@ def test_matches_skimage(self):
367375
assert len(our_result) > 0
368376
assert len(sk_contours) > 0
369377

370-
# Collect all our contour points into a set for proximity checking.
371-
our_points = np.vstack([coords for _, coords in our_result])
378+
# Transform our coordinate-space points back to array indices for
379+
# comparison with skimage (which returns array index coordinates).
380+
y_coords = agg.coords[agg.dims[0]].values
381+
x_coords = agg.coords[agg.dims[1]].values
382+
y_idx = np.arange(len(y_coords), dtype=np.float64)
383+
x_idx = np.arange(len(x_coords), dtype=np.float64)
384+
385+
our_points_idx = []
386+
for _, coords in our_result:
387+
pts = np.empty_like(coords)
388+
pts[:, 0] = np.interp(coords[:, 0], y_coords[::-1], y_idx[::-1]) \
389+
if y_coords[0] > y_coords[-1] \
390+
else np.interp(coords[:, 0], y_coords, y_idx)
391+
pts[:, 1] = np.interp(coords[:, 1], x_coords, x_idx)
392+
our_points_idx.append(pts)
393+
our_points = np.vstack(our_points_idx)
372394

373395
# Every skimage contour point should be close to some point of ours.
374396
for sk_line in sk_contours:

0 commit comments

Comments
 (0)