From a2f5c902a3f431661a028f3e6460f8966f03482f Mon Sep 17 00:00:00 2001 From: Schuwi Date: Sun, 8 Mar 2026 21:09:47 +0100 Subject: [PATCH] Add --false-color rendering mode for agentic interpretation Maps raw cloud cover to six distinct stepped colour bands (clear green through overcast red) on a dark navy background, with fine gradation below 30% where conditions matter for astrophotography and two coarse bands above. Skips OSM base map and alpha blending entirely. The triangulation now stores raw cover values; scale_cloud_cover() is applied post-blur only in the default blending mode, keeping its behaviour identical. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 28 ++++++++++++++-- src/main.rs | 11 +++++-- src/render.rs | 88 ++++++++++++++++++++++++++++++++++++++++----------- 3 files changed, 103 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 767af9f..1bda6dd 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,26 @@ The timestamp must match an ICON-D2 model run: every 3 hours starting at 00 UTC | `--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 | +| `--false-color` | — | False-colour mode: distinct stepped colour bands, no base map (see below) | + +### False-colour mode + +`--false-color` skips the OSM base layer and maps cloud cover directly to +semantically meaningful colour bands, making go/no-go decisions quick to read +at a glance — particularly useful for automated or agentic interpretation: + +| Cover | Colour | Meaning | +|---|---|---| +| < 1% | Bright green | Clear sky | +| 1–5% | Light green | Near-clear | +| 5–15% | Yellow-green | Light cloud | +| 15–30% | Amber | Marginal | +| 30–60% | Orange | Cloudy | +| > 60% | Red | Overcast | + +Areas outside the ICON-D2 data region are shown in dark navy. +The fine gradation below 30% reflects where cloud cover actually matters for +astrophotography; above 30% only two coarse bands are used. ### Examples @@ -60,6 +80,9 @@ The timestamp must match an ICON-D2 model run: every 3 hours starting at 00 UTC # Quick run without map tiles ./target/release/cloud_cover "2026-03-07T09:00:00Z" 6 --no-basemap + +# False-colour mode for easy agentic interpretation +./target/release/cloud_cover "2026-03-07T09:00:00Z" 6 --false-color ``` ## Output @@ -76,8 +99,9 @@ Each run produces files in `cache/_UTC/`: 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. +In the default mode, cloud cover is rendered as a continuous blend from the base map +(clear sky) toward a light blue-white tone (overcast). With `--false-color`, a stepped +colour scale is used instead (see above), with no base map. ## How it works diff --git a/src/main.rs b/src/main.rs index 914e792..e23c694 100644 --- a/src/main.rs +++ b/src/main.rs @@ -35,6 +35,11 @@ struct Args { #[arg(long)] no_basemap: bool, + /// Use false-colour mode: distinct stepped colour bands, no base map. + /// Optimised for quick go/no-go assessment for astrophotography. + #[arg(long)] + false_color: bool, + /// Map centre latitude in degrees (default: 52.56) #[arg(long, value_name = "LAT", default_value_t = render::DEFAULT_CENTER_LAT)] center_lat: f64, @@ -143,7 +148,7 @@ 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 { + let basemap = if args.no_basemap || args.false_color { None } else { let proj = render::make_projection(args.center_lat, args.center_lon, args.zoom); @@ -162,7 +167,7 @@ fn main() -> Result<()> { )) }; - let labels = if args.no_basemap { + let labels = if args.no_basemap || args.false_color { None } else { let proj = render::make_projection(args.center_lat, args.center_lon, args.zoom); @@ -212,7 +217,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, basemap.as_deref(), labels.as_deref(), args.center_lat, args.center_lon, args.zoom) + render::render_frame(&out_path, &lats, &lons, &cloud, &title, basemap.as_deref(), labels.as_deref(), args.center_lat, args.center_lon, args.zoom, args.false_color) .with_context(|| format!("Rendering frame {}", step))?; eprintln!(" [{:02}/{:02}] {}", step + 1, args.prediction_hours, out_name); diff --git a/src/render.rs b/src/render.rs index fbf7e18..f8b8e7e 100644 --- a/src/render.rs +++ b/src/render.rs @@ -110,6 +110,23 @@ fn cloud_color(base_color: RGBColor, cover: f32) -> Option { )) } +/// Map raw cloud cover (0–100 %) to a distinct false-colour band for easy visual +/// classification, particularly in the low-coverage range relevant for astrophotography. +/// Returns `None` for NaN (outside data region, will keep background colour). +fn false_color_pixel(cover: f32) -> Option { + if cover.is_nan() { + return None; + } + Some(match cover { + c if c < 1.0 => RGBColor( 0, 190, 0), // clear sky + c if c < 5.0 => RGBColor(110, 220, 40), // near-clear + c if c < 15.0 => RGBColor(185, 215, 0), // light cloud + c if c < 30.0 => RGBColor(235, 180, 0), // marginal + c if c < 60.0 => RGBColor(225, 85, 0), // cloudy + _ => RGBColor(190, 25, 25), // overcast + }) +} + /// Draw ASCII text using the built-in 8×8 pixel font. /// /// Coordinates are in the drawing area's local coordinate system. @@ -214,6 +231,7 @@ pub fn render_frame( center_lat: f64, center_lon: f64, zoom: u8, + false_color: bool, ) -> Result<()> { let proj = make_projection(center_lat, center_lon, zoom); @@ -247,7 +265,7 @@ pub fn render_frame( { continue; } - let _ = tri.insert(CloudVertex { position: Point2::new(col_f, row_f), cloud: scale_cloud_cover(cover) }); + let _ = tri.insert(CloudVertex { position: Point2::new(col_f, row_f), cloud: cover }); } // For each output pixel, locate the containing triangle and barycentric-interpolate @@ -305,18 +323,35 @@ pub fn render_frame( 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. + // In normal mode apply the opacity scaling after blur so the scale_cloud_cover + // threshold (< 1 % → transparent) still applies cleanly. In false-colour mode + // keep the raw percentages so the colour bands reflect actual cover values. + let cover_grid: Vec = if false_color { + cover_grid + } else { + cover_grid.iter().map(|&v| if v.is_nan() { f32::NAN } else { scale_cloud_cover(v) }).collect() + }; + + // Build pixel buffer: dark navy for false-colour, basemap or flat green otherwise. const SAT_BOOST: f32 = 1.8; - let mut pixel_buf: Vec = if let Some(bm) = basemap { + const FC_BG: RGBColor = RGBColor(30, 30, 45); + let mut pixel_buf: Vec = if false_color { + vec![FC_BG; n_pixels] + } else 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. + // Colour each pixel from the (blurred, optionally scaled) cover grid. for (idx, &cover) in cover_grid.iter().enumerate() { - if let Some(cloud_col) = cloud_color(pixel_buf[idx], cover) { - pixel_buf[idx] = cloud_col; + let new_col = if false_color { + false_color_pixel(cover) + } else { + cloud_color(pixel_buf[idx], cover) + }; + if let Some(c) = new_col { + pixel_buf[idx] = c; } } @@ -360,7 +395,7 @@ pub fn render_frame( draw_pixel_text(&root, title, MARGIN as i32, (TITLE_H / 2 - 8) as i32, 2, &BLACK); // Draw legend - draw_legend(&root, IMG_WIDTH - LEGEND_W, TITLE_H)?; + draw_legend(&root, IMG_WIDTH - LEGEND_W, TITLE_H, false_color)?; root.present().context("write PNG")?; Ok(()) @@ -370,6 +405,7 @@ fn draw_legend( root: &DrawingArea, x: u32, y: u32, + false_color: bool, ) -> Result<()> { draw_pixel_text(root, "Cloud cover", x as i32 + 4, y as i32 + 4, 1, &BLACK); @@ -398,18 +434,32 @@ fn draw_legend( ); }; - let entries = [ - (0.0f32, "Clear"), - (1.0, "1%"), - (25.0, "25%"), - (50.0, "50%"), - (100.0, "100%"), - ]; - - 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); + if false_color { + let entries: &[(&str, RGBColor)] = &[ + ("Clear", RGBColor( 0, 190, 0)), + ("1-5%", RGBColor(110, 220, 40)), + ("5-15%", RGBColor(185, 215, 0)), + ("15-30%", RGBColor(235, 180, 0)), + ("30-60%", RGBColor(225, 85, 0)), + (">60%", RGBColor(190, 25, 25)), + ]; + for (i, &(label, color)) in entries.iter().enumerate() { + let row_y = y as i32 + 22 + i as i32 * (box_h + 2); + draw_entry(label, color, row_y); + } + } else { + let entries = [ + (0.0f32, "Clear"), + (1.0, "1%"), + (25.0, "25%"), + (50.0, "50%"), + (100.0, "100%"), + ]; + 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); + } } Ok(())