Files
gods-eye-view/intro-sequence/tools/imagery/build_clouds.py
Claude c9421a3f33 feat(intro): add the First Light intro sequence
A deterministic WebGL film of God's Eye View coming online: a seed on Null
Island grows into a culture that draws the planet, indexes the surface
tile by tile, ingests the live feeds and outgrows its dish, ending on the
logo. Renders 16:9 and 9:16 frame by frame in headless Chromium; the
procedural score is locked to the same clock. Data and imagery are
fetched by the scripts in tools/ and are not committed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01KqP857pippaUs6konSDfe8
2026-10-18 03:21:30 +00:00

77 lines
3.0 KiB
Python

"""Deliverable 3: clouds.jpg (4096x2048) grayscale cloud opacity map for one recent day.
Source: NASA GIBS daily VIIRS Corrected Reflectance True Colour for the same UTC
date from three satellites (NOAA-20, NOAA-21, Suomi NPP), EPSG:4326 level 3
(10x5 tiles of 512px = 5120x2560), downsampled to 4096x2048.
Cloud metric per satellite:
whiteness = min(R,G,B) (clouds are bright in every channel)
greyness = 1 - saturation (clouds are colourless)
c = ramp(whiteness) * smoothstep(greyness)
The same metric computed on the Esri base map (earth_day_4k.jpg) is subtracted
to suppress bright deserts, salt flats, snow and ice that would otherwise read
as cloud. The three per-satellite results are combined with a per-pixel median
(nan-aware), which removes swath-edge haze / glint streaks that only one
satellite shows, and fills swath gaps. Remaining no-data (polar night) -> 0.
Usage: python3 build_clouds.py [YYYY-MM-DD]
"""
import os
import sys
import numpy as np
from PIL import Image
from common import OUT, gibs_mosaic
Image.MAX_IMAGE_PIXELS = None
DATE = sys.argv[1] if len(sys.argv) > 1 else "2026-10-15"
LAYERS = [
"VIIRS_NOAA20_CorrectedReflectance_TrueColor",
"VIIRS_NOAA21_CorrectedReflectance_TrueColor",
"VIIRS_SNPP_CorrectedReflectance_TrueColor",
]
W, H = 4096, 2048
def smooth(x, a, b):
t = np.clip((x - a) / (b - a), 0, 1)
return t * t * (3 - 2 * t)
def metric(rgb):
rgb = rgb.astype(np.float32) / 255.0
mn = rgb.min(2)
mx = rgb.max(2)
sat = (mx - mn) / (mx + 1e-4)
white = np.clip((mn - 0.14) / 0.86, 0, 1) ** 1.15 # linear-ish: keeps texture inside thick cloud
return white * smooth(1.0 - sat, 0.65, 0.95), mx
def main():
base = np.asarray(Image.open(os.path.join(OUT, "earth_day_4k.jpg")).convert("RGB"))
base_c, _ = metric(base)
# dilate the base metric a few px so small Esri/VIIRS coastline misregistration
# does not leave bright outlines along desert and ice coasts
from PIL import ImageFilter
base_c = np.asarray(Image.fromarray((base_c * 255).astype(np.uint8)).filter(ImageFilter.MaxFilter(3)).filter(ImageFilter.GaussianBlur(2))).astype(np.float32) / 255
stack = []
for L in LAYERS:
m = gibs_mosaic(L, DATE, "250m", 3, 5, 10, "jpg").resize((W, H), Image.LANCZOS)
c, mx = metric(np.asarray(m))
c = np.clip(c - base_c * 1.1, 0, 1)
c[mx < 0.03] = np.nan # no-data (black)
stack.append(c)
stack = np.stack(stack)
with np.errstate(all="ignore"):
import warnings
warnings.simplefilter("ignore")
cloud = np.nanmedian(stack, axis=0)
cloud = np.nan_to_num(cloud, nan=0.0)
cloud = np.clip((cloud - 0.06) / 0.94, 0, 1) # black point: kill faint surface/haze residue
img = Image.fromarray(np.clip(cloud * 255 + 0.5, 0, 255).astype(np.uint8), "L")
img.save(os.path.join(OUT, "clouds.jpg"), quality=92, optimize=True, progressive=True)
print("done", DATE, "mean opacity %.3f" % cloud.mean())
if __name__ == "__main__":
main()