@photivra/engine 2.16.0 → 2.18.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 (107) hide show
  1. package/CHANGELOG.md +104 -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/camera/sensor-equivalence.d.ts +156 -0
  10. package/dist/camera/sensor-equivalence.js +208 -0
  11. package/dist/camera/sensor-equivalence.js.map +1 -0
  12. package/dist/capture/native-environment-tabulated.d.ts +5 -0
  13. package/dist/capture/native-environment-tabulated.js +1 -1
  14. package/dist/capture/native-environment-tabulated.js.map +1 -1
  15. package/dist/core/version.d.ts +1 -1
  16. package/dist/core/version.js +1 -1
  17. package/dist/core/version.js.map +1 -1
  18. package/dist/equipment/generic-lens-catalogue.d.ts +227 -0
  19. package/dist/equipment/generic-lens-catalogue.js +243 -0
  20. package/dist/equipment/generic-lens-catalogue.js.map +1 -0
  21. package/dist/equipment/ideal-lens.d.ts +25 -0
  22. package/dist/equipment/ideal-lens.js +21 -0
  23. package/dist/equipment/ideal-lens.js.map +1 -0
  24. package/dist/facade/catalogue-lens-signal.d.ts +94 -0
  25. package/dist/facade/catalogue-lens-signal.js +362 -0
  26. package/dist/facade/catalogue-lens-signal.js.map +1 -0
  27. package/dist/facade/defocus-bokeh-zones.d.ts +33 -0
  28. package/dist/facade/defocus-bokeh-zones.js +53 -0
  29. package/dist/facade/defocus-bokeh-zones.js.map +1 -0
  30. package/dist/facade/defocus-image-space-provider.d.ts +9 -0
  31. package/dist/facade/defocus-image-space-provider.js +101 -9
  32. package/dist/facade/defocus-image-space-provider.js.map +1 -1
  33. package/dist/facade/diffraction-provider.d.ts +29 -0
  34. package/dist/facade/diffraction-provider.js +117 -11
  35. package/dist/facade/diffraction-provider.js.map +1 -1
  36. package/dist/facade/display-view-gpu-session.js +4 -0
  37. package/dist/facade/display-view-gpu-session.js.map +1 -1
  38. package/dist/facade/display-view-gpu.d.ts +10 -0
  39. package/dist/facade/display-view-gpu.js.map +1 -1
  40. package/dist/facade/display-view-lens-signal.d.ts +61 -0
  41. package/dist/facade/display-view-lens-signal.js +502 -0
  42. package/dist/facade/display-view-lens-signal.js.map +1 -0
  43. package/dist/facade/expected-signal-setup.d.ts +15 -0
  44. package/dist/facade/expected-signal-setup.js +19 -2
  45. package/dist/facade/expected-signal-setup.js.map +1 -1
  46. package/dist/facade/expected-signal.d.ts +13 -0
  47. package/dist/facade/expected-signal.js +155 -1
  48. package/dist/facade/expected-signal.js.map +1 -1
  49. package/dist/facade/generic-lens-capture.d.ts +7 -0
  50. package/dist/facade/generic-lens-capture.js +9 -0
  51. package/dist/facade/generic-lens-capture.js.map +1 -0
  52. package/dist/facade/large-defocus-lattice.d.ts +39 -0
  53. package/dist/facade/large-defocus-lattice.js +64 -0
  54. package/dist/facade/large-defocus-lattice.js.map +1 -0
  55. package/dist/facade/scene-radiance.d.ts +5 -0
  56. package/dist/facade/scene-radiance.js +5 -2
  57. package/dist/facade/scene-radiance.js.map +1 -1
  58. package/dist/facade/simulate-capture.d.ts +67 -7
  59. package/dist/facade/simulate-capture.js +400 -64
  60. package/dist/facade/simulate-capture.js.map +1 -1
  61. package/dist/facade/simulate-display-view.js +23 -17
  62. package/dist/facade/simulate-display-view.js.map +1 -1
  63. package/dist/gpu/display-view-quality-gpu.d.ts +0 -9
  64. package/dist/gpu/display-view-quality-gpu.js +6 -0
  65. package/dist/gpu/display-view-quality-gpu.js.map +1 -1
  66. package/dist/gpu/display-view-speed-gpu.js +2 -0
  67. package/dist/gpu/display-view-speed-gpu.js.map +1 -1
  68. package/dist/index.d.ts +6 -1
  69. package/dist/index.js +5 -0
  70. package/dist/index.js.map +1 -1
  71. package/dist/optics/defocus-bokeh-kernel.d.ts +207 -0
  72. package/dist/optics/defocus-bokeh-kernel.js +380 -0
  73. package/dist/optics/defocus-bokeh-kernel.js.map +1 -0
  74. package/dist/optics/defocus-disc-kernel.d.ts +41 -19
  75. package/dist/optics/defocus-disc-kernel.js +44 -23
  76. package/dist/optics/defocus-disc-kernel.js.map +1 -1
  77. package/dist/optics/generic-lens-psf.d.ts +68 -0
  78. package/dist/optics/generic-lens-psf.js +337 -0
  79. package/dist/optics/generic-lens-psf.js.map +1 -0
  80. package/dist/optics/illumination-vignetting.d.ts +6 -0
  81. package/dist/optics/illumination-vignetting.js +24 -7
  82. package/dist/optics/illumination-vignetting.js.map +1 -1
  83. package/dist/optics/sensor-environment-query.d.ts +6 -0
  84. package/dist/optics/sensor-environment-query.js +13 -6
  85. package/dist/optics/sensor-environment-query.js.map +1 -1
  86. package/dist/optics/site-kernel-runs.d.ts +77 -0
  87. package/dist/optics/site-kernel-runs.js +200 -0
  88. package/dist/optics/site-kernel-runs.js.map +1 -0
  89. package/dist/output/generic-lens-corrections.d.ts +24 -0
  90. package/dist/output/generic-lens-corrections.js +51 -0
  91. package/dist/output/generic-lens-corrections.js.map +1 -0
  92. package/docs/AGENT_GUIDE.md +4 -0
  93. package/docs/API_REFERENCE.md +77 -1
  94. package/docs/QUICK_START.md +305 -17
  95. package/docs/USAGE.md +98 -5
  96. package/docs/api/camera-sensor-equivalence.md +209 -0
  97. package/docs/api/core-version.md +1 -1
  98. package/docs/api/equipment-generic-lens-catalogue.md +281 -0
  99. package/docs/api/equipment-ideal-lens.md +26 -0
  100. package/docs/api/exports.json +202 -2
  101. package/docs/api/facade-simulate-capture.md +48 -4
  102. package/docs/api/optics-defocus-bokeh-kernel.md +145 -0
  103. package/docs/api/optics-defocus-disc-kernel.md +22 -15
  104. package/docs/api/optics-sensor-environment-query.md +6 -0
  105. package/docs/api/output-generic-lens-corrections.md +39 -0
  106. package/llms.txt +3 -0
  107. 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":"AAAA,sCAAsC;AAEtC;;;;;;GAMG;AAEH,OAAO,EAAE,2BAA2B,EAAE,MAAM,uBAAuB,CAAC;AACpE,OAAO,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,MAAM,8BAA8B,CAAC;AACpF,OAAO,EAAE,wBAAwB,EAA4B,MAAM,+BAA+B,CAAC;AACnG,OAAO,EAAE,qBAAqB,EAAE,qBAAqB,EAAE,kBAAkB,EAAE,MAAM,wCAAwC,CAAC;AAC1H,OAAO,EAAE,iCAAiC,EAAiE,MAAM,uBAAuB,CAAC;AAGzI;;;GAGG;AACH,MAAM,CAAC,MAAM,8CAA8C,GAAoD,eAAe,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,GAAG,EAAE,GAAG,EAAE,KAAK,EAAE,GAAG,EAAE,IAAI,EAAE,GAAG,EAAE,CAAC,CAAC;AAWlL;;;;GAIG;AACH,MAAM,UAAU,kCAAkC,CAAC,KAA8C;IAC/F,kBAAkB,CAAC,KAAK,EAAE,0CAA0C,CAAC,CAAC;IACtE,gBAAgB,CAAC,KAAK,EAAE,oCAAoC,EAAE,CAAC,QAAQ,EAAE,mBAAmB,EAAE,OAAO,CAAC,CAAC,CAAC;IACxG,MAAM,KAAK,GAAG,wBAAwB,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;IACpD,MAAM,IAAI,GAAG,kBAAkB,CAAC,EAAE,MAAM,EAAE,KAAK,CAAC,MAAM,EAAE,iBAAiB,EAAE,KAAK,CAAC,iBAAiB,EAAE,OAAO,EAAE,KAAK,CAAC,QAAQ,EAAE,CAAC,CAAC;IAC/H,IAAI,KAAK,CAAC,MAAM,KAAK,IAAI,CAAC,MAAM;QAAE,MAAM,IAAI,2BAA2B,CAAC,wCAAwC,GAAG,IAAI,CAAC,MAAM,GAAG,GAAG,CAAC,CAAC;IACtI,IAAI,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,aAAa,GAAG,IAAI,CAAC,aAAa,CAAC,GAAG,IAAI,GAAG,IAAI,CAAC,aAAa;QAChF,MAAM,IAAI,2BAA2B,CAAC,kCAAkC,GAAG,IAAI,CAAC,IAAI,GAAG,kCAAkC,GAAG,IAAI,CAAC,aAAa,GAAG,MAAM,CAAC,CAAC;IAC3J,MAAM,CAAC,GAAG,IAAI,CAAC,SAAS,EAAE,MAAM,GAAG,sBAA+B,CAAC;IACnE,MAAM,QAAQ,GAAG,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,QAAiB,EAAE,cAAc,EAAE,IAAI,EAAE,YAAY,EAAE,EAAc,EAAE,4BAA4B,EAAE,EAAc,EAAE,CAAC;IACjL,MAAM,KAAK,GAAG,CAAC,OAA8B,EAAU,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,YAAY,CAAC,QAAQ,GAAG,qBAAqB,CAAC,8CAA8C,CAAC,OAAO,CAAC,CAAC,CAAC;IACvK,MAAM,MAAM,GAAG,CAAC,OAA8B,EAA4B,EAAE,CAAC,CAAC,EAAE,EAAE,EAAE,IAAI,CAAC,MAAM,GAAG,cAAc,GAAG,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,YAAqB,EAAE,IAAI,EAAE,QAAiB;QAC3M,MAAM,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,KAAK,CAAC,OAAO,CAAC,CAAqC,EAAE,QAAQ,EAAE,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC;IAClH,OAAO,iCAAiC,CAAC,EAAE,aAAa,EAAE,OAAO,EAAE,EAAE,EAAE,WAAW,GAAG,IAAI,CAAC,MAAM,GAAG,cAAc,EAAE,OAAO,EAAE,OAAO,EAAE,KAAK;QACxI,QAAQ,EAAE,EAAE,IAAI,EAAE,oBAAoB,EAAE,KAAK,EAAE,yBAAyB,GAAG,IAAI,CAAC,IAAI,GAAG,4EAA4E;YACjK,YAAY,EAAE,gGAAgG,EAAE,OAAO,EAAE,CAAC,GAAG,qBAAqB,CAAC,EAAE;QACvJ,UAAU,EAAE;YACV,EAAE,GAAG,QAAQ,EAAE,EAAE,EAAE,YAAY,EAAE,IAAI,EAAE,UAAU,EAAE,YAAY,EAAE,iGAAiG;gBAChK,SAAS,EAAE,EAAE,EAAE,EAAE,IAAI,CAAC,MAAM,GAAG,aAAa,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,YAAY,EAAE,IAAI,EAAE,QAAQ;oBAC3G,OAAO,EAAE,EAAE,qBAAqB,EAAE,IAAI,CAAC,cAAc,EAAE,uBAAuB,EAAE,IAAI,EAAE,YAAY,EAAE,EAAE,EAAE,EAAE,CAAC,CAAC,UAAU,CAAC,MAAM,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,EAAE,EAAE;YACvJ,EAAE,GAAG,QAAQ,EAAE,EAAE,EAAE,YAAY,EAAE,IAAI,EAAE,YAAY,EAAE,YAAY,EAAE,gHAAgH;gBACjL,UAAU,EAAE,EAAE,GAAG,EAAE,MAAM,CAAC,KAAK,CAAC,EAAE,KAAK,EAAE,MAAM,CAAC,OAAO,CAAC,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,CAAC,EAAE,EAAE;YACpF,EAAE,GAAG,QAAQ,EAAE,EAAE,EAAE,YAAY,EAAE,IAAI,EAAE,yBAAyB,EAAE,YAAY,EAAE,6EAA6E;gBAC3J,OAAO,EAAE,EAAE,GAAG,CAAC,CAAC,UAAU,CAAC,OAAO,EAAE,uBAAuB,EAAE,IAAI,EAAE,EAAE,QAAQ,EAAE,CAAC,EAAE;SACrF,EAAE,CAAC,CAAC;AACT,CAAC","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,8 +27,12 @@ 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)). |
31
+ | Render a lens by its physical focal length and f-number | [`IDEAL_LENS_LIMITS`](api/equipment-ideal-lens.md#ideal_lens_limits) | `simulateCapture` / the display view with `lens.ideal: { focalLengthMm }` and `lens.fNumber`: an ideal thin lens on any sensor, f/0.5–f/128, 1–4000 mm; no crop-factor scaling. |
30
32
  | Field (angle) of view | [`calculateFieldOfView`](api/camera-field-of-view.md#calculatefieldofview) | Pass the sensor width, height or diagonal for that angle. |
31
33
  | Crop factor and 35 mm-equivalent focal length | [`calculateEquivalentFocalLength35Mm`](api/camera-equivalent-focal-length.md#calculateequivalentfocallength35mm) | Diagonal-based, against a 36 × 24 mm frame. |
34
+ | Equivalent f-number, sensor area ratio and angles of view of a lens on a sensor | [`calculateSensorEquivalence`](api/camera-sensor-equivalence.md#calculatesensorequivalence) | Call once per sensor. `equivalentFNumber` is for depth of field, blur and total light (an approximation); `exposureFNumber` is the f-number on the lens. `basis` says exact or approximated for every value. |
35
+ | Blur circle as a share of the frame or on a print | [`calculateBlurShareOfFrame`](api/camera-sensor-equivalence.md#calculateblurshareofframe) | Equal shares of the diagonal look the same at the same output size. |
32
36
  | Pixels left after a centred crop | [`calculateCenteredCrop`](api/output-crop.md#calculatecenteredcrop) | Keeps the aspect ratio to whole pixels. |
33
37
  | Motion blur for a shutter speed | [`calculateProjectedMotionBlur`](api/motion-projected-motion.md#calculateprojectedmotionblur) | Subject position and velocity in metres; blur in mm, and in pixels if you give a pixel pitch. |
34
38
  | Video shutter angle to exposure time | [`calculateShutterAngleExposureSeconds`](api/motion-video-sequence.md#calculateshutterangleexposureseconds) | Positional arguments: frame rate (Hz), angle (degrees). |
@@ -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.18.0 / root contract 2.18.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
 
@@ -16,6 +16,7 @@ Start here to find a function by task. [Using Photivra from an agent](AGENT_GUID
16
16
  | Depth of field, hyperfocal distance and defocus blur | [`calculateDepthOfField`](api/optics-depth-of-field.md#calculatedepthoffield), [`calculateDefocusCircle`](api/optics-depth-of-field.md#calculatedefocuscircle) |
17
17
  | Field of view | [`calculateFieldOfView`](api/camera-field-of-view.md#calculatefieldofview) |
18
18
  | Crop factor, 35 mm-equivalent focal length and crops | [`calculateEquivalentFocalLength35Mm`](api/camera-equivalent-focal-length.md#calculateequivalentfocallength35mm), [`calculateCenteredCrop`](api/output-crop.md#calculatecenteredcrop) |
19
+ | Equivalent f-number, sensor area and blur as a share of the frame | [`calculateSensorEquivalence`](api/camera-sensor-equivalence.md#calculatesensorequivalence), [`calculateBlurShareOfFrame`](api/camera-sensor-equivalence.md#calculateblurshareofframe) |
19
20
  | Motion blur and shutter speed | [`calculateProjectedMotionBlur`](api/motion-projected-motion.md#calculateprojectedmotionblur), [`calculateShutterAngleExposureSeconds`](api/motion-video-sequence.md#calculateshutterangleexposureseconds) |
20
21
  | Diffraction | [`calculateAiryDisk`](api/optics-diffraction.md#calculateairydisk) |
21
22
  | Sensor signal, noise and SNR | [`calculatePhotoelectrons`](api/sensor-signal-noise.md#calculatephotoelectrons), [`calculateSignalToNoise`](api/sensor-signal-noise.md#calculatesignaltonoise) |
@@ -79,6 +80,24 @@ Start here to find a function by task. [Using Photivra from an agent](AGENT_GUID
79
80
  | [`CalculateProjectedObjectSizeInput`](api/camera-projected-object-size.md#calculateprojectedobjectsizeinput) | Type |
80
81
  | [`ProjectedObjectSize`](api/camera-projected-object-size.md#projectedobjectsize) | Type |
81
82
 
83
+ ## camera/sensor-equivalence.ts
84
+
85
+ [Detailed contracts](api/camera-sensor-equivalence.md) · [Source](https://github.com/Photivra/photivra/blob/main/src/camera/sensor-equivalence.ts)
86
+
87
+ | Export | Kind |
88
+ | --- | --- |
89
+ | [`BlurShareOfFrame`](api/camera-sensor-equivalence.md#blurshareofframe) | Type |
90
+ | [`BlurShareOfPrint`](api/camera-sensor-equivalence.md#blurshareofprint) | Type |
91
+ | [`calculateBlurShareOfFrame`](api/camera-sensor-equivalence.md#calculateblurshareofframe) | Runtime |
92
+ | [`CalculateBlurShareOfFrameInput`](api/camera-sensor-equivalence.md#calculateblurshareofframeinput) | Type |
93
+ | [`calculateSensorEquivalence`](api/camera-sensor-equivalence.md#calculatesensorequivalence) | Runtime |
94
+ | [`CalculateSensorEquivalenceInput`](api/camera-sensor-equivalence.md#calculatesensorequivalenceinput) | Type |
95
+ | [`EquivalenceExactness`](api/camera-sensor-equivalence.md#equivalenceexactness) | Type |
96
+ | [`EquivalenceValueBasis`](api/camera-sensor-equivalence.md#equivalencevaluebasis) | Type |
97
+ | [`SensorEquivalence`](api/camera-sensor-equivalence.md#sensorequivalence) | Type |
98
+ | [`SensorEquivalenceFieldOfView`](api/camera-sensor-equivalence.md#sensorequivalencefieldofview) | Type |
99
+ | [`SensorEquivalenceQuantity`](api/camera-sensor-equivalence.md#sensorequivalencequantity) | Type |
100
+
82
101
  ## capture/capture-export-metadata.ts
83
102
 
84
103
  [Detailed contracts](api/capture-capture-export-metadata.md) · [Source](https://github.com/Photivra/photivra/blob/main/src/capture/capture-export-metadata.ts)
@@ -827,6 +846,28 @@ Start here to find a function by task. [Using Photivra from an agent](AGENT_GUID
827
846
  | [`ResolveGenericEquipmentExposureCapabilitiesInput`](api/equipment-exposure-capabilities.md#resolvegenericequipmentexposurecapabilitiesinput) | Type |
828
847
  | [`WidestAvailableFNumberCapability`](api/equipment-exposure-capabilities.md#widestavailablefnumbercapability) | Type |
829
848
 
849
+ ## equipment/generic-lens-catalogue.ts
850
+
851
+ [Detailed contracts](api/equipment-generic-lens-catalogue.md) · [Source](https://github.com/Photivra/photivra/blob/main/src/equipment/generic-lens-catalogue.ts)
852
+
853
+ | Export | Kind |
854
+ | --- | --- |
855
+ | [`GENERIC_LENS_CATALOGUE`](api/equipment-generic-lens-catalogue.md#generic_lens_catalogue) | Runtime |
856
+ | [`GENERIC_LENS_IDS`](api/equipment-generic-lens-catalogue.md#generic_lens_ids) | Runtime |
857
+ | [`GENERIC_LENS_SET_BASELINES`](api/equipment-generic-lens-catalogue.md#generic_lens_set_baselines) | Runtime |
858
+ | [`GenericLensAdjustments`](api/equipment-generic-lens-catalogue.md#genericlensadjustments) | Type |
859
+ | [`GenericLensCatalogueEntry`](api/equipment-generic-lens-catalogue.md#genericlenscatalogueentry) | Type |
860
+ | [`GenericLensCharacter`](api/equipment-generic-lens-catalogue.md#genericlenscharacter) | Type |
861
+ | [`GenericLensCharacterValues`](api/equipment-generic-lens-catalogue.md#genericlenscharactervalues) | Type |
862
+ | [`GenericLensFocusError`](api/equipment-generic-lens-catalogue.md#genericlensfocuserror) | Type |
863
+ | [`GenericLensKind`](api/equipment-generic-lens-catalogue.md#genericlenskind) | Type |
864
+ | [`GenericLensSet`](api/equipment-generic-lens-catalogue.md#genericlensset) | Type |
865
+ | [`ResolvedGenericLens`](api/equipment-generic-lens-catalogue.md#resolvedgenericlens) | Type |
866
+ | [`resolveGenericLens`](api/equipment-generic-lens-catalogue.md#resolvegenericlens) | Runtime |
867
+ | [`ResolveGenericLensInput`](api/equipment-generic-lens-catalogue.md#resolvegenericlensinput) | Type |
868
+ | [`simulateGenericLensFocusError`](api/equipment-generic-lens-catalogue.md#simulategenericlensfocuserror) | Runtime |
869
+ | [`SimulateGenericLensFocusErrorInput`](api/equipment-generic-lens-catalogue.md#simulategenericlensfocuserrorinput) | Type |
870
+
830
871
  ## equipment/generic-prime-lenses.ts
831
872
 
832
873
  [Detailed contracts](api/equipment-generic-prime-lenses.md) · [Source](https://github.com/Photivra/photivra/blob/main/src/equipment/generic-prime-lenses.ts)
@@ -874,6 +915,14 @@ Start here to find a function by task. [Using Photivra from an agent](AGENT_GUID
874
915
  | [`resolveGenericEquipmentTierCatalog`](api/equipment-generic-tier-presets.md#resolvegenericequipmenttiercatalog) | Runtime |
875
916
  | [`resolveGenericEquipmentTierLensProfiles`](api/equipment-generic-tier-presets.md#resolvegenericequipmenttierlensprofiles) | Runtime |
876
917
 
918
+ ## equipment/ideal-lens.ts
919
+
920
+ [Detailed contracts](api/equipment-ideal-lens.md) · [Source](https://github.com/Photivra/photivra/blob/main/src/equipment/ideal-lens.ts)
921
+
922
+ | Export | Kind |
923
+ | --- | --- |
924
+ | [`IDEAL_LENS_LIMITS`](api/equipment-ideal-lens.md#ideal_lens_limits) | Runtime |
925
+
877
926
  ## equipment/iso-capabilities.ts
878
927
 
879
928
  [Detailed contracts](api/equipment-iso-capabilities.md) · [Source](https://github.com/Photivra/photivra/blob/main/src/equipment/iso-capabilities.ts)
@@ -1297,6 +1346,7 @@ Start here to find a function by task. [Using Photivra from an agent](AGENT_GUID
1297
1346
  | [`SimulateCaptureExposureMode`](api/facade-simulate-capture.md#simulatecaptureexposuremode) | Type |
1298
1347
  | [`SimulateCaptureFastOption`](api/facade-simulate-capture.md#simulatecapturefastoption) | Type |
1299
1348
  | [`SimulateCaptureFastReport`](api/facade-simulate-capture.md#simulatecapturefastreport) | Type |
1349
+ | [`SimulateCaptureLensCatalogueRecord`](api/facade-simulate-capture.md#simulatecapturelenscataloguerecord) | Type |
1300
1350
  | [`SimulateCaptureLensDistortion`](api/facade-simulate-capture.md#simulatecapturelensdistortion) | Type |
1301
1351
  | [`SimulateCaptureMetering`](api/facade-simulate-capture.md#simulatecapturemetering) | Type |
1302
1352
  | [`SimulateCaptureMotion`](api/facade-simulate-capture.md#simulatecapturemotion) | Type |
@@ -1575,6 +1625,22 @@ Start here to find a function by task. [Using Photivra from an agent](AGENT_GUID
1575
1625
  | [`LensComplexPupilPsfSampling`](api/optics-complex-pupil-psf.md#lenscomplexpupilpsfsampling) | Type |
1576
1626
  | [`parseLensComplexPupilProfile`](api/optics-complex-pupil-psf.md#parselenscomplexpupilprofile) | Runtime |
1577
1627
 
1628
+ ## optics/defocus-bokeh-kernel.ts
1629
+
1630
+ [Detailed contracts](api/optics-defocus-bokeh-kernel.md) · [Source](https://github.com/Photivra/photivra/blob/main/src/optics/defocus-bokeh-kernel.ts)
1631
+
1632
+ | Export | Kind |
1633
+ | --- | --- |
1634
+ | [`calculateDefocusBokehKernel`](api/optics-defocus-bokeh-kernel.md#calculatedefocusbokehkernel) | Runtime |
1635
+ | [`CalculateDefocusBokehKernelInput`](api/optics-defocus-bokeh-kernel.md#calculatedefocusbokehkernelinput) | Type |
1636
+ | [`DEFOCUS_BOKEH_MAXIMUM_BLADES`](api/optics-defocus-bokeh-kernel.md#defocus_bokeh_maximum_blades) | Runtime |
1637
+ | [`DefocusBokehBlades`](api/optics-defocus-bokeh-kernel.md#defocusbokehblades) | Type |
1638
+ | [`DefocusBokehCatsEye`](api/optics-defocus-bokeh-kernel.md#defocusbokehcatseye) | Type |
1639
+ | [`DefocusBokehClippingAperture`](api/optics-defocus-bokeh-kernel.md#defocusbokehclippingaperture) | Type |
1640
+ | [`DefocusBokehKernel`](api/optics-defocus-bokeh-kernel.md#defocusbokehkernel) | Type |
1641
+ | [`DefocusBokehOptions`](api/optics-defocus-bokeh-kernel.md#defocusbokehoptions) | Type |
1642
+ | [`DefocusBokehSphericalAberration`](api/optics-defocus-bokeh-kernel.md#defocusbokehsphericalaberration) | Type |
1643
+
1578
1644
  ## optics/defocus-disc-kernel.ts
1579
1645
 
1580
1646
  [Detailed contracts](api/optics-defocus-disc-kernel.md) · [Source](https://github.com/Photivra/photivra/blob/main/src/optics/defocus-disc-kernel.ts)
@@ -2029,6 +2095,16 @@ Start here to find a function by task. [Using Photivra from an agent](AGENT_GUID
2029
2095
  | [`CalculateCenteredCropInput`](api/output-crop.md#calculatecenteredcropinput) | Type |
2030
2096
  | [`CenteredCrop`](api/output-crop.md#centeredcrop) | Type |
2031
2097
 
2098
+ ## output/generic-lens-corrections.ts
2099
+
2100
+ [Detailed contracts](api/output-generic-lens-corrections.md) · [Source](https://github.com/Photivra/photivra/blob/main/src/output/generic-lens-corrections.ts)
2101
+
2102
+ | Export | Kind |
2103
+ | --- | --- |
2104
+ | [`createGenericLensCorrectionProfile`](api/output-generic-lens-corrections.md#creategenericlenscorrectionprofile) | Runtime |
2105
+ | [`CreateGenericLensCorrectionProfileInput`](api/output-generic-lens-corrections.md#creategenericlenscorrectionprofileinput) | Type |
2106
+ | [`GENERIC_LENS_CORRECTION_CHANNEL_WAVELENGTHS_NM`](api/output-generic-lens-corrections.md#generic_lens_correction_channel_wavelengths_nm) | Runtime |
2107
+
2032
2108
  ## output/geometric-transforms.ts
2033
2109
 
2034
2110
  [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,94 @@ 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 renders `lens.catalogue` since #569, with the PSF taken per field zone
216
+ ([Display view, Catalogue lenses](https://github.com/Photivra/photivra/blob/main/docs/DISPLAY_VIEW.md#catalogue-lenses-569)).
217
+ - **Cost:** one scene query per kernel tap through the lens field mapping, like `lens.distortion`: a 48 × 48 region of a full frame
218
+ 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
219
+ regions, or use a coarser `sensor.spectralStepNanometers`.
220
+ - **Fast method (#568):** `fast: true` (or `lens: { catalogue: { kind: "pv-50-1.8", fast: true } }`) blends the PSF over a field
221
+ grid and tabulates each site's signal, a labeled approximation within 2.3e-4 of the frame's peak signal of the exact method
222
+ (measured): a 768 × 512 whole full frame renders within 2× of the same `lens.prime` capture. It needs a still camera, site-center
223
+ queries and a chart or material scene; elsewhere the exact method runs (`diagnostics.fast` says which ran).
224
+
225
+ ```ts
226
+ import { resolveGenericLens, simulateCapture } from "@photivra/engine";
227
+
228
+ const lens = resolveGenericLens({ lensId: "pv-50-1.8", activeImagingArea: { widthMm: 36, heightMm: 24 }, fNumber: 2.8 });
229
+ console.log(lens.name, lens.focalLengthMm, lens.character.distortion.corner); // PV 50 1.8, 50, about -0.009 (barrel)
230
+ const capture = await simulateCapture({
231
+ scene: { preset: "point-grid" },
232
+ // A whole 36 × 24 mm frame on 48 × 32 pixels.
233
+ output: { width: 48, height: 32 },
234
+ sensor: { pixelPitchMicrometers: 750, spectralStepNanometers: 50 },
235
+ lens: { catalogue: "pv-50-1.8", fNumber: 2.8 },
236
+ exposure: { mode: "manual", shutterSeconds: 1 / 1000, iso: 100 }
237
+ });
238
+ console.log(capture.assumptions.find((a) => a.id === "lens.catalogue.character")?.value);
239
+ ```
240
+
241
+ ### Ideal lens at any focal length and f-number (#576)
242
+
243
+ `lens.ideal: { focalLengthMm }` is a plain ideal lens: an aberration-free thin lens named by its **physical** focal length, on
244
+ whatever sensor the frame is (output width × height × pixel pitch). Unlike `lens.prime` and `lens.catalogue` it is not scaled by
245
+ the crop factor, so a 50 mm lens is 50 mm on full frame and on APS-C, as when a full-frame lens is mounted on a smaller sensor
246
+ (narrower field of view, the same blur in millimetres).
247
+
248
+ - **Any f-number within the bounds:** `lens.fNumber` from f/0.5 to f/128, focal lengths from 1 to 4000 mm (`IDEAL_LENS_LIMITS`).
249
+ f/0.5 is the physical limit of a lens in air (the sine condition: N = 1/(2·sin θ′) ≥ 0.5); the others are engine bounds. Values
250
+ beyond them are refused by name. Shutter priority and program may choose any aperture in that range (in dim light, wider
251
+ than real lenses open).
252
+ - **An optional f-number range:** `lens.ideal: { focalLengthMm, widestFNumber, narrowestFNumber }` (either or both, within
253
+ `IDEAL_LENS_LIMITS`, the widest no larger than the narrowest; each defaults to its bound) narrows that range like a real
254
+ lens's: `lens.fNumber` must lie in it, and shutter priority and program choose apertures only in it. The assumption then
255
+ records the range (`{ lensId, focalLengthMm, widestFNumber, narrowestFNumber }`); without it nothing changes. Program follows
256
+ the engine's program line (f/4–f/16) and refuses a range that does not hold it, as for `lens.prime` and `lens.catalogue`; a
257
+ range without f/8 takes the reference exposure at its aperture nearest f/8 (the same N²/t).
258
+ - **The same blur models as `lens.prime`:** `lens.defocus` (the thin-lens blur circle of `calculateDefocusCircle`), `lens.diffraction`
259
+ (the Airy pattern) and `lens.vignetting` (a caller profile). Without them the ideal lens renders sharp and evenly lit. It focuses
260
+ at any distance beyond the focal length (no minimum focus distance; the subject must be at least twice the focal length away, as
261
+ for every lens).
262
+ - **Capture and display view:** `simulateCapture`, `simulateDisplayView` and `prepareDisplayView` take it. It replaces the tier
263
+ lens's focal length and f-number range (its transmission is kept) and is recorded in the `lens.ideal` assumption
264
+ (`{ lensId: "ideal-50mm", focalLengthMm: 50 }`). At the same focal length and f-number it renders the same pixels as the tier
265
+ lens (`lens.focalLengthMm`) and the generic prime.
266
+ - **Not combinable** with `lens.prime`, `lens.catalogue` or `lens.focalLengthMm`.
267
+
268
+ A 50 mm f/1.8 lens on APS-C (23.5 × 15.6 mm: 235 × 156 pixels at 100 µm):
269
+
270
+ ```ts
271
+ import { simulateCapture } from "@photivra/engine";
272
+
273
+ const capture = await simulateCapture({
274
+ scene: { preset: "siemens-star" },
275
+ output: { width: 235, height: 156 },
276
+ sensor: { pixelPitchMicrometers: 100 },
277
+ subjectDistanceM: 3,
278
+ lens: { ideal: { focalLengthMm: 50 }, fNumber: 1.8, focusDistanceM: 2.5, defocus: { model: "geometric-disc" } },
279
+ exposure: { mode: "aperture-priority" }
280
+ });
281
+ console.log(capture.assumptions.find((a) => a.id === "lens.defocus")?.value); // diameterMicrometers 94.5 on any sensor
282
+ ```
283
+
194
284
  ### Fitting a chart to the frame (2.3)
195
285
 
196
286
  `calculateSubjectDistanceForFraming({ sensor, focalLengthMm | equivalentFocalLengthMm, chart, fit? })`
@@ -1005,15 +1095,20 @@ The optics options feed only what the environment site contract accepts:
1005
1095
  - A test blurs a sampled straight edge with the kernel and compares it with the disc's closed-form
1006
1096
  edge-spread function, the circular-segment area. They agree to rounding (2e-13) when the edge lies on cell
1007
1097
  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.
1098
+ - The kernel radius is capped at 512 sites, a disc of at most 1025 sites across (`DEFOCUS_DISC_KERNEL_MAXIMUM_RADIUS_SITES`;
1099
+ 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,
1100
+ for example `lens.defocus: The blur circle is 7.81 mm (1,303 sites) across; the engine renders at most 1,025 sites
1101
+ (DEFOCUS_DISC_KERNEL_MAXIMUM_RADIUS_SITES).` The disc is never clipped.
1102
+ - Up to a radius of 64 sites the disc is convolved directly, byte for byte as in 2.16.0. Above it, the
1103
+ [large-disc method](#large-defocus-discs-564) below applies.
1010
1104
  - **Check before rendering (#532):** `calculateDefocusDiscKernelRadius({ focalLengthMm, aperture, focusDistanceM,
1011
1105
  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.
1106
+ needs, `withinLimit`, `excessKernelRadiusSites` and `convolution` (`"direct"`, `"runs"` or null past the limit),
1107
+ without rendering. It runs the capture's own resolution and limit test, so it agrees with the refusal of
1108
+ `simulateCapture`, `simulateDisplayView` and `prepareDisplayView` at the boundary (`test/defocus-disc-limit.test.ts`,
1109
+ at two adjacent focus distances, for the limit and for the 64-site switch to runs). Pass the f-number the capture
1110
+ uses (in an automatic mode, `exposure.settings.fNumber`). The limit is on the disc alone: with `lens.diffraction`
1111
+ the diffraction kernel is applied with it and has no further limit. The other refusals below are not checked.
1017
1112
  - **The capture renders it** (#492, step 2a). The scene radiance on the site lattice is convolved with the disc,
1018
1113
  the same kernel at every spectral node, and each site query is answered with the result. This is the object-space
1019
1114
  convolution that `lens.diffraction` uses.
@@ -1022,7 +1117,8 @@ The optics options feed only what the environment site contract accepts:
1022
1117
  - `diagnostics.options` lists `lens.defocus`, and `diagnostics.timingsMs.defocus` times the provider's convolution.
1023
1118
  - The site inputs declare no PSF, because the defocus is applied upstream.
1024
1119
  - **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.
1120
+ one kernel. The combined radius is the diffraction radius plus the defocus radius, at most 32 + 64 = 96 sites. Above a
1121
+ disc radius of 64 sites the diffraction kernel is applied after the disc instead (#564, below).
1026
1122
  - This is a further approximation, and the assumption says so. The defocused wave PSF of a circular pupil is not
1027
1123
  exactly the in-focus Airy pattern convolved with the geometric disc. Where the two differ is measured in a
1028
1124
  later step of #492.
@@ -1075,9 +1171,8 @@ The optics options feed only what the environment site contract accepts:
1075
1171
  - **Refused:** camera rotation with `motion.shutter: "rolling"` (each row has its own instants, so each row would
1076
1172
  need its own image of the 2R + 1 rows the disc reaches), and a vignetting or distortion profile that does not
1077
1173
  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.
1174
+ with projection and distortion. `simulateDisplayView` and `prepareDisplayView` render these combinations too (#563),
1175
+ with the same image-space disc and the same refusals ([Display view](#display-view)).
1081
1176
  - **Sampling** is the lattice sampling below, unchanged. Measured against a fine-grid oracle: the environment PSF
1082
1177
  quadrature with the disc sampled 5 times finer than the site pitch (3 times with the box aperture), on a 16 × 12
1083
1178
  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 +1238,7 @@ The optics options feed only what the environment site contract accepts:
1143
1238
  - The tabulated and environment paths give the same RAW.
1144
1239
  - Regions and DNG parts equal the whole frame: chart, Quality options with diffraction, a `material-layers`
1145
1240
  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
1241
+ - **Cost of the direct convolution (up to 64 sites).** Direct convolution costs (2R + 1)² multiply-adds per site and node wherever the disc
1147
1242
  sees more than one material. Uniform areas cost almost nothing on the tabulated path. Measured on the tabulated
1148
1243
  capture path (Node 24, one thread, rounds interleaved over the radii, median [range] of 3 rounds; 2 at 2048 × 1366;
1149
1244
  a shared 4-vCPU host):
@@ -1156,10 +1251,9 @@ The optics options feed only what the environment site contract accepts:
1156
1251
  | 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
1252
  | Siemens star, 2048 × 1366 | 1.39 s | | 1.8 s | | 10.4 s | | 38.3 s [37.5–38.3] |
1158
1253
 
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.
1254
+ - 2.16.0 capped the radius at 64 sites from these numbers. Since 2.17.0 (#564) the direct convolution stops there and
1255
+ larger discs take the run convolution below.
1256
+ - The time grows with R² per edge site, and the caller chooses it through the focus distance.
1163
1257
  - A 24 MP frame costs proportionally more. Render it in parts across workers, as for diffraction.
1164
1258
  - Off the tabulated path (caller reflectance or radiance functions), the provider gathers only each 32-row band's
1165
1259
  own values, not the 2R extra rows a full-grid convolution would return (#419). On a 256 × 171
@@ -1167,6 +1261,183 @@ The optics options feed only what the environment site contract accepts:
1167
1261
  - It cannot be combined with `lens.pupilSampling` (the rays already render defocus) or `lens.psf` (a caller PSF
1168
1262
  declares its own defocus). It can be combined with `lens.diffraction`.
1169
1263
 
1264
+ #### Large defocus discs (#564)
1265
+
1266
+ 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
1267
+ 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
1268
+ discs up to a kernel radius of 512 sites (1025 sites across, 6.15 mm at 6 µm). It does this in `simulateCapture` (the
1269
+ object-space, image-space #548 and tabulated paths) and in `simulateDisplayView` and `prepareDisplayView` on the CPU.
1270
+
1271
+ - **Method: runs of equal weights.** Each kernel row is split into runs of consecutive equal weights. A run's contribution,
1272
+ 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
1273
+ is. Runs that share a weight are summed first and multiplied once. This is the summed-area table of Crow (1984)
1274
+ applied row by row; Heckbert (1986) convolves any piecewise-constant kernel this way. Both are free to read.
1275
+ - Every cell wholly inside the disc has the same weight (its area is exactly 1). So the disc of radius R costs one run
1276
+ per row plus the cells the circle crosses: 2,149 reads per output at R = 179, against 101,689 taps for the direct
1277
+ gather; 6,145 at R = 512 (`src/optics/site-kernel-runs.ts`).
1278
+ - **With bokeh character (#553).** The kernel convolved is the shaped kernel when `lens.defocus` has bokeh options, and
1279
+ the disc otherwise. Blade polygons have the disc's uniform interior, so they take this path at little cost: a 7-blade
1280
+ kernel of radius 85 needs 3,002 reads per output, against 29,241 taps for the direct gather. Spherical aberration's binned
1281
+ kernel has no equal neighbours, so it costs as much as the direct gather. The engine refuses any kernel that needs more
1282
+ reads per site than the direct gather at radius 64 (16,641): `lens.defocus: this blur needs 157,644 reads per site;
1283
+ above a kernel radius of 64 sites the limit is 16,641. Reduce the spherical aberration or the defocus.` A cat's eye
1284
+ above 64 sites is refused too, because this path takes one kernel per request and the cat's eye needs one per field
1285
+ zone (`lens.defocus.catsEye: the blur needs a kernel radius of 179 sites; with a cat's eye the limit is 64 sites
1286
+ (129 across). Reduce the defocus or omit catsEye.`).
1287
+ - **With the display view's lens character (#563).** With `lens.projection`, `lens.distortion`, `lens.vignetting` or
1288
+ camera rotation, the view applies the disc to each channel's in-focus signal through the lens, which already carries
1289
+ each source point's field factor. This is the same reordering as without them. Its site values match the capture's
1290
+ image-space provider within 1e-9 relative at R = 179 with distortion and vignetting
1291
+ (`test/defocus-integration-2-17.test.ts`). The fixed tiles never map lattice points beyond the kernel's reach, so a
1292
+ lens profile only has to cover the reach the error message names.
1293
+ - **Not an approximation.** Each output is the same sum of the same products as the direct gather, regrouped. Only
1294
+ the rounding changes. Measured against a compensated (Neumaier) sum of the same products, on the Siemens star,
1295
+ slanted edge and dead leaves at radii 65, 179 and 512, the runs were within 2.5e-14 relative. The direct gather was
1296
+ up to 9.9e-12 off, because it adds up to 826,537 products in one running sum. The runs and the direct gather
1297
+ differed by at most 9.9e-12 relative (RMS 9.9e-12 at R = 512 on dead leaves; 1.6e-12 at R = 179)
1298
+ (`node scripts/benchmark-large-defocus.mjs accuracy`).
1299
+ - Prefix sums of non-negative samples never decrease, so every output is non-negative.
1300
+ - **Where it runs.** Up to 64 sites nothing changes: the direct convolution, byte for byte as in 2.16.0, and every hash
1301
+ lock holds. Above it:
1302
+ - **The providers** (object space, and image space with the field factor) convolve each spectral node's samples with the
1303
+ disc by runs in fixed 64-site tiles. With `lens.diffraction` each node's diffraction kernel is gathered directly
1304
+ afterwards (associativity: K ⊛ (D ⊛ L) = (K ⊛ D) ⊛ L, the combined kernel's sum regrouped).
1305
+ - **The tabulated expected signal** (the capture's fast path and the display view) uses the fact that defocus of a plane
1306
+ is space-invariant and the same at every wavelength. It commutes with the spectral, query-offset and diffraction sums,
1307
+ 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
1308
+ lattice formula, with diffraction when it is on. The disc is then applied once per channel on the native site lattice
1309
+ instead of once per spectral node and lattice group: E in fixed 64-site tiles, D ⊛ E by runs in fixed 128-site tiles,
1310
+ each site taking its own channel.
1311
+ - **Fixed tiles at absolute positions** make every value a function of its site alone. Regions, strips, DNG parts and
1312
+ display-view parts are bit-identical to the whole frame (tested), and the tabulated and environment paths give the
1313
+ same RAW (tested at R = 179, with and without diffraction and the Quality filters).
1314
+ - The `lens.defocus` and `view.defocus` assumptions record `convolution: "runs"`; below 64 sites the field is absent.
1315
+ - **The GPU display view does not take these discs.** `gpu: "auto"` renders them on the CPU, labelled
1316
+ `unsupported-on-backend` with the reason.
1317
+ - **Validation** (`test/large-defocus.test.ts`, `test/site-kernel-runs.test.ts`, `test/display-view-defocus.test.mjs`):
1318
+ - Each path is compared with the direct gather of the same disc: object space (alone and with a diffraction kernel),
1319
+ image space with vignetting, and the tabulated expected signal with and without diffraction. All agree within 1e-12
1320
+ relative.
1321
+ - A vertical edge between lattice columns at R = 179 gives the exact circular-segment fraction to 1e-12.
1322
+ - The display view at R = 194 (Speed and Quality) matches the in-focus view of the scene blurred beforehand by the disc
1323
+ within ΔE2000 1e-6.
1324
+ - **Cost** (Apple M5 Pro, Node 24.19.0, built `dist`, one process per case, two rounds; the machine was shared, load average
1325
+ 5–7, so the times are upper bounds):
1326
+
1327
+ | Case | In focus | R 64 (direct) | R 65 (runs) | R 179 (runs) | R 512 (runs) |
1328
+ | --- | --- | --- | --- | --- | --- |
1329
+ | Per output, one 64-site tile (kernel only) | | 11.4 µs | 0.60 µs | 1.52 µs (direct: 65 µs) | 4.6 µs |
1330
+ | 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 |
1331
+ | Peak resident memory of that capture | 134–135 MiB | 157–158 MiB | 216–230 MiB | 247–253 MiB | 440–457 MiB |
1332
+ | 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] |
1333
+ | The same view on one thread, extrapolated from the 8 parts (mean × 294) | 14 s | 431 s | | 98 s | 296 s |
1334
+ | Peak resident memory of that view process | 260 MiB | 217 MiB | | 394 MiB | 390 MiB |
1335
+
1336
+ 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;
1337
+ 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
1338
+ does not take these discs) and can be spread across workers as today.
1339
+
1340
+ - 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
1341
+ 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
1342
+ capture for the providers, and 32 MiB each for the expected signal's in-focus and output tiles per prepared input (the
1343
+ in-focus cache grows to one output tile's span, about 37 MiB at R = 512).
1344
+ - The cost grows with R, not R²: about 12·R reads per output plus a fixed part per tile. Measured with
1345
+ `RADII=0,64,65,179,512 REPEATS=2 node scripts/benchmark-large-defocus.mjs capture` (and `kernel`, `view`).
1346
+ - **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
1347
+ 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
1348
+ 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.
1349
+
1350
+ #### Bokeh character (#553)
1351
+
1352
+ `lens.defocus` takes three optional parts that shape the defocus kernel by the lens. Each is off by default; without
1353
+ them the disc above is unchanged and every request is byte-identical. The same options, validated the same way, go to
1354
+ `calculateDefocusBokehKernel({ focalLengthMm, aperture, focusDistanceM, subjectDistanceM, pixelPitchMicrometers, bokeh,
1355
+ fieldTangent? })`, which returns the kernel the capture applies (for previews, tests and lens catalogues; the
1356
+ `DefocusBokehOptions` type is what a catalogue lens can carry, #549).
1357
+
1358
+ ```js
1359
+ lens: { focalLengthMm: 50, fNumber: 4, focusDistanceM: 1.4, defocus: { model: "geometric-disc",
1360
+ blades: { count: 9, firstBladeEdgeAngleDegrees: 0, curvature: 0.3, circularAtOrBelowFNumber: 1.8 },
1361
+ sphericalAberration: { w040Micrometers: 0.5, atFNumber: 4 },
1362
+ catsEye: { apertures: [{ radiusMm: 7, shiftPerTanFieldMm: 20 }], fieldZonesAcrossLongSide: 8 } } }
1363
+ ```
1364
+
1365
+ - **The model.** A geometric defocused image of a point is the open pupil projected onto the sensor. The ray through
1366
+ 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
1367
+ the thin-lens blur diameter (`calculateDefocusCircle`), σ = +1 for a scene plane behind the focus plane and −1 in front
1368
+ of it, and b = 8·N·W040/pitch is the transverse ray aberration of primary spherical aberration W = W040·ρ⁴,
1369
+ ε = −(R/r_p)·∂W/∂ρ with R/r_p = 2N (Wyant & Creath 1992, §II.A, free). It is an approximation: geometric optics, an ideal thin
1370
+ lens, the aberration referred to the paraxial focus, the exit pupil the same shape and orientation as the entrance pupil;
1371
+ no diffraction of the defocused wave, onion rings, higher-order or field-dependent aberrations, and the same kernel at
1372
+ every wavelength. The `lens.defocus` assumption gains `bokeh` (the options, the side, the shaped kernel's radius,
1373
+ the method, b in sites, and the cat's-eye zones) and says all this.
1374
+ - **`blades`**: a regular polygon of `count` (3–32) straight blades, with `calculateIdealApertureGeometry`'s
1375
+ `firstBladeEdgeAngleDegrees`; `curvature` κ in [0, 1] makes each edge an arc of radius 1/κ circumradii through its two
1376
+ vertices (κ = 1 is the circle); at or below `circularAtOrBelowFNumber` the blades are open and the opening is circular.
1377
+ 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
1378
+ disc's area.
1379
+ - **Front against back.** Behind the focus plane the rays have crossed before the sensor, in front they have not, so the
1380
+ two blurs are the same pupil turned through 180° (visible with an odd blade count or a cat's eye; shapes are declared as
1381
+ the blur behind the focus plane appears in the output image, +X right, +Y up). With spherical aberration the term b·ρ²
1382
+ adds to the defocus on one side and subtracts on the other: the image intensity at r = |σc/2·ρ + bρ³| of a circular
1383
+ pupil is proportional to 1/(|σc/2 + bρ²|·|σc/2 + 3bρ²|), so undercorrected aberration (W040 > 0) gives a soft-edged blur
1384
+ behind the focus plane and a bright-rimmed one in front, and overcorrected the reverse (the photographic description is
1385
+ H. H. Nasse, "Depth of Field and Bokeh", Carl Zeiss, 2010, free). `sphericalAberration` gives W040 in µm at `atFNumber`,
1386
+ scaled to the working f-number as W040·(N₀/N)⁴. The rim can widen the blur beyond the disc. Past a kernel radius of
1387
+ 512 sites the request is refused with the disc's short message. Above 64 sites it is refused when its binned kernel
1388
+ needs more than 16,641 reads per site, the run path's limit ("Large defocus discs", above).
1389
+ - **`catsEye`**: mechanical vignetting, the model of `calculateMechanicalVignetting` (Greivenkamp, OPTI-502 notes, sec. 10,
1390
+ free): each clipping aperture is a circle of `radiusMm` in the entrance-pupil plane, shifted by
1391
+ `shiftPerTanFieldMm`·tan θ toward the field point, and the open pupil is the opening inside every circle. The kernel
1392
+ varies over the field, so the capture cuts the native raster into square zones (`fieldZonesAcrossLongSide`, 1–32,
1393
+ default 8) and gives each site the kernel of its zone's centre: a piecewise-constant (sectioned) approximation of the
1394
+ space-variant blur (also see L. Denis, É. Thiébaut, F. Soulez, J.-M. Becker, R. Mourya, "Fast approximations of
1395
+ shift-variant blur", *International Journal of Computer Vision* 115, 2015, pp. 253–278). The assumption reports the
1396
+ zones, the smallest open-area ratio and its largest step between neighbouring zones (the size of the seams; raise the
1397
+ zone count to shrink it). The kernel has unit sum: the light the clip removes is not applied, so declare the falloff with
1398
+ `lens.vignetting`, as for any field throughput (`lightLossApplied: false`). It runs on the object-space and the
1399
+ image-space (#548) paths; the cat's eye skips the tabulated fast path (one kernel per node there), and it cannot be
1400
+ combined with `lens.diffraction` (every zone would need its own combined kernel per node). Above a kernel radius of 64
1401
+ sites it is refused: #564's run convolution takes one kernel per request, and extending it to one kernel per zone is left
1402
+ for later. `simulateDisplayView` and `prepareDisplayView` refuse it; they render `blades` and `sphericalAberration`.
1403
+ - **With `lens.catalogue` (#549)** the three options are refused by name. The catalogue lens folds a plain disc into its
1404
+ own PSF, so they would otherwise be dropped. Feeding a catalogue lens's spherical aberration and character into the
1405
+ bokeh kernel is a follow-up ([#567](https://github.com/Photivra/photivra/issues/567)), not a 2.17.0 feature.
1406
+ - **Sampling.** Without spherical aberration each site's weight is the exact area of the projected open pupil (a convex
1407
+ polygon; circles and blade arcs are 1024-gons scaled to their true area, within 5e-6 of the radius of the true curve)
1408
+ inside the site's cell, divided by its area (`"exact-polygon-cell-area"`). With it the pupil is sampled on a square grid
1409
+ fine enough that neighbouring rays land at most 1/8 site apart, each grid cell weighted by its exact overlap with the
1410
+ pupil's chord, and every ray binned into the site cell it lands in (`"pupil-ray-binning"`; it handles the caustic).
1411
+ Only correctly rounded operations and the portable trigonometry are used, so the weights are the same bits in every
1412
+ JavaScript engine.
1413
+ - **Validation** (`test/defocus-bokeh-kernel.test.ts`, `test/simulate-capture-defocus-bokeh.test.ts`):
1414
+ - 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
1415
+ every cell matches a 16 × 16 supersampling of `calculateIdealApertureGeometry`'s polygon to its resolution (1/16 cell).
1416
+ Without blades, at κ = 1 and with the blades open the kernel equals the exact disc kernel to 1e-4 of an interior weight.
1417
+ - Cat's eye against field position: the open-area ratio equals `calculateMechanicalVignetting`'s exact two-circle area to
1418
+ 1e-4 at 0–25° and three azimuths; the radial spread falls against the tangential one as the field grows, the light kept
1419
+ is on the field point's side behind the focus plane and on the other in front; on axis the disc is whole.
1420
+ - Spherical-aberration edge profile: the kernel's encircled energy follows the ray map's analytic ρ(r)² within 0.012 at
1421
+ 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
1422
+ below 0.8 behind and above 1.2 in front for W040 > 0, reversed for W040 < 0; W040 scales as (N₀/N)⁴.
1423
+ - Front against back: a 5-blade kernel in front is the one behind turned through 180° to 1e-15; a circular pupil without
1424
+ aberration gives the same blur on both sides.
1425
+ - 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
1426
+ 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;
1427
+ 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
1428
+ 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
1429
+ focus plane (focus 1.4 m) and 1.15 in front (focus 3.5 m).
1430
+ - Requests without the options, and in focus with them, give the same RAW codes; regions equal the whole frame with a
1431
+ cat's eye; the GPU display view's float32 emulation gathers a shaped asymmetric kernel within its a-priori bounds.
1432
+ - **Cost** (Apple M5 Pro, Node.js 24, built `dist`, one thread, median of 3 or 5; a shared machine). Building a kernel at
1433
+ 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
1434
+ 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
1435
+ 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
1436
+ 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
1437
+ and 1.02 s), because it leaves the tabulated path for the per-site environment path. The display view (CPU, 240 × 160 of
1438
+ that frame) took 0.12–0.16 s with the disc, 9 blades or spherical aberration. The GPU display view gathers the shaped
1439
+ kernel with the disc's own pass and cost (no `src/gpu` change); it does not take the cat's eye.
1440
+
1170
1441
  ### Pixel aperture (2.3)
1171
1442
 
1172
1443
  By default each site samples the irradiance at its centre (`sensor.pixelAperture: "point"`). Scene
@@ -1736,6 +2007,14 @@ request took 73 s instead of 103 s). In 2.6.0 the results were byte-identical to
1736
2007
  merged, which took 222 s, 450 s and 837 s on the phone; later releases changed some views' values (for example the
1737
2008
  exact area mean, 2.8.0; AHD's choice, 2.13.0).
1738
2009
 
2010
+ **Lens character (#563).** `lens.projection`, `lens.distortion`, `lens.vignetting` and camera rotation (global shutter)
2011
+ render in the view, in focus and with `lens.defocus` out of focus: each real site's expected electrons are the capture's,
2012
+ through the same mapping, field factor and rotation (within 1e-9 relative per site). They run on the CPU: with
2013
+ `gpu: "auto"` the view falls back labelled `unsupported-on-backend`. They cost more than the rectilinear view, most for
2014
+ distortion (the inverse distortion is solved per point) and rotation (one scene point per temporal midpoint)
2015
+ ([DISPLAY_VIEW.md](https://github.com/Photivra/photivra/blob/main/docs/DISPLAY_VIEW.md#lens-projection-distortion-vignetting-and-rotation)).
2016
+ Rotation with a rolling shutter stays refused.
2017
+
1739
2018
  **Options.** `view.width` and `view.height` (1 to the capture's output size), `view.samplesPerAxis` (1–32),
1740
2019
  `view.format` (`"srgb-8"` default or `"srgb-16"`) and `view.region` (a rectangle in display pixels, byte-identical to
1741
2020
  the same pixels of the whole view, for tiling across workers). The capture's file-format options (`format`,
@@ -1821,6 +2100,15 @@ provided, for rights reasons.
1821
2100
  independent test). The original analysis is G. B. Airy, "On the Diffraction of an Object-glass with
1822
2101
  Circular Aperture", *Transactions of the Cambridge Philosophical Society* 5, 1835, pp. 283–291. The
1823
2102
  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)).
2103
+ - Bokeh character (`lens.defocus` blades, spherical aberration and cat's eye, `calculateDefocusBokehKernel`, #553): the
2104
+ transverse ray aberration ε = −(R/r_p)·∂W/∂ρ of primary spherical aberration from Wyant & Creath 1992 (above), §II.A; the
2105
+ mechanical-vignetting model from J. E. Greivenkamp, OPTI-502 Optical Design and Instrumentation I course notes, University
2106
+ of Arizona, sec. 10 (free; as `calculateMechanicalVignetting`); the photographic account of front and back bokeh under
2107
+ spherical aberration in H. H. Nasse, "Depth of Field and Bokeh", Carl Zeiss Camera Lens Division, March 2010, free at
2108
+ <https://lenspire.zeiss.com/photo/app/uploads/2022/02/technical-article-depth-of-field-and-bokeh.pdf>. The intensity
2109
+ profiles follow from the ray map; the polygon clipping (Sutherland–Hodgman) and the shoelace area are elementary geometry.
2110
+ The zoned (sectioned) treatment of the field-varying cat's eye is surveyed in L. Denis et al., "Fast approximations of
2111
+ shift-variant blur", *International Journal of Computer Vision* 115 (2015) 253–278.
1824
2112
  - Print planning (`calculatePrintPlan`, `calculatePrintSizeLimit`): Z. Alomar, K. D. Bojikian, "Visual Acuity
1825
2113
  Testing in Adults", EyeWiki, American Academy of Ophthalmology, last edited June 16, 2026, free to read at
1826
2114
  <https://eyewiki.aao.org/Visual_Acuity_Testing_in_Adults> (the minimum angle of resolution of an ideal eye under optimal