This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

tools.realwallpaper: draw fires as dots from FIRMS detections

The GIBS VIIRS_NOAA20_Thermal_Anomalies_375m_All raster shows each
detection as a coarse blob, far bigger than its 375 m footprint at
wallpaper scale, so the fire layer was loud and distracting.

Fetch FIRMS' keyless global 24 h VIIRS CSVs of NOAA-20, NOAA-21 and
Suomi NPP instead (cached, refreshed hourly like the old layer), drop
low-confidence detections and draw each one as an antialiased dot in
screen space through the same forward mapping as the markers: about
1 px at 1080p, growing slightly with the lens zoom, opacity from FRP on
a log scale between 1 and 500 MW. The colour and max blend under the
clouds stay as before.

author
Hermes
committer
lassulus
date (Oct 17, 2026, 12:37 AM CEST) commit 996812a3 parent 0d32dfeb
+78 -11
+78 -11
tools/realwallpaper/realwallpaper.py
··· 17 17 import argparse 18 18 import dataclasses 19 19 import concurrent.futures 20 + import csv 20 21 import datetime as dt 21 22 import hashlib 22 23 import io ··· 217 218 218 219 def dh(self, s): 219 220 return np.interp(s, self._s, self._dh) 221 + 222 + def zoom(self, xs, ys): 223 + """Lens magnification (tangential) at screen px, 1 outside the lens.""" 224 + s = self.norm(xs - self.px, ys - self.py) 225 + si = np.minimum(s, 1.0) 226 + with np.errstate(divide="ignore", invalid="ignore"): 227 + return np.where(s < 1, np.where(si > 1e-9, si / self.h(si), self.m), 1.0) 220 228 221 229 def distance_for_demand(self, q: float) -> float: 222 230 """Radius (km) around home inside which some pixel needs finer than q.""" ··· 692 700 if path.exists() and time.time() - path.stat().st_mtime < ttl: 693 701 return path 694 702 try: 703 + t0 = time.monotonic() 695 704 body, _ = http_get(url, timeout=30) 696 705 if not body: 697 706 raise FetchError(f"{url}: empty response") 698 707 tmp = path.with_name(f".{path.name}.tmp") 699 708 tmp.write_bytes(body) 700 709 os.replace(tmp, path) 710 + log(f"fetched {name} ({len(body) / 1e6:.1f} MB) in {time.monotonic() - t0:.1f}s") 701 711 except Exception as e: # noqa: BLE001 702 712 log(f"warning: {name}: {e}") 703 713 return path if path.exists() else None ··· 785 795 transform=lambda c: smoothstep(0.15, 0.9, c), 786 796 ), 787 797 Layer( 788 - "fire", 789 - None, 790 - Wms("fire", GIBS, "VIIRS_NOAA20_Thermal_Anomalies_375m_All", 0.375, 3600, 791 - days_back=(0, 1)), 792 - prep_scalar, 793 - ), 794 - Layer( 795 798 "chlorophyll", 796 799 None, 797 800 Wms("chlorophyll", GIBS, "VIIRS_NOAA20_Chlorophyll_a", 1.0, 6 * 3600, ··· 843 846 # can end up coarser than the next one 844 847 layer.log_res = np.maximum.accumulate(np.log([lvl.res for lvl in layer.levels])) 845 848 out[layer.name] = layer 849 + return out 850 + 851 + 852 + # -------------------------------------------------------------------------- 853 + # fires: VIIRS 375 m active fire detections of the last 24 h from FIRMS, 854 + # drawn as screen-space dots 855 + 856 + FIRMS = "https://firms.modaps.eosdis.nasa.gov/data/active_fire" 857 + FIRMS_CSVS = [ 858 + "noaa-20-viirs-c2/csv/J1_VIIRS_C2_Global_24h.csv", 859 + "noaa-21-viirs-c2/csv/J2_VIIRS_C2_Global_24h.csv", 860 + "suomi-npp-viirs-c2/csv/SUOMI_VIIRS_C2_Global_24h.csv", 861 + ] 862 + FIRE_DOT = 1.6 # dot diameter in px at scale 1 (1800 px tall) 863 + FIRE_LENS_GROWTH = 0.25 # dots grow with the lens zoom to this power 864 + FIRE_FRP = (1.0, 500.0) # fire radiative power (MW) of the faintest and full dots 865 + 866 + 867 + @dataclass 868 + class Fires: 869 + lat: np.ndarray 870 + lon: np.ndarray 871 + frp: np.ndarray 872 + 873 + 874 + def load_fires(cache: Cache) -> Fires: 875 + lat, lon, frp = [], [], [] 876 + for rel in FIRMS_CSVS: 877 + path = cache.get_url(f"firms-{Path(rel).name}", f"{FIRMS}/{rel}", 3600) 878 + if path is None: 879 + continue 880 + try: 881 + with path.open(newline="") as f: 882 + for row in csv.DictReader(f): 883 + if row["confidence"] == "l": 884 + continue 885 + lat.append(float(row["latitude"])) 886 + lon.append(float(row["longitude"])) 887 + frp.append(float(row["frp"])) 888 + except Exception as e: # noqa: BLE001 - a broken file drops its satellite 889 + log(f"warning: {path.name}: {e}") 890 + return Fires(np.array(lat), np.array(lon), np.array(frp)) 891 + 892 + 893 + def fire_mask(proj: LensMap, fires: Fires, scale: float) -> np.ndarray: 894 + """Fire opacity per screen px: antialiased dots, the max where they overlap.""" 895 + W, H = proj.width, proj.height 896 + out = np.zeros((H, W), np.float32) 897 + if len(fires.lat) == 0: 898 + return out 899 + x, y = proj.forward(fires.lat, fires.lon) 900 + radius = FIRE_DOT * scale / 2 * proj.zoom(x, y) ** FIRE_LENS_GROWTH 901 + lo, hi = FIRE_FRP 902 + alpha = 0.2 + 0.8 * np.clip(np.log(np.maximum(fires.frp, lo) / lo) / math.log(hi / lo), 0, 1) 903 + k = math.ceil(radius.max() + 0.5) 904 + for oy in range(-k, k + 1): 905 + for ox in range(-k, k + 1): 906 + cx = np.floor(x).astype(np.int64) + ox 907 + cy = np.floor(y).astype(np.int64) + oy 908 + cover = np.clip(radius + 0.5 - np.hypot(cx + 0.5 - x, cy + 0.5 - y), 0, 1) 909 + ok = (cover > 0) & (cx >= 0) & (cx < W) & (cy >= 0) & (cy < H) 910 + np.maximum.at(out, (cy[ok], cx[ok]), (cover * alpha)[ok].astype(np.float32)) 846 911 return out 847 912 848 913 ··· 998 1063 return top * (1 - fy) + bottom * fy 999 1064 1000 1065 1001 - def render_base(proj: LensMap, lay: dict[str, Layer], sun: tuple[float, float]) -> np.ndarray: 1066 + def render_base(proj: LensMap, lay: dict[str, Layer], fires: Fires, 1067 + sun: tuple[float, float]) -> np.ndarray: 1002 1068 W, H = proj.width, proj.height 1003 1069 ground = np.zeros((H, W, 3), np.float32) 1004 1070 cloud = np.zeros((H, W), np.float32) ··· 1048 1114 light = smoothstep(-0.10, 0.08, cosz) 1049 1115 day_lit = day * (0.55 + 0.45 * np.sqrt(np.clip(cosz, 0, 1)))[:, None] 1050 1116 color = lerp(day * moonlight, day_lit, light) 1051 - if (s := get("fire")) is not None: 1052 - color = np.maximum(color, fire_rgb * 0.8 * s[0][:, 0][:, None]) 1053 1117 if (s := get("clouds")) is not None: 1054 1118 cloud[rows] = np.clip(s[0][:, 0], 0, 1).reshape(len(ys), W) 1055 1119 if (s := get("lights")) is not None: ··· 1062 1126 q_f[rows] = q.reshape(len(ys), W) 1063 1127 1064 1128 scale = H / 1800 1129 + ground = np.maximum(ground, fire_rgb * 0.8 * fire_mask(proj, fires, scale)[..., None]) 1065 1130 light = smoothstep(-0.10, 0.08, cosz_f) 1066 1131 if "clouds" in lay: 1067 1132 cloud = cloud_detail(cloud, lat_f, lon_f, q_f) ··· 1375 1440 sun_image = cache.get_url("sun.jpg", "https://sdo.gsfc.nasa.gov/assets/img/latest/latest_512_0171.jpg", 3600) 1376 1441 marker_file = cache.get_url("markers.json", args.marker_url, 0) if args.marker_url else None 1377 1442 lay = load_layers(proj, cache, when) 1443 + fires = load_fires(cache) 1444 + log(f"{len(fires.lat)} fire detections") 1378 1445 log(f"data ready in {time.monotonic() - t0:.1f}s") 1379 1446 1380 1447 try: ··· 1385 1452 sun = sky.bodies["sun"] if sky else (0.0, 0.0) 1386 1453 1387 1454 t0 = time.monotonic() 1388 - base = Image.fromarray(render_base(proj, lay, sun)).convert("RGBA") 1455 + base = Image.fromarray(render_base(proj, lay, fires, sun)).convert("RGBA") 1389 1456 log(f"rendered in {time.monotonic() - t0:.1f}s") 1390 1457 1391 1458 scale = args.height / 1800