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:
101
src/render.rs
101
src/render.rs
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user