@photivra/engine 2.19.0 → 2.20.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 (129) hide show
  1. package/CHANGELOG.md +112 -1
  2. package/CITATION.cff +1 -1
  3. package/NODE_SUPPORT.md +7 -5
  4. package/README.md +6 -6
  5. package/SECURITY.md +1 -1
  6. package/THIRD_PARTY.md +14 -10
  7. package/dist/capture/dng-lens-correction-opcodes.d.ts +32 -0
  8. package/dist/capture/dng-lens-correction-opcodes.js +119 -0
  9. package/dist/capture/dng-lens-correction-opcodes.js.map +1 -0
  10. package/dist/capture/native-environment-lean.js +1 -5
  11. package/dist/capture/native-environment-lean.js.map +1 -1
  12. package/dist/capture/native-raw-dng.d.ts +6 -0
  13. package/dist/capture/native-raw-dng.js +16 -2
  14. package/dist/capture/native-raw-dng.js.map +1 -1
  15. package/dist/capture/sensor-raw-producer.js +3 -5
  16. package/dist/capture/sensor-raw-producer.js.map +1 -1
  17. package/dist/core/shared-helpers.d.ts +2 -0
  18. package/dist/core/shared-helpers.js +4 -0
  19. package/dist/core/shared-helpers.js.map +1 -1
  20. package/dist/core/version.d.ts +1 -1
  21. package/dist/core/version.js +1 -1
  22. package/dist/core/version.js.map +1 -1
  23. package/dist/equipment/generic-lens-catalogue.d.ts +8 -2
  24. package/dist/equipment/generic-lens-catalogue.js +31 -2
  25. package/dist/equipment/generic-lens-catalogue.js.map +1 -1
  26. package/dist/equipment/generic-tier-assets.js +2 -3
  27. package/dist/equipment/generic-tier-assets.js.map +1 -1
  28. package/dist/equipment/generic-tier-presets.js +2 -3
  29. package/dist/equipment/generic-tier-presets.js.map +1 -1
  30. package/dist/facade/display-view-gpu.d.ts +7 -4
  31. package/dist/facade/display-view-gpu.js +4 -4
  32. package/dist/facade/display-view-gpu.js.map +1 -1
  33. package/dist/facade/display-view-lens-signal.d.ts +1 -1
  34. package/dist/facade/display-view-lens-signal.js +5 -3
  35. package/dist/facade/display-view-lens-signal.js.map +1 -1
  36. package/dist/facade/display-view.d.ts +3 -1
  37. package/dist/facade/display-view.js +1 -1
  38. package/dist/facade/display-view.js.map +1 -1
  39. package/dist/facade/fast-methods.d.ts +50 -0
  40. package/dist/facade/fast-methods.js +50 -0
  41. package/dist/facade/fast-methods.js.map +1 -0
  42. package/dist/facade/fast-resolution.d.ts +12 -0
  43. package/dist/facade/fast-resolution.js +18 -0
  44. package/dist/facade/fast-resolution.js.map +1 -0
  45. package/dist/facade/generic-lens-capture.d.ts +3 -2
  46. package/dist/facade/generic-lens-capture.js +3 -2
  47. package/dist/facade/generic-lens-capture.js.map +1 -1
  48. package/dist/facade/simulate-capture.d.ts +51 -11
  49. package/dist/facade/simulate-capture.js +144 -75
  50. package/dist/facade/simulate-capture.js.map +1 -1
  51. package/dist/facade/simulate-display-view.d.ts +3 -2
  52. package/dist/facade/simulate-display-view.js +22 -12
  53. package/dist/facade/simulate-display-view.js.map +1 -1
  54. package/dist/index.d.ts +7 -0
  55. package/dist/index.js +5 -0
  56. package/dist/index.js.map +1 -1
  57. package/dist/optics/aperture-diameter.d.ts +47 -0
  58. package/dist/optics/aperture-diameter.js +50 -0
  59. package/dist/optics/aperture-diameter.js.map +1 -0
  60. package/dist/optics/detail-limit.d.ts +182 -0
  61. package/dist/optics/detail-limit.js +336 -0
  62. package/dist/optics/detail-limit.js.map +1 -0
  63. package/dist/optics/generic-lens-psf.d.ts +18 -2
  64. package/dist/optics/generic-lens-psf.js +25 -2
  65. package/dist/optics/generic-lens-psf.js.map +1 -1
  66. package/dist/optics/scene-to-sensor-irradiance.d.ts +13 -0
  67. package/dist/optics/scene-to-sensor-irradiance.js +7 -0
  68. package/dist/optics/scene-to-sensor-irradiance.js.map +1 -1
  69. package/dist/output/crop-to-match-focal-length.d.ts +62 -0
  70. package/dist/output/crop-to-match-focal-length.js +63 -0
  71. package/dist/output/crop-to-match-focal-length.js.map +1 -0
  72. package/dist/output/developed-lens-corrections.js +2 -2
  73. package/dist/output/developed-lens-corrections.js.map +1 -1
  74. package/dist/output/generic-lens-corrections.d.ts +3 -1
  75. package/dist/output/generic-lens-corrections.js +8 -4
  76. package/dist/output/generic-lens-corrections.js.map +1 -1
  77. package/dist/output/lens-corrections.d.ts +12 -2
  78. package/dist/output/lens-corrections.js +22 -7
  79. package/dist/output/lens-corrections.js.map +1 -1
  80. package/dist/output/print-contrast-reference.js +1 -1
  81. package/dist/output/print-contrast-reference.js.map +1 -1
  82. package/dist/output/print-size-blur-noise.d.ts +110 -0
  83. package/dist/output/print-size-blur-noise.js +138 -0
  84. package/dist/output/print-size-blur-noise.js.map +1 -0
  85. package/dist/sensor/eqe-electron-rate.js +1 -7
  86. package/dist/sensor/eqe-electron-rate.js.map +1 -1
  87. package/dist/sensor/exposure-window.d.ts +2 -0
  88. package/dist/sensor/exposure-window.js +2 -2
  89. package/dist/sensor/exposure-window.js.map +1 -1
  90. package/dist/sensor/readout-timing.js +2 -23
  91. package/dist/sensor/readout-timing.js.map +1 -1
  92. package/dist/sensor/response-application-compatibility.js +1 -6
  93. package/dist/sensor/response-application-compatibility.js.map +1 -1
  94. package/dist/sensor/responsivity-photocurrent.js +1 -7
  95. package/dist/sensor/responsivity-photocurrent.js.map +1 -1
  96. package/dist/sensor/sensor-characteristics.d.ts +137 -0
  97. package/dist/sensor/sensor-characteristics.js +115 -0
  98. package/dist/sensor/sensor-characteristics.js.map +1 -0
  99. package/dist/sensor/spatial-sample-reduction.js +1 -5
  100. package/dist/sensor/spatial-sample-reduction.js.map +1 -1
  101. package/dist/sensor/spectral-quadrature.js +1 -6
  102. package/dist/sensor/spectral-quadrature.js.map +1 -1
  103. package/dist/sensor/temporal-photo-signal.d.ts +3 -0
  104. package/dist/sensor/temporal-photo-signal.js +5 -2
  105. package/dist/sensor/temporal-photo-signal.js.map +1 -1
  106. package/dist/stabilization/system.d.ts +9 -0
  107. package/dist/stabilization/system.js +5 -6
  108. package/dist/stabilization/system.js.map +1 -1
  109. package/docs/AGENT_GUIDE.md +4 -0
  110. package/docs/API_REFERENCE.md +76 -2
  111. package/docs/QUICK_START.md +88 -6
  112. package/docs/USAGE.md +65 -2
  113. package/docs/api/capture-native-raw-dng.md +6 -0
  114. package/docs/api/core-version.md +1 -1
  115. package/docs/api/equipment-generic-lens-catalogue.md +7 -2
  116. package/docs/api/exports.json +152 -2
  117. package/docs/api/facade-display-view-gpu.md +5 -2
  118. package/docs/api/facade-fast-methods.md +91 -0
  119. package/docs/api/facade-simulate-capture.md +36 -11
  120. package/docs/api/facade-simulate-display-view.md +3 -2
  121. package/docs/api/optics-aperture-diameter.md +51 -0
  122. package/docs/api/optics-detail-limit.md +203 -0
  123. package/docs/api/output-crop-to-match-focal-length.md +67 -0
  124. package/docs/api/output-generic-lens-corrections.md +3 -1
  125. package/docs/api/output-lens-corrections.md +11 -1
  126. package/docs/api/output-print-size-blur-noise.md +115 -0
  127. package/docs/api/sensor-sensor-characteristics.md +105 -0
  128. package/llms.txt +4 -0
  129. package/package.json +1 -1
@@ -48,6 +48,7 @@ the example above is [simulate-capture.mjs](examples/simulate-capture.mjs).
48
48
  | `raw` | The packed native RAW: codes, black levels, saturation flags and per-phase white levels |
49
49
  | `plan` | The native RAW plan the engine executed |
50
50
  | `request` | The complete underlying request: `environmentRaw` (the native environment RAW input), `siteInput(index)` (rebuilds the physical input of any sensor site), `development` (the RAW development input) and `whiteBalanceState` |
51
+ | `sensor` | 2.20 (#555, #535): the sensor characteristics used (pitch, peak QE, full well, read noise of each conversion gain and the dual-gain switch ISO, readout time, ADC bit depth, fixed-pattern noise), the calculated dynamic range (`dynamicRangeStops`, and per ISO setting) and, on exact requests, the mid-gray noise figure (`midGrey`); see [Sensor characteristics](#sensor-characteristics-555) |
51
52
  | `exposure` | Mode, resolved settings, EV100, `isoControl` (`"manual"` or `"automatic"`) and, with `iso: "auto"`, `autoIso` (how the ISO was chosen and what limited it), metered scene luminance, the deviation from the standard exposure in stops (`standardExposureDeviationStops`), the illuminance at which the settings give the standard exposure (`standardExposureIlluminanceLux`), from 2.14 the deviation from the standard exposure of an 18 % grey card under the scene's illuminance (`greyCardDeviationStops`, incident metering) and the exposure-mode resolver's full output |
52
53
  | `scene` | Chart parameters, illuminant, distance, framed size and the scene's SHA-256 digest (it covers caller illuminant and reflectance spectra; see `sourceSha256`) |
53
54
  | `diagnostics` | Radiance query count, highlight and rendering counters, per-stage timings (`timingsMs`) and, from 2.1, the opt-ins that ran (`options`); see [Cost of each option](#cost-of-each-option) |
@@ -206,11 +207,13 @@ and every number is a labelled approximation.
206
207
  for the macro); an f-number or distance outside the lens's range is refused.
207
208
  - **Its character is rendered:** a field- and wavelength-dependent PSF (diffraction with the lens's Seidel aberrations, axial and
208
209
  lateral colour) through the environment PSF quadrature, its radial distortion, its vignetting and, for the fisheyes, the
209
- equisolid projection (beyond 90° there is no scene, so the 8 mm's circle is black outside). Flare, focus breathing and the bokeh
210
- shape are reported in the assumption `lens.catalogue.character`, not rendered.
210
+ equisolid projection (beyond 90° there is no scene, so the 8 mm's circle is black outside). Flare and focus breathing are
211
+ reported in the assumption `lens.catalogue.character`, not rendered.
211
212
  - **Professional lenses are better** where the owner's table says so: sharper centres and corners, less coma, lateral colour,
212
213
  distortion and vignetting, better close focus and focus consistency. The measurements are in the catalogue page.
213
- - **Combinations:** `lens.defocus` convolves the disc into the lens's PSF (a combined kernel up to 16 sites in radius);
214
+ - **Combinations:** `lens.defocus` convolves the lens's own bokeh kernel (#567: its blades, its W040 and, on the fast wides
215
+ and normals, a cat's eye, which is lens.catalogue's exact method: on when the request resolves to exact (`fast: false`, the
216
+ default for DNG, JPEG and PNG), off on fast requests, since it would slow the fast method 2–3.5×; [Bokeh](https://github.com/Photivra/photivra/blob/main/docs/LENS_CATALOGUE.md#bokeh-567)) into the lens's PSF (a combined kernel up to 16 sites in radius);
214
217
  `lens.focusScatter: { seed, shotIndex? }` moves the focus by a seeded autofocus error (`simulateGenericLensFocusError`), off
215
218
  by default. `prime`, `focalLengthMm`, `projection`, `distortion`, `vignetting`, `psf`, `diffraction` and `pupilSampling` are
216
219
  set by the lens and refused with it. The display view renders `lens.catalogue` since #569, with the PSF taken per field zone
@@ -460,6 +463,73 @@ large-pixel sensor may therefore saturate earlier, and a real small-pixel sensor
460
463
  given a higher base ISO. When the pitch is not 6 µm, the assumption `sensor.isoAreaNormalization`
461
464
  records the area ratio and this approximation.
462
465
 
466
+ ### Sensor characteristics (#555)
467
+
468
+ Each characteristic of the body's sensor can be set on its own under `sensor`. All are off by
469
+ default: a request that sets none is byte-identical to 2.19.0, and every value set is listed in the
470
+ assumption `sensor.characteristics`. The figures under the result's `sensor` are calculated from
471
+ them; they are results, not settings.
472
+
473
+ ```ts
474
+ const r = await simulateCapture({ scene: { preset: "uniform" }, sensor: {
475
+ pixelPitchMicrometers: 4,
476
+ peakQuantumEfficiency: 0.8, // green EQE at 555 nm, 0.05–0.9 (default 0.5)
477
+ fullWell: { electrons: 50_000, link: "none" }, // or linked to pixel area (the default)
478
+ readNoise: { lowGainElectrons: 3, highGainElectrons: 1.2, highGainFromIso: 640 },
479
+ readoutSeconds: 0.012, // rolling-shutter skew
480
+ adcBitDepth: 16, // 12, 14 or 16
481
+ fixedPatternNoise: true // the body's synthetic pattern (advanced)
482
+ }, fast: false }); // fast: false computes the mid-gray noise figure
483
+ r.sensor.dynamicRangeStops; r.sensor.midGrey?.snrDecibels;
484
+ ```
485
+
486
+ | Option | Range | Default | Effect |
487
+ | --- | --- | --- | --- |
488
+ | `peakQuantumEfficiency` | 0.05–0.9 | 0.5 | Multiplies every channel's EQE curve by value / 0.5: the curves keep their shape and the colour matrix, and the collected charge scales in proportion. 0.9 keeps the blue channel's peak below 1. |
489
+ | `fullWell` | 100–1e10 e⁻ at the pitch | the body's base-ISO saturation at 6 µm, × pitch²/36 µm² | The charge at the RAW white level at base ISO (the saturation capacity of EMVA 1288 Release 4.0). `link: "pixel-area"` (default) scales `electrons`, the full well at `referencePixelPitchMicrometers` (default 6), by (pitch / reference)²; `link: "none"` uses it at any pitch. |
490
+ | `readNoise` | 0–100 e⁻; switch ISO 100–1e7 | the body's (tiers: 2/1.1, 3/1.4 and 6/2.5 e⁻, switch at ISO 800) | Read noise of the low and the high conversion gain, and `highGainFromIso`, the ISO from which the high gain is used. A switch above the body's top ISO keeps the low gain throughout. |
491
+ | `readoutSeconds` | 1e-6–1 s | the body's (8, 15, 30 ms) | The rolling shutter's top-to-bottom skew (`motion.shutter: "rolling"`; `motion.rollingShutterSkewSeconds` still overrides it). |
492
+ | `adcBitDepth` | 12, 14, 16 | the body's (consumer 12, others 14) | The black level scales by 2^Δbits (1 024 at 14 bits → 4 096 at 16), the saturation code is 2^bits − 1 and the conversion gain follows, so the same charge reaches the same fraction of the code range. A 16-bit RAW writes a 16-bit DNG with white level 65 535. |
493
+ | `fixedPatternNoise` | `true`/`false` | `false` | The body's synthetic fixed-pattern profile (PRNU, DSNU, hot pixels, row and column offsets), the same pattern in every capture. Not available in `simulateDisplayView`, which refuses it. |
494
+
495
+ How the full well and the ISO rating interact (an approximation, recorded in the assumption): the
496
+ facade keeps each ISO setting's readout regime, so the full well is reached at the white level of
497
+ base ISO and every regime's conversion gain and pre-ADC saturation scale with it (`chargeScale`).
498
+ The output per unit focal-plane exposure H then changes by 2^`sensitivityOffsetStops` =
499
+ QE scale × (pixel area / 36 µm²) × (body full well / full well): the body's rating S = 10/H (CIPA
500
+ DC-004-2020 §3.2) still holds only when that is 1, as it is for a linked full well and the default
501
+ QE. A higher QE makes the same exposure brighter; a larger unlinked full well makes it darker and
502
+ gives more highlight headroom. The high conversion gain's smaller charge storage is not modelled
503
+ when the switch ISO is lowered. The assumption notes a full well outside 200–10 000 e⁻ per µm² of
504
+ pixel area as unusual for the pitch; that is a design range around the tiers' 556–1 667 e⁻/µm² and
505
+ published values such as about 4 000 e⁻/µm² at 2 µm and 1 400 e⁻/µm² at 5.2 µm (Farrell, Xiao &
506
+ Kavusi, *Proc. SPIE* 6069, 2006, Table 1), not a published bound.
507
+
508
+ **Dynamic range** (`sensor.dynamicRangeStops` at the capture's ISO, `sensor.dynamicRangeByIso` for
509
+ every ISO setting of the body): engineering dynamic range log₂(S_sat / σ_floor). S_sat is the charge
510
+ at the RAW white level (the full well over the analog gain, limited by the charge capacity and the
511
+ ADC range) and σ_floor = √(σ_read² + σ_q²), the temporal dark noise of EMVA 1288 Release 4.0 without
512
+ dark current, with σ_q the ADC quantization (1/12 code², plus 1/12 code² of rounding after a digital
513
+ gain) in electrons. With no read noise the range is limited by the ADC alone: log₂((white − black)·√12).
514
+ The Professional tier gives 14.7 stops at ISO 100, 12.7 at ISO 800 (high gain) and 9.7 at ISO 6400;
515
+ the Consumer tier 11.7, 10.0 and 7.0.
516
+
517
+ **Noise figure** (#535, `sensor.midGrey`): computed only when the request resolves to exact (`fast:
518
+ false`, the default for DNG, JPEG and PNG exports; about 0.3 ms); a fast request (the sRGB default)
519
+ omits it. The expected signal-to-noise ratio of an 18 % Lambertian
520
+ gray patch under the request's illuminant and illuminance, at the capture's exposure, ISO and
521
+ sensor, from the engine's own noise model (a model figure, not a measurement). It is the green
522
+ channel next to the optical axis (no vignetting, PSF or motion). `signalElectrons` S is the patch's
523
+ expected charge; `noiseElectrons` splits the noise into photon shot √S, read, dark-current shot
524
+ √(D·t) with D = 1 e⁻/s, ADC quantization and, with `fixedPatternNoise`, the fixed pattern
525
+ √((PRNU·S)² + (DSNU·D·t)² + row² + column²) (hot pixels excluded; a spatial noise, the same in every
526
+ capture), added in quadrature as `total`. `snr` = S / total and `snrDecibels` = 20·log₁₀(snr).
527
+ `clipped` is true when the patch reaches the white level, where the figure no longer describes
528
+ the image. With the defaults (Professional, f/8, 1/125 s, ISO 100, 21 481 lux) S ≈ 7 842 e⁻ and the
529
+ SNR is 88.5 (38.9 dB); at ISO 6400 with 1/64 of the light, S ≈ 123 e⁻ and the SNR is 11.0 (20.8 dB):
530
+ the photon shot noise grows as √S, so less light means more noise whatever the ISO. The display view
531
+ reports the same `sensor` record as its capture.
532
+
463
533
  ## Exposure
464
534
 
465
535
  The facade meters the scene like a reflected-light meter:
@@ -1093,6 +1163,18 @@ The optics options feed only what the environment site contract accepts:
1093
1163
  scene; a test checks it to 1e-12 per site. With camera rotation, a projection or distortion, or
1094
1164
  vignetting, the per-tap quadrature runs instead (much slower). `request.diffraction` holds the profile,
1095
1165
  the equivalent `sampled-local` PSF and which execution ran.
1166
+ - Ties on chart boundaries (2.20.0, #565): the per-tap quadrature reads each tap's scene point at its site-lattice point, as
1167
+ the object-space convolution, the out-of-focus capture and the display view do, so a tap exactly on a chart boundary (a
1168
+ Siemens star's spokes through lattice points) resolves to the same material on every path. With camera rotation under
1169
+ `motion.shutter: "rolling"` every row has its own instants, so the lattice is read once per instant; there reading the
1170
+ lattice is `lens.diffraction`'s exact method and keeping each tap's own point its fast method; the lattice costs about
1171
+ 1.2× the time (dead leaves 48 × 32 at f/8: 4.0–4.1 s against 4.8–5.0 s; a Siemens star 32 × 24 at f/16: 6.8 s against
1172
+ 8.1–8.2 s; November 9, 2026, Apple M5 Pro, Node 24.19.0). The format default decides (2.20.0): fast for sRGB samples, exact for
1173
+ DNG, JPEG and PNG; `lens.diffraction: { kind, fast }` or the top-level `fast` wins, and the fast method reports
1174
+ `diagnostics.fast.applied["lens.diffraction"]: true` and the assumption `fast.lens.diffraction`. The two readings differ
1175
+ only at such ties. Region renders joined into one frame (`serializeSimulatedCaptureDngFromParts`,
1176
+ `simulateCaptureFromParts`) match the whole-frame render only if every part resolves `fast` the same way: give the parts
1177
+ and the whole request the same `fast` (a part rendered fast is refused by an exact join).
1096
1178
  - The convolution runs in 32-row bands and computes only each band's own values, not the 2R rows and
1097
1179
  columns of padding around it (#419; the values are bit-identical to the full-grid convolution's). On a
1098
1180
  function scene with the Quality options at 1 µm and f/16 (R 32), 96 × 72, `diagnostics.timingsMs.diffraction`
@@ -1423,9 +1505,9 @@ lens: { focalLengthMm: 50, fNumber: 4, focusDistanceM: 1.4, defocus: { model: "g
1423
1505
  combined with `lens.diffraction` (every zone would need its own combined kernel per node). Above a kernel radius of 64
1424
1506
  sites it is refused: #564's run convolution takes one kernel per request, and extending it to one kernel per zone is left
1425
1507
  for later. `simulateDisplayView` and `prepareDisplayView` refuse it; they render `blades` and `sphericalAberration`.
1426
- - **With `lens.catalogue` (#549)** the three options are refused by name. The catalogue lens folds a plain disc into its
1427
- own PSF, so they would otherwise be dropped. Feeding a catalogue lens's spherical aberration and character into the
1428
- bokeh kernel is a follow-up ([#567](https://github.com/Photivra/photivra/issues/567)), not a 2.17.0 feature.
1508
+ - **With `lens.catalogue` (#549)** the three options are refused by name: since 2.20.0 (#567) the catalogue lens sets
1509
+ all three itself (its blades, its own W040 and, on the fast wides and normals, a cat's eye) and folds that kernel into
1510
+ its PSF ([Lens catalogue, Bokeh](https://github.com/Photivra/photivra/blob/main/docs/LENS_CATALOGUE.md#bokeh-567)).
1429
1511
  - **Sampling.** Without spherical aberration each site's weight is the exact area of the projected open pupil (a convex
1430
1512
  polygon; circles and blade arcs are 1024-gons scaled to their true area, within 5e-6 of the radius of the true curve)
1431
1513
  inside the site's cell, divided by its area (`"exact-polygon-cell-area"`). With it the pupil is sampled on a square grid
package/docs/USAGE.md CHANGED
@@ -7147,6 +7147,7 @@ See [Print planning](https://github.com/Photivra/photivra/blob/main/docs/PRINT_P
7147
7147
 
7148
7148
  - `calculatePrintPlan()` says whether the native sampling is enough, and gives a compatible minimum file raster.
7149
7149
  - `calculatePrintSizeLimit()` distinguishes finite, unbounded and unavailable sampling limits.
7150
+ - `calculatePrintSizeLimitWithBlurAndNoise()` (2.20.0) gives the largest print at a viewing distance that still looks sharp and clean, counting blur and noise as well as pixels, and names the limiting factor ([Print size limit with blur and noise](https://github.com/Photivra/photivra/blob/main/docs/PRINT_PLANNING.md#print-size-limit-with-blur-and-noise)). An approximation.
7150
7151
  - Print quality and delivery are not assessed.
7151
7152
 
7152
7153
  The [regional Print detail diagnostic](https://github.com/Photivra/photivra/blob/main/docs/PRINT_DETAIL_ASSESSMENT.md) measures one declared, coherent sinusoidal fundamental in a selected linear-luminance ROI, and records the exact pipeline stage it measured. Its [validation protocol](https://github.com/Photivra/photivra/blob/main/docs/PRINT_DETAIL_VALIDATION.md) and [capability ledger](https://github.com/Photivra/photivra/blob/main/docs/validation/print-detail-capabilities.json) separate mathematical conformance from the still-blocked qualification of acquired images, renderers and perceived quality.
@@ -7516,10 +7517,12 @@ console.log(calculateGeometricFocusBreathing({
7516
7517
  `GENERIC_LENS_CATALOGUE` lists the engine's 30 generic primes (17 professional, 13 consumer, "PV 50 1.2 Pro", "PV 50 1.8"; Photivra's
7517
7518
  own designs, not real products). `resolveGenericLens` resolves one for a sensor, an f-number and a focus distance: its focal length
7518
7519
  (scaled by the crop factor), aperture range, close focus, projection and its character, Seidel aberrations, axial and lateral
7519
- colour, distortion and vignetting profiles, flare, breathing and focus scatter, each a labelled approximation.
7520
+ colour, distortion and vignetting profiles, flare, breathing, focus scatter and its bokeh (`character.bokeh`, #567: the options of
7521
+ `lens.defocus` and `calculateDefocusBokehKernel`), each a labelled approximation.
7520
7522
  `simulateGenericLensFocusError` draws a seeded autofocus error; `createGenericLensCorrectionProfile` builds the lens's generic
7521
7523
  correction profile for `resolveLensCorrectionPlan`, which `simulateCapture` applies to its developed image with
7522
- `output.lensCorrections` (#570; never to the RAW or the DNG). `simulateCapture` renders a catalogue lens with `lens.catalogue`
7524
+ `output.lensCorrections` (#570; never to the RAW or the DNG), and which a DNG can carry for RAW editors as optional DNG opcodes with
7525
+ `output.dng.lensCorrectionOpcodes` (#587; the RAW codes unchanged). `simulateCapture` renders a catalogue lens with `lens.catalogue`
7523
7526
  ([quick start](QUICK_START.md#generic-lens-catalogue-549)); `fast: true` (or `lens: { catalogue: { kind, fast: true } }`) takes its
7524
7527
  fast method (#568), a labeled approximation that blends the PSF over a field grid, for interactive captures. Equations, tables and the
7525
7528
  chart measurements: [LENS_CATALOGUE.md](https://github.com/Photivra/photivra/blob/main/docs/LENS_CATALOGUE.md).
@@ -8085,3 +8088,63 @@ console.log(share.print?.blurOnPrintMm); // 0.25 mm on the print
8085
8088
  - Equal shares of the frame diagonal look the same when images are shown at the same size. `calculateBlurShareOfFrame()` does not judge whether a blur is visible.
8086
8089
 
8087
8090
  See [Physics Foundation](https://github.com/Photivra/photivra/blob/main/docs/PHYSICS_FOUNDATION.md#sensor-equivalence-f-number-area-and-blur-share).
8091
+
8092
+ ## Aperture diameter and cropping to match a longer lens
8093
+
8094
+ `calculateApertureDiameter()` gives the entrance-pupil diameter in millimetres, focal length ÷ f-number (exact by the definition of the f-number, ideal lens focused at infinity). `calculateCropToMatchFocalLength()` gives the centred crop that makes an image from a shorter lens match a longer lens's framing from the same position, and the pixels it leaves:
8095
+
8096
+ ```ts
8097
+ import { calculateApertureDiameter, calculateCropToMatchFocalLength } from "@photivra/engine";
8098
+
8099
+ console.log(calculateApertureDiameter({ focalLengthMm: 50, fNumber: 2 }).value.entrancePupilDiameterMm); // 25
8100
+ console.log(calculateApertureDiameter({ focalLengthMm: 50, fNumber: 4 }).value.entrancePupilDiameterMm); // 12.5
8101
+
8102
+ const crop = calculateCropToMatchFocalLength({
8103
+ focalLengthMm: 50,
8104
+ targetFocalLengthMm: 100,
8105
+ pixelWidth: 6000,
8106
+ pixelHeight: 4000
8107
+ }).value;
8108
+
8109
+ console.log(crop.cropFactor); // 2
8110
+ console.log(crop.pixelWidth, crop.pixelHeight); // 3000 2000
8111
+ console.log(crop.megapixels, crop.sourceMegapixels); // 6 24
8112
+ ```
8113
+
8114
+ - The aperture diameter is the entrance pupil (the aperture stop seen from the front of the lens), not the diaphragm's opening. Close focus (working f-number `N·(1 + m/P)`, pupil magnification `P`) is not modelled.
8115
+ - The crop factor `target ÷ focal length` is exact for ideal rectilinear lenses focused at infinity; at close focus the matching factor is larger, `F·(d − f)/(f·(d − F))` at object distance `d` (thin lens), which is not applied. The crop is `calculateCenteredCrop()`'s (aspect ratio kept to whole pixels), and `targetFocalLengthMm` must be at least `focalLengthMm`.
8116
+
8117
+ See [Physics Foundation](https://github.com/Photivra/photivra/blob/main/docs/PHYSICS_FOUNDATION.md#aperture-entrance-pupil-diameter).
8118
+
8119
+ ## Which limits detail: the lens or the sensor
8120
+
8121
+ `calculateDetailLimit()` (model `detail-limit` 1.0.0, #575) answers "would a sharper lens or a denser sensor help?" for given settings. It returns the optics' resolution (MTF50 in cycles/mm, from diffraction, the lens's aberrations and any defocus), the sampling's (the Nyquist frequency 1/(2p) and, with a Bayer filter, the red and blue sites' 1/(4p)), the system MTF50, and which one limits:
8122
+
8123
+ ```ts
8124
+ import { calculateDetailLimit } from "@photivra/engine";
8125
+
8126
+ const sensor = { widthMm: 36, heightMm: 24, pixelWidth: 6000 }; // 6 µm pixels
8127
+
8128
+ const stoppedDown = calculateDetailLimit({ sensor, lens: { kind: "ideal", focalLengthMm: 50 }, fNumber: 22 }).value;
8129
+ console.log(stoppedDown.limitedBy, stoppedDown.limitingFactor); // "lens" "diffraction"
8130
+ console.log(stoppedDown.optics.mtf50CyclesPerMm); // ≈ 33.4, against a Nyquist frequency of ≈ 83.3
8131
+ console.log(stoppedDown.diffractionLimitedFNumber); // ≈ 8.8: from here on diffraction limits on this sensor
8132
+
8133
+ const wideOpen = calculateDetailLimit({ sensor, lens: { kind: "catalogue", lensId: "pv-50-1.2-pro" }, fNumber: 4 }).value;
8134
+ console.log(wideOpen.limitedBy, wideOpen.limitingFactor); // "sensor" "pixel-pitch"
8135
+
8136
+ const outOfFocus = calculateDetailLimit({
8137
+ sensor,
8138
+ lens: { kind: "ideal", focalLengthMm: 50 },
8139
+ fNumber: 2,
8140
+ focus: { focusDistanceM: 3, subjectDistanceM: 2.5 }
8141
+ }).value;
8142
+ console.log(outOfFocus.limitingFactor); // "defocus"
8143
+ ```
8144
+
8145
+ - The sensor limits when the optics still pass 50 % contrast at the Nyquist frequency (`opticsToNyquistRatio ≥ 1`): a denser sensor would record finer detail. Otherwise the lens limits, through `"diffraction"` when its RMS wavefront error is within λ/14 (the Maréchal criterion), else `"aberrations"` or `"defocus"`, whichever is larger. This rule is an approximation; the MTF values are the exact scalar OTF of the stated wavefront at one wavelength (550 nm unless `wavelengthNm` is given).
8146
+ - `lens.catalogue` lenses use their generic Seidel aberrations at `fieldPosition` (0 the center, 1 the corner), so corners are softer; `lens.ideal` has none.
8147
+ - Tangential and sagittal MTF50 are both reported; the decision uses the lower one.
8148
+ - Demosaicing, sharpening, noise and motion are not included.
8149
+
8150
+ See [Physics Foundation](https://github.com/Photivra/photivra/blob/main/docs/PHYSICS_FOUNDATION.md#which-limits-detail-the-lens-or-the-sensor).
@@ -82,5 +82,11 @@ export interface SerializeNativeRawDngInput {
82
82
  capturedAtUtc?: string;
83
83
  /** Optional (2.4) caller text: description, software, copyright, comment (see the module notes). If omitted, no text is added. */
84
84
  textMetadata?: OutputTextMetadata;
85
+ /**
86
+ * Optional (#587) OpcodeList3 (tag 51022) bytes, for example `createDngLensCorrectionOpcodes(...).opcodeList3`: a big-endian DNG
87
+ * opcode list (DNG 1.3.0.0+, "Opcode List Processing") that readers apply to the demosaiced stage-3 image. Only its framing is
88
+ * checked (count, per-opcode headers and parameter sizes filling the bytes exactly). The RAW codes are unchanged. If omitted, no tag.
89
+ */
90
+ opcodeList3?: Uint8Array;
85
91
  }
86
92
  ```
@@ -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.19.0" as const
14
+ ENGINE_API_VERSION = "2.20.0" as const
15
15
  ```
@@ -69,7 +69,7 @@ export interface GenericLensCatalogueEntry {
69
69
  note: string;
70
70
  /**
71
71
  * A short plain-English summary of the lens's character (at most 40 characters, US spelling), written from its adjustments and
72
- * `note`, for lists and pickers. It describes only what the catalogue models (bokeh shape is not modeled: the defocus is a disc).
72
+ * `note`, for lists and pickers. It describes the lens's sharpness, color, distortion and falloff, not its bokeh (the same rule for every lens of a set).
73
73
  */
74
74
  summary: string;
75
75
  }
@@ -102,6 +102,11 @@ export interface GenericLensCharacter {
102
102
  breathing: { fieldOfViewChange: number; atMinimumFocus: number };
103
103
  /** Standard deviation of the autofocus error as a fraction of the depth of focus 2·N·c. */
104
104
  focusScatterDepthOfFocusFraction: number;
105
+ /**
106
+ * Bokeh character (#567) at this f-number, focus and sensor, as the options of `lens.defocus` and `calculateDefocusBokehKernel`
107
+ * (#553): blades, the resolved W040 as `sphericalAberration` (µm at this f-number) and, for fast wides and normals, `catsEye`.
108
+ */
109
+ bokeh: DefocusBokehOptions;
105
110
  }
106
111
  ```
107
112
 
@@ -127,7 +132,7 @@ export interface GenericLensCharacterValues {
127
132
  distortionMagnitude: number;
128
133
  /** Corner fall-off in stops that stays when stopped down (natural vignetting). */
129
134
  naturalVignettingStops: number;
130
- /** Extra corner fall-off in stops at the widest aperture (optical vignetting); gone two stops down. */
135
+ /** Extra corner fall-off in stops at the widest aperture (optical vignetting); gone four stops down (N = 4·N0). */
131
136
  opticalVignettingStops: number;
132
137
  /** Veiling glare: the fraction of the image's mean irradiance spread as a uniform veil. */
133
138
  veilingGlareFraction: number;
@@ -1,6 +1,6 @@
1
1
  {
2
- "packageVersion": "2.19.0",
3
- "engineApiVersion": "2.19.0",
2
+ "packageVersion": "2.20.0",
3
+ "engineApiVersion": "2.20.0",
4
4
  "exports": [
5
5
  {
6
6
  "name": "AbsoluteUncertaintyEstimate",
@@ -77,6 +77,11 @@
77
77
  "source": "src/sensor/optical-stack.ts",
78
78
  "kind": "type"
79
79
  },
80
+ {
81
+ "name": "ApertureDiameter",
82
+ "source": "src/optics/aperture-diameter.ts",
83
+ "kind": "type"
84
+ },
80
85
  {
81
86
  "name": "AperturePriorityAutoIsoExposureModeResolution",
82
87
  "source": "src/exposure/exposure-mode-resolver.ts",
@@ -362,6 +367,16 @@
362
367
  "source": "src/optics/diffraction.ts",
363
368
  "kind": "type"
364
369
  },
370
+ {
371
+ "name": "calculateApertureDiameter",
372
+ "source": "src/optics/aperture-diameter.ts",
373
+ "kind": "runtime"
374
+ },
375
+ {
376
+ "name": "CalculateApertureDiameterInput",
377
+ "source": "src/optics/aperture-diameter.ts",
378
+ "kind": "type"
379
+ },
365
380
  {
366
381
  "name": "calculateAtmosphericHaze",
367
382
  "source": "src/optics/atmospheric-haze.ts",
@@ -582,6 +597,16 @@
582
597
  "source": "src/color/illuminants.ts",
583
598
  "kind": "runtime"
584
599
  },
600
+ {
601
+ "name": "calculateCropToMatchFocalLength",
602
+ "source": "src/output/crop-to-match-focal-length.ts",
603
+ "kind": "runtime"
604
+ },
605
+ {
606
+ "name": "CalculateCropToMatchFocalLengthInput",
607
+ "source": "src/output/crop-to-match-focal-length.ts",
608
+ "kind": "type"
609
+ },
585
610
  {
586
611
  "name": "calculateDefocusBokehKernel",
587
612
  "source": "src/optics/defocus-bokeh-kernel.ts",
@@ -632,6 +657,16 @@
632
657
  "source": "src/optics/depth-of-field.ts",
633
658
  "kind": "type"
634
659
  },
660
+ {
661
+ "name": "calculateDetailLimit",
662
+ "source": "src/optics/detail-limit.ts",
663
+ "kind": "runtime"
664
+ },
665
+ {
666
+ "name": "CalculateDetailLimitInput",
667
+ "source": "src/optics/detail-limit.ts",
668
+ "kind": "type"
669
+ },
635
670
  {
636
671
  "name": "calculatedResult",
637
672
  "source": "src/core/calculation-result.ts",
@@ -1152,6 +1187,11 @@
1152
1187
  "source": "src/output/print-plan.ts",
1153
1188
  "kind": "runtime"
1154
1189
  },
1190
+ {
1191
+ "name": "calculatePrintSizeLimitWithBlurAndNoise",
1192
+ "source": "src/output/print-size-blur-noise.ts",
1193
+ "kind": "runtime"
1194
+ },
1155
1195
  {
1156
1196
  "name": "calculateProcessedSensorRaw",
1157
1197
  "source": "src/capture/photographic-export.ts",
@@ -2752,6 +2792,11 @@
2752
2792
  "source": "src/optics/zernike-wavefront.ts",
2753
2793
  "kind": "type"
2754
2794
  },
2795
+ {
2796
+ "name": "CropToMatchFocalLength",
2797
+ "source": "src/output/crop-to-match-focal-length.ts",
2798
+ "kind": "type"
2799
+ },
2755
2800
  {
2756
2801
  "name": "DeadLeavesChartScene",
2757
2802
  "source": "src/facade/chart-scenes.ts",
@@ -2822,6 +2867,56 @@
2822
2867
  "source": "src/optics/depth-of-field.ts",
2823
2868
  "kind": "type"
2824
2869
  },
2870
+ {
2871
+ "name": "DETAIL_LIMIT_LIMITS",
2872
+ "source": "src/optics/detail-limit.ts",
2873
+ "kind": "runtime"
2874
+ },
2875
+ {
2876
+ "name": "DetailLimit",
2877
+ "source": "src/optics/detail-limit.ts",
2878
+ "kind": "type"
2879
+ },
2880
+ {
2881
+ "name": "DetailLimitBasis",
2882
+ "source": "src/optics/detail-limit.ts",
2883
+ "kind": "type"
2884
+ },
2885
+ {
2886
+ "name": "DetailLimitCatalogueLens",
2887
+ "source": "src/optics/detail-limit.ts",
2888
+ "kind": "type"
2889
+ },
2890
+ {
2891
+ "name": "DetailLimitedBy",
2892
+ "source": "src/optics/detail-limit.ts",
2893
+ "kind": "type"
2894
+ },
2895
+ {
2896
+ "name": "DetailLimitIdealLens",
2897
+ "source": "src/optics/detail-limit.ts",
2898
+ "kind": "type"
2899
+ },
2900
+ {
2901
+ "name": "DetailLimitingFactor",
2902
+ "source": "src/optics/detail-limit.ts",
2903
+ "kind": "type"
2904
+ },
2905
+ {
2906
+ "name": "DetailLimitOptics",
2907
+ "source": "src/optics/detail-limit.ts",
2908
+ "kind": "type"
2909
+ },
2910
+ {
2911
+ "name": "DetailLimitSampling",
2912
+ "source": "src/optics/detail-limit.ts",
2913
+ "kind": "type"
2914
+ },
2915
+ {
2916
+ "name": "DetailLimitSystem",
2917
+ "source": "src/optics/detail-limit.ts",
2918
+ "kind": "type"
2919
+ },
2825
2920
  {
2826
2921
  "name": "DIFFRACTION_LIMITED_PSF_PROFILE_LIMITS",
2827
2922
  "source": "src/optics/diffraction-psf-profile.ts",
@@ -5967,6 +6062,11 @@
5967
6062
  "source": "src/output/print-region-statistics.ts",
5968
6063
  "kind": "runtime"
5969
6064
  },
6065
+ {
6066
+ "name": "PrintBlurInput",
6067
+ "source": "src/output/print-size-blur-noise.ts",
6068
+ "kind": "type"
6069
+ },
5970
6070
  {
5971
6071
  "name": "PrintContrastReference",
5972
6072
  "source": "src/output/print-contrast-reference.ts",
@@ -5992,6 +6092,11 @@
5992
6092
  "source": "src/output/print-plan.ts",
5993
6093
  "kind": "type"
5994
6094
  },
6095
+ {
6096
+ "name": "PrintImageMm",
6097
+ "source": "src/output/print-size-blur-noise.ts",
6098
+ "kind": "type"
6099
+ },
5995
6100
  {
5996
6101
  "name": "PrintJpegInput",
5997
6102
  "source": "src/output/print-jpeg.ts",
@@ -6047,6 +6152,11 @@
6047
6152
  "source": "src/output/print-plan.ts",
6048
6153
  "kind": "type"
6049
6154
  },
6155
+ {
6156
+ "name": "PrintNoiseInput",
6157
+ "source": "src/output/print-size-blur-noise.ts",
6158
+ "kind": "type"
6159
+ },
6050
6160
  {
6051
6161
  "name": "PrintPlan",
6052
6162
  "source": "src/output/print-plan.ts",
@@ -6112,6 +6222,11 @@
6112
6222
  "source": "src/output/print-plan.ts",
6113
6223
  "kind": "type"
6114
6224
  },
6225
+ {
6226
+ "name": "PrintSharpCleanCriterion",
6227
+ "source": "src/output/print-size-blur-noise.ts",
6228
+ "kind": "type"
6229
+ },
6115
6230
  {
6116
6231
  "name": "PrintSinusoidalMeasurement",
6117
6232
  "source": "src/output/print-detail.ts",
@@ -6132,6 +6247,16 @@
6132
6247
  "source": "src/output/print-plan.ts",
6133
6248
  "kind": "type"
6134
6249
  },
6250
+ {
6251
+ "name": "PrintSizeLimitWithBlurAndNoise",
6252
+ "source": "src/output/print-size-blur-noise.ts",
6253
+ "kind": "type"
6254
+ },
6255
+ {
6256
+ "name": "PrintSizeLimitWithBlurAndNoiseInput",
6257
+ "source": "src/output/print-size-blur-noise.ts",
6258
+ "kind": "type"
6259
+ },
6135
6260
  {
6136
6261
  "name": "PROCESSED_SENSOR_RAW_MODEL_VERSION",
6137
6262
  "source": "src/capture/photographic-export.ts",
@@ -8512,6 +8637,11 @@
8512
8637
  "source": "src/facade/simulate-capture.ts",
8513
8638
  "kind": "runtime"
8514
8639
  },
8640
+ {
8641
+ "name": "SIMULATE_CAPTURE_FAST_METHODS",
8642
+ "source": "src/facade/fast-methods.ts",
8643
+ "kind": "runtime"
8644
+ },
8515
8645
  {
8516
8646
  "name": "SIMULATE_CAPTURE_FAST_OPTIONS",
8517
8647
  "source": "src/facade/simulate-capture.ts",
@@ -8602,6 +8732,11 @@
8602
8732
  "source": "src/facade/simulate-capture.ts",
8603
8733
  "kind": "type"
8604
8734
  },
8735
+ {
8736
+ "name": "SimulateCaptureFastMethod",
8737
+ "source": "src/facade/fast-methods.ts",
8738
+ "kind": "type"
8739
+ },
8605
8740
  {
8606
8741
  "name": "SimulateCaptureFastOption",
8607
8742
  "source": "src/facade/simulate-capture.ts",
@@ -8642,6 +8777,11 @@
8642
8777
  "source": "src/facade/simulate-capture.ts",
8643
8778
  "kind": "type"
8644
8779
  },
8780
+ {
8781
+ "name": "SimulateCaptureNoiseComponents",
8782
+ "source": "src/sensor/sensor-characteristics.ts",
8783
+ "kind": "type"
8784
+ },
8645
8785
  {
8646
8786
  "name": "SimulateCaptureOptions",
8647
8787
  "source": "src/facade/simulate-capture.ts",
@@ -8672,6 +8812,16 @@
8672
8812
  "source": "src/facade/simulate-capture.ts",
8673
8813
  "kind": "type"
8674
8814
  },
8815
+ {
8816
+ "name": "SimulateCaptureSensorCharacteristics",
8817
+ "source": "src/sensor/sensor-characteristics.ts",
8818
+ "kind": "type"
8819
+ },
8820
+ {
8821
+ "name": "SimulateCaptureSensorReport",
8822
+ "source": "src/sensor/sensor-characteristics.ts",
8823
+ "kind": "type"
8824
+ },
8675
8825
  {
8676
8826
  "name": "SimulateCaptureSharpening",
8677
8827
  "source": "src/facade/simulate-capture.ts",
@@ -81,13 +81,16 @@ export type DisplayViewGpuMode = "off" | "auto" | "require";
81
81
 
82
82
  ```ts
83
83
  export interface PrepareDisplayViewOptions {
84
- /** See {@link DisplayViewGpuMode}. Default `"off"`. */
84
+ /**
85
+ * See {@link DisplayViewGpuMode}. 2.20.0: default `"auto"` when the request resolves to fast (`fast` omitted or true: the
86
+ * display view's default) and `"off"` when it is exact (`fast: false`); an explicit value wins.
87
+ */
85
88
  gpu?: DisplayViewGpuMode;
86
89
  /**
87
90
  * Where the device comes from, with `gpu: "auto"` or `"require"`: a WebGPU entry point (a worker's `navigator.gpu`), or an
88
91
  * `ImageKernelAccelerator` whose device the view shares (the accelerator keeps ownership; `dispose()` of the prepared view
89
92
  * leaves it alone). Without it the engine acquires a device from `globalThis.navigator.gpu` and owns it until `dispose()`.
90
- * Refused with `gpu: "off"`.
93
+ * Refused when `gpu` is or defaults to `"off"`.
91
94
  */
92
95
  gpuDevice?: unknown;
93
96
  }