@photivra/engine 2.5.0 → 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 +38 -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 +224 -4
- package/dist/facade/simulate-capture.js +104 -33
- 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 +16 -0
- package/dist/facade/subject-framing.js +8 -2
- package/dist/facade/subject-framing.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/optics/diffraction-psf-profile.d.ts +9 -2
- package/dist/optics/diffraction-psf-profile.js +33 -4
- 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 +13 -1
- package/docs/QUICK_START.md +155 -4
- package/docs/USAGE.md +15 -0
- package/docs/api/core-version.md +1 -1
- package/docs/api/exports.json +22 -2
- package/docs/api/facade-simulate-capture.md +65 -1
- package/docs/api/facade-simulate-display-view.md +18 -0
- package/docs/api/facade-subject-framing.md +16 -0
- 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",
|
|
@@ -181,7 +182,7 @@ function parseAntiAliasing(value, pitch) {
|
|
|
181
182
|
return fail("sensor.antiAliasing.kind must be four-spot, two-spot or point-splitting.");
|
|
182
183
|
}
|
|
183
184
|
/** Midpoint nodes of an N × N rule over a square photosite, as offsets from its centre in sites (+x right, +y down). */
|
|
184
|
-
function apertureNodeOffsets(n) {
|
|
185
|
+
export function apertureNodeOffsets(n) {
|
|
185
186
|
const out = [];
|
|
186
187
|
for (let j = 0; j < n; j++)
|
|
187
188
|
for (let i = 0; i < n; i++)
|
|
@@ -197,7 +198,7 @@ const IDS = Object.freeze({ frame: "facade-frame", mode: "native-still", cfa: "p
|
|
|
197
198
|
capture: "facade-capture", spectral: "facade-colorimetric-eqe", sampling: "facade-sampling", stack: "facade-no-aa", stackAa: "facade-aa",
|
|
198
199
|
stationarity: "facade-stationarity", operatingState: "facade-operating-state", dark: "facade-dark", completeness: "facade-completeness",
|
|
199
200
|
chargeSampling: "facade-charge-sampling", capacity: "facade-capacity" });
|
|
200
|
-
const CHANNELS = ["red", "green", "blue"];
|
|
201
|
+
export const CHANNELS = ["red", "green", "blue"];
|
|
201
202
|
/**
|
|
202
203
|
* Synthetic colorimetric sensor: channel EQE(λ) = a·cmf(λ)·(555 nm/λ), with cmf = x̄, ȳ, z̄. The λ factor turns
|
|
203
204
|
* the energy-weighted CMF (colour-matching function) into a photon-counting response. So channel counts are
|
|
@@ -206,12 +207,12 @@ const CHANNELS = ["red", "green", "blue"];
|
|
|
206
207
|
*/
|
|
207
208
|
const EQE_SCALE = Object.freeze({ red: 0.5, green: 0.5, blue: 0.25 });
|
|
208
209
|
const CMF_KEY = Object.freeze({ red: "x", green: "y", blue: "z" });
|
|
209
|
-
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)));
|
|
210
211
|
const XYZ_TO_CAMERA = [[EQE_SCALE.red / EQE_SCALE.green, 0, 0], [0, 1, 0], [0, 0, EQE_SCALE.blue / EQE_SCALE.green]];
|
|
211
212
|
/** Synthetic dark current per site at the operating temperature, e⁻/s. */
|
|
212
|
-
const DARK_CURRENT_ELECTRONS_PER_SECOND = 1;
|
|
213
|
+
export const DARK_CURRENT_ELECTRONS_PER_SECOND = 1;
|
|
213
214
|
const OPERATING_TEMPERATURE_C = 20;
|
|
214
|
-
function fail(message) { throw new InvalidConfigurationError(message); }
|
|
215
|
+
export function fail(message) { throw new InvalidConfigurationError(message); }
|
|
215
216
|
/** Where `output.metadata` goes in each file format (reported in the `output.metadata` assumption). */
|
|
216
217
|
const TEXT_METADATA_LAYOUT = Object.freeze({
|
|
217
218
|
jpeg: "XMP packet in APP1 (dc:description, xmp:CreatorTool, dc:rights, exif:UserComment), plus a COM segment holding the comment",
|
|
@@ -230,7 +231,7 @@ function isoAreaScaledReadout(profile, areaScale) {
|
|
|
230
231
|
preAdcSaturationElectronEquivalent: { ...regime.preAdcSaturationElectronEquivalent, value: regime.preAdcSaturationElectronEquivalent.value * areaScale } })) });
|
|
231
232
|
}
|
|
232
233
|
/** Diagnostic clock for the 2.1 stage timings: `performance.now()` where the host has it. Never affects output. */
|
|
233
|
-
function clockMs() { return globalThis.performance?.now() ?? Date.now(); }
|
|
234
|
+
export function clockMs() { return globalThis.performance?.now() ?? Date.now(); }
|
|
234
235
|
function positive(value, name) {
|
|
235
236
|
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0)
|
|
236
237
|
fail(name + " must be a positive finite number.");
|
|
@@ -284,6 +285,12 @@ function luminanceOf(radiance) {
|
|
|
284
285
|
}
|
|
285
286
|
return PHOTOPIC_MAXIMUM_LUMINOUS_EFFICACY_LM_PER_W * sum;
|
|
286
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
|
+
}
|
|
287
294
|
function illuminantXyz(illuminant) {
|
|
288
295
|
let x = 0, y = 0, z = 0;
|
|
289
296
|
for (let nm = FACADE_SPECTRAL_RANGE_NANOMETERS.minimum; nm <= FACADE_SPECTRAL_RANGE_NANOMETERS.maximum; nm++) {
|
|
@@ -295,7 +302,7 @@ function illuminantXyz(illuminant) {
|
|
|
295
302
|
return [x / y, 1, z / y];
|
|
296
303
|
}
|
|
297
304
|
/** Bilinear RGGB demosaic as explicit linear neighbourhood kernels (weights sum to one per output channel). */
|
|
298
|
-
function bilinearPhaseProfiles() {
|
|
305
|
+
export function bilinearPhaseProfiles() {
|
|
299
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]];
|
|
300
307
|
const average = (offsets, channel) => offsets.map(([x, y]) => ({ offsetX: x, offsetY: y, sourceChannelId: channel, weight: 1 / offsets.length }));
|
|
301
308
|
const self = (channel) => [{ offsetX: 0, offsetY: 0, sourceChannelId: channel, weight: 1 }];
|
|
@@ -470,7 +477,7 @@ function encodeSdrFromLinear(rgb, encoding, bitDepth, mapping) {
|
|
|
470
477
|
* underlying request/plan, provenance and every assumption made. See docs/QUICK_START.md.
|
|
471
478
|
*/
|
|
472
479
|
export async function simulateCapture(options) {
|
|
473
|
-
return await runCapture(options,
|
|
480
|
+
return await runCapture(options, "capture");
|
|
474
481
|
}
|
|
475
482
|
/**
|
|
476
483
|
* The ideal reference of a capture request (2.3): the same scene, framing, chart size, output size, region and pixel
|
|
@@ -482,10 +489,30 @@ export async function simulateCapture(options) {
|
|
|
482
489
|
* @see docs/QUICK_START.md, "Ideal reference".
|
|
483
490
|
*/
|
|
484
491
|
export async function simulateIdealReference(options) {
|
|
485
|
-
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 } : {}) };
|
|
486
512
|
}
|
|
487
|
-
|
|
488
|
-
|
|
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";
|
|
489
516
|
requireInputObject(options, "simulateCapture options");
|
|
490
517
|
const top = allowKeys(options, "simulateCapture options", ["camera", "lens", "exposure", "scene", "subjectDistanceM", "chartSize", "illuminant",
|
|
491
518
|
"illuminanceLux", "output", "sensor", "motion", "signal", "onProgress"]);
|
|
@@ -714,6 +741,29 @@ async function runCapture(options, idealReference) {
|
|
|
714
741
|
}
|
|
715
742
|
}
|
|
716
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
|
+
}
|
|
717
767
|
const colorSamplingProfile = { schemaVersion: "0.1.0", profileId: IDS.cfa, evidence: FACADE_EVIDENCE, coordinateSystem: "native-sensor-color-sampling-site-index",
|
|
718
768
|
layout: { kind: "periodic-mosaic", repeatWidthSites: 2, repeatHeightSites: 2, siteChannelIds: ["red", "green", "green", "blue"], anchor: "native-sensor-top-left-site" } };
|
|
719
769
|
// Demosaic (2.0): built-in Bayer kernels and border handling. Omitted, the 1.0 bilinear kernels and a
|
|
@@ -752,13 +802,17 @@ async function runCapture(options, idealReference) {
|
|
|
752
802
|
if (region && regionNative)
|
|
753
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), " +
|
|
754
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. " +
|
|
755
|
-
|
|
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."));
|
|
756
809
|
if (raisedPixels !== undefined) {
|
|
757
810
|
if (nativeWidth > NATIVE_RAW_LIMITS.maximumDimension || nativeHeight > NATIVE_RAW_LIMITS.maximumDimension || nativeWidth * nativeHeight > raisedPixels) {
|
|
758
811
|
fail("output.maximumPixels (" + raisedPixels + ") must cover the native sensor raster of " + nativeWidth + " × " + nativeHeight +
|
|
759
812
|
" sites (the output plus the demosaic margin), at most " + NATIVE_RAW_LIMITS.maximumDimension + " per side.");
|
|
760
813
|
}
|
|
761
|
-
|
|
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)
|
|
762
816
|
fail("output.demosaic \"ahd\" admits at most " + PROCESSING_MAX_PIXELS + " native sites.");
|
|
763
817
|
}
|
|
764
818
|
const imagingArea = { widthMm: nativeWidth * pitch / 1000, heightMm: nativeHeight * pitch / 1000 };
|
|
@@ -847,10 +901,16 @@ async function runCapture(options, idealReference) {
|
|
|
847
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.");
|
|
848
902
|
const sourceSha256 = await sha256Hex(JSON.stringify(sceneDescription, (_key, value) => (typeof value === "object" && value !== null && "evaluate" in value ? "caller-spectrum" : value)));
|
|
849
903
|
checkAbort();
|
|
850
|
-
const
|
|
851
|
-
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,
|
|
852
906
|
...(chartSize ? { chart: { widthM: chartSize.widthM, heightM: chartSize.heightM, surroundReflectance: chartSize.surroundReflectance } } : {}) }, evidence: FACADE_EVIDENCE,
|
|
853
|
-
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);
|
|
854
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.");
|
|
855
915
|
// Metering (CIPA DC-004 standard output sensitivity and camera equation) and exposure-mode resolution
|
|
856
916
|
const exposureOptions = allowKeys(top.exposure, "exposure", ["mode", "iso", "shutterSeconds", "compensationStops"]);
|
|
@@ -1020,7 +1080,7 @@ async function runCapture(options, idealReference) {
|
|
|
1020
1080
|
else
|
|
1021
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.");
|
|
1022
1082
|
// White balance
|
|
1023
|
-
let whiteBalanceState = null, needsAuto = false;
|
|
1083
|
+
let whiteBalanceState = null, needsAuto = false, kelvinWhiteBalanceInput;
|
|
1024
1084
|
if (whiteBalanceOption === "none")
|
|
1025
1085
|
whiteBalanceState = null;
|
|
1026
1086
|
else if (whiteBalanceOption === "auto") {
|
|
@@ -1028,17 +1088,16 @@ async function runCapture(options, idealReference) {
|
|
|
1028
1088
|
fail("output.region cannot be combined with output.whiteBalance \"auto\": grey-world statistics of a region differ from the whole frame's.");
|
|
1029
1089
|
needsAuto = true;
|
|
1030
1090
|
}
|
|
1031
|
-
else if (whiteBalanceOption === "scene-illuminant")
|
|
1032
|
-
|
|
1033
|
-
whiteBalanceState = resolveCustomWhiteBalance({ stateId: "facade-wb-scene-illuminant", sampleSet: { imageStateId: "facade-white-diffuser",
|
|
1034
|
-
inputDomain: "relative-pre-wb-camera-linear-rgb", samples: [{ sampleId: "white", red: camera[0], green: camera[1], blue: camera[2], weight: 1, clipped: false }] } });
|
|
1035
|
-
}
|
|
1091
|
+
else if (whiteBalanceOption === "scene-illuminant")
|
|
1092
|
+
whiteBalanceState = sceneIlluminantWhiteBalance(illuminant);
|
|
1036
1093
|
else if (typeof whiteBalanceOption === "object" && whiteBalanceOption !== null && "kelvin" in whiteBalanceOption) {
|
|
1037
1094
|
const wb = allowKeys(whiteBalanceOption, "output.whiteBalance", ["kelvin", "tint"]);
|
|
1038
1095
|
const intent = createColorTemperatureWhiteBalanceIntent({ intentId: "facade-wb-cct", colorTemperatureKelvin: positive(wb.kelvin, "output.whiteBalance.kelvin"),
|
|
1039
1096
|
tint: wb.tint === undefined ? 0 : wb.tint });
|
|
1040
|
-
|
|
1041
|
-
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;
|
|
1042
1101
|
}
|
|
1043
1102
|
else if (typeof whiteBalanceOption === "object" && whiteBalanceOption !== null && "channelGains" in whiteBalanceOption) {
|
|
1044
1103
|
allowKeys(whiteBalanceOption, "output.whiteBalance", ["channelGains"]);
|
|
@@ -1236,6 +1295,8 @@ async function runCapture(options, idealReference) {
|
|
|
1236
1295
|
const rawProvider = diffraction && diffractionObjectSpace ? createDiffractedPlanarProvider({ base: provider, psf: diffractionPsf.configuration.psf,
|
|
1237
1296
|
wavelengthsNm: diffraction.profile.axes.wavelengthNm, kernelRadius: diffraction.kernelRadiusSamples, nativeWidth, nativeHeight, outputWidth: width,
|
|
1238
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 } : {}),
|
|
1239
1300
|
// The environment queries each site at each aperture node minus each split offset (sites, +x right, +y down).
|
|
1240
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;
|
|
1241
1302
|
if (antiAliasing && perSite > 100_000)
|
|
@@ -1245,13 +1306,9 @@ async function runCapture(options, idealReference) {
|
|
|
1245
1306
|
const tileWidth = Math.max(1, Math.min(256, Math.floor(100_000 / perSite)));
|
|
1246
1307
|
const environmentRaw = { raw: buildRawInput(intentOf(whiteBalanceState)), sceneBinding,
|
|
1247
1308
|
maximumProviderEvaluations: Math.min(nativeRawMaximumProviderEvaluations(raisedPixels === undefined ? {} : { maximumPixels: raisedPixels }), Math.max(nativeWidth * nativeHeight, evaluations)) };
|
|
1248
|
-
//
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
if (format !== "srgb-8" && format !== "srgb-16" && format !== "png")
|
|
1252
|
-
fail("simulateIdealReference supports output.format srgb-8, srgb-16 and png: a reference has no RAW (dng) and should not carry JPEG compression artefacts.");
|
|
1253
|
-
if (needsAuto)
|
|
1254
|
-
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) {
|
|
1255
1312
|
// 1. Electrons per unit spectral radiance at each spectral node, per channel: the facade's own environment chain at a
|
|
1256
1313
|
// site next to the optical axis, with the ideal optics (no PSF, pupil, distortion, vignetting or motion). The signal
|
|
1257
1314
|
// is linear in the radiance node values and, for this planar scene, the same at every site (paraxial π/(4N_w²)).
|
|
@@ -1260,7 +1317,7 @@ async function runCapture(options, idealReference) {
|
|
|
1260
1317
|
const electronsPerRadiance = {};
|
|
1261
1318
|
for (const channel of CHANNELS) {
|
|
1262
1319
|
if (channelAt(channelSites[channel] % nativeWidth, Math.floor(channelSites[channel] / nativeWidth)) !== channel)
|
|
1263
|
-
fail("
|
|
1320
|
+
fail(caller + " needs at least a 2 × 2 native raster.");
|
|
1264
1321
|
const environment = { ...siteInput(channelSites[channel]).environment };
|
|
1265
1322
|
environment.psf = { kind: "not-applied", evidence, limitation: "Ideal reference: no lens PSF." };
|
|
1266
1323
|
environment.fieldThroughput = { kind: "unity", evidence, limitation: "Ideal reference: no vignetting." };
|
|
@@ -1288,6 +1345,20 @@ async function runCapture(options, idealReference) {
|
|
|
1288
1345
|
systemConversionGainElectronsPerCode: gain, blackLevelCode: black, digitalSaturationCode: regime.adc.digitalSaturationCode, adcMaximumCode: 2 ** regime.adc.bitDepth - 1, digitalGain }, readoutProfile, regimeId);
|
|
1289
1346
|
relativePerElectron[channel] = digitalGain / (gain * (white - black));
|
|
1290
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");
|
|
1291
1362
|
// 3. Scene radiance per sample: chart materials once (and the fixed-size chart's surround), caller functions per sample.
|
|
1292
1363
|
const signalOf = (radiance) => {
|
|
1293
1364
|
const values = new Map(wavelengths.map((nm) => [nm, radiance(nm)]));
|