Files
remove-ai-watermarks/src/remove_ai_watermarks/gemini_engine.py
T
Victor Kuznetsov 0f54c6b54d fix(identify): kill three visible-detector false positives
Bright-background photos/renders and a tiny app icon were flagged as
AI-generated by the visible detectors. Two failure modes:

- Gemini sparkle on a bright background (snow+sky photo, white product
  render) scored ~0.51. The FP gate only demoted on a low core-ring
  brightness margin, which a bright background makes high. Add a gradient
  floor (_SPARKLE_FP_GRAD 0.55): a real sparkle is a crisp star (grad
  ~0.97-1.0), a smooth luminance blob that NCC-matches the diamond is not
  (the two FPs measured grad 0.105 / 0.463). The OR is a strict superset
  of the old margin-only demotion, so it cannot regress dark/mid (kept by
  margin) or white-bg (kept by confidence) real sparkles.

- A 48x48 geometric icon matched the Doubao/Jimeng CJK silhouette at
  0.41/0.47 NCC. Purely a small-size artifact (the same icon at >=256px
  collapses to ~0.06-0.10). Guard text-mark detection below a 200px short
  side (_MIN_DETECT_SHORT_SIDE); real marks ship on full-resolution
  renders (smallest captured sample 1086px).

Corpus re-sweep flips only OpenAI content and already-cleaned outputs,
all sub-0.5, so no provenance verdict changes. Add synthetic regression
fixtures for both modes; docs/module-internals.md updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 09:44:24 -07:00

1010 lines
47 KiB
Python

"""Gemini visible watermark removal engine.
Port of the GeminiWatermarkTool reverse-alpha-blending algorithm from C++ to Python.
Original author: Allen Kuo (allenk) — https://github.com/allenk/GeminiWatermarkTool
The Gemini AI watermark is applied using alpha blending:
watermarked = a * logo + (1 - a) * original
We reverse this to recover the original:
original = (watermarked - a * logo) / (1 - a)
The alpha maps are derived from background captures of the Gemini watermark
on pure-black backgrounds (48x48 for small images, 96x96 for large images).
"""
# cv2/numpy boundary: cv2 and numpy ship no usable type info for the array ops
# below, so strict pyright cannot know their element types. Relax the unknown-type
# rules for this file only; the public signatures are still annotated with NDArray[Any].
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownParameterType=false, reportMissingTypeArgument=false, reportMissingTypeStubs=false, reportMissingImports=false, reportArgumentType=false, reportAssignmentType=false, reportReturnType=false, reportCallIssue=false, reportIndexIssue=false, reportOperatorIssue=false, reportOptionalMemberAccess=false, reportOptionalCall=false, reportOptionalSubscript=false, reportOptionalOperand=false, reportAttributeAccessIssue=false, reportPrivateImportUsage=false, reportPrivateUsage=false, reportInvalidTypeForm=false, reportConstantRedefinition=false, reportUnnecessaryComparison=false
from __future__ import annotations
import logging
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal
import cv2
import numpy as np
from remove_ai_watermarks import image_io
if TYPE_CHECKING:
from collections.abc import Iterator
from numpy.typing import NDArray
logger = logging.getLogger(__name__)
class WatermarkSize(Enum):
"""Watermark size mode based on image dimensions."""
SMALL = "small" # 48x48, for images <= 1024x1024
LARGE = "large" # 96x96, for images > 1024x1024
@dataclass
class DetectionResult:
"""Result of watermark detection."""
detected: bool = False
confidence: float = 0.0
region: tuple[int, int, int, int] = (0, 0, 0, 0) # x, y, w, h
size: WatermarkSize = WatermarkSize.SMALL
# stage scores
spatial_score: float = 0.0
gradient_score: float = 0.0
variance_score: float = 0.0
@dataclass
class WatermarkPosition:
"""Watermark position configuration."""
margin_right: int
margin_bottom: int
logo_size: int
def get_position(self, image_width: int, image_height: int) -> tuple[int, int]:
"""Get top-left position for a given image size."""
x = image_width - self.margin_right - self.logo_size
y = image_height - self.margin_bottom - self.logo_size
return (x, y)
def get_watermark_config(width: int, height: int) -> WatermarkPosition:
"""Get the appropriate watermark configuration based on image size.
Rules discovered from Gemini:
- W > 1024 AND H > 1024: 96x96 logo at (W-64-96, H-64-96)
- Otherwise: 48x48 logo at (W-32-48, H-32-48)
"""
if width > 1024 and height > 1024:
return WatermarkPosition(margin_right=64, margin_bottom=64, logo_size=96)
return WatermarkPosition(margin_right=32, margin_bottom=32, logo_size=48)
def get_watermark_size(width: int, height: int) -> WatermarkSize:
"""Determine watermark size mode from image dimensions."""
if width > 1024 and height > 1024:
return WatermarkSize.LARGE
return WatermarkSize.SMALL
def _calculate_alpha_map(bg_capture: NDArray[Any]) -> NDArray[Any]:
"""Calculate alpha map from a background capture.
The alpha map represents how much the watermark affects each pixel.
alpha = max(R, G, B) / 255.0
"""
if len(bg_capture.shape) == 2:
gray = bg_capture.astype(np.float32)
elif bg_capture.shape[2] >= 3:
# Use max of channels for brightness
gray = np.max(bg_capture[:, :, :3], axis=2).astype(np.float32)
else:
gray = bg_capture[:, :, 0].astype(np.float32)
return gray / 255.0
def _load_embedded_asset(name: str) -> NDArray[Any]:
"""Load an embedded PNG asset and decode it with OpenCV."""
asset_path = Path(__file__).parent / "assets" / name
if not asset_path.exists():
raise FileNotFoundError(f"Embedded asset not found: {asset_path}")
data = asset_path.read_bytes()
buf = np.frombuffer(data, dtype=np.uint8)
img = cv2.imdecode(buf, cv2.IMREAD_COLOR)
if img is None:
raise RuntimeError(f"Failed to decode embedded asset: {name}")
return img
class GeminiEngine:
"""Engine for removing visible Gemini watermarks via reverse alpha blending.
This is a Python port of the GeminiWatermarkTool C++ engine.
"""
# Footprint pixels with alpha at/above this are the sparkle body; below it the
# mark barely affects the pixel, so those are excluded from both the
# over-subtraction test and the inpaint mask.
_FOOTPRINT_ALPHA = 0.1
# If more than this fraction of footprint pixels over-subtract (numerator < 0),
# the fixed alpha does not match this image's sparkle and reverse-alpha would
# punch a dark pit -- inpaint instead. demo_banana measures 0.0 (reverse-alpha
# kept), the issue #30 dark-grass image measures ~0.61 (inpaint), so the 0.05
# gate separates them with a wide margin.
_OVERSUB_FOOTPRINT_FRAC = 0.05
# Mid-tone over-subtraction (2026-06-18 prod "the color just changed, not removed"
# report). The numerator fraction above only trips when reverse-alpha drives a
# footprint pixel fully NEGATIVE -- the dark-background black-pit case. On a MID-TONE
# background a sparkle fainter than the captured alpha is over-subtracted into a
# visibly DARKER-than-background diamond while no pixel ever crosses zero, so the
# numerator gate misses it and ships the dark mark. Predict the reverse-alpha output
# at the bright core, (core - a*logo)/(1-a); when it lands more than this many gray
# levels BELOW the local background ring, reverse-alpha would leave a dark diamond --
# inpaint instead. Calibrated wide: clean removals predict within ~12 of background
# (demo_banana ~-1, a bright-bg sparkle ~-12), the prod regression predicts ~-40 and
# the issue #30 dark case ~-82, so 25 separates keep-vs-inpaint with margin.
_OVERSUB_DARK_MARGIN = 25.0
# Per-image alpha gain (under-subtraction fix). The captured alpha peaks ~0.51
# (a ~51%-opaque sparkle). Some real Gemini sparkles are rendered MORE opaque,
# so the fixed alpha under-subtracts and reverse-alpha leaves a bright residual
# the detector still fires on (~11% of marks on the spaces corpus). Estimate
# this image's effective sparkle opacity from the bright core vs the local
# background and scale the alpha to match, capped so alpha stays < 0.99. The
# gain is clamped to >= 1.0 so it only ever STRENGTHENS removal: ~1.0 when the
# sparkle matches the capture (working cases unchanged), >1 when more opaque.
# On the spaces corpus the gain cleanly separates -- under-removed marks ~1.47,
# cleanly-removed ~1.00. 1.94 is the cap that reaches alpha 0.99 from 0.51.
_ALPHA_GAIN_MAX = 1.94
_ALPHA_GAIN_CORE_FRAC = 0.8 # body pixels at >= this * peak alpha define the core
# Deadband: apply the gain only above this, so a sparkle that already matches the
# capture (estimated gain ~1.0-1.04 from background noise) stays byte-identical to
# the pre-fix output. Under-removed marks estimate >= 1.26, well clear of the band.
_ALPHA_GAIN_DEADBAND = 1.05
# Sparkle false-positive gate. A real Gemini sparkle is a bright WHITE overlay,
# so its core sits above the local background; a shape-only NCC match on ornate
# or flat content (text, banners, hatching) can score >0.5 without that lift.
# Demote a detection that is BOTH low-confidence AND low core-ring brightness
# margin -- the joint signature of a content false positive (verified on the
# spaces corpus: of 16 demoted, 13 carried no AI metadata and the 3 AI-meta ones
# were visually FPs / a near-invisible white-on-white sparkle whose AI verdict is
# held by metadata anyway). Real sparkles escape via EITHER high confidence
# (white-bg sparkles score >=0.79 despite a low margin) OR high margin (dark/mid
# backgrounds, incl. the #36 faint-corner case, lift well clear), so both must
# fail to demote.
_SPARKLE_FP_CONF = 0.65
_SPARKLE_FP_MARGIN = 5.0
# Bright-background content false positives (2026-06-26 landing-page FPs: a snow+sky
# photo and a white-background product render both scored ~0.51). The margin gate
# above cannot catch them -- a bright background gives the "core" a HIGH core-ring
# margin (it is genuinely brighter than its surroundings), so the brightness check
# reads it as a real overlay. The discriminating signature is the GRADIENT NCC: a
# real white sparkle is a crisp star silhouette (grad ~0.97-1.0 on the synthetic
# composites, ~0.96 on the real #36 corner sparkle), while a smooth luminance blob
# that shape-NCC-matches the rough outline has low gradient fidelity (the two FPs
# measured 0.105 and 0.463). So ALSO demote a low-confidence match whose gradient
# NCC is below this floor, regardless of margin -- 0.55 sits well above the worst FP
# (0.463) and far below every real sparkle (>=0.8). This only ADDS demotions on
# bright backgrounds (a real bright-bg sparkle keeps grad ~0.97), so it cannot
# regress a dark/mid sparkle (already kept by margin) or a white-bg one (kept by
# confidence >= 0.65, above the gate).
_SPARKLE_FP_GRAD = 0.55
# Self-verify fallback. The gain estimate corrects most under-subtractions, but
# on the spaces corpus a tail of strong sparkles still survived reverse-alpha
# (a few px of position jitter or a gain estimate the [1.0, 1.94] clamp could
# not fully reach). After the reverse blend, re-detect; if a sparkle this strong
# remains, inpaint the footprint and keep that ONLY when it lowers the re-detect
# confidence. Purely additive: the common clean removal re-detects below this and
# is returned untouched. Threshold matches the registry's real fail line (0.5),
# so it triggers exactly on the cases that would otherwise read as not-removed
# (rescued 4 of 15 corpus fails, 0 regressions). An offset+scale alignment search
# was prototyped on the remaining 11 but REJECTED: it only lowered the shape-NCC by
# moving the reverse-alpha to a different placement that left the sparkle as bright
# or brighter (NCC-gaming, not removal), so a brightness sanity check rejected every
# one. The footprint inpaint physically reconstructs the slot from its surroundings,
# so its rescues are genuine; the survivors are near-white ill-conditioning or
# detector false positives that no reverse-alpha placement fixes.
_VERIFY_FALLBACK_CONF = 0.5
# Corner promotion (issue #36): the size weight that suppresses tiny-patch
# false positives also buries a small, near-perfect sparkle when a larger,
# mediocre match sits elsewhere (e.g. a bright collar in a portrait). A small
# faint sparkle on a busy background therefore loses the global argmax and the
# image reads as clean -- the regression osachub reported when the search
# window widened 256px -> 512px (v0.7.2's tighter window still found it).
# Remedy: if the bottom-right corner holds a very-high-fidelity raw-NCC match,
# trust it regardless of size, without reverting the wider window (which is
# needed for variant margins). The threshold sits midway between the worst
# real-photo corner match (~0.78 across native + downscaled real photos) and a
# genuine faint sparkle (~0.93), so it adds true detections without adding
# false ones; it only ever overrides a lower-fidelity global pick, so it cannot
# weaken an existing detection.
_CORNER_PROMOTE_NCC = 0.85
# Bottom-right corner side for the promotion search, as a fraction of the
# image's short side, clamped to an absolute pixel band. Relative so the corner
# stays a true corner at every scale: a fixed 256 px is a genuine corner on a
# large image but covers ~70% of a small portrait, where a busy real photo can
# then raw-match the star template at ~0.81 (only 0.04 below the promote gate).
# Scaling the side down on small images drops that worst case to ~0.69, while
# the upper clamp stops it ballooning on huge images (more corner area = more
# random texture to false-match -- a real photo reached ~0.83 at 512 px). The
# Gemini sparkle sits ~60-160 px from the corner (fixed margins, not
# proportional), and the [96, 384] band covers that at every measured size.
_CORNER_PROMOTE_FRAC = 0.20
_CORNER_PROMOTE_MIN = 96
_CORNER_PROMOTE_MAX = 384
# Number of top size-weighted spatial candidates scored by full fusion before one
# is selected. The single size-weighted argmax can bury a genuine mid-size sparkle
# under a LARGER, lower-fidelity shape match (the 256->512 search-widening
# regression: a real corner sparkle at raw ~0.77 lost to a decoy at raw ~0.63).
# Scoring the top-K by gradient-bearing fusion rescues it. Top-K (NOT the raw-NCC
# argmax) keeps the tiny-patch suppression intact: a coincidental 16 px match never
# ranks in the size-weighted top-K, so widening selection cannot add a false
# positive on non-Gemini content (verified on the doubao/jimeng visible corpora).
_SELECT_TOPK = 3
def __init__(self, logo_value: float = 255.0) -> None:
"""Initialize the engine with embedded alpha maps.
Args:
logo_value: The logo brightness value (default 255.0 = white).
"""
self.logo_value = logo_value
# Load embedded background captures
bg_small = _load_embedded_asset("gemini_bg_48.png")
bg_large = _load_embedded_asset("gemini_bg_96.png")
# Ensure correct sizes
if bg_small.shape[:2] != (48, 48):
bg_small = cv2.resize(bg_small, (48, 48), interpolation=cv2.INTER_AREA)
if bg_large.shape[:2] != (96, 96):
bg_large = cv2.resize(bg_large, (96, 96), interpolation=cv2.INTER_AREA)
# Calculate alpha maps
self._alpha_small = _calculate_alpha_map(bg_small)
self._alpha_large = _calculate_alpha_map(bg_large)
logger.debug(
"Alpha maps loaded: small=%s, large=%s",
self._alpha_small.shape,
self._alpha_large.shape,
)
def get_alpha_map(self, size: WatermarkSize) -> NDArray[Any]:
"""Get the base alpha map for a specific standard size."""
if size == WatermarkSize.SMALL:
return self._alpha_small
return self._alpha_large
def get_interpolated_alpha(self, size_px: int) -> NDArray[Any]:
"""Create an interpolated alpha map dynamically scaled from the high-res 96x96 base."""
source = self._alpha_large
if size_px == source.shape[1]:
return source.copy()
interp = cv2.INTER_LINEAR if size_px > source.shape[1] else cv2.INTER_AREA
return cv2.resize(source, (size_px, size_px), interpolation=interp)
# ── Detection ────────────────────────────────────────────────────
def _scan_scales(self, gray: NDArray[Any]) -> Iterator[tuple[int, float, tuple[int, int]]]:
"""Yield ``(scale, max_ncc, max_loc)`` for the alpha template matched at each scale.
Shared multi-scale ``TM_CCOEFF_NORMED`` primitive over a normalized [0, 1]
grayscale region, used by both the size-weighted global search in
``detect_watermark`` and the raw-NCC corner pass in ``_corner_promote`` --
each applies its own scoring/argmax to the yielded values. The 96x96
``_alpha_large`` is the high-quality source downscaled per scale; the range
covers aggressively downscaled to slightly upscaled logos.
"""
for scale in range(16, 120, 2):
if scale > gray.shape[0] or scale > gray.shape[1]:
continue
tmpl = cv2.resize(self._alpha_large, (scale, scale), interpolation=cv2.INTER_AREA)
match_res = cv2.matchTemplate(gray, tmpl, cv2.TM_CCOEFF_NORMED)
_, max_val, _, max_loc = cv2.minMaxLoc(match_res)
yield scale, float(max_val), max_loc
def detect_watermark(
self,
image: NDArray[Any],
force_size: WatermarkSize | None = None,
) -> DetectionResult:
"""Detect Gemini watermark using multi-scale Snap Engine logic (ported from C++ vendor algorithm)."""
result = DetectionResult()
if image is None or image.size == 0:
return result
# Normalize to 3-channel BGR: the multi-scale search tolerates grayscale, but
# the FP-gate / alpha-gain helpers (_core_and_bg) reduce over axis=2 and would
# crash on a 2D/BGRA input reaching this public entry point (e.g. via the
# registry detect adapter or the library API).
image = image_io.to_bgr(image)
h, w = image.shape[:2]
base_size = force_size or get_watermark_size(w, h)
result.size = base_size
# Dynamically search bottom-right corner. 512 covers up to 512px from the
# corner -- enough for known Gemini margin variations (standard: 64+96=160px;
# observed variants up to ~300px). 256 was too tight and caused misses.
search_size = int(min(min(w, h), 512))
sx1 = max(0, w - search_size)
sy1 = max(0, h - search_size)
search_region = image[sy1:h, sx1:w]
if len(search_region.shape) == 3 and search_region.shape[2] >= 3:
gray_sr = cv2.cvtColor(search_region, cv2.COLOR_BGR2GRAY)
else:
gray_sr = search_region.copy()
gray_sr_f = gray_sr.astype(np.float32) / 255.0
# Phase 1 & 2: multi-scale spatial NCC search. The size weight (mimicking the
# C++ vendor weight) overcomes the NCC bias toward tiny patches, but its single
# argmax can bury a genuine mid-size sparkle under a LARGER, lower-fidelity
# shape match (the 256->512 search-widening regression). So score the top-K
# size-weighted candidates by the FULL fusion and keep the highest -- the
# gradient term separates a true white sparkle from a shape-only decoy. See
# _SELECT_TOPK for why top-K (not the raw-NCC argmax) preserves tiny-patch
# suppression and so cannot add a false positive on non-Gemini content.
scored: list[tuple[float, int, int, int, float]] = [] # (adj, scale, raw, x, y)
for scale, max_val, max_loc in self._scan_scales(gray_sr_f):
adj_val = max_val * min(1.0, (scale / 96.0) ** 0.5)
scored.append((adj_val, scale, max_val, sx1 + max_loc[0], sy1 + max_loc[1]))
scored.sort(reverse=True)
# Top-K candidates at distinct locations (NMS: drop a lower-ranked match that
# overlaps an already-kept one -- the same sparkle matches at adjacent scales).
candidates: list[tuple[int, int, int, float]] = []
for _adj, scale, raw, x, y in scored:
if any(
abs(x - px) < 0.5 * max(scale, ps) and abs(y - py) < 0.5 * max(scale, ps)
for ps, px, py, _ in candidates
):
continue
candidates.append((scale, x, y, raw))
if len(candidates) >= self._SELECT_TOPK:
break
# Corner promotion: a near-perfect small bottom-right sparkle the size weight
# buries even below the top-K (see _CORNER_PROMOTE_NCC) -- add it as a candidate.
promoted = self._corner_promote(image, candidates[0][3] if candidates else -1.0)
if promoted is not None:
candidates.append(promoted)
# Select the candidate with the highest full-fusion confidence (pre-FP-gate).
best_scale, pos_x, pos_y, best_raw_ncc = candidates[0]
grad_score, var_score, best_fused = 0.0, 0.0, -1.0
for c_scale, c_x, c_y, c_raw in candidates:
if c_raw < 0.25:
c_grad, c_var, c_fused = 0.0, 0.0, max(0.0, c_raw * 0.5)
else:
c_grad, c_var = self._grad_var_scores(image, c_scale, c_x, c_y)
c_fused = c_raw * 0.50 + c_grad * 0.30 + c_var * 0.20
if c_fused > best_fused:
best_fused = c_fused
best_scale, pos_x, pos_y = c_scale, c_x, c_y
best_raw_ncc, grad_score, var_score = c_raw, c_grad, c_var
result.region = (pos_x, pos_y, best_scale, best_scale)
result.spatial_score = float(best_raw_ncc)
result.gradient_score = float(grad_score)
result.variance_score = float(var_score)
if result.spatial_score < 0.25:
result.confidence = float(max(0.0, result.spatial_score * 0.5))
return result
# ── Fusion ───────────────────────────────────────────────────
# best_fused is the selected candidate's spatial*0.5 + grad*0.3 + var*0.2.
confidence = best_fused
# False-positive gate: a low-confidence match that shows NEITHER real-sparkle
# signature is a content false positive, not a white sparkle overlay. A real
# sparkle proves itself by a bright core (high core-ring margin, on dark/mid
# backgrounds) OR a crisp star silhouette (high gradient NCC, on any background
# incl. bright). Demote when both are weak -- this catches the dark/mid no-core
# FP (low margin) AND the bright-background smooth-blob FP (high margin but low
# gradient), which the margin check alone misses. See _SPARKLE_FP_GRAD.
if confidence < self._SPARKLE_FP_CONF:
margin = self._core_ring_margin(image, self.get_interpolated_alpha(best_scale), (pos_x, pos_y))
low_margin = margin is not None and margin < self._SPARKLE_FP_MARGIN
low_grad = grad_score < self._SPARKLE_FP_GRAD
if low_margin or low_grad:
logger.debug(
"Sparkle FP gate: conf=%.3f, core-ring margin=%s, grad=%.3f < %.2f; demoting.",
confidence,
f"{margin:.1f}" if margin is not None else "n/a",
grad_score,
self._SPARKLE_FP_GRAD,
)
confidence = min(confidence, 0.30)
result.confidence = float(max(0.0, min(1.0, confidence)))
result.detected = result.confidence >= 0.35
logger.debug(
"Detection: spatial=%.3f, grad=%.3f, var=%.3f → conf=%.3f (%s)",
result.spatial_score,
result.gradient_score,
var_score,
result.confidence,
"DETECTED" if result.detected else "not detected",
)
return result
def _grad_var_scores(
self,
image: NDArray[Any],
scale: int,
pos_x: int,
pos_y: int,
) -> tuple[float, float]:
"""Return ``(gradient_score, variance_score)`` for a candidate sparkle.
Factored out of ``detect_watermark`` so each top-K candidate can be scored by
the full fusion before one is selected. The gradient NCC correlates
Sobel-magnitude maps (shape fidelity, contrast-robust); the variance score
rewards a flat overlay region against the row band above it.
"""
h, w = image.shape[:2]
x1, y1 = pos_x, pos_y
x2, y2 = min(w, x1 + scale), min(h, y1 + scale)
region = image[y1:y2, x1:x2]
gray_region = cv2.cvtColor(region, cv2.COLOR_BGR2GRAY) if region.ndim == 3 and region.shape[2] >= 3 else region
gray_f = gray_region.astype(np.float32) / 255.0
alpha_region = self.get_interpolated_alpha(scale)[: y2 - y1, : x2 - x1]
# ── Gradient NCC ──
img_gmag = cv2.magnitude(
cv2.Sobel(gray_f, cv2.CV_32F, 1, 0, ksize=3), cv2.Sobel(gray_f, cv2.CV_32F, 0, 1, ksize=3)
)
alpha_gmag = cv2.magnitude(
cv2.Sobel(alpha_region, cv2.CV_32F, 1, 0, ksize=3), cv2.Sobel(alpha_region, cv2.CV_32F, 0, 1, ksize=3)
)
_, grad_score, _, _ = cv2.minMaxLoc(cv2.matchTemplate(img_gmag, alpha_gmag, cv2.TM_CCOEFF_NORMED))
# ── Variance ──
var_score = 0.0
ref_h = min(y1, scale)
if ref_h > 8:
ref_region = image[y1 - ref_h : y1, x1:x2]
gray_ref = cv2.cvtColor(ref_region, cv2.COLOR_BGR2GRAY) if ref_region.ndim == 3 else ref_region
_, s_wm = cv2.meanStdDev(gray_region)
_, s_ref = cv2.meanStdDev(gray_ref)
if s_ref[0][0] > 5.0:
var_score = max(0.0, min(1.0, 1.0 - (s_wm[0][0] / s_ref[0][0])))
return float(grad_score), float(var_score)
def _corner_promote(
self,
image: NDArray[Any],
current_raw_ncc: float,
) -> tuple[int, int, int, float] | None:
"""Search the bottom-right corner for a very-high-fidelity sparkle match.
Returns ``(scale, x, y, raw_ncc)`` when the corner holds a match with raw
NCC >= ``_CORNER_PROMOTE_NCC`` that beats the global pick's ``current_raw_ncc``,
else None. Used to rescue a small sparkle that the size weight buried under
a larger, lower-fidelity match elsewhere. See ``_CORNER_PROMOTE_NCC`` and
``_CORNER_PROMOTE_FRAC`` for the corner sizing.
"""
h, w = image.shape[:2]
side = max(
self._CORNER_PROMOTE_MIN, min(self._CORNER_PROMOTE_MAX, round(min(w, h) * self._CORNER_PROMOTE_FRAC))
)
cs = int(min(min(w, h), side))
cx1, cy1 = max(0, w - cs), max(0, h - cs)
corner = image[cy1:h, cx1:w]
gray = cv2.cvtColor(corner, cv2.COLOR_BGR2GRAY) if corner.ndim == 3 and corner.shape[2] >= 3 else corner
gray = gray.astype(np.float32) / 255.0
best_raw = -1.0
best_scale = 0
best_loc = (0, 0)
for scale, max_val, max_loc in self._scan_scales(gray):
if max_val > best_raw:
best_raw = max_val
best_scale = scale
best_loc = max_loc
if best_raw >= self._CORNER_PROMOTE_NCC and best_raw > current_raw_ncc:
return best_scale, cx1 + best_loc[0], cy1 + best_loc[1], float(best_raw)
return None
# ── Removal ──────────────────────────────────────────────────────
def remove_watermark(
self,
image: NDArray[Any],
force_size: WatermarkSize | None = None,
) -> NDArray[Any]:
"""Remove Gemini visible watermark from an image using reverse alpha blending.
No-op when the detector does not find a watermark: returns an unmodified
copy. Reverse alpha blending applied where no sparkle exists creates a
visible inverse artifact, so we refuse to touch pixels without a positive
detection. To bypass detection (e.g. you know the exact region), use
``remove_watermark_custom``.
Args:
image: BGR image as numpy array (will NOT be modified in-place).
force_size: Force a specific watermark size (auto-detect if None).
Returns:
Cleaned BGR image as numpy array, or an unmodified copy when no
watermark is detected.
"""
# Normalize to 3-channel BGR up front: 2D grayscale (no channel axis) and
# 4-channel BGRA both reach this public entry point and would otherwise
# crash on the channel-count checks / downstream 3-channel math.
result = image_io.to_bgr(image.copy())
size = force_size or get_watermark_size(result.shape[1], result.shape[0])
# Detect dynamic position & size (on the normalized 3-channel image so a
# grayscale/BGRA input does not crash the detector).
detection = self.detect_watermark(result, force_size=size)
if not detection.detected:
logger.debug(
"No watermark detected (conf=%.3f); returning image unchanged.",
detection.confidence,
)
return result
pos = (detection.region[0], detection.region[1])
alpha_map = self.get_interpolated_alpha(detection.region[2])
# Match the captured alpha to this image's sparkle opacity (under-subtraction
# fix): a more-opaque-than-captured sparkle would otherwise leave a bright
# residual. gain == 1.0 leaves the working cases byte-identical.
gain = self._estimate_alpha_gain(result, alpha_map, pos)
if gain > self._ALPHA_GAIN_DEADBAND:
alpha_map = np.clip(alpha_map * gain, 0.0, 0.99)
logger.debug(
"Removing watermark at (%d, %d) size %dx%d [conf=%.3f]",
pos[0],
pos[1],
detection.region[2],
detection.region[3],
detection.confidence,
)
# The captured alpha map (max ~0.51 = a ~50%-opaque white sparkle) is exact
# only when the real mark's effective opacity matches it. On a dark/textured
# background the sparkle's effective alpha is lower than the capture, so the
# fixed-alpha reverse blend OVER-subtracts and drives the footprint to black --
# the "white sparkle turns into a black pit" bug (issue #30). The signature is
# a large fraction of footprint pixels whose numerator (watermarked - a*logo)
# goes negative, which is physically impossible under a brightening overlay.
# In that case inpaint the small footprint from the surrounding pixels instead;
# on a bright background no pixel over-subtracts, so reverse-alpha is used and
# the result is byte-identical to before (verified on demo_banana: 0% vs 61%).
if self._reverse_alpha_oversubtracts(result, alpha_map, pos):
logger.debug("Reverse-alpha over-subtracts on this background; inpainting sparkle footprint.")
self._inpaint_footprint(result, alpha_map, pos)
else:
self._reverse_alpha_blend(result, alpha_map, pos)
return self._verify_and_repair(result, alpha_map, pos, size)
def remove_watermark_custom(
self,
image: NDArray[Any],
region: tuple[int, int, int, int],
) -> NDArray[Any]:
"""Remove watermark from a custom region with interpolated alpha map.
Args:
image: BGR image (will NOT be modified in-place).
region: (x, y, width, height) of the watermark region.
Returns:
Cleaned BGR image.
"""
# Same channel normalization as remove_watermark: the reverse-alpha blend
# assumes 3-channel BGR (a grayscale/BGRA input would mis-broadcast).
result = image_io.to_bgr(image.copy())
x, y, rw, rh = region
# Check standard sizes
if rw == 48 and rh == 48:
self._reverse_alpha_blend(result, self._alpha_small, (x, y))
return result
if rw == 96 and rh == 96:
self._reverse_alpha_blend(result, self._alpha_large, (x, y))
return result
# Interpolate alpha map for custom size
interp = cv2.INTER_LINEAR if rw > 96 else cv2.INTER_AREA
alpha = cv2.resize(self._alpha_large, (rw, rh), interpolation=interp)
self._reverse_alpha_blend(result, alpha, (x, y))
return result
def _footprint_indices(
self,
alpha_map: NDArray[Any],
position: tuple[int, int],
image_shape: tuple[int, ...],
) -> tuple[NDArray[Any], tuple[int, int, int, int]] | None:
"""Return (alpha_roi, (y1, y2, x1, x2)) for the placed footprint, or None.
Shared by the over-subtraction test and the inpaint mask so both operate on
exactly the same clipped, in-bounds region.
"""
x, y = position
ah, aw = alpha_map.shape[:2]
ih, iw = image_shape[:2]
x1, y1 = max(0, x), max(0, y)
x2, y2 = min(iw, x + aw), min(ih, y + ah)
if x1 >= x2 or y1 >= y2:
return None
ax1, ay1 = x1 - x, y1 - y
alpha_roi = alpha_map[ay1 : ay1 + (y2 - y1), ax1 : ax1 + (x2 - x1)]
return alpha_roi, (y1, y2, x1, x2)
def _core_and_bg(
self,
image: NDArray[Any],
alpha_map: NDArray[Any],
position: tuple[int, int],
) -> tuple[float, float, float] | None:
"""Return ``(core_obs, bg, a_cap)`` for the placed sparkle, or None.
``core_obs`` is the bright-core brightness (75th pct over the high-alpha
core), ``bg`` the local background ring median, ``a_cap`` the captured peak
alpha. Shared by the alpha-gain estimate and the false-positive margin gate.
None when the footprint or the background ring cannot be sampled.
"""
placed = self._footprint_indices(alpha_map, position, image.shape)
if placed is None:
return None
alpha_roi, (y1, y2, x1, x2) = placed
a_cap = float(alpha_roi.max())
if a_cap < 0.2:
return None
core = alpha_roi >= a_cap * self._ALPHA_GAIN_CORE_FRAC
if not bool(core.any()):
return None
# Convert only the footprint+ring crop to gray, not the whole image: every
# sample below lives inside the ring box, so a full-image mean is wasted work
# that scales with resolution (~70 ms on a 12 MP image, recomputed for both
# the alpha-gain estimate and the over-subtraction gate). The crop is sized by
# the footprint, so this is O(footprint^2) regardless of image size.
ih, iw = image.shape[:2]
pad = int((x2 - x1) * 0.7)
ry1, ry2 = max(0, y1 - pad), min(ih, y2 + pad)
rx1, rx2 = max(0, x1 - pad), min(iw, x2 + pad)
ring = image[ry1:ry2, rx1:rx2].astype(np.float32).mean(axis=2)
# Footprint box expressed in ring-crop coordinates.
fy1, fy2, fx1, fx2 = y1 - ry1, y2 - ry1, x1 - rx1, x2 - rx1
core_obs = float(np.percentile(ring[fy1:fy2, fx1:fx2][core], 75))
ring_mask = np.ones(ring.shape, dtype=bool)
ring_mask[fy1:fy2, fx1:fx2] = False
if int(ring_mask.sum()) < 10:
return None
return core_obs, float(np.median(ring[ring_mask])), a_cap
def _core_ring_margin(
self,
image: NDArray[Any],
alpha_map: NDArray[Any],
position: tuple[int, int],
) -> float | None:
"""Bright-core brightness minus the local background ring (gray levels).
A real white sparkle overlay lifts its core above the surroundings; a
shape-only NCC false positive on ornate/flat content does not. None when the
background ring cannot be sampled.
"""
cb = self._core_and_bg(image, alpha_map, position)
return None if cb is None else cb[0] - cb[1]
def _estimate_alpha_gain(
self,
image: NDArray[Any],
alpha_map: NDArray[Any],
position: tuple[int, int],
) -> float:
"""Scale factor matching the captured alpha to this image's sparkle opacity.
The captured alpha (peak ~0.51) under-represents sparkles rendered more
opaque; reverse-alpha then leaves a bright residual. Estimate the effective
opacity at the sparkle core (observed brightness vs the local background
ring) and return ``a_eff / a_capture``, clamped to ``[1.0, _ALPHA_GAIN_MAX]``
so it only ever STRENGTHENS removal (1.0 = no change on a matching sparkle).
Returns 1.0 when the background cannot be estimated reliably.
"""
cb = self._core_and_bg(image, alpha_map, position)
if cb is None:
return 1.0
core_obs, bg, a_cap = cb
if 255.0 - bg < 5.0:
return 1.0
a_eff = float(np.clip((core_obs - bg) / (255.0 - bg), 0.0, 0.99))
return float(np.clip(a_eff / a_cap, 1.0, self._ALPHA_GAIN_MAX))
def _reverse_alpha_oversubtracts(
self,
image: NDArray[Any],
alpha_map: NDArray[Any],
position: tuple[int, int],
) -> bool:
"""True when reverse-alpha would drive the footprint dark.
Two signatures of the captured alpha over-estimating this image's sparkle
opacity, either of which means reverse-alpha would leave a dark mark:
1. Dark-background black pit (issue #30): the numerator
``watermarked - alpha*logo`` over the sparkle body. A brightening overlay
can never make it negative, so a large negative fraction means the fixed
alpha over-subtracts past black.
2. Mid-tone dark diamond (see ``_OVERSUB_DARK_MARGIN``): on a mid-tone
background the over-subtraction darkens the core well below the background
without any pixel crossing zero, so case 1 misses it. Predict the
reverse-alpha core output and trip when it lands far below the local ring.
"""
placed = self._footprint_indices(alpha_map, position, image.shape)
if placed is None:
return False
alpha_roi, (y1, y2, x1, x2) = placed
body = alpha_roi >= self._FOOTPRINT_ALPHA
if not bool(body.any()):
return False
roi = image[y1:y2, x1:x2].astype(np.float32)
numerator = roi.mean(axis=2) - np.clip(alpha_roi, 0.0, 0.99) * self.logo_value
frac = float((numerator[body] < 0).sum()) / float(body.sum())
if frac > self._OVERSUB_FOOTPRINT_FRAC:
return True
# Mid-tone darkening: predict the reverse-alpha output at the bright core and
# compare to the local background ring (reuses the FP-gate / alpha-gain machinery).
cb = self._core_and_bg(image, alpha_map, position)
if cb is None:
return False
core_obs, bg, a_cap = cb
a = min(a_cap, 0.99)
predicted_core = (core_obs - a * self.logo_value) / (1.0 - a)
return predicted_core < bg - self._OVERSUB_DARK_MARGIN
def _inpaint_footprint(
self,
image: NDArray[Any],
alpha_map: NDArray[Any],
position: tuple[int, int],
) -> None:
"""Inpaint the sparkle body from surrounding pixels, in-place.
Fallback for backgrounds where reverse-alpha over-subtracts: a small mask of
the footprint (alpha >= threshold, dilated) is reconstructed by cv2 NS inpaint
from the continuous surroundings, so the sparkle is replaced by plausible
background instead of a black pit.
"""
placed = self._footprint_indices(alpha_map, position, image.shape)
if placed is None:
return
alpha_roi, (y1, y2, x1, x2) = placed
# Inpaint only a padded crop around the footprint, not the whole image: the
# mask is zero outside a ~96x96 corner, so inpainting the full (multi-MP)
# image would be ~hundreds of times more work for an identical result. The
# padding gives cv2 enough surrounding context to reconstruct the sparkle.
ih, iw = image.shape[:2]
pad = 24
cy1, cy2 = max(0, y1 - pad), min(ih, y2 + pad)
cx1, cx2 = max(0, x1 - pad), min(iw, x2 + pad)
crop = image[cy1:cy2, cx1:cx2]
mask = np.zeros(crop.shape[:2], dtype=np.uint8)
mask[y1 - cy1 : y2 - cy1, x1 - cx1 : x2 - cx1] = (alpha_roi >= self._FOOTPRINT_ALPHA).astype(np.uint8) * 255
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
mask = cv2.dilate(mask, kernel, iterations=2)
image[cy1:cy2, cx1:cx2] = cv2.inpaint(crop, mask, 6, cv2.INPAINT_NS)
def _verify_and_repair(
self,
result: NDArray[Any],
alpha_map: NDArray[Any],
position: tuple[int, int],
size: WatermarkSize,
) -> NDArray[Any]:
"""Inpaint-repair a sparkle that survived reverse-alpha, keeping the better.
Re-detect on the reverse-alpha output; if a sparkle this strong remains (an
alpha mismatch the gain estimate could not fully correct), inpaint the
footprint and return that ONLY when it lowers the re-detect confidence. The
footprint inpaint reconstructs from the (darker) surroundings, so it physically
removes the bright sparkle rather than gaming the shape-NCC. Returns ``result``
unchanged when the removal is already clean (the common case) or when the
inpaint does not improve it, so it can never regress.
"""
residual = self.detect_watermark(result, force_size=size).confidence
if residual < self._VERIFY_FALLBACK_CONF:
return result
candidate = result.copy()
self._inpaint_footprint(candidate, alpha_map, position)
if self.detect_watermark(candidate, force_size=size).confidence < residual:
logger.debug("Sparkle survived reverse-alpha (conf=%.3f); footprint inpaint improved it.", residual)
return candidate
return result
def _reverse_alpha_blend(
self,
image: NDArray[Any],
alpha_map: NDArray[Any],
position: tuple[int, int],
) -> None:
"""Apply reverse alpha blending in-place.
Formula: original = (watermarked - a * logo) / (1 - a)
"""
placed = self._footprint_indices(alpha_map, position, image.shape)
if placed is None:
return
alpha_roi, (y1, y2, x1, x2) = placed
image_roi = image[y1:y2, x1:x2].astype(np.float32)
alpha_threshold = 0.002
max_alpha = 0.99
# Vectorized reverse alpha blending
alpha = alpha_roi.copy()
mask = alpha >= alpha_threshold
alpha = np.clip(alpha, 0.0, max_alpha)
one_minus_alpha = 1.0 - alpha
# Expand alpha for 3-channel broadcast
alpha_3d = alpha[:, :, np.newaxis]
one_minus_3d = one_minus_alpha[:, :, np.newaxis]
mask_3d = mask[:, :, np.newaxis]
# original = (watermarked - alpha * logo) / (1 - alpha)
restored = (image_roi - alpha_3d * self.logo_value) / one_minus_3d
restored = np.clip(restored, 0.0, 255.0)
# Apply only where alpha is significant
image_roi = np.where(mask_3d, restored, image_roi)
image[y1:y2, x1:x2] = image_roi.astype(np.uint8)
# ── Inpainting cleanup ───────────────────────────────────────────
def inpaint_residual(
self,
image: NDArray[Any],
region: tuple[int, int, int, int],
strength: float = 0.85,
method: Literal["gaussian", "telea", "ns"] = "ns",
inpaint_radius: int = 10,
padding: int = 32,
) -> NDArray[Any]:
"""Apply inpaint cleanup on residual artifacts after reverse alpha blend.
Uses a sparse mask derived from alpha map gradient to repair only
the sparkle-edge pixels where interpolation broke the math.
Args:
image: BGR image (will NOT be modified in-place).
region: (x, y, w, h) of the watermark region.
strength: Blend strength (0.0 = keep original, 1.0 = fully inpainted).
method: Inpaint method ("gaussian", "telea", or "ns").
inpaint_radius: Radius for cv2.inpaint.
padding: Context padding around region in pixels.
Returns:
Cleaned BGR image.
"""
result = image.copy()
x, y, rw, rh = region
if rw < 4 or rh < 4:
return result
strength = max(0.0, min(1.0, strength))
if strength < 0.001:
return result
# Padded region
px1 = max(0, x - padding)
py1 = max(0, y - padding)
px2 = min(image.shape[1], x + rw + padding)
py2 = min(image.shape[0], y + rh + padding)
if (px2 - px1) < 8 or (py2 - py1) < 8:
return result
# Inner rect relative to padded
ix1 = x - px1
iy1 = y - py1
# Get alpha map (interpolated if needed)
source_alpha = self._alpha_large
interp = cv2.INTER_LINEAR if rw > source_alpha.shape[1] else cv2.INTER_AREA
alpha_resized = cv2.resize(source_alpha, (rw, rh), interpolation=interp)
# Compute gradient mask from alpha
grad_x = cv2.Sobel(alpha_resized, cv2.CV_32F, 1, 0, ksize=3)
grad_y = cv2.Sobel(alpha_resized, cv2.CV_32F, 0, 1, ksize=3)
grad_mag = cv2.magnitude(grad_x, grad_y)
grad_min, grad_max = grad_mag.min(), grad_mag.max()
if grad_max <= grad_min:
return result
# Normalize and apply gamma correction
grad_norm = (grad_mag - grad_min) / (grad_max - grad_min)
grad_weight = np.sqrt(grad_norm)
# Dilate the mask
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
grad_weight = cv2.dilate(grad_weight, kernel)
if method == "gaussian":
# Soft blend with Gaussian blur
padded_roi = result[py1:py2, px1:px2].copy()
blurred = cv2.GaussianBlur(padded_roi, (0, 0), sigmaX=2.0)
# Create weight mask on padded area (only inner region has weights)
weight_full = np.zeros((py2 - py1, px2 - px1), dtype=np.float32)
weight_full[iy1 : iy1 + rh, ix1 : ix1 + rw] = grad_weight * strength
weight_3d = weight_full[:, :, np.newaxis]
blended = padded_roi.astype(np.float32) * (1 - weight_3d) + blurred.astype(np.float32) * weight_3d
result[py1:py2, px1:px2] = blended.astype(np.uint8)
else:
# OpenCV inpainting (TELEA or NS)
inpaint_flag = cv2.INPAINT_TELEA if method == "telea" else cv2.INPAINT_NS
# Create binary mask from gradient weight
binary_mask = (grad_weight * 255).astype(np.uint8)
_, binary_mask = cv2.threshold(binary_mask, 30, 255, cv2.THRESH_BINARY)
# Expand mask to padded region
mask_full = np.zeros((py2 - py1, px2 - px1), dtype=np.uint8)
mask_full[iy1 : iy1 + rh, ix1 : ix1 + rw] = binary_mask
padded_roi = result[py1:py2, px1:px2].copy()
inpainted = cv2.inpaint(padded_roi, mask_full, inpaint_radius, inpaint_flag)
# Blend with strength
weight_full = np.zeros((py2 - py1, px2 - px1), dtype=np.float32)
weight_full[iy1 : iy1 + rh, ix1 : ix1 + rw] = grad_weight * strength
weight_3d = weight_full[:, :, np.newaxis]
blended = padded_roi.astype(np.float32) * (1 - weight_3d) + inpainted.astype(np.float32) * weight_3d
result[py1:py2, px1:px2] = blended.astype(np.uint8)
return result
def detect_sparkle_confidence(image_path: Path, *, image: NDArray[Any] | None = None) -> float | None:
"""Visible-sparkle detection confidence for a file, for provenance use.
Loads the image with cv2 and runs :meth:`GeminiEngine.detect_watermark`.
Returns the NCC confidence in [0, 1], or None if the image cannot be read
(cv2 returns None for unsupported containers such as HEIC). Kept here so the
cv2 dependency stays in this module; callers apply their own threshold.
``image`` lets a caller that has already decoded the file (e.g. ``identify``
running several visible-mark detectors) pass the BGR array to avoid a second
full decode; when None the file is read from ``image_path``.
"""
from remove_ai_watermarks import image_io
img = image if image is not None else image_io.imread(image_path)
if img is None:
return None
return float(GeminiEngine().detect_watermark(img).confidence)