Files
remove-ai-watermarks/src/remove_ai_watermarks/_text_mark_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

835 lines
42 KiB
Python

"""Shared base for visible text-mark detectors and localizers.
Each mark supplies a :class:`TextMarkConfig` with its silhouette, expected area,
scale ladder, detector frontend, and calibrated gates. The shared engine locates the
candidate, scores the silhouette with normalized correlation, and builds a removal
footprint for the common fill backend. Individual engines may override detection or
footprint behavior when their measured variant requires it.
The removal path never performs reverse-alpha pixel recovery. Gemini and the Jimeng
pill remain separate engines because their geometry and gating differ from text marks.
"""
# cv2/numpy boundary: third-party libs ship no usable element types; relax the
# unknown-type rules for this file only.
# 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 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 numpy.typing import NDArray
logger = logging.getLogger(__name__)
# Minimum image short side (px) for text-mark DETECTION. Below this the glyph
# template degrades to the ``min_gw`` floor (~8 px) and TM_CCOEFF_NORMED on a few
# pixels is noise, so an unrelated small geometric shape can spuriously correlate
# with the CJK silhouette (2026-06-26 FP: a 48x48 app icon -- a blue chevron --
# scored Doubao 0.41 / Jimeng 0.47, both above their thresholds). The FP is purely
# a small-size artifact: the same icon upscaled collapses to ~0.06-0.10 NCC at 256
# px and above. A real AI-generation text label is stamped on a full-resolution
# render (the captured samples are 1086-2048 px wide), so 200 px sits far below any
# genuine mark while killing the icon/thumbnail noise band (<=96 px). Detection is
# skipped (verdict stays "unknown", the safe default) rather than risk a false
# positive; removal is gated on detection, so it is suppressed too.
_MIN_DETECT_SHORT_SIDE = 200
# Provenance-confirmed NCC relaxation. When external metadata already confirms the
# vendor (so the mark is present with high prior), a faint or slightly re-rendered
# glyph that scores just below the standard NCC gate is still trusted. The relaxed
# gate is ``detect_ncc_threshold * provenance_ncc_factor``; the coverage gate still
# applies on top.
#
# This used to be ONE shared 0.7 for every text mark. Measured 2026-07-18 on the
# `auto` path (the default -- no flag, driven by TC260 metadata), it turned out to
# mean two completely different things per mark. Blind hand-label of the ADDITIONS
# (accepted with provenance, rejected without) over a labelled TC260 evaluation set,
# two-sided control (labeller sensitivity 100%/96%, specificity 100%/100%):
#
# mark band precision 95% CI n
# doubao whole arm 76% 61-87% 42
# [0.280,0.340) 58% 36-77% 19
# [0.340,0.400) 91% 73-98% 23
# jimeng whole arm 17% 10-27% 82
# [0.315,0.383) 12% 6-22% 68
# [0.383,0.450) 43% 21-67% 14
#
# Doubao stays at 0.70: both its bands return more true marks than false fills, so
# tightening would cost 11 genuine recoveries to prevent 8 false ones.
#
# Jimeng moves to 0.85. Its relaxed detector does not key on the "★ 即梦AI" wordmark
# any more -- it keys on "some text in the bottom-right corner": of 68 false
# additions, 33 were DOUBAO marks and 17 were other vendors' AI labels (千问, 百度,
# 星绘, 抖音). 45 of those 68 fill a corner nothing else would touch (the other 23
# are harmless -- doubao fires strictly there and fills the same box anyway). At
# 0.85 the [0.315,0.383) band is dropped: 8 genuine recoveries lost, 60 false fills
# prevented (7.5:1). A false fill is the worse error -- it destroys pixels AND makes
# the caller report a removal that did not happen, while a miss leaves the image
# untouched.
#
# NOTE: 0.85 is a patch on a detector problem, not a fix. Jimeng's silhouette is not
# discriminative against Doubao's (same corner, same script, both ByteDance), and no
# threshold repairs that -- it needs a better detection silhouette.
_DEFAULT_PROVENANCE_NCC_FACTOR = 0.7
@dataclass(frozen=True)
class TextMarkConfig:
"""All per-mark tuning for a text-mark detector/localizer."""
name: str # short label for log lines (e.g. "Doubao")
asset_name: str # bundled alpha PNG under assets/ (e.g. "doubao_alpha.png")
corner: Literal[
"br", "bl", "tl", "bc"
] # bottom-right (Doubao/Jimeng), bottom-left (Samsung), top-left (RunningHub), bottom-center (LibLibAI)
margin_floor: int # min margin in px for locate (4 for br marks, 2 for Samsung)
# locate geometry (fraction of scale_base -- see scale_base())
width_frac: float
height_frac: float
margin_x_frac: float # right margin (br) or left margin (bl)
margin_bottom_frac: float
# glyph appearance
max_saturation: float
logo_min_luma: float
tophat_delta: float
morph_open_size: int # MORPH_OPEN kernel side (5 for br marks, 3 for Samsung)
# detection
detect_min_coverage: float
detect_ncc_threshold: float
# alpha-map glyph geometry (fraction of scale_base) emitted by
# scripts/visible_alpha_solve.py, sizing the detection silhouette for
# template_match_score
alpha_width_frac: float
alpha_height_frac: float
min_gw: int # minimum glyph width for the template match (8 br, 16 Samsung)
# Asset names of RIVAL marks that occupy the same corner and can therefore be
# scored against the same glyph blob. Detection becomes COMPETITIVE: this mark's
# template must beat every rival's by `rival_margin`. See _rival_margin_ok.
# Detection front-end. "binary" thresholds the top-hat into a glyph blob and
# correlates a binary silhouette against it; "tophat" correlates the CONTINUOUS
# top-hat response against a soft template and never binarizes. See
# TextMarkEngine.tophat_response for the measurement that motivated the split.
# "gray" correlates the silhouette against the raw GRAYSCALE of the locate box:
# for a faint mid-gray mark (RunningHub) the top-hat's background-subtraction and
# max-normalization suppress the response to clean-arm levels (positives 0.16-0.23
# vs clean p99 0.31), while raw gray NCC separates (positives 0.38-0.54 vs clean
# p99 0.264 / max 0.304, measured 2026-07-22). Contrast-DEPENDENT, unlike tophat.
# "contrast" correlates against the ABSOLUTE local-luma residual. It is for a mark
# whose renderer switches between light-on-dark and dark-on-light while preserving
# one silhouette (Tencent Yuanbao); a one-polarity white top-hat misses the latter.
detect_frontend: Literal["binary", "tophat", "gray", "contrast"] = "binary"
# Gaussian sigma applied to the template in the "tophat" front-end (0 = none).
template_blur: float = 0.0
# Which image dimension the mark's size and margins scale with. VENDOR-SPECIFIC,
# measured, not assumed -- see TextMarkEngine.scale_base. "short" = min(h, w), "width" = w.
scale_basis: Literal["short", "width"] = "width"
# Scale rungs ``_ladder_best`` sweeps (the detection comb). PER-MARK: a vendor
# whose stamp sizes do not land on the shared 3-rung comb carries its own ladder
# (measured for 千问, whose marks sit in two size modes ~1.6x apart -- one fraction
# on 3 rungs covers only ~75% of them). Densifying the SHARED ladder for everyone
# was measured and rejected (false fire 2.52% -> 3.05%; see docs/verification-plan.md
# B2), so the default stays the shipped 3 rungs and a deviation must be calibrated
# per mark on real positives, never ported.
ladder: tuple[float, ...] = (0.8, 1.0, 1.25)
rivals: tuple[str, ...] = ()
rival_margin: float = 0.10
# Multiplier applied to detect_ncc_threshold when provenance confirms the vendor.
# Per-mark, NOT shared: see _DEFAULT_PROVENANCE_NCC_FACTOR for the measured
# precision that forced the split. Last field so it can carry a default.
provenance_ncc_factor: float = _DEFAULT_PROVENANCE_NCC_FACTOR
@dataclass
class TextMarkLocation:
"""Located watermark box, in absolute pixel coordinates."""
x: int
y: int
w: int
h: int
@property
def bbox(self) -> tuple[int, int, int, int]:
return self.x, self.y, self.w, self.h
@dataclass
class TextMarkDetection:
"""Result of visible text-mark detection."""
detected: bool = False
confidence: float = 0.0
region: tuple[int, int, int, int] = (0, 0, 0, 0)
coverage: float = 0.0 # fraction of the box occupied by glyph pixels
# ROI-local (x0, y0, x1, y1) of the ladder sweep's best match, in the LOCATED BOX's
# coordinates. None for the ``binary`` front-end (it runs no sweep) and whenever no
# rung matched. ``footprint_mask`` bounds the fill with it, so carrying it here is
# what stops the mask path re-running a sweep the detector already ran.
match_box: tuple[int, int, int, int] | None = None
# The trust level this detection was taken at, mirroring detect()'s ``provenance``.
# ``footprint_mask`` reuses a threaded detection only when it matches the STRICT
# level its own re-detect would have used -- see TextMarkEngine._strict_detection.
provenance: bool = False
@dataclass(frozen=True)
class TextMarkScan:
"""The trust-level-BLIND half of text-mark detection, reusable across both levels.
``loc is None`` means detection stopped before any scan (empty or too-small image).
``score is None`` means the binary front-end fell below its coverage gate, which is
a verdict of "not detected, confidence 0.0" without consulting the rival margin.
"""
loc: TextMarkLocation | None
box: NDArray[Any] | None # box-sized binary glyph mask
base: int # scale_base(image)
frame: tuple[int, int] = (0, 0) # (h, w) of the scanned image
coverage: float = 0.0
score: float | None = None
match_box: tuple[int, int, int, int] | None = None
# Alpha / silhouette templates, cached per asset name. This shared cache lets every
# text-mark engine reuse the loader without re-reading an asset. Only SUCCESSFUL loads
# are cached, so a missing asset is retried.
_alpha_cache: dict[str, NDArray[Any]] = {}
_silhouette_cache: dict[str, NDArray[Any]] = {}
def load_alpha_template(asset_name: str) -> NDArray[Any] | None:
"""Lazily load the bundled alpha template (float [0,1]) for ``asset_name``, or None."""
cached = _alpha_cache.get(asset_name)
if cached is not None:
return cached
path = Path(__file__).parent / "assets" / asset_name
img = image_io.imread(str(path), cv2.IMREAD_GRAYSCALE)
if img is None:
return None
_alpha_cache[asset_name] = img.astype(np.float32) / 255.0
return _alpha_cache[asset_name]
def glyph_silhouette(asset_name: str) -> NDArray[Any] | None:
"""Binary glyph silhouette (255 = glyph) from the bundled alpha map, or None."""
cached = _silhouette_cache.get(asset_name)
if cached is not None:
return cached
at = load_alpha_template(asset_name)
if at is None:
return None
_silhouette_cache[asset_name] = (at > 0.15).astype(np.uint8) * 255
return _silhouette_cache[asset_name]
_RIVAL_MODULES = {
"doubao_alpha.png": "remove_ai_watermarks.doubao_engine",
"jimeng_alpha.png": "remove_ai_watermarks.jimeng_engine",
"samsung_alpha.png": "remove_ai_watermarks.samsung_engine",
}
def _rival_config(asset_name: str, fallback: TextMarkConfig) -> TextMarkConfig:
"""The rival mark's own config, for scoring its template on a shared blob.
Looked up LAZILY by asset name: a rival's template geometry
(``alpha_*_frac`` / ``min_gw``) is its own, and scoring it with this mark's
geometry would compare a correctly-sized template against a mis-sized one and
hand the margin a free win. Lazy because the engine modules import this one.
"""
mod_path = _RIVAL_MODULES.get(asset_name)
if mod_path is None:
return fallback
from importlib import import_module
try:
return import_module(mod_path)._CONFIG
except Exception: # a missing/renamed engine must not break detection
logger.debug("rival config %s unavailable; skipping its margin check.", asset_name)
return fallback
def template_match_score(box_mask: NDArray[Any], scale_base: int, config: TextMarkConfig) -> float:
"""Zero-mean normalized correlation of the alpha-template glyph silhouette
(scaled to the mark's expected size) against the candidate ``box_mask``.
``TM_CCOEFF_NORMED`` keys on glyph SHAPE, not coverage, so a dense textured
corner does not score highly -- only the actual glyph shape does.
``scale_base`` is the mark's own scaling dimension (:meth:`TextMarkEngine.scale_base`),
not always the width: sizing the template on the wrong basis stretches it by the
aspect ratio on landscape inputs and the correlation collapses.
"""
sil = glyph_silhouette(config.asset_name)
if sil is None or box_mask.size == 0:
return 0.0
gw = min(box_mask.shape[1] - 1, max(config.min_gw, int(config.alpha_width_frac * scale_base)))
gh = min(box_mask.shape[0] - 1, max(4, int(config.alpha_height_frac * scale_base)))
if gw < config.min_gw or gh < 4:
return 0.0
template = cv2.resize(sil, (gw, gh), interpolation=cv2.INTER_NEAREST)
return float(cv2.matchTemplate(box_mask, template, cv2.TM_CCOEFF_NORMED).max())
class TextMarkEngine:
"""Visible text-mark detector/localizer (locate -> mask -> detect; mask feeds the fill)."""
def __init__(self, config: TextMarkConfig) -> None:
self.config = config
# ── Templates (delegate to the asset-keyed module cache) ────────────
def _alpha_template(self) -> NDArray[Any] | None:
return load_alpha_template(self.config.asset_name)
def _glyph_silhouette(self) -> NDArray[Any] | None:
return glyph_silhouette(self.config.asset_name)
def _template_match_score(self, box_mask: NDArray[Any], scale_base: int) -> float:
return template_match_score(box_mask, scale_base, self.config)
def _rival_margin_ok(self, score: float, box_mask: NDArray[Any], scale_base: int) -> bool:
"""Whether this mark's template beats every same-corner RIVAL's on the SAME blob.
Detection was purely ABSOLUTE -- each engine scored its own template and
compared against its own threshold, so nothing ever asked the discriminative
question "does this blob look more like the neighbour's mark than like mine?".
Two marks sharing a corner and a script (Doubao "豆包AI生成" and Jimeng
"★ 即梦AI", both bottom-right, both near-white CJK) survive binarization into
very similar blobs, so an absolute gate cannot separate them -- and under the
provenance relaxation it stopped trying because many Jimeng false additions
were actually Doubao marks.
Measured separability on hand-labelled examples, scoring BOTH templates
against the same glyph blob:
feature separability (0.5 = useless, 1.0 = perfect)
absolute ncc_jimeng 0.96
ncc_jimeng MINUS ncc_doubao 0.99
A 0.10 margin separated Jimeng wordmarks from the rival and clean examples
without reducing recall, unlike raising the absolute threshold.
Marks with no same-corner rival declare `rivals=()` and are unaffected.
"""
c = self.config
if not c.rivals:
return True
for rival_asset in c.rivals:
rival = _rival_config(rival_asset, c)
if score - template_match_score(box_mask, scale_base, rival) < c.rival_margin:
logger.debug("%s detect: loses the %s rival margin; rejecting.", c.name, rival_asset)
return False
return True
# ── Locate ──────────────────────────────────────────────────────────
def _roi_fields(
self, image: NDArray[Any], loc: TextMarkLocation
) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
"""``(luma, saturation, local_background)`` for the located box, all float32.
The ROI is normalized to 3-channel BGR first (grayscale / BGRA would break
``axis=2``).
Local background model: a strong Gaussian blur (sigma ~ box height); the white
top-hat (``luma - local_bg``) lights up bright thin strokes regardless of the
absolute background level.
The callers each keep their own ``bh < 16 or bw < 16`` guard: they return three
different sentinels for a degenerate ROI, so the check cannot move in here.
"""
x, y, bw, bh = loc.bbox
roi = image_io.to_bgr(image[y : y + bh, x : x + bw]).astype(np.float32)
luma = roi.mean(axis=2)
sat = roi.max(axis=2) - roi.min(axis=2)
sigma = max(4.0, bh * 0.4) # 0.4 factor and 4.0 floor are calibrated; do not retune
return luma, sat, cv2.GaussianBlur(luma, (0, 0), sigmaX=sigma, sigmaY=sigma)
def tophat_response(self, image: NDArray[Any], loc: TextMarkLocation) -> NDArray[Any] | None:
"""The CONTINUOUS white top-hat in the located box -- the glyph signal, unbinarized.
:meth:`extract_mask` thresholds this same response into a 0/255 glyph blob. That
is fine for a mark stamped bold and opaque, and destructive for a faint one: a
thin translucent overlay shatters into specks under the threshold, and no
template can match a blob that is not there.
Calibration on hand-verified examples, scoring each mark with its own template:
front-end doubao clean neg AUC doubao/neg
binary 0.723 ~0.12 --
tophat 0.781 0.122 1.00
The gates that were hard cuts in the binary path (saturation, absolute luma)
become WEIGHTS here, so a faint stroke contributes in proportion to its strength
instead of being dropped at a threshold. The response is max-normalized, which
makes the score contrast-invariant -- the point of the exercise.
Kept per-mark (``detect_frontend``) rather than switched globally, because a
front-end change must be measured per mark before it ships.
"""
return self._residual_response(image, loc, absolute=False)
def _residual_response(self, image: NDArray[Any], loc: TextMarkLocation, *, absolute: bool) -> NDArray[Any] | None:
"""Max-normalized uint8 local-luma residual in the located box, saturation-weighted.
``absolute=False`` keeps only the POSITIVE side -- the white top-hat, for a mark
always rendered brighter than its background. ``absolute=True`` takes the
magnitude, for a renderer that switches between light-on-dark and dark-on-light
while preserving one silhouette; a one-polarity top-hat misses the latter.
"""
c = self.config
_x, _y, bw, bh = loc.bbox
if bh < 16 or bw < 16:
return None
luma, sat, local_bg = self._roi_fields(image, loc)
residual = luma - local_bg
resp = (np.abs(residual) if absolute else np.clip(residual, 0, None)) * (sat < c.max_saturation)
peak = float(resp.max())
if peak <= 1e-6:
return None
return (resp / peak * 255).astype(np.uint8)
def _detect_response(self, image: NDArray[Any], loc: TextMarkLocation) -> NDArray[Any] | None:
"""The uint8 image the ladder sweep correlates the silhouette against, chosen by
``TextMarkConfig.detect_frontend``. ``binary`` runs no sweep and never reaches here."""
frontend = self.config.detect_frontend
if frontend == "gray":
x, y, bw, bh = loc.bbox
if bh < 16 or bw < 16:
return None
return cv2.cvtColor(image_io.to_bgr(image[y : y + bh, x : x + bw]), cv2.COLOR_BGR2GRAY)
if frontend == "contrast":
return self._residual_response(image, loc, absolute=True)
if frontend == "tophat":
return self._residual_response(image, loc, absolute=False)
raise ValueError(f"{frontend!r} has no ladder response (binary runs no sweep)")
def _ladder_best(
self, image: NDArray[Any], loc: TextMarkLocation
) -> tuple[float, tuple[int, int, int, int] | None]:
"""Best TM_CCOEFF_NORMED of the mark's silhouette against its front-end response,
and the ROI-local box (x0, y0, x1, y1) where that best match sits.
Sweeps the mark's scale ladder: the nominal glyph size is derived from the mark's
geometry, but a vendor re-rasterization shifts it by a few percent and the
continuous response is sharp enough that an exact-size template would miss. The
ladder is per-mark (``TextMarkConfig.ladder``), defaulting to the shipped 3 rungs.
Detection and the removal mask BOTH read this one method -- the score gates
detection, the box bounds the fill. Sharing it is deliberate: the standing rule is
that detection and the mask use the same front-end, and the way that rule was last
broken was a drift between two separate implementations. One method makes the drift
impossible instead of merely discouraged -- which is why the three continuous
front-ends (tophat / contrast / gray) sweep here rather than in a copy each.
Tie-breaking is load-bearing: the ``0.0`` seed plus the STRICT ``>`` means the
EARLIEST ladder rung wins a tie, and a sweep whose maximum is not above 0.0
returns no box at all.
"""
c = self.config
resp = self._detect_response(image, loc)
sil = self._glyph_silhouette()
if resp is None or sil is None:
return (0.0, None)
base = self.scale_base(image)
best_score = 0.0
best_box: tuple[int, int, int, int] | None = None
for scale in c.ladder:
gw = max(c.min_gw, int(c.alpha_width_frac * base * scale))
gh = max(4, int(c.alpha_height_frac * base * scale))
if gw >= resp.shape[1] or gh >= resp.shape[0]:
continue
tmpl = cv2.resize(sil, (gw, gh), interpolation=cv2.INTER_AREA)
if c.detect_frontend == "tophat" and c.template_blur > 0:
tmpl = cv2.GaussianBlur(
tmpl.astype(np.float32), (0, 0), sigmaX=c.template_blur, sigmaY=c.template_blur
).astype(np.uint8)
result = cv2.matchTemplate(resp, tmpl, cv2.TM_CCOEFF_NORMED)
_, score, _, top_left = cv2.minMaxLoc(result)
if score > best_score:
tx, ty = int(top_left[0]), int(top_left[1])
best_score, best_box = float(score), (tx, ty, tx + gw - 1, ty + gh - 1)
return (best_score, best_box)
def scale_base(self, image: NDArray[Any]) -> int:
"""The image dimension this mark's geometry scales with.
Per-mark, and MEASURED -- a single shared basis is wrong. The tuned fractions
were all calibrated on PORTRAIT captures, where width and short side coincide,
so the basis was never exercised until landscape inputs were measured.
Detector calibration showed that a width-scaled box is inflated by the aspect
ratio on a wide image and can miss the glyph entirely. A short-side basis
recovered the affected Doubao landscape cases.
The same switch broke Jimeng landscape positives because its wordmark tracks
the WIDTH. Both marks are ByteDance and share a
corner, and they still scale differently -- so this is a per-mark measurement,
not a house rule to generalize. Samsung keeps ``width`` because it has not been
calibrated for a different basis, and an unmeasured change is not an improvement.
China's GB 45438-2025 clause 5.2(e) mandates glyph height >= 5% of "the
shortest side" for CN marks, which is why a short-side basis is the natural
prior -- but Jimeng's measured behaviour overrides the prior, and measurement
wins over the standard's wording.
"""
return min(image.shape[:2]) if self.config.scale_basis == "short" else image.shape[1]
def locate(self, image: NDArray[Any]) -> TextMarkLocation:
"""Anchor the watermark box in the configured corner, scaled by ``scale_basis``.
Every fraction is taken against ``scale_base(image)`` -- see
:data:`TextMarkConfig.scale_basis`, which is per-mark because the vendors
genuinely differ.
"""
c = self.config
h, w = image.shape[:2]
base = self.scale_base(image)
wm_w = max(40, int(base * c.width_frac))
wm_h = max(16, int(base * c.height_frac))
margin_x = max(c.margin_floor, int(base * c.margin_x_frac))
margin_b = max(c.margin_floor, int(base * c.margin_bottom_frac))
if c.corner == "br":
x = max(0, w - margin_x - wm_w)
elif c.corner == "bc": # bottom-center: horizontally centered, margin_x unused
x = max(0, (w - wm_w) // 2)
else:
x = min(margin_x, max(0, w - wm_w))
# "tl" anchors at the top instead: margin_bottom_frac is then the TOP margin.
y = min(margin_b, max(0, h - wm_h)) if c.corner == "tl" else max(0, h - margin_b - wm_h)
wm_w = min(wm_w, w - x)
wm_h = min(wm_h, h - y)
return TextMarkLocation(x=x, y=y, w=wm_w, h=wm_h)
# ── Mask ────────────────────────────────────────────────────────────
def extract_mask(self, image: NDArray[Any], loc: TextMarkLocation) -> NDArray[Any]:
"""Build a box-sized uint8 mask (255 = watermark glyph) for ``loc``.
Returns just the glyph mask of the located box (shape ``(loc.h, loc.w)``),
not a full-frame array: every caller immediately crops to ``loc.bbox``, so
allocating a full ``(h, w)`` mask and embedding the box was O(image) work
and memory for an O(box) result -- a wasted full-frame uint8 allocation on
each detect (~12 MB on a 12 MP frame, recomputed per text-mark detector on
the memory-tight identify path). The box mask is byte-identical to the old
full-frame mask cropped to ``loc.bbox``.
Polarity-aware: the mark is a light, low-saturation gray rendered brighter
than the local background (white top-hat), so a white-paper document is left
untouched (nothing brighter than its surroundings is masked there).
"""
c = self.config
x, y, bw, bh = loc.bbox
# A degenerate ROI (a sliver from an extremely wide/short image) cannot hold
# the mark and would feed cv2's GaussianBlur/morphology a ~1-px-tall array,
# which can fault native code on some platforms. Skip the cv2 pipeline.
if bh < 16 or bw < 16:
return np.zeros((bh, bw), np.uint8)
# Normalize the ROI to 3-channel BGR (grayscale / BGRA would break axis=2).
roi = image_io.to_bgr(image[y : y + bh, x : x + bw]).astype(np.float32)
luma = roi.mean(axis=2)
sat = roi.max(axis=2) - roi.min(axis=2)
grayish = sat < c.max_saturation
# Local background model: a strong Gaussian blur (sigma ~ box height); the
# white top-hat (luma - local_bg) lights up bright thin strokes regardless
# of the absolute background level.
sigma = max(4.0, bh * 0.4)
local_bg = cv2.GaussianBlur(luma, (0, 0), sigmaX=sigma, sigmaY=sigma)
tophat = luma - local_bg
cand = grayish & (tophat > c.tophat_delta) & (luma > c.logo_min_luma)
glyph = cand.astype(np.uint8) * 255
glyph = cv2.morphologyEx(glyph, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8))
k = c.morph_open_size
return cv2.morphologyEx(glyph, cv2.MORPH_OPEN, np.ones((k, k), np.uint8))
# ── Detect ──────────────────────────────────────────────────────────
def detect(self, image: NDArray[Any], *, provenance: bool = False) -> TextMarkDetection:
"""Detect the mark by matching the alpha-template glyph silhouette against
the corner candidate (``TM_CCOEFF_NORMED``); keys on glyph SHAPE, not coverage.
``provenance`` signals that external metadata already confirms this vendor
(China-AIGC / byteimg for Doubao/Jimeng, ``samsung_genai`` for Samsung); the
NCC gate exists to keep a corner texture on an UNRELATED image from matching
the glyph silhouette, so when provenance confirms the vendor it is relaxed by
the mark's own ``provenance_ncc_factor`` to recover a faint or slightly
re-rendered mark (per-mark, not shared -- see _DEFAULT_PROVENANCE_NCC_FACTOR).
"""
scan = self._scan(image)
return self._verdict(scan, provenance=provenance)
def detect_both(self, image: NDArray[Any] | None) -> tuple[TextMarkDetection, TextMarkDetection]:
"""``(strict, relaxed)`` from ONE scan of the image.
``provenance`` scales the acceptance THRESHOLD and nothing else -- the locate
box, the glyph mask, the coverage and the front-end ladder score are computed
identically at either trust level. Two ``detect`` calls therefore ran the same
expensive sweep twice to reach two verdicts, which is what the arbiter's
perception pass did for every mark on every image.
Returns two DISTINCT objects: subclasses demote a verdict by mutating it.
"""
scan = self._scan(image)
return self._verdict(scan, provenance=False), self._verdict(scan, provenance=True)
def _scan(self, image: NDArray[Any] | None) -> TextMarkScan:
"""Everything in detection that does not depend on the trust level.
Per-CALL only, never memoized on ``self``: ``remove_auto_marks`` re-invokes each
engine on a progressively cleaned frame inside one process, so a cached scan
would answer for the wrong pixels.
"""
c = self.config
if image is None or image.size == 0:
return TextMarkScan(None, None, 0)
# Guard against the small-image NCC-noise false positive (see
# _MIN_DETECT_SHORT_SIDE): an icon/thumbnail is too small to carry a real
# text label, and the degraded few-pixel template spuriously correlates.
if min(image.shape[:2]) < _MIN_DETECT_SHORT_SIDE:
logger.debug(
"%s detect: image short side %d < %d; too small to carry the mark, skipping.",
c.name,
min(image.shape[:2]),
_MIN_DETECT_SHORT_SIDE,
)
return TextMarkScan(None, None, 0)
loc = self.locate(image)
box = self.extract_mask(image, loc) # box-sized mask (== old full-frame cropped to bbox)
_x, _y, bw, bh = loc.bbox
coverage = float((box > 0).sum()) / float(max(1, bw * bh))
base = self.scale_base(image)
match_box: tuple[int, int, int, int] | None = None
if c.detect_frontend == "binary":
# The coverage gate is a blob-AREA heuristic, so it applies only to the
# front-end that binarizes; the continuous ones never build a blob. Below
# the gate the detection stays at confidence 0.0 and the rival margin is
# never consulted, so the score stays None here.
score = self._template_match_score(box, base) if coverage >= c.detect_min_coverage else None
else:
score, match_box = self._ladder_best(image, loc)
return TextMarkScan(loc, box, base, frame=image.shape[:2], coverage=coverage, score=score, match_box=match_box)
def _verdict(self, scan: TextMarkScan, *, provenance: bool) -> TextMarkDetection:
"""Apply the trust-level-dependent tail to a scan, as a fresh result object."""
c = self.config
det = TextMarkDetection(provenance=provenance)
if scan.loc is None or scan.box is None:
return det
det.region = scan.loc.bbox
det.coverage = scan.coverage
det.match_box = scan.match_box
if scan.score is None: # binary front-end below the coverage gate
return det
threshold = c.detect_ncc_threshold * (c.provenance_ncc_factor if provenance else 1.0)
det.confidence = scan.score
# Short-circuit is load-bearing: _rival_margin_ok scores every rival template
# and logs its own rejection line, so it must stay unevaluated below threshold.
det.detected = scan.score >= threshold and self._rival_margin_ok(scan.score, scan.box, scan.base)
logger.debug(
"%s detect (%s): coverage=%.3f ncc=%.2f thr=%.2f detected=%s",
c.name,
c.detect_frontend,
scan.coverage,
scan.score,
threshold,
det.detected,
)
return self._post_gate(det, scan)
def _post_gate(self, det: TextMarkDetection, scan: TextMarkScan) -> TextMarkDetection:
"""Per-mark demotion applied after the shared threshold, for both trust levels.
OVERRIDABLE. It lives here rather than in a ``detect`` override so a mark's gate
cannot be silently skipped by the single-pass ``detect_both`` path -- which is
exactly what happened while the anchor demotions were ``detect`` overrides.
"""
return det
# ── Inpaint footprint (for the inpaint-fallback removal path) ────────
# Minimum glyph pixels for a template-free footprint. Below this the corner has
# no real wordmark (a few top-hat specks), so without ``force`` there is nothing
# to mask. A real strip covers hundreds of pixels.
_MIN_GLYPH_PIXELS = 20
def _strict_detection(self, image: NDArray[Any], detection: TextMarkDetection | None) -> TextMarkDetection:
"""The STRICT detection the footprint is bounded by.
A threaded detection is reused only when it was taken at the same strict level
this method would have used itself. A provenance-RELAXED detection is NOT
reused: a strict re-detect can demote a mark the relaxed gate accepted, which
for a continuous front-end means no mask at all. That is a MEASURED difference,
not a refactor, so the strict semantics stay.
Reuse is safe against ``remove_auto_marks`` chaining marks on a progressively
cleaned frame: the registry re-detects on that same cleaned array before
threading (``KnownMark.localize``), so a threaded detection is never stale.
"""
if detection is not None and not detection.provenance:
return detection
return self.detect(image) # polymorphic: a subclass gate must still apply
def _geometry_rect(self, loc: TextMarkLocation, frame: tuple[int, int]) -> tuple[int, int, int, int]:
"""The whole locate box, clamped to the frame -- the ``force`` footprint."""
bx, by, bw, bh = loc.bbox
h, w = frame
return (bx, by, min(w, bx + bw), min(h, by + bh))
def _extend_match_box(
self, box: tuple[int, int, int, int], loc: TextMarkLocation, frame: tuple[int, int]
) -> tuple[int, int, int, int]:
"""Grow an ROI-local box into the absolute fill rectangle by a symmetric pad.
OVERRIDABLE, and the override contract is specifically the DETECTOR'S MATCH BOX:
a mark whose removable footprint reaches beyond what the NCC localizes -- Baidu's
flat white tag right of the text run, LibLibAI's triangle logo left of the
wordmark -- supplies its own extension here and inherits the rest of the
footprint path. The blob-bbox branch never routes through an override.
"""
gx0, gy0, gx1, gy1 = box
bx, by, _bw, bh = loc.bbox
h, w = frame
pad = max(4, int(0.10 * bh))
return (
max(0, bx + gx0 - pad),
max(0, by + gy0 - pad),
min(w, bx + gx1 + 1 + pad),
min(h, by + gy1 + 1 + pad),
)
def _footprint_rect(
self,
image: NDArray[Any],
loc: TextMarkLocation,
*,
force: bool,
detection: TextMarkDetection | None,
) -> tuple[int, int, int, int] | None:
"""Default footprint policy: the binary glyph blob's bbox, else the detector's
own match box for the front-ends that under-segment, else the geometry box
under ``force``.
The gray front-end exists for marks the top-hat under-segments, so the binary
blob is NOT authoritative there: trusting it first bounded the fill by a PARTIAL
blob (the faint head glyphs dropped out) and left the leftmost "Runni" of
"RunningHub AI生成" unremoved (2026-07-22).
A dark-on-light Yuanbao mark has no WHITE top-hat blob at all, so the contrast
front-end is bounded by the polarity-independent detector's match box too.
A tophat mark found only by the CONTINUOUS front-end has no binary glyph blob to
bound, so the mask came back empty and removal was a silent no-op while
``identify`` still reported the mark. Use the DETECTOR'S OWN best-match box: the
correlation already located the mark at a position and scale, and thresholding
the response was a strictly worse proxy for that. An earlier fix thresholded the
max-normalized uint8 response at 0.5 -- which selects every non-zero pixel, not
"half the peak" as its comment claimed -- and filled ~120% of the corner box on
textured frames (measured: whole corner vs 58.7% for the match box, both
detector-clean). Gated on an actual detection: on a clean corner the box would
be spurious.
"""
ys, xs = np.where(self.extract_mask(image, loc) > 0)
blob = (
(int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max())) if xs.size >= self._MIN_GLYPH_PIXELS else None
)
frontend = self.config.detect_frontend
if frontend in ("gray", "contrast"):
det = self._strict_detection(image, detection)
box = det.match_box if det.detected else blob
elif blob is not None:
box = blob
elif frontend == "tophat":
det = self._strict_detection(image, detection)
box = det.match_box if det.detected else None
else:
box = None
if box is not None:
return self._extend_match_box(box, loc, image.shape[:2])
return self._geometry_rect(loc, image.shape[:2]) if force else None
def _match_box_rect(
self,
image: NDArray[Any],
loc: TextMarkLocation,
*,
force: bool,
detection: TextMarkDetection | None,
) -> tuple[int, int, int, int] | None:
"""Footprint policy for a mark whose fill must be bounded by the DETECTOR's match
box and never by the binary glyph blob.
Baidu's white tag has a flat interior a top-hat cannot answer, and LibLibAI's
blob bleeds up into background structure; in both cases the blob bbox is
measurably wrong and the NCC match box is right. ``force`` takes priority here,
unlike the default policy: a ``--no-detect`` caller named the mark, so the whole
geometry box is the honest footprint.
"""
if force:
return self._geometry_rect(loc, image.shape[:2])
det = self._strict_detection(image, detection)
if not det.detected or det.match_box is None:
return None
return self._extend_match_box(det.match_box, loc, image.shape[:2])
def footprint_mask(
self,
image: NDArray[Any] | None,
*,
force: bool = False,
dilate: int | None = None,
detection: TextMarkDetection | None = None,
) -> NDArray[Any] | None:
"""Full-frame uint8 mask (255 = mark) of the mark footprint, for the shared
fill removal path (cv2 / MI-GAN / LaMa), or None if no glyph is found.
Template-FREE: localize the glyph blob with the top-hat :meth:`extract_mask`,
take its bounding box in the corner, and fill that box solid (plus a small
margin + dilation). Filling the enclosing rectangle -- not the sparse glyph
strokes -- is what makes it robust: the top-hat under-segments individual
strokes (which used to leave a "三包"-style residual ghost when the strokes
themselves were the mask), but the inpaint reconstructs the whole wordmark
rectangle from its surroundings, so a stroke missed by the top-hat is still
covered. This drops the fixed alpha-template dependency, so a re-rendered or
differently-localized mark (e.g. a non-Italian Samsung string) is still masked.
With ``force`` and no glyph found, falls back to the whole geometry box (the
``--no-detect`` path). The caller gates on detection.
``detection`` is the caller's already-computed detection, threaded in so the
footprint does not re-run a sweep the detector already ran. See
:meth:`_strict_detection` for when it is reused.
"""
if image is None or image.size == 0:
return None # guard before to_bgr (cvtColor raises on an empty Mat); mirror detect()
image = image_io.to_bgr(image)
h, w = image.shape[:2]
if h < 32 or w < 64:
return None
loc = self.locate(image)
rect = self._footprint_rect(image, loc, force=force, detection=detection)
if rect is None:
return None
rx1, ry1, rx2, ry2 = rect
if rx1 >= rx2 or ry1 >= ry2:
return None
# Rectangular footprint + dilation is exactly region_eraser.boxes_to_mask (the
# same primitive the shared fill uses); reuse it instead of re-inlining the
# zeros/fill/MORPH_ELLIPSE-dilate here.
from remove_ai_watermarks import region_eraser
d = dilate if dilate is not None else max(3, int(0.02 * loc.w))
return region_eraser.boxes_to_mask((h, w), [(rx1, ry1, rx2 - rx1, ry2 - ry1)], dilate=d)