@photivra/engine 2.14.1 → 2.16.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 (80) hide show
  1. package/CHANGELOG.md +69 -1
  2. package/CITATION.cff +1 -1
  3. package/README.md +6 -6
  4. package/SECURITY.md +1 -1
  5. package/THIRD_PARTY.md +14 -0
  6. package/dist/capture/native-environment-lean.js +1 -1
  7. package/dist/capture/native-environment-lean.js.map +1 -1
  8. package/dist/capture/native-raw-development.d.ts +15 -0
  9. package/dist/capture/native-raw-development.js +12 -6
  10. package/dist/capture/native-raw-development.js.map +1 -1
  11. package/dist/capture/raw-development-kernel.d.ts +10 -3
  12. package/dist/capture/raw-development-kernel.js +25 -6
  13. package/dist/capture/raw-development-kernel.js.map +1 -1
  14. package/dist/core/internal-radiance-evaluator.d.ts +5 -2
  15. package/dist/core/internal-radiance-evaluator.js.map +1 -1
  16. package/dist/core/version.d.ts +1 -1
  17. package/dist/core/version.js +1 -1
  18. package/dist/core/version.js.map +1 -1
  19. package/dist/facade/defocus-image-space-provider.d.ts +109 -0
  20. package/dist/facade/defocus-image-space-provider.js +359 -0
  21. package/dist/facade/defocus-image-space-provider.js.map +1 -0
  22. package/dist/facade/diffraction-provider.d.ts +16 -0
  23. package/dist/facade/diffraction-provider.js +3 -3
  24. package/dist/facade/diffraction-provider.js.map +1 -1
  25. package/dist/facade/display-view.d.ts +21 -0
  26. package/dist/facade/display-view.js +66 -8
  27. package/dist/facade/display-view.js.map +1 -1
  28. package/dist/facade/simulate-capture.d.ts +112 -3
  29. package/dist/facade/simulate-capture.js +235 -48
  30. package/dist/facade/simulate-capture.js.map +1 -1
  31. package/dist/facade/simulate-display-view.js +7 -3
  32. package/dist/facade/simulate-display-view.js.map +1 -1
  33. package/dist/gpu/display-view-quality-gpu.d.ts +2 -0
  34. package/dist/gpu/display-view-quality-gpu.js +57 -13
  35. package/dist/gpu/display-view-quality-gpu.js.map +1 -1
  36. package/dist/gpu/display-view-speed-gpu.d.ts +2 -0
  37. package/dist/gpu/display-view-speed-gpu.js +45 -16
  38. package/dist/gpu/display-view-speed-gpu.js.map +1 -1
  39. package/dist/gpu/image-kernel-accelerator.js +3 -0
  40. package/dist/gpu/image-kernel-accelerator.js.map +1 -1
  41. package/dist/gpu/wgsl-display-view-quality-kernel.d.ts +4 -0
  42. package/dist/gpu/wgsl-display-view-quality-kernel.js +53 -2
  43. package/dist/gpu/wgsl-display-view-quality-kernel.js.map +1 -1
  44. package/dist/gpu/wgsl-display-view-speed-kernel.d.ts +15 -0
  45. package/dist/gpu/wgsl-display-view-speed-kernel.js +42 -1
  46. package/dist/gpu/wgsl-display-view-speed-kernel.js.map +1 -1
  47. package/dist/index.d.ts +3 -2
  48. package/dist/index.js +2 -1
  49. package/dist/index.js.map +1 -1
  50. package/dist/optics/defocus-disc-kernel.d.ts +91 -1
  51. package/dist/optics/defocus-disc-kernel.js +59 -5
  52. package/dist/optics/defocus-disc-kernel.js.map +1 -1
  53. package/dist/optics/diffraction-psf-profile.d.ts +9 -4
  54. package/dist/optics/diffraction-psf-profile.js +14 -6
  55. package/dist/optics/diffraction-psf-profile.js.map +1 -1
  56. package/dist/optics/lens-psf-kernels.d.ts +13 -2
  57. package/dist/optics/lens-psf-kernels.js +23 -6
  58. package/dist/optics/lens-psf-kernels.js.map +1 -1
  59. package/dist/optics/sensor-environment-query.d.ts +31 -0
  60. package/dist/optics/sensor-environment-query.js +45 -13
  61. package/dist/optics/sensor-environment-query.js.map +1 -1
  62. package/dist/optics/site-diffraction-kernel.d.ts +1 -1
  63. package/dist/optics/site-diffraction-kernel.js +37 -17
  64. package/dist/optics/site-diffraction-kernel.js.map +1 -1
  65. package/dist/processing/development-detail.d.ts +132 -0
  66. package/dist/processing/development-detail.js +325 -0
  67. package/dist/processing/development-detail.js.map +1 -0
  68. package/docs/AGENT_GUIDE.md +1 -0
  69. package/docs/API_REFERENCE.md +17 -1
  70. package/docs/QUICK_START.md +210 -16
  71. package/docs/USAGE.md +31 -3
  72. package/docs/api/capture-native-raw-development.md +16 -0
  73. package/docs/api/core-version.md +1 -1
  74. package/docs/api/exports.json +47 -2
  75. package/docs/api/facade-simulate-capture.md +129 -4
  76. package/docs/api/optics-defocus-disc-kernel.md +87 -0
  77. package/docs/api/optics-diffraction-psf-profile.md +6 -3
  78. package/docs/api/optics-lens-psf-kernels.md +3 -2
  79. package/llms.txt +1 -0
  80. package/package.json +1 -1
@@ -53,7 +53,7 @@ import { renderTabulatedNativeRaw, TABULATED_RELATIVE_TOLERANCE } from "../captu
53
53
  import { createDisplayViewExpectedSignal } from "./expected-signal.js";
54
54
  import { buildExpectedSignalInput } from "./expected-signal-setup.js";
55
55
  import { createNativeEnvironmentRawTask, createNativeEnvironmentRawRegionTask } from "../capture/native-environment-raw.js";
56
- import { createNativeRawDevelopmentTask } from "../capture/native-raw-development.js";
56
+ import { createNativeRawDevelopmentTask, NATIVE_RAW_DEVELOPMENT_EXPOSURE_RANGE_STOPS } from "../capture/native-raw-development.js";
57
57
  import { parseSdrRenderingProfile } from "../output/sdr-rendering.js";
58
58
  import { createPrintJpegTask, PRINT_JPEG_LIMITS } from "../output/print-jpeg.js";
59
59
  import { checkNativeRawIntegrity, prepareNativeRawDevelopment } from "../capture/native-raw-development.js";
@@ -76,6 +76,7 @@ import { calculateSensorSpectralQuadrature } from "../sensor/spectral-quadrature
76
76
  import { createDiffractionLimitedPsfProfile } from "../optics/diffraction-psf-profile.js";
77
77
  import { registerDiffractionPsfKey, siteDiffractionKernel } from "../optics/site-diffraction-kernel.js";
78
78
  import { convolveSiteKernels, createDefocusDiscSiteKernel, requireDefocusDiscKernelRadius, resolveDefocusDisc } from "../optics/defocus-disc-kernel.js";
79
+ import { calculateDefocusFieldVariation, createDefocusImageSpaceProvider, prepareDefocusImageSpaceGeometry } from "./defocus-image-space-provider.js";
79
80
  import { createDiffractedPlanarProvider } from "./diffraction-provider.js";
80
81
  import { resolveGenericPrimeLens } from "../equipment/generic-prime-lenses.js";
81
82
  import { calculateChannelElectronsPerRadiance } from "./channel-response.js";
@@ -85,6 +86,7 @@ import { resolveAntiAliasingSpatialKernel } from "../sensor/optical-stack.js";
85
86
  import { multiplyMatrices } from "../color/colorimetry-math.js";
86
87
  import { prepareRgbGamutMapping } from "../color/gamut-mapping.js";
87
88
  import { serializeNativeRawDng } from "../capture/native-raw-dng.js";
89
+ import { applyDiffractionCorrection, defaultDiffractionCorrectionRadiusPixels, designDiffractionCorrectionFilter, parseDiffractionCorrectionSettings, parseUnsharpMaskSettings, renderSdrWithUnsharpMask, DEVELOPMENT_DETAIL_MODEL_VERSION } from "../processing/development-detail.js";
88
90
  import { parseSensorReadoutConversionProfile, resolveSensorReadoutRegime } from "../sensor/raw-readout.js";
89
91
  import { resolveChartScene, chartMaterialInputs, CHART_SCENE_PRESETS } from "./chart-scenes.js";
90
92
  import { resolveChartMaterialLayers } from "./chart-material-layers.js";
@@ -101,7 +103,7 @@ export const SIMULATE_CAPTURE_BOX_APERTURE_DEFAULT_SAMPLES = 4;
101
103
  export const SIMULATE_CAPTURE_COST_OPTIONS = /* @__PURE__ */ Object.freeze(["lens.pupilSampling", "lens.projection", "lens.distortion", "lens.vignetting", "lens.psf",
102
104
  "lens.diffraction", "lens.defocus", "sensor.antiAliasing", "sensor.pixelAperture.box", "sensor.temporalSampleCount", "motion.rotation", "motion.shutter.rolling", "output.borderHandling.mirror", "output.region",
103
105
  "output.whiteBalance.auto", "output.demosaic.ahd", "output.highlightMode.reconstruct", "output.colorEncoding.wide-gamut", "output.gamutMapping.perceptual",
104
- "output.format.jpeg", "output.format.png", "output.format.dng"]);
106
+ "output.diffractionCorrection", "output.sharpening", "output.format.jpeg", "output.format.png", "output.format.dng"]);
105
107
  /**
106
108
  * The options that take the exact-or-fast flag (#395), in the path style of {@link SIMULATE_CAPTURE_COST_OPTIONS}: each accepts
107
109
  * the record form with `fast` and is reported in `diagnostics.fast.applied` when it runs. Every one of them takes the flag,
@@ -204,10 +206,15 @@ const channelResponseCache = new Map();
204
206
  * DIFFRACTION_PROFILE_CACHE_SIZE, most recently used last). Every region part and detail tile of one request builds the same
205
207
  * input. The kept record is private: each call gets its own copy, as mutable as a newly built one, so a caller's change
206
208
  * never reaches another call. The copy is made of plain records and arrays (plainDataCopy): the kernel set-up structured-clones
207
- * the profile once per spectral node, and in a CPU profile of the 96 × 64 Quality tile that clone took about twice as long
208
- * (24 instead of 12 ms per call) on a deep-frozen record or on a structuredClone copy as on a newly built record or this copy.
209
+ * the profile (once per spectral node before 2.15.0, once per request since), and in a CPU profile of the 96 × 64 Quality tile
210
+ * that clone took about twice as long (24 instead of 12 ms per call) on a deep-frozen record or on a structuredClone copy as on a newly built record or this copy.
211
+ * 2.15.0: profiles may hold up to 400 wavelengths (a 1 nm spectral step), so the cache also keeps at most
212
+ * DIFFRACTION_PROFILE_CACHE_KERNEL_VALUES kernel values in all (wavelengths × (2R + 1)², summed), the most it could hold before
213
+ * (8 profiles of 64 wavelengths at radius 32); the newest profile is always kept.
209
214
  */
210
215
  const DIFFRACTION_PROFILE_CACHE_SIZE = 8;
216
+ const DIFFRACTION_PROFILE_CACHE_KERNEL_VALUES = 8 * 64 * 65 * 65;
217
+ const kernelValuesOf = (profile) => profile.wavelengths.length * (2 * profile.kernelRadiusSamples + 1) ** 2;
211
218
  const diffractionProfileCache = new Map();
212
219
  /** A copy of plain data (records, arrays, primitives; typed arrays are sliced), built from ordinary literals. */
213
220
  function plainDataCopy(value) {
@@ -231,8 +238,14 @@ function cachedDiffractionProfile(input) {
231
238
  else
232
239
  kept = createDiffractionLimitedPsfProfile(input).value;
233
240
  diffractionProfileCache.set(key, kept);
234
- if (diffractionProfileCache.size > DIFFRACTION_PROFILE_CACHE_SIZE)
235
- diffractionProfileCache.delete(diffractionProfileCache.keys().next().value);
241
+ let kernelValues = 0;
242
+ for (const profile of diffractionProfileCache.values())
243
+ kernelValues += kernelValuesOf(profile);
244
+ while (diffractionProfileCache.size > 1 && (diffractionProfileCache.size > DIFFRACTION_PROFILE_CACHE_SIZE || kernelValues > DIFFRACTION_PROFILE_CACHE_KERNEL_VALUES)) {
245
+ const oldest = diffractionProfileCache.keys().next().value;
246
+ kernelValues -= kernelValuesOf(diffractionProfileCache.get(oldest));
247
+ diffractionProfileCache.delete(oldest);
248
+ }
236
249
  // The same fields in the same order as the built record, frozen at the top level like it; `model` is the frozen constant.
237
250
  return Object.freeze({ ...kept, profile: plainDataCopy(kept.profile), wavelengths: plainDataCopy(kept.wavelengths), quadrature: plainDataCopy(kept.quadrature) });
238
251
  }
@@ -572,7 +585,8 @@ async function developLinearSrgb(input, afterRow, ahd) {
572
585
  const native = plan.exposure.geometry.nativeRaster, g = plan.exposure.resolvedGeometry, active = g.activeCapture.nativeRect;
573
586
  checkNativeRawIntegrity(raw, native.pixelWidth, layout.repeatWidthSites, layout.repeatHeightSites, 0, native.pixelHeight);
574
587
  const developer = createRawPixelDeveloper({ nativeWidth: native.pixelWidth, repeatWidth: layout.repeatWidthSites, repeatHeight: layout.repeatHeightSites,
575
- codes: raw.codes, blackLevels: raw.blackLevels, saturationFlags: raw.saturationFlags, whiteByPhase: raw.effectiveSaturationCodes }, v.phases, v.development, v.mirror);
588
+ codes: raw.codes, blackLevels: raw.blackLevels, saturationFlags: raw.saturationFlags, whiteByPhase: raw.effectiveSaturationCodes,
589
+ ...(v.exposureGain === 1 ? {} : { exposureGain: v.exposureGain }) }, v.phases, v.development, v.mirror);
576
590
  const width = v.raster.pixelWidth, height = v.raster.pixelHeight, rgb = new Float64Array(width * height * 3), row = new Float64Array(width * 3);
577
591
  const at = (x, y) => {
578
592
  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 });
@@ -601,7 +615,7 @@ async function developLinearSrgb(input, afterRow, ahd) {
601
615
  async function developAhd(v, raw, repeatWidth, repeatHeight, width, height, at, afterRow, host) {
602
616
  const native = v.plan.exposure.geometry.nativeRaster, W = native.pixelWidth, H = native.pixelHeight;
603
617
  const source = { nativeWidth: W, repeatWidth, repeatHeight, codes: raw.codes, blackLevels: raw.blackLevels,
604
- saturationFlags: raw.saturationFlags, whiteByPhase: raw.effectiveSaturationCodes };
618
+ saturationFlags: raw.saturationFlags, whiteByPhase: raw.effectiveSaturationCodes, ...(v.exposureGain === 1 ? {} : { exposureGain: v.exposureGain }) };
605
619
  const samples = new Float32Array(W * H), saturated = new Uint8Array(W * H);
606
620
  for (let y = 0; y < H; y++)
607
621
  for (let x = 0; x < W; x++) {
@@ -849,6 +863,30 @@ function resolveDefocusOption(input) {
849
863
  "diffraction kernel convolved with the disc, a further approximation)" : "") + "; see the view.defocus assumption.");
850
864
  return disc;
851
865
  }
866
+ // #548: with a lens field mapping, vignetting or camera rotation the capture applies the disc in image space, on the sensor's site lattice.
867
+ if (input.imageSpace) {
868
+ const { options, fieldVariation } = input.imageSpace(disc), v = fieldVariation, bounds = [];
869
+ if (v.rotationMaximumDiameterChangeSites !== null)
870
+ bounds.push("camera rotation: pitch or yaw tilts the scene plane in the rotated camera, so the disc of a scene point's " +
871
+ "axial depth there differs from the disc used by at most " + formatBound(v.rotationMaximumDiameterChangeSites) + " sites over the rendered instants (0 for roll alone)");
872
+ if (v.distortionMaximumLocalScaleDeviation !== null)
873
+ bounds.push("distortion: its local scale departs from 1 by at most " + formatBound(v.distortionMaximumLocalScaleDeviation) +
874
+ " (fraction), the relative disc-size difference to a model that blurs the undistorted image and then distorts it");
875
+ if (v.projectionMaximumDiameterChangeSites !== null)
876
+ bounds.push("non-rectilinear projection: the disc of the distance along each scene point's ray, instead of its axial depth, " +
877
+ "differs by at most " + formatBound(v.projectionMaximumDiameterChangeSites) + " sites (a real lens of that projection may lie anywhere between the two)");
878
+ assume("lens.defocus", "model", { ...value, execution: "image-space-convolution", combinedWithDiffraction: input.diffraction, imageSpaceOptions: [...options],
879
+ fieldVariation: { ...fieldVariation } }, model +
880
+ "Applied as an image-space convolution (#548, with " + options.join(", ") + "): for each spectral node (and, with camera rotation, each temporal midpoint, in that instant's camera frame) " +
881
+ "the sensor-plane irradiance before any PSF, through the lens field mapping and the field throughput, is formed on the site lattice of the sensor and convolved with the disc, " +
882
+ "the disc of the same diameter at every field point, centred on each point's chief ray. This is the environment PSF quadrature with the disc as a sampled PSF, summed in another order " +
883
+ "(a test checks it to 1e-12 per site). Vignetting is applied at each scene point before the disc spreads its light (field throughput, as for any PSF); the disc does not take the " +
884
+ "cat's-eye shape of mechanical vignetting. " + (input.diffraction ? "With lens.diffraction, each spectral node's diffraction kernel is convolved with the disc and applied as one " +
885
+ "image-space kernel, a further approximation (the defocused wave PSF is not exactly the Airy pattern convolved with the disc). " : "") +
886
+ (bounds.length > 0 ? "Field-variation bounds of the constant disc, on a " + v.gridPointsPerSide + " × " + v.gridPointsPerSide + " grid over the frame: " + bounds.join("; ") + ". " : "") +
887
+ "The disc kernel is a sampling of the disc on the site lattice, as on the object-space path.");
888
+ return disc;
889
+ }
852
890
  // The capture (#492, step 2a): an object-space convolution of the scene radiance on the site lattice.
853
891
  assume("lens.defocus", "model", { ...value, execution: "object-space-convolution", combinedWithDiffraction: input.diffraction }, model +
854
892
  "Applied as an object-space convolution: the scene radiance on the site lattice is convolved with the disc, the same kernel at every spectral node, " +
@@ -858,6 +896,10 @@ function resolveDefocusOption(input) {
858
896
  "The disc kernel is a sampling of the disc on the site lattice; lens.pupilSampling (ray-sampled, no diffraction) is the independent reference.");
859
897
  return disc;
860
898
  }
899
+ /** A field-variation bound for an explanation: 3 significant digits (the assumption value keeps the full number). */
900
+ function formatBound(value) {
901
+ return value === 0 ? "0" : String(Number(value.toPrecision(3)));
902
+ }
861
903
  /** Internal: passed as the view of a "display-view" run to resolve the request without a view (`prepareDisplayView`, #387). */
862
904
  export const DISPLAY_VIEW_WITHOUT_VIEW = Symbol("display view without view");
863
905
  /**
@@ -1031,8 +1073,8 @@ export async function runCapture(options, run, viewOption, renderView) {
1031
1073
  // through one linear map (still camera, rectilinear, no distortion or field throughput); otherwise per tap.
1032
1074
  const diffractionObjectSpace = diffractionRadiusOption !== undefined && !rotating && !lensFieldMapping && !vignetting;
1033
1075
  // Output and sensor
1034
- const outputOptions = allowKeys(top.output, "output", ["width", "height", "format", "toneCurve", "toneCurveParameters", "highlightMode", "whiteBalance", "jpegQuantizationStep",
1035
- "colorEncoding", "bitDepth", "gamutMapping", "demosaic", "borderHandling", "dng", "maximumPixels", "region", "metadata"]);
1076
+ const outputOptions = allowKeys(top.output, "output", ["width", "height", "format", "toneCurve", "toneCurveParameters", "highlightMode", "exposureCompensationStops", "whiteBalance", "jpegQuantizationStep",
1077
+ "colorEncoding", "bitDepth", "gamutMapping", "demosaic", "borderHandling", "dng", "maximumPixels", "region", "metadata", "diffractionCorrection", "sharpening"]);
1036
1078
  // Opt-in large images: a caller-raised native pixel limit. Without it the 4094-pixel sides are unchanged.
1037
1079
  const raisedPixels = outputOptions.maximumPixels === undefined ? undefined : integerIn(outputOptions.maximumPixels, "output.maximumPixels", 1, NATIVE_RAW_LIMITS.ceilingPixels);
1038
1080
  const maximumSide = raisedPixels === undefined ? 4094 : NATIVE_RAW_LIMITS.maximumDimension;
@@ -1086,6 +1128,19 @@ export async function runCapture(options, run, viewOption, renderView) {
1086
1128
  if (linearPath && toneCurve !== "identity")
1087
1129
  fail("output.toneCurve other than identity is available only for sRGB output; wide-gamut encodings are converted from the linear developed colour.");
1088
1130
  const highlightMode = pick(outputOptions, "highlightMode", "output.highlightMode", DEFAULTS.highlightMode, "Saturated RAW highlights are clipped to neutral.");
1131
+ // #556: development exposure push, a gain on the normalized RAW before white balance. Opt-in: no assumption when omitted.
1132
+ let exposureCompensationStops = 0;
1133
+ if (outputOptions.exposureCompensationStops !== undefined) {
1134
+ const stops = outputOptions.exposureCompensationStops, range = NATIVE_RAW_DEVELOPMENT_EXPOSURE_RANGE_STOPS;
1135
+ if (typeof stops !== "number" || !Number.isFinite(stops) || stops < range.minimum || stops > range.maximum)
1136
+ fail("output.exposureCompensationStops must be a finite number from -2 to +5.");
1137
+ exposureCompensationStops = stops;
1138
+ assume("output.exposureCompensationStops", "model", { stops, gain: 2 ** stops, appliedTo: "black-subtracted white-normalized RAW, before demosaic and white balance",
1139
+ dng: format === "dng" ? "unmodified RAW (the push is a development setting)" : null }, "Development exposure push: each site's (code − black)/(white − black) is multiplied by 2^stops = " + 2 ** stops + " before demosaic, white balance, the highlight mode, colour, the tone curve and encoding, " +
1140
+ "like a raw editor's exposure slider. It is not a capture exposure: no photons are added, so raised shadows show the RAW's real shot, read and dark noise and its ADC quantization steps, scaled by the gain. " +
1141
+ "A site pushed to or past white clips at white and counts as saturated (highlightMode \"reconstruct\" rolls it off). The RAW codes are unchanged" + (format === "dng" ? ", and the DNG holds the unmodified RAW." : "."));
1142
+ }
1143
+ const exposureGain = exposureCompensationStops === 0 ? 1 : 2 ** exposureCompensationStops;
1089
1144
  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).");
1090
1145
  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;
1091
1146
  if (format !== "jpeg" && outputOptions.jpegQuantizationStep !== undefined)
@@ -1117,6 +1172,8 @@ export async function runCapture(options, run, viewOption, renderView) {
1117
1172
  }
1118
1173
  }
1119
1174
  const boxAperture = sensorOptions.pixelAperture !== undefined && sensorOptions.pixelAperture !== "point";
1175
+ // The environment queries each site at each aperture node minus each anti-aliasing split offset (sites, +x right, +y down).
1176
+ const 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 })));
1120
1177
  // Display view: its own parameters, and the capture options it reproduces, checked before any scene work.
1121
1178
  const parsedView = displayView && viewOption !== DISPLAY_VIEW_WITHOUT_VIEW ? parseDisplayViewOption(viewOption, width, height) : undefined;
1122
1179
  if (displayView) {
@@ -1139,6 +1196,8 @@ export async function runCapture(options, run, viewOption, renderView) {
1139
1196
  unsupported("output.toneCurve " + String(outputOptions.toneCurve), "luminance tone curves couple the channels; the view supports identity and positive-reinhard-per-channel.");
1140
1197
  if (outputOptions.highlightMode === "reconstruct")
1141
1198
  unsupported("output.highlightMode \"reconstruct\"", "the view supports the per-channel highlight clip.");
1199
+ if (outputOptions.diffractionCorrection !== undefined || outputOptions.sharpening !== undefined)
1200
+ unsupported("output.diffractionCorrection or output.sharpening", "the development detail stages filter the whole developed capture image.");
1142
1201
  }
1143
1202
  const colorSamplingProfile = { schemaVersion: "0.1.0", profileId: IDS.cfa, evidence: FACADE_EVIDENCE, coordinateSystem: "native-sensor-color-sampling-site-index",
1144
1203
  layout: { kind: "periodic-mosaic", repeatWidthSites: 2, repeatHeightSites: 2, siteChannelIds: ["red", "green", "green", "blue"], anchor: "native-sensor-top-left-site" } };
@@ -1207,6 +1266,37 @@ export async function runCapture(options, run, viewOption, renderView) {
1207
1266
  // region results without diffraction stay byte-identical to 2.5.0.
1208
1267
  (diffractionRadiusOption !== undefined ? "With lens.diffraction the object-space convolution (when it applies) covers only the lattice the region's native sites query (plus the kernel radius), in bands of at most 32 native rows; its values are the whole render's." :
1209
1268
  "With lens.diffraction the object-space convolution runs over whole-width bands of 32 native rows that the region touches."));
1269
+ // Development detail (#552): opt-in sharpening and diffraction correction, validated before any scene work.
1270
+ let sharpening, diffractionCorrectionOption;
1271
+ if (outputOptions.diffractionCorrection !== undefined || outputOptions.sharpening !== undefined) {
1272
+ if (idealReference)
1273
+ fail("simulateIdealReference does not take output.diffractionCorrection or output.sharpening: a reference has no camera development.");
1274
+ if (region)
1275
+ fail("output.diffractionCorrection and output.sharpening are not combinable with output.region: they filter across the whole developed image, so a region's edge pixels would differ from the whole render.");
1276
+ }
1277
+ if (outputOptions.diffractionCorrection !== undefined) {
1278
+ if (diffractionRadiusOption === undefined)
1279
+ fail("output.diffractionCorrection needs lens.diffraction: it inverts the diffraction blur that option simulates.");
1280
+ const o = allowKeys(outputOptions.diffractionCorrection, "output.diffractionCorrection", ["wavelengthNm", "regularization", "maximumGain", "kernelRadiusPixels"]);
1281
+ const parsed = parseDiffractionCorrectionSettings({
1282
+ wavelengthNm: pick(o, "wavelengthNm", "output.diffractionCorrection.wavelengthNm", 550, "Design wavelength 550 nm, near the photopic peak, for all three channels: the real blur is polychromatic, and longer wavelengths have a lower cut-off than the one used here."),
1283
+ regularization: pick(o, "regularization", "output.diffractionCorrection.regularization", 0.01, "Regularization α = 0.01: an assumed noise-to-signal power ratio of 1 %, a first-party choice, not a measurement of this capture's noise."),
1284
+ maximumGain: pick(o, "maximumGain", "output.diffractionCorrection.maximumGain", 4, "Gain bound 4: no frequency is amplified more than 4×, which bounds the noise gain."),
1285
+ kernelRadiusPixels: o.kernelRadiusPixels === undefined ? 1 : o.kernelRadiusPixels
1286
+ }, "output.diffractionCorrection");
1287
+ diffractionCorrectionOption = { wavelengthNm: parsed.wavelengthNm, regularization: parsed.regularization, maximumGain: parsed.maximumGain,
1288
+ ...(o.kernelRadiusPixels === undefined ? {} : { kernelRadiusPixels: parsed.kernelRadiusPixels }) };
1289
+ }
1290
+ if (outputOptions.sharpening !== undefined) {
1291
+ if (linearPath)
1292
+ fail("output.sharpening runs on the sRGB SDR path only, not with a wide-gamut output.colorEncoding or output.gamutMapping \"perceptual\".");
1293
+ const o = allowKeys(outputOptions.sharpening, "output.sharpening", ["radiusPixels", "amount", "threshold"]);
1294
+ sharpening = parseUnsharpMaskSettings({
1295
+ radiusPixels: pick(o, "radiusPixels", "output.sharpening.radiusPixels", 1, "Unsharp-mask Gaussian σ = 1 output pixel (kernel reach 3 pixels)."),
1296
+ amount: pick(o, "amount", "output.sharpening.amount", 0.5, "Unsharp-mask amount 0.5: half the detail signal is added back."),
1297
+ threshold: pick(o, "threshold", "output.sharpening.threshold", 0, "Unsharp-mask threshold 0: every difference is sharpened, including noise.")
1298
+ }, "output.sharpening");
1299
+ }
1210
1300
  if (raisedPixels !== undefined) {
1211
1301
  if (nativeWidth > NATIVE_RAW_LIMITS.maximumDimension || nativeHeight > NATIVE_RAW_LIMITS.maximumDimension || nativeWidth * nativeHeight > raisedPixels) {
1212
1302
  fail("output.maximumPixels (" + raisedPixels + ") must cover the native sensor raster of " + nativeWidth + " × " + nativeHeight +
@@ -1500,15 +1590,27 @@ export async function runCapture(options, run, viewOption, renderView) {
1500
1590
  const exposureDeviationStops = standardExposureDeviationStops(fNumber, shutterSeconds, resolvedIso, meteredLuminance);
1501
1591
  const exposureIlluminanceLux = standardExposureIlluminanceLux(fNumber, shutterSeconds, resolvedIso, meteredLuminance, illuminanceLux, callerScene?.kind !== "radiance-function");
1502
1592
  const exposureGreyCardDeviationStops = callerScene?.kind === "radiance-function" ? null : greyCardDeviationStops(fNumber, shutterSeconds, resolvedIso, illuminanceLux);
1503
- const defocusDisc = defocusModel === undefined ? undefined :
1504
- resolveDefocusOption({ run, assume, focalLengthMm, fNumber, focusDistanceM, subjectDistanceM, pitch, diffraction: diffractionRadiusOption !== undefined });
1505
1593
  // #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.
1506
- if (defocusDisc) {
1507
- const reason = rotating ? "camera rotation (motion.angularVelocityRadPerSec)" : lensFieldMapping ? "lens.projection or lens.distortion" : vignetting ? "lens.vignetting" : undefined;
1508
- if (reason !== undefined)
1509
- fail("simulateCapture does not support lens.defocus out of focus with " + reason + ": the defocus disc is applied as an object-space convolution " +
1510
- "on the site lattice, and " + reason + " moves the scene points the sites see off that lattice. Set lens.focusDistanceM equal to subjectDistanceM, or omit one of the two options.");
1511
- }
1594
+ // #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
1595
+ // site lattice (defocus-image-space-provider.ts). The display view refuses those options in any focus (above).
1596
+ const imageSpaceOptions = [...(lensFieldMapping ? [lensOptions.distortion !== undefined ? (lensOptions.projection !== undefined ? "lens.projection, lens.distortion" : "lens.distortion") : "lens.projection"] : []),
1597
+ ...(vignetting ? ["lens.vignetting"] : []), ...(rotating ? ["camera rotation"] : [])];
1598
+ const defocusGeometry = defocusModel !== undefined && !displayView && imageSpaceOptions.length > 0 ? prepareDefocusImageSpaceGeometry({ focalLengthMm, focusDistanceM,
1599
+ angularVelocityRadPerSec: angular, ...(lensFieldMapping ? { lensMapping: prepareEnvironmentLensFieldMapping(lensFieldMapping) } : {}), ...(vignetting ? { vignetting } : {}) }) : undefined;
1600
+ const defocusDisc = defocusModel === undefined ? undefined :
1601
+ resolveDefocusOption({ run, assume, focalLengthMm, fNumber, focusDistanceM, subjectDistanceM, pitch, diffraction: diffractionRadiusOption !== undefined,
1602
+ ...(defocusGeometry ? { imageSpace: (disc) => {
1603
+ // Rotation with a rolling shutter: each row's instants differ, so one instant's image on the lattice would mix rows exposed at other times.
1604
+ if (rotating && shutterKind === "rolling")
1605
+ fail("simulateCapture does not support lens.defocus out of focus with camera rotation and motion.shutter \"rolling\": the disc is applied " +
1606
+ "to each temporal midpoint's image on the sensor lattice, and under a rolling shutter every row has its own instants, so each row would need its own image of the rows the disc " +
1607
+ "reaches (about 2R + 1 rows per row, R the kernel radius). Use motion.shutter \"global\", set lens.focusDistanceM equal to subjectDistanceM, or omit one of the options.");
1608
+ const temporalMidpointsSeconds = rotating ? Array.from({ length: temporalSampleCount }, (_, i) => (i + 0.5) * (shutterSeconds / temporalSampleCount)) : [];
1609
+ return { options: imageSpaceOptions, fieldVariation: calculateDefocusFieldVariation({ geometry: defocusGeometry, ...(lensFieldMapping ? { lensMapping: prepareEnvironmentLensFieldMapping(lensFieldMapping) } : {}),
1610
+ focalLengthMm, fNumber, focusDistanceM, subjectDistanceM, pitchMicrometers: pitch, nativeWidth, nativeHeight, angularVelocityRadPerSec: angular, temporalMidpointsSeconds,
1611
+ diameterSites: disc.diameterSites, discAt: (distanceM) => resolveDefocusDisc({ focalLengthMm, aperture: fNumber, focusDistanceM, subjectDistanceM: distanceM, pitchMicrometers: pitch }).diameterSites }) };
1612
+ } } : {}) });
1613
+ const defocusImageSpace = defocusDisc !== undefined && defocusGeometry !== undefined;
1512
1614
  // #492: the disc on the site lattice (radius 0 for a disc inside one cell: the identity, still applied).
1513
1615
  const defocusKernel = defocusDisc && !displayView ? createDefocusDiscSiteKernel(defocusDisc.diameterSites) ?? undefined : undefined;
1514
1616
  // The display view's disc (#492, step 2b): its expected-signal tables apply it (simulate-display-view.ts).
@@ -1639,9 +1741,10 @@ export async function runCapture(options, run, viewOption, renderView) {
1639
1741
  ...(outputOptions.toneCurveParameters === undefined ? {} : { toneCurveParameters: outputOptions.toneCurveParameters }),
1640
1742
  gamutHandling: "clip-components", outputDynamicRange: "sdr", transferFunction: "srgb", bitDepth: format === "srgb-16" || (format === "png" && !wideGamut && pngBitDepth === 16) ? 16 : 8, rounding: "nearest-ties-up", dither: "none" };
1641
1743
  if (!linearPath)
1642
- assume("output.rendering", "model", { renderingExposureEv: 0, gamutHandling: "clip-components" }, "Developed linear sRGB/D65 with the RAW white level mapped to display white, no exposure push, out-of-gamut components clipped.");
1744
+ assume("output.rendering", "model", { renderingExposureEv: 0, gamutHandling: "clip-components" }, exposureGain === 1 ? "Developed linear sRGB/D65 with the RAW white level mapped to display white, no exposure push, out-of-gamut components clipped." :
1745
+ "Developed linear sRGB/D65 with the RAW white level mapped to display white, no further exposure push after development (output.exposureCompensationStops acts on the RAW), out-of-gamut components clipped.");
1643
1746
  else
1644
- assume("output.rendering", "model", { renderingExposureEv: 0, gamutHandling: "clip-components-in-target-gamut", sdrRendererUsed: false }, "The sRGB SDR renderer is not used: the linear development (RAW white level = relative 1.0, no exposure push) is converted to the target encoding; request.development.rendering is validated but only its exposure and tone curve (identity) apply.");
1747
+ assume("output.rendering", "model", { renderingExposureEv: 0, gamutHandling: "clip-components-in-target-gamut", sdrRendererUsed: false }, "The sRGB SDR renderer is not used: the linear development (RAW white level = relative 1.0, " + (exposureGain === 1 ? "no exposure push" : "after the development exposure push on the RAW") + ") is converted to the target encoding; request.development.rendering is validated but only its exposure and tone curve (identity) apply.");
1645
1748
  // Rendering choices are validated before any radiance work, so a bad tone curve fails fast.
1646
1749
  parseSdrRenderingProfile(rendering);
1647
1750
  if (highlightMode !== "clip" && highlightMode !== "reconstruct")
@@ -1729,7 +1832,8 @@ export async function runCapture(options, run, viewOption, renderView) {
1729
1832
  fidelity: { spectral: "wavelength-resolved", material: "spectral-data", visibility: "resolved", directTransport: "approximation", indirectTransport: "not-modeled" },
1730
1833
  wavelengthChangingTransportModeled: false, volumetricTransportModeled: false, polarizationModeled: false, evidence, limitations: ["Engine-authored planar chart or caller scene function.",
1731
1834
  ...(diffractionObjectSpace ? ["Radiance is the scene radiance convolved with the lens diffraction PSF mapped onto the scene plane (object-space convolution, request.diffraction)."] : []),
1732
- ...(defocusKernel ? ["Radiance is the scene radiance convolved with the geometric defocus disc mapped onto the scene plane (object-space convolution, lens.defocus)."] : [])] }
1835
+ ...(defocusKernel ? [defocusImageSpace ? "Radiance is the sensor-plane irradiance before any PSF convolved with the geometric defocus disc on the sensor's site lattice, divided by " +
1836
+ "the queried point's field factor (image-space convolution, lens.defocus)." : "Radiance is the scene radiance convolved with the geometric defocus disc mapped onto the scene plane (object-space convolution, lens.defocus)."] : [])] }
1733
1837
  };
1734
1838
  const lensId = lensExposure.profileId;
1735
1839
  const opticsProfile = { schemaVersion: "0.1.0", profileId: lensId + ":facade-transmission", profileVersion: SIMULATE_CAPTURE_FACADE_VERSION, lensProfileId: lensId,
@@ -1779,16 +1883,18 @@ export async function runCapture(options, run, viewOption, renderView) {
1779
1883
  assume("lens.diffraction", "model", { model: diffraction.model, aperture: "circular-aperture", fNumber, workingFNumber: diffraction.workingFNumber,
1780
1884
  kernelRadiusSamples, samplePitchMicrometers: pitch, maximumTruncatedEnergyFraction: diffraction.maximumTruncatedEnergyFraction,
1781
1885
  truncatedEnergyByWavelength: diffraction.wavelengths.map((w) => ({ wavelengthNm: w.wavelengthNm, truncatedEnergyFraction: w.truncatedEnergyFraction })),
1782
- 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). " +
1886
+ execution: diffractionObjectSpace ? "object-space-convolution" : defocusImageSpace ? "image-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). " +
1783
1887
  "One kernel per spectral node, binned into site-sized cells over a " + (2 * kernelRadiusSamples + 1) + " × " + (2 * kernelRadiusSamples + 1) + " window" +
1784
1888
  (diffractionRadiusOption !== null ? "" : thirdRingSites > 32 ? " (capped at 32 sites; the third dark ring at the longest node needs " + thirdRingSites + ")" :
1785
1889
  " (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. " +
1786
1890
  (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). " :
1787
- "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. ") +
1891
+ 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), " +
1892
+ "the same sum as the environment PSF quadrature with that kernel. " :
1893
+ "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. ") +
1788
1894
  "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."));
1789
1895
  }
1790
- // #492: each spectral node's kernel for lens.defocus out of focus: the disc, or with lens.diffraction (always on the object-space path here)
1791
- // the node's diffraction kernel convolved with the disc, radius R_diffraction + R_defocus (built on first use, kept for the request).
1896
+ // #492: each spectral node's kernel for lens.defocus out of focus: the disc, or with lens.diffraction the node's diffraction kernel convolved
1897
+ // with the disc, radius R_diffraction + R_defocus (built on first use, kept for the request). Object space, or image space (#548).
1792
1898
  let defocusSiteKernels;
1793
1899
  if (defocusKernel) {
1794
1900
  if (diffraction) {
@@ -1812,8 +1918,26 @@ export async function runCapture(options, run, viewOption, renderView) {
1812
1918
  else
1813
1919
  defocusSiteKernels = { radius: defocusKernel.radius, wavelengthsNm: quadratureWavelengths(), at: () => defocusKernel.weights };
1814
1920
  }
1921
+ // #548: in image space the kernel reaches its radius (plus the anti-aliasing and pixel-aperture offsets) beyond the frame, so the lens
1922
+ // field mapping and the vignetting profile must cover the frame's corners plus that reach.
1923
+ if (defocusSiteKernels && defocusGeometry && defocusImageSpace) {
1924
+ const reach = defocusSiteKernels.radius + Math.max(0, ...queryOffsetsSites.map((o) => Math.ceil(Math.max(Math.abs(o.x), Math.abs(o.y)) - 1e-6))) + 0.5;
1925
+ const xMm = (nativeWidth / 2 + reach) * pitch / 1000, yMm = (nativeHeight / 2 + reach) * pitch / 1000;
1926
+ for (const [x, y] of [[-xMm, -yMm], [xMm, -yMm], [-xMm, yMm], [xMm, yMm]]) {
1927
+ try {
1928
+ defocusGeometry.lookOf(x, y, 0);
1929
+ }
1930
+ catch (error) {
1931
+ fail("lens.defocus out of focus with " + imageSpaceOptions.join(", ") + " reads the scene through the lens up to " + defocusSiteKernels.radius + " sites beyond the frame (the " +
1932
+ (diffraction ? "kernel radius of the diffraction kernel convolved with the disc" : "disc's kernel radius") + "), and at the frame's corner plus that reach (" +
1933
+ Math.hypot(x, y).toFixed(3) + " mm from the axis) " + error.message + " Extend the profile's range, or bring the focus closer to the subject.");
1934
+ }
1935
+ }
1936
+ }
1815
1937
  const psf = lensPsf ?? (diffractionObjectSpace ? { kind: "not-applied", evidence,
1816
1938
  limitation: "No environment PSF: lens diffraction is applied upstream by the scene provider as an object-space convolution (request.diffraction)." } : undefined) ??
1939
+ (defocusSiteKernels && defocusImageSpace ? { kind: "not-applied", evidence, limitation: "No environment PSF: lens defocus" + (diffraction ? " (with lens.diffraction)" : "") +
1940
+ " is applied upstream by the scene provider as an image-space convolution on the sensor's site lattice (lens.defocus)." } : undefined) ??
1817
1941
  diffractionPsf ?? (defocusSiteKernels ? { kind: "not-applied", evidence,
1818
1942
  limitation: "No environment PSF: lens defocus is applied upstream by the scene provider as an object-space convolution (lens.defocus)." } : undefined) ??
1819
1943
  { kind: "not-applied", evidence, limitation: "No lens PSF (point optics)." };
@@ -1854,18 +1978,23 @@ export async function runCapture(options, run, viewOption, renderView) {
1854
1978
  const yieldControl = createHostYield().yieldControl;
1855
1979
  const perSite = queriesPerSite * (diffraction && psf === diffractionPsf ? (2 * diffraction.kernelRadiusSamples + 1) ** 2 : 1);
1856
1980
  let diffractionMs = 0, defocusMs = 0, demosaicMs, encodeMs;
1857
- const diffractedProvider = (window) => defocusSiteKernels ? createDiffractedPlanarProvider({ base: provider, siteKernels: defocusSiteKernels.at,
1858
- wavelengthsNm: defocusSiteKernels.wavelengthsNm, kernelRadius: defocusSiteKernels.radius, nativeWidth, nativeHeight, outputWidth: width,
1859
- sitePitchMicrometers: pitch, distanceM: subjectDistanceM, metresPerFrameUnit, onConvolution: (ms) => { defocusMs += ms; },
1860
- ...(window ? { window } : {}),
1861
- ...(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 }))) } : {}) }) :
1862
- diffraction && diffractionObjectSpace ? createDiffractedPlanarProvider({ base: provider, psf: diffractionPsf.configuration.psf,
1863
- wavelengthsNm: diffraction.profile.axes.wavelengthNm, kernelRadius: diffraction.kernelRadiusSamples, nativeWidth, nativeHeight, outputWidth: width,
1864
- sitePitchMicrometers: pitch, distanceM: subjectDistanceM, metresPerFrameUnit, onConvolution: (ms) => { diffractionMs += ms; },
1865
- // Region (2.6): convolve only the lattice the region's native sites query; the values are the whole render's.
1981
+ const diffractedProvider = (window) =>
1982
+ // #548: the disc (or diffraction ⊛ disc) in image space, on the sensor's site lattice, through the mapping, field throughput and rotation.
1983
+ defocusSiteKernels && defocusGeometry && defocusImageSpace ? createDefocusImageSpaceProvider({ base: provider, geometry: defocusGeometry, siteKernels: defocusSiteKernels.at,
1984
+ wavelengthsNm: defocusSiteKernels.wavelengthsNm, kernelRadius: defocusSiteKernels.radius, nativeWidth, nativeHeight, sitePitchMicrometers: pitch, distanceM: subjectDistanceM,
1985
+ metresPerFrameUnit, queryOffsetsSites, timesPerBand: temporalSampleCount, onConvolution: (ms) => { defocusMs += ms; }, ...(window ? { window } : {}) }) :
1986
+ defocusSiteKernels ? createDiffractedPlanarProvider({ base: provider, siteKernels: defocusSiteKernels.at,
1987
+ wavelengthsNm: defocusSiteKernels.wavelengthsNm, kernelRadius: defocusSiteKernels.radius, nativeWidth, nativeHeight, outputWidth: width,
1988
+ sitePitchMicrometers: pitch, distanceM: subjectDistanceM, metresPerFrameUnit, onConvolution: (ms) => { defocusMs += ms; },
1866
1989
  ...(window ? { window } : {}),
1867
- // The environment queries each site at each aperture node minus each split offset (sites, +x right, +y down).
1868
- ...(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;
1990
+ ...(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 }))) } : {}) }) :
1991
+ diffraction && diffractionObjectSpace ? createDiffractedPlanarProvider({ base: provider, psf: diffractionPsf.configuration.psf,
1992
+ wavelengthsNm: diffraction.profile.axes.wavelengthNm, kernelRadius: diffraction.kernelRadiusSamples, nativeWidth, nativeHeight, outputWidth: width,
1993
+ sitePitchMicrometers: pitch, distanceM: subjectDistanceM, metresPerFrameUnit, onConvolution: (ms) => { diffractionMs += ms; },
1994
+ // Region (2.6): convolve only the lattice the region's native sites query; the values are the whole render's.
1995
+ ...(window ? { window } : {}),
1996
+ // The environment queries each site at each aperture node minus each split offset (sites, +x right, +y down).
1997
+ ...(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;
1869
1998
  // Built on first use: a capture that takes the tabulated path (#394 step 2) never queries it, and its kernels cost a setup.
1870
1999
  let rawProviderBuilt;
1871
2000
  const rawProvider = () => (rawProviderBuilt ??= diffractedProvider(regionNative));
@@ -1940,7 +2069,7 @@ export async function runCapture(options, run, viewOption, renderView) {
1940
2069
  // Display view: the renderer that simulate-display-view.ts passes in reads this request's resolved state, so a bundle
1941
2070
  // that only captures does not contain the display view.
1942
2071
  if (displayView)
1943
- return await renderView({ ahd, fastReport, antiAliasing, aspect, assume, assumptions, axisChannelResponse, boxSamples, buildProvider, callerScene, chart, materialLayers, chartReport, chartSize, diffraction, defocus: displayViewDefocus, diffractionPsf, fNumber, focalLengthMm, focusDistanceM, height, highlightMode, 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 });
2072
+ return await renderView({ 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 });
1944
2073
  // Ideal reference (2.3): the scene without camera effects, at the capture's own exposure, white balance and encoding.
1945
2074
  if (idealReference) {
1946
2075
  const referenceStarted = clockMs();
@@ -1976,7 +2105,8 @@ export async function runCapture(options, run, viewOption, renderView) {
1976
2105
  sum[2] += s[2];
1977
2106
  }
1978
2107
  // Normalized RAW values saturate at the white level, as in development.
1979
- const c = sum.map((value) => Math.min(1, Math.max(0, value / (n * n))));
2108
+ // #556: the development exposure push multiplies the normalized RAW before the white clip.
2109
+ const c = exposureGain === 1 ? sum.map((value) => Math.min(1, Math.max(0, value / (n * n)))) : sum.map((value) => Math.min(1, Math.max(0, value / (n * n) * exposureGain)));
1980
2110
  development.developInto(c[0], c[1], c[2], rgb, (py * outputWidth + px) * 3, counts);
1981
2111
  }
1982
2112
  if ((py & 15) === 15) {
@@ -2182,10 +2312,52 @@ export async function runCapture(options, run, viewOption, renderView) {
2182
2312
  // Development
2183
2313
  const development = { phaseProfiles, colorProfile,
2184
2314
  whiteBalance: developmentWhiteBalance, rendering, highlightMode, ...(mirrorBorder ? { borderHandling: "mirror" } : {}),
2315
+ ...(exposureCompensationStops === 0 ? {} : { exposureCompensationStops }),
2185
2316
  maximumRetainedPayloadBytes: developmentPlan.outputBytes + outputWidth * outputHeight * 3 * (rendering.bitDepth / 8) };
2186
2317
  let developed;
2187
2318
  let gamutMappingDiagnostics;
2188
- if (ahd) {
2319
+ // Development detail (#552): diffraction correction on the developed linear colour, then the unsharp mask between the sRGB
2320
+ // transfer and quantization. Both need the whole developed image, so a request with either develops through developLinearSrgb
2321
+ // (the native development's own operator); without them every path below is unchanged.
2322
+ const detailRequested = sharpening !== undefined || diffractionCorrectionOption !== undefined;
2323
+ const detailDiagnostics = {};
2324
+ let diffractionFilter;
2325
+ if (diffractionCorrectionOption) {
2326
+ const workingFNumber = fNumber * imageDistanceMm / focalLengthMm;
2327
+ const settings = { ...diffractionCorrectionOption, kernelRadiusPixels: diffractionCorrectionOption.kernelRadiusPixels ??
2328
+ defaultDiffractionCorrectionRadiusPixels(workingFNumber, diffractionCorrectionOption.wavelengthNm, pitch) };
2329
+ if (diffractionCorrectionOption.kernelRadiusPixels === undefined)
2330
+ assume("output.diffractionCorrection.kernelRadiusPixels", "default", settings.kernelRadiusPixels, "FIR radius: the Airy pattern's third dark ring (3.2383·λ·N_w) at the design wavelength, in pixels, rounded up, within 1–16.");
2331
+ diffractionFilter = designDiffractionCorrectionFilter({ workingFNumber, pixelPitchMicrometers: pitch, settings });
2332
+ assume("output.diffractionCorrection", "model", { model: "development-detail", modelVersion: DEVELOPMENT_DETAIL_MODEL_VERSION, method: "bounded-wiener-tikhonov-fir",
2333
+ stage: "linear colour after demosaic, colour and white balance; before the tone curve", fNumber, workingFNumber, ...settings, pixelPitchMicrometers: pitch,
2334
+ cutoffCyclesPerPixel: diffractionFilter.cutoffCyclesPerPixel, nyquistCyclesPerPixel: 0.5, nyquistAreaBeyondCutoffFraction: diffractionFilter.nyquistAreaBeyondCutoffFraction,
2335
+ designPeakGain: diffractionFilter.designPeakGain, responsePeakGain: diffractionFilter.responsePeakGain, responseMaximumBeyondCutoff: diffractionFilter.responseMaximumBeyondCutoff,
2336
+ edgeHandling: "clamp", recoversBeyondCutoff: false }, "Deconvolution with the known diffraction OTF of an ideal, aberration-free circular pupil at the capture's working f-number N_w = N·v/f, H(s) = (2/π)(acos s − s√(1 − s²)), s = f/f_c, f_c = 1/(λ·N_w) " +
2337
+ "(MIT OpenCourseWare 2.71, lectures 21–22). Gain G = min(maximumGain, H(1 + α)/(H² + α)) (Wiener deconvolution with a constant noise-to-signal ratio α, as in scikit-image's Wiener–Hunt filter), " +
2338
+ "sampled, inverse transformed and truncated to a raised-cosine (2R + 1)² FIR with unit DC gain, applied to each linear channel with clamped edges. " +
2339
+ "The OTF is exactly zero beyond f_c: that detail never reached the sensor, and no correction restores it. The design gain there is 0; responseMaximumBeyondCutoff is the truncated FIR's leakage, which passes only noise and aliasing. " +
2340
+ "Below f_c the gain is bounded, so attenuated detail is only partly restored and noise is amplified by up to the measured responsePeakGain. " +
2341
+ "Not inverted: the pixel aperture, optical anti-aliasing filter, demosaic blur, defocus and aberrations. One design wavelength for all channels (the simulated blur is polychromatic).");
2342
+ }
2343
+ const correctLinear = (rgb) => {
2344
+ if (!diffractionFilter)
2345
+ return rgb;
2346
+ const corrected = applyDiffractionCorrection(rgb, outputWidth, outputHeight, diffractionFilter);
2347
+ detailDiagnostics.diffractionCorrection = { ...corrected.diagnostics, cutoffCyclesPerPixel: diffractionFilter.cutoffCyclesPerPixel,
2348
+ responsePeakGain: diffractionFilter.responsePeakGain, responseMaximumBeyondCutoff: diffractionFilter.responseMaximumBeyondCutoff };
2349
+ return corrected.samples;
2350
+ };
2351
+ if (sharpening)
2352
+ assume("output.sharpening", "model", { model: "development-detail", modelVersion: DEVELOPMENT_DETAIL_MODEL_VERSION, method: "unsharp-mask", ...sharpening,
2353
+ kernelRadiusPixels: Math.ceil(3 * sharpening.radiusPixels), domain: "srgb-encoded", stage: "after the tone curve, gamut handling and sRGB transfer; before quantization", edgeHandling: "clamp" }, "Unsharp mask y = x + amount·(x − G_σ∗x) where |x − G_σ∗x| ≥ threshold (the GIMP and ImageMagick unsharp mask: radius, amount, threshold), per channel on the sRGB-encoded values in [0, 1], " +
2354
+ "separable Gaussian truncated at ⌈3σ⌉ with clamped edges, then clipped to [0, 1] and rounded. It raises contrast at edges and creates halos (overshoot and undershoot; see diagnostics.detail.sharpening) " +
2355
+ "and sharpens noise above the threshold. It adds no optical detail.");
2356
+ if (detailRequested)
2357
+ assume("output.developmentOrder", "model", [...(exposureGain === 1 ? [] : ["development exposure push (RAW)"]), "demosaic", "colour and white balance", ...(diffractionCorrectionOption ? ["diffraction correction (linear)"] : []),
2358
+ "rendering exposure and tone curve", "gamut handling", "transfer function", ...(sharpening ? ["unsharp mask"] : []), "quantization"], "Development detail order: the deconvolution acts on linear light, where the blur is a convolution; the unsharp mask acts on the encoded values just before quantization." +
2359
+ (exposureGain === 1 ? "" : " A development exposure push (output.exposureCompensationStops) scales the black-subtracted, white-normalized RAW first, so pushed sites clip at white before the deconvolution sees them."));
2360
+ if (ahd || (detailRequested && !linearPath)) {
2189
2361
  let lastYield = Date.now();
2190
2362
  const linear = await developLinearSrgb({ ...development, raw: rawOutput.raw }, async (completed, total) => {
2191
2363
  onProgress?.({ stage: "development", completedTileCount: completed, totalTileCount: total });
@@ -2194,21 +2366,29 @@ export async function runCapture(options, run, viewOption, renderView) {
2194
2366
  checkAbort();
2195
2367
  lastYield = Date.now();
2196
2368
  }
2197
- }, { yieldControl: () => yieldControl(), checkAbort, onDemosaicMs: (ms) => { demosaicMs = ms; } });
2198
- if (!linearPath) {
2369
+ }, ahd ? { yieldControl: () => yieldControl(), checkAbort, onDemosaicMs: (ms) => { demosaicMs = ms; } } : undefined);
2370
+ const rgb = correctLinear(linear.rgb);
2371
+ if (!linearPath && sharpening) {
2372
+ const rendered = renderSdrWithUnsharpMask(rgb, outputWidth, outputHeight, rendering, sharpening);
2373
+ detailDiagnostics.sharpening = rendered.sharpening;
2374
+ developed = { raw: rawOutput.raw, integerSamples: rendered.codes, highlight: linear.highlight, diagnostics: { renderingNegativeSampleCount: rendered.counts.negative,
2375
+ renderingAboveReferenceSampleCount: rendered.counts.above, toneChangedSampleCount: rendered.counts.changed, gamutClippedLowSampleCount: rendered.counts.low,
2376
+ gamutClippedHighSampleCount: rendered.counts.high, captureSaturation: "consumed-in-raw-development" } };
2377
+ }
2378
+ else if (!linearPath) {
2199
2379
  // The native development's SDR kernel, row by row, on the AHD-developed linear sRGB.
2200
2380
  const maximum = 2 ** rendering.bitDepth - 1, tone = prepareSdrPixelTone(rendering), scratch = new Float64Array(3);
2201
2381
  const codes = rendering.bitDepth === 8 ? new Uint8Array(outputWidth * outputHeight * 3) : new Uint16Array(outputWidth * outputHeight * 3);
2202
2382
  const counts = { negative: 0, above: 0, changed: 0, low: 0, high: 0, outOfGamut: false };
2203
2383
  for (let y = 0; y < outputHeight; y++)
2204
- renderSdrRunInto(linear.rgb, y * outputWidth * 3, outputWidth, rendering, tone, 2 ** rendering.renderingExposureEv, maximum, codes, y * outputWidth * 3, scratch, counts);
2384
+ renderSdrRunInto(rgb, y * outputWidth * 3, outputWidth, rendering, tone, 2 ** rendering.renderingExposureEv, maximum, codes, y * outputWidth * 3, scratch, counts);
2205
2385
  developed = { raw: rawOutput.raw, integerSamples: codes, highlight: linear.highlight, diagnostics: { renderingNegativeSampleCount: counts.negative,
2206
2386
  renderingAboveReferenceSampleCount: counts.above, toneChangedSampleCount: counts.changed, gamutClippedLowSampleCount: counts.low,
2207
2387
  gamutClippedHighSampleCount: counts.high, captureSaturation: "consumed-in-raw-development" } };
2208
2388
  }
2209
2389
  else {
2210
2390
  const mapping = perceptual ? prepareRgbGamutMapping({ colorSpace: colorEncoding, method: "cielab-hue-preserving-chroma-compression", kneeFraction: PERCEPTUAL_GAMUT_KNEE_FRACTION }) : undefined;
2211
- const encoded = encodeSdrFromLinear(linear.rgb, colorEncoding, pngBitDepth, mapping);
2391
+ const encoded = encodeSdrFromLinear(rgb, colorEncoding, pngBitDepth, mapping);
2212
2392
  developed = { raw: rawOutput.raw, integerSamples: encoded.samples, highlight: linear.highlight, diagnostics: encoded.diagnostics };
2213
2393
  if (mapping)
2214
2394
  gamutMappingDiagnostics = { method: "cielab-hue-preserving-chroma-compression", kneeFraction: PERCEPTUAL_GAMUT_KNEE_FRACTION,
@@ -2234,7 +2414,7 @@ export async function runCapture(options, run, viewOption, renderView) {
2234
2414
  });
2235
2415
  {
2236
2416
  const mapping = perceptual ? prepareRgbGamutMapping({ colorSpace: colorEncoding, method: "cielab-hue-preserving-chroma-compression", kneeFraction: PERCEPTUAL_GAMUT_KNEE_FRACTION }) : undefined;
2237
- const encoded = encodeSdrFromLinear(linear.rgb, colorEncoding, pngBitDepth, mapping);
2417
+ const encoded = encodeSdrFromLinear(correctLinear(linear.rgb), colorEncoding, pngBitDepth, mapping);
2238
2418
  developed = { raw: rawOutput.raw, integerSamples: encoded.samples, highlight: linear.highlight, diagnostics: encoded.diagnostics };
2239
2419
  if (mapping) {
2240
2420
  gamutMappingDiagnostics = { method: "cielab-hue-preserving-chroma-compression", kneeFraction: PERCEPTUAL_GAMUT_KNEE_FRACTION,
@@ -2295,14 +2475,18 @@ export async function runCapture(options, run, viewOption, renderView) {
2295
2475
  ...(rollingSchedule ? ["motion.shutter.rolling"] : []), ...(mirrorBorder ? ["output.borderHandling.mirror"] : []), ...(region ? ["output.region"] : []),
2296
2476
  ...(needsAuto ? ["output.whiteBalance.auto"] : []), ...(ahd ? ["output.demosaic.ahd"] : []),
2297
2477
  ...(highlightMode === "reconstruct" ? ["output.highlightMode.reconstruct"] : []), ...(wideGamut ? ["output.colorEncoding.wide-gamut"] : []),
2298
- ...(perceptual ? ["output.gamutMapping.perceptual"] : []), ...(format === "jpeg" || format === "png" || format === "dng" ? ["output.format." + format] : [])]);
2478
+ ...(perceptual ? ["output.gamutMapping.perceptual"] : []), ...(diffractionFilter ? ["output.diffractionCorrection"] : []),
2479
+ ...(sharpening ? ["output.sharpening"] : []), ...(format === "jpeg" || format === "png" || format === "dng" ? ["output.format." + format] : [])]);
2480
+ // #552: the detail stages' contracts, in execution order inside the development.
2481
+ const detailContracts = diffractionFilter ? ["designDiffractionCorrectionFilter", "applyDiffractionCorrection"] : [], sdrContract = sharpening ? "renderSdrWithUnsharpMask" : "SDR rendering kernel";
2299
2482
  return Object.freeze({
2300
2483
  facadeVersion: SIMULATE_CAPTURE_FACADE_VERSION, engineApiVersion: ENGINE_API_VERSION,
2301
2484
  image: { width: outputWidth, height: outputHeight, format, samples: developed.integerSamples, ...(jpeg ? { jpeg } : {}), ...(png ? { png, colorEncoding: colorEncoding } : {}), ...(dng ? { dng: dng.bytes } : {}) },
2302
2485
  raw: developed.raw, plan: developmentPlan,
2303
2486
  ...(region && regionNative ? { region: { ...region, frameWidth: width, frameHeight: height, nativeRect: { ...regionNative } } } : {}),
2304
2487
  request: { environmentRaw: finalRawInput, siteInput, development, whiteBalanceState,
2305
- ...(diffraction ? { diffraction: { profile: diffraction, psf: diffractionPsf, execution: diffractionObjectSpace ? "object-space-convolution" : "environment-psf-quadrature" } } : {}) },
2488
+ ...(diffraction ? { diffraction: { profile: diffraction, psf: diffractionPsf, execution: diffractionObjectSpace ? "object-space-convolution" :
2489
+ defocusImageSpace ? "image-space-convolution" : "environment-psf-quadrature" } } : {}) },
2306
2490
  exposure: { mode, settings: { fNumber, shutterSeconds, iso: resolvedIso }, ev100: Math.log2(fNumber ** 2 / shutterSeconds), ...isoReport,
2307
2491
  metering, meteredSceneLuminanceCdPerSquareMeter: meteredLuminance, standardExposureDeviationStops: exposureDeviationStops,
2308
2492
  standardExposureIlluminanceLux: exposureIlluminanceLux, greyCardDeviationStops: exposureGreyCardDeviationStops, resolution },
@@ -2310,6 +2494,7 @@ export async function runCapture(options, run, viewOption, renderView) {
2310
2494
  illuminant: { id: illuminant.id, description: illuminant.description, illuminanceLux }, distanceM: subjectDistanceM,
2311
2495
  framedWidthM: 2 * metresPerFrameUnit, framedHeightM: 2 * metresPerFrameUnit * aspect, ...(chartReport ? { chart: chartReport } : {}), sourceSha256 },
2312
2496
  diagnostics: { providerEvaluationCount, highlight: developed.highlight, rendering: developed.diagnostics, ...(gamutMappingDiagnostics ? { gamutMapping: gamutMappingDiagnostics } : {}),
2497
+ ...(detailRequested ? { detail: Object.freeze(detailDiagnostics) } : {}),
2313
2498
  timingsMs: { raw: rawMs, whiteBalance: wbMs, development: devMs, jpeg: jpegMs, total: Date.now() - started,
2314
2499
  ...(diffraction && diffractionObjectSpace && !defocusSiteKernels ? { diffraction: diffractionMs } : {}), ...(defocusSiteKernels ? { defocus: defocusMs } : {}), ...(demosaicMs === undefined ? {} : { demosaic: demosaicMs }),
2315
2500
  ...(encodeMs === undefined ? {} : { encode: encodeMs }) },
@@ -2320,10 +2505,12 @@ export async function runCapture(options, run, viewOption, renderView) {
2320
2505
  "resolve" + (mode === "manual" ? "Manual" : mode === "aperture-priority" ? "AperturePriority" : mode === "shutter-priority" ? "ShutterPriority" : "ProgramAuto") + "ExposureMode",
2321
2506
  ...(rollingSchedule ? ["resolveElectronicRollingShutterExposureSchedule", "createRollingShutterCaptureExposureSchedule"] : []),
2322
2507
  ...(diffraction ? ["createDiffractionLimitedPsfProfile", ...(diffractionObjectSpace ? ["resolveLensPsfConvolutionKernels", ...(defocusSiteKernels ? [] : ["calculateIrradianceConvolution"])] : [])] : []),
2323
- ...(defocusSiteKernels ? ["calculateDefocusCircle", "defocus disc site kernel (object-space convolution)"] : []),
2508
+ ...(defocusSiteKernels ? ["calculateDefocusCircle", defocusImageSpace ? "defocus disc site kernel (image-space convolution)" : "defocus disc site kernel (object-space convolution)"] : []),
2324
2509
  ...(antiAliasing ? ["resolveAntiAliasingSpatialKernel"] : []), "resolveGenericIsoSignalChain", "createNativeEnvironmentRawTask", ...(needsAuto ? ["estimateAutoWhiteBalance"] : []),
2325
2510
  ...(builtInDemosaic ? ["createBuiltInBayerDemosaicProfiles"] : []), ...(ahd ? ["createAhdDemosaicTask"] : []),
2326
- ...(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"]),
2511
+ ...(ahd ? ["native RAW development operator (colour, after AHD)", ...detailContracts, ...(linearPath ? ["resolveRgbColorSpace", ...(perceptual ? ["prepareRgbGamutMapping"] : [])] : [sdrContract])] :
2512
+ linearPath ? ["native RAW development operator (linear, pre-clip)", ...detailContracts, "resolveRgbColorSpace", ...(perceptual ? ["prepareRgbGamutMapping"] : [])] :
2513
+ detailRequested ? ["native RAW development operator (linear, pre-clip)", ...detailContracts, sdrContract] : ["createNativeRawDevelopmentTask"]),
2327
2514
  ...(jpeg ? ["createPrintJpegTask"] : []), ...(png ? ["serializePngImage"] : []), ...(dng ? ["resolveSensorReadoutRegime", "serializeNativeRawDng"] : [])],
2328
2515
  limitations: ["Educational approximation: synthetic tier sensor values, idealized colorimetric sensor, planar Lambertian scene and point optics; not a calibrated camera emulation.",
2329
2516
  "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."] },