@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.
- package/LICENSE +22 -0
- package/README.md +371 -0
- package/README_zh.md +371 -0
- package/bin/gwr.mjs +12 -0
- package/package.json +76 -0
- package/skills/gemini-watermark-remover/SKILL.md +28 -0
- package/skills/gemini-watermark-remover/agents/openai.yaml +3 -0
- package/skills/gemini-watermark-remover/references/inputs-and-outputs.md +9 -0
- package/skills/gemini-watermark-remover/references/limitations.md +5 -0
- package/skills/gemini-watermark-remover/references/usage.md +19 -0
- package/skills/gemini-watermark-remover/scripts/run.mjs +153 -0
- package/src/cli/gwrCli.js +17 -0
- package/src/cli/gwrRemoveCommand.js +317 -0
- package/src/core/adaptiveDetector.js +488 -0
- package/src/core/alphaMap.js +30 -0
- package/src/core/blendModes.js +70 -0
- package/src/core/candidateSelector.js +1446 -0
- package/src/core/canvasBlob.js +26 -0
- package/src/core/embeddedAlphaMaps.js +49 -0
- package/src/core/geminiSizeCatalog.js +239 -0
- package/src/core/multiPassRemoval.js +93 -0
- package/src/core/previewAlphaCalibration.js +822 -0
- package/src/core/restorationMetrics.js +251 -0
- package/src/core/selectionDebug.js +47 -0
- package/src/core/watermarkConfig.js +146 -0
- package/src/core/watermarkDecisionPolicy.js +163 -0
- package/src/core/watermarkDisplay.js +60 -0
- package/src/core/watermarkEngine.js +150 -0
- package/src/core/watermarkPresence.js +12 -0
- package/src/core/watermarkProcessor.js +875 -0
- package/src/core/workerClient.js +114 -0
- package/src/sdk/browser.d.ts +13 -0
- package/src/sdk/browser.js +29 -0
- package/src/sdk/image-data.d.ts +14 -0
- package/src/sdk/image-data.js +55 -0
- package/src/sdk/index.d.ts +144 -0
- package/src/sdk/index.js +8 -0
- package/src/sdk/node.d.ts +42 -0
- 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
|
+
}
|