FIX GLCM gray-level quantization and texture orientation - #50
Merged
Conversation
The input band was converted with np.array(..., dtype="uint8"), which wraps modulo 256 rather than rescaling. On any 16-bit raster - every Landsat, Sentinel and ASTER product, including the bundled example - this destroyed gray-level ordering: DN 3311 became 239, DN 6200 became 56, and the correlation between the source band and the array fed to the co-occurrence matrix was -0.22. Texture was computed on scrambled data. Replace it with an explicit global linear quantization to `levels` gray levels. The scaling uses whole-image extrema so texture values stay comparable across the scene, which is what makes the texture contrast between lithological units meaningful. Monotonic, so gray-level ordering is preserved (correlation > 0.999 on the same band). Default to 64 levels rather than 256. A WxW window supplies only (W-1)*W*2 ordered pairs - 12 for W=3 - and spreading 12 pairs over a 256x256 matrix populates 0.018% of its cells, so the Haralick statistics describe matrix sparsity rather than surface texture. 32-64 levels is the standard working range for windowed GLCM. Expose `distances` and `angles`, averaging the property over every pair. The previous hardcoded [1], [0] measured texture only east-west, making a roughness map depend on scene orientation. The four-angle default is rotation invariant; a single angle stays available for deliberately directional work such as bedding, foliation or lineament fabric. Center the analysis window on its pixel by default, with reflect padding. The window was anchored at (i, j) and extended down and to the right, which displaced the whole texture map by (W-1)//2 pixels relative to the source grid - a 210 m offset at 30 m resolution with W=15, and a real georeferencing error once the result is overlaid on a map. The legacy anchoring remains reachable via centered=False. Also accept `property` as the correct spelling while keeping `propery` working, and replace the per-row print with a module logger. Update docs/glcm.md, which documented the cast as "quantization" and the window offset as intended behavior.
This was referenced Aug 20, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
GLCMCalculator.__init__converted the input band with:np.array(..., dtype="uint8")on integer input performs a modular wraparound, not a rescale and not a clip. Any value above 255 wraps modulo 256, so gray-level ordering is destroyed rather than compressed. This affects every 16-bit raster — Landsat, Sentinel, ASTER — including the repository's own example data.Measured on
example/data/nir.tif(int16, range 23–6200):Two radiometrically adjacent pixels (DN 255 and 256) land at opposite ends of the gray-level range. Every texture value the tool produced on 16-bit input was computed on effectively scrambled data.
Changes
1. Explicit global quantization. Linear rescale to
levelsgray levels, monotonic, so ordering is preserved — correlation with the source band rises from −0.22 to 0.9995 on the same raster. Scaling uses whole-image extrema, not per-window: a texture value in one part of the scene must be comparable with one elsewhere, since in lithological discrimination the signal is the texture contrast between units. Constant bands map to zeros; non-finite pixels are excluded from the range.2.
levelsdefaults to 64, not 256. AW×Wwindow supplies only(W-1)·W·2ordered pairs — 12 forW=3. Spreading 12 pairs over a 256×256 matrix populates 0.018% of its cells, socontrastandcorrelationend up describing matrix sparsity rather than surface texture. 32–64 levels is the standard working range for windowed GLCM.3.
distancesandanglesexposed, results averaged over every pair. The previous hardcoded[1], [0]measured texture only east–west, so a surface-roughness map depended on how the scene happened to be oriented. The four-angle default is rotation invariant. A single angle remains available, which is what you want for deliberately directional targets — bedding traces, foliation, dune crests, lineament fabric.4. Window centered on its pixel by default, with reflect padding. The window was anchored at
(i, j)and extended down and to the right, displacing the entire texture map by(W-1)//2pixels relative to the source grid. At 30 m Landsat resolution withW=15that is a 210 m offset — a georeferencing error the moment the result is overlaid on a geological map or exported as a georeferenced raster. Reflect padding also means border pixels get a full neighbourhood instead of a truncated one.centered=Falserestores the previous behaviour.5. Smaller items from the issue:
propertyaccepted as the correct spelling withproperystill working (the #32 fix had not landed onmain); the per-rowprintreplaced with a moduleloggingcall at DEBUG level.Behaviour changes
Deliberate, and documented in
docs/glcm.md:len(distances) × len(angles), so the default is ~4× the previous cost. Noted in the docs.The public constructor signature is unchanged for existing positional and keyword calls; every new parameter is keyword-only with a default.
Documentation
docs/glcm.mddescribed the cast as "Quantization Baseline" and the window offset as intended behaviour, and its formulation section assumed input already lay in[0, G-1]with nothing enforcing it. All three are corrected, with the reasoning for thelevelsdefault and the registration consequence ofcentered=Falsewritten out.Tests
322 pass (306 before, 16 new). New coverage:
levelshonored across 2–256; constant band maps to zeros; quantization is global rather than per-window.levels/distances/angles.process()writes nothing to stdout.The existing
_expected_glcm_valuereference helper was updated to mirror the corrected pipeline, and the border-behaviour tests were split into a centered case and acentered=Falselegacy case.Verified end-to-end on a 64×64 crop of
example/data/nir.tif: finite output, quantization correlation 0.9995, silent stdout.