@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.
Files changed (44) hide show
  1. package/CHANGELOG.md +16 -1
  2. package/CITATION.cff +1 -1
  3. package/README.md +6 -6
  4. package/SECURITY.md +1 -1
  5. package/dist/capture/native-environment-raw.d.ts +18 -0
  6. package/dist/capture/native-environment-raw.js +17 -1
  7. package/dist/capture/native-environment-raw.js.map +1 -1
  8. package/dist/capture/native-raw.d.ts +3 -0
  9. package/dist/capture/native-raw.js +8 -5
  10. package/dist/capture/native-raw.js.map +1 -1
  11. package/dist/core/version.d.ts +1 -1
  12. package/dist/core/version.js +1 -1
  13. package/dist/core/version.js.map +1 -1
  14. package/dist/equipment/generic-prime-lenses.d.ts +46 -0
  15. package/dist/equipment/generic-prime-lenses.js +59 -0
  16. package/dist/equipment/generic-prime-lenses.js.map +1 -0
  17. package/dist/facade/chart-scenes.d.ts +270 -1
  18. package/dist/facade/chart-scenes.js +443 -17
  19. package/dist/facade/chart-scenes.js.map +1 -1
  20. package/dist/facade/scene-radiance.d.ts +47 -0
  21. package/dist/facade/scene-radiance.js +65 -5
  22. package/dist/facade/scene-radiance.js.map +1 -1
  23. package/dist/facade/simulate-capture.d.ts +151 -4
  24. package/dist/facade/simulate-capture.js +296 -55
  25. package/dist/facade/simulate-capture.js.map +1 -1
  26. package/dist/facade/subject-framing.d.ts +55 -0
  27. package/dist/facade/subject-framing.js +66 -0
  28. package/dist/facade/subject-framing.js.map +1 -0
  29. package/dist/gpu/chart-radiance-gpu.d.ts +8 -0
  30. package/dist/gpu/chart-radiance-gpu.js +20 -1
  31. package/dist/gpu/chart-radiance-gpu.js.map +1 -1
  32. package/dist/index.d.ts +5 -3
  33. package/dist/index.js +5 -3
  34. package/dist/index.js.map +1 -1
  35. package/docs/API_REFERENCE.md +48 -1
  36. package/docs/QUICK_START.md +264 -3
  37. package/docs/api/core-version.md +1 -1
  38. package/docs/api/equipment-generic-prime-lenses.md +85 -0
  39. package/docs/api/exports.json +167 -2
  40. package/docs/api/facade-chart-scenes.md +280 -4
  41. package/docs/api/facade-scene-radiance.md +42 -0
  42. package/docs/api/facade-simulate-capture.md +107 -2
  43. package/docs/api/facade-subject-framing.md +72 -0
  44. 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
- const focalDefault = lens.exposure.focalLengthMmRange.value.minimum;
477
- const focalLengthMm = positive(pick(lensOptions, "focalLengthMm", "lens.focalLengthMm", focalDefault, "The preset lens's focal length (the generic primes are 50 mm)."), "lens.focalLengthMm");
478
- const capabilities = resolveGenericEquipmentExposureCapabilities({ bodyProfile: body.exposure, lensProfile: lens.exposure, selectedFocalLengthMm: focalLengthMm });
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
- assume("scene.framing", "derived", { framedWidthM: 2 * metresPerFrameUnit, framedHeightM: 2 * metresPerFrameUnit * aspect }, "The scene plane is perpendicular to the optical axis and the chart is sized to fill the active frame exactly.");
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", chart.parameters[key], "Chart default for " + chart.preset + ".");
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 sceneSpectra = chart ? chart.materials.flatMap((m, index) => {
718
- if (m.kind !== "spectrum")
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 }, evidence: FACADE_EVIDENCE,
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
- exposure: { captureId: IDS.capture, sceneStateId: IDS.sceneState, sceneTimeSeconds: 0, geometry,
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: nativeWidth * nativeHeight * 7, tileWidth, ...(raisedPixels === undefined ? {} : { maximumPixels: raisedPixels })
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 = lens.exposure.profileId;
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: 1, spatialSampleCountY: 1 },
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 its centre minus each split offset (sites, +x right, +y down).
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 rawTask = createNativeEnvironmentRawTask(environmentRaw, {
1132
- readTile: async (request) => { const tile = await readTile(request); rawTick(); return tile; },
1133
- evaluateRadiance: rawProvider.evaluateRadiance, ...(pupil ? { evaluateApertureRadiance: provider.evaluateApertureRadiance } : {}), yieldControl
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: plan.outputBytes + width * height * 3 * (rendering.bitDepth / 8) };
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(width * height * 3) : new Uint16Array(width * height * 3);
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 < height; y++)
1210
- renderSdrRunInto(linear.rgb, y * width * 3, width, rendering, tone, 2 ** rendering.renderingExposureEv, maximum, codes, y * width * 3, scratch, counts);
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(height / 32) * Math.ceil(width / 256), jpegTick = progress("jpeg", reads);
1264
- const jpegTask = createPrintJpegTask({ source, crop: { x: 0, y: 0, width, height }, outputWidth: width, outputHeight: height, quantizationStep: jpegStep,
1265
- maximumEncodedBytes: Math.max(65536, width * height * 6), maximumProviderReads: reads,
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 || width * height <= PRINT_JPEG_LIMITS.maximumPixels ? {} : { maximumPixels: raisedPixels }) }, {
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) * width + request.x) * 3, ((request.y + row) * width + request.x + request.width) * 3), row * request.width * 3);
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 || width * height <= PNG_ENCODER_LIMITS.maximumPixels ? {} : { maximumPixels: raisedPixels }) });
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 }),