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