mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-07 14:38:35 +02:00
Merge branch 'claude/modest-carson-d72243': corpus-mining provenance + removal fixes
Retained-corpus mining (2026-06-20) fixes, all gate-green: - C2PA vendor coverage (Volcano Engine CJK legal name, ElevenLabs; TikTok/PixelBin vetted out) - identify AI-generated vs AI-enhanced (ai_source_kind) + shared GEMINI_SPARKLE_TRUST_CONF (detect/remove threshold unify) - text-mark over-subtraction guard (Doubao/Jimeng/Samsung) - region-targeted regeneration for AI-enhanced composites (feather_region_composite + remove_watermark(region=)) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> # Conflicts: # CLAUDE.md
This commit is contained in:
@@ -37,6 +37,28 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Reverse-alpha over-subtraction guard (ported from gemini_engine, 2026-06-20).
|
||||
# The reverse-alpha blend ``(wm - a*logo)/(1-a)`` over-subtracts when the captured
|
||||
# alpha over-estimates THIS image's mark opacity: on a dark or mid-tone background
|
||||
# it drives the glyph footprint into a visibly DARKER-than-background ghost (a
|
||||
# "dark pit") instead of recovering the true pixels. The retained-corpus mining
|
||||
# (2026-06-20) showed the sparkle-only fix (commit 41f6797) left this unhandled
|
||||
# for the Doubao/Jimeng text marks. Mirror the sparkle gate: when the recovered
|
||||
# glyph body lands more than this many gray levels below the local background
|
||||
# ring, abandon the reverse-alpha output for the footprint and inpaint it from
|
||||
# the surroundings instead. Calibrated to the same 25-level margin the sparkle
|
||||
# gate uses -- clean text-mark removals recover within ~10 of the ring, the dark
|
||||
# pit lands tens of levels below.
|
||||
_OVERSUB_DARK_MARGIN = 25.0
|
||||
# Glyph-body / background-ring sampling for the guard. The ring is a pad around
|
||||
# the glyph box (excluding the box); the body is the bright-core glyph pixels.
|
||||
_OVERSUB_RING_PAD_FRAC = 0.6 # ring pad as a fraction of the glyph-box height
|
||||
_OVERSUB_BODY_ALPHA_FLOOR = 0.15 # alpha above which a block pixel counts as glyph body
|
||||
# Footprint inpaint when the guard trips: dilate the glyph mask wider than the
|
||||
# thin residual pass so the whole darkened ghost is reconstructed, not just its edge.
|
||||
_OVERSUB_INPAINT_DILATE = 9
|
||||
_OVERSUB_INPAINT_RADIUS = 4
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TextMarkConfig:
|
||||
@@ -335,6 +357,74 @@ class TextMarkEngine:
|
||||
out[y1:y2, x1:x2] = np.clip((roi - a3 * logo) / np.clip(1.0 - a3, 0.25, 1.0), 0, 255).astype(np.uint8)
|
||||
return out
|
||||
|
||||
def _reverse_alpha_oversubtracts(
|
||||
self, image: NDArray[Any], amap: NDArray[Any], region: tuple[int, int, int, int]
|
||||
) -> bool:
|
||||
"""True when reverse-alpha would darken the glyph footprint into a dark pit.
|
||||
|
||||
Ported from ``gemini_engine._reverse_alpha_oversubtracts`` (2026-06-20):
|
||||
PREDICT the reverse-alpha output at the bright glyph core directly from the
|
||||
INPUT and the captured alpha, ``(core_obs - a*logo)/(1-a)``, and trip when it
|
||||
lands more than ``_OVERSUB_DARK_MARGIN`` gray levels below the local
|
||||
background ring. Predicting from the input (not the produced output) keeps the
|
||||
gate independent of which placement the reverse-alpha picked, so a clean
|
||||
full-strength mark (whose strokes predict back to the background) never trips,
|
||||
while a mark fainter than the capture (over-subtracted into a ghost) does.
|
||||
"""
|
||||
ax, ay, gw, gh = region
|
||||
ih, iw = image.shape[:2]
|
||||
if gw < 4 or gh < 4:
|
||||
return False
|
||||
if float(amap.max()) < 0.2: # too faint a capture to over-subtract meaningfully
|
||||
return False
|
||||
body_box = amap >= _OVERSUB_BODY_ALPHA_FLOOR # glyph strokes
|
||||
if not bool(body_box.any()):
|
||||
return False
|
||||
pad = max(4, int(gh * _OVERSUB_RING_PAD_FRAC))
|
||||
ry1, ry2 = max(0, ay - pad), min(ih, ay + gh + pad)
|
||||
rx1, rx2 = max(0, ax - pad), min(iw, ax + gw + pad)
|
||||
ring = image[ry1:ry2, rx1:rx2].astype(np.float32).mean(axis=2)
|
||||
fy1, fy2, fx1, fx2 = ay - ry1, ay - ry1 + gh, ax - rx1, ax - rx1 + gw
|
||||
ring_mask = np.ones(ring.shape, dtype=bool)
|
||||
ring_mask[fy1:fy2, fx1:fx2] = False
|
||||
if int(ring_mask.sum()) < 10:
|
||||
return False
|
||||
# Predict the reverse-alpha output PER PIXEL over the glyph body -- exactly
|
||||
# the (obs - a*logo)/(1-a) math the remover applies -- so a cleanly captured
|
||||
# mark predicts back to the true background everywhere (no trip), while a mark
|
||||
# fainter than the capture predicts a body far below the local ring. The
|
||||
# per-pixel alpha (not a single peak value) keeps the prediction faithful
|
||||
# across the glyph's anti-aliased alpha gradient.
|
||||
obs = ring[fy1:fy2, fx1:fx2]
|
||||
a = np.clip(amap, 0.0, 0.99)
|
||||
logo = float(np.mean(self.config.alpha_logo_bgr))
|
||||
predicted = (obs - a * logo) / (1.0 - a)
|
||||
predicted_core = float(np.median(predicted[body_box]))
|
||||
bg = float(np.median(ring[ring_mask]))
|
||||
oversub = predicted_core < bg - _OVERSUB_DARK_MARGIN
|
||||
if oversub:
|
||||
logger.debug(
|
||||
"%s reverse-alpha over-subtracts: predicted core=%.1f bg=%.1f (margin %.0f) -> footprint inpaint",
|
||||
self.config.name,
|
||||
predicted_core,
|
||||
bg,
|
||||
_OVERSUB_DARK_MARGIN,
|
||||
)
|
||||
return oversub
|
||||
|
||||
def _inpaint_footprint(
|
||||
self, image: NDArray[Any], amap: NDArray[Any], region: tuple[int, int, int, int]
|
||||
) -> NDArray[Any]:
|
||||
"""Reconstruct the glyph footprint from its surroundings (used when
|
||||
reverse-alpha would over-subtract into a dark pit). Inpaints the ORIGINAL
|
||||
image over a dilated glyph mask, so the result never contains the darkened
|
||||
reverse-alpha pixels."""
|
||||
ax, ay, gw, gh = region
|
||||
mask = np.zeros(image.shape[:2], np.uint8)
|
||||
mask[ay : ay + gh, ax : ax + gw] = (amap > self.config.residual_alpha_floor).astype(np.uint8) * 255
|
||||
mask = cv2.dilate(mask, np.ones((_OVERSUB_INPAINT_DILATE, _OVERSUB_INPAINT_DILATE), np.uint8))
|
||||
return cv2.inpaint(image, mask, _OVERSUB_INPAINT_RADIUS, cv2.INPAINT_NS)
|
||||
|
||||
def remove_watermark_reverse_alpha(self, image: NDArray[Any], *, residual_inpaint: bool = True) -> NDArray[Any]:
|
||||
"""Recover the original pixels by inverting the alpha blend, then clear the
|
||||
residual outline with a thin inpaint over the glyph footprint.
|
||||
@@ -370,6 +460,13 @@ class TextMarkEngine:
|
||||
best_residual, best_out, best_amap, best_region = residual, out, amap, region
|
||||
if best_out is None or best_amap is None or best_region is None: # pragma: no cover - maps is non-empty
|
||||
return image.copy()
|
||||
# Over-subtraction guard: on a dark/mid-tone background the captured alpha can
|
||||
# over-estimate the mark's opacity and reverse-alpha leaves a darker-than-
|
||||
# background ghost. When the recovered glyph body sits far below the local
|
||||
# ring, reconstruct the footprint from its surroundings instead of shipping the
|
||||
# dark pit (the thin residual inpaint cannot fix a footprint-wide darkening).
|
||||
if self._reverse_alpha_oversubtracts(image, best_amap, best_region):
|
||||
return self._inpaint_footprint(image, best_amap, best_region)
|
||||
if residual_inpaint:
|
||||
# Embed the glyph-sized alpha block into a full-frame uint8 mask only for
|
||||
# the inpaint (cv2.inpaint needs a mask matching best_out). One uint8
|
||||
|
||||
@@ -881,6 +881,13 @@ def cmd_identify(ctx: click.Context, source: Path, no_visible: bool, as_json: bo
|
||||
|
||||
_banner()
|
||||
verdict = {True: "AI-generated", False: "not AI", None: "unknown"}[report.is_ai_generated]
|
||||
# Sharpen the True verdict when the C2PA source type says the image is a real
|
||||
# photo with an AI-composited region rather than a full AI generation, so the
|
||||
# caller (and the user) can tell "scrub the whole frame" from "scrub the AI region".
|
||||
if report.is_ai_generated and report.ai_source_kind == "enhanced":
|
||||
verdict = "AI-enhanced (real content with an AI-composited region)"
|
||||
elif report.is_ai_generated and report.ai_source_kind == "generated":
|
||||
verdict = "AI-generated (fully synthetic)"
|
||||
console.print(f"\n Verdict: {verdict} (confidence: {report.confidence})")
|
||||
console.print(f" Platform: {report.platform or 'undetermined'}")
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ from remove_ai_watermarks.metadata import (
|
||||
)
|
||||
from remove_ai_watermarks.noai.c2pa import cbor_text_after, extract_c2pa_info, soft_binding_vendors_in
|
||||
from remove_ai_watermarks.noai.constants import C2PA_AI_TOOLS, C2PA_AI_VENDORS, C2PA_ISSUERS
|
||||
from remove_ai_watermarks.watermark_registry import GEMINI_SPARKLE_TRUST_CONF
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
@@ -57,11 +58,14 @@ log = logging.getLogger(__name__)
|
||||
_SCAN_BYTES = 1024 * 1024
|
||||
|
||||
# Visible-sparkle confidence above which the signal is trusted as provenance.
|
||||
# Stricter than the removal default (0.25): on the corpus, Gemini-family
|
||||
# sparkles score >= 0.56 while non-sparkle images top out at 0.49, so 0.5
|
||||
# cleanly separates them and avoids false positives when sparkle is the only
|
||||
# signal (e.g. an OpenAI image scored 0.37 -- below threshold, correctly dropped).
|
||||
_SPARKLE_THRESHOLD = 0.5
|
||||
# Shared with the removal arbitration (watermark_registry.GEMINI_SPARKLE_TRUST_CONF)
|
||||
# so the provenance "is there a sparkle" verdict and the removal "take the sparkle"
|
||||
# decision can never drift apart -- the detect-vs-remove desync the retained-corpus
|
||||
# mining surfaced (2026-06-20). On the corpus Gemini-family sparkles score >= 0.56
|
||||
# while non-sparkle images top out at 0.49, so 0.5 cleanly separates them and avoids
|
||||
# false positives when the sparkle is the only signal (e.g. an OpenAI image scored
|
||||
# 0.37 -- below threshold, correctly dropped).
|
||||
_SPARKLE_THRESHOLD = GEMINI_SPARKLE_TRUST_CONF
|
||||
|
||||
# Issuer (C2PA signer) -> human-readable generating platform, derived from the
|
||||
# single C2PA_AI_VENDORS registry. Ordered: when a manifest names several issuers
|
||||
@@ -132,6 +136,14 @@ class ProvenanceReport:
|
||||
is_ai_generated: bool | None # True / False is never asserted; None = unknown
|
||||
platform: str | None
|
||||
confidence: str # "high" | "medium" | "none"
|
||||
# Coarse AI-origin kind from the C2PA digital-source-type, so a caller can
|
||||
# branch on full generation vs an AI-touched real photo:
|
||||
# "generated" -- digitalSourceType trainedAlgorithmicMedia (fully AI).
|
||||
# "enhanced" -- compositeWithTrainedAlgorithmicMedia (real content with an
|
||||
# AI-composited region; scrub the AI region, keep the photo).
|
||||
# None -- no C2PA AI source-type (verdict, if AI, came from another
|
||||
# signal: IPTC, AIGC, local gen params, xAI, ...).
|
||||
ai_source_kind: str | None = None
|
||||
watermarks: list[str] = field(default_factory=list[str])
|
||||
signals: list[Signal] = field(default_factory=list["Signal"])
|
||||
caveats: list[str] = field(default_factory=list[str])
|
||||
@@ -484,9 +496,18 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
|
||||
# ── C2PA Content Credentials ────────────────────────────────────
|
||||
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")
|
||||
)
|
||||
# Full AI generation (trainedAlgorithmicMedia) vs an AI-enhanced real photo
|
||||
# (compositeWithTrainedAlgorithmicMedia). The structured kind is parsed once in
|
||||
# noai.c2pa._populate_registry_fields (covers PNG + any container the c2pa-python
|
||||
# reader handles); fall back to a raw head scan for the non-PNG raw-blob path
|
||||
# where extract_c2pa_info returns {}. Full generation wins when both appear.
|
||||
c2pa_source_kind = info.get("ai_source_kind")
|
||||
if c2pa_source_kind is None:
|
||||
if b"trainedAlgorithmicMedia" in head:
|
||||
c2pa_source_kind = "generated"
|
||||
elif b"compositeWithTrainedAlgorithmicMedia" in head:
|
||||
c2pa_source_kind = "enhanced"
|
||||
c2pa_is_ai = c2pa_source_kind is not None
|
||||
# Generator string (for the signal detail): structured for PNG, CBOR-scanned
|
||||
# for other containers. Best-effort -- some manifests key it as
|
||||
# `claim_generator_info` (Pixel), so this can be None even when a device is
|
||||
@@ -734,6 +755,9 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
|
||||
is_ai_generated=is_ai,
|
||||
platform=platform,
|
||||
confidence=confidence,
|
||||
# Only meaningful when the AI verdict actually came from the C2PA source
|
||||
# type; a non-C2PA AI signal (IPTC/AIGC/local gen) leaves it None.
|
||||
ai_source_kind=c2pa_source_kind if (is_ai and has_c2pa) else None,
|
||||
watermarks=watermarks,
|
||||
signals=signals,
|
||||
caveats=caveats,
|
||||
|
||||
@@ -363,14 +363,22 @@ def _populate_registry_fields(buf: bytes, c2pa_info: dict[str, Any]) -> bool:
|
||||
# Digital source type (matched anywhere in the store, including ingredient
|
||||
# manifests -- a ChatGPT edit of a Sora generation carries the AI marker on
|
||||
# the parent, not the active manifest).
|
||||
# ``ai_source_kind`` is the structured generated-vs-enhanced split the caller
|
||||
# branches on (full-frame scrub vs region-targeted clean); ``source_type`` is the
|
||||
# human-readable form. The two byte strings are unambiguous:
|
||||
# "compositeWithTrainedAlgorithmicMedia" capitalizes the inner "Trained", so a
|
||||
# lowercase "trainedAlgorithmicMedia" match is standalone full generation, which
|
||||
# wins when both appear (an edit chain).
|
||||
ai_source = False
|
||||
if b"trainedAlgorithmicMedia" in buf:
|
||||
c2pa_info["source_type"] = "trainedAlgorithmicMedia (AI-generated)"
|
||||
c2pa_info["ai_source_kind"] = "generated"
|
||||
ai_source = True
|
||||
elif b"algorithmicMedia" in buf:
|
||||
c2pa_info["source_type"] = "algorithmicMedia"
|
||||
elif b"compositeWithTrainedAlgorithmicMedia" in buf:
|
||||
c2pa_info["source_type"] = "compositeWithTrainedAlgorithmicMedia (AI-enhanced)"
|
||||
c2pa_info["ai_source_kind"] = "enhanced"
|
||||
ai_source = True
|
||||
|
||||
# SynthID pixel-watermark proxy: a C2PA manifest from a SynthID-using
|
||||
|
||||
@@ -122,6 +122,20 @@ C2PA_AI_VENDORS: tuple[C2paAiVendor, ...] = (
|
||||
C2paAiVendor(
|
||||
b"volcengine", "ByteDance (Volcano Engine)", "ByteDance (Doubao / Jimeng / Volcano Engine)", "ByteDance"
|
||||
),
|
||||
# Some Volcano Engine certs name the signer with the Chinese legal entity
|
||||
# "北京火山引擎科技有限公司" (Beijing Volcano Engine Technology Co., Ltd.) rather
|
||||
# than the latin "volcengine" -- the latin needle misses it entirely, so real
|
||||
# ByteDance output was un-attributed in production traffic. The issuer is the
|
||||
# UTF-8 of the Chinese name (it appears UTF-8-encoded in the manifest-store
|
||||
# JSON and the raw caBX bytes alike); it normalizes to the same "ByteDance"
|
||||
# needle and platform as the volcengine row, so the two collapse together for
|
||||
# clash detection. Verified against the mined retained corpus, 2026-06-20.
|
||||
C2paAiVendor(
|
||||
"北京火山引擎科技有限公司".encode(),
|
||||
"ByteDance (Volcano Engine)",
|
||||
"ByteDance (Doubao / Jimeng / Volcano Engine)",
|
||||
"ByteDance",
|
||||
),
|
||||
# ByteDance's international brand (BytePlus / Seedream / Seededit) signs its
|
||||
# cert as "Byteplus Pte. Ltd." -- the bare ``volcengine`` needle misses it, so
|
||||
# real BytePlus AI output was mis-attributed (an incidental "Adobe XMP" string
|
||||
@@ -136,11 +150,29 @@ C2PA_AI_VENDORS: tuple[C2paAiVendor, ...] = (
|
||||
# source read AI but no platform was attributed. Verified on real signed files
|
||||
# in production traffic, 2026-06-19. Canva does not use SynthID.
|
||||
C2paAiVendor(b"Canva", "Canva", "Canva (Magic Media)", "Canva"),
|
||||
# ElevenLabs is a pure generative-AI company (AI voice / audio, and image /
|
||||
# video via its API); it signs output as "Eleven Labs Inc.", so the C2PA
|
||||
# manifest alone marks AI generation. Verified against the mined retained
|
||||
# corpus, 2026-06-20. ElevenLabs does not use SynthID.
|
||||
C2paAiVendor(b"Eleven Labs", "ElevenLabs", "ElevenLabs", "ElevenLabs"),
|
||||
# Truepic is a C2PA signing authority, not an AI generator: no platform label,
|
||||
# never asserts is_ai (the verdict comes from the digital-source-type).
|
||||
C2paAiVendor(b"Truepic", "Truepic", None, None),
|
||||
)
|
||||
|
||||
# Deliberately NOT registered as AI-generation vendors (mined-corpus candidates
|
||||
# evaluated 2026-06-20):
|
||||
# - TikTok Inc.: signs C2PA as a content-provenance / AI-labeling authority on
|
||||
# uploads, not as an image generator. The is_ai verdict keys off the
|
||||
# digitalSourceType (trainedAlgorithmicMedia), which is already honored; a
|
||||
# bare TikTok signer marks distribution provenance, not generation, so adding
|
||||
# it as a generator needle would mis-label human uploads as AI.
|
||||
# - PixelBin.io (issuer "Fynd"): an image transformation / optimization / CDN
|
||||
# service. Its C2PA stamps a transform/upload step, not a generation event.
|
||||
# Both are excluded to avoid false-positive AI attribution; re-evaluate only
|
||||
# against a real signed file whose manifest carries a trainedAlgorithmicMedia
|
||||
# digital-source type produced by the vendor itself.
|
||||
|
||||
# Derived view -- add a vendor to C2PA_AI_VENDORS above, not here.
|
||||
# C2PA issuer signature -> resolved org name, for the manifest byte-scan.
|
||||
C2PA_ISSUERS: dict[bytes, str] = {v.issuer: v.org for v in C2PA_AI_VENDORS}
|
||||
|
||||
@@ -100,6 +100,59 @@ def feather_weights(width: int, height: int, overlap: int) -> NDArray[Any]:
|
||||
return weights
|
||||
|
||||
|
||||
def feather_region_composite(
|
||||
base: NDArray[Any],
|
||||
regenerated: NDArray[Any],
|
||||
box: tuple[int, int, int, int],
|
||||
*,
|
||||
feather: int = 64,
|
||||
) -> NDArray[Any]:
|
||||
"""Composite ``regenerated`` over ``base`` inside ``box`` only, feathering the seam.
|
||||
|
||||
For AI-ENHANCED composites (digitalSourceType ``compositeWithTrainedAlgorithmicMedia``):
|
||||
the diffusion remover regenerates the whole frame, but only the AI-composited
|
||||
REGION should change -- the rest is a real photo that must be preserved. This
|
||||
blends the regenerated pixels in over ``box = (x, y, w, h)`` with a separable
|
||||
linear taper of ``feather`` px at the box edges, so the result equals ``base``
|
||||
EXACTLY outside the box and ramps smoothly (no hard seam) at the boundary.
|
||||
|
||||
Pure and model-free (unit-tested): ``base`` and ``regenerated`` must be the same
|
||||
shape (H x W, or H x W x C). The output preserves ``base``'s dtype. ``feather`` is
|
||||
clamped to half the box on each axis, so a small region still tapers symmetrically;
|
||||
``feather=0`` is a hard-edged paste.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
if base.shape != regenerated.shape:
|
||||
raise ValueError(f"shape mismatch: base {base.shape} vs regenerated {regenerated.shape}")
|
||||
h, w = base.shape[:2]
|
||||
x, y, bw, bh = box
|
||||
x0, y0 = max(0, x), max(0, y)
|
||||
x1, y1 = min(w, x + bw), min(h, y + bh)
|
||||
out = base.copy()
|
||||
if x1 <= x0 or y1 <= y0:
|
||||
return out # empty / off-image box -> nothing regenerated
|
||||
|
||||
def taper(n: int) -> NDArray[Any]:
|
||||
win = np.ones(n, dtype=np.float32)
|
||||
f = min(max(feather, 0), n // 2)
|
||||
if f > 0:
|
||||
ramp = (np.arange(f, dtype=np.float32) + 1.0) / (f + 1.0) # in (0, 1), 0 at the edge
|
||||
win[:f] = ramp
|
||||
win[n - f :] = ramp[::-1]
|
||||
return win
|
||||
|
||||
rh, rw = y1 - y0, x1 - x0
|
||||
wmap = np.outer(taper(rh), taper(rw)) # ~0 at the box edge, 1 in the interior
|
||||
if base.ndim == 3:
|
||||
wmap = wmap[:, :, None]
|
||||
roi_base = base[y0:y1, x0:x1].astype(np.float32)
|
||||
roi_gen = regenerated[y0:y1, x0:x1].astype(np.float32)
|
||||
blended = roi_base * (1.0 - wmap) + roi_gen * wmap
|
||||
out[y0:y1, x0:x1] = np.clip(blended, 0, 255).astype(base.dtype)
|
||||
return out
|
||||
|
||||
|
||||
def run_tiled(
|
||||
generate_tile: Callable[[PILImage.Image], PILImage.Image],
|
||||
image: PILImage.Image,
|
||||
|
||||
@@ -566,6 +566,8 @@ class WatermarkRemover:
|
||||
tile: bool = False,
|
||||
tile_size: int = 1024,
|
||||
tile_overlap: int = 128,
|
||||
region: tuple[int, int, int, int] | None = None,
|
||||
region_feather: int = 64,
|
||||
) -> Path:
|
||||
"""Remove watermark from an image using regeneration attack.
|
||||
|
||||
@@ -589,6 +591,15 @@ class WatermarkRemover:
|
||||
tile_size: Tile dimension in px (default 1024, SDXL's training size).
|
||||
tile_overlap: Overlap between adjacent tiles in px (default 128), feather-
|
||||
blended so there is no visible seam.
|
||||
region: Restrict the regeneration to the AI-composited box ``(x, y, w, h)``
|
||||
and feather-composite it back over the ORIGINAL pixels everywhere else.
|
||||
For AI-ENHANCED composites (digitalSourceType
|
||||
``compositeWithTrainedAlgorithmicMedia``, surfaced as
|
||||
``identify.ProvenanceReport.ai_source_kind == "enhanced"``): the real
|
||||
photo outside the box is preserved exactly, only the AI region is
|
||||
scrubbed. The box is supplied by the caller (a C2PA composite manifest
|
||||
does not carry a reliable machine-readable region). None -> whole frame.
|
||||
region_feather: Seam taper in px for ``region`` compositing (default 64).
|
||||
|
||||
Returns:
|
||||
Path to the cleaned image.
|
||||
@@ -660,6 +671,22 @@ class WatermarkRemover:
|
||||
self._controlnet_pipeline = None
|
||||
cleaned_image = _generate()
|
||||
|
||||
# Region-targeted regeneration for AI-enhanced composites: keep the real photo
|
||||
# outside the AI box pixel-exact, blend only the regenerated AI region back in.
|
||||
if region is not None:
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks.noai.tiling import feather_region_composite
|
||||
|
||||
gen = cleaned_image.convert("RGB")
|
||||
if gen.size != init_image.size: # a downscaled/tiled pass can resize
|
||||
gen = gen.resize(init_image.size)
|
||||
cleaned_image = gen
|
||||
base_rgb = np.asarray(init_image) # original RGB, untouched outside the box
|
||||
merged = feather_region_composite(base_rgb, np.asarray(gen), region, feather=region_feather)
|
||||
cleaned_image = Image.fromarray(merged)
|
||||
self._set_progress(f"Region-targeted regeneration: AI box {region}, real photo preserved")
|
||||
|
||||
self._set_progress(f"Regeneration complete · Output: {w}x{h}px {cleaned_image.mode}")
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -877,12 +904,17 @@ def remove_watermark(
|
||||
model_id: str | None = None,
|
||||
device: str | None = None,
|
||||
hf_token: str | None = None,
|
||||
region: tuple[int, int, int, int] | None = None,
|
||||
) -> Path:
|
||||
"""Convenience function to remove watermark from an image.
|
||||
|
||||
``strength=None`` lets the profile pick its vendor-adaptive default
|
||||
(0.20 OpenAI / 0.30 Google / 0.30 unknown, from the C2PA SynthID proxy on the
|
||||
input; same ladder for the controlnet and sdxl pipelines). Pass a value to override.
|
||||
|
||||
``region=(x, y, w, h)`` restricts the regeneration to that box and preserves the
|
||||
real photo elsewhere -- for AI-enhanced composites (see
|
||||
``WatermarkRemover.remove_watermark``).
|
||||
"""
|
||||
from remove_ai_watermarks.noai.watermark_profiles import vendor_for_strength
|
||||
|
||||
@@ -892,4 +924,5 @@ def remove_watermark(
|
||||
output_path=output_path,
|
||||
strength=strength,
|
||||
vendor=vendor_for_strength(image_path),
|
||||
region=region,
|
||||
)
|
||||
|
||||
@@ -90,13 +90,27 @@ class KnownMark:
|
||||
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
|
||||
# Single source of truth for the Gemini-sparkle "trust this as a real mark"
|
||||
# confidence, shared by BOTH the removal arbitration here (`best_auto_mark` /
|
||||
# `_gemini_detect`) and the provenance detector in `identify` (which imports it
|
||||
# as its sparkle threshold). Defining it once removes the detect-vs-remove
|
||||
# threshold drift the retained-corpus mining surfaced (2026-06-20): identify
|
||||
# would report a sparkle while removal declined it, or vice versa, whenever the
|
||||
# two independently-maintained 0.5 constants fell out of step. Now they cannot.
|
||||
#
|
||||
# Value 0.5 is corpus-validated: the gemini engine's own `detected` flag uses a
|
||||
# looser internal threshold (0.35) and weakly fires (~0.36-0.42) on unrelated
|
||||
# bottom-right text -- a real Doubao mark scores ~0.40-0.42 as a gemini match,
|
||||
# and its core-ring brightness margin is HIGHER than a genuine faint sparkle's,
|
||||
# so neither confidence nor the brightness gate separates them in the [0.35, 0.5)
|
||||
# band. Lowering this gate to recover faint sparkles was evaluated against that
|
||||
# band (2026-06-20) and REJECTED: it cannot be done without re-admitting the
|
||||
# Doubao-text / content false positives, trading a rare miss for false-positive
|
||||
# removals on clean images. The band below the gate is therefore intentionally
|
||||
# left to the higher-strength / metadata paths. 0.5 gives 0 false positives on
|
||||
# the corpus.
|
||||
GEMINI_SPARKLE_TRUST_CONF = 0.5
|
||||
_GEMINI_AUTO_MIN_CONF = GEMINI_SPARKLE_TRUST_CONF
|
||||
|
||||
# ── Engine adapters (lazy singletons; engines are cv2-only, no model load) ──
|
||||
|
||||
|
||||
Reference in New Issue
Block a user