Skip to content

Commit 32f28da

Browse files
authored
Fix KDE all-zero output with descending-coordinate templates (#1199)
* Fix unbounded allocation DoS and VRT path traversal in geotiff Two security fixes for the geotiff subpackage: 1. Add a configurable max_pixels guard to read_to_array() and all internal read functions (_read_strips, _read_tiles, _read_cog_http). A crafted TIFF with fabricated header dimensions could previously trigger multi-TB allocations. The default limit is 1 billion pixels (~4 GB for float32 single-band), overridable via max_pixels kwarg. Fixes #1184. 2. Canonicalize VRT source filenames with os.path.realpath() after resolving relative paths. Previously, a VRT file with "../" in SourceFilename could read arbitrary files outside the VRT directory. Fixes #1185. * Fix VRT parser test failure on Windows os.path.realpath() converts Unix-style paths to Windows paths on Windows (e.g. /data/tile.tif becomes D:\data\tile.tif). Use os.path.realpath() in the assertion so it matches the production code's canonicalization on all platforms. * Fix KDE all-zero output with descending-coordinate templates (#1198) The bounding-box index calculation in _kde_cpu and _line_density_cpu divided by dx/dy to convert coordinate offsets to pixel indices. When dy or dx was negative (descending coordinates, common for north-up rasters), the division flipped lo/hi so the inner loops never executed, producing all-zero output. Fix: compute both index endpoints and use min/max to get the correct lo and hi regardless of spacing sign.
1 parent 476e0c5 commit 32f28da

File tree

2 files changed

+175
-8
lines changed

2 files changed

+175
-8
lines changed

xrspatial/kde.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -118,10 +118,16 @@ def _kde_cpu(xs, ys, ws, out, x0, y0, dx, dy, bw, kernel_id):
118118
w = ws[p]
119119

120120
# Pixel range that falls within the cutoff.
121-
col_lo = int(max(0, (px - cutoff - x0) / dx))
122-
col_hi = int(min(cols - 1, (px + cutoff - x0) / dx)) + 1
123-
row_lo = int(max(0, (py - cutoff - y0) / dy))
124-
row_hi = int(min(rows - 1, (py + cutoff - y0) / dy)) + 1
121+
# Compute both endpoints and use min/max so that negative
122+
# spacing (descending coordinates) still produces lo <= hi.
123+
c_a = int((px - cutoff - x0) / dx)
124+
c_b = int((px + cutoff - x0) / dx)
125+
col_lo = max(0, min(c_a, c_b))
126+
col_hi = min(cols - 1, max(c_a, c_b)) + 1
127+
r_a = int((py - cutoff - y0) / dy)
128+
r_b = int((py + cutoff - y0) / dy)
129+
row_lo = max(0, min(r_a, r_b))
130+
row_hi = min(rows - 1, max(r_a, r_b)) + 1
125131

126132
for r in range(row_lo, row_hi):
127133
cy = y0 + r * dy
@@ -200,10 +206,14 @@ def _line_density_cpu(x1s, y1s, x2s, y2s, ws, out,
200206
px = ax + t * (bx - ax)
201207
py = ay + t * (by - ay)
202208

203-
col_lo = int(max(0, (px - cutoff - x0) / dx))
204-
col_hi = int(min(cols - 1, (px + cutoff - x0) / dx)) + 1
205-
row_lo = int(max(0, (py - cutoff - y0) / dy))
206-
row_hi = int(min(rows - 1, (py + cutoff - y0) / dy)) + 1
209+
c_a = int((px - cutoff - x0) / dx)
210+
c_b = int((px + cutoff - x0) / dx)
211+
col_lo = max(0, min(c_a, c_b))
212+
col_hi = min(cols - 1, max(c_a, c_b)) + 1
213+
r_a = int((py - cutoff - y0) / dy)
214+
r_b = int((py + cutoff - y0) / dy)
215+
row_lo = max(0, min(r_a, r_b))
216+
row_hi = min(rows - 1, max(r_a, r_b)) + 1
207217

208218
for r in range(row_lo, row_hi):
209219
cy = y0 + r * dy

xrspatial/tests/test_kde.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,163 @@ def test_template_not_2d_raises(self):
198198
kde([0], [0], bandwidth=1.0, template=t)
199199

200200

201+
# ---------------------------------------------------------------------------
202+
# Descending coordinate templates (#1198)
203+
# ---------------------------------------------------------------------------
204+
205+
class TestDescendingCoordinates:
206+
"""KDE must produce correct results when template coords are descending.
207+
208+
Geospatial rasters commonly have row 0 at the top (descending y).
209+
Before the fix in #1198, negative dy/dx caused the bounding-box
210+
index math to produce lo > hi, so the inner loops never ran.
211+
"""
212+
213+
@pytest.fixture
214+
def asc_template(self):
215+
return xr.DataArray(
216+
np.zeros((16, 16), dtype=np.float64),
217+
dims=['y', 'x'],
218+
coords={
219+
'y': np.linspace(-4, 4, 16),
220+
'x': np.linspace(-4, 4, 16),
221+
},
222+
)
223+
224+
@pytest.fixture
225+
def desc_y_template(self):
226+
"""Row 0 = top (descending y, ascending x)."""
227+
return xr.DataArray(
228+
np.zeros((16, 16), dtype=np.float64),
229+
dims=['y', 'x'],
230+
coords={
231+
'y': np.linspace(4, -4, 16),
232+
'x': np.linspace(-4, 4, 16),
233+
},
234+
)
235+
236+
@pytest.fixture
237+
def desc_x_template(self):
238+
"""Ascending y, descending x."""
239+
return xr.DataArray(
240+
np.zeros((16, 16), dtype=np.float64),
241+
dims=['y', 'x'],
242+
coords={
243+
'y': np.linspace(-4, 4, 16),
244+
'x': np.linspace(4, -4, 16),
245+
},
246+
)
247+
248+
@pytest.fixture
249+
def desc_both_template(self):
250+
"""Both axes descending."""
251+
return xr.DataArray(
252+
np.zeros((16, 16), dtype=np.float64),
253+
dims=['y', 'x'],
254+
coords={
255+
'y': np.linspace(4, -4, 16),
256+
'x': np.linspace(4, -4, 16),
257+
},
258+
)
259+
260+
def test_descending_y_nonzero(self, desc_y_template):
261+
result = kde([0.0], [0.0], bandwidth=1.0, template=desc_y_template)
262+
assert float(result.sum()) > 0.0
263+
264+
def test_descending_y_matches_ascending(self, asc_template, desc_y_template):
265+
"""Descending-y result should equal ascending-y result, row-flipped."""
266+
r_asc = kde([0.0], [0.0], bandwidth=1.0, template=asc_template)
267+
r_desc = kde([0.0], [0.0], bandwidth=1.0, template=desc_y_template)
268+
np.testing.assert_allclose(
269+
r_desc.values[::-1], r_asc.values, rtol=1e-12,
270+
)
271+
272+
def test_descending_x_matches_ascending(self, asc_template, desc_x_template):
273+
"""Descending-x result should equal ascending-x result, col-flipped."""
274+
r_asc = kde([0.0], [0.0], bandwidth=1.0, template=asc_template)
275+
r_desc = kde([0.0], [0.0], bandwidth=1.0, template=desc_x_template)
276+
np.testing.assert_allclose(
277+
r_desc.values[:, ::-1], r_asc.values, rtol=1e-12,
278+
)
279+
280+
def test_both_descending_matches_ascending(self, asc_template,
281+
desc_both_template):
282+
r_asc = kde([0.0], [0.0], bandwidth=1.0, template=asc_template)
283+
r_both = kde([0.0], [0.0], bandwidth=1.0, template=desc_both_template)
284+
np.testing.assert_allclose(
285+
r_both.values[::-1, ::-1], r_asc.values, rtol=1e-12,
286+
)
287+
288+
@pytest.mark.parametrize('kernel', ['epanechnikov', 'quartic'])
289+
def test_compact_kernels_descending_y(self, asc_template,
290+
desc_y_template, kernel):
291+
"""Compact kernels match exactly (no cutoff-boundary rounding)."""
292+
pts_x = [0.0, 1.0, -1.0]
293+
pts_y = [0.0, 1.0, -1.0]
294+
r_asc = kde(pts_x, pts_y, bandwidth=1.0, kernel=kernel,
295+
template=asc_template)
296+
r_desc = kde(pts_x, pts_y, bandwidth=1.0, kernel=kernel,
297+
template=desc_y_template)
298+
np.testing.assert_allclose(
299+
r_desc.values[::-1], r_asc.values, rtol=1e-12,
300+
)
301+
302+
def test_gaussian_descending_y_multipoint(self, asc_template,
303+
desc_y_template):
304+
"""Gaussian with multiple points: allow tiny diffs at cutoff edge.
305+
306+
The 4*bw box cutoff means int() truncation can include/exclude
307+
a different fringe pixel when spacing flips sign. The affected
308+
values are near exp(-8) ~ 3e-4, so atol=1e-5 is plenty tight.
309+
"""
310+
pts_x = [0.0, 1.0, -1.0]
311+
pts_y = [0.0, 1.0, -1.0]
312+
r_asc = kde(pts_x, pts_y, bandwidth=1.0, kernel='gaussian',
313+
template=asc_template)
314+
r_desc = kde(pts_x, pts_y, bandwidth=1.0, kernel='gaussian',
315+
template=desc_y_template)
316+
np.testing.assert_allclose(
317+
r_desc.values[::-1], r_asc.values, atol=1e-5,
318+
)
319+
320+
def test_line_density_descending_y_quartic(self, asc_template,
321+
desc_y_template):
322+
"""Compact kernel: exact match with descending y."""
323+
r_asc = line_density([0.0], [0.0], [1.0], [1.0],
324+
bandwidth=0.5, kernel='quartic',
325+
template=asc_template)
326+
r_desc = line_density([0.0], [0.0], [1.0], [1.0],
327+
bandwidth=0.5, kernel='quartic',
328+
template=desc_y_template)
329+
np.testing.assert_allclose(
330+
r_desc.values[::-1], r_asc.values, rtol=1e-12,
331+
)
332+
333+
def test_line_density_descending_y_gaussian(self, asc_template,
334+
desc_y_template):
335+
"""Gaussian: allow tiny diffs at the 4*bw cutoff edge."""
336+
r_asc = line_density([0.0], [0.0], [1.0], [1.0],
337+
bandwidth=0.5, kernel='gaussian',
338+
template=asc_template)
339+
r_desc = line_density([0.0], [0.0], [1.0], [1.0],
340+
bandwidth=0.5, kernel='gaussian',
341+
template=desc_y_template)
342+
np.testing.assert_allclose(
343+
r_desc.values[::-1], r_asc.values, atol=1e-4,
344+
)
345+
346+
def test_line_density_descending_x(self, asc_template, desc_x_template):
347+
r_asc = line_density([0.0], [0.0], [1.0], [1.0],
348+
bandwidth=0.5, kernel='quartic',
349+
template=asc_template)
350+
r_desc = line_density([0.0], [0.0], [1.0], [1.0],
351+
bandwidth=0.5, kernel='quartic',
352+
template=desc_x_template)
353+
np.testing.assert_allclose(
354+
r_desc.values[:, ::-1], r_asc.values, rtol=1e-12,
355+
)
356+
357+
201358
# ---------------------------------------------------------------------------
202359
# Line density
203360
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)