@photivra/engine 2.2.1 → 2.3.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 +16 -1
- package/CITATION.cff +1 -1
- package/README.md +6 -6
- package/SECURITY.md +1 -1
- package/dist/capture/native-environment-raw.d.ts +18 -0
- package/dist/capture/native-environment-raw.js +17 -1
- package/dist/capture/native-environment-raw.js.map +1 -1
- package/dist/capture/native-raw.d.ts +3 -0
- package/dist/capture/native-raw.js +8 -5
- package/dist/capture/native-raw.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-prime-lenses.d.ts +46 -0
- package/dist/equipment/generic-prime-lenses.js +59 -0
- package/dist/equipment/generic-prime-lenses.js.map +1 -0
- package/dist/facade/chart-scenes.d.ts +270 -1
- package/dist/facade/chart-scenes.js +443 -17
- package/dist/facade/chart-scenes.js.map +1 -1
- package/dist/facade/scene-radiance.d.ts +47 -0
- package/dist/facade/scene-radiance.js +65 -5
- package/dist/facade/scene-radiance.js.map +1 -1
- package/dist/facade/simulate-capture.d.ts +151 -4
- package/dist/facade/simulate-capture.js +296 -55
- package/dist/facade/simulate-capture.js.map +1 -1
- package/dist/facade/subject-framing.d.ts +55 -0
- package/dist/facade/subject-framing.js +66 -0
- package/dist/facade/subject-framing.js.map +1 -0
- package/dist/gpu/chart-radiance-gpu.d.ts +8 -0
- package/dist/gpu/chart-radiance-gpu.js +20 -1
- package/dist/gpu/chart-radiance-gpu.js.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/docs/API_REFERENCE.md +48 -1
- package/docs/QUICK_START.md +264 -3
- package/docs/api/core-version.md +1 -1
- package/docs/api/equipment-generic-prime-lenses.md +85 -0
- package/docs/api/exports.json +167 -2
- package/docs/api/facade-chart-scenes.md +280 -4
- package/docs/api/facade-scene-radiance.md +42 -0
- package/docs/api/facade-simulate-capture.md +107 -2
- package/docs/api/facade-subject-framing.md +72 -0
- package/package.json +1 -1
|
@@ -44,7 +44,7 @@ import { createColorTemperatureWhiteBalanceIntent, resolveColorTemperatureWhiteB
|
|
|
44
44
|
import { createCaptureWhiteBalanceIntent } from "../capture/simulated-capture.js";
|
|
45
45
|
import { SENSOR_RAW_PRODUCER_NOISE_MODEL } from "../capture/sensor-raw-producer.js";
|
|
46
46
|
import { calculateNativeRawPlan, nativeRawMaximumProviderEvaluations, NATIVE_RAW_LIMITS } from "../capture/native-raw.js";
|
|
47
|
-
import { createNativeEnvironmentRawTask } from "../capture/native-environment-raw.js";
|
|
47
|
+
import { createNativeEnvironmentRawTask, createNativeEnvironmentRawRegionTask } from "../capture/native-environment-raw.js";
|
|
48
48
|
import { createNativeRawDevelopmentTask } from "../capture/native-raw-development.js";
|
|
49
49
|
import { parseSdrRenderingProfile } from "../output/sdr-rendering.js";
|
|
50
50
|
import { createPrintJpegTask, PRINT_JPEG_LIMITS } from "../output/print-jpeg.js";
|
|
@@ -66,21 +66,27 @@ import { calculateCaptureExposureWindows } from "../sensor/exposure-window.js";
|
|
|
66
66
|
import { calculateSensorSpectralQuadrature } from "../sensor/spectral-quadrature.js";
|
|
67
67
|
import { createDiffractionLimitedPsfProfile } from "../optics/diffraction-psf-profile.js";
|
|
68
68
|
import { createDiffractedPlanarProvider } from "./diffraction-provider.js";
|
|
69
|
+
import { resolveGenericPrimeLens } from "../equipment/generic-prime-lenses.js";
|
|
70
|
+
import { calculateEnvironmentSensorPhotoSignal } from "../sensor/environment-photo-signal.js";
|
|
71
|
+
import { calculateSiteEffectiveSaturationCode } from "../capture/raw-white-level.js";
|
|
72
|
+
import { prepareSensorColorDevelopment } from "../color/sensor-color-development.js";
|
|
69
73
|
import { resolveAntiAliasingSpatialKernel } from "../sensor/optical-stack.js";
|
|
70
74
|
import { multiplyMatrices } from "../color/colorimetry-math.js";
|
|
71
75
|
import { prepareRgbGamutMapping } from "../color/gamut-mapping.js";
|
|
72
76
|
import { serializeNativeRawDng } from "../capture/native-raw-dng.js";
|
|
73
77
|
import { parseSensorReadoutConversionProfile, resolveSensorReadoutRegime } from "../sensor/raw-readout.js";
|
|
74
|
-
import { resolveChartScene, CHART_SCENE_PRESETS } from "./chart-scenes.js";
|
|
75
|
-
import { resolveCaptureIlluminant, calculateIlluminantIrradianceScale, buildPlanarSceneRadianceProvider, requireCallerScene, FACADE_SPECTRAL_RANGE_NANOMETERS, PHOTOPIC_MAXIMUM_LUMINOUS_EFFICACY_LM_PER_W } from "./scene-radiance.js";
|
|
78
|
+
import { resolveChartScene, chartMaterialInputs, CHART_SCENE_PRESETS } from "./chart-scenes.js";
|
|
79
|
+
import { resolveCaptureIlluminant, calculateIlluminantIrradianceScale, buildPlanarSceneRadianceProvider, requireCallerScene, FACADE_SPECTRAL_RANGE_NANOMETERS, resolveChartSizeOption, PHOTOPIC_MAXIMUM_LUMINOUS_EFFICACY_LM_PER_W } from "./scene-radiance.js";
|
|
76
80
|
/** Version of how the facade builds requests. It changes whenever the same input would build a different request. */
|
|
77
81
|
export const SIMULATE_CAPTURE_FACADE_VERSION = "1.0.0";
|
|
82
|
+
/** Default midpoint samples per axis of `sensor.pixelAperture: "box"`. */
|
|
83
|
+
export const SIMULATE_CAPTURE_BOX_APERTURE_DEFAULT_SAMPLES = 4;
|
|
78
84
|
/**
|
|
79
85
|
* Options reported in `diagnostics.options` (2.1), in this order, when they ran: the ones that change how much work
|
|
80
86
|
* a capture does. `motion.rotation` is a non-zero angular velocity; `sensor.temporalSampleCount` a count above 1.
|
|
81
87
|
*/
|
|
82
88
|
export const SIMULATE_CAPTURE_COST_OPTIONS = Object.freeze(["lens.pupilSampling", "lens.projection", "lens.distortion", "lens.vignetting", "lens.psf",
|
|
83
|
-
"lens.diffraction", "sensor.antiAliasing", "sensor.temporalSampleCount", "motion.rotation", "motion.shutter.rolling", "output.borderHandling.mirror",
|
|
89
|
+
"lens.diffraction", "sensor.antiAliasing", "sensor.pixelAperture.box", "sensor.temporalSampleCount", "motion.rotation", "motion.shutter.rolling", "output.borderHandling.mirror", "output.region",
|
|
84
90
|
"output.whiteBalance.auto", "output.demosaic.ahd", "output.highlightMode.reconstruct", "output.colorEncoding.wide-gamut", "output.gamutMapping.perceptual",
|
|
85
91
|
"output.format.jpeg", "output.format.png", "output.format.dng"]);
|
|
86
92
|
const FACADE_EVIDENCE = Object.freeze([Object.freeze({ sourceOrigin: "photivra",
|
|
@@ -167,6 +173,14 @@ function parseAntiAliasing(value, pitch) {
|
|
|
167
173
|
}
|
|
168
174
|
return fail("sensor.antiAliasing.kind must be four-spot, two-spot or point-splitting.");
|
|
169
175
|
}
|
|
176
|
+
/** Midpoint nodes of an N × N rule over a square photosite, as offsets from its centre in sites (+x right, +y down). */
|
|
177
|
+
function apertureNodeOffsets(n) {
|
|
178
|
+
const out = [];
|
|
179
|
+
for (let j = 0; j < n; j++)
|
|
180
|
+
for (let i = 0; i < n; i++)
|
|
181
|
+
out.push({ x: (i + 0.5) / n - 0.5, y: (j + 0.5) / n - 0.5 });
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
170
184
|
/** Third dark ring of the Airy pattern in units of λ·N_w (j₁,₃/π, j₁,₃ = 10.1734681351): the default diffraction kernel reach. */
|
|
171
185
|
const AIRY_THIRD_ZERO_PER_LAMBDA_N = 10.173468135062722 / Math.PI;
|
|
172
186
|
/** Default temporal midpoints when the camera rotates (2.0 motion option). */
|
|
@@ -443,9 +457,24 @@ function encodeSdrFromLinear(rgb, encoding, bitDepth, mapping) {
|
|
|
443
457
|
* underlying request/plan, provenance and every assumption made. See docs/QUICK_START.md.
|
|
444
458
|
*/
|
|
445
459
|
export async function simulateCapture(options) {
|
|
460
|
+
return await runCapture(options, false);
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* The ideal reference of a capture request (2.3): the same scene, framing, chart size, output size, region and pixel
|
|
464
|
+
* grid as `simulateCapture(options)` would use, rendered with no camera effects (no lens blur, diffraction, defocus,
|
|
465
|
+
* distortion, vignetting, anti-aliasing filter, mosaic or demosaic, noise, dark signal or motion), at the capture's
|
|
466
|
+
* own exposure and developed with its white balance, colour matrices, tone curve and encoding (the
|
|
467
|
+
* `reference.exposure` assumption). Deterministic and cheap: no RAW is simulated. Formats srgb-8, srgb-16 and png.
|
|
468
|
+
* The result is labelled `kind: "ideal-reference"`; it is not a capture.
|
|
469
|
+
* @see docs/QUICK_START.md, "Ideal reference".
|
|
470
|
+
*/
|
|
471
|
+
export async function simulateIdealReference(options) {
|
|
472
|
+
return await runCapture(options, true);
|
|
473
|
+
}
|
|
474
|
+
async function runCapture(options, idealReference) {
|
|
446
475
|
const started = Date.now();
|
|
447
476
|
requireInputObject(options, "simulateCapture options");
|
|
448
|
-
const top = allowKeys(options, "simulateCapture options", ["camera", "lens", "exposure", "scene", "subjectDistanceM", "illuminant",
|
|
477
|
+
const top = allowKeys(options, "simulateCapture options", ["camera", "lens", "exposure", "scene", "subjectDistanceM", "chartSize", "illuminant",
|
|
449
478
|
"illuminanceLux", "output", "sensor", "motion", "signal", "onProgress"]);
|
|
450
479
|
const signal = top.signal, onProgress = top.onProgress;
|
|
451
480
|
if (signal !== undefined && (typeof signal !== "object" || signal === null || typeof signal.aborted !== "boolean"))
|
|
@@ -472,16 +501,37 @@ export async function simulateCapture(options) {
|
|
|
472
501
|
preset = parseCallerCameraPreset(cameraOption);
|
|
473
502
|
const body = preset.body, lens = preset.lens;
|
|
474
503
|
// Lens
|
|
475
|
-
const lensOptions = allowKeys(top.lens, "lens", ["focalLengthMm", "fNumber", "focusDistanceM", "pupilSampling", "projection", "distortion", "vignetting", "psf", "diffraction"]);
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
504
|
+
const lensOptions = allowKeys(top.lens, "lens", ["focalLengthMm", "fNumber", "focusDistanceM", "pupilSampling", "projection", "distortion", "vignetting", "psf", "diffraction", "prime"]);
|
|
505
|
+
// Generic prime (2.3): resolved on the simulated frame's size, which the output and sensor sections validate again below.
|
|
506
|
+
let prime;
|
|
507
|
+
if (lensOptions.prime !== undefined) {
|
|
508
|
+
if (lensOptions.focalLengthMm !== undefined)
|
|
509
|
+
fail("lens.prime and lens.focalLengthMm cannot both be given: the prime sets the focal length.");
|
|
510
|
+
const o = (typeof top.output === "object" && top.output !== null ? top.output : {}), sn = (typeof top.sensor === "object" && top.sensor !== null ? top.sensor : {});
|
|
511
|
+
const frameW = positive(o.width ?? DEFAULTS.width, "output.width"), frameH = positive(o.height ?? DEFAULTS.height, "output.height"), framePitch = positive(sn.pixelPitchMicrometers ?? DEFAULTS.pixelPitchMicrometers, "sensor.pixelPitchMicrometers");
|
|
512
|
+
if (typeof lensOptions.prime !== "number")
|
|
513
|
+
fail("lens.prime must be a generic prime's full-frame-equivalent focal length in mm.");
|
|
514
|
+
prime = resolveGenericPrimeLens({ equivalentFocalLengthMm: lensOptions.prime, activeImagingArea: { widthMm: frameW * framePitch / 1000, heightMm: frameH * framePitch / 1000 } });
|
|
515
|
+
assume("lens.prime", "model", { lensId: prime.lensId, equivalentFocalLengthMm: prime.equivalentFocalLengthMm, focalLengthMm: prime.focalLengthMm, cropFactor35Mm: prime.cropFactor35Mm,
|
|
516
|
+
widestFNumber: prime.widestFNumber, narrowestFNumber: prime.narrowestFNumber, minimumFocusDistanceM: prime.minimumFocusDistanceM }, "Generic prime (not a real product): focal length = equivalent ÷ the simulated frame's diagonal crop factor, f/" + prime.widestFNumber + "–f/" + prime.narrowestFNumber +
|
|
517
|
+
", minimum focus max(0.10 m, f·(1 + 1/0.15)) (thin-lens approximation). It replaces the tier lens's focal length and f-number range; the tier lens's transmission is kept.");
|
|
518
|
+
}
|
|
519
|
+
const lensExposure = prime ? { ...lens.exposure, profileId: "photivra-" + prime.lensId + ":exposure",
|
|
520
|
+
focalLengthMmRange: { ...lens.exposure.focalLengthMmRange, value: { minimum: prime.focalLengthMm, maximum: prime.focalLengthMm } },
|
|
521
|
+
aperture: { ...lens.exposure.aperture, widestAvailableFNumber: { kind: "constant", fNumber: { value: prime.widestFNumber, evidence: FACADE_EVIDENCE } },
|
|
522
|
+
narrowestAvailableFNumber: { value: prime.narrowestFNumber, evidence: FACADE_EVIDENCE } } } : lens.exposure;
|
|
523
|
+
const focalDefault = lensExposure.focalLengthMmRange.value.minimum;
|
|
524
|
+
const focalLengthMm = prime ? prime.focalLengthMm :
|
|
525
|
+
positive(pick(lensOptions, "focalLengthMm", "lens.focalLengthMm", focalDefault, "The preset lens's focal length (the generic primes are 50 mm)."), "lens.focalLengthMm");
|
|
526
|
+
const capabilities = resolveGenericEquipmentExposureCapabilities({ bodyProfile: body.exposure, lensProfile: lensExposure, selectedFocalLengthMm: focalLengthMm });
|
|
479
527
|
const subjectDistanceM = positive(pick(top, "subjectDistanceM", "subjectDistanceM", DEFAULTS.subjectDistanceM, "Scene plane 5 m in front of the camera (the generic tiers' reference focus distance)."), "subjectDistanceM");
|
|
480
528
|
if (subjectDistanceM * 1000 <= focalLengthMm * 2)
|
|
481
529
|
fail("subjectDistanceM must exceed twice the focal length.");
|
|
482
530
|
const focusDistanceM = positive(pick(lensOptions, "focusDistanceM", "lens.focusDistanceM", subjectDistanceM, "Focused on the scene plane."), "lens.focusDistanceM");
|
|
483
531
|
if (focusDistanceM * 1000 <= focalLengthMm)
|
|
484
532
|
fail("lens.focusDistanceM must exceed the focal length.");
|
|
533
|
+
if (prime && (subjectDistanceM < prime.minimumFocusDistanceM || focusDistanceM < prime.minimumFocusDistanceM))
|
|
534
|
+
fail("The " + prime.lensId + " focuses no closer than " + prime.minimumFocusDistanceM + " m (max(0.10 m, f·(1 + 1/0.15))); subjectDistanceM and lens.focusDistanceM must be at least that.");
|
|
485
535
|
let pupil;
|
|
486
536
|
if (lensOptions.pupilSampling === undefined)
|
|
487
537
|
assume("lens.pupilSampling", "default", null, "No pupil sampling: each scene query follows the central (chief) ray, so defocus and depth of field are not rendered. Enable lens.pupilSampling for an ideal circular pupil.");
|
|
@@ -570,7 +620,7 @@ export async function simulateCapture(options) {
|
|
|
570
620
|
const diffractionObjectSpace = diffractionRadiusOption !== undefined && !rotating && !lensFieldMapping && !vignetting;
|
|
571
621
|
// Output and sensor
|
|
572
622
|
const outputOptions = allowKeys(top.output, "output", ["width", "height", "format", "toneCurve", "toneCurveParameters", "highlightMode", "whiteBalance", "jpegQuantizationStep",
|
|
573
|
-
"colorEncoding", "bitDepth", "gamutMapping", "demosaic", "borderHandling", "dng", "maximumPixels"]);
|
|
623
|
+
"colorEncoding", "bitDepth", "gamutMapping", "demosaic", "borderHandling", "dng", "maximumPixels", "region"]);
|
|
574
624
|
// Opt-in large images: a caller-raised native pixel limit. Without it the 4094-pixel sides are unchanged.
|
|
575
625
|
const raisedPixels = outputOptions.maximumPixels === undefined ? undefined : integerIn(outputOptions.maximumPixels, "output.maximumPixels", 1, NATIVE_RAW_LIMITS.ceilingPixels);
|
|
576
626
|
const maximumSide = raisedPixels === undefined ? 4094 : NATIVE_RAW_LIMITS.maximumDimension;
|
|
@@ -619,7 +669,7 @@ export async function simulateCapture(options) {
|
|
|
619
669
|
const jpegStep = format === "jpeg" ? integerIn(pick(outputOptions, "jpegQuantizationStep", "output.jpegQuantizationStep", DEFAULTS.jpegQuantizationStep, "Constant JPEG quantization step 2 (not a manufacturer quality scale)."), "output.jpegQuantizationStep", 1, 255) : undefined;
|
|
620
670
|
if (format !== "jpeg" && outputOptions.jpegQuantizationStep !== undefined)
|
|
621
671
|
fail("output.jpegQuantizationStep applies only to output.format \"jpeg\".");
|
|
622
|
-
const sensorOptions = allowKeys(top.sensor, "sensor", ["pixelPitchMicrometers", "spectralStepNanometers", "temporalSampleCount", "noiseSeed", "antiAliasing"]);
|
|
672
|
+
const sensorOptions = allowKeys(top.sensor, "sensor", ["pixelPitchMicrometers", "spectralStepNanometers", "temporalSampleCount", "noiseSeed", "antiAliasing", "pixelAperture"]);
|
|
623
673
|
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");
|
|
624
674
|
if (pitch < 0.5 || pitch > 100)
|
|
625
675
|
fail("sensor.pixelPitchMicrometers must be 0.5-100.");
|
|
@@ -630,6 +680,20 @@ export async function simulateCapture(options) {
|
|
|
630
680
|
pick(sensorOptions, "temporalSampleCount", "sensor.temporalSampleCount", DEFAULTS.temporalSampleCount, "One temporal sample: the static scene and still camera make more samples unnecessary."), "sensor.temporalSampleCount", 1, 256);
|
|
631
681
|
const noiseSeed = integerIn(pick(sensorOptions, "noiseSeed", "sensor.noiseSeed", DEFAULTS.noiseSeed, "Fixed noise seed 1 (deterministic output)."), "sensor.noiseSeed", 0, 0xffffffff);
|
|
632
682
|
const antiAliasing = sensorOptions.antiAliasing === undefined ? undefined : parseAntiAliasing(sensorOptions.antiAliasing, pitch);
|
|
683
|
+
// Pixel aperture (2.3): N × N midpoint nodes over the square photosite; 1 = the default centre sample.
|
|
684
|
+
let boxSamples = 1;
|
|
685
|
+
if (sensorOptions.pixelAperture !== undefined && sensorOptions.pixelAperture !== "point") {
|
|
686
|
+
const a = sensorOptions.pixelAperture;
|
|
687
|
+
if (a === "box")
|
|
688
|
+
boxSamples = SIMULATE_CAPTURE_BOX_APERTURE_DEFAULT_SAMPLES;
|
|
689
|
+
else {
|
|
690
|
+
const record = allowKeys(a, "sensor.pixelAperture", ["kind", "samplesPerAxis"]);
|
|
691
|
+
if (record.kind !== "box")
|
|
692
|
+
fail("sensor.pixelAperture must be \"point\", \"box\" or { kind: \"box\", samplesPerAxis }.");
|
|
693
|
+
boxSamples = record.samplesPerAxis === undefined ? SIMULATE_CAPTURE_BOX_APERTURE_DEFAULT_SAMPLES : integerIn(record.samplesPerAxis, "sensor.pixelAperture.samplesPerAxis", 1, 16);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
const boxAperture = sensorOptions.pixelAperture !== undefined && sensorOptions.pixelAperture !== "point";
|
|
633
697
|
const colorSamplingProfile = { schemaVersion: "0.1.0", profileId: IDS.cfa, evidence: FACADE_EVIDENCE, coordinateSystem: "native-sensor-color-sampling-site-index",
|
|
634
698
|
layout: { kind: "periodic-mosaic", repeatWidthSites: 2, repeatHeightSites: 2, siteChannelIds: ["red", "green", "green", "blue"], anchor: "native-sensor-top-left-site" } };
|
|
635
699
|
// Demosaic (2.0): built-in Bayer kernels and border handling. Omitted, the 1.0 bilinear kernels and a
|
|
@@ -650,12 +714,31 @@ export async function simulateCapture(options) {
|
|
|
650
714
|
fail("output.demosaic \"ahd\" with mirror borders needs an output of at least 3 × 3 pixels.");
|
|
651
715
|
// Geometry: the demosaic border (if any) on each side; output crop is the active area.
|
|
652
716
|
const nativeWidth = width + 2 * margin, nativeHeight = height + 2 * margin;
|
|
717
|
+
// Region (2.3): a rectangle of the whole frame. Its native sites are the region plus the demosaic reach on each side
|
|
718
|
+
// (clipped to the raster, where mirror borders apply exactly as in the whole render), with the origin rounded down to
|
|
719
|
+
// the 2 × 2 CFA repeat so the region raster keeps the frame's CFA phase.
|
|
720
|
+
let region, regionNative;
|
|
721
|
+
if (outputOptions.region !== undefined) {
|
|
722
|
+
const r = allowKeys(outputOptions.region, "output.region", ["x", "y", "width", "height"]);
|
|
723
|
+
const x = integerIn(r.x, "output.region.x", 0, width - 1), y = integerIn(r.y, "output.region.y", 0, height - 1);
|
|
724
|
+
region = { x, y, width: integerIn(r.width, "output.region.width", 1, width - x), height: integerIn(r.height, "output.region.height", 1, height - y) };
|
|
725
|
+
const reach = margin > 0 ? margin : ahd ? AHD_MARGIN : builtInDemosaic ? builtInDemosaic.kernelReachSites : MARGIN;
|
|
726
|
+
let x0 = Math.max(0, region.x + margin - reach), y0 = Math.max(0, region.y + margin - reach);
|
|
727
|
+
x0 -= x0 % 2;
|
|
728
|
+
y0 -= y0 % 2;
|
|
729
|
+
regionNative = { x: x0, y: y0, width: Math.min(nativeWidth, region.x + region.width + margin + reach) - x0, height: Math.min(nativeHeight, region.y + region.height + margin + reach) - y0 };
|
|
730
|
+
}
|
|
731
|
+
const outputWidth = region ? region.width : width, outputHeight = region ? region.height : height;
|
|
732
|
+
if (region && regionNative)
|
|
733
|
+
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), " +
|
|
734
|
+
"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. " +
|
|
735
|
+
"With lens.diffraction the object-space convolution runs over whole-width bands of 32 native rows that the region touches.");
|
|
653
736
|
if (raisedPixels !== undefined) {
|
|
654
737
|
if (nativeWidth > NATIVE_RAW_LIMITS.maximumDimension || nativeHeight > NATIVE_RAW_LIMITS.maximumDimension || nativeWidth * nativeHeight > raisedPixels) {
|
|
655
738
|
fail("output.maximumPixels (" + raisedPixels + ") must cover the native sensor raster of " + nativeWidth + " × " + nativeHeight +
|
|
656
739
|
" sites (the output plus the demosaic margin), at most " + NATIVE_RAW_LIMITS.maximumDimension + " per side.");
|
|
657
740
|
}
|
|
658
|
-
if (ahd && nativeWidth * nativeHeight > PROCESSING_MAX_PIXELS)
|
|
741
|
+
if (ahd && (regionNative ? regionNative.width * regionNative.height : nativeWidth * nativeHeight) > PROCESSING_MAX_PIXELS)
|
|
659
742
|
fail("output.demosaic \"ahd\" admits at most " + PROCESSING_MAX_PIXELS + " native sites.");
|
|
660
743
|
}
|
|
661
744
|
const imagingArea = { widthMm: nativeWidth * pitch / 1000, heightMm: nativeHeight * pitch / 1000 };
|
|
@@ -665,7 +748,24 @@ export async function simulateCapture(options) {
|
|
|
665
748
|
const imageDistanceMm = focalLengthMm * focusDistanceM * 1000 / (focusDistanceM * 1000 - focalLengthMm);
|
|
666
749
|
const metresPerFrameUnit = (width * pitch / 2000) * subjectDistanceM / imageDistanceMm;
|
|
667
750
|
const aspect = height / width;
|
|
668
|
-
|
|
751
|
+
// Fixed-size chart (2.3): validated here, before any scene work.
|
|
752
|
+
const chartSize = top.chartSize === undefined ? undefined : resolveChartSizeOption(top.chartSize, aspect);
|
|
753
|
+
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." :
|
|
754
|
+
"The scene plane is perpendicular to the optical axis and the chart is sized to fill the active frame exactly.");
|
|
755
|
+
let chartReport;
|
|
756
|
+
if (chartSize) {
|
|
757
|
+
for (const key of chartSize.defaulted)
|
|
758
|
+
assume("chartSize." + key, "default", chartSize[key], key === "heightM" ?
|
|
759
|
+
"The chart has the frame's shape: height = width × output height / output width." : "An 18 % neutral grey plane surrounds the chart.");
|
|
760
|
+
const magnification = imageDistanceMm / (subjectDistanceM * 1000), framedWidthM = 2 * metresPerFrameUnit, framedHeightM = framedWidthM * aspect;
|
|
761
|
+
chartReport = { widthM: chartSize.widthM, heightM: chartSize.heightM, surroundReflectance: chartSize.surroundReflectance,
|
|
762
|
+
frameWidthFraction: chartSize.widthM / framedWidthM, frameHeightFraction: chartSize.heightM / framedHeightM, magnification,
|
|
763
|
+
imageWidthMm: chartSize.widthM * 1000 * magnification, imageHeightMm: chartSize.heightM * 1000 * magnification,
|
|
764
|
+
imageWidthPixels: chartSize.widthM * 1e6 * magnification / pitch, imageHeightPixels: chartSize.heightM * 1e6 * magnification / pitch };
|
|
765
|
+
assume("scene.chartSize", "model", chartReport, "Fixed-size chart: the chart is widthM × heightM metres on the scene plane, centred on the optical axis, and the rest of the plane is a neutral Lambertian surround under the same light. " +
|
|
766
|
+
"Its image size follows the facade's thin-lens framing: magnification m = v/d with the image distance v = f·s/(s − f) at the focus distance s and the subject distance d, so the frame spans " +
|
|
767
|
+
"sensor width / m on the plane. frameWidthFraction and frameHeightFraction above 1 mean the chart is larger than the frame and cropped.");
|
|
768
|
+
}
|
|
669
769
|
if (demosaicOption === undefined && borderOption === undefined) {
|
|
670
770
|
assume("output.demosaic", "model", "bilinear-rggb", "Bilinear RGGB demosaic through explicit linear kernels; the native raster has a one-site border on each side so every output pixel has full support.");
|
|
671
771
|
}
|
|
@@ -687,7 +787,7 @@ export async function simulateCapture(options) {
|
|
|
687
787
|
const psfTaps = lensPsf ? Math.max(1, ...(lensPsf.configuration.psf.profile.nodes ?? [])
|
|
688
788
|
.map((node) => Number(node.kernel?.widthSamples) * Number(node.kernel?.heightSamples)).filter((taps) => Number.isSafeInteger(taps) && taps > 0)) : 1;
|
|
689
789
|
const queriesPerSite = temporalSampleCount * (Math.ceil((FACADE_SPECTRAL_RANGE_NANOMETERS.maximum - FACADE_SPECTRAL_RANGE_NANOMETERS.minimum) / spectralStep) + 1) *
|
|
690
|
-
(pupil ? pupil.radialSamples * pupil.angularSamples : 1) * psfTaps * (antiAliasing ? antiAliasing.components.length : 1);
|
|
790
|
+
(pupil ? pupil.radialSamples * pupil.angularSamples : 1) * psfTaps * (antiAliasing ? antiAliasing.components.length : 1) * boxSamples * boxSamples;
|
|
691
791
|
// Scene and illuminant
|
|
692
792
|
// The scene is the one required input: the facade never guesses what is being photographed.
|
|
693
793
|
if (top.scene === undefined)
|
|
@@ -704,9 +804,12 @@ export async function simulateCapture(options) {
|
|
|
704
804
|
callerScene = requireCallerScene(sceneOption);
|
|
705
805
|
}
|
|
706
806
|
else {
|
|
707
|
-
chart = resolveChartScene({ scene: sceneOption, aspect });
|
|
807
|
+
chart = resolveChartScene({ scene: sceneOption, aspect: chartSize ? chartSize.heightM / chartSize.widthM : aspect });
|
|
808
|
+
// Composite charts (2.3) report nested defaults by dotted path, for example "elements.2.parameters.cycles".
|
|
809
|
+
const parameterAt = (key) => key in chart.parameters ? chart.parameters[key] :
|
|
810
|
+
key.split(".").reduce((value, part) => value?.[part], chart.parameters);
|
|
708
811
|
for (const key of chart.defaultedParameters)
|
|
709
|
-
assume("scene." + key, "default",
|
|
812
|
+
assume("scene." + key, "default", parameterAt(key), "Chart default for " + chart.preset + ".");
|
|
710
813
|
}
|
|
711
814
|
const scenePreset = chart ? chart.preset : callerScene.kind;
|
|
712
815
|
// Caller spectra enter the digest as data (see spectrumDigestForm). Without caller spectra the description is unchanged,
|
|
@@ -714,21 +817,19 @@ export async function simulateCapture(options) {
|
|
|
714
817
|
const illuminantRecord = typeof illuminantOption === "object" ? illuminantOption : undefined;
|
|
715
818
|
const illuminantSpectrum = illuminantRecord?.kind === "spectrum" ? { spectrum: spectrumDigestForm(illuminant.spectrum, illuminant.shape) } :
|
|
716
819
|
illuminantRecord?.kind === "daylight" ? { basisDatasetId: illuminantRecord.basis?.datasetId, spectrum: spectrumDigestForm(illuminant.spectrum, illuminant.shape) } : undefined;
|
|
717
|
-
const
|
|
718
|
-
|
|
719
|
-
return [];
|
|
720
|
-
const raw = chart.preset === "uniform" ? sceneRecord.reflectance : sceneRecord.patches[index].reflectance;
|
|
721
|
-
return [{ material: index, spectrum: spectrumDigestForm(raw, m.spectrum) }];
|
|
722
|
-
}) : [];
|
|
820
|
+
const materialInputs = chart ? chartMaterialInputs(chart) : [];
|
|
821
|
+
const sceneSpectra = chart ? chart.materials.flatMap((m, index) => m.kind !== "spectrum" ? [] : [{ material: index, spectrum: spectrumDigestForm(materialInputs[index], m.spectrum) }]) : [];
|
|
723
822
|
const sceneDescription = { facadeVersion: SIMULATE_CAPTURE_FACADE_VERSION, scene: chart ? { preset: chart.preset, parameters: chart.parameters } :
|
|
724
823
|
{ kind: callerScene.kind, sceneId: callerScene.sceneId }, illuminant: illuminant.id, illuminanceLux, subjectDistanceM, framedWidthM: 2 * metresPerFrameUnit, aspect,
|
|
824
|
+
...(chartSize ? { chartSize: { widthM: chartSize.widthM, heightM: chartSize.heightM, surroundReflectance: chartSize.surroundReflectance } } : {}),
|
|
725
825
|
...(illuminantSpectrum === undefined ? {} : { illuminantSpectrum }), ...(sceneSpectra.length === 0 ? {} : { sceneSpectra }) };
|
|
726
826
|
if (callerScene)
|
|
727
827
|
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.");
|
|
728
828
|
const sourceSha256 = await sha256Hex(JSON.stringify(sceneDescription, (_key, value) => (typeof value === "object" && value !== null && "evaluate" in value ? "caller-spectrum" : value)));
|
|
729
829
|
checkAbort();
|
|
730
830
|
const provider = buildPlanarSceneRadianceProvider({ scene: chart ?? callerScene, sceneId: IDS.scene, providerProfileId: IDS.provider,
|
|
731
|
-
illuminant, irradianceScale, geometry: { distanceM: subjectDistanceM, metresPerFrameUnit
|
|
831
|
+
illuminant, irradianceScale, geometry: { distanceM: subjectDistanceM, metresPerFrameUnit,
|
|
832
|
+
...(chartSize ? { chart: { widthM: chartSize.widthM, heightM: chartSize.heightM, surroundReflectance: chartSize.surroundReflectance } } : {}) }, evidence: FACADE_EVIDENCE,
|
|
732
833
|
limitations: ["Planar Lambertian scene under uniform illumination; no shadows, inter-reflection, gloss or fluorescence."] });
|
|
733
834
|
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.");
|
|
734
835
|
// Metering (CIPA DC-004 standard output sensitivity and camera equation) and exposure-mode resolution
|
|
@@ -768,6 +869,8 @@ export async function simulateCapture(options) {
|
|
|
768
869
|
let fNumber, shutterSeconds;
|
|
769
870
|
if (mode === "manual" || mode === "aperture-priority") {
|
|
770
871
|
fNumber = positive(pick(lensOptions, "fNumber", "lens.fNumber", DEFAULTS.fNumber, "f/8."), "lens.fNumber");
|
|
872
|
+
if (prime && (fNumber < prime.widestFNumber || fNumber > prime.narrowestFNumber))
|
|
873
|
+
fail("lens.fNumber " + fNumber + " is outside the " + prime.lensId + " range f/" + prime.widestFNumber + "–f/" + prime.narrowestFNumber + ".");
|
|
771
874
|
}
|
|
772
875
|
else {
|
|
773
876
|
if (lensOptions.fNumber !== undefined)
|
|
@@ -830,6 +933,12 @@ export async function simulateCapture(options) {
|
|
|
830
933
|
}
|
|
831
934
|
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.");
|
|
832
935
|
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 %).");
|
|
936
|
+
if (boxAperture) {
|
|
937
|
+
assume("sensor.pixelAperture", "model", { kind: "box", samplesPerAxis: boxSamples, fillFactor: 1 }, "Each site integrates the irradiance uniformly over its square photosite (fill factor 1; the environment's uniform geometric-aperture response) with a " + boxSamples + " × " + boxSamples +
|
|
938
|
+
" midpoint rule (spatialSampleCountX/Y). The ideal box aperture of width p has the pixel MTF |sinc(f·p)| = |sin(πfp)/(πfp)|; the midpoint rule gives |sin(πfp)/(N·sin(πfp/N))| " +
|
|
939
|
+
"instead, within 3 % of it below the Nyquist frequency 1/(2p) for N ≥ 4, with the same zeros at multiples of 1/p except at multiples of N/p, where scene detail still aliases. " +
|
|
940
|
+
"No microlens, carrier diffusion or crosstalk. The scene queries are multiplied by " + boxSamples * boxSamples + ".");
|
|
941
|
+
}
|
|
833
942
|
if (!antiAliasing)
|
|
834
943
|
assume("sensor.fillFactor", "model", 1, "Square sensitive aperture equal to the pixel pitch; no microlens or anti-aliasing filter.");
|
|
835
944
|
else {
|
|
@@ -885,8 +994,11 @@ export async function simulateCapture(options) {
|
|
|
885
994
|
let whiteBalanceState = null, needsAuto = false;
|
|
886
995
|
if (whiteBalanceOption === "none")
|
|
887
996
|
whiteBalanceState = null;
|
|
888
|
-
else if (whiteBalanceOption === "auto")
|
|
997
|
+
else if (whiteBalanceOption === "auto") {
|
|
998
|
+
if (region)
|
|
999
|
+
fail("output.region cannot be combined with output.whiteBalance \"auto\": grey-world statistics of a region differ from the whole frame's.");
|
|
889
1000
|
needsAuto = true;
|
|
1001
|
+
}
|
|
890
1002
|
else if (whiteBalanceOption === "scene-illuminant") {
|
|
891
1003
|
const xyz = illuminantXyz(illuminant), camera = XYZ_TO_CAMERA.map((row) => row[0] * xyz[0] + row[1] * xyz[1] + row[2] * xyz[2]);
|
|
892
1004
|
whiteBalanceState = resolveCustomWhiteBalance({ stateId: "facade-wb-scene-illuminant", sampleSet: { imageStateId: "facade-white-diffuser",
|
|
@@ -930,15 +1042,16 @@ export async function simulateCapture(options) {
|
|
|
930
1042
|
const exposureWindow = rollingSchedule ? createRollingShutterCaptureExposureSchedule(rollingSchedule) :
|
|
931
1043
|
{ shutterMechanism: "electronic", nominalExposureDurationSeconds: { value: shutterSeconds, unit: "s", evidence },
|
|
932
1044
|
opening: { kind: "simultaneous" }, closing: { kind: "simultaneous" } };
|
|
933
|
-
const buildRawInput = (intent) => (
|
|
934
|
-
|
|
1045
|
+
const buildRawInput = (intent) => buildRawInputFor(intent, geometry, captureModeProfile, bindingProfile, nativeWidth * nativeHeight);
|
|
1046
|
+
const buildRawInputFor = (intent, rasterGeometry, modeProfile, binding, sites) => ({
|
|
1047
|
+
exposure: { captureId: IDS.capture, sceneStateId: IDS.sceneState, sceneTimeSeconds: 0, geometry: rasterGeometry,
|
|
935
1048
|
exposure: { focalLengthMm, aperture: fNumber, shutterSeconds, iso: resolvedIso }, focus: { kind: "finite", distanceM: focusDistanceM },
|
|
936
1049
|
noise: { seedUint32: noiseSeed, realizationId: "facade-noise", model: { ...SENSOR_RAW_PRODUCER_NOISE_MODEL } },
|
|
937
1050
|
source: { kind: "scene-radiance-source", artifactId: "facade-scene-" + scenePreset, sha256: sourceSha256, dynamicRangeHistory: "no-loss-declared" },
|
|
938
1051
|
whiteBalanceIntent: intent, adoptedWhiteXyz: null,
|
|
939
1052
|
models: [{ profile: { id: "photivra-simulate-capture", version: SIMULATE_CAPTURE_FACADE_VERSION }, scientificStatus: "approximation", publicEvidenceIds: ["photivra:simulate-capture-facade"] }] },
|
|
940
|
-
frameId: IDS.frame, modeId: IDS.mode, captureModeProfile, colorSamplingProfile, bindingProfile, exposureWindow,
|
|
941
|
-
maximumOutputBytes:
|
|
1053
|
+
frameId: IDS.frame, modeId: IDS.mode, captureModeProfile: modeProfile, colorSamplingProfile, bindingProfile: binding, exposureWindow,
|
|
1054
|
+
maximumOutputBytes: sites * 7, tileWidth, ...(raisedPixels === undefined ? {} : { maximumPixels: raisedPixels })
|
|
942
1055
|
});
|
|
943
1056
|
const sceneBinding = { sceneStateId: IDS.sceneState, providerSceneId: IDS.scene, evidence };
|
|
944
1057
|
// Shared per-site environment pieces (identical objects for every site keep the native task's shared-state fast path).
|
|
@@ -1002,7 +1115,7 @@ export async function simulateCapture(options) {
|
|
|
1002
1115
|
wavelengthChangingTransportModeled: false, volumetricTransportModeled: false, polarizationModeled: false, evidence, limitations: ["Engine-authored planar chart or caller scene function.",
|
|
1003
1116
|
...(diffractionObjectSpace ? ["Radiance is the scene radiance convolved with the lens diffraction PSF mapped onto the scene plane (object-space convolution, request.diffraction)."] : [])] }
|
|
1004
1117
|
};
|
|
1005
|
-
const lensId =
|
|
1118
|
+
const lensId = lensExposure.profileId;
|
|
1006
1119
|
const opticsProfile = { schemaVersion: "0.1.0", profileId: lensId + ":facade-transmission", profileVersion: SIMULATE_CAPTURE_FACADE_VERSION, lensProfileId: lensId,
|
|
1007
1120
|
scientificStatus: "approximation", applicability: { focalLengthMm: { minimum: capabilities.selectedFocalLengthMm, maximum: capabilities.selectedFocalLengthMm },
|
|
1008
1121
|
nominalFNumber: { minimum: capabilities.aperture.widestAvailableFNumber, maximum: capabilities.aperture.narrowestAvailableFNumber }, focus: { kind: "any" } },
|
|
@@ -1049,7 +1162,7 @@ export async function simulateCapture(options) {
|
|
|
1049
1162
|
" (reaching the third dark ring at the longest node)") + "; the Airy tail energy outside it (listed per wavelength) is put back by unit-sum renormalization. " +
|
|
1050
1163
|
(diffractionObjectSpace ? "Applied as an object-space convolution: the scene radiance on the site lattice is convolved once per spectral node (calculateIrradianceConvolution) and the provider answers each site query with it, so the site inputs declare no PSF; this equals the per-tap environment PSF quadrature for this still, distortion-free planar scene (request.diffraction.psf). " :
|
|
1051
1164
|
"Applied through the environment PSF quadrature (one scene query per kernel tap), because camera rotation, a lens field mapping or vignetting moves the taps off the site lattice. ") +
|
|
1052
|
-
"No defocus, aberration or pupil clipping; the sensor pixel aperture is still point-sampled at the site centre.");
|
|
1165
|
+
"No defocus, aberration or pupil clipping; " + (boxAperture ? "the pixel aperture is integrated as declared by sensor.pixelAperture." : "the sensor pixel aperture is still point-sampled at the site centre."));
|
|
1053
1166
|
}
|
|
1054
1167
|
const psf = lensPsf ?? (diffractionObjectSpace ? { kind: "not-applied", evidence,
|
|
1055
1168
|
limitation: "No environment PSF: lens diffraction is applied upstream by the scene provider as an object-space convolution (request.diffraction)." } : undefined) ??
|
|
@@ -1064,7 +1177,7 @@ export async function simulateCapture(options) {
|
|
|
1064
1177
|
fail("Native site index out of range.");
|
|
1065
1178
|
const x = index % nativeWidth, y = Math.floor(index / nativeWidth), channel = channelAt(x, y), site = { x, y }, c = perChannel[channel], r = regimeByChannel[channel];
|
|
1066
1179
|
const environment = { temporalIntegrationId: IDS.frame + ":native:" + index,
|
|
1067
|
-
sensor: { spatialSampling: { imagingArea, samplingApertureProfile, opticalStackProfile, site, spatialSampleCountX:
|
|
1180
|
+
sensor: { spatialSampling: { imagingArea, samplingApertureProfile, opticalStackProfile, site, spatialSampleCountX: boxSamples, spatialSampleCountY: boxSamples },
|
|
1068
1181
|
spectralSampling: spectralSamplingByChannel[channel], colorSamplingProfile, spectralResponseProfile: spectralResponseProfiles[channel],
|
|
1069
1182
|
responseApplication: { applicationProfile: c.applicationProfile, sourcePlane, operatingConditions }, operatingRangeProfile: c.operatingRangeProfile, localExposure },
|
|
1070
1183
|
sceneBindings, optics, motion, temporalSampleCount, fieldThroughput, psf, ...(pupilRecord ? { pupil: pupilRecord } : {}), ...(lensFieldMapping ? { lensFieldMapping } : {}) };
|
|
@@ -1094,8 +1207,8 @@ export async function simulateCapture(options) {
|
|
|
1094
1207
|
const rawProvider = diffraction && diffractionObjectSpace ? createDiffractedPlanarProvider({ base: provider, psf: diffractionPsf.configuration.psf,
|
|
1095
1208
|
wavelengthsNm: diffraction.profile.axes.wavelengthNm, kernelRadius: diffraction.kernelRadiusSamples, nativeWidth, nativeHeight, outputWidth: width,
|
|
1096
1209
|
sitePitchMicrometers: pitch, distanceM: subjectDistanceM, metresPerFrameUnit, onConvolution: (ms) => { diffractionMs += ms; },
|
|
1097
|
-
// The environment queries each site at
|
|
1098
|
-
...(antiAliasing ? { queryOffsetsSites: antiAliasing.components.map((c) => ({ x: -c.offsetMicrometers.x / pitch, y: -c.offsetMicrometers.y / pitch })) } : {}) }) : provider;
|
|
1210
|
+
// The environment queries each site at each aperture node minus each split offset (sites, +x right, +y down).
|
|
1211
|
+
...(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;
|
|
1099
1212
|
if (antiAliasing && perSite > 100_000)
|
|
1100
1213
|
fail("sensor.antiAliasing with these options needs " + perSite + " scene queries per site; the environment admits at most 100000.");
|
|
1101
1214
|
const evaluations = nativeWidth * nativeHeight * perSite;
|
|
@@ -1103,6 +1216,121 @@ export async function simulateCapture(options) {
|
|
|
1103
1216
|
const tileWidth = Math.max(1, Math.min(256, Math.floor(100_000 / perSite)));
|
|
1104
1217
|
const environmentRaw = { raw: buildRawInput(intentOf(whiteBalanceState)), sceneBinding,
|
|
1105
1218
|
maximumProviderEvaluations: Math.min(nativeRawMaximumProviderEvaluations(raisedPixels === undefined ? {} : { maximumPixels: raisedPixels }), Math.max(nativeWidth * nativeHeight, evaluations)) };
|
|
1219
|
+
// Ideal reference (2.3): the scene without camera effects, at the capture's own exposure, white balance and encoding.
|
|
1220
|
+
if (idealReference) {
|
|
1221
|
+
const referenceStarted = clockMs();
|
|
1222
|
+
if (format !== "srgb-8" && format !== "srgb-16" && format !== "png")
|
|
1223
|
+
fail("simulateIdealReference supports output.format srgb-8, srgb-16 and png: a reference has no RAW (dng) and should not carry JPEG compression artefacts.");
|
|
1224
|
+
if (needsAuto)
|
|
1225
|
+
fail("simulateIdealReference cannot reproduce output.whiteBalance \"auto\": the capture measures it from its noisy RAW. Use \"scene-illuminant\" or a fixed white balance.");
|
|
1226
|
+
// 1. Electrons per unit spectral radiance at each spectral node, per channel: the facade's own environment chain at a
|
|
1227
|
+
// site next to the optical axis, with the ideal optics (no PSF, pupil, distortion, vignetting or motion). The signal
|
|
1228
|
+
// is linear in the radiance node values and, for this planar scene, the same at every site (paraxial π/(4N_w²)).
|
|
1229
|
+
const cx = Math.floor(nativeWidth / 2) & ~1, cy = Math.floor(nativeHeight / 2) & ~1;
|
|
1230
|
+
const channelSites = { red: cy * nativeWidth + cx, green: cy * nativeWidth + Math.min(cx + 1, nativeWidth - 1), blue: Math.min(cy + 1, nativeHeight - 1) * nativeWidth + Math.min(cx + 1, nativeWidth - 1) };
|
|
1231
|
+
const electronsPerRadiance = {};
|
|
1232
|
+
for (const channel of CHANNELS) {
|
|
1233
|
+
if (channelAt(channelSites[channel] % nativeWidth, Math.floor(channelSites[channel] / nativeWidth)) !== channel)
|
|
1234
|
+
fail("simulateIdealReference needs at least a 2 × 2 native raster.");
|
|
1235
|
+
const environment = { ...siteInput(channelSites[channel]).environment };
|
|
1236
|
+
environment.psf = { kind: "not-applied", evidence, limitation: "Ideal reference: no lens PSF." };
|
|
1237
|
+
environment.fieldThroughput = { kind: "unity", evidence, limitation: "Ideal reference: no vignetting." };
|
|
1238
|
+
environment.motion = { ...motion, angularVelocityRadPerSec: { pitch: 0, yaw: 0, roll: 0 } };
|
|
1239
|
+
environment.temporalSampleCount = 1;
|
|
1240
|
+
delete environment.pupil;
|
|
1241
|
+
delete environment.lensFieldMapping;
|
|
1242
|
+
const electrons = (radiance) => calculateEnvironmentSensorPhotoSignal({ ...environment,
|
|
1243
|
+
evaluateRadiance: (request) => ({ ...provider.evaluateRadiance(request), spectralRadianceWattsPerSquareMeterSteradianNanometer: radiance(request.wavelengthNanometers) })
|
|
1244
|
+
}).value
|
|
1245
|
+
.photo.value.photoSignal.expectedGeneratedElectronCount;
|
|
1246
|
+
const nodes = new Set();
|
|
1247
|
+
electrons((nm) => { nodes.add(nm); return 1; });
|
|
1248
|
+
electronsPerRadiance[channel] = new Map([...nodes].sort((a, b) => a - b).map((node) => [node, electrons((nm) => (nm === node ? 1 : 0))]));
|
|
1249
|
+
}
|
|
1250
|
+
const wavelengths = [...new Set(CHANNELS.flatMap((c) => [...electronsPerRadiance[c].keys()]))].sort((a, b) => a - b);
|
|
1251
|
+
// 2. Electrons → normalized RAW signal: (code − black)/(white − black) = e·d/(G·(W − B)) for the linear readout
|
|
1252
|
+
// (conversion gain G e⁻/DN, digital gain d, black B, effective white W of the capture's readout regime).
|
|
1253
|
+
const relativePerElectron = {};
|
|
1254
|
+
for (const channel of CHANNELS) {
|
|
1255
|
+
const { readoutProfile, regimeId } = regimeByChannel[channel];
|
|
1256
|
+
const regime = resolveSensorReadoutRegime({ profile: readoutProfile, regimeId }).regime;
|
|
1257
|
+
const gain = regime.systemConversionGainElectronsPerCode.value, digitalGain = regime.adc.digitalGain ?? 1, black = regime.adc.blackLevelCode;
|
|
1258
|
+
const white = calculateSiteEffectiveSaturationCode({ physicalChargeCapacityElectrons: capacityElectrons, preAdcSaturationElectronEquivalent: regime.preAdcSaturationElectronEquivalent.value,
|
|
1259
|
+
systemConversionGainElectronsPerCode: gain, blackLevelCode: black, digitalSaturationCode: regime.adc.digitalSaturationCode, adcMaximumCode: 2 ** regime.adc.bitDepth - 1, digitalGain }, readoutProfile, regimeId);
|
|
1260
|
+
relativePerElectron[channel] = digitalGain / (gain * (white - black));
|
|
1261
|
+
}
|
|
1262
|
+
// 3. Scene radiance per sample: chart materials once (and the fixed-size chart's surround), caller functions per sample.
|
|
1263
|
+
const signalOf = (radiance) => {
|
|
1264
|
+
const values = new Map(wavelengths.map((nm) => [nm, radiance(nm)]));
|
|
1265
|
+
return CHANNELS.map((c) => { let e = 0; for (const [nm, a] of electronsPerRadiance[c])
|
|
1266
|
+
e += a * values.get(nm); return e * relativePerElectron[c]; });
|
|
1267
|
+
};
|
|
1268
|
+
const materialSignals = provider.chartMaterialAt ? Array.from({ length: chart.materials.length + (chartSize ? 1 : 0) }, (_, m) => signalOf((nm) => provider.materialRadiance(m, nm))) : undefined;
|
|
1269
|
+
// 4. Pixels: the native sites of the output (or region) pixels, each centre-sampled or box-integrated like the capture.
|
|
1270
|
+
const x0 = (region ? region.x : 0) + margin, y0 = (region ? region.y : 0) + margin, n = boxSamples, unit = 2 / width;
|
|
1271
|
+
const referenceIntent = intentOf(whiteBalanceState), referenceGains = referenceIntent ? [referenceIntent.channelGains.red, referenceIntent.channelGains.green, referenceIntent.channelGains.blue] : null;
|
|
1272
|
+
const development = prepareSensorColorDevelopment(CAMERA_TO_XYZ, referenceGains, highlightMode);
|
|
1273
|
+
const rgb = new Float64Array(outputWidth * outputHeight * 3), counts = { whiteBalanceClippedSampleCount: 0, reconstructedPixelCount: 0 }, sum = [0, 0, 0];
|
|
1274
|
+
for (let py = 0; py < outputHeight; py++) {
|
|
1275
|
+
for (let px = 0; px < outputWidth; px++) {
|
|
1276
|
+
sum[0] = 0;
|
|
1277
|
+
sum[1] = 0;
|
|
1278
|
+
sum[2] = 0;
|
|
1279
|
+
for (let l = 0; l < n; l++)
|
|
1280
|
+
for (let k = 0; k < n; k++) {
|
|
1281
|
+
const u = (x0 + px + (k + 0.5) / n - nativeWidth / 2) * unit, v = -(y0 + py + (l + 0.5) / n - nativeHeight / 2) * unit;
|
|
1282
|
+
const s = materialSignals ? materialSignals[provider.chartMaterialAt(u, v)] : signalOf((nm) => provider.radianceAt(u, v, nm));
|
|
1283
|
+
sum[0] += s[0];
|
|
1284
|
+
sum[1] += s[1];
|
|
1285
|
+
sum[2] += s[2];
|
|
1286
|
+
}
|
|
1287
|
+
// Normalized RAW values saturate at the white level, as in development.
|
|
1288
|
+
const c = sum.map((value) => Math.min(1, Math.max(0, value / (n * n))));
|
|
1289
|
+
development.developInto(c[0], c[1], c[2], rgb, (py * outputWidth + px) * 3, counts);
|
|
1290
|
+
}
|
|
1291
|
+
if ((py & 15) === 15) {
|
|
1292
|
+
await yieldControl();
|
|
1293
|
+
checkAbort();
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
let samples;
|
|
1297
|
+
if (!linearPath) {
|
|
1298
|
+
const maximum = 2 ** rendering.bitDepth - 1, tone = prepareSdrPixelTone(rendering), scratch = new Float64Array(3);
|
|
1299
|
+
samples = rendering.bitDepth === 8 ? new Uint8Array(rgb.length) : new Uint16Array(rgb.length);
|
|
1300
|
+
const sdrCounts = { negative: 0, above: 0, changed: 0, low: 0, high: 0, outOfGamut: false };
|
|
1301
|
+
for (let y = 0; y < outputHeight; y++)
|
|
1302
|
+
renderSdrRunInto(rgb, y * outputWidth * 3, outputWidth, rendering, tone, 2 ** rendering.renderingExposureEv, maximum, samples, y * outputWidth * 3, scratch, sdrCounts);
|
|
1303
|
+
}
|
|
1304
|
+
else {
|
|
1305
|
+
const mapping = perceptual ? prepareRgbGamutMapping({ colorSpace: colorEncoding, method: "cielab-hue-preserving-chroma-compression", kneeFraction: PERCEPTUAL_GAMUT_KNEE_FRACTION }) : undefined;
|
|
1306
|
+
samples = encodeSdrFromLinear(rgb, colorEncoding, pngBitDepth, mapping).samples;
|
|
1307
|
+
}
|
|
1308
|
+
const png = format === "png" ? serializePngImage({ width: outputWidth, height: outputHeight, bitDepth: pngBitDepth, samples, colourEncoding: pngColourEncoding(colorEncoding),
|
|
1309
|
+
...(raisedPixels === undefined || outputWidth * outputHeight <= PNG_ENCODER_LIMITS.maximumPixels ? {} : { maximumPixels: raisedPixels }) }) : undefined;
|
|
1310
|
+
const referenceMs = clockMs() - referenceStarted;
|
|
1311
|
+
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.");
|
|
1312
|
+
assume("reference.excludes", "model", ["lens PSF, diffraction, defocus and pupil sampling", "projection and distortion (ideal rectilinear mapping)", "vignetting", "anti-aliasing filter",
|
|
1313
|
+
"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.");
|
|
1314
|
+
assume("reference.exposure", "model", { convention: "exposure-matched", fNumber, shutterSeconds, iso: resolvedIso, illuminanceLux,
|
|
1315
|
+
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), " +
|
|
1316
|
+
"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. " +
|
|
1317
|
+
"So a uniform patch renders at the capture's mean level, and differences from the capture are the camera effects plus noise. Chosen over a fixed reflectance → sRGB mapping because that would differ from the capture by the exposure, which is not a camera artefact.");
|
|
1318
|
+
assume("reference.sampling", "model", boxAperture ? { kind: "box", samplesPerAxis: n } : { kind: "point" }, boxAperture ? "Each pixel averages the scene over its square photosite with the same " + n + " × " + n + " midpoint rule as sensor.pixelAperture \"box\"." :
|
|
1319
|
+
"Each pixel samples the scene at its centre, like the capture's default point aperture; use sensor.pixelAperture \"box\" for photosite-averaged pixels.");
|
|
1320
|
+
return Object.freeze({ kind: "ideal-reference", label: "Ideal reference (no camera effects); not a capture.", facadeVersion: SIMULATE_CAPTURE_FACADE_VERSION, engineApiVersion: ENGINE_API_VERSION,
|
|
1321
|
+
image: { width: outputWidth, height: outputHeight, format, samples, ...(png ? { png, colorEncoding: colorEncoding } : {}) },
|
|
1322
|
+
exposure: { mode, settings: { fNumber, shutterSeconds, iso: resolvedIso }, ev100: Math.log2(fNumber ** 2 / shutterSeconds) },
|
|
1323
|
+
scene: { sceneId: chart ? "chart:" + chart.preset : callerScene.sceneId, preset: scenePreset, parameters: chart ? chart.parameters : {},
|
|
1324
|
+
illuminant: { id: illuminant.id, description: illuminant.description, illuminanceLux }, distanceM: subjectDistanceM,
|
|
1325
|
+
framedWidthM: 2 * metresPerFrameUnit, framedHeightM: 2 * metresPerFrameUnit * aspect, ...(chartReport ? { chart: chartReport } : {}), sourceSha256 },
|
|
1326
|
+
...(region && regionNative ? { region: { ...region, frameWidth: width, frameHeight: height } } : {}),
|
|
1327
|
+
diagnostics: { sampleCount: outputWidth * outputHeight * n * n, timingsMs: { reference: referenceMs, total: Date.now() - started } },
|
|
1328
|
+
provenance: { scientificStatus: "approximation", cameraPresetId: preset.presetId, cameraPresetVersion: preset.presetVersion, evidence: [...FACADE_EVIDENCE, ...preset.evidence],
|
|
1329
|
+
composedContracts: ["calculateEnvironmentSensorPhotoSignal", "resolveSensorReadoutRegime", "calculateSiteEffectiveSaturationCode", "prepareSensorColorDevelopment",
|
|
1330
|
+
...(linearPath ? ["resolveRgbColorSpace", ...(perceptual ? ["prepareRgbGamutMapping"] : [])] : ["SDR rendering kernel"]), ...(png ? ["serializePngImage"] : [])],
|
|
1331
|
+
limitations: ["Ideal reference of the planar scene at the capture's exposure: not a capture and not a measurement; it shows what the camera effects change, not an absolute colour truth."] },
|
|
1332
|
+
assumptions: Object.freeze(assumptions.map((a) => Object.freeze(a))) });
|
|
1333
|
+
}
|
|
1106
1334
|
async function runTask(task) {
|
|
1107
1335
|
const onAbort = () => task.cancel();
|
|
1108
1336
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
@@ -1127,13 +1355,25 @@ export async function simulateCapture(options) {
|
|
|
1127
1355
|
let completed = 0;
|
|
1128
1356
|
return () => { completed++; onProgress?.({ stage, completedTileCount: completed, totalTileCount: total }); };
|
|
1129
1357
|
};
|
|
1130
|
-
const rawTick = progress("raw", plan.tileCount);
|
|
1131
|
-
const
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1358
|
+
const rawTick = progress("raw", regionNative ? regionNative.height * Math.ceil(regionNative.width / tileWidth) : plan.tileCount);
|
|
1359
|
+
const rawCallbacks = { readTile: async (request) => { const tile = await readTile(request); rawTick(); return tile; },
|
|
1360
|
+
evaluateRadiance: rawProvider.evaluateRadiance, ...(pupil ? { evaluateApertureRadiance: provider.evaluateApertureRadiance } : {}), yieldControl };
|
|
1361
|
+
const rawTask = regionNative ? createNativeEnvironmentRawRegionTask(environmentRaw, regionNative, rawCallbacks) :
|
|
1362
|
+
createNativeEnvironmentRawTask(environmentRaw, rawCallbacks);
|
|
1135
1363
|
await runTask(rawTask);
|
|
1136
1364
|
let rawOutput = rawTask.takeOutput();
|
|
1365
|
+
// Region (2.3): the region's sites, re-described by a plan of the region raster (active area = the region), for development.
|
|
1366
|
+
let developmentPlan = plan;
|
|
1367
|
+
if (region && regionNative) {
|
|
1368
|
+
const raster = { pixelWidth: regionNative.width, pixelHeight: regionNative.height };
|
|
1369
|
+
const regionGeometry = { imagingArea: { widthMm: raster.pixelWidth * pitch / 1000, heightMm: raster.pixelHeight * pitch / 1000 }, nativeRaster: raster, orientation: "landscape",
|
|
1370
|
+
activeCaptureRect: { x: region.x + margin - regionNative.x, y: region.y + margin - regionNative.y, width: region.width, height: region.height },
|
|
1371
|
+
outputRaster: { pixelWidth: region.width, pixelHeight: region.height } };
|
|
1372
|
+
developmentPlan = calculateNativeRawPlan(buildRawInputFor(intentOf(whiteBalanceState), regionGeometry, { ...captureModeProfile, modes: captureModeProfile.modes.map((m) => ({ ...m, processedImageRaster: { ...m.processedImageRaster, value: raster } })) }, { ...bindingProfile, nativeRaster: raster }, raster.pixelWidth * raster.pixelHeight));
|
|
1373
|
+
if (Array.from(rawOutput.raw.effectiveSaturationCodes).some((code) => code === 0))
|
|
1374
|
+
fail("output.region needs every CFA phase in its native rectangle.");
|
|
1375
|
+
rawOutput = { ...rawOutput, raw: { ...rawOutput.raw, plan: developmentPlan } };
|
|
1376
|
+
}
|
|
1137
1377
|
const providerEvaluationCount = rawOutput.providerEvaluationCount;
|
|
1138
1378
|
const rawMs = Date.now() - rawStarted, wbStarted = Date.now();
|
|
1139
1379
|
let finalRawInput = environmentRaw;
|
|
@@ -1188,7 +1428,7 @@ export async function simulateCapture(options) {
|
|
|
1188
1428
|
// Development
|
|
1189
1429
|
const development = { phaseProfiles, colorProfile,
|
|
1190
1430
|
whiteBalance: developmentWhiteBalance, rendering, highlightMode, ...(mirrorBorder ? { borderHandling: "mirror" } : {}),
|
|
1191
|
-
maximumRetainedPayloadBytes:
|
|
1431
|
+
maximumRetainedPayloadBytes: developmentPlan.outputBytes + outputWidth * outputHeight * 3 * (rendering.bitDepth / 8) };
|
|
1192
1432
|
let developed;
|
|
1193
1433
|
let gamutMappingDiagnostics;
|
|
1194
1434
|
if (ahd) {
|
|
@@ -1204,10 +1444,10 @@ export async function simulateCapture(options) {
|
|
|
1204
1444
|
if (!linearPath) {
|
|
1205
1445
|
// The native development's SDR kernel, row by row, on the AHD-developed linear sRGB.
|
|
1206
1446
|
const maximum = 2 ** rendering.bitDepth - 1, tone = prepareSdrPixelTone(rendering), scratch = new Float64Array(3);
|
|
1207
|
-
const codes = rendering.bitDepth === 8 ? new Uint8Array(
|
|
1447
|
+
const codes = rendering.bitDepth === 8 ? new Uint8Array(outputWidth * outputHeight * 3) : new Uint16Array(outputWidth * outputHeight * 3);
|
|
1208
1448
|
const counts = { negative: 0, above: 0, changed: 0, low: 0, high: 0, outOfGamut: false };
|
|
1209
|
-
for (let y = 0; y <
|
|
1210
|
-
renderSdrRunInto(linear.rgb, y *
|
|
1449
|
+
for (let y = 0; y < outputHeight; y++)
|
|
1450
|
+
renderSdrRunInto(linear.rgb, y * outputWidth * 3, outputWidth, rendering, tone, 2 ** rendering.renderingExposureEv, maximum, codes, y * outputWidth * 3, scratch, counts);
|
|
1211
1451
|
developed = { raw: rawOutput.raw, integerSamples: codes, highlight: linear.highlight, diagnostics: { renderingNegativeSampleCount: counts.negative,
|
|
1212
1452
|
renderingAboveReferenceSampleCount: counts.above, toneChangedSampleCount: counts.changed, gamutClippedLowSampleCount: counts.low,
|
|
1213
1453
|
gamutClippedHighSampleCount: counts.high, captureSaturation: "consumed-in-raw-development" } };
|
|
@@ -1258,18 +1498,18 @@ export async function simulateCapture(options) {
|
|
|
1258
1498
|
if (format === "jpeg") {
|
|
1259
1499
|
const samples = developed.integerSamples;
|
|
1260
1500
|
const jpegSha = await sha256Hex(Array.from(samples.subarray(0, Math.min(samples.length, 4096))).join(",") + ":" + samples.length + ":" + sourceSha256);
|
|
1261
|
-
const source = { captureId: IDS.capture, imageStateId: "facade-developed", artifactId: "facade-developed-srgb8", sha256: jpegSha, width, height, encoding: "encoded-srgb-8-rgb" };
|
|
1501
|
+
const source = { captureId: IDS.capture, imageStateId: "facade-developed", artifactId: "facade-developed-srgb8", sha256: jpegSha, width: outputWidth, height: outputHeight, encoding: "encoded-srgb-8-rgb" };
|
|
1262
1502
|
// The encoder reads every pixel once, in source tiles of at most 256 × 32 aligned to the crop origin.
|
|
1263
|
-
const reads = Math.ceil(
|
|
1264
|
-
const jpegTask = createPrintJpegTask({ source, crop: { x: 0, y: 0, width, height }, outputWidth
|
|
1265
|
-
maximumEncodedBytes: Math.max(65536,
|
|
1503
|
+
const reads = Math.ceil(outputHeight / 32) * Math.ceil(outputWidth / 256), jpegTick = progress("jpeg", reads);
|
|
1504
|
+
const jpegTask = createPrintJpegTask({ source, crop: { x: 0, y: 0, width: outputWidth, height: outputHeight }, outputWidth, outputHeight, quantizationStep: jpegStep,
|
|
1505
|
+
maximumEncodedBytes: Math.max(65536, outputWidth * outputHeight * 6), maximumProviderReads: reads,
|
|
1266
1506
|
// The JPEG records its admission plan, so the raised limit is passed (and recorded) only when the image needs it.
|
|
1267
|
-
...(raisedPixels === undefined ||
|
|
1507
|
+
...(raisedPixels === undefined || outputWidth * outputHeight <= PRINT_JPEG_LIMITS.maximumPixels ? {} : { maximumPixels: raisedPixels }) }, {
|
|
1268
1508
|
readTile: async (request) => {
|
|
1269
1509
|
jpegTick();
|
|
1270
1510
|
const out = new Uint8Array(request.width * request.height * 3);
|
|
1271
1511
|
for (let row = 0; row < request.height; row++)
|
|
1272
|
-
out.set(samples.subarray(((request.y + row) *
|
|
1512
|
+
out.set(samples.subarray(((request.y + row) * outputWidth + request.x) * 3, ((request.y + row) * outputWidth + request.x + request.width) * 3), row * request.width * 3);
|
|
1273
1513
|
return { ...request, samples: out };
|
|
1274
1514
|
}, yieldControl
|
|
1275
1515
|
});
|
|
@@ -1283,30 +1523,31 @@ export async function simulateCapture(options) {
|
|
|
1283
1523
|
let png;
|
|
1284
1524
|
if (format === "png") {
|
|
1285
1525
|
const pngStarted = clockMs();
|
|
1286
|
-
png = serializePngImage({ width, height, bitDepth: pngBitDepth, samples: developed.integerSamples, colourEncoding: pngColourEncoding(colorEncoding),
|
|
1287
|
-
...(raisedPixels === undefined ||
|
|
1526
|
+
png = serializePngImage({ width: outputWidth, height: outputHeight, bitDepth: pngBitDepth, samples: developed.integerSamples, colourEncoding: pngColourEncoding(colorEncoding),
|
|
1527
|
+
...(raisedPixels === undefined || outputWidth * outputHeight <= PNG_ENCODER_LIMITS.maximumPixels ? {} : { maximumPixels: raisedPixels }) });
|
|
1288
1528
|
encodeMs = clockMs() - pngStarted;
|
|
1289
1529
|
assume("output.png", "model", { encoder: "serializePngImage", bitDepth: pngBitDepth, colourEncoding: pngColourEncoding(colorEncoding) }, "Deterministic PNG (8/16-bit RGB, in-house zlib) with the colour encoding signalled in the file: the sRGB chunk for sRGB, otherwise a cICP chunk with ITU-T H.273 primaries/transfer code points. No mastering-display (mDCV) or content-light-level (cLLI) metadata is written.");
|
|
1290
1530
|
}
|
|
1291
1531
|
const ranOptions = new Set([...(pupil ? ["lens.pupilSampling"] : []), ...(projection !== undefined ? ["lens.projection"] : []),
|
|
1292
1532
|
...(distortion ? ["lens.distortion"] : []), ...(vignetting ? ["lens.vignetting"] : []), ...(lensPsf ? ["lens.psf"] : []),
|
|
1293
|
-
...(diffraction ? ["lens.diffraction"] : []), ...(antiAliasing ? ["sensor.antiAliasing"] : []),
|
|
1533
|
+
...(diffraction ? ["lens.diffraction"] : []), ...(antiAliasing ? ["sensor.antiAliasing"] : []), ...(boxAperture ? ["sensor.pixelAperture.box"] : []),
|
|
1294
1534
|
...(temporalSampleCount > 1 ? ["sensor.temporalSampleCount"] : []), ...(rotating ? ["motion.rotation"] : []),
|
|
1295
|
-
...(rollingSchedule ? ["motion.shutter.rolling"] : []), ...(mirrorBorder ? ["output.borderHandling.mirror"] : []),
|
|
1535
|
+
...(rollingSchedule ? ["motion.shutter.rolling"] : []), ...(mirrorBorder ? ["output.borderHandling.mirror"] : []), ...(region ? ["output.region"] : []),
|
|
1296
1536
|
...(needsAuto ? ["output.whiteBalance.auto"] : []), ...(ahd ? ["output.demosaic.ahd"] : []),
|
|
1297
1537
|
...(highlightMode === "reconstruct" ? ["output.highlightMode.reconstruct"] : []), ...(wideGamut ? ["output.colorEncoding.wide-gamut"] : []),
|
|
1298
1538
|
...(perceptual ? ["output.gamutMapping.perceptual"] : []), ...(format === "jpeg" || format === "png" || format === "dng" ? ["output.format." + format] : [])]);
|
|
1299
1539
|
return Object.freeze({
|
|
1300
1540
|
facadeVersion: SIMULATE_CAPTURE_FACADE_VERSION, engineApiVersion: ENGINE_API_VERSION,
|
|
1301
|
-
image: { width, height, format, samples: developed.integerSamples, ...(jpeg ? { jpeg } : {}), ...(png ? { png, colorEncoding: colorEncoding } : {}), ...(dng ? { dng: dng.bytes } : {}) },
|
|
1302
|
-
raw: developed.raw, plan,
|
|
1541
|
+
image: { width: outputWidth, height: outputHeight, format, samples: developed.integerSamples, ...(jpeg ? { jpeg } : {}), ...(png ? { png, colorEncoding: colorEncoding } : {}), ...(dng ? { dng: dng.bytes } : {}) },
|
|
1542
|
+
raw: developed.raw, plan: developmentPlan,
|
|
1543
|
+
...(region && regionNative ? { region: { ...region, frameWidth: width, frameHeight: height, nativeRect: { ...regionNative } } } : {}),
|
|
1303
1544
|
request: { environmentRaw: finalRawInput, siteInput, development, whiteBalanceState,
|
|
1304
1545
|
...(diffraction ? { diffraction: { profile: diffraction, psf: diffractionPsf, execution: diffractionObjectSpace ? "object-space-convolution" : "environment-psf-quadrature" } } : {}) },
|
|
1305
1546
|
exposure: { mode, settings: { fNumber, shutterSeconds, iso: resolvedIso }, ev100: Math.log2(fNumber ** 2 / shutterSeconds),
|
|
1306
1547
|
meteredSceneLuminanceCdPerSquareMeter: meteredLuminance, resolution },
|
|
1307
1548
|
scene: { sceneId: chart ? "chart:" + chart.preset : callerScene.sceneId, preset: scenePreset, parameters: chart ? chart.parameters : {},
|
|
1308
1549
|
illuminant: { id: illuminant.id, description: illuminant.description, illuminanceLux }, distanceM: subjectDistanceM,
|
|
1309
|
-
framedWidthM: 2 * metresPerFrameUnit, framedHeightM: 2 * metresPerFrameUnit * aspect, sourceSha256 },
|
|
1550
|
+
framedWidthM: 2 * metresPerFrameUnit, framedHeightM: 2 * metresPerFrameUnit * aspect, ...(chartReport ? { chart: chartReport } : {}), sourceSha256 },
|
|
1310
1551
|
diagnostics: { providerEvaluationCount, highlight: developed.highlight, rendering: developed.diagnostics, ...(gamutMappingDiagnostics ? { gamutMapping: gamutMappingDiagnostics } : {}),
|
|
1311
1552
|
timingsMs: { raw: rawMs, whiteBalance: wbMs, development: devMs, jpeg: jpegMs, total: Date.now() - started,
|
|
1312
1553
|
...(diffraction && diffractionObjectSpace ? { diffraction: diffractionMs } : {}), ...(demosaicMs === undefined ? {} : { demosaic: demosaicMs }),
|