@photivra/engine 2.0.0 → 2.1.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.
@@ -50,7 +50,7 @@ the example above is [simulate-capture.mjs](examples/simulate-capture.mjs).
50
50
  | `request` | The complete underlying request: `environmentRaw` (the native environment RAW input), `siteInput(index)` (rebuilds the physical input of any sensor site), `development` (the RAW development input) and `whiteBalanceState` |
51
51
  | `exposure` | Mode, resolved settings, EV100, metered scene luminance and the exposure-mode resolver's full output |
52
52
  | `scene` | Chart parameters, illuminant, distance, framed size and the scene's SHA-256 digest (it covers caller illuminant and reflectance spectra; see `sourceSha256`) |
53
- | `diagnostics` | Radiance query count, highlight and rendering counters, and timings |
53
+ | `diagnostics` | Radiance query count, highlight and rendering counters, per-stage timings (`timingsMs`) and, from 2.1, the opt-ins that ran (`options`); see [Cost of each option](#cost-of-each-option) |
54
54
  | `provenance` | Status (`approximation`), camera preset identity, evidence, the composed contracts in order, and limitations |
55
55
  | `assumptions` | Every default, derived value and fixed modelling choice, each with an explanation |
56
56
 
@@ -71,6 +71,7 @@ modelling choices (such as "no lens PSF") as `kind: "model"`.
71
71
  | `lens.distortion` | none | `{ kind: "radial", profile }` or `{ kind: "brown-conrady", profile }`. |
72
72
  | `lens.vignetting` | none | An `IlluminationVignettingProfile` (radial relative illumination). |
73
73
  | `lens.psf` | none | A `{ kind: "sampled-local", configuration }` sampled lens PSF, passed to the environment contract unchanged. |
74
+ | `lens.diffraction` | off | 2.1: `"circular-aperture"` or `{ aperture: "circular-aperture", kernelRadiusSamples }`: diffraction blur of an ideal lens at the resolved f-number (see [Optics](#optics)). |
74
75
  | `exposure.mode` | `"manual"` | Also `"aperture-priority"`, `"shutter-priority"`, `"program"`. |
75
76
  | `exposure.iso` | 100 | Selects the body's ISO readout regime (gain and read noise). |
76
77
  | `exposure.shutterSeconds` | 1/125 | Used in `manual` and `shutter-priority`. |
@@ -85,7 +86,7 @@ modelling choices (such as "no lens PSF") as `kind: "model"`.
85
86
  | `output.bitDepth` | 8 | PNG only: 8 or 16. |
86
87
  | `output.gamutMapping` | `"clip"` | PNG only: or `"perceptual"`. |
87
88
  | `output.dng.capturedAtUtc` | none | DNG only: capture time for the date tags. |
88
- | `output.demosaic` | the 1.0 bilinear kernels | `"bilinear"` (see [Demosaic](#demosaic)). |
89
+ | `output.demosaic` | the 1.0 bilinear kernels | `"bilinear"` or, from 2.1, `"ahd"` (see [Demosaic](#demosaic)). |
89
90
  | `output.borderHandling` | `"margin"` | Or `"mirror"`. |
90
91
  | `output.toneCurve` | `"identity"` | Any `SdrToneCurve`, for example `"positive-reinhard-per-channel"`. |
91
92
  | `output.toneCurveParameters` | none | Required by the luminance curves (`extended-reinhard-luminance`, `naka-rushton-luminance`); the facade invents no curve parameters. |
@@ -96,14 +97,16 @@ modelling choices (such as "no lens PSF") as `kind: "model"`.
96
97
  | `sensor.spectralStepNanometers` | 20 | Spectral quadrature over 380–780 nm. |
97
98
  | `sensor.temporalSampleCount` | 1, or 8 when the camera rotates | Midpoints across each site's exposure window. |
98
99
  | `sensor.noiseSeed` | 1 | Output is deterministic for a given seed. |
100
+ | `sensor.antiAliasing` | off | 2.1: an optical low-pass (anti-aliasing) filter: `{ kind: "four-spot", separationSites? }`, `{ kind: "two-spot", axis, separationSites? }` or `{ kind: "point-splitting", components }` (see [Anti-aliasing filter](#anti-aliasing-filter)). |
99
101
  | `motion.angularVelocityRadPerSec` | still | `{ pitch?, yaw?, roll? }` in rad/s (see [Motion](#motion)). |
100
102
  | `motion.shutter` | `"global"` | Or `"rolling"`. |
101
103
  | `motion.rollingShutterSkewSeconds` | the body's readout skew | Rolling only. |
102
- | `signal`, `onProgress` | none | An `AbortSignal` cancels the running task; `onProgress` reports `raw`, `white-balance`, `development` and `jpeg` tiles. The last event of each stage has `completedTileCount === totalTileCount`. |
104
+ | `signal`, `onProgress` | none | An `AbortSignal` cancels the running task; `onProgress` reports `raw`, `white-balance`, `development` and `jpeg` tiles. The last event of each stage has `completedTileCount === totalTileCount`. The capture yields to the host about every 8 ms with a `MessageChannel` message (else `setImmediate`, else `setTimeout`), so other queued tasks run in between: an abort sent from another task, such as a Web Worker `message` event, takes effect at the next yield. |
103
105
 
104
106
  Unknown fields are rejected, so a typo never silently falls back to a default.
105
107
 
106
- Every option added in 2.0 (PNG, demosaic, optics and motion) is opt-in. With none of them given, the
108
+ Every option added in 2.0 (PNG, demosaic, optics and motion) and in 2.1 (`lens.diffraction`,
109
+ `output.demosaic: "ahd"`, `sensor.antiAliasing`) is opt-in. With none of them given, the
107
110
  request, RAW, samples and JPEG are byte-identical to the 1.0 facade at the same illuminance, and a
108
111
  test checks that. (The default illuminance changed before release; see [Exposure](#exposure).)
109
112
 
@@ -217,14 +220,14 @@ These choices appear in `assumptions` as `kind: "model"` entries:
217
220
  lens capability. By default no PSF (point-spread function) or diffraction, vignetting, distortion,
218
221
  lateral colour or stray light is applied. The tier's optical data exist only at f/4 and 5 m, and
219
222
  the facade does not extrapolate them. Vignetting, distortion and PSF are applied only from profiles
220
- you supply.
223
+ you supply; diffraction of an ideal lens only with `lens.diffraction` (2.1).
221
224
  - **Sensor.** Square pixels with a fill factor of 1 and no anti-aliasing filter. A global
222
225
  electronic shutter unless `motion.shutter` is `"rolling"`. A synthetic uniform dark current of
223
226
  1 e⁻/s at 20 °C (the tiers declare none). The charge capacity equals the body's base-ISO saturation signal.
224
227
  - **Demosaic.** Bilinear RGGB interpolation through the existing linear-kernel contract, unless
225
- `output.demosaic` selects a built-in kernel. (Demosaicing fills in the two missing colours at each
228
+ `output.demosaic` selects a built-in kernel or AHD. (Demosaicing fills in the two missing colours at each
226
229
  pixel of a colour-filter mosaic.) Sharp edges without lens blur therefore show the usual zipper and
227
- false-colour artefacts.
230
+ false-colour artefacts; `"ahd"` and `lens.diffraction` reduce them.
228
231
  - **Scene.** A planar Lambertian surface: radiance = reflectance × irradiance / π, with illuminance
229
232
  from K_m = 683 lm/W and the analytic ȳ. No shadows, inter-reflection, gloss or fluorescence.
230
233
  - **Rendering.** Linear sRGB with RAW white at display white, no exposure push, out-of-gamut
@@ -323,9 +326,23 @@ console.log(dng.width, dng.activeArea, dng.whiteLevel, dng.codes.length === shot
323
326
 
324
327
  ## Demosaic
325
328
 
326
- `output.demosaic` selects the built-in Bayer kernels of `createBuiltInBayerDemosaicProfiles`:
327
- `"bilinear"` (nearest same-channel averages; pixel-identical to the default). The Malvar–He–Cutler
328
- kernels are not in 2.0 (a patent is still in force; see
329
+ `output.demosaic` selects:
330
+
331
+ - `"bilinear"`: the built-in Bayer kernels of `createBuiltInBayerDemosaicProfiles` (nearest same-channel
332
+ averages; pixel-identical to the default).
333
+ - `"ahd"` (2.1): adaptive homogeneity-directed demosaicing, `createAhdDemosaicTask` (after Hirakawa &
334
+ Parks, *IEEE Trans. Image Processing* 14(3), 2005). Green is interpolated along rows and along columns,
335
+ red and blue follow from colour differences, and each pixel takes the more homogeneous of the two
336
+ candidates in CIELAB. It is non-linear and edge-directed, so it shows much less zipper and false colour
337
+ on sharp edges than bilinear (mean false colour of a neutral 32 × 32 Siemens star: 138 instead of 209
338
+ 8-bit codes; slanted edge: 5.3 instead of 11.4). The RAW sites are normalized and white-level clipped
339
+ exactly as in the native development, the homogeneity is measured on white-balanced colour, and the
340
+ native development's colour operator and SDR kernel follow. The paper's iterative median step is not
341
+ applied. With `"margin"` borders the sensor gets 5 extra sites per side (an AHD pixel depends on sites
342
+ up to 5 away); `request.development` keeps the bilinear kernels, which validate the development but do
343
+ not produce the pixels.
344
+
345
+ The Malvar–He–Cutler kernels are not offered (a patent is still in force; see
329
346
  [PROCESSING.md](https://github.com/Photivra/photivra/blob/main/docs/PROCESSING.md)).
330
347
 
331
348
  `output.borderHandling` decides how edge pixels get their neighbours:
@@ -365,6 +382,25 @@ The optics options feed only what the environment site contract accepts:
365
382
  1 + r2·ρ² + r4·ρ⁴ + r6·ρ⁶, applied once at the physical sensor point before any PSF.
366
383
  - `lens.psf` is a caller sampled PSF passed through unchanged. Its focal length, f-number and focus
367
384
  must equal the resolved capture settings exactly, and it cannot be combined with pupil sampling.
385
+ - `lens.diffraction` (2.1) adds the diffraction blur of an ideal lens at the capture's resolved
386
+ f-number, so stopping down now softens the image as well as darkening it.
387
+ - The engine builds the kernels (`createDiffractionLimitedPsfProfile`): the Airy pattern of a uniformly
388
+ lit circular pupil (scalar Fraunhofer diffraction), aberration-free and in focus, with pupil diameter
389
+ f/N and the thin-lens image distance (working f-number N·v/f). Each kernel holds the PSF energy of
390
+ site-sized cells, one kernel per spectral node.
391
+ - The kernel window reaches the third dark ring at the longest node by default (2, 4 and 7 sites at
392
+ f/4, f/8 and f/16 with 6 µm pixels); set `kernelRadiusSamples` (0–32) to change it. The Airy tail
393
+ outside the window (2–5 % of the energy with the default) is listed per wavelength in the
394
+ `lens.diffraction` assumption and put back by renormalizing each kernel to unit sum.
395
+ - It runs as an object-space convolution: the scene radiance on the site lattice is convolved once per
396
+ spectral node and the provider answers each site query with it. That is the same sum as the
397
+ environment PSF quadrature (one scene query per kernel tap) for this still, distortion-free planar
398
+ scene; a test checks it to 1e-12 per site. With camera rotation, a projection or distortion, or
399
+ vignetting, the per-tap quadrature runs instead (much slower). `request.diffraction` holds the profile,
400
+ the equivalent `sampled-local` PSF and which execution ran.
401
+ - Not modelled: defocus, aberrations, pupil clipping, a polygonal (bladed) aperture (the tiers declare
402
+ no blade count) and the pixel aperture (each site is still sampled at its centre). It cannot be
403
+ combined with `lens.psf` or `lens.pupilSampling`.
368
404
 
369
405
  The default frame is a 0.38 mm crop behind a 50 mm lens, a field of well under a degree. Projection
370
406
  and distortion effects therefore only become visible with larger pixels (or a custom lens). The
@@ -397,6 +433,65 @@ const lensy = await simulateCapture({
397
433
  console.log(lensy.request.siteInput(0).environment.lensFieldMapping, lensy.assumptions.find((a) => a.id === "lens.vignetting")?.kind);
398
434
  ```
399
435
 
436
+ ```ts
437
+ import { simulateCapture } from "@photivra/engine";
438
+
439
+ const stoppedDown = await simulateCapture({
440
+ scene: { preset: "siemens-star" },
441
+ lens: { fNumber: 16, diffraction: "circular-aperture" },
442
+ exposure: { shutterSeconds: 1 / 30 },
443
+ output: { width: 48, height: 48, demosaic: "ahd" }
444
+ });
445
+ console.log(stoppedDown.request.diffraction?.execution, stoppedDown.request.diffraction?.profile.maximumTruncatedEnergyFraction);
446
+ ```
447
+
448
+ ### Anti-aliasing filter
449
+
450
+ `sensor.antiAliasing` (2.1) puts an optical low-pass filter in front of the sensor. Most real cameras
451
+ with a Bayer sensor carry one; the generic tiers declare none, so it is off by default. It is modelled
452
+ the way a birefringent quartz filter works: every image point is split into equally bright copies, and
453
+ each site collects the light that would otherwise have landed at its centre minus each offset (the
454
+ engine's `normalized-point-splitting-kernel` optical-stack response, resolved by
455
+ `resolveAntiAliasingSpatialKernel`).
456
+
457
+ - `{ kind: "four-spot", separationSites }`: two crossed plates, four spots at (±d/2, ±d/2) with weight
458
+ 1/4 each. Its MTF is |cos(π·f_x·d)·cos(π·f_y·d)|. The most common filter in digital cameras.
459
+ - `{ kind: "two-spot", axis: "horizontal" | "vertical", separationSites }`: one plate, two spots at
460
+ ±d/2 along the axis, MTF |cos(π·f·d)|.
461
+ - `{ kind: "point-splitting", components }`: your own kernel, offsets in micrometres (+x right, +y
462
+ down) and positive weights that sum to 1; at most 16 components, each within 4 sites.
463
+ - d = `separationSites` × pixel pitch, 0 < `separationSites` ≤ 4. The default 1 puts the first MTF
464
+ zero at the sensor's Nyquist frequency 1/(2·pitch): a four-spot filter one pixel wide cuts off at
465
+ Nyquist (Palum 2006). Above Nyquist the cosine rises again, so a lens (for example
466
+ `lens.diffraction`) is still needed to suppress the highest frequencies.
467
+
468
+ Measured at f/8 on 128 × 128 neutral charts (mean false colour |R − G| + |B − G| in 8-bit codes):
469
+ Siemens star 100.8 → 52.2 with bilinear and 68.7 → 25.1 with AHD; slanted edge 4.9 → 3.8 (bilinear).
470
+ The price is sharpness: the slanted-edge 10–90 % width grows from 1.49 to 2.34 px (bilinear).
471
+
472
+ Each site queries the scene once per spot, so the radiance work grows by the number of spots (4 for
473
+ `four-spot`). With `lens.diffraction` the object-space convolution still applies: the taps around
474
+ each split point lie on the site lattice shifted by its offset, so the engine convolves once per
475
+ distinct sub-site shift (one half-site lattice for the default four-spot filter). A test checks it
476
+ against the per-tap environment quadrature to 1e-12 per site.
477
+
478
+ Approximations, listed in the `sensor.antiAliasing` assumption: the split is the same at every
479
+ wavelength, field point and polarization (birefringence dispersion is ignored), the spots are exact
480
+ points of equal weight for unpolarized light, and the filter's transmission, reflections and focus
481
+ shift are not modelled. It is not a measured filter.
482
+
483
+ ```ts
484
+ import { simulateCapture } from "@photivra/engine";
485
+
486
+ const filtered = await simulateCapture({
487
+ scene: { preset: "siemens-star" },
488
+ sensor: { antiAliasing: { kind: "four-spot" } },
489
+ lens: { diffraction: "circular-aperture" },
490
+ output: { width: 48, height: 48, demosaic: "ahd" }
491
+ });
492
+ console.log(filtered.assumptions.find((a) => a.id === "sensor.antiAliasing")?.value);
493
+ ```
494
+
400
495
  ## Motion
401
496
 
402
497
  `motion.angularVelocityRadPerSec` rotates the camera at a constant rate during the exposure, in the
@@ -450,10 +545,78 @@ The 1000 × 1000 row is not a measurement. It multiplies the 256 × 256 CPU cost
450
545
  count. Before the lean executor, the same 64 × 64 capture took about 6.0 s of CPU on this host, and
451
546
  128 × 128 about 20.6 s. Development and encoding are a small part of the total.
452
547
 
548
+ The 2.1 options, measured on the same kind of host (4-vCPU Intel Xeon 2.80 GHz, Node 24, median of three
549
+ runs, slanted edge and Siemens star): at 48 × 48, default 0.38–0.45 s, `lens.diffraction` 0.49–0.71 s
550
+ (f/4–f/16), `"ahd"` 0.48–0.63 s, both 0.60–0.87 s; at 128 × 128, default 2.1–2.3 s, diffraction
551
+ 2.5–3.4 s, AHD 2.4–2.5 s, both 2.8–4.0 s. The same diffraction through the per-tap environment quadrature
552
+ took 13.1 s at 48 × 48, f/8. [Cost of each option](#cost-of-each-option) covers every opt-in.
553
+
453
554
  These numbers come from one run on one machine. They are not a performance claim. Small captures
454
555
  (up to about 128 × 128) take a few seconds. Use them while you explore, and render large images only
455
556
  when you need them.
456
557
 
558
+ ## Cost of each option
559
+
560
+ Slow options are opt-in, so an application can decide which ones it can afford: a feature that is
561
+ too slow for a phone today may be cheap on the next generation of hardware. The table gives the
562
+ measured wall time of each opt-in that affects run time, relative to the default capture of the same
563
+ size.
564
+
565
+ **Method.** Slanted-edge chart, every other option at its default (f/8, 1/125 s, ISO 100, sRGB 8-bit,
566
+ bilinear demosaic). One fresh Node process per row and size; one 16 × 16 warm-up capture, then three
567
+ timed captures (`performance.now()` around `simulateCapture`), median reported. Built `dist`, Node
568
+ 24.21.0, 4-vCPU Intel Xeon 2.80 GHz, Linux, nothing else running. One run on one machine: not a
569
+ performance claim, but the ratios carry over roughly.
570
+
571
+ | Option | 48 × 48 | 128 × 128 | What it buys |
572
+ | --- | --- | --- | --- |
573
+ | default | 0.50 s (1×) | 2.49 s (1×) | Point optics, bilinear demosaic, sRGB samples |
574
+ | `lens.diffraction: "circular-aperture"` | 0.65 s (1.3×) | 2.84 s (1.1×) | Aperture-dependent sharpness (Airy blur); convolution 0.38 s at 128 |
575
+ | `output.demosaic: "ahd"` | 0.65 s (1.3×) | 2.60 s (1.0×) | About half the false colour on sharp edges; 5-site margin, demosaic 0.05 s at 128 |
576
+ | `sensor.antiAliasing: { kind: "four-spot" }` | 0.94 s (1.9×) | 4.96 s (2.0×) | Optical low-pass filter: false colour halved again, at a cost in sharpness |
577
+ | anti-aliasing + diffraction | 1.16 s (2.3×) | 6.02 s (2.4×) | Both, still on the fast convolution path |
578
+ | anti-aliasing + diffraction + AHD | 1.42 s (2.8×) | 6.99 s (2.8×) | The most faithful still image of a planar chart |
579
+ | `lens.pupilSampling` 1 × 4 rays | 1.79 s (3.6×) | 11.5 s (4.6×) | Defocus and depth of field, coarse |
580
+ | `lens.pupilSampling` 2 × 8 rays | 5.20 s (10×) | 33.1 s (13×) | Defocus and depth of field, smoother bokeh |
581
+ | `lens.psf` (caller 3 × 3 kernel) | 2.11 s (4.2×) | 12.4 s (5.0×) | Any sampled lens PSF; runs per tap, so cost grows with the kernel size (a 9 × 9 diffraction kernel took 13.1 s at 48 × 48) |
582
+ | `motion` yaw 0.01 rad/s (8 temporal samples) | 1.70 s (3.4×) | 8.74 s (3.5×) | Motion blur; cost grows with `sensor.temporalSampleCount` |
583
+ | rotation + `shutter: "rolling"` | 1.79 s (3.6×) | 9.24 s (3.7×) | Rolling-shutter skew of moving content |
584
+ | `motion.shutter: "rolling"` (still camera) | 0.47 s (0.9×) | 2.51 s (1.0×) | Per-row exposure windows; no cost alone |
585
+ | `output.format: "png"` | 0.50 s (1.0×) | 2.63 s (1.1×) | PNG file bytes; encode 16 ms at 128 |
586
+ | `output.format: "jpeg"` | 0.52 s (1.0×) | 2.59 s (1.0×) | Baseline JPEG; encode 45 ms at 128 |
587
+ | `output.format: "dng"` | 0.50 s (1.0×) | 2.27 s (0.9×) | Lossless CFA DNG of the RAW; encode 4 ms at 128 |
588
+ | `output.highlightMode: "reconstruct"` | 0.50 s (1.0×) | 2.20 s (0.9×) | Near-clipped highlights blend smoothly toward neutral instead of clipping per channel; no measurable cost |
589
+ | `output.whiteBalance: "auto"` | 0.77 s (1.6×) | 4.76 s (1.9×) | Grey-world white balance; renders the RAW a second time (2.24 s at 128) |
590
+
591
+ Ratios below about 1.1× are within the run-to-run spread (the three default runs at 128 × 128 took
592
+ 2.23–2.50 s). The radiance work dominates: options that multiply the scene queries per site
593
+ (anti-aliasing spots, pupil rays, PSF taps, temporal samples) multiply the run time by about the same
594
+ factor, and output-side options cost almost nothing.
595
+
596
+ ### Measuring cost on the user's device
597
+
598
+ From 2.1 every result says what ran and how long each stage took, so an application can decide on the
599
+ user's own device which options to offer:
600
+
601
+ - `diagnostics.options` lists the opt-ins that changed the work of the run, in the fixed order of
602
+ `SIMULATE_CAPTURE_COST_OPTIONS` (for example `["lens.diffraction", "sensor.antiAliasing",
603
+ "output.demosaic.ahd", "output.format.png"]`). It is empty when every default is used.
604
+ - `diagnostics.timingsMs` has `raw`, `whiteBalance` (the second RAW pass of `whiteBalance: "auto"`),
605
+ `development` (includes DNG writing), `jpeg` and `total` in whole milliseconds, and, where the stage
606
+ ran, `diffraction` (the object-space convolution, part of `raw`), `demosaic` (the AHD demosaic, part
607
+ of `development`) and `encode` (PNG, JPEG or DNG serialization), with sub-millisecond resolution where
608
+ the host has `performance.now()`.
609
+
610
+ Timings change from run to run, but nothing else does: they never enter the scene digest, the RAW,
611
+ the samples or the encoded files, and a test checks that two runs differ only in `timingsMs`.
612
+
613
+ ```ts
614
+ import { simulateCapture } from "@photivra/engine";
615
+
616
+ const probe = await simulateCapture({ scene: { preset: "slanted-edge" }, output: { width: 32, height: 32, demosaic: "ahd" } });
617
+ console.log(probe.diagnostics.options, probe.diagnostics.timingsMs.total, probe.diagnostics.timingsMs.demosaic);
618
+ ```
619
+
457
620
  ## Not available
458
621
 
459
622
  The facade offers no atmospheric haze or polarizer (see [Optics](#optics)), flash, video, HDR brackets
@@ -462,6 +625,11 @@ provided, for rights reasons.
462
625
 
463
626
  ## Sources
464
627
 
628
+ - R. Palum, "Anti-Aliasing Filter Analysis for Digital Cameras", Proc. IS&T International Congress of
629
+ Imaging Science (ICIS '06), 2006, free at
630
+ <https://www.imaging.org/common/uploaded%20files/pdfs/Papers/2006/ICIS-0-736/33683.pdf>
631
+ (birefringent quartz four-spot filter; one pixel wide in each direction cuts off at Nyquist; its MTF
632
+ is the modulus of a cosine that returns to one at the sampling frequency).
465
633
  - CIPA DC-004-Translation-2020, *Sensitivity of Digital Cameras*, free at
466
634
  <https://www.cipa.jp/e/std/std-sec.html> (standard output sensitivity S = 10/H, §3.2; reference
467
635
  level tied to 18 % grey, §5.2 and Explanation 1, 2.2; focal-plane exposure H = 0.65·L·t/N², §5.5
@@ -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.0.0" as const
14
+ ENGINE_API_VERSION = "2.1.0" as const
15
15
  ```
@@ -1,6 +1,6 @@
1
1
  {
2
- "packageVersion": "2.0.0",
3
- "engineApiVersion": "2.0.0",
2
+ "packageVersion": "2.1.0",
3
+ "engineApiVersion": "2.1.0",
4
4
  "exports": [
5
5
  {
6
6
  "name": "AbsoluteUncertaintyEstimate",
@@ -2122,6 +2122,16 @@
2122
2122
  "source": "src/color/white-balance.ts",
2123
2123
  "kind": "type"
2124
2124
  },
2125
+ {
2126
+ "name": "createDiffractionLimitedPsfProfile",
2127
+ "source": "src/optics/diffraction-psf-profile.ts",
2128
+ "kind": "runtime"
2129
+ },
2130
+ {
2131
+ "name": "CreateDiffractionLimitedPsfProfileInput",
2132
+ "source": "src/optics/diffraction-psf-profile.ts",
2133
+ "kind": "type"
2134
+ },
2125
2135
  {
2126
2136
  "name": "createDurandDorseyToneMappingTask",
2127
2137
  "source": "src/processing/tone-mapping.ts",
@@ -2437,6 +2447,26 @@
2437
2447
  "source": "src/optics/depth-of-field.ts",
2438
2448
  "kind": "type"
2439
2449
  },
2450
+ {
2451
+ "name": "DIFFRACTION_LIMITED_PSF_PROFILE_LIMITS",
2452
+ "source": "src/optics/diffraction-psf-profile.ts",
2453
+ "kind": "runtime"
2454
+ },
2455
+ {
2456
+ "name": "DIFFRACTION_LIMITED_PSF_PROFILE_MODEL",
2457
+ "source": "src/optics/diffraction-psf-profile.ts",
2458
+ "kind": "runtime"
2459
+ },
2460
+ {
2461
+ "name": "DiffractionLimitedPsfProfile",
2462
+ "source": "src/optics/diffraction-psf-profile.ts",
2463
+ "kind": "type"
2464
+ },
2465
+ {
2466
+ "name": "DiffractionLimitedPsfWavelength",
2467
+ "source": "src/optics/diffraction-psf-profile.ts",
2468
+ "kind": "type"
2469
+ },
2440
2470
  {
2441
2471
  "name": "DigitalGeometricTransform",
2442
2472
  "source": "src/output/geometric-transforms.ts",
@@ -7692,6 +7722,11 @@
7692
7722
  "source": "src/sensor/signal-noise.ts",
7693
7723
  "kind": "type"
7694
7724
  },
7725
+ {
7726
+ "name": "SIMULATE_CAPTURE_COST_OPTIONS",
7727
+ "source": "src/facade/simulate-capture.ts",
7728
+ "kind": "runtime"
7729
+ },
7695
7730
  {
7696
7731
  "name": "SIMULATE_CAPTURE_DEFAULT_ILLUMINANCE_LUX",
7697
7732
  "source": "src/facade/simulate-capture.ts",
@@ -7722,6 +7757,11 @@
7722
7757
  "source": "src/facade/simulate-capture.ts",
7723
7758
  "kind": "type"
7724
7759
  },
7760
+ {
7761
+ "name": "SimulateCaptureCostOption",
7762
+ "source": "src/facade/simulate-capture.ts",
7763
+ "kind": "type"
7764
+ },
7725
7765
  {
7726
7766
  "name": "SimulateCaptureDemosaic",
7727
7767
  "source": "src/facade/simulate-capture.ts",
@@ -7762,6 +7802,11 @@
7762
7802
  "source": "src/facade/simulate-capture.ts",
7763
7803
  "kind": "type"
7764
7804
  },
7805
+ {
7806
+ "name": "SimulateCaptureSensorAntiAliasing",
7807
+ "source": "src/facade/simulate-capture.ts",
7808
+ "kind": "type"
7809
+ },
7765
7810
  {
7766
7811
  "name": "SimulateCaptureWhiteBalance",
7767
7812
  "source": "src/facade/simulate-capture.ts",
@@ -2,6 +2,18 @@
2
2
 
3
3
  The package and root API versions are recorded once in [Navigation](../API_REFERENCE.md) · [Developer guide](https://github.com/Photivra/photivra/blob/main/docs/DEVELOPERS.md). Generated signatures retain independent schema/model versions. Only the exports listed here are root-package contracts; module-local helpers are not supported deep imports.
4
4
 
5
+ ## SIMULATE_CAPTURE_COST_OPTIONS
6
+
7
+ Options reported in `diagnostics.options` (2.1), in this order, when they ran: the ones that change how much work
8
+ a capture does. `motion.rotation` is a non-zero angular velocity; `sensor.temporalSampleCount` a count above 1.
9
+
10
+ ```ts
11
+ SIMULATE_CAPTURE_COST_OPTIONS = Object.freeze(["lens.pupilSampling", "lens.projection", "lens.distortion", "lens.vignetting", "lens.psf",
12
+ "lens.diffraction", "sensor.antiAliasing", "sensor.temporalSampleCount", "motion.rotation", "motion.shutter.rolling", "output.borderHandling.mirror",
13
+ "output.whiteBalance.auto", "output.demosaic.ahd", "output.highlightMode.reconstruct", "output.colorEncoding.wide-gamut", "output.gamutMapping.perceptual",
14
+ "output.format.jpeg", "output.format.png", "output.format.dng"] as const)
15
+ ```
16
+
5
17
  ## SIMULATE_CAPTURE_DEFAULT_ILLUMINANCE_LUX
6
18
 
7
19
  Default illuminance, lux: an 18 % Lambertian grey card (L = 0.18·E/π) then gets exactly the CIPA DC-004 standard
@@ -70,12 +82,22 @@ PQ and HLG (BT.2100) are not part of 2.0; see THIRD_PARTY.md.
70
82
  export type SimulateCaptureColorEncoding = "srgb" | "display-p3" | "rec2020";
71
83
  ```
72
84
 
85
+ ## SimulateCaptureCostOption
86
+
87
+ The exact contract is declared below; use the domain guide for assumptions and staged integration.
88
+
89
+ ```ts
90
+ export type SimulateCaptureCostOption = typeof SIMULATE_CAPTURE_COST_OPTIONS[number];
91
+ ```
92
+
73
93
  ## SimulateCaptureDemosaic
74
94
 
75
- Built-in Bayer demosaic kernels (`createBuiltInBayerDemosaicProfiles`).
95
+ Demosaic. `bilinear`: the built-in Bayer kernels (`createBuiltInBayerDemosaicProfiles`). `ahd` (2.1): adaptive
96
+ homogeneity-directed demosaicing (`createAhdDemosaicTask`, after Hirakawa & Parks 2005), a non-linear
97
+ edge-directed method that reduces zipper and false colour on sharp edges.
76
98
 
77
99
  ```ts
78
- export type SimulateCaptureDemosaic = "bilinear";
100
+ export type SimulateCaptureDemosaic = "bilinear" | "ahd";
79
101
  ```
80
102
 
81
103
  ## SimulateCaptureExposureMode
@@ -139,6 +161,13 @@ export interface SimulateCaptureOptions {
139
161
  * focus must equal the resolved capture settings exactly. Not combinable with pupilSampling.
140
162
  */
141
163
  psf?: Extract<CalculateEnvironmentSensorPhotoSignalInput["psf"], { kind: "sampled-local" }>;
164
+ /**
165
+ * Opt-in (2.1) diffraction blur of an ideal, aberration-free, in-focus lens with a circular pupil at the
166
+ * capture's resolved f-number: the engine builds the Airy kernels (`createDiffractionLimitedPsfProfile`)
167
+ * on the site pitch. `kernelRadiusSamples` (0–32) sets the kernel window; default: the third dark ring at
168
+ * the longest spectral node. Not combinable with `psf` or `pupilSampling`. Default off.
169
+ */
170
+ diffraction?: SimulateCaptureLensDiffraction;
142
171
  };
143
172
  exposure?: {
144
173
  /** Default "manual". */
@@ -201,6 +230,11 @@ export interface SimulateCaptureOptions {
201
230
  temporalSampleCount?: number;
202
231
  /** Noise seed (uint32). Default 1. */
203
232
  noiseSeed?: number;
233
+ /**
234
+ * Opt-in (2.1) optical anti-aliasing filter (birefringent point splitting, `SimulateCaptureSensorAntiAliasing`).
235
+ * Multiplies the scene queries by the number of split points. Default off (the tiers declare no filter).
236
+ */
237
+ antiAliasing?: SimulateCaptureSensorAntiAliasing;
204
238
  };
205
239
  /** Aborting cancels the running task; the promise rejects with a domain error and returns no partial image. */
206
240
  signal?: AbortSignal;
@@ -262,6 +296,14 @@ export interface SimulateCaptureResult {
262
296
  siteInput(nativeIndex: number): SimulateEnvironmentSensorRawFrameInput["sites"][number];
263
297
  development: Omit<NativeRawDevelopmentInput, "raw">;
264
298
  whiteBalanceState: ResolvedWhiteBalanceState | null;
299
+ /**
300
+ * Present with lens.diffraction (2.1): the engine-built Airy profile, the equivalent environment sampled PSF
301
+ * selection, and how it was applied. With `object-space-convolution` the site inputs declare no PSF and the
302
+ * provider answers with the PSF-convolved radiance; putting `psf` into each site input and using the plain
303
+ * scene provider gives the same expected signal (to float rounding) through the per-tap environment quadrature.
304
+ */
305
+ diffraction?: { profile: DiffractionLimitedPsfProfile; psf: Extract<CalculateEnvironmentSensorPhotoSignalInput["psf"], { kind: "sampled-local" }>;
306
+ execution: "object-space-convolution" | "environment-psf-quadrature" };
265
307
  };
266
308
  exposure: {
267
309
  mode: SimulateCaptureExposureMode;
@@ -295,7 +337,20 @@ export interface SimulateCaptureResult {
295
337
  rendering: SdrRenderingResult["diagnostics"];
296
338
  /** Present with output.gamutMapping "perceptual": pixels whose chroma or lightness the mapping changed. */
297
339
  gamutMapping?: { method: "cielab-hue-preserving-chroma-compression"; kneeFraction: number; adjustedPixelCount: number; lightnessClippedPixelCount: number };
298
- timingsMs: { raw: number; whiteBalance: number; development: number; jpeg: number; total: number };
340
+ /**
341
+ * Wall-clock milliseconds per stage. `raw`, `whiteBalance` (the second RAW pass of auto white balance), `development`
342
+ * (includes DNG writing), `jpeg` and `total` are whole milliseconds (`Date.now()`). Since 2.1, where a stage ran:
343
+ * `diffraction` (the object-space convolution of `lens.diffraction`; part of `raw` and `whiteBalance`), `demosaic`
344
+ * (the AHD demosaic; part of `development`) and `encode` (PNG, JPEG or DNG serialization), with sub-millisecond
345
+ * resolution where the host has `performance.now()`. Timings vary from run to run and never enter any digest or output byte.
346
+ */
347
+ timingsMs: { raw: number; whiteBalance: number; development: number; jpeg: number; total: number; diffraction?: number; demosaic?: number; encode?: number };
348
+ /**
349
+ * 2.1: the opt-in options that changed the work of this run, in a fixed order (see `SIMULATE_CAPTURE_COST_OPTIONS`).
350
+ * Empty for a capture with every default. With `timingsMs` it lets an application measure each option's cost on
351
+ * the user's device.
352
+ */
353
+ options: readonly SimulateCaptureCostOption[];
299
354
  };
300
355
  provenance: {
301
356
  scientificStatus: "approximation";
@@ -310,6 +365,24 @@ export interface SimulateCaptureResult {
310
365
  }
311
366
  ```
312
367
 
368
+ ## SimulateCaptureSensorAntiAliasing
369
+
370
+ Optical anti-aliasing (low-pass) filter (2.1), modelled as birefringent point splitting: each image point is
371
+ split into equally bright copies (the engine's `normalized-point-splitting-kernel` optical-stack response).
372
+ - `four-spot`: two crossed birefringent plates; copies at (±d/2, ±d/2), weight 1/4 each, MTF |cos(π·f_x·d)·cos(π·f_y·d)|.
373
+ - `two-spot`: one plate splitting along `axis`; copies at ±d/2, weight 1/2 each, MTF |cos(π·f·d)|.
374
+ - `point-splitting`: the engine contract itself, offsets in micrometres (+x right, +y down) and positive weights
375
+ summing to 1 (at most 16 components, each within 4 sites).
376
+ d = `separationSites` × pixel pitch, 0 < separationSites ≤ 4, default 1 (first MTF zero at the sensor's Nyquist
377
+ frequency 1/(2·pitch)).
378
+
379
+ ```ts
380
+ export type SimulateCaptureSensorAntiAliasing =
381
+ | { kind: "four-spot"; separationSites?: number }
382
+ | { kind: "two-spot"; axis: "horizontal" | "vertical"; separationSites?: number }
383
+ | { kind: "point-splitting"; components: readonly AntiAliasingPointSplitComponent[] };
384
+ ```
385
+
313
386
  ## SimulateCaptureWhiteBalance
314
387
 
315
388
  The exact contract is declared below; use the domain guide for assumptions and staged integration.
@@ -0,0 +1,96 @@
1
+ # optics/diffraction-psf-profile.ts public contracts
2
+
3
+ The package and root API versions are recorded once in [Navigation](../API_REFERENCE.md) · [Developer guide](https://github.com/Photivra/photivra/blob/main/docs/DEVELOPERS.md). Generated signatures retain independent schema/model versions. Only the exports listed here are root-package contracts; module-local helpers are not supported deep imports.
4
+
5
+ ## createDiffractionLimitedPsfProfile
6
+
7
+ Creates a sampled lens PSF profile for an ideal diffraction-limited lens with a circular pupil: one
8
+ unit-sum Airy kernel per wavelength on one shared (2R+1)² grid, with the truncated energy reported per
9
+ wavelength. The profile declares the given focal length, f-number and focus exactly, and the field axes
10
+ ±`fieldHalfExtentMm` with the same kernel at every field node.
11
+
12
+ ```ts
13
+ export function createDiffractionLimitedPsfProfile(input: CreateDiffractionLimitedPsfProfileInput): CalculationResult<DiffractionLimitedPsfProfile>;
14
+ ```
15
+
16
+ This call is synchronous; any supplied provider must follow its explicit synchronous contract.
17
+
18
+ ## CreateDiffractionLimitedPsfProfileInput
19
+
20
+ The exact contract is declared below; use the domain guide for assumptions and staged integration.
21
+
22
+ ```ts
23
+ export interface CreateDiffractionLimitedPsfProfileInput {
24
+ /** Only `circular` in 2.1.0 (polygon pupils: `calculateIdealPolygonDiffractionPsf` gives point densities). */
25
+ aperture: "circular";
26
+ focalLengthMm: number;
27
+ apertureFNumber: number;
28
+ focus: FocusPlane;
29
+ /** Strictly increasing wavelengths, nm, in the medium of propagation (no air/vacuum conversion is applied). 1–64. */
30
+ wavelengthsNm: readonly number[];
31
+ /** Square sample pitch of every kernel, µm. */
32
+ samplePitchMicrometers: number;
33
+ /** Kernel half-width in samples; each kernel is (2R + 1)². 0–32. */
34
+ kernelRadiusSamples: number;
35
+ /** Half-extent of the image field over which the on-axis PSF is declared (profile field axes ±x, ±y), mm. */
36
+ fieldHalfExtentMm: { x: number; y: number };
37
+ profileId: string;
38
+ }
39
+ ```
40
+
41
+ ## DIFFRACTION_LIMITED_PSF_PROFILE_LIMITS
42
+
43
+ Input limits: at most 64 wavelengths, kernel radius 0–32 samples (the irradiance-convolution limit).
44
+
45
+ ```ts
46
+ DIFFRACTION_LIMITED_PSF_PROFILE_LIMITS = Object.freeze({ maximumWavelengths: 64, maximumKernelRadiusSamples: 32 })
47
+ ```
48
+
49
+ ## DIFFRACTION_LIMITED_PSF_PROFILE_MODEL
50
+
51
+ The exact contract is declared below; use the domain guide for assumptions and staged integration.
52
+
53
+ ```ts
54
+ DIFFRACTION_LIMITED_PSF_PROFILE_MODEL = Object.freeze({
55
+ modelId: "photivra-diffraction-limited-circular-pupil-psf" as const, modelVersion: "1.0.0" as const })
56
+ ```
57
+
58
+ ## DiffractionLimitedPsfProfile
59
+
60
+ The exact contract is declared below; use the domain guide for assumptions and staged integration.
61
+
62
+ ```ts
63
+ export interface DiffractionLimitedPsfProfile {
64
+ profile: LensSampledPsfProfile;
65
+ model: typeof DIFFRACTION_LIMITED_PSF_PROFILE_MODEL;
66
+ pupilDiameterMm: number;
67
+ pupilToImageDistanceMm: number;
68
+ workingFNumber: number;
69
+ kernelRadiusSamples: number;
70
+ samplePitchMicrometers: number;
71
+ wavelengths: readonly DiffractionLimitedPsfWavelength[];
72
+ /** Largest truncated energy over the wavelengths. */
73
+ maximumTruncatedEnergyFraction: number;
74
+ quadrature: { method: "polar-frequency-domain-trapezoid-gauss-legendre"; maximumRadialNodes: number; maximumAngularNodes: number };
75
+ }
76
+ ```
77
+
78
+ ## DiffractionLimitedPsfWavelength
79
+
80
+ The exact contract is declared below; use the domain guide for assumptions and staged integration.
81
+
82
+ ```ts
83
+ export interface DiffractionLimitedPsfWavelength {
84
+ wavelengthNm: number;
85
+ /** f_c = 1/(λ·N_w), cycles per µm. */
86
+ cutoffFrequencyCyclesPerMicrometer: number;
87
+ /** 1.21967·λ·N_w, µm. */
88
+ firstZeroRadiusMicrometers: number;
89
+ /** Σ of the cell energies inside the window before renormalization (of a total of 1). */
90
+ capturedEnergyFraction: number;
91
+ /** 1 − capturedEnergyFraction: the Airy tail energy outside the window, put back by renormalization. */
92
+ truncatedEnergyFraction: number;
93
+ /** Fraction of the renormalized kernel in the centre sample. */
94
+ centreSampleFraction: number;
95
+ }
96
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@photivra/engine",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "private": false,
5
5
  "description": "Scientific TypeScript engine for camera optics, exposure, focus, motion, sensor sampling, diffraction, and digital imaging calculations.",
6
6
  "type": "module",
@@ -35,7 +35,8 @@
35
35
  "dist/schema",
36
36
  "dist/sensor",
37
37
  "dist/simulation",
38
- "dist/stabilization","dist/processing",
38
+ "dist/stabilization",
39
+ "dist/processing",
39
40
  "!dist/capture/browser-native-reference-*",
40
41
  "!dist/schema/camera.js",
41
42
  "!dist/schema/camera.js.map",