@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,251 @@
1
+ import {
2
+ computeRegionGradientCorrelation,
3
+ computeRegionSpatialCorrelation
4
+ } from './adaptiveDetector.js';
5
+
6
+ const NEAR_BLACK_THRESHOLD = 5;
7
+ const TEXTURE_REFERENCE_MARGIN = 1;
8
+ const TEXTURE_STD_FLOOR_RATIO = 0.8;
9
+ const TEXTURE_DARKNESS_VISIBILITY_HARD_REJECT_THRESHOLD = 1.5;
10
+ const TEXTURE_DARKNESS_HARD_REJECT_PENALTY_THRESHOLD = 0.5;
11
+ const TEXTURE_FLATNESS_HARD_REJECT_PENALTY_THRESHOLD = 0.2;
12
+ const DEFAULT_HALO_MIN_ALPHA = 0.12;
13
+ const DEFAULT_HALO_MAX_ALPHA = 0.35;
14
+ const DEFAULT_HALO_OUTSIDE_ALPHA_MAX = 0.01;
15
+ const DEFAULT_HALO_OUTER_MARGIN = 3;
16
+
17
+ export function cloneImageData(imageData) {
18
+ if (typeof ImageData !== 'undefined' && imageData instanceof ImageData) {
19
+ return new ImageData(
20
+ new Uint8ClampedArray(imageData.data),
21
+ imageData.width,
22
+ imageData.height
23
+ );
24
+ }
25
+
26
+ return {
27
+ width: imageData.width,
28
+ height: imageData.height,
29
+ data: new Uint8ClampedArray(imageData.data)
30
+ };
31
+ }
32
+
33
+ export function calculateNearBlackRatio(imageData, position) {
34
+ let nearBlack = 0;
35
+ let total = 0;
36
+ for (let row = 0; row < position.height; row++) {
37
+ for (let col = 0; col < position.width; col++) {
38
+ const idx = ((position.y + row) * imageData.width + (position.x + col)) * 4;
39
+ const r = imageData.data[idx];
40
+ const g = imageData.data[idx + 1];
41
+ const b = imageData.data[idx + 2];
42
+ if (r <= NEAR_BLACK_THRESHOLD && g <= NEAR_BLACK_THRESHOLD && b <= NEAR_BLACK_THRESHOLD) {
43
+ nearBlack++;
44
+ }
45
+ total++;
46
+ }
47
+ }
48
+
49
+ return total > 0 ? nearBlack / total : 0;
50
+ }
51
+
52
+ function calculateRegionTextureStats(imageData, region) {
53
+ let sum = 0;
54
+ let sq = 0;
55
+ let total = 0;
56
+
57
+ for (let row = 0; row < region.height; row++) {
58
+ for (let col = 0; col < region.width; col++) {
59
+ const idx = ((region.y + row) * imageData.width + (region.x + col)) * 4;
60
+ const lum =
61
+ 0.2126 * imageData.data[idx] +
62
+ 0.7152 * imageData.data[idx + 1] +
63
+ 0.0722 * imageData.data[idx + 2];
64
+ sum += lum;
65
+ sq += lum * lum;
66
+ total++;
67
+ }
68
+ }
69
+
70
+ const meanLum = total > 0 ? sum / total : 0;
71
+ const variance = total > 0 ? Math.max(0, sq / total - meanLum * meanLum) : 0;
72
+
73
+ return {
74
+ meanLum,
75
+ stdLum: Math.sqrt(variance)
76
+ };
77
+ }
78
+
79
+ export function getRegionTextureStats(imageData, region) {
80
+ return calculateRegionTextureStats(imageData, region);
81
+ }
82
+
83
+ export function assessAlphaBandHalo({
84
+ imageData,
85
+ position,
86
+ alphaMap,
87
+ minAlpha = DEFAULT_HALO_MIN_ALPHA,
88
+ maxAlpha = DEFAULT_HALO_MAX_ALPHA,
89
+ outsideAlphaMax = DEFAULT_HALO_OUTSIDE_ALPHA_MAX,
90
+ outerMargin = DEFAULT_HALO_OUTER_MARGIN
91
+ }) {
92
+ let bandSum = 0;
93
+ let bandSq = 0;
94
+ let bandCount = 0;
95
+ let outerSum = 0;
96
+ let outerSq = 0;
97
+ let outerCount = 0;
98
+
99
+ for (let row = -outerMargin; row < position.height + outerMargin; row++) {
100
+ for (let col = -outerMargin; col < position.width + outerMargin; col++) {
101
+ const pixelX = position.x + col;
102
+ const pixelY = position.y + row;
103
+ if (pixelX < 0 || pixelY < 0 || pixelX >= imageData.width || pixelY >= imageData.height) {
104
+ continue;
105
+ }
106
+
107
+ const pixelIndex = (pixelY * imageData.width + pixelX) * 4;
108
+ const luminance =
109
+ 0.2126 * imageData.data[pixelIndex] +
110
+ 0.7152 * imageData.data[pixelIndex + 1] +
111
+ 0.0722 * imageData.data[pixelIndex + 2];
112
+ const insideRegion = row >= 0 && col >= 0 && row < position.height && col < position.width;
113
+ const alpha = insideRegion
114
+ ? alphaMap[row * position.width + col]
115
+ : 0;
116
+
117
+ if (insideRegion && alpha >= minAlpha && alpha <= maxAlpha) {
118
+ bandSum += luminance;
119
+ bandSq += luminance * luminance;
120
+ bandCount++;
121
+ continue;
122
+ }
123
+
124
+ if (!insideRegion || alpha <= outsideAlphaMax) {
125
+ outerSum += luminance;
126
+ outerSq += luminance * luminance;
127
+ outerCount++;
128
+ }
129
+ }
130
+ }
131
+
132
+ const bandMeanLum = bandCount > 0 ? bandSum / bandCount : 0;
133
+ const outerMeanLum = outerCount > 0 ? outerSum / outerCount : 0;
134
+ const bandStdLum = bandCount > 0 ? Math.sqrt(Math.max(0, bandSq / bandCount - bandMeanLum * bandMeanLum)) : 0;
135
+ const outerStdLum = outerCount > 0 ? Math.sqrt(Math.max(0, outerSq / outerCount - outerMeanLum * outerMeanLum)) : 0;
136
+ const deltaLum = bandMeanLum - outerMeanLum;
137
+ const visibility = deltaLum / Math.max(1, outerStdLum);
138
+
139
+ return {
140
+ bandCount,
141
+ outerCount,
142
+ bandMeanLum,
143
+ outerMeanLum,
144
+ bandStdLum,
145
+ outerStdLum,
146
+ deltaLum,
147
+ positiveDeltaLum: Math.max(0, deltaLum),
148
+ visibility
149
+ };
150
+ }
151
+
152
+ function getReferenceRegion(position, imageData) {
153
+ const referenceY = position.y - position.height;
154
+ if (referenceY < 0) return null;
155
+
156
+ return {
157
+ x: position.x,
158
+ y: referenceY,
159
+ width: position.width,
160
+ height: position.height
161
+ };
162
+ }
163
+
164
+ export function assessReferenceTextureAlignment({
165
+ originalImageData,
166
+ referenceImageData,
167
+ candidateImageData,
168
+ position
169
+ }) {
170
+ const candidateTextureStats = candidateImageData
171
+ ? calculateRegionTextureStats(candidateImageData, position)
172
+ : null;
173
+
174
+ return assessReferenceTextureAlignmentFromStats({
175
+ originalImageData,
176
+ referenceImageData,
177
+ candidateTextureStats,
178
+ position
179
+ });
180
+ }
181
+
182
+ export function assessReferenceTextureAlignmentFromStats({
183
+ originalImageData,
184
+ referenceImageData,
185
+ candidateTextureStats,
186
+ position
187
+ }) {
188
+ const resolvedReferenceImageData = referenceImageData ?? originalImageData;
189
+ const referenceRegion = resolvedReferenceImageData
190
+ ? getReferenceRegion(position, resolvedReferenceImageData)
191
+ : null;
192
+ const referenceTextureStats = referenceRegion
193
+ ? calculateRegionTextureStats(resolvedReferenceImageData, referenceRegion)
194
+ : null;
195
+ const darknessPenalty = referenceTextureStats && candidateTextureStats
196
+ ? Math.max(0, referenceTextureStats.meanLum - candidateTextureStats.meanLum - TEXTURE_REFERENCE_MARGIN) /
197
+ Math.max(1, referenceTextureStats.meanLum)
198
+ : 0;
199
+ const flatnessPenalty = referenceTextureStats && candidateTextureStats
200
+ ? Math.max(0, referenceTextureStats.stdLum * TEXTURE_STD_FLOOR_RATIO - candidateTextureStats.stdLum) /
201
+ Math.max(1, referenceTextureStats.stdLum)
202
+ : 0;
203
+ const darknessVisibility = referenceTextureStats && candidateTextureStats
204
+ ? Math.max(0, referenceTextureStats.meanLum - candidateTextureStats.meanLum - TEXTURE_REFERENCE_MARGIN) /
205
+ Math.max(1, referenceTextureStats.stdLum)
206
+ : 0;
207
+ const tooDark = darknessPenalty > 0;
208
+ const tooFlat = flatnessPenalty > 0;
209
+ const visibleDarkHole = tooDark && darknessVisibility >= TEXTURE_DARKNESS_VISIBILITY_HARD_REJECT_THRESHOLD;
210
+ const strongDarkFlatCollapse =
211
+ tooDark &&
212
+ tooFlat &&
213
+ darknessPenalty >= TEXTURE_DARKNESS_HARD_REJECT_PENALTY_THRESHOLD &&
214
+ flatnessPenalty >= TEXTURE_FLATNESS_HARD_REJECT_PENALTY_THRESHOLD;
215
+
216
+ return {
217
+ referenceTextureStats,
218
+ candidateTextureStats,
219
+ darknessPenalty,
220
+ flatnessPenalty,
221
+ darknessVisibility,
222
+ texturePenalty: darknessPenalty * 2 + flatnessPenalty * 2,
223
+ tooDark,
224
+ tooFlat,
225
+ visibleDarkHole,
226
+ hardReject: strongDarkFlatCollapse || visibleDarkHole
227
+ };
228
+ }
229
+
230
+ export function scoreRegion(imageData, alphaMap, position) {
231
+ return {
232
+ spatialScore: computeRegionSpatialCorrelation({
233
+ imageData,
234
+ alphaMap,
235
+ region: {
236
+ x: position.x,
237
+ y: position.y,
238
+ size: position.width
239
+ }
240
+ }),
241
+ gradientScore: computeRegionGradientCorrelation({
242
+ imageData,
243
+ alphaMap,
244
+ region: {
245
+ x: position.x,
246
+ y: position.y,
247
+ size: position.width
248
+ }
249
+ })
250
+ };
251
+ }
@@ -0,0 +1,47 @@
1
+ function normalizeConfig(config) {
2
+ if (!config || typeof config !== 'object') return null;
3
+ const { logoSize, marginRight, marginBottom } = config;
4
+ if (![logoSize, marginRight, marginBottom].every(Number.isFinite)) {
5
+ return null;
6
+ }
7
+ return { logoSize, marginRight, marginBottom };
8
+ }
9
+
10
+ function normalizePosition(position) {
11
+ if (!position || typeof position !== 'object') return null;
12
+ const { x, y, width, height } = position;
13
+ if (![x, y, width, height].every(Number.isFinite)) {
14
+ return null;
15
+ }
16
+ return { x, y, width, height };
17
+ }
18
+
19
+ export function createSelectionDebugSummary({
20
+ selectedTrial,
21
+ selectionSource = null,
22
+ initialConfig = null,
23
+ initialPosition = null
24
+ } = {}) {
25
+ if (!selectedTrial) return null;
26
+
27
+ const candidateSource = typeof selectionSource === 'string' && selectionSource
28
+ ? selectionSource
29
+ : (typeof selectedTrial.source === 'string' ? selectedTrial.source : null);
30
+
31
+ return {
32
+ candidateSource,
33
+ initialConfig: normalizeConfig(initialConfig),
34
+ initialPosition: normalizePosition(initialPosition),
35
+ finalConfig: normalizeConfig(selectedTrial.config),
36
+ finalPosition: normalizePosition(selectedTrial.position),
37
+ texturePenalty: Number.isFinite(selectedTrial.texturePenalty) ? selectedTrial.texturePenalty : null,
38
+ tooDark: selectedTrial.tooDark === true,
39
+ tooFlat: selectedTrial.tooFlat === true,
40
+ hardReject: selectedTrial.hardReject === true,
41
+ usedCatalogVariant: selectedTrial.provenance?.catalogVariant === true,
42
+ usedSizeJitter: selectedTrial.provenance?.sizeJitter === true,
43
+ usedLocalShift: selectedTrial.provenance?.localShift === true,
44
+ usedAdaptive: selectedTrial.provenance?.adaptive === true,
45
+ usedPreviewAnchor: selectedTrial.provenance?.previewAnchor === true
46
+ };
47
+ }
@@ -0,0 +1,146 @@
1
+ import { computeRegionSpatialCorrelation, interpolateAlphaMap } from './adaptiveDetector.js';
2
+ import {
3
+ resolveOfficialGeminiSearchConfigs,
4
+ resolveOfficialGeminiWatermarkConfig
5
+ } from './geminiSizeCatalog.js';
6
+
7
+ /**
8
+ * Detect watermark configuration based on image size
9
+ * @param {number} imageWidth - Image width
10
+ * @param {number} imageHeight - Image height
11
+ * @returns {Object} Watermark configuration {logoSize, marginRight, marginBottom}
12
+ */
13
+ export function detectWatermarkConfig(imageWidth, imageHeight) {
14
+ const officialConfig = resolveOfficialGeminiWatermarkConfig(imageWidth, imageHeight);
15
+ if (officialConfig) {
16
+ return { ...officialConfig };
17
+ }
18
+
19
+ // Gemini's historical default rules:
20
+ // If both image width and height are greater than 1024, use 96×96 watermark
21
+ // Otherwise, use 48×48 watermark
22
+ if (imageWidth > 1024 && imageHeight > 1024) {
23
+ return {
24
+ logoSize: 96,
25
+ marginRight: 64,
26
+ marginBottom: 64
27
+ };
28
+ }
29
+
30
+ return {
31
+ logoSize: 48,
32
+ marginRight: 32,
33
+ marginBottom: 32
34
+ };
35
+ }
36
+
37
+ /**
38
+ * Calculate watermark position in image based on image size and watermark configuration
39
+ * @param {number} imageWidth - Image width
40
+ * @param {number} imageHeight - Image height
41
+ * @param {Object} config - Watermark configuration {logoSize, marginRight, marginBottom}
42
+ * @returns {Object} Watermark position {x, y, width, height}
43
+ */
44
+ export function calculateWatermarkPosition(imageWidth, imageHeight, config) {
45
+ const { logoSize, marginRight, marginBottom } = config;
46
+
47
+ return {
48
+ x: imageWidth - marginRight - logoSize,
49
+ y: imageHeight - marginBottom - logoSize,
50
+ width: logoSize,
51
+ height: logoSize
52
+ };
53
+ }
54
+
55
+ function getStandardConfig(size) {
56
+ return size === 96
57
+ ? { logoSize: 96, marginRight: 64, marginBottom: 64 }
58
+ : { logoSize: 48, marginRight: 32, marginBottom: 32 };
59
+ }
60
+
61
+ function getAlphaMapForConfig(config, alpha48, alpha96) {
62
+ if (!config) return null;
63
+ if (config.logoSize === 48) return alpha48;
64
+ if (config.logoSize === 96) return alpha96;
65
+ return alpha96 ? interpolateAlphaMap(alpha96, 96, config.logoSize) : null;
66
+ }
67
+
68
+ function isRegionInsideImage(imageData, region) {
69
+ return region.x >= 0 &&
70
+ region.y >= 0 &&
71
+ region.x + region.width <= imageData.width &&
72
+ region.y + region.height <= imageData.height;
73
+ }
74
+
75
+ /**
76
+ * Resolve initial standard config by comparing 48/96 template correlation scores.
77
+ * This helps when fixed size rules mismatch newer Gemini output layouts.
78
+ */
79
+ export function resolveInitialStandardConfig({
80
+ imageData,
81
+ defaultConfig,
82
+ alpha48,
83
+ alpha96,
84
+ minSwitchScore = 0.25,
85
+ minScoreDelta = 0.08
86
+ }) {
87
+ if (!imageData || !defaultConfig || !alpha48 || !alpha96) return defaultConfig;
88
+
89
+ const fallbackConfig = getStandardConfig(48);
90
+ const primaryConfig = defaultConfig.logoSize === 96 ? getStandardConfig(96) : fallbackConfig;
91
+ const alternateConfig = defaultConfig.logoSize === 96 ? fallbackConfig : getStandardConfig(96);
92
+ const candidateConfigs = [primaryConfig, alternateConfig];
93
+
94
+ for (const officialConfig of resolveOfficialGeminiSearchConfigs(imageData.width, imageData.height, {
95
+ limit: 1
96
+ })) {
97
+ if (!candidateConfigs.some((candidate) => (
98
+ candidate.logoSize === officialConfig.logoSize &&
99
+ candidate.marginRight === officialConfig.marginRight &&
100
+ candidate.marginBottom === officialConfig.marginBottom
101
+ ))) {
102
+ candidateConfigs.push(officialConfig);
103
+ }
104
+ }
105
+
106
+ let bestConfig = null;
107
+ let bestScore = Number.NEGATIVE_INFINITY;
108
+
109
+ for (const candidateConfig of candidateConfigs) {
110
+ const candidateRegion = calculateWatermarkPosition(
111
+ imageData.width,
112
+ imageData.height,
113
+ candidateConfig
114
+ );
115
+ if (!isRegionInsideImage(imageData, candidateRegion)) continue;
116
+
117
+ const alphaMap = getAlphaMapForConfig(candidateConfig, alpha48, alpha96);
118
+ if (!alphaMap) continue;
119
+
120
+ const candidateScore = computeRegionSpatialCorrelation({
121
+ imageData,
122
+ alphaMap,
123
+ region: {
124
+ x: candidateRegion.x,
125
+ y: candidateRegion.y,
126
+ size: candidateRegion.width
127
+ }
128
+ });
129
+
130
+ if (!bestConfig) {
131
+ bestConfig = candidateConfig;
132
+ bestScore = candidateScore;
133
+ continue;
134
+ }
135
+
136
+ if (
137
+ candidateScore >= minSwitchScore &&
138
+ candidateScore > bestScore + minScoreDelta
139
+ ) {
140
+ bestConfig = candidateConfig;
141
+ bestScore = candidateScore;
142
+ }
143
+ }
144
+
145
+ return bestConfig ?? defaultConfig;
146
+ }
@@ -0,0 +1,163 @@
1
+ // Keep detector thresholds, processed-meta attribution, and UI classification
2
+ // on the same vocabulary so "removed", "validated", and "Gemini-like" do not
3
+ // diverge across modules.
4
+ const STANDARD_DIRECT_MATCH_MIN_SPATIAL_SCORE = 0.3;
5
+ const STANDARD_DIRECT_MATCH_MIN_GRADIENT_SCORE = 0.12;
6
+ const STANDARD_STRONG_GRADIENT_DIRECT_MATCH_MIN_SPATIAL_SCORE = 0.295;
7
+ const STANDARD_STRONG_GRADIENT_DIRECT_MATCH_MIN_GRADIENT_SCORE = 0.45;
8
+
9
+ const ADAPTIVE_DIRECT_MATCH_MIN_CONFIDENCE = 0.5;
10
+ const ADAPTIVE_DIRECT_MATCH_MIN_SPATIAL_SCORE = 0.45;
11
+ const ADAPTIVE_DIRECT_MATCH_MIN_GRADIENT_SCORE = 0.12;
12
+ const ADAPTIVE_DIRECT_MATCH_MIN_SIZE = 40;
13
+ const ADAPTIVE_DIRECT_MATCH_MAX_SIZE = 192;
14
+
15
+ const ATTRIBUTION_MIN_SIZE = 24;
16
+ const ATTRIBUTION_MAX_SIZE = 192;
17
+ const ATTRIBUTION_MAX_RESIDUAL_SCORE = 0.2;
18
+ const ATTRIBUTION_MIN_SUPPRESSION_GAIN = 0.25;
19
+ const ATTRIBUTION_MIN_SPATIAL_SCORE = 0.22;
20
+ const ATTRIBUTION_MIN_VALIDATED_SPATIAL_SCORE = 0.2;
21
+ const ATTRIBUTION_MIN_VALIDATED_SUPPRESSION_GAIN = 0.3;
22
+ const ATTRIBUTION_MIN_ADAPTIVE_CONFIDENCE = 0.35;
23
+ const ATTRIBUTION_MIN_ADAPTIVE_SUPPRESSION_GAIN = 0.16;
24
+
25
+ function toFiniteNumber(value) {
26
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
27
+ }
28
+
29
+ function isPositionSized(position) {
30
+ const width = toFiniteNumber(position?.width);
31
+ const height = toFiniteNumber(position?.height);
32
+ return width !== null && height !== null;
33
+ }
34
+
35
+ export function classifyStandardWatermarkSignal({ spatialScore, gradientScore }) {
36
+ const spatial = toFiniteNumber(spatialScore);
37
+ const gradient = toFiniteNumber(gradientScore);
38
+
39
+ if (spatial === null || gradient === null) {
40
+ return { tier: 'insufficient' };
41
+ }
42
+
43
+ if (
44
+ (
45
+ spatial >= STANDARD_DIRECT_MATCH_MIN_SPATIAL_SCORE &&
46
+ gradient >= STANDARD_DIRECT_MATCH_MIN_GRADIENT_SCORE
47
+ ) ||
48
+ (
49
+ spatial >= STANDARD_STRONG_GRADIENT_DIRECT_MATCH_MIN_SPATIAL_SCORE &&
50
+ gradient >= STANDARD_STRONG_GRADIENT_DIRECT_MATCH_MIN_GRADIENT_SCORE
51
+ )
52
+ ) {
53
+ return { tier: 'direct-match' };
54
+ }
55
+
56
+ if (spatial > 0 || gradient > 0) {
57
+ return { tier: 'needs-validation' };
58
+ }
59
+
60
+ return { tier: 'insufficient' };
61
+ }
62
+
63
+ export function classifyAdaptiveWatermarkSignal(adaptiveResult) {
64
+ if (!adaptiveResult || adaptiveResult.found !== true) {
65
+ return { tier: 'insufficient' };
66
+ }
67
+
68
+ const confidence = toFiniteNumber(adaptiveResult.confidence);
69
+ const spatial = toFiniteNumber(adaptiveResult.spatialScore);
70
+ const gradient = toFiniteNumber(adaptiveResult.gradientScore);
71
+ const size = toFiniteNumber(adaptiveResult?.region?.size);
72
+
73
+ if (
74
+ confidence === null ||
75
+ spatial === null ||
76
+ gradient === null ||
77
+ size === null
78
+ ) {
79
+ return { tier: 'insufficient' };
80
+ }
81
+
82
+ if (
83
+ confidence >= ADAPTIVE_DIRECT_MATCH_MIN_CONFIDENCE &&
84
+ spatial >= ADAPTIVE_DIRECT_MATCH_MIN_SPATIAL_SCORE &&
85
+ gradient >= ADAPTIVE_DIRECT_MATCH_MIN_GRADIENT_SCORE &&
86
+ size >= ADAPTIVE_DIRECT_MATCH_MIN_SIZE &&
87
+ size <= ADAPTIVE_DIRECT_MATCH_MAX_SIZE
88
+ ) {
89
+ return { tier: 'direct-match' };
90
+ }
91
+
92
+ if (
93
+ size >= ADAPTIVE_DIRECT_MATCH_MIN_SIZE &&
94
+ size <= ADAPTIVE_DIRECT_MATCH_MAX_SIZE &&
95
+ gradient >= ADAPTIVE_DIRECT_MATCH_MIN_GRADIENT_SCORE &&
96
+ (confidence > 0 || spatial > 0)
97
+ ) {
98
+ return { tier: 'needs-validation' };
99
+ }
100
+
101
+ return { tier: 'insufficient' };
102
+ }
103
+
104
+ export function classifyGeminiAttributionFromWatermarkMeta(watermarkMeta) {
105
+ if (!watermarkMeta || typeof watermarkMeta !== 'object') {
106
+ return { tier: 'insufficient' };
107
+ }
108
+ if (watermarkMeta.applied === false) {
109
+ return { tier: 'insufficient' };
110
+ }
111
+
112
+ const size = toFiniteNumber(watermarkMeta.size);
113
+ if (size === null || size < ATTRIBUTION_MIN_SIZE || size > ATTRIBUTION_MAX_SIZE) {
114
+ return { tier: 'insufficient' };
115
+ }
116
+ if (!isPositionSized(watermarkMeta.position)) {
117
+ return { tier: 'insufficient' };
118
+ }
119
+
120
+ const detection = watermarkMeta.detection || {};
121
+ const adaptiveConfidence = toFiniteNumber(detection.adaptiveConfidence);
122
+ const originalSpatialScore = toFiniteNumber(detection.originalSpatialScore);
123
+ const processedSpatialScore = toFiniteNumber(detection.processedSpatialScore);
124
+ const suppressionGain = toFiniteNumber(detection.suppressionGain);
125
+ const source = typeof watermarkMeta.source === 'string' ? watermarkMeta.source : '';
126
+
127
+ // Adaptive/direct evidence is strongest, validated-match requires both a
128
+ // validated source path and measurable suppression, and safe-removal keeps
129
+ // "looks removable" separate from "confident Gemini attribution".
130
+ if (
131
+ adaptiveConfidence !== null &&
132
+ suppressionGain !== null &&
133
+ adaptiveConfidence >= ATTRIBUTION_MIN_ADAPTIVE_CONFIDENCE &&
134
+ suppressionGain >= ATTRIBUTION_MIN_ADAPTIVE_SUPPRESSION_GAIN
135
+ ) {
136
+ return { tier: 'adaptive-match' };
137
+ }
138
+
139
+ if (
140
+ source.includes('validated') &&
141
+ originalSpatialScore !== null &&
142
+ processedSpatialScore !== null &&
143
+ suppressionGain !== null &&
144
+ originalSpatialScore >= ATTRIBUTION_MIN_VALIDATED_SPATIAL_SCORE &&
145
+ processedSpatialScore <= ATTRIBUTION_MAX_RESIDUAL_SCORE &&
146
+ suppressionGain >= ATTRIBUTION_MIN_VALIDATED_SUPPRESSION_GAIN
147
+ ) {
148
+ return { tier: 'validated-match' };
149
+ }
150
+
151
+ if (
152
+ originalSpatialScore !== null &&
153
+ processedSpatialScore !== null &&
154
+ suppressionGain !== null &&
155
+ originalSpatialScore >= ATTRIBUTION_MIN_SPATIAL_SCORE &&
156
+ processedSpatialScore <= ATTRIBUTION_MAX_RESIDUAL_SCORE &&
157
+ suppressionGain >= ATTRIBUTION_MIN_SUPPRESSION_GAIN
158
+ ) {
159
+ return { tier: 'safe-removal' };
160
+ }
161
+
162
+ return { tier: 'insufficient' };
163
+ }
@@ -0,0 +1,60 @@
1
+ function isFiniteNumber(value) {
2
+ return typeof value === 'number' && Number.isFinite(value);
3
+ }
4
+
5
+ function isValidPosition(position) {
6
+ if (!position) return false;
7
+ return isFiniteNumber(position.x) &&
8
+ isFiniteNumber(position.y) &&
9
+ isFiniteNumber(position.width) &&
10
+ isFiniteNumber(position.height);
11
+ }
12
+
13
+ function buildConfigFromPosition(item, size, position) {
14
+ if (!item?.originalImg) return null;
15
+
16
+ return {
17
+ logoSize: size,
18
+ marginRight: item.originalImg.width - position.x - position.width,
19
+ marginBottom: item.originalImg.height - position.y - position.height
20
+ };
21
+ }
22
+
23
+ export function isConfirmedWatermarkDecision(item) {
24
+ const decisionTier = item?.processedMeta?.decisionTier;
25
+ if (typeof decisionTier === 'string') {
26
+ return decisionTier !== 'insufficient';
27
+ }
28
+
29
+ return item?.processedMeta?.applied !== false;
30
+ }
31
+
32
+ /**
33
+ * Resolve watermark info for UI display.
34
+ * Prefer processed runtime metadata and fallback to static estimate.
35
+ */
36
+ export function resolveDisplayWatermarkInfo(item, estimatedInfo) {
37
+ const processedMeta = item?.processedMeta;
38
+ const position = processedMeta?.position;
39
+
40
+ if (isValidPosition(position)) {
41
+ const size = isFiniteNumber(processedMeta.size) ? processedMeta.size : position.width;
42
+ if (isFiniteNumber(size)) {
43
+ return {
44
+ size,
45
+ position,
46
+ config: processedMeta.config || buildConfigFromPosition(item, size, position),
47
+ source: processedMeta.source || 'processed',
48
+ decisionTier: processedMeta.decisionTier || null
49
+ };
50
+ }
51
+ }
52
+
53
+ if (!estimatedInfo) return null;
54
+
55
+ return {
56
+ ...estimatedInfo,
57
+ source: 'estimated',
58
+ decisionTier: null
59
+ };
60
+ }