raw-webgpu 0.1.0-alpha.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.
- package/LICENSE +21 -0
- package/NOTICE +21 -0
- package/README.md +115 -0
- package/THIRD_PARTY_LICENSES.txt +2842 -0
- package/dist/decode/instantiate.d.ts +3 -0
- package/dist/decode/module.d.ts +2 -0
- package/dist/decode/session.d.ts +7 -0
- package/dist/decode/tiff-worker.d.ts +1 -0
- package/dist/decode/worker.d.ts +1 -0
- package/dist/develop/gpu.d.ts +27 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.js +710 -0
- package/dist/libraw.js +2 -0
- package/dist/libraw.wasm +0 -0
- package/dist/math.d.ts +4 -0
- package/dist/tiff/color.d.ts +17 -0
- package/dist/tiff/index.d.ts +7 -0
- package/dist/tiff/upload.d.ts +21 -0
- package/dist/tiff-worker.js +181 -0
- package/dist/types.d.ts +51 -0
- package/dist/worker.js +116 -0
- package/docs/conversion.md +52 -0
- package/native/bridge.cpp +119 -0
- package/native/build.py +229 -0
- package/native/calibration.cpp +136 -0
- package/native/direct.h +29 -0
- package/native/dng-no-xmp.patch +34 -0
- package/native/dng.cpp +42 -0
- package/native/pixels.cpp +213 -0
- package/native/raw.h +47 -0
- package/native/tiff.cpp +164 -0
- package/package.json +68 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
// src/decode/module.ts
|
|
2
|
+
var compiled;
|
|
3
|
+
async function compile() {
|
|
4
|
+
const response = await fetch(new URL("./libraw.wasm", import.meta.url));
|
|
5
|
+
if (!response.ok) {
|
|
6
|
+
throw Error("Could not load the native decoder.");
|
|
7
|
+
}
|
|
8
|
+
const type = response.headers.get("content-type")?.split(";")[0].trim();
|
|
9
|
+
if (type === "application/wasm" && "compileStreaming" in WebAssembly) {
|
|
10
|
+
return WebAssembly.compileStreaming(response);
|
|
11
|
+
}
|
|
12
|
+
return WebAssembly.compile(await response.arrayBuffer());
|
|
13
|
+
}
|
|
14
|
+
function compileDecoder() {
|
|
15
|
+
compiled ??= compile().catch((error) => {
|
|
16
|
+
compiled = undefined;
|
|
17
|
+
throw error;
|
|
18
|
+
});
|
|
19
|
+
return compiled;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// src/decode/session.ts
|
|
23
|
+
function createSession(compiled2) {
|
|
24
|
+
const worker = new Worker(new URL("./worker.js", import.meta.url), {
|
|
25
|
+
type: "module"
|
|
26
|
+
});
|
|
27
|
+
const pending = new Map;
|
|
28
|
+
let sequence = 0;
|
|
29
|
+
let failure;
|
|
30
|
+
function dispose(error = Error("RAW source is closed.")) {
|
|
31
|
+
failure = error;
|
|
32
|
+
worker.terminate();
|
|
33
|
+
for (const request of pending.values()) {
|
|
34
|
+
request.reject(error);
|
|
35
|
+
}
|
|
36
|
+
pending.clear();
|
|
37
|
+
}
|
|
38
|
+
compiled2.then((module) => worker.postMessage({ module }), (error) => dispose(error instanceof Error ? error : Error(String(error))));
|
|
39
|
+
worker.onmessage = ({
|
|
40
|
+
data
|
|
41
|
+
}) => {
|
|
42
|
+
const request = pending.get(data.id);
|
|
43
|
+
pending.delete(data.id);
|
|
44
|
+
if ("error" in data) {
|
|
45
|
+
request?.reject(Error(data.error));
|
|
46
|
+
} else {
|
|
47
|
+
request?.resolve(data);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
worker.onerror = (event) => {
|
|
51
|
+
event.preventDefault();
|
|
52
|
+
dispose(Error(event.message || "RAW decoding worker failed."));
|
|
53
|
+
};
|
|
54
|
+
worker.onmessageerror = () => dispose(Error("Could not read the RAW worker response."));
|
|
55
|
+
return {
|
|
56
|
+
request(value) {
|
|
57
|
+
if (failure) {
|
|
58
|
+
return Promise.reject(failure);
|
|
59
|
+
}
|
|
60
|
+
const id = ++sequence;
|
|
61
|
+
const result = Promise.withResolvers();
|
|
62
|
+
pending.set(id, result);
|
|
63
|
+
try {
|
|
64
|
+
worker.postMessage({ id, value });
|
|
65
|
+
} catch (error) {
|
|
66
|
+
pending.delete(id);
|
|
67
|
+
result.reject(error);
|
|
68
|
+
}
|
|
69
|
+
return result.promise;
|
|
70
|
+
},
|
|
71
|
+
dispose
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// src/develop/develop.wgsl
|
|
76
|
+
var develop_default = `// Sensor samples in, unclipped linear Rec.2020 out: normalize, white balance,
|
|
77
|
+
// demosaic when needed, correct vignetting, convert color and apply exposure.
|
|
78
|
+
|
|
79
|
+
struct Sensor {
|
|
80
|
+
black: vec4f,
|
|
81
|
+
patternSize: vec4u,
|
|
82
|
+
white: f32,
|
|
83
|
+
flip: u32,
|
|
84
|
+
mosaic: u32,
|
|
85
|
+
floating: u32,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
struct Calibration {
|
|
89
|
+
gains: vec3f,
|
|
90
|
+
exposure: f32,
|
|
91
|
+
matrix: mat3x3f,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
struct Vignette {
|
|
95
|
+
coefficients: vec4f,
|
|
96
|
+
origin: vec2f,
|
|
97
|
+
step: vec2f,
|
|
98
|
+
last: f32,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
@group(0) @binding(0) var source: texture_2d<u32>;
|
|
102
|
+
@group(0) @binding(1) var<uniform> sensor: Sensor;
|
|
103
|
+
@group(0) @binding(2) var<uniform> calibration: Calibration;
|
|
104
|
+
@group(0) @binding(3) var<uniform> vignette: Vignette;
|
|
105
|
+
|
|
106
|
+
@group(0) @binding(4) var<uniform> pattern: array<vec4u, 9>;
|
|
107
|
+
|
|
108
|
+
// CFA color index at a sensor position: R=0, G=1 or 3, B=2.
|
|
109
|
+
fn channel(p: vec2i) -> u32 {
|
|
110
|
+
if (sensor.patternSize.x == 2u) {
|
|
111
|
+
return pattern[0][(p.y & 1) * 2 + (p.x & 1)];
|
|
112
|
+
}
|
|
113
|
+
let side = i32(sensor.patternSize.x);
|
|
114
|
+
let q = (p % side + side) % side;
|
|
115
|
+
let i = u32(q.y * side + q.x);
|
|
116
|
+
return pattern[i / 4u][i % 4u];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// One normalized, white-balanced sample; both greens share the green gain.
|
|
120
|
+
fn sample(p: vec2i, size: vec2i) -> f32 {
|
|
121
|
+
// Reflect at the border without changing CFA parity.
|
|
122
|
+
let q = min(abs(p), 2 * (size - 1) - p);
|
|
123
|
+
let c = channel(q);
|
|
124
|
+
let value = f32(textureLoad(source, q, 0).r);
|
|
125
|
+
let gain = calibration.gains[select(c, 1u, c == 3u)];
|
|
126
|
+
return max(value - sensor.black[c], 0.0) / (sensor.white - sensor.black[c]) * gain;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Malvar, He and Cutler, ICASSP 2004, Fig. 2: gradient-corrected 5x5 Bayer filters.
|
|
130
|
+
fn demosaic(p: vec2i, size: vec2i) -> vec3f {
|
|
131
|
+
let center = sample(p, size);
|
|
132
|
+
let horizontal = sample(p + vec2i(-1, 0), size) + sample(p + vec2i(1, 0), size);
|
|
133
|
+
let vertical = sample(p + vec2i(0, -1), size) + sample(p + vec2i(0, 1), size);
|
|
134
|
+
let horizontal2 = sample(p + vec2i(-2, 0), size) + sample(p + vec2i(2, 0), size);
|
|
135
|
+
let vertical2 = sample(p + vec2i(0, -2), size) + sample(p + vec2i(0, 2), size);
|
|
136
|
+
let diagonal = sample(p + vec2i(-1, -1), size) + sample(p + vec2i(1, -1), size)
|
|
137
|
+
+ sample(p + vec2i(-1, 1), size) + sample(p + vec2i(1, 1), size);
|
|
138
|
+
let c = channel(p);
|
|
139
|
+
|
|
140
|
+
// At a red or blue site: green from the cross, the opposite color from the diagonals.
|
|
141
|
+
if (c == 0u || c == 2u) {
|
|
142
|
+
let green = (4.0 * center + 2.0 * (horizontal + vertical) - horizontal2 - vertical2) / 8.0;
|
|
143
|
+
let opposite = (6.0 * center + 2.0 * diagonal - 1.5 * (horizontal2 + vertical2)) / 8.0;
|
|
144
|
+
return select(vec3f(opposite, green, center), vec3f(center, green, opposite), c == 0u);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// At a green site: the color of the row neighbors uses the row filter, the other the column filter.
|
|
148
|
+
let across = (5.0 * center + 4.0 * horizontal - horizontal2 - diagonal + 0.5 * vertical2) / 8.0;
|
|
149
|
+
let along = (5.0 * center + 4.0 * vertical - vertical2 - diagonal + 0.5 * horizontal2) / 8.0;
|
|
150
|
+
return select(vec3f(along, center, across), vec3f(across, center, along), channel(p + vec2i(1, 0)) == 0u);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Normalized convolution for larger RGB mosaics. Preserve the measured channel and
|
|
154
|
+
// reconstruct missing colors from nearby sites, weighted by inverse fourth-power distance.
|
|
155
|
+
fn demosaicPattern(p: vec2i, size: vec2i) -> vec3f {
|
|
156
|
+
var sum = vec3f(0.0);
|
|
157
|
+
var weight = vec3f(0.0);
|
|
158
|
+
for (var y = -3; y <= 3; y++) {
|
|
159
|
+
for (var x = -3; x <= 3; x++) {
|
|
160
|
+
let q = p + vec2i(x, y);
|
|
161
|
+
if (any(q < vec2i(0)) || any(q >= size)) { continue; }
|
|
162
|
+
let c = channel(q);
|
|
163
|
+
let rgb = select(c, 1u, c == 3u);
|
|
164
|
+
let distance2 = f32(max(x * x + y * y, 1));
|
|
165
|
+
let w = 1.0 / (distance2 * distance2);
|
|
166
|
+
sum[rgb] += sample(q, size) * w;
|
|
167
|
+
weight[rgb] += w;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
var color = sum / max(weight, vec3f(0.00001));
|
|
171
|
+
let c = channel(p);
|
|
172
|
+
color[select(c, 1u, c == 3u)] = sample(p, size);
|
|
173
|
+
return color;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
@fragment fn fs_main(@builtin(position) position: vec4f) -> @location(0) vec4f {
|
|
177
|
+
// Map the oriented output pixel back to its sensor position.
|
|
178
|
+
let size = vec2i(textureDimensions(source));
|
|
179
|
+
var p = vec2i(position.xy);
|
|
180
|
+
if ((sensor.flip & 4u) != 0u) { p = p.yx; }
|
|
181
|
+
if ((sensor.flip & 2u) != 0u) { p.y = size.y - 1 - p.y; }
|
|
182
|
+
if ((sensor.flip & 1u) != 0u) { p.x = size.x - 1 - p.x; }
|
|
183
|
+
|
|
184
|
+
var camera: vec3f;
|
|
185
|
+
if (sensor.mosaic != 0u) {
|
|
186
|
+
if (sensor.patternSize.x == 2u) {
|
|
187
|
+
camera = demosaic(p, size);
|
|
188
|
+
} else {
|
|
189
|
+
camera = demosaicPattern(p, size);
|
|
190
|
+
}
|
|
191
|
+
} else {
|
|
192
|
+
let bits = textureLoad(source, p, 0).rgb;
|
|
193
|
+
let value = select(vec3f(bits), bitcast<vec3f>(bits), sensor.floating != 0u);
|
|
194
|
+
camera = max(value - sensor.black.rgb, vec3f(0.0))
|
|
195
|
+
/ (vec3f(sensor.white) - sensor.black.rgb) * calibration.gains;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// DNG FixVignetteRadial: a polynomial in r² over normalized coordinates.
|
|
199
|
+
let q = (vec2f(p) + 0.5) * vignette.step + vignette.origin;
|
|
200
|
+
let r2 = dot(q, q);
|
|
201
|
+
let k = vignette.coefficients;
|
|
202
|
+
let gain = 1.0 + r2 * (k.x + r2 * (k.y + r2 * (k.z + r2 * (k.w + r2 * vignette.last))));
|
|
203
|
+
|
|
204
|
+
return vec4f(calibration.matrix * camera * gain * calibration.exposure, 1.0);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
@group(0) @binding(5) var cameraSource: texture_2d<f32>;
|
|
208
|
+
|
|
209
|
+
// Cached X-Trans camera RGB: subsequent edits only apply per-pixel linear transforms.
|
|
210
|
+
@fragment fn fs_camera(@builtin(position) position: vec4f) -> @location(0) vec4f {
|
|
211
|
+
let camera = textureLoad(cameraSource, vec2i(position.xy), 0).rgb;
|
|
212
|
+
return vec4f(calibration.matrix * (camera * calibration.gains) * calibration.exposure, 1.0);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// One triangle covering the whole destination.
|
|
216
|
+
@vertex fn vs_main(@builtin(vertex_index) index: u32) -> @builtin(position) vec4f {
|
|
217
|
+
let points = array<vec2f, 3>(vec2f(-1, -1), vec2f(3, -1), vec2f(-1, 3));
|
|
218
|
+
return vec4f(points[index], 0, 1);
|
|
219
|
+
}
|
|
220
|
+
`;
|
|
221
|
+
|
|
222
|
+
// src/develop/gpu.ts
|
|
223
|
+
async function createPipeline(device) {
|
|
224
|
+
const module = device.createShaderModule({ code: develop_default });
|
|
225
|
+
function create(entryPoint) {
|
|
226
|
+
return device.createRenderPipelineAsync({
|
|
227
|
+
layout: "auto",
|
|
228
|
+
vertex: { module, entryPoint: "vs_main" },
|
|
229
|
+
fragment: { module, entryPoint, targets: [{ format: "rgba16float" }] },
|
|
230
|
+
primitive: { topology: "triangle-list" }
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
const [sensor, camera] = await Promise.all([
|
|
234
|
+
create("fs_main"),
|
|
235
|
+
create("fs_camera")
|
|
236
|
+
]);
|
|
237
|
+
return { sensor, camera };
|
|
238
|
+
}
|
|
239
|
+
function createUniform(device, data) {
|
|
240
|
+
const buffer = device.createBuffer({
|
|
241
|
+
size: data.byteLength,
|
|
242
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
|
|
243
|
+
});
|
|
244
|
+
device.queue.writeBuffer(buffer, 0, data);
|
|
245
|
+
return buffer;
|
|
246
|
+
}
|
|
247
|
+
function createSensorUniform(device, metadata) {
|
|
248
|
+
const data = new ArrayBuffer(48);
|
|
249
|
+
const floats = new Float32Array(data);
|
|
250
|
+
const integers = new Uint32Array(data);
|
|
251
|
+
floats.set(metadata.black);
|
|
252
|
+
integers[4] = metadata.cfaSize ?? 2;
|
|
253
|
+
floats[8] = metadata.white;
|
|
254
|
+
integers[9] = metadata.flip;
|
|
255
|
+
integers[10] = Number(metadata.cfa !== null);
|
|
256
|
+
integers[11] = Number(metadata.sampleFormat === "float32");
|
|
257
|
+
return createUniform(device, data);
|
|
258
|
+
}
|
|
259
|
+
function createVignetteUniform(device, metadata) {
|
|
260
|
+
const data = new Float32Array(12);
|
|
261
|
+
data.set(metadata.vignette.slice(0, 4));
|
|
262
|
+
data.set(metadata.vignette.slice(5, 9), 4);
|
|
263
|
+
data[8] = metadata.vignette[4];
|
|
264
|
+
return createUniform(device, data.buffer);
|
|
265
|
+
}
|
|
266
|
+
function createGpuSource(device, pixels, pipeline) {
|
|
267
|
+
const { data, ...metadata } = pixels;
|
|
268
|
+
const mosaic = metadata.cfa !== null;
|
|
269
|
+
const size = metadata.flip & 4 ? [metadata.size[1], metadata.size[0]] : metadata.size;
|
|
270
|
+
const floating = metadata.sampleFormat === "float32";
|
|
271
|
+
const integerFormat = mosaic ? "r16uint" : "rgba16uint";
|
|
272
|
+
const format = floating ? "rgba32uint" : integerFormat;
|
|
273
|
+
const texture = device.createTexture({
|
|
274
|
+
size: metadata.size,
|
|
275
|
+
format,
|
|
276
|
+
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.COPY_SRC | GPUTextureUsage.RENDER_ATTACHMENT
|
|
277
|
+
});
|
|
278
|
+
const uniforms = [];
|
|
279
|
+
const passes = new Set;
|
|
280
|
+
let cameraTexture;
|
|
281
|
+
let closed = false;
|
|
282
|
+
function dispose() {
|
|
283
|
+
if (closed) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
closed = true;
|
|
287
|
+
for (const close of passes) {
|
|
288
|
+
close();
|
|
289
|
+
}
|
|
290
|
+
for (const buffer of uniforms) {
|
|
291
|
+
buffer.destroy();
|
|
292
|
+
}
|
|
293
|
+
texture.destroy();
|
|
294
|
+
cameraTexture?.destroy();
|
|
295
|
+
}
|
|
296
|
+
function createPass(renderPipeline, entries) {
|
|
297
|
+
if (closed) {
|
|
298
|
+
throw Error("RAW source is closed.");
|
|
299
|
+
}
|
|
300
|
+
const calibrationData = new Float32Array(16);
|
|
301
|
+
const calibrationBuffer = createUniform(device, calibrationData.buffer);
|
|
302
|
+
const group = device.createBindGroup({
|
|
303
|
+
layout: renderPipeline.getBindGroupLayout(0),
|
|
304
|
+
entries: [
|
|
305
|
+
...entries,
|
|
306
|
+
{ binding: 2, resource: { buffer: calibrationBuffer } }
|
|
307
|
+
]
|
|
308
|
+
});
|
|
309
|
+
let disposed = false;
|
|
310
|
+
function close() {
|
|
311
|
+
disposed = true;
|
|
312
|
+
calibrationBuffer.destroy();
|
|
313
|
+
passes.delete(close);
|
|
314
|
+
}
|
|
315
|
+
passes.add(close);
|
|
316
|
+
return {
|
|
317
|
+
render(options) {
|
|
318
|
+
if (disposed || closed) {
|
|
319
|
+
throw Error("RAW development pass is closed.");
|
|
320
|
+
}
|
|
321
|
+
const { destination, calibration, exposure = 0 } = options;
|
|
322
|
+
if (destination.format !== "rgba16float" || destination.width !== size[0] || destination.height !== size[1]) {
|
|
323
|
+
throw Error("RAW destination must be an rgba16float texture matching the oriented source size.");
|
|
324
|
+
}
|
|
325
|
+
calibrationData.set(calibration.gains);
|
|
326
|
+
calibrationData[3] = 2 ** exposure;
|
|
327
|
+
for (let column = 0;column < 3; column++) {
|
|
328
|
+
calibrationData.set(calibration.matrix.slice(column * 3, column * 3 + 3), 4 + column * 4);
|
|
329
|
+
}
|
|
330
|
+
device.queue.writeBuffer(calibrationBuffer, 0, calibrationData);
|
|
331
|
+
const encoder = options.encoder ?? device.createCommandEncoder();
|
|
332
|
+
const pass = encoder.beginRenderPass({
|
|
333
|
+
colorAttachments: [
|
|
334
|
+
{
|
|
335
|
+
view: destination.createView(),
|
|
336
|
+
loadOp: "clear",
|
|
337
|
+
storeOp: "store"
|
|
338
|
+
}
|
|
339
|
+
]
|
|
340
|
+
});
|
|
341
|
+
pass.setPipeline(renderPipeline);
|
|
342
|
+
pass.setBindGroup(0, group);
|
|
343
|
+
pass.draw(3);
|
|
344
|
+
pass.end();
|
|
345
|
+
if (!options.encoder) {
|
|
346
|
+
device.queue.submit([encoder.finish()]);
|
|
347
|
+
}
|
|
348
|
+
},
|
|
349
|
+
dispose: close
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
try {
|
|
353
|
+
if (data.byteLength > device.limits.maxBufferSize) {
|
|
354
|
+
const encoder = device.createCommandEncoder();
|
|
355
|
+
encoder.beginRenderPass({
|
|
356
|
+
colorAttachments: [
|
|
357
|
+
{ view: texture.createView(), loadOp: "clear", storeOp: "store" }
|
|
358
|
+
]
|
|
359
|
+
}).end();
|
|
360
|
+
device.queue.submit([encoder.finish()]);
|
|
361
|
+
}
|
|
362
|
+
const bytesPerPixel = data.BYTES_PER_ELEMENT * (mosaic ? 1 : 4);
|
|
363
|
+
const bytesPerRow = metadata.size[0] * bytesPerPixel;
|
|
364
|
+
const rowsPerUpload = Math.floor(device.limits.maxBufferSize / (Math.ceil(bytesPerRow / 256) * 256));
|
|
365
|
+
for (let y = 0;y < metadata.size[1]; y += rowsPerUpload) {
|
|
366
|
+
const height = Math.min(rowsPerUpload, metadata.size[1] - y);
|
|
367
|
+
device.queue.writeTexture({ texture, origin: [0, y] }, data.buffer, { offset: data.byteOffset + y * bytesPerRow, bytesPerRow }, [metadata.size[0], height]);
|
|
368
|
+
}
|
|
369
|
+
const sensor = createSensorUniform(device, metadata);
|
|
370
|
+
uniforms.push(sensor);
|
|
371
|
+
const pattern = new Uint32Array(36);
|
|
372
|
+
pattern.set(metadata.cfa ?? []);
|
|
373
|
+
const patternBuffer = createUniform(device, pattern.buffer);
|
|
374
|
+
uniforms.push(patternBuffer);
|
|
375
|
+
const vignette = createVignetteUniform(device, metadata);
|
|
376
|
+
uniforms.push(vignette);
|
|
377
|
+
const sensorEntries = [
|
|
378
|
+
{ binding: 0, resource: texture.createView() },
|
|
379
|
+
{ binding: 1, resource: { buffer: sensor } },
|
|
380
|
+
{ binding: 3, resource: { buffer: vignette } },
|
|
381
|
+
{ binding: 4, resource: { buffer: patternBuffer } }
|
|
382
|
+
];
|
|
383
|
+
if (mosaic && (metadata.cfaSize ?? 2) > 2) {
|
|
384
|
+
cameraTexture = device.createTexture({
|
|
385
|
+
size,
|
|
386
|
+
format: "rgba16float",
|
|
387
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING
|
|
388
|
+
});
|
|
389
|
+
const prepare = createPass(pipeline.sensor, sensorEntries);
|
|
390
|
+
prepare.render({
|
|
391
|
+
destination: cameraTexture,
|
|
392
|
+
calibration: {
|
|
393
|
+
gains: [1, 1, 1],
|
|
394
|
+
matrix: [1, 0, 0, 0, 1, 0, 0, 0, 1]
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
prepare.dispose();
|
|
398
|
+
}
|
|
399
|
+
return {
|
|
400
|
+
texture,
|
|
401
|
+
metadata,
|
|
402
|
+
size,
|
|
403
|
+
createDevelopPass() {
|
|
404
|
+
if (cameraTexture) {
|
|
405
|
+
return createPass(pipeline.camera, [
|
|
406
|
+
{ binding: 5, resource: cameraTexture.createView() }
|
|
407
|
+
]);
|
|
408
|
+
}
|
|
409
|
+
return createPass(pipeline.sensor, sensorEntries);
|
|
410
|
+
},
|
|
411
|
+
dispose
|
|
412
|
+
};
|
|
413
|
+
} catch (error) {
|
|
414
|
+
dispose();
|
|
415
|
+
throw error;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// src/tiff/upload.wgsl
|
|
420
|
+
var upload_default = `// Interleaved TIFF samples in, linear Rec.2020 with straight alpha out, one row band per dispatch.
|
|
421
|
+
|
|
422
|
+
struct Params {
|
|
423
|
+
width: u32,
|
|
424
|
+
height: u32,
|
|
425
|
+
channels: u32,
|
|
426
|
+
bytes: u32, // per sample
|
|
427
|
+
bits: u32, // per sample; zero for float
|
|
428
|
+
orientation: u32, // TIFF orientation tag, 1 to 8
|
|
429
|
+
photometric: u32, // 0 white is zero, 1 black is zero, 2 RGB
|
|
430
|
+
alpha: u32, // 1 when the extra sample is associated (premultiplied) alpha
|
|
431
|
+
rowBytes: u32,
|
|
432
|
+
startRow: u32, // first image row in this band
|
|
433
|
+
rows: u32, // rows in this band
|
|
434
|
+
bigEndian: u32,
|
|
435
|
+
matrix: mat3x3f,
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
@group(0) @binding(0) var<storage, read> source: array<u32>;
|
|
439
|
+
@group(0) @binding(1) var<storage, read> curves: array<f32>;
|
|
440
|
+
@group(0) @binding(2) var<uniform> params: Params;
|
|
441
|
+
@group(0) @binding(3) var output: texture_storage_2d<rgba16float, write>;
|
|
442
|
+
|
|
443
|
+
// One sample at a byte offset, normalized to 0..1 for integers and left as is for floats.
|
|
444
|
+
fn sample(offset: u32) -> f32 {
|
|
445
|
+
var word = source[offset / 4u];
|
|
446
|
+
if params.bigEndian == 1u {
|
|
447
|
+
if params.bytes == 2u { word = ((word & 0x00ff00ffu) << 8u) | ((word & 0xff00ff00u) >> 8u); }
|
|
448
|
+
if params.bytes == 4u { word = (word << 24u) | ((word & 0xff00u) << 8u) | ((word >> 8u) & 0xff00u) | (word >> 24u); }
|
|
449
|
+
}
|
|
450
|
+
if params.bits == 0u { return bitcast<f32>(word); }
|
|
451
|
+
var value = word;
|
|
452
|
+
if params.bytes < 4u { value = (word >> ((offset % 4u) * 8u)) & ((1u << (params.bytes * 8u)) - 1u); }
|
|
453
|
+
return f32(value) / (exp2(f32(params.bits)) - 1.0);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Transfer curve over 0..1; samples outside keep their distance, so HDR and negative values pass through.
|
|
457
|
+
fn transfer(value: f32, channel: u32) -> f32 {
|
|
458
|
+
let last = arrayLength(&curves) / 3u - 1u;
|
|
459
|
+
let bounded = clamp(value, 0.0, 1.0);
|
|
460
|
+
let position = bounded * f32(last);
|
|
461
|
+
let low = min(u32(position), last - 1u);
|
|
462
|
+
let base = channel * (last + 1u) + low;
|
|
463
|
+
return mix(curves[base], curves[base + 1u], position - f32(low)) + (value - bounded);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Where a stored pixel lands after the orientation tag.
|
|
467
|
+
fn oriented(p: vec2u) -> vec2u {
|
|
468
|
+
let w = params.width - 1u;
|
|
469
|
+
let h = params.height - 1u;
|
|
470
|
+
switch params.orientation {
|
|
471
|
+
case 2u: { return vec2u(w - p.x, p.y); }
|
|
472
|
+
case 3u: { return vec2u(w - p.x, h - p.y); }
|
|
473
|
+
case 4u: { return vec2u(p.x, h - p.y); }
|
|
474
|
+
case 5u: { return p.yx; }
|
|
475
|
+
case 6u: { return vec2u(h - p.y, p.x); }
|
|
476
|
+
case 7u: { return vec2u(h - p.y, w - p.x); }
|
|
477
|
+
case 8u: { return vec2u(p.y, w - p.x); }
|
|
478
|
+
default: { return p; }
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
@compute @workgroup_size(16, 16) fn main(@builtin(global_invocation_id) id: vec3u) {
|
|
483
|
+
if id.x >= params.width || id.y >= params.rows { return; }
|
|
484
|
+
let offset = id.y * params.rowBytes + id.x * params.channels * params.bytes;
|
|
485
|
+
|
|
486
|
+
// Gray replicates its single sample; RGB reads three.
|
|
487
|
+
var rgb = vec3f(sample(offset));
|
|
488
|
+
var colors = 1u;
|
|
489
|
+
if params.photometric == 2u {
|
|
490
|
+
colors = 3u;
|
|
491
|
+
rgb = vec3f(rgb.r, sample(offset + params.bytes), sample(offset + 2u * params.bytes));
|
|
492
|
+
}
|
|
493
|
+
var alpha = 1.0;
|
|
494
|
+
if params.channels > colors { alpha = sample(offset + colors * params.bytes); }
|
|
495
|
+
if params.photometric == 0u { rgb = 1.0 - rgb; }
|
|
496
|
+
if params.alpha == 1u { rgb /= max(alpha, 0.000001); }
|
|
497
|
+
|
|
498
|
+
let linear = vec3f(transfer(rgb.r, 0u), transfer(rgb.g, 1u), transfer(rgb.b, 2u));
|
|
499
|
+
textureStore(output, oriented(vec2u(id.x, id.y + params.startRow)), vec4f(params.matrix * linear, alpha));
|
|
500
|
+
}
|
|
501
|
+
`;
|
|
502
|
+
|
|
503
|
+
// src/tiff/upload.ts
|
|
504
|
+
var pipelines = new WeakMap;
|
|
505
|
+
function createFilledBuffer(device, data, usage) {
|
|
506
|
+
const result = device.createBuffer({
|
|
507
|
+
size: Math.ceil(data.byteLength / 4) * 4,
|
|
508
|
+
usage,
|
|
509
|
+
mappedAtCreation: true
|
|
510
|
+
});
|
|
511
|
+
new Uint8Array(result.getMappedRange()).set(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
|
|
512
|
+
result.unmap();
|
|
513
|
+
return result;
|
|
514
|
+
}
|
|
515
|
+
function createParams(pixels, startRow, rows) {
|
|
516
|
+
const params = new ArrayBuffer(96);
|
|
517
|
+
const integers = new Uint32Array(params);
|
|
518
|
+
integers.set(pixels.metadata);
|
|
519
|
+
integers[9] = startRow;
|
|
520
|
+
integers[10] = rows;
|
|
521
|
+
integers[11] = Number(pixels.bigEndian);
|
|
522
|
+
const floats = new Float32Array(params);
|
|
523
|
+
for (let column = 0;column < 3; column++) {
|
|
524
|
+
floats.set(pixels.color.matrix.slice(column * 3, column * 3 + 3), 12 + column * 4);
|
|
525
|
+
}
|
|
526
|
+
return new Uint8Array(params);
|
|
527
|
+
}
|
|
528
|
+
async function uploadTiff(device, pixels) {
|
|
529
|
+
let pending = pipelines.get(device);
|
|
530
|
+
if (!pending) {
|
|
531
|
+
pending = device.createComputePipelineAsync({
|
|
532
|
+
layout: "auto",
|
|
533
|
+
compute: {
|
|
534
|
+
module: device.createShaderModule({ code: upload_default }),
|
|
535
|
+
entryPoint: "main"
|
|
536
|
+
}
|
|
537
|
+
}).catch((error) => {
|
|
538
|
+
pipelines.delete(device);
|
|
539
|
+
throw error;
|
|
540
|
+
});
|
|
541
|
+
pipelines.set(device, pending);
|
|
542
|
+
}
|
|
543
|
+
const pipeline = await pending;
|
|
544
|
+
const { data, metadata, color } = pixels;
|
|
545
|
+
const [width, height, , , , orientation, , , rowBytes] = metadata;
|
|
546
|
+
const size = orientation >= 5 ? [height, width] : [width, height];
|
|
547
|
+
if (size.some((value) => value > device.limits.maxTextureDimension2D)) {
|
|
548
|
+
throw Error("TIFF exceeds the device texture size limit.");
|
|
549
|
+
}
|
|
550
|
+
const rowsPerBand = Math.floor(Math.min(device.limits.maxStorageBufferBindingSize, device.limits.maxBufferSize) / rowBytes);
|
|
551
|
+
if (!rowsPerBand) {
|
|
552
|
+
throw Error("TIFF row exceeds the device buffer size limit.");
|
|
553
|
+
}
|
|
554
|
+
const texture = device.createTexture({
|
|
555
|
+
size,
|
|
556
|
+
format: "rgba16float",
|
|
557
|
+
usage: GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_SRC
|
|
558
|
+
});
|
|
559
|
+
const curves = createFilledBuffer(device, color.table, GPUBufferUsage.STORAGE);
|
|
560
|
+
try {
|
|
561
|
+
for (let y = 0;y < height; y += rowsPerBand) {
|
|
562
|
+
const rows = Math.min(rowsPerBand, height - y);
|
|
563
|
+
const source = createFilledBuffer(device, data.subarray(y * rowBytes, (y + rows) * rowBytes), GPUBufferUsage.STORAGE);
|
|
564
|
+
const params = createFilledBuffer(device, createParams(pixels, y, rows), GPUBufferUsage.UNIFORM);
|
|
565
|
+
try {
|
|
566
|
+
const bindings = device.createBindGroup({
|
|
567
|
+
layout: pipeline.getBindGroupLayout(0),
|
|
568
|
+
entries: [
|
|
569
|
+
{ binding: 0, resource: { buffer: source } },
|
|
570
|
+
{ binding: 1, resource: { buffer: curves } },
|
|
571
|
+
{ binding: 2, resource: { buffer: params } },
|
|
572
|
+
{ binding: 3, resource: texture.createView() }
|
|
573
|
+
]
|
|
574
|
+
});
|
|
575
|
+
const encoder = device.createCommandEncoder();
|
|
576
|
+
const pass = encoder.beginComputePass();
|
|
577
|
+
pass.setPipeline(pipeline);
|
|
578
|
+
pass.setBindGroup(0, bindings);
|
|
579
|
+
pass.dispatchWorkgroups(Math.ceil(width / 16), Math.ceil(rows / 16));
|
|
580
|
+
pass.end();
|
|
581
|
+
device.queue.submit([encoder.finish()]);
|
|
582
|
+
} finally {
|
|
583
|
+
source.destroy();
|
|
584
|
+
params.destroy();
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return { texture, size, dispose: () => texture.destroy() };
|
|
588
|
+
} catch (error) {
|
|
589
|
+
texture.destroy();
|
|
590
|
+
throw error;
|
|
591
|
+
} finally {
|
|
592
|
+
curves.destroy();
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// src/tiff/index.ts
|
|
597
|
+
async function decodeTiff(device, file, { signal } = {}) {
|
|
598
|
+
if (signal?.aborted) {
|
|
599
|
+
throw signal.reason;
|
|
600
|
+
}
|
|
601
|
+
const worker = new Worker(new URL("./tiff-worker.js", import.meta.url), {
|
|
602
|
+
type: "module"
|
|
603
|
+
});
|
|
604
|
+
const aborted = Promise.withResolvers();
|
|
605
|
+
const abort = () => aborted.reject(signal?.reason);
|
|
606
|
+
signal?.addEventListener("abort", abort);
|
|
607
|
+
try {
|
|
608
|
+
const module = await Promise.race([compileDecoder(), aborted.promise]);
|
|
609
|
+
const decoded = new Promise((resolve, reject) => {
|
|
610
|
+
worker.onmessage = ({ data }) => {
|
|
611
|
+
if (data.error) {
|
|
612
|
+
reject(Error(data.error));
|
|
613
|
+
} else {
|
|
614
|
+
resolve(data);
|
|
615
|
+
}
|
|
616
|
+
};
|
|
617
|
+
worker.onerror = (event) => {
|
|
618
|
+
event.preventDefault();
|
|
619
|
+
reject(Error(event.message || "TIFF decoding worker failed."));
|
|
620
|
+
};
|
|
621
|
+
worker.onmessageerror = () => reject(Error("Could not read TIFF worker response."));
|
|
622
|
+
worker.postMessage({ file, module });
|
|
623
|
+
});
|
|
624
|
+
const pixels = await Promise.race([decoded, aborted.promise]);
|
|
625
|
+
return await uploadTiff(device, pixels);
|
|
626
|
+
} finally {
|
|
627
|
+
signal?.removeEventListener("abort", abort);
|
|
628
|
+
worker.terminate();
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// src/index.ts
|
|
633
|
+
function createRawDecoder(device) {
|
|
634
|
+
let pipeline;
|
|
635
|
+
const sources = new Set;
|
|
636
|
+
let closed = false;
|
|
637
|
+
function dispose() {
|
|
638
|
+
closed = true;
|
|
639
|
+
for (const close of sources) {
|
|
640
|
+
close();
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
device.lost.then(dispose);
|
|
644
|
+
return {
|
|
645
|
+
async load(file, { signal } = {}) {
|
|
646
|
+
if (closed) {
|
|
647
|
+
throw Error("RAW decoder is closed.");
|
|
648
|
+
}
|
|
649
|
+
if (signal?.aborted) {
|
|
650
|
+
throw signal.reason;
|
|
651
|
+
}
|
|
652
|
+
const session = createSession(compileDecoder());
|
|
653
|
+
let gpuSource;
|
|
654
|
+
let disposed = false;
|
|
655
|
+
function close() {
|
|
656
|
+
disposed = true;
|
|
657
|
+
signal?.removeEventListener("abort", close);
|
|
658
|
+
gpuSource?.dispose();
|
|
659
|
+
session.dispose();
|
|
660
|
+
sources.delete(close);
|
|
661
|
+
}
|
|
662
|
+
sources.add(close);
|
|
663
|
+
signal?.addEventListener("abort", close);
|
|
664
|
+
try {
|
|
665
|
+
pipeline ??= createPipeline(device).catch((error) => {
|
|
666
|
+
pipeline = undefined;
|
|
667
|
+
throw error;
|
|
668
|
+
});
|
|
669
|
+
const [reply, gpuPipeline] = await Promise.all([
|
|
670
|
+
session.request(file),
|
|
671
|
+
pipeline
|
|
672
|
+
]);
|
|
673
|
+
if (disposed) {
|
|
674
|
+
throw Error("RAW source is closed.");
|
|
675
|
+
}
|
|
676
|
+
if (!reply.image) {
|
|
677
|
+
throw Error("RAW decoder returned no sensor samples.");
|
|
678
|
+
}
|
|
679
|
+
if (reply.image.size.some((value) => value > device.limits.maxTextureDimension2D)) {
|
|
680
|
+
throw Error("RAW image exceeds this device's texture size limit.");
|
|
681
|
+
}
|
|
682
|
+
gpuSource = createGpuSource(device, reply.image, gpuPipeline);
|
|
683
|
+
signal?.removeEventListener("abort", close);
|
|
684
|
+
return {
|
|
685
|
+
...gpuSource,
|
|
686
|
+
asShot: reply.asShot,
|
|
687
|
+
calibration: reply.calibration,
|
|
688
|
+
async calibrate(balance = reply.asShot) {
|
|
689
|
+
if (disposed) {
|
|
690
|
+
throw Error("RAW source is closed.");
|
|
691
|
+
}
|
|
692
|
+
if (!Number.isFinite(balance.temperature) || balance.temperature <= 0 || !Number.isFinite(balance.tint)) {
|
|
693
|
+
throw Error("White balance needs a positive temperature and a finite tint.");
|
|
694
|
+
}
|
|
695
|
+
return (await session.request(balance)).calibration;
|
|
696
|
+
},
|
|
697
|
+
dispose: close
|
|
698
|
+
};
|
|
699
|
+
} catch (error) {
|
|
700
|
+
close();
|
|
701
|
+
throw signal?.aborted ? signal.reason : error;
|
|
702
|
+
}
|
|
703
|
+
},
|
|
704
|
+
dispose
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
export {
|
|
708
|
+
decodeTiff,
|
|
709
|
+
createRawDecoder
|
|
710
|
+
};
|