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);

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

@@ -6,14 +6,22 @@ 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;
/// Map image centre and view window.
pub const CENTER_LAT: f64 = 52.56;
pub const CENTER_LON: f64 = 13.08;
pub const HALF_H_DEG: f64 = 0.4; // vertical half-extent in degrees of arc
// HALF_W_DEG is derived so that degrees-per-pixel is equal on both axes
// (MAP_W / MAP_H × HALF_H_DEG), preventing horizontal squish.
pub const HALF_W_DEG: f64 = HALF_H_DEG * (MAP_W as f64) / (MAP_H as f64);
const BACKGROUND: RGBColor = RGBColor(240, 248, 255); // pale sky-blue
const LAND_BG: RGBColor = RGBColor(142, 178, 35); // strong green for land
@@ -154,22 +162,19 @@ pub fn render_frame(
lons: &[f32],
cloud_cover: &[f32],
title: &str,
basemap: Option<&[[u8; 3]]>,
labels: Option<&[[u8; 4]]>,
) -> Result<()> {
let proj = OrthoProjection::new(CENTER_LAT, CENTER_LON, HALF_W_DEG, HALF_H_DEG);
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;
map_area.fill(&LAND_BG).context("fill map area")?;
let map_w = MAP_W;
let map_h = MAP_H;
let n_pixels = map_w as usize * map_h as usize;
// Build a Delaunay triangulation in continuous pixel-space coordinates.
// Include all grid points that project within a small margin outside the image
@@ -250,30 +255,58 @@ pub fn render_frame(
const BLUR_SIGMA: f32 = 8.0;
let cover_grid = gaussian_blur(&cover_grid, grid_w, grid_h, BLUR_SIGMA);
// Paint the interpolated grid.
for row in 0..map_h as i32 {
for col in 0..map_w as i32 {
let cover = cover_grid[row as usize * grid_w + col as usize];
if let Some(color) = cloud_color(cover) {
map_area.draw_pixel((col, row), &color).ok();
}
// NaN or clear sky: keep the land background
// Build pixel buffer: start from basemap or flat LAND_BG.
let mut pixel_buf: Vec<[u8; 3]> = if let Some(bm) = basemap {
bm.to_vec()
} else {
vec![[LAND_BG.0, LAND_BG.1, LAND_BG.2]; n_pixels]
};
// Blend cloud cover toward white.
for (idx, &cover) in cover_grid.iter().enumerate() {
if cover.is_nan() || cover < 1.0 {
continue;
}
let t = (cover / 100.0).clamp(0.0, 1.0);
let [r, g, b] = pixel_buf[idx];
let blend = |base: u8| -> u8 { (base as f32 + t * (255.0 - base as f32)).round() as u8 };
pixel_buf[idx] = [blend(r), blend(g), blend(b)];
}
// 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 [r, g, b] = pixel_buf[idx];
pixel_buf[idx] = [
(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,
];
}
}
// 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();
// Flush pixel buffer to drawing area.
for row in 0..map_h as i32 {
for col in 0..map_w as i32 {
let [r, g, b] = pixel_buf[row as usize * map_w as usize + col as usize];
map_area.draw_pixel((col, row), &RGBColor(r, g, b)).ok();
}
}
// Label offset slightly above and to the right of the marker
draw_pixel_text(&map_area, label, col + 5, row - 9, 1, &BLACK);
// 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)

367
src/tiles.rs Normal file
View File

@@ -0,0 +1,367 @@
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)
}
/// Choose a zoom level so that tile pixels approximately match the output
/// resolution. `lon_range` is the geographic longitude span in degrees,
/// `map_w` is the output width in pixels.
fn choose_zoom(lon_range: f64, map_w: u32) -> u8 {
// At zoom z, the world is 256 * 2^z pixels wide covering 360° of longitude.
// We want: 256 * 2^z / 360 >= map_w / lon_range (tiles at least as detailed as output)
// => 2^z >= map_w * 360 / (lon_range * 256)
// Use floor so we pick the level just below 1:1, then bilinear upscaling gives clean result.
let needed = (map_w as f64 * 360.0) / (lon_range * TILE_SIZE as f64);
let z = needed.log2().floor() as u8;
z.clamp(1, 18)
}
/// 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,
cache_dir: &Path,
) -> Result<TileCache> {
let (min_lon, max_lon, min_lat, max_lat) = map_bbox(proj, map_w, map_h);
let zoom = choose_zoom(max_lon - min_lon, map_w);
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,
cache_dir: &Path,
) -> Result<LabelTileCache> {
let (min_lon, max_lon, min_lat, max_lat) = map_bbox(proj, map_w, map_h);
let zoom = choose_zoom(max_lon - min_lon, map_w);
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()
}