Added the gallery

This commit is contained in:
connor committed 2026-10-15 12:33:40 -04:00
1 parent d44f159c98
commit 8861858f0d
8 files changed
+482 -11

No files matched your search

+84 -4
View File
@@ -9,6 +9,7 @@ use std::time::Instant;
use clap::{Args, Parser, Subcommand};
use resolved::gpu::AdapterChoice;
use resolved::radiometry::{Radiometry, AU};
use resolved::{
CameraTier, EarthTexture, Fidelity, Frame, LightingTier, MoonTexture, Renderer, RendererOptions,
};
@@ -45,6 +46,11 @@ struct RenderArgs {
/// An 8-bit sRGB PNG for viewing: bias subtracted, saturation as white.
#[arg(long)]
preview: Option<PathBuf>,
/// Make the preview look like a photograph rather than the raw signal: white
/// balanced to sunlight, and a tone curve that brightens shadows and midtones and
/// rolls highlights off to white at saturation. --out is unchanged.
#[arg(long)]
pretty: bool,
/// Lighting fidelity tier: L0, or L1 (adds Earth's surface, shadows on targets,
/// light from Earth and the Moon, eclipses on them, and the Sun's limb darkening;
/// needs Blue Marble data).
@@ -267,7 +273,21 @@ fn render(args: &RenderArgs) -> Result<(), String> {
// Bias reads as black and the saturation level as white.
let black = detector.bias;
let white = detector.dn(detector.full_well) as f64;
write_preview(&frame, preview, black, white)?;
let look = if args.pretty {
// Each channel scaled to the weakest one's response to a white surface in
// sunlight, so sunlit white comes out neutral and no channel is amplified.
let radiometry = Radiometry::new(renderer.camera());
let white_surface: Vec<f64> = (0..frame.channels as usize)
.map(|k| radiometry.lambert_radiance(k, 1.0, 1.0, AU))
.collect();
let weakest = white_surface.iter().copied().fold(f64::INFINITY, f64::min);
Look::Pretty {
balance: white_surface.iter().map(|w| weakest / w).collect(),
}
} else {
Look::Raw
};
write_preview(&frame, preview, black, white, &look)?;
}
Ok(())
}
@@ -303,7 +323,33 @@ fn write_dn(frame: &Frame, path: &Path, adc_bits: u32) -> Result<(), String> {
.map_err(|e| format!("{}: {e}", path.display()))
}
fn write_preview(frame: &Frame, path: &Path, black: f64, white: f64) -> Result<(), String> {
/// How the preview turns DN into an image.
enum Look {
/// The signal as recorded: linear from the bias (black) to saturation (white).
Raw,
/// Like a photograph: each channel scaled by `balance`, then [`tone`].
Pretty { balance: Vec<f64> },
}
/// How much the pretty tone curve brightens the darkest values: 3, about 1.6 stops.
const PRETTY_LIFT: f64 = 3.0;
/// The pretty tone curve, from the fraction of saturation `x` to linear display value:
/// Reinhard's operator with its white point at saturation. It starts with slope
/// [`PRETTY_LIFT`] and rises ever more slowly to 1 at `x = 1`, so midtones brighten
/// and highlights compress instead of clipping.
fn tone(x: f64) -> f64 {
let x = x.max(0.0);
PRETTY_LIFT * x * (1.0 + x / PRETTY_LIFT) / (1.0 + PRETTY_LIFT * x)
}
fn write_preview(
frame: &Frame,
path: &Path,
black: f64,
white: f64,
look: &Look,
) -> Result<(), String> {
let srgb = |x: f64| {
let x = x.clamp(0.0, 1.0);
let v = if x <= 0.003_130_8 {
@@ -313,10 +359,25 @@ fn write_preview(frame: &Frame, path: &Path, black: f64, white: f64) -> Result<(
};
(v * 255.0).round() as u8
};
let level = |d: u16| (d as f64 - black) / (white - black);
let bytes: Vec<u8> = frame
.dn
.iter()
.map(|&d| srgb((d as f64 - black) / (white - black)))
.chunks(frame.channels as usize)
.flat_map(|pixel| -> Vec<u8> {
match look {
Look::Raw => pixel.iter().map(|&d| srgb(level(d))).collect(),
// A channel at saturation lost how bright it was, and balancing the
// others would tint it: saturated pixels are white.
Look::Pretty { .. } if pixel.iter().any(|&d| d as f64 >= white) => {
vec![255; pixel.len()]
}
Look::Pretty { balance } => pixel
.iter()
.zip(balance)
.map(|(&d, b)| srgb(tone(level(d) * b)))
.collect(),
}
})
.collect();
image::save_buffer(
path,
@@ -327,3 +388,22 @@ fn write_preview(frame: &Frame, path: &Path, black: f64, white: f64) -> Result<(
)
.map_err(|e| format!("{}: {e}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tone_lifts_the_darks_and_reaches_white_at_saturation() {
assert_eq!(tone(0.0), 0.0);
assert!((tone(1.0) - 1.0).abs() < 1e-12);
// Slope PRETTY_LIFT at zero; brighter than raw everywhere between.
assert!((tone(1e-6) / 1e-6 - PRETTY_LIFT).abs() < 1e-3);
let mut last = 0.0;
for i in 1..100 {
let x = i as f64 / 100.0;
assert!(tone(x) > x && tone(x) > last);
last = tone(x);
}
}
}
+28
View File
@@ -120,6 +120,34 @@ fn example_scene_on_gpu_and_cpu() {
);
}
#[test]
fn pretty_previews_lift_the_darks_and_leave_space_black() {
let preview = |pretty: bool| {
let path = out_dir().join(format!("preview-pretty-{pretty}.png"));
let path_str = path.to_str().unwrap();
let mut args = vec![
"render",
"--scene",
"examples/scenes/boxwing.toml",
"--preview",
path_str,
];
if pretty {
args.push("--pretty");
}
let result = resolved(&args);
assert!(result.status.success(), "{result:?}");
image::open(&path).unwrap().into_rgb8()
};
let (raw, pretty) = (preview(false), preview(true));
// Space is the bias, black either way.
assert_eq!(raw.get_pixel(0, 0).0, [0; 3]);
assert_eq!(pretty.get_pixel(0, 0).0, [0; 3]);
// Earth's dayside below the limb, well short of saturation: brighter when pretty.
let (r, p) = (raw.get_pixel(0, 1079).0, pretty.get_pixel(0, 1079).0);
assert!((0..3).all(|k| p[k] > r[k]), "raw {r:?}, pretty {p:?}");
}
#[cfg(feature = "astrokit")]
#[test]
fn lunar_eclipse_darkens_the_moon() {