@photivra/engine 2.7.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +114 -1
  2. package/CITATION.cff +1 -1
  3. package/README.md +6 -6
  4. package/SECURITY.md +1 -1
  5. package/dist/capture/export-jpeg.js +25 -12
  6. package/dist/capture/export-jpeg.js.map +1 -1
  7. package/dist/capture/native-environment-lean.d.ts +35 -5
  8. package/dist/capture/native-environment-lean.js +175 -16
  9. package/dist/capture/native-environment-lean.js.map +1 -1
  10. package/dist/capture/native-environment-raw.d.ts +9 -1
  11. package/dist/capture/native-environment-raw.js +14 -9
  12. package/dist/capture/native-environment-raw.js.map +1 -1
  13. package/dist/capture/native-environment-tabulated.d.ts +58 -0
  14. package/dist/capture/native-environment-tabulated.js +91 -0
  15. package/dist/capture/native-environment-tabulated.js.map +1 -0
  16. package/dist/core/internal-radiance-evaluator.d.ts +28 -0
  17. package/dist/core/internal-radiance-evaluator.js +30 -0
  18. package/dist/core/internal-radiance-evaluator.js.map +1 -1
  19. package/dist/core/version.d.ts +1 -1
  20. package/dist/core/version.js +1 -1
  21. package/dist/core/version.js.map +1 -1
  22. package/dist/facade/diffraction-provider.js +33 -12
  23. package/dist/facade/diffraction-provider.js.map +1 -1
  24. package/dist/facade/display-view.d.ts +14 -83
  25. package/dist/facade/display-view.js +331 -846
  26. package/dist/facade/display-view.js.map +1 -1
  27. package/dist/facade/expected-signal-setup.d.ts +65 -0
  28. package/dist/facade/expected-signal-setup.js +52 -0
  29. package/dist/facade/expected-signal-setup.js.map +1 -0
  30. package/dist/facade/expected-signal.d.ts +83 -0
  31. package/dist/facade/expected-signal.js +571 -0
  32. package/dist/facade/expected-signal.js.map +1 -0
  33. package/dist/facade/scene-radiance.d.ts +6 -0
  34. package/dist/facade/scene-radiance.js +31 -10
  35. package/dist/facade/scene-radiance.js.map +1 -1
  36. package/dist/facade/simulate-capture.js +109 -28
  37. package/dist/facade/simulate-capture.js.map +1 -1
  38. package/dist/facade/simulate-display-view.js +26 -49
  39. package/dist/facade/simulate-display-view.js.map +1 -1
  40. package/dist/optics/site-diffraction-kernel.d.ts +5 -0
  41. package/dist/optics/site-diffraction-kernel.js +32 -0
  42. package/dist/optics/site-diffraction-kernel.js.map +1 -0
  43. package/dist/output/print-jpeg.js +70 -42
  44. package/dist/output/print-jpeg.js.map +1 -1
  45. package/docs/API_REFERENCE.md +1 -1
  46. package/docs/QUICK_START.md +1 -1
  47. package/docs/api/core-version.md +1 -1
  48. package/docs/api/exports.json +2 -2
  49. package/package.json +1 -1
@@ -32,7 +32,6 @@
32
32
  * added as independent per-pixel variance, weighted by Σ_j (w_j/A)², as is the output quantization (Δ²/12).
33
33
  * One correlated normal draw per display pixel (the sum of hundreds of independent site noises) gives the noise.
34
34
  */
35
- import { InvalidConfigurationError } from "../core/configuration-error.js";
36
35
  import { createDeterministicUniformStream, createPortableStandardNormalPairs, deriveNativeSiteStreamSeedUint32, fmix32, samplePortableStandardNormal } from "../sensor/stochastic-seed.js";
37
36
  import { encodeSrgbComponentPortable } from "../color/srgb-transfer.js";
38
37
  import { createPoissonSampler } from "../sensor/raw-readout.js";
@@ -41,569 +40,7 @@ import { portableCbrt, portableCosTwoPi, portableExp, portableExp2, portableLog2
41
40
  export const DISPLAY_VIEW_JITTER_STREAM_ID = 32;
42
41
  export const DISPLAY_VIEW_NOISE_STREAM_ID = 33;
43
42
  export const DISPLAY_VIEW_AHD_STREAM_ID = 34;
44
- const OFFSET_TOLERANCE = 1e-9;
45
- /** Largest cache window, in sites or lattice points; larger windows are evaluated without caching. */
46
- const CACHE_LIMIT = 1 << 22;
47
- /** Largest band of looked-up lattice materials per group, in lattice points (a band that would be larger restarts at the window). */
48
- const BAND_LIMIT = 1 << 22;
49
- /**
50
- * Expected photo electrons per site: μ_c(i, j) = Σ_o w_o Σ_λ A_c(λ) Σ_t K_λ(t)·L(p_o(i, j) − t, λ), where p_o is the
51
- * query point of offset o and K the diffraction kernel (a single unit tap without diffraction). This is the sum the
52
- * facade's environment chain forms for a still, rectilinear, distortion-free planar scene, where the
53
- * radiance-to-irradiance factor is the same at every site (docs/DISPLAY_VIEW.md, "Expected signal").
54
- *
55
- * With diffraction, offsets that share a fractional shift read one lattice, and their kernels are combined in advance:
56
- * C_c(d) = Σ_members w·Σ_λ A_c(λ)·K_λ(t)·L(λ) over the taps t that reach lattice offset d. A neutral material's radiance
57
- * is its reflectance times the white radiance, so all neutral materials share one combined kernel per channel.
58
- */
59
- export function createDisplayViewExpectedSignal(input) {
60
- const W = input.nativeWidth, H = input.nativeHeight, g = 2 / input.outputWidth, nodes = input.wavelengthsNm.length;
61
- const R = input.kernels ? input.kernels.radius : 0, size = 2 * R + 1;
62
- if (input.kernels && input.kernels.perNode.length !== nodes)
63
- throw new InvalidConfigurationError("Display view kernels must match the spectral nodes.");
64
- if (!input.kernels && !input.queryPoint)
65
- throw new InvalidConfigurationError("Display view needs the environment query points without diffraction.");
66
- const A = input.electronsPerRadiance, scene = input.scene, offsets = input.offsets, channelAt = input.channelAt;
67
- const kernelAt = (node, t) => (input.kernels ? input.kernels.perNode[node][t] : 1);
68
- // Materials: neutral ones by reflectance, spectral ones by index.
69
- let reflectance = new Float64Array(0), spectralIndex = new Int32Array(0);
70
- const spectral = [];
71
- if (scene.kind === "materials") {
72
- reflectance = new Float64Array(scene.materials.length);
73
- spectralIndex = new Int32Array(scene.materials.length).fill(-1);
74
- scene.materials.forEach((m, index) => { if ("neutral" in m)
75
- reflectance[index] = m.neutral;
76
- else {
77
- spectralIndex[index] = spectral.length;
78
- spectral.push(m.radiance);
79
- } });
80
- }
81
- // Offsets grouped by fractional shift: offsets that differ by whole sites share one lattice.
82
- const frac = (v) => { const f = v - Math.floor(v); return f > 1 - OFFSET_TOLERANCE || f < OFFSET_TOLERANCE ? 0 : f; };
83
- const groups = [];
84
- for (const o of offsets) {
85
- const fx = frac(o.x), fy = frac(o.y), ix = Math.round(o.x - fx), iy = Math.round(o.y - fy);
86
- let group = groups.find((q) => Math.abs(q.fx - fx) <= OFFSET_TOLERANCE && Math.abs(q.fy - fy) <= OFFSET_TOLERANCE);
87
- if (!group) {
88
- group = { fx, fy, members: [], minIx: ix, maxIx: ix, minIy: iy, maxIy: iy, cw: 0, ch: 0, neutral: [], spectral: [], perNode: [], cache: new Int32Array(0), cx0: 0, cy0: 0, cpw: 0, cph: 0 };
89
- groups.push(group);
90
- }
91
- group.members.push({ ix, iy, weight: o.weight });
92
- group.minIx = Math.min(group.minIx, ix);
93
- group.maxIx = Math.max(group.maxIx, ix);
94
- group.minIy = Math.min(group.minIy, iy);
95
- group.maxIy = Math.max(group.maxIy, iy);
96
- }
97
- if (input.kernels)
98
- for (const group of groups) {
99
- // Combined kernel over lattice offsets d = (ix − tu, iy − tv), stored at (d − min + R).
100
- const cw = group.maxIx - group.minIx + size, ch = group.maxIy - group.minIy + size;
101
- group.cw = cw;
102
- group.ch = ch;
103
- const combine = (value) => [0, 1, 2].map((c) => {
104
- const out = new Float64Array(cw * ch);
105
- for (const member of group.members)
106
- for (let tv = -R, t = 0; tv <= R; tv++)
107
- for (let tu = -R; tu <= R; tu++, t++) {
108
- let sum = 0;
109
- for (let n = 0; n < nodes; n++)
110
- sum += value(c, n, t);
111
- const index = (member.iy - tv - group.minIy + R) * cw + (member.ix - tu - group.minIx + R);
112
- out[index] = out[index] + member.weight * sum;
113
- }
114
- return out;
115
- });
116
- if (scene.kind === "materials") {
117
- group.neutral = combine((c, n, t) => A[c][n] * kernelAt(n, t) * scene.whiteRadiance[n]);
118
- const perMaterial = spectral.map((radiance) => combine((c, n, t) => A[c][n] * kernelAt(n, t) * radiance[n]));
119
- group.spectral = [0, 1, 2].map((c) => perMaterial.map((tables) => tables[c]));
120
- }
121
- else {
122
- // Radiance scenes: combined kernels per node, applied to the cached radiance at each node.
123
- group.perNode = Array.from({ length: nodes }, (_, n) => combine((c, node, t) => (node === n ? A[c][n] * kernelAt(n, t) : 0)));
124
- }
125
- }
126
- // Neutral tap-0 signal per channel and spectral per material, for the direct (no-kernel) path.
127
- const neutralDirect = scene.kind === "materials" ? [0, 1, 2].map((c) => { let s = 0; for (let n = 0; n < nodes; n++)
128
- s += A[c][n] * scene.whiteRadiance[n]; return s; }) : [];
129
- const spectralDirect = [0, 1, 2].map((c) => spectral.map((radiance) => { let s = 0; for (let n = 0; n < nodes; n++)
130
- s += A[c][n] * radiance[n]; return s; }));
131
- let lookups = 0;
132
- const point = new Float64Array(2);
133
- const materialSignalDirect = (m, c) => {
134
- const si = spectralIndex[m];
135
- if (si === undefined)
136
- throw new InvalidConfigurationError("Unknown chart material.");
137
- return si < 0 ? reflectance[m] * neutralDirect[c] : spectralDirect[c][si];
138
- };
139
- /** Without diffraction: one site from the environment's own query points. */
140
- function directSite(x, y) {
141
- const c = channelAt(x, y);
142
- let sum = 0;
143
- for (let o = 0; o < offsets.length; o++) {
144
- input.queryPoint(x, y, o, point);
145
- let value;
146
- if (scene.kind === "materials") {
147
- value = materialSignalDirect(scene.materialAt(point[0], point[1]), c);
148
- lookups++;
149
- }
150
- else {
151
- value = 0;
152
- for (let n = 0; n < nodes; n++)
153
- value += A[c][n] * scene.radianceAt(point[0], point[1], input.wavelengthsNm[n]);
154
- lookups += nodes;
155
- }
156
- sum += offsets[o].weight * value;
157
- }
158
- return sum;
159
- }
160
- // Lattice point (p, q) of a group: the facade's lattice formula (createDiffractedPlanarProvider).
161
- const latticeU = (group, p) => (group.fx === 0 ? (p + 0.5 - W / 2) * g : (p + 0.5 + group.fx - W / 2) * g);
162
- const latticeV = (group, q) => (group.fy === 0 ? -(q + 0.5 - H / 2) * g : -(q + 0.5 + group.fy - H / 2) * g);
163
- const radianceScratch = new Float64Array(nodes);
164
- /** Material (or radiance at each node, into radianceScratch) of a group's lattice point, from the window cache when it holds it. */
165
- function latticeMaterial(group, p, q) {
166
- const i = p - group.cx0, j = q - group.cy0, cached = i >= 0 && j >= 0 && i < group.cpw && j < group.cph;
167
- if (cached) {
168
- const m = group.cache[j * group.cpw + i];
169
- if (m >= 0)
170
- return m;
171
- }
172
- const m = scene.materialAt(latticeU(group, p), latticeV(group, q));
173
- lookups++;
174
- if (cached)
175
- group.cache[j * group.cpw + i] = m;
176
- return m;
177
- }
178
- function latticeRadiance(group, p, q) {
179
- const i = p - group.cx0, j = q - group.cy0, cached = i >= 0 && j >= 0 && i < group.cpw && j < group.cph, cache = group.cache;
180
- if (cached && !Number.isNaN(cache[(j * group.cpw + i) * nodes])) {
181
- for (let n = 0; n < nodes; n++)
182
- radianceScratch[n] = cache[(j * group.cpw + i) * nodes + n];
183
- return radianceScratch;
184
- }
185
- const u = latticeU(group, p), v = latticeV(group, q);
186
- for (let n = 0; n < nodes; n++)
187
- radianceScratch[n] = scene.radianceAt(u, v, input.wavelengthsNm[n]);
188
- lookups += nodes;
189
- if (cached)
190
- for (let n = 0; n < nodes; n++)
191
- cache[(j * group.cpw + i) * nodes + n] = radianceScratch[n];
192
- return radianceScratch;
193
- }
194
- /** With diffraction: one site through the combined kernels on each group's lattice. */
195
- function kernelSite(x, y) {
196
- const c = channelAt(x, y);
197
- let sum = 0;
198
- for (const group of groups) {
199
- const p0 = x + group.minIx - R, q0 = y + group.minIy - R, cw = group.cw, ch = group.ch;
200
- if (scene.kind === "materials") {
201
- const nk = group.neutral[c], sk = group.spectral[c];
202
- for (let dy = 0; dy < ch; dy++)
203
- for (let dx = 0; dx < cw; dx++) {
204
- const m = latticeMaterial(group, p0 + dx, q0 + dy), si = spectralIndex[m];
205
- if (si === undefined)
206
- throw new InvalidConfigurationError("Unknown chart material.");
207
- sum += si < 0 ? reflectance[m] * nk[dy * cw + dx] : sk[si][dy * cw + dx];
208
- }
209
- }
210
- else {
211
- for (let dy = 0; dy < ch; dy++)
212
- for (let dx = 0; dx < cw; dx++) {
213
- const radiance = latticeRadiance(group, p0 + dx, q0 + dy);
214
- for (let n = 0; n < nodes; n++)
215
- sum += group.perNode[n][c][dy * cw + dx] * radiance[n];
216
- }
217
- }
218
- }
219
- return sum;
220
- }
221
- // Filled windows (chart materials with diffraction, every lattice point of the window cacheable): each group keeps a band of
222
- // looked-up lattice materials that covers the window's rows and grows to the right as the windows of a display row move
223
- // right, with summed-area tables of material changes. A site whose kernel reads one material only (each group's rectangle
224
- // uniform, the same material in all) takes the value of the same sum over that material, precomputed below in the same
225
- // order; other sites run the same sum from the band. Either way each site's value is the same sequence of operations as
226
- // kernelSite, so it is bit-identical. The scene lookup count stays the count kernelSite's lazy window cache makes (each
227
- // cache point a computed site reads counts once per window, points outside the cache once per read): the rectangles of
228
- // the sites computed in a window are counted when the window closes (windowLookups).
229
- const uniformSite = scene.kind === "materials" && input.kernels ? [0, 1, 2].map((c) => Float64Array.from(scene.materials, (_, m) => {
230
- const si = spectralIndex[m];
231
- let sum = 0;
232
- for (const group of groups) {
233
- const nk = group.neutral[c], sk = si < 0 ? nk : group.spectral[c][si];
234
- for (let t = 0; t < group.cw * group.ch; t++)
235
- sum += si < 0 ? reflectance[m] * nk[t] : sk[t];
236
- }
237
- return sum;
238
- })) : [];
239
- const allNeutral = spectral.length === 0;
240
- const bands = groups.map(() => ({ x0: 0, y0: 0, rows: 0, capacity: 0, filled: 0, material: new Int32Array(0), reflectance: new Float64Array(0),
241
- changeLeft: new Int32Array(0), changeUp: new Int32Array(0), v: new Float64Array(0) }));
242
- /** Makes the band of group g cover lattice columns [x0, x1) of rows [y0, y0 + rows), extending it to the right when it can. */
243
- function coverBand(g, x0, x1, y0, rows) {
244
- const group = groups[g], band = bands[g], materials = scene;
245
- if (!(band.rows === rows && band.y0 === y0 && x0 >= band.x0 && x1 - band.x0 <= band.capacity)) {
246
- const capacity = Math.max(x1 - x0, Math.min(Math.floor(BAND_LIMIT / rows), W + 2 * (x1 - x0)));
247
- const size = rows * capacity, tables = (rows + 1) * (capacity + 1);
248
- band.x0 = x0;
249
- band.y0 = y0;
250
- band.rows = rows;
251
- band.capacity = capacity;
252
- band.filled = 0;
253
- if (band.material.length < size) {
254
- band.material = new Int32Array(size);
255
- band.reflectance = new Float64Array(allNeutral ? size : 0);
256
- }
257
- if (band.changeLeft.length < tables) {
258
- band.changeLeft = new Int32Array(tables);
259
- band.changeUp = new Int32Array(tables);
260
- }
261
- band.changeLeft.fill(0, 0, capacity + 1);
262
- band.changeUp.fill(0, 0, capacity + 1);
263
- for (let j = 0; j < rows; j++) {
264
- band.changeLeft[(j + 1) * (capacity + 1)] = 0;
265
- band.changeUp[(j + 1) * (capacity + 1)] = 0;
266
- }
267
- if (band.v.length < rows)
268
- band.v = new Float64Array(rows);
269
- for (let j = 0; j < rows; j++)
270
- band.v[j] = latticeV(group, y0 + j);
271
- }
272
- const stride = band.capacity, S1 = stride + 1, material = band.material, left = band.changeLeft, up = band.changeUp;
273
- for (let i = band.filled; i < x1 - band.x0; i++) {
274
- const u = latticeU(group, band.x0 + i);
275
- for (let j = 0; j < rows; j++) {
276
- const m = materials.materialAt(u, band.v[j]);
277
- if (spectralIndex[m] === undefined)
278
- throw new InvalidConfigurationError("Unknown chart material.");
279
- material[j * stride + i] = m;
280
- if (allNeutral)
281
- band.reflectance[j * stride + i] = reflectance[m];
282
- const changedLeft = i > 0 && material[j * stride + i - 1] !== m ? 1 : 0, changedUp = j > 0 && material[(j - 1) * stride + i] !== m ? 1 : 0;
283
- left[(j + 1) * S1 + i + 1] = left[j * S1 + i + 1] + left[(j + 1) * S1 + i] - left[j * S1 + i] + changedLeft;
284
- up[(j + 1) * S1 + i + 1] = up[j * S1 + i + 1] + up[(j + 1) * S1 + i] - up[j * S1 + i] + changedUp;
285
- }
286
- }
287
- band.filled = Math.max(band.filled, x1 - band.x0);
288
- }
289
- const rectSum = (sat, S1, i0, j0, i1, j1) => sat[j1 * S1 + i1] - sat[j0 * S1 + i1] - sat[j1 * S1 + i0] + sat[j0 * S1 + i0];
290
- let filled = false;
291
- /** Sites outside the site window computed in this window (x, y pairs in window coordinates); their rectangles count when the window closes. */
292
- let outsideSites = [];
293
- let marks = new Uint8Array(0), cover = new Uint8Array(0);
294
- /** A site of a filled window: (i, j) are its coordinates in the site window, inside or outside it. */
295
- function filledSite(x, y, i, j) {
296
- const c = channelAt(x, y), inside = i >= 0 && j >= 0 && i < sw && j < sh;
297
- if (inside) {
298
- let m = -1;
299
- for (let g = 0; g < groups.length; g++) {
300
- const group = groups[g], band = bands[g], S1 = band.capacity + 1, bi = x + group.minIx - R - band.x0, bj = y + group.minIy - R - band.y0;
301
- if (rectSum(band.changeLeft, S1, bi + 1, bj, bi + group.cw, bj + group.ch) !== 0 || rectSum(band.changeUp, S1, bi, bj + 1, bi + group.cw, bj + group.ch) !== 0) {
302
- m = -2;
303
- break;
304
- }
305
- const own = band.material[bj * band.capacity + bi];
306
- if (m === -1)
307
- m = own;
308
- else if (own !== m) {
309
- m = -2;
310
- break;
311
- }
312
- }
313
- if (m >= 0) {
314
- lastMaterial = m;
315
- return uniformSite[c][m];
316
- }
317
- }
318
- lastMaterial = -1;
319
- let sum = 0;
320
- for (let g = 0; g < groups.length; g++) {
321
- const group = groups[g], band = bands[g], cw = group.cw, ch = group.ch, nk = group.neutral[c], sk = group.spectral[c], stride = band.capacity;
322
- const p0 = x + group.minIx - R, q0 = y + group.minIy - R, bi = p0 - band.x0, bj = q0 - band.y0;
323
- if (inside && allNeutral) {
324
- const refl = band.reflectance;
325
- for (let dy = 0, t = 0; dy < ch; dy++) {
326
- const row = (bj + dy) * stride + bi;
327
- for (let dx = 0; dx < cw; dx++, t++)
328
- sum += refl[row + dx] * nk[t];
329
- }
330
- }
331
- else if (inside) {
332
- const material = band.material;
333
- for (let dy = 0, t = 0; dy < ch; dy++) {
334
- const row = (bj + dy) * stride + bi;
335
- for (let dx = 0; dx < cw; dx++, t++) {
336
- const m = material[row + dx], si = spectralIndex[m];
337
- sum += si < 0 ? reflectance[m] * nk[t] : sk[si][t];
338
- }
339
- }
340
- }
341
- else {
342
- for (let dy = 0; dy < ch; dy++)
343
- for (let dx = 0; dx < cw; dx++) {
344
- const ci = i + dx, cj = j + dy;
345
- let m;
346
- if (ci >= 0 && cj >= 0 && ci < group.cpw && cj < group.cph)
347
- m = band.material[(bj + dy) * stride + bi + dx];
348
- else
349
- m = scene.materialAt(latticeU(group, p0 + dx), latticeV(group, q0 + dy));
350
- const si = spectralIndex[m];
351
- if (si === undefined)
352
- throw new InvalidConfigurationError("Unknown chart material.");
353
- sum += si < 0 ? reflectance[m] * nk[dy * cw + dx] : sk[si][dy * cw + dx];
354
- }
355
- }
356
- }
357
- return sum;
358
- }
359
- /** Lattice points of an outside site's rectangles that lie outside the window caches (kernelSite looks these up on every read). */
360
- function outsideLookups(i, j) {
361
- let count = 0;
362
- for (const group of groups) {
363
- const w = Math.max(0, Math.min(i + group.cw, group.cpw) - Math.max(i, 0)), h = Math.max(0, Math.min(j + group.ch, group.cph) - Math.max(j, 0));
364
- count += group.cw * group.ch - w * h;
365
- }
366
- return count;
367
- }
368
- // Site values of filled windows persist across windows (a site's value does not depend on the window): rows of native
369
- // sites in a ring of a power-of-two number of rows, row y in slot y mod ringRows. The window's site cache still holds the
370
- // sites read in the window, which is also what its lookup count needs.
371
- let ringRows = 0, ringOn = false, ring = new Float64Array(0), ringMaterial = new Int32Array(0), ringRowId = new Int32Array(0), windowMaterial = new Int32Array(0), lastMaterial = -1;
372
- /** A filled-window site read outside the site window: counted as kernelSite counts it. */
373
- function outsideRead(i, j) { lookups += outsideLookups(i, j); outsideSites.push(i, j); }
374
- /** A site from the ring; on a miss (re)claims the row's slot when another row holds it, computes the site and stores it. */
375
- function ringRead(x, y, i, j) {
376
- const slot = y & (ringRows - 1), q = slot * W + x;
377
- if (ringRowId[slot] === y) {
378
- const v = ring[q];
379
- if (v === v) {
380
- lastMaterial = ringMaterial[q];
381
- return v;
382
- }
383
- }
384
- else {
385
- ringRowId[slot] = y;
386
- ring.fill(NaN, slot * W, (slot + 1) * W);
387
- }
388
- const v = filledSite(x, y, i, j);
389
- ring[q] = v;
390
- ringMaterial[q] = lastMaterial;
391
- return v;
392
- }
393
- // Without diffraction (#392): site values persist across windows the same way, in their own ring (a site's value does not
394
- // depend on the window). A ring hit counts the lookups directSite would have made, so `lookups` is unchanged.
395
- let directRows = 0, directOn = false, directRing = new Float64Array(0), directRowId = new Int32Array(0);
396
- const directLookups = offsets.length * (scene.kind === "materials" ? 1 : nodes);
397
- function directRead(x, y) {
398
- const slot = y & (directRows - 1), q = slot * W + x;
399
- if (directRowId[slot] === y) {
400
- const v = directRing[q];
401
- if (v === v) {
402
- lookups += directLookups;
403
- return v;
404
- }
405
- }
406
- else {
407
- directRowId[slot] = y;
408
- directRing.fill(NaN, slot * W, (slot + 1) * W);
409
- }
410
- const v = directSite(x, y);
411
- directRing[q] = v;
412
- return v;
413
- }
414
- /** A site not in the window's site cache: from a ring, a filled window without the ring, the lazy window caches, or uncached. */
415
- function siteMiss(x, y, i, j, cached) {
416
- if (directOn) {
417
- const v = directRead(x, y);
418
- if (cached)
419
- siteCache[j * sw + i] = v;
420
- return v;
421
- }
422
- if (ringOn) {
423
- if (!cached) {
424
- outsideRead(i, j);
425
- return ringRead(x, y, i, j);
426
- }
427
- const v = ringRead(x, y, i, j);
428
- siteCache[j * sw + i] = v;
429
- windowMaterial[j * sw + i] = lastMaterial;
430
- return v;
431
- }
432
- if (filled && !cached)
433
- outsideRead(i, j);
434
- const v = filled ? filledSite(x, y, i, j) : input.kernels ? kernelSite(x, y) : directSite(x, y);
435
- if (cached)
436
- siteCache[j * sw + i] = v;
437
- return v;
438
- }
439
- /**
440
- * Lattice points of the closing window that kernelSite's lazy cache would have looked up: per group, the window cache points
441
- * covered by the rectangles of the computed sites (the site cache's filled entries and the outside sites), a box dilation.
442
- */
443
- function windowLookups() {
444
- let total = 0;
445
- for (const group of groups) {
446
- const cw = group.cw, ch = group.ch, cpw = group.cpw, cph = group.cph, ew = cpw + cw - 1, eh = cph + ch - 1;
447
- // Top-left marks in extended coordinates (window coordinates + (cw − 1, ch − 1)), and their bounding box.
448
- if (marks.length < ew * eh) {
449
- marks = new Uint8Array(ew * eh);
450
- }
451
- let x0 = ew, x1 = -1, y0 = eh, y1 = -1;
452
- const mark = (ei, ej) => {
453
- if (ei < 0 || ej < 0 || ei >= ew || ej >= eh)
454
- return;
455
- marks[ej * ew + ei] = 1;
456
- if (ei < x0)
457
- x0 = ei;
458
- if (ei > x1)
459
- x1 = ei;
460
- if (ej < y0)
461
- y0 = ej;
462
- if (ej > y1)
463
- y1 = ej;
464
- };
465
- for (let j = 0; j < sh; j++)
466
- for (let i = 0; i < sw; i++)
467
- if (!Number.isNaN(siteCache[j * sw + i]))
468
- mark(i + cw - 1, j + ch - 1);
469
- for (let k = 0; k < outsideSites.length; k += 2)
470
- mark(outsideSites[k] + cw - 1, outsideSites[k + 1] + ch - 1);
471
- if (x1 < 0)
472
- continue;
473
- // Cache point (i, j) is covered when a mark lies in extended columns [i, i + cw) and rows [j, j + ch): a horizontal
474
- // then a vertical running count over the marks' box (outside it nothing is covered).
475
- const i0 = Math.max(0, x0 - cw + 1), i1 = Math.min(cpw - 1, x1), cwid = i1 - i0 + 1;
476
- if (cover.length < cwid * (y1 - y0 + 1))
477
- cover = new Uint8Array(cwid * (y1 - y0 + 1));
478
- for (let r = y0; r <= y1; r++) {
479
- let run = 0;
480
- for (let e = i0; e <= i1 + cw - 1; e++) {
481
- run += marks[r * ew + e];
482
- if (e - cw >= i0)
483
- run -= marks[r * ew + e - cw];
484
- if (e >= i0 + cw - 1)
485
- cover[(r - y0) * cwid + e - cw + 1 - i0] = run > 0 ? 1 : 0;
486
- }
487
- marks.fill(0, r * ew + x0, r * ew + x1 + 1);
488
- }
489
- for (let i = 0; i < cwid; i++) {
490
- let run = 0;
491
- for (let r = y0; r <= y1 + ch - 1; r++) {
492
- if (r <= y1)
493
- run += cover[(r - y0) * cwid + i];
494
- if (r - ch >= y0 && r - ch <= y1)
495
- run -= cover[(r - ch - y0) * cwid + i];
496
- const j = r - ch + 1;
497
- if (j >= 0 && j < cph && run > 0)
498
- total++;
499
- }
500
- }
501
- }
502
- return total;
503
- }
504
- let sx0 = 0, sy0 = 0, sw = 0, sh = 0, siteCache = new Float64Array(0);
505
- const pilotNode = input.wavelengthsNm[Math.floor(nodes / 2)];
506
- return {
507
- get lookups() { return lookups + (filled ? windowLookups() : 0); },
508
- get uniformMaterial() { return lastMaterial; },
509
- get tracksMaterial() { return ringOn; },
510
- get cachesSites() { return sw > 0; },
511
- window(x0, y0, x1, y1) {
512
- if (filled)
513
- lookups += windowLookups();
514
- filled = false;
515
- ringOn = false;
516
- outsideSites = [];
517
- sx0 = x0;
518
- sy0 = y0;
519
- sw = Math.max(0, x1 - x0);
520
- sh = Math.max(0, y1 - y0);
521
- if (sw * sh > CACHE_LIMIT) {
522
- sw = 0;
523
- sh = 0;
524
- }
525
- if (siteCache.length < sw * sh)
526
- siteCache = new Float64Array(sw * sh);
527
- siteCache.fill(NaN, 0, sw * sh);
528
- if (!input.kernels) {
529
- // A ring of at least twice the window's rows keeps the rows of the previous display row's windows.
530
- if (sh > 0 && directRows < 2 * sh) {
531
- let rows = 1;
532
- while (rows < 2 * sh)
533
- rows *= 2;
534
- if (rows * W <= CACHE_LIMIT) {
535
- directRows = rows;
536
- directRing = new Float64Array(rows * W).fill(NaN);
537
- directRowId = new Int32Array(rows).fill(-1);
538
- }
539
- }
540
- directOn = sh > 0 && directRows >= 2 * sh;
541
- return;
542
- }
543
- for (const group of groups) {
544
- group.cx0 = x0 + group.minIx - R;
545
- group.cy0 = y0 + group.minIy - R;
546
- group.cpw = sw === 0 ? 0 : sw + group.cw - 1;
547
- group.cph = sh === 0 ? 0 : sh + group.ch - 1;
548
- if (group.cpw * group.cph * (scene.kind === "materials" ? 1 : nodes) > CACHE_LIMIT) {
549
- group.cpw = 0;
550
- group.cph = 0;
551
- }
552
- }
553
- // Chart materials whose every group caches the whole window: the filled path (bands). Otherwise the lazy window caches.
554
- if (scene.kind === "materials" && sw > 0 && groups.every((group) => group.cpw > 0)) {
555
- groups.forEach((group, g) => coverBand(g, group.cx0, group.cx0 + group.cpw, group.cy0, group.cph));
556
- filled = true;
557
- if (windowMaterial.length < sw * sh)
558
- windowMaterial = new Int32Array(sw * sh);
559
- // A ring of at least twice the window's rows keeps the rows of the previous display row's windows.
560
- if (ringRows < 2 * sh) {
561
- let rows = 1;
562
- while (rows < 2 * sh)
563
- rows *= 2;
564
- if (rows * W <= CACHE_LIMIT) {
565
- ringRows = rows;
566
- ring = new Float64Array(rows * W).fill(NaN);
567
- ringMaterial = new Int32Array(rows * W);
568
- ringRowId = new Int32Array(rows).fill(-1);
569
- }
570
- }
571
- ringOn = ringRows >= 2 * sh;
572
- return;
573
- }
574
- for (const group of groups) {
575
- const n = group.cpw * group.cph * (scene.kind === "materials" ? 1 : nodes);
576
- if (scene.kind === "materials") {
577
- if (group.cache.length < n || !(group.cache instanceof Int32Array))
578
- group.cache = new Int32Array(n);
579
- group.cache.fill(-1, 0, n);
580
- }
581
- else {
582
- if (group.cache.length < n || !(group.cache instanceof Float64Array))
583
- group.cache = new Float64Array(n);
584
- group.cache.fill(NaN, 0, n);
585
- }
586
- }
587
- },
588
- site(x, y) {
589
- // The window's site cache first (with the ring: the sites read in this window, which its lookup count needs too).
590
- const i = x - sx0, j = y - sy0, cached = i >= 0 && j >= 0 && i < sw && j < sh;
591
- if (cached) {
592
- const w = j * sw + i, v = siteCache[w];
593
- if (v === v) {
594
- if (ringOn)
595
- lastMaterial = windowMaterial[w];
596
- return v;
597
- }
598
- }
599
- return siteMiss(x, y, i, j, cached);
600
- },
601
- pilot(u, v) {
602
- lookups++;
603
- return scene.kind === "materials" ? scene.materialAt(u, v) : scene.radianceAt(u, v, pilotNode);
604
- }
605
- };
606
- }
43
+ export { createDisplayViewExpectedSignal } from "./expected-signal.js";
607
44
  // ---------------------------------------------------------------------------------------------------------------
608
45
  // Normal-distribution helpers.
609
46
  /** Standard normal density. */
@@ -884,6 +321,13 @@ function correctionStates(key) {
884
321
  const MEMO_SIZE = 37;
885
322
  /** Most real pixels a display pixel's footprint may have for the per-real-pixel memo (#392); larger footprints rarely repeat a real pixel. */
886
323
  const PIXEL_MEMO_LIMIT = 1 << 16;
324
+ /**
325
+ * Most real pixels a footprint may hold for the exact area mean (#393). Measured on 8-row strips of a 24 MP view (Siemens star
326
+ * and dead leaves, one thread): the exact path matched the stratified estimator's time within run-to-run spread up to 25 real
327
+ * pixels per display pixel and was slower above (1.2× at 36, 1.4–1.5× at 64, 1.5–2.4× at 144), because every real pixel is
328
+ * evaluated while the stratified estimator takes a quarter of its samples per axis where the pilot finds no detail.
329
+ */
330
+ export const EXACT_AREA_MEAN_MAX_PIXELS = 25;
887
331
  /** Pilot points per axis: about one per two sites of the footprint plus its reach, 4 to 16. */
888
332
  function pilotCount(span) { return Math.max(4, Math.min(16, Math.ceil(span / 2))); }
889
333
  /** Renders the display view (docs/DISPLAY_VIEW.md). */
@@ -891,6 +335,21 @@ export async function renderDisplayView(setup) {
891
335
  const { frameWidth, frameHeight, margin, nativeWidth, nativeHeight, viewWidth, viewHeight, region, seed, reachSites } = setup;
892
336
  const sx = frameWidth / viewWidth, sy = frameHeight / viewHeight, area = sx * sy, g = 2 / frameWidth;
893
337
  const fullK = setup.samplesPerAxis, uniformK = Math.max(1, Math.round(fullK / 4));
338
+ // Exact area mean (#393): where a footprint holds at most EXACT_AREA_MEAN_MAX_PIXELS real pixels and at most K², every real
339
+ // pixel it touches is evaluated once with its exact overlap weight a_j = w_j / A, so the expected value is D_P = Σ_j a_j·E[L_j]
340
+ // with no sampling error at no more cost than sampling; coarser views keep the stratified estimator, whose cost does not
341
+ // grow with the footprint.
342
+ const exactView = area <= Math.min(EXACT_AREA_MEAN_MAX_PIXELS, fullK * fullK);
343
+ const columnOverlaps = exactView ? Array.from({ length: region.width }, (_, i) => overlaps((region.x + i) * sx, (region.x + i + 1) * sx, frameWidth)) : [];
344
+ // On the exact path a real pixel's recorded terms are shared by the 2–4 display pixels whose footprints touch it: they are
345
+ // kept for the region's native columns in a ring of native rows (a power of two, at least twice a footprint's height plus
346
+ // one), valid while a slot's stamp is its native row + 1.
347
+ const exactX0 = Math.floor(region.x * sx) + margin, exactWidth = Math.min(frameWidth - 1, Math.ceil((region.x + region.width) * sx) - 1) + margin - exactX0 + 1;
348
+ let exactRows = 1;
349
+ while (exactRows < 2 * (Math.ceil(sy) + 1))
350
+ exactRows *= 2;
351
+ const exactMemo = exactView && exactRows * exactWidth <= PIXEL_MEMO_LIMIT * 4;
352
+ const exactStamp = new Int32Array(exactMemo ? exactRows * exactWidth : 0), exactRecords = new Float64Array(exactMemo ? exactRows * exactWidth * MEMO_SIZE : 0);
894
353
  const maximum = setup.bitDepth === 8 ? 255 : 65535, step = 1 / maximum;
895
354
  const pixelCount = region.width * region.height;
896
355
  const samples = setup.bitDepth === 8 ? new Uint8Array(pixelCount * 3) : new Uint16Array(pixelCount * 3);
@@ -1105,7 +564,7 @@ export async function renderDisplayView(setup) {
1105
564
  }
1106
565
  let siteCount = 0, sampleCount = 0, detail = 0, uniform = 0, lastYield = Date.now();
1107
566
  /** Recorded terms of samples whose sites all read one chart material, by material·4 + CFA phase (layout: MEMO_SIZE). */
1108
- const sampleMemo = new Map();
567
+ const sampleMemo = new Map(), recordScratch = new Float64Array(MEMO_SIZE);
1109
568
  // Per-real-pixel memo (#392): a sample's recorded terms depend only on its real pixel (its sites' expected electrons and its
1110
569
  // CFA phase), not on the display pixel or the sample, so a later sample of the same real pixel in the same display pixel
1111
570
  // replays them (the same values added to the same sums in the same order, as the one-material memo does). Slots cover the
@@ -1137,7 +596,8 @@ export async function renderDisplayView(setup) {
1137
596
  const ringWidth = region.width + 2, flagRows = [];
1138
597
  const pilotRow = (vy) => {
1139
598
  const row = new Uint8Array(ringWidth);
1140
- if (vy < 0 || vy >= viewHeight)
599
+ // The exact area mean needs no pilot: every pixel evaluates its whole footprint.
600
+ if (exactView || vy < 0 || vy >= viewHeight)
1141
601
  return row.fill(1);
1142
602
  for (let i = 0; i < ringWidth; i++) {
1143
603
  const vx = region.x - 1 + i;
@@ -1147,7 +607,7 @@ export async function renderDisplayView(setup) {
1147
607
  };
1148
608
  flagRows.push(pilotRow(region.y - 1), pilotRow(region.y));
1149
609
  for (let ry = 0; ry < region.height; ry++) {
1150
- const vy = region.y + ry, rows = axisSums(vy * sy, (vy + 1) * sy, frameHeight, margin);
610
+ const vy = region.y + ry, rows = axisSums(vy * sy, (vy + 1) * sy, frameHeight, margin), rowOverlaps = exactView ? overlaps(vy * sy, (vy + 1) * sy, frameHeight) : undefined;
1151
611
  flagRows.push(pilotRow(vy + 1));
1152
612
  if (flagRows.length > 3)
1153
613
  flagRows.shift();
@@ -1162,15 +622,19 @@ export async function renderDisplayView(setup) {
1162
622
  break;
1163
623
  }
1164
624
  const K = same ? uniformK : fullK, Q = K * K;
1165
- if (same)
625
+ // On the exact path every pixel counts as detailed (its whole footprint is evaluated).
626
+ if (exactView)
627
+ detail++;
628
+ else if (same)
1166
629
  uniform++;
1167
630
  else
1168
631
  detail++;
632
+ const cols = exactView ? columnOverlaps[rx] : undefined, nx = cols ? cols.w.length : 0, sampleTotal = exactView ? nx * rowOverlaps.w.length : Q;
1169
633
  setup.signal.window(Math.floor(vx * sx) + margin - windowReach, Math.floor(vy * sy) + margin - windowReach, Math.ceil((vx + 1) * sx) + margin + windowReach, Math.ceil((vy + 1) * sy) + margin + windowReach);
1170
634
  const jitter = createDeterministicUniformStream(deriveNativeSiteStreamSeedUint32(seed, index, DISPLAY_VIEW_JITTER_STREAM_ID)), tracking = setup.signal.tracksMaterial;
1171
635
  const mx0 = Math.floor(vx * sx) + margin, my0 = Math.floor(vy * sy) + margin;
1172
636
  const mw = Math.min(frameWidth - 1, Math.ceil((vx + 1) * sx)) + margin - mx0 + 1, mh = Math.min(frameHeight - 1, Math.ceil((vy + 1) * sy)) + margin - my0 + 1;
1173
- const pixelMemo = setup.signal.cachesSites && mw * mh <= PIXEL_MEMO_LIMIT;
637
+ const pixelMemo = !exactView && setup.signal.cachesSites && mw * mh <= PIXEL_MEMO_LIMIT;
1174
638
  if (pixelMemo) {
1175
639
  if (pixelStamp.length < mw * mh) {
1176
640
  pixelStamp = new Int32Array(mw * mh);
@@ -1180,311 +644,330 @@ export async function renderDisplayView(setup) {
1180
644
  pixelGeneration++;
1181
645
  }
1182
646
  let s0 = 0, s1 = 0, s2 = 0, c00 = 0, c01 = 0, c02 = 0, c11 = 0, c12 = 0, c22 = 0, r0 = 0, r1 = 0, r2 = 0, pixelVariance = 0, corrected = false;
1183
- for (let b = 0; b < K; b++)
1184
- for (let a = 0; a < K; a++) {
647
+ for (let n = 0; n < sampleTotal; n++) {
648
+ // A sample's weights: 1 for the stratified estimator (its sums are divided by Q below); a_j for the expected value and
649
+ // linear covariance and a_j² for the independent residual on the exact path. Multiplying by 1 leaves every value's bits.
650
+ let x, y, wE = 1, wR = 1;
651
+ if (cols) {
652
+ const i = n % nx, j = (n - i) / nx, aj = cols.w[i] * rowOverlaps.w[j] / area;
653
+ x = cols.first + i + margin;
654
+ y = rowOverlaps.first + j + margin;
655
+ wE = aj;
656
+ wR = aj * aj;
657
+ }
658
+ else {
659
+ const a = n % K, b = (n - a) / K;
1185
660
  const fx = (vx + (a + jitter.next()) / K) * sx, fy = (vy + (b + jitter.next()) / K) * sy;
1186
- const x = Math.min(frameWidth - 1, Math.floor(fx)) + margin, y = Math.min(frameHeight - 1, Math.floor(fy)) + margin;
1187
- const phase = (y & 1) * 2 + (x & 1), list = phaseList[phase];
1188
- let material = -2;
1189
- const px = x - mx0, py = y - my0;
1190
- const pk = pixelMemo && px >= 0 && py >= 0 && px < mw && py < mh && x >= reach && y >= reach && x + reach < nativeWidth && y + reach < nativeHeight ? py * mw + px : -1;
1191
- let memo, mo = 0;
1192
- if (pk >= 0 && pixelStamp[pk] === pixelGeneration) {
1193
- memo = pixelRecords;
1194
- mo = pk * MEMO_SIZE;
1195
- siteCount += list.length;
1196
- }
1197
- // The expected electrons of the sites the demosaic reads (sites outside the raster, at mirror borders, are read
1198
- // reflect-101); where the signal tracks it, whether they all read one chart material, with the statistics below only
1199
- // when the sample is not replayed; otherwise the statistics at once.
1200
- if (!memo)
1201
- for (let li = 0; li < list.length; li++) {
1202
- const slot = list[li];
1203
- siteCount++;
1204
- let sxs = x + (slot % side) - reach, sys = y + ((slot / side) | 0) - reach;
1205
- if (sxs < 0)
1206
- sxs = -sxs;
1207
- else if (sxs >= nativeWidth)
1208
- sxs = 2 * nativeWidth - 2 - sxs;
1209
- if (sys < 0)
1210
- sys = -sys;
1211
- else if (sys >= nativeHeight)
1212
- sys = 2 * nativeHeight - 2 - sys;
1213
- const c = (sys & 1) === 0 ? ((sxs & 1) === 0 ? 0 : 1) : ((sxs & 1) === 0 ? 1 : 2);
1214
- const e = setup.signal.site(sxs, sys) + dark[c];
1215
- siteChannel[slot] = c;
1216
- if (tracking) {
1217
- siteElectrons[slot] = e;
1218
- const own = setup.signal.uniformMaterial;
1219
- material = material === -2 || material === own ? own : -1;
1220
- continue;
1221
- }
1222
- // As siteStatistics, written out here for the untracked (Speed) path.
1223
- const kc = k[c], mean = kc * e, variance = kc * kc * e + c0[c], gap = 1 - mean;
1224
- siteThird[slot] = kc * kc * kc * e;
1225
- if (gap > 0 && gap * gap > reach2 * variance) {
1226
- siteMean[slot] = mean;
1227
- siteVar[slot] = variance;
1228
- }
1229
- else {
1230
- upperClip(mean, variance, 1, clip);
1231
- siteMean[slot] = clip[0];
1232
- siteVar[slot] = clip[1];
1233
- }
1234
- }
1235
- if (!tracking)
1236
- material = -1;
1237
- // A sample whose sites all read one chart material only has the statistics of every other such sample of that material
1238
- // and CFA phase: replay its recorded terms (the same values added to the same sums in the same order).
1239
- const memoKey = !memo && material >= 0 ? material * 4 + phase : -1;
1240
- if (memoKey >= 0) {
1241
- memo = sampleMemo.get(memoKey);
1242
- if (memo && pk >= 0) {
1243
- pixelRecords.set(memo, pk * MEMO_SIZE);
1244
- pixelStamp[pk] = pixelGeneration;
1245
- }
1246
- }
1247
- const cs = candidateSlots[phase], cw = candidateWeights[phase], count = cs.length, share = 1 / count;
1248
- if (memo) {
1249
- for (let d = 0; d < count; d++) {
1250
- for (let c = 0; c < 3; c++) {
1251
- const o = mo + 3 + (d * 3 + c) * 3, a0 = memo[o], a1 = memo[o + 1], a2 = memo[o + 2], wgt = share * B[c] * memo[mo + c];
1252
- c00 += wgt * a0 * a0;
1253
- c01 += wgt * a0 * a1;
1254
- c02 += wgt * a0 * a2;
1255
- c11 += wgt * a1 * a1;
1256
- c12 += wgt * a1 * a2;
1257
- c22 += wgt * a2 * a2;
1258
- }
1259
- r0 += memo[mo + 21 + d * 3];
1260
- r1 += memo[mo + 22 + d * 3];
1261
- r2 += memo[mo + 23 + d * 3];
661
+ x = Math.min(frameWidth - 1, Math.floor(fx)) + margin;
662
+ y = Math.min(frameHeight - 1, Math.floor(fy)) + margin;
663
+ }
664
+ const phase = (y & 1) * 2 + (x & 1), list = phaseList[phase];
665
+ let material = -2;
666
+ const px = x - mx0, py = y - my0;
667
+ const inside = x >= reach && y >= reach && x + reach < nativeWidth && y + reach < nativeHeight;
668
+ const stamps = exactMemo ? exactStamp : pixelStamp, records = exactMemo ? exactRecords : pixelRecords, stamp = exactMemo ? y + 1 : pixelGeneration;
669
+ const pk = exactMemo ? (inside && x >= exactX0 && x - exactX0 < exactWidth ? (y & (exactRows - 1)) * exactWidth + x - exactX0 : -1)
670
+ : pixelMemo && px >= 0 && py >= 0 && px < mw && py < mh && inside ? py * mw + px : -1;
671
+ let memo, mo = 0;
672
+ if (pk >= 0 && stamps[pk] === stamp) {
673
+ memo = records;
674
+ mo = pk * MEMO_SIZE;
675
+ siteCount += list.length;
676
+ }
677
+ // The expected electrons of the sites the demosaic reads (sites outside the raster, at mirror borders, are read
678
+ // reflect-101); where the signal tracks it, whether they all read one chart material, with the statistics below only
679
+ // when the sample is not replayed; otherwise the statistics at once.
680
+ if (!memo)
681
+ for (let li = 0; li < list.length; li++) {
682
+ const slot = list[li];
683
+ siteCount++;
684
+ let sxs = x + (slot % side) - reach, sys = y + ((slot / side) | 0) - reach;
685
+ if (sxs < 0)
686
+ sxs = -sxs;
687
+ else if (sxs >= nativeWidth)
688
+ sxs = 2 * nativeWidth - 2 - sxs;
689
+ if (sys < 0)
690
+ sys = -sys;
691
+ else if (sys >= nativeHeight)
692
+ sys = 2 * nativeHeight - 2 - sys;
693
+ const c = (sys & 1) === 0 ? ((sxs & 1) === 0 ? 0 : 1) : ((sxs & 1) === 0 ? 1 : 2);
694
+ const e = setup.signal.site(sxs, sys) + dark[c];
695
+ siteChannel[slot] = c;
696
+ if (tracking) {
697
+ siteElectrons[slot] = e;
698
+ const own = setup.signal.uniformMaterial;
699
+ material = material === -2 || material === own ? own : -1;
700
+ continue;
1262
701
  }
1263
- s0 += memo[mo + 27];
1264
- s1 += memo[mo + 28];
1265
- s2 += memo[mo + 29];
1266
- if (count === 2) {
1267
- r0 += memo[mo + 30];
1268
- r1 += memo[mo + 31];
1269
- r2 += memo[mo + 32];
702
+ // As siteStatistics, written out here for the untracked (Speed) path.
703
+ const kc = k[c], mean = kc * e, variance = kc * kc * e + c0[c], gap = 1 - mean;
704
+ siteThird[slot] = kc * kc * kc * e;
705
+ if (gap > 0 && gap * gap > reach2 * variance) {
706
+ siteMean[slot] = mean;
707
+ siteVar[slot] = variance;
1270
708
  }
1271
- if (memo[mo + 33] === 1) {
1272
- s0 += memo[mo + 34];
1273
- s1 += memo[mo + 35];
1274
- s2 += memo[mo + 36];
1275
- corrected = true;
709
+ else {
710
+ upperClip(mean, variance, 1, clip);
711
+ siteMean[slot] = clip[0];
712
+ siteVar[slot] = clip[1];
1276
713
  }
1277
- continue;
1278
714
  }
1279
- const record = memoKey >= 0 ? new Float64Array(MEMO_SIZE) : pk >= 0 ? pixelRecords.subarray(pk * MEMO_SIZE, (pk + 1) * MEMO_SIZE).fill(0) : undefined;
1280
- if (tracking)
1281
- for (let li = 0; li < list.length; li++) {
1282
- const slot = list[li];
1283
- siteStatistics(slot, siteChannel[slot], siteElectrons[slot]);
1284
- }
1285
- // Site variance of each channel through the bilinear kernels (for B_c), then the demosaic's own pixel statistics: the
1286
- // mean and covariance of its camera channels, averaged over its candidates (AHD picks one per pixel).
1287
- const slots = phaseSlots[phase], weights = phaseWeights[phase], w2 = phaseW2[phase];
1288
- for (let c = 0; c < 3; c++) {
1289
- const sl = slots[c], wt = weights[c];
1290
- let variance = 0;
1291
- for (let t = 0; t < sl.length; t++)
1292
- variance += wt[t] * wt[t] * siteVar[sl[t]];
1293
- csv[c] = variance / w2[c];
715
+ if (!tracking)
716
+ material = -1;
717
+ // A sample whose sites all read one chart material only has the statistics of every other such sample of that material
718
+ // and CFA phase: replay its recorded terms (the same values added to the same sums in the same order).
719
+ const memoKey = !memo && material >= 0 ? material * 4 + phase : -1;
720
+ if (memoKey >= 0) {
721
+ memo = sampleMemo.get(memoKey);
722
+ if (memo && pk >= 0) {
723
+ records.set(memo, pk * MEMO_SIZE);
724
+ stamps[pk] = stamp;
1294
725
  }
1295
- // Each candidate of the demosaic (one for bilinear; AHD's horizontal and vertical ones, of which AHD picks one per pixel)
1296
- // is propagated on its own: camera-channel mean and covariance, white balance and highlight clip, colour matrix and
1297
- // rendering; the pixel's statistics are their average.
1298
- let e0 = 0, e1 = 0, e2 = 0;
726
+ }
727
+ const cs = candidateSlots[phase], cw = candidateWeights[phase], count = cs.length, share = 1 / count;
728
+ if (memo) {
1299
729
  for (let d = 0; d < count; d++) {
1300
730
  for (let c = 0; c < 3; c++) {
1301
- const sl = cs[d][c], wt = cw[d][c];
1302
- let mean = 0;
1303
- for (let t = 0; t < sl.length; t++)
1304
- mean += wt[t] * siteMean[sl[t]];
1305
- cm[c] = mean;
1306
- cmAverage[c] = (d === 0 ? 0 : cmAverage[c]) + mean * share;
1307
- }
1308
- // Covariance of channels i, j: Σ over the sites of w_i·w_j·Var(site), with the kernels as dense weights over the window.
1309
- const dense = candidateDense[phase][d], list = candidateList[phase][d];
1310
- let v00 = 0, v01 = 0, v02 = 0, v11 = 0, v12 = 0, v22 = 0;
1311
- for (let li = 0; li < list.length; li++) {
1312
- const slot = list[li], v = siteVar[slot], w0 = dense[slot], w1 = dense[slotsCount + slot], w2 = dense[2 * slotsCount + slot];
1313
- v00 += w0 * w0 * v;
1314
- v01 += w0 * w1 * v;
1315
- v02 += w0 * w2 * v;
1316
- v11 += w1 * w1 * v;
1317
- v12 += w1 * w2 * v;
1318
- v22 += w2 * w2 * v;
1319
- }
1320
- C[0] = v00;
1321
- C[1] = v01;
1322
- C[2] = v02;
1323
- C[3] = v01;
1324
- C[4] = v11;
1325
- C[5] = v12;
1326
- C[6] = v02;
1327
- C[7] = v12;
1328
- C[8] = v22;
1329
- // White balance and the highlight clip at the smallest gain (per channel; covariances scaled by the expected slopes).
1330
- for (let c = 0; c < 3; c++) {
1331
- const mean = cm[c], variance = C[c * 4];
1332
- cv[c] = variance;
1333
- const gc = c === 0 ? g0 : c === 1 ? g1 : g2, um = gc * mean, uv = gc * gc * variance, gap = L - um;
1334
- if (gap > 0 && gap * gap > reach2 * uv) {
1335
- bm[c] = um;
1336
- bv[c] = uv;
1337
- bs[c] = gc;
1338
- }
1339
- else {
1340
- upperClip(um, uv, L, clip);
1341
- bm[c] = clip[0];
1342
- bv[c] = clip[1];
1343
- bs[c] = gc * clip[2];
1344
- }
1345
- }
1346
- // Colour matrix, then the rendering of each component (tone curve, clip to [0, 1]).
1347
- for (let comp = 0; comp < 3; comp++) {
1348
- const r0m = comp === 0 ? m00 : comp === 1 ? m10 : m20, r1m = comp === 0 ? m01 : comp === 1 ? m11 : m21, r2m = comp === 0 ? m02 : comp === 1 ? m12 : m22;
1349
- const mean = r0m * bm[0] + r1m * bm[1] + r2m * bm[2];
1350
- const variance = r0m * r0m * bv[0] + r1m * r1m * bv[1] + r2m * r2m * bv[2] +
1351
- 2 * (r0m * r1m * bs[0] * bs[1] * C[1] + r0m * r2m * bs[0] * bs[2] * C[2] + r1m * r2m * bs[1] * bs[2] * C[5]);
1352
- if (!reinhard && mean > 0 && mean < 1 && mean * mean > reach2 * variance && (1 - mean) * (1 - mean) > reach2 * variance) {
1353
- hm[comp] = mean;
1354
- hv[comp] = variance;
1355
- hs[comp] = 1;
1356
- }
1357
- else if (reinhard) {
1358
- reinhardMoments(mean, variance, clip);
1359
- hm[comp] = clip[0];
1360
- hv[comp] = clip[1];
1361
- hs[comp] = clip[2];
1362
- }
1363
- else {
1364
- // The component is a linear combination of the sites: κ₃ = Σ (Σ_c M·Γ_c·w_c,site)³·κ₃(site).
1365
- const dense = candidateDense[phase][d], list = candidateList[phase][d];
1366
- let third = 0;
1367
- for (let li = 0; li < list.length; li++) {
1368
- const slot = list[li], coefficient = r0m * bs[0] * dense[slot] + r1m * bs[1] * dense[slotsCount + slot] + r2m * bs[2] * dense[2 * slotsCount + slot];
1369
- third += coefficient * coefficient * coefficient * siteThird[slot];
1370
- }
1371
- unitClip(mean, variance, clip, variance > 0 ? third / (variance * Math.sqrt(variance)) : 0);
1372
- hm[comp] = clip[0];
1373
- hv[comp] = clip[1];
1374
- hs[comp] = clip[2];
1375
- }
1376
- }
1377
- e0 += share * hm[0];
1378
- e1 += share * hm[1];
1379
- e2 += share * hm[2];
1380
- // Linear (Stein) part: a_c = J ∘ M[:, c]·Γ_c, accumulated with B_c·σ_c² (site variance of channel c); the pixel's own
1381
- // linear variance (aᵀ·C·a, full channel covariance) for the residual.
1382
- let l0 = 0, l1 = 0, l2 = 0;
1383
- for (let c = 0; c < 3; c++) {
1384
- const a0 = hs[0] * (c === 0 ? m00 : c === 1 ? m01 : m02) * bs[c], a1 = hs[1] * (c === 0 ? m10 : c === 1 ? m11 : m12) * bs[c], a2 = hs[2] * (c === 0 ? m20 : c === 1 ? m21 : m22) * bs[c];
1385
- const wgt = share * B[c] * csv[c];
731
+ const o = mo + 3 + (d * 3 + c) * 3, a0 = memo[o], a1 = memo[o + 1], a2 = memo[o + 2], wgt = wE * share * B[c] * memo[mo + c];
1386
732
  c00 += wgt * a0 * a0;
1387
733
  c01 += wgt * a0 * a1;
1388
734
  c02 += wgt * a0 * a2;
1389
735
  c11 += wgt * a1 * a1;
1390
736
  c12 += wgt * a1 * a2;
1391
737
  c22 += wgt * a2 * a2;
1392
- an[c] = a0;
1393
- an[3 + c] = a1;
1394
- an[6 + c] = a2;
1395
- if (record) {
1396
- const o = 3 + (d * 3 + c) * 3;
1397
- record[o] = a0;
1398
- record[o + 1] = a1;
1399
- record[o + 2] = a2;
1400
- }
1401
738
  }
1402
- for (let i = 0; i < 3; i++)
1403
- for (let j = 0; j < 3; j++) {
1404
- const cij = C[i * 3 + j];
1405
- l0 += an[i] * an[j] * cij;
1406
- l1 += an[3 + i] * an[3 + j] * cij;
1407
- l2 += an[6 + i] * an[6 + j] * cij;
1408
- }
1409
- const t0 = share * Math.max(0, hv[0] - l0), t1 = share * Math.max(0, hv[1] - l1), t2 = share * Math.max(0, hv[2] - l2);
1410
- r0 += t0;
1411
- r1 += t1;
1412
- r2 += t2;
1413
- if (record) {
1414
- record[21 + d * 3] = t0;
1415
- record[22 + d * 3] = t1;
1416
- record[23 + d * 3] = t2;
1417
- }
1418
- pixelVariance = Math.max(pixelVariance, hv[0], hv[1], hv[2]);
1419
- }
1420
- s0 += e0;
1421
- s1 += e1;
1422
- s2 += e2;
1423
- if (record) {
1424
- record[0] = csv[0];
1425
- record[1] = csv[1];
1426
- record[2] = csv[2];
1427
- record[27] = e0;
1428
- record[28] = e1;
1429
- record[29] = e2;
739
+ r0 += wR * memo[mo + 21 + d * 3];
740
+ r1 += wR * memo[mo + 22 + d * 3];
741
+ r2 += wR * memo[mo + 23 + d * 3];
1430
742
  }
743
+ s0 += wE * memo[mo + 27];
744
+ s1 += wE * memo[mo + 28];
745
+ s2 += wE * memo[mo + 29];
1431
746
  if (count === 2) {
1432
- // AHD's per-pixel choice between the candidates adds independent variance: with a noise-driven choice each pixel
1433
- // deviates from the candidates' average by ±½(H − V), variance ¼·Var(H − V) (linear in the sites, at the last
1434
- // candidate's slopes).
1435
- const dh = candidateDense[phase][0], dv = candidateDense[phase][1], list = phaseList[phase];
1436
- let x0 = 0, x1 = 0, x2 = 0;
1437
- for (let li = 0; li < list.length; li++) {
1438
- const slot = list[li], d0 = bs[0] * (dh[slot] - dv[slot]), d1 = bs[1] * (dh[slotsCount + slot] - dv[slotsCount + slot]), d2 = bs[2] * (dh[2 * slotsCount + slot] - dv[2 * slotsCount + slot]);
1439
- const v = siteVar[slot], a = hs[0] * (m00 * d0 + m01 * d1 + m02 * d2), b = hs[1] * (m10 * d0 + m11 * d1 + m12 * d2), c = hs[2] * (m20 * d0 + m21 * d1 + m22 * d2);
1440
- x0 += a * a * v;
1441
- x1 += b * b * v;
1442
- x2 += c * c * v;
747
+ r0 += wR * memo[mo + 30];
748
+ r1 += wR * memo[mo + 31];
749
+ r2 += wR * memo[mo + 32];
750
+ }
751
+ if (memo[mo + 33] === 1) {
752
+ s0 += wE * memo[mo + 34];
753
+ s1 += wE * memo[mo + 35];
754
+ s2 += wE * memo[mo + 36];
755
+ corrected = true;
756
+ }
757
+ continue;
758
+ }
759
+ // The terms are written where they are kept: the real pixel's memo slot, or (for the first sample of a one-material key)
760
+ // a scratch buffer that is then copied; no typed-array view per sample.
761
+ const rt = memoKey >= 0 ? recordScratch.fill(0) : pk >= 0 ? records.fill(0, pk * MEMO_SIZE, (pk + 1) * MEMO_SIZE) : undefined, ro = memoKey < 0 && pk >= 0 ? pk * MEMO_SIZE : 0;
762
+ if (tracking)
763
+ for (let li = 0; li < list.length; li++) {
764
+ const slot = list[li];
765
+ siteStatistics(slot, siteChannel[slot], siteElectrons[slot]);
766
+ }
767
+ // Site variance of each channel through the bilinear kernels (for B_c), then the demosaic's own pixel statistics: the
768
+ // mean and covariance of its camera channels, averaged over its candidates (AHD picks one per pixel).
769
+ const slots = phaseSlots[phase], weights = phaseWeights[phase], w2 = phaseW2[phase];
770
+ for (let c = 0; c < 3; c++) {
771
+ const sl = slots[c], wt = weights[c];
772
+ let variance = 0;
773
+ for (let t = 0; t < sl.length; t++)
774
+ variance += wt[t] * wt[t] * siteVar[sl[t]];
775
+ csv[c] = variance / w2[c];
776
+ }
777
+ // Each candidate of the demosaic (one for bilinear; AHD's horizontal and vertical ones, of which AHD picks one per pixel)
778
+ // is propagated on its own: camera-channel mean and covariance, white balance and highlight clip, colour matrix and
779
+ // rendering; the pixel's statistics are their average.
780
+ let e0 = 0, e1 = 0, e2 = 0;
781
+ for (let d = 0; d < count; d++) {
782
+ for (let c = 0; c < 3; c++) {
783
+ const sl = cs[d][c], wt = cw[d][c];
784
+ let mean = 0;
785
+ for (let t = 0; t < sl.length; t++)
786
+ mean += wt[t] * siteMean[sl[t]];
787
+ cm[c] = mean;
788
+ cmAverage[c] = (d === 0 ? 0 : cmAverage[c]) + mean * share;
789
+ }
790
+ // Covariance of channels i, j: Σ over the sites of w_i·w_j·Var(site), with the kernels as dense weights over the window.
791
+ const dense = candidateDense[phase][d], list = candidateList[phase][d];
792
+ let v00 = 0, v01 = 0, v02 = 0, v11 = 0, v12 = 0, v22 = 0;
793
+ for (let li = 0; li < list.length; li++) {
794
+ const slot = list[li], v = siteVar[slot], w0 = dense[slot], w1 = dense[slotsCount + slot], w2 = dense[2 * slotsCount + slot];
795
+ v00 += w0 * w0 * v;
796
+ v01 += w0 * w1 * v;
797
+ v02 += w0 * w2 * v;
798
+ v11 += w1 * w1 * v;
799
+ v12 += w1 * w2 * v;
800
+ v22 += w2 * w2 * v;
801
+ }
802
+ C[0] = v00;
803
+ C[1] = v01;
804
+ C[2] = v02;
805
+ C[3] = v01;
806
+ C[4] = v11;
807
+ C[5] = v12;
808
+ C[6] = v02;
809
+ C[7] = v12;
810
+ C[8] = v22;
811
+ // White balance and the highlight clip at the smallest gain (per channel; covariances scaled by the expected slopes).
812
+ for (let c = 0; c < 3; c++) {
813
+ const mean = cm[c], variance = C[c * 4];
814
+ cv[c] = variance;
815
+ const gc = c === 0 ? g0 : c === 1 ? g1 : g2, um = gc * mean, uv = gc * gc * variance, gap = L - um;
816
+ if (gap > 0 && gap * gap > reach2 * uv) {
817
+ bm[c] = um;
818
+ bv[c] = uv;
819
+ bs[c] = gc;
1443
820
  }
1444
- r0 += x0 / 4;
1445
- r1 += x1 / 4;
1446
- r2 += x2 / 4;
1447
- if (record) {
1448
- record[30] = x0 / 4;
1449
- record[31] = x1 / 4;
1450
- record[32] = x2 / 4;
821
+ else {
822
+ upperClip(um, uv, L, clip);
823
+ bm[c] = clip[0];
824
+ bv[c] = clip[1];
825
+ bs[c] = gc * clip[2];
1451
826
  }
1452
827
  }
1453
- // AHD at low signal: its homogeneity choice reacts to the noise, which the linear candidates cannot express. A sample
1454
- // whose pixel noise exceeds the threshold adds the correction of a flat neighbourhood with its channel means, from the
1455
- // shared grid of states (flatCorrection): there AHD and the two linear candidates run on the same drawn neighbourhoods
1456
- // and the difference of the rendered results is averaged, a control variate whose expectation is the AHD bias
1457
- // (docs/DISPLAY_VIEW.md).
1458
- if (ahdCorrection && pixelVariance > ahdCorrection.thresholdVariance) {
1459
- const fix = flatCorrection(cmAverage[0], cmAverage[1], cmAverage[2]);
1460
- s0 += fix[0];
1461
- s1 += fix[1];
1462
- s2 += fix[2];
1463
- corrected = true;
1464
- if (record) {
1465
- record[33] = 1;
1466
- record[34] = fix[0];
1467
- record[35] = fix[1];
1468
- record[36] = fix[2];
828
+ // Colour matrix, then the rendering of each component (tone curve, clip to [0, 1]).
829
+ for (let comp = 0; comp < 3; comp++) {
830
+ const r0m = comp === 0 ? m00 : comp === 1 ? m10 : m20, r1m = comp === 0 ? m01 : comp === 1 ? m11 : m21, r2m = comp === 0 ? m02 : comp === 1 ? m12 : m22;
831
+ const mean = r0m * bm[0] + r1m * bm[1] + r2m * bm[2];
832
+ const variance = r0m * r0m * bv[0] + r1m * r1m * bv[1] + r2m * r2m * bv[2] +
833
+ 2 * (r0m * r1m * bs[0] * bs[1] * C[1] + r0m * r2m * bs[0] * bs[2] * C[2] + r1m * r2m * bs[1] * bs[2] * C[5]);
834
+ if (!reinhard && mean > 0 && mean < 1 && mean * mean > reach2 * variance && (1 - mean) * (1 - mean) > reach2 * variance) {
835
+ hm[comp] = mean;
836
+ hv[comp] = variance;
837
+ hs[comp] = 1;
838
+ }
839
+ else if (reinhard) {
840
+ reinhardMoments(mean, variance, clip);
841
+ hm[comp] = clip[0];
842
+ hv[comp] = clip[1];
843
+ hs[comp] = clip[2];
844
+ }
845
+ else {
846
+ // The component is a linear combination of the sites: κ₃ = Σ (Σ_c M·Γ_c·w_c,site)³·κ₃(site).
847
+ const dense = candidateDense[phase][d], list = candidateList[phase][d];
848
+ let third = 0;
849
+ for (let li = 0; li < list.length; li++) {
850
+ const slot = list[li], coefficient = r0m * bs[0] * dense[slot] + r1m * bs[1] * dense[slotsCount + slot] + r2m * bs[2] * dense[2 * slotsCount + slot];
851
+ third += coefficient * coefficient * coefficient * siteThird[slot];
852
+ }
853
+ unitClip(mean, variance, clip, variance > 0 ? third / (variance * Math.sqrt(variance)) : 0);
854
+ hm[comp] = clip[0];
855
+ hv[comp] = clip[1];
856
+ hs[comp] = clip[2];
1469
857
  }
1470
858
  }
1471
- if (record && memoKey >= 0) {
1472
- sampleMemo.set(memoKey, record);
1473
- if (pk >= 0)
1474
- pixelRecords.set(record, pk * MEMO_SIZE);
859
+ e0 += share * hm[0];
860
+ e1 += share * hm[1];
861
+ e2 += share * hm[2];
862
+ // Linear (Stein) part: a_c = J ∘ M[:, c]·Γ_c, accumulated with B_c·σ_c² (site variance of channel c); the pixel's own
863
+ // linear variance (aᵀ·C·a, full channel covariance) for the residual.
864
+ let l0 = 0, l1 = 0, l2 = 0;
865
+ for (let c = 0; c < 3; c++) {
866
+ const a0 = hs[0] * (c === 0 ? m00 : c === 1 ? m01 : m02) * bs[c], a1 = hs[1] * (c === 0 ? m10 : c === 1 ? m11 : m12) * bs[c], a2 = hs[2] * (c === 0 ? m20 : c === 1 ? m21 : m22) * bs[c];
867
+ const wgt = wE * share * B[c] * csv[c];
868
+ c00 += wgt * a0 * a0;
869
+ c01 += wgt * a0 * a1;
870
+ c02 += wgt * a0 * a2;
871
+ c11 += wgt * a1 * a1;
872
+ c12 += wgt * a1 * a2;
873
+ c22 += wgt * a2 * a2;
874
+ an[c] = a0;
875
+ an[3 + c] = a1;
876
+ an[6 + c] = a2;
877
+ if (rt) {
878
+ const o = 3 + (d * 3 + c) * 3;
879
+ rt[ro + o] = a0;
880
+ rt[ro + o + 1] = a1;
881
+ rt[ro + o + 2] = a2;
882
+ }
1475
883
  }
884
+ for (let i = 0; i < 3; i++)
885
+ for (let j = 0; j < 3; j++) {
886
+ const cij = C[i * 3 + j];
887
+ l0 += an[i] * an[j] * cij;
888
+ l1 += an[3 + i] * an[3 + j] * cij;
889
+ l2 += an[6 + i] * an[6 + j] * cij;
890
+ }
891
+ const t0 = share * Math.max(0, hv[0] - l0), t1 = share * Math.max(0, hv[1] - l1), t2 = share * Math.max(0, hv[2] - l2);
892
+ r0 += wR * t0;
893
+ r1 += wR * t1;
894
+ r2 += wR * t2;
895
+ if (rt) {
896
+ rt[ro + 21 + d * 3] = t0;
897
+ rt[ro + 22 + d * 3] = t1;
898
+ rt[ro + 23 + d * 3] = t2;
899
+ }
900
+ pixelVariance = Math.max(pixelVariance, hv[0], hv[1], hv[2]);
901
+ }
902
+ s0 += wE * e0;
903
+ s1 += wE * e1;
904
+ s2 += wE * e2;
905
+ if (rt) {
906
+ rt[ro + 0] = csv[0];
907
+ rt[ro + 1] = csv[1];
908
+ rt[ro + 2] = csv[2];
909
+ rt[ro + 27] = e0;
910
+ rt[ro + 28] = e1;
911
+ rt[ro + 29] = e2;
912
+ }
913
+ if (count === 2) {
914
+ // AHD's per-pixel choice between the candidates adds independent variance: with a noise-driven choice each pixel
915
+ // deviates from the candidates' average by ±½(H − V), variance ¼·Var(H − V) (linear in the sites, at the last
916
+ // candidate's slopes).
917
+ const dh = candidateDense[phase][0], dv = candidateDense[phase][1], list = phaseList[phase];
918
+ let x0 = 0, x1 = 0, x2 = 0;
919
+ for (let li = 0; li < list.length; li++) {
920
+ const slot = list[li], d0 = bs[0] * (dh[slot] - dv[slot]), d1 = bs[1] * (dh[slotsCount + slot] - dv[slotsCount + slot]), d2 = bs[2] * (dh[2 * slotsCount + slot] - dv[2 * slotsCount + slot]);
921
+ const v = siteVar[slot], a = hs[0] * (m00 * d0 + m01 * d1 + m02 * d2), b = hs[1] * (m10 * d0 + m11 * d1 + m12 * d2), c = hs[2] * (m20 * d0 + m21 * d1 + m22 * d2);
922
+ x0 += a * a * v;
923
+ x1 += b * b * v;
924
+ x2 += c * c * v;
925
+ }
926
+ r0 += wR * (x0 / 4);
927
+ r1 += wR * (x1 / 4);
928
+ r2 += wR * (x2 / 4);
929
+ if (rt) {
930
+ rt[ro + 30] = x0 / 4;
931
+ rt[ro + 31] = x1 / 4;
932
+ rt[ro + 32] = x2 / 4;
933
+ }
934
+ }
935
+ // AHD at low signal: its homogeneity choice reacts to the noise, which the linear candidates cannot express. A sample
936
+ // whose pixel noise exceeds the threshold adds the correction of a flat neighbourhood with its channel means, from the
937
+ // shared grid of states (flatCorrection): there AHD and the two linear candidates run on the same drawn neighbourhoods
938
+ // and the difference of the rendered results is averaged, a control variate whose expectation is the AHD bias
939
+ // (docs/DISPLAY_VIEW.md).
940
+ if (ahdCorrection && pixelVariance > ahdCorrection.thresholdVariance) {
941
+ const fix = flatCorrection(cmAverage[0], cmAverage[1], cmAverage[2]);
942
+ s0 += wE * fix[0];
943
+ s1 += wE * fix[1];
944
+ s2 += wE * fix[2];
945
+ corrected = true;
946
+ if (rt) {
947
+ rt[ro + 33] = 1;
948
+ rt[ro + 34] = fix[0];
949
+ rt[ro + 35] = fix[1];
950
+ rt[ro + 36] = fix[2];
951
+ }
952
+ }
953
+ if (rt && memoKey >= 0) {
954
+ sampleMemo.set(memoKey, rt.slice());
1476
955
  if (pk >= 0)
1477
- pixelStamp[pk] = pixelGeneration;
1478
- pixelVariance = 0;
956
+ records.set(rt, pk * MEMO_SIZE);
1479
957
  }
1480
- sampleCount += Q;
958
+ if (pk >= 0)
959
+ stamps[pk] = stamp;
960
+ pixelVariance = 0;
961
+ }
962
+ sampleCount += sampleTotal;
1481
963
  if (corrected)
1482
964
  correctedPixels++;
1483
- const mean0 = s0 / Q, mean1 = s1 / Q, mean2 = s2 / Q, pixels = B[3];
965
+ // Stratified: means over Q samples. Exact: the weights already sum to 1 (Σ a_j = 1) and the residual carries Σ a_j²·r_j.
966
+ const Qd = exactView ? 1 : Q, mean0 = s0 / Qd, mean1 = s1 / Qd, mean2 = s2 / Qd, pixels = B[3];
1484
967
  // The real pixels' output rounding (Δ²/12 in the encoded domain), mapped to linear light at the mean.
1485
968
  const q0 = quantizationVariance(mean0, step), q1 = quantizationVariance(mean1, step), q2 = quantizationVariance(mean2, step);
1486
969
  // Covariance in linear light: the linear part plus the independent per-pixel residuals and rounding.
1487
- const v00 = c00 / Q + pixels * (r0 / Q + q0), v01 = c01 / Q, v02 = c02 / Q, v11 = c11 / Q + pixels * (r1 / Q + q1), v12 = c12 / Q, v22 = c22 / Q + pixels * (r2 / Q + q2);
970
+ const v00 = exactView ? c00 + r0 + pixels * q0 : c00 / Q + pixels * (r0 / Q + q0), v01 = c01 / Qd, v02 = c02 / Qd, v11 = exactView ? c11 + r1 + pixels * q1 : c11 / Q + pixels * (r1 / Q + q1), v12 = c12 / Qd, v22 = exactView ? c22 + r2 + pixels * q2 : c22 / Q + pixels * (r2 / Q + q2);
1488
971
  const noise = createDeterministicUniformStream(deriveNativeSiteStreamSeedUint32(seed, index, DISPLAY_VIEW_NOISE_STREAM_ID));
1489
972
  const z0 = samplePortableStandardNormal(noise), z1 = samplePortableStandardNormal(noise), z2 = samplePortableStandardNormal(noise);
1490
973
  // Cholesky factor of the 3 × 3 covariance (positive semidefinite; tiny negative pivots are treated as 0).
@@ -1504,14 +987,16 @@ export async function renderDisplayView(setup) {
1504
987
  noiseStd[o] = Math.sqrt(Math.max(0, v00));
1505
988
  noiseStd[o + 1] = Math.sqrt(Math.max(0, v11));
1506
989
  noiseStd[o + 2] = Math.sqrt(Math.max(0, v22));
1507
- if (Date.now() - lastYield >= 8) {
990
+ // The clock is read every 16 display pixels and at the end of each row (yield timing never changes the output).
991
+ if (((rx & 15) === 15 || rx === region.width - 1) && Date.now() - lastYield >= 8) {
1508
992
  await setup.yieldControl();
1509
993
  setup.checkAbort();
1510
994
  lastYield = Date.now();
1511
995
  }
1512
996
  }
1513
997
  }
1514
- return { samples, expected, noiseStd, sampleCount, siteCount, detailPixelCount: detail, uniformPixelCount: uniform, ahdCorrectedPixelCount: correctedPixels };
998
+ return { samples, expected, noiseStd, sampleCount, siteCount, detailPixelCount: detail, uniformPixelCount: uniform, ahdCorrectedPixelCount: correctedPixels,
999
+ estimation: exactView ? "exact-area-mean" : "stratified-monte-carlo" };
1515
1000
  }
1516
1001
  /**
1517
1002
  * The centre c of a normal of variance v whose clip to [0, 1] has mean `target` (bisection; c = target when the noise does