Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/sweep-accuracy-state.csv
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ fire,2026-04-30,,,,All ops per-pixel (no accumulation/stencil/projected distance
flood,2026-04-30,,MEDIUM,2;5,"MEDIUM (not fixed): dask backend preserves float32 input dtype while numpy promotes to float64 in flood_depth and curve_number_runoff; DataArray inputs for curve_number, mannings_n bypass scalar > 0 (and CN <= 100) range validation, silently producing NaN/garbage."
focal,2026-03-30T13:00:00Z,1092,,,
geotiff,2026-04-23,1247,HIGH,1;3;4;5,HIGH fixed: CPU fp_predictor_decode wrong byte-lane layout for multi-sample predictor=3 (GPU was correct). MEDIUMs also fixed on same PR: eager and streaming writers emit LONG8 strip/tile offsets in BigTIFF output (were LONG); VRT read honors AREA_OR_POINT=Point; VRT nodata cast uses source dtype instead of float32. LOW fixed: duplicate LERC codec block removed from _compression.py.
glcm,2026-05-01,1408,HIGH,2,"angle=None averaged NaN as 0, masking no-valid-pairs as zero texture; fixed via nanmean-style averaging"
hillshade,2026-04-10T12:00:00Z,,,,"Horn's method correct. All backends consistent. NaN propagation correct. float32 adequate for [0,1] output."
hydro,2026-04-30,,LOW,1,Only LOW: twi log(0)=-inf if fa=0 (out-of-contract); MFD weighted sum no Kahan (negligible). No CRIT/HIGH issues.
kde,2026-04-13T12:00:00Z,1198,,,kde/line_density return zeros for descending-y templates. Fix in PR #1199.
Expand Down
18 changes: 13 additions & 5 deletions xrspatial/glcm.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,17 +324,25 @@ def _run_glcm_on_quantized(quantized, metrics, window_size, levels,
dx *= distance
_glcm_numba_kernel(quantized, out, flags, levels, half, dy, dx)
else:
out = np.zeros((n_metrics, h, w), dtype=np.float64)
# Average across all four angles using nanmean-style accumulation:
# only count angles that produced a valid (non-NaN) value at each
# pixel. If no angle produced a valid value (e.g. all-NaN window,
# 1x1 raster), the output stays NaN -- otherwise the user could
# not distinguish "metric is 0" from "no valid data".
sums = np.zeros((n_metrics, h, w), dtype=np.float64)
counts = np.zeros((n_metrics, h, w), dtype=np.int32)
for a in _ANGLE_OFFSETS:
tmp = np.full((n_metrics, h, w), np.nan, dtype=np.float64)
dy, dx = _ANGLE_OFFSETS[a]
dy *= distance
dx *= distance
_glcm_numba_kernel(quantized, tmp, flags, levels, half, dy, dx)
nan_mask = np.isnan(tmp)
tmp[nan_mask] = 0.0
out += tmp
out /= 4.0
valid = ~np.isnan(tmp)
sums[valid] += tmp[valid]
counts[valid] += 1
out = np.full((n_metrics, h, w), np.nan, dtype=np.float64)
any_valid = counts > 0
out[any_valid] = sums[any_valid] / counts[any_valid]

return out

Expand Down
59 changes: 46 additions & 13 deletions xrspatial/tests/test_glcm.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,12 +198,21 @@ def test_checkerboard_has_high_contrast(checkerboard):
# ---- NaN handling ----

def test_all_nan_raster():
# Regression test for issue #1408: an all-NaN window has no valid pairs,
# so every metric should be NaN. Both the single-angle and angle=None
# paths must agree -- previously angle=None returned 0.0 because the
# averaging step replaced per-angle NaN with 0 before dividing by 4.
data = np.full((6, 6), np.nan)
agg = create_test_raster(data)
result = glcm_texture(agg, metric='contrast', window_size=3, levels=8)
# No valid pixel pairs -> all NaN (or zero from averaging)
# Just verify it doesn't crash and returns correct shape
assert result.shape == (6, 6)
for angle in [None, 0, 45, 90, 135]:
for m in VALID_METRICS:
result = glcm_texture(agg, metric=m, window_size=3, levels=8,
angle=angle)
assert result.shape == (6, 6)
assert np.all(np.isnan(result.values)), (
f"metric={m} angle={angle} produced finite values "
f"on all-NaN input: {np.unique(result.values)}"
)


def test_partial_nan(texture_data):
Expand All @@ -217,14 +226,40 @@ def test_partial_nan(texture_data):
assert np.isfinite(result.values[3, 3])


def test_angle_none_partial_nan_uses_valid_angles():
# Regression test for issue #1408: when only some angles produce a valid
# value at a pixel, the angle=None result should be the mean of the valid
# angles only -- not (sum / 4) with NaN treated as 0.
rng = np.random.default_rng(7)
data = rng.random((8, 8))
agg = create_test_raster(data)

avg = glcm_texture(agg, metric='correlation', window_size=3, levels=4)
per_angle = []
for ang in [0, 45, 90, 135]:
r = glcm_texture(agg, metric='correlation', window_size=3,
levels=4, angle=ang)
per_angle.append(r.values)
expected = np.nanmean(per_angle, axis=0)
np.testing.assert_allclose(avg.values, expected, rtol=1e-10,
equal_nan=True)


# ---- Single-cell / tiny rasters ----

def test_single_cell():
# Regression test for issue #1408: a 1x1 raster has no valid pairs at any
# angle, so the result must be NaN for both single-angle and angle=None.
data = np.array([[5.0]])
agg = create_test_raster(data)
# Window extends beyond array; no valid pairs
result = glcm_texture(agg, metric='contrast', window_size=3, levels=4)
assert result.shape == (1, 1)
for angle in [None, 0, 45, 90, 135]:
result = glcm_texture(agg, metric='contrast', window_size=3,
levels=4, angle=angle)
assert result.shape == (1, 1)
assert np.isnan(result.values[0, 0]), (
f"angle={angle} produced finite value {result.values[0, 0]} "
"on 1x1 raster (no valid pairs)"
)


# ---- Angle parameter ----
Expand All @@ -240,19 +275,17 @@ def test_each_angle_produces_output(texture_data):

def test_angle_none_averages(texture_data):
agg = create_test_raster(texture_data)
# angle=None should be the mean of all four angles
# angle=None averages over angles that produced a valid (non-NaN) value
# at each pixel. Pixels where every angle is NaN stay NaN.
avg = glcm_texture(agg, metric='contrast', window_size=3, levels=16)
per_angle = []
for ang in [0, 45, 90, 135]:
r = glcm_texture(agg, metric='contrast', window_size=3,
levels=16, angle=ang)
per_angle.append(r.values)
expected = np.nanmean(per_angle, axis=0)
# Replace NaN->0 to match the averaging in the implementation
for i in range(4):
per_angle[i] = np.where(np.isnan(per_angle[i]), 0, per_angle[i])
expected = np.mean(per_angle, axis=0)
np.testing.assert_allclose(avg.values, expected, rtol=1e-10)
np.testing.assert_allclose(avg.values, expected, rtol=1e-10,
equal_nan=True)


# ---- All metrics produce finite values ----
Expand Down
Loading