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 <noreply@anthropic.com>
This commit is contained in:
Schuwi
2026-03-08 21:09:47 +01:00
parent 56f779990d
commit a2f5c902a3
3 changed files with 103 additions and 24 deletions

View File

@@ -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 |
| 15% | Light green | Near-clear |
| 515% | Yellow-green | Light cloud |
| 1530% | Amber | Marginal |
| 3060% | 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/<date>_<hour>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

View File

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

View File

@@ -110,6 +110,23 @@ fn cloud_color(base_color: RGBColor, cover: f32) -> Option<RGBColor> {
))
}
/// Map raw cloud cover (0100 %) 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<RGBColor> {
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<f32> = 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<RGBColor> = if let Some(bm) = basemap {
const FC_BG: RGBColor = RGBColor(30, 30, 45);
let mut pixel_buf: Vec<RGBColor> = 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<BitMapBackend, plotters::coord::Shift>,
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,6 +434,20 @@ fn draw_legend(
);
};
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%"),
@@ -405,12 +455,12 @@ fn draw_legend(
(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(())
}