diff --git a/xrspatial/glcm.py b/xrspatial/glcm.py index 01c78e5b..9cf26168 100644 --- a/xrspatial/glcm.py +++ b/xrspatial/glcm.py @@ -105,6 +105,11 @@ def glcm_texture( f"must be one of {VALID_METRICS}" ) + # Sort metrics to match the kernel's output order (VALID_METRICS order). + # Without this, coordinate labels would be wrong when the user requests + # metrics in a different order (e.g. ['entropy', 'contrast']). + metrics = _sorted_metrics(metrics) + mapper = ArrayTypeFunctionMapping( numpy_func=_glcm_numpy, cupy_func=_glcm_cupy, @@ -278,6 +283,16 @@ def _metric_flags(metrics): return flags +def _sorted_metrics(metrics): + """Return *metrics* sorted in VALID_METRICS order. + + The numba kernel always writes output slots in VALID_METRICS order, + so coordinate labels must follow the same ordering. + """ + order = {m: i for i, m in enumerate(VALID_METRICS)} + return sorted(metrics, key=lambda m: order[m]) + + def _run_glcm_on_quantized(quantized, metrics, window_size, levels, distance, angle): """Run GLCM computation on pre-quantized int32 data. diff --git a/xrspatial/tests/test_glcm_metric_order.py b/xrspatial/tests/test_glcm_metric_order.py new file mode 100644 index 00000000..d782ff4f --- /dev/null +++ b/xrspatial/tests/test_glcm_metric_order.py @@ -0,0 +1,162 @@ +"""Tests for issue #1106: multi-metric output ordering bug. + +When glcm_texture() receives metrics in non-standard order, the coordinate +labels must still match the actual data. The kernel writes output slots in +VALID_METRICS order, so the labels need to follow. +""" +try: + import dask.array as da +except ImportError: + da = None + +import numpy as np +import pytest +import xarray as xr + +from xrspatial.glcm import glcm_texture, VALID_METRICS, _sorted_metrics +from xrspatial.tests.general_checks import ( + create_test_raster, + dask_array_available, +) + + +# ---- _sorted_metrics helper ---- + +def test_sorted_metrics_standard_order(): + assert _sorted_metrics(['contrast', 'entropy']) == ['contrast', 'entropy'] + + +def test_sorted_metrics_reversed(): + assert _sorted_metrics(['entropy', 'contrast']) == ['contrast', 'entropy'] + + +def test_sorted_metrics_all(): + shuffled = list(reversed(VALID_METRICS)) + assert _sorted_metrics(shuffled) == list(VALID_METRICS) + + +def test_sorted_metrics_single(): + assert _sorted_metrics(['homogeneity']) == ['homogeneity'] + + +# ---- Core regression test: label matches data ---- + +@pytest.fixture +def random_8x8(): + rng = np.random.default_rng(99) + return rng.random((8, 8)) + + +def test_reversed_order_matches_solo(random_8x8): + """Requesting metrics in reversed order should still label them correctly.""" + agg = create_test_raster(random_8x8) + + contrast_solo = glcm_texture(agg, metric='contrast', window_size=3, + levels=16, angle=0) + homogeneity_solo = glcm_texture(agg, metric='homogeneity', window_size=3, + levels=16, angle=0) + + # Reversed order: homogeneity before contrast + multi = glcm_texture(agg, metric=['homogeneity', 'contrast'], + window_size=3, levels=16, angle=0) + + np.testing.assert_allclose( + multi.sel(metric='contrast').values, + contrast_solo.values, + rtol=1e-10, + ) + np.testing.assert_allclose( + multi.sel(metric='homogeneity').values, + homogeneity_solo.values, + rtol=1e-10, + ) + + +def test_arbitrary_order_matches_solo(random_8x8): + """Any permutation of metrics should label outputs correctly.""" + agg = create_test_raster(random_8x8) + metrics_order = ['entropy', 'contrast', 'homogeneity'] + + multi = glcm_texture(agg, metric=metrics_order, window_size=3, + levels=16, angle=0) + + for m in metrics_order: + solo = glcm_texture(agg, metric=m, window_size=3, levels=16, angle=0) + np.testing.assert_allclose( + multi.sel(metric=m).values, + solo.values, + rtol=1e-10, equal_nan=True, + ) + + +def test_all_metrics_reversed(random_8x8): + """All six metrics reversed should still produce correct labels.""" + agg = create_test_raster(random_8x8) + reversed_metrics = list(reversed(VALID_METRICS)) + + multi = glcm_texture(agg, metric=reversed_metrics, window_size=3, + levels=16, angle=0) + + for m in VALID_METRICS: + solo = glcm_texture(agg, metric=m, window_size=3, levels=16, angle=0) + np.testing.assert_allclose( + multi.sel(metric=m).values, + solo.values, + rtol=1e-10, equal_nan=True, + ) + + +def test_standard_order_still_works(random_8x8): + """Standard order (which worked before the fix) should still be fine.""" + agg = create_test_raster(random_8x8) + metrics = ['contrast', 'dissimilarity'] + + multi = glcm_texture(agg, metric=metrics, window_size=3, + levels=16, angle=0) + + for m in metrics: + solo = glcm_texture(agg, metric=m, window_size=3, levels=16, angle=0) + np.testing.assert_allclose( + multi.sel(metric=m).values, + solo.values, + rtol=1e-10, equal_nan=True, + ) + + +def test_angle_none_with_reversed_order(random_8x8): + """angle=None averaging should also respect metric ordering.""" + agg = create_test_raster(random_8x8) + metrics = ['energy', 'contrast'] + + multi = glcm_texture(agg, metric=metrics, window_size=3, levels=16) + + for m in metrics: + solo = glcm_texture(agg, metric=m, window_size=3, levels=16) + np.testing.assert_allclose( + multi.sel(metric=m).values, + solo.values, + rtol=1e-10, equal_nan=True, + ) + + +# ---- Dask backend ---- + +@dask_array_available +def test_dask_reversed_order_matches_numpy(random_8x8): + """Dask backend with reversed metric order should match numpy results.""" + numpy_agg = create_test_raster(random_8x8) + dask_agg = create_test_raster(random_8x8, backend='dask+numpy', + chunks=(4, 4)) + metrics = ['entropy', 'contrast'] + + np_result = glcm_texture(numpy_agg, metric=metrics, window_size=3, + levels=16, angle=0) + da_result = glcm_texture(dask_agg, metric=metrics, window_size=3, + levels=16, angle=0) + + for m in metrics: + np.testing.assert_allclose( + np_result.sel(metric=m).values, + da_result.sel(metric=m).values, + rtol=1e-10, equal_nan=True, + )