Skip to content

FIX GLCM gray-level quantization and texture orientation - #50

Merged
aradfarahani merged 1 commit into
mainfrom
hotfix/glcm-quantization
Aug 21, 2026
Merged

FIX GLCM gray-level quantization and texture orientation#50
aradfarahani merged 1 commit into
mainfrom
hotfix/glcm-quantization

Conversation

@mkiani12

Copy link
Copy Markdown
Collaborator

Problem

GLCMCalculator.__init__ converted the input band with:

self.nir_image = np.array(self.metadata_bands["nir"]["image_skimage"], dtype="uint8")

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):

DN 3311 -> 239
DN 6200 ->  56
correlation between source band and the array fed to graycomatrix: -0.2248

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 levels gray 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. levels defaults to 64, not 256. A W×W window supplies only (W-1)·W·2 ordered pairs — 12 for W=3. Spreading 12 pairs over a 256×256 matrix populates 0.018% of its cells, so contrast and correlation end up describing matrix sparsity rather than surface texture. 32–64 levels is the standard working range for windowed GLCM.

3. distances and angles exposed, 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)//2 pixels relative to the source grid. At 30 m Landsat resolution with W=15 that 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=False restores the previous behaviour.

5. Smaller items from the issue: property accepted as the correct spelling with propery still working (the #32 fix had not landed on main); the per-row print replaced with a module logging call at DEBUG level.

Behaviour changes

Deliberate, and documented in docs/glcm.md:

  • Texture values change for all inputs, not only 16-bit ones — quantization now always rescales, and the default is 64 levels with four averaged angles.
  • Output is registered half a window differently, since the window is now centered.
  • Runtime scales with 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.md described 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 the levels default and the registration consequence of centered=False written out.

Tests

322 pass (306 before, 16 new). New coverage:

  • Quantization is monotonic and preserves gray-level ordering on values > 255; correlation with a synthetic 16-bit band > 0.99 where the wraparound cast gives < 0.5.
  • levels honored across 2–256; constant band maps to zeros; quantization is global rather than per-window.
  • Angle-averaged texture is rotation invariant on a striped pattern and its 90° rotation, while single-angle texture is not.
  • A single bright pixel produces peak contrast on its own pixel when centered, and displaced by one pixel under the legacy anchoring — the registration fix, asserted directly.
  • Both property spellings accepted, passing both raises, validation of levels/distances/angles.
  • process() writes nothing to stdout.

The existing _expected_glcm_value reference helper was updated to mirror the corrected pipeline, and the border-behaviour tests were split into a centered case and a centered=False legacy case.

Verified end-to-end on a 64×64 crop of example/data/nir.tif: finite output, quantization correlation 0.9995, silent stdout.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants