"""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)