Got the movie working in the gallery

This commit is contained in:
connor committed 2026-10-15 13:35:00 -04:00
1 parent 8861858f0d
commit 782820261e
15 files changed
+1291 -111

No files matched your search

+6 -95
View File
@@ -9,9 +9,9 @@ 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,
CameraTier, EarthTexture, Fidelity, Frame, LightingTier, Look, MoonTexture, Preview, Renderer,
RendererOptions,
};
use crate::scene_file::SceneFile;
@@ -270,24 +270,8 @@ fn render(args: &RenderArgs) -> Result<(), String> {
write_dn(&frame, out, detector.adc_bits)?;
}
if let Some(preview) = preview {
// Bias reads as black and the saturation level as white.
let black = detector.bias;
let white = detector.dn(detector.full_well) as f64;
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)?;
let look = if args.pretty { Look::Pretty } else { Look::Raw };
write_preview(&frame, preview, &Preview::new(renderer.camera(), look))?;
}
Ok(())
}
@@ -323,62 +307,8 @@ fn write_dn(frame: &Frame, path: &Path, adc_bits: u32) -> Result<(), String> {
.map_err(|e| format!("{}: {e}", path.display()))
}
/// 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 {
12.92 * x
} else {
1.055 * x.powf(1.0 / 2.4) - 0.055
};
(v * 255.0).round() as u8
};
let level = |d: u16| (d as f64 - black) / (white - black);
let bytes: Vec<u8> = frame
.dn
.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();
fn write_preview(frame: &Frame, path: &Path, preview: &Preview) -> Result<(), String> {
let bytes = preview.srgb8(&frame.dn, frame.width);
image::save_buffer(
path,
&bytes,
@@ -388,22 +318,3 @@ fn write_preview(
)
.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);
}
}
}
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "resolved-showcase"
description = "A showcase movie for the resolved renderer: a propagated flight around a spacecraft"
publish = false
version.workspace = true
edition.workspace = true
rust-version.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
[[bin]]
name = "resolved-movie"
path = "src/main.rs"
[dependencies]
resolved = { workspace = true, features = ["astrokit"] }
# Not on crates.io, so a path dependency; a sibling checkout is expected, as for the
# library.
astrokit = { path = "../../../astrokit", default-features = false }
# The kernels' type, as astrokit and the library use it.
anise = { version = "0.10.6", default-features = false }
hifitime = "4.3"
clap = { version = "4.6", features = ["derive"] }
image = { version = "0.25", default-features = false, features = ["jpeg"] }
+327
View File
@@ -0,0 +1,327 @@
//! The flight: a target in low Earth orbit, propagated by astrokit with J2, and an
//! inspector holding station behind and above it with the camera, sampled once per
//! movie frame.
//!
//! The camera holds a station in the target's local frame (as an inspection spacecraft
//! does by thrusting), looking forward and down past the target toward the horizon
//! ahead, so the limb crosses the frame just above the target. On top of the station it
//! moves as a free spacecraft would: a deputy, propagated by astrokit alongside the
//! target, flies a small drift-free relative ellipse around it (the Clohessy-Wiltshire
//! solution with no along-track drift), and the camera follows the deputy's offset from
//! the target. The orbit's plane holds both the Sun and the Moon.
//!
//! Time runs at a varying rate, like a documentary's time-lapse: fast while cruising
//! over the daylit Earth, slowing as the Moon rises over the limb ahead, and slowing
//! nearly to real time as the target passes into Earth's shadow, so the penumbra can
//! be seen darkening it. The two moments are found on the propagated orbit, and the
//! cruising rate is solved so they fall at [`MOONRISE_AT`] and [`SUNSET_AT`].
use std::f64::consts::{FRAC_PI_2, PI};
use std::sync::Arc;
use anise::almanac::Almanac;
use astrokit::prelude::*;
use resolved::ephemeris::Ephemeris;
use resolved::nalgebra::{Matrix3, Rotation3, UnitQuaternion, Vector3};
use resolved::Pose;
/// Earth's equatorial radius, in meters.
const EARTH_RADIUS: f64 = 6_378_137.0;
/// The target's orbit radius: 705 km up, Landsat 8's altitude.
pub const ORBIT_RADIUS: f64 = EARTH_RADIUS + 705e3;
/// The movie's length, in seconds.
pub const MOVIE: f64 = 30.0;
/// When the Moon's center stands MOON_CLEAR above the limb ahead, in movie seconds:
/// clear of the horizon, so the slow passage shows it rising rather than still hidden.
pub const MOONRISE_AT: f64 = 9.0;
const MOON_CLEAR: f64 = 2.0 * PI / 180.0;
/// When the target enters Earth's shadow, in movie seconds.
pub const SUNSET_AT: f64 = 26.5;
/// Simulated seconds per movie second as the Moon rises, and as the target sets.
const MOONRISE_RATE: f64 = 8.0;
const SUNSET_RATE: f64 = 1.5;
/// How long each slow passage lasts, in movie seconds: the moonrise's is the half
/// width of a flat-topped window (exp(-x^6 / 2)), since the Moon has the whole sky above
/// the limb to climb through at the orbit's rate; the sunset's, the standard deviation
/// of a Gaussian.
const MOONRISE_SPREAD: f64 = 5.0;
const SUNSET_SPREAD: f64 = 2.0;
/// The camera station's distance from the target, and its line of sight below the
/// local horizontal (1.2 degrees steeper than the horizon, so the limb is just above
/// the target).
const RANGE: f64 = 120.0;
const DEPRESSION: f64 = 27.0 * PI / 180.0;
/// The relative ellipse's radial amplitude (it is twice that along-track) and its
/// amplitude out of the orbit plane, in meters. Small: at RANGE, a meter radially
/// tilts the view by half a degree, and the limb is only 1.2 degrees above center.
const RELATIVE_RADIAL: f64 = 5.0;
const RELATIVE_CROSS: f64 = 30.0;
/// Where the relative ellipse has the camera across the orbit plane at the moonrise,
/// in meters: to one side, so the Moon comes up beside the target instead of behind
/// it and its solar array.
const CROSS_AT_MOONRISE: f64 = 8.0;
/// One movie frame's geometry.
#[derive(Clone, Copy, Debug)]
pub struct Shot {
/// The frame's epoch.
pub epoch: Epoch,
/// The target's pose: body axes along-track, orbit normal and zenith.
pub target: Pose,
/// The camera: at the inspector, looking at the target's center.
pub camera: Pose,
}
/// What the flight works out about itself.
#[derive(Clone, Copy, Debug)]
pub struct Plan {
/// The angle between the Sun and the Moon seen from Earth, in radians.
pub elongation: f64,
/// Simulated seconds per movie second while cruising.
pub cruise: f64,
/// Simulated time the movie covers, in seconds.
pub span: f64,
}
/// Simulated seconds per movie second at movie time `tau`, with `cruise` between the
/// slow passages.
fn rate(tau: f64, cruise: f64) -> f64 {
let m = (-0.5 * ((tau - MOONRISE_AT) / MOONRISE_SPREAD).powi(6)).exp();
let s = (-0.5 * ((tau - SUNSET_AT) / SUNSET_SPREAD).powi(2)).exp();
cruise * (1.0 - m - s).max(0.0) + MOONRISE_RATE * m + SUNSET_RATE * s
}
/// Simulated time elapsed from movie time `from` to `to`, at `cruise`.
fn elapsed(from: f64, to: f64, cruise: f64) -> f64 {
let steps = ((to - from) * 1000.0).ceil().max(1.0) as usize;
let h = (to - from) / steps as f64;
(0..steps)
.map(|i| rate(from + (i as f64 + 0.5) * h, cruise) * h)
.sum()
}
/// The first time in `[lo, hi]` where `f` changes sign from `f(lo)`, by bisection.
fn crossing(lo: f64, hi: f64, f: impl Fn(f64) -> f64) -> Option<f64> {
// Scan in 5 s steps for the sign change, then bisect.
let start = f(lo).signum();
let mut a = lo;
while a < hi {
let b = (a + 5.0).min(hi);
if f(b).signum() != start {
let (mut a, mut b) = (a, b);
for _ in 0..50 {
let mid = 0.5 * (a + b);
if f(mid).signum() == start {
a = mid;
} else {
b = mid;
}
}
return Some(0.5 * (a + b));
}
a = b;
}
None
}
/// Every frame of the flight, `fps` per movie second, propagated with `almanac`'s
/// kernels (the ones `ephemeris` reads). `epoch` sets the Sun and the Moon
/// and is where propagation starts, `lead` seconds before the moonrise is due; `center`
/// is the target model's center in its body axes, which the camera keeps in the middle
/// of the image.
pub fn fly(
almanac: Arc<Almanac>,
ephemeris: &Ephemeris,
epoch: Epoch,
center: Vector3<f64>,
fps: u32,
) -> Result<(Plan, Vec<Shot>), String> {
let error = |e: resolved::Error| e.to_string();
let sun = ephemeris.sun(epoch).map_err(error)?;
let moon = ephemeris.moon(epoch).map_err(error)?;
let mu = Body::EARTH.mu;
let n = (mu / ORBIT_RADIUS.powi(3)).sqrt();
// The orbit plane, with angles measured from the Moon and the Sun at -elongation,
// so the target moves away from the Sun's side toward the Moon's.
let m = moon.normalize();
let elongation = sun.normalize().dot(&m).clamp(-1.0, 1.0).acos();
let (x, y) = (m, -(sun - sun.dot(&m) * m).normalize());
let at = |angle: f64| x * angle.cos() + y * angle.sin();
// Start well before the moonrise: the line of sight grazing the limb ahead points
// 90 degrees ahead of the target's position, less the horizon's dip.
let dip = (EARTH_RADIUS / ORBIT_RADIUS).acos();
let lead = 2400.0;
let start = -(FRAC_PI_2 - dip) - n * lead;
// Long enough to reach the shadow from there and run on past it.
let length = lead + (PI - elongation) / n + 2400.0;
let target = Cartesian::new(
at(start) * ORBIT_RADIUS,
at(start + FRAC_PI_2) * (n * ORBIT_RADIUS),
Frame::EARTH_J2000,
);
let forces = [Zonal {
field: GravityModel::Egm96,
degree: 2,
}];
let mut run = Propagation::from_cartesian(epoch, target, &forces)
.almanac(almanac.clone())
.duration(Duration::from_seconds(length))
.accuracy(Accuracy::High);
let solution = run.run();
let state = |t: f64| {
Orbit
.state(solution.interpolate(t))
.cartesian(Frame::EARTH_J2000)
};
let when = |t: f64| astrokit::time::advance(epoch, t);
// The moonrise: the Moon's center MOON_CLEAR above the horizon ahead.
let moon_height = |t: f64| {
let p = state(t).position;
let to_moon = (ephemeris.moon(when(t)).expect("in the kernels") - p).normalize();
to_moon.dot(&p.normalize()).asin() + (EARTH_RADIUS / p.norm()).acos() - MOON_CLEAR
};
let moonrise = crossing(0.0, length, moon_height)
.ok_or("the Moon never rises over the limb ahead; try another epoch")?;
// The sunset: the target entering Earth's (cylindrical) shadow.
let sunlit = |t: f64| {
let p = state(t).position;
let s = ephemeris.sun(when(t)).expect("in the kernels").normalize();
let along = p.dot(&s);
if along > 0.0 {
1.0
} else {
(p - along * s).norm() - EARTH_RADIUS
}
};
let sunset = crossing(moonrise, length, sunlit)
.ok_or("the target never reaches Earth's shadow; try another epoch")?;
// The cruising rate that puts the sunset at SUNSET_AT, given the moonrise at
// MOONRISE_AT: elapsed time is linear in it.
let base = elapsed(MOONRISE_AT, SUNSET_AT, 0.0);
let per = elapsed(MOONRISE_AT, SUNSET_AT, 1.0) - base;
let cruise = (sunset - moonrise - base) / per;
if cruise <= MOONRISE_RATE {
return Err(format!(
"the moonrise and the sunset are only {:.0} s apart; try another epoch",
sunset - moonrise
));
}
let first = moonrise - elapsed(0.0, MOONRISE_AT, cruise);
let last = sunset + elapsed(SUNSET_AT, MOVIE, cruise);
if first < 0.0 || last > length {
return Err("the flight does not fit the propagated span".into());
}
// The deputy: on a drift-free relative ellipse around the target. In the target's
// plane the motion is the Clohessy-Wiltshire ellipse, phased to cross the station's
// height halfway through the flight, so the camera spends as long above it as
// below: with u = n (t - middle), radial A sin u and along-track 2A cos u. Across
// the plane it is an independent oscillation, phased to be CROSS_AT_MOONRISE out
// at the moonrise: with w = n (t - moonrise) + asin(CROSS_AT_MOONRISE / B),
// cross-track B sin w.
let deputy = {
let start = state(0.0);
let (r, v) = (start.position, start.velocity);
let (zenith, normal) = (r.normalize(), r.cross(&v).normalize());
let along = normal.cross(&zenith);
let rate = r.cross(&v).norm() / r.norm_squared();
let (a, b) = (RELATIVE_RADIAL, RELATIVE_CROSS);
let u = -rate * (first + last) / 2.0;
let w = -rate * moonrise + (CROSS_AT_MOONRISE / b).asin();
let offset = zenith * (a * u.sin()) + along * (2.0 * a * u.cos()) + normal * (b * w.sin());
let drift = zenith * (a * rate * u.cos())
+ along * (-2.0 * a * rate * u.sin())
+ normal * (b * rate * w.cos());
// Rates in the rotating frame, plus the frame's own turning.
let velocity = v + drift + (normal * rate).cross(&offset);
Cartesian::new(r + offset, velocity, Frame::EARTH_J2000)
};
let mut deputy_run = Propagation::from_cartesian(epoch, deputy, &forces)
.almanac(almanac)
.duration(Duration::from_seconds(length))
.accuracy(Accuracy::High);
let deputy_solution = deputy_run.run();
let deputy_position = |t: f64| {
Orbit
.state(deputy_solution.interpolate(t))
.cartesian(Frame::EARTH_J2000)
.position
};
let frames = (MOVIE * fps as f64).round() as usize;
let mut shots = Vec::with_capacity(frames);
let mut t = first;
for k in 0..frames {
let tau = k as f64 / fps as f64;
if k > 0 {
t += elapsed(tau - 1.0 / fps as f64, tau, cruise);
}
let target = state(t);
// Target axes: x along-track, y the orbit normal, z zenith.
let zenith = target.position.normalize();
let normal = target.position.cross(&target.velocity).normalize();
let along = normal.cross(&zenith);
let attitude = UnitQuaternion::from_rotation_matrix(&Rotation3::from_matrix_unchecked(
Matrix3::from_columns(&[along, normal, zenith]),
));
let aim = target.position + attitude * center;
// The station, behind and above, looking DEPRESSION down, moved by the deputy's
// natural motion relative to the target.
let station = aim - along * (RANGE * DEPRESSION.cos())
+ zenith * (RANGE * DEPRESSION.sin())
+ (deputy_position(t) - target.position);
let camera = Pose::looking_at(station, aim, zenith)
.expect("the line of sight is never along the zenith");
shots.push(Shot {
epoch: when(t),
target: Pose::new(target.position, attitude),
camera,
});
}
Ok((
Plan {
elongation,
cruise,
span: last - first,
},
shots,
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn time_slows_to_the_set_rates_at_the_two_moments() {
let cruise = 200.0;
assert!((rate(MOONRISE_AT, cruise) - MOONRISE_RATE).abs() < 1e-9);
assert!((rate(SUNSET_AT, cruise) - SUNSET_RATE).abs() < 1.0);
// Between them it is well above both and never above the cruising rate.
let between = rate((MOONRISE_AT + SUNSET_AT) / 2.0, cruise);
assert!(between > 10.0 * MOONRISE_RATE && between <= cruise);
// Elapsed time adds up across a split, and is linear in the cruising rate.
let whole = elapsed(0.0, MOVIE, cruise);
let split = elapsed(0.0, 12.0, cruise) + elapsed(12.0, MOVIE, cruise);
assert!((whole - split).abs() / whole < 1e-9);
let (a, b) = (elapsed(2.0, 20.0, 100.0), elapsed(2.0, 20.0, 300.0));
let mid = elapsed(2.0, 20.0, 200.0);
assert!((mid - (a + b) / 2.0).abs() / mid < 1e-9);
}
#[test]
fn crossing_finds_the_first_sign_change() {
let t = crossing(0.0, 100.0, |t| (t - 37.25) * (t - 80.0)).unwrap();
assert!((t - 37.25).abs() < 1e-9);
assert!(crossing(0.0, 30.0, |t| t + 1.0).is_none());
}
}
+450
View File
@@ -0,0 +1,450 @@
//! `resolved-movie`: a showcase of the renderer, as a movie of a propagated flight.
//!
//! An inspector carrying the example camera flies around a Landsat 8 model in low
//! orbit, both propagated by astrokit with J2 (see [`flight`]), rendered at lighting L1:
//! Earth's surface turning below, the Moon rising over the limb, shadows moving across
//! the spacecraft and, at the end, the spacecraft passing into Earth's shadow.
//!
//! `--out movie.mp4` renders every frame and encodes them with ffmpeg. `--serve
//! ADDRESS` renders live, in step with the wall clock, and streams the frames as MJPEG
//! to any browser that opens the address, looping the flight; it renders only while
//! someone is watching.
mod flight;
mod stream;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode, Stdio};
use std::sync::Arc;
use std::time::{Duration, Instant};
use astrokit::prelude::Epoch;
use clap::Parser;
use resolved::ephemeris::Ephemeris;
use resolved::gpu::AdapterChoice;
use resolved::nalgebra::Vector3;
use resolved::{
CameraConfig, Detector, EarthTexture, Fidelity, LightingTier, LoadOptions, Look, Mesh,
MoonTexture, Preview, Renderer, RendererOptions, Scene, Target,
};
type Result<T> = std::result::Result<T, String>;
#[derive(Parser)]
#[command(
version,
about = "A showcase movie: a propagated flight around a spacecraft"
)]
struct Cli {
/// Write the movie here as H.264 MP4, through ffmpeg.
#[arg(long)]
out: Option<PathBuf>,
/// Render live and stream to browsers at this address, such as 0.0.0.0:8010.
#[arg(long, value_name = "ADDRESS")]
serve: Option<String>,
/// Frames per second.
#[arg(long, default_value_t = 60)]
fps: u32,
/// Sets the Sun and the Moon; the flight starts 40 minutes before the Moon rises
/// over the limb ahead. The Moon should be 90 to 120 degrees from the Sun, so it is
/// about half lit, Earth below is in daylight, and the target reaches Earth's
/// shadow before the movie ends.
#[arg(long, default_value = "2026-01-10T03:00:00 UTC")]
epoch: String,
/// The lens's focal length, in meters.
#[arg(long, default_value_t = 0.035)]
focal_length: f64,
/// The exposure, in seconds: where auto exposure starts, or with --fixed-exposure,
/// the exposure throughout.
#[arg(long, default_value_t = 2e-4)]
exposure: f64,
/// Keep the exposure fixed instead of adjusting it frame by frame.
#[arg(long)]
fixed_exposure: bool,
/// Show the raw signal instead of the pretty look (see `resolved render --pretty`).
#[arg(long)]
raw: bool,
/// Device: auto, gpu, cpu, or part of an adapter's name.
#[arg(long, default_value = "auto")]
adapter: String,
/// Directory with earth/bmng-2004-MM.jpg and moon/wac-hapke-643.png. Without it,
/// the repository's data/.
#[arg(long, value_name = "DIR")]
data: Option<PathBuf>,
/// Directory of astrokit's kernels. Without it: ASTROKIT_KERNELS, then
/// ./data/kernels, then the sibling astrokit checkout.
#[arg(long, value_name = "DIR")]
kernels: Option<PathBuf>,
}
fn main() -> ExitCode {
match run(&Cli::parse()) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e}");
ExitCode::FAILURE
}
}
}
fn repository() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")
}
fn load_almanac(explicit: Option<&Path>) -> Result<Arc<anise::almanac::Almanac>> {
let directories: Vec<PathBuf> = match explicit {
Some(dir) => vec![dir.to_owned()],
None => std::env::var_os("ASTROKIT_KERNELS")
.map(PathBuf::from)
.into_iter()
.chain([
PathBuf::from("data/kernels"),
repository().join("../astrokit/data/kernels"),
])
.collect(),
};
let mut failures = Vec::new();
for dir in &directories {
match astrokit::kernels::load(dir) {
Ok(almanac) => return Ok(Arc::new(almanac)),
Err(e) => failures.push(format!(" {}: {e}", dir.display())),
}
}
Err(format!(
"astrokit kernels not found. Pass --kernels DIR or set ASTROKIT_KERNELS; astrokit's \
scripts/fetch-kernels.sh downloads them. Tried:\n{}",
failures.join("\n")
))
}
/// Auto exposure: after each frame, steer the next frame's exposure so the brightest
/// parts of the image (its 99.9th percentile) sit at LEVEL of full scale (below half,
/// since the pretty look lifts what is below it), changing by at most a factor of
/// STEP per second of movie, between the shortest exposure and one frame's time, as a
/// video camera is limited to.
const LEVEL: f64 = 0.45;
const STEP: f64 = 6.0;
const SHORTEST: f64 = 1e-5;
/// Everything a frame needs, set up once.
struct Movie {
renderer: Renderer,
preview: Preview,
/// The longest exposure, or None to keep it fixed.
auto_exposure: Option<f64>,
/// The most auto exposure changes from one frame to the next, as a factor.
step: f64,
detector: Detector,
/// One per frame, with its camera.
frames: Vec<(Scene, resolved::Pose)>,
}
impl Movie {
fn new(cli: &Cli) -> Result<Self> {
let started = Instant::now();
let almanac = load_almanac(cli.kernels.as_deref())?;
let ephemeris = Ephemeris::new(almanac.clone());
let epoch: Epoch = cli
.epoch
.parse()
.map_err(|e| format!("epoch {:?}: {e}", cli.epoch))?;
let error = |e: resolved::Error| e.to_string();
let model = repository().join("examples/models/nasa/landsat8.glb");
let mesh = Mesh::load(&model, &LoadOptions::default())
.map_err(|e| format!("{e} (scripts/fetch-models.sh downloads the model)"))?;
let (mut lo, mut hi) = (Vector3::repeat(f64::MAX), Vector3::repeat(f64::MIN));
for p in &mesh.positions {
let p = Vector3::from(*p).cast::<f64>();
lo = lo.inf(&p);
hi = hi.sup(&p);
}
let (plan, shots) = flight::fly(almanac, &ephemeris, epoch, (lo + hi) / 2.0, cli.fps)?;
let data = cli
.data
.clone()
.unwrap_or_else(|| repository().join("data"));
let month = epoch.to_gregorian_utc().1;
let earth = data.join(format!("earth/bmng-2004-{month:02}.jpg"));
let moon = data.join("moon/wac-hapke-643.png");
let earth = EarthTexture::load(&earth)
.map_err(|e| format!("{e} (scripts/fetch-earth.sh downloads it)"))?;
let moon = MoonTexture::load(&moon)
.map_err(|e| format!("{e} (scripts/fetch-moon.py makes it)"))?;
let mut camera = CameraConfig::example();
camera.exposure = cli.exposure;
camera.focal_length = cli.focal_length;
let adapter = match cli.adapter.as_str() {
"auto" => AdapterChoice::Auto,
"gpu" => AdapterChoice::Gpu,
"cpu" => AdapterChoice::Cpu,
name => AdapterChoice::Named(name.to_owned()),
};
let options = RendererOptions {
adapter,
fidelity: Fidelity {
lighting: LightingTier::L1,
..Default::default()
},
earth_texture: Some(Arc::new(earth)),
moon_texture: Some(Arc::new(moon)),
..Default::default()
};
let mut renderer = Renderer::new(camera, options).map_err(error)?;
let model = renderer.load_model(&mesh).map_err(error)?;
let frames = shots
.iter()
.map(|shot| {
let target = Target {
model,
pose: shot.target,
};
Ok((
ephemeris.scene(shot.epoch, vec![target]).map_err(error)?,
shot.camera,
))
})
.collect::<Result<Vec<_>>>()?;
let look = if cli.raw { Look::Raw } else { Look::Pretty };
let preview = Preview::new(renderer.camera(), look);
eprintln!(
"{} frames on {}, set up in {:.1} s. The Moon is {:.0} degrees from the Sun; {:.0} \
minutes of flight, cruising at {:.0}x real time, slowing for the moonrise at {} s \
and the target's sunset at {} s.",
frames.len(),
renderer.adapter_info().name,
started.elapsed().as_secs_f64(),
plan.elongation.to_degrees(),
plan.span / 60.0,
plan.cruise,
flight::MOONRISE_AT,
flight::SUNSET_AT,
);
let detector = renderer.camera().detector;
Ok(Self {
renderer,
preview,
auto_exposure: (!cli.fixed_exposure).then_some(1.0 / cli.fps as f64),
step: STEP.powf(1.0 / cli.fps as f64),
detector,
frames,
})
}
fn width(&self) -> u32 {
self.renderer.camera().width
}
fn height(&self) -> u32 {
self.renderer.camera().height
}
/// Frame `k` as 8-bit sRGB, and the next frame's exposure set from it.
fn render(&mut self, k: usize) -> Result<Vec<u8>> {
let (scene, camera) = &self.frames[k];
let error = |e: resolved::Error| e.to_string();
let view = self
.renderer
.submit(scene, camera)
.map_err(error)?
.wait_view()
.map_err(error)?;
let rgb = self.preview.srgb8(view.dn(), view.width);
let level = brightest(view.dn(), view.channels as usize, &self.detector);
drop(view);
if let Some(longest) = self.auto_exposure {
let exposure = self.renderer.camera().exposure;
let wanted = exposure * LEVEL / level.max(1e-6);
let next = wanted.clamp(exposure / self.step, exposure * self.step);
self.renderer
.set_exposure(next.clamp(SHORTEST, longest))
.map_err(error)?;
}
Ok(rgb)
}
}
/// The 99.9th percentile of the image's brightest channel, as a fraction of full scale
/// above the bias, from every fourth pixel.
fn brightest(dn: &[u16], channels: usize, detector: &Detector) -> f64 {
let (bias, saturated) = (detector.bias, detector.dn(detector.full_well) as f64);
let mut counts = vec![0u32; 1 << detector.adc_bits];
let last = counts.len() - 1;
let mut total = 0u32;
for pixel in dn.chunks(channels).step_by(4) {
let top = pixel.iter().copied().max().unwrap_or(0) as usize;
counts[top.min(last)] += 1;
total += 1;
}
let mut above = total / 1000;
for (value, &count) in counts.iter().enumerate().rev() {
if count > above {
return ((value as f64 - bias) / (saturated - bias)).max(0.0);
}
above -= count;
}
0.0
}
fn run(cli: &Cli) -> Result<()> {
if cli.out.is_none() && cli.serve.is_none() {
return Err("nothing to do: give --out FILE or --serve ADDRESS".into());
}
let mut movie = Movie::new(cli)?;
if let Some(out) = &cli.out {
record(&mut movie, out, cli.fps)?;
}
if let Some(address) = &cli.serve {
serve(&mut movie, address, cli.fps)?;
}
Ok(())
}
/// Every frame, through ffmpeg to H.264.
fn record(movie: &mut Movie, out: &Path, fps: u32) -> Result<()> {
let size = format!("{}x{}", movie.width(), movie.height());
let mut ffmpeg = Command::new("ffmpeg")
.args([
"-y",
"-loglevel",
"error",
"-f",
"rawvideo",
"-pix_fmt",
"rgb24",
])
.args(["-s", &size, "-r", &fps.to_string(), "-i", "-"])
.args(["-c:v", "libx264", "-preset", "slow", "-crf", "18"])
.args(["-pix_fmt", "yuv420p", "-movflags", "+faststart"])
.arg(out)
.stdin(Stdio::piped())
.spawn()
.map_err(|e| format!("ffmpeg: {e} (is it installed?)"))?;
let mut input = ffmpeg.stdin.take().expect("piped");
let started = Instant::now();
let mut rendering = Duration::ZERO;
let mut exposures = Vec::with_capacity(movie.frames.len());
for k in 0..movie.frames.len() {
let t = Instant::now();
exposures.push(movie.renderer.camera().exposure);
let rgb = movie.render(k)?;
rendering += t.elapsed();
input.write_all(&rgb).map_err(|e| format!("ffmpeg: {e}"))?;
}
drop(input);
let status = ffmpeg.wait().map_err(|e| format!("ffmpeg: {e}"))?;
if !status.success() {
return Err(format!("ffmpeg failed: {status}"));
}
let n = movie.frames.len() as f64;
eprintln!(
"wrote {}: rendering and tone mapping {:.1} ms per frame, {:.1} s in all with encoding",
out.display(),
rendering.as_secs_f64() * 1e3 / n,
started.elapsed().as_secs_f64()
);
// The exposure each second, for seeing what auto exposure did.
let each: Vec<String> = exposures
.iter()
.step_by(fps as usize)
.map(|e| format!("{:.2}", e * 1e3))
.collect();
eprintln!("exposure each second, ms: {}", each.join(" "));
Ok(())
}
/// Live, in step with the wall clock, looping, while anyone watches. JPEG encoding
/// takes several times as long as rendering, so it runs on worker threads.
fn serve(movie: &mut Movie, address: &str, fps: u32) -> Result<()> {
const ENCODERS: usize = 8;
let listener = std::net::TcpListener::bind(address).map_err(|e| format!("{address}: {e}"))?;
let broadcast = Arc::new(stream::Broadcast::default());
{
let broadcast = broadcast.clone();
std::thread::spawn(move || stream::serve(listener, broadcast));
}
let (width, height) = (movie.width(), movie.height());
// Frames go to the encoders in turn, and one publisher takes the JPEGs back in the
// same turn, so they reach viewers in order and none is dropped for finishing late.
let mut encoders = Vec::with_capacity(ENCODERS);
let mut finished = Vec::with_capacity(ENCODERS);
for _ in 0..ENCODERS {
let (to_encoder, from_renderer) = std::sync::mpsc::sync_channel::<(u64, Vec<u8>)>(1);
let (to_publisher, from_encoder) = std::sync::mpsc::sync_channel::<(u64, Vec<u8>)>(1);
std::thread::spawn(move || {
for (sequence, rgb) in from_renderer {
let mut jpeg = Vec::new();
let encoded = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, 85)
.encode(&rgb, width, height, image::ExtendedColorType::Rgb8);
if encoded.is_err() || to_publisher.send((sequence, jpeg)).is_err() {
return;
}
}
});
encoders.push(to_encoder);
finished.push(from_encoder);
}
{
let broadcast = broadcast.clone();
std::thread::spawn(move || {
for turn in (0..ENCODERS).cycle() {
let Ok((sequence, jpeg)) = finished[turn].recv() else {
return;
};
broadcast.publish(sequence, jpeg);
}
});
}
eprintln!("streaming at http://{address}/ (Ctrl+C to stop)");
let frame_time = Duration::from_secs_f64(1.0 / fps as f64);
let count = movie.frames.len() as u64;
let mut start = Instant::now();
// `tick` counts frames of the flight and restarts for each new audience; `sequence`
// orders frames for the encoders and never restarts.
let (mut tick, mut sequence) = (0u64, 0u64);
let mut watching = false;
let (mut shown, mut skipped, mut rendering, mut report) =
(0u32, 0u64, Duration::ZERO, Instant::now());
loop {
if broadcast.viewers() == 0 {
watching = false;
std::thread::sleep(Duration::from_millis(100));
continue;
}
if !watching {
// Start the flight over for a new audience.
(watching, start, tick) = (true, Instant::now(), 0);
}
if let Some(wait) = (frame_time * tick as u32).checked_sub(start.elapsed()) {
std::thread::sleep(wait);
}
// More than a frame late: skip ahead to the frame that is due.
let due = (start.elapsed().as_secs_f64() * fps as f64) as u64;
if due > tick + 1 {
skipped += due - tick;
tick = due;
}
let t = Instant::now();
let rgb = movie.render((tick % count) as usize)?;
rendering += t.elapsed();
encoders[(sequence % ENCODERS as u64) as usize]
.send((sequence, rgb))
.map_err(|_| "the encoders stopped".to_string())?;
(tick, sequence) = (tick + 1, sequence + 1);
shown += 1;
if report.elapsed() >= Duration::from_secs(5) {
eprintln!(
"{:.1} frames per second ({} skipped), rendering {:.1} ms per frame",
shown as f64 / report.elapsed().as_secs_f64(),
skipped,
rendering.as_secs_f64() * 1e3 / shown as f64
);
(shown, skipped, rendering, report) = (0, 0, Duration::ZERO, Instant::now());
}
}
}
+105
View File
@@ -0,0 +1,105 @@
//! A live MJPEG stream over HTTP: each frame a JPEG, sent to every viewer as one part
//! of a `multipart/x-mixed-replace` response, which browsers show as a moving image in
//! an `<img>`.
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Condvar, Mutex};
/// The newest frame, shared by the encoders and every viewer.
#[derive(Default)]
pub struct Broadcast {
/// A count that rises with each frame published, the frame's own sequence number,
/// and the JPEG.
latest: Mutex<(u64, u64, Arc<Vec<u8>>)>,
fresh: Condvar,
viewers: AtomicUsize,
}
impl Broadcast {
/// Publishes frame `sequence` to every viewer, unless a later frame is already out.
pub fn publish(&self, sequence: u64, jpeg: Vec<u8>) {
let mut latest = self.latest.lock().expect("not poisoned");
if latest.0 > 0 && sequence <= latest.1 {
return;
}
*latest = (latest.0 + 1, sequence, Arc::new(jpeg));
self.fresh.notify_all();
}
/// How many viewers are connected.
pub fn viewers(&self) -> usize {
self.viewers.load(Ordering::Relaxed)
}
/// Waits for a frame newer than `seen`.
fn next(&self, seen: u64) -> (u64, Arc<Vec<u8>>) {
let mut latest = self.latest.lock().expect("not poisoned");
while latest.0 == seen {
latest = self.fresh.wait(latest).expect("not poisoned");
}
(latest.0, latest.2.clone())
}
}
const PAGE: &str = "<!doctype html><html><head><meta charset=\"utf-8\"><title>resolved movie</title>\
<style>body{margin:0;background:#000;display:grid;place-items:center;height:100vh}\
img{max-width:100%;max-height:100vh}</style></head><body><img src=\"/stream\" alt=\"live\"></body></html>";
/// Serves `broadcast` on `listener` until the process ends: the stream at `/stream`
/// and a page showing it at `/`.
pub fn serve(listener: TcpListener, broadcast: Arc<Broadcast>) {
for connection in listener.incoming() {
let Ok(connection) = connection else { continue };
let broadcast = broadcast.clone();
std::thread::spawn(move || {
// A viewer that hangs up ends its write with an error; nothing to report.
let _ = handle(connection, &broadcast);
});
}
}
fn handle(mut connection: TcpStream, broadcast: &Broadcast) -> std::io::Result<()> {
let mut request = String::new();
BufReader::new(&connection).read_line(&mut request)?;
let path = request.split_whitespace().nth(1).unwrap_or("/");
match path {
"/" => {
write!(
connection,
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\n\
Content-Length: {}\r\nConnection: close\r\n\r\n{PAGE}",
PAGE.len()
)
}
"/stream" => {
write!(
connection,
"HTTP/1.1 200 OK\r\nContent-Type: multipart/x-mixed-replace; boundary=frame\r\n\
Cache-Control: no-cache\r\nConnection: close\r\n\r\n"
)?;
broadcast.viewers.fetch_add(1, Ordering::Relaxed);
let result = (|| {
let mut seen = 0;
loop {
let (seq, jpeg) = broadcast.next(seen);
seen = seq;
write!(
connection,
"--frame\r\nContent-Type: image/jpeg\r\nContent-Length: {}\r\n\r\n",
jpeg.len()
)?;
connection.write_all(&jpeg)?;
connection.write_all(b"\r\n")?;
}
})();
broadcast.viewers.fetch_sub(1, Ordering::Relaxed);
result
}
_ => write!(
connection,
"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
),
}
}
+2
View File
@@ -28,6 +28,7 @@ pub mod fidelity;
pub mod gpu;
pub mod model;
pub mod moon;
pub mod preview;
pub mod radiometry;
pub mod render;
pub mod scene;
@@ -42,6 +43,7 @@ pub use error::Error;
pub use fidelity::{CameraTier, Fidelity, LightingTier};
pub use model::{LoadOptions, Material, Mesh, ModelId};
pub use moon::MoonTexture;
pub use preview::{Look, Preview};
pub use radiometry::Radiometry;
pub use render::{Frame, FrameView, Pending, Renderer, RendererOptions};
pub use scene::{CameraRelative, Placement, Pose, Scene, Target};
+164
View File
@@ -0,0 +1,164 @@
//! Turning a frame's DN into an 8-bit sRGB image for looking at.
//!
//! [`Look::Raw`] shows the signal as recorded: linear from the bias (black) to
//! saturation (white), with the sRGB curve on top. A scene exposed to keep its
//! brightest parts unsaturated looks dark in it. [`Look::Pretty`] makes the image look
//! like a photograph instead: each channel scaled so a white surface in sunlight comes
//! out neutral, then a tone curve ([`tone`]) that brightens shadows and midtones and
//! rolls highlights off to white at saturation. Neither changes the DN.
use crate::camera::CameraConfig;
use crate::earth::parallel_rows;
use crate::radiometry::{Radiometry, AU};
/// How DN become an image.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Look {
/// The signal as recorded.
#[default]
Raw,
/// White balanced to sunlight, with [`tone`].
Pretty,
}
/// How much [`tone`] brightens the darkest values: 3, about 1.6 stops.
pub 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.
pub 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 srgb(x: f64) -> u8 {
let x = x.clamp(0.0, 1.0);
let v = if x <= 0.003_130_8 {
12.92 * x
} else {
1.055 * x.powf(1.0 / 2.4) - 0.055
};
(v * 255.0).round() as u8
}
/// Converts one camera's DN to 8-bit sRGB, through a table with an entry for every DN
/// in every channel.
#[derive(Clone, Debug)]
pub struct Preview {
channels: usize,
/// The DN of a saturated pixel.
saturated: u16,
look: Look,
/// Per channel, the output for each DN.
table: Vec<Vec<u8>>,
}
impl Preview {
/// The conversion for `camera`'s detector and channels.
pub fn new(camera: &CameraConfig, look: Look) -> Self {
let detector = &camera.detector;
let channels = camera.channels.len();
let black = detector.bias;
let saturated = detector.dn(detector.full_well);
let white = saturated as f64;
// 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 balance: Vec<f64> = match look {
Look::Raw => vec![1.0; channels],
Look::Pretty => {
let radiometry = Radiometry::new(camera);
let white_surface: Vec<f64> = (0..channels)
.map(|k| radiometry.lambert_radiance(k, 1.0, 1.0, AU))
.collect();
let weakest = white_surface.iter().copied().fold(f64::INFINITY, f64::min);
white_surface.iter().map(|w| weakest / w).collect()
}
};
let levels = 1usize << detector.adc_bits;
let table = balance
.iter()
.map(|b| {
(0..levels)
.map(|d| {
let x = (d as f64 - black) / (white - black);
match look {
Look::Raw => srgb(x),
Look::Pretty => srgb(tone(x * b)),
}
})
.collect()
})
.collect();
Self {
channels,
saturated,
look,
table,
}
}
/// The image for `dn` (channels interleaved per pixel, rows of `width` pixels from
/// the top, as in [`crate::Frame::dn`]), in the same layout.
pub fn srgb8(&self, dn: &[u16], width: u32) -> Vec<u8> {
let row = width as usize * self.channels;
let mut out = vec![0u8; dn.len()];
parallel_rows(&mut out, row, |y, out_row| {
let in_row = &dn[y * row..(y + 1) * row];
for (pixel, out_pixel) in in_row
.chunks(self.channels)
.zip(out_row.chunks_mut(self.channels))
{
// In the pretty look, a channel at saturation lost how bright it was,
// and balancing the others would tint it: saturated pixels are white.
if self.look == Look::Pretty && pixel.iter().any(|&d| d >= self.saturated) {
out_pixel.fill(255);
continue;
}
for ((o, &d), table) in out_pixel.iter_mut().zip(pixel).zip(&self.table) {
*o = table[(d as usize).min(table.len() - 1)];
}
}
});
out
}
}
#[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);
}
}
#[test]
fn raw_maps_bias_to_black_and_saturation_to_white_and_pretty_whitens_saturation() {
let camera = CameraConfig::example();
let saturated = camera.detector.dn(camera.detector.full_well);
let bias = camera.detector.bias as u16;
let raw = Preview::new(&camera, Look::Raw);
assert_eq!(
raw.srgb8(&[bias, bias, bias, saturated, saturated, saturated], 2),
[0, 0, 0, 255, 255, 255]
);
// One saturated channel: white in the pretty look, whatever the others hold.
let pretty = Preview::new(&camera, Look::Pretty);
assert_eq!(pretty.srgb8(&[saturated, bias, bias], 1), [255, 255, 255]);
// Below saturation, pretty is brighter than raw.
let mid = bias + 300;
let (r, p) = (raw.srgb8(&[mid; 3], 1), pretty.srgb8(&[mid; 3], 1));
assert!((0..3).all(|k| p[k] > r[k]), "{r:?} {p:?}");
}
}
+15
View File
@@ -265,6 +265,21 @@ impl Renderer {
self.body_radiometry.moon_textured = true;
}
/// Changes the exposure time, in seconds, from the next frame submitted on: what an
/// auto-exposure loop does between frames. Only the conversion from radiance to
/// electrons depends on it, so this costs nothing beyond a few multiplications.
pub fn set_exposure(&mut self, seconds: f64) -> Result<(), Error> {
if !(seconds.is_finite() && seconds > 0.0) {
return Err(Error::InvalidCamera(format!(
"exposure must be positive, not {seconds}"
)));
}
self.radiometry.electrons_per_radiance *= seconds / self.camera.exposure;
self.camera.exposure = seconds;
self.sensor_uniforms = SensorUniforms::new(&self.camera, &self.radiometry);
Ok(())
}
/// The tiers this renderer draws at.
pub fn fidelity(&self) -> Fidelity {
self.fidelity
+44
View File
@@ -322,6 +322,50 @@ fn the_sun_is_uniform_at_l0_and_limb_darkened_at_l1() {
}
}
#[test]
fn set_exposure_scales_the_signal() {
// Doubling the exposure doubles every unsaturated pixel's signal above the bias,
// to within the rounding of one DN; an invalid exposure is refused and changes
// nothing.
let _devices = common::device_lock();
for adapter in [AdapterChoice::Gpu, AdapterChoice::Cpu] {
let options = RendererOptions {
adapter,
..Default::default()
};
let mut renderer = Renderer::new(CameraConfig::example(), options).unwrap();
let model = renderer.load_model(&cube()).unwrap();
let scene = scene(vec![cube_target(model)]);
let once = renderer_frame(&mut renderer, &scene);
let bias = CameraConfig::example().detector.bias as i32;
renderer
.set_exposure(2.0 * CameraConfig::example().exposure)
.unwrap();
assert!(renderer.set_exposure(0.0).is_err());
assert!(renderer.set_exposure(f64::NAN).is_err());
let twice = renderer_frame(&mut renderer, &scene);
let name = renderer.adapter_info().name.clone();
let mut lit = 0;
for (&a, &b) in once.iter().zip(&twice) {
let (a, b) = (a as i32 - bias, b as i32 - bias);
if a > 100 && a < 1500 {
lit += 1;
assert!((b - 2 * a).abs() <= 2, "{name}: {a} then {b}");
}
}
assert!(lit > 1000, "{name}: {lit} lit samples");
}
}
fn renderer_frame(renderer: &mut Renderer, scene: &Scene) -> Vec<u16> {
renderer
.submit(scene, &observer())
.unwrap()
.wait()
.unwrap()
.dn
}
#[test]
fn a_frame_view_reads_what_wait_copies() {
// Both ways of getting the image to the CPU, read where the sensor pass writes it