@pilio/gemini-watermark-remover 1.0.21 → 1.0.23

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/README.md CHANGED
@@ -1,32 +1,34 @@
1
- [中文文档](README_zh.md)
2
-
3
- > 🔥 Tired of Gemini watermarks? Try the more powerful **GPT Image 2** at [pilio.ai](https://pilio.ai) — free for a limited time.
4
-
5
- # Gemini Watermark Remover — Lossless Watermark Removal Tool
6
-
7
- An open-source tool to **remove Gemini watermarks** from AI-generated images with high-fidelity, reproducible results on supported outputs. Built with pure JavaScript, the engine uses a mathematically exact **Reverse Alpha Blending** algorithm instead of unpredictable AI inpainting.
8
-
9
- > **🚀 Looking for the `Online Gemini Watermark Remover (Recommended)`? Try [geminiwatermarkremover.io](https://geminiwatermarkremover.io/)** — free, no install, works directly in your browser.
10
- >
11
- > 💡 Got watermarks that Gemini Watermark Remover can't handle? Try the general-purpose AI watermark remover: [pilio.ai/image-watermark-remover](https://pilio.ai/image-watermark-remover)
12
-
13
- <p align="center">
1
+ [中文文档](README_zh.md)
2
+
3
+ > 🔥 Tired of Gemini watermarks? Try the more powerful **GPT Image 2** at [pilio.ai](https://pilio.ai) — free for a limited time.
4
+
5
+ # Gemini Watermark Remover — Lossless Watermark Removal Tool
6
+
7
+ An open-source tool to **remove Gemini watermarks** from AI-generated images with high-fidelity, reproducible results on supported outputs. Built with pure JavaScript, the engine uses a mathematically exact **Reverse Alpha Blending** algorithm instead of unpredictable AI inpainting.
8
+
9
+ > **🚀 Looking for the `Online Gemini Watermark Remover (Recommended)`? Try [geminiwatermarkremover.io](https://geminiwatermarkremover.io/)** — free, no install, works directly in your browser.
10
+ >
11
+ > 💡 Got watermarks that Gemini Watermark Remover can't handle? Try the general-purpose AI watermark remover: [pilio.ai/image-watermark-remover](https://pilio.ai/image-watermark-remover)
12
+
13
+ <p align="center">
14
14
  <a href="https://geminiwatermarkremover.io/"><img src="https://img.shields.io/badge/🛠️_Online_Tool-geminiwatermarkremover.io-blue?style=for-the-badge" alt="Online Tool"></a>&nbsp;
15
+ <a href="https://geminiwatermarkremover.io/video"><img src="https://img.shields.io/badge/🎬_Video_Remover-New!-ff6b6b?style=for-the-badge" alt="Video Watermark Remover"></a>&nbsp;
15
16
  <a href="https://chromewebstore.google.com/detail/gemini-watermark-remover/cjlmnfcfnofnglkphbcdclbpimdjkmdf"><img src="https://img.shields.io/badge/🧩_Chrome_Extension-Install-blueviolet?style=for-the-badge" alt="Chrome Extension"></a>&nbsp;
16
17
  <a href="https://geminiwatermarkremover.io/userscript/gemini-watermark-remover.user.js"><img src="https://img.shields.io/badge/🐒_Userscript-Install-green?style=for-the-badge" alt="Userscript"></a>&nbsp;
17
18
  <a href="https://pilio.ai/image-watermark-remover"><img src="https://img.shields.io/badge/🧹_General_Remover-pilio.ai-gray?style=for-the-badge" alt="General Watermark Remover"></a>
18
19
  </p>
19
-
20
- <p align="center">
21
- <img src="https://count.getloli.com/@gemini-watermark-remover?name=gemini-watermark-remover&theme=minecraft&padding=7&offset=0&align=top&scale=1&pixelated=1&darkmode=auto" width="400">
22
- </p>
23
-
24
- ## Features
25
-
26
- - ✅ **100% Local Processing** - All image processing happens locally in your browser or on your machine. Nothing is uploaded.
27
- - ✅ **Mathematical Precision** - Based on the Reverse Alpha Blending formula, not "hallucinating" AI models.
28
- - ✅ **Auto-Detection** - Automatically identifies watermark size and position using Gemini's known output catalog and local anchor search.
20
+
21
+ <p align="center">
22
+ <img src="https://count.getloli.com/@gemini-watermark-remover?name=gemini-watermark-remover&theme=minecraft&padding=7&offset=0&align=top&scale=1&pixelated=1&darkmode=auto" width="400">
23
+ </p>
24
+
25
+ ## Features
26
+
27
+ - ✅ **100% Local Processing** - All image processing happens locally in your browser or on your machine. Nothing is uploaded.
28
+ - ✅ **Mathematical Precision** - Based on the Reverse Alpha Blending formula, not "hallucinating" AI models.
29
+ - ✅ **Auto-Detection** - Automatically identifies watermark size and position using Gemini's known output catalog and local anchor search.
29
30
  - ✅ **Flexible Usage** - Online tool for quick use, Chrome extension or userscript for seamless Gemini page integration, CLI and Skill for scripting and automation.
31
+ - ✅ **Video Support** - Remove watermarks from Gemini-generated videos at [geminiwatermarkremover.io/video](https://geminiwatermarkremover.io/video).
30
32
  - ✅ **Cross-Platform** - Works in modern browsers (Chrome, Firefox, Safari, Edge) and Node.js environments.
31
33
 
32
34
  ## Gemini Watermark Removal Examples
@@ -66,6 +68,17 @@ For all users — the fastest and easiest way to remove Gemini watermarks from i
66
68
  3. The engine will automatically process and remove the watermark.
67
69
  4. Download the cleaned image.
68
70
 
71
+ ### Online Video Watermark Remover (New!)
72
+
73
+ For Gemini-generated **videos** with visible watermarks:
74
+
75
+ 1. Open **[geminiwatermarkremover.io/video](https://geminiwatermarkremover.io/video)**.
76
+ 2. Upload or drag-and-drop your Gemini-generated video.
77
+ 3. The tool will automatically detect and remove the watermark from the video.
78
+ 4. Download the cleaned video.
79
+
80
+ > **Note**: Video watermark removal runs entirely in your browser — no files are uploaded to any server.
81
+
69
82
  ### Chrome Extension
70
83
 
71
84
  Use this if you do not want a userscript manager but still want automatic Gemini page integration for previews, copy, and download actions.
@@ -371,6 +384,7 @@ gemini-watermark-remover/
371
384
  ## Limitations
372
385
 
373
386
  - Only removes **Gemini visible watermarks** <small>(the semi-transparent logo in bottom-right)</small>
387
+ - Supports both **image** and **video** watermark removal — video processing is available via the [online tool](https://geminiwatermarkremover.io/video)
374
388
  - Does not remove invisible/steganographic watermarks. <small>[(Learn more about SynthID)](https://support.google.com/gemini/answer/16722517)</small>
375
389
  - Designed for Gemini's current visible watermark pattern <small>(validated against this repo through April 2026)</small>
376
390
 
package/README_zh.md CHANGED
@@ -12,6 +12,7 @@
12
12
 
13
13
  <p align="center">
14
14
  <a href="https://geminiwatermarkremover.io/"><img src="https://img.shields.io/badge/🛠️_在线工具-geminiwatermarkremover.io-blue?style=for-the-badge" alt="在线工具"></a>&nbsp;
15
+ <a href="https://geminiwatermarkremover.io/video"><img src="https://img.shields.io/badge/🎬_视频去水印-New!-ff6b6b?style=for-the-badge" alt="视频去水印"></a>&nbsp;
15
16
  <a href="https://chromewebstore.google.com/detail/gemini-watermark-remover/cjlmnfcfnofnglkphbcdclbpimdjkmdf"><img src="https://img.shields.io/badge/🧩_Chrome插件-安装-blueviolet?style=for-the-badge" alt="Chrome 插件"></a>&nbsp;
16
17
  <a href="https://geminiwatermarkremover.io/userscript/gemini-watermark-remover.user.js"><img src="https://img.shields.io/badge/🐒_油猴脚本-安装-green?style=for-the-badge" alt="油猴脚本"></a>&nbsp;
17
18
  <a href="https://pilio.ai/image-watermark-remover"><img src="https://img.shields.io/badge/🧹_通用去水印-pilio.ai-gray?style=for-the-badge" alt="通用去水印"></a>
@@ -27,6 +28,7 @@
27
28
  - ✅ **数学精确** - 基于反向 Alpha 混合算法,而非 AI 修复模型
28
29
  - ✅ **自动检测** - 基于 Gemini 已知输出尺寸目录和局部锚点搜索,自动识别水印大小与位置
29
30
  - ✅ **灵活使用** - 在线工具快速上手、Chrome 插件或油猴脚本无缝集成 Gemini 页面、CLI 和 Skill 支持脚本化与自动化
31
+ - ✅ **视频支持** - 支持去除 Gemini 生成视频中的水印,访问 [geminiwatermarkremover.io/video](https://geminiwatermarkremover.io/video) 即可使用
30
32
  - ✅ **跨平台** - 支持现代浏览器(Chrome、Firefox、Safari、Edge)和 Node.js 环境
31
33
 
32
34
  ## Gemini 去水印效果示例
@@ -66,6 +68,17 @@
66
68
  3. 图片会自动开始处理,移除水印
67
69
  4. 下载处理后的图片
68
70
 
71
+ ### 在线视频去水印工具(新功能!)
72
+
73
+ 针对带有可见水印的 Gemini 生成**视频**:
74
+
75
+ 1. 浏览器打开 **[geminiwatermarkremover.io/video](https://geminiwatermarkremover.io/video)**
76
+ 2. 拖拽或点击选择带水印的 Gemini 生成视频
77
+ 3. 工具会自动检测并去除视频中的水印
78
+ 4. 下载处理后的视频
79
+
80
+ > **说明**:视频去水印处理完全在浏览器本地完成,不会上传任何文件到服务器。
81
+
69
82
  ### Chrome 插件
70
83
 
71
84
  适合不想安装油猴脚本、但希望在 Gemini 页面里实时自动处理预览图、复制和下载动作的用户。
@@ -371,6 +384,7 @@ gemini-watermark-remover/
371
384
  ## 局限性
372
385
 
373
386
  - 只去除了 **Gemini 可见的水印**<small>(即右下角的半透明 Logo)</small>
387
+ - 同时支持**图片**和**视频**水印去除 — 视频处理请使用[在线工具](https://geminiwatermarkremover.io/video)
374
388
  - 无法去除隐形或隐写水印。<small>[(了解更多关于 SynthID 的信息)](https://support.google.com/gemini/answer/16722517)</small>
375
389
  - 针对 Gemini 当前的可见水印模式设计<small>(本仓库验证范围截至 2026 年 4 月)</small>
376
390
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pilio/gemini-watermark-remover",
3
3
  "description": "Automatically removes watermarks from Gemini AI generated images",
4
- "version": "1.0.21",
4
+ "version": "1.0.23",
5
5
  "author": "GargantuaX",
6
6
  "sideEffects": false,
7
7
  "files": [
@@ -188,6 +188,49 @@ function calculateAllenkPaddedRoi({ imageWidth, imageHeight, region, padding = A
188
188
  };
189
189
  }
190
190
 
191
+ function calculateAllenkRuntimeRoi({
192
+ imageWidth,
193
+ imageHeight,
194
+ region,
195
+ padding = ALLENK_FDNCNN_MODEL.defaultPadding,
196
+ targetWidth = null,
197
+ targetHeight = null
198
+ } = {}) {
199
+ const padded = calculateAllenkPaddedRoi({ imageWidth, imageHeight, region, padding });
200
+ if (!padded) return null;
201
+
202
+ const safeTargetWidth = Number.isInteger(targetWidth) && targetWidth > 0 ? targetWidth : null;
203
+ const safeTargetHeight = Number.isInteger(targetHeight) && targetHeight > 0 ? targetHeight : null;
204
+ if (
205
+ !safeTargetWidth ||
206
+ !safeTargetHeight ||
207
+ imageWidth < safeTargetWidth ||
208
+ imageHeight < safeTargetHeight ||
209
+ padded.width > safeTargetWidth ||
210
+ padded.height > safeTargetHeight
211
+ ) {
212
+ return padded;
213
+ }
214
+
215
+ const centerX = region.x + region.width / 2;
216
+ const centerY = region.y + region.height / 2;
217
+ const x = clamp(Math.round(centerX - safeTargetWidth / 2), 0, imageWidth - safeTargetWidth);
218
+ const y = clamp(Math.round(centerY - safeTargetHeight / 2), 0, imageHeight - safeTargetHeight);
219
+
220
+ return {
221
+ x,
222
+ y,
223
+ width: safeTargetWidth,
224
+ height: safeTargetHeight,
225
+ inner: {
226
+ x: clamp(Math.round(region.x - x), 0, safeTargetWidth),
227
+ y: clamp(Math.round(region.y - y), 0, safeTargetHeight),
228
+ width: clamp(Math.round(region.width), 0, safeTargetWidth),
229
+ height: clamp(Math.round(region.height), 0, safeTargetHeight)
230
+ }
231
+ };
232
+ }
233
+
191
234
  function embedAllenkRoiWeights({ roiWeights, roiWidth, roiHeight, paddedRoi, blurSigma = 1 } = {}) {
192
235
  if (!roiWeights || !paddedRoi?.inner || roiWidth <= 0 || roiHeight <= 0) {
193
236
  return new Float32Array(0);
@@ -319,6 +362,7 @@ export {
319
362
  blendAllenkDenoisedRoi,
320
363
  buildAllenkFdncnnInput,
321
364
  calculateAllenkPaddedRoi,
365
+ calculateAllenkRuntimeRoi,
322
366
  convertAllenkFdncnnOutputToRgba,
323
367
  createAllenkGradientMask,
324
368
  embedAllenkRoiWeights,
@@ -240,6 +240,35 @@ function createV2SmallVariantConfig(width, height) {
240
240
  return x >= 0 && y >= 0 ? config : null;
241
241
  }
242
242
 
243
+ function createProjectedConfig(baseConfig, scaleX, scaleY, { minLogoSize, maxLogoSize, roundLogoSize = Math.round }) {
244
+ if (!baseConfig) return null;
245
+
246
+ return {
247
+ logoSize: clamp(
248
+ roundLogoSize(baseConfig.logoSize * ((scaleX + scaleY) / 2)),
249
+ minLogoSize,
250
+ maxLogoSize
251
+ ),
252
+ marginRight: Math.max(8, Math.round(baseConfig.marginRight * scaleX)),
253
+ marginBottom: Math.max(8, Math.round(baseConfig.marginBottom * scaleY)),
254
+ ...(baseConfig.alphaVariant ? { alphaVariant: baseConfig.alphaVariant } : {})
255
+ };
256
+ }
257
+
258
+ function getNearOfficialProjectionConfigs(entry, baseConfig) {
259
+ const configs = [{ config: baseConfig, family: 'near-official-projected', source: `${entry.width}x${entry.height}` }];
260
+ if (entry?.modelFamily === 'gemini-3.x-image' && entry.resolutionTier === '1k') {
261
+ configs.push({
262
+ config: GEMINI_3X_CURRENT_1K_LARGE_MARGIN_WATERMARK_CONFIG,
263
+ family: 'near-official-current-large-margin',
264
+ source: `${entry.width}x${entry.height}-large-margin`,
265
+ roundLogoSize: Math.ceil
266
+ });
267
+ }
268
+
269
+ return configs;
270
+ }
271
+
243
272
  export function matchOfficialGeminiImageSize(width, height) {
244
273
  const normalizedWidth = normalizeDimension(width);
245
274
  const normalizedHeight = normalizeDimension(height);
@@ -389,9 +418,9 @@ export function resolveOfficialGeminiSearchConfigEntries(
389
418
  // this only proposes search seeds; later validation still decides safety.
390
419
  const targetAspectRatio = normalizedWidth / normalizedHeight;
391
420
  const candidates = OFFICIAL_GEMINI_IMAGE_SIZES
392
- .map((entry) => {
421
+ .flatMap((entry) => {
393
422
  const baseConfig = getEntryConfig(entry);
394
- if (!baseConfig) return null;
423
+ if (!baseConfig) return [];
395
424
 
396
425
  const scaleX = normalizedWidth / entry.width;
397
426
  const scaleY = normalizedHeight / entry.height;
@@ -400,35 +429,39 @@ export function resolveOfficialGeminiSearchConfigEntries(
400
429
  const relativeAspectRatioDelta = Math.abs(targetAspectRatio - entryAspectRatio) / entryAspectRatio;
401
430
  const scaleMismatchRatio = Math.abs(scaleX - scaleY) / Math.max(scaleX, scaleY);
402
431
 
403
- if (relativeAspectRatioDelta > maxRelativeAspectRatioDelta) return null;
404
- if (scaleMismatchRatio > maxScaleMismatchRatio) return null;
405
-
406
- const config = {
407
- logoSize: clamp(Math.round(baseConfig.logoSize * scale), minLogoSize, maxLogoSize),
408
- marginRight: Math.max(8, Math.round(baseConfig.marginRight * scaleX)),
409
- marginBottom: Math.max(8, Math.round(baseConfig.marginBottom * scaleY))
410
- };
411
-
412
- const x = normalizedWidth - config.marginRight - config.logoSize;
413
- const y = normalizedHeight - config.marginBottom - config.logoSize;
414
- if (x < 0 || y < 0) return null;
415
-
416
- return {
417
- config,
418
- metadata: {
419
- family: 'near-official-projected',
420
- sourcePriority: 4,
421
- evidenceGate: 'required',
422
- modelFamily: entry.modelFamily,
423
- resolutionTier: entry.resolutionTier,
424
- aspectRatio: entry.aspectRatio,
425
- source: `${entry.width}x${entry.height}`
426
- },
427
- score:
428
- relativeAspectRatioDelta * 100 +
429
- scaleMismatchRatio * 20 +
430
- Math.abs(Math.log2(Math.max(scale, 1e-6)))
431
- };
432
+ if (relativeAspectRatioDelta > maxRelativeAspectRatioDelta) return [];
433
+ if (scaleMismatchRatio > maxScaleMismatchRatio) return [];
434
+
435
+ return getNearOfficialProjectionConfigs(entry, baseConfig)
436
+ .map((projection) => {
437
+ const config = createProjectedConfig(projection.config, scaleX, scaleY, {
438
+ minLogoSize,
439
+ maxLogoSize,
440
+ roundLogoSize: projection.roundLogoSize
441
+ });
442
+
443
+ const x = normalizedWidth - config.marginRight - config.logoSize;
444
+ const y = normalizedHeight - config.marginBottom - config.logoSize;
445
+ if (x < 0 || y < 0) return null;
446
+
447
+ return {
448
+ config,
449
+ metadata: {
450
+ family: projection.family,
451
+ sourcePriority: 4,
452
+ evidenceGate: 'required',
453
+ modelFamily: entry.modelFamily,
454
+ resolutionTier: entry.resolutionTier,
455
+ aspectRatio: entry.aspectRatio,
456
+ source: projection.source
457
+ },
458
+ score:
459
+ relativeAspectRatioDelta * 100 +
460
+ scaleMismatchRatio * 20 +
461
+ Math.abs(Math.log2(Math.max(scale, 1e-6)))
462
+ };
463
+ })
464
+ .filter(Boolean);
432
465
  })
433
466
  .filter(Boolean)
434
467
  .sort((a, b) => a.score - b.score);
@@ -149,7 +149,11 @@ const WEAK_ALPHA_FINE_TUNE_MAX_GRADIENT_INCREASE = 0.08;
149
149
  const STRONG_POSITIVE_FINE_TUNE_MIN_ORIGINAL_SPATIAL = 0.75;
150
150
  const STRONG_POSITIVE_FINE_TUNE_MIN_ORIGINAL_GRADIENT = 0.65;
151
151
  const STRONG_POSITIVE_FINE_TUNE_MIN_CURRENT_SPATIAL = 0.3;
152
+ const STRONG_POSITIVE_FINE_TUNE_MID_GAINS = Object.freeze([0.7, 0.85]);
152
153
  const STRONG_POSITIVE_FINE_TUNE_EXTRA_GAINS = Object.freeze([0.95, 1]);
154
+ const STRONG_POSITIVE_FINE_TUNE_MAX_ACCEPTED_SPATIAL = 0.12;
155
+ const STRONG_POSITIVE_FINE_TUNE_MAX_ACCEPTED_GRADIENT = 0.25;
156
+ const STRONG_POSITIVE_FINE_TUNE_MAX_POSITIVE_HALO_LUM = 4;
153
157
  const CATALOG_ALPHA_DARK_FINE_TUNE_MIN_ORIGINAL_SPATIAL = 0.6;
154
158
  const CATALOG_ALPHA_DARK_FINE_TUNE_MIN_ORIGINAL_GRADIENT = 0.45;
155
159
  const CATALOG_ALPHA_DARK_FINE_TUNE_MAX_NEGATIVE_RESIDUAL = -0.12;
@@ -164,6 +168,15 @@ const SMALL_PREVIEW_REFINEMENT_MIN_CURRENT_SPATIAL = 0.04;
164
168
  const SMALL_PREVIEW_REFINEMENT_MAX_CURRENT_GRADIENT = 0.08;
165
169
  const FIRST_PASS_SIGN_FLIP_GRADIENT_THRESHOLD = 0.08;
166
170
  const FIRST_PASS_SIGN_FLIP_MIN_GRADIENT_DROP = 0.2;
171
+ const SMALL_ANCHOR_RELOCATION_MIN_SIZE = 40;
172
+ const SMALL_ANCHOR_RELOCATION_MAX_SIZE = 56;
173
+ const SMALL_ANCHOR_RELOCATION_SIZE_DELTA = 5;
174
+ const SMALL_ANCHOR_RELOCATION_MARGIN_DELTA = 10;
175
+ const SMALL_ANCHOR_RELOCATION_MIN_CURRENT_GRADIENT = 0.24;
176
+ const SMALL_ANCHOR_RELOCATION_MAX_ACCEPTED_SPATIAL = 0.14;
177
+ const SMALL_ANCHOR_RELOCATION_MAX_ACCEPTED_GRADIENT = 0.24;
178
+ const SMALL_ANCHOR_RELOCATION_MIN_ORIGINAL_SPATIAL = 0.32;
179
+ const SMALL_ANCHOR_RELOCATION_MIN_SUPPRESSION_GAIN = 0.22;
167
180
 
168
181
  function nowMs() {
169
182
  if (typeof globalThis.performance?.now === 'function') {
@@ -560,12 +573,16 @@ function fineTuneWeakPositiveResidualAlpha({
560
573
  alphaGainCandidates.add(alphaGain);
561
574
  }
562
575
 
563
- if (
576
+ const shouldTryStrongPositiveFineTune =
564
577
  currentSpatialScore >= STRONG_POSITIVE_FINE_TUNE_MIN_CURRENT_SPATIAL &&
565
578
  originalSpatialScore >= STRONG_POSITIVE_FINE_TUNE_MIN_ORIGINAL_SPATIAL &&
566
- originalGradientScore >= STRONG_POSITIVE_FINE_TUNE_MIN_ORIGINAL_GRADIENT
567
- ) {
568
- for (const alphaGain of STRONG_POSITIVE_FINE_TUNE_EXTRA_GAINS) {
579
+ originalGradientScore >= STRONG_POSITIVE_FINE_TUNE_MIN_ORIGINAL_GRADIENT;
580
+
581
+ if (shouldTryStrongPositiveFineTune) {
582
+ for (const alphaGain of [
583
+ ...STRONG_POSITIVE_FINE_TUNE_MID_GAINS,
584
+ ...STRONG_POSITIVE_FINE_TUNE_EXTRA_GAINS
585
+ ]) {
569
586
  alphaGainCandidates.add(alphaGain);
570
587
  }
571
588
  }
@@ -597,17 +614,22 @@ function fineTuneWeakPositiveResidualAlpha({
597
614
  });
598
615
  const absSpatialImprovement = Math.abs(currentSpatialScore) - Math.abs(spatialScore);
599
616
  const gradientIncrease = gradientScore - currentGradientScore;
600
- if (
617
+ const strongPositiveClearsResidual = shouldTryStrongPositiveFineTune &&
618
+ Math.abs(spatialScore) <= STRONG_POSITIVE_FINE_TUNE_MAX_ACCEPTED_SPATIAL &&
619
+ gradientScore <= STRONG_POSITIVE_FINE_TUNE_MAX_ACCEPTED_GRADIENT &&
620
+ (artifacts?.halo?.positiveDeltaLum ?? Number.POSITIVE_INFINITY) <= STRONG_POSITIVE_FINE_TUNE_MAX_POSITIVE_HALO_LUM;
621
+ if (!strongPositiveClearsResidual && (
601
622
  absSpatialImprovement < WEAK_ALPHA_FINE_TUNE_MIN_ABS_SPATIAL_IMPROVEMENT ||
602
623
  gradientIncrease > WEAK_ALPHA_FINE_TUNE_MAX_GRADIENT_INCREASE
603
- ) {
624
+ )) {
604
625
  continue;
605
626
  }
606
627
 
607
628
  const cost = artifacts
608
629
  ? artifacts.visualArtifactCost + Math.max(0, gradientIncrease) * 0.25
609
630
  : Math.abs(spatialScore) + Math.max(0, gradientIncrease) * 0.25 + nearBlackRatio * 2;
610
- if (!best || cost < best.cost) {
631
+ const clearsResidual = strongPositiveClearsResidual;
632
+ if (!best || (clearsResidual && !best.clearsResidual) || (clearsResidual === best.clearsResidual && cost < best.cost)) {
611
633
  best = {
612
634
  imageData: candidate,
613
635
  alphaGain,
@@ -615,7 +637,8 @@ function fineTuneWeakPositiveResidualAlpha({
615
637
  gradientScore,
616
638
  nearBlackRatio,
617
639
  suppressionGain: originalSpatialScore - spatialScore,
618
- cost
640
+ cost,
641
+ clearsResidual
619
642
  };
620
643
  }
621
644
  }
@@ -1850,6 +1873,198 @@ function refineLocatedAggressiveRemoval({
1850
1873
  return best;
1851
1874
  }
1852
1875
 
1876
+ function shouldRelocateSmallFixedLocalAnchor({
1877
+ source,
1878
+ config,
1879
+ position,
1880
+ currentGradientScore,
1881
+ currentResidualVisibility
1882
+ }) {
1883
+ const sourceText = String(source || '');
1884
+ if (!sourceText.includes('fixed-local')) return false;
1885
+ if (
1886
+ !position ||
1887
+ position.width !== position.height ||
1888
+ position.width < SMALL_ANCHOR_RELOCATION_MIN_SIZE ||
1889
+ position.width > SMALL_ANCHOR_RELOCATION_MAX_SIZE
1890
+ ) {
1891
+ return false;
1892
+ }
1893
+ if (
1894
+ !config ||
1895
+ config.logoSize < SMALL_ANCHOR_RELOCATION_MIN_SIZE ||
1896
+ config.logoSize > SMALL_ANCHOR_RELOCATION_MAX_SIZE
1897
+ ) {
1898
+ return false;
1899
+ }
1900
+ if (!currentResidualVisibility?.visible) return false;
1901
+ return currentResidualVisibility.visiblePositiveHalo === true ||
1902
+ currentGradientScore >= SMALL_ANCHOR_RELOCATION_MIN_CURRENT_GRADIENT;
1903
+ }
1904
+
1905
+ function resolveRelocationAlphaMap({ size, currentAlphaMap, currentPosition, alpha96, getAlphaMap }) {
1906
+ if (size === currentPosition?.width) return currentAlphaMap;
1907
+ if (typeof getAlphaMap === 'function') {
1908
+ const resolved = getAlphaMap(size);
1909
+ if (resolved) return resolved;
1910
+ }
1911
+ return interpolateAlphaMap(alpha96, 96, size);
1912
+ }
1913
+
1914
+ function buildSmallAnchorRelocationCandidate({
1915
+ originalImageData,
1916
+ alphaMap,
1917
+ size,
1918
+ marginRight,
1919
+ marginBottom,
1920
+ alphaGain
1921
+ }) {
1922
+ const position = {
1923
+ x: originalImageData.width - marginRight - size,
1924
+ y: originalImageData.height - marginBottom - size,
1925
+ width: size,
1926
+ height: size
1927
+ };
1928
+ if (
1929
+ position.x < 0 ||
1930
+ position.y < 0 ||
1931
+ position.x + size > originalImageData.width ||
1932
+ position.y + size > originalImageData.height
1933
+ ) {
1934
+ return null;
1935
+ }
1936
+
1937
+ const originalSpatialScore = computeRegionSpatialCorrelation({
1938
+ imageData: originalImageData,
1939
+ alphaMap,
1940
+ region: { x: position.x, y: position.y, size }
1941
+ });
1942
+ if (originalSpatialScore < SMALL_ANCHOR_RELOCATION_MIN_ORIGINAL_SPATIAL) {
1943
+ return null;
1944
+ }
1945
+
1946
+ const originalGradientScore = computeRegionGradientCorrelation({
1947
+ imageData: originalImageData,
1948
+ alphaMap,
1949
+ region: { x: position.x, y: position.y, size }
1950
+ });
1951
+ const candidateImageData = cloneImageData(originalImageData);
1952
+ removeWatermark(candidateImageData, alphaMap, position, { alphaGain });
1953
+ const spatialScore = computeRegionSpatialCorrelation({
1954
+ imageData: candidateImageData,
1955
+ alphaMap,
1956
+ region: { x: position.x, y: position.y, size }
1957
+ });
1958
+ const gradientScore = computeRegionGradientCorrelation({
1959
+ imageData: candidateImageData,
1960
+ alphaMap,
1961
+ region: { x: position.x, y: position.y, size }
1962
+ });
1963
+ const suppressionGain = originalSpatialScore - spatialScore;
1964
+ if (
1965
+ Math.abs(spatialScore) > SMALL_ANCHOR_RELOCATION_MAX_ACCEPTED_SPATIAL ||
1966
+ gradientScore > SMALL_ANCHOR_RELOCATION_MAX_ACCEPTED_GRADIENT ||
1967
+ suppressionGain < SMALL_ANCHOR_RELOCATION_MIN_SUPPRESSION_GAIN
1968
+ ) {
1969
+ return null;
1970
+ }
1971
+
1972
+ const residualVisibility = assessWatermarkResidualVisibility({
1973
+ imageData: candidateImageData,
1974
+ position,
1975
+ alphaMap
1976
+ });
1977
+ if (residualVisibility.visible) return null;
1978
+
1979
+ return {
1980
+ imageData: candidateImageData,
1981
+ alphaMap,
1982
+ position,
1983
+ config: { logoSize: size, marginRight, marginBottom },
1984
+ alphaGain,
1985
+ originalSpatialScore,
1986
+ originalGradientScore,
1987
+ spatialScore,
1988
+ gradientScore,
1989
+ suppressionGain,
1990
+ residualVisibility,
1991
+ cost: Math.abs(spatialScore) * 0.8 +
1992
+ Math.max(0, gradientScore) * 0.75 +
1993
+ Math.max(0, residualVisibility.positiveHaloLum ?? 0) * 0.01
1994
+ };
1995
+ }
1996
+
1997
+ function refineSmallFixedLocalAnchorGeometry({
1998
+ originalImageData,
1999
+ currentAlphaMap,
2000
+ currentPosition,
2001
+ currentConfig,
2002
+ currentSource,
2003
+ currentGradientScore,
2004
+ currentResidualVisibility,
2005
+ alpha96,
2006
+ getAlphaMap
2007
+ }) {
2008
+ if (!shouldRelocateSmallFixedLocalAnchor({
2009
+ source: currentSource,
2010
+ config: currentConfig,
2011
+ position: currentPosition,
2012
+ currentGradientScore,
2013
+ currentResidualVisibility
2014
+ })) {
2015
+ return null;
2016
+ }
2017
+
2018
+ const currentSize = currentConfig.logoSize ?? currentPosition.width;
2019
+ const currentMarginRight = currentConfig.marginRight;
2020
+ const currentMarginBottom = currentConfig.marginBottom;
2021
+ if (![currentSize, currentMarginRight, currentMarginBottom].every(Number.isFinite)) {
2022
+ return null;
2023
+ }
2024
+
2025
+ let best = null;
2026
+ const minSize = Math.max(SMALL_ANCHOR_RELOCATION_MIN_SIZE, currentSize);
2027
+ const maxSize = Math.min(SMALL_ANCHOR_RELOCATION_MAX_SIZE, currentSize + SMALL_ANCHOR_RELOCATION_SIZE_DELTA);
2028
+ const minMarginRight = Math.max(0, currentMarginRight - SMALL_ANCHOR_RELOCATION_MARGIN_DELTA);
2029
+ const maxMarginRight = currentMarginRight + SMALL_ANCHOR_RELOCATION_MARGIN_DELTA;
2030
+ const minMarginBottom = Math.max(0, currentMarginBottom - SMALL_ANCHOR_RELOCATION_MARGIN_DELTA);
2031
+ const maxMarginBottom = currentMarginBottom + SMALL_ANCHOR_RELOCATION_MARGIN_DELTA;
2032
+
2033
+ for (let size = minSize; size <= maxSize; size++) {
2034
+ const alphaMap = resolveRelocationAlphaMap({
2035
+ size,
2036
+ currentAlphaMap,
2037
+ currentPosition,
2038
+ alpha96,
2039
+ getAlphaMap
2040
+ });
2041
+ if (!alphaMap) continue;
2042
+
2043
+ for (let marginRight = minMarginRight; marginRight <= maxMarginRight; marginRight++) {
2044
+ for (let marginBottom = minMarginBottom; marginBottom <= maxMarginBottom; marginBottom++) {
2045
+ const candidateY = originalImageData.height - marginBottom - size;
2046
+ if (candidateY > currentPosition.y + 1) continue;
2047
+ for (const candidateAlphaGain of ALPHA_GAIN_CANDIDATES) {
2048
+ const candidate = buildSmallAnchorRelocationCandidate({
2049
+ originalImageData,
2050
+ alphaMap,
2051
+ size,
2052
+ marginRight,
2053
+ marginBottom,
2054
+ alphaGain: candidateAlphaGain
2055
+ });
2056
+ if (!candidate) continue;
2057
+ if (!best || candidate.cost < best.cost) {
2058
+ best = candidate;
2059
+ }
2060
+ }
2061
+ }
2062
+ }
2063
+ }
2064
+
2065
+ return best;
2066
+ }
2067
+
1853
2068
  export function processWatermarkImageData(imageData, options = {}) {
1854
2069
  const totalStartedAt = nowMs();
1855
2070
  const debugTimingsEnabled = options.debugTimings === true;
@@ -2526,8 +2741,53 @@ export function processWatermarkImageData(imageData, options = {}) {
2526
2741
  source = `${source}+small-preview-refine`;
2527
2742
  }
2528
2743
 
2744
+ const smallFixedLocalResidualVisibility = assessWatermarkResidualVisibility({
2745
+ imageData: finalImageData,
2746
+ position,
2747
+ alphaMap
2748
+ });
2749
+ const smallFixedLocalRelocated = refineSmallFixedLocalAnchorGeometry({
2750
+ originalImageData,
2751
+ currentAlphaMap: alphaMap,
2752
+ currentPosition: position,
2753
+ currentConfig: config,
2754
+ currentSource: source,
2755
+ currentGradientScore: finalProcessedGradientScore,
2756
+ currentResidualVisibility: smallFixedLocalResidualVisibility,
2757
+ alpha96,
2758
+ getAlphaMap: options.getAlphaMap
2759
+ });
2760
+ if (smallFixedLocalRelocated) {
2761
+ recordAlphaAdjustmentStage({
2762
+ stage: 'small-fixed-local-anchor-relocation',
2763
+ fromAlphaGain: alphaGain,
2764
+ toAlphaGain: smallFixedLocalRelocated.alphaGain,
2765
+ beforeSpatialScore: finalProcessedSpatialScore,
2766
+ beforeGradientScore: finalProcessedGradientScore,
2767
+ afterSpatialScore: smallFixedLocalRelocated.spatialScore,
2768
+ afterGradientScore: smallFixedLocalRelocated.gradientScore,
2769
+ suppressionGain: smallFixedLocalRelocated.suppressionGain,
2770
+ cost: smallFixedLocalRelocated.cost,
2771
+ allowSameAlphaGain: true
2772
+ });
2773
+ finalImageData = smallFixedLocalRelocated.imageData;
2774
+ alphaMap = smallFixedLocalRelocated.alphaMap;
2775
+ position = smallFixedLocalRelocated.position;
2776
+ config = smallFixedLocalRelocated.config;
2777
+ alphaGain = smallFixedLocalRelocated.alphaGain;
2778
+ originalSpatialScore = smallFixedLocalRelocated.originalSpatialScore;
2779
+ originalGradientScore = smallFixedLocalRelocated.originalGradientScore;
2780
+ finalProcessedSpatialScore = smallFixedLocalRelocated.spatialScore;
2781
+ finalProcessedGradientScore = smallFixedLocalRelocated.gradientScore;
2782
+ suppressionGain = smallFixedLocalRelocated.suppressionGain;
2783
+ source = `${source}+small-anchor-relocated`;
2784
+ }
2785
+
2529
2786
  const locatedAggressiveStartedAt = nowMs();
2530
- if (options.locatedAggressiveRemoval !== false) {
2787
+ if (
2788
+ options.locatedAggressiveRemoval !== false &&
2789
+ smallFixedLocalRelocated?.residualVisibility?.visible !== false
2790
+ ) {
2531
2791
  const aggressiveRefined = refineLocatedAggressiveRemoval({
2532
2792
  originalImageData,
2533
2793
  currentImageData: finalImageData,