@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,875 @@
1
+ import { removeWatermark } from './blendModes.js';
2
+ import { removeRepeatedWatermarkLayers } from './multiPassRemoval.js';
3
+ import {
4
+ computeRegionGradientCorrelation,
5
+ computeRegionSpatialCorrelation,
6
+ warpAlphaMap
7
+ } from './adaptiveDetector.js';
8
+ import {
9
+ calculateNearBlackRatio,
10
+ scoreRegion,
11
+ selectInitialCandidate
12
+ } from './candidateSelector.js';
13
+ import { assessAlphaBandHalo } from './restorationMetrics.js';
14
+ import { createSelectionDebugSummary } from './selectionDebug.js';
15
+ import {
16
+ calculateWatermarkPosition,
17
+ detectWatermarkConfig,
18
+ resolveInitialStandardConfig
19
+ } from './watermarkConfig.js';
20
+
21
+ const RESIDUAL_RECALIBRATION_THRESHOLD = 0.5;
22
+ const MIN_SUPPRESSION_FOR_SKIP_RECALIBRATION = 0.18;
23
+ const MIN_RECALIBRATION_SCORE_DELTA = 0.18;
24
+ const MAX_NEAR_BLACK_RATIO_INCREASE = 0.05;
25
+ const OUTLINE_REFINEMENT_THRESHOLD = 0.42;
26
+ const OUTLINE_REFINEMENT_MIN_GAIN = 1.2;
27
+ const SUBPIXEL_REFINE_SHIFTS = [-0.25, 0, 0.25];
28
+ const SUBPIXEL_REFINE_SCALES = [0.99, 1, 1.01];
29
+ const ALPHA_GAIN_CANDIDATES = [1.05, 1.12, 1.2, 1.28, 1.36, 1.45, 1.52, 1.6, 1.7, 1.85, 2.0, 2.2, 2.4, 2.6];
30
+ const PREVIEW_EDGE_CLEANUP_MAX_SIZE = 40;
31
+ const PREVIEW_EDGE_CLEANUP_SPATIAL_THRESHOLD = 0.08;
32
+ const PREVIEW_EDGE_CLEANUP_GRADIENT_THRESHOLD = 0.1;
33
+ const PREVIEW_EDGE_CLEANUP_MIN_GRADIENT_IMPROVEMENT = 0.03;
34
+ const PREVIEW_EDGE_CLEANUP_MAX_SPATIAL_DRIFT = 0.04;
35
+ const PREVIEW_EDGE_CLEANUP_MAX_APPLIED_PASSES = 3;
36
+ const PREVIEW_EDGE_CLEANUP_FINE_GRADIENT_THRESHOLD = 0.16;
37
+ const PREVIEW_EDGE_CLEANUP_FINE_MIN_GRADIENT_IMPROVEMENT = 0.005;
38
+ const PREVIEW_EDGE_CLEANUP_HALO_RELAXED_MIN_GRADIENT_IMPROVEMENT = 0.01;
39
+ const PREVIEW_EDGE_CLEANUP_HALO_WEIGHT = 0.02;
40
+ const PREVIEW_EDGE_CLEANUP_MIN_HALO_REDUCTION = 1.5;
41
+ const PREVIEW_EDGE_CLEANUP_STRONG_HALO_THRESHOLD = 4;
42
+ const PREVIEW_EDGE_CLEANUP_HALO_SPATIAL_THRESHOLD = 0.18;
43
+ const PREVIEW_EDGE_CLEANUP_PRESETS = Object.freeze([
44
+ { minAlpha: 0.02, maxAlpha: 0.45, radius: 2, strength: 0.7, outsideAlphaMax: 0.05 },
45
+ { minAlpha: 0.05, maxAlpha: 0.55, radius: 3, strength: 0.7, outsideAlphaMax: 0.08 },
46
+ { minAlpha: 0.1, maxAlpha: 0.7, radius: 3, strength: 0.8, outsideAlphaMax: 0.12 },
47
+ { minAlpha: 0.01, maxAlpha: 0.35, radius: 4, strength: 1.4, outsideAlphaMax: 0.05 }
48
+ ]);
49
+ const PREVIEW_EDGE_CLEANUP_STRONG_GRADIENT_THRESHOLD = 0.45;
50
+ const PREVIEW_EDGE_CLEANUP_AGGRESSIVE_PRESETS = Object.freeze([
51
+ {
52
+ minAlpha: 0.01,
53
+ maxAlpha: 0.55,
54
+ radius: 2,
55
+ strength: 1.3,
56
+ outsideAlphaMax: 0.05,
57
+ minGradientImprovement: 0.12,
58
+ maxSpatialDrift: 0.18,
59
+ maxAcceptedSpatial: 0.18
60
+ }
61
+ ]);
62
+ const FIRST_PASS_SIGN_FLIP_GRADIENT_THRESHOLD = 0.08;
63
+ const FIRST_PASS_SIGN_FLIP_MIN_GRADIENT_DROP = 0.2;
64
+
65
+ function nowMs() {
66
+ if (typeof globalThis.performance?.now === 'function') {
67
+ return globalThis.performance.now();
68
+ }
69
+ return Date.now();
70
+ }
71
+
72
+ function cloneImageData(imageData) {
73
+ if (typeof ImageData !== 'undefined' && imageData instanceof ImageData) {
74
+ return new ImageData(
75
+ new Uint8ClampedArray(imageData.data),
76
+ imageData.width,
77
+ imageData.height
78
+ );
79
+ }
80
+
81
+ return {
82
+ width: imageData.width,
83
+ height: imageData.height,
84
+ data: new Uint8ClampedArray(imageData.data)
85
+ };
86
+ }
87
+
88
+ function normalizeMetaPosition(position) {
89
+ if (!position) return null;
90
+
91
+ const { x, y, width, height } = position;
92
+ if (![x, y, width, height].every((value) => Number.isFinite(value))) {
93
+ return null;
94
+ }
95
+
96
+ return { x, y, width, height };
97
+ }
98
+
99
+ function normalizeMetaConfig(config) {
100
+ if (!config) return null;
101
+
102
+ const { logoSize, marginRight, marginBottom } = config;
103
+ if (![logoSize, marginRight, marginBottom].every((value) => Number.isFinite(value))) {
104
+ return null;
105
+ }
106
+
107
+ return { logoSize, marginRight, marginBottom };
108
+ }
109
+
110
+ function createWatermarkMeta({
111
+ position = null,
112
+ config = null,
113
+ adaptiveConfidence = null,
114
+ originalSpatialScore = null,
115
+ originalGradientScore = null,
116
+ processedSpatialScore = null,
117
+ processedGradientScore = null,
118
+ suppressionGain = null,
119
+ templateWarp = null,
120
+ alphaGain = 1,
121
+ passCount = 0,
122
+ attemptedPassCount = 0,
123
+ passStopReason = null,
124
+ passes = null,
125
+ source = 'standard',
126
+ decisionTier = null,
127
+ applied = true,
128
+ skipReason = null,
129
+ subpixelShift = null,
130
+ selectionDebug = null
131
+ } = {}) {
132
+ const normalizedPosition = normalizeMetaPosition(position);
133
+
134
+ return {
135
+ applied,
136
+ skipReason: applied ? null : skipReason,
137
+ size: normalizedPosition ? normalizedPosition.width : null,
138
+ position: normalizedPosition,
139
+ config: normalizeMetaConfig(config),
140
+ detection: {
141
+ adaptiveConfidence,
142
+ originalSpatialScore,
143
+ originalGradientScore,
144
+ processedSpatialScore,
145
+ processedGradientScore,
146
+ suppressionGain
147
+ },
148
+ templateWarp: templateWarp ?? null,
149
+ alphaGain,
150
+ passCount,
151
+ attemptedPassCount,
152
+ passStopReason,
153
+ passes: Array.isArray(passes) ? passes : null,
154
+ // decisionTier is the normalized contract used by UI and attribution.
155
+ // source remains as a verbose execution trace for debugging/tests.
156
+ source,
157
+ decisionTier,
158
+ subpixelShift: subpixelShift ?? null,
159
+ selectionDebug
160
+ };
161
+ }
162
+
163
+ function shouldRecalibrateAlphaStrength({ originalScore, processedScore, suppressionGain }) {
164
+ return originalScore >= 0.6 &&
165
+ processedScore >= RESIDUAL_RECALIBRATION_THRESHOLD &&
166
+ suppressionGain <= MIN_SUPPRESSION_FOR_SKIP_RECALIBRATION;
167
+ }
168
+
169
+ function shouldStopAfterFirstPass({
170
+ originalSpatialScore,
171
+ originalGradientScore,
172
+ firstPassSpatialScore,
173
+ firstPassGradientScore
174
+ }) {
175
+ if (Math.abs(firstPassSpatialScore) <= 0.25) {
176
+ return true;
177
+ }
178
+
179
+ return originalSpatialScore >= 0 &&
180
+ firstPassSpatialScore < 0 &&
181
+ firstPassGradientScore <= FIRST_PASS_SIGN_FLIP_GRADIENT_THRESHOLD &&
182
+ (originalGradientScore - firstPassGradientScore) >= FIRST_PASS_SIGN_FLIP_MIN_GRADIENT_DROP;
183
+ }
184
+
185
+ function refineSubpixelOutline({
186
+ sourceImageData,
187
+ alphaMap,
188
+ position,
189
+ alphaGain,
190
+ originalNearBlackRatio,
191
+ baselineSpatialScore,
192
+ baselineGradientScore,
193
+ baselineShift,
194
+ minGain = OUTLINE_REFINEMENT_MIN_GAIN,
195
+ shiftCandidates = SUBPIXEL_REFINE_SHIFTS,
196
+ scaleCandidates = SUBPIXEL_REFINE_SCALES,
197
+ minGradientImprovement = 0.04,
198
+ maxSpatialDrift = 0.08
199
+ }) {
200
+ const size = position.width;
201
+ if (!size || size <= 8) return null;
202
+ if (alphaGain < minGain) return null;
203
+
204
+ const maxAllowedNearBlackRatio = Math.min(1, originalNearBlackRatio + MAX_NEAR_BLACK_RATIO_INCREASE);
205
+ const gainCandidates = [alphaGain];
206
+ const lower = Math.max(1, Number((alphaGain - 0.01).toFixed(2)));
207
+ const upper = Number((alphaGain + 0.01).toFixed(2));
208
+ if (lower !== alphaGain) gainCandidates.push(lower);
209
+ if (upper !== alphaGain) gainCandidates.push(upper);
210
+
211
+ const baseDx = baselineShift?.dx ?? 0;
212
+ const baseDy = baselineShift?.dy ?? 0;
213
+ const baseScale = baselineShift?.scale ?? 1;
214
+
215
+ let best = null;
216
+ for (const scaleDelta of scaleCandidates) {
217
+ const scale = Number((baseScale * scaleDelta).toFixed(4));
218
+ for (const dyDelta of shiftCandidates) {
219
+ const dy = baseDy + dyDelta;
220
+ for (const dxDelta of shiftCandidates) {
221
+ const dx = baseDx + dxDelta;
222
+ const warped = warpAlphaMap(alphaMap, size, { dx, dy, scale });
223
+ for (const gain of gainCandidates) {
224
+ const candidate = cloneImageData(sourceImageData);
225
+ removeWatermark(candidate, warped, position, { alphaGain: gain });
226
+ const nearBlackRatio = calculateNearBlackRatio(candidate, position);
227
+ if (nearBlackRatio > maxAllowedNearBlackRatio) continue;
228
+
229
+ const spatialScore = computeRegionSpatialCorrelation({
230
+ imageData: candidate,
231
+ alphaMap: warped,
232
+ region: { x: position.x, y: position.y, size }
233
+ });
234
+ const gradientScore = computeRegionGradientCorrelation({
235
+ imageData: candidate,
236
+ alphaMap: warped,
237
+ region: { x: position.x, y: position.y, size }
238
+ });
239
+
240
+ const cost = Math.abs(spatialScore) * 0.6 + Math.max(0, gradientScore);
241
+ if (!best || cost < best.cost) {
242
+ best = {
243
+ imageData: candidate,
244
+ alphaMap: warped,
245
+ alphaGain: gain,
246
+ shift: { dx, dy, scale },
247
+ spatialScore,
248
+ gradientScore,
249
+ nearBlackRatio,
250
+ cost
251
+ };
252
+ }
253
+ }
254
+ }
255
+ }
256
+ }
257
+
258
+ if (!best) return null;
259
+
260
+ const improvedGradient = best.gradientScore <= baselineGradientScore - minGradientImprovement;
261
+ const keptSpatial = Math.abs(best.spatialScore) <= Math.abs(baselineSpatialScore) + maxSpatialDrift;
262
+ if (!improvedGradient || !keptSpatial) return null;
263
+
264
+ return best;
265
+ }
266
+
267
+ function recalibrateAlphaStrength({
268
+ sourceImageData,
269
+ alphaMap,
270
+ position,
271
+ originalSpatialScore,
272
+ processedSpatialScore,
273
+ originalNearBlackRatio
274
+ }) {
275
+ let bestScore = processedSpatialScore;
276
+ let bestGain = 1;
277
+ let bestImageData = null;
278
+ const maxAllowedNearBlackRatio = Math.min(1, originalNearBlackRatio + MAX_NEAR_BLACK_RATIO_INCREASE);
279
+
280
+ for (const alphaGain of ALPHA_GAIN_CANDIDATES) {
281
+ const candidate = cloneImageData(sourceImageData);
282
+ removeWatermark(candidate, alphaMap, position, { alphaGain });
283
+ const candidateNearBlackRatio = calculateNearBlackRatio(candidate, position);
284
+ if (candidateNearBlackRatio > maxAllowedNearBlackRatio) {
285
+ continue;
286
+ }
287
+
288
+ const score = computeRegionSpatialCorrelation({
289
+ imageData: candidate,
290
+ alphaMap,
291
+ region: {
292
+ x: position.x,
293
+ y: position.y,
294
+ size: position.width
295
+ }
296
+ });
297
+
298
+ if (score < bestScore) {
299
+ bestScore = score;
300
+ bestGain = alphaGain;
301
+ bestImageData = candidate;
302
+ }
303
+ }
304
+
305
+ const refinedCandidates = [];
306
+ for (let delta = -0.05; delta <= 0.05; delta += 0.01) {
307
+ refinedCandidates.push(Number((bestGain + delta).toFixed(2)));
308
+ }
309
+
310
+ for (const alphaGain of refinedCandidates) {
311
+ if (alphaGain <= 1 || alphaGain >= 3) continue;
312
+ const candidate = cloneImageData(sourceImageData);
313
+ removeWatermark(candidate, alphaMap, position, { alphaGain });
314
+ const candidateNearBlackRatio = calculateNearBlackRatio(candidate, position);
315
+ if (candidateNearBlackRatio > maxAllowedNearBlackRatio) {
316
+ continue;
317
+ }
318
+
319
+ const score = computeRegionSpatialCorrelation({
320
+ imageData: candidate,
321
+ alphaMap,
322
+ region: {
323
+ x: position.x,
324
+ y: position.y,
325
+ size: position.width
326
+ }
327
+ });
328
+
329
+ if (score < bestScore) {
330
+ bestScore = score;
331
+ bestGain = alphaGain;
332
+ bestImageData = candidate;
333
+ }
334
+ }
335
+
336
+ const scoreDelta = processedSpatialScore - bestScore;
337
+ if (!bestImageData || scoreDelta < MIN_RECALIBRATION_SCORE_DELTA) {
338
+ return null;
339
+ }
340
+
341
+ return {
342
+ imageData: bestImageData,
343
+ alphaGain: bestGain,
344
+ processedSpatialScore: bestScore,
345
+ suppressionGain: originalSpatialScore - bestScore
346
+ };
347
+ }
348
+
349
+ function shouldRefinePreviewResidualEdge({
350
+ source,
351
+ position,
352
+ baselineSpatialScore,
353
+ baselineGradientScore,
354
+ baselinePositiveHalo
355
+ }) {
356
+ return typeof source === 'string' &&
357
+ source.includes('preview-anchor') &&
358
+ position?.width >= 24 &&
359
+ position?.width <= PREVIEW_EDGE_CLEANUP_MAX_SIZE &&
360
+ (
361
+ Math.abs(baselineSpatialScore) <= PREVIEW_EDGE_CLEANUP_SPATIAL_THRESHOLD ||
362
+ (
363
+ baselinePositiveHalo >= PREVIEW_EDGE_CLEANUP_STRONG_HALO_THRESHOLD &&
364
+ Math.abs(baselineSpatialScore) <= PREVIEW_EDGE_CLEANUP_HALO_SPATIAL_THRESHOLD
365
+ )
366
+ ) &&
367
+ baselineGradientScore >= PREVIEW_EDGE_CLEANUP_GRADIENT_THRESHOLD;
368
+ }
369
+
370
+ function shouldUsePreviewAnchorFastCleanup(selectedTrial, position) {
371
+ return selectedTrial?.provenance?.previewAnchor === true &&
372
+ position?.width >= 24 &&
373
+ position?.width <= PREVIEW_EDGE_CLEANUP_MAX_SIZE;
374
+ }
375
+
376
+ function blendPreviewResidualEdge({
377
+ sourceImageData,
378
+ alphaMap,
379
+ position,
380
+ minAlpha,
381
+ maxAlpha,
382
+ radius,
383
+ strength,
384
+ outsideAlphaMax
385
+ }) {
386
+ const candidate = cloneImageData(sourceImageData);
387
+ const { width: imageWidth, height: imageHeight, data } = sourceImageData;
388
+ const regionSize = position.width;
389
+ const maxAlphaSafe = Math.max(maxAlpha, 1e-6);
390
+
391
+ for (let row = 0; row < regionSize; row++) {
392
+ for (let col = 0; col < regionSize; col++) {
393
+ const alpha = alphaMap[row * regionSize + col];
394
+ if (alpha < minAlpha || alpha > maxAlpha) continue;
395
+
396
+ let sumR = 0;
397
+ let sumG = 0;
398
+ let sumB = 0;
399
+ let sumWeight = 0;
400
+
401
+ for (let dy = -radius; dy <= radius; dy++) {
402
+ for (let dx = -radius; dx <= radius; dx++) {
403
+ if (dx === 0 && dy === 0) continue;
404
+
405
+ const localY = row + dy;
406
+ const localX = col + dx;
407
+ const pixelX = position.x + localX;
408
+ const pixelY = position.y + localY;
409
+
410
+ if (pixelX < 0 || pixelY < 0 || pixelX >= imageWidth || pixelY >= imageHeight) {
411
+ continue;
412
+ }
413
+
414
+ let neighborAlpha = 0;
415
+ if (localY >= 0 && localX >= 0 && localY < regionSize && localX < regionSize) {
416
+ neighborAlpha = alphaMap[localY * regionSize + localX];
417
+ }
418
+ if (neighborAlpha > outsideAlphaMax) continue;
419
+
420
+ const distance = Math.sqrt(dx * dx + dy * dy) || 1;
421
+ const weight = 1 / distance;
422
+ const pixelIndex = (pixelY * imageWidth + pixelX) * 4;
423
+ sumR += data[pixelIndex] * weight;
424
+ sumG += data[pixelIndex + 1] * weight;
425
+ sumB += data[pixelIndex + 2] * weight;
426
+ sumWeight += weight;
427
+ }
428
+ }
429
+
430
+ if (sumWeight <= 0) continue;
431
+
432
+ const blend = Math.max(0, Math.min(1, strength * alpha / maxAlphaSafe));
433
+ const pixelIndex = ((position.y + row) * imageWidth + (position.x + col)) * 4;
434
+ candidate.data[pixelIndex] = Math.round(data[pixelIndex] * (1 - blend) + (sumR / sumWeight) * blend);
435
+ candidate.data[pixelIndex + 1] = Math.round(data[pixelIndex + 1] * (1 - blend) + (sumG / sumWeight) * blend);
436
+ candidate.data[pixelIndex + 2] = Math.round(data[pixelIndex + 2] * (1 - blend) + (sumB / sumWeight) * blend);
437
+ }
438
+ }
439
+
440
+ return candidate;
441
+ }
442
+
443
+ function refinePreviewResidualEdge({
444
+ sourceImageData,
445
+ alphaMap,
446
+ position,
447
+ source,
448
+ baselineSpatialScore,
449
+ baselineGradientScore,
450
+ minGradientImprovement = PREVIEW_EDGE_CLEANUP_MIN_GRADIENT_IMPROVEMENT,
451
+ maxSpatialDrift = PREVIEW_EDGE_CLEANUP_MAX_SPATIAL_DRIFT,
452
+ allowAggressivePresets = false
453
+ }) {
454
+ const baselineHalo = assessAlphaBandHalo({
455
+ imageData: sourceImageData,
456
+ position,
457
+ alphaMap
458
+ });
459
+ const baselinePositiveHalo = baselineHalo.positiveDeltaLum;
460
+ if (!shouldRefinePreviewResidualEdge({
461
+ source,
462
+ position,
463
+ baselineSpatialScore,
464
+ baselineGradientScore,
465
+ baselinePositiveHalo
466
+ })) {
467
+ return null;
468
+ }
469
+
470
+ const baselineNearBlackRatio = calculateNearBlackRatio(sourceImageData, position);
471
+ const maxAllowedNearBlackRatio = Math.min(1, baselineNearBlackRatio + MAX_NEAR_BLACK_RATIO_INCREASE);
472
+ const resolvedMinGradientImprovement = baselineGradientScore <= PREVIEW_EDGE_CLEANUP_FINE_GRADIENT_THRESHOLD
473
+ ? PREVIEW_EDGE_CLEANUP_FINE_MIN_GRADIENT_IMPROVEMENT
474
+ : (
475
+ baselinePositiveHalo >= PREVIEW_EDGE_CLEANUP_STRONG_HALO_THRESHOLD
476
+ ? PREVIEW_EDGE_CLEANUP_HALO_RELAXED_MIN_GRADIENT_IMPROVEMENT
477
+ : minGradientImprovement
478
+ );
479
+ const presets = allowAggressivePresets &&
480
+ baselineGradientScore >= PREVIEW_EDGE_CLEANUP_STRONG_GRADIENT_THRESHOLD &&
481
+ Math.abs(baselineSpatialScore) <= 0.05
482
+ ? [...PREVIEW_EDGE_CLEANUP_PRESETS, ...PREVIEW_EDGE_CLEANUP_AGGRESSIVE_PRESETS]
483
+ : PREVIEW_EDGE_CLEANUP_PRESETS;
484
+ let best = null;
485
+
486
+ for (const preset of presets) {
487
+ const candidate = blendPreviewResidualEdge({
488
+ sourceImageData,
489
+ alphaMap,
490
+ position,
491
+ ...preset
492
+ });
493
+ const nearBlackRatio = calculateNearBlackRatio(candidate, position);
494
+ if (nearBlackRatio > maxAllowedNearBlackRatio) continue;
495
+
496
+ const spatialScore = computeRegionSpatialCorrelation({
497
+ imageData: candidate,
498
+ alphaMap,
499
+ region: { x: position.x, y: position.y, size: position.width }
500
+ });
501
+ const gradientScore = computeRegionGradientCorrelation({
502
+ imageData: candidate,
503
+ alphaMap,
504
+ region: { x: position.x, y: position.y, size: position.width }
505
+ });
506
+ const halo = assessAlphaBandHalo({
507
+ imageData: candidate,
508
+ position,
509
+ alphaMap
510
+ });
511
+
512
+ const presetMinGradientImprovement = preset.minGradientImprovement ?? resolvedMinGradientImprovement;
513
+ const presetMaxSpatialDrift = preset.maxSpatialDrift ?? maxSpatialDrift;
514
+ const presetMaxAcceptedSpatial = preset.maxAcceptedSpatial ?? 0.22;
515
+ const improvedGradient = gradientScore <= baselineGradientScore - presetMinGradientImprovement;
516
+ const keptSpatial = Math.abs(spatialScore) <= Math.abs(baselineSpatialScore) + presetMaxSpatialDrift;
517
+ const keptResidualWithinTarget = Math.abs(spatialScore) <= presetMaxAcceptedSpatial;
518
+ const candidatePositiveHalo = halo.positiveDeltaLum;
519
+ const improvedHalo = baselinePositiveHalo < PREVIEW_EDGE_CLEANUP_STRONG_HALO_THRESHOLD ||
520
+ candidatePositiveHalo <= baselinePositiveHalo - PREVIEW_EDGE_CLEANUP_MIN_HALO_REDUCTION;
521
+ if (!improvedGradient || !keptSpatial || !keptResidualWithinTarget || !improvedHalo) continue;
522
+
523
+ const cost = Math.abs(spatialScore) * 0.6 +
524
+ Math.max(0, gradientScore) +
525
+ candidatePositiveHalo * PREVIEW_EDGE_CLEANUP_HALO_WEIGHT;
526
+ if (!best || cost < best.cost) {
527
+ best = {
528
+ imageData: candidate,
529
+ spatialScore,
530
+ gradientScore,
531
+ halo,
532
+ cost
533
+ };
534
+ }
535
+ }
536
+
537
+ return best;
538
+ }
539
+
540
+ export function processWatermarkImageData(imageData, options = {}) {
541
+ const totalStartedAt = nowMs();
542
+ const debugTimingsEnabled = options.debugTimings === true;
543
+ const debugTimings = debugTimingsEnabled ? {} : null;
544
+ const adaptiveMode = options.adaptiveMode || 'auto';
545
+ const allowAdaptiveSearch =
546
+ adaptiveMode !== 'never' &&
547
+ adaptiveMode !== 'off';
548
+ const originalImageData = cloneImageData(imageData);
549
+ const { alpha48, alpha96 } = options;
550
+ const alphaGainCandidates = ALPHA_GAIN_CANDIDATES;
551
+
552
+ if (!alpha48 || !alpha96) {
553
+ throw new Error('processWatermarkImageData requires alpha48 and alpha96');
554
+ }
555
+
556
+ const defaultConfig = detectWatermarkConfig(originalImageData.width, originalImageData.height);
557
+ const resolvedConfig = resolveInitialStandardConfig({
558
+ imageData: originalImageData,
559
+ defaultConfig,
560
+ alpha48,
561
+ alpha96
562
+ });
563
+
564
+ let config = resolvedConfig;
565
+ let position = calculateWatermarkPosition(originalImageData.width, originalImageData.height, config);
566
+ let alphaMap = config.logoSize === 96 ? alpha96 : alpha48;
567
+ let source = 'standard';
568
+ let adaptiveConfidence = null;
569
+ let alphaGain = 1;
570
+ let subpixelShift = null;
571
+ let templateWarp = null;
572
+ let decisionTier = null;
573
+ let passCount = 0;
574
+ let attemptedPassCount = 0;
575
+ let passStopReason = null;
576
+ let passes = null;
577
+
578
+ const initialSelectionStartedAt = nowMs();
579
+ const initialSelection = selectInitialCandidate({
580
+ originalImageData,
581
+ config,
582
+ position,
583
+ alpha48,
584
+ alpha96,
585
+ getAlphaMap: options.getAlphaMap,
586
+ allowAdaptiveSearch,
587
+ alphaGainCandidates
588
+ });
589
+ if (debugTimingsEnabled) {
590
+ debugTimings.initialSelectionMs = nowMs() - initialSelectionStartedAt;
591
+ }
592
+
593
+ if (!initialSelection.selectedTrial) {
594
+ if (debugTimingsEnabled) {
595
+ debugTimings.totalMs = nowMs() - totalStartedAt;
596
+ }
597
+ return {
598
+ imageData: originalImageData,
599
+ meta: createWatermarkMeta({
600
+ adaptiveConfidence: initialSelection.adaptiveConfidence,
601
+ originalSpatialScore: initialSelection.standardSpatialScore,
602
+ originalGradientScore: initialSelection.standardGradientScore,
603
+ processedSpatialScore: initialSelection.standardSpatialScore,
604
+ processedGradientScore: initialSelection.standardGradientScore,
605
+ suppressionGain: 0,
606
+ alphaGain: 1,
607
+ source: 'skipped',
608
+ decisionTier: initialSelection.decisionTier ?? 'insufficient',
609
+ applied: false,
610
+ skipReason: 'no-watermark-detected',
611
+ selectionDebug: null
612
+ }),
613
+ debugTimings
614
+ };
615
+ }
616
+
617
+ position = initialSelection.position;
618
+ alphaMap = initialSelection.alphaMap;
619
+ config = initialSelection.config;
620
+ source = initialSelection.source;
621
+ adaptiveConfidence = initialSelection.adaptiveConfidence;
622
+ templateWarp = initialSelection.templateWarp;
623
+ alphaGain = initialSelection.alphaGain;
624
+ decisionTier = initialSelection.decisionTier;
625
+
626
+ const selectedTrial = initialSelection.selectedTrial;
627
+ const usePreviewAnchorFastCleanup = shouldUsePreviewAnchorFastCleanup(selectedTrial, position);
628
+ const skipPreviewAnchorMultiPass = selectedTrial?.provenance?.previewAnchor === true;
629
+
630
+ let finalImageData = selectedTrial.imageData;
631
+
632
+ let originalSpatialScore = selectedTrial.originalSpatialScore;
633
+ let originalGradientScore = selectedTrial.originalGradientScore;
634
+
635
+ const firstPassMetricsStartedAt = nowMs();
636
+ const firstPassSpatialScore = computeRegionSpatialCorrelation({
637
+ imageData: finalImageData,
638
+ alphaMap,
639
+ region: { x: position.x, y: position.y, size: position.width }
640
+ });
641
+ const firstPassGradientScore = computeRegionGradientCorrelation({
642
+ imageData: finalImageData,
643
+ alphaMap,
644
+ region: { x: position.x, y: position.y, size: position.width }
645
+ });
646
+ const firstPassNearBlackRatio = calculateNearBlackRatio(finalImageData, position);
647
+ const firstPassRecord = {
648
+ index: 1,
649
+ beforeSpatialScore: originalSpatialScore,
650
+ beforeGradientScore: originalGradientScore,
651
+ afterSpatialScore: firstPassSpatialScore,
652
+ afterGradientScore: firstPassGradientScore,
653
+ improvement: Math.abs(originalSpatialScore) - Math.abs(firstPassSpatialScore),
654
+ gradientDelta: firstPassGradientScore - originalGradientScore,
655
+ nearBlackRatio: firstPassNearBlackRatio
656
+ };
657
+ if (debugTimingsEnabled) {
658
+ debugTimings.firstPassMetricsMs = nowMs() - firstPassMetricsStartedAt;
659
+ }
660
+
661
+ const totalMaxPasses = Math.max(
662
+ 1,
663
+ options.maxPasses ?? 4
664
+ );
665
+ const remainingPasses = Math.max(0, totalMaxPasses - 1);
666
+ const firstPassClearedResidual = shouldStopAfterFirstPass({
667
+ originalSpatialScore,
668
+ originalGradientScore,
669
+ firstPassSpatialScore,
670
+ firstPassGradientScore
671
+ });
672
+ const extraPassStartedAt = nowMs();
673
+ const extraPassResult = remainingPasses > 0 &&
674
+ !firstPassClearedResidual &&
675
+ !skipPreviewAnchorMultiPass
676
+ ? removeRepeatedWatermarkLayers({
677
+ imageData: finalImageData,
678
+ alphaMap,
679
+ position,
680
+ maxPasses: remainingPasses,
681
+ startingPassIndex: 1,
682
+ alphaGain
683
+ })
684
+ : null;
685
+ if (debugTimingsEnabled) {
686
+ debugTimings.extraPassMs = nowMs() - extraPassStartedAt;
687
+ }
688
+ finalImageData = extraPassResult?.imageData ?? finalImageData;
689
+ passCount = extraPassResult?.passCount ?? 1;
690
+ attemptedPassCount = extraPassResult?.attemptedPassCount ?? 1;
691
+ passStopReason = extraPassResult?.stopReason ?? (
692
+ firstPassClearedResidual
693
+ ? 'residual-low'
694
+ : (skipPreviewAnchorMultiPass ? 'preview-anchor-single-pass' : 'max-passes')
695
+ );
696
+ passes = [firstPassRecord, ...(extraPassResult?.passes ?? [])];
697
+ if (passCount > 1) {
698
+ source = `${source}+multipass`;
699
+ }
700
+
701
+ const finalMetricsStartedAt = nowMs();
702
+ const processedSpatialScore = computeRegionSpatialCorrelation({
703
+ imageData: finalImageData,
704
+ alphaMap,
705
+ region: {
706
+ x: position.x,
707
+ y: position.y,
708
+ size: position.width
709
+ }
710
+ });
711
+ const processedGradientScore = computeRegionGradientCorrelation({
712
+ imageData: finalImageData,
713
+ alphaMap,
714
+ region: {
715
+ x: position.x,
716
+ y: position.y,
717
+ size: position.width
718
+ }
719
+ });
720
+ if (debugTimingsEnabled) {
721
+ debugTimings.finalMetricsMs = nowMs() - finalMetricsStartedAt;
722
+ }
723
+ let finalProcessedSpatialScore = processedSpatialScore;
724
+ let finalProcessedGradientScore = processedGradientScore;
725
+ let suppressionGain = originalSpatialScore - finalProcessedSpatialScore;
726
+
727
+ const recalibrationStartedAt = nowMs();
728
+ if (shouldRecalibrateAlphaStrength({
729
+ originalScore: originalSpatialScore,
730
+ processedScore: finalProcessedSpatialScore,
731
+ suppressionGain
732
+ })) {
733
+ const originalNearBlackRatio = calculateNearBlackRatio(finalImageData, position);
734
+ const recalibrated = recalibrateAlphaStrength({
735
+ sourceImageData: finalImageData,
736
+ alphaMap,
737
+ position,
738
+ originalSpatialScore,
739
+ processedSpatialScore: finalProcessedSpatialScore,
740
+ originalNearBlackRatio
741
+ });
742
+
743
+ if (recalibrated) {
744
+ finalImageData = recalibrated.imageData;
745
+ alphaGain = recalibrated.alphaGain;
746
+ finalProcessedSpatialScore = recalibrated.processedSpatialScore;
747
+ finalProcessedGradientScore = computeRegionGradientCorrelation({
748
+ imageData: finalImageData,
749
+ alphaMap,
750
+ region: {
751
+ x: position.x,
752
+ y: position.y,
753
+ size: position.width
754
+ }
755
+ });
756
+ suppressionGain = recalibrated.suppressionGain;
757
+ source = source === 'adaptive' ? 'adaptive+gain' : `${source}+gain`;
758
+ }
759
+ }
760
+ if (debugTimingsEnabled) {
761
+ debugTimings.recalibrationMs = nowMs() - recalibrationStartedAt;
762
+ }
763
+
764
+ let previewEdgeCleanupElapsedMs = 0;
765
+ const applyPreviewEdgeCleanup = () => {
766
+ const previewEdgeStartedAt = nowMs();
767
+ const previewEdgeRefined = refinePreviewResidualEdge({
768
+ sourceImageData: finalImageData,
769
+ alphaMap,
770
+ position,
771
+ source,
772
+ baselineSpatialScore: finalProcessedSpatialScore,
773
+ baselineGradientScore: finalProcessedGradientScore,
774
+ allowAggressivePresets: usePreviewAnchorFastCleanup
775
+ });
776
+ previewEdgeCleanupElapsedMs += nowMs() - previewEdgeStartedAt;
777
+
778
+ if (!previewEdgeRefined) {
779
+ return false;
780
+ }
781
+
782
+ finalImageData = previewEdgeRefined.imageData;
783
+ finalProcessedSpatialScore = previewEdgeRefined.spatialScore;
784
+ finalProcessedGradientScore = previewEdgeRefined.gradientScore;
785
+ suppressionGain = originalSpatialScore - finalProcessedSpatialScore;
786
+ source = `${source}+edge-cleanup`;
787
+ return true;
788
+ };
789
+
790
+ const subpixelStartedAt = nowMs();
791
+ if (
792
+ !usePreviewAnchorFastCleanup &&
793
+ finalProcessedSpatialScore <= 0.3 &&
794
+ finalProcessedGradientScore >= OUTLINE_REFINEMENT_THRESHOLD
795
+ ) {
796
+ const originalNearBlackRatio = calculateNearBlackRatio(finalImageData, position);
797
+ const baselineShift = templateWarp ?? { dx: 0, dy: 0, scale: 1 };
798
+ const refined = refineSubpixelOutline({
799
+ sourceImageData: finalImageData,
800
+ alphaMap,
801
+ position,
802
+ alphaGain,
803
+ originalNearBlackRatio,
804
+ baselineSpatialScore: finalProcessedSpatialScore,
805
+ baselineGradientScore: finalProcessedGradientScore,
806
+ baselineShift,
807
+ minGain: OUTLINE_REFINEMENT_MIN_GAIN,
808
+ shiftCandidates: SUBPIXEL_REFINE_SHIFTS,
809
+ scaleCandidates: SUBPIXEL_REFINE_SCALES,
810
+ minGradientImprovement: 0.04,
811
+ maxSpatialDrift: 0.08
812
+ });
813
+
814
+ if (refined) {
815
+ finalImageData = refined.imageData;
816
+ alphaMap = refined.alphaMap;
817
+ alphaGain = refined.alphaGain;
818
+ finalProcessedSpatialScore = refined.spatialScore;
819
+ finalProcessedGradientScore = refined.gradientScore;
820
+ suppressionGain = originalSpatialScore - finalProcessedSpatialScore;
821
+ source = `${source}+subpixel`;
822
+ subpixelShift = refined.shift;
823
+ }
824
+ }
825
+ if (debugTimingsEnabled) {
826
+ debugTimings.subpixelRefinementMs = nowMs() - subpixelStartedAt;
827
+ }
828
+
829
+ let previewEdgeCleanupPassCount = 0;
830
+ while (previewEdgeCleanupPassCount < PREVIEW_EDGE_CLEANUP_MAX_APPLIED_PASSES) {
831
+ if (!applyPreviewEdgeCleanup()) {
832
+ break;
833
+ }
834
+ previewEdgeCleanupPassCount++;
835
+ }
836
+ if (debugTimingsEnabled) {
837
+ debugTimings.previewEdgeCleanupMs = previewEdgeCleanupElapsedMs;
838
+ debugTimings.totalMs = nowMs() - totalStartedAt;
839
+ }
840
+
841
+ return {
842
+ imageData: finalImageData,
843
+ meta: createWatermarkMeta({
844
+ position,
845
+ config,
846
+ adaptiveConfidence,
847
+ originalSpatialScore,
848
+ originalGradientScore,
849
+ processedSpatialScore: finalProcessedSpatialScore,
850
+ processedGradientScore: finalProcessedGradientScore,
851
+ suppressionGain,
852
+ templateWarp,
853
+ alphaGain,
854
+ passCount,
855
+ attemptedPassCount,
856
+ passStopReason,
857
+ passes,
858
+ source,
859
+ decisionTier,
860
+ applied: true,
861
+ subpixelShift,
862
+ selectionDebug: createSelectionDebugSummary({
863
+ selectedTrial,
864
+ selectionSource: initialSelection.source,
865
+ initialConfig: resolvedConfig,
866
+ initialPosition: calculateWatermarkPosition(
867
+ originalImageData.width,
868
+ originalImageData.height,
869
+ resolvedConfig
870
+ )
871
+ })
872
+ }),
873
+ debugTimings
874
+ };
875
+ }