mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-27 11:37:13 -06:00
255 lines
11 KiB
Rust
255 lines
11 KiB
Rust
//! Procedurally generated, photographic-looking demo scenes.
|
||
//!
|
||
//! Scenes are resolution independent (`render(w, h)` evaluates a continuous function) and return
|
||
//! scene-referred linear Rec.2020 radiance with real highlight headroom (the sun is ≫ 1.0), so the
|
||
//! develop pipeline's highlight recovery, tone mapping and colour tools have something real to do.
|
||
//! Every scene is deterministic for its seed. No external assets: zero licensing or privacy risk.
|
||
#![forbid(unsafe_code)]
|
||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||
|
||
mod noise;
|
||
mod paint;
|
||
|
||
use lightcraft_raster::Rgb32f;
|
||
use serde::Serialize;
|
||
|
||
pub use paint::render_srgb_linear;
|
||
|
||
/// Kinds of scene the generator knows.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub enum Kind {
|
||
AlpineLake,
|
||
Dunes,
|
||
OceanSunset,
|
||
Aurora,
|
||
MistyForest,
|
||
Lavender,
|
||
Macro,
|
||
Canyon,
|
||
Beach,
|
||
BlueHour,
|
||
}
|
||
|
||
/// Fake capture metadata so the library looks like a real shoot.
|
||
#[derive(Clone, Debug, Serialize)]
|
||
pub struct SceneMeta {
|
||
pub camera: &'static str,
|
||
pub lens: &'static str,
|
||
pub focal_mm: f32,
|
||
pub aperture: f32,
|
||
pub shutter: &'static str,
|
||
pub iso: u32,
|
||
/// ISO 8601 local time.
|
||
pub captured: String,
|
||
pub location: &'static str,
|
||
pub keywords: Vec<&'static str>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize)]
|
||
pub struct Scene {
|
||
pub id: u32,
|
||
pub name: String,
|
||
pub kind: Kind,
|
||
pub seed: u32,
|
||
/// Native pixel size (what an import would report).
|
||
pub width: u32,
|
||
pub height: u32,
|
||
pub meta: SceneMeta,
|
||
}
|
||
|
||
impl Scene {
|
||
pub fn aspect(&self) -> f32 {
|
||
self.width as f32 / self.height as f32
|
||
}
|
||
|
||
/// Render at `w × h` (any size; the aspect should match `aspect()`).
|
||
pub fn render(&self, w: usize, h: usize) -> Rgb32f {
|
||
paint::render(self.kind, self.seed, w, h)
|
||
}
|
||
|
||
/// Render fitting within `max_edge` on the long side (at most the scene's own size).
|
||
pub fn render_fit(&self, max_edge: usize) -> Rgb32f {
|
||
let (w, h) = self.fit(max_edge);
|
||
self.render(w, h)
|
||
}
|
||
|
||
/// [`Scene::render_fit`] on the calling thread alone, without rayon (the same pixels).
|
||
pub fn render_fit_serial(&self, max_edge: usize) -> Rgb32f {
|
||
let (w, h) = self.fit(max_edge);
|
||
paint::render_serial(self.kind, self.seed, w, h)
|
||
}
|
||
|
||
fn fit(&self, max_edge: usize) -> (usize, usize) {
|
||
// never past the scene's own size: that is a demo photo's full resolution, and a size
|
||
// from a damaged catalog (a width of 4294967295) asked for more memory than exists
|
||
let max_edge = max_edge.min(self.width.max(self.height).max(1) as usize);
|
||
let a = self.aspect();
|
||
let (w, h) = if a >= 1.0 { (max_edge, (max_edge as f32 / a).round() as usize) } else { ((max_edge as f32 * a).round() as usize, max_edge) };
|
||
(w.max(1), h.max(1))
|
||
}
|
||
}
|
||
|
||
struct Def {
|
||
kind: Kind,
|
||
name: &'static str,
|
||
seed: u32,
|
||
portrait: bool,
|
||
location: &'static str,
|
||
keywords: &'static [&'static str],
|
||
}
|
||
|
||
const DEFS: &[Def] = &[
|
||
Def {
|
||
kind: Kind::AlpineLake,
|
||
name: "Lake at first light",
|
||
seed: 11,
|
||
portrait: false,
|
||
location: "Dolomites",
|
||
keywords: &["mountains", "lake", "sunrise"],
|
||
},
|
||
Def { kind: Kind::Dunes, name: "Dune ridges", seed: 7, portrait: false, location: "Namib", keywords: &["desert", "dunes"] },
|
||
Def { kind: Kind::OceanSunset, name: "Last light over the sea", seed: 3, portrait: false, location: "Big Sur", keywords: &["ocean", "sunset"] },
|
||
Def {
|
||
kind: Kind::Aurora,
|
||
name: "Aurora over the ridge",
|
||
seed: 21,
|
||
portrait: false,
|
||
location: "Lofoten",
|
||
keywords: &["night", "aurora", "stars"],
|
||
},
|
||
Def { kind: Kind::MistyForest, name: "Morning fog", seed: 5, portrait: false, location: "Black Forest", keywords: &["forest", "fog"] },
|
||
Def { kind: Kind::Lavender, name: "Lavender rows", seed: 9, portrait: false, location: "Valensole", keywords: &["fields", "lavender", "summer"] },
|
||
Def { kind: Kind::Macro, name: "Cosmos bloom", seed: 13, portrait: true, location: "Home garden", keywords: &["flower", "macro", "bokeh"] },
|
||
Def { kind: Kind::Canyon, name: "Canyon walls", seed: 17, portrait: false, location: "Utah", keywords: &["canyon", "rock"] },
|
||
Def { kind: Kind::Beach, name: "Lagoon", seed: 19, portrait: false, location: "Maldives", keywords: &["beach", "tropical"] },
|
||
Def { kind: Kind::BlueHour, name: "Blue hour peaks", seed: 23, portrait: false, location: "Patagonia", keywords: &["mountains", "blue hour"] },
|
||
Def { kind: Kind::AlpineLake, name: "Still water", seed: 41, portrait: false, location: "Banff", keywords: &["mountains", "lake"] },
|
||
Def { kind: Kind::OceanSunset, name: "Golden horizon", seed: 44, portrait: false, location: "Algarve", keywords: &["ocean", "sunset"] },
|
||
Def { kind: Kind::Dunes, name: "Afternoon sand", seed: 57, portrait: true, location: "Sahara", keywords: &["desert"] },
|
||
Def { kind: Kind::Aurora, name: "Green curtains", seed: 60, portrait: false, location: "Yukon", keywords: &["night", "aurora"] },
|
||
Def { kind: Kind::Macro, name: "Pink petals", seed: 71, portrait: false, location: "Home garden", keywords: &["flower", "macro"] },
|
||
Def { kind: Kind::MistyForest, name: "Valley haze", seed: 73, portrait: false, location: "Oregon", keywords: &["forest", "fog"] },
|
||
Def { kind: Kind::Canyon, name: "Red layers", seed: 79, portrait: true, location: "Arizona", keywords: &["canyon"] },
|
||
Def { kind: Kind::Beach, name: "Turquoise shallows", seed: 83, portrait: false, location: "Bahamas", keywords: &["beach"] },
|
||
Def { kind: Kind::Lavender, name: "Evening fields", seed: 89, portrait: false, location: "Provence", keywords: &["fields"] },
|
||
Def { kind: Kind::BlueHour, name: "Cold summit", seed: 97, portrait: false, location: "Alps", keywords: &["mountains", "snow"] },
|
||
Def { kind: Kind::AlpineLake, name: "Alpenglow", seed: 101, portrait: true, location: "Zermatt", keywords: &["mountains"] },
|
||
Def { kind: Kind::OceanSunset, name: "Calm sea", seed: 107, portrait: false, location: "Crete", keywords: &["ocean"] },
|
||
Def { kind: Kind::Macro, name: "Violet bloom", seed: 113, portrait: false, location: "Kew", keywords: &["flower"] },
|
||
Def { kind: Kind::Dunes, name: "Wind lines", seed: 127, portrait: false, location: "Death Valley", keywords: &["desert"] },
|
||
];
|
||
|
||
const CAMERAS: &[(&str, &str, f32)] = &[
|
||
("Synthetic S1", "24-70mm f/2.8", 35.0),
|
||
("Synthetic S1", "70-200mm f/4", 135.0),
|
||
("Synthetic X2", "16-35mm f/4", 18.0),
|
||
("Synthetic X2", "90mm f/2.8 Macro", 90.0),
|
||
];
|
||
|
||
/// The demo library: 24 scenes, 6000×4000 (or 4000×6000) nominal size.
|
||
pub fn demo_library() -> Vec<Scene> {
|
||
DEFS.iter()
|
||
.enumerate()
|
||
.map(|(i, d)| {
|
||
let cam = if d.kind == Kind::Macro { CAMERAS[3] } else { CAMERAS[i % 3] };
|
||
let shutter = match d.kind {
|
||
Kind::Aurora => "8",
|
||
Kind::BlueHour => "1/4",
|
||
Kind::MistyForest => "1/60",
|
||
_ => ["1/250", "1/500", "1/125", "1/1000"][i % 4],
|
||
};
|
||
let iso = match d.kind {
|
||
Kind::Aurora => 3200,
|
||
Kind::BlueHour => 400,
|
||
_ => [100, 200, 64, 100][i % 4],
|
||
};
|
||
let (w, h) = if d.portrait { (4000, 6000) } else { (6000, 4000) };
|
||
Scene {
|
||
id: i as u32 + 1,
|
||
name: d.name.to_string(),
|
||
kind: d.kind,
|
||
seed: d.seed,
|
||
width: w,
|
||
height: h,
|
||
meta: SceneMeta {
|
||
camera: cam.0,
|
||
lens: cam.1,
|
||
focal_mm: cam.2,
|
||
aperture: [2.8, 8.0, 11.0, 4.0][i % 4],
|
||
shutter,
|
||
iso,
|
||
captured: format!("2026-{:02}-{:02}T{:02}:{:02}:00", 3 + i % 7, 1 + (i * 5) % 27, 5 + (i * 3) % 15, (i * 17) % 60),
|
||
location: d.location,
|
||
keywords: d.keywords.to_vec(),
|
||
},
|
||
}
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn library_is_deterministic_and_varied() {
|
||
let lib = demo_library();
|
||
assert_eq!(lib.len(), 24);
|
||
let a = lib[0].render(64, 43);
|
||
let b = lib[0].render(64, 43);
|
||
assert_eq!(a, b);
|
||
// every scene renders finite, non-negative, non-trivial images
|
||
for s in &lib {
|
||
let img = s.render_fit(48);
|
||
assert!(img.data.iter().all(|p| p.iter().all(|v| v.is_finite() && *v >= 0.0)), "{}", s.name);
|
||
let mean: f32 = img.data.iter().map(|p| p[1]).sum::<f32>() / img.len() as f32;
|
||
assert!(mean > 0.005 && mean < 3.0, "{} mean {mean}", s.name);
|
||
}
|
||
}
|
||
|
||
/// Found by fuzzing catalog snapshots: a demo photo whose width read 4294967295 asked its scene
|
||
/// for a render that size, and the pixel buffer's size overflowed (a panic on the render
|
||
/// thread). A scene renders at most at its own size, which is the photo's full resolution.
|
||
#[test]
|
||
fn a_scene_renders_at_most_at_its_own_size() {
|
||
let mut s = demo_library().remove(0);
|
||
(s.width, s.height) = (40, 30);
|
||
let img = s.render_fit(usize::MAX);
|
||
assert_eq!((img.width, img.height), (40, 30));
|
||
assert_eq!(s.fit(20), (20, 15), "smaller sizes as asked");
|
||
}
|
||
|
||
#[test]
|
||
fn sunsets_have_highlight_headroom() {
|
||
let lib = demo_library();
|
||
let s = lib.iter().find(|s| s.kind == Kind::OceanSunset).unwrap();
|
||
let img = s.render_fit(200);
|
||
let max = img.data.iter().map(|p| p[0]).fold(0.0f32, f32::max);
|
||
assert!(max > 2.0, "{max}");
|
||
}
|
||
|
||
/// The serial render is the parallel one, pixel for pixel (both with and without supersampling), and
|
||
/// an empty size is an empty image.
|
||
#[test]
|
||
fn the_serial_render_is_the_same_picture() {
|
||
for s in demo_library().iter().take(3) {
|
||
for edge in [40, 700] {
|
||
assert_eq!(s.render_fit_serial(edge), s.render_fit(edge), "{} at {edge}", s.name);
|
||
}
|
||
}
|
||
assert!(paint::render_serial(Kind::Dunes, 1, 0, 5).data.is_empty());
|
||
assert!(demo_library()[0].render(0, 0).data.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn resolution_independent() {
|
||
let s = &demo_library()[1];
|
||
let small = s.render(60, 40);
|
||
let big = s.render(240, 160);
|
||
let down = lightcraft_raster::resample::resize(&big, 60, 40, lightcraft_raster::resample::Filter::Box);
|
||
let err: f32 = small.data.iter().zip(&down.data).map(|(a, b)| (a[1] - b[1]).abs()).sum::<f32>() / small.len() as f32;
|
||
assert!(err < 0.08, "{err}");
|
||
}
|
||
}
|