@pilio/gemini-watermark-remover 1.0.20 → 1.0.21
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/package.json +67 -2
- package/src/cli/gwrCli.js +1 -1
- package/src/cli/gwrRemoveCommand.js +82 -14
- package/src/core/adaptiveDetector.js +22 -3
- package/src/core/allenkFdncnnDenoise.js +326 -0
- package/src/core/allenkFdncnnNcnnModel.js +244 -0
- package/src/core/allenkFdncnnOnnxExport.js +337 -0
- package/src/core/allenkFdncnnOnnxRuntime.js +138 -0
- package/src/core/allenkFdncnnReferenceRuntime.js +169 -0
- package/src/core/alphaGradientMask.js +165 -0
- package/src/core/blendModes.js +9 -4
- package/src/core/candidateSelector.js +422 -33
- package/src/core/embeddedAlphaMaps.js +2 -0
- package/src/core/geminiSizeCatalog.js +44 -1
- package/src/core/restorationMetrics.js +44 -0
- package/src/core/watermarkEngine.js +2 -1
- package/src/core/watermarkProcessor.js +1048 -82
- package/src/sdk/index.d.ts +29 -1
- package/src/sdk/node.d.ts +13 -0
- package/src/sdk/node.js +7 -0
- package/src/sdk/video.d.ts +84 -0
- package/src/sdk/video.js +263 -0
- package/src/shared/debugFileHandoff.js +109 -0
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.
|
|
4
|
+
"version": "1.0.21",
|
|
5
5
|
"author": "GargantuaX",
|
|
6
6
|
"sideEffects": false,
|
|
7
7
|
"files": [
|
|
@@ -45,6 +45,10 @@
|
|
|
45
45
|
"types": "./src/sdk/node.d.ts",
|
|
46
46
|
"import": "./src/sdk/node.js"
|
|
47
47
|
},
|
|
48
|
+
"./video": {
|
|
49
|
+
"types": "./src/sdk/video.d.ts",
|
|
50
|
+
"import": "./src/sdk/video.js"
|
|
51
|
+
},
|
|
48
52
|
"./runtime-browser": {
|
|
49
53
|
"types": "./src/runtime/browser.d.ts",
|
|
50
54
|
"import": "./src/runtime/browser.js"
|
|
@@ -65,10 +69,14 @@
|
|
|
65
69
|
"devDependencies": {
|
|
66
70
|
"@types/node": "^24.6.0",
|
|
67
71
|
"esbuild": "^0.24.0",
|
|
72
|
+
"onnxruntime-web": "^1.26.0",
|
|
68
73
|
"playwright": "^1.58.2",
|
|
69
74
|
"sharp": "^0.34.5",
|
|
70
75
|
"typescript": "^5.9.3"
|
|
71
76
|
},
|
|
77
|
+
"dependencies": {
|
|
78
|
+
"mediabunny": "^1.46.0"
|
|
79
|
+
},
|
|
72
80
|
"scripts": {
|
|
73
81
|
"dev": "node build.js",
|
|
74
82
|
"build": "node build.js --prod",
|
|
@@ -84,12 +92,69 @@
|
|
|
84
92
|
"probe:real-page:compare": "node scripts/real-page-pixel-compare.js",
|
|
85
93
|
"benchmark:samples": "node scripts/sample-benchmark.js",
|
|
86
94
|
"benchmark:userscript": "node scripts/userscript-benchmark.js",
|
|
95
|
+
"compare:allenk-v2": "node scripts/create-allenk-v2-comparison-report.js",
|
|
96
|
+
"release:readiness": "node scripts/create-release-readiness-report.js",
|
|
97
|
+
"release:quality-gate": "pnpm compare:allenk-v2 -- --fail-on-incomplete && pnpm release:readiness -- --fail-on-not-ready",
|
|
98
|
+
"release:goal-audit": "node scripts/create-release-goal-audit-report.js",
|
|
99
|
+
"release:preflight": "pnpm test && pnpm build && pnpm package:extension && pnpm release:quality-gate && pnpm release:goal-audit -- --fail-on-incomplete",
|
|
100
|
+
"visible-residual:loop": "node scripts/run-visible-residual-loop.js",
|
|
101
|
+
"visible-residual:verify": "node scripts/verify-visible-residual-loop.js",
|
|
102
|
+
"visible-residual:validate-human-review": "node scripts/validate-visible-residual-human-review.js",
|
|
103
|
+
"visible-residual:create-gold-manifest": "node scripts/create-visible-residual-gold-manifest.js",
|
|
104
|
+
"visible-residual:review-status": "node scripts/report-visible-residual-review-progress.js",
|
|
105
|
+
"visible-residual:apply-focused-batch": "node scripts/apply-visible-residual-focused-review-batch.js",
|
|
106
|
+
"visible-residual:review-worksheet": "node scripts/create-visible-residual-review-worksheet.js",
|
|
107
|
+
"visible-residual:admission-report": "node scripts/create-visible-residual-admission-report.js",
|
|
108
|
+
"visible-residual:goal-audit": "node scripts/create-visible-residual-goal-audit-report.js",
|
|
109
|
+
"visible-residual:cluster-report": "node scripts/create-visible-residual-cluster-report.js",
|
|
110
|
+
"visible-residual:geometry-audit": "node scripts/audit-visible-residual-geometry.js",
|
|
87
111
|
"scan:sample-watermarks": "node scripts/scan-sample-watermarks.js",
|
|
88
112
|
"render:skipped-crops": "node scripts/render-skipped-candidate-crops.js",
|
|
113
|
+
"render:video-crops": "node scripts/render-video-crop-sheet.js",
|
|
114
|
+
"analyze:video-residual": "node scripts/analyze-video-residual.js",
|
|
115
|
+
"benchmark:video-crops": "node scripts/video-crop-benchmark.js",
|
|
116
|
+
"report:video-crops": "node scripts/report-video-crop-benchmark.js",
|
|
117
|
+
"diagnose:video-lowbody": "node scripts/diagnose-video-lowbody-regression.js",
|
|
118
|
+
"lab:video-frames": "node scripts/run-video-frame-backend-lab.js",
|
|
119
|
+
"lab:allenk-fdncnn-onnx-frames": "node scripts/run-allenk-fdncnn-onnx-frame-lab.js",
|
|
120
|
+
"lab:video-alpha-profile": "node scripts/run-video-alpha-profile-lab.js",
|
|
121
|
+
"lab:video-temporal-residual": "node scripts/run-video-temporal-residual-lab.js",
|
|
122
|
+
"fit:video-alpha-shape": "node scripts/fit-video-alpha-shape.js",
|
|
123
|
+
"gate:video-alpha-shape": "node scripts/gate-video-alpha-shape-candidates.js",
|
|
124
|
+
"gate:video-denoise": "node scripts/gate-video-denoise-candidates.js",
|
|
125
|
+
"gate:video-delivery": "node scripts/run-video-delivery-gate.js",
|
|
126
|
+
"report:video-alpha-policy-evidence": "node scripts/create-video-alpha-policy-evidence-report.js",
|
|
127
|
+
"report:video-frame-lab": "node scripts/report-video-frame-lab-sweep.js",
|
|
128
|
+
"report:video-review-pack": "node scripts/create-video-review-pack.js",
|
|
129
|
+
"report:video-review-index": "node scripts/create-video-review-index.js",
|
|
130
|
+
"report:video-review-screenshot": "node scripts/create-video-review-screenshot.js",
|
|
131
|
+
"report:video-review-thumbnail-sheet": "node scripts/create-video-review-thumbnail-sheet.js",
|
|
132
|
+
"report:video-dashboard-screenshot": "node scripts/create-video-review-screenshot.js --html .artifacts/video-delivery-dashboard/latest-video-dashboard.html --output .artifacts/video-delivery-dashboard/latest-video-dashboard.png --report .artifacts/video-delivery-dashboard/latest-video-dashboard-screenshot.json",
|
|
133
|
+
"report:video-review-decision": "node scripts/create-video-review-decision-report.js",
|
|
134
|
+
"report:video-acceptance-quickstart": "node scripts/create-video-acceptance-quickstart.js",
|
|
135
|
+
"report:video-pending-review-decision": "node scripts/create-video-pending-review-decision.js",
|
|
136
|
+
"report:video-alpha-policy-review-pack": "node scripts/create-video-alpha-policy-review-pack.js",
|
|
137
|
+
"report:video-light-polish-review-pack": "node scripts/create-video-light-polish-review-pack.js",
|
|
138
|
+
"report:video-polish-sweep-review-pack": "node scripts/create-video-polish-sweep-review-pack.js",
|
|
139
|
+
"report:video-delivery-dashboard": "node scripts/create-video-delivery-dashboard.js",
|
|
140
|
+
"report:video-goal-status": "node scripts/create-video-goal-status-report.js",
|
|
141
|
+
"report:video-delivery-bundle": "node scripts/create-video-delivery-bundle.js",
|
|
142
|
+
"verify:video-delivery-bundle": "node scripts/verify-video-delivery-bundle.js",
|
|
143
|
+
"export:video-backend": "node scripts/export-video-backend-variant.js",
|
|
144
|
+
"export:allenk-fdncnn-onnx-video": "node scripts/export-allenk-fdncnn-onnx-video.js",
|
|
145
|
+
"export:allenk-fdncnn-onnx-frame-video": "node scripts/export-allenk-fdncnn-onnx-frame-video.js",
|
|
146
|
+
"report:allenk-fdncnn-onnx-video-evidence": "node scripts/create-allenk-fdncnn-onnx-video-evidence.js",
|
|
147
|
+
"export:video-ui-preset": "node scripts/export-video-ui-preset.js",
|
|
148
|
+
"render:video-comparison-grid": "node scripts/render-video-comparison-grid.js",
|
|
89
149
|
"package:extension": "node scripts/package-extension-release.js",
|
|
90
150
|
"cli:smoke": "node bin/gwr.mjs --help",
|
|
91
151
|
"serve": "pnpm dlx serve dist",
|
|
92
152
|
"export:samples": "node scripts/export-fixed-samples.js",
|
|
93
|
-
"calibrate:preview-alpha": "node scripts/calibrate-preview-alpha.js"
|
|
153
|
+
"calibrate:preview-alpha": "node scripts/calibrate-preview-alpha.js",
|
|
154
|
+
"extract:allenk-fdncnn": "node scripts/extract-allenk-fdncnn-model.js",
|
|
155
|
+
"export:allenk-fdncnn-onnx": "node scripts/export-allenk-fdncnn-onnx.js",
|
|
156
|
+
"smoke:allenk-fdncnn-onnx-runtime": "node scripts/smoke-allenk-fdncnn-onnx-runtime.js",
|
|
157
|
+
"report:allenk-fdncnn-browser-spike": "node scripts/create-allenk-fdncnn-browser-spike-report.js",
|
|
158
|
+
"report:allenk-fdncnn-runtime-seam": "node scripts/create-allenk-fdncnn-runtime-seam-report.js"
|
|
94
159
|
}
|
|
95
160
|
}
|
package/src/cli/gwrCli.js
CHANGED
|
@@ -2,7 +2,7 @@ import { runRemoveCommand } from './gwrRemoveCommand.js';
|
|
|
2
2
|
|
|
3
3
|
export async function main(argv, io) {
|
|
4
4
|
if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
|
|
5
|
-
io.stdout.write('Usage: gwr remove <input> [--output <file> | --out-dir <dir>] [--overwrite] [--json]\n');
|
|
5
|
+
io.stdout.write('Usage: gwr remove <input> [--output <file> | --out-dir <dir>] [--overwrite] [--json] [--video-page <url-or-file>]\n');
|
|
6
6
|
return 0;
|
|
7
7
|
}
|
|
8
8
|
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { mkdir, readdir, stat } from 'node:fs/promises';
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
inferMimeTypeFromPath,
|
|
6
|
+
inferVideoMimeTypeFromPath,
|
|
7
|
+
isVideoMimeType,
|
|
8
|
+
removeVideoWatermarkFromFile,
|
|
9
|
+
removeWatermarkFromFile
|
|
10
|
+
} from '../sdk/node.js';
|
|
5
11
|
|
|
6
12
|
const REMOVE_USAGE =
|
|
7
|
-
'Usage: gwr remove <input> [--output <file> | --out-dir <dir>] [--overwrite] [--json]';
|
|
13
|
+
'Usage: gwr remove <input> [--output <file> | --out-dir <dir>] [--overwrite] [--json] [--video-page <url-or-file>]';
|
|
8
14
|
|
|
9
15
|
export async function runRemoveCommand(argv, io) {
|
|
10
16
|
if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
|
|
@@ -18,15 +24,7 @@ export async function runRemoveCommand(argv, io) {
|
|
|
18
24
|
return 2;
|
|
19
25
|
}
|
|
20
26
|
|
|
21
|
-
const
|
|
22
|
-
io.stderr.write(`${error.message}\n`);
|
|
23
|
-
return null;
|
|
24
|
-
});
|
|
25
|
-
if (!codec) {
|
|
26
|
-
return 2;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const commandOptions = { ...options, codec };
|
|
27
|
+
const commandOptions = { ...options, imageCodecPromise: null };
|
|
30
28
|
const inputStats = await stat(commandOptions.input).catch(() => null);
|
|
31
29
|
if (!inputStats) {
|
|
32
30
|
io.stderr.write(`Input not found: ${commandOptions.input}\n`);
|
|
@@ -64,7 +62,11 @@ function parseRemoveArgs(argv) {
|
|
|
64
62
|
overwrite: false,
|
|
65
63
|
json: false,
|
|
66
64
|
decoder: null,
|
|
67
|
-
encoder: null
|
|
65
|
+
encoder: null,
|
|
66
|
+
videoPage: null,
|
|
67
|
+
videoDenoiseBackend: null,
|
|
68
|
+
videoTimeoutMs: null,
|
|
69
|
+
allowLowConfidence: false
|
|
68
70
|
};
|
|
69
71
|
|
|
70
72
|
let parseOptions = true;
|
|
@@ -116,6 +118,30 @@ function parseRemoveArgs(argv) {
|
|
|
116
118
|
continue;
|
|
117
119
|
}
|
|
118
120
|
|
|
121
|
+
if (token === '--video-page') {
|
|
122
|
+
const parsed = parseOptionValue(argv, index, '--video-page');
|
|
123
|
+
if (!parsed.ok) return parsed;
|
|
124
|
+
options.videoPage = parsed.value;
|
|
125
|
+
index = parsed.index;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (token === '--video-denoise-backend') {
|
|
130
|
+
const parsed = parseOptionValue(argv, index, '--video-denoise-backend');
|
|
131
|
+
if (!parsed.ok) return parsed;
|
|
132
|
+
options.videoDenoiseBackend = parsed.value;
|
|
133
|
+
index = parsed.index;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (token === '--video-timeout-ms') {
|
|
138
|
+
const parsed = parseOptionValue(argv, index, '--video-timeout-ms');
|
|
139
|
+
if (!parsed.ok) return parsed;
|
|
140
|
+
options.videoTimeoutMs = Number(parsed.value);
|
|
141
|
+
index = parsed.index;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
119
145
|
if (token === '--overwrite') {
|
|
120
146
|
options.overwrite = true;
|
|
121
147
|
continue;
|
|
@@ -126,6 +152,11 @@ function parseRemoveArgs(argv) {
|
|
|
126
152
|
continue;
|
|
127
153
|
}
|
|
128
154
|
|
|
155
|
+
if (token === '--allow-low-confidence') {
|
|
156
|
+
options.allowLowConfidence = true;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
|
|
129
160
|
return { ok: false, error: `Unknown option: ${token}` };
|
|
130
161
|
}
|
|
131
162
|
|
|
@@ -145,6 +176,10 @@ function parseRemoveArgs(argv) {
|
|
|
145
176
|
return { ok: false, error: '--decoder and --encoder must be used together.' };
|
|
146
177
|
}
|
|
147
178
|
|
|
179
|
+
if (options.videoTimeoutMs !== null && (!Number.isFinite(options.videoTimeoutMs) || options.videoTimeoutMs <= 0)) {
|
|
180
|
+
return { ok: false, error: '--video-timeout-ms must be a positive number.' };
|
|
181
|
+
}
|
|
182
|
+
|
|
148
183
|
return options;
|
|
149
184
|
}
|
|
150
185
|
|
|
@@ -301,17 +336,50 @@ async function processOneFile(inputPath, outputPath, options) {
|
|
|
301
336
|
}
|
|
302
337
|
|
|
303
338
|
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
339
|
+
if (isVideoPath(inputPath) || isVideoPath(outputPath)) {
|
|
340
|
+
const result = await removeVideoWatermarkFromFile(inputPath, {
|
|
341
|
+
outputPath,
|
|
342
|
+
mimeType: inferVideoMimeTypeFromPath(outputPath || inputPath),
|
|
343
|
+
pagePath: options.videoPage || undefined,
|
|
344
|
+
denoiseBackend: options.videoDenoiseBackend || undefined,
|
|
345
|
+
timeoutMs: Number.isFinite(options.videoTimeoutMs) && options.videoTimeoutMs > 0
|
|
346
|
+
? options.videoTimeoutMs
|
|
347
|
+
: undefined,
|
|
348
|
+
allowLowConfidence: options.allowLowConfidence
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
input: inputPath,
|
|
353
|
+
output: outputPath,
|
|
354
|
+
kind: 'video',
|
|
355
|
+
meta: result.meta
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const codec = await getImageCodec(options);
|
|
304
360
|
const mimeType = inferMimeTypeFromPath(outputPath);
|
|
305
361
|
const result = await removeWatermarkFromFile(inputPath, {
|
|
306
362
|
outputPath,
|
|
307
363
|
mimeType,
|
|
308
|
-
decodeImageData:
|
|
309
|
-
encodeImageData:
|
|
364
|
+
decodeImageData: codec.decodeImageData,
|
|
365
|
+
encodeImageData: codec.encodeImageData
|
|
310
366
|
});
|
|
311
367
|
|
|
312
368
|
return {
|
|
313
369
|
input: inputPath,
|
|
314
370
|
output: outputPath,
|
|
371
|
+
kind: 'image',
|
|
315
372
|
meta: result.meta
|
|
316
373
|
};
|
|
317
374
|
}
|
|
375
|
+
|
|
376
|
+
function isVideoPath(filePath) {
|
|
377
|
+
return isVideoMimeType(inferVideoMimeTypeFromPath(filePath));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function getImageCodec(options) {
|
|
381
|
+
if (!options.imageCodecPromise) {
|
|
382
|
+
options.imageCodecPromise = resolveCodecOptions(options);
|
|
383
|
+
}
|
|
384
|
+
return options.imageCodecPromise;
|
|
385
|
+
}
|
|
@@ -7,6 +7,8 @@ import { resolveGeminiWatermarkSearchConfigs } from './geminiSizeCatalog.js';
|
|
|
7
7
|
|
|
8
8
|
const DEFAULT_THRESHOLD = 0.35;
|
|
9
9
|
const EPSILON = 1e-8;
|
|
10
|
+
const REFERENCE_WATERMARK_SIZE = 96;
|
|
11
|
+
const MIN_COARSE_ADJUSTED_SCORE = 0.08;
|
|
10
12
|
|
|
11
13
|
const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
|
|
12
14
|
|
|
@@ -172,6 +174,23 @@ function createScaleList(minSize, maxSize) {
|
|
|
172
174
|
return [...set].sort((a, b) => a - b);
|
|
173
175
|
}
|
|
174
176
|
|
|
177
|
+
export function computeSizeAdjustedConfidence(confidence, size, referenceSize = REFERENCE_WATERMARK_SIZE) {
|
|
178
|
+
if (
|
|
179
|
+
!Number.isFinite(confidence) ||
|
|
180
|
+
!Number.isFinite(size) ||
|
|
181
|
+
!Number.isFinite(referenceSize) ||
|
|
182
|
+
size <= 0 ||
|
|
183
|
+
referenceSize <= 0
|
|
184
|
+
) {
|
|
185
|
+
return 0;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// NCC tends to favor tiny templates. A cube-root penalty keeps that bias in
|
|
189
|
+
// check without crushing legitimate preview-tier watermarks.
|
|
190
|
+
const sizeWeight = Math.min(1, Math.cbrt(size / referenceSize));
|
|
191
|
+
return confidence * sizeWeight;
|
|
192
|
+
}
|
|
193
|
+
|
|
175
194
|
function buildSeedConfigs(width, height, defaultConfig) {
|
|
176
195
|
// Start adaptive search from both the coarse default anchor and any
|
|
177
196
|
// catalog-projected anchors for official or near-official Gemini sizes.
|
|
@@ -407,7 +426,7 @@ export function detectAdaptiveWatermarkRegion({
|
|
|
407
426
|
size: seedCandidate.size,
|
|
408
427
|
x: seedCandidate.x,
|
|
409
428
|
y: seedCandidate.y,
|
|
410
|
-
adjustedScore: seedCandidate.confidence
|
|
429
|
+
adjustedScore: computeSizeAdjustedConfidence(seedCandidate.confidence, seedCandidate.size)
|
|
411
430
|
});
|
|
412
431
|
}
|
|
413
432
|
|
|
@@ -424,8 +443,8 @@ export function detectAdaptiveWatermarkRegion({
|
|
|
424
443
|
if (!score) continue;
|
|
425
444
|
|
|
426
445
|
// Prefer sizes close to known watermark scales to avoid tiny-template bias.
|
|
427
|
-
const adjustedScore = score.confidence
|
|
428
|
-
if (adjustedScore <
|
|
446
|
+
const adjustedScore = computeSizeAdjustedConfidence(score.confidence, size);
|
|
447
|
+
if (adjustedScore < MIN_COARSE_ADJUSTED_SCORE) continue;
|
|
429
448
|
|
|
430
449
|
pushTopK({
|
|
431
450
|
size,
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { createAlphaGradientMask } from './alphaGradientMask.js';
|
|
2
|
+
|
|
3
|
+
const ALLENK_FDNCNN_MODEL = Object.freeze({
|
|
4
|
+
name: 'FDnCNN Color FP16',
|
|
5
|
+
upstream: 'allenk/GeminiWatermarkTool',
|
|
6
|
+
license: 'MIT',
|
|
7
|
+
runtime: 'NCNN',
|
|
8
|
+
inputBlob: 0,
|
|
9
|
+
outputBlob: 20,
|
|
10
|
+
inputLayout: '[R, G, B, sigma] CHW float32',
|
|
11
|
+
outputLayout: '[R, G, B] CHW float32',
|
|
12
|
+
defaultSigma: 25,
|
|
13
|
+
defaultStrength: 0.85,
|
|
14
|
+
defaultPadding: 16,
|
|
15
|
+
maxSigma: 150,
|
|
16
|
+
maxStrength: 3
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
function clamp(value, min, max) {
|
|
20
|
+
return Math.max(min, Math.min(max, value));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function createGaussianKernel(sigma, radius = Math.ceil(sigma * 3)) {
|
|
24
|
+
const safeSigma = Math.max(0.01, sigma);
|
|
25
|
+
const safeRadius = Math.max(1, Math.round(radius));
|
|
26
|
+
const kernel = new Float32Array(safeRadius * 2 + 1);
|
|
27
|
+
let sum = 0;
|
|
28
|
+
|
|
29
|
+
for (let i = -safeRadius; i <= safeRadius; i++) {
|
|
30
|
+
const value = Math.exp(-(i * i) / (2 * safeSigma * safeSigma));
|
|
31
|
+
kernel[i + safeRadius] = value;
|
|
32
|
+
sum += value;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (let i = 0; i < kernel.length; i++) {
|
|
36
|
+
kernel[i] /= sum;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return { kernel, radius: safeRadius };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function gaussianBlurFloatMap(source, width, height, sigma, radius = Math.ceil(sigma * 3)) {
|
|
43
|
+
if (!source || width <= 0 || height <= 0 || !Number.isFinite(sigma) || sigma <= 0) {
|
|
44
|
+
return new Float32Array(source || 0);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const { kernel, radius: r } = createGaussianKernel(sigma, radius);
|
|
48
|
+
const temp = new Float32Array(source.length);
|
|
49
|
+
const output = new Float32Array(source.length);
|
|
50
|
+
|
|
51
|
+
for (let y = 0; y < height; y++) {
|
|
52
|
+
for (let x = 0; x < width; x++) {
|
|
53
|
+
let sum = 0;
|
|
54
|
+
for (let dx = -r; dx <= r; dx++) {
|
|
55
|
+
const xx = clamp(x + dx, 0, width - 1);
|
|
56
|
+
sum += source[y * width + xx] * kernel[dx + r];
|
|
57
|
+
}
|
|
58
|
+
temp[y * width + x] = sum;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
for (let y = 0; y < height; y++) {
|
|
63
|
+
for (let x = 0; x < width; x++) {
|
|
64
|
+
let sum = 0;
|
|
65
|
+
for (let dy = -r; dy <= r; dy++) {
|
|
66
|
+
const yy = clamp(y + dy, 0, height - 1);
|
|
67
|
+
sum += temp[yy * width + x] * kernel[dy + r];
|
|
68
|
+
}
|
|
69
|
+
output[y * width + x] = sum;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return output;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function inferSquareSize(alphaMap) {
|
|
77
|
+
const size = Math.round(Math.sqrt(alphaMap?.length || 0));
|
|
78
|
+
return size > 0 && size * size === alphaMap.length ? size : 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function resizeSquareAlphaMapArea(sourceAlpha, sourceSize, targetWidth, targetHeight = targetWidth) {
|
|
82
|
+
if (!sourceAlpha || sourceSize <= 0 || targetWidth <= 0 || targetHeight <= 0) {
|
|
83
|
+
return new Float32Array(0);
|
|
84
|
+
}
|
|
85
|
+
if (sourceSize === targetWidth && sourceSize === targetHeight) {
|
|
86
|
+
return new Float32Array(sourceAlpha);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const output = new Float32Array(targetWidth * targetHeight);
|
|
90
|
+
const scaleX = sourceSize / targetWidth;
|
|
91
|
+
const scaleY = sourceSize / targetHeight;
|
|
92
|
+
|
|
93
|
+
for (let y = 0; y < targetHeight; y++) {
|
|
94
|
+
const yStart = y * scaleY;
|
|
95
|
+
const yEnd = (y + 1) * scaleY;
|
|
96
|
+
const y0 = Math.floor(yStart);
|
|
97
|
+
const y1 = Math.ceil(yEnd);
|
|
98
|
+
|
|
99
|
+
for (let x = 0; x < targetWidth; x++) {
|
|
100
|
+
const xStart = x * scaleX;
|
|
101
|
+
const xEnd = (x + 1) * scaleX;
|
|
102
|
+
const x0 = Math.floor(xStart);
|
|
103
|
+
const x1 = Math.ceil(xEnd);
|
|
104
|
+
|
|
105
|
+
let sum = 0;
|
|
106
|
+
let areaSum = 0;
|
|
107
|
+
for (let sy = y0; sy < y1; sy++) {
|
|
108
|
+
if (sy < 0 || sy >= sourceSize) continue;
|
|
109
|
+
const wy = Math.max(0, Math.min(yEnd, sy + 1) - Math.max(yStart, sy));
|
|
110
|
+
for (let sx = x0; sx < x1; sx++) {
|
|
111
|
+
if (sx < 0 || sx >= sourceSize) continue;
|
|
112
|
+
const wx = Math.max(0, Math.min(xEnd, sx + 1) - Math.max(xStart, sx));
|
|
113
|
+
const area = wx * wy;
|
|
114
|
+
sum += sourceAlpha[sy * sourceSize + sx] * area;
|
|
115
|
+
areaSum += area;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
output[y * targetWidth + x] = areaSum > 0 ? sum / areaSum : 0;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return output;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function normalizeAllenkFdncnnOptions(options = {}) {
|
|
127
|
+
const sigma = Number.isFinite(options.sigma)
|
|
128
|
+
? clamp(options.sigma, 0, ALLENK_FDNCNN_MODEL.maxSigma)
|
|
129
|
+
: ALLENK_FDNCNN_MODEL.defaultSigma;
|
|
130
|
+
const strength = Number.isFinite(options.strength)
|
|
131
|
+
? clamp(options.strength, 0, ALLENK_FDNCNN_MODEL.maxStrength)
|
|
132
|
+
: ALLENK_FDNCNN_MODEL.defaultStrength;
|
|
133
|
+
const padding = Number.isFinite(options.padding)
|
|
134
|
+
? Math.max(0, Math.round(options.padding))
|
|
135
|
+
: ALLENK_FDNCNN_MODEL.defaultPadding;
|
|
136
|
+
|
|
137
|
+
return { sigma, strength, padding };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function createAllenkGradientMask({
|
|
141
|
+
alphaMap,
|
|
142
|
+
width,
|
|
143
|
+
height = width,
|
|
144
|
+
strength = ALLENK_FDNCNN_MODEL.defaultStrength
|
|
145
|
+
} = {}) {
|
|
146
|
+
const sourceSize = inferSquareSize(alphaMap);
|
|
147
|
+
const resizedAlphaMap = sourceSize > 0
|
|
148
|
+
? resizeSquareAlphaMapArea(alphaMap, sourceSize, width, height)
|
|
149
|
+
: alphaMap;
|
|
150
|
+
|
|
151
|
+
return createAlphaGradientMask({
|
|
152
|
+
alphaMap: resizedAlphaMap,
|
|
153
|
+
width,
|
|
154
|
+
height,
|
|
155
|
+
strength,
|
|
156
|
+
gamma: 0.5,
|
|
157
|
+
dilateRadius: 2,
|
|
158
|
+
blurSigma: 2
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function calculateAllenkPaddedRoi({ imageWidth, imageHeight, region, padding = ALLENK_FDNCNN_MODEL.defaultPadding } = {}) {
|
|
163
|
+
if (!region || imageWidth <= 0 || imageHeight <= 0 || region.width <= 0 || region.height <= 0) {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const safePadding = Math.max(0, Math.round(padding));
|
|
168
|
+
const x = clamp(Math.round(region.x - safePadding), 0, imageWidth);
|
|
169
|
+
const y = clamp(Math.round(region.y - safePadding), 0, imageHeight);
|
|
170
|
+
const right = clamp(Math.round(region.x + region.width + safePadding), 0, imageWidth);
|
|
171
|
+
const bottom = clamp(Math.round(region.y + region.height + safePadding), 0, imageHeight);
|
|
172
|
+
const width = right - x;
|
|
173
|
+
const height = bottom - y;
|
|
174
|
+
|
|
175
|
+
if (width < 4 || height < 4) return null;
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
x,
|
|
179
|
+
y,
|
|
180
|
+
width,
|
|
181
|
+
height,
|
|
182
|
+
inner: {
|
|
183
|
+
x: clamp(Math.round(region.x - x), 0, width),
|
|
184
|
+
y: clamp(Math.round(region.y - y), 0, height),
|
|
185
|
+
width: clamp(Math.round(region.width), 0, width),
|
|
186
|
+
height: clamp(Math.round(region.height), 0, height)
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function embedAllenkRoiWeights({ roiWeights, roiWidth, roiHeight, paddedRoi, blurSigma = 1 } = {}) {
|
|
192
|
+
if (!roiWeights || !paddedRoi?.inner || roiWidth <= 0 || roiHeight <= 0) {
|
|
193
|
+
return new Float32Array(0);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const weights = new Float32Array(paddedRoi.width * paddedRoi.height);
|
|
197
|
+
const inner = paddedRoi.inner;
|
|
198
|
+
|
|
199
|
+
for (let y = 0; y < roiHeight; y++) {
|
|
200
|
+
const py = inner.y + y;
|
|
201
|
+
if (py < 0 || py >= paddedRoi.height) continue;
|
|
202
|
+
|
|
203
|
+
for (let x = 0; x < roiWidth; x++) {
|
|
204
|
+
const px = inner.x + x;
|
|
205
|
+
if (px < 0 || px >= paddedRoi.width) continue;
|
|
206
|
+
weights[py * paddedRoi.width + px] = clamp(roiWeights[y * roiWidth + x] || 0, 0, 1);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return gaussianBlurFloatMap(weights, paddedRoi.width, paddedRoi.height, blurSigma);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function buildAllenkFdncnnInput({ imageData, sigma = ALLENK_FDNCNN_MODEL.defaultSigma } = {}) {
|
|
214
|
+
if (!imageData?.data || imageData.width <= 0 || imageData.height <= 0) {
|
|
215
|
+
return new Float32Array(0);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const { width, height, data } = imageData;
|
|
219
|
+
const pixelCount = width * height;
|
|
220
|
+
const input = new Float32Array(pixelCount * 4);
|
|
221
|
+
const sigmaNorm = clamp(sigma, 0, ALLENK_FDNCNN_MODEL.maxSigma) / 255;
|
|
222
|
+
const stride = data.length >= pixelCount * 4 ? 4 : 3;
|
|
223
|
+
|
|
224
|
+
for (let i = 0; i < pixelCount; i++) {
|
|
225
|
+
const src = i * stride;
|
|
226
|
+
input[i] = (data[src] || 0) / 255;
|
|
227
|
+
input[pixelCount + i] = (data[src + 1] || 0) / 255;
|
|
228
|
+
input[pixelCount * 2 + i] = (data[src + 2] || 0) / 255;
|
|
229
|
+
input[pixelCount * 3 + i] = sigmaNorm;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return input;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function convertAllenkFdncnnOutputToRgba({ output, width, height, alpha = 255 } = {}) {
|
|
236
|
+
if (!output || width <= 0 || height <= 0) {
|
|
237
|
+
return new Uint8ClampedArray(0);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const pixelCount = width * height;
|
|
241
|
+
const rgba = new Uint8ClampedArray(pixelCount * 4);
|
|
242
|
+
|
|
243
|
+
for (let i = 0; i < pixelCount; i++) {
|
|
244
|
+
rgba[i * 4] = Math.round(clamp(output[i] || 0, 0, 1) * 255);
|
|
245
|
+
rgba[i * 4 + 1] = Math.round(clamp(output[pixelCount + i] || 0, 0, 1) * 255);
|
|
246
|
+
rgba[i * 4 + 2] = Math.round(clamp(output[pixelCount * 2 + i] || 0, 0, 1) * 255);
|
|
247
|
+
rgba[i * 4 + 3] = alpha;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return rgba;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function getLocalMeanRgb(data, width, height, x, y, channel) {
|
|
254
|
+
let sum = 0;
|
|
255
|
+
let count = 0;
|
|
256
|
+
for (let dy = -1; dy <= 1; dy++) {
|
|
257
|
+
const yy = y + dy;
|
|
258
|
+
if (yy < 0 || yy >= height) continue;
|
|
259
|
+
for (let dx = -1; dx <= 1; dx++) {
|
|
260
|
+
const xx = x + dx;
|
|
261
|
+
if (xx < 0 || xx >= width) continue;
|
|
262
|
+
sum += data[(yy * width + xx) * 4 + channel];
|
|
263
|
+
count++;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return count > 0 ? sum / count : data[(y * width + x) * 4 + channel];
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function blendAllenkDenoisedRoi({
|
|
270
|
+
originalData,
|
|
271
|
+
denoisedData,
|
|
272
|
+
weights,
|
|
273
|
+
width = 0,
|
|
274
|
+
height = 0,
|
|
275
|
+
preserveHighpassStrength = 0
|
|
276
|
+
} = {}) {
|
|
277
|
+
if (!originalData || !denoisedData || !weights || originalData.length !== denoisedData.length) {
|
|
278
|
+
return new Uint8ClampedArray(originalData || 0);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const output = new Uint8ClampedArray(originalData);
|
|
282
|
+
const pixelCount = Math.min(weights.length, Math.floor(originalData.length / 4));
|
|
283
|
+
const canPreserveHighpass = (
|
|
284
|
+
Number.isFinite(preserveHighpassStrength) &&
|
|
285
|
+
preserveHighpassStrength > 0 &&
|
|
286
|
+
Number.isFinite(width) &&
|
|
287
|
+
Number.isFinite(height) &&
|
|
288
|
+
width > 0 &&
|
|
289
|
+
height > 0 &&
|
|
290
|
+
width * height <= pixelCount
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
for (let pixel = 0; pixel < pixelCount; pixel++) {
|
|
294
|
+
const weight = clamp(weights[pixel] || 0, 0, 1);
|
|
295
|
+
if (weight <= 0) continue;
|
|
296
|
+
|
|
297
|
+
const idx = pixel * 4;
|
|
298
|
+
const x = canPreserveHighpass ? pixel % width : 0;
|
|
299
|
+
const y = canPreserveHighpass ? Math.floor(pixel / width) : 0;
|
|
300
|
+
const highpassGain = canPreserveHighpass
|
|
301
|
+
? Math.min(0.28, weight * preserveHighpassStrength)
|
|
302
|
+
: 0;
|
|
303
|
+
for (let c = 0; c < 3; c++) {
|
|
304
|
+
const blended = (
|
|
305
|
+
originalData[idx + c] * (1 - weight) + denoisedData[idx + c] * weight
|
|
306
|
+
);
|
|
307
|
+
const highpass = highpassGain > 0
|
|
308
|
+
? clamp(originalData[idx + c] - getLocalMeanRgb(originalData, width, height, x, y, c), -14, 14)
|
|
309
|
+
: 0;
|
|
310
|
+
output[idx + c] = Math.round(clamp(blended + highpass * highpassGain, 0, 255));
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return output;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export {
|
|
318
|
+
ALLENK_FDNCNN_MODEL,
|
|
319
|
+
blendAllenkDenoisedRoi,
|
|
320
|
+
buildAllenkFdncnnInput,
|
|
321
|
+
calculateAllenkPaddedRoi,
|
|
322
|
+
convertAllenkFdncnnOutputToRgba,
|
|
323
|
+
createAllenkGradientMask,
|
|
324
|
+
embedAllenkRoiWeights,
|
|
325
|
+
normalizeAllenkFdncnnOptions
|
|
326
|
+
};
|