PSF/Blurring. Turned out to be tough

This commit is contained in:
connor committed 2026-10-15 18:08:43 -04:00
1 parent 4d070db85e
commit d0cec6572e
22 files changed
+1250 -92

No files matched your search

+146 -8
View File
@@ -17,7 +17,8 @@ Every pixel value comes from the same sequence:
2. Some of it lands on a surface (Earth, the Moon, the target), which
reflects part of it in various directions.
3. The fraction reflected toward the camera enters the lens aperture. The
optics focus it onto one pixel, losing a little to absorption.
optics focus it onto the detector, losing a little to absorption. Each
point of the scene lands as a small spot rather than a point.
4. The pixel converts some of the arriving photons into electrons. The
fraction depends on wavelength and on the color filter over that pixel.
5. The electrons pile up during the exposure, up to a limit.
@@ -60,8 +61,8 @@ Some quantities that follow from that:
IFOV.
Real lenses are not perfect pinholes. They bend straight lines slightly
(distortion) and spread each point into a small blur (the point spread
function); neither is modeled yet. The pinhole model is the starting point
(distortion), not modeled yet, and spread each point into a small blur (the
point spread function, below). The pinhole model is the starting point
those effects are added to.
### Coordinate conventions
@@ -425,11 +426,13 @@ fraction in quarters, so a pixel on the limb can be off by up to an eighth
of the difference across the edge (measured 0.10 on Earth's limb from low
orbit, against up to a half at C0). Patterns that stagger the rays fix
aligned edges but do worse at other angles; the regular grid had the
smallest error on average over all angles. The lens blur of the point
spread function, once modeled, spreads every edge over a few pixels.
smallest error on average over all angles. At C1 the point spread function
(below) then spreads every edge over a few pixels, and C1 also renders each
pixel as 2 x 2 finer ones by default, each sampled this way, which divides
these errors further.
C1 costs 0.4 to 0.5 ms more per 1920 x 1080 frame on an RTX 3060, and 1.2
to 1.4 ms at 3840 x 2160, measured on Landsat 8, ICESat-2 and the moonrise
At one rendered pixel per image pixel, C1 costs 0.4 to 0.5 ms more per
1920 x 1080 frame on an RTX 3060, and 1.2 to 1.4 ms at 3840 x 2160, measured on Landsat 8, ICESat-2 and the moonrise
scene. On the CPU through lavapipe it costs much more, 2.6 to 8 times the
mesh pass at C0 (ICESat-2's 752,000 triangles go from 74 to 580 ms per
frame), because lavapipe appears to run the fragment shader for every
@@ -437,6 +440,141 @@ sample rather than every pixel. The 4 samples of radiance and depth also
take 80 bytes per pixel of GPU memory: 166 MB at 1920 x 1080 and 660 MB at
3840 x 2160.
## Blur: the point spread function
Code: `camera.rs` (`Psf`); `gpu/blur.rs`, `shaders/blur.wgsl`.
No camera focuses a point to a point. Light from one point of the scene
lands on the detector as a small spot, and the shape of that spot is the
point spread function (PSF). Several things widen it:
- **Diffraction.** Light passing through a round aperture spreads into a
pattern of rings (the Airy pattern) whose size depends only on the
wavelength and the f-number. At f/4 and 550 nm the first dark ring is
2.7 um from the center, half a pixel of the example camera.
- **Aberrations and defocus.** Real lenses bring different parts of the
aperture, different wavelengths and different parts of the field to
slightly different points.
- **The detector.** Electrons freed deep in a pixel can drift into its
neighbors before they are collected (charge diffusion).
- **Jitter.** The camera shakes a little during the exposure.
Each of these blurs the image of the one before it, and many such blurs
together come out close to a Gaussian, a bell curve in 2D. So a common model
of a camera's PSF is a Gaussian with one width, its standard deviation
sigma, measured in pixels. A spot with sigma 0.5 px is 1.2 px wide at half
its peak, and a point centered on a pixel puts about 47 % of its light in
that pixel and the rest in its neighbors; at 0.7 px, 28 %. Longer
wavelengths diffract more, so sigma is set per channel and usually grows
toward the red.
How wide the spot should be depends on the camera. Published measurements,
converted to a Gaussian's sigma with the pixel's own area taken out:
- Earth-observation imagers are built sharp: about 0.2 px for Landsat 8's
OLI, the sharpest, and 0.35 to 0.55 px for WorldView-3, Pléiades Neo and
Sentinel-2's specification. The Lunar Reconnaissance Orbiter's cameras are
specified in the same range.
- Navigation and small-satellite cameras are softer: about 0.6 to 0.9 px
for DART's DRACO camera in flight, and 0.55 to 0.7 px for Satellogic's
imagers.
- Star trackers are defocused so each star covers several pixels. The one
measured in flight here (on Lunar Flashlight) has a sharp core and wide
wings that no single Gaussian matches: its spot is as wide at half its
peak as a 0.4 px Gaussian, and spreads its light as widely as a 1.25 px
one.
The imagers' numbers are for the whole system, including the atmosphere
and image processing, so the optics alone are somewhat sharper. The example
camera uses 0.5 px in every channel, a sharp camera, not derived from its
optics; diffraction alone at f/4 would give about 0.17 px.
Without blur, a star or a distant spacecraft lands in one pixel or in none,
and every edge is as sharp as the pixel grid allows. Algorithms that find
the center of a star or a target to a fraction of a pixel (centroiding)
need the spot spread over more than one pixel, so they cannot be tested on
an unblurred image.
The blur is applied to radiance on the focal plane, before light becomes
electrons. That order matters for anything bright enough to saturate. The
Sun's image is tens of thousands of times brighter than a full well.
Blurred first, its spot spreads that light into the neighboring pixels,
which also fill, so the saturated region is wider than the Sun's disk, as
in a real camera. Clipped first and then blurred, the saturated region
would shrink back inside the disk and its edge would fade over a pixel or
two, as if the Sun were barely brighter than the full well. The blur's
weights sum to 1, so it moves light without adding or removing any.
### Blurring before the pixels average
The blur has to act on the light where it lands, before each pixel averages
it. Blurring an image of pixel averages afterward is not the same once the
spot is about as small as a pixel: a point of light that fell anywhere in a
pixel has become that pixel's value, and where in the pixel it fell is
gone. Its blurred spot should lean toward wherever it was, and no blur of
the averages can tell which way. Measured with sigma 0.4 px, points of
light blurred after averaging put up to 51 % of their light in the wrong
pixel, and edges were off by up to 12 % of their step.
So from camera tier C1 the renderer draws the scene finer than the pixels,
n x n rendered pixels to each image pixel, blurs that by a Gaussian PSF set
in `CameraConfig::psf`, and only then averages each n x n block into one
pixel. Each rendered pixel still collects light over its own area the way
the previous section describes, so the finer image is itself made of
small-pixel averages, and the same error remains at a smaller scale:
| Rendered pixels per pixel | Points, worst pixel | Edges, worst pixel |
|---|---|---|
| 1 x 1 | 51 % of the light | 12 % of the step |
| 2 x 2 | 19 % | 2.2 % |
| 4 x 4 | 4.5 % | 0.5 % |
(sigma 0.4 px, points at 16 and edges at 129 positions within a pixel; the
worst edges lie on a pixel boundary.) C1 renders 2 x 2 by default;
`RendererOptions::supersampling` sets 1 to 4. Point sources, stars in
particular, need better than that, and will be drawn with the blurred spot
computed exactly over each pixel instead.
A 2D Gaussian is the product of a 1D Gaussian across and one down, and
averaging an n x n block is likewise an average across and one down. So the
blur runs as two passes, one along rows and one along columns, each taking
a weighted sum of the rendered pixels out to 4 sigma on either side
(beyond which a Gaussian holds less than 1e-4 of its light) and averaging
n of them into one pixel at once. Light from just outside the frame also
blurs into it, so the renderer draws a margin of that width around the
image, and the blur reads it and drops it. The weights are the Gaussian at
each rendered pixel's offset, which keeps a spot's width (its variance)
within 1 % of sigma squared once sigma is 0.65 rendered pixels or more:
0.33 px in image pixels at 2 x 2.
Rendering finer costs about n^2 times the drawing, and the blur grows with
it. To keep that down, the meshes are covered at 2 points per rendered pixel
rather than 4 when rendering finer: at 2 x 2 that still puts samples at 4
distinct offsets across and down each image pixel, as 4 samples did at
1 x 1, so mesh edges still grade in quarters or better. (A device that
cannot render 2 samples, such as lavapipe, uses 4.) GPU time per frame at
C1, from the first pass to the last, on an RTX 3060 (Landsat 8, ICESat-2
and the moonrise scene, at lighting L1, sigma 0.5 px):
| Rendered pixels per pixel | 1920 x 1080 | 3840 x 2160 |
|---|---|---|
| 1 x 1 | 1.9 to 2.5 ms | 6.1 to 6.9 ms |
| 2 x 2 (default) | 3.7 to 4.4 ms | 12.6 to 14.6 ms |
| 3 x 3 | 6.7 to 7.9 ms | 34 to 38 ms |
| 4 x 4 | 10.9 to 13.0 ms | 59 to 66 ms |
Of the 3.7 to 4.4 ms at 2 x 2, the bodies take 1.3 to 1.4 ms, the meshes
0.7 to 1.1 ms and the blur 1.0 ms. With 2 samples instead of 4 the meshes
and bodies took 0.3 to 0.6 ms less in all; with 1 sample, 0.7 to 1.0 ms
less, but a mesh edge then grades only in halves at 2 x 2, coarser than at
1 x 1. The rendered images take 60 bytes per rendered pixel (100 at 1 x 1,
with 4 samples), and the blur 16 bytes per image pixel per rendered row:
about 0.27, 0.60, 1.25 and 2.15 GB at 1920 x 1080 for 1 x 1 to 4 x 4, and
four times that at 3840 x 2160. The renderer reports running out of memory
as an error. On the CPU through lavapipe, 2 x 2 adds 9 to 13 ms to
1920 x 1080 frames of 116 to 680 ms, and 3 x 3 at 3840 x 2160 runs out of
memory.
## Earth, Moon and Sun
Code: `bodies.rs`, `gpu/bodies.rs`, `shaders/bodies.wgsl`.
@@ -1115,7 +1253,7 @@ velocity as an input, which the scene does not carry yet.
- Earthshine on the Moon's night side
- Lens distortion
- Blur from diffraction and lens imperfections (point spread function)
- The diffraction pattern's rings, and a lens's measured point spread function
- Noise: photon shot noise, read noise, dark current, pixel-to-pixel variation
- Saturation spilling into neighbors (blooming)
- Non-Lambertian materials (BRDFs), glints, and the full Moon's edge as bright