Spaces:
Paused
Paused
File size: 3,041 Bytes
0127c21 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 | """
Cloud coverage estimator for optical imagery.
Phase 9 (P0 — hero feature enabler). Satisfies the master brief's core
innovation: "The system should determine whether available evidence is
sufficient for a query."
MODEL CHOICE — brightness/low-saturation heuristic, NOT a trained cloud
segmentation model (e.g. s2cloudless) and NOT the Sentinel-2 QA60 cloud
mask band. This pipeline accepts arbitrary GeoTIFF/TIFF/PNG/JPEG uploads
and does not guarantee a QA band is present or which band index it would
be — so no "we read the official cloud mask" claim is made.
Heuristic: a pixel is "cloud-like" if it is BOTH bright (after a 2-98
percentile stretch, same normalization used everywhere else in this
codebase) AND low-saturation (small spread between its brightest and
darkest channel — i.e. close to white/grey rather than vividly colored
land cover). This is the same rule of thumb a person uses to eyeball
cloud cover in a thumbnail.
Known false positives (disclose these, don't hide them): bright sand,
snow, salt flats, and pale rooftops will register as "cloud" by this
heuristic. It is a prototype signal, not a validated cloud mask — never
present its output as ground truth to a judge.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import numpy as np
import rasterio
_BRIGHTNESS_THRESHOLD = 190 # 0-255 scale, after percentile stretch
_SATURATION_SPREAD_THRESHOLD = 30 # max-min channel diff, 0-255 scale
@dataclass
class CloudEstimate:
cloud_pct: float
method: str
note: str
def _percentile_stretch(a: np.ndarray) -> np.ndarray:
finite = a[np.isfinite(a)]
if finite.size == 0:
return np.zeros_like(a, dtype="float32")
lo, hi = np.percentile(finite, [2, 98])
if hi <= lo:
return np.zeros_like(a, dtype="float32")
return np.clip((a - lo) / (hi - lo), 0, 1) * 255
def estimate_cloud_coverage(optical_path: Path) -> CloudEstimate:
with rasterio.open(optical_path) as src:
count = src.count
if count >= 3:
r, g, b = (src.read(i).astype("float32") for i in (1, 2, 3))
else:
band = src.read(1).astype("float32")
r = g = b = band
brightness = _percentile_stretch((r + g + b) / 3.0)
spread = _percentile_stretch(np.maximum.reduce([r, g, b]) - np.minimum.reduce([r, g, b]))
cloud_mask = (brightness > _BRIGHTNESS_THRESHOLD) & (spread < _SATURATION_SPREAD_THRESHOLD)
cloud_pct = float(cloud_mask.mean() * 100)
note = (
f"{cloud_pct:.1f}% of pixels are bright and low-color-variation (a 'looks white/grey' "
"heuristic), not a validated cloud mask such as Sentinel-2 QA60 or s2cloudless. Can "
"misfire on snow, sand, salt flats, or pale rooftops — treat as a prototype signal, "
"not ground truth."
)
return CloudEstimate(
cloud_pct=round(cloud_pct, 1),
method="brightness_saturation_heuristic (deterministic, not a trained cloud model)",
note=note,
)
|