mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-07 06:28:36 +02:00
Remove assume_ai, add tophat front-end and rival margin, fix two CLI defects
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
a8f3536d3e
commit
cfefd9d819
@@ -1,8 +1,8 @@
|
||||
"""Shared base for the visible text-mark detectors/localizers (localize -> fill).
|
||||
|
||||
The Doubao "豆包AI生成", Jimeng "★ 即梦AI", and Samsung "✦ Contenuti generati
|
||||
dall'AI" marks are the SAME algorithm: anchor a bottom-corner box by width-relative
|
||||
geometry, extract the light low-saturation glyph candidate (white top-hat), detect
|
||||
dall'AI" marks are the SAME algorithm: anchor a bottom-corner box by geometry
|
||||
relative to the image's SHORT side, extract the light low-saturation glyph candidate (white top-hat), detect
|
||||
by matching the bundled alpha-glyph silhouette via ``TM_CCOEFF_NORMED``, and build a
|
||||
removal MASK from the glyph blob's bounding box (:meth:`footprint_mask`) for the
|
||||
shared fill (region_eraser). The mask is template-FREE -- the top-hat glyph bbox, not
|
||||
@@ -54,10 +54,41 @@ _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. 0.7 recovers
|
||||
# the near-threshold marks without dropping so low that an unrelated corner texture
|
||||
# on a (provenance-confirmed) image would match -- the coverage gate still applies.
|
||||
_PROVENANCE_NCC_FACTOR = 0.7
|
||||
# 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 4417 unique TC260 carriers,
|
||||
# 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)
|
||||
@@ -68,7 +99,7 @@ class TextMarkConfig:
|
||||
asset_name: str # bundled alpha PNG under assets/ (e.g. "doubao_alpha.png")
|
||||
corner: Literal["br", "bl"] # bottom-right (Doubao/Jimeng) or bottom-left (Samsung)
|
||||
margin_floor: int # min margin in px for locate (4 for br marks, 2 for Samsung)
|
||||
# locate geometry (fraction of image WIDTH)
|
||||
# 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)
|
||||
@@ -81,12 +112,31 @@ class TextMarkConfig:
|
||||
# detection
|
||||
detect_min_coverage: float
|
||||
detect_ncc_threshold: float
|
||||
# alpha-map glyph geometry (fraction of WIDTH) emitted by
|
||||
# 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.
|
||||
detect_frontend: Literal["binary", "tophat"] = "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"
|
||||
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
|
||||
@@ -146,18 +196,49 @@ def glyph_silhouette(asset_name: str) -> NDArray[Any] | None:
|
||||
return _silhouette_cache[asset_name]
|
||||
|
||||
|
||||
def template_match_score(box_mask: NDArray[Any], image_width: int, config: TextMarkConfig) -> float:
|
||||
_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 * image_width)))
|
||||
gh = min(box_mask.shape[0] - 1, max(4, int(config.alpha_height_frac * image_width)))
|
||||
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)
|
||||
@@ -178,19 +259,153 @@ class TextMarkEngine:
|
||||
def _glyph_silhouette(self) -> NDArray[Any] | None:
|
||||
return glyph_silhouette(self.config.asset_name)
|
||||
|
||||
def _template_match_score(self, box_mask: NDArray[Any], image_width: int) -> float:
|
||||
return template_match_score(box_mask, image_width, self.config)
|
||||
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: 33 of jimeng's 68 false additions
|
||||
were Doubao marks (corpus-measured 2026-07-18).
|
||||
|
||||
Measured separability on hand-labelled examples, scoring BOTH templates
|
||||
against the same glyph blob (n=40 jimeng / 75 doubao / 20 other-vendor labels
|
||||
/ 89 clean):
|
||||
|
||||
feature separability (0.5 = useless, 1.0 = perfect)
|
||||
absolute ncc_jimeng 0.96
|
||||
ncc_jimeng MINUS ncc_doubao 0.99
|
||||
|
||||
At a 0.10 margin: real Jimeng wordmarks pass 100%, Doubao strips 8%, other
|
||||
vendors' AI labels (千问/百度/星绘/抖音) 55%, no-mark corners 12%. Because the
|
||||
real marks pass at 100%, this costs NO recall -- it is a pure precision gain,
|
||||
unlike raising the threshold, which trades recall away.
|
||||
|
||||
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 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.
|
||||
|
||||
Measured 2026-07-18 on hand-verified corpus positives (40 doubao, 14 千问, 60
|
||||
verified-clean), 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.
|
||||
"""
|
||||
c = self.config
|
||||
x, y, bw, bh = loc.bbox
|
||||
if bh < 16 or bw < 16:
|
||||
return None
|
||||
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)
|
||||
tophat = luma - cv2.GaussianBlur(luma, (0, 0), sigmaX=sigma, sigmaY=sigma)
|
||||
resp = np.clip(tophat, 0, None) * (sat < c.max_saturation)
|
||||
peak = float(resp.max())
|
||||
if peak <= 1e-6:
|
||||
return None
|
||||
return (resp / peak * 255).astype(np.uint8)
|
||||
|
||||
def _tophat_score(self, image: NDArray[Any], loc: TextMarkLocation) -> float:
|
||||
"""TM_CCOEFF_NORMED of a soft template against the continuous response.
|
||||
|
||||
Sweeps a small scale band: 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.
|
||||
"""
|
||||
c = self.config
|
||||
resp = self.tophat_response(image, loc)
|
||||
sil = self._glyph_silhouette()
|
||||
if resp is None or sil is None:
|
||||
return 0.0
|
||||
base = self.scale_base(image)
|
||||
best = 0.0
|
||||
for scale in (0.8, 1.0, 1.25):
|
||||
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).astype(np.float32)
|
||||
if c.template_blur > 0:
|
||||
tmpl = cv2.GaussianBlur(tmpl, (0, 0), sigmaX=c.template_blur, sigmaY=c.template_blur)
|
||||
best = max(best, float(cv2.matchTemplate(resp, tmpl.astype(np.uint8), cv2.TM_CCOEFF_NORMED).max()))
|
||||
return best
|
||||
|
||||
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.
|
||||
|
||||
Corpus-measured 2026-07-18 (2572 unique TC260 carriers; the harness is
|
||||
`scripts/visible_eval.py`). Before any fix, doubao detection by aspect ratio:
|
||||
portrait 60%, square 41%, **landscape 0% (0 of 435)** -- the width-scaled box
|
||||
is inflated by the aspect ratio on a wide image and the glyph never lands in
|
||||
it. Re-running the previously-undetected set with a short-side basis recovered
|
||||
**56% of landscape** images (12% square, 4% portrait).
|
||||
|
||||
But the same switch took JIMENG's labelled landscape positives from 13/13 to
|
||||
0/13: 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 there is no
|
||||
corpus evidence either way (1 addition corpus-wide) 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 bottom corner by geometry."""
|
||||
"""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]
|
||||
wm_w = max(40, int(w * c.width_frac))
|
||||
wm_h = max(16, int(w * c.height_frac))
|
||||
margin_x = max(c.margin_floor, int(w * c.margin_x_frac))
|
||||
margin_b = max(c.margin_floor, int(w * c.margin_bottom_frac))
|
||||
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))
|
||||
x = max(0, w - margin_x - wm_w) if c.corner == "br" else min(margin_x, max(0, w - wm_w))
|
||||
y = max(0, h - margin_b - wm_h)
|
||||
wm_w = min(wm_w, w - x)
|
||||
@@ -251,7 +466,8 @@ class TextMarkEngine:
|
||||
(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
|
||||
``_PROVENANCE_NCC_FACTOR`` to recover a faint or slightly re-rendered mark.
|
||||
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).
|
||||
"""
|
||||
c = self.config
|
||||
det = TextMarkDetection()
|
||||
@@ -274,11 +490,20 @@ class TextMarkEngine:
|
||||
coverage = float((box > 0).sum()) / float(max(1, bw * bh))
|
||||
det.region = loc.bbox
|
||||
det.coverage = coverage
|
||||
if coverage >= c.detect_min_coverage:
|
||||
score = self._template_match_score(box, image.shape[1])
|
||||
threshold = c.detect_ncc_threshold * (_PROVENANCE_NCC_FACTOR if provenance else 1.0)
|
||||
if c.detect_frontend == "tophat":
|
||||
# The continuous front-end does not depend on the binarized blob, so the
|
||||
# coverage gate (a blob-area heuristic) does not apply to it.
|
||||
score = self._tophat_score(image, loc)
|
||||
threshold = c.detect_ncc_threshold * (c.provenance_ncc_factor if provenance else 1.0)
|
||||
det.confidence = score
|
||||
det.detected = score >= threshold
|
||||
det.detected = score >= threshold and self._rival_margin_ok(score, box, self.scale_base(image))
|
||||
logger.debug("%s detect (tophat): ncc=%.2f thr=%.2f detected=%s", c.name, score, threshold, det.detected)
|
||||
return det
|
||||
if coverage >= c.detect_min_coverage:
|
||||
score = self._template_match_score(box, self.scale_base(image))
|
||||
threshold = c.detect_ncc_threshold * (c.provenance_ncc_factor if provenance else 1.0)
|
||||
det.confidence = score
|
||||
det.detected = score >= threshold and self._rival_margin_ok(score, box, self.scale_base(image))
|
||||
logger.debug(
|
||||
"%s detect: coverage=%.3f ncc=%.2f thr=%.2f detected=%s",
|
||||
c.name,
|
||||
|
||||
@@ -8,7 +8,7 @@ up metadata provenance, or preserve the alpha channel by hand:
|
||||
import remove_ai_watermarks as raiw
|
||||
raiw.remove_visible("in.png", "out.png") # path -> file, provenance auto
|
||||
result, removed = raiw.remove_visible(bgr_array) # array -> array
|
||||
raiw.remove_visible("shot.png", "out.png", sensitivity="assume_ai")
|
||||
raiw.remove_visible("shot.png", "out.png", sensitivity="strict")
|
||||
raiw.visible_provenance("in.png") # -> frozenset({"gemini"})
|
||||
|
||||
Imports stay lazy (inside the functions), so ``import remove_ai_watermarks`` is cheap.
|
||||
@@ -134,10 +134,8 @@ def remove_visible(
|
||||
array is always returned as well, so an empty ``removed`` list tells a caller nothing
|
||||
known was found (e.g. route to the diffusion ``all`` path or ``erase``).
|
||||
|
||||
``sensitivity`` (``auto``/``strict``/``assume_ai``) and ``backend``
|
||||
``sensitivity`` (``auto``/``strict``) and ``backend``
|
||||
(``auto``/``cv2``/``migan``/``lama``) are the same knobs as the CLI. Pass
|
||||
``sensitivity="assume_ai"`` for a metadata-stripped screenshot the caller knows is
|
||||
AI-generated (best recall, at the cost of a small near-lossless fill on a clean
|
||||
corner if the guess is wrong).
|
||||
|
||||
``strip_metadata`` (default True, matching the CLI ``visible --strip-metadata``)
|
||||
@@ -152,6 +150,9 @@ def remove_visible(
|
||||
"""
|
||||
from remove_ai_watermarks import watermark_registry
|
||||
|
||||
# Reject a removed sensitivity loudly; `Sensitivity` is a Literal and not enforced
|
||||
# at runtime, so a 0.15 caller would otherwise get `auto` behaviour in silence.
|
||||
watermark_registry.validate_sensitivity(sensitivity)
|
||||
loaded = _load_visible_input(source)
|
||||
result, removed = watermark_registry.remove_auto_marks(
|
||||
loaded.bgr,
|
||||
|
||||
@@ -307,14 +307,13 @@ _visible_backend_option = click.option(
|
||||
_visible_sensitivity_option = click.option(
|
||||
"--sensitivity",
|
||||
"sensitivity",
|
||||
type=click.Choice(["auto", "strict", "assume-ai"]),
|
||||
type=click.Choice(["auto", "strict"]),
|
||||
default="auto",
|
||||
help="How hard to trust a borderline mark. auto: relax a mark only when metadata "
|
||||
"or a same-product sibling mark corroborates it (safe; clean images untouched). "
|
||||
"strict: high-precision visual gate only, never relaxed. assume-ai: treat the "
|
||||
"image as AI and relax every mark, keeping a confidence floor where the vendor is "
|
||||
"unconfirmed (best recall on metadata-stripped screenshots; a clean image is still "
|
||||
"left untouched).",
|
||||
"strict: high-precision visual gate only, never relaxed. To act on a mark YOU can "
|
||||
"see but the detector missed, use 'erase --region' or '--mark <name> --no-detect' "
|
||||
"rather than a blanket relaxation.",
|
||||
)
|
||||
|
||||
|
||||
@@ -396,13 +395,12 @@ def _remove_visible_auto(
|
||||
|
||||
|
||||
def _parse_sensitivity(value: str) -> watermark_registry.Sensitivity:
|
||||
"""Map the CLI ``--sensitivity`` choice (kebab ``assume-ai``) to the registry
|
||||
literal (``assume_ai``); ``auto``/``strict`` pass through unchanged."""
|
||||
if value == "assume-ai":
|
||||
return "assume_ai"
|
||||
if value == "strict":
|
||||
return "strict"
|
||||
return "auto"
|
||||
"""Map the CLI ``--sensitivity`` choice to the registry literal.
|
||||
|
||||
A pass-through since ``assume-ai`` was removed (2026-07-19); kept as the single
|
||||
conversion point so a future kebab-cased choice has an obvious home.
|
||||
"""
|
||||
return "strict" if value == "strict" else "auto"
|
||||
|
||||
|
||||
# Exit code for the standalone ``visible`` command when no visible mark was
|
||||
@@ -412,7 +410,7 @@ def _parse_sensitivity(value: str) -> watermark_registry.Sensitivity:
|
||||
EXIT_NO_VISIBLE_MARK = 2
|
||||
|
||||
|
||||
def _no_visible_mark_exit(source: Path, *, sensitivity: str = "auto") -> NoReturn:
|
||||
def _no_visible_mark_exit(source: Path) -> NoReturn:
|
||||
"""Explain why no visible watermark was removed, then exit non-zero.
|
||||
|
||||
The visible registry handles only known visual marks (the Gemini sparkle and
|
||||
@@ -423,10 +421,25 @@ def _no_visible_mark_exit(source: Path, *, sensitivity: str = "auto") -> NoRetur
|
||||
watermarked image -- the recurring "it didn't work" report. Instead, run a
|
||||
cheap metadata-only :func:`identify`, tell the user what the image actually
|
||||
carries and which command removes it, and exit
|
||||
:data:`EXIT_NO_VISIBLE_MARK`. When the conservative detector found nothing and
|
||||
the user has NOT already asked for the aggressive pass, point them at
|
||||
``--sensitivity assume-ai`` (a faint or moved visible mark may be sitting just
|
||||
below the default gate).
|
||||
:data:`EXIT_NO_VISIBLE_MARK`.
|
||||
|
||||
When the user can SEE a mark the detector missed, the honest next step is one that
|
||||
executes their instruction rather than guessing harder. This used to recommend
|
||||
``--sensitivity assume-ai``, which did the opposite -- it relaxed every mark's gate
|
||||
on a blanket assumption -- and that mode is gone (2026-07-19).
|
||||
|
||||
The advice is per-mark, because the forced paths are not equally reliable
|
||||
(measured 2026-07-19):
|
||||
* ``erase --region`` is always sound: the user supplies the coordinates, so there
|
||||
is nothing to guess. This is the primary recommendation.
|
||||
* ``--mark <text-mark> --no-detect`` is reasonable for the TEXT marks: the forced
|
||||
mask is built from the actual glyph blob, non-empty on 13/13 real marks the
|
||||
detector missed.
|
||||
* ``--mark gemini --no-detect`` is NOT recommended and is deliberately not
|
||||
suggested here: with no detection it falls back to a fixed default sparkle slot,
|
||||
which covered the real sparkle on only **31% of 97** genuine sparkles the strict
|
||||
gate missed (median offset 63px up-and-left). The other 69% fill a clean corner
|
||||
AND report a removal that did not happen -- the worst outcome the tool has.
|
||||
"""
|
||||
from remove_ai_watermarks.identify import identify
|
||||
|
||||
@@ -448,12 +461,13 @@ def _no_visible_mark_exit(source: Path, *, sensitivity: str = "auto") -> NoRetur
|
||||
" If instead there is a logo or object to remove, target it with the region eraser:\n"
|
||||
f" remove-ai-watermarks erase {source.name} --region x,y,w,h"
|
||||
)
|
||||
if sensitivity != "assume_ai":
|
||||
console.print(
|
||||
" If you know this image is AI-generated, retry the visible pass with\n"
|
||||
f" remove-ai-watermarks visible {source.name} --sensitivity assume-ai\n"
|
||||
" which relaxes detection to catch a faint or moved mark the default gate skips."
|
||||
)
|
||||
console.print(
|
||||
" If you can SEE a mark here that was not detected, point at it directly --\n"
|
||||
" that removes what you actually see instead of guessing:\n"
|
||||
f" remove-ai-watermarks erase {source.name} --region x,y,w,h\n"
|
||||
" For a known CJK text mark you can also force it by name:\n"
|
||||
f" remove-ai-watermarks visible {source.name} --mark doubao --no-detect"
|
||||
)
|
||||
raise SystemExit(EXIT_NO_VISIBLE_MARK)
|
||||
|
||||
|
||||
@@ -562,7 +576,7 @@ def _run_visible_auto(
|
||||
if not removed:
|
||||
# write_noop=False means nothing was written, so a pre-existing output is intact.
|
||||
console.print(" No known visible mark detected (gemini / doubao / jimeng / jimeng-pill / samsung).")
|
||||
_no_visible_mark_exit(source, sensitivity=sensitivity)
|
||||
_no_visible_mark_exit(source)
|
||||
console.print(f" Removed: {', '.join(removed)}")
|
||||
size_kb = output.stat().st_size / 1024
|
||||
console.print(f" Saved: {output} ({size_kb:.0f} KB, {elapsed:.2f}s)")
|
||||
@@ -592,7 +606,7 @@ def _run_visible_explicit(
|
||||
target = "gemini" if mark == "auto" else mark # --no-detect auto: gemini fallback
|
||||
chosen = watermark_registry.get_mark(target)
|
||||
# A single explicit mark has no sibling corroboration. Keep its trust resolution
|
||||
# aligned with the registry arbiter, including the assumption-only floor.
|
||||
# aligned with the registry arbiter.
|
||||
trust = watermark_registry.resolve_trust(
|
||||
chosen.key,
|
||||
sensitivity=sensitivity,
|
||||
@@ -601,12 +615,9 @@ def _run_visible_explicit(
|
||||
)
|
||||
relax = trust != "strict"
|
||||
detection = chosen.detect(image, provenance=relax)
|
||||
if trust == "assumed" and not watermark_registry.assumed_floor_ok(chosen.key, detection.confidence):
|
||||
relax = False
|
||||
detection = chosen.detect(image, provenance=False)
|
||||
if detect and not detection.detected:
|
||||
console.print(f" {chosen.label} not detected (conf {detection.confidence:.2f}). Use --no-detect to force.")
|
||||
_no_visible_mark_exit(source, sensitivity=sensitivity)
|
||||
_no_visible_mark_exit(source)
|
||||
if detection.detected:
|
||||
console.print(f" {chosen.label} detected ({chosen.location}, conf {detection.confidence:.2f})")
|
||||
|
||||
@@ -954,7 +965,7 @@ def cmd_metadata(
|
||||
from WebM/MP3/WAV/FLAC/OGG. The coded image, audio, and video data are left
|
||||
untouched.
|
||||
"""
|
||||
from remove_ai_watermarks.metadata import get_ai_metadata, has_ai_metadata, remove_ai_metadata
|
||||
from remove_ai_watermarks.metadata import get_ai_metadata, has_ai_metadata, strip_and_verify
|
||||
|
||||
# No _validate_image() here: unlike the image-only commands, metadata also
|
||||
# accepts video/audio containers, so the image-format warning would misfire.
|
||||
@@ -982,10 +993,16 @@ def cmd_metadata(
|
||||
|
||||
# Remove
|
||||
try:
|
||||
out = remove_ai_metadata(source, output, keep_standard=keep_standard)
|
||||
out, leftover = strip_and_verify(source, output, keep_standard=keep_standard)
|
||||
except (OSError, ValueError) as e: # unreadable / truncated / non-image (PIL raises OSError subclasses)
|
||||
console.print(f" Error: cannot process {source.name}: {e}")
|
||||
raise SystemExit(1) from e
|
||||
|
||||
if leftover:
|
||||
console.print(f" FAILED: {len(leftover)} AI metadata marker(s) survived in {out}")
|
||||
console.print(f" still present: {', '.join(sorted(leftover))}")
|
||||
console.print(" the file could not be decoded, so it was copied through unchanged")
|
||||
raise SystemExit(1)
|
||||
console.print(f" AI metadata stripped -> {out}")
|
||||
|
||||
|
||||
@@ -1448,9 +1465,15 @@ def _process_batch_image(
|
||||
synthid_skipped = _run_batch_invisible(ctx, img_path, out_path, mode, options)
|
||||
|
||||
if mode in ("metadata", "all"):
|
||||
from remove_ai_watermarks.metadata import remove_ai_metadata
|
||||
from remove_ai_watermarks.metadata import strip_and_verify
|
||||
|
||||
remove_ai_metadata(img_path if mode == "metadata" else out_path, out_path)
|
||||
# Same verification the single-image command does: the fail-safe copy-through
|
||||
# would otherwise leave an AI-reading output and still exit 0, contradicting the
|
||||
# batch contract that a failed image must make the run exit non-zero.
|
||||
_, leftover = strip_and_verify(img_path if mode == "metadata" else out_path, out_path)
|
||||
if leftover:
|
||||
msg = f"AI metadata survived the strip ({', '.join(sorted(leftover))}); file could not be decoded"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
# In "all" mode, the invisible step (color-only OpenCV paths) drops alpha,
|
||||
# so re-attach the cached alpha when the input had transparency.
|
||||
|
||||
@@ -47,7 +47,21 @@ TOPHAT_DELTA = 12 # glyph must exceed the local background by this many levels
|
||||
# Shape-consistent detection: match the bundled alpha glyph silhouette against the
|
||||
# corner candidate via TM_CCOEFF_NORMED (keys on glyph SHAPE, not coverage; #23).
|
||||
DETECT_MIN_COVERAGE = 0.04
|
||||
DETECT_NCC_THRESHOLD = 0.4
|
||||
# NOTE: this gate is FRONT-END SPECIFIC. The continuous top-hat front-end scores higher
|
||||
# overall than the binary one (mean 0.809 vs 0.723 on the same 90 positives), so the
|
||||
# binary-era 0.40 left the provenance-relaxed gate (x0.7) far too low and admitted false
|
||||
# fires. Calibrated on the 240-image unbiased recall sample, full auto path:
|
||||
#
|
||||
# gate relaxed recall precision true false
|
||||
# 0.40 0.280 96% 91% 86 8
|
||||
# 0.45 0.315 94% 93% 85 6
|
||||
# 0.50 0.350 92% 99% 83 1 <- chosen
|
||||
# 0.60 0.420 87% 99% 78 1
|
||||
#
|
||||
# 0.50 beats the binary front-end on recall (92% vs 89%) at identical precision (99%),
|
||||
# which is the only reason the front-end switch is worth it. Do not port this number to
|
||||
# a binary-front-end mark; re-calibrate per front-end.
|
||||
DETECT_NCC_THRESHOLD = 0.50
|
||||
|
||||
# Detection-silhouette geometry, emitted by scripts/visible_alpha_solve.py at the
|
||||
# captured width. Sizes the glyph silhouette for the TM_CCOEFF_NORMED detection match
|
||||
@@ -71,6 +85,12 @@ _CONFIG = TextMarkConfig(
|
||||
morph_open_size=5,
|
||||
detect_min_coverage=DETECT_MIN_COVERAGE,
|
||||
detect_ncc_threshold=DETECT_NCC_THRESHOLD,
|
||||
detect_frontend="tophat",
|
||||
scale_basis="short", # measured: recovers 56% of landscape misses (see scale_base)
|
||||
# No rival margin: measured 2026-07-18, the symmetric gate cost Doubao 7 genuine
|
||||
# detections to prevent 5 false ones (1.4:1 against). Doubao's absolute detector
|
||||
# is already 86% precise, so it has nothing to buy; Jimeng's is 38% and gains 25pp
|
||||
# for free. The confusion is asymmetric, so the remedy is too.
|
||||
alpha_width_frac=_ALPHA_WIDTH_FRAC,
|
||||
alpha_height_frac=_ALPHA_HEIGHT_FRAC,
|
||||
min_gw=8,
|
||||
@@ -90,9 +110,9 @@ def _glyph_silhouette() -> NDArray[Any] | None:
|
||||
return _text_mark_engine.glyph_silhouette(_CONFIG.asset_name)
|
||||
|
||||
|
||||
def _template_match_score(box_mask: NDArray[Any], image_width: int) -> float:
|
||||
def _template_match_score(box_mask: NDArray[Any], scale_base: int) -> float:
|
||||
"""TM_CCOEFF_NORMED of the Doubao glyph silhouette against ``box_mask``."""
|
||||
return _text_mark_engine.template_match_score(box_mask, image_width, _CONFIG)
|
||||
return _text_mark_engine.template_match_score(box_mask, scale_base, _CONFIG)
|
||||
|
||||
|
||||
class DoubaoEngine(TextMarkEngine):
|
||||
|
||||
@@ -42,9 +42,21 @@ TOPHAT_DELTA = 12
|
||||
|
||||
# Shape-consistent detection. Threshold 0.45 cleanly separates real Jimeng marks
|
||||
# (>=0.81) from the Doubao strip (0.21), so the two ByteDance marks do not cross-fire.
|
||||
#
|
||||
# That separation holds at the STRICT threshold ONLY. Relaxed under provenance it
|
||||
# collapses: at the old shared 0.7 factor (gate 0.315) the arm ran at 17% precision
|
||||
# and 33 of its 68 false additions were Doubao marks (corpus-measured 2026-07-18 --
|
||||
# full table at _DEFAULT_PROVENANCE_NCC_FACTOR). That was patched with a tighter 0.85
|
||||
# factor at the time; the competitive rival margin replaced it -- see below.
|
||||
DETECT_MIN_COVERAGE = 0.02
|
||||
DETECT_NCC_THRESHOLD = 0.45
|
||||
|
||||
# Back to the 0.70 default. The 0.85 patch (2026-07-18) existed only to blunt the
|
||||
# Doubao cross-fire by sacrificing recall; the competitive RIVAL MARGIN below
|
||||
# discriminates directly and passes real wordmarks at 100%, so the recall the patch
|
||||
# gave up is no longer the price of precision. See _rival_margin_ok.
|
||||
PROVENANCE_NCC_FACTOR = 0.7
|
||||
|
||||
# Detection-silhouette geometry, emitted by scripts/visible_alpha_solve.py from the
|
||||
# gray capture at the captured width (sizes the silhouette for the detection match;
|
||||
# removal is the template-free glyph-bbox footprint mask).
|
||||
@@ -67,6 +79,8 @@ _CONFIG = TextMarkConfig(
|
||||
morph_open_size=5,
|
||||
detect_min_coverage=DETECT_MIN_COVERAGE,
|
||||
detect_ncc_threshold=DETECT_NCC_THRESHOLD,
|
||||
provenance_ncc_factor=PROVENANCE_NCC_FACTOR,
|
||||
rivals=("doubao_alpha.png",),
|
||||
alpha_width_frac=_ALPHA_WIDTH_FRAC,
|
||||
alpha_height_frac=_ALPHA_HEIGHT_FRAC,
|
||||
min_gw=8,
|
||||
@@ -85,9 +99,9 @@ def _glyph_silhouette() -> NDArray[Any] | None:
|
||||
return _text_mark_engine.glyph_silhouette(_CONFIG.asset_name)
|
||||
|
||||
|
||||
def _template_match_score(box_mask: NDArray[Any], image_width: int) -> float:
|
||||
def _template_match_score(box_mask: NDArray[Any], scale_base: int) -> float:
|
||||
"""TM_CCOEFF_NORMED of the Jimeng glyph silhouette against ``box_mask``."""
|
||||
return _text_mark_engine.template_match_score(box_mask, image_width, _CONFIG)
|
||||
return _text_mark_engine.template_match_score(box_mask, scale_base, _CONFIG)
|
||||
|
||||
|
||||
class JimengEngine(TextMarkEngine):
|
||||
|
||||
@@ -1076,6 +1076,27 @@ def _sniff_image_format(head: bytes) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def strip_and_verify(
|
||||
source_path: Path,
|
||||
output_path: Path | None = None,
|
||||
*,
|
||||
keep_standard: bool = True,
|
||||
) -> tuple[Path, dict[str, str]]:
|
||||
"""Strip AI metadata, then RE-SCAN the output and report what survived.
|
||||
|
||||
:func:`remove_ai_metadata` is deliberately fail-safe: a file PIL cannot decode is
|
||||
copied through UNCHANGED rather than crashing a caller, and the path it returns is
|
||||
indistinguishable from a real strip. Any caller that reports an outcome to a user
|
||||
therefore cannot tell a no-op from a success -- corpus-observed on real Samsung
|
||||
Galaxy S22 C2PA PNGs, where `metadata --remove` printed "stripped" and exited 0 while
|
||||
the output still read as AI (2026-07-19 parity audit).
|
||||
|
||||
Returns ``(output_path, surviving_markers)``; an empty mapping means a real strip.
|
||||
"""
|
||||
out = remove_ai_metadata(source_path, output_path, keep_standard=keep_standard)
|
||||
return out, get_ai_metadata(out)
|
||||
|
||||
|
||||
def remove_ai_metadata(
|
||||
source_path: Path,
|
||||
output_path: Path | None = None,
|
||||
|
||||
@@ -5,6 +5,7 @@ Pure configuration and lookup functions with no ML dependencies.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -137,6 +138,30 @@ def resolve_strength(strength: float | None, vendor: str | None = None, pipeline
|
||||
return _VENDOR_STRENGTH.get(vendor or "", UNKNOWN_STRENGTH)
|
||||
|
||||
|
||||
def viable_steps(num_inference_steps: int, strength: float) -> int:
|
||||
"""The smallest step count >= ``num_inference_steps`` that actually denoises.
|
||||
|
||||
diffusers derives its img2img timesteps as ``int(steps * strength)``. When that
|
||||
rounds to ZERO the pipeline builds an empty latent and dies deep inside attention
|
||||
with ``cannot reshape tensor of 0 elements into shape [0, -1, 1, 512]`` -- an opaque
|
||||
torch error for what is really "these two options cannot work together".
|
||||
|
||||
The combination is reachable with entirely valid CLI arguments: at the default
|
||||
strength 0.15 every ``--steps`` below 7 crashed, and nothing told the user that
|
||||
``--steps`` and ``--strength`` interact (found by the release smoke matrix,
|
||||
2026-07-19). Raising the count to the minimum that denoises keeps the caller's intent
|
||||
-- they asked for "few steps", not "zero" -- and the engine logs the adjustment.
|
||||
|
||||
A non-positive ``strength`` cannot denoise at any step count; return the caller's
|
||||
value unchanged rather than dividing by zero.
|
||||
"""
|
||||
if strength <= 0:
|
||||
return num_inference_steps
|
||||
if int(num_inference_steps * strength) >= 1:
|
||||
return num_inference_steps
|
||||
return math.ceil(1 / strength)
|
||||
|
||||
|
||||
def vendor_for_strength(image_path: Path) -> Literal["openai", "google"] | None:
|
||||
"""Detect the SynthID vendor for strength selection: ``"openai"`` / ``"google"`` / None.
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ from remove_ai_watermarks.noai.watermark_profiles import (
|
||||
QWEN_MODEL_ID,
|
||||
normalize_profile,
|
||||
resolve_strength,
|
||||
viable_steps,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -690,6 +691,20 @@ class WatermarkRemover:
|
||||
self._set_progress(f"Setting reproducible seed: {seed}")
|
||||
generator = _make_seed_generator(self.device, seed)
|
||||
|
||||
# A step count whose product with strength rounds to zero kills the pipeline
|
||||
# inside attention with an opaque reshape error, so raise it to the minimum that
|
||||
# denoises. Must be applied to the value HANDED TO THE PIPELINE -- the old
|
||||
# max(1, ...) below only clamped the number in the log line.
|
||||
adjusted = viable_steps(num_inference_steps, strength)
|
||||
if adjusted != num_inference_steps:
|
||||
logger.warning(
|
||||
"steps=%s at strength=%s denoises 0 steps and would crash; using steps=%s (1 effective)",
|
||||
num_inference_steps,
|
||||
strength,
|
||||
adjusted,
|
||||
)
|
||||
num_inference_steps = adjusted
|
||||
|
||||
effective_steps = max(1, int(num_inference_steps * strength))
|
||||
self._set_progress(
|
||||
f"Config: strength={strength}, steps={num_inference_steps} "
|
||||
|
||||
@@ -92,9 +92,9 @@ def _glyph_silhouette() -> NDArray[Any] | None:
|
||||
return _text_mark_engine.glyph_silhouette(_CONFIG.asset_name)
|
||||
|
||||
|
||||
def _template_match_score(box_mask: NDArray[Any], image_width: int) -> float:
|
||||
def _template_match_score(box_mask: NDArray[Any], scale_base: int) -> float:
|
||||
"""TM_CCOEFF_NORMED of the Samsung glyph silhouette against ``box_mask``."""
|
||||
return _text_mark_engine.template_match_score(box_mask, image_width, _CONFIG)
|
||||
return _text_mark_engine.template_match_score(box_mask, scale_base, _CONFIG)
|
||||
|
||||
|
||||
class SamsungEngine(TextMarkEngine):
|
||||
|
||||
@@ -52,21 +52,26 @@ Backend = Literal["auto", "cv2", "migan", "lama"]
|
||||
# evidence the mark is there -- metadata provenance for that vendor, or a confidently
|
||||
# detected sibling mark of the same product (see ``resolve_trust``). No evidence ->
|
||||
# stays strict. Safe: it only escalates where the mark is corroborated.
|
||||
# * ``assume_ai``: relax every mark's gate regardless of evidence -- the caller asserts
|
||||
# the image is AI and wants the mark gone (e.g. a metadata-stripped screenshot uploaded
|
||||
# to a watermark remover). Recovers the faint/moved marks the strict gate demotes. The
|
||||
# library CANNOT infer this from a stripped image -- only the caller's out-of-band
|
||||
# context (the user uploaded to remove a mark) justifies it. An assertion that the
|
||||
# image is AI is NOT evidence of WHICH vendor made it, so a mark relaxed on assumption
|
||||
# alone must still clear ``_ASSUMED_CONF_FLOOR``; see that constant for why.
|
||||
Sensitivity = Literal["auto", "strict", "assume_ai"]
|
||||
#
|
||||
# REMOVED 2026-07-19: ``assume_ai`` relaxed every mark's gate on the caller's bare
|
||||
# assertion that the image is AI. It was a statistical gamble, not an instruction:
|
||||
# "this image is AI" says nothing about WHICH vendor or WHERE, which is exactly what a
|
||||
# gate bypass needs, so it took a confidence floor to be tolerable at all (before that
|
||||
# floor it filled a phantom sparkle on 59.8% of genuine camera photos). It also had no
|
||||
# place in the product's own model -- detector finds a mark, remove it; detector finds
|
||||
# nothing, leave the image alone; the USER sees a mark and says so, act on that. A user
|
||||
# who can see the mark is better served by pointing at it (``erase --region``) or naming
|
||||
# it (``--mark X --no-detect`` for a text mark), both of which execute an instruction
|
||||
# instead of guessing. Removing it also collapsed the trust ladder from three levels to
|
||||
# two. See docs/module-internals.md for the measurements.
|
||||
Sensitivity = Literal["auto", "strict"]
|
||||
|
||||
# The trust level a mark's detection gate is resolved to (see ``resolve_trust``). The
|
||||
# split between ``assumed`` and ``confirmed`` is load-bearing: both bypass the engine's
|
||||
# false-positive gate, but only ``confirmed`` has evidence naming THIS vendor, which is
|
||||
# exactly what that bypass is documented to require (see GeminiEngine.detect_watermark's
|
||||
# ``trust_provenance`` contract). ``assumed`` therefore carries a confidence floor.
|
||||
Trust = Literal["strict", "assumed", "confirmed"]
|
||||
# The trust level a mark's detection gate is resolved to (see ``resolve_trust``).
|
||||
# ``confirmed`` bypasses the engine's false-positive gate, and that bypass is documented
|
||||
# to require evidence naming THIS vendor (see GeminiEngine.detect_watermark's
|
||||
# ``trust_provenance`` contract) -- so it is only ever reached from same-product evidence.
|
||||
# A third ``assumed`` level existed for ``assume_ai`` and went with it (2026-07-19).
|
||||
Trust = Literal["strict", "confirmed"]
|
||||
|
||||
# Product family per mark, for the ``auto`` cross-mark corroboration: a confidently
|
||||
# detected mark relaxes only OTHER marks of the SAME product (different corners, one
|
||||
@@ -82,6 +87,32 @@ _PRODUCT_OF: dict[str, str] = {
|
||||
}
|
||||
|
||||
|
||||
# Marks whose own detection is too weak to serve as EVIDENCE for a sibling of the
|
||||
# same product, even though they share one. Sibling corroboration grants ``confirmed``
|
||||
# trust, which bypasses the sibling's false-positive gate outright -- so a detector
|
||||
# that false-fires often must not be able to hand that bypass to anyone.
|
||||
#
|
||||
# The pill qualifies on its own documented numbers: ~7% raw false-fire (5.5% measured
|
||||
# on 578 vendor negatives, 2026-07-18). Letting it corroborate produced a closed loop
|
||||
# on the DEFAULT auto path, no user flag involved:
|
||||
# pill false-fires on a clean non-ByteDance image
|
||||
# -> _PRODUCT_OF maps it to "jimeng", so jimeng resolves to `confirmed`
|
||||
# -> jimeng's NCC gate drops 0.45 -> 0.3825 and it false-fires too
|
||||
# -> _keep_pill now sees "jimeng" in keys and takes the WORDMARK arm, which
|
||||
# removes the pill unrestricted -- skipping the flatness guard that exists
|
||||
# precisely to stop the fill smearing a textured corner.
|
||||
# Measured on the corpus: 3 of 578 negatives ran the full loop, one of them with
|
||||
# footprint_flat=0 (the exact case the guard was written to block). Cutting the pill
|
||||
# out of corroboration removed all 3 and cost NOTHING on 4417 TC260 carriers
|
||||
# (jimeng fires 398 -> 398), so this is a defect fix, not a recall trade.
|
||||
#
|
||||
# `_keep_pill` already encodes the same distrust for the pill's own ACTION; this
|
||||
# closes the gap that its TESTIMONY was never gated.
|
||||
# Regression: tests/test_watermark_registry.py::TestArbiter::
|
||||
# test_weak_pill_detection_does_not_confirm_the_jimeng_wordmark
|
||||
_CANNOT_CORROBORATE: frozenset[str] = frozenset({"jimeng_pill"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MarkDetection:
|
||||
"""Uniform detection result for a known mark (across heterogeneous engines)."""
|
||||
@@ -109,6 +140,32 @@ class Localization:
|
||||
mask: NDArray[Any] | None
|
||||
|
||||
|
||||
_REMOVED_SENSITIVITIES = {
|
||||
"assume_ai": (
|
||||
"sensitivity='assume_ai' was removed in 0.16: it relaxed EVERY mark's detection "
|
||||
"gate on the bare assertion that an image is AI, which says nothing about which "
|
||||
"vendor made it or where the mark is. If you can see a mark the detector missed, "
|
||||
"act on what you see: erase(image, region=(x, y, w, h)), or the CLI "
|
||||
"`--mark <name> --no-detect` for a known text mark. Use sensitivity='auto' for "
|
||||
"the default evidence-driven behaviour."
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def validate_sensitivity(value: str) -> Sensitivity:
|
||||
"""Reject a removed sensitivity LOUDLY instead of silently falling back to ``auto``.
|
||||
|
||||
``Sensitivity`` is a ``Literal``, which is not enforced at runtime, so a caller
|
||||
upgrading from 0.15 would pass ``"assume_ai"`` and quietly get ``auto`` behaviour --
|
||||
a silent semantic change on the one release where they most need to be told.
|
||||
"""
|
||||
if value in _REMOVED_SENSITIVITIES:
|
||||
raise ValueError(_REMOVED_SENSITIVITIES[value])
|
||||
if value not in ("auto", "strict"):
|
||||
raise ValueError(f"unknown sensitivity {value!r}; expected 'auto' or 'strict'")
|
||||
return value # type: ignore[return-value]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Context:
|
||||
"""The evidence + policy the removal arbiter decides against (perception is
|
||||
@@ -120,6 +177,9 @@ class Context:
|
||||
sensitivity: Sensitivity = "auto"
|
||||
provenance: frozenset[str] = frozenset()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
validate_sensitivity(self.sensitivity)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Candidate:
|
||||
@@ -127,9 +187,8 @@ class Candidate:
|
||||
|
||||
Carries the mark's verdict at BOTH trust levels (``detected_strict`` = the
|
||||
conservative gate, ``detected_relaxed`` = the gate the engine relaxes to under
|
||||
provenance/assume), so the arbiter can pick per mark without re-running detection.
|
||||
``relaxed_confidence`` is the gate-bypassed detection's confidence, which the arbiter
|
||||
needs to apply :func:`assumed_floor_ok` when a mark is relaxed on assumption alone.
|
||||
provenance), so the arbiter can pick per mark without re-running detection.
|
||||
|
||||
``features`` is a generic bag of physical measurements a mark's gate may need (the
|
||||
mark owns which it reports via ``KnownMark._features``); e.g. the pill supplies
|
||||
``footprint_flat`` (0/1). Empty for marks whose gate needs no extra evidence."""
|
||||
@@ -138,7 +197,6 @@ class Candidate:
|
||||
label: str
|
||||
detected_strict: bool
|
||||
detected_relaxed: bool
|
||||
relaxed_confidence: float
|
||||
features: dict[str, float] # generic; both construction sites always supply it (empty when none)
|
||||
|
||||
|
||||
@@ -240,16 +298,41 @@ GEMINI_SPARKLE_TRUST_CONF = 0.5
|
||||
_GEMINI_AUTO_MIN_CONF = GEMINI_SPARKLE_TRUST_CONF
|
||||
|
||||
# Provenance-confirmed Gemini trust gate. When external metadata already proves the
|
||||
# image is a Google generation (C2PA issuer "Google"/"Gemini"), the [0.35, 0.5)
|
||||
# band that the no-provenance gate leaves out is no longer ambiguous with Doubao
|
||||
# text: a Doubao image carries ByteDance provenance, not Google, so it never reaches
|
||||
# this relaxed gate. The vendor moving/re-rendering the sparkle (bigger, lighter,
|
||||
# shifted north-west) drops a real sparkle into this band, and the fixed-slot
|
||||
# detector demotes it -- provenance is exactly the extra evidence that lets us trust
|
||||
# it. Set to the engine's own internal `detected` floor (0.35); combined with the
|
||||
# engine's FP-gate being skipped under provenance (see gemini_engine), this recovers
|
||||
# the moved-mark misses without touching the no-provenance precision.
|
||||
_GEMINI_PROVENANCE_MIN_CONF = 0.35
|
||||
# image is a Google generation (C2PA issuer "Google"/"Gemini"), the [gate, 0.5) band
|
||||
# that the no-provenance gate leaves out is no longer ambiguous with Doubao text: a
|
||||
# Doubao image carries ByteDance provenance, not Google, so it never reaches this
|
||||
# relaxed gate. The vendor moving/re-rendering the sparkle (bigger, lighter, shifted
|
||||
# north-west) drops a real sparkle into this band, and the fixed-slot detector demotes
|
||||
# it -- provenance is exactly the extra evidence that lets us trust it.
|
||||
#
|
||||
# The gate was originally the engine's own `detected` floor (0.35). Raised to 0.42
|
||||
# on 2026-07-18 after measuring what this arm actually admits, because the Doubao
|
||||
# argument above -- while correct -- is not the binding constraint. Google C2PA is
|
||||
# carried by Imagen, API generations and NotebookLM exports, none of which stamp a
|
||||
# visible sparkle at all, so the relaxed gate spends most of its budget on images
|
||||
# that never had a mark rather than on moved ones.
|
||||
#
|
||||
# Measured blind on 954 unique Google-metadata uploads (detector never saw the
|
||||
# metadata), hand-labelled against a two-sided control (labeller sensitivity ~88%,
|
||||
# specificity 100%). "Additions" = accepted with provenance but not without:
|
||||
#
|
||||
# band precision 95% CI population
|
||||
# 0.35-0.42 13% 5-30% 120
|
||||
# 0.42-0.46 35% 19-54% 47
|
||||
# 0.46-0.50 27% 14-46% 44
|
||||
# 0.50-0.54 40% 20-64% 15
|
||||
#
|
||||
# Precision is flat above 0.42 and collapses below it, and that bottom band alone is
|
||||
# half the arm's volume -- so this is a step, not a gradient, and 0.42 is where it
|
||||
# sits. Raising the gate here drops ~16 genuine recoveries to prevent ~104 false
|
||||
# fills (6.5:1), cutting false fills from 18.7% to 7.8% of Google-metadata uploads.
|
||||
# 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: even at 0.42 this arm runs at ~33% precision (two false fills per genuine
|
||||
# recovery). Whether an arm that inaccurate should exist at all is a product call,
|
||||
# not a tuning one -- do not read this constant as "now correct".
|
||||
_GEMINI_PROVENANCE_MIN_CONF = 0.42
|
||||
|
||||
# ── Engine adapters (lazy singletons; engines are cv2-only, no model load) ──
|
||||
|
||||
@@ -411,7 +494,7 @@ def _pill_mask(
|
||||
|
||||
def _pill_features(image: NDArray[Any]) -> dict[str, float]:
|
||||
"""The pill's own gate feature: top-left footprint flatness (1.0 = flat enough for
|
||||
an invisible fill), read by the metadata/assume arm of :func:`_keep_pill`."""
|
||||
an invisible fill), read by the metadata arm of :func:`_keep_pill`."""
|
||||
return {"footprint_flat": float(_engine("jimeng_pill").footprint_is_flat(image))}
|
||||
|
||||
|
||||
@@ -457,36 +540,6 @@ def detect_marks(
|
||||
return [m.detect(image, provenance=m.key in provenance) for m in _REGISTRY if include_explicit or m.in_auto]
|
||||
|
||||
|
||||
# Minimum gate-bypassed confidence a mark must reach when it is relaxed on ASSUMPTION
|
||||
# (``assume_ai``) rather than on evidence naming its vendor. Relaxing bypasses the
|
||||
# engine's false-positive gate entirely, which is justified by vendor CONFIRMATION; an
|
||||
# assumption that the image is AI says nothing about WHICH vendor, so the bypassed
|
||||
# detector needs its own floor or it fires on ordinary content.
|
||||
#
|
||||
# Corpus-measured 2026-07-16 (256 genuine camera captures -- Make/Model/exposure/aperture
|
||||
# present and no AI token, so a Gemini sparkle cannot be there -- vs 697 Google-C2PA
|
||||
# positives, metadata used only as the label, never fed to the detector):
|
||||
#
|
||||
# bypassed threshold recall false-fire on clean photos
|
||||
# 0.35 82.6% 59.8% <- the bare detector gate
|
||||
# 0.45 66.6% 12.5%
|
||||
# 0.50 59.4% 0.0% <- chosen
|
||||
# strict gate 56.4% 0.0%
|
||||
#
|
||||
# So 0.35 sat on a cliff: it bought +26pp recall over strict by filling a corner on ~6
|
||||
# of every 10 CLEAN photos. At 0.50 the flag is honest -- it still beats strict, for free.
|
||||
# Marks absent from this dict relax identically at both levels; their bypassed false-fire
|
||||
# on the same negatives is under 1% (doubao 0.8%, jimeng 0.4%, samsung 0.4%).
|
||||
_ASSUMED_CONF_FLOOR: dict[str, float] = {"gemini": 0.50}
|
||||
|
||||
|
||||
def assumed_floor_ok(key: str, confidence: float) -> bool:
|
||||
"""Whether an ``assumed``-trust detection of ``key`` at ``confidence`` is trustworthy
|
||||
enough to act on (see :data:`_ASSUMED_CONF_FLOOR`). Marks with no floor always pass."""
|
||||
floor = _ASSUMED_CONF_FLOOR.get(key)
|
||||
return floor is None or confidence >= floor
|
||||
|
||||
|
||||
def resolve_trust(
|
||||
key: str,
|
||||
*,
|
||||
@@ -500,19 +553,19 @@ def resolve_trust(
|
||||
level (which the engines consume as ``provenance = level != "strict"``). ``strict``
|
||||
never relaxes. A mark is ``confirmed`` only on same-product evidence -- the vendor
|
||||
confirmed by metadata (``key in provenance``) or a confidently strict-detected
|
||||
sibling of the same product (``_PRODUCT_OF``). Without that evidence, ``assume_ai``
|
||||
yields ``assumed`` (relaxed, but subject to :func:`assumed_floor_ok`) and ``auto``
|
||||
stays ``strict``."""
|
||||
sibling of the same product (``_PRODUCT_OF``, minus the marks too weak to vouch,
|
||||
:data:`_CANNOT_CORROBORATE`). Without that evidence a mark stays ``strict``: there is
|
||||
no path that relaxes a gate on anything less than same-product evidence."""
|
||||
if sensitivity == "strict":
|
||||
return "strict"
|
||||
product = _PRODUCT_OF[key]
|
||||
confirmed = key in provenance or any(_PRODUCT_OF[k] == product for k in strict_keys if k != key)
|
||||
if confirmed:
|
||||
return "confirmed"
|
||||
return "assumed" if sensitivity == "assume_ai" else "strict"
|
||||
confirmed = key in provenance or any(
|
||||
_PRODUCT_OF[k] == product for k in strict_keys if k != key and k not in _CANNOT_CORROBORATE
|
||||
)
|
||||
return "confirmed" if confirmed else "strict"
|
||||
|
||||
|
||||
def _keep_pill(keys: set[str], *, provenance: frozenset[str], sensitivity: Sensitivity, footprint_flat: bool) -> bool:
|
||||
def _keep_pill(keys: set[str], *, provenance: frozenset[str], footprint_flat: bool) -> bool:
|
||||
"""Whether to auto-remove the capture-less 'AI生成' pill given the fired marks.
|
||||
|
||||
Pure decision (the flatness feature is precomputed at perception time and passed
|
||||
@@ -522,11 +575,10 @@ def _keep_pill(keys: set[str], *, provenance: frozenset[str], sensitivity: Sensi
|
||||
its false fires were textured ceilings/walls that the fill visibly SMEARS. Arms:
|
||||
* bottom-right "★ 即梦AI" wordmark fired -> ~94% precise, and it survives
|
||||
metadata-STRIPPED uploads: remove the pill unrestricted;
|
||||
* TC260 metadata confirms Jimeng (``"jimeng" in provenance``, no wordmark) OR the
|
||||
caller asserts AI (``sensitivity == "assume_ai"``) -> remove ONLY when the
|
||||
* TC260 metadata confirms Jimeng (``"jimeng" in provenance``, no wordmark) -> remove ONLY when the
|
||||
top-left footprint is flat enough for an invisible fill (``footprint_flat``),
|
||||
so real flat-scene pills (and harmless flat false fires) are cleaned while the
|
||||
damaging textured false fires are left untouched even under assume_ai.
|
||||
damaging textured false fires are left untouched.
|
||||
A Doubao image is TC260 too but is not Jimeng-basic, so the pill never rides on a
|
||||
Doubao detection. No confirmation at all -> never remove (blocks false fires on
|
||||
non-Jimeng content)."""
|
||||
@@ -534,7 +586,7 @@ def _keep_pill(keys: set[str], *, provenance: frozenset[str], sensitivity: Sensi
|
||||
return False
|
||||
if "jimeng" in keys:
|
||||
return True
|
||||
if "jimeng" in provenance or sensitivity == "assume_ai":
|
||||
if "jimeng" in provenance:
|
||||
return footprint_flat
|
||||
return False
|
||||
|
||||
@@ -557,7 +609,7 @@ def _build_candidates(image: NDArray[Any]) -> list[Candidate]:
|
||||
strict = m.detect(image, provenance=False)
|
||||
relaxed = m.detect(image, provenance=True)
|
||||
feats = m.features(image) if (strict.detected or relaxed.detected) else {}
|
||||
cands.append(Candidate(m.key, m.label, strict.detected, relaxed.detected, relaxed.confidence, feats))
|
||||
cands.append(Candidate(m.key, m.label, strict.detected, relaxed.detected, feats))
|
||||
return cands
|
||||
|
||||
|
||||
@@ -566,9 +618,8 @@ def decide(candidates: list[Candidate], context: Context) -> list[Decision]:
|
||||
ordered list of marks to remove (and the trust level each was accepted at).
|
||||
|
||||
All policy lives here, in one place: per-mark trust resolution (:func:`resolve_trust`,
|
||||
which needs the strict-detected siblings for ``auto`` cross-mark corroboration), the
|
||||
assumed-trust confidence floor (:func:`assumed_floor_ok`) and the capture-less pill
|
||||
gate (:func:`_keep_pill`). No image, no I/O -- so it is unit-testable in isolation and
|
||||
which needs the strict-detected siblings for ``auto`` cross-mark corroboration) and
|
||||
the capture-less pill gate (:func:`_keep_pill`). No image, no I/O -- so it is unit-testable in isolation and
|
||||
the same decision drives every caller."""
|
||||
strict_keys = {c.key for c in candidates if c.detected_strict}
|
||||
fired: list[Decision] = []
|
||||
@@ -578,23 +629,13 @@ def decide(candidates: list[Candidate], context: Context) -> list[Decision]:
|
||||
)
|
||||
relax = trust != "strict"
|
||||
ok = c.detected_relaxed if relax else c.detected_strict
|
||||
if trust == "assumed" and not assumed_floor_ok(c.key, c.relaxed_confidence):
|
||||
# Relaxed on assumption alone and too weak to trust: fall back to the strict
|
||||
# verdict rather than dropping the mark, so assume_ai is monotonic -- it only
|
||||
# ever ADDS recall over strict, never removes less than strict would.
|
||||
ok, relax = c.detected_strict, False
|
||||
if ok:
|
||||
fired.append(Decision(c, relax))
|
||||
keys = {d.candidate.key for d in fired}
|
||||
if "jimeng_pill" in keys:
|
||||
pill = next(d for d in fired if d.candidate.key == "jimeng_pill")
|
||||
flat = bool(pill.candidate.features.get("footprint_flat", 0.0))
|
||||
if not _keep_pill(
|
||||
keys,
|
||||
provenance=context.provenance,
|
||||
sensitivity=context.sensitivity,
|
||||
footprint_flat=flat,
|
||||
):
|
||||
if not _keep_pill(keys, provenance=context.provenance, footprint_flat=flat):
|
||||
fired = [d for d in fired if d.candidate.key != "jimeng_pill"]
|
||||
return fired
|
||||
|
||||
|
||||
Reference in New Issue
Block a user