@photivra/engine 2.14.1 → 2.16.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 (80) hide show
  1. package/CHANGELOG.md +69 -1
  2. package/CITATION.cff +1 -1
  3. package/README.md +6 -6
  4. package/SECURITY.md +1 -1
  5. package/THIRD_PARTY.md +14 -0
  6. package/dist/capture/native-environment-lean.js +1 -1
  7. package/dist/capture/native-environment-lean.js.map +1 -1
  8. package/dist/capture/native-raw-development.d.ts +15 -0
  9. package/dist/capture/native-raw-development.js +12 -6
  10. package/dist/capture/native-raw-development.js.map +1 -1
  11. package/dist/capture/raw-development-kernel.d.ts +10 -3
  12. package/dist/capture/raw-development-kernel.js +25 -6
  13. package/dist/capture/raw-development-kernel.js.map +1 -1
  14. package/dist/core/internal-radiance-evaluator.d.ts +5 -2
  15. package/dist/core/internal-radiance-evaluator.js.map +1 -1
  16. package/dist/core/version.d.ts +1 -1
  17. package/dist/core/version.js +1 -1
  18. package/dist/core/version.js.map +1 -1
  19. package/dist/facade/defocus-image-space-provider.d.ts +109 -0
  20. package/dist/facade/defocus-image-space-provider.js +359 -0
  21. package/dist/facade/defocus-image-space-provider.js.map +1 -0
  22. package/dist/facade/diffraction-provider.d.ts +16 -0
  23. package/dist/facade/diffraction-provider.js +3 -3
  24. package/dist/facade/diffraction-provider.js.map +1 -1
  25. package/dist/facade/display-view.d.ts +21 -0
  26. package/dist/facade/display-view.js +66 -8
  27. package/dist/facade/display-view.js.map +1 -1
  28. package/dist/facade/simulate-capture.d.ts +112 -3
  29. package/dist/facade/simulate-capture.js +235 -48
  30. package/dist/facade/simulate-capture.js.map +1 -1
  31. package/dist/facade/simulate-display-view.js +7 -3
  32. package/dist/facade/simulate-display-view.js.map +1 -1
  33. package/dist/gpu/display-view-quality-gpu.d.ts +2 -0
  34. package/dist/gpu/display-view-quality-gpu.js +57 -13
  35. package/dist/gpu/display-view-quality-gpu.js.map +1 -1
  36. package/dist/gpu/display-view-speed-gpu.d.ts +2 -0
  37. package/dist/gpu/display-view-speed-gpu.js +45 -16
  38. package/dist/gpu/display-view-speed-gpu.js.map +1 -1
  39. package/dist/gpu/image-kernel-accelerator.js +3 -0
  40. package/dist/gpu/image-kernel-accelerator.js.map +1 -1
  41. package/dist/gpu/wgsl-display-view-quality-kernel.d.ts +4 -0
  42. package/dist/gpu/wgsl-display-view-quality-kernel.js +53 -2
  43. package/dist/gpu/wgsl-display-view-quality-kernel.js.map +1 -1
  44. package/dist/gpu/wgsl-display-view-speed-kernel.d.ts +15 -0
  45. package/dist/gpu/wgsl-display-view-speed-kernel.js +42 -1
  46. package/dist/gpu/wgsl-display-view-speed-kernel.js.map +1 -1
  47. package/dist/index.d.ts +3 -2
  48. package/dist/index.js +2 -1
  49. package/dist/index.js.map +1 -1
  50. package/dist/optics/defocus-disc-kernel.d.ts +91 -1
  51. package/dist/optics/defocus-disc-kernel.js +59 -5
  52. package/dist/optics/defocus-disc-kernel.js.map +1 -1
  53. package/dist/optics/diffraction-psf-profile.d.ts +9 -4
  54. package/dist/optics/diffraction-psf-profile.js +14 -6
  55. package/dist/optics/diffraction-psf-profile.js.map +1 -1
  56. package/dist/optics/lens-psf-kernels.d.ts +13 -2
  57. package/dist/optics/lens-psf-kernels.js +23 -6
  58. package/dist/optics/lens-psf-kernels.js.map +1 -1
  59. package/dist/optics/sensor-environment-query.d.ts +31 -0
  60. package/dist/optics/sensor-environment-query.js +45 -13
  61. package/dist/optics/sensor-environment-query.js.map +1 -1
  62. package/dist/optics/site-diffraction-kernel.d.ts +1 -1
  63. package/dist/optics/site-diffraction-kernel.js +37 -17
  64. package/dist/optics/site-diffraction-kernel.js.map +1 -1
  65. package/dist/processing/development-detail.d.ts +132 -0
  66. package/dist/processing/development-detail.js +325 -0
  67. package/dist/processing/development-detail.js.map +1 -0
  68. package/docs/AGENT_GUIDE.md +1 -0
  69. package/docs/API_REFERENCE.md +17 -1
  70. package/docs/QUICK_START.md +210 -16
  71. package/docs/USAGE.md +31 -3
  72. package/docs/api/capture-native-raw-development.md +16 -0
  73. package/docs/api/core-version.md +1 -1
  74. package/docs/api/exports.json +47 -2
  75. package/docs/api/facade-simulate-capture.md +129 -4
  76. package/docs/api/optics-defocus-disc-kernel.md +87 -0
  77. package/docs/api/optics-diffraction-psf-profile.md +6 -3
  78. package/docs/api/optics-lens-psf-kernels.md +3 -2
  79. package/llms.txt +1 -0
  80. package/package.json +1 -1
@@ -99,6 +99,9 @@ modelling choices (such as "no lens PSF") as `kind: "model"`.
99
99
  | `output.toneCurve` | `"identity"` | Any `SdrToneCurve`, for example `"positive-reinhard-per-channel"`. |
100
100
  | `output.toneCurveParameters` | none | Required by the luminance curves (`extended-reinhard-luminance`, `naka-rushton-luminance`); the facade invents no curve parameters. |
101
101
  | `output.highlightMode` | `"clip"` | Or `"reconstruct"`. |
102
+ | `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)). |
103
+ | `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)). |
104
+ | `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
105
  | `output.whiteBalance` | `"scene-illuminant"` | Also `"auto"`, `"none"`, `{ kelvin, tint? }` and `{ channelGains }`. |
103
106
  | `output.jpegQuantizationStep` | 2 | JPEG only: constant step 1–255, not a manufacturer quality scale. |
104
107
  | `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)). |
@@ -631,6 +634,41 @@ with diffraction, the four-spot filter and AHD.
631
634
  | `{ kelvin, tint? }` | A colour-temperature setting on the Planckian locus (tint as Duv). |
632
635
  | `{ channelGains }` | Manual channel gains. |
633
636
 
637
+ ## Development exposure push (#556)
638
+
639
+ `output.exposureCompensationStops` brightens (or darkens) the same RAW in development, like a raw editor's exposure
640
+ slider: underexpose, then push the shadows up. It takes −2 to +5 stops (`NATIVE_RAW_DEVELOPMENT_EXPOSURE_RANGE_STOPS`),
641
+ whole or fractional; the default 0 gives byte-identical results.
642
+
643
+ ```ts
644
+ import { simulateCapture } from "@photivra/engine";
645
+
646
+ // Three stops under (1/1000 s instead of 1/125 s), brightened three stops in development.
647
+ const pushed = await simulateCapture({ scene: { preset: "uniform" }, exposure: { shutterSeconds: 1 / 1000 },
648
+ output: { width: 64, height: 64, exposureCompensationStops: 3 } });
649
+ console.log(pushed.assumptions.find((a) => a.id === "output.exposureCompensationStops")?.value); // { stops: 3, gain: 8, ... }
650
+ ```
651
+
652
+ - **What it does.** Each site's black-subtracted, white-normalized value (code − black)/(white − black) is multiplied
653
+ by 2^stops before demosaic, white balance, the highlight mode, colour, the tone curve and encoding. It is a
654
+ development setting, not a capture exposure: no photons are added.
655
+ - **Shadows keep their noise.** The pushed image shows the RAW's own shot, dark and read noise and its ADC quantization
656
+ steps, scaled by the gain. A sensor with less read noise or more bits per sample recovers shadows more cleanly: a
657
+ 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
658
+ normal exposure's mean, with about √8 times its shot noise.
659
+ - **Highlights clip.** A site pushed to or past white clips at white and counts in
660
+ `diagnostics.highlight.saturatedPixelCount`; `highlightMode: "reconstruct"` rolls those pixels off toward neutral.
661
+ Nothing above white is recovered.
662
+ - **Where it applies.** The developed samples (`image.samples`, also with format `"dng"`), JPEG, PNG (every encoding),
663
+ both demosaics, `simulateIdealReference` and `simulateDisplayView` (its per-site readout moments are scaled by the
664
+ gain) honour it. The RAW codes, `result.raw` and the DNG are the unmodified RAW. `output.whiteBalance: "auto"` is
665
+ measured on the unpushed RAW. `request.development.exposureCompensationStops` carries a non-zero push, so
666
+ `createNativeRawDevelopmentTask` replays it.
667
+ - **Reported** in the assumption `output.exposureCompensationStops` (`{ stops, gain, appliedTo, dng }`), only when the
668
+ option is given.
669
+ - **Not the same as `renderingExposureEv`.** The SDR rendering profile's exposure scales the developed colour after the
670
+ highlight clip, so it cannot clip a pushed site; the facade keeps it at 0.
671
+
634
672
  ## Modelling choices
635
673
 
636
674
  These choices appear in `assumptions` as `kind: "model"` entries:
@@ -644,7 +682,11 @@ These choices appear in `assumptions` as `kind: "model"` entries:
644
682
  lens capability. By default no PSF (point-spread function) or diffraction, vignetting, distortion,
645
683
  lateral colour or stray light is applied. The tier's optical data exist only at f/4 and 5 m, and
646
684
  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).
685
+ you supply; diffraction of an ideal lens only with `lens.diffraction` (2.1); defocus only with
686
+ `lens.defocus`, as the geometric thin-lens disc (an approximation). With camera rotation, a projection,
687
+ distortion or vignetting the disc is applied in image space with the same diameter at every field point,
688
+ a further approximation whose bounds the `lens.defocus` assumption records (`fieldVariation`, #548; see
689
+ [Optics](#optics)).
648
690
  - **Sensor.** Square pixels with a fill factor of 1 and no anti-aliasing filter. A global
649
691
  electronic shutter unless `motion.shutter` is `"rolling"`. A synthetic uniform dark current of
650
692
  1 e⁻/s at 20 °C (the tiers declare none). The charge capacity equals the body's base-ISO saturation signal,
@@ -655,8 +697,9 @@ These choices appear in `assumptions` as `kind: "model"` entries:
655
697
  false-colour artefacts; `"ahd"` and `lens.diffraction` reduce them.
656
698
  - **Scene.** A planar Lambertian surface: radiance = reflectance × irradiance / π, with illuminance
657
699
  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.
700
+ - **Rendering.** Linear sRGB with RAW white at display white, no exposure push (unless
701
+ `output.exposureCompensationStops` asks for one on the RAW), out-of-gamut components clipped, then the sRGB
702
+ transfer function.
660
703
 
661
704
  ## Output formats
662
705
 
@@ -807,6 +850,46 @@ Why these containers:
807
850
  Pillow 12.3 and ImageMagick 6.9 read the PNG text chunks and the JPEG comment, and Python's XML parser
808
851
  reads both XMP packets.
809
852
 
853
+ ## Sharpening and diffraction correction (#552)
854
+
855
+ Two development stages, both off by default. Without them a request takes the same path and gives the same bytes as before.
856
+
857
+ ```ts
858
+ import { simulateCapture } from "@photivra/engine";
859
+
860
+ const result = await simulateCapture({
861
+ scene: { preset: "slanted-edge" },
862
+ lens: { fNumber: 16, diffraction: "circular-aperture" },
863
+ output: { width: 16, height: 16, format: "jpeg", diffractionCorrection: {}, sharpening: { radiusPixels: 1, amount: 0.5 } }
864
+ });
865
+ // The value holds cutoffCyclesPerPixel (the diffraction cut-off) and recoversBeyondCutoff: false.
866
+ console.log(result.assumptions.find((a) => a.id === "output.diffractionCorrection")?.value);
867
+ console.log(result.diagnostics.detail); // { diffractionCorrection: {...}, sharpening: {...} }
868
+ ```
869
+
870
+ - **Order:** development exposure push on the RAW (`output.exposureCompensationStops`, when given) → demosaic → colour and
871
+ white balance → **diffraction correction** (linear) → rendering exposure and tone curve → gamut handling and sRGB transfer →
872
+ **unsharp mask** → quantization. The `output.developmentOrder` assumption lists it. A push clips at white before the
873
+ correction, on this path as on the default one.
874
+ - **`output.diffractionCorrection`** needs `lens.diffraction`. It deconvolves the developed linear colour with the
875
+ diffraction OTF of the ideal circular pupil at the capture's working f-number, through a bounded Wiener gain
876
+ min(maximumGain, H(1 + α)/(H² + α)) built as a (2R + 1)² filter. Defaults: 550 nm, α = 0.01, gain bound 4, R = the Airy
877
+ 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
878
+ stays lost. Below it, contrast is only partly restored and noise is amplified by up to the reported `responsePeakGain`.
879
+ - **`output.sharpening`** is an unsharp mask on the sRGB-encoded values: y = x + amount·(x − G_σ∗x) where the difference is at
880
+ least `threshold`. Defaults: σ = 1 pixel, amount 0.5, threshold 0. It creates halos on both sides of an edge and sharpens
881
+ noise; it adds no detail. Not with a wide-gamut `output.colorEncoding` or `output.gamutMapping: "perceptual"`.
882
+ - **Reported:** the `output.diffractionCorrection` and `output.sharpening` assumptions (settings, cut-off, measured filter
883
+ response, defaults used), `diagnostics.detail` (ringing, clipping and halo counts) and `diagnostics.options`.
884
+ - **Not combinable** with `output.region`; not offered by `simulateDisplayView` or `simulateIdealReference`. The DNG holds the
885
+ RAW codes and is unchanged; only `image.samples` (and the JPEG or PNG made from them) change.
886
+ - **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
887
+ development took 8 ms by default, 15 ms with sharpening and 18–20 ms with diffraction correction (R = 3–5), one run each.
888
+
889
+ The trade-offs of these and the lens corrections (corner resolution after distortion correction, corner noise after
890
+ vignetting correction, residual longitudinal CA, halo overshoot, nothing beyond the cut-off) are measured in
891
+ [Digital optics foundation](https://github.com/Photivra/photivra/blob/main/docs/DIGITAL_OPTICS_FOUNDATION.md#sharpening-diffraction-correction-and-their-trade-offs-552).
892
+
810
893
  ## Demosaic
811
894
 
812
895
  `output.demosaic` selects:
@@ -871,6 +954,10 @@ The optics options feed only what the environment site contract accepts:
871
954
  lit circular pupil (scalar Fraunhofer diffraction), aberration-free and in focus, with pupil diameter
872
955
  f/N and the thin-lens image distance (working f-number N·v/f). Each kernel holds the PSF energy of
873
956
  site-sized cells, one kernel per spectral node.
957
+ - Every spectral step works (2.15.0): the 1–100 nm range of `sensor.spectralStepNanometers` gives 4–400 nodes,
958
+ and each node gets its own exact kernel. Before 2.15.0 a step under 6.25 nm (more than 64 nodes, for example
959
+ the 5 nm step recommended for color work) failed with "wavelengthsNm must list 1 to 64 wavelengths." Time and
960
+ memory are linear in the node count; measurements are in [PSF foundation](https://github.com/Photivra/photivra/blob/main/docs/PSF_FOUNDATION.md#spectral-nodes-2150).
874
961
  - Sources: the Airy pattern is the textbook Fraunhofer diffraction pattern of a circular aperture
875
962
  (Born & Wolf, *Principles of Optics*, 7th ed., §8.5.2; Goodman, *Introduction to Fourier Optics*,
876
963
  4th ed., §4.4). The kernels come from the pupil's incoherent optical transfer function (Goodman,
@@ -903,7 +990,7 @@ The optics options feed only what the environment site contract accepts:
903
990
  is sampled at its centre). It cannot be
904
991
  combined with `lens.psf` or `lens.pupilSampling`.
905
992
 
906
- - `lens.defocus: { model: "geometric-disc" }` (#492; the capture renders it, the display view does not yet) models defocus of the flat scene plane when
993
+ - `lens.defocus: { model: "geometric-disc" }` (#492; the capture and the display view render it) models defocus of the flat scene plane when
907
994
  `lens.focusDistanceM` differs from `subjectDistanceM`.
908
995
  - The model is a geometric approximation for an ideal uniform circular pupil. Each in-focus image point spreads
909
996
  into a uniform disc of diameter c = f²·|s − d| / (N·d·(s − f)) on the sensor (`calculateDefocusCircle`, thin-lens
@@ -920,6 +1007,13 @@ The optics options feed only what the environment site contract accepts:
920
1007
  boundaries. Elsewhere they agree within the site sampling of the edge.
921
1008
  - The kernel radius is capped at 64 sites, a 129 × 129 window (`DEFOCUS_DISC_KERNEL_MAXIMUM_RADIUS_SITES`). A larger
922
1009
  blur is refused with an error that names the cap and the radius needed; the disc is never clipped.
1010
+ - **Check before rendering (#532):** `calculateDefocusDiscKernelRadius({ focalLengthMm, aperture, focusDistanceM,
1011
+ subjectDistanceM, pixelPitchMicrometers })` returns the disc's diameter in µm and in sites, the kernel radius it
1012
+ needs, `withinLimit` and `excessKernelRadiusSites`, without rendering. It runs the capture's own resolution and limit
1013
+ test, so it agrees with the refusal of `simulateCapture`, `simulateDisplayView` and `prepareDisplayView` at the
1014
+ boundary (`test/defocus-disc-limit.test.ts`, at two adjacent focus distances). Pass the f-number the capture uses
1015
+ (in an automatic mode, `exposure.settings.fNumber`). The limit is on the disc alone: with `lens.diffraction` the
1016
+ combined radius has no further limit. The other refusals below are not checked.
923
1017
  - **The capture renders it** (#492, step 2a). The scene radiance on the site lattice is convolved with the disc,
924
1018
  the same kernel at every spectral node, and each site query is answered with the result. This is the object-space
925
1019
  convolution that `lens.diffraction` uses.
@@ -942,9 +1036,88 @@ The optics options feed only what the environment site contract accepts:
942
1036
  [Display view, Defocus](https://github.com/Photivra/photivra/blob/main/docs/DISPLAY_VIEW.md#defocus).
943
1037
  - In focus (c = 0) no kernel is applied, and the image is byte-identical to the same request without the option.
944
1038
  `simulateIdealReference`, which has no camera effects, only records the disc.
945
- - **Refused out of focus** with camera rotation, `lens.projection` or `lens.distortion`, and `lens.vignetting`. Each
946
- of these moves the scene points that a site sees off the site lattice, so the convolution does not apply. In
947
- focus they render as without `lens.defocus`.
1039
+ - **With camera rotation, `lens.projection`, `lens.distortion` or `lens.vignetting` (#548)** the capture applies the
1040
+ disc in image space instead. Each of these moves the scene points that a site sees off the plane lattice (or, for
1041
+ vignetting, gives each point its own radiance-to-irradiance factor), so the object-space convolution does not apply.
1042
+ - **Method.** For each spectral node, the sensor-plane irradiance before any PSF is formed on the sensor's site
1043
+ lattice: each lattice point's scene ray through the inverse distortion, the declared projection and (with rotation)
1044
+ the camera's orientation at that instant, times its field factor (vignetting throughput × distortion area factor
1045
+ 1/det J). That image is convolved with the disc, and each site query is answered with the result divided by the
1046
+ queried point's own field factor, which the environment multiplies back. This is the environment PSF quadrature with
1047
+ the disc as a sampled PSF (one scene query per kernel tap), summed in another order: a test checks it to 1e-12 per
1048
+ site in expected electrons, and the RAW codes equal those of the same request with the disc passed as `lens.psf`.
1049
+ With the identity mapping (`lens.projection: "rectilinear"`, no distortion) it gives the object-space path's RAW.
1050
+ - **Rotation.** Each environment temporal midpoint is a still camera, so the disc is applied to that instant's
1051
+ image in that instant's frame, then the midpoints are summed as before.
1052
+ - **Vignetting** is applied at each scene point before the disc spreads its light, as for any environment PSF. No
1053
+ multiplier is moved past the blur, so there is no commutation error to bound.
1054
+ The disc does not take the cat's-eye shape of mechanical (pupil) vignetting; the vignetting profile models
1055
+ illumination falloff only.
1056
+ - **With `lens.diffraction`** each node's diffraction kernel convolved with the disc is the image-space kernel, and
1057
+ `request.diffraction.execution` and the `lens.diffraction` assumption say `"image-space-convolution"`.
1058
+ - **What is approximate**, beyond the disc itself: the disc has the same diameter at every field point, centred on
1059
+ each point's chief ray. For the rectilinear, distortion-free thin lens that is exact (a fronto-parallel plane is
1060
+ imaged onto a plane, and the cone from the circular pupil cuts the sensor in a circle of the same size everywhere).
1061
+ For the other cases the `lens.defocus` assumption's `fieldVariation` records a bound for the request, evaluated on
1062
+ a 9 × 9 grid of sensor points over the frame (edges included):
1063
+ - `rotationMaximumDiameterChangeSites`: pitch or yaw tilts the scene plane in the rotated camera, so the disc of a
1064
+ scene point's axial depth there differs from the one used; the largest change over the rendered instants, in
1065
+ sites (0 for roll alone).
1066
+ - `distortionMaximumLocalScaleDeviation`: the largest departure from 1 of the distortion's local scale (singular
1067
+ values of d distorted / d ideal), the relative disc-size difference to a model that blurs the undistorted image
1068
+ and then distorts it. Which model a real lens follows depends on where its distortion arises; the first-order
1069
+ argument (the image-side cone is bounded by the exit pupil, which distortion does not change) favours the
1070
+ constant disc used here.
1071
+ - `projectionMaximumDiameterChangeSites`: for a fisheye or equirectangular projection, the disc of the distance
1072
+ along each scene point's ray instead of its axial depth. The engine has no pupil model for these projections
1073
+ (`lens.pupilSampling` refuses them; a traced reference waits for the 3D ray provider, #358), so a real lens may lie
1074
+ anywhere between the two.
1075
+ - **Refused:** camera rotation with `motion.shutter: "rolling"` (each row has its own instants, so each row would
1076
+ need its own image of the 2R + 1 rows the disc reaches), and a vignetting or distortion profile that does not
1077
+ reach the frame's corners plus the kernel radius (the message names the reach). `lens.pupilSampling` stays refused
1078
+ with projection and distortion. `simulateDisplayView` and `prepareDisplayView` refuse rotation, projection,
1079
+ distortion and vignetting in any focus (the expected-signal tables assume the rectilinear lattice and one field
1080
+ factor), so they have nothing to add here.
1081
+ - **Sampling** is the lattice sampling below, unchanged. Measured against a fine-grid oracle: the environment PSF
1082
+ quadrature with the disc sampled 5 times finer than the site pitch (3 times with the box aperture), on a 16 × 12
1083
+ 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
1084
+ difference in expected electrons per site (max / RMS). Each image-space case stays within the rectilinear capture's
1085
+ error (the motion-blur rows are lower because the rotation smears the edge):
1086
+
1087
+ | Case (#548) | Point aperture | 4 × 4 box |
1088
+ | --- | --- | --- |
1089
+ | rectilinear, no option (object-space path) | 0.110 / 0.026 | 0.032 / 0.0060 |
1090
+ | `lens.vignetting` (r2 −0.3 at 0.2 mm) | 0.110 / 0.026 | 0.032 / 0.0061 |
1091
+ | `"fisheye-equisolid-angle"` | 0.110 / 0.026 | 0.032 / 0.0060 |
1092
+ | radial distortion, k1 −0.2 at 0.25 mm | 0.110 / 0.026 | 0.032 / 0.0060 |
1093
+ | rotation pitch 0.2, yaw 0.5, roll 3 rad/s, 2 midpoints | 0.069 / 0.016 | 0.017 / 0.0029 |
1094
+ | all of them (equidistant fisheye, Brown–Conrady) | 0.065 / 0.015 | 0.015 / 0.0027 |
1095
+
1096
+ The recorded bounds on these frames were small: distortion local scale 0.046 (radial) and 0.044 (Brown–Conrady),
1097
+ 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
1098
+ projection and distortion bounds grow with the field: read them from the assumption. On the app's full frame
1099
+ (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
1100
+ (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
1101
+ 0.02 rad/s at 1/125 s 8e-4 sites.
1102
+ - **Cost** at the app's defaults (Speed preset: 384 × 256 whole frame at 93.75 µm with the 2 × 2 box; Siemens star;
1103
+ 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
1104
+ row; a shared machine, so the slow rows varied by up to a third between sessions). The image-space convolution itself
1105
+ is `diagnostics.timingsMs.defocus`; with one kernel for every node (no diffraction) it convolves once per scene
1106
+ material in each band instead of once per spectral node, the same sum regrouped.
1107
+
1108
+ | Option | In focus | Focus 1 m (R 3) | Focus 0.6 m (R 6) |
1109
+ | --- | --- | --- | --- |
1110
+ | none (object-space path) | 0.06 s | 0.05 s | 0.07 s |
1111
+ | `lens.vignetting` | 1.0 s | 1.6 s [1.5–1.6] (convolution 0.27 s) | 1.7 s (0.38 s) |
1112
+ | `"fisheye-equisolid-angle"` | 11 s | 15–19 s (0.18 s) | |
1113
+ | radial distortion | 18–21 s | 19–20 s (0.36 s) | |
1114
+ | 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) |
1115
+ | fisheye + distortion + vignetting + rotation | 94–97 s | 101–124 s (3.4–5.9 s) | |
1116
+
1117
+ The in-focus cost of a lens mapping is the environment's per-site query path, which `lens.defocus` does not change;
1118
+ out of focus adds the band convolutions and one lattice lookup per query (a forward map of the query's ray to the
1119
+ sensor). Without a lens mapping the lean executor reads the values by lattice key, as for the diffraction provider.
1120
+ In focus every request is byte-identical to the same request without the option.
948
1121
  - **Sampling.** The scene enters at the lattice points the sites query: site centres, or the pixel-aperture nodes
949
1122
  and anti-aliasing spots. So near an edge the result can differ from the continuous disc integral by the lattice's
950
1123
  point sampling of the edge. Measured on a 16 × 12 slanted edge, as the relative difference in expected electrons
@@ -1519,7 +1692,11 @@ resolves them).
1519
1692
 
1520
1693
  **How it is computed** (details and error analysis in [DISPLAY_VIEW.md](https://github.com/Photivra/photivra/blob/main/docs/DISPLAY_VIEW.md)):
1521
1694
 
1522
- - The expected value of each display pixel is a stratified Monte Carlo average over K × K real pixels of its rectangle
1695
+ - Where a display pixel's rectangle holds at most 25 real pixels (and at most `view.samplesPerAxis`²), so views at least
1696
+ 1/5 of the frame's width, its expected value is the exact area mean (2.8.0, #393): every real pixel it touches is
1697
+ evaluated once and weighted by its exact overlap, with no sampling error. The `view.estimation` assumption names the
1698
+ method used.
1699
+ - Coarser display pixels take a stratified Monte Carlo average over K × K real pixels of their rectangle
1523
1700
  (`view.samplesPerAxis`, default 16), seeded and unbiased. For each sampled pixel the expected electrons of the real
1524
1701
  sites its demosaic reads are exact, and the clips and tone curve act on the pixel's own noise distribution. A pilot
1525
1702
  grid gives uniform areas K/4 samples per axis; their error is zero anyway.
@@ -1534,10 +1711,12 @@ Validated against real full-resolution captures resized in linear light: three s
1534
1711
 
1535
1712
  **Cost.** Where a display pixel covers more than 25 real pixels, the cost follows the detailed display pixels ×
1536
1713
  `samplesPerAxis`², not the real pixel count; at 25 or fewer (the exact area mean) it follows the real pixels the region
1537
- 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
1538
- 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
1539
- 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),
1540
- `node scripts/benchmark-display-view.mjs --app`). The table below is the 2.6.0 record of whole views against the
1714
+ 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
1715
+ 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,
1716
+ 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
1717
+ 0.18 s on a 4-vCPU 2.80 GHz Xeon (Node 24.21.0). Both measurements are in
1718
+ [the tables](https://github.com/Photivra/photivra/blob/main/docs/DISPLAY_VIEW.md#a-device-pixel-view-rendered-in-parts), from
1719
+ `node scripts/benchmark-display-view.mjs --app`. The table below is the 2.6.0 record of whole views against the
1541
1720
  whole-frame view of the same request (Siemens star, f/8, ISO 100, one run, same host; every row, and the times before
1542
1721
  the exact speed-ups, in [DISPLAY_VIEW.md](https://github.com/Photivra/photivra/blob/main/docs/DISPLAY_VIEW.md#cost)):
1543
1722
 
@@ -1552,9 +1731,10 @@ the exact speed-ups, in [DISPLAY_VIEW.md](https://github.com/Photivra/photivra/b
1552
1731
 
1553
1732
  Quality costs more on small pixels. The diffraction kernel spans several real sites, and at 1.4 µm even ISO 100 is
1554
1733
  noisy enough for the AHD correction to run on every sampled pixel; its correction states are kept for later views of
1555
- the same sensor, exposure and development in the same JavaScript realm (a second 384-wide phone view of the same
1556
- request took 73 s instead of 103 s). The results are byte-identical to those of the display view as first merged,
1557
- which took 222 s, 450 s and 837 s on the phone.
1734
+ the same sensor, exposure and development in the same JavaScript realm (2.6.0: a second 384-wide phone view of the same
1735
+ request took 73 s instead of 103 s). In 2.6.0 the results were byte-identical to those of the display view as first
1736
+ merged, which took 222 s, 450 s and 837 s on the phone; later releases changed some views' values (for example the
1737
+ exact area mean, 2.8.0; AHD's choice, 2.13.0).
1558
1738
 
1559
1739
  **Options.** `view.width` and `view.height` (1 to the capture's output size), `view.samplesPerAxis` (1–32),
1560
1740
  `view.format` (`"srgb-8"` default or `"srgb-16"`) and `view.region` (a rectangle in display pixels, byte-identical to
@@ -1589,9 +1769,23 @@ what ran (see [Rendering on the GPU](https://github.com/Photivra/photivra/blob/m
1589
1769
  `signal` applies to preparing; give each render its own. Keep one prepared view per request in each worker: measured on
1590
1770
  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).
1591
1771
 
1772
+ **Highlight count (#533).** `diagnostics.highlight.saturatedPixelCount` is the number of the real capture's output pixels
1773
+ in the rendered region that are saturated (a clipped contributing RAW site), as `simulateCapture` reports it. It is the
1774
+ expected value over the noise (`statistic: "expected-value"`), because the capture's own count is one noise sample:
1775
+
1776
+ - it equals the capture's count where every site is far from white;
1777
+ - it is a real number, not an integer;
1778
+ - the counts of regions that tile a view add up to the view's count;
1779
+ - it changes no sample;
1780
+ - 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
1781
+ count.
1782
+
1783
+ See [Highlight count](https://github.com/Photivra/photivra/blob/main/docs/DISPLAY_VIEW.md#highlight-count-533).
1784
+
1592
1785
  **Not supported** (refused with an `InvalidConfigurationError`): `lens.pupilSampling`, `lens.projection`,
1593
1786
  `lens.distortion`, `lens.vignetting`, `lens.psf`, camera rotation, `output.whiteBalance: "auto"`, the luminance tone
1594
- curves, `output.highlightMode: "reconstruct"` and `output.region` (use `view.region`). `lens.defocus` is rendered as a
1787
+ curves, `output.highlightMode: "reconstruct"` and `output.region` (use `view.region`). `output.exposureCompensationStops` is
1788
+ supported (#556). `lens.defocus` is rendered as a
1595
1789
  site kernel in the expected signal (#492; see [Optics](#optics)). `onProgress` is not called; `signal` cancels.
1596
1790
 
1597
1791
  The result is labelled `kind: "display-view"` with a `label` for the user interface; it has no RAW, plan or file, and
package/docs/USAGE.md CHANGED
@@ -1455,6 +1455,23 @@ console.log(blur.value.diameterMm);
1455
1455
 
1456
1456
  You supply the circle-of-confusion criterion used for depth of field (DOF). These are ideal geometric thin-lens models. They do not include diffraction or real-lens aberrations in the DOF criterion.
1457
1457
 
1458
+ `simulateCapture` and the display view render the defocus blur disc with `lens.defocus: { model: "geometric-disc" }`, up to a kernel radius of `DEFOCUS_DISC_KERNEL_MAXIMUM_RADIUS_SITES` (64 sites, a disc 129 sites across) and refuse a larger one. Use `calculateDefocusDiscKernelRadius()` to check a request before rendering it. It runs the capture's own resolution and limit test, so it agrees with the capture's refusal. Pass the f-number the capture uses (in an automatic exposure mode, the resolved one) and the sensor's pixel pitch:
1459
+
1460
+ ```ts
1461
+ import { calculateDefocusDiscKernelRadius } from "@photivra/engine";
1462
+
1463
+ const check = calculateDefocusDiscKernelRadius({
1464
+ focalLengthMm: 50,
1465
+ aperture: 1.4,
1466
+ focusDistanceM: 1,
1467
+ subjectDistanceM: 2,
1468
+ pixelPitchMicrometers: 6
1469
+ });
1470
+
1471
+ console.log(check.value.withinLimit); // false: lens.defocus refuses this request
1472
+ console.log(check.value.kernelRadiusSites, check.value.excessKernelRadiusSites); // 78, 14
1473
+ ```
1474
+
1458
1475
  See [Physics Foundation](https://github.com/Photivra/photivra/blob/main/docs/PHYSICS_FOUNDATION.md#thin-lens-focus-and-depth-of-field).
1459
1476
 
1460
1477
  ## Real-lens PSF profiles and complex-pupil reference
@@ -3276,6 +3293,9 @@ void develop;
3276
3293
 
3277
3294
  - `highlightMode: "reconstruct"` blends pixels above 0.8 of white towards neutral, instead of clipping
3278
3295
  each WB-scaled channel. It is an approximation, not a recovery of the clipped signal.
3296
+ - `exposureCompensationStops` (#556, −2 to +5, default 0) multiplies each site's black-subtracted, white-normalized
3297
+ value by 2^stops before demosaic and white balance. A site pushed to white clips and counts as saturated; the RAW
3298
+ codes are not changed. 0 develops byte for byte as before. The WebGPU accelerator rejects a non-zero push.
3279
3299
  - `extended-reinhard-luminance` takes `{ whiteLuminance }`, the luminance mapped to output white.
3280
3300
  - `createPhotographicExportPair(input, { signal })` accepts an optional `AbortSignal`.
3281
3301
 
@@ -7785,16 +7805,24 @@ import { simulateDisplayView } from "@photivra/engine";
7785
7805
  const view = await simulateDisplayView({ scene: { preset: "slanted-edge" }, sensor: { pixelPitchMicrometers: 3 }, output: { width: 600, height: 400 } },
7786
7806
  { width: 60, height: 40 });
7787
7807
  console.log(view.kind, view.view.scaleX, view.assumptions.find((a) => a.id === "view.definition")?.value);
7808
+ // #533: the real capture's clipped-highlight pixels in the rendered region, as the expected value of simulateCapture's count.
7809
+ console.log(view.diagnostics.highlight.saturatedPixelCount, view.diagnostics.highlight.statistic); // a real number, "expected-value"
7788
7810
  ```
7789
7811
 
7812
+ `diagnostics.highlight.saturatedPixelCount` is the expected number of the real capture's output pixels in the rendered
7813
+ region that have a clipped contributing site. `simulateCapture` counts the same pixels on its one noise sample, so the two
7814
+ are equal where every site is far from white. Counts of regions that tile a view add up to the whole view's count. See
7815
+ [Highlight count](https://github.com/Photivra/photivra/blob/main/docs/DISPLAY_VIEW.md#highlight-count-533).
7816
+
7790
7817
  #### Rendering a prepared view on the GPU (2.11, #481)
7791
7818
 
7792
7819
  `prepareDisplayView(request, { gpu })` chooses the renderer: `"off"` (the default) is the CPU reference, `"auto"` the GPU
7793
7820
  when a conforming device is available and the request is supported (else the CPU, labelled), `"require"` fails instead of
7794
7821
  falling back. `gpuDevice` hands the view a worker's `navigator.gpu` or an `ImageKernelAccelerator` whose device it shares.
7795
- Every render says which path ran in `diagnostics.fast.execution`, in the accelerator's words. No GPU display-view renderer
7796
- exists yet (#478), so today `"auto"` runs the CPU reference labelled `unsupported-on-backend` without requesting a device;
7797
- see [Rendering on the GPU](https://github.com/Photivra/photivra/blob/main/docs/DISPLAY_VIEW.md#rendering-on-the-gpu).
7822
+ Every render says which path ran in `diagnostics.fast.execution`, in the accelerator's words. The engine's renderer runs the
7823
+ Speed (#478) and Quality (#479) kernels on a conforming device for the requests it covers. Elsewhere `"auto"` runs the CPU
7824
+ reference and gives the reason (in Node without a WebGPU entry point, for example); see
7825
+ [Rendering on the GPU](https://github.com/Photivra/photivra/blob/main/docs/DISPLAY_VIEW.md#rendering-on-the-gpu).
7798
7826
 
7799
7827
  ```ts
7800
7828
  import { prepareDisplayView, type SimulateCaptureOptions } from "@photivra/engine";
@@ -31,6 +31,14 @@ export function createNativeRawDevelopmentTask(input: NativeRawDevelopmentInput,
31
31
 
32
32
  This call is synchronous; any supplied provider must follow its explicit synchronous contract.
33
33
 
34
+ ## NATIVE_RAW_DEVELOPMENT_EXPOSURE_RANGE_STOPS
35
+
36
+ Accepted range of `NativeRawDevelopmentInput.exposureCompensationStops` and `simulateCapture`'s `output.exposureCompensationStops` (#556).
37
+
38
+ ```ts
39
+ NATIVE_RAW_DEVELOPMENT_EXPOSURE_RANGE_STOPS = Object.freeze({ minimum: -2, maximum: 5 } as const)
40
+ ```
41
+
34
42
  ## NativeRawDevelopmentInput
35
43
 
36
44
  The exact contract is declared below; use the domain guide for assumptions and staged integration.
@@ -56,6 +64,14 @@ export interface NativeRawDevelopmentInput {
56
64
  * always true for a 2×2 Bayer pattern. Interior pixels are unchanged.
57
65
  */
58
66
  borderHandling?: RawReconstructionBorderHandling;
67
+ /**
68
+ * Development exposure push in stops (#556), from {@link NATIVE_RAW_DEVELOPMENT_EXPOSURE_RANGE_STOPS} −2 to +5; default 0.
69
+ * Each site's black-subtracted, white-normalized value is multiplied by 2^stops before demosaic, white balance, the
70
+ * highlight mode, colour and the tone curve, like a raw editor's exposure slider. A site whose pushed value reaches the
71
+ * white level is clipped there and counted as saturated (so `highlightMode: "reconstruct"` rolls it off); values below
72
+ * black stay signed. The RAW codes are not changed. 0 (or omitted) develops exactly as before, byte for byte.
73
+ */
74
+ exposureCompensationStops?: number;
59
75
  /** Counts the kept packed RAW plus the integer output. It does not count caller buffers, process heap or GPU memory. */
60
76
  maximumRetainedPayloadBytes: number;
61
77
  }
@@ -11,5 +11,5 @@ version they were made with. Proof-of-concept (POC), schema and model contracts
11
11
  own versions. Release checks confirm it matches package.json and the packed package.
12
12
 
13
13
  ```ts
14
- ENGINE_API_VERSION = "2.14.1" as const
14
+ ENGINE_API_VERSION = "2.16.0" as const
15
15
  ```
@@ -1,6 +1,6 @@
1
1
  {
2
- "packageVersion": "2.14.1",
3
- "engineApiVersion": "2.14.1",
2
+ "packageVersion": "2.16.0",
3
+ "engineApiVersion": "2.16.0",
4
4
  "exports": [
5
5
  {
6
6
  "name": "AbsoluteUncertaintyEstimate",
@@ -572,6 +572,16 @@
572
572
  "source": "src/optics/depth-of-field.ts",
573
573
  "kind": "type"
574
574
  },
575
+ {
576
+ "name": "calculateDefocusDiscKernelRadius",
577
+ "source": "src/optics/defocus-disc-kernel.ts",
578
+ "kind": "runtime"
579
+ },
580
+ {
581
+ "name": "CalculateDefocusDiscKernelRadiusInput",
582
+ "source": "src/optics/defocus-disc-kernel.ts",
583
+ "kind": "type"
584
+ },
575
585
  {
576
586
  "name": "calculateDefocusWavefront",
577
587
  "source": "src/optics/zernike-wavefront.ts",
@@ -2692,11 +2702,21 @@
2692
2702
  "source": "src/sensor/capture-mode.ts",
2693
2703
  "kind": "type"
2694
2704
  },
2705
+ {
2706
+ "name": "DEFOCUS_DISC_KERNEL_MAXIMUM_RADIUS_SITES",
2707
+ "source": "src/optics/defocus-disc-kernel.ts",
2708
+ "kind": "runtime"
2709
+ },
2695
2710
  {
2696
2711
  "name": "DefocusCircle",
2697
2712
  "source": "src/optics/depth-of-field.ts",
2698
2713
  "kind": "type"
2699
2714
  },
2715
+ {
2716
+ "name": "DefocusDiscKernelRadius",
2717
+ "source": "src/optics/defocus-disc-kernel.ts",
2718
+ "kind": "type"
2719
+ },
2700
2720
  {
2701
2721
  "name": "DefocusWavefront",
2702
2722
  "source": "src/optics/zernike-wavefront.ts",
@@ -2782,6 +2802,11 @@
2782
2802
  "source": "src/facade/display-view-gpu.ts",
2783
2803
  "kind": "type"
2784
2804
  },
2805
+ {
2806
+ "name": "DisplayViewHighlightDiagnostics",
2807
+ "source": "src/facade/simulate-capture.ts",
2808
+ "kind": "type"
2809
+ },
2785
2810
  {
2786
2811
  "name": "DisplayViewResult",
2787
2812
  "source": "src/facade/simulate-capture.ts",
@@ -4557,6 +4582,11 @@
4557
4582
  "source": "src/capture/native-environment-optical-stage.ts",
4558
4583
  "kind": "runtime"
4559
4584
  },
4585
+ {
4586
+ "name": "NATIVE_RAW_DEVELOPMENT_EXPOSURE_RANGE_STOPS",
4587
+ "source": "src/capture/native-raw-development.ts",
4588
+ "kind": "runtime"
4589
+ },
4560
4590
  {
4561
4591
  "name": "NATIVE_RAW_DNG_SCHEMA_VERSION",
4562
4592
  "source": "src/capture/native-raw-dng.ts",
@@ -8327,6 +8357,16 @@
8327
8357
  "source": "src/facade/simulate-capture.ts",
8328
8358
  "kind": "type"
8329
8359
  },
8360
+ {
8361
+ "name": "SimulateCaptureDetailDiagnostics",
8362
+ "source": "src/facade/simulate-capture.ts",
8363
+ "kind": "type"
8364
+ },
8365
+ {
8366
+ "name": "SimulateCaptureDiffractionCorrection",
8367
+ "source": "src/facade/simulate-capture.ts",
8368
+ "kind": "type"
8369
+ },
8330
8370
  {
8331
8371
  "name": "SimulateCaptureDngPart",
8332
8372
  "source": "src/facade/simulate-capture.ts",
@@ -8392,6 +8432,11 @@
8392
8432
  "source": "src/facade/simulate-capture.ts",
8393
8433
  "kind": "type"
8394
8434
  },
8435
+ {
8436
+ "name": "SimulateCaptureSharpening",
8437
+ "source": "src/facade/simulate-capture.ts",
8438
+ "kind": "type"
8439
+ },
8395
8440
  {
8396
8441
  "name": "SimulateCaptureWhiteBalance",
8397
8442
  "source": "src/facade/simulate-capture.ts",