@photivra/engine 2.16.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 (93) hide show
  1. package/CHANGELOG.md +56 -1
  2. package/CITATION.cff +1 -1
  3. package/README.md +6 -6
  4. package/SECURITY.md +1 -1
  5. package/THIRD_PARTY.md +24 -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/core/version.d.ts +1 -1
  10. package/dist/core/version.js +1 -1
  11. package/dist/core/version.js.map +1 -1
  12. package/dist/equipment/generic-lens-catalogue.d.ts +227 -0
  13. package/dist/equipment/generic-lens-catalogue.js +243 -0
  14. package/dist/equipment/generic-lens-catalogue.js.map +1 -0
  15. package/dist/facade/defocus-bokeh-zones.d.ts +33 -0
  16. package/dist/facade/defocus-bokeh-zones.js +53 -0
  17. package/dist/facade/defocus-bokeh-zones.js.map +1 -0
  18. package/dist/facade/defocus-image-space-provider.d.ts +9 -0
  19. package/dist/facade/defocus-image-space-provider.js +96 -7
  20. package/dist/facade/defocus-image-space-provider.js.map +1 -1
  21. package/dist/facade/diffraction-provider.d.ts +29 -0
  22. package/dist/facade/diffraction-provider.js +117 -11
  23. package/dist/facade/diffraction-provider.js.map +1 -1
  24. package/dist/facade/display-view-gpu-session.js +4 -0
  25. package/dist/facade/display-view-gpu-session.js.map +1 -1
  26. package/dist/facade/display-view-gpu.d.ts +10 -0
  27. package/dist/facade/display-view-gpu.js.map +1 -1
  28. package/dist/facade/display-view-lens-signal.d.ts +30 -0
  29. package/dist/facade/display-view-lens-signal.js +427 -0
  30. package/dist/facade/display-view-lens-signal.js.map +1 -0
  31. package/dist/facade/expected-signal-setup.d.ts +7 -0
  32. package/dist/facade/expected-signal-setup.js +12 -2
  33. package/dist/facade/expected-signal-setup.js.map +1 -1
  34. package/dist/facade/expected-signal.d.ts +8 -0
  35. package/dist/facade/expected-signal.js +150 -0
  36. package/dist/facade/expected-signal.js.map +1 -1
  37. package/dist/facade/generic-lens-capture.d.ts +6 -0
  38. package/dist/facade/generic-lens-capture.js +8 -0
  39. package/dist/facade/generic-lens-capture.js.map +1 -0
  40. package/dist/facade/large-defocus-lattice.d.ts +39 -0
  41. package/dist/facade/large-defocus-lattice.js +64 -0
  42. package/dist/facade/large-defocus-lattice.js.map +1 -0
  43. package/dist/facade/scene-radiance.d.ts +5 -0
  44. package/dist/facade/scene-radiance.js +5 -2
  45. package/dist/facade/scene-radiance.js.map +1 -1
  46. package/dist/facade/simulate-capture.d.ts +38 -5
  47. package/dist/facade/simulate-capture.js +306 -49
  48. package/dist/facade/simulate-capture.js.map +1 -1
  49. package/dist/facade/simulate-display-view.js +22 -16
  50. package/dist/facade/simulate-display-view.js.map +1 -1
  51. package/dist/gpu/display-view-quality-gpu.d.ts +0 -9
  52. package/dist/gpu/display-view-quality-gpu.js +6 -0
  53. package/dist/gpu/display-view-quality-gpu.js.map +1 -1
  54. package/dist/gpu/display-view-speed-gpu.js +2 -0
  55. package/dist/gpu/display-view-speed-gpu.js.map +1 -1
  56. package/dist/index.d.ts +3 -0
  57. package/dist/index.js +3 -0
  58. package/dist/index.js.map +1 -1
  59. package/dist/optics/defocus-bokeh-kernel.d.ts +207 -0
  60. package/dist/optics/defocus-bokeh-kernel.js +380 -0
  61. package/dist/optics/defocus-bokeh-kernel.js.map +1 -0
  62. package/dist/optics/defocus-disc-kernel.d.ts +41 -19
  63. package/dist/optics/defocus-disc-kernel.js +44 -23
  64. package/dist/optics/defocus-disc-kernel.js.map +1 -1
  65. package/dist/optics/generic-lens-psf.d.ts +68 -0
  66. package/dist/optics/generic-lens-psf.js +337 -0
  67. package/dist/optics/generic-lens-psf.js.map +1 -0
  68. package/dist/optics/illumination-vignetting.d.ts +6 -0
  69. package/dist/optics/illumination-vignetting.js +24 -7
  70. package/dist/optics/illumination-vignetting.js.map +1 -1
  71. package/dist/optics/sensor-environment-query.d.ts +6 -0
  72. package/dist/optics/sensor-environment-query.js +13 -6
  73. package/dist/optics/sensor-environment-query.js.map +1 -1
  74. package/dist/optics/site-kernel-runs.d.ts +77 -0
  75. package/dist/optics/site-kernel-runs.js +200 -0
  76. package/dist/optics/site-kernel-runs.js.map +1 -0
  77. package/dist/output/generic-lens-corrections.d.ts +24 -0
  78. package/dist/output/generic-lens-corrections.js +51 -0
  79. package/dist/output/generic-lens-corrections.js.map +1 -0
  80. package/docs/AGENT_GUIDE.md +1 -0
  81. package/docs/API_REFERENCE.md +49 -1
  82. package/docs/QUICK_START.md +257 -17
  83. package/docs/USAGE.md +57 -5
  84. package/docs/api/core-version.md +1 -1
  85. package/docs/api/equipment-generic-lens-catalogue.md +281 -0
  86. package/docs/api/exports.json +137 -2
  87. package/docs/api/facade-simulate-capture.md +23 -2
  88. package/docs/api/optics-defocus-bokeh-kernel.md +145 -0
  89. package/docs/api/optics-defocus-disc-kernel.md +22 -15
  90. package/docs/api/optics-sensor-environment-query.md +6 -0
  91. package/docs/api/output-generic-lens-corrections.md +39 -0
  92. package/llms.txt +1 -0
  93. package/package.json +1 -1
@@ -0,0 +1 @@
1
+ {"version":3,"file":"generic-lens-corrections.js","sourceRoot":"","sources":["../../src/output/generic-lens-corrections.ts"],"names":[],"mappings":"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","sourcesContent":["// SPDX-License-Identifier: Apache-2.0\n\n/**\n * The generic correction profile of a catalogue lens (#549), for the post-capture lens corrections\n * (`resolveLensCorrectionPlan`, `calculateLensCorrectedCapture`): a distortion map, a lateral-colour map per channel and a\n * peripheral-illumination gain, each from the lens's own character (`resolveGenericLens`). Consumer lenses rely on it more:\n * their uncorrected distortion and vignetting are larger, so the same profile corrects more. Like every catalogue number it is\n * a labelled approximation, bound to one exact camera state (`OpticalProfileState`).\n */\n\nimport { InvalidScientificInputError } from \"../core/validation.js\";\nimport { requireInputKeys, requireInputObject } from \"../core/record-validation.js\";\nimport { parseOpticalProfileState, type OpticalProfileState } from \"../optics/profile-contract.js\";\nimport { GENERIC_LENS_EVIDENCE, genericLensDispersion, resolveGenericLens } from \"../equipment/generic-lens-catalogue.js\";\nimport { parseGenericLensCorrectionProfile, type GenericLensCorrectionProfile, type LensCorrectionChannel } from \"./lens-corrections.js\";\nimport type { AffineGeometricTransform } from \"./geometric-transforms.js\";\n\n/**\n * Effective wavelengths of the red, green and blue channels for the lateral-colour map, nm: a generic approximation of where\n * a camera's channel responses peak, not a measured camera.\n */\nexport const GENERIC_LENS_CORRECTION_CHANNEL_WAVELENGTHS_NM: Readonly<Record<LensCorrectionChannel, number>> = /* @__PURE__ */ Object.freeze({ red: 610, green: 540, blue: 465 });\n\nexport interface CreateGenericLensCorrectionProfileInput {\n /** A catalogue id (`GENERIC_LENS_IDS`); `state.lensId` must be the same id. */\n lensId: string;\n /** The sensor's active capture area, mm (the lens is scaled to it as in `resolveGenericLens`). */\n activeImagingArea: { widthMm: number; heightMm: number };\n /** The exact camera state the profile applies to: its focal length must be the lens's on this sensor, its aperture in range. */\n state: OpticalProfileState;\n}\n\n/**\n * Builds the catalogue lens's generic correction profile for one camera state: a radial distortion map (the lens's own\n * r_d = r(1 + k1ρ²), read at each ideal destination point), per-channel lateral-colour scales 1 + L·D(λ_channel), and the\n * relative-illumination gain at the state's aperture (strength 1). The result passes `parseGenericLensCorrectionProfile`.\n */\nexport function createGenericLensCorrectionProfile(input: CreateGenericLensCorrectionProfileInput): GenericLensCorrectionProfile {\n requireInputObject(input, \"createGenericLensCorrectionProfile input\");\n requireInputKeys(input, \"createGenericLensCorrectionProfile\", [\"lensId\", \"activeImagingArea\", \"state\"]);\n const state = parseOpticalProfileState(input.state);\n const lens = resolveGenericLens({ lensId: input.lensId, activeImagingArea: input.activeImagingArea, fNumber: state.aperture });\n if (state.lensId !== lens.lensId) throw new InvalidScientificInputError(\"state.lensId must be the catalogue id \" + lens.lensId + \".\");\n if (Math.abs(state.focalLengthMm - lens.focalLengthMm) > 1e-9 * lens.focalLengthMm)\n throw new InvalidScientificInputError(\"state.focalLengthMm must be the \" + lens.name + \"'s focal length on this sensor, \" + lens.focalLengthMm + \" mm.\");\n const c = lens.character, domain = \"reconstructed-linear\" as const;\n const identity = { version: \"1.0.0\", domain, availability: \"toggle\" as const, defaultEnabled: true, dependencies: [] as string[], requiredByStabilizationModes: [] as string[] };\n const scale = (channel: LensCorrectionChannel): number => 1 + c.lateralColor.fraction * genericLensDispersion(GENERIC_LENS_CORRECTION_CHANNEL_WAVELENGTHS_NM[channel]);\n const affine = (channel: LensCorrectionChannel): AffineGeometricTransform => ({ id: lens.lensId + \":lateral-ca:\" + channel, version: \"1.0.0\", domain, purpose: \"lateral-ca\" as const, kind: \"affine\" as const,\n matrix: [scale(channel), 0, 0, scale(channel)] as [number, number, number, number], offsetMm: { x: 0, y: 0 } });\n return parseGenericLensCorrectionProfile({ schemaVersion: \"0.1.0\", id: \"photivra-\" + lens.lensId + \":corrections\", version: \"1.0.0\", state,\n evidence: { kind: \"generic-parametric\", basis: \"Generic catalogue lens \" + lens.name + \" (#549): the inverse of its own distortion, lateral colour and vignetting.\",\n residualNote: \"Generic approximation, not a measured lens; axial colour, coma and the other aberrations stay.\", sources: [...GENERIC_LENS_EVIDENCE] },\n components: [\n { ...identity, id: \"distortion\", kind: \"geometry\", residualNote: \"Corrects the lens's radial distortion r_d = r(1 + k1ρ²); the stretched corners lose resolution.\",\n transform: { id: lens.lensId + \":distortion\", version: \"1.0.0\", domain, purpose: \"distortion\", kind: \"radial\",\n profile: { normalizationRadiusMm: lens.halfDiagonalMm, maximumNormalizedRadius: 1.05, coefficients: { k1: c.distortion.corner, k2: 0, k3: 0 } } } },\n { ...identity, id: \"lateral-ca\", kind: \"lateral-ca\", residualNote: \"Registers the channels at one effective wavelength each; colour inside a channel's band and axial colour stay.\",\n transforms: { red: affine(\"red\"), green: affine(\"green\"), blue: affine(\"blue\") } },\n { ...identity, id: \"vignetting\", kind: \"peripheral-illumination\", residualNote: \"Gain 1/E at the profile's aperture; it raises corner noise with the signal.\",\n profile: { ...c.vignetting.profile, maximumNormalizedRadius: 1.05 }, strength: 1 }\n ] });\n}\n"]}
@@ -27,6 +27,7 @@ one-call capture, see the [Quick Start](QUICK_START.md). For every export, see t
27
27
  | Depth of field and hyperfocal distance | [`calculateDepthOfField`](api/optics-depth-of-field.md#calculatedepthoffield) | Thin-lens model. `farLimitM` is `null` when it is infinite. |
28
28
  | Blur of an out-of-focus subject | [`calculateDefocusCircle`](api/optics-depth-of-field.md#calculatedefocuscircle) | Geometric blur-circle diameter at the sensor. |
29
29
  | Check a defocus blur before rendering it | [`calculateDefocusDiscKernelRadius`](api/optics-defocus-disc-kernel.md#calculatedefocusdisckernelradius) | Whether `lens.defocus` renders the disc or refuses it (kernel radius at most `DEFOCUS_DISC_KERNEL_MAXIMUM_RADIUS_SITES`, 64 sites), and by how much it is over. |
30
+ | A generic catalogue lens and its character | [`resolveGenericLens`](api/equipment-generic-lens-catalogue.md#resolvegenericlens) | 30 Photivra primes ("PV 50 1.2 Pro", "PV 50 1.8"), every value a labelled approximation; `simulateCapture` renders one with `lens.catalogue` ([catalogue](https://github.com/Photivra/photivra/blob/main/docs/LENS_CATALOGUE.md)). |
30
31
  | Field (angle) of view | [`calculateFieldOfView`](api/camera-field-of-view.md#calculatefieldofview) | Pass the sensor width, height or diagonal for that angle. |
31
32
  | Crop factor and 35 mm-equivalent focal length | [`calculateEquivalentFocalLength35Mm`](api/camera-equivalent-focal-length.md#calculateequivalentfocallength35mm) | Diagonal-based, against a 36 × 24 mm frame. |
32
33
  | Pixels left after a centred crop | [`calculateCenteredCrop`](api/output-crop.md#calculatecenteredcrop) | Keeps the aspect ratio to whole pixels. |
@@ -1,6 +1,6 @@
1
1
  # Public API reference
2
2
 
3
- Generated from the exact root exports for **@photivra/engine 2.16.0 / root contract 2.16.0** by `node scripts/generate-api-reference.mjs`. No module deep import is supported.
3
+ Generated from the exact root exports for **@photivra/engine 2.17.0 / root contract 2.17.0** by `node scripts/generate-api-reference.mjs`. No module deep import is supported.
4
4
 
5
5
  Use [the developer guide](https://github.com/Photivra/photivra/blob/main/docs/DEVELOPERS.md) for executable paths and scientific boundaries. Types are contracts, not runtime validation: parse untrusted data, preserve units/reference frames, read assessment blockers, and retain evidence/limitations. Optional properties do not imply a universal default. Model/schema IDs remain independent of the distribution.
6
6
 
@@ -827,6 +827,28 @@ Start here to find a function by task. [Using Photivra from an agent](AGENT_GUID
827
827
  | [`ResolveGenericEquipmentExposureCapabilitiesInput`](api/equipment-exposure-capabilities.md#resolvegenericequipmentexposurecapabilitiesinput) | Type |
828
828
  | [`WidestAvailableFNumberCapability`](api/equipment-exposure-capabilities.md#widestavailablefnumbercapability) | Type |
829
829
 
830
+ ## equipment/generic-lens-catalogue.ts
831
+
832
+ [Detailed contracts](api/equipment-generic-lens-catalogue.md) · [Source](https://github.com/Photivra/photivra/blob/main/src/equipment/generic-lens-catalogue.ts)
833
+
834
+ | Export | Kind |
835
+ | --- | --- |
836
+ | [`GENERIC_LENS_CATALOGUE`](api/equipment-generic-lens-catalogue.md#generic_lens_catalogue) | Runtime |
837
+ | [`GENERIC_LENS_IDS`](api/equipment-generic-lens-catalogue.md#generic_lens_ids) | Runtime |
838
+ | [`GENERIC_LENS_SET_BASELINES`](api/equipment-generic-lens-catalogue.md#generic_lens_set_baselines) | Runtime |
839
+ | [`GenericLensAdjustments`](api/equipment-generic-lens-catalogue.md#genericlensadjustments) | Type |
840
+ | [`GenericLensCatalogueEntry`](api/equipment-generic-lens-catalogue.md#genericlenscatalogueentry) | Type |
841
+ | [`GenericLensCharacter`](api/equipment-generic-lens-catalogue.md#genericlenscharacter) | Type |
842
+ | [`GenericLensCharacterValues`](api/equipment-generic-lens-catalogue.md#genericlenscharactervalues) | Type |
843
+ | [`GenericLensFocusError`](api/equipment-generic-lens-catalogue.md#genericlensfocuserror) | Type |
844
+ | [`GenericLensKind`](api/equipment-generic-lens-catalogue.md#genericlenskind) | Type |
845
+ | [`GenericLensSet`](api/equipment-generic-lens-catalogue.md#genericlensset) | Type |
846
+ | [`ResolvedGenericLens`](api/equipment-generic-lens-catalogue.md#resolvedgenericlens) | Type |
847
+ | [`resolveGenericLens`](api/equipment-generic-lens-catalogue.md#resolvegenericlens) | Runtime |
848
+ | [`ResolveGenericLensInput`](api/equipment-generic-lens-catalogue.md#resolvegenericlensinput) | Type |
849
+ | [`simulateGenericLensFocusError`](api/equipment-generic-lens-catalogue.md#simulategenericlensfocuserror) | Runtime |
850
+ | [`SimulateGenericLensFocusErrorInput`](api/equipment-generic-lens-catalogue.md#simulategenericlensfocuserrorinput) | Type |
851
+
830
852
  ## equipment/generic-prime-lenses.ts
831
853
 
832
854
  [Detailed contracts](api/equipment-generic-prime-lenses.md) · [Source](https://github.com/Photivra/photivra/blob/main/src/equipment/generic-prime-lenses.ts)
@@ -1575,6 +1597,22 @@ Start here to find a function by task. [Using Photivra from an agent](AGENT_GUID
1575
1597
  | [`LensComplexPupilPsfSampling`](api/optics-complex-pupil-psf.md#lenscomplexpupilpsfsampling) | Type |
1576
1598
  | [`parseLensComplexPupilProfile`](api/optics-complex-pupil-psf.md#parselenscomplexpupilprofile) | Runtime |
1577
1599
 
1600
+ ## optics/defocus-bokeh-kernel.ts
1601
+
1602
+ [Detailed contracts](api/optics-defocus-bokeh-kernel.md) · [Source](https://github.com/Photivra/photivra/blob/main/src/optics/defocus-bokeh-kernel.ts)
1603
+
1604
+ | Export | Kind |
1605
+ | --- | --- |
1606
+ | [`calculateDefocusBokehKernel`](api/optics-defocus-bokeh-kernel.md#calculatedefocusbokehkernel) | Runtime |
1607
+ | [`CalculateDefocusBokehKernelInput`](api/optics-defocus-bokeh-kernel.md#calculatedefocusbokehkernelinput) | Type |
1608
+ | [`DEFOCUS_BOKEH_MAXIMUM_BLADES`](api/optics-defocus-bokeh-kernel.md#defocus_bokeh_maximum_blades) | Runtime |
1609
+ | [`DefocusBokehBlades`](api/optics-defocus-bokeh-kernel.md#defocusbokehblades) | Type |
1610
+ | [`DefocusBokehCatsEye`](api/optics-defocus-bokeh-kernel.md#defocusbokehcatseye) | Type |
1611
+ | [`DefocusBokehClippingAperture`](api/optics-defocus-bokeh-kernel.md#defocusbokehclippingaperture) | Type |
1612
+ | [`DefocusBokehKernel`](api/optics-defocus-bokeh-kernel.md#defocusbokehkernel) | Type |
1613
+ | [`DefocusBokehOptions`](api/optics-defocus-bokeh-kernel.md#defocusbokehoptions) | Type |
1614
+ | [`DefocusBokehSphericalAberration`](api/optics-defocus-bokeh-kernel.md#defocusbokehsphericalaberration) | Type |
1615
+
1578
1616
  ## optics/defocus-disc-kernel.ts
1579
1617
 
1580
1618
  [Detailed contracts](api/optics-defocus-disc-kernel.md) · [Source](https://github.com/Photivra/photivra/blob/main/src/optics/defocus-disc-kernel.ts)
@@ -2029,6 +2067,16 @@ Start here to find a function by task. [Using Photivra from an agent](AGENT_GUID
2029
2067
  | [`CalculateCenteredCropInput`](api/output-crop.md#calculatecenteredcropinput) | Type |
2030
2068
  | [`CenteredCrop`](api/output-crop.md#centeredcrop) | Type |
2031
2069
 
2070
+ ## output/generic-lens-corrections.ts
2071
+
2072
+ [Detailed contracts](api/output-generic-lens-corrections.md) · [Source](https://github.com/Photivra/photivra/blob/main/src/output/generic-lens-corrections.ts)
2073
+
2074
+ | Export | Kind |
2075
+ | --- | --- |
2076
+ | [`createGenericLensCorrectionProfile`](api/output-generic-lens-corrections.md#creategenericlenscorrectionprofile) | Runtime |
2077
+ | [`CreateGenericLensCorrectionProfileInput`](api/output-generic-lens-corrections.md#creategenericlenscorrectionprofileinput) | Type |
2078
+ | [`GENERIC_LENS_CORRECTION_CHANNEL_WAVELENGTHS_NM`](api/output-generic-lens-corrections.md#generic_lens_correction_channel_wavelengths_nm) | Runtime |
2079
+
2032
2080
  ## output/geometric-transforms.ts
2033
2081
 
2034
2082
  [Detailed contracts](api/output-geometric-transforms.md) · [Source](https://github.com/Photivra/photivra/blob/main/src/output/geometric-transforms.ts)
@@ -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"`. |
@@ -191,6 +193,46 @@ kept, and the assumption `lens.prime` records the resolved lens. `resolveGeneric
191
193
  same lens for any sensor size. `lens.focalLengthMm` keeps working as before and cannot be combined with
192
194
  `lens.prime`.
193
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
+
194
236
  ### Fitting a chart to the frame (2.3)
195
237
 
196
238
  `calculateSubjectDistanceForFraming({ sensor, focalLengthMm | equivalentFocalLengthMm, chart, fit? })`
@@ -1005,15 +1047,20 @@ The optics options feed only what the environment site contract accepts:
1005
1047
  - A test blurs a sampled straight edge with the kernel and compares it with the disc's closed-form
1006
1048
  edge-spread function, the circular-segment area. They agree to rounding (2e-13) when the edge lies on cell
1007
1049
  boundaries. Elsewhere they agree within the site sampling of the edge.
1008
- - The kernel radius is capped at 64 sites, a 129 × 129 window (`DEFOCUS_DISC_KERNEL_MAXIMUM_RADIUS_SITES`). A larger
1009
- 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.
1010
1056
  - **Check before rendering (#532):** `calculateDefocusDiscKernelRadius({ focalLengthMm, aperture, focusDistanceM,
1011
1057
  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.
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.
1017
1064
  - **The capture renders it** (#492, step 2a). The scene radiance on the site lattice is convolved with the disc,
1018
1065
  the same kernel at every spectral node, and each site query is answered with the result. This is the object-space
1019
1066
  convolution that `lens.diffraction` uses.
@@ -1022,7 +1069,8 @@ The optics options feed only what the environment site contract accepts:
1022
1069
  - `diagnostics.options` lists `lens.defocus`, and `diagnostics.timingsMs.defocus` times the provider's convolution.
1023
1070
  - The site inputs declare no PSF, because the defocus is applied upstream.
1024
1071
  - **With `lens.diffraction`**, each spectral node's diffraction kernel is convolved with the disc and applied as
1025
- 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).
1026
1074
  - This is a further approximation, and the assumption says so. The defocused wave PSF of a circular pupil is not
1027
1075
  exactly the in-focus Airy pattern convolved with the geometric disc. Where the two differ is measured in a
1028
1076
  later step of #492.
@@ -1075,9 +1123,8 @@ The optics options feed only what the environment site contract accepts:
1075
1123
  - **Refused:** camera rotation with `motion.shutter: "rolling"` (each row has its own instants, so each row would
1076
1124
  need its own image of the 2R + 1 rows the disc reaches), and a vignetting or distortion profile that does not
1077
1125
  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.
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)).
1081
1128
  - **Sampling** is the lattice sampling below, unchanged. Measured against a fine-grid oracle: the environment PSF
1082
1129
  quadrature with the disc sampled 5 times finer than the site pitch (3 times with the box aperture), on a 16 × 12
1083
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
@@ -1143,7 +1190,7 @@ The optics options feed only what the environment site contract accepts:
1143
1190
  - The tabulated and environment paths give the same RAW.
1144
1191
  - Regions and DNG parts equal the whole frame: chart, Quality options with diffraction, a `material-layers`
1145
1192
  scene and a caller function.
1146
- - **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
1147
1194
  sees more than one material. Uniform areas cost almost nothing on the tabulated path. Measured on the tabulated
1148
1195
  capture path (Node 24, one thread, rounds interleaved over the radii, median [range] of 3 rounds; 2 at 2048 × 1366;
1149
1196
  a shared 4-vCPU host):
@@ -1156,10 +1203,9 @@ The optics options feed only what the environment site contract accepts:
1156
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] |
1157
1204
  | Siemens star, 2048 × 1366 | 1.39 s | | 1.8 s | | 10.4 s | | 38.3 s [37.5–38.3] |
1158
1205
 
1159
- - 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
1160
- 2 m, focus 1.6 m: R = 41) with room for larger blurs.
1161
- - The time grows with R² per edge site, and the caller chooses it through the focus distance. Past the cap the
1162
- 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.
1163
1209
  - A 24 MP frame costs proportionally more. Render it in parts across workers, as for diffraction.
1164
1210
  - Off the tabulated path (caller reflectance or radiance functions), the provider gathers only each 32-row band's
1165
1211
  own values, not the 2R extra rows a full-grid convolution would return (#419). On a 256 × 171
@@ -1167,6 +1213,183 @@ The optics options feed only what the environment site contract accepts:
1167
1213
  - It cannot be combined with `lens.pupilSampling` (the rays already render defocus) or `lens.psf` (a caller PSF
1168
1214
  declares its own defocus). It can be combined with `lens.diffraction`.
1169
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
+
1170
1393
  ### Pixel aperture (2.3)
1171
1394
 
1172
1395
  By default each site samples the irradiance at its centre (`sensor.pixelAperture: "point"`). Scene
@@ -1736,6 +1959,14 @@ request took 73 s instead of 103 s). In 2.6.0 the results were byte-identical to
1736
1959
  merged, which took 222 s, 450 s and 837 s on the phone; later releases changed some views' values (for example the
1737
1960
  exact area mean, 2.8.0; AHD's choice, 2.13.0).
1738
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
+
1739
1970
  **Options.** `view.width` and `view.height` (1 to the capture's output size), `view.samplesPerAxis` (1–32),
1740
1971
  `view.format` (`"srgb-8"` default or `"srgb-16"`) and `view.region` (a rectangle in display pixels, byte-identical to
1741
1972
  the same pixels of the whole view, for tiling across workers). The capture's file-format options (`format`,
@@ -1821,6 +2052,15 @@ provided, for rights reasons.
1821
2052
  independent test). The original analysis is G. B. Airy, "On the Diffraction of an Object-glass with
1822
2053
  Circular Aperture", *Transactions of the Cambridge Philosophical Society* 5, 1835, pp. 283–291. The
1823
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.
1824
2064
  - Print planning (`calculatePrintPlan`, `calculatePrintSizeLimit`): Z. Alomar, K. D. Bojikian, "Visual Acuity
1825
2065
  Testing in Adults", EyeWiki, American Academy of Ophthalmology, last edited June 16, 2026, free to read at
1826
2066
  <https://eyewiki.aao.org/Visual_Acuity_Testing_in_Adults> (the minimum angle of resolution of an ideal eye under optimal
package/docs/USAGE.md CHANGED
@@ -1455,7 +1455,7 @@ 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:
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` (512 sites, a disc 1025 sites across, 6.15 mm at 6 µm; 64 sites before 2.17.0) and refuse a larger one. Up to 64 sites the disc is convolved directly, as before; above it, by runs of equal kernel weights (#564), the same sums at a cost that grows with the radius instead of its square ([Quick start, Large defocus discs](QUICK_START.md#large-defocus-discs-564)). 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, and `convolution` says which method renders it. Pass the f-number the capture uses (in an automatic exposure mode, the resolved one) and the sensor's pixel pitch:
1459
1459
 
1460
1460
  ```ts
1461
1461
  import { calculateDefocusDiscKernelRadius } from "@photivra/engine";
@@ -1463,13 +1463,14 @@ import { calculateDefocusDiscKernelRadius } from "@photivra/engine";
1463
1463
  const check = calculateDefocusDiscKernelRadius({
1464
1464
  focalLengthMm: 50,
1465
1465
  aperture: 1.4,
1466
- focusDistanceM: 1,
1466
+ focusDistanceM: 0.25,
1467
1467
  subjectDistanceM: 2,
1468
1468
  pixelPitchMicrometers: 6
1469
1469
  });
1470
1470
 
1471
- console.log(check.value.withinLimit); // false: lens.defocus refuses this request
1472
- console.log(check.value.kernelRadiusSites, check.value.excessKernelRadiusSites); // 78, 14
1471
+ console.log(check.value.withinLimit); // false: lens.defocus refuses this request (a 7.81 mm blur, 1,303 sites across)
1472
+ console.log(check.value.kernelRadiusSites, check.value.excessKernelRadiusSites); // 651, 139
1473
+ // Focused at 1 m instead: 157 sites across, radius 78, withinLimit true, convolution "runs".
1473
1474
  ```
1474
1475
 
1475
1476
  See [Physics Foundation](https://github.com/Photivra/photivra/blob/main/docs/PHYSICS_FOUNDATION.md#thin-lens-focus-and-depth-of-field).
@@ -7470,6 +7471,28 @@ console.log(calculateGeometricFocusBreathing({
7470
7471
  }).value.deltaDegrees);
7471
7472
  ```
7472
7473
 
7474
+ ## Generic lens catalogue (#549)
7475
+
7476
+ `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
7477
+ own designs, not real products). `resolveGenericLens` resolves one for a sensor, an f-number and a focus distance: its focal length
7478
+ (scaled by the crop factor), aperture range, close focus, projection and its character, Seidel aberrations, axial and lateral
7479
+ colour, distortion and vignetting profiles, flare, breathing and focus scatter, each a labelled approximation.
7480
+ `simulateGenericLensFocusError` draws a seeded autofocus error; `createGenericLensCorrectionProfile` builds the lens's generic
7481
+ correction profile for `resolveLensCorrectionPlan`. `simulateCapture` renders a catalogue lens with `lens.catalogue`
7482
+ ([quick start](QUICK_START.md#generic-lens-catalogue-549)). Equations, tables and the chart measurements:
7483
+ [LENS_CATALOGUE.md](https://github.com/Photivra/photivra/blob/main/docs/LENS_CATALOGUE.md).
7484
+
7485
+ ```ts
7486
+ import { GENERIC_LENS_CATALOGUE, resolveGenericLens, simulateGenericLensFocusError } from "@photivra/engine";
7487
+
7488
+ const frame = { widthMm: 36, heightMm: 24 };
7489
+ const pro = resolveGenericLens({ lensId: "pv-50-1.2-pro", activeImagingArea: frame, fNumber: 2 });
7490
+ const consumer = resolveGenericLens({ lensId: "pv-50-1.8", activeImagingArea: frame, fNumber: 2 });
7491
+ console.log(GENERIC_LENS_CATALOGUE.length, pro.character.aberrations.comaWaves < consumer.character.aberrations.comaWaves); // 30 true
7492
+ const shot = simulateGenericLensFocusError({ lensId: "pv-50-1.8", activeImagingArea: frame, fNumber: 1.8, focusDistanceM: 2, seed: 1, shotIndex: 0 });
7493
+ console.log(shot.focusDistanceM, shot.imageSideErrorMicrometers);
7494
+ ```
7495
+
7473
7496
  ## Parallel native environment RAW (row-band partitions, 2.0)
7474
7497
 
7475
7498
  `createNativeEnvironmentRawTask` runs on one thread. For several cores, split the capture into row bands with
@@ -7814,6 +7837,20 @@ region that have a clipped contributing site. `simulateCapture` counts the same
7814
7837
  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
7838
  [Highlight count](https://github.com/Photivra/photivra/blob/main/docs/DISPLAY_VIEW.md#highlight-count-533).
7816
7839
 
7840
+ From #563 the view renders `lens.projection`, `lens.distortion`, `lens.vignetting` and camera rotation (global
7841
+ shutter), in focus and with `lens.defocus` out of focus, with each real site's expected electrons as the capture's. These
7842
+ requests run on the CPU (`gpu: "auto"` falls back labelled `unsupported-on-backend`); rotation with a rolling shutter is
7843
+ refused. See [Lens projection, distortion, vignetting and rotation](https://github.com/Photivra/photivra/blob/main/docs/DISPLAY_VIEW.md#lens-projection-distortion-vignetting-and-rotation).
7844
+
7845
+ ```ts
7846
+ import { simulateDisplayView } from "@photivra/engine";
7847
+
7848
+ const fisheye = await simulateDisplayView({ scene: { preset: "siemens-star" }, sensor: { pixelPitchMicrometers: 3 }, output: { width: 120, height: 80 },
7849
+ lens: { projection: "fisheye-equisolid-angle", vignetting: { normalizationRadiusMm: 0.25, maximumNormalizedRadius: 1, coefficients: { r2: -0.4, r4: 0, r6: 0 } } } },
7850
+ { width: 30, height: 20 });
7851
+ console.log(fisheye.kind, fisheye.assumptions.find((a) => a.id === "lens.vignetting")?.kind); // "display-view" "model"
7852
+ ```
7853
+
7817
7854
  #### Rendering a prepared view on the GPU (2.11, #481)
7818
7855
 
7819
7856
  `prepareDisplayView(request, { gpu })` chooses the renderer: `"off"` (the default) is the CPU reference, `"auto"` the GPU
@@ -7923,7 +7960,7 @@ console.log(barrel.request.siteInput(0).environment.lensFieldMapping?.distortion
7923
7960
  lattice, and the capture convolves the scene radiance with it, which is a geometric approximation. With
7924
7961
  `lens.diffraction`, each spectral node's diffraction kernel is convolved with the disc, which is a further
7925
7962
  approximation. The `lens.defocus` assumption records the diameter in µm and in sites, the kernel radius and the
7926
- execution. `simulateDisplayView` does not render it yet. See [Optics](QUICK_START.md#optics).
7963
+ execution. `simulateDisplayView` renders it too, also with projection, distortion, vignetting and rotation (#563). See [Optics](QUICK_START.md#optics).
7927
7964
 
7928
7965
  ```ts
7929
7966
  import { simulateCapture } from "@photivra/engine";
@@ -7937,6 +7974,21 @@ const blurred = await simulateCapture({
7937
7974
  console.log(blurred.assumptions.find((a) => a.id === "lens.defocus")?.value); // diameterSites ≈ 5.95, kernelRadiusSites 3
7938
7975
  ```
7939
7976
 
7977
+ Bokeh character (#553): `lens.defocus` also takes `blades` (a polygonal or rounded-blade opening), `sphericalAberration`
7978
+ (primary W040: a soft-edged blur on one side of the focus plane and a bright-rimmed one on the other) and `catsEye`
7979
+ (mechanical vignetting by field position; `simulateCapture` only). Each is off by default. `calculateDefocusBokehKernel`
7980
+ returns the kernel without rendering. See [Bokeh character](QUICK_START.md#bokeh-character-553).
7981
+
7982
+ ```ts
7983
+ import { calculateDefocusBokehKernel } from "@photivra/engine";
7984
+
7985
+ const kernel = calculateDefocusBokehKernel({
7986
+ focalLengthMm: 50, aperture: 4, focusDistanceM: 1.4, subjectDistanceM: 2, pixelPitchMicrometers: 6,
7987
+ bokeh: { blades: { count: 5 }, sphericalAberration: { w040Micrometers: 0.5, atFNumber: 4 } }
7988
+ });
7989
+ console.log(kernel.value.side, kernel.value.kernelRadiusSites, kernel.value.method); // "behind-focus", 17, "pupil-ray-binning"
7990
+ ```
7991
+
7940
7992
  `motion.angularVelocityRadPerSec` rotates the camera during the exposure (8 time midpoints by
7941
7993
  default). `motion.shutter: "rolling"` derives each site's exposure window from the body's rolling
7942
7994
  readout, with `resolveElectronicRollingShutterExposureSchedule`. See [Motion](QUICK_START.md#motion).
@@ -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.16.0" as const
14
+ ENGINE_API_VERSION = "2.17.0" as const
15
15
  ```