@photivra/engine 2.4.1 → 2.6.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 +53 -1
- package/CITATION.cff +1 -1
- package/README.md +6 -6
- package/SECURITY.md +1 -1
- package/THIRD_PARTY.md +1 -0
- package/dist/capture/native-environment-lean.d.ts +1 -1
- package/dist/capture/native-environment-lean.js +17 -3
- package/dist/capture/native-environment-lean.js.map +1 -1
- package/dist/capture/native-environment-raw.d.ts +0 -4
- package/dist/capture/native-environment-raw.js +6 -2
- package/dist/capture/native-environment-raw.js.map +1 -1
- package/dist/color/illuminants.d.ts +15 -0
- package/dist/color/illuminants.js +81 -24
- package/dist/color/illuminants.js.map +1 -1
- package/dist/color/srgb-transfer.d.ts +6 -0
- package/dist/color/srgb-transfer.js +9 -0
- package/dist/color/srgb-transfer.js.map +1 -1
- package/dist/color/white-balance.d.ts +6 -0
- package/dist/color/white-balance.js +15 -2
- package/dist/color/white-balance.js.map +1 -1
- package/dist/core/internal-radiance-evaluator.d.ts +6 -0
- package/dist/core/internal-radiance-evaluator.js +33 -0
- package/dist/core/internal-radiance-evaluator.js.map +1 -0
- package/dist/core/portable-math.d.ts +33 -0
- package/dist/core/portable-math.js +342 -0
- package/dist/core/portable-math.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/facade/chart-scenes.d.ts +9 -0
- package/dist/facade/chart-scenes.js +17 -4
- package/dist/facade/chart-scenes.js.map +1 -1
- package/dist/facade/diffraction-provider.d.ts +10 -0
- package/dist/facade/diffraction-provider.js +38 -17
- package/dist/facade/diffraction-provider.js.map +1 -1
- package/dist/facade/display-view.d.ts +208 -0
- package/dist/facade/display-view.js +1461 -0
- package/dist/facade/display-view.js.map +1 -0
- package/dist/facade/scene-radiance.d.ts +7 -0
- package/dist/facade/scene-radiance.js +18 -5
- package/dist/facade/scene-radiance.js.map +1 -1
- package/dist/facade/simulate-capture.d.ts +229 -5
- package/dist/facade/simulate-capture.js +119 -35
- package/dist/facade/simulate-capture.js.map +1 -1
- package/dist/facade/simulate-display-view.d.ts +21 -0
- package/dist/facade/simulate-display-view.js +168 -0
- package/dist/facade/simulate-display-view.js.map +1 -0
- package/dist/facade/subject-framing.d.ts +78 -0
- package/dist/facade/subject-framing.js +69 -20
- package/dist/facade/subject-framing.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/optics/diffraction-psf-profile.d.ts +59 -3
- package/dist/optics/diffraction-psf-profile.js +147 -5
- package/dist/optics/diffraction-psf-profile.js.map +1 -1
- package/dist/optics/irradiance-convolution.js +1 -1
- package/dist/optics/irradiance-convolution.js.map +1 -1
- package/dist/optics/scene-to-sensor-irradiance.d.ts +7 -0
- package/dist/optics/scene-to-sensor-irradiance.js +10 -5
- package/dist/optics/scene-to-sensor-irradiance.js.map +1 -1
- package/dist/output/print-plan.d.ts +8 -1
- package/dist/output/print-plan.js +8 -1
- package/dist/output/print-plan.js.map +1 -1
- package/dist/schema/scene-radiance.d.ts +16 -0
- package/dist/schema/scene-radiance.js +25 -4
- package/dist/schema/scene-radiance.js.map +1 -1
- package/dist/sensor/raw-readout.d.ts +12 -0
- package/dist/sensor/raw-readout.js +131 -9
- package/dist/sensor/raw-readout.js.map +1 -1
- package/dist/sensor/stochastic-seed.d.ts +13 -0
- package/dist/sensor/stochastic-seed.js +42 -8
- package/dist/sensor/stochastic-seed.js.map +1 -1
- package/docs/AGENT_GUIDE.md +1 -0
- package/docs/API_REFERENCE.md +16 -1
- package/docs/QUICK_START.md +245 -9
- package/docs/USAGE.md +15 -0
- package/docs/api/core-version.md +1 -1
- package/docs/api/exports.json +37 -2
- package/docs/api/facade-simulate-capture.md +70 -2
- package/docs/api/facade-simulate-display-view.md +18 -0
- package/docs/api/facade-subject-framing.md +83 -0
- package/docs/api/optics-diffraction-psf-profile.md +8 -1
- package/docs/api/output-print-plan.md +8 -1
- package/package.json +3 -2
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
* without it every encoded file is byte-identical to 2.3.
|
|
29
29
|
* @see docs/QUICK_START.md
|
|
30
30
|
*/
|
|
31
|
+
import { markInternalSceneRadianceEvaluator } from "../core/internal-radiance-evaluator.js";
|
|
31
32
|
import { InvalidConfigurationError } from "../core/configuration-error.js";
|
|
32
33
|
import { requireInputObject } from "../core/record-validation.js";
|
|
33
34
|
import { ENGINE_API_VERSION } from "../core/version.js";
|
|
@@ -92,7 +93,7 @@ export const SIMULATE_CAPTURE_COST_OPTIONS = Object.freeze(["lens.pupilSampling"
|
|
|
92
93
|
"lens.diffraction", "sensor.antiAliasing", "sensor.pixelAperture.box", "sensor.temporalSampleCount", "motion.rotation", "motion.shutter.rolling", "output.borderHandling.mirror", "output.region",
|
|
93
94
|
"output.whiteBalance.auto", "output.demosaic.ahd", "output.highlightMode.reconstruct", "output.colorEncoding.wide-gamut", "output.gamutMapping.perceptual",
|
|
94
95
|
"output.format.jpeg", "output.format.png", "output.format.dng"]);
|
|
95
|
-
const FACADE_EVIDENCE = Object.freeze([Object.freeze({ sourceOrigin: "photivra",
|
|
96
|
+
export const FACADE_EVIDENCE = Object.freeze([Object.freeze({ sourceOrigin: "photivra",
|
|
96
97
|
sourceReference: "photivra:simulate-capture-facade", reuseStatus: "photivra-owned" })]);
|
|
97
98
|
const DEFAULTS = Object.freeze({ camera: "professional", fNumber: 8, shutterSeconds: 1 / 125, iso: 100, compensationStops: 0,
|
|
98
99
|
subjectDistanceM: 5, width: 64, height: 64, format: "srgb-8", toneCurve: "identity", highlightMode: "clip",
|
|
@@ -120,6 +121,10 @@ export const SIMULATE_CAPTURE_DEFAULT_ILLUMINANCE_LUX = Math.PI * sosReferenceLu
|
|
|
120
121
|
* aperture with fill factor 1. At other areas the facade scales the electron-domain readout values by
|
|
121
122
|
* area / this reference, so the same focal-plane exposure H gives the same output code (see `isoAreaScaledReadout`).
|
|
122
123
|
*/
|
|
124
|
+
/** 2.5: the largest accepted pixel pitch, µm (44 mm ÷ 48 pixels ≈ 917 µm, a whole full-frame or larger sensor at a small output). */
|
|
125
|
+
const SIMULATE_CAPTURE_PIXEL_PITCH_MAXIMUM_MICROMETERS = 1000;
|
|
126
|
+
/** Pitches above this, µm, are larger than any camera photosite: the capture is a downsized whole-frame view (assumption sensor.wholeFrameView). */
|
|
127
|
+
const SIMULATE_CAPTURE_PHOTOSITE_PITCH_MAXIMUM_MICROMETERS = 100;
|
|
123
128
|
const ISO_REFERENCE_APERTURE_AREA_SQUARE_MICROMETERS = DEFAULTS.pixelPitchMicrometers * DEFAULTS.pixelPitchMicrometers;
|
|
124
129
|
/** Demosaic border, native sites per side. */
|
|
125
130
|
const MARGIN = 1;
|
|
@@ -177,7 +182,7 @@ function parseAntiAliasing(value, pitch) {
|
|
|
177
182
|
return fail("sensor.antiAliasing.kind must be four-spot, two-spot or point-splitting.");
|
|
178
183
|
}
|
|
179
184
|
/** Midpoint nodes of an N × N rule over a square photosite, as offsets from its centre in sites (+x right, +y down). */
|
|
180
|
-
function apertureNodeOffsets(n) {
|
|
185
|
+
export function apertureNodeOffsets(n) {
|
|
181
186
|
const out = [];
|
|
182
187
|
for (let j = 0; j < n; j++)
|
|
183
188
|
for (let i = 0; i < n; i++)
|
|
@@ -193,7 +198,7 @@ const IDS = Object.freeze({ frame: "facade-frame", mode: "native-still", cfa: "p
|
|
|
193
198
|
capture: "facade-capture", spectral: "facade-colorimetric-eqe", sampling: "facade-sampling", stack: "facade-no-aa", stackAa: "facade-aa",
|
|
194
199
|
stationarity: "facade-stationarity", operatingState: "facade-operating-state", dark: "facade-dark", completeness: "facade-completeness",
|
|
195
200
|
chargeSampling: "facade-charge-sampling", capacity: "facade-capacity" });
|
|
196
|
-
const CHANNELS = ["red", "green", "blue"];
|
|
201
|
+
export const CHANNELS = ["red", "green", "blue"];
|
|
197
202
|
/**
|
|
198
203
|
* Synthetic colorimetric sensor: channel EQE(λ) = a·cmf(λ)·(555 nm/λ), with cmf = x̄, ȳ, z̄. The λ factor turns
|
|
199
204
|
* the energy-weighted CMF (colour-matching function) into a photon-counting response. So channel counts are
|
|
@@ -202,12 +207,12 @@ const CHANNELS = ["red", "green", "blue"];
|
|
|
202
207
|
*/
|
|
203
208
|
const EQE_SCALE = Object.freeze({ red: 0.5, green: 0.5, blue: 0.25 });
|
|
204
209
|
const CMF_KEY = Object.freeze({ red: "x", green: "y", blue: "z" });
|
|
205
|
-
const CAMERA_TO_XYZ = Object.freeze([[EQE_SCALE.green / EQE_SCALE.red, 0, 0], [0, 1, 0], [0, 0, EQE_SCALE.green / EQE_SCALE.blue]].map((r) => Object.freeze(r)));
|
|
210
|
+
export const CAMERA_TO_XYZ = Object.freeze([[EQE_SCALE.green / EQE_SCALE.red, 0, 0], [0, 1, 0], [0, 0, EQE_SCALE.green / EQE_SCALE.blue]].map((r) => Object.freeze(r)));
|
|
206
211
|
const XYZ_TO_CAMERA = [[EQE_SCALE.red / EQE_SCALE.green, 0, 0], [0, 1, 0], [0, 0, EQE_SCALE.blue / EQE_SCALE.green]];
|
|
207
212
|
/** Synthetic dark current per site at the operating temperature, e⁻/s. */
|
|
208
|
-
const DARK_CURRENT_ELECTRONS_PER_SECOND = 1;
|
|
213
|
+
export const DARK_CURRENT_ELECTRONS_PER_SECOND = 1;
|
|
209
214
|
const OPERATING_TEMPERATURE_C = 20;
|
|
210
|
-
function fail(message) { throw new InvalidConfigurationError(message); }
|
|
215
|
+
export function fail(message) { throw new InvalidConfigurationError(message); }
|
|
211
216
|
/** Where `output.metadata` goes in each file format (reported in the `output.metadata` assumption). */
|
|
212
217
|
const TEXT_METADATA_LAYOUT = Object.freeze({
|
|
213
218
|
jpeg: "XMP packet in APP1 (dc:description, xmp:CreatorTool, dc:rights, exif:UserComment), plus a COM segment holding the comment",
|
|
@@ -226,7 +231,7 @@ function isoAreaScaledReadout(profile, areaScale) {
|
|
|
226
231
|
preAdcSaturationElectronEquivalent: { ...regime.preAdcSaturationElectronEquivalent, value: regime.preAdcSaturationElectronEquivalent.value * areaScale } })) });
|
|
227
232
|
}
|
|
228
233
|
/** Diagnostic clock for the 2.1 stage timings: `performance.now()` where the host has it. Never affects output. */
|
|
229
|
-
function clockMs() { return globalThis.performance?.now() ?? Date.now(); }
|
|
234
|
+
export function clockMs() { return globalThis.performance?.now() ?? Date.now(); }
|
|
230
235
|
function positive(value, name) {
|
|
231
236
|
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0)
|
|
232
237
|
fail(name + " must be a positive finite number.");
|
|
@@ -280,6 +285,12 @@ function luminanceOf(radiance) {
|
|
|
280
285
|
}
|
|
281
286
|
return PHOTOPIC_MAXIMUM_LUMINOUS_EFFICACY_LM_PER_W * sum;
|
|
282
287
|
}
|
|
288
|
+
/** The white balance measured from a perfect white diffuser under the scene illuminant ("scene-illuminant"). */
|
|
289
|
+
export function sceneIlluminantWhiteBalance(illuminant) {
|
|
290
|
+
const xyz = illuminantXyz(illuminant), camera = XYZ_TO_CAMERA.map((row) => row[0] * xyz[0] + row[1] * xyz[1] + row[2] * xyz[2]);
|
|
291
|
+
return resolveCustomWhiteBalance({ stateId: "facade-wb-scene-illuminant", sampleSet: { imageStateId: "facade-white-diffuser",
|
|
292
|
+
inputDomain: "relative-pre-wb-camera-linear-rgb", samples: [{ sampleId: "white", red: camera[0], green: camera[1], blue: camera[2], weight: 1, clipped: false }] } });
|
|
293
|
+
}
|
|
283
294
|
function illuminantXyz(illuminant) {
|
|
284
295
|
let x = 0, y = 0, z = 0;
|
|
285
296
|
for (let nm = FACADE_SPECTRAL_RANGE_NANOMETERS.minimum; nm <= FACADE_SPECTRAL_RANGE_NANOMETERS.maximum; nm++) {
|
|
@@ -291,7 +302,7 @@ function illuminantXyz(illuminant) {
|
|
|
291
302
|
return [x / y, 1, z / y];
|
|
292
303
|
}
|
|
293
304
|
/** Bilinear RGGB demosaic as explicit linear neighbourhood kernels (weights sum to one per output channel). */
|
|
294
|
-
function bilinearPhaseProfiles() {
|
|
305
|
+
export function bilinearPhaseProfiles() {
|
|
295
306
|
const cross = [[1, 0], [-1, 0], [0, 1], [0, -1]], diagonal = [[1, 1], [1, -1], [-1, 1], [-1, -1]], horizontal = [[1, 0], [-1, 0]], vertical = [[0, 1], [0, -1]];
|
|
296
307
|
const average = (offsets, channel) => offsets.map(([x, y]) => ({ offsetX: x, offsetY: y, sourceChannelId: channel, weight: 1 / offsets.length }));
|
|
297
308
|
const self = (channel) => [{ offsetX: 0, offsetY: 0, sourceChannelId: channel, weight: 1 }];
|
|
@@ -466,7 +477,7 @@ function encodeSdrFromLinear(rgb, encoding, bitDepth, mapping) {
|
|
|
466
477
|
* underlying request/plan, provenance and every assumption made. See docs/QUICK_START.md.
|
|
467
478
|
*/
|
|
468
479
|
export async function simulateCapture(options) {
|
|
469
|
-
return await runCapture(options,
|
|
480
|
+
return await runCapture(options, "capture");
|
|
470
481
|
}
|
|
471
482
|
/**
|
|
472
483
|
* The ideal reference of a capture request (2.3): the same scene, framing, chart size, output size, region and pixel
|
|
@@ -478,10 +489,30 @@ export async function simulateCapture(options) {
|
|
|
478
489
|
* @see docs/QUICK_START.md, "Ideal reference".
|
|
479
490
|
*/
|
|
480
491
|
export async function simulateIdealReference(options) {
|
|
481
|
-
return await runCapture(options,
|
|
492
|
+
return await runCapture(options, "ideal-reference");
|
|
493
|
+
}
|
|
494
|
+
/** Default stratified samples per axis of a display view (`view.samplesPerAxis`): 16 × 16 sampled real pixels per display pixel with detail, 4 × 4 where it is uniform. */
|
|
495
|
+
export const SIMULATE_DISPLAY_VIEW_DEFAULT_SAMPLES_PER_AXIS = 16;
|
|
496
|
+
function parseDisplayViewOption(value, frameWidth, frameHeight) {
|
|
497
|
+
if (value === undefined)
|
|
498
|
+
fail("simulateDisplayView needs a view, for example { width: 384, height: 256 }.");
|
|
499
|
+
const v = allowKeys(value, "view", ["width", "height", "samplesPerAxis", "format", "region"]);
|
|
500
|
+
const width = integerIn(v.width, "view.width", 1, frameWidth), height = integerIn(v.height, "view.height", 1, frameHeight);
|
|
501
|
+
const samplesPerAxis = v.samplesPerAxis === undefined ? SIMULATE_DISPLAY_VIEW_DEFAULT_SAMPLES_PER_AXIS : integerIn(v.samplesPerAxis, "view.samplesPerAxis", 1, 32);
|
|
502
|
+
const format = v.format === undefined ? "srgb-8" : v.format;
|
|
503
|
+
if (format !== "srgb-8" && format !== "srgb-16")
|
|
504
|
+
fail("view.format must be srgb-8 or srgb-16.");
|
|
505
|
+
let region;
|
|
506
|
+
if (v.region !== undefined) {
|
|
507
|
+
const r = allowKeys(v.region, "view.region", ["x", "y", "width", "height"]);
|
|
508
|
+
const x = integerIn(r.x, "view.region.x", 0, width - 1), y = integerIn(r.y, "view.region.y", 0, height - 1);
|
|
509
|
+
region = { x, y, width: integerIn(r.width, "view.region.width", 1, width - x), height: integerIn(r.height, "view.region.height", 1, height - y) };
|
|
510
|
+
}
|
|
511
|
+
return { width, height, samplesPerAxis, bitDepth: format === "srgb-8" ? 8 : 16, format: format, ...(region ? { region } : {}) };
|
|
482
512
|
}
|
|
483
|
-
|
|
484
|
-
|
|
513
|
+
/** Internal: the request path shared by {@link simulateCapture}, {@link simulateIdealReference} and the display view, whose renderer is passed in. */
|
|
514
|
+
export async function runCapture(options, run, viewOption, renderView) {
|
|
515
|
+
const started = Date.now(), idealReference = run === "ideal-reference", displayView = run === "display-view";
|
|
485
516
|
requireInputObject(options, "simulateCapture options");
|
|
486
517
|
const top = allowKeys(options, "simulateCapture options", ["camera", "lens", "exposure", "scene", "subjectDistanceM", "chartSize", "illuminant",
|
|
487
518
|
"illuminanceLux", "output", "sensor", "motion", "signal", "onProgress"]);
|
|
@@ -687,8 +718,8 @@ async function runCapture(options, idealReference) {
|
|
|
687
718
|
fail("output.jpegQuantizationStep applies only to output.format \"jpeg\".");
|
|
688
719
|
const sensorOptions = allowKeys(top.sensor, "sensor", ["pixelPitchMicrometers", "spectralStepNanometers", "temporalSampleCount", "noiseSeed", "antiAliasing", "pixelAperture"]);
|
|
689
720
|
const pitch = positive(pick(sensorOptions, "pixelPitchMicrometers", "sensor.pixelPitchMicrometers", DEFAULTS.pixelPitchMicrometers, "6 µm pixels (about a 24 MP full-frame sensor). 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");
|
|
690
|
-
if (pitch < 0.5 || pitch >
|
|
691
|
-
fail("sensor.pixelPitchMicrometers must be 0.5-
|
|
721
|
+
if (pitch < 0.5 || pitch > SIMULATE_CAPTURE_PIXEL_PITCH_MAXIMUM_MICROMETERS)
|
|
722
|
+
fail("sensor.pixelPitchMicrometers must be 0.5-" + SIMULATE_CAPTURE_PIXEL_PITCH_MAXIMUM_MICROMETERS + ".");
|
|
692
723
|
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");
|
|
693
724
|
if (spectralStep < 1 || spectralStep > 100)
|
|
694
725
|
fail("sensor.spectralStepNanometers must be 1-100.");
|
|
@@ -710,6 +741,29 @@ async function runCapture(options, idealReference) {
|
|
|
710
741
|
}
|
|
711
742
|
}
|
|
712
743
|
const boxAperture = sensorOptions.pixelAperture !== undefined && sensorOptions.pixelAperture !== "point";
|
|
744
|
+
// Display view: its own parameters, and the capture options it reproduces, checked before any scene work.
|
|
745
|
+
const parsedView = displayView ? parseDisplayViewOption(viewOption, width, height) : undefined;
|
|
746
|
+
if (displayView) {
|
|
747
|
+
const unsupported = (option, reason) => fail("simulateDisplayView does not support " + option + ": " + reason);
|
|
748
|
+
if (pupil)
|
|
749
|
+
unsupported("lens.pupilSampling", "defocus moves each query off the site lattice the expected-signal tables use.");
|
|
750
|
+
if (lensFieldMapping)
|
|
751
|
+
unsupported("lens.projection or lens.distortion", "the expected signal assumes the ideal rectilinear, distortion-free mapping of the site lattice.");
|
|
752
|
+
if (vignetting)
|
|
753
|
+
unsupported("lens.vignetting", "the expected signal assumes the same radiance-to-irradiance factor at every site.");
|
|
754
|
+
if (lensPsf)
|
|
755
|
+
unsupported("lens.psf", "only the engine-built diffraction kernels (lens.diffraction) are tabulated.");
|
|
756
|
+
if (rotating)
|
|
757
|
+
unsupported("motion.angularVelocityRadPerSec", "camera rotation moves the queries off the site lattice.");
|
|
758
|
+
if (outputOptions.region !== undefined)
|
|
759
|
+
unsupported("output.region", "pass the rectangle as view.region, in display pixels.");
|
|
760
|
+
if (outputOptions.whiteBalance === "auto")
|
|
761
|
+
unsupported("output.whiteBalance \"auto\"", "the capture measures it from its noisy full-resolution RAW; use \"scene-illuminant\" or a fixed white balance.");
|
|
762
|
+
if (outputOptions.toneCurve !== undefined && outputOptions.toneCurve !== "identity" && outputOptions.toneCurve !== "positive-reinhard-per-channel")
|
|
763
|
+
unsupported("output.toneCurve " + String(outputOptions.toneCurve), "luminance tone curves couple the channels; the view supports identity and positive-reinhard-per-channel.");
|
|
764
|
+
if (outputOptions.highlightMode === "reconstruct")
|
|
765
|
+
unsupported("output.highlightMode \"reconstruct\"", "the view supports the per-channel highlight clip.");
|
|
766
|
+
}
|
|
713
767
|
const colorSamplingProfile = { schemaVersion: "0.1.0", profileId: IDS.cfa, evidence: FACADE_EVIDENCE, coordinateSystem: "native-sensor-color-sampling-site-index",
|
|
714
768
|
layout: { kind: "periodic-mosaic", repeatWidthSites: 2, repeatHeightSites: 2, siteChannelIds: ["red", "green", "green", "blue"], anchor: "native-sensor-top-left-site" } };
|
|
715
769
|
// Demosaic (2.0): built-in Bayer kernels and border handling. Omitted, the 1.0 bilinear kernels and a
|
|
@@ -748,13 +802,17 @@ async function runCapture(options, idealReference) {
|
|
|
748
802
|
if (region && regionNative)
|
|
749
803
|
assume("output.region", "model", { ...region, frameWidth: width, frameHeight: height, nativeRect: { ...regionNative } }, "Only this rectangle of the " + width + " × " + height + " frame is rendered. Framing, metering, exposure, diffraction kernels and every site's physics, noise seeds and exposure window (rolling-shutter row timing) are those of the whole frame (absolute native indices), " +
|
|
750
804
|
"and the native rectangle includes the demosaic reach on each side (" + (margin > 0 ? margin : "mirror-border") + "), so the pixels are byte-identical to the same pixels of the whole render. " +
|
|
751
|
-
|
|
805
|
+
// 2.6: with lens.diffraction the convolution is limited to the region; the sentence of 2.3–2.5 is kept verbatim otherwise, so
|
|
806
|
+
// region results without diffraction stay byte-identical to 2.5.0.
|
|
807
|
+
(diffractionRadiusOption !== undefined ? "With lens.diffraction the object-space convolution (when it applies) covers only the lattice the region's native sites query (plus the kernel radius), in bands of at most 32 native rows; its values are the whole render's." :
|
|
808
|
+
"With lens.diffraction the object-space convolution runs over whole-width bands of 32 native rows that the region touches."));
|
|
752
809
|
if (raisedPixels !== undefined) {
|
|
753
810
|
if (nativeWidth > NATIVE_RAW_LIMITS.maximumDimension || nativeHeight > NATIVE_RAW_LIMITS.maximumDimension || nativeWidth * nativeHeight > raisedPixels) {
|
|
754
811
|
fail("output.maximumPixels (" + raisedPixels + ") must cover the native sensor raster of " + nativeWidth + " × " + nativeHeight +
|
|
755
812
|
" sites (the output plus the demosaic margin), at most " + NATIVE_RAW_LIMITS.maximumDimension + " per side.");
|
|
756
813
|
}
|
|
757
|
-
|
|
814
|
+
// The display view allocates no full-resolution raster, so the AHD working limit does not apply to it.
|
|
815
|
+
if (ahd && !displayView && (regionNative ? regionNative.width * regionNative.height : nativeWidth * nativeHeight) > PROCESSING_MAX_PIXELS)
|
|
758
816
|
fail("output.demosaic \"ahd\" admits at most " + PROCESSING_MAX_PIXELS + " native sites.");
|
|
759
817
|
}
|
|
760
818
|
const imagingArea = { widthMm: nativeWidth * pitch / 1000, heightMm: nativeHeight * pitch / 1000 };
|
|
@@ -843,10 +901,16 @@ async function runCapture(options, idealReference) {
|
|
|
843
901
|
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.");
|
|
844
902
|
const sourceSha256 = await sha256Hex(JSON.stringify(sceneDescription, (_key, value) => (typeof value === "object" && value !== null && "evaluate" in value ? "caller-spectrum" : value)));
|
|
845
903
|
checkAbort();
|
|
846
|
-
const
|
|
847
|
-
illuminant, irradianceScale, geometry: { distanceM: subjectDistanceM, metresPerFrameUnit,
|
|
904
|
+
const buildProvider = (sceneIlluminant, scale) => buildPlanarSceneRadianceProvider({ scene: chart ?? callerScene,
|
|
905
|
+
sceneId: IDS.scene, providerProfileId: IDS.provider, illuminant: sceneIlluminant, irradianceScale: scale, geometry: { distanceM: subjectDistanceM, metresPerFrameUnit,
|
|
848
906
|
...(chartSize ? { chart: { widthM: chartSize.widthM, heightM: chartSize.heightM, surroundReflectance: chartSize.surroundReflectance } } : {}) }, evidence: FACADE_EVIDENCE,
|
|
849
|
-
limitations: ["Planar Lambertian scene under uniform illumination; no shadows, inter-reflection, gloss or fluorescence."] });
|
|
907
|
+
limitations: Object.freeze(["Planar Lambertian scene under uniform illumination; no shadows, inter-reflection, gloss or fluorescence."]) });
|
|
908
|
+
const provider = buildProvider(illuminant, irradianceScale);
|
|
909
|
+
// 2.6: a chart preset's evaluator is engine-built end to end (its results: the schema's exact keys, constant fields,
|
|
910
|
+
// the request's identifiers, deep-frozen FACADE_EVIDENCE, UNCERTAINTY and limitations, and an engine-computed
|
|
911
|
+
// radiance), so the native task may skip re-parsing the fixed parts. Caller scenes keep the full validation.
|
|
912
|
+
if (chart)
|
|
913
|
+
markInternalSceneRadianceEvaluator(provider.evaluateRadiance);
|
|
850
914
|
assume("model.scene-radiometry", "model", "lambertian-plane", "Radiance = reflectance × irradiance / π on one plane; illuminance from K_m = 683 lm/W and the engine's analytic CIE 1931 ȳ fit.");
|
|
851
915
|
// Metering (CIPA DC-004 standard output sensitivity and camera equation) and exposure-mode resolution
|
|
852
916
|
const exposureOptions = allowKeys(top.exposure, "exposure", ["mode", "iso", "shutterSeconds", "compensationStops"]);
|
|
@@ -947,6 +1011,15 @@ async function runCapture(options, idealReference) {
|
|
|
947
1011
|
"the aperture area: a design consequence of holding the tier's ISO rating and highlight headroom fixed, not a measured full-well model (published sensor data grow less than " +
|
|
948
1012
|
"proportionally with area across technologies).");
|
|
949
1013
|
}
|
|
1014
|
+
// Whole-frame views (2.5): a pitch above 100 µm is larger than any camera photosite, so the capture is a downsized view of a frame width × pitch wide.
|
|
1015
|
+
if (pitch > SIMULATE_CAPTURE_PHOTOSITE_PITCH_MAXIMUM_MICROMETERS) {
|
|
1016
|
+
assume("sensor.wholeFrameView", "model", { pixelPitchMicrometers: pitch, frameWidthMm: width * pitch / 1000, frameHeightMm: height * pitch / 1000 }, "A pixel pitch above 100 µm is larger than any camera photosite, so each output pixel stands for a " + pitch + " µm square of a " + width * pitch / 1000 + " × " + height * pitch / 1000 +
|
|
1017
|
+
" mm frame: a downsized view of the whole frame, with the framing, crop factor, generic prime and minimum focus of that frame. Its signal is the charge of the whole square " +
|
|
1018
|
+
"(sensor.isoAreaNormalization), so its shot noise is that of all the real photosites in the square summed or averaged (Poisson counts add), and the image brightness is the " +
|
|
1019
|
+
"same as at any other pitch. Read noise and dark current are applied once per output pixel, not once per real photosite, so in deep shadows the noise is lower than a real " +
|
|
1020
|
+
"sensor's binned output. This is the noise of a binned, downsized view, not the real sensor's per-pixel noise. A point pixel aperture samples one point of each square; " +
|
|
1021
|
+
"use sensor.pixelAperture box (or sensor.antiAliasing) to average scene detail finer than the pitch.");
|
|
1022
|
+
}
|
|
950
1023
|
assume("sensor.darkCurrent", "model", { electronsPerSecond: DARK_CURRENT_ELECTRONS_PER_SECOND, temperatureC: OPERATING_TEMPERATURE_C }, "Synthetic uniform dark current of 1 e⁻/s per site at 20 °C (the tier presets declare none); dark shot noise follows from it.");
|
|
951
1024
|
assume("sensor.spectralResponse", "model", { kind: "colorimetric-cmf-eqe", peakScale: EQE_SCALE }, "Idealized colorimetric sensor: channel EQE = a·{x̄, ȳ, z̄}(λ)·555/λ (the engine's analytic CIE 1931 fit), so RAW channels are proportional to CIE XYZ (Luther–Ives) and the camera matrix is diagonal. Not a real camera's response; CMF air wavelengths are used as vacuum wavelengths (difference about 0.03 %).");
|
|
952
1025
|
if (boxAperture) {
|
|
@@ -1007,7 +1080,7 @@ async function runCapture(options, idealReference) {
|
|
|
1007
1080
|
else
|
|
1008
1081
|
assume("lens.projection", "model", projection, "Ideal " + projection + " projection through the environment lens field mapping. At finite focus its scale is the thin-lens image distance (an approximation); the chart is still sized with the rectilinear thin-lens scale, so its edges move with the projection.");
|
|
1009
1082
|
// White balance
|
|
1010
|
-
let whiteBalanceState = null, needsAuto = false;
|
|
1083
|
+
let whiteBalanceState = null, needsAuto = false, kelvinWhiteBalanceInput;
|
|
1011
1084
|
if (whiteBalanceOption === "none")
|
|
1012
1085
|
whiteBalanceState = null;
|
|
1013
1086
|
else if (whiteBalanceOption === "auto") {
|
|
@@ -1015,17 +1088,16 @@ async function runCapture(options, idealReference) {
|
|
|
1015
1088
|
fail("output.region cannot be combined with output.whiteBalance \"auto\": grey-world statistics of a region differ from the whole frame's.");
|
|
1016
1089
|
needsAuto = true;
|
|
1017
1090
|
}
|
|
1018
|
-
else if (whiteBalanceOption === "scene-illuminant")
|
|
1019
|
-
|
|
1020
|
-
whiteBalanceState = resolveCustomWhiteBalance({ stateId: "facade-wb-scene-illuminant", sampleSet: { imageStateId: "facade-white-diffuser",
|
|
1021
|
-
inputDomain: "relative-pre-wb-camera-linear-rgb", samples: [{ sampleId: "white", red: camera[0], green: camera[1], blue: camera[2], weight: 1, clipped: false }] } });
|
|
1022
|
-
}
|
|
1091
|
+
else if (whiteBalanceOption === "scene-illuminant")
|
|
1092
|
+
whiteBalanceState = sceneIlluminantWhiteBalance(illuminant);
|
|
1023
1093
|
else if (typeof whiteBalanceOption === "object" && whiteBalanceOption !== null && "kelvin" in whiteBalanceOption) {
|
|
1024
1094
|
const wb = allowKeys(whiteBalanceOption, "output.whiteBalance", ["kelvin", "tint"]);
|
|
1025
1095
|
const intent = createColorTemperatureWhiteBalanceIntent({ intentId: "facade-wb-cct", colorTemperatureKelvin: positive(wb.kelvin, "output.whiteBalance.kelvin"),
|
|
1026
1096
|
tint: wb.tint === undefined ? 0 : wb.tint });
|
|
1027
|
-
|
|
1028
|
-
camera: { profileId: IDS.spectral, profileVersion: SIMULATE_CAPTURE_FACADE_VERSION, xyzToCameraRgb: XYZ_TO_CAMERA.map((r) => [...r]) } }
|
|
1097
|
+
const kelvinInput = { stateId: "facade-wb-cct", intent, whiteLocus: "planckian", tintInterpretation: "duv-cie-1960-uv",
|
|
1098
|
+
camera: { profileId: IDS.spectral, profileVersion: SIMULATE_CAPTURE_FACADE_VERSION, xyzToCameraRgb: XYZ_TO_CAMERA.map((r) => [...r]) } };
|
|
1099
|
+
whiteBalanceState = resolveColorTemperatureWhiteBalance(kelvinInput);
|
|
1100
|
+
kelvinWhiteBalanceInput = kelvinInput;
|
|
1029
1101
|
}
|
|
1030
1102
|
else if (typeof whiteBalanceOption === "object" && whiteBalanceOption !== null && "channelGains" in whiteBalanceOption) {
|
|
1031
1103
|
allowKeys(whiteBalanceOption, "output.whiteBalance", ["channelGains"]);
|
|
@@ -1223,6 +1295,8 @@ async function runCapture(options, idealReference) {
|
|
|
1223
1295
|
const rawProvider = diffraction && diffractionObjectSpace ? createDiffractedPlanarProvider({ base: provider, psf: diffractionPsf.configuration.psf,
|
|
1224
1296
|
wavelengthsNm: diffraction.profile.axes.wavelengthNm, kernelRadius: diffraction.kernelRadiusSamples, nativeWidth, nativeHeight, outputWidth: width,
|
|
1225
1297
|
sitePitchMicrometers: pitch, distanceM: subjectDistanceM, metresPerFrameUnit, onConvolution: (ms) => { diffractionMs += ms; },
|
|
1298
|
+
// Region (2.6): convolve only the lattice the region's native sites query; the values are the whole render's.
|
|
1299
|
+
...(regionNative ? { window: regionNative } : {}),
|
|
1226
1300
|
// The environment queries each site at each aperture node minus each split offset (sites, +x right, +y down).
|
|
1227
1301
|
...(antiAliasing || boxSamples > 1 ? { queryOffsetsSites: apertureNodeOffsets(boxSamples).flatMap((node) => (antiAliasing ? antiAliasing.components : [{ offsetMicrometers: { x: 0, y: 0 } }]).map((c) => ({ x: node.x - c.offsetMicrometers.x / pitch, y: node.y - c.offsetMicrometers.y / pitch }))) } : {}) }) : provider;
|
|
1228
1302
|
if (antiAliasing && perSite > 100_000)
|
|
@@ -1232,13 +1306,9 @@ async function runCapture(options, idealReference) {
|
|
|
1232
1306
|
const tileWidth = Math.max(1, Math.min(256, Math.floor(100_000 / perSite)));
|
|
1233
1307
|
const environmentRaw = { raw: buildRawInput(intentOf(whiteBalanceState)), sceneBinding,
|
|
1234
1308
|
maximumProviderEvaluations: Math.min(nativeRawMaximumProviderEvaluations(raisedPixels === undefined ? {} : { maximumPixels: raisedPixels }), Math.max(nativeWidth * nativeHeight, evaluations)) };
|
|
1235
|
-
//
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
if (format !== "srgb-8" && format !== "srgb-16" && format !== "png")
|
|
1239
|
-
fail("simulateIdealReference supports output.format srgb-8, srgb-16 and png: a reference has no RAW (dng) and should not carry JPEG compression artefacts.");
|
|
1240
|
-
if (needsAuto)
|
|
1241
|
-
fail("simulateIdealReference cannot reproduce output.whiteBalance \"auto\": the capture measures it from its noisy RAW. Use \"scene-illuminant\" or a fixed white balance.");
|
|
1309
|
+
// The facade's environment chain at a site next to the optical axis, shared by the ideal reference (2.3) and the display
|
|
1310
|
+
// view: expected electrons per unit spectral radiance per channel and node, and the normalized RAW signal per electron.
|
|
1311
|
+
function axisChannelResponse(caller) {
|
|
1242
1312
|
// 1. Electrons per unit spectral radiance at each spectral node, per channel: the facade's own environment chain at a
|
|
1243
1313
|
// site next to the optical axis, with the ideal optics (no PSF, pupil, distortion, vignetting or motion). The signal
|
|
1244
1314
|
// is linear in the radiance node values and, for this planar scene, the same at every site (paraxial π/(4N_w²)).
|
|
@@ -1247,7 +1317,7 @@ async function runCapture(options, idealReference) {
|
|
|
1247
1317
|
const electronsPerRadiance = {};
|
|
1248
1318
|
for (const channel of CHANNELS) {
|
|
1249
1319
|
if (channelAt(channelSites[channel] % nativeWidth, Math.floor(channelSites[channel] / nativeWidth)) !== channel)
|
|
1250
|
-
fail("
|
|
1320
|
+
fail(caller + " needs at least a 2 × 2 native raster.");
|
|
1251
1321
|
const environment = { ...siteInput(channelSites[channel]).environment };
|
|
1252
1322
|
environment.psf = { kind: "not-applied", evidence, limitation: "Ideal reference: no lens PSF." };
|
|
1253
1323
|
environment.fieldThroughput = { kind: "unity", evidence, limitation: "Ideal reference: no vignetting." };
|
|
@@ -1275,6 +1345,20 @@ async function runCapture(options, idealReference) {
|
|
|
1275
1345
|
systemConversionGainElectronsPerCode: gain, blackLevelCode: black, digitalSaturationCode: regime.adc.digitalSaturationCode, adcMaximumCode: 2 ** regime.adc.bitDepth - 1, digitalGain }, readoutProfile, regimeId);
|
|
1276
1346
|
relativePerElectron[channel] = digitalGain / (gain * (white - black));
|
|
1277
1347
|
}
|
|
1348
|
+
return { electronsPerRadiance, wavelengths, relativePerElectron };
|
|
1349
|
+
}
|
|
1350
|
+
// Display view: the renderer that simulate-display-view.ts passes in reads this request's resolved state, so a bundle
|
|
1351
|
+
// that only captures does not contain the display view.
|
|
1352
|
+
if (displayView)
|
|
1353
|
+
return await renderView({ ahd, antiAliasing, aspect, assume, assumptions, axisChannelResponse, boxSamples, buildProvider, callerScene, chart, chartReport, chartSize, diffraction, diffractionPsf, fNumber, focalLengthMm, focusDistanceM, height, highlightMode, illuminanceLux, illuminant, illuminantOption, imagingArea, intentOf, metresPerFrameUnit, noiseSeed, outputOptions, parsedView, pitch, preset, provider, regimeByChannel, resolvedIso, sceneOption, scenePreset, shutterSeconds, started, subjectDistanceM, toneCurve, whiteBalanceOption, whiteBalanceState, width, checkAbort, margin, mode, nativeHeight, nativeWidth, sourceSha256, yieldControl, kelvinWhiteBalanceInput });
|
|
1354
|
+
// Ideal reference (2.3): the scene without camera effects, at the capture's own exposure, white balance and encoding.
|
|
1355
|
+
if (idealReference) {
|
|
1356
|
+
const referenceStarted = clockMs();
|
|
1357
|
+
if (format !== "srgb-8" && format !== "srgb-16" && format !== "png")
|
|
1358
|
+
fail("simulateIdealReference supports output.format srgb-8, srgb-16 and png: a reference has no RAW (dng) and should not carry JPEG compression artefacts.");
|
|
1359
|
+
if (needsAuto)
|
|
1360
|
+
fail("simulateIdealReference cannot reproduce output.whiteBalance \"auto\": the capture measures it from its noisy RAW. Use \"scene-illuminant\" or a fixed white balance.");
|
|
1361
|
+
const { electronsPerRadiance, wavelengths, relativePerElectron } = axisChannelResponse("simulateIdealReference");
|
|
1278
1362
|
// 3. Scene radiance per sample: chart materials once (and the fixed-size chart's surround), caller functions per sample.
|
|
1279
1363
|
const signalOf = (radiance) => {
|
|
1280
1364
|
const values = new Map(wavelengths.map((nm) => [nm, radiance(nm)]));
|