Skip to content

Commit ccfa6bd

Browse files
authored
Fix multi-metric output mislabeling in glcm_texture (#1106) (#1107)
The kernel always writes output slots in VALID_METRICS order, but coordinate labels were set from the user's list. When metrics were requested in non-standard order (e.g. ['entropy', 'contrast']), result.sel(metric='contrast') returned entropy values. Added _sorted_metrics() to reorder the user's list to match the kernel's output order. 10 new tests covering various orderings.
1 parent 8ae9961 commit ccfa6bd

File tree

2 files changed

+177
-0
lines changed

2 files changed

+177
-0
lines changed

xrspatial/glcm.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,11 @@ def glcm_texture(
105105
f"must be one of {VALID_METRICS}"
106106
)
107107

108+
# Sort metrics to match the kernel's output order (VALID_METRICS order).
109+
# Without this, coordinate labels would be wrong when the user requests
110+
# metrics in a different order (e.g. ['entropy', 'contrast']).
111+
metrics = _sorted_metrics(metrics)
112+
108113
mapper = ArrayTypeFunctionMapping(
109114
numpy_func=_glcm_numpy,
110115
cupy_func=_glcm_cupy,
@@ -278,6 +283,16 @@ def _metric_flags(metrics):
278283
return flags
279284

280285

286+
def _sorted_metrics(metrics):
287+
"""Return *metrics* sorted in VALID_METRICS order.
288+
289+
The numba kernel always writes output slots in VALID_METRICS order,
290+
so coordinate labels must follow the same ordering.
291+
"""
292+
order = {m: i for i, m in enumerate(VALID_METRICS)}
293+
return sorted(metrics, key=lambda m: order[m])
294+
295+
281296
def _run_glcm_on_quantized(quantized, metrics, window_size, levels,
282297
distance, angle):
283298
"""Run GLCM computation on pre-quantized int32 data.
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
"""Tests for issue #1106: multi-metric output ordering bug.
2+
3+
When glcm_texture() receives metrics in non-standard order, the coordinate
4+
labels must still match the actual data. The kernel writes output slots in
5+
VALID_METRICS order, so the labels need to follow.
6+
"""
7+
try:
8+
import dask.array as da
9+
except ImportError:
10+
da = None
11+
12+
import numpy as np
13+
import pytest
14+
import xarray as xr
15+
16+
from xrspatial.glcm import glcm_texture, VALID_METRICS, _sorted_metrics
17+
from xrspatial.tests.general_checks import (
18+
create_test_raster,
19+
dask_array_available,
20+
)
21+
22+
23+
# ---- _sorted_metrics helper ----
24+
25+
def test_sorted_metrics_standard_order():
26+
assert _sorted_metrics(['contrast', 'entropy']) == ['contrast', 'entropy']
27+
28+
29+
def test_sorted_metrics_reversed():
30+
assert _sorted_metrics(['entropy', 'contrast']) == ['contrast', 'entropy']
31+
32+
33+
def test_sorted_metrics_all():
34+
shuffled = list(reversed(VALID_METRICS))
35+
assert _sorted_metrics(shuffled) == list(VALID_METRICS)
36+
37+
38+
def test_sorted_metrics_single():
39+
assert _sorted_metrics(['homogeneity']) == ['homogeneity']
40+
41+
42+
# ---- Core regression test: label matches data ----
43+
44+
@pytest.fixture
45+
def random_8x8():
46+
rng = np.random.default_rng(99)
47+
return rng.random((8, 8))
48+
49+
50+
def test_reversed_order_matches_solo(random_8x8):
51+
"""Requesting metrics in reversed order should still label them correctly."""
52+
agg = create_test_raster(random_8x8)
53+
54+
contrast_solo = glcm_texture(agg, metric='contrast', window_size=3,
55+
levels=16, angle=0)
56+
homogeneity_solo = glcm_texture(agg, metric='homogeneity', window_size=3,
57+
levels=16, angle=0)
58+
59+
# Reversed order: homogeneity before contrast
60+
multi = glcm_texture(agg, metric=['homogeneity', 'contrast'],
61+
window_size=3, levels=16, angle=0)
62+
63+
np.testing.assert_allclose(
64+
multi.sel(metric='contrast').values,
65+
contrast_solo.values,
66+
rtol=1e-10,
67+
)
68+
np.testing.assert_allclose(
69+
multi.sel(metric='homogeneity').values,
70+
homogeneity_solo.values,
71+
rtol=1e-10,
72+
)
73+
74+
75+
def test_arbitrary_order_matches_solo(random_8x8):
76+
"""Any permutation of metrics should label outputs correctly."""
77+
agg = create_test_raster(random_8x8)
78+
metrics_order = ['entropy', 'contrast', 'homogeneity']
79+
80+
multi = glcm_texture(agg, metric=metrics_order, window_size=3,
81+
levels=16, angle=0)
82+
83+
for m in metrics_order:
84+
solo = glcm_texture(agg, metric=m, window_size=3, levels=16, angle=0)
85+
np.testing.assert_allclose(
86+
multi.sel(metric=m).values,
87+
solo.values,
88+
rtol=1e-10, equal_nan=True,
89+
)
90+
91+
92+
def test_all_metrics_reversed(random_8x8):
93+
"""All six metrics reversed should still produce correct labels."""
94+
agg = create_test_raster(random_8x8)
95+
reversed_metrics = list(reversed(VALID_METRICS))
96+
97+
multi = glcm_texture(agg, metric=reversed_metrics, window_size=3,
98+
levels=16, angle=0)
99+
100+
for m in VALID_METRICS:
101+
solo = glcm_texture(agg, metric=m, window_size=3, levels=16, angle=0)
102+
np.testing.assert_allclose(
103+
multi.sel(metric=m).values,
104+
solo.values,
105+
rtol=1e-10, equal_nan=True,
106+
)
107+
108+
109+
def test_standard_order_still_works(random_8x8):
110+
"""Standard order (which worked before the fix) should still be fine."""
111+
agg = create_test_raster(random_8x8)
112+
metrics = ['contrast', 'dissimilarity']
113+
114+
multi = glcm_texture(agg, metric=metrics, window_size=3,
115+
levels=16, angle=0)
116+
117+
for m in metrics:
118+
solo = glcm_texture(agg, metric=m, window_size=3, levels=16, angle=0)
119+
np.testing.assert_allclose(
120+
multi.sel(metric=m).values,
121+
solo.values,
122+
rtol=1e-10, equal_nan=True,
123+
)
124+
125+
126+
def test_angle_none_with_reversed_order(random_8x8):
127+
"""angle=None averaging should also respect metric ordering."""
128+
agg = create_test_raster(random_8x8)
129+
metrics = ['energy', 'contrast']
130+
131+
multi = glcm_texture(agg, metric=metrics, window_size=3, levels=16)
132+
133+
for m in metrics:
134+
solo = glcm_texture(agg, metric=m, window_size=3, levels=16)
135+
np.testing.assert_allclose(
136+
multi.sel(metric=m).values,
137+
solo.values,
138+
rtol=1e-10, equal_nan=True,
139+
)
140+
141+
142+
# ---- Dask backend ----
143+
144+
@dask_array_available
145+
def test_dask_reversed_order_matches_numpy(random_8x8):
146+
"""Dask backend with reversed metric order should match numpy results."""
147+
numpy_agg = create_test_raster(random_8x8)
148+
dask_agg = create_test_raster(random_8x8, backend='dask+numpy',
149+
chunks=(4, 4))
150+
metrics = ['entropy', 'contrast']
151+
152+
np_result = glcm_texture(numpy_agg, metric=metrics, window_size=3,
153+
levels=16, angle=0)
154+
da_result = glcm_texture(dask_agg, metric=metrics, window_size=3,
155+
levels=16, angle=0)
156+
157+
for m in metrics:
158+
np.testing.assert_allclose(
159+
np_result.sel(metric=m).values,
160+
da_result.sel(metric=m).values,
161+
rtol=1e-10, equal_nan=True,
162+
)

0 commit comments

Comments
 (0)