@photivra/engine 2.20.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 +178 -1
- package/CITATION.cff +1 -1
- package/README.md +7 -6
- package/SECURITY.md +1 -1
- package/THIRD_PARTY.md +7 -0
- package/dist/capture/dng-lens-correction-opcodes.d.ts +2 -2
- package/dist/capture/dng-lens-correction-opcodes.js +2 -2
- package/dist/capture/dng-lens-correction-opcodes.js.map +1 -1
- 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 +41 -73
- 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.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 +12 -3
- package/dist/capture/sensor-raw-producer.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 +3 -2
- package/dist/equipment/generic-lens-catalogue.js +18 -5
- 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 +16 -11
- package/dist/equipment/generic-tier-assets.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 +6 -1
- package/dist/facade/display-view-gpu.js.map +1 -1
- package/dist/facade/display-view-lens-signal.js +25 -23
- package/dist/facade/display-view-lens-signal.js.map +1 -1
- package/dist/facade/display-view.js +5 -23
- 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.js +9 -1
- package/dist/facade/fast-methods.js.map +1 -1
- package/dist/facade/generic-lens-capture.d.ts +2 -2
- package/dist/facade/generic-lens-capture.js +2 -2
- 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 +39 -10
- package/dist/facade/simulate-capture.js +252 -92
- package/dist/facade/simulate-capture.js.map +1 -1
- package/dist/facade/simulate-display-view.js +14 -6
- 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 +11 -3
- package/dist/index.js +7 -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/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.js +54 -46
- 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/output/developed-lens-corrections.d.ts +19 -5
- package/dist/output/developed-lens-corrections.js +28 -9
- package/dist/output/developed-lens-corrections.js.map +1 -1
- package/dist/output/generic-lens-corrections.d.ts +41 -5
- package/dist/output/generic-lens-corrections.js +52 -8
- package/dist/output/generic-lens-corrections.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/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/sensor-characteristics.d.ts +10 -0
- package/dist/sensor/sensor-characteristics.js +20 -9
- package/dist/sensor/sensor-characteristics.js.map +1 -1
- package/docs/AGENT_GUIDE.md +1 -0
- package/docs/API_REFERENCE.md +98 -1
- package/docs/QUICK_START.md +125 -0
- package/docs/USAGE.md +92 -1
- package/docs/api/core-version.md +1 -1
- package/docs/api/equipment-generic-lens-catalogue.md +10 -5
- package/docs/api/equipment-sensor-profile-body.md +64 -0
- package/docs/api/equipment-sensor-profiles.md +86 -0
- package/docs/api/exports.json +207 -2
- package/docs/api/facade-exposure-build-up.md +105 -0
- package/docs/api/facade-fast-methods.md +9 -1
- 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 +26 -6
- package/docs/api/motion-subject-motion.md +23 -0
- package/docs/api/optics-generic-lens-lateral-color.md +79 -0
- package/docs/api/output-developed-lens-corrections.md +10 -1
- package/docs/api/output-generic-lens-corrections.md +40 -4
- package/docs/api/output-print-plan.md +35 -1
- package/llms.txt +2 -0
- package/package.json +1 -1
|
@@ -35,6 +35,7 @@ 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";
|
|
39
40
|
import { fastFormatDefault, resolveFast } from "./fast-resolution.js";
|
|
40
41
|
import { SCENE_TO_SENSOR_BRIDGE_SCOPE } from "../optics/scene-to-sensor-irradiance.js";
|
|
@@ -53,6 +54,7 @@ import { SENSOR_RAW_PRODUCER_NOISE_MODEL } from "../capture/sensor-raw-producer.
|
|
|
53
54
|
import { calculateNativeRawPlan, nativeRawMaximumProviderEvaluations, NATIVE_RAW_LIMITS } from "../capture/native-raw.js";
|
|
54
55
|
import { renderTabulatedNativeRaw, TABULATED_RELATIVE_TOLERANCE } from "../capture/native-environment-tabulated.js";
|
|
55
56
|
import { createDisplayViewExpectedSignal } from "./expected-signal.js";
|
|
57
|
+
import * as mixedIlluminationKit from "./mixed-illumination.js";
|
|
56
58
|
import { buildExpectedSignalInput } from "./expected-signal-setup.js";
|
|
57
59
|
import { createNativeEnvironmentRawTask, createNativeEnvironmentRawRegionTask } from "../capture/native-environment-raw.js";
|
|
58
60
|
import { createNativeRawDevelopmentTask, NATIVE_RAW_DEVELOPMENT_EXPOSURE_RANGE_STOPS } from "../capture/native-raw-development.js";
|
|
@@ -94,9 +96,11 @@ import { prepareRgbGamutMapping } from "../color/gamut-mapping.js";
|
|
|
94
96
|
import { serializeNativeRawDng } from "../capture/native-raw-dng.js";
|
|
95
97
|
import { correctDiffractionInPlace, defaultDiffractionCorrectionRadiusPixels, designDiffractionCorrectionFilter, parseDiffractionCorrectionSettings, parseUnsharpMaskSettings, renderSdrWithUnsharpMaskInPlace, DEVELOPMENT_DETAIL_MODEL_VERSION } from "../processing/development-detail.js";
|
|
96
98
|
import { resolveSensorReadoutRegime } from "../sensor/raw-readout.js";
|
|
99
|
+
import { createSubjectMotionProvider, subjectTravelM } from "../motion/subject-motion.js";
|
|
97
100
|
import { characterizeReadout, parseSensorCharacteristics, sensorReport, SENSOR_CHARACTERISTIC_KEYS } from "../sensor/sensor-characteristics.js";
|
|
98
101
|
import { resolveChartScene, chartMaterialInputs, CHART_SCENE_PRESETS } from "./chart-scenes.js";
|
|
99
102
|
import { resolveChartMaterialLayers } from "./chart-material-layers.js";
|
|
103
|
+
import { imagePlaneSupport } from "./image-plane-support.js";
|
|
100
104
|
import { resolveCaptureIlluminant, calculateIlluminantIrradianceScale, buildPlanarSceneRadianceProvider, requireCallerScene, FACADE_SPECTRAL_RANGE_NANOMETERS, resolveChartSizeOption, PHOTOPIC_MAXIMUM_LUMINOUS_EFFICACY_LM_PER_W, planarProviderMaterialOfDirection } from "./scene-radiance.js";
|
|
101
105
|
/** Version of how the facade builds requests. It changes whenever the same input would build a different request. */
|
|
102
106
|
export const SIMULATE_CAPTURE_FACADE_VERSION = "1.0.0";
|
|
@@ -108,7 +112,7 @@ export const SIMULATE_CAPTURE_BOX_APERTURE_DEFAULT_SAMPLES = 4;
|
|
|
108
112
|
* a capture does. `motion.rotation` is a non-zero angular velocity; `sensor.temporalSampleCount` a count above 1.
|
|
109
113
|
*/
|
|
110
114
|
export const SIMULATE_CAPTURE_COST_OPTIONS = /* @__PURE__ */ Object.freeze(["lens.pupilSampling", "lens.projection", "lens.distortion", "lens.vignetting", "lens.psf",
|
|
111
|
-
"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",
|
|
112
116
|
"output.whiteBalance.auto", "output.demosaic.ahd", "output.highlightMode.reconstruct", "output.colorEncoding.wide-gamut", "output.gamutMapping.perceptual",
|
|
113
117
|
"output.diffractionCorrection", "output.sharpening", "output.lensCorrections", "output.format.jpeg", "output.format.png", "output.format.dng"]);
|
|
114
118
|
/**
|
|
@@ -116,7 +120,7 @@ export const SIMULATE_CAPTURE_COST_OPTIONS = /* @__PURE__ */ Object.freeze(["len
|
|
|
116
120
|
* the record form with `fast` and is reported in `diagnostics.fast.applied` when it runs. Every one of them takes the flag,
|
|
117
121
|
* whether or not it has a fast method yet; one without runs exact and reports `false`.
|
|
118
122
|
*/
|
|
119
|
-
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"]);
|
|
120
124
|
export const FACADE_EVIDENCE = /* @__PURE__ */ Object.freeze([/* @__PURE__ */ Object.freeze({ sourceOrigin: "photivra",
|
|
121
125
|
sourceReference: "photivra:simulate-capture-facade", reuseStatus: "photivra-owned" })]);
|
|
122
126
|
const DEFAULTS = /* @__PURE__ */ Object.freeze({ camera: "professional", fNumber: 8, shutterSeconds: 1 / 125, iso: 100, compensationStops: 0,
|
|
@@ -416,14 +420,14 @@ function luminanceOf(radiance) {
|
|
|
416
420
|
return PHOTOPIC_MAXIMUM_LUMINOUS_EFFICACY_LM_PER_W * sum;
|
|
417
421
|
}
|
|
418
422
|
/**
|
|
419
|
-
* 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
|
|
420
424
|
* Auto ISO's default `isoSteps: "whole-stop"`. A whole-stop grid is returned unchanged, so 2.0.0 records resolve as before.
|
|
421
425
|
*/
|
|
422
426
|
function wholeStopIsoCapabilities(capabilities) {
|
|
423
427
|
const grid = capabilities.iso.settingGrid;
|
|
424
428
|
if (grid.kind !== "discrete-values")
|
|
425
429
|
return capabilities;
|
|
426
|
-
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])));
|
|
427
431
|
if (whole.length === grid.values.length || whole.length === 0)
|
|
428
432
|
return capabilities;
|
|
429
433
|
return { ...capabilities, iso: { ...capabilities.iso, settingGrid: { kind: "discrete-values", values: Object.freeze(whole) } } };
|
|
@@ -926,11 +930,13 @@ export const DISPLAY_VIEW_WITHOUT_VIEW = Symbol("display view without view");
|
|
|
926
930
|
* Internal: the request path shared by {@link simulateCapture}, {@link simulateIdealReference} and the display view, whose renderer is
|
|
927
931
|
* passed in; for a display view the renderer's value is returned (a result, or a prepared view with DISPLAY_VIEW_WITHOUT_VIEW).
|
|
928
932
|
*/
|
|
929
|
-
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) {
|
|
930
936
|
const started = Date.now(), idealReference = run === "ideal-reference", displayView = run === "display-view";
|
|
931
937
|
requireInputObject(options, "simulateCapture options");
|
|
932
938
|
const top = allowKeys(options, "simulateCapture options", ["camera", "lens", "exposure", "scene", "subjectDistanceM", "chartSize", "illuminant",
|
|
933
|
-
"illuminanceLux", "output", "sensor", "motion", "fast", "signal", "onProgress"]);
|
|
939
|
+
"illuminanceLux", "illumination", "output", "sensor", "motion", "fast", "signal", "onProgress"]);
|
|
934
940
|
const signal = top.signal, onProgress = top.onProgress;
|
|
935
941
|
if (signal !== undefined && (typeof signal !== "object" || signal === null || typeof signal.aborted !== "boolean"))
|
|
936
942
|
fail("signal must be an AbortSignal.");
|
|
@@ -954,6 +960,38 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
954
960
|
preset = GENERIC_TIER_ASSETS.find((p) => p.tier === cameraOption) ?? fail("Unknown camera tier.");
|
|
955
961
|
else
|
|
956
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
|
+
}
|
|
957
995
|
const body = preset.body, lens = preset.lens;
|
|
958
996
|
// Lens
|
|
959
997
|
const lensOptions = allowKeys(top.lens, "lens", ["focalLengthMm", "fNumber", "focusDistanceM", "pupilSampling", "projection", "distortion", "vignetting", "psf", "diffraction", "prime", "defocus",
|
|
@@ -963,7 +1001,7 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
963
1001
|
// The simulated frame in mm (output width × height × pixel pitch), validated again below.
|
|
964
1002
|
const frameMm = () => {
|
|
965
1003
|
const o = (typeof top.output === "object" && top.output !== null ? top.output : {}), sn = (typeof top.sensor === "object" && top.sensor !== null ? top.sensor : {});
|
|
966
|
-
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");
|
|
967
1005
|
return { widthMm: frameW * framePitch / 1000, heightMm: frameH * framePitch / 1000 };
|
|
968
1006
|
};
|
|
969
1007
|
if (lensOptions.prime !== undefined) {
|
|
@@ -1013,9 +1051,13 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1013
1051
|
for (const key of ["prime", "focalLengthMm", "projection", "distortion", "vignetting", "psf", "diffraction", "pupilSampling"])
|
|
1014
1052
|
if (lensOptions[key] !== undefined)
|
|
1015
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).");
|
|
1016
|
-
|
|
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();
|
|
1017
1056
|
lensKit = await import("./generic-lens-capture.js");
|
|
1018
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.");
|
|
1019
1061
|
assume("lens.catalogue", "model", { lensId: catalogue.lensId, name: catalogue.name, set: catalogue.set, kind: catalogue.kind, equivalentFocalLengthMm: catalogue.equivalentFocalLengthMm,
|
|
1020
1062
|
focalLengthMm: catalogue.focalLengthMm, cropFactor35Mm: catalogue.cropFactor35Mm, widestFNumber: catalogue.widestFNumber, narrowestFNumber: catalogue.narrowestFNumber,
|
|
1021
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/" +
|
|
@@ -1153,7 +1195,28 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1153
1195
|
defocusModel = "geometric-disc";
|
|
1154
1196
|
}
|
|
1155
1197
|
// Motion (2.0): constant camera rotation and the shutter type.
|
|
1156
|
-
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.");
|
|
1157
1220
|
const angular = { pitch: 0, yaw: 0, roll: 0 };
|
|
1158
1221
|
if (motionOptions.angularVelocityRadPerSec !== undefined) {
|
|
1159
1222
|
const a = allowKeys(motionOptions.angularVelocityRadPerSec, "motion.angularVelocityRadPerSec", ["pitch", "yaw", "roll"]);
|
|
@@ -1174,7 +1237,7 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1174
1237
|
const rollingSkewOption = motionOptions.rollingShutterSkewSeconds === undefined ? undefined : positive(motionOptions.rollingShutterSkewSeconds, "motion.rollingShutterSkewSeconds");
|
|
1175
1238
|
// Diffraction runs as an object-space convolution when every PSF tap's source is another site centre seen
|
|
1176
1239
|
// through one linear map (still camera, rectilinear, no distortion or field throughput); otherwise per tap.
|
|
1177
|
-
const diffractionObjectSpace = diffractionRadiusOption !== undefined && !rotating && !lensFieldMapping && !vignetting;
|
|
1240
|
+
const diffractionObjectSpace = diffractionRadiusOption !== undefined && !rotating && !subject && !lensFieldMapping && !vignetting;
|
|
1178
1241
|
// Output and sensor
|
|
1179
1242
|
const outputOptions = allowKeys(top.output, "output", ["width", "height", "format", "toneCurve", "toneCurveParameters", "highlightMode", "exposureCompensationStops", "whiteBalance", "jpegQuantizationStep",
|
|
1180
1243
|
"colorEncoding", "bitDepth", "gamutMapping", "demosaic", "borderHandling", "dng", "maximumPixels", "region", "metadata", "diffractionCorrection", "sharpening", "lensCorrections"]);
|
|
@@ -1267,16 +1330,25 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1267
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;
|
|
1268
1331
|
if (format !== "jpeg" && outputOptions.jpegQuantizationStep !== undefined)
|
|
1269
1332
|
fail("output.jpegQuantizationStep applies only to output.format \"jpeg\".");
|
|
1270
|
-
const sensorOptions = allowKeys(top.sensor, "sensor", ["pixelPitchMicrometers", "spectralStepNanometers", "temporalSampleCount", "noiseSeed", "antiAliasing", "pixelAperture", ...SENSOR_CHARACTERISTIC_KEYS]);
|
|
1271
|
-
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");
|
|
1272
1336
|
if (pitch < 0.5 || pitch > SIMULATE_CAPTURE_PIXEL_PITCH_MAXIMUM_MICROMETERS)
|
|
1273
1337
|
fail("sensor.pixelPitchMicrometers must be 0.5-" + SIMULATE_CAPTURE_PIXEL_PITCH_MAXIMUM_MICROMETERS + ".");
|
|
1274
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");
|
|
1275
1339
|
if (spectralStep < 1 || spectralStep > 100)
|
|
1276
1340
|
fail("sensor.spectralStepNanometers must be 1-100.");
|
|
1277
|
-
|
|
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.") :
|
|
1278
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);
|
|
1279
|
-
const
|
|
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;
|
|
1280
1352
|
const noiseSeed = integerIn(pick(sensorOptions, "noiseSeed", "sensor.noiseSeed", DEFAULTS.noiseSeed, "Fixed noise seed 1 (deterministic output)."), "sensor.noiseSeed", 0, 0xffffffff);
|
|
1281
1353
|
const antiAliasing = sensorOptions.antiAliasing === undefined ? undefined : parseAntiAliasing(sensorOptions.antiAliasing, pitch);
|
|
1282
1354
|
const antiAliasingFast = antiAliasing ? fastOverride(sensorOptions.antiAliasing, "sensor.antiAliasing") : undefined;
|
|
@@ -1309,6 +1381,8 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1309
1381
|
if (rotating && shutterKind === "rolling")
|
|
1310
1382
|
unsupported("camera rotation with motion.shutter \"rolling\"", "every row has its own instants, so the view's " +
|
|
1311
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.");
|
|
1312
1386
|
if (outputOptions.region !== undefined)
|
|
1313
1387
|
unsupported("output.region", "pass the rectangle as view.region, in display pixels.");
|
|
1314
1388
|
if (outputOptions.whiteBalance === "auto")
|
|
@@ -1351,7 +1425,8 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1351
1425
|
const fastTop = resolveFast(top.fast, fastFormatDefault(format, displayView ? "display-view" : idealReference ? "ideal-reference" : "capture"));
|
|
1352
1426
|
const fastInUse = [["lens.diffraction", diffractionRadiusOption !== undefined, diffractionFast],
|
|
1353
1427
|
["lens.catalogue", catalogue !== undefined, catalogueFast],
|
|
1354
|
-
["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]];
|
|
1355
1430
|
const fastApplied = {};
|
|
1356
1431
|
for (const [option, used] of fastInUse)
|
|
1357
1432
|
if (used)
|
|
@@ -1359,7 +1434,7 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1359
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.
|
|
1360
1435
|
const catalogueFastRequested = catalogue !== undefined && resolveFast(catalogueFast, fastTop);
|
|
1361
1436
|
let fastReport = Object.freeze({
|
|
1362
|
-
requested: fastTop || [diffractionFast, catalogueFast, antiAliasingFast, boxFast, demosaicFast].some((value) => value === true),
|
|
1437
|
+
requested: fastTop || [diffractionFast, catalogueFast, antiAliasingFast, boxFast, demosaicFast, subjectFast].some((value) => value === true),
|
|
1363
1438
|
defaultedFrom: top.fast === undefined ? "format" : "request", applied: Object.freeze({ ...fastApplied }), assurance: "engine-reference"
|
|
1364
1439
|
});
|
|
1365
1440
|
const markFast = (option) => {
|
|
@@ -1384,7 +1459,9 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1384
1459
|
if (lensCorrectionSelection) {
|
|
1385
1460
|
if (idealReference)
|
|
1386
1461
|
fail("simulateIdealReference does not take output.lensCorrections: a reference has no camera development.");
|
|
1387
|
-
|
|
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);
|
|
1388
1465
|
}
|
|
1389
1466
|
// Region (2.3): a rectangle of the whole frame. Its native sites are the region plus the demosaic reach on each side
|
|
1390
1467
|
// (clipped to the raster, where mirror borders apply exactly as in the whole render), with the origin rounded down to
|
|
@@ -1460,8 +1537,11 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1460
1537
|
const imageDistanceMm = focalLengthMm * focusDistanceM * 1000 / (focusDistanceM * 1000 - focalLengthMm);
|
|
1461
1538
|
const metresPerFrameUnit = (width * pitch / 2000) * subjectDistanceM / imageDistanceMm;
|
|
1462
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;
|
|
1463
1542
|
// Fixed-size chart (2.3): validated here, before any scene work.
|
|
1464
|
-
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);
|
|
1465
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." :
|
|
1466
1546
|
"The scene plane is perpendicular to the optical axis and the chart is sized to fill the active frame exactly.");
|
|
1467
1547
|
let chartReport;
|
|
@@ -1498,7 +1578,7 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1498
1578
|
// Radiance queries per native site: spectral subintervals × temporal midpoints × pupil rays × PSF taps.
|
|
1499
1579
|
const psfTaps = lensPsf ? Math.max(1, ...(lensPsf.configuration.psf.profile.nodes ?? [])
|
|
1500
1580
|
.map((node) => Number(node.kernel?.widthSamples) * Number(node.kernel?.heightSamples)).filter((taps) => Number.isSafeInteger(taps) && taps > 0)) : 1;
|
|
1501
|
-
|
|
1581
|
+
let queriesPerSite = temporalSampleCount * (Math.ceil((FACADE_SPECTRAL_RANGE_NANOMETERS.maximum - FACADE_SPECTRAL_RANGE_NANOMETERS.minimum) / spectralStep) + 1) *
|
|
1502
1582
|
(pupil ? pupil.radialSamples * pupil.angularSamples : 1) * psfTaps * (antiAliasing ? antiAliasing.components.length : 1) * boxSamples * boxSamples;
|
|
1503
1583
|
// Scene and illuminant
|
|
1504
1584
|
// The scene is the one required input: the facade never guesses what is being photographed.
|
|
@@ -1506,10 +1586,23 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1506
1586
|
fail("simulateCapture needs a scene, for example { preset: \"uniform\" } (an 18 % grey card) or { preset: \"slanted-edge\" }.");
|
|
1507
1587
|
const sceneOption = top.scene;
|
|
1508
1588
|
requireInputObject(sceneOption, "scene");
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
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.");
|
|
1513
1606
|
let chart, callerScene, materialLayers;
|
|
1514
1607
|
const sceneRecord = sceneOption;
|
|
1515
1608
|
if ("kind" in sceneRecord) {
|
|
@@ -1517,7 +1610,8 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1517
1610
|
// the resolved layers are kept for the display view's GPU renderer.
|
|
1518
1611
|
if (sceneRecord.kind === "material-layers")
|
|
1519
1612
|
materialLayers = resolveChartMaterialLayers({ scene: sceneOption, aspect: chartSize ? chartSize.heightM / chartSize.widthM : aspect });
|
|
1520
|
-
callerScene =
|
|
1613
|
+
callerScene = imagePlaneModule ? await imagePlaneModule.resolveImagePlaneScene(sceneOption, chartSize.heightM / chartSize.widthM, assume) :
|
|
1614
|
+
requireCallerScene(materialLayers ? materialLayers.callerScene : sceneOption);
|
|
1521
1615
|
}
|
|
1522
1616
|
else {
|
|
1523
1617
|
chart = resolveChartScene({ scene: sceneOption, aspect: chartSize ? chartSize.heightM / chartSize.widthM : aspect });
|
|
@@ -1539,21 +1633,29 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1539
1633
|
const sceneMaterials = callerScene?.kind === "material-function" ? callerScene.materials.map((m, index) => (m.kind === "neutral" ? m.reflectance :
|
|
1540
1634
|
spectrumDigestForm(callerScene.materialInputs[index], m.spectrum))) : undefined;
|
|
1541
1635
|
const sceneDescription = { facadeVersion: SIMULATE_CAPTURE_FACADE_VERSION, scene: chart ? { preset: chart.preset, parameters: chart.parameters } :
|
|
1542
|
-
|
|
1636
|
+
callerScene.digestForm ??
|
|
1637
|
+
{ kind: callerScene.kind, sceneId: callerScene.sceneId }, illuminant: illuminant.id, illuminanceLux, subjectDistanceM, framedWidthM: 2 * metresPerFrameUnit, aspect,
|
|
1543
1638
|
...(chartSize ? { chartSize: { widthM: chartSize.widthM, heightM: chartSize.heightM, surroundReflectance: chartSize.surroundReflectance } } : {}),
|
|
1544
|
-
...(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)]) } } : {}) };
|
|
1545
1641
|
if (callerScene?.kind === "material-function")
|
|
1546
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.");
|
|
1547
|
-
|
|
1643
|
+
// #581: an image-plane scene recorded its own digest assumption when it was resolved.
|
|
1644
|
+
else if (callerScene && callerScene.kind !== "image-plane")
|
|
1548
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.");
|
|
1549
1646
|
const sourceSha256 = await sha256Hex(JSON.stringify(sceneDescription, (_key, value) => (typeof value === "object" && value !== null && "evaluate" in value ? "caller-spectrum" : value)));
|
|
1550
1647
|
checkAbort();
|
|
1551
|
-
const buildProvider = (sceneIlluminant, scale) => buildPlanarSceneRadianceProvider({ scene: chart ?? callerScene,
|
|
1648
|
+
const buildProvider = (sceneIlluminant, scale, light) => buildPlanarSceneRadianceProvider({ scene: chart ?? callerScene,
|
|
1552
1649
|
sceneId: IDS.scene, providerProfileId: IDS.provider, illuminant: sceneIlluminant, irradianceScale: scale, geometry: { distanceM: subjectDistanceM, metresPerFrameUnit,
|
|
1553
1650
|
...(chartSize ? { chart: { widthM: chartSize.widthM, heightM: chartSize.heightM, surroundReflectance: chartSize.surroundReflectance } } : {}) }, evidence: FACADE_EVIDENCE,
|
|
1554
|
-
limitations: Object.freeze(["Planar Lambertian scene under uniform illumination; no shadows, inter-reflection, gloss or fluorescence."]),
|
|
1555
|
-
...(catalogue?.kind === "fisheye" ? { zeroBeyondHorizon: true } : {}) });
|
|
1556
|
-
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;
|
|
1557
1659
|
// 2.6: a chart preset's evaluator is engine-built end to end (its results: the schema's exact keys, constant fields,
|
|
1558
1660
|
// the request's identifiers, deep-frozen FACADE_EVIDENCE, UNCERTAINTY and limitations, and an engine-computed
|
|
1559
1661
|
// radiance), so the native task may skip re-parsing the fixed parts. Caller scenes keep the full validation.
|
|
@@ -1576,7 +1678,9 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1576
1678
|
const mode = pick(exposureOptions, "mode", "exposure.mode", "manual", "Manual exposure.");
|
|
1577
1679
|
if (!["manual", "aperture-priority", "shutter-priority", "program"].includes(mode))
|
|
1578
1680
|
fail("Unknown exposure mode.");
|
|
1579
|
-
|
|
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.");
|
|
1580
1684
|
// #516: "auto" resolves the ISO with the mode's Auto ISO resolver; NaN marks the ISO as not yet known.
|
|
1581
1685
|
const automaticIso = isoOption === "auto";
|
|
1582
1686
|
if (typeof isoOption === "string" && !automaticIso)
|
|
@@ -1597,7 +1701,9 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1597
1701
|
const clamp = (n) => lensRange ? Math.min(lensRange.narrowestFNumber, Math.max(lensRange.widestFNumber, n)) : n, referenceAperture = clamp(DEFAULTS.fNumber);
|
|
1598
1702
|
// (N0/8)² is exactly 1 at f/8, so f/8 keeps 1/125 s exactly.
|
|
1599
1703
|
const sameShutter = DEFAULTS.shutterSeconds * (referenceAperture / DEFAULTS.fNumber) ** 2;
|
|
1600
|
-
|
|
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 };
|
|
1601
1707
|
const referenceLuminance = sosReferenceLuminance(reference.aperture, reference.shutterSeconds, reference.iso);
|
|
1602
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²)" +
|
|
1603
1709
|
(referenceAperture === DEFAULTS.fNumber ? "." : "; the lens excludes f/8: " + (reference.shutterSeconds > sameShutter ? "the shortest shutter" : "the same N²/t") + " at f/" + referenceAperture + "."));
|
|
@@ -1620,6 +1726,8 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1620
1726
|
const cardLuminance = GREY_CARD_REFLECTANCE * illuminanceLux / Math.PI;
|
|
1621
1727
|
for (const sample of zoneSamples)
|
|
1622
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.");
|
|
1623
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.");
|
|
1624
1732
|
}
|
|
1625
1733
|
const meterResult = meterRelativeExposure({ profile: meterProfile, sampleSet: { measurementId: "facade-meter", sceneStateId: IDS.sceneState,
|
|
@@ -1636,7 +1744,9 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1636
1744
|
fail("lens.fNumber " + fNumber + " is outside the " + lensRange.name + " range f/" + lensRange.widestFNumber + "–f/" + lensRange.narrowestFNumber + ".");
|
|
1637
1745
|
};
|
|
1638
1746
|
if (mode === "manual" || mode === "aperture-priority") {
|
|
1639
|
-
|
|
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");
|
|
1640
1750
|
checkFNumber();
|
|
1641
1751
|
}
|
|
1642
1752
|
else {
|
|
@@ -1747,6 +1857,19 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1747
1857
|
fNumber = resolution.resolvedSettings.aperture ?? fNumber;
|
|
1748
1858
|
shutterSeconds = resolution.resolvedSettings.shutterSeconds ?? shutterSeconds;
|
|
1749
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
|
+
}
|
|
1750
1873
|
if (!Number.isFinite(resolvedIso) || resolvedIso <= 0)
|
|
1751
1874
|
fail("Exposure could not be resolved: the resolved ISO is not a positive finite number.");
|
|
1752
1875
|
const isoReport = automaticIso ? { isoControl: "automatic", autoIso: autoIsoReport(resolution, resolverCapabilities.iso, autoIsoShutterPolicy, autoIsoLimits, automaticSlowestShutter, autoIsoOptions.isoSteps === undefined ? undefined : autoIsoIsoSteps) } : { isoControl: "manual" };
|
|
@@ -1755,7 +1878,7 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1755
1878
|
const exposureGreyCardDeviationStops = callerScene?.kind === "radiance-function" ? null : greyCardDeviationStops(fNumber, shutterSeconds, resolvedIso, illuminanceLux);
|
|
1756
1879
|
// #549: the catalogue lens's character at the resolved f-number and focus: focus scatter, the defocus disc folded into its PSF, then
|
|
1757
1880
|
// its distortion and vignetting profiles over the field the PSF and the queries reach, and its PSF profile.
|
|
1758
|
-
let catalogueTaps, catalogueDefocusApplied = false, catalogueKernelRadius = 0;
|
|
1881
|
+
let catalogueTaps, catalogueDefocusApplied = false, catalogueKernelRadius = 0, catalogueCornerStops = 0;
|
|
1759
1882
|
if (catalogue && lensKit) {
|
|
1760
1883
|
const { GENERIC_LENS_EVIDENCE, genericLensDistortionProfile, genericLensVignettingProfile, resolveGenericLens, simulateGenericLensFocusError, createGenericLensPsfProfile, genericLensPsfKernelRadius, GENERIC_LENS_PSF_MAXIMUM_COMBINED_RADIUS_SITES, genericLensBokehKernel } = lensKit;
|
|
1761
1884
|
checkFNumber();
|
|
@@ -1769,6 +1892,8 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1769
1892
|
focusDistanceM = sample.focusDistanceM;
|
|
1770
1893
|
}
|
|
1771
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;
|
|
1772
1897
|
let extraKernel;
|
|
1773
1898
|
if (lensOptions.defocus !== undefined) {
|
|
1774
1899
|
const disc = resolveDefocusDisc({ focalLengthMm, aperture: fNumber, focusDistanceM, subjectDistanceM, pitchMicrometers: pitch });
|
|
@@ -1854,17 +1979,21 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1854
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
|
|
1855
1980
|
// site lattice (defocus-image-space-provider.ts). #563: the display view reads the same geometry in any focus (display-view-lens-signal.ts).
|
|
1856
1981
|
const imageSpaceOptions = [...(lensFieldMapping ? [lensOptions.distortion !== undefined ? (lensOptions.projection !== undefined ? "lens.projection, lens.distortion" : "lens.distortion") : "lens.projection"] : []),
|
|
1857
|
-
...(vignetting ? ["lens.vignetting"] : []), ...(rotating ? ["camera rotation"] : [])];
|
|
1858
|
-
|
|
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,
|
|
1859
1990
|
angularVelocityRadPerSec: angular, ...(lensFieldMapping ? { lensMapping: prepareEnvironmentLensFieldMapping(lensFieldMapping) } : {}), ...(vignetting ? { vignetting } : {}) }) : undefined;
|
|
1860
1991
|
const defocusGeometry = defocusModel !== undefined ? lensGeometry : undefined;
|
|
1861
|
-
// #565: the in-focus PSF quadrature reads each tap's source on this lattice; with rotation under a rolling shutter (each row has its own
|
|
1862
|
-
// instants, so a band per instant, about 1.2× the time) that is lens.diffraction's exact method, and each tap's own point its fast one.
|
|
1863
|
-
const rollingFastTaps = !displayView && rotating && shutterKind === "rolling" && diffractionRadiusOption !== undefined && lensGeometry !== undefined && resolveFast(diffractionFast, fastTop);
|
|
1864
1992
|
if (rollingFastTaps) {
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
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.");
|
|
1868
1997
|
}
|
|
1869
1998
|
const latticeTaps = lensGeometry && !rollingFastTaps;
|
|
1870
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.
|
|
@@ -1921,8 +2050,8 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1921
2050
|
...(defocusBokeh ? { bokehOptions: defocusBokeh, bokeh: bokehSummary } : {}),
|
|
1922
2051
|
...(defocusGeometry ? { imageSpace: (disc) => {
|
|
1923
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.
|
|
1924
|
-
if (rotating && shutterKind === "rolling")
|
|
1925
|
-
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 " +
|
|
1926
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 " +
|
|
1927
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.");
|
|
1928
2057
|
const temporalMidpointsSeconds = rotating ? Array.from({ length: temporalSampleCount }, (_, i) => (i + 0.5) * (shutterSeconds / temporalSampleCount)) : [];
|
|
@@ -1940,7 +2069,8 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1940
2069
|
// output, independent of pixel size, while the collected charge is proportional to the sensitive-aperture area
|
|
1941
2070
|
// (EMVA 1288 Release 4.0, Eq. 3). The tier's signal chain is anchored to the 6 µm aperture; at another area the
|
|
1942
2071
|
// charge-domain readout scales with the area so the same H gives the same code. At 6 µm nothing is changed.
|
|
1943
|
-
|
|
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;
|
|
1944
2074
|
// #555: a full well replaces the area-scaled base-ISO saturation; the charge-domain transfer scales by the same rule.
|
|
1945
2075
|
const fullWell = characteristics?.fullWell, fullWellElectrons = fullWell && (fullWell.link === "none" ? fullWell.electrons : fullWell.electrons * (pitch / (fullWell.referencePixelPitchMicrometers ?? 6)) ** 2);
|
|
1946
2076
|
if (fullWellElectrons !== undefined && !(fullWellElectrons >= 100 && fullWellElectrons <= 1e10))
|
|
@@ -1958,7 +2088,7 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1958
2088
|
const capacityElectrons = Math.max(...capacityRegimes.map((r) => r.preAdcSaturationElectronEquivalent.value), baseRegime.preAdcSaturationElectronEquivalent.value);
|
|
1959
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.");
|
|
1960
2090
|
if (areaScale !== 1 && !fullWell) {
|
|
1961
|
-
assume("sensor.isoAreaNormalization", "model", { referenceApertureAreaSquareMicrometers:
|
|
2091
|
+
assume("sensor.isoAreaNormalization", "model", { referenceApertureAreaSquareMicrometers: referenceArea,
|
|
1962
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, " +
|
|
1963
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 " +
|
|
1964
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 " +
|
|
@@ -1969,7 +2099,7 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
1969
2099
|
if (characteristics) {
|
|
1970
2100
|
const density = capacityElectrons / (pitch * pitch);
|
|
1971
2101
|
assume("sensor.characteristics", "model", { ...Object.fromEntries(SENSOR_CHARACTERISTIC_KEYS.filter((k) => sensorOptions[k] !== undefined).map((k) => [k, sensorOptions[k]])),
|
|
1972
|
-
chargeScale, sensitivityOffsetStops: Math.log2(
|
|
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); " +
|
|
1973
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: " +
|
|
1974
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. " +
|
|
1975
2105
|
"A bit depth scales the black and saturation codes by 2^Δbits." + (density < 200 || density > 1e4 ? " Unusual for the pitch: " + density +
|
|
@@ -2058,8 +2188,11 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2058
2188
|
fail("output.region cannot be combined with output.whiteBalance \"auto\": grey-world statistics of a region differ from the whole frame's.");
|
|
2059
2189
|
needsAuto = true;
|
|
2060
2190
|
}
|
|
2061
|
-
else if (whiteBalanceOption === "scene-illuminant")
|
|
2191
|
+
else if (whiteBalanceOption === "scene-illuminant") {
|
|
2062
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
|
+
}
|
|
2063
2196
|
else if (typeof whiteBalanceOption === "object" && whiteBalanceOption !== null && "kelvin" in whiteBalanceOption) {
|
|
2064
2197
|
const wb = allowKeys(whiteBalanceOption, "output.whiteBalance", ["kelvin", "tint"]);
|
|
2065
2198
|
const intent = createColorTemperatureWhiteBalanceIntent({ intentId: "facade-wb-cct", colorTemperatureKelvin: positive(wb.kelvin, "output.whiteBalance.kelvin"),
|
|
@@ -2101,7 +2234,13 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2101
2234
|
const exposureWindow = rollingSchedule ? createRollingShutterCaptureExposureSchedule(rollingSchedule) :
|
|
2102
2235
|
{ shutterMechanism: "electronic", nominalExposureDurationSeconds: { value: shutterSeconds, unit: "s", evidence },
|
|
2103
2236
|
opening: { kind: "simultaneous" }, closing: { kind: "simultaneous" } };
|
|
2104
|
-
|
|
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
|
+
};
|
|
2105
2244
|
const buildRawInputFor = (intent, rasterGeometry, modeProfile, binding, sites) => ({
|
|
2106
2245
|
exposure: { captureId: IDS.capture, sceneStateId: IDS.sceneState, sceneTimeSeconds: 0, geometry: rasterGeometry,
|
|
2107
2246
|
exposure: { focalLengthMm, aperture: fNumber, shutterSeconds, iso: resolvedIso }, focus: { kind: "finite", distanceM: focusDistanceM },
|
|
@@ -2120,7 +2259,7 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2120
2259
|
const eqeSamples = (channel) => {
|
|
2121
2260
|
const out = [];
|
|
2122
2261
|
for (let nm = range.minimum; nm <= range.maximum + 1e-9; nm += eqeSpacing)
|
|
2123
|
-
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 });
|
|
2124
2263
|
return out;
|
|
2125
2264
|
};
|
|
2126
2265
|
const operatingConditions = { temperatureC: 25, incidenceAngleDegreesFromNormal: 0, polarization: "unpolarized" };
|
|
@@ -2341,10 +2480,10 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2341
2480
|
let diffractionMs = 0, defocusMs = 0, demosaicMs, encodeMs;
|
|
2342
2481
|
const diffractedProvider = (window) =>
|
|
2343
2482
|
// #548: the disc (or diffraction ⊛ disc) in image space, on the sensor's site lattice, through the mapping, field throughput and rotation.
|
|
2344
|
-
defocusSiteKernels && defocusGeometry && defocusImageSpace ? createDefocusImageSpaceProvider({ base:
|
|
2483
|
+
defocusSiteKernels && defocusGeometry && defocusImageSpace ? createDefocusImageSpaceProvider({ base: sceneProvider, geometry: defocusGeometry, siteKernels: defocusSiteKernels.at,
|
|
2345
2484
|
...(defocusSiteKernels.large ? { largeKernel: defocusSiteKernels.large } : {}),
|
|
2346
2485
|
wavelengthsNm: defocusSiteKernels.wavelengthsNm, kernelRadius: defocusSiteKernels.radius, nativeWidth, nativeHeight, sitePitchMicrometers: pitch, distanceM: subjectDistanceM,
|
|
2347
|
-
metresPerFrameUnit, queryOffsetsSites, timesPerBand: temporalSampleCount, onConvolution: (ms) => { defocusMs += ms; }, ...(window ? { window } : {}),
|
|
2486
|
+
metresPerFrameUnit, queryOffsetsSites, timesPerBand: temporalSampleCount, onConvolution: (ms) => { defocusMs += ms; }, ...(window ? { window } : {}), ...(subject ? { timeDependentScene: true } : {}),
|
|
2348
2487
|
...(bokehZones ? { fieldZones: bokehZones.zones } : {}) }) :
|
|
2349
2488
|
defocusSiteKernels ? createDiffractedPlanarProvider({ base: provider, siteKernels: defocusSiteKernels.at, ...(defocusSiteKernels.large ? { largeKernel: defocusSiteKernels.large } : {}),
|
|
2350
2489
|
wavelengthsNm: defocusSiteKernels.wavelengthsNm, kernelRadius: defocusSiteKernels.radius, nativeWidth, nativeHeight, outputWidth: width,
|
|
@@ -2358,11 +2497,12 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2358
2497
|
...(window ? { window } : {}),
|
|
2359
2498
|
// The environment queries each site at each aperture node minus each split offset (sites, +x right, +y down).
|
|
2360
2499
|
...(antiAliasing || boxSamples > 1 ? { queryOffsetsSites } : {}) }) :
|
|
2361
|
-
// #565: 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.
|
|
2362
|
-
diffraction && latticeTaps ? createDefocusImageSpaceProvider({ base:
|
|
2363
|
-
kernelRadius: 0, wavelengthsNm: diffraction.profile.axes.wavelengthNm, nativeWidth, nativeHeight, sitePitchMicrometers: pitch, distanceM: subjectDistanceM, metresPerFrameUnit,
|
|
2364
|
-
queryOffsetsSites: [-1, 0, 1].flatMap((k) => queryOffsetsSites.map((o) => ({ x: o.x +
|
|
2365
|
-
timesPerBand: temporalSampleCount, ...(window ? { window } : {})
|
|
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;
|
|
2366
2506
|
// Built on first use: a capture that takes the tabulated path (#394 step 2) never queries it, and its kernels cost a setup.
|
|
2367
2507
|
let rawProviderBuilt;
|
|
2368
2508
|
const rawProvider = () => (rawProviderBuilt ??= diffractedProvider(regionNative));
|
|
@@ -2451,7 +2591,7 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2451
2591
|
if (displayViewLensPsf)
|
|
2452
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\").");
|
|
2453
2593
|
const lensSignal = viewLensKit ? viewLensKit.prepareDisplayViewLensSignal({ geometry: lensGeometry,
|
|
2454
|
-
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,
|
|
2455
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 +
|
|
2456
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;
|
|
2457
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;
|
|
@@ -2459,11 +2599,13 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2459
2599
|
let greyElectrons = fastTop ? undefined : 0;
|
|
2460
2600
|
if (!idealReference && greyElectrons !== undefined)
|
|
2461
2601
|
for (const [nm, a] of axisChannelResponse(displayView ? "simulateDisplayView" : "simulateCapture").electronsPerRadiance.green)
|
|
2462
|
-
greyElectrons += a * GREY_CARD_REFLECTANCE *
|
|
2463
|
-
|
|
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,
|
|
2464
2605
|
readoutSeconds: characteristics?.readoutSeconds ?? body.rollingReadoutTiming?.profile.spatialSamplingSkewSeconds?.value ?? null }, fixedPattern);
|
|
2465
2606
|
if (displayView)
|
|
2466
|
-
return await renderView({ sensor, viewFast: { ahd: viewAhdFast, uniformSampling: fastTop }, 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 });
|
|
2467
2609
|
// Ideal reference (2.3): the scene without camera effects, at the capture's own exposure, white balance and encoding.
|
|
2468
2610
|
if (idealReference) {
|
|
2469
2611
|
const referenceStarted = clockMs();
|
|
@@ -2472,19 +2614,20 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2472
2614
|
if (needsAuto)
|
|
2473
2615
|
fail("simulateIdealReference cannot reproduce output.whiteBalance \"auto\": the capture measures it from its noisy RAW. Use \"scene-illuminant\" or a fixed white balance.");
|
|
2474
2616
|
const { electronsPerRadiance, wavelengths, relativePerElectron } = axisChannelResponse("simulateIdealReference");
|
|
2475
|
-
// 3.
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
};
|
|
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; });
|
|
2481
2622
|
const materialCount = chart ? chart.materials.length : callerScene?.kind === "material-function" ? callerScene.materials.length : 0;
|
|
2482
|
-
const
|
|
2623
|
+
const spectra = provider.chartMaterialAt ? Array.from({ length: materialCount + (chartSize ? 1 : 0) }, (_, m) => wavelengths.map((nm) => provider.materialRadiance(m, nm))) : undefined;
|
|
2483
2624
|
// 4. Pixels: the native sites of the output (or region) pixels, each centre-sampled or box-integrated like the capture.
|
|
2484
2625
|
const x0 = (region ? region.x : 0) + margin, y0 = (region ? region.y : 0) + margin, n = boxSamples, unit = 2 / width;
|
|
2485
2626
|
const referenceIntent = intentOf(whiteBalanceState), referenceGains = referenceIntent ? [referenceIntent.channelGains.red, referenceIntent.channelGains.green, referenceIntent.channelGains.blue] : null;
|
|
2486
2627
|
const development = prepareSensorColorDevelopment(CAMERA_TO_XYZ, referenceGains, highlightMode);
|
|
2487
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 = [];
|
|
2488
2631
|
for (let py = 0; py < outputHeight; py++) {
|
|
2489
2632
|
for (let px = 0; px < outputWidth; px++) {
|
|
2490
2633
|
sum[0] = 0;
|
|
@@ -2492,8 +2635,11 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2492
2635
|
sum[2] = 0;
|
|
2493
2636
|
for (let l = 0; l < n; l++)
|
|
2494
2637
|
for (let k = 0; k < n; k++) {
|
|
2495
|
-
const u = (x0 + px + (k + 0.5) / n - nativeWidth / 2) * unit, v = -(y0 + py + (l + 0.5) / n - nativeHeight / 2) * unit;
|
|
2496
|
-
|
|
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
|
+
}
|
|
2497
2643
|
sum[0] += s[0];
|
|
2498
2644
|
sum[1] += s[1];
|
|
2499
2645
|
sum[2] += s[2];
|
|
@@ -2528,7 +2674,7 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2528
2674
|
const referenceMs = clockMs() - referenceStarted;
|
|
2529
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.");
|
|
2530
2676
|
assume("reference.excludes", "model", ["lens PSF, diffraction, defocus and pupil sampling", "projection and distortion (ideal rectilinear mapping)", "vignetting", "anti-aliasing filter",
|
|
2531
|
-
"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.");
|
|
2532
2678
|
assume("reference.exposure", "model", { convention: "exposure-matched", fNumber, shutterSeconds, iso: resolvedIso, illuminanceLux,
|
|
2533
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), " +
|
|
2534
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. " +
|
|
@@ -2538,7 +2684,7 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2538
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,
|
|
2539
2685
|
image: { width: outputWidth, height: outputHeight, format, samples, ...(png ? { png, colorEncoding: colorEncoding } : {}) },
|
|
2540
2686
|
exposure: { mode, settings: { fNumber, shutterSeconds, iso: resolvedIso }, ev100: Math.log2(fNumber ** 2 / shutterSeconds), ...isoReport },
|
|
2541
|
-
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 ?? {},
|
|
2542
2688
|
illuminant: { id: illuminant.id, description: illuminant.description, illuminanceLux }, distanceM: subjectDistanceM,
|
|
2543
2689
|
framedWidthM: 2 * metresPerFrameUnit, framedHeightM: 2 * metresPerFrameUnit * aspect, ...(chartReport ? { chart: chartReport } : {}), sourceSha256 },
|
|
2544
2690
|
...(region && regionNative ? { region: { ...region, frameWidth: width, frameHeight: height } } : {}),
|
|
@@ -2579,7 +2725,7 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2579
2725
|
evaluateRadiance: rawProvider().evaluateRadiance, ...(pupil ? { evaluateApertureRadiance: provider.evaluateApertureRadiance } : {}), yieldControl });
|
|
2580
2726
|
// #394 step 2: an engine-built planar scene (chart preset or caller material scene; still, rectilinear, no distortion, vignetting,
|
|
2581
2727
|
// pupil rays or caller PSF) takes its expected electrons from the tabulated signal, verified per CFA phase against the chain.
|
|
2582
|
-
const tabulatedScene = chart !== undefined || callerScene?.kind === "material-function";
|
|
2728
|
+
const tabulatedScene = chart !== undefined || callerScene?.kind === "material-function" || callerScene?.kind === "image-plane";
|
|
2583
2729
|
// #391: a DNG from region parts takes the whole frame's RAW from the parts instead of rendering it.
|
|
2584
2730
|
// #571: simulateCaptureFromParts takes the RAW from the parts in the same way, then develops it as a capture does.
|
|
2585
2731
|
const dngParts = run === "dng-from-parts" || run === "develop-from-parts" ? viewOption : undefined, partsCaller = run === "dng-from-parts" ? "serializeSimulatedCaptureDngFromParts" : "simulateCaptureFromParts";
|
|
@@ -2590,13 +2736,15 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2590
2736
|
// still camera, site-centre queries and a chart or material scene; verified per CFA phase against the exact chain within its tolerance.
|
|
2591
2737
|
let catalogueFastRun;
|
|
2592
2738
|
const catalogueFastEligible = catalogueFastRequested && catalogueTaps !== undefined && lensKit !== undefined && lensPsf !== undefined && dngParts === undefined && tabulatedScene &&
|
|
2593
|
-
!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;
|
|
2594
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).
|
|
2595
2742
|
const tabulatedTables = () => {
|
|
2596
2743
|
const { electronsPerRadiance, wavelengths } = axisChannelResponse("simulateCapture");
|
|
2597
|
-
|
|
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;
|
|
2598
2746
|
return { wavelengths, perNode: CHANNELS.map((c) => Float64Array.from(wavelengths, (nm) => electronsPerRadiance[c].get(nm) ?? 0)),
|
|
2599
|
-
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,
|
|
2600
2748
|
list: [...sceneMaterials.map((m, index) => (m.kind === "neutral" ? { neutral: m.reflectance } : { radiance: (nm) => materialRadiance(index, nm) })),
|
|
2601
2749
|
...(chartSize ? [{ neutral: chartSize.surroundReflectance }] : [])] } };
|
|
2602
2750
|
};
|
|
@@ -2616,20 +2764,21 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2616
2764
|
catalogueFastRun = { ...signal.grid, verificationRelativeDifference: tabulated.verificationRelativeDifference };
|
|
2617
2765
|
}
|
|
2618
2766
|
// #553: a cat's eye varies the kernel over the field, which the expected-signal tables (one kernel per node) do not take.
|
|
2619
|
-
else if (dngParts === undefined && tabulatedScene && !pupil && !lensFieldMapping && !vignetting && !lensPsf && !rotating && !bokehZones && (!diffraction || diffractionObjectSpace) &&
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
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 });
|
|
2624
2776
|
// #492: with lens.defocus the table convolves the lattice radiance with each node's defocus (or combined) kernel, on the
|
|
2625
2777
|
// lattice formula, exactly as the object-space provider does.
|
|
2626
2778
|
let signalInput = builtSignalInput;
|
|
2627
2779
|
if (defocusSiteKernels?.large) {
|
|
2628
2780
|
// #564: the in-focus signal on the lattice formula (diffraction kernels, or a unit tap per node), then the disc by runs.
|
|
2629
|
-
signalInput = buildExpectedSignalInput({
|
|
2630
|
-
diffraction: diffraction ? { radius: diffraction.kernelRadiusSamples, psf: diffractionPsf.configuration.psf, profileWavelengths: diffraction.profile.axes.wavelengthNm } : undefined,
|
|
2631
|
-
largeDefocus: defocusSiteKernels.large.runs, materials: tabulatedMaterials,
|
|
2632
|
-
radianceAt: provider.radianceAt, imagingArea, focalLengthMm, focusDistanceM, subjectDistanceM, metresPerFrameUnit, caller: "simulateCapture" }).signalInput;
|
|
2781
|
+
signalInput = buildExpectedSignalInput({ ...shared, diffraction: diffractionKernels, largeDefocus: defocusSiteKernels.large.runs }).signalInput;
|
|
2633
2782
|
}
|
|
2634
2783
|
else if (defocusSiteKernels) {
|
|
2635
2784
|
const lattice = { ...builtSignalInput };
|
|
@@ -2726,6 +2875,15 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2726
2875
|
evidence, limitations: ["Diagonal camera→XYZ matrix of the idealized colorimetric (Luther–Ives) sensor; exact up to spectral quadrature."] };
|
|
2727
2876
|
const phaseProfiles = builtInDemosaic ? builtInDemosaic.phaseProfiles : bilinearPhaseProfiles();
|
|
2728
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
|
+
};
|
|
2729
2887
|
// DNG (2.0): the packed native RAW itself, before development takes ownership of it.
|
|
2730
2888
|
let dng;
|
|
2731
2889
|
if (format === "dng") {
|
|
@@ -2734,11 +2892,11 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2734
2892
|
return [channel, { conversionGainElectronsPerCode: resolved.regime.systemConversionGainElectronsPerCode.value / (resolved.regime.adc.digitalGain ?? 1),
|
|
2735
2893
|
readNoiseRmsElectrons: resolved.combinedReadNoiseRmsElectrons }];
|
|
2736
2894
|
}));
|
|
2737
|
-
const dngStarted = clockMs(), opcodes = dngOpcodeSelection && lensKit.createDngLensCorrectionOpcodes(lensKit.resolveGenericLens({ lensId: catalogue.lensId, activeImagingArea: catalogueFrame, fNumber }), dngOpcodeSelection, width, height, pitch / 1000,
|
|
2895
|
+
const dngStarted = clockMs(), opcodes = dngOpcodeSelection && lensKit.createDngLensCorrectionOpcodes(lensKit.resolveGenericLens({ lensId: catalogue.lensId, activeImagingArea: catalogueFrame, fNumber }), dngOpcodeSelection, width, height, pitch / 1000, captureChannelDispersion());
|
|
2738
2896
|
dng = await serializeNativeRawDng({ raw: rawOutput.raw, colorProfile, noise, ...(dngCapturedAtUtc === undefined ? {} : { capturedAtUtc: dngCapturedAtUtc }),
|
|
2739
2897
|
...(textMetadata ? { textMetadata } : {}), ...(opcodes ? { opcodeList3: opcodes.opcodeList3 } : {}) });
|
|
2740
2898
|
if (opcodes)
|
|
2741
|
-
assume("output.dng.lensCorrectionOpcodes", "model", { tag: 51022, 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.");
|
|
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.");
|
|
2742
2900
|
encodeMs = clockMs() - dngStarted;
|
|
2743
2901
|
assumeTextMetadata(TEXT_METADATA_LAYOUT.dng);
|
|
2744
2902
|
checkAbort();
|
|
@@ -2781,10 +2939,12 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2781
2939
|
// #570: the lens corrections after the diffraction correction (which acts on the sensor's own lattice), before the tone curve.
|
|
2782
2940
|
let cornerStops = null;
|
|
2783
2941
|
if (lensCorrection) {
|
|
2942
|
+
const channelDispersion = captureChannelDispersion();
|
|
2943
|
+
lensCorrection = lensKit.prepareDevelopedLensCorrection(catalogue, lensCorrectionSelection, width, height, pitch / 1000, channelDispersion);
|
|
2784
2944
|
const s = lensCorrectionSelection, c = lensCorrection, k = (channel) => c.scales[channel];
|
|
2785
2945
|
if (s.vignetting)
|
|
2786
|
-
cornerStops =
|
|
2787
|
-
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) },
|
|
2788
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 " +
|
|
2789
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). " +
|
|
2790
2950
|
"Corners lose resolution, corner noise rises with the gain, axial colour stays. The DNG is not corrected.");
|
|
@@ -2932,7 +3092,7 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2932
3092
|
...(distortion ? ["lens.distortion"] : []), ...(vignetting ? ["lens.vignetting"] : []), ...(lensPsf ? ["lens.psf"] : []),
|
|
2933
3093
|
...(catalogue ? ["lens.catalogue"] : []),
|
|
2934
3094
|
...(diffraction ? ["lens.diffraction"] : []), ...(defocusSiteKernels || catalogueDefocusApplied ? ["lens.defocus"] : []), ...(antiAliasing ? ["sensor.antiAliasing"] : []), ...(boxAperture ? ["sensor.pixelAperture.box"] : []),
|
|
2935
|
-
...(temporalSampleCount > 1 ? ["sensor.temporalSampleCount"] : []), ...(rotating ? ["motion.rotation"] : []),
|
|
3095
|
+
...(temporalSampleCount > 1 ? ["sensor.temporalSampleCount"] : []), ...(rotating ? ["motion.rotation"] : []), ...(subject ? ["motion.subject"] : []),
|
|
2936
3096
|
...(rollingSchedule ? ["motion.shutter.rolling"] : []), ...(mirrorBorder ? ["output.borderHandling.mirror"] : []), ...(region ? ["output.region"] : []),
|
|
2937
3097
|
...(needsAuto ? ["output.whiteBalance.auto"] : []), ...(ahd ? ["output.demosaic.ahd"] : []),
|
|
2938
3098
|
...(highlightMode === "reconstruct" ? ["output.highlightMode.reconstruct"] : []), ...(wideGamut ? ["output.colorEncoding.wide-gamut"] : []),
|
|
@@ -2952,7 +3112,7 @@ export async function runCapture(options, run, viewOption, renderView) {
|
|
|
2952
3112
|
exposure: { mode, settings: { fNumber, shutterSeconds, iso: resolvedIso }, ev100: Math.log2(fNumber ** 2 / shutterSeconds), ...isoReport,
|
|
2953
3113
|
metering, meteredSceneLuminanceCdPerSquareMeter: meteredLuminance, standardExposureDeviationStops: exposureDeviationStops,
|
|
2954
3114
|
standardExposureIlluminanceLux: exposureIlluminanceLux, greyCardDeviationStops: exposureGreyCardDeviationStops, resolution },
|
|
2955
|
-
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 ?? {},
|
|
2956
3116
|
illuminant: { id: illuminant.id, description: illuminant.description, illuminanceLux }, distanceM: subjectDistanceM,
|
|
2957
3117
|
framedWidthM: 2 * metresPerFrameUnit, framedHeightM: 2 * metresPerFrameUnit * aspect, ...(chartReport ? { chart: chartReport } : {}), sourceSha256 },
|
|
2958
3118
|
diagnostics: { providerEvaluationCount, highlight: developed.highlight, rendering: developed.diagnostics, ...(gamutMappingDiagnostics ? { gamutMapping: gamutMappingDiagnostics } : {}),
|