@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 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.20",
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 { inferMimeTypeFromPath, removeWatermarkFromFile } from '../sdk/node.js';
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 codec = await resolveCodecOptions(options).catch((error) => {
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: options.codec.decodeImageData,
309
- encodeImageData: options.codec.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 * Math.min(1, Math.sqrt(seedCandidate.size / 96))
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 * Math.min(1, Math.sqrt(size / 96));
428
- if (adjustedScore < 0.08) continue;
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
+ };