Visible-watermark registry: reverse-alpha-only Doubao + Gemini, exact native recovery (#28)

* fix(trustmark): gate detection on re-encode durability to kill false positives

TrustMark's wm_present flag is a BCH validity check that spuriously
validates on a content-correlated fraction of un-watermarked images
(AI textures trip it more than camera photos). On a 1343-image set all
20 raw detections were false, several on Gemini/OpenAI/Doubao output that
cannot carry Adobe's watermark, with random-bytes secrets.

A genuine TrustMark is a durable soft binding that survives re-encoding,
so detect_trustmark now re-decodes after a mild JPEG round-trip and
requires the same schema both times. Every observed false positive
collapsed under this gate; the second decode runs only on the rare hit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(identify): Samsung Galaxy AI, FLUX, ByteDance C2PA; fix C2PA substring FP

Detection extensions verified on real signed files (2026-05-29):

- Samsung Galaxy AI: signer attribution via a new _SIGNER_C2PA_PLATFORM
  (Samsung Galaxy / ASUS Gallery) kept separate from the capture-camera
  _DEVICE_C2PA_PLATFORM so a Galaxy AI edit (device cert + AI source type)
  does not trip the camera-vs-AI integrity clash. Plus metadata.samsung_genai:
  the proprietary genAIType marker in PhotoEditor_Re_Edit_Data, a medium-
  confidence AI-editing signal (samsung_only branch).
- Black Forest Labs (FLUX) and ByteDance Volcano Engine (Doubao/Jimeng)
  added as C2PA issuers + issuer->platform mappings.
- fix: C2PA presence required only the bare 4-byte 'c2pa' substring, which
  false-positives on compressed pixel data (a recompressed PNG IDAT re-flagged
  C2PA after its manifest was correctly stripped). New c2pa_marker_in() requires
  the JUMBF wrapper (jumb+c2pa) or the C2PA uuid box; applied in identify +
  metadata. Verified: all 535 real C2PA files carry jumb.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(doubao): gate detection on text structure to cut ~95% of false positives (#23)

Coverage alone over-fired: any textured bottom-right corner cleared the
threshold, so the detector false-positived on ~28% of arbitrary images.
The real '豆包AI生成' mark is six glyphs in one row, so detect now also
requires the text-structure signature (_glyph_structure): many connected
components, no single dominant blob, concentration in a thin horizontal
band. False positives dropped 343 -> 17 across the corpus while keeping
real-mark recall and the doubao-1.png sample. Also accept a no-op force
kwarg for remover-interface symmetry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(samsung): add Samsung Galaxy AI visible-badge remover

New samsung_engine.py removes the bottom-left sparkle + localized
'AI-generated content' badge that Galaxy AI tools stamp. Mirrors the
Doubao locate->mask->inpaint pattern but bottom-left, with a dual-polarity
top-hat mask (the badge is light-on-dark or dark-on-light). Detection gates
on a band + left-anchor signature (the Doubao CJK-component gate does not
transfer: Latin badge letters connect into few blobs). Explicit-only --
tuned on few real badges with a ~4% FP floor, so it is not used in auto.
Synthetic byte-blob fixtures (real badges are user content, not shipped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(visible): unified known-watermark registry + LaMa inpaint backend

watermark_registry.py is a single catalog of known visible marks, each
tying {usual location, in_auto flag, recovery strategy, detect adapter,
remove adapter}: gemini (reverse-alpha, exact), doubao, samsung. cmd_visible
is now registry-driven (best_auto_mark for --mark auto; mark_keys() feeds the
CLI choices) -- the per-mark _run_doubao/_run_samsung helper branches are gone.

Cross-engine confidences are not comparable, so the gemini adapter applies the
corpus-validated 0.5 sparkle threshold for auto arbitration (its engine flag is
loose and weakly fired ~0.36 on Doubao text, hijacking auto).

--backend auto|cv2|lama chooses background reconstruction for the mask-based
marks; auto = LaMa when onnxruntime is present, else cv2. For LaMa the mask is
the FILLED glyph bounding box (sparse glyph masks leave anti-aliased edges
behind). cv2 stays the zero-dependency fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: watermark registry, Samsung/FLUX/ByteDance detection, LaMa backend, trustmark gate

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(doubao): exact reverse-alpha removal from captured alpha map

The Doubao '豆包AI生成' mark is a fixed semi-transparent white overlay, so
given its alpha map the original pixels are recovered exactly:
original = (wm - a*logo)/(1-a) -- no inpaint hallucination.

The alpha map + logo colour were solved from real black+gray Doubao captures
on a controlled background: on black captured = a*logo, and the black/gray pair
solves a per-pixel without assuming the logo colour (a_max~0.65, logo near-white);
the white capture cross-validates (mark vanishes to a flat fill). Bundled as
assets/doubao_alpha.png + geometry constants.

remove_watermark_reverse_alpha applies it scaled to image width; exact at the
captured width, so the registry routes doubao through it only when
reverse_alpha_available (width within the calibrated band) and the mark is
detected, falling back to mask inpaint (cv2/LaMa) otherwise. A light residual
inpaint cleans the sub-pixel rescaling error. Add captures at more resolutions
to widen exact coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(visible): reverse-alpha only -- drop inpaint removal + heuristic detection

Per the principle that we only remove/detect what we can do exactly, the
visible-mark path is now reverse-alpha only:

- Doubao detect is reverse-alpha-consistent: match the bundled alpha glyph
  silhouette against the corner via TM_CCOEFF_NORMED (DETECT_NCC_THRESHOLD 0.4)
  -- keys on the '豆包AI生成' SHAPE, not coverage/structure heuristics. FP
  7/1243 (0.6%). Removes the cv2 inpaint path + the _glyph_structure gate.
- Registry is reverse-alpha only: dropped the cv2/LaMa backend (_glyph_remove,
  _lama_box_inpaint, default_backend, --backend) and the Samsung entry. Doubao
  outside the alpha resolution band is skipped, never inpainted.
- Removed samsung_engine.py + tests + --mark samsung (no alpha map captured;
  Samsung C2PA/genAIType metadata detection in identify is unaffected).
- The universal erase --region (cv2/LaMa) is unchanged -- arbitrary-region
  inpainting stays a user-directed tool, separate from the known-mark registry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(doubao): NCC sub-pixel alignment -> reverse-alpha at any resolution

A pure width-scale of the captured alpha map is only sub-pixel-accurate at the
captured width and leaves a faint ghost elsewhere. remove_watermark_reverse_alpha
now registers the alpha glyph to the actual mark via a TM_CCOEFF_NORMED
scale+position search (_aligned_alpha_map) before inverting the blend, so the
single 2048 capture works at any resolution -- verified clean on the 1773x2364
(3:4) corpus size, the biggest coverage gap (23 files).

reverse_alpha_available is now just 'asset present' (no width band); the registry
still gates removal on detect so a clean corner is never touched. Drops the
_ALPHA_WIDTH_TOLERANCE gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(doubao): keep native recovery exact -- fixed geometry at captured width

Integer-pixel NCC alignment landed ~1px off at the captured width, degrading the
otherwise-exact native reverse-alpha (synthetic recovery error 0.94 -> 1.39).
remove_watermark_reverse_alpha now uses exact width-relative geometry within
_ALPHA_NATIVE_BAND of the captured width and the NCC search only off it -- best
of both: native back to 0.94, other resolutions still aligned.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(doubao): harden alignment -- try fixed+aligned, keep least residual (56/56)

On a faint/busy-background mark the NCC alignment peak can wander a few px off
the true mark and leave a residual (2/56 real corpus files). Off the captured
width, remove_watermark_reverse_alpha now builds BOTH the fixed-geometry and the
NCC-aligned alpha map, applies each, and keeps whichever leaves the least
residual mark (re-detect confidence on the bare reverse-alpha) -- geometry wins
on faint marks, alignment on clear ones, no magic threshold. Real-file round-trip
now removes 56/56 detected Doubao clean across every corpus resolution (was 54).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(doubao): skip residual inpaint at native width for exact recovery

At the captured width the fixed-geometry reverse-alpha is pixel-exact, so
inpainting over it only replaced exactly-recovered interior pixels with a
cv2 hallucination -- measured worse on a textured background (native error
vs true bg 1.6 reverse-alpha-only vs 2.6 with the old always-on
full-footprint inpaint). Native now returns the bare recovery untouched;
off-native, where NCC alignment is only sub-pixel-approximate, the footprint
inpaint stays to clean the seam. Real round-trip still 56/56 across all
corpus resolutions; negatives 0/60, Gemini unaffected.

Add test_native_returns_exact_reverse_alpha_no_inpaint as the regression
guard. Sync CLAUDE.md + README (the table cell and prose described the
pre-NCC "skipped off native / cv2-LaMa" behavior, now stale). Gitignore the
session scheduled_tasks.lock, and add the text-protection research note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Victor Kuznetsov
2026-05-29 19:49:09 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent ef6fdaeeec
commit 58bdf51c59
17 changed files with 1148 additions and 266 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

+46 -113
View File
@@ -20,12 +20,12 @@ from rich.panel import Panel
from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
from rich.table import Table
from remove_ai_watermarks import __version__
from remove_ai_watermarks import __version__, watermark_registry
if TYPE_CHECKING:
from numpy.typing import NDArray
from remove_ai_watermarks.gemini_engine import DetectionResult, GeminiEngine
from remove_ai_watermarks.gemini_engine import DetectionResult
console = Console()
@@ -133,72 +133,6 @@ def _write_bgr_with_alpha(
image_io.imwrite(path, bgra)
def _run_doubao_if_selected(
ctx: click.Context,
image: NDArray[Any],
alpha: NDArray[Any] | None,
output: Path,
mark: str,
gemini_engine: GeminiEngine,
detect: bool,
detect_threshold: float,
inpaint_method: str,
strip_metadata: bool,
) -> bool:
"""Run the Doubao text-strip removal path when it is the selected mark.
Returns True when this path handled the image (caller should stop). In
``auto`` mode the Doubao detector competes with the Gemini detector and wins
only when it is both positive and at least as confident.
"""
from remove_ai_watermarks.doubao_engine import DoubaoEngine
doubao = DoubaoEngine()
d_det = doubao.detect(image)
if mark == "auto":
g_det = gemini_engine.detect_watermark(image)
use_doubao = d_det.detected and d_det.confidence >= g_det.confidence
console.print(
f" [dim]Mark auto:[/] gemini={g_det.confidence:.2f} doubao={d_det.confidence:.2f} "
f"-> {'doubao' if use_doubao else 'gemini'}"
)
else:
use_doubao = mark == "doubao"
if not use_doubao:
return False
if detect and not d_det.detected and d_det.confidence < detect_threshold:
console.print(
f" [yellow]⚠[/] Doubao mark not detected [dim](coverage {d_det.coverage:.1%}). "
f"Use --no-detect to force.[/]"
)
raise SystemExit(0)
method: Literal["telea", "ns"] = "ns" if inpaint_method == "ns" else "telea"
t0 = time.monotonic()
with console.status("[cyan]Removing Doubao watermark…[/]"):
result = doubao.remove_watermark(image, inpaint_method=method)
elapsed = time.monotonic() - t0
output.parent.mkdir(parents=True, exist_ok=True)
_write_bgr_with_alpha(output, result, alpha, clear_region=d_det.region)
if strip_metadata:
try:
from remove_ai_watermarks.metadata import remove_ai_metadata
remove_ai_metadata(output, output)
except Exception as e:
if ctx.obj.get("verbose"):
console.print(f" [yellow]⚠[/] Failed to strip metadata: {e}")
size_kb = output.stat().st_size / 1024
console.print(f" [green]✓[/] Doubao mark removed → {output} [dim]({size_kb:.0f} KB, {elapsed:.2f}s)[/]")
return True
# ── Main group ───────────────────────────────────────────────────────
@@ -238,9 +172,10 @@ def main(ctx: click.Context, verbose: bool) -> None:
@click.option("--detect-threshold", type=float, default=0.25, help="Detection confidence threshold.")
@click.option(
"--mark",
type=click.Choice(["auto", "gemini", "doubao"]),
type=click.Choice(["auto", *watermark_registry.mark_keys()]),
default="auto",
help="Which visible mark to target. auto picks the stronger of the two detectors.",
help="Which known visible mark to target (auto picks the strongest detected). "
"All marks are removed by exact reverse-alpha against a captured alpha map.",
)
@click.option("--strip-metadata/--keep-metadata", default=True, help="Strip AI metadata from output.")
@click.pass_context
@@ -256,13 +191,14 @@ def cmd_visible(
mark: str,
strip_metadata: bool,
) -> None:
"""Remove a visible AI watermark from an image.
"""Remove a known visible AI watermark from an image.
Targets the Gemini sparkle logo (reverse alpha blending) or the Doubao
"豆包AI生成" text strip (locate -> mask -> inpaint). Fast, deterministic,
offline. ``--mark auto`` picks whichever detector fires stronger.
Finds a known mark in its usual place (Gemini sparkle / Doubao text) via the
watermark registry and removes it by exact reverse-alpha against a captured
alpha map -- recovering the true pixels, not an inpaint guess. ``--mark auto``
picks the strongest detected mark. For arbitrary logos/objects, use ``erase``.
"""
from remove_ai_watermarks.gemini_engine import GeminiEngine
from remove_ai_watermarks import watermark_registry as registry
_banner()
source = _validate_image(source)
@@ -270,8 +206,6 @@ def cmd_visible(
if output is None:
output = source.with_stem(source.stem + "_clean")
engine = GeminiEngine()
# Load image (preserving any alpha channel separately)
image, alpha = _read_bgr_and_alpha(source)
if image is None:
@@ -281,45 +215,44 @@ def cmd_visible(
h, w = image.shape[:2]
console.print(f" [dim]Input:[/] {source.name} ({w}x{h})")
# Resolve which visible mark to target, then run the Doubao path if chosen.
if _run_doubao_if_selected(
ctx, image, alpha, output, mark, engine, detect, detect_threshold, inpaint_method, strip_metadata
):
return
# Detection (we always detect softly, to find dynamic region for inpainting)
with console.status("[cyan]Detecting watermark…[/]"):
det = engine.detect_watermark(image)
if detect:
if det.detected:
console.print(
f" [green]✓[/] Watermark detected "
f"[dim](confidence: {det.confidence:.1%}, "
f"spatial: {det.spatial_score:.3f}, "
f"gradient: {det.gradient_score:.3f})[/]"
)
else:
console.print(f" [yellow]⚠[/] Watermark not detected [dim](confidence: {det.confidence:.1%})[/]")
if det.confidence < detect_threshold:
console.print(" [dim]Skipping. Use --no-detect to force removal.[/]")
# Resolve the target mark from the known-watermark registry. ``auto`` scans
# every in-auto mark in its usual place and picks the strongest; an explicit
# ``--mark <key>`` targets that one (the user asserts its presence).
if mark == "auto":
best = registry.best_auto_mark(image)
if best is None:
console.print(" [yellow]⚠[/] No known visible mark detected (gemini / doubao).")
if detect:
console.print(" [dim]Skipping. Use --mark <name> --no-detect to force.[/]")
raise SystemExit(0)
target = "gemini" # forced (no-detect): fall back to the default mark
else:
target = best.key
console.print(f" [dim]Mark auto:[/] {best.label} [dim]({best.location}, conf {best.confidence:.2f})[/]")
else:
target = mark
# Removal
chosen = registry.get_mark(target)
det = chosen.detect(image)
if detect and not det.detected:
console.print(
f" [yellow]⚠[/] {chosen.label} not detected "
f"[dim](conf {det.confidence:.2f}). Use --no-detect to force.[/]"
)
raise SystemExit(0)
if det.detected:
console.print(f" [green]✓[/] {chosen.label} detected [dim]({chosen.location}, conf {det.confidence:.2f})[/]")
method: Literal["telea", "ns"] = "ns" if inpaint_method == "ns" else "telea"
t0 = time.monotonic()
region: tuple[int, int, int, int] | None = None
with console.status("[cyan]Removing watermark…[/]"):
result = engine.remove_watermark(image)
if inpaint:
region = _watermark_region(det, w, h)
result = engine.inpaint_residual(
result,
region,
strength=inpaint_strength,
method=inpaint_method,
)
with console.status(f"[cyan]Removing {chosen.label}… ({chosen.recovery})[/]"):
result, region = chosen.remove(
image,
inpaint_method=method,
inpaint=inpaint,
inpaint_strength=inpaint_strength,
force=not detect,
)
elapsed = time.monotonic() - t0
# Save (preserves transparency by clearing alpha in the watermark region)
+210 -74
View File
@@ -1,29 +1,24 @@
"""Doubao visible watermark removal engine.
Doubao (ByteDance) stamps every generated image with a visible "豆包AI生成"
(Doubao AI generated) text strip in the bottom-right corner. This is the
explicit AIGC label mandated by China's TC260 standard, rendered as a
near-white / light-gray, low-saturation text overlay.
(Doubao AI generated) text strip in the bottom-right corner -- the explicit AIGC
label mandated by China's TC260 standard, a near-white semi-transparent overlay.
Unlike the Gemini sparkle (a fixed square logo removed by reverse alpha
blending against a captured alpha map), the Doubao mark is a text strip whose
exact alpha map we do not yet have. This engine therefore removes it by:
Like the Gemini sparkle, it is a fixed overlay, so it is removed by **exact
reverse-alpha blending** against a captured alpha map (``remove_watermark_reverse_alpha``):
``original = (wm - a*logo)/(1-a)`` -- recovering the true pixels, not an inpaint
guess. The alpha map + logo colour were solved from black+gray Doubao captures
(see data/doubao_capture/ and the reverse-alpha section below) and bundled as
``assets/doubao_alpha.png``.
locate -> mask -> inpaint
Detection (``detect``) is reverse-alpha-consistent: it matches that same alpha
glyph silhouette against the corner via normalized correlation, so it keys on
the actual "豆包AI生成" shape rather than coverage/structure heuristics.
1. Locate: the mark scales with image WIDTH and sits in the bottom-right at a
fixed margin, so we anchor a generous box there (geometry only -- no bundled
template). Constants below are derived from measured Doubao output.
2. Mask: within the box, extract the light, low-saturation glyph pixels with a
polarity-aware rule (the mark is brighter than dark backgrounds and a
distinct off-white gray against light backgrounds).
3. Inpaint: cv2 inpainting (TELEA / NS) reconstructs the covered pixels.
This is fast, offline, deterministic, and needs no GPU. A future upgrade path
is per-pixel reverse alpha blending once a Doubao alpha map is captured on a
controlled black background (see data/doubao_capture/), which would recover the
true pixels instead of hallucinating them -- the same approach as the Gemini
engine.
``locate`` (geometry box, scales with image WIDTH) and ``extract_mask`` (the
candidate glyph mask the detector correlates) remain; there is no inpaint-based
removal here -- arbitrary-region inpainting lives in ``region_eraser`` / the
``erase`` command. Fast, offline, no GPU.
"""
# cv2/numpy boundary: third-party libs ship no usable element types; relax the
@@ -33,7 +28,7 @@ from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Literal
from typing import TYPE_CHECKING, Any
import cv2
import numpy as np
@@ -66,17 +61,63 @@ MAX_SATURATION = 55 # max channel spread to count a pixel as "grayish"
LOGO_MIN_LUMA = 150 # glyphs are at least this bright in absolute terms
TOPHAT_DELTA = 12 # glyph must exceed the local background by this many levels
# Detection: a genuine label fills a meaningful fraction of the box. Measured
# coverage is >=0.20 on real Doubao outputs; random/textured corners stay <=0.06
# on large images but can spike to ~0.15 on tiny ones (small box -> high variance),
# so the threshold sits above that spike and below the real-mark floor.
DETECT_MIN_COVERAGE = 0.16
# Detection is reverse-alpha-consistent: the mark is recognized by matching the
# bundled alpha-template glyph silhouette (assets/doubao_alpha.png -- the exact
# shape we invert) against the extracted candidate mask via zero-mean normalized
# correlation (cv2 TM_CCOEFF_NORMED). It keys on the actual "豆包AI生成" glyph
# SHAPE, not on coverage/structure heuristics, so a merely-textured corner does
# not fire (the old coverage detector false-positived on ~28% of images; #23).
# Corpus-tuned: real marks score median ~0.61, arbitrary corners <=0.17 (p99);
# threshold 0.4 -> false positives 7/1243 (0.6%). A small coverage floor skips
# the template match on a near-empty candidate box.
DETECT_MIN_COVERAGE = 0.04
DETECT_NCC_THRESHOLD = 0.4
# Safety: a text strip fills a modest slice of the (generous) box. When the box
# is over a dense-text / document background the mask explodes and cv2 inpainting
# would smear the real content. Above this coverage we refuse to inpaint and
# leave the image untouched -- that hard case needs the neural path, not a guess.
MAX_INPAINT_COVERAGE = 0.50
# ── Reverse-alpha (exact recovery, Gemini-style) ─────────────────────
# The Doubao mark is a fixed semi-transparent white overlay, so given its alpha
# map the original pixels are recovered exactly: original = (wm - a*logo)/(1-a).
# The alpha map + logo colour were solved from black+gray Doubao captures on a
# controlled background (data/doubao_capture/): on black, captured = a*logo, and
# the black/gray pair solves a per-pixel WITHOUT assuming the logo colour. The
# bundled asset (assets/doubao_alpha.png) is the alpha template (a*255) at the
# captured width. The mark scales with image WIDTH, but a pure width-scale is
# only sub-pixel-accurate at the captured width and ghosts elsewhere, so removal
# does NOT trust fixed geometry: `_aligned_alpha_map` registers the template to
# the actual mark by a TM_CCOEFF_NORMED scale+position search, which makes the
# single capture work at any resolution (verified clean on 1773x2364). Verified
# 2026-05-29: white-capture cross-check -> mark vanishes to a flat fill; clean on
# doubao-1.png (2048) and the 3:4 portrait corpus size.
_ALPHA_NATIVE_WIDTH = 2048
_ALPHA_LOGO_BGR: tuple[float, float, float] = (252.0, 255.0, 255.0)
_ALPHA_WIDTH_FRAC = 0.1572 # glyph width / image width -- the alignment scale seed
_ALPHA_HEIGHT_FRAC = 0.0347
# Margins (of image WIDTH) of the captured mark -- the geometry record / where to
# seed; alignment refines the actual position, so these are not load-bearing.
_ALPHA_MARGIN_RIGHT_FRAC = 0.0166
_ALPHA_MARGIN_BOTTOM_FRAC = 0.0195
# Alignment scale search (np.linspace args) around the width-scaled glyph size.
_ALPHA_ALIGN_SEARCH = (0.88, 1.12, 13)
# At (near) the captured width the fixed geometry is pixel-exact, so we use it
# directly there -- NCC alignment is integer-pixel and would land ~1px off,
# degrading the otherwise-exact native recovery. Off this band, alignment wins.
_ALPHA_NATIVE_BAND = 0.03
_alpha_template_cache: NDArray[Any] | None = None
def _alpha_template() -> NDArray[Any] | None:
"""Lazily load the bundled Doubao alpha template (float [0,1]), or None."""
global _alpha_template_cache
if _alpha_template_cache is None:
from pathlib import Path
from remove_ai_watermarks import image_io
path = Path(__file__).parent / "assets" / "doubao_alpha.png"
img = image_io.imread(str(path), cv2.IMREAD_GRAYSCALE)
if img is None:
return None
_alpha_template_cache = img.astype(np.float32) / 255.0
return _alpha_template_cache
@dataclass(frozen=True)
@@ -104,6 +145,39 @@ class DoubaoDetection:
coverage: float = 0.0 # fraction of the box occupied by glyph pixels
_silhouette_cache: NDArray[Any] | None = None
def _glyph_silhouette() -> NDArray[Any] | None:
"""Binary "豆包AI生成" silhouette (255 = glyph) from the bundled alpha map,
used as the detection template. None if the alpha asset is missing."""
global _silhouette_cache
if _silhouette_cache is None:
at = _alpha_template()
if at is None:
return None
_silhouette_cache = (at > 0.15).astype(np.uint8) * 255
return _silhouette_cache
def _template_match_score(box_mask: NDArray[Any], image_width: int) -> 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 "豆包AI生成" shape does.
"""
sil = _glyph_silhouette()
if sil is None or box_mask.size == 0:
return 0.0
gw = min(box_mask.shape[1] - 1, max(8, int(_ALPHA_WIDTH_FRAC * image_width)))
gh = min(box_mask.shape[0] - 1, max(4, int(_ALPHA_HEIGHT_FRAC * image_width)))
if gw < 8 or gh < 4:
return 0.0
template = cv2.resize(sil, (gw, gh), interpolation=cv2.INTER_NEAREST)
return float(cv2.matchTemplate(box_mask, template, cv2.TM_CCOEFF_NORMED).max())
class DoubaoEngine:
"""Remove the visible Doubao "豆包AI生成" watermark (locate -> mask -> inpaint)."""
@@ -176,10 +250,12 @@ class DoubaoEngine:
# ── Detect ────────────────────────────────────────────────────────
def detect(self, image: NDArray[Any]) -> DoubaoDetection:
"""Detect the visible Doubao mark by glyph coverage in the corner box.
"""Detect the visible Doubao mark by matching the alpha-template glyph
silhouette against the corner candidate (TM_CCOEFF_NORMED).
Heuristic: a genuine label fills a meaningful fraction of the box with
text-like glyph pixels. Coverage maps to a confidence score.
Keys on the "豆包AI生成" SHAPE, not coverage, so a textured corner does
not fire. ``confidence`` is the correlation score; ``detected`` is it
clearing ``DETECT_NCC_THRESHOLD``.
"""
det = DoubaoDetection()
if image is None or image.size == 0:
@@ -191,53 +267,113 @@ class DoubaoEngine:
coverage = float((box > 0).sum()) / float(max(1, bw * bh))
det.region = loc.bbox
det.coverage = coverage
# Map coverage to a 0-1 confidence: ~0.06 (noise floor) -> 0, ~0.26 -> 1.
det.confidence = float(max(0.0, min(1.0, (coverage - 0.06) / 0.20)))
det.detected = coverage >= DETECT_MIN_COVERAGE
logger.debug("Doubao detect: coverage=%.3f conf=%.3f", coverage, det.confidence)
if coverage >= DETECT_MIN_COVERAGE:
score = _template_match_score(box, image.shape[1])
det.confidence = score
det.detected = score >= DETECT_NCC_THRESHOLD
logger.debug("Doubao detect: coverage=%.3f ncc=%.2f detected=%s", coverage, score, det.detected)
return det
# ── Remove ────────────────────────────────────────────────────────
# ── Reverse-alpha (exact recovery) ────────────────────────────────
def remove_watermark(
self,
image: NDArray[Any],
*,
inpaint_method: Literal["telea", "ns"] = "telea",
inpaint_radius: int = 6,
dilate: int = 3,
) -> NDArray[Any]:
"""Remove the visible Doubao watermark by inpainting the glyph mask.
def reverse_alpha_available(self, image: NDArray[Any]) -> bool:
"""True if the bundled alpha map is loadable. Sub-pixel NCC alignment
(see ``_aligned_alpha_map``) places it on the actual mark at ANY
resolution, so there is no width gate -- the caller still gates on
``detect`` so a clean corner is never touched."""
return image is not None and image.size > 0 and _alpha_template() is not None
Returns an unmodified copy when no glyph pixels are found (so we never
smear a clean corner). ``dilate`` grows the mask to cover anti-aliased
glyph edges before inpainting.
"""
if image is None or image.size == 0:
return image
def _fixed_alpha_map(self, image: NDArray[Any]) -> tuple[NDArray[Any], tuple[int, int, int, int]] | None:
"""Place the template by fixed width-relative geometry -- pixel-exact at
the captured width (used there instead of integer-pixel NCC alignment)."""
at = _alpha_template()
if at is None:
return None
h, w = image.shape[:2]
gw, gh = max(1, int(_ALPHA_WIDTH_FRAC * w)), max(1, int(_ALPHA_HEIGHT_FRAC * w))
ax = max(0, w - int(_ALPHA_MARGIN_RIGHT_FRAC * w) - gw)
ay = max(0, h - int(_ALPHA_MARGIN_BOTTOM_FRAC * w) - gh)
amap = np.zeros((h, w), np.float32)
amap[ay : ay + gh, ax : ax + gw] = cv2.resize(at, (gw, gh), interpolation=cv2.INTER_LINEAR)
return amap, (ax, ay, gw, gh)
def _aligned_alpha_map(self, image: NDArray[Any]) -> tuple[NDArray[Any], tuple[int, int, int, int]] | None:
"""Build a full-image alpha map with the captured template registered to
the actual mark via a TM_CCOEFF_NORMED scale + position search -- so the
single capture works off the captured width (a pure width-scale ghosts).
Returns ``(alpha_map, glyph_bbox)`` or None."""
at = _alpha_template()
sil = _glyph_silhouette()
if at is None or sil is None:
return None
h, w = image.shape[:2]
loc = self.locate(image)
mask = self.extract_mask(image, loc)
if not mask.any():
logger.debug("Doubao remove: no glyph pixels found; returning copy")
bx, by, bw, bh = loc.bbox
box_mask = self.extract_mask(image, loc)[by : by + bh, bx : bx + bw]
expected = _ALPHA_WIDTH_FRAC * w
best: tuple[float, int, int, int, int] | None = None
for scale in np.linspace(*_ALPHA_ALIGN_SEARCH):
gw, gh = int(expected * scale), int(_ALPHA_HEIGHT_FRAC * w * scale)
if gw < 8 or gh < 4 or gw >= bw or gh >= bh:
continue
t = cv2.resize(sil, (gw, gh), interpolation=cv2.INTER_NEAREST)
_, score, _, top_left = cv2.minMaxLoc(cv2.matchTemplate(box_mask, t, cv2.TM_CCOEFF_NORMED))
if best is None or score > best[0]:
best = (score, gw, gh, top_left[0], top_left[1])
if best is None:
return None
_, gw, gh, ox, oy = best
ax, ay = bx + ox, by + oy
amap = np.zeros((h, w), np.float32)
amap[ay : ay + gh, ax : ax + gw] = cv2.resize(at, (gw, gh), interpolation=cv2.INTER_LINEAR)
return amap, (ax, ay, gw, gh)
def _apply_reverse_alpha(self, image: NDArray[Any], amap: NDArray[Any]) -> NDArray[Any]:
"""Invert the alpha blend with ``amap``: ``original = (wm - a*logo)/(1-a)``."""
a3 = np.clip(amap, 0.0, 1.0)[:, :, None]
logo = np.array(_ALPHA_LOGO_BGR, np.float32)
return np.clip((image.astype(np.float32) - a3 * logo) / np.clip(1.0 - a3, 0.25, 1.0), 0, 255).astype(np.uint8)
def remove_watermark_reverse_alpha(self, image: NDArray[Any], *, residual_inpaint: bool = True) -> NDArray[Any]:
"""Recover the original pixels by inverting the alpha blend
``original = (wm - a*logo)/(1-a)``.
Placement: at (near) the captured width the fixed geometry is pixel-exact,
so the recovery is returned UNTOUCHED -- inpainting over exactly-recovered
interior pixels only swaps them for a cv2 hallucination (measured worse on
textured backgrounds: native error vs true bg 1.6 reverse-alpha-only vs
2.6 with full-footprint inpaint). Off-native, NCC alignment registers the
template to the real mark; the alignment is only sub-pixel-approximate, so
the interior recovery is no longer exact and the seam can re-trip the
detector. There we try BOTH placements and keep whichever leaves the least
residual mark (on a faint/busy-background mark the NCC peak can wander a
few px, where geometry wins; on a clear mark alignment wins) -- no magic
threshold, it just picks the better removal -- then a residual inpaint over
the glyph footprint cleans the seam (the interior is approximate anyway, so
inpaint there costs nothing and reliably clears the mark).
Call only when :meth:`reverse_alpha_available` and the mark is detected.
"""
at_native = abs(image.shape[1] / _ALPHA_NATIVE_WIDTH - 1.0) <= _ALPHA_NATIVE_BAND
if at_native:
amap = self._fixed_alpha_map(image)
return self._apply_reverse_alpha(image, amap[0]) if amap is not None else image.copy()
maps = [c for c in (self._fixed_alpha_map(image), self._aligned_alpha_map(image)) if c is not None]
if not maps:
return image.copy()
x, y, bw, bh = loc.bbox
coverage = float((mask[y : y + bh, x : x + bw] > 0).sum()) / float(max(1, bw * bh))
if coverage > MAX_INPAINT_COVERAGE:
logger.warning(
"Doubao remove: box coverage %.2f exceeds %.2f (dense-text/document "
"background); leaving image untouched to avoid smearing content",
coverage,
MAX_INPAINT_COVERAGE,
)
best_out: NDArray[Any] | None = None
best_amap: NDArray[Any] | None = None
best_residual = float("inf")
for amap, _region in maps:
out = self._apply_reverse_alpha(image, amap)
residual = self.detect(out).confidence
if residual < best_residual:
best_residual, best_out, best_amap = residual, out, amap
if best_out is None or best_amap is None: # pragma: no cover - maps is non-empty
return image.copy()
if dilate > 0:
k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * dilate + 1, 2 * dilate + 1))
mask = cv2.dilate(mask, k)
flag = cv2.INPAINT_TELEA if inpaint_method == "telea" else cv2.INPAINT_NS
return cv2.inpaint(image, mask, inpaint_radius, flag)
if residual_inpaint:
rm = cv2.dilate((best_amap > 0.10).astype(np.uint8) * 255, np.ones((3, 3), np.uint8))
best_out = cv2.inpaint(best_out, rm, 3, cv2.INPAINT_TELEA)
return best_out
def load_image_bgr(path: str | Path) -> NDArray[Any]:
+64 -8
View File
@@ -25,14 +25,15 @@ from typing import TYPE_CHECKING
from remove_ai_watermarks.metadata import (
AI_METADATA_KEYS,
AIGC_MARKERS,
C2PA_UUID,
IPTC_AI_FIELD_MARKERS,
IPTC_AI_MARKERS,
aigc_label,
c2pa_marker_in,
exif_generator,
get_ai_metadata,
huggingface_job,
iptc_ai_system,
samsung_genai,
scan_head,
xai_signature,
)
@@ -65,6 +66,8 @@ _ISSUER_PLATFORM: tuple[tuple[str, str], ...] = (
("OpenAI", "OpenAI (ChatGPT / gpt-image / DALL-E / Sora)"),
("Google", "Google (Gemini / Imagen)"),
("Stability AI", "Stability AI (Stable Image / DreamStudio)"),
("Black Forest Labs", "Black Forest Labs (FLUX)"),
("ByteDance", "ByteDance (Doubao / Jimeng / Volcano Engine)"),
)
# PNG-text / EXIF keys that indicate a local diffusion pipeline (vs. a hosted
@@ -95,6 +98,12 @@ _HF_JOB_CAVEAT = (
"generation) but names neither the model nor the content type, so it is a "
"medium-confidence signal, not proof the pixels are AI-generated."
)
_SAMSUNG_GENAI_CAVEAT = (
"Samsung's genAIType marker shows a Galaxy AI editing tool (Generative Edit, "
"Sketch to Image, ...) touched the image; it is an undocumented proprietary "
"field, so it is a medium-confidence signal of AI editing, not proof the "
"whole image is AI-generated."
)
@dataclass
@@ -151,7 +160,9 @@ def _ai_tools_in(data: bytes) -> list[str]:
# assert is_ai on their own (the verdict still comes from the digital-source-type:
# the Pixel sample carries `computationalCapture`, not `trainedAlgorithmicMedia`).
# Only tokens verified against a real signed file are listed (Leica, Nikon,
# Truepic, Google Pixel); add Sony/Canon/Samsung/Bria as real samples are captured.
# Sony, Truepic, Google Pixel); add Canon/Bria as real samples are captured.
# Samsung Galaxy is an AI-capable editing device, not a pure-capture camera, so
# it lives in `_SIGNER_C2PA_PLATFORM` below (it must not feed the camera clash).
_DEVICE_C2PA_PLATFORM: tuple[tuple[bytes, str], ...] = (
(b"lc_c2pa", "Leica (camera, C2PA capture)"),
(b"Leica Camera", "Leica (camera, C2PA capture)"),
@@ -177,6 +188,32 @@ def _device_platform(head: bytes) -> str | None:
return None
# C2PA signers that are an editing app or AI-capable device rather than a
# verified-capture camera. Unlike `_DEVICE_C2PA_PLATFORM`, these do NOT feed the
# camera-vs-AI integrity clash (rule 2 in `_integrity_clashes`): a Galaxy phone
# legitimately stamps BOTH its device credentials AND a `trainedAlgorithmicMedia`
# source type on a Generative-Edit image, so treating it as a "genuine camera
# capture" would false-flag every Galaxy AI edit. They only resolve the platform
# label; the AI verdict still comes from the digital-source-type / genAIType.
# Tokens verified against real signed files (2026-05-29):
# Samsung Galaxy -- cert org on Galaxy S23 FE / S24 / S25 C2PA JPEGs/PNGs
# (distinct from the EXIF "SM-xxxx" model string on ordinary Samsung photos).
# com.asus.gallery -- ASUS Gallery claim_generator (a C2PA-signed edit, no AI
# source type or genAIType on the samples, so it never asserts is_ai).
_SIGNER_C2PA_PLATFORM: tuple[tuple[bytes, str], ...] = (
(b"Samsung Galaxy", "Samsung Galaxy (C2PA)"),
(b"com.asus.gallery", "ASUS Gallery (C2PA signer)"),
)
def _signer_platform(head: bytes) -> str | None:
"""Map a C2PA editing-app / AI-capable-device signer token to a platform."""
for token, platform in _SIGNER_C2PA_PLATFORM:
if token in head:
return platform
return None
def _attribute_platform(issuers: list[str], *, is_ai: bool = True) -> str | None:
"""Map a set of C2PA issuer names to a human-readable generating platform.
@@ -353,9 +390,10 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
# neither is a trustworthy "the generator stamped its identity" claim.
ai_vendor_claims: dict[str, str] = {}
camera_label = _device_platform(head)
signer_label = _signer_platform(head)
# ── C2PA Content Credentials ────────────────────────────────────
has_c2pa = bool(info) or b"c2pa" in head.lower() or C2PA_UUID in head
has_c2pa = bool(info) or c2pa_marker_in(head)
issuers = [info["issuer"]] if info.get("issuer") else _issuers_in(head)
c2pa_is_ai = "trainedAlgorithmicMedia" in info.get("source_type", "") or any(
m in head for m in (b"trainedAlgorithmicMedia", b"compositeWithTrainedAlgorithmicMedia")
@@ -370,10 +408,11 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
or (", ".join(tools) if (tools := _ai_tools_in(head)) else None)
)
# Platform: a distinctive device/camera token in the manifest wins (it is the
# signer/producer), with the issuer byte-scan only as fallback. The issuer
# scan alone mis-attributed real samples (Leica->Truepic timestamp authority,
# Nikon->Adobe namespace, Pixel->Google Gemini) -- the device scan fixes that.
platform = (camera_label or _attribute_platform(issuers, is_ai=c2pa_is_ai)) if has_c2pa else None
# signer/producer), then an editing-app/AI-device signer (Samsung Galaxy,
# ASUS Gallery), with the issuer byte-scan only as fallback. The issuer scan
# alone mis-attributed real samples (Leica->Truepic timestamp authority,
# Nikon->Adobe namespace, Pixel->Google Gemini) -- the token scans fix that.
platform = (camera_label or signer_label or _attribute_platform(issuers, is_ai=c2pa_is_ai)) if has_c2pa else None
if has_c2pa:
detail = ", ".join(filter(None, [", ".join(issuers), generator, info.get("source_type")]))
signals.append(Signal("c2pa", detail or "C2PA manifest present", "high"))
@@ -484,6 +523,22 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
if platform is None:
platform = "HuggingFace-hosted job (model not identified)"
# ── Samsung Galaxy AI editing marker (genAIType) ─────────────────
# Galaxy AI tools stamp a proprietary genAIType in PhotoEditor_Re_Edit_Data.
# Medium confidence: it co-occurs with the C2PA trainedAlgorithmicMedia type
# on Galaxy files that record one, and is the SOLE AI marker on a Galaxy S24
# sample that omits the source type -- so it lifts an otherwise-Unknown
# verdict, but the field is undocumented, so it never overrides a high-
# confidence signal. The platform is usually already "Samsung Galaxy" via the
# signer-token scan; the fallback covers a future file without the cert org.
samsung_genai_type = samsung_genai(image_path)
if samsung_genai_type is not None:
signals.append(Signal("samsung_genai", f"Samsung genAIType={samsung_genai_type}", "medium"))
watermarks.append("Samsung Galaxy AI editing marker (genAIType)")
caveats.append(_SAMSUNG_GENAI_CAVEAT)
if platform is None:
platform = "Samsung Galaxy (Galaxy AI editing)"
# ── Open invisible watermark (SD / SDXL / FLUX, dwtDct) ──────────
# Public decoder, no key -- a definitive embedded signal on pristine files.
if check_invisible and (scheme := _invisible_watermark(image_path)) is not None:
@@ -527,11 +582,12 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
visible_only = any(s.name == "visible_sparkle" for s in signals) and not ai_from_metadata
hf_only = bool(hf_job) and not ai_from_metadata
samsung_only = samsung_genai_type is not None and not ai_from_metadata
if ai_from_metadata:
is_ai: bool | None = True
confidence = "high"
elif visible_only or hf_only:
elif visible_only or hf_only or samsung_only:
is_ai = True
confidence = "medium"
else:
+54 -4
View File
@@ -65,6 +65,22 @@ AI_KEYWORDS: tuple[str, ...] = (
# Reference: https://spec.c2pa.org/specifications/specifications/2.1/specs/C2PA_Specification.html
C2PA_UUID: bytes = bytes.fromhex("d8fec3d61b0e483c92975828877ec481")
def c2pa_marker_in(data: bytes) -> bool:
"""True if ``data`` carries a real C2PA manifest marker, not just an
incidental 4-byte ``c2pa`` substring.
A bare ``c2pa`` byte match false-positives on compressed pixel data -- a
recompressed PNG IDAT (or any large binary) can contain the bytes ``c2pa``
by chance (verified 2026-05-29: 4 cleaned PNGs re-flagged this way after
their manifest was correctly stripped). Every real manifest is JUMBF-wrapped
(the ``jumb`` box FourCC accompanies the ``c2pa`` content type) or uses the
standalone C2PA ``uuid`` box in ISOBMFF, so we require one of those: the
joint ``jumb`` + ``c2pa`` match has negligible random-collision probability.
"""
return C2PA_UUID in data or (b"jumb" in data and b"c2pa" in data.lower())
# IPTC ``digitalSourceType`` values (IPTC 2025.1) that flag AI provenance.
# Used by Instagram, Facebook, X (Twitter) to show "Made with AI" labels.
IPTC_AI_MARKERS: tuple[bytes, ...] = (
@@ -213,9 +229,7 @@ def has_ai_metadata(image_path: Path) -> bool:
# Binary scan covers C2PA (PNG caBX, JPEG APP11, AVIF/HEIF/JXL uuid boxes)
# and IPTC AI markers in XMP. First 512KB (plus late ISOBMFF provenance boxes).
data = scan_head(image_path, 512 * 1024)
if b"c2pa" in data.lower() or b"C2PA" in data:
return True
if C2PA_UUID in data:
if c2pa_marker_in(data):
return True
if any(marker in data for marker in AIGC_MARKERS):
return True
@@ -310,6 +324,39 @@ def huggingface_job(image_path: Path) -> str | None:
return None
# Samsung Galaxy AI editing marker. Galaxy AI tools (Generative Edit, Sketch to
# Image, Portrait Studio, Drawing Assist, ...) record their re-edit data as a
# proprietary ``PhotoEditor_Re_Edit_Data`` JSON that carries a ``genAIType``
# field; a non-zero value flags that a generative-AI tool produced or altered
# the pixels. The field is undocumented by Samsung (verified 2026-05-29: absent
# from the C2PA spec and Samsung's public docs/forums), so detection is
# empirical -- on real Galaxy S23/S24/S25 files it co-occurs with the C2PA
# ``trainedAlgorithmicMedia`` source type (3/3 of the verified files that record
# that type), and on a Galaxy S24 sample it is the *only* AI marker (the C2PA
# source type was absent there). Medium confidence: it signals Galaxy AI editing
# without proving the whole image is AI-generated. Scoped to the Samsung editor
# container to avoid matching a stray ``genAIType`` token elsewhere.
_SAMSUNG_GENAI_RE = re.compile(rb'genAIType"\s*:\s*(-?\d+)')
_SAMSUNG_EDITOR_MARKER = b"PhotoEditor_Re_Edit_Data"
def samsung_genai(image_path: Path) -> int | None:
"""Return Samsung's non-zero ``genAIType`` value if the image carries the
Galaxy AI editing marker, else None.
See the module note above ``_SAMSUNG_GENAI_RE``: detection is empirical and
gated on the ``PhotoEditor_Re_Edit_Data`` container so an incidental
``genAIType`` token cannot false-positive.
"""
head = scan_head(image_path, 512 * 1024)
if _SAMSUNG_EDITOR_MARKER not in head:
return None
m = _SAMSUNG_GENAI_RE.search(head)
if m is None:
return None
return int(m.group(1)) or None
def iptc_ai_system(image_path: Path) -> str | None:
"""Return an IPTC 2025.1 AI-disclosure note if the file carries those XMP
properties, else None.
@@ -360,7 +407,7 @@ def synthid_source(image_path: Path) -> str | None:
# C2PA manifest where the PNG parser can't reach it. Binary-scan for the
# same signal: a C2PA manifest from a SynthID-using issuer on AI content.
data = scan_head(image_path)
has_c2pa = b"c2pa" in data.lower() or C2PA_UUID in data
has_c2pa = c2pa_marker_in(data)
# Matches both "trainedAlgorithmicMedia" and "compositeWithTrainedAlgorithmicMedia".
ai_source = b"trainedAlgorithmicMedia" in data or b"TrainedAlgorithmicMedia" in data
if not (has_c2pa and ai_source):
@@ -585,6 +632,9 @@ def get_ai_metadata(image_path: Path) -> dict[str, str]:
# HuggingFace-hosted job marker (hf-job-id PNG text chunk).
if job := huggingface_job(image_path):
result.setdefault("huggingface_job", f"HuggingFace-hosted job ({job})")
# Samsung Galaxy AI editing marker (genAIType in PhotoEditor_Re_Edit_Data).
if (genai := samsung_genai(image_path)) is not None:
result.setdefault("samsung_genai", f"Samsung Galaxy AI editing marker (genAIType={genai})")
return result
@@ -88,6 +88,14 @@ C2PA_ISSUERS = {
# Stability AI signs C2PA as "Stability AI" (cert org "Stability AI Ltd").
# Verified on a live Brand Studio (DreamStudio successor) output, 2026-05-24.
b"Stability AI": "Stability AI",
# Black Forest Labs (FLUX) API output: claim_generator_info "Black Forest
# Labs API" + a c2pa.ai_generated_content assertion + trainedAlgorithmicMedia.
# Verified on a real signed FLUX JPEG, 2026-05-29.
b"Black Forest Labs": "Black Forest Labs",
# ByteDance's Volcano Engine (Volcengine) signs its AI image output with a
# cert from certificate_center@volcengine.com -- the platform behind Doubao /
# Jimeng. Verified on two real signed JPEGs, 2026-05-29.
b"volcengine": "ByteDance (Volcano Engine)",
}
# C2PA issuers whose signed outputs also carry an invisible SynthID pixel
+46 -5
View File
@@ -51,12 +51,31 @@ def _decoder() -> Any:
return _tm
# JPEG quality for the false-positive durability gate (see detect_trustmark).
# Deliberately mild: a genuine TrustMark survives far harsher, while every
# observed false positive collapsed even at this quality.
_REENCODE_QUALITY = 95
def detect_trustmark(image_path: Path) -> str | None:
"""Return a TrustMark scheme note if a TrustMark watermark is decoded, else None.
"""Return a TrustMark scheme note if a *durable* TrustMark watermark is
decoded, else None.
Returns e.g. ``"Adobe TrustMark (variant P, schema 0)"`` when the decoder
reports the watermark present, or None if it is absent, the optional
``trustmark`` package is not installed, or the image cannot be read/decoded.
reports the watermark present AND it survives a mild JPEG re-encode, or None
if it is absent, the optional ``trustmark`` package is not installed, or the
image cannot be read/decoded.
**False-positive gate.** TrustMark's ``wm_present`` flag is a BCH
error-correction validity check, which spuriously validates on a small
fraction of un-watermarked images -- content-correlated, so AI-generated
textures trip it more often than camera photos (verified 2026-05-29 on real
files: the false "detections" were on Gemini / OpenAI / Doubao output that
cannot carry Adobe's watermark, and decoded a random-bytes secret). A genuine
TrustMark is a *durable* soft binding engineered to survive re-encoding (that
is its entire purpose once C2PA is stripped), so we re-decode after a mild
JPEG round-trip and require the same schema both times. Every observed false
positive collapsed under this gate.
"""
if not is_available():
return None
@@ -65,8 +84,30 @@ def detect_trustmark(image_path: Path) -> str | None:
with Image.open(image_path) as img:
cover = img.convert("RGB")
_wm_secret, wm_present, wm_schema = _decoder().decode(cover)
decoder = _decoder()
_wm_secret, wm_present, wm_schema = decoder.decode(cover)
if not wm_present:
return None
if not _survives_reencode(decoder, cover, wm_schema):
log.debug("TrustMark decode for %s did not survive re-encode; treating as false positive", image_path)
return None
except Exception as exc: # model download / decode failure / unreadable image
log.debug("TrustMark decode failed for %s: %s", image_path, exc)
return None
return f"Adobe TrustMark (variant {_MODEL_TYPE}, schema {wm_schema})" if wm_present else None
return f"Adobe TrustMark (variant {_MODEL_TYPE}, schema {wm_schema})"
def _survives_reencode(decoder: Any, cover: Any, schema: int) -> bool:
"""True if the watermark re-decodes with the same schema after a mild JPEG
round-trip -- the durability a genuine TrustMark guarantees, which a BCH
false positive (content noise) does not."""
import io
from PIL import Image
buffer = io.BytesIO()
cover.save(buffer, "JPEG", quality=_REENCODE_QUALITY)
buffer.seek(0)
with Image.open(buffer) as reencoded:
_secret, present, reencoded_schema = decoder.decode(reencoded.convert("RGB"))
return bool(present) and reencoded_schema == schema
@@ -0,0 +1,202 @@
"""Registry of known visible watermarks.
A single catalog that ties each known visible mark to (a) where it usually sits,
(b) how to recognize it there, and (c) how to remove it. One pass over the
registry detects every known mark in its usual place and removes the ones
present.
**Reverse-alpha only.** A known mark is a fixed semi-transparent overlay, so it
is removed by inverting the alpha blend against a captured alpha map
(``original = (wm - a*logo)/(1-a)``) -- exact recovery of the true pixels, not an
inpaint guess. Detection is consistent with that: each mark is recognized by
matching its known shape/template (the thing we invert), not by heuristics. A
mark is therefore listed here only once a real alpha map has been captured for
it; everything else (arbitrary logos/objects) is the user-directed
``erase --region`` tool, not this catalog.
Entries:
- ``gemini`` -- Google Gemini / Nano Banana sparkle, bottom-right.
- ``doubao`` -- ByteDance Doubao "豆包AI生成" text strip, bottom-right.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Literal
if TYPE_CHECKING:
from collections.abc import Callable
from numpy.typing import NDArray
# cv2 method for the Gemini reverse-alpha edge-residual cleanup (not a standalone
# remover): "ns" / "telea".
InpaintMethod = Literal["telea", "ns"]
Region = tuple[int, int, int, int]
@dataclass(frozen=True)
class MarkDetection:
"""Uniform detection result for a known mark (across heterogeneous engines)."""
key: str
label: str
location: str
detected: bool
confidence: float
region: Region
@dataclass(frozen=True)
class KnownMark:
"""A known visible watermark: where it lives, how to find and remove it."""
key: str
label: str
location: str # usual place, human-readable ("bottom-right")
in_auto: bool # participate in `--mark auto` scanning
recovery: str # removal strategy (all reverse-alpha today)
_detect: Callable[[NDArray[Any]], MarkDetection]
_remove: Callable[..., tuple[NDArray[Any], Region | None]]
def detect(self, image: NDArray[Any]) -> MarkDetection:
return self._detect(image)
def remove(
self,
image: NDArray[Any],
*,
inpaint_method: InpaintMethod = "ns",
inpaint: bool = True,
inpaint_strength: float = 0.85,
force: bool = False,
) -> tuple[NDArray[Any], Region | None]:
"""Remove this mark by reverse-alpha; returns ``(result, cleared_region)``
(region for clearing alpha on save, or None if nothing was removed).
``inpaint`` / ``inpaint_strength`` / ``inpaint_method`` tune the Gemini
reverse-alpha edge-residual cleanup only. ``force`` removes at the mark's
usual location even without a positive detection (the ``--no-detect`` path).
"""
return self._remove(image, inpaint_method, inpaint, inpaint_strength, force)
# Gemini-sparkle confidence above which the registry treats it as a confident
# detection for arbitration. Matches identify's corpus-validated sparkle
# threshold (0.5): the gemini engine's own detect flag uses a looser internal
# threshold and weakly fires (~0.36) on unrelated bottom-right text (e.g. the
# Doubao mark), which would otherwise let it hijack `--mark auto`. 0.5 gives 0
# false positives on the corpus.
_GEMINI_AUTO_MIN_CONF = 0.5
# ── Engine adapters (lazy singletons; engines are cv2-only, no model load) ──
_engines: dict[str, Any] = {}
def _engine(key: str) -> Any:
if key not in _engines:
if key == "gemini":
from remove_ai_watermarks.gemini_engine import GeminiEngine
_engines[key] = GeminiEngine()
elif key == "doubao":
from remove_ai_watermarks.doubao_engine import DoubaoEngine
_engines[key] = DoubaoEngine()
else: # pragma: no cover - guarded by the registry keys
raise KeyError(key)
return _engines[key]
def _gemini_detect(image: NDArray[Any]) -> MarkDetection:
d = _engine("gemini").detect_watermark(image)
detected = bool(d.detected) and d.confidence >= _GEMINI_AUTO_MIN_CONF
return MarkDetection("gemini", "Google Gemini sparkle", "bottom-right", detected, d.confidence, d.region)
def _gemini_remove(
image: NDArray[Any], inpaint_method: InpaintMethod, inpaint: bool, strength: float, force: bool
) -> tuple[NDArray[Any], Region | None]:
engine = _engine("gemini")
det = engine.detect_watermark(image)
if not det.detected:
if not force:
return image.copy(), None
# Forced (--no-detect): remove at the default sparkle slot for the size.
from remove_ai_watermarks.gemini_engine import get_watermark_config
h, w = image.shape[:2]
cfg = get_watermark_config(w, h)
px, py = cfg.get_position(w, h)
region = (px, py, cfg.logo_size, cfg.logo_size)
result = engine.remove_watermark_custom(image, region)
if inpaint:
result = engine.inpaint_residual(result, region, strength=strength, method=inpaint_method)
return result, region
result = engine.remove_watermark(image)
# Reverse-alpha leaves a faint residual at the sparkle edge; the engine's
# own residual inpaint cleans that seam (part of its reverse-alpha pipeline).
if inpaint:
result = engine.inpaint_residual(result, det.region, strength=strength, method=inpaint_method)
return result, det.region
def _doubao_detect(image: NDArray[Any]) -> MarkDetection:
d = _engine("doubao").detect(image)
return MarkDetection("doubao", "Doubao 豆包AI生成 text", "bottom-right", d.detected, d.confidence, d.region)
def _doubao_remove(
image: NDArray[Any], _inpaint_method: InpaintMethod, _inpaint: bool, _strength: float, force: bool
) -> tuple[NDArray[Any], Region | None]:
# Reverse-alpha only: apply when the mark is present AND the resolution is in
# the alpha map's calibrated band. Outside it we do NOT inpaint (no
# hallucination) -- removal is skipped until a capture for that resolution.
engine = _engine("doubao")
det = engine.detect(image)
if (det.detected or force) and engine.reverse_alpha_available(image):
return engine.remove_watermark_reverse_alpha(image), (det.region if det.detected else None)
return image.copy(), None
_REGISTRY: tuple[KnownMark, ...] = (
KnownMark("gemini", "Google Gemini sparkle", "bottom-right", True, "reverse-alpha", _gemini_detect, _gemini_remove),
KnownMark(
"doubao", "Doubao 豆包AI生成 text", "bottom-right", True, "reverse-alpha", _doubao_detect, _doubao_remove
),
)
def known_marks() -> tuple[KnownMark, ...]:
"""All registered known visible watermarks."""
return _REGISTRY
def mark_keys() -> list[str]:
"""Keys of all registered marks (for CLI choices)."""
return [m.key for m in _REGISTRY]
def get_mark(key: str) -> KnownMark:
"""Look up a known mark by key (raises KeyError if unknown)."""
for m in _REGISTRY:
if m.key == key:
return m
raise KeyError(key)
def detect_marks(image: NDArray[Any], *, include_explicit: bool = True) -> list[MarkDetection]:
"""Detect every known mark in its usual place.
Returns one MarkDetection per scanned mark (``detected`` flags which fired).
``include_explicit=False`` scans only the ``in_auto`` marks -- the set used
by ``--mark auto``.
"""
return [m.detect(image) for m in _REGISTRY if include_explicit or m.in_auto]
def best_auto_mark(image: NDArray[Any]) -> MarkDetection | None:
"""The highest-confidence detected ``in_auto`` mark, or None if none fired."""
fired = [d for d in detect_marks(image, include_explicit=False) if d.detected]
return max(fired, key=lambda d: d.confidence) if fired else None