This repository has no description
0

Configure Feed

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

tools.realwallpaper: rewrite as a live lens-projection world map

Replaces pkgs.realwallpaper (xplanet, dead NASA NEO sources) with a
Python renderer: oblique cylindrical map centred on home with a zoom
lens (or plain/mercator), multi-resolution WMS imagery (Sentinel-2
cloudless, Blue Marble, Black Marble lights), live EUMETSAT clouds with
synthesised detail, VIIRS snow/fire/chlorophyll, sea ice and the sky.

The krebs.realwallpaper module, its nginx config and the mors
fetchWallpaper config are dropped; the tool runs standalone for now.

+1473 -708
-6
configs/fetchWallpaper.nix
··· 1 - { 2 - lass.fetchWallpaper = { 3 - enable = true; 4 - url = "http://wallpaper.r/realwallpaper-krebs-stars-berlin.png"; 5 - }; 6 - }
-44
configs/realwallpaper.nix
··· 1 - { 2 - self, 3 - config, 4 - pkgs, 5 - ... 6 - }: 7 - { 8 - krebs.realwallpaper.enable = true; 9 - 10 - system.activationScripts.wallpaper-chmod = '' 11 - ${pkgs.coreutils}/bin/chmod +rx /var/realwallpaper 12 - ''; 13 - services.nginx.virtualHosts.wallpaper = { 14 - extraConfig = '' 15 - if ( $server_addr = "${ 16 - self.inputs.kartei.hosts.${config.networking.hostName}.nets.internet.ip4.addr 17 - }" ) { 18 - return 403; 19 - } 20 - ''; 21 - serverAliases = [ 22 - "wallpaper.r" 23 - ]; 24 - locations."/".extraConfig = '' 25 - autoindex on; 26 - root /var/realwallpaper/; 27 - ''; 28 - locations."/realwallpaper.png".extraConfig = '' 29 - root /var/realwallpaper/; 30 - ''; 31 - locations."/realwallpaper-krebs.png".extraConfig = '' 32 - root /var/realwallpaper/; 33 - ''; 34 - locations."/realwallpaper-krebs-stars.png".extraConfig = '' 35 - root /var/realwallpaper/; 36 - ''; 37 - locations."/realwallpaper-krebs-stars-berlin.png".extraConfig = '' 38 - root /var/realwallpaper/; 39 - ''; 40 - locations."/realwallpaper-video.mp4".extraConfig = '' 41 - root /var/realwallpaper/archive; 42 - ''; 43 - }; 44 - }
+1 -2
modules/default.nix
··· 4 4 ./fetchWallpaper.nix 5 5 ./sync-containers3.nix 6 6 ./parley.nix 7 - # copied from stockholm, see the header of each 7 + # copied from stockholm, see the header 8 8 ./reaktor2.nix 9 - ./realwallpaper.nix 10 9 ]; 11 10 }
-81
modules/realwallpaper.nix
··· 1 - # Copied from stockholm krebs/3modules/realwallpaper.nix (cc283503); 2 - # realwallpaper itself is pkgs/realwallpaper. 3 - { 4 - config, 5 - lib, 6 - pkgs, 7 - self, 8 - ... 9 - }: 10 - 11 - with lib; 12 - let 13 - cfg = config.krebs.realwallpaper; 14 - 15 - out = { 16 - options.krebs.realwallpaper = api; 17 - config = lib.mkIf cfg.enable imp; 18 - }; 19 - 20 - api = { 21 - enable = mkEnableOption "realwallpaper"; 22 - 23 - workingDir = mkOption { 24 - type = types.str; 25 - default = "/var/realwallpaper/"; 26 - }; 27 - 28 - marker = mkOption { 29 - type = types.str; 30 - default = "http://graph.r/network.json"; 31 - }; 32 - 33 - timerConfig = mkOption { 34 - type = types.unspecified; 35 - default = { 36 - OnCalendar = "*:0/15"; 37 - }; 38 - }; 39 - }; 40 - 41 - imp = { 42 - systemd.timers.realwallpaper = { 43 - description = "real wallpaper generator timer"; 44 - wantedBy = [ "timers.target" ]; 45 - 46 - timerConfig = cfg.timerConfig; 47 - }; 48 - 49 - systemd.services.realwallpaper = { 50 - description = "real wallpaper generator"; 51 - after = [ "network.target" ]; 52 - 53 - environment = { 54 - working_dir = cfg.workingDir; 55 - marker_url = cfg.marker; 56 - }; 57 - 58 - restartIfChanged = true; 59 - 60 - serviceConfig = { 61 - Type = "simple"; 62 - Restart = "on-failure"; 63 - ExecStart = "${ 64 - self.packages.${pkgs.stdenv.hostPlatform.system}.realwallpaper 65 - }/bin/generate-wallpaper"; 66 - User = "realwallpaper"; 67 - }; 68 - }; 69 - 70 - users.extraUsers.realwallpaper = { 71 - group = "realwallpaper"; 72 - home = cfg.workingDir; 73 - createHome = true; 74 - isSystemUser = true; 75 - }; 76 - 77 - users.groups.realwallpaper = { }; 78 - }; 79 - 80 - in 81 - out
-1
machines/mors/config.nix
··· 15 15 ../../configs/browsers.nix 16 16 ../../configs/pass.nix 17 17 ../../configs/steam.nix 18 - ../../configs/fetchWallpaper.nix 19 18 ../../configs/mail.nix 20 19 ../../configs/syncthing.nix 21 20 ../../configs/ableton.nix
-14
machines/neoprism/config.nix
··· 73 73 # where.is.lassul.us (location from home-assistant on a map) 74 74 ../../configs/websites/where.is.lassul.us 75 75 76 - # realwallpaper generator 77 - ../../configs/realwallpaper.nix 78 - 79 76 # paste + cyberlocker 80 77 ../../configs/paste 81 78 ··· 128 125 # lassul.us shouldn't be the default vhost here (nginx.nix already sets one) 129 126 services.nginx.virtualHosts."lassul.us" = { 130 127 default = lib.mkForce false; 131 - locations = { 132 - "= /wallpaper-marker.png".extraConfig = '' 133 - alias /var/realwallpaper/realwallpaper-marker.png; 134 - ''; 135 - "= /wallpaper.png".extraConfig = '' 136 - alias /var/realwallpaper/realwallpaper.png; 137 - ''; 138 - "= /wallpaper-stars-berlin.png".extraConfig = '' 139 - alias /var/realwallpaper/realwallpaper-krebs-stars-berlin.png; 140 - ''; 141 - }; 142 128 }; 143 129 144 130 # riot VM (HFOS): fresh libvirt guest on a dedicated bridged public IP.
-41
pkgs/realwallpaper/get_constellations.py
··· 1 - from astropy.coordinates import SkyCoord, ITRS, representation 2 - from astropy.time import Time 3 - import json 4 - import sys 5 - 6 - 7 - def convert_to_itrs(coord): 8 - c = SkyCoord(coord[0], coord[1], unit='degree', frame='icrs') 9 - c_itrs = c.transform_to(ITRS(obstime=Time.now())) 10 - rep = c_itrs.represent_as(representation.UnitSphericalRepresentation) 11 - return [rep.lat.deg, rep.lon.deg] 12 - 13 - 14 - def points_to_lines(points): 15 - lines = [] 16 - for x in range(len(points) - 1): 17 - lines.append([points[x], points[x+1]]) 18 - return lines 19 - 20 - 21 - def main(): 22 - with open(sys.argv[1]) as f: 23 - constellations = json.load(f)['features'] 24 - 25 - output = [] 26 - 27 - for const in constellations: 28 - for line in const['geometry']['coordinates']: 29 - transformed_line = [] 30 - for point in line: 31 - transformed_line.append(convert_to_itrs(point)) 32 - 33 - line_combined = points_to_lines(transformed_line) 34 - for l in line_combined: # noqa 35 - output.append(f'{l[0][0]} {l[0][1]} {l[1][0]} {l[1][1]} # {const["id"]}') # noqa 36 - 37 - print('\n'.join(output)) 38 - 39 - 40 - if __name__ == "__main__": 41 - main()
-24
pkgs/realwallpaper/grib2json.nix
··· 1 - # Copied from stockholm krebs/5pkgs/simple/grib2json (cc283503). 2 - { 3 - stdenv, 4 - fetchzip, 5 - jre, 6 - }: 7 - 8 - stdenv.mkDerivation { 9 - name = "grib2json"; 10 - src = fetchzip { 11 - url = "https://github.com/krebs/grib2json-bin/archive/31efe677b40fe491c988d50f96b59b1b7e2d46f7.zip"; 12 - hash = "sha256-yyijh5SQ0nMZqox8lweIwVKLHNl6aazmYWovcMTrfsA="; 13 - }; 14 - installPhase = '' 15 - mkdir -p $out/bin 16 - cp -r lib $out/ 17 - cat > $out/bin/grib2json << EOF 18 - #!/bin/sh 19 - set -x 20 - ${jre}/bin/java -jar $out/lib/grib2json-0.8.0-SNAPSHOT.jar "\$@" 21 - EOF 22 - chmod +x $out/bin/grib2json 23 - ''; 24 - }
-56
pkgs/realwallpaper/nomads-cloud.nix
··· 1 - # Copied from stockholm krebs/5pkgs/simple/nomads-cloud (cc283503). 2 - { 3 - callPackage, 4 - writers, 5 - coreutils, 6 - curl, 7 - jq, 8 - findutils, 9 - imagemagick, 10 - }: 11 - let 12 - grib2json = callPackage ./grib2json.nix { }; 13 - in 14 - writers.writeDashBin "nomads-cloud" '' 15 - prefix=$(mktemp -d) 16 - grib_path=$prefix/clouds.grib 17 - json_path=$prefix/clouds.json 18 - pgm_path=$prefix/clouds.pgm 19 - png_path=$1 20 - 21 - mkdir -p "$prefix" 22 - 23 - date=$(${coreutils}/bin/date +%Y%m%d) 24 - for hour in 18 12 06 00; do 25 - url="https://nomads.ncep.noaa.gov/cgi-bin/filter_gfs_0p25_1hr.pl?file=gfs.t''${hour}z.pgrb2.0p25.anl&lev_entire_atmosphere_%5C%28considered_as_a_single_layer%5C%29=on&var_CWAT=on&leftlon=-180&rightlon=180&toplat=90&bottomlat=-90&dir=%2Fgfs.''${date}%2F''${hour}%2Fatmos" 26 - echo "$url" 27 - ${curl}/bin/curl -fsS "$url" > "$grib_path" 28 - if [ "$?" -eq 0 ]; then 29 - break 30 - fi 31 - done 32 - ${grib2json}/bin/grib2json --data "$grib_path" > "$json_path" 33 - 34 - width=$(${jq}/bin/jq '.[0].header.nx' < "$json_path") 35 - height=$(${jq}/bin/jq '.[0].header.ny' < "$json_path") 36 - 37 - # The maximum gray value. Must be bigger than 0 and less than 65536. 38 - maxval=1000 39 - 40 - # pgm - Netpbm grayscale image format 41 - # http://netpbm.sourceforge.net/doc/pgm.html 42 - { 43 - echo P2 44 - echo "$width $height" 45 - echo "$maxval" 46 - cat "$json_path" | 47 - ${jq}/bin/jq --argjson maxval "$maxval" -c ' 48 - ((.[0].data[]) * $maxval | round) 49 - ' | 50 - ${findutils}/bin/xargs -n "$width" 51 - } > "$pgm_path" 52 - 53 - ${imagemagick}/bin/convert -roll +50% "$pgm_path" "$png_path" 54 - 55 - rm -r "$prefix" 56 - ''
-374
pkgs/realwallpaper/package.nix
··· 1 - # Copied from stockholm krebs/5pkgs/simple/realwallpaper (cc283503). 2 - # nomads-cloud and grib2json are its stockholm dependencies, used only here. 3 - { pkgs, callPackage }: 4 - let 5 - nomads-cloud = callPackage ./nomads-cloud.nix { }; 6 - in 7 - (pkgs.writers.writeDashBin "generate-wallpaper" '' 8 - set -euf 9 - 10 - export PATH=${ 11 - pkgs.lib.makeBinPath ( 12 - [ 13 - nomads-cloud 14 - ] 15 - ++ (with pkgs; [ 16 - coreutils 17 - curl 18 - gnugrep 19 - gnused 20 - file 21 - findutils 22 - imagemagick 23 - inkscape 24 - jq 25 - xplanet 26 - ]) 27 - ) 28 - } 29 - 30 - # usage: getimg FILENAME URL 31 - fetch() { 32 - echo "fetch $1" 33 - curl -LsS -z "$1" -o "$1" "$2" 34 - } 35 - 36 - # usage: check_type FILENAME TYPE 37 - check_type() { 38 - if ! file -ib "$1" | grep -q "^$2/"; then 39 - echo "$1 is not of type $2" >&2 40 - rm "$1" 41 - return 1 42 - fi 43 - } 44 - 45 - # check if file exists and fetch only if missing 46 - fetch_once() { 47 - name=$1 48 - url=$2 49 - test -e "$name" || fetch "$name" "$url" 50 - } 51 - 52 - fetch_older_min() { 53 - min=$1 54 - name=$2 55 - url=$3 56 - if ! test "$(find $name -mmin -$min)"; then 57 - fetch "$name" "$url" 58 - fi 59 - } 60 - 61 - fetch_older_days() { 62 - days=$1 63 - name=$2 64 - url=$3 65 - if ! test "$(find $name -mtime -$days)"; then 66 - fetch "$name" "$url" 67 - fi 68 - } 69 - 70 - # usage: needs_rebuild DST SRC... 71 - needs_rebuild() { 72 - a="$1" 73 - shift 74 - if ! test -e "$a"; then 75 - #echo " $a does not exist" >&2 76 - result=0 77 - else 78 - result=1 79 - for b; do 80 - if check_type "$b" image; then 81 - if test "$b" -nt "$a"; then 82 - #echo " $b is newer than $a" >&2 83 - result=0 84 - fi 85 - fi 86 - done 87 - fi 88 - #case $result in 89 - # 0) echo "$a needs rebuild" >&2;; 90 - #esac 91 - return $result 92 - } 93 - 94 - get_neo_url() { 95 - url=$1 96 - curl -Ss "$url" | grep '3600 x 1800' | sed 's/.*href="\([^"]*\)".*/\1/' 97 - } 98 - 99 - main() { 100 - cd "''${working_dir:-$PWD}" 101 - 102 - # fetch source images in parallel 103 - fetch_once nightmap-raw.jpg \ 104 - 'https://eoimages.gsfc.nasa.gov/images/imagerecords/144000/144898/BlackMarble_2016_3km.jpg' & 105 - fetch_once daymap-raw.tif \ 106 - 'https://eoimages.gsfc.nasa.gov/images/imagerecords/57000/57752/land_shallow_topo_8192.tif' & 107 - 108 - fetch_once mercury-raw.svg \ 109 - 'https://upload.wikimedia.org/wikipedia/commons/2/2e/Mercury_symbol.svg' & 110 - fetch_once venus-raw.svg \ 111 - 'https://upload.wikimedia.org/wikipedia/commons/6/66/Venus_symbol.svg' & 112 - fetch_once mars-raw.svg \ 113 - 'https://upload.wikimedia.org/wikipedia/commons/b/b7/Mars_symbol.svg' & 114 - fetch_once jupiter-raw.svg \ 115 - 'https://upload.wikimedia.org/wikipedia/commons/2/26/Jupiter_symbol.svg' & 116 - fetch_once saturn-raw.svg \ 117 - 'https://upload.wikimedia.org/wikipedia/commons/7/74/Saturn_symbol.svg' & 118 - fetch_once uranus-raw.svg \ 119 - 'https://upload.wikimedia.org/wikipedia/commons/f/f1/Uranus_symbol.svg' & 120 - fetch_once neptune-raw.svg \ 121 - 'https://upload.wikimedia.org/wikipedia/commons/4/47/Neptune_symbol.svg' & 122 - 123 - fetch_once krebs-raw.svg \ 124 - 'https://raw.githubusercontent.com/krebs/painload/master/cholerab/bling/krebs_aquarium.svg' & 125 - 126 - fetch_older_min 720 ice-raw.jpg $(get_neo_url \ 127 - 'https://neo.gsfc.nasa.gov/view.php?datasetId=NISE_D') & 128 - fetch_older_days 1 snow-raw.jpg $(get_neo_url \ 129 - 'https://neo.gsfc.nasa.gov/view.php?datasetId=MOD10C1_E_SNOW') & 130 - fetch_older_days 1 chlora-raw.jpg $(get_neo_url \ 131 - 'https://neo.gsfc.nasa.gov/view.php?datasetId=MY1DMM_CHLORA') & 132 - fetch_older_days 1 fire-raw.jpg $(get_neo_url \ 133 - 'https://neo.gsfc.nasa.gov/view.php?datasetId=MOD14A1_E_FIRE') & 134 - 135 - # regular fetches 136 - fetch marker.json.tmp "''${marker_url:-}" || : 137 - if [ -s marker.json.tmp ]; then 138 - mv marker.json.tmp marker.json 139 - fi 140 - fetch sun-raw.jpg 'https://sdo.gsfc.nasa.gov/assets/img/latest/latest_512_0171.jpg' & 141 - 142 - wait 143 - 144 - # fetch clouds if they are older than 3h 145 - if ! test "$(find clouds-raw.png -mmin -180)"; then 146 - nomads-cloud clouds-raw.png 147 - fi 148 - 149 - in_size=3600x1800 150 - xplanet_out_size=3200x2500 151 - out_geometry=3200x1800+0+350 152 - 153 - for raw in \ 154 - nightmap-raw.jpg \ 155 - daymap-raw.tif \ 156 - chlora-raw.jpg \ 157 - clouds-raw.png \ 158 - ; 159 - do 160 - normal=''${raw%-raw.*}.png 161 - if needs_rebuild $normal $raw; then 162 - echo "make $normal; normalize $raw" >&2 163 - convert $raw -scale $in_size $normal 164 - fi 165 - done 166 - 167 - # remove snow from ice map 168 - if needs_rebuild ice.png ice-raw.jpg; then 169 - convert ice-raw.jpg -fuzz 20% -fill black -opaque white -colorspace gray -blur 0x6 -scale "$in_size" ice.png 170 - fi 171 - 172 - if needs_rebuild snow.png snow-raw.jpg; then 173 - convert snow-raw.jpg -fuzz 20% -fill '#DEDEDE' -opaque white -scale "$in_size" snow.png 174 - fi 175 - 176 - # make fire more red 177 - if needs_rebuild fire.png fire-raw.jpg; then 178 - convert fire-raw.jpg -fuzz 20% -fill '#ef840c' -opaque white -scale "$in_size" fire.png 179 - fi 180 - 181 - # cut out sun with alpha transparency 182 - if needs_rebuild sun.png sun-raw.jpg; then 183 - convert sun-raw.jpg \ 184 - \( +clone -colorspace HSB -fill white -draw "circle 256,256 256,54" -separate -delete 0,1 \) \ 185 - -compose copyopacity -composite -crop 512x472+0+20 -scale "100x100" sun.png 186 - fi 187 - 188 - if needs_rebuild krebs.png krebs-raw.svg; then 189 - inkscape --export-type="png" --export-width=16 --export-height=16 --export-filename=krebs.png krebs-raw.svg 190 - fi 191 - 192 - # -- Planets -- 193 - for planet in mercury venus mars jupiter saturn uranus neptune; do 194 - if needs_rebuild "$planet".png "$planet"-raw.svg; then 195 - sed -i 's/#000/#FE8019/g' "$planet"-raw.svg 196 - inkscape --export-type="png" --export-width=40 --export-height=40 --export-filename="$planet.png" "$planet-raw.svg" 197 - fi 198 - done 199 - 200 - # -- Daymap -- 201 - 202 - # merge with water chlora layer 203 - if needs_rebuild daymap-final.png daymap.png fire.png snow.png ice.png chlora.png; then 204 - convert daymap.png fire.png -compose lighten -composite daymap-1.png 205 - convert daymap-1.png ice.png -compose lighten -composite daymap-2.png 206 - convert daymap-2.png snow.png -compose lighten -composite daymap-3.png 207 - convert daymap-3.png chlora.png -compose lighten -composite daymap-final.png 208 - fi 209 - 210 - # -- Nightmap -- 211 - 212 - if needs_rebuild nightmap-final.png nightmap.png fire.png snow.png ice.png chlora.png; then 213 - convert nightmap.png fire.png -compose lighten -composite nightmap-1.png 214 - convert nightmap-1.png \( -fill black -colorize 70% ice.png \) -compose lighten -composite nightmap-2.png 215 - convert nightmap-2.png \( -fill black -colorize 70% snow.png \) -compose lighten -composite nightmap-3.png 216 - convert nightmap-3.png \( -fill black -colorize 70% chlora.png \) -compose lighten -composite nightmap-final.png 217 - fi 218 - 219 - # create marker file from json 220 - echo 'position=sun image=sun.png' > marker_file 221 - echo 'position=moon image=moon.png' >> marker_file 222 - echo 'position=mercury image=mercury.png' >> marker_file 223 - echo 'position=venus image=venus.png' >> marker_file 224 - echo 'position=mars image=mars.png' >> marker_file 225 - echo 'position=jupiter image=jupiter.png' >> marker_file 226 - echo 'position=saturn image=saturn.png' >> marker_file 227 - echo 'position=uranus image=uranus.png' >> marker_file 228 - echo 'position=neptune image=neptune.png' >> marker_file 229 - 230 - # generate moon 231 - xplanet -body moon --num_times 1 -origin earth \ 232 - --transpng moon.png --geometry 50x50 \ 233 - -config ${pkgs.writeText "moon.config" '' 234 - [moon] 235 - shade=10 236 - ''} 237 - 238 - # rebuild every time to update shadow 239 - xplanet --num_times 1 --geometry $xplanet_out_size \ 240 - --output xplanet-output.png --projection merc \ 241 - -config ${pkgs.writeText "xplanet.config" '' 242 - [earth] 243 - "Earth" 244 - map=daymap-final.png 245 - night_map=nightmap-final.png 246 - cloud_map=clouds.png 247 - cloud_threshold=1 248 - cloud_gamma=10 249 - shade=15 250 - ''} 251 - 252 - xplanet --num_times 1 --geometry $xplanet_out_size \ 253 - --output xplanet-marker-output.png --projection merc \ 254 - -config ${pkgs.writeText "xplanet-marker.config" '' 255 - [earth] 256 - "Earth" 257 - map=daymap-final.png 258 - night_map=nightmap-final.png 259 - cloud_map=clouds.png 260 - cloud_threshold=1 261 - cloud_gamma=10 262 - marker_file=marker_file 263 - shade=15 264 - ''} 265 - 266 - if [ -s marker.json ]; then 267 - jq -r 'to_entries[] | select(.value.latitude != null) | @json "\(.value.latitude) \(.value.longitude) image=krebs.png"' marker.json >> marker_file 268 - fi 269 - 270 - xplanet --num_times 1 --geometry $xplanet_out_size \ 271 - --output xplanet-krebs-output.png --projection merc \ 272 - -config ${pkgs.writeText "xplanet-krebs.config" '' 273 - [earth] 274 - "Earth" 275 - map=daymap-final.png 276 - night_map=nightmap-final.png 277 - cloud_map=clouds.png 278 - cloud_threshold=1 279 - cloud_gamma=10 280 - marker_file=marker_file 281 - shade=15 282 - ''} 283 - 284 - ${ 285 - pkgs.writers.writePython3 "get_constellations" { 286 - libraries = [ pkgs.python3Packages.astropy ]; 287 - } ./get_constellations.py 288 - } ${ 289 - pkgs.fetchurl { 290 - url = "https://raw.githubusercontent.com/ofrohn/d3-celestial/7e720a3de062059d4c5400a379146a601d9010e0/data/constellations.lines.json"; 291 - hash = "sha256-KU9mvvXVz1Cx4X8W0u+h2XoVExYSxo3ZNa3vbnNz4Tw="; 292 - } 293 - } > constellations.arcs || : # seems like astropy doesn't want to convert from icrs to itrs anymore 294 - 295 - xplanet --num_times 1 --geometry $xplanet_out_size \ 296 - --output xplanet-krebs-stars-output.png --projection merc \ 297 - -config ${pkgs.writeText "xplanet-krebs-stars.config" '' 298 - [default] 299 - 300 - arc_thickness=1 301 - arc_file=constellations.arcs 302 - 303 - [earth] 304 - "Earth" 305 - map=daymap-final.png 306 - night_map=nightmap-final.png 307 - cloud_map=clouds.png 308 - cloud_threshold=1 309 - cloud_gamma=10 310 - marker_file=marker_file 311 - shade=15 312 - ''} 313 - 314 - xplanet --num_times 1 --geometry $xplanet_out_size \ 315 - --latitude 52.520008 --longitude 13.404954 \ 316 - --output xplanet-krebs-stars-berlin-output.png --projection merc \ 317 - -config ${pkgs.writeText "xplanet-krebs-stars.config" '' 318 - [default] 319 - 320 - arc_thickness=1 321 - arc_file=constellations.arcs 322 - 323 - [earth] 324 - "Earth" 325 - map=daymap-final.png 326 - night_map=nightmap-final.png 327 - cloud_map=clouds.png 328 - cloud_threshold=1 329 - cloud_gamma=10 330 - marker_file=marker_file 331 - shade=15 332 - ''} 333 - 334 - # trim xplanet output 335 - if needs_rebuild realwallpaper.png xplanet-output.png; then 336 - convert xplanet-output.png -crop $out_geometry \ 337 - realwallpaper-tmp.png 338 - mv realwallpaper-tmp.png realwallpaper.png 339 - fi 340 - 341 - if needs_rebuild realwallpaper-marker.png xplanet-marker-output.png; then 342 - convert xplanet-marker-output.png -crop $out_geometry \ 343 - realwallpaper-marker-tmp.png 344 - mv realwallpaper-marker-tmp.png realwallpaper-marker.png 345 - fi 346 - 347 - if needs_rebuild realwallpaper-krebs.png xplanet-krebs-output.png; then 348 - convert xplanet-krebs-output.png -crop $out_geometry \ 349 - realwallpaper-krebs-tmp.png 350 - mv realwallpaper-krebs-tmp.png realwallpaper-krebs.png 351 - mkdir -p archive 352 - convert realwallpaper-krebs.png archive/"$(date -Is)".jpg 353 - fi 354 - 355 - if needs_rebuild realwallpaper-krebs-stars.png xplanet-krebs-stars-output.png; then 356 - convert xplanet-krebs-stars-output.png -crop $out_geometry \ 357 - realwallpaper-krebs-stars-tmp.png 358 - mv realwallpaper-krebs-stars-tmp.png realwallpaper-krebs-stars.png 359 - fi 360 - 361 - if needs_rebuild realwallpaper-krebs-stars-berlin.png xplanet-krebs-stars-berlin-output.png; then 362 - convert xplanet-krebs-stars-berlin-output.png -crop $out_geometry \ 363 - realwallpaper-krebs-stars-berlin-tmp.png 364 - mv realwallpaper-krebs-stars-berlin-tmp.png realwallpaper-krebs-stars-berlin.png 365 - fi 366 - } 367 - 368 - main "$@" 369 - '').overrideAttrs 370 - (old: { 371 - passthru = (old.passthru or { }) // { 372 - updateScript = ./update.sh; 373 - }; 374 - })
-65
pkgs/realwallpaper/update.sh
··· 1 - #!/usr/bin/env bash 2 - set -euo pipefail 3 - 4 - # realwallpaper has no version of its own; it pins two upstream GitHub assets 5 - # by commit. Bump each to the head of its default branch and refresh its hash. 6 - # Run by update-pkgs from the flake root ($0 is a read-only store copy). 7 - attr=realwallpaper 8 - dir=pkgs/realwallpaper 9 - system=$(nix eval --raw --impure --expr 'builtins.currentSystem') 10 - build_attr=".#packages.$system.$attr" 11 - 12 - # file | owner/repo | branch | URL prefix up to (and excluding) the 40-char rev 13 - assets=( 14 - "$dir/grib2json.nix|krebs/grib2json-bin|master|https://github.com/krebs/grib2json-bin/archive/" 15 - "$dir/package.nix|ofrohn/d3-celestial|master|https://raw.githubusercontent.com/ofrohn/d3-celestial/" 16 - ) 17 - 18 - # The probe build is *expected* to fail (hash mismatch); `|| true` keeps 19 - # pipefail/errexit from turning that into an empty `got`. awk must not 20 - # `exit` early or tee dies of SIGPIPE and takes the pipeline with it. 21 - probe_hash() { 22 - { nix build --no-link "$build_attr" 2>&1 || true; } | tee /dev/stderr \ 23 - | awk '/got:/ && !seen { print $2; seen = 1 }' || true 24 - } 25 - 26 - changed=0 27 - for asset in "${assets[@]}"; do 28 - IFS='|' read -r file repo branch prefix <<<"$asset" 29 - prefix_re=${prefix//./\\.} 30 - 31 - old_rev=$(grep -oE "${prefix_re}[0-9a-f]{40}" "$file" | sed "s|^${prefix_re}||") 32 - new_rev=$(curl -fsSL "https://api.github.com/repos/$repo/commits/$branch" | jq -r .sha) 33 - if [ -z "$old_rev" ] || [ -z "$new_rev" ] || [ "$new_rev" = null ]; then 34 - echo "$attr: could not determine revisions for $repo (old='$old_rev' new='$new_rev')" >&2 35 - exit 1 36 - fi 37 - if [ "$old_rev" = "$new_rev" ]; then 38 - echo "$attr: $repo already at $new_rev" 39 - continue 40 - fi 41 - 42 - # Bump the rev and blank the hash that follows the URL: with the old hash in 43 - # place nix would reuse the cached FOD and "succeed" on stale content. 44 - sed -i \ 45 - -e "s|${prefix_re}${old_rev}|${prefix}${new_rev}|" \ 46 - -e "\|${prefix_re}${new_rev}|,\|hash = |s|hash = \"[^\"]*\"|hash = \"\"|" \ 47 - "$file" 48 - 49 - # Only this asset's hash is blank, so the first mismatch belongs to it. 50 - got=$(probe_hash) 51 - if [ -z "$got" ]; then 52 - echo "$attr: no hash mismatch reported for $repo" >&2 53 - exit 1 54 - fi 55 - sed -i "\|${prefix_re}${new_rev}|,\|hash = |s|hash = \"\"|hash = \"$got\"|" "$file" 56 - echo "$attr: $repo $old_rev → $new_rev ($got)" 57 - changed=1 58 - done 59 - 60 - if [ "$changed" = 0 ]; then 61 - echo "$attr: already up to date" 62 - exit 0 63 - fi 64 - 65 - nix build --no-link "$build_attr"
+43
tools/realwallpaper/flake-module.nix
··· 1 + { 2 + perSystem = 3 + { pkgs, ... }: 4 + let 5 + python = pkgs.python3.withPackages ( 6 + ps: with ps; [ 7 + astropy 8 + numpy 9 + pillow 10 + ] 11 + ); 12 + constellations = pkgs.fetchurl { 13 + url = "https://raw.githubusercontent.com/ofrohn/d3-celestial/7e720a3de062059d4c5400a379146a601d9010e0/data/constellations.lines.json"; 14 + hash = "sha256-KU9mvvXVz1Cx4X8W0u+h2XoVExYSxo3ZNa3vbnNz4Tw="; 15 + }; 16 + krebsIcon = 17 + pkgs.runCommand "krebs.png" 18 + { 19 + nativeBuildInputs = [ pkgs.librsvg ]; 20 + src = pkgs.fetchurl { 21 + url = "https://raw.githubusercontent.com/krebs/painload/25bb340f4d8d05bb3ab6792277951b77923e7010/cholerab/bling/krebs_aquarium.svg"; 22 + hash = "sha256-KLoY58M3bPcI0WrqomPbDNewKz+akeR0oujccDeUqnY="; 23 + }; 24 + } 25 + '' 26 + rsvg-convert -w 64 -h 64 "$src" -o "$out" 27 + ''; 28 + in 29 + { 30 + packages.realwallpaper = 31 + (pkgs.writeShellApplication { 32 + name = "realwallpaper"; 33 + runtimeInputs = [ python ]; 34 + text = '' 35 + export REALWALLPAPER_CONSTELLATIONS=${constellations} 36 + export REALWALLPAPER_KREBS_ICON=${krebsIcon} 37 + export REALWALLPAPER_FONT=${pkgs.dejavu_fonts}/share/fonts/truetype/DejaVuSans.ttf 38 + exec python3 ${./realwallpaper.py} "$@" 39 + ''; 40 + }).overrideAttrs 41 + { passthru.usage = builtins.readFile ./usage.kdl; }; 42 + }; 43 + }
+1386
tools/realwallpaper/realwallpaper.py
··· 1 + """Render a live world map wallpaper: oblique Mercator with a lens on home. 2 + 3 + The globe is rotated so home (default Berlin) is in the middle of the screen 4 + on the projection's equator, then shown in Mercator filling the screen edge 5 + to edge. Around home a smooth radial lens magnifies the map and blends back 6 + into the plain map at its rim. 7 + 8 + Imagery is fetched over WMS at the resolution each pixel needs: a few local 9 + levels around home, one global level, and downsampled global mipmaps. The 10 + night side is the day map darkened, plus city lights as a glow. Every fetch 11 + is cached with a TTL; a failing source falls back to its last good copy or 12 + is skipped, so one dead upstream no longer kills the whole wallpaper. 13 + """ 14 + 15 + from __future__ import annotations 16 + 17 + import argparse 18 + import dataclasses 19 + import concurrent.futures 20 + import datetime as dt 21 + import hashlib 22 + import io 23 + import json 24 + import math 25 + import os 26 + import sys 27 + import time 28 + import urllib.parse 29 + import urllib.request 30 + import warnings 31 + from dataclasses import dataclass, field 32 + from pathlib import Path 33 + from typing import Callable 34 + 35 + import numpy as np 36 + from PIL import Image, ImageDraw, ImageFilter, ImageFont 37 + 38 + R_EARTH = 6371.0088 # km 39 + KM_PER_DEG = math.pi * R_EARTH / 180 40 + GLOBAL_WIDTH = 8192 # px of the global equirectangular level 41 + # px per WMS request side. The servers render on the fly at a roughly fixed 42 + # px/s per request (EOX: ~0.35 Mpx/s) but serve requests in parallel, so 43 + # many medium tiles fetched concurrently are much faster than few big ones 44 + MAX_TILE = 2048 45 + TILE_WORKERS = 4 # concurrent requests per level 46 + MAX_LEVEL_SIDE = 8192 # px per local level side 47 + USER_AGENT = "realwallpaper (+https://github.com/Lassulus/superconfig)" 48 + 49 + GIBS = "https://gibs.earthdata.nasa.gov/wms/epsg4326/best/wms.cgi" 50 + EUMETSAT = "https://view.eumetsat.int/geoserver/wms" 51 + EOX = "https://tiles.maps.eox.at/wms" 52 + 53 + 54 + def log(msg: str) -> None: 55 + print(msg, file=sys.stderr, flush=True) 56 + 57 + 58 + def smoothstep(e0: float, e1: float, x: np.ndarray) -> np.ndarray: 59 + t = np.clip((x - e0) / (e1 - e0), 0.0, 1.0) 60 + return t * t * (3.0 - 2.0 * t) 61 + 62 + 63 + # -------------------------------------------------------------------------- 64 + # projection 65 + 66 + 67 + def solve_cylinder(half_height: float) -> float: 68 + """Parameter a of the cylindrical y(lat) = ln tan(pi/4 + a lat/2) / a 69 + whose poles sit exactly half_height (radians of longitude) from the 70 + equator. a -> 0 is plate carrée (pi/2), a = 1 is Mercator (infinite); 71 + Miller is a = 0.8. Wider screens than 2:1 fall back to plate carrée.""" 72 + lo, hi = 1e-6, 1 - 1e-9 73 + if half_height <= math.pi / 2: 74 + return lo 75 + for _ in range(200): 76 + a = (lo + hi) / 2 77 + if math.log(math.tan(math.pi / 4 + a * math.pi / 4)) / a > half_height: 78 + hi = a 79 + else: 80 + lo = a 81 + return (lo + hi) / 2 82 + 83 + 84 + def rotate_to_home(lat0: float, lat, dlon): 85 + """Geographic (lat, lon - lon0) -> frame where home is lat' = lon' = 0. 86 + 87 + Rotating about the east-west axis keeps local north pointing up at home. 88 + All angles in radians. 89 + """ 90 + x = np.cos(lat) * np.cos(dlon) 91 + y = np.cos(lat) * np.sin(dlon) 92 + z = np.sin(lat) 93 + s0, c0 = math.sin(lat0), math.cos(lat0) 94 + xr = x * c0 + z * s0 95 + zr = -x * s0 + z * c0 96 + return np.arcsin(np.clip(zr, -1.0, 1.0)), np.arctan2(y, xr) 97 + 98 + 99 + def rotate_from_home(lat0: float, lat_r, lon_r): 100 + """Inverse of rotate_to_home: returns (lat, lon - lon0) in radians.""" 101 + xr = np.cos(lat_r) * np.cos(lon_r) 102 + y = np.cos(lat_r) * np.sin(lon_r) 103 + zr = np.sin(lat_r) 104 + s0, c0 = math.sin(lat0), math.cos(lat0) 105 + x = xr * c0 - zr * s0 106 + z = xr * s0 + zr * c0 107 + return np.arcsin(np.clip(z, -1.0, 1.0)), np.arctan2(y, x) 108 + 109 + 110 + class LensMap: 111 + """Screen-filling oblique cylindrical map around home, with a lens. 112 + 113 + The globe is rotated so home sits on the projection's equator in the 114 + middle of the screen (local north up); its poles land top and bottom 115 + only when home is on the real equator. The cylinder is Mercator-like 116 + (conformal along that equator) but with finite poles, y = ln tan(pi/4 + 117 + a lat/2) / a, with a picked so the whole globe fits the screen exactly: 118 + nothing is cut off, only squeezed towards the top and bottom edges. 119 + 120 + Projections (--projection): 121 + - lens: the above plus the magnifying lens around home (default) 122 + - plain: the above without the lens, no distortion beyond the cylinder 123 + - mercator: the classic map, north up, true Mercator centred on the 124 + equator and the home longitude; the poles are cut off (about +-71 125 + degrees fit 16:9) and there is no lens 126 + 127 + The lens is focus + glue + context (Yamamoto et al. 2009; the same idea 128 + as Snyder's 1987 "magnifying-glass" azimuthal projections): a lens 129 + "radius" n is measured as a circle, or following the screen's aspect 130 + as an ellipse (p = 2) or rounded rectangle (p = 4), the norm 131 + 132 + n = ((|dx| / ax)^p + (|dy| / ay)^p)^(1/p) 133 + 134 + (ax, ay = half the screen size times --lens). A screen point at n shows 135 + the map point at h(n) = n / z(n) along the same ray, where z is the 136 + local zoom: M in the focus (n < f, undistorted), 1 from n = 1 on (plain 137 + map), and in between, with t = (n - f) / (1 - f), 138 + 139 + ln z(t) = ln M * (1 - I(t) / I(1)), I(t) = int_0^t u^(a-1) (1 - u) du 140 + 141 + How squashed shapes are is the ratio of radial to tangential scale, 142 + 1 - t z'/z; its excess over 1 is proportional to t^a (1 - t): none at home, largest at 143 + t = a / (a + 1) =: --falloff, back to 1 at the lens edge. So --falloff 144 + says where the distortion sits: low keeps it near home, high pushes it 145 + out towards the border and leaves the middle closer to a plain zoom. 146 + z never increases outwards, so h' > 0 and the lens never folds. 147 + """ 148 + 149 + def __init__( 150 + self, 151 + lat: float, 152 + lon: float, 153 + width: int, 154 + height: int, 155 + magnification: float, 156 + lens: float, 157 + shape: str, 158 + focus: float, 159 + falloff: float, 160 + projection: str = "lens", 161 + ) -> None: 162 + if magnification < 1: 163 + raise SystemExit("--magnification must be >= 1") 164 + if not 0 <= focus < 1: 165 + raise SystemExit("--focus must be in [0, 1)") 166 + if not 0 < falloff < 1: 167 + raise SystemExit("--falloff must be in (0, 1)") 168 + self.lat0 = math.radians(lat) 169 + self.lon0 = math.radians(lon) 170 + self.width = width 171 + self.height = height 172 + self.m = magnification if projection == "lens" else 1.0 173 + self.k = 2 * math.pi / width # radians of longitude per px 174 + if projection == "mercator": 175 + self.rot_lat = 0.0 # no rotation: the real equator is the axis 176 + self.a = 1.0 # true Mercator 177 + else: 178 + self.rot_lat = self.lat0 # home on the projection's equator 179 + self.a = solve_cylinder(math.pi * height / width) 180 + self.px = width / 2 181 + self.py = height / 2 182 + self.shape = shape 183 + if shape == "circle": 184 + self.ax = self.ay = lens * height / 2 185 + else: 186 + self.ax = lens * width / 2 187 + self.ay = lens * height / 2 188 + # tabulate the profile; forward() inverts it by interpolation 189 + self._s = np.linspace(0.0, 1.0, 8001) 190 + t = np.clip((self._s - focus) / (1 - focus), 0.0, 1.0) 191 + a = falloff / (1 - falloff) 192 + with np.errstate(divide="ignore", invalid="ignore"): 193 + f_int = np.where(t > 0, t ** (a - 1) * (1 - t), 0.0) 194 + integral = np.concatenate([[0.0], np.cumsum((f_int[1:] + f_int[:-1]) / 2 * np.diff(t))]) 195 + zoom = np.exp(math.log(self.m) * (1 - integral / max(integral[-1], 1e-12))) 196 + self._h = self._s / zoom 197 + self._dh = np.gradient(self._h, self._s) 198 + # sample the screen to plan which resolutions are needed where 199 + ys, xs = np.mgrid[0:height:6, 0:width:6] + 3.0 200 + lat_g, lon_g, q = self.inverse(xs.ravel(), ys.ravel()) 201 + s = self.norm(xs.ravel() - self.px, ys.ravel() - self.py) 202 + self._plan_q = q[s < 1] 203 + self._plan_d = great_circle_km(lat, lon, lat_g[s < 1], lon_g[s < 1]) 204 + self.q0 = float(q.min()) 205 + self.q_max = float(q.max()) 206 + 207 + def norm(self, dx, dy): 208 + if self.shape == "circle": 209 + return np.hypot(dx, dy) / self.ax 210 + if self.shape == "ellipse": 211 + return np.hypot(dx / self.ax, dy / self.ay) 212 + return ((np.abs(dx) / self.ax) ** 4 + (np.abs(dy) / self.ay) ** 4) ** 0.25 213 + 214 + def h(self, s): 215 + return np.interp(s, self._s, self._h) 216 + 217 + def dh(self, s): 218 + return np.interp(s, self._s, self._dh) 219 + 220 + def distance_for_demand(self, q: float) -> float: 221 + """Radius (km) around home inside which some pixel needs finer than q.""" 222 + need = self._plan_d[self._plan_q < q] 223 + return float(min(need.max(), 3500.0)) if len(need) else 0.0 224 + 225 + def inverse(self, xs: np.ndarray, ys: np.ndarray): 226 + """Screen px -> lat, lon (degrees) and ground size of the px (km).""" 227 + dx, dy = xs - self.px, ys - self.py 228 + s = self.norm(dx, dy) 229 + inside = s < 1 230 + si = np.where(inside, s, 1.0) 231 + with np.errstate(divide="ignore", invalid="ignore"): 232 + tangential = np.where(si > 1e-9, self.h(si) / si, 1 / self.m) 233 + radial = self.dh(si) 234 + scale = np.where(inside, tangential, 1.0) 235 + wx = self.px + dx * scale 236 + wy = self.py + dy * scale 237 + lon_r = (wx - self.px) * self.k 238 + u = (self.py - wy) * self.k 239 + lat_r = (2 / self.a) * (np.arctan(np.exp(self.a * u)) - math.pi / 4) 240 + lat_r = np.clip(lat_r, -math.pi / 2, math.pi / 2) 241 + lat, dlon = rotate_from_home(self.rot_lat, lat_r, lon_r) 242 + lon = (self.lon0 + dlon + math.pi) % (2 * math.pi) - math.pi 243 + # the lens squeezes radially more than tangentially in its ring; use 244 + # the finer scale for sharpness, but not below a quarter of the other 245 + fine = np.minimum(tangential, radial) 246 + coarse = np.maximum(tangential, radial) 247 + lens_f = np.where(inside, np.maximum(fine, coarse / 4), 1.0) 248 + # cylinder: east-west px size R k cos(lat), north-south R k cos(a lat) 249 + base_f = np.maximum(np.cos(lat_r), np.cos(self.a * lat_r) / 4) 250 + q = R_EARTH * self.k * base_f * lens_f 251 + return np.degrees(lat), np.degrees(lon), np.maximum(q, 1e-3) 252 + 253 + def forward(self, lat_deg, lon_deg): 254 + lat = np.radians(np.asarray(lat_deg, dtype=np.float64)) 255 + dlon = np.radians(np.asarray(lon_deg, dtype=np.float64)) - self.lon0 256 + lat_r, lon_r = rotate_to_home(self.rot_lat, lat, dlon) 257 + lat_r = np.clip(lat_r, -math.radians(89.5), math.radians(89.5)) 258 + wx = self.px + lon_r / self.k 259 + wy = self.py - np.log(np.tan(math.pi / 4 + self.a * lat_r / 2)) / (self.a * self.k) 260 + dx, dy = wx - self.px, wy - self.py 261 + r = self.norm(dx, dy) 262 + s = np.interp(np.minimum(r, 1.0), self._h, self._s) 263 + with np.errstate(divide="ignore", invalid="ignore"): 264 + scale = np.where(r < 1, np.where(r > 1e-12, s / r, self.m), 1.0) 265 + return self.px + dx * scale, self.py + dy * scale 266 + 267 + 268 + def great_circle_km(lat0: float, lon0: float, lat, lon) -> np.ndarray: 269 + la0, lo0 = math.radians(lat0), math.radians(lon0) 270 + la, lo = np.radians(lat), np.radians(lon) 271 + c = math.sin(la0) * np.sin(la) + math.cos(la0) * np.cos(la) * np.cos(lo - lo0) 272 + return np.arccos(np.clip(c, -1.0, 1.0)) * R_EARTH 273 + 274 + 275 + # -------------------------------------------------------------------------- 276 + # sources and levels 277 + 278 + 279 + @dataclass(frozen=True) 280 + class Wms: 281 + key: str 282 + base: str 283 + layer: str 284 + native_km: float 285 + ttl: float 286 + fmt: str = "image/png" 287 + time: str | None = None 288 + # TIME=<today - n days> for each n, newest first; gaps are filled from 289 + # older days (daily polar-orbiter products are full of swath gaps) 290 + days_back: tuple[int, ...] = () 291 + # fetch this many times finer and box-filter down: some servers (EOX) 292 + # pick single pixels when asked for a coarse scale, which aliases 293 + oversample: int = 1 294 + # per-channel gain to match the colours of the other day source 295 + gain: tuple[float, float, float] | None = None 296 + 297 + def times(self, when: dt.datetime) -> list[str | None]: 298 + if self.days_back: 299 + return [(when - dt.timedelta(days=n)).strftime("%Y-%m-%d") for n in self.days_back] 300 + return [self.time] 301 + 302 + def url(self, bbox: tuple[float, float, float, float], w: int, h: int, t: str | None) -> str: 303 + s, west, n, e = bbox 304 + params = { 305 + "SERVICE": "WMS", 306 + "VERSION": "1.3.0", 307 + "REQUEST": "GetMap", 308 + "LAYERS": self.layer, 309 + "STYLES": "", 310 + "CRS": "EPSG:4326", 311 + "BBOX": f"{s:.6f},{west:.6f},{n:.6f},{e:.6f}", 312 + "WIDTH": str(w), 313 + "HEIGHT": str(h), 314 + "FORMAT": self.fmt, 315 + "TRANSPARENT": "TRUE", 316 + } 317 + if t: 318 + params["TIME"] = t 319 + return self.base + "?" + urllib.parse.urlencode(params) 320 + 321 + 322 + @dataclass 323 + class Level: 324 + res: float # km per px 325 + south: float 326 + west: float 327 + north: float 328 + east: float 329 + width: int 330 + height: int 331 + wrap: bool 332 + data: np.ndarray | None = None 333 + 334 + @property 335 + def bbox(self) -> tuple[float, float, float, float]: 336 + return (self.south, self.west, self.north, self.east) 337 + 338 + 339 + def local_level(proj: LensMap, res: float, radius_km: float) -> Level: 340 + lat = math.degrees(proj.lat0) 341 + lon = math.degrees(proj.lon0) 342 + dlat = math.degrees(radius_km / R_EARTH) 343 + south, north = max(-90.0, lat - dlat), min(90.0, lat + dlat) 344 + coslat = math.cos(proj.lat0) 345 + s = math.sin(radius_km / R_EARTH) / coslat if coslat > 1e-6 else 2.0 346 + if north >= 90.0 or south <= -90.0 or s >= 1.0: 347 + west, east = -180.0, 180.0 348 + else: 349 + dlon = math.degrees(math.asin(s)) 350 + west, east = lon - dlon, lon + dlon 351 + mid = math.cos(math.radians((south + north) / 2)) 352 + width_km = (east - west) * KM_PER_DEG * max(mid, 0.05) 353 + height_km = (north - south) * KM_PER_DEG 354 + res = max(res, width_km / MAX_LEVEL_SIDE, height_km / MAX_LEVEL_SIDE) 355 + return Level( 356 + res=res, 357 + south=south, 358 + west=west, 359 + north=north, 360 + east=east, 361 + width=max(2, math.ceil(width_km / res)), 362 + height=max(2, math.ceil(height_km / res)), 363 + wrap=False, 364 + ) 365 + 366 + 367 + def global_level(width: int) -> Level: 368 + return Level( 369 + res=360 / width * KM_PER_DEG, 370 + south=-90.0, 371 + west=-180.0, 372 + north=90.0, 373 + east=180.0, 374 + width=width, 375 + height=width // 2, 376 + wrap=True, 377 + ) 378 + 379 + 380 + def plan_local(proj: LensMap, native_km: float) -> list[Level]: 381 + """Local levels, finest first, each 4x coarser than the previous one.""" 382 + g = global_level(GLOBAL_WIDTH).res 383 + resolutions = [max(proj.q0, native_km)] 384 + while resolutions[-1] * 4 < g / 1.5: 385 + resolutions.append(resolutions[-1] * 4) 386 + if resolutions[0] >= g / 1.5: 387 + return [] 388 + levels = [] 389 + for i, res in enumerate(resolutions): 390 + nxt = resolutions[i + 1] if i + 1 < len(resolutions) else g 391 + radius = proj.distance_for_demand(nxt) * 1.1 392 + if radius > 0: 393 + levels.append(local_level(proj, res, radius)) 394 + return levels 395 + 396 + 397 + def mipmaps(level: Level, q_max: float) -> list[Level]: 398 + out = [] 399 + cur = level 400 + while cur.res < q_max and cur.height >= 64 and cur.data is not None: 401 + d = cur.data.astype(np.uint16) 402 + d = (d[0::2, 0::2] + d[1::2, 0::2] + d[0::2, 1::2] + d[1::2, 1::2] + 2) // 4 403 + cur = Level( 404 + res=cur.res * 2, 405 + south=-90.0, 406 + west=-180.0, 407 + north=90.0, 408 + east=180.0, 409 + width=cur.width // 2, 410 + height=cur.height // 2, 411 + wrap=True, 412 + data=d.astype(np.uint8), 413 + ) 414 + out.append(cur) 415 + return out 416 + 417 + 418 + # -------------------------------------------------------------------------- 419 + # raster preparation: RGBA -> channels, last channel is data coverage, value 420 + # channels are premultiplied by coverage. Bump PREP_VERSION whenever a prep 421 + # function changes so cached prepared rasters are rebuilt from the raw ones. 422 + 423 + PREP_VERSION = 3 424 + 425 + 426 + class Colormap: 427 + """GIBS colormap: maps the rendered colours back to a 0..1 data value.""" 428 + 429 + def __init__(self, path: Path) -> None: 430 + import xml.etree.ElementTree as ET 431 + 432 + entries = [ 433 + e 434 + for e in ET.parse(path).iter() 435 + if e.tag.endswith("ColorMapEntry") 436 + and e.get("transparent") != "true" 437 + and e.get("nodata") != "true" 438 + ] 439 + if not entries: 440 + raise ValueError(f"{path}: no colormap entries") 441 + self.rgb = np.array([[int(c) for c in e.get("rgb", "").split(",")] for e in entries]) 442 + self.value = np.linspace(0.0, 1.0, len(entries)) 443 + 444 + def decode(self, rgba: np.ndarray) -> np.ndarray: 445 + out = np.zeros(rgba.shape[:2], np.float32) 446 + mask = rgba[..., 3] > 0 447 + px = rgba[mask][:, :3].astype(np.int64) 448 + if len(px) == 0: 449 + return out 450 + uniq, inv = np.unique((px[:, 0] << 16) | (px[:, 1] << 8) | px[:, 2], return_inverse=True) 451 + colors = np.stack([uniq >> 16, (uniq >> 8) & 255, uniq & 255], axis=-1) 452 + values = np.empty(len(uniq)) 453 + for i in range(0, len(uniq), 4096): 454 + d = ((colors[i : i + 4096, None, :] - self.rgb[None]) ** 2).sum(-1) 455 + values[i : i + 4096] = self.value[d.argmin(1)] 456 + out[mask] = values[inv] 457 + return out 458 + 459 + 460 + def to_u8(x: np.ndarray) -> np.ndarray: 461 + return np.round(np.clip(x, 0, 1) * 255).astype(np.uint8) 462 + 463 + 464 + def prep_rgb(layer: "Layer", rgba: np.ndarray, cmap: Colormap | None) -> np.ndarray: 465 + a = rgba[..., 3:4].astype(np.uint16) 466 + rgb = (rgba[..., :3].astype(np.uint16) * a + 127) // 255 467 + return np.concatenate([rgb.astype(np.uint8), rgba[..., 3:4]], axis=-1) 468 + 469 + 470 + def prep_lights(layer: "Layer", rgba: np.ndarray, cmap: Colormap | None) -> np.ndarray: 471 + """Black Marble -> city light intensity; drops its dim blue land texture.""" 472 + red = rgba[..., 0].astype(np.float32) / 255 473 + full = np.full(rgba.shape[:2], 255, np.uint8) 474 + return np.stack([to_u8(np.clip((red - 0.12) / 0.75, 0, 1) ** 1.2), full], axis=-1) 475 + 476 + 477 + def prep_cloud(layer: "Layer", rgba: np.ndarray, cmap: Colormap | None) -> np.ndarray: 478 + """Infrared 10.8 µm brightness (cold = bright) -> cloud opacity.""" 479 + v = rgba[..., 0].astype(np.float32) / 255 480 + cov = rgba[..., 3].astype(np.float32) / 255 481 + assert layer.transform is not None 482 + return np.stack([to_u8(layer.transform(v) * cov), rgba[..., 3]], axis=-1) 483 + 484 + 485 + def prep_scalar(layer: "Layer", rgba: np.ndarray, cmap: Colormap | None) -> np.ndarray: 486 + """Colormapped data value (or plain presence) -> opacity, full coverage.""" 487 + v = cmap.decode(rgba) if cmap else rgba[..., 3].astype(np.float32) / 255 488 + if layer.transform is not None: 489 + v = layer.transform(v) * (rgba[..., 3] > 0) 490 + full = np.full(rgba.shape[:2], 255, np.uint8) 491 + return np.stack([to_u8(v), full], axis=-1) 492 + 493 + 494 + def prep_tint(layer: "Layer", rgba: np.ndarray, cmap: Colormap | None) -> np.ndarray: 495 + """Colormapped value plus where it is defined, full coverage.""" 496 + assert cmap is not None 497 + present = (rgba[..., 3] > 0).astype(np.float32) 498 + full = np.full(rgba.shape[:2], 255, np.uint8) 499 + return np.stack([to_u8(cmap.decode(rgba) * present), to_u8(present), full], axis=-1) 500 + 501 + 502 + def combine_max(layers: list[np.ndarray]) -> np.ndarray: 503 + out = layers[0].copy() 504 + for older in layers[1:]: 505 + np.maximum(out, older, out=out) 506 + return out 507 + 508 + 509 + def combine_fill(layers: list[np.ndarray]) -> np.ndarray: 510 + """Fill where the newest has no data (channel -2 = presence) from older.""" 511 + out = layers[0].copy() 512 + for older in layers[1:]: 513 + gap = out[..., -2] == 0 514 + out[gap] = older[gap] 515 + return out 516 + 517 + 518 + # -------------------------------------------------------------------------- 519 + # fetching 520 + 521 + 522 + class FetchError(Exception): 523 + pass 524 + 525 + 526 + def http_get(url: str, timeout: float = 120) -> tuple[bytes, str]: 527 + last: Exception | None = None 528 + for attempt in range(3): 529 + try: 530 + req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) 531 + with urllib.request.urlopen(req, timeout=timeout) as resp: 532 + return resp.read(), resp.headers.get("Content-Type", "") 533 + except Exception as e: # noqa: BLE001 - network errors come in many types 534 + last = e 535 + time.sleep(2 * (attempt + 1)) 536 + raise FetchError(f"{url}: {last}") 537 + 538 + 539 + def fetch_image(url: str) -> np.ndarray: 540 + body, ctype = http_get(url) 541 + if not ctype.startswith("image/"): 542 + raise FetchError(f"{url}: got {ctype}: {body[:200]!r}") 543 + return np.asarray(Image.open(io.BytesIO(body)).convert("RGBA")) 544 + 545 + 546 + def split_columns(level: Level) -> list[tuple[int, int]]: 547 + px = (level.east - level.west) / level.width 548 + cuts = {0, level.width} 549 + for meridian in (-180.0, 180.0): 550 + if level.west < meridian < level.east and not level.wrap: 551 + cuts.add(round((meridian - level.west) / px)) 552 + c = 0 553 + while c < level.width: 554 + cuts.add(c) 555 + c += MAX_TILE 556 + edges = sorted(x for x in cuts if 0 <= x <= level.width) 557 + return [(a, b) for a, b in zip(edges, edges[1:]) if b > a] 558 + 559 + 560 + def fetch_level(src: Wms, level: Level, t: str | None) -> np.ndarray: 561 + factor = max(1, min(src.oversample, 2 * MAX_LEVEL_SIDE // max(level.width, level.height))) 562 + big = dataclasses.replace(level, width=level.width * factor, height=level.height * factor, 563 + data=None) 564 + img = fetch_tiles(src, big, t) 565 + if factor > 1: 566 + img = np.asarray(Image.fromarray(img, "RGBA").reduce(factor)) 567 + return img 568 + 569 + 570 + def apply_gain(rgba: np.ndarray, gain: tuple[float, float, float] | None) -> np.ndarray: 571 + if gain is None: 572 + return rgba 573 + out = rgba.copy() 574 + out[..., :3] = to_u8(rgba[..., :3] / 255.0 * np.array(gain)) 575 + return out 576 + 577 + 578 + def fetch_tiles(src: Wms, level: Level, t: str | None) -> np.ndarray: 579 + px_lon = (level.east - level.west) / level.width 580 + px_lat = (level.north - level.south) / level.height 581 + rows = [(r, min(r + MAX_TILE, level.height)) for r in range(0, level.height, MAX_TILE)] 582 + img = np.zeros((level.height, level.width, 4), np.uint8) 583 + 584 + def one(r0: int, r1: int, c0: int, c1: int) -> None: 585 + w = level.west + c0 * px_lon 586 + e = level.west + c1 * px_lon 587 + mid = (w + e) / 2 588 + shift = -360.0 if mid > 180 else 360.0 if mid < -180 else 0.0 589 + w, e = max(-180.0, w + shift), min(180.0, e + shift) 590 + n = level.north - r0 * px_lat 591 + s = level.north - r1 * px_lat 592 + tile = fetch_image(src.url((s, w, n, e), c1 - c0, r1 - r0, t)) 593 + if tile.shape[:2] != (r1 - r0, c1 - c0): 594 + tile = np.asarray(Image.fromarray(tile).resize((c1 - c0, r1 - r0), Image.BILINEAR)) 595 + img[r0:r1, c0:c1] = tile 596 + 597 + with concurrent.futures.ThreadPoolExecutor(max_workers=TILE_WORKERS) as pool: 598 + futures = [pool.submit(one, r0, r1, c0, c1) 599 + for r0, r1 in rows for c0, c1 in split_columns(level)] 600 + for fut in futures: 601 + fut.result() # re-raise the first failure 602 + return img 603 + 604 + 605 + @dataclass 606 + class Layer: 607 + name: str 608 + local: Wms | None 609 + global_: Wms 610 + prep: Callable[["Layer", np.ndarray, Colormap | None], np.ndarray] 611 + combine: Callable[[list[np.ndarray]], np.ndarray] = combine_max 612 + colormap: str | None = None # GIBS colormap name 613 + transform: Callable[[np.ndarray], np.ndarray] | None = None 614 + required: bool = False 615 + cmap: Colormap | None = None 616 + levels: list[Level] = field(default_factory=list) 617 + log_res: np.ndarray | None = None 618 + 619 + 620 + class Cache: 621 + """Raw WMS rasters as PNG (refetched after the source TTL) and prepared 622 + rasters as .npy (rebuilt when a raw one changed or PREP_VERSION moved).""" 623 + 624 + def __init__(self, root: Path) -> None: 625 + self.root = root 626 + self.root.mkdir(parents=True, exist_ok=True) 627 + self.used: set[Path] = set() 628 + 629 + def _path(self, prefix: str, ident: object, suffix: str) -> Path: 630 + digest = hashlib.sha1(json.dumps(ident, sort_keys=True).encode()).hexdigest()[:16] 631 + path = self.root / f"{prefix}-{digest}{suffix}" 632 + self.used.add(path) 633 + return path 634 + 635 + def raw(self, src: Wms, level: Level, t: str | None) -> Path | None: 636 + ident = [src.layer, t, level.bbox, level.width, level.height, src.oversample] 637 + path = self._path(src.key, ident, ".png") 638 + if path.exists() and time.time() - path.stat().st_mtime < src.ttl: 639 + return path 640 + try: 641 + t0 = time.monotonic() 642 + img = fetch_level(src, level, t) 643 + tmp = path.with_name(f".{path.name}.tmp") 644 + Image.fromarray(img, "RGBA").save(tmp, format="PNG", compress_level=1) 645 + os.replace(tmp, path) 646 + log(f"fetched {src.key} {t or ''} {level.width}x{level.height} " 647 + f"@{level.res:.2f}km in {time.monotonic() - t0:.1f}s") 648 + except Exception as e: # noqa: BLE001 - any failure falls back to the cache 649 + log(f"warning: {src.key} {t or ''}: {e}" 650 + + ("; using cached copy" if path.exists() else "")) 651 + return path if path.exists() else None 652 + 653 + def get(self, layer: Layer, src: Wms, level: Level, when: dt.datetime) -> np.ndarray | None: 654 + raws = [p for t in src.times(when) if (p := self.raw(src, level, t)) is not None] 655 + if not raws: 656 + return None 657 + path = self._path( 658 + f"{layer.name}-prep", 659 + [PREP_VERSION, src.key, src.gain, [p.name for p in raws]], 660 + ".npy", 661 + ) 662 + newest = max(p.stat().st_mtime for p in raws) 663 + if not path.exists() or path.stat().st_mtime < newest: 664 + try: 665 + preps = [ 666 + layer.prep(layer, apply_gain(np.asarray(Image.open(p).convert("RGBA")), src.gain), 667 + layer.cmap) 668 + for p in raws 669 + ] 670 + tmp = path.with_name(f".{path.name}.tmp.npy") 671 + np.save(tmp, layer.combine(preps)) 672 + os.replace(tmp, path) 673 + except Exception as e: # noqa: BLE001 - a broken raster drops the level 674 + log(f"warning: {layer.name}: preparing {src.key}: {e}") 675 + return None 676 + return np.load(path, mmap_mode="r") 677 + 678 + def get_url(self, name: str, url: str, ttl: float) -> Path | None: 679 + path = self.root / name 680 + self.used.add(path) 681 + if path.exists() and time.time() - path.stat().st_mtime < ttl: 682 + return path 683 + try: 684 + body, _ = http_get(url, timeout=30) 685 + if not body: 686 + raise FetchError(f"{url}: empty response") 687 + tmp = path.with_name(f".{path.name}.tmp") 688 + tmp.write_bytes(body) 689 + os.replace(tmp, path) 690 + except Exception as e: # noqa: BLE001 691 + log(f"warning: {name}: {e}") 692 + return path if path.exists() else None 693 + 694 + def cleanup(self, max_age: float = 3 * 86400) -> None: 695 + for p in self.root.iterdir(): 696 + if p not in self.used and time.time() - p.stat().st_mtime > max_age: 697 + p.unlink() 698 + 699 + 700 + def snow_fraction(ndsi: np.ndarray) -> np.ndarray: 701 + # MODIS/VIIRS fractional snow cover from NDSI, ignoring the faint 702 + # detections that are mostly cloud edges and bright soil 703 + return smoothstep(0.35, 0.6, ndsi) 704 + 705 + 706 + def cloud_opacity(ir: np.ndarray) -> np.ndarray: 707 + return smoothstep(0.42, 0.78, ir) ** 1.2 708 + 709 + 710 + def layers() -> list[Layer]: 711 + day = 86400.0 712 + return [ 713 + Layer( 714 + "day", 715 + # s2cloudless is bluer than Blue Marble over the same land 716 + Wms("s2cloudless", EOX, "s2cloudless-2025", 0.01, 180 * day, "image/jpeg", 717 + oversample=4, gain=(1.0, 0.96, 0.85)), 718 + Wms("bluemarble", GIBS, "BlueMarble_NextGeneration", 0.5, 180 * day, "image/jpeg"), 719 + prep_rgb, 720 + required=True, 721 + ), 722 + Layer( 723 + "lights", 724 + None, 725 + Wms("blackmarble", GIBS, "VIIRS_Black_Marble", 0.5, 180 * day, "image/jpeg", 726 + time="2016-01-01"), 727 + prep_lights, 728 + ), 729 + Layer( 730 + "clouds", 731 + Wms("mtg-ir105", EUMETSAT, "mtg_fd:ir105_hrfi", 1.0, 600), 732 + Wms("georing-ir108", EUMETSAT, "mumi:worldcloudmap_ir108", 3.0, 3600), 733 + prep_cloud, 734 + transform=cloud_opacity, 735 + ), 736 + Layer( 737 + "snow", 738 + None, 739 + Wms("snow", GIBS, "VIIRS_NOAA20_NDSI_Snow_Cover", 0.375, 6 * 3600, 740 + days_back=(1, 2, 3)), 741 + prep_scalar, 742 + colormap="MODIS_NDSI_Snow_Cover", 743 + transform=snow_fraction, 744 + ), 745 + Layer( 746 + "ice", 747 + None, 748 + Wms("seaice", GIBS, "GHRSST_L4_MUR_Sea_Ice_Concentration", 1.0, 6 * 3600, 749 + days_back=(1, 2, 3)), 750 + prep_scalar, 751 + colormap="GHRSST_Sea_Ice_Concentration", 752 + transform=lambda c: smoothstep(0.15, 0.9, c), 753 + ), 754 + Layer( 755 + "fire", 756 + None, 757 + Wms("fire", GIBS, "VIIRS_NOAA20_Thermal_Anomalies_375m_All", 0.375, 3600, 758 + days_back=(0, 1)), 759 + prep_scalar, 760 + ), 761 + Layer( 762 + "chlorophyll", 763 + None, 764 + Wms("chlorophyll", GIBS, "VIIRS_NOAA20_Chlorophyll_a", 1.0, 6 * 3600, 765 + days_back=(1, 2, 3, 4)), 766 + prep_tint, 767 + combine=combine_fill, 768 + colormap="VIIRS_Chlorophyll", 769 + ), 770 + ] 771 + 772 + 773 + def load_layers(proj: LensMap, cache: Cache, when: dt.datetime) -> dict[str, Layer]: 774 + all_layers = [] 775 + for layer in layers(): 776 + if layer.colormap: 777 + path = cache.get_url(f"colormap-{layer.colormap}.xml", 778 + f"https://gibs.earthdata.nasa.gov/colormaps/v1.3/{layer.colormap}.xml", 779 + 30 * 86400) 780 + try: 781 + layer.cmap = Colormap(path) if path else None 782 + except Exception as e: # noqa: BLE001 783 + log(f"warning: colormap {layer.colormap}: {e}") 784 + if layer.cmap is None: 785 + log(f"warning: no colormap for {layer.name}, leaving it out") 786 + continue 787 + all_layers.append(layer) 788 + jobs = [] 789 + for layer in all_layers: 790 + local_src = layer.local or layer.global_ 791 + for lvl in plan_local(proj, local_src.native_km): 792 + jobs.append((layer, local_src, lvl)) 793 + jobs.append((layer, layer.global_, global_level(GLOBAL_WIDTH))) 794 + with concurrent.futures.ThreadPoolExecutor(max_workers=6) as pool: 795 + futures = [(layer, lvl, pool.submit(cache.get, layer, src, lvl, when)) 796 + for layer, src, lvl in jobs] 797 + for layer, lvl, fut in futures: 798 + lvl.data = fut.result() 799 + if lvl.data is not None: 800 + layer.levels.append(lvl) 801 + out = {} 802 + for layer in all_layers: 803 + if not layer.levels or not layer.levels[-1].wrap: 804 + if layer.required: 805 + raise SystemExit(f"no global data for required layer {layer.name}") 806 + log(f"warning: layer {layer.name} has no global data, leaving it out") 807 + continue 808 + layer.levels.extend(mipmaps(layer.levels[-1], proj.q_max)) 809 + # np.interp needs increasing resolutions; a size-capped local level 810 + # can end up coarser than the next one 811 + layer.log_res = np.maximum.accumulate(np.log([lvl.res for lvl in layer.levels])) 812 + out[layer.name] = layer 813 + return out 814 + 815 + 816 + # -------------------------------------------------------------------------- 817 + # sampling 818 + 819 + 820 + def sample_level(level: Level, lat: np.ndarray, lon: np.ndarray): 821 + data = level.data 822 + assert data is not None 823 + h, w = data.shape[:2] 824 + col = ((lon - level.west) % 360.0) / (level.east - level.west) * w - 0.5 825 + row = (level.north - lat) / (level.north - level.south) * h - 0.5 826 + if level.wrap: 827 + valid = np.ones(lat.shape, bool) 828 + c = np.floor(col) 829 + fc = (col - c)[:, None] 830 + c0 = c.astype(np.int64) % w 831 + c1 = (c0 + 1) % w 832 + else: 833 + valid = (col >= -0.5) & (col <= w - 0.5) & (row >= -0.5) & (row <= h - 0.5) 834 + col = np.clip(col, 0, w - 1) 835 + c0 = np.floor(col).astype(np.int64) 836 + c1 = np.minimum(c0 + 1, w - 1) 837 + fc = (col - c0)[:, None] 838 + row = np.clip(row, 0, h - 1) 839 + r0 = np.floor(row).astype(np.int64) 840 + r1 = np.minimum(r0 + 1, h - 1) 841 + fr = (row - r0)[:, None] 842 + top = data[r0, c0] * (1 - fc) + data[r0, c1] * fc 843 + bottom = data[r1, c0] * (1 - fc) + data[r1, c1] * fc 844 + return (top * (1 - fr) + bottom * fr) / 255.0, valid 845 + 846 + 847 + def sample(layer: Layer, lat: np.ndarray, lon: np.ndarray, logq: np.ndarray): 848 + """Trilinear sample: blend the two levels whose resolution brackets q. 849 + 850 + Returns premultiplied values and the total data coverage. 851 + """ 852 + n = len(layer.levels) 853 + t = np.interp(logq, layer.log_res, np.arange(n, dtype=np.float64)) 854 + channels = layer.levels[0].data.shape[-1] - 1 855 + out = np.zeros((len(lat), channels)) 856 + rem = np.ones(len(lat)) 857 + for i, lvl in enumerate(layer.levels): 858 + w = np.clip(i + 1 - t, 0.0, 1.0) 859 + idx = np.nonzero((w > 0) & (rem > 1e-4))[0] 860 + if len(idx) == 0: 861 + continue 862 + v, valid = sample_level(lvl, lat[idx], lon[idx]) 863 + wi = rem[idx] * w[idx] * valid 864 + out[idx] += v[:, :-1] * wi[:, None] 865 + rem[idx] -= wi * v[:, -1] 866 + return out, 1.0 - rem 867 + 868 + 869 + def lerp(a: np.ndarray, b, t: np.ndarray) -> np.ndarray: 870 + return a + (np.asarray(b) - a) * t[:, None] 871 + 872 + 873 + def subsolar_cos(lat, lon, sun_lat: float, sun_lon: float) -> np.ndarray: 874 + la, lo = np.radians(lat), np.radians(lon) 875 + sl, so = math.radians(sun_lat), math.radians(sun_lon) 876 + return np.sin(la) * math.sin(sl) + np.cos(la) * math.cos(sl) * np.cos(lo - so) 877 + 878 + 879 + # -------------------------------------------------------------------------- 880 + # cloud post-processing: the best live cloud data is ~1 km, far coarser than 881 + # the zoomed-in pixels around home. Synthesise the missing detail (noise 882 + # anchored to the ground, so it moves with the clouds, not the screen), and 883 + # light the clouds from the sun: relief shading plus shadows on the ground. 884 + 885 + CLOUD_DATA_KM = 1.0 # resolution of the finest cloud source 886 + CLOUD_HEIGHT_KM = 3.0 # assumed cloud height for the shadow offset 887 + 888 + 889 + def value_noise(p: np.ndarray) -> np.ndarray: 890 + """Smooth 3D value noise in [-1, 1] at integer lattice spacing.""" 891 + i = np.floor(p).astype(np.int64) 892 + f = p - i 893 + u = f * f * (3 - 2 * f) 894 + out = np.zeros(len(p)) 895 + for dx in (0, 1): 896 + for dy in (0, 1): 897 + for dz in (0, 1): 898 + h = ((i[:, 0] + dx) * 73856093) ^ ((i[:, 1] + dy) * 19349663) ^ ((i[:, 2] + dz) * 83492791) 899 + h = (h * 0x2545F491) & 0xFFFFFF 900 + w = (u[:, 0] if dx else 1 - u[:, 0]) * (u[:, 1] if dy else 1 - u[:, 1]) \ 901 + * (u[:, 2] if dz else 1 - u[:, 2]) 902 + out += w * (h / 0x7FFFFF - 1.0) 903 + return out 904 + 905 + 906 + def cloud_detail(a: np.ndarray, lat: np.ndarray, lon: np.ndarray, q: np.ndarray) -> np.ndarray: 907 + """Turn the soft, upsampled cloud edges into crisp fractal ones. 908 + 909 + Octaves (in km on the ground) are only added where they span at least 910 + two output px and lie below the data resolution, so the far, small parts 911 + of the map stay as they are. 912 + """ 913 + out = a.copy() 914 + flat_a, flat_q = a.ravel(), q.ravel() 915 + todo = np.nonzero((flat_a > 0.02) & (flat_a < 0.98) & (flat_q < 2 * CLOUD_DATA_KM))[0] 916 + octaves = [(0.4, 0.35), (0.8, 0.5), (1.6, 0.75), (3.2, 1.0)] # wavelength km, amplitude 917 + total = sum(amp for _, amp in octaves) 918 + flat_out = out.ravel() 919 + for c0 in range(0, len(todo), 1_000_000): 920 + idx = todo[c0 : c0 + 1_000_000] 921 + la, lo = np.radians(lat.ravel()[idx]), np.radians(lon.ravel()[idx]) 922 + p = R_EARTH * np.stack([np.cos(la) * np.cos(lo), np.cos(la) * np.sin(lo), np.sin(la)], -1) 923 + qi, ai = flat_q[idx], flat_a[idx] 924 + n = np.zeros(len(idx)) 925 + weight = np.zeros(len(idx)) 926 + for wavelength, amp in octaves: 927 + w = smoothstep(2.0, 4.0, wavelength / qi) * amp 928 + n += w * value_noise(p / wavelength) 929 + weight += w 930 + detail = np.clip(weight / total * 1.5, 0, 1) * smoothstep(2 * CLOUD_DATA_KM, 0.5 * CLOUD_DATA_KM, qi) 931 + nn = n / np.maximum(weight, 1e-6) # fBm in [-1, 1] 932 + # modulate rather than threshold: thin parts get the most texture, 933 + # dense cores only some, and translucency survives 934 + textured = np.clip(ai * (1 + 0.9 * nn * (1 - 0.6 * ai)), 0, 1) 935 + textured = textured + (smoothstep(0.15, 0.85, textured) - textured) * 0.5 936 + flat_out[idx] = ai + (textured - ai) * detail 937 + return out 938 + 939 + 940 + def box_blur(a: np.ndarray, r: int) -> np.ndarray: 941 + """Approximate Gaussian blur (three box passes per axis).""" 942 + if r < 1: 943 + return a 944 + out = a.astype(np.float32) 945 + for axis in (0, 1): 946 + for _ in range(3): 947 + pad = [(0, 0), (0, 0)] 948 + pad[axis] = (r + 1, r) 949 + c = np.cumsum(np.pad(out, pad, mode="edge"), axis=axis, dtype=np.float64) 950 + hi = np.take(c, np.arange(2 * r + 1, c.shape[axis]), axis=axis) 951 + lo = np.take(c, np.arange(0, c.shape[axis] - 2 * r - 1), axis=axis) 952 + out = ((hi - lo) / (2 * r + 1)).astype(np.float32) 953 + return out 954 + 955 + 956 + def gather(a: np.ndarray, x: np.ndarray, y: np.ndarray) -> np.ndarray: 957 + """Bilinear lookup of a 2D array at fractional pixel positions.""" 958 + h, w = a.shape 959 + x = np.clip(x, 0, w - 1.001) 960 + y = np.clip(y, 0, h - 1.001) 961 + x0, y0 = np.floor(x).astype(np.int64), np.floor(y).astype(np.int64) 962 + fx, fy = x - x0, y - y0 963 + top = a[y0, x0] * (1 - fx) + a[y0, x0 + 1] * fx 964 + bottom = a[y0 + 1, x0] * (1 - fx) + a[y0 + 1, x0 + 1] * fx 965 + return top * (1 - fy) + bottom * fy 966 + 967 + 968 + def render_base(proj: LensMap, lay: dict[str, Layer], sun: tuple[float, float]) -> np.ndarray: 969 + W, H = proj.width, proj.height 970 + ground = np.zeros((H, W, 3), np.float32) 971 + cloud = np.zeros((H, W), np.float32) 972 + cosz_f = np.zeros((H, W), np.float32) 973 + lights = np.zeros((H, W), np.float32) 974 + lat_f = np.zeros((H, W), np.float32) 975 + lon_f = np.zeros((H, W), np.float32) 976 + q_f = np.zeros((H, W), np.float32) 977 + chunk = max(1, 1_000_000 // W) 978 + xs = np.arange(W) + 0.5 979 + snow_rgb = np.array([0.87, 0.87, 0.87]) 980 + ice_rgb = np.array([0.90, 0.94, 1.00]) 981 + fire_rgb = np.array([0.94, 0.52, 0.05]) 982 + # chlorophyll: turquoise for moderately productive water, green for blooms 983 + plankton_rgb = np.array([0.06, 0.32, 0.40]) 984 + bloom_rgb = np.array([0.14, 0.40, 0.16]) 985 + # the night side is the (high resolution) day map in faint moonlight 986 + moonlight = np.array([0.20, 0.24, 0.36]) 987 + for y0 in range(0, H, chunk): 988 + ys = np.arange(y0, min(H, y0 + chunk)) + 0.5 989 + rows = slice(y0, y0 + len(ys)) 990 + X, Y = np.meshgrid(xs, ys) 991 + lat, lon, q = proj.inverse(X.ravel(), Y.ravel()) 992 + logq = np.log(q) 993 + 994 + def get(name: str): 995 + return sample(lay[name], lat, lon, logq) if name in lay else None 996 + 997 + day, cov = get("day") 998 + day /= np.maximum(cov, 1e-6)[:, None] 999 + 1000 + if (s := get("chlorophyll")) is not None: 1001 + # the value is log-scaled 0.01..20 mg/m³: 0.30 is ~0.1 mg/m³ 1002 + # (productive shelf water), 0.75 is ~3 mg/m³ (a bloom) 1003 + v, _ = s 1004 + present = v[:, 1] 1005 + value = v[:, 0] / np.maximum(present, 1e-6) 1006 + tint = lerp(np.tile(plankton_rgb, (len(value), 1)), bloom_rgb, 1007 + smoothstep(0.55, 0.85, value)) 1008 + day = lerp(day, tint, present * smoothstep(0.30, 0.75, value) * 0.7) 1009 + if (s := get("ice")) is not None: 1010 + day = lerp(day, ice_rgb, s[0][:, 0] * 0.9) 1011 + if (s := get("snow")) is not None: 1012 + day = lerp(day, snow_rgb, s[0][:, 0] * 0.85) 1013 + 1014 + cosz = subsolar_cos(lat, lon, *sun) 1015 + light = smoothstep(-0.10, 0.08, cosz) 1016 + day_lit = day * (0.55 + 0.45 * np.sqrt(np.clip(cosz, 0, 1)))[:, None] 1017 + color = lerp(day * moonlight, day_lit, light) 1018 + if (s := get("fire")) is not None: 1019 + color = np.maximum(color, fire_rgb * 0.8 * s[0][:, 0][:, None]) 1020 + if (s := get("clouds")) is not None: 1021 + cloud[rows] = np.clip(s[0][:, 0], 0, 1).reshape(len(ys), W) 1022 + if (s := get("lights")) is not None: 1023 + lights[rows] = (s[0][:, 0] * (1 - light)).reshape(len(ys), W) 1024 + 1025 + ground[rows] = color.reshape(len(ys), W, 3) 1026 + cosz_f[rows] = cosz.reshape(len(ys), W) 1027 + lat_f[rows] = lat.reshape(len(ys), W) 1028 + lon_f[rows] = lon.reshape(len(ys), W) 1029 + q_f[rows] = q.reshape(len(ys), W) 1030 + 1031 + scale = H / 1800 1032 + light = smoothstep(-0.10, 0.08, cosz_f) 1033 + if "clouds" in lay: 1034 + cloud = cloud_detail(cloud, lat_f, lon_f, q_f) 1035 + del lat_f, lon_f 1036 + 1037 + # sun direction on screen: cos(zenith) grows towards the sun 1038 + gy, gx = np.gradient(cosz_f) 1039 + norm = np.hypot(gx, gy) 1040 + lx = np.where(norm > 1e-9, gx / np.maximum(norm, 1e-9), 0.0).astype(np.float32) 1041 + ly = np.where(norm > 1e-9, gy / np.maximum(norm, 1e-9), 0.0).astype(np.float32) 1042 + 1043 + # shadows: the cloud seen from the ground towards the sun, displaced by 1044 + # height / tan(elevation), only where the sun is up 1045 + elev = np.arcsin(np.clip(cosz_f, 0.0, 1.0)) 1046 + offset = np.clip(CLOUD_HEIGHT_KM / np.maximum(np.tan(elev), 0.1) / q_f, 0, 40 * scale) 1047 + yy, xx = np.mgrid[0:H, 0:W].astype(np.float32) 1048 + soft = box_blur(cloud, max(1, round(1.5 * scale))) 1049 + shadow = gather(soft, xx + lx * offset, yy + ly * offset) 1050 + del xx, yy 1051 + ground *= (1 - 0.45 * shadow * light * smoothstep(0.0, 0.15, cosz_f))[..., None] 1052 + 1053 + # relief: treat the (blurred) opacity as cloud height and light its 1054 + # slopes; a slope facing the sun has its height falling towards the sun 1055 + height = box_blur(cloud, max(1, round(2 * scale))) 1056 + hy, hx = np.gradient(height) 1057 + relief = np.clip(1 - 6.0 * (hx * lx + hy * ly) / scale, 0.6, 1.12) 1058 + 1059 + sunlit = light * (0.6 + 0.4 * np.sqrt(np.clip(cosz_f, 0, 1))) * relief 1060 + cloud_rgb = ( 1061 + np.array([0.11, 0.12, 0.16], np.float32) * (1 - light)[..., None] 1062 + + np.array([0.88, 0.89, 0.90], np.float32) * sunlit[..., None] 1063 + ) 1064 + out = ground + (cloud_rgb - ground) * cloud[..., None] 1065 + 1066 + # city lights as a glow drawn in screen space: smooth at any zoom, so the 1067 + # ~1 km squares of the source never show; clouds dim them 1068 + lights *= 1 - 0.75 * cloud 1069 + img = Image.fromarray(to_u8(lights), "L") 1070 + core = np.asarray(img.filter(ImageFilter.GaussianBlur(0.9 * scale)), np.float32) / 255 1071 + halo = np.asarray(img.filter(ImageFilter.GaussianBlur(6 * scale)), np.float32) / 255 1072 + glow = np.minimum(core * 1.1 + halo * 0.8, 1.5)[..., None] 1073 + out += glow * np.array([1.0, 0.80, 0.50], np.float32) 1074 + return to_u8(out) 1075 + 1076 + 1077 + # -------------------------------------------------------------------------- 1078 + # sky: sun, moon, planets and constellations at their sub-points 1079 + 1080 + 1081 + PLANETS = { 1082 + "mercury": "☿", 1083 + "venus": "♀", 1084 + "mars": "♂", 1085 + "jupiter": "♃", 1086 + "saturn": "♄", 1087 + "uranus": "♅", 1088 + "neptune": "♆", 1089 + } 1090 + 1091 + 1092 + @dataclass 1093 + class Sky: 1094 + bodies: dict[str, tuple[float, float]] 1095 + moon_elongation: float 1096 + lines: list[tuple[tuple[float, float], tuple[float, float]]] 1097 + 1098 + 1099 + def wrap180(x): 1100 + return (np.asarray(x) + 180.0) % 360.0 - 180.0 1101 + 1102 + 1103 + def compute_sky(when: dt.datetime, constellations: Path | None) -> Sky: 1104 + warnings.filterwarnings("ignore", module="astropy") 1105 + warnings.filterwarnings("ignore", module="erfa") 1106 + from astropy.utils import iers 1107 + 1108 + # offline: bundled IERS tables, UT1-UTC drift is far below a pixel 1109 + iers.conf.auto_download = False 1110 + iers.conf.auto_max_age = None 1111 + iers.conf.iers_degraded_accuracy = "warn" 1112 + import astropy.units as u 1113 + from astropy.coordinates import TETE, SkyCoord, get_body 1114 + from astropy.time import Time 1115 + 1116 + t = Time(when) 1117 + gast = t.sidereal_time("apparent", "greenwich").deg 1118 + frame = TETE(obstime=t) 1119 + 1120 + def subpoints(coord): 1121 + c = coord.transform_to(frame) 1122 + return np.asarray(c.dec.deg), wrap180(np.asarray(c.ra.deg) - gast) 1123 + 1124 + bodies = {} 1125 + raw = {} 1126 + for name in ["sun", "moon", *PLANETS]: 1127 + raw[name] = get_body(name, t) 1128 + la, lo = subpoints(raw[name]) 1129 + bodies[name] = (float(la), float(lo)) 1130 + elong = float(raw["sun"].separation(raw["moon"]).deg) 1131 + 1132 + lines = [] 1133 + if constellations is not None: 1134 + feats = json.loads(constellations.read_text())["features"] 1135 + strips = [ 1136 + line for f in feats for line in f["geometry"]["coordinates"] if len(line) > 1 1137 + ] 1138 + pts = np.array([p for line in strips for p in line], dtype=np.float64) 1139 + la, lo = subpoints(SkyCoord(ra=pts[:, 0] * u.deg, dec=pts[:, 1] * u.deg, frame="icrs")) 1140 + i = 0 1141 + for line in strips: 1142 + for k in range(len(line) - 1): 1143 + lines.append(((la[i + k], lo[i + k]), (la[i + k + 1], lo[i + k + 1]))) 1144 + i += len(line) 1145 + return Sky(bodies, elong, lines) 1146 + 1147 + 1148 + def to_vec(lat: float, lon: float) -> np.ndarray: 1149 + la, lo = math.radians(lat), math.radians(lon) 1150 + return np.array([math.cos(la) * math.cos(lo), math.cos(la) * math.sin(lo), math.sin(la)]) 1151 + 1152 + 1153 + def great_circle(a, b, proj: LensMap, max_px: float = 3.0) -> list[list[tuple[float, float]]]: 1154 + """Project the great circle arc a->b, split where it wraps around the map.""" 1155 + va, vb = to_vec(*a), to_vec(*b) 1156 + omega = math.acos(max(-1.0, min(1.0, float(va @ vb)))) 1157 + # sample densely, then drop points that add nothing on screen 1158 + n = 2 1159 + while n < 4096: 1160 + ts = np.linspace(0, 1, n) 1161 + if omega < 1e-9: 1162 + vs = np.outer(1 - ts, va) + np.outer(ts, vb) 1163 + else: 1164 + vs = (np.outer(np.sin((1 - ts) * omega), va) + np.outer(np.sin(ts * omega), vb)) / math.sin(omega) 1165 + lat = np.degrees(np.arcsin(np.clip(vs[:, 2], -1, 1))) 1166 + lon = np.degrees(np.arctan2(vs[:, 1], vs[:, 0])) 1167 + x, y = proj.forward(lat, lon) 1168 + step = np.hypot(np.diff(x), np.diff(y)) 1169 + if step.max() <= max_px: 1170 + break 1171 + n *= 2 1172 + pieces, cur = [], [(x[0], y[0])] 1173 + for k, s in enumerate(step): 1174 + if s > proj.width * 0.25: 1175 + if len(cur) > 1: 1176 + pieces.append(cur) 1177 + cur = [] 1178 + cur.append((x[k + 1], y[k + 1])) 1179 + if len(cur) > 1: 1180 + pieces.append(cur) 1181 + return pieces 1182 + 1183 + 1184 + def sun_icon(path: Path | None, size: int) -> Image.Image: 1185 + if path is not None: 1186 + try: 1187 + img = Image.open(path).convert("RGB").resize((size, size), Image.LANCZOS) 1188 + yy, xx = np.mgrid[:size, :size] 1189 + r = np.hypot(xx - size / 2 + 0.5, yy - size / 2 + 0.5) / (size / 2) 1190 + alpha = np.clip((0.86 - r) / 0.06, 0, 1) 1191 + rgba = np.dstack([np.asarray(img), (alpha * 255).astype(np.uint8)]) 1192 + return Image.fromarray(rgba, "RGBA") 1193 + except Exception as e: # noqa: BLE001 1194 + log(f"warning: sun image: {e}") 1195 + img = Image.new("RGBA", (size, size)) 1196 + ImageDraw.Draw(img).ellipse((size * 0.2, size * 0.2, size * 0.8, size * 0.8), fill=(255, 210, 80, 255)) 1197 + return img 1198 + 1199 + 1200 + def moon_icon(size: int, elongation: float, sun_dir: tuple[float, float]) -> Image.Image: 1201 + """Moon disc lit from screen direction sun_dir with the real phase.""" 1202 + phase = math.radians(180.0 - elongation) # sun-moon-earth angle 1203 + ex, ey = sun_dir 1204 + yy, xx = np.mgrid[:size, :size] 1205 + x = (xx + 0.5 - size / 2) / (size / 2) 1206 + y = -(yy + 0.5 - size / 2) / (size / 2) 1207 + r2 = x * x + y * y 1208 + z = np.sqrt(np.clip(1 - r2, 0, 1)) 1209 + lit = (x * ex + y * ey) * math.sin(phase) + z * math.cos(phase) 1210 + shade = np.clip(lit * 4, 0, 1) * 0.85 + 0.12 1211 + alpha = np.clip((1 - np.sqrt(r2)) * size / 2, 0, 1) 1212 + g = (shade * 235).astype(np.uint8) 1213 + rgba = np.dstack([g, g, (shade * 225).astype(np.uint8), (alpha * 255).astype(np.uint8)]) 1214 + return Image.fromarray(rgba, "RGBA") 1215 + 1216 + 1217 + def paste_centered(canvas: Image.Image, icon: Image.Image, x: float, y: float) -> None: 1218 + canvas.alpha_composite(icon, (int(round(x - icon.width / 2)), int(round(y - icon.height / 2)))) 1219 + 1220 + 1221 + def draw_lines(canvas: Image.Image, proj: LensMap, sky: Sky, scale: float) -> None: 1222 + layer = Image.new("RGBA", canvas.size) 1223 + d = ImageDraw.Draw(layer) 1224 + width = max(1, round(1.2 * scale)) 1225 + for a, b in sky.lines: 1226 + for piece in great_circle(a, b, proj): 1227 + d.line(piece, fill=(255, 255, 255, 110), width=width, joint="curve") 1228 + canvas.alpha_composite(layer) 1229 + 1230 + 1231 + def draw_bodies(canvas, proj, sky: Sky, scale: float, font_path, sun_image) -> None: 1232 + sx, sy = proj.forward(*sky.bodies["sun"]) 1233 + mx, my = proj.forward(*sky.bodies["moon"]) 1234 + font = ImageFont.truetype(str(font_path), max(8, round(34 * scale))) if font_path else None 1235 + d = ImageDraw.Draw(canvas) 1236 + for name, glyph in PLANETS.items(): 1237 + x, y = proj.forward(*sky.bodies[name]) 1238 + if font is not None: 1239 + d.text((float(x), float(y)), glyph, font=font, anchor="mm", fill=(254, 128, 25, 255), 1240 + stroke_width=max(1, round(2 * scale)), stroke_fill=(0, 0, 0, 200)) 1241 + else: 1242 + r = 6 * scale 1243 + d.ellipse((x - r, y - r, x + r, y + r), fill=(254, 128, 25, 255)) 1244 + dx, dy = float(sx - mx), float(my - sy) 1245 + norm = math.hypot(dx, dy) or 1.0 1246 + paste_centered(canvas, moon_icon(max(8, round(50 * scale)), sky.moon_elongation, (dx / norm, dy / norm)), 1247 + float(mx), float(my)) 1248 + paste_centered(canvas, sun_icon(sun_image, max(8, round(100 * scale))), float(sx), float(sy)) 1249 + 1250 + 1251 + def draw_markers(canvas, proj, markers: list[tuple[float, float]], icon_path, scale: float) -> None: 1252 + if not markers or icon_path is None: 1253 + return 1254 + size = max(8, round(20 * scale)) 1255 + icon = Image.open(icon_path).convert("RGBA").resize((size, size), Image.LANCZOS) 1256 + for lat, lon in markers: 1257 + x, y = proj.forward(lat, lon) 1258 + paste_centered(canvas, icon, float(x), float(y)) 1259 + 1260 + 1261 + def load_markers(path: Path | None) -> list[tuple[float, float]]: 1262 + if path is None: 1263 + return [] 1264 + try: 1265 + data = json.loads(path.read_text()) 1266 + except Exception as e: # noqa: BLE001 1267 + log(f"warning: markers: {e}") 1268 + return [] 1269 + entries = data.values() if isinstance(data, dict) else data 1270 + out = [] 1271 + for v in entries: 1272 + if isinstance(v, dict) and v.get("latitude") is not None and v.get("longitude") is not None: 1273 + out.append((float(v["latitude"]), float(v["longitude"]))) 1274 + return out 1275 + 1276 + 1277 + def save(img: Image.Image, path: Path, **kw) -> None: 1278 + tmp = path.with_name(f".{path.name}.tmp") 1279 + img.save(tmp, format=path.suffix.lstrip(".").replace("jpg", "jpeg").upper(), **kw) 1280 + os.replace(tmp, path) 1281 + 1282 + 1283 + # -------------------------------------------------------------------------- 1284 + 1285 + 1286 + def main() -> None: 1287 + env = os.environ.get 1288 + p = argparse.ArgumentParser(prog="realwallpaper", description=__doc__.splitlines()[0]) 1289 + p.add_argument("--dir", type=Path, default=Path("."), 1290 + help="output and cache directory (default: .)") 1291 + p.add_argument("--lat", type=float, default=52.520008, 1292 + help="home latitude in degrees, south negative (default Berlin)") 1293 + p.add_argument("--lon", type=float, default=13.404954, 1294 + help="home longitude in degrees, west negative (default Berlin)") 1295 + p.add_argument("--width", type=int, default=3840) 1296 + p.add_argument("--height", type=int, default=2160) 1297 + p.add_argument("--magnification", type=float, default=6.0, 1298 + help="zoom at home relative to the plain map (default 6)") 1299 + p.add_argument("--lens", type=float, default=1.0, 1300 + help="lens size relative to the screen; 1 reaches the edges (default 1)") 1301 + p.add_argument("--lens-shape", choices=["circle", "ellipse", "rect"], default="ellipse", 1302 + help="circle; ellipse or rect follow the screen's aspect (default ellipse)") 1303 + p.add_argument("--focus", type=float, default=0.0, 1304 + help="share of the lens zoomed undistorted before the transition (default 0)") 1305 + p.add_argument("--falloff", type=float, default=0.65, 1306 + help="where across the lens (0..1) the distortion peaks; higher keeps " 1307 + "the middle closer to a plain zoom (default 0.65)") 1308 + p.add_argument("--projection", choices=["lens", "plain", "mercator"], default="lens", 1309 + help="lens: centred on home with the zoom lens; plain: centred on home " 1310 + "without distortion; mercator: classic north-up map (default lens)") 1311 + p.add_argument("--marker-url", default=None, 1312 + help="JSON with {name: {latitude, longitude}} for krebs markers") 1313 + p.add_argument("--time", type=dt.datetime.fromisoformat, default=None, 1314 + help="render for this UTC time instead of now (ISO 8601)") 1315 + p.add_argument("--no-archive", action="store_true", help="do not keep a jpg in archive/") 1316 + args = p.parse_args() 1317 + 1318 + when = args.time or dt.datetime.now(dt.timezone.utc) 1319 + if when.tzinfo is None: 1320 + when = when.replace(tzinfo=dt.timezone.utc) 1321 + when = when.astimezone(dt.timezone.utc) 1322 + 1323 + out_dir = args.dir.resolve() 1324 + out_dir.mkdir(parents=True, exist_ok=True) 1325 + cache = Cache(out_dir / "cache") 1326 + proj = LensMap(args.lat, args.lon, args.width, args.height, 1327 + args.magnification, args.lens, args.lens_shape, args.focus, args.falloff, 1328 + args.projection) 1329 + home_x, home_y = proj.forward(args.lat, args.lon) 1330 + home_q = proj.inverse(np.array([home_x]), np.array([home_y]))[2][0] 1331 + log(f"projection: {args.projection}, home at {home_q * 1000:.0f} m/px, " 1332 + f"finest {proj.q0 * 1000:.0f} m/px") 1333 + 1334 + font = env("REALWALLPAPER_FONT") 1335 + constellations = env("REALWALLPAPER_CONSTELLATIONS") 1336 + krebs_icon = env("REALWALLPAPER_KREBS_ICON") 1337 + 1338 + t0 = time.monotonic() 1339 + sun_image = cache.get_url("sun.jpg", "https://sdo.gsfc.nasa.gov/assets/img/latest/latest_512_0171.jpg", 3600) 1340 + marker_file = cache.get_url("markers.json", args.marker_url, 0) if args.marker_url else None 1341 + lay = load_layers(proj, cache, when) 1342 + log(f"data ready in {time.monotonic() - t0:.1f}s") 1343 + 1344 + try: 1345 + sky = compute_sky(when, Path(constellations) if constellations else None) 1346 + except Exception as e: # noqa: BLE001 - the map is still useful without the sky 1347 + log(f"warning: sky: {e}") 1348 + sky = None 1349 + sun = sky.bodies["sun"] if sky else (0.0, 0.0) 1350 + 1351 + t0 = time.monotonic() 1352 + base = Image.fromarray(render_base(proj, lay, sun)).convert("RGBA") 1353 + log(f"rendered in {time.monotonic() - t0:.1f}s") 1354 + 1355 + scale = args.height / 1800 1356 + markers = load_markers(marker_file) 1357 + 1358 + plain = base 1359 + marker = plain.copy() 1360 + if sky: 1361 + draw_bodies(marker, proj, sky, scale, font, sun_image) 1362 + krebs = marker.copy() 1363 + draw_markers(krebs, proj, markers, krebs_icon, scale) 1364 + stars = plain.copy() 1365 + if sky: 1366 + draw_lines(stars, proj, sky, scale) 1367 + draw_bodies(stars, proj, sky, scale, font, sun_image) 1368 + draw_markers(stars, proj, markers, krebs_icon, scale) 1369 + 1370 + for name, img in [ 1371 + ("realwallpaper.png", plain), 1372 + ("realwallpaper-marker.png", marker), 1373 + ("realwallpaper-krebs.png", krebs), 1374 + ("realwallpaper-krebs-stars.png", stars), 1375 + ]: 1376 + save(img.convert("RGB"), out_dir / name, compress_level=3) 1377 + if not args.no_archive: 1378 + (out_dir / "archive").mkdir(exist_ok=True) 1379 + save(krebs.convert("RGB"), out_dir / "archive" / f"{when.strftime('%Y-%m-%dT%H:%M:%SZ')}.jpg", 1380 + quality=88) 1381 + cache.cleanup() 1382 + log("done") 1383 + 1384 + 1385 + if __name__ == "__main__": 1386 + main()
+43
tools/realwallpaper/usage.kdl
··· 1 + bin "realwallpaper" 2 + about "Render a live world map wallpaper: oblique Mercator centred on home with a fisheye lens (clouds, day/night, snow, ice, fire, sky)" 3 + 4 + flag "--dir" help="Output and cache directory (default .)" { 5 + arg "<dir>" 6 + } 7 + flag "--lat" help="Home latitude (default Berlin)" { 8 + arg "<deg>" 9 + } 10 + flag "--lon" help="Home longitude (default Berlin)" { 11 + arg "<deg>" 12 + } 13 + flag "--width" help="Output width in px (default 3840)" { 14 + arg "<px>" 15 + } 16 + flag "--height" help="Output height in px (default 2160)" { 17 + arg "<px>" 18 + } 19 + flag "--magnification" help="Zoom at home relative to the plain map (default 6)" { 20 + arg "<factor>" 21 + } 22 + flag "--lens" help="Lens size relative to the screen; 1 reaches the edges (default 1)" { 23 + arg "<fraction>" 24 + } 25 + flag "--lens-shape" help="circle; ellipse or rect follow the screen's aspect (default ellipse)" { 26 + arg "<shape>" 27 + } 28 + flag "--focus" help="Share of the lens zoomed undistorted before the transition (default 0)" { 29 + arg "<fraction>" 30 + } 31 + flag "--falloff" help="Where across the lens (0..1) the distortion peaks; higher keeps the middle closer to a plain zoom (default 0.65)" { 32 + arg "<position>" 33 + } 34 + flag "--projection" help="lens: centred on home with the zoom lens; plain: centred on home without distortion; mercator: classic north-up map (default lens)" { 35 + arg "<mode>" 36 + } 37 + flag "--marker-url" help="JSON {name: {latitude, longitude}} for krebs markers" { 38 + arg "<url>" 39 + } 40 + flag "--time" help="Render for this UTC time instead of now (ISO 8601)" { 41 + arg "<time>" 42 + } 43 + flag "--no-archive" help="Do not keep a jpg copy in archive/"