geotiff: surface rotated_affine 6-tuple on DataArray attrs (#2129)#2157
Merged
Conversation
Reads opened with ``allow_rotated=True`` now emit ``attrs['rotated_affine']`` as a rasterio-style 6-tuple so callers can recover the rotated mapping without reaching into reader internals. The attr is read-only: ``to_geotiff`` keeps dropping the rotation on round-trip until the writer learns to emit ``ModelTransformationTag`` (#2115 follow-up). Bumps the attrs contract version to 4. Existing keys keep their pre-v4 shape; only the additive ``rotated_affine`` is new.
brendancol
commented
May 20, 2026
Contributor
Author
brendancol
left a comment
There was a problem hiding this comment.
PR Review: geotiff: surface rotated_affine 6-tuple on DataArray attrs (#2129)
Blockers
None.
Suggestions
- VRT backend does not emit
rotated_affinefor the same condition.xrspatial/geotiff/_backends/vrt.py:277detects rotated GDAL GeoTransforms via_vrt_is_rotated = (gt[2] != 0.0 or gt[4] != 0.0)and routes the array into therotated_droppedbucket on both the eager (line 336) and chunked (line 842) builds. NeitherGeoTIFFMetadata(...)site populates the newrotated_affinefield, so a VRT user who opens a rotated VRT withallow_rotated=Truestill cannot recover the 6-tuple. Same gap #2129 closes for theModelTransformationTagpath. The 6-tuple is reconstructable fromvrt.geo_transformvia the existing_gdal_geotransform_to_affine_tuplehelper (used by the non-rotated VRT branch at line 597). Addingrotated_affine=_gdal_geotransform_to_affine_tuple(vrt.geo_transform) if _vrt_is_rotated else Noneto both builds keeps the backends consistent.
Nits
-
test_geotiff_metadata_2139.py::_representative_attrs_dicts(line 237) drives the symmetric round-trip tests. The new attr is intentionally asymmetric (read-only), so it does not belong there — but a short comment near the helper saying so would prevent a future contributor from adding it and hitting a confusing failure. - No GPU-backed end-to-end test for the new attr. The GPU read path shares the marshalling step with the eager/dask paths so the unit tests cover the logic, but a
@_gpu_onlysibling would close the matrix.
What looks good
- Flows through the existing
GeoTIFFMetadata/metadata_to_attrsstep rather than adding a parallel code path. - Read-only round-trip contract is explicit and pinned:
attrs_to_metadatadoes not parse the attr back, andtest_attrs_to_metadata_drops_rotated_affinewill fail loudly if that ever changes. tuple(src_t.rotated_affine)cast is defensive against a parser change to list/ndarray;test_rotated_affine_is_tuple_not_listpins the type.- Contract version bump from 3 to 4 is paired with both pin-test updates and an
attrs_contract.rstversioning note. test_open_geotiff_axis_aligned_omits_rotated_affineis a useful negative test: an axis-alignedModelTransformationTagstill rides onattrs['transform']and the new attr stays off.
Checklist
- Algorithm matches reference (N/A — metadata)
- All implemented backends consistent (caveat: VRT gap above)
- NaN handling correct (N/A)
- Edge cases tested (rotated with/without CRS, plain no-georef, axis-aligned, dask, tuple type, writer-side drop)
- Dask chunk boundaries handled (
test_open_geotiff_rotated_emits_rotated_affine_dask) - No premature materialization
- Benchmark exists or not needed (not needed)
- README feature matrix updated (N/A — no new function)
- Docstrings updated (
open_geotiff+attrs_contract.rst)
Closes the cross-backend gap surfaced in the PR review: the VRT backend already detects rotated GDAL GeoTransforms via ``_vrt_is_rotated`` and lands the array in ``georef_status='rotated_dropped'``, but neither ``GeoTIFFMetadata(...)`` build site populated the new ``rotated_affine`` field. A rotated VRT opened with ``allow_rotated=True`` now surfaces the rotated 6-tuple alongside the rotated TIFF path, reconstructed via the existing ``_gdal_geotransform_to_affine_tuple`` helper. Also adds: * eager + chunked VRT tests, plus an axis-aligned VRT negative test, to ``test_rotated_affine_attr_2129.py``; * a docstring note on ``_representative_attrs_dicts`` explaining why ``rotated_affine`` is intentionally excluded from the symmetric ``metadata_to_attrs`` / ``attrs_to_metadata`` round-trip fixture.
brendancol
commented
May 20, 2026
Contributor
Author
brendancol
left a comment
There was a problem hiding this comment.
PR Review: follow-up pass on a4c48d5
Blockers
None.
Suggestions
None.
Nits
None.
What looks good
- VRT rotated_affine gap closed at both build sites (
_backends/vrt.py:343eager,_backends/vrt.py:854chunked);_gdal_geotransform_to_affine_tuplecorrectly maps GDAL ordering to rasterio Affine ordering, so the VRT surface matches the non-VRTModelTransformationTagsurface. - The new
_vrt_rotated_affineis computed only when_vrt_is_rotated; gating mirrors the existingrotated_droppedgate so the two attrs stay in lockstep. - VRT tests added (eager + chunked + axis-aligned negative) and the symmetric round-trip helper now carries an inline note explaining why
rotated_affineis excluded — so a future contributor does not waste cycles wondering. - Lazy import of
_gdal_geotransform_to_affine_tupleis already in scope at both modified sites (existing imports at lines 231 and 603).
Disposition of original review
- Suggestion (VRT gap): fixed in a4c48d5.
- Nit (
_representative_attrs_dictsdoc note): fixed in a4c48d5. - Nit (GPU-marked test): dismissed. The GPU read path shares
_populate_attrs_from_geo_infowith eager/dask, whichtest_rotated_optin_emits_rotated_affine_tuplealready exercises directly. A GPU-marked sibling would only confirm the shared helper still runs under cupy, which the four other GPU rotated tests in the suite already cover at theattrs['crs']/attrs['transform']level.
Nothing left to fix.
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.
Closes #2129.
Summary
allow_rotated=Truenow emitattrs['rotated_affine']as a rasterio-style 6-tuple(a, b, c, d, e, f)so callers can recover the rotated mapping without reaching intogeo_info.transform.ModelTransformationTag; plain no-georef reads and axis-aligned reads do not grow the attr.attrs_to_metadata(the writer-side boundary parser) intentionally dropsrotated_affine, soto_geotiffkeeps writing a plain no-georef file until the writer learns to emitModelTransformationTag(allow_rotated does not work for rotated GeoTIFFs #2115 follow-up).Backend coverage
All four read backends share the same marshalling step (
_populate_attrs_from_geo_info→geo_info_to_metadata→metadata_to_attrs), so numpy, cupy, dask+numpy, and dask+cupy paths all emit the new attr without per-backend code.Test plan
test_rotated_affine_attr_2129.pycovers the rotated path (with and without CRS), the plain no-georef path, axis-aligned reads, tuple type guarantee, dask path, and the writer round-trip drop contract.test_allow_rotated_*suites still pass — CRS-drop and no-georef behaviour unchanged.test_attrs_contract_version_1984.pyandtest_georef_status_2136.pyupdated to track v4.xrspatial/geotiff/tests/suite: 4453 passed, 1 pre-existing unrelated failure ontest_lowlevel_write_pushdown_2138::test_write_vs_to_geotiff_byte_parity_uint8[lz4](also fails onmain).