@photivra/engine 2.0.0 → 2.2.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 (77) hide show
  1. package/CHANGELOG.md +39 -1
  2. package/CITATION.cff +1 -1
  3. package/README.md +6 -6
  4. package/SECURITY.md +1 -1
  5. package/dist/capture/export-jpeg.js +7 -1
  6. package/dist/capture/export-jpeg.js.map +1 -1
  7. package/dist/capture/native-environment-lean.js +5 -2
  8. package/dist/capture/native-environment-lean.js.map +1 -1
  9. package/dist/capture/native-environment-raw.js +2 -2
  10. package/dist/capture/native-environment-raw.js.map +1 -1
  11. package/dist/capture/native-raw-development.js +1 -1
  12. package/dist/capture/native-raw-development.js.map +1 -1
  13. package/dist/capture/native-raw.d.ts +16 -0
  14. package/dist/capture/native-raw.js +16 -3
  15. package/dist/capture/native-raw.js.map +1 -1
  16. package/dist/capture/packed-sensor-raw.d.ts +7 -0
  17. package/dist/capture/packed-sensor-raw.js +6 -2
  18. package/dist/capture/packed-sensor-raw.js.map +1 -1
  19. package/dist/core/pixel-limits.d.ts +14 -0
  20. package/dist/core/pixel-limits.js +40 -0
  21. package/dist/core/pixel-limits.js.map +1 -0
  22. package/dist/core/version.d.ts +1 -1
  23. package/dist/core/version.js +1 -1
  24. package/dist/core/version.js.map +1 -1
  25. package/dist/facade/chart-radiance-batch.d.ts +7 -0
  26. package/dist/facade/chart-radiance-batch.js +7 -3
  27. package/dist/facade/chart-radiance-batch.js.map +1 -1
  28. package/dist/facade/diffraction-provider.d.ts +30 -0
  29. package/dist/facade/diffraction-provider.js +127 -0
  30. package/dist/facade/diffraction-provider.js.map +1 -0
  31. package/dist/facade/host-yield.d.ts +1 -1
  32. package/dist/facade/host-yield.js +0 -4
  33. package/dist/facade/host-yield.js.map +1 -1
  34. package/dist/facade/simulate-capture.d.ts +88 -2
  35. package/dist/facade/simulate-capture.js +343 -41
  36. package/dist/facade/simulate-capture.js.map +1 -1
  37. package/dist/index.d.ts +5 -2
  38. package/dist/index.js +4 -2
  39. package/dist/index.js.map +1 -1
  40. package/dist/motion/temporal-exposure-sum.d.ts +7 -0
  41. package/dist/motion/temporal-exposure-sum.js +6 -2
  42. package/dist/motion/temporal-exposure-sum.js.map +1 -1
  43. package/dist/optics/diffraction-psf-profile.d.ts +118 -0
  44. package/dist/optics/diffraction-psf-profile.js +252 -0
  45. package/dist/optics/diffraction-psf-profile.js.map +1 -0
  46. package/dist/optics/irradiance-convolution.d.ts +7 -0
  47. package/dist/optics/irradiance-convolution.js +6 -2
  48. package/dist/optics/irradiance-convolution.js.map +1 -1
  49. package/dist/output/native-capture-sdr.d.ts +7 -0
  50. package/dist/output/native-capture-sdr.js +6 -3
  51. package/dist/output/native-capture-sdr.js.map +1 -1
  52. package/dist/output/png-encoder.d.ts +10 -1
  53. package/dist/output/png-encoder.js +10 -4
  54. package/dist/output/png-encoder.js.map +1 -1
  55. package/dist/output/print-jpeg.d.ts +18 -0
  56. package/dist/output/print-jpeg.js +15 -4
  57. package/dist/output/print-jpeg.js.map +1 -1
  58. package/dist/sensor/sampling-aperture.d.ts +15 -0
  59. package/dist/sensor/sampling-aperture.js +31 -10
  60. package/dist/sensor/sampling-aperture.js.map +1 -1
  61. package/docs/API_REFERENCE.md +27 -1
  62. package/docs/QUICK_START.md +285 -13
  63. package/docs/USAGE.md +3 -1
  64. package/docs/api/capture-native-raw.md +11 -2
  65. package/docs/api/capture-packed-sensor-raw.md +7 -1
  66. package/docs/api/core-pixel-limits.md +19 -0
  67. package/docs/api/core-version.md +1 -1
  68. package/docs/api/exports.json +62 -2
  69. package/docs/api/facade-chart-radiance-batch.md +8 -2
  70. package/docs/api/facade-simulate-capture.md +83 -3
  71. package/docs/api/motion-temporal-exposure-sum.md +7 -1
  72. package/docs/api/optics-diffraction-psf-profile.md +96 -0
  73. package/docs/api/optics-irradiance-convolution.md +7 -1
  74. package/docs/api/output-native-capture-sdr.md +7 -2
  75. package/docs/api/output-png-encoder.md +8 -1
  76. package/docs/api/output-print-jpeg.md +17 -0
  77. package/package.json +3 -2
@@ -22,6 +22,8 @@
22
22
  * - optics: the environment `lensFieldMapping` (projection, radial / Brown–Conrady distortion), radial
23
23
  * illumination vignetting as field throughput and a caller sampled lens PSF;
24
24
  * - motion: constant camera angular velocity and the electronic rolling-shutter exposure schedule.
25
+ * The 2.1 options are opt-in in the same way: `lens.diffraction`, `output.demosaic: "ahd"` and
26
+ * `sensor.antiAliasing` (a birefringent point-splitting low-pass filter in the sensor optical stack).
25
27
  * @see docs/QUICK_START.md
26
28
  */
27
29
  import { InvalidConfigurationError } from "../core/configuration-error.js";
@@ -41,30 +43,46 @@ import { estimateCie1931ColorMatchingFunctions } from "../color/color-matching-f
41
43
  import { createColorTemperatureWhiteBalanceIntent, resolveColorTemperatureWhiteBalance, resolveCustomWhiteBalance, resolveManualWhiteBalance, estimateAutoWhiteBalance } from "../color/white-balance.js";
42
44
  import { createCaptureWhiteBalanceIntent } from "../capture/simulated-capture.js";
43
45
  import { SENSOR_RAW_PRODUCER_NOISE_MODEL } from "../capture/sensor-raw-producer.js";
44
- import { calculateNativeRawPlan } from "../capture/native-raw.js";
46
+ import { calculateNativeRawPlan, nativeRawMaximumProviderEvaluations, NATIVE_RAW_LIMITS } from "../capture/native-raw.js";
45
47
  import { createNativeEnvironmentRawTask } from "../capture/native-environment-raw.js";
46
48
  import { createNativeRawDevelopmentTask } from "../capture/native-raw-development.js";
47
49
  import { parseSdrRenderingProfile } from "../output/sdr-rendering.js";
48
- import { createPrintJpegTask } from "../output/print-jpeg.js";
50
+ import { createPrintJpegTask, PRINT_JPEG_LIMITS } from "../output/print-jpeg.js";
49
51
  import { checkNativeRawIntegrity, prepareNativeRawDevelopment } from "../capture/native-raw-development.js";
50
- import { createRawPixelDeveloper } from "../capture/raw-development-kernel.js";
52
+ import { createRawPixelDeveloper, normalizedRawSite, rawSiteSaturated } from "../capture/raw-development-kernel.js";
53
+ import { createAhdDemosaicTask } from "../processing/demosaic.js";
54
+ import { PROCESSING_MAX_PIXELS } from "../processing/processing-task.js";
55
+ import { renderSdrRunInto } from "../output/native-capture-sdr.js";
56
+ import { prepareSdrPixelTone } from "../output/sdr-rendering.js";
51
57
  import { BUILT_IN_BAYER_DEMOSAIC_MODEL_VERSION as BUILT_IN_DEMOSAIC_VERSION, createBuiltInBayerDemosaicProfiles } from "../sensor/raw-reconstruction.js";
52
58
  import { transformOrientedRasterPointToNative } from "../output/capture-geometry.js";
53
59
  import { resolveCaptureColorModel } from "../color/capture-color.js";
54
60
  import { evaluateBt709Oetf, evaluateSrgbEncoding, resolveRgbColorSpace } from "../color/rgb-color-spaces.js";
55
- import { serializePngImage } from "../output/png-encoder.js";
61
+ import { serializePngImage, PNG_ENCODER_LIMITS } from "../output/png-encoder.js";
56
62
  import { parseLensProjectionModel } from "../camera/lens-projection.js";
57
63
  import { prepareEnvironmentLensFieldMapping } from "../optics/sensor-environment-query.js";
58
64
  import { createRollingShutterCaptureExposureSchedule, resolveElectronicRollingShutterExposureSchedule } from "../sensor/readout-exposure-linkage.js";
59
65
  import { calculateCaptureExposureWindows } from "../sensor/exposure-window.js";
66
+ import { calculateSensorSpectralQuadrature } from "../sensor/spectral-quadrature.js";
67
+ import { createDiffractionLimitedPsfProfile } from "../optics/diffraction-psf-profile.js";
68
+ import { createDiffractedPlanarProvider } from "./diffraction-provider.js";
69
+ import { resolveAntiAliasingSpatialKernel } from "../sensor/optical-stack.js";
60
70
  import { multiplyMatrices } from "../color/colorimetry-math.js";
61
71
  import { prepareRgbGamutMapping } from "../color/gamut-mapping.js";
62
72
  import { serializeNativeRawDng } from "../capture/native-raw-dng.js";
63
- import { resolveSensorReadoutRegime } from "../sensor/raw-readout.js";
73
+ import { parseSensorReadoutConversionProfile, resolveSensorReadoutRegime } from "../sensor/raw-readout.js";
64
74
  import { resolveChartScene, CHART_SCENE_PRESETS } from "./chart-scenes.js";
65
75
  import { resolveCaptureIlluminant, calculateIlluminantIrradianceScale, buildPlanarSceneRadianceProvider, requireCallerScene, FACADE_SPECTRAL_RANGE_NANOMETERS, PHOTOPIC_MAXIMUM_LUMINOUS_EFFICACY_LM_PER_W } from "./scene-radiance.js";
66
76
  /** Version of how the facade builds requests. It changes whenever the same input would build a different request. */
67
77
  export const SIMULATE_CAPTURE_FACADE_VERSION = "1.0.0";
78
+ /**
79
+ * Options reported in `diagnostics.options` (2.1), in this order, when they ran: the ones that change how much work
80
+ * a capture does. `motion.rotation` is a non-zero angular velocity; `sensor.temporalSampleCount` a count above 1.
81
+ */
82
+ 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",
84
+ "output.whiteBalance.auto", "output.demosaic.ahd", "output.highlightMode.reconstruct", "output.colorEncoding.wide-gamut", "output.gamutMapping.perceptual",
85
+ "output.format.jpeg", "output.format.png", "output.format.dng"]);
68
86
  const FACADE_EVIDENCE = Object.freeze([Object.freeze({ sourceOrigin: "photivra",
69
87
  sourceReference: "photivra:simulate-capture-facade", reuseStatus: "photivra-owned" })]);
70
88
  const DEFAULTS = Object.freeze({ camera: "professional", fNumber: 8, shutterSeconds: 1 / 125, iso: 100, compensationStops: 0,
@@ -88,13 +106,74 @@ function sosReferenceLuminance(fNumber, shutterSeconds, iso) {
88
106
  */
89
107
  export const SIMULATE_CAPTURE_DEFAULT_ILLUMINANCE_LUX = Math.PI * sosReferenceLuminance(DEFAULTS.fNumber, DEFAULTS.shutterSeconds, DEFAULTS.iso) /
90
108
  GREY_CARD_REFLECTANCE;
109
+ /**
110
+ * Sensitive-aperture area (µm²) at which the tier's ISO signal chain applies unchanged: the default 6 µm square
111
+ * aperture with fill factor 1. At other areas the facade scales the electron-domain readout values by
112
+ * area / this reference, so the same focal-plane exposure H gives the same output code (see `isoAreaScaledReadout`).
113
+ */
114
+ const ISO_REFERENCE_APERTURE_AREA_SQUARE_MICROMETERS = DEFAULTS.pixelPitchMicrometers * DEFAULTS.pixelPitchMicrometers;
91
115
  /** Demosaic border, native sites per side. */
92
116
  const MARGIN = 1;
117
+ /**
118
+ * AHD border, native sites per side: an AHD output pixel depends on sites up to 5 away (directional green ±2,
119
+ * colour differences ±1 of it, the homogeneity's 4-neighbours and its 3 × 3 sum), so a 5-site rendered margin keeps
120
+ * every output pixel off the plane's mirrored edge.
121
+ */
122
+ const AHD_MARGIN = 5;
123
+ /** Sites whose values enter an AHD output pixel (interpolation reach), for the saturated-pixel count. */
124
+ const AHD_VALUE_REACH = 3;
125
+ /** Anti-aliasing filter limits: split points, and the largest offset in sites (bounds the scene work and the convolution margin). */
126
+ const AA_MAXIMUM_COMPONENTS = 16;
127
+ const AA_MAXIMUM_OFFSET_SITES = 4;
128
+ /** Default birefringent separation in sites: the two-spot MTF |cos(π·f·d)| then has its first zero at Nyquist, f = 1/(2·pitch). */
129
+ const AA_DEFAULT_SEPARATION_SITES = 1;
130
+ /** Parses `sensor.antiAliasing` into engine point-splitting components (offsets in µm, +x right, +y down). */
131
+ function parseAntiAliasing(value, pitch) {
132
+ requireInputObject(value, "sensor.antiAliasing");
133
+ const kind = value.kind;
134
+ const separation = (record) => {
135
+ if (record.separationSites === undefined)
136
+ return AA_DEFAULT_SEPARATION_SITES;
137
+ const d = positive(record.separationSites, "sensor.antiAliasing.separationSites");
138
+ if (d > AA_MAXIMUM_OFFSET_SITES)
139
+ fail("sensor.antiAliasing.separationSites must be greater than 0 and at most " + AA_MAXIMUM_OFFSET_SITES + ".");
140
+ return d;
141
+ };
142
+ if (kind === "four-spot") {
143
+ const record = allowKeys(value, "sensor.antiAliasing", ["kind", "separationSites"]), d = separation(record), h = d * pitch / 2;
144
+ return { kind, separationSites: d, components: [[-h, -h], [h, -h], [-h, h], [h, h]].map(([x, y]) => ({ offsetMicrometers: { x: x, y: y }, normalizedWeight: 0.25 })) };
145
+ }
146
+ if (kind === "two-spot") {
147
+ const record = allowKeys(value, "sensor.antiAliasing", ["kind", "axis", "separationSites"]), d = separation(record), h = d * pitch / 2;
148
+ if (record.axis !== "horizontal" && record.axis !== "vertical")
149
+ fail("sensor.antiAliasing.axis must be horizontal or vertical.");
150
+ const axis = record.axis;
151
+ return { kind, axis, separationSites: d, components: [-h, h].map((o) => ({ offsetMicrometers: axis === "horizontal" ? { x: o, y: 0 } : { x: 0, y: o }, normalizedWeight: 0.5 })) };
152
+ }
153
+ if (kind === "point-splitting") {
154
+ const record = allowKeys(value, "sensor.antiAliasing", ["kind", "components"]);
155
+ if (!Array.isArray(record.components) || record.components.length === 0 || record.components.length > AA_MAXIMUM_COMPONENTS) {
156
+ fail("sensor.antiAliasing.components must be an array of 1 to " + AA_MAXIMUM_COMPONENTS + " components.");
157
+ }
158
+ // The engine's optical-stack parser checks shape, finiteness, positive weights and the unit sum (1e-12).
159
+ const kernel = resolveAntiAliasingSpatialKernel({ schemaVersion: "0.1.0", profileId: "facade-aa-check", evidence: FACADE_EVIDENCE,
160
+ effectiveAntiAliasingSpatialResponse: { kind: "normalized-point-splitting-kernel", evidence: FACADE_EVIDENCE, coordinateSystem: "native-sensor-physical",
161
+ scope: "field-wavelength-polarization-invariant-approximation", components: record.components } });
162
+ for (const c of kernel.components)
163
+ if (Math.max(Math.abs(c.offsetMicrometers.x), Math.abs(c.offsetMicrometers.y)) > AA_MAXIMUM_OFFSET_SITES * pitch) {
164
+ fail("sensor.antiAliasing.components offsets must lie within " + AA_MAXIMUM_OFFSET_SITES + " sites (" + AA_MAXIMUM_OFFSET_SITES * pitch + " µm) of the site centre.");
165
+ }
166
+ return { kind, components: kernel.components.map((c) => ({ offsetMicrometers: { ...c.offsetMicrometers }, normalizedWeight: c.normalizedWeight })) };
167
+ }
168
+ return fail("sensor.antiAliasing.kind must be four-spot, two-spot or point-splitting.");
169
+ }
170
+ /** Third dark ring of the Airy pattern in units of λ·N_w (j₁,₃/π, j₁,₃ = 10.1734681351): the default diffraction kernel reach. */
171
+ const AIRY_THIRD_ZERO_PER_LAMBDA_N = 10.173468135062722 / Math.PI;
93
172
  /** Default temporal midpoints when the camera rotates (2.0 motion option). */
94
173
  const ROTATING_TEMPORAL_SAMPLE_COUNT = 8;
95
174
  const IDS = Object.freeze({ frame: "facade-frame", mode: "native-still", cfa: "photivra-generic-bayer", binding: "facade-native-cfa",
96
175
  sceneState: "facade-scene-state", scene: "facade-scene", provider: "facade-provider", lights: "facade-lights", materials: "facade-materials",
97
- capture: "facade-capture", spectral: "facade-colorimetric-eqe", sampling: "facade-sampling", stack: "facade-no-aa",
176
+ capture: "facade-capture", spectral: "facade-colorimetric-eqe", sampling: "facade-sampling", stack: "facade-no-aa", stackAa: "facade-aa",
98
177
  stationarity: "facade-stationarity", operatingState: "facade-operating-state", dark: "facade-dark", completeness: "facade-completeness",
99
178
  chargeSampling: "facade-charge-sampling", capacity: "facade-capacity" });
100
179
  const CHANNELS = ["red", "green", "blue"];
@@ -112,6 +191,19 @@ const XYZ_TO_CAMERA = [[EQE_SCALE.red / EQE_SCALE.green, 0, 0], [0, 1, 0], [0, 0
112
191
  const DARK_CURRENT_ELECTRONS_PER_SECOND = 1;
113
192
  const OPERATING_TEMPERATURE_C = 20;
114
193
  function fail(message) { throw new InvalidConfigurationError(message); }
194
+ /**
195
+ * The readout profile with every regime's charge-domain transfer scaled by `areaScale`: system conversion gain
196
+ * (e⁻ per code) and pre-ADC saturation (e⁻ equivalent) are multiplied by it, so codes per unit focal-plane exposure
197
+ * stay those of the reference aperture. Read noise stays in electrons per pixel; codes, black level, ADC and digital
198
+ * gain are unchanged. The result is re-checked by the readout parser.
199
+ */
200
+ function isoAreaScaledReadout(profile, areaScale) {
201
+ return parseSensorReadoutConversionProfile({ ...profile, regimes: profile.regimes.map((regime) => ({ ...regime,
202
+ systemConversionGainElectronsPerCode: { ...regime.systemConversionGainElectronsPerCode, value: regime.systemConversionGainElectronsPerCode.value * areaScale },
203
+ preAdcSaturationElectronEquivalent: { ...regime.preAdcSaturationElectronEquivalent, value: regime.preAdcSaturationElectronEquivalent.value * areaScale } })) });
204
+ }
205
+ /** Diagnostic clock for the 2.1 stage timings: `performance.now()` where the host has it. Never affects output. */
206
+ function clockMs() { return globalThis.performance?.now() ?? Date.now(); }
115
207
  function positive(value, name) {
116
208
  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0)
117
209
  fail(name + " must be a positive finite number.");
@@ -222,7 +314,7 @@ function pngColourEncoding(encoding) {
222
314
  * clipping, white balance, highlight mode and the camera→XYZ→linear-sRGB matrices are all the same. It
223
315
  * stops before tone mapping, gamut clipping and quantization. It reads the RAW without taking ownership.
224
316
  */
225
- async function developLinearSrgb(input, afterRow) {
317
+ async function developLinearSrgb(input, afterRow, ahd) {
226
318
  const v = prepareNativeRawDevelopment(input), plan = v.plan, raw = input.raw, layout = plan.frame.colorSamplingProfile.layout;
227
319
  if (layout.kind !== "periodic-mosaic")
228
320
  fail("Development requires a periodic CFA.");
@@ -235,6 +327,8 @@ async function developLinearSrgb(input, afterRow) {
235
327
  const q = transformOrientedRasterPointToNative({ point: { x: v.crop.x + x + 0.5, y: v.crop.y + y + 0.5 }, nativeRaster: g.activeCapture.raster, orientation: plan.exposure.geometry.orientation });
236
328
  return { x: Math.floor(q.x) + active.x, y: Math.floor(q.y) + active.y };
237
329
  };
330
+ if (ahd)
331
+ return developAhd(v, raw, layout.repeatWidthSites, layout.repeatHeightSites, width, height, at, afterRow, ahd);
238
332
  for (let y = 0; y < height; y++) {
239
333
  const first = at(0, y), second = width > 1 ? at(1, y) : first;
240
334
  developer.developRun(first.x, first.y, second.x - first.x, second.y - first.y, width, row);
@@ -245,6 +339,57 @@ async function developLinearSrgb(input, afterRow) {
245
339
  return { rgb, highlight: { mode: v.highlightMode, saturatedPixelCount: c.saturatedPixelCount, whiteBalanceClippedSampleCount: c.whiteBalanceClippedSampleCount,
246
340
  reconstructedPixelCount: c.reconstructedPixelCount } };
247
341
  }
342
+ /**
343
+ * AHD development (2.1): the same site normalization as the native development kernel (flagged or white-level sites
344
+ * are 1, others (code − black)/(white − black)) on the whole native raster, demosaiced by `createAhdDemosaicTask`
345
+ * (mirrored plane edges), then the native development's own colour operator (`developInto`: WB, highlight mode,
346
+ * camera → XYZ → linear sRGB) per output pixel. The AHD homogeneity metric sees white-balanced colour: its
347
+ * camera → XYZ matrix is the profile's matrix times the WB gains (the output values are not transformed by it).
348
+ * The mosaic is stored in float32, as the AHD task requires.
349
+ */
350
+ async function developAhd(v, raw, repeatWidth, repeatHeight, width, height, at, afterRow, host) {
351
+ const native = v.plan.exposure.geometry.nativeRaster, W = native.pixelWidth, H = native.pixelHeight;
352
+ const source = { nativeWidth: W, repeatWidth, repeatHeight, codes: raw.codes, blackLevels: raw.blackLevels,
353
+ saturationFlags: raw.saturationFlags, whiteByPhase: raw.effectiveSaturationCodes };
354
+ const samples = new Float32Array(W * H), saturated = new Uint8Array(W * H);
355
+ for (let y = 0; y < H; y++)
356
+ for (let x = 0; x < W; x++) {
357
+ samples[y * W + x] = normalizedRawSite(source, x, y);
358
+ if (rawSiteSaturated(source, x, y))
359
+ saturated[y * W + x] = 1;
360
+ }
361
+ const intent = v.plan.exposure.whiteBalanceIntent, gains = intent ? [intent.channelGains.red, intent.channelGains.green, intent.channelGains.blue] : [1, 1, 1];
362
+ const m = v.color.normalizedCameraChannelsToXyz;
363
+ const cameraToXyz = [0, 1, 2].map((r) => [0, 1, 2].map((c) => m[r][c] * gains[c]));
364
+ // A caller-raised pixel limit already bounds the raster, so its working memory is not capped again at the 1 GiB default.
365
+ const task = createAhdDemosaicTask({ plane: { width: W, height: H, captureModeId: v.plan.frame.modeId, colorSamplingProfile: v.plan.frame.colorSamplingProfile, samples },
366
+ channelRoles: { red: "red", green: "green", blue: "blue" }, cameraToXyz, ...(v.plan.maximumPixels === undefined ? {} : { maximumWorkingBytes: Number.MAX_SAFE_INTEGER }) }, { yieldControl: async () => { await host.yieldControl(); host.checkAbort(); } });
367
+ const demosaicStarted = clockMs();
368
+ await task.run();
369
+ host.onDemosaicMs?.(clockMs() - demosaicStarted);
370
+ const demosaiced = task.takeOutput().data, rgb = new Float64Array(width * height * 3);
371
+ const counts = { saturatedPixelCount: 0, whiteBalanceClippedSampleCount: 0, reconstructedPixelCount: 0 };
372
+ for (let y = 0; y < height; y++) {
373
+ for (let x = 0; x < width; x++) {
374
+ const p = at(x, y), i = p.y * W + p.x;
375
+ let clipped = false;
376
+ for (let dy = -AHD_VALUE_REACH; dy <= AHD_VALUE_REACH && !clipped; dy++)
377
+ for (let dx = -AHD_VALUE_REACH; dx <= AHD_VALUE_REACH; dx++) {
378
+ const sx = p.x + dx, sy = p.y + dy;
379
+ if (sx >= 0 && sy >= 0 && sx < W && sy < H && saturated[sy * W + sx] !== 0) {
380
+ clipped = true;
381
+ break;
382
+ }
383
+ }
384
+ if (clipped)
385
+ counts.saturatedPixelCount++;
386
+ v.development.developInto(demosaiced[3 * i], demosaiced[3 * i + 1], demosaiced[3 * i + 2], rgb, (y * width + x) * 3, counts);
387
+ }
388
+ await afterRow(y + 1, height);
389
+ }
390
+ return { rgb, highlight: { mode: v.highlightMode, saturatedPixelCount: counts.saturatedPixelCount, whiteBalanceClippedSampleCount: counts.whiteBalanceClippedSampleCount,
391
+ reconstructedPixelCount: counts.reconstructedPixelCount } };
392
+ }
248
393
  /** Linear sRGB/D65 → CIE XYZ (inverse of the development's XYZ → sRGB matrix) → linear target RGB/D65. */
249
394
  function linearSrgbToTarget(target) {
250
395
  return multiplyMatrices(resolveRgbColorSpace(target).value.xyzToRgb, resolveCaptureColorModel().cameraRgbToXyz);
@@ -327,7 +472,7 @@ export async function simulateCapture(options) {
327
472
  preset = parseCallerCameraPreset(cameraOption);
328
473
  const body = preset.body, lens = preset.lens;
329
474
  // Lens
330
- const lensOptions = allowKeys(top.lens, "lens", ["focalLengthMm", "fNumber", "focusDistanceM", "pupilSampling", "projection", "distortion", "vignetting", "psf"]);
475
+ const lensOptions = allowKeys(top.lens, "lens", ["focalLengthMm", "fNumber", "focusDistanceM", "pupilSampling", "projection", "distortion", "vignetting", "psf", "diffraction"]);
331
476
  const focalDefault = lens.exposure.focalLengthMmRange.value.minimum;
332
477
  const focalLengthMm = positive(pick(lensOptions, "focalLengthMm", "lens.focalLengthMm", focalDefault, "The preset lens's focal length (the generic primes are 50 mm)."), "lens.focalLengthMm");
333
478
  const capabilities = resolveGenericEquipmentExposureCapabilities({ bodyProfile: body.exposure, lensProfile: lens.exposure, selectedFocalLengthMm: focalLengthMm });
@@ -383,6 +528,23 @@ export async function simulateCapture(options) {
383
528
  fail("lens.psf cannot be combined with lens.pupilSampling: geometric defocus would be counted twice.");
384
529
  lensPsf = p;
385
530
  }
531
+ // Diffraction (2.1): validated here, built once the f-number is resolved.
532
+ let diffractionRadiusOption;
533
+ if (lensOptions.diffraction !== undefined) {
534
+ const d = lensOptions.diffraction;
535
+ if (d === "circular-aperture")
536
+ diffractionRadiusOption = null;
537
+ else {
538
+ const record = allowKeys(d, "lens.diffraction", ["aperture", "kernelRadiusSamples"]);
539
+ if (record.aperture !== "circular-aperture")
540
+ fail("lens.diffraction must be \"circular-aperture\" or { aperture: \"circular-aperture\", kernelRadiusSamples }.");
541
+ diffractionRadiusOption = record.kernelRadiusSamples === undefined ? null : integerIn(record.kernelRadiusSamples, "lens.diffraction.kernelRadiusSamples", 0, 32);
542
+ }
543
+ if (lensPsf)
544
+ fail("lens.diffraction cannot be combined with lens.psf: diffraction would be counted twice.");
545
+ if (pupil)
546
+ fail("lens.diffraction cannot be combined with lens.pupilSampling: the environment contract does not mix a sampled PSF with pupil rays.");
547
+ }
386
548
  // Motion (2.0): constant camera rotation and the shutter type.
387
549
  const motionOptions = allowKeys(top.motion, "motion", ["angularVelocityRadPerSec", "shutter", "rollingShutterSkewSeconds"]);
388
550
  const angular = { pitch: 0, yaw: 0, roll: 0 };
@@ -403,11 +565,17 @@ export async function simulateCapture(options) {
403
565
  if (motionOptions.rollingShutterSkewSeconds !== undefined && shutterKind !== "rolling")
404
566
  fail("motion.rollingShutterSkewSeconds needs motion.shutter \"rolling\".");
405
567
  const rollingSkewOption = motionOptions.rollingShutterSkewSeconds === undefined ? undefined : positive(motionOptions.rollingShutterSkewSeconds, "motion.rollingShutterSkewSeconds");
568
+ // Diffraction runs as an object-space convolution when every PSF tap's source is another site centre seen
569
+ // through one linear map (still camera, rectilinear, no distortion or field throughput); otherwise per tap.
570
+ const diffractionObjectSpace = diffractionRadiusOption !== undefined && !rotating && !lensFieldMapping && !vignetting;
406
571
  // Output and sensor
407
572
  const outputOptions = allowKeys(top.output, "output", ["width", "height", "format", "toneCurve", "toneCurveParameters", "highlightMode", "whiteBalance", "jpegQuantizationStep",
408
- "colorEncoding", "bitDepth", "gamutMapping", "demosaic", "borderHandling", "dng"]);
409
- const width = integerIn(pick(outputOptions, "width", "output.width", DEFAULTS.width, "64 × 64 output pixels."), "output.width", 1, 4094);
410
- const height = integerIn(pick(outputOptions, "height", "output.height", DEFAULTS.height, "64 × 64 output pixels."), "output.height", 1, 4094);
573
+ "colorEncoding", "bitDepth", "gamutMapping", "demosaic", "borderHandling", "dng", "maximumPixels"]);
574
+ // Opt-in large images: a caller-raised native pixel limit. Without it the 4094-pixel sides are unchanged.
575
+ const raisedPixels = outputOptions.maximumPixels === undefined ? undefined : integerIn(outputOptions.maximumPixels, "output.maximumPixels", 1, NATIVE_RAW_LIMITS.ceilingPixels);
576
+ const maximumSide = raisedPixels === undefined ? 4094 : NATIVE_RAW_LIMITS.maximumDimension;
577
+ const width = integerIn(pick(outputOptions, "width", "output.width", DEFAULTS.width, "64 × 64 output pixels."), "output.width", 1, maximumSide);
578
+ const height = integerIn(pick(outputOptions, "height", "output.height", DEFAULTS.height, "64 × 64 output pixels."), "output.height", 1, maximumSide);
411
579
  const format = pick(outputOptions, "format", "output.format", DEFAULTS.format, "8-bit sRGB samples.");
412
580
  if (!["srgb-8", "srgb-16", "jpeg", "png", "dng"].includes(format))
413
581
  fail("output.format must be srgb-8, srgb-16, jpeg, png or dng.");
@@ -451,7 +619,7 @@ export async function simulateCapture(options) {
451
619
  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;
452
620
  if (format !== "jpeg" && outputOptions.jpegQuantizationStep !== undefined)
453
621
  fail("output.jpegQuantizationStep applies only to output.format \"jpeg\".");
454
- const sensorOptions = allowKeys(top.sensor, "sensor", ["pixelPitchMicrometers", "spectralStepNanometers", "temporalSampleCount", "noiseSeed"]);
622
+ const sensorOptions = allowKeys(top.sensor, "sensor", ["pixelPitchMicrometers", "spectralStepNanometers", "temporalSampleCount", "noiseSeed", "antiAliasing"]);
455
623
  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");
456
624
  if (pitch < 0.5 || pitch > 100)
457
625
  fail("sensor.pixelPitchMicrometers must be 0.5-100.");
@@ -461,23 +629,35 @@ export async function simulateCapture(options) {
461
629
  const temporalSampleCount = integerIn(rotating ? pick(sensorOptions, "temporalSampleCount", "sensor.temporalSampleCount", ROTATING_TEMPORAL_SAMPLE_COUNT, "Eight temporal midpoints across each site's exposure window sample the camera rotation (motion blur); raise it for long exposures or fast rotation.") :
462
630
  pick(sensorOptions, "temporalSampleCount", "sensor.temporalSampleCount", DEFAULTS.temporalSampleCount, "One temporal sample: the static scene and still camera make more samples unnecessary."), "sensor.temporalSampleCount", 1, 256);
463
631
  const noiseSeed = integerIn(pick(sensorOptions, "noiseSeed", "sensor.noiseSeed", DEFAULTS.noiseSeed, "Fixed noise seed 1 (deterministic output)."), "sensor.noiseSeed", 0, 0xffffffff);
632
+ const antiAliasing = sensorOptions.antiAliasing === undefined ? undefined : parseAntiAliasing(sensorOptions.antiAliasing, pitch);
464
633
  const colorSamplingProfile = { schemaVersion: "0.1.0", profileId: IDS.cfa, evidence: FACADE_EVIDENCE, coordinateSystem: "native-sensor-color-sampling-site-index",
465
634
  layout: { kind: "periodic-mosaic", repeatWidthSites: 2, repeatHeightSites: 2, siteChannelIds: ["red", "green", "green", "blue"], anchor: "native-sensor-top-left-site" } };
466
635
  // Demosaic (2.0): built-in Bayer kernels and border handling. Omitted, the 1.0 bilinear kernels and a
467
636
  // one-site rendered border are used unchanged.
468
637
  const demosaicOption = outputOptions.demosaic, borderOption = outputOptions.borderHandling;
469
- if (demosaicOption !== undefined && demosaicOption !== "bilinear")
470
- fail("output.demosaic must be bilinear.");
638
+ if (demosaicOption !== undefined && demosaicOption !== "bilinear" && demosaicOption !== "ahd")
639
+ fail("output.demosaic must be bilinear or ahd.");
640
+ const ahd = demosaicOption === "ahd";
471
641
  if (borderOption !== undefined && borderOption !== "margin" && borderOption !== "mirror")
472
642
  fail("output.borderHandling must be margin or mirror.");
473
643
  const builtInDemosaic = demosaicOption === undefined ? undefined : createBuiltInBayerDemosaicProfiles({ kernel: "bilinear",
474
644
  captureModeId: IDS.mode, colorSamplingProfile, channelRoles: { red: "red", green: "green", blue: "blue" } }).value;
475
645
  const mirrorBorder = borderOption === "mirror";
476
- const margin = mirrorBorder ? 0 : builtInDemosaic ? builtInDemosaic.kernelReachSites : MARGIN;
646
+ const margin = mirrorBorder ? 0 : ahd ? AHD_MARGIN : builtInDemosaic ? builtInDemosaic.kernelReachSites : MARGIN;
477
647
  if (mirrorBorder && (width < 2 || height < 2))
478
648
  fail("output.borderHandling \"mirror\" needs an output of at least 2 × 2 pixels.");
649
+ if (ahd && mirrorBorder && (width < 3 || height < 3))
650
+ fail("output.demosaic \"ahd\" with mirror borders needs an output of at least 3 × 3 pixels.");
479
651
  // Geometry: the demosaic border (if any) on each side; output crop is the active area.
480
652
  const nativeWidth = width + 2 * margin, nativeHeight = height + 2 * margin;
653
+ if (raisedPixels !== undefined) {
654
+ if (nativeWidth > NATIVE_RAW_LIMITS.maximumDimension || nativeHeight > NATIVE_RAW_LIMITS.maximumDimension || nativeWidth * nativeHeight > raisedPixels) {
655
+ fail("output.maximumPixels (" + raisedPixels + ") must cover the native sensor raster of " + nativeWidth + " × " + nativeHeight +
656
+ " sites (the output plus the demosaic margin), at most " + NATIVE_RAW_LIMITS.maximumDimension + " per side.");
657
+ }
658
+ if (ahd && nativeWidth * nativeHeight > PROCESSING_MAX_PIXELS)
659
+ fail("output.demosaic \"ahd\" admits at most " + PROCESSING_MAX_PIXELS + " native sites.");
660
+ }
481
661
  const imagingArea = { widthMm: nativeWidth * pitch / 1000, heightMm: nativeHeight * pitch / 1000 };
482
662
  const geometry = { imagingArea, nativeRaster: { pixelWidth: nativeWidth, pixelHeight: nativeHeight }, orientation: "landscape",
483
663
  activeCaptureRect: { x: margin, y: margin, width, height }, outputRaster: { pixelWidth: width, pixelHeight: height } };
@@ -492,6 +672,8 @@ export async function simulateCapture(options) {
492
672
  else {
493
673
  if (demosaicOption === undefined)
494
674
  assume("output.demosaic", "default", "bilinear-rggb", "The 1.0 facade's bilinear RGGB kernels (explicit linear neighbourhood weights).");
675
+ else if (ahd)
676
+ assume("output.demosaic", "model", { method: "ahd-hirakawa-parks-2005", dependenceReachSites: AHD_MARGIN }, "Adaptive homogeneity-directed demosaicing (createAhdDemosaicTask, after Hirakawa & Parks, IEEE TIP 14(3), 2005): directional green, red/blue from colour differences, and per pixel the more homogeneous of the horizontal and vertical candidates in CIELAB (white-balanced camera colour); the paper's iterative median step is not applied. Non-linear; the mosaic is normalized and white-level clipped as in the native development and stored in float32, and the native development's colour operator follows. request.development keeps the built-in bilinear kernels, which validate the development but are not used for these pixels.");
495
677
  else
496
678
  assume("output.demosaic", "model", { kernel: builtInDemosaic.kernel, modelVersion: BUILT_IN_DEMOSAIC_VERSION, kernelReachSites: builtInDemosaic.kernelReachSites }, "Built-in bilinear Bayer kernels (createBuiltInBayerDemosaicProfiles): nearest same-channel averages.");
497
679
  if (borderOption === undefined)
@@ -505,7 +687,7 @@ export async function simulateCapture(options) {
505
687
  const psfTaps = lensPsf ? Math.max(1, ...(lensPsf.configuration.psf.profile.nodes ?? [])
506
688
  .map((node) => Number(node.kernel?.widthSamples) * Number(node.kernel?.heightSamples)).filter((taps) => Number.isSafeInteger(taps) && taps > 0)) : 1;
507
689
  const queriesPerSite = temporalSampleCount * (Math.ceil((FACADE_SPECTRAL_RANGE_NANOMETERS.maximum - FACADE_SPECTRAL_RANGE_NANOMETERS.minimum) / spectralStep) + 1) *
508
- (pupil ? pupil.radialSamples * pupil.angularSamples : 1) * psfTaps;
690
+ (pupil ? pupil.radialSamples * pupil.angularSamples : 1) * psfTaps * (antiAliasing ? antiAliasing.components.length : 1);
509
691
  // Scene and illuminant
510
692
  // The scene is the one required input: the facade never guesses what is being photographed.
511
693
  if (top.scene === undefined)
@@ -621,20 +803,46 @@ export async function simulateCapture(options) {
621
803
  fNumber = resolution.resolvedSettings.aperture ?? fNumber;
622
804
  shutterSeconds = resolution.resolvedSettings.shutterSeconds ?? shutterSeconds;
623
805
  const resolvedIso = resolution.resolvedSettings.iso ?? iso;
624
- // Sensor readout for the ISO setting
806
+ // Sensor readout for the ISO setting. Standard output sensitivity S = 10/H (CIPA DC-004 §3.2) is defined at the
807
+ // output, independent of pixel size, while the collected charge is proportional to the sensitive-aperture area
808
+ // (EMVA 1288 Release 4.0, Eq. 3). The tier's signal chain is anchored to the 6 µm aperture; at another area the
809
+ // charge-domain readout scales with the area so the same H gives the same code. At 6 µm nothing is changed.
810
+ const areaScale = pitch * pitch / ISO_REFERENCE_APERTURE_AREA_SQUARE_MICROMETERS;
625
811
  const regimeByChannel = {};
626
812
  for (const channel of CHANNELS) {
627
813
  const asset = body.readout.find((a) => a.profile.channelId === channel) ?? fail("Camera readout lacks channel " + channel + ".");
628
814
  const chain = resolveGenericIsoSignalChain({ profile: body.signalChain, isoCapability: body.iso, requestedIsoSetting: { kind: "standard", exposureIndex: resolvedIso },
629
815
  captureModeId: IDS.mode, readoutProfile: asset.profile });
630
- regimeByChannel[channel] = { readoutProfile: asset.profile, regimeId: chain.readout.regime.regimeId };
816
+ regimeByChannel[channel] = { readoutProfile: areaScale === 1 ? asset.profile : isoAreaScaledReadout(asset.profile, areaScale), regimeId: chain.readout.regime.regimeId };
631
817
  }
632
- const baseRegime = body.readout[0].profile.regimes[0];
633
- const capacityElectrons = Math.max(...body.readout[0].profile.regimes.map((r) => r.preAdcSaturationElectronEquivalent.value), baseRegime.preAdcSaturationElectronEquivalent.value);
818
+ const capacityRegimes = (areaScale === 1 ? body.readout[0].profile : isoAreaScaledReadout(body.readout[0].profile, areaScale)).regimes;
819
+ const baseRegime = capacityRegimes[0];
820
+ const capacityElectrons = Math.max(...capacityRegimes.map((r) => r.preAdcSaturationElectronEquivalent.value), baseRegime.preAdcSaturationElectronEquivalent.value);
634
821
  assume("sensor.capacityElectrons", "derived", capacityElectrons, "Physical charge capacity set to the body's largest declared pre-ADC saturation (its base-ISO saturation signal), so storage never clips before the declared readout white.");
822
+ if (areaScale !== 1) {
823
+ assume("sensor.isoAreaNormalization", "model", { referenceApertureAreaSquareMicrometers: ISO_REFERENCE_APERTURE_AREA_SQUARE_MICROMETERS,
824
+ apertureAreaSquareMicrometers: pitch * pitch, areaScale }, "Approximation. The tier's ISO signal chain is declared for a 6 µm square aperture. Standard output sensitivity S = 10/H (CIPA DC-004-2020 §3.2) is defined at the output, " +
825
+ "independent of pixel size, and the collected charge is proportional to the aperture area (EMVA 1288 Release 4.0, Eq. 3), so the system conversion gain (e⁻ per code) and the " +
826
+ "pre-ADC saturation of every ISO regime are multiplied by area / 36 µm² = " + areaScale + ". The same exposure gives the same output code at any pitch; per-pixel shot-noise SNR " +
827
+ "falls with smaller pixels. Read noise and dark current stay per pixel in electrons. The charge capacity follows the scaled base-ISO saturation, so it is proportional to " +
828
+ "the aperture area: a design consequence of holding the tier's ISO rating and highlight headroom fixed, not a measured full-well model (published sensor data grow less than " +
829
+ "proportionally with area across technologies).");
830
+ }
635
831
  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.");
636
832
  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 %).");
637
- assume("sensor.fillFactor", "model", 1, "Square sensitive aperture equal to the pixel pitch; no microlens or anti-aliasing filter.");
833
+ if (!antiAliasing)
834
+ assume("sensor.fillFactor", "model", 1, "Square sensitive aperture equal to the pixel pitch; no microlens or anti-aliasing filter.");
835
+ else {
836
+ assume("sensor.fillFactor", "model", 1, "Square sensitive aperture equal to the pixel pitch; no microlens.");
837
+ assume("sensor.antiAliasing", "model", { kind: antiAliasing.kind, ...(antiAliasing.axis ? { axis: antiAliasing.axis } : {}),
838
+ ...(antiAliasing.separationSites === undefined ? {} : { separationSites: antiAliasing.separationSites, separationMicrometers: antiAliasing.separationSites * pitch }),
839
+ components: antiAliasing.components }, "Optical low-pass (anti-aliasing) filter as a normalized point-splitting kernel in the sensor optical stack (resolveAntiAliasingSpatialKernel): every site integrates the light that would " +
840
+ "otherwise land at its centre minus each offset, weighted. " + (antiAliasing.kind === "point-splitting" ? "Caller-declared components. " :
841
+ "This is the ideal birefringent " + (antiAliasing.kind === "four-spot" ? "two-plate (crossed) splitter: four equal spots at (±d/2, ±d/2), MTF |cos(π·f_x·d)·cos(π·f_y·d)|" :
842
+ "single-plate splitter: two equal spots at ±d/2 along the " + antiAliasing.axis + " axis, MTF |cos(π·f·d)|") + ", d = " + antiAliasing.separationSites + " site(s); first zero at f = 1/(2d). ") +
843
+ "Approximations: the split is the same at every wavelength, field point and polarization (birefringence dispersion and the thickness-dependent displacement are not modelled), the spots are exact points with equal weights for unpolarized light, " +
844
+ "and the filter's transmission, reflections and focus shift are not modelled. Not a measured filter; the tier presets declare none. The scene queries are multiplied by " + antiAliasing.components.length + ".");
845
+ }
638
846
  // Shutter (2.0): the electronic rolling-shutter schedule derived from the body's rolling readout declaration.
639
847
  let rollingSchedule;
640
848
  if (shutterKind === "rolling") {
@@ -655,7 +863,8 @@ export async function simulateCapture(options) {
655
863
  assume("motion.angularVelocityRadPerSec", "model", { ...angular }, "Constant camera angular velocity applied to every scene ray at each temporal midpoint of each site's exposure window (engine rotation convention, docs/SENSOR_ENVIRONMENT_QUERY.md); motion blur is the midpoint quadrature over sensor.temporalSampleCount samples.");
656
864
  if (lensPsf)
657
865
  assume("lens.psf", "model", { kind: "sampled-local" }, "Caller-supplied sampled lens PSF redistributes irradiance through the environment PSF quadrature; its evidence and limits are the caller's.");
658
- else
866
+ // With lens.diffraction the PSF is recorded as lens.diffraction once its kernels are built.
867
+ else if (diffractionRadiusOption === undefined)
659
868
  assume("lens.psf", "model", "not-applied", "No lens PSF or diffraction blur is applied (point optics); the generic tiers' optical data are declared only at f/4.");
660
869
  assume("lens.transmission", "model", lens.matchedReference.transmission, "The tier lens's spectrally flat transmission is used at every f-number and focus in the lens capability; the tier declares it only at its f/4, 5 m reference state.");
661
870
  if (!vignetting && !lensFieldMapping)
@@ -729,7 +938,7 @@ export async function simulateCapture(options) {
729
938
  whiteBalanceIntent: intent, adoptedWhiteXyz: null,
730
939
  models: [{ profile: { id: "photivra-simulate-capture", version: SIMULATE_CAPTURE_FACADE_VERSION }, scientificStatus: "approximation", publicEvidenceIds: ["photivra:simulate-capture-facade"] }] },
731
940
  frameId: IDS.frame, modeId: IDS.mode, captureModeProfile, colorSamplingProfile, bindingProfile, exposureWindow,
732
- maximumOutputBytes: nativeWidth * nativeHeight * 7, tileWidth
941
+ maximumOutputBytes: nativeWidth * nativeHeight * 7, tileWidth, ...(raisedPixels === undefined ? {} : { maximumPixels: raisedPixels })
733
942
  });
734
943
  const sceneBinding = { sceneStateId: IDS.sceneState, providerSceneId: IDS.scene, evidence };
735
944
  // Shared per-site environment pieces (identical objects for every site keep the native task's shared-state fast path).
@@ -753,10 +962,14 @@ export async function simulateCapture(options) {
753
962
  siteCenterLattice: { kind: "regular-rectangular-site-center-lattice", coordinateSystem: "native-sensor-physical", pitchXMicrometers: pitch, pitchYMicrometers: pitch,
754
963
  firstSiteCenterFromImagingAreaTopLeftMicrometers: { x: pitch / 2, y: pitch / 2 }, evidence },
755
964
  geometricSensitiveAperture: { kind: "uniform-axis-aligned-rectangle", widthMicrometers: pitch, heightMicrometers: pitch, centerOffsetFromSiteCenterMicrometers: { x: 0, y: 0 }, evidence } };
756
- const opticalStackProfile = { schemaVersion: "0.1.0", profileId: IDS.stack, evidence, effectiveAntiAliasingSpatialResponse: { kind: "absent", evidence } };
965
+ const stackId = antiAliasing ? IDS.stackAa : IDS.stack;
966
+ const opticalStackProfile = antiAliasing ? { schemaVersion: "0.1.0", profileId: stackId, evidence, effectiveAntiAliasingSpatialResponse: {
967
+ kind: "normalized-point-splitting-kernel", evidence, coordinateSystem: "native-sensor-physical", scope: "field-wavelength-polarization-invariant-approximation",
968
+ components: antiAliasing.components
969
+ } } : { schemaVersion: "0.1.0", profileId: IDS.stack, evidence, effectiveAntiAliasingSpatialResponse: { kind: "absent", evidence } };
757
970
  const perChannel = Object.fromEntries(CHANNELS.map((channelId) => [channelId, {
758
971
  applicationProfile: { schemaVersion: "0.1.0", profileId: "facade-application-" + channelId, spectralResponseProfileId: IDS.spectral, colorSamplingProfileId: IDS.cfa, channelId,
759
- samplingApertureProfileId: IDS.sampling, opticalStackProfileId: IDS.stack, evidence, incidentAreaBasis: { kind: "geometric-sensitive-aperture", areaSquareMicrometers: apertureArea },
972
+ samplingApertureProfileId: IDS.sampling, opticalStackProfileId: stackId, evidence, incidentAreaBasis: { kind: "geometric-sensitive-aperture", areaSquareMicrometers: apertureArea },
760
973
  spatialResponseModel: { kind: "uniform-over-geometric-sensitive-aperture", scientificStatus: "approximation", evidence, limitation: "Uniform response over the square aperture." },
761
974
  referenceConditionPolicy: { kind: "exact-match-required" } },
762
975
  operatingRangeProfile: { schemaVersion: "0.1.0", profileId: "facade-linearity-" + channelId, responseApplicationProfileId: "facade-application-" + channelId,
@@ -786,7 +999,8 @@ export async function simulateCapture(options) {
786
999
  outputQuantity: "outgoing-spectral-radiance", outputUnit: "W/m^2/sr/nm", scientificStatus: "approximation",
787
1000
  uncertainty: { kind: "not-quantified", limitation: "Analytic planar scene." },
788
1001
  fidelity: { spectral: "wavelength-resolved", material: "spectral-data", visibility: "resolved", directTransport: "approximation", indirectTransport: "not-modeled" },
789
- wavelengthChangingTransportModeled: false, volumetricTransportModeled: false, polarizationModeled: false, evidence, limitations: ["Engine-authored planar chart or caller scene function."] }
1002
+ wavelengthChangingTransportModeled: false, volumetricTransportModeled: false, polarizationModeled: false, evidence, limitations: ["Engine-authored planar chart or caller scene function.",
1003
+ ...(diffractionObjectSpace ? ["Radiance is the scene radiance convolved with the lens diffraction PSF mapped onto the scene plane (object-space convolution, request.diffraction)."] : [])] }
790
1004
  };
791
1005
  const lensId = lens.exposure.profileId;
792
1006
  const opticsProfile = { schemaVersion: "0.1.0", profileId: lensId + ":facade-transmission", profileVersion: SIMULATE_CAPTURE_FACADE_VERSION, lensProfileId: lensId,
@@ -805,11 +1019,39 @@ export async function simulateCapture(options) {
805
1019
  const fieldThroughput = vignetting ? { kind: "radial-illumination-vignetting", profile: vignetting, evidence,
806
1020
  limitation: "Caller-declared radial relative illumination applied by the capture facade; not a measured lens." } :
807
1021
  { kind: "unity", evidence, limitation: "No vignetting or field throughput loss applied by the facade." };
808
- const psf = lensPsf ?? { kind: "not-applied", evidence, limitation: "No lens PSF (point optics)." };
809
- const pupilRecord = pupil ? { kind: "ideal-uniform-circular-pupil", radialSampleCount: pupil.radialSamples, angularSampleCount: pupil.angularSamples, evidence,
810
- limitation: "Ideal uniform circular pupil; finite ray sampling, no convergence claim." } : undefined;
811
1022
  const spectralSamplingByChannel = Object.fromEntries(CHANNELS.map((channelId) => [channelId, { channelId, wavelengthBasis: "vacuum", wavelengthRangeNanometers: range,
812
1023
  maximumSubintervalWidthNanometers: spectralStep }]));
1024
+ // Diffraction (2.1): Airy kernels at the resolved f-number on the site pitch, one per spectral node of the sensor quadrature.
1025
+ let diffraction, diffractionPsf;
1026
+ if (diffractionRadiusOption !== undefined) {
1027
+ const wavelengths = [...new Set(CHANNELS.flatMap((channelId) => calculateSensorSpectralQuadrature({ ...spectralSamplingByChannel[channelId], colorSamplingProfile,
1028
+ spectralResponseProfile: spectralResponseProfiles[channelId] }).value.nodes.map((node) => node.wavelengthNanometers)))].sort((a, b) => a - b);
1029
+ const lambdaN = wavelengths[wavelengths.length - 1] / 1000 * fNumber * imageDistanceMm / focalLengthMm;
1030
+ // The environment resolves the PSF at each pre-filter source point, up to the largest split offset outside the imaging area.
1031
+ const aaReachMm = antiAliasing ? Math.max(...antiAliasing.components.map((c) => Math.max(Math.abs(c.offsetMicrometers.x), Math.abs(c.offsetMicrometers.y)))) / 1000 : 0;
1032
+ const kernelRadiusSamples = diffractionRadiusOption ?? Math.min(32, Math.max(1, Math.ceil(AIRY_THIRD_ZERO_PER_LAMBDA_N * lambdaN / pitch)));
1033
+ diffraction = createDiffractionLimitedPsfProfile({ aperture: "circular", focalLengthMm, apertureFNumber: fNumber, focus: { kind: "finite", distanceM: focusDistanceM },
1034
+ wavelengthsNm: wavelengths, samplePitchMicrometers: pitch, kernelRadiusSamples, fieldHalfExtentMm: aaReachMm === 0 ? { x: imagingArea.widthMm / 2, y: imagingArea.heightMm / 2 } :
1035
+ { x: imagingArea.widthMm / 2 + aaReachMm, y: imagingArea.heightMm / 2 + aaReachMm },
1036
+ profileId: "facade-diffraction-airy" }).value;
1037
+ diffractionPsf = { kind: "sampled-local", configuration: { psf: { profile: diffraction.profile, focalLengthMm, focus: { kind: "finite", distanceM: focusDistanceM },
1038
+ apertureFNumber: fNumber, signedDefocusImagePlaneMicrometers: 0 }, psfWavelengthBasis: { value: "vacuum", evidence },
1039
+ spatialModel: { kind: "destination-local-shift-invariant-approximation", evidence, limitation: "Diffraction-limited Airy PSF, the same at every field point." } } };
1040
+ assume("lens.diffraction", "model", { model: diffraction.model, aperture: "circular-aperture", fNumber, workingFNumber: diffraction.workingFNumber,
1041
+ kernelRadiusSamples, samplePitchMicrometers: pitch, maximumTruncatedEnergyFraction: diffraction.maximumTruncatedEnergyFraction,
1042
+ truncatedEnergyByWavelength: diffraction.wavelengths.map((w) => ({ wavelengthNm: w.wavelengthNm, truncatedEnergyFraction: w.truncatedEnergyFraction })),
1043
+ execution: diffractionObjectSpace ? "object-space-convolution" : "environment-psf-quadrature" }, "Scalar Fraunhofer diffraction of an ideal, aberration-free circular pupil (Airy pattern, createDiffractionLimitedPsfProfile) at the resolved f-number, in focus, with D = f/N and the thin-lens image distance (working f-number N·v/f). " +
1044
+ "One kernel per spectral node, binned into site-sized cells over a " + (2 * kernelRadiusSamples + 1) + " × " + (2 * kernelRadiusSamples + 1) + " window" +
1045
+ (diffractionRadiusOption === null ? " (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. " +
1046
+ (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). " :
1047
+ "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. ") +
1048
+ "No defocus, aberration or pupil clipping; the sensor pixel aperture is still point-sampled at the site centre.");
1049
+ }
1050
+ const psf = lensPsf ?? (diffractionObjectSpace ? { kind: "not-applied", evidence,
1051
+ limitation: "No environment PSF: lens diffraction is applied upstream by the scene provider as an object-space convolution (request.diffraction)." } : undefined) ??
1052
+ diffractionPsf ?? { kind: "not-applied", evidence, limitation: "No lens PSF (point optics)." };
1053
+ const pupilRecord = pupil ? { kind: "ideal-uniform-circular-pupil", radialSampleCount: pupil.radialSamples, angularSampleCount: pupil.angularSamples, evidence,
1054
+ limitation: "Ideal uniform circular pupil; finite ray sampling, no convergence claim." } : undefined;
813
1055
  const chargeSamplingProfile = { schemaVersion: "0.1.0", profileId: IDS.chargeSampling, completenessProfileId: IDS.completeness, scientificStatus: "approximation",
814
1056
  photoShotNoiseModel: "poisson", darkShotNoiseModel: "poisson", additionalComponentPolicies: [], evidence, limitations: ["Poisson photo and dark shot noise."] };
815
1057
  const channelAt = (x, y) => ["red", "green", "green", "blue"][(y % 2) * 2 + (x % 2)];
@@ -843,12 +1085,20 @@ export async function simulateCapture(options) {
843
1085
  }
844
1086
  const readTile = async (request) => ({ ...request, sites: Array.from({ length: request.width }, (_, j) => siteInput(request.y * nativeWidth + request.x + j)) });
845
1087
  const yieldControl = createHostYield().yieldControl;
846
- const perSite = queriesPerSite;
1088
+ const perSite = queriesPerSite * (diffraction && psf === diffractionPsf ? (2 * diffraction.kernelRadiusSamples + 1) ** 2 : 1);
1089
+ let diffractionMs = 0, demosaicMs, encodeMs;
1090
+ const rawProvider = diffraction && diffractionObjectSpace ? createDiffractedPlanarProvider({ base: provider, psf: diffractionPsf.configuration.psf,
1091
+ wavelengthsNm: diffraction.profile.axes.wavelengthNm, kernelRadius: diffraction.kernelRadiusSamples, nativeWidth, nativeHeight, outputWidth: width,
1092
+ sitePitchMicrometers: pitch, distanceM: subjectDistanceM, metresPerFrameUnit, onConvolution: (ms) => { diffractionMs += ms; },
1093
+ // The environment queries each site at its centre minus each split offset (sites, +x right, +y down).
1094
+ ...(antiAliasing ? { queryOffsetsSites: antiAliasing.components.map((c) => ({ x: -c.offsetMicrometers.x / pitch, y: -c.offsetMicrometers.y / pitch })) } : {}) }) : provider;
1095
+ if (antiAliasing && perSite > 100_000)
1096
+ fail("sensor.antiAliasing with these options needs " + perSite + " scene queries per site; the environment admits at most 100000.");
847
1097
  const evaluations = nativeWidth * nativeHeight * perSite;
848
1098
  // The native task admits at most 100 000 radiance queries per tile.
849
1099
  const tileWidth = Math.max(1, Math.min(256, Math.floor(100_000 / perSite)));
850
1100
  const environmentRaw = { raw: buildRawInput(intentOf(whiteBalanceState)), sceneBinding,
851
- maximumProviderEvaluations: Math.min(2_000_000_000, Math.max(nativeWidth * nativeHeight, evaluations)) };
1101
+ maximumProviderEvaluations: Math.min(nativeRawMaximumProviderEvaluations(raisedPixels === undefined ? {} : { maximumPixels: raisedPixels }), Math.max(nativeWidth * nativeHeight, evaluations)) };
852
1102
  async function runTask(task) {
853
1103
  const onAbort = () => task.cancel();
854
1104
  signal?.addEventListener("abort", onAbort, { once: true });
@@ -876,7 +1126,7 @@ export async function simulateCapture(options) {
876
1126
  const rawTick = progress("raw", plan.tileCount);
877
1127
  const rawTask = createNativeEnvironmentRawTask(environmentRaw, {
878
1128
  readTile: async (request) => { const tile = await readTile(request); rawTick(); return tile; },
879
- evaluateRadiance: provider.evaluateRadiance, ...(pupil ? { evaluateApertureRadiance: provider.evaluateApertureRadiance } : {}), yieldControl
1129
+ evaluateRadiance: rawProvider.evaluateRadiance, ...(pupil ? { evaluateApertureRadiance: provider.evaluateApertureRadiance } : {}), yieldControl
880
1130
  });
881
1131
  await runTask(rawTask);
882
1132
  let rawOutput = rawTask.takeOutput();
@@ -905,7 +1155,7 @@ export async function simulateCapture(options) {
905
1155
  // retained optical stage, and is faster: retaining the optical stage takes the first pass off the lean executor.
906
1156
  const wbTask = createNativeEnvironmentRawTask(finalRawInput, {
907
1157
  readTile: async (request) => { const tile = await readTile(request); wbTick(); return tile; },
908
- evaluateRadiance: provider.evaluateRadiance, ...(pupil ? { evaluateApertureRadiance: provider.evaluateApertureRadiance } : {}), yieldControl
1158
+ evaluateRadiance: rawProvider.evaluateRadiance, ...(pupil ? { evaluateApertureRadiance: provider.evaluateApertureRadiance } : {}), yieldControl
909
1159
  });
910
1160
  await runTask(wbTask);
911
1161
  rawOutput = wbTask.takeOutput();
@@ -924,7 +1174,9 @@ export async function simulateCapture(options) {
924
1174
  return [channel, { conversionGainElectronsPerCode: resolved.regime.systemConversionGainElectronsPerCode.value / (resolved.regime.adc.digitalGain ?? 1),
925
1175
  readNoiseRmsElectrons: resolved.combinedReadNoiseRmsElectrons }];
926
1176
  }));
1177
+ const dngStarted = clockMs();
927
1178
  dng = await serializeNativeRawDng({ raw: rawOutput.raw, colorProfile, noise, ...(dngCapturedAtUtc === undefined ? {} : { capturedAtUtc: dngCapturedAtUtc }) });
1179
+ encodeMs = clockMs() - dngStarted;
928
1180
  checkAbort();
929
1181
  assume("output.dng", "model", { writer: "serializeNativeRawDng", schemaVersion: dng.schemaVersion, whiteLevel: dng.whiteLevel, blackLevels: dng.blackLevels,
930
1182
  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.");
@@ -935,7 +1187,37 @@ export async function simulateCapture(options) {
935
1187
  maximumRetainedPayloadBytes: plan.outputBytes + width * height * 3 * (rendering.bitDepth / 8) };
936
1188
  let developed;
937
1189
  let gamutMappingDiagnostics;
938
- if (!linearPath) {
1190
+ if (ahd) {
1191
+ let lastYield = Date.now();
1192
+ const linear = await developLinearSrgb({ ...development, raw: rawOutput.raw }, async (completed, total) => {
1193
+ onProgress?.({ stage: "development", completedTileCount: completed, totalTileCount: total });
1194
+ if (Date.now() - lastYield >= 8) {
1195
+ await yieldControl();
1196
+ checkAbort();
1197
+ lastYield = Date.now();
1198
+ }
1199
+ }, { yieldControl: () => yieldControl(), checkAbort, onDemosaicMs: (ms) => { demosaicMs = ms; } });
1200
+ if (!linearPath) {
1201
+ // The native development's SDR kernel, row by row, on the AHD-developed linear sRGB.
1202
+ const maximum = 2 ** rendering.bitDepth - 1, tone = prepareSdrPixelTone(rendering), scratch = new Float64Array(3);
1203
+ const codes = rendering.bitDepth === 8 ? new Uint8Array(width * height * 3) : new Uint16Array(width * height * 3);
1204
+ const counts = { negative: 0, above: 0, changed: 0, low: 0, high: 0, outOfGamut: false };
1205
+ for (let y = 0; y < height; y++)
1206
+ renderSdrRunInto(linear.rgb, y * width * 3, width, rendering, tone, 2 ** rendering.renderingExposureEv, maximum, codes, y * width * 3, scratch, counts);
1207
+ developed = { raw: rawOutput.raw, integerSamples: codes, highlight: linear.highlight, diagnostics: { renderingNegativeSampleCount: counts.negative,
1208
+ renderingAboveReferenceSampleCount: counts.above, toneChangedSampleCount: counts.changed, gamutClippedLowSampleCount: counts.low,
1209
+ gamutClippedHighSampleCount: counts.high, captureSaturation: "consumed-in-raw-development" } };
1210
+ }
1211
+ else {
1212
+ const mapping = perceptual ? prepareRgbGamutMapping({ colorSpace: colorEncoding, method: "cielab-hue-preserving-chroma-compression", kneeFraction: PERCEPTUAL_GAMUT_KNEE_FRACTION }) : undefined;
1213
+ const encoded = encodeSdrFromLinear(linear.rgb, colorEncoding, pngBitDepth, mapping);
1214
+ developed = { raw: rawOutput.raw, integerSamples: encoded.samples, highlight: linear.highlight, diagnostics: encoded.diagnostics };
1215
+ if (mapping)
1216
+ gamutMappingDiagnostics = { method: "cielab-hue-preserving-chroma-compression", kneeFraction: PERCEPTUAL_GAMUT_KNEE_FRACTION,
1217
+ adjustedPixelCount: encoded.adjustedPixelCount, lightnessClippedPixelCount: encoded.lightnessClippedPixelCount };
1218
+ }
1219
+ }
1220
+ else if (!linearPath) {
939
1221
  const devTask = createNativeRawDevelopmentTask({ ...development, raw: rawOutput.raw }, { yieldControl,
940
1222
  onProgress: (p) => onProgress?.({ stage: "development", completedTileCount: p.completedTileCount, totalTileCount: p.totalTileCount }) });
941
1223
  await runTask(devTask);
@@ -976,7 +1258,9 @@ export async function simulateCapture(options) {
976
1258
  // The encoder reads every pixel once, in source tiles of at most 256 × 32 aligned to the crop origin.
977
1259
  const reads = Math.ceil(height / 32) * Math.ceil(width / 256), jpegTick = progress("jpeg", reads);
978
1260
  const jpegTask = createPrintJpegTask({ source, crop: { x: 0, y: 0, width, height }, outputWidth: width, outputHeight: height, quantizationStep: jpegStep,
979
- maximumEncodedBytes: Math.max(65536, width * height * 6), maximumProviderReads: reads }, {
1261
+ maximumEncodedBytes: Math.max(65536, width * height * 6), maximumProviderReads: reads,
1262
+ // The JPEG records its admission plan, so the raised limit is passed (and recorded) only when the image needs it.
1263
+ ...(raisedPixels === undefined || width * height <= PRINT_JPEG_LIMITS.maximumPixels ? {} : { maximumPixels: raisedPixels }) }, {
980
1264
  readTile: async (request) => {
981
1265
  jpegTick();
982
1266
  const out = new Uint8Array(request.width * request.height * 3);
@@ -985,36 +1269,54 @@ export async function simulateCapture(options) {
985
1269
  return { ...request, samples: out };
986
1270
  }, yieldControl
987
1271
  });
1272
+ const jpegEncodeStarted = clockMs();
988
1273
  await runTask(jpegTask);
989
1274
  jpeg = jpegTask.takeOutput().bytes;
1275
+ encodeMs = clockMs() - jpegEncodeStarted;
990
1276
  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.");
991
1277
  }
992
1278
  const jpegMs = Date.now() - jpegStarted;
993
1279
  let png;
994
1280
  if (format === "png") {
995
- png = serializePngImage({ width, height, bitDepth: pngBitDepth, samples: developed.integerSamples, colourEncoding: pngColourEncoding(colorEncoding) });
1281
+ const pngStarted = clockMs();
1282
+ png = serializePngImage({ width, height, bitDepth: pngBitDepth, samples: developed.integerSamples, colourEncoding: pngColourEncoding(colorEncoding),
1283
+ ...(raisedPixels === undefined || width * height <= PNG_ENCODER_LIMITS.maximumPixels ? {} : { maximumPixels: raisedPixels }) });
1284
+ encodeMs = clockMs() - pngStarted;
996
1285
  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.");
997
1286
  }
1287
+ const ranOptions = new Set([...(pupil ? ["lens.pupilSampling"] : []), ...(projection !== undefined ? ["lens.projection"] : []),
1288
+ ...(distortion ? ["lens.distortion"] : []), ...(vignetting ? ["lens.vignetting"] : []), ...(lensPsf ? ["lens.psf"] : []),
1289
+ ...(diffraction ? ["lens.diffraction"] : []), ...(antiAliasing ? ["sensor.antiAliasing"] : []),
1290
+ ...(temporalSampleCount > 1 ? ["sensor.temporalSampleCount"] : []), ...(rotating ? ["motion.rotation"] : []),
1291
+ ...(rollingSchedule ? ["motion.shutter.rolling"] : []), ...(mirrorBorder ? ["output.borderHandling.mirror"] : []),
1292
+ ...(needsAuto ? ["output.whiteBalance.auto"] : []), ...(ahd ? ["output.demosaic.ahd"] : []),
1293
+ ...(highlightMode === "reconstruct" ? ["output.highlightMode.reconstruct"] : []), ...(wideGamut ? ["output.colorEncoding.wide-gamut"] : []),
1294
+ ...(perceptual ? ["output.gamutMapping.perceptual"] : []), ...(format === "jpeg" || format === "png" || format === "dng" ? ["output.format." + format] : [])]);
998
1295
  return Object.freeze({
999
1296
  facadeVersion: SIMULATE_CAPTURE_FACADE_VERSION, engineApiVersion: ENGINE_API_VERSION,
1000
1297
  image: { width, height, format, samples: developed.integerSamples, ...(jpeg ? { jpeg } : {}), ...(png ? { png, colorEncoding: colorEncoding } : {}), ...(dng ? { dng: dng.bytes } : {}) },
1001
1298
  raw: developed.raw, plan,
1002
- request: { environmentRaw: finalRawInput, siteInput, development, whiteBalanceState },
1299
+ request: { environmentRaw: finalRawInput, siteInput, development, whiteBalanceState,
1300
+ ...(diffraction ? { diffraction: { profile: diffraction, psf: diffractionPsf, execution: diffractionObjectSpace ? "object-space-convolution" : "environment-psf-quadrature" } } : {}) },
1003
1301
  exposure: { mode, settings: { fNumber, shutterSeconds, iso: resolvedIso }, ev100: Math.log2(fNumber ** 2 / shutterSeconds),
1004
1302
  meteredSceneLuminanceCdPerSquareMeter: meteredLuminance, resolution },
1005
1303
  scene: { sceneId: chart ? "chart:" + chart.preset : callerScene.sceneId, preset: scenePreset, parameters: chart ? chart.parameters : {},
1006
1304
  illuminant: { id: illuminant.id, description: illuminant.description, illuminanceLux }, distanceM: subjectDistanceM,
1007
1305
  framedWidthM: 2 * metresPerFrameUnit, framedHeightM: 2 * metresPerFrameUnit * aspect, sourceSha256 },
1008
1306
  diagnostics: { providerEvaluationCount, highlight: developed.highlight, rendering: developed.diagnostics, ...(gamutMappingDiagnostics ? { gamutMapping: gamutMappingDiagnostics } : {}),
1009
- timingsMs: { raw: rawMs, whiteBalance: wbMs, development: devMs, jpeg: jpegMs, total: Date.now() - started } },
1307
+ timingsMs: { raw: rawMs, whiteBalance: wbMs, development: devMs, jpeg: jpegMs, total: Date.now() - started,
1308
+ ...(diffraction && diffractionObjectSpace ? { diffraction: diffractionMs } : {}), ...(demosaicMs === undefined ? {} : { demosaic: demosaicMs }),
1309
+ ...(encodeMs === undefined ? {} : { encode: encodeMs }) },
1310
+ options: Object.freeze(SIMULATE_CAPTURE_COST_OPTIONS.filter((option) => ranOptions.has(option))) },
1010
1311
  provenance: { scientificStatus: "approximation", cameraPresetId: preset.presetId, cameraPresetVersion: preset.presetVersion,
1011
1312
  evidence: [...FACADE_EVIDENCE, ...preset.evidence],
1012
1313
  composedContracts: ["resolveGenericEquipmentExposureCapabilities", "meterRelativeExposure", "createExposureMeterTargetFromMeteringResult",
1013
1314
  "resolve" + (mode === "manual" ? "Manual" : mode === "aperture-priority" ? "AperturePriority" : mode === "shutter-priority" ? "ShutterPriority" : "ProgramAuto") + "ExposureMode",
1014
1315
  ...(rollingSchedule ? ["resolveElectronicRollingShutterExposureSchedule", "createRollingShutterCaptureExposureSchedule"] : []),
1015
- "resolveGenericIsoSignalChain", "createNativeEnvironmentRawTask", ...(needsAuto ? ["estimateAutoWhiteBalance"] : []),
1016
- ...(builtInDemosaic ? ["createBuiltInBayerDemosaicProfiles"] : []),
1017
- ...(linearPath ? ["native RAW development operator (linear, pre-clip)", "resolveRgbColorSpace", ...(perceptual ? ["prepareRgbGamutMapping"] : [])] : ["createNativeRawDevelopmentTask"]),
1316
+ ...(diffraction ? ["createDiffractionLimitedPsfProfile", ...(diffractionObjectSpace ? ["resolveLensPsfConvolutionKernels", "calculateIrradianceConvolution"] : [])] : []),
1317
+ ...(antiAliasing ? ["resolveAntiAliasingSpatialKernel"] : []), "resolveGenericIsoSignalChain", "createNativeEnvironmentRawTask", ...(needsAuto ? ["estimateAutoWhiteBalance"] : []),
1318
+ ...(builtInDemosaic ? ["createBuiltInBayerDemosaicProfiles"] : []), ...(ahd ? ["createAhdDemosaicTask"] : []),
1319
+ ...(ahd ? ["native RAW development operator (colour, after AHD)", ...(linearPath ? ["resolveRgbColorSpace", ...(perceptual ? ["prepareRgbGamutMapping"] : [])] : ["SDR rendering kernel"])] : linearPath ? ["native RAW development operator (linear, pre-clip)", "resolveRgbColorSpace", ...(perceptual ? ["prepareRgbGamutMapping"] : [])] : ["createNativeRawDevelopmentTask"]),
1018
1320
  ...(jpeg ? ["createPrintJpegTask"] : []), ...(png ? ["serializePngImage"] : []), ...(dng ? ["resolveSensorReadoutRegime", "serializeNativeRawDng"] : [])],
1019
1321
  limitations: ["Educational approximation: synthetic tier sensor values, idealized colorimetric sensor, planar Lambertian scene and point optics; not a calibrated camera emulation.",
1020
1322
  "Not offered by the facade: atmospheric haze and polarizers (the scene-radiance provider contract declares no volumetric transport or polarization), flash, video, HDR brackets and processing operators; compose those APIs directly."] },