@photivra/engine 2.19.0 → 2.21.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 +289 -1
- package/CITATION.cff +1 -1
- package/NODE_SUPPORT.md +7 -5
- package/README.md +7 -6
- package/SECURITY.md +1 -1
- package/THIRD_PARTY.md +21 -10
- package/dist/capture/dng-lens-correction-opcodes.d.ts +32 -0
- package/dist/capture/dng-lens-correction-opcodes.js +119 -0
- package/dist/capture/dng-lens-correction-opcodes.js.map +1 -0
- package/dist/capture/exposure-build-up-stage.d.ts +16 -0
- package/dist/capture/exposure-build-up-stage.js +4 -0
- package/dist/capture/exposure-build-up-stage.js.map +1 -0
- package/dist/capture/native-environment-lean.d.ts +2 -1
- package/dist/capture/native-environment-lean.js +42 -78
- package/dist/capture/native-environment-lean.js.map +1 -1
- package/dist/capture/native-environment-tabulated.js +4 -3
- package/dist/capture/native-environment-tabulated.js.map +1 -1
- package/dist/capture/native-raw-dng.d.ts +6 -0
- package/dist/capture/native-raw-dng.js +16 -2
- package/dist/capture/native-raw-dng.js.map +1 -1
- package/dist/capture/native-raw.d.ts +3 -2
- package/dist/capture/native-raw.js +5 -4
- package/dist/capture/native-raw.js.map +1 -1
- package/dist/capture/sensor-raw-producer.d.ts +4 -1
- package/dist/capture/sensor-raw-producer.js +15 -8
- package/dist/capture/sensor-raw-producer.js.map +1 -1
- package/dist/core/shared-helpers.d.ts +2 -0
- package/dist/core/shared-helpers.js +4 -0
- package/dist/core/shared-helpers.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 +11 -4
- package/dist/equipment/generic-lens-catalogue.js +49 -7
- package/dist/equipment/generic-lens-catalogue.js.map +1 -1
- package/dist/equipment/generic-tier-assets.d.ts +32 -0
- package/dist/equipment/generic-tier-assets.js +18 -14
- package/dist/equipment/generic-tier-assets.js.map +1 -1
- package/dist/equipment/generic-tier-presets.js +2 -3
- package/dist/equipment/generic-tier-presets.js.map +1 -1
- package/dist/equipment/sensor-profile-body.d.ts +78 -0
- package/dist/equipment/sensor-profile-body.js +77 -0
- package/dist/equipment/sensor-profile-body.js.map +1 -0
- package/dist/equipment/sensor-profiles.d.ts +8 -0
- package/dist/equipment/sensor-profiles.js +82 -0
- package/dist/equipment/sensor-profiles.js.map +1 -0
- package/dist/facade/catalogue-lens-signal.d.ts +2 -2
- package/dist/facade/catalogue-lens-signal.js +58 -71
- package/dist/facade/catalogue-lens-signal.js.map +1 -1
- package/dist/facade/chart-radiance-batch.js +2 -1
- package/dist/facade/chart-radiance-batch.js.map +1 -1
- package/dist/facade/defocus-image-space-provider.d.ts +7 -0
- package/dist/facade/defocus-image-space-provider.js +64 -76
- package/dist/facade/defocus-image-space-provider.js.map +1 -1
- package/dist/facade/display-view-gpu-session.js +2 -0
- package/dist/facade/display-view-gpu-session.js.map +1 -1
- package/dist/facade/display-view-gpu.d.ts +13 -5
- package/dist/facade/display-view-gpu.js +4 -4
- package/dist/facade/display-view-gpu.js.map +1 -1
- package/dist/facade/display-view-lens-signal.d.ts +1 -1
- package/dist/facade/display-view-lens-signal.js +30 -26
- package/dist/facade/display-view-lens-signal.js.map +1 -1
- package/dist/facade/display-view.d.ts +3 -1
- package/dist/facade/display-view.js +6 -24
- package/dist/facade/display-view.js.map +1 -1
- package/dist/facade/expected-signal-setup.d.ts +3 -1
- package/dist/facade/expected-signal-setup.js +1 -1
- package/dist/facade/expected-signal-setup.js.map +1 -1
- package/dist/facade/expected-signal.d.ts +47 -14
- package/dist/facade/expected-signal.js +117 -112
- package/dist/facade/expected-signal.js.map +1 -1
- package/dist/facade/exposure-build-up.d.ts +70 -0
- package/dist/facade/exposure-build-up.js +164 -0
- package/dist/facade/exposure-build-up.js.map +1 -0
- package/dist/facade/fast-methods.d.ts +50 -0
- package/dist/facade/fast-methods.js +58 -0
- package/dist/facade/fast-methods.js.map +1 -0
- package/dist/facade/fast-resolution.d.ts +12 -0
- package/dist/facade/fast-resolution.js +18 -0
- package/dist/facade/fast-resolution.js.map +1 -0
- package/dist/facade/generic-lens-capture.d.ts +5 -4
- package/dist/facade/generic-lens-capture.js +5 -4
- package/dist/facade/generic-lens-capture.js.map +1 -1
- package/dist/facade/image-plane-enable.d.ts +7 -0
- package/dist/facade/image-plane-enable.js +17 -0
- package/dist/facade/image-plane-enable.js.map +1 -0
- package/dist/facade/image-plane-lattice-data.d.ts +6 -0
- package/dist/facade/image-plane-lattice-data.js +10 -0
- package/dist/facade/image-plane-lattice-data.js.map +1 -0
- package/dist/facade/image-plane-support.d.ts +33 -0
- package/dist/facade/image-plane-support.js +11 -0
- package/dist/facade/image-plane-support.js.map +1 -0
- package/dist/facade/image-plane.d.ts +36 -0
- package/dist/facade/image-plane.js +353 -0
- package/dist/facade/image-plane.js.map +1 -0
- package/dist/facade/mixed-illumination.d.ts +108 -0
- package/dist/facade/mixed-illumination.js +174 -0
- package/dist/facade/mixed-illumination.js.map +1 -0
- package/dist/facade/representative-light-sources.d.ts +72 -0
- package/dist/facade/representative-light-sources.js +124 -0
- package/dist/facade/representative-light-sources.js.map +1 -0
- package/dist/facade/scene-radiance.d.ts +129 -4
- package/dist/facade/scene-radiance.js +48 -13
- package/dist/facade/scene-radiance.js.map +1 -1
- package/dist/facade/simulate-capture.d.ts +88 -19
- package/dist/facade/simulate-capture.js +374 -145
- package/dist/facade/simulate-capture.js.map +1 -1
- package/dist/facade/simulate-display-view.d.ts +3 -2
- package/dist/facade/simulate-display-view.js +36 -18
- package/dist/facade/simulate-display-view.js.map +1 -1
- package/dist/gpu/chart-composite-gpu.d.ts +13 -1
- package/dist/gpu/chart-composite-gpu.js +48 -9
- package/dist/gpu/chart-composite-gpu.js.map +1 -1
- package/dist/gpu/chart-radiance-gpu.js +4 -30
- package/dist/gpu/chart-radiance-gpu.js.map +1 -1
- package/dist/gpu/display-view-composite-material-module.d.ts +10 -4
- package/dist/gpu/display-view-composite-material-module.js +39 -13
- package/dist/gpu/display-view-composite-material-module.js.map +1 -1
- package/dist/gpu/display-view-image-material-module.d.ts +86 -0
- package/dist/gpu/display-view-image-material-module.js +339 -0
- package/dist/gpu/display-view-image-material-module.js.map +1 -0
- package/dist/gpu/display-view-image-quality.d.ts +45 -0
- package/dist/gpu/display-view-image-quality.js +380 -0
- package/dist/gpu/display-view-image-quality.js.map +1 -0
- package/dist/gpu/display-view-quality-gpu.d.ts +67 -1
- package/dist/gpu/display-view-quality-gpu.js +136 -205
- package/dist/gpu/display-view-quality-gpu.js.map +1 -1
- package/dist/gpu/display-view-speed-gpu.d.ts +51 -3
- package/dist/gpu/display-view-speed-gpu.js +131 -93
- package/dist/gpu/display-view-speed-gpu.js.map +1 -1
- package/dist/gpu/wgsl-display-view-quality-kernel.d.ts +29 -15
- package/dist/gpu/wgsl-display-view-quality-kernel.js +58 -36
- package/dist/gpu/wgsl-display-view-quality-kernel.js.map +1 -1
- package/dist/gpu/wgsl-display-view-speed-kernel.d.ts +21 -6
- package/dist/gpu/wgsl-display-view-speed-kernel.js +46 -11
- package/dist/gpu/wgsl-display-view-speed-kernel.js.map +1 -1
- package/dist/gpu/wgsl-exact-sum.d.ts +20 -0
- package/dist/gpu/wgsl-exact-sum.js +42 -0
- package/dist/gpu/wgsl-exact-sum.js.map +1 -0
- package/dist/index.d.ts +18 -3
- package/dist/index.js +12 -1
- package/dist/index.js.map +1 -1
- package/dist/motion/subject-motion.d.ts +66 -0
- package/dist/motion/subject-motion.js +52 -0
- package/dist/motion/subject-motion.js.map +1 -0
- package/dist/optics/aperture-diameter.d.ts +47 -0
- package/dist/optics/aperture-diameter.js +50 -0
- package/dist/optics/aperture-diameter.js.map +1 -0
- package/dist/optics/detail-limit.d.ts +182 -0
- package/dist/optics/detail-limit.js +336 -0
- package/dist/optics/detail-limit.js.map +1 -0
- package/dist/optics/generic-lens-lateral-color.d.ts +83 -0
- package/dist/optics/generic-lens-lateral-color.js +77 -0
- package/dist/optics/generic-lens-lateral-color.js.map +1 -0
- package/dist/optics/generic-lens-psf.d.ts +18 -2
- package/dist/optics/generic-lens-psf.js +79 -48
- package/dist/optics/generic-lens-psf.js.map +1 -1
- package/dist/optics/lens-psf-profile.d.ts +7 -0
- package/dist/optics/lens-psf-profile.js +2 -1
- package/dist/optics/lens-psf-profile.js.map +1 -1
- package/dist/optics/scene-to-sensor-irradiance.d.ts +13 -0
- package/dist/optics/scene-to-sensor-irradiance.js +7 -0
- package/dist/optics/scene-to-sensor-irradiance.js.map +1 -1
- package/dist/output/crop-to-match-focal-length.d.ts +62 -0
- package/dist/output/crop-to-match-focal-length.js +63 -0
- package/dist/output/crop-to-match-focal-length.js.map +1 -0
- package/dist/output/developed-lens-corrections.d.ts +19 -5
- package/dist/output/developed-lens-corrections.js +30 -11
- package/dist/output/developed-lens-corrections.js.map +1 -1
- package/dist/output/generic-lens-corrections.d.ts +41 -3
- package/dist/output/generic-lens-corrections.js +57 -9
- package/dist/output/generic-lens-corrections.js.map +1 -1
- package/dist/output/lens-corrections.d.ts +12 -2
- package/dist/output/lens-corrections.js +22 -7
- package/dist/output/lens-corrections.js.map +1 -1
- package/dist/output/print-contrast-reference.js +1 -1
- package/dist/output/print-contrast-reference.js.map +1 -1
- package/dist/output/print-plan.d.ts +41 -5
- package/dist/output/print-plan.js +59 -14
- package/dist/output/print-plan.js.map +1 -1
- package/dist/output/print-size-blur-noise.d.ts +110 -0
- package/dist/output/print-size-blur-noise.js +138 -0
- package/dist/output/print-size-blur-noise.js.map +1 -0
- package/dist/sensor/binomial-sample.d.ts +3 -0
- package/dist/sensor/binomial-sample.js +86 -0
- package/dist/sensor/binomial-sample.js.map +1 -0
- package/dist/sensor/eqe-electron-rate.js +1 -7
- package/dist/sensor/eqe-electron-rate.js.map +1 -1
- package/dist/sensor/exposure-window.d.ts +2 -0
- package/dist/sensor/exposure-window.js +2 -2
- package/dist/sensor/exposure-window.js.map +1 -1
- package/dist/sensor/readout-timing.js +2 -23
- package/dist/sensor/readout-timing.js.map +1 -1
- package/dist/sensor/response-application-compatibility.js +1 -6
- package/dist/sensor/response-application-compatibility.js.map +1 -1
- package/dist/sensor/responsivity-photocurrent.js +1 -7
- package/dist/sensor/responsivity-photocurrent.js.map +1 -1
- package/dist/sensor/sensor-characteristics.d.ts +147 -0
- package/dist/sensor/sensor-characteristics.js +126 -0
- package/dist/sensor/sensor-characteristics.js.map +1 -0
- package/dist/sensor/spatial-sample-reduction.js +1 -5
- package/dist/sensor/spatial-sample-reduction.js.map +1 -1
- package/dist/sensor/spectral-quadrature.js +1 -6
- package/dist/sensor/spectral-quadrature.js.map +1 -1
- package/dist/sensor/temporal-photo-signal.d.ts +3 -0
- package/dist/sensor/temporal-photo-signal.js +5 -2
- package/dist/sensor/temporal-photo-signal.js.map +1 -1
- package/dist/stabilization/system.d.ts +9 -0
- package/dist/stabilization/system.js +5 -6
- package/dist/stabilization/system.js.map +1 -1
- package/docs/AGENT_GUIDE.md +5 -0
- package/docs/API_REFERENCE.md +173 -2
- package/docs/QUICK_START.md +213 -6
- package/docs/USAGE.md +157 -3
- package/docs/api/capture-native-raw-dng.md +6 -0
- package/docs/api/core-version.md +1 -1
- package/docs/api/equipment-generic-lens-catalogue.md +17 -7
- package/docs/api/equipment-sensor-profile-body.md +64 -0
- package/docs/api/equipment-sensor-profiles.md +86 -0
- package/docs/api/exports.json +357 -2
- package/docs/api/facade-display-view-gpu.md +5 -2
- package/docs/api/facade-exposure-build-up.md +105 -0
- package/docs/api/facade-fast-methods.md +99 -0
- package/docs/api/facade-image-plane-enable.md +16 -0
- package/docs/api/facade-mixed-illumination.md +67 -0
- package/docs/api/facade-representative-light-sources.md +131 -0
- package/docs/api/facade-scene-radiance.md +54 -3
- package/docs/api/facade-simulate-capture.md +60 -15
- package/docs/api/facade-simulate-display-view.md +3 -2
- package/docs/api/motion-subject-motion.md +23 -0
- package/docs/api/optics-aperture-diameter.md +51 -0
- package/docs/api/optics-detail-limit.md +203 -0
- package/docs/api/optics-generic-lens-lateral-color.md +79 -0
- package/docs/api/output-crop-to-match-focal-length.md +67 -0
- package/docs/api/output-developed-lens-corrections.md +10 -1
- package/docs/api/output-generic-lens-corrections.md +40 -2
- package/docs/api/output-lens-corrections.md +11 -1
- package/docs/api/output-print-plan.md +35 -1
- package/docs/api/output-print-size-blur-noise.md +115 -0
- package/docs/api/sensor-sensor-characteristics.md +105 -0
- package/llms.txt +6 -0
- package/package.json +1 -1
|
@@ -35,7 +35,10 @@ import { requireInputObject } from "../core/record-validation.js";
|
|
|
35
35
|
import { ENGINE_API_VERSION } from "../core/version.js";
|
|
36
36
|
import { GENERIC_TIER_ASSETS } from "../equipment/generic-tier-assets.js";
|
|
37
37
|
import { parseCallerCameraPreset } from "./camera-preset.js";
|
|
38
|
+
import * as sensorProfileKit from "../equipment/sensor-profile-body.js";
|
|
38
39
|
import { createHostYield } from "./host-yield.js";
|
|
40
|
+
import { fastFormatDefault, resolveFast } from "./fast-resolution.js";
|
|
41
|
+
import { SCENE_TO_SENSOR_BRIDGE_SCOPE } from "../optics/scene-to-sensor-irradiance.js";
|
|
39
42
|
import { resolveGenericEquipmentExposureCapabilities } from "../equipment/exposure-capabilities.js";
|
|
40
43
|
import { resolveGenericIsoSignalChain } from "../sensor/iso-signal-chain.js";
|
|
41
44
|
import { meterRelativeExposure } from "../exposure/metering.js";
|
|
@@ -51,6 +54,7 @@ import { SENSOR_RAW_PRODUCER_NOISE_MODEL } from "../capture/sensor-raw-producer.
|
|
|
51
54
|
import { calculateNativeRawPlan, nativeRawMaximumProviderEvaluations, NATIVE_RAW_LIMITS } from "../capture/native-raw.js";
|
|
52
55
|
import { renderTabulatedNativeRaw, TABULATED_RELATIVE_TOLERANCE } from "../capture/native-environment-tabulated.js";
|
|
53
56
|
import { createDisplayViewExpectedSignal } from "./expected-signal.js";
|
|
57
|
+
import * as mixedIlluminationKit from "./mixed-illumination.js";
|
|
54
58
|
import { buildExpectedSignalInput } from "./expected-signal-setup.js";
|
|
55
59
|
import { createNativeEnvironmentRawTask, createNativeEnvironmentRawRegionTask } from "../capture/native-environment-raw.js";
|
|
56
60
|
import { createNativeRawDevelopmentTask, NATIVE_RAW_DEVELOPMENT_EXPOSURE_RANGE_STOPS } from "../capture/native-raw-development.js";
|
|
@@ -91,9 +95,12 @@ import { multiplyMatrices } from "../color/colorimetry-math.js";
|
|
|
91
95
|
import { prepareRgbGamutMapping } from "../color/gamut-mapping.js";
|
|
92
96
|
import { serializeNativeRawDng } from "../capture/native-raw-dng.js";
|
|
93
97
|
import { correctDiffractionInPlace, defaultDiffractionCorrectionRadiusPixels, designDiffractionCorrectionFilter, parseDiffractionCorrectionSettings, parseUnsharpMaskSettings, renderSdrWithUnsharpMaskInPlace, DEVELOPMENT_DETAIL_MODEL_VERSION } from "../processing/development-detail.js";
|
|
94
|
-
import {
|
|
98
|
+
import { resolveSensorReadoutRegime } from "../sensor/raw-readout.js";
|
|
99
|
+
import { createSubjectMotionProvider, subjectTravelM } from "../motion/subject-motion.js";
|
|
100
|
+
import { characterizeReadout, parseSensorCharacteristics, sensorReport, SENSOR_CHARACTERISTIC_KEYS } from "../sensor/sensor-characteristics.js";
|
|
95
101
|
import { resolveChartScene, chartMaterialInputs, CHART_SCENE_PRESETS } from "./chart-scenes.js";
|
|
96
102
|
import { resolveChartMaterialLayers } from "./chart-material-layers.js";
|
|
103
|
+
import { imagePlaneSupport } from "./image-plane-support.js";
|
|
97
104
|
import { resolveCaptureIlluminant, calculateIlluminantIrradianceScale, buildPlanarSceneRadianceProvider, requireCallerScene, FACADE_SPECTRAL_RANGE_NANOMETERS, resolveChartSizeOption, PHOTOPIC_MAXIMUM_LUMINOUS_EFFICACY_LM_PER_W, planarProviderMaterialOfDirection } from "./scene-radiance.js";
|
|
98
105
|
/** Version of how the facade builds requests. It changes whenever the same input would build a different request. */
|
|
99
106
|
export const SIMULATE_CAPTURE_FACADE_VERSION = "1.0.0";
|
|
@@ -105,7 +112,7 @@ export const SIMULATE_CAPTURE_BOX_APERTURE_DEFAULT_SAMPLES = 4;
|
|
|
105
112
|
* a capture does. `motion.rotation` is a non-zero angular velocity; `sensor.temporalSampleCount` a count above 1.
|
|
106
113
|
*/
|
|
107
114
|
export const SIMULATE_CAPTURE_COST_OPTIONS = /* @__PURE__ */ Object.freeze(["lens.pupilSampling", "lens.projection", "lens.distortion", "lens.vignetting", "lens.psf",
|
|
108
|
-
"lens.diffraction", "lens.defocus", "lens.catalogue", "sensor.antiAliasing", "sensor.pixelAperture.box", "sensor.temporalSampleCount", "motion.rotation", "motion.shutter.rolling", "output.borderHandling.mirror", "output.region",
|
|
115
|
+
"lens.diffraction", "lens.defocus", "lens.catalogue", "sensor.antiAliasing", "sensor.pixelAperture.box", "sensor.temporalSampleCount", "motion.rotation", "motion.subject", "motion.shutter.rolling", "output.borderHandling.mirror", "output.region",
|
|
109
116
|
"output.whiteBalance.auto", "output.demosaic.ahd", "output.highlightMode.reconstruct", "output.colorEncoding.wide-gamut", "output.gamutMapping.perceptual",
|
|
110
117
|
"output.diffractionCorrection", "output.sharpening", "output.lensCorrections", "output.format.jpeg", "output.format.png", "output.format.dng"]);
|
|
111
118
|
/**
|
|
@@ -113,15 +120,17 @@ export const SIMULATE_CAPTURE_COST_OPTIONS = /* @__PURE__ */ Object.freeze(["len
|
|
|
113
120
|
* the record form with `fast` and is reported in `diagnostics.fast.applied` when it runs. Every one of them takes the flag,
|
|
114
121
|
* whether or not it has a fast method yet; one without runs exact and reports `false`.
|
|
115
122
|
*/
|
|
116
|
-
export const SIMULATE_CAPTURE_FAST_OPTIONS = /* @__PURE__ */ Object.freeze(["lens.diffraction", "lens.catalogue", "sensor.antiAliasing", "sensor.pixelAperture.box", "output.demosaic.ahd"]);
|
|
123
|
+
export const SIMULATE_CAPTURE_FAST_OPTIONS = /* @__PURE__ */ Object.freeze(["lens.diffraction", "lens.catalogue", "sensor.antiAliasing", "sensor.pixelAperture.box", "output.demosaic.ahd", "motion.subject"]);
|
|
117
124
|
export const FACADE_EVIDENCE = /* @__PURE__ */ Object.freeze([/* @__PURE__ */ Object.freeze({ sourceOrigin: "photivra",
|
|
118
125
|
sourceReference: "photivra:simulate-capture-facade", reuseStatus: "photivra-owned" })]);
|
|
119
126
|
const DEFAULTS = /* @__PURE__ */ Object.freeze({ camera: "professional", fNumber: 8, shutterSeconds: 1 / 125, iso: 100, compensationStops: 0,
|
|
120
127
|
subjectDistanceM: 5, width: 64, height: 64, format: "srgb-8", toneCurve: "identity", highlightMode: "clip",
|
|
121
|
-
whiteBalance: "scene-illuminant", jpegQuantizationStep:
|
|
128
|
+
whiteBalance: "scene-illuminant", jpegQuantizationStep: 1, pixelPitchMicrometers: 6, spectralStepNanometers: 20,
|
|
122
129
|
temporalSampleCount: 1, noiseSeed: 1, illuminantKelvin: 6500 });
|
|
123
130
|
/** Standard output sensitivity S = 10 / H (CIPA DC-004-2020 §3.2): the reference exposure in lx·s is 10 / S. */
|
|
124
131
|
const SOS_REFERENCE_EXPOSURE_NUMERATOR_LUX_SECONDS = 10;
|
|
132
|
+
/** #565: the one-tap kernel that makes the image-space provider read each query at its lattice point. */
|
|
133
|
+
const UNIT_SITE_KERNEL = /* @__PURE__ */ new Float64Array([1]);
|
|
125
134
|
/** Camera equation H = 0.65·L·t/N² (CIPA DC-004-2020 §5.5 (4); 0.65 = τ·π/4 with τ = 0.828, Explanation 4). */
|
|
126
135
|
const CAMERA_EQUATION_CONSTANT = 0.65;
|
|
127
136
|
/** Neutral reference reflectance: DC-004 ties the SOS reference level to an 18 % grey (Explanation 1 and 2.2). */
|
|
@@ -179,15 +188,16 @@ function greyCardDeviationStops(fNumber, shutterSeconds, iso, illuminanceLux) {
|
|
|
179
188
|
*/
|
|
180
189
|
export const SIMULATE_CAPTURE_DEFAULT_ILLUMINANCE_LUX = Math.PI * sosReferenceLuminance(DEFAULTS.fNumber, DEFAULTS.shutterSeconds, DEFAULTS.iso) /
|
|
181
190
|
GREY_CARD_REFLECTANCE;
|
|
182
|
-
/**
|
|
183
|
-
* Sensitive-aperture area (µm²) at which the tier's ISO signal chain applies unchanged: the default 6 µm square
|
|
184
|
-
* aperture with fill factor 1. At other areas the facade scales the electron-domain readout values by
|
|
185
|
-
* area / this reference, so the same focal-plane exposure H gives the same output code (see `isoAreaScaledReadout`).
|
|
186
|
-
*/
|
|
187
191
|
/** 2.5: the largest accepted pixel pitch, µm (44 mm ÷ 48 pixels ≈ 917 µm, a whole full-frame or larger sensor at a small output). */
|
|
188
192
|
const SIMULATE_CAPTURE_PIXEL_PITCH_MAXIMUM_MICROMETERS = 1000;
|
|
189
193
|
/** Pitches above this, µm, are larger than any camera photosite: the capture is a downsized whole-frame view (assumption sensor.wholeFrameView). */
|
|
190
194
|
const SIMULATE_CAPTURE_PHOTOSITE_PITCH_MAXIMUM_MICROMETERS = 100;
|
|
195
|
+
/**
|
|
196
|
+
* Sensitive-aperture area (µm²) at which the tier's ISO signal chain applies unchanged: the default 6 µm square
|
|
197
|
+
* aperture with fill factor 1. At other areas the facade scales the electron-domain readout values by
|
|
198
|
+
* area / this reference, so the same focal-plane exposure H gives the same output code (the `chargeScale` of
|
|
199
|
+
* `characterizeReadout`, src/sensor/sensor-characteristics.ts).
|
|
200
|
+
*/
|
|
191
201
|
const ISO_REFERENCE_APERTURE_AREA_SQUARE_MICROMETERS = DEFAULTS.pixelPitchMicrometers * DEFAULTS.pixelPitchMicrometers;
|
|
192
202
|
/** Demosaic border, native sites per side. */
|
|
193
203
|
const MARGIN = 1;
|
|
@@ -340,17 +350,6 @@ const TEXT_METADATA_LAYOUT = /* @__PURE__ */ Object.freeze({
|
|
|
340
350
|
png: "One tEXt (printable Latin-1) or iTXt (UTF-8) chunk per field, keywords Description, Software, Copyright, Comment, before the first IDAT",
|
|
341
351
|
dng: "ImageDescription (statement, then the description) and Copyright when printable ASCII; an XMP packet in tag 700 with every field"
|
|
342
352
|
});
|
|
343
|
-
/**
|
|
344
|
-
* The readout profile with every regime's charge-domain transfer scaled by `areaScale`: system conversion gain
|
|
345
|
-
* (e⁻ per code) and pre-ADC saturation (e⁻ equivalent) are multiplied by it, so codes per unit focal-plane exposure
|
|
346
|
-
* stay those of the reference aperture. Read noise stays in electrons per pixel; codes, black level, ADC and digital
|
|
347
|
-
* gain are unchanged. The result is re-checked by the readout parser.
|
|
348
|
-
*/
|
|
349
|
-
function isoAreaScaledReadout(profile, areaScale) {
|
|
350
|
-
return parseSensorReadoutConversionProfile({ ...profile, regimes: profile.regimes.map((regime) => ({ ...regime,
|
|
351
|
-
systemConversionGainElectronsPerCode: { ...regime.systemConversionGainElectronsPerCode, value: regime.systemConversionGainElectronsPerCode.value * areaScale },
|
|
352
|
-
preAdcSaturationElectronEquivalent: { ...regime.preAdcSaturationElectronEquivalent, value: regime.preAdcSaturationElectronEquivalent.value * areaScale } })) });
|
|
353
|
-
}
|
|
354
353
|
/** Diagnostic clock for the 2.1 stage timings: `performance.now()` where the host has it. Never affects output. */
|
|
355
354
|
export function clockMs() { return globalThis.performance?.now() ?? Date.now(); }
|
|
356
355
|
function positive(value, name) {
|
|
@@ -421,14 +420,14 @@ function luminanceOf(radiance) {
|
|
|
421
420
|
return PHOTOPIC_MAXIMUM_LUMINOUS_EFFICACY_LM_PER_W * sum;
|
|
422
421
|
}
|
|
423
422
|
/**
|
|
424
|
-
* 2.14: the capabilities with the ISO grid cut to its whole stops (ISO 100·2^k, as the 2.0.0
|
|
423
|
+
* 2.14: the capabilities with the ISO grid cut to its whole stops from the base ISO (ISO 100·2^k on the tiers, as the 2.0.0 bodies list them; #554: a profile's base·2^k), for
|
|
425
424
|
* Auto ISO's default `isoSteps: "whole-stop"`. A whole-stop grid is returned unchanged, so 2.0.0 records resolve as before.
|
|
426
425
|
*/
|
|
427
426
|
function wholeStopIsoCapabilities(capabilities) {
|
|
428
427
|
const grid = capabilities.iso.settingGrid;
|
|
429
428
|
if (grid.kind !== "discrete-values")
|
|
430
429
|
return capabilities;
|
|
431
|
-
const whole = grid.values.filter((iso) => Number.isInteger(Math.log2(iso /
|
|
430
|
+
const whole = grid.values.filter((iso) => Number.isInteger(Math.log2(iso / grid.values[0])));
|
|
432
431
|
if (whole.length === grid.values.length || whole.length === 0)
|
|
433
432
|
return capabilities;
|
|
434
433
|
return { ...capabilities, iso: { ...capabilities.iso, settingGrid: { kind: "discrete-values", values: Object.freeze(whole) } } };
|
|
@@ -744,7 +743,7 @@ export async function serializeSimulatedCaptureDngFromParts(options, parts) {
|
|
|
744
743
|
return await runCapture(options, "dng-from-parts", parts);
|
|
745
744
|
}
|
|
746
745
|
/** The whole frame's packed RAW from region parts (see `serializeSimulatedCaptureDngFromParts`). */
|
|
747
|
-
function joinNativeRawParts(value, plan, width, height, caller) {
|
|
746
|
+
function joinNativeRawParts(value, plan, width, height, caller, fast) {
|
|
748
747
|
if (!Array.isArray(value) || value.length === 0)
|
|
749
748
|
throw new InvalidConfigurationError(caller + " needs at least one part.");
|
|
750
749
|
const parts = value, count = width * height;
|
|
@@ -755,6 +754,8 @@ function joinNativeRawParts(value, plan, width, height, caller) {
|
|
|
755
754
|
const rect = part?.region?.nativeRect, raw = part?.raw;
|
|
756
755
|
if (!rect || !raw)
|
|
757
756
|
bad(k, "needs raw and region.nativeRect (a region render's result).");
|
|
757
|
+
if (part.fast === true && !fast)
|
|
758
|
+
bad(k, "a fast method rendered it, but this request resolves to the exact methods (fast: false, the default for a DNG, JPEG or PNG). Render the parts with fast: false, or pass fast: true here.");
|
|
758
759
|
const { x, y, width: w, height: h } = rect;
|
|
759
760
|
if (![x, y, w, h].every(Number.isSafeInteger) || x < 0 || y < 0 || w < 1 || h < 1 || x + w > width || y + h > height)
|
|
760
761
|
bad(k, "its nativeRect is not inside the frame's native raster.");
|
|
@@ -929,11 +930,13 @@ export const DISPLAY_VIEW_WITHOUT_VIEW = Symbol("display view without view");
|
|
|
929
930
|
* Internal: the request path shared by {@link simulateCapture}, {@link simulateIdealReference} and the display view, whose renderer is
|
|
930
931
|
* passed in; for a display view the renderer's value is returned (a result, or a prepared view with DISPLAY_VIEW_WITHOUT_VIEW).
|
|
931
932
|
*/
|
|
932
|
-
export async function runCapture(options, run,
|
|
933
|
+
export async function runCapture(options, run,
|
|
934
|
+
/** The display view option, the parts, or (#534) for a capture an exposure build-up's callback, which sees each native RAW input. */
|
|
935
|
+
viewOption, renderView) {
|
|
933
936
|
const started = Date.now(), idealReference = run === "ideal-reference", displayView = run === "display-view";
|
|
934
937
|
requireInputObject(options, "simulateCapture options");
|
|
935
938
|
const top = allowKeys(options, "simulateCapture options", ["camera", "lens", "exposure", "scene", "subjectDistanceM", "chartSize", "illuminant",
|
|
936
|
-
"illuminanceLux", "output", "sensor", "motion", "fast", "signal", "onProgress"]);
|
|
939
|
+
"illuminanceLux", "illumination", "output", "sensor", "motion", "fast", "signal", "onProgress"]);
|
|
937
940
|
const signal = top.signal, onProgress = top.onProgress;
|
|
938
941
|
if (signal !== undefined && (typeof signal !== "object" || signal === null || typeof signal.aborted !== "boolean"))
|
|
939
942
|
fail("signal must be an AbortSignal.");
|
|
@@ -957,6 +960,38 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
957
960
|
preset = GENERIC_TIER_ASSETS.find((p) => p.tier === cameraOption) ?? fail("Unknown camera tier.");
|
|
958
961
|
else
|
|
959
962
|
preset = parseCallerCameraPreset(cameraOption);
|
|
963
|
+
// #554: a sensor profile record (the catalogue is never imported) replaces the body's sensor assets; its other capabilities and
|
|
964
|
+
// the lens are kept. The parser and body builder are a static import (2.21.0 size pass): as a lazy chunk they made the bundler
|
|
965
|
+
// split the core modules into a shared chunk of their own, which cost more than the 1.7 KB they keep out of the entry.
|
|
966
|
+
const profileRecord = (typeof top.sensor === "object" && top.sensor !== null ? top.sensor : {}).profile;
|
|
967
|
+
let profile, profileSensor, profilePitch = DEFAULTS.pixelPitchMicrometers, profileReadNoiseScale = 1;
|
|
968
|
+
if (profileRecord !== undefined) {
|
|
969
|
+
const kit = sensorProfileKit;
|
|
970
|
+
profile = kit.parseSensorProfile(profileRecord);
|
|
971
|
+
profilePitch = profile.pixelPitchMicrometers;
|
|
972
|
+
// #554: with a catalogue lens the capture frames on the profile's sensor: by default the largest centred region of the output's
|
|
973
|
+
// aspect (pitch = min(W/w, H/h)); an explicit pitch gives a centred crop of it. A site of N photosites sums their charge (the
|
|
974
|
+
// full well scales with the area) and their read noise in quadrature (×√N); below one photosite the read noise is kept.
|
|
975
|
+
const o = (typeof top.output === "object" && top.output !== null ? top.output : {}), explicit = top.sensor.pixelPitchMicrometers;
|
|
976
|
+
const W = profile.sensorWidthMm, H = profile.sensorHeightMm, w = positive(o.width ?? DEFAULTS.width, "output.width"), h = positive(o.height ?? DEFAULTS.height, "output.height");
|
|
977
|
+
if (typeof top.lens === "object" && top.lens !== null && top.lens.catalogue !== undefined) {
|
|
978
|
+
profilePitch = typeof explicit === "number" ? explicit : Math.min(W / w, H / h) * 1000;
|
|
979
|
+
if (w * profilePitch > W * 1000 * (1 + 1e-9) || h * profilePitch > H * 1000 * (1 + 1e-9))
|
|
980
|
+
fail("sensor.pixelPitchMicrometers makes the frame larger than the profile's sensor.");
|
|
981
|
+
profileSensor = { widthMm: W, heightMm: H };
|
|
982
|
+
}
|
|
983
|
+
else if (typeof explicit === "number")
|
|
984
|
+
profilePitch = explicit;
|
|
985
|
+
const sites = (profilePitch / profile.pixelPitchMicrometers) ** 2;
|
|
986
|
+
profileReadNoiseScale = Math.sqrt(Math.max(1, sites));
|
|
987
|
+
preset = { ...preset, body: kit.sensorProfileBody(profile, preset.body, preset.presetVersion, profileReadNoiseScale) };
|
|
988
|
+
if (profileSensor || sites !== 1)
|
|
989
|
+
assume("sensor.profileView", "model", { sensorWidthMm: W, sensorHeightMm: H, frameWidthMm: w * profilePitch / 1000, frameHeightMm: h * profilePitch / 1000,
|
|
990
|
+
pixelPitchMicrometers: profilePitch, croppedSensorAxis: profileSensor ? (w * profilePitch < W * 1000 * (1 - 1e-9) ? "width" : h * profilePitch < H * 1000 * (1 - 1e-9) ? "height" : "none") : null,
|
|
991
|
+
photositesPerSite: sites, readNoiseScale: profileReadNoiseScale, lensFramedOnSensor: !!profileSensor }, "Approximation (#554; docs/SENSOR_PROFILES.md): the catalogue lens is resolved on the profile's sensor and the capture is its centred frame of the output's aspect (cropped, " +
|
|
992
|
+
"not stretched); a site sums photositesPerSite photosites (charge by area, read noise ×readNoiseScale). A downsized view, not the sensor's own pixels.");
|
|
993
|
+
assume("sensor.profile", "model", { ...profile, sensitivityOffsetStops: Math.log2(kit.sensorProfileSaturationIso(profile) / profile.baseIso) }, "Sensor profile (#554; docs/SENSOR_PROFILES.md): published, derived and design values, not a camera calibration; its base ISO is re-rated from full well, QE and pixel area.");
|
|
994
|
+
}
|
|
960
995
|
const body = preset.body, lens = preset.lens;
|
|
961
996
|
// Lens
|
|
962
997
|
const lensOptions = allowKeys(top.lens, "lens", ["focalLengthMm", "fNumber", "focusDistanceM", "pupilSampling", "projection", "distortion", "vignetting", "psf", "diffraction", "prime", "defocus",
|
|
@@ -966,7 +1001,7 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
966
1001
|
// The simulated frame in mm (output width × height × pixel pitch), validated again below.
|
|
967
1002
|
const frameMm = () => {
|
|
968
1003
|
const o = (typeof top.output === "object" && top.output !== null ? top.output : {}), sn = (typeof top.sensor === "object" && top.sensor !== null ? top.sensor : {});
|
|
969
|
-
const frameW = positive(o.width ?? DEFAULTS.width, "output.width"), frameH = positive(o.height ?? DEFAULTS.height, "output.height"), framePitch = positive(sn.pixelPitchMicrometers ??
|
|
1004
|
+
const frameW = positive(o.width ?? DEFAULTS.width, "output.width"), frameH = positive(o.height ?? DEFAULTS.height, "output.height"), framePitch = positive(sn.pixelPitchMicrometers ?? profilePitch, "sensor.pixelPitchMicrometers");
|
|
970
1005
|
return { widthMm: frameW * framePitch / 1000, heightMm: frameH * framePitch / 1000 };
|
|
971
1006
|
};
|
|
972
1007
|
if (lensOptions.prime !== undefined) {
|
|
@@ -1016,9 +1051,13 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1016
1051
|
for (const key of ["prime", "focalLengthMm", "projection", "distortion", "vignetting", "psf", "diffraction", "pupilSampling"])
|
|
1017
1052
|
if (lensOptions[key] !== undefined)
|
|
1018
1053
|
fail("lens.catalogue cannot be combined with lens." + key + ": the catalogue lens sets its own focal length, projection, distortion, vignetting and PSF (with diffraction).");
|
|
1019
|
-
|
|
1054
|
+
// #554: with a sensor profile, the lens is resolved on the profile's sensor (sensor.profileView), the capture being its centred frame.
|
|
1055
|
+
catalogueFrame = profileSensor ?? frameMm();
|
|
1020
1056
|
lensKit = await import("./generic-lens-capture.js");
|
|
1021
1057
|
catalogue = lensKit.resolveGenericLens({ lensId: catalogueId, activeImagingArea: catalogueFrame });
|
|
1058
|
+
// #554: the phone lenses are fixed to a phone sensor profile, and it takes only them.
|
|
1059
|
+
if ((catalogue.set === "phone") !== (profile?.fixedLenses === "phone"))
|
|
1060
|
+
fail("lens.catalogue: the phone lenses go only with a phone sensor.profile (fixedLenses \"phone\"), which takes only them.");
|
|
1022
1061
|
assume("lens.catalogue", "model", { lensId: catalogue.lensId, name: catalogue.name, set: catalogue.set, kind: catalogue.kind, equivalentFocalLengthMm: catalogue.equivalentFocalLengthMm,
|
|
1023
1062
|
focalLengthMm: catalogue.focalLengthMm, cropFactor35Mm: catalogue.cropFactor35Mm, widestFNumber: catalogue.widestFNumber, narrowestFNumber: catalogue.narrowestFNumber,
|
|
1024
1063
|
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/" +
|
|
@@ -1148,16 +1187,36 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1148
1187
|
fail("lens.defocus cannot be combined with lens.pupilSampling: the pupil rays already render defocus, so it would be counted twice.");
|
|
1149
1188
|
if (lensPsf)
|
|
1150
1189
|
fail("lens.defocus cannot be combined with lens.psf: a caller PSF declares its own defocus, so it could be counted twice.");
|
|
1151
|
-
// #549: with a catalogue lens
|
|
1152
|
-
// options (#553) shape the lens.defocus kernel only, so they are refused rather than dropped (a follow-up for a later release).
|
|
1190
|
+
// #549, #567: with a catalogue lens its own bokeh kernel is convolved into its PSF (below); it sets all three bokeh options.
|
|
1153
1191
|
if (catalogue && defocusBokeh)
|
|
1154
1192
|
fail("lens.defocus.blades, sphericalAberration and catsEye cannot be combined with lens.catalogue: the catalogue lens " +
|
|
1155
|
-
"
|
|
1193
|
+
"sets its own (lens.catalogue.character). Omit the bokeh options, or use lens.focalLengthMm instead of the catalogue lens.");
|
|
1156
1194
|
if (!catalogue)
|
|
1157
1195
|
defocusModel = "geometric-disc";
|
|
1158
1196
|
}
|
|
1159
1197
|
// Motion (2.0): constant camera rotation and the shutter type.
|
|
1160
|
-
const motionOptions = allowKeys(top.motion, "motion", ["angularVelocityRadPerSec", "shutter", "rollingShutterSkewSeconds"]);
|
|
1198
|
+
const motionOptions = allowKeys(top.motion, "motion", ["angularVelocityRadPerSec", "shutter", "rollingShutterSkewSeconds", "subject"]);
|
|
1199
|
+
// #551: the moving subject (subject-motion.ts); its plane points depend on time, as camera rotation's rays do.
|
|
1200
|
+
let subject, subjectFast;
|
|
1201
|
+
if (motionOptions.subject !== undefined) {
|
|
1202
|
+
const record = allowKeys(motionOptions.subject, "motion.subject", ["velocityMPerSec", "angularVelocityRadPerSec", "region", "fast"]);
|
|
1203
|
+
const v = allowKeys(record.velocityMPerSec, "motion.subject.velocityMPerSec", ["x", "y"]), r = record.region === undefined ? undefined :
|
|
1204
|
+
allowKeys(record.region, "motion.subject.region", ["centerXM", "centerYM", "widthM", "heightM"]);
|
|
1205
|
+
// Each number finite and within ±10000 (m/s, rad/s, m); a missing velocity component or rate is 0.
|
|
1206
|
+
const finite = (x, name, fallback) => {
|
|
1207
|
+
if (x === undefined && fallback === 0)
|
|
1208
|
+
return 0;
|
|
1209
|
+
if (typeof x !== "number" || !Number.isFinite(x) || Math.abs(x) > 1e4)
|
|
1210
|
+
fail("motion.subject." + name + " must be a finite number within ±10000.");
|
|
1211
|
+
return x;
|
|
1212
|
+
};
|
|
1213
|
+
subject = { vx: finite(v.x, "velocityMPerSec.x", 0), vy: finite(v.y, "velocityMPerSec.y", 0), omega: finite(record.angularVelocityRadPerSec, "angularVelocityRadPerSec", 0),
|
|
1214
|
+
region: r && { cx: finite(r.centerXM, "region.centerXM"), cy: finite(r.centerYM, "region.centerYM"), hw: positive(r.widthM, "motion.subject.region.widthM") / 2,
|
|
1215
|
+
hh: positive(r.heightM, "motion.subject.region.heightM") / 2 } };
|
|
1216
|
+
subjectFast = fastOverride(record, "motion.subject");
|
|
1217
|
+
}
|
|
1218
|
+
if (subject && pupil)
|
|
1219
|
+
fail("motion.subject cannot be combined with lens.pupilSampling.");
|
|
1161
1220
|
const angular = { pitch: 0, yaw: 0, roll: 0 };
|
|
1162
1221
|
if (motionOptions.angularVelocityRadPerSec !== undefined) {
|
|
1163
1222
|
const a = allowKeys(motionOptions.angularVelocityRadPerSec, "motion.angularVelocityRadPerSec", ["pitch", "yaw", "roll"]);
|
|
@@ -1178,7 +1237,7 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1178
1237
|
const rollingSkewOption = motionOptions.rollingShutterSkewSeconds === undefined ? undefined : positive(motionOptions.rollingShutterSkewSeconds, "motion.rollingShutterSkewSeconds");
|
|
1179
1238
|
// Diffraction runs as an object-space convolution when every PSF tap's source is another site centre seen
|
|
1180
1239
|
// through one linear map (still camera, rectilinear, no distortion or field throughput); otherwise per tap.
|
|
1181
|
-
const diffractionObjectSpace = diffractionRadiusOption !== undefined && !rotating && !lensFieldMapping && !vignetting;
|
|
1240
|
+
const diffractionObjectSpace = diffractionRadiusOption !== undefined && !rotating && !subject && !lensFieldMapping && !vignetting;
|
|
1182
1241
|
// Output and sensor
|
|
1183
1242
|
const outputOptions = allowKeys(top.output, "output", ["width", "height", "format", "toneCurve", "toneCurveParameters", "highlightMode", "exposureCompensationStops", "whiteBalance", "jpegQuantizationStep",
|
|
1184
1243
|
"colorEncoding", "bitDepth", "gamutMapping", "demosaic", "borderHandling", "dng", "maximumPixels", "region", "metadata", "diffractionCorrection", "sharpening", "lensCorrections"]);
|
|
@@ -1190,10 +1249,29 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1190
1249
|
const format = pick(outputOptions, "format", "output.format", DEFAULTS.format, "8-bit sRGB samples.");
|
|
1191
1250
|
if (!["srgb-8", "srgb-16", "jpeg", "png", "dng"].includes(format))
|
|
1192
1251
|
fail("output.format must be srgb-8, srgb-16, jpeg, png or dng.");
|
|
1193
|
-
|
|
1252
|
+
const correctionSelection = (value, name) => {
|
|
1253
|
+
if (value === undefined)
|
|
1254
|
+
return undefined;
|
|
1255
|
+
const o = allowKeys(value, name, ["distortion", "lateralColor", "vignetting"]), selection = {};
|
|
1256
|
+
for (const key of ["distortion", "lateralColor", "vignetting"]) {
|
|
1257
|
+
if (o[key] !== undefined && typeof o[key] !== "boolean")
|
|
1258
|
+
fail(name + "." + key + " must be true or false.");
|
|
1259
|
+
selection[key] = o[key] === true;
|
|
1260
|
+
}
|
|
1261
|
+
if (!Object.values(selection).includes(true))
|
|
1262
|
+
return undefined;
|
|
1263
|
+
if (!catalogue)
|
|
1264
|
+
fail(name + " needs lens.catalogue: the corrections are the catalogue lens's own profile.");
|
|
1265
|
+
return selection;
|
|
1266
|
+
};
|
|
1267
|
+
let dngCapturedAtUtc, dngOpcodeSelection;
|
|
1194
1268
|
if (format === "dng") {
|
|
1195
|
-
const d = allowKeys(outputOptions.dng, "output.dng", ["capturedAtUtc"]);
|
|
1269
|
+
const d = allowKeys(outputOptions.dng, "output.dng", ["capturedAtUtc", "lensCorrectionOpcodes"]);
|
|
1196
1270
|
dngCapturedAtUtc = d.capturedAtUtc;
|
|
1271
|
+
// #587: the corrections as DNG opcodes, which apply to the whole frame's ActiveArea.
|
|
1272
|
+
dngOpcodeSelection = correctionSelection(d.lensCorrectionOpcodes, "output.dng.lensCorrectionOpcodes");
|
|
1273
|
+
if (dngOpcodeSelection && outputOptions.region !== undefined)
|
|
1274
|
+
fail("output.dng.lensCorrectionOpcodes needs the whole frame (no output.region).");
|
|
1197
1275
|
if (dngCapturedAtUtc === undefined)
|
|
1198
1276
|
assume("output.dng.capturedAtUtc", "default", null, "No capture time: the DNG carries no DateTimeOriginal (the engine never invents a wall clock).");
|
|
1199
1277
|
}
|
|
@@ -1249,18 +1327,28 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1249
1327
|
}
|
|
1250
1328
|
const exposureGain = exposureCompensationStops === 0 ? 1 : 2 ** exposureCompensationStops;
|
|
1251
1329
|
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).");
|
|
1252
|
-
const jpegStep = format === "jpeg" ? integerIn(pick(outputOptions, "jpegQuantizationStep", "output.jpegQuantizationStep", DEFAULTS.jpegQuantizationStep, "Constant JPEG quantization step
|
|
1330
|
+
const jpegStep = format === "jpeg" ? integerIn(pick(outputOptions, "jpegQuantizationStep", "output.jpegQuantizationStep", DEFAULTS.jpegQuantizationStep, "Constant JPEG quantization step 1 (not a manufacturer quality scale)."), "output.jpegQuantizationStep", 1, 255) : undefined;
|
|
1253
1331
|
if (format !== "jpeg" && outputOptions.jpegQuantizationStep !== undefined)
|
|
1254
1332
|
fail("output.jpegQuantizationStep applies only to output.format \"jpeg\".");
|
|
1255
|
-
const sensorOptions = allowKeys(top.sensor, "sensor", ["pixelPitchMicrometers", "spectralStepNanometers", "temporalSampleCount", "noiseSeed", "antiAliasing", "pixelAperture"]);
|
|
1256
|
-
const pitch = positive(pick(sensorOptions, "pixelPitchMicrometers", "sensor.pixelPitchMicrometers",
|
|
1333
|
+
const sensorOptions = allowKeys(top.sensor, "sensor", ["pixelPitchMicrometers", "spectralStepNanometers", "temporalSampleCount", "noiseSeed", "antiAliasing", "pixelAperture", "profile", ...SENSOR_CHARACTERISTIC_KEYS]);
|
|
1334
|
+
const pitch = positive(pick(sensorOptions, "pixelPitchMicrometers", "sensor.pixelPitchMicrometers", profilePitch, (profileSensor ? "The profile's sensor across the output (sensor.profileView)" : profile ? "The profile's pitch" : "6 µm pixels (about a 24 MP full-frame sensor)") +
|
|
1335
|
+
". The simulated sensor is width × pitch wide, so small images are a central crop of a large sensor, not a downscaled full frame."), "sensor.pixelPitchMicrometers");
|
|
1257
1336
|
if (pitch < 0.5 || pitch > SIMULATE_CAPTURE_PIXEL_PITCH_MAXIMUM_MICROMETERS)
|
|
1258
1337
|
fail("sensor.pixelPitchMicrometers must be 0.5-" + SIMULATE_CAPTURE_PIXEL_PITCH_MAXIMUM_MICROMETERS + ".");
|
|
1259
1338
|
const spectralStep = positive(pick(sensorOptions, "spectralStepNanometers", "sensor.spectralStepNanometers", DEFAULTS.spectralStepNanometers, "Spectral midpoint quadrature over 380-780 nm with subintervals of at most 20 nm."), "sensor.spectralStepNanometers");
|
|
1260
1339
|
if (spectralStep < 1 || spectralStep > 100)
|
|
1261
1340
|
fail("sensor.spectralStepNanometers must be 1-100.");
|
|
1262
|
-
|
|
1341
|
+
let temporalSampleCount = integerIn(rotating || subject ? pick(sensorOptions, "temporalSampleCount", "sensor.temporalSampleCount", ROTATING_TEMPORAL_SAMPLE_COUNT, rotating ? "Eight temporal midpoints across each site's exposure window sample the camera rotation (motion blur); raise it for long exposures or fast rotation." :
|
|
1342
|
+
"Eight temporal midpoints sample the subject's motion; raise it for long streaks.") :
|
|
1263
1343
|
pick(sensorOptions, "temporalSampleCount", "sensor.temporalSampleCount", DEFAULTS.temporalSampleCount, "One temporal sample: the static scene and still camera make more samples unnecessary."), "sensor.temporalSampleCount", 1, 256);
|
|
1344
|
+
const parsedCharacteristics = parseSensorCharacteristics(sensorOptions), rn = parsedCharacteristics?.readNoise, rs = profileReadNoiseScale;
|
|
1345
|
+
// #554: on a profile's downsized view a site sums photosites: explicit read noises (per photosite) scale as the profile's do; no fixed pattern.
|
|
1346
|
+
const characteristics = rn && rs !== 1 ? { ...parsedCharacteristics, readNoise: { ...rn, ...(rn.lowGainElectrons === undefined ? {} : { lowGainElectrons: rn.lowGainElectrons * rs }),
|
|
1347
|
+
...(rn.highGainElectrons === undefined ? {} : { highGainElectrons: rn.highGainElectrons * rs }) } } : parsedCharacteristics;
|
|
1348
|
+
if (characteristics?.fixedPatternNoise && profile && pitch !== profile.pixelPitchMicrometers)
|
|
1349
|
+
fail("sensor.fixedPatternNoise needs the profile's own pixel pitch: the pattern is per photosite.");
|
|
1350
|
+
// The EQE scale over the facade's QE 0.5 curve: an explicit peak QE, else the profile's (#554), else 1.
|
|
1351
|
+
const bodyQeScale = profile ? profile.peakQuantumEfficiency / 0.5 : 1, qeScale = characteristics?.peakQuantumEfficiency !== undefined ? characteristics.qeScale : bodyQeScale;
|
|
1264
1352
|
const noiseSeed = integerIn(pick(sensorOptions, "noiseSeed", "sensor.noiseSeed", DEFAULTS.noiseSeed, "Fixed noise seed 1 (deterministic output)."), "sensor.noiseSeed", 0, 0xffffffff);
|
|
1265
1353
|
const antiAliasing = sensorOptions.antiAliasing === undefined ? undefined : parseAntiAliasing(sensorOptions.antiAliasing, pitch);
|
|
1266
1354
|
const antiAliasingFast = antiAliasing ? fastOverride(sensorOptions.antiAliasing, "sensor.antiAliasing") : undefined;
|
|
@@ -1293,6 +1381,8 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1293
1381
|
if (rotating && shutterKind === "rolling")
|
|
1294
1382
|
unsupported("camera rotation with motion.shutter \"rolling\"", "every row has its own instants, so the view's " +
|
|
1295
1383
|
"scene points of one instant would mix rows exposed at other times; use motion.shutter \"global\" for the view.");
|
|
1384
|
+
if (subject && (shutterKind === "rolling" || subject.omega !== 0))
|
|
1385
|
+
unsupported("motion.subject with a rolling shutter or angularVelocityRadPerSec", "the view renders sideways motion under a global shutter only; simulateCapture renders both.");
|
|
1296
1386
|
if (outputOptions.region !== undefined)
|
|
1297
1387
|
unsupported("output.region", "pass the rectangle as view.region, in display pixels.");
|
|
1298
1388
|
if (outputOptions.whiteBalance === "auto")
|
|
@@ -1328,23 +1418,29 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1328
1418
|
if (demosaicOption !== undefined && demosaicOption !== "bilinear" && demosaicOption !== "ahd")
|
|
1329
1419
|
fail("output.demosaic must be bilinear or ahd.");
|
|
1330
1420
|
const ahd = demosaicOption === "ahd";
|
|
1331
|
-
// #395: exact or fast per option
|
|
1421
|
+
// #395: exact or fast per option: the option record's `fast`, else the top-level one, else (2.20.0) the format's default: fast for
|
|
1422
|
+
// sRGB samples and the display view, exact for the exports (DNG, JPEG, PNG) and the ideal reference. Fast methods report true when they run.
|
|
1332
1423
|
if (top.fast !== undefined && typeof top.fast !== "boolean")
|
|
1333
1424
|
fail("fast must be true or false.");
|
|
1334
|
-
const fastTop = top.fast
|
|
1425
|
+
const fastTop = resolveFast(top.fast, fastFormatDefault(format, displayView ? "display-view" : idealReference ? "ideal-reference" : "capture"));
|
|
1335
1426
|
const fastInUse = [["lens.diffraction", diffractionRadiusOption !== undefined, diffractionFast],
|
|
1336
1427
|
["lens.catalogue", catalogue !== undefined, catalogueFast],
|
|
1337
|
-
["sensor.antiAliasing", antiAliasing !== undefined, antiAliasingFast], ["sensor.pixelAperture.box", boxAperture, boxFast], ["output.demosaic.ahd", ahd, demosaicFast]
|
|
1428
|
+
["sensor.antiAliasing", antiAliasing !== undefined, antiAliasingFast], ["sensor.pixelAperture.box", boxAperture, boxFast], ["output.demosaic.ahd", ahd, demosaicFast],
|
|
1429
|
+
["motion.subject", subject !== undefined, subjectFast]];
|
|
1338
1430
|
const fastApplied = {};
|
|
1339
1431
|
for (const [option, used] of fastInUse)
|
|
1340
1432
|
if (used)
|
|
1341
1433
|
fastApplied[option] = false;
|
|
1342
1434
|
// #568: the catalogue's fast method, when requested (its record's flag, else the top-level one); whether it runs is decided with the RAW path.
|
|
1343
|
-
const catalogueFastRequested = catalogue !== undefined && (catalogueFast
|
|
1435
|
+
const catalogueFastRequested = catalogue !== undefined && resolveFast(catalogueFast, fastTop);
|
|
1344
1436
|
let fastReport = Object.freeze({
|
|
1345
|
-
requested: fastTop || [diffractionFast, catalogueFast, antiAliasingFast, boxFast, demosaicFast].some((value) => value === true),
|
|
1346
|
-
applied: Object.freeze({ ...fastApplied }), assurance: "engine-reference"
|
|
1437
|
+
requested: fastTop || [diffractionFast, catalogueFast, antiAliasingFast, boxFast, demosaicFast, subjectFast].some((value) => value === true),
|
|
1438
|
+
defaultedFrom: top.fast === undefined ? "format" : "request", applied: Object.freeze({ ...fastApplied }), assurance: "engine-reference"
|
|
1347
1439
|
});
|
|
1440
|
+
const markFast = (option) => {
|
|
1441
|
+
fastApplied[option] = true;
|
|
1442
|
+
fastReport = Object.freeze({ ...fastReport, applied: Object.freeze({ ...fastApplied }), assurance: "approximation-with-a-priori-bound" });
|
|
1443
|
+
};
|
|
1348
1444
|
if (borderOption !== undefined && borderOption !== "margin" && borderOption !== "mirror")
|
|
1349
1445
|
fail("output.borderHandling must be margin or mirror.");
|
|
1350
1446
|
const builtInDemosaic = demosaicOption === undefined ? undefined : createBuiltInBayerDemosaicProfiles({ kernel: "bilinear",
|
|
@@ -1358,22 +1454,14 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1358
1454
|
// Geometry: the demosaic border (if any) on each side; output crop is the active area.
|
|
1359
1455
|
const nativeWidth = width + 2 * margin, nativeHeight = height + 2 * margin;
|
|
1360
1456
|
// Lens corrections (#570): the catalogue lens's corrections on the developed linear colour; all off is no option at all.
|
|
1361
|
-
let lensCorrection
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
if (keys.some((key) => selection[key])) {
|
|
1370
|
-
if (idealReference)
|
|
1371
|
-
fail("simulateIdealReference does not take output.lensCorrections: a reference has no camera development.");
|
|
1372
|
-
if (!catalogue || !lensKit)
|
|
1373
|
-
fail("output.lensCorrections needs lens.catalogue: the corrections are the catalogue lens's own profile.");
|
|
1374
|
-
lensCorrectionSelection = selection;
|
|
1375
|
-
lensCorrection = lensKit.prepareDevelopedLensCorrection(catalogue, selection, width, height, pitch / 1000, lensKit.genericLensDispersion);
|
|
1376
|
-
}
|
|
1457
|
+
let lensCorrection;
|
|
1458
|
+
const lensCorrectionSelection = correctionSelection(outputOptions.lensCorrections, "output.lensCorrections");
|
|
1459
|
+
if (lensCorrectionSelection) {
|
|
1460
|
+
if (idealReference)
|
|
1461
|
+
fail("simulateIdealReference does not take output.lensCorrections: a reference has no camera development.");
|
|
1462
|
+
// #589: the channel scales follow the capture's own channels, known only once the sensor is built (below); until then they
|
|
1463
|
+
// span every channel's possible dispersion, so the developed rectangle a region reads holds the sources of any of them.
|
|
1464
|
+
lensCorrection = lensKit.prepareDevelopedLensCorrection(catalogue, lensCorrectionSelection, width, height, pitch / 1000);
|
|
1377
1465
|
}
|
|
1378
1466
|
// Region (2.3): a rectangle of the whole frame. Its native sites are the region plus the demosaic reach on each side
|
|
1379
1467
|
// (clipped to the raster, where mirror borders apply exactly as in the whole render), with the origin rounded down to
|
|
@@ -1449,8 +1537,11 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1449
1537
|
const imageDistanceMm = focalLengthMm * focusDistanceM * 1000 / (focusDistanceM * 1000 - focalLengthMm);
|
|
1450
1538
|
const metresPerFrameUnit = (width * pitch / 2000) * subjectDistanceM / imageDistanceMm;
|
|
1451
1539
|
const aspect = height / width;
|
|
1540
|
+
// #581: an image-plane scene takes the module prepareImagePlaneScenes() loaded, which places its print (chartSize, or fitted in the frame).
|
|
1541
|
+
const imagePlaneModule = top.scene?.kind === "image-plane" ? imagePlaneSupport() ?? fail("Call prepareImagePlaneScenes() first.") : undefined;
|
|
1452
1542
|
// Fixed-size chart (2.3): validated here, before any scene work.
|
|
1453
|
-
const chartSize =
|
|
1543
|
+
const chartSize = imagePlaneModule ? imagePlaneModule.imagePlanePrint(top.scene, top.chartSize, 2 * metresPerFrameUnit, aspect, assume) :
|
|
1544
|
+
top.chartSize === undefined ? undefined : resolveChartSizeOption(top.chartSize, aspect);
|
|
1454
1545
|
assume("scene.framing", "derived", { framedWidthM: 2 * metresPerFrameUnit, framedHeightM: 2 * metresPerFrameUnit * aspect }, chartSize ? "The scene plane is perpendicular to the optical axis; the frame covers this much of it (thin lens, focused distance), and the chart has the physical size in scene.chartSize." :
|
|
1455
1546
|
"The scene plane is perpendicular to the optical axis and the chart is sized to fill the active frame exactly.");
|
|
1456
1547
|
let chartReport;
|
|
@@ -1487,7 +1578,7 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1487
1578
|
// Radiance queries per native site: spectral subintervals × temporal midpoints × pupil rays × PSF taps.
|
|
1488
1579
|
const psfTaps = lensPsf ? Math.max(1, ...(lensPsf.configuration.psf.profile.nodes ?? [])
|
|
1489
1580
|
.map((node) => Number(node.kernel?.widthSamples) * Number(node.kernel?.heightSamples)).filter((taps) => Number.isSafeInteger(taps) && taps > 0)) : 1;
|
|
1490
|
-
|
|
1581
|
+
let queriesPerSite = temporalSampleCount * (Math.ceil((FACADE_SPECTRAL_RANGE_NANOMETERS.maximum - FACADE_SPECTRAL_RANGE_NANOMETERS.minimum) / spectralStep) + 1) *
|
|
1491
1582
|
(pupil ? pupil.radialSamples * pupil.angularSamples : 1) * psfTaps * (antiAliasing ? antiAliasing.components.length : 1) * boxSamples * boxSamples;
|
|
1492
1583
|
// Scene and illuminant
|
|
1493
1584
|
// The scene is the one required input: the facade never guesses what is being photographed.
|
|
@@ -1495,10 +1586,23 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1495
1586
|
fail("simulateCapture needs a scene, for example { preset: \"uniform\" } (an 18 % grey card) or { preset: \"slanted-edge\" }.");
|
|
1496
1587
|
const sceneOption = top.scene;
|
|
1497
1588
|
requireInputObject(sceneOption, "scene");
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
const
|
|
1589
|
+
// #550: mixed lighting (loaded only when asked for). Its frame-center light stands in for the single illuminant below: the strongest
|
|
1590
|
+
// source at the center (white balance, the illumination profile) and the center's illuminance (grey card, standard exposure).
|
|
1591
|
+
// A static import (2.21.0 size pass): as a lazy chunk it made the bundler split the core modules into a shared chunk of their own.
|
|
1592
|
+
const mixedKit = top.illumination === undefined ? undefined : mixedIlluminationKit;
|
|
1593
|
+
if (mixedKit && (top.illuminant !== undefined || top.illuminanceLux !== undefined))
|
|
1594
|
+
fail("illumination replaces illuminant and illuminanceLux; omit them.");
|
|
1595
|
+
const lightKit = (resolveIlluminant) => mixedKit.resolveMixedIllumination(top.illumination, resolveIlluminant);
|
|
1596
|
+
const mixed = mixedKit && lightKit(resolveCaptureIlluminant), strongest = mixed?.sources[mixed.center.strongestSource];
|
|
1597
|
+
const illuminantOption = strongest ? strongest.option : pick(top, "illuminant", "illuminant", { kind: "planckian", temperatureKelvin: DEFAULTS.illuminantKelvin }, "Planckian (blackbody) 6500 K as a stand-in for daylight. CIE D65 is not bundled; pass its S0/S1/S2 basis as a daylight illuminant to use it.");
|
|
1598
|
+
const illuminant = strongest ? strongest.illuminant : resolveCaptureIlluminant(illuminantOption);
|
|
1599
|
+
const illuminanceLux = mixed ? mixed.center.illuminanceLux : positive(pick(top, "illuminanceLux", "illuminanceLux", SIMULATE_CAPTURE_DEFAULT_ILLUMINANCE_LUX, "π·10·N²/(0.65·t·S·0.18) lux at f/8, 1/125 s, ISO 100 (CIPA DC-004 standard output sensitivity H = 10/S and camera equation H = 0.65·L·t/N²): an 18 % grey card then gets the reference exposure for the default settings. It does not follow the caller's exposure settings."), "illuminanceLux");
|
|
1600
|
+
const irradianceScale = strongest ? strongest.irradianceScale : calculateIlluminantIrradianceScale(illuminant.shape, illuminanceLux);
|
|
1601
|
+
if (mixed)
|
|
1602
|
+
assume("scene.illumination", "model", { lights: mixed.sources.map((s) => s.report), blend: mixed.blend,
|
|
1603
|
+
coordinates: chartSize ? "chart-local" : "frame", center: mixed.center }, "Mixed lighting (#550): the irradiance at a point is the declared blend Σ w_i·E_i(λ) of the sources (each scaled to its illuminance, weights summing to 1; a crossfade, not a falloff model) " +
|
|
1604
|
+
"plus each point source's I·cos θ/d² (inverse square and Lambert's cosine law; no occlusion). " +
|
|
1605
|
+
"Scene-average metering reads the lit frame; the frame center's light sets the grey card, the standard-exposure illuminance (all sources scaled together) and the grey-card deviation.");
|
|
1502
1606
|
let chart, callerScene, materialLayers;
|
|
1503
1607
|
const sceneRecord = sceneOption;
|
|
1504
1608
|
if ("kind" in sceneRecord) {
|
|
@@ -1506,7 +1610,8 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1506
1610
|
// the resolved layers are kept for the display view's GPU renderer.
|
|
1507
1611
|
if (sceneRecord.kind === "material-layers")
|
|
1508
1612
|
materialLayers = resolveChartMaterialLayers({ scene: sceneOption, aspect: chartSize ? chartSize.heightM / chartSize.widthM : aspect });
|
|
1509
|
-
callerScene =
|
|
1613
|
+
callerScene = imagePlaneModule ? await imagePlaneModule.resolveImagePlaneScene(sceneOption, chartSize.heightM / chartSize.widthM, assume) :
|
|
1614
|
+
requireCallerScene(materialLayers ? materialLayers.callerScene : sceneOption);
|
|
1510
1615
|
}
|
|
1511
1616
|
else {
|
|
1512
1617
|
chart = resolveChartScene({ scene: sceneOption, aspect: chartSize ? chartSize.heightM / chartSize.widthM : aspect });
|
|
@@ -1528,21 +1633,29 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1528
1633
|
const sceneMaterials = callerScene?.kind === "material-function" ? callerScene.materials.map((m, index) => (m.kind === "neutral" ? m.reflectance :
|
|
1529
1634
|
spectrumDigestForm(callerScene.materialInputs[index], m.spectrum))) : undefined;
|
|
1530
1635
|
const sceneDescription = { facadeVersion: SIMULATE_CAPTURE_FACADE_VERSION, scene: chart ? { preset: chart.preset, parameters: chart.parameters } :
|
|
1531
|
-
|
|
1636
|
+
callerScene.digestForm ??
|
|
1637
|
+
{ kind: callerScene.kind, sceneId: callerScene.sceneId }, illuminant: illuminant.id, illuminanceLux, subjectDistanceM, framedWidthM: 2 * metresPerFrameUnit, aspect,
|
|
1532
1638
|
...(chartSize ? { chartSize: { widthM: chartSize.widthM, heightM: chartSize.heightM, surroundReflectance: chartSize.surroundReflectance } } : {}),
|
|
1533
|
-
...(illuminantSpectrum === undefined ? {} : { illuminantSpectrum }), ...(sceneSpectra.length === 0 ? {} : { sceneSpectra }), ...(sceneMaterials ? { sceneMaterials } : {})
|
|
1639
|
+
...(illuminantSpectrum === undefined ? {} : { illuminantSpectrum }), ...(sceneSpectra.length === 0 ? {} : { sceneSpectra }), ...(sceneMaterials ? { sceneMaterials } : {}),
|
|
1640
|
+
...(mixed ? { illumination: { blend: mixed.blend, lights: mixed.sources.map((s) => [s.report, spectrumDigestForm(s.illuminant.spectrum, s.illuminant.shape)]) } } : {}) };
|
|
1534
1641
|
if (callerScene?.kind === "material-function")
|
|
1535
1642
|
assume("scene.digest", "model", "caller-function-not-hashed", "The source digest covers the caller's sceneId, its materials and the framing, not the body of the caller's materialAt function.");
|
|
1536
|
-
|
|
1643
|
+
// #581: an image-plane scene recorded its own digest assumption when it was resolved.
|
|
1644
|
+
else if (callerScene && callerScene.kind !== "image-plane")
|
|
1537
1645
|
assume("scene.digest", "model", "caller-function-not-hashed", "The source digest covers the caller's sceneId and the framing, not the body of the caller's evaluate function.");
|
|
1538
1646
|
const sourceSha256 = await sha256Hex(JSON.stringify(sceneDescription, (_key, value) => (typeof value === "object" && value !== null && "evaluate" in value ? "caller-spectrum" : value)));
|
|
1539
1647
|
checkAbort();
|
|
1540
|
-
const buildProvider = (sceneIlluminant, scale) => buildPlanarSceneRadianceProvider({ scene: chart ?? callerScene,
|
|
1648
|
+
const buildProvider = (sceneIlluminant, scale, light) => buildPlanarSceneRadianceProvider({ scene: chart ?? callerScene,
|
|
1541
1649
|
sceneId: IDS.scene, providerProfileId: IDS.provider, illuminant: sceneIlluminant, irradianceScale: scale, geometry: { distanceM: subjectDistanceM, metresPerFrameUnit,
|
|
1542
1650
|
...(chartSize ? { chart: { widthM: chartSize.widthM, heightM: chartSize.heightM, surroundReflectance: chartSize.surroundReflectance } } : {}) }, evidence: FACADE_EVIDENCE,
|
|
1543
|
-
limitations: Object.freeze(["Planar Lambertian scene under uniform illumination; no shadows, inter-reflection, gloss or fluorescence."]),
|
|
1544
|
-
...(catalogue?.kind === "fisheye" ? { zeroBeyondHorizon: true } : {}) });
|
|
1545
|
-
const provider = buildProvider(illuminant, irradianceScale);
|
|
1651
|
+
limitations: Object.freeze(["Planar Lambertian scene under " + (light ? "blended" : "uniform") + " illumination; no shadows, inter-reflection, gloss or fluorescence."]),
|
|
1652
|
+
...(catalogue?.kind === "fisheye" ? { zeroBeyondHorizon: true } : {}), ...(light ? { irradianceAt: light.irradianceAt } : {}) });
|
|
1653
|
+
const provider = buildProvider(illuminant, irradianceScale, mixed);
|
|
1654
|
+
// #551: the moving subject's scene; renderers read it at each query's time. The revealed background is a neutral 18 % reflectance
|
|
1655
|
+
// under the plane's irradiance at the revealed point: the illuminant, or (#550) the blend and point sources there, from the spectrum.
|
|
1656
|
+
const half = chartSize ? chartSize.widthM / 2 / metresPerFrameUnit : 1;
|
|
1657
|
+
const movingScene = subject ? (base, scale, shape, light) => createSubjectMotionProvider(base, subject, subjectDistanceM, metresPerFrameUnit, (u, v, nm) => 0.18 * (light ? light.irradianceAt(u / half, v / half, u * metresPerFrameUnit, v * metresPerFrameUnit, nm) : scale * shape(nm)) / Math.PI) : undefined;
|
|
1658
|
+
const sceneProvider = movingScene ? movingScene(provider, irradianceScale, (nm) => illuminant.shape.evaluate(nm), mixed) : provider;
|
|
1546
1659
|
// 2.6: a chart preset's evaluator is engine-built end to end (its results: the schema's exact keys, constant fields,
|
|
1547
1660
|
// the request's identifiers, deep-frozen FACADE_EVIDENCE, UNCERTAINTY and limitations, and an engine-computed
|
|
1548
1661
|
// radiance), so the native task may skip re-parsing the fixed parts. Caller scenes keep the full validation.
|
|
@@ -1565,7 +1678,9 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1565
1678
|
const mode = pick(exposureOptions, "mode", "exposure.mode", "manual", "Manual exposure.");
|
|
1566
1679
|
if (!["manual", "aperture-priority", "shutter-priority", "program"].includes(mode))
|
|
1567
1680
|
fail("Unknown exposure mode.");
|
|
1568
|
-
|
|
1681
|
+
// #554: a profile whose range starts above ISO 100 defaults to its base ISO.
|
|
1682
|
+
const isoOption = profile && profile.baseIso > DEFAULTS.iso ? pick(exposureOptions, "iso", "exposure.iso", profile.baseIso, "The sensor profile's base ISO.") :
|
|
1683
|
+
pick(exposureOptions, "iso", "exposure.iso", DEFAULTS.iso, "ISO 100, the tiers' base ISO.");
|
|
1569
1684
|
// #516: "auto" resolves the ISO with the mode's Auto ISO resolver; NaN marks the ISO as not yet known.
|
|
1570
1685
|
const automaticIso = isoOption === "auto";
|
|
1571
1686
|
if (typeof isoOption === "string" && !automaticIso)
|
|
@@ -1586,7 +1701,9 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1586
1701
|
const clamp = (n) => lensRange ? Math.min(lensRange.narrowestFNumber, Math.max(lensRange.widestFNumber, n)) : n, referenceAperture = clamp(DEFAULTS.fNumber);
|
|
1587
1702
|
// (N0/8)² is exactly 1 at f/8, so f/8 keeps 1/125 s exactly.
|
|
1588
1703
|
const sameShutter = DEFAULTS.shutterSeconds * (referenceAperture / DEFAULTS.fNumber) ** 2;
|
|
1589
|
-
|
|
1704
|
+
// #554: a profile whose range starts above ISO 100 takes the same N²/(t·S) at its base ISO (a shorter t).
|
|
1705
|
+
const referenceIso = profile && profile.baseIso > DEFAULTS.iso ? profile.baseIso : DEFAULTS.iso;
|
|
1706
|
+
const reference = { aperture: referenceAperture, shutterSeconds: Math.max(sameShutter * DEFAULTS.iso / referenceIso, capabilities.shutter.minimumSeconds), iso: referenceIso };
|
|
1590
1707
|
const referenceLuminance = sosReferenceLuminance(reference.aperture, reference.shutterSeconds, reference.iso);
|
|
1591
1708
|
assume("exposure.referenceExposure", "model", { ...reference, meteredLuminanceCdPerSquareMeter: referenceLuminance }, "Exposure anchor: f/8, 1/125 s, ISO 100 gives the CIPA DC-004 standard output sensitivity reference exposure H = 10/S = 0.1 lx·s for an average scene luminance of 10·N²/(0.65·t·S) ≈ 1230.77 cd/m² (camera equation H = 0.65·L·t/N²)" +
|
|
1592
1709
|
(referenceAperture === DEFAULTS.fNumber ? "." : "; the lens excludes f/8: " + (reference.shutterSeconds > sameShutter ? "the shortest shutter" : "the same N²/t") + " at f/" + referenceAperture + "."));
|
|
@@ -1609,6 +1726,8 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1609
1726
|
const cardLuminance = GREY_CARD_REFLECTANCE * illuminanceLux / Math.PI;
|
|
1610
1727
|
for (const sample of zoneSamples)
|
|
1611
1728
|
sample.relativeLinearSignal = meterProfile.target.targetRelativeSignal * cardLuminance / referenceLuminance;
|
|
1729
|
+
if (mixed)
|
|
1730
|
+
assume("exposure.meteringCard", "model", { position: { u: 0, v: 0 }, illuminanceLux }, "Mixed lighting (#550): the grey card sits at the frame center and reads the light there.");
|
|
1612
1731
|
assume("exposure.meteringSource", "model", { metering, greyCardReflectance: GREY_CARD_REFLECTANCE, luminanceCdPerSquareMeter: cardLuminance }, "Grey-card (incident) metering: every meter zone reads an 18 % Lambertian grey card under the scene illuminance, L = 0.18·E/π, instead of the scene.");
|
|
1613
1732
|
}
|
|
1614
1733
|
const meterResult = meterRelativeExposure({ profile: meterProfile, sampleSet: { measurementId: "facade-meter", sceneStateId: IDS.sceneState,
|
|
@@ -1625,7 +1744,9 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1625
1744
|
fail("lens.fNumber " + fNumber + " is outside the " + lensRange.name + " range f/" + lensRange.widestFNumber + "–f/" + lensRange.narrowestFNumber + ".");
|
|
1626
1745
|
};
|
|
1627
1746
|
if (mode === "manual" || mode === "aperture-priority") {
|
|
1628
|
-
|
|
1747
|
+
// #554: a fixed-aperture lens (the phone lenses) defaults to its one f-number.
|
|
1748
|
+
const fixed = lensRange && lensRange.widestFNumber === lensRange.narrowestFNumber ? lensRange.widestFNumber : undefined;
|
|
1749
|
+
fNumber = positive(pick(lensOptions, "fNumber", "lens.fNumber", fixed ?? DEFAULTS.fNumber, fixed ? "The lens's fixed f/" + fixed + "." : "f/8."), "lens.fNumber");
|
|
1629
1750
|
checkFNumber();
|
|
1630
1751
|
}
|
|
1631
1752
|
else {
|
|
@@ -1736,6 +1857,19 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1736
1857
|
fNumber = resolution.resolvedSettings.aperture ?? fNumber;
|
|
1737
1858
|
shutterSeconds = resolution.resolvedSettings.shutterSeconds ?? shutterSeconds;
|
|
1738
1859
|
const resolvedIso = resolution.resolvedSettings.iso ?? iso;
|
|
1860
|
+
// #551: motion.subject's fast method: with the default count and a still camera, one midpoint per site of the streak, plus one.
|
|
1861
|
+
if (subject) {
|
|
1862
|
+
const streakSites = subjectTravelM(subject, shutterSeconds, metresPerFrameUnit * Math.hypot(1, aspect)) * width / (2 * metresPerFrameUnit), fewer = Math.ceil(streakSites) + 1;
|
|
1863
|
+
if (sensorOptions.temporalSampleCount === undefined && !rotating && resolveFast(subjectFast, fastTop) && fewer < temporalSampleCount) {
|
|
1864
|
+
queriesPerSite = queriesPerSite / temporalSampleCount * fewer;
|
|
1865
|
+
temporalSampleCount = fewer;
|
|
1866
|
+
markFast("motion.subject");
|
|
1867
|
+
assume("fast.motion.subject", "model", { temporalSampleCount: fewer, streakSites }, "Fast: temporal midpoints less than one site apart along the subject's streak replace the default 8; exact is fast: false.");
|
|
1868
|
+
}
|
|
1869
|
+
assume("motion.subject", "model", { velocityMPerSec: { x: subject.vx, y: subject.vy }, angularVelocityRadPerSec: subject.omega,
|
|
1870
|
+
region: subject.region ? { centerXM: subject.region.cx, centerYM: subject.region.cy, widthM: 2 * subject.region.hw, heightM: 2 * subject.region.hh } : null,
|
|
1871
|
+
streakSites, timeReference: "first-opening-boundary-phase" }, "Rigid motion on the scene plane, read at each query's time (docs/QUICK_START.md, \"Subject motion\"); streakSites = v·T·m on the sensor.");
|
|
1872
|
+
}
|
|
1739
1873
|
if (!Number.isFinite(resolvedIso) || resolvedIso <= 0)
|
|
1740
1874
|
fail("Exposure could not be resolved: the resolved ISO is not a positive finite number.");
|
|
1741
1875
|
const isoReport = automaticIso ? { isoControl: "automatic", autoIso: autoIsoReport(resolution, resolverCapabilities.iso, autoIsoShutterPolicy, autoIsoLimits, automaticSlowestShutter, autoIsoOptions.isoSteps === undefined ? undefined : autoIsoIsoSteps) } : { isoControl: "manual" };
|
|
@@ -1744,9 +1878,9 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1744
1878
|
const exposureGreyCardDeviationStops = callerScene?.kind === "radiance-function" ? null : greyCardDeviationStops(fNumber, shutterSeconds, resolvedIso, illuminanceLux);
|
|
1745
1879
|
// #549: the catalogue lens's character at the resolved f-number and focus: focus scatter, the defocus disc folded into its PSF, then
|
|
1746
1880
|
// its distortion and vignetting profiles over the field the PSF and the queries reach, and its PSF profile.
|
|
1747
|
-
let catalogueTaps, catalogueDefocusApplied = false, catalogueKernelRadius = 0;
|
|
1881
|
+
let catalogueTaps, catalogueDefocusApplied = false, catalogueKernelRadius = 0, catalogueCornerStops = 0;
|
|
1748
1882
|
if (catalogue && lensKit) {
|
|
1749
|
-
const { GENERIC_LENS_EVIDENCE, genericLensDistortionProfile, genericLensVignettingProfile, resolveGenericLens, simulateGenericLensFocusError, createGenericLensPsfProfile, genericLensPsfKernelRadius, GENERIC_LENS_PSF_MAXIMUM_COMBINED_RADIUS_SITES } = lensKit;
|
|
1883
|
+
const { GENERIC_LENS_EVIDENCE, genericLensDistortionProfile, genericLensVignettingProfile, resolveGenericLens, simulateGenericLensFocusError, createGenericLensPsfProfile, genericLensPsfKernelRadius, GENERIC_LENS_PSF_MAXIMUM_COMBINED_RADIUS_SITES, genericLensBokehKernel } = lensKit;
|
|
1750
1884
|
checkFNumber();
|
|
1751
1885
|
if (lensOptions.focusScatter !== undefined) {
|
|
1752
1886
|
const scatterOptions = allowKeys(lensOptions.focusScatter, "lens.focusScatter", ["seed", "shotIndex"]);
|
|
@@ -1758,6 +1892,8 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1758
1892
|
focusDistanceM = sample.focusDistanceM;
|
|
1759
1893
|
}
|
|
1760
1894
|
const character = resolveGenericLens({ lensId: catalogue.lensId, activeImagingArea: catalogueFrame, fNumber, focusDistanceM });
|
|
1895
|
+
// The vignetting depends on the f-number only (output.lensCorrections reads it).
|
|
1896
|
+
catalogueCornerStops = character.character.vignetting.cornerStops;
|
|
1761
1897
|
let extraKernel;
|
|
1762
1898
|
if (lensOptions.defocus !== undefined) {
|
|
1763
1899
|
const disc = resolveDefocusDisc({ focalLengthMm, aperture: fNumber, focusDistanceM, subjectDistanceM, pitchMicrometers: pitch });
|
|
@@ -1768,16 +1904,25 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1768
1904
|
catch {
|
|
1769
1905
|
fail("lens.defocus: " + defocusDiscLimitMessage(disc.diameterSites, disc.diameterMicrometers));
|
|
1770
1906
|
}
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1907
|
+
// #567: the lens's bokeh kernel (blades, its W040, a cat's eye) instead of the plain disc.
|
|
1908
|
+
// The cat's eye (its finer fast grid) is lens.catalogue's exact method; fast leaves it out.
|
|
1909
|
+
const catsEye = !catalogueFastRequested;
|
|
1910
|
+
if (run !== "ideal-reference" && disc.diameterSites > 0) {
|
|
1911
|
+
extraKernel = genericLensBokehKernel(character, disc.diameterSites, subjectDistanceM > focusDistanceM ? 1 : -1, pitch, catsEye);
|
|
1774
1912
|
catalogueDefocusApplied = true;
|
|
1913
|
+
if (!catsEye && character.character.bokeh.catsEye) {
|
|
1914
|
+
markFast("lens.catalogue");
|
|
1915
|
+
assume("fast.lens.catalogue.catsEye", "model", { method: "bokeh-without-cats-eye" }, "Fast: the lens's bokeh leaves out its cat's eye (the pupil clipped by field position), " +
|
|
1916
|
+
"so the kernel is the axis kernel everywhere; the exact method (fast: false, the default for DNG, JPEG and PNG) shapes it at each field point.");
|
|
1917
|
+
}
|
|
1775
1918
|
}
|
|
1919
|
+
const k = extraKernel?.axis;
|
|
1776
1920
|
assume("lens.defocus", "model", { model: "geometric-disc", scientificStatus: "approximation", diameterMicrometers: disc.diameterMicrometers, diameterSites: disc.diameterSites,
|
|
1777
|
-
kernelRadiusSites: disc.diameterSites === 0 ? null : discRadius, fNumber, focusDistanceM, subjectDistanceM, samplePitchMicrometers: pitch,
|
|
1778
|
-
defocusCircleModel: { id: "thin-lens-defocus-circle", version: "1.1.0" }, execution: "catalogue-lens-psf"
|
|
1921
|
+
kernelRadiusSites: disc.diameterSites === 0 ? null : k ? k.radius : discRadius, fNumber, focusDistanceM, subjectDistanceM, samplePitchMicrometers: pitch,
|
|
1922
|
+
defocusCircleModel: { id: "thin-lens-defocus-circle", version: "1.1.0" }, execution: "catalogue-lens-psf",
|
|
1923
|
+
...(k ? { bokeh: { catsEye: catsEye && !!character.character.bokeh.catsEye, method: k.method, sphericalAberrationSites: k.sphericalAberrationSites, axisOpenAreaRatio: k.openAreaRatio } } : {}) }, "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. " +
|
|
1779
1924
|
(disc.diameterSites === 0 ? "In focus: no disc is applied." : run === "ideal-reference" ? "Resolved but not applied: the ideal reference has no camera effects." :
|
|
1780
|
-
"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."));
|
|
1925
|
+
"With the catalogue lens the disc is shaped by the lens's bokeh (#567, #553's kernel; see lens.catalogue.character) and 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."));
|
|
1781
1926
|
}
|
|
1782
1927
|
// An upper bound of the PSF's radius (the built window may be narrower): the field reach below uses it.
|
|
1783
1928
|
const kernelRadius = genericLensPsfKernelRadius({ lens: character, focusDistanceM, sitePitchMicrometers: pitch }) + (extraKernel ? extraKernel.radius : 0);
|
|
@@ -1821,23 +1966,36 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1821
1966
|
}
|
|
1822
1967
|
assume("lens.catalogue.character", "model", { lensId: catalogue.lensId, fNumber, focusDistanceM, magnification: c.magnification, closeFocusGain: c.closeFocusGain, pupilScale: c.pupilScale,
|
|
1823
1968
|
aberrationsWaves550: { ...c.aberrations }, lateralColor: { ...c.lateralColor }, distortionCorner: c.distortion.corner, vignettingCornerStops: c.vignetting.cornerStops,
|
|
1824
|
-
notRendered: { strayLight: { ...c.strayLight }, breathing: { ...c.breathing }, focusScatterDepthOfFocusFraction: c.focusScatterDepthOfFocusFraction
|
|
1969
|
+
bokeh: c.bokeh, notRendered: { strayLight: { ...c.strayLight }, breathing: { ...c.breathing }, focusScatterDepthOfFocusFraction: c.focusScatterDepthOfFocusFraction },
|
|
1825
1970
|
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), " +
|
|
1826
1971
|
"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. " +
|
|
1827
1972
|
(run === "ideal-reference" ? "Not applied: the ideal reference has no camera effects. " :
|
|
1828
1973
|
"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) " +
|
|
1829
1974
|
"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" +
|
|
1830
1975
|
(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" : "") + ". ") +
|
|
1831
|
-
"Flare
|
|
1976
|
+
"Flare and focus breathing are reported, not rendered; the bokeh shapes lens.defocus; ρ is normalised to the frame's half-diagonal, so the lens is scaled to the simulated frame like lens.prime.");
|
|
1832
1977
|
}
|
|
1833
1978
|
// #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.
|
|
1834
1979
|
// #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
|
|
1835
1980
|
// site lattice (defocus-image-space-provider.ts). #563: the display view reads the same geometry in any focus (display-view-lens-signal.ts).
|
|
1836
1981
|
const imageSpaceOptions = [...(lensFieldMapping ? [lensOptions.distortion !== undefined ? (lensOptions.projection !== undefined ? "lens.projection, lens.distortion" : "lens.distortion") : "lens.projection"] : []),
|
|
1837
|
-
...(vignetting ? ["lens.vignetting"] : []), ...(rotating ? ["camera rotation"] : [])];
|
|
1838
|
-
|
|
1982
|
+
...(vignetting ? ["lens.vignetting"] : []), ...(rotating ? ["camera rotation"] : []), ...(subject ? ["motion.subject"] : [])];
|
|
1983
|
+
// #565, #588: the in-focus diffraction PSF's and a catalogue lens's PSF quadrature read each tap's source on this lattice (the two are
|
|
1984
|
+
// never combined). With rotation or a moving subject (#551) under a rolling shutter (each row has its own instants, so a band per
|
|
1985
|
+
// instant, about 1.1–1.2× the time) that is the option's exact method, and each tap's own point its fast one (lens.diffraction,
|
|
1986
|
+
// lens.catalogue).
|
|
1987
|
+
const rollingTaps = !displayView && (rotating || subject !== undefined) && shutterKind === "rolling";
|
|
1988
|
+
const rollingFastTaps = rollingTaps && (diffractionRadiusOption !== undefined && resolveFast(diffractionFast, fastTop) || catalogueTaps !== undefined && catalogueFastRequested);
|
|
1989
|
+
const lensGeometry = (defocusModel !== undefined || displayView || diffractionRadiusOption !== undefined || catalogueTaps !== undefined && !rollingFastTaps) && imageSpaceOptions.length > 0 ? prepareDefocusImageSpaceGeometry({ focalLengthMm, focusDistanceM,
|
|
1839
1990
|
angularVelocityRadPerSec: angular, ...(lensFieldMapping ? { lensMapping: prepareEnvironmentLensFieldMapping(lensFieldMapping) } : {}), ...(vignetting ? { vignetting } : {}) }) : undefined;
|
|
1840
1991
|
const defocusGeometry = defocusModel !== undefined ? lensGeometry : undefined;
|
|
1992
|
+
if (rollingFastTaps) {
|
|
1993
|
+
const catalogueTapsFast = catalogueTaps !== undefined;
|
|
1994
|
+
markFast(catalogueTapsFast ? "lens.catalogue" : "lens.diffraction");
|
|
1995
|
+
assume(catalogueTapsFast ? "fast.lens.catalogue" : "fast.lens.diffraction", "model", { method: "per-tap-source-points" }, "Fast: under rolling-shutter rotation each PSF tap reads the scene at its own source point, " +
|
|
1996
|
+
"not at its site-lattice point, so a tap exactly on a chart boundary can resolve to the other material (#565, #588); elsewhere the values agree to rounding. The exact method is fast: false.");
|
|
1997
|
+
}
|
|
1998
|
+
const latticeTaps = lensGeometry && !rollingFastTaps;
|
|
1841
1999
|
// #553: the shaped kernel (on axis) and, with a cat's eye, its field zones; built when the disc is resolved out of focus.
|
|
1842
2000
|
let bokehAxisKernel, bokehZones;
|
|
1843
2001
|
const bokehSummary = defocusBokeh === undefined ? undefined : (disc) => {
|
|
@@ -1892,8 +2050,8 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1892
2050
|
...(defocusBokeh ? { bokehOptions: defocusBokeh, bokeh: bokehSummary } : {}),
|
|
1893
2051
|
...(defocusGeometry ? { imageSpace: (disc) => {
|
|
1894
2052
|
// 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.
|
|
1895
|
-
if (rotating && shutterKind === "rolling")
|
|
1896
|
-
fail("simulateCapture does not support lens.defocus out of focus with camera rotation and motion.shutter \"rolling\": the disc is applied " +
|
|
2053
|
+
if ((rotating || subject) && shutterKind === "rolling")
|
|
2054
|
+
fail("simulateCapture does not support lens.defocus out of focus with camera rotation and motion.shutter \"rolling\" (or motion.subject with it): the disc is applied " +
|
|
1897
2055
|
"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 " +
|
|
1898
2056
|
"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.");
|
|
1899
2057
|
const temporalMidpointsSeconds = rotating ? Array.from({ length: temporalSampleCount }, (_, i) => (i + 0.5) * (shutterSeconds / temporalSampleCount)) : [];
|
|
@@ -1911,20 +2069,26 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1911
2069
|
// output, independent of pixel size, while the collected charge is proportional to the sensitive-aperture area
|
|
1912
2070
|
// (EMVA 1288 Release 4.0, Eq. 3). The tier's signal chain is anchored to the 6 µm aperture; at another area the
|
|
1913
2071
|
// charge-domain readout scales with the area so the same H gives the same code. At 6 µm nothing is changed.
|
|
1914
|
-
|
|
2072
|
+
// #554: a profile's readout is declared at its own photosite area.
|
|
2073
|
+
const referenceArea = profile ? profile.pixelPitchMicrometers ** 2 : ISO_REFERENCE_APERTURE_AREA_SQUARE_MICROMETERS, areaScale = pitch * pitch / referenceArea;
|
|
2074
|
+
// #555: a full well replaces the area-scaled base-ISO saturation; the charge-domain transfer scales by the same rule.
|
|
2075
|
+
const fullWell = characteristics?.fullWell, fullWellElectrons = fullWell && (fullWell.link === "none" ? fullWell.electrons : fullWell.electrons * (pitch / (fullWell.referencePixelPitchMicrometers ?? 6)) ** 2);
|
|
2076
|
+
if (fullWellElectrons !== undefined && !(fullWellElectrons >= 100 && fullWellElectrons <= 1e10))
|
|
2077
|
+
fail("sensor.fullWell must give 100 to 1e10 electrons at the pixel pitch (" + fullWellElectrons + ").");
|
|
2078
|
+
const chargeScale = fullWellElectrons === undefined ? areaScale : fullWellElectrons / body.readout[0].profile.regimes[0].preAdcSaturationElectronEquivalent.value;
|
|
1915
2079
|
const regimeByChannel = {};
|
|
1916
2080
|
for (const channel of CHANNELS) {
|
|
1917
2081
|
const asset = body.readout.find((a) => a.profile.channelId === channel) ?? fail("Camera readout lacks channel " + channel + ".");
|
|
1918
2082
|
const chain = resolveGenericIsoSignalChain({ profile: body.signalChain, isoCapability: body.iso, requestedIsoSetting: { kind: "standard", exposureIndex: resolvedIso },
|
|
1919
2083
|
captureModeId: IDS.mode, readoutProfile: asset.profile });
|
|
1920
|
-
regimeByChannel[channel] = { readoutProfile:
|
|
2084
|
+
regimeByChannel[channel] = { readoutProfile: characterizeReadout(asset.profile, chargeScale, characteristics), regimeId: chain.readout.regime.regimeId };
|
|
1921
2085
|
}
|
|
1922
|
-
const capacityRegimes = (
|
|
2086
|
+
const capacityRegimes = characterizeReadout(body.readout[0].profile, chargeScale, characteristics).regimes;
|
|
1923
2087
|
const baseRegime = capacityRegimes[0];
|
|
1924
2088
|
const capacityElectrons = Math.max(...capacityRegimes.map((r) => r.preAdcSaturationElectronEquivalent.value), baseRegime.preAdcSaturationElectronEquivalent.value);
|
|
1925
2089
|
assume("sensor.capacityElectrons", "derived", capacityElectrons, "Physical charge capacity set to the body's largest declared pre-ADC saturation (its base-ISO saturation signal), so storage never clips before the declared readout white.");
|
|
1926
|
-
if (areaScale !== 1) {
|
|
1927
|
-
assume("sensor.isoAreaNormalization", "model", { referenceApertureAreaSquareMicrometers:
|
|
2090
|
+
if (areaScale !== 1 && !fullWell) {
|
|
2091
|
+
assume("sensor.isoAreaNormalization", "model", { referenceApertureAreaSquareMicrometers: referenceArea,
|
|
1928
2092
|
apertureAreaSquareMicrometers: pitch * pitch, areaScale }, "Approximation. The tier's ISO signal chain is declared for a 6 µm square aperture. Standard output sensitivity S = 10/H (CIPA DC-004-2020 §3.2) is defined at the output, " +
|
|
1929
2093
|
"independent of pixel size, and the collected charge is proportional to the aperture area (EMVA 1288 Release 4.0, Eq. 3), so the system conversion gain (e⁻ per code) and the " +
|
|
1930
2094
|
"pre-ADC saturation of every ISO regime are multiplied by area / 36 µm² = " + areaScale + ". The same exposure gives the same output code at any pitch; per-pixel shot-noise SNR " +
|
|
@@ -1932,6 +2096,15 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1932
2096
|
"the aperture area: a design consequence of holding the tier's ISO rating and highlight headroom fixed, not a measured full-well model (published sensor data grow less than " +
|
|
1933
2097
|
"proportionally with area across technologies).");
|
|
1934
2098
|
}
|
|
2099
|
+
if (characteristics) {
|
|
2100
|
+
const density = capacityElectrons / (pitch * pitch);
|
|
2101
|
+
assume("sensor.characteristics", "model", { ...Object.fromEntries(SENSOR_CHARACTERISTIC_KEYS.filter((k) => sensorOptions[k] !== undefined).map((k) => [k, sensorOptions[k]])),
|
|
2102
|
+
chargeScale, sensitivityOffsetStops: Math.log2(qeScale / bodyQeScale * areaScale / chargeScale) }, "Approximation (#555), not a measured sensor: these replace the body's values. The full well is the base-ISO charge at the RAW white level (EMVA 1288 Release 4.0 saturation capacity); " +
|
|
2103
|
+
"every ISO regime's conversion gain and pre-ADC saturation are multiplied by chargeScale. Each ISO keeps its regime, so the output per unit exposure changes by 2^sensitivityOffsetStops: " +
|
|
2104
|
+
"the rating S = 10/H (CIPA DC-004-2020 §3.2) holds only if peak QE × pixel area / full well is the body's. Read noise replaces each regime's; the high gain's smaller storage is not modelled. " +
|
|
2105
|
+
"A bit depth scales the black and saturation codes by 2^Δbits." + (density < 200 || density > 1e4 ? " Unusual for the pitch: " + density +
|
|
2106
|
+
" e⁻/µm², outside 200–10 000 (a design range around the tiers' 556–1 667, not a published bound)." : ""));
|
|
2107
|
+
}
|
|
1935
2108
|
// Whole-frame views (2.5): a pitch above 100 µm is larger than any camera photosite, so the capture is a downsized view of a frame width × pitch wide.
|
|
1936
2109
|
if (pitch > SIMULATE_CAPTURE_PHOTOSITE_PITCH_MAXIMUM_MICROMETERS) {
|
|
1937
2110
|
assume("sensor.wholeFrameView", "model", { pixelPitchMicrometers: pitch, frameWidthMm: width * pitch / 1000, frameHeightMm: height * pitch / 1000 }, "A pixel pitch above 100 µm is larger than any camera photosite, so each output pixel stands for a " + pitch + " µm square of a " + width * pitch / 1000 + " × " + height * pitch / 1000 +
|
|
@@ -1965,7 +2138,10 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1965
2138
|
// Shutter (2.0): the electronic rolling-shutter schedule derived from the body's rolling readout declaration.
|
|
1966
2139
|
let rollingSchedule;
|
|
1967
2140
|
if (shutterKind === "rolling") {
|
|
1968
|
-
const
|
|
2141
|
+
const bodyReadout = body.rollingReadoutTiming?.profile;
|
|
2142
|
+
// #555: sensor.readoutSeconds replaces the body's readout time and skew.
|
|
2143
|
+
const seconds = characteristics?.readoutSeconds, declared = bodyReadout && seconds ? { ...bodyReadout, captureReadoutDurationSeconds: { ...bodyReadout.captureReadoutDurationSeconds, value: seconds },
|
|
2144
|
+
spatialSamplingSkewSeconds: { ...bodyReadout.spatialSamplingSkewSeconds, value: seconds } } : bodyReadout;
|
|
1969
2145
|
if (!declared || declared.readoutMode !== "rolling")
|
|
1970
2146
|
fail("motion.shutter \"rolling\" needs a camera body with a rolling readout timing declaration.");
|
|
1971
2147
|
const readout = rollingSkewOption === undefined ? declared : { ...declared, spatialSamplingSkewSeconds: { value: rollingSkewOption, unit: "s", evidence: FACADE_EVIDENCE } };
|
|
@@ -2012,8 +2188,11 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2012
2188
|
fail("output.region cannot be combined with output.whiteBalance \"auto\": grey-world statistics of a region differ from the whole frame's.");
|
|
2013
2189
|
needsAuto = true;
|
|
2014
2190
|
}
|
|
2015
|
-
else if (whiteBalanceOption === "scene-illuminant")
|
|
2191
|
+
else if (whiteBalanceOption === "scene-illuminant") {
|
|
2016
2192
|
whiteBalanceState = sceneIlluminantWhiteBalance(illuminant);
|
|
2193
|
+
if (mixed)
|
|
2194
|
+
assume("output.whiteBalanceSource", "model", { source: mixed.center.strongestSource, illuminant: illuminant.id }, "Mixed lighting (#550): measured under the light strongest at the frame center (the largest illuminance there, w_i·E_i or a point's I·cos θ/d²; the first on a tie). An explicit white balance is used as given.");
|
|
2195
|
+
}
|
|
2017
2196
|
else if (typeof whiteBalanceOption === "object" && whiteBalanceOption !== null && "kelvin" in whiteBalanceOption) {
|
|
2018
2197
|
const wb = allowKeys(whiteBalanceOption, "output.whiteBalance", ["kelvin", "tint"]);
|
|
2019
2198
|
const intent = createColorTemperatureWhiteBalanceIntent({ intentId: "facade-wb-cct", colorTemperatureKelvin: positive(wb.kelvin, "output.whiteBalance.kelvin"),
|
|
@@ -2055,7 +2234,13 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2055
2234
|
const exposureWindow = rollingSchedule ? createRollingShutterCaptureExposureSchedule(rollingSchedule) :
|
|
2056
2235
|
{ shutterMechanism: "electronic", nominalExposureDurationSeconds: { value: shutterSeconds, unit: "s", evidence },
|
|
2057
2236
|
opening: { kind: "simultaneous" }, closing: { kind: "simultaneous" } };
|
|
2058
|
-
|
|
2237
|
+
// #534: an exposure build-up render sees each native RAW input it renders (to attach its sensor stage).
|
|
2238
|
+
const buildRawInput = (intent) => {
|
|
2239
|
+
const input = buildRawInputFor(intent, geometry, captureModeProfile, bindingProfile, nativeWidth * nativeHeight);
|
|
2240
|
+
if (run === "capture")
|
|
2241
|
+
viewOption?.(input);
|
|
2242
|
+
return input;
|
|
2243
|
+
};
|
|
2059
2244
|
const buildRawInputFor = (intent, rasterGeometry, modeProfile, binding, sites) => ({
|
|
2060
2245
|
exposure: { captureId: IDS.capture, sceneStateId: IDS.sceneState, sceneTimeSeconds: 0, geometry: rasterGeometry,
|
|
2061
2246
|
exposure: { focalLengthMm, aperture: fNumber, shutterSeconds, iso: resolvedIso }, focus: { kind: "finite", distanceM: focusDistanceM },
|
|
@@ -2074,7 +2259,7 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2074
2259
|
const eqeSamples = (channel) => {
|
|
2075
2260
|
const out = [];
|
|
2076
2261
|
for (let nm = range.minimum; nm <= range.maximum + 1e-9; nm += eqeSpacing)
|
|
2077
|
-
out.push({ nm, value: EQE_SCALE[channel] * estimateCie1931ColorMatchingFunctions(nm)[CMF_KEY[channel]] * 555 / nm });
|
|
2262
|
+
out.push({ nm, value: EQE_SCALE[channel] * estimateCie1931ColorMatchingFunctions(nm)[CMF_KEY[channel]] * 555 / nm * qeScale });
|
|
2078
2263
|
return out;
|
|
2079
2264
|
};
|
|
2080
2265
|
const operatingConditions = { temperatureC: 25, incidenceAngleDegreesFromNormal: 0, polarization: "unpolarized" };
|
|
@@ -2138,9 +2323,7 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2138
2323
|
samples: { value: [{ wavelengthNanometers: range.minimum, linearTransmissionFactor: lens.matchedReference.transmission },
|
|
2139
2324
|
{ wavelengthNanometers: range.maximum, linearTransmissionFactor: lens.matchedReference.transmission }], evidence: preset.evidence },
|
|
2140
2325
|
uncertainty: { kind: "not-quantified", limitation: "Synthetic spectrally flat transmission from the tier preset." } },
|
|
2141
|
-
|
|
2142
|
-
sensorOpticalStackIncluded: false, strayLightIncluded: false, polarizationModeled: false, wavelengthChangingBehaviorModeled: false, volumetricScatteringModeled: false,
|
|
2143
|
-
evidence: preset.evidence, limitations: [...lens.limitations, "Flat transmission applied across the lens capability by the capture facade."] };
|
|
2326
|
+
...SCENE_TO_SENSOR_BRIDGE_SCOPE, evidence: preset.evidence, limitations: [...lens.limitations, "Flat transmission applied across the lens capability by the capture facade."] };
|
|
2144
2327
|
const optics = { profile: opticsProfile, focalLengthMm, nominalFNumber: fNumber,
|
|
2145
2328
|
focus: { kind: "ideal-symmetric-thin-lens", objectDistanceM: focusDistanceM, pupilMagnificationAssumption: "unity" } };
|
|
2146
2329
|
const motion = { angularVelocityRadPerSec: angular, timeReference: "first-opening-boundary-phase", environmentDirectionConvention: "outgoing-radiance-toward-camera" };
|
|
@@ -2185,7 +2368,7 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2185
2368
|
(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). " :
|
|
2186
2369
|
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), " +
|
|
2187
2370
|
"the same sum as the environment PSF quadrature with that kernel. " :
|
|
2188
|
-
"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. ") +
|
|
2371
|
+
"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" + (latticeTaps ? "; each tap reads the scene at its lattice point (#565). " : ". ")) +
|
|
2189
2372
|
"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."));
|
|
2190
2373
|
}
|
|
2191
2374
|
// #492: each spectral node's kernel for lens.defocus out of focus: the disc, or with lens.diffraction the node's diffraction kernel convolved
|
|
@@ -2258,6 +2441,10 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2258
2441
|
limitation: "Ideal uniform circular pupil; finite ray sampling, no convergence claim." } : undefined;
|
|
2259
2442
|
const chargeSamplingProfile = { schemaVersion: "0.1.0", profileId: IDS.chargeSampling, completenessProfileId: IDS.completeness, scientificStatus: "approximation",
|
|
2260
2443
|
photoShotNoiseModel: "poisson", darkShotNoiseModel: "poisson", additionalComponentPolicies: [], evidence, limitations: ["Poisson photo and dark shot noise."] };
|
|
2444
|
+
// #555: the body's fixed-pattern noise profile, opt-in; the display view's expected-value estimate has no fixed pattern.
|
|
2445
|
+
const fixedPattern = characteristics?.fixedPatternNoise ? body.fixedPatternNoise.profile : undefined;
|
|
2446
|
+
if (fixedPattern && displayView)
|
|
2447
|
+
fail("simulateDisplayView does not render sensor.fixedPatternNoise; use simulateCapture.");
|
|
2261
2448
|
const channelAt = (x, y) => ["red", "green", "green", "blue"][(y % 2) * 2 + (x % 2)];
|
|
2262
2449
|
function siteInput(index) {
|
|
2263
2450
|
if (!Number.isSafeInteger(index) || index < 0 || index >= nativeWidth * nativeHeight)
|
|
@@ -2285,7 +2472,7 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2285
2472
|
capacityMeaning: "physical-charge-storage-capacity-electrons", capacityElectrons, scientificStatus: "approximation",
|
|
2286
2473
|
uncertainty: { kind: "not-quantified", limitation: "Derived from the tier's declared saturation signal." }, evidence, siteApplicability: { kind: "exact-site", site },
|
|
2287
2474
|
operatingState: { stateId: IDS.operatingState, evidence }, temperatureApplicability: { kind: "exact-reference-temperature", temperatureC: OPERATING_TEMPERATURE_C } },
|
|
2288
|
-
operatingStateId: IDS.operatingState, readoutProfile: r.readoutProfile, regimeId: r.regimeId } };
|
|
2475
|
+
operatingStateId: IDS.operatingState, readoutProfile: r.readoutProfile, regimeId: r.regimeId, ...(fixedPattern ? { fixedPatternNoise: fixedPattern } : {}) } };
|
|
2289
2476
|
}
|
|
2290
2477
|
const readTile = async (request) => ({ ...request, sites: Array.from({ length: request.width }, (_, j) => siteInput(request.y * nativeWidth + request.x + j)) });
|
|
2291
2478
|
const yieldControl = createHostYield().yieldControl;
|
|
@@ -2293,23 +2480,29 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2293
2480
|
let diffractionMs = 0, defocusMs = 0, demosaicMs, encodeMs;
|
|
2294
2481
|
const diffractedProvider = (window) =>
|
|
2295
2482
|
// #548: the disc (or diffraction ⊛ disc) in image space, on the sensor's site lattice, through the mapping, field throughput and rotation.
|
|
2296
|
-
defocusSiteKernels && defocusGeometry && defocusImageSpace ? createDefocusImageSpaceProvider({ base:
|
|
2483
|
+
defocusSiteKernels && defocusGeometry && defocusImageSpace ? createDefocusImageSpaceProvider({ base: sceneProvider, geometry: defocusGeometry, siteKernels: defocusSiteKernels.at,
|
|
2297
2484
|
...(defocusSiteKernels.large ? { largeKernel: defocusSiteKernels.large } : {}),
|
|
2298
2485
|
wavelengthsNm: defocusSiteKernels.wavelengthsNm, kernelRadius: defocusSiteKernels.radius, nativeWidth, nativeHeight, sitePitchMicrometers: pitch, distanceM: subjectDistanceM,
|
|
2299
|
-
metresPerFrameUnit, queryOffsetsSites, timesPerBand: temporalSampleCount, onConvolution: (ms) => { defocusMs += ms; }, ...(window ? { window } : {}),
|
|
2486
|
+
metresPerFrameUnit, queryOffsetsSites, timesPerBand: temporalSampleCount, onConvolution: (ms) => { defocusMs += ms; }, ...(window ? { window } : {}), ...(subject ? { timeDependentScene: true } : {}),
|
|
2300
2487
|
...(bokehZones ? { fieldZones: bokehZones.zones } : {}) }) :
|
|
2301
2488
|
defocusSiteKernels ? createDiffractedPlanarProvider({ base: provider, siteKernels: defocusSiteKernels.at, ...(defocusSiteKernels.large ? { largeKernel: defocusSiteKernels.large } : {}),
|
|
2302
2489
|
wavelengthsNm: defocusSiteKernels.wavelengthsNm, kernelRadius: defocusSiteKernels.radius, nativeWidth, nativeHeight, outputWidth: width,
|
|
2303
2490
|
sitePitchMicrometers: pitch, distanceM: subjectDistanceM, metresPerFrameUnit, onConvolution: (ms) => { defocusMs += ms; },
|
|
2304
2491
|
...(window ? { window } : {}), ...(bokehZones ? { fieldZones: bokehZones.zones } : {}),
|
|
2305
|
-
...(antiAliasing || boxSamples > 1 ? { queryOffsetsSites
|
|
2492
|
+
...(antiAliasing || boxSamples > 1 ? { queryOffsetsSites } : {}) }) :
|
|
2306
2493
|
diffraction && diffractionObjectSpace ? createDiffractedPlanarProvider({ base: provider, psf: diffractionPsf.configuration.psf,
|
|
2307
2494
|
wavelengthsNm: diffraction.profile.axes.wavelengthNm, kernelRadius: diffraction.kernelRadiusSamples, nativeWidth, nativeHeight, outputWidth: width,
|
|
2308
2495
|
sitePitchMicrometers: pitch, distanceM: subjectDistanceM, metresPerFrameUnit, onConvolution: (ms) => { diffractionMs += ms; },
|
|
2309
2496
|
// Region (2.6): convolve only the lattice the region's native sites query; the values are the whole render's.
|
|
2310
2497
|
...(window ? { window } : {}),
|
|
2311
2498
|
// The environment queries each site at each aperture node minus each split offset (sites, +x right, +y down).
|
|
2312
|
-
...(antiAliasing || boxSamples > 1 ? { queryOffsetsSites
|
|
2499
|
+
...(antiAliasing || boxSamples > 1 ? { queryOffsetsSites } : {}) }) :
|
|
2500
|
+
// #565, #588: the PSF quadrature reads each tap's source at its lattice point (a unit kernel on #548's lattice), so ties resolve as on the lattice.
|
|
2501
|
+
(diffraction || catalogueTaps !== undefined) && latticeTaps ? createDefocusImageSpaceProvider({ base: sceneProvider, geometry: lensGeometry, siteKernels: () => UNIT_SITE_KERNEL,
|
|
2502
|
+
kernelRadius: 0, wavelengthsNm: diffraction ? diffraction.profile.axes.wavelengthNm : quadratureWavelengths(), nativeWidth, nativeHeight, sitePitchMicrometers: pitch, distanceM: subjectDistanceM, metresPerFrameUnit,
|
|
2503
|
+
queryOffsetsSites: [-1, 0, 1].flatMap((k) => { const r = k * (diffraction ? diffraction.kernelRadiusSamples : catalogueKernelRadius); return queryOffsetsSites.map((o) => ({ x: o.x + r, y: o.y + r })); }),
|
|
2504
|
+
timesPerBand: temporalSampleCount, zeroBeyondHorizon: catalogue?.kind === "fisheye", ...(window ? { window } : {}),
|
|
2505
|
+
...(subject ? { timeDependentScene: true } : {}) }) : sceneProvider;
|
|
2313
2506
|
// Built on first use: a capture that takes the tabulated path (#394 step 2) never queries it, and its kernels cost a setup.
|
|
2314
2507
|
let rawProviderBuilt;
|
|
2315
2508
|
const rawProvider = () => (rawProviderBuilt ??= diffractedProvider(regionNative));
|
|
@@ -2387,16 +2580,32 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2387
2580
|
// site centre plus the kernel radius and the largest query offset.
|
|
2388
2581
|
// #569: a catalogue lens's field-dependent PSF, read per field zone by the lens signal (display-view-lens-signal.ts, loaded on demand).
|
|
2389
2582
|
const viewLensKit = displayView && lensGeometry ? await import("./display-view-lens-signal.js") : undefined;
|
|
2583
|
+
// 2.20.0: the field zones are lens.catalogue's fast method in the view; exact resolves the PSF at every site (zones of one site).
|
|
2390
2584
|
const displayViewLensPsf = viewLensKit && catalogue && lensPsf ?
|
|
2391
|
-
viewLensKit.createDisplayViewLensPsf(lensPsf.configuration.psf, catalogueKernelRadius, nativeWidth, nativeHeight, pitch) : undefined;
|
|
2585
|
+
viewLensKit.createDisplayViewLensPsf(lensPsf.configuration.psf, catalogueKernelRadius, nativeWidth, nativeHeight, pitch, catalogueFastRequested ? undefined : Math.max(nativeWidth, nativeHeight)) : undefined;
|
|
2586
|
+
if (displayViewLensPsf && displayViewLensPsf.zoneSites > 1)
|
|
2587
|
+
markFast("lens.catalogue");
|
|
2588
|
+
const viewAhdFast = ahd && resolveFast(demosaicFast, fastTop);
|
|
2589
|
+
if (displayView && viewAhdFast)
|
|
2590
|
+
markFast("output.demosaic.ahd");
|
|
2392
2591
|
if (displayViewLensPsf)
|
|
2393
2592
|
assume("view.lensPsf", "model", { method: "piecewise-constant-field-zones", zoneSites: displayViewLensPsf.zoneSites, kernelRadiusSites: catalogueKernelRadius, scientificStatus: "approximation" }, "Each site takes the lens PSF at its field zone's centre, where the capture resolves it at each query point (docs/DISPLAY_VIEW.md, \"Catalogue lenses\").");
|
|
2394
2593
|
const lensSignal = viewLensKit ? viewLensKit.prepareDisplayViewLensSignal({ geometry: lensGeometry,
|
|
2395
|
-
times: rotating ? Array.from({ length: temporalSampleCount }, (_, i) => (i + 0.5) * (shutterSeconds / temporalSampleCount)) : [0], distanceM: subjectDistanceM, metresPerFrameUnit,
|
|
2594
|
+
times: rotating || subject ? Array.from({ length: temporalSampleCount }, (_, i) => (i + 0.5) * (shutterSeconds / temporalSampleCount)) : [0], distanceM: subjectDistanceM, metresPerFrameUnit,
|
|
2396
2595
|
pitch, imagingArea, boxSamples, spots: antiAliasing ? antiAliasing.components : [{ offsetMicrometers: { x: 0, y: 0 } }], psf: displayViewLensPsf, zeroBeyondHorizon: catalogue?.kind === "fisheye" }, nativeWidth, nativeHeight, (diffraction ? diffraction.kernelRadiusSamples : 0) + (displayViewDefocus ? (bokehAxisKernel?.radius ?? defocusDiscKernelRadius(displayViewDefocus.diameterSites)) : 0) + catalogueKernelRadius +
|
|
2397
2596
|
Math.max(0, ...queryOffsetsSites.map((o) => Math.max(Math.abs(o.x), Math.abs(o.y)))) - 0.5, catalogue ? "lens.catalogue" : imageSpaceOptions.join(", ")) : undefined;
|
|
2597
|
+
// #535: the green channel's expected charge from an 18 % Lambertian grey patch (radiance 0.18·E·S(λ)/π) next to the axis, and the sensor report;
|
|
2598
|
+
// the mid-grey figure only when the request resolves to exact (fast: false, the exports' default): it costs about 0.3 ms.
|
|
2599
|
+
let greyElectrons = fastTop ? undefined : 0;
|
|
2600
|
+
if (!idealReference && greyElectrons !== undefined)
|
|
2601
|
+
for (const [nm, a] of axisChannelResponse(displayView ? "simulateDisplayView" : "simulateCapture").electronsPerRadiance.green)
|
|
2602
|
+
greyElectrons += mixed ? a * GREY_CARD_REFLECTANCE * mixed.irradianceAt(0, 0, 0, 0, nm) / Math.PI :
|
|
2603
|
+
a * GREY_CARD_REFLECTANCE * irradianceScale * illuminant.shape.evaluate(nm) / Math.PI;
|
|
2604
|
+
const sensor = sensorReport(regimeByChannel.green.readoutProfile, regimeByChannel.green.regimeId, capacityElectrons, greyElectrons, DARK_CURRENT_ELECTRONS_PER_SECOND * shutterSeconds, { pixelPitchMicrometers: pitch, peakQuantumEfficiency: 0.5 * qeScale,
|
|
2605
|
+
readoutSeconds: characteristics?.readoutSeconds ?? body.rollingReadoutTiming?.profile.spatialSamplingSkewSeconds?.value ?? null }, fixedPattern);
|
|
2398
2606
|
if (displayView)
|
|
2399
|
-
return await renderView({ lens: lensSignal, lensPsf: displayViewLensPsf, ahd, fastReport, antiAliasing, aspect, assume, assumptions, axisChannelResponse, boxSamples, buildProvider, callerScene, chart, materialLayers, chartReport, chartSize, diffraction, defocus: displayViewDefocus, diffractionPsf, fNumber, focalLengthMm, focusDistanceM, height, highlightMode, exposureGain, illuminanceLux, illuminant, illuminantOption, imagingArea, intentOf, metresPerFrameUnit, noiseSeed, outputOptions, parsedView, pitch, preset, provider, regimeByChannel, resolvedIso, isoReport, standardExposureDeviationStops: exposureDeviationStops, standardExposureIlluminanceLux: exposureIlluminanceLux, greyCardDeviationStops: exposureGreyCardDeviationStops, metering, sceneOption, scenePreset, shutterSeconds, started, subjectDistanceM, toneCurve, whiteBalanceOption, whiteBalanceState, width, checkAbort, margin, mode, nativeHeight, nativeWidth, sourceSha256, yieldControl, kelvinWhiteBalanceInput
|
|
2607
|
+
return await renderView({ sensor, viewFast: { ahd: viewAhdFast, uniformSampling: fastTop }, lens: lensSignal, lensPsf: displayViewLensPsf, movingScene, 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,
|
|
2608
|
+
mixedIllumination: mixedKit && lightKit });
|
|
2400
2609
|
// Ideal reference (2.3): the scene without camera effects, at the capture's own exposure, white balance and encoding.
|
|
2401
2610
|
if (idealReference) {
|
|
2402
2611
|
const referenceStarted = clockMs();
|
|
@@ -2405,19 +2614,20 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2405
2614
|
if (needsAuto)
|
|
2406
2615
|
fail("simulateIdealReference cannot reproduce output.whiteBalance \"auto\": the capture measures it from its noisy RAW. Use \"scene-illuminant\" or a fixed white balance.");
|
|
2407
2616
|
const { electronsPerRadiance, wavelengths, relativePerElectron } = axisChannelResponse("simulateIdealReference");
|
|
2408
|
-
// 3.
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
};
|
|
2617
|
+
// 3. Each sample's signals, integrated from its spectral radiance at the nodes against each channel's response when it is read
|
|
2618
|
+
// (2.21.0): chart materials keep their spectra (and the fixed-size chart's surround), caller functions are read per sample.
|
|
2619
|
+
const responses = CHANNELS.map((c) => [[...electronsPerRadiance[c]].map(([nm, a]) => [wavelengths.indexOf(nm), a]), relativePerElectron[c]]);
|
|
2620
|
+
const signalOf = (spectrum) => responses.map(([terms, scale]) => { let e = 0; for (const [i, a] of terms)
|
|
2621
|
+
e += a * spectrum[i]; return e * scale; });
|
|
2414
2622
|
const materialCount = chart ? chart.materials.length : callerScene?.kind === "material-function" ? callerScene.materials.length : 0;
|
|
2415
|
-
const
|
|
2623
|
+
const spectra = provider.chartMaterialAt ? Array.from({ length: materialCount + (chartSize ? 1 : 0) }, (_, m) => wavelengths.map((nm) => provider.materialRadiance(m, nm))) : undefined;
|
|
2416
2624
|
// 4. Pixels: the native sites of the output (or region) pixels, each centre-sampled or box-integrated like the capture.
|
|
2417
2625
|
const x0 = (region ? region.x : 0) + margin, y0 = (region ? region.y : 0) + margin, n = boxSamples, unit = 2 / width;
|
|
2418
2626
|
const referenceIntent = intentOf(whiteBalanceState), referenceGains = referenceIntent ? [referenceIntent.channelGains.red, referenceIntent.channelGains.green, referenceIntent.channelGains.blue] : null;
|
|
2419
2627
|
const development = prepareSensorColorDevelopment(CAMERA_TO_XYZ, referenceGains, highlightMode);
|
|
2420
2628
|
const rgb = new Float64Array(outputWidth * outputHeight * 3), counts = { whiteBalanceClippedSampleCount: 0, reconstructedPixelCount: 0 }, sum = [0, 0, 0];
|
|
2629
|
+
// A sample that reads the previous sample's material reuses that sample's integral (nothing is kept per material).
|
|
2630
|
+
let last = -1, s = [];
|
|
2421
2631
|
for (let py = 0; py < outputHeight; py++) {
|
|
2422
2632
|
for (let px = 0; px < outputWidth; px++) {
|
|
2423
2633
|
sum[0] = 0;
|
|
@@ -2425,8 +2635,11 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2425
2635
|
sum[2] = 0;
|
|
2426
2636
|
for (let l = 0; l < n; l++)
|
|
2427
2637
|
for (let k = 0; k < n; k++) {
|
|
2428
|
-
const u = (x0 + px + (k + 0.5) / n - nativeWidth / 2) * unit, v = -(y0 + py + (l + 0.5) / n - nativeHeight / 2) * unit;
|
|
2429
|
-
|
|
2638
|
+
const u = (x0 + px + (k + 0.5) / n - nativeWidth / 2) * unit, v = -(y0 + py + (l + 0.5) / n - nativeHeight / 2) * unit, m = spectra ? provider.chartMaterialAt(u, v) : -1;
|
|
2639
|
+
if (m < 0 || m !== last) {
|
|
2640
|
+
s = signalOf(m < 0 ? wavelengths.map((nm) => provider.radianceAt(u, v, nm)) : spectra[m]);
|
|
2641
|
+
last = m;
|
|
2642
|
+
}
|
|
2430
2643
|
sum[0] += s[0];
|
|
2431
2644
|
sum[1] += s[1];
|
|
2432
2645
|
sum[2] += s[2];
|
|
@@ -2461,7 +2674,7 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2461
2674
|
const referenceMs = clockMs() - referenceStarted;
|
|
2462
2675
|
assume("reference.kind", "model", "ideal-reference", "An ideal reference, not a capture: the same scene, framing, chart size, output size, region and pixel grid as the capture request, with no camera effects.");
|
|
2463
2676
|
assume("reference.excludes", "model", ["lens PSF, diffraction, defocus and pupil sampling", "projection and distortion (ideal rectilinear mapping)", "vignetting", "anti-aliasing filter",
|
|
2464
|
-
"colour filter mosaic and demosaicing (every pixel has all three channels)", "photon, dark and read noise", "dark signal", "ADC rounding and non-linearity", "camera motion and rolling shutter"], "None of these is applied; each pixel's channels come from the scene radiance at the pixel alone.");
|
|
2677
|
+
"colour filter mosaic and demosaicing (every pixel has all three channels)", "photon, dark and read noise", "dark signal", "ADC rounding and non-linearity", "camera motion and rolling shutter", ...(subject ? ["subject motion"] : [])], "None of these is applied; each pixel's channels come from the scene radiance at the pixel alone.");
|
|
2465
2678
|
assume("reference.exposure", "model", { convention: "exposure-matched", fNumber, shutterSeconds, iso: resolvedIso, illuminanceLux,
|
|
2466
2679
|
normalizedSignalPerElectron: { ...relativePerElectron } }, "Exposure-matched: each channel is the expected (noise-free) signal the capture's own sensor would record at the resolved exposure (f-number, shutter, ISO, illuminance and lens transmission; the facade's environment chain at the optical axis), " +
|
|
2467
2680
|
"normalized like the RAW development ((code − black)/(white − black) = e·d/(G·(W − B)) for a linear readout), clipped at white, then developed with the capture's white balance, camera → XYZ → sRGB matrices, highlight mode, tone curve and encoding. " +
|
|
@@ -2471,7 +2684,7 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2471
2684
|
return Object.freeze({ kind: "ideal-reference", label: "Ideal reference (no camera effects); not a capture.", facadeVersion: SIMULATE_CAPTURE_FACADE_VERSION, engineApiVersion: ENGINE_API_VERSION,
|
|
2472
2685
|
image: { width: outputWidth, height: outputHeight, format, samples, ...(png ? { png, colorEncoding: colorEncoding } : {}) },
|
|
2473
2686
|
exposure: { mode, settings: { fNumber, shutterSeconds, iso: resolvedIso }, ev100: Math.log2(fNumber ** 2 / shutterSeconds), ...isoReport },
|
|
2474
|
-
scene: { sceneId: chart ? "chart:" + chart.preset : callerScene.sceneId, preset: scenePreset, parameters: chart
|
|
2687
|
+
scene: { sceneId: chart ? "chart:" + chart.preset : callerScene.sceneId, preset: scenePreset, parameters: (chart ?? callerScene).parameters ?? {},
|
|
2475
2688
|
illuminant: { id: illuminant.id, description: illuminant.description, illuminanceLux }, distanceM: subjectDistanceM,
|
|
2476
2689
|
framedWidthM: 2 * metresPerFrameUnit, framedHeightM: 2 * metresPerFrameUnit * aspect, ...(chartReport ? { chart: chartReport } : {}), sourceSha256 },
|
|
2477
2690
|
...(region && regionNative ? { region: { ...region, frameWidth: width, frameHeight: height } } : {}),
|
|
@@ -2512,7 +2725,7 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2512
2725
|
evaluateRadiance: rawProvider().evaluateRadiance, ...(pupil ? { evaluateApertureRadiance: provider.evaluateApertureRadiance } : {}), yieldControl });
|
|
2513
2726
|
// #394 step 2: an engine-built planar scene (chart preset or caller material scene; still, rectilinear, no distortion, vignetting,
|
|
2514
2727
|
// pupil rays or caller PSF) takes its expected electrons from the tabulated signal, verified per CFA phase against the chain.
|
|
2515
|
-
const tabulatedScene = chart !== undefined || callerScene?.kind === "material-function";
|
|
2728
|
+
const tabulatedScene = chart !== undefined || callerScene?.kind === "material-function" || callerScene?.kind === "image-plane";
|
|
2516
2729
|
// #391: a DNG from region parts takes the whole frame's RAW from the parts instead of rendering it.
|
|
2517
2730
|
// #571: simulateCaptureFromParts takes the RAW from the parts in the same way, then develops it as a capture does.
|
|
2518
2731
|
const dngParts = run === "dng-from-parts" || run === "develop-from-parts" ? viewOption : undefined, partsCaller = run === "dng-from-parts" ? "serializeSimulatedCaptureDngFromParts" : "simulateCaptureFromParts";
|
|
@@ -2523,13 +2736,15 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2523
2736
|
// still camera, site-centre queries and a chart or material scene; verified per CFA phase against the exact chain within its tolerance.
|
|
2524
2737
|
let catalogueFastRun;
|
|
2525
2738
|
const catalogueFastEligible = catalogueFastRequested && catalogueTaps !== undefined && lensKit !== undefined && lensPsf !== undefined && dngParts === undefined && tabulatedScene &&
|
|
2526
|
-
!pupil && !rotating && boxSamples === 1 && !antiAliasing && distortion?.kind === "radial" && provider.chartMaterialAt !== undefined && provider.materialRadiance !== undefined;
|
|
2739
|
+
!pupil && !rotating && !subject && boxSamples === 1 && !antiAliasing && distortion?.kind === "radial" && provider.chartMaterialAt !== undefined && provider.materialRadiance !== undefined;
|
|
2527
2740
|
// The tabulated paths' channel response per spectral node and material tables (the fast catalogue path and the expected-signal path).
|
|
2741
|
+
// #550: under mixed lighting a point's radiance is not its material's, so the tables read the provider's radiance instead (materials undefined).
|
|
2528
2742
|
const tabulatedTables = () => {
|
|
2529
2743
|
const { electronsPerRadiance, wavelengths } = axisChannelResponse("simulateCapture");
|
|
2530
|
-
|
|
2744
|
+
// #581: an image-plane print has no materials (its spectral nodes are the expected signal's, `image`).
|
|
2745
|
+
const sceneMaterials = chart ? chart.materials : callerScene.materials ?? [], materialRadiance = provider.materialRadiance;
|
|
2531
2746
|
return { wavelengths, perNode: CHANNELS.map((c) => Float64Array.from(wavelengths, (nm) => electronsPerRadiance[c].get(nm) ?? 0)),
|
|
2532
|
-
materials: { materialAt: provider.chartMaterialAt, whiteRadiance: (nm) => irradianceScale * illuminant.shape.evaluate(nm) / Math.PI,
|
|
2747
|
+
materials: mixed ? undefined : { materialAt: provider.chartMaterialAt, whiteRadiance: (nm) => irradianceScale * illuminant.shape.evaluate(nm) / Math.PI,
|
|
2533
2748
|
list: [...sceneMaterials.map((m, index) => (m.kind === "neutral" ? { neutral: m.reflectance } : { radiance: (nm) => materialRadiance(index, nm) })),
|
|
2534
2749
|
...(chartSize ? [{ neutral: chartSize.surroundReflectance }] : [])] } };
|
|
2535
2750
|
};
|
|
@@ -2549,20 +2764,21 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2549
2764
|
catalogueFastRun = { ...signal.grid, verificationRelativeDifference: tabulated.verificationRelativeDifference };
|
|
2550
2765
|
}
|
|
2551
2766
|
// #553: a cat's eye varies the kernel over the field, which the expected-signal tables (one kernel per node) do not take.
|
|
2552
|
-
else if (dngParts === undefined && tabulatedScene && !pupil && !lensFieldMapping && !vignetting && !lensPsf && !rotating && !bokehZones && (!diffraction || diffractionObjectSpace) &&
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2767
|
+
else if (dngParts === undefined && tabulatedScene && !pupil && !lensFieldMapping && !vignetting && !lensPsf && !rotating && !subject && !bokehZones && (!diffraction || diffractionObjectSpace) &&
|
|
2768
|
+
(mixed || provider.materialRadiance || provider.imageSignalScene)) {
|
|
2769
|
+
// #581: an image-plane print takes the radiance path, its signals integrated per pixel from the reconstructed spectrum, instead of material tables.
|
|
2770
|
+
const { wavelengths, perNode, materials } = tabulatedTables(), tabulatedMaterials = provider.imageSignalScene ? undefined : materials;
|
|
2771
|
+
// The input both expected signals share (the scene, the responses, the aperture and the geometry).
|
|
2772
|
+
const shared = { nativeWidth, nativeHeight, width, wavelengths, perNode, apertureNodes: apertureNodeOffsets(boxSamples), boxSamples, antiAliasing, pitch, materials: tabulatedMaterials,
|
|
2773
|
+
imageSignalScene: provider.imageSignalScene, radianceAt: provider.radianceAt, imagingArea, focalLengthMm, focusDistanceM, subjectDistanceM, metresPerFrameUnit, caller: "simulateCapture" };
|
|
2774
|
+
const diffractionKernels = diffraction ? { radius: diffraction.kernelRadiusSamples, psf: diffractionPsf.configuration.psf, profileWavelengths: diffraction.profile.axes.wavelengthNm } : undefined;
|
|
2775
|
+
const { signalInput: builtSignalInput } = buildExpectedSignalInput({ ...shared, diffraction: defocusSiteKernels ? undefined : diffractionKernels });
|
|
2557
2776
|
// #492: with lens.defocus the table convolves the lattice radiance with each node's defocus (or combined) kernel, on the
|
|
2558
2777
|
// lattice formula, exactly as the object-space provider does.
|
|
2559
2778
|
let signalInput = builtSignalInput;
|
|
2560
2779
|
if (defocusSiteKernels?.large) {
|
|
2561
2780
|
// #564: the in-focus signal on the lattice formula (diffraction kernels, or a unit tap per node), then the disc by runs.
|
|
2562
|
-
signalInput = buildExpectedSignalInput({
|
|
2563
|
-
diffraction: diffraction ? { radius: diffraction.kernelRadiusSamples, psf: diffractionPsf.configuration.psf, profileWavelengths: diffraction.profile.axes.wavelengthNm } : undefined,
|
|
2564
|
-
largeDefocus: defocusSiteKernels.large.runs, materials: tabulatedMaterials,
|
|
2565
|
-
radianceAt: provider.radianceAt, imagingArea, focalLengthMm, focusDistanceM, subjectDistanceM, metresPerFrameUnit, caller: "simulateCapture" }).signalInput;
|
|
2781
|
+
signalInput = buildExpectedSignalInput({ ...shared, diffraction: diffractionKernels, largeDefocus: defocusSiteKernels.large.runs }).signalInput;
|
|
2566
2782
|
}
|
|
2567
2783
|
else if (defocusSiteKernels) {
|
|
2568
2784
|
const lattice = { ...builtSignalInput };
|
|
@@ -2577,14 +2793,13 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2577
2793
|
}
|
|
2578
2794
|
let rawOutput;
|
|
2579
2795
|
if (dngParts !== undefined) {
|
|
2580
|
-
rawOutput = { raw: joinNativeRawParts(dngParts, plan, nativeWidth, nativeHeight, partsCaller), providerEvaluationCount: 0 };
|
|
2796
|
+
rawOutput = { raw: joinNativeRawParts(dngParts, plan, nativeWidth, nativeHeight, partsCaller, fastReport.requested), providerEvaluationCount: 0 };
|
|
2581
2797
|
if (run === "develop-from-parts")
|
|
2582
2798
|
assume("parts", "model", { partCount: dngParts.length }, "The whole render's RAW, joined from region renders of this request (#571); how they were rendered is in their results.");
|
|
2583
2799
|
}
|
|
2584
2800
|
else if (tabulated && catalogueFastRun) {
|
|
2585
2801
|
rawOutput = { raw: tabulated.raw, providerEvaluationCount: tabulated.providerEvaluationCount };
|
|
2586
|
-
|
|
2587
|
-
fastReport = Object.freeze({ ...fastReport, applied: Object.freeze({ ...fastApplied }), assurance: "approximation-with-a-priori-bound" });
|
|
2802
|
+
markFast("lens.catalogue");
|
|
2588
2803
|
for (const a of lensKit.catalogueFastAssumptions(catalogueFastRun))
|
|
2589
2804
|
assume(a.id, "model", a.value, a.explanation);
|
|
2590
2805
|
}
|
|
@@ -2660,6 +2875,15 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2660
2875
|
evidence, limitations: ["Diagonal camera→XYZ matrix of the idealized colorimetric (Luther–Ives) sensor; exact up to spectral quadrature."] };
|
|
2661
2876
|
const phaseProfiles = builtInDemosaic ? builtInDemosaic.phaseProfiles : bilinearPhaseProfiles();
|
|
2662
2877
|
const developmentWhiteBalance = whiteBalanceState ? "apply-resolved-sensor-gains" : "not-required";
|
|
2878
|
+
// #589: each channel's effective dispersion (its own response × the illuminant at the capture's spectral nodes) for the catalogue
|
|
2879
|
+
// lens's lateral-colour corrections: the developed ones and, in a DNG, the opcodes (#587), so both use the same channel scales.
|
|
2880
|
+
let channelDispersion;
|
|
2881
|
+
const captureChannelDispersion = () => {
|
|
2882
|
+
if (channelDispersion)
|
|
2883
|
+
return channelDispersion;
|
|
2884
|
+
const { electronsPerRadiance, wavelengths } = axisChannelResponse("simulateCapture");
|
|
2885
|
+
return channelDispersion = lensKit.channelEffectiveDispersion(wavelengths, CHANNELS.map((c) => wavelengths.map((nm) => electronsPerRadiance[c].get(nm) ?? 0)), wavelengths.map((nm) => illuminant.shape.evaluate(nm)));
|
|
2886
|
+
};
|
|
2663
2887
|
// DNG (2.0): the packed native RAW itself, before development takes ownership of it.
|
|
2664
2888
|
let dng;
|
|
2665
2889
|
if (format === "dng") {
|
|
@@ -2668,9 +2892,11 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2668
2892
|
return [channel, { conversionGainElectronsPerCode: resolved.regime.systemConversionGainElectronsPerCode.value / (resolved.regime.adc.digitalGain ?? 1),
|
|
2669
2893
|
readNoiseRmsElectrons: resolved.combinedReadNoiseRmsElectrons }];
|
|
2670
2894
|
}));
|
|
2671
|
-
const dngStarted = clockMs();
|
|
2895
|
+
const dngStarted = clockMs(), opcodes = dngOpcodeSelection && lensKit.createDngLensCorrectionOpcodes(lensKit.resolveGenericLens({ lensId: catalogue.lensId, activeImagingArea: catalogueFrame, fNumber }), dngOpcodeSelection, width, height, pitch / 1000, captureChannelDispersion());
|
|
2672
2896
|
dng = await serializeNativeRawDng({ raw: rawOutput.raw, colorProfile, noise, ...(dngCapturedAtUtc === undefined ? {} : { capturedAtUtc: dngCapturedAtUtc }),
|
|
2673
|
-
...(textMetadata ? { textMetadata } : {}) });
|
|
2897
|
+
...(textMetadata ? { textMetadata } : {}), ...(opcodes ? { opcodeList3: opcodes.opcodeList3 } : {}) });
|
|
2898
|
+
if (opcodes)
|
|
2899
|
+
assume("output.dng.lensCorrectionOpcodes", "model", { tag: 51022, channelDispersion: captureChannelDispersion(), opcodes: opcodes.opcodes }, "output.lensCorrections' profile as optional DNG opcodes, in order (docs/NATIVE_RAW_AND_PRINT.md, \"Lens-correction opcodes\"); the RAW is unchanged.");
|
|
2674
2900
|
encodeMs = clockMs() - dngStarted;
|
|
2675
2901
|
assumeTextMetadata(TEXT_METADATA_LAYOUT.dng);
|
|
2676
2902
|
checkAbort();
|
|
@@ -2713,10 +2939,12 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2713
2939
|
// #570: the lens corrections after the diffraction correction (which acts on the sensor's own lattice), before the tone curve.
|
|
2714
2940
|
let cornerStops = null;
|
|
2715
2941
|
if (lensCorrection) {
|
|
2942
|
+
const channelDispersion = captureChannelDispersion();
|
|
2943
|
+
lensCorrection = lensKit.prepareDevelopedLensCorrection(catalogue, lensCorrectionSelection, width, height, pitch / 1000, channelDispersion);
|
|
2716
2944
|
const s = lensCorrectionSelection, c = lensCorrection, k = (channel) => c.scales[channel];
|
|
2717
2945
|
if (s.vignetting)
|
|
2718
|
-
cornerStops =
|
|
2719
|
-
assume("output.lensCorrections", "model", { profileId: "photivra-" + catalogue.lensId + ":corrections", ...s, k1: c.k1, channelScales: { red: k(0), green: k(1), blue: k(2) },
|
|
2946
|
+
cornerStops = catalogueCornerStops;
|
|
2947
|
+
assume("output.lensCorrections", "model", { profileId: "photivra-" + catalogue.lensId + ":corrections", ...s, k1: c.k1, channelDispersion, channelScales: { red: k(0), green: k(1), blue: k(2) },
|
|
2720
2948
|
cornerStops, cornerGain: cornerStops === null ? 1 : 2 ** cornerStops, magnification: 1 / c.fill, ...(imageRegion ? { developedRect: region } : {}), dng: "uncorrected" }, "The catalogue lens's correction profile (createGenericLensCorrectionProfile; an approximation) on the developed linear colour: pixel p reads channel c bilinearly at q(1 + k1|q|²/R²), q = a_c·p/magnification " +
|
|
2721
2949
|
"(the smallest magnification ≥ 1 that keeps every source in the frame), times det J/E at the source, so a flat field stays flat (E = (1 − bρ²)², 1/det J the distortion's area factor). " +
|
|
2722
2950
|
"Corners lose resolution, corner noise rises with the gain, axial colour stays. The DNG is not corrected.");
|
|
@@ -2864,7 +3092,7 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2864
3092
|
...(distortion ? ["lens.distortion"] : []), ...(vignetting ? ["lens.vignetting"] : []), ...(lensPsf ? ["lens.psf"] : []),
|
|
2865
3093
|
...(catalogue ? ["lens.catalogue"] : []),
|
|
2866
3094
|
...(diffraction ? ["lens.diffraction"] : []), ...(defocusSiteKernels || catalogueDefocusApplied ? ["lens.defocus"] : []), ...(antiAliasing ? ["sensor.antiAliasing"] : []), ...(boxAperture ? ["sensor.pixelAperture.box"] : []),
|
|
2867
|
-
...(temporalSampleCount > 1 ? ["sensor.temporalSampleCount"] : []), ...(rotating ? ["motion.rotation"] : []),
|
|
3095
|
+
...(temporalSampleCount > 1 ? ["sensor.temporalSampleCount"] : []), ...(rotating ? ["motion.rotation"] : []), ...(subject ? ["motion.subject"] : []),
|
|
2868
3096
|
...(rollingSchedule ? ["motion.shutter.rolling"] : []), ...(mirrorBorder ? ["output.borderHandling.mirror"] : []), ...(region ? ["output.region"] : []),
|
|
2869
3097
|
...(needsAuto ? ["output.whiteBalance.auto"] : []), ...(ahd ? ["output.demosaic.ahd"] : []),
|
|
2870
3098
|
...(highlightMode === "reconstruct" ? ["output.highlightMode.reconstruct"] : []), ...(wideGamut ? ["output.colorEncoding.wide-gamut"] : []),
|
|
@@ -2880,10 +3108,11 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2880
3108
|
request: { environmentRaw: finalRawInput, siteInput, development, whiteBalanceState,
|
|
2881
3109
|
...(diffraction ? { diffraction: { profile: diffraction, psf: diffractionPsf, execution: diffractionObjectSpace ? "object-space-convolution" :
|
|
2882
3110
|
defocusImageSpace ? "image-space-convolution" : "environment-psf-quadrature" } } : {}) },
|
|
3111
|
+
sensor,
|
|
2883
3112
|
exposure: { mode, settings: { fNumber, shutterSeconds, iso: resolvedIso }, ev100: Math.log2(fNumber ** 2 / shutterSeconds), ...isoReport,
|
|
2884
3113
|
metering, meteredSceneLuminanceCdPerSquareMeter: meteredLuminance, standardExposureDeviationStops: exposureDeviationStops,
|
|
2885
3114
|
standardExposureIlluminanceLux: exposureIlluminanceLux, greyCardDeviationStops: exposureGreyCardDeviationStops, resolution },
|
|
2886
|
-
scene: { sceneId: chart ? "chart:" + chart.preset : callerScene.sceneId, preset: scenePreset, parameters: chart
|
|
3115
|
+
scene: { sceneId: chart ? "chart:" + chart.preset : callerScene.sceneId, preset: scenePreset, parameters: (chart ?? callerScene).parameters ?? {},
|
|
2887
3116
|
illuminant: { id: illuminant.id, description: illuminant.description, illuminanceLux }, distanceM: subjectDistanceM,
|
|
2888
3117
|
framedWidthM: 2 * metresPerFrameUnit, framedHeightM: 2 * metresPerFrameUnit * aspect, ...(chartReport ? { chart: chartReport } : {}), sourceSha256 },
|
|
2889
3118
|
diagnostics: { providerEvaluationCount, highlight: developed.highlight, rendering: developed.diagnostics, ...(gamutMappingDiagnostics ? { gamutMapping: gamutMappingDiagnostics } : {}),
|