Night light analysis.

June 10, 2026☕ 7 min read

Some weeks ago I worked on a fun experiment, trying to see populated areas at night using satellite imagery. I found this dataset from VIIRS (Visible Infrared Imaging Radiometer Suite), which sensor was particularly designed to detect low light levels.

First step was to get the data from the Annual VNL v2.2. dataset.

Nighttime light imagery

3D rendering of Europe at night using high-resolution Nighttime Light texture as emission source in Blender.

It looks very good when zoomed out, but zoomed in it's a different story. If we clip the image the the Area of Interest (AOI) we want to analyze (in this case, Berlin), we can see the resolution is not very good and the image is very noisy.

Code to clip the AOI
aoi_bbox = (11.016088, 51.062939, 15.334464, 53.700949)
aoi_geom = [box(*aoi_bbox).__geo_interface__]

with rasterio.open(SRC_PATH) as src:
  clipped, clipped_transform = mask(src, aoi_geom, crop=True)
  clipped_meta = src.meta.copy()
  clipped_meta.update({
      "height": clipped.shape[1],
      "width": clipped.shape[2],
      "transform": clipped_transform,
  })
Clipped AOI

Raw data, clipped to the AOI. Yes, it's very hard to see anything!

We can actually visualize the radiance data using matplotlib.pyplot:

Code to plot the radiance data
with rasterio.open(AOI_CLIPPED) as src:
    data = src.read(1)
    bounds = src.bounds

fig, ax = plt.subplots(figsize=(10, 8))
im = ax.imshow(
    np.log1p(np.clip(data, 0, None)),
    extent=(bounds.left, bounds.right, bounds.bottom, bounds.top),
    cmap="inferno",
)
Night light radiance (log scale)

Mapping the radiance data to a logarithmic scale and visualizing it using the inferno colormap.

If we clip the data to an even smaller AOI, around the city center, we can really see the low resolution of the data. Bounding box here is (13.4190, 52.3926, 13.5218, 52.5029).

Berlin night radiance

You can't even see streets from here!

About the data

Radiance values in this raster are in nW/cm²/sr (nanoWatts per square centimeter per steradian) — VNL v2 annual composite, vcmslcfg variant. Let's try to de-noise the data as much as possible.

EOG's processing already eliminates the adaptive-background pixels, but the rest of the tail is still mostly noise + moonlight scatter. We get rid of everything below NOISE_FLOOR (default 0.5 nW/cm²/sr for now) before looking at the distribution.

We can plot the hisgtogram of the radiance values to see how the data is distributed (and the percentiles of the distribution):

Pseudo-code to plot the histogram
vals = aoi_data[~np.isnan(aoi_data)] if aoi_nodata is None else aoi_data[aoi_data != aoi_nodata]
# We can now get vals.min(), .max(), .mean(), .std()...
# percentiles with `float(np.percentile(vals, 50))` for P50, etc.

# Radiance is power-law-distributed: use log-spaced bins on log-x so the
# city-scale tail is actually visible alongside the dim-pixel mass.
log_bins = np.logspace(np.log10(vals.min()), np.log10(vals.max()), 80)
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
axes[0].hist(vals, bins=log_bins, color="steelblue")
axes[0].set_xscale("log")

axes[1].hist(vals, bins=log_bins, color="steelblue")
axes[1].set_xscale("log")
axes[1].set_yscale("log")
plt.show()

(Simplified the code above, but you get the idea.)

Histogram of radiance values

Another exploration is to do a threshold sensitivity sweep with Otsu's threshold: For each candidate threshold, count how many lit pixels remain and how many connected polygons would be produced. We also compute Otsu's threshold from the same data.

Note: Otsu's method is automatic threshold that maximizes between-class variance for bimodal data (dark background vs. lit clusters). It's a good baseline when we don't have a target lit-area, like in this case.

Code to compute Otsu's threshold and count polygons for each threshold
from rasterio.features import shapes as rio_shapes

def count_polygons(mask_arr, transform):
    n = 0
    for _, val in rio_shapes(mask_arr, transform=transform):
        if val == 1:
            n += 1
    return n

def otsu_threshold(x):
    # Standard 1-D Otsu on a 256-bin histogram
    hist, edges = np.histogram(x, bins=256)
    p = hist / hist.sum()
    omega = np.cumsum(p)
    mu = np.cumsum(p * (edges[:-1] + np.diff(edges) / 2))
    mu_t = mu[-1]
    denom = omega * (1 - omega)
    sigma_b2 = np.where(denom > 0, (mu_t * omega - mu) ** 2 / denom, 0)
    k = np.argmax(sigma_b2)
    return float((edges[k] + edges[k + 1]) / 2)

otsu = otsu_threshold(vals)
print(f"Otsu threshold: {otsu:.3f}")

with rasterio.open(AOI_CLIPPED) as src:
    aoi_transform = src.transform

thresholds = [NOISE_FLOOR, 1.0, 2.0, 5.0, 10.0, stats["P95"], stats["P99"], otsu]
thresholds = sorted(set(round(t, 3) for t in thresholds))

rows = []
for t in thresholds:
    mask_arr = (
        (aoi_data > t)
        & (aoi_data > NOISE_FLOOR)
        & (aoi_data != aoi_nodata)
    ).astype(np.uint8)
    lit_px = int(mask_arr.sum())
    n_polys = count_polygons(mask_arr, aoi_transform)
    rows.append((t, lit_px, lit_px / vals.size * 100, n_polys))

print(f"{'thresh':>8} {'lit_px':>10} {'lit_%':>8} {'n_polys':>8}")
for t, lp, pct, n in rows:
    print(f"{t:>8.3f} {lp:>10,} {pct:>7.2f}% {n:>8}")

# we then use matplotlib to plot `rows`
Otsu's threshold and polygon count

After some experimentation, we can see that Otsu's threshold might be a bit too high for our AOI, and we might want to use a lower threshold to get more polygons, so I'll try 4.0, around the start of the tail of the distribution. Let's polygonize the AOI data using this threshold and visualize the results. We can save the polygons to a gpkg file and visualize it in QGIS or any other GIS software.

Code to polygonize the AOI data using the 4.0 threshold
def polygonize_night_lights(raster_path, threshold=1.0, output_path=LIT_AREAS, noise_floor=0.0):
  """Convert lit pixels (radiance > max(threshold, noise_floor)) to polygons.

  threshold and noise_floor are in nW/cm^2/sr.
  """
  features = []
  with rasterio.open(raster_path) as src:
      data = src.read(1)
      nodata = src.nodata
      cut = max(threshold, noise_floor)
      mask_arr = np.where(
          (data > cut) & (data != nodata),
          1,
          0,
      ).astype(np.uint8)
      for geom, val in shapes(mask_arr, transform=src.transform):
          if val == 1:
              features.append({"geometry": shape(geom), "value": val})
      crs = src.crs

  gdf = gpd.GeoDataFrame(features, crs=crs)
  gdf = gdf.dissolve()
  gdf.to_file(output_path, driver="GPKG")
  print(f"Saved {len(gdf)} polygons to {output_path}")
  return gdf

gdf = polygonize_night_lights(AOI_CLIPPED, threshold=4.0)
gdf.head()

And we can now plot the polygons over the raster data to visualize the results.

Polygons over raster data

The code from before (polygonize_night_lights) calls gdf.dissolve(), which packs all 906 lit-area parts into a single MultiPolygon row. We explode() it into 906 individual polygons, reproject from EPSG:4326 to EPSG:3035 (ETRS89 LAEA Europe — equal-area), and compute each polygon's area in km².

Areas are heavily right-skewed: a few large urban blobs (Berlin, Magdeburg, Cottbus) and many tiny single-pixel clusters. We can plot this using log-spaced bins on a log x axis so the shape is visible across the whole size range.

Code to compute polygon stats
# Explode the dissolved multipolygon back into individual polygons.
polys = gdf.explode(index_parts=False).reset_index(drop=True)

# Reproject to ETRS89 LAEA Europe (EPSG:3035) for equal-area calculation.
polys_eq = polys.to_crs("EPSG:3035")
polys["area_km2"] = polys_eq.geometry.area / 1e6

n = len(polys)
areas = polys["area_km2"].to_numpy()
print(f"# polygons: {n}")
print(f"min:    {areas.min():.4f} km²")
print(f"max:    {areas.max():.2f} km²")
print(f"mean:   {areas.mean():.2f} km²")
print(f"median: {np.median(areas):.4f} km²")
print(f"total:  {areas.sum():.1f} km² lit")
for q in [50, 75, 90, 95, 99]:
    print(f"P{q}:    {np.percentile(areas, q):.3f} km²")

print("\nTop 10 largest polygons:")
print(polys.nlargest(10, "area_km2")[["area_km2"]].to_string())

Summary statistics of the polygons

MetricValue
Polygons906
Min0.1277 km²
Max963.37 km²
Mean4.83 km²
Median0.9455 km²
Total4373.0 km² lit
P500.945 km²
P752.508 km²
P907.443 km²
P9512.254 km²
P9956.707 km²

Top 10 largest polygons (in km²)

PolygonArea (km²)
508963.369265
300259.946248
804240.895565
145115.464188
223105.468932
55187.893229
88267.469343
50558.130556
89558.060506
21157.343108
Histogram of polygon areas

Now, let's try dropping everything below the mean (area_km2 > mean). This keeps roughly the top ~12% of polygons (the ones in the long tail) and discards the noise/single-pixel cluster mass. We then replot the radiance map with only the surviving polygons outlined.

 Bigger polygons outlined over radiance map

Radiance map with only the bigger polygons outlined.

Switching to a different dataset.

This has been fun but could already see a few steps back in the process that this data is not very useful for city-scale analysis, and that the resolution is too low to see streets or buildings. Let's try a different dataset, in this case Sentinel-2 Night-Time Acquisitions (Copernicus). on CDSE (s3://eodata/Sentinel-2/MSI/S2MSI_NIGHT/).

There's no coverage for the whole world so we switch AOI to Madrid, Spain. The one we'll be using is Madrid, 2025-12-22: S2A_OPER_PRD_R058_V20251222T221730_20251222T221745. The units are also different (calibrated spectral radiance, W·m⁻²·sr⁻¹·µm⁻¹, float), and the processing is different as well.

Having the date of the acquisition is important because we want to avoid moonlight scatter, which can be a big source of noise in night-time imagery. This is not in the metadata, but we can use Lunation 0 to calculate it:

Code to calculate the lunation number/illumination from the moon
from datetime import datetime, timezone
import math

ACQ_TIME = datetime(2025, 12, 22, 22, 17, 30, tzinfo=timezone.utc)
_ref_new_moon = datetime(2000, 1, 6, 18, 14, tzinfo=timezone.utc)
_synodic = 29.530588853
_age = ((ACQ_TIME - _ref_new_moon).total_seconds() / 86400.0) % _synodic
_illum = (1 - math.cos(2 * math.pi * _age / _synodic)) / 2
print(f"Moon age:    {_age:.2f} days post-new")
print(f"Illuminated: {_illum * 100:.1f}%  (lower → less moonlight scatter, more emitter-only signal)")

Out:

Moon age:    2.85 days post-new
Illuminated: 8.9%  (lower → less moonlight scatter, more emitter-only signal)

The zip downloaded from S3 contains 13 single-band orthorectified GeoTIFFs (B01-B12, B8A) in EPSG:4326. We read them directly through GDAL's /vsizip/ driver, no extraction needed. B04 (red, ~665 nm) is a reasonable single band for visualizing lights. Each file is huge (24970×12427 pixels) so I won't load them here directly like I did with VIIRS raw data, but here's a fun preview:

Sentinel-2 B04 band

Flag of Meerhout, or a night-time view of Madrid, not sure.

We can recover something like a VIIRS view by:

  1. Thresholding each band above its scan-line noise floor (B12 > 0.10, B08/B03 > 0.50), keeping only true emitters.
  2. Summing the three bands as a normalized "lit signal".
  3. Convolving with a Gaussian of σ = 750 m / 10 m / 2.355 ≈ 32 px (matches VIIRS DNB FWHM).

The result shows central Madrid, Barajas airport, and satellite towns (Getafe, Rivas?) as fused glowing patches, much closer to the VIIRS view we started this post with.

VIIRS-like view from Sentinel-2

The Sentinel-2 acquisition is a MultiPolygon of two tilted strips: the two black strips you see in the preview above. The west strip covers Salamanca / Sierra de Gredos (sparsely populated, dim signal). The east strip contains Madrid, which is the interesting part, so we restrict everything to the east polygon.

We fetch the STAC item, take geometry.coordinates[1] (east strip), use its bbox for cropping VIIRS and Sentinel-2, and use the polygon as a footprint mask so the gap area shows as nan rather than "black = no light".

We also apply a local column-median destripe to suppress the documented Sen2VM detector-overlap stripe noise before thresholding, so PSF-blurring doesn't smear stripe artifacts into fake glowing columns.

Code to destripe the Sentinel-2 data
import json
import urllib.request
from rasterio.windows import from_bounds
from rasterio.vrt import WarpedVRT
from rasterio.enums import Resampling
from rasterio.features import geometry_mask
from scipy.ndimage import gaussian_filter
from shapely.geometry import shape as shp_shape

STAC_ITEM_URL = (
    "https://stac.dataspace.copernicus.eu/v1/collections/"
    "sentinel-2-night-time-acquisitions/items/"
    "S2A_OPER_PRD_R058_V20251222T221730_20251222T221745"
)
stac_item = json.loads(urllib.request.urlopen(STAC_ITEM_URL).read())

east_polygon = {
    "type": "Polygon",
    "coordinates": stac_item["geometry"]["coordinates"][1],
}
east_shape = shp_shape(east_polygon)
EAST_BBOX = east_shape.bounds  # (minx, miny, maxx, maxy)
print(f"East strip bbox: ({EAST_BBOX[0]:.4f}, {EAST_BBOX[1]:.4f}, {EAST_BBOX[2]:.4f}, {EAST_BBOX[3]:.4f})")

DECIM = 10  # 10m → 100m pixels
S2_REF_URL = band_vsi("B03")

def read_decim_on_ref(band, decim=DECIM):
    with rasterio.open(S2_REF_URL) as ref, rasterio.open(band_vsi(band)) as src:
        with WarpedVRT(
            src,
            crs=ref.crs,
            transform=ref.transform,
            width=ref.width,
            height=ref.height,
            resampling=Resampling.bilinear,
        ) as vrt:
            win = from_bounds(*EAST_BBOX, transform=vrt.transform)
            out_shape = (int(win.height // decim), int(win.width // decim))
            d = vrt.read(1, window=win, out_shape=out_shape,
                         out_dtype=np.float32, resampling=Resampling.average)
            nd = vrt.nodata
            win_transform = vrt.window_transform(win)
            scale_x = win.width / out_shape[1]
            scale_y = win.height / out_shape[0]
            decim_transform = win_transform * win_transform.scale(scale_x, scale_y)
    valid = (d != nd) & np.isfinite(d) & (np.abs(d) < 1000)
    return np.where(valid, d, 0.0), decim_transform

print("Reading decimated Sentinel-2 bands over east strip...")
b12_e, s2_grid_transform = read_decim_on_ref("B12")
b08_e, _ = read_decim_on_ref("B08")
b03_e, _ = read_decim_on_ref("B03")
print(f"Sentinel-2 decimated shape: {b03_e.shape}  (~100m pixels)")

def local_column_destripe(arr, mask, height=51):
    """Subtract a moving column-median computed over `height` rows.

    The Sen2VM detector-overlap stripes run along the orbit ground track
    (~15° off vertical here). A tall narrow window captures the local
    per-column stripe offset without needing to rotate into orbit coordinates.
    """
    from numpy.lib.stride_tricks import sliding_window_view
    arr_m = np.where(mask, arr, np.nan).astype(np.float32)
    H = arr_m.shape[0]
    half = height // 2
    bg = np.full_like(arr_m, np.nan)
    if H >= height:
        for j in range(arr_m.shape[1]):
            padded = np.concatenate([np.full(half, np.nan), arr_m[:, j], np.full(half, np.nan)])
            windows = sliding_window_view(padded, height)
            with np.errstate(all="ignore"):
                bg[:, j] = np.nanmedian(windows, axis=1)
    bg = np.where(np.isnan(bg), 0.0, bg)
    return np.where(mask, arr - bg, 0.0)

footprint_mask = geometry_mask(
    [east_polygon],
    out_shape=b03_e.shape,
    transform=s2_grid_transform,
    invert=True,  # True inside the polygon
)
print(f"Polygon covers {100 * footprint_mask.sum() / footprint_mask.size:.1f}% of bbox")

print("Destriping (Sen2VM detector-overlap noise)...")
b12_e = local_column_destripe(b12_e, footprint_mask, height=51)
b08_e = local_column_destripe(b08_e, footprint_mask, height=51)
b03_e = local_column_destripe(b03_e, footprint_mask, height=51)

lit_e = (
    np.where(b12_e > 0.020, b12_e, 0.0) / 0.1
    + np.where(b08_e > 0.060, b08_e, 0.0) / 0.5
    + np.where(b03_e > 0.100, b03_e, 0.0) / 0.5
)
lit_e = np.where(footprint_mask, lit_e, 0.0)
n_lit = int((lit_e > 0).sum())
print(f"Lit pixels in east strip: {n_lit:,} / {int(footprint_mask.sum()):,} ({100 * n_lit / max(footprint_mask.sum(), 1):.3f}% of swath)")

sigma_px_e = 750 / 100 / 2.355
s2_blurred_e = gaussian_filter(lit_e, sigma=sigma_px_e)
s2_blurred_e = np.where(footprint_mask, s2_blurred_e, np.nan)

Finally, plotting the blurred lit signal over the east strip of Madrid, we can see the city lights and surrounding areas.

Madrid night lights from Sentinel-2

Unfortunately in this case the Sentinel-2 data is not as clean as VIIRS (because of the type of sensor, most likely), and we can see a lot of noise in the image. The next step would be to try to clean the data and extract the lit areas, but we can already see that even though the resolution is higher, it's still not useful for city-scale analysis at night.

This was a fun experiment, and I hope you enjoyed reading about it!