@photivra/engine 2.15.0 → 2.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +102 -1
- package/CITATION.cff +1 -1
- package/README.md +6 -6
- package/SECURITY.md +1 -1
- package/THIRD_PARTY.md +38 -0
- package/dist/camera/lens-projection.d.ts +5 -0
- package/dist/camera/lens-projection.js +10 -0
- package/dist/camera/lens-projection.js.map +1 -1
- package/dist/capture/native-environment-lean.js +1 -1
- package/dist/capture/native-environment-lean.js.map +1 -1
- package/dist/capture/native-raw-development.d.ts +15 -0
- package/dist/capture/native-raw-development.js +12 -6
- package/dist/capture/native-raw-development.js.map +1 -1
- package/dist/capture/raw-development-kernel.d.ts +10 -3
- package/dist/capture/raw-development-kernel.js +25 -6
- package/dist/capture/raw-development-kernel.js.map +1 -1
- package/dist/core/internal-radiance-evaluator.d.ts +5 -2
- package/dist/core/internal-radiance-evaluator.js.map +1 -1
- package/dist/core/version.d.ts +1 -1
- package/dist/core/version.js +1 -1
- package/dist/core/version.js.map +1 -1
- package/dist/equipment/generic-lens-catalogue.d.ts +227 -0
- package/dist/equipment/generic-lens-catalogue.js +243 -0
- package/dist/equipment/generic-lens-catalogue.js.map +1 -0
- package/dist/facade/defocus-bokeh-zones.d.ts +33 -0
- package/dist/facade/defocus-bokeh-zones.js +53 -0
- package/dist/facade/defocus-bokeh-zones.js.map +1 -0
- package/dist/facade/defocus-image-space-provider.d.ts +118 -0
- package/dist/facade/defocus-image-space-provider.js +448 -0
- package/dist/facade/defocus-image-space-provider.js.map +1 -0
- package/dist/facade/diffraction-provider.d.ts +45 -0
- package/dist/facade/diffraction-provider.js +120 -14
- package/dist/facade/diffraction-provider.js.map +1 -1
- package/dist/facade/display-view-gpu-session.js +4 -0
- package/dist/facade/display-view-gpu-session.js.map +1 -1
- package/dist/facade/display-view-gpu.d.ts +10 -0
- package/dist/facade/display-view-gpu.js.map +1 -1
- package/dist/facade/display-view-lens-signal.d.ts +30 -0
- package/dist/facade/display-view-lens-signal.js +427 -0
- package/dist/facade/display-view-lens-signal.js.map +1 -0
- package/dist/facade/display-view.d.ts +21 -0
- package/dist/facade/display-view.js +66 -8
- package/dist/facade/display-view.js.map +1 -1
- package/dist/facade/expected-signal-setup.d.ts +7 -0
- package/dist/facade/expected-signal-setup.js +12 -2
- package/dist/facade/expected-signal-setup.js.map +1 -1
- package/dist/facade/expected-signal.d.ts +8 -0
- package/dist/facade/expected-signal.js +150 -0
- package/dist/facade/expected-signal.js.map +1 -1
- package/dist/facade/generic-lens-capture.d.ts +6 -0
- package/dist/facade/generic-lens-capture.js +8 -0
- package/dist/facade/generic-lens-capture.js.map +1 -0
- package/dist/facade/large-defocus-lattice.d.ts +39 -0
- package/dist/facade/large-defocus-lattice.js +64 -0
- package/dist/facade/large-defocus-lattice.js.map +1 -0
- package/dist/facade/scene-radiance.d.ts +5 -0
- package/dist/facade/scene-radiance.js +5 -2
- package/dist/facade/scene-radiance.js.map +1 -1
- package/dist/facade/simulate-capture.d.ts +148 -6
- package/dist/facade/simulate-capture.js +521 -88
- package/dist/facade/simulate-capture.js.map +1 -1
- package/dist/facade/simulate-display-view.js +29 -19
- package/dist/facade/simulate-display-view.js.map +1 -1
- package/dist/gpu/display-view-quality-gpu.d.ts +2 -9
- package/dist/gpu/display-view-quality-gpu.js +63 -13
- package/dist/gpu/display-view-quality-gpu.js.map +1 -1
- package/dist/gpu/display-view-speed-gpu.d.ts +2 -0
- package/dist/gpu/display-view-speed-gpu.js +47 -16
- package/dist/gpu/display-view-speed-gpu.js.map +1 -1
- package/dist/gpu/image-kernel-accelerator.js +3 -0
- package/dist/gpu/image-kernel-accelerator.js.map +1 -1
- package/dist/gpu/wgsl-display-view-quality-kernel.d.ts +4 -0
- package/dist/gpu/wgsl-display-view-quality-kernel.js +53 -2
- package/dist/gpu/wgsl-display-view-quality-kernel.js.map +1 -1
- package/dist/gpu/wgsl-display-view-speed-kernel.d.ts +15 -0
- package/dist/gpu/wgsl-display-view-speed-kernel.js +42 -1
- package/dist/gpu/wgsl-display-view-speed-kernel.js.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/optics/defocus-bokeh-kernel.d.ts +207 -0
- package/dist/optics/defocus-bokeh-kernel.js +380 -0
- package/dist/optics/defocus-bokeh-kernel.js.map +1 -0
- package/dist/optics/defocus-disc-kernel.d.ts +44 -21
- package/dist/optics/defocus-disc-kernel.js +47 -25
- package/dist/optics/defocus-disc-kernel.js.map +1 -1
- package/dist/optics/generic-lens-psf.d.ts +68 -0
- package/dist/optics/generic-lens-psf.js +337 -0
- package/dist/optics/generic-lens-psf.js.map +1 -0
- package/dist/optics/illumination-vignetting.d.ts +6 -0
- package/dist/optics/illumination-vignetting.js +24 -7
- package/dist/optics/illumination-vignetting.js.map +1 -1
- package/dist/optics/sensor-environment-query.d.ts +37 -0
- package/dist/optics/sensor-environment-query.js +58 -19
- package/dist/optics/sensor-environment-query.js.map +1 -1
- package/dist/optics/site-kernel-runs.d.ts +77 -0
- package/dist/optics/site-kernel-runs.js +200 -0
- package/dist/optics/site-kernel-runs.js.map +1 -0
- package/dist/output/generic-lens-corrections.d.ts +24 -0
- package/dist/output/generic-lens-corrections.js +51 -0
- package/dist/output/generic-lens-corrections.js.map +1 -0
- package/dist/processing/development-detail.d.ts +132 -0
- package/dist/processing/development-detail.js +325 -0
- package/dist/processing/development-detail.js.map +1 -0
- package/docs/AGENT_GUIDE.md +1 -0
- package/docs/API_REFERENCE.md +54 -1
- package/docs/QUICK_START.md +444 -26
- package/docs/USAGE.md +71 -8
- package/docs/api/capture-native-raw-development.md +16 -0
- package/docs/api/core-version.md +1 -1
- package/docs/api/equipment-generic-lens-catalogue.md +281 -0
- package/docs/api/exports.json +162 -2
- package/docs/api/facade-simulate-capture.md +151 -5
- package/docs/api/optics-defocus-bokeh-kernel.md +145 -0
- package/docs/api/optics-defocus-disc-kernel.md +25 -17
- package/docs/api/optics-sensor-environment-query.md +6 -0
- package/docs/api/output-generic-lens-corrections.md +39 -0
- package/llms.txt +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bokeh character (#553): the defocus kernel of `lens.defocus` shaped by the lens, instead of the uniform disc of an ideal
|
|
3
|
+
* circular pupil. Three opt-in parts, each off by default (without them `lens.defocus` keeps the exact disc of
|
|
4
|
+
* `createDefocusDiscSiteKernel`, byte for byte):
|
|
5
|
+
*
|
|
6
|
+
* - **Aperture blades** (`blades`): the opening is a regular polygon of `count` straight blades
|
|
7
|
+
* (`calculateIdealApertureGeometry`'s vertex convention, `firstBladeEdgeAngleDegrees`), or with `curvature` κ in (0, 1]
|
|
8
|
+
* rounded blades: each edge an arc of radius 1/κ (circumradius units) through the edge's two vertices, so κ = 1 is the
|
|
9
|
+
* circumscribed circle. At or below `circularAtOrBelowFNumber` the blades are open and the opening is the circle.
|
|
10
|
+
* The opening is scaled to the area of the circle of diameter f/N (equal-area convention): the f-number keeps its
|
|
11
|
+
* meaning for light throughput, which the capture applies elsewhere (π/(4·N_w²)), and the kernel's support has the
|
|
12
|
+
* disc's area.
|
|
13
|
+
* - **Cat's eye** (`catsEye`): mechanical vignetting, the model of `calculateMechanicalVignetting` (Greivenkamp, OPTI-502
|
|
14
|
+
* course notes, University of Arizona, sec. 10, free; also see Kingslake, Lens Design Fundamentals, sec. 6.4). Each
|
|
15
|
+
* clipping aperture (a barrel or element rim referred to the entrance-pupil plane) is a circle of `radiusMm`, shifted by
|
|
16
|
+
* `shiftPerTanFieldMm`·tan θ toward the field point; the open pupil is the opening intersected with every circle. It
|
|
17
|
+
* depends on the field point, so the capture applies it per field zone (see `defocus-image-space-provider.ts`). The light
|
|
18
|
+
* it removes is not applied by the kernel (the kernel has unit sum, a shape only): declare the illumination falloff with
|
|
19
|
+
* `lens.vignetting`, as for any field throughput; `openAreaRatio` reports the open fraction.
|
|
20
|
+
* - **Spherical aberration** (`sphericalAberration`): primary (Seidel) spherical aberration W = W040·ρ⁴ of the working
|
|
21
|
+
* pupil (ρ the pupil radius normalized to the pupil of f/N), given as W040 in µm at a reference f-number N₀ and scaled
|
|
22
|
+
* to the working f-number as W040·(N₀/N)⁴ (the pupil coordinate of a fixed lens shrinks with the stop). Its transverse
|
|
23
|
+
* ray aberration at the image is ε(ρ) = −(R/r_p)·∂W/∂ρ = −8·N·W040·ρ³ for R/r_p = 2N (Wyant & Creath 1992, "Basic
|
|
24
|
+
* Wavefront Aberration Theory for Optical Metrology", Applied Optics and Optical Engineering XI, §II.A and eq. 31, free).
|
|
25
|
+
*
|
|
26
|
+
* The ray map. A geometric (ray-optics) defocused image of a point is the pupil projected onto the sensor: the ray through
|
|
27
|
+
* pupil point P = ρ·p̂ (pupil-radius units) lands at displacement
|
|
28
|
+
* y(P) = (σ·c/2 + b·ρ²)·P, b = 8·N·W040(N)/pitch (sites),
|
|
29
|
+
* from the chief ray, with c the thin-lens blur diameter in sites (`calculateDefocusCircle`, the marginal ray of the
|
|
30
|
+
* aberration-free lens) and σ = +1 for a scene point behind the focus plane, −1 in front of it. Rays from behind the
|
|
31
|
+
* focus plane cross before the sensor, those from in front have not crossed yet, so the two blurs are the same pupil
|
|
32
|
+
* turned through 180° relative to each other, and spherical aberration adds to the defocus on one side and subtracts on
|
|
33
|
+
* the other: with undercorrected spherical aberration (W040 > 0) the blur behind the focus plane is soft-edged and the
|
|
34
|
+
* blur in front bright-rimmed (H. H. Nasse, "Depth of Field and Bokeh", Carl Zeiss Camera Lens Division, 2010, free; the
|
|
35
|
+
* ray density follows from the map: for a circular pupil the image intensity at r = |σc/2·ρ + bρ³| is
|
|
36
|
+
* proportional to 1/(|σc/2 + bρ²|·|σc/2 + 3bρ²|)). The convention of this module, used for `firstBladeEdgeAngleDegrees` and
|
|
37
|
+
* the cat's-eye shift: shapes are declared as the blur of a point behind the focus plane appears in the output image
|
|
38
|
+
* (+X right, +Y up), and the field tangent (tan θ along the field azimuth) is in the same frame.
|
|
39
|
+
*
|
|
40
|
+
* Sampling on the site lattice. As for the disc, a site's weight is the fraction of the blur's light inside that site's
|
|
41
|
+
* square cell [i − ½, i + ½] × [j − ½, j + ½].
|
|
42
|
+
* - Without spherical aberration the blur is uniform over the projected open pupil, a convex polygon (circles and blade
|
|
43
|
+
* arcs are polygons of 1024 sides, scaled to their true area; the boundary then lies within 5e-6 of the radius of the true
|
|
44
|
+
* curve), and each weight is the exact area of the polygon inside the cell (convex clipping and the shoelace formula),
|
|
45
|
+
* divided by the polygon's area: method `"exact-polygon-cell-area"`.
|
|
46
|
+
* - With spherical aberration the intensity is not uniform (and has a caustic when |b| > c/6 on the bright-rimmed side), so
|
|
47
|
+
* the pupil is sampled on a square grid of step h ≤ 1/64, fine enough that neighbouring rays land at most 1/8 site apart,
|
|
48
|
+
* each grid cell's row segment weighted by its exact overlap with the open pupil's chord, and every ray binned into the
|
|
49
|
+
* site cell it lands in: method `"pupil-ray-binning"`, a quadrature with an error of the order of the ray spacing at the
|
|
50
|
+
* cells the blur's edge or caustic crosses.
|
|
51
|
+
* Every step uses correctly rounded operations and the engine's portable trigonometry, so the weights are the same bits in
|
|
52
|
+
* every JavaScript engine, and they are normalized as the disc's are (exact sum 1 within half an ulp of the largest weight,
|
|
53
|
+
* which takes up the rounding instead of the centre: off axis the cat's eye can leave the centre cell empty).
|
|
54
|
+
*
|
|
55
|
+
* What it is: a geometric approximation (no diffraction of the defocused wave, an ideal thin lens with an aberration-free
|
|
56
|
+
* pupil apart from the declared primary spherical aberration, referred to the paraxial focus, and an exit pupil of the same
|
|
57
|
+
* shape and orientation as the entrance pupil). Not modelled: onion rings (aspherical-element texture), higher-order or
|
|
58
|
+
* field-dependent aberrations, colour (the kernel is the same at every wavelength) and the depth-dependent change of the
|
|
59
|
+
* cat's eye with the subject distance.
|
|
60
|
+
*/
|
|
61
|
+
import { type CalculationResult } from "../core/calculation-result.js";
|
|
62
|
+
import { type DefocusDiscSiteKernel } from "./defocus-disc-kernel.js";
|
|
63
|
+
/** Largest blade count of `lens.defocus.blades` (and of {@link DefocusBokehBlades}). */
|
|
64
|
+
export declare const DEFOCUS_BOKEH_MAXIMUM_BLADES = 32;
|
|
65
|
+
/** Largest number of field zones across the frame's long side for the cat's eye (`catsEye.fieldZonesAcrossLongSide`). */
|
|
66
|
+
export declare const DEFOCUS_BOKEH_MAXIMUM_FIELD_ZONES = 32;
|
|
67
|
+
/** Default field zones across the long side. */
|
|
68
|
+
export declare const DEFOCUS_BOKEH_DEFAULT_FIELD_ZONES = 8;
|
|
69
|
+
/** Aperture blades: a regular polygon, optionally with rounded blades and open at wide f-numbers. */
|
|
70
|
+
export interface DefocusBokehBlades {
|
|
71
|
+
/** Number of diaphragm blades, 3 to {@link DEFOCUS_BOKEH_MAXIMUM_BLADES}. */
|
|
72
|
+
count: number;
|
|
73
|
+
/** Rotation of the first blade edge, degrees counter-clockwise (`calculateIdealApertureGeometry`'s convention). Default 0. */
|
|
74
|
+
firstBladeEdgeAngleDegrees?: number;
|
|
75
|
+
/** Blade roundness κ in [0, 1]: each edge is an arc of radius 1/κ circumradii (0: straight blades, 1: a circle). Default 0. */
|
|
76
|
+
curvature?: number;
|
|
77
|
+
/** At or below this f-number the blades are open and the opening is circular (the lens's widest aperture). Default: never. */
|
|
78
|
+
circularAtOrBelowFNumber?: number;
|
|
79
|
+
}
|
|
80
|
+
/** Primary (Seidel) spherical aberration W = W040·ρ⁴, given at a reference f-number. */
|
|
81
|
+
export interface DefocusBokehSphericalAberration {
|
|
82
|
+
/** W040 in µm of optical path at the pupil edge of `atFNumber`; positive = undercorrected (marginal rays focus short). */
|
|
83
|
+
w040Micrometers: number;
|
|
84
|
+
/** The f-number at which `w040Micrometers` is given; the working value is W040·(atFNumber/N)⁴. */
|
|
85
|
+
atFNumber: number;
|
|
86
|
+
}
|
|
87
|
+
/** One clipping aperture of the cat's eye, as `MechanicalVignettingAperture` of `calculateMechanicalVignetting`. */
|
|
88
|
+
export interface DefocusBokehClippingAperture {
|
|
89
|
+
/** Aperture radius referred to the entrance-pupil plane, mm. */
|
|
90
|
+
radiusMm: number;
|
|
91
|
+
/** Shift of its projected centre per unit tan(field angle), mm, toward the field point when positive. */
|
|
92
|
+
shiftPerTanFieldMm: number;
|
|
93
|
+
}
|
|
94
|
+
/** Mechanical vignetting (cat's eye): the open pupil clipped by each aperture at the field point. */
|
|
95
|
+
export interface DefocusBokehCatsEye {
|
|
96
|
+
/** One through eight clipping apertures. */
|
|
97
|
+
apertures: readonly DefocusBokehClippingAperture[];
|
|
98
|
+
/**
|
|
99
|
+
* `lens.defocus` only: zones across the frame's long side (1 to {@link DEFOCUS_BOKEH_MAXIMUM_FIELD_ZONES}, default
|
|
100
|
+
* {@link DEFOCUS_BOKEH_DEFAULT_FIELD_ZONES}); square zones, each with the kernel of its centre's field point.
|
|
101
|
+
*/
|
|
102
|
+
fieldZonesAcrossLongSide?: number;
|
|
103
|
+
}
|
|
104
|
+
/** The bokeh options of `lens.defocus` (#553), each off when omitted; a lens catalogue entry can carry them as they are. */
|
|
105
|
+
export interface DefocusBokehOptions {
|
|
106
|
+
blades?: DefocusBokehBlades;
|
|
107
|
+
sphericalAberration?: DefocusBokehSphericalAberration;
|
|
108
|
+
catsEye?: DefocusBokehCatsEye;
|
|
109
|
+
}
|
|
110
|
+
/** Validated {@link DefocusBokehOptions}: a copy with defaults filled in. */
|
|
111
|
+
export interface ResolvedDefocusBokehOptions {
|
|
112
|
+
blades?: Required<Omit<DefocusBokehBlades, "circularAtOrBelowFNumber">> & {
|
|
113
|
+
circularAtOrBelowFNumber: number | null;
|
|
114
|
+
};
|
|
115
|
+
sphericalAberration?: DefocusBokehSphericalAberration;
|
|
116
|
+
catsEye?: {
|
|
117
|
+
apertures: readonly DefocusBokehClippingAperture[];
|
|
118
|
+
fieldZonesAcrossLongSide: number;
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Validates bokeh options (unknown fields: `InvalidConfigurationError`; out-of-range values: `InvalidScientificInputError`).
|
|
123
|
+
* `label` prefixes the messages (for example `"lens.defocus"`).
|
|
124
|
+
*/
|
|
125
|
+
export declare function resolveDefocusBokehOptions(value: unknown, label: string): ResolvedDefocusBokehOptions;
|
|
126
|
+
/** Internal: the open-area ratio of the pupil at a field point (1 without a cat's eye), without building a kernel. */
|
|
127
|
+
export declare function openPupilAreaRatio(options: ResolvedDefocusBokehOptions, fNumber: number, focalLengthMm: number, field: {
|
|
128
|
+
x: number;
|
|
129
|
+
y: number;
|
|
130
|
+
}): number;
|
|
131
|
+
/** The shaped kernel on the site lattice, with what shaped it. */
|
|
132
|
+
export interface DefocusBokehSiteKernel extends DefocusDiscSiteKernel {
|
|
133
|
+
/** Open pupil area / unclipped opening area at this field point (1 without a cat's eye). */
|
|
134
|
+
openAreaRatio: number;
|
|
135
|
+
/** "exact-polygon-cell-area" without spherical aberration, "pupil-ray-binning" with it. */
|
|
136
|
+
method: "exact-polygon-cell-area" | "pupil-ray-binning";
|
|
137
|
+
/** Rays binned (pupil-ray-binning), else 0. */
|
|
138
|
+
raySamples: number;
|
|
139
|
+
/** b = 8·N·W040(N) / pitch, the transverse spherical aberration of the marginal ray in sites (0 without it). */
|
|
140
|
+
sphericalAberrationSites: number;
|
|
141
|
+
}
|
|
142
|
+
export interface DefocusBokehKernelParameters {
|
|
143
|
+
/** Thin-lens blur diameter c in sites (> 0). */
|
|
144
|
+
diameterSites: number;
|
|
145
|
+
/** +1: scene plane behind the focus plane (subject farther than focus); −1: in front of it. */
|
|
146
|
+
side: 1 | -1;
|
|
147
|
+
fNumber: number;
|
|
148
|
+
focalLengthMm: number;
|
|
149
|
+
pitchMicrometers: number;
|
|
150
|
+
options: ResolvedDefocusBokehOptions;
|
|
151
|
+
/** Field tangent tan θ·(cos φ, sin φ) in output-image axes (+X right, +Y up); only the cat's eye reads it. Default on axis. */
|
|
152
|
+
field?: {
|
|
153
|
+
x: number;
|
|
154
|
+
y: number;
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/** Internal: the shaped defocus kernel for one field point (see the module comment). Fails past the radius cap or when the pupil closes. */
|
|
158
|
+
export declare function createDefocusBokehSiteKernel(input: DefocusBokehKernelParameters): DefocusBokehSiteKernel;
|
|
159
|
+
export interface CalculateDefocusBokehKernelInput {
|
|
160
|
+
/** Lens focal length, mm. */
|
|
161
|
+
focalLengthMm: number;
|
|
162
|
+
/** Working f-number. */
|
|
163
|
+
aperture: number;
|
|
164
|
+
/** Focus-plane distance, m. */
|
|
165
|
+
focusDistanceM: number;
|
|
166
|
+
/** Scene-plane distance, m. */
|
|
167
|
+
subjectDistanceM: number;
|
|
168
|
+
/** Sensor site pitch, µm: the lattice the kernel is sampled on. */
|
|
169
|
+
pixelPitchMicrometers: number;
|
|
170
|
+
/** The bokeh options, as `lens.defocus` takes them (blades, sphericalAberration, catsEye). */
|
|
171
|
+
bokeh: DefocusBokehOptions;
|
|
172
|
+
/** Field tangent tan θ·(cos φ, sin φ) in output-image axes (+X right, +Y up); only the cat's eye reads it. Default on axis. */
|
|
173
|
+
fieldTangent?: {
|
|
174
|
+
x: number;
|
|
175
|
+
y: number;
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
export interface DefocusBokehKernel {
|
|
179
|
+
/** "behind-focus" (subject farther than focus), "in-front-of-focus", or "in-focus" (no kernel). */
|
|
180
|
+
side: "behind-focus" | "in-front-of-focus" | "in-focus";
|
|
181
|
+
/** Thin-lens blur diameter c, µm and sites (`calculateDefocusCircle`, thin-lens model 1.1.0). */
|
|
182
|
+
diameterMicrometers: number;
|
|
183
|
+
diameterSites: number;
|
|
184
|
+
/** Kernel radius R and window side 2R + 1 (0 and 1 in focus). */
|
|
185
|
+
kernelRadiusSites: number;
|
|
186
|
+
width: number;
|
|
187
|
+
/** Row-major weights, index (j + R)·width + (i + R) for site offset (i right, j down) from the chief ray; null in focus. */
|
|
188
|
+
weights: Float64Array | null;
|
|
189
|
+
/** Open pupil area / unclipped opening area at the field point (the cat's eye's open fraction; 1 without one). */
|
|
190
|
+
openAreaRatio: number;
|
|
191
|
+
method: "exact-polygon-cell-area" | "pupil-ray-binning" | "none";
|
|
192
|
+
/** Transverse spherical aberration of the marginal ray at the working f-number, sites (8·N·W040(N)/pitch). */
|
|
193
|
+
sphericalAberrationSites: number;
|
|
194
|
+
raySamples: number;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* The defocus kernel of `lens.defocus` with bokeh options (#553), on the site lattice, for one field point: the same kernel
|
|
198
|
+
* `simulateCapture` and the display view apply (on the field zone's centre with a cat's eye). Pure; for tests, previews and
|
|
199
|
+
* lens catalogues. Fails with `InvalidScientificInputError` past {@link DEFOCUS_DISC_KERNEL_MAXIMUM_RADIUS_SITES} (spherical
|
|
200
|
+
* aberration can widen the blur beyond the disc) or when the cat's eye closes the pupil, `InvalidConfigurationError` for
|
|
201
|
+
* unknown fields. A geometric approximation: see the module comment of `defocus-bokeh-kernel.ts` and docs/QUICK_START.md,
|
|
202
|
+
* "Bokeh character".
|
|
203
|
+
*
|
|
204
|
+
* @param input Lens, f-number, distances, pitch, bokeh options and an optional field point.
|
|
205
|
+
* @returns The kernel, its radius, the open-area ratio, the method and the spherical-aberration size in sites.
|
|
206
|
+
*/
|
|
207
|
+
export declare function calculateDefocusBokehKernel(input: CalculateDefocusBokehKernelInput): CalculationResult<DefocusBokehKernel>;
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Bokeh character (#553): the defocus kernel of `lens.defocus` shaped by the lens, instead of the uniform disc of an ideal
|
|
4
|
+
* circular pupil. Three opt-in parts, each off by default (without them `lens.defocus` keeps the exact disc of
|
|
5
|
+
* `createDefocusDiscSiteKernel`, byte for byte):
|
|
6
|
+
*
|
|
7
|
+
* - **Aperture blades** (`blades`): the opening is a regular polygon of `count` straight blades
|
|
8
|
+
* (`calculateIdealApertureGeometry`'s vertex convention, `firstBladeEdgeAngleDegrees`), or with `curvature` κ in (0, 1]
|
|
9
|
+
* rounded blades: each edge an arc of radius 1/κ (circumradius units) through the edge's two vertices, so κ = 1 is the
|
|
10
|
+
* circumscribed circle. At or below `circularAtOrBelowFNumber` the blades are open and the opening is the circle.
|
|
11
|
+
* The opening is scaled to the area of the circle of diameter f/N (equal-area convention): the f-number keeps its
|
|
12
|
+
* meaning for light throughput, which the capture applies elsewhere (π/(4·N_w²)), and the kernel's support has the
|
|
13
|
+
* disc's area.
|
|
14
|
+
* - **Cat's eye** (`catsEye`): mechanical vignetting, the model of `calculateMechanicalVignetting` (Greivenkamp, OPTI-502
|
|
15
|
+
* course notes, University of Arizona, sec. 10, free; also see Kingslake, Lens Design Fundamentals, sec. 6.4). Each
|
|
16
|
+
* clipping aperture (a barrel or element rim referred to the entrance-pupil plane) is a circle of `radiusMm`, shifted by
|
|
17
|
+
* `shiftPerTanFieldMm`·tan θ toward the field point; the open pupil is the opening intersected with every circle. It
|
|
18
|
+
* depends on the field point, so the capture applies it per field zone (see `defocus-image-space-provider.ts`). The light
|
|
19
|
+
* it removes is not applied by the kernel (the kernel has unit sum, a shape only): declare the illumination falloff with
|
|
20
|
+
* `lens.vignetting`, as for any field throughput; `openAreaRatio` reports the open fraction.
|
|
21
|
+
* - **Spherical aberration** (`sphericalAberration`): primary (Seidel) spherical aberration W = W040·ρ⁴ of the working
|
|
22
|
+
* pupil (ρ the pupil radius normalized to the pupil of f/N), given as W040 in µm at a reference f-number N₀ and scaled
|
|
23
|
+
* to the working f-number as W040·(N₀/N)⁴ (the pupil coordinate of a fixed lens shrinks with the stop). Its transverse
|
|
24
|
+
* ray aberration at the image is ε(ρ) = −(R/r_p)·∂W/∂ρ = −8·N·W040·ρ³ for R/r_p = 2N (Wyant & Creath 1992, "Basic
|
|
25
|
+
* Wavefront Aberration Theory for Optical Metrology", Applied Optics and Optical Engineering XI, §II.A and eq. 31, free).
|
|
26
|
+
*
|
|
27
|
+
* The ray map. A geometric (ray-optics) defocused image of a point is the pupil projected onto the sensor: the ray through
|
|
28
|
+
* pupil point P = ρ·p̂ (pupil-radius units) lands at displacement
|
|
29
|
+
* y(P) = (σ·c/2 + b·ρ²)·P, b = 8·N·W040(N)/pitch (sites),
|
|
30
|
+
* from the chief ray, with c the thin-lens blur diameter in sites (`calculateDefocusCircle`, the marginal ray of the
|
|
31
|
+
* aberration-free lens) and σ = +1 for a scene point behind the focus plane, −1 in front of it. Rays from behind the
|
|
32
|
+
* focus plane cross before the sensor, those from in front have not crossed yet, so the two blurs are the same pupil
|
|
33
|
+
* turned through 180° relative to each other, and spherical aberration adds to the defocus on one side and subtracts on
|
|
34
|
+
* the other: with undercorrected spherical aberration (W040 > 0) the blur behind the focus plane is soft-edged and the
|
|
35
|
+
* blur in front bright-rimmed (H. H. Nasse, "Depth of Field and Bokeh", Carl Zeiss Camera Lens Division, 2010, free; the
|
|
36
|
+
* ray density follows from the map: for a circular pupil the image intensity at r = |σc/2·ρ + bρ³| is
|
|
37
|
+
* proportional to 1/(|σc/2 + bρ²|·|σc/2 + 3bρ²|)). The convention of this module, used for `firstBladeEdgeAngleDegrees` and
|
|
38
|
+
* the cat's-eye shift: shapes are declared as the blur of a point behind the focus plane appears in the output image
|
|
39
|
+
* (+X right, +Y up), and the field tangent (tan θ along the field azimuth) is in the same frame.
|
|
40
|
+
*
|
|
41
|
+
* Sampling on the site lattice. As for the disc, a site's weight is the fraction of the blur's light inside that site's
|
|
42
|
+
* square cell [i − ½, i + ½] × [j − ½, j + ½].
|
|
43
|
+
* - Without spherical aberration the blur is uniform over the projected open pupil, a convex polygon (circles and blade
|
|
44
|
+
* arcs are polygons of 1024 sides, scaled to their true area; the boundary then lies within 5e-6 of the radius of the true
|
|
45
|
+
* curve), and each weight is the exact area of the polygon inside the cell (convex clipping and the shoelace formula),
|
|
46
|
+
* divided by the polygon's area: method `"exact-polygon-cell-area"`.
|
|
47
|
+
* - With spherical aberration the intensity is not uniform (and has a caustic when |b| > c/6 on the bright-rimmed side), so
|
|
48
|
+
* the pupil is sampled on a square grid of step h ≤ 1/64, fine enough that neighbouring rays land at most 1/8 site apart,
|
|
49
|
+
* each grid cell's row segment weighted by its exact overlap with the open pupil's chord, and every ray binned into the
|
|
50
|
+
* site cell it lands in: method `"pupil-ray-binning"`, a quadrature with an error of the order of the ray spacing at the
|
|
51
|
+
* cells the blur's edge or caustic crosses.
|
|
52
|
+
* Every step uses correctly rounded operations and the engine's portable trigonometry, so the weights are the same bits in
|
|
53
|
+
* every JavaScript engine, and they are normalized as the disc's are (exact sum 1 within half an ulp of the largest weight,
|
|
54
|
+
* which takes up the rounding instead of the centre: off axis the cat's eye can leave the centre cell empty).
|
|
55
|
+
*
|
|
56
|
+
* What it is: a geometric approximation (no diffraction of the defocused wave, an ideal thin lens with an aberration-free
|
|
57
|
+
* pupil apart from the declared primary spherical aberration, referred to the paraxial focus, and an exit pupil of the same
|
|
58
|
+
* shape and orientation as the entrance pupil). Not modelled: onion rings (aspherical-element texture), higher-order or
|
|
59
|
+
* field-dependent aberrations, colour (the kernel is the same at every wavelength) and the depth-dependent change of the
|
|
60
|
+
* cat's eye with the subject distance.
|
|
61
|
+
*/
|
|
62
|
+
import { calculatedResult } from "../core/calculation-result.js";
|
|
63
|
+
import { InvalidConfigurationError } from "../core/configuration-error.js";
|
|
64
|
+
import { portableAsin, portableCosTwoPi, portableSinTwoPi } from "../core/portable-math.js";
|
|
65
|
+
import { requireAllowlistedRecord, requireInputKeys } from "../core/record-validation.js";
|
|
66
|
+
import { InvalidScientificInputError, requirePositiveFinite } from "../core/validation.js";
|
|
67
|
+
import { DEFOCUS_DISC_KERNEL_MAXIMUM_RADIUS_SITES, defocusDiscLimitMessage, normalizeSiteKernel, resolveDefocusDisc } from "./defocus-disc-kernel.js";
|
|
68
|
+
/** Largest blade count of `lens.defocus.blades` (and of {@link DefocusBokehBlades}). */
|
|
69
|
+
export const DEFOCUS_BOKEH_MAXIMUM_BLADES = 32;
|
|
70
|
+
/** Largest number of field zones across the frame's long side for the cat's eye (`catsEye.fieldZonesAcrossLongSide`). */
|
|
71
|
+
export const DEFOCUS_BOKEH_MAXIMUM_FIELD_ZONES = 32;
|
|
72
|
+
/** Default field zones across the long side. */
|
|
73
|
+
export const DEFOCUS_BOKEH_DEFAULT_FIELD_ZONES = 8;
|
|
74
|
+
/** Sides of the polygon that stands for a circle (and of all blade arcs together). */
|
|
75
|
+
const CURVE_SIDES = 1024;
|
|
76
|
+
/** Largest number of clipping apertures (as `calculateMechanicalVignetting`). */
|
|
77
|
+
const MAXIMUM_CLIPPING_APERTURES = 8;
|
|
78
|
+
function finiteIn(name, value, low, high) {
|
|
79
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < low || value > high)
|
|
80
|
+
throw new InvalidScientificInputError(name + " must be a finite number from " + low + " to " + high + ".");
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
function integerIn(name, value, low, high) {
|
|
84
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < low || value > high)
|
|
85
|
+
throw new InvalidScientificInputError(name + " must be an integer from " + low + " to " + high + ".");
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Validates bokeh options (unknown fields: `InvalidConfigurationError`; out-of-range values: `InvalidScientificInputError`).
|
|
90
|
+
* `label` prefixes the messages (for example `"lens.defocus"`).
|
|
91
|
+
*/
|
|
92
|
+
export function resolveDefocusBokehOptions(value, label) {
|
|
93
|
+
const record = requireAllowlistedRecord(value, ["blades", "sphericalAberration", "catsEye"], label + " bokeh options must be an object.");
|
|
94
|
+
const out = {};
|
|
95
|
+
if (record.blades !== undefined) {
|
|
96
|
+
const b = requireAllowlistedRecord(record.blades, ["count", "firstBladeEdgeAngleDegrees", "curvature", "circularAtOrBelowFNumber"], label + ".blades must be an object.");
|
|
97
|
+
out.blades = {
|
|
98
|
+
count: integerIn(label + ".blades.count", b.count, 3, DEFOCUS_BOKEH_MAXIMUM_BLADES),
|
|
99
|
+
firstBladeEdgeAngleDegrees: b.firstBladeEdgeAngleDegrees === undefined ? 0 : finiteIn(label + ".blades.firstBladeEdgeAngleDegrees", b.firstBladeEdgeAngleDegrees, -360, 360),
|
|
100
|
+
curvature: b.curvature === undefined ? 0 : finiteIn(label + ".blades.curvature", b.curvature, 0, 1),
|
|
101
|
+
circularAtOrBelowFNumber: b.circularAtOrBelowFNumber === undefined ? null : finiteIn(label + ".blades.circularAtOrBelowFNumber", b.circularAtOrBelowFNumber, 0.5, 1024)
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
if (record.sphericalAberration !== undefined) {
|
|
105
|
+
const s = requireAllowlistedRecord(record.sphericalAberration, ["w040Micrometers", "atFNumber"], label + ".sphericalAberration must be an object.");
|
|
106
|
+
out.sphericalAberration = { w040Micrometers: finiteIn(label + ".sphericalAberration.w040Micrometers", s.w040Micrometers, -100, 100),
|
|
107
|
+
atFNumber: finiteIn(label + ".sphericalAberration.atFNumber", s.atFNumber, 0.5, 1024) };
|
|
108
|
+
}
|
|
109
|
+
if (record.catsEye !== undefined) {
|
|
110
|
+
const c = requireAllowlistedRecord(record.catsEye, ["apertures", "fieldZonesAcrossLongSide"], label + ".catsEye must be an object.");
|
|
111
|
+
if (!Array.isArray(c.apertures) || c.apertures.length < 1 || c.apertures.length > MAXIMUM_CLIPPING_APERTURES)
|
|
112
|
+
throw new InvalidScientificInputError(label + ".catsEye.apertures must list 1 to " + MAXIMUM_CLIPPING_APERTURES + " clipping apertures.");
|
|
113
|
+
const apertures = c.apertures.map((a, k) => {
|
|
114
|
+
const r = requireAllowlistedRecord(a, ["radiusMm", "shiftPerTanFieldMm"], label + ".catsEye.apertures[" + k + "] must be an object.");
|
|
115
|
+
return { radiusMm: finiteIn(label + ".catsEye.apertures[" + k + "].radiusMm", r.radiusMm, 1e-6, 1e4),
|
|
116
|
+
shiftPerTanFieldMm: finiteIn(label + ".catsEye.apertures[" + k + "].shiftPerTanFieldMm", r.shiftPerTanFieldMm, -1e4, 1e4) };
|
|
117
|
+
});
|
|
118
|
+
out.catsEye = { apertures, fieldZonesAcrossLongSide: c.fieldZonesAcrossLongSide === undefined ? DEFOCUS_BOKEH_DEFAULT_FIELD_ZONES :
|
|
119
|
+
integerIn(label + ".catsEye.fieldZonesAcrossLongSide", c.fieldZonesAcrossLongSide, 1, DEFOCUS_BOKEH_MAXIMUM_FIELD_ZONES) };
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
function polygonArea(p) {
|
|
124
|
+
let twice = 0;
|
|
125
|
+
for (let k = 0, n = p.length; k < n; k += 2) {
|
|
126
|
+
const k2 = k + 2 === n ? 0 : k + 2;
|
|
127
|
+
twice += p[k] * p[k2 + 1] - p[k2] * p[k + 1];
|
|
128
|
+
}
|
|
129
|
+
return Math.abs(twice) / 2;
|
|
130
|
+
}
|
|
131
|
+
/** Sutherland–Hodgman: the part of `p` where a·x + b·y ≤ c (one half-plane). */
|
|
132
|
+
function clipHalfPlane(p, a, b, c) {
|
|
133
|
+
const out = [], n = p.length;
|
|
134
|
+
if (n === 0)
|
|
135
|
+
return out;
|
|
136
|
+
let px = p[n - 2], py = p[n - 1], pd = a * px + b * py - c;
|
|
137
|
+
for (let k = 0; k < n; k += 2) {
|
|
138
|
+
const x = p[k], y = p[k + 1], d = a * x + b * y - c;
|
|
139
|
+
if (d <= 0) {
|
|
140
|
+
if (pd > 0) {
|
|
141
|
+
const t = pd / (pd - d);
|
|
142
|
+
out.push(px + t * (x - px), py + t * (y - py));
|
|
143
|
+
}
|
|
144
|
+
out.push(x, y);
|
|
145
|
+
}
|
|
146
|
+
else if (pd <= 0) {
|
|
147
|
+
const t = pd / (pd - d);
|
|
148
|
+
out.push(px + t * (x - px), py + t * (y - py));
|
|
149
|
+
}
|
|
150
|
+
px = x;
|
|
151
|
+
py = y;
|
|
152
|
+
pd = d;
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
/** The part of convex `p` inside convex counter-clockwise `clip` (each clip edge a half-plane). */
|
|
157
|
+
function clipConvex(p, clip) {
|
|
158
|
+
let out = p;
|
|
159
|
+
for (let k = 0, n = clip.length; k < n && out.length > 0; k += 2) {
|
|
160
|
+
const k2 = k + 2 === n ? 0 : k + 2, ax = clip[k], ay = clip[k + 1], ex = clip[k2] - ax, ey = clip[k2 + 1] - ay;
|
|
161
|
+
// Inside (left of the edge): ex·(y − ay) − ey·(x − ax) ≥ 0, i.e. ey·x − ex·y ≤ ey·ax − ex·ay.
|
|
162
|
+
out = clipHalfPlane(out, ey, -ex, ey * ax - ex * ay);
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
/** The unit circle as a regular polygon of {@link CURVE_SIDES} sides, scaled to the circle's area π (built once). */
|
|
167
|
+
let unitCircle;
|
|
168
|
+
function unitCirclePolygon() {
|
|
169
|
+
if (unitCircle)
|
|
170
|
+
return unitCircle;
|
|
171
|
+
const p = [];
|
|
172
|
+
for (let k = 0; k < CURVE_SIDES; k++)
|
|
173
|
+
p.push(portableCosTwoPi(k / CURVE_SIDES), portableSinTwoPi(k / CURVE_SIDES));
|
|
174
|
+
unitCircle = scaled(p, Math.sqrt(Math.PI / polygonArea(p)));
|
|
175
|
+
return unitCircle;
|
|
176
|
+
}
|
|
177
|
+
function scaled(p, s) { return p.map((v) => v * s); }
|
|
178
|
+
/**
|
|
179
|
+
* The aperture opening in pupil-radius units (the circle of f/N has radius 1), scaled to area π: a circle, a regular
|
|
180
|
+
* polygon (vertex k at turn ¼·(firstEdge/90 − 1) + (k + ½)/n, `calculateIdealApertureGeometry`'s convention) or rounded blades.
|
|
181
|
+
*/
|
|
182
|
+
function openingPolygon(blades, fNumber) {
|
|
183
|
+
if (!blades || blades.curvature === 1 || (blades.circularAtOrBelowFNumber !== null && fNumber <= blades.circularAtOrBelowFNumber))
|
|
184
|
+
return unitCirclePolygon();
|
|
185
|
+
const n = blades.count, first = blades.firstBladeEdgeAngleDegrees / 360 - 0.25 + 0.5 / n, p = [];
|
|
186
|
+
if (blades.curvature === 0) {
|
|
187
|
+
for (let k = 0; k < n; k++)
|
|
188
|
+
p.push(portableCosTwoPi(first + k / n), portableSinTwoPi(first + k / n));
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
// Edge k runs from vertex k to vertex k + 1; its normal is at turn first + (k + ½)/n. The arc of radius ρ_b = 1/κ through both
|
|
192
|
+
// vertices has its centre on the normal at hc − √(ρ_b² − hs²) (hc = cos(π/n), hs = sin(π/n)) and spans ±α, sin α = hs·κ.
|
|
193
|
+
const rb = 1 / blades.curvature, hs = portableSinTwoPi(0.5 / n), hc = portableCosTwoPi(0.5 / n);
|
|
194
|
+
const centre = hc - Math.sqrt((rb - hs) * (rb + hs)), alphaTurns = portableAsin(Math.min(1, hs * blades.curvature)) / (2 * Math.PI);
|
|
195
|
+
const perEdge = Math.max(2, Math.ceil(CURVE_SIDES / n));
|
|
196
|
+
for (let k = 0; k < n; k++) {
|
|
197
|
+
const normal = first + (k + 0.5) / n, cx = centre * portableCosTwoPi(normal), cy = centre * portableSinTwoPi(normal);
|
|
198
|
+
for (let m = 0; m < perEdge; m++) {
|
|
199
|
+
const u = normal - alphaTurns + 2 * alphaTurns * (m / perEdge);
|
|
200
|
+
p.push(cx + rb * portableCosTwoPi(u), cy + rb * portableSinTwoPi(u));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return scaled(p, Math.sqrt(Math.PI / polygonArea(p)));
|
|
205
|
+
}
|
|
206
|
+
/** The open pupil: the opening clipped by each cat's-eye circle at field tangent t (pupil-radius units). */
|
|
207
|
+
function openPupil(options, fNumber, pupilRadiusMm, field) {
|
|
208
|
+
const opening = openingPolygon(options.blades, fNumber), full = polygonArea(opening);
|
|
209
|
+
let open = opening;
|
|
210
|
+
if (options.catsEye)
|
|
211
|
+
for (const a of options.catsEye.apertures) {
|
|
212
|
+
const r = a.radiusMm / pupilRadiusMm, s = a.shiftPerTanFieldMm / pupilRadiusMm, circle = unitCirclePolygon(), cx = s * field.x, cy = s * field.y;
|
|
213
|
+
const c = [];
|
|
214
|
+
for (let k = 0; k < circle.length; k += 2)
|
|
215
|
+
c.push(cx + r * circle[k], cy + r * circle[k + 1]);
|
|
216
|
+
open = clipConvex(open, c);
|
|
217
|
+
}
|
|
218
|
+
return { polygon: open, openAreaRatio: open === opening ? 1 : polygonArea(open) / full };
|
|
219
|
+
}
|
|
220
|
+
/** Internal: the open-area ratio of the pupil at a field point (1 without a cat's eye), without building a kernel. */
|
|
221
|
+
export function openPupilAreaRatio(options, fNumber, focalLengthMm, field) {
|
|
222
|
+
return openPupil(options, fNumber, focalLengthMm / fNumber / 2, field).openAreaRatio;
|
|
223
|
+
}
|
|
224
|
+
/** Exact area of convex `p` (native site units, +Y down) inside each cell of the (2R + 1)² window. */
|
|
225
|
+
function polygonCellAreas(p, R) {
|
|
226
|
+
const width = 2 * R + 1, out = new Float64Array(width * width);
|
|
227
|
+
for (let j = -R; j <= R; j++) {
|
|
228
|
+
const strip = clipHalfPlane(clipHalfPlane(p, 0, -1, -(j - 0.5)), 0, 1, j + 0.5);
|
|
229
|
+
if (strip.length < 6)
|
|
230
|
+
continue;
|
|
231
|
+
let xmin = Infinity, xmax = -Infinity;
|
|
232
|
+
for (let k = 0; k < strip.length; k += 2) {
|
|
233
|
+
xmin = Math.min(xmin, strip[k]);
|
|
234
|
+
xmax = Math.max(xmax, strip[k]);
|
|
235
|
+
}
|
|
236
|
+
const i0 = Math.max(-R, Math.ceil(xmin - 0.5)), i1 = Math.min(R, Math.floor(xmax + 0.5));
|
|
237
|
+
for (let i = i0; i <= i1; i++) {
|
|
238
|
+
const cell = clipHalfPlane(clipHalfPlane(strip, -1, 0, -(i - 0.5)), 1, 0, i + 0.5);
|
|
239
|
+
if (cell.length >= 6)
|
|
240
|
+
out[(j + R) * width + i + R] = polygonArea(cell);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
245
|
+
/** The index of the largest weight (the first on ties), which takes up the normalization's rounding: the centre may be empty off axis. */
|
|
246
|
+
function largestIndex(weights) {
|
|
247
|
+
let best = 0;
|
|
248
|
+
for (let k = 1; k < weights.length; k++)
|
|
249
|
+
if (weights[k] > weights[best])
|
|
250
|
+
best = k;
|
|
251
|
+
return best;
|
|
252
|
+
}
|
|
253
|
+
function radiusOfExtent(extent) { return extent <= 0.5 ? 0 : Math.ceil(extent - 0.5); }
|
|
254
|
+
/** The kernel radius of a blur reaching `extent` sites from the centre, refused past the cap with the disc's short message (#564). */
|
|
255
|
+
function requireRadius(extent, pitchMicrometers) {
|
|
256
|
+
const radius = radiusOfExtent(extent);
|
|
257
|
+
if (radius > DEFOCUS_DISC_KERNEL_MAXIMUM_RADIUS_SITES)
|
|
258
|
+
throw new InvalidScientificInputError(defocusDiscLimitMessage(2 * extent, 2 * extent * pitchMicrometers));
|
|
259
|
+
return radius;
|
|
260
|
+
}
|
|
261
|
+
/** Internal: the shaped defocus kernel for one field point (see the module comment). Fails past the radius cap or when the pupil closes. */
|
|
262
|
+
export function createDefocusBokehSiteKernel(input) {
|
|
263
|
+
const { diameterSites, side, fNumber, options } = input;
|
|
264
|
+
const a = diameterSites / 2, pupilRadiusMm = input.focalLengthMm / fNumber / 2, field = input.field ?? { x: 0, y: 0 };
|
|
265
|
+
const sa = options.sphericalAberration;
|
|
266
|
+
const q = sa ? sa.atFNumber / fNumber : 0, w040 = sa ? sa.w040Micrometers * ((q * q) * (q * q)) : 0, b = 8 * fNumber * w040 / input.pitchMicrometers;
|
|
267
|
+
const { polygon, openAreaRatio } = openPupil(options, fNumber, pupilRadiusMm, field);
|
|
268
|
+
if (polygon.length < 6 || openAreaRatio <= 0)
|
|
269
|
+
throw new InvalidScientificInputError("lens.defocus: the cat's-eye apertures close the pupil at field tangent (" + field.x + ", " + field.y + ").");
|
|
270
|
+
const s = side * a;
|
|
271
|
+
if (b === 0) {
|
|
272
|
+
// Uniform blur: the open pupil scaled by σ·c/2, +Y up to native +Y down.
|
|
273
|
+
const image = [];
|
|
274
|
+
let extent = 0;
|
|
275
|
+
for (let k = 0; k < polygon.length; k += 2) {
|
|
276
|
+
const x = s * polygon[k], y = -s * polygon[k + 1];
|
|
277
|
+
image.push(x, y);
|
|
278
|
+
extent = Math.max(extent, Math.abs(x), Math.abs(y));
|
|
279
|
+
}
|
|
280
|
+
const radius = requireRadius(extent, input.pitchMicrometers);
|
|
281
|
+
const width = 2 * radius + 1, weights = polygonCellAreas(image, radius);
|
|
282
|
+
normalizeSiteKernel(weights, largestIndex(weights));
|
|
283
|
+
return { diameterSites, radius, width, weights, openAreaRatio, method: "exact-polygon-cell-area", raySamples: 0, sphericalAberrationSites: 0 };
|
|
284
|
+
}
|
|
285
|
+
// Spherical aberration: y(P) = (σc/2 + bρ²)·P, binned. The extent bound: max over ρ ≤ ρmax of |s·ρ + b·ρ³|.
|
|
286
|
+
let rhoMax2 = 0, xmin = Infinity, xmax = -Infinity, ymin = Infinity, ymax = -Infinity;
|
|
287
|
+
for (let k = 0; k < polygon.length; k += 2) {
|
|
288
|
+
const x = polygon[k], y = polygon[k + 1];
|
|
289
|
+
rhoMax2 = Math.max(rhoMax2, x * x + y * y);
|
|
290
|
+
xmin = Math.min(xmin, x);
|
|
291
|
+
xmax = Math.max(xmax, x);
|
|
292
|
+
ymin = Math.min(ymin, y);
|
|
293
|
+
ymax = Math.max(ymax, y);
|
|
294
|
+
}
|
|
295
|
+
const rhoMax = Math.sqrt(rhoMax2);
|
|
296
|
+
let extent = Math.abs(s * rhoMax + b * rhoMax * rhoMax2);
|
|
297
|
+
const stationary2 = -s / (3 * b);
|
|
298
|
+
if (stationary2 > 0 && stationary2 < rhoMax2) {
|
|
299
|
+
const r = Math.sqrt(stationary2);
|
|
300
|
+
extent = Math.max(extent, Math.abs(s * r + b * r * stationary2));
|
|
301
|
+
}
|
|
302
|
+
const radius = requireRadius(extent, input.pitchMicrometers);
|
|
303
|
+
const width = 2 * radius + 1, weights = new Float64Array(width * width);
|
|
304
|
+
// Pupil step: rays at most 1/8 site apart where the map is steepest (|s| + 3|b|ρ²), and at least 64 steps per pupil radius.
|
|
305
|
+
const h = Math.min(1 / 64, 1 / (8 * (Math.abs(s) + 3 * Math.abs(b) * rhoMax2)));
|
|
306
|
+
const rows = Math.ceil((ymax - ymin) / h), columns = Math.ceil((xmax - xmin) / h), n = polygon.length;
|
|
307
|
+
let rays = 0;
|
|
308
|
+
for (let r = 0; r < rows; r++) {
|
|
309
|
+
const y = ymin + (r + 0.5) * h;
|
|
310
|
+
let left = Infinity, right = -Infinity;
|
|
311
|
+
for (let k = 0; k < n; k += 2) {
|
|
312
|
+
const k2 = k + 2 === n ? 0 : k + 2, ay = polygon[k + 1], by = polygon[k2 + 1];
|
|
313
|
+
if ((ay <= y && y < by) || (by <= y && y < ay)) {
|
|
314
|
+
const ax = polygon[k], x = ax + (y - ay) * (polygon[k2] - ax) / (by - ay);
|
|
315
|
+
left = Math.min(left, x);
|
|
316
|
+
right = Math.max(right, x);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (!(left < right))
|
|
320
|
+
continue;
|
|
321
|
+
const m0 = Math.max(0, Math.floor((left - xmin) / h)), m1 = Math.min(columns - 1, Math.floor((right - xmin) / h));
|
|
322
|
+
for (let m = m0; m <= m1; m++) {
|
|
323
|
+
const x0 = xmin + m * h, x = x0 + 0.5 * h, coverage = Math.min(x0 + h, right) - Math.max(x0, left);
|
|
324
|
+
if (!(coverage > 0))
|
|
325
|
+
continue;
|
|
326
|
+
const f = s + b * (x * x + y * y);
|
|
327
|
+
let i = Math.round(f * x), j = Math.round(-f * y);
|
|
328
|
+
if (i > radius)
|
|
329
|
+
i = radius;
|
|
330
|
+
else if (i < -radius)
|
|
331
|
+
i = -radius;
|
|
332
|
+
if (j > radius)
|
|
333
|
+
j = radius;
|
|
334
|
+
else if (j < -radius)
|
|
335
|
+
j = -radius;
|
|
336
|
+
const index = (j + radius) * width + i + radius;
|
|
337
|
+
weights[index] = weights[index] + coverage;
|
|
338
|
+
rays++;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
normalizeSiteKernel(weights, largestIndex(weights));
|
|
342
|
+
return { diameterSites, radius, width, weights, openAreaRatio, method: "pupil-ray-binning", raySamples: rays, sphericalAberrationSites: b };
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* The defocus kernel of `lens.defocus` with bokeh options (#553), on the site lattice, for one field point: the same kernel
|
|
346
|
+
* `simulateCapture` and the display view apply (on the field zone's centre with a cat's eye). Pure; for tests, previews and
|
|
347
|
+
* lens catalogues. Fails with `InvalidScientificInputError` past {@link DEFOCUS_DISC_KERNEL_MAXIMUM_RADIUS_SITES} (spherical
|
|
348
|
+
* aberration can widen the blur beyond the disc) or when the cat's eye closes the pupil, `InvalidConfigurationError` for
|
|
349
|
+
* unknown fields. A geometric approximation: see the module comment of `defocus-bokeh-kernel.ts` and docs/QUICK_START.md,
|
|
350
|
+
* "Bokeh character".
|
|
351
|
+
*
|
|
352
|
+
* @param input Lens, f-number, distances, pitch, bokeh options and an optional field point.
|
|
353
|
+
* @returns The kernel, its radius, the open-area ratio, the method and the spherical-aberration size in sites.
|
|
354
|
+
*/
|
|
355
|
+
export function calculateDefocusBokehKernel(input) {
|
|
356
|
+
requireInputKeys(input, "calculateDefocusBokehKernel", ["focalLengthMm", "aperture", "focusDistanceM", "subjectDistanceM", "pixelPitchMicrometers", "bokeh", "fieldTangent"]);
|
|
357
|
+
requirePositiveFinite("pixelPitchMicrometers", input.pixelPitchMicrometers);
|
|
358
|
+
const options = resolveDefocusBokehOptions(input.bokeh, "calculateDefocusBokehKernel");
|
|
359
|
+
let field = { x: 0, y: 0 };
|
|
360
|
+
if (input.fieldTangent !== undefined) {
|
|
361
|
+
const f = requireAllowlistedRecord(input.fieldTangent, ["x", "y"], "fieldTangent must be { x, y }.");
|
|
362
|
+
if (typeof f.x !== "number" || typeof f.y !== "number" || !Number.isFinite(f.x) || !Number.isFinite(f.y))
|
|
363
|
+
throw new InvalidConfigurationError("fieldTangent x and y must be finite numbers.");
|
|
364
|
+
field = { x: f.x, y: f.y };
|
|
365
|
+
}
|
|
366
|
+
const disc = resolveDefocusDisc({ focalLengthMm: input.focalLengthMm, aperture: input.aperture, focusDistanceM: input.focusDistanceM,
|
|
367
|
+
subjectDistanceM: input.subjectDistanceM, pitchMicrometers: input.pixelPitchMicrometers });
|
|
368
|
+
const assumptions = ["Geometric defocus kernel shaped by the lens (#553), an approximation: thin-lens blur circle, projected open pupil, primary spherical aberration by its ray map",
|
|
369
|
+
"No diffraction, onion rings, higher-order or field-dependent aberrations, or colour"];
|
|
370
|
+
if (disc.diameterSites === 0)
|
|
371
|
+
return calculatedResult({ side: "in-focus", diameterMicrometers: 0, diameterSites: 0, kernelRadiusSites: 0, width: 1, weights: null,
|
|
372
|
+
openAreaRatio: 1, method: "none", sphericalAberrationSites: 0, raySamples: 0 }, "defocus-bokeh-kernel", "1.0.0", assumptions);
|
|
373
|
+
const side = input.subjectDistanceM > input.focusDistanceM ? 1 : -1;
|
|
374
|
+
const kernel = createDefocusBokehSiteKernel({ diameterSites: disc.diameterSites, side, fNumber: input.aperture, focalLengthMm: input.focalLengthMm,
|
|
375
|
+
pitchMicrometers: input.pixelPitchMicrometers, options, field });
|
|
376
|
+
return calculatedResult({ side: side === 1 ? "behind-focus" : "in-front-of-focus", diameterMicrometers: disc.diameterMicrometers, diameterSites: disc.diameterSites,
|
|
377
|
+
kernelRadiusSites: kernel.radius, width: kernel.width, weights: kernel.weights, openAreaRatio: kernel.openAreaRatio, method: kernel.method,
|
|
378
|
+
sphericalAberrationSites: kernel.sphericalAberrationSites, raySamples: kernel.raySamples }, "defocus-bokeh-kernel", "1.0.0", assumptions);
|
|
379
|
+
}
|
|
380
|
+
//# sourceMappingURL=defocus-bokeh-kernel.js.map
|