@photivra/engine 2.15.0 → 2.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/CHANGELOG.md +102 -1
  2. package/CITATION.cff +1 -1
  3. package/README.md +6 -6
  4. package/SECURITY.md +1 -1
  5. package/THIRD_PARTY.md +38 -0
  6. package/dist/camera/lens-projection.d.ts +5 -0
  7. package/dist/camera/lens-projection.js +10 -0
  8. package/dist/camera/lens-projection.js.map +1 -1
  9. package/dist/capture/native-environment-lean.js +1 -1
  10. package/dist/capture/native-environment-lean.js.map +1 -1
  11. package/dist/capture/native-raw-development.d.ts +15 -0
  12. package/dist/capture/native-raw-development.js +12 -6
  13. package/dist/capture/native-raw-development.js.map +1 -1
  14. package/dist/capture/raw-development-kernel.d.ts +10 -3
  15. package/dist/capture/raw-development-kernel.js +25 -6
  16. package/dist/capture/raw-development-kernel.js.map +1 -1
  17. package/dist/core/internal-radiance-evaluator.d.ts +5 -2
  18. package/dist/core/internal-radiance-evaluator.js.map +1 -1
  19. package/dist/core/version.d.ts +1 -1
  20. package/dist/core/version.js +1 -1
  21. package/dist/core/version.js.map +1 -1
  22. package/dist/equipment/generic-lens-catalogue.d.ts +227 -0
  23. package/dist/equipment/generic-lens-catalogue.js +243 -0
  24. package/dist/equipment/generic-lens-catalogue.js.map +1 -0
  25. package/dist/facade/defocus-bokeh-zones.d.ts +33 -0
  26. package/dist/facade/defocus-bokeh-zones.js +53 -0
  27. package/dist/facade/defocus-bokeh-zones.js.map +1 -0
  28. package/dist/facade/defocus-image-space-provider.d.ts +118 -0
  29. package/dist/facade/defocus-image-space-provider.js +448 -0
  30. package/dist/facade/defocus-image-space-provider.js.map +1 -0
  31. package/dist/facade/diffraction-provider.d.ts +45 -0
  32. package/dist/facade/diffraction-provider.js +120 -14
  33. package/dist/facade/diffraction-provider.js.map +1 -1
  34. package/dist/facade/display-view-gpu-session.js +4 -0
  35. package/dist/facade/display-view-gpu-session.js.map +1 -1
  36. package/dist/facade/display-view-gpu.d.ts +10 -0
  37. package/dist/facade/display-view-gpu.js.map +1 -1
  38. package/dist/facade/display-view-lens-signal.d.ts +30 -0
  39. package/dist/facade/display-view-lens-signal.js +427 -0
  40. package/dist/facade/display-view-lens-signal.js.map +1 -0
  41. package/dist/facade/display-view.d.ts +21 -0
  42. package/dist/facade/display-view.js +66 -8
  43. package/dist/facade/display-view.js.map +1 -1
  44. package/dist/facade/expected-signal-setup.d.ts +7 -0
  45. package/dist/facade/expected-signal-setup.js +12 -2
  46. package/dist/facade/expected-signal-setup.js.map +1 -1
  47. package/dist/facade/expected-signal.d.ts +8 -0
  48. package/dist/facade/expected-signal.js +150 -0
  49. package/dist/facade/expected-signal.js.map +1 -1
  50. package/dist/facade/generic-lens-capture.d.ts +6 -0
  51. package/dist/facade/generic-lens-capture.js +8 -0
  52. package/dist/facade/generic-lens-capture.js.map +1 -0
  53. package/dist/facade/large-defocus-lattice.d.ts +39 -0
  54. package/dist/facade/large-defocus-lattice.js +64 -0
  55. package/dist/facade/large-defocus-lattice.js.map +1 -0
  56. package/dist/facade/scene-radiance.d.ts +5 -0
  57. package/dist/facade/scene-radiance.js +5 -2
  58. package/dist/facade/scene-radiance.js.map +1 -1
  59. package/dist/facade/simulate-capture.d.ts +148 -6
  60. package/dist/facade/simulate-capture.js +521 -88
  61. package/dist/facade/simulate-capture.js.map +1 -1
  62. package/dist/facade/simulate-display-view.js +29 -19
  63. package/dist/facade/simulate-display-view.js.map +1 -1
  64. package/dist/gpu/display-view-quality-gpu.d.ts +2 -9
  65. package/dist/gpu/display-view-quality-gpu.js +63 -13
  66. package/dist/gpu/display-view-quality-gpu.js.map +1 -1
  67. package/dist/gpu/display-view-speed-gpu.d.ts +2 -0
  68. package/dist/gpu/display-view-speed-gpu.js +47 -16
  69. package/dist/gpu/display-view-speed-gpu.js.map +1 -1
  70. package/dist/gpu/image-kernel-accelerator.js +3 -0
  71. package/dist/gpu/image-kernel-accelerator.js.map +1 -1
  72. package/dist/gpu/wgsl-display-view-quality-kernel.d.ts +4 -0
  73. package/dist/gpu/wgsl-display-view-quality-kernel.js +53 -2
  74. package/dist/gpu/wgsl-display-view-quality-kernel.js.map +1 -1
  75. package/dist/gpu/wgsl-display-view-speed-kernel.d.ts +15 -0
  76. package/dist/gpu/wgsl-display-view-speed-kernel.js +42 -1
  77. package/dist/gpu/wgsl-display-view-speed-kernel.js.map +1 -1
  78. package/dist/index.d.ts +5 -2
  79. package/dist/index.js +4 -1
  80. package/dist/index.js.map +1 -1
  81. package/dist/optics/defocus-bokeh-kernel.d.ts +207 -0
  82. package/dist/optics/defocus-bokeh-kernel.js +380 -0
  83. package/dist/optics/defocus-bokeh-kernel.js.map +1 -0
  84. package/dist/optics/defocus-disc-kernel.d.ts +44 -21
  85. package/dist/optics/defocus-disc-kernel.js +47 -25
  86. package/dist/optics/defocus-disc-kernel.js.map +1 -1
  87. package/dist/optics/generic-lens-psf.d.ts +68 -0
  88. package/dist/optics/generic-lens-psf.js +337 -0
  89. package/dist/optics/generic-lens-psf.js.map +1 -0
  90. package/dist/optics/illumination-vignetting.d.ts +6 -0
  91. package/dist/optics/illumination-vignetting.js +24 -7
  92. package/dist/optics/illumination-vignetting.js.map +1 -1
  93. package/dist/optics/sensor-environment-query.d.ts +37 -0
  94. package/dist/optics/sensor-environment-query.js +58 -19
  95. package/dist/optics/sensor-environment-query.js.map +1 -1
  96. package/dist/optics/site-kernel-runs.d.ts +77 -0
  97. package/dist/optics/site-kernel-runs.js +200 -0
  98. package/dist/optics/site-kernel-runs.js.map +1 -0
  99. package/dist/output/generic-lens-corrections.d.ts +24 -0
  100. package/dist/output/generic-lens-corrections.js +51 -0
  101. package/dist/output/generic-lens-corrections.js.map +1 -0
  102. package/dist/processing/development-detail.d.ts +132 -0
  103. package/dist/processing/development-detail.js +325 -0
  104. package/dist/processing/development-detail.js.map +1 -0
  105. package/docs/AGENT_GUIDE.md +1 -0
  106. package/docs/API_REFERENCE.md +54 -1
  107. package/docs/QUICK_START.md +444 -26
  108. package/docs/USAGE.md +71 -8
  109. package/docs/api/capture-native-raw-development.md +16 -0
  110. package/docs/api/core-version.md +1 -1
  111. package/docs/api/equipment-generic-lens-catalogue.md +281 -0
  112. package/docs/api/exports.json +162 -2
  113. package/docs/api/facade-simulate-capture.md +151 -5
  114. package/docs/api/optics-defocus-bokeh-kernel.md +145 -0
  115. package/docs/api/optics-defocus-disc-kernel.md +25 -17
  116. package/docs/api/optics-sensor-environment-query.md +6 -0
  117. package/docs/api/output-generic-lens-corrections.md +39 -0
  118. package/llms.txt +1 -0
  119. package/package.json +1 -1
@@ -65,6 +65,8 @@ modelling choices (such as "no lens PSF") as `kind: "model"`.
65
65
  | `camera` | `"professional"` | A generic tier id (`"consumer"`, `"prosumer"`, `"professional"`) or a full `GenericEquipmentTierPreset` record. A record must have exactly the preset shape (unknown fields at the top level, in `body`, `lens` or `lens.matchedReference` are rejected) and each profile is re-checked by its own parser. Tier values are synthetic educational designs, not real cameras. |
66
66
  | `lens.focalLengthMm` | 50 | Must lie in the lens capability; the generic primes are 50 mm. |
67
67
  | `lens.prime` | off | 2.3: a generic prime by full-frame-equivalent focal length (see [Generic prime lenses](#generic-prime-lenses-23)). |
68
+ | `lens.catalogue` | off | #549: a generic catalogue lens by id, with its rendered character (see [Generic lens catalogue](#generic-lens-catalogue-549)). |
69
+ | `lens.focusScatter` | off | #549, with `lens.catalogue`: a seeded autofocus error `{ seed, shotIndex? }`. |
68
70
  | `lens.fNumber` | 8 | Used in `manual` and `aperture-priority`; resolved automatically otherwise. Must lie in f/4–f/16. |
69
71
  | `lens.focusDistanceM` | the subject distance | |
70
72
  | `lens.pupilSampling` | off | `{ radialSamples, angularSamples }` traces an ideal circular pupil, which renders defocus. Off means one chief ray per sample. Angular samples 4–64, at most 256 rays. |
@@ -72,7 +74,7 @@ modelling choices (such as "no lens PSF") as `kind: "model"`.
72
74
  | `lens.distortion` | none | `{ kind: "radial", profile }` or `{ kind: "brown-conrady", profile }`. |
73
75
  | `lens.vignetting` | none | An `IlluminationVignettingProfile` (radial relative illumination). |
74
76
  | `lens.psf` | none | A `{ kind: "sampled-local", configuration }` sampled lens PSF, passed to the environment contract unchanged. |
75
- | `lens.defocus` | off | `{ model: "geometric-disc" }`: defocus of the scene plane as the geometric blur disc (see [Optics](#optics)). `simulateCapture` and `simulateDisplayView` render it. |
77
+ | `lens.defocus` | off | `{ model: "geometric-disc" }`: defocus of the scene plane as the geometric blur disc (see [Optics](#optics)). `simulateCapture` and `simulateDisplayView` render it. Optional bokeh character (#553), each part off by default: `blades`, `sphericalAberration`, `catsEye` (see [Bokeh character](#bokeh-character-553)). |
76
78
  | `lens.diffraction` | off | 2.1: `"circular-aperture"` or `{ kind: "circular-aperture", kernelRadiusSamples, fast }`: diffraction blur of an ideal lens at the resolved f-number (see [Optics](#optics)). `aperture` is accepted as a deprecated alias of `kind` until 3.0.0. |
77
79
  | `exposure.mode` | `"manual"` | Also `"aperture-priority"`, `"shutter-priority"`, `"program"`. |
78
80
  | `exposure.iso` | 100 | Selects the body's ISO readout regime (gain and read noise). From 2.14 the tier bodies (preset 2.1.0) take the third-stop settings `GENERIC_THIRD_STOP_ISO_SETTINGS` (100, 125, 160, 200 … up to the body's maximum: 6400, 12 800 or 25 600). `"auto"` lets Auto ISO choose it from the meter in any mode (see [Auto ISO](#auto-iso-516)); it steps in whole stops unless `exposure.autoIso.isoSteps: "third-stop"`. |
@@ -99,6 +101,9 @@ modelling choices (such as "no lens PSF") as `kind: "model"`.
99
101
  | `output.toneCurve` | `"identity"` | Any `SdrToneCurve`, for example `"positive-reinhard-per-channel"`. |
100
102
  | `output.toneCurveParameters` | none | Required by the luminance curves (`extended-reinhard-luminance`, `naka-rushton-luminance`); the facade invents no curve parameters. |
101
103
  | `output.highlightMode` | `"clip"` | Or `"reconstruct"`. |
104
+ | `output.exposureCompensationStops` | 0 | #556: a development exposure push, −2 to +5 stops, on the RAW before white balance (see [Development exposure push](#development-exposure-push-556)). |
105
+ | `output.diffractionCorrection` | off | #552: `{ wavelengthNm?, regularization?, maximumGain?, kernelRadiusPixels? }`, a bounded deconvolution of the diffraction blur; needs `lens.diffraction`. It never restores detail beyond the diffraction cut-off (see [Sharpening and diffraction correction](#sharpening-and-diffraction-correction-552)). |
106
+ | `output.sharpening` | off | #552: `{ radiusPixels?, amount?, threshold? }`, an unsharp mask after the tone curve; sRGB output only (see [Sharpening and diffraction correction](#sharpening-and-diffraction-correction-552)). |
102
107
  | `output.whiteBalance` | `"scene-illuminant"` | Also `"auto"`, `"none"`, `{ kelvin, tint? }` and `{ channelGains }`. |
103
108
  | `output.jpegQuantizationStep` | 2 | JPEG only: constant step 1–255, not a manufacturer quality scale. |
104
109
  | `sensor.pixelPitchMicrometers` | 6 | 0.5–1000 (2.5; 0.5–100 before). The sensor is `width × pitch` wide (see [Geometry](#geometry)); the ISO readout scales with the pixel area (see [ISO and pixel size](#iso-and-pixel-size)). Above 100 µm the capture is a downsized view of a whole frame (see [Whole-frame views](#whole-frame-views-25)). |
@@ -188,6 +193,46 @@ kept, and the assumption `lens.prime` records the resolved lens. `resolveGeneric
188
193
  same lens for any sensor size. `lens.focalLengthMm` keeps working as before and cannot be combined with
189
194
  `lens.prime`.
190
195
 
196
+ ### Generic lens catalogue (#549)
197
+
198
+ `lens.catalogue` picks one of the engine's 30 generic lenses by id: two sets of primes, Professional ("PV 50 1.2 Pro",
199
+ `pv-50-1.2-pro`) and Consumer ("PV 50 1.8", `pv-50-1.8`), from the 8 mm circular fisheye to 1200 mm, listed in
200
+ `GENERIC_LENS_CATALOGUE` and [docs/LENS_CATALOGUE.md](https://github.com/Photivra/photivra/blob/main/docs/LENS_CATALOGUE.md). They are Photivra's own designs, not real products,
201
+ and every number is a labelled approximation.
202
+
203
+ - **Like `lens.prime`:** the focal length is the full-frame equivalent ÷ the simulated frame's diagonal crop factor, and the lens
204
+ replaces the tier lens's focal length and f-number range (its transmission is kept). Close focus is the generic 0.15× rule (1:1
205
+ for the macro); an f-number or distance outside the lens's range is refused.
206
+ - **Its character is rendered:** a field- and wavelength-dependent PSF (diffraction with the lens's Seidel aberrations, axial and
207
+ lateral colour) through the environment PSF quadrature, its radial distortion, its vignetting and, for the fisheyes, the
208
+ equisolid projection (beyond 90° there is no scene, so the 8 mm's circle is black outside). Flare, focus breathing and the bokeh
209
+ shape are reported in the assumption `lens.catalogue.character`, not rendered.
210
+ - **Professional lenses are better** where the owner's table says so: sharper centres and corners, less coma, lateral colour,
211
+ distortion and vignetting, better close focus and focus consistency. The measurements are in the catalogue page.
212
+ - **Combinations:** `lens.defocus` convolves the disc into the lens's PSF (a combined kernel up to 16 sites in radius);
213
+ `lens.focusScatter: { seed, shotIndex? }` moves the focus by a seeded autofocus error (`simulateGenericLensFocusError`), off
214
+ by default. `prime`, `focalLengthMm`, `projection`, `distortion`, `vignetting`, `psf`, `diffraction` and `pupilSampling` are
215
+ set by the lens and refused with it; the display view refuses `lens.catalogue`.
216
+ - **Cost:** one scene query per kernel tap through the lens field mapping, like `lens.distortion`: a 48 × 48 region of a full frame
217
+ at 6 µm takes about 1 s with *PV 50 1.2 Pro* and 6 s with *PV 50 1.8* (Apple M5 Pro), against 0.01 s with `lens.prime`. Render
218
+ regions, or use a coarser `sensor.spectralStepNanometers`.
219
+
220
+ ```ts
221
+ import { resolveGenericLens, simulateCapture } from "@photivra/engine";
222
+
223
+ const lens = resolveGenericLens({ lensId: "pv-50-1.8", activeImagingArea: { widthMm: 36, heightMm: 24 }, fNumber: 2.8 });
224
+ console.log(lens.name, lens.focalLengthMm, lens.character.distortion.corner); // PV 50 1.8, 50, about -0.009 (barrel)
225
+ const capture = await simulateCapture({
226
+ scene: { preset: "point-grid" },
227
+ // A whole 36 × 24 mm frame on 48 × 32 pixels.
228
+ output: { width: 48, height: 32 },
229
+ sensor: { pixelPitchMicrometers: 750, spectralStepNanometers: 50 },
230
+ lens: { catalogue: "pv-50-1.8", fNumber: 2.8 },
231
+ exposure: { mode: "manual", shutterSeconds: 1 / 1000, iso: 100 }
232
+ });
233
+ console.log(capture.assumptions.find((a) => a.id === "lens.catalogue.character")?.value);
234
+ ```
235
+
191
236
  ### Fitting a chart to the frame (2.3)
192
237
 
193
238
  `calculateSubjectDistanceForFraming({ sensor, focalLengthMm | equivalentFocalLengthMm, chart, fit? })`
@@ -631,6 +676,41 @@ with diffraction, the four-spot filter and AHD.
631
676
  | `{ kelvin, tint? }` | A colour-temperature setting on the Planckian locus (tint as Duv). |
632
677
  | `{ channelGains }` | Manual channel gains. |
633
678
 
679
+ ## Development exposure push (#556)
680
+
681
+ `output.exposureCompensationStops` brightens (or darkens) the same RAW in development, like a raw editor's exposure
682
+ slider: underexpose, then push the shadows up. It takes −2 to +5 stops (`NATIVE_RAW_DEVELOPMENT_EXPOSURE_RANGE_STOPS`),
683
+ whole or fractional; the default 0 gives byte-identical results.
684
+
685
+ ```ts
686
+ import { simulateCapture } from "@photivra/engine";
687
+
688
+ // Three stops under (1/1000 s instead of 1/125 s), brightened three stops in development.
689
+ const pushed = await simulateCapture({ scene: { preset: "uniform" }, exposure: { shutterSeconds: 1 / 1000 },
690
+ output: { width: 64, height: 64, exposureCompensationStops: 3 } });
691
+ console.log(pushed.assumptions.find((a) => a.id === "output.exposureCompensationStops")?.value); // { stops: 3, gain: 8, ... }
692
+ ```
693
+
694
+ - **What it does.** Each site's black-subtracted, white-normalized value (code − black)/(white − black) is multiplied
695
+ by 2^stops before demosaic, white balance, the highlight mode, colour, the tone curve and encoding. It is a
696
+ development setting, not a capture exposure: no photons are added.
697
+ - **Shadows keep their noise.** The pushed image shows the RAW's own shot, dark and read noise and its ADC quantization
698
+ steps, scaled by the gain. A sensor with less read noise or more bits per sample recovers shadows more cleanly: a
699
+ pushed gradient shows 12-bit steps that 14- and 16-bit RAWs do not. A +3-stop push of a 3-stop underexposure has the
700
+ normal exposure's mean, with about √8 times its shot noise.
701
+ - **Highlights clip.** A site pushed to or past white clips at white and counts in
702
+ `diagnostics.highlight.saturatedPixelCount`; `highlightMode: "reconstruct"` rolls those pixels off toward neutral.
703
+ Nothing above white is recovered.
704
+ - **Where it applies.** The developed samples (`image.samples`, also with format `"dng"`), JPEG, PNG (every encoding),
705
+ both demosaics, `simulateIdealReference` and `simulateDisplayView` (its per-site readout moments are scaled by the
706
+ gain) honour it. The RAW codes, `result.raw` and the DNG are the unmodified RAW. `output.whiteBalance: "auto"` is
707
+ measured on the unpushed RAW. `request.development.exposureCompensationStops` carries a non-zero push, so
708
+ `createNativeRawDevelopmentTask` replays it.
709
+ - **Reported** in the assumption `output.exposureCompensationStops` (`{ stops, gain, appliedTo, dng }`), only when the
710
+ option is given.
711
+ - **Not the same as `renderingExposureEv`.** The SDR rendering profile's exposure scales the developed colour after the
712
+ highlight clip, so it cannot clip a pushed site; the facade keeps it at 0.
713
+
634
714
  ## Modelling choices
635
715
 
636
716
  These choices appear in `assumptions` as `kind: "model"` entries:
@@ -644,7 +724,11 @@ These choices appear in `assumptions` as `kind: "model"` entries:
644
724
  lens capability. By default no PSF (point-spread function) or diffraction, vignetting, distortion,
645
725
  lateral colour or stray light is applied. The tier's optical data exist only at f/4 and 5 m, and
646
726
  the facade does not extrapolate them. Vignetting, distortion and PSF are applied only from profiles
647
- you supply; diffraction of an ideal lens only with `lens.diffraction` (2.1).
727
+ you supply; diffraction of an ideal lens only with `lens.diffraction` (2.1); defocus only with
728
+ `lens.defocus`, as the geometric thin-lens disc (an approximation). With camera rotation, a projection,
729
+ distortion or vignetting the disc is applied in image space with the same diameter at every field point,
730
+ a further approximation whose bounds the `lens.defocus` assumption records (`fieldVariation`, #548; see
731
+ [Optics](#optics)).
648
732
  - **Sensor.** Square pixels with a fill factor of 1 and no anti-aliasing filter. A global
649
733
  electronic shutter unless `motion.shutter` is `"rolling"`. A synthetic uniform dark current of
650
734
  1 e⁻/s at 20 °C (the tiers declare none). The charge capacity equals the body's base-ISO saturation signal,
@@ -655,8 +739,9 @@ These choices appear in `assumptions` as `kind: "model"` entries:
655
739
  false-colour artefacts; `"ahd"` and `lens.diffraction` reduce them.
656
740
  - **Scene.** A planar Lambertian surface: radiance = reflectance × irradiance / π, with illuminance
657
741
  from K_m = 683 lm/W and the analytic ȳ. No shadows, inter-reflection, gloss or fluorescence.
658
- - **Rendering.** Linear sRGB with RAW white at display white, no exposure push, out-of-gamut
659
- components clipped, then the sRGB transfer function.
742
+ - **Rendering.** Linear sRGB with RAW white at display white, no exposure push (unless
743
+ `output.exposureCompensationStops` asks for one on the RAW), out-of-gamut components clipped, then the sRGB
744
+ transfer function.
660
745
 
661
746
  ## Output formats
662
747
 
@@ -807,6 +892,46 @@ Why these containers:
807
892
  Pillow 12.3 and ImageMagick 6.9 read the PNG text chunks and the JPEG comment, and Python's XML parser
808
893
  reads both XMP packets.
809
894
 
895
+ ## Sharpening and diffraction correction (#552)
896
+
897
+ Two development stages, both off by default. Without them a request takes the same path and gives the same bytes as before.
898
+
899
+ ```ts
900
+ import { simulateCapture } from "@photivra/engine";
901
+
902
+ const result = await simulateCapture({
903
+ scene: { preset: "slanted-edge" },
904
+ lens: { fNumber: 16, diffraction: "circular-aperture" },
905
+ output: { width: 16, height: 16, format: "jpeg", diffractionCorrection: {}, sharpening: { radiusPixels: 1, amount: 0.5 } }
906
+ });
907
+ // The value holds cutoffCyclesPerPixel (the diffraction cut-off) and recoversBeyondCutoff: false.
908
+ console.log(result.assumptions.find((a) => a.id === "output.diffractionCorrection")?.value);
909
+ console.log(result.diagnostics.detail); // { diffractionCorrection: {...}, sharpening: {...} }
910
+ ```
911
+
912
+ - **Order:** development exposure push on the RAW (`output.exposureCompensationStops`, when given) → demosaic → colour and
913
+ white balance → **diffraction correction** (linear) → rendering exposure and tone curve → gamut handling and sRGB transfer →
914
+ **unsharp mask** → quantization. The `output.developmentOrder` assumption lists it. A push clips at white before the
915
+ correction, on this path as on the default one.
916
+ - **`output.diffractionCorrection`** needs `lens.diffraction`. It deconvolves the developed linear colour with the
917
+ diffraction OTF of the ideal circular pupil at the capture's working f-number, through a bounded Wiener gain
918
+ min(maximumGain, H(1 + α)/(H² + α)) built as a (2R + 1)² filter. Defaults: 550 nm, α = 0.01, gain bound 4, R = the Airy
919
+ third dark ring in pixels (1–16). The OTF is zero beyond the cut-off 1/(λ·N_w): that detail never reached the sensor and
920
+ stays lost. Below it, contrast is only partly restored and noise is amplified by up to the reported `responsePeakGain`.
921
+ - **`output.sharpening`** is an unsharp mask on the sRGB-encoded values: y = x + amount·(x − G_σ∗x) where the difference is at
922
+ least `threshold`. Defaults: σ = 1 pixel, amount 0.5, threshold 0. It creates halos on both sides of an edge and sharpens
923
+ noise; it adds no detail. Not with a wide-gamut `output.colorEncoding` or `output.gamutMapping: "perceptual"`.
924
+ - **Reported:** the `output.diffractionCorrection` and `output.sharpening` assumptions (settings, cut-off, measured filter
925
+ response, defaults used), `diagnostics.detail` (ringing, clipping and halo counts) and `diagnostics.options`.
926
+ - **Not combinable** with `output.region`; not offered by `simulateDisplayView` or `simulateIdealReference`. The DNG holds the
927
+ RAW codes and is unchanged; only `image.samples` (and the JPEG or PNG made from them) change.
928
+ - **Cost:** both stages hold the whole developed image, as AHD does. At 256 × 256 on an Apple M5 Pro (Node.js 24.19.0) the
929
+ development took 8 ms by default, 15 ms with sharpening and 18–20 ms with diffraction correction (R = 3–5), one run each.
930
+
931
+ The trade-offs of these and the lens corrections (corner resolution after distortion correction, corner noise after
932
+ vignetting correction, residual longitudinal CA, halo overshoot, nothing beyond the cut-off) are measured in
933
+ [Digital optics foundation](https://github.com/Photivra/photivra/blob/main/docs/DIGITAL_OPTICS_FOUNDATION.md#sharpening-diffraction-correction-and-their-trade-offs-552).
934
+
810
935
  ## Demosaic
811
936
 
812
937
  `output.demosaic` selects:
@@ -907,7 +1032,7 @@ The optics options feed only what the environment site contract accepts:
907
1032
  is sampled at its centre). It cannot be
908
1033
  combined with `lens.psf` or `lens.pupilSampling`.
909
1034
 
910
- - `lens.defocus: { model: "geometric-disc" }` (#492; the capture renders it, the display view does not yet) models defocus of the flat scene plane when
1035
+ - `lens.defocus: { model: "geometric-disc" }` (#492; the capture and the display view render it) models defocus of the flat scene plane when
911
1036
  `lens.focusDistanceM` differs from `subjectDistanceM`.
912
1037
  - The model is a geometric approximation for an ideal uniform circular pupil. Each in-focus image point spreads
913
1038
  into a uniform disc of diameter c = f²·|s − d| / (N·d·(s − f)) on the sensor (`calculateDefocusCircle`, thin-lens
@@ -922,15 +1047,20 @@ The optics options feed only what the environment site contract accepts:
922
1047
  - A test blurs a sampled straight edge with the kernel and compares it with the disc's closed-form
923
1048
  edge-spread function, the circular-segment area. They agree to rounding (2e-13) when the edge lies on cell
924
1049
  boundaries. Elsewhere they agree within the site sampling of the edge.
925
- - The kernel radius is capped at 64 sites, a 129 × 129 window (`DEFOCUS_DISC_KERNEL_MAXIMUM_RADIUS_SITES`). A larger
926
- blur is refused with an error that names the cap and the radius needed; the disc is never clipped.
1050
+ - The kernel radius is capped at 512 sites, a disc of at most 1025 sites across (`DEFOCUS_DISC_KERNEL_MAXIMUM_RADIUS_SITES`;
1051
+ 64 sites before 2.17.0, #564). At 6 µm that is a blur circle of 6.15 mm. A larger blur is refused with a short error,
1052
+ for example `lens.defocus: The blur circle is 7.81 mm (1,303 sites) across; the engine renders at most 1,025 sites
1053
+ (DEFOCUS_DISC_KERNEL_MAXIMUM_RADIUS_SITES).` The disc is never clipped.
1054
+ - Up to a radius of 64 sites the disc is convolved directly, byte for byte as in 2.16.0. Above it, the
1055
+ [large-disc method](#large-defocus-discs-564) below applies.
927
1056
  - **Check before rendering (#532):** `calculateDefocusDiscKernelRadius({ focalLengthMm, aperture, focusDistanceM,
928
1057
  subjectDistanceM, pixelPitchMicrometers })` returns the disc's diameter in µm and in sites, the kernel radius it
929
- needs, `withinLimit` and `excessKernelRadiusSites`, without rendering. It runs the capture's own resolution and limit
930
- test, so it agrees with the refusal of `simulateCapture`, `simulateDisplayView` and `prepareDisplayView` at the
931
- boundary (`test/defocus-disc-limit.test.ts`, at two adjacent focus distances). Pass the f-number the capture uses
932
- (in an automatic mode, `exposure.settings.fNumber`). The limit is on the disc alone: with `lens.diffraction` the
933
- combined radius has no further limit. The other refusals below are not checked.
1058
+ needs, `withinLimit`, `excessKernelRadiusSites` and `convolution` (`"direct"`, `"runs"` or null past the limit),
1059
+ without rendering. It runs the capture's own resolution and limit test, so it agrees with the refusal of
1060
+ `simulateCapture`, `simulateDisplayView` and `prepareDisplayView` at the boundary (`test/defocus-disc-limit.test.ts`,
1061
+ at two adjacent focus distances, for the limit and for the 64-site switch to runs). Pass the f-number the capture
1062
+ uses (in an automatic mode, `exposure.settings.fNumber`). The limit is on the disc alone: with `lens.diffraction`
1063
+ the diffraction kernel is applied with it and has no further limit. The other refusals below are not checked.
934
1064
  - **The capture renders it** (#492, step 2a). The scene radiance on the site lattice is convolved with the disc,
935
1065
  the same kernel at every spectral node, and each site query is answered with the result. This is the object-space
936
1066
  convolution that `lens.diffraction` uses.
@@ -939,7 +1069,8 @@ The optics options feed only what the environment site contract accepts:
939
1069
  - `diagnostics.options` lists `lens.defocus`, and `diagnostics.timingsMs.defocus` times the provider's convolution.
940
1070
  - The site inputs declare no PSF, because the defocus is applied upstream.
941
1071
  - **With `lens.diffraction`**, each spectral node's diffraction kernel is convolved with the disc and applied as
942
- one kernel. The combined radius is the diffraction radius plus the defocus radius, at most 32 + 64 = 96 sites.
1072
+ one kernel. The combined radius is the diffraction radius plus the defocus radius, at most 32 + 64 = 96 sites. Above a
1073
+ disc radius of 64 sites the diffraction kernel is applied after the disc instead (#564, below).
943
1074
  - This is a further approximation, and the assumption says so. The defocused wave PSF of a circular pupil is not
944
1075
  exactly the in-focus Airy pattern convolved with the geometric disc. Where the two differ is measured in a
945
1076
  later step of #492.
@@ -953,9 +1084,87 @@ The optics options feed only what the environment site contract accepts:
953
1084
  [Display view, Defocus](https://github.com/Photivra/photivra/blob/main/docs/DISPLAY_VIEW.md#defocus).
954
1085
  - In focus (c = 0) no kernel is applied, and the image is byte-identical to the same request without the option.
955
1086
  `simulateIdealReference`, which has no camera effects, only records the disc.
956
- - **Refused out of focus** with camera rotation, `lens.projection` or `lens.distortion`, and `lens.vignetting`. Each
957
- of these moves the scene points that a site sees off the site lattice, so the convolution does not apply. In
958
- focus they render as without `lens.defocus`.
1087
+ - **With camera rotation, `lens.projection`, `lens.distortion` or `lens.vignetting` (#548)** the capture applies the
1088
+ disc in image space instead. Each of these moves the scene points that a site sees off the plane lattice (or, for
1089
+ vignetting, gives each point its own radiance-to-irradiance factor), so the object-space convolution does not apply.
1090
+ - **Method.** For each spectral node, the sensor-plane irradiance before any PSF is formed on the sensor's site
1091
+ lattice: each lattice point's scene ray through the inverse distortion, the declared projection and (with rotation)
1092
+ the camera's orientation at that instant, times its field factor (vignetting throughput × distortion area factor
1093
+ 1/det J). That image is convolved with the disc, and each site query is answered with the result divided by the
1094
+ queried point's own field factor, which the environment multiplies back. This is the environment PSF quadrature with
1095
+ the disc as a sampled PSF (one scene query per kernel tap), summed in another order: a test checks it to 1e-12 per
1096
+ site in expected electrons, and the RAW codes equal those of the same request with the disc passed as `lens.psf`.
1097
+ With the identity mapping (`lens.projection: "rectilinear"`, no distortion) it gives the object-space path's RAW.
1098
+ - **Rotation.** Each environment temporal midpoint is a still camera, so the disc is applied to that instant's
1099
+ image in that instant's frame, then the midpoints are summed as before.
1100
+ - **Vignetting** is applied at each scene point before the disc spreads its light, as for any environment PSF. No
1101
+ multiplier is moved past the blur, so there is no commutation error to bound.
1102
+ The disc does not take the cat's-eye shape of mechanical (pupil) vignetting; the vignetting profile models
1103
+ illumination falloff only.
1104
+ - **With `lens.diffraction`** each node's diffraction kernel convolved with the disc is the image-space kernel, and
1105
+ `request.diffraction.execution` and the `lens.diffraction` assumption say `"image-space-convolution"`.
1106
+ - **What is approximate**, beyond the disc itself: the disc has the same diameter at every field point, centred on
1107
+ each point's chief ray. For the rectilinear, distortion-free thin lens that is exact (a fronto-parallel plane is
1108
+ imaged onto a plane, and the cone from the circular pupil cuts the sensor in a circle of the same size everywhere).
1109
+ For the other cases the `lens.defocus` assumption's `fieldVariation` records a bound for the request, evaluated on
1110
+ a 9 × 9 grid of sensor points over the frame (edges included):
1111
+ - `rotationMaximumDiameterChangeSites`: pitch or yaw tilts the scene plane in the rotated camera, so the disc of a
1112
+ scene point's axial depth there differs from the one used; the largest change over the rendered instants, in
1113
+ sites (0 for roll alone).
1114
+ - `distortionMaximumLocalScaleDeviation`: the largest departure from 1 of the distortion's local scale (singular
1115
+ values of d distorted / d ideal), the relative disc-size difference to a model that blurs the undistorted image
1116
+ and then distorts it. Which model a real lens follows depends on where its distortion arises; the first-order
1117
+ argument (the image-side cone is bounded by the exit pupil, which distortion does not change) favours the
1118
+ constant disc used here.
1119
+ - `projectionMaximumDiameterChangeSites`: for a fisheye or equirectangular projection, the disc of the distance
1120
+ along each scene point's ray instead of its axial depth. The engine has no pupil model for these projections
1121
+ (`lens.pupilSampling` refuses them; a traced reference waits for the 3D ray provider, #358), so a real lens may lie
1122
+ anywhere between the two.
1123
+ - **Refused:** camera rotation with `motion.shutter: "rolling"` (each row has its own instants, so each row would
1124
+ need its own image of the 2R + 1 rows the disc reaches), and a vignetting or distortion profile that does not
1125
+ reach the frame's corners plus the kernel radius (the message names the reach). `lens.pupilSampling` stays refused
1126
+ with projection and distortion. `simulateDisplayView` and `prepareDisplayView` render these combinations too (#563),
1127
+ with the same image-space disc and the same refusals ([Display view](#display-view)).
1128
+ - **Sampling** is the lattice sampling below, unchanged. Measured against a fine-grid oracle: the environment PSF
1129
+ quadrature with the disc sampled 5 times finer than the site pitch (3 times with the box aperture), on a 16 × 12
1130
+ slanted edge with the 5.95-site disc (50 mm f/4, subject 2 m, focus 1.8 m, 6 µm, 80 nm spectral step), as the relative
1131
+ difference in expected electrons per site (max / RMS). Each image-space case stays within the rectilinear capture's
1132
+ error (the motion-blur rows are lower because the rotation smears the edge):
1133
+
1134
+ | Case (#548) | Point aperture | 4 × 4 box |
1135
+ | --- | --- | --- |
1136
+ | rectilinear, no option (object-space path) | 0.110 / 0.026 | 0.032 / 0.0060 |
1137
+ | `lens.vignetting` (r2 −0.3 at 0.2 mm) | 0.110 / 0.026 | 0.032 / 0.0061 |
1138
+ | `"fisheye-equisolid-angle"` | 0.110 / 0.026 | 0.032 / 0.0060 |
1139
+ | radial distortion, k1 −0.2 at 0.25 mm | 0.110 / 0.026 | 0.032 / 0.0060 |
1140
+ | rotation pitch 0.2, yaw 0.5, roll 3 rad/s, 2 midpoints | 0.069 / 0.016 | 0.017 / 0.0029 |
1141
+ | all of them (equidistant fisheye, Brown–Conrady) | 0.065 / 0.015 | 0.015 / 0.0027 |
1142
+
1143
+ The recorded bounds on these frames were small: distortion local scale 0.046 (radial) and 0.044 (Brown–Conrady),
1144
+ rotation 5.0e-4 sites, projection 4.7e-5 sites (a frame 0.1 mm wide sees little field angle). On a real frame the
1145
+ projection and distortion bounds grow with the field: read them from the assumption. On the app's full frame
1146
+ (36 × 24 mm at 93.75 µm, 50 mm f/2.8, subject 2 m, focus 1 m, a 5.0-site disc): an equisolid fisheye 0.43 sites
1147
+ (8.6 % of the disc), radial distortion k1 −0.1 at 25 mm a local scale 0.28 off 1 at the corners, yaw 0.05 and pitch
1148
+ 0.02 rad/s at 1/125 s 8e-4 sites.
1149
+ - **Cost** at the app's defaults (Speed preset: 384 × 256 whole frame at 93.75 µm with the 2 × 2 box; Siemens star;
1150
+ 50 mm f/2.8, subject 2 m; Apple M5 Pro, Node.js 24.19.0, built `dist`, median [range] of 3, one round for the last
1151
+ row; a shared machine, so the slow rows varied by up to a third between sessions). The image-space convolution itself
1152
+ is `diagnostics.timingsMs.defocus`; with one kernel for every node (no diffraction) it convolves once per scene
1153
+ material in each band instead of once per spectral node, the same sum regrouped.
1154
+
1155
+ | Option | In focus | Focus 1 m (R 3) | Focus 0.6 m (R 6) |
1156
+ | --- | --- | --- | --- |
1157
+ | none (object-space path) | 0.06 s | 0.05 s | 0.07 s |
1158
+ | `lens.vignetting` | 1.0 s | 1.6 s [1.5–1.6] (convolution 0.27 s) | 1.7 s (0.38 s) |
1159
+ | `"fisheye-equisolid-angle"` | 11 s | 15–19 s (0.18 s) | |
1160
+ | radial distortion | 18–21 s | 19–20 s (0.36 s) | |
1161
+ | rotation (yaw 0.05, pitch 0.02 rad/s, 8 midpoints) | 2.1–3.3 s | 7.5–8.8 s (1.3–2.2 s) | 6.5 s (1.8 s) |
1162
+ | fisheye + distortion + vignetting + rotation | 94–97 s | 101–124 s (3.4–5.9 s) | |
1163
+
1164
+ The in-focus cost of a lens mapping is the environment's per-site query path, which `lens.defocus` does not change;
1165
+ out of focus adds the band convolutions and one lattice lookup per query (a forward map of the query's ray to the
1166
+ sensor). Without a lens mapping the lean executor reads the values by lattice key, as for the diffraction provider.
1167
+ In focus every request is byte-identical to the same request without the option.
959
1168
  - **Sampling.** The scene enters at the lattice points the sites query: site centres, or the pixel-aperture nodes
960
1169
  and anti-aliasing spots. So near an edge the result can differ from the continuous disc integral by the lattice's
961
1170
  point sampling of the edge. Measured on a 16 × 12 slanted edge, as the relative difference in expected electrons
@@ -981,7 +1190,7 @@ The optics options feed only what the environment site contract accepts:
981
1190
  - The tabulated and environment paths give the same RAW.
982
1191
  - Regions and DNG parts equal the whole frame: chart, Quality options with diffraction, a `material-layers`
983
1192
  scene and a caller function.
984
- - **Cost and the 64-site cap.** Direct convolution costs (2R + 1)² multiply-adds per site and node wherever the disc
1193
+ - **Cost of the direct convolution (up to 64 sites).** Direct convolution costs (2R + 1)² multiply-adds per site and node wherever the disc
985
1194
  sees more than one material. Uniform areas cost almost nothing on the tabulated path. Measured on the tabulated
986
1195
  capture path (Node 24, one thread, rounds interleaved over the radii, median [range] of 3 rounds; 2 at 2048 × 1366;
987
1196
  a shared 4-vCPU host):
@@ -994,10 +1203,9 @@ The optics options feed only what the environment site contract accepts:
994
1203
  | Siemens star, 1024 × 683, Quality (diffraction, four-spot, 2 × 2 box, AHD) | 0.81 s | 0.87 s | 1.7 s | 4.1 s | 12.5 s | 20.0 s | 52.9 s [52.0–53.2] |
995
1204
  | Siemens star, 2048 × 1366 | 1.39 s | | 1.8 s | | 10.4 s | | 38.3 s [37.5–38.3] |
996
1205
 
997
- - The cap stays at 64 sites, set from these numbers. It covers the 1.4 µm example of #492 (50 mm f/2.8, subject
998
- 2 m, focus 1.6 m: R = 41) with room for larger blurs.
999
- - The time grows with R² per edge site, and the caller chooses it through the focus distance. Past the cap the
1000
- request fails and names the radius it needed.
1206
+ - 2.16.0 capped the radius at 64 sites from these numbers. Since 2.17.0 (#564) the direct convolution stops there and
1207
+ larger discs take the run convolution below.
1208
+ - The time grows with R² per edge site, and the caller chooses it through the focus distance.
1001
1209
  - A 24 MP frame costs proportionally more. Render it in parts across workers, as for diffraction.
1002
1210
  - Off the tabulated path (caller reflectance or radiance functions), the provider gathers only each 32-row band's
1003
1211
  own values, not the 2R extra rows a full-grid convolution would return (#419). On a 256 × 171
@@ -1005,6 +1213,183 @@ The optics options feed only what the environment site contract accepts:
1005
1213
  - It cannot be combined with `lens.pupilSampling` (the rays already render defocus) or `lens.psf` (a caller PSF
1006
1214
  declares its own defocus). It can be combined with `lens.diffraction`.
1007
1215
 
1216
+ #### Large defocus discs (#564)
1217
+
1218
+ Wide apertures blur far more than 129 sites. 50 mm f/1.4 on a 6 µm full-frame sensor, focused at infinity with the
1219
+ subject at 0.83 m, gives a 2,144 µm blur circle, 357 sites across (kernel radius 179). Since 2.17.0 `lens.defocus` renders
1220
+ discs up to a kernel radius of 512 sites (1025 sites across, 6.15 mm at 6 µm). It does this in `simulateCapture` (the
1221
+ object-space, image-space #548 and tabulated paths) and in `simulateDisplayView` and `prepareDisplayView` on the CPU.
1222
+
1223
+ - **Method: runs of equal weights.** Each kernel row is split into runs of consecutive equal weights. A run's contribution,
1224
+ w·Σ L over the run, is the difference of two prefix sums of that source row, so it costs two reads however long the run
1225
+ is. Runs that share a weight are summed first and multiplied once. This is the summed-area table of Crow (1984)
1226
+ applied row by row; Heckbert (1986) convolves any piecewise-constant kernel this way. Both are free to read.
1227
+ - Every cell wholly inside the disc has the same weight (its area is exactly 1). So the disc of radius R costs one run
1228
+ per row plus the cells the circle crosses: 2,149 reads per output at R = 179, against 101,689 taps for the direct
1229
+ gather; 6,145 at R = 512 (`src/optics/site-kernel-runs.ts`).
1230
+ - **With bokeh character (#553).** The kernel convolved is the shaped kernel when `lens.defocus` has bokeh options, and
1231
+ the disc otherwise. Blade polygons have the disc's uniform interior, so they take this path at little cost: a 7-blade
1232
+ kernel of radius 85 needs 3,002 reads per output, against 29,241 taps for the direct gather. Spherical aberration's binned
1233
+ kernel has no equal neighbours, so it costs as much as the direct gather. The engine refuses any kernel that needs more
1234
+ reads per site than the direct gather at radius 64 (16,641): `lens.defocus: this blur needs 157,644 reads per site;
1235
+ above a kernel radius of 64 sites the limit is 16,641. Reduce the spherical aberration or the defocus.` A cat's eye
1236
+ above 64 sites is refused too, because this path takes one kernel per request and the cat's eye needs one per field
1237
+ zone (`lens.defocus.catsEye: the blur needs a kernel radius of 179 sites; with a cat's eye the limit is 64 sites
1238
+ (129 across). Reduce the defocus or omit catsEye.`).
1239
+ - **With the display view's lens character (#563).** With `lens.projection`, `lens.distortion`, `lens.vignetting` or
1240
+ camera rotation, the view applies the disc to each channel's in-focus signal through the lens, which already carries
1241
+ each source point's field factor. This is the same reordering as without them. Its site values match the capture's
1242
+ image-space provider within 1e-9 relative at R = 179 with distortion and vignetting
1243
+ (`test/defocus-integration-2-17.test.ts`). The fixed tiles never map lattice points beyond the kernel's reach, so a
1244
+ lens profile only has to cover the reach the error message names.
1245
+ - **Not an approximation.** Each output is the same sum of the same products as the direct gather, regrouped. Only
1246
+ the rounding changes. Measured against a compensated (Neumaier) sum of the same products, on the Siemens star,
1247
+ slanted edge and dead leaves at radii 65, 179 and 512, the runs were within 2.5e-14 relative. The direct gather was
1248
+ up to 9.9e-12 off, because it adds up to 826,537 products in one running sum. The runs and the direct gather
1249
+ differed by at most 9.9e-12 relative (RMS 9.9e-12 at R = 512 on dead leaves; 1.6e-12 at R = 179)
1250
+ (`node scripts/benchmark-large-defocus.mjs accuracy`).
1251
+ - Prefix sums of non-negative samples never decrease, so every output is non-negative.
1252
+ - **Where it runs.** Up to 64 sites nothing changes: the direct convolution, byte for byte as in 2.16.0, and every hash
1253
+ lock holds. Above it:
1254
+ - **The providers** (object space, and image space with the field factor) convolve each spectral node's samples with the
1255
+ disc by runs in fixed 64-site tiles. With `lens.diffraction` each node's diffraction kernel is gathered directly
1256
+ afterwards (associativity: K ⊛ (D ⊛ L) = (K ⊛ D) ⊛ L, the combined kernel's sum regrouped).
1257
+ - **The tabulated expected signal** (the capture's fast path and the display view) uses the fact that defocus of a plane
1258
+ is space-invariant and the same at every wavelength. It commutes with the spectral, query-offset and diffraction sums,
1259
+ so μ_c(x) = Σ_s D(s)·E_c(x − s). Here E_c is a channel's in-focus expected electrons at each site position, on the
1260
+ lattice formula, with diffraction when it is on. The disc is then applied once per channel on the native site lattice
1261
+ instead of once per spectral node and lattice group: E in fixed 64-site tiles, D ⊛ E by runs in fixed 128-site tiles,
1262
+ each site taking its own channel.
1263
+ - **Fixed tiles at absolute positions** make every value a function of its site alone. Regions, strips, DNG parts and
1264
+ display-view parts are bit-identical to the whole frame (tested), and the tabulated and environment paths give the
1265
+ same RAW (tested at R = 179, with and without diffraction and the Quality filters).
1266
+ - The `lens.defocus` and `view.defocus` assumptions record `convolution: "runs"`; below 64 sites the field is absent.
1267
+ - **The GPU display view does not take these discs.** `gpu: "auto"` renders them on the CPU, labelled
1268
+ `unsupported-on-backend` with the reason.
1269
+ - **Validation** (`test/large-defocus.test.ts`, `test/site-kernel-runs.test.ts`, `test/display-view-defocus.test.mjs`):
1270
+ - Each path is compared with the direct gather of the same disc: object space (alone and with a diffraction kernel),
1271
+ image space with vignetting, and the tabulated expected signal with and without diffraction. All agree within 1e-12
1272
+ relative.
1273
+ - A vertical edge between lattice columns at R = 179 gives the exact circular-segment fraction to 1e-12.
1274
+ - The display view at R = 194 (Speed and Quality) matches the in-focus view of the scene blurred beforehand by the disc
1275
+ within ΔE2000 1e-6.
1276
+ - **Cost** (Apple M5 Pro, Node 24.19.0, built `dist`, one process per case, two rounds; the machine was shared, load average
1277
+ 5–7, so the times are upper bounds):
1278
+
1279
+ | Case | In focus | R 64 (direct) | R 65 (runs) | R 179 (runs) | R 512 (runs) |
1280
+ | --- | --- | --- | --- | --- | --- |
1281
+ | Per output, one 64-site tile (kernel only) | | 11.4 µs | 0.60 µs | 1.52 µs (direct: 65 µs) | 4.6 µs |
1282
+ | Capture, Siemens star 1024 × 683, 6 µm (tabulated) | 0.24–0.26 s | 5.7–6.8 s | 0.72–0.77 s | 1.59–1.66 s | 5.4–5.7 s |
1283
+ | Peak resident memory of that capture | 134–135 MiB | 157–158 MiB | 216–230 MiB | 247–253 MiB | 440–457 MiB |
1284
+ | Display view, the app's request (24 MP at 6 µm, 2028 × 1352 in 96 × 96 parts, Speed, 85 mm-equivalent f/1.4, subject 3 m), part median [slowest of 8], one round | 44 ms [105 ms] | 274 ms [5.6 s] | | 327 ms [540 ms] | 1.10 s [1.20 s] |
1285
+ | The same view on one thread, extrapolated from the 8 parts (mean × 294) | 14 s | 431 s | | 98 s | 296 s |
1286
+ | Peak resident memory of that view process | 260 MiB | 217 MiB | | 394 MiB | 390 MiB |
1287
+
1288
+ The disc of the owner's case (R = 179) costs the capture 1.6 s here, against 6–7 s for the direct convolution at R = 64;
1289
+ the display view's whole-view estimate is 98 s, against 431 s at R = 64. The view's parts are rendered on the CPU (the GPU
1290
+ does not take these discs) and can be spread across workers as today.
1291
+
1292
+ - Memory: the disc's own weights are (2R + 1)² doubles (1.0 MB at R = 179, 8.4 MB at 512), and each provider tile's
1293
+ source and prefix sums (64 + 2R)² doubles each (1.4 MB each at R = 179, 9.5 MB each at 512). Kept tiles are capped at 96 MiB per
1294
+ capture for the providers, and 32 MiB each for the expected signal's in-focus and output tiles per prepared input (the
1295
+ in-focus cache grows to one output tile's span, about 37 MiB at R = 512).
1296
+ - The cost grows with R, not R²: about 12·R reads per output plus a fixed part per tile. Measured with
1297
+ `RADII=0,64,65,179,512 REPEATS=2 node scripts/benchmark-large-defocus.mjs capture` (and `kernel`, `view`).
1298
+ - **Why 512.** It bounds the reads per site (6,145, below the 16,641 of the direct gather at 64), the kernel's memory and
1299
+ each tile's source, at a measured cost (above). It covers the owner's case (R = 179), and at 6 µm a 50 mm or 85 mm f/1.2
1300
+ focused at infinity with the subject at 1 m or farther (R ≤ 502). A blur past it fails with a short message (above); the disc is never clipped.
1301
+
1302
+ #### Bokeh character (#553)
1303
+
1304
+ `lens.defocus` takes three optional parts that shape the defocus kernel by the lens. Each is off by default; without
1305
+ them the disc above is unchanged and every request is byte-identical. The same options, validated the same way, go to
1306
+ `calculateDefocusBokehKernel({ focalLengthMm, aperture, focusDistanceM, subjectDistanceM, pixelPitchMicrometers, bokeh,
1307
+ fieldTangent? })`, which returns the kernel the capture applies (for previews, tests and lens catalogues; the
1308
+ `DefocusBokehOptions` type is what a catalogue lens can carry, #549).
1309
+
1310
+ ```js
1311
+ lens: { focalLengthMm: 50, fNumber: 4, focusDistanceM: 1.4, defocus: { model: "geometric-disc",
1312
+ blades: { count: 9, firstBladeEdgeAngleDegrees: 0, curvature: 0.3, circularAtOrBelowFNumber: 1.8 },
1313
+ sphericalAberration: { w040Micrometers: 0.5, atFNumber: 4 },
1314
+ catsEye: { apertures: [{ radiusMm: 7, shiftPerTanFieldMm: 20 }], fieldZonesAcrossLongSide: 8 } } }
1315
+ ```
1316
+
1317
+ - **The model.** A geometric defocused image of a point is the open pupil projected onto the sensor. The ray through
1318
+ pupil point P = ρ·p̂ (in units of the radius of the f/N pupil) lands at (σ·c/2 + b·ρ²)·P sites from the chief ray: c is
1319
+ the thin-lens blur diameter (`calculateDefocusCircle`), σ = +1 for a scene plane behind the focus plane and −1 in front
1320
+ of it, and b = 8·N·W040/pitch is the transverse ray aberration of primary spherical aberration W = W040·ρ⁴,
1321
+ ε = −(R/r_p)·∂W/∂ρ with R/r_p = 2N (Wyant & Creath 1992, §II.A, free). It is an approximation: geometric optics, an ideal thin
1322
+ lens, the aberration referred to the paraxial focus, the exit pupil the same shape and orientation as the entrance pupil;
1323
+ no diffraction of the defocused wave, onion rings, higher-order or field-dependent aberrations, and the same kernel at
1324
+ every wavelength. The `lens.defocus` assumption gains `bokeh` (the options, the side, the shaped kernel's radius,
1325
+ the method, b in sites, and the cat's-eye zones) and says all this.
1326
+ - **`blades`**: a regular polygon of `count` (3–32) straight blades, with `calculateIdealApertureGeometry`'s
1327
+ `firstBladeEdgeAngleDegrees`; `curvature` κ in [0, 1] makes each edge an arc of radius 1/κ circumradii through its two
1328
+ vertices (κ = 1 is the circle); at or below `circularAtOrBelowFNumber` the blades are open and the opening is circular.
1329
+ The opening is scaled to the area of the f/N circle, so the f-number keeps its meaning for light and the blur has the
1330
+ disc's area.
1331
+ - **Front against back.** Behind the focus plane the rays have crossed before the sensor, in front they have not, so the
1332
+ two blurs are the same pupil turned through 180° (visible with an odd blade count or a cat's eye; shapes are declared as
1333
+ the blur behind the focus plane appears in the output image, +X right, +Y up). With spherical aberration the term b·ρ²
1334
+ adds to the defocus on one side and subtracts on the other: the image intensity at r = |σc/2·ρ + bρ³| of a circular
1335
+ pupil is proportional to 1/(|σc/2 + bρ²|·|σc/2 + 3bρ²|), so undercorrected aberration (W040 > 0) gives a soft-edged blur
1336
+ behind the focus plane and a bright-rimmed one in front, and overcorrected the reverse (the photographic description is
1337
+ H. H. Nasse, "Depth of Field and Bokeh", Carl Zeiss, 2010, free). `sphericalAberration` gives W040 in µm at `atFNumber`,
1338
+ scaled to the working f-number as W040·(N₀/N)⁴. The rim can widen the blur beyond the disc. Past a kernel radius of
1339
+ 512 sites the request is refused with the disc's short message. Above 64 sites it is refused when its binned kernel
1340
+ needs more than 16,641 reads per site, the run path's limit ("Large defocus discs", above).
1341
+ - **`catsEye`**: mechanical vignetting, the model of `calculateMechanicalVignetting` (Greivenkamp, OPTI-502 notes, sec. 10,
1342
+ free): each clipping aperture is a circle of `radiusMm` in the entrance-pupil plane, shifted by
1343
+ `shiftPerTanFieldMm`·tan θ toward the field point, and the open pupil is the opening inside every circle. The kernel
1344
+ varies over the field, so the capture cuts the native raster into square zones (`fieldZonesAcrossLongSide`, 1–32,
1345
+ default 8) and gives each site the kernel of its zone's centre: a piecewise-constant (sectioned) approximation of the
1346
+ space-variant blur (also see L. Denis, É. Thiébaut, F. Soulez, J.-M. Becker, R. Mourya, "Fast approximations of
1347
+ shift-variant blur", *International Journal of Computer Vision* 115, 2015, pp. 253–278). The assumption reports the
1348
+ zones, the smallest open-area ratio and its largest step between neighbouring zones (the size of the seams; raise the
1349
+ zone count to shrink it). The kernel has unit sum: the light the clip removes is not applied, so declare the falloff with
1350
+ `lens.vignetting`, as for any field throughput (`lightLossApplied: false`). It runs on the object-space and the
1351
+ image-space (#548) paths; the cat's eye skips the tabulated fast path (one kernel per node there), and it cannot be
1352
+ combined with `lens.diffraction` (every zone would need its own combined kernel per node). Above a kernel radius of 64
1353
+ sites it is refused: #564's run convolution takes one kernel per request, and extending it to one kernel per zone is left
1354
+ for later. `simulateDisplayView` and `prepareDisplayView` refuse it; they render `blades` and `sphericalAberration`.
1355
+ - **With `lens.catalogue` (#549)** the three options are refused by name. The catalogue lens folds a plain disc into its
1356
+ own PSF, so they would otherwise be dropped. Feeding a catalogue lens's spherical aberration and character into the
1357
+ bokeh kernel is a follow-up ([#567](https://github.com/Photivra/photivra/issues/567)), not a 2.17.0 feature.
1358
+ - **Sampling.** Without spherical aberration each site's weight is the exact area of the projected open pupil (a convex
1359
+ polygon; circles and blade arcs are 1024-gons scaled to their true area, within 5e-6 of the radius of the true curve)
1360
+ inside the site's cell, divided by its area (`"exact-polygon-cell-area"`). With it the pupil is sampled on a square grid
1361
+ fine enough that neighbouring rays land at most 1/8 site apart, each grid cell weighted by its exact overlap with the
1362
+ pupil's chord, and every ray binned into the site cell it lands in (`"pupil-ray-binning"`; it handles the caustic).
1363
+ Only correctly rounded operations and the portable trigonometry are used, so the weights are the same bits in every
1364
+ JavaScript engine.
1365
+ - **Validation** (`test/defocus-bokeh-kernel.test.ts`, `test/simulate-capture-defocus-bokeh.test.ts`):
1366
+ - Polygon area and shape: an interior cell holds 1/(π·(c/2)²) of the light (to 1e-6 relative) for 5, 6 and 9 blades, and
1367
+ every cell matches a 16 × 16 supersampling of `calculateIdealApertureGeometry`'s polygon to its resolution (1/16 cell).
1368
+ Without blades, at κ = 1 and with the blades open the kernel equals the exact disc kernel to 1e-4 of an interior weight.
1369
+ - Cat's eye against field position: the open-area ratio equals `calculateMechanicalVignetting`'s exact two-circle area to
1370
+ 1e-4 at 0–25° and three azimuths; the radial spread falls against the tangential one as the field grows, the light kept
1371
+ is on the field point's side behind the focus plane and on the other in front; on axis the disc is whole.
1372
+ - Spherical-aberration edge profile: the kernel's encircled energy follows the ray map's analytic ρ(r)² within 0.012 at
1373
+ c = 60 sites, b = 4.5 sites on both sides; the rim-to-centre intensity ratio matches the analytic one to 0.05 and is
1374
+ below 0.8 behind and above 1.2 in front for W040 > 0, reversed for W040 < 0; W040 scales as (N₀/N)⁴.
1375
+ - Front against back: a 5-blade kernel in front is the one behind turned through 180° to 1e-15; a circular pupil without
1376
+ aberration gives the same blur on both sides.
1377
+ - On the point grid out of focus (240 × 144, 6 µm, 50 mm f/4, subject 2 m, focus 1.4 m, a 23-site blur): the centre
1378
+ point's blob has a max/min radial extent of 1.07 with the disc and 1.23 with 5 blades (1/cos 36° = 1.236), five-fold;
1379
+ with a cat's eye the left point's blob has a radial-to-tangential spread of 0.57 (0.99–1.01 for the centre point and
1380
+ without the option) and its centroid moves 3.5 px outward; with W040 0.5 µm the rim-to-centre ratio is 0.90 behind the
1381
+ focus plane (focus 1.4 m) and 1.15 in front (focus 3.5 m).
1382
+ - Requests without the options, and in focus with them, give the same RAW codes; regions equal the whole frame with a
1383
+ cat's eye; the GPU display view's float32 emulation gathers a shaped asymmetric kernel within its a-priori bounds.
1384
+ - **Cost** (Apple M5 Pro, Node.js 24, built `dist`, one thread, median of 3 or 5; a shared machine). Building a kernel at
1385
+ 1.4 µm, 50 mm f/4, subject 2 m, focus 1.6 m (c = 57.6 sites): the circle or 9 blades 0.2–0.8 ms (R 29–30), a cat's-eye
1386
+ kernel at tan θ = 0.3 3.5 ms, spherical aberration (b = 6.9 sites, R 36, about 0.5 million rays) 3–4 ms. A Siemens star
1387
+ capture at 480 × 320, 6 µm, focus 1.6 m (c = 13.4 sites, R 7–9): the disc, 9 blades and spherical aberration all
1388
+ 0.06–0.08 s on the tabulated path; a cat's eye 2.2 s with 8 zones and 3.2 s with 16 (the zoned convolution itself 0.51 s
1389
+ and 1.02 s), because it leaves the tabulated path for the per-site environment path. The display view (CPU, 240 × 160 of
1390
+ that frame) took 0.12–0.16 s with the disc, 9 blades or spherical aberration. The GPU display view gathers the shaped
1391
+ kernel with the disc's own pass and cost (no `src/gpu` change); it does not take the cat's eye.
1392
+
1008
1393
  ### Pixel aperture (2.3)
1009
1394
 
1010
1395
  By default each site samples the irradiance at its centre (`sensor.pixelAperture: "point"`). Scene
@@ -1549,10 +1934,12 @@ Validated against real full-resolution captures resized in linear light: three s
1549
1934
 
1550
1935
  **Cost.** Where a display pixel covers more than 25 real pixels, the cost follows the detailed display pixels ×
1551
1936
  `samplesPerAxis`², not the real pixel count; at 25 or fewer (the exact area mean) it follows the real pixels the region
1552
- touches. A device-pixel view of a 24 MP frame, 2028 × 1352 in 96 × 96 parts from one prepared view, takes about 0.09 s
1553
- per part with the engine defaults and about 0.18 s with the Quality options (2.9.0, Node 24.21.0, one thread on a
1554
- 4-vCPU 2.80 GHz Xeon; [the table](https://github.com/Photivra/photivra/blob/main/docs/DISPLAY_VIEW.md#a-device-pixel-view-rendered-in-parts),
1555
- `node scripts/benchmark-display-view.mjs --app`). The table below is the 2.6.0 record of whole views against the
1937
+ touches. A device-pixel view of a 24 MP frame, 2028 × 1352 in 96 × 96 parts from one prepared view, takes about 0.04 s
1938
+ per part with the engine defaults and about 0.08 s with the Quality options. Those are the medians on 2.15.0: Node 24.19.0,
1939
+ one thread on an Apple M5 Pro under a load average of about 6, two repeats. On 2.9.0 the same parts took about 0.09 s and
1940
+ 0.18 s on a 4-vCPU 2.80 GHz Xeon (Node 24.21.0). Both measurements are in
1941
+ [the tables](https://github.com/Photivra/photivra/blob/main/docs/DISPLAY_VIEW.md#a-device-pixel-view-rendered-in-parts), from
1942
+ `node scripts/benchmark-display-view.mjs --app`. The table below is the 2.6.0 record of whole views against the
1556
1943
  whole-frame view of the same request (Siemens star, f/8, ISO 100, one run, same host; every row, and the times before
1557
1944
  the exact speed-ups, in [DISPLAY_VIEW.md](https://github.com/Photivra/photivra/blob/main/docs/DISPLAY_VIEW.md#cost)):
1558
1945
 
@@ -1572,6 +1959,14 @@ request took 73 s instead of 103 s). In 2.6.0 the results were byte-identical to
1572
1959
  merged, which took 222 s, 450 s and 837 s on the phone; later releases changed some views' values (for example the
1573
1960
  exact area mean, 2.8.0; AHD's choice, 2.13.0).
1574
1961
 
1962
+ **Lens character (#563).** `lens.projection`, `lens.distortion`, `lens.vignetting` and camera rotation (global shutter)
1963
+ render in the view, in focus and with `lens.defocus` out of focus: each real site's expected electrons are the capture's,
1964
+ through the same mapping, field factor and rotation (within 1e-9 relative per site). They run on the CPU: with
1965
+ `gpu: "auto"` the view falls back labelled `unsupported-on-backend`. They cost more than the rectilinear view, most for
1966
+ distortion (the inverse distortion is solved per point) and rotation (one scene point per temporal midpoint)
1967
+ ([DISPLAY_VIEW.md](https://github.com/Photivra/photivra/blob/main/docs/DISPLAY_VIEW.md#lens-projection-distortion-vignetting-and-rotation)).
1968
+ Rotation with a rolling shutter stays refused.
1969
+
1575
1970
  **Options.** `view.width` and `view.height` (1 to the capture's output size), `view.samplesPerAxis` (1–32),
1576
1971
  `view.format` (`"srgb-8"` default or `"srgb-16"`) and `view.region` (a rectangle in display pixels, byte-identical to
1577
1972
  the same pixels of the whole view, for tiling across workers). The capture's file-format options (`format`,
@@ -1605,9 +2000,23 @@ what ran (see [Rendering on the GPU](https://github.com/Photivra/photivra/blob/m
1605
2000
  `signal` applies to preparing; give each render its own. Keep one prepared view per request in each worker: measured on
1606
2001
  a 24 MP request, an 8 × 8 region took 6–40 ms through a prepared view instead of 0.24–0.41 s as its own call (Speed).
1607
2002
 
2003
+ **Highlight count (#533).** `diagnostics.highlight.saturatedPixelCount` is the number of the real capture's output pixels
2004
+ in the rendered region that are saturated (a clipped contributing RAW site), as `simulateCapture` reports it. It is the
2005
+ expected value over the noise (`statistic: "expected-value"`), because the capture's own count is one noise sample:
2006
+
2007
+ - it equals the capture's count where every site is far from white;
2008
+ - it is a real number, not an integer;
2009
+ - the counts of regions that tile a view add up to the view's count;
2010
+ - it changes no sample;
2011
+ - with AHD it counts the sites the view's AHD candidates read, so near a clipped corner it can be lower than the capture's
2012
+ count.
2013
+
2014
+ See [Highlight count](https://github.com/Photivra/photivra/blob/main/docs/DISPLAY_VIEW.md#highlight-count-533).
2015
+
1608
2016
  **Not supported** (refused with an `InvalidConfigurationError`): `lens.pupilSampling`, `lens.projection`,
1609
2017
  `lens.distortion`, `lens.vignetting`, `lens.psf`, camera rotation, `output.whiteBalance: "auto"`, the luminance tone
1610
- curves, `output.highlightMode: "reconstruct"` and `output.region` (use `view.region`). `lens.defocus` is rendered as a
2018
+ curves, `output.highlightMode: "reconstruct"` and `output.region` (use `view.region`). `output.exposureCompensationStops` is
2019
+ supported (#556). `lens.defocus` is rendered as a
1611
2020
  site kernel in the expected signal (#492; see [Optics](#optics)). `onProgress` is not called; `signal` cancels.
1612
2021
 
1613
2022
  The result is labelled `kind: "display-view"` with a `label` for the user interface; it has no RAW, plan or file, and
@@ -1643,6 +2052,15 @@ provided, for rights reasons.
1643
2052
  independent test). The original analysis is G. B. Airy, "On the Diffraction of an Object-glass with
1644
2053
  Circular Aperture", *Transactions of the Cambridge Philosophical Society* 5, 1835, pp. 283–291. The
1645
2054
  engine's implementation and its tests are its own (see [PSF foundation](https://github.com/Photivra/photivra/blob/main/docs/PSF_FOUNDATION.md#diffraction-limited-sampled-profile-21)).
2055
+ - Bokeh character (`lens.defocus` blades, spherical aberration and cat's eye, `calculateDefocusBokehKernel`, #553): the
2056
+ transverse ray aberration ε = −(R/r_p)·∂W/∂ρ of primary spherical aberration from Wyant & Creath 1992 (above), §II.A; the
2057
+ mechanical-vignetting model from J. E. Greivenkamp, OPTI-502 Optical Design and Instrumentation I course notes, University
2058
+ of Arizona, sec. 10 (free; as `calculateMechanicalVignetting`); the photographic account of front and back bokeh under
2059
+ spherical aberration in H. H. Nasse, "Depth of Field and Bokeh", Carl Zeiss Camera Lens Division, March 2010, free at
2060
+ <https://lenspire.zeiss.com/photo/app/uploads/2022/02/technical-article-depth-of-field-and-bokeh.pdf>. The intensity
2061
+ profiles follow from the ray map; the polygon clipping (Sutherland–Hodgman) and the shoelace area are elementary geometry.
2062
+ The zoned (sectioned) treatment of the field-varying cat's eye is surveyed in L. Denis et al., "Fast approximations of
2063
+ shift-variant blur", *International Journal of Computer Vision* 115 (2015) 253–278.
1646
2064
  - Print planning (`calculatePrintPlan`, `calculatePrintSizeLimit`): Z. Alomar, K. D. Bojikian, "Visual Acuity
1647
2065
  Testing in Adults", EyeWiki, American Academy of Ophthalmology, last edited June 16, 2026, free to read at
1648
2066
  <https://eyewiki.aao.org/Visual_Acuity_Testing_in_Adults> (the minimum angle of resolution of an ideal eye under optimal