mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-09-01 18:20:35 +02:00
Merge remote-tracking branch 'origin/main' into docs/arxiv-paper-review
# Conflicts: # docs/installation.md # docs/supported-signals.md # docs/verification-plan.md
This commit is contained in:
@@ -33,7 +33,7 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
|
||||
_warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*")
|
||||
|
||||
|
||||
__version__ = "0.31.1"
|
||||
__version__ = "0.34.1"
|
||||
|
||||
__all__ = [
|
||||
"BatchSummary",
|
||||
|
||||
@@ -426,6 +426,11 @@ def _structured_manifest_fields(store: dict[str, Any]) -> dict[str, Any]:
|
||||
source_types: list[str] = []
|
||||
soft_binding_algorithms: list[str] = []
|
||||
soft_binding_values: list[str] = []
|
||||
# Raw signer/generator identity strings, used to scope SynthID evidence to
|
||||
# the vendor that actually asserted the manifest. A vendor token appearing
|
||||
# anywhere else in the chain (e.g. Microsoft Designer's "Azure OpenAI
|
||||
# ImageGen" softwareAgent) is a service name, not that vendor's provenance.
|
||||
identity_strings: list[str] = []
|
||||
claim_generator_asserts_ai = False
|
||||
|
||||
def add_tool_matches(value: str, *, asserts_ai: bool = False) -> None:
|
||||
@@ -443,9 +448,11 @@ def _structured_manifest_fields(store: dict[str, Any]) -> dict[str, Any]:
|
||||
value = signature.get(key)
|
||||
if isinstance(value, str):
|
||||
issuers.extend(_ordered_matches(value.encode(), C2PA_ISSUERS))
|
||||
identity_strings.append(value)
|
||||
|
||||
direct_generator = manifest.get("claim_generator")
|
||||
if isinstance(direct_generator, str):
|
||||
identity_strings.append(direct_generator)
|
||||
add_tool_matches(direct_generator, asserts_ai=True)
|
||||
|
||||
candidates = manifest.get("claim_generator_info")
|
||||
@@ -456,6 +463,7 @@ def _structured_manifest_fields(store: dict[str, Any]) -> dict[str, Any]:
|
||||
name = cast("dict[object, object]", candidate_value).get("name")
|
||||
if isinstance(name, str):
|
||||
add_tool_matches(name, asserts_ai=True)
|
||||
identity_strings.append(name)
|
||||
|
||||
assertions = manifest.get("assertions")
|
||||
if not isinstance(assertions, list):
|
||||
@@ -528,9 +536,14 @@ def _structured_manifest_fields(store: dict[str, Any]) -> dict[str, Any]:
|
||||
has_watermark_action = any(action.startswith("watermarked") for action in actions)
|
||||
if has_watermark_action:
|
||||
info["watermarked"] = True
|
||||
if info.get("ai_source_kind"):
|
||||
selected_bytes = json.dumps(chain, ensure_ascii=False).encode()
|
||||
synthid = synthid_evidence_vendors_in(selected_bytes, has_watermark_action=has_watermark_action)
|
||||
if info.get("ai_source_kind") and not soft_binding_algorithms:
|
||||
# Evidence scope: only the signer/generator identity strings above, never
|
||||
# the whole chain - a vendor named inside another vendor's manifest (the
|
||||
# Designer case) must not turn into that vendor's SynthID provenance. A
|
||||
# manifest that names its own forensic soft-binding algorithm carries
|
||||
# that vendor's mark and is excluded from the generic inference entirely.
|
||||
identity_bytes = json.dumps(identity_strings, ensure_ascii=False).encode()
|
||||
synthid = synthid_evidence_vendors_in(identity_bytes, has_watermark_action=has_watermark_action)
|
||||
if synthid:
|
||||
info["synthid_vendors"] = synthid
|
||||
info["synthid_watermark"] = synthid_verdict(", ".join(synthid))
|
||||
@@ -611,12 +624,16 @@ def _populate_registry_fields(buffer: bytes, info: dict[str, Any]) -> bool:
|
||||
|
||||
if b"c2pa.watermarked" in buffer:
|
||||
info["watermarked"] = True
|
||||
synthid = synthid_evidence_vendors_in(buffer, has_watermark_action=info.get("watermarked", False))
|
||||
soft_bindings = soft_binding_vendors_in(buffer)
|
||||
synthid = (
|
||||
[]
|
||||
if soft_bindings
|
||||
else synthid_evidence_vendors_in(buffer, has_watermark_action=info.get("watermarked", False))
|
||||
)
|
||||
if ai_source and synthid:
|
||||
info["synthid_vendors"] = synthid
|
||||
info["synthid_watermark"] = synthid_verdict(", ".join(synthid))
|
||||
|
||||
soft_bindings = soft_binding_vendors_in(buffer)
|
||||
if soft_bindings:
|
||||
info["soft_binding_vendors"] = soft_bindings
|
||||
info["soft_binding"] = ", ".join(soft_bindings)
|
||||
|
||||
@@ -70,7 +70,7 @@ C2PA_AI_VENDORS: tuple[C2paAiVendor, ...] = (
|
||||
_vendor(
|
||||
b"OpenAI",
|
||||
"OpenAI",
|
||||
"OpenAI (ChatGPT / gpt-image / DALL-E / Sora)",
|
||||
"OpenAI (ChatGPT / GPT Image / DALL·E / Sora)",
|
||||
"OpenAI",
|
||||
synthid=True,
|
||||
synthid_requires_watermark_action=True,
|
||||
@@ -81,29 +81,32 @@ C2PA_AI_VENDORS: tuple[C2paAiVendor, ...] = (
|
||||
_vendor(
|
||||
b"volcengine",
|
||||
"ByteDance (Volcano Engine)",
|
||||
"ByteDance (Doubao / Jimeng / Dreamina / Volcano Engine)",
|
||||
"ByteDance",
|
||||
"ByteDance Volcano Engine",
|
||||
"Volcano Engine",
|
||||
),
|
||||
_vendor(
|
||||
"北京火山引擎科技有限公司",
|
||||
"ByteDance (Volcano Engine)",
|
||||
"ByteDance (Doubao / Jimeng / Dreamina / Volcano Engine)",
|
||||
"ByteDance",
|
||||
),
|
||||
_vendor(
|
||||
b"Byteplus", "BytePlus (ByteDance)", "ByteDance (Doubao / Jimeng / Dreamina / Volcano Engine)", "ByteDance"
|
||||
"ByteDance Volcano Engine",
|
||||
"Volcano Engine",
|
||||
),
|
||||
_vendor(b"Byteplus", "BytePlus (ByteDance)", "BytePlus (ByteDance)", "BytePlus"),
|
||||
_vendor(
|
||||
b"Dreamina",
|
||||
"ByteDance (Dreamina)",
|
||||
"ByteDance (Doubao / Jimeng / Dreamina / Volcano Engine)",
|
||||
"ByteDance",
|
||||
"ByteDance Dreamina",
|
||||
"Dreamina",
|
||||
asserts_ai=True,
|
||||
),
|
||||
_vendor(b"Canva", "Canva", "Canva (Magic Media)", "Canva"),
|
||||
_vendor(b"Eleven Labs", "ElevenLabs", "ElevenLabs", "ElevenLabs"),
|
||||
_vendor(b"fal-ai", "fal.ai", "fal.ai", "fal.ai", asserts_ai=True),
|
||||
_vendor(b"Bria", "Bria Artificial Intelligence", "Bria AI", "Bria", asserts_ai=True),
|
||||
# Ideogram signs its downloads' Content Credentials with "Ideogram, Inc"; the
|
||||
# issuer token is the org prefix (same substring-match class as "Bria" in
|
||||
# "Bria Artificial Intelligence"). Found as an unmapped signer on 4 corpus
|
||||
# uploads 2026-08-08 that identify reported as unknown-signer C2PA.
|
||||
_vendor(b"Ideogram", "Ideogram", "Ideogram", "Ideogram", asserts_ai=True),
|
||||
_vendor(b"Truepic", "Truepic", None, None),
|
||||
)
|
||||
|
||||
@@ -116,7 +119,7 @@ C2PA_IDENTITY_AI_ORGS = frozenset(vendor.org for vendor in C2PA_AI_VENDORS if ve
|
||||
C2PA_CLAIM_GENERATOR_PLATFORMS: tuple[tuple[str, str], ...] = (
|
||||
("adobe_firefly", "Adobe Firefly"),
|
||||
("firefly", "Adobe Firefly"),
|
||||
("dreamina", "ByteDance (Doubao / Jimeng / Dreamina / Volcano Engine)"),
|
||||
("dreamina", "ByteDance Dreamina"),
|
||||
("higgsfield ai", "Higgsfield AI"),
|
||||
("topaz labs image api", "Topaz Labs"),
|
||||
("tiktok ad creative toolbox", "TikTok Ad Creative Toolbox"),
|
||||
@@ -128,8 +131,8 @@ C2PA_AI_TOOLS = {
|
||||
("GPT-4o", "GPT-4o"),
|
||||
("ChatGPT", "ChatGPT"),
|
||||
("Sora", "Sora"),
|
||||
("DALL-E", "DALL-E"),
|
||||
("DALL", "DALL-E"),
|
||||
("DALL-E", "DALL·E"),
|
||||
("DALL", "DALL·E"),
|
||||
("Imagen", "Imagen"),
|
||||
("Firefly", "Firefly"),
|
||||
("Dreamina", "Dreamina"),
|
||||
@@ -138,16 +141,18 @@ C2PA_AI_TOOLS = {
|
||||
|
||||
C2PA_SOFT_BINDINGS = {
|
||||
b"com.adobe.trustmark": "Adobe TrustMark",
|
||||
b"com.adobe.icn": "Adobe (content fingerprint)",
|
||||
b"com.digimarc": "Digimarc",
|
||||
b"com.adobe.icn": "Adobe Image Comparator Network",
|
||||
b"com.digimarc": "Digimarc Validate",
|
||||
b"com.imatag.lamark": "Imatag (Lamark)",
|
||||
b"ai.steg": "Steg.AI",
|
||||
b"com.microsoft.invismark": "Microsoft InvisMark",
|
||||
b"com.microsoft.wavmark": "Microsoft WavMark",
|
||||
b"com.verimatrix": "Verimatrix",
|
||||
b"com.nagra.nexguard": "NAGRA NexGuard",
|
||||
b"com.aiwatermark": "AIWatermark (Meta PixelSeal)",
|
||||
b"ai.trufo": "Trufo",
|
||||
b"com.aiwatermark.pixelseal": "AIWatermark PixelSeal",
|
||||
b"com.aiwatermark.videoseal": "AIWatermark VideoSeal",
|
||||
b"com.aiwatermark.audioseal": "AIWatermark AudioSeal",
|
||||
b"ai.trufo": "Trufo PawPrint",
|
||||
b"app.overlai": "Overlai",
|
||||
b"com.markany": "MarkAny",
|
||||
b"com.mentaport": "Mentaport",
|
||||
@@ -199,7 +204,7 @@ C2PA_ACTIONS = {f"c2pa.{action}".encode(): action for action in _C2PA_ACTION_NAM
|
||||
# what stops a newly registered TC260 vendor from silently falling back to ByteDance.
|
||||
#
|
||||
# What a TC260 label confirms when its producer is absent or unmapped. Historical
|
||||
# behaviour, kept as the fallback so an unrecognized producer never regresses to no
|
||||
# behavior, kept as the fallback so an unrecognized producer never regresses to no
|
||||
# relaxation at all: ByteDance's two products are the ones the relaxed band was
|
||||
# calibrated on (see _text_mark_engine._DEFAULT_PROVENANCE_NCC_FACTOR).
|
||||
TC260_FALLBACK_VENDORS: frozenset[str] = frozenset({"doubao", "jimeng"})
|
||||
|
||||
@@ -1074,29 +1074,14 @@ class QwenZImagePipeline:
|
||||
fidelity_anchor: bool = False,
|
||||
) -> Image.Image:
|
||||
"""Execute global regeneration and masked face repair."""
|
||||
if text_manifest is not None and tile:
|
||||
raise ValueError("Verified text restoration is not calibrated with tiled diffusion")
|
||||
self._require_cuda()
|
||||
seed = resolve_seed(seed)
|
||||
donor = None
|
||||
if text_manifest is not None:
|
||||
self._progress("Reconstructing the verified text donor with the Qwen VAE...")
|
||||
if tile and max(image.size) > tile_size:
|
||||
from remove_ai_watermarks._internal.tiling import run_tiled
|
||||
|
||||
donor = run_tiled(
|
||||
self._qwen_vae_roundtrip,
|
||||
image,
|
||||
tile_size,
|
||||
tile_overlap,
|
||||
lambda message: self._progress(
|
||||
message.replace(
|
||||
"Tiled diffusion",
|
||||
"Reconstructing the verified text donor",
|
||||
1,
|
||||
)
|
||||
),
|
||||
)
|
||||
else:
|
||||
donor = self._qwen_vae_roundtrip(image)
|
||||
donor = self._qwen_vae_roundtrip(image)
|
||||
global_strength = (
|
||||
resolution_adaptive_denoise(image.width, image.height) if strength is None else float(strength)
|
||||
)
|
||||
|
||||
@@ -79,17 +79,34 @@ QWEN_ZIMAGE_GOOGLE_STRENGTH = 0.27
|
||||
# cross-source spread (0.00725) to the worst clean boundary: 0.0695 + 0.00725.
|
||||
QWEN_ZIMAGE_OPENAI_STRENGTH = 0.07675
|
||||
|
||||
# Microsoft's public detector returned Inconclusive rather than an API-level
|
||||
# watermark-negative verdict. Three valid Paint sources first cleared at 0.04125,
|
||||
# Microsoft's public detector (https://ai.azure.com/nextgen/validate) returned
|
||||
# Inconclusive rather than an API-level watermark-negative verdict. Three valid
|
||||
# Paint sources first cleared at 0.04125,
|
||||
# 0.055, and 0.095. Add one full observed cross-source spread to the worst clean
|
||||
# boundary: 0.095 + (0.095 - 0.04125) = 0.14875, rounded up to 0.15. This is a
|
||||
# measured corpus margin, not a universal InvisMark threshold.
|
||||
QWEN_ZIMAGE_MICROSOFT_STRENGTH = 0.15
|
||||
|
||||
# Meta Muse Image stamps every output with Content Seal, but no provenance signal
|
||||
# survives to route it: the outputs carry no C2PA, and their IPTC
|
||||
# trainedAlgorithmicMedia companion tag is a standard code many platforms use, so
|
||||
# it cannot key this cohort the way an issuer keys the others. The floor is
|
||||
# therefore selected by an explicit --vendor meta override, never by detection.
|
||||
# Derivation (oracle meta.ai/identification, 2026-08-26/27, corpus in
|
||||
# data/contentseal/): five independent 2.56 MP generations bracketed at
|
||||
# lighthouse (0.0525, 0.06], fox (0.03, 0.0375], night_city (0.03, 0.0375],
|
||||
# mug <= 0.03, text <= 0.015. Worst clean boundary plus one full observed
|
||||
# cross-source spread: 0.06 + (0.0525 - 0.015) = 0.0975, rounded up to 0.1.
|
||||
# sdxl-zimage has no measured Meta floor; its vendor map stays without a meta
|
||||
# entry so an explicit --vendor meta there falls to the unknown 0.25, which is
|
||||
# above this floor and therefore conservative.
|
||||
QWEN_ZIMAGE_META_STRENGTH = 0.1
|
||||
|
||||
_QWEN_ZIMAGE_FLAT_STRENGTH_BY_VENDOR: dict[str, float] = {
|
||||
"google": QWEN_ZIMAGE_GOOGLE_STRENGTH,
|
||||
"openai": QWEN_ZIMAGE_OPENAI_STRENGTH,
|
||||
"microsoft": QWEN_ZIMAGE_MICROSOFT_STRENGTH,
|
||||
"meta": QWEN_ZIMAGE_META_STRENGTH,
|
||||
}
|
||||
|
||||
|
||||
@@ -129,7 +146,8 @@ def strength_default_help() -> str:
|
||||
return (
|
||||
"profile-adaptive (qwen-zimage uses resolution-adaptive denoise, with a "
|
||||
f"flat OpenAI {QWEN_ZIMAGE_OPENAI_STRENGTH} / Google {QWEN_ZIMAGE_GOOGLE_STRENGTH} / "
|
||||
f"Microsoft InvisMark {QWEN_ZIMAGE_MICROSOFT_STRENGTH} floors; sdxl-zimage "
|
||||
f"Microsoft InvisMark {QWEN_ZIMAGE_MICROSOFT_STRENGTH} / Meta Content Seal "
|
||||
f"{QWEN_ZIMAGE_META_STRENGTH} floors; sdxl-zimage "
|
||||
f"uses OpenAI {SDXL_ZIMAGE_OPENAI_STRENGTH} / Google {SDXL_ZIMAGE_GEMINI_STRENGTH} / "
|
||||
f"unknown {SDXL_ZIMAGE_UNKNOWN_STRENGTH}, from the C2PA issuer)"
|
||||
)
|
||||
@@ -167,8 +185,18 @@ def resolve_strength(
|
||||
return resolution_adaptive_denoise(*size)
|
||||
|
||||
|
||||
def vendor_for_strength(image_path: Path) -> Literal["openai", "google", "microsoft"] | None:
|
||||
"""Select the strength cohort from non-invalid pixel-watermark provenance."""
|
||||
def vendor_for_strength(image_path: Path) -> Literal["openai", "google", "microsoft", "meta"] | None:
|
||||
"""Select the strength cohort from non-invalid pixel-watermark provenance.
|
||||
|
||||
OpenAI / Google / Microsoft come from their C2PA issuers. Meta is the
|
||||
fallback cohort: Muse Image carries no C2PA at all, and its only readable
|
||||
companion is the IPTC ``trainedAlgorithmicMedia`` XMP tag -- a standard code
|
||||
other platforms also use. Attributing that tag to Meta is a measured bet,
|
||||
not an identification: the other tag users in this project's model (ByteDance
|
||||
products, X) ship no invisible pixel watermark this profile targets, so the
|
||||
worst misroute spends the Meta floor (0.1) where the resolution curve would
|
||||
have spent a similar amount, and Google/OpenAI files never reach this arm
|
||||
because their C2PA matched first."""
|
||||
try:
|
||||
from remove_ai_watermarks._internal.c2pa import (
|
||||
c2pa_info_has_invalid_credential,
|
||||
@@ -187,4 +215,25 @@ def vendor_for_strength(image_path: Path) -> Literal["openai", "google", "micros
|
||||
return "openai"
|
||||
if not c2pa_info_has_invalid_credential(info) and c2pa_info_has_invismark(info):
|
||||
return "microsoft"
|
||||
if _standalone_iptc_ai_tag(image_path):
|
||||
return "meta"
|
||||
return None
|
||||
|
||||
|
||||
def _standalone_iptc_ai_tag(image_path: Path) -> bool:
|
||||
"""True when the file carries an AI IPTC marker with no C2PA around it.
|
||||
|
||||
Mirrors identify's ``standalone_iptc`` condition (the tag is only
|
||||
trustworthy as platform evidence when no manifest supersedes it) without
|
||||
importing the heavy identify module: the shared chunk-aware
|
||||
:func:`metadata.scan_head` window -- Muse WebP outputs place their XMP
|
||||
packet in a tail chunk up to hundreds of KB past a plain head read, which
|
||||
is exactly what scan_head's extensions exist to catch.
|
||||
"""
|
||||
try:
|
||||
from remove_ai_watermarks.metadata import IPTC_AI_MARKERS, c2pa_marker_in, scan_head
|
||||
|
||||
scan = scan_head(image_path)
|
||||
except Exception:
|
||||
return False
|
||||
return any(marker in scan for marker in IPTC_AI_MARKERS) and not c2pa_marker_in(scan)
|
||||
|
||||
@@ -52,8 +52,8 @@ _MIN_DETECT_SHORT_SIDE = 200
|
||||
# This used to be ONE shared 0.7 for every text mark. Measured 2026-07-18 on the
|
||||
# `auto` path (the default -- no flag, driven by TC260 metadata), it turned out to
|
||||
# mean two completely different things per mark. Blind hand-label of the ADDITIONS
|
||||
# (accepted with provenance, rejected without) over a labelled TC260 evaluation set,
|
||||
# two-sided control (labeller sensitivity 100%/96%, specificity 100%/100%):
|
||||
# (accepted with provenance, rejected without) over a labeled TC260 evaluation set,
|
||||
# two-sided control (labeler sensitivity 100%/96%, specificity 100%/100%):
|
||||
#
|
||||
# mark band precision 95% CI n
|
||||
# doubao whole arm 76% 61-87% 42
|
||||
@@ -89,8 +89,8 @@ class TextMarkConfig:
|
||||
name: str # short label for log lines (e.g. "Doubao")
|
||||
asset_name: str # bundled alpha PNG under assets/ (e.g. "doubao_alpha.png")
|
||||
corner: Literal[
|
||||
"br", "bl", "tl", "bc"
|
||||
] # bottom-right (Doubao/Jimeng), bottom-left (Samsung), top-left (RunningHub), bottom-center (LibLibAI)
|
||||
"br", "bl", "tl", "tr", "bc"
|
||||
] # br (Doubao/Jimeng), bl (Samsung), tl (RunningHub), tr (Microsoft), bc (LiblibAI)
|
||||
margin_floor: int # min margin in px for locate (4 for br marks, 2 for Samsung)
|
||||
# locate geometry (fraction of scale_base -- see scale_base())
|
||||
width_frac: float
|
||||
@@ -131,7 +131,7 @@ class TextMarkConfig:
|
||||
template_blur: float = 0.0
|
||||
# Which image dimension the mark's size and margins scale with. VENDOR-SPECIFIC,
|
||||
# measured, not assumed -- see TextMarkEngine.scale_base. "short" = min(h, w), "width" = w.
|
||||
scale_basis: Literal["short", "width"] = "width"
|
||||
scale_basis: Literal["short", "width", "long"] = "width"
|
||||
# Scale rungs ``_ladder_best`` sweeps (the detection comb). PER-MARK: a vendor
|
||||
# whose stamp sizes do not land on the shared 3-rung comb carries its own ladder
|
||||
# (measured for 千问, whose marks sit in two size modes ~1.6x apart -- one fraction
|
||||
@@ -309,7 +309,7 @@ class TextMarkEngine:
|
||||
provenance relaxation it stopped trying because many Jimeng false additions
|
||||
were actually Doubao marks.
|
||||
|
||||
Measured separability on hand-labelled examples, scoring BOTH templates
|
||||
Measured separability on hand-labeled examples, scoring BOTH templates
|
||||
against the same glyph blob:
|
||||
|
||||
feature separability (0.5 = useless, 1.0 = perfect)
|
||||
@@ -480,10 +480,19 @@ class TextMarkEngine:
|
||||
|
||||
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
|
||||
prior -- but Jimeng's measured behavior overrides the prior, and measurement
|
||||
wins over the standard's wording.
|
||||
|
||||
"long" (max of the two sides) is the Microsoft badge's measured basis: the
|
||||
pill tracks the RENDER dimension, so on a 1024x1536 portrait it scales with
|
||||
the 1536 (a width basis undersized the template by the aspect ratio and the
|
||||
portrait carriers fell to 0.15-0.32 NCC; measured 2026-08-27).
|
||||
"""
|
||||
return min(image.shape[:2]) if self.config.scale_basis == "short" else image.shape[1]
|
||||
if self.config.scale_basis == "short":
|
||||
return min(image.shape[:2])
|
||||
if self.config.scale_basis == "long":
|
||||
return max(image.shape[:2])
|
||||
return image.shape[1]
|
||||
|
||||
def locate(self, image: NDArray[Any]) -> TextMarkLocation:
|
||||
"""Anchor the watermark box in the configured corner, scaled by ``scale_basis``.
|
||||
@@ -499,14 +508,14 @@ class TextMarkEngine:
|
||||
wm_h = max(16, int(base * c.height_frac))
|
||||
margin_x = max(c.margin_floor, int(base * c.margin_x_frac))
|
||||
margin_b = max(c.margin_floor, int(base * c.margin_bottom_frac))
|
||||
if c.corner == "br":
|
||||
if c.corner == "br" or c.corner == "tr":
|
||||
x = max(0, w - margin_x - wm_w)
|
||||
elif c.corner == "bc": # bottom-center: horizontally centered, margin_x unused
|
||||
x = max(0, (w - wm_w) // 2)
|
||||
else:
|
||||
x = min(margin_x, max(0, w - wm_w))
|
||||
# "tl" anchors at the top instead: margin_bottom_frac is then the TOP margin.
|
||||
y = min(margin_b, max(0, h - wm_h)) if c.corner == "tl" else max(0, h - margin_b - wm_h)
|
||||
# "tl"/"tr" anchor at the top instead: margin_bottom_frac is then the TOP margin.
|
||||
y = min(margin_b, max(0, h - wm_h)) if c.corner in ("tl", "tr") else max(0, h - margin_b - wm_h)
|
||||
wm_w = min(wm_w, w - x)
|
||||
wm_h = min(wm_h, h - y)
|
||||
return TextMarkLocation(x=x, y=y, w=wm_w, h=wm_h)
|
||||
@@ -695,7 +704,7 @@ class TextMarkEngine:
|
||||
|
||||
OVERRIDABLE, and the override contract is specifically the DETECTOR'S MATCH BOX:
|
||||
a mark whose removable footprint reaches beyond what the NCC localizes -- Baidu's
|
||||
flat white tag right of the text run, LibLibAI's triangle logo left of the
|
||||
flat white tag right of the text run, LiblibAI's triangle logo left of the
|
||||
wordmark -- supplies its own extension here and inherits the rest of the
|
||||
footprint path. The blob-bbox branch never routes through an override.
|
||||
"""
|
||||
@@ -771,7 +780,7 @@ class TextMarkEngine:
|
||||
"""Footprint policy for a mark whose fill must be bounded by the DETECTOR's match
|
||||
box and never by the binary glyph blob.
|
||||
|
||||
Baidu's white tag has a flat interior a top-hat cannot answer, and LibLibAI's
|
||||
Baidu's white tag has a flat interior a top-hat cannot answer, and LiblibAI's
|
||||
blob bleeds up into background structure; in both cases the blob bbox is
|
||||
measurably wrong and the NCC match box is right. ``force`` takes priority here,
|
||||
unlike the default policy: a ``--no-detect`` caller named the mark, so the whole
|
||||
|
||||
@@ -73,7 +73,7 @@ def _tc260_vendors(path: Path) -> frozenset[str]:
|
||||
|
||||
An absent, unreadable or unmapped producer falls back to the historical pair rather
|
||||
than to nothing: the caller has already established that the AIGC signal fired, so
|
||||
the image IS China-AIGC labelled, and dropping to no relaxation would lose the
|
||||
the image IS China-AIGC labeled, and dropping to no relaxation would lose the
|
||||
detections the fallback recovers today. The re-read is deliberately isolated -- a
|
||||
failure here must narrow the answer, never discard the rest of the provenance.
|
||||
"""
|
||||
@@ -156,9 +156,10 @@ def remove_visible(
|
||||
) -> tuple[NDArray[Any], list[str]]:
|
||||
"""Remove every detected known visible AI mark through localize then fill.
|
||||
|
||||
The registry currently covers the Gemini sparkle; Doubao, Jimeng, Qwen, Kling,
|
||||
Yuanbao, Samsung, RunningHub, Baidu, and LibLibAI text marks; and the Jimeng
|
||||
pill. Returns ``(result_bgr, [labels removed])``.
|
||||
The registry currently covers the Gemini visible watermark; Doubao, Jimeng,
|
||||
Qwen, Kling AI, Yuanbao, Samsung, RunningHub, Baidu, and LiblibAI text marks;
|
||||
one Microsoft top-right AI-badge variant; and the Jimeng pill. Returns
|
||||
``(result_bgr, [labels removed])``.
|
||||
|
||||
``source`` is a file path OR a BGR ndarray. For a PATH, metadata provenance is read
|
||||
automatically (so ``sensitivity="auto"`` recovers a moved/faint mark whenever the
|
||||
@@ -184,7 +185,7 @@ 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.
|
||||
# at runtime, so a 0.15 caller would otherwise get `auto` behavior in silence.
|
||||
watermark_registry.validate_sensitivity(sensitivity)
|
||||
loaded = _load_visible_input(source)
|
||||
result, removed = watermark_registry.remove_auto_marks(
|
||||
@@ -232,6 +233,7 @@ class InvisibleOptions:
|
||||
|
||||
strength: float | None = None
|
||||
pipeline: str = "qwen-zimage"
|
||||
vendor: str | None = None
|
||||
seed: int | None = None
|
||||
hf_token: str | None = None
|
||||
humanize: float = 0.0
|
||||
@@ -487,13 +489,17 @@ def _run_invisible(
|
||||
if not is_available():
|
||||
say("invisible", "unavailable")
|
||||
return "unavailable"
|
||||
if not (force or evidence.has_invisible_target()):
|
||||
if not (force or opts.vendor is not None or evidence.has_invisible_target()):
|
||||
say("invisible", "no-signal")
|
||||
return "no-signal"
|
||||
|
||||
from remove_ai_watermarks._internal.watermark_profiles import resolve_strength, vendor_for_strength
|
||||
|
||||
vendor = vendor_for_strength(vendor_source)
|
||||
# An explicit vendor override wins over detection and implies the scrub runs:
|
||||
# naming the cohort (e.g. "meta" for Muse Image Content Seal, which carries no
|
||||
# provenance to detect) asserts the pixel watermark is present, so the no-signal
|
||||
# gate must not skip it.
|
||||
vendor = opts.vendor or vendor_for_strength(vendor_source)
|
||||
# Report the strength the engine will actually execute, resolved the same way it
|
||||
# resolves it, so the reported value cannot drift from the executed one.
|
||||
with suppress(Exception):
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
@@ -274,6 +274,32 @@ _strength_option = click.option(
|
||||
default=None,
|
||||
help=f"Denoising strength (0.0-1.0). Default: {strength_default_help()}.",
|
||||
)
|
||||
# Explicit strength-cohort override. Auto-detection reads the C2PA issuer, so it
|
||||
# covers OpenAI / Google / Microsoft; Meta Content Seal has no provenance signal
|
||||
# (no C2PA; the IPTC tag is a standard code), and an unknown or stripped manifest
|
||||
# also leaves the resolution-adaptive curve in charge -- this flag is the way to
|
||||
# name the cohort when the user knows what the file does not say.
|
||||
_vendor_option = click.option(
|
||||
"--vendor",
|
||||
type=click.Choice(["auto", "openai", "google", "microsoft", "meta"]),
|
||||
default="auto",
|
||||
help=(
|
||||
"Strength cohort for the invisible-removal default, and it implies the scrub "
|
||||
"runs even without a local signal: naming the cohort asserts the pixel "
|
||||
"watermark is present. auto: derive from C2PA provenance, else "
|
||||
"resolution-adaptive. Set explicitly when the source is known but unreadable "
|
||||
"(e.g. meta for Muse Image Content Seal, which never carries C2PA)."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _explicit_vendor(vendor: str | None) -> str | None:
|
||||
"""Normalize --vendor's ``auto`` default to None for the engine/API seam.
|
||||
|
||||
One helper so the three diffusion commands cannot drift on the spelling."""
|
||||
return None if vendor in (None, "auto") else vendor
|
||||
|
||||
|
||||
_seed_option = click.option(
|
||||
"--seed",
|
||||
type=int,
|
||||
@@ -281,7 +307,7 @@ _seed_option = click.option(
|
||||
help="Random seed for reproducibility. Default 0: both profiles are certified "
|
||||
"at a fixed seed, because SynthID removal near the strength floor is seed-dependent.",
|
||||
)
|
||||
_hf_token_option = click.option("--hf-token", type=str, default=None, help="HuggingFace API token.")
|
||||
_hf_token_option = click.option("--hf-token", type=str, default=None, help="Hugging Face API token.")
|
||||
_humanize_option = click.option(
|
||||
"--humanize", type=float, default=0.0, help="Analog Humanizer film grain intensity (0 = off, typical: 2.0-6.0)."
|
||||
)
|
||||
@@ -515,7 +541,7 @@ def _should_skip_invisible_scrub(force: bool, image_path: Path) -> bool:
|
||||
@click.option("-v", "--verbose", is_flag=True, help="Enable verbose logging.")
|
||||
@click.pass_context
|
||||
def main(ctx: click.Context, verbose: bool) -> None:
|
||||
"""Remove visible and invisible AI watermarks from images, plus provenance metadata from video."""
|
||||
"""Remove visible and invisible AI watermarks, plus metadata provenance marks, from images and video."""
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv() # Load .env (e.g. HF_TOKEN)
|
||||
@@ -796,6 +822,7 @@ def cmd_erase(
|
||||
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
||||
@_output_option
|
||||
@_strength_option
|
||||
@_vendor_option
|
||||
@_pipeline_option
|
||||
@_seed_option
|
||||
@_hf_token_option
|
||||
@@ -815,6 +842,7 @@ def cmd_invisible(
|
||||
source: Path,
|
||||
output: Path | None,
|
||||
strength: float | None,
|
||||
vendor: str | None,
|
||||
pipeline: str,
|
||||
seed: int | None,
|
||||
hf_token: str | None,
|
||||
@@ -831,7 +859,7 @@ def cmd_invisible(
|
||||
text_manifest: Path | None,
|
||||
fidelity_anchor: bool,
|
||||
) -> None:
|
||||
"""Remove invisible AI watermarks (SynthID, StableSignature, TreeRing).
|
||||
"""Attempt to disrupt invisible AI watermarks through pixel regeneration.
|
||||
|
||||
Regenerates the pixels with the two-stage diffusion profile. CUDA-only:
|
||||
pip install 'remove-ai-watermarks[qwen-zimage]'
|
||||
@@ -851,11 +879,17 @@ def cmd_invisible(
|
||||
if output is None:
|
||||
output = source.with_stem(source.stem + "_clean")
|
||||
|
||||
# An explicit --vendor wins over detection (see the option help) and implies the
|
||||
# scrub runs: naming the cohort asserts the pixel watermark is present, so the
|
||||
# no-signal gate must not skip it. Resolved BEFORE the gate for the same reason.
|
||||
resolved_vendor = _explicit_vendor(vendor)
|
||||
|
||||
# Gate BEFORE building the engine: skip the destructive regeneration when no
|
||||
# invisible AI watermark is locally detectable (it would only degrade a clean
|
||||
# image -- dominant paid score-0 cause), so the common skip path pays nothing for
|
||||
# engine construction. A skip never claims the image is clean; --force overrides.
|
||||
if _should_skip_invisible_scrub(force, source):
|
||||
# engine construction. A skip never claims the image is clean; --force and an
|
||||
# explicit --vendor override.
|
||||
if _should_skip_invisible_scrub(force or resolved_vendor is not None, source):
|
||||
_no_invisible_signal_exit(source)
|
||||
|
||||
def progress_cb(msg: str) -> None:
|
||||
@@ -870,11 +904,18 @@ def cmd_invisible(
|
||||
)
|
||||
|
||||
# Detect the SynthID vendor from the ORIGINAL (before processing strips C2PA) so the
|
||||
# displayed and executed strength agree on the vendor-adaptive default.
|
||||
vendor = vendor_for_strength(source)
|
||||
# displayed and executed strength agree on the vendor-adaptive default. An explicit
|
||||
# --vendor override wins over detection: it names a cohort the file cannot prove
|
||||
# (Meta Content Seal never carries C2PA; a stripped manifest proves nothing).
|
||||
detected_vendor = vendor_for_strength(source) if resolved_vendor is None else None
|
||||
vendor_label = resolved_vendor or detected_vendor
|
||||
vendor_note = " (override)" if resolved_vendor else ""
|
||||
console.print(f" Input: {source.name}")
|
||||
console.print(f" Pipeline: {pipeline}")
|
||||
console.print(f" Strength: {_resolved_strength_for_display(source, strength, vendor, pipeline)}")
|
||||
console.print(
|
||||
f" Strength: {_resolved_strength_for_display(source, strength, vendor_label, pipeline)}"
|
||||
+ (f" [vendor: {vendor_label}{vendor_note}]" if vendor_label else "")
|
||||
)
|
||||
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
@@ -887,7 +928,7 @@ def cmd_invisible(
|
||||
unsharp=unsharp,
|
||||
adaptive_polish=adaptive_polish,
|
||||
max_resolution=max_resolution,
|
||||
vendor=vendor,
|
||||
vendor=vendor_label,
|
||||
tile=tile,
|
||||
tile_size=tile_size,
|
||||
tile_overlap=tile_overlap,
|
||||
@@ -960,8 +1001,8 @@ def cmd_metadata(
|
||||
Strips EXIF AI tags, PNG text chunks, C2PA provenance manifests, and the
|
||||
China TC260 AIGC label. Beyond images (PNG/JPEG/WebP/AVIF/HEIF/JXL) it also
|
||||
strips provenance metadata from MP4/MOV/M4V/M4A containers and, via ffmpeg,
|
||||
from WebM/MKV/AVI/FLV/MP3/WAV/FLAC/OGG. The coded image, audio, and video
|
||||
data are left untouched.
|
||||
from WebM/MKV/MKA/AVI/FLV/MP3/WAV/FLAC/OGG/OGA/Opus/AAC. The coded image,
|
||||
audio, and video data are left untouched.
|
||||
"""
|
||||
from remove_ai_watermarks.metadata import get_ai_metadata, has_ai_metadata, strip_and_verify
|
||||
|
||||
@@ -1493,6 +1534,7 @@ def cmd_identify(ctx: click.Context, source: Path, no_visible: bool, as_json: bo
|
||||
@_visible_backend_option
|
||||
@_visible_sensitivity_option
|
||||
@_strength_option
|
||||
@_vendor_option
|
||||
@_pipeline_option
|
||||
@_seed_option
|
||||
@_hf_token_option
|
||||
@@ -1514,6 +1556,7 @@ def cmd_all(
|
||||
backend: str,
|
||||
sensitivity: str,
|
||||
strength: float | None,
|
||||
vendor: str | None,
|
||||
pipeline: str,
|
||||
seed: int | None,
|
||||
hf_token: str | None,
|
||||
@@ -1594,6 +1637,7 @@ def cmd_all(
|
||||
sensitivity=_parse_sensitivity(sensitivity),
|
||||
invisible=InvisibleOptions(
|
||||
strength=strength,
|
||||
vendor=_explicit_vendor(vendor),
|
||||
pipeline=pipeline,
|
||||
seed=seed,
|
||||
hf_token=hf_token,
|
||||
@@ -1688,6 +1732,7 @@ def _batch_engine(mode: str, options: InvisibleOptions) -> object | None:
|
||||
@_visible_backend_option
|
||||
@_visible_sensitivity_option
|
||||
@_humanize_option
|
||||
@_vendor_option
|
||||
@_pipeline_option
|
||||
@_seed_option
|
||||
@_hf_token_option
|
||||
@@ -1705,6 +1750,7 @@ def cmd_batch(
|
||||
mode: str,
|
||||
output_dir: Path | None,
|
||||
strength: float | None,
|
||||
vendor: str | None,
|
||||
pipeline: str,
|
||||
seed: int | None,
|
||||
hf_token: str | None,
|
||||
@@ -1742,6 +1788,7 @@ def cmd_batch(
|
||||
|
||||
invisible_options = InvisibleOptions(
|
||||
strength=strength,
|
||||
vendor=_explicit_vendor(vendor),
|
||||
pipeline=pipeline,
|
||||
seed=seed,
|
||||
hf_token=hf_token,
|
||||
|
||||
@@ -97,7 +97,7 @@ class _DecodeMaxDct:
|
||||
return decoded
|
||||
|
||||
def _plane_bits(self, trimmed: NDArray[Any], channel: int, scale: int) -> NDArray[Any]:
|
||||
"""Block bits for one colour plane, a strip of block-rows at a time.
|
||||
"""Block bits for one color plane, a strip of block-rows at a time.
|
||||
|
||||
``dwt2`` is ``dwtn``: it transforms along axis 0, then along axis 1 over
|
||||
both halves, and three of the four bands it returns are discarded here.
|
||||
|
||||
@@ -135,12 +135,17 @@ _C2PA_INVALID_CAVEAT = (
|
||||
"are retained only as removal hints, not as verified provenance."
|
||||
)
|
||||
_IPTC_ONLY_CAVEAT = "The IPTC 'Made with AI' tag flags AI provenance but does not identify the specific platform."
|
||||
_CONTENT_SEAL_CAVEAT = (
|
||||
"Meta Muse Image outputs carry the invisible Content Seal pixel watermark, which has no "
|
||||
"local decoder; `invisible` removes it (auto when this tag is present, or `--vendor meta` "
|
||||
"on stripped files) and meta.ai/identification verifies it."
|
||||
)
|
||||
_INVISIBLE_WM_CAVEAT = (
|
||||
"The open invisible watermark is fragile: it does not survive JPEG re-encoding "
|
||||
"or resizing, so it confirms origin only on a pristine (un-re-encoded) file."
|
||||
)
|
||||
_HF_JOB_CAVEAT = (
|
||||
"The hf-job-id tag marks a HuggingFace-hosted job (commonly diffusion "
|
||||
"The hf-job-id tag marks a Hugging Face-hosted job (commonly diffusion "
|
||||
"generation) but names neither the model nor the content type, so it is a "
|
||||
"medium-confidence signal, not proof the pixels are AI-generated."
|
||||
)
|
||||
@@ -440,7 +445,7 @@ def evidence_from_metadata_record(
|
||||
if iptc_system:
|
||||
ai_metadata.setdefault("ai_system", f"IPTC 2025.1 AI disclosure ({iptc_system})")
|
||||
if hf_job:
|
||||
ai_metadata.setdefault("huggingface_job", f"HuggingFace-hosted job ({hf_job})")
|
||||
ai_metadata.setdefault("huggingface_job", f"Hugging Face-hosted job ({hf_job})")
|
||||
if samsung is not None:
|
||||
ai_metadata.setdefault("samsung_genai", f"Samsung Galaxy AI editing marker (genAIType={samsung})")
|
||||
|
||||
@@ -948,7 +953,7 @@ def _visible_sparkle(image_path: Path, *, image: NDArray[Any] | None = None) ->
|
||||
# metadata label); the per-engine detection thresholds live in the registry.
|
||||
# Text mark -> the platform sentence this report prints when that mark is the strongest
|
||||
# evidence, DERIVED from the registry rows so registering a mark is one edit. It was a
|
||||
# hand-maintained copy, and that class of copy is how LibLibAI ended up registered but
|
||||
# hand-maintained copy, and that class of copy is how LiblibAI ended up registered but
|
||||
# missing from the pill veto. Insertion order is the registry's, which is what fixes the
|
||||
# scan order below. The Gemini sparkle and the capture-less pill carry no platform of
|
||||
# their own (`KnownMark.platform is None`) and are excluded here: the sparkle has its
|
||||
@@ -1100,7 +1105,7 @@ def _collect_visible_signals(
|
||||
sparkle_conf = _visible_sparkle(image_path, image=image)
|
||||
if sparkle_conf is not None and sparkle_conf >= _SPARKLE_THRESHOLD:
|
||||
signals.append(Signal("visible_sparkle", f"NCC confidence {sparkle_conf:.2f}", "medium"))
|
||||
watermarks.append(f"Visible Gemini sparkle (confidence {sparkle_conf:.2f})")
|
||||
watermarks.append(f"Google Gemini visible watermark (sparkle; confidence {sparkle_conf:.2f})")
|
||||
if platform is None:
|
||||
platform = "Google Gemini family (visible sparkle detected)"
|
||||
|
||||
@@ -1268,7 +1273,19 @@ def _identify_from_evidence(
|
||||
# reusing the derived `has_c2pa` / `source_kind` above, which are broader:
|
||||
# the file path's answer must not move.
|
||||
trained_source = b"trainedAlgorithmicMedia" in head or b"TrainedAlgorithmicMedia" in head
|
||||
if not synthid and trained_source and c2pa_marker_in(head) and (vendors := synthid_evidence_vendors_in(region)):
|
||||
# Same suppression as every other inference site: bytes that name their own
|
||||
# forensic soft-binding algorithm carry that vendor's mark, and the generic
|
||||
# vendor-token inference must not add a second, differently-attributed
|
||||
# invisible watermark (Microsoft Designer: "Azure OpenAI ImageGen" agent +
|
||||
# the InvisMark watermarked action read as "SynthID per OpenAI").
|
||||
soft_binding_vendors = soft_binding_vendors_in(region)
|
||||
if (
|
||||
not synthid
|
||||
and trained_source
|
||||
and c2pa_marker_in(head)
|
||||
and not soft_binding_vendors
|
||||
and (vendors := synthid_evidence_vendors_in(region))
|
||||
):
|
||||
synthid = synthid_verdict(", ".join(vendors))
|
||||
if synthid:
|
||||
watermarks.append(
|
||||
@@ -1283,7 +1300,7 @@ def _identify_from_evidence(
|
||||
# ── C2PA soft-binding: a named forensic/third-party watermark vendor ─
|
||||
# (Adobe TrustMark, Digimarc, Imatag, ...). Present in the manifest even when
|
||||
# the watermark itself can't be decoded; names whose watermark stamped the pixels.
|
||||
soft_binding = meta.get("soft_binding") or (", ".join(v) if (v := soft_binding_vendors_in(region)) else None)
|
||||
soft_binding = meta.get("soft_binding") or (", ".join(soft_binding_vendors) if soft_binding_vendors else None)
|
||||
if soft_binding:
|
||||
soft_binding_algorithm = meta.get("soft_binding_algorithm") or info.get("soft_binding_algorithm")
|
||||
soft_binding_value = meta.get("soft_binding_value") or info.get("soft_binding_value")
|
||||
@@ -1317,17 +1334,38 @@ def _identify_from_evidence(
|
||||
if standalone_iptc:
|
||||
signals.append(Signal("iptc", "digitalSourceType (Made with AI)", "high"))
|
||||
watermarks.append("IPTC digitalSourceType (Made with AI)")
|
||||
# Muse Image stamps every output with the invisible Content Seal, and this
|
||||
# tag is the only provenance such a file carries - the same measured bet
|
||||
# the strength router makes (vendor_for_strength -> "meta"). Emit the seal
|
||||
# as its own stable signal, the way InvisMark is additive over
|
||||
# soft_binding, so clients select pixel removal from the signal list
|
||||
# instead of parsing caveats. It is an attribution, not a decode: no
|
||||
# public Content Seal decoder exists, hence "medium".
|
||||
signals.append(
|
||||
Signal(
|
||||
"content_seal",
|
||||
"Meta Muse Content Seal pixel watermark (attributed by the standalone AI digital-source tag)",
|
||||
"medium",
|
||||
)
|
||||
)
|
||||
watermarks.append("Invisible Content Seal watermark (Meta Muse attribution)")
|
||||
caveats.append(_IPTC_ONLY_CAVEAT)
|
||||
caveats.append(_CONTENT_SEAL_CAVEAT)
|
||||
if platform is None:
|
||||
# Apple Photos Clean Up (Apple Intelligence object removal) marks
|
||||
# the edit with photoshop:Credit / IPTC "Apple Photos Clean Up"
|
||||
# next to compositeWithTrainedAlgorithmicMedia. It was detected but
|
||||
# previously never attributed.
|
||||
platform = (
|
||||
"Apple Photos (Clean Up AI edit)"
|
||||
if b"Apple Photos Clean Up" in head
|
||||
else "Made-with-AI tag (e.g. Meta AI); platform not specified"
|
||||
)
|
||||
if b"Apple Photos Clean Up" in head:
|
||||
platform = "Apple Photos (Clean Up AI edit)"
|
||||
else:
|
||||
# The platform line follows the same measured bet the seal signal
|
||||
# and the strength router make: Muse Image is the tag writer whose
|
||||
# outputs this profile targets, so a hedged Muse attribution is
|
||||
# more useful than "platform not specified" while the panel below
|
||||
# already prices the Content Seal removal. The hedge stays in the
|
||||
# wording - it names the attribution basis, not a detection.
|
||||
platform = "Meta Muse Image (attributed by the standalone AI digital-source tag)"
|
||||
|
||||
# ── IPTC 2025.1 AI-disclosure fields (Iptc4xmpExt:AISystemUsed etc.) ─
|
||||
iptc_ai = any(m in head for m in IPTC_AI_FIELD_MARKERS)
|
||||
@@ -1388,17 +1426,17 @@ def _identify_from_evidence(
|
||||
platform = "xAI (Grok / Aurora)"
|
||||
ai_vendor_claims["xai"] = "xAI"
|
||||
|
||||
# ── HuggingFace-hosted job marker (hf-job-id PNG text chunk) ─────
|
||||
# ── Hugging Face-hosted job marker (hf-job-id PNG text chunk) ─────
|
||||
# Marks the hosting job, not a model -- medium confidence (commonly diffusion
|
||||
# output). Like the visible sparkle, it lifts an otherwise-Unknown verdict to
|
||||
# a tentative AI, but never overrides a high-confidence metadata signal.
|
||||
hf_job = evidence.huggingface_job
|
||||
if hf_job:
|
||||
signals.append(Signal("hf_job", f"HuggingFace job {hf_job}", "medium"))
|
||||
watermarks.append("HuggingFace-hosted job (hf-job-id)")
|
||||
signals.append(Signal("hf_job", f"Hugging Face job {hf_job}", "medium"))
|
||||
watermarks.append("Hugging Face-hosted job (hf-job-id)")
|
||||
caveats.append(_HF_JOB_CAVEAT)
|
||||
if platform is None:
|
||||
platform = "HuggingFace-hosted job (model not identified)"
|
||||
platform = "Hugging Face-hosted job (model not identified)"
|
||||
|
||||
# ── Samsung Galaxy AI editing marker (genAIType) ─────────────────
|
||||
# Galaxy AI tools stamp a proprietary genAIType in PhotoEditor_Re_Edit_Data.
|
||||
|
||||
@@ -33,7 +33,7 @@ warnings.filterwarnings("ignore", category=UserWarning, module="huggingface_hub"
|
||||
warnings.filterwarnings("ignore", category=UserWarning, module="diffusers")
|
||||
warnings.filterwarnings("ignore", module="transformers")
|
||||
|
||||
# Suppress HuggingFace internal logging
|
||||
# Suppress Hugging Face internal logging
|
||||
os.environ["TRANSFORMERS_VERBOSITY"] = "error"
|
||||
os.environ["DIFFUSERS_VERBOSITY"] = "error"
|
||||
|
||||
@@ -104,7 +104,7 @@ class InvisibleEngine:
|
||||
global pass, vendor-adaptive strength because an SDXL global stage
|
||||
needs more of it). BOTH ARE CUDA-ONLY -- there is no CPU or MPS path
|
||||
for invisible-watermark removal.
|
||||
hf_token: HuggingFace API token.
|
||||
hf_token: Hugging Face API token.
|
||||
progress_callback: Optional callback for progress messages.
|
||||
controlnet_conditioning_scale: Canny ControlNet structure-preservation
|
||||
strength on the global stage of both profiles.
|
||||
@@ -187,9 +187,8 @@ class InvisibleEngine:
|
||||
pixels. Enables the experimental Qwen-VAE ``vae-glyphs`` post-pass.
|
||||
Requires the ``text-restoration`` extra and the ``qwen-zimage``
|
||||
profile. Incompatible with downscaling, humanize, unsharp, and
|
||||
adaptive polish. Tiling is supported: the VAE donor uses the same
|
||||
overlapping tiles as the global pass, then glyph restore runs on
|
||||
the blended full frame.
|
||||
adaptive polish. Tiling is rejected because that combination has
|
||||
no provider-oracle calibration.
|
||||
fidelity_anchor: Blend 15% of the Qwen-VAE donor across the whole frame
|
||||
before glyph restoration. OFF by default since 0.27.1: that global
|
||||
blend was measured to return detector-visible OpenAI SynthID on
|
||||
@@ -211,6 +210,8 @@ class InvisibleEngine:
|
||||
if text_manifest is not None:
|
||||
if self._remover.model_profile != QWEN_ZIMAGE_PROFILE:
|
||||
raise ValueError("--text-manifest is supported only by the qwen-zimage profile")
|
||||
if tile:
|
||||
raise ValueError("--text-manifest is not calibrated with --tile")
|
||||
if max_resolution != 0:
|
||||
raise ValueError("--text-manifest requires --max-resolution 0")
|
||||
if humanize > 0.0 or unsharp > 0.0 or adaptive_polish:
|
||||
|
||||
@@ -99,7 +99,7 @@ def detect_invisible_watermark(image_path: Path, *, image: NDArray[Any] | None =
|
||||
|
||||
# ``image`` lets a caller that has already decoded these pixels hand them in
|
||||
# (mirrors gemini_engine.detect_sparkle_confidence). The decoder only reads the
|
||||
# array -- it converts colour spaces into fresh buffers -- so no copy is needed.
|
||||
# array -- it converts color spaces into fresh buffers -- so no copy is needed.
|
||||
img = image if image is not None else image_io.imread(image_path)
|
||||
if img is None:
|
||||
return None
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Kling (可灵, Kuaishou) visible watermark detector/localizer.
|
||||
"""Kling AI (可灵AI, Kuaishou) visible watermark detector/localizer.
|
||||
|
||||
Kling stamps its generations with a thin, light-gray "可灵AI 3.0" text strip in the
|
||||
Kling AI stamps its generations with a thin, light-gray "可灵AI 3.0" text strip in the
|
||||
bottom-right corner, preceded by the vendor's spiral logo (not part of the detection
|
||||
silhouette -- logos vary between releases, the text run is what discriminates).
|
||||
Known variants: an "Omni" suffix release, a latin "KlingAI 3.0" release, and a
|
||||
@@ -9,7 +9,7 @@ suffix variants are only caught when the core run is bold enough (measured below
|
||||
|
||||
Detection matches the bundled glyph silhouette against the corner; removal is the
|
||||
shared **localize -> fill** (the glyph-bbox :meth:`footprint_mask` feeds
|
||||
``region_eraser``), NOT reverse-alpha. This module supplies only Kling's tuned
|
||||
``region_eraser``), NOT reverse-alpha. This module supplies only Kling AI's tuned
|
||||
:class:`TextMarkConfig` (``assets/kling_alpha.png`` -- a font-rendered synthetic
|
||||
silhouette from ``scripts/render_vendor_silhouettes.py``, never cut from an
|
||||
upload). It also feeds ``identify`` as the medium-confidence ``visible_kling``
|
||||
@@ -26,7 +26,7 @@ producer USCC 91110108335469089C names the entity, 2026-07-21; harness
|
||||
* ``alpha_height_frac`` comes from the silhouette aspect (0.239) at the fitted
|
||||
width, matching the aspect the fit converged on (0.25).
|
||||
* Gate 0.35, one step above the clean arm's max: on the cohort-vs-clean run
|
||||
(cohort-contamination-guarded, 286 hand-labelled clean frames) the clean arm
|
||||
(cohort-contamination-guarded, 286 hand-labeled clean frames) the clean arm
|
||||
scored p99 0.304 / max 0.320, and every cohort frame >= 0.35 carries a visible
|
||||
可灵AI 3.0 mark (9 of ~19 eyeballed visible marks fire = ~47% recall of visible
|
||||
marks; the misses are the faint "Omni"-suffix release, the latin "KlingAI"
|
||||
@@ -38,7 +38,7 @@ producer USCC 91110108335469089C names the entity, 2026-07-21; harness
|
||||
provenance relaxation exists for this mark.
|
||||
* No rival margin: at the shipped gate the template fires on 1 of 400
|
||||
Doubao-marked frames (0.2%, a 豆包 frame sitting INSIDE the Kling cohort, still
|
||||
below the gate), 0 of 298 Jimeng-marked frames and 0 of 286 hand-labelled clean
|
||||
below the gate), 0 of 298 Jimeng-marked frames and 0 of 286 hand-labeled clean
|
||||
frames, and a 0.10 rival margin costs zero genuine Kling detections -- so it is
|
||||
simply unnecessary (same conclusion shape as Qwen).
|
||||
"""
|
||||
@@ -74,7 +74,7 @@ LOGO_MIN_LUMA = 150
|
||||
TOPHAT_DELTA = 12
|
||||
|
||||
DETECT_MIN_COVERAGE = 0.04 # unused by the tophat front-end (kept for config parity)
|
||||
# Calibrated 2026-07-21 on the vendor cohort vs 286 hand-labelled clean frames
|
||||
# Calibrated 2026-07-21 on the vendor cohort vs 286 hand-labeled clean frames
|
||||
# (cohort-contamination-guarded): clean p99 0.304 / max 0.320, and every cohort
|
||||
# frame scoring >= 0.35 carries a visible 可灵AI 3.0 mark. 0.35 was picked over
|
||||
# 0.33 (also zero clean fires) for margin against unseen clean content at a cost
|
||||
@@ -87,7 +87,7 @@ _ALPHA_WIDTH_FRAC = 0.12
|
||||
_ALPHA_HEIGHT_FRAC = 0.0287
|
||||
|
||||
_CONFIG = TextMarkConfig(
|
||||
name="Kling",
|
||||
name="Kling AI",
|
||||
asset_name="kling_alpha.png",
|
||||
corner="br",
|
||||
margin_floor=4,
|
||||
@@ -114,7 +114,7 @@ _CONFIG = TextMarkConfig(
|
||||
|
||||
|
||||
def _alpha_template() -> NDArray[Any] | None:
|
||||
"""The bundled Kling alpha template (float [0,1]), or None."""
|
||||
"""The bundled Kling AI alpha template (float [0,1]), or None."""
|
||||
return _text_mark_engine.load_alpha_template(_CONFIG.asset_name)
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@ def _glyph_silhouette() -> NDArray[Any] | None:
|
||||
|
||||
|
||||
class KlingEngine(TextMarkEngine):
|
||||
"""Detect/localize the visible Kling "可灵AI 3.0" watermark (locate -> mask; mask feeds the fill)."""
|
||||
"""Detect/localize the visible Kling AI "可灵AI 3.0" watermark (locate -> mask; mask feeds the fill)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(_CONFIG)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"""LibLibAI visible watermark detector/localizer.
|
||||
"""LiblibAI visible watermark detector/localizer.
|
||||
|
||||
LibLibAI (哩布哩布AI, USCC 91110105MACJ6K1C8A) stamps its generations with a
|
||||
white triangle logo + "LibLibAI" latin wordmark at **bottom-center** (not a
|
||||
LiblibAI (哩布哩布AI, USCC 91110105MACJ6K1C8A) stamps its generations with a
|
||||
white triangle logo + "LiblibAI" Latin wordmark at **bottom-center** (not a
|
||||
corner -- the locate box is horizontally centered). Detection matches the
|
||||
bundled font-rendered "LibLibAI" silhouette (the triangle logo is NOT rendered
|
||||
bundled font-rendered "LiblibAI" silhouette (the triangle logo is NOT rendered
|
||||
-- logos vary, the wordmark discriminates); removal is the shared **localize ->
|
||||
fill** (the glyph blob covers logo + wordmark, both bright).
|
||||
|
||||
This module supplies only LibLibAI's tuned :class:`TextMarkConfig`
|
||||
This module supplies only LiblibAI's tuned :class:`TextMarkConfig`
|
||||
(``assets/liblib_alpha.png`` from ``scripts/render_vendor_silhouettes.py``,
|
||||
never cut from an upload).
|
||||
|
||||
@@ -64,7 +64,7 @@ _ALPHA_HEIGHT_FRAC = 0.026
|
||||
_LADDER = (0.9, 1.0, 1.1)
|
||||
|
||||
_CONFIG = TextMarkConfig(
|
||||
name="LibLibAI",
|
||||
name="LiblibAI",
|
||||
asset_name="liblib_alpha.png",
|
||||
corner="bc",
|
||||
margin_floor=4,
|
||||
@@ -90,12 +90,12 @@ _CONFIG = TextMarkConfig(
|
||||
|
||||
|
||||
def _alpha_template() -> NDArray[Any] | None:
|
||||
"""The bundled LibLibAI alpha template (float [0,1]), or None."""
|
||||
"""The bundled LiblibAI alpha template (float [0,1]), or None."""
|
||||
return _text_mark_engine.load_alpha_template(_CONFIG.asset_name)
|
||||
|
||||
|
||||
class LibLibEngine(TextMarkEngine):
|
||||
"""Detect/localize the visible LibLibAI wordmark (bottom-center; localize -> fill)."""
|
||||
"""Detect/localize the visible LiblibAI wordmark (bottom-center; localize -> fill)."""
|
||||
|
||||
# Per-mark size floor prevents small generic icons from matching the wordmark.
|
||||
_MIN_SHORT_SIDE = 480
|
||||
|
||||
@@ -199,7 +199,7 @@ def parse_tc260_aigc_json(value: bytes) -> dict[str, str] | None:
|
||||
return fields if TC260_AIGC_FIELDS & fields.keys() else None
|
||||
|
||||
|
||||
# HuggingFace-hosted GPU jobs (Jobs / Spaces) stamp generated PNGs with this
|
||||
# Hugging Face-hosted GPU jobs (Jobs / Spaces) stamp generated PNGs with this
|
||||
# ``tEXt`` chunk key holding the job UUID. It marks the hosting job, not a
|
||||
# specific model -- a medium-confidence AI signal (commonly diffusion output).
|
||||
_HF_JOB_KEY: str = "hf-job-id"
|
||||
@@ -413,7 +413,7 @@ def _scan_head_impl(image_path: Path, size: int) -> bytes:
|
||||
# packet larger than this is not a provenance label.
|
||||
_DECODED_TEXT_LIMIT = 512 * 1024
|
||||
# Decoder values that are binary payloads with their own readers, not metadata text.
|
||||
# An ICC profile is colour data and can run to hundreds of kilobytes; appending it
|
||||
# An ICC profile is color data and can run to hundreds of kilobytes; appending it
|
||||
# would bloat the buffer every later detector re-scans, for no signal.
|
||||
_DECODER_BINARY_KEYS = frozenset({"icc_profile"})
|
||||
|
||||
@@ -510,7 +510,7 @@ def has_ai_metadata(image_path: Path) -> bool:
|
||||
# only the XMP form; the raw-JSON tEXt chunk needs the PIL-based parse).
|
||||
if aigc_label(image_path) is not None:
|
||||
return True
|
||||
# HuggingFace-hosted job marker (hf-job-id PNG text chunk).
|
||||
# Hugging Face-hosted job marker (hf-job-id PNG text chunk).
|
||||
if huggingface_job(image_path):
|
||||
return True
|
||||
# xAI / Grok: no C2PA/IPTC/XMP -- only the EXIF Signature + UUID-Artist pair.
|
||||
@@ -682,10 +682,10 @@ def c2pa_cloud_manifest(image_path: Path) -> str | None:
|
||||
|
||||
|
||||
def _huggingface_job_impl(image_path: Path) -> str | None:
|
||||
"""Return the HuggingFace job id if the image carries an ``hf-job-id`` PNG
|
||||
"""Return the Hugging Face job id if the image carries an ``hf-job-id`` PNG
|
||||
text chunk, else None.
|
||||
|
||||
HuggingFace-hosted GPU jobs (Jobs / Spaces) stamp generated PNGs with an
|
||||
Hugging Face-hosted GPU jobs (Jobs / Spaces) stamp generated PNGs with an
|
||||
``hf-job-id`` ``tEXt`` chunk holding the job's UUID. It identifies the
|
||||
*hosting job*, not a specific model, and is most commonly seen on diffusion-
|
||||
generation output -- a medium-confidence AI signal, not proof of AI pixels
|
||||
@@ -840,6 +840,13 @@ def synthid_source(image_path: Path, *, c2pa_info: dict[str, Any] | None = None)
|
||||
ai_source = b"trainedAlgorithmicMedia" in data or b"TrainedAlgorithmicMedia" in data
|
||||
if not (has_c2pa and ai_source):
|
||||
return None
|
||||
from remove_ai_watermarks._internal.c2pa import soft_binding_vendors_in
|
||||
|
||||
# A scan that names its own forensic soft-binding algorithm carries that
|
||||
# vendor's mark; the generic vendor-token inference must not add a second,
|
||||
# differently-attributed invisible watermark from the same bytes.
|
||||
if soft_binding_vendors_in(data):
|
||||
return None
|
||||
matched = synthid_evidence_vendors_in(data)
|
||||
return ", ".join(matched) if matched else None
|
||||
|
||||
@@ -1210,9 +1217,9 @@ def get_ai_metadata(image_path: Path) -> dict[str, str]:
|
||||
if system := iptc_ai_system(image_path):
|
||||
result.setdefault("ai_system", f"IPTC 2025.1 AI disclosure ({system})")
|
||||
|
||||
# HuggingFace-hosted job marker (hf-job-id PNG text chunk).
|
||||
# Hugging Face-hosted job marker (hf-job-id PNG text chunk).
|
||||
if job := huggingface_job(image_path):
|
||||
result.setdefault("huggingface_job", f"HuggingFace-hosted job ({job})")
|
||||
result.setdefault("huggingface_job", f"Hugging Face-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})")
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Microsoft top-right AI-badge detector/localizer.
|
||||
|
||||
This engine covers one measured Microsoft output variant: a white pill with dark
|
||||
internal shapes in the top-right corner. The evaluated files used both "Made with
|
||||
AI" and "AI-Generated" wording. This is narrower than Microsoft's documented
|
||||
watermark feature, which can use a Copilot icon or text and can place the mark in
|
||||
other positions. A Microsoft provenance signal therefore does not establish that
|
||||
this exact visible variant is present.
|
||||
|
||||
Detection matches a synthetic pill silhouette (white pill with the sparkle and
|
||||
text KNOCKED OUT) against the top-hat blob of the located box: the holes are what
|
||||
discriminate this pill from any other bright rounded element in the corner.
|
||||
Removal is the shared **localize -> fill**; the glyph-bbox :meth:`footprint_mask`
|
||||
covers the whole pill including its text.
|
||||
|
||||
The tuned numbers below were remeasured on 2026-08-27 with the registered engine
|
||||
and ``scripts/registered_mark_calibrate.py``. The arms were kept distinct: 17
|
||||
visually confirmed carriers, 343 Microsoft-provenance files whose visible-mark
|
||||
status was not adjudicated, and 1200 non-overlapping no-signal controls:
|
||||
|
||||
* Geometry is single-mode and tight: pill 0.152 x 0.040 of the LONG side
|
||||
(aspect 3.73-3.89 over 720..1536 px), margins ~0.010/0.007 of the same basis. One size
|
||||
mode, so the shared 3-rung ladder is untouched and the locate box simply
|
||||
wraps the pill with NCC slack.
|
||||
* Provenance relaxation 0.7 (relaxed gate 0.266), enabled 2026-08-28 when the
|
||||
cohort the strict-only note was waiting for became available: an OCR badge
|
||||
census split the 343 Microsoft-C2PA uploads into 86 badge carriers and 257
|
||||
true badge-less files (the watermark is a per-user opt-in, so 75% of MS
|
||||
uploads carry none). Badge-less max 0.251 / p99 0.213, so the relaxed band
|
||||
[0.251, 0.38) holds three genuine faint badges and zero false fills
|
||||
(measured 3/3; doubao ships 0.7 on a 58%-precision band). Strict controls
|
||||
max 0.293 / p99 0.200 vs the 0.38 gate.
|
||||
* Front-end "binary": the pill is a bold opaque overlay; the tophat blob is
|
||||
solid with dark-text holes, exactly the template's shape.
|
||||
"""
|
||||
|
||||
# pyright: reportUnusedFunction=false
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from remove_ai_watermarks import _text_mark_engine
|
||||
from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkEngine
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from numpy.typing import NDArray
|
||||
|
||||
# Locate geometry as a fraction of the image LONG side (measured; scale_basis="long":
|
||||
# on 1024x1536 portraits the pill tracks the 1536, and a width basis undersized
|
||||
# the template until the portrait carriers fell to 0.15-0.32 NCC).
|
||||
# The box wraps the measured pill rect (0.152W x 0.040W) with NCC slack; margins
|
||||
# sit inside the pill's own ~0.010W-right / ~0.007W-top insets.
|
||||
WM_WIDTH_FRAC = 0.170
|
||||
WM_HEIGHT_FRAC = 0.055
|
||||
MARGIN_RIGHT_FRAC = 0.004
|
||||
MARGIN_TOP_FRAC = 0.003
|
||||
|
||||
# Glyph appearance: a bright near-white pill (luma ~245), gray-scale (sat < 60).
|
||||
MAX_SATURATION = 60
|
||||
LOGO_MIN_LUMA = 170
|
||||
TOPHAT_DELTA = 10
|
||||
|
||||
# Calibrated 2026-08-27: non-overlapping no-signal controls (n=1200) max 0.293 /
|
||||
# p99 0.200; visually confirmed carriers (n=17) p50 0.519 / p90 0.578 / max
|
||||
# 0.579, with 15/17 above the 0.38 gate. The two misses score 0.249 and 0.315.
|
||||
DETECT_MIN_COVERAGE = 0.30 # the pill fills most of its box; content corners do not
|
||||
DETECT_NCC_THRESHOLD = 0.38
|
||||
|
||||
# Pill silhouette geometry (fraction of width): 0.152W x 0.040W, aspect ~3.78.
|
||||
_ALPHA_NATIVE_WIDTH = 335
|
||||
_ALPHA_WIDTH_FRAC = 0.152
|
||||
_ALPHA_HEIGHT_FRAC = 0.040
|
||||
|
||||
_CONFIG = TextMarkConfig(
|
||||
name="Microsoft top-right AI badge",
|
||||
asset_name="microsoft_alpha.png",
|
||||
corner="tr",
|
||||
margin_floor=2,
|
||||
width_frac=WM_WIDTH_FRAC,
|
||||
height_frac=WM_HEIGHT_FRAC,
|
||||
margin_x_frac=MARGIN_RIGHT_FRAC,
|
||||
margin_bottom_frac=MARGIN_TOP_FRAC,
|
||||
max_saturation=MAX_SATURATION,
|
||||
logo_min_luma=LOGO_MIN_LUMA,
|
||||
tophat_delta=TOPHAT_DELTA,
|
||||
morph_open_size=5,
|
||||
detect_min_coverage=DETECT_MIN_COVERAGE,
|
||||
detect_ncc_threshold=DETECT_NCC_THRESHOLD,
|
||||
alpha_width_frac=_ALPHA_WIDTH_FRAC,
|
||||
alpha_height_frac=_ALPHA_HEIGHT_FRAC,
|
||||
min_gw=24,
|
||||
detect_frontend="binary",
|
||||
scale_basis="long",
|
||||
provenance_ncc_factor=0.7,
|
||||
)
|
||||
|
||||
|
||||
def _alpha_template() -> NDArray[Any] | None:
|
||||
"""The bundled Microsoft pill template (float [0,1]), or None."""
|
||||
return _text_mark_engine.load_alpha_template(_CONFIG.asset_name)
|
||||
|
||||
|
||||
class MicrosoftEngine(TextMarkEngine):
|
||||
"""Detect/localize the measured Microsoft top-right AI badge."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(_CONFIG)
|
||||
@@ -64,7 +64,7 @@ _MASK_W, _MASK_H = 0.205, 0.115 # width of W, height of W
|
||||
#
|
||||
# Measured through the PRODUCT path (the `_keep_pill` gate), not the raw detector, by
|
||||
# ``scripts/pill_gate_audit.py`` -- the raw path bypasses the gate and reads as a
|
||||
# disaster that the shipped behaviour does not have. Re-run it when the gate changes.
|
||||
# disaster that the shipped behavior does not have. Re-run it when the gate changes.
|
||||
_FLAT_TEXTURE_MAX = 6.0
|
||||
|
||||
_silhouette: NDArray[Any] | None = None
|
||||
|
||||
@@ -23,7 +23,7 @@ whose producer USCC 91440101MA9Y9T4H7A names the entity, 2026-07-21; harness
|
||||
ratio 1.64 -- wider than the shared 3-rung ladder's 1.5625 span), so a single
|
||||
fraction on the shared ladder covers ~75% of marks and the rest land in the
|
||||
comb's collapse zone. Qwen therefore carries its OWN 2-rung ladder
|
||||
(``TextMarkConfig.ladder``), one rung centred on each mode; the shared
|
||||
(``TextMarkConfig.ladder``), one rung centered on each mode; the shared
|
||||
default is untouched for every other mark.
|
||||
* The mark also sits FARTHER off the corner than Doubao's box assumes (right
|
||||
margin ~0.025 vs 0.004 of the short side), so Doubao's locate box clipped the
|
||||
@@ -38,7 +38,7 @@ whose producer USCC 91440101MA9Y9T4H7A names the entity, 2026-07-21; harness
|
||||
arm would be mostly false fills. No provenance relaxation exists for this
|
||||
mark.
|
||||
* No rival margin: at the shipped gate the template fires on 0 of 400
|
||||
Doubao-marked frames, 0 of 298 Jimeng-marked frames and 0 of 286 hand-labelled
|
||||
Doubao-marked frames, 0 of 298 Jimeng-marked frames and 0 of 286 hand-labeled
|
||||
clean frames (the shared tail correlates at ~0.22, far below the gate), while
|
||||
a 0.10 rival margin would have suppressed ~10% of genuine Qwen detections.
|
||||
"""
|
||||
@@ -74,7 +74,7 @@ LOGO_MIN_LUMA = 150
|
||||
TOPHAT_DELTA = 12
|
||||
|
||||
DETECT_MIN_COVERAGE = 0.04 # unused by the tophat front-end (kept for config parity)
|
||||
# Calibrated 2026-07-21 on the vendor cohort vs 286 hand-labelled clean frames
|
||||
# Calibrated 2026-07-21 on the vendor cohort vs 286 hand-labeled clean frames
|
||||
# (cohort-contamination-guarded): clean p99 0.301 / max 0.316, and every cohort
|
||||
# frame scoring >= 0.45 carries a visible 千问AI生成 mark (86% of the eyeballed
|
||||
# visible marks fire, the misses being white-on-near-white contrast losses).
|
||||
|
||||
@@ -32,7 +32,7 @@ whose producer USCC names the entity, harvested 2026-07-22 by
|
||||
* STRICT ONLY (``provenance_ncc_factor`` 1.0): raw gray NCC is
|
||||
contrast-DEPENDENT and the sub-gate band of a corner-anchored gray match is
|
||||
unmeasured beyond the clean arm, so no provenance relaxation exists.
|
||||
* Gate 0.34: on 283 hand-labelled clean frames (cohort-contamination-guarded)
|
||||
* Gate 0.34: on 283 hand-labeled clean frames (cohort-contamination-guarded)
|
||||
corner-anchored gray NCC p99 is 0.264 / max 0.304, while the 4 positives
|
||||
score 0.38-0.54. 0.34 sits above the clean max with a small margin; the
|
||||
positives are few, so the margin is deliberately thin on the recall side.
|
||||
@@ -75,14 +75,14 @@ LOGO_MIN_LUMA = 150
|
||||
TOPHAT_DELTA = 12
|
||||
|
||||
DETECT_MIN_COVERAGE = 0.04 # unused by the gray front-end (kept for config parity)
|
||||
# Calibrated 2026-07-22 on the vendor cohort vs 283 hand-labelled clean frames:
|
||||
# Calibrated 2026-07-22 on the vendor cohort vs 283 hand-labeled clean frames:
|
||||
# corner-anchored gray NCC, clean p99 0.264 / max 0.304; positives 0.38-0.54.
|
||||
DETECT_NCC_THRESHOLD = 0.34
|
||||
|
||||
# Detection-silhouette geometry (fraction of the image width), measured on the
|
||||
# positives: mark width is ~0.320 of width on all three frame sizes (266px at 832,
|
||||
# 345px at 1080, 491px at 1536), and the NCC is razor-sharp in size (0.537 on-size,
|
||||
# 0.223 at +5.6% -- the same comb behaviour Qwen measured), so the nominal sits
|
||||
# 0.223 at +5.6% -- the same comb behavior Qwen measured), so the nominal sits
|
||||
# exactly on the measured size with a TIGHT ladder around it, not the shared 3 rungs
|
||||
# (whose nearest rung landed 5.6% off and collapsed the match to 0.22).
|
||||
_ALPHA_WIDTH_FRAC = 0.32
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""High-level video processing API.
|
||||
|
||||
The product path covers provenance identification, container-level AI metadata
|
||||
removal, temporally stabilized visible Sora, Veo, Seedance, Dola, Hailuo, and
|
||||
Kling removal, and an oracle-certified opt-in VAE profile for video SynthID.
|
||||
removal, temporally stabilized visible Sora, Veo, Seedance, Dola, Hailuo AI, and
|
||||
Kling AI removal, and an oracle-certified opt-in VAE profile for video SynthID.
|
||||
The visible pixel path reuses the image package's shared fill backends.
|
||||
"""
|
||||
|
||||
@@ -179,8 +179,8 @@ _VISIBLE_PLATFORM = {
|
||||
"veo": "Google Veo",
|
||||
"seedance": "ByteDance Seedance",
|
||||
"dola": "ByteDance Dola",
|
||||
"hailuo": "MiniMax Hailuo",
|
||||
"kling": "Kuaishou Kling",
|
||||
"hailuo": "MiniMax Hailuo AI",
|
||||
"kling": "Kuaishou Kling AI",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ Supported marks use fully synthetic silhouettes made from geometric primitives,
|
||||
OpenCV's built-in font, and Pillow's bundled font. Sora detection searches the
|
||||
full frame because the wordmark moves. Veo detection covers both the current
|
||||
four-point diamond and legacy ``Veo`` text. Seedance detects the boxed ``AI``
|
||||
label, Dola detects its compact text label, Hailuo detects the composite
|
||||
MINIMAX/Hailuo label, and Kling detects its version-independent wordmark core.
|
||||
label, Dola detects its compact text label, Hailuo AI detects the composite
|
||||
MINIMAX/Hailuo AI label, and Kling AI detects its version-independent wordmark core.
|
||||
A single frame is never enough to authorize removal: the temporal arbiter
|
||||
requires the candidate to recur at the same location across adjacent frames.
|
||||
This keeps isolated lookalikes in clean videos from becoming removal masks.
|
||||
|
||||
@@ -17,17 +17,18 @@ localizer stays cheap (cv2/numpy, CPU) so a memory-tight caller can run it on a
|
||||
small worker; the heavy fill (MI-GAN / LaMa) is opt-in and chosen by the caller.
|
||||
|
||||
Entries:
|
||||
- ``gemini`` -- Google Gemini / Nano Banana sparkle, bottom-right.
|
||||
- ``gemini`` -- Google Gemini / Nano Banana visible watermark (sparkle), bottom-right.
|
||||
- ``doubao`` -- ByteDance Doubao "豆包AI生成" text strip, bottom-right.
|
||||
- ``jimeng`` -- ByteDance Jimeng / Dreamina "★ 即梦AI" wordmark, bottom-right.
|
||||
- ``qwen`` -- Alibaba Qwen "千问AI生成" text strip, bottom-right.
|
||||
- ``kling`` -- Kuaishou Kling "可灵AI 3.0" text strip, bottom-right.
|
||||
- ``qwen`` -- Alibaba Cloud Qwen "千问AI生成" text strip, bottom-right.
|
||||
- ``kling`` -- Kuaishou Kling AI "可灵AI 3.0" text strip, bottom-right.
|
||||
- ``yuanbao`` -- Tencent Yuanbao "元宝 / AI生成" two-line mark, bottom-right.
|
||||
- ``samsung`` -- Samsung Galaxy AI "Contenuti generati dall'AI" strip, bottom-left.
|
||||
- ``jimeng_pill`` -- Jimeng-basic "AI生成" pill, top-left (capture-less).
|
||||
- ``runninghub`` -- RunningHub "RunningHub AI生成" text, top-left (gray front-end).
|
||||
- ``baidu`` -- Baidu "百度 AI生成" text + white tag, bottom-right.
|
||||
- ``liblib`` -- LibLibAI "LibLibAI" wordmark, bottom-center.
|
||||
- ``liblib`` -- LiblibAI "LiblibAI" wordmark, bottom-center.
|
||||
- ``microsoft`` -- one measured Microsoft white AI-badge variant, top-right.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -147,7 +148,7 @@ _REMOVED_SENSITIVITIES = {
|
||||
"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."
|
||||
"the default evidence-driven behavior."
|
||||
)
|
||||
}
|
||||
|
||||
@@ -156,7 +157,7 @@ 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 --
|
||||
upgrading from 0.15 would pass ``"assume_ai"`` and quietly get ``auto`` behavior --
|
||||
a silent semantic change on the one release where they most need to be told.
|
||||
"""
|
||||
if value in _REMOVED_SENSITIVITIES:
|
||||
@@ -368,7 +369,7 @@ _GEMINI_AUTO_MIN_CONF = GEMINI_SPARKLE_TRUST_CONF
|
||||
# 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%,
|
||||
# metadata), hand-labeled against a two-sided control (labeler sensitivity ~88%,
|
||||
# specificity 100%). "Additions" = accepted with provenance but not without:
|
||||
#
|
||||
# band precision 95% CI population
|
||||
@@ -409,6 +410,7 @@ _ENGINE_CLASS: dict[str, tuple[str, str]] = {
|
||||
"runninghub": ("runninghub_engine", "RunningHubEngine"),
|
||||
"baidu": ("baidu_engine", "BaiduEngine"),
|
||||
"liblib": ("liblib_engine", "LibLibEngine"),
|
||||
"microsoft": ("microsoft_engine", "MicrosoftEngine"),
|
||||
}
|
||||
|
||||
|
||||
@@ -485,7 +487,14 @@ def fill(image: NDArray[Any], mask: NDArray[Any], *, backend: Backend = "auto")
|
||||
def _gemini_wrap(d: Any, *, provenance: bool) -> MarkDetection:
|
||||
gate = _GEMINI_PROVENANCE_MIN_CONF if provenance else _GEMINI_AUTO_MIN_CONF
|
||||
detected = bool(d.detected) and d.confidence >= gate
|
||||
return MarkDetection("gemini", "Google Gemini sparkle", "bottom-right", detected, d.confidence, d.region)
|
||||
return MarkDetection(
|
||||
"gemini",
|
||||
"Google Gemini visible watermark (sparkle)",
|
||||
"bottom-right",
|
||||
detected,
|
||||
d.confidence,
|
||||
d.region,
|
||||
)
|
||||
|
||||
|
||||
def _gemini_detect(image: NDArray[Any], *, provenance: bool = False) -> MarkDetection:
|
||||
@@ -561,12 +570,14 @@ def _text_mark(
|
||||
label_regime: str | None = "tc260",
|
||||
provenance_signals: tuple[str, ...] = ("aigc",),
|
||||
tc260_producer_codes: tuple[str, ...] = (),
|
||||
provenance_platform_tokens: tuple[str, ...] = (),
|
||||
) -> KnownMark:
|
||||
"""Build a text-mark registry row from its shared detector and mask adapters.
|
||||
|
||||
``product`` defaults to the key (one mark, one product); pass it only when two
|
||||
marks share a product. ``label_regime`` and ``provenance_signals`` default to the
|
||||
China-AIGC label because every text mark registered so far except Samsung uses it.
|
||||
China-AIGC label because every text mark registered so far except Samsung and
|
||||
Microsoft uses it.
|
||||
"""
|
||||
return KnownMark(
|
||||
key,
|
||||
@@ -580,6 +591,7 @@ def _text_mark(
|
||||
_text_mark_mask(key),
|
||||
provenance_signals=provenance_signals,
|
||||
tc260_producer_codes=tc260_producer_codes,
|
||||
provenance_platform_tokens=provenance_platform_tokens,
|
||||
_detect_both=_text_mark_detect_both(key, label, location),
|
||||
)
|
||||
|
||||
@@ -616,11 +628,11 @@ def _pill_features(image: NDArray[Any]) -> dict[str, float]:
|
||||
|
||||
|
||||
_REGISTRY: tuple[KnownMark, ...] = (
|
||||
# Gemini is a Google C2PA/SynthID product, not a China-AIGC labeller: label_regime
|
||||
# Gemini is a Google C2PA/SynthID product, not a China-AIGC labeler: label_regime
|
||||
# is None so it can never act as a TC260 sibling in _keep_pill.
|
||||
KnownMark(
|
||||
"gemini",
|
||||
"Google Gemini sparkle",
|
||||
"Google Gemini visible watermark (sparkle)",
|
||||
"bottom-right",
|
||||
True,
|
||||
"gemini",
|
||||
@@ -651,14 +663,14 @@ _REGISTRY: tuple[KnownMark, ...] = (
|
||||
"qwen",
|
||||
"Qwen 千问AI生成 text",
|
||||
"bottom-right",
|
||||
platform="Alibaba Qwen (visible 千问AI生成 mark detected)",
|
||||
platform="Alibaba Cloud Qwen (visible 千问AI生成 mark detected)",
|
||||
tc260_producer_codes=("91440101MA9Y9T4H7A",),
|
||||
),
|
||||
_text_mark(
|
||||
"kling",
|
||||
"Kling 可灵AI 3.0 text",
|
||||
"Kling AI 可灵AI 3.0 text",
|
||||
"bottom-right",
|
||||
platform="Kuaishou Kling (visible 可灵AI 3.0 mark detected)",
|
||||
platform="Kuaishou Kling AI (visible 可灵AI 3.0 mark detected)",
|
||||
tc260_producer_codes=("91110108335469089C",),
|
||||
),
|
||||
_text_mark(
|
||||
@@ -693,11 +705,23 @@ _REGISTRY: tuple[KnownMark, ...] = (
|
||||
),
|
||||
_text_mark(
|
||||
"liblib",
|
||||
"LibLibAI wordmark",
|
||||
"LiblibAI wordmark",
|
||||
"bottom-center",
|
||||
platform="LibLibAI (visible LibLibAI mark detected)",
|
||||
platform="LiblibAI (visible LiblibAI mark detected)",
|
||||
tc260_producer_codes=("91110105MACJ6K1C8A",),
|
||||
),
|
||||
# One measured Microsoft visible-mark variant: a white top-right pill with
|
||||
# dark internal shapes. Microsoft's documented feature also permits other
|
||||
# icon, text, and placement variants, which this detector does not cover.
|
||||
_text_mark(
|
||||
"microsoft",
|
||||
"Microsoft top-right AI badge",
|
||||
"top-right",
|
||||
label_regime=None,
|
||||
provenance_signals=(),
|
||||
platform="Microsoft (visible top-right AI badge detected)",
|
||||
provenance_platform_tokens=("microsoft",),
|
||||
),
|
||||
# Same product as the Jimeng wordmark -- the one pair that cross-relaxes.
|
||||
KnownMark(
|
||||
"jimeng_pill",
|
||||
@@ -794,7 +818,7 @@ def tc260_producer_vendors() -> dict[str, str]:
|
||||
def _pill_suppressors() -> set[str]:
|
||||
"""Marks whose detection vetoes the capture-less pill: same label regime as the
|
||||
pill, different product. Derived so a newly registered TC260 mark cannot be
|
||||
forgotten here -- which is exactly how LibLibAI ended up missing."""
|
||||
forgotten here -- which is exactly how LiblibAI ended up missing."""
|
||||
pill = get_mark("jimeng_pill")
|
||||
return {
|
||||
m.key
|
||||
@@ -822,9 +846,9 @@ def _keep_pill(keys: set[str], *, provenance: frozenset[str], footprint_flat: bo
|
||||
No confirmation at all -> never remove (blocks false fires on non-Jimeng content).
|
||||
|
||||
The suppressor set is DERIVED from the registry (same label regime, different
|
||||
product), not hand-listed. The hand-written list had drifted: LibLibAI was
|
||||
product), not hand-listed. The hand-written list had drifted: LiblibAI was
|
||||
registered alongside RunningHub and Baidu but never added to it, so a confident
|
||||
LibLibAI detection did not veto the pill the way its two siblings did. Marks
|
||||
LiblibAI detection did not veto the pill the way its two siblings did. Marks
|
||||
outside the TC260 regime (Gemini, Samsung) are deliberately NOT suppressors --
|
||||
neither can put ``"jimeng"`` into ``provenance``, so neither can enable the arm
|
||||
they would be vetoing."""
|
||||
|
||||
Reference in New Issue
Block a user