Skip to content

Add color table support to raster IO - #40

Merged
fbunt merged 2 commits into
mainfrom
color-table-support
Jul 29, 2026
Merged

Add color table support to raster IO#40
fbunt merged 2 commits into
mainfrom
color-table-support

Conversation

@fbunt

@fbunt fbunt commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a color_table keyword to Raster.save and io.write_raster that attaches a
discrete color palette to the written raster.

raster.save("lc.tif", color_table={0: (0, 0, 0), 1: (34, 139, 34)})   # dict
raster.save("lc.tif", color_table=np.array([[0, 0, 0], [34, 139, 34]]))  # (N,3)/(N,4) LUT
raster.save("lc.tif", color_table="existing.tif")                     # copy from a raster

Accepted specs: a dict mapping cell values to (r, g, b) or (r, g, b, a)
components in 0-255, an array-like of shape (N, 3) or (N, 4) where row i is
the color for value i, or a path to an existing raster whose first-band table is
copied. save_chunks forwards it, so each tile gets the same table.

And a read_color_table for the other direction, exported at the top level:

table = rts.read_color_table("lc.tif")   # -> {value: (r, g, b, a)}
table[2] = (1, 2, 3, 255)
raster.save("edited.tif", color_table=table)

rioxarray discards color tables on read, so before this there was no way to see a
palette through raster_tools at all -- you could copy one by path but not inspect or
edit it. The result feeds straight back into save. It takes a path rather than a
Raster because a Raster deliberately does not carry a table, and its recorded
source goes stale after any operation.

Additive only: with color_table=None the write path is unchanged, and a test pins
that the output has no palette and stays min-is-black.

Why this scope

No color table state is tracked on the Raster. The palette is supplied at write
time and nothing else in the library knows about it.

This means open() -> save() does not preserve a palette, which is the obvious
question to ask of this PR. It is deliberate, not an oversight. rioxarray discards
color tables on read (its only mention of them is a no-op stub in merge.py), so
carrying one would mean reading it out separately and deciding how it survives
every operation in the library. Most operations must drop it -- arithmetic, focal
means, and interpolating reprojection all produce values that no longer index the
table -- while clipping, band selection, and nearest/mode resampling should keep it.
That is a much larger change with real semantics to get wrong, and it is not needed
for the case this addresses: writing an already-classified raster out with colors
attached.

If it is picked up later, _ds.attrs is the carrier to use, because make_raster_ds
builds a fresh Dataset per operation and so drops attrs by default -- the safe
behavior for value-metadata, meaning code is only needed where preservation is
wanted rather than where it is dangerous.

Naming

GDAL calls this structure a color table (GetColorTable/SetColorTable,
gdal.ColorTable, and Color Table (RGB with 256 entries) in gdalinfo output).
"Palette" names the interpretation -- that a band's values index one.

The TIFF tag and rasterio's API both say "colormap", but in Python geo that word
overwhelmingly means a matplotlib continuous ramp, and the collision is not
theoretical: save(colormap="viridis") would have been read as a file path and
failed with FileNotFoundError: Path does not exist: 'viridis'. color_table
follows the existing house style of preferring the clearer term over the wrapped
library's, as null_value already does for nodata.

Implementation notes

GDAL fails silently in most cases where a color table cannot be stored, so those are
rejected up front rather than letting the palette disappear:

  • Tables only work on uint8 and uint16 bands; int16 and wider raise NULL color table inside GDAL.
  • A table written to a multi-band file "succeeds" and then reads back as
    ValueError, leaving colorinterp as (palette, green, blue).
  • Out-of-range values, malformed or non-sequence colors, empty tables, and source
    files without a palette all raise ValueError/RasterIOError.

Three behaviors that are less obvious:

  • Padding. GDAL pads a table it hands back out to the band dtype's full index
    range, so an eight class table reads back with 256 entries (65536 for uint16).
    On the write side those extras are an artifact rather than intent, so entries
    outside the target dtype's range are dropped only when the table was read from
    a file -- copying a uint16 source table onto a uint8 raster works instead of
    failing on index 256. read_color_table returns them, because the padding cannot
    be filtered safely: an undefined entry is opaque black, which is byte-identical to
    a deliberate black entry, and a black class is common in categorical data where it
    marks background. Trimming needs the values a raster actually uses, which means
    reading the data, so it is left to the caller.
  • Alpha. The GeoTIFF color table has no alpha plane, so alpha is forced opaque
    with a warning naming the driver. PNG and GIF palettes do store alpha, so it is
    kept there. .png was already a reachable output path (it is not in
    WRITE_NOT_IMPLEMENTED_EXTS); this stops that path silently destroying
    transparency the format can hold, rather than adding PNG support.
  • Overviews. Averaging or interpolating resampling blends palette indices into
    indices that no longer correspond to the original colors, so that combination
    warns. The check is case-insensitive because the COG translator lowercases the
    method before GDAL sees it, and it only fires when overviews will actually be
    built, so a raster too small for the auto chain does not warn about output that
    will not exist.

The table is written in an update pass after the data, which is also what sets the
file's palette photometric interpretation. Requesting that interpretation as a
creation option on top of it only reserves a second color table block that nothing
reads -- measured at exactly 1548 wasted bytes per GTiff, with COG output
byte-for-byte identical -- so the tag is left to the update pass and pinned by the
tests instead. The COG driver has no palette creation option, so that path applies
the table to its staging file, which rasterio.shutil.copy carries over.

Testing

51 new cases in the "Color tables" and "read_color_table" sections of
tests/test_write.py, covering each spec form, uint8/uint16, COG, PNG,
save_chunks forwarding, read/edit/write round-trips, cell values being
undisturbed, and every rejection path. Full suite: 2527 passed.

Conformance is asserted by reading TIFF tag 262 directly (_tiff_photometric),
because rasterio exposes neither dataset.photometric nor profile["photometric"]
for these files -- both return None even for a correctly tagged palette -- and
colorinterp reports palette even when the tag says min-is-black.

Every guard was mutation-checked: 12 of 12 seeded mutations (band-count source,
padding skip, alpha gating, overview predicate, case sensitivity, error type, dtype
coercion, dtype restriction, multi-band guard, and both table-write call sites) are
caught by a named test.

Public surface

  • read_color_table is added to raster_tools.__all__ and gets a "Color Tables"
    section in docs/reference/top_level.rst.
  • Raster.save gains the color_table keyword. It is already in the docs
    autosummary, so the parameter documentation flows through from the docstring.
  • normalize_color_table(color_table, dtype, driver=None) and
    COLOR_TABLE_DTYPES are non-underscore names in raster_tools.io, alongside
    the existing write_raster and normalize_null_value, but are not exported at
    the top level. Happy to make them private if that is preferred.

Not included

Attaching a color table to a Raster so it survives operations, categorical/RAT
support, and continuous ramps -- which a GeoTIFF cannot represent at all, and would
want a separate helper expanding a raster plus a ramp into RGB bands.

fbunt added 2 commits July 27, 2026 18:41
Raster.save and write_raster accept a color_table keyword that attaches a
discrete color palette to the written raster. It takes a dict mapping
values to (r, g, b) or (r, g, b, a) components, an array-like of shape
(N, 3) or (N, 4) where row i is the color for value i, or the path to an
existing raster whose first-band table is copied. No color table state is
tracked on the Raster; it is supplied at write time only.

GDAL names this structure a color table (GetColorTable/SetColorTable,
gdalinfo's "Color Table (RGB with 256 entries)"), and "palette" names the
interpretation that a band's values index one. The TIFF tag and rasterio's
API both say colormap, but that word means a continuous ramp to most of
Python geo, so color_table follows the existing house style of preferring
the clearer term over the wrapped library's, as null_value already does
for nodata.

The table is written in an update pass after the data, which is also what
sets the file's palette photometric interpretation. Requesting that
interpretation as a creation option on top of this only reserves a second
color table block that nothing reads, so the tag is left to the update
pass and pinned by the tests instead. The COG driver has no palette
creation option, so that path applies the table to its staging file,
which the copy carries over.

GDAL fails silently in the cases where a color table cannot be stored, so
they are rejected up front: tables only work on uint8 and uint16 bands,
and one written to a multi-band file is dropped without an error. Entries
outside the target dtype's index range are dropped only when the table
came from a file, since GDAL pads a table it hands back out to the source
dtype's full range; that padding is an artifact rather than intent, so
copying a uint16 source table onto a uint8 raster works.

Alpha is kept for the palette formats that store it and forced opaque,
with a warning naming the driver, for those that do not. Averaging or
interpolating overview resampling blends the indices into ones that no
longer correspond to the original colors, so that combination warns when
overviews will actually be built.
save() could copy a color table from a file by path, but there was no way
to see one. rioxarray discards color tables on read, so inspecting or
editing a palette meant dropping to rasterio.

read_color_table(path, band=1) returns the table as a dict that can be
handed straight back to save(), so an existing palette can be inspected,
edited entry by entry, or used as the base for a new one. It takes a path
rather than a Raster because a Raster deliberately does not carry a table,
and its recorded source goes stale after any operation.

The table is returned exactly as stored, which for a GeoTIFF means padded
out to the band dtype's full index range: an eight class table reads back
with 256 entries. The padding is not filtered because it cannot be done
safely. An undefined entry is opaque black, which is indistinguishable
from a deliberate black entry, and a black class is common in categorical
data where it marks background. Trimming would need the values a raster
actually uses, which means reading the data, so it is left to the caller.

An out of range band raises rasterio's IndexError rather than being
wrapped, since "No such band index: 2" already says what is wrong.
@fbunt
fbunt merged commit 821ae57 into main Jul 29, 2026
2 checks passed
@fbunt
fbunt deleted the color-table-support branch July 29, 2026 00:39
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.

1 participant