@photivra/engine 2.2.1 → 2.4.1
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/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-dng.d.ts +3 -0
- package/dist/capture/native-raw-dng.js +23 -3
- package/dist/capture/native-raw-dng.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 +158 -4
- package/dist/facade/simulate-capture.js +324 -56
- 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 +6 -3
- package/dist/index.js +6 -3
- package/dist/index.js.map +1 -1
- package/dist/output/text-metadata.d.ts +32 -0
- package/dist/output/text-metadata.js +201 -0
- package/dist/output/text-metadata.js.map +1 -0
- package/docs/API_REFERENCE.md +56 -1
- package/docs/QUICK_START.md +325 -4
- package/docs/api/capture-native-raw-dng.md +2 -0
- package/docs/api/core-version.md +1 -1
- package/docs/api/equipment-generic-prime-lenses.md +85 -0
- package/docs/api/exports.json +172 -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 +113 -2
- package/docs/api/facade-subject-framing.md +72 -0
- package/docs/api/output-text-metadata.md +20 -0
- package/package.json +1 -1
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
* - motion: constant camera angular velocity and the electronic rolling-shutter exposure schedule.
|
|
25
25
|
* The 2.1 options are opt-in in the same way: `lens.diffraction`, `output.demosaic: "ahd"` and
|
|
26
26
|
* `sensor.antiAliasing` (a birefringent point-splitting low-pass filter in the sensor optical stack).
|
|
27
|
+
* The 2.4 `output.metadata` adds the caller's text to JPEG, PNG and DNG files (see `src/output/text-metadata.ts`);
|
|
28
|
+
* without it every encoded file is byte-identical to 2.3.
|
|
27
29
|
* @see docs/QUICK_START.md
|
|
28
30
|
*/
|
|
29
31
|
import { InvalidConfigurationError } from "../core/configuration-error.js";
|
|
@@ -44,7 +46,7 @@ import { createColorTemperatureWhiteBalanceIntent, resolveColorTemperatureWhiteB
|
|
|
44
46
|
import { createCaptureWhiteBalanceIntent } from "../capture/simulated-capture.js";
|
|
45
47
|
import { SENSOR_RAW_PRODUCER_NOISE_MODEL } from "../capture/sensor-raw-producer.js";
|
|
46
48
|
import { calculateNativeRawPlan, nativeRawMaximumProviderEvaluations, NATIVE_RAW_LIMITS } from "../capture/native-raw.js";
|
|
47
|
-
import { createNativeEnvironmentRawTask } from "../capture/native-environment-raw.js";
|
|
49
|
+
import { createNativeEnvironmentRawTask, createNativeEnvironmentRawRegionTask } from "../capture/native-environment-raw.js";
|
|
48
50
|
import { createNativeRawDevelopmentTask } from "../capture/native-raw-development.js";
|
|
49
51
|
import { parseSdrRenderingProfile } from "../output/sdr-rendering.js";
|
|
50
52
|
import { createPrintJpegTask, PRINT_JPEG_LIMITS } from "../output/print-jpeg.js";
|
|
@@ -59,6 +61,7 @@ import { transformOrientedRasterPointToNative } from "../output/capture-geometry
|
|
|
59
61
|
import { resolveCaptureColorModel } from "../color/capture-color.js";
|
|
60
62
|
import { evaluateBt709Oetf, evaluateSrgbEncoding, resolveRgbColorSpace } from "../color/rgb-color-spaces.js";
|
|
61
63
|
import { serializePngImage, PNG_ENCODER_LIMITS } from "../output/png-encoder.js";
|
|
64
|
+
import { addJpegTextMetadata, addPngTextMetadata, parseOutputTextMetadata } from "../output/text-metadata.js";
|
|
62
65
|
import { parseLensProjectionModel } from "../camera/lens-projection.js";
|
|
63
66
|
import { prepareEnvironmentLensFieldMapping } from "../optics/sensor-environment-query.js";
|
|
64
67
|
import { createRollingShutterCaptureExposureSchedule, resolveElectronicRollingShutterExposureSchedule } from "../sensor/readout-exposure-linkage.js";
|
|
@@ -66,21 +69,27 @@ import { calculateCaptureExposureWindows } from "../sensor/exposure-window.js";
|
|
|
66
69
|
import { calculateSensorSpectralQuadrature } from "../sensor/spectral-quadrature.js";
|
|
67
70
|
import { createDiffractionLimitedPsfProfile } from "../optics/diffraction-psf-profile.js";
|
|
68
71
|
import { createDiffractedPlanarProvider } from "./diffraction-provider.js";
|
|
72
|
+
import { resolveGenericPrimeLens } from "../equipment/generic-prime-lenses.js";
|
|
73
|
+
import { calculateEnvironmentSensorPhotoSignal } from "../sensor/environment-photo-signal.js";
|
|
74
|
+
import { calculateSiteEffectiveSaturationCode } from "../capture/raw-white-level.js";
|
|
75
|
+
import { prepareSensorColorDevelopment } from "../color/sensor-color-development.js";
|
|
69
76
|
import { resolveAntiAliasingSpatialKernel } from "../sensor/optical-stack.js";
|
|
70
77
|
import { multiplyMatrices } from "../color/colorimetry-math.js";
|
|
71
78
|
import { prepareRgbGamutMapping } from "../color/gamut-mapping.js";
|
|
72
79
|
import { serializeNativeRawDng } from "../capture/native-raw-dng.js";
|
|
73
80
|
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";
|
|
81
|
+
import { resolveChartScene, chartMaterialInputs, CHART_SCENE_PRESETS } from "./chart-scenes.js";
|
|
82
|
+
import { resolveCaptureIlluminant, calculateIlluminantIrradianceScale, buildPlanarSceneRadianceProvider, requireCallerScene, FACADE_SPECTRAL_RANGE_NANOMETERS, resolveChartSizeOption, PHOTOPIC_MAXIMUM_LUMINOUS_EFFICACY_LM_PER_W } from "./scene-radiance.js";
|
|
76
83
|
/** Version of how the facade builds requests. It changes whenever the same input would build a different request. */
|
|
77
84
|
export const SIMULATE_CAPTURE_FACADE_VERSION = "1.0.0";
|
|
85
|
+
/** Default midpoint samples per axis of `sensor.pixelAperture: "box"`. */
|
|
86
|
+
export const SIMULATE_CAPTURE_BOX_APERTURE_DEFAULT_SAMPLES = 4;
|
|
78
87
|
/**
|
|
79
88
|
* Options reported in `diagnostics.options` (2.1), in this order, when they ran: the ones that change how much work
|
|
80
89
|
* a capture does. `motion.rotation` is a non-zero angular velocity; `sensor.temporalSampleCount` a count above 1.
|
|
81
90
|
*/
|
|
82
91
|
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",
|
|
92
|
+
"lens.diffraction", "sensor.antiAliasing", "sensor.pixelAperture.box", "sensor.temporalSampleCount", "motion.rotation", "motion.shutter.rolling", "output.borderHandling.mirror", "output.region",
|
|
84
93
|
"output.whiteBalance.auto", "output.demosaic.ahd", "output.highlightMode.reconstruct", "output.colorEncoding.wide-gamut", "output.gamutMapping.perceptual",
|
|
85
94
|
"output.format.jpeg", "output.format.png", "output.format.dng"]);
|
|
86
95
|
const FACADE_EVIDENCE = Object.freeze([Object.freeze({ sourceOrigin: "photivra",
|
|
@@ -167,6 +176,14 @@ function parseAntiAliasing(value, pitch) {
|
|
|
167
176
|
}
|
|
168
177
|
return fail("sensor.antiAliasing.kind must be four-spot, two-spot or point-splitting.");
|
|
169
178
|
}
|
|
179
|
+
/** Midpoint nodes of an N × N rule over a square photosite, as offsets from its centre in sites (+x right, +y down). */
|
|
180
|
+
function apertureNodeOffsets(n) {
|
|
181
|
+
const out = [];
|
|
182
|
+
for (let j = 0; j < n; j++)
|
|
183
|
+
for (let i = 0; i < n; i++)
|
|
184
|
+
out.push({ x: (i + 0.5) / n - 0.5, y: (j + 0.5) / n - 0.5 });
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
170
187
|
/** Third dark ring of the Airy pattern in units of λ·N_w (j₁,₃/π, j₁,₃ = 10.1734681351): the default diffraction kernel reach. */
|
|
171
188
|
const AIRY_THIRD_ZERO_PER_LAMBDA_N = 10.173468135062722 / Math.PI;
|
|
172
189
|
/** Default temporal midpoints when the camera rotates (2.0 motion option). */
|
|
@@ -191,6 +208,12 @@ const XYZ_TO_CAMERA = [[EQE_SCALE.red / EQE_SCALE.green, 0, 0], [0, 1, 0], [0, 0
|
|
|
191
208
|
const DARK_CURRENT_ELECTRONS_PER_SECOND = 1;
|
|
192
209
|
const OPERATING_TEMPERATURE_C = 20;
|
|
193
210
|
function fail(message) { throw new InvalidConfigurationError(message); }
|
|
211
|
+
/** Where `output.metadata` goes in each file format (reported in the `output.metadata` assumption). */
|
|
212
|
+
const TEXT_METADATA_LAYOUT = Object.freeze({
|
|
213
|
+
jpeg: "XMP packet in APP1 (dc:description, xmp:CreatorTool, dc:rights, exif:UserComment), plus a COM segment holding the comment",
|
|
214
|
+
png: "One tEXt (printable Latin-1) or iTXt (UTF-8) chunk per field, keywords Description, Software, Copyright, Comment, before the first IDAT",
|
|
215
|
+
dng: "ImageDescription (statement, then the description) and Copyright when printable ASCII; an XMP packet in tag 700 with every field"
|
|
216
|
+
});
|
|
194
217
|
/**
|
|
195
218
|
* The readout profile with every regime's charge-domain transfer scaled by `areaScale`: system conversion gain
|
|
196
219
|
* (e⁻ per code) and pre-ADC saturation (e⁻ equivalent) are multiplied by it, so codes per unit focal-plane exposure
|
|
@@ -443,9 +466,24 @@ function encodeSdrFromLinear(rgb, encoding, bitDepth, mapping) {
|
|
|
443
466
|
* underlying request/plan, provenance and every assumption made. See docs/QUICK_START.md.
|
|
444
467
|
*/
|
|
445
468
|
export async function simulateCapture(options) {
|
|
469
|
+
return await runCapture(options, false);
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* The ideal reference of a capture request (2.3): the same scene, framing, chart size, output size, region and pixel
|
|
473
|
+
* grid as `simulateCapture(options)` would use, rendered with no camera effects (no lens blur, diffraction, defocus,
|
|
474
|
+
* distortion, vignetting, anti-aliasing filter, mosaic or demosaic, noise, dark signal or motion), at the capture's
|
|
475
|
+
* own exposure and developed with its white balance, colour matrices, tone curve and encoding (the
|
|
476
|
+
* `reference.exposure` assumption). Deterministic and cheap: no RAW is simulated. Formats srgb-8, srgb-16 and png.
|
|
477
|
+
* The result is labelled `kind: "ideal-reference"`; it is not a capture.
|
|
478
|
+
* @see docs/QUICK_START.md, "Ideal reference".
|
|
479
|
+
*/
|
|
480
|
+
export async function simulateIdealReference(options) {
|
|
481
|
+
return await runCapture(options, true);
|
|
482
|
+
}
|
|
483
|
+
async function runCapture(options, idealReference) {
|
|
446
484
|
const started = Date.now();
|
|
447
485
|
requireInputObject(options, "simulateCapture options");
|
|
448
|
-
const top = allowKeys(options, "simulateCapture options", ["camera", "lens", "exposure", "scene", "subjectDistanceM", "illuminant",
|
|
486
|
+
const top = allowKeys(options, "simulateCapture options", ["camera", "lens", "exposure", "scene", "subjectDistanceM", "chartSize", "illuminant",
|
|
449
487
|
"illuminanceLux", "output", "sensor", "motion", "signal", "onProgress"]);
|
|
450
488
|
const signal = top.signal, onProgress = top.onProgress;
|
|
451
489
|
if (signal !== undefined && (typeof signal !== "object" || signal === null || typeof signal.aborted !== "boolean"))
|
|
@@ -472,16 +510,37 @@ export async function simulateCapture(options) {
|
|
|
472
510
|
preset = parseCallerCameraPreset(cameraOption);
|
|
473
511
|
const body = preset.body, lens = preset.lens;
|
|
474
512
|
// Lens
|
|
475
|
-
const lensOptions = allowKeys(top.lens, "lens", ["focalLengthMm", "fNumber", "focusDistanceM", "pupilSampling", "projection", "distortion", "vignetting", "psf", "diffraction"]);
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
513
|
+
const lensOptions = allowKeys(top.lens, "lens", ["focalLengthMm", "fNumber", "focusDistanceM", "pupilSampling", "projection", "distortion", "vignetting", "psf", "diffraction", "prime"]);
|
|
514
|
+
// Generic prime (2.3): resolved on the simulated frame's size, which the output and sensor sections validate again below.
|
|
515
|
+
let prime;
|
|
516
|
+
if (lensOptions.prime !== undefined) {
|
|
517
|
+
if (lensOptions.focalLengthMm !== undefined)
|
|
518
|
+
fail("lens.prime and lens.focalLengthMm cannot both be given: the prime sets the focal length.");
|
|
519
|
+
const o = (typeof top.output === "object" && top.output !== null ? top.output : {}), sn = (typeof top.sensor === "object" && top.sensor !== null ? top.sensor : {});
|
|
520
|
+
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");
|
|
521
|
+
if (typeof lensOptions.prime !== "number")
|
|
522
|
+
fail("lens.prime must be a generic prime's full-frame-equivalent focal length in mm.");
|
|
523
|
+
prime = resolveGenericPrimeLens({ equivalentFocalLengthMm: lensOptions.prime, activeImagingArea: { widthMm: frameW * framePitch / 1000, heightMm: frameH * framePitch / 1000 } });
|
|
524
|
+
assume("lens.prime", "model", { lensId: prime.lensId, equivalentFocalLengthMm: prime.equivalentFocalLengthMm, focalLengthMm: prime.focalLengthMm, cropFactor35Mm: prime.cropFactor35Mm,
|
|
525
|
+
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 +
|
|
526
|
+
", 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.");
|
|
527
|
+
}
|
|
528
|
+
const lensExposure = prime ? { ...lens.exposure, profileId: "photivra-" + prime.lensId + ":exposure",
|
|
529
|
+
focalLengthMmRange: { ...lens.exposure.focalLengthMmRange, value: { minimum: prime.focalLengthMm, maximum: prime.focalLengthMm } },
|
|
530
|
+
aperture: { ...lens.exposure.aperture, widestAvailableFNumber: { kind: "constant", fNumber: { value: prime.widestFNumber, evidence: FACADE_EVIDENCE } },
|
|
531
|
+
narrowestAvailableFNumber: { value: prime.narrowestFNumber, evidence: FACADE_EVIDENCE } } } : lens.exposure;
|
|
532
|
+
const focalDefault = lensExposure.focalLengthMmRange.value.minimum;
|
|
533
|
+
const focalLengthMm = prime ? prime.focalLengthMm :
|
|
534
|
+
positive(pick(lensOptions, "focalLengthMm", "lens.focalLengthMm", focalDefault, "The preset lens's focal length (the generic primes are 50 mm)."), "lens.focalLengthMm");
|
|
535
|
+
const capabilities = resolveGenericEquipmentExposureCapabilities({ bodyProfile: body.exposure, lensProfile: lensExposure, selectedFocalLengthMm: focalLengthMm });
|
|
479
536
|
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
537
|
if (subjectDistanceM * 1000 <= focalLengthMm * 2)
|
|
481
538
|
fail("subjectDistanceM must exceed twice the focal length.");
|
|
482
539
|
const focusDistanceM = positive(pick(lensOptions, "focusDistanceM", "lens.focusDistanceM", subjectDistanceM, "Focused on the scene plane."), "lens.focusDistanceM");
|
|
483
540
|
if (focusDistanceM * 1000 <= focalLengthMm)
|
|
484
541
|
fail("lens.focusDistanceM must exceed the focal length.");
|
|
542
|
+
if (prime && (subjectDistanceM < prime.minimumFocusDistanceM || focusDistanceM < prime.minimumFocusDistanceM))
|
|
543
|
+
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
544
|
let pupil;
|
|
486
545
|
if (lensOptions.pupilSampling === undefined)
|
|
487
546
|
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 +629,7 @@ export async function simulateCapture(options) {
|
|
|
570
629
|
const diffractionObjectSpace = diffractionRadiusOption !== undefined && !rotating && !lensFieldMapping && !vignetting;
|
|
571
630
|
// Output and sensor
|
|
572
631
|
const outputOptions = allowKeys(top.output, "output", ["width", "height", "format", "toneCurve", "toneCurveParameters", "highlightMode", "whiteBalance", "jpegQuantizationStep",
|
|
573
|
-
"colorEncoding", "bitDepth", "gamutMapping", "demosaic", "borderHandling", "dng", "maximumPixels"]);
|
|
632
|
+
"colorEncoding", "bitDepth", "gamutMapping", "demosaic", "borderHandling", "dng", "maximumPixels", "region", "metadata"]);
|
|
574
633
|
// Opt-in large images: a caller-raised native pixel limit. Without it the 4094-pixel sides are unchanged.
|
|
575
634
|
const raisedPixels = outputOptions.maximumPixels === undefined ? undefined : integerIn(outputOptions.maximumPixels, "output.maximumPixels", 1, NATIVE_RAW_LIMITS.ceilingPixels);
|
|
576
635
|
const maximumSide = raisedPixels === undefined ? 4094 : NATIVE_RAW_LIMITS.maximumDimension;
|
|
@@ -588,6 +647,13 @@ export async function simulateCapture(options) {
|
|
|
588
647
|
}
|
|
589
648
|
else if (outputOptions.dng !== undefined)
|
|
590
649
|
fail("output.dng applies only to output.format \"dng\".");
|
|
650
|
+
const textMetadata = parseOutputTextMetadata(outputOptions.metadata, "output.metadata");
|
|
651
|
+
if (textMetadata && format !== "jpeg" && format !== "png" && format !== "dng")
|
|
652
|
+
fail("output.metadata applies only to output.format \"jpeg\", \"png\" or \"dng\".");
|
|
653
|
+
const assumeTextMetadata = (layout) => {
|
|
654
|
+
if (textMetadata)
|
|
655
|
+
assume("output.metadata", "model", { fields: Object.keys(textMetadata), layout }, "The caller's text, written unchanged; the engine adds no text of its own (in the DNG the description follows the existing simulated-RAW statement). Without output.metadata the file is byte-identical to 2.3.");
|
|
656
|
+
};
|
|
591
657
|
let colorEncoding, pngBitDepth;
|
|
592
658
|
if (format === "png") {
|
|
593
659
|
colorEncoding = pick(outputOptions, "colorEncoding", "output.colorEncoding", "srgb", "sRGB (W3C CSS Color 4 srgb) samples signalled with the PNG sRGB chunk (perceptual intent) and its prescribed gAMA/cHRM values.");
|
|
@@ -619,7 +685,7 @@ export async function simulateCapture(options) {
|
|
|
619
685
|
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
686
|
if (format !== "jpeg" && outputOptions.jpegQuantizationStep !== undefined)
|
|
621
687
|
fail("output.jpegQuantizationStep applies only to output.format \"jpeg\".");
|
|
622
|
-
const sensorOptions = allowKeys(top.sensor, "sensor", ["pixelPitchMicrometers", "spectralStepNanometers", "temporalSampleCount", "noiseSeed", "antiAliasing"]);
|
|
688
|
+
const sensorOptions = allowKeys(top.sensor, "sensor", ["pixelPitchMicrometers", "spectralStepNanometers", "temporalSampleCount", "noiseSeed", "antiAliasing", "pixelAperture"]);
|
|
623
689
|
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
690
|
if (pitch < 0.5 || pitch > 100)
|
|
625
691
|
fail("sensor.pixelPitchMicrometers must be 0.5-100.");
|
|
@@ -630,6 +696,20 @@ export async function simulateCapture(options) {
|
|
|
630
696
|
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
697
|
const noiseSeed = integerIn(pick(sensorOptions, "noiseSeed", "sensor.noiseSeed", DEFAULTS.noiseSeed, "Fixed noise seed 1 (deterministic output)."), "sensor.noiseSeed", 0, 0xffffffff);
|
|
632
698
|
const antiAliasing = sensorOptions.antiAliasing === undefined ? undefined : parseAntiAliasing(sensorOptions.antiAliasing, pitch);
|
|
699
|
+
// Pixel aperture (2.3): N × N midpoint nodes over the square photosite; 1 = the default centre sample.
|
|
700
|
+
let boxSamples = 1;
|
|
701
|
+
if (sensorOptions.pixelAperture !== undefined && sensorOptions.pixelAperture !== "point") {
|
|
702
|
+
const a = sensorOptions.pixelAperture;
|
|
703
|
+
if (a === "box")
|
|
704
|
+
boxSamples = SIMULATE_CAPTURE_BOX_APERTURE_DEFAULT_SAMPLES;
|
|
705
|
+
else {
|
|
706
|
+
const record = allowKeys(a, "sensor.pixelAperture", ["kind", "samplesPerAxis"]);
|
|
707
|
+
if (record.kind !== "box")
|
|
708
|
+
fail("sensor.pixelAperture must be \"point\", \"box\" or { kind: \"box\", samplesPerAxis }.");
|
|
709
|
+
boxSamples = record.samplesPerAxis === undefined ? SIMULATE_CAPTURE_BOX_APERTURE_DEFAULT_SAMPLES : integerIn(record.samplesPerAxis, "sensor.pixelAperture.samplesPerAxis", 1, 16);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
const boxAperture = sensorOptions.pixelAperture !== undefined && sensorOptions.pixelAperture !== "point";
|
|
633
713
|
const colorSamplingProfile = { schemaVersion: "0.1.0", profileId: IDS.cfa, evidence: FACADE_EVIDENCE, coordinateSystem: "native-sensor-color-sampling-site-index",
|
|
634
714
|
layout: { kind: "periodic-mosaic", repeatWidthSites: 2, repeatHeightSites: 2, siteChannelIds: ["red", "green", "green", "blue"], anchor: "native-sensor-top-left-site" } };
|
|
635
715
|
// Demosaic (2.0): built-in Bayer kernels and border handling. Omitted, the 1.0 bilinear kernels and a
|
|
@@ -650,12 +730,31 @@ export async function simulateCapture(options) {
|
|
|
650
730
|
fail("output.demosaic \"ahd\" with mirror borders needs an output of at least 3 × 3 pixels.");
|
|
651
731
|
// Geometry: the demosaic border (if any) on each side; output crop is the active area.
|
|
652
732
|
const nativeWidth = width + 2 * margin, nativeHeight = height + 2 * margin;
|
|
733
|
+
// Region (2.3): a rectangle of the whole frame. Its native sites are the region plus the demosaic reach on each side
|
|
734
|
+
// (clipped to the raster, where mirror borders apply exactly as in the whole render), with the origin rounded down to
|
|
735
|
+
// the 2 × 2 CFA repeat so the region raster keeps the frame's CFA phase.
|
|
736
|
+
let region, regionNative;
|
|
737
|
+
if (outputOptions.region !== undefined) {
|
|
738
|
+
const r = allowKeys(outputOptions.region, "output.region", ["x", "y", "width", "height"]);
|
|
739
|
+
const x = integerIn(r.x, "output.region.x", 0, width - 1), y = integerIn(r.y, "output.region.y", 0, height - 1);
|
|
740
|
+
region = { x, y, width: integerIn(r.width, "output.region.width", 1, width - x), height: integerIn(r.height, "output.region.height", 1, height - y) };
|
|
741
|
+
const reach = margin > 0 ? margin : ahd ? AHD_MARGIN : builtInDemosaic ? builtInDemosaic.kernelReachSites : MARGIN;
|
|
742
|
+
let x0 = Math.max(0, region.x + margin - reach), y0 = Math.max(0, region.y + margin - reach);
|
|
743
|
+
x0 -= x0 % 2;
|
|
744
|
+
y0 -= y0 % 2;
|
|
745
|
+
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 };
|
|
746
|
+
}
|
|
747
|
+
const outputWidth = region ? region.width : width, outputHeight = region ? region.height : height;
|
|
748
|
+
if (region && regionNative)
|
|
749
|
+
assume("output.region", "model", { ...region, frameWidth: width, frameHeight: height, nativeRect: { ...regionNative } }, "Only this rectangle of the " + width + " × " + height + " frame is rendered. Framing, metering, exposure, diffraction kernels and every site's physics, noise seeds and exposure window (rolling-shutter row timing) are those of the whole frame (absolute native indices), " +
|
|
750
|
+
"and the native rectangle includes the demosaic reach on each side (" + (margin > 0 ? margin : "mirror-border") + "), so the pixels are byte-identical to the same pixels of the whole render. " +
|
|
751
|
+
"With lens.diffraction the object-space convolution runs over whole-width bands of 32 native rows that the region touches.");
|
|
653
752
|
if (raisedPixels !== undefined) {
|
|
654
753
|
if (nativeWidth > NATIVE_RAW_LIMITS.maximumDimension || nativeHeight > NATIVE_RAW_LIMITS.maximumDimension || nativeWidth * nativeHeight > raisedPixels) {
|
|
655
754
|
fail("output.maximumPixels (" + raisedPixels + ") must cover the native sensor raster of " + nativeWidth + " × " + nativeHeight +
|
|
656
755
|
" sites (the output plus the demosaic margin), at most " + NATIVE_RAW_LIMITS.maximumDimension + " per side.");
|
|
657
756
|
}
|
|
658
|
-
if (ahd && nativeWidth * nativeHeight > PROCESSING_MAX_PIXELS)
|
|
757
|
+
if (ahd && (regionNative ? regionNative.width * regionNative.height : nativeWidth * nativeHeight) > PROCESSING_MAX_PIXELS)
|
|
659
758
|
fail("output.demosaic \"ahd\" admits at most " + PROCESSING_MAX_PIXELS + " native sites.");
|
|
660
759
|
}
|
|
661
760
|
const imagingArea = { widthMm: nativeWidth * pitch / 1000, heightMm: nativeHeight * pitch / 1000 };
|
|
@@ -665,7 +764,24 @@ export async function simulateCapture(options) {
|
|
|
665
764
|
const imageDistanceMm = focalLengthMm * focusDistanceM * 1000 / (focusDistanceM * 1000 - focalLengthMm);
|
|
666
765
|
const metresPerFrameUnit = (width * pitch / 2000) * subjectDistanceM / imageDistanceMm;
|
|
667
766
|
const aspect = height / width;
|
|
668
|
-
|
|
767
|
+
// Fixed-size chart (2.3): validated here, before any scene work.
|
|
768
|
+
const chartSize = top.chartSize === undefined ? undefined : resolveChartSizeOption(top.chartSize, aspect);
|
|
769
|
+
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." :
|
|
770
|
+
"The scene plane is perpendicular to the optical axis and the chart is sized to fill the active frame exactly.");
|
|
771
|
+
let chartReport;
|
|
772
|
+
if (chartSize) {
|
|
773
|
+
for (const key of chartSize.defaulted)
|
|
774
|
+
assume("chartSize." + key, "default", chartSize[key], key === "heightM" ?
|
|
775
|
+
"The chart has the frame's shape: height = width × output height / output width." : "An 18 % neutral grey plane surrounds the chart.");
|
|
776
|
+
const magnification = imageDistanceMm / (subjectDistanceM * 1000), framedWidthM = 2 * metresPerFrameUnit, framedHeightM = framedWidthM * aspect;
|
|
777
|
+
chartReport = { widthM: chartSize.widthM, heightM: chartSize.heightM, surroundReflectance: chartSize.surroundReflectance,
|
|
778
|
+
frameWidthFraction: chartSize.widthM / framedWidthM, frameHeightFraction: chartSize.heightM / framedHeightM, magnification,
|
|
779
|
+
imageWidthMm: chartSize.widthM * 1000 * magnification, imageHeightMm: chartSize.heightM * 1000 * magnification,
|
|
780
|
+
imageWidthPixels: chartSize.widthM * 1e6 * magnification / pitch, imageHeightPixels: chartSize.heightM * 1e6 * magnification / pitch };
|
|
781
|
+
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. " +
|
|
782
|
+
"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 " +
|
|
783
|
+
"sensor width / m on the plane. frameWidthFraction and frameHeightFraction above 1 mean the chart is larger than the frame and cropped.");
|
|
784
|
+
}
|
|
669
785
|
if (demosaicOption === undefined && borderOption === undefined) {
|
|
670
786
|
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
787
|
}
|
|
@@ -687,7 +803,7 @@ export async function simulateCapture(options) {
|
|
|
687
803
|
const psfTaps = lensPsf ? Math.max(1, ...(lensPsf.configuration.psf.profile.nodes ?? [])
|
|
688
804
|
.map((node) => Number(node.kernel?.widthSamples) * Number(node.kernel?.heightSamples)).filter((taps) => Number.isSafeInteger(taps) && taps > 0)) : 1;
|
|
689
805
|
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);
|
|
806
|
+
(pupil ? pupil.radialSamples * pupil.angularSamples : 1) * psfTaps * (antiAliasing ? antiAliasing.components.length : 1) * boxSamples * boxSamples;
|
|
691
807
|
// Scene and illuminant
|
|
692
808
|
// The scene is the one required input: the facade never guesses what is being photographed.
|
|
693
809
|
if (top.scene === undefined)
|
|
@@ -704,9 +820,12 @@ export async function simulateCapture(options) {
|
|
|
704
820
|
callerScene = requireCallerScene(sceneOption);
|
|
705
821
|
}
|
|
706
822
|
else {
|
|
707
|
-
chart = resolveChartScene({ scene: sceneOption, aspect });
|
|
823
|
+
chart = resolveChartScene({ scene: sceneOption, aspect: chartSize ? chartSize.heightM / chartSize.widthM : aspect });
|
|
824
|
+
// Composite charts (2.3) report nested defaults by dotted path, for example "elements.2.parameters.cycles".
|
|
825
|
+
const parameterAt = (key) => key in chart.parameters ? chart.parameters[key] :
|
|
826
|
+
key.split(".").reduce((value, part) => value?.[part], chart.parameters);
|
|
708
827
|
for (const key of chart.defaultedParameters)
|
|
709
|
-
assume("scene." + key, "default",
|
|
828
|
+
assume("scene." + key, "default", parameterAt(key), "Chart default for " + chart.preset + ".");
|
|
710
829
|
}
|
|
711
830
|
const scenePreset = chart ? chart.preset : callerScene.kind;
|
|
712
831
|
// Caller spectra enter the digest as data (see spectrumDigestForm). Without caller spectra the description is unchanged,
|
|
@@ -714,21 +833,19 @@ export async function simulateCapture(options) {
|
|
|
714
833
|
const illuminantRecord = typeof illuminantOption === "object" ? illuminantOption : undefined;
|
|
715
834
|
const illuminantSpectrum = illuminantRecord?.kind === "spectrum" ? { spectrum: spectrumDigestForm(illuminant.spectrum, illuminant.shape) } :
|
|
716
835
|
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
|
-
}) : [];
|
|
836
|
+
const materialInputs = chart ? chartMaterialInputs(chart) : [];
|
|
837
|
+
const sceneSpectra = chart ? chart.materials.flatMap((m, index) => m.kind !== "spectrum" ? [] : [{ material: index, spectrum: spectrumDigestForm(materialInputs[index], m.spectrum) }]) : [];
|
|
723
838
|
const sceneDescription = { facadeVersion: SIMULATE_CAPTURE_FACADE_VERSION, scene: chart ? { preset: chart.preset, parameters: chart.parameters } :
|
|
724
839
|
{ kind: callerScene.kind, sceneId: callerScene.sceneId }, illuminant: illuminant.id, illuminanceLux, subjectDistanceM, framedWidthM: 2 * metresPerFrameUnit, aspect,
|
|
840
|
+
...(chartSize ? { chartSize: { widthM: chartSize.widthM, heightM: chartSize.heightM, surroundReflectance: chartSize.surroundReflectance } } : {}),
|
|
725
841
|
...(illuminantSpectrum === undefined ? {} : { illuminantSpectrum }), ...(sceneSpectra.length === 0 ? {} : { sceneSpectra }) };
|
|
726
842
|
if (callerScene)
|
|
727
843
|
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
844
|
const sourceSha256 = await sha256Hex(JSON.stringify(sceneDescription, (_key, value) => (typeof value === "object" && value !== null && "evaluate" in value ? "caller-spectrum" : value)));
|
|
729
845
|
checkAbort();
|
|
730
846
|
const provider = buildPlanarSceneRadianceProvider({ scene: chart ?? callerScene, sceneId: IDS.scene, providerProfileId: IDS.provider,
|
|
731
|
-
illuminant, irradianceScale, geometry: { distanceM: subjectDistanceM, metresPerFrameUnit
|
|
847
|
+
illuminant, irradianceScale, geometry: { distanceM: subjectDistanceM, metresPerFrameUnit,
|
|
848
|
+
...(chartSize ? { chart: { widthM: chartSize.widthM, heightM: chartSize.heightM, surroundReflectance: chartSize.surroundReflectance } } : {}) }, evidence: FACADE_EVIDENCE,
|
|
732
849
|
limitations: ["Planar Lambertian scene under uniform illumination; no shadows, inter-reflection, gloss or fluorescence."] });
|
|
733
850
|
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
851
|
// Metering (CIPA DC-004 standard output sensitivity and camera equation) and exposure-mode resolution
|
|
@@ -768,6 +885,8 @@ export async function simulateCapture(options) {
|
|
|
768
885
|
let fNumber, shutterSeconds;
|
|
769
886
|
if (mode === "manual" || mode === "aperture-priority") {
|
|
770
887
|
fNumber = positive(pick(lensOptions, "fNumber", "lens.fNumber", DEFAULTS.fNumber, "f/8."), "lens.fNumber");
|
|
888
|
+
if (prime && (fNumber < prime.widestFNumber || fNumber > prime.narrowestFNumber))
|
|
889
|
+
fail("lens.fNumber " + fNumber + " is outside the " + prime.lensId + " range f/" + prime.widestFNumber + "–f/" + prime.narrowestFNumber + ".");
|
|
771
890
|
}
|
|
772
891
|
else {
|
|
773
892
|
if (lensOptions.fNumber !== undefined)
|
|
@@ -830,6 +949,12 @@ export async function simulateCapture(options) {
|
|
|
830
949
|
}
|
|
831
950
|
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
951
|
assume("sensor.spectralResponse", "model", { kind: "colorimetric-cmf-eqe", peakScale: EQE_SCALE }, "Idealized colorimetric sensor: channel EQE = a·{x̄, ȳ, z̄}(λ)·555/λ (the engine's analytic CIE 1931 fit), so RAW channels are proportional to CIE XYZ (Luther–Ives) and the camera matrix is diagonal. Not a real camera's response; CMF air wavelengths are used as vacuum wavelengths (difference about 0.03 %).");
|
|
952
|
+
if (boxAperture) {
|
|
953
|
+
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 +
|
|
954
|
+
" 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))| " +
|
|
955
|
+
"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. " +
|
|
956
|
+
"No microlens, carrier diffusion or crosstalk. The scene queries are multiplied by " + boxSamples * boxSamples + ".");
|
|
957
|
+
}
|
|
833
958
|
if (!antiAliasing)
|
|
834
959
|
assume("sensor.fillFactor", "model", 1, "Square sensitive aperture equal to the pixel pitch; no microlens or anti-aliasing filter.");
|
|
835
960
|
else {
|
|
@@ -885,8 +1010,11 @@ export async function simulateCapture(options) {
|
|
|
885
1010
|
let whiteBalanceState = null, needsAuto = false;
|
|
886
1011
|
if (whiteBalanceOption === "none")
|
|
887
1012
|
whiteBalanceState = null;
|
|
888
|
-
else if (whiteBalanceOption === "auto")
|
|
1013
|
+
else if (whiteBalanceOption === "auto") {
|
|
1014
|
+
if (region)
|
|
1015
|
+
fail("output.region cannot be combined with output.whiteBalance \"auto\": grey-world statistics of a region differ from the whole frame's.");
|
|
889
1016
|
needsAuto = true;
|
|
1017
|
+
}
|
|
890
1018
|
else if (whiteBalanceOption === "scene-illuminant") {
|
|
891
1019
|
const xyz = illuminantXyz(illuminant), camera = XYZ_TO_CAMERA.map((row) => row[0] * xyz[0] + row[1] * xyz[1] + row[2] * xyz[2]);
|
|
892
1020
|
whiteBalanceState = resolveCustomWhiteBalance({ stateId: "facade-wb-scene-illuminant", sampleSet: { imageStateId: "facade-white-diffuser",
|
|
@@ -930,15 +1058,16 @@ export async function simulateCapture(options) {
|
|
|
930
1058
|
const exposureWindow = rollingSchedule ? createRollingShutterCaptureExposureSchedule(rollingSchedule) :
|
|
931
1059
|
{ shutterMechanism: "electronic", nominalExposureDurationSeconds: { value: shutterSeconds, unit: "s", evidence },
|
|
932
1060
|
opening: { kind: "simultaneous" }, closing: { kind: "simultaneous" } };
|
|
933
|
-
const buildRawInput = (intent) => (
|
|
934
|
-
|
|
1061
|
+
const buildRawInput = (intent) => buildRawInputFor(intent, geometry, captureModeProfile, bindingProfile, nativeWidth * nativeHeight);
|
|
1062
|
+
const buildRawInputFor = (intent, rasterGeometry, modeProfile, binding, sites) => ({
|
|
1063
|
+
exposure: { captureId: IDS.capture, sceneStateId: IDS.sceneState, sceneTimeSeconds: 0, geometry: rasterGeometry,
|
|
935
1064
|
exposure: { focalLengthMm, aperture: fNumber, shutterSeconds, iso: resolvedIso }, focus: { kind: "finite", distanceM: focusDistanceM },
|
|
936
1065
|
noise: { seedUint32: noiseSeed, realizationId: "facade-noise", model: { ...SENSOR_RAW_PRODUCER_NOISE_MODEL } },
|
|
937
1066
|
source: { kind: "scene-radiance-source", artifactId: "facade-scene-" + scenePreset, sha256: sourceSha256, dynamicRangeHistory: "no-loss-declared" },
|
|
938
1067
|
whiteBalanceIntent: intent, adoptedWhiteXyz: null,
|
|
939
1068
|
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:
|
|
1069
|
+
frameId: IDS.frame, modeId: IDS.mode, captureModeProfile: modeProfile, colorSamplingProfile, bindingProfile: binding, exposureWindow,
|
|
1070
|
+
maximumOutputBytes: sites * 7, tileWidth, ...(raisedPixels === undefined ? {} : { maximumPixels: raisedPixels })
|
|
942
1071
|
});
|
|
943
1072
|
const sceneBinding = { sceneStateId: IDS.sceneState, providerSceneId: IDS.scene, evidence };
|
|
944
1073
|
// Shared per-site environment pieces (identical objects for every site keep the native task's shared-state fast path).
|
|
@@ -1002,7 +1131,7 @@ export async function simulateCapture(options) {
|
|
|
1002
1131
|
wavelengthChangingTransportModeled: false, volumetricTransportModeled: false, polarizationModeled: false, evidence, limitations: ["Engine-authored planar chart or caller scene function.",
|
|
1003
1132
|
...(diffractionObjectSpace ? ["Radiance is the scene radiance convolved with the lens diffraction PSF mapped onto the scene plane (object-space convolution, request.diffraction)."] : [])] }
|
|
1004
1133
|
};
|
|
1005
|
-
const lensId =
|
|
1134
|
+
const lensId = lensExposure.profileId;
|
|
1006
1135
|
const opticsProfile = { schemaVersion: "0.1.0", profileId: lensId + ":facade-transmission", profileVersion: SIMULATE_CAPTURE_FACADE_VERSION, lensProfileId: lensId,
|
|
1007
1136
|
scientificStatus: "approximation", applicability: { focalLengthMm: { minimum: capabilities.selectedFocalLengthMm, maximum: capabilities.selectedFocalLengthMm },
|
|
1008
1137
|
nominalFNumber: { minimum: capabilities.aperture.widestAvailableFNumber, maximum: capabilities.aperture.narrowestAvailableFNumber }, focus: { kind: "any" } },
|
|
@@ -1049,7 +1178,7 @@ export async function simulateCapture(options) {
|
|
|
1049
1178
|
" (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
1179
|
(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
1180
|
"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.");
|
|
1181
|
+
"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
1182
|
}
|
|
1054
1183
|
const psf = lensPsf ?? (diffractionObjectSpace ? { kind: "not-applied", evidence,
|
|
1055
1184
|
limitation: "No environment PSF: lens diffraction is applied upstream by the scene provider as an object-space convolution (request.diffraction)." } : undefined) ??
|
|
@@ -1064,7 +1193,7 @@ export async function simulateCapture(options) {
|
|
|
1064
1193
|
fail("Native site index out of range.");
|
|
1065
1194
|
const x = index % nativeWidth, y = Math.floor(index / nativeWidth), channel = channelAt(x, y), site = { x, y }, c = perChannel[channel], r = regimeByChannel[channel];
|
|
1066
1195
|
const environment = { temporalIntegrationId: IDS.frame + ":native:" + index,
|
|
1067
|
-
sensor: { spatialSampling: { imagingArea, samplingApertureProfile, opticalStackProfile, site, spatialSampleCountX:
|
|
1196
|
+
sensor: { spatialSampling: { imagingArea, samplingApertureProfile, opticalStackProfile, site, spatialSampleCountX: boxSamples, spatialSampleCountY: boxSamples },
|
|
1068
1197
|
spectralSampling: spectralSamplingByChannel[channel], colorSamplingProfile, spectralResponseProfile: spectralResponseProfiles[channel],
|
|
1069
1198
|
responseApplication: { applicationProfile: c.applicationProfile, sourcePlane, operatingConditions }, operatingRangeProfile: c.operatingRangeProfile, localExposure },
|
|
1070
1199
|
sceneBindings, optics, motion, temporalSampleCount, fieldThroughput, psf, ...(pupilRecord ? { pupil: pupilRecord } : {}), ...(lensFieldMapping ? { lensFieldMapping } : {}) };
|
|
@@ -1094,8 +1223,8 @@ export async function simulateCapture(options) {
|
|
|
1094
1223
|
const rawProvider = diffraction && diffractionObjectSpace ? createDiffractedPlanarProvider({ base: provider, psf: diffractionPsf.configuration.psf,
|
|
1095
1224
|
wavelengthsNm: diffraction.profile.axes.wavelengthNm, kernelRadius: diffraction.kernelRadiusSamples, nativeWidth, nativeHeight, outputWidth: width,
|
|
1096
1225
|
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;
|
|
1226
|
+
// The environment queries each site at each aperture node minus each split offset (sites, +x right, +y down).
|
|
1227
|
+
...(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
1228
|
if (antiAliasing && perSite > 100_000)
|
|
1100
1229
|
fail("sensor.antiAliasing with these options needs " + perSite + " scene queries per site; the environment admits at most 100000.");
|
|
1101
1230
|
const evaluations = nativeWidth * nativeHeight * perSite;
|
|
@@ -1103,6 +1232,124 @@ export async function simulateCapture(options) {
|
|
|
1103
1232
|
const tileWidth = Math.max(1, Math.min(256, Math.floor(100_000 / perSite)));
|
|
1104
1233
|
const environmentRaw = { raw: buildRawInput(intentOf(whiteBalanceState)), sceneBinding,
|
|
1105
1234
|
maximumProviderEvaluations: Math.min(nativeRawMaximumProviderEvaluations(raisedPixels === undefined ? {} : { maximumPixels: raisedPixels }), Math.max(nativeWidth * nativeHeight, evaluations)) };
|
|
1235
|
+
// Ideal reference (2.3): the scene without camera effects, at the capture's own exposure, white balance and encoding.
|
|
1236
|
+
if (idealReference) {
|
|
1237
|
+
const referenceStarted = clockMs();
|
|
1238
|
+
if (format !== "srgb-8" && format !== "srgb-16" && format !== "png")
|
|
1239
|
+
fail("simulateIdealReference supports output.format srgb-8, srgb-16 and png: a reference has no RAW (dng) and should not carry JPEG compression artefacts.");
|
|
1240
|
+
if (needsAuto)
|
|
1241
|
+
fail("simulateIdealReference cannot reproduce output.whiteBalance \"auto\": the capture measures it from its noisy RAW. Use \"scene-illuminant\" or a fixed white balance.");
|
|
1242
|
+
// 1. Electrons per unit spectral radiance at each spectral node, per channel: the facade's own environment chain at a
|
|
1243
|
+
// site next to the optical axis, with the ideal optics (no PSF, pupil, distortion, vignetting or motion). The signal
|
|
1244
|
+
// is linear in the radiance node values and, for this planar scene, the same at every site (paraxial π/(4N_w²)).
|
|
1245
|
+
const cx = Math.floor(nativeWidth / 2) & ~1, cy = Math.floor(nativeHeight / 2) & ~1;
|
|
1246
|
+
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) };
|
|
1247
|
+
const electronsPerRadiance = {};
|
|
1248
|
+
for (const channel of CHANNELS) {
|
|
1249
|
+
if (channelAt(channelSites[channel] % nativeWidth, Math.floor(channelSites[channel] / nativeWidth)) !== channel)
|
|
1250
|
+
fail("simulateIdealReference needs at least a 2 × 2 native raster.");
|
|
1251
|
+
const environment = { ...siteInput(channelSites[channel]).environment };
|
|
1252
|
+
environment.psf = { kind: "not-applied", evidence, limitation: "Ideal reference: no lens PSF." };
|
|
1253
|
+
environment.fieldThroughput = { kind: "unity", evidence, limitation: "Ideal reference: no vignetting." };
|
|
1254
|
+
environment.motion = { ...motion, angularVelocityRadPerSec: { pitch: 0, yaw: 0, roll: 0 } };
|
|
1255
|
+
environment.temporalSampleCount = 1;
|
|
1256
|
+
delete environment.pupil;
|
|
1257
|
+
delete environment.lensFieldMapping;
|
|
1258
|
+
const electrons = (radiance) => calculateEnvironmentSensorPhotoSignal({ ...environment,
|
|
1259
|
+
evaluateRadiance: (request) => ({ ...provider.evaluateRadiance(request), spectralRadianceWattsPerSquareMeterSteradianNanometer: radiance(request.wavelengthNanometers) })
|
|
1260
|
+
}).value
|
|
1261
|
+
.photo.value.photoSignal.expectedGeneratedElectronCount;
|
|
1262
|
+
const nodes = new Set();
|
|
1263
|
+
electrons((nm) => { nodes.add(nm); return 1; });
|
|
1264
|
+
electronsPerRadiance[channel] = new Map([...nodes].sort((a, b) => a - b).map((node) => [node, electrons((nm) => (nm === node ? 1 : 0))]));
|
|
1265
|
+
}
|
|
1266
|
+
const wavelengths = [...new Set(CHANNELS.flatMap((c) => [...electronsPerRadiance[c].keys()]))].sort((a, b) => a - b);
|
|
1267
|
+
// 2. Electrons → normalized RAW signal: (code − black)/(white − black) = e·d/(G·(W − B)) for the linear readout
|
|
1268
|
+
// (conversion gain G e⁻/DN, digital gain d, black B, effective white W of the capture's readout regime).
|
|
1269
|
+
const relativePerElectron = {};
|
|
1270
|
+
for (const channel of CHANNELS) {
|
|
1271
|
+
const { readoutProfile, regimeId } = regimeByChannel[channel];
|
|
1272
|
+
const regime = resolveSensorReadoutRegime({ profile: readoutProfile, regimeId }).regime;
|
|
1273
|
+
const gain = regime.systemConversionGainElectronsPerCode.value, digitalGain = regime.adc.digitalGain ?? 1, black = regime.adc.blackLevelCode;
|
|
1274
|
+
const white = calculateSiteEffectiveSaturationCode({ physicalChargeCapacityElectrons: capacityElectrons, preAdcSaturationElectronEquivalent: regime.preAdcSaturationElectronEquivalent.value,
|
|
1275
|
+
systemConversionGainElectronsPerCode: gain, blackLevelCode: black, digitalSaturationCode: regime.adc.digitalSaturationCode, adcMaximumCode: 2 ** regime.adc.bitDepth - 1, digitalGain }, readoutProfile, regimeId);
|
|
1276
|
+
relativePerElectron[channel] = digitalGain / (gain * (white - black));
|
|
1277
|
+
}
|
|
1278
|
+
// 3. Scene radiance per sample: chart materials once (and the fixed-size chart's surround), caller functions per sample.
|
|
1279
|
+
const signalOf = (radiance) => {
|
|
1280
|
+
const values = new Map(wavelengths.map((nm) => [nm, radiance(nm)]));
|
|
1281
|
+
return CHANNELS.map((c) => { let e = 0; for (const [nm, a] of electronsPerRadiance[c])
|
|
1282
|
+
e += a * values.get(nm); return e * relativePerElectron[c]; });
|
|
1283
|
+
};
|
|
1284
|
+
const materialSignals = provider.chartMaterialAt ? Array.from({ length: chart.materials.length + (chartSize ? 1 : 0) }, (_, m) => signalOf((nm) => provider.materialRadiance(m, nm))) : undefined;
|
|
1285
|
+
// 4. Pixels: the native sites of the output (or region) pixels, each centre-sampled or box-integrated like the capture.
|
|
1286
|
+
const x0 = (region ? region.x : 0) + margin, y0 = (region ? region.y : 0) + margin, n = boxSamples, unit = 2 / width;
|
|
1287
|
+
const referenceIntent = intentOf(whiteBalanceState), referenceGains = referenceIntent ? [referenceIntent.channelGains.red, referenceIntent.channelGains.green, referenceIntent.channelGains.blue] : null;
|
|
1288
|
+
const development = prepareSensorColorDevelopment(CAMERA_TO_XYZ, referenceGains, highlightMode);
|
|
1289
|
+
const rgb = new Float64Array(outputWidth * outputHeight * 3), counts = { whiteBalanceClippedSampleCount: 0, reconstructedPixelCount: 0 }, sum = [0, 0, 0];
|
|
1290
|
+
for (let py = 0; py < outputHeight; py++) {
|
|
1291
|
+
for (let px = 0; px < outputWidth; px++) {
|
|
1292
|
+
sum[0] = 0;
|
|
1293
|
+
sum[1] = 0;
|
|
1294
|
+
sum[2] = 0;
|
|
1295
|
+
for (let l = 0; l < n; l++)
|
|
1296
|
+
for (let k = 0; k < n; k++) {
|
|
1297
|
+
const u = (x0 + px + (k + 0.5) / n - nativeWidth / 2) * unit, v = -(y0 + py + (l + 0.5) / n - nativeHeight / 2) * unit;
|
|
1298
|
+
const s = materialSignals ? materialSignals[provider.chartMaterialAt(u, v)] : signalOf((nm) => provider.radianceAt(u, v, nm));
|
|
1299
|
+
sum[0] += s[0];
|
|
1300
|
+
sum[1] += s[1];
|
|
1301
|
+
sum[2] += s[2];
|
|
1302
|
+
}
|
|
1303
|
+
// Normalized RAW values saturate at the white level, as in development.
|
|
1304
|
+
const c = sum.map((value) => Math.min(1, Math.max(0, value / (n * n))));
|
|
1305
|
+
development.developInto(c[0], c[1], c[2], rgb, (py * outputWidth + px) * 3, counts);
|
|
1306
|
+
}
|
|
1307
|
+
if ((py & 15) === 15) {
|
|
1308
|
+
await yieldControl();
|
|
1309
|
+
checkAbort();
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
let samples;
|
|
1313
|
+
if (!linearPath) {
|
|
1314
|
+
const maximum = 2 ** rendering.bitDepth - 1, tone = prepareSdrPixelTone(rendering), scratch = new Float64Array(3);
|
|
1315
|
+
samples = rendering.bitDepth === 8 ? new Uint8Array(rgb.length) : new Uint16Array(rgb.length);
|
|
1316
|
+
const sdrCounts = { negative: 0, above: 0, changed: 0, low: 0, high: 0, outOfGamut: false };
|
|
1317
|
+
for (let y = 0; y < outputHeight; y++)
|
|
1318
|
+
renderSdrRunInto(rgb, y * outputWidth * 3, outputWidth, rendering, tone, 2 ** rendering.renderingExposureEv, maximum, samples, y * outputWidth * 3, scratch, sdrCounts);
|
|
1319
|
+
}
|
|
1320
|
+
else {
|
|
1321
|
+
const mapping = perceptual ? prepareRgbGamutMapping({ colorSpace: colorEncoding, method: "cielab-hue-preserving-chroma-compression", kneeFraction: PERCEPTUAL_GAMUT_KNEE_FRACTION }) : undefined;
|
|
1322
|
+
samples = encodeSdrFromLinear(rgb, colorEncoding, pngBitDepth, mapping).samples;
|
|
1323
|
+
}
|
|
1324
|
+
const encodedPng = format === "png" ? serializePngImage({ width: outputWidth, height: outputHeight, bitDepth: pngBitDepth, samples, colourEncoding: pngColourEncoding(colorEncoding),
|
|
1325
|
+
...(raisedPixels === undefined || outputWidth * outputHeight <= PNG_ENCODER_LIMITS.maximumPixels ? {} : { maximumPixels: raisedPixels }) }) : undefined;
|
|
1326
|
+
const png = encodedPng && textMetadata ? addPngTextMetadata(encodedPng, textMetadata) : encodedPng;
|
|
1327
|
+
if (png)
|
|
1328
|
+
assumeTextMetadata(TEXT_METADATA_LAYOUT.png);
|
|
1329
|
+
const referenceMs = clockMs() - referenceStarted;
|
|
1330
|
+
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.");
|
|
1331
|
+
assume("reference.excludes", "model", ["lens PSF, diffraction, defocus and pupil sampling", "projection and distortion (ideal rectilinear mapping)", "vignetting", "anti-aliasing filter",
|
|
1332
|
+
"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.");
|
|
1333
|
+
assume("reference.exposure", "model", { convention: "exposure-matched", fNumber, shutterSeconds, iso: resolvedIso, illuminanceLux,
|
|
1334
|
+
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), " +
|
|
1335
|
+
"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. " +
|
|
1336
|
+
"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.");
|
|
1337
|
+
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\"." :
|
|
1338
|
+
"Each pixel samples the scene at its centre, like the capture's default point aperture; use sensor.pixelAperture \"box\" for photosite-averaged pixels.");
|
|
1339
|
+
return Object.freeze({ kind: "ideal-reference", label: "Ideal reference (no camera effects); not a capture.", facadeVersion: SIMULATE_CAPTURE_FACADE_VERSION, engineApiVersion: ENGINE_API_VERSION,
|
|
1340
|
+
image: { width: outputWidth, height: outputHeight, format, samples, ...(png ? { png, colorEncoding: colorEncoding } : {}) },
|
|
1341
|
+
exposure: { mode, settings: { fNumber, shutterSeconds, iso: resolvedIso }, ev100: Math.log2(fNumber ** 2 / shutterSeconds) },
|
|
1342
|
+
scene: { sceneId: chart ? "chart:" + chart.preset : callerScene.sceneId, preset: scenePreset, parameters: chart ? chart.parameters : {},
|
|
1343
|
+
illuminant: { id: illuminant.id, description: illuminant.description, illuminanceLux }, distanceM: subjectDistanceM,
|
|
1344
|
+
framedWidthM: 2 * metresPerFrameUnit, framedHeightM: 2 * metresPerFrameUnit * aspect, ...(chartReport ? { chart: chartReport } : {}), sourceSha256 },
|
|
1345
|
+
...(region && regionNative ? { region: { ...region, frameWidth: width, frameHeight: height } } : {}),
|
|
1346
|
+
diagnostics: { sampleCount: outputWidth * outputHeight * n * n, timingsMs: { reference: referenceMs, total: Date.now() - started } },
|
|
1347
|
+
provenance: { scientificStatus: "approximation", cameraPresetId: preset.presetId, cameraPresetVersion: preset.presetVersion, evidence: [...FACADE_EVIDENCE, ...preset.evidence],
|
|
1348
|
+
composedContracts: ["calculateEnvironmentSensorPhotoSignal", "resolveSensorReadoutRegime", "calculateSiteEffectiveSaturationCode", "prepareSensorColorDevelopment",
|
|
1349
|
+
...(linearPath ? ["resolveRgbColorSpace", ...(perceptual ? ["prepareRgbGamutMapping"] : [])] : ["SDR rendering kernel"]), ...(png ? ["serializePngImage"] : [])],
|
|
1350
|
+
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."] },
|
|
1351
|
+
assumptions: Object.freeze(assumptions.map((a) => Object.freeze(a))) });
|
|
1352
|
+
}
|
|
1106
1353
|
async function runTask(task) {
|
|
1107
1354
|
const onAbort = () => task.cancel();
|
|
1108
1355
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
@@ -1127,13 +1374,25 @@ export async function simulateCapture(options) {
|
|
|
1127
1374
|
let completed = 0;
|
|
1128
1375
|
return () => { completed++; onProgress?.({ stage, completedTileCount: completed, totalTileCount: total }); };
|
|
1129
1376
|
};
|
|
1130
|
-
const rawTick = progress("raw", plan.tileCount);
|
|
1131
|
-
const
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1377
|
+
const rawTick = progress("raw", regionNative ? regionNative.height * Math.ceil(regionNative.width / tileWidth) : plan.tileCount);
|
|
1378
|
+
const rawCallbacks = { readTile: async (request) => { const tile = await readTile(request); rawTick(); return tile; },
|
|
1379
|
+
evaluateRadiance: rawProvider.evaluateRadiance, ...(pupil ? { evaluateApertureRadiance: provider.evaluateApertureRadiance } : {}), yieldControl };
|
|
1380
|
+
const rawTask = regionNative ? createNativeEnvironmentRawRegionTask(environmentRaw, regionNative, rawCallbacks) :
|
|
1381
|
+
createNativeEnvironmentRawTask(environmentRaw, rawCallbacks);
|
|
1135
1382
|
await runTask(rawTask);
|
|
1136
1383
|
let rawOutput = rawTask.takeOutput();
|
|
1384
|
+
// Region (2.3): the region's sites, re-described by a plan of the region raster (active area = the region), for development.
|
|
1385
|
+
let developmentPlan = plan;
|
|
1386
|
+
if (region && regionNative) {
|
|
1387
|
+
const raster = { pixelWidth: regionNative.width, pixelHeight: regionNative.height };
|
|
1388
|
+
const regionGeometry = { imagingArea: { widthMm: raster.pixelWidth * pitch / 1000, heightMm: raster.pixelHeight * pitch / 1000 }, nativeRaster: raster, orientation: "landscape",
|
|
1389
|
+
activeCaptureRect: { x: region.x + margin - regionNative.x, y: region.y + margin - regionNative.y, width: region.width, height: region.height },
|
|
1390
|
+
outputRaster: { pixelWidth: region.width, pixelHeight: region.height } };
|
|
1391
|
+
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));
|
|
1392
|
+
if (Array.from(rawOutput.raw.effectiveSaturationCodes).some((code) => code === 0))
|
|
1393
|
+
fail("output.region needs every CFA phase in its native rectangle.");
|
|
1394
|
+
rawOutput = { ...rawOutput, raw: { ...rawOutput.raw, plan: developmentPlan } };
|
|
1395
|
+
}
|
|
1137
1396
|
const providerEvaluationCount = rawOutput.providerEvaluationCount;
|
|
1138
1397
|
const rawMs = Date.now() - rawStarted, wbStarted = Date.now();
|
|
1139
1398
|
let finalRawInput = environmentRaw;
|
|
@@ -1179,8 +1438,10 @@ export async function simulateCapture(options) {
|
|
|
1179
1438
|
readNoiseRmsElectrons: resolved.combinedReadNoiseRmsElectrons }];
|
|
1180
1439
|
}));
|
|
1181
1440
|
const dngStarted = clockMs();
|
|
1182
|
-
dng = await serializeNativeRawDng({ raw: rawOutput.raw, colorProfile, noise, ...(dngCapturedAtUtc === undefined ? {} : { capturedAtUtc: dngCapturedAtUtc })
|
|
1441
|
+
dng = await serializeNativeRawDng({ raw: rawOutput.raw, colorProfile, noise, ...(dngCapturedAtUtc === undefined ? {} : { capturedAtUtc: dngCapturedAtUtc }),
|
|
1442
|
+
...(textMetadata ? { textMetadata } : {}) });
|
|
1183
1443
|
encodeMs = clockMs() - dngStarted;
|
|
1444
|
+
assumeTextMetadata(TEXT_METADATA_LAYOUT.dng);
|
|
1184
1445
|
checkAbort();
|
|
1185
1446
|
assume("output.dng", "model", { writer: "serializeNativeRawDng", schemaVersion: dng.schemaVersion, whiteLevel: dng.whiteLevel, blackLevels: dng.blackLevels,
|
|
1186
1447
|
noiseProfile: dng.noiseProfile, unrepresentedSaturationFlagCount: dng.unrepresentedSaturationFlagCount }, "Lossless uncompressed 16-bit CFA DNG of exactly the packed native RAW codes: per-phase BlackLevel, WhiteLevel = smallest per-phase effective white, ActiveArea and DefaultCrop from the capture geometry, ColorMatrix1/ForwardMatrix1 from the facade's diagonal camera→XYZ profile (D65), AsShotNeutral from the white-balance intent, BaselineExposure 0, and NoiseProfile from each channel's readout regime (conversion gain ÷ digital gain, combined read noise; Poisson + Gaussian + quantization only). No linear master or developed image is declared; saturation flags below WhiteLevel are not representable and are counted.");
|
|
@@ -1188,7 +1449,7 @@ export async function simulateCapture(options) {
|
|
|
1188
1449
|
// Development
|
|
1189
1450
|
const development = { phaseProfiles, colorProfile,
|
|
1190
1451
|
whiteBalance: developmentWhiteBalance, rendering, highlightMode, ...(mirrorBorder ? { borderHandling: "mirror" } : {}),
|
|
1191
|
-
maximumRetainedPayloadBytes:
|
|
1452
|
+
maximumRetainedPayloadBytes: developmentPlan.outputBytes + outputWidth * outputHeight * 3 * (rendering.bitDepth / 8) };
|
|
1192
1453
|
let developed;
|
|
1193
1454
|
let gamutMappingDiagnostics;
|
|
1194
1455
|
if (ahd) {
|
|
@@ -1204,10 +1465,10 @@ export async function simulateCapture(options) {
|
|
|
1204
1465
|
if (!linearPath) {
|
|
1205
1466
|
// The native development's SDR kernel, row by row, on the AHD-developed linear sRGB.
|
|
1206
1467
|
const maximum = 2 ** rendering.bitDepth - 1, tone = prepareSdrPixelTone(rendering), scratch = new Float64Array(3);
|
|
1207
|
-
const codes = rendering.bitDepth === 8 ? new Uint8Array(
|
|
1468
|
+
const codes = rendering.bitDepth === 8 ? new Uint8Array(outputWidth * outputHeight * 3) : new Uint16Array(outputWidth * outputHeight * 3);
|
|
1208
1469
|
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 *
|
|
1470
|
+
for (let y = 0; y < outputHeight; y++)
|
|
1471
|
+
renderSdrRunInto(linear.rgb, y * outputWidth * 3, outputWidth, rendering, tone, 2 ** rendering.renderingExposureEv, maximum, codes, y * outputWidth * 3, scratch, counts);
|
|
1211
1472
|
developed = { raw: rawOutput.raw, integerSamples: codes, highlight: linear.highlight, diagnostics: { renderingNegativeSampleCount: counts.negative,
|
|
1212
1473
|
renderingAboveReferenceSampleCount: counts.above, toneChangedSampleCount: counts.changed, gamutClippedLowSampleCount: counts.low,
|
|
1213
1474
|
gamutClippedHighSampleCount: counts.high, captureSaturation: "consumed-in-raw-development" } };
|
|
@@ -1258,24 +1519,27 @@ export async function simulateCapture(options) {
|
|
|
1258
1519
|
if (format === "jpeg") {
|
|
1259
1520
|
const samples = developed.integerSamples;
|
|
1260
1521
|
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" };
|
|
1522
|
+
const source = { captureId: IDS.capture, imageStateId: "facade-developed", artifactId: "facade-developed-srgb8", sha256: jpegSha, width: outputWidth, height: outputHeight, encoding: "encoded-srgb-8-rgb" };
|
|
1262
1523
|
// 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,
|
|
1524
|
+
const reads = Math.ceil(outputHeight / 32) * Math.ceil(outputWidth / 256), jpegTick = progress("jpeg", reads);
|
|
1525
|
+
const jpegTask = createPrintJpegTask({ source, crop: { x: 0, y: 0, width: outputWidth, height: outputHeight }, outputWidth, outputHeight, quantizationStep: jpegStep,
|
|
1526
|
+
maximumEncodedBytes: Math.max(65536, outputWidth * outputHeight * 6), maximumProviderReads: reads,
|
|
1266
1527
|
// The JPEG records its admission plan, so the raised limit is passed (and recorded) only when the image needs it.
|
|
1267
|
-
...(raisedPixels === undefined ||
|
|
1528
|
+
...(raisedPixels === undefined || outputWidth * outputHeight <= PRINT_JPEG_LIMITS.maximumPixels ? {} : { maximumPixels: raisedPixels }) }, {
|
|
1268
1529
|
readTile: async (request) => {
|
|
1269
1530
|
jpegTick();
|
|
1270
1531
|
const out = new Uint8Array(request.width * request.height * 3);
|
|
1271
1532
|
for (let row = 0; row < request.height; row++)
|
|
1272
|
-
out.set(samples.subarray(((request.y + row) *
|
|
1533
|
+
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
1534
|
return { ...request, samples: out };
|
|
1274
1535
|
}, yieldControl
|
|
1275
1536
|
});
|
|
1276
1537
|
const jpegEncodeStarted = clockMs();
|
|
1277
1538
|
await runTask(jpegTask);
|
|
1278
1539
|
jpeg = jpegTask.takeOutput().bytes;
|
|
1540
|
+
if (textMetadata)
|
|
1541
|
+
jpeg = addJpegTextMetadata(jpeg, textMetadata);
|
|
1542
|
+
assumeTextMetadata(TEXT_METADATA_LAYOUT.jpeg);
|
|
1279
1543
|
encodeMs = clockMs() - jpegEncodeStarted;
|
|
1280
1544
|
assume("output.jpeg", "model", { encoder: "photivra-print-jpeg", iccProfile: "photivra-srgb" }, "Baseline JPEG from the existing print JPEG encoder with an embedded sRGB ICC profile; the source digest covers a sample prefix, length and scene digest.");
|
|
1281
1545
|
}
|
|
@@ -1283,30 +1547,34 @@ export async function simulateCapture(options) {
|
|
|
1283
1547
|
let png;
|
|
1284
1548
|
if (format === "png") {
|
|
1285
1549
|
const pngStarted = clockMs();
|
|
1286
|
-
png = serializePngImage({ width, height, bitDepth: pngBitDepth, samples: developed.integerSamples, colourEncoding: pngColourEncoding(colorEncoding),
|
|
1287
|
-
...(raisedPixels === undefined ||
|
|
1550
|
+
png = serializePngImage({ width: outputWidth, height: outputHeight, bitDepth: pngBitDepth, samples: developed.integerSamples, colourEncoding: pngColourEncoding(colorEncoding),
|
|
1551
|
+
...(raisedPixels === undefined || outputWidth * outputHeight <= PNG_ENCODER_LIMITS.maximumPixels ? {} : { maximumPixels: raisedPixels }) });
|
|
1552
|
+
if (textMetadata)
|
|
1553
|
+
png = addPngTextMetadata(png, textMetadata);
|
|
1554
|
+
assumeTextMetadata(TEXT_METADATA_LAYOUT.png);
|
|
1288
1555
|
encodeMs = clockMs() - pngStarted;
|
|
1289
1556
|
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
1557
|
}
|
|
1291
1558
|
const ranOptions = new Set([...(pupil ? ["lens.pupilSampling"] : []), ...(projection !== undefined ? ["lens.projection"] : []),
|
|
1292
1559
|
...(distortion ? ["lens.distortion"] : []), ...(vignetting ? ["lens.vignetting"] : []), ...(lensPsf ? ["lens.psf"] : []),
|
|
1293
|
-
...(diffraction ? ["lens.diffraction"] : []), ...(antiAliasing ? ["sensor.antiAliasing"] : []),
|
|
1560
|
+
...(diffraction ? ["lens.diffraction"] : []), ...(antiAliasing ? ["sensor.antiAliasing"] : []), ...(boxAperture ? ["sensor.pixelAperture.box"] : []),
|
|
1294
1561
|
...(temporalSampleCount > 1 ? ["sensor.temporalSampleCount"] : []), ...(rotating ? ["motion.rotation"] : []),
|
|
1295
|
-
...(rollingSchedule ? ["motion.shutter.rolling"] : []), ...(mirrorBorder ? ["output.borderHandling.mirror"] : []),
|
|
1562
|
+
...(rollingSchedule ? ["motion.shutter.rolling"] : []), ...(mirrorBorder ? ["output.borderHandling.mirror"] : []), ...(region ? ["output.region"] : []),
|
|
1296
1563
|
...(needsAuto ? ["output.whiteBalance.auto"] : []), ...(ahd ? ["output.demosaic.ahd"] : []),
|
|
1297
1564
|
...(highlightMode === "reconstruct" ? ["output.highlightMode.reconstruct"] : []), ...(wideGamut ? ["output.colorEncoding.wide-gamut"] : []),
|
|
1298
1565
|
...(perceptual ? ["output.gamutMapping.perceptual"] : []), ...(format === "jpeg" || format === "png" || format === "dng" ? ["output.format." + format] : [])]);
|
|
1299
1566
|
return Object.freeze({
|
|
1300
1567
|
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,
|
|
1568
|
+
image: { width: outputWidth, height: outputHeight, format, samples: developed.integerSamples, ...(jpeg ? { jpeg } : {}), ...(png ? { png, colorEncoding: colorEncoding } : {}), ...(dng ? { dng: dng.bytes } : {}) },
|
|
1569
|
+
raw: developed.raw, plan: developmentPlan,
|
|
1570
|
+
...(region && regionNative ? { region: { ...region, frameWidth: width, frameHeight: height, nativeRect: { ...regionNative } } } : {}),
|
|
1303
1571
|
request: { environmentRaw: finalRawInput, siteInput, development, whiteBalanceState,
|
|
1304
1572
|
...(diffraction ? { diffraction: { profile: diffraction, psf: diffractionPsf, execution: diffractionObjectSpace ? "object-space-convolution" : "environment-psf-quadrature" } } : {}) },
|
|
1305
1573
|
exposure: { mode, settings: { fNumber, shutterSeconds, iso: resolvedIso }, ev100: Math.log2(fNumber ** 2 / shutterSeconds),
|
|
1306
1574
|
meteredSceneLuminanceCdPerSquareMeter: meteredLuminance, resolution },
|
|
1307
1575
|
scene: { sceneId: chart ? "chart:" + chart.preset : callerScene.sceneId, preset: scenePreset, parameters: chart ? chart.parameters : {},
|
|
1308
1576
|
illuminant: { id: illuminant.id, description: illuminant.description, illuminanceLux }, distanceM: subjectDistanceM,
|
|
1309
|
-
framedWidthM: 2 * metresPerFrameUnit, framedHeightM: 2 * metresPerFrameUnit * aspect, sourceSha256 },
|
|
1577
|
+
framedWidthM: 2 * metresPerFrameUnit, framedHeightM: 2 * metresPerFrameUnit * aspect, ...(chartReport ? { chart: chartReport } : {}), sourceSha256 },
|
|
1310
1578
|
diagnostics: { providerEvaluationCount, highlight: developed.highlight, rendering: developed.diagnostics, ...(gamutMappingDiagnostics ? { gamutMapping: gamutMappingDiagnostics } : {}),
|
|
1311
1579
|
timingsMs: { raw: rawMs, whiteBalance: wbMs, development: devMs, jpeg: jpegMs, total: Date.now() - started,
|
|
1312
1580
|
...(diffraction && diffractionObjectSpace ? { diffraction: diffractionMs } : {}), ...(demosaicMs === undefined ? {} : { demosaic: demosaicMs }),
|