@photivra/engine 2.15.0 → 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 (67) hide show
  1. package/CHANGELOG.md +47 -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 +111 -2
  29. package/dist/facade/simulate-capture.js +220 -44
  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 +2 -2
  48. package/dist/index.js +1 -1
  49. package/dist/index.js.map +1 -1
  50. package/dist/optics/defocus-disc-kernel.d.ts +3 -2
  51. package/dist/optics/defocus-disc-kernel.js +3 -2
  52. package/dist/optics/defocus-disc-kernel.js.map +1 -1
  53. package/dist/optics/sensor-environment-query.d.ts +31 -0
  54. package/dist/optics/sensor-environment-query.js +45 -13
  55. package/dist/optics/sensor-environment-query.js.map +1 -1
  56. package/dist/processing/development-detail.d.ts +132 -0
  57. package/dist/processing/development-detail.js +325 -0
  58. package/dist/processing/development-detail.js.map +1 -0
  59. package/docs/API_REFERENCE.md +6 -1
  60. package/docs/QUICK_START.md +190 -12
  61. package/docs/USAGE.md +14 -3
  62. package/docs/api/capture-native-raw-development.md +16 -0
  63. package/docs/api/core-version.md +1 -1
  64. package/docs/api/exports.json +27 -2
  65. package/docs/api/facade-simulate-capture.md +128 -3
  66. package/docs/api/optics-defocus-disc-kernel.md +3 -2
  67. 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,
@@ -583,7 +585,8 @@ async function developLinearSrgb(input, afterRow, ahd) {
583
585
  const native = plan.exposure.geometry.nativeRaster, g = plan.exposure.resolvedGeometry, active = g.activeCapture.nativeRect;
584
586
  checkNativeRawIntegrity(raw, native.pixelWidth, layout.repeatWidthSites, layout.repeatHeightSites, 0, native.pixelHeight);
585
587
  const developer = createRawPixelDeveloper({ nativeWidth: native.pixelWidth, repeatWidth: layout.repeatWidthSites, repeatHeight: layout.repeatHeightSites,
586
- 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);
587
590
  const width = v.raster.pixelWidth, height = v.raster.pixelHeight, rgb = new Float64Array(width * height * 3), row = new Float64Array(width * 3);
588
591
  const at = (x, y) => {
589
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 });
@@ -612,7 +615,7 @@ async function developLinearSrgb(input, afterRow, ahd) {
612
615
  async function developAhd(v, raw, repeatWidth, repeatHeight, width, height, at, afterRow, host) {
613
616
  const native = v.plan.exposure.geometry.nativeRaster, W = native.pixelWidth, H = native.pixelHeight;
614
617
  const source = { nativeWidth: W, repeatWidth, repeatHeight, codes: raw.codes, blackLevels: raw.blackLevels,
615
- saturationFlags: raw.saturationFlags, whiteByPhase: raw.effectiveSaturationCodes };
618
+ saturationFlags: raw.saturationFlags, whiteByPhase: raw.effectiveSaturationCodes, ...(v.exposureGain === 1 ? {} : { exposureGain: v.exposureGain }) };
616
619
  const samples = new Float32Array(W * H), saturated = new Uint8Array(W * H);
617
620
  for (let y = 0; y < H; y++)
618
621
  for (let x = 0; x < W; x++) {
@@ -860,6 +863,30 @@ function resolveDefocusOption(input) {
860
863
  "diffraction kernel convolved with the disc, a further approximation)" : "") + "; see the view.defocus assumption.");
861
864
  return disc;
862
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
+ }
863
890
  // The capture (#492, step 2a): an object-space convolution of the scene radiance on the site lattice.
864
891
  assume("lens.defocus", "model", { ...value, execution: "object-space-convolution", combinedWithDiffraction: input.diffraction }, model +
865
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, " +
@@ -869,6 +896,10 @@ function resolveDefocusOption(input) {
869
896
  "The disc kernel is a sampling of the disc on the site lattice; lens.pupilSampling (ray-sampled, no diffraction) is the independent reference.");
870
897
  return disc;
871
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
+ }
872
903
  /** Internal: passed as the view of a "display-view" run to resolve the request without a view (`prepareDisplayView`, #387). */
873
904
  export const DISPLAY_VIEW_WITHOUT_VIEW = Symbol("display view without view");
874
905
  /**
@@ -1042,8 +1073,8 @@ export async function runCapture(options, run, viewOption, renderView) {
1042
1073
  // through one linear map (still camera, rectilinear, no distortion or field throughput); otherwise per tap.
1043
1074
  const diffractionObjectSpace = diffractionRadiusOption !== undefined && !rotating && !lensFieldMapping && !vignetting;
1044
1075
  // Output and sensor
1045
- const outputOptions = allowKeys(top.output, "output", ["width", "height", "format", "toneCurve", "toneCurveParameters", "highlightMode", "whiteBalance", "jpegQuantizationStep",
1046
- "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"]);
1047
1078
  // Opt-in large images: a caller-raised native pixel limit. Without it the 4094-pixel sides are unchanged.
1048
1079
  const raisedPixels = outputOptions.maximumPixels === undefined ? undefined : integerIn(outputOptions.maximumPixels, "output.maximumPixels", 1, NATIVE_RAW_LIMITS.ceilingPixels);
1049
1080
  const maximumSide = raisedPixels === undefined ? 4094 : NATIVE_RAW_LIMITS.maximumDimension;
@@ -1097,6 +1128,19 @@ export async function runCapture(options, run, viewOption, renderView) {
1097
1128
  if (linearPath && toneCurve !== "identity")
1098
1129
  fail("output.toneCurve other than identity is available only for sRGB output; wide-gamut encodings are converted from the linear developed colour.");
1099
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;
1100
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).");
1101
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;
1102
1146
  if (format !== "jpeg" && outputOptions.jpegQuantizationStep !== undefined)
@@ -1128,6 +1172,8 @@ export async function runCapture(options, run, viewOption, renderView) {
1128
1172
  }
1129
1173
  }
1130
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 })));
1131
1177
  // Display view: its own parameters, and the capture options it reproduces, checked before any scene work.
1132
1178
  const parsedView = displayView && viewOption !== DISPLAY_VIEW_WITHOUT_VIEW ? parseDisplayViewOption(viewOption, width, height) : undefined;
1133
1179
  if (displayView) {
@@ -1150,6 +1196,8 @@ export async function runCapture(options, run, viewOption, renderView) {
1150
1196
  unsupported("output.toneCurve " + String(outputOptions.toneCurve), "luminance tone curves couple the channels; the view supports identity and positive-reinhard-per-channel.");
1151
1197
  if (outputOptions.highlightMode === "reconstruct")
1152
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.");
1153
1201
  }
1154
1202
  const colorSamplingProfile = { schemaVersion: "0.1.0", profileId: IDS.cfa, evidence: FACADE_EVIDENCE, coordinateSystem: "native-sensor-color-sampling-site-index",
1155
1203
  layout: { kind: "periodic-mosaic", repeatWidthSites: 2, repeatHeightSites: 2, siteChannelIds: ["red", "green", "green", "blue"], anchor: "native-sensor-top-left-site" } };
@@ -1218,6 +1266,37 @@ export async function runCapture(options, run, viewOption, renderView) {
1218
1266
  // region results without diffraction stay byte-identical to 2.5.0.
1219
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." :
1220
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
+ }
1221
1300
  if (raisedPixels !== undefined) {
1222
1301
  if (nativeWidth > NATIVE_RAW_LIMITS.maximumDimension || nativeHeight > NATIVE_RAW_LIMITS.maximumDimension || nativeWidth * nativeHeight > raisedPixels) {
1223
1302
  fail("output.maximumPixels (" + raisedPixels + ") must cover the native sensor raster of " + nativeWidth + " × " + nativeHeight +
@@ -1511,15 +1590,27 @@ export async function runCapture(options, run, viewOption, renderView) {
1511
1590
  const exposureDeviationStops = standardExposureDeviationStops(fNumber, shutterSeconds, resolvedIso, meteredLuminance);
1512
1591
  const exposureIlluminanceLux = standardExposureIlluminanceLux(fNumber, shutterSeconds, resolvedIso, meteredLuminance, illuminanceLux, callerScene?.kind !== "radiance-function");
1513
1592
  const exposureGreyCardDeviationStops = callerScene?.kind === "radiance-function" ? null : greyCardDeviationStops(fNumber, shutterSeconds, resolvedIso, illuminanceLux);
1514
- const defocusDisc = defocusModel === undefined ? undefined :
1515
- resolveDefocusOption({ run, assume, focalLengthMm, fNumber, focusDistanceM, subjectDistanceM, pitch, diffraction: diffractionRadiusOption !== undefined });
1516
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.
1517
- if (defocusDisc) {
1518
- const reason = rotating ? "camera rotation (motion.angularVelocityRadPerSec)" : lensFieldMapping ? "lens.projection or lens.distortion" : vignetting ? "lens.vignetting" : undefined;
1519
- if (reason !== undefined)
1520
- fail("simulateCapture does not support lens.defocus out of focus with " + reason + ": the defocus disc is applied as an object-space convolution " +
1521
- "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.");
1522
- }
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;
1523
1614
  // #492: the disc on the site lattice (radius 0 for a disc inside one cell: the identity, still applied).
1524
1615
  const defocusKernel = defocusDisc && !displayView ? createDefocusDiscSiteKernel(defocusDisc.diameterSites) ?? undefined : undefined;
1525
1616
  // The display view's disc (#492, step 2b): its expected-signal tables apply it (simulate-display-view.ts).
@@ -1650,9 +1741,10 @@ export async function runCapture(options, run, viewOption, renderView) {
1650
1741
  ...(outputOptions.toneCurveParameters === undefined ? {} : { toneCurveParameters: outputOptions.toneCurveParameters }),
1651
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" };
1652
1743
  if (!linearPath)
1653
- 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.");
1654
1746
  else
1655
- 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.");
1656
1748
  // Rendering choices are validated before any radiance work, so a bad tone curve fails fast.
1657
1749
  parseSdrRenderingProfile(rendering);
1658
1750
  if (highlightMode !== "clip" && highlightMode !== "reconstruct")
@@ -1740,7 +1832,8 @@ export async function runCapture(options, run, viewOption, renderView) {
1740
1832
  fidelity: { spectral: "wavelength-resolved", material: "spectral-data", visibility: "resolved", directTransport: "approximation", indirectTransport: "not-modeled" },
1741
1833
  wavelengthChangingTransportModeled: false, volumetricTransportModeled: false, polarizationModeled: false, evidence, limitations: ["Engine-authored planar chart or caller scene function.",
1742
1834
  ...(diffractionObjectSpace ? ["Radiance is the scene radiance convolved with the lens diffraction PSF mapped onto the scene plane (object-space convolution, request.diffraction)."] : []),
1743
- ...(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)."] : [])] }
1744
1837
  };
1745
1838
  const lensId = lensExposure.profileId;
1746
1839
  const opticsProfile = { schemaVersion: "0.1.0", profileId: lensId + ":facade-transmission", profileVersion: SIMULATE_CAPTURE_FACADE_VERSION, lensProfileId: lensId,
@@ -1790,16 +1883,18 @@ export async function runCapture(options, run, viewOption, renderView) {
1790
1883
  assume("lens.diffraction", "model", { model: diffraction.model, aperture: "circular-aperture", fNumber, workingFNumber: diffraction.workingFNumber,
1791
1884
  kernelRadiusSamples, samplePitchMicrometers: pitch, maximumTruncatedEnergyFraction: diffraction.maximumTruncatedEnergyFraction,
1792
1885
  truncatedEnergyByWavelength: diffraction.wavelengths.map((w) => ({ wavelengthNm: w.wavelengthNm, truncatedEnergyFraction: w.truncatedEnergyFraction })),
1793
- 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). " +
1794
1887
  "One kernel per spectral node, binned into site-sized cells over a " + (2 * kernelRadiusSamples + 1) + " × " + (2 * kernelRadiusSamples + 1) + " window" +
1795
1888
  (diffractionRadiusOption !== null ? "" : thirdRingSites > 32 ? " (capped at 32 sites; the third dark ring at the longest node needs " + thirdRingSites + ")" :
1796
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. " +
1797
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). " :
1798
- "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. ") +
1799
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."));
1800
1895
  }
1801
- // #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)
1802
- // 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).
1803
1898
  let defocusSiteKernels;
1804
1899
  if (defocusKernel) {
1805
1900
  if (diffraction) {
@@ -1823,8 +1918,26 @@ export async function runCapture(options, run, viewOption, renderView) {
1823
1918
  else
1824
1919
  defocusSiteKernels = { radius: defocusKernel.radius, wavelengthsNm: quadratureWavelengths(), at: () => defocusKernel.weights };
1825
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
+ }
1826
1937
  const psf = lensPsf ?? (diffractionObjectSpace ? { kind: "not-applied", evidence,
1827
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) ??
1828
1941
  diffractionPsf ?? (defocusSiteKernels ? { kind: "not-applied", evidence,
1829
1942
  limitation: "No environment PSF: lens defocus is applied upstream by the scene provider as an object-space convolution (lens.defocus)." } : undefined) ??
1830
1943
  { kind: "not-applied", evidence, limitation: "No lens PSF (point optics)." };
@@ -1865,18 +1978,23 @@ export async function runCapture(options, run, viewOption, renderView) {
1865
1978
  const yieldControl = createHostYield().yieldControl;
1866
1979
  const perSite = queriesPerSite * (diffraction && psf === diffractionPsf ? (2 * diffraction.kernelRadiusSamples + 1) ** 2 : 1);
1867
1980
  let diffractionMs = 0, defocusMs = 0, demosaicMs, encodeMs;
1868
- const diffractedProvider = (window) => defocusSiteKernels ? createDiffractedPlanarProvider({ base: provider, siteKernels: defocusSiteKernels.at,
1869
- wavelengthsNm: defocusSiteKernels.wavelengthsNm, kernelRadius: defocusSiteKernels.radius, nativeWidth, nativeHeight, outputWidth: width,
1870
- sitePitchMicrometers: pitch, distanceM: subjectDistanceM, metresPerFrameUnit, onConvolution: (ms) => { defocusMs += ms; },
1871
- ...(window ? { window } : {}),
1872
- ...(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 }))) } : {}) }) :
1873
- diffraction && diffractionObjectSpace ? createDiffractedPlanarProvider({ base: provider, psf: diffractionPsf.configuration.psf,
1874
- wavelengthsNm: diffraction.profile.axes.wavelengthNm, kernelRadius: diffraction.kernelRadiusSamples, nativeWidth, nativeHeight, outputWidth: width,
1875
- sitePitchMicrometers: pitch, distanceM: subjectDistanceM, metresPerFrameUnit, onConvolution: (ms) => { diffractionMs += ms; },
1876
- // 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; },
1877
1989
  ...(window ? { window } : {}),
1878
- // The environment queries each site at each aperture node minus each split offset (sites, +x right, +y down).
1879
- ...(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;
1880
1998
  // Built on first use: a capture that takes the tabulated path (#394 step 2) never queries it, and its kernels cost a setup.
1881
1999
  let rawProviderBuilt;
1882
2000
  const rawProvider = () => (rawProviderBuilt ??= diffractedProvider(regionNative));
@@ -1951,7 +2069,7 @@ export async function runCapture(options, run, viewOption, renderView) {
1951
2069
  // Display view: the renderer that simulate-display-view.ts passes in reads this request's resolved state, so a bundle
1952
2070
  // that only captures does not contain the display view.
1953
2071
  if (displayView)
1954
- 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 });
1955
2073
  // Ideal reference (2.3): the scene without camera effects, at the capture's own exposure, white balance and encoding.
1956
2074
  if (idealReference) {
1957
2075
  const referenceStarted = clockMs();
@@ -1987,7 +2105,8 @@ export async function runCapture(options, run, viewOption, renderView) {
1987
2105
  sum[2] += s[2];
1988
2106
  }
1989
2107
  // Normalized RAW values saturate at the white level, as in development.
1990
- 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)));
1991
2110
  development.developInto(c[0], c[1], c[2], rgb, (py * outputWidth + px) * 3, counts);
1992
2111
  }
1993
2112
  if ((py & 15) === 15) {
@@ -2193,10 +2312,52 @@ export async function runCapture(options, run, viewOption, renderView) {
2193
2312
  // Development
2194
2313
  const development = { phaseProfiles, colorProfile,
2195
2314
  whiteBalance: developmentWhiteBalance, rendering, highlightMode, ...(mirrorBorder ? { borderHandling: "mirror" } : {}),
2315
+ ...(exposureCompensationStops === 0 ? {} : { exposureCompensationStops }),
2196
2316
  maximumRetainedPayloadBytes: developmentPlan.outputBytes + outputWidth * outputHeight * 3 * (rendering.bitDepth / 8) };
2197
2317
  let developed;
2198
2318
  let gamutMappingDiagnostics;
2199
- 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)) {
2200
2361
  let lastYield = Date.now();
2201
2362
  const linear = await developLinearSrgb({ ...development, raw: rawOutput.raw }, async (completed, total) => {
2202
2363
  onProgress?.({ stage: "development", completedTileCount: completed, totalTileCount: total });
@@ -2205,21 +2366,29 @@ export async function runCapture(options, run, viewOption, renderView) {
2205
2366
  checkAbort();
2206
2367
  lastYield = Date.now();
2207
2368
  }
2208
- }, { yieldControl: () => yieldControl(), checkAbort, onDemosaicMs: (ms) => { demosaicMs = ms; } });
2209
- 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) {
2210
2379
  // The native development's SDR kernel, row by row, on the AHD-developed linear sRGB.
2211
2380
  const maximum = 2 ** rendering.bitDepth - 1, tone = prepareSdrPixelTone(rendering), scratch = new Float64Array(3);
2212
2381
  const codes = rendering.bitDepth === 8 ? new Uint8Array(outputWidth * outputHeight * 3) : new Uint16Array(outputWidth * outputHeight * 3);
2213
2382
  const counts = { negative: 0, above: 0, changed: 0, low: 0, high: 0, outOfGamut: false };
2214
2383
  for (let y = 0; y < outputHeight; y++)
2215
- 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);
2216
2385
  developed = { raw: rawOutput.raw, integerSamples: codes, highlight: linear.highlight, diagnostics: { renderingNegativeSampleCount: counts.negative,
2217
2386
  renderingAboveReferenceSampleCount: counts.above, toneChangedSampleCount: counts.changed, gamutClippedLowSampleCount: counts.low,
2218
2387
  gamutClippedHighSampleCount: counts.high, captureSaturation: "consumed-in-raw-development" } };
2219
2388
  }
2220
2389
  else {
2221
2390
  const mapping = perceptual ? prepareRgbGamutMapping({ colorSpace: colorEncoding, method: "cielab-hue-preserving-chroma-compression", kneeFraction: PERCEPTUAL_GAMUT_KNEE_FRACTION }) : undefined;
2222
- const encoded = encodeSdrFromLinear(linear.rgb, colorEncoding, pngBitDepth, mapping);
2391
+ const encoded = encodeSdrFromLinear(rgb, colorEncoding, pngBitDepth, mapping);
2223
2392
  developed = { raw: rawOutput.raw, integerSamples: encoded.samples, highlight: linear.highlight, diagnostics: encoded.diagnostics };
2224
2393
  if (mapping)
2225
2394
  gamutMappingDiagnostics = { method: "cielab-hue-preserving-chroma-compression", kneeFraction: PERCEPTUAL_GAMUT_KNEE_FRACTION,
@@ -2245,7 +2414,7 @@ export async function runCapture(options, run, viewOption, renderView) {
2245
2414
  });
2246
2415
  {
2247
2416
  const mapping = perceptual ? prepareRgbGamutMapping({ colorSpace: colorEncoding, method: "cielab-hue-preserving-chroma-compression", kneeFraction: PERCEPTUAL_GAMUT_KNEE_FRACTION }) : undefined;
2248
- const encoded = encodeSdrFromLinear(linear.rgb, colorEncoding, pngBitDepth, mapping);
2417
+ const encoded = encodeSdrFromLinear(correctLinear(linear.rgb), colorEncoding, pngBitDepth, mapping);
2249
2418
  developed = { raw: rawOutput.raw, integerSamples: encoded.samples, highlight: linear.highlight, diagnostics: encoded.diagnostics };
2250
2419
  if (mapping) {
2251
2420
  gamutMappingDiagnostics = { method: "cielab-hue-preserving-chroma-compression", kneeFraction: PERCEPTUAL_GAMUT_KNEE_FRACTION,
@@ -2306,14 +2475,18 @@ export async function runCapture(options, run, viewOption, renderView) {
2306
2475
  ...(rollingSchedule ? ["motion.shutter.rolling"] : []), ...(mirrorBorder ? ["output.borderHandling.mirror"] : []), ...(region ? ["output.region"] : []),
2307
2476
  ...(needsAuto ? ["output.whiteBalance.auto"] : []), ...(ahd ? ["output.demosaic.ahd"] : []),
2308
2477
  ...(highlightMode === "reconstruct" ? ["output.highlightMode.reconstruct"] : []), ...(wideGamut ? ["output.colorEncoding.wide-gamut"] : []),
2309
- ...(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";
2310
2482
  return Object.freeze({
2311
2483
  facadeVersion: SIMULATE_CAPTURE_FACADE_VERSION, engineApiVersion: ENGINE_API_VERSION,
2312
2484
  image: { width: outputWidth, height: outputHeight, format, samples: developed.integerSamples, ...(jpeg ? { jpeg } : {}), ...(png ? { png, colorEncoding: colorEncoding } : {}), ...(dng ? { dng: dng.bytes } : {}) },
2313
2485
  raw: developed.raw, plan: developmentPlan,
2314
2486
  ...(region && regionNative ? { region: { ...region, frameWidth: width, frameHeight: height, nativeRect: { ...regionNative } } } : {}),
2315
2487
  request: { environmentRaw: finalRawInput, siteInput, development, whiteBalanceState,
2316
- ...(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" } } : {}) },
2317
2490
  exposure: { mode, settings: { fNumber, shutterSeconds, iso: resolvedIso }, ev100: Math.log2(fNumber ** 2 / shutterSeconds), ...isoReport,
2318
2491
  metering, meteredSceneLuminanceCdPerSquareMeter: meteredLuminance, standardExposureDeviationStops: exposureDeviationStops,
2319
2492
  standardExposureIlluminanceLux: exposureIlluminanceLux, greyCardDeviationStops: exposureGreyCardDeviationStops, resolution },
@@ -2321,6 +2494,7 @@ export async function runCapture(options, run, viewOption, renderView) {
2321
2494
  illuminant: { id: illuminant.id, description: illuminant.description, illuminanceLux }, distanceM: subjectDistanceM,
2322
2495
  framedWidthM: 2 * metresPerFrameUnit, framedHeightM: 2 * metresPerFrameUnit * aspect, ...(chartReport ? { chart: chartReport } : {}), sourceSha256 },
2323
2496
  diagnostics: { providerEvaluationCount, highlight: developed.highlight, rendering: developed.diagnostics, ...(gamutMappingDiagnostics ? { gamutMapping: gamutMappingDiagnostics } : {}),
2497
+ ...(detailRequested ? { detail: Object.freeze(detailDiagnostics) } : {}),
2324
2498
  timingsMs: { raw: rawMs, whiteBalance: wbMs, development: devMs, jpeg: jpegMs, total: Date.now() - started,
2325
2499
  ...(diffraction && diffractionObjectSpace && !defocusSiteKernels ? { diffraction: diffractionMs } : {}), ...(defocusSiteKernels ? { defocus: defocusMs } : {}), ...(demosaicMs === undefined ? {} : { demosaic: demosaicMs }),
2326
2500
  ...(encodeMs === undefined ? {} : { encode: encodeMs }) },
@@ -2331,10 +2505,12 @@ export async function runCapture(options, run, viewOption, renderView) {
2331
2505
  "resolve" + (mode === "manual" ? "Manual" : mode === "aperture-priority" ? "AperturePriority" : mode === "shutter-priority" ? "ShutterPriority" : "ProgramAuto") + "ExposureMode",
2332
2506
  ...(rollingSchedule ? ["resolveElectronicRollingShutterExposureSchedule", "createRollingShutterCaptureExposureSchedule"] : []),
2333
2507
  ...(diffraction ? ["createDiffractionLimitedPsfProfile", ...(diffractionObjectSpace ? ["resolveLensPsfConvolutionKernels", ...(defocusSiteKernels ? [] : ["calculateIrradianceConvolution"])] : [])] : []),
2334
- ...(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)"] : []),
2335
2509
  ...(antiAliasing ? ["resolveAntiAliasingSpatialKernel"] : []), "resolveGenericIsoSignalChain", "createNativeEnvironmentRawTask", ...(needsAuto ? ["estimateAutoWhiteBalance"] : []),
2336
2510
  ...(builtInDemosaic ? ["createBuiltInBayerDemosaicProfiles"] : []), ...(ahd ? ["createAhdDemosaicTask"] : []),
2337
- ...(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"]),
2338
2514
  ...(jpeg ? ["createPrintJpegTask"] : []), ...(png ? ["serializePngImage"] : []), ...(dng ? ["resolveSensorReadoutRegime", "serializeNativeRawDng"] : [])],
2339
2515
  limitations: ["Educational approximation: synthetic tier sensor values, idealized colorimetric sensor, planar Lambertian scene and point optics; not a calibrated camera emulation.",
2340
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."] },