diva.js 6.0.1 → 7.2.3

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 (133) hide show
  1. package/.clang-format +7 -0
  2. package/.github/workflows/npm-publish.yml +45 -0
  3. package/LICENSE +55 -0
  4. package/Makefile +75 -0
  5. package/README.md +15 -108
  6. package/elm.json +32 -0
  7. package/package.json +12 -59
  8. package/review/elm.json +52 -0
  9. package/review/src/ReviewConfig.elm +87 -0
  10. package/scripts/elm-esm.sh +40 -0
  11. package/scripts/minify-css.mjs +31 -0
  12. package/src/Filters.elm +1044 -0
  13. package/src/Main.elm +1217 -0
  14. package/src/Model.elm +213 -0
  15. package/src/Msg.elm +59 -0
  16. package/src/Utilities.elm +46 -0
  17. package/src/View/CollectionExplorer.elm +172 -0
  18. package/src/View/Helpers.elm +86 -0
  19. package/src/View/HtmlRenderer.elm +136 -0
  20. package/src/View/Icons.elm +159 -0
  21. package/src/View/ManifestInfoModal.elm +363 -0
  22. package/src/View/PageViewModal.elm +1046 -0
  23. package/src/View/Sidebar.elm +786 -0
  24. package/src/View/Toolbar.elm +189 -0
  25. package/src/View.elm +244 -0
  26. package/src/diva.ts +802 -0
  27. package/src/filters.ts +1843 -0
  28. package/src/styles/app.css +328 -0
  29. package/src/styles/collection.css +75 -0
  30. package/src/styles/modal.css +388 -0
  31. package/src/styles/sidebar.css +215 -0
  32. package/src/styles/theme.css +39 -0
  33. package/src/styles/toolbar.css +154 -0
  34. package/src/viewer-element.ts +1307 -0
  35. package/testing/index.html +52 -0
  36. package/testing/testing.html +231 -0
  37. package/tsconfig.json +12 -0
  38. package/AUTHORS +0 -22
  39. package/_site/diva.iml +0 -11
  40. package/build/diva.css +0 -554
  41. package/build/diva.css.map +0 -1
  42. package/build/diva.js +0 -9
  43. package/build/diva.js.map +0 -1
  44. package/build/plugins/download.js +0 -2
  45. package/build/plugins/download.js.map +0 -1
  46. package/build/plugins/manipulation.js +0 -2
  47. package/build/plugins/manipulation.js.map +0 -1
  48. package/build/plugins/metadata.js +0 -2
  49. package/build/plugins/metadata.js.map +0 -1
  50. package/diva.iml +0 -11
  51. package/index.html +0 -28
  52. package/karma.conf.js +0 -87
  53. package/source/css/_mixins.scss +0 -43
  54. package/source/css/_variables.scss +0 -50
  55. package/source/css/_viewer.scss +0 -462
  56. package/source/css/diva.scss +0 -15
  57. package/source/css/plugins/_manipulation.scss +0 -228
  58. package/source/css/plugins/_metadata.scss +0 -31
  59. package/source/img/adjust.svg +0 -11
  60. package/source/img/book-view.svg +0 -6
  61. package/source/img/close.svg +0 -6
  62. package/source/img/download.svg +0 -6
  63. package/source/img/from-fullscreen.svg +0 -8
  64. package/source/img/grid-fewer.svg +0 -6
  65. package/source/img/grid-more.svg +0 -6
  66. package/source/img/grid-view.svg +0 -6
  67. package/source/img/link.svg +0 -6
  68. package/source/img/metadata.svg +0 -9
  69. package/source/img/page-view.svg +0 -6
  70. package/source/img/to-fullscreen.svg +0 -11
  71. package/source/img/zoom-in.svg +0 -6
  72. package/source/img/zoom-out.svg +0 -7
  73. package/source/js/composite-image.js +0 -174
  74. package/source/js/diva-global.js +0 -7
  75. package/source/js/diva.js +0 -1543
  76. package/source/js/document-handler.js +0 -180
  77. package/source/js/document-layout.js +0 -286
  78. package/source/js/exceptions.js +0 -26
  79. package/source/js/gesture-events.js +0 -190
  80. package/source/js/grid-handler.js +0 -122
  81. package/source/js/iiif-source-adapter.js +0 -63
  82. package/source/js/image-cache.js +0 -113
  83. package/source/js/image-manifest.js +0 -157
  84. package/source/js/image-request-handler.js +0 -76
  85. package/source/js/interpolate-animation.js +0 -122
  86. package/source/js/page-layouts/book-layout.js +0 -161
  87. package/source/js/page-layouts/grid-layout.js +0 -97
  88. package/source/js/page-layouts/index.js +0 -38
  89. package/source/js/page-layouts/page-dimensions.js +0 -9
  90. package/source/js/page-layouts/singles-layout.js +0 -27
  91. package/source/js/page-overlay-manager.js +0 -102
  92. package/source/js/page-tools-overlay.js +0 -95
  93. package/source/js/parse-iiif-manifest.js +0 -302
  94. package/source/js/plugins/_filters.js +0 -679
  95. package/source/js/plugins/download.js +0 -83
  96. package/source/js/plugins/manipulation.js +0 -837
  97. package/source/js/plugins/metadata.js +0 -190
  98. package/source/js/renderer.js +0 -584
  99. package/source/js/settings-view.js +0 -30
  100. package/source/js/tile-coverage-map.js +0 -25
  101. package/source/js/toolbar.js +0 -572
  102. package/source/js/utils/dragscroll.js +0 -106
  103. package/source/js/utils/elt.js +0 -94
  104. package/source/js/utils/events.js +0 -190
  105. package/source/js/utils/get-scrollbar-width.js +0 -29
  106. package/source/js/utils/hash-params.js +0 -86
  107. package/source/js/utils/parse-label-value.js +0 -34
  108. package/source/js/utils/vanilla.kinetic.js +0 -527
  109. package/source/js/validation-runner.js +0 -177
  110. package/source/js/viewer-core.js +0 -1505
  111. package/source/js/viewport.js +0 -143
  112. package/test/_setup.js +0 -13
  113. package/test/composite-image_test.js +0 -94
  114. package/test/diva_test.js +0 -43
  115. package/test/hash-params_test.js +0 -221
  116. package/test/image-cache_test.js +0 -106
  117. package/test/main.js +0 -6
  118. package/test/manifests/beromunsterManifest.json +0 -15514
  119. package/test/manifests/iiifv2.json +0 -11032
  120. package/test/manifests/iiifv2pages.json +0 -30437
  121. package/test/manifests/iiifv3.json +0 -10965
  122. package/test/navigation_test.js +0 -355
  123. package/test/parse-iiif-manifest_test.js +0 -68
  124. package/test/public_test.js +0 -881
  125. package/test/settings_test.js +0 -487
  126. package/test/utils/book-layout_test.js +0 -148
  127. package/test/utils/elt_test.js +0 -102
  128. package/test/utils/events_test.js +0 -245
  129. package/test/utils/hash-params_test.js +0 -79
  130. package/test/utils/parse-label-value_test.js +0 -45
  131. package/test/z_plugins_test.js +0 -180
  132. package/webpack.config.js +0 -58
  133. package/webpack.config.test.js +0 -45
package/src/filters.ts ADDED
@@ -0,0 +1,1843 @@
1
+ /*
2
+ * This software was developed at the National Institute of Standards and
3
+ * Technology by employees of the Federal Government in the course of
4
+ * their official duties. Pursuant to title 17 Section 105 of the United
5
+ * States Code this software is not subject to copyright protection and is
6
+ * in the public domain. This software is an experimental system. NIST assumes
7
+ * no responsibility whatsoever for its use by other parties, and makes no
8
+ * guarantees, expressed or implied, about its quality, reliability, or
9
+ * any other characteristic. We would appreciate acknowledgement if the
10
+ * software is used.
11
+ */
12
+
13
+ /**
14
+ * The basic framework for this is from:
15
+ *
16
+ * @author Antoine Vandecreme <antoine.vandecreme@nist.gov>
17
+ * https://github.com/usnistgov/OpenSeadragonFiltering/blob/master/openseadragon-filtering.js
18
+ *
19
+ * Additional filters and modifications to the processing methods are from
20
+ * CamanJS:
21
+ * https://github.com/meltingice/CamanJS/blob/master/src/lib/filters.coffee
22
+ *
23
+ *
24
+ */
25
+
26
+ export type FilterProcessor = (context: CanvasRenderingContext2D, callback: () => void) => void;
27
+ type PixelTransformInPlace = (r: number, g: number, b: number, a: number, out: number[]) => void;
28
+ type ResettableItem = {reset: () => void};
29
+
30
+ export type FilterDefinition = {
31
+ items?: ResettableItem | ResettableItem[]; processors : FilterProcessor | FilterProcessor[];
32
+ };
33
+
34
+ export type FilterOptions = {
35
+ loadMode?: string;
36
+ filters?: FilterDefinition | FilterDefinition[];
37
+ };
38
+
39
+ type FilterPluginInstance = {
40
+ viewer: any; filters : FilterDefinition[]; filterIncrement : number;
41
+ };
42
+
43
+ export function setFilterOptions(viewer: any, options: FilterOptions): void
44
+ {
45
+ if (!viewer)
46
+ {
47
+ throw new Error("A viewer must be specified.");
48
+ }
49
+ if (!viewer.filterPluginInstance)
50
+ {
51
+ viewer.filterPluginInstance = createFilterPlugin(viewer, options || {});
52
+ }
53
+ else
54
+ {
55
+ setOptions(viewer.filterPluginInstance, options || {});
56
+ }
57
+ }
58
+
59
+ function readImageDataFromContext(context: CanvasRenderingContext2D): ImageData
60
+ {
61
+ const width = context.canvas.width;
62
+ const height = context.canvas.height;
63
+ const scratchContext = Filters._ensureScratchContext(width, height);
64
+ scratchContext.clearRect(0, 0, width, height);
65
+ scratchContext.drawImage(context.canvas, 0, 0);
66
+ return scratchContext.getImageData(0, 0, width, height);
67
+ }
68
+
69
+ function createFilterPlugin(viewer: any, options: FilterOptions): FilterPluginInstance
70
+ {
71
+ const instance: FilterPluginInstance = {viewer, filters : [], filterIncrement : 0};
72
+
73
+ const self = instance;
74
+ self.viewer.addHandler("tile-loaded", tileLoadedHandler);
75
+ self.viewer.addHandler("tile-drawing", tileDrawingHandler);
76
+
77
+ setOptions(self, options);
78
+
79
+ function tileLoadedHandler(event: any): void
80
+ {
81
+ const processors = getFiltersProcessors(self, event.tiledImage);
82
+ if (processors.length === 0)
83
+ {
84
+ return;
85
+ }
86
+ const tile = event.tile;
87
+ const image = event.data || event.image;
88
+ if (image !== null && image !== undefined)
89
+ {
90
+ const canvas = window.document.createElement("canvas");
91
+ canvas.width = image.width;
92
+ canvas.height = image.height;
93
+ const context = canvas.getContext("2d", {willReadFrequently : true}) as CanvasRenderingContext2D;
94
+ context.drawImage(image, 0, 0);
95
+ tile._renderedContext = context;
96
+ const callback = event.getCompletionCallback();
97
+ applyFilters(context, processors, callback);
98
+ tile._filterIncrement = self.filterIncrement;
99
+ }
100
+ }
101
+
102
+ function applyFilters(context: CanvasRenderingContext2D, filtersProcessors: FilterProcessor[],
103
+ callback?: () => void): void
104
+ {
105
+ if (callback)
106
+ {
107
+ const currentIncrement = self.filterIncrement;
108
+ const callbacks: Array<() => void> = [];
109
+ for (let i = 0; i < filtersProcessors.length - 1; i += 1)
110
+ {
111
+ callbacks[i] = () => {
112
+ if (self.filterIncrement !== currentIncrement)
113
+ {
114
+ return;
115
+ }
116
+ filtersProcessors[i + 1](context, callbacks[i + 1]);
117
+ };
118
+ }
119
+ callbacks[filtersProcessors.length - 1] = () => {
120
+ if (self.filterIncrement !== currentIncrement)
121
+ {
122
+ return;
123
+ }
124
+ callback();
125
+ };
126
+ filtersProcessors[0](context, callbacks[0]);
127
+ }
128
+ else
129
+ {
130
+ for (let i = 0; i < filtersProcessors.length; i += 1)
131
+ {
132
+ filtersProcessors[i](context, () => {});
133
+ }
134
+ }
135
+ }
136
+
137
+ function tileDrawingHandler(event: any): void
138
+ {
139
+ const tile = event.tile;
140
+ const rendered = event.rendered;
141
+ if (rendered._filterIncrement === self.filterIncrement)
142
+ {
143
+ return;
144
+ }
145
+ const processors = getFiltersProcessors(self, event.tiledImage);
146
+ if (processors.length === 0)
147
+ {
148
+ if (rendered._originalImageData)
149
+ {
150
+ rendered.putImageData(rendered._originalImageData, 0, 0);
151
+ delete rendered._originalImageData;
152
+ }
153
+ rendered._filterIncrement = self.filterIncrement;
154
+ return;
155
+ }
156
+
157
+ if (rendered._originalImageData)
158
+ {
159
+ rendered.putImageData(rendered._originalImageData, 0, 0);
160
+ }
161
+ else
162
+ {
163
+ rendered._originalImageData = readImageDataFromContext(rendered);
164
+ }
165
+
166
+ if (tile._renderedContext)
167
+ {
168
+ if (tile._filterIncrement === self.filterIncrement)
169
+ {
170
+ const imgData = readImageDataFromContext(tile._renderedContext);
171
+ rendered.putImageData(imgData, 0, 0);
172
+ delete tile._renderedContext;
173
+ delete tile._filterIncrement;
174
+ rendered._filterIncrement = self.filterIncrement;
175
+ return;
176
+ }
177
+ delete tile._renderedContext;
178
+ delete tile._filterIncrement;
179
+ }
180
+ applyFilters(rendered, processors);
181
+ rendered._filterIncrement = self.filterIncrement;
182
+ }
183
+
184
+ return instance;
185
+ }
186
+
187
+ function setOptions(instance: FilterPluginInstance, options: FilterOptions): void
188
+ {
189
+ const filters = options.filters;
190
+ instance.filters = !filters ? [] : Array.isArray(filters) ? filters : [ filters ];
191
+ for (let i = 0; i < instance.filters.length; i += 1)
192
+ {
193
+ const filter = instance.filters[i];
194
+ if (!filter.processors)
195
+ {
196
+ throw new Error("Filter processors must be specified.");
197
+ }
198
+ filter.processors = Array.isArray(filter.processors) ? filter.processors : [ filter.processors ];
199
+ }
200
+ instance.filterIncrement += 1;
201
+
202
+ if (options.loadMode === "sync")
203
+ {
204
+ instance.viewer.forceRedraw();
205
+ }
206
+ else
207
+ {
208
+ let itemsToReset: any[] = [];
209
+ for (let i = 0; i < instance.filters.length; i += 1)
210
+ {
211
+ const filter = instance.filters[i];
212
+ if (!filter.items)
213
+ {
214
+ itemsToReset = getAllItems(instance.viewer.world);
215
+ break;
216
+ }
217
+ if (Array.isArray(filter.items))
218
+ {
219
+ for (let j = 0; j < filter.items.length; j += 1)
220
+ {
221
+ addItemToReset(filter.items[j], itemsToReset);
222
+ }
223
+ }
224
+ else
225
+ {
226
+ addItemToReset(filter.items, itemsToReset);
227
+ }
228
+ }
229
+ for (let i = 0; i < itemsToReset.length; i += 1)
230
+ {
231
+ itemsToReset[i].reset();
232
+ }
233
+ }
234
+ }
235
+
236
+ function addItemToReset(item: any, itemsToReset: any[]): void
237
+ {
238
+ if (itemsToReset.indexOf(item) >= 0)
239
+ {
240
+ throw new Error("An item can not have filters assigned multiple times.");
241
+ }
242
+ itemsToReset.push(item);
243
+ }
244
+
245
+ function getAllItems(world: any): any[]
246
+ {
247
+ const result = [];
248
+ for (let i = 0; i < world.getItemCount(); i += 1)
249
+ {
250
+ result.push(world.getItemAt(i));
251
+ }
252
+ return result;
253
+ }
254
+
255
+ function getFiltersProcessors(instance: any, item: any): FilterProcessor[]
256
+ {
257
+ if (instance.filters.length === 0)
258
+ {
259
+ return [];
260
+ }
261
+
262
+ let globalProcessors: FilterProcessor[]|null = null;
263
+ for (let i = 0; i < instance.filters.length; i += 1)
264
+ {
265
+ const filter = instance.filters[i];
266
+ if (!filter.items)
267
+ {
268
+ globalProcessors = filter.processors;
269
+ }
270
+ else if (filter.items === item || Array.isArray(filter.items) && filter.items.indexOf(item) >= 0)
271
+ {
272
+ return filter.processors;
273
+ }
274
+ }
275
+ return globalProcessors ? globalProcessors : [];
276
+ }
277
+
278
+ const sampleStops = (stops: Array<{stop : number; color : number[]}>, t: number): number[] => {
279
+ let lower = stops[0];
280
+ let upper = stops[stops.length - 1];
281
+ for (let i = 0; i < stops.length; i += 1)
282
+ {
283
+ if (stops[i].stop >= t)
284
+ {
285
+ upper = stops[i];
286
+ lower = stops[Math.max(0, i - 1)];
287
+ break;
288
+ }
289
+ }
290
+ if (upper.stop === lower.stop)
291
+ {
292
+ return upper.color.slice();
293
+ }
294
+ const localT = (t - lower.stop) / (upper.stop - lower.stop);
295
+ const r = Math.round(lower.color[0] + (upper.color[0] - lower.color[0]) * localT);
296
+ const g = Math.round(lower.color[1] + (upper.color[1] - lower.color[1]) * localT);
297
+ const b = Math.round(lower.color[2] + (upper.color[2] - lower.color[2]) * localT);
298
+ return [ r, g, b ];
299
+ };
300
+
301
+ const buildColormap = (stops: Array<{stop : number; color : number[]}>): number[][] => {
302
+ const map: number[][] = [];
303
+ for (let i = 0; i < 256; i += 1)
304
+ {
305
+ const t = i / 255;
306
+ map[i] = sampleStops(stops, t);
307
+ }
308
+ return map;
309
+ };
310
+
311
+ const colormapCache = new Map<string, number[][]>();
312
+
313
+ const getColormap = (preset: string): number[][]|null => {
314
+ const cached = colormapCache.get(preset);
315
+ if (cached)
316
+ {
317
+ return cached;
318
+ }
319
+ let result: number[][]|null = null;
320
+ switch (preset)
321
+ {
322
+ case "hot":
323
+ result = buildColormap([
324
+ {stop : 0, color : [ 0, 0, 0 ]}, {stop : 0.4, color : [ 255, 0, 0 ]}, {stop : 0.7, color : [ 255, 255, 0 ]},
325
+ {stop : 1, color : [ 255, 255, 255 ]}
326
+ ]);
327
+ break;
328
+ case "cool":
329
+ result = buildColormap([
330
+ {stop : 0, color : [ 0, 0, 0 ]}, {stop : 0.5, color : [ 0, 128, 255 ]},
331
+ {stop : 1, color : [ 255, 255, 255 ]}
332
+ ]);
333
+ break;
334
+ case "gray":
335
+ case "":
336
+ result = buildColormap([ {stop : 0, color : [ 0, 0, 0 ]}, {stop : 1, color : [ 255, 255, 255 ]} ]);
337
+ break;
338
+ default:
339
+ return null;
340
+ }
341
+ colormapCache.set(preset, result);
342
+ return result;
343
+ };
344
+
345
+ const clampByte = (value: number): number => {
346
+ if (value < 0)
347
+ {
348
+ return 0;
349
+ }
350
+ if (value > 255)
351
+ {
352
+ return 255;
353
+ }
354
+ return value;
355
+ };
356
+
357
+ const clamp01 = (value: number): number => {
358
+ if (value < 0)
359
+ {
360
+ return 0;
361
+ }
362
+ if (value > 1)
363
+ {
364
+ return 1;
365
+ }
366
+ return value;
367
+ };
368
+
369
+ const hexToRgb = (hex: string): [ number, number, number ]|null => {
370
+ if (!hex)
371
+ {
372
+ return null;
373
+ }
374
+ let cleaned = hex.trim();
375
+ if (cleaned.startsWith("#"))
376
+ {
377
+ cleaned = cleaned.slice(1);
378
+ }
379
+ if (cleaned.length === 3)
380
+ {
381
+ cleaned = cleaned.split("").map((ch) => ch + ch).join("");
382
+ }
383
+ if (cleaned.length !== 6)
384
+ {
385
+ return null;
386
+ }
387
+ const value = parseInt(cleaned, 16);
388
+ if (Number.isNaN(value))
389
+ {
390
+ return null;
391
+ }
392
+ return [ (value >> 16) & 0xff, (value >> 8) & 0xff, value & 0xff ];
393
+ };
394
+
395
+ const boxBlur = (input: Uint8ClampedArray, width: number, height: number, radius: number,
396
+ grayscale?: boolean): Uint8ClampedArray => {
397
+ const size = width * height;
398
+ const output = new Uint8ClampedArray(input.length);
399
+ if (!radius)
400
+ {
401
+ output.set(input);
402
+ return output;
403
+ }
404
+ const windowSize = radius * 2 + 1;
405
+ const invWindowSize = 1 / windowSize;
406
+ if (grayscale)
407
+ {
408
+ const temp = new Uint8ClampedArray(size);
409
+ for (let y = 0; y < height; y += 1)
410
+ {
411
+ let sum = 0;
412
+ for (let x = -radius; x <= radius; x += 1)
413
+ {
414
+ const cx = Math.min(width - 1, Math.max(0, x));
415
+ sum += input[y * width + cx];
416
+ }
417
+ for (let x = 0; x < width; x += 1)
418
+ {
419
+ temp[y * width + x] = (sum * invWindowSize + 0.5) | 0;
420
+ const removeX = Math.max(0, x - radius);
421
+ const addX = Math.min(width - 1, x + radius + 1);
422
+ sum += input[y * width + addX] - input[y * width + removeX];
423
+ }
424
+ }
425
+ for (let x = 0; x < width; x += 1)
426
+ {
427
+ let sum = 0;
428
+ for (let y = -radius; y <= radius; y += 1)
429
+ {
430
+ const cy = Math.min(height - 1, Math.max(0, y));
431
+ sum += temp[cy * width + x];
432
+ }
433
+ for (let y = 0; y < height; y += 1)
434
+ {
435
+ output[y * width + x] = (sum * invWindowSize + 0.5) | 0;
436
+ const removeY = Math.max(0, y - radius);
437
+ const addY = Math.min(height - 1, y + radius + 1);
438
+ sum += temp[addY * width + x] - temp[removeY * width + x];
439
+ }
440
+ }
441
+ return output;
442
+ }
443
+ const temp = new Uint8ClampedArray(input.length);
444
+ for (let y = 0; y < height; y += 1)
445
+ {
446
+ let sumR = 0;
447
+ let sumG = 0;
448
+ let sumB = 0;
449
+ for (let x = -radius; x <= radius; x += 1)
450
+ {
451
+ const cx = Math.min(width - 1, Math.max(0, x));
452
+ const idx = (y * width + cx) * 4;
453
+ sumR += input[idx];
454
+ sumG += input[idx + 1];
455
+ sumB += input[idx + 2];
456
+ }
457
+ for (let x = 0; x < width; x += 1)
458
+ {
459
+ const idx = (y * width + x) * 4;
460
+ temp[idx] = (sumR * invWindowSize + 0.5) | 0;
461
+ temp[idx + 1] = (sumG * invWindowSize + 0.5) | 0;
462
+ temp[idx + 2] = (sumB * invWindowSize + 0.5) | 0;
463
+ temp[idx + 3] = input[idx + 3];
464
+ const removeX = Math.max(0, x - radius);
465
+ const addX = Math.min(width - 1, x + radius + 1);
466
+ const removeIdx = (y * width + removeX) * 4;
467
+ const addIdx = (y * width + addX) * 4;
468
+ sumR += input[addIdx] - input[removeIdx];
469
+ sumG += input[addIdx + 1] - input[removeIdx + 1];
470
+ sumB += input[addIdx + 2] - input[removeIdx + 2];
471
+ }
472
+ }
473
+ for (let x = 0; x < width; x += 1)
474
+ {
475
+ let sumR = 0;
476
+ let sumG = 0;
477
+ let sumB = 0;
478
+ for (let y = -radius; y <= radius; y += 1)
479
+ {
480
+ const cy = Math.min(height - 1, Math.max(0, y));
481
+ const idx = (cy * width + x) * 4;
482
+ sumR += temp[idx];
483
+ sumG += temp[idx + 1];
484
+ sumB += temp[idx + 2];
485
+ }
486
+ for (let y = 0; y < height; y += 1)
487
+ {
488
+ const idx = (y * width + x) * 4;
489
+ output[idx] = (sumR * invWindowSize + 0.5) | 0;
490
+ output[idx + 1] = (sumG * invWindowSize + 0.5) | 0;
491
+ output[idx + 2] = (sumB * invWindowSize + 0.5) | 0;
492
+ output[idx + 3] = temp[idx + 3];
493
+ const removeY = Math.max(0, y - radius);
494
+ const addY = Math.min(height - 1, y + radius + 1);
495
+ const removeIdx = (removeY * width + x) * 4;
496
+ const addIdx = (addY * width + x) * 4;
497
+ sumR += temp[addIdx] - temp[removeIdx];
498
+ sumG += temp[addIdx + 1] - temp[removeIdx + 1];
499
+ sumB += temp[addIdx + 2] - temp[removeIdx + 2];
500
+ }
501
+ }
502
+ return output;
503
+ };
504
+
505
+ function applyPixelTransformInPlace(transform: PixelTransformInPlace): FilterProcessor
506
+ {
507
+ return function(context: CanvasRenderingContext2D, callback: () => void): void {
508
+ Filters._applyPixelTransformInPlace(context, transform);
509
+ callback();
510
+ };
511
+ }
512
+
513
+ function buildLuminance(data: Uint8ClampedArray, width: number, height: number): Uint8ClampedArray
514
+ {
515
+ const length = width * height;
516
+ const lum = Filters._ensureScratch(length);
517
+ for (let i = 0, p = 0; p < length; i += 4, p += 1)
518
+ {
519
+ lum[p] = (77 * data[i] + 150 * data[i + 1] + 29 * data[i + 2]) >> 8;
520
+ }
521
+ return lum;
522
+ }
523
+
524
+ function withImageData(context: CanvasRenderingContext2D, handler: (imgData: ImageData) => boolean): void
525
+ {
526
+ const width = context.canvas.width;
527
+ const height = context.canvas.height;
528
+ const scratchContext = Filters._ensureScratchContext(width, height);
529
+ scratchContext.clearRect(0, 0, width, height);
530
+ scratchContext.drawImage(context.canvas, 0, 0);
531
+ const imgData = scratchContext.getImageData(0, 0, width, height);
532
+ const didChange = handler(imgData);
533
+ if (didChange)
534
+ {
535
+ scratchContext.putImageData(imgData, 0, 0);
536
+ context.clearRect(0, 0, width, height);
537
+ context.drawImage(Filters._scratchCanvas as HTMLCanvasElement, 0, 0);
538
+ }
539
+ }
540
+
541
+ function convolvePixels(originalPixels: Uint8ClampedArray, outputPixels: Uint8ClampedArray, width: number,
542
+ height: number, kernel: number[], kernelSize: number, kernelHalfSize: number): void
543
+ {
544
+ const rowStride = width * 4;
545
+
546
+ // Process interior pixels (no bounds checking needed)
547
+ const interiorStartY = kernelHalfSize;
548
+ const interiorEndY = height - kernelHalfSize;
549
+ const interiorStartX = kernelHalfSize;
550
+ const interiorEndX = width - kernelHalfSize;
551
+
552
+ for (let y = interiorStartY; y < interiorEndY; y += 1)
553
+ {
554
+ const rowOffset = y * rowStride;
555
+ for (let x = interiorStartX; x < interiorEndX; x += 1)
556
+ {
557
+ let r = 0;
558
+ let g = 0;
559
+ let b = 0;
560
+ for (let j = 0; j < kernelSize; j += 1)
561
+ {
562
+ const kernelRowOffset = (y + j - kernelHalfSize) * rowStride;
563
+ for (let i = 0; i < kernelSize; i += 1)
564
+ {
565
+ const offset = kernelRowOffset + (x + i - kernelHalfSize) * 4;
566
+ const weight = kernel[j * kernelSize + i];
567
+ r += originalPixels[offset] * weight;
568
+ g += originalPixels[offset + 1] * weight;
569
+ b += originalPixels[offset + 2] * weight;
570
+ }
571
+ }
572
+ const outOffset = rowOffset + x * 4;
573
+ outputPixels[outOffset] = r;
574
+ outputPixels[outOffset + 1] = g;
575
+ outputPixels[outOffset + 2] = b;
576
+ }
577
+ }
578
+
579
+ // Process edge pixels (with bounds checking)
580
+ for (let y = 0; y < height; y += 1)
581
+ {
582
+ const rowOffset = y * rowStride;
583
+ const isInteriorY = y >= interiorStartY && y < interiorEndY;
584
+ for (let x = 0; x < width; x += 1)
585
+ {
586
+ // Skip interior pixels (already processed)
587
+ if (isInteriorY && x >= interiorStartX && x < interiorEndX)
588
+ {
589
+ continue;
590
+ }
591
+ let r = 0;
592
+ let g = 0;
593
+ let b = 0;
594
+ for (let j = 0; j < kernelSize; j += 1)
595
+ {
596
+ const pixelY = y + j - kernelHalfSize;
597
+ if (pixelY < 0 || pixelY >= height)
598
+ {
599
+ continue;
600
+ }
601
+ const kernelRowOffset = pixelY * rowStride;
602
+ for (let i = 0; i < kernelSize; i += 1)
603
+ {
604
+ const pixelX = x + i - kernelHalfSize;
605
+ if (pixelX >= 0 && pixelX < width)
606
+ {
607
+ const offset = kernelRowOffset + pixelX * 4;
608
+ const weight = kernel[j * kernelSize + i];
609
+ r += originalPixels[offset] * weight;
610
+ g += originalPixels[offset + 1] * weight;
611
+ b += originalPixels[offset + 2] * weight;
612
+ }
613
+ }
614
+ }
615
+ const outOffset = rowOffset + x * 4;
616
+ outputPixels[outOffset] = r;
617
+ outputPixels[outOffset + 1] = g;
618
+ outputPixels[outOffset + 2] = b;
619
+ }
620
+ }
621
+ }
622
+
623
+ function morphPixels(originalPixels: Uint8ClampedArray, outputPixels: Uint8ClampedArray, width: number, height: number,
624
+ kernelSize: number, kernelHalfSize: number, comparator: (a: number, b: number) => number): void
625
+ {
626
+ const rowStride = width * 4;
627
+
628
+ // Process interior pixels (no bounds checking needed)
629
+ const interiorStartY = kernelHalfSize;
630
+ const interiorEndY = height - kernelHalfSize;
631
+ const interiorStartX = kernelHalfSize;
632
+ const interiorEndX = width - kernelHalfSize;
633
+
634
+ for (let y = interiorStartY; y < interiorEndY; y += 1)
635
+ {
636
+ const rowOffset = y * rowStride;
637
+ for (let x = interiorStartX; x < interiorEndX; x += 1)
638
+ {
639
+ const centerOffset = rowOffset + x * 4;
640
+ let r = originalPixels[centerOffset];
641
+ let g = originalPixels[centerOffset + 1];
642
+ let b = originalPixels[centerOffset + 2];
643
+ for (let j = 0; j < kernelSize; j += 1)
644
+ {
645
+ const kernelRowOffset = (y + j - kernelHalfSize) * rowStride;
646
+ for (let i = 0; i < kernelSize; i += 1)
647
+ {
648
+ const offset = kernelRowOffset + (x + i - kernelHalfSize) * 4;
649
+ r = comparator(originalPixels[offset], r);
650
+ g = comparator(originalPixels[offset + 1], g);
651
+ b = comparator(originalPixels[offset + 2], b);
652
+ }
653
+ }
654
+ outputPixels[centerOffset] = r;
655
+ outputPixels[centerOffset + 1] = g;
656
+ outputPixels[centerOffset + 2] = b;
657
+ }
658
+ }
659
+
660
+ // Process edge pixels (with bounds checking)
661
+ for (let y = 0; y < height; y += 1)
662
+ {
663
+ const rowOffset = y * rowStride;
664
+ const isInteriorY = y >= interiorStartY && y < interiorEndY;
665
+ for (let x = 0; x < width; x += 1)
666
+ {
667
+ // Skip interior pixels (already processed)
668
+ if (isInteriorY && x >= interiorStartX && x < interiorEndX)
669
+ {
670
+ continue;
671
+ }
672
+ const centerOffset = rowOffset + x * 4;
673
+ let r = originalPixels[centerOffset];
674
+ let g = originalPixels[centerOffset + 1];
675
+ let b = originalPixels[centerOffset + 2];
676
+ for (let j = 0; j < kernelSize; j += 1)
677
+ {
678
+ const pixelY = y + j - kernelHalfSize;
679
+ if (pixelY < 0 || pixelY >= height)
680
+ {
681
+ continue;
682
+ }
683
+ const kernelRowOffset = pixelY * rowStride;
684
+ for (let i = 0; i < kernelSize; i += 1)
685
+ {
686
+ const pixelX = x + i - kernelHalfSize;
687
+ if (pixelX >= 0 && pixelX < width)
688
+ {
689
+ const offset = kernelRowOffset + pixelX * 4;
690
+ r = comparator(originalPixels[offset], r);
691
+ g = comparator(originalPixels[offset + 1], g);
692
+ b = comparator(originalPixels[offset + 2], b);
693
+ }
694
+ }
695
+ }
696
+ outputPixels[centerOffset] = r;
697
+ outputPixels[centerOffset + 1] = g;
698
+ outputPixels[centerOffset + 2] = b;
699
+ }
700
+ }
701
+ }
702
+
703
+ function dot3(a: number[], b: number[]): number { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
704
+
705
+ function normalize3(v: number[]): number[]
706
+ {
707
+ const len = Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]) || 1;
708
+ return [ v[0] / len, v[1] / len, v[2] / len ];
709
+ }
710
+
711
+ function scaleMat3(m: number[][], s: number): number[][]
712
+ {
713
+ return [
714
+ [ m[0][0] * s, m[0][1] * s, m[0][2] * s ], [ m[1][0] * s, m[1][1] * s, m[1][2] * s ],
715
+ [ m[2][0] * s, m[2][1] * s, m[2][2] * s ]
716
+ ];
717
+ }
718
+
719
+ function outer3(v: number[]): number[][]
720
+ {
721
+ return [
722
+ [ v[0] * v[0], v[0] * v[1], v[0] * v[2] ], [ v[1] * v[0], v[1] * v[1], v[1] * v[2] ],
723
+ [ v[2] * v[0], v[2] * v[1], v[2] * v[2] ]
724
+ ];
725
+ }
726
+
727
+ function matVec3(m: number[][], v: number[]): number[]
728
+ {
729
+ return [
730
+ m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2], m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
731
+ m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2]
732
+ ];
733
+ }
734
+
735
+ function matSub(a: number[][], b: number[][]): number[][]
736
+ {
737
+ return [
738
+ [ a[0][0] - b[0][0], a[0][1] - b[0][1], a[0][2] - b[0][2] ],
739
+ [ a[1][0] - b[1][0], a[1][1] - b[1][1], a[1][2] - b[1][2] ],
740
+ [ a[2][0] - b[2][0], a[2][1] - b[2][1], a[2][2] - b[2][2] ]
741
+ ];
742
+ }
743
+
744
+ function powerIterEigen(m: number[][], iterations: number): {vector: number[]; value : number}
745
+ {
746
+ let v = normalize3([ 1, 1, 1 ]);
747
+ for (let i = 0; i < iterations; i += 1)
748
+ {
749
+ v = normalize3(matVec3(m, v));
750
+ }
751
+ const mv = matVec3(m, v);
752
+ const value = dot3(v, mv);
753
+ return {vector : v, value};
754
+ }
755
+
756
+ type PcaBasis = {
757
+ mean: number[];
758
+ vectors: number[][];
759
+ values: number[];
760
+ };
761
+
762
+ type RunningPcaStats = {
763
+ count: number;
764
+ mean: number[];
765
+ m2: number[][];
766
+ };
767
+
768
+ function computePcaBasisFromCov(mean: number[], cov: number[][]): PcaBasis
769
+ {
770
+ const eig1 = powerIterEigen(cov, 12);
771
+ const deflated = matSub(cov, scaleMat3(outer3(eig1.vector), eig1.value));
772
+ const eig2 = powerIterEigen(deflated, 12);
773
+ const eig3 = normalize3([
774
+ eig1.vector[1] * eig2.vector[2] - eig1.vector[2] * eig2.vector[1],
775
+ eig1.vector[2] * eig2.vector[0] - eig1.vector[0] * eig2.vector[2],
776
+ eig1.vector[0] * eig2.vector[1] - eig1.vector[1] * eig2.vector[0]
777
+ ]);
778
+ const eig3Value = dot3(eig3, matVec3(cov, eig3));
779
+
780
+ return {
781
+ mean,
782
+ vectors : [ eig1.vector, eig2.vector, eig3 ],
783
+ values : [ Math.max(0, eig1.value), Math.max(0, eig2.value), Math.max(0, eig3Value) ]
784
+ };
785
+ }
786
+
787
+ function computePcaVectors(data: Uint8ClampedArray): PcaBasis
788
+ {
789
+ const count = data.length / 4;
790
+ let meanR = 0;
791
+ let meanG = 0;
792
+ let meanB = 0;
793
+ for (let i = 0; i < data.length; i += 4)
794
+ {
795
+ meanR += data[i];
796
+ meanG += data[i + 1];
797
+ meanB += data[i + 2];
798
+ }
799
+ meanR /= count;
800
+ meanG /= count;
801
+ meanB /= count;
802
+
803
+ let c00 = 0, c01 = 0, c02 = 0;
804
+ let c11 = 0, c12 = 0;
805
+ let c22 = 0;
806
+ for (let i = 0; i < data.length; i += 4)
807
+ {
808
+ const r = data[i] - meanR;
809
+ const g = data[i + 1] - meanG;
810
+ const b = data[i + 2] - meanB;
811
+ c00 += r * r;
812
+ c01 += r * g;
813
+ c02 += r * b;
814
+ c11 += g * g;
815
+ c12 += g * b;
816
+ c22 += b * b;
817
+ }
818
+ const inv = 1 / Math.max(1, count - 1);
819
+ const cov = [
820
+ [ c00 * inv, c01 * inv, c02 * inv ], [ c01 * inv, c11 * inv, c12 * inv ], [ c02 * inv, c12 * inv, c22 * inv ]
821
+ ];
822
+
823
+ return computePcaBasisFromCov([ meanR, meanG, meanB ], cov);
824
+ }
825
+
826
+ function applyPcaColor(imgData: ImageData, mode: string, hueDegrees: number = 0): void
827
+ {
828
+ const data = imgData.data;
829
+ const pca = computePcaVectors(data);
830
+ const mean = pca.mean;
831
+ const v1 = pca.vectors[0];
832
+ const v2 = pca.vectors[1];
833
+ const v3 = pca.vectors[2];
834
+
835
+ let min1 = Infinity, max1 = -Infinity;
836
+ let min2 = Infinity, max2 = -Infinity;
837
+ let min3 = Infinity, max3 = -Infinity;
838
+
839
+ for (let i = 0; i < data.length; i += 4)
840
+ {
841
+ const r = data[i] - mean[0];
842
+ const g = data[i + 1] - mean[1];
843
+ const b = data[i + 2] - mean[2];
844
+ const c1 = r * v1[0] + g * v1[1] + b * v1[2];
845
+ const c2 = r * v2[0] + g * v2[1] + b * v2[2];
846
+ const c3 = r * v3[0] + g * v3[1] + b * v3[2];
847
+ if (c1 < min1)
848
+ min1 = c1;
849
+ if (c1 > max1)
850
+ max1 = c1;
851
+ if (c2 < min2)
852
+ min2 = c2;
853
+ if (c2 > max2)
854
+ max2 = c2;
855
+ if (c3 < min3)
856
+ min3 = c3;
857
+ if (c3 > max3)
858
+ max3 = c3;
859
+ }
860
+
861
+ const range1 = max1 - min1 || 1;
862
+ const range2 = max2 - min2 || 1;
863
+ const range3 = max3 - min3 || 1;
864
+
865
+ const toByte = (v: number, min: number, range: number) => clampByte(((v - min) / range) * 255);
866
+
867
+ for (let i = 0; i < data.length; i += 4)
868
+ {
869
+ const r = data[i] - mean[0];
870
+ const g = data[i + 1] - mean[1];
871
+ const b = data[i + 2] - mean[2];
872
+ const c1 = r * v1[0] + g * v1[1] + b * v1[2];
873
+ const c2 = r * v2[0] + g * v2[1] + b * v2[2];
874
+ const c3 = r * v3[0] + g * v3[1] + b * v3[2];
875
+
876
+ if (mode === "pca1")
877
+ {
878
+ const v = toByte(c1, min1, range1);
879
+ data[i] = v;
880
+ data[i + 1] = v;
881
+ data[i + 2] = v;
882
+ }
883
+ else if (mode === "pca2")
884
+ {
885
+ const v = toByte(c2, min2, range2);
886
+ data[i] = v;
887
+ data[i + 1] = v;
888
+ data[i + 2] = v;
889
+ }
890
+ else if (mode === "pca3")
891
+ {
892
+ const v = toByte(c3, min3, range3);
893
+ data[i] = v;
894
+ data[i + 1] = v;
895
+ data[i + 2] = v;
896
+ }
897
+ else
898
+ {
899
+ data[i] = toByte(c1, min1, range1);
900
+ data[i + 1] = toByte(c2, min2, range2);
901
+ data[i + 2] = toByte(c3, min3, range3);
902
+ }
903
+ }
904
+
905
+ applyHueRotationInPlace(data, hueDegrees);
906
+ }
907
+
908
+ type HueRotationMatrix = {
909
+ m00: number; m01 : number; m02 : number;
910
+ m10: number; m11 : number; m12 : number;
911
+ m20: number; m21 : number; m22 : number;
912
+ };
913
+
914
+ function buildHueRotationMatrix(degrees: number): HueRotationMatrix
915
+ {
916
+ const angle = (degrees * Math.PI) / 180;
917
+ const cosA = Math.cos(angle);
918
+ const sinA = Math.sin(angle);
919
+ const oneThird = 1 / 3;
920
+ const sqrt1_3 = Math.sqrt(oneThird);
921
+
922
+ return {
923
+ m00 : cosA + (1 - cosA) * oneThird,
924
+ m01 : oneThird * (1 - cosA) - sqrt1_3 * sinA,
925
+ m02 : oneThird * (1 - cosA) + sqrt1_3 * sinA,
926
+ m10 : oneThird * (1 - cosA) + sqrt1_3 * sinA,
927
+ m11 : cosA + (1 - cosA) * oneThird,
928
+ m12 : oneThird * (1 - cosA) - sqrt1_3 * sinA,
929
+ m20 : oneThird * (1 - cosA) - sqrt1_3 * sinA,
930
+ m21 : oneThird * (1 - cosA) + sqrt1_3 * sinA,
931
+ m22 : cosA + (1 - cosA) * oneThird
932
+ };
933
+ }
934
+
935
+ function applyHueRotationInPlace(data: Uint8ClampedArray, degrees: number): void
936
+ {
937
+ if (degrees === 0)
938
+ {
939
+ return;
940
+ }
941
+ const matrix = buildHueRotationMatrix(degrees);
942
+
943
+ for (let i = 0; i < data.length; i += 4)
944
+ {
945
+ const r = data[i];
946
+ const g = data[i + 1];
947
+ const b = data[i + 2];
948
+ data[i] = clampByte(matrix.m00 * r + matrix.m01 * g + matrix.m02 * b);
949
+ data[i + 1] = clampByte(matrix.m10 * r + matrix.m11 * g + matrix.m12 * b);
950
+ data[i + 2] = clampByte(matrix.m20 * r + matrix.m21 * g + matrix.m22 * b);
951
+ }
952
+ }
953
+
954
+ function createRunningPcaStats(): RunningPcaStats
955
+ {
956
+ return {
957
+ count : 0,
958
+ mean : [ 0, 0, 0 ],
959
+ m2 : [
960
+ [ 0, 0, 0 ],
961
+ [ 0, 0, 0 ],
962
+ [ 0, 0, 0 ]
963
+ ]
964
+ };
965
+ }
966
+
967
+ function updateRunningPcaStats(stats: RunningPcaStats, data: Uint8ClampedArray): void
968
+ {
969
+ const sampleStride = 16;
970
+ for (let i = 0; i < data.length; i += 4 * sampleStride)
971
+ {
972
+ const sample = [ data[i], data[i + 1], data[i + 2] ];
973
+ const nextCount = stats.count + 1;
974
+ const delta = [
975
+ sample[0] - stats.mean[0],
976
+ sample[1] - stats.mean[1],
977
+ sample[2] - stats.mean[2]
978
+ ];
979
+ const nextMean = [
980
+ stats.mean[0] + delta[0] / nextCount,
981
+ stats.mean[1] + delta[1] / nextCount,
982
+ stats.mean[2] + delta[2] / nextCount
983
+ ];
984
+ const delta2 = [
985
+ sample[0] - nextMean[0],
986
+ sample[1] - nextMean[1],
987
+ sample[2] - nextMean[2]
988
+ ];
989
+
990
+ stats.m2[0][0] += delta[0] * delta2[0];
991
+ stats.m2[0][1] += delta[0] * delta2[1];
992
+ stats.m2[0][2] += delta[0] * delta2[2];
993
+ stats.m2[1][0] += delta[1] * delta2[0];
994
+ stats.m2[1][1] += delta[1] * delta2[1];
995
+ stats.m2[1][2] += delta[1] * delta2[2];
996
+ stats.m2[2][0] += delta[2] * delta2[0];
997
+ stats.m2[2][1] += delta[2] * delta2[1];
998
+ stats.m2[2][2] += delta[2] * delta2[2];
999
+
1000
+ stats.count = nextCount;
1001
+ stats.mean = nextMean;
1002
+ }
1003
+ }
1004
+
1005
+ function basisFromRunningStats(stats: RunningPcaStats): PcaBasis|null
1006
+ {
1007
+ if (stats.count < 2)
1008
+ {
1009
+ return null;
1010
+ }
1011
+ const inv = 1 / (stats.count - 1);
1012
+ const cov = [
1013
+ [ stats.m2[0][0] * inv, stats.m2[0][1] * inv, stats.m2[0][2] * inv ],
1014
+ [ stats.m2[1][0] * inv, stats.m2[1][1] * inv, stats.m2[1][2] * inv ],
1015
+ [ stats.m2[2][0] * inv, stats.m2[2][1] * inv, stats.m2[2][2] * inv ]
1016
+ ];
1017
+ return computePcaBasisFromCov(stats.mean, cov);
1018
+ }
1019
+
1020
+ function applyPcaColorWithBasis(imgData: ImageData, mode: string, basis: PcaBasis, hueDegrees: number): void
1021
+ {
1022
+ const data = imgData.data;
1023
+ const mean = basis.mean;
1024
+ const v1 = basis.vectors[0];
1025
+ const v2 = basis.vectors[1];
1026
+ const v3 = basis.vectors[2];
1027
+ const sigma1 = Math.sqrt(Math.max(1e-6, basis.values[0]));
1028
+ const sigma2 = Math.sqrt(Math.max(1e-6, basis.values[1]));
1029
+ const sigma3 = Math.sqrt(Math.max(1e-6, basis.values[2]));
1030
+ const scale = 3;
1031
+
1032
+ const toByte = (component: number, sigma: number) => clampByte(128 + (component / (scale * sigma)) * 127);
1033
+
1034
+ for (let i = 0; i < data.length; i += 4)
1035
+ {
1036
+ const r = data[i] - mean[0];
1037
+ const g = data[i + 1] - mean[1];
1038
+ const b = data[i + 2] - mean[2];
1039
+ const c1 = r * v1[0] + g * v1[1] + b * v1[2];
1040
+ const c2 = r * v2[0] + g * v2[1] + b * v2[2];
1041
+ const c3 = r * v3[0] + g * v3[1] + b * v3[2];
1042
+
1043
+ if (mode === "pca1")
1044
+ {
1045
+ const v = toByte(c1, sigma1);
1046
+ data[i] = v;
1047
+ data[i + 1] = v;
1048
+ data[i + 2] = v;
1049
+ }
1050
+ else if (mode === "pca2")
1051
+ {
1052
+ const v = toByte(c2, sigma2);
1053
+ data[i] = v;
1054
+ data[i + 1] = v;
1055
+ data[i + 2] = v;
1056
+ }
1057
+ else if (mode === "pca3")
1058
+ {
1059
+ const v = toByte(c3, sigma3);
1060
+ data[i] = v;
1061
+ data[i + 1] = v;
1062
+ data[i + 2] = v;
1063
+ }
1064
+ else
1065
+ {
1066
+ data[i] = toByte(c1, sigma1);
1067
+ data[i + 1] = toByte(c2, sigma2);
1068
+ data[i + 2] = toByte(c3, sigma3);
1069
+ }
1070
+ }
1071
+
1072
+ applyHueRotationInPlace(data, hueDegrees);
1073
+ }
1074
+
1075
+ const noopFilter: FilterProcessor = (_context, callback) => { callback(); };
1076
+
1077
+ function rgbToHSV(r: number, g: number, b: number): {h: number; s : number; v : number}
1078
+ {
1079
+ const rr = r / 255;
1080
+ const gg = g / 255;
1081
+ const bb = b / 255;
1082
+ const maxValue = Math.max(rr, gg, bb);
1083
+ const minValue = Math.min(rr, gg, bb);
1084
+ const v = maxValue;
1085
+ const d = maxValue - minValue;
1086
+
1087
+ const s = maxValue === 0 ? 0 : d / maxValue;
1088
+ let h = 0;
1089
+ if (maxValue !== minValue)
1090
+ {
1091
+ switch (maxValue)
1092
+ {
1093
+ case rr:
1094
+ h = (gg - bb) / d + (gg < bb ? 6 : 0);
1095
+ break;
1096
+ case gg:
1097
+ h = (bb - rr) / d + 2;
1098
+ break;
1099
+ default:
1100
+ h = (rr - gg) / d + 4;
1101
+ }
1102
+ h /= 6;
1103
+ }
1104
+ return {h, s, v};
1105
+ }
1106
+
1107
+ function hsvToRGB(h: number, s: number, v: number): {r: number; g : number; b : number}
1108
+ {
1109
+ let r = 0;
1110
+ let g = 0;
1111
+ let b = 0;
1112
+ const i = Math.floor(h * 6);
1113
+ const f = h * 6 - i;
1114
+ const p = v * (1 - s);
1115
+ const q = v * (1 - f * s);
1116
+ const t = v * (1 - (1 - f) * s);
1117
+
1118
+ switch (i % 6)
1119
+ {
1120
+ case 0:
1121
+ r = v;
1122
+ g = t;
1123
+ b = p;
1124
+ break;
1125
+ case 1:
1126
+ r = q;
1127
+ g = v;
1128
+ b = p;
1129
+ break;
1130
+ case 2:
1131
+ r = p;
1132
+ g = v;
1133
+ b = t;
1134
+ break;
1135
+ case 3:
1136
+ r = p;
1137
+ g = q;
1138
+ b = v;
1139
+ break;
1140
+ case 4:
1141
+ r = t;
1142
+ g = p;
1143
+ b = v;
1144
+ break;
1145
+ default:
1146
+ r = v;
1147
+ g = p;
1148
+ b = q;
1149
+ }
1150
+
1151
+ return {r : Math.floor(r * 255), g : Math.floor(g * 255), b : Math.floor(b * 255)};
1152
+ }
1153
+
1154
+ function applyChannelLUT(channel: number, lut: number[]): FilterProcessor
1155
+ {
1156
+ if (channel === 0)
1157
+ {
1158
+ return applyPixelTransformInPlace((r: number, g: number, b: number, a: number, out: number[]) => {
1159
+ out[0] = lut[r];
1160
+ out[1] = g;
1161
+ out[2] = b;
1162
+ out[3] = a;
1163
+ });
1164
+ }
1165
+ if (channel === 1)
1166
+ {
1167
+ return applyPixelTransformInPlace((r: number, g: number, b: number, a: number, out: number[]) => {
1168
+ out[0] = r;
1169
+ out[1] = lut[g];
1170
+ out[2] = b;
1171
+ out[3] = a;
1172
+ });
1173
+ }
1174
+ return applyPixelTransformInPlace((r: number, g: number, b: number, a: number, out: number[]) => {
1175
+ out[0] = r;
1176
+ out[1] = g;
1177
+ out[2] = lut[b];
1178
+ out[3] = a;
1179
+ });
1180
+ }
1181
+
1182
+ function ccChannel(channel: number, adjustment: number): FilterProcessor
1183
+ {
1184
+ const adj = adjustment / 100;
1185
+ const absAdj = Math.abs(adj);
1186
+ const transform = (ch: number) => adj > 0 ? ch + (255 - ch) * adj : ch - ch * absAdj;
1187
+ if (channel === 0)
1188
+ {
1189
+ return applyPixelTransformInPlace((r: number, g: number, b: number, a: number, out: number[]) => {
1190
+ out[0] = transform(r);
1191
+ out[1] = g;
1192
+ out[2] = b;
1193
+ out[3] = a;
1194
+ });
1195
+ }
1196
+ if (channel === 1)
1197
+ {
1198
+ return applyPixelTransformInPlace((r: number, g: number, b: number, a: number, out: number[]) => {
1199
+ out[0] = r;
1200
+ out[1] = transform(g);
1201
+ out[2] = b;
1202
+ out[3] = a;
1203
+ });
1204
+ }
1205
+ return applyPixelTransformInPlace((r: number, g: number, b: number, a: number, out: number[]) => {
1206
+ out[0] = r;
1207
+ out[1] = g;
1208
+ out[2] = transform(b);
1209
+ out[3] = a;
1210
+ });
1211
+ }
1212
+
1213
+ function altChannelGamma(channel: number, amount: number): FilterProcessor
1214
+ {
1215
+ const strength = Math.max(0, Math.min(100, amount || 0));
1216
+ if (strength === 0)
1217
+ {
1218
+ return noopFilter;
1219
+ }
1220
+ const exponent = 1 - (strength / 100) * 0.8;
1221
+ const lut: number[] = [];
1222
+ for (let i = 0; i < 256; i += 1)
1223
+ {
1224
+ lut[i] = clampByte(Math.pow(i / 255, exponent) * 255);
1225
+ }
1226
+ return applyChannelLUT(channel, lut);
1227
+ }
1228
+
1229
+ function altChannelSigmoid(channel: number, amount: number): FilterProcessor
1230
+ {
1231
+ const strength = Math.max(0, Math.min(100, amount || 0)) / 100;
1232
+ if (strength === 0)
1233
+ {
1234
+ return noopFilter;
1235
+ }
1236
+ const a = 8;
1237
+ const lut: number[] = [];
1238
+ for (let i = 0; i < 256; i += 1)
1239
+ {
1240
+ const sig = 1 / (1 + Math.exp(-a * (i / 255 - 0.5)));
1241
+ const target = sig * 255;
1242
+ lut[i] = clampByte(i + (target - i) * strength);
1243
+ }
1244
+ return applyChannelLUT(channel, lut);
1245
+ }
1246
+
1247
+ function altChannelHue(hueTarget: number, amount: number, window?: number): FilterProcessor
1248
+ {
1249
+ const strength = Math.max(-100, Math.min(100, amount || 0)) / 100;
1250
+ if (strength === 0)
1251
+ {
1252
+ return noopFilter;
1253
+ }
1254
+ const windowSize = Math.max(0.02, Math.min(0.3, (window === undefined ? 8 : window) / 100));
1255
+ return applyPixelTransformInPlace((r: number, g: number, b: number, aPx: number, out: number[]) => {
1256
+ const hsv = rgbToHSV(r, g, b);
1257
+ const hueDist = hueTarget === 0 ? Math.min(Math.abs(hsv.h), Math.abs(1 - hsv.h)) : Math.abs(hsv.h - hueTarget);
1258
+ const hueWeight = clamp01(1 - hueDist / windowSize);
1259
+ const weight = hueWeight * Math.abs(strength);
1260
+ if (weight <= 0)
1261
+ {
1262
+ out[0] = r;
1263
+ out[1] = g;
1264
+ out[2] = b;
1265
+ out[3] = aPx;
1266
+ return;
1267
+ }
1268
+ const sign = strength >= 0 ? 1 : -1;
1269
+ const nextS = clamp01(hsv.s + sign * (1 - hsv.s) * weight);
1270
+ const nextV = clamp01(hsv.v + sign * hsv.v * weight * 0.2);
1271
+ const rgb = hsvToRGB(hsv.h, nextS, nextV);
1272
+ out[0] = rgb.r;
1273
+ out[1] = rgb.g;
1274
+ out[2] = rgb.b;
1275
+ out[3] = aPx;
1276
+ });
1277
+ }
1278
+
1279
+ function altChannelVibrance(channel: number, amount: number): FilterProcessor
1280
+ {
1281
+ const strength = Math.max(0, Math.min(100, amount || 0)) / 100;
1282
+ if (strength === 0)
1283
+ {
1284
+ return noopFilter;
1285
+ }
1286
+ const lut: number[] = [];
1287
+ for (let i = 0; i < 256; i += 1)
1288
+ {
1289
+ const weight = (i / 255) * (i / 255) * strength;
1290
+ lut[i] = clampByte(i + (255 - i) * weight);
1291
+ }
1292
+ return applyChannelLUT(channel, lut);
1293
+ }
1294
+
1295
+ export const Filters = {
1296
+ _scratch : null as Uint8ClampedArray | null,
1297
+ _scratchCanvas : null as HTMLCanvasElement | null,
1298
+ _scratchContext : null as CanvasRenderingContext2D | null,
1299
+ _ensureScratch : function(length: number) : Uint8ClampedArray {
1300
+ // Returns a shared mutable buffer. Callers should consume/copy the
1301
+ // contents before the next _ensureScratch call, which may overwrite it.
1302
+ if (!this._scratch || this._scratch.length < length)
1303
+ {
1304
+ this._scratch = new Uint8ClampedArray(length);
1305
+ }
1306
+ return this._scratch;
1307
+ },
1308
+ _ensureScratchContext : function(width: number, height: number) : CanvasRenderingContext2D {
1309
+ if (!this._scratchCanvas)
1310
+ {
1311
+ this._scratchCanvas = window.document.createElement("canvas");
1312
+ }
1313
+ if (!this._scratchContext)
1314
+ {
1315
+ this._scratchContext =
1316
+ this._scratchCanvas.getContext("2d", {willReadFrequently : true}) as CanvasRenderingContext2D;
1317
+ }
1318
+ if (this._scratchCanvas.width !== width)
1319
+ {
1320
+ this._scratchCanvas.width = width;
1321
+ }
1322
+ if (this._scratchCanvas.height !== height)
1323
+ {
1324
+ this._scratchCanvas.height = height;
1325
+ }
1326
+ return this._scratchContext;
1327
+ },
1328
+ _applyPixelTransformInPlace : function(context: CanvasRenderingContext2D, transform: PixelTransformInPlace) : void {
1329
+ const width = context.canvas.width;
1330
+ const height = context.canvas.height;
1331
+ const scratchContext = this._ensureScratchContext(width, height);
1332
+ scratchContext.clearRect(0, 0, width, height);
1333
+ scratchContext.drawImage(context.canvas, 0, 0);
1334
+ const imgData = scratchContext.getImageData(0, 0, width, height);
1335
+ const pixels = imgData.data;
1336
+ const out = [ 0, 0, 0, 0 ];
1337
+ for (let i = 0, pxl = pixels.length; i < pxl; i += 4)
1338
+ {
1339
+ transform(pixels[i], pixels[i + 1], pixels[i + 2], pixels[i + 3], out);
1340
+ pixels[i] = out[0];
1341
+ pixels[i + 1] = out[1];
1342
+ pixels[i + 2] = out[2];
1343
+ pixels[i + 3] = out[3];
1344
+ }
1345
+ scratchContext.putImageData(imgData, 0, 0);
1346
+ context.clearRect(0, 0, width, height);
1347
+ context.drawImage(this._scratchCanvas as HTMLCanvasElement, 0, 0);
1348
+ },
1349
+ THRESHOLDING : function(threshold: number) : FilterProcessor {
1350
+ if (threshold < 0 || threshold > 255)
1351
+ {
1352
+ throw new Error("Threshold must be between 0 and 255.");
1353
+ }
1354
+ return applyPixelTransformInPlace((r: number, g: number, b: number, _a: number, out: number[]) => {
1355
+ const v = ((54 * r + 183 * g + 19 * b) >> 8) >= threshold ? 255 : 0;
1356
+ out[0] = v;
1357
+ out[1] = v;
1358
+ out[2] = v;
1359
+ out[3] = 255;
1360
+ });
1361
+ },
1362
+ SATURATION : function(adjustment: number) : FilterProcessor {
1363
+ const adj = adjustment * -0.01;
1364
+ return applyPixelTransformInPlace((r: number, g: number, b: number, a: number, out: number[]) => {
1365
+ const maxValue = Math.max(r, g, b);
1366
+ out[0] = r !== maxValue ? r + (maxValue - r) * adj : r;
1367
+ out[1] = g !== maxValue ? g + (maxValue - g) * adj : g;
1368
+ out[2] = b !== maxValue ? b + (maxValue - b) * adj : b;
1369
+ out[3] = a;
1370
+ });
1371
+ },
1372
+ VIBRANCE : function(adjustment: number) : FilterProcessor {
1373
+ const adj = adjustment * -1;
1374
+ return applyPixelTransformInPlace((r: number, g: number, b: number, a: number, out: number[]) => {
1375
+ const maxValue = Math.max(r, g, b);
1376
+ const avg = (r + g + b) / 3;
1377
+ const amt = ((Math.abs(maxValue - avg) * 2 / 255) * adj) / 100;
1378
+ out[0] = r !== maxValue ? r + (maxValue - r) * amt : r;
1379
+ out[1] = g !== maxValue ? g + (maxValue - g) * amt : g;
1380
+ out[2] = b !== maxValue ? b + (maxValue - b) * amt : b;
1381
+ out[3] = a;
1382
+ });
1383
+ },
1384
+ HUE : function(adjustment: number) : FilterProcessor {
1385
+ // Use direct hue rotation matrix instead of RGB→HSV→RGB conversion
1386
+ // Hue rotation is a rotation around the (1,1,1) axis in RGB space
1387
+ const degrees = (Math.abs(adjustment) / 100) * 360;
1388
+ const matrix = buildHueRotationMatrix(degrees);
1389
+
1390
+ return applyPixelTransformInPlace((r: number, g: number, b: number, a: number, out: number[]) => {
1391
+ out[0] = clampByte(matrix.m00 * r + matrix.m01 * g + matrix.m02 * b);
1392
+ out[1] = clampByte(matrix.m10 * r + matrix.m11 * g + matrix.m12 * b);
1393
+ out[2] = clampByte(matrix.m20 * r + matrix.m21 * g + matrix.m22 * b);
1394
+ out[3] = a;
1395
+ });
1396
+ },
1397
+ BRIGHTNESS : function(adjustment: number) : FilterProcessor {
1398
+ if (adjustment < -255 || adjustment > 255)
1399
+ {
1400
+ throw new Error("Brightness adjustment must be between -255 and 255.");
1401
+ }
1402
+ const precomputedBrightness: number[] = [];
1403
+ for (let i = 0; i < 256; i += 1)
1404
+ {
1405
+ precomputedBrightness[i] = i + adjustment;
1406
+ }
1407
+ return applyPixelTransformInPlace((r: number, g: number, b: number, a: number, out: number[]) => {
1408
+ out[0] = precomputedBrightness[r];
1409
+ out[1] = precomputedBrightness[g];
1410
+ out[2] = precomputedBrightness[b];
1411
+ out[3] = a;
1412
+ });
1413
+ },
1414
+ CC_RED : function(adjustment: number) : FilterProcessor { return ccChannel(0, adjustment);},
1415
+ CC_GREEN : function(adjustment: number) : FilterProcessor { return ccChannel(1, adjustment);},
1416
+ CC_BLUE : function(adjustment: number) : FilterProcessor { return ccChannel(2, adjustment);},
1417
+ CONTRAST : function(adjustment: number) : FilterProcessor {
1418
+ if (adjustment < 0)
1419
+ {
1420
+ throw new Error("Contrast adjustment must be positive.");
1421
+ }
1422
+ const precomputedContrast: number[] = [];
1423
+ for (let i = 0; i < 256; i += 1)
1424
+ {
1425
+ precomputedContrast[i] = i * adjustment;
1426
+ }
1427
+ return applyPixelTransformInPlace((r: number, g: number, b: number, a: number, out: number[]) => {
1428
+ out[0] = precomputedContrast[r];
1429
+ out[1] = precomputedContrast[g];
1430
+ out[2] = precomputedContrast[b];
1431
+ out[3] = a;
1432
+ });
1433
+ },
1434
+ GAMMA : function(adjustment: number) : FilterProcessor {
1435
+ if (adjustment < 0)
1436
+ {
1437
+ throw new Error("Gamma adjustment must be positive.");
1438
+ }
1439
+ const precomputedGamma: number[] = [];
1440
+ for (let i = 0; i < 256; i += 1)
1441
+ {
1442
+ precomputedGamma[i] = Math.pow(i / 255, adjustment) * 255;
1443
+ }
1444
+ return applyPixelTransformInPlace((r: number, g: number, b: number, a: number, out: number[]) => {
1445
+ out[0] = precomputedGamma[r];
1446
+ out[1] = precomputedGamma[g];
1447
+ out[2] = precomputedGamma[b];
1448
+ out[3] = a;
1449
+ });
1450
+ },
1451
+ GREYSCALE : function() : FilterProcessor {
1452
+ return applyPixelTransformInPlace((r: number, g: number, b: number, _a: number, out: number[]) => {
1453
+ const val = (77 * r + 150 * g + 29 * b) >> 8;
1454
+ out[0] = val;
1455
+ out[1] = val;
1456
+ out[2] = val;
1457
+ out[3] = 255;
1458
+ });
1459
+ },
1460
+ INVERT : function() : FilterProcessor {
1461
+ const precomputedInvert: number[] = [];
1462
+ for (let i = 0; i < 256; i += 1)
1463
+ {
1464
+ precomputedInvert[i] = 255 - i;
1465
+ }
1466
+ return applyPixelTransformInPlace((r: number, g: number, b: number, a: number, out: number[]) => {
1467
+ out[0] = precomputedInvert[r];
1468
+ out[1] = precomputedInvert[g];
1469
+ out[2] = precomputedInvert[b];
1470
+ out[3] = a;
1471
+ });
1472
+ },
1473
+ MORPHOLOGICAL_OPERATION : function(kernelSize: number, comparator: (a: number, b: number) => number) :
1474
+ FilterProcessor {
1475
+ if (kernelSize % 2 === 0)
1476
+ {
1477
+ throw new Error("The kernel size must be an odd number.");
1478
+ }
1479
+ const kernelHalfSize = Math.floor(kernelSize / 2);
1480
+
1481
+ if (!comparator)
1482
+ {
1483
+ throw new Error("A comparator must be defined.");
1484
+ }
1485
+
1486
+ return function(context: CanvasRenderingContext2D, callback: () => void): void {
1487
+ withImageData(context, (imgData) => {
1488
+ const width = imgData.width;
1489
+ const height = imgData.height;
1490
+ const originalPixels = Filters._ensureScratch(imgData.data.length);
1491
+ originalPixels.set(imgData.data);
1492
+ morphPixels(originalPixels, imgData.data, width, height, kernelSize, kernelHalfSize, comparator);
1493
+ return true;
1494
+ });
1495
+ callback();
1496
+ };
1497
+ },
1498
+ CONVOLUTION : function(kernel: number[]) : FilterProcessor {
1499
+ if (!Array.isArray(kernel))
1500
+ {
1501
+ throw new Error("The kernel must be an array.");
1502
+ }
1503
+ const kernelSize = Math.sqrt(kernel.length);
1504
+ if ((kernelSize + 1) % 2 !== 0)
1505
+ {
1506
+ throw new Error("The kernel must be a square matrix with odd width and height.");
1507
+ }
1508
+ const kernelHalfSize = (kernelSize - 1) / 2;
1509
+
1510
+ return function(context: CanvasRenderingContext2D, callback: () => void): void {
1511
+ withImageData(context, (imgData) => {
1512
+ const width = imgData.width;
1513
+ const height = imgData.height;
1514
+ const originalPixels = Filters._ensureScratch(imgData.data.length);
1515
+ originalPixels.set(imgData.data);
1516
+ convolvePixels(originalPixels, imgData.data, width, height, kernel, kernelSize, kernelHalfSize);
1517
+ return true;
1518
+ });
1519
+ callback();
1520
+ };
1521
+ },
1522
+ COLORMAP : function(cmap: number[][], ctr: number) : FilterProcessor {
1523
+ const resampledCmap = cmap.slice(0);
1524
+ const diff = 255 - ctr;
1525
+ for (let i = 0; i < 256; i += 1)
1526
+ {
1527
+ let position = 0;
1528
+ if (i > ctr)
1529
+ {
1530
+ position = Math.min((i - ctr) / diff * 128 + 128, 255) | 0;
1531
+ }
1532
+ else
1533
+ {
1534
+ position = Math.max(0, i / (ctr / 128)) | 0;
1535
+ }
1536
+ resampledCmap[i] = cmap[position];
1537
+ }
1538
+ return applyPixelTransformInPlace((r: number, g: number, b: number, _a: number, out: number[]) => {
1539
+ const v = (r + g + b) / 3 | 0;
1540
+ const c = resampledCmap[v];
1541
+ out[0] = c[0];
1542
+ out[1] = c[1];
1543
+ out[2] = c[2];
1544
+ out[3] = 255;
1545
+ });
1546
+ },
1547
+ COLORMAP_PRESET : function(preset: string) : number[][] | null { return getColormap(preset || "");},
1548
+ CONVOLUTION_PRESET : function(preset: string) : number[] |
1549
+ null {
1550
+ const normalized = (preset || "").toLowerCase();
1551
+ switch (normalized)
1552
+ {
1553
+ case "sharpen":
1554
+ return [ 0, -1, 0, -1, 5, -1, 0, -1, 0 ];
1555
+ case "blur":
1556
+ return [ 1 / 9, 1 / 9, 1 / 9, 1 / 9, 1 / 9, 1 / 9, 1 / 9, 1 / 9, 1 / 9 ];
1557
+ case "edge":
1558
+ return [ -1, -1, -1, -1, 8, -1, -1, -1, -1 ];
1559
+ case "emboss":
1560
+ return [ -2, -1, 0, -1, 1, 1, 0, 1, 2 ];
1561
+ default:
1562
+ return null;
1563
+ }
1564
+ },
1565
+ PSEUDOCOLOR : function(mode: string, red?: number, green?: number, blue?: number) : FilterProcessor {
1566
+ const normalized = (mode || "").toLowerCase();
1567
+ const rWeight = red === undefined ? 1 : red;
1568
+ const gWeight = green === undefined ? 1 : green;
1569
+ const bWeight = blue === undefined ? 1 : blue;
1570
+ const applyWeightsInPlace = (c0: number, c1: number, c2: number, out: number[]) => {
1571
+ out[0] = clampByte(c0 * rWeight);
1572
+ out[1] = clampByte(c1 * gWeight);
1573
+ out[2] = clampByte(c2 * bWeight);
1574
+ };
1575
+ return applyPixelTransformInPlace((r: number, g: number, b: number, a: number, out: number[]) => {
1576
+ const luma = 0.2126 * r + 0.7152 * g + 0.0722 * b;
1577
+ switch (normalized)
1578
+ {
1579
+ case "rg":
1580
+ {
1581
+ const v = clampByte(128 + (r - g));
1582
+ applyWeightsInPlace(v, 255 - v, (r + g) / 2, out);
1583
+ out[3] = a;
1584
+ return;
1585
+ }
1586
+ case "gb":
1587
+ {
1588
+ const v = clampByte(128 + (g - b));
1589
+ applyWeightsInPlace((g + b) / 2, v, 255 - v, out);
1590
+ out[3] = a;
1591
+ return;
1592
+ }
1593
+ case "rb":
1594
+ {
1595
+ const v = clampByte(128 + (r - b));
1596
+ applyWeightsInPlace(v, (r + b) / 2, 255 - v, out);
1597
+ out[3] = a;
1598
+ return;
1599
+ }
1600
+ case "luma":
1601
+ {
1602
+ const v = clampByte(luma);
1603
+ applyWeightsInPlace(v, 255 - v, v, out);
1604
+ out[3] = a;
1605
+ return;
1606
+ }
1607
+ case "cmy":
1608
+ {
1609
+ applyWeightsInPlace(255 - r, 255 - g, 255 - b, out);
1610
+ out[3] = a;
1611
+ return;
1612
+ }
1613
+ case "heat":
1614
+ {
1615
+ const v = clampByte(luma);
1616
+ const t = v / 255;
1617
+ applyWeightsInPlace(clampByte(255 * Math.min(1, t * 3)),
1618
+ clampByte(255 * Math.min(1, Math.max(0, (t - 0.33) * 3))),
1619
+ clampByte(255 * Math.min(1, Math.max(0, (t - 0.66) * 3))), out);
1620
+ out[3] = a;
1621
+ return;
1622
+ }
1623
+ case "pca1":
1624
+ {
1625
+ const v = clampByte(0.6 * r + 0.3 * g + 0.1 * b);
1626
+ applyWeightsInPlace(v, 255 - v, v, out);
1627
+ out[3] = a;
1628
+ return;
1629
+ }
1630
+ case "pca2":
1631
+ {
1632
+ const v = clampByte(0.5 * r - 0.2 * g - 0.3 * b + 128);
1633
+ applyWeightsInPlace(v, 255 - v, 255 - v, out);
1634
+ out[3] = a;
1635
+ return;
1636
+ }
1637
+ case "pca3":
1638
+ {
1639
+ const v = clampByte(0.2 * r + 0.6 * g - 0.8 * b + 128);
1640
+ applyWeightsInPlace(255 - v, v, 255 - v, out);
1641
+ out[3] = a;
1642
+ return;
1643
+ }
1644
+ default:
1645
+ {
1646
+ applyWeightsInPlace(r, g, b, out);
1647
+ out[3] = a;
1648
+ }
1649
+ }
1650
+ });
1651
+ },
1652
+ COLOR_REPLACE : function(source: string, target: string, tolerance?: number, blend?: number,
1653
+ preserveLum?: boolean) : FilterProcessor {
1654
+ const src = hexToRgb(source);
1655
+ const dst = hexToRgb(target);
1656
+ if (!src || !dst)
1657
+ {
1658
+ return noopFilter;
1659
+ }
1660
+ const tol = Math.max(0, Math.min(255, tolerance === undefined ? 0 : tolerance));
1661
+ const strength = Math.max(0, Math.min(1, blend === undefined ? 1 : blend));
1662
+ const targetHsv = rgbToHSV(dst[0], dst[1], dst[2]);
1663
+ return applyPixelTransformInPlace((r: number, g: number, b: number, a: number, out: number[]) => {
1664
+ const dr = r - src[0];
1665
+ const dg = g - src[1];
1666
+ const db = b - src[2];
1667
+ const dist = Math.sqrt(dr * dr + dg * dg + db * db);
1668
+ let weight = 0;
1669
+ if (tol <= 0)
1670
+ {
1671
+ weight = dist === 0 ? 1 : 0;
1672
+ }
1673
+ else
1674
+ {
1675
+ weight = 1 - Math.min(1, dist / tol);
1676
+ }
1677
+ weight *= strength;
1678
+ if (weight <= 0)
1679
+ {
1680
+ out[0] = r;
1681
+ out[1] = g;
1682
+ out[2] = b;
1683
+ out[3] = a;
1684
+ return;
1685
+ }
1686
+ let tr = dst[0];
1687
+ let tg = dst[1];
1688
+ let tb = dst[2];
1689
+ if (preserveLum)
1690
+ {
1691
+ const luma = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
1692
+ const rgb = hsvToRGB(targetHsv.h, targetHsv.s, luma);
1693
+ tr = rgb.r;
1694
+ tg = rgb.g;
1695
+ tb = rgb.b;
1696
+ }
1697
+ out[0] = clampByte(r + (tr - r) * weight);
1698
+ out[1] = clampByte(g + (tg - g) * weight);
1699
+ out[2] = clampByte(b + (tb - b) * weight);
1700
+ out[3] = a;
1701
+ });
1702
+ },
1703
+ ALT_RED_GAMMA : function(amount: number) : FilterProcessor { return altChannelGamma(0, amount);},
1704
+ ALT_GREEN_GAMMA : function(amount: number) : FilterProcessor { return altChannelGamma(1, amount);},
1705
+ ALT_BLUE_GAMMA : function(amount: number) : FilterProcessor { return altChannelGamma(2, amount);},
1706
+ ALT_RED_SIGMOID : function(amount: number) : FilterProcessor { return altChannelSigmoid(0, amount);},
1707
+ ALT_GREEN_SIGMOID : function(amount: number) : FilterProcessor { return altChannelSigmoid(1, amount);},
1708
+ ALT_BLUE_SIGMOID : function(amount: number) : FilterProcessor { return altChannelSigmoid(2, amount);},
1709
+ ALT_RED_HUE : function(amount: number, window?: number) :
1710
+ FilterProcessor { return altChannelHue(0, amount, window);},
1711
+ ALT_GREEN_HUE : function(amount: number, window?: number) :
1712
+ FilterProcessor { return altChannelHue(1 / 3, amount, window);},
1713
+ ALT_BLUE_HUE : function(amount: number, window?: number) :
1714
+ FilterProcessor { return altChannelHue(2 / 3, amount, window);},
1715
+ ALT_RED_VIBRANCE : function(amount: number) : FilterProcessor { return altChannelVibrance(0, amount);},
1716
+ ALT_GREEN_VIBRANCE : function(amount: number) : FilterProcessor { return altChannelVibrance(1, amount);},
1717
+ ALT_BLUE_VIBRANCE : function(amount: number) : FilterProcessor { return altChannelVibrance(2, amount);},
1718
+ GLOBAL_PCA_COLOR : function(mode: string, hueDegrees?: number) : FilterProcessor {
1719
+ const normalized = (mode || "").toLowerCase();
1720
+ const hue = Math.max(-180, Math.min(180, hueDegrees ?? 0));
1721
+ const runningStats = createRunningPcaStats();
1722
+ // This basis is intentionally scoped to this processor instance.
1723
+ // Caller assumption: filter rebuilds (new processor instance) occur
1724
+ // when switching images/tile sources or changing filter settings.
1725
+ // If that lifecycle changes, this basis should be explicitly reset.
1726
+ let globalBasis: PcaBasis|null = null;
1727
+ const minSamples = 2000;
1728
+
1729
+ return function(context: CanvasRenderingContext2D, callback: () => void): void {
1730
+ withImageData(context, (imgData) => {
1731
+ const data = imgData.data;
1732
+ if (!globalBasis)
1733
+ {
1734
+ updateRunningPcaStats(runningStats, data);
1735
+ if (runningStats.count >= minSamples)
1736
+ {
1737
+ globalBasis = basisFromRunningStats(runningStats);
1738
+ }
1739
+ }
1740
+
1741
+ if (globalBasis)
1742
+ {
1743
+ applyPcaColorWithBasis(imgData, normalized, globalBasis, hue);
1744
+ }
1745
+ else
1746
+ {
1747
+ applyPcaColor(imgData, normalized, hue);
1748
+ }
1749
+ return true;
1750
+ });
1751
+ callback();
1752
+ };
1753
+ },
1754
+ BACKGROUND_NORMALIZE : function(strength?: number) : FilterProcessor {
1755
+ const amount = Math.max(0, Math.min(2, strength || 0));
1756
+ return function(context: CanvasRenderingContext2D, callback: () => void): void {
1757
+ if (amount === 0)
1758
+ {
1759
+ callback();
1760
+ return;
1761
+ }
1762
+ withImageData(context, (imgData) => {
1763
+ const width = imgData.width;
1764
+ const height = imgData.height;
1765
+ if (!width || !height)
1766
+ {
1767
+ return false;
1768
+ }
1769
+ const data = imgData.data;
1770
+ const lum = buildLuminance(data, width, height);
1771
+ const blurred = boxBlur(lum, width, height, 6, true);
1772
+ for (let i = 0, p = 0; i < data.length; i += 4, p += 1)
1773
+ {
1774
+ const normalized = clampByte((lum[p] - blurred[p]) * amount + 128);
1775
+ data[i] = normalized;
1776
+ data[i + 1] = normalized;
1777
+ data[i + 2] = normalized;
1778
+ }
1779
+ return true;
1780
+ });
1781
+ callback();
1782
+ };
1783
+ },
1784
+ UNSHARP_MASK : function(amount?: number) : FilterProcessor {
1785
+ const strength = Math.max(0, Math.min(3, amount || 0));
1786
+ return function(context: CanvasRenderingContext2D, callback: () => void): void {
1787
+ if (strength === 0)
1788
+ {
1789
+ callback();
1790
+ return;
1791
+ }
1792
+ withImageData(context, (imgData) => {
1793
+ const width = imgData.width;
1794
+ const height = imgData.height;
1795
+ if (!width || !height)
1796
+ {
1797
+ return false;
1798
+ }
1799
+ const blurred = boxBlur(imgData.data, width, height, 2);
1800
+ const data = imgData.data;
1801
+ for (let i = 0; i < data.length; i += 4)
1802
+ {
1803
+ data[i] = clampByte(data[i] + strength * (data[i] - blurred[i]));
1804
+ data[i + 1] = clampByte(data[i + 1] + strength * (data[i + 1] - blurred[i + 1]));
1805
+ data[i + 2] = clampByte(data[i + 2] + strength * (data[i + 2] - blurred[i + 2]));
1806
+ }
1807
+ return true;
1808
+ });
1809
+ callback();
1810
+ };
1811
+ },
1812
+ ADAPTIVE_THRESHOLD : function(windowSize?: number, offset?: number) : FilterProcessor {
1813
+ let size = windowSize || 15;
1814
+ if (size % 2 === 0)
1815
+ {
1816
+ size += 1;
1817
+ }
1818
+ size = Math.max(3, Math.min(51, size));
1819
+ const bias = Math.max(-50, Math.min(50, offset || 0));
1820
+ return function(context: CanvasRenderingContext2D, callback: () => void): void {
1821
+ withImageData(context, (imgData) => {
1822
+ const width = imgData.width;
1823
+ const height = imgData.height;
1824
+ if (!width || !height)
1825
+ {
1826
+ return false;
1827
+ }
1828
+ const data = imgData.data;
1829
+ const lum = buildLuminance(data, width, height);
1830
+ const mean = boxBlur(lum, width, height, Math.floor(size / 2), true);
1831
+ for (let i = 0, p = 0; i < data.length; i += 4, p += 1)
1832
+ {
1833
+ const value = lum[p] > mean[p] + bias ? 255 : 0;
1834
+ data[i] = value;
1835
+ data[i + 1] = value;
1836
+ data[i + 2] = value;
1837
+ }
1838
+ return true;
1839
+ });
1840
+ callback();
1841
+ };
1842
+ }
1843
+ };