Converting Rasters to 8-bit for Display
Map valid values onto 1–255 with a fixed stretch, and keep 0 for nodata:
import numpy as np
def to_uint8(arr: np.ma.MaskedArray, lo: float, hi: float, gamma: float = 1.0) -> np.ndarray:
scaled = np.clip((arr.astype("float32") - lo) / (hi - lo), 0, 1) ** gamma
return (scaled * 254 + 1).filled(0).astype("uint8") # 0 = nodata, 1-255 = data
The display copy is a derivative. The analytical file is never modified and remains the only thing anyone measures. This page belongs to preparing rasters for the web in Visualization, Tiling & Web Delivery.
What the Conversion Throws Away
Each of the three losses is irreversible. Clipped values are gone; quantisation merges roughly a dozen source units into each display level; and gamma, if applied, redistributes those levels non-linearly. Recording the parameters makes it possible to say what a display value means approximately, but never to recover the original.
Environment & Setup
| Package | Version pin | Used for |
|---|---|---|
rasterio |
>=1.3.0 |
Reading masked bands and writing tags |
numpy |
>=1.23 |
Stretch, gamma and quantisation |
pip install "rasterio>=1.3.0" "numpy>=1.23"
Complete Working Example
import json
import numpy as np
import rasterio
from rasterio.windows import Window
def collection_limits(paths: list[str], band: int = 1, *, sample_px: int = 2_000_000,
pct: tuple[float, float] = (2, 98)) -> tuple[float, float]:
"""One pair of limits for a whole collection, from decimated samples."""
samples = []
per_file = max(1, sample_px // max(len(paths), 1))
for p in paths:
with rasterio.open(p) as src:
f = max(1, int(np.sqrt(src.width * src.height / per_file)))
a = src.read(band, out_shape=(src.height // f, src.width // f), masked=True)
samples.append(a.compressed())
allv = np.concatenate(samples)
return float(np.percentile(allv, pct[0])), float(np.percentile(allv, pct[1]))
def write_display_copy(src_path: str, dst_path: str,
limits: dict[int, tuple[float, float]],
*, gamma: float = 1.0, block: int = 1024) -> None:
with rasterio.open(src_path) as src:
profile = src.profile | {"dtype": "uint8", "nodata": 0, "compress": "deflate",
"tiled": True, "blockxsize": 512, "blockysize": 512}
profile.pop("photometric", None)
with rasterio.open(dst_path, "w", **profile) as dst:
for row in range(0, src.height, block):
for col in range(0, src.width, block):
win = Window(col, row, min(block, src.width - col),
min(block, src.height - row))
for b in range(1, src.count + 1):
lo, hi = limits[b]
a = src.read(b, window=win, masked=True).astype("float32")
s = np.clip((a - lo) / max(hi - lo, 1e-9), 0, 1) ** gamma
dst.write((s * 254 + 1).filled(0).astype("uint8"), b, window=win)
for b, (lo, hi) in limits.items():
dst.update_tags(b, stretch_min=f"{lo:g}", stretch_max=f"{hi:g}")
dst.update_tags(display_copy="true", gamma=f"{gamma:g}",
source=src_path,
decode="value = lo + ((v - 1) / 254) ** (1/gamma) * (hi - lo)")
The windowed loop keeps memory flat regardless of scene size, and computing the collection limits from decimated samples keeps that step cheap too — percentile estimates from a few million pixels are indistinguishable from those over the full archive. Writing a decode expression into the tags is a courtesy to future readers: it says exactly what a display value approximately means.
Gamma and Where the Levels Go
Satellite reflectance is concentrated at the dark end — vegetation, water and soil all sit well below the bright tail formed by cloud, snow and sand. A linear conversion spends half its levels on that bright tail, where there is little to see. A gamma around 0.7 moves those levels down into the range where the landscape actually is, and the image gains visible detail without any change to the stretch limits.
For a single-band index product rendered through a colour ramp, leave gamma at 1.0: the ramp already controls how values map to colours, and adding a gamma makes the colour bar lie about the value at each colour.
Keeping Display and Analysis Apart
The safest arrangement is to make it structurally impossible to confuse the two files. Name them so the difference is in the filename — ndvi_analysis.tif and ndvi_display.tif — and set a display_copy=true tag on the derivative, as the example does. A pipeline step that computes statistics can then refuse any input carrying that tag, which turns a convention into a guard.
It is also worth publishing them from different places: the analytical file under the product path that a catalog item points at, the display file under a path that only the tile service and the web viewer use. People find files through catalogs and links, and if the only link to the 8-bit copy is from a map, nobody will pick it up by mistake. The catalog side of that separation is covered in attaching model metadata to STAC items.
Verification
import numpy as np
import rasterio
with rasterio.open("ndvi_display.tif") as src:
v = src.read(1)
tags = src.tags(1)
valid = v[v > 0]
low_clip = float((valid == 1).mean())
high_clip = float((valid == 255).mean())
print(f"clipped low {low_clip:.1%}, high {high_clip:.1%}")
assert low_clip < 0.05 and high_clip < 0.05, "stretch limits are too tight"
assert "stretch_min" in tags and "stretch_max" in tags, "stretch not recorded"
The clip fractions are the direct measure of whether the limits suit the data. With 2nd and 98th percentile limits from the same collection they should sit near 2% at each end; much higher means the limits came from a different product or the scene is unusual, and much lower on both ends means the limits are loose and contrast is being wasted.
Common Errors
Nodata areas render as black
Zero was written but the file’s nodata was not set to 0. Set nodata=0 in the profile so viewers treat it as transparent.
Scene boundaries show in a mosaic of display copies
Limits were computed per scene. Compute one set for the collection and apply it everywhere.
The display copy has banding in smooth gradients
Quantisation to 254 levels is visible across a very wide stretch. Narrow the limits, or accept that 8-bit cannot show fine gradients across a large range.
Values near zero disappear
The valid data included zero and was not offset to start at 1. Map onto 1–255, never 0–255.
Frequently Asked Questions
Q: Why reserve zero for nodata? Because an 8-bit band has no spare value outside its range. If valid pixels can be zero, nodata and the darkest real value become indistinguishable. Mapping valid data onto 1 to 255 costs one grey level and keeps the footprint unambiguous.
Q: Can the 8-bit copy be used for analysis? No. It is quantised to 256 levels, clipped at both ends of the stretch, and possibly gamma-corrected, so arithmetic on it gives wrong answers. It is a picture of the data, and the analytical file remains the only thing to measure.
Q: Should every scene get its own stretch? Not if the scenes will ever be seen together. Per-scene stretches make each image look good and make a mosaic of them patchy. Choose limits from a representative sample of the collection and apply them everywhere.
Q: Is a dynamic tiler a substitute for a display copy? Usually, yes — a tiler applies the stretch per request, so the analytical file can be served directly. A display copy earns its place when the layer is viewed constantly, when static tiles are needed, or when the rendering must be frozen for a publication.
Related
- Preparing Rasters for the Web — the parent topic on web-ready derivatives.
- Building True Colour Composites with Contrast Stretch — the same stretch applied at figure time.
- Choosing a Nodata Value That Survives Band Math — nodata conventions across dtypes.
- Generating Web Mercator Tile Pyramids — the geometric counterpart to this value conversion.