@pilio/gemini-watermark-remover 1.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +371 -0
  3. package/README_zh.md +371 -0
  4. package/bin/gwr.mjs +12 -0
  5. package/package.json +76 -0
  6. package/skills/gemini-watermark-remover/SKILL.md +28 -0
  7. package/skills/gemini-watermark-remover/agents/openai.yaml +3 -0
  8. package/skills/gemini-watermark-remover/references/inputs-and-outputs.md +9 -0
  9. package/skills/gemini-watermark-remover/references/limitations.md +5 -0
  10. package/skills/gemini-watermark-remover/references/usage.md +19 -0
  11. package/skills/gemini-watermark-remover/scripts/run.mjs +153 -0
  12. package/src/cli/gwrCli.js +17 -0
  13. package/src/cli/gwrRemoveCommand.js +317 -0
  14. package/src/core/adaptiveDetector.js +488 -0
  15. package/src/core/alphaMap.js +30 -0
  16. package/src/core/blendModes.js +70 -0
  17. package/src/core/candidateSelector.js +1446 -0
  18. package/src/core/canvasBlob.js +26 -0
  19. package/src/core/embeddedAlphaMaps.js +49 -0
  20. package/src/core/geminiSizeCatalog.js +239 -0
  21. package/src/core/multiPassRemoval.js +93 -0
  22. package/src/core/previewAlphaCalibration.js +822 -0
  23. package/src/core/restorationMetrics.js +251 -0
  24. package/src/core/selectionDebug.js +47 -0
  25. package/src/core/watermarkConfig.js +146 -0
  26. package/src/core/watermarkDecisionPolicy.js +163 -0
  27. package/src/core/watermarkDisplay.js +60 -0
  28. package/src/core/watermarkEngine.js +150 -0
  29. package/src/core/watermarkPresence.js +12 -0
  30. package/src/core/watermarkProcessor.js +875 -0
  31. package/src/core/workerClient.js +114 -0
  32. package/src/sdk/browser.d.ts +13 -0
  33. package/src/sdk/browser.js +29 -0
  34. package/src/sdk/image-data.d.ts +14 -0
  35. package/src/sdk/image-data.js +55 -0
  36. package/src/sdk/index.d.ts +144 -0
  37. package/src/sdk/index.js +8 -0
  38. package/src/sdk/node.d.ts +42 -0
  39. package/src/sdk/node.js +77 -0
@@ -0,0 +1,822 @@
1
+ import { warpAlphaMap } from './adaptiveDetector.js';
2
+ import { removeWatermark } from './blendModes.js';
3
+
4
+ function clamp01(value) {
5
+ if (!Number.isFinite(value)) return 0;
6
+ if (value <= 0) return 0;
7
+ if (value >= 1) return 1;
8
+ return value;
9
+ }
10
+
11
+ function resolveChannelAlpha(original, preview) {
12
+ const denominator = 255 - original;
13
+ if (!Number.isFinite(denominator) || denominator <= 0) {
14
+ return 0;
15
+ }
16
+
17
+ return clamp01((preview - original) / denominator);
18
+ }
19
+
20
+ export function estimatePreviewAlphaMap({
21
+ sourceImageData,
22
+ previewImageData,
23
+ position
24
+ }) {
25
+ if (!sourceImageData || !previewImageData || !position) {
26
+ throw new TypeError('estimatePreviewAlphaMap requires sourceImageData, previewImageData, and position');
27
+ }
28
+ if (sourceImageData.width !== previewImageData.width || sourceImageData.height !== previewImageData.height) {
29
+ throw new RangeError('sourceImageData and previewImageData must have identical dimensions');
30
+ }
31
+
32
+ const { x, y, width, height } = position;
33
+ if (![x, y, width, height].every((value) => Number.isInteger(value) && value >= 0)) {
34
+ throw new RangeError('position must contain non-negative integer bounds');
35
+ }
36
+
37
+ const alphaMap = new Float32Array(width * height);
38
+ for (let row = 0; row < height; row++) {
39
+ for (let col = 0; col < width; col++) {
40
+ const idx = ((y + row) * sourceImageData.width + (x + col)) * 4;
41
+ const r = resolveChannelAlpha(sourceImageData.data[idx], previewImageData.data[idx]);
42
+ const g = resolveChannelAlpha(sourceImageData.data[idx + 1], previewImageData.data[idx + 1]);
43
+ const b = resolveChannelAlpha(sourceImageData.data[idx + 2], previewImageData.data[idx + 2]);
44
+
45
+ alphaMap[row * width + col] = clamp01(Math.max(r, g, b));
46
+ }
47
+ }
48
+
49
+ return alphaMap;
50
+ }
51
+
52
+ export function aggregatePreviewAlphaMaps(alphaMaps) {
53
+ if (!Array.isArray(alphaMaps) || alphaMaps.length === 0) {
54
+ throw new TypeError('aggregatePreviewAlphaMaps requires at least one alpha map');
55
+ }
56
+
57
+ const expectedLength = alphaMaps[0]?.length;
58
+ if (!Number.isInteger(expectedLength) || expectedLength <= 0) {
59
+ throw new TypeError('alpha maps must be typed arrays with a positive length');
60
+ }
61
+
62
+ for (const alphaMap of alphaMaps) {
63
+ if (!alphaMap || alphaMap.length !== expectedLength) {
64
+ throw new RangeError('all alpha maps must have identical lengths');
65
+ }
66
+ }
67
+
68
+ const aggregated = new Float32Array(expectedLength);
69
+ for (let i = 0; i < expectedLength; i++) {
70
+ const values = alphaMaps
71
+ .map((alphaMap) => clamp01(alphaMap[i]))
72
+ .sort((left, right) => left - right);
73
+ const middle = Math.floor(values.length / 2);
74
+
75
+ aggregated[i] = values.length % 2 === 1
76
+ ? values[middle]
77
+ : (values[middle - 1] + values[middle]) / 2;
78
+ }
79
+
80
+ return aggregated;
81
+ }
82
+
83
+ export function blurAlphaMap(alphaMap, size, radius = 0) {
84
+ const blurPasses = Number.isInteger(radius) ? radius : Math.max(0, Math.round(radius || 0));
85
+ if (blurPasses <= 0 || size <= 0) {
86
+ return new Float32Array(alphaMap);
87
+ }
88
+
89
+ let current = new Float32Array(alphaMap);
90
+ for (let pass = 0; pass < blurPasses; pass++) {
91
+ const next = new Float32Array(current.length);
92
+ for (let y = 0; y < size; y++) {
93
+ for (let x = 0; x < size; x++) {
94
+ let sum = 0;
95
+ let weight = 0;
96
+ for (let dy = -1; dy <= 1; dy++) {
97
+ for (let dx = -1; dx <= 1; dx++) {
98
+ const xx = x + dx;
99
+ const yy = y + dy;
100
+ if (xx < 0 || yy < 0 || xx >= size || yy >= size) continue;
101
+ const w = dx === 0 && dy === 0 ? 4 : (dx === 0 || dy === 0 ? 2 : 1);
102
+ sum += current[yy * size + xx] * w;
103
+ weight += w;
104
+ }
105
+ }
106
+ next[y * size + x] = clamp01(sum / Math.max(1, weight));
107
+ }
108
+ }
109
+ current = next;
110
+ }
111
+
112
+ return current;
113
+ }
114
+
115
+ function cloneImageData(imageData) {
116
+ return {
117
+ width: imageData.width,
118
+ height: imageData.height,
119
+ data: new Uint8ClampedArray(imageData.data)
120
+ };
121
+ }
122
+
123
+ function clampChannel(value) {
124
+ if (!Number.isFinite(value)) return 0;
125
+ if (value <= 0) return 0;
126
+ if (value >= 255) return 255;
127
+ return Math.round(value);
128
+ }
129
+
130
+ function applyPreviewWatermark(imageData, alphaMap, position, alphaGain = 1) {
131
+ for (let row = 0; row < position.height; row++) {
132
+ for (let col = 0; col < position.width; col++) {
133
+ const alpha = clamp01(alphaMap[row * position.width + col] * alphaGain);
134
+ if (alpha <= 0.001) continue;
135
+
136
+ const idx = ((position.y + row) * imageData.width + (position.x + col)) * 4;
137
+ for (let channel = 0; channel < 3; channel++) {
138
+ const original = imageData.data[idx + channel];
139
+ imageData.data[idx + channel] = clampChannel(alpha * 255 + (1 - alpha) * original);
140
+ }
141
+ }
142
+ }
143
+ }
144
+
145
+ function blurImageDataRegionOnce(imageData, position) {
146
+ const blurred = cloneImageData(imageData);
147
+
148
+ for (let row = 0; row < position.height; row++) {
149
+ for (let col = 0; col < position.width; col++) {
150
+ let sumR = 0;
151
+ let sumG = 0;
152
+ let sumB = 0;
153
+ let weight = 0;
154
+
155
+ for (let dy = -1; dy <= 1; dy++) {
156
+ for (let dx = -1; dx <= 1; dx++) {
157
+ const localX = Math.max(0, Math.min(position.width - 1, col + dx));
158
+ const localY = Math.max(0, Math.min(position.height - 1, row + dy));
159
+ const idx = ((position.y + localY) * imageData.width + (position.x + localX)) * 4;
160
+ const w = dx === 0 && dy === 0 ? 4 : (dx === 0 || dy === 0 ? 2 : 1);
161
+ sumR += imageData.data[idx] * w;
162
+ sumG += imageData.data[idx + 1] * w;
163
+ sumB += imageData.data[idx + 2] * w;
164
+ weight += w;
165
+ }
166
+ }
167
+
168
+ const outIdx = ((position.y + row) * blurred.width + (position.x + col)) * 4;
169
+ blurred.data[outIdx] = clampChannel(sumR / weight);
170
+ blurred.data[outIdx + 1] = clampChannel(sumG / weight);
171
+ blurred.data[outIdx + 2] = clampChannel(sumB / weight);
172
+ }
173
+ }
174
+
175
+ return blurred;
176
+ }
177
+
178
+ function blurImageDataRegion(imageData, position, radius = 0) {
179
+ const blurPasses = Number.isInteger(radius) ? radius : Math.max(0, Math.round(radius || 0));
180
+ if (blurPasses <= 0) {
181
+ return cloneImageData(imageData);
182
+ }
183
+
184
+ let current = cloneImageData(imageData);
185
+ for (let pass = 0; pass < blurPasses; pass++) {
186
+ current = blurImageDataRegionOnce(current, position);
187
+ }
188
+
189
+ return current;
190
+ }
191
+
192
+ function averageStripColor(imageData, {
193
+ xFrom,
194
+ xTo,
195
+ yFrom,
196
+ yTo
197
+ }) {
198
+ let sumR = 0;
199
+ let sumG = 0;
200
+ let sumB = 0;
201
+ let count = 0;
202
+
203
+ const minX = Math.max(0, Math.min(xFrom, xTo));
204
+ const maxX = Math.min(imageData.width - 1, Math.max(xFrom, xTo));
205
+ const minY = Math.max(0, Math.min(yFrom, yTo));
206
+ const maxY = Math.min(imageData.height - 1, Math.max(yFrom, yTo));
207
+
208
+ for (let y = minY; y <= maxY; y++) {
209
+ for (let x = minX; x <= maxX; x++) {
210
+ const idx = (y * imageData.width + x) * 4;
211
+ sumR += imageData.data[idx];
212
+ sumG += imageData.data[idx + 1];
213
+ sumB += imageData.data[idx + 2];
214
+ count++;
215
+ }
216
+ }
217
+
218
+ if (count <= 0) {
219
+ return [0, 0, 0];
220
+ }
221
+
222
+ return [sumR / count, sumG / count, sumB / count];
223
+ }
224
+
225
+ function lerpColor(left, right, t) {
226
+ return [
227
+ left[0] * (1 - t) + right[0] * t,
228
+ left[1] * (1 - t) + right[1] * t,
229
+ left[2] * (1 - t) + right[2] * t
230
+ ];
231
+ }
232
+
233
+ function measureRegionAbsDelta(candidateImageData, targetImageData, position) {
234
+ let total = 0;
235
+ let count = 0;
236
+ for (let row = 0; row < position.height; row++) {
237
+ for (let col = 0; col < position.width; col++) {
238
+ const idx = ((position.y + row) * candidateImageData.width + (position.x + col)) * 4;
239
+ for (let channel = 0; channel < 3; channel++) {
240
+ total += Math.abs(candidateImageData.data[idx + channel] - targetImageData.data[idx + channel]);
241
+ count++;
242
+ }
243
+ }
244
+ }
245
+
246
+ return count > 0 ? total / count : 0;
247
+ }
248
+
249
+ function averageSampleColor(imageData, samples) {
250
+ let sumR = 0;
251
+ let sumG = 0;
252
+ let sumB = 0;
253
+ let count = 0;
254
+
255
+ for (const [x, y] of samples) {
256
+ if (x < 0 || y < 0 || x >= imageData.width || y >= imageData.height) {
257
+ continue;
258
+ }
259
+ const idx = (y * imageData.width + x) * 4;
260
+ sumR += imageData.data[idx];
261
+ sumG += imageData.data[idx + 1];
262
+ sumB += imageData.data[idx + 2];
263
+ count++;
264
+ }
265
+
266
+ if (count <= 0) {
267
+ return [0, 0, 0];
268
+ }
269
+
270
+ return [sumR / count, sumG / count, sumB / count];
271
+ }
272
+
273
+ export function measurePreviewBoundaryMetrics(candidateImageData, previewImageData, position) {
274
+ let rawTotal = 0;
275
+ let previewBoundaryTotal = 0;
276
+ let localContrastTotal = 0;
277
+ let count = 0;
278
+
279
+ const compareBoundaryPixel = (x, y, insideSamples, outsideSamples) => {
280
+ const idx = (y * candidateImageData.width + x) * 4;
281
+ const previewIdx = (y * previewImageData.width + x) * 4;
282
+ const inside = averageSampleColor(previewImageData, insideSamples);
283
+ const outside = averageSampleColor(previewImageData, outsideSamples);
284
+
285
+ for (let channel = 0; channel < 3; channel++) {
286
+ rawTotal += Math.abs(candidateImageData.data[idx + channel] - outside[channel]);
287
+ previewBoundaryTotal += Math.abs(previewImageData.data[previewIdx + channel] - outside[channel]);
288
+ localContrastTotal += Math.abs(inside[channel] - outside[channel]);
289
+ count++;
290
+ }
291
+ };
292
+
293
+ for (let col = 0; col < position.width; col++) {
294
+ const x = position.x + col;
295
+ compareBoundaryPixel(
296
+ x,
297
+ position.y,
298
+ [
299
+ [x - 1, position.y],
300
+ [x, position.y],
301
+ [x + 1, position.y]
302
+ ],
303
+ [
304
+ [x - 1, position.y - 1],
305
+ [x, position.y - 1],
306
+ [x + 1, position.y - 1]
307
+ ]
308
+ );
309
+ compareBoundaryPixel(
310
+ x,
311
+ position.y + position.height - 1,
312
+ [
313
+ [x - 1, position.y + position.height - 1],
314
+ [x, position.y + position.height - 1],
315
+ [x + 1, position.y + position.height - 1]
316
+ ],
317
+ [
318
+ [x - 1, position.y + position.height],
319
+ [x, position.y + position.height],
320
+ [x + 1, position.y + position.height]
321
+ ]
322
+ );
323
+ }
324
+
325
+ for (let row = 1; row < position.height - 1; row++) {
326
+ const y = position.y + row;
327
+ compareBoundaryPixel(
328
+ position.x,
329
+ y,
330
+ [
331
+ [position.x, y - 1],
332
+ [position.x, y],
333
+ [position.x, y + 1]
334
+ ],
335
+ [
336
+ [position.x - 1, y - 1],
337
+ [position.x - 1, y],
338
+ [position.x - 1, y + 1]
339
+ ]
340
+ );
341
+ compareBoundaryPixel(
342
+ position.x + position.width - 1,
343
+ y,
344
+ [
345
+ [position.x + position.width - 1, y - 1],
346
+ [position.x + position.width - 1, y],
347
+ [position.x + position.width - 1, y + 1]
348
+ ],
349
+ [
350
+ [position.x + position.width, y - 1],
351
+ [position.x + position.width, y],
352
+ [position.x + position.width, y + 1]
353
+ ]
354
+ );
355
+ }
356
+
357
+ const rawScore = count > 0 ? rawTotal / count : 0;
358
+ const previewBoundaryScore = count > 0 ? previewBoundaryTotal / count : 0;
359
+ const localContrastScore = count > 0 ? localContrastTotal / count : 0;
360
+ const normalizer = Math.max(1, previewBoundaryScore, localContrastScore);
361
+
362
+ return {
363
+ rawScore,
364
+ previewBoundaryScore,
365
+ localContrastScore,
366
+ normalizer,
367
+ normalizedScore: rawScore / normalizer
368
+ };
369
+ }
370
+
371
+ function measurePreviewBoundaryContinuity(candidateImageData, previewImageData, position) {
372
+ return measurePreviewBoundaryMetrics(candidateImageData, previewImageData, position).normalizedScore;
373
+ }
374
+
375
+ export function buildPreviewNeighborhoodPrior({
376
+ previewImageData,
377
+ position,
378
+ radius = 6
379
+ }) {
380
+ if (!previewImageData || !position) {
381
+ throw new TypeError('buildPreviewNeighborhoodPrior requires previewImageData and position');
382
+ }
383
+
384
+ const stripRadius = Math.max(1, Math.round(radius || 1));
385
+ const prior = cloneImageData(previewImageData);
386
+ const leftBoundary = [];
387
+ const rightBoundary = [];
388
+ const topBoundary = [];
389
+ const bottomBoundary = [];
390
+
391
+ for (let row = 0; row < position.height; row++) {
392
+ const y = position.y + row;
393
+ leftBoundary.push(averageStripColor(previewImageData, {
394
+ xFrom: position.x - stripRadius,
395
+ xTo: position.x - 1,
396
+ yFrom: y - 1,
397
+ yTo: y + 1
398
+ }));
399
+ rightBoundary.push(averageStripColor(previewImageData, {
400
+ xFrom: position.x + position.width,
401
+ xTo: position.x + position.width + stripRadius - 1,
402
+ yFrom: y - 1,
403
+ yTo: y + 1
404
+ }));
405
+ }
406
+
407
+ for (let col = 0; col < position.width; col++) {
408
+ const x = position.x + col;
409
+ topBoundary.push(averageStripColor(previewImageData, {
410
+ xFrom: x - 1,
411
+ xTo: x + 1,
412
+ yFrom: position.y - stripRadius,
413
+ yTo: position.y - 1
414
+ }));
415
+ bottomBoundary.push(averageStripColor(previewImageData, {
416
+ xFrom: x - 1,
417
+ xTo: x + 1,
418
+ yFrom: position.y + position.height,
419
+ yTo: position.y + position.height + stripRadius - 1
420
+ }));
421
+ }
422
+
423
+ for (let row = 0; row < position.height; row++) {
424
+ const ty = position.height <= 1 ? 0.5 : row / (position.height - 1);
425
+ for (let col = 0; col < position.width; col++) {
426
+ const tx = position.width <= 1 ? 0.5 : col / (position.width - 1);
427
+ const horizontal = lerpColor(leftBoundary[row], rightBoundary[row], tx);
428
+ const vertical = lerpColor(topBoundary[col], bottomBoundary[col], ty);
429
+ const idx = ((position.y + row) * prior.width + (position.x + col)) * 4;
430
+ prior.data[idx] = clampChannel((horizontal[0] + vertical[0]) * 0.5);
431
+ prior.data[idx + 1] = clampChannel((horizontal[1] + vertical[1]) * 0.5);
432
+ prior.data[idx + 2] = clampChannel((horizontal[2] + vertical[2]) * 0.5);
433
+ }
434
+ }
435
+
436
+ if (position.width <= 1 || position.height <= 1) {
437
+ return prior;
438
+ }
439
+
440
+ const relaxationPasses = Math.max(24, Math.round((position.width + position.height) * 2));
441
+ for (let pass = 0; pass < relaxationPasses; pass++) {
442
+ for (let row = 0; row < position.height; row++) {
443
+ const y = position.y + row;
444
+ for (let col = 0; col < position.width; col++) {
445
+ const x = position.x + col;
446
+ const idx = (y * prior.width + x) * 4;
447
+ for (let channel = 0; channel < 3; channel++) {
448
+ let sum = 0;
449
+ let weight = 0;
450
+ const neighbors = [
451
+ [x - 1, y, 1],
452
+ [x + 1, y, 1],
453
+ [x, y - 1, 1],
454
+ [x, y + 1, 1],
455
+ [x - 1, y - 1, 0.5],
456
+ [x + 1, y - 1, 0.5],
457
+ [x - 1, y + 1, 0.5],
458
+ [x + 1, y + 1, 0.5]
459
+ ];
460
+
461
+ for (const [neighborX, neighborY, neighborWeight] of neighbors) {
462
+ if (
463
+ neighborX < 0 ||
464
+ neighborY < 0 ||
465
+ neighborX >= prior.width ||
466
+ neighborY >= prior.height
467
+ ) {
468
+ continue;
469
+ }
470
+
471
+ const neighborIdx = (neighborY * prior.width + neighborX) * 4;
472
+ sum += prior.data[neighborIdx + channel] * neighborWeight;
473
+ weight += neighborWeight;
474
+ }
475
+
476
+ prior.data[idx + channel] = clampChannel(sum / Math.max(1, weight));
477
+ }
478
+ }
479
+ }
480
+ }
481
+
482
+ return prior;
483
+ }
484
+
485
+ export function renderPreviewWatermarkObservation({
486
+ sourceImageData,
487
+ alphaMap,
488
+ position,
489
+ alphaGain = 1,
490
+ compositeBlurRadius = 0
491
+ }) {
492
+ if (!sourceImageData || !alphaMap || !position) {
493
+ throw new TypeError('renderPreviewWatermarkObservation requires sourceImageData, alphaMap, and position');
494
+ }
495
+
496
+ const rendered = cloneImageData(sourceImageData);
497
+ applyPreviewWatermark(rendered, alphaMap, position, alphaGain);
498
+ return blurImageDataRegion(rendered, position, compositeBlurRadius);
499
+ }
500
+
501
+ export function fitConstrainedPreviewAlphaModel({
502
+ sourceImageData,
503
+ previewImageData,
504
+ standardAlphaMap,
505
+ position,
506
+ shiftCandidates = [-0.5, 0, 0.5],
507
+ scaleCandidates = [0.99, 1, 1.01],
508
+ blurRadii = [0, 1],
509
+ alphaGainCandidates = [1]
510
+ }) {
511
+ if (!sourceImageData || !previewImageData || !standardAlphaMap || !position) {
512
+ throw new TypeError('fitConstrainedPreviewAlphaModel requires sourceImageData, previewImageData, standardAlphaMap, and position');
513
+ }
514
+
515
+ const size = position.width;
516
+ if (!size || size !== position.height || standardAlphaMap.length !== size * size) {
517
+ throw new RangeError('fitConstrainedPreviewAlphaModel requires a square ROI and matching standardAlphaMap size');
518
+ }
519
+
520
+ let best = null;
521
+ for (const scale of scaleCandidates) {
522
+ for (const dy of shiftCandidates) {
523
+ for (const dx of shiftCandidates) {
524
+ const warped = warpAlphaMap(standardAlphaMap, size, { dx, dy, scale });
525
+ for (const blurRadius of blurRadii) {
526
+ const alphaMap = blurAlphaMap(warped, size, blurRadius);
527
+ for (const alphaGain of alphaGainCandidates) {
528
+ const restored = cloneImageData(previewImageData);
529
+ removeWatermark(restored, alphaMap, position, { alphaGain });
530
+ const score = measureRegionAbsDelta(restored, sourceImageData, position);
531
+
532
+ if (!best || score < best.score) {
533
+ best = {
534
+ alphaMap,
535
+ alphaGain,
536
+ params: {
537
+ shift: { dx, dy, scale },
538
+ blurRadius
539
+ },
540
+ score
541
+ };
542
+ }
543
+ }
544
+ }
545
+ }
546
+ }
547
+ }
548
+
549
+ return best;
550
+ }
551
+
552
+ export function fitPreviewRenderModel({
553
+ sourceImageData,
554
+ previewImageData,
555
+ standardAlphaMap,
556
+ position,
557
+ shiftCandidates = [-0.5, 0, 0.5],
558
+ scaleCandidates = [0.99, 1, 1.01],
559
+ alphaBlurRadii = [0, 1],
560
+ compositeBlurRadii = [0, 1],
561
+ alphaGainCandidates = [1]
562
+ }) {
563
+ if (!sourceImageData || !previewImageData || !standardAlphaMap || !position) {
564
+ throw new TypeError('fitPreviewRenderModel requires sourceImageData, previewImageData, standardAlphaMap, and position');
565
+ }
566
+
567
+ const size = position.width;
568
+ if (!size || size !== position.height || standardAlphaMap.length !== size * size) {
569
+ throw new RangeError('fitPreviewRenderModel requires a square ROI and matching standardAlphaMap size');
570
+ }
571
+
572
+ let best = null;
573
+ for (const scale of scaleCandidates) {
574
+ for (const dy of shiftCandidates) {
575
+ for (const dx of shiftCandidates) {
576
+ const warped = warpAlphaMap(standardAlphaMap, size, { dx, dy, scale });
577
+ for (const alphaBlurRadius of alphaBlurRadii) {
578
+ const alphaMap = blurAlphaMap(warped, size, alphaBlurRadius);
579
+ for (const compositeBlurRadius of compositeBlurRadii) {
580
+ for (const alphaGain of alphaGainCandidates) {
581
+ const rendered = renderPreviewWatermarkObservation({
582
+ sourceImageData,
583
+ alphaMap,
584
+ position,
585
+ alphaGain,
586
+ compositeBlurRadius
587
+ });
588
+ const score = measureRegionAbsDelta(rendered, previewImageData, position);
589
+
590
+ if (!best || score < best.score) {
591
+ best = {
592
+ alphaMap,
593
+ alphaGain,
594
+ params: {
595
+ shift: { dx, dy, scale },
596
+ alphaBlurRadius,
597
+ compositeBlurRadius
598
+ },
599
+ score
600
+ };
601
+ }
602
+ }
603
+ }
604
+ }
605
+ }
606
+ }
607
+ }
608
+
609
+ return best;
610
+ }
611
+
612
+ export function fitPreviewOnlyRenderModel({
613
+ previewImageData,
614
+ standardAlphaMap,
615
+ position,
616
+ shiftCandidates = [-0.5, 0, 0.5],
617
+ scaleCandidates = [0.99, 1, 1.01],
618
+ alphaBlurRadii = [0, 1],
619
+ compositeBlurRadii = [0, 1],
620
+ alphaGainCandidates = [1],
621
+ blendStrengthCandidates = [0.85],
622
+ priorRadiusCandidates = null,
623
+ priorRadius = 6,
624
+ boundaryContinuityWeight = 0
625
+ }) {
626
+ if (!previewImageData || !standardAlphaMap || !position) {
627
+ throw new TypeError('fitPreviewOnlyRenderModel requires previewImageData, standardAlphaMap, and position');
628
+ }
629
+
630
+ const size = position.width;
631
+ if (!size || size !== position.height || standardAlphaMap.length !== size * size) {
632
+ throw new RangeError('fitPreviewOnlyRenderModel requires a square ROI and matching standardAlphaMap size');
633
+ }
634
+
635
+ const resolvedPriorRadiusCandidates = Array.isArray(priorRadiusCandidates) && priorRadiusCandidates.length > 0
636
+ ? priorRadiusCandidates
637
+ : [priorRadius];
638
+
639
+ const alphaCandidates = [];
640
+ for (const candidatePriorRadius of resolvedPriorRadiusCandidates) {
641
+ const priorImageData = buildPreviewNeighborhoodPrior({
642
+ previewImageData,
643
+ position,
644
+ radius: candidatePriorRadius
645
+ });
646
+
647
+ let alphaBestForRadius = null;
648
+ for (const scale of scaleCandidates) {
649
+ for (const dy of shiftCandidates) {
650
+ for (const dx of shiftCandidates) {
651
+ const warped = warpAlphaMap(standardAlphaMap, size, { dx, dy, scale });
652
+ for (const alphaBlurRadius of alphaBlurRadii) {
653
+ const alphaMap = blurAlphaMap(warped, size, alphaBlurRadius);
654
+ for (const compositeBlurRadius of compositeBlurRadii) {
655
+ for (const alphaGain of alphaGainCandidates) {
656
+ const rendered = renderPreviewWatermarkObservation({
657
+ sourceImageData: priorImageData,
658
+ alphaMap,
659
+ position,
660
+ alphaGain,
661
+ compositeBlurRadius
662
+ });
663
+ const forwardScore = measureRegionAbsDelta(rendered, previewImageData, position);
664
+
665
+ if (!alphaBestForRadius || forwardScore < alphaBestForRadius.forwardScore) {
666
+ alphaBestForRadius = {
667
+ alphaMap,
668
+ alphaGain,
669
+ priorImageData,
670
+ params: {
671
+ shift: { dx, dy, scale },
672
+ alphaBlurRadius,
673
+ compositeBlurRadius,
674
+ priorRadius: candidatePriorRadius
675
+ },
676
+ forwardScore
677
+ };
678
+ }
679
+ }
680
+ }
681
+ }
682
+ }
683
+ }
684
+ }
685
+
686
+ if (alphaBestForRadius) {
687
+ alphaCandidates.push(alphaBestForRadius);
688
+ }
689
+ }
690
+
691
+ let best = null;
692
+ for (const alphaCandidate of alphaCandidates) {
693
+ for (const blendStrength of blendStrengthCandidates) {
694
+ const restored = restorePreviewRegionWithNeighborhoodPrior({
695
+ previewImageData,
696
+ alphaMap: alphaCandidate.alphaMap,
697
+ position,
698
+ alphaGain: alphaCandidate.alphaGain,
699
+ priorImageData: alphaCandidate.priorImageData,
700
+ blendStrength
701
+ });
702
+ const rerendered = renderPreviewWatermarkObservation({
703
+ sourceImageData: restored,
704
+ alphaMap: alphaCandidate.alphaMap,
705
+ position,
706
+ alphaGain: alphaCandidate.alphaGain,
707
+ compositeBlurRadius: alphaCandidate.params.compositeBlurRadius
708
+ });
709
+ const inverseScore = measureRegionAbsDelta(rerendered, previewImageData, position);
710
+ const boundaryMetrics = boundaryContinuityWeight > 0
711
+ ? measurePreviewBoundaryMetrics(restored, previewImageData, position)
712
+ : {
713
+ rawScore: 0,
714
+ previewBoundaryScore: 0,
715
+ localContrastScore: 0,
716
+ normalizer: 1,
717
+ normalizedScore: 0
718
+ };
719
+ const boundaryScore = boundaryMetrics.normalizedScore;
720
+ const score = inverseScore + boundaryScore * boundaryContinuityWeight;
721
+
722
+ if (!best || score < best.score) {
723
+ best = {
724
+ alphaMap: alphaCandidate.alphaMap,
725
+ alphaGain: alphaCandidate.alphaGain,
726
+ priorImageData: alphaCandidate.priorImageData,
727
+ params: {
728
+ ...alphaCandidate.params,
729
+ blendStrength
730
+ },
731
+ score,
732
+ diagnostics: {
733
+ forwardScore: alphaCandidate.forwardScore,
734
+ inverseScore,
735
+ boundaryScore,
736
+ boundaryRawScore: boundaryMetrics.rawScore,
737
+ boundaryPreviewScore: boundaryMetrics.previewBoundaryScore,
738
+ boundaryContrastScore: boundaryMetrics.localContrastScore,
739
+ boundaryNormalizer: boundaryMetrics.normalizer
740
+ }
741
+ };
742
+ }
743
+ }
744
+ }
745
+
746
+ return best;
747
+ }
748
+
749
+ export function restorePreviewRegionWithRenderModel({
750
+ previewImageData,
751
+ alphaMap,
752
+ position,
753
+ alphaGain = 1,
754
+ compositeBlurRadius = 0,
755
+ iterations = 12,
756
+ stepSize = 0.85
757
+ }) {
758
+ if (!previewImageData || !alphaMap || !position) {
759
+ throw new TypeError('restorePreviewRegionWithRenderModel requires previewImageData, alphaMap, and position');
760
+ }
761
+
762
+ let deblurred = cloneImageData(previewImageData);
763
+ const totalIterations = Math.max(0, Math.round(iterations || 0));
764
+ const resolvedStepSize = Number.isFinite(stepSize) ? stepSize : 0.85;
765
+
766
+ if (compositeBlurRadius > 0 && totalIterations > 0) {
767
+ for (let iteration = 0; iteration < totalIterations; iteration++) {
768
+ const reblurred = blurImageDataRegion(deblurred, position, compositeBlurRadius);
769
+
770
+ for (let row = 0; row < position.height; row++) {
771
+ for (let col = 0; col < position.width; col++) {
772
+ const idx = ((position.y + row) * deblurred.width + (position.x + col)) * 4;
773
+ for (let channel = 0; channel < 3; channel++) {
774
+ const error = previewImageData.data[idx + channel] - reblurred.data[idx + channel];
775
+ deblurred.data[idx + channel] = clampChannel(
776
+ deblurred.data[idx + channel] + error * resolvedStepSize
777
+ );
778
+ }
779
+ }
780
+ }
781
+ }
782
+ }
783
+
784
+ const restored = cloneImageData(deblurred);
785
+ removeWatermark(restored, alphaMap, position, { alphaGain });
786
+ return restored;
787
+ }
788
+
789
+ export function restorePreviewRegionWithNeighborhoodPrior({
790
+ previewImageData,
791
+ alphaMap,
792
+ position,
793
+ alphaGain = 1,
794
+ priorImageData,
795
+ blendStrength = 0.85
796
+ }) {
797
+ if (!previewImageData || !alphaMap || !position || !priorImageData) {
798
+ throw new TypeError('restorePreviewRegionWithNeighborhoodPrior requires previewImageData, alphaMap, position, and priorImageData');
799
+ }
800
+
801
+ const restored = cloneImageData(previewImageData);
802
+ removeWatermark(restored, alphaMap, position, { alphaGain });
803
+
804
+ const resolvedBlendStrength = Number.isFinite(blendStrength) ? blendStrength : 0.85;
805
+ for (let row = 0; row < position.height; row++) {
806
+ for (let col = 0; col < position.width; col++) {
807
+ const alpha = clamp01(alphaMap[row * position.width + col] * alphaGain);
808
+ if (alpha <= 0.001) continue;
809
+
810
+ const blend = Math.max(0, Math.min(1, Math.sqrt(alpha) * resolvedBlendStrength));
811
+ const idx = ((position.y + row) * restored.width + (position.x + col)) * 4;
812
+ for (let channel = 0; channel < 3; channel++) {
813
+ restored.data[idx + channel] = clampChannel(
814
+ restored.data[idx + channel] * (1 - blend) +
815
+ priorImageData.data[idx + channel] * blend
816
+ );
817
+ }
818
+ }
819
+ }
820
+
821
+ return restored;
822
+ }