@photivra/engine 2.19.0 → 2.20.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 (129) hide show
  1. package/CHANGELOG.md +112 -1
  2. package/CITATION.cff +1 -1
  3. package/NODE_SUPPORT.md +7 -5
  4. package/README.md +6 -6
  5. package/SECURITY.md +1 -1
  6. package/THIRD_PARTY.md +14 -10
  7. package/dist/capture/dng-lens-correction-opcodes.d.ts +32 -0
  8. package/dist/capture/dng-lens-correction-opcodes.js +119 -0
  9. package/dist/capture/dng-lens-correction-opcodes.js.map +1 -0
  10. package/dist/capture/native-environment-lean.js +1 -5
  11. package/dist/capture/native-environment-lean.js.map +1 -1
  12. package/dist/capture/native-raw-dng.d.ts +6 -0
  13. package/dist/capture/native-raw-dng.js +16 -2
  14. package/dist/capture/native-raw-dng.js.map +1 -1
  15. package/dist/capture/sensor-raw-producer.js +3 -5
  16. package/dist/capture/sensor-raw-producer.js.map +1 -1
  17. package/dist/core/shared-helpers.d.ts +2 -0
  18. package/dist/core/shared-helpers.js +4 -0
  19. package/dist/core/shared-helpers.js.map +1 -1
  20. package/dist/core/version.d.ts +1 -1
  21. package/dist/core/version.js +1 -1
  22. package/dist/core/version.js.map +1 -1
  23. package/dist/equipment/generic-lens-catalogue.d.ts +8 -2
  24. package/dist/equipment/generic-lens-catalogue.js +31 -2
  25. package/dist/equipment/generic-lens-catalogue.js.map +1 -1
  26. package/dist/equipment/generic-tier-assets.js +2 -3
  27. package/dist/equipment/generic-tier-assets.js.map +1 -1
  28. package/dist/equipment/generic-tier-presets.js +2 -3
  29. package/dist/equipment/generic-tier-presets.js.map +1 -1
  30. package/dist/facade/display-view-gpu.d.ts +7 -4
  31. package/dist/facade/display-view-gpu.js +4 -4
  32. package/dist/facade/display-view-gpu.js.map +1 -1
  33. package/dist/facade/display-view-lens-signal.d.ts +1 -1
  34. package/dist/facade/display-view-lens-signal.js +5 -3
  35. package/dist/facade/display-view-lens-signal.js.map +1 -1
  36. package/dist/facade/display-view.d.ts +3 -1
  37. package/dist/facade/display-view.js +1 -1
  38. package/dist/facade/display-view.js.map +1 -1
  39. package/dist/facade/fast-methods.d.ts +50 -0
  40. package/dist/facade/fast-methods.js +50 -0
  41. package/dist/facade/fast-methods.js.map +1 -0
  42. package/dist/facade/fast-resolution.d.ts +12 -0
  43. package/dist/facade/fast-resolution.js +18 -0
  44. package/dist/facade/fast-resolution.js.map +1 -0
  45. package/dist/facade/generic-lens-capture.d.ts +3 -2
  46. package/dist/facade/generic-lens-capture.js +3 -2
  47. package/dist/facade/generic-lens-capture.js.map +1 -1
  48. package/dist/facade/simulate-capture.d.ts +51 -11
  49. package/dist/facade/simulate-capture.js +144 -75
  50. package/dist/facade/simulate-capture.js.map +1 -1
  51. package/dist/facade/simulate-display-view.d.ts +3 -2
  52. package/dist/facade/simulate-display-view.js +22 -12
  53. package/dist/facade/simulate-display-view.js.map +1 -1
  54. package/dist/index.d.ts +7 -0
  55. package/dist/index.js +5 -0
  56. package/dist/index.js.map +1 -1
  57. package/dist/optics/aperture-diameter.d.ts +47 -0
  58. package/dist/optics/aperture-diameter.js +50 -0
  59. package/dist/optics/aperture-diameter.js.map +1 -0
  60. package/dist/optics/detail-limit.d.ts +182 -0
  61. package/dist/optics/detail-limit.js +336 -0
  62. package/dist/optics/detail-limit.js.map +1 -0
  63. package/dist/optics/generic-lens-psf.d.ts +18 -2
  64. package/dist/optics/generic-lens-psf.js +25 -2
  65. package/dist/optics/generic-lens-psf.js.map +1 -1
  66. package/dist/optics/scene-to-sensor-irradiance.d.ts +13 -0
  67. package/dist/optics/scene-to-sensor-irradiance.js +7 -0
  68. package/dist/optics/scene-to-sensor-irradiance.js.map +1 -1
  69. package/dist/output/crop-to-match-focal-length.d.ts +62 -0
  70. package/dist/output/crop-to-match-focal-length.js +63 -0
  71. package/dist/output/crop-to-match-focal-length.js.map +1 -0
  72. package/dist/output/developed-lens-corrections.js +2 -2
  73. package/dist/output/developed-lens-corrections.js.map +1 -1
  74. package/dist/output/generic-lens-corrections.d.ts +3 -1
  75. package/dist/output/generic-lens-corrections.js +8 -4
  76. package/dist/output/generic-lens-corrections.js.map +1 -1
  77. package/dist/output/lens-corrections.d.ts +12 -2
  78. package/dist/output/lens-corrections.js +22 -7
  79. package/dist/output/lens-corrections.js.map +1 -1
  80. package/dist/output/print-contrast-reference.js +1 -1
  81. package/dist/output/print-contrast-reference.js.map +1 -1
  82. package/dist/output/print-size-blur-noise.d.ts +110 -0
  83. package/dist/output/print-size-blur-noise.js +138 -0
  84. package/dist/output/print-size-blur-noise.js.map +1 -0
  85. package/dist/sensor/eqe-electron-rate.js +1 -7
  86. package/dist/sensor/eqe-electron-rate.js.map +1 -1
  87. package/dist/sensor/exposure-window.d.ts +2 -0
  88. package/dist/sensor/exposure-window.js +2 -2
  89. package/dist/sensor/exposure-window.js.map +1 -1
  90. package/dist/sensor/readout-timing.js +2 -23
  91. package/dist/sensor/readout-timing.js.map +1 -1
  92. package/dist/sensor/response-application-compatibility.js +1 -6
  93. package/dist/sensor/response-application-compatibility.js.map +1 -1
  94. package/dist/sensor/responsivity-photocurrent.js +1 -7
  95. package/dist/sensor/responsivity-photocurrent.js.map +1 -1
  96. package/dist/sensor/sensor-characteristics.d.ts +137 -0
  97. package/dist/sensor/sensor-characteristics.js +115 -0
  98. package/dist/sensor/sensor-characteristics.js.map +1 -0
  99. package/dist/sensor/spatial-sample-reduction.js +1 -5
  100. package/dist/sensor/spatial-sample-reduction.js.map +1 -1
  101. package/dist/sensor/spectral-quadrature.js +1 -6
  102. package/dist/sensor/spectral-quadrature.js.map +1 -1
  103. package/dist/sensor/temporal-photo-signal.d.ts +3 -0
  104. package/dist/sensor/temporal-photo-signal.js +5 -2
  105. package/dist/sensor/temporal-photo-signal.js.map +1 -1
  106. package/dist/stabilization/system.d.ts +9 -0
  107. package/dist/stabilization/system.js +5 -6
  108. package/dist/stabilization/system.js.map +1 -1
  109. package/docs/AGENT_GUIDE.md +4 -0
  110. package/docs/API_REFERENCE.md +76 -2
  111. package/docs/QUICK_START.md +88 -6
  112. package/docs/USAGE.md +65 -2
  113. package/docs/api/capture-native-raw-dng.md +6 -0
  114. package/docs/api/core-version.md +1 -1
  115. package/docs/api/equipment-generic-lens-catalogue.md +7 -2
  116. package/docs/api/exports.json +152 -2
  117. package/docs/api/facade-display-view-gpu.md +5 -2
  118. package/docs/api/facade-fast-methods.md +91 -0
  119. package/docs/api/facade-simulate-capture.md +36 -11
  120. package/docs/api/facade-simulate-display-view.md +3 -2
  121. package/docs/api/optics-aperture-diameter.md +51 -0
  122. package/docs/api/optics-detail-limit.md +203 -0
  123. package/docs/api/output-crop-to-match-focal-length.md +67 -0
  124. package/docs/api/output-generic-lens-corrections.md +3 -1
  125. package/docs/api/output-lens-corrections.md +11 -1
  126. package/docs/api/output-print-size-blur-noise.md +115 -0
  127. package/docs/api/sensor-sensor-characteristics.md +105 -0
  128. package/llms.txt +4 -0
  129. package/package.json +1 -1
@@ -36,6 +36,8 @@ import { ENGINE_API_VERSION } from "../core/version.js";
36
36
  import { GENERIC_TIER_ASSETS } from "../equipment/generic-tier-assets.js";
37
37
  import { parseCallerCameraPreset } from "./camera-preset.js";
38
38
  import { createHostYield } from "./host-yield.js";
39
+ import { fastFormatDefault, resolveFast } from "./fast-resolution.js";
40
+ import { SCENE_TO_SENSOR_BRIDGE_SCOPE } from "../optics/scene-to-sensor-irradiance.js";
39
41
  import { resolveGenericEquipmentExposureCapabilities } from "../equipment/exposure-capabilities.js";
40
42
  import { resolveGenericIsoSignalChain } from "../sensor/iso-signal-chain.js";
41
43
  import { meterRelativeExposure } from "../exposure/metering.js";
@@ -91,7 +93,8 @@ import { multiplyMatrices } from "../color/colorimetry-math.js";
91
93
  import { prepareRgbGamutMapping } from "../color/gamut-mapping.js";
92
94
  import { serializeNativeRawDng } from "../capture/native-raw-dng.js";
93
95
  import { correctDiffractionInPlace, defaultDiffractionCorrectionRadiusPixels, designDiffractionCorrectionFilter, parseDiffractionCorrectionSettings, parseUnsharpMaskSettings, renderSdrWithUnsharpMaskInPlace, DEVELOPMENT_DETAIL_MODEL_VERSION } from "../processing/development-detail.js";
94
- import { parseSensorReadoutConversionProfile, resolveSensorReadoutRegime } from "../sensor/raw-readout.js";
96
+ import { resolveSensorReadoutRegime } from "../sensor/raw-readout.js";
97
+ import { characterizeReadout, parseSensorCharacteristics, sensorReport, SENSOR_CHARACTERISTIC_KEYS } from "../sensor/sensor-characteristics.js";
95
98
  import { resolveChartScene, chartMaterialInputs, CHART_SCENE_PRESETS } from "./chart-scenes.js";
96
99
  import { resolveChartMaterialLayers } from "./chart-material-layers.js";
97
100
  import { resolveCaptureIlluminant, calculateIlluminantIrradianceScale, buildPlanarSceneRadianceProvider, requireCallerScene, FACADE_SPECTRAL_RANGE_NANOMETERS, resolveChartSizeOption, PHOTOPIC_MAXIMUM_LUMINOUS_EFFICACY_LM_PER_W, planarProviderMaterialOfDirection } from "./scene-radiance.js";
@@ -118,10 +121,12 @@ export const FACADE_EVIDENCE = /* @__PURE__ */ Object.freeze([/* @__PURE__ */ Ob
118
121
  sourceReference: "photivra:simulate-capture-facade", reuseStatus: "photivra-owned" })]);
119
122
  const DEFAULTS = /* @__PURE__ */ Object.freeze({ camera: "professional", fNumber: 8, shutterSeconds: 1 / 125, iso: 100, compensationStops: 0,
120
123
  subjectDistanceM: 5, width: 64, height: 64, format: "srgb-8", toneCurve: "identity", highlightMode: "clip",
121
- whiteBalance: "scene-illuminant", jpegQuantizationStep: 2, pixelPitchMicrometers: 6, spectralStepNanometers: 20,
124
+ whiteBalance: "scene-illuminant", jpegQuantizationStep: 1, pixelPitchMicrometers: 6, spectralStepNanometers: 20,
122
125
  temporalSampleCount: 1, noiseSeed: 1, illuminantKelvin: 6500 });
123
126
  /** Standard output sensitivity S = 10 / H (CIPA DC-004-2020 §3.2): the reference exposure in lx·s is 10 / S. */
124
127
  const SOS_REFERENCE_EXPOSURE_NUMERATOR_LUX_SECONDS = 10;
128
+ /** #565: the one-tap kernel that makes the image-space provider read each query at its lattice point. */
129
+ const UNIT_SITE_KERNEL = /* @__PURE__ */ new Float64Array([1]);
125
130
  /** Camera equation H = 0.65·L·t/N² (CIPA DC-004-2020 §5.5 (4); 0.65 = τ·π/4 with τ = 0.828, Explanation 4). */
126
131
  const CAMERA_EQUATION_CONSTANT = 0.65;
127
132
  /** Neutral reference reflectance: DC-004 ties the SOS reference level to an 18 % grey (Explanation 1 and 2.2). */
@@ -179,15 +184,16 @@ function greyCardDeviationStops(fNumber, shutterSeconds, iso, illuminanceLux) {
179
184
  */
180
185
  export const SIMULATE_CAPTURE_DEFAULT_ILLUMINANCE_LUX = Math.PI * sosReferenceLuminance(DEFAULTS.fNumber, DEFAULTS.shutterSeconds, DEFAULTS.iso) /
181
186
  GREY_CARD_REFLECTANCE;
182
- /**
183
- * Sensitive-aperture area (µm²) at which the tier's ISO signal chain applies unchanged: the default 6 µm square
184
- * aperture with fill factor 1. At other areas the facade scales the electron-domain readout values by
185
- * area / this reference, so the same focal-plane exposure H gives the same output code (see `isoAreaScaledReadout`).
186
- */
187
187
  /** 2.5: the largest accepted pixel pitch, µm (44 mm ÷ 48 pixels ≈ 917 µm, a whole full-frame or larger sensor at a small output). */
188
188
  const SIMULATE_CAPTURE_PIXEL_PITCH_MAXIMUM_MICROMETERS = 1000;
189
189
  /** Pitches above this, µm, are larger than any camera photosite: the capture is a downsized whole-frame view (assumption sensor.wholeFrameView). */
190
190
  const SIMULATE_CAPTURE_PHOTOSITE_PITCH_MAXIMUM_MICROMETERS = 100;
191
+ /**
192
+ * Sensitive-aperture area (µm²) at which the tier's ISO signal chain applies unchanged: the default 6 µm square
193
+ * aperture with fill factor 1. At other areas the facade scales the electron-domain readout values by
194
+ * area / this reference, so the same focal-plane exposure H gives the same output code (the `chargeScale` of
195
+ * `characterizeReadout`, src/sensor/sensor-characteristics.ts).
196
+ */
191
197
  const ISO_REFERENCE_APERTURE_AREA_SQUARE_MICROMETERS = DEFAULTS.pixelPitchMicrometers * DEFAULTS.pixelPitchMicrometers;
192
198
  /** Demosaic border, native sites per side. */
193
199
  const MARGIN = 1;
@@ -340,17 +346,6 @@ const TEXT_METADATA_LAYOUT = /* @__PURE__ */ Object.freeze({
340
346
  png: "One tEXt (printable Latin-1) or iTXt (UTF-8) chunk per field, keywords Description, Software, Copyright, Comment, before the first IDAT",
341
347
  dng: "ImageDescription (statement, then the description) and Copyright when printable ASCII; an XMP packet in tag 700 with every field"
342
348
  });
343
- /**
344
- * The readout profile with every regime's charge-domain transfer scaled by `areaScale`: system conversion gain
345
- * (e⁻ per code) and pre-ADC saturation (e⁻ equivalent) are multiplied by it, so codes per unit focal-plane exposure
346
- * stay those of the reference aperture. Read noise stays in electrons per pixel; codes, black level, ADC and digital
347
- * gain are unchanged. The result is re-checked by the readout parser.
348
- */
349
- function isoAreaScaledReadout(profile, areaScale) {
350
- return parseSensorReadoutConversionProfile({ ...profile, regimes: profile.regimes.map((regime) => ({ ...regime,
351
- systemConversionGainElectronsPerCode: { ...regime.systemConversionGainElectronsPerCode, value: regime.systemConversionGainElectronsPerCode.value * areaScale },
352
- preAdcSaturationElectronEquivalent: { ...regime.preAdcSaturationElectronEquivalent, value: regime.preAdcSaturationElectronEquivalent.value * areaScale } })) });
353
- }
354
349
  /** Diagnostic clock for the 2.1 stage timings: `performance.now()` where the host has it. Never affects output. */
355
350
  export function clockMs() { return globalThis.performance?.now() ?? Date.now(); }
356
351
  function positive(value, name) {
@@ -744,7 +739,7 @@ export async function serializeSimulatedCaptureDngFromParts(options, parts) {
744
739
  return await runCapture(options, "dng-from-parts", parts);
745
740
  }
746
741
  /** The whole frame's packed RAW from region parts (see `serializeSimulatedCaptureDngFromParts`). */
747
- function joinNativeRawParts(value, plan, width, height, caller) {
742
+ function joinNativeRawParts(value, plan, width, height, caller, fast) {
748
743
  if (!Array.isArray(value) || value.length === 0)
749
744
  throw new InvalidConfigurationError(caller + " needs at least one part.");
750
745
  const parts = value, count = width * height;
@@ -755,6 +750,8 @@ function joinNativeRawParts(value, plan, width, height, caller) {
755
750
  const rect = part?.region?.nativeRect, raw = part?.raw;
756
751
  if (!rect || !raw)
757
752
  bad(k, "needs raw and region.nativeRect (a region render's result).");
753
+ if (part.fast === true && !fast)
754
+ bad(k, "a fast method rendered it, but this request resolves to the exact methods (fast: false, the default for a DNG, JPEG or PNG). Render the parts with fast: false, or pass fast: true here.");
758
755
  const { x, y, width: w, height: h } = rect;
759
756
  if (![x, y, w, h].every(Number.isSafeInteger) || x < 0 || y < 0 || w < 1 || h < 1 || x + w > width || y + h > height)
760
757
  bad(k, "its nativeRect is not inside the frame's native raster.");
@@ -1148,11 +1145,10 @@ export async function runCapture(options, run, viewOption, renderView) {
1148
1145
  fail("lens.defocus cannot be combined with lens.pupilSampling: the pupil rays already render defocus, so it would be counted twice.");
1149
1146
  if (lensPsf)
1150
1147
  fail("lens.defocus cannot be combined with lens.psf: a caller PSF declares its own defocus, so it could be counted twice.");
1151
- // #549: with a catalogue lens the disc is convolved into the lens's PSF (below), not applied by the lens.defocus paths. The bokeh
1152
- // options (#553) shape the lens.defocus kernel only, so they are refused rather than dropped (a follow-up for a later release).
1148
+ // #549, #567: with a catalogue lens its own bokeh kernel is convolved into its PSF (below); it sets all three bokeh options.
1153
1149
  if (catalogue && defocusBokeh)
1154
1150
  fail("lens.defocus.blades, sphericalAberration and catsEye cannot be combined with lens.catalogue: the catalogue lens " +
1155
- "folds a plain disc into its own PSF. Omit the bokeh options, or use lens.focalLengthMm instead of the catalogue lens.");
1151
+ "sets its own (lens.catalogue.character). Omit the bokeh options, or use lens.focalLengthMm instead of the catalogue lens.");
1156
1152
  if (!catalogue)
1157
1153
  defocusModel = "geometric-disc";
1158
1154
  }
@@ -1190,10 +1186,29 @@ export async function runCapture(options, run, viewOption, renderView) {
1190
1186
  const format = pick(outputOptions, "format", "output.format", DEFAULTS.format, "8-bit sRGB samples.");
1191
1187
  if (!["srgb-8", "srgb-16", "jpeg", "png", "dng"].includes(format))
1192
1188
  fail("output.format must be srgb-8, srgb-16, jpeg, png or dng.");
1193
- let dngCapturedAtUtc;
1189
+ const correctionSelection = (value, name) => {
1190
+ if (value === undefined)
1191
+ return undefined;
1192
+ const o = allowKeys(value, name, ["distortion", "lateralColor", "vignetting"]), selection = {};
1193
+ for (const key of ["distortion", "lateralColor", "vignetting"]) {
1194
+ if (o[key] !== undefined && typeof o[key] !== "boolean")
1195
+ fail(name + "." + key + " must be true or false.");
1196
+ selection[key] = o[key] === true;
1197
+ }
1198
+ if (!Object.values(selection).includes(true))
1199
+ return undefined;
1200
+ if (!catalogue)
1201
+ fail(name + " needs lens.catalogue: the corrections are the catalogue lens's own profile.");
1202
+ return selection;
1203
+ };
1204
+ let dngCapturedAtUtc, dngOpcodeSelection;
1194
1205
  if (format === "dng") {
1195
- const d = allowKeys(outputOptions.dng, "output.dng", ["capturedAtUtc"]);
1206
+ const d = allowKeys(outputOptions.dng, "output.dng", ["capturedAtUtc", "lensCorrectionOpcodes"]);
1196
1207
  dngCapturedAtUtc = d.capturedAtUtc;
1208
+ // #587: the corrections as DNG opcodes, which apply to the whole frame's ActiveArea.
1209
+ dngOpcodeSelection = correctionSelection(d.lensCorrectionOpcodes, "output.dng.lensCorrectionOpcodes");
1210
+ if (dngOpcodeSelection && outputOptions.region !== undefined)
1211
+ fail("output.dng.lensCorrectionOpcodes needs the whole frame (no output.region).");
1197
1212
  if (dngCapturedAtUtc === undefined)
1198
1213
  assume("output.dng.capturedAtUtc", "default", null, "No capture time: the DNG carries no DateTimeOriginal (the engine never invents a wall clock).");
1199
1214
  }
@@ -1249,10 +1264,10 @@ export async function runCapture(options, run, viewOption, renderView) {
1249
1264
  }
1250
1265
  const exposureGain = exposureCompensationStops === 0 ? 1 : 2 ** exposureCompensationStops;
1251
1266
  const whiteBalanceOption = pick(outputOptions, "whiteBalance", "output.whiteBalance", DEFAULTS.whiteBalance, "White balance measured from a perfect white diffuser under the scene illuminant (a custom/grey-card white balance).");
1252
- 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;
1267
+ const jpegStep = format === "jpeg" ? integerIn(pick(outputOptions, "jpegQuantizationStep", "output.jpegQuantizationStep", DEFAULTS.jpegQuantizationStep, "Constant JPEG quantization step 1 (not a manufacturer quality scale)."), "output.jpegQuantizationStep", 1, 255) : undefined;
1253
1268
  if (format !== "jpeg" && outputOptions.jpegQuantizationStep !== undefined)
1254
1269
  fail("output.jpegQuantizationStep applies only to output.format \"jpeg\".");
1255
- const sensorOptions = allowKeys(top.sensor, "sensor", ["pixelPitchMicrometers", "spectralStepNanometers", "temporalSampleCount", "noiseSeed", "antiAliasing", "pixelAperture"]);
1270
+ const sensorOptions = allowKeys(top.sensor, "sensor", ["pixelPitchMicrometers", "spectralStepNanometers", "temporalSampleCount", "noiseSeed", "antiAliasing", "pixelAperture", ...SENSOR_CHARACTERISTIC_KEYS]);
1256
1271
  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");
1257
1272
  if (pitch < 0.5 || pitch > SIMULATE_CAPTURE_PIXEL_PITCH_MAXIMUM_MICROMETERS)
1258
1273
  fail("sensor.pixelPitchMicrometers must be 0.5-" + SIMULATE_CAPTURE_PIXEL_PITCH_MAXIMUM_MICROMETERS + ".");
@@ -1261,6 +1276,7 @@ export async function runCapture(options, run, viewOption, renderView) {
1261
1276
  fail("sensor.spectralStepNanometers must be 1-100.");
1262
1277
  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.") :
1263
1278
  pick(sensorOptions, "temporalSampleCount", "sensor.temporalSampleCount", DEFAULTS.temporalSampleCount, "One temporal sample: the static scene and still camera make more samples unnecessary."), "sensor.temporalSampleCount", 1, 256);
1279
+ const characteristics = parseSensorCharacteristics(sensorOptions);
1264
1280
  const noiseSeed = integerIn(pick(sensorOptions, "noiseSeed", "sensor.noiseSeed", DEFAULTS.noiseSeed, "Fixed noise seed 1 (deterministic output)."), "sensor.noiseSeed", 0, 0xffffffff);
1265
1281
  const antiAliasing = sensorOptions.antiAliasing === undefined ? undefined : parseAntiAliasing(sensorOptions.antiAliasing, pitch);
1266
1282
  const antiAliasingFast = antiAliasing ? fastOverride(sensorOptions.antiAliasing, "sensor.antiAliasing") : undefined;
@@ -1328,10 +1344,11 @@ export async function runCapture(options, run, viewOption, renderView) {
1328
1344
  if (demosaicOption !== undefined && demosaicOption !== "bilinear" && demosaicOption !== "ahd")
1329
1345
  fail("output.demosaic must be bilinear or ahd.");
1330
1346
  const ahd = demosaicOption === "ahd";
1331
- // #395: exact or fast per option. Only lens.catalogue has a fast method (#568); it reports true below when it ran.
1347
+ // #395: exact or fast per option: the option record's `fast`, else the top-level one, else (2.20.0) the format's default: fast for
1348
+ // sRGB samples and the display view, exact for the exports (DNG, JPEG, PNG) and the ideal reference. Fast methods report true when they run.
1332
1349
  if (top.fast !== undefined && typeof top.fast !== "boolean")
1333
1350
  fail("fast must be true or false.");
1334
- const fastTop = top.fast === true;
1351
+ const fastTop = resolveFast(top.fast, fastFormatDefault(format, displayView ? "display-view" : idealReference ? "ideal-reference" : "capture"));
1335
1352
  const fastInUse = [["lens.diffraction", diffractionRadiusOption !== undefined, diffractionFast],
1336
1353
  ["lens.catalogue", catalogue !== undefined, catalogueFast],
1337
1354
  ["sensor.antiAliasing", antiAliasing !== undefined, antiAliasingFast], ["sensor.pixelAperture.box", boxAperture, boxFast], ["output.demosaic.ahd", ahd, demosaicFast]];
@@ -1340,11 +1357,15 @@ export async function runCapture(options, run, viewOption, renderView) {
1340
1357
  if (used)
1341
1358
  fastApplied[option] = false;
1342
1359
  // #568: the catalogue's fast method, when requested (its record's flag, else the top-level one); whether it runs is decided with the RAW path.
1343
- const catalogueFastRequested = catalogue !== undefined && (catalogueFast ?? fastTop);
1360
+ const catalogueFastRequested = catalogue !== undefined && resolveFast(catalogueFast, fastTop);
1344
1361
  let fastReport = Object.freeze({
1345
1362
  requested: fastTop || [diffractionFast, catalogueFast, antiAliasingFast, boxFast, demosaicFast].some((value) => value === true),
1346
- applied: Object.freeze({ ...fastApplied }), assurance: "engine-reference"
1363
+ defaultedFrom: top.fast === undefined ? "format" : "request", applied: Object.freeze({ ...fastApplied }), assurance: "engine-reference"
1347
1364
  });
1365
+ const markFast = (option) => {
1366
+ fastApplied[option] = true;
1367
+ fastReport = Object.freeze({ ...fastReport, applied: Object.freeze({ ...fastApplied }), assurance: "approximation-with-a-priori-bound" });
1368
+ };
1348
1369
  if (borderOption !== undefined && borderOption !== "margin" && borderOption !== "mirror")
1349
1370
  fail("output.borderHandling must be margin or mirror.");
1350
1371
  const builtInDemosaic = demosaicOption === undefined ? undefined : createBuiltInBayerDemosaicProfiles({ kernel: "bilinear",
@@ -1358,22 +1379,12 @@ export async function runCapture(options, run, viewOption, renderView) {
1358
1379
  // Geometry: the demosaic border (if any) on each side; output crop is the active area.
1359
1380
  const nativeWidth = width + 2 * margin, nativeHeight = height + 2 * margin;
1360
1381
  // Lens corrections (#570): the catalogue lens's corrections on the developed linear colour; all off is no option at all.
1361
- let lensCorrection, lensCorrectionSelection;
1362
- if (outputOptions.lensCorrections !== undefined) {
1363
- const keys = ["distortion", "lateralColor", "vignetting"], o = allowKeys(outputOptions.lensCorrections, "output.lensCorrections", keys), selection = {};
1364
- for (const key of keys) {
1365
- if (o[key] !== undefined && typeof o[key] !== "boolean")
1366
- fail("output.lensCorrections." + key + " must be true or false.");
1367
- selection[key] = o[key] === true;
1368
- }
1369
- if (keys.some((key) => selection[key])) {
1370
- if (idealReference)
1371
- fail("simulateIdealReference does not take output.lensCorrections: a reference has no camera development.");
1372
- if (!catalogue || !lensKit)
1373
- fail("output.lensCorrections needs lens.catalogue: the corrections are the catalogue lens's own profile.");
1374
- lensCorrectionSelection = selection;
1375
- lensCorrection = lensKit.prepareDevelopedLensCorrection(catalogue, selection, width, height, pitch / 1000, lensKit.genericLensDispersion);
1376
- }
1382
+ let lensCorrection;
1383
+ const lensCorrectionSelection = correctionSelection(outputOptions.lensCorrections, "output.lensCorrections");
1384
+ if (lensCorrectionSelection) {
1385
+ if (idealReference)
1386
+ fail("simulateIdealReference does not take output.lensCorrections: a reference has no camera development.");
1387
+ lensCorrection = lensKit.prepareDevelopedLensCorrection(catalogue, lensCorrectionSelection, width, height, pitch / 1000, lensKit.genericLensDispersion);
1377
1388
  }
1378
1389
  // Region (2.3): a rectangle of the whole frame. Its native sites are the region plus the demosaic reach on each side
1379
1390
  // (clipped to the raster, where mirror borders apply exactly as in the whole render), with the origin rounded down to
@@ -1746,7 +1757,7 @@ export async function runCapture(options, run, viewOption, renderView) {
1746
1757
  // its distortion and vignetting profiles over the field the PSF and the queries reach, and its PSF profile.
1747
1758
  let catalogueTaps, catalogueDefocusApplied = false, catalogueKernelRadius = 0;
1748
1759
  if (catalogue && lensKit) {
1749
- const { GENERIC_LENS_EVIDENCE, genericLensDistortionProfile, genericLensVignettingProfile, resolveGenericLens, simulateGenericLensFocusError, createGenericLensPsfProfile, genericLensPsfKernelRadius, GENERIC_LENS_PSF_MAXIMUM_COMBINED_RADIUS_SITES } = lensKit;
1760
+ const { GENERIC_LENS_EVIDENCE, genericLensDistortionProfile, genericLensVignettingProfile, resolveGenericLens, simulateGenericLensFocusError, createGenericLensPsfProfile, genericLensPsfKernelRadius, GENERIC_LENS_PSF_MAXIMUM_COMBINED_RADIUS_SITES, genericLensBokehKernel } = lensKit;
1750
1761
  checkFNumber();
1751
1762
  if (lensOptions.focusScatter !== undefined) {
1752
1763
  const scatterOptions = allowKeys(lensOptions.focusScatter, "lens.focusScatter", ["seed", "shotIndex"]);
@@ -1768,16 +1779,25 @@ export async function runCapture(options, run, viewOption, renderView) {
1768
1779
  catch {
1769
1780
  fail("lens.defocus: " + defocusDiscLimitMessage(disc.diameterSites, disc.diameterMicrometers));
1770
1781
  }
1771
- const kernel = run === "ideal-reference" ? null : createDefocusDiscSiteKernel(disc.diameterSites);
1772
- if (kernel) {
1773
- extraKernel = { weights: kernel.weights, radius: kernel.radius };
1782
+ // #567: the lens's bokeh kernel (blades, its W040, a cat's eye) instead of the plain disc.
1783
+ // The cat's eye (its finer fast grid) is lens.catalogue's exact method; fast leaves it out.
1784
+ const catsEye = !catalogueFastRequested;
1785
+ if (run !== "ideal-reference" && disc.diameterSites > 0) {
1786
+ extraKernel = genericLensBokehKernel(character, disc.diameterSites, subjectDistanceM > focusDistanceM ? 1 : -1, pitch, catsEye);
1774
1787
  catalogueDefocusApplied = true;
1788
+ if (!catsEye && character.character.bokeh.catsEye) {
1789
+ markFast("lens.catalogue");
1790
+ assume("fast.lens.catalogue.catsEye", "model", { method: "bokeh-without-cats-eye" }, "Fast: the lens's bokeh leaves out its cat's eye (the pupil clipped by field position), " +
1791
+ "so the kernel is the axis kernel everywhere; the exact method (fast: false, the default for DNG, JPEG and PNG) shapes it at each field point.");
1792
+ }
1775
1793
  }
1794
+ const k = extraKernel?.axis;
1776
1795
  assume("lens.defocus", "model", { model: "geometric-disc", scientificStatus: "approximation", diameterMicrometers: disc.diameterMicrometers, diameterSites: disc.diameterSites,
1777
- kernelRadiusSites: disc.diameterSites === 0 ? null : discRadius, fNumber, focusDistanceM, subjectDistanceM, samplePitchMicrometers: pitch,
1778
- defocusCircleModel: { id: "thin-lens-defocus-circle", version: "1.1.0" }, execution: "catalogue-lens-psf" }, "Geometric defocus of the flat scene plane (an approximation): the thin-lens blur circle c = f²·|s − d| / (N·d·(s − f)) (calculateDefocusCircle 1.1.0) as a uniform disc sampled on the site lattice. " +
1796
+ kernelRadiusSites: disc.diameterSites === 0 ? null : k ? k.radius : discRadius, fNumber, focusDistanceM, subjectDistanceM, samplePitchMicrometers: pitch,
1797
+ defocusCircleModel: { id: "thin-lens-defocus-circle", version: "1.1.0" }, execution: "catalogue-lens-psf",
1798
+ ...(k ? { bokeh: { catsEye: catsEye && !!character.character.bokeh.catsEye, method: k.method, sphericalAberrationSites: k.sphericalAberrationSites, axisOpenAreaRatio: k.openAreaRatio } } : {}) }, "Geometric defocus of the flat scene plane (an approximation): the thin-lens blur circle c = f²·|s − d| / (N·d·(s − f)) (calculateDefocusCircle 1.1.0) as a uniform disc sampled on the site lattice. " +
1779
1799
  (disc.diameterSites === 0 ? "In focus: no disc is applied." : run === "ideal-reference" ? "Resolved but not applied: the ideal reference has no camera effects." :
1780
- "With the catalogue lens the disc is convolved into every node of the lens's PSF (a further approximation: the defocused aberrated wave PSF is not exactly the in-focus PSF convolved with the disc) and applied through the environment PSF quadrature."));
1800
+ "With the catalogue lens the disc is shaped by the lens's bokeh (#567, #553's kernel; see lens.catalogue.character) and convolved into every node of the lens's PSF (a further approximation: the defocused aberrated wave PSF is not exactly the in-focus PSF convolved with the disc) and applied through the environment PSF quadrature."));
1781
1801
  }
1782
1802
  // An upper bound of the PSF's radius (the built window may be narrower): the field reach below uses it.
1783
1803
  const kernelRadius = genericLensPsfKernelRadius({ lens: character, focusDistanceM, sitePitchMicrometers: pitch }) + (extraKernel ? extraKernel.radius : 0);
@@ -1821,23 +1841,32 @@ export async function runCapture(options, run, viewOption, renderView) {
1821
1841
  }
1822
1842
  assume("lens.catalogue.character", "model", { lensId: catalogue.lensId, fNumber, focusDistanceM, magnification: c.magnification, closeFocusGain: c.closeFocusGain, pupilScale: c.pupilScale,
1823
1843
  aberrationsWaves550: { ...c.aberrations }, lateralColor: { ...c.lateralColor }, distortionCorner: c.distortion.corner, vignettingCornerStops: c.vignetting.cornerStops,
1824
- notRendered: { strayLight: { ...c.strayLight }, breathing: { ...c.breathing }, focusScatterDepthOfFocusFraction: c.focusScatterDepthOfFocusFraction, bokeh: "disc (#553)" },
1844
+ bokeh: c.bokeh, notRendered: { strayLight: { ...c.strayLight }, breathing: { ...c.breathing }, focusScatterDepthOfFocusFraction: c.focusScatterDepthOfFocusFraction },
1825
1845
  psf: psfReport, profiles: { vignetting, distortion: distortion.profile, maximumNormalizedRadius: reachRadius } }, "The catalogue lens's character, a labelled approximation of a generic lens (docs/LENS_CATALOGUE.md): Seidel wave aberrations at the frame corner (spherical, balanced by defocus; coma; astigmatism; field curvature), " +
1826
1846
  "scaled from the widest aperture by the pupil ratio a = N0/N (a⁴, a³, a², a²) and by the close-focus factor; axial colour as a defocus and lateral colour as a radial shift, both with the normalised Cauchy dispersion. " +
1827
1847
  (run === "ideal-reference" ? "Not applied: the ideal reference has no camera effects. " :
1828
1848
  "Rendered as a sampled PSF (scalar Fraunhofer diffraction of the aberrated circular pupil, integrated over each site cell, on a 5 × 5 field grid × 5 wavelengths with centroid-aligned interpolation) " +
1829
1849
  "through the environment PSF quadrature (one scene query per kernel tap), with the lens's radial distortion (r_d = r·(1 + k1ρ²)) and relative illumination (E = (1 − bρ²)²) as lens field mapping and field throughput" +
1830
1850
  (catalogue.kind === "fisheye" ? ", and the equisolid projection; directions at or beyond 90° see no scene (zero radiance), so a circular fisheye's image ends at its 180° circle" : "") + ". ") +
1831
- "Flare, focus breathing and bokeh shape are reported, not rendered; ρ is normalised to the frame's half-diagonal, so the lens is scaled to the simulated frame like lens.prime.");
1851
+ "Flare and focus breathing are reported, not rendered; the bokeh shapes lens.defocus; ρ is normalised to the frame's half-diagonal, so the lens is scaled to the simulated frame like lens.prime.");
1832
1852
  }
1833
1853
  // #492: the capture applies the disc as an object-space convolution, which needs every disc point a site sees to be another site's scene point.
1834
1854
  // #548: a lens field mapping, vignetting or camera rotation breaks that, so the capture then applies the disc in image space, on the sensor's
1835
1855
  // site lattice (defocus-image-space-provider.ts). #563: the display view reads the same geometry in any focus (display-view-lens-signal.ts).
1836
1856
  const imageSpaceOptions = [...(lensFieldMapping ? [lensOptions.distortion !== undefined ? (lensOptions.projection !== undefined ? "lens.projection, lens.distortion" : "lens.distortion") : "lens.projection"] : []),
1837
1857
  ...(vignetting ? ["lens.vignetting"] : []), ...(rotating ? ["camera rotation"] : [])];
1838
- const lensGeometry = (defocusModel !== undefined || displayView) && imageSpaceOptions.length > 0 ? prepareDefocusImageSpaceGeometry({ focalLengthMm, focusDistanceM,
1858
+ const lensGeometry = (defocusModel !== undefined || displayView || diffractionRadiusOption !== undefined) && imageSpaceOptions.length > 0 ? prepareDefocusImageSpaceGeometry({ focalLengthMm, focusDistanceM,
1839
1859
  angularVelocityRadPerSec: angular, ...(lensFieldMapping ? { lensMapping: prepareEnvironmentLensFieldMapping(lensFieldMapping) } : {}), ...(vignetting ? { vignetting } : {}) }) : undefined;
1840
1860
  const defocusGeometry = defocusModel !== undefined ? lensGeometry : undefined;
1861
+ // #565: the in-focus PSF quadrature reads each tap's source on this lattice; with rotation under a rolling shutter (each row has its own
1862
+ // instants, so a band per instant, about 1.2× the time) that is lens.diffraction's exact method, and each tap's own point its fast one.
1863
+ const rollingFastTaps = !displayView && rotating && shutterKind === "rolling" && diffractionRadiusOption !== undefined && lensGeometry !== undefined && resolveFast(diffractionFast, fastTop);
1864
+ if (rollingFastTaps) {
1865
+ markFast("lens.diffraction");
1866
+ assume("fast.lens.diffraction", "model", { method: "per-tap-source-points" }, "Fast: under rolling-shutter rotation each PSF tap reads the scene at its own source point, " +
1867
+ "not at its site-lattice point, so a tap exactly on a chart boundary can resolve to the other material (#565); elsewhere the values agree to rounding. The exact method is fast: false.");
1868
+ }
1869
+ const latticeTaps = lensGeometry && !rollingFastTaps;
1841
1870
  // #553: the shaped kernel (on axis) and, with a cat's eye, its field zones; built when the disc is resolved out of focus.
1842
1871
  let bokehAxisKernel, bokehZones;
1843
1872
  const bokehSummary = defocusBokeh === undefined ? undefined : (disc) => {
@@ -1912,18 +1941,23 @@ export async function runCapture(options, run, viewOption, renderView) {
1912
1941
  // (EMVA 1288 Release 4.0, Eq. 3). The tier's signal chain is anchored to the 6 µm aperture; at another area the
1913
1942
  // charge-domain readout scales with the area so the same H gives the same code. At 6 µm nothing is changed.
1914
1943
  const areaScale = pitch * pitch / ISO_REFERENCE_APERTURE_AREA_SQUARE_MICROMETERS;
1944
+ // #555: a full well replaces the area-scaled base-ISO saturation; the charge-domain transfer scales by the same rule.
1945
+ const fullWell = characteristics?.fullWell, fullWellElectrons = fullWell && (fullWell.link === "none" ? fullWell.electrons : fullWell.electrons * (pitch / (fullWell.referencePixelPitchMicrometers ?? 6)) ** 2);
1946
+ if (fullWellElectrons !== undefined && !(fullWellElectrons >= 100 && fullWellElectrons <= 1e10))
1947
+ fail("sensor.fullWell must give 100 to 1e10 electrons at the pixel pitch (" + fullWellElectrons + ").");
1948
+ const chargeScale = fullWellElectrons === undefined ? areaScale : fullWellElectrons / body.readout[0].profile.regimes[0].preAdcSaturationElectronEquivalent.value;
1915
1949
  const regimeByChannel = {};
1916
1950
  for (const channel of CHANNELS) {
1917
1951
  const asset = body.readout.find((a) => a.profile.channelId === channel) ?? fail("Camera readout lacks channel " + channel + ".");
1918
1952
  const chain = resolveGenericIsoSignalChain({ profile: body.signalChain, isoCapability: body.iso, requestedIsoSetting: { kind: "standard", exposureIndex: resolvedIso },
1919
1953
  captureModeId: IDS.mode, readoutProfile: asset.profile });
1920
- regimeByChannel[channel] = { readoutProfile: areaScale === 1 ? asset.profile : isoAreaScaledReadout(asset.profile, areaScale), regimeId: chain.readout.regime.regimeId };
1954
+ regimeByChannel[channel] = { readoutProfile: characterizeReadout(asset.profile, chargeScale, characteristics), regimeId: chain.readout.regime.regimeId };
1921
1955
  }
1922
- const capacityRegimes = (areaScale === 1 ? body.readout[0].profile : isoAreaScaledReadout(body.readout[0].profile, areaScale)).regimes;
1956
+ const capacityRegimes = characterizeReadout(body.readout[0].profile, chargeScale, characteristics).regimes;
1923
1957
  const baseRegime = capacityRegimes[0];
1924
1958
  const capacityElectrons = Math.max(...capacityRegimes.map((r) => r.preAdcSaturationElectronEquivalent.value), baseRegime.preAdcSaturationElectronEquivalent.value);
1925
1959
  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.");
1926
- if (areaScale !== 1) {
1960
+ if (areaScale !== 1 && !fullWell) {
1927
1961
  assume("sensor.isoAreaNormalization", "model", { referenceApertureAreaSquareMicrometers: ISO_REFERENCE_APERTURE_AREA_SQUARE_MICROMETERS,
1928
1962
  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, " +
1929
1963
  "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 " +
@@ -1932,6 +1966,15 @@ export async function runCapture(options, run, viewOption, renderView) {
1932
1966
  "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 " +
1933
1967
  "proportionally with area across technologies).");
1934
1968
  }
1969
+ if (characteristics) {
1970
+ const density = capacityElectrons / (pitch * pitch);
1971
+ assume("sensor.characteristics", "model", { ...Object.fromEntries(SENSOR_CHARACTERISTIC_KEYS.filter((k) => sensorOptions[k] !== undefined).map((k) => [k, sensorOptions[k]])),
1972
+ chargeScale, sensitivityOffsetStops: Math.log2(characteristics.qeScale * areaScale / chargeScale) }, "Approximation (#555), not a measured sensor: these replace the body's values. The full well is the base-ISO charge at the RAW white level (EMVA 1288 Release 4.0 saturation capacity); " +
1973
+ "every ISO regime's conversion gain and pre-ADC saturation are multiplied by chargeScale. Each ISO keeps its regime, so the output per unit exposure changes by 2^sensitivityOffsetStops: " +
1974
+ "the rating S = 10/H (CIPA DC-004-2020 §3.2) holds only if peak QE × pixel area / full well is the body's. Read noise replaces each regime's; the high gain's smaller storage is not modelled. " +
1975
+ "A bit depth scales the black and saturation codes by 2^Δbits." + (density < 200 || density > 1e4 ? " Unusual for the pitch: " + density +
1976
+ " e⁻/µm², outside 200–10 000 (a design range around the tiers' 556–1 667, not a published bound)." : ""));
1977
+ }
1935
1978
  // Whole-frame views (2.5): a pitch above 100 µm is larger than any camera photosite, so the capture is a downsized view of a frame width × pitch wide.
1936
1979
  if (pitch > SIMULATE_CAPTURE_PHOTOSITE_PITCH_MAXIMUM_MICROMETERS) {
1937
1980
  assume("sensor.wholeFrameView", "model", { pixelPitchMicrometers: pitch, frameWidthMm: width * pitch / 1000, frameHeightMm: height * pitch / 1000 }, "A pixel pitch above 100 µm is larger than any camera photosite, so each output pixel stands for a " + pitch + " µm square of a " + width * pitch / 1000 + " × " + height * pitch / 1000 +
@@ -1965,7 +2008,10 @@ export async function runCapture(options, run, viewOption, renderView) {
1965
2008
  // Shutter (2.0): the electronic rolling-shutter schedule derived from the body's rolling readout declaration.
1966
2009
  let rollingSchedule;
1967
2010
  if (shutterKind === "rolling") {
1968
- const declared = body.rollingReadoutTiming?.profile;
2011
+ const bodyReadout = body.rollingReadoutTiming?.profile;
2012
+ // #555: sensor.readoutSeconds replaces the body's readout time and skew.
2013
+ const seconds = characteristics?.readoutSeconds, declared = bodyReadout && seconds ? { ...bodyReadout, captureReadoutDurationSeconds: { ...bodyReadout.captureReadoutDurationSeconds, value: seconds },
2014
+ spatialSamplingSkewSeconds: { ...bodyReadout.spatialSamplingSkewSeconds, value: seconds } } : bodyReadout;
1969
2015
  if (!declared || declared.readoutMode !== "rolling")
1970
2016
  fail("motion.shutter \"rolling\" needs a camera body with a rolling readout timing declaration.");
1971
2017
  const readout = rollingSkewOption === undefined ? declared : { ...declared, spatialSamplingSkewSeconds: { value: rollingSkewOption, unit: "s", evidence: FACADE_EVIDENCE } };
@@ -2074,7 +2120,7 @@ export async function runCapture(options, run, viewOption, renderView) {
2074
2120
  const eqeSamples = (channel) => {
2075
2121
  const out = [];
2076
2122
  for (let nm = range.minimum; nm <= range.maximum + 1e-9; nm += eqeSpacing)
2077
- out.push({ nm, value: EQE_SCALE[channel] * estimateCie1931ColorMatchingFunctions(nm)[CMF_KEY[channel]] * 555 / nm });
2123
+ out.push({ nm, value: EQE_SCALE[channel] * estimateCie1931ColorMatchingFunctions(nm)[CMF_KEY[channel]] * 555 / nm * (characteristics?.qeScale ?? 1) });
2078
2124
  return out;
2079
2125
  };
2080
2126
  const operatingConditions = { temperatureC: 25, incidenceAngleDegreesFromNormal: 0, polarization: "unpolarized" };
@@ -2138,9 +2184,7 @@ export async function runCapture(options, run, viewOption, renderView) {
2138
2184
  samples: { value: [{ wavelengthNanometers: range.minimum, linearTransmissionFactor: lens.matchedReference.transmission },
2139
2185
  { wavelengthNanometers: range.maximum, linearTransmissionFactor: lens.matchedReference.transmission }], evidence: preset.evidence },
2140
2186
  uncertainty: { kind: "not-quantified", limitation: "Synthetic spectrally flat transmission from the tier preset." } },
2141
- distortionAreaMappingOwnership: "not-applied-by-bridge", psfRedistributionOwnership: "downstream-normalized-energy-redistribution",
2142
- sensorOpticalStackIncluded: false, strayLightIncluded: false, polarizationModeled: false, wavelengthChangingBehaviorModeled: false, volumetricScatteringModeled: false,
2143
- evidence: preset.evidence, limitations: [...lens.limitations, "Flat transmission applied across the lens capability by the capture facade."] };
2187
+ ...SCENE_TO_SENSOR_BRIDGE_SCOPE, evidence: preset.evidence, limitations: [...lens.limitations, "Flat transmission applied across the lens capability by the capture facade."] };
2144
2188
  const optics = { profile: opticsProfile, focalLengthMm, nominalFNumber: fNumber,
2145
2189
  focus: { kind: "ideal-symmetric-thin-lens", objectDistanceM: focusDistanceM, pupilMagnificationAssumption: "unity" } };
2146
2190
  const motion = { angularVelocityRadPerSec: angular, timeReference: "first-opening-boundary-phase", environmentDirectionConvention: "outgoing-radiance-toward-camera" };
@@ -2185,7 +2229,7 @@ export async function runCapture(options, run, viewOption, renderView) {
2185
2229
  (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). " :
2186
2230
  defocusImageSpace ? "Applied with lens.defocus as one image-space kernel per spectral node (the diffraction kernel convolved with the disc) on the sensor's site lattice (#548; see the lens.defocus assumption), " +
2187
2231
  "the same sum as the environment PSF quadrature with that kernel. " :
2188
- "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. ") +
2232
+ "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" + (latticeTaps ? "; each tap reads the scene at its lattice point (#565). " : ". ")) +
2189
2233
  "No defocus, aberration or pupil clipping; " + (boxAperture ? "the pixel aperture is integrated as declared by sensor.pixelAperture." : "the sensor pixel aperture is still point-sampled at the site centre."));
2190
2234
  }
2191
2235
  // #492: each spectral node's kernel for lens.defocus out of focus: the disc, or with lens.diffraction the node's diffraction kernel convolved
@@ -2258,6 +2302,10 @@ export async function runCapture(options, run, viewOption, renderView) {
2258
2302
  limitation: "Ideal uniform circular pupil; finite ray sampling, no convergence claim." } : undefined;
2259
2303
  const chargeSamplingProfile = { schemaVersion: "0.1.0", profileId: IDS.chargeSampling, completenessProfileId: IDS.completeness, scientificStatus: "approximation",
2260
2304
  photoShotNoiseModel: "poisson", darkShotNoiseModel: "poisson", additionalComponentPolicies: [], evidence, limitations: ["Poisson photo and dark shot noise."] };
2305
+ // #555: the body's fixed-pattern noise profile, opt-in; the display view's expected-value estimate has no fixed pattern.
2306
+ const fixedPattern = characteristics?.fixedPatternNoise ? body.fixedPatternNoise.profile : undefined;
2307
+ if (fixedPattern && displayView)
2308
+ fail("simulateDisplayView does not render sensor.fixedPatternNoise; use simulateCapture.");
2261
2309
  const channelAt = (x, y) => ["red", "green", "green", "blue"][(y % 2) * 2 + (x % 2)];
2262
2310
  function siteInput(index) {
2263
2311
  if (!Number.isSafeInteger(index) || index < 0 || index >= nativeWidth * nativeHeight)
@@ -2285,7 +2333,7 @@ export async function runCapture(options, run, viewOption, renderView) {
2285
2333
  capacityMeaning: "physical-charge-storage-capacity-electrons", capacityElectrons, scientificStatus: "approximation",
2286
2334
  uncertainty: { kind: "not-quantified", limitation: "Derived from the tier's declared saturation signal." }, evidence, siteApplicability: { kind: "exact-site", site },
2287
2335
  operatingState: { stateId: IDS.operatingState, evidence }, temperatureApplicability: { kind: "exact-reference-temperature", temperatureC: OPERATING_TEMPERATURE_C } },
2288
- operatingStateId: IDS.operatingState, readoutProfile: r.readoutProfile, regimeId: r.regimeId } };
2336
+ operatingStateId: IDS.operatingState, readoutProfile: r.readoutProfile, regimeId: r.regimeId, ...(fixedPattern ? { fixedPatternNoise: fixedPattern } : {}) } };
2289
2337
  }
2290
2338
  const readTile = async (request) => ({ ...request, sites: Array.from({ length: request.width }, (_, j) => siteInput(request.y * nativeWidth + request.x + j)) });
2291
2339
  const yieldControl = createHostYield().yieldControl;
@@ -2302,14 +2350,19 @@ export async function runCapture(options, run, viewOption, renderView) {
2302
2350
  wavelengthsNm: defocusSiteKernels.wavelengthsNm, kernelRadius: defocusSiteKernels.radius, nativeWidth, nativeHeight, outputWidth: width,
2303
2351
  sitePitchMicrometers: pitch, distanceM: subjectDistanceM, metresPerFrameUnit, onConvolution: (ms) => { defocusMs += ms; },
2304
2352
  ...(window ? { window } : {}), ...(bokehZones ? { fieldZones: bokehZones.zones } : {}),
2305
- ...(antiAliasing || boxSamples > 1 ? { queryOffsetsSites: apertureNodeOffsets(boxSamples).flatMap((node) => (antiAliasing ? antiAliasing.components : [{ offsetMicrometers: { x: 0, y: 0 } }]).map((c) => ({ x: node.x - c.offsetMicrometers.x / pitch, y: node.y - c.offsetMicrometers.y / pitch }))) } : {}) }) :
2353
+ ...(antiAliasing || boxSamples > 1 ? { queryOffsetsSites } : {}) }) :
2306
2354
  diffraction && diffractionObjectSpace ? createDiffractedPlanarProvider({ base: provider, psf: diffractionPsf.configuration.psf,
2307
2355
  wavelengthsNm: diffraction.profile.axes.wavelengthNm, kernelRadius: diffraction.kernelRadiusSamples, nativeWidth, nativeHeight, outputWidth: width,
2308
2356
  sitePitchMicrometers: pitch, distanceM: subjectDistanceM, metresPerFrameUnit, onConvolution: (ms) => { diffractionMs += ms; },
2309
2357
  // Region (2.6): convolve only the lattice the region's native sites query; the values are the whole render's.
2310
2358
  ...(window ? { window } : {}),
2311
2359
  // The environment queries each site at each aperture node minus each split offset (sites, +x right, +y down).
2312
- ...(antiAliasing || boxSamples > 1 ? { queryOffsetsSites: apertureNodeOffsets(boxSamples).flatMap((node) => (antiAliasing ? antiAliasing.components : [{ offsetMicrometers: { x: 0, y: 0 } }]).map((c) => ({ x: node.x - c.offsetMicrometers.x / pitch, y: node.y - c.offsetMicrometers.y / pitch }))) } : {}) }) : provider;
2360
+ ...(antiAliasing || boxSamples > 1 ? { queryOffsetsSites } : {}) }) :
2361
+ // #565: the PSF quadrature reads each tap's source at its lattice point (a unit kernel on #548's lattice), so ties resolve as on the lattice.
2362
+ diffraction && latticeTaps ? createDefocusImageSpaceProvider({ base: provider, geometry: lensGeometry, siteKernels: () => UNIT_SITE_KERNEL,
2363
+ kernelRadius: 0, wavelengthsNm: diffraction.profile.axes.wavelengthNm, nativeWidth, nativeHeight, sitePitchMicrometers: pitch, distanceM: subjectDistanceM, metresPerFrameUnit,
2364
+ queryOffsetsSites: [-1, 0, 1].flatMap((k) => queryOffsetsSites.map((o) => ({ x: o.x + k * diffraction.kernelRadiusSamples, y: o.y + k * diffraction.kernelRadiusSamples }))),
2365
+ timesPerBand: temporalSampleCount, ...(window ? { window } : {}) }) : provider;
2313
2366
  // Built on first use: a capture that takes the tabulated path (#394 step 2) never queries it, and its kernels cost a setup.
2314
2367
  let rawProviderBuilt;
2315
2368
  const rawProvider = () => (rawProviderBuilt ??= diffractedProvider(regionNative));
@@ -2387,16 +2440,30 @@ export async function runCapture(options, run, viewOption, renderView) {
2387
2440
  // site centre plus the kernel radius and the largest query offset.
2388
2441
  // #569: a catalogue lens's field-dependent PSF, read per field zone by the lens signal (display-view-lens-signal.ts, loaded on demand).
2389
2442
  const viewLensKit = displayView && lensGeometry ? await import("./display-view-lens-signal.js") : undefined;
2443
+ // 2.20.0: the field zones are lens.catalogue's fast method in the view; exact resolves the PSF at every site (zones of one site).
2390
2444
  const displayViewLensPsf = viewLensKit && catalogue && lensPsf ?
2391
- viewLensKit.createDisplayViewLensPsf(lensPsf.configuration.psf, catalogueKernelRadius, nativeWidth, nativeHeight, pitch) : undefined;
2445
+ viewLensKit.createDisplayViewLensPsf(lensPsf.configuration.psf, catalogueKernelRadius, nativeWidth, nativeHeight, pitch, catalogueFastRequested ? undefined : Math.max(nativeWidth, nativeHeight)) : undefined;
2446
+ if (displayViewLensPsf && displayViewLensPsf.zoneSites > 1)
2447
+ markFast("lens.catalogue");
2448
+ const viewAhdFast = ahd && resolveFast(demosaicFast, fastTop);
2449
+ if (displayView && viewAhdFast)
2450
+ markFast("output.demosaic.ahd");
2392
2451
  if (displayViewLensPsf)
2393
2452
  assume("view.lensPsf", "model", { method: "piecewise-constant-field-zones", zoneSites: displayViewLensPsf.zoneSites, kernelRadiusSites: catalogueKernelRadius, scientificStatus: "approximation" }, "Each site takes the lens PSF at its field zone's centre, where the capture resolves it at each query point (docs/DISPLAY_VIEW.md, \"Catalogue lenses\").");
2394
2453
  const lensSignal = viewLensKit ? viewLensKit.prepareDisplayViewLensSignal({ geometry: lensGeometry,
2395
2454
  times: rotating ? Array.from({ length: temporalSampleCount }, (_, i) => (i + 0.5) * (shutterSeconds / temporalSampleCount)) : [0], distanceM: subjectDistanceM, metresPerFrameUnit,
2396
2455
  pitch, imagingArea, boxSamples, spots: antiAliasing ? antiAliasing.components : [{ offsetMicrometers: { x: 0, y: 0 } }], psf: displayViewLensPsf, zeroBeyondHorizon: catalogue?.kind === "fisheye" }, nativeWidth, nativeHeight, (diffraction ? diffraction.kernelRadiusSamples : 0) + (displayViewDefocus ? (bokehAxisKernel?.radius ?? defocusDiscKernelRadius(displayViewDefocus.diameterSites)) : 0) + catalogueKernelRadius +
2397
2456
  Math.max(0, ...queryOffsetsSites.map((o) => Math.max(Math.abs(o.x), Math.abs(o.y)))) - 0.5, catalogue ? "lens.catalogue" : imageSpaceOptions.join(", ")) : undefined;
2457
+ // #535: the green channel's expected charge from an 18 % Lambertian grey patch (radiance 0.18·E·S(λ)/π) next to the axis, and the sensor report;
2458
+ // the mid-grey figure only when the request resolves to exact (fast: false, the exports' default): it costs about 0.3 ms.
2459
+ let greyElectrons = fastTop ? undefined : 0;
2460
+ if (!idealReference && greyElectrons !== undefined)
2461
+ for (const [nm, a] of axisChannelResponse(displayView ? "simulateDisplayView" : "simulateCapture").electronsPerRadiance.green)
2462
+ greyElectrons += a * GREY_CARD_REFLECTANCE * irradianceScale * illuminant.shape.evaluate(nm) / Math.PI;
2463
+ const sensor = sensorReport(regimeByChannel.green.readoutProfile, regimeByChannel.green.regimeId, capacityElectrons, greyElectrons, DARK_CURRENT_ELECTRONS_PER_SECOND * shutterSeconds, { pixelPitchMicrometers: pitch, peakQuantumEfficiency: 0.5 * (characteristics?.qeScale ?? 1),
2464
+ readoutSeconds: characteristics?.readoutSeconds ?? body.rollingReadoutTiming?.profile.spatialSamplingSkewSeconds?.value ?? null }, fixedPattern);
2398
2465
  if (displayView)
2399
- return await renderView({ lens: lensSignal, lensPsf: displayViewLensPsf, ahd, fastReport, antiAliasing, aspect, assume, assumptions, axisChannelResponse, boxSamples, buildProvider, callerScene, chart, materialLayers, chartReport, chartSize, diffraction, defocus: displayViewDefocus, diffractionPsf, fNumber, focalLengthMm, focusDistanceM, height, highlightMode, exposureGain, illuminanceLux, illuminant, illuminantOption, imagingArea, intentOf, metresPerFrameUnit, noiseSeed, outputOptions, parsedView, pitch, preset, provider, regimeByChannel, resolvedIso, isoReport, standardExposureDeviationStops: exposureDeviationStops, standardExposureIlluminanceLux: exposureIlluminanceLux, greyCardDeviationStops: exposureGreyCardDeviationStops, metering, sceneOption, scenePreset, shutterSeconds, started, subjectDistanceM, toneCurve, whiteBalanceOption, whiteBalanceState, width, checkAbort, margin, mode, nativeHeight, nativeWidth, sourceSha256, yieldControl, kelvinWhiteBalanceInput });
2466
+ return await renderView({ sensor, viewFast: { ahd: viewAhdFast, uniformSampling: fastTop }, lens: lensSignal, lensPsf: displayViewLensPsf, ahd, fastReport, antiAliasing, aspect, assume, assumptions, axisChannelResponse, boxSamples, buildProvider, callerScene, chart, materialLayers, chartReport, chartSize, diffraction, defocus: displayViewDefocus, diffractionPsf, fNumber, focalLengthMm, focusDistanceM, height, highlightMode, exposureGain, illuminanceLux, illuminant, illuminantOption, imagingArea, intentOf, metresPerFrameUnit, noiseSeed, outputOptions, parsedView, pitch, preset, provider, regimeByChannel, resolvedIso, isoReport, standardExposureDeviationStops: exposureDeviationStops, standardExposureIlluminanceLux: exposureIlluminanceLux, greyCardDeviationStops: exposureGreyCardDeviationStops, metering, sceneOption, scenePreset, shutterSeconds, started, subjectDistanceM, toneCurve, whiteBalanceOption, whiteBalanceState, width, checkAbort, margin, mode, nativeHeight, nativeWidth, sourceSha256, yieldControl, kelvinWhiteBalanceInput });
2400
2467
  // Ideal reference (2.3): the scene without camera effects, at the capture's own exposure, white balance and encoding.
2401
2468
  if (idealReference) {
2402
2469
  const referenceStarted = clockMs();
@@ -2577,14 +2644,13 @@ export async function runCapture(options, run, viewOption, renderView) {
2577
2644
  }
2578
2645
  let rawOutput;
2579
2646
  if (dngParts !== undefined) {
2580
- rawOutput = { raw: joinNativeRawParts(dngParts, plan, nativeWidth, nativeHeight, partsCaller), providerEvaluationCount: 0 };
2647
+ rawOutput = { raw: joinNativeRawParts(dngParts, plan, nativeWidth, nativeHeight, partsCaller, fastReport.requested), providerEvaluationCount: 0 };
2581
2648
  if (run === "develop-from-parts")
2582
2649
  assume("parts", "model", { partCount: dngParts.length }, "The whole render's RAW, joined from region renders of this request (#571); how they were rendered is in their results.");
2583
2650
  }
2584
2651
  else if (tabulated && catalogueFastRun) {
2585
2652
  rawOutput = { raw: tabulated.raw, providerEvaluationCount: tabulated.providerEvaluationCount };
2586
- fastApplied["lens.catalogue"] = true;
2587
- fastReport = Object.freeze({ ...fastReport, applied: Object.freeze({ ...fastApplied }), assurance: "approximation-with-a-priori-bound" });
2653
+ markFast("lens.catalogue");
2588
2654
  for (const a of lensKit.catalogueFastAssumptions(catalogueFastRun))
2589
2655
  assume(a.id, "model", a.value, a.explanation);
2590
2656
  }
@@ -2668,9 +2734,11 @@ export async function runCapture(options, run, viewOption, renderView) {
2668
2734
  return [channel, { conversionGainElectronsPerCode: resolved.regime.systemConversionGainElectronsPerCode.value / (resolved.regime.adc.digitalGain ?? 1),
2669
2735
  readNoiseRmsElectrons: resolved.combinedReadNoiseRmsElectrons }];
2670
2736
  }));
2671
- const dngStarted = clockMs();
2737
+ const dngStarted = clockMs(), opcodes = dngOpcodeSelection && lensKit.createDngLensCorrectionOpcodes(lensKit.resolveGenericLens({ lensId: catalogue.lensId, activeImagingArea: catalogueFrame, fNumber }), dngOpcodeSelection, width, height, pitch / 1000, lensKit.genericLensDispersion);
2672
2738
  dng = await serializeNativeRawDng({ raw: rawOutput.raw, colorProfile, noise, ...(dngCapturedAtUtc === undefined ? {} : { capturedAtUtc: dngCapturedAtUtc }),
2673
- ...(textMetadata ? { textMetadata } : {}) });
2739
+ ...(textMetadata ? { textMetadata } : {}), ...(opcodes ? { opcodeList3: opcodes.opcodeList3 } : {}) });
2740
+ if (opcodes)
2741
+ assume("output.dng.lensCorrectionOpcodes", "model", { tag: 51022, opcodes: opcodes.opcodes }, "output.lensCorrections' profile as optional DNG opcodes, in order (docs/NATIVE_RAW_AND_PRINT.md, \"Lens-correction opcodes\"); the RAW is unchanged.");
2674
2742
  encodeMs = clockMs() - dngStarted;
2675
2743
  assumeTextMetadata(TEXT_METADATA_LAYOUT.dng);
2676
2744
  checkAbort();
@@ -2880,6 +2948,7 @@ export async function runCapture(options, run, viewOption, renderView) {
2880
2948
  request: { environmentRaw: finalRawInput, siteInput, development, whiteBalanceState,
2881
2949
  ...(diffraction ? { diffraction: { profile: diffraction, psf: diffractionPsf, execution: diffractionObjectSpace ? "object-space-convolution" :
2882
2950
  defocusImageSpace ? "image-space-convolution" : "environment-psf-quadrature" } } : {}) },
2951
+ sensor,
2883
2952
  exposure: { mode, settings: { fNumber, shutterSeconds, iso: resolvedIso }, ev100: Math.log2(fNumber ** 2 / shutterSeconds), ...isoReport,
2884
2953
  metering, meteredSceneLuminanceCdPerSquareMeter: meteredLuminance, standardExposureDeviationStops: exposureDeviationStops,
2885
2954
  standardExposureIlluminanceLux: exposureIlluminanceLux, greyCardDeviationStops: exposureGreyCardDeviationStops, resolution },