Sensor/detector DNs

This commit is contained in:
connor committed 2026-10-14 17:05:21 -04:00
1 parent da1d41d7f3
commit 32cc17a622
10 files changed
+532 -56

No files matched your search

+31
View File
@@ -33,6 +33,24 @@ pub struct Detector {
pub adc_bits: u32, pub adc_bits: u32,
} }
impl Detector {
/// The largest value the ADC can output.
pub fn dn_max(&self) -> u32 {
(1 << self.adc_bits) - 1
}
/// The DN a pixel reads after collecting `electrons`, with no noise: the charge
/// saturates at the full well, is divided by the gain, offset by the bias, rounded
/// to the nearest integer and clipped to the ADC's range. The sensor pass on the GPU
/// does the same in f32.
pub fn dn(&self, electrons: f64) -> u16 {
let charge = electrons.clamp(0.0, self.full_well);
(charge / self.gain + self.bias)
.round()
.clamp(0.0, self.dn_max() as f64) as u16
}
}
/// A pinhole camera with radiometric optics and a detector. /// A pinhole camera with radiometric optics and a detector.
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub struct CameraConfig { pub struct CameraConfig {
@@ -232,6 +250,19 @@ mod tests {
assert!(camera.validate().is_err()); assert!(camera.validate().is_err());
} }
#[test]
fn detector_saturates_at_full_well_then_adc() {
let d = CameraConfig::example().detector;
assert_eq!(d.dn(0.0), 64);
assert_eq!(d.dn(-5.0), 64);
assert_eq!(d.dn(11_138.8), 2292);
// The full well fills before the ADC tops out: 20,000 / 5 + 64 = 4064 < 4095.
assert_eq!(d.dn(20_000.0), 4064);
assert_eq!(d.dn(5e8), 4064);
let tight = Detector { gain: 1.0, ..d };
assert_eq!(tight.dn(1e6), 4095);
}
#[test] #[test]
fn boresight_projects_to_principal_point() { fn boresight_projects_to_principal_point() {
let k = CameraConfig::example().intrinsics(); let k = CameraConfig::example().intrinsics();
+1
View File
@@ -3,6 +3,7 @@
pub mod bodies; pub mod bodies;
pub mod context; pub mod context;
pub mod mesh; pub mod mesh;
pub mod sensor;
pub mod targets; pub mod targets;
pub use context::{adapters, AdapterChoice, GpuContext}; pub use context::{adapters, AdapterChoice, GpuContext};
+124
View File
@@ -0,0 +1,124 @@
//! The pass that turns channel radiance into the camera's DN.
//!
//! It is the last pass of a frame: a compute shader that reads the radiance target and
//! writes [`FrameTargets::dn`]. No noise yet (M3); the result for each pixel is
//! [`Detector::dn`](crate::camera::Detector::dn) of [`Radiometry::electrons`].
use super::targets::FrameTargets;
use super::GpuContext;
use crate::camera::CameraConfig;
use crate::radiometry::Radiometry;
/// The shader's uniform block; the layout matches `Sensor` in `sensor.wgsl`.
#[repr(C)]
#[derive(Clone, Copy, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
pub struct SensorUniforms {
intrinsics: [f32; 4],
detector: [f32; 4],
dims: [u32; 4],
}
impl SensorUniforms {
/// The constants for `camera`, whose channels `radiometry` was built from. They do
/// not change from frame to frame.
pub fn new(camera: &CameraConfig, radiometry: &Radiometry) -> Self {
let k = camera.intrinsics();
let d = &camera.detector;
Self {
intrinsics: [k.fx, k.fy, k.cx, k.cy].map(|x| x as f32),
detector: [
radiometry.electrons_per_radiance,
d.full_well,
d.gain,
d.bias,
]
.map(|x| x as f32),
dims: [
camera.width,
camera.height,
camera.channels.len() as u32,
d.dn_max(),
],
}
}
}
const WORKGROUP: u32 = 256;
/// The compiled sensor pipeline and its uniform buffer.
#[derive(Debug)]
pub struct SensorPass {
pipeline: wgpu::ComputePipeline,
uniforms: wgpu::Buffer,
}
impl SensorPass {
/// Compiles the shader and allocates the uniform buffer.
pub fn new(ctx: &GpuContext) -> Self {
let module = ctx
.device
.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("sensor"),
source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/sensor.wgsl").into()),
});
let pipeline = ctx
.device
.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("sensor"),
layout: None,
module: &module,
entry_point: Some("main"),
compilation_options: Default::default(),
cache: None,
});
let uniforms = ctx.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("sensor uniforms"),
size: std::mem::size_of::<SensorUniforms>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
Self { pipeline, uniforms }
}
/// Uploads `uniforms` and records the pass into `encoder`, reading `targets`'
/// radiance and writing its DN buffer.
pub fn encode(
&self,
ctx: &GpuContext,
encoder: &mut wgpu::CommandEncoder,
targets: &FrameTargets,
uniforms: &SensorUniforms,
) {
ctx.queue
.write_buffer(&self.uniforms, 0, bytemuck::bytes_of(uniforms));
let radiance = targets.radiance.create_view(&Default::default());
let bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("sensor"),
layout: &self.pipeline.get_bind_group_layout(0),
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&radiance),
},
wgpu::BindGroupEntry {
binding: 1,
resource: targets.dn.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: self.uniforms.as_entire_binding(),
},
],
});
// One invocation per u32 word, in rows of at most 65,535 workgroups.
let groups = (targets.dn.size() / 4).div_ceil(WORKGROUP as u64) as u32;
let (x, y) = (groups.min(65_535), groups.div_ceil(65_535));
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("sensor"),
timestamp_writes: None,
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &bind_group, &[]);
pass.dispatch_workgroups(x, y, 1);
}
}
+51 -3
View File
@@ -21,11 +21,16 @@ pub struct FrameTargets {
pub radiance: wgpu::Texture, pub radiance: wgpu::Texture,
/// Depth, [`DEPTH_FORMAT`]. /// Depth, [`DEPTH_FORMAT`].
pub depth: wgpu::Texture, pub depth: wgpu::Texture,
/// Number of camera channels.
pub channels: u32,
/// The sensor pass's output: u16 DN, channels interleaved per pixel, rows from the
/// top, packed two to a u32 word.
pub dn: wgpu::Buffer,
} }
impl FrameTargets { impl FrameTargets {
/// Allocates targets of `width` x `height` pixels. /// Allocates targets of `width` x `height` pixels with `channels` channels.
pub fn new(ctx: &GpuContext, width: u32, height: u32) -> Self { pub fn new(ctx: &GpuContext, width: u32, height: u32, channels: u32) -> Self {
let texture = |label, format, usage| { let texture = |label, format, usage| {
ctx.device.create_texture(&wgpu::TextureDescriptor { ctx.device.create_texture(&wgpu::TextureDescriptor {
label: Some(label), label: Some(label),
@@ -45,14 +50,57 @@ impl FrameTargets {
let usage = wgpu::TextureUsages::RENDER_ATTACHMENT let usage = wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC; | wgpu::TextureUsages::COPY_SRC;
let values = width as u64 * height as u64 * channels as u64;
Self { Self {
width, width,
height, height,
radiance: texture("radiance", RADIANCE_FORMAT, usage), // Writable from the CPU so tests can supply radiance directly.
radiance: texture(
"radiance",
RADIANCE_FORMAT,
usage | wgpu::TextureUsages::COPY_DST,
),
depth: texture("depth", DEPTH_FORMAT, usage), depth: texture("depth", DEPTH_FORMAT, usage),
channels,
dn: ctx.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("dn"),
size: values.div_ceil(2) * 4,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
}),
} }
} }
/// Number of u16 values in the DN image.
pub fn dn_len(&self) -> usize {
self.width as usize * self.height as usize * self.channels as usize
}
/// Reads the DN image back. Slow, like [`FrameTargets::read_radiance`]; the per-frame
/// readback is the renderer's.
pub fn read_dn(&self, ctx: &GpuContext) -> Vec<u16> {
let staging = ctx.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("dn readback"),
size: self.dn.size(),
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut encoder = ctx.device.create_command_encoder(&Default::default());
encoder.copy_buffer_to_buffer(&self.dn, 0, &staging, 0, self.dn.size());
ctx.queue.submit([encoder.finish()]);
staging
.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.expect("readback map"));
ctx.device
.poll(wgpu::PollType::wait_indefinitely())
.expect("readback poll");
let view = staging
.slice(..)
.get_mapped_range()
.expect("readback range");
bytemuck::cast_slice::<u8, u16>(&view)[..self.dn_len()].to_vec()
}
/// Reads the radiance target back, row by row from the top. Slow; for tests and /// Reads the radiance target back, row by row from the top. Slow; for tests and
/// debugging, not the per-frame path. /// debugging, not the per-frame path.
pub fn read_radiance(&self, ctx: &GpuContext) -> Vec<[f32; 4]> { pub fn read_radiance(&self, ctx: &GpuContext) -> Vec<[f32; 4]> {
+47
View File
@@ -0,0 +1,47 @@
// Channel radiance to DN: the camera equation, then the detector.
//
// 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.
struct Sensor {
// fx, fy, cx, cy in pixels.
intrinsics: vec4<f32>,
// Electrons per unit channel radiance on axis, full well, gain (e-/DN), bias (DN).
detector: vec4<f32>,
// Width, height, channel count, largest DN.
dims: vec4<u32>,
}
@group(0) @binding(0) var radiance: texture_2d<f32>;
@group(0) @binding(1) var<storage, read_write> dn: array<u32>;
@group(0) @binding(2) var<uniform> s: Sensor;
fn value(i: u32) -> u32 {
let n = s.dims.z;
if i >= s.dims.x * s.dims.y * n {
return 0u;
}
let pixel = i / n;
let x = pixel % s.dims.x;
let y = pixel / s.dims.x;
let l = textureLoad(radiance, vec2<u32>(x, y), 0)[i % n];
// Off-axis falloff: cos^4 of the angle between this pixel's line of sight and the
// boresight.
let k = s.intrinsics;
let ray = vec3<f32>((f32(x) + 0.5 - k.z) / k.x, (f32(y) + 0.5 - k.w) / k.y, 1.0);
let c2 = 1.0 / dot(ray, ray);
let electrons = clamp(l * s.detector.x * c2 * c2, 0.0, s.detector.y);
let level = floor(electrons / s.detector.z + s.detector.w + 0.5);
return u32(clamp(level, 0.0, f32(s.dims.w)));
}
@compute @workgroup_size(256)
fn main(@builtin(global_invocation_id) id: vec3<u32>, @builtin(num_workgroups) groups: vec3<u32>) {
let word = id.x + id.y * groups.x * 256u;
if word >= arrayLength(&dn) {
return;
}
dn[word] = value(2u * word) | (value(2u * word + 1u) << 16u);
}
+20 -19
View File
@@ -7,7 +7,7 @@ use approx::assert_relative_eq;
use resolved::bodies::{Appearance, EARTH_EQUATORIAL_RADIUS, EARTH_POLAR_RADIUS, MOON_RADIUS}; use resolved::bodies::{Appearance, EARTH_EQUATORIAL_RADIUS, EARTH_POLAR_RADIUS, MOON_RADIUS};
use resolved::gpu::bodies::{BodiesPass, BodiesUniforms, BodyRadiometry}; use resolved::gpu::bodies::{BodiesPass, BodiesUniforms, BodyRadiometry};
use resolved::gpu::targets::FrameTargets; use resolved::gpu::targets::FrameTargets;
use resolved::gpu::{AdapterChoice, GpuContext}; use resolved::gpu::GpuContext;
use resolved::nalgebra::{UnitQuaternion, Vector3}; use resolved::nalgebra::{UnitQuaternion, Vector3};
use resolved::radiometry::{Radiometry, AU, SUN_RADIUS}; use resolved::radiometry::{Radiometry, AU, SUN_RADIUS};
use resolved::{CameraConfig, Pose, Scene}; use resolved::{CameraConfig, Pose, Scene};
@@ -29,10 +29,6 @@ impl Frame {
} }
} }
fn contexts() -> [GpuContext; 2] {
[AdapterChoice::Gpu, AdapterChoice::Cpu].map(|c| GpuContext::new(&c).unwrap())
}
fn body_radiometry() -> BodyRadiometry { fn body_radiometry() -> BodyRadiometry {
let radiometry = Radiometry::new(&CameraConfig::example()); let radiometry = Radiometry::new(&CameraConfig::example());
BodyRadiometry::new(&radiometry, &Appearance::default()).unwrap() BodyRadiometry::new(&radiometry, &Appearance::default()).unwrap()
@@ -49,7 +45,12 @@ fn render_with(
camera: &Pose, camera: &Pose,
config: &CameraConfig, config: &CameraConfig,
) -> Frame { ) -> Frame {
let targets = FrameTargets::new(ctx, config.width, config.height); let targets = FrameTargets::new(
ctx,
config.width,
config.height,
config.channels.len() as u32,
);
let pass = BodiesPass::new(ctx); let pass = BodiesPass::new(ctx);
let uniforms = BodiesUniforms::new( let uniforms = BodiesUniforms::new(
scene, scene,
@@ -100,8 +101,8 @@ fn sunlit_earth_center_radiance_and_depth() {
let scene = scene(Vector3::new(AU, 0.0, 0.0), moon_away()); let scene = scene(Vector3::new(AU, 0.0, 0.0), moon_away());
let expected = body_radiometry().earth_lambert_1au; let expected = body_radiometry().earth_lambert_1au;
let near = CameraConfig::example().near_clip; let near = CameraConfig::example().near_clip;
for ctx in contexts() { for ctx in common::contexts() {
let frame = render(&ctx, "earth-sunlit", &scene, &earth_camera()); let frame = render(ctx, "earth-sunlit", &scene, &earth_camera());
let (radiance, depth) = frame.at(960, 540); let (radiance, depth) = frame.at(960, 540);
for k in 0..3 { for k in 0..3 {
assert_relative_eq!(radiance[k], expected[k], max_relative = 1e-4); assert_relative_eq!(radiance[k], expected[k], max_relative = 1e-4);
@@ -122,8 +123,8 @@ fn earth_limb_lands_where_projection_says() {
let expected = (0..1920) let expected = (0..1920)
.filter(|u| ((*u as f64 + 0.5) - k.cx).abs() < half_width) .filter(|u| ((*u as f64 + 0.5) - k.cx).abs() < half_width)
.count(); .count();
for ctx in contexts() { for ctx in common::contexts() {
let frame = render(&ctx, "earth-limb", &scene, &earth_camera()); let frame = render(ctx, "earth-limb", &scene, &earth_camera());
let hit = (0..frame.width) let hit = (0..frame.width)
.filter(|&u| frame.at(u, 540).1 > 0.0) .filter(|&u| frame.at(u, 540).1 > 0.0)
.count(); .count();
@@ -140,8 +141,8 @@ fn night_side_is_dark_and_hides_the_sun() {
// The Sun directly behind Earth: the visible hemisphere is unlit and the solar disk // The Sun directly behind Earth: the visible hemisphere is unlit and the solar disk
// is eclipsed. // is eclipsed.
let scene = scene(Vector3::new(-AU, 0.0, 0.0), moon_away()); let scene = scene(Vector3::new(-AU, 0.0, 0.0), moon_away());
for ctx in contexts() { for ctx in common::contexts() {
let frame = render(&ctx, "earth-night", &scene, &earth_camera()); let frame = render(ctx, "earth-night", &scene, &earth_camera());
assert!( assert!(
frame.radiance.iter().all(|r| *r == [0.0; 4]), frame.radiance.iter().all(|r| *r == [0.0; 4]),
"{}", "{}",
@@ -162,8 +163,8 @@ fn solar_disk_size_and_radiance() {
let radius = k.fx * (SUN_RADIUS / (sun - position).norm()).asin().tan(); let radius = k.fx * (SUN_RADIUS / (sun - position).norm()).asin().tan();
let expected = std::f64::consts::PI * radius * radius; let expected = std::f64::consts::PI * radius * radius;
let sun_radiance = body_radiometry().sun_radiance; let sun_radiance = body_radiometry().sun_radiance;
for ctx in contexts() { for ctx in common::contexts() {
let frame = render(&ctx, "sun-disk", &scene, &camera); let frame = render(ctx, "sun-disk", &scene, &camera);
assert_eq!(frame.at(960, 540).0, sun_radiance); assert_eq!(frame.at(960, 540).0, sun_radiance);
let lit = frame let lit = frame
.radiance .radiance
@@ -186,8 +187,8 @@ fn moon_in_front_of_earth_wins() {
.moon_lambert_1au .moon_lambert_1au
.map(|l| l / (au * au) as f32) .map(|l| l / (au * au) as f32)
}; };
for ctx in contexts() { for ctx in common::contexts() {
let frame = render(&ctx, "moon-in-front-of-earth", &scene, &earth_camera()); let frame = render(ctx, "moon-in-front-of-earth", &scene, &earth_camera());
let (radiance, depth) = frame.at(960, 540); let (radiance, depth) = frame.at(960, 540);
for k in 0..3 { for k in 0..3 {
assert_relative_eq!(radiance[k], expected[k], max_relative = 1e-4); assert_relative_eq!(radiance[k], expected[k], max_relative = 1e-4);
@@ -214,10 +215,10 @@ fn limb_follows_the_ellipsoid() {
let x = a * a / RANGE; let x = a * a / RANGE;
let north = Vector3::new(x, 0.0, b * (1.0 - (a / RANGE).powi(2)).sqrt()); let north = Vector3::new(x, 0.0, b * (1.0 - (a / RANGE).powi(2)).sqrt());
let east = Vector3::new(x, a * (1.0 - (a / RANGE).powi(2)).sqrt(), 0.0); let east = Vector3::new(x, a * (1.0 - (a / RANGE).powi(2)).sqrt(), 0.0);
for ctx in contexts() { for ctx in common::contexts() {
// Boresight on the north limb, north up: Earth fills rows below the center. // Boresight on the north limb, north up: Earth fills rows below the center.
let camera = Pose::looking_at(position, north, Vector3::z()).unwrap(); let camera = Pose::looking_at(position, north, Vector3::z()).unwrap();
let frame = render_with(&ctx, "limb-north", &scene, &camera, &config); let frame = render_with(ctx, "limb-north", &scene, &camera, &config);
let column: Vec<bool> = (0..1080).map(|v| frame.at(960, v).1 > 0.0).collect(); let column: Vec<bool> = (0..1080).map(|v| frame.at(960, v).1 > 0.0).collect();
let edge = column.iter().position(|hit| *hit).unwrap(); let edge = column.iter().position(|hit| *hit).unwrap();
assert!(column[edge..].iter().all(|hit| *hit)); assert!(column[edge..].iter().all(|hit| *hit));
@@ -229,7 +230,7 @@ fn limb_follows_the_ellipsoid() {
// Boresight on the east limb, north up: Earth is to the left of center. // Boresight on the east limb, north up: Earth is to the left of center.
let camera = Pose::looking_at(position, east, Vector3::z()).unwrap(); let camera = Pose::looking_at(position, east, Vector3::z()).unwrap();
let frame = render_with(&ctx, "limb-east", &scene, &camera, &config); let frame = render_with(ctx, "limb-east", &scene, &camera, &config);
let row: Vec<bool> = (0..1920).map(|u| frame.at(u, 540).1 > 0.0).collect(); let row: Vec<bool> = (0..1920).map(|u| frame.at(u, 540).1 > 0.0).collect();
let edge = row.iter().position(|hit| !*hit).unwrap(); let edge = row.iter().position(|hit| !*hit).unwrap();
assert!(row[edge..].iter().all(|hit| !*hit)); assert!(row[edge..].iter().all(|hit| !*hit));
+46 -15
View File
@@ -1,14 +1,27 @@
//! Shared by the GPU integration tests: saving what a test rendered, for looking at. //! Shared by the GPU integration tests: the devices to run on, and saving what a test
//! rendered, for looking at.
//! //!
//! Set `RESOLVED_TEST_IMAGES=1` and each test writes PNGs to //! Set `RESOLVED_TEST_IMAGES=1` and each test writes PNGs to
//! `target/tmp/test-images/`, one per test case and device. //! `target/tmp/test-images/`, one per test case and device.
use std::path::PathBuf; // Each test binary compiles this module and uses only some of it.
#![allow(dead_code)]
use resolved::gpu::GpuContext; use std::path::PathBuf;
use std::sync::OnceLock;
use resolved::gpu::{AdapterChoice, GpuContext};
use resolved::radiometry::{Radiometry, AU}; use resolved::radiometry::{Radiometry, AU};
use resolved::CameraConfig; use resolved::CameraConfig;
/// A hardware GPU and lavapipe, opened once per test binary and shared by its tests.
pub fn contexts() -> &'static [GpuContext; 2] {
static CONTEXTS: OnceLock<[GpuContext; 2]> = OnceLock::new();
CONTEXTS.get_or_init(|| {
[AdapterChoice::Gpu, AdapterChoice::Cpu].map(|c| GpuContext::new(&c).unwrap())
})
}
/// Writes `radiance` (example camera, row-major from the top) as an sRGB PNG named /// Writes `radiance` (example camera, row-major from the top) as an sRGB PNG named
/// `<name>-<gpu|cpu>.png`, if `RESOLVED_TEST_IMAGES` is set. /// `<name>-<gpu|cpu>.png`, if `RESOLVED_TEST_IMAGES` is set.
/// ///
@@ -19,32 +32,50 @@ pub fn save(name: &str, ctx: &GpuContext, radiance: &[[f32; 4]]) {
if std::env::var_os("RESOLVED_TEST_IMAGES").is_none() { if std::env::var_os("RESOLVED_TEST_IMAGES").is_none() {
return; return;
} }
let config = CameraConfig::example(); let r = Radiometry::new(&CameraConfig::example());
let r = Radiometry::new(&config);
let white: Vec<f32> = (0..3) let white: Vec<f32> = (0..3)
.map(|k| r.lambert_radiance(k, 1.0, 1.0, AU) as f32) .map(|k| r.lambert_radiance(k, 1.0, 1.0, AU) as f32)
.collect(); .collect();
let srgb = |x: f32| {
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 pixels: Vec<u8> = radiance let pixels: Vec<u8> = radiance
.iter() .iter()
.flat_map(|p| (0..3).map(move |k| (k, p[k]))) .flat_map(|p| (0..3).map(move |k| (k, p[k])))
.map(|(k, v)| srgb(v / white[k])) .map(|(k, v)| srgb(v / white[k]))
.collect(); .collect();
write(name, ctx, &pixels);
}
/// Writes a DN image (example camera, three channels interleaved) as an sRGB PNG named
/// `<name>-dn-<gpu|cpu>.png`, if `RESOLVED_TEST_IMAGES` is set. The ADC's full range
/// maps to black through white, as a raw camera frame would display.
pub fn save_dn(name: &str, ctx: &GpuContext, dn: &[u16]) {
if std::env::var_os("RESOLVED_TEST_IMAGES").is_none() {
return;
}
let max = CameraConfig::example().detector.dn_max() as f32;
let pixels: Vec<u8> = dn.iter().map(|&d| srgb(d as f32 / max)).collect();
write(&format!("{name}-dn"), ctx, &pixels);
}
/// Linear 0..1 to 8-bit sRGB.
fn srgb(x: f32) -> 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
}
fn write(name: &str, ctx: &GpuContext, rgb: &[u8]) {
let config = CameraConfig::example();
let dir = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("test-images"); let dir = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("test-images");
std::fs::create_dir_all(&dir).unwrap(); std::fs::create_dir_all(&dir).unwrap();
let device = if ctx.is_cpu() { "cpu" } else { "gpu" }; let device = if ctx.is_cpu() { "cpu" } else { "gpu" };
let path = dir.join(format!("{name}-{device}.png")); let path = dir.join(format!("{name}-{device}.png"));
image::save_buffer( image::save_buffer(
&path, &path,
&pixels, rgb,
config.width, config.width,
config.height, config.height,
image::ExtendedColorType::Rgb8, image::ExtendedColorType::Rgb8,
+17 -16
View File
@@ -11,7 +11,7 @@ use resolved::eclipse::sun_visible_fraction;
use resolved::gpu::bodies::{BodiesPass, BodiesUniforms, BodyRadiometry}; use resolved::gpu::bodies::{BodiesPass, BodiesUniforms, BodyRadiometry};
use resolved::gpu::mesh::{MeshDraw, MeshPass}; use resolved::gpu::mesh::{MeshDraw, MeshPass};
use resolved::gpu::targets::FrameTargets; use resolved::gpu::targets::FrameTargets;
use resolved::gpu::{AdapterChoice, GpuContext}; use resolved::gpu::GpuContext;
use resolved::model::{LoadOptions, Material, Mesh, Part}; use resolved::model::{LoadOptions, Material, Mesh, Part};
use resolved::nalgebra::{UnitQuaternion, Vector3}; use resolved::nalgebra::{UnitQuaternion, Vector3};
use resolved::radiometry::{Radiometry, AU}; use resolved::radiometry::{Radiometry, AU};
@@ -30,10 +30,6 @@ impl Frame {
} }
} }
fn contexts() -> [GpuContext; 2] {
[AdapterChoice::Gpu, AdapterChoice::Cpu].map(|c| GpuContext::new(&c).unwrap())
}
/// A cube with edges of 2 `scale` meters, centered on its origin, gray albedo 0.5. /// A cube with edges of 2 `scale` meters, centered on its origin, gray albedo 0.5.
fn cube(scale: f64) -> Mesh { fn cube(scale: f64) -> Mesh {
let mut s = String::from("solid cube\n"); let mut s = String::from("solid cube\n");
@@ -88,7 +84,12 @@ fn render(ctx: &GpuContext, name: &str, scene: &Scene, camera: &Pose, mesh: &Mes
let config = CameraConfig::example(); let config = CameraConfig::example();
let k = config.intrinsics(); let k = config.intrinsics();
let radiometry = Radiometry::new(&config); let radiometry = Radiometry::new(&config);
let targets = FrameTargets::new(ctx, config.width, config.height); let targets = FrameTargets::new(
ctx,
config.width,
config.height,
config.channels.len() as u32,
);
let bodies = BodiesPass::new(ctx); let bodies = BodiesPass::new(ctx);
let mut meshes = MeshPass::new(ctx); let mut meshes = MeshPass::new(ctx);
let gpu_mesh = meshes.upload(ctx, mesh, &radiometry).unwrap(); let gpu_mesh = meshes.upload(ctx, mesh, &radiometry).unwrap();
@@ -142,8 +143,8 @@ fn sunlit_face_radiance_depth_and_silhouette() {
let expected_width = (0..1920) let expected_width = (0..1920)
.filter(|u| ((*u as f64 + 0.5) - k.cx).abs() < half_width) .filter(|u| ((*u as f64 + 0.5) - k.cx).abs() < half_width)
.count(); .count();
for ctx in contexts() { for ctx in common::contexts() {
let frame = render(&ctx, "cube-sunlit", &scene, &camera, &cube(1.0)); let frame = render(ctx, "cube-sunlit", &scene, &camera, &cube(1.0));
let (radiance, depth) = frame.at(960, 540); let (radiance, depth) = frame.at(960, 540);
for c in 0..3 { for c in 0..3 {
assert_relative_eq!(radiance[c], expected[c], max_relative = 1e-4); assert_relative_eq!(radiance[c], expected[c], max_relative = 1e-4);
@@ -173,10 +174,10 @@ fn earth_hides_a_target_behind_it_but_not_in_front() {
) )
.unwrap(); .unwrap();
let big = cube(5.0e4); let big = cube(5.0e4);
for ctx in contexts() { for ctx in common::contexts() {
let empty = render(&ctx, "cube-scene-empty", &scene(sun, None), &camera, &big); let empty = render(ctx, "cube-scene-empty", &scene(sun, None), &camera, &big);
let behind = render( let behind = render(
&ctx, ctx,
"cube-behind-earth", "cube-behind-earth",
&scene(sun, Some(identity_at(Vector3::new(-7.0e6, 0.0, 0.0)))), &scene(sun, Some(identity_at(Vector3::new(-7.0e6, 0.0, 0.0)))),
&camera, &camera,
@@ -188,7 +189,7 @@ fn earth_hides_a_target_behind_it_but_not_in_front() {
ctx.info.name ctx.info.name
); );
let front = render( let front = render(
&ctx, ctx,
"cube-in-front-of-earth", "cube-in-front-of-earth",
&scene(sun, Some(identity_at(Vector3::new(7.0e6, 0.0, 0.0)))), &scene(sun, Some(identity_at(Vector3::new(7.0e6, 0.0, 0.0)))),
&camera, &camera,
@@ -210,14 +211,14 @@ fn penumbra_dims_and_umbra_darkens() {
// Targets 7000 km behind Earth, one on the shadow axis and one at Earth's radius // Targets 7000 km behind Earth, one on the shadow axis and one at Earth's radius
// off it (inside the penumbra), each seen from 30 m on its sunward side. // off it (inside the penumbra), each seen from 30 m on its sunward side.
let sun = Vector3::new(AU, 0.0, 0.0); let sun = Vector3::new(AU, 0.0, 0.0);
for ctx in contexts() { for ctx in common::contexts() {
for y in [0.0, EARTH_EQUATORIAL_RADIUS] { for y in [0.0, EARTH_EQUATORIAL_RADIUS] {
let target = Vector3::new(-7.0e6, y, 0.0); let target = Vector3::new(-7.0e6, y, 0.0);
let camera = let camera =
Pose::looking_at(target + Vector3::new(30.0, 0.0, 0.0), target, Vector3::z()) Pose::looking_at(target + Vector3::new(30.0, 0.0, 0.0), target, Vector3::z())
.unwrap(); .unwrap();
let frame = render( let frame = render(
&ctx, ctx,
&format!( &format!(
"cube-shadow-{}", "cube-shadow-{}",
if y == 0.0 { "umbra" } else { "penumbra" } if y == 0.0 { "umbra" } else { "penumbra" }
@@ -271,9 +272,9 @@ fn thin_panel_is_lit_on_the_side_facing_the_camera() {
let camera = let camera =
Pose::looking_at(target + Vector3::new(30.0, 0.0, 0.0), target, Vector3::z()).unwrap(); Pose::looking_at(target + Vector3::new(30.0, 0.0, 0.0), target, Vector3::z()).unwrap();
let expected = facing_sun((sun - target).norm()); let expected = facing_sun((sun - target).norm());
for ctx in contexts() { for ctx in common::contexts() {
let frame = render( let frame = render(
&ctx, ctx,
"thin-panel", "thin-panel",
&scene(sun, Some(identity_at(target))), &scene(sun, Some(identity_at(target))),
&camera, &camera,
+166
View File
@@ -0,0 +1,166 @@
//! The sensor pass against the CPU reference (`Radiometry::electrons` then
//! `Detector::dn`), on the GPU and on the CPU (lavapipe).
mod common;
use resolved::bodies::Appearance;
use resolved::gpu::bodies::{BodiesPass, BodiesUniforms, BodyRadiometry};
use resolved::gpu::sensor::{SensorPass, SensorUniforms};
use resolved::gpu::targets::FrameTargets;
use resolved::gpu::GpuContext;
use resolved::nalgebra::{UnitQuaternion, Vector2, Vector3};
use resolved::radiometry::{Radiometry, AU};
use resolved::{CameraConfig, Pose, Scene};
/// Runs the sensor pass over `radiance` (example camera, rows from the top) and
/// returns the DN image.
fn sense(ctx: &GpuContext, name: &str, radiance: &[[f32; 4]]) -> Vec<u16> {
let config = CameraConfig::example();
let targets = FrameTargets::new(ctx, config.width, config.height, 3);
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(config.width * 16),
rows_per_image: Some(config.height),
},
targets.radiance.size(),
);
let pass = SensorPass::new(ctx);
let uniforms = SensorUniforms::new(&config, &Radiometry::new(&config));
let mut encoder = ctx.device.create_command_encoder(&Default::default());
pass.encode(ctx, &mut encoder, &targets, &uniforms);
ctx.queue.submit([encoder.finish()]);
let dn = targets.read_dn(ctx);
common::save_dn(name, ctx, &dn);
dn
}
/// The DN the CPU reference gives for `radiance` in channel `c` at pixel (u, v).
fn reference(config: &CameraConfig, r: &Radiometry, radiance: f32, u: usize, v: usize) -> u16 {
let ray = config
.intrinsics()
.ray(&Vector2::new(u as f64 + 0.5, v as f64 + 0.5));
config.detector.dn(r.electrons(radiance as f64, ray.z))
}
/// Channel radiance of a white Lambertian surface facing the Sun at 1 AU.
fn white() -> [f32; 4] {
let r = Radiometry::new(&CameraConfig::example());
let mut out = [0.0; 4];
for (k, o) in out.iter_mut().take(3).enumerate() {
*o = r.lambert_radiance(k, 1.0, 1.0, AU) as f32;
}
out
}
#[test]
fn every_pixel_matches_the_cpu_reference() {
// A pattern from black to three times the white level (past saturation), different
// in each channel and pixel.
let config = CameraConfig::example();
let r = Radiometry::new(&config);
let w = white();
let level = |u: usize, v: usize, c: usize| ((u * 7 + v * 13 + c * 5) % 1000) as f32 / 333.0;
let radiance: Vec<[f32; 4]> = (0..1080)
.flat_map(|v| (0..1920).map(move |u| (u, v)))
.map(|(u, v)| [0, 1, 2, 3].map(|c| if c < 3 { w[c] * level(u, v, c) } else { 0.0 }))
.collect();
for ctx in common::contexts() {
let dn = sense(ctx, "sensor-pattern", &radiance);
let mut off_by_one = 0;
// Every 8th row, plus the last: enough to catch any indexing mistake.
for v in (0..1080).step_by(8).chain([1079]) {
for u in 0..1920 {
for c in 0..3 {
let got = dn[(v * 1920 + u) * 3 + c];
let want = reference(&config, &r, radiance[v * 1920 + u][c], u, v);
assert!(
got.abs_diff(want) <= 1,
"{} ({u},{v},{c}): {got} vs {want}",
ctx.info.name
);
off_by_one += usize::from(got != want);
}
}
}
// f32 on the GPU and f64 on the CPU disagree only where a value lands within
// rounding of a half-DN boundary: measured 557 of all 6.2 million values on the
// RTX 3060 and 699 on lavapipe, about 1 in 10,000.
assert!(
off_by_one < 1920 * 136 * 3 / 5000,
"{}: {off_by_one} off by one",
ctx.info.name
);
}
}
#[test]
fn worked_example_white_surface_saturation_and_bias() {
let w = white();
for ctx in common::contexts() {
// A white surface facing the Sun fills the frame: docs/optics.md's worked example.
let dn = sense(ctx, "sensor-white", &vec![w; 1920 * 1080]);
let green = |u: usize, v: usize| dn[(v * 1920 + u) * 3 + 1];
assert_eq!(green(960, 540), 2292);
// cos^4 falloff: 6.9 degrees off axis at the corner, 10,819 electrons instead of
// 11,139, so 64 + 10,819 / 5 = 2227.8.
assert_eq!(green(0, 0), 2228);
// Looking straight at the Sun: the full well caps the signal at 20,000 / 5 + 64.
let sun = w.map(|x| x * 5e4);
assert!(sense(ctx, "sensor-sun", &vec![sun; 1920 * 1080])
.iter()
.all(|&d| d == 4064));
// Darkness reads as the bias.
assert!(sense(ctx, "sensor-dark", &vec![[0.0; 4]; 1920 * 1080])
.iter()
.all(|&d| d == 64));
}
}
#[test]
fn sunlit_earth_through_the_whole_chain() {
// Earth seen from 200,000 km with the Sun behind the camera: the sub-camera point
// has albedo 0.3 facing the Sun, so 0.3 x 11,138.8 electrons in green: 732 DN.
let config = CameraConfig::example();
let radiometry = Radiometry::new(&config);
let scene = Scene {
earth_attitude: UnitQuaternion::identity(),
sun: Vector3::new(AU, 0.0, 0.0),
moon: Vector3::new(0.0, -3.844e8, 0.0),
targets: vec![],
};
let camera = Pose::looking_at(
Vector3::new(2.0e8, 0.0, 0.0),
Vector3::zeros(),
Vector3::z(),
)
.unwrap();
let body_radiometry = BodyRadiometry::new(&radiometry, &Appearance::default()).unwrap();
let bodies = BodiesUniforms::new(
&scene,
&camera,
&config.intrinsics(),
config.near_clip,
&body_radiometry,
);
let sensor = SensorUniforms::new(&config, &radiometry);
for ctx in common::contexts() {
let targets = FrameTargets::new(ctx, config.width, config.height, 3);
let mut encoder = ctx.device.create_command_encoder(&Default::default());
BodiesPass::new(ctx).encode(ctx, &mut encoder, &targets, &bodies);
SensorPass::new(ctx).encode(ctx, &mut encoder, &targets, &sensor);
ctx.queue.submit([encoder.finish()]);
let dn = targets.read_dn(ctx);
common::save_dn("sensor-earth", ctx, &dn);
assert_eq!(dn[(540 * 1920 + 960) * 3 + 1], 732);
assert_eq!(dn[0], 64, "space reads as the bias");
}
}
+29 -3
View File
@@ -242,7 +242,7 @@ the channel count generic for that reason.
## From radiance to electrons: the camera equation ## From radiance to electrons: the camera equation
Code: `radiometry.rs` (`Radiometry::electrons`); the sensor pass, #9. Code: `radiometry.rs` (`Radiometry::electrons`); `gpu/sensor.rs`, `shaders/sensor.wgsl`.
A lens with focal length f and aperture diameter D has f-number A lens with focal length f and aperture diameter D has f-number
N = f / D. The example camera is f/4: a 50 mm lens with a 12.5 mm aperture. N = f / D. The example camera is f/4: a 50 mm lens with a 12.5 mm aperture.
@@ -282,7 +282,7 @@ For the example camera, pi * A * t / (4 N^2) is 1.48e-16 m^2 sr s.
## From electrons to DN: the detector ## From electrons to DN: the detector
Code: `camera.rs` (`Detector`); the sensor pass, #9. Code: `camera.rs` (`Detector::dn`); `gpu/sensor.rs`, `shaders/sensor.wgsl`.
- **Full well.** The maximum number of electrons a pixel holds. Beyond it - **Full well.** The maximum number of electrons a pixel holds. Beyond it
the pixel saturates and reads its maximum value whatever the light. the pixel saturates and reads its maximum value whatever the light.
@@ -294,7 +294,28 @@ Code: `camera.rs` (`Detector`); the sensor pass, #9.
signal does not get clipped at 0. The example uses 64 DN. signal does not get clipped at 0. The example uses 64 DN.
- **ADC bits.** The output resolution. 12 bits gives values 0 to 4095. - **ADC bits.** The output resolution. 12 bits gives values 0 to 4095.
So DN = bias + electrons / gain, rounded and clipped to the ADC range. So DN = bias + min(electrons, full well) / gain, rounded and clipped to the
ADC range. The renderer does this on the GPU for every pixel and channel,
as the last step of a frame, and reads back only the resulting integers.
Noise (M3) will enter between the electrons and the gain.
Two consequences of these numbers show up in every image:
- **Saturation is not always the ADC's maximum.** In the example camera the
full well fills first: 20,000 / 5 + 64 = 4064 DN, below the 12-bit maximum
of 4095. A saturated pixel there reads 4064. Real cameras are designed
either way, and an algorithm that detects saturation by looking for 4095
would miss it in this one.
- **Black is not zero.** A pixel that receives no light reads the bias,
64 DN, so empty space in a raw frame displays as dark gray.
Raw frames are also not white-balanced. The camera reports what each
channel collected, and the channels collect different amounts from the same
gray surface. In the example camera, sunlight on a gray surface gives about
15 % fewer electrons in blue than in red, so gray things look slightly
yellow-green in a raw image. A white balance step, applied after capture,
would scale the channels to make them equal. The renderer leaves it out,
because it simulates what the sensor records.
### Worked example ### Worked example
@@ -308,6 +329,11 @@ checks the green figure.)
- The full well is 20,000 electrons, so this surface is at 56 % of - The full well is 20,000 electrons, so this surface is at 56 % of
saturation. saturation.
- Earth, with albedo near 0.3, reads about 64 + 3,342 / 5, about 732 DN. - Earth, with albedo near 0.3, reads about 64 + 3,342 / 5, about 732 DN.
- The same white surface at the image corner, 6.9 degrees off axis, gets
cos^4 of that, 97 %: 10,819 electrons, 2228 DN.
The sensor tests check the 2292, 2228, 732, 4064 and 64 DN figures
exactly, on the GPU and on lavapipe.
Picking the exposure time is the same trade a photographer makes. A longer Picking the exposure time is the same trade a photographer makes. A longer
exposure saturates the sunlit target. A shorter one leaves the side of the exposure saturates the sunlit target. A shorter one leaves the side of the