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:
28
README.md
28
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/<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
|
||||
|
||||
|
||||
11
src/main.rs
11
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);
|
||||
|
||||
@@ -110,6 +110,23 @@ fn cloud_color(base_color: RGBColor, cover: f32) -> Option<RGBColor> {
|
||||
))
|
||||
}
|
||||
|
||||
/// 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<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,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(())
|
||||
|
||||
Reference in New Issue
Block a user