@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.
Files changed (52) hide show
  1. package/CHANGELOG.md +38 -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-dng.d.ts +3 -0
  9. package/dist/capture/native-raw-dng.js +23 -3
  10. package/dist/capture/native-raw-dng.js.map +1 -1
  11. package/dist/capture/native-raw.d.ts +3 -0
  12. package/dist/capture/native-raw.js +8 -5
  13. package/dist/capture/native-raw.js.map +1 -1
  14. package/dist/core/version.d.ts +1 -1
  15. package/dist/core/version.js +1 -1
  16. package/dist/core/version.js.map +1 -1
  17. package/dist/equipment/generic-prime-lenses.d.ts +46 -0
  18. package/dist/equipment/generic-prime-lenses.js +59 -0
  19. package/dist/equipment/generic-prime-lenses.js.map +1 -0
  20. package/dist/facade/chart-scenes.d.ts +270 -1
  21. package/dist/facade/chart-scenes.js +443 -17
  22. package/dist/facade/chart-scenes.js.map +1 -1
  23. package/dist/facade/scene-radiance.d.ts +47 -0
  24. package/dist/facade/scene-radiance.js +65 -5
  25. package/dist/facade/scene-radiance.js.map +1 -1
  26. package/dist/facade/simulate-capture.d.ts +158 -4
  27. package/dist/facade/simulate-capture.js +324 -56
  28. package/dist/facade/simulate-capture.js.map +1 -1
  29. package/dist/facade/subject-framing.d.ts +55 -0
  30. package/dist/facade/subject-framing.js +66 -0
  31. package/dist/facade/subject-framing.js.map +1 -0
  32. package/dist/gpu/chart-radiance-gpu.d.ts +8 -0
  33. package/dist/gpu/chart-radiance-gpu.js +20 -1
  34. package/dist/gpu/chart-radiance-gpu.js.map +1 -1
  35. package/dist/index.d.ts +6 -3
  36. package/dist/index.js +6 -3
  37. package/dist/index.js.map +1 -1
  38. package/dist/output/text-metadata.d.ts +32 -0
  39. package/dist/output/text-metadata.js +201 -0
  40. package/dist/output/text-metadata.js.map +1 -0
  41. package/docs/API_REFERENCE.md +56 -1
  42. package/docs/QUICK_START.md +325 -4
  43. package/docs/api/capture-native-raw-dng.md +2 -0
  44. package/docs/api/core-version.md +1 -1
  45. package/docs/api/equipment-generic-prime-lenses.md +85 -0
  46. package/docs/api/exports.json +172 -2
  47. package/docs/api/facade-chart-scenes.md +280 -4
  48. package/docs/api/facade-scene-radiance.md +42 -0
  49. package/docs/api/facade-simulate-capture.md +113 -2
  50. package/docs/api/facade-subject-framing.md +72 -0
  51. package/docs/api/output-text-metadata.md +20 -0
  52. 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
- 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 });
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
- 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.");
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", chart.parameters[key], "Chart default for " + chart.preset + ".");
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 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
- }) : [];
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 }, evidence: FACADE_EVIDENCE,
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
- exposure: { captureId: IDS.capture, sceneStateId: IDS.sceneState, sceneTimeSeconds: 0, geometry,
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: nativeWidth * nativeHeight * 7, tileWidth, ...(raisedPixels === undefined ? {} : { maximumPixels: raisedPixels })
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 = lens.exposure.profileId;
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: 1, spatialSampleCountY: 1 },
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 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;
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 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
- });
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: plan.outputBytes + width * height * 3 * (rendering.bitDepth / 8) };
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(width * height * 3) : new Uint16Array(width * height * 3);
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 < height; y++)
1210
- renderSdrRunInto(linear.rgb, y * width * 3, width, rendering, tone, 2 ** rendering.renderingExposureEv, maximum, codes, y * width * 3, scratch, counts);
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(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,
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 || width * height <= PRINT_JPEG_LIMITS.maximumPixels ? {} : { maximumPixels: raisedPixels }) }, {
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) * width + request.x) * 3, ((request.y + row) * width + request.x + request.width) * 3), row * request.width * 3);
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 || width * height <= PNG_ENCODER_LIMITS.maximumPixels ? {} : { maximumPixels: raisedPixels }) });
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 }),