@photivra/engine 2.8.0 → 2.8.1

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.
@@ -74,7 +74,7 @@ import { calculateSensorSpectralQuadrature } from "../sensor/spectral-quadrature
74
74
  import { createDiffractionLimitedPsfProfile } from "../optics/diffraction-psf-profile.js";
75
75
  import { createDiffractedPlanarProvider } from "./diffraction-provider.js";
76
76
  import { resolveGenericPrimeLens } from "../equipment/generic-prime-lenses.js";
77
- import { calculateEnvironmentSensorPhotoSignal } from "../sensor/environment-photo-signal.js";
77
+ import { executeEnvironmentSensorPhotoSignal, planEnvironmentSensorPhotoSignal } from "../sensor/environment-photo-signal.js";
78
78
  import { calculateSiteEffectiveSaturationCode } from "../capture/raw-white-level.js";
79
79
  import { prepareSensorColorDevelopment } from "../color/sensor-color-development.js";
80
80
  import { resolveAntiAliasingSpatialKernel } from "../sensor/optical-stack.js";
@@ -142,6 +142,9 @@ const AHD_VALUE_REACH = 3;
142
142
  /** Anti-aliasing filter limits: split points, and the largest offset in sites (bounds the scene work and the convolution margin). */
143
143
  const AA_MAXIMUM_COMPONENTS = 16;
144
144
  const AA_MAXIMUM_OFFSET_SITES = 4;
145
+ /** Channel responses kept across calls (`axisChannelResponse`): one entry per distinct sensor, exposure and optics state. */
146
+ const CHANNEL_RESPONSE_CACHE_SIZE = 8;
147
+ const channelResponseCache = new Map();
145
148
  /** Default birefringent separation in sites: the two-spot MTF |cos(π·f·d)| then has its first zero at Nyquist, f = 1/(2·pitch). */
146
149
  const AA_DEFAULT_SEPARATION_SITES = 1;
147
150
  /** Parses `sensor.antiAliasing` into engine point-splitting components (offsets in µm, +x right, +y down). */
@@ -1313,13 +1316,16 @@ export async function runCapture(options, run, viewOption, renderView) {
1313
1316
  const yieldControl = createHostYield().yieldControl;
1314
1317
  const perSite = queriesPerSite * (diffraction && psf === diffractionPsf ? (2 * diffraction.kernelRadiusSamples + 1) ** 2 : 1);
1315
1318
  let diffractionMs = 0, demosaicMs, encodeMs;
1316
- const rawProvider = diffraction && diffractionObjectSpace ? createDiffractedPlanarProvider({ base: provider, psf: diffractionPsf.configuration.psf,
1319
+ const diffractedProvider = (window) => diffraction && diffractionObjectSpace ? createDiffractedPlanarProvider({ base: provider, psf: diffractionPsf.configuration.psf,
1317
1320
  wavelengthsNm: diffraction.profile.axes.wavelengthNm, kernelRadius: diffraction.kernelRadiusSamples, nativeWidth, nativeHeight, outputWidth: width,
1318
1321
  sitePitchMicrometers: pitch, distanceM: subjectDistanceM, metresPerFrameUnit, onConvolution: (ms) => { diffractionMs += ms; },
1319
1322
  // Region (2.6): convolve only the lattice the region's native sites query; the values are the whole render's.
1320
- ...(regionNative ? { window: regionNative } : {}),
1323
+ ...(window ? { window } : {}),
1321
1324
  // The environment queries each site at each aperture node minus each split offset (sites, +x right, +y down).
1322
1325
  ...(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;
1326
+ // Built on first use: a capture that takes the tabulated path (#394 step 2) never queries it, and its kernels cost a setup.
1327
+ let rawProviderBuilt;
1328
+ const rawProvider = () => (rawProviderBuilt ??= diffractedProvider(regionNative));
1323
1329
  if (antiAliasing && perSite > 100_000)
1324
1330
  fail("sensor.antiAliasing with these options needs " + perSite + " scene queries per site; the environment admits at most 100000.");
1325
1331
  const evaluations = nativeWidth * nativeHeight * perSite;
@@ -1335,7 +1341,7 @@ export async function runCapture(options, run, viewOption, renderView) {
1335
1341
  // is linear in the radiance node values and, for this planar scene, the same at every site (paraxial π/(4N_w²)).
1336
1342
  const cx = Math.floor(nativeWidth / 2) & ~1, cy = Math.floor(nativeHeight / 2) & ~1;
1337
1343
  const channelSites = { red: cy * nativeWidth + cx, green: cy * nativeWidth + Math.min(cx + 1, nativeWidth - 1), blue: Math.min(cy + 1, nativeHeight - 1) * nativeWidth + Math.min(cx + 1, nativeWidth - 1) };
1338
- const electronsPerRadiance = {};
1344
+ const environments = {};
1339
1345
  for (const channel of CHANNELS) {
1340
1346
  if (channelAt(channelSites[channel] % nativeWidth, Math.floor(channelSites[channel] / nativeWidth)) !== channel)
1341
1347
  fail(caller + " needs at least a 2 × 2 native raster.");
@@ -1346,13 +1352,36 @@ export async function runCapture(options, run, viewOption, renderView) {
1346
1352
  environment.temporalSampleCount = 1;
1347
1353
  delete environment.pupil;
1348
1354
  delete environment.lensFieldMapping;
1349
- const electrons = (radiance) => calculateEnvironmentSensorPhotoSignal({ ...environment,
1350
- evaluateRadiance: (request) => ({ ...provider.evaluateRadiance(request), spectralRadianceWattsPerSquareMeterSteradianNanometer: radiance(request.wavelengthNanometers) })
1351
- }).value
1352
- .photo.value.photoSignal.expectedGeneratedElectronCount;
1353
- const nodes = new Set();
1354
- electrons((nm) => { nodes.add(nm); return 1; });
1355
- electronsPerRadiance[channel] = new Map([...nodes].sort((a, b) => a - b).map((node) => [node, electrons((nm) => (nm === node ? 1 : 0))]));
1355
+ delete environment.evaluateRadiance;
1356
+ delete environment.evaluateApertureRadiance;
1357
+ environments[channel] = environment;
1358
+ }
1359
+ // The responses are a function of these records alone (the radiance is replaced per node), and a request split into region
1360
+ // tiles builds the same records for every tile, so they are kept across calls (the last CHANNEL_RESPONSE_CACHE_SIZE).
1361
+ const key = JSON.stringify(CHANNELS.map((c) => environments[c]));
1362
+ const copy = (responses) => Object.fromEntries(CHANNELS.map((c) => [c, new Map(responses[c])]));
1363
+ const cached = channelResponseCache.get(key);
1364
+ let electronsPerRadiance;
1365
+ if (cached) {
1366
+ channelResponseCache.delete(key);
1367
+ channelResponseCache.set(key, cached);
1368
+ electronsPerRadiance = copy(cached);
1369
+ }
1370
+ else {
1371
+ electronsPerRadiance = {};
1372
+ for (const channel of CHANNELS) {
1373
+ // Planned once per channel and executed per spectral node: calculateEnvironmentSensorPhotoSignal is this plan and execution.
1374
+ const photoPlan = planEnvironmentSensorPhotoSignal(environments[channel]);
1375
+ const electrons = (radiance) => executeEnvironmentSensorPhotoSignal(photoPlan, (request) => ({ ...provider.evaluateRadiance(request), spectralRadianceWattsPerSquareMeterSteradianNanometer: radiance(request.wavelengthNanometers) })).value
1376
+ .photo.value.photoSignal.expectedGeneratedElectronCount;
1377
+ const nodes = new Set();
1378
+ electrons((nm) => { nodes.add(nm); return 1; });
1379
+ electronsPerRadiance[channel] = new Map([...nodes].sort((a, b) => a - b).map((node) => [node, electrons((nm) => (nm === node ? 1 : 0))]));
1380
+ }
1381
+ // Callers get their own maps; the cached ones are never handed out.
1382
+ channelResponseCache.set(key, copy(electronsPerRadiance));
1383
+ if (channelResponseCache.size > CHANNEL_RESPONSE_CACHE_SIZE)
1384
+ channelResponseCache.delete(channelResponseCache.keys().next().value);
1356
1385
  }
1357
1386
  const wavelengths = [...new Set(CHANNELS.flatMap((c) => [...electronsPerRadiance[c].keys()]))].sort((a, b) => a - b);
1358
1387
  // 2. Electrons → normalized RAW signal: (code − black)/(white − black) = e·d/(G·(W − B)) for the linear readout
@@ -1482,8 +1511,8 @@ export async function runCapture(options, run, viewOption, renderView) {
1482
1511
  };
1483
1512
  const rawTick = progress("raw", regionNative ? regionNative.height * Math.ceil(regionNative.width / tileWidth) : plan.tileCount);
1484
1513
  // #394: siteInput builds fresh site records per call and never changes them, so the native task plans them without a copy.
1485
- const rawCallbacks = { readTile: markInternalNativeTileReader(async (request) => { const tile = await readTile(request); rawTick(); return tile; }),
1486
- evaluateRadiance: rawProvider.evaluateRadiance, ...(pupil ? { evaluateApertureRadiance: provider.evaluateApertureRadiance } : {}), yieldControl };
1514
+ const rawCallbacks = () => ({ readTile: markInternalNativeTileReader(async (request) => { const tile = await readTile(request); rawTick(); return tile; }),
1515
+ evaluateRadiance: rawProvider().evaluateRadiance, ...(pupil ? { evaluateApertureRadiance: provider.evaluateApertureRadiance } : {}), yieldControl });
1487
1516
  // #394 step 2: an engine-built planar scene (chart preset or caller material scene; still, rectilinear, no distortion, vignetting,
1488
1517
  // pupil rays or caller PSF) takes its expected electrons from the tabulated signal, verified per CFA phase against the chain.
1489
1518
  const tabulatedScene = chart !== undefined || callerScene?.kind === "material-function";
@@ -1498,7 +1527,10 @@ export async function runCapture(options, run, viewOption, renderView) {
1498
1527
  list: [...sceneMaterials.map((m, index) => (m.kind === "neutral" ? { neutral: m.reflectance } : { radiance: (nm) => materialRadiance(index, nm) })),
1499
1528
  ...(chartSize ? [{ neutral: chartSize.surroundReflectance }] : [])] },
1500
1529
  radianceAt: provider.radianceAt, imagingArea, focalLengthMm, focusDistanceM, subjectDistanceM, metresPerFrameUnit, caller: "simulateCapture" });
1501
- tabulated = await renderTabulatedNativeRaw({ input: environmentRaw, region: regionNative, siteInput, evaluateRadiance: rawProvider.evaluateRadiance,
1530
+ // The verification reads only the region's top-left 2 × 2 block, so its diffraction convolves only that block's lattice; the
1531
+ // window never changes a value (see diffraction-provider.ts), so the chain it runs is the whole render's.
1532
+ const verificationProvider = diffractedProvider({ x: regionNative?.x ?? 0, y: regionNative?.y ?? 0, width: 2, height: 2 });
1533
+ tabulated = await renderTabulatedNativeRaw({ input: environmentRaw, region: regionNative, siteInput, evaluateRadiance: verificationProvider.evaluateRadiance,
1502
1534
  electrons: createDisplayViewExpectedSignal(signalInput), onTile: rawTick, tileWidth, yieldControl: () => yieldControl(), checkAbort });
1503
1535
  }
1504
1536
  let rawOutput;
@@ -1512,8 +1544,8 @@ export async function runCapture(options, run, viewOption, renderView) {
1512
1544
  "diagnostics.providerEvaluationCount counts those sites' evaluator calls and the table's scene lookups.");
1513
1545
  }
1514
1546
  else {
1515
- const rawTask = regionNative ? createNativeEnvironmentRawRegionTask(environmentRaw, regionNative, rawCallbacks) :
1516
- createNativeEnvironmentRawTask(environmentRaw, rawCallbacks);
1547
+ const rawTask = regionNative ? createNativeEnvironmentRawRegionTask(environmentRaw, regionNative, rawCallbacks()) :
1548
+ createNativeEnvironmentRawTask(environmentRaw, rawCallbacks());
1517
1549
  await runTask(rawTask);
1518
1550
  rawOutput = rawTask.takeOutput();
1519
1551
  }
@@ -1562,7 +1594,7 @@ export async function runCapture(options, run, viewOption, renderView) {
1562
1594
  // retained optical stage, and is faster: retaining the optical stage takes the first pass off the lean executor.
1563
1595
  const wbTask = createNativeEnvironmentRawTask(finalRawInput, {
1564
1596
  readTile: markInternalNativeTileReader(async (request) => { const tile = await readTile(request); wbTick(); return tile; }),
1565
- evaluateRadiance: rawProvider.evaluateRadiance, ...(pupil ? { evaluateApertureRadiance: provider.evaluateApertureRadiance } : {}), yieldControl
1597
+ evaluateRadiance: rawProvider().evaluateRadiance, ...(pupil ? { evaluateApertureRadiance: provider.evaluateApertureRadiance } : {}), yieldControl
1566
1598
  });
1567
1599
  await runTask(wbTask);
1568
1600
  rawOutput = wbTask.takeOutput();