feat(gemini-web): auto-remove watermark from generated images
Uses reverse alpha blending to restore original pixels from Gemini's visible watermark. Enabled by default; opt out with --keep-watermark. Algorithm ported from gemini-watermark-remover (MIT). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
462d080a0e
commit
98848ea7bf
|
|
@ -122,10 +122,19 @@ ${BUN_X} {baseDir}/scripts/main.ts "Hello" --json
|
|||
| `--sessionId` | Session ID for multi-turn conversation |
|
||||
| `--list-sessions` | List saved sessions |
|
||||
| `--json` | Output as JSON |
|
||||
| `--keep-watermark` | Skip watermark removal (removed by default) |
|
||||
| `--login` | Refresh cookies, then exit |
|
||||
| `--cookie-path` | Custom cookie file path |
|
||||
| `--profile-dir` | Chrome profile directory |
|
||||
|
||||
## Watermark Removal
|
||||
|
||||
Generated images have Gemini's visible watermark removed by default using reverse alpha blending. This is a lossless mathematical operation that precisely restores the original pixels — not AI inpainting.
|
||||
|
||||
To keep the watermark, pass `--keep-watermark`.
|
||||
|
||||
Algorithm credit: [gemini-watermark-remover](https://github.com/journey-ad/gemini-watermark-remover) by journey-ad, based on [GeminiWatermarkTool](https://github.com/allenk/GeminiWatermarkTool) by allenk.
|
||||
|
||||
## Models
|
||||
|
||||
| Model | Description |
|
||||
|
|
|
|||
|
|
@ -5,10 +5,22 @@
|
|||
"name": "baoyu-danger-gemini-web-scripts",
|
||||
"dependencies": {
|
||||
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp",
|
||||
"pngjs": "^7.0.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/pngjs": "^6.0.5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
||||
|
||||
"@types/pngjs": ["@types/pngjs@6.0.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ=="],
|
||||
|
||||
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:vendor/baoyu-chrome-cdp", {}],
|
||||
|
||||
"pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="],
|
||||
|
||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.0 KiB |
|
|
@ -3,6 +3,7 @@ import { mkdir, writeFile } from 'node:fs/promises';
|
|||
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { cookie_header, fetch_with_timeout } from '../utils/http.js';
|
||||
import { removeWatermarkFromFile } from '../watermark.js';
|
||||
|
||||
export class Image {
|
||||
constructor(
|
||||
|
|
@ -105,10 +106,20 @@ export class GeneratedImage extends Image {
|
|||
verbose: boolean = false,
|
||||
skip_invalid_filename: boolean = false,
|
||||
full_size: boolean = true,
|
||||
stripWatermark: boolean = true,
|
||||
): Promise<string | null> {
|
||||
const u = full_size ? `${this.url}=s2048` : this.url;
|
||||
const f = filename ?? `${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}_${u.slice(-10)}.png`;
|
||||
const img = new Image(u, this.title, this.alt, this.proxy);
|
||||
return await img.save(p, f, cookies ?? this.cookies, verbose, skip_invalid_filename);
|
||||
const dest = await img.save(p, f, cookies ?? this.cookies, verbose, skip_invalid_filename);
|
||||
if (dest && stripWatermark) {
|
||||
try {
|
||||
const removed = await removeWatermarkFromFile(dest);
|
||||
if (verbose) logger.info(removed ? `Watermark removed from ${dest}` : `Watermark removal skipped (non-PNG?) for ${dest}`);
|
||||
} catch (e) {
|
||||
if (verbose) logger.warning(`Watermark removal failed: ${e}`);
|
||||
}
|
||||
}
|
||||
return dest;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
import path from 'node:path';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { PNG } from 'pngjs';
|
||||
|
||||
const ALPHA_THRESHOLD = 0.002;
|
||||
const MAX_ALPHA = 0.99;
|
||||
const LOGO_VALUE = 255;
|
||||
|
||||
type WmConfig = { logoSize: number; marginRight: number; marginBottom: number };
|
||||
type WmPosition = { x: number; y: number; width: number; height: number };
|
||||
|
||||
function detectConfig(w: number, h: number): WmConfig {
|
||||
if (w > 1024 && h > 1024) return { logoSize: 96, marginRight: 64, marginBottom: 64 };
|
||||
return { logoSize: 48, marginRight: 32, marginBottom: 32 };
|
||||
}
|
||||
|
||||
function calcPosition(w: number, h: number, cfg: WmConfig): WmPosition {
|
||||
return {
|
||||
x: w - cfg.marginRight - cfg.logoSize,
|
||||
y: h - cfg.marginBottom - cfg.logoSize,
|
||||
width: cfg.logoSize,
|
||||
height: cfg.logoSize,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAlphaMap(bgData: Buffer, w: number, h: number): Float32Array {
|
||||
const map = new Float32Array(w * h);
|
||||
for (let i = 0; i < map.length; i++) {
|
||||
const idx = i * 4;
|
||||
map[i] = Math.max(bgData[idx]!, bgData[idx + 1]!, bgData[idx + 2]!) / 255.0;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function applyReverseBlend(
|
||||
data: Buffer,
|
||||
imgW: number,
|
||||
alphaMap: Float32Array,
|
||||
pos: WmPosition,
|
||||
): void {
|
||||
for (let row = 0; row < pos.height; row++) {
|
||||
for (let col = 0; col < pos.width; col++) {
|
||||
const imgIdx = ((pos.y + row) * imgW + (pos.x + col)) * 4;
|
||||
const alphaIdx = row * pos.width + col;
|
||||
let alpha = alphaMap[alphaIdx]!;
|
||||
|
||||
if (alpha < ALPHA_THRESHOLD) continue;
|
||||
alpha = Math.min(alpha, MAX_ALPHA);
|
||||
const inv = 1.0 - alpha;
|
||||
|
||||
for (let c = 0; c < 3; c++) {
|
||||
const val = (data[imgIdx + c]! - alpha * LOGO_VALUE) / inv;
|
||||
data[imgIdx + c] = Math.max(0, Math.min(255, Math.round(val)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const alphaMaps: Record<number, Float32Array> = {};
|
||||
|
||||
async function getAlphaMap(size: number): Promise<Float32Array> {
|
||||
if (alphaMaps[size]) return alphaMaps[size]!;
|
||||
|
||||
const file = size === 48 ? 'bg_48.png' : 'bg_96.png';
|
||||
const dir = (import.meta as any).dir ?? path.dirname(new URL(import.meta.url).pathname);
|
||||
const buf = await readFile(path.join(dir, 'assets', file));
|
||||
const png = PNG.sync.read(buf);
|
||||
const map = buildAlphaMap(png.data as unknown as Buffer, png.width, png.height);
|
||||
alphaMaps[size] = map;
|
||||
return map;
|
||||
}
|
||||
|
||||
export async function removeWatermarkFromFile(filePath: string): Promise<boolean> {
|
||||
const buf = await readFile(filePath);
|
||||
|
||||
let png: PNG;
|
||||
try {
|
||||
png = PNG.sync.read(buf);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { width, height } = png;
|
||||
const cfg = detectConfig(width, height);
|
||||
const pos = calcPosition(width, height, cfg);
|
||||
|
||||
if (pos.x < 0 || pos.y < 0) return false;
|
||||
|
||||
const alphaMap = await getAlphaMap(cfg.logoSize);
|
||||
applyReverseBlend(png.data as unknown as Buffer, width, alphaMap, pos);
|
||||
|
||||
const out = PNG.sync.write(png);
|
||||
await writeFile(filePath, out);
|
||||
return true;
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ type CliArgs = {
|
|||
login: boolean;
|
||||
cookiePath: string | null;
|
||||
profileDir: string | null;
|
||||
keepWatermark: boolean;
|
||||
help: boolean;
|
||||
};
|
||||
|
||||
|
|
@ -95,6 +96,7 @@ Options:
|
|||
--ref <files...> Alias for --reference
|
||||
--sessionId <id> Session ID for multi-turn conversation (agent should generate unique ID)
|
||||
--list-sessions List saved sessions (max 100, sorted by update time)
|
||||
--keep-watermark Skip watermark removal (watermark is removed by default)
|
||||
--login Only refresh cookies, then exit
|
||||
--cookie-path <path> Cookie file path (default: ${cookiePath})
|
||||
--profile-dir <path> Chrome profile dir (default: ${profileDir})
|
||||
|
|
@ -117,6 +119,7 @@ function parseArgs(argv: string[]): CliArgs {
|
|||
login: false,
|
||||
cookiePath: null,
|
||||
profileDir: null,
|
||||
keepWatermark: false,
|
||||
help: false,
|
||||
};
|
||||
|
||||
|
|
@ -157,6 +160,11 @@ function parseArgs(argv: string[]): CliArgs {
|
|||
continue;
|
||||
}
|
||||
|
||||
if (a === '--keep-watermark') {
|
||||
out.keepWatermark = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (a === '--prompt' || a === '-p') {
|
||||
const v = argv[++i];
|
||||
if (!v) throw new Error(`Missing value for ${a}`);
|
||||
|
|
@ -474,7 +482,7 @@ async function main(): Promise<void> {
|
|||
const dp = dir;
|
||||
|
||||
if (img instanceof GeneratedImage) {
|
||||
savedImage = await img.save(dp, fn, undefined, false, false, true);
|
||||
savedImage = await img.save(dp, fn, undefined, false, false, true, !args.keepWatermark);
|
||||
} else {
|
||||
savedImage = await img.save(dp, fn, c.cookies, false, false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@
|
|||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp"
|
||||
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp",
|
||||
"pngjs": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/pngjs": "^6.0.5"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue