@photivra/engine 2.0.0 → 2.1.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.
@@ -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";
@@ -47,7 +49,10 @@ import { createNativeRawDevelopmentTask } from "../capture/native-raw-developmen
47
49
  import { parseSdrRenderingProfile } from "../output/sdr-rendering.js";
48
50
  import { createPrintJpegTask } 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 { renderSdrRunInto } from "../output/native-capture-sdr.js";
55
+ import { prepareSdrPixelTone } from "../output/sdr-rendering.js";
51
56
  import { BUILT_IN_BAYER_DEMOSAIC_MODEL_VERSION as BUILT_IN_DEMOSAIC_VERSION, createBuiltInBayerDemosaicProfiles } from "../sensor/raw-reconstruction.js";
52
57
  import { transformOrientedRasterPointToNative } from "../output/capture-geometry.js";
53
58
  import { resolveCaptureColorModel } from "../color/capture-color.js";
@@ -57,6 +62,10 @@ import { parseLensProjectionModel } from "../camera/lens-projection.js";
57
62
  import { prepareEnvironmentLensFieldMapping } from "../optics/sensor-environment-query.js";
58
63
  import { createRollingShutterCaptureExposureSchedule, resolveElectronicRollingShutterExposureSchedule } from "../sensor/readout-exposure-linkage.js";
59
64
  import { calculateCaptureExposureWindows } from "../sensor/exposure-window.js";
65
+ import { calculateSensorSpectralQuadrature } from "../sensor/spectral-quadrature.js";
66
+ import { createDiffractionLimitedPsfProfile } from "../optics/diffraction-psf-profile.js";
67
+ import { createDiffractedPlanarProvider } from "./diffraction-provider.js";
68
+ import { resolveAntiAliasingSpatialKernel } from "../sensor/optical-stack.js";
60
69
  import { multiplyMatrices } from "../color/colorimetry-math.js";
61
70
  import { prepareRgbGamutMapping } from "../color/gamut-mapping.js";
62
71
  import { serializeNativeRawDng } from "../capture/native-raw-dng.js";
@@ -65,6 +74,14 @@ import { resolveChartScene, CHART_SCENE_PRESETS } from "./chart-scenes.js";
65
74
  import { resolveCaptureIlluminant, calculateIlluminantIrradianceScale, buildPlanarSceneRadianceProvider, requireCallerScene, FACADE_SPECTRAL_RANGE_NANOMETERS, PHOTOPIC_MAXIMUM_LUMINOUS_EFFICACY_LM_PER_W } from "./scene-radiance.js";
66
75
  /** Version of how the facade builds requests. It changes whenever the same input would build a different request. */
67
76
  export const SIMULATE_CAPTURE_FACADE_VERSION = "1.0.0";
77
+ /**
78
+ * Options reported in `diagnostics.options` (2.1), in this order, when they ran: the ones that change how much work
79
+ * a capture does. `motion.rotation` is a non-zero angular velocity; `sensor.temporalSampleCount` a count above 1.
80
+ */
81
+ export const SIMULATE_CAPTURE_COST_OPTIONS = Object.freeze(["lens.pupilSampling", "lens.projection", "lens.distortion", "lens.vignetting", "lens.psf",
82
+ "lens.diffraction", "sensor.antiAliasing", "sensor.temporalSampleCount", "motion.rotation", "motion.shutter.rolling", "output.borderHandling.mirror",
83
+ "output.whiteBalance.auto", "output.demosaic.ahd", "output.highlightMode.reconstruct", "output.colorEncoding.wide-gamut", "output.gamutMapping.perceptual",
84
+ "output.format.jpeg", "output.format.png", "output.format.dng"]);
68
85
  const FACADE_EVIDENCE = Object.freeze([Object.freeze({ sourceOrigin: "photivra",
69
86
  sourceReference: "photivra:simulate-capture-facade", reuseStatus: "photivra-owned" })]);
70
87
  const DEFAULTS = Object.freeze({ camera: "professional", fNumber: 8, shutterSeconds: 1 / 125, iso: 100, compensationStops: 0,
@@ -90,11 +107,66 @@ export const SIMULATE_CAPTURE_DEFAULT_ILLUMINANCE_LUX = Math.PI * sosReferenceLu
90
107
  GREY_CARD_REFLECTANCE;
91
108
  /** Demosaic border, native sites per side. */
92
109
  const MARGIN = 1;
110
+ /**
111
+ * AHD border, native sites per side: an AHD output pixel depends on sites up to 5 away (directional green ±2,
112
+ * colour differences ±1 of it, the homogeneity's 4-neighbours and its 3 × 3 sum), so a 5-site rendered margin keeps
113
+ * every output pixel off the plane's mirrored edge.
114
+ */
115
+ const AHD_MARGIN = 5;
116
+ /** Sites whose values enter an AHD output pixel (interpolation reach), for the saturated-pixel count. */
117
+ const AHD_VALUE_REACH = 3;
118
+ /** Anti-aliasing filter limits: split points, and the largest offset in sites (bounds the scene work and the convolution margin). */
119
+ const AA_MAXIMUM_COMPONENTS = 16;
120
+ const AA_MAXIMUM_OFFSET_SITES = 4;
121
+ /** Default birefringent separation in sites: the two-spot MTF |cos(π·f·d)| then has its first zero at Nyquist, f = 1/(2·pitch). */
122
+ const AA_DEFAULT_SEPARATION_SITES = 1;
123
+ /** Parses `sensor.antiAliasing` into engine point-splitting components (offsets in µm, +x right, +y down). */
124
+ function parseAntiAliasing(value, pitch) {
125
+ requireInputObject(value, "sensor.antiAliasing");
126
+ const kind = value.kind;
127
+ const separation = (record) => {
128
+ if (record.separationSites === undefined)
129
+ return AA_DEFAULT_SEPARATION_SITES;
130
+ const d = positive(record.separationSites, "sensor.antiAliasing.separationSites");
131
+ if (d > AA_MAXIMUM_OFFSET_SITES)
132
+ fail("sensor.antiAliasing.separationSites must be greater than 0 and at most " + AA_MAXIMUM_OFFSET_SITES + ".");
133
+ return d;
134
+ };
135
+ if (kind === "four-spot") {
136
+ const record = allowKeys(value, "sensor.antiAliasing", ["kind", "separationSites"]), d = separation(record), h = d * pitch / 2;
137
+ return { kind, separationSites: d, components: [[-h, -h], [h, -h], [-h, h], [h, h]].map(([x, y]) => ({ offsetMicrometers: { x: x, y: y }, normalizedWeight: 0.25 })) };
138
+ }
139
+ if (kind === "two-spot") {
140
+ const record = allowKeys(value, "sensor.antiAliasing", ["kind", "axis", "separationSites"]), d = separation(record), h = d * pitch / 2;
141
+ if (record.axis !== "horizontal" && record.axis !== "vertical")
142
+ fail("sensor.antiAliasing.axis must be horizontal or vertical.");
143
+ const axis = record.axis;
144
+ return { kind, axis, separationSites: d, components: [-h, h].map((o) => ({ offsetMicrometers: axis === "horizontal" ? { x: o, y: 0 } : { x: 0, y: o }, normalizedWeight: 0.5 })) };
145
+ }
146
+ if (kind === "point-splitting") {
147
+ const record = allowKeys(value, "sensor.antiAliasing", ["kind", "components"]);
148
+ if (!Array.isArray(record.components) || record.components.length === 0 || record.components.length > AA_MAXIMUM_COMPONENTS) {
149
+ fail("sensor.antiAliasing.components must be an array of 1 to " + AA_MAXIMUM_COMPONENTS + " components.");
150
+ }
151
+ // The engine's optical-stack parser checks shape, finiteness, positive weights and the unit sum (1e-12).
152
+ const kernel = resolveAntiAliasingSpatialKernel({ schemaVersion: "0.1.0", profileId: "facade-aa-check", evidence: FACADE_EVIDENCE,
153
+ effectiveAntiAliasingSpatialResponse: { kind: "normalized-point-splitting-kernel", evidence: FACADE_EVIDENCE, coordinateSystem: "native-sensor-physical",
154
+ scope: "field-wavelength-polarization-invariant-approximation", components: record.components } });
155
+ for (const c of kernel.components)
156
+ if (Math.max(Math.abs(c.offsetMicrometers.x), Math.abs(c.offsetMicrometers.y)) > AA_MAXIMUM_OFFSET_SITES * pitch) {
157
+ fail("sensor.antiAliasing.components offsets must lie within " + AA_MAXIMUM_OFFSET_SITES + " sites (" + AA_MAXIMUM_OFFSET_SITES * pitch + " µm) of the site centre.");
158
+ }
159
+ return { kind, components: kernel.components.map((c) => ({ offsetMicrometers: { ...c.offsetMicrometers }, normalizedWeight: c.normalizedWeight })) };
160
+ }
161
+ return fail("sensor.antiAliasing.kind must be four-spot, two-spot or point-splitting.");
162
+ }
163
+ /** Third dark ring of the Airy pattern in units of λ·N_w (j₁,₃/π, j₁,₃ = 10.1734681351): the default diffraction kernel reach. */
164
+ const AIRY_THIRD_ZERO_PER_LAMBDA_N = 10.173468135062722 / Math.PI;
93
165
  /** Default temporal midpoints when the camera rotates (2.0 motion option). */
94
166
  const ROTATING_TEMPORAL_SAMPLE_COUNT = 8;
95
167
  const IDS = Object.freeze({ frame: "facade-frame", mode: "native-still", cfa: "photivra-generic-bayer", binding: "facade-native-cfa",
96
168
  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",
169
+ capture: "facade-capture", spectral: "facade-colorimetric-eqe", sampling: "facade-sampling", stack: "facade-no-aa", stackAa: "facade-aa",
98
170
  stationarity: "facade-stationarity", operatingState: "facade-operating-state", dark: "facade-dark", completeness: "facade-completeness",
99
171
  chargeSampling: "facade-charge-sampling", capacity: "facade-capacity" });
100
172
  const CHANNELS = ["red", "green", "blue"];
@@ -112,6 +184,8 @@ const XYZ_TO_CAMERA = [[EQE_SCALE.red / EQE_SCALE.green, 0, 0], [0, 1, 0], [0, 0
112
184
  const DARK_CURRENT_ELECTRONS_PER_SECOND = 1;
113
185
  const OPERATING_TEMPERATURE_C = 20;
114
186
  function fail(message) { throw new InvalidConfigurationError(message); }
187
+ /** Diagnostic clock for the 2.1 stage timings: `performance.now()` where the host has it. Never affects output. */
188
+ function clockMs() { return globalThis.performance?.now() ?? Date.now(); }
115
189
  function positive(value, name) {
116
190
  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0)
117
191
  fail(name + " must be a positive finite number.");
@@ -222,7 +296,7 @@ function pngColourEncoding(encoding) {
222
296
  * clipping, white balance, highlight mode and the camera→XYZ→linear-sRGB matrices are all the same. It
223
297
  * stops before tone mapping, gamut clipping and quantization. It reads the RAW without taking ownership.
224
298
  */
225
- async function developLinearSrgb(input, afterRow) {
299
+ async function developLinearSrgb(input, afterRow, ahd) {
226
300
  const v = prepareNativeRawDevelopment(input), plan = v.plan, raw = input.raw, layout = plan.frame.colorSamplingProfile.layout;
227
301
  if (layout.kind !== "periodic-mosaic")
228
302
  fail("Development requires a periodic CFA.");
@@ -235,6 +309,8 @@ async function developLinearSrgb(input, afterRow) {
235
309
  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
310
  return { x: Math.floor(q.x) + active.x, y: Math.floor(q.y) + active.y };
237
311
  };
312
+ if (ahd)
313
+ return developAhd(v, raw, layout.repeatWidthSites, layout.repeatHeightSites, width, height, at, afterRow, ahd);
238
314
  for (let y = 0; y < height; y++) {
239
315
  const first = at(0, y), second = width > 1 ? at(1, y) : first;
240
316
  developer.developRun(first.x, first.y, second.x - first.x, second.y - first.y, width, row);
@@ -245,6 +321,56 @@ async function developLinearSrgb(input, afterRow) {
245
321
  return { rgb, highlight: { mode: v.highlightMode, saturatedPixelCount: c.saturatedPixelCount, whiteBalanceClippedSampleCount: c.whiteBalanceClippedSampleCount,
246
322
  reconstructedPixelCount: c.reconstructedPixelCount } };
247
323
  }
324
+ /**
325
+ * AHD development (2.1): the same site normalization as the native development kernel (flagged or white-level sites
326
+ * are 1, others (code − black)/(white − black)) on the whole native raster, demosaiced by `createAhdDemosaicTask`
327
+ * (mirrored plane edges), then the native development's own colour operator (`developInto`: WB, highlight mode,
328
+ * camera → XYZ → linear sRGB) per output pixel. The AHD homogeneity metric sees white-balanced colour: its
329
+ * camera → XYZ matrix is the profile's matrix times the WB gains (the output values are not transformed by it).
330
+ * The mosaic is stored in float32, as the AHD task requires.
331
+ */
332
+ async function developAhd(v, raw, repeatWidth, repeatHeight, width, height, at, afterRow, host) {
333
+ const native = v.plan.exposure.geometry.nativeRaster, W = native.pixelWidth, H = native.pixelHeight;
334
+ const source = { nativeWidth: W, repeatWidth, repeatHeight, codes: raw.codes, blackLevels: raw.blackLevels,
335
+ saturationFlags: raw.saturationFlags, whiteByPhase: raw.effectiveSaturationCodes };
336
+ const samples = new Float32Array(W * H), saturated = new Uint8Array(W * H);
337
+ for (let y = 0; y < H; y++)
338
+ for (let x = 0; x < W; x++) {
339
+ samples[y * W + x] = normalizedRawSite(source, x, y);
340
+ if (rawSiteSaturated(source, x, y))
341
+ saturated[y * W + x] = 1;
342
+ }
343
+ const intent = v.plan.exposure.whiteBalanceIntent, gains = intent ? [intent.channelGains.red, intent.channelGains.green, intent.channelGains.blue] : [1, 1, 1];
344
+ const m = v.color.normalizedCameraChannelsToXyz;
345
+ const cameraToXyz = [0, 1, 2].map((r) => [0, 1, 2].map((c) => m[r][c] * gains[c]));
346
+ const task = createAhdDemosaicTask({ plane: { width: W, height: H, captureModeId: v.plan.frame.modeId, colorSamplingProfile: v.plan.frame.colorSamplingProfile, samples },
347
+ channelRoles: { red: "red", green: "green", blue: "blue" }, cameraToXyz }, { yieldControl: async () => { await host.yieldControl(); host.checkAbort(); } });
348
+ const demosaicStarted = clockMs();
349
+ await task.run();
350
+ host.onDemosaicMs?.(clockMs() - demosaicStarted);
351
+ const demosaiced = task.takeOutput().data, rgb = new Float64Array(width * height * 3);
352
+ const counts = { saturatedPixelCount: 0, whiteBalanceClippedSampleCount: 0, reconstructedPixelCount: 0 };
353
+ for (let y = 0; y < height; y++) {
354
+ for (let x = 0; x < width; x++) {
355
+ const p = at(x, y), i = p.y * W + p.x;
356
+ let clipped = false;
357
+ for (let dy = -AHD_VALUE_REACH; dy <= AHD_VALUE_REACH && !clipped; dy++)
358
+ for (let dx = -AHD_VALUE_REACH; dx <= AHD_VALUE_REACH; dx++) {
359
+ const sx = p.x + dx, sy = p.y + dy;
360
+ if (sx >= 0 && sy >= 0 && sx < W && sy < H && saturated[sy * W + sx] !== 0) {
361
+ clipped = true;
362
+ break;
363
+ }
364
+ }
365
+ if (clipped)
366
+ counts.saturatedPixelCount++;
367
+ v.development.developInto(demosaiced[3 * i], demosaiced[3 * i + 1], demosaiced[3 * i + 2], rgb, (y * width + x) * 3, counts);
368
+ }
369
+ await afterRow(y + 1, height);
370
+ }
371
+ return { rgb, highlight: { mode: v.highlightMode, saturatedPixelCount: counts.saturatedPixelCount, whiteBalanceClippedSampleCount: counts.whiteBalanceClippedSampleCount,
372
+ reconstructedPixelCount: counts.reconstructedPixelCount } };
373
+ }
248
374
  /** Linear sRGB/D65 → CIE XYZ (inverse of the development's XYZ → sRGB matrix) → linear target RGB/D65. */
249
375
  function linearSrgbToTarget(target) {
250
376
  return multiplyMatrices(resolveRgbColorSpace(target).value.xyzToRgb, resolveCaptureColorModel().cameraRgbToXyz);
@@ -327,7 +453,7 @@ export async function simulateCapture(options) {
327
453
  preset = parseCallerCameraPreset(cameraOption);
328
454
  const body = preset.body, lens = preset.lens;
329
455
  // Lens
330
- const lensOptions = allowKeys(top.lens, "lens", ["focalLengthMm", "fNumber", "focusDistanceM", "pupilSampling", "projection", "distortion", "vignetting", "psf"]);
456
+ const lensOptions = allowKeys(top.lens, "lens", ["focalLengthMm", "fNumber", "focusDistanceM", "pupilSampling", "projection", "distortion", "vignetting", "psf", "diffraction"]);
331
457
  const focalDefault = lens.exposure.focalLengthMmRange.value.minimum;
332
458
  const focalLengthMm = positive(pick(lensOptions, "focalLengthMm", "lens.focalLengthMm", focalDefault, "The preset lens's focal length (the generic primes are 50 mm)."), "lens.focalLengthMm");
333
459
  const capabilities = resolveGenericEquipmentExposureCapabilities({ bodyProfile: body.exposure, lensProfile: lens.exposure, selectedFocalLengthMm: focalLengthMm });
@@ -383,6 +509,23 @@ export async function simulateCapture(options) {
383
509
  fail("lens.psf cannot be combined with lens.pupilSampling: geometric defocus would be counted twice.");
384
510
  lensPsf = p;
385
511
  }
512
+ // Diffraction (2.1): validated here, built once the f-number is resolved.
513
+ let diffractionRadiusOption;
514
+ if (lensOptions.diffraction !== undefined) {
515
+ const d = lensOptions.diffraction;
516
+ if (d === "circular-aperture")
517
+ diffractionRadiusOption = null;
518
+ else {
519
+ const record = allowKeys(d, "lens.diffraction", ["aperture", "kernelRadiusSamples"]);
520
+ if (record.aperture !== "circular-aperture")
521
+ fail("lens.diffraction must be \"circular-aperture\" or { aperture: \"circular-aperture\", kernelRadiusSamples }.");
522
+ diffractionRadiusOption = record.kernelRadiusSamples === undefined ? null : integerIn(record.kernelRadiusSamples, "lens.diffraction.kernelRadiusSamples", 0, 32);
523
+ }
524
+ if (lensPsf)
525
+ fail("lens.diffraction cannot be combined with lens.psf: diffraction would be counted twice.");
526
+ if (pupil)
527
+ fail("lens.diffraction cannot be combined with lens.pupilSampling: the environment contract does not mix a sampled PSF with pupil rays.");
528
+ }
386
529
  // Motion (2.0): constant camera rotation and the shutter type.
387
530
  const motionOptions = allowKeys(top.motion, "motion", ["angularVelocityRadPerSec", "shutter", "rollingShutterSkewSeconds"]);
388
531
  const angular = { pitch: 0, yaw: 0, roll: 0 };
@@ -403,6 +546,9 @@ export async function simulateCapture(options) {
403
546
  if (motionOptions.rollingShutterSkewSeconds !== undefined && shutterKind !== "rolling")
404
547
  fail("motion.rollingShutterSkewSeconds needs motion.shutter \"rolling\".");
405
548
  const rollingSkewOption = motionOptions.rollingShutterSkewSeconds === undefined ? undefined : positive(motionOptions.rollingShutterSkewSeconds, "motion.rollingShutterSkewSeconds");
549
+ // Diffraction runs as an object-space convolution when every PSF tap's source is another site centre seen
550
+ // through one linear map (still camera, rectilinear, no distortion or field throughput); otherwise per tap.
551
+ const diffractionObjectSpace = diffractionRadiusOption !== undefined && !rotating && !lensFieldMapping && !vignetting;
406
552
  // Output and sensor
407
553
  const outputOptions = allowKeys(top.output, "output", ["width", "height", "format", "toneCurve", "toneCurveParameters", "highlightMode", "whiteBalance", "jpegQuantizationStep",
408
554
  "colorEncoding", "bitDepth", "gamutMapping", "demosaic", "borderHandling", "dng"]);
@@ -451,7 +597,7 @@ export async function simulateCapture(options) {
451
597
  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
598
  if (format !== "jpeg" && outputOptions.jpegQuantizationStep !== undefined)
453
599
  fail("output.jpegQuantizationStep applies only to output.format \"jpeg\".");
454
- const sensorOptions = allowKeys(top.sensor, "sensor", ["pixelPitchMicrometers", "spectralStepNanometers", "temporalSampleCount", "noiseSeed"]);
600
+ const sensorOptions = allowKeys(top.sensor, "sensor", ["pixelPitchMicrometers", "spectralStepNanometers", "temporalSampleCount", "noiseSeed", "antiAliasing"]);
455
601
  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
602
  if (pitch < 0.5 || pitch > 100)
457
603
  fail("sensor.pixelPitchMicrometers must be 0.5-100.");
@@ -461,21 +607,25 @@ export async function simulateCapture(options) {
461
607
  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
608
  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
609
  const noiseSeed = integerIn(pick(sensorOptions, "noiseSeed", "sensor.noiseSeed", DEFAULTS.noiseSeed, "Fixed noise seed 1 (deterministic output)."), "sensor.noiseSeed", 0, 0xffffffff);
610
+ const antiAliasing = sensorOptions.antiAliasing === undefined ? undefined : parseAntiAliasing(sensorOptions.antiAliasing, pitch);
464
611
  const colorSamplingProfile = { schemaVersion: "0.1.0", profileId: IDS.cfa, evidence: FACADE_EVIDENCE, coordinateSystem: "native-sensor-color-sampling-site-index",
465
612
  layout: { kind: "periodic-mosaic", repeatWidthSites: 2, repeatHeightSites: 2, siteChannelIds: ["red", "green", "green", "blue"], anchor: "native-sensor-top-left-site" } };
466
613
  // Demosaic (2.0): built-in Bayer kernels and border handling. Omitted, the 1.0 bilinear kernels and a
467
614
  // one-site rendered border are used unchanged.
468
615
  const demosaicOption = outputOptions.demosaic, borderOption = outputOptions.borderHandling;
469
- if (demosaicOption !== undefined && demosaicOption !== "bilinear")
470
- fail("output.demosaic must be bilinear.");
616
+ if (demosaicOption !== undefined && demosaicOption !== "bilinear" && demosaicOption !== "ahd")
617
+ fail("output.demosaic must be bilinear or ahd.");
618
+ const ahd = demosaicOption === "ahd";
471
619
  if (borderOption !== undefined && borderOption !== "margin" && borderOption !== "mirror")
472
620
  fail("output.borderHandling must be margin or mirror.");
473
621
  const builtInDemosaic = demosaicOption === undefined ? undefined : createBuiltInBayerDemosaicProfiles({ kernel: "bilinear",
474
622
  captureModeId: IDS.mode, colorSamplingProfile, channelRoles: { red: "red", green: "green", blue: "blue" } }).value;
475
623
  const mirrorBorder = borderOption === "mirror";
476
- const margin = mirrorBorder ? 0 : builtInDemosaic ? builtInDemosaic.kernelReachSites : MARGIN;
624
+ const margin = mirrorBorder ? 0 : ahd ? AHD_MARGIN : builtInDemosaic ? builtInDemosaic.kernelReachSites : MARGIN;
477
625
  if (mirrorBorder && (width < 2 || height < 2))
478
626
  fail("output.borderHandling \"mirror\" needs an output of at least 2 × 2 pixels.");
627
+ if (ahd && mirrorBorder && (width < 3 || height < 3))
628
+ fail("output.demosaic \"ahd\" with mirror borders needs an output of at least 3 × 3 pixels.");
479
629
  // Geometry: the demosaic border (if any) on each side; output crop is the active area.
480
630
  const nativeWidth = width + 2 * margin, nativeHeight = height + 2 * margin;
481
631
  const imagingArea = { widthMm: nativeWidth * pitch / 1000, heightMm: nativeHeight * pitch / 1000 };
@@ -492,6 +642,8 @@ export async function simulateCapture(options) {
492
642
  else {
493
643
  if (demosaicOption === undefined)
494
644
  assume("output.demosaic", "default", "bilinear-rggb", "The 1.0 facade's bilinear RGGB kernels (explicit linear neighbourhood weights).");
645
+ else if (ahd)
646
+ 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
647
  else
496
648
  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
649
  if (borderOption === undefined)
@@ -505,7 +657,7 @@ export async function simulateCapture(options) {
505
657
  const psfTaps = lensPsf ? Math.max(1, ...(lensPsf.configuration.psf.profile.nodes ?? [])
506
658
  .map((node) => Number(node.kernel?.widthSamples) * Number(node.kernel?.heightSamples)).filter((taps) => Number.isSafeInteger(taps) && taps > 0)) : 1;
507
659
  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;
660
+ (pupil ? pupil.radialSamples * pupil.angularSamples : 1) * psfTaps * (antiAliasing ? antiAliasing.components.length : 1);
509
661
  // Scene and illuminant
510
662
  // The scene is the one required input: the facade never guesses what is being photographed.
511
663
  if (top.scene === undefined)
@@ -634,7 +786,19 @@ export async function simulateCapture(options) {
634
786
  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.");
635
787
  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
788
  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.");
789
+ if (!antiAliasing)
790
+ assume("sensor.fillFactor", "model", 1, "Square sensitive aperture equal to the pixel pitch; no microlens or anti-aliasing filter.");
791
+ else {
792
+ assume("sensor.fillFactor", "model", 1, "Square sensitive aperture equal to the pixel pitch; no microlens.");
793
+ assume("sensor.antiAliasing", "model", { kind: antiAliasing.kind, ...(antiAliasing.axis ? { axis: antiAliasing.axis } : {}),
794
+ ...(antiAliasing.separationSites === undefined ? {} : { separationSites: antiAliasing.separationSites, separationMicrometers: antiAliasing.separationSites * pitch }),
795
+ 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 " +
796
+ "otherwise land at its centre minus each offset, weighted. " + (antiAliasing.kind === "point-splitting" ? "Caller-declared components. " :
797
+ "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)|" :
798
+ "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). ") +
799
+ "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, " +
800
+ "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 + ".");
801
+ }
638
802
  // Shutter (2.0): the electronic rolling-shutter schedule derived from the body's rolling readout declaration.
639
803
  let rollingSchedule;
640
804
  if (shutterKind === "rolling") {
@@ -655,7 +819,8 @@ export async function simulateCapture(options) {
655
819
  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
820
  if (lensPsf)
657
821
  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
822
+ // With lens.diffraction the PSF is recorded as lens.diffraction once its kernels are built.
823
+ else if (diffractionRadiusOption === undefined)
659
824
  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
825
  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
826
  if (!vignetting && !lensFieldMapping)
@@ -753,10 +918,14 @@ export async function simulateCapture(options) {
753
918
  siteCenterLattice: { kind: "regular-rectangular-site-center-lattice", coordinateSystem: "native-sensor-physical", pitchXMicrometers: pitch, pitchYMicrometers: pitch,
754
919
  firstSiteCenterFromImagingAreaTopLeftMicrometers: { x: pitch / 2, y: pitch / 2 }, evidence },
755
920
  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 } };
921
+ const stackId = antiAliasing ? IDS.stackAa : IDS.stack;
922
+ const opticalStackProfile = antiAliasing ? { schemaVersion: "0.1.0", profileId: stackId, evidence, effectiveAntiAliasingSpatialResponse: {
923
+ kind: "normalized-point-splitting-kernel", evidence, coordinateSystem: "native-sensor-physical", scope: "field-wavelength-polarization-invariant-approximation",
924
+ components: antiAliasing.components
925
+ } } : { schemaVersion: "0.1.0", profileId: IDS.stack, evidence, effectiveAntiAliasingSpatialResponse: { kind: "absent", evidence } };
757
926
  const perChannel = Object.fromEntries(CHANNELS.map((channelId) => [channelId, {
758
927
  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 },
928
+ samplingApertureProfileId: IDS.sampling, opticalStackProfileId: stackId, evidence, incidentAreaBasis: { kind: "geometric-sensitive-aperture", areaSquareMicrometers: apertureArea },
760
929
  spatialResponseModel: { kind: "uniform-over-geometric-sensitive-aperture", scientificStatus: "approximation", evidence, limitation: "Uniform response over the square aperture." },
761
930
  referenceConditionPolicy: { kind: "exact-match-required" } },
762
931
  operatingRangeProfile: { schemaVersion: "0.1.0", profileId: "facade-linearity-" + channelId, responseApplicationProfileId: "facade-application-" + channelId,
@@ -786,7 +955,8 @@ export async function simulateCapture(options) {
786
955
  outputQuantity: "outgoing-spectral-radiance", outputUnit: "W/m^2/sr/nm", scientificStatus: "approximation",
787
956
  uncertainty: { kind: "not-quantified", limitation: "Analytic planar scene." },
788
957
  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."] }
958
+ wavelengthChangingTransportModeled: false, volumetricTransportModeled: false, polarizationModeled: false, evidence, limitations: ["Engine-authored planar chart or caller scene function.",
959
+ ...(diffractionObjectSpace ? ["Radiance is the scene radiance convolved with the lens diffraction PSF mapped onto the scene plane (object-space convolution, request.diffraction)."] : [])] }
790
960
  };
791
961
  const lensId = lens.exposure.profileId;
792
962
  const opticsProfile = { schemaVersion: "0.1.0", profileId: lensId + ":facade-transmission", profileVersion: SIMULATE_CAPTURE_FACADE_VERSION, lensProfileId: lensId,
@@ -805,11 +975,39 @@ export async function simulateCapture(options) {
805
975
  const fieldThroughput = vignetting ? { kind: "radial-illumination-vignetting", profile: vignetting, evidence,
806
976
  limitation: "Caller-declared radial relative illumination applied by the capture facade; not a measured lens." } :
807
977
  { 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
978
  const spectralSamplingByChannel = Object.fromEntries(CHANNELS.map((channelId) => [channelId, { channelId, wavelengthBasis: "vacuum", wavelengthRangeNanometers: range,
812
979
  maximumSubintervalWidthNanometers: spectralStep }]));
980
+ // Diffraction (2.1): Airy kernels at the resolved f-number on the site pitch, one per spectral node of the sensor quadrature.
981
+ let diffraction, diffractionPsf;
982
+ if (diffractionRadiusOption !== undefined) {
983
+ const wavelengths = [...new Set(CHANNELS.flatMap((channelId) => calculateSensorSpectralQuadrature({ ...spectralSamplingByChannel[channelId], colorSamplingProfile,
984
+ spectralResponseProfile: spectralResponseProfiles[channelId] }).value.nodes.map((node) => node.wavelengthNanometers)))].sort((a, b) => a - b);
985
+ const lambdaN = wavelengths[wavelengths.length - 1] / 1000 * fNumber * imageDistanceMm / focalLengthMm;
986
+ // The environment resolves the PSF at each pre-filter source point, up to the largest split offset outside the imaging area.
987
+ const aaReachMm = antiAliasing ? Math.max(...antiAliasing.components.map((c) => Math.max(Math.abs(c.offsetMicrometers.x), Math.abs(c.offsetMicrometers.y)))) / 1000 : 0;
988
+ const kernelRadiusSamples = diffractionRadiusOption ?? Math.min(32, Math.max(1, Math.ceil(AIRY_THIRD_ZERO_PER_LAMBDA_N * lambdaN / pitch)));
989
+ diffraction = createDiffractionLimitedPsfProfile({ aperture: "circular", focalLengthMm, apertureFNumber: fNumber, focus: { kind: "finite", distanceM: focusDistanceM },
990
+ wavelengthsNm: wavelengths, samplePitchMicrometers: pitch, kernelRadiusSamples, fieldHalfExtentMm: aaReachMm === 0 ? { x: imagingArea.widthMm / 2, y: imagingArea.heightMm / 2 } :
991
+ { x: imagingArea.widthMm / 2 + aaReachMm, y: imagingArea.heightMm / 2 + aaReachMm },
992
+ profileId: "facade-diffraction-airy" }).value;
993
+ diffractionPsf = { kind: "sampled-local", configuration: { psf: { profile: diffraction.profile, focalLengthMm, focus: { kind: "finite", distanceM: focusDistanceM },
994
+ apertureFNumber: fNumber, signedDefocusImagePlaneMicrometers: 0 }, psfWavelengthBasis: { value: "vacuum", evidence },
995
+ spatialModel: { kind: "destination-local-shift-invariant-approximation", evidence, limitation: "Diffraction-limited Airy PSF, the same at every field point." } } };
996
+ assume("lens.diffraction", "model", { model: diffraction.model, aperture: "circular-aperture", fNumber, workingFNumber: diffraction.workingFNumber,
997
+ kernelRadiusSamples, samplePitchMicrometers: pitch, maximumTruncatedEnergyFraction: diffraction.maximumTruncatedEnergyFraction,
998
+ truncatedEnergyByWavelength: diffraction.wavelengths.map((w) => ({ wavelengthNm: w.wavelengthNm, truncatedEnergyFraction: w.truncatedEnergyFraction })),
999
+ 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). " +
1000
+ "One kernel per spectral node, binned into site-sized cells over a " + (2 * kernelRadiusSamples + 1) + " × " + (2 * kernelRadiusSamples + 1) + " window" +
1001
+ (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. " +
1002
+ (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). " :
1003
+ "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. ") +
1004
+ "No defocus, aberration or pupil clipping; the sensor pixel aperture is still point-sampled at the site centre.");
1005
+ }
1006
+ const psf = lensPsf ?? (diffractionObjectSpace ? { kind: "not-applied", evidence,
1007
+ limitation: "No environment PSF: lens diffraction is applied upstream by the scene provider as an object-space convolution (request.diffraction)." } : undefined) ??
1008
+ diffractionPsf ?? { kind: "not-applied", evidence, limitation: "No lens PSF (point optics)." };
1009
+ const pupilRecord = pupil ? { kind: "ideal-uniform-circular-pupil", radialSampleCount: pupil.radialSamples, angularSampleCount: pupil.angularSamples, evidence,
1010
+ limitation: "Ideal uniform circular pupil; finite ray sampling, no convergence claim." } : undefined;
813
1011
  const chargeSamplingProfile = { schemaVersion: "0.1.0", profileId: IDS.chargeSampling, completenessProfileId: IDS.completeness, scientificStatus: "approximation",
814
1012
  photoShotNoiseModel: "poisson", darkShotNoiseModel: "poisson", additionalComponentPolicies: [], evidence, limitations: ["Poisson photo and dark shot noise."] };
815
1013
  const channelAt = (x, y) => ["red", "green", "green", "blue"][(y % 2) * 2 + (x % 2)];
@@ -843,7 +1041,15 @@ export async function simulateCapture(options) {
843
1041
  }
844
1042
  const readTile = async (request) => ({ ...request, sites: Array.from({ length: request.width }, (_, j) => siteInput(request.y * nativeWidth + request.x + j)) });
845
1043
  const yieldControl = createHostYield().yieldControl;
846
- const perSite = queriesPerSite;
1044
+ const perSite = queriesPerSite * (diffraction && psf === diffractionPsf ? (2 * diffraction.kernelRadiusSamples + 1) ** 2 : 1);
1045
+ let diffractionMs = 0, demosaicMs, encodeMs;
1046
+ const rawProvider = diffraction && diffractionObjectSpace ? createDiffractedPlanarProvider({ base: provider, psf: diffractionPsf.configuration.psf,
1047
+ wavelengthsNm: diffraction.profile.axes.wavelengthNm, kernelRadius: diffraction.kernelRadiusSamples, nativeWidth, nativeHeight, outputWidth: width,
1048
+ sitePitchMicrometers: pitch, distanceM: subjectDistanceM, metresPerFrameUnit, onConvolution: (ms) => { diffractionMs += ms; },
1049
+ // The environment queries each site at its centre minus each split offset (sites, +x right, +y down).
1050
+ ...(antiAliasing ? { queryOffsetsSites: antiAliasing.components.map((c) => ({ x: -c.offsetMicrometers.x / pitch, y: -c.offsetMicrometers.y / pitch })) } : {}) }) : provider;
1051
+ if (antiAliasing && perSite > 100_000)
1052
+ fail("sensor.antiAliasing with these options needs " + perSite + " scene queries per site; the environment admits at most 100000.");
847
1053
  const evaluations = nativeWidth * nativeHeight * perSite;
848
1054
  // The native task admits at most 100 000 radiance queries per tile.
849
1055
  const tileWidth = Math.max(1, Math.min(256, Math.floor(100_000 / perSite)));
@@ -876,7 +1082,7 @@ export async function simulateCapture(options) {
876
1082
  const rawTick = progress("raw", plan.tileCount);
877
1083
  const rawTask = createNativeEnvironmentRawTask(environmentRaw, {
878
1084
  readTile: async (request) => { const tile = await readTile(request); rawTick(); return tile; },
879
- evaluateRadiance: provider.evaluateRadiance, ...(pupil ? { evaluateApertureRadiance: provider.evaluateApertureRadiance } : {}), yieldControl
1085
+ evaluateRadiance: rawProvider.evaluateRadiance, ...(pupil ? { evaluateApertureRadiance: provider.evaluateApertureRadiance } : {}), yieldControl
880
1086
  });
881
1087
  await runTask(rawTask);
882
1088
  let rawOutput = rawTask.takeOutput();
@@ -905,7 +1111,7 @@ export async function simulateCapture(options) {
905
1111
  // retained optical stage, and is faster: retaining the optical stage takes the first pass off the lean executor.
906
1112
  const wbTask = createNativeEnvironmentRawTask(finalRawInput, {
907
1113
  readTile: async (request) => { const tile = await readTile(request); wbTick(); return tile; },
908
- evaluateRadiance: provider.evaluateRadiance, ...(pupil ? { evaluateApertureRadiance: provider.evaluateApertureRadiance } : {}), yieldControl
1114
+ evaluateRadiance: rawProvider.evaluateRadiance, ...(pupil ? { evaluateApertureRadiance: provider.evaluateApertureRadiance } : {}), yieldControl
909
1115
  });
910
1116
  await runTask(wbTask);
911
1117
  rawOutput = wbTask.takeOutput();
@@ -924,7 +1130,9 @@ export async function simulateCapture(options) {
924
1130
  return [channel, { conversionGainElectronsPerCode: resolved.regime.systemConversionGainElectronsPerCode.value / (resolved.regime.adc.digitalGain ?? 1),
925
1131
  readNoiseRmsElectrons: resolved.combinedReadNoiseRmsElectrons }];
926
1132
  }));
1133
+ const dngStarted = clockMs();
927
1134
  dng = await serializeNativeRawDng({ raw: rawOutput.raw, colorProfile, noise, ...(dngCapturedAtUtc === undefined ? {} : { capturedAtUtc: dngCapturedAtUtc }) });
1135
+ encodeMs = clockMs() - dngStarted;
928
1136
  checkAbort();
929
1137
  assume("output.dng", "model", { writer: "serializeNativeRawDng", schemaVersion: dng.schemaVersion, whiteLevel: dng.whiteLevel, blackLevels: dng.blackLevels,
930
1138
  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 +1143,37 @@ export async function simulateCapture(options) {
935
1143
  maximumRetainedPayloadBytes: plan.outputBytes + width * height * 3 * (rendering.bitDepth / 8) };
936
1144
  let developed;
937
1145
  let gamutMappingDiagnostics;
938
- if (!linearPath) {
1146
+ if (ahd) {
1147
+ let lastYield = Date.now();
1148
+ const linear = await developLinearSrgb({ ...development, raw: rawOutput.raw }, async (completed, total) => {
1149
+ onProgress?.({ stage: "development", completedTileCount: completed, totalTileCount: total });
1150
+ if (Date.now() - lastYield >= 8) {
1151
+ await yieldControl();
1152
+ checkAbort();
1153
+ lastYield = Date.now();
1154
+ }
1155
+ }, { yieldControl: () => yieldControl(), checkAbort, onDemosaicMs: (ms) => { demosaicMs = ms; } });
1156
+ if (!linearPath) {
1157
+ // The native development's SDR kernel, row by row, on the AHD-developed linear sRGB.
1158
+ const maximum = 2 ** rendering.bitDepth - 1, tone = prepareSdrPixelTone(rendering), scratch = new Float64Array(3);
1159
+ const codes = rendering.bitDepth === 8 ? new Uint8Array(width * height * 3) : new Uint16Array(width * height * 3);
1160
+ const counts = { negative: 0, above: 0, changed: 0, low: 0, high: 0, outOfGamut: false };
1161
+ for (let y = 0; y < height; y++)
1162
+ renderSdrRunInto(linear.rgb, y * width * 3, width, rendering, tone, 2 ** rendering.renderingExposureEv, maximum, codes, y * width * 3, scratch, counts);
1163
+ developed = { raw: rawOutput.raw, integerSamples: codes, highlight: linear.highlight, diagnostics: { renderingNegativeSampleCount: counts.negative,
1164
+ renderingAboveReferenceSampleCount: counts.above, toneChangedSampleCount: counts.changed, gamutClippedLowSampleCount: counts.low,
1165
+ gamutClippedHighSampleCount: counts.high, captureSaturation: "consumed-in-raw-development" } };
1166
+ }
1167
+ else {
1168
+ const mapping = perceptual ? prepareRgbGamutMapping({ colorSpace: colorEncoding, method: "cielab-hue-preserving-chroma-compression", kneeFraction: PERCEPTUAL_GAMUT_KNEE_FRACTION }) : undefined;
1169
+ const encoded = encodeSdrFromLinear(linear.rgb, colorEncoding, pngBitDepth, mapping);
1170
+ developed = { raw: rawOutput.raw, integerSamples: encoded.samples, highlight: linear.highlight, diagnostics: encoded.diagnostics };
1171
+ if (mapping)
1172
+ gamutMappingDiagnostics = { method: "cielab-hue-preserving-chroma-compression", kneeFraction: PERCEPTUAL_GAMUT_KNEE_FRACTION,
1173
+ adjustedPixelCount: encoded.adjustedPixelCount, lightnessClippedPixelCount: encoded.lightnessClippedPixelCount };
1174
+ }
1175
+ }
1176
+ else if (!linearPath) {
939
1177
  const devTask = createNativeRawDevelopmentTask({ ...development, raw: rawOutput.raw }, { yieldControl,
940
1178
  onProgress: (p) => onProgress?.({ stage: "development", completedTileCount: p.completedTileCount, totalTileCount: p.totalTileCount }) });
941
1179
  await runTask(devTask);
@@ -985,36 +1223,53 @@ export async function simulateCapture(options) {
985
1223
  return { ...request, samples: out };
986
1224
  }, yieldControl
987
1225
  });
1226
+ const jpegEncodeStarted = clockMs();
988
1227
  await runTask(jpegTask);
989
1228
  jpeg = jpegTask.takeOutput().bytes;
1229
+ encodeMs = clockMs() - jpegEncodeStarted;
990
1230
  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
1231
  }
992
1232
  const jpegMs = Date.now() - jpegStarted;
993
1233
  let png;
994
1234
  if (format === "png") {
1235
+ const pngStarted = clockMs();
995
1236
  png = serializePngImage({ width, height, bitDepth: pngBitDepth, samples: developed.integerSamples, colourEncoding: pngColourEncoding(colorEncoding) });
1237
+ encodeMs = clockMs() - pngStarted;
996
1238
  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
1239
  }
1240
+ const ranOptions = new Set([...(pupil ? ["lens.pupilSampling"] : []), ...(projection !== undefined ? ["lens.projection"] : []),
1241
+ ...(distortion ? ["lens.distortion"] : []), ...(vignetting ? ["lens.vignetting"] : []), ...(lensPsf ? ["lens.psf"] : []),
1242
+ ...(diffraction ? ["lens.diffraction"] : []), ...(antiAliasing ? ["sensor.antiAliasing"] : []),
1243
+ ...(temporalSampleCount > 1 ? ["sensor.temporalSampleCount"] : []), ...(rotating ? ["motion.rotation"] : []),
1244
+ ...(rollingSchedule ? ["motion.shutter.rolling"] : []), ...(mirrorBorder ? ["output.borderHandling.mirror"] : []),
1245
+ ...(needsAuto ? ["output.whiteBalance.auto"] : []), ...(ahd ? ["output.demosaic.ahd"] : []),
1246
+ ...(highlightMode === "reconstruct" ? ["output.highlightMode.reconstruct"] : []), ...(wideGamut ? ["output.colorEncoding.wide-gamut"] : []),
1247
+ ...(perceptual ? ["output.gamutMapping.perceptual"] : []), ...(format === "jpeg" || format === "png" || format === "dng" ? ["output.format." + format] : [])]);
998
1248
  return Object.freeze({
999
1249
  facadeVersion: SIMULATE_CAPTURE_FACADE_VERSION, engineApiVersion: ENGINE_API_VERSION,
1000
1250
  image: { width, height, format, samples: developed.integerSamples, ...(jpeg ? { jpeg } : {}), ...(png ? { png, colorEncoding: colorEncoding } : {}), ...(dng ? { dng: dng.bytes } : {}) },
1001
1251
  raw: developed.raw, plan,
1002
- request: { environmentRaw: finalRawInput, siteInput, development, whiteBalanceState },
1252
+ request: { environmentRaw: finalRawInput, siteInput, development, whiteBalanceState,
1253
+ ...(diffraction ? { diffraction: { profile: diffraction, psf: diffractionPsf, execution: diffractionObjectSpace ? "object-space-convolution" : "environment-psf-quadrature" } } : {}) },
1003
1254
  exposure: { mode, settings: { fNumber, shutterSeconds, iso: resolvedIso }, ev100: Math.log2(fNumber ** 2 / shutterSeconds),
1004
1255
  meteredSceneLuminanceCdPerSquareMeter: meteredLuminance, resolution },
1005
1256
  scene: { sceneId: chart ? "chart:" + chart.preset : callerScene.sceneId, preset: scenePreset, parameters: chart ? chart.parameters : {},
1006
1257
  illuminant: { id: illuminant.id, description: illuminant.description, illuminanceLux }, distanceM: subjectDistanceM,
1007
1258
  framedWidthM: 2 * metresPerFrameUnit, framedHeightM: 2 * metresPerFrameUnit * aspect, sourceSha256 },
1008
1259
  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 } },
1260
+ timingsMs: { raw: rawMs, whiteBalance: wbMs, development: devMs, jpeg: jpegMs, total: Date.now() - started,
1261
+ ...(diffraction && diffractionObjectSpace ? { diffraction: diffractionMs } : {}), ...(demosaicMs === undefined ? {} : { demosaic: demosaicMs }),
1262
+ ...(encodeMs === undefined ? {} : { encode: encodeMs }) },
1263
+ options: Object.freeze(SIMULATE_CAPTURE_COST_OPTIONS.filter((option) => ranOptions.has(option))) },
1010
1264
  provenance: { scientificStatus: "approximation", cameraPresetId: preset.presetId, cameraPresetVersion: preset.presetVersion,
1011
1265
  evidence: [...FACADE_EVIDENCE, ...preset.evidence],
1012
1266
  composedContracts: ["resolveGenericEquipmentExposureCapabilities", "meterRelativeExposure", "createExposureMeterTargetFromMeteringResult",
1013
1267
  "resolve" + (mode === "manual" ? "Manual" : mode === "aperture-priority" ? "AperturePriority" : mode === "shutter-priority" ? "ShutterPriority" : "ProgramAuto") + "ExposureMode",
1014
1268
  ...(rollingSchedule ? ["resolveElectronicRollingShutterExposureSchedule", "createRollingShutterCaptureExposureSchedule"] : []),
1015
- "resolveGenericIsoSignalChain", "createNativeEnvironmentRawTask", ...(needsAuto ? ["estimateAutoWhiteBalance"] : []),
1016
- ...(builtInDemosaic ? ["createBuiltInBayerDemosaicProfiles"] : []),
1017
- ...(linearPath ? ["native RAW development operator (linear, pre-clip)", "resolveRgbColorSpace", ...(perceptual ? ["prepareRgbGamutMapping"] : [])] : ["createNativeRawDevelopmentTask"]),
1269
+ ...(diffraction ? ["createDiffractionLimitedPsfProfile", ...(diffractionObjectSpace ? ["resolveLensPsfConvolutionKernels", "calculateIrradianceConvolution"] : [])] : []),
1270
+ ...(antiAliasing ? ["resolveAntiAliasingSpatialKernel"] : []), "resolveGenericIsoSignalChain", "createNativeEnvironmentRawTask", ...(needsAuto ? ["estimateAutoWhiteBalance"] : []),
1271
+ ...(builtInDemosaic ? ["createBuiltInBayerDemosaicProfiles"] : []), ...(ahd ? ["createAhdDemosaicTask"] : []),
1272
+ ...(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
1273
  ...(jpeg ? ["createPrintJpegTask"] : []), ...(png ? ["serializePngImage"] : []), ...(dng ? ["resolveSensorReadoutRegime", "serializeNativeRawDng"] : [])],
1019
1274
  limitations: ["Educational approximation: synthetic tier sensor values, idealized colorimetric sensor, planar Lambertian scene and point optics; not a calibrated camera emulation.",
1020
1275
  "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."] },