Write continuous-raster symbology sidecars from to_geotiff (#3537)#3538
Merged
Conversation
Add an opt-in color_ramp parameter to to_geotiff. For a continuous single-band raster it writes a QGIS .qml singlebandpseudocolor style plus STATISTICS_* in the PAM .aux.xml, so the file opens in QGIS with a color ramp instead of a flat grayscale stretch -- the continuous counterpart to the existing categorical RAT sidecar. - new _symbology.py: hardcoded ramps (viridis default), QML builder, cross-backend single-pass _finite_stats, sidecar orchestration - _pam.py: build/write stats PAM sidecar helpers - eager.py: color_ramp / color_ramp_range params, folded into the shared sidecar closure across all write paths (numpy, dask, GPU, VRT) - covers numpy / cupy / dask+numpy / dask+cupy with parity tests
brendancol
commented
Jun 26, 2026
brendancol
left a comment
Contributor
Author
There was a problem hiding this comment.
PR Review: Write continuous-raster symbology sidecars from to_geotiff (#3537)
Reviewed the four changed files in full on the PR branch. The feature is well-scoped and mirrors the categorical sidecar pattern cleanly.
Blockers (must fix before merge)
None.
Suggestions (should fix, not blocking)
xrspatial/geotiff/_writers/eager.py:481—_sym_data = datacaptures the original DataArray, but_packrewritesdatalater at line 544. Whenpack=Trueandcolor_rampare combined, the statistics and the.qmlclassificationMin/Maxare computed from the logical (pre-pack) values, while the TIFF stores the packed integers. QGIS renders against the raw stored pixels, so the ramp would be stretched over the wrong range.packis experimental and the combination is unusual, but it produces a silently wrong-looking render. Worth either skipping symbology whenpack=True(with a one-line note) or documenting the interaction in thecolor_rampdocstring.
Nits (optional improvements)
xrspatial/geotiff/_symbology.py— thegreysramp follows matplotlib's orientation (0 -> white, 1 -> black), so low elevations render light and high render dark. That is the matplotlib convention, but it is the opposite of what many DEM users expect. Not wrong, just worth a mention in the docstring if it is easy.- No end-to-end test exercises the
.vrtwrite path withcolor_ramp(the closure is shared, so the unit + numpy/dask/gpu coverage already protects it, but a one-liner would close the gap).
What looks good
_finite_statsfuses the dask reductions into a singledask.compute, so the graph is read once rather than four times. Backend parity is verified by an actual test across numpy/dask/cupy/dask+cupy.- Gating is thorough and tested: categorical wins, multiband skipped, file-like ignored, unknown ramp raises before any bytes, constant raster writes stats but skips the degenerate QML.
- The
qml_pathextension-replace vs PAM append distinction is called out with a comment explaining why a wrong name is silently ignored by QGIS — the exact trap to flag. - Ramps are static constants, no matplotlib runtime dependency.
Checklist
- Algorithm/format matches QGIS singlebandpseudocolor + GDAL STATISTICS_*
- All four backends produce consistent statistics
- NaN handling correct (finite mask excludes NaN and the nodata sentinel)
- Edge cases covered (all-NaN, constant, nodata exclusion, multiband, file-like)
- Dask path reads the graph once, no premature materialization
- Benchmark not needed (opt-in sidecar I/O)
- README matrix not applicable (parameter on existing function)
- Docstrings present for the new params and helpers
…3537) - skip symbology when pack=True: the on-disk packed values would not match a ramp/stats built from the logical values - docstring notes for the pack interaction and greys orientation - tests for the pack-skip gate and the .vrt write path
brendancol
commented
Jun 26, 2026
brendancol
left a comment
Contributor
Author
There was a problem hiding this comment.
Follow-up review (after 7ceb4b3)
Re-reviewed against the follow-up commit. All findings from the first pass are resolved.
Disposition of prior findings
- Suggestion — pack/color_ramp range mismatch — fixed.
_writers/eager.pynow gates symbology onnot pack(with a comment), so a packed write never emits sidecars whose range disagrees with the stored pixels. Documented in thecolor_rampdocstring. Covered bytest_pack_skips_symbology. - Nit — greys orientation — fixed. The
color_rampdocstring now notes greys follows matplotlib's light-to-dark orientation. - Nit — no VRT end-to-end test — fixed. Added
test_vrt_write_emits_sidecars, which writes a dask-backed array to a.vrtpath and asserts bothmosaic.qmlandmosaic.vrt.aux.xml.
Verification
pytest xrspatial/geotiff/tests/write/test_symbology_sidecar_3537.py— 21 passed (numpy, dask, GPU, dask+cupy parity).- Categorical regression suites still green.
- flake8 clean on all four changed files.
No remaining blockers, suggestions, or nits.
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 #3537.
Adds an opt-in
color_rampparameter toto_geotiff. For a continuous single-band raster it writes two sidecars next to the file so QGIS opens it with a color ramp instead of a flat grayscale stretch:<base>.qml— a QGISsinglebandpseudocolorstyle (extension-replacing name, which is what QGIS auto-loads).STATISTICS_MINIMUM/MAXIMUM/MEAN/STDDEVin the PAM<file>.aux.xml, so GDAL tools and QGIS get a min/max stretch even if the.qmlis ignored.This is the continuous-data counterpart to the categorical RAT sidecar (#3482 / #3483). Categorical rasters keep the RAT sidecar; the two are mutually exclusive and never collide.
Usage
Ramps ship as hardcoded control points (viridis default, plus plasma, magma, inferno, cividis, greys, spectral, terrain), so there is no matplotlib runtime dependency.
color_ramp_range=(min, max)sets the stretch bounds and skips the statistics pass for large dask graphs.Backend coverage
Statistics come from a single backend-aware reduction pass; the dask path fuses the reductions into one
dask.computeso the graph is read once. Covered across numpy, cupy, dask+numpy, dask+cupy.Gating
color_ramp→ no sidecars, behaviour unchanged).category_names)..qml.ValueErrorbefore any bytes are written.Test plan
pytest xrspatial/geotiff/tests/write/test_symbology_sidecar_3537.py(19 passed, GPU + dask+cupy included locally)test_category_sidecar_backends_3483.py,test_rasterize_categorical_3482.py(21 passed)dem.tif/dem.qml/dem.tif.aux.xml;.qmlparses with correct classificationMin/Max; stats exclude the NaN hole