@pilio/gemini-watermark-remover 1.0.10

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 (39) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +371 -0
  3. package/README_zh.md +371 -0
  4. package/bin/gwr.mjs +12 -0
  5. package/package.json +76 -0
  6. package/skills/gemini-watermark-remover/SKILL.md +28 -0
  7. package/skills/gemini-watermark-remover/agents/openai.yaml +3 -0
  8. package/skills/gemini-watermark-remover/references/inputs-and-outputs.md +9 -0
  9. package/skills/gemini-watermark-remover/references/limitations.md +5 -0
  10. package/skills/gemini-watermark-remover/references/usage.md +19 -0
  11. package/skills/gemini-watermark-remover/scripts/run.mjs +153 -0
  12. package/src/cli/gwrCli.js +17 -0
  13. package/src/cli/gwrRemoveCommand.js +317 -0
  14. package/src/core/adaptiveDetector.js +488 -0
  15. package/src/core/alphaMap.js +30 -0
  16. package/src/core/blendModes.js +70 -0
  17. package/src/core/candidateSelector.js +1446 -0
  18. package/src/core/canvasBlob.js +26 -0
  19. package/src/core/embeddedAlphaMaps.js +49 -0
  20. package/src/core/geminiSizeCatalog.js +239 -0
  21. package/src/core/multiPassRemoval.js +93 -0
  22. package/src/core/previewAlphaCalibration.js +822 -0
  23. package/src/core/restorationMetrics.js +251 -0
  24. package/src/core/selectionDebug.js +47 -0
  25. package/src/core/watermarkConfig.js +146 -0
  26. package/src/core/watermarkDecisionPolicy.js +163 -0
  27. package/src/core/watermarkDisplay.js +60 -0
  28. package/src/core/watermarkEngine.js +150 -0
  29. package/src/core/watermarkPresence.js +12 -0
  30. package/src/core/watermarkProcessor.js +875 -0
  31. package/src/core/workerClient.js +114 -0
  32. package/src/sdk/browser.d.ts +13 -0
  33. package/src/sdk/browser.js +29 -0
  34. package/src/sdk/image-data.d.ts +14 -0
  35. package/src/sdk/image-data.js +55 -0
  36. package/src/sdk/index.d.ts +144 -0
  37. package/src/sdk/index.js +8 -0
  38. package/src/sdk/node.d.ts +42 -0
  39. package/src/sdk/node.js +77 -0
@@ -0,0 +1,488 @@
1
+ /**
2
+ * Adaptive watermark detector
3
+ * Uses coarse-to-fine template matching around bottom-right region.
4
+ */
5
+
6
+ import { resolveGeminiWatermarkSearchConfigs } from './geminiSizeCatalog.js';
7
+
8
+ const DEFAULT_THRESHOLD = 0.35;
9
+ const EPSILON = 1e-8;
10
+
11
+ const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
12
+
13
+ function meanAndVariance(values) {
14
+ let sum = 0;
15
+ for (let i = 0; i < values.length; i++) sum += values[i];
16
+ const mean = sum / values.length;
17
+
18
+ let sq = 0;
19
+ for (let i = 0; i < values.length; i++) {
20
+ const d = values[i] - mean;
21
+ sq += d * d;
22
+ }
23
+ return { mean, variance: sq / values.length };
24
+ }
25
+
26
+ function normalizedCrossCorrelation(a, b) {
27
+ if (a.length !== b.length || a.length === 0) return 0;
28
+
29
+ const statsA = meanAndVariance(a);
30
+ const statsB = meanAndVariance(b);
31
+ const den = Math.sqrt(statsA.variance * statsB.variance) * a.length;
32
+
33
+ if (den < EPSILON) return 0;
34
+
35
+ let num = 0;
36
+ for (let i = 0; i < a.length; i++) {
37
+ num += (a[i] - statsA.mean) * (b[i] - statsB.mean);
38
+ }
39
+ return num / den;
40
+ }
41
+
42
+ function getRegion(data, width, x, y, size) {
43
+ const out = new Float32Array(size * size);
44
+ for (let row = 0; row < size; row++) {
45
+ const srcBase = (y + row) * width + x;
46
+ const dstBase = row * size;
47
+ for (let col = 0; col < size; col++) {
48
+ out[dstBase + col] = data[srcBase + col];
49
+ }
50
+ }
51
+ return out;
52
+ }
53
+
54
+ function toRegionGrayscale(imageData, region) {
55
+ const { width, height, data } = imageData;
56
+ const size = region.size ?? Math.min(region.width, region.height);
57
+ if (!size || size <= 0) return new Float32Array(0);
58
+ if (region.x < 0 || region.y < 0 || region.x + size > width || region.y + size > height) {
59
+ return new Float32Array(0);
60
+ }
61
+
62
+ const out = new Float32Array(size * size);
63
+ for (let row = 0; row < size; row++) {
64
+ for (let col = 0; col < size; col++) {
65
+ const idx = ((region.y + row) * width + (region.x + col)) * 4;
66
+ out[row * size + col] =
67
+ (0.2126 * data[idx] + 0.7152 * data[idx + 1] + 0.0722 * data[idx + 2]) / 255;
68
+ }
69
+ }
70
+ return out;
71
+ }
72
+
73
+ function toGrayscale(imageData) {
74
+ const { width, height, data } = imageData;
75
+ const out = new Float32Array(width * height);
76
+
77
+ for (let i = 0; i < out.length; i++) {
78
+ const j = i * 4;
79
+ out[i] = (0.2126 * data[j] + 0.7152 * data[j + 1] + 0.0722 * data[j + 2]) / 255;
80
+ }
81
+
82
+ return out;
83
+ }
84
+
85
+ function sobelMagnitude(gray, width, height) {
86
+ const grad = new Float32Array(width * height);
87
+
88
+ for (let y = 1; y < height - 1; y++) {
89
+ for (let x = 1; x < width - 1; x++) {
90
+ const i = y * width + x;
91
+ const gx =
92
+ -gray[i - width - 1] - 2 * gray[i - 1] - gray[i + width - 1] +
93
+ gray[i - width + 1] + 2 * gray[i + 1] + gray[i + width + 1];
94
+ const gy =
95
+ -gray[i - width - 1] - 2 * gray[i - width] - gray[i - width + 1] +
96
+ gray[i + width - 1] + 2 * gray[i + width] + gray[i + width + 1];
97
+ grad[i] = Math.sqrt(gx * gx + gy * gy);
98
+ }
99
+ }
100
+
101
+ return grad;
102
+ }
103
+
104
+ function stdDevRegion(data, width, x, y, size) {
105
+ let sum = 0;
106
+ let sq = 0;
107
+ let n = 0;
108
+
109
+ for (let row = 0; row < size; row++) {
110
+ const base = (y + row) * width + x;
111
+ for (let col = 0; col < size; col++) {
112
+ const v = data[base + col];
113
+ sum += v;
114
+ sq += v * v;
115
+ n++;
116
+ }
117
+ }
118
+
119
+ if (n === 0) return 0;
120
+ const mean = sum / n;
121
+ const variance = Math.max(0, sq / n - mean * mean);
122
+ return Math.sqrt(variance);
123
+ }
124
+
125
+ function buildTemplateGradient(alphaMap, size) {
126
+ return sobelMagnitude(alphaMap, size, size);
127
+ }
128
+
129
+ function scoreCandidate({ gray, grad, width, height }, alphaMap, templateGrad, candidate) {
130
+ const { x, y, size } = candidate;
131
+ if (x < 0 || y < 0 || x + size > width || y + size > height) {
132
+ return null;
133
+ }
134
+
135
+ const grayRegion = getRegion(gray, width, x, y, size);
136
+ const gradRegion = getRegion(grad, width, x, y, size);
137
+
138
+ const spatial = normalizedCrossCorrelation(grayRegion, alphaMap);
139
+ const gradient = normalizedCrossCorrelation(gradRegion, templateGrad);
140
+
141
+ let varianceScore = 0;
142
+ if (y > 8) {
143
+ const refY = Math.max(0, y - size);
144
+ const refH = Math.min(size, y - refY);
145
+ if (refH > 8) {
146
+ const wmStd = stdDevRegion(gray, width, x, y, size);
147
+ const refStd = stdDevRegion(gray, width, x, refY, refH);
148
+ if (refStd > EPSILON) {
149
+ varianceScore = clamp(1 - wmStd / refStd, 0, 1);
150
+ }
151
+ }
152
+ }
153
+
154
+ const confidence =
155
+ Math.max(0, spatial) * 0.5 +
156
+ Math.max(0, gradient) * 0.3 +
157
+ varianceScore * 0.2;
158
+
159
+ return {
160
+ confidence: clamp(confidence, 0, 1),
161
+ spatialScore: spatial,
162
+ gradientScore: gradient,
163
+ varianceScore
164
+ };
165
+ }
166
+
167
+ function createScaleList(minSize, maxSize) {
168
+ const set = new Set();
169
+ for (let s = minSize; s <= maxSize; s += 8) set.add(s);
170
+ if (48 >= minSize && 48 <= maxSize) set.add(48);
171
+ if (96 >= minSize && 96 <= maxSize) set.add(96);
172
+ return [...set].sort((a, b) => a - b);
173
+ }
174
+
175
+ function buildSeedConfigs(width, height, defaultConfig) {
176
+ // Start adaptive search from both the coarse default anchor and any
177
+ // catalog-projected anchors for official or near-official Gemini sizes.
178
+ return resolveGeminiWatermarkSearchConfigs(width, height, defaultConfig);
179
+ }
180
+
181
+ function getTemplate(cache, alpha96, size) {
182
+ if (cache.has(size)) return cache.get(size);
183
+
184
+ const alpha = size === 96 ? alpha96 : interpolateAlphaMap(alpha96, 96, size);
185
+ const grad = buildTemplateGradient(alpha, size);
186
+ const tpl = { alpha, grad };
187
+ cache.set(size, tpl);
188
+ return tpl;
189
+ }
190
+
191
+ function shiftAlphaMap(alphaMap, size, dx, dy) {
192
+ if (!Number.isFinite(dx) || !Number.isFinite(dy) || size <= 0) return new Float32Array(0);
193
+ return warpAlphaMap(alphaMap, size, { dx, dy, scale: 1 });
194
+ }
195
+
196
+ export function warpAlphaMap(alphaMap, size, { dx = 0, dy = 0, scale = 1 } = {}) {
197
+ if (size <= 0) return new Float32Array(0);
198
+ if (!Number.isFinite(dx) || !Number.isFinite(dy) || !Number.isFinite(scale) || scale <= 0) {
199
+ return new Float32Array(0);
200
+ }
201
+ if (dx === 0 && dy === 0 && scale === 1) return new Float32Array(alphaMap);
202
+
203
+ const sample = (x, y) => {
204
+ const x0 = Math.floor(x);
205
+ const y0 = Math.floor(y);
206
+ const fx = x - x0;
207
+ const fy = y - y0;
208
+
209
+ const ix0 = clamp(x0, 0, size - 1);
210
+ const iy0 = clamp(y0, 0, size - 1);
211
+ const ix1 = clamp(x0 + 1, 0, size - 1);
212
+ const iy1 = clamp(y0 + 1, 0, size - 1);
213
+
214
+ const p00 = alphaMap[iy0 * size + ix0];
215
+ const p10 = alphaMap[iy0 * size + ix1];
216
+ const p01 = alphaMap[iy1 * size + ix0];
217
+ const p11 = alphaMap[iy1 * size + ix1];
218
+
219
+ const top = p00 + (p10 - p00) * fx;
220
+ const bottom = p01 + (p11 - p01) * fx;
221
+ return top + (bottom - top) * fy;
222
+ };
223
+
224
+ const out = new Float32Array(size * size);
225
+ const c = (size - 1) / 2;
226
+ for (let y = 0; y < size; y++) {
227
+ for (let x = 0; x < size; x++) {
228
+ const sx = (x - c) / scale + c + dx;
229
+ const sy = (y - c) / scale + c + dy;
230
+ out[y * size + x] = sample(sx, sy);
231
+ }
232
+ }
233
+ return out;
234
+ }
235
+
236
+ export function interpolateAlphaMap(sourceAlpha, sourceSize, targetSize) {
237
+ if (targetSize <= 0) return new Float32Array(0);
238
+ if (sourceSize === targetSize) return new Float32Array(sourceAlpha);
239
+
240
+ const out = new Float32Array(targetSize * targetSize);
241
+ const scale = (sourceSize - 1) / Math.max(1, targetSize - 1);
242
+
243
+ for (let y = 0; y < targetSize; y++) {
244
+ const sy = y * scale;
245
+ const y0 = Math.floor(sy);
246
+ const y1 = Math.min(sourceSize - 1, y0 + 1);
247
+ const fy = sy - y0;
248
+
249
+ for (let x = 0; x < targetSize; x++) {
250
+ const sx = x * scale;
251
+ const x0 = Math.floor(sx);
252
+ const x1 = Math.min(sourceSize - 1, x0 + 1);
253
+ const fx = sx - x0;
254
+
255
+ const p00 = sourceAlpha[y0 * sourceSize + x0];
256
+ const p10 = sourceAlpha[y0 * sourceSize + x1];
257
+ const p01 = sourceAlpha[y1 * sourceSize + x0];
258
+ const p11 = sourceAlpha[y1 * sourceSize + x1];
259
+
260
+ const top = p00 + (p10 - p00) * fx;
261
+ const bottom = p01 + (p11 - p01) * fx;
262
+ out[y * targetSize + x] = top + (bottom - top) * fy;
263
+ }
264
+ }
265
+
266
+ return out;
267
+ }
268
+
269
+ export function computeRegionSpatialCorrelation({ imageData, alphaMap, region }) {
270
+ const patch = toRegionGrayscale(imageData, region);
271
+ if (patch.length === 0 || patch.length !== alphaMap.length) return 0;
272
+ return normalizedCrossCorrelation(patch, alphaMap);
273
+ }
274
+
275
+ export function computeRegionGradientCorrelation({ imageData, alphaMap, region }) {
276
+ const patch = toRegionGrayscale(imageData, region);
277
+ if (patch.length === 0 || patch.length !== alphaMap.length) return 0;
278
+ const size = region.size ?? Math.min(region.width, region.height);
279
+ if (!size || size <= 2) return 0;
280
+
281
+ const patchGrad = sobelMagnitude(patch, size, size);
282
+ const alphaGrad = sobelMagnitude(alphaMap, size, size);
283
+ return normalizedCrossCorrelation(patchGrad, alphaGrad);
284
+ }
285
+
286
+ export function shouldAttemptAdaptiveFallback({
287
+ processedImageData,
288
+ alphaMap,
289
+ position,
290
+ residualThreshold = 0.22,
291
+ originalImageData = null,
292
+ originalSpatialMismatchThreshold = 0
293
+ }) {
294
+ const residualScore = computeRegionSpatialCorrelation({
295
+ imageData: processedImageData,
296
+ alphaMap,
297
+ region: {
298
+ x: position.x,
299
+ y: position.y,
300
+ size: position.width ?? position.size
301
+ }
302
+ });
303
+
304
+ if (residualScore >= residualThreshold) {
305
+ return true;
306
+ }
307
+
308
+ if (originalImageData) {
309
+ const originalScore = computeRegionSpatialCorrelation({
310
+ imageData: originalImageData,
311
+ alphaMap,
312
+ region: {
313
+ x: position.x,
314
+ y: position.y,
315
+ size: position.width ?? position.size
316
+ }
317
+ });
318
+
319
+ if (originalScore <= originalSpatialMismatchThreshold) {
320
+ return true;
321
+ }
322
+ }
323
+
324
+ return false;
325
+ }
326
+
327
+ export function detectAdaptiveWatermarkRegion({
328
+ imageData,
329
+ alpha96,
330
+ defaultConfig,
331
+ threshold = DEFAULT_THRESHOLD
332
+ }) {
333
+ const { width, height } = imageData;
334
+ const gray = toGrayscale(imageData);
335
+ const grad = sobelMagnitude(gray, width, height);
336
+ const context = { gray, grad, width, height };
337
+ const templateCache = new Map();
338
+
339
+ const seedConfigs = buildSeedConfigs(width, height, defaultConfig);
340
+ const seedCandidates = seedConfigs
341
+ .map((config) => {
342
+ const size = config.logoSize;
343
+ const candidate = {
344
+ size,
345
+ x: width - config.marginRight - size,
346
+ y: height - config.marginBottom - size
347
+ };
348
+ if (candidate.x < 0 || candidate.y < 0 || candidate.x + size > width || candidate.y + size > height) {
349
+ return null;
350
+ }
351
+
352
+ const template = getTemplate(templateCache, alpha96, size);
353
+ const score = scoreCandidate(context, template.alpha, template.grad, candidate);
354
+ if (!score) return null;
355
+
356
+ return {
357
+ ...candidate,
358
+ ...score
359
+ };
360
+ })
361
+ .filter(Boolean);
362
+
363
+ const bestSeed = seedCandidates.reduce((best, candidate) => {
364
+ if (!best || candidate.confidence > best.confidence) return candidate;
365
+ return best;
366
+ }, null);
367
+ if (bestSeed && bestSeed.confidence >= threshold + 0.08) {
368
+ return {
369
+ found: true,
370
+ confidence: bestSeed.confidence,
371
+ spatialScore: bestSeed.spatialScore,
372
+ gradientScore: bestSeed.gradientScore,
373
+ varianceScore: bestSeed.varianceScore,
374
+ region: {
375
+ x: bestSeed.x,
376
+ y: bestSeed.y,
377
+ size: bestSeed.size
378
+ }
379
+ };
380
+ }
381
+
382
+ const baseSize = defaultConfig.logoSize;
383
+
384
+ const minSize = clamp(Math.round(baseSize * 0.65), 24, 144);
385
+ const maxSize = clamp(
386
+ Math.min(Math.round(baseSize * 2.8), Math.floor(Math.min(width, height) * 0.4)),
387
+ minSize,
388
+ 192
389
+ );
390
+ const scaleList = createScaleList(minSize, maxSize);
391
+
392
+ const marginRange = Math.max(32, Math.round(baseSize * 0.75));
393
+ const minMarginRight = clamp(defaultConfig.marginRight - marginRange, 8, width - minSize - 1);
394
+ const maxMarginRight = clamp(defaultConfig.marginRight + marginRange, minMarginRight, width - minSize - 1);
395
+ const minMarginBottom = clamp(defaultConfig.marginBottom - marginRange, 8, height - minSize - 1);
396
+ const maxMarginBottom = clamp(defaultConfig.marginBottom + marginRange, minMarginBottom, height - minSize - 1);
397
+
398
+ const topK = [];
399
+ const pushTopK = (candidate) => {
400
+ topK.push(candidate);
401
+ topK.sort((a, b) => b.adjustedScore - a.adjustedScore);
402
+ if (topK.length > 5) topK.length = 5;
403
+ };
404
+
405
+ for (const seedCandidate of seedCandidates) {
406
+ pushTopK({
407
+ size: seedCandidate.size,
408
+ x: seedCandidate.x,
409
+ y: seedCandidate.y,
410
+ adjustedScore: seedCandidate.confidence * Math.min(1, Math.sqrt(seedCandidate.size / 96))
411
+ });
412
+ }
413
+
414
+ for (const size of scaleList) {
415
+ const tpl = getTemplate(templateCache, alpha96, size);
416
+ for (let mr = minMarginRight; mr <= maxMarginRight; mr += 8) {
417
+ const x = width - mr - size;
418
+ if (x < 0) continue;
419
+ for (let mb = minMarginBottom; mb <= maxMarginBottom; mb += 8) {
420
+ const y = height - mb - size;
421
+ if (y < 0) continue;
422
+
423
+ const score = scoreCandidate(context, tpl.alpha, tpl.grad, { x, y, size });
424
+ if (!score) continue;
425
+
426
+ // Prefer sizes close to known watermark scales to avoid tiny-template bias.
427
+ const adjustedScore = score.confidence * Math.min(1, Math.sqrt(size / 96));
428
+ if (adjustedScore < 0.08) continue;
429
+
430
+ pushTopK({
431
+ size,
432
+ x,
433
+ y,
434
+ adjustedScore
435
+ });
436
+ }
437
+ }
438
+ }
439
+
440
+ let best = bestSeed ?? {
441
+ x: width - defaultConfig.marginRight - defaultConfig.logoSize,
442
+ y: height - defaultConfig.marginBottom - defaultConfig.logoSize,
443
+ size: defaultConfig.logoSize,
444
+ confidence: 0,
445
+ spatialScore: 0,
446
+ gradientScore: 0,
447
+ varianceScore: 0
448
+ };
449
+
450
+ for (const coarse of topK) {
451
+ const scaleLo = clamp(coarse.size - 10, minSize, maxSize);
452
+ const scaleHi = clamp(coarse.size + 10, minSize, maxSize);
453
+
454
+ for (let size = scaleLo; size <= scaleHi; size += 2) {
455
+ const tpl = getTemplate(templateCache, alpha96, size);
456
+ for (let x = coarse.x - 8; x <= coarse.x + 8; x += 2) {
457
+ if (x < 0 || x + size > width) continue;
458
+ for (let y = coarse.y - 8; y <= coarse.y + 8; y += 2) {
459
+ if (y < 0 || y + size > height) continue;
460
+ const score = scoreCandidate(context, tpl.alpha, tpl.grad, { x, y, size });
461
+ if (!score) continue;
462
+
463
+ if (score.confidence > best.confidence) {
464
+ best = {
465
+ x,
466
+ y,
467
+ size,
468
+ ...score
469
+ };
470
+ }
471
+ }
472
+ }
473
+ }
474
+ }
475
+
476
+ return {
477
+ found: best.confidence >= threshold,
478
+ confidence: best.confidence,
479
+ spatialScore: best.spatialScore,
480
+ gradientScore: best.gradientScore,
481
+ varianceScore: best.varianceScore,
482
+ region: {
483
+ x: best.x,
484
+ y: best.y,
485
+ size: best.size
486
+ }
487
+ };
488
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Alpha Map calculator
3
+ * calculate alpha map from capture background image
4
+ */
5
+
6
+ /**
7
+ * Calculate alpha map from background captured image
8
+ * @param {ImageData} bgCaptureImageData -ImageData object for background capture
9
+ * @returns {Float32Array} Alpha map (value range 0.0-1.0)
10
+ */
11
+ export function calculateAlphaMap(bgCaptureImageData) {
12
+ const { width, height, data } = bgCaptureImageData;
13
+ const alphaMap = new Float32Array(width * height);
14
+
15
+ // For each pixel, take the maximum value of the three RGB channels and normalize it to [0, 1]
16
+ for (let i = 0; i < alphaMap.length; i++) {
17
+ const idx = i * 4; // RGBA format, 4 bytes per pixel
18
+ const r = data[idx];
19
+ const g = data[idx + 1];
20
+ const b = data[idx + 2];
21
+
22
+ // Take the maximum value of the three RGB channels as the brightness value
23
+ const maxChannel = Math.max(r, g, b);
24
+
25
+ // Normalize to [0, 1] range
26
+ alphaMap[i] = maxChannel / 255.0;
27
+ }
28
+
29
+ return alphaMap;
30
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Reverse alpha blending module
3
+ * Core algorithm for removing watermarks
4
+ */
5
+
6
+ // Constants definition
7
+ const ALPHA_NOISE_FLOOR = 3 / 255; // Remove low-level quantization noise from alpha map
8
+ const ALPHA_THRESHOLD = 0.002; // Ignore very small alpha values after noise floor removal
9
+ const MAX_ALPHA = 0.99; // Avoid division by near-zero values
10
+ const LOGO_VALUE = 255; // Color value for white watermark
11
+
12
+ /**
13
+ * Remove watermark using reverse alpha blending
14
+ *
15
+ * Principle:
16
+ * Gemini adds watermark: watermarked = α × logo + (1 - α) × original
17
+ * Reverse solve: original = (watermarked - α × logo) / (1 - α)
18
+ *
19
+ * @param {ImageData} imageData - Image data to process (will be modified in place)
20
+ * @param {Float32Array} alphaMap - Alpha channel data
21
+ * @param {Object} position - Watermark position {x, y, width, height}
22
+ * @param {Object} [options] - Optional settings
23
+ * @param {number} [options.alphaGain=1] - Gain multiplier for alpha map strength
24
+ */
25
+ export function removeWatermark(imageData, alphaMap, position, options = {}) {
26
+ const { x, y, width, height } = position;
27
+ const alphaGain = Number.isFinite(options.alphaGain) && options.alphaGain > 0
28
+ ? options.alphaGain
29
+ : 1;
30
+
31
+ // Process each pixel in the watermark area
32
+ for (let row = 0; row < height; row++) {
33
+ for (let col = 0; col < width; col++) {
34
+ // Calculate index in original image (RGBA format, 4 bytes per pixel)
35
+ const imgIdx = ((y + row) * imageData.width + (x + col)) * 4;
36
+
37
+ // Calculate index in alpha map
38
+ const alphaIdx = row * width + col;
39
+
40
+ // Get alpha value
41
+ const rawAlpha = alphaMap[alphaIdx];
42
+
43
+ // Remove low-level alpha noise from compressed background capture.
44
+ const signalAlpha = Math.max(0, rawAlpha - ALPHA_NOISE_FLOOR) * alphaGain;
45
+
46
+ // Skip very small alpha values (noise)
47
+ if (signalAlpha < ALPHA_THRESHOLD) {
48
+ continue;
49
+ }
50
+
51
+ // Use original alpha for inverse solve; use denoised alpha as activation signal.
52
+ const alpha = Math.min(rawAlpha * alphaGain, MAX_ALPHA);
53
+ const oneMinusAlpha = 1.0 - alpha;
54
+
55
+ // Apply reverse alpha blending to each RGB channel
56
+ for (let c = 0; c < 3; c++) {
57
+ const watermarked = imageData.data[imgIdx + c];
58
+
59
+ // Reverse alpha blending formula
60
+ const original = (watermarked - alpha * LOGO_VALUE) / oneMinusAlpha;
61
+
62
+ // Clip to [0, 255] range
63
+ imageData.data[imgIdx + c] = Math.max(0, Math.min(255, Math.round(original)));
64
+ }
65
+
66
+ // Alpha channel remains unchanged
67
+ // imageData.data[imgIdx + 3] does not need modification
68
+ }
69
+ }
70
+ }