Added Airy and PSF via FFT. Decided to make it a C2 feature

This commit is contained in:
connor committed 2026-10-17 18:28:52 -04:00
1 parent fbd7ce4147
commit 5e2769faa8
25 files changed
+2319 -117

No files matched your search

+11 -4
View File
@@ -57,10 +57,11 @@ struct RenderArgs {
/// catalog).
#[arg(long, default_value = "L0")]
lighting: LightingTier,
/// Camera fidelity tier: C0, or C1 (adds pixels that collect light over their area,
/// Camera fidelity tier: C0, C1 (adds pixels that collect light over their area,
/// for graded edges and continuous thin parts, lens distortion, lens blur, and the
/// Sun, Moon and Earth where light time and aberration make them appear, detector
/// noise, fixed pattern and blooming, and at lighting L1 stars).
/// noise, fixed pattern and blooming, and at lighting L1 stars), or C2 (adds the
/// lens blur by FFT, with diffraction or a measured PSF, whose wings reach far).
#[arg(long, default_value = "C0")]
camera: CameraTier,
/// From camera C1: render each pixel as N x N finer ones (1 to 4; default 2), which
@@ -74,6 +75,11 @@ struct RenderArgs {
/// give the same image.
#[arg(long, default_value_t = 0)]
frame: u64,
/// From camera C2: how far in pixels the PSF must reach from a point (default 16).
/// The FFT grid grows to hold it, and the PSF then reaches as far as the grid
/// allows: 64 px at 1920 x 1080 and 2 x 2 supersampling, up to 1088 px at 1 x 1.
#[arg(long, value_name = "PIXELS")]
psf_reach: Option<u32>,
/// Device: auto, gpu, cpu, or part of an adapter's name.
#[arg(long, default_value = "auto")]
adapter: String,
@@ -162,7 +168,8 @@ fn render(args: &RenderArgs) -> Result<(), String> {
}
let path = args.scene.as_path();
let file = SceneFile::read(path)?;
let camera = file.camera();
let dir = path.parent().unwrap_or(Path::new("."));
let camera = file.camera(dir)?;
// What the tiers need, gathered before the renderer exists so it can check them.
let start = Instant::now();
@@ -230,12 +237,12 @@ fn render(args: &RenderArgs) -> Result<(), String> {
stars,
noise_seed: args.seed,
fixed_pattern: None,
psf_reach: args.psf_reach,
};
let start = Instant::now();
let mut renderer = Renderer::new(camera, options).map_err(|e| e.to_string())?;
let setup = start.elapsed();
let start = Instant::now();
let dir = path.parent().unwrap_or(Path::new("."));
let scene = file.scene(&mut renderer, dir, args.kernels.as_deref())?;
let observer = file.observer()?;
let loading = start.elapsed();
+100 -12
View File
@@ -12,7 +12,7 @@ use resolved::nalgebra::{Quaternion, UnitQuaternion, Vector2, Vector3};
use resolved::scene::Velocities;
use resolved::{
Blooming, BrownConrady, CameraConfig, LoadOptions, Material, Mesh, ModelId, Pose, Psf,
Renderer, Scene, Spectrum, Target,
PsfImage, Renderer, Scene, Spectrum, Target,
};
use serde::Deserialize;
@@ -88,6 +88,13 @@ pub struct CameraFile {
/// The Gaussian blur's sigma in pixels, from camera C1: one for every channel, one
/// per channel, or 0 for none.
pub psf_sigma: Option<Sigma>,
/// Diffraction by the aperture, from camera C2: the diameter of a centered
/// obstruction as a fraction of the aperture's, 0 for none.
pub psf_airy: Option<f64>,
/// A measured PSF, from camera C2: `{ image = "spot.png", pixel_scale = 0.25 }`, a
/// PNG relative to the scene file, grayscale for every channel or RGB for three,
/// whose samples are `pixel_scale` pixels apart.
pub psf_measured: Option<MeasuredFile>,
/// Lens distortion from camera C1, in OpenCV's order: [k1, k2, p1, p2, k3].
pub distortion: Option<[f64; 5]>,
}
@@ -101,6 +108,14 @@ pub enum BloomingFile {
Neighbors(f64),
}
/// A measured PSF in a scene file.
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MeasuredFile {
pub image: PathBuf,
pub pixel_scale: f64,
}
/// One sigma, or one per channel.
#[derive(Debug, Deserialize)]
#[serde(untagged)]
@@ -165,7 +180,7 @@ impl SceneFile {
}
/// The example camera with this file's overrides.
pub fn camera(&self) -> CameraConfig {
pub fn camera(&self, dir: &Path) -> Result<CameraConfig> {
let c = &self.camera;
let mut camera = CameraConfig::example();
let set = |field: &mut f64, value: Option<f64>| {
@@ -219,17 +234,30 @@ impl SceneFile {
if let Some([k1, k2, p1, p2, k3]) = c.distortion {
camera.distortion = Some(BrownConrady { k1, k2, k3, p1, p2 });
}
camera.psf = match &c.psf_sigma {
None => camera.psf,
Some(Sigma::All(s)) if *s == 0.0 => Psf::None,
Some(Sigma::All(s)) => Psf::Gaussian {
let given = [
c.psf_sigma.is_some(),
c.psf_airy.is_some(),
c.psf_measured.is_some(),
];
if given.iter().filter(|&&g| g).count() > 1 {
return Err("give at most one of psf_sigma, psf_airy and psf_measured".into());
}
camera.psf = match (&c.psf_sigma, c.psf_airy, &c.psf_measured) {
(Some(Sigma::All(s)), _, _) if *s == 0.0 => Psf::None,
(Some(Sigma::All(s)), _, _) => Psf::Gaussian {
sigma: vec![*s; camera.channels.len()],
},
Some(Sigma::PerChannel(sigma)) => Psf::Gaussian {
(Some(Sigma::PerChannel(sigma)), _, _) => Psf::Gaussian {
sigma: sigma.clone(),
},
(_, Some(obstruction), _) => Psf::Airy { obstruction },
(_, _, Some(m)) => Psf::Measured {
image: psf_image(&dir.join(&m.image))?,
pixel_scale: m.pixel_scale,
},
(None, None, None) => camera.psf,
};
camera
Ok(camera)
}
/// Earth's and the Moon's appearance.
@@ -407,6 +435,27 @@ impl SceneFile {
}
/// Where to look for astrokit's kernels: `explicit` alone if given; otherwise
/// A measured PSF's image from a PNG: one image from a grayscale one, three from an RGB
/// one, each sample's value as a fraction of the largest the format holds.
fn psf_image(path: &Path) -> Result<PsfImage> {
let image = image::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
let (width, height) = (image.width(), image.height());
let channels = if image.color().has_color() {
let rgb = image.into_rgb16();
(0..3)
.map(|k| rgb.pixels().map(|p| p[k] as f64 / 65535.0).collect())
.collect()
} else {
let gray = image.into_luma16();
vec![gray.pixels().map(|p| p[0] as f64 / 65535.0).collect()]
};
Ok(PsfImage {
width,
height,
channels,
})
}
/// `ASTROKIT_KERNELS`, then `data/kernels` under the working directory, then the
/// sibling astrokit checkout this binary was built against.
#[cfg(feature = "astrokit")]
@@ -478,7 +527,10 @@ mod tests {
#[test]
fn minimal_file_uses_the_example_camera() {
let file: SceneFile = toml::from_str(MINIMAL).unwrap();
assert_eq!(file.camera(), CameraConfig::example());
assert_eq!(
file.camera(Path::new(".")).unwrap(),
CameraConfig::example()
);
assert!(file.targets.is_empty());
let observer = file.observer().unwrap();
// Looking from +X toward the origin.
@@ -493,7 +545,7 @@ mod tests {
pattern_seed = 9\nblooming = {{ column = 0.25 }}\n"
))
.unwrap();
let camera = file.camera();
let camera = file.camera(Path::new(".")).unwrap();
assert_eq!((camera.width, camera.height), (640, 1080));
assert_eq!(camera.exposure, 2e-3);
assert_eq!(camera.detector.adc_bits, 10);
@@ -520,18 +572,24 @@ mod tests {
},
),
("psf_sigma = 0", Psf::None),
("psf_airy = 0.3", Psf::Airy { obstruction: 0.3 }),
] {
let file: SceneFile =
toml::from_str(&format!("{MINIMAL}\n[camera]\n{line}\n")).unwrap();
assert_eq!(file.camera().psf, psf, "{line}");
assert_eq!(file.camera(Path::new(".")).unwrap().psf, psf, "{line}");
}
let file: SceneFile = toml::from_str(&format!(
"{MINIMAL}\n[camera]\npsf_sigma = 1\npsf_airy = 0\n"
))
.unwrap();
assert!(file.camera(Path::new(".")).is_err());
let file: SceneFile = toml::from_str(&format!(
"{MINIMAL}\n[camera]\ndistortion = [-0.1, 0.02, 1e-3, -2e-3, 0.005]\n"
))
.unwrap();
assert_eq!(
file.camera().distortion,
file.camera(Path::new(".")).unwrap().distortion,
Some(BrownConrady {
k1: -0.1,
k2: 0.02,
@@ -542,6 +600,36 @@ mod tests {
);
}
#[test]
fn a_measured_psf_loads_from_a_png() {
// 16-bit RGB, 3 x 2: one image per channel, each sample's value over 65,535.
let dir = std::env::temp_dir().join(format!("resolved-psf-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let values: Vec<u16> = (0..18).map(|i| i * 1000).collect();
image::ImageBuffer::<image::Rgb<u16>, _>::from_raw(3, 2, values)
.unwrap()
.save(dir.join("spot.png"))
.unwrap();
let file: SceneFile = toml::from_str(&format!(
"{MINIMAL}\n[camera]\npsf_measured = {{ image = \"spot.png\", pixel_scale = 0.5 }}\n"
))
.unwrap();
let camera = file.camera(&dir).unwrap();
std::fs::remove_dir_all(&dir).unwrap();
let Psf::Measured { image, pixel_scale } = camera.psf else {
panic!("{:?}", camera.psf);
};
assert_eq!((image.width, image.height, pixel_scale), (3, 2, 0.5));
for (k, channel) in image.channels.iter().enumerate() {
let want: Vec<f64> = (0..6)
.map(|i| ((3 * i + k) * 1000) as f64 / 65535.0)
.collect();
for (a, b) in channel.iter().zip(&want) {
assert!((a - b).abs() < 1e-6, "channel {k}: {channel:?}");
}
}
}
#[test]
fn earth_month_comes_from_the_setting() {
let with = |extra: &str| -> SceneFile {