Converting int16 Reflectance to Float Safely
Mask on the raw integers, cast, then scale and offset:
import numpy as np
raw = src.read(1) # int16 / uint16 digital numbers
mask = raw == src.nodata # compare RAW values, not scaled ones
refl = raw.astype("float32") * np.float32(1e-4) + np.float32(offset)
refl[mask] = np.nan
That ordering — mask, cast, scale — is the whole technique. Every common error in this conversion is a violation of it. This page belongs to raster dtypes, scaling and numerical precision in Core Raster Fundamentals & STAC Mapping.
The Three Things That Go Wrong
The missed offset is the most expensive of the three in practice, because it hides inside time series. An archive spanning the processing-baseline change shows an apparent jump in every index at the switch date — a jump that looks exactly like a regional environmental event and has sent more than one analysis in the wrong direction.
Environment & Setup
| Package | Version pin | Used for |
|---|---|---|
rasterio |
>=1.3.0 |
Raw reads, nodata and embedded scales |
numpy |
>=1.23 |
Explicit casting and masking |
pystac |
>=1.10 |
Reading scale and offset from the catalog item when the file lacks them |
pip install "rasterio>=1.3.0" "numpy>=1.23" "pystac>=1.10"
Complete Working Example
from dataclasses import dataclass
import numpy as np
import rasterio
@dataclass(frozen=True)
class Radiometry:
scale: float
offset: float
nodata: int | None
source: str # where the numbers came from, for the log
def radiometry_for(path: str, item=None, asset_key: str | None = None) -> Radiometry:
"""Prefer the file's own metadata; fall back to the catalog item's raster extension."""
with rasterio.open(path) as src:
scale, offset, nodata = src.scales[0], src.offsets[0], src.nodata
if (scale, offset) != (1.0, 0.0):
return Radiometry(scale, offset, nodata, "file")
if item is not None and asset_key:
bands = item.assets[asset_key].extra_fields.get("raster:bands", [{}])
b = bands[0]
if "scale" in b or "offset" in b:
return Radiometry(b.get("scale", 1.0), b.get("offset", 0.0),
b.get("nodata", nodata), "catalog")
raise ValueError(f"no scale/offset declared for {path}; refusing to guess")
def to_reflectance(path: str, radiometry: Radiometry, band: int = 1) -> np.ndarray:
with rasterio.open(path) as src:
raw = src.read(band)
missing = np.zeros(raw.shape, dtype=bool)
if radiometry.nodata is not None:
missing = raw == radiometry.nodata # 1. mask on RAW values
out = raw.astype("float32") # 2. cast before arithmetic
out *= np.float32(radiometry.scale) # 3. scale
out += np.float32(radiometry.offset) # then offset
out[missing] = np.nan
return out
Refusing to guess is the most important line in the file. A function that silently falls back to a scale of 1.0 when metadata is missing produces reflectance of 2,000 that downstream code may clip, threshold or average without complaint. Failing loudly forces the question to be answered once, where the answer can be recorded — and recording the source of the numbers makes a later audit possible.
Scenes Either Side of a Baseline Change
The safe pattern is per-scene radiometry: read each scene’s scale and offset from its own metadata, never from a constant defined once for the archive. Catalogs that expose the raster extension make this easy, since every asset carries its own values. The catalog mechanics are covered in querying STAC catalogs programmatically, and the sensor-level details of the correction in comparing L1C and L2A Sentinel-2 products.
One Place to Apply the Scale
Most double-scaling bugs come from two well-meaning layers each applying the conversion. A reader configured with mask_and_scale=True returns physical values, and then a helper written for raw data multiplies by the scale again. Neither piece of code is wrong on its own.
The durable fix is organisational rather than technical: decide that reflectance is converted in exactly one function at the edge of the pipeline, and that everything past that function works in physical units. Name variables to match — raw_b04 versus refl_b04 — so a reviewer can see at a glance which side of the boundary a value is on. When a library is doing the conversion, disable any hand-written scaling rather than trying to detect whether it has already happened; detection by inspecting value ranges is fragile, especially for dark scenes.
Verification
import numpy as np
refl = to_reflectance("B04.tif", radiometry_for("B04.tif", item, "red"))
valid = refl[np.isfinite(refl)]
p1, p99 = np.percentile(valid, [1, 99])
print(f"p1 {p1:.4f} p99 {p99:.4f}")
assert p1 > -0.1, "sentinel or offset problem at the low end"
assert p99 < 1.5, "values look unscaled"
assert p99 > 0.01, "values look scaled twice"
Three thresholds catch all three failure modes, and they are loose enough that a legitimately unusual scene — mostly water, or mostly snow — still passes. Run them per scene in the ingest step and the class of bugs described on this page cannot reach anything downstream.
Common Errors
Values around 0.00002
The scale was applied twice. Remove the manual scaling or disable mask_and_scale, not both.
An abrupt shift in every index on one date
The processing baseline changed and the offset was not applied per scene. Read the offset from each scene’s metadata.
Very negative outliers in statistics
The nodata sentinel was scaled with the data. Mask on the raw integers before scaling.
Integer results from a conversion
An integer offset was added before casting. Cast to float32 first, then scale, then offset.
Frequently Asked Questions
Q: What scale and offset does Sentinel-2 L2A use? A scale of 1/10000 throughout, and from processing baseline 04.00 onward an additive offset of minus 1000 digital numbers, which becomes minus 0.1 in reflectance. Older scenes have no offset, so the offset must be read per scene rather than assumed for the whole archive.
Q: Why cast to float before multiplying by the scale? Because NumPy follows the input dtype for some operations, and integer arithmetic truncates or overflows. Multiplying an int16 array by 0.0001 does promote to float, but adding an integer offset first, or dividing with an integer, can silently stay integer. Casting first removes the question.
Q: Can negative reflectance be real? Small negative values appear over dark targets such as deep water after atmospheric correction, and the offset in newer products exists precisely so they can be stored. They are noise around zero rather than physics, but clipping them to zero biases dark-target statistics, so keep them unless a downstream step requires non-negative input.
Q: What about Landsat Collection 2? The same pattern with different numbers: a multiplicative scale and an additive offset for surface reflectance, documented per product and exposed in its catalog items. The function above handles it unchanged, because it reads the values rather than assuming them.
Related
- Raster Dtypes, Scaling and Numerical Precision — the parent topic.
- Handling Nodata and Scale Factors in Band Math — the xarray view of the same rules.
- Converting Landsat DN to TOA Reflectance — the radiometric conversion one step earlier.
- Choosing a Nodata Value That Survives Band Math — keeping the mask intact through all of this.