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:
Victor Kuznetsov
2026-07-20 08:14:50 -07:00
co-authored by Claude Opus 4.8
parent a8f3536d3e
commit cfefd9d819
26 changed files with 1715 additions and 257 deletions
+248 -23
View File
@@ -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,
+5 -4
View File
@@ -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,
+56 -33
View File
@@ -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.
+23 -3
View File
@@ -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):
+16 -2
View File
@@ -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):
+21
View File
@@ -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} "
+2 -2
View File
@@ -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):
+127 -86
View File
@@ -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