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>
This commit is contained in:
Schuwi
2026-03-07 10:52:34 +01:00
parent ac9f9da3c4
commit ff69c1d2db
4 changed files with 524 additions and 35 deletions

View File

@@ -2,6 +2,7 @@ mod download;
mod grib;
mod projection;
mod render;
mod tiles;
use anyhow::{bail, Context, Result};
use chrono::{DateTime, Datelike, Timelike, Utc};
@@ -29,6 +30,10 @@ 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,
}
fn main() -> Result<()> {
@@ -125,6 +130,53 @@ fn main() -> Result<()> {
})?;
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 = projection::OrthoProjection::new(
render::CENTER_LAT,
render::CENTER_LON,
render::HALF_W_DEG,
render::HALF_H_DEG,
);
let tile_cache = tiles::fetch_tiles(
&proj,
render::MAP_W,
render::MAP_H,
&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 = projection::OrthoProjection::new(
render::CENTER_LAT,
render::CENTER_LON,
render::HALF_W_DEG,
render::HALF_H_DEG,
);
let label_cache = tiles::fetch_label_tiles(
&proj,
render::MAP_W,
render::MAP_H,
&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();
@@ -156,7 +208,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())
.with_context(|| format!("Rendering frame {}", step))?;
eprintln!(" [{:02}/{:02}] {}", step + 1, args.prediction_hours, out_name);