mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-09 15:36:01 +02:00
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>
480 lines
20 KiB
Python
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)
|