feat(auto): adaptive detail-targeting polish + --adaptive-polish flag

The fixed mild auto polish (unsharp 0.5 / grain 2.0) under-corrected soft
photo/face output (gemini_3 stayed at lap-var 84 vs its 592 original) and its
grain speckled small text. Replace it with humanizer.adaptive_polish: target the
input's Laplacian variance with a capped unsharp scaled to the deficit + edge-
masked grain (smooth regions only), calibrated by a short sigma search. Self-
limiting on text/graphics -- already high-frequency, so almost no polish lands
and text edges are masked out. Validated on the spaces corpus (gemini_3 84 -> 334
end-to-end; openai_1 text near-untouched).

Interface: every --auto decision is now independently overridable -- add
--adaptive-polish/--no-adaptive-polish (matching --restore-faces; works without
--auto too) so the polish can be disabled or used manually. _apply_auto overrides
exactly the three content-adaptive modes (pipeline, restore-faces, adaptive-
polish); --unsharp/--humanize stay independent fixed filters.

cv2-only, no new deps. Threaded through invisible/all (not batch).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Victor Kuznetsov
2026-06-03 21:49:08 -07:00
co-authored by Claude Opus 4.8
parent 9bd2c17cc4
commit b686dbdd79
8 changed files with 223 additions and 44 deletions
@@ -141,6 +141,7 @@ class InvisibleEngine:
restore_faces: bool = False,
restore_faces_weight: float = 0.5,
unsharp: float = 0.0,
adaptive_polish: bool = False,
) -> Path:
"""Remove invisible watermark from an image.
@@ -163,6 +164,12 @@ class InvisibleEngine:
Applied last (after face restoration) to counter the soft,
over-smoothed look of the diffusion/GFPGAN passes; ~0.5-0.8 is a
safe range, higher risks edge halos.
adaptive_polish: When True (the --auto mode default), restore the input's
detail level in the softened output instead of fixed unsharp/humanize:
a capped unsharp + edge-masked grain targeting the input's Laplacian
variance (self-limiting on text/graphics). Runs LAST, after face
restoration. The fixed ``humanize``/``unsharp`` knobs are normally 0
when this is on.
max_resolution: Cap the long side (px) before diffusion. 0 (default)
= no cap. Set a positive value only to bound GPU/MPS memory on
very large inputs (it reintroduces a lossy downscale->upscale
@@ -189,6 +196,9 @@ class InvisibleEngine:
image = Image.open(image_path)
image = ImageOps.exif_transpose(image)
orig_size = image.size # (width, height)
# Full-res original, kept for the adaptive-polish detail target (image is
# reassigned to the resized copy below; PIL resize returns a new object).
reference_pil = image
target = _target_size(image.width, image.height, max_resolution, min_resolution)
if target is not None:
@@ -287,6 +297,23 @@ class InvisibleEngine:
self._progress_callback(f"Sharpening (unsharp mask: {unsharp})...")
image_io.imwrite(out_path, unsharp_mask(out_cv, amount=unsharp))
# Adaptive polish (--auto): restore the input's detail level in the softened
# output, sparing text/edges. Replaces the fixed unsharp/humanize knobs.
if adaptive_polish:
import cv2
import numpy as np
from remove_ai_watermarks import humanizer, image_io
out_cv = image_io.imread(out_path, cv2.IMREAD_COLOR)
if out_cv is not None:
ref = cv2.cvtColor(np.array(reference_pil.convert("RGB")), cv2.COLOR_RGB2BGR)
if (ref.shape[1], ref.shape[0]) != (out_cv.shape[1], out_cv.shape[0]):
ref = cv2.resize(ref, (out_cv.shape[1], out_cv.shape[0]), interpolation=cv2.INTER_LANCZOS4)
if self._progress_callback:
self._progress_callback("Adaptive polish (sharpen + grain to the input's detail level)...")
image_io.imwrite(out_path, humanizer.adaptive_polish(out_cv, ref, seed=seed))
return out_path
finally:
# _tmp_path is always set above (we persist the image unconditionally).