Fix GeoTIFF localization grid when tie points are rectilinear but unevenly spaced - #48
Open
JF3Env wants to merge 1 commit into
Open
Conversation
JF3Env
force-pushed
the
fix/SIS-000-localization-grid-single-axis-split
branch
from
August 24, 2026 15:48
ada02ed to
79e5182
Compare
`Localization.nonLinear(…)` cannot build a localization grid when the model tie
points are on a rectilinear grid, i.e. when every combination of the distinct
x and y pixel coordinates is present exactly once, but the spacing between those
coordinates is not an integer amount of pixels repeated exactly.
`LocalizationGridBuilder` infers the grid size from the greatest common divisor
of the tie point coordinates. That divisor is much smaller than the actual step
when the step is fractional, and it collapses to 1 when a rounding to integers
makes one step differ from the others by one pixel. Either way the inferred grid
becomes as large as the image and the constructor throws `ArithmeticException`.
The recovery heuristic then splits the tie points in four parts around a
threshold computed per axis. That heuristic was written for Sentinel 1 images,
whose last step is genuinely 18 pixels shorter than the others, and it behaves
badly on a grid that is rectilinear:
* If the spacing is irregular on a single axis, `threshold(…)` returns NaN for
the other axis, the comparisons against it are always false, two of the four
parts receive no point, and `Vector.pick(int[])` throws
`IndexOutOfBoundsException: Range [0 … -1] is not valid`.
* If a part is itself irregular it is split again, but the transform computed
by the recursive call is added to the map of specializations only when it
could be built without splitting. Otherwise the sub-area silently falls back
to an extrapolation of the largest part, far from the tie points it was
supposed to honor.
This affects every ICEYE product. They carry no `ModelPixelScaleTag` and no
`ModelTransformationTag`, so they always take this code path, and their tie
points are at k × (size-1) / (n-1) pixels, sometimes rounded to integers.
Such grids are now built without splitting: the ranks of the distinct pixel
coordinates are used as grid indices, and the linear relationship between pixel
coordinates and ranks is applied before the localization grid. The resulting
transform is continuous and honors every tie point. Grids whose spacing is
irregular for a real reason, such as Sentinel 1, are detected by
`isUniformAfterRounding(…)` and are still handled by splitting, unchanged.
The empty parts of the split are skipped, so the exception above cannot happen
anymore even for the grids that are still split.
Measured on five ICEYE products, the worst distance between a tie point as
declared in the file and as computed by the transform. "Before" is the same code
with only the empty parts skipped, since without that the products cannot be
opened at all. About 0.2 m of the remaining error on the GRD and SLC products,
and about 4 m on the ScanSAR product, is the half pixel between the tie point
convention of those files and `PixelInCell.CELL_CORNER`; it is unchanged by this
commit.
product size spacing before after
ScanSAR 19250 × 19510 fractional 2161.51 m 4.55 m
GRD 20000 × 20000 rounded, both 2.09 m 0.33 m
GRD 20000 × 20000 rounded, both 2.64 m 0.33 m
SLC 34484 × 15342 rounded, alt. 17.09 m 0.34 m
SLC 114644 × 16714 rounded, one 2.23 m 0.16 m
The ScanSAR product is the sample published by ESA at
https://earth.esa.int/eogateway/ftp/missions/sample-data/third-party-missions/iceye/ICEYE-Scan-mode.zip
so this can be reproduced without an ICEYE licence.
`LocalizationTest` covers the spacings of those products, the Sentinel 1
spacing, and a regular grid as a control. The tie points are built from a
non-linear model, otherwise every candidate transform would reproduce them
exactly and the tests would only verify the absence of exception.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AG48J4hdPvWYWd3hFrhZGz
JF3Env
force-pushed
the
fix/SIS-000-localization-grid-single-axis-split
branch
from
August 24, 2026 15:57
79e5182 to
c3d4ab2
Compare
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
Localization.nonLinear(…)cannot build a localization grid when the model tie points are on arectilinear grid — every combination of the distinct x and y pixel coordinates present
exactly once — but the spacing between those coordinates is not an integer amount of pixels
repeated exactly.
LocalizationGridBuilderinfers the grid size from the greatest common divisor of the tie pointcoordinates (
LocalizationGridBuilder.infer(…)).That divisor is much smaller than the actual step when the step is fractional, and it collapses to
1 when a rounding to integers makes one step differ from the others by one pixel. Either way the
inferred grid becomes as large as the image, and the constructor throws
ArithmeticException.The recovery heuristic then splits the tie points in four parts around a threshold computed per
axis. It was written for Sentinel 1, whose last step is genuinely 18 pixels shorter than the
others, and it behaves badly on a rectilinear grid:
Crash. If the spacing is irregular on a single axis,
threshold(…)returnsNaNfor theother axis, the comparisons against it are always false, two of the four parts receive no
point, and
Vector.pick(int[])is called with an empty index array:Silently wrong coordinates. If a part is itself irregular it is split again, but the
transform computed by the recursive call is added to the map of specializations only when it
could be built without splitting — in the
catchbranch the result ofMathTransforms.specialize(…)is returned and never put intoaddTo. The sub-area then fallsback to an extrapolation of the largest part, far from the tie points it was supposed to honor.
Impact
Every ICEYE product takes this path: they carry no
ModelPixelScaleTagand noModelTransformationTag, and their tie points are atk × (size-1) / (n-1)pixels, sometimesrounded to integers. ICEYE GRD and SLC products stay in native SAR geometry and are
"tagged with ground control points (GCP) and rapid positioning capability polynomial coefficients (RPC's)";
SIS has no RPC support, so the tie points are the only georeferencing available to it.
Worst distance between a tie point as declared in the file and as computed by the transform,
measured on five products. Before is this same code with only the empty parts skipped, because
without that the products cannot be opened at all:
About 0.2 m of the remaining error on the GRD and SLC products, and about 4 m on the ScanSAR
product, is the half pixel between the tie point convention of those files
(
GTRasterTypeGeoKey = RasterPixelIsArea) andPixelInCell.CELL_CORNER. It is a constant offset,identical before and after, and out of scope here.
The residual is not evenly distributed. On the 34484 × 15342 SLC, with only the empty parts
skipped, only the diagonal of the tie point grid is honored (residual in metres):
Fix
rectilinearGrid(…)is attempted before the split. When the tie points are a complete rectilineargrid whose distinct coordinates are evenly spaced up to a rounding to integers, the grid is built
without splitting: the ranks of the distinct pixel coordinates are the grid indices, and the
linear relationship between pixel coordinates and ranks is applied before the localization grid.
The transform is then continuous and honors every tie point.
Grids whose spacing is irregular for a real reason — Sentinel 1 — deviate from an evenly spaced
sequence by much more than one pixel, are rejected by
isUniformAfterRounding(…), and are stillhandled by the existing split, unchanged. The empty parts of that split are skipped, so failure
mode 1 cannot happen even for the grids that are still split.
Failure mode 2 is not fixed here. It is reachable only by grids that are still split, for which
I have no sample product, so fixing it would ship less well validated than the rest. It is
described above so it is not lost.
Tests
LocalizationTestcovers the spacings of the products above, the Sentinel 1 spacing, and a regulargrid as a control. The tie points are built from a non-linear model — with a linear model every
candidate transform reproduces the tie points exactly and the tests only verify the absence of
exception, which is why failure mode 2 is invisible to such a test.
maintestRegularGridtestGridWithFractionalSpacingIndexOutOfBoundsExceptiontestGridWithIrregularStepOnOneAxisIndexOutOfBoundsExceptiontestGridWithIrregularStepOnBothAxesIndexOutOfBoundsExceptiontestGridWithAlternatingStepsIndexOutOfBoundsExceptiontestGridWithShorterLastSteptestGridWithShorterLastStepOnOneAxisIndexOutOfBoundsExceptionFull
:endorsed:teston this branch: 3786 tests, 0 failures, 275 skipped.How to verify
Without any data. The tests construct the tie points directly:
Use JDK 24: building SIS requires Java 22 or later, and the pinned Gradle 8.14.4 runs only on
JDK 17–24.
With a public product. ESA publishes ICEYE sample products, no licence or account required.
The ScanSAR one contains a GeoTIFF and is the first row of the table above:
That file is 19250 × 19510 pixels. Its
ModelTiepointTagholds 1560 tie points on a 39 × 40 grid:39 distinct x coordinates spaced by exactly 506.5526315789… pixels and 40 distinct y
coordinates spaced by exactly 500.2307692307… pixels.
ModelPixelScaleTagandModelTransformationTagare absent,RPCCoefficientTagis present. GDAL exposes the tie points asGCPs, so
gdalinfoon it is a quick independent check of those numbers.Opening it with
GeoTiffStoreand callinggetGridGeometry():mainIndexOutOfBoundsException: Range [0 … -1] is not valid.The ~4 m floor in the last two rows is the half pixel of anchor convention mentioned above; on this
product one pixel is about 8 m.
Checking that the tie points are honored. This transforms each tie point of a GeoTIFF through
the transform SIS built for it, and prints the worst disagreement:
References
Localization.javafix/SIS-000-localization-grid-single-axis-split(the branch name predates the wider fix; it is kept so this PR is not closed by a rename)If a Jira issue is wanted before this is applied, I will file one and rename the branch accordingly.
Disclosure: this contribution was prepared with the assistance of an AI coding tool (Claude Code).
The defect was found while integrating Apache SIS into a downstream SAR application; the fix, the
tests, and every measurement above were produced and verified against the project's own test suite
and against real products, and a human maintainer of that downstream project reviewed and
authorised this submission. Please review it as you would any other outside contribution.