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) |
|
| `--center-lon` | 13.08 | Map centre longitude (°E) |
|
||||||
| `--zoom` | 10 | OSM tile zoom level (higher = more detail, smaller area) |
|
| `--zoom` | 10 | OSM tile zoom level (higher = more detail, smaller area) |
|
||||||
| `--no-basemap` | — | Skip OSM tiles; use a plain green background |
|
| `--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
|
### 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
|
# Quick run without map tiles
|
||||||
./target/release/cloud_cover "2026-03-07T09:00:00Z" 6 --no-basemap
|
./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
|
## 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
|
Image titles show the forecast time converted to CET/CEST (Europe/Berlin). The first
|
||||||
frame is labelled "Conditions at …", subsequent frames "Prediction at … (+NNh)".
|
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
|
In the default mode, cloud cover is rendered as a continuous blend from the base map
|
||||||
light blue-white tone (overcast), matching the style familiar from weather apps.
|
(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
|
## How it works
|
||||||
|
|
||||||
|
|||||||
11
src/main.rs
11
src/main.rs
@@ -35,6 +35,11 @@ struct Args {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
no_basemap: bool,
|
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)
|
/// Map centre latitude in degrees (default: 52.56)
|
||||||
#[arg(long, value_name = "LAT", default_value_t = render::DEFAULT_CENTER_LAT)]
|
#[arg(long, value_name = "LAT", default_value_t = render::DEFAULT_CENTER_LAT)]
|
||||||
center_lat: f64,
|
center_lat: f64,
|
||||||
@@ -143,7 +148,7 @@ fn main() -> Result<()> {
|
|||||||
cloud_data.sort_unstable_by_key(|(step, _)| *step);
|
cloud_data.sort_unstable_by_key(|(step, _)| *step);
|
||||||
|
|
||||||
// Fetch and rasterize OSM basemap (once, reused for all frames).
|
// 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
|
None
|
||||||
} else {
|
} else {
|
||||||
let proj = render::make_projection(args.center_lat, args.center_lon, args.zoom);
|
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
|
None
|
||||||
} else {
|
} else {
|
||||||
let proj = render::make_projection(args.center_lat, args.center_lon, args.zoom);
|
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_name = format!("clct_{:06}.png", step + 1);
|
||||||
let out_path = cache_dir.join(&out_name);
|
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))?;
|
.with_context(|| format!("Rendering frame {}", step))?;
|
||||||
|
|
||||||
eprintln!(" [{:02}/{:02}] {}", step + 1, args.prediction_hours, out_name);
|
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.
|
/// Draw ASCII text using the built-in 8×8 pixel font.
|
||||||
///
|
///
|
||||||
/// Coordinates are in the drawing area's local coordinate system.
|
/// Coordinates are in the drawing area's local coordinate system.
|
||||||
@@ -214,6 +231,7 @@ pub fn render_frame(
|
|||||||
center_lat: f64,
|
center_lat: f64,
|
||||||
center_lon: f64,
|
center_lon: f64,
|
||||||
zoom: u8,
|
zoom: u8,
|
||||||
|
false_color: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let proj = make_projection(center_lat, center_lon, zoom);
|
let proj = make_projection(center_lat, center_lon, zoom);
|
||||||
|
|
||||||
@@ -247,7 +265,7 @@ pub fn render_frame(
|
|||||||
{
|
{
|
||||||
continue;
|
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
|
// 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;
|
const BLUR_SIGMA: f32 = 8.0;
|
||||||
let cover_grid = gaussian_blur(&cover_grid, grid_w, grid_h, BLUR_SIGMA);
|
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;
|
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()
|
bm.iter().map(|&p| boost_saturation(p, SAT_BOOST)).map(|p| RGBColor(p[0], p[1], p[2])).collect()
|
||||||
} else {
|
} else {
|
||||||
vec![LAND_BG; n_pixels]
|
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() {
|
for (idx, &cover) in cover_grid.iter().enumerate() {
|
||||||
if let Some(cloud_col) = cloud_color(pixel_buf[idx], cover) {
|
let new_col = if false_color {
|
||||||
pixel_buf[idx] = cloud_col;
|
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_pixel_text(&root, title, MARGIN as i32, (TITLE_H / 2 - 8) as i32, 2, &BLACK);
|
||||||
|
|
||||||
// Draw legend
|
// 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")?;
|
root.present().context("write PNG")?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -370,6 +405,7 @@ fn draw_legend(
|
|||||||
root: &DrawingArea<BitMapBackend, plotters::coord::Shift>,
|
root: &DrawingArea<BitMapBackend, plotters::coord::Shift>,
|
||||||
x: u32,
|
x: u32,
|
||||||
y: u32,
|
y: u32,
|
||||||
|
false_color: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
draw_pixel_text(root, "Cloud cover", x as i32 + 4, y as i32 + 4, 1, &BLACK);
|
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 = [
|
if false_color {
|
||||||
(0.0f32, "Clear"),
|
let entries: &[(&str, RGBColor)] = &[
|
||||||
(1.0, "1%"),
|
("Clear", RGBColor( 0, 190, 0)),
|
||||||
(25.0, "25%"),
|
("1-5%", RGBColor(110, 220, 40)),
|
||||||
(50.0, "50%"),
|
("5-15%", RGBColor(185, 215, 0)),
|
||||||
(100.0, "100%"),
|
("15-30%", RGBColor(235, 180, 0)),
|
||||||
];
|
("30-60%", RGBColor(225, 85, 0)),
|
||||||
|
(">60%", RGBColor(190, 25, 25)),
|
||||||
for (i, &(cover, label)) in entries.iter().enumerate() {
|
];
|
||||||
let row_y = y as i32 + 22 + i as i32 * (box_h + 2);
|
for (i, &(label, color)) in entries.iter().enumerate() {
|
||||||
let color = cloud_color(LAND_BG, scale_cloud_cover(cover)).unwrap_or(LAND_BG);
|
let row_y = y as i32 + 22 + i as i32 * (box_h + 2);
|
||||||
draw_entry(label, color, row_y);
|
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(())
|
Ok(())
|
||||||
|
|||||||
Reference in New Issue
Block a user