@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.
- package/CHANGELOG.md +104 -1
- package/CITATION.cff +1 -1
- package/README.md +6 -6
- package/SECURITY.md +1 -1
- package/THIRD_PARTY.md +24 -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/camera/sensor-equivalence.d.ts +156 -0
- package/dist/camera/sensor-equivalence.js +208 -0
- package/dist/camera/sensor-equivalence.js.map +1 -0
- package/dist/capture/native-environment-tabulated.d.ts +5 -0
- package/dist/capture/native-environment-tabulated.js +1 -1
- package/dist/capture/native-environment-tabulated.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/equipment/ideal-lens.d.ts +25 -0
- package/dist/equipment/ideal-lens.js +21 -0
- package/dist/equipment/ideal-lens.js.map +1 -0
- package/dist/facade/catalogue-lens-signal.d.ts +94 -0
- package/dist/facade/catalogue-lens-signal.js +362 -0
- package/dist/facade/catalogue-lens-signal.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 +9 -0
- package/dist/facade/defocus-image-space-provider.js +101 -9
- package/dist/facade/defocus-image-space-provider.js.map +1 -1
- package/dist/facade/diffraction-provider.d.ts +29 -0
- package/dist/facade/diffraction-provider.js +117 -11
- 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 +61 -0
- package/dist/facade/display-view-lens-signal.js +502 -0
- package/dist/facade/display-view-lens-signal.js.map +1 -0
- package/dist/facade/expected-signal-setup.d.ts +15 -0
- package/dist/facade/expected-signal-setup.js +19 -2
- package/dist/facade/expected-signal-setup.js.map +1 -1
- package/dist/facade/expected-signal.d.ts +13 -0
- package/dist/facade/expected-signal.js +155 -1
- package/dist/facade/expected-signal.js.map +1 -1
- package/dist/facade/generic-lens-capture.d.ts +7 -0
- package/dist/facade/generic-lens-capture.js +9 -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 +67 -7
- package/dist/facade/simulate-capture.js +400 -64
- package/dist/facade/simulate-capture.js.map +1 -1
- package/dist/facade/simulate-display-view.js +23 -17
- package/dist/facade/simulate-display-view.js.map +1 -1
- package/dist/gpu/display-view-quality-gpu.d.ts +0 -9
- package/dist/gpu/display-view-quality-gpu.js +6 -0
- package/dist/gpu/display-view-quality-gpu.js.map +1 -1
- package/dist/gpu/display-view-speed-gpu.js +2 -0
- package/dist/gpu/display-view-speed-gpu.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.js +5 -0
- 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 +41 -19
- package/dist/optics/defocus-disc-kernel.js +44 -23
- 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 +6 -0
- package/dist/optics/sensor-environment-query.js +13 -6
- 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/docs/AGENT_GUIDE.md +4 -0
- package/docs/API_REFERENCE.md +77 -1
- package/docs/QUICK_START.md +305 -17
- package/docs/USAGE.md +98 -5
- package/docs/api/camera-sensor-equivalence.md +209 -0
- package/docs/api/core-version.md +1 -1
- package/docs/api/equipment-generic-lens-catalogue.md +281 -0
- package/docs/api/equipment-ideal-lens.md +26 -0
- package/docs/api/exports.json +202 -2
- package/docs/api/facade-simulate-capture.md +48 -4
- package/docs/api/optics-defocus-bokeh-kernel.md +145 -0
- package/docs/api/optics-defocus-disc-kernel.md +22 -15
- package/docs/api/optics-sensor-environment-query.md +6 -0
- package/docs/api/output-generic-lens-corrections.md +39 -0
- package/llms.txt +3 -0
- package/package.json +1 -1
|
@@ -75,10 +75,14 @@ import { calculateCaptureExposureWindows } from "../sensor/exposure-window.js";
|
|
|
75
75
|
import { calculateSensorSpectralQuadrature } from "../sensor/spectral-quadrature.js";
|
|
76
76
|
import { createDiffractionLimitedPsfProfile } from "../optics/diffraction-psf-profile.js";
|
|
77
77
|
import { registerDiffractionPsfKey, siteDiffractionKernel } from "../optics/site-diffraction-kernel.js";
|
|
78
|
-
import { convolveSiteKernels, createDefocusDiscSiteKernel, requireDefocusDiscKernelRadius, resolveDefocusDisc } from "../optics/defocus-disc-kernel.js";
|
|
78
|
+
import { convolveSiteKernels, createDefocusDiscSiteKernel, defocusDiscKernelRadius, defocusDiscLimitMessage, requireDefocusDiscKernelRadius, resolveDefocusDisc } from "../optics/defocus-disc-kernel.js";
|
|
79
79
|
import { calculateDefocusFieldVariation, createDefocusImageSpaceProvider, prepareDefocusImageSpaceGeometry } from "./defocus-image-space-provider.js";
|
|
80
80
|
import { createDiffractedPlanarProvider } from "./diffraction-provider.js";
|
|
81
|
+
import { DEFOCUS_DIRECT_KERNEL_MAXIMUM_RADIUS_SITES, siteKernelRuns } from "../optics/site-kernel-runs.js";
|
|
82
|
+
import { createDefocusBokehSiteKernel, resolveDefocusBokehOptions } from "../optics/defocus-bokeh-kernel.js";
|
|
83
|
+
import { planDefocusBokehZones } from "./defocus-bokeh-zones.js";
|
|
81
84
|
import { resolveGenericPrimeLens } from "../equipment/generic-prime-lenses.js";
|
|
85
|
+
import { IDEAL_LENS_LIMITS } from "../equipment/ideal-lens.js";
|
|
82
86
|
import { calculateChannelElectronsPerRadiance } from "./channel-response.js";
|
|
83
87
|
import { calculateSiteEffectiveSaturationCode } from "../capture/raw-white-level.js";
|
|
84
88
|
import { prepareSensorColorDevelopment } from "../color/sensor-color-development.js";
|
|
@@ -101,7 +105,7 @@ export const SIMULATE_CAPTURE_BOX_APERTURE_DEFAULT_SAMPLES = 4;
|
|
|
101
105
|
* a capture does. `motion.rotation` is a non-zero angular velocity; `sensor.temporalSampleCount` a count above 1.
|
|
102
106
|
*/
|
|
103
107
|
export const SIMULATE_CAPTURE_COST_OPTIONS = /* @__PURE__ */ Object.freeze(["lens.pupilSampling", "lens.projection", "lens.distortion", "lens.vignetting", "lens.psf",
|
|
104
|
-
"lens.diffraction", "lens.defocus", "sensor.antiAliasing", "sensor.pixelAperture.box", "sensor.temporalSampleCount", "motion.rotation", "motion.shutter.rolling", "output.borderHandling.mirror", "output.region",
|
|
108
|
+
"lens.diffraction", "lens.defocus", "lens.catalogue", "sensor.antiAliasing", "sensor.pixelAperture.box", "sensor.temporalSampleCount", "motion.rotation", "motion.shutter.rolling", "output.borderHandling.mirror", "output.region",
|
|
105
109
|
"output.whiteBalance.auto", "output.demosaic.ahd", "output.highlightMode.reconstruct", "output.colorEncoding.wide-gamut", "output.gamutMapping.perceptual",
|
|
106
110
|
"output.diffractionCorrection", "output.sharpening", "output.format.jpeg", "output.format.png", "output.format.dng"]);
|
|
107
111
|
/**
|
|
@@ -109,7 +113,7 @@ export const SIMULATE_CAPTURE_COST_OPTIONS = /* @__PURE__ */ Object.freeze(["len
|
|
|
109
113
|
* the record form with `fast` and is reported in `diagnostics.fast.applied` when it runs. Every one of them takes the flag,
|
|
110
114
|
* whether or not it has a fast method yet; one without runs exact and reports `false`.
|
|
111
115
|
*/
|
|
112
|
-
export const SIMULATE_CAPTURE_FAST_OPTIONS = /* @__PURE__ */ Object.freeze(["lens.diffraction", "sensor.antiAliasing", "sensor.pixelAperture.box", "output.demosaic.ahd"]);
|
|
116
|
+
export const SIMULATE_CAPTURE_FAST_OPTIONS = /* @__PURE__ */ Object.freeze(["lens.diffraction", "lens.catalogue", "sensor.antiAliasing", "sensor.pixelAperture.box", "output.demosaic.ahd"]);
|
|
113
117
|
export const FACADE_EVIDENCE = /* @__PURE__ */ Object.freeze([/* @__PURE__ */ Object.freeze({ sourceOrigin: "photivra",
|
|
114
118
|
sourceReference: "photivra:simulate-capture-facade", reuseStatus: "photivra-owned" })]);
|
|
115
119
|
const DEFAULTS = /* @__PURE__ */ Object.freeze({ camera: "professional", fNumber: 8, shutterSeconds: 1 / 125, iso: 100, compensationStops: 0,
|
|
@@ -834,20 +838,38 @@ export function parseDisplayViewOption(value, frameWidth, frameHeight) {
|
|
|
834
838
|
function resolveDefocusOption(input) {
|
|
835
839
|
const { run, assume, focalLengthMm, fNumber, focusDistanceM, subjectDistanceM, pitch } = input;
|
|
836
840
|
const disc = resolveDefocusDisc({ focalLengthMm, aperture: fNumber, focusDistanceM, subjectDistanceM, pitchMicrometers: pitch });
|
|
837
|
-
const size = disc.diameterMicrometers + " µm (" + disc.diameterSites + " sites of " + pitch + " µm)";
|
|
838
841
|
let radius;
|
|
839
842
|
try {
|
|
840
843
|
radius = requireDefocusDiscKernelRadius(disc.diameterSites);
|
|
841
844
|
}
|
|
842
|
-
catch
|
|
843
|
-
fail("lens.defocus:
|
|
845
|
+
catch {
|
|
846
|
+
fail("lens.defocus: " + defocusDiscLimitMessage(disc.diameterSites, disc.diameterMicrometers));
|
|
844
847
|
}
|
|
845
|
-
|
|
848
|
+
let value = { model: "geometric-disc", scientificStatus: "approximation", diameterMicrometers: disc.diameterMicrometers, diameterSites: disc.diameterSites,
|
|
846
849
|
kernelRadiusSites: disc.diameterSites === 0 ? null : radius, fNumber, focusDistanceM, subjectDistanceM, samplePitchMicrometers: pitch,
|
|
847
850
|
defocusCircleModel: { id: "thin-lens-defocus-circle", version: "1.1.0" } };
|
|
848
|
-
|
|
851
|
+
let model = "Geometric defocus of the flat scene plane (an approximation): the thin-lens blur circle of an ideal uniform circular pupil, " +
|
|
849
852
|
"c = f²·|s − d| / (N·d·(s − f)) (calculateDefocusCircle 1.1.0), as a uniform disc sampled on the site lattice (exact disc area in each site's cell). " +
|
|
850
853
|
"No diffraction of the defocused wave, aberrations or pupil vignetting. ";
|
|
854
|
+
// #553: the bokeh options. Out of focus, where a kernel is applied, the shaped kernel replaces the disc and its record joins the value.
|
|
855
|
+
let kernelRadius = radius;
|
|
856
|
+
if (input.bokehOptions) {
|
|
857
|
+
value = { ...value, bokeh: { options: input.bokehOptions } };
|
|
858
|
+
if (disc.diameterSites > 0 && run !== "ideal-reference" && input.bokeh) {
|
|
859
|
+
const shaped = input.bokeh(disc);
|
|
860
|
+
value = { ...value, bokeh: shaped.value };
|
|
861
|
+
model += shaped.explanation;
|
|
862
|
+
kernelRadius = shaped.value.kernelRadiusSites;
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
// #564: above the direct radius the kernel (the disc, or the shaped kernel of #553) is convolved by runs; the field is absent below it,
|
|
866
|
+
// so those records are unchanged.
|
|
867
|
+
if (kernelRadius > DEFOCUS_DIRECT_KERNEL_MAXIMUM_RADIUS_SITES) {
|
|
868
|
+
value = { ...value, convolution: "runs" };
|
|
869
|
+
model += "The kernel radius is above 64 sites, so the kernel is convolved by runs (#564): each kernel row's run of equal weights is a difference of two prefix sums, " +
|
|
870
|
+
"the other cells (the edge) are read one by one, and with lens.diffraction each node's diffraction kernel is applied after the defocus kernel. " +
|
|
871
|
+
"That is the same sum as the direct convolution, regrouped (exact up to rounding, not an approximation). ";
|
|
872
|
+
}
|
|
851
873
|
if (disc.diameterSites === 0) {
|
|
852
874
|
assume("lens.defocus", "model", value, model + "In focus (lens.focusDistanceM equals subjectDistanceM): the disc has zero diameter, so no defocus kernel is applied.");
|
|
853
875
|
return undefined;
|
|
@@ -856,8 +878,9 @@ function resolveDefocusOption(input) {
|
|
|
856
878
|
assume("lens.defocus", "model", value, model + "Resolved but not applied: the ideal reference has no camera effects.");
|
|
857
879
|
return undefined;
|
|
858
880
|
}
|
|
859
|
-
// The display view (#492, step 2b): a site kernel in its expected-signal tables (simulate-display-view.ts)
|
|
860
|
-
|
|
881
|
+
// The display view (#492, step 2b): a site kernel in its expected-signal tables (simulate-display-view.ts); with a lens field mapping,
|
|
882
|
+
// vignetting or rotation (#563) applied in image space as in the capture, so it records the capture's assumption below.
|
|
883
|
+
if (run === "display-view" && !input.imageSpace) {
|
|
861
884
|
assume("lens.defocus", "model", { ...value, execution: "expected-signal-site-kernel", combinedWithDiffraction: input.diffraction }, model +
|
|
862
885
|
"Applied in the display view's expected-signal tables as a site kernel on the lattice" + (input.diffraction ? " (with lens.diffraction, each spectral node's " +
|
|
863
886
|
"diffraction kernel convolved with the disc, a further approximation)" : "") + "; see the view.defocus assumption.");
|
|
@@ -936,37 +959,96 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
936
959
|
preset = parseCallerCameraPreset(cameraOption);
|
|
937
960
|
const body = preset.body, lens = preset.lens;
|
|
938
961
|
// Lens
|
|
939
|
-
const lensOptions = allowKeys(top.lens, "lens", ["focalLengthMm", "fNumber", "focusDistanceM", "pupilSampling", "projection", "distortion", "vignetting", "psf", "diffraction", "prime", "defocus"
|
|
962
|
+
const lensOptions = allowKeys(top.lens, "lens", ["focalLengthMm", "fNumber", "focusDistanceM", "pupilSampling", "projection", "distortion", "vignetting", "psf", "diffraction", "prime", "defocus",
|
|
963
|
+
"catalogue", "focusScatter", "ideal"]);
|
|
940
964
|
// Generic prime (2.3): resolved on the simulated frame's size, which the output and sensor sections validate again below.
|
|
941
965
|
let prime;
|
|
966
|
+
// The simulated frame in mm (output width × height × pixel pitch), validated again below.
|
|
967
|
+
const frameMm = () => {
|
|
968
|
+
const o = (typeof top.output === "object" && top.output !== null ? top.output : {}), sn = (typeof top.sensor === "object" && top.sensor !== null ? top.sensor : {});
|
|
969
|
+
const frameW = positive(o.width ?? DEFAULTS.width, "output.width"), frameH = positive(o.height ?? DEFAULTS.height, "output.height"), framePitch = positive(sn.pixelPitchMicrometers ?? DEFAULTS.pixelPitchMicrometers, "sensor.pixelPitchMicrometers");
|
|
970
|
+
return { widthMm: frameW * framePitch / 1000, heightMm: frameH * framePitch / 1000 };
|
|
971
|
+
};
|
|
942
972
|
if (lensOptions.prime !== undefined) {
|
|
943
973
|
if (lensOptions.focalLengthMm !== undefined)
|
|
944
974
|
fail("lens.prime and lens.focalLengthMm cannot both be given: the prime sets the focal length.");
|
|
945
|
-
const
|
|
946
|
-
const frameW = positive(o.width ?? DEFAULTS.width, "output.width"), frameH = positive(o.height ?? DEFAULTS.height, "output.height"), framePitch = positive(sn.pixelPitchMicrometers ?? DEFAULTS.pixelPitchMicrometers, "sensor.pixelPitchMicrometers");
|
|
975
|
+
const activeImagingArea = frameMm();
|
|
947
976
|
if (typeof lensOptions.prime !== "number")
|
|
948
977
|
fail("lens.prime must be a generic prime's full-frame-equivalent focal length in mm.");
|
|
949
|
-
prime = resolveGenericPrimeLens({ equivalentFocalLengthMm: lensOptions.prime, activeImagingArea
|
|
978
|
+
prime = resolveGenericPrimeLens({ equivalentFocalLengthMm: lensOptions.prime, activeImagingArea });
|
|
950
979
|
assume("lens.prime", "model", { lensId: prime.lensId, equivalentFocalLengthMm: prime.equivalentFocalLengthMm, focalLengthMm: prime.focalLengthMm, cropFactor35Mm: prime.cropFactor35Mm,
|
|
951
980
|
widestFNumber: prime.widestFNumber, narrowestFNumber: prime.narrowestFNumber, minimumFocusDistanceM: prime.minimumFocusDistanceM }, "Generic prime (not a real product): focal length = equivalent ÷ the simulated frame's diagonal crop factor, f/" + prime.widestFNumber + "–f/" + prime.narrowestFNumber +
|
|
952
981
|
", minimum focus max(0.10 m, f·(1 + 1/0.15)) (thin-lens approximation). It replaces the tier lens's focal length and f-number range; the tier lens's transmission is kept.");
|
|
953
982
|
}
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
983
|
+
// Ideal lens (#576): a physical focal length on any frame, every f-number within IDEAL_LENS_LIMITS.
|
|
984
|
+
let ideal;
|
|
985
|
+
if (lensOptions.ideal !== undefined) {
|
|
986
|
+
for (const key of ["prime", "catalogue", "focalLengthMm"])
|
|
987
|
+
if (lensOptions[key] !== undefined)
|
|
988
|
+
fail("lens.ideal cannot be combined with lens." + key + ".");
|
|
989
|
+
const io = allowKeys(lensOptions.ideal, "lens.ideal", ["focalLengthMm", "widestFNumber", "narrowestFNumber"]), f = io.focalLengthMm, { focalLengthMm: b, fNumber: n } = IDEAL_LENS_LIMITS;
|
|
990
|
+
if (!(typeof f === "number" && f >= b.minimum && f <= b.maximum))
|
|
991
|
+
fail("lens.ideal.focalLengthMm must be " + b.minimum + "–" + b.maximum + " mm (IDEAL_LENS_LIMITS).");
|
|
992
|
+
// #576 (owner, 2026-11-09): an optional f-number range within IDEAL_LENS_LIMITS, for the automatic exposure modes; default the whole range.
|
|
993
|
+
const ranged = io.widestFNumber !== undefined || io.narrowestFNumber !== undefined, lo = io.widestFNumber === undefined ? n.minimum : io.widestFNumber, hi = io.narrowestFNumber === undefined ? n.maximum : io.narrowestFNumber;
|
|
994
|
+
if (!(typeof lo === "number" && typeof hi === "number" && lo >= n.minimum && hi <= n.maximum && lo <= hi))
|
|
995
|
+
fail("lens.ideal.widestFNumber and narrowestFNumber must be f-numbers in f/" + n.minimum + "–f/" + n.maximum + " (IDEAL_LENS_LIMITS), the widest no larger than the narrowest.");
|
|
996
|
+
const lensId = "ideal-" + f + "mm";
|
|
997
|
+
ideal = { id: lensId, name: lensId, focalLengthMm: f, widestFNumber: lo, narrowestFNumber: hi };
|
|
998
|
+
assume("lens.ideal", "model", { lensId, focalLengthMm: f, ...(ranged ? { widestFNumber: lo, narrowestFNumber: hi } : {}) }, "Ideal thin lens without aberrations (not a real product) at this physical focal length" +
|
|
999
|
+
(ranged ? ", f/" + lo + "–f/" + hi : "") + "; the tier lens's transmission is kept.");
|
|
1000
|
+
}
|
|
1001
|
+
// Generic lens catalogue (#549): resolved on the simulated frame like the prime; its character is built once the f-number is known.
|
|
1002
|
+
let catalogue, catalogueFrame;
|
|
1003
|
+
let lensKit;
|
|
1004
|
+
// #568: the shorthand id or the record { kind: <id>, fast }.
|
|
1005
|
+
let catalogueId, catalogueFast;
|
|
1006
|
+
if (lensOptions.catalogue !== undefined && typeof lensOptions.catalogue === "object" && lensOptions.catalogue !== null) {
|
|
1007
|
+
const record = allowKeys(lensOptions.catalogue, "lens.catalogue", ["kind", "fast"]);
|
|
1008
|
+
if (typeof record.kind !== "string")
|
|
1009
|
+
fail("lens.catalogue.kind must be a catalogue lens id (GENERIC_LENS_IDS).");
|
|
1010
|
+
catalogueId = record.kind;
|
|
1011
|
+
catalogueFast = fastOverride(record, "lens.catalogue");
|
|
1012
|
+
}
|
|
1013
|
+
else if (lensOptions.catalogue !== undefined)
|
|
1014
|
+
catalogueId = lensOptions.catalogue;
|
|
1015
|
+
if (lensOptions.catalogue !== undefined) {
|
|
1016
|
+
for (const key of ["prime", "focalLengthMm", "projection", "distortion", "vignetting", "psf", "diffraction", "pupilSampling"])
|
|
1017
|
+
if (lensOptions[key] !== undefined)
|
|
1018
|
+
fail("lens.catalogue cannot be combined with lens." + key + ": the catalogue lens sets its own focal length, projection, distortion, vignetting and PSF (with diffraction).");
|
|
1019
|
+
catalogueFrame = frameMm();
|
|
1020
|
+
lensKit = await import("./generic-lens-capture.js");
|
|
1021
|
+
catalogue = lensKit.resolveGenericLens({ lensId: catalogueId, activeImagingArea: catalogueFrame });
|
|
1022
|
+
assume("lens.catalogue", "model", { lensId: catalogue.lensId, name: catalogue.name, set: catalogue.set, kind: catalogue.kind, equivalentFocalLengthMm: catalogue.equivalentFocalLengthMm,
|
|
1023
|
+
focalLengthMm: catalogue.focalLengthMm, cropFactor35Mm: catalogue.cropFactor35Mm, widestFNumber: catalogue.widestFNumber, narrowestFNumber: catalogue.narrowestFNumber,
|
|
1024
|
+
minimumFocusDistanceM: catalogue.minimumFocusDistanceM, maximumMagnification: catalogue.maximumMagnification, projection: catalogue.projection, fisheyeImage: catalogue.fisheyeImage }, "Generic catalogue lens " + catalogue.name + " (#549; Photivra's own design, not a real product): focal length = equivalent ÷ the simulated frame's diagonal crop factor, f/" +
|
|
1025
|
+
catalogue.widestFNumber + "–f/" + catalogue.narrowestFNumber + ", minimum focus max(0.10 m, f·(1 + 1/" + catalogue.maximumMagnification + ")) (thin lens). Its character is a labelled " +
|
|
1026
|
+
"approximation (the lens.catalogue.character assumption). It replaces the tier lens's focal length and f-number range; the tier lens's transmission is kept.");
|
|
1027
|
+
}
|
|
1028
|
+
else if (lensOptions.focusScatter !== undefined)
|
|
1029
|
+
fail("lens.focusScatter needs lens.catalogue: the focus scatter is a catalogue lens's own.");
|
|
1030
|
+
const lensRange = prime ? { id: prime.lensId, name: prime.lensId, focalLengthMm: prime.focalLengthMm, widestFNumber: prime.widestFNumber, narrowestFNumber: prime.narrowestFNumber } :
|
|
1031
|
+
catalogue ? { id: catalogue.lensId, name: catalogue.name, focalLengthMm: catalogue.focalLengthMm, widestFNumber: catalogue.widestFNumber, narrowestFNumber: catalogue.narrowestFNumber } : ideal;
|
|
1032
|
+
const lensExposure = lensRange ? { ...lens.exposure, profileId: "photivra-" + lensRange.id + ":exposure",
|
|
1033
|
+
focalLengthMmRange: { ...lens.exposure.focalLengthMmRange, value: { minimum: lensRange.focalLengthMm, maximum: lensRange.focalLengthMm } },
|
|
1034
|
+
aperture: { ...lens.exposure.aperture, widestAvailableFNumber: { kind: "constant", fNumber: { value: lensRange.widestFNumber, evidence: FACADE_EVIDENCE } },
|
|
1035
|
+
narrowestAvailableFNumber: { value: lensRange.narrowestFNumber, evidence: FACADE_EVIDENCE } } } : lens.exposure;
|
|
958
1036
|
const focalDefault = lensExposure.focalLengthMmRange.value.minimum;
|
|
959
|
-
const focalLengthMm =
|
|
1037
|
+
const focalLengthMm = lensRange ? lensRange.focalLengthMm :
|
|
960
1038
|
positive(pick(lensOptions, "focalLengthMm", "lens.focalLengthMm", focalDefault, "The preset lens's focal length (the generic primes are 50 mm)."), "lens.focalLengthMm");
|
|
961
1039
|
const capabilities = resolveGenericEquipmentExposureCapabilities({ bodyProfile: body.exposure, lensProfile: lensExposure, selectedFocalLengthMm: focalLengthMm });
|
|
962
1040
|
const subjectDistanceM = positive(pick(top, "subjectDistanceM", "subjectDistanceM", DEFAULTS.subjectDistanceM, "Scene plane 5 m in front of the camera (the generic tiers' reference focus distance)."), "subjectDistanceM");
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
1041
|
+
// #569: twice the focal length itself is admitted (magnification 1, the catalogue Macro's close focus f·(1 + 1/1)).
|
|
1042
|
+
if (subjectDistanceM * 1000 < focalLengthMm * 2)
|
|
1043
|
+
fail("subjectDistanceM must be at least twice the focal length.");
|
|
1044
|
+
let focusDistanceM = positive(pick(lensOptions, "focusDistanceM", "lens.focusDistanceM", subjectDistanceM, "Focused on the scene plane."), "lens.focusDistanceM");
|
|
966
1045
|
if (focusDistanceM * 1000 <= focalLengthMm)
|
|
967
1046
|
fail("lens.focusDistanceM must exceed the focal length.");
|
|
968
1047
|
if (prime && (subjectDistanceM < prime.minimumFocusDistanceM || focusDistanceM < prime.minimumFocusDistanceM))
|
|
969
1048
|
fail("The " + prime.lensId + " focuses no closer than " + prime.minimumFocusDistanceM + " m (max(0.10 m, f·(1 + 1/0.15))); subjectDistanceM and lens.focusDistanceM must be at least that.");
|
|
1049
|
+
if (catalogue && (subjectDistanceM < catalogue.minimumFocusDistanceM || focusDistanceM < catalogue.minimumFocusDistanceM))
|
|
1050
|
+
fail("The " + catalogue.name + " focuses no closer than " + catalogue.minimumFocusDistanceM + " m (max(0.10 m, f·(1 + 1/" + catalogue.maximumMagnification +
|
|
1051
|
+
"))); subjectDistanceM and lens.focusDistanceM must be at least that.");
|
|
970
1052
|
let pupil;
|
|
971
1053
|
if (lensOptions.pupilSampling === undefined)
|
|
972
1054
|
assume("lens.pupilSampling", "default", null, lensOptions.defocus === undefined ?
|
|
@@ -980,7 +1062,8 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
980
1062
|
fail("lens.pupilSampling needs at most 256 rays (radialSamples × angularSamples).");
|
|
981
1063
|
}
|
|
982
1064
|
// 2.0 optics hooks: only what the environment site contract accepts.
|
|
983
|
-
const projection = lensOptions.projection === undefined ?
|
|
1065
|
+
const projection = lensOptions.projection === undefined ? (catalogue && catalogue.projection !== "rectilinear" ? catalogue.projection : undefined) :
|
|
1066
|
+
parseLensProjectionModel(lensOptions.projection);
|
|
984
1067
|
let distortion;
|
|
985
1068
|
if (lensOptions.distortion !== undefined) {
|
|
986
1069
|
const d = allowKeys(lensOptions.distortion, "lens.distortion", ["kind", "profile"]);
|
|
@@ -988,7 +1071,11 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
988
1071
|
fail("lens.distortion.kind must be radial or brown-conrady.");
|
|
989
1072
|
distortion = d;
|
|
990
1073
|
}
|
|
991
|
-
|
|
1074
|
+
// #549: a catalogue lens's distortion (and the fisheyes' projection) is set once its field reach is known (below); the mapping
|
|
1075
|
+
// stands in for it in the combination checks until then.
|
|
1076
|
+
if (catalogue)
|
|
1077
|
+
distortion = { kind: "radial", profile: lensKit.genericLensDistortionProfile(catalogue.character.distortion.corner, catalogue.halfDiagonalMm) };
|
|
1078
|
+
let lensFieldMapping = projection === undefined && distortion === undefined ? undefined :
|
|
992
1079
|
{ projectionModel: projection ?? "rectilinear", distortion: distortion ?? { kind: "none" }, radiometricAreaCorrection: "inverse-distortion-jacobian" };
|
|
993
1080
|
if (lensFieldMapping) {
|
|
994
1081
|
if (pupil)
|
|
@@ -1038,16 +1125,36 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1038
1125
|
fail("lens.diffraction cannot be combined with lens.pupilSampling: the environment contract does not mix a sampled PSF with pupil rays.");
|
|
1039
1126
|
}
|
|
1040
1127
|
// Defocus (#492): validated here, resolved once the f-number is.
|
|
1041
|
-
let defocusModel;
|
|
1128
|
+
let defocusModel, defocusBokeh;
|
|
1042
1129
|
if (lensOptions.defocus !== undefined) {
|
|
1043
|
-
const record = allowKeys(lensOptions.defocus, "lens.defocus", ["model"]);
|
|
1130
|
+
const record = allowKeys(lensOptions.defocus, "lens.defocus", ["model", "blades", "sphericalAberration", "catsEye"]);
|
|
1044
1131
|
if (record.model !== "geometric-disc")
|
|
1045
1132
|
fail("lens.defocus.model must be \"geometric-disc\".");
|
|
1133
|
+
// #553: the bokeh options; none given keeps the disc of #492 (byte-identical requests).
|
|
1134
|
+
if (record.blades !== undefined || record.sphericalAberration !== undefined || record.catsEye !== undefined) {
|
|
1135
|
+
const bokeh = { ...record };
|
|
1136
|
+
delete bokeh.model;
|
|
1137
|
+
try {
|
|
1138
|
+
defocusBokeh = resolveDefocusBokehOptions(bokeh, "lens.defocus");
|
|
1139
|
+
}
|
|
1140
|
+
catch (error) {
|
|
1141
|
+
fail(error.message);
|
|
1142
|
+
}
|
|
1143
|
+
if (defocusBokeh.catsEye && diffractionRadiusOption !== undefined)
|
|
1144
|
+
fail("lens.defocus.catsEye cannot be combined with lens.diffraction: the cat's eye varies over the field, " +
|
|
1145
|
+
"so each field zone would need its own diffraction kernel convolved with its clipped pupil at every spectral node. Omit one of the options.");
|
|
1146
|
+
}
|
|
1046
1147
|
if (pupil)
|
|
1047
1148
|
fail("lens.defocus cannot be combined with lens.pupilSampling: the pupil rays already render defocus, so it would be counted twice.");
|
|
1048
1149
|
if (lensPsf)
|
|
1049
1150
|
fail("lens.defocus cannot be combined with lens.psf: a caller PSF declares its own defocus, so it could be counted twice.");
|
|
1050
|
-
|
|
1151
|
+
// #549: with a catalogue lens the disc is convolved into the lens's PSF (below), not applied by the lens.defocus paths. The bokeh
|
|
1152
|
+
// options (#553) shape the lens.defocus kernel only, so they are refused rather than dropped (a follow-up for a later release).
|
|
1153
|
+
if (catalogue && defocusBokeh)
|
|
1154
|
+
fail("lens.defocus.blades, sphericalAberration and catsEye cannot be combined with lens.catalogue: the catalogue lens " +
|
|
1155
|
+
"folds a plain disc into its own PSF. Omit the bokeh options, or use lens.focalLengthMm instead of the catalogue lens.");
|
|
1156
|
+
if (!catalogue)
|
|
1157
|
+
defocusModel = "geometric-disc";
|
|
1051
1158
|
}
|
|
1052
1159
|
// Motion (2.0): constant camera rotation and the shutter type.
|
|
1053
1160
|
const motionOptions = allowKeys(top.motion, "motion", ["angularVelocityRadPerSec", "shutter", "rollingShutterSkewSeconds"]);
|
|
@@ -1180,14 +1287,12 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1180
1287
|
const unsupported = (option, reason) => fail("simulateDisplayView does not support " + option + ": " + reason);
|
|
1181
1288
|
if (pupil)
|
|
1182
1289
|
unsupported("lens.pupilSampling", "pupil rays move each query off the site lattice the expected-signal tables use; lens.defocus renders the defocus of the scene plane on the lattice.");
|
|
1183
|
-
if (lensFieldMapping)
|
|
1184
|
-
unsupported("lens.projection or lens.distortion", "the expected signal assumes the ideal rectilinear, distortion-free mapping of the site lattice.");
|
|
1185
|
-
if (vignetting)
|
|
1186
|
-
unsupported("lens.vignetting", "the expected signal assumes the same radiance-to-irradiance factor at every site.");
|
|
1187
1290
|
if (lensPsf)
|
|
1188
1291
|
unsupported("lens.psf", "only the engine-built diffraction kernels (lens.diffraction) are tabulated.");
|
|
1189
|
-
|
|
1190
|
-
|
|
1292
|
+
// #563: projection, distortion, vignetting and rotation are supported (display-view-lens-signal.ts), except rotation under a rolling shutter.
|
|
1293
|
+
if (rotating && shutterKind === "rolling")
|
|
1294
|
+
unsupported("camera rotation with motion.shutter \"rolling\"", "every row has its own instants, so the view's " +
|
|
1295
|
+
"scene points of one instant would mix rows exposed at other times; use motion.shutter \"global\" for the view.");
|
|
1191
1296
|
if (outputOptions.region !== undefined)
|
|
1192
1297
|
unsupported("output.region", "pass the rectangle as view.region, in display pixels.");
|
|
1193
1298
|
if (outputOptions.whiteBalance === "auto")
|
|
@@ -1198,6 +1303,9 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1198
1303
|
unsupported("output.highlightMode \"reconstruct\"", "the view supports the per-channel highlight clip.");
|
|
1199
1304
|
if (outputOptions.diffractionCorrection !== undefined || outputOptions.sharpening !== undefined)
|
|
1200
1305
|
unsupported("output.diffractionCorrection or output.sharpening", "the development detail stages filter the whole developed capture image.");
|
|
1306
|
+
if (defocusBokeh?.catsEye)
|
|
1307
|
+
unsupported("lens.defocus.catsEye", "the expected-signal tables hold one kernel per spectral node, and the cat's eye varies over the field; " +
|
|
1308
|
+
"simulateCapture renders it. lens.defocus.blades and lens.defocus.sphericalAberration are supported.");
|
|
1201
1309
|
}
|
|
1202
1310
|
const colorSamplingProfile = { schemaVersion: "0.1.0", profileId: IDS.cfa, evidence: FACADE_EVIDENCE, coordinateSystem: "native-sensor-color-sampling-site-index",
|
|
1203
1311
|
layout: { kind: "periodic-mosaic", repeatWidthSites: 2, repeatHeightSites: 2, siteChannelIds: ["red", "green", "green", "blue"], anchor: "native-sensor-top-left-site" } };
|
|
@@ -1218,19 +1326,22 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1218
1326
|
if (demosaicOption !== undefined && demosaicOption !== "bilinear" && demosaicOption !== "ahd")
|
|
1219
1327
|
fail("output.demosaic must be bilinear or ahd.");
|
|
1220
1328
|
const ahd = demosaicOption === "ahd";
|
|
1221
|
-
// #395: exact or fast per option.
|
|
1329
|
+
// #395: exact or fast per option. Only lens.catalogue has a fast method (#568); it reports true below when it ran.
|
|
1222
1330
|
if (top.fast !== undefined && typeof top.fast !== "boolean")
|
|
1223
1331
|
fail("fast must be true or false.");
|
|
1224
1332
|
const fastTop = top.fast === true;
|
|
1225
1333
|
const fastInUse = [["lens.diffraction", diffractionRadiusOption !== undefined, diffractionFast],
|
|
1334
|
+
["lens.catalogue", catalogue !== undefined, catalogueFast],
|
|
1226
1335
|
["sensor.antiAliasing", antiAliasing !== undefined, antiAliasingFast], ["sensor.pixelAperture.box", boxAperture, boxFast], ["output.demosaic.ahd", ahd, demosaicFast]];
|
|
1227
1336
|
const fastApplied = {};
|
|
1228
1337
|
for (const [option, used] of fastInUse)
|
|
1229
1338
|
if (used)
|
|
1230
1339
|
fastApplied[option] = false;
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1340
|
+
// #568: the catalogue's fast method, when requested (its record's flag, else the top-level one); whether it runs is decided with the RAW path.
|
|
1341
|
+
const catalogueFastRequested = catalogue !== undefined && (catalogueFast ?? fastTop);
|
|
1342
|
+
let fastReport = Object.freeze({
|
|
1343
|
+
requested: fastTop || [diffractionFast, catalogueFast, antiAliasingFast, boxFast, demosaicFast].some((value) => value === true),
|
|
1344
|
+
applied: Object.freeze({ ...fastApplied }), assurance: "engine-reference"
|
|
1234
1345
|
});
|
|
1235
1346
|
if (borderOption !== undefined && borderOption !== "margin" && borderOption !== "mirror")
|
|
1236
1347
|
fail("output.borderHandling must be margin or mirror.");
|
|
@@ -1404,7 +1515,8 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1404
1515
|
const buildProvider = (sceneIlluminant, scale) => buildPlanarSceneRadianceProvider({ scene: chart ?? callerScene,
|
|
1405
1516
|
sceneId: IDS.scene, providerProfileId: IDS.provider, illuminant: sceneIlluminant, irradianceScale: scale, geometry: { distanceM: subjectDistanceM, metresPerFrameUnit,
|
|
1406
1517
|
...(chartSize ? { chart: { widthM: chartSize.widthM, heightM: chartSize.heightM, surroundReflectance: chartSize.surroundReflectance } } : {}) }, evidence: FACADE_EVIDENCE,
|
|
1407
|
-
limitations: Object.freeze(["Planar Lambertian scene under uniform illumination; no shadows, inter-reflection, gloss or fluorescence."])
|
|
1518
|
+
limitations: Object.freeze(["Planar Lambertian scene under uniform illumination; no shadows, inter-reflection, gloss or fluorescence."]),
|
|
1519
|
+
...(catalogue?.kind === "fisheye" ? { zeroBeyondHorizon: true } : {}) });
|
|
1408
1520
|
const provider = buildProvider(illuminant, irradianceScale);
|
|
1409
1521
|
// 2.6: a chart preset's evaluator is engine-built end to end (its results: the schema's exact keys, constant fields,
|
|
1410
1522
|
// the request's identifiers, deep-frozen FACADE_EVIDENCE, UNCERTAINTY and limitations, and an engine-computed
|
|
@@ -1441,9 +1553,15 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1441
1553
|
fail("exposure.compensationStops must be within ±10.");
|
|
1442
1554
|
if (exposureOptions.compensationStops === undefined)
|
|
1443
1555
|
assume("exposure.compensationStops", "default", 0, "No exposure compensation.");
|
|
1444
|
-
|
|
1556
|
+
// #569: a lens whose range excludes f/8 (the catalogue's PV 800 9, f/9–f/32; an ideal lens with a narrowed range) takes the same reference
|
|
1557
|
+
// exposure at the aperture of its range nearest f/8, N0 with t = 1/125 s × (N0/8)², which has the same N²/t and so the same reference
|
|
1558
|
+
// luminance; every other request keeps f/8, 1/125 s.
|
|
1559
|
+
const referenceAperture = lensRange ? Math.min(lensRange.narrowestFNumber, Math.max(lensRange.widestFNumber, DEFAULTS.fNumber)) : DEFAULTS.fNumber;
|
|
1560
|
+
const reference = { aperture: referenceAperture, shutterSeconds: referenceAperture === DEFAULTS.fNumber ? DEFAULTS.shutterSeconds :
|
|
1561
|
+
DEFAULTS.shutterSeconds * (referenceAperture / DEFAULTS.fNumber) ** 2, iso: DEFAULTS.iso };
|
|
1445
1562
|
const referenceLuminance = sosReferenceLuminance(reference.aperture, reference.shutterSeconds, reference.iso);
|
|
1446
|
-
assume("exposure.referenceExposure", "model", { ...reference, meteredLuminanceCdPerSquareMeter: referenceLuminance }, "Exposure anchor: f/8, 1/125 s, ISO 100 gives the CIPA DC-004 standard output sensitivity reference exposure H = 10/S = 0.1 lx·s for an average scene luminance of 10·N²/(0.65·t·S) ≈ 1230.77 cd/m² (camera equation H = 0.65·L·t/N²)
|
|
1563
|
+
assume("exposure.referenceExposure", "model", { ...reference, meteredLuminanceCdPerSquareMeter: referenceLuminance }, "Exposure anchor: f/8, 1/125 s, ISO 100 gives the CIPA DC-004 standard output sensitivity reference exposure H = 10/S = 0.1 lx·s for an average scene luminance of 10·N²/(0.65·t·S) ≈ 1230.77 cd/m² (camera equation H = 0.65·L·t/N²)" +
|
|
1564
|
+
(referenceAperture === DEFAULTS.fNumber ? "." : "; the lens excludes f/8: the same N²/t at f/" + referenceAperture + "."));
|
|
1447
1565
|
const meterProfile = body.metering[0].profile, zones = 16, zoneSamples = [];
|
|
1448
1566
|
let luminanceSum = 0;
|
|
1449
1567
|
for (let j = 0; j < zones; j++)
|
|
@@ -1474,10 +1592,13 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1474
1592
|
target = setExposureCompensationOnMeterTarget({ targetId: "facade-target-ec", baseTarget: target, exposureCompensationStops: compensation });
|
|
1475
1593
|
let resolution;
|
|
1476
1594
|
let fNumber, shutterSeconds;
|
|
1595
|
+
const checkFNumber = () => {
|
|
1596
|
+
if (lensRange && (fNumber < lensRange.widestFNumber || fNumber > lensRange.narrowestFNumber))
|
|
1597
|
+
fail("lens.fNumber " + fNumber + " is outside the " + lensRange.name + " range f/" + lensRange.widestFNumber + "–f/" + lensRange.narrowestFNumber + ".");
|
|
1598
|
+
};
|
|
1477
1599
|
if (mode === "manual" || mode === "aperture-priority") {
|
|
1478
1600
|
fNumber = positive(pick(lensOptions, "fNumber", "lens.fNumber", DEFAULTS.fNumber, "f/8."), "lens.fNumber");
|
|
1479
|
-
|
|
1480
|
-
fail("lens.fNumber " + fNumber + " is outside the " + prime.lensId + " range f/" + prime.widestFNumber + "–f/" + prime.narrowestFNumber + ".");
|
|
1601
|
+
checkFNumber();
|
|
1481
1602
|
}
|
|
1482
1603
|
else {
|
|
1483
1604
|
if (lensOptions.fNumber !== undefined)
|
|
@@ -1590,15 +1711,154 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1590
1711
|
const exposureDeviationStops = standardExposureDeviationStops(fNumber, shutterSeconds, resolvedIso, meteredLuminance);
|
|
1591
1712
|
const exposureIlluminanceLux = standardExposureIlluminanceLux(fNumber, shutterSeconds, resolvedIso, meteredLuminance, illuminanceLux, callerScene?.kind !== "radiance-function");
|
|
1592
1713
|
const exposureGreyCardDeviationStops = callerScene?.kind === "radiance-function" ? null : greyCardDeviationStops(fNumber, shutterSeconds, resolvedIso, illuminanceLux);
|
|
1714
|
+
// #549: the catalogue lens's character at the resolved f-number and focus: focus scatter, the defocus disc folded into its PSF, then
|
|
1715
|
+
// its distortion and vignetting profiles over the field the PSF and the queries reach, and its PSF profile.
|
|
1716
|
+
let catalogueTaps, catalogueDefocusApplied = false, catalogueKernelRadius = 0;
|
|
1717
|
+
if (catalogue && lensKit) {
|
|
1718
|
+
const { GENERIC_LENS_EVIDENCE, genericLensDistortionProfile, genericLensVignettingProfile, resolveGenericLens, simulateGenericLensFocusError, createGenericLensPsfProfile, genericLensPsfKernelRadius, GENERIC_LENS_PSF_MAXIMUM_COMBINED_RADIUS_SITES } = lensKit;
|
|
1719
|
+
checkFNumber();
|
|
1720
|
+
if (lensOptions.focusScatter !== undefined) {
|
|
1721
|
+
const scatterOptions = allowKeys(lensOptions.focusScatter, "lens.focusScatter", ["seed", "shotIndex"]);
|
|
1722
|
+
const sample = simulateGenericLensFocusError({ lensId: catalogue.lensId, activeImagingArea: catalogueFrame, fNumber, focusDistanceM, seed: scatterOptions.seed,
|
|
1723
|
+
...(scatterOptions.shotIndex === undefined ? {} : { shotIndex: scatterOptions.shotIndex }) });
|
|
1724
|
+
assume("lens.focusScatter", "model", { ...sample }, "Seeded autofocus error (an approximation of focus consistency): image-side error z·σ·2Nc with z a standard normal draw from the seed and shot index, σ = " + sample.depthOfFocusFraction +
|
|
1725
|
+
" of the depth of focus 2·N·c (c = the frame diagonal / 1500), read back through the thin lens. The focus distance " + sample.intendedFocusDistanceM + " m becomes " + sample.focusDistanceM + " m" +
|
|
1726
|
+
(lensOptions.defocus === undefined ? "; without lens.defocus the capture renders no defocus, so the image does not change." : "."));
|
|
1727
|
+
focusDistanceM = sample.focusDistanceM;
|
|
1728
|
+
}
|
|
1729
|
+
const character = resolveGenericLens({ lensId: catalogue.lensId, activeImagingArea: catalogueFrame, fNumber, focusDistanceM });
|
|
1730
|
+
let extraKernel;
|
|
1731
|
+
if (lensOptions.defocus !== undefined) {
|
|
1732
|
+
const disc = resolveDefocusDisc({ focalLengthMm, aperture: fNumber, focusDistanceM, subjectDistanceM, pitchMicrometers: pitch });
|
|
1733
|
+
let discRadius;
|
|
1734
|
+
try {
|
|
1735
|
+
discRadius = requireDefocusDiscKernelRadius(disc.diameterSites);
|
|
1736
|
+
}
|
|
1737
|
+
catch {
|
|
1738
|
+
fail("lens.defocus: " + defocusDiscLimitMessage(disc.diameterSites, disc.diameterMicrometers));
|
|
1739
|
+
}
|
|
1740
|
+
const kernel = run === "ideal-reference" ? null : createDefocusDiscSiteKernel(disc.diameterSites);
|
|
1741
|
+
if (kernel) {
|
|
1742
|
+
extraKernel = { weights: kernel.weights, radius: kernel.radius };
|
|
1743
|
+
catalogueDefocusApplied = true;
|
|
1744
|
+
}
|
|
1745
|
+
assume("lens.defocus", "model", { model: "geometric-disc", scientificStatus: "approximation", diameterMicrometers: disc.diameterMicrometers, diameterSites: disc.diameterSites,
|
|
1746
|
+
kernelRadiusSites: disc.diameterSites === 0 ? null : discRadius, fNumber, focusDistanceM, subjectDistanceM, samplePitchMicrometers: pitch,
|
|
1747
|
+
defocusCircleModel: { id: "thin-lens-defocus-circle", version: "1.1.0" }, execution: "catalogue-lens-psf" }, "Geometric defocus of the flat scene plane (an approximation): the thin-lens blur circle c = f²·|s − d| / (N·d·(s − f)) (calculateDefocusCircle 1.1.0) as a uniform disc sampled on the site lattice. " +
|
|
1748
|
+
(disc.diameterSites === 0 ? "In focus: no disc is applied." : run === "ideal-reference" ? "Resolved but not applied: the ideal reference has no camera effects." :
|
|
1749
|
+
"With the catalogue lens the disc is convolved into every node of the lens's PSF (a further approximation: the defocused aberrated wave PSF is not exactly the in-focus PSF convolved with the disc) and applied through the environment PSF quadrature."));
|
|
1750
|
+
}
|
|
1751
|
+
// An upper bound of the PSF's radius (the built window may be narrower): the field reach below uses it.
|
|
1752
|
+
const kernelRadius = genericLensPsfKernelRadius({ lens: character, focusDistanceM, sitePitchMicrometers: pitch }) + (extraKernel ? extraKernel.radius : 0);
|
|
1753
|
+
// The field the PSF taps and the site queries reach: the native raster plus the kernel radius and the query offsets.
|
|
1754
|
+
const queryReach = Math.max(0, ...queryOffsetsSites.map((q) => Math.max(Math.abs(q.x), Math.abs(q.y))));
|
|
1755
|
+
const reachMm = (kernelRadius + queryReach + 1) * pitch / 1000;
|
|
1756
|
+
const frameHalfMm = { x: nativeWidth * pitch / 2000, y: nativeHeight * pitch / 2000 }, extentMm = { x: frameHalfMm.x + reachMm, y: frameHalfMm.y + reachMm };
|
|
1757
|
+
const reachRadius = Math.hypot(extentMm.x, extentMm.y) / catalogue.halfDiagonalMm;
|
|
1758
|
+
const b = 1 - 2 ** (-character.character.vignetting.cornerStops / 2);
|
|
1759
|
+
if (!(b * reachRadius * reachRadius < 0.98))
|
|
1760
|
+
fail("lens.catalogue: the " + catalogue.name + "'s vignetting E(ρ) = (1 − bρ²)² is defined inside ρ < " + (1 / Math.sqrt(b)).toFixed(3) + " of the frame's half-diagonal, and this frame's PSF reach goes to ρ = " +
|
|
1761
|
+
reachRadius.toFixed(3) + ". Use a larger output (or smaller pixels' fewer sites of reach): the reach is " + (kernelRadius + queryReach + 1) + " sites beyond the frame.");
|
|
1762
|
+
vignetting = genericLensVignettingProfile(character.character.vignetting.cornerStops, catalogue.halfDiagonalMm, reachRadius);
|
|
1763
|
+
const corner = character.character.distortion.corner;
|
|
1764
|
+
distortion = { kind: "radial", profile: genericLensDistortionProfile(corner, catalogue.halfDiagonalMm, corner < 0 ? reachRadius / (1 + corner * reachRadius * reachRadius) * 1.01 : reachRadius) };
|
|
1765
|
+
lensFieldMapping = { projectionModel: projection ?? "rectilinear", distortion, radiometricAreaCorrection: "inverse-distortion-jacobian",
|
|
1766
|
+
...(catalogue.kind === "fisheye" ? { outsideImageCircle: "backward-ray" } : {}) };
|
|
1767
|
+
try {
|
|
1768
|
+
prepareEnvironmentLensFieldMapping(lensFieldMapping);
|
|
1769
|
+
}
|
|
1770
|
+
catch (error) {
|
|
1771
|
+
fail("lens.catalogue: the " + catalogue.name + "'s distortion over this frame's reach: " + error.message);
|
|
1772
|
+
}
|
|
1773
|
+
const c = character.character;
|
|
1774
|
+
let psfReport = null;
|
|
1775
|
+
if (run !== "ideal-reference") {
|
|
1776
|
+
const built = createGenericLensPsfProfile({ lens: character, focusDistanceM, sitePitchMicrometers: pitch, fieldHalfExtentMm: extentMm, frameHalfMm, evidence: GENERIC_LENS_EVIDENCE,
|
|
1777
|
+
...(extraKernel ? { extraKernel } : {}) });
|
|
1778
|
+
lensPsf = { kind: "sampled-local", configuration: { psf: { profile: built.profile, focalLengthMm, focus: { kind: "finite", distanceM: focusDistanceM }, apertureFNumber: fNumber,
|
|
1779
|
+
signedDefocusImagePlaneMicrometers: 0 }, psfWavelengthBasis: { value: "vacuum", evidence: GENERIC_LENS_EVIDENCE },
|
|
1780
|
+
spatialModel: { kind: "destination-local-shift-invariant-approximation", evidence: GENERIC_LENS_EVIDENCE,
|
|
1781
|
+
limitation: "Catalogue lens PSF: a 5 × 5 field grid × 5 wavelengths, interpolated with centroid alignment; each site's PSF is that of its own field point." } } };
|
|
1782
|
+
if (built.kernelRadiusSites > GENERIC_LENS_PSF_MAXIMUM_COMBINED_RADIUS_SITES)
|
|
1783
|
+
fail("lens.catalogue with lens.defocus needs a PSF of radius " + built.kernelRadiusSites + " sites (the lens's " + built.aberrationKernelRadiusSites + " plus the disc's " +
|
|
1784
|
+
(built.kernelRadiusSites - built.aberrationKernelRadiusSites) + "), over the limit of " + GENERIC_LENS_PSF_MAXIMUM_COMBINED_RADIUS_SITES + " (" + (2 * built.kernelRadiusSites + 1) ** 2 +
|
|
1785
|
+
" scene queries per site and spectral node through the environment quadrature). Bring the focus closer to the subject, stop down, or use larger pixels.");
|
|
1786
|
+
catalogueTaps = (2 * built.kernelRadiusSites + 1) ** 2;
|
|
1787
|
+
catalogueKernelRadius = built.kernelRadiusSites;
|
|
1788
|
+
psfReport = { aberrationKernelRadiusSites: built.aberrationKernelRadiusSites, kernelRadiusSites: built.kernelRadiusSites, maximumTruncatedEnergyFraction: built.maximumTruncatedEnergyFraction,
|
|
1789
|
+
fieldNodesMm: { x: built.fieldXmm, y: built.fieldYmm }, wavelengthNodesNm: built.wavelengthsNm, transformSize: built.transformSize, maximumPhaseStepWaves: built.maximumPhaseStepWaves };
|
|
1790
|
+
}
|
|
1791
|
+
assume("lens.catalogue.character", "model", { lensId: catalogue.lensId, fNumber, focusDistanceM, magnification: c.magnification, closeFocusGain: c.closeFocusGain, pupilScale: c.pupilScale,
|
|
1792
|
+
aberrationsWaves550: { ...c.aberrations }, lateralColor: { ...c.lateralColor }, distortionCorner: c.distortion.corner, vignettingCornerStops: c.vignetting.cornerStops,
|
|
1793
|
+
notRendered: { strayLight: { ...c.strayLight }, breathing: { ...c.breathing }, focusScatterDepthOfFocusFraction: c.focusScatterDepthOfFocusFraction, bokeh: "disc (#553)" },
|
|
1794
|
+
psf: psfReport, profiles: { vignetting, distortion: distortion.profile, maximumNormalizedRadius: reachRadius } }, "The catalogue lens's character, a labelled approximation of a generic lens (docs/LENS_CATALOGUE.md): Seidel wave aberrations at the frame corner (spherical, balanced by defocus; coma; astigmatism; field curvature), " +
|
|
1795
|
+
"scaled from the widest aperture by the pupil ratio a = N0/N (a⁴, a³, a², a²) and by the close-focus factor; axial colour as a defocus and lateral colour as a radial shift, both with the normalised Cauchy dispersion. " +
|
|
1796
|
+
(run === "ideal-reference" ? "Not applied: the ideal reference has no camera effects. " :
|
|
1797
|
+
"Rendered as a sampled PSF (scalar Fraunhofer diffraction of the aberrated circular pupil, integrated over each site cell, on a 5 × 5 field grid × 5 wavelengths with centroid-aligned interpolation) " +
|
|
1798
|
+
"through the environment PSF quadrature (one scene query per kernel tap), with the lens's radial distortion (r_d = r·(1 + k1ρ²)) and relative illumination (E = (1 − bρ²)²) as lens field mapping and field throughput" +
|
|
1799
|
+
(catalogue.kind === "fisheye" ? ", and the equisolid projection; directions at or beyond 90° see no scene (zero radiance), so a circular fisheye's image ends at its 180° circle" : "") + ". ") +
|
|
1800
|
+
"Flare, focus breathing and bokeh shape are reported, not rendered; ρ is normalised to the frame's half-diagonal, so the lens is scaled to the simulated frame like lens.prime.");
|
|
1801
|
+
}
|
|
1593
1802
|
// #492: the capture applies the disc as an object-space convolution, which needs every disc point a site sees to be another site's scene point.
|
|
1594
1803
|
// #548: a lens field mapping, vignetting or camera rotation breaks that, so the capture then applies the disc in image space, on the sensor's
|
|
1595
|
-
// site lattice (defocus-image-space-provider.ts).
|
|
1804
|
+
// site lattice (defocus-image-space-provider.ts). #563: the display view reads the same geometry in any focus (display-view-lens-signal.ts).
|
|
1596
1805
|
const imageSpaceOptions = [...(lensFieldMapping ? [lensOptions.distortion !== undefined ? (lensOptions.projection !== undefined ? "lens.projection, lens.distortion" : "lens.distortion") : "lens.projection"] : []),
|
|
1597
1806
|
...(vignetting ? ["lens.vignetting"] : []), ...(rotating ? ["camera rotation"] : [])];
|
|
1598
|
-
const
|
|
1807
|
+
const lensGeometry = (defocusModel !== undefined || displayView) && imageSpaceOptions.length > 0 ? prepareDefocusImageSpaceGeometry({ focalLengthMm, focusDistanceM,
|
|
1599
1808
|
angularVelocityRadPerSec: angular, ...(lensFieldMapping ? { lensMapping: prepareEnvironmentLensFieldMapping(lensFieldMapping) } : {}), ...(vignetting ? { vignetting } : {}) }) : undefined;
|
|
1809
|
+
const defocusGeometry = defocusModel !== undefined ? lensGeometry : undefined;
|
|
1810
|
+
// #553: the shaped kernel (on axis) and, with a cat's eye, its field zones; built when the disc is resolved out of focus.
|
|
1811
|
+
let bokehAxisKernel, bokehZones;
|
|
1812
|
+
const bokehSummary = defocusBokeh === undefined ? undefined : (disc) => {
|
|
1813
|
+
const options = defocusBokeh, side = subjectDistanceM > focusDistanceM ? 1 : -1;
|
|
1814
|
+
const build = (field) => {
|
|
1815
|
+
try {
|
|
1816
|
+
return createDefocusBokehSiteKernel({ diameterSites: disc.diameterSites, side, fNumber, focalLengthMm, pitchMicrometers: pitch, options, ...(field ? { field } : {}) });
|
|
1817
|
+
}
|
|
1818
|
+
catch (error) {
|
|
1819
|
+
const message = error.message;
|
|
1820
|
+
fail(message.startsWith("lens.defocus") ? message : "lens.defocus: " + message);
|
|
1821
|
+
}
|
|
1822
|
+
};
|
|
1823
|
+
const axis = build();
|
|
1824
|
+
bokehAxisKernel = axis;
|
|
1825
|
+
// The run convolution above 64 sites (#564) takes one kernel for the whole frame; the cat's eye needs one per field zone.
|
|
1826
|
+
if (options.catsEye && !displayView && axis.radius > DEFOCUS_DIRECT_KERNEL_MAXIMUM_RADIUS_SITES)
|
|
1827
|
+
fail("lens.defocus.catsEye: the blur needs a kernel radius of " + axis.radius + " sites; with a cat's eye the limit is " + DEFOCUS_DIRECT_KERNEL_MAXIMUM_RADIUS_SITES +
|
|
1828
|
+
" sites (129 across). Reduce the defocus or omit catsEye.");
|
|
1829
|
+
if (options.catsEye && !displayView) {
|
|
1830
|
+
// The field tangent of a native point (sites from the top-left corner), in output-image axes (+Y up): through the lens
|
|
1831
|
+
// mapping in image space (#548; at the exposure start, so camera rotation does not move the field), else the planar lattice's.
|
|
1832
|
+
const g = 2 / width, mmPerSite = pitch / 1000;
|
|
1833
|
+
const fieldOf = defocusGeometry ? (x, y) => {
|
|
1834
|
+
const look = defocusGeometry.lookOf((x - nativeWidth / 2) * mmPerSite, (y - nativeHeight / 2) * mmPerSite, 0);
|
|
1835
|
+
return { x: look.x / look.z, y: look.y / look.z };
|
|
1836
|
+
} : (x, y) => ({ x: (x - nativeWidth / 2) * g * metresPerFrameUnit / subjectDistanceM, y: -(y - nativeHeight / 2) * g * metresPerFrameUnit / subjectDistanceM });
|
|
1837
|
+
bokehZones = planDefocusBokehZones({ options, nativeWidth, nativeHeight, radius: axis.radius, fNumber, focalLengthMm, fieldOf, kernelAt: build });
|
|
1838
|
+
if (!(bokehZones.minimumOpenAreaRatio > 0))
|
|
1839
|
+
fail("lens.defocus.catsEye: the clipping apertures close the pupil in part of the frame; reduce shiftPerTanFieldMm or enlarge radiusMm.");
|
|
1840
|
+
}
|
|
1841
|
+
const zones = bokehZones;
|
|
1842
|
+
return { value: { options, side: side === 1 ? "behind-focus" : "in-front-of-focus", kernelRadiusSites: axis.radius, method: axis.method, raySamples: axis.raySamples,
|
|
1843
|
+
sphericalAberrationSites: axis.sphericalAberrationSites, axisOpenAreaRatio: axis.openAreaRatio,
|
|
1844
|
+
...(zones ? { catsEye: { execution: "piecewise-constant-field-zones", zoneSizeSites: zones.zones.size, columns: zones.zones.columns, rows: zones.zones.rows,
|
|
1845
|
+
minimumOpenAreaRatio: zones.minimumOpenAreaRatio, largestAdjacentOpenAreaRatioChange: zones.largestAdjacentOpenAreaRatioChange, lightLossApplied: false } } : {}) },
|
|
1846
|
+
explanation: "The kernel is shaped by the lens instead (#553, bokeh character; calculateDefocusBokehKernel): the projected open pupil " +
|
|
1847
|
+
(options.blades ? "(" + options.blades.count + " blades" + (options.blades.curvature > 0 ? ", curvature " + options.blades.curvature : "") +
|
|
1848
|
+
(options.blades.circularAtOrBelowFNumber !== null ? ", circular at or below f/" + options.blades.circularAtOrBelowFNumber : "") + ", scaled to the disc's area) " : "(a circle) ") +
|
|
1849
|
+
"of a point " + (side === 1 ? "behind" : "in front of") + " the focus plane" + (side === 1 ? "" : " (the pupil turned through 180°: the rays have not crossed yet)") +
|
|
1850
|
+
(options.sphericalAberration ? ", with primary spherical aberration W040 " + options.sphericalAberration.w040Micrometers + " µm at f/" + options.sphericalAberration.atFNumber +
|
|
1851
|
+
" (" + axis.sphericalAberrationSites + " sites at the marginal ray here; ray map (σc/2 + bρ²)·P, rays binned per site cell)" : " (exact polygon area in each site's cell)") +
|
|
1852
|
+
(zones ? ". Cat's eye: the pupil is clipped by " + options.catsEye.apertures.length + " aperture(s) shifted by field position (calculateMechanicalVignetting's model), applied as " +
|
|
1853
|
+
zones.zones.columns + " × " + zones.zones.rows + " field zones of " + zones.zones.size + " sites, each with its centre's kernel (a piecewise-constant approximation; " +
|
|
1854
|
+
"the open-area ratio falls to " + formatBound(zones.minimumOpenAreaRatio) + " and changes by at most " + formatBound(zones.largestAdjacentOpenAreaRatioChange) +
|
|
1855
|
+
" between neighbouring zones). The kernel has unit sum: the light the clip removes is not applied; declare it with lens.vignetting" : "") +
|
|
1856
|
+
". A geometric approximation: no diffraction, onion rings, higher-order or field-dependent aberrations; the same kernel at every wavelength. "
|
|
1857
|
+
};
|
|
1858
|
+
};
|
|
1600
1859
|
const defocusDisc = defocusModel === undefined ? undefined :
|
|
1601
1860
|
resolveDefocusOption({ run, assume, focalLengthMm, fNumber, focusDistanceM, subjectDistanceM, pitch, diffraction: diffractionRadiusOption !== undefined,
|
|
1861
|
+
...(defocusBokeh ? { bokehOptions: defocusBokeh, bokeh: bokehSummary } : {}),
|
|
1602
1862
|
...(defocusGeometry ? { imageSpace: (disc) => {
|
|
1603
1863
|
// Rotation with a rolling shutter: each row's instants differ, so one instant's image on the lattice would mix rows exposed at other times.
|
|
1604
1864
|
if (rotating && shutterKind === "rolling")
|
|
@@ -1612,9 +1872,10 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1612
1872
|
} } : {}) });
|
|
1613
1873
|
const defocusImageSpace = defocusDisc !== undefined && defocusGeometry !== undefined;
|
|
1614
1874
|
// #492: the disc on the site lattice (radius 0 for a disc inside one cell: the identity, still applied).
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1875
|
+
// #553: the shaped kernel replaces the disc (with a cat's eye, the axis kernel; the zones replace it in the gather).
|
|
1876
|
+
const defocusKernel = defocusDisc && !displayView ? bokehAxisKernel ?? createDefocusDiscSiteKernel(defocusDisc.diameterSites) ?? undefined : undefined;
|
|
1877
|
+
// The display view's disc (#492, step 2b): its expected-signal tables apply it (simulate-display-view.ts); #553: or the shaped kernel.
|
|
1878
|
+
const displayViewDefocus = displayView ? (defocusDisc && bokehAxisKernel ? { ...defocusDisc, siteKernel: bokehAxisKernel } : defocusDisc) : undefined;
|
|
1618
1879
|
// Sensor readout for the ISO setting. Standard output sensitivity S = 10/H (CIPA DC-004 §3.2) is defined at the
|
|
1619
1880
|
// output, independent of pixel size, while the collected charge is proportional to the sensitive-aperture area
|
|
1620
1881
|
// (EMVA 1288 Release 4.0, Eq. 3). The tier's signal chain is anchored to the 6 µm aperture; at another area the
|
|
@@ -1688,7 +1949,10 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1688
1949
|
assume("sensor.shutter", "model", "global-electronic", "Global electronic shutter: every site integrates the same exposure window.");
|
|
1689
1950
|
if (rotating)
|
|
1690
1951
|
assume("motion.angularVelocityRadPerSec", "model", { ...angular }, "Constant camera angular velocity applied to every scene ray at each temporal midpoint of each site's exposure window (engine rotation convention, docs/SENSOR_ENVIRONMENT_QUERY.md); motion blur is the midpoint quadrature over sensor.temporalSampleCount samples.");
|
|
1691
|
-
if (
|
|
1952
|
+
if (catalogue)
|
|
1953
|
+
assume("lens.psf", "model", lensPsf ? { kind: "sampled-local", source: "lens.catalogue" } : "not-applied", lensPsf ?
|
|
1954
|
+
"The catalogue lens's engine-built sampled PSF (see lens.catalogue.character) redistributes irradiance through the environment PSF quadrature." : "No lens PSF: the ideal reference has no camera effects.");
|
|
1955
|
+
else if (lensPsf)
|
|
1692
1956
|
assume("lens.psf", "model", { kind: "sampled-local" }, "Caller-supplied sampled lens PSF redistributes irradiance through the environment PSF quadrature; its evidence and limits are the caller's.");
|
|
1693
1957
|
// With lens.diffraction the PSF is recorded as lens.diffraction once its kernels are built.
|
|
1694
1958
|
else if (diffractionRadiusOption === undefined)
|
|
@@ -1698,11 +1962,11 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1698
1962
|
assume("lens.fieldThroughput", "model", "unity", "No vignetting, distortion, lateral colour or stray light.");
|
|
1699
1963
|
else {
|
|
1700
1964
|
if (vignetting)
|
|
1701
|
-
assume("lens.vignetting", "model", { kind: "radial-illumination-vignetting", profile: vignetting }, "Caller-declared radial relative illumination 1 + r2·ρ² + r4·ρ⁴ + r6·ρ⁶, applied once as environment field throughput at the physical sensor point before any PSF.");
|
|
1965
|
+
assume("lens.vignetting", "model", { kind: "radial-illumination-vignetting", profile: vignetting }, (catalogue ? "The catalogue lens's" : "Caller-declared") + " radial relative illumination 1 + r2·ρ² + r4·ρ⁴ + r6·ρ⁶, applied once as environment field throughput at the physical sensor point before any PSF.");
|
|
1702
1966
|
else
|
|
1703
1967
|
assume("lens.fieldThroughput", "model", "unity", "No vignetting, lateral colour or stray light.");
|
|
1704
1968
|
if (distortion)
|
|
1705
|
-
assume("lens.distortion", "model", distortion, "Caller-declared " + distortion.kind + " distortion: each sensor point is inverse-distorted before the scene-ray query and irradiance is multiplied exactly once by the area factor 1/det(J).");
|
|
1969
|
+
assume("lens.distortion", "model", distortion, (catalogue ? "The catalogue lens's " : "Caller-declared ") + distortion.kind + " distortion: each sensor point is inverse-distorted before the scene-ray query and irradiance is multiplied exactly once by the area factor 1/det(J).");
|
|
1706
1970
|
}
|
|
1707
1971
|
if (projection === undefined)
|
|
1708
1972
|
assume("lens.projection", "default", "rectilinear", "Ideal rectilinear (pinhole) projection.");
|
|
@@ -1896,7 +2160,25 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1896
2160
|
// #492: each spectral node's kernel for lens.defocus out of focus: the disc, or with lens.diffraction the node's diffraction kernel convolved
|
|
1897
2161
|
// with the disc, radius R_diffraction + R_defocus (built on first use, kept for the request). Object space, or image space (#548).
|
|
1898
2162
|
let defocusSiteKernels;
|
|
1899
|
-
if (defocusKernel) {
|
|
2163
|
+
if (defocusKernel && defocusKernel.radius > DEFOCUS_DIRECT_KERNEL_MAXIMUM_RADIUS_SITES) {
|
|
2164
|
+
// #564: above the direct radius the disc is convolved by runs (site-kernel-runs.ts) and each node's diffraction kernel applied
|
|
2165
|
+
// after it, instead of one combined kernel per node: the same sums regrouped. `at` is never read on this path.
|
|
2166
|
+
const runs = siteKernelRuns(defocusKernel.weights, defocusKernel.radius), diffractionRadius = diffraction ? diffraction.kernelRadiusSamples : 0;
|
|
2167
|
+
const psfConfiguration = diffraction ? diffractionPsf.configuration.psf : undefined, profileWavelengths = diffraction ? diffraction.profile.axes.wavelengthNm : quadratureWavelengths();
|
|
2168
|
+
const diffractionKernels = new Map();
|
|
2169
|
+
defocusSiteKernels = { radius: diffractionRadius + defocusKernel.radius, wavelengthsNm: profileWavelengths, at: () => fail("simulateCapture: the large defocus kernel has no combined kernels."),
|
|
2170
|
+
large: { runs, ...(diffraction ? { diffraction: { radius: diffractionRadius, kernelAt(wavelengthNm) {
|
|
2171
|
+
let kernel = diffractionKernels.get(wavelengthNm);
|
|
2172
|
+
if (!kernel) {
|
|
2173
|
+
if (!profileWavelengths.includes(wavelengthNm))
|
|
2174
|
+
fail("simulateCapture: a spectral node has no diffraction kernel.");
|
|
2175
|
+
kernel = siteDiffractionKernel(psfConfiguration, wavelengthNm, pitch, diffractionRadius, "simulateCapture: diffraction kernels must sample the site pitch exactly.");
|
|
2176
|
+
diffractionKernels.set(wavelengthNm, kernel);
|
|
2177
|
+
}
|
|
2178
|
+
return kernel;
|
|
2179
|
+
} } } : {}) } };
|
|
2180
|
+
}
|
|
2181
|
+
else if (defocusKernel) {
|
|
1900
2182
|
if (diffraction) {
|
|
1901
2183
|
const diffractionRadius = diffraction.kernelRadiusSamples, combined = new Map(), profileWavelengths = diffraction.profile.axes.wavelengthNm;
|
|
1902
2184
|
const radius = diffractionRadius + defocusKernel.radius;
|
|
@@ -1976,17 +2258,19 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1976
2258
|
}
|
|
1977
2259
|
const readTile = async (request) => ({ ...request, sites: Array.from({ length: request.width }, (_, j) => siteInput(request.y * nativeWidth + request.x + j)) });
|
|
1978
2260
|
const yieldControl = createHostYield().yieldControl;
|
|
1979
|
-
const perSite = queriesPerSite * (diffraction && psf === diffractionPsf ? (2 * diffraction.kernelRadiusSamples + 1) ** 2 : 1);
|
|
2261
|
+
const perSite = queriesPerSite * (diffraction && psf === diffractionPsf ? (2 * diffraction.kernelRadiusSamples + 1) ** 2 : catalogueTaps ?? 1);
|
|
1980
2262
|
let diffractionMs = 0, defocusMs = 0, demosaicMs, encodeMs;
|
|
1981
2263
|
const diffractedProvider = (window) =>
|
|
1982
2264
|
// #548: the disc (or diffraction ⊛ disc) in image space, on the sensor's site lattice, through the mapping, field throughput and rotation.
|
|
1983
2265
|
defocusSiteKernels && defocusGeometry && defocusImageSpace ? createDefocusImageSpaceProvider({ base: provider, geometry: defocusGeometry, siteKernels: defocusSiteKernels.at,
|
|
2266
|
+
...(defocusSiteKernels.large ? { largeKernel: defocusSiteKernels.large } : {}),
|
|
1984
2267
|
wavelengthsNm: defocusSiteKernels.wavelengthsNm, kernelRadius: defocusSiteKernels.radius, nativeWidth, nativeHeight, sitePitchMicrometers: pitch, distanceM: subjectDistanceM,
|
|
1985
|
-
metresPerFrameUnit, queryOffsetsSites, timesPerBand: temporalSampleCount, onConvolution: (ms) => { defocusMs += ms; }, ...(window ? { window } : {})
|
|
1986
|
-
|
|
2268
|
+
metresPerFrameUnit, queryOffsetsSites, timesPerBand: temporalSampleCount, onConvolution: (ms) => { defocusMs += ms; }, ...(window ? { window } : {}),
|
|
2269
|
+
...(bokehZones ? { fieldZones: bokehZones.zones } : {}) }) :
|
|
2270
|
+
defocusSiteKernels ? createDiffractedPlanarProvider({ base: provider, siteKernels: defocusSiteKernels.at, ...(defocusSiteKernels.large ? { largeKernel: defocusSiteKernels.large } : {}),
|
|
1987
2271
|
wavelengthsNm: defocusSiteKernels.wavelengthsNm, kernelRadius: defocusSiteKernels.radius, nativeWidth, nativeHeight, outputWidth: width,
|
|
1988
2272
|
sitePitchMicrometers: pitch, distanceM: subjectDistanceM, metresPerFrameUnit, onConvolution: (ms) => { defocusMs += ms; },
|
|
1989
|
-
...(window ? { window } : {}),
|
|
2273
|
+
...(window ? { window } : {}), ...(bokehZones ? { fieldZones: bokehZones.zones } : {}),
|
|
1990
2274
|
...(antiAliasing || boxSamples > 1 ? { queryOffsetsSites: apertureNodeOffsets(boxSamples).flatMap((node) => (antiAliasing ? antiAliasing.components : [{ offsetMicrometers: { x: 0, y: 0 } }]).map((c) => ({ x: node.x - c.offsetMicrometers.x / pitch, y: node.y - c.offsetMicrometers.y / pitch }))) } : {}) }) :
|
|
1991
2275
|
diffraction && diffractionObjectSpace ? createDiffractedPlanarProvider({ base: provider, psf: diffractionPsf.configuration.psf,
|
|
1992
2276
|
wavelengthsNm: diffraction.profile.axes.wavelengthNm, kernelRadius: diffraction.kernelRadiusSamples, nativeWidth, nativeHeight, outputWidth: width,
|
|
@@ -2068,8 +2352,20 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2068
2352
|
}
|
|
2069
2353
|
// Display view: the renderer that simulate-display-view.ts passes in reads this request's resolved state, so a bundle
|
|
2070
2354
|
// that only captures does not contain the display view.
|
|
2355
|
+
// #563: the view's expected signal through the lens, from the capture's own geometry (#548), checked to cover its farthest source point: the outer
|
|
2356
|
+
// site centre plus the kernel radius and the largest query offset.
|
|
2357
|
+
// #569: a catalogue lens's field-dependent PSF, read per field zone by the lens signal (display-view-lens-signal.ts, loaded on demand).
|
|
2358
|
+
const viewLensKit = displayView && lensGeometry ? await import("./display-view-lens-signal.js") : undefined;
|
|
2359
|
+
const displayViewLensPsf = viewLensKit && catalogue && lensPsf ?
|
|
2360
|
+
viewLensKit.createDisplayViewLensPsf(lensPsf.configuration.psf, catalogueKernelRadius, nativeWidth, nativeHeight, pitch) : undefined;
|
|
2361
|
+
if (displayViewLensPsf)
|
|
2362
|
+
assume("view.lensPsf", "model", { method: "piecewise-constant-field-zones", zoneSites: displayViewLensPsf.zoneSites, kernelRadiusSites: catalogueKernelRadius, scientificStatus: "approximation" }, "Each site takes the lens PSF at its field zone's centre, where the capture resolves it at each query point (docs/DISPLAY_VIEW.md, \"Catalogue lenses\").");
|
|
2363
|
+
const lensSignal = viewLensKit ? viewLensKit.prepareDisplayViewLensSignal({ geometry: lensGeometry,
|
|
2364
|
+
times: rotating ? Array.from({ length: temporalSampleCount }, (_, i) => (i + 0.5) * (shutterSeconds / temporalSampleCount)) : [0], distanceM: subjectDistanceM, metresPerFrameUnit,
|
|
2365
|
+
pitch, imagingArea, boxSamples, spots: antiAliasing ? antiAliasing.components : [{ offsetMicrometers: { x: 0, y: 0 } }], psf: displayViewLensPsf, zeroBeyondHorizon: catalogue?.kind === "fisheye" }, nativeWidth, nativeHeight, (diffraction ? diffraction.kernelRadiusSamples : 0) + (displayViewDefocus ? (bokehAxisKernel?.radius ?? defocusDiscKernelRadius(displayViewDefocus.diameterSites)) : 0) + catalogueKernelRadius +
|
|
2366
|
+
Math.max(0, ...queryOffsetsSites.map((o) => Math.max(Math.abs(o.x), Math.abs(o.y)))) - 0.5, catalogue ? "lens.catalogue" : imageSpaceOptions.join(", ")) : undefined;
|
|
2071
2367
|
if (displayView)
|
|
2072
|
-
return await renderView({ ahd, fastReport, antiAliasing, aspect, assume, assumptions, axisChannelResponse, boxSamples, buildProvider, callerScene, chart, materialLayers, chartReport, chartSize, diffraction, defocus: displayViewDefocus, diffractionPsf, fNumber, focalLengthMm, focusDistanceM, height, highlightMode, exposureGain, illuminanceLux, illuminant, illuminantOption, imagingArea, intentOf, metresPerFrameUnit, noiseSeed, outputOptions, parsedView, pitch, preset, provider, regimeByChannel, resolvedIso, isoReport, standardExposureDeviationStops: exposureDeviationStops, standardExposureIlluminanceLux: exposureIlluminanceLux, greyCardDeviationStops: exposureGreyCardDeviationStops, metering, sceneOption, scenePreset, shutterSeconds, started, subjectDistanceM, toneCurve, whiteBalanceOption, whiteBalanceState, width, checkAbort, margin, mode, nativeHeight, nativeWidth, sourceSha256, yieldControl, kelvinWhiteBalanceInput });
|
|
2368
|
+
return await renderView({ lens: lensSignal, lensPsf: displayViewLensPsf, ahd, fastReport, antiAliasing, aspect, assume, assumptions, axisChannelResponse, boxSamples, buildProvider, callerScene, chart, materialLayers, chartReport, chartSize, diffraction, defocus: displayViewDefocus, diffractionPsf, fNumber, focalLengthMm, focusDistanceM, height, highlightMode, exposureGain, illuminanceLux, illuminant, illuminantOption, imagingArea, intentOf, metresPerFrameUnit, noiseSeed, outputOptions, parsedView, pitch, preset, provider, regimeByChannel, resolvedIso, isoReport, standardExposureDeviationStops: exposureDeviationStops, standardExposureIlluminanceLux: exposureIlluminanceLux, greyCardDeviationStops: exposureGreyCardDeviationStops, metering, sceneOption, scenePreset, shutterSeconds, started, subjectDistanceM, toneCurve, whiteBalanceOption, whiteBalanceState, width, checkAbort, margin, mode, nativeHeight, nativeWidth, sourceSha256, yieldControl, kelvinWhiteBalanceInput });
|
|
2073
2369
|
// Ideal reference (2.3): the scene without camera effects, at the capture's own exposure, white balance and encoding.
|
|
2074
2370
|
if (idealReference) {
|
|
2075
2371
|
const referenceStarted = clockMs();
|
|
@@ -2191,20 +2487,52 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2191
2487
|
fail("serializeSimulatedCaptureDngFromParts writes the whole frame: the options take no output.region.");
|
|
2192
2488
|
const dngParts = run === "dng-from-parts" ? viewOption : undefined;
|
|
2193
2489
|
let tabulated;
|
|
2194
|
-
|
|
2490
|
+
// #568: lens.catalogue's fast method: tabulated expected electrons with the PSF blended over a field grid (catalogue-lens-signal.ts), for a
|
|
2491
|
+
// still camera, site-centre queries and a chart or material scene; verified per CFA phase against the exact chain within its tolerance.
|
|
2492
|
+
let catalogueFastRun;
|
|
2493
|
+
const catalogueFastEligible = catalogueFastRequested && catalogueTaps !== undefined && lensKit !== undefined && lensPsf !== undefined && dngParts === undefined && tabulatedScene &&
|
|
2494
|
+
!pupil && !rotating && boxSamples === 1 && !antiAliasing && distortion?.kind === "radial" && provider.chartMaterialAt !== undefined && provider.materialRadiance !== undefined;
|
|
2495
|
+
// The tabulated paths' channel response per spectral node and material tables (the fast catalogue path and the expected-signal path).
|
|
2496
|
+
const tabulatedTables = () => {
|
|
2195
2497
|
const { electronsPerRadiance, wavelengths } = axisChannelResponse("simulateCapture");
|
|
2196
|
-
const perNode = CHANNELS.map((c) => Float64Array.from(wavelengths, (nm) => electronsPerRadiance[c].get(nm) ?? 0));
|
|
2197
2498
|
const sceneMaterials = chart ? chart.materials : callerScene.materials, materialRadiance = provider.materialRadiance;
|
|
2198
|
-
|
|
2199
|
-
diffraction: diffraction && !defocusSiteKernels ? { radius: diffraction.kernelRadiusSamples, psf: diffractionPsf.configuration.psf, profileWavelengths: diffraction.profile.axes.wavelengthNm } : undefined,
|
|
2499
|
+
return { wavelengths, perNode: CHANNELS.map((c) => Float64Array.from(wavelengths, (nm) => electronsPerRadiance[c].get(nm) ?? 0)),
|
|
2200
2500
|
materials: { materialAt: provider.chartMaterialAt, whiteRadiance: (nm) => irradianceScale * illuminant.shape.evaluate(nm) / Math.PI,
|
|
2201
2501
|
list: [...sceneMaterials.map((m, index) => (m.kind === "neutral" ? { neutral: m.reflectance } : { radiance: (nm) => materialRadiance(index, nm) })),
|
|
2202
|
-
...(chartSize ? [{ neutral: chartSize.surroundReflectance }] : [])] }
|
|
2203
|
-
|
|
2502
|
+
...(chartSize ? [{ neutral: chartSize.surroundReflectance }] : [])] } };
|
|
2503
|
+
};
|
|
2504
|
+
if (catalogueFastEligible) {
|
|
2505
|
+
const { wavelengths, perNode, materials } = tabulatedTables();
|
|
2506
|
+
const { signalInput } = buildExpectedSignalInput({ nativeWidth, nativeHeight, width, wavelengths, perNode, apertureNodes: apertureNodeOffsets(1), boxSamples: 1, antiAliasing: undefined, pitch,
|
|
2507
|
+
materials, radianceAt: provider.radianceAt, imagingArea, focalLengthMm, focusDistanceM, subjectDistanceM, metresPerFrameUnit, caller: "simulateCapture" });
|
|
2508
|
+
const radial = distortion.profile;
|
|
2509
|
+
const signal = lensKit.createCatalogueLensSignal({ nativeWidth, nativeHeight, pitch, psf: lensPsf.configuration.psf, wavelengthsNm: wavelengths, electronsPerRadiance: perNode,
|
|
2510
|
+
scene: signalInput.scene, channelAt: signalInput.channelAt,
|
|
2511
|
+
distortion: { normalizationRadiusMm: radial.normalizationRadiusMm, coefficients: { k1: radial.coefficients.k1, k2: radial.coefficients.k2 ?? 0, k3: radial.coefficients.k3 ?? 0 } },
|
|
2512
|
+
...(vignetting ? { vignetting } : {}), projection: projection === "fisheye-equisolid-angle" ? projection : "rectilinear", outsideImageCircleSeesNothing: catalogue.kind === "fisheye",
|
|
2513
|
+
focalLengthMm, focusDistanceM, distanceM: subjectDistanceM, metresPerFrameUnit });
|
|
2514
|
+
tabulated = await renderTabulatedNativeRaw({ input: environmentRaw, region: regionNative, siteInput, evaluateRadiance: rawProvider().evaluateRadiance,
|
|
2515
|
+
electrons: signal, relativeTolerance: lensKit.CATALOGUE_FAST_VERIFICATION_TOLERANCE, onTile: rawTick, tileWidth, yieldControl: () => yieldControl(), checkAbort });
|
|
2516
|
+
if (tabulated)
|
|
2517
|
+
catalogueFastRun = { ...signal.grid, verificationRelativeDifference: tabulated.verificationRelativeDifference };
|
|
2518
|
+
}
|
|
2519
|
+
// #553: a cat's eye varies the kernel over the field, which the expected-signal tables (one kernel per node) do not take.
|
|
2520
|
+
else if (dngParts === undefined && tabulatedScene && !pupil && !lensFieldMapping && !vignetting && !lensPsf && !rotating && !bokehZones && (!diffraction || diffractionObjectSpace) && provider.chartMaterialAt && provider.materialRadiance) {
|
|
2521
|
+
const { wavelengths, perNode, materials: tabulatedMaterials } = tabulatedTables();
|
|
2522
|
+
const { signalInput: builtSignalInput } = buildExpectedSignalInput({ nativeWidth, nativeHeight, width, wavelengths, perNode, apertureNodes: apertureNodeOffsets(boxSamples), boxSamples, antiAliasing, pitch,
|
|
2523
|
+
diffraction: diffraction && !defocusSiteKernels ? { radius: diffraction.kernelRadiusSamples, psf: diffractionPsf.configuration.psf, profileWavelengths: diffraction.profile.axes.wavelengthNm } : undefined,
|
|
2524
|
+
materials: tabulatedMaterials, radianceAt: provider.radianceAt, imagingArea, focalLengthMm, focusDistanceM, subjectDistanceM, metresPerFrameUnit, caller: "simulateCapture" });
|
|
2204
2525
|
// #492: with lens.defocus the table convolves the lattice radiance with each node's defocus (or combined) kernel, on the
|
|
2205
2526
|
// lattice formula, exactly as the object-space provider does.
|
|
2206
2527
|
let signalInput = builtSignalInput;
|
|
2207
|
-
if (defocusSiteKernels) {
|
|
2528
|
+
if (defocusSiteKernels?.large) {
|
|
2529
|
+
// #564: the in-focus signal on the lattice formula (diffraction kernels, or a unit tap per node), then the disc by runs.
|
|
2530
|
+
signalInput = buildExpectedSignalInput({ nativeWidth, nativeHeight, width, wavelengths, perNode, apertureNodes: apertureNodeOffsets(boxSamples), boxSamples, antiAliasing, pitch,
|
|
2531
|
+
diffraction: diffraction ? { radius: diffraction.kernelRadiusSamples, psf: diffractionPsf.configuration.psf, profileWavelengths: diffraction.profile.axes.wavelengthNm } : undefined,
|
|
2532
|
+
largeDefocus: defocusSiteKernels.large.runs, materials: tabulatedMaterials,
|
|
2533
|
+
radianceAt: provider.radianceAt, imagingArea, focalLengthMm, focusDistanceM, subjectDistanceM, metresPerFrameUnit, caller: "simulateCapture" }).signalInput;
|
|
2534
|
+
}
|
|
2535
|
+
else if (defocusSiteKernels) {
|
|
2208
2536
|
const lattice = { ...builtSignalInput };
|
|
2209
2537
|
delete lattice.queryPoint;
|
|
2210
2538
|
signalInput = { ...lattice, kernels: { radius: defocusSiteKernels.radius, perNode: wavelengths.map((nm) => defocusSiteKernels.at(nm)) } };
|
|
@@ -2218,6 +2546,13 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2218
2546
|
let rawOutput;
|
|
2219
2547
|
if (dngParts !== undefined)
|
|
2220
2548
|
rawOutput = { raw: joinNativeRawParts(dngParts, plan, nativeWidth, nativeHeight), providerEvaluationCount: 0 };
|
|
2549
|
+
else if (tabulated && catalogueFastRun) {
|
|
2550
|
+
rawOutput = { raw: tabulated.raw, providerEvaluationCount: tabulated.providerEvaluationCount };
|
|
2551
|
+
fastApplied["lens.catalogue"] = true;
|
|
2552
|
+
fastReport = Object.freeze({ ...fastReport, applied: Object.freeze({ ...fastApplied }), assurance: "approximation-with-a-priori-bound" });
|
|
2553
|
+
for (const a of lensKit.catalogueFastAssumptions(catalogueFastRun))
|
|
2554
|
+
assume(a.id, "model", a.value, a.explanation);
|
|
2555
|
+
}
|
|
2221
2556
|
else if (tabulated) {
|
|
2222
2557
|
rawOutput = { raw: tabulated.raw, providerEvaluationCount: tabulated.providerEvaluationCount };
|
|
2223
2558
|
// The verification's relative difference itself is not reported: it is a last-bits quantity, and captures use the JavaScript
|
|
@@ -2470,7 +2805,8 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2470
2805
|
}
|
|
2471
2806
|
const ranOptions = new Set([...(pupil ? ["lens.pupilSampling"] : []), ...(projection !== undefined ? ["lens.projection"] : []),
|
|
2472
2807
|
...(distortion ? ["lens.distortion"] : []), ...(vignetting ? ["lens.vignetting"] : []), ...(lensPsf ? ["lens.psf"] : []),
|
|
2473
|
-
...(
|
|
2808
|
+
...(catalogue ? ["lens.catalogue"] : []),
|
|
2809
|
+
...(diffraction ? ["lens.diffraction"] : []), ...(defocusSiteKernels || catalogueDefocusApplied ? ["lens.defocus"] : []), ...(antiAliasing ? ["sensor.antiAliasing"] : []), ...(boxAperture ? ["sensor.pixelAperture.box"] : []),
|
|
2474
2810
|
...(temporalSampleCount > 1 ? ["sensor.temporalSampleCount"] : []), ...(rotating ? ["motion.rotation"] : []),
|
|
2475
2811
|
...(rollingSchedule ? ["motion.shutter.rolling"] : []), ...(mirrorBorder ? ["output.borderHandling.mirror"] : []), ...(region ? ["output.region"] : []),
|
|
2476
2812
|
...(needsAuto ? ["output.whiteBalance.auto"] : []), ...(ahd ? ["output.demosaic.ahd"] : []),
|