Compare commits

...

11 Commits

Author SHA1 Message Date
Schuwi
56f779990d Update README to reflect current project state 2026-03-07 23:38:36 +01:00
Schuwi
28b7e90af8 Improve color scheme
- boost base map contrast
- adjust cloud colors to a slightly bluish shade
- start drawing lowest cloud cover percentage above 0 at 30% alpha
2026-03-07 23:19:11 +01:00
Schuwi
c7c08ebead Add --center-lat, --center-lon, --zoom CLI arguments
Allow overriding the map centre and OSM zoom level from the command
line, keeping the existing values as defaults.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 11:10:55 +01:00
Schuwi
71d6588dc3 Derive OSM viewport from zoom level instead of the other way around
Replace the heuristic choose_zoom() back-computation with a fixed
OSM_ZOOM constant (zoom 10). The orthographic projection half-extents
are now derived so that one tile pixel maps exactly to one output pixel,
eliminating bilinear interpolation artefacts on the OSM base layer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 11:04:26 +01:00
Schuwi
ff69c1d2db Add OpenStreetMap base layer with Carto tile overlay
- Fetch Carto Voyager no-label base tiles and label-only tiles separately,
  cached under cache/osm_tiles/{z}/ and cache/osm_tiles/labels/{z}/
- Rasterize both into the orthographic projection via exact inverse projection
  (new unproject/pixel_to_geo methods on OrthoProjection)
- Render pipeline: basemap → cloud blend → label overlay (labels above clouds)
- Bilinear interpolation for base tiles; premultiplied-alpha bilinear for label
  tiles to prevent dark-fringe artifacts at text edges
- Dynamic zoom selection (floor-based) from actual geographic bounding box
- Fix horizontal squish: derive HALF_W_DEG from pixel aspect ratio so
  degrees-per-pixel is equal on both axes
- Add --no-basemap flag to skip tile fetching for offline/fast use
- Remove hardcoded city markers/labels when tile label overlay is present

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 10:52:34 +01:00
Schuwi
ac9f9da3c4 Fix green bar at bottom of map caused by off-by-one in map height
map_h was subtracting MARGIN twice but the map_area only has a single
bottom margin, leaving the last 8 rows of the land background unrendered.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 22:57:05 +01:00
Schuwi
764f4f6378 Add animated GIF output with parallelised frame quantization
Render an animated clct_animation.gif alongside the per-step PNGs.
Frames are loaded and colour-quantized in parallel via rayon, then
written sequentially with the gif crate.  Also converts timestamps
to CET/CEST using chrono-tz.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 22:53:49 +01:00
Schuwi
0c73e04959 Parallelize GRIB2 downloads using a dedicated rayon thread pool
Downloads clat/clon concurrently via rayon::join and all forecast steps
in parallel via par_iter, then renders frames sequentially. A separate
thread pool (up to 16 threads) is used for downloads to avoid blocking
the global pool during CPU-bound rendering.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 22:27:39 +01:00
Schuwi
ee9b039d56 Improve rendering: barycentric interpolation, Gaussian blur, white-cloud palette
Replace nearest-neighbour BFS flood fill with Delaunay triangulation
(spade) + barycentric interpolation for smooth cloud boundaries.
Add NaN-aware separable Gaussian blur (σ=8 px, rayon-parallelised) to
remove triangulation facets. Switch colour scheme from blue bands to a
continuous green-to-white opacity blend matching the DWD app style.
Pixel interpolation loop is also parallelised with rayon.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 22:19:46 +01:00
Schuwi
4ee33a882d Flood fill to improve performance 2026-03-06 21:41:16 +01:00
Schuwi
a9eed7f378 Fix GRIB2 scale factor decoding (sign-magnitude, not two's complement)
GRIB2 encodes binary (E) and decimal (D) scale factors in sign-and-magnitude
format (MSB = sign bit, lower 15 bits = magnitude), not two's complement.
The previous read_i16 call misinterpreted 0x8009 as -32759 instead of -9,
causing 2^E to underflow to 0.0 in f32 and zeroing all decoded values.

Fix: replace read_i16 with read_grib_scale for E and D in
decode_simple_packing. Add regression tests covering sign-magnitude decoding
and a realistic DWD CLCT packing scenario.
2026-03-06 21:40:39 +01:00
9 changed files with 1150 additions and 177 deletions

205
Cargo.lock generated
View File

@@ -8,6 +8,12 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "android_system_properties"
version = "0.1.5"
@@ -121,6 +127,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.11.1"
@@ -180,6 +192,16 @@ dependencies = [
"windows-link",
]
[[package]]
name = "chrono-tz"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
dependencies = [
"chrono",
"phf",
]
[[package]]
name = "clap"
version = "4.5.60"
@@ -227,10 +249,15 @@ dependencies = [
"anyhow",
"bzip2",
"chrono",
"chrono-tz",
"clap",
"font8x8",
"gif",
"image 0.25.9",
"plotters",
"rayon",
"reqwest",
"spade",
]
[[package]]
@@ -260,6 +287,31 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "displaydoc"
version = "0.2.5"
@@ -271,6 +323,18 @@ dependencies = [
"syn",
]
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "fdeflate"
version = "0.3.7"
@@ -296,6 +360,12 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "font8x8"
version = "0.2.7"
@@ -387,6 +457,27 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "gif"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e"
dependencies = [
"color_quant",
"weezl",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
@@ -630,7 +721,20 @@ dependencies = [
"color_quant",
"jpeg-decoder",
"num-traits",
"png",
"png 0.17.16",
]
[[package]]
name = "image"
version = "0.25.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a"
dependencies = [
"bytemuck",
"byteorder-lite",
"moxcms",
"num-traits",
"png 0.18.1",
]
[[package]]
@@ -728,6 +832,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "moxcms"
version = "0.7.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -755,6 +869,24 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "phf"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
dependencies = [
"phf_shared",
]
[[package]]
name = "phf_shared"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
dependencies = [
"siphasher",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@@ -798,7 +930,7 @@ version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72ce181e3f6bf82d6c1dc569103ca7b1bd964c60ba03d7e6cdfbb3e3eb7f7405"
dependencies = [
"image",
"image 0.24.9",
"plotters-backend",
]
@@ -815,6 +947,19 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags 2.11.0",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -842,6 +987,12 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "pxfm"
version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
[[package]]
name = "quinn"
version = "0.11.9"
@@ -941,6 +1092,26 @@ dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rayon"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "reqwest"
version = "0.12.28"
@@ -995,6 +1166,12 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "robust"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e27ee8bb91ca0adcf0ecb116293afa12d393f9c2b9b9cd54d33e8078fe19839"
[[package]]
name = "rustc-hash"
version = "2.1.1"
@@ -1115,6 +1292,12 @@ version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
[[package]]
name = "siphasher"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
[[package]]
name = "slab"
version = "0.4.12"
@@ -1137,6 +1320,18 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "spade"
version = "2.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb313e1c8afee5b5647e00ee0fe6855e3d529eb863a0fdae1d60006c4d1e9990"
dependencies = [
"hashbrown",
"num-traits",
"robust",
"smallvec",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -1473,6 +1668,12 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "windows-core"
version = "0.62.2"

View File

@@ -15,3 +15,8 @@ font8x8 = "0.2"
clap = { version = "4", features = ["derive"] }
chrono = { version = "0.4", default-features = false, features = ["std", "clock"] }
anyhow = "1"
spade = "2"
rayon = "1"
image = { version = "0.25", default-features = false, features = ["png"] }
gif = "0.14"
chrono-tz = "0.10.4"

156
README.md
View File

@@ -1,97 +1,141 @@
# cloud_cover
Downloads total cloud cover (CLCT) predictions from the [DWD open-data
server](https://opendata.dwd.de/weather/nwp/icon-d2/grib/) and renders
one PNG map per forecast hour, centred on the Brandenburg / Berlin area.
Cloud cover forecast maps for astrophotography planning, powered by DWD open data.
This is a Rust port of `../cloud_cover_prediction.py`. The output format
differs: instead of using PyNGL's contour renderer the Rust version does a
direct point-by-point orthographic projection of the ICON-D2 grid onto a
bitmap, which avoids every system-library dependency (no CDO, no PyNIO, no
PyNGL, no libssl, no libbz2 — the binary is fully self-contained).
![Example output — prediction for the Berlin/Brandenburg area](docs/example.png)
## What is this?
The German Weather Service (DWD) publishes free, high-resolution numerical weather
predictions through its [open-data server](https://opendata.dwd.de/). The
**ICON-D2** model covers Germany and surrounding areas with ~2 km grid spacing and
produces forecasts up to 48 hours ahead, updated every 3 hours.
This tool downloads the **total cloud cover (CLCT)** field from ICON-D2 and renders
it as a series of map images — one per forecast hour — overlaid on an OpenStreetMap
base layer. The result is a quick visual answer to "will the sky be clear tonight?"
## Quick start
```sh
cargo build --release
./target/release/cloud_cover "2026-03-07T09:00:00Z" 24
```
Output appears in `cache/2026-03-07_09UTC/` — one PNG per forecast hour plus an
animated GIF.
## Usage
```
cloud_cover <ISO_TIMESTAMP> <HOURS>
cloud_cover [OPTIONS] <ISO_TIMESTAMP> <HOURS>
```
| Argument | Description |
|---|---|
| `ISO_TIMESTAMP` | Model run time in ISO 8601, e.g. `2026-03-03T18:00:00Z` |
| `ISO_TIMESTAMP` | Model run time in ISO 8601, e.g. `2026-03-07T09:00:00Z` |
| `HOURS` | Number of forecast steps to render (048) |
The timestamp must correspond to an ICON-D2 model run, which is issued
every 3 hours starting at 00 UTC (00, 03, 06, 09, 12, 15, 18, 21).
The timestamp must match an ICON-D2 model run: every 3 hours starting at 00 UTC
(00, 03, 06, 09, 12, 15, 18, 21).
### Example
### Options
| Flag | Default | Description |
|---|---|---|
| `--center-lat` | 52.56 | Map centre latitude (°N) |
| `--center-lon` | 13.08 | Map centre longitude (°E) |
| `--zoom` | 10 | OSM tile zoom level (higher = more detail, smaller area) |
| `--no-basemap` | — | Skip OSM tiles; use a plain green background |
### Examples
```sh
cargo run --release -- "2026-03-03T18:00:00Z" 12
# Next 12 hours from the 18 UTC run, default viewport (Falkensee / Berlin)
./target/release/cloud_cover "2026-03-06T18:00:00Z" 12
# Wider area centred on central Germany at zoom 8
./target/release/cloud_cover "2026-03-07T06:00:00Z" 24 \
--center-lat 51.0 --center-lon 10.5 --zoom 8
# Quick run without map tiles
./target/release/cloud_cover "2026-03-07T09:00:00Z" 6 --no-basemap
```
Output PNGs are written to `cache/<date>_<hour>UTC/clct_000001.png`
`clct_NNNNNN.png`. Downloaded GRIB2 files are cached in the same directory
so that re-running the same timestamp skips the network requests.
## Output
Each run produces files in `cache/<date>_<hour>UTC/`:
- **`clct_000001.png``clct_NNNNNN.png`** — one 900 × 600 px map per forecast
hour, showing cloud cover blended over the OSM base layer with a colour legend
- **`clct_animation.gif`** — animated loop of all frames (generated when there are
multiple steps)
- **`clat.grib2`, `clon.grib2`, `clct_NNN.grib2`** — cached raw forecast data;
re-running the same timestamp skips the download
Image titles show the forecast time converted to CET/CEST (Europe/Berlin). The first
frame is labelled "Conditions at …", subsequent frames "Prediction at … (+NNh)".
Cloud cover is rendered as a continuous blend from the base map (clear sky) toward a
light blue-white tone (overcast), matching the style familiar from weather apps.
## How it works
1. **Download**`reqwest` fetches the bzip2-compressed GRIB2 files from
the DWD server. Two coordinate files (`clat`, `clon`) give the latitude
and longitude of every point on the ICON-D2 icosahedral grid (~542 000
points for Germany). One `clct` file per forecast step contains the
total cloud cover percentage at each point.
1. **Download**Fetches bzip2-compressed GRIB2 files from the DWD open-data
server: two coordinate grids (`clat`, `clon`) describing the ~542,000 points of
the ICON-D2 icosahedral grid over Germany, plus one `clct` file per forecast step.
Downloads run in parallel and are cached to disk.
2. **Decompress**`bzip2` decompresses the files in-memory.
2. **Parse** — A built-in minimal GRIB2 decoder extracts the data arrays. Only
simple packing (data representation template 0) is implemented, which is the
format DWD uses for these fields. Grid points flagged absent by the GRIB2 bitmap
are set to NaN.
3. **Parse** — A minimal built-in GRIB2 decoder extracts the data values.
Only simple packing (data representation template 0) is supported, which
is what DWD uses. Grid points that are flagged as absent by the section-6
bitmap are set to `NaN` and skipped during rendering.
3. **Interpolate** — The irregular grid points are projected into pixel space via an
orthographic projection, then connected into a Delaunay triangulation. Each output
pixel is interpolated via barycentric coordinates within its enclosing triangle,
followed by a NaN-aware Gaussian blur to smooth triangle edges.
4. **Project & render**Each grid point is projected onto the image plane
using an orthographic projection centred on Falkensee (52.56 °N,
13.08 °E). Points are painted as 2 × 2 pixel squares, colour-coded by
cloud cover percentage. City markers and labels are drawn on top, and a
colour-scale legend is shown on the right. `plotters` writes the final
PNG.
4. **Render**The base layer comes from Carto Voyager OSM tiles (fetched and
cached separately for the basemap and for labels). Cloud cover is blended on top,
then map labels are composited above the clouds. City markers, a title bar, and a
colour legend complete the frame.
## Cloud cover colour scale
| Cloud cover | Colour |
|---|---|
| < 1 % | Land background (clear) |
| 12 % | Very light blue |
| 25 % | Light blue |
| 510 % | Medium blue |
| 1020 % | Blue |
| 2050 % | Dark blue |
| 50100 % | Very dark blue |
## Marked locations
Falkensee · Pausin · Nauen · Hennigsdorf · Ketzin/Brückenkopf · Potsdam ·
Berlin (Mitte)
5. **Animate** — All frames are colour-quantised to 256-colour palettes (in parallel)
and assembled into a looping GIF.
## Dependencies
All dependencies are pure Rust or vendored C code compiled into the binary.
No system shared libraries are required at runtime beyond libc.
All dependencies are pure Rust or vendored C compiled into the binary — no system
shared libraries are required at runtime beyond libc.
| Crate | Role |
|---|---|
| `reqwest` (rustls-tls) | HTTP client with pure-Rust TLS |
| `bzip2` | bzip2 decompression (vendors libbzip2) |
| `plotters` + `plotters-bitmap` | PNG rendering with built-in bitmap font |
| `bzip2` | Bzip2 decompression (vendors libbzip2) |
| `plotters` + `plotters-bitmap` | PNG rendering |
| `font8x8` | Built-in 8×8 bitmap font for map labels |
| `clap` | CLI argument parsing |
| `chrono` | Date/time handling |
| `chrono` + `chrono-tz` | Date/time handling and timezone conversion |
| `anyhow` | Error propagation |
| `spade` | Delaunay triangulation for grid interpolation |
| `rayon` | Parallel downloads, rendering, and GIF quantisation |
| `image` | PNG decoding (loading frames for GIF assembly) |
| `gif` | GIF encoding |
## Building
```sh
cd cloud_cover_rs
cargo build --release
```
The compiled binary ends up at `target/release/cloud_cover`.
The binary is at `target/release/cloud_cover`.
## Future ideas
- Configurable display timezone (currently hardcoded to Europe/Berlin)
- Configurable city markers / observation sites
- Configurable data sources
- Vector map tile support for increased rendering resolution
- Separation of cache and output files & automatic cache management
- Automatic newest prediction fetching

BIN
docs/example.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

View File

@@ -171,20 +171,23 @@ fn decode_simple_packing(sec5: &[u8], sec7: &[u8], n: u32) -> Result<Vec<f32>> {
let r_bytes: [u8; 4] = sec5[0..4].try_into().unwrap();
let r = f32::from_bits(u32::from_be_bytes(r_bytes));
let e = read_i16(&sec5[4..6]);
let d = read_i16(&sec5[6..8]);
// GRIB2 encodes E and D as sign-and-magnitude (MSB = sign, lower 15 bits = magnitude),
// NOT two's complement. Using i16::from_be_bytes would misinterpret e.g. 0x8009 as
// -32759 instead of the correct -9, causing 2^E to underflow to zero.
let e = read_grib_scale(&sec5[4..6]);
let d = read_grib_scale(&sec5[6..8]);
let n_bits = sec5[8] as u32;
if n_bits == 0 {
// All values equal the reference value
return Ok(vec![r * 10f32.powi(-(d as i32)); n as usize]);
return Ok(vec![r * 10f32.powi(-d); n as usize]);
}
if n_bits > 32 {
bail!("Implausible n_bits = {}", n_bits);
}
let scale_e = 2f32.powi(e as i32);
let scale_d = 10f32.powi(-(d as i32));
let scale_e = 2f32.powi(e);
let scale_d = 10f32.powi(-d);
let mut values = Vec::with_capacity(n as usize);
let mut bit_offset: u64 = 0;
@@ -239,8 +242,15 @@ fn read_u16(b: &[u8]) -> u16 {
u16::from_be_bytes(b[0..2].try_into().unwrap())
}
fn read_i16(b: &[u8]) -> i16 {
i16::from_be_bytes(b[0..2].try_into().unwrap())
/// Decode a GRIB2 sign-and-magnitude 16-bit integer.
///
/// The WMO GRIB2 specification encodes binary and decimal scale factors using
/// a non-standard representation: bit 15 is the sign (1 = negative) and bits
/// 140 hold the unsigned magnitude. This is different from two's complement.
fn read_grib_scale(b: &[u8]) -> i32 {
let raw = u16::from_be_bytes(b[0..2].try_into().unwrap());
let magnitude = (raw & 0x7FFF) as i32;
if raw & 0x8000 != 0 { -magnitude } else { magnitude }
}
#[cfg(test)]
@@ -270,6 +280,34 @@ mod tests {
assert_eq!(result, vec![5.0f32, 5.0, 5.0]);
}
#[test]
fn test_read_grib_scale_sign_magnitude() {
// 0x8009 = sign bit set, magnitude 9 → should be -9, not -32759 (two's complement)
assert_eq!(read_grib_scale(&[0x80, 0x09]), -9);
// 0x0009 = positive 9
assert_eq!(read_grib_scale(&[0x00, 0x09]), 9);
// 0x0000 = zero
assert_eq!(read_grib_scale(&[0x00, 0x00]), 0);
// 0x8000 = negative zero (sign bit set, magnitude 0) → 0
assert_eq!(read_grib_scale(&[0x80, 0x00]), 0);
}
#[test]
fn test_simple_packing_with_neg_e() {
// Simulate DWD CLCT packing: R=0, E=-9 (0x8009), D=0, n_bits=16
// A packed value of 51200 should decode to 51200 * 2^-9 = 100.0
let r = 0.0f32;
let mut sec5 = vec![0u8; 10];
sec5[0..4].copy_from_slice(&r.to_bits().to_be_bytes());
sec5[4..6].copy_from_slice(&[0x80, 0x09]); // E = -9 in sign-magnitude
sec5[6..8].copy_from_slice(&[0x00, 0x00]); // D = 0
sec5[8] = 16; // 16 bits per value
// Packed value 51200 as big-endian u16
let sec7 = [0xC8u8, 0x00]; // 0xC800 = 51200
let result = decode_simple_packing(&sec5, &sec7, 1).unwrap();
assert!((result[0] - 100.0f32).abs() < 0.01, "Expected ~100.0, got {}", result[0]);
}
#[test]
fn test_expand_with_bitmap() {
// bitmap: 0b10110000 → positions 0, 2, 3 are present; 1, 4-7 are missing

View File

@@ -2,10 +2,12 @@ mod download;
mod grib;
mod projection;
mod render;
mod tiles;
use anyhow::{bail, Context, Result};
use chrono::{DateTime, Datelike, Timelike, Utc};
use clap::Parser;
use rayon::prelude::*;
use std::path::PathBuf;
const BASE_URL: &str = "https://opendata.dwd.de/weather/nwp/icon-d2/grib/";
@@ -28,6 +30,22 @@ struct Args {
/// Number of forecast hours to download and render (048)
#[arg(value_name = "HOURS")]
prediction_hours: u32,
/// Disable OpenStreetMap base layer (use flat green background)
#[arg(long)]
no_basemap: bool,
/// Map centre latitude in degrees (default: 52.56)
#[arg(long, value_name = "LAT", default_value_t = render::DEFAULT_CENTER_LAT)]
center_lat: f64,
/// Map centre longitude in degrees (default: 13.08)
#[arg(long, value_name = "LON", default_value_t = render::DEFAULT_CENTER_LON)]
center_lon: f64,
/// OSM tile zoom level (default: 10)
#[arg(long, value_name = "ZOOM", default_value_t = render::DEFAULT_OSM_ZOOM)]
zoom: u8,
}
fn main() -> Result<()> {
@@ -77,26 +95,22 @@ fn main() -> Result<()> {
dt.hour()
);
// Download & decode coordinate grids
// Build a dedicated thread pool for I/O-bound downloads, separate from
// the global rayon pool used for CPU-bound rendering.
let num_download_threads = (args.prediction_hours as usize + 2).min(16);
let download_pool = rayon::ThreadPoolBuilder::new()
.num_threads(num_download_threads)
.build()
.context("Failed to build download thread pool")?;
// Download coordinate grids (clat / clon) concurrently.
eprintln!("Downloading coordinate grids (clat / clon)...");
let lats = download_and_decode(
&run_url,
&date_str,
"clat",
"time-invariant",
None,
&cache_dir,
)
.context("clat")?;
let lons = download_and_decode(
&run_url,
&date_str,
"clon",
"time-invariant",
None,
&cache_dir,
)
.context("clon")?;
let (lats, lons) = download_pool.join(
|| download_and_decode(&run_url, &date_str, "clat", "time-invariant", None, &cache_dir).context("clat"),
|| download_and_decode(&run_url, &date_str, "clon", "time-invariant", None, &cache_dir).context("clon"),
);
let lats = lats?;
let lons = lons?;
if lats.len() != lons.len() {
bail!(
@@ -107,21 +121,70 @@ fn main() -> Result<()> {
}
eprintln!(" Grid has {} points.", lats.len());
// Download, decode, and render each forecast hour
// Download all forecast steps in parallel, then render sequentially.
eprintln!("Downloading {} forecast frame(s) in parallel...", args.prediction_hours);
let mut cloud_data: Vec<(u32, Vec<f32>)> = download_pool.install(|| {
(0..args.prediction_hours)
.into_par_iter()
.map(|step| {
let cloud = download_and_decode(
&run_url,
&date_str,
"clct",
"single-level",
Some(step),
&cache_dir,
)
.with_context(|| format!("clct step {}", step))?;
Ok::<_, anyhow::Error>((step, cloud))
})
.collect::<Result<Vec<_>>>()
})?;
cloud_data.sort_unstable_by_key(|(step, _)| *step);
// Fetch and rasterize OSM basemap (once, reused for all frames).
let basemap = if args.no_basemap {
None
} else {
let proj = render::make_projection(args.center_lat, args.center_lon, args.zoom);
let tile_cache = tiles::fetch_tiles(
&proj,
render::MAP_W,
render::MAP_H,
args.zoom,
&PathBuf::from(CACHE_DIR),
)?;
Some(tiles::rasterize_basemap(
&proj,
render::MAP_W,
render::MAP_H,
&tile_cache,
))
};
let labels = if args.no_basemap {
None
} else {
let proj = render::make_projection(args.center_lat, args.center_lon, args.zoom);
let label_cache = tiles::fetch_label_tiles(
&proj,
render::MAP_W,
render::MAP_H,
args.zoom,
&PathBuf::from(CACHE_DIR),
)?;
Some(tiles::rasterize_labels(
&proj,
render::MAP_W,
render::MAP_H,
&label_cache,
))
};
eprintln!("Rendering {} frame(s)...", args.prediction_hours);
let mut output_paths = Vec::new();
for step in 0..args.prediction_hours {
let cloud = download_and_decode(
&run_url,
&date_str,
"clct",
"single-level",
Some(step),
&cache_dir,
)
.with_context(|| format!("clct step {}", step))?;
for (step, cloud) in cloud_data {
if cloud.len() != lats.len() {
bail!(
"clct step {} has {} values but coordinate grids have {}",
@@ -132,14 +195,15 @@ fn main() -> Result<()> {
}
let forecast_dt = dt + chrono::Duration::hours(step as i64);
let forecast_dt = forecast_dt.with_timezone(&chrono_tz::Europe::Berlin);
let title = if step == 0 {
format!(
"Conditions at {} UTC",
"Conditions at {} CET",
forecast_dt.format("%Y-%m-%d %H:%M")
)
} else {
format!(
"Prediction at {} UTC (+{:02}h)",
"Prediction at {} CET (+{:02}h)",
forecast_dt.format("%Y-%m-%d %H:%M"),
step
)
@@ -148,7 +212,7 @@ fn main() -> Result<()> {
let out_name = format!("clct_{:06}.png", step + 1);
let out_path = cache_dir.join(&out_name);
render::render_frame(&out_path, &lats, &lons, &cloud, &title)
render::render_frame(&out_path, &lats, &lons, &cloud, &title, basemap.as_deref(), labels.as_deref(), args.center_lat, args.center_lon, args.zoom)
.with_context(|| format!("Rendering frame {}", step))?;
eprintln!(" [{:02}/{:02}] {}", step + 1, args.prediction_hours, out_name);
@@ -160,6 +224,47 @@ fn main() -> Result<()> {
eprintln!(" {}", p.display());
}
if output_paths.len() > 1 {
eprintln!("Building animation...");
let gif_path = cache_dir.join("clct_animation.gif");
create_gif(&output_paths, &gif_path).context("Creating GIF")?;
eprintln!(" GIF: {}", gif_path.display());
}
Ok(())
}
fn create_gif(png_paths: &[PathBuf], out_path: &PathBuf) -> Result<()> {
use gif::{Encoder, Frame, Repeat};
// Load PNGs and quantize to 256-colour palettes in parallel (the bottleneck).
let n = png_paths.len();
let frames: Vec<Frame<'static>> = png_paths
.par_iter()
.enumerate()
.map(|(i, path)| -> Result<Frame<'static>> {
let img = image::open(path)
.with_context(|| format!("Loading PNG {}", path.display()))?
.into_rgba8();
let (width, height) = img.dimensions();
let mut pixels = img.into_raw();
// speed 8: slightly better quality than the default (10)
let mut frame =
Frame::from_rgba_speed(width as u16, height as u16, &mut pixels, 8);
// Pause longer on first and last frame; 1 unit = 10 ms
frame.delay = if i == 0 || i == n - 1 { 200 } else { 40 };
Ok(frame)
})
.collect::<Result<Vec<_>>>()?;
// Write frames sequentially (GIF encoder is not thread-safe).
let file = std::fs::File::create(out_path)?;
let (w, h) = (frames[0].width, frames[0].height);
let mut encoder = Encoder::new(file, w, h, &[])?;
encoder.set_repeat(Repeat::Infinite)?;
for frame in &frames {
encoder.write_frame(frame)?;
}
Ok(())
}

View File

@@ -50,6 +50,43 @@ impl OrthoProjection {
Some((x.to_degrees(), y.to_degrees()))
}
/// Inverse of `project`: convert projected (x, y) in degrees-of-arc back to
/// geographic (lat, lon) in degrees.
pub fn unproject(&self, x: f64, y: f64) -> (f64, f64) {
let x_rad = x.to_radians();
let y_rad = y.to_radians();
let rho = (x_rad * x_rad + y_rad * y_rad).sqrt();
if rho < 1e-14 {
// At the projection centre
return (self.phi0.to_degrees(), self.lam0.to_degrees());
}
let c = rho.asin();
let sin_c = c.sin();
let cos_c = c.cos();
let lat = (cos_c * self.phi0.sin() + y_rad * sin_c * self.phi0.cos() / rho).asin();
let lon = self.lam0
+ (x_rad * sin_c).atan2(rho * self.phi0.cos() * cos_c - y_rad * self.phi0.sin() * sin_c);
(lat.to_degrees(), lon.to_degrees())
}
/// Inverse of `to_pixel`: convert pixel (col, row) back to projected (x, y)
/// in degrees-of-arc.
pub fn from_pixel(&self, col: f64, row: f64, width: u32, height: u32) -> (f64, f64) {
let x = col / width as f64 * (2.0 * self.half_width) - self.half_width;
let y = self.half_height - row / height as f64 * (2.0 * self.half_height);
(x, y)
}
/// Convenience: convert pixel (col, row) directly to geographic (lat, lon).
pub fn pixel_to_geo(&self, col: f64, row: f64, width: u32, height: u32) -> (f64, f64) {
let (x, y) = self.from_pixel(col, row, width, height);
self.unproject(x, y)
}
/// Convert projected (x, y) in degrees-of-arc to pixel (col, row).
///
/// Returns `None` if the point falls outside the image boundary.

View File

@@ -2,31 +2,44 @@ use crate::projection::OrthoProjection;
use anyhow::{Context, Result};
use font8x8::{UnicodeFonts, BASIC_FONTS};
use plotters::prelude::*;
use rayon::prelude::*;
use spade::{DelaunayTriangulation, HasPosition, Point2, Triangulation};
use std::path::Path;
const IMG_WIDTH: u32 = 900;
const IMG_HEIGHT: u32 = 600;
pub const IMG_WIDTH: u32 = 900;
pub const IMG_HEIGHT: u32 = 600;
/// Map image centre and view window (must match what the Python script used).
const CENTER_LAT: f64 = 52.56;
const CENTER_LON: f64 = 13.08;
const HALF_W_DEG: f64 = 0.8; // horizontal half-extent in degrees of arc
const HALF_H_DEG: f64 = 0.4; // vertical half-extent
pub const LEGEND_W: u32 = 130;
pub const TITLE_H: u32 = 40;
pub const MARGIN: u32 = 8;
pub const MAP_W: u32 = IMG_WIDTH - LEGEND_W - MARGIN * 2;
pub const MAP_H: u32 = IMG_HEIGHT - TITLE_H - MARGIN;
/// Cloud-cover thresholds and their associated RGB colours.
/// Values below the first threshold are drawn as the background colour.
const LEVELS: [(f32, (u8, u8, u8)); 7] = [
(1.0, (208, 232, 248)), // very thin
(2.0, (160, 200, 232)),
(5.0, (112, 168, 216)),
(10.0, ( 64, 128, 192)),
(20.0, ( 32, 80, 160)),
(50.0, ( 16, 32, 96)),
(100.0, ( 5, 10, 48)),
];
/// Default map image centre and OSM zoom level.
pub const DEFAULT_CENTER_LAT: f64 = 52.56;
pub const DEFAULT_CENTER_LON: f64 = 13.08;
/// Default OSM tile zoom level. The viewport half-extents are derived from this so that
/// one tile pixel maps to exactly one output pixel, eliminating interpolation
/// artefacts on the OSM base layer.
pub const DEFAULT_OSM_ZOOM: u8 = 10;
/// Build the orthographic projection whose viewport is sized so that the
/// output resolution matches OSM tiles at `zoom` exactly.
///
/// At zoom `z`, Web-Mercator tiles cover `360 / 2^z` degrees of longitude per
/// tile, each 256 px wide. Near the centre latitude `φ`, 1° of longitude ≈
/// `cos φ` arc-degrees, so the arc-degree scale is:
/// `pixels_per_arc_deg = 256 · 2^z / (360 · cos φ)`
/// Half-extents follow directly from the map pixel dimensions.
pub fn make_projection(center_lat: f64, center_lon: f64, zoom: u8) -> OrthoProjection {
let scale = f64::from(256u32 << zoom as u32) / (360.0 * center_lat.to_radians().cos());
let half_w = MAP_W as f64 / (2.0 * scale);
let half_h = MAP_H as f64 / (2.0 * scale);
OrthoProjection::new(center_lat, center_lon, half_w, half_h)
}
const BACKGROUND: RGBColor = RGBColor(240, 248, 255); // pale sky-blue
const LAND_BG: RGBColor = RGBColor(232, 236, 220); // muted green for land
const LAND_BG: RGBColor = RGBColor(142, 178, 35); // strong green for land
const BLACK: RGBColor = RGBColor( 0, 0, 0);
const RED: RGBColor = RGBColor(220, 30, 30);
@@ -41,16 +54,60 @@ const LOCATIONS: &[(&str, f64, f64)] = &[
("Berlin (Mitte)", 13.39, 52.52),
];
fn cloud_color(cover: f32) -> Option<RGBColor> {
#[derive(Clone, Copy)]
struct CloudVertex {
position: Point2<f64>,
cloud: f32,
}
impl HasPosition for CloudVertex {
type Scalar = f64;
fn position(&self) -> Point2<f64> {
self.position
}
}
/// Target colour for 100 % cloud cover: a light blue-white, like an overcast sky.
/// Using a tinted target (rather than pure white) gives better contrast against
/// the pale yellow-beige tones of the basemap.
const CLOUD_TARGET: [f32; 3] = [226.0, 230.0, 239.0];
/// Boost the perceptual saturation of an RGB pixel by `factor`.
/// Uses a luma-weighted approach: moves each channel away from the grey value.
fn boost_saturation(pixel: [u8; 3], factor: f32) -> [u8; 3] {
let [r, g, b] = pixel;
let lum = 0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32;
let clamp_u8 = |v: f32| v.round().clamp(0.0, 255.0) as u8;
[
clamp_u8(lum + factor * (r as f32 - lum)),
clamp_u8(lum + factor * (g as f32 - lum)),
clamp_u8(lum + factor * (b as f32 - lum)),
]
}
fn scale_cloud_cover(cover: f32) -> f32 {
if cover < 1.0 {
return None; // clear sky — use background
0.0
} else {
30.0 + 0.7 * cover
}
for &(threshold, (r, g, b)) in LEVELS.iter().rev() {
if cover >= threshold {
return Some(RGBColor(r, g, b));
}
}
/// Map cloud cover (0100 %) to a colour by blending the base color toward
/// `CLOUD_TARGET`. Returns `None` for near-zero cover or NaN so the land background shows through.
fn cloud_color(base_color: RGBColor, cover: f32) -> Option<RGBColor> {
if cover.is_nan() || cover < 1.0 {
return None;
}
Some(RGBColor(LEVELS[0].1 .0, LEVELS[0].1 .1, LEVELS[0].1 .2))
let t = (cover / 100.0).clamp(0.0, 1.0);
let blend = |base: u8, target: f32| -> u8 {
(base as f32 + t * (target - base as f32)).round() as u8
};
Some(RGBColor(
blend(base_color.0, CLOUD_TARGET[0]),
blend(base_color.1, CLOUD_TARGET[1]),
blend(base_color.2, CLOUD_TARGET[2]),
))
}
/// Draw ASCII text using the built-in 8×8 pixel font.
@@ -90,6 +147,57 @@ fn draw_pixel_text(
}
}
/// Separable Gaussian blur on a flat f32 grid.
///
/// NaN pixels are treated as transparent: they contribute zero weight and the
/// result is renormalised over non-NaN neighbours only. Pixels with no non-NaN
/// neighbours remain NaN.
fn gaussian_blur(grid: &[f32], w: usize, h: usize, sigma: f32) -> Vec<f32> {
let radius = (3.0 * sigma).ceil() as usize;
let kernel: Vec<f32> = (0..=2 * radius)
.map(|i| {
let x = i as f32 - radius as f32;
(-x * x / (2.0 * sigma * sigma)).exp()
})
.collect();
// Horizontal pass
let temp: Vec<f32> = (0..h * w)
.into_par_iter()
.map(|idx| {
let row = idx / w;
let col = idx % w;
let mut val = 0.0f32;
let mut wsum = 0.0f32;
for (ki, &k) in kernel.iter().enumerate() {
let c = col as i64 + ki as i64 - radius as i64;
if c < 0 || c >= w as i64 { continue; }
let v = grid[row * w + c as usize];
if !v.is_nan() { val += k * v; wsum += k; }
}
if wsum > 0.0 { val / wsum } else { f32::NAN }
})
.collect();
// Vertical pass
(0..h * w)
.into_par_iter()
.map(|idx| {
let row = idx / w;
let col = idx % w;
let mut val = 0.0f32;
let mut wsum = 0.0f32;
for (ki, &k) in kernel.iter().enumerate() {
let r = row as i64 + ki as i64 - radius as i64;
if r < 0 || r >= h as i64 { continue; }
let v = temp[r as usize * w + col];
if !v.is_nan() { val += k * v; wsum += k; }
}
if wsum > 0.0 { val / wsum } else { f32::NAN }
})
.collect()
}
/// Render one forecast frame as a PNG.
///
/// - `lats`, `lons`, `cloud_cover` must all have the same length.
@@ -101,65 +209,151 @@ pub fn render_frame(
lons: &[f32],
cloud_cover: &[f32],
title: &str,
basemap: Option<&[[u8; 3]]>,
labels: Option<&[[u8; 4]]>,
center_lat: f64,
center_lon: f64,
zoom: u8,
) -> Result<()> {
let proj = OrthoProjection::new(CENTER_LAT, CENTER_LON, HALF_W_DEG, HALF_H_DEG);
let proj = make_projection(center_lat, center_lon, zoom);
let root = BitMapBackend::new(output_path, (IMG_WIDTH, IMG_HEIGHT)).into_drawing_area();
root.fill(&BACKGROUND).context("fill background")?;
const LEGEND_W: u32 = 130;
const TITLE_H: u32 = 40;
const MARGIN: u32 = 8;
let map_area = root.margin(TITLE_H, MARGIN, MARGIN, LEGEND_W + MARGIN);
let map_w = IMG_WIDTH - LEGEND_W - MARGIN * 2;
let map_h = IMG_HEIGHT - TITLE_H - MARGIN * 2;
let map_w = MAP_W;
let map_h = MAP_H;
let n_pixels = map_w as usize * map_h as usize;
map_area.fill(&LAND_BG).context("fill map area")?;
// Plot grid points
// Build a Delaunay triangulation in continuous pixel-space coordinates.
// Include all grid points that project within a small margin outside the image
// bounds so that boundary pixels interpolate cleanly without edge artefacts.
const TRI_MARGIN: f64 = 10.0;
let mut tri = DelaunayTriangulation::<CloudVertex>::new();
for i in 0..lats.len() {
let lat = lats[i] as f64;
let lon = lons[i] as f64;
let Some((px, py)) = proj.project(lat, lon) else { continue };
let Some((col, row)) = proj.to_pixel(px, py, map_w, map_h) else { continue };
let cover = cloud_cover[i];
if cover.is_nan() {
continue; // missing value (bitmap) — keep background
continue;
}
let color = match cloud_color(cover) {
Some(c) => c,
None => continue, // clear sky — keep land background
};
let Some((px, py)) = proj.project(lats[i] as f64, lons[i] as f64) else { continue };
// Continuous pixel coordinates — no rounding.
let col_f = (px + proj.half_width) / (2.0 * proj.half_width) * map_w as f64;
let row_f = (-py + proj.half_height) / (2.0 * proj.half_height) * map_h as f64;
if col_f < -TRI_MARGIN
|| col_f > map_w as f64 + TRI_MARGIN
|| row_f < -TRI_MARGIN
|| row_f > map_h as f64 + TRI_MARGIN
{
continue;
}
let _ = tri.insert(CloudVertex { position: Point2::new(col_f, row_f), cloud: scale_cloud_cover(cover) });
}
// Paint a 2×2 block to avoid gaps between grid points
for dy in 0..2i32 {
for dx in 0..2i32 {
let c = col + dx;
let r = row + dy;
if c >= 0 && c < map_w as i32 && r >= 0 && r < map_h as i32 {
map_area.draw_pixel((c, r), &color).ok();
// For each output pixel, locate the containing triangle and barycentric-interpolate
// the cloud cover from its three vertices. The triangulation is immutable at this
// point, so the work is embarrassingly parallel via rayon.
let grid_w = map_w as usize;
let grid_h = map_h as usize;
let cover_grid: Vec<f32> = (0..grid_h * grid_w)
.into_par_iter()
.map(|i| {
let col = (i % grid_w) as f64 + 0.5;
let row = (i / grid_w) as f64 + 0.5;
let p = Point2::new(col, row);
use spade::PositionInTriangulation::*;
match tri.locate(p) {
OnVertex(v) => tri.vertex(v).data().cloud,
OnEdge(e) => {
// Linear interpolation along the edge.
let edge = tri.directed_edge(e);
let a = edge.from();
let b = edge.to();
let (ax, ay) = (a.position().x, a.position().y);
let (bx, by) = (b.position().x, b.position().y);
let ab2 = (bx - ax).powi(2) + (by - ay).powi(2);
let t = if ab2 == 0.0 {
0.0
} else {
(((p.x - ax) * (bx - ax) + (p.y - ay) * (by - ay)) / ab2)
.clamp(0.0, 1.0)
};
a.data().cloud * (1.0 - t) as f32 + b.data().cloud * t as f32
}
OnFace(f) => {
let face = tri.face(f);
let [v0, v1, v2] = face.vertices();
let (ax, ay) = (v0.position().x, v0.position().y);
let (bx, by) = (v1.position().x, v1.position().y);
let (cx, cy) = (v2.position().x, v2.position().y);
let denom = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy);
if denom == 0.0 {
return v0.data().cloud;
}
let l0 = ((by - cy) * (p.x - cx) + (cx - bx) * (p.y - cy)) / denom;
let l1 = ((cy - ay) * (p.x - cx) + (ax - cx) * (p.y - cy)) / denom;
let l2 = 1.0 - l0 - l1;
v0.data().cloud * l0 as f32
+ v1.data().cloud * l1 as f32
+ v2.data().cloud * l2 as f32
}
OutsideOfConvexHull(_) | NoTriangulation => f32::NAN,
}
})
.collect();
const BLUR_SIGMA: f32 = 8.0;
let cover_grid = gaussian_blur(&cover_grid, grid_w, grid_h, BLUR_SIGMA);
// Build pixel buffer: start from basemap (saturation-boosted) or flat LAND_BG.
const SAT_BOOST: f32 = 1.8;
let mut pixel_buf: Vec<RGBColor> = if let Some(bm) = basemap {
bm.iter().map(|&p| boost_saturation(p, SAT_BOOST)).map(|p| RGBColor(p[0], p[1], p[2])).collect()
} else {
vec![LAND_BG; n_pixels]
};
// Blend cloud cover toward CLOUD_TARGET.
for (idx, &cover) in cover_grid.iter().enumerate() {
if let Some(cloud_col) = cloud_color(pixel_buf[idx], cover) {
pixel_buf[idx] = cloud_col;
}
}
// Draw city markers and labels
for &(label, lon, lat) in LOCATIONS {
let Some((px, py)) = proj.project(lat, lon) else { continue };
let Some((col, row)) = proj.to_pixel(px, py, map_w, map_h) else { continue };
// Small cross marker
for d in -3i32..=3i32 {
map_area.draw_pixel((col + d, row), &RED).ok();
map_area.draw_pixel((col, row + d), &RED).ok();
// Composite label overlay (map labels above clouds).
if let Some(lbl) = labels {
for (idx, &[lr, lg, lb, la]) in lbl.iter().enumerate() {
if la == 0 { continue; }
let a = la as f32 / 255.0;
let RGBColor(r, g, b) = pixel_buf[idx];
pixel_buf[idx] = RGBColor(
(lr as f32 * a + r as f32 * (1.0 - a)).round() as u8,
(lg as f32 * a + g as f32 * (1.0 - a)).round() as u8,
(lb as f32 * a + b as f32 * (1.0 - a)).round() as u8,
);
}
}
// Label offset slightly above and to the right of the marker
draw_pixel_text(&map_area, label, col + 5, row - 9, 1, &BLACK);
// Flush pixel buffer to drawing area.
for row in 0..map_h as i32 {
for col in 0..map_w as i32 {
let pixel = pixel_buf[row as usize * map_w as usize + col as usize];
map_area.draw_pixel((col, row), &pixel).ok();
}
}
// Draw city markers (cross only; labels come from the tile label overlay)
if labels.is_none() {
for &(label, lon, lat) in LOCATIONS {
let Some((px, py)) = proj.project(lat, lon) else { continue };
let Some((col, row)) = proj.to_pixel(px, py, map_w, map_h) else { continue };
for d in -3i32..=3i32 {
map_area.draw_pixel((col + d, row), &RED).ok();
map_area.draw_pixel((col, row + d), &RED).ok();
}
draw_pixel_text(&map_area, label, col + 5, row - 9, 1, &BLACK);
}
}
// Draw title (scale=2 → 16px tall)
@@ -204,22 +398,17 @@ fn draw_legend(
);
};
// "< 1%" row (clear sky)
draw_entry("< 1%", LAND_BG, y as i32 + 22);
// Remaining threshold rows — use ASCII hyphen instead of en-dash
let entries = [
(1.0f32, "1-2%"),
(2.0, "2-5%"),
(5.0, "5-10%"),
(10.0, "10-20%"),
(20.0, "20-50%"),
(50.0, "50-100%"),
(0.0f32, "Clear"),
(1.0, "1%"),
(25.0, "25%"),
(50.0, "50%"),
(100.0, "100%"),
];
for (i, &(lo, label)) in entries.iter().enumerate() {
let row_y = y as i32 + 22 + (i as i32 + 1) * (box_h + 2);
let color = cloud_color(lo).unwrap_or(BACKGROUND);
for (i, &(cover, label)) in entries.iter().enumerate() {
let row_y = y as i32 + 22 + i as i32 * (box_h + 2);
let color = cloud_color(LAND_BG, scale_cloud_cover(cover)).unwrap_or(LAND_BG);
draw_entry(label, color, row_y);
}

354
src/tiles.rs Normal file
View File

@@ -0,0 +1,354 @@
use crate::projection::OrthoProjection;
use anyhow::{bail, Context, Result};
use image::{RgbaImage, RgbImage};
use rayon::prelude::*;
use std::collections::HashMap;
use std::path::Path;
use std::sync::Mutex;
const TILE_SIZE: u32 = 256;
const TILE_URL_BASE: &str = "https://cartodb-basemaps-a.global.ssl.fastly.net/rastertiles/voyager_nolabels";
const TILE_URL_LABELS: &str = "https://cartodb-basemaps-a.global.ssl.fastly.net/rastertiles/voyager_only_labels";
const USER_AGENT: &str = "cloud_cover_rs/0.1 (DWD cloud cover visualization)";
/// Convert geographic (lat, lon) in degrees to fractional tile coordinates at
/// the given zoom level (slippy map / Web Mercator convention).
fn geo_to_tile_coords(lat_deg: f64, lon_deg: f64, zoom: u8) -> (f64, f64) {
let n = f64::from(1u32 << zoom);
let x = (lon_deg + 180.0) / 360.0 * n;
let lat_rad = lat_deg.to_radians();
let y = (1.0 - (lat_rad.tan() + 1.0 / lat_rad.cos()).ln() / std::f64::consts::PI) / 2.0 * n;
(x, y)
}
/// Determine the set of tile (x, y) indices needed to cover the given
/// geographic bounding box at the given zoom level.
fn required_tiles(min_lon: f64, max_lon: f64, min_lat: f64, max_lat: f64, zoom: u8) -> Vec<(u32, u32)> {
// Note: higher latitude → lower y-tile (north = top)
let (x_min_f, y_min_f) = geo_to_tile_coords(max_lat, min_lon, zoom);
let (x_max_f, y_max_f) = geo_to_tile_coords(min_lat, max_lon, zoom);
let x_min = x_min_f.floor() as u32;
let x_max = x_max_f.floor() as u32;
let y_min = y_min_f.floor() as u32;
let y_max = y_max_f.floor() as u32;
let mut tiles = Vec::new();
for x in x_min..=x_max {
for y in y_min..=y_max {
tiles.push((x, y));
}
}
tiles
}
/// Compute the geographic bounding box for the map area defined by `proj`.
fn map_bbox(proj: &OrthoProjection, map_w: u32, map_h: u32) -> (f64, f64, f64, f64) {
let w = map_w as f64;
let h = map_h as f64;
let mut min_lat = f64::MAX;
let mut max_lat = f64::MIN;
let mut min_lon = f64::MAX;
let mut max_lon = f64::MIN;
for (col, row) in [
(0.0, 0.0), (w, 0.0), (0.0, h), (w, h),
(w / 2.0, 0.0), (w / 2.0, h),
(0.0, h / 2.0), (w, h / 2.0),
] {
let (lat, lon) = proj.pixel_to_geo(col, row, map_w, map_h);
min_lat = min_lat.min(lat);
max_lat = max_lat.max(lat);
min_lon = min_lon.min(lon);
max_lon = max_lon.max(lon);
}
(min_lon, max_lon, min_lat, max_lat)
}
// ─── RGB tile cache (base layer, no labels) ──────────────────────────────────
/// A cache of decoded RGB tiles (no labels), keyed by (x_tile, y_tile).
pub struct TileCache {
zoom: u8,
tiles: HashMap<(u32, u32), RgbImage>,
}
impl TileCache {
/// Sample the tile cache at a geographic coordinate using bilinear interpolation.
pub fn sample(&self, lat: f64, lon: f64) -> Option<[u8; 3]> {
let (tx, ty) = geo_to_tile_coords(lat, lon, self.zoom);
// Absolute sub-pixel position in the tile grid (in tile pixels)
let apx = tx * TILE_SIZE as f64;
let apy = ty * TILE_SIZE as f64;
let px0 = apx.floor() as u32;
let py0 = apy.floor() as u32;
let px1 = px0 + 1;
let py1 = py0 + 1;
let fx = (apx - px0 as f64) as f32;
let fy = (apy - py0 as f64) as f32;
let get = |apx: u32, apy: u32| -> Option<[u8; 3]> {
let tx = apx / TILE_SIZE;
let ty = apy / TILE_SIZE;
let lx = apx % TILE_SIZE;
let ly = apy % TILE_SIZE;
let tile = self.tiles.get(&(tx, ty))?;
Some(tile.get_pixel(lx, ly).0)
};
let p00 = get(px0, py0)?;
let p10 = get(px1, py0).unwrap_or(p00);
let p01 = get(px0, py1).unwrap_or(p00);
let p11 = get(px1, py1).unwrap_or(p00);
let blend_ch = |c00: u8, c10: u8, c01: u8, c11: u8| -> u8 {
let top = c00 as f32 * (1.0 - fx) + c10 as f32 * fx;
let bot = c01 as f32 * (1.0 - fx) + c11 as f32 * fx;
(top * (1.0 - fy) + bot * fy).round() as u8
};
Some([
blend_ch(p00[0], p10[0], p01[0], p11[0]),
blend_ch(p00[1], p10[1], p01[1], p11[1]),
blend_ch(p00[2], p10[2], p01[2], p11[2]),
])
}
}
fn fetch_rgb_tile(x: u32, y: u32, zoom: u8, url_base: &str, cache_dir: &Path) -> Result<RgbImage> {
let cache_path = cache_dir
.join(zoom.to_string())
.join(x.to_string())
.join(format!("{y}.png"));
if cache_path.exists() {
return Ok(image::open(&cache_path)
.with_context(|| format!("Failed to decode cached tile {}", cache_path.display()))?
.into_rgb8());
}
let url = format!("{url_base}/{zoom}/{x}/{y}.png");
eprintln!(" Fetching tile {url}");
let client = reqwest::blocking::Client::builder()
.user_agent(USER_AGENT)
.build()
.context("Failed to build HTTP client")?;
let response = client.get(&url).send()
.with_context(|| format!("HTTP request failed for {url}"))?;
let status = response.status();
if !status.is_success() {
bail!("HTTP {status} for {url}");
}
let bytes = response.bytes()
.with_context(|| format!("Failed to read response body from {url}"))?;
if let Some(parent) = cache_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create tile cache dir {}", parent.display()))?;
}
std::fs::write(&cache_path, &bytes)
.with_context(|| format!("Failed to write tile cache {}", cache_path.display()))?;
Ok(image::load_from_memory(&bytes).context("Failed to decode tile PNG")?.into_rgb8())
}
/// Fetch all no-label base tiles needed to cover the map area.
pub fn fetch_tiles(
proj: &OrthoProjection,
map_w: u32,
map_h: u32,
zoom: u8,
cache_dir: &Path,
) -> Result<TileCache> {
let (min_lon, max_lon, min_lat, max_lat) = map_bbox(proj, map_w, map_h);
let tile_coords = required_tiles(min_lon, max_lon, min_lat, max_lat, zoom);
eprintln!("Fetching {} base tiles (zoom {zoom})...", tile_coords.len());
let tile_cache_dir = cache_dir.join("osm_tiles");
let tiles: Mutex<HashMap<_, _>> = Mutex::new(HashMap::new());
let pool = rayon::ThreadPoolBuilder::new().num_threads(2).build()
.context("Failed to build tile fetch thread pool")?;
pool.install(|| {
tile_coords.par_iter().try_for_each(|&(x, y)| -> Result<()> {
let img = fetch_rgb_tile(x, y, zoom, TILE_URL_BASE, &tile_cache_dir)?;
tiles.lock().unwrap().insert((x, y), img);
Ok(())
})
})?;
Ok(TileCache { zoom, tiles: tiles.into_inner().unwrap() })
}
/// Rasterize the no-label basemap for the entire map area.
pub fn rasterize_basemap(
proj: &OrthoProjection,
map_w: u32,
map_h: u32,
tile_cache: &TileCache,
) -> Vec<[u8; 3]> {
let size = map_w as usize * map_h as usize;
(0..size)
.into_par_iter()
.map(|i| {
let col = (i % map_w as usize) as f64 + 0.5;
let row = (i / map_w as usize) as f64 + 0.5;
let (lat, lon) = proj.pixel_to_geo(col, row, map_w, map_h);
tile_cache.sample(lat, lon).unwrap_or([200, 200, 200])
})
.collect()
}
// ─── RGBA label tile cache (labels only, transparent background) ──────────────
/// A cache of decoded RGBA label-only tiles, keyed by (x_tile, y_tile).
pub struct LabelTileCache {
zoom: u8,
tiles: HashMap<(u32, u32), RgbaImage>,
}
impl LabelTileCache {
/// Sample the label cache at a geographic coordinate using bilinear interpolation
/// in premultiplied-alpha space, then convert back to straight alpha.
///
/// Premultiplied interpolation prevents dark fringe artifacts: transparent pixels
/// with undefined RGB values (commonly [0,0,0,0]) would otherwise bleed dark
/// colours into semi-transparent text edges in straight-alpha blending.
pub fn sample(&self, lat: f64, lon: f64) -> Option<[u8; 4]> {
let (tx, ty) = geo_to_tile_coords(lat, lon, self.zoom);
let apx = tx * TILE_SIZE as f64;
let apy = ty * TILE_SIZE as f64;
let px0 = apx.floor() as u32;
let py0 = apy.floor() as u32;
let px1 = px0 + 1;
let py1 = py0 + 1;
let fx = (apx - px0 as f64) as f32;
let fy = (apy - py0 as f64) as f32;
let get = |apx: u32, apy: u32| -> Option<[f32; 4]> {
let tx = apx / TILE_SIZE;
let ty = apy / TILE_SIZE;
let lx = apx % TILE_SIZE;
let ly = apy % TILE_SIZE;
let tile = self.tiles.get(&(tx, ty))?;
let [r, g, b, a] = tile.get_pixel(lx, ly).0;
// Convert to premultiplied float
let af = a as f32 / 255.0;
Some([r as f32 * af, g as f32 * af, b as f32 * af, a as f32])
};
let p00 = get(px0, py0)?;
let p10 = get(px1, py0).unwrap_or(p00);
let p01 = get(px0, py1).unwrap_or(p00);
let p11 = get(px1, py1).unwrap_or(p00);
let blend_ch = |c00: f32, c10: f32, c01: f32, c11: f32| -> f32 {
let top = c00 * (1.0 - fx) + c10 * fx;
let bot = c01 * (1.0 - fx) + c11 * fx;
top * (1.0 - fy) + bot * fy
};
let pr = blend_ch(p00[0], p10[0], p01[0], p11[0]);
let pg = blend_ch(p00[1], p10[1], p01[1], p11[1]);
let pb = blend_ch(p00[2], p10[2], p01[2], p11[2]);
let pa = blend_ch(p00[3], p10[3], p01[3], p11[3]);
// Convert back from premultiplied to straight alpha
let (r, g, b) = if pa > 0.0 {
let inv = 255.0 / pa;
((pr * inv).round() as u8, (pg * inv).round() as u8, (pb * inv).round() as u8)
} else {
(0, 0, 0)
};
Some([r, g, b, pa.round() as u8])
}
}
fn fetch_rgba_tile(x: u32, y: u32, zoom: u8, url_base: &str, cache_dir: &Path) -> Result<RgbaImage> {
let cache_path = cache_dir
.join(zoom.to_string())
.join(x.to_string())
.join(format!("{y}.png"));
if cache_path.exists() {
return Ok(image::open(&cache_path)
.with_context(|| format!("Failed to decode cached label tile {}", cache_path.display()))?
.into_rgba8());
}
let url = format!("{url_base}/{zoom}/{x}/{y}.png");
eprintln!(" Fetching label tile {url}");
let client = reqwest::blocking::Client::builder()
.user_agent(USER_AGENT)
.build()
.context("Failed to build HTTP client")?;
let response = client.get(&url).send()
.with_context(|| format!("HTTP request failed for {url}"))?;
let status = response.status();
if !status.is_success() {
bail!("HTTP {status} for {url}");
}
let bytes = response.bytes()
.with_context(|| format!("Failed to read response body from {url}"))?;
if let Some(parent) = cache_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create label tile cache dir {}", parent.display()))?;
}
std::fs::write(&cache_path, &bytes)
.with_context(|| format!("Failed to write label tile cache {}", cache_path.display()))?;
Ok(image::load_from_memory(&bytes).context("Failed to decode label tile PNG")?.into_rgba8())
}
/// Fetch all label-only tiles needed to cover the map area.
pub fn fetch_label_tiles(
proj: &OrthoProjection,
map_w: u32,
map_h: u32,
zoom: u8,
cache_dir: &Path,
) -> Result<LabelTileCache> {
let (min_lon, max_lon, min_lat, max_lat) = map_bbox(proj, map_w, map_h);
let tile_coords = required_tiles(min_lon, max_lon, min_lat, max_lat, zoom);
eprintln!("Fetching {} label tiles (zoom {zoom})...", tile_coords.len());
let tile_cache_dir = cache_dir.join("osm_tiles").join("labels");
let tiles: Mutex<HashMap<_, _>> = Mutex::new(HashMap::new());
let pool = rayon::ThreadPoolBuilder::new().num_threads(2).build()
.context("Failed to build tile fetch thread pool")?;
pool.install(|| {
tile_coords.par_iter().try_for_each(|&(x, y)| -> Result<()> {
let img = fetch_rgba_tile(x, y, zoom, TILE_URL_LABELS, &tile_cache_dir)?;
tiles.lock().unwrap().insert((x, y), img);
Ok(())
})
})?;
Ok(LabelTileCache { zoom, tiles: tiles.into_inner().unwrap() })
}
/// Rasterize the label overlay for the entire map area.
/// Returns one RGBA per pixel (alpha=0 where there is no label).
pub fn rasterize_labels(
proj: &OrthoProjection,
map_w: u32,
map_h: u32,
tile_cache: &LabelTileCache,
) -> Vec<[u8; 4]> {
let size = map_w as usize * map_h as usize;
(0..size)
.into_par_iter()
.map(|i| {
let col = (i % map_w as usize) as f64 + 0.5;
let row = (i / map_w as usize) as f64 + 0.5;
let (lat, lon) = proj.pixel_to_geo(col, row, map_w, map_h);
tile_cache.sample(lat, lon).unwrap_or([0, 0, 0, 0])
})
.collect()
}