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

Generated
+12
View File
@@ -1579,6 +1579,18 @@ dependencies = [
"toml",
]
[[package]]
name = "resolved-showcase"
version = "0.1.0"
dependencies = [
"anise",
"astrokit",
"clap",
"hifitime",
"image",
"resolved",
]
[[package]]
name = "roots"
version = "0.0.8"
+1 -1
View File
@@ -1,5 +1,5 @@
[workspace]
members = ["crates/resolved", "crates/resolved-cli"]
members = ["crates/resolved", "crates/resolved-cli", "crates/resolved-showcase"]
# "3" picks dependency versions that support rust-version below.
resolver = "3"
+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
+9 -4
View File
@@ -4,19 +4,24 @@
set -e
# resolved-showcase (the showcase movie) is left out of every step here; pre-commit's
# clippy still builds it. It also turns on the library's astrokit feature, and Cargo
# unifies features across a workspace build, so with it in, the steps without default
# features would build the library with astrokit after all.
echo "pre-push: cargo test"
cargo test --workspace --all-features --quiet
cargo test --workspace --exclude resolved-showcase --all-features --quiet
# Without default features: every target must still build cleanly, and the unit
# tests run. The GPU integration tests do not depend on features, so the run above
# covers them.
echo "pre-push: cargo clippy --no-default-features"
cargo clippy --workspace --all-targets --no-default-features --quiet -- -D warnings
cargo clippy --workspace --exclude resolved-showcase --all-targets --no-default-features --quiet -- -D warnings
echo "pre-push: cargo test --no-default-features (unit tests)"
cargo test --workspace --no-default-features --lib --bins --quiet
cargo test --workspace --exclude resolved-showcase --no-default-features --lib --bins --quiet
echo "pre-push: cargo doc"
RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --all-features --quiet
RUSTDOCFLAGS="-D warnings" cargo doc --workspace --exclude resolved-showcase --no-deps --all-features --quiet
echo "pre-push: ok"
+17
View File
@@ -131,6 +131,23 @@ cargo run --release -p resolved-cli -- render \
`scripts/serve-gallery.py` serves the result as a web page on port 8000 of
every interface, each scene captioned from its file.
`crates/resolved-showcase` makes a 30-second movie at lighting L1: an inspector
holding station behind a Landsat 8 model 705 km up, which astrokit propagates
with J2. Time runs fast over the daylit Earth, slows as the Moon rises over the
limb ahead, and slows almost to real time as the spacecraft passes into Earth's
shadow. The camera drifts on a small natural relative orbit around its station,
and adjusts its exposure frame by frame as a video camera does (up to one
frame's time), through dawn and dusk. `--out movie.mp4` encodes it with ffmpeg,
rendering each 1920 x 1080 frame in about 4 ms on the RTX 3060. `--serve
0.0.0.0:8010` renders it live, in step with the clock, and streams it as MJPEG
at 60 frames per second (`--fps`; it keeps up to 120, rendering each frame in
under 5 ms) to the gallery page's Movie tab, which also plays and offers the
recorded file (`make-gallery.sh` writes `gallery/movie.mp4`):
```sh
cargo run --release -p resolved-showcase -- --serve 0.0.0.0:8010
```
- `--out` writes raw DN as a 16-bit PNG, shifted up to fill 16 bits as
cameras usually store them: a 12-bit DN is the stored value divided by 16.
It is linear, so it looks dark next to the preview.
+9 -2
View File
@@ -2,7 +2,8 @@
# Renders every example scene at lighting L0 and L1 into gallery/ (gitignored), as
# <scene>-L0.png and <scene>-L1.png previews made with --pretty: white balanced, with a
# tone curve, from exposures that keep the raw signal short of saturation. A scene whose
# model, maps or kernels are missing is skipped with the reason. View the result with
# model, maps or kernels are missing is skipped with the reason. Then the showcase
# movie, gallery/movie.mp4 (resolved-movie, which needs ffmpeg). View the result with
# scripts/serve-gallery.py.
#
# Needs: scripts/fetch-models.sh for the NASA models, scripts/fetch-earth.sh and
@@ -11,7 +12,7 @@
set -e
cd "$(dirname "$0")/.."
cargo build --quiet --release -p resolved-cli
cargo build --quiet --release -p resolved-cli -p resolved-showcase
mkdir -p gallery
made=0
@@ -32,3 +33,9 @@ for scene in examples/scenes/*.toml; do
done
done
echo "$made images in gallery/, $skipped skipped"
if error=$(./target/release/resolved-movie --out gallery/movie.mp4 2>&1); then
echo "gallery/movie.mp4"
else
echo "skipped the movie: ${error#error: }"
fi
+105 -9
View File
@@ -2,13 +2,18 @@
"""Serves gallery/ (from scripts/make-gallery.sh) as a web page on every interface, for
looking at the example renders from another machine on the network.
python3 scripts/serve-gallery.py [--port 8000]
python3 scripts/serve-gallery.py [--port 8000] [--live-port 8010]
Each example scene gets a card with its L0 and L1 renders side by side, captioned
from the comment at the top of its file in examples/scenes/. Click an image to see it
full size; arrow keys step through them and Escape closes. The page is built on each
request, so rerunning make-gallery.sh and reloading shows the new images. Only the
PNG files in gallery/ are served.
Stills: each example scene gets a card with its L0 and L1 renders side by side,
captioned from the comment at the top of its file in examples/scenes/. Click an image
to see it full size; arrow keys step through them and Escape closes.
Movie: gallery/movie.mp4 (from make-gallery.sh or resolved-movie --out) to play or
download, and the live stream from `resolved-movie --serve 0.0.0.0:8010` on this
machine, which the page connects to only while the tab is open.
The page is built on each request, so rerunning make-gallery.sh and reloading shows
the new images. Only the PNG files and movie.mp4 in gallery/ are served.
Needs only the Python standard library.
"""
@@ -75,9 +80,37 @@ figcaption b { color: var(--text); font-weight: 600; }
#view.open { display: flex; }
#view img { max-width: 100%; max-height: calc(100vh - 4rem); }
#view div { color: var(--muted); font-size: 0.9rem; }
[hidden] { display: none !important; }
nav { max-width: 1400px; margin: 0 auto; padding: 0 1.5rem; display: flex; gap: 0.5rem; }
nav a {
color: var(--muted); text-decoration: none; padding: 0.45rem 1rem; border-radius: 999px;
border: 1px solid var(--line);
}
nav a.on { color: var(--bg); background: var(--accent); border-color: var(--accent); }
.movie video, .movie .live {
width: 100%; aspect-ratio: 16 / 9; display: block; background: #000;
border-radius: 8px; border: 1px solid var(--line);
}
.movie .live { object-fit: contain; }
.movie a.download { color: var(--accent); }
.movie code { color: var(--text); }
"""
SCRIPT = """
const live = document.getElementById('live');
function tab() {
const movie = location.hash === '#movie';
// The panels' ids differ from the links' anchors, so a link switches the tab
// instead of scrolling the page to it.
document.getElementById('stills-tab').hidden = movie;
document.getElementById('movie-tab').hidden = !movie;
document.querySelectorAll('nav a').forEach(
(a) => a.classList.toggle('on', (a.hash === '#movie') === movie));
// The live stream renders only while someone watches: connect only on this tab.
if (live) live.src = movie ? live.dataset.src : '';
}
window.addEventListener('hashchange', tab);
tab();
const images = [...document.querySelectorAll('figure img')];
const view = document.getElementById('view');
const big = view.querySelector('img');
@@ -120,7 +153,40 @@ def caption(scene):
return paragraphs
def page():
def movie_section(host, live_port):
movie = GALLERY / "movie.mp4"
if movie.is_file():
stamp = int(movie.stat().st_mtime)
size = movie.stat().st_size / 1e6
recorded = (
f'<video src="/movie.mp4?{stamp}" controls loop playsinline></video>'
f'<p class="caption"><a class="download" href="/movie.mp4?{stamp}" '
f'download="resolved-movie.mp4">Download the MP4</a> ({size:.1f} MB)</p>'
)
else:
recorded = (
'<p class="caption">No movie yet: run scripts/make-gallery.sh, or '
"<code>cargo run --release -p resolved-showcase -- --out gallery/movie.mp4</code>"
".</p>"
)
stream = f"http://{host}:{live_port}/stream"
return f"""<section class="movie"><h2>The movie</h2>
<p class="caption">An inspector holds station 120 m behind and above a Landsat 8 model
705 km up; the target is propagated by astrokit with J2 and everything is rendered at
lighting L1. The camera drifts on a small natural relative orbit around its station and
sets its exposure frame by frame. Time runs fast over the daylit Earth, slows as the
Moon rises over the limb ahead, and slows almost to real time as the spacecraft passes
into Earth's shadow.</p>{recorded}</section>
<section class="movie"><h2>Live</h2>
<p class="caption">Rendered in real time on this machine's GPU and streamed as it is
made. Start it with
<code>cargo run --release -p resolved-showcase -- --serve 0.0.0.0:{live_port}</code>;
it renders only while this tab is open.</p>
<img id="live" class="live" data-src="{html.escape(stream)}" alt="live stream (not running)">
</section>"""
def page(host, live_port):
names = sorted({p.stem.rsplit("-", 1)[0] for p in GALLERY.glob("*.png")})
cards = []
count = 0
@@ -168,7 +234,9 @@ def page():
<body><header><h1><span>resolved</span> gallery</h1>
<p>{count} renders of {len(names)} example scenes, last rendered {when}.
Click an image to see it full size.</p></header>
<main>{"".join(cards)}</main>
<nav><a href="#stills">Stills</a><a href="#movie">Movie</a></nav>
<main id="stills-tab">{"".join(cards)}</main>
<main id="movie-tab" hidden>{movie_section(host, live_port)}</main>
<div id="view"><img alt=""><div></div></div>
<script>{SCRIPT}</script></body></html>"""
@@ -177,7 +245,11 @@ class Handler(BaseHTTPRequestHandler):
def do_GET(self):
path = unquote(self.path.split("?", 1)[0])
if path in ("/", "/index.html"):
self.send(200, "text/html; charset=utf-8", page().encode())
host = (self.headers.get("Host") or "localhost").rsplit(":", 1)[0]
body = page(host, self.server.live_port).encode()
self.send(200, "text/html; charset=utf-8", body)
elif path == "/movie.mp4" and (GALLERY / "movie.mp4").is_file():
self.send_movie(GALLERY / "movie.mp4")
elif path.startswith("/img/"):
name = path[len("/img/"):]
# Only a PNG directly in gallery/, by its exact name.
@@ -189,6 +261,27 @@ class Handler(BaseHTTPRequestHandler):
else:
self.send(404, "text/plain", b"not found")
def send_movie(self, movie):
"""The movie, or the byte range asked for: browsers seek in a video by range."""
data = movie.read_bytes()
wanted = self.headers.get("Range", "")
if wanted.startswith("bytes="):
first, _, last = wanted[len("bytes="):].partition("-")
start = int(first) if first else max(len(data) - int(last), 0)
end = int(last) if first and last else len(data) - 1
end = min(end, len(data) - 1)
part = data[start:end + 1]
self.send_response(206)
self.send_header("Content-Range", f"bytes {start}-{end}/{len(data)}")
else:
part = data
self.send_response(200)
self.send_header("Content-Type", "video/mp4")
self.send_header("Accept-Ranges", "bytes")
self.send_header("Content-Length", str(len(part)))
self.end_headers()
self.wfile.write(part)
def send(self, status, kind, body):
self.send_response(status)
self.send_header("Content-Type", kind)
@@ -215,8 +308,11 @@ def lan_address():
def main():
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("--port", type=int, default=8000)
parser.add_argument("--live-port", type=int, default=8010,
help="the port resolved-movie --serve streams on")
args = parser.parse_args()
server = ThreadingHTTPServer(("0.0.0.0", args.port), Handler)
server.live_port = args.live_port
print(f"serving {GALLERY} at http://{lan_address()}:{args.port}/ (Ctrl+C to stop)", flush=True)
try:
server.serve_forever()