@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.
- package/CHANGELOG.md +47 -1
- package/CITATION.cff +1 -1
- package/README.md +6 -6
- package/SECURITY.md +1 -1
- package/THIRD_PARTY.md +14 -0
- package/dist/capture/native-environment-lean.js +1 -1
- package/dist/capture/native-environment-lean.js.map +1 -1
- package/dist/capture/native-raw-development.d.ts +15 -0
- package/dist/capture/native-raw-development.js +12 -6
- package/dist/capture/native-raw-development.js.map +1 -1
- package/dist/capture/raw-development-kernel.d.ts +10 -3
- package/dist/capture/raw-development-kernel.js +25 -6
- package/dist/capture/raw-development-kernel.js.map +1 -1
- package/dist/core/internal-radiance-evaluator.d.ts +5 -2
- package/dist/core/internal-radiance-evaluator.js.map +1 -1
- package/dist/core/version.d.ts +1 -1
- package/dist/core/version.js +1 -1
- package/dist/core/version.js.map +1 -1
- package/dist/facade/defocus-image-space-provider.d.ts +109 -0
- package/dist/facade/defocus-image-space-provider.js +359 -0
- package/dist/facade/defocus-image-space-provider.js.map +1 -0
- package/dist/facade/diffraction-provider.d.ts +16 -0
- package/dist/facade/diffraction-provider.js +3 -3
- package/dist/facade/diffraction-provider.js.map +1 -1
- package/dist/facade/display-view.d.ts +21 -0
- package/dist/facade/display-view.js +66 -8
- package/dist/facade/display-view.js.map +1 -1
- package/dist/facade/simulate-capture.d.ts +111 -2
- package/dist/facade/simulate-capture.js +220 -44
- package/dist/facade/simulate-capture.js.map +1 -1
- package/dist/facade/simulate-display-view.js +7 -3
- package/dist/facade/simulate-display-view.js.map +1 -1
- package/dist/gpu/display-view-quality-gpu.d.ts +2 -0
- package/dist/gpu/display-view-quality-gpu.js +57 -13
- package/dist/gpu/display-view-quality-gpu.js.map +1 -1
- package/dist/gpu/display-view-speed-gpu.d.ts +2 -0
- package/dist/gpu/display-view-speed-gpu.js +45 -16
- package/dist/gpu/display-view-speed-gpu.js.map +1 -1
- package/dist/gpu/image-kernel-accelerator.js +3 -0
- package/dist/gpu/image-kernel-accelerator.js.map +1 -1
- package/dist/gpu/wgsl-display-view-quality-kernel.d.ts +4 -0
- package/dist/gpu/wgsl-display-view-quality-kernel.js +53 -2
- package/dist/gpu/wgsl-display-view-quality-kernel.js.map +1 -1
- package/dist/gpu/wgsl-display-view-speed-kernel.d.ts +15 -0
- package/dist/gpu/wgsl-display-view-speed-kernel.js +42 -1
- package/dist/gpu/wgsl-display-view-speed-kernel.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/optics/defocus-disc-kernel.d.ts +3 -2
- package/dist/optics/defocus-disc-kernel.js +3 -2
- package/dist/optics/defocus-disc-kernel.js.map +1 -1
- package/dist/optics/sensor-environment-query.d.ts +31 -0
- package/dist/optics/sensor-environment-query.js +45 -13
- package/dist/optics/sensor-environment-query.js.map +1 -1
- package/dist/processing/development-detail.d.ts +132 -0
- package/dist/processing/development-detail.js +325 -0
- package/dist/processing/development-detail.js.map +1 -0
- package/docs/API_REFERENCE.md +6 -1
- package/docs/QUICK_START.md +190 -12
- package/docs/USAGE.md +14 -3
- package/docs/api/capture-native-raw-development.md +16 -0
- package/docs/api/core-version.md +1 -1
- package/docs/api/exports.json +27 -2
- package/docs/api/facade-simulate-capture.md +128 -3
- package/docs/api/optics-defocus-disc-kernel.md +3 -2
- 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
|
|
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
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
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
|
|
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
|
|
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
|
|
1802
|
-
//
|
|
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) =>
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
wavelengthsNm:
|
|
1875
|
-
sitePitchMicrometers: pitch, distanceM: subjectDistanceM, metresPerFrameUnit, onConvolution: (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
|
-
|
|
1879
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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"] : []), ...(
|
|
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" :
|
|
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"] : [])] : [
|
|
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."] },
|