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.
- package/.clang-format +7 -0
- package/.github/workflows/npm-publish.yml +45 -0
- package/LICENSE +55 -0
- package/Makefile +75 -0
- package/README.md +15 -108
- package/elm.json +32 -0
- package/package.json +12 -59
- package/review/elm.json +52 -0
- package/review/src/ReviewConfig.elm +87 -0
- package/scripts/elm-esm.sh +40 -0
- package/scripts/minify-css.mjs +31 -0
- package/src/Filters.elm +1044 -0
- package/src/Main.elm +1217 -0
- package/src/Model.elm +213 -0
- package/src/Msg.elm +59 -0
- package/src/Utilities.elm +46 -0
- package/src/View/CollectionExplorer.elm +172 -0
- package/src/View/Helpers.elm +86 -0
- package/src/View/HtmlRenderer.elm +136 -0
- package/src/View/Icons.elm +159 -0
- package/src/View/ManifestInfoModal.elm +363 -0
- package/src/View/PageViewModal.elm +1046 -0
- package/src/View/Sidebar.elm +786 -0
- package/src/View/Toolbar.elm +189 -0
- package/src/View.elm +244 -0
- package/src/diva.ts +802 -0
- package/src/filters.ts +1843 -0
- package/src/styles/app.css +328 -0
- package/src/styles/collection.css +75 -0
- package/src/styles/modal.css +388 -0
- package/src/styles/sidebar.css +215 -0
- package/src/styles/theme.css +39 -0
- package/src/styles/toolbar.css +154 -0
- package/src/viewer-element.ts +1307 -0
- package/testing/index.html +52 -0
- package/testing/testing.html +231 -0
- package/tsconfig.json +12 -0
- package/AUTHORS +0 -22
- package/_site/diva.iml +0 -11
- package/build/diva.css +0 -554
- package/build/diva.css.map +0 -1
- package/build/diva.js +0 -9
- package/build/diva.js.map +0 -1
- package/build/plugins/download.js +0 -2
- package/build/plugins/download.js.map +0 -1
- package/build/plugins/manipulation.js +0 -2
- package/build/plugins/manipulation.js.map +0 -1
- package/build/plugins/metadata.js +0 -2
- package/build/plugins/metadata.js.map +0 -1
- package/diva.iml +0 -11
- package/index.html +0 -28
- package/karma.conf.js +0 -87
- package/source/css/_mixins.scss +0 -43
- package/source/css/_variables.scss +0 -50
- package/source/css/_viewer.scss +0 -462
- package/source/css/diva.scss +0 -15
- package/source/css/plugins/_manipulation.scss +0 -228
- package/source/css/plugins/_metadata.scss +0 -31
- package/source/img/adjust.svg +0 -11
- package/source/img/book-view.svg +0 -6
- package/source/img/close.svg +0 -6
- package/source/img/download.svg +0 -6
- package/source/img/from-fullscreen.svg +0 -8
- package/source/img/grid-fewer.svg +0 -6
- package/source/img/grid-more.svg +0 -6
- package/source/img/grid-view.svg +0 -6
- package/source/img/link.svg +0 -6
- package/source/img/metadata.svg +0 -9
- package/source/img/page-view.svg +0 -6
- package/source/img/to-fullscreen.svg +0 -11
- package/source/img/zoom-in.svg +0 -6
- package/source/img/zoom-out.svg +0 -7
- package/source/js/composite-image.js +0 -174
- package/source/js/diva-global.js +0 -7
- package/source/js/diva.js +0 -1543
- package/source/js/document-handler.js +0 -180
- package/source/js/document-layout.js +0 -286
- package/source/js/exceptions.js +0 -26
- package/source/js/gesture-events.js +0 -190
- package/source/js/grid-handler.js +0 -122
- package/source/js/iiif-source-adapter.js +0 -63
- package/source/js/image-cache.js +0 -113
- package/source/js/image-manifest.js +0 -157
- package/source/js/image-request-handler.js +0 -76
- package/source/js/interpolate-animation.js +0 -122
- package/source/js/page-layouts/book-layout.js +0 -161
- package/source/js/page-layouts/grid-layout.js +0 -97
- package/source/js/page-layouts/index.js +0 -38
- package/source/js/page-layouts/page-dimensions.js +0 -9
- package/source/js/page-layouts/singles-layout.js +0 -27
- package/source/js/page-overlay-manager.js +0 -102
- package/source/js/page-tools-overlay.js +0 -95
- package/source/js/parse-iiif-manifest.js +0 -302
- package/source/js/plugins/_filters.js +0 -679
- package/source/js/plugins/download.js +0 -83
- package/source/js/plugins/manipulation.js +0 -837
- package/source/js/plugins/metadata.js +0 -190
- package/source/js/renderer.js +0 -584
- package/source/js/settings-view.js +0 -30
- package/source/js/tile-coverage-map.js +0 -25
- package/source/js/toolbar.js +0 -572
- package/source/js/utils/dragscroll.js +0 -106
- package/source/js/utils/elt.js +0 -94
- package/source/js/utils/events.js +0 -190
- package/source/js/utils/get-scrollbar-width.js +0 -29
- package/source/js/utils/hash-params.js +0 -86
- package/source/js/utils/parse-label-value.js +0 -34
- package/source/js/utils/vanilla.kinetic.js +0 -527
- package/source/js/validation-runner.js +0 -177
- package/source/js/viewer-core.js +0 -1505
- package/source/js/viewport.js +0 -143
- package/test/_setup.js +0 -13
- package/test/composite-image_test.js +0 -94
- package/test/diva_test.js +0 -43
- package/test/hash-params_test.js +0 -221
- package/test/image-cache_test.js +0 -106
- package/test/main.js +0 -6
- package/test/manifests/beromunsterManifest.json +0 -15514
- package/test/manifests/iiifv2.json +0 -11032
- package/test/manifests/iiifv2pages.json +0 -30437
- package/test/manifests/iiifv3.json +0 -10965
- package/test/navigation_test.js +0 -355
- package/test/parse-iiif-manifest_test.js +0 -68
- package/test/public_test.js +0 -881
- package/test/settings_test.js +0 -487
- package/test/utils/book-layout_test.js +0 -148
- package/test/utils/elt_test.js +0 -102
- package/test/utils/events_test.js +0 -245
- package/test/utils/hash-params_test.js +0 -79
- package/test/utils/parse-label-value_test.js +0 -45
- package/test/z_plugins_test.js +0 -180
- package/webpack.config.js +0 -58
- 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
|
+
};
|