PRNU, DSNU, and hot pixels

This commit is contained in:
connor committed 2026-10-16 15:59:20 -04:00
1 parent 1ed95982ad
commit 3d0b129582
17 files changed
+763 -37

No files matched your search

+2 -1
View File
@@ -60,7 +60,7 @@ struct RenderArgs {
/// Camera fidelity tier: C0, or 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, and at lighting L1 stars).
/// noise and fixed pattern, and at lighting L1 stars).
#[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
@@ -229,6 +229,7 @@ fn render(args: &RenderArgs) -> Result<(), String> {
supersampling: args.supersampling,
stars,
noise_seed: args.seed,
fixed_pattern: None,
};
let start = Instant::now();
let mut renderer = Renderer::new(camera, options).map_err(|e| e.to_string())?;
+21 -1
View File
@@ -74,6 +74,14 @@ pub struct CameraFile {
pub dark_current: Option<f64>,
/// From camera C1: whether the electrons collected carry shot noise.
pub shot_noise: Option<bool>,
/// From camera C1: photo-response nonuniformity, a fraction RMS.
pub prnu: Option<f64>,
/// From camera C1: dark signal nonuniformity, a fraction RMS.
pub dsnu: Option<f64>,
/// From camera C1: the fraction of hot pixels and their dark current's multiple.
pub hot_pixels: Option<[f64; 2]>,
/// From camera C1: the seed of the fixed pattern.
pub pattern_seed: Option<u64>,
/// 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>,
@@ -174,6 +182,15 @@ impl SceneFile {
if let Some(shot) = c.shot_noise {
camera.detector.shot_noise = shot;
}
set(&mut camera.detector.prnu, c.prnu);
set(&mut camera.detector.dsnu, c.dsnu);
if let Some([fraction, multiple]) = c.hot_pixels {
camera.detector.hot_pixel_fraction = fraction;
camera.detector.hot_pixel_multiple = multiple;
}
if let Some(seed) = c.pattern_seed {
camera.detector.pattern_seed = seed;
}
if let Some([u, v]) = c.principal_point {
camera.principal_point = Some(Vector2::new(u, v));
}
@@ -453,7 +470,8 @@ mod tests {
fn camera_overrides_apply() {
let file: SceneFile = toml::from_str(&format!(
"{MINIMAL}\n[camera]\nwidth = 640\nexposure = 2e-3\nadc_bits = 10\nread_noise = 2.5\n\
dark_current = 0\nshot_noise = false\n"
dark_current = 0\nshot_noise = false\nprnu = 0.02\nhot_pixels = [1e-3, 20]\n\
pattern_seed = 9\n"
))
.unwrap();
let camera = file.camera();
@@ -465,6 +483,8 @@ mod tests {
(d.read_noise, d.dark_current, d.shot_noise),
(2.5, 0.0, false)
);
assert_eq!((d.prnu, d.dsnu, d.pattern_seed), (0.02, 0.1, 9));
assert_eq!((d.hot_pixel_fraction, d.hot_pixel_multiple), (1e-3, 20.0));
for (line, psf) in [
(
+38 -4
View File
@@ -22,9 +22,9 @@ pub struct Channel {
/// How the detector turns electrons into numbers.
///
/// The noise fields apply from camera tier C1 up. A detector with no read noise, no
/// dark current and no shot noise ([`Detector::noiseless`]) gives noise-free images at
/// C1, for ground truth and labels.
/// The noise and fixed-pattern fields apply from camera tier C1 up. A detector with
/// neither ([`Detector::noiseless`]) gives noise-free images at C1, for ground truth
/// and labels.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Detector {
/// Electrons per DN.
@@ -42,6 +42,18 @@ pub struct Detector {
pub dark_current: f64,
/// Whether the electrons collected vary as a Poisson count about their mean.
pub shot_noise: bool,
/// Photo-response nonuniformity: the spread of each pixel's response to light
/// about the average, as a fraction RMS ([`FixedPattern`](crate::FixedPattern)).
pub prnu: f64,
/// Dark signal nonuniformity: the spread of each pixel's dark current about the
/// average, as a fraction RMS, apart from hot pixels.
pub dsnu: f64,
/// The fraction of values (pixels and channels) that are hot.
pub hot_pixel_fraction: f64,
/// A hot pixel's dark current, as a multiple of the average.
pub hot_pixel_multiple: f64,
/// The seed of the generated pattern: one camera, one pattern, whatever the noise.
pub pattern_seed: u64,
}
impl Detector {
@@ -61,12 +73,16 @@ impl Detector {
.clamp(0.0, self.dn_max() as f64) as u16
}
/// The same detector with no read noise, dark current or shot noise.
/// The same detector with no read noise, dark current, shot noise or fixed
/// pattern.
pub fn noiseless(self) -> Self {
Self {
read_noise: 0.0,
dark_current: 0.0,
shot_noise: false,
prnu: 0.0,
dsnu: 0.0,
hot_pixel_fraction: 0.0,
..self
}
}
@@ -76,6 +92,11 @@ impl Detector {
self.shot_noise || self.read_noise > 0.0 || self.dark_current > 0.0
}
/// Whether the detector generates a fixed pattern.
pub fn has_fixed_pattern(&self) -> bool {
self.prnu > 0.0 || self.dsnu > 0.0 || self.hot_pixel_fraction > 0.0
}
/// The variance, in electrons squared, of one pixel's reading for a mean of
/// `electrons` from light over an exposure of `exposure` seconds, below the full
/// well: the shot noise of the light and of the dark current, the read noise, and
@@ -264,6 +285,8 @@ impl CameraConfig {
/// - 20,000 electron full well, 12-bit ADC, 5 electrons per DN, 64 DN bias.
/// - 5 electrons RMS read noise, 20 electrons per second dark current, and shot
/// noise: a CMOS sensor at room temperature.
/// - 1 % PRNU, 10 % DSNU, and 1 value in 10,000 hot at 50 times the dark current,
/// from pattern seed 0.
/// - 0.1 ms exposure: a white Lambertian surface facing the Sun fills about half the
/// well (48-57 % across the channels), so Earth and the target are both unsaturated.
/// - No lens distortion.
@@ -299,6 +322,11 @@ impl CameraConfig {
read_noise: 5.0,
dark_current: 20.0,
shot_noise: true,
prnu: 0.01,
dsnu: 0.1,
hot_pixel_fraction: 1e-4,
hot_pixel_multiple: 50.0,
pattern_seed: 0,
},
near_clip: 0.01,
mount: Pose::new(Vector3::zeros(), UnitQuaternion::identity()),
@@ -354,6 +382,12 @@ impl CameraConfig {
if !nonnegative(d.read_noise) || !nonnegative(d.dark_current) {
return fail("read noise and dark current must be nonnegative");
}
if !nonnegative(d.prnu) || !nonnegative(d.dsnu) || !nonnegative(d.hot_pixel_multiple) {
return fail("PRNU, DSNU and the hot pixels' multiple must be nonnegative");
}
if !(0.0..=1.0).contains(&d.hot_pixel_fraction) {
return fail("the hot pixels' fraction must be between 0 and 1");
}
Ok(())
}
+2 -2
View File
@@ -50,8 +50,8 @@ pub enum CameraTier {
/// ([`Scene::velocities`](crate::Scene::velocities)). Target meshes are covered at several
/// points per rendered pixel, and rendered pixels on the edge of Earth, the Moon or
/// the Sun cast a grid of rays, 4 x 4 and more for bodies only a few pixels across.
/// The detector adds its shot, dark and read noise
/// ([`Detector`](crate::camera::Detector)), seeded by
/// The detector adds its fixed pattern ([`FixedPattern`](crate::FixedPattern)) and
/// its shot, dark and read noise ([`Detector`](crate::camera::Detector)), seeded by
/// [`RendererOptions::noise_seed`](crate::RendererOptions::noise_seed) and the
/// frame number. More of the camera joins this tier as it is modeled.
C1,
+197
View File
@@ -0,0 +1,197 @@
//! Fixed-pattern nonuniformity: how each pixel's response to light (PRNU) and dark
//! current (DSNU) differ from the detector's average, the same in every frame.
//!
//! From camera tier C1 the sensor pass applies a pattern to every value. One generated
//! from the detector's [`Detector::prnu`], [`Detector::dsnu`], hot pixels and
//! [`Detector::pattern_seed`] is computed in the shader from a hash, so it costs no
//! memory; [`FixedPattern::generate`] computes the same one on the CPU. One measured
//! from calibration frames ([`FixedPattern::from_calibration`]) is uploaded instead
//! when given ([`RendererOptions::fixed_pattern`](crate::RendererOptions::fixed_pattern)).
use crate::camera::Detector;
use crate::Error;
/// Per pixel and channel, the factor on the light's electrons and the factor on the
/// dark current's.
#[derive(Clone, Debug, PartialEq)]
pub struct FixedPattern {
/// Width in pixels.
pub width: u32,
/// Height in pixels.
pub height: u32,
/// Channels per pixel.
pub channels: u32,
/// `[response, dark]` per value, channels interleaved per pixel, rows from the top,
/// as in [`Frame::dn`](crate::Frame::dn). Both average about 1.
pub values: Vec<[f32; 2]>,
}
/// The hash the sensor shader draws its noise and pattern from (Jarzynski and Olano
/// 2020, pcg4d): four words into four.
pub(crate) fn pcg4d(input: [u32; 4]) -> [u32; 4] {
let mut v = input.map(|x| x.wrapping_mul(1_664_525).wrapping_add(1_013_904_223));
let mix = |v: &mut [u32; 4]| {
v[0] = v[0].wrapping_add(v[1].wrapping_mul(v[3]));
v[1] = v[1].wrapping_add(v[2].wrapping_mul(v[0]));
v[2] = v[2].wrapping_add(v[0].wrapping_mul(v[1]));
v[3] = v[3].wrapping_add(v[1].wrapping_mul(v[2]));
};
mix(&mut v);
v = v.map(|x| x ^ (x >> 16));
mix(&mut v);
v
}
/// Uniform in (0, 1) from the top 24 bits, as `unit` in `sensor.wgsl`.
pub(crate) fn unit(x: u32) -> f64 {
((x >> 8) as f64 + 0.5) / 16_777_216.0
}
/// The fourth word of the pattern's hash input, which the noise's never takes with the
/// same other words in practice: it keeps the two streams apart.
pub(crate) const PATTERN_STREAM: u32 = 0x8000_0000;
impl FixedPattern {
/// The pattern `detector` generates for value `index` (pixel times channels plus
/// channel): `[response, dark]`. The sensor shader computes the same in f32.
pub fn generated_value(detector: &Detector, index: u32) -> [f64; 2] {
let seed = detector.pattern_seed;
let r = pcg4d([seed as u32, index, PATTERN_STREAM, (seed >> 32) as u32]);
let radius = (-2.0 * unit(r[0]).ln()).sqrt();
let angle = std::f64::consts::TAU * unit(r[1]);
let response = (1.0 + detector.prnu * radius * angle.cos()).max(0.0);
let dark = if unit(r[2]) < detector.hot_pixel_fraction {
detector.hot_pixel_multiple
} else {
(1.0 + detector.dsnu * radius * angle.sin()).max(0.0)
};
[response, dark]
}
/// The pattern `detector` generates for an image `width` by `height` with
/// `channels` channels.
pub fn generate(detector: &Detector, width: u32, height: u32, channels: u32) -> Self {
let values = (0..width * height * channels)
.map(|i| Self::generated_value(detector, i).map(|x| x as f32))
.collect();
Self {
width,
height,
channels,
values,
}
}
/// A pattern from calibration frames: `flat`, a uniformly lit frame, and `dark`, a
/// frame in the dark, both with the bias removed and averaged over many exposures
/// so their own noise is small, laid out as [`FixedPattern::values`]. Each channel
/// of each is divided by its mean.
pub fn from_calibration(
width: u32,
height: u32,
channels: u32,
flat: &[f32],
dark: &[f32],
) -> Result<Self, Error> {
let n = (width * height * channels) as usize;
let fail = |what: &str| Err(Error::InvalidCamera(what.to_owned()));
if flat.len() != n || dark.len() != n {
return fail("calibration frames must have one value per pixel and channel");
}
if !flat.iter().chain(dark).all(|x| x.is_finite() && *x >= 0.0) {
return fail("calibration frames must be finite and nonnegative");
}
let c = channels as usize;
let mean = |frame: &[f32], k: usize| {
frame
.iter()
.skip(k)
.step_by(c)
.map(|&x| x as f64)
.sum::<f64>()
/ (n / c) as f64
};
let flat_means: Vec<f64> = (0..c).map(|k| mean(flat, k)).collect();
let dark_means: Vec<f64> = (0..c).map(|k| mean(dark, k)).collect();
if flat_means.iter().chain(&dark_means).any(|&m| m <= 0.0) {
return fail("each channel of the calibration frames needs a positive mean");
}
let values = (0..n)
.map(|i| {
[
(flat[i] as f64 / flat_means[i % c]) as f32,
(dark[i] as f64 / dark_means[i % c]) as f32,
]
})
.collect();
Ok(Self {
width,
height,
channels,
values,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::CameraConfig;
#[test]
fn a_generated_pattern_has_the_detectors_statistics() {
// 1 % PRNU and 10 % DSNU over 600,000 values, with 1e-3 hot pixels at 50 times:
// the response's mean is 1 and its spread 1 %, the dark factor's spread among
// ordinary pixels 10 %, each to 1 %, and the hot pixels number 600 to 5 standard
// deviations.
let detector = Detector {
hot_pixel_fraction: 1e-3,
..CameraConfig::example().detector
};
let p = FixedPattern::generate(&detector, 400, 500, 3);
let n = p.values.len() as f64;
let response: Vec<f64> = p.values.iter().map(|v| v[0] as f64).collect();
let mean = response.iter().sum::<f64>() / n;
let sd = (response.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / n).sqrt();
assert!((mean - 1.0).abs() < 1e-4, "{mean}");
assert!((sd / 0.01 - 1.0).abs() < 0.01, "{sd}");
let hot = p.values.iter().filter(|v| v[1] == 50.0).count() as f64;
assert!((hot - n * 1e-3).abs() < 5.0 * (n * 1e-3).sqrt(), "{hot}");
let dark: Vec<f64> = p
.values
.iter()
.filter(|v| v[1] != 50.0)
.map(|v| v[1] as f64)
.collect();
let m = dark.iter().sum::<f64>() / dark.len() as f64;
let sd = (dark.iter().map(|d| (d - m).powi(2)).sum::<f64>() / dark.len() as f64).sqrt();
assert!((sd / 0.1 - 1.0).abs() < 0.01, "{sd}");
let other = FixedPattern::generate(
&Detector {
pattern_seed: 1,
..detector
},
400,
500,
3,
);
assert_ne!(p, other);
assert_eq!(p, FixedPattern::generate(&detector, 400, 500, 3));
}
#[test]
fn calibration_frames_are_normalized_per_channel() {
let flat = [2.0, 10.0, 4.0, 30.0];
let dark = [1.0, 0.5, 3.0, 1.5];
// Channel 0's flat is 2 and 4 (mean 3), channel 1's 10 and 30 (mean 20); the
// darks' means are 2 and 1.
let p = FixedPattern::from_calibration(2, 1, 2, &flat, &dark).unwrap();
let third = |x: f64| (x / 3.0) as f32;
assert_eq!(
p.values,
vec![[third(2.0), 0.5], [0.5, 0.5], [third(4.0), 1.5], [1.5, 1.5]]
);
assert!(FixedPattern::from_calibration(2, 1, 2, &flat[..3], &dark).is_err());
assert!(FixedPattern::from_calibration(2, 1, 2, &[0.0; 4], &dark).is_err());
}
}
+54 -4
View File
@@ -11,8 +11,20 @@ use super::targets::FrameTargets;
use super::warp::DistortionUniform;
use super::GpuContext;
use crate::camera::CameraConfig;
use crate::fixed_pattern::{FixedPattern, PATTERN_STREAM};
use crate::radiometry::Radiometry;
/// Where the sensor pass takes the detector's fixed pattern from.
#[derive(Clone, Copy, Debug)]
pub enum SensorPattern<'a> {
/// No fixed pattern.
None,
/// Generated in the shader from the detector's fields.
Generated,
/// From calibration maps.
Loaded(&'a FixedPattern),
}
/// The shader's uniform block; the layout matches `Sensor` in `sensor.wgsl`.
#[repr(C)]
#[derive(Clone, Copy, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
@@ -22,6 +34,8 @@ pub struct SensorUniforms {
dims: [u32; 4],
noise: [f32; 4],
key: [u32; 4],
pattern: [f32; 4],
pattern_key: [u32; 4],
distortion: DistortionUniform,
}
@@ -55,6 +69,13 @@ impl SensorUniforms {
]
.map(|x| x as f32),
key: [0; 4],
pattern: [d.prnu, d.dsnu, d.hot_pixel_fraction, d.hot_pixel_multiple].map(|x| x as f32),
pattern_key: [
d.pattern_seed as u32,
(d.pattern_seed >> 32) as u32,
PATTERN_STREAM,
0,
],
distortion: DistortionUniform::new(k.distortion.as_ref().filter(|_| distorted)),
}
}
@@ -76,19 +97,30 @@ pub struct SensorPass {
/// Bound for the stars' electrons without stars: the shader names the binding even
/// where it never reads it.
no_stars: wgpu::Buffer,
/// The loaded pattern, or a placeholder bound the same way.
pattern: wgpu::Buffer,
}
impl SensorPass {
/// Compiles the shader, adding the stars' electrons if `stars` and the detector's
/// noise if `noise`, and allocates the uniform buffer.
pub fn new(ctx: &GpuContext, stars: bool, noise: bool) -> Self {
/// Compiles the shader, adding the stars' electrons if `stars`, the detector's
/// noise if `noise`, and its fixed pattern from `pattern`, and allocates the uniform
/// buffer and uploads a loaded pattern.
pub fn new(ctx: &GpuContext, stars: bool, noise: bool, pattern: SensorPattern) -> Self {
let kind = match pattern {
SensorPattern::None => 0,
SensorPattern::Generated => 1,
SensorPattern::Loaded(_) => 2,
};
let module = ctx
.device
.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("sensor"),
source: wgpu::ShaderSource::Wgsl(
[
&format!("const STARS: bool = {stars};\nconst NOISE: bool = {noise};\n"),
&format!(
"const STARS: bool = {stars};\nconst NOISE: bool = {noise};\n\
const PATTERN: u32 = {kind}u;\n"
),
include_str!("../shaders/distortion.wgsl"),
include_str!("../shaders/sensor.wgsl"),
]
@@ -122,6 +154,20 @@ impl SensorPass {
usage: wgpu::BufferUsages::STORAGE,
mapped_at_creation: false,
}),
pattern: {
use wgpu::util::DeviceExt;
let placeholder = [[0.0f32; 2]];
let values: &[[f32; 2]] = match pattern {
SensorPattern::Loaded(p) => &p.values,
_ => &placeholder,
};
ctx.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("fixed pattern"),
contents: bytemuck::cast_slice(values),
usage: wgpu::BufferUsages::STORAGE,
})
},
}
}
@@ -160,6 +206,10 @@ impl SensorPass {
binding: 3,
resource: stars.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 4,
resource: self.pattern.as_entire_binding(),
},
];
let bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("sensor"),
+2
View File
@@ -25,6 +25,7 @@ pub mod eclipse;
pub mod ephemeris;
pub mod error;
pub mod fidelity;
pub mod fixed_pattern;
pub mod gpu;
pub mod model;
pub mod moon;
@@ -42,6 +43,7 @@ pub use camera::{BrownConrady, CameraConfig, Channel, Detector, Intrinsics, Psf}
pub use earth::EarthTexture;
pub use error::Error;
pub use fidelity::{CameraTier, Fidelity, LightingTier};
pub use fixed_pattern::FixedPattern;
pub use model::{LoadOptions, Material, Mesh, ModelId};
pub use moon::MoonTexture;
pub use preview::{Look, Preview};
+27 -2
View File
@@ -18,12 +18,13 @@ use std::sync::Arc;
use crate::earth::EarthTexture;
use crate::eclipse::{sun_visible_fraction, sun_visible_light};
use crate::fidelity::{CameraTier, Fidelity, LightingTier};
use crate::fixed_pattern::FixedPattern;
use crate::gpu::blur::BlurPass;
use crate::gpu::bodies::{BodiesPass, BodiesUniforms, BodyRadiometry};
use crate::gpu::earthshine::{EarthshinePass, TargetLight};
use crate::gpu::mesh::{GpuMesh, MeshDraw, MeshPass, ShadowDraw};
use crate::gpu::profile::{PassTimings, Profiler};
use crate::gpu::sensor::{SensorPass, SensorUniforms};
use crate::gpu::sensor::{SensorPass, SensorPattern, SensorUniforms};
use crate::gpu::shadow::{CasterDraw, ShadowPass};
use crate::gpu::stars::{draws as star_draws, StarPass};
use crate::gpu::targets::{FrameTargets, TargetLayout, DEPTH_FORMAT, RADIANCE_FORMAT};
@@ -82,6 +83,10 @@ pub struct RendererOptions {
/// on the seed and its number ([`Frame::number`]), so the same seed and number give
/// the same image, and different numbers independent noise.
pub noise_seed: u64,
/// From camera C1, the detector's fixed pattern measured from calibration frames,
/// in place of the one its fields generate. It must match the camera's size and
/// channels.
pub fixed_pattern: Option<Arc<FixedPattern>>,
}
/// One rendered image.
@@ -299,6 +304,16 @@ impl Renderer {
row,
)
});
if let Some(p) = &options.fixed_pattern {
if (p.width, p.height, p.channels)
!= (camera.width, camera.height, camera.channels.len() as u32)
|| p.values.len() != (p.width * p.height * p.channels) as usize
{
return Err(Error::InvalidCamera(
"the fixed pattern must match the camera's size and channels".to_owned(),
));
}
}
if options.fidelity.lighting >= LightingTier::L1 && options.earth_texture.is_none() {
return Err(Error::MissingInput(format!(
"lighting {} draws Earth's surface from an image: set RendererOptions::earth_texture",
@@ -322,7 +337,17 @@ impl Renderer {
blur,
shadows: ShadowPass::new(&ctx, options.shadow_map_size.unwrap_or(4096)),
earthshine: EarthshinePass::new(&ctx),
sensor: SensorPass::new(&ctx, stars.is_some(), area && camera.detector.is_noisy()),
sensor: SensorPass::new(
&ctx,
stars.is_some(),
area && camera.detector.is_noisy(),
match &options.fixed_pattern {
_ if !area => SensorPattern::None,
Some(p) => SensorPattern::Loaded(p),
None if camera.detector.has_fixed_pattern() => SensorPattern::Generated,
None => SensorPattern::None,
},
),
stars: stars.map(|catalog| {
(
catalog,
+34 -1
View File
@@ -5,6 +5,11 @@
// from a counter-based generator keyed by the seed, the frame number and the value's
// index, so a frame's noise is the same every time it is rendered.
//
// With PATTERN (also declared by the includer: 0 for none, 1 generated, 2 loaded):
// each value's response to light and its dark current scaled by the detector's fixed
// pattern, generated from a hash of the pattern seed and the index (as
// FixedPattern::generated_value computes on the CPU), or read from calibration maps.
//
// Output is u16 per channel, channels interleaved per pixel, rows from the top. WGSL
// has no 16-bit integers, so each invocation computes two consecutive values and packs
// them into one u32 word, low half first. Two invocations never touch the same word.
@@ -21,6 +26,10 @@ struct Sensor {
noise: vec4<f32>,
// The frame number, then the seed's low and high words.
key: vec4<u32>,
// PRNU and DSNU (fractions RMS), the hot pixels' fraction and multiple.
pattern: vec4<f32>,
// The pattern seed's low and high words, then the pattern's stream word.
pattern_key: vec4<u32>,
// The lens distortion (distortion.wgsl, included first).
distortion: Distortion,
}
@@ -31,6 +40,8 @@ struct Sensor {
// With STARS (declared by the code that includes this file): the stars' electrons per
// value, 1/64 of an electron per unit (stars.wgsl).
@group(0) @binding(3) var<storage, read> star_electrons: array<u32>;
// With PATTERN 2: response and dark factors per value.
@group(0) @binding(4) var<storage, read> loaded_pattern: array<vec2<f32>>;
// A hash of four words into four (Jarzynski and Olano 2020, pcg4d).
fn pcg4d(input: vec4<u32>) -> vec4<u32> {
@@ -68,6 +79,22 @@ fn poisson(mean: f32, u: f32) -> f32 {
return k;
}
// Value i's response and dark factors.
fn fixed_pattern(i: u32) -> vec2<f32> {
if PATTERN == 2u {
return loaded_pattern[i];
}
let r = pcg4d(vec4<u32>(s.pattern_key.x, i, s.pattern_key.z, s.pattern_key.y));
let radius = sqrt(-2.0 * log(unit(r.x)));
let angle = 6.2831853 * unit(r.y);
let response = max(1.0 + s.pattern.x * radius * cos(angle), 0.0);
var dark = max(1.0 + s.pattern.y * radius * sin(angle), 0.0);
if unit(r.z) < s.pattern.z {
dark = s.pattern.w;
}
return vec2<f32>(response, dark);
}
// Above this mean a Poisson count is drawn as a Gaussian of the same variance.
const SMALL: f32 = 50.0;
@@ -98,13 +125,19 @@ fn value(i: u32) -> u32 {
// Light from the stars adds before the pixel fills.
collected += f32(star_electrons[i]) / 64.0;
}
var dark = 1.0;
if PATTERN != 0u {
let f = fixed_pattern(i);
collected *= f.x;
dark = f.y;
}
var electrons = clamp(collected, 0.0, s.detector.y);
if NOISE {
let r = pcg4d(vec4<u32>(i, s.key.x, s.key.y, s.key.z));
// Two independent standard normals (Box-Muller).
let radius = sqrt(-2.0 * log(unit(r.x)));
let angle = 6.2831853 * unit(r.y);
let mean = collected + s.noise.z;
let mean = collected + s.noise.z * dark;
var count = mean;
if s.noise.x > 0.5 {
if mean > SMALL {
+2 -2
View File
@@ -4,7 +4,7 @@
use crate::common;
use crate::pixel_area::plate;
use resolved::gpu::sensor::{SensorPass, SensorUniforms};
use resolved::gpu::sensor::{SensorPass, SensorPattern, SensorUniforms};
use resolved::gpu::targets::{FrameTargets, TargetLayout};
use resolved::gpu::AdapterChoice;
use resolved::nalgebra::{UnitQuaternion, Vector2, Vector3};
@@ -302,7 +302,7 @@ fn the_sensor_uses_the_undistorted_line_of_sight() {
targets.radiance.size(),
);
let mut encoder = ctx.device.create_command_encoder(&Default::default());
SensorPass::new(ctx, false, false).encode(
SensorPass::new(ctx, false, false, SensorPattern::None).encode(
ctx,
&mut encoder,
&targets,
+301
View File
@@ -0,0 +1,301 @@
//! Camera tier C1's fixed pattern: generated in the shader as on the CPU, the same in
//! every frame, and loaded from calibration frames, on the GPU and on the CPU
//! (lavapipe).
use crate::common;
use resolved::camera::Detector;
use resolved::gpu::sensor::{SensorPass, SensorPattern, SensorUniforms};
use resolved::gpu::targets::{FrameTargets, TargetLayout};
use resolved::gpu::GpuContext;
use resolved::radiometry::Radiometry;
use resolved::{CameraConfig, FixedPattern};
const SIZE: u32 = 256;
/// 256 x 256 pixels of a 1 m focal length (falloff off axis under 1e-5), reading whole
/// electrons (1 e-/DN, 16 bits, bias 100) with no noise or pattern unless set.
fn camera(exposure: f64, detector: Detector) -> CameraConfig {
CameraConfig {
width: SIZE,
height: SIZE,
focal_length: 1.0,
exposure,
detector,
..CameraConfig::example()
}
}
fn counting() -> Detector {
Detector {
gain: 1.0,
bias: 100.0,
full_well: 60_000.0,
adc_bits: 16,
..CameraConfig::example().detector.noiseless()
}
}
/// The sensor pass over a flat field of `electrons` per value, with `pattern`, for
/// frame `frame` of noise seed `seed`: the DN less the bias.
fn flat(
ctx: &GpuContext,
config: &CameraConfig,
pattern: SensorPattern,
electrons: f64,
seed: u64,
frame: u64,
) -> Vec<f64> {
let targets = FrameTargets::new(ctx, &TargetLayout::new(SIZE, SIZE, 3));
let radiometry = Radiometry::new(config);
let level = (electrons / radiometry.electrons_per_radiance) as f32;
let radiance = vec![[level, level, level, 0.0]; (SIZE * SIZE) as usize];
ctx.queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &targets.radiance,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
bytemuck::cast_slice(&radiance),
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(SIZE * 16),
rows_per_image: Some(SIZE),
},
targets.radiance.size(),
);
let pass = SensorPass::new(ctx, false, config.detector.is_noisy(), pattern);
let mut uniforms = SensorUniforms::new(config, &radiometry, false);
uniforms.set_key(seed, frame);
let mut encoder = ctx.device.create_command_encoder(&Default::default());
pass.encode(ctx, &mut encoder, &targets, &uniforms, None);
ctx.queue.submit([encoder.finish()]);
let bias = config.detector.bias;
targets
.read_dn(ctx)
.into_iter()
.map(|d| d as f64 - bias)
.collect()
}
fn mean(x: &[f64]) -> f64 {
x.iter().sum::<f64>() / x.len() as f64
}
fn sd(x: &[f64]) -> f64 {
let m = mean(x);
(x.iter().map(|v| (v - m) * (v - m)).sum::<f64>() / (x.len() - 1) as f64).sqrt()
}
fn correlation(a: &[f64], b: &[f64]) -> f64 {
let (ma, mb) = (mean(a), mean(b));
let cov: f64 = a.iter().zip(b).map(|(x, y)| (x - ma) * (y - mb)).sum();
cov / (sd(a) * sd(b)) / (a.len() - 1) as f64
}
/// The largest difference between `got` and `want`: at most 0.5 DN if `got` is `want`
/// rounded, and up to 0.005 more for the shader's f32 arithmetic, a few parts in 10^7
/// of 12,000 electrons.
fn largest_error(got: &[f64], want: impl Iterator<Item = f64>) -> f64 {
got.iter()
.zip(want)
.fold(0.0f64, |worst, (g, w)| worst.max((g - w).abs()))
}
/// Value `i`'s share of the light on axis: cos^4 of its pixel's angle off axis, at most
/// 2e-6 short of 1 here.
fn falloff(config: &CameraConfig, i: usize) -> f64 {
let pixel = i as u32 / 3;
let ray = config.intrinsics().ray(&resolved::nalgebra::Vector2::new(
(pixel % SIZE) as f64 + 0.5,
(pixel / SIZE) as f64 + 0.5,
));
ray.z.powi(4)
}
const ROUNDED: f64 = 0.505;
#[test]
fn the_generated_pattern_is_the_cpus() {
// No noise. A flat field of 10,000 electrons reads 10,000 times each value's
// response, and a dark frame of 100 or 200 dark electrons that times each value's
// dark factor, as FixedPattern::generate gives them, to the rounding (the shader's
// f32 against the CPU's f64). The
// response has mean 1 and the requested spread to 2 %, the dark factor (hot pixels
// aside) too, and the dark frames scale with the exposure.
let detector = Detector {
prnu: 0.01,
dsnu: 0.1,
hot_pixel_fraction: 1e-3,
hot_pixel_multiple: 50.0,
pattern_seed: 3,
..counting()
};
let cpu = FixedPattern::generate(&detector, SIZE, SIZE, 3);
for ctx in common::contexts() {
let name = &ctx.info.name;
let lit = flat(
ctx,
&camera(1e-4, detector),
SensorPattern::Generated,
1e4,
0,
0,
);
let lens = camera(1e-4, counting());
let want = cpu
.values
.iter()
.enumerate()
.map(|(i, v)| 1e4 * v[0] as f64 * falloff(&lens, i));
let worst = largest_error(&lit, want);
assert!(worst <= ROUNDED, "{name}: off by {worst}");
let response: Vec<f64> = lit.iter().map(|d| d / 1e4).collect();
assert!(
(mean(&response) - 1.0).abs() < 1e-4,
"{name}: mean {}",
mean(&response)
);
assert!(
(sd(&response) / 0.01 - 1.0).abs() < 0.02,
"{name}: PRNU {}",
sd(&response)
);
let lit = Detector {
dark_current: 1000.0,
..detector
};
let short = flat(ctx, &camera(0.1, lit), SensorPattern::Generated, 0.0, 0, 0);
let long = flat(ctx, &camera(0.2, lit), SensorPattern::Generated, 0.0, 0, 0);
for (frame, electrons) in [(&short, 100.0), (&long, 200.0)] {
let want = cpu.values.iter().map(|v| electrons * v[1] as f64);
let worst = largest_error(frame, want);
assert!(worst <= ROUNDED, "{name}: {electrons}: off by {worst}");
}
let hot = short.iter().filter(|&&d| d == 5000.0).count();
let cpu_hot = cpu.values.iter().filter(|v| v[1] == 50.0).count();
assert_eq!(hot, cpu_hot, "{name}: hot pixels");
let ordinary: Vec<f64> = long
.iter()
.filter(|&&d| d != 10_000.0)
.map(|d| d / 200.0)
.collect();
assert!(
(sd(&ordinary) / 0.1 - 1.0).abs() < 0.02,
"{name}: DSNU {}",
sd(&ordinary)
);
}
}
#[test]
fn the_pattern_stays_through_frames_and_noise_seeds() {
// With shot and read noise on a flat field of 10,000 electrons, the 1 % pattern
// (100 electrons) and the noise (about 100) are equal parts of each frame's spread,
// so two frames, or two noise seeds, correlate by 10,000 / 20,025 = 0.50. Another
// pattern seed shares nothing: |r| < 0.01.
let detector = Detector {
prnu: 0.01,
read_noise: 5.0,
shot_noise: true,
..counting()
};
let config = camera(1e-4, detector);
let other = camera(
1e-4,
Detector {
pattern_seed: 1,
..detector
},
);
let want = 1e4 / (1e4 + 1e4 + 25.0 + 1.0 / 12.0);
for ctx in common::contexts() {
let name = &ctx.info.name;
let base = flat(ctx, &config, SensorPattern::Generated, 1e4, 0, 0);
for (what, frame, r_want) in [
(
"next frame",
flat(ctx, &config, SensorPattern::Generated, 1e4, 0, 1),
want,
),
(
"next seed",
flat(ctx, &config, SensorPattern::Generated, 1e4, 1, 0),
want,
),
(
"other pattern",
flat(ctx, &other, SensorPattern::Generated, 1e4, 0, 1),
0.0,
),
] {
let r = correlation(&base, &frame);
assert!(
(r - r_want).abs() < 0.01,
"{name}: {what}: r = {r}, expected {r_want}"
);
}
}
}
#[test]
fn calibration_maps_apply_exactly() {
// A pattern from made-up calibration frames, a response from 0.8 to 1.2 and a
// dark factor from 0 to 3. Each value of a flat field reads its 10,000 electrons
// times its response, and of a dark frame its 100 dark electrons times its dark
// factor, to the rounding; a pattern the wrong size is refused.
let n = (SIZE * SIZE * 3) as usize;
let flat_frame: Vec<f32> = (0..n).map(|i| 1000.0 + (i * 7919 % 400) as f32).collect();
let dark_frame: Vec<f32> = (0..n).map(|i| (i * 104_729 % 300) as f32).collect();
let pattern = FixedPattern::from_calibration(SIZE, SIZE, 3, &flat_frame, &dark_frame).unwrap();
for ctx in common::contexts() {
let name = &ctx.info.name;
let lit = flat(
ctx,
&camera(1e-4, counting()),
SensorPattern::Loaded(&pattern),
1e4,
0,
0,
);
let lens = camera(1e-4, counting());
let want = pattern
.values
.iter()
.enumerate()
.map(|(i, v)| 1e4 * v[0] as f64 * falloff(&lens, i));
let worst = largest_error(&lit, want);
assert!(worst <= ROUNDED, "{name}: flat: off by {worst}");
let dark = Detector {
dark_current: 1000.0,
..counting()
};
let frame = flat(
ctx,
&camera(0.1, dark),
SensorPattern::Loaded(&pattern),
0.0,
0,
0,
);
let want = pattern.values.iter().map(|v| 100.0 * v[1] as f64);
let worst = largest_error(&frame, want);
assert!(worst <= ROUNDED, "{name}: dark: off by {worst}");
}
let _devices = common::device_lock();
let small = FixedPattern::from_calibration(2, 2, 3, &[1.0; 12], &[1.0; 12]).unwrap();
let options = resolved::RendererOptions {
adapter: resolved::gpu::AdapterChoice::Cpu,
fidelity: resolved::Fidelity {
camera: resolved::CameraTier::C1,
..Default::default()
},
fixed_pattern: Some(std::sync::Arc::new(small)),
..Default::default()
};
assert!(resolved::Renderer::new(camera(1e-4, counting()), options).is_err());
}
+1
View File
@@ -11,6 +11,7 @@ mod distortion;
mod earth_texture;
mod earthshine;
mod eclipses;
mod fixed_pattern;
mod gpu_context;
mod mesh;
mod moon_texture;
+9 -6
View File
@@ -7,7 +7,7 @@ use crate::common;
use std::sync::Arc;
use resolved::camera::Detector;
use resolved::gpu::sensor::{SensorPass, SensorUniforms};
use resolved::gpu::sensor::{SensorPass, SensorPattern, SensorUniforms};
use resolved::gpu::targets::{FrameTargets, TargetLayout};
use resolved::gpu::{AdapterChoice, GpuContext};
use resolved::nalgebra::{UnitQuaternion, Vector2, Vector3};
@@ -32,13 +32,13 @@ fn camera(detector: Detector) -> CameraConfig {
}
}
/// The example detector with its noise replaced.
/// The example detector with its noise replaced and no fixed pattern.
fn detector(read_noise: f64, dark_current: f64, shot_noise: bool) -> Detector {
Detector {
read_noise,
dark_current,
shot_noise,
..CameraConfig::example().detector
..CameraConfig::example().detector.noiseless()
}
}
@@ -52,6 +52,7 @@ fn counting(read_noise: f64, dark_current: f64) -> Detector {
read_noise,
dark_current,
shot_noise: true,
..CameraConfig::example().detector.noiseless()
}
}
@@ -83,7 +84,7 @@ fn flat(
},
targets.radiance.size(),
);
let pass = SensorPass::new(ctx, false, true);
let pass = SensorPass::new(ctx, false, true, SensorPattern::None);
let mut uniforms = SensorUniforms::new(config, &radiometry, false);
uniforms.set_key(seed, frame);
let mut encoder = ctx.device.create_command_encoder(&Default::default());
@@ -271,12 +272,14 @@ fn a_faint_star_has_the_signal_to_noise_the_detector_gives() {
// mean and spread match that to 2 % and 12 % (400 stars measure a spread to 3.5 %). The
// renderer numbers its frames, and setting the number back repeats a frame.
let _devices = common::device_lock();
let photometry = StarPhotometry::new(&camera(CameraConfig::example().detector));
// The example detector's noise, without its fixed pattern.
let noisy = detector(5.0, 20.0, true);
let photometry = StarPhotometry::new(&camera(noisy));
let signal = 300.0;
let exposure = signal / (photometry.rate(1, 11.5, 0.6) * photometry.aperture);
let config = CameraConfig {
exposure,
..camera(CameraConfig::example().detector)
..camera(noisy)
};
let k = config.intrinsics();
let position = Vector3::new(4.2e7, 0.0, 0.0);
+9 -3
View File
@@ -5,7 +5,7 @@ use crate::common;
use resolved::bodies::Appearance;
use resolved::gpu::bodies::{BodiesPass, BodiesUniforms, BodyRadiometry};
use resolved::gpu::sensor::{SensorPass, SensorUniforms};
use resolved::gpu::sensor::{SensorPass, SensorPattern, SensorUniforms};
use resolved::gpu::targets::{FrameTargets, TargetLayout};
use resolved::gpu::GpuContext;
use resolved::nalgebra::{UnitQuaternion, Vector2, Vector3};
@@ -32,7 +32,7 @@ fn sense(ctx: &GpuContext, name: &str, radiance: &[[f32; 4]]) -> Vec<u16> {
},
targets.radiance.size(),
);
let pass = SensorPass::new(ctx, false, false);
let pass = SensorPass::new(ctx, false, false, SensorPattern::None);
let uniforms = SensorUniforms::new(&config, &Radiometry::new(&config), false);
let mut encoder = ctx.device.create_command_encoder(&Default::default());
pass.encode(ctx, &mut encoder, &targets, &uniforms, None);
@@ -164,7 +164,13 @@ fn sunlit_earth_through_the_whole_chain() {
let targets = FrameTargets::new(ctx, &TargetLayout::new(config.width, config.height, 3));
let mut encoder = ctx.device.create_command_encoder(&Default::default());
BodiesPass::new(ctx, 1, false).encode(ctx, &mut encoder, &targets, &bodies, None);
SensorPass::new(ctx, false, false).encode(ctx, &mut encoder, &targets, &sensor, None);
SensorPass::new(ctx, false, false, SensorPattern::None).encode(
ctx,
&mut encoder,
&targets,
&sensor,
None,
);
ctx.queue.submit([encoder.finish()]);
let dn = targets.read_dn(ctx);
common::save_dn("sensor-earth", &ctx.info, &dn);
+1 -3
View File
@@ -36,9 +36,7 @@ fn camera(exposure: f64) -> CameraConfig {
bias: 100.0,
full_well: 60_000.0,
adc_bits: 16,
read_noise: 0.0,
dark_current: 0.0,
shot_noise: false,
..CameraConfig::example().detector.noiseless()
},
..CameraConfig::example()
}