@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
|
@@ -53,7 +53,7 @@ import { renderTabulatedNativeRaw, TABULATED_RELATIVE_TOLERANCE } from "../captu
|
|
|
53
53
|
import { createDisplayViewExpectedSignal } from "./expected-signal.js";
|
|
54
54
|
import { buildExpectedSignalInput } from "./expected-signal-setup.js";
|
|
55
55
|
import { createNativeEnvironmentRawTask, createNativeEnvironmentRawRegionTask } from "../capture/native-environment-raw.js";
|
|
56
|
-
import { createNativeRawDevelopmentTask } from "../capture/native-raw-development.js";
|
|
56
|
+
import { createNativeRawDevelopmentTask, NATIVE_RAW_DEVELOPMENT_EXPOSURE_RANGE_STOPS } from "../capture/native-raw-development.js";
|
|
57
57
|
import { parseSdrRenderingProfile } from "../output/sdr-rendering.js";
|
|
58
58
|
import { createPrintJpegTask, PRINT_JPEG_LIMITS } from "../output/print-jpeg.js";
|
|
59
59
|
import { checkNativeRawIntegrity, prepareNativeRawDevelopment } from "../capture/native-raw-development.js";
|
|
@@ -75,8 +75,12 @@ 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
|
+
import { calculateDefocusFieldVariation, createDefocusImageSpaceProvider, prepareDefocusImageSpaceGeometry } from "./defocus-image-space-provider.js";
|
|
79
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";
|
|
80
84
|
import { resolveGenericPrimeLens } from "../equipment/generic-prime-lenses.js";
|
|
81
85
|
import { calculateChannelElectronsPerRadiance } from "./channel-response.js";
|
|
82
86
|
import { calculateSiteEffectiveSaturationCode } from "../capture/raw-white-level.js";
|
|
@@ -85,6 +89,7 @@ import { resolveAntiAliasingSpatialKernel } from "../sensor/optical-stack.js";
|
|
|
85
89
|
import { multiplyMatrices } from "../color/colorimetry-math.js";
|
|
86
90
|
import { prepareRgbGamutMapping } from "../color/gamut-mapping.js";
|
|
87
91
|
import { serializeNativeRawDng } from "../capture/native-raw-dng.js";
|
|
92
|
+
import { applyDiffractionCorrection, defaultDiffractionCorrectionRadiusPixels, designDiffractionCorrectionFilter, parseDiffractionCorrectionSettings, parseUnsharpMaskSettings, renderSdrWithUnsharpMask, DEVELOPMENT_DETAIL_MODEL_VERSION } from "../processing/development-detail.js";
|
|
88
93
|
import { parseSensorReadoutConversionProfile, resolveSensorReadoutRegime } from "../sensor/raw-readout.js";
|
|
89
94
|
import { resolveChartScene, chartMaterialInputs, CHART_SCENE_PRESETS } from "./chart-scenes.js";
|
|
90
95
|
import { resolveChartMaterialLayers } from "./chart-material-layers.js";
|
|
@@ -99,9 +104,9 @@ export const SIMULATE_CAPTURE_BOX_APERTURE_DEFAULT_SAMPLES = 4;
|
|
|
99
104
|
* a capture does. `motion.rotation` is a non-zero angular velocity; `sensor.temporalSampleCount` a count above 1.
|
|
100
105
|
*/
|
|
101
106
|
export const SIMULATE_CAPTURE_COST_OPTIONS = /* @__PURE__ */ Object.freeze(["lens.pupilSampling", "lens.projection", "lens.distortion", "lens.vignetting", "lens.psf",
|
|
102
|
-
"lens.diffraction", "lens.defocus", "sensor.antiAliasing", "sensor.pixelAperture.box", "sensor.temporalSampleCount", "motion.rotation", "motion.shutter.rolling", "output.borderHandling.mirror", "output.region",
|
|
107
|
+
"lens.diffraction", "lens.defocus", "lens.catalogue", "sensor.antiAliasing", "sensor.pixelAperture.box", "sensor.temporalSampleCount", "motion.rotation", "motion.shutter.rolling", "output.borderHandling.mirror", "output.region",
|
|
103
108
|
"output.whiteBalance.auto", "output.demosaic.ahd", "output.highlightMode.reconstruct", "output.colorEncoding.wide-gamut", "output.gamutMapping.perceptual",
|
|
104
|
-
"output.format.jpeg", "output.format.png", "output.format.dng"]);
|
|
109
|
+
"output.diffractionCorrection", "output.sharpening", "output.format.jpeg", "output.format.png", "output.format.dng"]);
|
|
105
110
|
/**
|
|
106
111
|
* The options that take the exact-or-fast flag (#395), in the path style of {@link SIMULATE_CAPTURE_COST_OPTIONS}: each accepts
|
|
107
112
|
* the record form with `fast` and is reported in `diagnostics.fast.applied` when it runs. Every one of them takes the flag,
|
|
@@ -583,7 +588,8 @@ async function developLinearSrgb(input, afterRow, ahd) {
|
|
|
583
588
|
const native = plan.exposure.geometry.nativeRaster, g = plan.exposure.resolvedGeometry, active = g.activeCapture.nativeRect;
|
|
584
589
|
checkNativeRawIntegrity(raw, native.pixelWidth, layout.repeatWidthSites, layout.repeatHeightSites, 0, native.pixelHeight);
|
|
585
590
|
const developer = createRawPixelDeveloper({ nativeWidth: native.pixelWidth, repeatWidth: layout.repeatWidthSites, repeatHeight: layout.repeatHeightSites,
|
|
586
|
-
codes: raw.codes, blackLevels: raw.blackLevels, saturationFlags: raw.saturationFlags, whiteByPhase: raw.effectiveSaturationCodes
|
|
591
|
+
codes: raw.codes, blackLevels: raw.blackLevels, saturationFlags: raw.saturationFlags, whiteByPhase: raw.effectiveSaturationCodes,
|
|
592
|
+
...(v.exposureGain === 1 ? {} : { exposureGain: v.exposureGain }) }, v.phases, v.development, v.mirror);
|
|
587
593
|
const width = v.raster.pixelWidth, height = v.raster.pixelHeight, rgb = new Float64Array(width * height * 3), row = new Float64Array(width * 3);
|
|
588
594
|
const at = (x, y) => {
|
|
589
595
|
const q = transformOrientedRasterPointToNative({ point: { x: v.crop.x + x + 0.5, y: v.crop.y + y + 0.5 }, nativeRaster: g.activeCapture.raster, orientation: plan.exposure.geometry.orientation });
|
|
@@ -612,7 +618,7 @@ async function developLinearSrgb(input, afterRow, ahd) {
|
|
|
612
618
|
async function developAhd(v, raw, repeatWidth, repeatHeight, width, height, at, afterRow, host) {
|
|
613
619
|
const native = v.plan.exposure.geometry.nativeRaster, W = native.pixelWidth, H = native.pixelHeight;
|
|
614
620
|
const source = { nativeWidth: W, repeatWidth, repeatHeight, codes: raw.codes, blackLevels: raw.blackLevels,
|
|
615
|
-
saturationFlags: raw.saturationFlags, whiteByPhase: raw.effectiveSaturationCodes };
|
|
621
|
+
saturationFlags: raw.saturationFlags, whiteByPhase: raw.effectiveSaturationCodes, ...(v.exposureGain === 1 ? {} : { exposureGain: v.exposureGain }) };
|
|
616
622
|
const samples = new Float32Array(W * H), saturated = new Uint8Array(W * H);
|
|
617
623
|
for (let y = 0; y < H; y++)
|
|
618
624
|
for (let x = 0; x < W; x++) {
|
|
@@ -831,20 +837,38 @@ export function parseDisplayViewOption(value, frameWidth, frameHeight) {
|
|
|
831
837
|
function resolveDefocusOption(input) {
|
|
832
838
|
const { run, assume, focalLengthMm, fNumber, focusDistanceM, subjectDistanceM, pitch } = input;
|
|
833
839
|
const disc = resolveDefocusDisc({ focalLengthMm, aperture: fNumber, focusDistanceM, subjectDistanceM, pitchMicrometers: pitch });
|
|
834
|
-
const size = disc.diameterMicrometers + " µm (" + disc.diameterSites + " sites of " + pitch + " µm)";
|
|
835
840
|
let radius;
|
|
836
841
|
try {
|
|
837
842
|
radius = requireDefocusDiscKernelRadius(disc.diameterSites);
|
|
838
843
|
}
|
|
839
|
-
catch
|
|
840
|
-
fail("lens.defocus:
|
|
844
|
+
catch {
|
|
845
|
+
fail("lens.defocus: " + defocusDiscLimitMessage(disc.diameterSites, disc.diameterMicrometers));
|
|
841
846
|
}
|
|
842
|
-
|
|
847
|
+
let value = { model: "geometric-disc", scientificStatus: "approximation", diameterMicrometers: disc.diameterMicrometers, diameterSites: disc.diameterSites,
|
|
843
848
|
kernelRadiusSites: disc.diameterSites === 0 ? null : radius, fNumber, focusDistanceM, subjectDistanceM, samplePitchMicrometers: pitch,
|
|
844
849
|
defocusCircleModel: { id: "thin-lens-defocus-circle", version: "1.1.0" } };
|
|
845
|
-
|
|
850
|
+
let model = "Geometric defocus of the flat scene plane (an approximation): the thin-lens blur circle of an ideal uniform circular pupil, " +
|
|
846
851
|
"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). " +
|
|
847
852
|
"No diffraction of the defocused wave, aberrations or pupil vignetting. ";
|
|
853
|
+
// #553: the bokeh options. Out of focus, where a kernel is applied, the shaped kernel replaces the disc and its record joins the value.
|
|
854
|
+
let kernelRadius = radius;
|
|
855
|
+
if (input.bokehOptions) {
|
|
856
|
+
value = { ...value, bokeh: { options: input.bokehOptions } };
|
|
857
|
+
if (disc.diameterSites > 0 && run !== "ideal-reference" && input.bokeh) {
|
|
858
|
+
const shaped = input.bokeh(disc);
|
|
859
|
+
value = { ...value, bokeh: shaped.value };
|
|
860
|
+
model += shaped.explanation;
|
|
861
|
+
kernelRadius = shaped.value.kernelRadiusSites;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
// #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,
|
|
865
|
+
// so those records are unchanged.
|
|
866
|
+
if (kernelRadius > DEFOCUS_DIRECT_KERNEL_MAXIMUM_RADIUS_SITES) {
|
|
867
|
+
value = { ...value, convolution: "runs" };
|
|
868
|
+
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, " +
|
|
869
|
+
"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. " +
|
|
870
|
+
"That is the same sum as the direct convolution, regrouped (exact up to rounding, not an approximation). ";
|
|
871
|
+
}
|
|
848
872
|
if (disc.diameterSites === 0) {
|
|
849
873
|
assume("lens.defocus", "model", value, model + "In focus (lens.focusDistanceM equals subjectDistanceM): the disc has zero diameter, so no defocus kernel is applied.");
|
|
850
874
|
return undefined;
|
|
@@ -853,13 +877,38 @@ function resolveDefocusOption(input) {
|
|
|
853
877
|
assume("lens.defocus", "model", value, model + "Resolved but not applied: the ideal reference has no camera effects.");
|
|
854
878
|
return undefined;
|
|
855
879
|
}
|
|
856
|
-
// The display view (#492, step 2b): a site kernel in its expected-signal tables (simulate-display-view.ts)
|
|
857
|
-
|
|
880
|
+
// The display view (#492, step 2b): a site kernel in its expected-signal tables (simulate-display-view.ts); with a lens field mapping,
|
|
881
|
+
// vignetting or rotation (#563) applied in image space as in the capture, so it records the capture's assumption below.
|
|
882
|
+
if (run === "display-view" && !input.imageSpace) {
|
|
858
883
|
assume("lens.defocus", "model", { ...value, execution: "expected-signal-site-kernel", combinedWithDiffraction: input.diffraction }, model +
|
|
859
884
|
"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 " +
|
|
860
885
|
"diffraction kernel convolved with the disc, a further approximation)" : "") + "; see the view.defocus assumption.");
|
|
861
886
|
return disc;
|
|
862
887
|
}
|
|
888
|
+
// #548: with a lens field mapping, vignetting or camera rotation the capture applies the disc in image space, on the sensor's site lattice.
|
|
889
|
+
if (input.imageSpace) {
|
|
890
|
+
const { options, fieldVariation } = input.imageSpace(disc), v = fieldVariation, bounds = [];
|
|
891
|
+
if (v.rotationMaximumDiameterChangeSites !== null)
|
|
892
|
+
bounds.push("camera rotation: pitch or yaw tilts the scene plane in the rotated camera, so the disc of a scene point's " +
|
|
893
|
+
"axial depth there differs from the disc used by at most " + formatBound(v.rotationMaximumDiameterChangeSites) + " sites over the rendered instants (0 for roll alone)");
|
|
894
|
+
if (v.distortionMaximumLocalScaleDeviation !== null)
|
|
895
|
+
bounds.push("distortion: its local scale departs from 1 by at most " + formatBound(v.distortionMaximumLocalScaleDeviation) +
|
|
896
|
+
" (fraction), the relative disc-size difference to a model that blurs the undistorted image and then distorts it");
|
|
897
|
+
if (v.projectionMaximumDiameterChangeSites !== null)
|
|
898
|
+
bounds.push("non-rectilinear projection: the disc of the distance along each scene point's ray, instead of its axial depth, " +
|
|
899
|
+
"differs by at most " + formatBound(v.projectionMaximumDiameterChangeSites) + " sites (a real lens of that projection may lie anywhere between the two)");
|
|
900
|
+
assume("lens.defocus", "model", { ...value, execution: "image-space-convolution", combinedWithDiffraction: input.diffraction, imageSpaceOptions: [...options],
|
|
901
|
+
fieldVariation: { ...fieldVariation } }, model +
|
|
902
|
+
"Applied as an image-space convolution (#548, with " + options.join(", ") + "): for each spectral node (and, with camera rotation, each temporal midpoint, in that instant's camera frame) " +
|
|
903
|
+
"the sensor-plane irradiance before any PSF, through the lens field mapping and the field throughput, is formed on the site lattice of the sensor and convolved with the disc, " +
|
|
904
|
+
"the disc of the same diameter at every field point, centred on each point's chief ray. This is the environment PSF quadrature with the disc as a sampled PSF, summed in another order " +
|
|
905
|
+
"(a test checks it to 1e-12 per site). Vignetting is applied at each scene point before the disc spreads its light (field throughput, as for any PSF); the disc does not take the " +
|
|
906
|
+
"cat's-eye shape of mechanical vignetting. " + (input.diffraction ? "With lens.diffraction, each spectral node's diffraction kernel is convolved with the disc and applied as one " +
|
|
907
|
+
"image-space kernel, a further approximation (the defocused wave PSF is not exactly the Airy pattern convolved with the disc). " : "") +
|
|
908
|
+
(bounds.length > 0 ? "Field-variation bounds of the constant disc, on a " + v.gridPointsPerSide + " × " + v.gridPointsPerSide + " grid over the frame: " + bounds.join("; ") + ". " : "") +
|
|
909
|
+
"The disc kernel is a sampling of the disc on the site lattice, as on the object-space path.");
|
|
910
|
+
return disc;
|
|
911
|
+
}
|
|
863
912
|
// The capture (#492, step 2a): an object-space convolution of the scene radiance on the site lattice.
|
|
864
913
|
assume("lens.defocus", "model", { ...value, execution: "object-space-convolution", combinedWithDiffraction: input.diffraction }, model +
|
|
865
914
|
"Applied as an object-space convolution: the scene radiance on the site lattice is convolved with the disc, the same kernel at every spectral node, " +
|
|
@@ -869,6 +918,10 @@ function resolveDefocusOption(input) {
|
|
|
869
918
|
"The disc kernel is a sampling of the disc on the site lattice; lens.pupilSampling (ray-sampled, no diffraction) is the independent reference.");
|
|
870
919
|
return disc;
|
|
871
920
|
}
|
|
921
|
+
/** A field-variation bound for an explanation: 3 significant digits (the assumption value keeps the full number). */
|
|
922
|
+
function formatBound(value) {
|
|
923
|
+
return value === 0 ? "0" : String(Number(value.toPrecision(3)));
|
|
924
|
+
}
|
|
872
925
|
/** Internal: passed as the view of a "display-view" run to resolve the request without a view (`prepareDisplayView`, #387). */
|
|
873
926
|
export const DISPLAY_VIEW_WITHOUT_VIEW = Symbol("display view without view");
|
|
874
927
|
/**
|
|
@@ -905,7 +958,8 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
905
958
|
preset = parseCallerCameraPreset(cameraOption);
|
|
906
959
|
const body = preset.body, lens = preset.lens;
|
|
907
960
|
// Lens
|
|
908
|
-
const lensOptions = allowKeys(top.lens, "lens", ["focalLengthMm", "fNumber", "focusDistanceM", "pupilSampling", "projection", "distortion", "vignetting", "psf", "diffraction", "prime", "defocus"
|
|
961
|
+
const lensOptions = allowKeys(top.lens, "lens", ["focalLengthMm", "fNumber", "focusDistanceM", "pupilSampling", "projection", "distortion", "vignetting", "psf", "diffraction", "prime", "defocus",
|
|
962
|
+
"catalogue", "focusScatter"]);
|
|
909
963
|
// Generic prime (2.3): resolved on the simulated frame's size, which the output and sensor sections validate again below.
|
|
910
964
|
let prime;
|
|
911
965
|
if (lensOptions.prime !== undefined) {
|
|
@@ -920,22 +974,49 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
920
974
|
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 +
|
|
921
975
|
", 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.");
|
|
922
976
|
}
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
977
|
+
// Generic lens catalogue (#549): resolved on the simulated frame like the prime; its character is built once the f-number is known.
|
|
978
|
+
let catalogue, catalogueFrame;
|
|
979
|
+
let lensKit;
|
|
980
|
+
if (lensOptions.catalogue !== undefined) {
|
|
981
|
+
for (const key of ["prime", "focalLengthMm", "projection", "distortion", "vignetting", "psf", "diffraction", "pupilSampling"])
|
|
982
|
+
if (lensOptions[key] !== undefined)
|
|
983
|
+
fail("lens.catalogue cannot be combined with lens." + key + ": the catalogue lens sets its own focal length, projection, distortion, vignetting and PSF (with diffraction).");
|
|
984
|
+
if (displayView)
|
|
985
|
+
fail("simulateDisplayView does not support lens.catalogue: the catalogue lens's field-dependent PSF, distortion and vignetting run through the capture's environment quadrature.");
|
|
986
|
+
const o = (typeof top.output === "object" && top.output !== null ? top.output : {}), sn = (typeof top.sensor === "object" && top.sensor !== null ? top.sensor : {});
|
|
987
|
+
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");
|
|
988
|
+
catalogueFrame = { widthMm: frameW * framePitch / 1000, heightMm: frameH * framePitch / 1000 };
|
|
989
|
+
lensKit = await import("./generic-lens-capture.js");
|
|
990
|
+
catalogue = lensKit.resolveGenericLens({ lensId: lensOptions.catalogue, activeImagingArea: catalogueFrame });
|
|
991
|
+
assume("lens.catalogue", "model", { lensId: catalogue.lensId, name: catalogue.name, set: catalogue.set, kind: catalogue.kind, equivalentFocalLengthMm: catalogue.equivalentFocalLengthMm,
|
|
992
|
+
focalLengthMm: catalogue.focalLengthMm, cropFactor35Mm: catalogue.cropFactor35Mm, widestFNumber: catalogue.widestFNumber, narrowestFNumber: catalogue.narrowestFNumber,
|
|
993
|
+
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/" +
|
|
994
|
+
catalogue.widestFNumber + "–f/" + catalogue.narrowestFNumber + ", minimum focus max(0.10 m, f·(1 + 1/" + catalogue.maximumMagnification + ")) (thin lens). Its character is a labelled " +
|
|
995
|
+
"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.");
|
|
996
|
+
}
|
|
997
|
+
else if (lensOptions.focusScatter !== undefined)
|
|
998
|
+
fail("lens.focusScatter needs lens.catalogue: the focus scatter is a catalogue lens's own.");
|
|
999
|
+
const lensRange = prime ? { id: prime.lensId, focalLengthMm: prime.focalLengthMm, widestFNumber: prime.widestFNumber, narrowestFNumber: prime.narrowestFNumber } :
|
|
1000
|
+
catalogue ? { id: catalogue.lensId, focalLengthMm: catalogue.focalLengthMm, widestFNumber: catalogue.widestFNumber, narrowestFNumber: catalogue.narrowestFNumber } : undefined;
|
|
1001
|
+
const lensExposure = lensRange ? { ...lens.exposure, profileId: "photivra-" + lensRange.id + ":exposure",
|
|
1002
|
+
focalLengthMmRange: { ...lens.exposure.focalLengthMmRange, value: { minimum: lensRange.focalLengthMm, maximum: lensRange.focalLengthMm } },
|
|
1003
|
+
aperture: { ...lens.exposure.aperture, widestAvailableFNumber: { kind: "constant", fNumber: { value: lensRange.widestFNumber, evidence: FACADE_EVIDENCE } },
|
|
1004
|
+
narrowestAvailableFNumber: { value: lensRange.narrowestFNumber, evidence: FACADE_EVIDENCE } } } : lens.exposure;
|
|
927
1005
|
const focalDefault = lensExposure.focalLengthMmRange.value.minimum;
|
|
928
|
-
const focalLengthMm =
|
|
1006
|
+
const focalLengthMm = lensRange ? lensRange.focalLengthMm :
|
|
929
1007
|
positive(pick(lensOptions, "focalLengthMm", "lens.focalLengthMm", focalDefault, "The preset lens's focal length (the generic primes are 50 mm)."), "lens.focalLengthMm");
|
|
930
1008
|
const capabilities = resolveGenericEquipmentExposureCapabilities({ bodyProfile: body.exposure, lensProfile: lensExposure, selectedFocalLengthMm: focalLengthMm });
|
|
931
1009
|
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");
|
|
932
1010
|
if (subjectDistanceM * 1000 <= focalLengthMm * 2)
|
|
933
1011
|
fail("subjectDistanceM must exceed twice the focal length.");
|
|
934
|
-
|
|
1012
|
+
let focusDistanceM = positive(pick(lensOptions, "focusDistanceM", "lens.focusDistanceM", subjectDistanceM, "Focused on the scene plane."), "lens.focusDistanceM");
|
|
935
1013
|
if (focusDistanceM * 1000 <= focalLengthMm)
|
|
936
1014
|
fail("lens.focusDistanceM must exceed the focal length.");
|
|
937
1015
|
if (prime && (subjectDistanceM < prime.minimumFocusDistanceM || focusDistanceM < prime.minimumFocusDistanceM))
|
|
938
1016
|
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.");
|
|
1017
|
+
if (catalogue && (subjectDistanceM < catalogue.minimumFocusDistanceM || focusDistanceM < catalogue.minimumFocusDistanceM))
|
|
1018
|
+
fail("The " + catalogue.name + " focuses no closer than " + catalogue.minimumFocusDistanceM + " m (max(0.10 m, f·(1 + 1/" + catalogue.maximumMagnification +
|
|
1019
|
+
"))); subjectDistanceM and lens.focusDistanceM must be at least that.");
|
|
939
1020
|
let pupil;
|
|
940
1021
|
if (lensOptions.pupilSampling === undefined)
|
|
941
1022
|
assume("lens.pupilSampling", "default", null, lensOptions.defocus === undefined ?
|
|
@@ -949,7 +1030,8 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
949
1030
|
fail("lens.pupilSampling needs at most 256 rays (radialSamples × angularSamples).");
|
|
950
1031
|
}
|
|
951
1032
|
// 2.0 optics hooks: only what the environment site contract accepts.
|
|
952
|
-
const projection = lensOptions.projection === undefined ?
|
|
1033
|
+
const projection = lensOptions.projection === undefined ? (catalogue && catalogue.projection !== "rectilinear" ? catalogue.projection : undefined) :
|
|
1034
|
+
parseLensProjectionModel(lensOptions.projection);
|
|
953
1035
|
let distortion;
|
|
954
1036
|
if (lensOptions.distortion !== undefined) {
|
|
955
1037
|
const d = allowKeys(lensOptions.distortion, "lens.distortion", ["kind", "profile"]);
|
|
@@ -957,7 +1039,11 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
957
1039
|
fail("lens.distortion.kind must be radial or brown-conrady.");
|
|
958
1040
|
distortion = d;
|
|
959
1041
|
}
|
|
960
|
-
|
|
1042
|
+
// #549: a catalogue lens's distortion (and the fisheyes' projection) is set once its field reach is known (below); the mapping
|
|
1043
|
+
// stands in for it in the combination checks until then.
|
|
1044
|
+
if (catalogue)
|
|
1045
|
+
distortion = { kind: "radial", profile: lensKit.genericLensDistortionProfile(catalogue.character.distortion.corner, catalogue.halfDiagonalMm) };
|
|
1046
|
+
let lensFieldMapping = projection === undefined && distortion === undefined ? undefined :
|
|
961
1047
|
{ projectionModel: projection ?? "rectilinear", distortion: distortion ?? { kind: "none" }, radiometricAreaCorrection: "inverse-distortion-jacobian" };
|
|
962
1048
|
if (lensFieldMapping) {
|
|
963
1049
|
if (pupil)
|
|
@@ -1007,16 +1093,36 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1007
1093
|
fail("lens.diffraction cannot be combined with lens.pupilSampling: the environment contract does not mix a sampled PSF with pupil rays.");
|
|
1008
1094
|
}
|
|
1009
1095
|
// Defocus (#492): validated here, resolved once the f-number is.
|
|
1010
|
-
let defocusModel;
|
|
1096
|
+
let defocusModel, defocusBokeh;
|
|
1011
1097
|
if (lensOptions.defocus !== undefined) {
|
|
1012
|
-
const record = allowKeys(lensOptions.defocus, "lens.defocus", ["model"]);
|
|
1098
|
+
const record = allowKeys(lensOptions.defocus, "lens.defocus", ["model", "blades", "sphericalAberration", "catsEye"]);
|
|
1013
1099
|
if (record.model !== "geometric-disc")
|
|
1014
1100
|
fail("lens.defocus.model must be \"geometric-disc\".");
|
|
1101
|
+
// #553: the bokeh options; none given keeps the disc of #492 (byte-identical requests).
|
|
1102
|
+
if (record.blades !== undefined || record.sphericalAberration !== undefined || record.catsEye !== undefined) {
|
|
1103
|
+
const bokeh = { ...record };
|
|
1104
|
+
delete bokeh.model;
|
|
1105
|
+
try {
|
|
1106
|
+
defocusBokeh = resolveDefocusBokehOptions(bokeh, "lens.defocus");
|
|
1107
|
+
}
|
|
1108
|
+
catch (error) {
|
|
1109
|
+
fail(error.message);
|
|
1110
|
+
}
|
|
1111
|
+
if (defocusBokeh.catsEye && diffractionRadiusOption !== undefined)
|
|
1112
|
+
fail("lens.defocus.catsEye cannot be combined with lens.diffraction: the cat's eye varies over the field, " +
|
|
1113
|
+
"so each field zone would need its own diffraction kernel convolved with its clipped pupil at every spectral node. Omit one of the options.");
|
|
1114
|
+
}
|
|
1015
1115
|
if (pupil)
|
|
1016
1116
|
fail("lens.defocus cannot be combined with lens.pupilSampling: the pupil rays already render defocus, so it would be counted twice.");
|
|
1017
1117
|
if (lensPsf)
|
|
1018
1118
|
fail("lens.defocus cannot be combined with lens.psf: a caller PSF declares its own defocus, so it could be counted twice.");
|
|
1019
|
-
|
|
1119
|
+
// #549: with a catalogue lens the disc is convolved into the lens's PSF (below), not applied by the lens.defocus paths. The bokeh
|
|
1120
|
+
// options (#553) shape the lens.defocus kernel only, so they are refused rather than dropped (a follow-up for a later release).
|
|
1121
|
+
if (catalogue && defocusBokeh)
|
|
1122
|
+
fail("lens.defocus.blades, sphericalAberration and catsEye cannot be combined with lens.catalogue: the catalogue lens " +
|
|
1123
|
+
"folds a plain disc into its own PSF. Omit the bokeh options, or use lens.focalLengthMm instead of the catalogue lens.");
|
|
1124
|
+
if (!catalogue)
|
|
1125
|
+
defocusModel = "geometric-disc";
|
|
1020
1126
|
}
|
|
1021
1127
|
// Motion (2.0): constant camera rotation and the shutter type.
|
|
1022
1128
|
const motionOptions = allowKeys(top.motion, "motion", ["angularVelocityRadPerSec", "shutter", "rollingShutterSkewSeconds"]);
|
|
@@ -1042,8 +1148,8 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1042
1148
|
// through one linear map (still camera, rectilinear, no distortion or field throughput); otherwise per tap.
|
|
1043
1149
|
const diffractionObjectSpace = diffractionRadiusOption !== undefined && !rotating && !lensFieldMapping && !vignetting;
|
|
1044
1150
|
// Output and sensor
|
|
1045
|
-
const outputOptions = allowKeys(top.output, "output", ["width", "height", "format", "toneCurve", "toneCurveParameters", "highlightMode", "whiteBalance", "jpegQuantizationStep",
|
|
1046
|
-
"colorEncoding", "bitDepth", "gamutMapping", "demosaic", "borderHandling", "dng", "maximumPixels", "region", "metadata"]);
|
|
1151
|
+
const outputOptions = allowKeys(top.output, "output", ["width", "height", "format", "toneCurve", "toneCurveParameters", "highlightMode", "exposureCompensationStops", "whiteBalance", "jpegQuantizationStep",
|
|
1152
|
+
"colorEncoding", "bitDepth", "gamutMapping", "demosaic", "borderHandling", "dng", "maximumPixels", "region", "metadata", "diffractionCorrection", "sharpening"]);
|
|
1047
1153
|
// Opt-in large images: a caller-raised native pixel limit. Without it the 4094-pixel sides are unchanged.
|
|
1048
1154
|
const raisedPixels = outputOptions.maximumPixels === undefined ? undefined : integerIn(outputOptions.maximumPixels, "output.maximumPixels", 1, NATIVE_RAW_LIMITS.ceilingPixels);
|
|
1049
1155
|
const maximumSide = raisedPixels === undefined ? 4094 : NATIVE_RAW_LIMITS.maximumDimension;
|
|
@@ -1097,6 +1203,19 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1097
1203
|
if (linearPath && toneCurve !== "identity")
|
|
1098
1204
|
fail("output.toneCurve other than identity is available only for sRGB output; wide-gamut encodings are converted from the linear developed colour.");
|
|
1099
1205
|
const highlightMode = pick(outputOptions, "highlightMode", "output.highlightMode", DEFAULTS.highlightMode, "Saturated RAW highlights are clipped to neutral.");
|
|
1206
|
+
// #556: development exposure push, a gain on the normalized RAW before white balance. Opt-in: no assumption when omitted.
|
|
1207
|
+
let exposureCompensationStops = 0;
|
|
1208
|
+
if (outputOptions.exposureCompensationStops !== undefined) {
|
|
1209
|
+
const stops = outputOptions.exposureCompensationStops, range = NATIVE_RAW_DEVELOPMENT_EXPOSURE_RANGE_STOPS;
|
|
1210
|
+
if (typeof stops !== "number" || !Number.isFinite(stops) || stops < range.minimum || stops > range.maximum)
|
|
1211
|
+
fail("output.exposureCompensationStops must be a finite number from -2 to +5.");
|
|
1212
|
+
exposureCompensationStops = stops;
|
|
1213
|
+
assume("output.exposureCompensationStops", "model", { stops, gain: 2 ** stops, appliedTo: "black-subtracted white-normalized RAW, before demosaic and white balance",
|
|
1214
|
+
dng: format === "dng" ? "unmodified RAW (the push is a development setting)" : null }, "Development exposure push: each site's (code − black)/(white − black) is multiplied by 2^stops = " + 2 ** stops + " before demosaic, white balance, the highlight mode, colour, the tone curve and encoding, " +
|
|
1215
|
+
"like a raw editor's exposure slider. It is not a capture exposure: no photons are added, so raised shadows show the RAW's real shot, read and dark noise and its ADC quantization steps, scaled by the gain. " +
|
|
1216
|
+
"A site pushed to or past white clips at white and counts as saturated (highlightMode \"reconstruct\" rolls it off). The RAW codes are unchanged" + (format === "dng" ? ", and the DNG holds the unmodified RAW." : "."));
|
|
1217
|
+
}
|
|
1218
|
+
const exposureGain = exposureCompensationStops === 0 ? 1 : 2 ** exposureCompensationStops;
|
|
1100
1219
|
const whiteBalanceOption = pick(outputOptions, "whiteBalance", "output.whiteBalance", DEFAULTS.whiteBalance, "White balance measured from a perfect white diffuser under the scene illuminant (a custom/grey-card white balance).");
|
|
1101
1220
|
const jpegStep = format === "jpeg" ? integerIn(pick(outputOptions, "jpegQuantizationStep", "output.jpegQuantizationStep", DEFAULTS.jpegQuantizationStep, "Constant JPEG quantization step 2 (not a manufacturer quality scale)."), "output.jpegQuantizationStep", 1, 255) : undefined;
|
|
1102
1221
|
if (format !== "jpeg" && outputOptions.jpegQuantizationStep !== undefined)
|
|
@@ -1128,20 +1247,20 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1128
1247
|
}
|
|
1129
1248
|
}
|
|
1130
1249
|
const boxAperture = sensorOptions.pixelAperture !== undefined && sensorOptions.pixelAperture !== "point";
|
|
1250
|
+
// The environment queries each site at each aperture node minus each anti-aliasing split offset (sites, +x right, +y down).
|
|
1251
|
+
const 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 })));
|
|
1131
1252
|
// Display view: its own parameters, and the capture options it reproduces, checked before any scene work.
|
|
1132
1253
|
const parsedView = displayView && viewOption !== DISPLAY_VIEW_WITHOUT_VIEW ? parseDisplayViewOption(viewOption, width, height) : undefined;
|
|
1133
1254
|
if (displayView) {
|
|
1134
1255
|
const unsupported = (option, reason) => fail("simulateDisplayView does not support " + option + ": " + reason);
|
|
1135
1256
|
if (pupil)
|
|
1136
1257
|
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.");
|
|
1137
|
-
if (lensFieldMapping)
|
|
1138
|
-
unsupported("lens.projection or lens.distortion", "the expected signal assumes the ideal rectilinear, distortion-free mapping of the site lattice.");
|
|
1139
|
-
if (vignetting)
|
|
1140
|
-
unsupported("lens.vignetting", "the expected signal assumes the same radiance-to-irradiance factor at every site.");
|
|
1141
1258
|
if (lensPsf)
|
|
1142
1259
|
unsupported("lens.psf", "only the engine-built diffraction kernels (lens.diffraction) are tabulated.");
|
|
1143
|
-
|
|
1144
|
-
|
|
1260
|
+
// #563: projection, distortion, vignetting and rotation are supported (display-view-lens-signal.ts), except rotation under a rolling shutter.
|
|
1261
|
+
if (rotating && shutterKind === "rolling")
|
|
1262
|
+
unsupported("camera rotation with motion.shutter \"rolling\"", "every row has its own instants, so the view's " +
|
|
1263
|
+
"scene points of one instant would mix rows exposed at other times; use motion.shutter \"global\" for the view.");
|
|
1145
1264
|
if (outputOptions.region !== undefined)
|
|
1146
1265
|
unsupported("output.region", "pass the rectangle as view.region, in display pixels.");
|
|
1147
1266
|
if (outputOptions.whiteBalance === "auto")
|
|
@@ -1150,6 +1269,11 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1150
1269
|
unsupported("output.toneCurve " + String(outputOptions.toneCurve), "luminance tone curves couple the channels; the view supports identity and positive-reinhard-per-channel.");
|
|
1151
1270
|
if (outputOptions.highlightMode === "reconstruct")
|
|
1152
1271
|
unsupported("output.highlightMode \"reconstruct\"", "the view supports the per-channel highlight clip.");
|
|
1272
|
+
if (outputOptions.diffractionCorrection !== undefined || outputOptions.sharpening !== undefined)
|
|
1273
|
+
unsupported("output.diffractionCorrection or output.sharpening", "the development detail stages filter the whole developed capture image.");
|
|
1274
|
+
if (defocusBokeh?.catsEye)
|
|
1275
|
+
unsupported("lens.defocus.catsEye", "the expected-signal tables hold one kernel per spectral node, and the cat's eye varies over the field; " +
|
|
1276
|
+
"simulateCapture renders it. lens.defocus.blades and lens.defocus.sphericalAberration are supported.");
|
|
1153
1277
|
}
|
|
1154
1278
|
const colorSamplingProfile = { schemaVersion: "0.1.0", profileId: IDS.cfa, evidence: FACADE_EVIDENCE, coordinateSystem: "native-sensor-color-sampling-site-index",
|
|
1155
1279
|
layout: { kind: "periodic-mosaic", repeatWidthSites: 2, repeatHeightSites: 2, siteChannelIds: ["red", "green", "green", "blue"], anchor: "native-sensor-top-left-site" } };
|
|
@@ -1218,6 +1342,37 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1218
1342
|
// region results without diffraction stay byte-identical to 2.5.0.
|
|
1219
1343
|
(diffractionRadiusOption !== undefined ? "With lens.diffraction the object-space convolution (when it applies) covers only the lattice the region's native sites query (plus the kernel radius), in bands of at most 32 native rows; its values are the whole render's." :
|
|
1220
1344
|
"With lens.diffraction the object-space convolution runs over whole-width bands of 32 native rows that the region touches."));
|
|
1345
|
+
// Development detail (#552): opt-in sharpening and diffraction correction, validated before any scene work.
|
|
1346
|
+
let sharpening, diffractionCorrectionOption;
|
|
1347
|
+
if (outputOptions.diffractionCorrection !== undefined || outputOptions.sharpening !== undefined) {
|
|
1348
|
+
if (idealReference)
|
|
1349
|
+
fail("simulateIdealReference does not take output.diffractionCorrection or output.sharpening: a reference has no camera development.");
|
|
1350
|
+
if (region)
|
|
1351
|
+
fail("output.diffractionCorrection and output.sharpening are not combinable with output.region: they filter across the whole developed image, so a region's edge pixels would differ from the whole render.");
|
|
1352
|
+
}
|
|
1353
|
+
if (outputOptions.diffractionCorrection !== undefined) {
|
|
1354
|
+
if (diffractionRadiusOption === undefined)
|
|
1355
|
+
fail("output.diffractionCorrection needs lens.diffraction: it inverts the diffraction blur that option simulates.");
|
|
1356
|
+
const o = allowKeys(outputOptions.diffractionCorrection, "output.diffractionCorrection", ["wavelengthNm", "regularization", "maximumGain", "kernelRadiusPixels"]);
|
|
1357
|
+
const parsed = parseDiffractionCorrectionSettings({
|
|
1358
|
+
wavelengthNm: pick(o, "wavelengthNm", "output.diffractionCorrection.wavelengthNm", 550, "Design wavelength 550 nm, near the photopic peak, for all three channels: the real blur is polychromatic, and longer wavelengths have a lower cut-off than the one used here."),
|
|
1359
|
+
regularization: pick(o, "regularization", "output.diffractionCorrection.regularization", 0.01, "Regularization α = 0.01: an assumed noise-to-signal power ratio of 1 %, a first-party choice, not a measurement of this capture's noise."),
|
|
1360
|
+
maximumGain: pick(o, "maximumGain", "output.diffractionCorrection.maximumGain", 4, "Gain bound 4: no frequency is amplified more than 4×, which bounds the noise gain."),
|
|
1361
|
+
kernelRadiusPixels: o.kernelRadiusPixels === undefined ? 1 : o.kernelRadiusPixels
|
|
1362
|
+
}, "output.diffractionCorrection");
|
|
1363
|
+
diffractionCorrectionOption = { wavelengthNm: parsed.wavelengthNm, regularization: parsed.regularization, maximumGain: parsed.maximumGain,
|
|
1364
|
+
...(o.kernelRadiusPixels === undefined ? {} : { kernelRadiusPixels: parsed.kernelRadiusPixels }) };
|
|
1365
|
+
}
|
|
1366
|
+
if (outputOptions.sharpening !== undefined) {
|
|
1367
|
+
if (linearPath)
|
|
1368
|
+
fail("output.sharpening runs on the sRGB SDR path only, not with a wide-gamut output.colorEncoding or output.gamutMapping \"perceptual\".");
|
|
1369
|
+
const o = allowKeys(outputOptions.sharpening, "output.sharpening", ["radiusPixels", "amount", "threshold"]);
|
|
1370
|
+
sharpening = parseUnsharpMaskSettings({
|
|
1371
|
+
radiusPixels: pick(o, "radiusPixels", "output.sharpening.radiusPixels", 1, "Unsharp-mask Gaussian σ = 1 output pixel (kernel reach 3 pixels)."),
|
|
1372
|
+
amount: pick(o, "amount", "output.sharpening.amount", 0.5, "Unsharp-mask amount 0.5: half the detail signal is added back."),
|
|
1373
|
+
threshold: pick(o, "threshold", "output.sharpening.threshold", 0, "Unsharp-mask threshold 0: every difference is sharpened, including noise.")
|
|
1374
|
+
}, "output.sharpening");
|
|
1375
|
+
}
|
|
1221
1376
|
if (raisedPixels !== undefined) {
|
|
1222
1377
|
if (nativeWidth > NATIVE_RAW_LIMITS.maximumDimension || nativeHeight > NATIVE_RAW_LIMITS.maximumDimension || nativeWidth * nativeHeight > raisedPixels) {
|
|
1223
1378
|
fail("output.maximumPixels (" + raisedPixels + ") must cover the native sensor raster of " + nativeWidth + " × " + nativeHeight +
|
|
@@ -1325,7 +1480,8 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1325
1480
|
const buildProvider = (sceneIlluminant, scale) => buildPlanarSceneRadianceProvider({ scene: chart ?? callerScene,
|
|
1326
1481
|
sceneId: IDS.scene, providerProfileId: IDS.provider, illuminant: sceneIlluminant, irradianceScale: scale, geometry: { distanceM: subjectDistanceM, metresPerFrameUnit,
|
|
1327
1482
|
...(chartSize ? { chart: { widthM: chartSize.widthM, heightM: chartSize.heightM, surroundReflectance: chartSize.surroundReflectance } } : {}) }, evidence: FACADE_EVIDENCE,
|
|
1328
|
-
limitations: Object.freeze(["Planar Lambertian scene under uniform illumination; no shadows, inter-reflection, gloss or fluorescence."])
|
|
1483
|
+
limitations: Object.freeze(["Planar Lambertian scene under uniform illumination; no shadows, inter-reflection, gloss or fluorescence."]),
|
|
1484
|
+
...(catalogue?.kind === "fisheye" ? { zeroBeyondHorizon: true } : {}) });
|
|
1329
1485
|
const provider = buildProvider(illuminant, irradianceScale);
|
|
1330
1486
|
// 2.6: a chart preset's evaluator is engine-built end to end (its results: the schema's exact keys, constant fields,
|
|
1331
1487
|
// the request's identifiers, deep-frozen FACADE_EVIDENCE, UNCERTAINTY and limitations, and an engine-computed
|
|
@@ -1399,6 +1555,8 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1399
1555
|
fNumber = positive(pick(lensOptions, "fNumber", "lens.fNumber", DEFAULTS.fNumber, "f/8."), "lens.fNumber");
|
|
1400
1556
|
if (prime && (fNumber < prime.widestFNumber || fNumber > prime.narrowestFNumber))
|
|
1401
1557
|
fail("lens.fNumber " + fNumber + " is outside the " + prime.lensId + " range f/" + prime.widestFNumber + "–f/" + prime.narrowestFNumber + ".");
|
|
1558
|
+
if (catalogue && (fNumber < catalogue.widestFNumber || fNumber > catalogue.narrowestFNumber))
|
|
1559
|
+
fail("lens.fNumber " + fNumber + " is outside the " + catalogue.name + " range f/" + catalogue.widestFNumber + "–f/" + catalogue.narrowestFNumber + ".");
|
|
1402
1560
|
}
|
|
1403
1561
|
else {
|
|
1404
1562
|
if (lensOptions.fNumber !== undefined)
|
|
@@ -1511,19 +1669,171 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1511
1669
|
const exposureDeviationStops = standardExposureDeviationStops(fNumber, shutterSeconds, resolvedIso, meteredLuminance);
|
|
1512
1670
|
const exposureIlluminanceLux = standardExposureIlluminanceLux(fNumber, shutterSeconds, resolvedIso, meteredLuminance, illuminanceLux, callerScene?.kind !== "radiance-function");
|
|
1513
1671
|
const exposureGreyCardDeviationStops = callerScene?.kind === "radiance-function" ? null : greyCardDeviationStops(fNumber, shutterSeconds, resolvedIso, illuminanceLux);
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
if (
|
|
1518
|
-
const
|
|
1519
|
-
if (
|
|
1520
|
-
fail("
|
|
1521
|
-
|
|
1672
|
+
// #549: the catalogue lens's character at the resolved f-number and focus: focus scatter, the defocus disc folded into its PSF, then
|
|
1673
|
+
// its distortion and vignetting profiles over the field the PSF and the queries reach, and its PSF profile.
|
|
1674
|
+
let catalogueTaps, catalogueDefocusApplied = false;
|
|
1675
|
+
if (catalogue && lensKit) {
|
|
1676
|
+
const { GENERIC_LENS_EVIDENCE, genericLensDistortionProfile, genericLensVignettingProfile, resolveGenericLens, simulateGenericLensFocusError, createGenericLensPsfProfile, genericLensPsfKernelRadius, GENERIC_LENS_PSF_MAXIMUM_COMBINED_RADIUS_SITES } = lensKit;
|
|
1677
|
+
if (fNumber < catalogue.widestFNumber || fNumber > catalogue.narrowestFNumber)
|
|
1678
|
+
fail("lens.fNumber " + fNumber + " is outside the " + catalogue.name + " range f/" + catalogue.widestFNumber + "–f/" + catalogue.narrowestFNumber + ".");
|
|
1679
|
+
if (lensOptions.focusScatter !== undefined) {
|
|
1680
|
+
const scatterOptions = allowKeys(lensOptions.focusScatter, "lens.focusScatter", ["seed", "shotIndex"]);
|
|
1681
|
+
const sample = simulateGenericLensFocusError({ lensId: catalogue.lensId, activeImagingArea: catalogueFrame, fNumber, focusDistanceM, seed: scatterOptions.seed,
|
|
1682
|
+
...(scatterOptions.shotIndex === undefined ? {} : { shotIndex: scatterOptions.shotIndex }) });
|
|
1683
|
+
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 +
|
|
1684
|
+
" 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" +
|
|
1685
|
+
(lensOptions.defocus === undefined ? "; without lens.defocus the capture renders no defocus, so the image does not change." : "."));
|
|
1686
|
+
focusDistanceM = sample.focusDistanceM;
|
|
1687
|
+
}
|
|
1688
|
+
const character = resolveGenericLens({ lensId: catalogue.lensId, activeImagingArea: catalogueFrame, fNumber, focusDistanceM });
|
|
1689
|
+
let extraKernel;
|
|
1690
|
+
if (lensOptions.defocus !== undefined) {
|
|
1691
|
+
const disc = resolveDefocusDisc({ focalLengthMm, aperture: fNumber, focusDistanceM, subjectDistanceM, pitchMicrometers: pitch });
|
|
1692
|
+
let discRadius;
|
|
1693
|
+
try {
|
|
1694
|
+
discRadius = requireDefocusDiscKernelRadius(disc.diameterSites);
|
|
1695
|
+
}
|
|
1696
|
+
catch {
|
|
1697
|
+
fail("lens.defocus: " + defocusDiscLimitMessage(disc.diameterSites, disc.diameterMicrometers));
|
|
1698
|
+
}
|
|
1699
|
+
const kernel = run === "ideal-reference" ? null : createDefocusDiscSiteKernel(disc.diameterSites);
|
|
1700
|
+
if (kernel) {
|
|
1701
|
+
extraKernel = { weights: kernel.weights, radius: kernel.radius };
|
|
1702
|
+
catalogueDefocusApplied = true;
|
|
1703
|
+
}
|
|
1704
|
+
assume("lens.defocus", "model", { model: "geometric-disc", scientificStatus: "approximation", diameterMicrometers: disc.diameterMicrometers, diameterSites: disc.diameterSites,
|
|
1705
|
+
kernelRadiusSites: disc.diameterSites === 0 ? null : discRadius, fNumber, focusDistanceM, subjectDistanceM, samplePitchMicrometers: pitch,
|
|
1706
|
+
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. " +
|
|
1707
|
+
(disc.diameterSites === 0 ? "In focus: no disc is applied." : run === "ideal-reference" ? "Resolved but not applied: the ideal reference has no camera effects." :
|
|
1708
|
+
"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."));
|
|
1709
|
+
}
|
|
1710
|
+
// An upper bound of the PSF's radius (the built window may be narrower): the field reach below uses it.
|
|
1711
|
+
const kernelRadius = genericLensPsfKernelRadius({ lens: character, focusDistanceM, sitePitchMicrometers: pitch }) + (extraKernel ? extraKernel.radius : 0);
|
|
1712
|
+
// The field the PSF taps and the site queries reach: the native raster plus the kernel radius and the query offsets.
|
|
1713
|
+
const queryReach = Math.max(0, ...queryOffsetsSites.map((q) => Math.max(Math.abs(q.x), Math.abs(q.y))));
|
|
1714
|
+
const reachMm = (kernelRadius + queryReach + 1) * pitch / 1000;
|
|
1715
|
+
const frameHalfMm = { x: nativeWidth * pitch / 2000, y: nativeHeight * pitch / 2000 }, extentMm = { x: frameHalfMm.x + reachMm, y: frameHalfMm.y + reachMm };
|
|
1716
|
+
const reachRadius = Math.hypot(extentMm.x, extentMm.y) / catalogue.halfDiagonalMm;
|
|
1717
|
+
const b = 1 - 2 ** (-character.character.vignetting.cornerStops / 2);
|
|
1718
|
+
if (!(b * reachRadius * reachRadius < 0.98))
|
|
1719
|
+
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 ρ = " +
|
|
1720
|
+
reachRadius.toFixed(3) + ". Use a larger output (or smaller pixels' fewer sites of reach): the reach is " + (kernelRadius + queryReach + 1) + " sites beyond the frame.");
|
|
1721
|
+
vignetting = genericLensVignettingProfile(character.character.vignetting.cornerStops, catalogue.halfDiagonalMm, reachRadius);
|
|
1722
|
+
const corner = character.character.distortion.corner;
|
|
1723
|
+
distortion = { kind: "radial", profile: genericLensDistortionProfile(corner, catalogue.halfDiagonalMm, corner < 0 ? reachRadius / (1 + corner * reachRadius * reachRadius) * 1.01 : reachRadius) };
|
|
1724
|
+
lensFieldMapping = { projectionModel: projection ?? "rectilinear", distortion, radiometricAreaCorrection: "inverse-distortion-jacobian",
|
|
1725
|
+
...(catalogue.kind === "fisheye" ? { outsideImageCircle: "backward-ray" } : {}) };
|
|
1726
|
+
try {
|
|
1727
|
+
prepareEnvironmentLensFieldMapping(lensFieldMapping);
|
|
1728
|
+
}
|
|
1729
|
+
catch (error) {
|
|
1730
|
+
fail("lens.catalogue: the " + catalogue.name + "'s distortion over this frame's reach: " + error.message);
|
|
1731
|
+
}
|
|
1732
|
+
const c = character.character;
|
|
1733
|
+
let psfReport = null;
|
|
1734
|
+
if (run !== "ideal-reference") {
|
|
1735
|
+
const built = createGenericLensPsfProfile({ lens: character, focusDistanceM, sitePitchMicrometers: pitch, fieldHalfExtentMm: extentMm, frameHalfMm, evidence: GENERIC_LENS_EVIDENCE,
|
|
1736
|
+
...(extraKernel ? { extraKernel } : {}) });
|
|
1737
|
+
lensPsf = { kind: "sampled-local", configuration: { psf: { profile: built.profile, focalLengthMm, focus: { kind: "finite", distanceM: focusDistanceM }, apertureFNumber: fNumber,
|
|
1738
|
+
signedDefocusImagePlaneMicrometers: 0 }, psfWavelengthBasis: { value: "vacuum", evidence: GENERIC_LENS_EVIDENCE },
|
|
1739
|
+
spatialModel: { kind: "destination-local-shift-invariant-approximation", evidence: GENERIC_LENS_EVIDENCE,
|
|
1740
|
+
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." } } };
|
|
1741
|
+
if (built.kernelRadiusSites > GENERIC_LENS_PSF_MAXIMUM_COMBINED_RADIUS_SITES)
|
|
1742
|
+
fail("lens.catalogue with lens.defocus needs a PSF of radius " + built.kernelRadiusSites + " sites (the lens's " + built.aberrationKernelRadiusSites + " plus the disc's " +
|
|
1743
|
+
(built.kernelRadiusSites - built.aberrationKernelRadiusSites) + "), over the limit of " + GENERIC_LENS_PSF_MAXIMUM_COMBINED_RADIUS_SITES + " (" + (2 * built.kernelRadiusSites + 1) ** 2 +
|
|
1744
|
+
" scene queries per site and spectral node through the environment quadrature). Bring the focus closer to the subject, stop down, or use larger pixels.");
|
|
1745
|
+
catalogueTaps = (2 * built.kernelRadiusSites + 1) ** 2;
|
|
1746
|
+
psfReport = { aberrationKernelRadiusSites: built.aberrationKernelRadiusSites, kernelRadiusSites: built.kernelRadiusSites, maximumTruncatedEnergyFraction: built.maximumTruncatedEnergyFraction,
|
|
1747
|
+
fieldNodesMm: { x: built.fieldXmm, y: built.fieldYmm }, wavelengthNodesNm: built.wavelengthsNm, transformSize: built.transformSize, maximumPhaseStepWaves: built.maximumPhaseStepWaves };
|
|
1748
|
+
}
|
|
1749
|
+
assume("lens.catalogue.character", "model", { lensId: catalogue.lensId, fNumber, focusDistanceM, magnification: c.magnification, closeFocusGain: c.closeFocusGain, pupilScale: c.pupilScale,
|
|
1750
|
+
aberrationsWaves550: { ...c.aberrations }, lateralColor: { ...c.lateralColor }, distortionCorner: c.distortion.corner, vignettingCornerStops: c.vignetting.cornerStops,
|
|
1751
|
+
notRendered: { strayLight: { ...c.strayLight }, breathing: { ...c.breathing }, focusScatterDepthOfFocusFraction: c.focusScatterDepthOfFocusFraction, bokeh: "disc (#553)" },
|
|
1752
|
+
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), " +
|
|
1753
|
+
"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. " +
|
|
1754
|
+
(run === "ideal-reference" ? "Not applied: the ideal reference has no camera effects. " :
|
|
1755
|
+
"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) " +
|
|
1756
|
+
"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" +
|
|
1757
|
+
(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" : "") + ". ") +
|
|
1758
|
+
"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.");
|
|
1522
1759
|
}
|
|
1760
|
+
// #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.
|
|
1761
|
+
// #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
|
|
1762
|
+
// site lattice (defocus-image-space-provider.ts). #563: the display view reads the same geometry in any focus (display-view-lens-signal.ts).
|
|
1763
|
+
const imageSpaceOptions = [...(lensFieldMapping ? [lensOptions.distortion !== undefined ? (lensOptions.projection !== undefined ? "lens.projection, lens.distortion" : "lens.distortion") : "lens.projection"] : []),
|
|
1764
|
+
...(vignetting ? ["lens.vignetting"] : []), ...(rotating ? ["camera rotation"] : [])];
|
|
1765
|
+
const lensGeometry = (defocusModel !== undefined || displayView) && imageSpaceOptions.length > 0 ? prepareDefocusImageSpaceGeometry({ focalLengthMm, focusDistanceM,
|
|
1766
|
+
angularVelocityRadPerSec: angular, ...(lensFieldMapping ? { lensMapping: prepareEnvironmentLensFieldMapping(lensFieldMapping) } : {}), ...(vignetting ? { vignetting } : {}) }) : undefined;
|
|
1767
|
+
const defocusGeometry = defocusModel !== undefined ? lensGeometry : undefined;
|
|
1768
|
+
// #553: the shaped kernel (on axis) and, with a cat's eye, its field zones; built when the disc is resolved out of focus.
|
|
1769
|
+
let bokehAxisKernel, bokehZones;
|
|
1770
|
+
const bokehSummary = defocusBokeh === undefined ? undefined : (disc) => {
|
|
1771
|
+
const options = defocusBokeh, side = subjectDistanceM > focusDistanceM ? 1 : -1;
|
|
1772
|
+
const build = (field) => {
|
|
1773
|
+
try {
|
|
1774
|
+
return createDefocusBokehSiteKernel({ diameterSites: disc.diameterSites, side, fNumber, focalLengthMm, pitchMicrometers: pitch, options, ...(field ? { field } : {}) });
|
|
1775
|
+
}
|
|
1776
|
+
catch (error) {
|
|
1777
|
+
const message = error.message;
|
|
1778
|
+
fail(message.startsWith("lens.defocus") ? message : "lens.defocus: " + message);
|
|
1779
|
+
}
|
|
1780
|
+
};
|
|
1781
|
+
const axis = build();
|
|
1782
|
+
bokehAxisKernel = axis;
|
|
1783
|
+
// The run convolution above 64 sites (#564) takes one kernel for the whole frame; the cat's eye needs one per field zone.
|
|
1784
|
+
if (options.catsEye && !displayView && axis.radius > DEFOCUS_DIRECT_KERNEL_MAXIMUM_RADIUS_SITES)
|
|
1785
|
+
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 +
|
|
1786
|
+
" sites (129 across). Reduce the defocus or omit catsEye.");
|
|
1787
|
+
if (options.catsEye && !displayView) {
|
|
1788
|
+
// The field tangent of a native point (sites from the top-left corner), in output-image axes (+Y up): through the lens
|
|
1789
|
+
// mapping in image space (#548; at the exposure start, so camera rotation does not move the field), else the planar lattice's.
|
|
1790
|
+
const g = 2 / width, mmPerSite = pitch / 1000;
|
|
1791
|
+
const fieldOf = defocusGeometry ? (x, y) => {
|
|
1792
|
+
const look = defocusGeometry.lookOf((x - nativeWidth / 2) * mmPerSite, (y - nativeHeight / 2) * mmPerSite, 0);
|
|
1793
|
+
return { x: look.x / look.z, y: look.y / look.z };
|
|
1794
|
+
} : (x, y) => ({ x: (x - nativeWidth / 2) * g * metresPerFrameUnit / subjectDistanceM, y: -(y - nativeHeight / 2) * g * metresPerFrameUnit / subjectDistanceM });
|
|
1795
|
+
bokehZones = planDefocusBokehZones({ options, nativeWidth, nativeHeight, radius: axis.radius, fNumber, focalLengthMm, fieldOf, kernelAt: build });
|
|
1796
|
+
if (!(bokehZones.minimumOpenAreaRatio > 0))
|
|
1797
|
+
fail("lens.defocus.catsEye: the clipping apertures close the pupil in part of the frame; reduce shiftPerTanFieldMm or enlarge radiusMm.");
|
|
1798
|
+
}
|
|
1799
|
+
const zones = bokehZones;
|
|
1800
|
+
return { value: { options, side: side === 1 ? "behind-focus" : "in-front-of-focus", kernelRadiusSites: axis.radius, method: axis.method, raySamples: axis.raySamples,
|
|
1801
|
+
sphericalAberrationSites: axis.sphericalAberrationSites, axisOpenAreaRatio: axis.openAreaRatio,
|
|
1802
|
+
...(zones ? { catsEye: { execution: "piecewise-constant-field-zones", zoneSizeSites: zones.zones.size, columns: zones.zones.columns, rows: zones.zones.rows,
|
|
1803
|
+
minimumOpenAreaRatio: zones.minimumOpenAreaRatio, largestAdjacentOpenAreaRatioChange: zones.largestAdjacentOpenAreaRatioChange, lightLossApplied: false } } : {}) },
|
|
1804
|
+
explanation: "The kernel is shaped by the lens instead (#553, bokeh character; calculateDefocusBokehKernel): the projected open pupil " +
|
|
1805
|
+
(options.blades ? "(" + options.blades.count + " blades" + (options.blades.curvature > 0 ? ", curvature " + options.blades.curvature : "") +
|
|
1806
|
+
(options.blades.circularAtOrBelowFNumber !== null ? ", circular at or below f/" + options.blades.circularAtOrBelowFNumber : "") + ", scaled to the disc's area) " : "(a circle) ") +
|
|
1807
|
+
"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)") +
|
|
1808
|
+
(options.sphericalAberration ? ", with primary spherical aberration W040 " + options.sphericalAberration.w040Micrometers + " µm at f/" + options.sphericalAberration.atFNumber +
|
|
1809
|
+
" (" + 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)") +
|
|
1810
|
+
(zones ? ". Cat's eye: the pupil is clipped by " + options.catsEye.apertures.length + " aperture(s) shifted by field position (calculateMechanicalVignetting's model), applied as " +
|
|
1811
|
+
zones.zones.columns + " × " + zones.zones.rows + " field zones of " + zones.zones.size + " sites, each with its centre's kernel (a piecewise-constant approximation; " +
|
|
1812
|
+
"the open-area ratio falls to " + formatBound(zones.minimumOpenAreaRatio) + " and changes by at most " + formatBound(zones.largestAdjacentOpenAreaRatioChange) +
|
|
1813
|
+
" between neighbouring zones). The kernel has unit sum: the light the clip removes is not applied; declare it with lens.vignetting" : "") +
|
|
1814
|
+
". A geometric approximation: no diffraction, onion rings, higher-order or field-dependent aberrations; the same kernel at every wavelength. "
|
|
1815
|
+
};
|
|
1816
|
+
};
|
|
1817
|
+
const defocusDisc = defocusModel === undefined ? undefined :
|
|
1818
|
+
resolveDefocusOption({ run, assume, focalLengthMm, fNumber, focusDistanceM, subjectDistanceM, pitch, diffraction: diffractionRadiusOption !== undefined,
|
|
1819
|
+
...(defocusBokeh ? { bokehOptions: defocusBokeh, bokeh: bokehSummary } : {}),
|
|
1820
|
+
...(defocusGeometry ? { imageSpace: (disc) => {
|
|
1821
|
+
// 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.
|
|
1822
|
+
if (rotating && shutterKind === "rolling")
|
|
1823
|
+
fail("simulateCapture does not support lens.defocus out of focus with camera rotation and motion.shutter \"rolling\": the disc is applied " +
|
|
1824
|
+
"to each temporal midpoint's image on the sensor lattice, and under a rolling shutter every row has its own instants, so each row would need its own image of the rows the disc " +
|
|
1825
|
+
"reaches (about 2R + 1 rows per row, R the kernel radius). Use motion.shutter \"global\", set lens.focusDistanceM equal to subjectDistanceM, or omit one of the options.");
|
|
1826
|
+
const temporalMidpointsSeconds = rotating ? Array.from({ length: temporalSampleCount }, (_, i) => (i + 0.5) * (shutterSeconds / temporalSampleCount)) : [];
|
|
1827
|
+
return { options: imageSpaceOptions, fieldVariation: calculateDefocusFieldVariation({ geometry: defocusGeometry, ...(lensFieldMapping ? { lensMapping: prepareEnvironmentLensFieldMapping(lensFieldMapping) } : {}),
|
|
1828
|
+
focalLengthMm, fNumber, focusDistanceM, subjectDistanceM, pitchMicrometers: pitch, nativeWidth, nativeHeight, angularVelocityRadPerSec: angular, temporalMidpointsSeconds,
|
|
1829
|
+
diameterSites: disc.diameterSites, discAt: (distanceM) => resolveDefocusDisc({ focalLengthMm, aperture: fNumber, focusDistanceM, subjectDistanceM: distanceM, pitchMicrometers: pitch }).diameterSites }) };
|
|
1830
|
+
} } : {}) });
|
|
1831
|
+
const defocusImageSpace = defocusDisc !== undefined && defocusGeometry !== undefined;
|
|
1523
1832
|
// #492: the disc on the site lattice (radius 0 for a disc inside one cell: the identity, still applied).
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1833
|
+
// #553: the shaped kernel replaces the disc (with a cat's eye, the axis kernel; the zones replace it in the gather).
|
|
1834
|
+
const defocusKernel = defocusDisc && !displayView ? bokehAxisKernel ?? createDefocusDiscSiteKernel(defocusDisc.diameterSites) ?? undefined : undefined;
|
|
1835
|
+
// The display view's disc (#492, step 2b): its expected-signal tables apply it (simulate-display-view.ts); #553: or the shaped kernel.
|
|
1836
|
+
const displayViewDefocus = displayView ? (defocusDisc && bokehAxisKernel ? { ...defocusDisc, siteKernel: bokehAxisKernel } : defocusDisc) : undefined;
|
|
1527
1837
|
// Sensor readout for the ISO setting. Standard output sensitivity S = 10/H (CIPA DC-004 §3.2) is defined at the
|
|
1528
1838
|
// output, independent of pixel size, while the collected charge is proportional to the sensitive-aperture area
|
|
1529
1839
|
// (EMVA 1288 Release 4.0, Eq. 3). The tier's signal chain is anchored to the 6 µm aperture; at another area the
|
|
@@ -1597,7 +1907,10 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1597
1907
|
assume("sensor.shutter", "model", "global-electronic", "Global electronic shutter: every site integrates the same exposure window.");
|
|
1598
1908
|
if (rotating)
|
|
1599
1909
|
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.");
|
|
1600
|
-
if (
|
|
1910
|
+
if (catalogue)
|
|
1911
|
+
assume("lens.psf", "model", lensPsf ? { kind: "sampled-local", source: "lens.catalogue" } : "not-applied", lensPsf ?
|
|
1912
|
+
"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.");
|
|
1913
|
+
else if (lensPsf)
|
|
1601
1914
|
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.");
|
|
1602
1915
|
// With lens.diffraction the PSF is recorded as lens.diffraction once its kernels are built.
|
|
1603
1916
|
else if (diffractionRadiusOption === undefined)
|
|
@@ -1607,11 +1920,11 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1607
1920
|
assume("lens.fieldThroughput", "model", "unity", "No vignetting, distortion, lateral colour or stray light.");
|
|
1608
1921
|
else {
|
|
1609
1922
|
if (vignetting)
|
|
1610
|
-
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.");
|
|
1923
|
+
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.");
|
|
1611
1924
|
else
|
|
1612
1925
|
assume("lens.fieldThroughput", "model", "unity", "No vignetting, lateral colour or stray light.");
|
|
1613
1926
|
if (distortion)
|
|
1614
|
-
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).");
|
|
1927
|
+
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).");
|
|
1615
1928
|
}
|
|
1616
1929
|
if (projection === undefined)
|
|
1617
1930
|
assume("lens.projection", "default", "rectilinear", "Ideal rectilinear (pinhole) projection.");
|
|
@@ -1650,9 +1963,10 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1650
1963
|
...(outputOptions.toneCurveParameters === undefined ? {} : { toneCurveParameters: outputOptions.toneCurveParameters }),
|
|
1651
1964
|
gamutHandling: "clip-components", outputDynamicRange: "sdr", transferFunction: "srgb", bitDepth: format === "srgb-16" || (format === "png" && !wideGamut && pngBitDepth === 16) ? 16 : 8, rounding: "nearest-ties-up", dither: "none" };
|
|
1652
1965
|
if (!linearPath)
|
|
1653
|
-
assume("output.rendering", "model", { renderingExposureEv: 0, gamutHandling: "clip-components" }, "Developed linear sRGB/D65 with the RAW white level mapped to display white, no exposure push, out-of-gamut components clipped."
|
|
1966
|
+
assume("output.rendering", "model", { renderingExposureEv: 0, gamutHandling: "clip-components" }, exposureGain === 1 ? "Developed linear sRGB/D65 with the RAW white level mapped to display white, no exposure push, out-of-gamut components clipped." :
|
|
1967
|
+
"Developed linear sRGB/D65 with the RAW white level mapped to display white, no further exposure push after development (output.exposureCompensationStops acts on the RAW), out-of-gamut components clipped.");
|
|
1654
1968
|
else
|
|
1655
|
-
assume("output.rendering", "model", { renderingExposureEv: 0, gamutHandling: "clip-components-in-target-gamut", sdrRendererUsed: false }, "The sRGB SDR renderer is not used: the linear development (RAW white level = relative 1.0, no exposure push) is converted to the target encoding; request.development.rendering is validated but only its exposure and tone curve (identity) apply.");
|
|
1969
|
+
assume("output.rendering", "model", { renderingExposureEv: 0, gamutHandling: "clip-components-in-target-gamut", sdrRendererUsed: false }, "The sRGB SDR renderer is not used: the linear development (RAW white level = relative 1.0, " + (exposureGain === 1 ? "no exposure push" : "after the development exposure push on the RAW") + ") is converted to the target encoding; request.development.rendering is validated but only its exposure and tone curve (identity) apply.");
|
|
1656
1970
|
// Rendering choices are validated before any radiance work, so a bad tone curve fails fast.
|
|
1657
1971
|
parseSdrRenderingProfile(rendering);
|
|
1658
1972
|
if (highlightMode !== "clip" && highlightMode !== "reconstruct")
|
|
@@ -1740,7 +2054,8 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1740
2054
|
fidelity: { spectral: "wavelength-resolved", material: "spectral-data", visibility: "resolved", directTransport: "approximation", indirectTransport: "not-modeled" },
|
|
1741
2055
|
wavelengthChangingTransportModeled: false, volumetricTransportModeled: false, polarizationModeled: false, evidence, limitations: ["Engine-authored planar chart or caller scene function.",
|
|
1742
2056
|
...(diffractionObjectSpace ? ["Radiance is the scene radiance convolved with the lens diffraction PSF mapped onto the scene plane (object-space convolution, request.diffraction)."] : []),
|
|
1743
|
-
...(defocusKernel ? ["Radiance is the
|
|
2057
|
+
...(defocusKernel ? [defocusImageSpace ? "Radiance is the sensor-plane irradiance before any PSF convolved with the geometric defocus disc on the sensor's site lattice, divided by " +
|
|
2058
|
+
"the queried point's field factor (image-space convolution, lens.defocus)." : "Radiance is the scene radiance convolved with the geometric defocus disc mapped onto the scene plane (object-space convolution, lens.defocus)."] : [])] }
|
|
1744
2059
|
};
|
|
1745
2060
|
const lensId = lensExposure.profileId;
|
|
1746
2061
|
const opticsProfile = { schemaVersion: "0.1.0", profileId: lensId + ":facade-transmission", profileVersion: SIMULATE_CAPTURE_FACADE_VERSION, lensProfileId: lensId,
|
|
@@ -1790,18 +2105,38 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1790
2105
|
assume("lens.diffraction", "model", { model: diffraction.model, aperture: "circular-aperture", fNumber, workingFNumber: diffraction.workingFNumber,
|
|
1791
2106
|
kernelRadiusSamples, samplePitchMicrometers: pitch, maximumTruncatedEnergyFraction: diffraction.maximumTruncatedEnergyFraction,
|
|
1792
2107
|
truncatedEnergyByWavelength: diffraction.wavelengths.map((w) => ({ wavelengthNm: w.wavelengthNm, truncatedEnergyFraction: w.truncatedEnergyFraction })),
|
|
1793
|
-
execution: diffractionObjectSpace ? "object-space-convolution" : "environment-psf-quadrature" }, "Scalar Fraunhofer diffraction of an ideal, aberration-free circular pupil (Airy pattern, createDiffractionLimitedPsfProfile) at the resolved f-number, in focus, with D = f/N and the thin-lens image distance (working f-number N·v/f). " +
|
|
2108
|
+
execution: diffractionObjectSpace ? "object-space-convolution" : defocusImageSpace ? "image-space-convolution" : "environment-psf-quadrature" }, "Scalar Fraunhofer diffraction of an ideal, aberration-free circular pupil (Airy pattern, createDiffractionLimitedPsfProfile) at the resolved f-number, in focus, with D = f/N and the thin-lens image distance (working f-number N·v/f). " +
|
|
1794
2109
|
"One kernel per spectral node, binned into site-sized cells over a " + (2 * kernelRadiusSamples + 1) + " × " + (2 * kernelRadiusSamples + 1) + " window" +
|
|
1795
2110
|
(diffractionRadiusOption !== null ? "" : thirdRingSites > 32 ? " (capped at 32 sites; the third dark ring at the longest node needs " + thirdRingSites + ")" :
|
|
1796
2111
|
" (reaching the third dark ring at the longest node)") + "; the Airy tail energy outside it (listed per wavelength) is put back by unit-sum renormalization. " +
|
|
1797
2112
|
(diffractionObjectSpace ? "Applied as an object-space convolution: the scene radiance on the site lattice is convolved once per spectral node (calculateIrradianceConvolution) and the provider answers each site query with it, so the site inputs declare no PSF; this equals the per-tap environment PSF quadrature for this still, distortion-free planar scene (request.diffraction.psf). " :
|
|
1798
|
-
"Applied
|
|
2113
|
+
defocusImageSpace ? "Applied with lens.defocus as one image-space kernel per spectral node (the diffraction kernel convolved with the disc) on the sensor's site lattice (#548; see the lens.defocus assumption), " +
|
|
2114
|
+
"the same sum as the environment PSF quadrature with that kernel. " :
|
|
2115
|
+
"Applied through the environment PSF quadrature (one scene query per kernel tap), because camera rotation, a lens field mapping or vignetting moves the taps off the site lattice. ") +
|
|
1799
2116
|
"No defocus, aberration or pupil clipping; " + (boxAperture ? "the pixel aperture is integrated as declared by sensor.pixelAperture." : "the sensor pixel aperture is still point-sampled at the site centre."));
|
|
1800
2117
|
}
|
|
1801
|
-
// #492: each spectral node's kernel for lens.defocus out of focus: the disc, or with lens.diffraction
|
|
1802
|
-
//
|
|
2118
|
+
// #492: each spectral node's kernel for lens.defocus out of focus: the disc, or with lens.diffraction the node's diffraction kernel convolved
|
|
2119
|
+
// with the disc, radius R_diffraction + R_defocus (built on first use, kept for the request). Object space, or image space (#548).
|
|
1803
2120
|
let defocusSiteKernels;
|
|
1804
|
-
if (defocusKernel) {
|
|
2121
|
+
if (defocusKernel && defocusKernel.radius > DEFOCUS_DIRECT_KERNEL_MAXIMUM_RADIUS_SITES) {
|
|
2122
|
+
// #564: above the direct radius the disc is convolved by runs (site-kernel-runs.ts) and each node's diffraction kernel applied
|
|
2123
|
+
// after it, instead of one combined kernel per node: the same sums regrouped. `at` is never read on this path.
|
|
2124
|
+
const runs = siteKernelRuns(defocusKernel.weights, defocusKernel.radius), diffractionRadius = diffraction ? diffraction.kernelRadiusSamples : 0;
|
|
2125
|
+
const psfConfiguration = diffraction ? diffractionPsf.configuration.psf : undefined, profileWavelengths = diffraction ? diffraction.profile.axes.wavelengthNm : quadratureWavelengths();
|
|
2126
|
+
const diffractionKernels = new Map();
|
|
2127
|
+
defocusSiteKernels = { radius: diffractionRadius + defocusKernel.radius, wavelengthsNm: profileWavelengths, at: () => fail("simulateCapture: the large defocus kernel has no combined kernels."),
|
|
2128
|
+
large: { runs, ...(diffraction ? { diffraction: { radius: diffractionRadius, kernelAt(wavelengthNm) {
|
|
2129
|
+
let kernel = diffractionKernels.get(wavelengthNm);
|
|
2130
|
+
if (!kernel) {
|
|
2131
|
+
if (!profileWavelengths.includes(wavelengthNm))
|
|
2132
|
+
fail("simulateCapture: a spectral node has no diffraction kernel.");
|
|
2133
|
+
kernel = siteDiffractionKernel(psfConfiguration, wavelengthNm, pitch, diffractionRadius, "simulateCapture: diffraction kernels must sample the site pitch exactly.");
|
|
2134
|
+
diffractionKernels.set(wavelengthNm, kernel);
|
|
2135
|
+
}
|
|
2136
|
+
return kernel;
|
|
2137
|
+
} } } : {}) } };
|
|
2138
|
+
}
|
|
2139
|
+
else if (defocusKernel) {
|
|
1805
2140
|
if (diffraction) {
|
|
1806
2141
|
const diffractionRadius = diffraction.kernelRadiusSamples, combined = new Map(), profileWavelengths = diffraction.profile.axes.wavelengthNm;
|
|
1807
2142
|
const radius = diffractionRadius + defocusKernel.radius;
|
|
@@ -1823,8 +2158,26 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1823
2158
|
else
|
|
1824
2159
|
defocusSiteKernels = { radius: defocusKernel.radius, wavelengthsNm: quadratureWavelengths(), at: () => defocusKernel.weights };
|
|
1825
2160
|
}
|
|
2161
|
+
// #548: in image space the kernel reaches its radius (plus the anti-aliasing and pixel-aperture offsets) beyond the frame, so the lens
|
|
2162
|
+
// field mapping and the vignetting profile must cover the frame's corners plus that reach.
|
|
2163
|
+
if (defocusSiteKernels && defocusGeometry && defocusImageSpace) {
|
|
2164
|
+
const reach = defocusSiteKernels.radius + Math.max(0, ...queryOffsetsSites.map((o) => Math.ceil(Math.max(Math.abs(o.x), Math.abs(o.y)) - 1e-6))) + 0.5;
|
|
2165
|
+
const xMm = (nativeWidth / 2 + reach) * pitch / 1000, yMm = (nativeHeight / 2 + reach) * pitch / 1000;
|
|
2166
|
+
for (const [x, y] of [[-xMm, -yMm], [xMm, -yMm], [-xMm, yMm], [xMm, yMm]]) {
|
|
2167
|
+
try {
|
|
2168
|
+
defocusGeometry.lookOf(x, y, 0);
|
|
2169
|
+
}
|
|
2170
|
+
catch (error) {
|
|
2171
|
+
fail("lens.defocus out of focus with " + imageSpaceOptions.join(", ") + " reads the scene through the lens up to " + defocusSiteKernels.radius + " sites beyond the frame (the " +
|
|
2172
|
+
(diffraction ? "kernel radius of the diffraction kernel convolved with the disc" : "disc's kernel radius") + "), and at the frame's corner plus that reach (" +
|
|
2173
|
+
Math.hypot(x, y).toFixed(3) + " mm from the axis) " + error.message + " Extend the profile's range, or bring the focus closer to the subject.");
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
1826
2177
|
const psf = lensPsf ?? (diffractionObjectSpace ? { kind: "not-applied", evidence,
|
|
1827
2178
|
limitation: "No environment PSF: lens diffraction is applied upstream by the scene provider as an object-space convolution (request.diffraction)." } : undefined) ??
|
|
2179
|
+
(defocusSiteKernels && defocusImageSpace ? { kind: "not-applied", evidence, limitation: "No environment PSF: lens defocus" + (diffraction ? " (with lens.diffraction)" : "") +
|
|
2180
|
+
" is applied upstream by the scene provider as an image-space convolution on the sensor's site lattice (lens.defocus)." } : undefined) ??
|
|
1828
2181
|
diffractionPsf ?? (defocusSiteKernels ? { kind: "not-applied", evidence,
|
|
1829
2182
|
limitation: "No environment PSF: lens defocus is applied upstream by the scene provider as an object-space convolution (lens.defocus)." } : undefined) ??
|
|
1830
2183
|
{ kind: "not-applied", evidence, limitation: "No lens PSF (point optics)." };
|
|
@@ -1863,20 +2216,27 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1863
2216
|
}
|
|
1864
2217
|
const readTile = async (request) => ({ ...request, sites: Array.from({ length: request.width }, (_, j) => siteInput(request.y * nativeWidth + request.x + j)) });
|
|
1865
2218
|
const yieldControl = createHostYield().yieldControl;
|
|
1866
|
-
const perSite = queriesPerSite * (diffraction && psf === diffractionPsf ? (2 * diffraction.kernelRadiusSamples + 1) ** 2 : 1);
|
|
2219
|
+
const perSite = queriesPerSite * (diffraction && psf === diffractionPsf ? (2 * diffraction.kernelRadiusSamples + 1) ** 2 : catalogueTaps ?? 1);
|
|
1867
2220
|
let diffractionMs = 0, defocusMs = 0, demosaicMs, encodeMs;
|
|
1868
|
-
const diffractedProvider = (window) =>
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
...(
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
...(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 }))) } : {}) }) :
|
|
2221
|
+
const diffractedProvider = (window) =>
|
|
2222
|
+
// #548: the disc (or diffraction ⊛ disc) in image space, on the sensor's site lattice, through the mapping, field throughput and rotation.
|
|
2223
|
+
defocusSiteKernels && defocusGeometry && defocusImageSpace ? createDefocusImageSpaceProvider({ base: provider, geometry: defocusGeometry, siteKernels: defocusSiteKernels.at,
|
|
2224
|
+
...(defocusSiteKernels.large ? { largeKernel: defocusSiteKernels.large } : {}),
|
|
2225
|
+
wavelengthsNm: defocusSiteKernels.wavelengthsNm, kernelRadius: defocusSiteKernels.radius, nativeWidth, nativeHeight, sitePitchMicrometers: pitch, distanceM: subjectDistanceM,
|
|
2226
|
+
metresPerFrameUnit, queryOffsetsSites, timesPerBand: temporalSampleCount, onConvolution: (ms) => { defocusMs += ms; }, ...(window ? { window } : {}),
|
|
2227
|
+
...(bokehZones ? { fieldZones: bokehZones.zones } : {}) }) :
|
|
2228
|
+
defocusSiteKernels ? createDiffractedPlanarProvider({ base: provider, siteKernels: defocusSiteKernels.at, ...(defocusSiteKernels.large ? { largeKernel: defocusSiteKernels.large } : {}),
|
|
2229
|
+
wavelengthsNm: defocusSiteKernels.wavelengthsNm, kernelRadius: defocusSiteKernels.radius, nativeWidth, nativeHeight, outputWidth: width,
|
|
2230
|
+
sitePitchMicrometers: pitch, distanceM: subjectDistanceM, metresPerFrameUnit, onConvolution: (ms) => { defocusMs += ms; },
|
|
2231
|
+
...(window ? { window } : {}), ...(bokehZones ? { fieldZones: bokehZones.zones } : {}),
|
|
2232
|
+
...(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 }))) } : {}) }) :
|
|
2233
|
+
diffraction && diffractionObjectSpace ? createDiffractedPlanarProvider({ base: provider, psf: diffractionPsf.configuration.psf,
|
|
2234
|
+
wavelengthsNm: diffraction.profile.axes.wavelengthNm, kernelRadius: diffraction.kernelRadiusSamples, nativeWidth, nativeHeight, outputWidth: width,
|
|
2235
|
+
sitePitchMicrometers: pitch, distanceM: subjectDistanceM, metresPerFrameUnit, onConvolution: (ms) => { diffractionMs += ms; },
|
|
2236
|
+
// Region (2.6): convolve only the lattice the region's native sites query; the values are the whole render's.
|
|
2237
|
+
...(window ? { window } : {}),
|
|
2238
|
+
// The environment queries each site at each aperture node minus each split offset (sites, +x right, +y down).
|
|
2239
|
+
...(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 }))) } : {}) }) : provider;
|
|
1880
2240
|
// Built on first use: a capture that takes the tabulated path (#394 step 2) never queries it, and its kernels cost a setup.
|
|
1881
2241
|
let rawProviderBuilt;
|
|
1882
2242
|
const rawProvider = () => (rawProviderBuilt ??= diffractedProvider(regionNative));
|
|
@@ -1950,8 +2310,14 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1950
2310
|
}
|
|
1951
2311
|
// Display view: the renderer that simulate-display-view.ts passes in reads this request's resolved state, so a bundle
|
|
1952
2312
|
// that only captures does not contain the display view.
|
|
2313
|
+
// #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
|
|
2314
|
+
// site centre plus the kernel radius and the largest query offset.
|
|
2315
|
+
const lensSignal = displayView && lensGeometry ? (await import("./display-view-lens-signal.js")).prepareDisplayViewLensSignal({ geometry: lensGeometry,
|
|
2316
|
+
times: rotating ? Array.from({ length: temporalSampleCount }, (_, i) => (i + 0.5) * (shutterSeconds / temporalSampleCount)) : [0], distanceM: subjectDistanceM, metresPerFrameUnit,
|
|
2317
|
+
pitch, imagingArea, boxSamples, spots: antiAliasing ? antiAliasing.components : [{ offsetMicrometers: { x: 0, y: 0 } }] }, nativeWidth, nativeHeight, (diffraction ? diffraction.kernelRadiusSamples : 0) + (displayViewDefocus ? (bokehAxisKernel?.radius ?? defocusDiscKernelRadius(displayViewDefocus.diameterSites)) : 0) +
|
|
2318
|
+
Math.max(0, ...queryOffsetsSites.map((o) => Math.max(Math.abs(o.x), Math.abs(o.y)))) - 0.5, imageSpaceOptions.join(", ")) : undefined;
|
|
1953
2319
|
if (displayView)
|
|
1954
|
-
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, 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 });
|
|
2320
|
+
return await renderView({ lens: lensSignal, 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 });
|
|
1955
2321
|
// Ideal reference (2.3): the scene without camera effects, at the capture's own exposure, white balance and encoding.
|
|
1956
2322
|
if (idealReference) {
|
|
1957
2323
|
const referenceStarted = clockMs();
|
|
@@ -1987,7 +2353,8 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1987
2353
|
sum[2] += s[2];
|
|
1988
2354
|
}
|
|
1989
2355
|
// Normalized RAW values saturate at the white level, as in development.
|
|
1990
|
-
|
|
2356
|
+
// #556: the development exposure push multiplies the normalized RAW before the white clip.
|
|
2357
|
+
const c = exposureGain === 1 ? sum.map((value) => Math.min(1, Math.max(0, value / (n * n)))) : sum.map((value) => Math.min(1, Math.max(0, value / (n * n) * exposureGain)));
|
|
1991
2358
|
development.developInto(c[0], c[1], c[2], rgb, (py * outputWidth + px) * 3, counts);
|
|
1992
2359
|
}
|
|
1993
2360
|
if ((py & 15) === 15) {
|
|
@@ -2072,20 +2439,28 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2072
2439
|
fail("serializeSimulatedCaptureDngFromParts writes the whole frame: the options take no output.region.");
|
|
2073
2440
|
const dngParts = run === "dng-from-parts" ? viewOption : undefined;
|
|
2074
2441
|
let tabulated;
|
|
2075
|
-
|
|
2442
|
+
// #553: a cat's eye varies the kernel over the field, which the expected-signal tables (one kernel per node) do not take.
|
|
2443
|
+
if (dngParts === undefined && tabulatedScene && !pupil && !lensFieldMapping && !vignetting && !lensPsf && !rotating && !bokehZones && (!diffraction || diffractionObjectSpace) && provider.chartMaterialAt && provider.materialRadiance) {
|
|
2076
2444
|
const { electronsPerRadiance, wavelengths } = axisChannelResponse("simulateCapture");
|
|
2077
2445
|
const perNode = CHANNELS.map((c) => Float64Array.from(wavelengths, (nm) => electronsPerRadiance[c].get(nm) ?? 0));
|
|
2078
2446
|
const sceneMaterials = chart ? chart.materials : callerScene.materials, materialRadiance = provider.materialRadiance;
|
|
2447
|
+
const tabulatedMaterials = { materialAt: provider.chartMaterialAt, whiteRadiance: (nm) => irradianceScale * illuminant.shape.evaluate(nm) / Math.PI,
|
|
2448
|
+
list: [...sceneMaterials.map((m, index) => (m.kind === "neutral" ? { neutral: m.reflectance } : { radiance: (nm) => materialRadiance(index, nm) })),
|
|
2449
|
+
...(chartSize ? [{ neutral: chartSize.surroundReflectance }] : [])] };
|
|
2079
2450
|
const { signalInput: builtSignalInput } = buildExpectedSignalInput({ nativeWidth, nativeHeight, width, wavelengths, perNode, apertureNodes: apertureNodeOffsets(boxSamples), boxSamples, antiAliasing, pitch,
|
|
2080
2451
|
diffraction: diffraction && !defocusSiteKernels ? { radius: diffraction.kernelRadiusSamples, psf: diffractionPsf.configuration.psf, profileWavelengths: diffraction.profile.axes.wavelengthNm } : undefined,
|
|
2081
|
-
materials:
|
|
2082
|
-
list: [...sceneMaterials.map((m, index) => (m.kind === "neutral" ? { neutral: m.reflectance } : { radiance: (nm) => materialRadiance(index, nm) })),
|
|
2083
|
-
...(chartSize ? [{ neutral: chartSize.surroundReflectance }] : [])] },
|
|
2084
|
-
radianceAt: provider.radianceAt, imagingArea, focalLengthMm, focusDistanceM, subjectDistanceM, metresPerFrameUnit, caller: "simulateCapture" });
|
|
2452
|
+
materials: tabulatedMaterials, radianceAt: provider.radianceAt, imagingArea, focalLengthMm, focusDistanceM, subjectDistanceM, metresPerFrameUnit, caller: "simulateCapture" });
|
|
2085
2453
|
// #492: with lens.defocus the table convolves the lattice radiance with each node's defocus (or combined) kernel, on the
|
|
2086
2454
|
// lattice formula, exactly as the object-space provider does.
|
|
2087
2455
|
let signalInput = builtSignalInput;
|
|
2088
|
-
if (defocusSiteKernels) {
|
|
2456
|
+
if (defocusSiteKernels?.large) {
|
|
2457
|
+
// #564: the in-focus signal on the lattice formula (diffraction kernels, or a unit tap per node), then the disc by runs.
|
|
2458
|
+
signalInput = buildExpectedSignalInput({ nativeWidth, nativeHeight, width, wavelengths, perNode, apertureNodes: apertureNodeOffsets(boxSamples), boxSamples, antiAliasing, pitch,
|
|
2459
|
+
diffraction: diffraction ? { radius: diffraction.kernelRadiusSamples, psf: diffractionPsf.configuration.psf, profileWavelengths: diffraction.profile.axes.wavelengthNm } : undefined,
|
|
2460
|
+
largeDefocus: defocusSiteKernels.large.runs, materials: tabulatedMaterials,
|
|
2461
|
+
radianceAt: provider.radianceAt, imagingArea, focalLengthMm, focusDistanceM, subjectDistanceM, metresPerFrameUnit, caller: "simulateCapture" }).signalInput;
|
|
2462
|
+
}
|
|
2463
|
+
else if (defocusSiteKernels) {
|
|
2089
2464
|
const lattice = { ...builtSignalInput };
|
|
2090
2465
|
delete lattice.queryPoint;
|
|
2091
2466
|
signalInput = { ...lattice, kernels: { radius: defocusSiteKernels.radius, perNode: wavelengths.map((nm) => defocusSiteKernels.at(nm)) } };
|
|
@@ -2193,10 +2568,52 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2193
2568
|
// Development
|
|
2194
2569
|
const development = { phaseProfiles, colorProfile,
|
|
2195
2570
|
whiteBalance: developmentWhiteBalance, rendering, highlightMode, ...(mirrorBorder ? { borderHandling: "mirror" } : {}),
|
|
2571
|
+
...(exposureCompensationStops === 0 ? {} : { exposureCompensationStops }),
|
|
2196
2572
|
maximumRetainedPayloadBytes: developmentPlan.outputBytes + outputWidth * outputHeight * 3 * (rendering.bitDepth / 8) };
|
|
2197
2573
|
let developed;
|
|
2198
2574
|
let gamutMappingDiagnostics;
|
|
2199
|
-
|
|
2575
|
+
// Development detail (#552): diffraction correction on the developed linear colour, then the unsharp mask between the sRGB
|
|
2576
|
+
// transfer and quantization. Both need the whole developed image, so a request with either develops through developLinearSrgb
|
|
2577
|
+
// (the native development's own operator); without them every path below is unchanged.
|
|
2578
|
+
const detailRequested = sharpening !== undefined || diffractionCorrectionOption !== undefined;
|
|
2579
|
+
const detailDiagnostics = {};
|
|
2580
|
+
let diffractionFilter;
|
|
2581
|
+
if (diffractionCorrectionOption) {
|
|
2582
|
+
const workingFNumber = fNumber * imageDistanceMm / focalLengthMm;
|
|
2583
|
+
const settings = { ...diffractionCorrectionOption, kernelRadiusPixels: diffractionCorrectionOption.kernelRadiusPixels ??
|
|
2584
|
+
defaultDiffractionCorrectionRadiusPixels(workingFNumber, diffractionCorrectionOption.wavelengthNm, pitch) };
|
|
2585
|
+
if (diffractionCorrectionOption.kernelRadiusPixels === undefined)
|
|
2586
|
+
assume("output.diffractionCorrection.kernelRadiusPixels", "default", settings.kernelRadiusPixels, "FIR radius: the Airy pattern's third dark ring (3.2383·λ·N_w) at the design wavelength, in pixels, rounded up, within 1–16.");
|
|
2587
|
+
diffractionFilter = designDiffractionCorrectionFilter({ workingFNumber, pixelPitchMicrometers: pitch, settings });
|
|
2588
|
+
assume("output.diffractionCorrection", "model", { model: "development-detail", modelVersion: DEVELOPMENT_DETAIL_MODEL_VERSION, method: "bounded-wiener-tikhonov-fir",
|
|
2589
|
+
stage: "linear colour after demosaic, colour and white balance; before the tone curve", fNumber, workingFNumber, ...settings, pixelPitchMicrometers: pitch,
|
|
2590
|
+
cutoffCyclesPerPixel: diffractionFilter.cutoffCyclesPerPixel, nyquistCyclesPerPixel: 0.5, nyquistAreaBeyondCutoffFraction: diffractionFilter.nyquistAreaBeyondCutoffFraction,
|
|
2591
|
+
designPeakGain: diffractionFilter.designPeakGain, responsePeakGain: diffractionFilter.responsePeakGain, responseMaximumBeyondCutoff: diffractionFilter.responseMaximumBeyondCutoff,
|
|
2592
|
+
edgeHandling: "clamp", recoversBeyondCutoff: false }, "Deconvolution with the known diffraction OTF of an ideal, aberration-free circular pupil at the capture's working f-number N_w = N·v/f, H(s) = (2/π)(acos s − s√(1 − s²)), s = f/f_c, f_c = 1/(λ·N_w) " +
|
|
2593
|
+
"(MIT OpenCourseWare 2.71, lectures 21–22). Gain G = min(maximumGain, H(1 + α)/(H² + α)) (Wiener deconvolution with a constant noise-to-signal ratio α, as in scikit-image's Wiener–Hunt filter), " +
|
|
2594
|
+
"sampled, inverse transformed and truncated to a raised-cosine (2R + 1)² FIR with unit DC gain, applied to each linear channel with clamped edges. " +
|
|
2595
|
+
"The OTF is exactly zero beyond f_c: that detail never reached the sensor, and no correction restores it. The design gain there is 0; responseMaximumBeyondCutoff is the truncated FIR's leakage, which passes only noise and aliasing. " +
|
|
2596
|
+
"Below f_c the gain is bounded, so attenuated detail is only partly restored and noise is amplified by up to the measured responsePeakGain. " +
|
|
2597
|
+
"Not inverted: the pixel aperture, optical anti-aliasing filter, demosaic blur, defocus and aberrations. One design wavelength for all channels (the simulated blur is polychromatic).");
|
|
2598
|
+
}
|
|
2599
|
+
const correctLinear = (rgb) => {
|
|
2600
|
+
if (!diffractionFilter)
|
|
2601
|
+
return rgb;
|
|
2602
|
+
const corrected = applyDiffractionCorrection(rgb, outputWidth, outputHeight, diffractionFilter);
|
|
2603
|
+
detailDiagnostics.diffractionCorrection = { ...corrected.diagnostics, cutoffCyclesPerPixel: diffractionFilter.cutoffCyclesPerPixel,
|
|
2604
|
+
responsePeakGain: diffractionFilter.responsePeakGain, responseMaximumBeyondCutoff: diffractionFilter.responseMaximumBeyondCutoff };
|
|
2605
|
+
return corrected.samples;
|
|
2606
|
+
};
|
|
2607
|
+
if (sharpening)
|
|
2608
|
+
assume("output.sharpening", "model", { model: "development-detail", modelVersion: DEVELOPMENT_DETAIL_MODEL_VERSION, method: "unsharp-mask", ...sharpening,
|
|
2609
|
+
kernelRadiusPixels: Math.ceil(3 * sharpening.radiusPixels), domain: "srgb-encoded", stage: "after the tone curve, gamut handling and sRGB transfer; before quantization", edgeHandling: "clamp" }, "Unsharp mask y = x + amount·(x − G_σ∗x) where |x − G_σ∗x| ≥ threshold (the GIMP and ImageMagick unsharp mask: radius, amount, threshold), per channel on the sRGB-encoded values in [0, 1], " +
|
|
2610
|
+
"separable Gaussian truncated at ⌈3σ⌉ with clamped edges, then clipped to [0, 1] and rounded. It raises contrast at edges and creates halos (overshoot and undershoot; see diagnostics.detail.sharpening) " +
|
|
2611
|
+
"and sharpens noise above the threshold. It adds no optical detail.");
|
|
2612
|
+
if (detailRequested)
|
|
2613
|
+
assume("output.developmentOrder", "model", [...(exposureGain === 1 ? [] : ["development exposure push (RAW)"]), "demosaic", "colour and white balance", ...(diffractionCorrectionOption ? ["diffraction correction (linear)"] : []),
|
|
2614
|
+
"rendering exposure and tone curve", "gamut handling", "transfer function", ...(sharpening ? ["unsharp mask"] : []), "quantization"], "Development detail order: the deconvolution acts on linear light, where the blur is a convolution; the unsharp mask acts on the encoded values just before quantization." +
|
|
2615
|
+
(exposureGain === 1 ? "" : " A development exposure push (output.exposureCompensationStops) scales the black-subtracted, white-normalized RAW first, so pushed sites clip at white before the deconvolution sees them."));
|
|
2616
|
+
if (ahd || (detailRequested && !linearPath)) {
|
|
2200
2617
|
let lastYield = Date.now();
|
|
2201
2618
|
const linear = await developLinearSrgb({ ...development, raw: rawOutput.raw }, async (completed, total) => {
|
|
2202
2619
|
onProgress?.({ stage: "development", completedTileCount: completed, totalTileCount: total });
|
|
@@ -2205,21 +2622,29 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2205
2622
|
checkAbort();
|
|
2206
2623
|
lastYield = Date.now();
|
|
2207
2624
|
}
|
|
2208
|
-
}, { yieldControl: () => yieldControl(), checkAbort, onDemosaicMs: (ms) => { demosaicMs = ms; } });
|
|
2209
|
-
|
|
2625
|
+
}, ahd ? { yieldControl: () => yieldControl(), checkAbort, onDemosaicMs: (ms) => { demosaicMs = ms; } } : undefined);
|
|
2626
|
+
const rgb = correctLinear(linear.rgb);
|
|
2627
|
+
if (!linearPath && sharpening) {
|
|
2628
|
+
const rendered = renderSdrWithUnsharpMask(rgb, outputWidth, outputHeight, rendering, sharpening);
|
|
2629
|
+
detailDiagnostics.sharpening = rendered.sharpening;
|
|
2630
|
+
developed = { raw: rawOutput.raw, integerSamples: rendered.codes, highlight: linear.highlight, diagnostics: { renderingNegativeSampleCount: rendered.counts.negative,
|
|
2631
|
+
renderingAboveReferenceSampleCount: rendered.counts.above, toneChangedSampleCount: rendered.counts.changed, gamutClippedLowSampleCount: rendered.counts.low,
|
|
2632
|
+
gamutClippedHighSampleCount: rendered.counts.high, captureSaturation: "consumed-in-raw-development" } };
|
|
2633
|
+
}
|
|
2634
|
+
else if (!linearPath) {
|
|
2210
2635
|
// The native development's SDR kernel, row by row, on the AHD-developed linear sRGB.
|
|
2211
2636
|
const maximum = 2 ** rendering.bitDepth - 1, tone = prepareSdrPixelTone(rendering), scratch = new Float64Array(3);
|
|
2212
2637
|
const codes = rendering.bitDepth === 8 ? new Uint8Array(outputWidth * outputHeight * 3) : new Uint16Array(outputWidth * outputHeight * 3);
|
|
2213
2638
|
const counts = { negative: 0, above: 0, changed: 0, low: 0, high: 0, outOfGamut: false };
|
|
2214
2639
|
for (let y = 0; y < outputHeight; y++)
|
|
2215
|
-
renderSdrRunInto(
|
|
2640
|
+
renderSdrRunInto(rgb, y * outputWidth * 3, outputWidth, rendering, tone, 2 ** rendering.renderingExposureEv, maximum, codes, y * outputWidth * 3, scratch, counts);
|
|
2216
2641
|
developed = { raw: rawOutput.raw, integerSamples: codes, highlight: linear.highlight, diagnostics: { renderingNegativeSampleCount: counts.negative,
|
|
2217
2642
|
renderingAboveReferenceSampleCount: counts.above, toneChangedSampleCount: counts.changed, gamutClippedLowSampleCount: counts.low,
|
|
2218
2643
|
gamutClippedHighSampleCount: counts.high, captureSaturation: "consumed-in-raw-development" } };
|
|
2219
2644
|
}
|
|
2220
2645
|
else {
|
|
2221
2646
|
const mapping = perceptual ? prepareRgbGamutMapping({ colorSpace: colorEncoding, method: "cielab-hue-preserving-chroma-compression", kneeFraction: PERCEPTUAL_GAMUT_KNEE_FRACTION }) : undefined;
|
|
2222
|
-
const encoded = encodeSdrFromLinear(
|
|
2647
|
+
const encoded = encodeSdrFromLinear(rgb, colorEncoding, pngBitDepth, mapping);
|
|
2223
2648
|
developed = { raw: rawOutput.raw, integerSamples: encoded.samples, highlight: linear.highlight, diagnostics: encoded.diagnostics };
|
|
2224
2649
|
if (mapping)
|
|
2225
2650
|
gamutMappingDiagnostics = { method: "cielab-hue-preserving-chroma-compression", kneeFraction: PERCEPTUAL_GAMUT_KNEE_FRACTION,
|
|
@@ -2245,7 +2670,7 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2245
2670
|
});
|
|
2246
2671
|
{
|
|
2247
2672
|
const mapping = perceptual ? prepareRgbGamutMapping({ colorSpace: colorEncoding, method: "cielab-hue-preserving-chroma-compression", kneeFraction: PERCEPTUAL_GAMUT_KNEE_FRACTION }) : undefined;
|
|
2248
|
-
const encoded = encodeSdrFromLinear(linear.rgb, colorEncoding, pngBitDepth, mapping);
|
|
2673
|
+
const encoded = encodeSdrFromLinear(correctLinear(linear.rgb), colorEncoding, pngBitDepth, mapping);
|
|
2249
2674
|
developed = { raw: rawOutput.raw, integerSamples: encoded.samples, highlight: linear.highlight, diagnostics: encoded.diagnostics };
|
|
2250
2675
|
if (mapping) {
|
|
2251
2676
|
gamutMappingDiagnostics = { method: "cielab-hue-preserving-chroma-compression", kneeFraction: PERCEPTUAL_GAMUT_KNEE_FRACTION,
|
|
@@ -2301,19 +2726,24 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2301
2726
|
}
|
|
2302
2727
|
const ranOptions = new Set([...(pupil ? ["lens.pupilSampling"] : []), ...(projection !== undefined ? ["lens.projection"] : []),
|
|
2303
2728
|
...(distortion ? ["lens.distortion"] : []), ...(vignetting ? ["lens.vignetting"] : []), ...(lensPsf ? ["lens.psf"] : []),
|
|
2304
|
-
...(
|
|
2729
|
+
...(catalogue ? ["lens.catalogue"] : []),
|
|
2730
|
+
...(diffraction ? ["lens.diffraction"] : []), ...(defocusSiteKernels || catalogueDefocusApplied ? ["lens.defocus"] : []), ...(antiAliasing ? ["sensor.antiAliasing"] : []), ...(boxAperture ? ["sensor.pixelAperture.box"] : []),
|
|
2305
2731
|
...(temporalSampleCount > 1 ? ["sensor.temporalSampleCount"] : []), ...(rotating ? ["motion.rotation"] : []),
|
|
2306
2732
|
...(rollingSchedule ? ["motion.shutter.rolling"] : []), ...(mirrorBorder ? ["output.borderHandling.mirror"] : []), ...(region ? ["output.region"] : []),
|
|
2307
2733
|
...(needsAuto ? ["output.whiteBalance.auto"] : []), ...(ahd ? ["output.demosaic.ahd"] : []),
|
|
2308
2734
|
...(highlightMode === "reconstruct" ? ["output.highlightMode.reconstruct"] : []), ...(wideGamut ? ["output.colorEncoding.wide-gamut"] : []),
|
|
2309
|
-
...(perceptual ? ["output.gamutMapping.perceptual"] : []), ...(
|
|
2735
|
+
...(perceptual ? ["output.gamutMapping.perceptual"] : []), ...(diffractionFilter ? ["output.diffractionCorrection"] : []),
|
|
2736
|
+
...(sharpening ? ["output.sharpening"] : []), ...(format === "jpeg" || format === "png" || format === "dng" ? ["output.format." + format] : [])]);
|
|
2737
|
+
// #552: the detail stages' contracts, in execution order inside the development.
|
|
2738
|
+
const detailContracts = diffractionFilter ? ["designDiffractionCorrectionFilter", "applyDiffractionCorrection"] : [], sdrContract = sharpening ? "renderSdrWithUnsharpMask" : "SDR rendering kernel";
|
|
2310
2739
|
return Object.freeze({
|
|
2311
2740
|
facadeVersion: SIMULATE_CAPTURE_FACADE_VERSION, engineApiVersion: ENGINE_API_VERSION,
|
|
2312
2741
|
image: { width: outputWidth, height: outputHeight, format, samples: developed.integerSamples, ...(jpeg ? { jpeg } : {}), ...(png ? { png, colorEncoding: colorEncoding } : {}), ...(dng ? { dng: dng.bytes } : {}) },
|
|
2313
2742
|
raw: developed.raw, plan: developmentPlan,
|
|
2314
2743
|
...(region && regionNative ? { region: { ...region, frameWidth: width, frameHeight: height, nativeRect: { ...regionNative } } } : {}),
|
|
2315
2744
|
request: { environmentRaw: finalRawInput, siteInput, development, whiteBalanceState,
|
|
2316
|
-
...(diffraction ? { diffraction: { profile: diffraction, psf: diffractionPsf, execution: diffractionObjectSpace ? "object-space-convolution" :
|
|
2745
|
+
...(diffraction ? { diffraction: { profile: diffraction, psf: diffractionPsf, execution: diffractionObjectSpace ? "object-space-convolution" :
|
|
2746
|
+
defocusImageSpace ? "image-space-convolution" : "environment-psf-quadrature" } } : {}) },
|
|
2317
2747
|
exposure: { mode, settings: { fNumber, shutterSeconds, iso: resolvedIso }, ev100: Math.log2(fNumber ** 2 / shutterSeconds), ...isoReport,
|
|
2318
2748
|
metering, meteredSceneLuminanceCdPerSquareMeter: meteredLuminance, standardExposureDeviationStops: exposureDeviationStops,
|
|
2319
2749
|
standardExposureIlluminanceLux: exposureIlluminanceLux, greyCardDeviationStops: exposureGreyCardDeviationStops, resolution },
|
|
@@ -2321,6 +2751,7 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2321
2751
|
illuminant: { id: illuminant.id, description: illuminant.description, illuminanceLux }, distanceM: subjectDistanceM,
|
|
2322
2752
|
framedWidthM: 2 * metresPerFrameUnit, framedHeightM: 2 * metresPerFrameUnit * aspect, ...(chartReport ? { chart: chartReport } : {}), sourceSha256 },
|
|
2323
2753
|
diagnostics: { providerEvaluationCount, highlight: developed.highlight, rendering: developed.diagnostics, ...(gamutMappingDiagnostics ? { gamutMapping: gamutMappingDiagnostics } : {}),
|
|
2754
|
+
...(detailRequested ? { detail: Object.freeze(detailDiagnostics) } : {}),
|
|
2324
2755
|
timingsMs: { raw: rawMs, whiteBalance: wbMs, development: devMs, jpeg: jpegMs, total: Date.now() - started,
|
|
2325
2756
|
...(diffraction && diffractionObjectSpace && !defocusSiteKernels ? { diffraction: diffractionMs } : {}), ...(defocusSiteKernels ? { defocus: defocusMs } : {}), ...(demosaicMs === undefined ? {} : { demosaic: demosaicMs }),
|
|
2326
2757
|
...(encodeMs === undefined ? {} : { encode: encodeMs }) },
|
|
@@ -2331,10 +2762,12 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2331
2762
|
"resolve" + (mode === "manual" ? "Manual" : mode === "aperture-priority" ? "AperturePriority" : mode === "shutter-priority" ? "ShutterPriority" : "ProgramAuto") + "ExposureMode",
|
|
2332
2763
|
...(rollingSchedule ? ["resolveElectronicRollingShutterExposureSchedule", "createRollingShutterCaptureExposureSchedule"] : []),
|
|
2333
2764
|
...(diffraction ? ["createDiffractionLimitedPsfProfile", ...(diffractionObjectSpace ? ["resolveLensPsfConvolutionKernels", ...(defocusSiteKernels ? [] : ["calculateIrradianceConvolution"])] : [])] : []),
|
|
2334
|
-
...(defocusSiteKernels ? ["calculateDefocusCircle", "defocus disc site kernel (object-space convolution)"] : []),
|
|
2765
|
+
...(defocusSiteKernels ? ["calculateDefocusCircle", defocusImageSpace ? "defocus disc site kernel (image-space convolution)" : "defocus disc site kernel (object-space convolution)"] : []),
|
|
2335
2766
|
...(antiAliasing ? ["resolveAntiAliasingSpatialKernel"] : []), "resolveGenericIsoSignalChain", "createNativeEnvironmentRawTask", ...(needsAuto ? ["estimateAutoWhiteBalance"] : []),
|
|
2336
2767
|
...(builtInDemosaic ? ["createBuiltInBayerDemosaicProfiles"] : []), ...(ahd ? ["createAhdDemosaicTask"] : []),
|
|
2337
|
-
...(ahd ? ["native RAW development operator (colour, after AHD)", ...(linearPath ? ["resolveRgbColorSpace", ...(perceptual ? ["prepareRgbGamutMapping"] : [])] : [
|
|
2768
|
+
...(ahd ? ["native RAW development operator (colour, after AHD)", ...detailContracts, ...(linearPath ? ["resolveRgbColorSpace", ...(perceptual ? ["prepareRgbGamutMapping"] : [])] : [sdrContract])] :
|
|
2769
|
+
linearPath ? ["native RAW development operator (linear, pre-clip)", ...detailContracts, "resolveRgbColorSpace", ...(perceptual ? ["prepareRgbGamutMapping"] : [])] :
|
|
2770
|
+
detailRequested ? ["native RAW development operator (linear, pre-clip)", ...detailContracts, sdrContract] : ["createNativeRawDevelopmentTask"]),
|
|
2338
2771
|
...(jpeg ? ["createPrintJpegTask"] : []), ...(png ? ["serializePngImage"] : []), ...(dng ? ["resolveSensorReadoutRegime", "serializeNativeRawDng"] : [])],
|
|
2339
2772
|
limitations: ["Educational approximation: synthetic tier sensor values, idealized colorimetric sensor, planar Lambertian scene and point optics; not a calibrated camera emulation.",
|
|
2340
2773
|
"Not offered by the facade: atmospheric haze and polarizers (the scene-radiance provider contract declares no volumetric transport or polarization), flash, video, HDR brackets and processing operators; compose those APIs directly."] },
|