Files
remove-ai-watermarks/src/remove_ai_watermarks/gemini_engine.py
T
Victor KuznetsovandClaude Opus 5 78d9e81d0f Collapse the duplicated detection path and lift the image pipeline into the library
The visible-mark path had grown three copies of one ladder sweep, four
near-identical `detect` arms, and four hand-rolled `footprint_mask` overrides;
mark knowledge sat in five hand-maintained tables across three modules; and the
flagship `all`/`batch` pipeline existed only in cli.py, written twice with
divergent behavior.

Detection is now one measurement. `_ladder_best` replaces the three sweeps,
`_scan`/`_verdict` replace the four arms, and the winning box travels to the
mask on `TextMarkDetection.match_box` instead of being swept a second time.
`detect_both` returns the strict and relaxed verdicts from one scan, which
halves the arbiter's perception cost (260 -> 130 matchTemplate calls on a 2048²
image, verdicts identical field for field). A per-mark demotion goes in the new
`_post_gate` hook, never in a `detect` override -- an override is invisible to
the single-pass path, which is how the RunningHub and Yuanbao anchor gates
briefly stopped applying.

Everything about a mark is now one registry row: product, label regime, the
platform sentence `identify` reports, the metadata signals that confirm it, and
its TC260 producer codes. `identify._VISIBLE_MARK_PLATFORM`, the signal mapping
in `api.visible_provenance`, `_PRODUCT_OF` and the pill veto are derived from
those rows.

`api.remove_all` / `api.remove_batch` are the library form of the `all` and
`batch` commands; the CLI is a wrapper that owns console text and exit codes.
Progress is a `(stage, detail)` pair of stable tokens, so the CLI keys its
wording off structure rather than parsing the library's prose back.

Two intentional behavior changes, both verified against a recorded 811-image
sample of detector verdicts, removal-mask hashes, arbiter decisions and
`identify` reports:

  * A TC260 label now relaxes the vendor its `ContentProducer` names rather than
    ByteDance's pair on every China-AIGC image. 333 of 811 samples move; on 185
    of them the previously relaxed pair was simply the wrong vendor, and the
    mark actually present never reached the relaxed gate its own
    `provenance_ncc_factor` was calibrated for.
  * A confident LibLibAI detection suppresses the Jimeng pill, like every other
    TC260 product's mark. It was registered alongside RunningHub and Baidu, both
    of which were added to the hand-written veto list, and it was not. 1 sample
    moves, and it is exactly the co-firing case.

Nothing else in that record changes: detector verdicts, mask hashes and
`identify` verdicts are byte-identical, and all 200 calibration constants are
untouched.

Also: `aigc_label` and friends plus `extract_c2pa_info` are memoized on
(path, mtime_ns, size) -- size because this package rewrites in place; the
native TC260 container readers route on magic bytes instead of the file
extension, so a mislabeled AVI or FLV is no longer invisible; `identify` shares
one pixel decode between the DWT-DCT and visible stages (TrustMark keeps its own
Pillow decode, which is not substitutable); and the six `stabilize_*` video
wrappers collapse into one policy table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:49:45 -07:00

480 lines
20 KiB
Python

"""Locate the visible Gemini sparkle and build a mask for shared inpainting."""
# OpenCV and NumPy expose incomplete types at this array-processing boundary.
# 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 functools
import logging
from dataclasses import dataclass, replace
from enum import Enum
from pathlib import Path
from typing import TYPE_CHECKING, Any
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):
"""Provider size tier selected from the source dimensions."""
SMALL = "small"
LARGE = "large"
@dataclass
class DetectionResult:
"""Detection decision and its component scores."""
detected: bool = False
confidence: float = 0.0
region: tuple[int, int, int, int] = (0, 0, 0, 0)
size: WatermarkSize = WatermarkSize.SMALL
spatial_score: float = 0.0
gradient_score: float = 0.0
variance_score: float = 0.0
@dataclass(frozen=True, slots=True)
class WatermarkPosition:
"""Expected provider margins and logo size."""
margin_right: int
margin_bottom: int
logo_size: int
def get_position(self, image_width: int, image_height: int) -> tuple[int, int]:
return image_width - self.margin_right - self.logo_size, image_height - self.margin_bottom - self.logo_size
@dataclass(frozen=True, slots=True)
class _Candidate:
scale: int
x: int
y: int
spatial: float
gradient: float = 0.0
variance: float = 0.0
@property
def fused(self) -> float:
if self.spatial < 0.25:
return max(0.0, self.spatial * 0.5)
return self.spatial * 0.50 + self.gradient * 0.30 + self.variance * 0.20
@dataclass(frozen=True, slots=True)
class _SparkleScan:
"""The provenance-BLIND half of sparkle detection, reusable across trust levels.
``source`` is the BGR-normalized image the false-positive gate re-reads, ``best``
the winning candidate, and ``base`` a template result carrying everything the scan
already resolved (``size`` and, when a candidate won, ``region`` and the component
scores). ``best is None`` covers both no-candidate cases; ``base`` distinguishes
them, since an empty image never resolves a ``size`` and a candidate-less one does.
Per-CALL only, never cached on the engine: ``remove_auto_marks`` re-invokes each
engine on a progressively cleaned frame within one process, so a memo on ``self``
would hand back a pre-fill scan of a different image.
"""
source: NDArray[Any] | None
best: _Candidate | None
base: DetectionResult
def get_watermark_size(width: int, height: int) -> WatermarkSize:
"""Return the provider's large tier only when both axes exceed 1024."""
return WatermarkSize.LARGE if width > 1024 and height > 1024 else WatermarkSize.SMALL
def get_watermark_config(width: int, height: int) -> WatermarkPosition:
"""Return the observed standard placement for the selected size tier."""
if get_watermark_size(width, height) is WatermarkSize.LARGE:
return WatermarkPosition(64, 64, 96)
return WatermarkPosition(32, 32, 48)
def _calculate_alpha_map(background_capture: NDArray[Any]) -> NDArray[Any]:
"""Convert a black-background sparkle capture to a normalized opacity map."""
if background_capture.ndim == 2:
intensity = background_capture
elif background_capture.shape[2] >= 3:
intensity = background_capture[:, :, :3].max(axis=2)
else:
intensity = background_capture[:, :, 0]
return intensity.astype(np.float32) / 255.0
def _load_capture(filename: str, expected_side: int) -> NDArray[Any]:
capture = image_io.imread(Path(__file__).parent / "assets" / filename, cv2.IMREAD_COLOR)
if capture is None:
raise RuntimeError(f"Failed to decode embedded asset: {filename}")
if capture.shape[:2] != (expected_side, expected_side):
capture = cv2.resize(capture, (expected_side, expected_side), interpolation=cv2.INTER_AREA)
return capture
def _gray_float(image: NDArray[Any]) -> NDArray[Any]:
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if image.ndim == 3 and image.shape[2] >= 3 else image
return gray.astype(np.float32) / 255.0
def _overlaps(candidate: _Candidate, selected: _Candidate) -> bool:
radius = 0.5 * max(candidate.scale, selected.scale)
return abs(candidate.x - selected.x) < radius and abs(candidate.y - selected.y) < radius
_TEMPLATE_SCALES = tuple(range(16, 120, 2))
class GeminiEngine:
"""Project-native detector and mask builder for the white Gemini sparkle."""
_CORE_ALPHA_FRAC = 0.8
_SPARKLE_FP_CONF = 0.65
_SPARKLE_FP_MARGIN = 5.0
_SPARKLE_FP_GRAD = 0.55
_SPARKLE_KEEP_CONF = 0.52
_SPARKLE_WHITE_SAT = 0.20
_CORNER_PROMOTE_NCC = 0.85
_CORNER_PROMOTE_FRAC = 0.20
_CORNER_PROMOTE_MIN = 96
_CORNER_PROMOTE_MAX = 384
_SELECT_TOPK = 3
_MASK_ALPHA = 0.04
_MASK_DILATE_FRAC = 0.18
def __init__(self, logo_value: float = 255.0) -> None:
self.logo_value = logo_value
self._alpha_small = _calculate_alpha_map(_load_capture("gemini_bg_48.png", 48))
self._alpha_large = _calculate_alpha_map(_load_capture("gemini_bg_96.png", 96))
self._tmpl_cache: dict[int, NDArray[Any]] = {
side: cv2.resize(self._alpha_large, (side, side), interpolation=cv2.INTER_AREA) for side in _TEMPLATE_SCALES
}
def get_alpha_map(self, size: WatermarkSize) -> NDArray[Any]:
return self._alpha_small if size is WatermarkSize.SMALL else self._alpha_large
def get_interpolated_alpha(self, size_px: int) -> NDArray[Any]:
if size_px == self._alpha_large.shape[1]:
return self._alpha_large.copy()
method = cv2.INTER_LINEAR if size_px > self._alpha_large.shape[1] else cv2.INTER_AREA
return cv2.resize(self._alpha_large, (size_px, size_px), interpolation=method)
def _scan_scales(self, gray: NDArray[Any]) -> Iterator[tuple[int, float, tuple[int, int]]]:
"""Yield the strongest normalized template match at every usable scale."""
height, width = gray.shape[:2]
for side, template in self._tmpl_cache.items():
if side > height or side > width:
continue
response = cv2.matchTemplate(gray, template, cv2.TM_CCOEFF_NORMED)
_minimum, maximum, _min_location, max_location = cv2.minMaxLoc(response)
yield side, float(maximum), max_location
def _global_candidates(self, image: NDArray[Any]) -> list[_Candidate]:
height, width = image.shape[:2]
search_side = min(height, width, 512)
origin_x, origin_y = width - search_side, height - search_side
gray = _gray_float(image[origin_y:height, origin_x:width])
ranked = sorted(
(
(
score * min(1.0, (side / 96.0) ** 0.5),
_Candidate(side, origin_x + location[0], origin_y + location[1], score),
)
for side, score, location in self._scan_scales(gray)
),
key=lambda item: (item[0], item[1].scale, item[1].spatial, item[1].x, item[1].y),
reverse=True,
)
selected: list[_Candidate] = []
for _weighted, candidate in ranked:
if any(_overlaps(candidate, prior) for prior in selected):
continue
selected.append(candidate)
if len(selected) == self._SELECT_TOPK:
break
return selected
def _score_candidate(self, image: NDArray[Any], candidate: _Candidate) -> _Candidate:
if candidate.spatial < 0.25:
return candidate
gradient, variance = self._grad_var_scores(image, candidate.scale, candidate.x, candidate.y)
return _Candidate(candidate.scale, candidate.x, candidate.y, candidate.spatial, gradient, variance)
def detect_watermark(
self,
image: NDArray[Any],
force_size: WatermarkSize | None = None,
*,
trust_provenance: bool = False,
) -> DetectionResult:
"""Return the strongest sparkle-shaped bottom-right candidate."""
scan = self._sparkle_scan(image, force_size)
return self._verdict(scan, trust_provenance=trust_provenance)
def detect_watermark_both(
self, image: NDArray[Any], force_size: WatermarkSize | None = None
) -> tuple[DetectionResult, DetectionResult]:
"""``(strict, relaxed)`` from ONE scan of the image.
The scan -- global candidate search, corner promotion and fused scoring -- is
provenance-blind; ``trust_provenance`` only decides whether the false-positive
gate demotes the confidence afterwards. Two calls therefore repeated the whole
sweep to reach two verdicts.
The two results are DISTINCT objects with genuinely different confidences (the
gate rewrites one of them), and callers mutate them.
"""
scan = self._sparkle_scan(image, force_size)
return (
self._verdict(scan, trust_provenance=False),
self._verdict(scan, trust_provenance=True),
)
def _sparkle_scan(self, image: NDArray[Any], force_size: WatermarkSize | None) -> _SparkleScan:
"""Everything in detection that does not depend on the trust level."""
if image is None or image.size == 0:
return _SparkleScan(None, None, DetectionResult())
source = image_io.to_bgr(image)
height, width = source.shape[:2]
size = force_size or get_watermark_size(width, height)
candidates = self._global_candidates(source)
promoted = self._corner_promote(source, candidates[0].spatial if candidates else -1.0)
if promoted is not None:
candidates.append(_Candidate(promoted[0], promoted[1], promoted[2], promoted[3]))
# The no-candidate result is NOT the empty-image one: `size` is already resolved
# here, and it is a public field the caller can force.
base = DetectionResult(size=size)
if not candidates:
return _SparkleScan(None, None, base)
best = max((self._score_candidate(source, candidate) for candidate in candidates), key=lambda item: item.fused)
base.region = (best.x, best.y, best.scale, best.scale)
base.spatial_score = float(best.spatial)
base.gradient_score = float(best.gradient)
base.variance_score = float(best.variance)
return _SparkleScan(source, best, base)
def _verdict(self, scan: _SparkleScan, *, trust_provenance: bool) -> DetectionResult:
"""Apply the trust-level-dependent tail to a scan, as a fresh result object."""
result = replace(scan.base)
if scan.best is None or scan.source is None:
return result
best = scan.best
confidence = best.fused
if best.spatial >= 0.25 and confidence < self._SPARKLE_FP_CONF and not trust_provenance:
confidence = self._apply_false_positive_gate(scan.source, best, confidence)
result.confidence = float(np.clip(confidence, 0.0, 1.0))
result.detected = result.confidence >= 0.35
return result
def _apply_false_positive_gate(self, image: NDArray[Any], candidate: _Candidate, confidence: float) -> float:
alpha = self.get_interpolated_alpha(candidate.scale)
position = (candidate.x, candidate.y)
margin = self._core_ring_margin(image, alpha, position)
low_margin = margin is not None and margin < self._SPARKLE_FP_MARGIN
low_gradient = candidate.gradient < self._SPARKLE_FP_GRAD
if not low_margin and not low_gradient:
return confidence
saturation = self._core_saturation(image, alpha, position)
neutral_core = not low_margin and saturation is not None and saturation <= self._SPARKLE_WHITE_SAT
if confidence >= self._SPARKLE_KEEP_CONF and neutral_core:
return confidence
logger.debug(
"Sparkle candidate demoted: confidence=%.3f, margin=%s, gradient=%.3f, saturation=%s",
confidence,
margin,
candidate.gradient,
saturation,
)
return min(confidence, 0.30)
def _grad_var_scores(self, image: NDArray[Any], scale: int, pos_x: int, pos_y: int) -> tuple[float, float]:
height, width = image.shape[:2]
x2, y2 = min(width, pos_x + scale), min(height, pos_y + scale)
region = image[pos_y:y2, pos_x:x2]
gray = _gray_float(region)
alpha = self.get_interpolated_alpha(scale)[: y2 - pos_y, : x2 - pos_x]
image_edges = cv2.magnitude(
cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize=3),
cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3),
)
alpha_edges = cv2.magnitude(
cv2.Sobel(alpha, cv2.CV_32F, 1, 0, ksize=3),
cv2.Sobel(alpha, cv2.CV_32F, 0, 1, ksize=3),
)
response = cv2.matchTemplate(image_edges, alpha_edges, cv2.TM_CCOEFF_NORMED)
_minimum, gradient, _min_location, _max_location = cv2.minMaxLoc(response)
variance = 0.0
reference_height = min(pos_y, scale)
if reference_height > 8:
reference = image[pos_y - reference_height : pos_y, pos_x:x2]
reference_gray = cv2.cvtColor(reference, cv2.COLOR_BGR2GRAY) if reference.ndim == 3 else reference
_mean, region_std = cv2.meanStdDev((gray * 255.0).astype(np.uint8))
_reference_mean, reference_std = cv2.meanStdDev(reference_gray)
if reference_std[0][0] > 5.0:
variance = float(np.clip(1.0 - region_std[0][0] / reference_std[0][0], 0.0, 1.0))
return float(gradient), variance
def _corner_promote(self, image: NDArray[Any], current_raw_ncc: float) -> tuple[int, int, int, float] | None:
height, width = image.shape[:2]
desired = round(min(width, height) * self._CORNER_PROMOTE_FRAC)
side = min(min(width, height), max(self._CORNER_PROMOTE_MIN, min(self._CORNER_PROMOTE_MAX, desired)))
origin_x, origin_y = width - side, height - side
matches = self._scan_scales(_gray_float(image[origin_y:height, origin_x:width]))
best = max(matches, key=lambda item: item[1], default=None)
if best is None or best[1] < self._CORNER_PROMOTE_NCC or best[1] <= current_raw_ncc:
return None
return best[0], origin_x + best[2][0], origin_y + best[2][1], float(best[1])
def footprint_mask(
self,
image: NDArray[Any],
*,
force: bool = False,
dilate: int | None = None,
region: tuple[int, int, int, int] | None = None,
) -> NDArray[Any] | None:
"""Build a full-frame mask from a resolved or newly detected sparkle."""
if image is None or image.size == 0:
return None
source = image_io.to_bgr(image)
height, width = source.shape[:2]
if region is not None:
x, y, scale = region[:3]
else:
detection = self.detect_watermark(source)
if detection.detected:
x, y, scale = detection.region[:3]
elif force:
config = get_watermark_config(width, height)
x, y = config.get_position(width, height)
scale = config.logo_size
else:
return None
placed = self._footprint_indices(self.get_interpolated_alpha(scale), (x, y), source.shape)
if placed is None:
return None
alpha, (y1, y2, x1, x2) = placed
silhouette = (alpha > self._MASK_ALPHA).astype(np.uint8) * 255
if not silhouette.any():
return None
mask = np.zeros((height, width), dtype=np.uint8)
mask[y1:y2, x1:x2] = silhouette
radius = dilate if dilate is not None else max(13, int(scale * self._MASK_DILATE_FRAC))
if radius > 0:
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * radius + 1, 2 * radius + 1))
mask = cv2.dilate(mask, kernel)
return mask
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:
x, y = position
alpha_height, alpha_width = alpha_map.shape[:2]
image_height, image_width = image_shape[:2]
x1, y1 = max(0, x), max(0, y)
x2, y2 = min(image_width, x + alpha_width), min(image_height, y + alpha_height)
if x1 >= x2 or y1 >= y2:
return None
alpha_x, alpha_y = x1 - x, y1 - y
clipped = alpha_map[alpha_y : alpha_y + y2 - y1, alpha_x : alpha_x + x2 - x1]
return clipped, (y1, y2, x1, x2)
def _core_mask_and_box(
self,
image: NDArray[Any],
alpha_map: NDArray[Any],
position: tuple[int, int],
) -> tuple[NDArray[Any], NDArray[Any], tuple[int, int, int, int], float] | None:
placed = self._footprint_indices(alpha_map, position, image.shape)
if placed is None:
return None
alpha, bounds = placed
peak = float(alpha.max())
if peak < 0.2:
return None
core = alpha >= peak * self._CORE_ALPHA_FRAC
if not core.any():
return None
y1, y2, x1, x2 = bounds
return core, image[y1:y2, x1:x2], bounds, peak
def _core_and_bg(
self,
image: NDArray[Any],
alpha_map: NDArray[Any],
position: tuple[int, int],
) -> tuple[float, float, float] | None:
sample = self._core_mask_and_box(image, alpha_map, position)
if sample is None:
return None
core, _box, (y1, y2, x1, x2), peak = sample
height, width = image.shape[:2]
padding = int((x2 - x1) * 0.7)
ry1, ry2 = max(0, y1 - padding), min(height, y2 + padding)
rx1, rx2 = max(0, x1 - padding), min(width, x2 + padding)
luminance = image[ry1:ry2, rx1:rx2].astype(np.float32).mean(axis=2)
fy1, fy2, fx1, fx2 = y1 - ry1, y2 - ry1, x1 - rx1, x2 - rx1
core_value = float(np.percentile(luminance[fy1:fy2, fx1:fx2][core], 75))
background = np.ones(luminance.shape, dtype=bool)
background[fy1:fy2, fx1:fx2] = False
if background.sum() < 10:
return None
return core_value, float(np.median(luminance[background])), peak
def _core_ring_margin(
self,
image: NDArray[Any],
alpha_map: NDArray[Any],
position: tuple[int, int],
) -> float | None:
sample = self._core_and_bg(image, alpha_map, position)
return None if sample is None else sample[0] - sample[1]
def _core_saturation(
self,
image: NDArray[Any],
alpha_map: NDArray[Any],
position: tuple[int, int],
) -> float | None:
sample = self._core_mask_and_box(image, alpha_map, position)
if sample is None:
return None
core, box, _bounds, _peak = sample
pixels = box[core].astype(np.float32)
brightest = pixels.max(axis=1)
darkest = pixels.min(axis=1)
return float(np.median((brightest - darkest) / (brightest + 1.0)))
@functools.lru_cache(maxsize=1)
def _shared_engine() -> GeminiEngine:
return GeminiEngine()
def detect_sparkle_confidence(image_path: Path, *, image: NDArray[Any] | None = None) -> float | None:
"""Return the local sparkle confidence, or None when decoding fails."""
decoded = image if image is not None else image_io.imread(image_path)
if decoded is None:
return None
return float(_shared_engine().detect_watermark(decoded).confidence)