Rewrite internal watermark pipeline and preserve behavior

This commit is contained in:
Victor Kuznetsov
2026-07-31 16:53:41 -07:00
parent 7c922e5133
commit a6c0c1c6f0
54 changed files with 2266 additions and 3771 deletions
@@ -1,10 +1,8 @@
"""Vendored noai-watermark code for invisible watermark removal.
Original: https://github.com/mertizci/noai-watermark (MIT License)
"""Compatibility namespace for metadata and regeneration helpers.
The public API (``WatermarkRemover`` / ``remove_watermark`` / ``remove_ai_metadata``)
is exposed **lazily** via PEP 562 ``__getattr__``: importing a light submodule
(e.g. ``noai.c2pa`` / ``noai.constants`` from ``identify``) must NOT eagerly pull
(e.g. ``_internal.c2pa`` / ``_internal.constants`` from ``identify``) must NOT eagerly pull
``watermark_remover``, which imports torch + diffusers at module top. Keeping this
lazy is what lets ``import remove_ai_watermarks.identify`` stay cheap (~36 MB, no
torch) even in a full install where the ``diffusion`` extra is present --
@@ -17,8 +15,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from remove_ai_watermarks._internal.watermark_remover import WatermarkRemover, remove_watermark
from remove_ai_watermarks.metadata import remove_ai_metadata
from remove_ai_watermarks.noai.watermark_remover import WatermarkRemover, remove_watermark
__all__ = ["WatermarkRemover", "remove_ai_metadata", "remove_watermark"]
@@ -27,12 +25,12 @@ def __getattr__(name: str) -> object:
"""Resolve the public API on first access (PEP 562), not at package import."""
if name == "remove_ai_metadata":
# Re-export the single, robust stripper (byte-level, lossless-for-JPEG, all
# containers); the old noai.cleaner implementation is retired.
# containers); the old legacy metadata helper implementation is retired.
from remove_ai_watermarks.metadata import remove_ai_metadata
return remove_ai_metadata
if name in ("WatermarkRemover", "remove_watermark"):
from remove_ai_watermarks.noai import watermark_remover
from remove_ai_watermarks._internal import watermark_remover
return getattr(watermark_remover, name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+363
View File
@@ -0,0 +1,363 @@
"""C2PA inspection through the official reader with a bounded PNG fallback."""
from __future__ import annotations
import contextlib
import functools
import json
import logging
import re
import struct
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
from remove_ai_watermarks._internal.constants import (
C2PA_ACTIONS,
C2PA_AI_TOOLS,
C2PA_CHUNK_TYPE,
C2PA_ISSUERS,
C2PA_SIGNATURES,
C2PA_SOFT_BINDINGS,
PNG_SIGNATURE,
SYNTHID_C2PA_ISSUERS,
)
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from typing import BinaryIO
_C2paReader: Any = None
with contextlib.suppress(Exception):
from c2pa import Reader as _C2paReader # pyright: ignore[reportMissingTypeStubs]
_C2PA_READER_AVAILABLE = _C2paReader is not None
_PNG_HEADER = struct.Struct(">I4s")
@dataclass(frozen=True)
class _PngChunk:
payload: bytes
serialized: bytes
def reader_available() -> bool:
"""Return whether the official C2PA reader loaded successfully."""
return _C2PA_READER_AVAILABLE
def _manifest_json_uncached(path: str) -> str | None:
try:
reader = _C2paReader.try_create(path)
except Exception as error:
logger.debug("C2PA reader rejected %s: %s", path, error)
return None
if reader is None:
return None
try:
with reader:
return cast("str", reader.json())
except Exception as error:
logger.debug("C2PA reader could not serialize %s: %s", path, error)
return None
@functools.lru_cache(maxsize=8)
def _manifest_json_cached(path: str, _mtime_ns: int) -> str | None:
return _manifest_json_uncached(path)
def read_manifest_store_json(image_path: Path) -> str | None:
"""Read the complete manifest-store JSON, caching it until the file changes."""
if not reader_available():
return None
path = str(image_path)
try:
return _manifest_json_cached(path, image_path.stat().st_mtime_ns)
except OSError:
return _manifest_json_uncached(path)
def _find_c2pa_chunk(path: Path) -> _PngChunk | None:
"""Return the first recognizable C2PA chunk without loading the whole PNG."""
try:
stream = path.open("rb")
except OSError:
return None
with stream:
if stream.read(len(PNG_SIGNATURE)) != PNG_SIGNATURE:
return None
file_size = stream.seek(0, 2)
stream.seek(len(PNG_SIGNATURE))
while True:
header = stream.read(_PNG_HEADER.size)
if len(header) != _PNG_HEADER.size:
return None
length, kind = _PNG_HEADER.unpack(header)
if length + 4 > file_size - stream.tell():
return None
if kind == C2PA_CHUNK_TYPE:
payload = stream.read(length)
crc = stream.read(4)
if _looks_like_c2pa(payload):
return _PngChunk(payload, header + payload + crc)
else:
stream.seek(length + 4, 1)
if kind == b"IEND":
return None
def _is_well_formed_png(path: Path) -> bool:
"""Validate PNG chunk bounds with seeks rather than payload allocations."""
try:
stream = path.open("rb")
except OSError:
return False
with stream:
if stream.read(len(PNG_SIGNATURE)) != PNG_SIGNATURE:
return False
file_size = stream.seek(0, 2)
stream.seek(len(PNG_SIGNATURE))
while True:
header = stream.read(_PNG_HEADER.size)
if len(header) != _PNG_HEADER.size:
return False
length, kind = _PNG_HEADER.unpack(header)
if length + 4 > file_size - stream.tell():
return False
stream.seek(length + 4, 1)
if kind == b"IEND":
return True
def _copy_bytes(source: BinaryIO, target: BinaryIO, byte_count: int) -> None:
"""Copy exactly one bounded chunk without allocating its complete payload."""
remaining = byte_count
while remaining:
block = source.read(min(remaining, 1024 * 1024))
if not block:
raise OSError("PNG changed while it was being copied")
target.write(block)
remaining -= len(block)
def _looks_like_c2pa(payload: bytes) -> bool:
lowered = payload.lower()
return any(signature in payload for signature in C2PA_SIGNATURES) or b"c2pa" in lowered or b"jumb" in lowered
def extract_c2pa_chunk(image_path: Path) -> bytes | None:
"""Return the first complete C2PA PNG chunk, including header and CRC."""
if image_path.suffix.casefold() != ".png":
return None
chunk = _find_c2pa_chunk(image_path)
return None if chunk is None else chunk.serialized
def has_c2pa_metadata(image_path: Path) -> bool:
"""Return whether a validly bounded PNG contains a recognizable C2PA chunk."""
return extract_c2pa_chunk(Path(image_path)) is not None
def _active_manifest(store: dict[str, Any]) -> dict[str, Any]:
manifests = store.get("manifests")
if not isinstance(manifests, dict):
return {}
typed_manifests = cast("dict[object, object]", manifests)
active = typed_manifests.get(store.get("active_manifest"))
return cast("dict[str, Any]", active) if isinstance(active, dict) else {}
def _claim_generator_from_store(store: dict[str, Any]) -> str | None:
active = _active_manifest(store)
direct = active.get("claim_generator")
if isinstance(direct, str) and direct.isprintable() and direct:
return direct
candidates = active.get("claim_generator_info")
if isinstance(candidates, list) and candidates and isinstance(candidates[0], dict):
candidate = cast("dict[object, object]", candidates[0])
name = candidate.get("name")
if isinstance(name, str) and name.isprintable() and name:
return name
return None
def synthid_verdict(vendors: str) -> str:
"""Describe why metadata implies a likely pixel-level SynthID watermark."""
return f"likely present ({vendors} embeds SynthID with C2PA)"
def _names_present(buffer: bytes, registry: dict[bytes, str]) -> list[str]:
return sorted({label for token, label in registry.items() if token in buffer})
def synthid_vendors_in(buffer: bytes) -> list[str]:
"""List matching C2PA issuers known to pair their manifests with SynthID."""
registry = {token: label for token, label in C2PA_ISSUERS.items() if token in SYNTHID_C2PA_ISSUERS}
return _names_present(buffer, registry)
def soft_binding_vendors_in(buffer: bytes) -> list[str]:
"""List the soft-binding algorithms named in manifest bytes."""
return _names_present(buffer, C2PA_SOFT_BINDINGS)
def _ordered_matches(buffer: bytes, registry: dict[bytes, str]) -> list[str]:
return list(dict.fromkeys(label for token, label in registry.items() if token in buffer))
def _populate_registry_fields(buffer: bytes, info: dict[str, Any]) -> bool:
issuers = _ordered_matches(buffer, C2PA_ISSUERS)
tools = _ordered_matches(buffer, C2PA_AI_TOOLS)
actions = _ordered_matches(buffer, C2PA_ACTIONS)
if issuers:
info["issuer"] = ", ".join(issuers)
if tools:
info["ai_tool"] = ", ".join(tools)
if actions:
info["actions"] = ", ".join(actions)
ai_source = False
if b"trainedAlgorithmicMedia" in buffer:
info.update(source_type="trainedAlgorithmicMedia (AI-generated)", ai_source_kind="generated")
ai_source = True
elif b"compositeWithTrainedAlgorithmicMedia" in buffer:
info.update(source_type="compositeWithTrainedAlgorithmicMedia (AI-enhanced)", ai_source_kind="enhanced")
ai_source = True
elif b"algorithmicMedia" in buffer:
info["source_type"] = "algorithmicMedia"
synthid = synthid_vendors_in(buffer)
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)
return ai_source
def _base_info(byte_count: int, *, fallback: bool = False) -> dict[str, Any]:
container = "C2PA manifest" if fallback else "C2PA manifest store"
return {
"has_c2pa": True,
"type": "C2PA (Coalition for Content Provenance and Authenticity)",
"c2pa_manifest": f"{container} ({byte_count} bytes)",
}
def _info_from_store(store: dict[str, Any], encoded: bytes) -> dict[str, Any]:
info = _base_info(len(encoded))
_populate_registry_fields(encoded, info)
generator = _claim_generator_from_store(store)
if generator is not None:
info["claim_generator"] = generator
signature_value = _active_manifest(store).get("signature_info")
if isinstance(signature_value, dict):
signature = cast("dict[object, object]", signature_value)
timestamp = signature.get("time")
if timestamp:
info["timestamp"] = str(timestamp)
return info
def c2pa_info_from_manifest_store(store: str | dict[str, Any]) -> dict[str, Any]:
"""Normalize a manifest store supplied as JSON text or a decoded object."""
try:
raw_decoded: object = store if isinstance(store, dict) else json.loads(store)
decoded = cast("dict[str, Any]", raw_decoded) if isinstance(raw_decoded, dict) else None
if not isinstance(decoded, dict) or not decoded or decoded.get("error"):
return {}
encoded = json.dumps(decoded, ensure_ascii=False).encode() if isinstance(store, dict) else store.encode()
except (TypeError, ValueError):
return {}
return _info_from_store(decoded, encoded)
def cbor_text_after(payload: bytes, key: bytes) -> str | None:
"""Decode a definite-length CBOR text value immediately following ``key``."""
key_end = payload.find(key)
if key_end < 0:
return None
cursor = key_end + len(key)
if cursor >= len(payload):
return None
initial = payload[cursor]
if 0x60 <= initial <= 0x77:
length, cursor = initial & 0x1F, cursor + 1
elif initial == 0x78 and cursor + 1 < len(payload):
length, cursor = payload[cursor + 1], cursor + 2
elif initial == 0x79 and cursor + 2 < len(payload):
length = int.from_bytes(payload[cursor + 1 : cursor + 3], "big")
cursor += 3
else:
return None
raw = payload[cursor : cursor + length]
if len(raw) != length:
return None
try:
return raw.decode()
except UnicodeDecodeError:
return raw.decode("latin1", errors="replace")
def _parse_c2pa_chunk(payload: bytes, info: dict[str, Any]) -> None:
info.update(_base_info(len(payload), fallback=True))
_populate_registry_fields(payload, info)
for key, output_key in ((b"name", "claim_generator"), (b"specVersion", "c2pa_spec")):
value = cbor_text_after(payload, key)
if value and value.isprintable():
info[output_key] = value
timestamps = [item.decode() for item in re.findall(rb"\d{14}Z", payload)]
if timestamps:
info["timestamp"] = timestamps[0]
if len(timestamps) > 1:
info["timestamps"] = timestamps[:3]
def _extract_c2pa_info_png(image_path: Path) -> dict[str, Any]:
if image_path.suffix.casefold() != ".png":
return {}
chunk = _find_c2pa_chunk(image_path)
if chunk is None:
return {}
info: dict[str, Any] = {}
_parse_c2pa_chunk(chunk.payload, info)
return info
def extract_c2pa_info(image_path: Path) -> dict[str, Any]:
"""Return normalized C2PA evidence from the official reader or PNG fallback."""
store = read_manifest_store_json(Path(image_path))
if store is not None:
return c2pa_info_from_manifest_store(store)
return _extract_c2pa_info_png(Path(image_path))
def inject_c2pa_chunk(target_path: Path, output_path: Path, c2pa_chunk: bytes) -> None:
"""Replace any C2PA chunks in a PNG and insert ``c2pa_chunk`` before IDAT."""
if target_path.suffix.casefold() != ".png" or output_path.suffix.casefold() != ".png":
raise ValueError("C2PA chunk injection is only supported for PNG files")
if not _is_well_formed_png(target_path):
raise ValueError("Target is not a well-formed PNG file")
output_path.parent.mkdir(parents=True, exist_ok=True)
with target_path.open("rb") as source, output_path.open("wb") as target:
target.write(source.read(len(PNG_SIGNATURE)))
inserted = False
while True:
header = source.read(_PNG_HEADER.size)
length, kind = _PNG_HEADER.unpack(header)
if kind == b"IDAT" and not inserted:
target.write(c2pa_chunk)
inserted = True
if kind == C2PA_CHUNK_TYPE:
source.seek(length + 4, 1)
else:
target.write(header)
_copy_bytes(source, target, length + 4)
if kind == b"IEND":
break
@@ -0,0 +1,152 @@
"""Registries shared by metadata extraction and provenance classification."""
from __future__ import annotations
from dataclasses import dataclass
def _tokens(value: str) -> tuple[str, ...]:
return tuple(value.split("|"))
SUPPORTED_FORMATS = frozenset(_tokens(".png|.jpg|.jpeg|.webp|.heic|.heif|.avif"))
AI_METADATA_KEYS = _tokens(
"parameters|postprocessing|extras|workflow|prompt|Dream|SD:mode|StableDiffusionVersion|"
"generation_time|Model|Model hash|Seed"
)
PNG_METADATA_KEYS = _tokens(
"Author|Title|Description|Copyright|Creation Time|Software|Disclaimer|Warning|Source|Comment"
)
AI_KEYWORDS = _tokens(
"prompt|negative_prompt|sampler|cfg_scale|lora|diffusion|comfy|midjourney|dall-e|dalle|imagen|firefly|c2pa|chatgpt|gpt-4|sora|openai|truepic|stable_diffusion|invokeai"
)
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
C2PA_CHUNK_TYPE = b"caBX"
C2PA_SIGNATURES = tuple(
token.encode() for token in _tokens("c2pa|C2PA|jumb|jumd|JUMBF|jumbf|cbor|contentcreds|digid|assertions|manifest")
)
@dataclass(frozen=True, slots=True)
class C2paAiVendor:
"""One issuer signature and its normalized product attribution."""
issuer: bytes
org: str
platform: str | None
needle: str | None
synthid: bool = False
asserts_ai: bool = False
def _vendor(
issuer: bytes | str,
org: str,
platform: str | None,
needle: str | None,
*,
synthid: bool = False,
asserts_ai: bool = False,
) -> C2paAiVendor:
token = issuer.encode() if isinstance(issuer, str) else issuer
return C2paAiVendor(token, org, platform, needle, synthid, asserts_ai)
# Order is product priority when a manifest mentions more than one organization.
C2PA_AI_VENDORS: tuple[C2paAiVendor, ...] = (
_vendor(b"Microsoft", "Microsoft", "Microsoft (Bing Image Creator / Designer)", "Microsoft"),
_vendor(b"Adobe", "Adobe", "Adobe Firefly", "Adobe"),
_vendor(b"OpenAI", "OpenAI", "OpenAI (ChatGPT / gpt-image / DALL-E / Sora)", "OpenAI", synthid=True),
_vendor(b"Google", "Google LLC", "Google (Gemini / Imagen)", "Google", synthid=True),
_vendor(b"Stability AI", "Stability AI", "Stability AI (Stable Image / DreamStudio)", "Stability AI"),
_vendor(b"Black Forest Labs", "Black Forest Labs", "Black Forest Labs (FLUX)", "Black Forest Labs"),
_vendor(b"volcengine", "ByteDance (Volcano Engine)", "ByteDance (Doubao / Jimeng / Volcano Engine)", "ByteDance"),
_vendor(
"北京火山引擎科技有限公司",
"ByteDance (Volcano Engine)",
"ByteDance (Doubao / Jimeng / Volcano Engine)",
"ByteDance",
),
_vendor(b"Byteplus", "BytePlus (ByteDance)", "ByteDance (Doubao / Jimeng / Volcano Engine)", "ByteDance"),
_vendor(
b"Dreamina",
"ByteDance (Dreamina)",
"ByteDance (Doubao / Jimeng / Volcano Engine)",
"ByteDance",
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),
_vendor(b"Truepic", "Truepic", None, None),
)
C2PA_ISSUERS = {vendor.issuer: vendor.org for vendor in C2PA_AI_VENDORS}
C2PA_IDENTITY_AI_ORGS = frozenset(vendor.org for vendor in C2PA_AI_VENDORS if vendor.asserts_ai)
SYNTHID_C2PA_ISSUERS = frozenset(vendor.issuer for vendor in C2PA_AI_VENDORS if vendor.synthid)
C2PA_AI_TOOLS = {
token.encode(): label
for token, label in (
("GPT-4o", "GPT-4o"),
("ChatGPT", "ChatGPT"),
("Sora", "Sora"),
("DALL-E", "DALL-E"),
("DALL", "DALL-E"),
("Imagen", "Imagen"),
("Firefly", "Firefly"),
)
}
C2PA_SOFT_BINDINGS = {
b"com.adobe.trustmark": "Adobe TrustMark",
b"com.adobe.icn": "Adobe (content fingerprint)",
b"com.digimarc": "Digimarc",
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"app.overlai": "Overlai",
b"com.markany": "MarkAny",
b"com.mentaport": "Mentaport",
b"es.lumatrace": "LumaTrace",
b"ai.verda": "VerdaAI",
b"ai.contentlens": "ContentLens",
b"io.iscc": "ISCC (content code)",
}
AI_GENERATOR_TOKENS = frozenset(
{
"firefly",
"dall-e",
"dalle",
"midjourney",
"stable diffusion",
"stable-diffusion",
"stablediffusion",
"comfyui",
"automatic1111",
"invokeai",
"imagen",
"gpt-image",
"nightcafe",
"ideogram",
"leonardo",
"flux",
"dreamstudio",
"novelai",
"reve.com",
"aphrodite ai",
"apple photos clean up",
"fal-ai",
}
)
_C2PA_ACTION_NAMES = _tokens("created|converted|edited|filtered|cropped|resized|opened|placed")
C2PA_ACTIONS = {f"c2pa.{action}".encode(): action for action in _C2PA_ACTION_NAMES}
@@ -0,0 +1,98 @@
"""Read image metadata without changing the source container."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, cast
import piexif
from PIL import Image
from remove_ai_watermarks._internal.c2pa import extract_c2pa_chunk, extract_c2pa_info, has_c2pa_metadata
from remove_ai_watermarks._internal.constants import AI_KEYWORDS, AI_METADATA_KEYS
if TYPE_CHECKING:
from pathlib import Path
_EXIF_KEY = "exif"
_AI_KEYS_CASEFOLD = frozenset(key.casefold() for key in AI_METADATA_KEYS)
def _read_pillow_info(source_path: Path) -> dict[str, Any]:
with Image.open(source_path) as image:
return {key: value for key, value in image.info.items() if isinstance(key, str)}
def _decode_exif(raw: object) -> tuple[str, object]:
if not isinstance(raw, bytes):
return "exif_raw", raw
try:
return _EXIF_KEY, piexif.load(raw)
except Exception:
return "exif_raw", raw
def _is_ai_field(key: str) -> bool:
folded = key.casefold()
return folded in _AI_KEYS_CASEFOLD or any(token in folded for token in AI_KEYWORDS)
def _attach_c2pa(source_path: Path, metadata: dict[str, Any]) -> None:
if not has_c2pa_metadata(source_path):
return
metadata["c2pa"] = extract_c2pa_info(source_path)
payload = extract_c2pa_chunk(source_path)
if payload is not None:
metadata["c2pa_chunk"] = payload
def extract_metadata(source_path: Path) -> dict[str, Any]:
"""Return every Pillow-visible field plus decoded EXIF and C2PA data."""
raw_info = _read_pillow_info(source_path)
metadata = dict(raw_info)
if _EXIF_KEY in raw_info:
metadata.pop(_EXIF_KEY, None)
decoded_key, decoded_value = _decode_exif(raw_info[_EXIF_KEY])
metadata[decoded_key] = decoded_value
_attach_c2pa(source_path, metadata)
return metadata
def extract_ai_metadata(source_path: Path) -> dict[str, Any]:
"""Return only metadata keys recognized as AI provenance or generation data."""
metadata = {key: value for key, value in _read_pillow_info(source_path).items() if _is_ai_field(key)}
_attach_c2pa(source_path, metadata)
return metadata
def has_ai_metadata(image_path: Path) -> bool:
"""Return whether a supported metadata signal is present."""
if any(_is_ai_field(key) for key in _read_pillow_info(image_path)):
return True
return has_c2pa_metadata(image_path)
def _summary_value(value: object) -> str:
if isinstance(value, bytes):
return f"<binary data ({len(value)} bytes)>"
text = str(value)
return text if len(text) <= 100 else f"{text[:100]}..."
def get_ai_metadata_summary(source_path: Path) -> str:
"""Format the AI-only metadata view for the command-line report."""
metadata = extract_ai_metadata(source_path)
if not metadata:
return "No AI metadata found."
lines = ["AI Image Metadata:", "-" * 40]
for key, value in metadata.items():
if key == "c2pa_chunk":
continue
if key == "c2pa" and isinstance(value, dict):
lines.append("C2PA Metadata:")
c2pa_fields = cast("dict[str, object]", value)
lines.extend(f" {name}: {_summary_value(item)}" for name, item in c2pa_fields.items())
continue
lines.append(f"{key}: {_summary_value(value)}")
return "\n".join(lines)
@@ -0,0 +1,136 @@
"""Execute Diffusers img2img calls and recover from an MPS runtime failure."""
from __future__ import annotations
import contextlib
import logging
from typing import TYPE_CHECKING, Any
from remove_ai_watermarks._internal.progress import is_mps_error, make_pipeline_progress
if TYPE_CHECKING:
from collections.abc import Callable
from PIL import Image
logger = logging.getLogger(__name__)
def _pipeline_arguments(
image: Image.Image,
strength: float,
num_inference_steps: int,
guidance_scale: float,
generator: Any,
step_callback: Any,
overrides: dict[str, Any] | None,
) -> dict[str, Any]:
arguments: dict[str, Any] = {
"prompt": "",
"image": image,
"strength": strength,
"num_inference_steps": num_inference_steps,
"guidance_scale": guidance_scale,
"generator": generator,
}
arguments.update(overrides or {})
if step_callback is not None:
arguments.update(callback=step_callback, callback_steps=1)
return arguments
def _invoke(pipeline: Any, arguments: dict[str, Any]) -> Image.Image:
response = pipeline(**arguments)
return response.images[0]
def run_img2img(
pipeline: Any,
image: Image.Image,
strength: float,
num_inference_steps: int,
guidance_scale: float,
generator: Any,
device: str,
set_progress: Callable[[str], None],
extra_kwargs: dict[str, Any] | None = None,
) -> Image.Image:
"""Run one img2img request and report denoising progress when supported."""
callback, started, finished, launch_monitor = make_pipeline_progress(
max(1, int(num_inference_steps * strength)), device, set_progress
)
launch_monitor()
arguments = _pipeline_arguments(
image, strength, num_inference_steps, guidance_scale, generator, callback, extra_kwargs
)
try:
try:
return _invoke(pipeline, arguments)
except TypeError as error:
if "callback" not in str(error):
raise
started.set()
arguments.pop("callback", None)
arguments.pop("callback_steps", None)
return _invoke(pipeline, arguments)
finally:
started.set()
finished.set()
def run_img2img_with_mps_fallback(
load_pipeline: Callable[[], Any],
image: Image.Image,
strength: float,
num_inference_steps: int,
guidance_scale: float,
generator: Any,
device: str,
set_progress: Callable[[str], None],
*,
reload_on_cpu: Callable[[], Any],
extra_kwargs: dict[str, Any] | None = None,
) -> tuple[Image.Image, str]:
"""Retry an MPS-specific failure once with a freshly loaded CPU pipeline."""
try:
output = run_img2img(
load_pipeline(),
image,
strength,
num_inference_steps,
guidance_scale,
generator,
device,
set_progress,
extra_kwargs,
)
return output, device
except RuntimeError as error:
if device != "mps" or not is_mps_error(error):
raise
logger.warning("MPS execution failed (%s); retrying on CPU", error)
set_progress("MPS execution failed; retrying on CPU...")
try_empty_device_cache("mps")
output = run_img2img(
reload_on_cpu(),
image,
strength,
num_inference_steps,
guidance_scale,
None,
"cpu",
set_progress,
extra_kwargs,
)
return output, "cpu"
def try_empty_device_cache(device: str) -> None:
"""Ask Torch to release cached accelerator memory when the backend supports it."""
with contextlib.suppress(Exception):
import torch
backend = getattr(torch, device, None)
empty_cache = getattr(backend, "empty_cache", None)
if callable(empty_cache):
empty_cache()
@@ -0,0 +1,212 @@
"""Progress reporting utilities for long-running optional model operations."""
from __future__ import annotations
import contextlib
import io
import os
import sys
import threading
import time
import warnings
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Callable
_BAR_WIDTH = 28
_SPINNER = ("|", "/", "-", "\\")
def _truncate(text: str, max_len: int = 72) -> str:
if len(text) <= max_len:
return text
return f"{text[: max(0, max_len - 3)]}..."
def _build_bar(step: int) -> str:
position = step % (2 * _BAR_WIDTH - 2)
if position >= _BAR_WIDTH:
position = 2 * _BAR_WIDTH - 2 - position
cells = ["-"] * _BAR_WIDTH
cells[position] = "="
return "".join(cells)
@dataclass
class _TaskResult:
value: Any = None
error: BaseException | None = None
complete: threading.Event = field(default_factory=threading.Event)
def run_with_progress(task: Callable[[], Any], progress_state: dict[str, str] | None = None) -> Any:
"""Run ``task`` on a worker thread and render a compact terminal heartbeat."""
outcome = _TaskResult()
def invoke() -> None:
try:
outcome.value = task()
except BaseException as error: # re-raised on the caller thread
outcome.error = error
finally:
outcome.complete.set()
worker = threading.Thread(target=invoke, name="raiw-progress-task", daemon=True)
worker.start()
started_at = time.monotonic()
frame = 0
terminal = sys.__stderr__
while not outcome.complete.wait(0.1):
message = _truncate((progress_state or {}).get("message", "Processing..."))
elapsed = int(time.monotonic() - started_at)
if terminal is not None:
terminal.write(
f"\r\033[2K {_SPINNER[frame % len(_SPINNER)]} [{_build_bar(frame)}] {elapsed:>3}s {message}"
)
terminal.flush()
frame += 1
worker.join()
elapsed = int(time.monotonic() - started_at)
message = _truncate((progress_state or {}).get("message", "Processing..."))
if terminal is not None:
terminal.write(f"\r\033[2K Completed in {elapsed}s {message}\n")
terminal.flush()
if outcome.error is not None:
raise outcome.error
return outcome.value
def _silence_diffusers() -> None:
from diffusers.utils import logging as diffusers_logging
diffusers_logging.set_verbosity_error()
disable = getattr(diffusers_logging, "disable_progress_bar", None)
if callable(disable):
disable()
def _configure_quiet_libraries() -> None:
os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")
operations = (
lambda: __import__("transformers").logging.set_verbosity_error(),
_silence_diffusers,
lambda: __import__("huggingface_hub").logging.set_verbosity_error(),
)
for operation in operations:
with contextlib.suppress(Exception):
operation()
def silence_library_output(
run_func: Callable[[], Any],
set_progress: Callable[[str], None] | None = None,
) -> Callable[[], Any]:
"""Wrap a model call so third-party progress bars do not corrupt our CLI UI."""
def quiet_call() -> Any:
if set_progress is not None:
set_progress("Preparing model runtime...")
_configure_quiet_libraries()
with (
warnings.catch_warnings(),
contextlib.redirect_stdout(io.StringIO()),
contextlib.redirect_stderr(io.StringIO()),
):
warnings.simplefilter("ignore")
if set_progress is not None:
set_progress("Running watermark regeneration...")
return run_func()
return quiet_call
@dataclass
class _PipelineMonitor:
total_steps: int
device: str
update: Callable[[str], None]
bar_len: int
label: str
pre_phases: list[tuple[int, str]]
post_phases: list[tuple[int, str]]
first_step: threading.Event = field(default_factory=threading.Event)
done: threading.Event = field(default_factory=threading.Event)
started_at: float = field(default_factory=time.monotonic)
last_step_at: float = field(default_factory=time.monotonic)
def callback(self, step: int, _timestep: int, _latents: Any) -> None:
self.first_step.set()
now = time.monotonic()
self.last_step_at = now
current = min(self.total_steps, step + 1)
filled = round(self.bar_len * current / self.total_steps)
elapsed = now - self.started_at
eta = elapsed * max(0, self.total_steps - current) / max(1, current)
bar = "#" * filled + "." * (self.bar_len - filled)
self.update(
f"{self.label} [{bar}] {current}/{self.total_steps}, "
f"{elapsed:.0f}s elapsed, ~{eta:.0f}s left, {self.device}"
)
def _phase_message(self, phases: list[tuple[int, str]], elapsed: float) -> str:
message = phases[0][1]
for threshold, candidate in phases:
if elapsed < threshold:
break
message = candidate
return message
def monitor(self) -> None:
while not self.first_step.wait(0.4):
elapsed = time.monotonic() - self.started_at
self.update(self._phase_message(self.pre_phases, elapsed))
decode_started: float | None = None
while not self.done.wait(0.4):
if time.monotonic() - self.last_step_at < 1.5:
decode_started = None
continue
decode_started = decode_started or time.monotonic()
self.update(self._phase_message(self.post_phases, time.monotonic() - decode_started))
def start(self) -> threading.Thread:
self.started_at = self.last_step_at = time.monotonic()
self.first_step.clear()
self.done.clear()
thread = threading.Thread(target=self.monitor, name="raiw-pipeline-progress", daemon=True)
thread.start()
return thread
def make_pipeline_progress(
effective_steps: int,
device: str,
set_progress: Callable[[str], None],
*,
bar_len: int = 20,
label: str = "Denoising",
pre_phases: list[tuple[int, str]] | None = None,
post_phases: list[tuple[int, str]] | None = None,
) -> tuple[Callable[..., None], threading.Event, threading.Event, Callable[[], threading.Thread]]:
"""Build a callback and monitor for the legacy Diffusers callback interface."""
def qualify(entries: list[tuple[int, str]]) -> list[tuple[int, str]]:
return [(second, f"{text} on {device}") for second, text in entries]
monitor = _PipelineMonitor(
total_steps=max(1, effective_steps),
device=device,
update=set_progress,
bar_len=bar_len,
label=label,
pre_phases=pre_phases or qualify([(0, "Encoding image"), (8, "Preparing denoiser"), (20, "Starting sampler")]),
post_phases=post_phases or qualify([(0, "Decoding image"), (10, "Finalizing pixels"), (45, "Still decoding")]),
)
return monitor.callback, monitor.first_step, monitor.done, monitor.start
def is_mps_error(error: Exception) -> bool:
"""Return whether an error message identifies Apple's MPS backend."""
return "mps" in str(error).casefold()
@@ -1,17 +1,9 @@
"""Qwen 2512 Canny regeneration followed by masked Z-Image face repair.
"""Project-native Qwen regeneration with an optional masked face refinement pass.
This profile ports the two-stage architecture used by cebeuq/Synthid-Bypass:
1. Qwen-Image-2512 img2img with the 4-step Lightning LoRA and the DiffSynth
blockwise Canny ControlNet regenerates the whole image, optionally as
overlapping feather-blended tiles for large inputs.
2. Faces are detected on the original image, refined to masks with SAM, regenerated
from the original face crops with Z-Image Turbo, and feathered into stage 1.
The runtime intentionally uses permissively licensed YuNet instead of the reference
workflow's Ultralytics detector. All diffusion and segmentation models remain the same
model families. The adaptive formulas are direct ports, while the face result is scaled
for this runtime's different sampler and mask-compositing path.
The profile was inspired by public experiments that combine structure-guided global
regeneration with a second face-only pass. Its orchestration, sizing rules, adaptive
strength policy, detector, masks, prompts, and compositing are implemented here for
this library's Pillow and DiffSynth runtime.
"""
# DiffSynth, torch, transformers, and cv2 expose mostly untyped tensor/array APIs.
@@ -33,7 +25,7 @@ from typing import TYPE_CHECKING, Any
import numpy as np
from PIL import Image
from remove_ai_watermarks.noai.watermark_profiles import resolve_seed
from remove_ai_watermarks._internal.watermark_profiles import resolve_seed
if TYPE_CHECKING:
from collections.abc import Callable
@@ -53,10 +45,8 @@ YUNET_MODEL_URL = (
)
YUNET_MODEL_NAME = "face_detection_yunet_2023mar.onnx"
YUNET_MODEL_SHA256 = "8f2383e4dd3cfbb4553ea8718107fc0423210dc964f9f4280604804ed2552fa4"
# The upstream graph's 0.2 threshold belongs to YOLO and does not transfer to
# YuNet's score calibration. At 0.2 YuNet admitted background and decorative
# false positives, multiplying the serial Z-Image face-stage cost. A 0.5 gate
# retained all visible faces in the public and upstream comparison fixtures.
# This threshold retained the faces in the public validation set without admitting
# decorative background regions as faces.
YUNET_SCORE_THRESHOLD = 0.5
GLOBAL_STEPS = 4
@@ -65,19 +55,13 @@ GLOBAL_CFG = 1.0
FACE_CFG = 1.0
GLOBAL_CONTROLNET_SCALE = 1.0
RESIDENT_FACE_MODEL_MIN_VRAM_GIB = 64.0
# The reference face denoise assumes its ComfyUI detailer sampler, latent
# noise-mask feather, and inpaint path. Applying that value unchanged to this
# DiffSynth crop-regeneration port over-processes faces. Paired public-fixture
# measurements and both provider oracles certified half the reference value.
FACE_DENOISE_SCALE = 0.5
# The source graph uses normalized Canny thresholds 0.05 and 0.25. OpenCV takes
# byte thresholds, so round 255*x to the matching integer values.
_CANNY_LOW = 13
_CANNY_HIGH = 64
# These strings intentionally preserve the reference workflow spelling. They are
# model inputs, not user-facing copy, and changing them would change the port.
# These short model inputs are retained as calibrated compatibility parameters.
# Changing them requires the same provider-oracle and identity evaluation as a model change.
_GLOBAL_PROMPT = "ultra clear and smoothe skin, spotless skin"
_GLOBAL_NEGATIVE = "moles, freckes, high detail skin"
_FACE_PROMPT = ""
@@ -169,28 +153,18 @@ def resolution_adaptive_denoise(
denoise_min: float = 0.08,
denoise_max: float = 0.15,
) -> float:
"""Port the reference resolution-based adaptive denoise calculation.
At neutral level 5, 0.30 MP maps to ``denoise_min`` and 3.70 MP maps to
``denoise_max``. Levels above or below 5 add the same asymmetric spread as the
reference custom node.
"""
image_mp = max(1.0, float(width) * float(height)) / 1_000_000.0
normalized = _clamp((image_mp - 0.30) / (3.70 - 0.30), 0.0, 1.0)
minimum = float(denoise_min)
maximum = float(denoise_max)
if maximum < minimum:
minimum, maximum = maximum, minimum
denoise_range = maximum - minimum
base = minimum + denoise_range * normalized
level = int(adaptive_level)
if level >= 5:
offset = ((float(level) - 5.0) / 5.0) * denoise_range * 0.285714
"""Choose the calibrated global strength from image area and operator level."""
low, high = sorted((float(denoise_min), float(denoise_max)))
megapixels = max(1.0, float(width) * float(height)) * 1e-6
area_fraction = _clamp((megapixels - 0.30) / 3.40, 0.0, 1.0)
strength_range = high - low
strength = low + strength_range * area_fraction
level_delta = float(int(adaptive_level)) - 5.0
if level_delta >= 0.0:
strength += level_delta * strength_range * (0.285714 / 5.0)
else:
offset = -((5.0 - float(level)) / 4.0) * denoise_range * 0.257143
return _clamp(base + offset, 0.0001, 1.0)
strength += level_delta * strength_range * (0.257143 / 4.0)
return _clamp(strength, 0.0001, 1.0)
def largest_face_denoise(
@@ -202,7 +176,7 @@ def largest_face_denoise(
denoise_min: float = 0.05,
denoise_max: float = 0.28,
) -> float:
"""Scale face denoise from the largest face area, matching reference mode."""
"""Choose the calibrated face strength from the largest detected face area."""
width, height = image_size
image_area = max(1.0, float(width) * float(height))
largest_ratio = 0.0
@@ -211,7 +185,7 @@ def largest_face_denoise(
largest_ratio = max(largest_ratio, box_area / image_area)
if largest_ratio <= 0.0:
return _clamp(base_denoise, denoise_min, denoise_max)
scaled = float(base_denoise) * (largest_ratio / max(1e-6, float(adaptive_ratio)))
scaled = float(base_denoise) * largest_ratio / max(1e-6, float(adaptive_ratio))
return _clamp(scaled, denoise_min, denoise_max)
@@ -229,7 +203,7 @@ def _resize_to_target(image: Image.Image) -> Image.Image:
def build_canny_control_image(image: Image.Image) -> Image.Image:
"""Build the three-channel Canny conditioning image used by stage 1."""
"""Build the calibrated three-channel Canny conditioning map."""
import cv2
rgb = np.asarray(image.convert("RGB"))
@@ -259,8 +233,6 @@ def build_global_kwargs(
"seed": seed,
"rand_device": "cpu",
"num_inference_steps": GLOBAL_STEPS,
# The source graph applies ModelSamplingAuraFlow with shift=3. DiffSynth
# expresses the same rational sigma shift as exp(mu), so mu=log(3).
"exponential_shift_mu": math.log(3.0),
"blockwise_controlnet_inputs": [controlnet_input],
}
@@ -312,7 +284,7 @@ def _expanded_box(
*,
factor: float = 2.5,
) -> tuple[int, int, int, int]:
"""Expand a face box around its center, matching the reference crop factor."""
"""Expand a face box around its center to include local lighting context."""
x1, y1, x2, y2 = box
image_width, image_height = image_size
center_x = (x1 + x2) / 2.0
@@ -813,7 +785,7 @@ class QwenZImagePipeline:
scale_for_face = 768.0 / max(1, max(face_width, face_height))
scale_for_crop = 1024.0 / max(1, max(crop_width, crop_height))
scale = min(scale_for_face, scale_for_crop)
# Never shrink below the crop's current size unless the 1024 cap requires it.
# Never shrink below the crop's current size unless the cap requires it.
if max(crop_width, crop_height) <= 1024:
scale = max(1.0, scale)
width = max(16, round(crop_width * scale / 16.0) * 16)
@@ -881,7 +853,7 @@ class QwenZImagePipeline:
resolution_adaptive_denoise(image.width, image.height) if strength is None else float(strength)
)
if tile and max(image.size) > tile_size:
from remove_ai_watermarks.noai.tiling import run_tiled
from remove_ai_watermarks._internal.tiling import run_tiled
global_result = run_tiled(
lambda tile_image: self._run_global(tile_image, global_strength, seed),
@@ -0,0 +1,30 @@
"""Small path helpers shared by optional image pipelines."""
from __future__ import annotations
from typing import TYPE_CHECKING
from remove_ai_watermarks._internal.constants import SUPPORTED_FORMATS
if TYPE_CHECKING:
from pathlib import Path
_PIL_FORMAT_BY_SUFFIX = {
".jpg": "JPEG",
".jpeg": "JPEG",
".png": "PNG",
}
def is_supported_format(file_path: Path) -> bool:
"""Return whether ``file_path`` has a supported raster suffix."""
return file_path.suffix.casefold() in SUPPORTED_FORMATS
def get_image_format(file_path: Path) -> str:
"""Return the Pillow save format used by the legacy metadata API.
The metadata writer only has specialized PNG and JPEG paths. Other accepted
inputs therefore use its PNG fallback, matching the established API contract.
"""
return _PIL_FORMAT_BY_SUFFIX.get(file_path.suffix.casefold(), "PNG")
@@ -0,0 +1,105 @@
"""Project-owned configuration for invisible-watermark regeneration profiles."""
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal
if TYPE_CHECKING:
from pathlib import Path
DEFAULT_MODEL_ID = "stabilityai/stable-diffusion-xl-base-1.0"
QWEN_MODEL_ID = "Qwen/Qwen-Image"
CONTROLNET_CANNY_MODEL = "xinsir/controlnet-canny-sdxl-1.0"
SDXL_PROFILE = "sdxl"
QWEN_ZIMAGE_PROFILE = "qwen-zimage"
OPENAI_STRENGTH = 0.10
GEMINI_STRENGTH = 0.15
UNKNOWN_STRENGTH = GEMINI_STRENGTH
DEFAULT_STRENGTH = UNKNOWN_STRENGTH
QWEN_OPENAI_STRENGTH = 0.10
QWEN_GEMINI_STRENGTH = 0.25
QWEN_UNKNOWN_STRENGTH = QWEN_GEMINI_STRENGTH
@dataclass(frozen=True)
class _StrengthPolicy:
unknown: float
by_vendor: dict[str, float]
def choose(self, vendor: str | None) -> float:
return self.by_vendor.get((vendor or "").casefold(), self.unknown)
_STANDARD_POLICY = _StrengthPolicy(
unknown=UNKNOWN_STRENGTH,
by_vendor={"openai": OPENAI_STRENGTH, "google": GEMINI_STRENGTH},
)
_QWEN_POLICY = _StrengthPolicy(
unknown=QWEN_UNKNOWN_STRENGTH,
by_vendor={"openai": QWEN_OPENAI_STRENGTH, "google": QWEN_GEMINI_STRENGTH},
)
_ALIASES = {"default": SDXL_PROFILE, "qwen_zimage": QWEN_ZIMAGE_PROFILE}
def normalize_profile(profile: str) -> str:
"""Normalize spelling and resolve compatibility aliases."""
value = profile.strip().casefold()
return _ALIASES.get(value, value)
def resolve_steps(num_inference_steps: int | None, pipeline: str) -> int:
"""Return an explicit step count or the selected profile's default."""
if num_inference_steps is not None:
return num_inference_steps
return 4 if normalize_profile(pipeline) == QWEN_ZIMAGE_PROFILE else 50
def resolve_seed(seed: int | None, pipeline: str) -> int | None:
"""Keep the fixed Qwen plus Z-Image profile reproducible by default."""
if seed is not None:
return seed
return 0 if normalize_profile(pipeline) == QWEN_ZIMAGE_PROFILE else None
def strength_default_help() -> str:
"""Describe the live default policy without duplicating its values."""
return (
f"vendor-adaptive (OpenAI {OPENAI_STRENGTH} / Google {GEMINI_STRENGTH} / "
f"unknown {UNKNOWN_STRENGTH}, from the C2PA issuer; qwen-zimage instead uses "
"resolution-adaptive denoise)"
)
def resolve_strength(strength: float | None, vendor: str | None = None, pipeline: str | None = None) -> float:
"""Resolve a user override or the calibrated policy for a profile and vendor."""
if strength is not None:
return strength
policy = _QWEN_POLICY if pipeline is not None and normalize_profile(pipeline) == "qwen" else _STANDARD_POLICY
return policy.choose(vendor)
def viable_steps(num_inference_steps: int, strength: float) -> int:
"""Ensure Diffusers receives at least one effective img2img denoising step."""
if strength <= 0 or int(num_inference_steps * strength) >= 1:
return num_inference_steps
return math.ceil(1.0 / strength)
def vendor_for_strength(image_path: Path) -> Literal["openai", "google"] | None:
"""Select the strength cohort using the input's SynthID provenance proxy."""
try:
from remove_ai_watermarks.metadata import synthid_source
evidence = (synthid_source(image_path) or "").casefold()
except Exception:
return None
if "google" in evidence:
return "google"
if "openai" in evidence:
return "openai"
return None
@@ -0,0 +1,649 @@
"""Project-native orchestration for diffusion-based pixel regeneration."""
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownParameterType=false, reportMissingTypeArgument=false, reportMissingTypeStubs=false, reportMissingImports=false, reportArgumentType=false, reportAssignmentType=false, reportReturnType=false, reportCallIssue=false, reportIndexIssue=false, reportOperatorIssue=false, reportOptionalMemberAccess=false, reportOptionalCall=false, reportOptionalSubscript=false, reportOptionalOperand=false, reportAttributeAccessIssue=false, reportPrivateImportUsage=false, reportPrivateUsage=false, reportInvalidTypeForm=false, reportConstantRedefinition=false, reportUnnecessaryComparison=false
from __future__ import annotations
import contextlib
import logging
import os
import subprocess
from typing import TYPE_CHECKING, Any
from PIL import Image
from remove_ai_watermarks._internal.watermark_profiles import (
CONTROLNET_CANNY_MODEL,
DEFAULT_MODEL_ID,
DEFAULT_STRENGTH,
QWEN_MODEL_ID,
QWEN_ZIMAGE_PROFILE,
normalize_profile,
resolve_seed,
resolve_steps,
resolve_strength,
viable_steps,
)
if TYPE_CHECKING:
from collections.abc import Callable
from pathlib import Path
logger = logging.getLogger(__name__)
try:
import torch
_HAS_TORCH = True
except ImportError:
torch = None # type: ignore[assignment]
_HAS_TORCH = False
try:
from diffusers import AutoPipelineForImage2Image as AutoImg2ImgPipeline
_HAS_DIFFUSERS = True
except ImportError:
AutoImg2ImgPipeline = None # type: ignore[assignment,misc]
_HAS_DIFFUSERS = False
_SDXL_FP16_VAE_ID = "madebyollin/sdxl-vae-fp16-fix"
_DEGENERATE_THRESHOLD = 1.0
_CANNY_LOW = 100
_CANNY_HIGH = 200
_CONTROLNET_PROMPT = "best quality, high quality, sharp, detailed, photographic"
_CONTROLNET_NEGATIVE = "blurry, lowres, deformed, distorted text, garbled text, watermark, jpeg artifacts"
_QWEN_PROMPT = "high quality, sharp, detailed, faithful to the original"
_QWEN_NEGATIVE = "blurry, lowres, distorted text, garbled text, artifacts"
def is_watermark_removal_available() -> bool:
"""Return whether the standard diffusion runtime can be imported."""
return _HAS_TORCH and _HAS_DIFFUSERS
def _ensure_watermark_deps() -> None:
if not is_watermark_removal_available():
raise ImportError(
"Invisible watermark regeneration requires the 'diffusion' extra. Install remove-ai-watermarks[diffusion]."
)
def _needs_fp16_vae_fix(model_id: str, default_model_id: str, is_fp16: bool) -> bool:
"""Return whether the default SDXL pipeline needs the overflow-safe VAE."""
return is_fp16 and model_id == default_model_id
def _is_degenerate_image(image: Image.Image) -> bool:
"""Detect the uniform near-black output produced by an fp16 decode collapse."""
import numpy as np
pixels = np.asarray(image.convert("RGB"), dtype=np.float32)
return float(pixels.mean()) < _DEGENERATE_THRESHOLD and float(pixels.std()) < _DEGENERATE_THRESHOLD
def _has_nvidia_gpu() -> bool:
try:
subprocess.run(
["nvidia-smi"],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except (FileNotFoundError, subprocess.CalledProcessError):
return False
return True
def _detect_cuda_index_url() -> str:
"""Return a PyTorch wheel index compatible with the reported CUDA runtime."""
try:
report = subprocess.run(
["nvidia-smi"],
check=True,
capture_output=True,
text=True,
).stdout
except (FileNotFoundError, subprocess.CalledProcessError):
return "https://download.pytorch.org/whl/cu121"
import re
match = re.search(r"CUDA Version:\s*(\d+)\.(\d+)", report)
if match is None:
return "https://download.pytorch.org/whl/cu121"
return f"https://download.pytorch.org/whl/cu{match.group(1)}{match.group(2)}"
def _backend_works(device: str) -> bool:
try:
probe = torch.tensor([1.0], device=device) # type: ignore[union-attr]
_ = probe + probe
except (AssertionError, RuntimeError):
return False
return True
def get_device() -> str:
"""Select CUDA, XPU, MPS, or CPU in that order when each backend is usable."""
if not _HAS_TORCH:
return "cpu"
if torch.cuda.is_available() and _backend_works("cuda"): # type: ignore[union-attr]
return "cuda"
xpu = getattr(torch, "xpu", None)
if xpu is not None and xpu.is_available() and _backend_works("xpu"):
return "xpu"
if _has_nvidia_gpu():
logger.warning("NVIDIA GPU detected, but the installed PyTorch build has no working CUDA backend")
mps = getattr(getattr(torch, "backends", None), "mps", None)
if mps is not None and mps.is_available():
return "mps"
return "cpu"
def _make_seed_generator(device: str, seed: int) -> Any:
"""Create a deterministic generator, using CPU when device RNG is unavailable."""
try:
return torch.Generator(device=device).manual_seed(seed) # type: ignore[union-attr]
except (RuntimeError, TypeError):
return torch.Generator().manual_seed(seed) # type: ignore[union-attr]
def _qwen_target_size(width: int, height: int) -> tuple[int, int]:
"""Floor dimensions to Qwen's 16-pixel latent grid."""
return max(16, width - width % 16), max(16, height - height % 16)
def _build_qwen_kwargs(
image: Image.Image,
strength: float,
num_inference_steps: int,
true_cfg_scale: float,
generator: Any,
) -> dict[str, Any]:
"""Build the Qwen img2img call without importing its optional pipeline class."""
width, height = _qwen_target_size(image.width, image.height)
return {
"prompt": _QWEN_PROMPT,
"negative_prompt": _QWEN_NEGATIVE,
"image": image,
"strength": strength,
"num_inference_steps": num_inference_steps,
"true_cfg_scale": true_cfg_scale,
"generator": generator,
"width": width,
"height": height,
}
class WatermarkRemover:
"""Load one regeneration profile and write a metadata-clean raster output."""
DEFAULT_MODEL_ID = DEFAULT_MODEL_ID
DEFAULT_STRENGTH = DEFAULT_STRENGTH
CONTROLNET_CANNY_MODEL = CONTROLNET_CANNY_MODEL
_DEVICES = frozenset({"cpu", "mps", "cuda", "xpu"})
def __init__(
self,
model_id: str | None = None,
device: str | None = None,
torch_dtype: Any = None,
progress_callback: Callable[[str], None] | None = None,
hf_token: str | None = None,
pipeline: str = "controlnet",
controlnet_conditioning_scale: float = 1.0,
cpu_offload: bool = False,
) -> None:
requested_model = model_id or self.DEFAULT_MODEL_ID
self.model_profile = normalize_profile(pipeline)
if self.model_profile == QWEN_ZIMAGE_PROFILE and model_id not in {None, self.DEFAULT_MODEL_ID}:
raise ValueError("The qwen-zimage profile uses a fixed Qwen-Image-2512 and Z-Image model stack.")
self.model_id = (
"Qwen/Qwen-Image-2512 + Tongyi-MAI/Z-Image-Turbo"
if self.model_profile == QWEN_ZIMAGE_PROFILE
else requested_model
)
_ensure_watermark_deps()
selected_device = (device or get_device()).casefold()
self.device = get_device() if selected_device == "auto" else selected_device
if self.device not in self._DEVICES:
raise ValueError(f"Unsupported device '{device}'. Use one of: auto, cpu, mps, cuda, xpu.")
if torch_dtype is not None:
self.torch_dtype = torch_dtype
elif self.device in {"cpu", "mps"}:
self.torch_dtype = torch.float32 # type: ignore[union-attr]
elif self.model_profile in {"qwen", QWEN_ZIMAGE_PROFILE}:
self.torch_dtype = torch.bfloat16 # type: ignore[union-attr]
else:
self.torch_dtype = torch.float16 # type: ignore[union-attr]
self.cpu_offload = cpu_offload
self.controlnet_conditioning_scale = controlnet_conditioning_scale
self.hf_token = hf_token or os.environ.get("HF_TOKEN")
self._progress_callback = progress_callback
self._pipeline: Any = None
self._controlnet_pipeline: Any = None
self._qwen_pipeline: Any = None
self._qwen_zimage_pipeline: Any = None
def _set_progress(self, message: str) -> None:
if self._progress_callback is not None:
with contextlib.suppress(Exception):
self._progress_callback(message)
def preload(self, *, global_only: bool = False) -> None:
"""Materialize the selected model stack before the first request."""
if self.model_profile == QWEN_ZIMAGE_PROFILE:
self._load_qwen_zimage_pipeline().preload(global_only=global_only)
elif self.model_profile == "qwen":
self._load_qwen_pipeline()
elif self.model_profile == "controlnet":
self._load_controlnet_pipeline()
else:
self._load_pipeline()
def _base_load_kwargs(self) -> dict[str, Any]:
options: dict[str, Any] = {"torch_dtype": self.torch_dtype}
if self.hf_token:
options["token"] = self.hf_token
return options
def _load_from_pretrained(self, cls: Any, model_id: str, **kwargs: Any) -> Any:
if self.torch_dtype == torch.float16: # type: ignore[union-attr]
try:
return cls.from_pretrained(model_id, variant="fp16", **kwargs)
except Exception as error:
logger.info("Model %s has no usable fp16 variant (%s); using default weights", model_id, error)
return cls.from_pretrained(model_id, **kwargs)
def _maybe_add_fp16_vae(self, options: dict[str, Any]) -> None:
if not _needs_fp16_vae_fix(
self.model_id,
self.DEFAULT_MODEL_ID,
self.torch_dtype == torch.float16, # type: ignore[union-attr]
):
return
from diffusers import AutoencoderKL
options["vae"] = AutoencoderKL.from_pretrained(_SDXL_FP16_VAE_ID, torch_dtype=torch.float16)
@staticmethod
def _disable_sdxl_watermarker(options: dict[str, Any]) -> None:
options["add_watermarker"] = False
def _move_to_device_and_optimize(self, pipeline: Any) -> Any:
if self.cpu_offload and self.device == "cuda":
offload = getattr(pipeline, "enable_model_cpu_offload", None)
if not callable(offload):
raise RuntimeError("CPU offload was requested, but this pipeline does not support it.")
offload(device="cuda")
else:
try:
pipeline = pipeline.to(self.device)
except (RuntimeError, AssertionError) as error:
if self.device == "cuda":
raise RuntimeError(
f"Failed to move model to CUDA ({error}). Install a compatible PyTorch wheel from "
f"{_detect_cuda_index_url()}."
) from error
raise
optimize = getattr(pipeline, "enable_xformers_memory_efficient_attention", None)
if callable(optimize):
with contextlib.suppress(Exception):
optimize()
if self.device == "mps":
slice_attention = getattr(pipeline, "enable_attention_slicing", None)
if callable(slice_attention):
with contextlib.suppress(Exception):
slice_attention("max")
return pipeline
def _sdxl_options(self) -> dict[str, Any]:
options = self._base_load_kwargs()
self._disable_sdxl_watermarker(options)
self._maybe_add_fp16_vae(options)
return options
def _load_pipeline(self) -> Any:
if self._pipeline is None:
options = self._sdxl_options()
options.update(safety_checker=None, requires_safety_checker=False)
loaded = self._load_from_pretrained(AutoImg2ImgPipeline, self.model_id, **options)
self._pipeline = self._move_to_device_and_optimize(loaded)
return self._pipeline
def _load_controlnet_pipeline(self) -> Any:
if self._controlnet_pipeline is None:
from diffusers import ControlNetModel, StableDiffusionXLControlNetImg2ImgPipeline
controlnet = self._load_from_pretrained(
ControlNetModel,
CONTROLNET_CANNY_MODEL,
torch_dtype=self.torch_dtype,
)
options = self._sdxl_options()
options["controlnet"] = controlnet
loaded = self._load_from_pretrained(
StableDiffusionXLControlNetImg2ImgPipeline,
self.model_id,
**options,
)
self._controlnet_pipeline = self._move_to_device_and_optimize(loaded)
return self._controlnet_pipeline
def _load_qwen_pipeline(self) -> Any:
if self._qwen_pipeline is None:
try:
from diffusers import QwenImageImg2ImgPipeline
except ImportError as error:
raise ImportError("The qwen profile requires Diffusers with QwenImageImg2ImgPipeline.") from error
model_id = QWEN_MODEL_ID if self.model_id == self.DEFAULT_MODEL_ID else self.model_id
loaded = QwenImageImg2ImgPipeline.from_pretrained(model_id, **self._base_load_kwargs())
self._qwen_pipeline = self._move_to_device_and_optimize(loaded)
return self._qwen_pipeline
def _load_qwen_zimage_pipeline(self) -> Any:
if self._qwen_zimage_pipeline is None:
from remove_ai_watermarks._internal.qwen_zimage_pipeline import QwenZImagePipeline
self._qwen_zimage_pipeline = QwenZImagePipeline(
device=self.device,
torch_dtype=self.torch_dtype,
hf_token=self.hf_token,
progress_callback=self._progress_callback,
controlnet_conditioning_scale=self.controlnet_conditioning_scale,
keep_face_models_on_device=False if self.cpu_offload else None,
)
return self._qwen_zimage_pipeline
def _reload_on_cpu(self, cache_name: str, loader: Callable[[], Any]) -> Any:
self.device = "cpu"
self.torch_dtype = torch.float32 # type: ignore[union-attr]
setattr(self, cache_name, None)
return loader()
def _run_img2img(
self,
init_image: Image.Image,
strength: float,
num_inference_steps: int,
guidance_scale: float,
generator: Any,
) -> Image.Image:
from remove_ai_watermarks._internal.img2img_runner import run_img2img_with_mps_fallback
output, device = run_img2img_with_mps_fallback(
self._load_pipeline,
init_image,
strength,
num_inference_steps,
guidance_scale,
generator,
self.device,
self._set_progress,
reload_on_cpu=lambda: self._reload_on_cpu("_pipeline", self._load_pipeline),
)
self.device = device
return output
def _build_canny_control_image(self, init_image: Image.Image) -> Image.Image:
import cv2
import numpy as np
gray = cv2.cvtColor(np.asarray(init_image.convert("RGB")), cv2.COLOR_RGB2GRAY)
edges = cv2.Canny(gray, _CANNY_LOW, _CANNY_HIGH)
return Image.fromarray(np.repeat(edges[:, :, None], 3, axis=2))
def _run_controlnet(
self,
init_image: Image.Image,
strength: float,
num_inference_steps: int,
guidance_scale: float,
generator: Any,
) -> Image.Image:
from remove_ai_watermarks._internal.img2img_runner import run_img2img_with_mps_fallback
extras = {
"prompt": _CONTROLNET_PROMPT,
"negative_prompt": _CONTROLNET_NEGATIVE,
"control_image": self._build_canny_control_image(init_image),
"controlnet_conditioning_scale": float(self.controlnet_conditioning_scale),
}
output, device = run_img2img_with_mps_fallback(
self._load_controlnet_pipeline,
init_image,
strength,
num_inference_steps,
guidance_scale,
generator,
self.device,
self._set_progress,
reload_on_cpu=lambda: self._reload_on_cpu("_controlnet_pipeline", self._load_controlnet_pipeline),
extra_kwargs=extras,
)
self.device = device
return output
def _run_qwen(
self,
init_image: Image.Image,
strength: float,
num_inference_steps: int,
guidance_scale: float,
generator: Any,
) -> Image.Image:
response = self._load_qwen_pipeline()(
**_build_qwen_kwargs(init_image, strength, num_inference_steps, guidance_scale, generator)
)
return response.images[0]
def _run_qwen_zimage(
self,
init_image: Image.Image,
strength: float,
seed: int | None,
*,
tile: bool = False,
tile_size: int = 1024,
tile_overlap: int = 128,
) -> Image.Image:
return self._load_qwen_zimage_pipeline().run(
init_image,
strength=strength,
seed=seed,
tile=tile,
tile_size=tile_size,
tile_overlap=tile_overlap,
)
def _generate(
self,
image: Image.Image,
strength: float,
steps: int,
guidance: float,
generator: Any,
seed: int | None,
*,
tile: bool,
tile_size: int,
tile_overlap: int,
) -> Image.Image:
if self.model_profile == QWEN_ZIMAGE_PROFILE:
return self._run_qwen_zimage(
image,
strength,
seed,
tile=tile,
tile_size=tile_size,
tile_overlap=tile_overlap,
)
runner = {
"qwen": self._run_qwen,
"controlnet": self._run_controlnet,
}.get(self.model_profile, self._run_img2img)
if tile and max(image.size) > tile_size:
from remove_ai_watermarks._internal.tiling import run_tiled
return run_tiled(
lambda crop: runner(crop, strength, steps, guidance, generator),
image,
tile_size,
tile_overlap,
self._set_progress,
)
return runner(image, strength, steps, guidance, generator)
def _write_output(self, image: Image.Image, output_path: Path) -> None:
import numpy as np
from remove_ai_watermarks import image_io
output_path.parent.mkdir(parents=True, exist_ok=True)
bgr = np.ascontiguousarray(np.asarray(image.convert("RGB"))[:, :, ::-1])
if not image_io.imwrite(str(output_path), bgr):
image.save(output_path)
from remove_ai_watermarks.metadata import remove_ai_metadata
remove_ai_metadata(output_path, output_path, keep_standard=True)
def remove_watermark(
self,
image_path: Path,
output_path: Path | None = None,
strength: float | None = None,
num_inference_steps: int | None = None,
guidance_scale: float | None = None,
seed: int | None = None,
vendor: str | None = None,
tile: bool = False,
tile_size: int = 1024,
tile_overlap: int = 128,
region: tuple[int, int, int, int] | None = None,
region_feather: int = 64,
) -> Path:
"""Regenerate image pixels and write the result without AI metadata."""
if not image_path.exists():
raise FileNotFoundError(f"Image not found: {image_path}")
destination = output_path or image_path
with Image.open(image_path) as opened:
source = opened.convert("RGB")
if self.model_profile == QWEN_ZIMAGE_PROFILE:
from remove_ai_watermarks._internal.qwen_zimage_pipeline import resolution_adaptive_denoise
resolved_strength = strength if strength is not None else resolution_adaptive_denoise(*source.size)
else:
resolved_strength = resolve_strength(strength, vendor, self.model_profile)
if not 0.0 <= resolved_strength <= 1.0:
raise ValueError(f"Strength must be between 0.0 and 1.0, got {resolved_strength}")
resolved_seed = resolve_seed(seed, self.model_profile)
steps = resolve_steps(num_inference_steps, self.model_profile)
guidance = (
1.0 if guidance_scale is None and self.model_profile == QWEN_ZIMAGE_PROFILE else guidance_scale or 7.5
)
if self.model_profile == QWEN_ZIMAGE_PROFILE:
if steps != 4:
raise ValueError("The qwen-zimage profile requires 4 steps.")
if guidance != 1.0:
raise ValueError("The qwen-zimage profile requires CFG 1.0.")
else:
steps = viable_steps(steps, resolved_strength)
generator = None
if resolved_seed is not None and _HAS_TORCH:
generator = _make_seed_generator(self.device, resolved_seed)
result = self._generate(
source,
resolved_strength,
steps,
guidance,
generator,
resolved_seed,
tile=tile,
tile_size=tile_size,
tile_overlap=tile_overlap,
)
if self.torch_dtype == torch.float16 and _is_degenerate_image(result): # type: ignore[union-attr]
self.torch_dtype = torch.float32 # type: ignore[union-attr]
self._pipeline = self._controlnet_pipeline = self._qwen_pipeline = self._qwen_zimage_pipeline = None
result = self._generate(
source,
resolved_strength,
steps,
guidance,
generator,
resolved_seed,
tile=tile,
tile_size=tile_size,
tile_overlap=tile_overlap,
)
if region is not None:
import numpy as np
from remove_ai_watermarks._internal.tiling import feather_region_composite
if result.size != source.size:
result = result.resize(source.size, Image.Resampling.LANCZOS)
merged = feather_region_composite(
np.asarray(source),
np.asarray(result.convert("RGB")),
region,
feather=region_feather,
)
result = Image.fromarray(merged)
self._write_output(result, destination)
return destination
def remove_watermark_batch(
self,
input_dir: Path,
output_dir: Path,
strength: float | None = None,
num_inference_steps: int | None = None,
extensions: tuple[str, ...] = (".png", ".jpg", ".jpeg", ".webp"),
) -> list[Path]:
"""Process matching files in a directory, logging and continuing on failures."""
if not input_dir.exists():
raise FileNotFoundError(f"Input directory not found: {input_dir}")
output_dir.mkdir(parents=True, exist_ok=True)
from remove_ai_watermarks._internal.img2img_runner import try_empty_device_cache
outputs: list[Path] = []
candidates = sorted(path for path in input_dir.iterdir() if path.suffix.casefold() in extensions)
for source in candidates:
try:
outputs.append(self.remove_watermark(source, output_dir / source.name, strength, num_inference_steps))
except Exception as error:
logger.error("Failed to process %s: %s", source, error)
finally:
try_empty_device_cache(self.device)
return outputs
def remove_watermark(
image_path: Path,
output_path: Path | None = None,
strength: float | None = None,
model_id: str | None = None,
device: str | None = None,
hf_token: str | None = None,
region: tuple[int, int, int, int] | None = None,
) -> Path:
"""Convenience wrapper using the default ControlNet profile."""
from remove_ai_watermarks._internal.watermark_profiles import vendor_for_strength
remover = WatermarkRemover(model_id=model_id, device=device, hf_token=hf_token)
return remover.remove_watermark(
image_path,
output_path,
strength,
vendor=vendor_for_strength(image_path),
region=region,
)
+4 -4
View File
@@ -21,8 +21,8 @@ from typing import TYPE_CHECKING, Any, Literal, NoReturn
import click
from remove_ai_watermarks import __version__, image_io, watermark_registry
from remove_ai_watermarks.noai.constants import SUPPORTED_FORMATS
from remove_ai_watermarks.noai.watermark_profiles import (
from remove_ai_watermarks._internal.constants import SUPPORTED_FORMATS
from remove_ai_watermarks._internal.watermark_profiles import (
resolve_seed,
resolve_steps,
resolve_strength,
@@ -158,7 +158,7 @@ def _resolved_strength_for_display(
if pipeline == "qwen-zimage" and strength is None:
from PIL import Image
from remove_ai_watermarks.noai.qwen_zimage_pipeline import resolution_adaptive_denoise
from remove_ai_watermarks._internal.qwen_zimage_pipeline import resolution_adaptive_denoise
with Image.open(source) as image:
return resolution_adaptive_denoise(image.width, image.height)
@@ -269,7 +269,7 @@ def _normalize_pipeline(ctx: click.Context, param: click.Parameter, value: str |
"""
if value is None:
return None
from remove_ai_watermarks.noai.watermark_profiles import normalize_profile
from remove_ai_watermarks._internal.watermark_profiles import normalize_profile
normalized = normalize_profile(value)
if value.strip().lower() == "default":
+251 -562
View File
@@ -1,22 +1,6 @@
"""Gemini visible-sparkle detector and localizer (cv2/numpy, no GPU).
"""Locate the visible Gemini sparkle and build a mask for shared inpainting."""
Locates the Google Gemini / Nano Banana sparkle so the shared fill (region_eraser)
can inpaint it. Detection is a multi-scale NCC search against the captured sparkle
alpha template (ported from GeminiWatermarkTool's Snap Engine; original author
Allen Kuo (allenk), https://github.com/allenk/GeminiWatermarkTool), scored by a
spatial + gradient + variance fusion with a false-positive gate. ``footprint_mask``
returns the sparkle footprint (captured alpha thresholded low to include the halo,
then dilated) as a full-frame mask for the fill.
The captured alpha maps are background captures of the sparkle on pure-black
backgrounds (48x48 for small images, 96x96 for large). NB: they are used here only
to DETECT and to shape the removal mask -- the old reverse-alpha pixel recovery
(``original = (watermarked - a*logo)/(1-a)``) is gone; removal is localize -> fill.
"""
# cv2/numpy boundary: cv2 and numpy ship no usable type info for the array ops
# below, so strict pyright cannot know their element types. Relax the unknown-type
# rules for this file only; the public signatures are still annotated with NDArray[Any].
# OpenCV and NumPy expose incomplete types at this array-processing boundary.
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownParameterType=false, reportMissingTypeArgument=false, reportMissingTypeStubs=false, reportMissingImports=false, reportArgumentType=false, reportAssignmentType=false, reportReturnType=false, reportCallIssue=false, reportIndexIssue=false, reportOperatorIssue=false, reportOptionalMemberAccess=false, reportOptionalCall=false, reportOptionalSubscript=false, reportOptionalOperand=false, reportAttributeAccessIssue=false, reportPrivateImportUsage=false, reportPrivateUsage=false, reportInvalidTypeForm=false, reportConstantRedefinition=false, reportUnnecessaryComparison=false
from __future__ import annotations
@@ -41,263 +25,172 @@ logger = logging.getLogger(__name__)
class WatermarkSize(Enum):
"""Watermark size mode based on image dimensions."""
"""Provider size tier selected from the source dimensions."""
SMALL = "small" # 48x48, for images <= 1024x1024
LARGE = "large" # 96x96, for images > 1024x1024
SMALL = "small"
LARGE = "large"
@dataclass
class DetectionResult:
"""Result of watermark detection."""
"""Detection decision and its component scores."""
detected: bool = False
confidence: float = 0.0
region: tuple[int, int, int, int] = (0, 0, 0, 0) # x, y, w, h
region: tuple[int, int, int, int] = (0, 0, 0, 0)
size: WatermarkSize = WatermarkSize.SMALL
# stage scores
spatial_score: float = 0.0
gradient_score: float = 0.0
variance_score: float = 0.0
@dataclass
@dataclass(frozen=True, slots=True)
class WatermarkPosition:
"""Watermark position configuration."""
"""Expected provider margins and logo size."""
margin_right: int
margin_bottom: int
logo_size: int
def get_position(self, image_width: int, image_height: int) -> tuple[int, int]:
"""Get top-left position for a given image size."""
x = image_width - self.margin_right - self.logo_size
y = image_height - self.margin_bottom - self.logo_size
return (x, y)
return image_width - self.margin_right - self.logo_size, image_height - self.margin_bottom - self.logo_size
def get_watermark_config(width: int, height: int) -> WatermarkPosition:
"""Get the appropriate watermark configuration based on image size.
@dataclass(frozen=True, slots=True)
class _Candidate:
scale: int
x: int
y: int
spatial: float
gradient: float = 0.0
variance: float = 0.0
Rules discovered from Gemini:
- W > 1024 AND H > 1024: 96x96 logo at (W-64-96, H-64-96)
- Otherwise: 48x48 logo at (W-32-48, H-32-48)
"""
if width > 1024 and height > 1024:
return WatermarkPosition(margin_right=64, margin_bottom=64, logo_size=96)
return WatermarkPosition(margin_right=32, margin_bottom=32, logo_size=48)
@property
def fused(self) -> float:
if self.spatial < 0.25:
return max(0.0, self.spatial * 0.5)
return self.spatial * 0.50 + self.gradient * 0.30 + self.variance * 0.20
def get_watermark_size(width: int, height: int) -> WatermarkSize:
"""Determine watermark size mode from image dimensions."""
if width > 1024 and height > 1024:
return WatermarkSize.LARGE
return WatermarkSize.SMALL
"""Return the provider's large tier only when both axes exceed 1024."""
return WatermarkSize.LARGE if width > 1024 and height > 1024 else WatermarkSize.SMALL
def _calculate_alpha_map(bg_capture: NDArray[Any]) -> NDArray[Any]:
"""Calculate alpha map from a background capture.
def get_watermark_config(width: int, height: int) -> WatermarkPosition:
"""Return the observed standard placement for the selected size tier."""
if get_watermark_size(width, height) is WatermarkSize.LARGE:
return WatermarkPosition(64, 64, 96)
return WatermarkPosition(32, 32, 48)
The alpha map represents how much the watermark affects each pixel.
alpha = max(R, G, B) / 255.0
"""
if len(bg_capture.shape) == 2:
gray = bg_capture.astype(np.float32)
elif bg_capture.shape[2] >= 3:
# Use max of channels for brightness
gray = np.max(bg_capture[:, :, :3], axis=2).astype(np.float32)
def _calculate_alpha_map(background_capture: NDArray[Any]) -> NDArray[Any]:
"""Convert a black-background sparkle capture to a normalized opacity map."""
if background_capture.ndim == 2:
intensity = background_capture
elif background_capture.shape[2] >= 3:
intensity = background_capture[:, :, :3].max(axis=2)
else:
gray = bg_capture[:, :, 0].astype(np.float32)
return gray / 255.0
intensity = background_capture[:, :, 0]
return intensity.astype(np.float32) / 255.0
def _load_embedded_asset(name: str) -> NDArray[Any]:
"""Load an embedded PNG asset and decode it with OpenCV."""
asset_path = Path(__file__).parent / "assets" / name
if not asset_path.exists():
raise FileNotFoundError(f"Embedded asset not found: {asset_path}")
data = asset_path.read_bytes()
buf = np.frombuffer(data, dtype=np.uint8)
img = cv2.imdecode(buf, cv2.IMREAD_COLOR)
if img is None:
raise RuntimeError(f"Failed to decode embedded asset: {name}")
return img
def _load_capture(filename: str, expected_side: int) -> NDArray[Any]:
capture = image_io.imread(Path(__file__).parent / "assets" / filename, cv2.IMREAD_COLOR)
if capture is None:
raise RuntimeError(f"Failed to decode embedded asset: {filename}")
if capture.shape[:2] != (expected_side, expected_side):
capture = cv2.resize(capture, (expected_side, expected_side), interpolation=cv2.INTER_AREA)
return capture
# Single source of truth for the multi-scale template ladder (aggressively downscaled to
# slightly upscaled): the precomputed `_tmpl_cache` and the `_scan_scales` loop must use
# the SAME scales or a scan scale would miss the cache and KeyError.
_TEMPLATE_SCALES: tuple[int, ...] = tuple(range(16, 120, 2))
def _gray_float(image: NDArray[Any]) -> NDArray[Any]:
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if image.ndim == 3 and image.shape[2] >= 3 else image
return gray.astype(np.float32) / 255.0
def _overlaps(candidate: _Candidate, selected: _Candidate) -> bool:
radius = 0.5 * max(candidate.scale, selected.scale)
return abs(candidate.x - selected.x) < radius and abs(candidate.y - selected.y) < radius
_TEMPLATE_SCALES = tuple(range(16, 120, 2))
class GeminiEngine:
"""Detects and localizes the visible Gemini sparkle for the shared fill removal.
"""Project-native detector and mask builder for the white Gemini sparkle."""
The multi-scale NCC detection is a Python port of the GeminiWatermarkTool C++
Snap Engine; ``footprint_mask`` turns a detection into a removal mask.
"""
# Body pixels at >= this fraction of the peak captured alpha define the sparkle
# "core", sampled by the detection FP-gate's core-vs-ring brightness margin
# (:meth:`_core_and_bg`).
_CORE_ALPHA_FRAC = 0.8
# Sparkle false-positive gate. A real Gemini sparkle is a bright WHITE overlay,
# so its core sits above the local background; a shape-only NCC match on ornate
# or flat content (text, banners, hatching) can score >0.5 without that lift.
# Demote a detection that is BOTH low-confidence AND low core-ring brightness
# margin -- the joint signature of a content false positive (verified on the
# detector calibration: demoted examples were visual false positives or a
# near-invisible white-on-white sparkle whose AI verdict is held by metadata
# anyway). Real sparkles escape via EITHER high confidence
# (white-bg sparkles score >=0.79 despite a low margin) OR high margin (dark/mid
# backgrounds, incl. the #36 faint-corner case, lift well clear), so both must
# fail to demote.
_SPARKLE_FP_CONF = 0.65
_SPARKLE_FP_MARGIN = 5.0
# Bright-background content false positives (2026-06-26 landing-page FPs: a snow+sky
# photo and a white-background product render both scored ~0.51). The margin gate
# above cannot catch them -- a bright background gives the "core" a HIGH core-ring
# margin (it is genuinely brighter than its surroundings), so the brightness check
# reads it as a real overlay. The discriminating signature is the GRADIENT NCC: a
# real white sparkle is a crisp star silhouette (grad ~0.97-1.0 on the synthetic
# composites, ~0.96 on the real #36 corner sparkle), while a smooth luminance blob
# that shape-NCC-matches the rough outline has low gradient fidelity (the two FPs
# measured 0.105 and 0.463). So ALSO demote a low-confidence match whose gradient
# NCC is below this floor, regardless of margin -- 0.55 sits well above the worst FP
# (0.463) and far below every real sparkle (>=0.8). This only ADDS demotions on
# bright backgrounds (a real bright-bg sparkle keeps grad ~0.97), so it cannot
# regress a dark/mid sparkle (already kept by margin) or a white-bg one (kept by
# confidence >= 0.65, above the gate).
_SPARKLE_FP_GRAD = 0.55
# White-core rescue for the gate above. A real but FAINT sparkle -- a soft white
# star on a bright/textured background -- has a high core-ring margin but low
# gradient fidelity, the SAME signature the grad gate uses to demote the smooth
# colored-corner FP, so faint real sparkles get demoted with it. The separator the
# grad gate discards is the CORE COLOR: a real Gemini sparkle core is near-WHITE
# (low saturation), while a clean bright corner that shape-matches (sky, sun, a warm
# light) is COLORED. So do NOT demote a low-grad match that already clears the trust
# confidence (_SPARKLE_KEEP_CONF -- the registry's 0.5 sparkle gate plus a small
# margin so the ~0.51 bright-background FPs the grad gate was added for stay demoted)
# AND has a bright (margin) near-neutral core (_core_saturation <= _SPARKLE_WHITE_SAT).
# Calibrated on metadata-stripped faint sparkles to recover low-gradient marks
# without materially increasing clean false fires.
_SPARKLE_KEEP_CONF = 0.52
_SPARKLE_WHITE_SAT = 0.20
# Corner promotion (issue #36): the size weight that suppresses tiny-patch
# false positives also buries a small, near-perfect sparkle when a larger,
# mediocre match sits elsewhere (e.g. a bright collar in a portrait). A small
# faint sparkle on a busy background therefore loses the global argmax and the
# image reads as clean -- the regression osachub reported when the search
# window widened 256px -> 512px (v0.7.2's tighter window still found it).
# Remedy: if the bottom-right corner holds a very-high-fidelity raw-NCC match,
# trust it regardless of size, without reverting the wider window (which is
# needed for variant margins). The threshold sits midway between the worst
# real-photo corner match (~0.78 across native + downscaled real photos) and a
# genuine faint sparkle (~0.93), so it adds true detections without adding
# false ones; it only ever overrides a lower-fidelity global pick, so it cannot
# weaken an existing detection.
_CORNER_PROMOTE_NCC = 0.85
# Bottom-right corner side for the promotion search, as a fraction of the
# image's short side, clamped to an absolute pixel band. Relative so the corner
# stays a true corner at every scale: a fixed 256 px is a genuine corner on a
# large image but covers ~70% of a small portrait, where a busy real photo can
# then raw-match the star template at ~0.81 (only 0.04 below the promote gate).
# Scaling the side down on small images drops that worst case to ~0.69, while
# the upper clamp stops it ballooning on huge images (more corner area = more
# random texture to false-match -- a real photo reached ~0.83 at 512 px). The
# Gemini sparkle sits ~60-160 px from the corner (fixed margins, not
# proportional), and the [96, 384] band covers that at every measured size.
_CORNER_PROMOTE_FRAC = 0.20
_CORNER_PROMOTE_MIN = 96
_CORNER_PROMOTE_MAX = 384
# Number of top size-weighted spatial candidates scored by full fusion before one
# is selected. The single size-weighted argmax can bury a genuine mid-size sparkle
# under a LARGER, lower-fidelity shape match (the 256->512 search-widening
# regression: a real corner sparkle at raw ~0.77 lost to a decoy at raw ~0.63).
# Scoring the top-K by gradient-bearing fusion rescues it. Top-K (NOT the raw-NCC
# argmax) keeps the tiny-patch suppression intact: a coincidental 16 px match never
# ranks in the size-weighted top-K, so widening selection cannot add a false
# positive on non-Gemini content (verified on the doubao/jimeng visible corpora).
_SELECT_TOPK = 3
_MASK_ALPHA = 0.04
_MASK_DILATE_FRAC = 0.18
def __init__(self, logo_value: float = 255.0) -> None:
"""Initialize the engine with embedded alpha maps.
Args:
logo_value: The logo brightness value (default 255.0 = white).
"""
self.logo_value = logo_value
# Load embedded background captures
bg_small = _load_embedded_asset("gemini_bg_48.png")
bg_large = _load_embedded_asset("gemini_bg_96.png")
# Ensure correct sizes
if bg_small.shape[:2] != (48, 48):
bg_small = cv2.resize(bg_small, (48, 48), interpolation=cv2.INTER_AREA)
if bg_large.shape[:2] != (96, 96):
bg_large = cv2.resize(bg_large, (96, 96), interpolation=cv2.INTER_AREA)
# Calculate alpha maps
self._alpha_small = _calculate_alpha_map(bg_small)
self._alpha_large = _calculate_alpha_map(bg_large)
# Per-scale resized templates are constant (``_alpha_large`` never changes),
# so precompute the whole fixed 16..118 ladder once: ``_scan_scales`` runs it on
# every image (twice -- global + corner), and re-``resize``-ing the 96x96 source
# each time is pure repeated work. Prebuilt (not lazy) so the dict is read-only
# after construction and safe to share across threads via the module singleton.
self._alpha_small = _calculate_alpha_map(_load_capture("gemini_bg_48.png", 48))
self._alpha_large = _calculate_alpha_map(_load_capture("gemini_bg_96.png", 96))
self._tmpl_cache: dict[int, NDArray[Any]] = {
scale: cv2.resize(self._alpha_large, (scale, scale), interpolation=cv2.INTER_AREA)
for scale in _TEMPLATE_SCALES
side: cv2.resize(self._alpha_large, (side, side), interpolation=cv2.INTER_AREA) for side in _TEMPLATE_SCALES
}
logger.debug(
"Alpha maps loaded: small=%s, large=%s",
self._alpha_small.shape,
self._alpha_large.shape,
)
def get_alpha_map(self, size: WatermarkSize) -> NDArray[Any]:
"""Get the base alpha map for a specific standard size."""
if size == WatermarkSize.SMALL:
return self._alpha_small
return self._alpha_large
return self._alpha_small if size is WatermarkSize.SMALL else self._alpha_large
def get_interpolated_alpha(self, size_px: int) -> NDArray[Any]:
"""Create an interpolated alpha map dynamically scaled from the high-res 96x96 base."""
source = self._alpha_large
if size_px == source.shape[1]:
return source.copy()
interp = cv2.INTER_LINEAR if size_px > source.shape[1] else cv2.INTER_AREA
return cv2.resize(source, (size_px, size_px), interpolation=interp)
# ── Detection ────────────────────────────────────────────────────
if size_px == self._alpha_large.shape[1]:
return self._alpha_large.copy()
method = cv2.INTER_LINEAR if size_px > self._alpha_large.shape[1] else cv2.INTER_AREA
return cv2.resize(self._alpha_large, (size_px, size_px), interpolation=method)
def _scan_scales(self, gray: NDArray[Any]) -> Iterator[tuple[int, float, tuple[int, int]]]:
"""Yield ``(scale, max_ncc, max_loc)`` for the alpha template matched at each scale.
Shared multi-scale ``TM_CCOEFF_NORMED`` primitive over a normalized [0, 1]
grayscale region, used by both the size-weighted global search in
``detect_watermark`` and the raw-NCC corner pass in ``_corner_promote`` --
each applies its own scoring/argmax to the yielded values. The 96x96
``_alpha_large`` is the high-quality source downscaled per scale; the range
covers aggressively downscaled to slightly upscaled logos.
"""
for scale in _TEMPLATE_SCALES:
if scale > gray.shape[0] or scale > gray.shape[1]:
"""Yield the strongest normalized template match at every usable scale."""
height, width = gray.shape[:2]
for side, template in self._tmpl_cache.items():
if side > height or side > width:
continue
match_res = cv2.matchTemplate(gray, self._tmpl_cache[scale], cv2.TM_CCOEFF_NORMED)
_, max_val, _, max_loc = cv2.minMaxLoc(match_res)
yield scale, float(max_val), max_loc
response = cv2.matchTemplate(gray, template, cv2.TM_CCOEFF_NORMED)
_minimum, maximum, _min_location, max_location = cv2.minMaxLoc(response)
yield side, float(maximum), max_location
def _global_candidates(self, image: NDArray[Any]) -> list[_Candidate]:
height, width = image.shape[:2]
search_side = min(height, width, 512)
origin_x, origin_y = width - search_side, height - search_side
gray = _gray_float(image[origin_y:height, origin_x:width])
ranked = sorted(
(
(
score * min(1.0, (side / 96.0) ** 0.5),
_Candidate(side, origin_x + location[0], origin_y + location[1], score),
)
for side, score, location in self._scan_scales(gray)
),
key=lambda item: (item[0], item[1].scale, item[1].spatial, item[1].x, item[1].y),
reverse=True,
)
selected: list[_Candidate] = []
for _weighted, candidate in ranked:
if any(_overlaps(candidate, prior) for prior in selected):
continue
selected.append(candidate)
if len(selected) == self._SELECT_TOPK:
break
return selected
def _score_candidate(self, image: NDArray[Any], candidate: _Candidate) -> _Candidate:
if candidate.spatial < 0.25:
return candidate
gradient, variance = self._grad_var_scores(image, candidate.scale, candidate.x, candidate.y)
return _Candidate(candidate.scale, candidate.x, candidate.y, candidate.spatial, gradient, variance)
def detect_watermark(
self,
@@ -306,241 +199,94 @@ class GeminiEngine:
*,
trust_provenance: bool = False,
) -> DetectionResult:
"""Detect Gemini watermark using multi-scale Snap Engine logic (ported from C++ vendor algorithm).
``trust_provenance`` signals that external metadata already proves this is a
Google generation (C2PA issuer "Google"/"Gemini"). The false-positive gate
exists only to reject content that shape-matches the sparkle on NON-Google
images (Doubao text, ornate corners); when provenance confirms Google, that
gate would demote a genuine sparkle the vendor moved/re-rendered (bigger,
lighter, shifted), so it is skipped. The caller (registry) still applies the
relaxed provenance trust gate to the returned confidence."""
"""Return the strongest sparkle-shaped bottom-right candidate."""
result = DetectionResult()
if image is None or image.size == 0:
return result
# Normalize to 3-channel BGR: the multi-scale search tolerates grayscale, but
# the FP-gate / alpha-gain helpers (_core_and_bg) reduce over axis=2 and would
# crash on a 2D/BGRA input reaching this public entry point (e.g. via the
# registry detect adapter or the library API).
image = image_io.to_bgr(image)
h, w = image.shape[:2]
base_size = force_size or get_watermark_size(w, h)
result.size = base_size
# Dynamically search bottom-right corner. 512 covers up to 512px from the
# corner -- enough for known Gemini margin variations (standard: 64+96=160px;
# observed variants up to ~300px). 256 was too tight and caused misses.
search_size = int(min(min(w, h), 512))
sx1 = max(0, w - search_size)
sy1 = max(0, h - search_size)
search_region = image[sy1:h, sx1:w]
if len(search_region.shape) == 3 and search_region.shape[2] >= 3:
gray_sr = cv2.cvtColor(search_region, cv2.COLOR_BGR2GRAY)
else:
gray_sr = search_region.copy()
gray_sr_f = gray_sr.astype(np.float32) / 255.0
# Phase 1 & 2: multi-scale spatial NCC search. The size weight (mimicking the
# C++ vendor weight) overcomes the NCC bias toward tiny patches, but its single
# argmax can bury a genuine mid-size sparkle under a LARGER, lower-fidelity
# shape match (the 256->512 search-widening regression). So score the top-K
# size-weighted candidates by the FULL fusion and keep the highest -- the
# gradient term separates a true white sparkle from a shape-only decoy. See
# _SELECT_TOPK for why top-K (not the raw-NCC argmax) preserves tiny-patch
# suppression and so cannot add a false positive on non-Gemini content.
scored: list[tuple[float, int, int, int, float]] = [] # (adj, scale, raw, x, y)
for scale, max_val, max_loc in self._scan_scales(gray_sr_f):
adj_val = max_val * min(1.0, (scale / 96.0) ** 0.5)
scored.append((adj_val, scale, max_val, sx1 + max_loc[0], sy1 + max_loc[1]))
scored.sort(reverse=True)
# Top-K candidates at distinct locations (NMS: drop a lower-ranked match that
# overlaps an already-kept one -- the same sparkle matches at adjacent scales).
candidates: list[tuple[int, int, int, float]] = []
for _adj, scale, raw, x, y in scored:
if any(
abs(x - px) < 0.5 * max(scale, ps) and abs(y - py) < 0.5 * max(scale, ps)
for ps, px, py, _ in candidates
):
continue
candidates.append((scale, x, y, raw))
if len(candidates) >= self._SELECT_TOPK:
break
# Corner promotion: a near-perfect small bottom-right sparkle the size weight
# buries even below the top-K (see _CORNER_PROMOTE_NCC) -- add it as a candidate.
promoted = self._corner_promote(image, candidates[0][3] if candidates else -1.0)
source = image_io.to_bgr(image)
height, width = source.shape[:2]
result.size = force_size or get_watermark_size(width, height)
candidates = self._global_candidates(source)
promoted = self._corner_promote(source, candidates[0].spatial if candidates else -1.0)
if promoted is not None:
candidates.append(promoted)
# No candidate at any scale: the search region is smaller than the 16px template
# floor (an image whose short side is < 16px), so nothing is detectable. Return
# the empty (detected=False) result rather than dereferencing candidates[0].
candidates.append(_Candidate(promoted[0], promoted[1], promoted[2], promoted[3]))
if not candidates:
return result
# Select the candidate with the highest full-fusion confidence (pre-FP-gate).
best_scale, pos_x, pos_y, best_raw_ncc = candidates[0]
grad_score, var_score, best_fused = 0.0, 0.0, -1.0
for c_scale, c_x, c_y, c_raw in candidates:
if c_raw < 0.25:
c_grad, c_var, c_fused = 0.0, 0.0, max(0.0, c_raw * 0.5)
else:
c_grad, c_var = self._grad_var_scores(image, c_scale, c_x, c_y)
c_fused = c_raw * 0.50 + c_grad * 0.30 + c_var * 0.20
if c_fused > best_fused:
best_fused = c_fused
best_scale, pos_x, pos_y = c_scale, c_x, c_y
best_raw_ncc, grad_score, var_score = c_raw, c_grad, c_var
best = max((self._score_candidate(source, candidate) for candidate in candidates), key=lambda item: item.fused)
result.region = (best.x, best.y, best.scale, best.scale)
result.spatial_score = float(best.spatial)
result.gradient_score = float(best.gradient)
result.variance_score = float(best.variance)
result.region = (pos_x, pos_y, best_scale, best_scale)
result.spatial_score = float(best_raw_ncc)
result.gradient_score = float(grad_score)
result.variance_score = float(var_score)
if result.spatial_score < 0.25:
result.confidence = float(max(0.0, result.spatial_score * 0.5))
return result
# ── Fusion ───────────────────────────────────────────────────
# best_fused is the selected candidate's spatial*0.5 + grad*0.3 + var*0.2.
confidence = best_fused
# False-positive gate: a low-confidence match that shows NEITHER real-sparkle
# signature is a content false positive, not a white sparkle overlay. A real
# sparkle proves itself by a bright core (high core-ring margin, on dark/mid
# backgrounds) OR a crisp star silhouette (high gradient NCC, on any background
# incl. bright). Demote when both are weak -- this catches the dark/mid no-core
# FP (low margin) AND the bright-background smooth-blob FP (high margin but low
# gradient), which the margin check alone misses. See _SPARKLE_FP_GRAD.
if confidence < self._SPARKLE_FP_CONF and not trust_provenance:
alpha = self.get_interpolated_alpha(best_scale)
pos = (pos_x, pos_y)
margin = self._core_ring_margin(image, alpha, pos)
low_margin = margin is not None and margin < self._SPARKLE_FP_MARGIN
low_grad = grad_score < self._SPARKLE_FP_GRAD
if low_margin or low_grad:
# White-core rescue: a real faint sparkle clears the trust confidence,
# has a bright core (not low_margin), and a near-WHITE core -- unlike the
# colored-corner FP the low-grad demotion targets. See _SPARKLE_WHITE_SAT.
core_sat = self._core_saturation(image, alpha, pos)
white_core = not low_margin and core_sat is not None and core_sat <= self._SPARKLE_WHITE_SAT
if not (confidence >= self._SPARKLE_KEEP_CONF and white_core):
logger.debug(
"Sparkle FP gate: conf=%.3f, margin=%s, grad=%.3f, core_sat=%s; demoting.",
confidence,
f"{margin:.1f}" if margin is not None else "n/a",
grad_score,
f"{core_sat:.2f}" if core_sat is not None else "n/a",
)
confidence = min(confidence, 0.30)
result.confidence = float(max(0.0, min(1.0, confidence)))
confidence = best.fused
if best.spatial >= 0.25 and confidence < self._SPARKLE_FP_CONF and not trust_provenance:
confidence = self._apply_false_positive_gate(source, best, confidence)
result.confidence = float(np.clip(confidence, 0.0, 1.0))
result.detected = result.confidence >= 0.35
logger.debug(
"Detection: spatial=%.3f, grad=%.3f, var=%.3f → conf=%.3f (%s)",
result.spatial_score,
result.gradient_score,
var_score,
result.confidence,
"DETECTED" if result.detected else "not detected",
)
return result
def _grad_var_scores(
self,
image: NDArray[Any],
scale: int,
pos_x: int,
pos_y: int,
) -> tuple[float, float]:
"""Return ``(gradient_score, variance_score)`` for a candidate sparkle.
Factored out of ``detect_watermark`` so each top-K candidate can be scored by
the full fusion before one is selected. The gradient NCC correlates
Sobel-magnitude maps (shape fidelity, contrast-robust); the variance score
rewards a flat overlay region against the row band above it.
"""
h, w = image.shape[:2]
x1, y1 = pos_x, pos_y
x2, y2 = min(w, x1 + scale), min(h, y1 + scale)
region = image[y1:y2, x1:x2]
gray_region = cv2.cvtColor(region, cv2.COLOR_BGR2GRAY) if region.ndim == 3 and region.shape[2] >= 3 else region
gray_f = gray_region.astype(np.float32) / 255.0
alpha_region = self.get_interpolated_alpha(scale)[: y2 - y1, : x2 - x1]
# ── Gradient NCC ──
img_gmag = cv2.magnitude(
cv2.Sobel(gray_f, cv2.CV_32F, 1, 0, ksize=3), cv2.Sobel(gray_f, cv2.CV_32F, 0, 1, ksize=3)
def _apply_false_positive_gate(self, image: NDArray[Any], candidate: _Candidate, confidence: float) -> float:
alpha = self.get_interpolated_alpha(candidate.scale)
position = (candidate.x, candidate.y)
margin = self._core_ring_margin(image, alpha, position)
low_margin = margin is not None and margin < self._SPARKLE_FP_MARGIN
low_gradient = candidate.gradient < self._SPARKLE_FP_GRAD
if not low_margin and not low_gradient:
return confidence
saturation = self._core_saturation(image, alpha, position)
neutral_core = not low_margin and saturation is not None and saturation <= self._SPARKLE_WHITE_SAT
if confidence >= self._SPARKLE_KEEP_CONF and neutral_core:
return confidence
logger.debug(
"Sparkle candidate demoted: confidence=%.3f, margin=%s, gradient=%.3f, saturation=%s",
confidence,
margin,
candidate.gradient,
saturation,
)
alpha_gmag = cv2.magnitude(
cv2.Sobel(alpha_region, cv2.CV_32F, 1, 0, ksize=3), cv2.Sobel(alpha_region, cv2.CV_32F, 0, 1, ksize=3)
return min(confidence, 0.30)
def _grad_var_scores(self, image: NDArray[Any], scale: int, pos_x: int, pos_y: int) -> tuple[float, float]:
height, width = image.shape[:2]
x2, y2 = min(width, pos_x + scale), min(height, pos_y + scale)
region = image[pos_y:y2, pos_x:x2]
gray = _gray_float(region)
alpha = self.get_interpolated_alpha(scale)[: y2 - pos_y, : x2 - pos_x]
image_edges = cv2.magnitude(
cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize=3),
cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3),
)
_, grad_score, _, _ = cv2.minMaxLoc(cv2.matchTemplate(img_gmag, alpha_gmag, cv2.TM_CCOEFF_NORMED))
# ── Variance ──
var_score = 0.0
ref_h = min(y1, scale)
if ref_h > 8:
ref_region = image[y1 - ref_h : y1, x1:x2]
gray_ref = cv2.cvtColor(ref_region, cv2.COLOR_BGR2GRAY) if ref_region.ndim == 3 else ref_region
_, s_wm = cv2.meanStdDev(gray_region)
_, s_ref = cv2.meanStdDev(gray_ref)
if s_ref[0][0] > 5.0:
var_score = max(0.0, min(1.0, 1.0 - (s_wm[0][0] / s_ref[0][0])))
return float(grad_score), float(var_score)
def _corner_promote(
self,
image: NDArray[Any],
current_raw_ncc: float,
) -> tuple[int, int, int, float] | None:
"""Search the bottom-right corner for a very-high-fidelity sparkle match.
Returns ``(scale, x, y, raw_ncc)`` when the corner holds a match with raw
NCC >= ``_CORNER_PROMOTE_NCC`` that beats the global pick's ``current_raw_ncc``,
else None. Used to rescue a small sparkle that the size weight buried under
a larger, lower-fidelity match elsewhere. See ``_CORNER_PROMOTE_NCC`` and
``_CORNER_PROMOTE_FRAC`` for the corner sizing.
"""
h, w = image.shape[:2]
side = max(
self._CORNER_PROMOTE_MIN, min(self._CORNER_PROMOTE_MAX, round(min(w, h) * self._CORNER_PROMOTE_FRAC))
alpha_edges = cv2.magnitude(
cv2.Sobel(alpha, cv2.CV_32F, 1, 0, ksize=3),
cv2.Sobel(alpha, cv2.CV_32F, 0, 1, ksize=3),
)
cs = int(min(min(w, h), side))
cx1, cy1 = max(0, w - cs), max(0, h - cs)
corner = image[cy1:h, cx1:w]
gray = cv2.cvtColor(corner, cv2.COLOR_BGR2GRAY) if corner.ndim == 3 and corner.shape[2] >= 3 else corner
gray = gray.astype(np.float32) / 255.0
response = cv2.matchTemplate(image_edges, alpha_edges, cv2.TM_CCOEFF_NORMED)
_minimum, gradient, _min_location, _max_location = cv2.minMaxLoc(response)
best_raw = -1.0
best_scale = 0
best_loc = (0, 0)
for scale, max_val, max_loc in self._scan_scales(gray):
if max_val > best_raw:
best_raw = max_val
best_scale = scale
best_loc = max_loc
variance = 0.0
reference_height = min(pos_y, scale)
if reference_height > 8:
reference = image[pos_y - reference_height : pos_y, pos_x:x2]
reference_gray = cv2.cvtColor(reference, cv2.COLOR_BGR2GRAY) if reference.ndim == 3 else reference
_mean, region_std = cv2.meanStdDev((gray * 255.0).astype(np.uint8))
_reference_mean, reference_std = cv2.meanStdDev(reference_gray)
if reference_std[0][0] > 5.0:
variance = float(np.clip(1.0 - region_std[0][0] / reference_std[0][0], 0.0, 1.0))
return float(gradient), variance
if best_raw >= self._CORNER_PROMOTE_NCC and best_raw > current_raw_ncc:
return best_scale, cx1 + best_loc[0], cy1 + best_loc[1], float(best_raw)
return None
# ── Removal ──────────────────────────────────────────────────────
# Footprint mask for the localize -> fill removal path. The mask must cover the
# WHOLE sparkle including its faint semi-transparent halo, not just the bright
# core, or the fill leaves a visible ring. Threshold the captured alpha low
# (>_MASK_ALPHA catches the halo the core-only 0.10 misses) then dilate by a
# sparkle-relative margin so alignment slop and the outermost halo are absorbed.
_MASK_ALPHA = 0.04
_MASK_DILATE_FRAC = 0.18 # dilation radius as a fraction of the sparkle scale
def _corner_promote(self, image: NDArray[Any], current_raw_ncc: float) -> tuple[int, int, int, float] | None:
height, width = image.shape[:2]
desired = round(min(width, height) * self._CORNER_PROMOTE_FRAC)
side = min(min(width, height), max(self._CORNER_PROMOTE_MIN, min(self._CORNER_PROMOTE_MAX, desired)))
origin_x, origin_y = width - side, height - side
matches = self._scan_scales(_gray_float(image[origin_y:height, origin_x:width]))
best = max(matches, key=lambda item: item[1], default=None)
if best is None or best[1] < self._CORNER_PROMOTE_NCC or best[1] <= current_raw_ncc:
return None
return best[0], origin_x + best[2][0], origin_y + best[2][1], float(best[1])
def footprint_mask(
self,
@@ -550,50 +296,37 @@ class GeminiEngine:
dilate: int | None = None,
region: tuple[int, int, int, int] | None = None,
) -> NDArray[Any] | None:
"""Full-frame uint8 mask (255 = sparkle) of the sparkle footprint, for the
shared fill removal path (cv2 / MI-GAN / LaMa), or None.
The footprint is the interpolated captured alpha at the detected scale,
thresholded LOW so the faint halo is included, then dilated by a
sparkle-relative margin. When ``force`` and nothing is detected, falls back to
the default sparkle slot for the image size (the ``--no-detect`` path).
``region`` is the already-resolved ``(x, y, scale)`` from the caller's detection
(the registry passes the decision's provenance-aware region). When given, the
mask is built from it directly WITHOUT a second internal detect -- otherwise a
provenance/assume-relaxed sparkle would be re-demoted by the strict re-detect and
yield no mask (reported-removed-but-unchanged). Absent ``region``, direct callers
keep the detect-then-force behavior.
"""
"""Build a full-frame mask from a resolved or newly detected sparkle."""
if image is None or image.size == 0:
return None # guard before to_bgr (cvtColor raises on an empty Mat); mirror detect_watermark
image = image_io.to_bgr(image)
h, w = image.shape[:2]
return None
source = image_io.to_bgr(image)
height, width = source.shape[:2]
if region is not None:
x, y, scale = region[0], region[1], region[2]
x, y, scale = region[:3]
else:
det = self.detect_watermark(image)
if det.detected:
x, y, scale = det.region[0], det.region[1], det.region[2]
detection = self.detect_watermark(source)
if detection.detected:
x, y, scale = detection.region[:3]
elif force:
cfg = get_watermark_config(w, h)
x, y = cfg.get_position(w, h)
scale = cfg.logo_size
config = get_watermark_config(width, height)
x, y = config.get_position(width, height)
scale = config.logo_size
else:
return None
alpha = self.get_interpolated_alpha(scale)
fp = self._footprint_indices(alpha, (x, y), image.shape)
if fp is None:
placed = self._footprint_indices(self.get_interpolated_alpha(scale), (x, y), source.shape)
if placed is None:
return None
aroi, (y1, y2, x1, x2) = fp
sil = (aroi > self._MASK_ALPHA).astype(np.uint8) * 255
if int((sil > 0).sum()) == 0:
alpha, (y1, y2, x1, x2) = placed
silhouette = (alpha > self._MASK_ALPHA).astype(np.uint8) * 255
if not silhouette.any():
return None
mask = np.zeros((h, w), np.uint8)
mask[y1:y2, x1:x2] = sil
d = dilate if dilate is not None else max(13, int(scale * self._MASK_DILATE_FRAC))
if d > 0:
mask = cv2.dilate(mask, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * d + 1, 2 * d + 1)))
mask = np.zeros((height, width), dtype=np.uint8)
mask[y1:y2, x1:x2] = silhouette
radius = dilate if dilate is not None else max(13, int(scale * self._MASK_DILATE_FRAC))
if radius > 0:
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * radius + 1, 2 * radius + 1))
mask = cv2.dilate(mask, kernel)
return mask
def _footprint_indices(
@@ -602,21 +335,35 @@ class GeminiEngine:
position: tuple[int, int],
image_shape: tuple[int, ...],
) -> tuple[NDArray[Any], tuple[int, int, int, int]] | None:
"""Return (alpha_roi, (y1, y2, x1, x2)) for the placed footprint, or None.
Shared by the over-subtraction test and the inpaint mask so both operate on
exactly the same clipped, in-bounds region.
"""
x, y = position
ah, aw = alpha_map.shape[:2]
ih, iw = image_shape[:2]
alpha_height, alpha_width = alpha_map.shape[:2]
image_height, image_width = image_shape[:2]
x1, y1 = max(0, x), max(0, y)
x2, y2 = min(iw, x + aw), min(ih, y + ah)
x2, y2 = min(image_width, x + alpha_width), min(image_height, y + alpha_height)
if x1 >= x2 or y1 >= y2:
return None
ax1, ay1 = x1 - x, y1 - y
alpha_roi = alpha_map[ay1 : ay1 + (y2 - y1), ax1 : ax1 + (x2 - x1)]
return alpha_roi, (y1, y2, x1, x2)
alpha_x, alpha_y = x1 - x, y1 - y
clipped = alpha_map[alpha_y : alpha_y + y2 - y1, alpha_x : alpha_x + x2 - x1]
return clipped, (y1, y2, x1, x2)
def _core_mask_and_box(
self,
image: NDArray[Any],
alpha_map: NDArray[Any],
position: tuple[int, int],
) -> tuple[NDArray[Any], NDArray[Any], tuple[int, int, int, int], float] | None:
placed = self._footprint_indices(alpha_map, position, image.shape)
if placed is None:
return None
alpha, bounds = placed
peak = float(alpha.max())
if peak < 0.2:
return None
core = alpha >= peak * self._CORE_ALPHA_FRAC
if not core.any():
return None
y1, y2, x1, x2 = bounds
return core, image[y1:y2, x1:x2], bounds, peak
def _core_and_bg(
self,
@@ -624,41 +371,22 @@ class GeminiEngine:
alpha_map: NDArray[Any],
position: tuple[int, int],
) -> tuple[float, float, float] | None:
"""Return ``(core_obs, bg, a_cap)`` for the placed sparkle, or None.
``core_obs`` is the bright-core brightness (75th pct over the high-alpha
core), ``bg`` the local background ring median, ``a_cap`` the captured peak
alpha. Shared by the alpha-gain estimate and the false-positive margin gate.
None when the footprint or the background ring cannot be sampled.
"""
placed = self._footprint_indices(alpha_map, position, image.shape)
if placed is None:
sample = self._core_mask_and_box(image, alpha_map, position)
if sample is None:
return None
alpha_roi, (y1, y2, x1, x2) = placed
a_cap = float(alpha_roi.max())
if a_cap < 0.2:
return None
core = alpha_roi >= a_cap * self._CORE_ALPHA_FRAC
if not bool(core.any()):
return None
# Convert only the footprint+ring crop to gray, not the whole image: every
# sample below lives inside the ring box, so a full-image mean is wasted work
# that scales with resolution (~70 ms on a 12 MP image, recomputed for both
# the alpha-gain estimate and the over-subtraction gate). The crop is sized by
# the footprint, so this is O(footprint^2) regardless of image size.
ih, iw = image.shape[:2]
pad = int((x2 - x1) * 0.7)
ry1, ry2 = max(0, y1 - pad), min(ih, y2 + pad)
rx1, rx2 = max(0, x1 - pad), min(iw, x2 + pad)
ring = image[ry1:ry2, rx1:rx2].astype(np.float32).mean(axis=2)
# Footprint box expressed in ring-crop coordinates.
core, _box, (y1, y2, x1, x2), peak = sample
height, width = image.shape[:2]
padding = int((x2 - x1) * 0.7)
ry1, ry2 = max(0, y1 - padding), min(height, y2 + padding)
rx1, rx2 = max(0, x1 - padding), min(width, x2 + padding)
luminance = image[ry1:ry2, rx1:rx2].astype(np.float32).mean(axis=2)
fy1, fy2, fx1, fx2 = y1 - ry1, y2 - ry1, x1 - rx1, x2 - rx1
core_obs = float(np.percentile(ring[fy1:fy2, fx1:fx2][core], 75))
ring_mask = np.ones(ring.shape, dtype=bool)
ring_mask[fy1:fy2, fx1:fx2] = False
if int(ring_mask.sum()) < 10:
core_value = float(np.percentile(luminance[fy1:fy2, fx1:fx2][core], 75))
background = np.ones(luminance.shape, dtype=bool)
background[fy1:fy2, fx1:fx2] = False
if background.sum() < 10:
return None
return core_obs, float(np.median(ring[ring_mask])), a_cap
return core_value, float(np.median(luminance[background])), peak
def _core_ring_margin(
self,
@@ -666,14 +394,8 @@ class GeminiEngine:
alpha_map: NDArray[Any],
position: tuple[int, int],
) -> float | None:
"""Bright-core brightness minus the local background ring (gray levels).
A real white sparkle overlay lifts its core above the surroundings; a
shape-only NCC false positive on ornate/flat content does not. None when the
background ring cannot be sampled.
"""
cb = self._core_and_bg(image, alpha_map, position)
return None if cb is None else cb[0] - cb[1]
sample = self._core_and_bg(image, alpha_map, position)
return None if sample is None else sample[0] - sample[1]
def _core_saturation(
self,
@@ -681,57 +403,24 @@ class GeminiEngine:
alpha_map: NDArray[Any],
position: tuple[int, int],
) -> float | None:
"""Median color saturation of the sparkle core (0 = white/neutral, higher =
colored). A real Gemini sparkle is a white star, so its core is near-neutral;
a clean bright corner that shape-matches (sky, sun, a warm light) is colored,
so a high core saturation flags the false positive the brightness/gradient
gates miss. Samples the same high-alpha core pixels as :meth:`_core_and_bg`.
None when the footprint cannot be placed or the core is empty.
"""
placed = self._footprint_indices(alpha_map, position, image.shape)
if placed is None:
sample = self._core_mask_and_box(image, alpha_map, position)
if sample is None:
return None
alpha_roi, (y1, y2, x1, x2) = placed
a_cap = float(alpha_roi.max())
if a_cap < 0.2:
return None
core = alpha_roi >= a_cap * self._CORE_ALPHA_FRAC
box = image[y1:y2, x1:x2]
if box.shape[:2] != core.shape or not bool(core.any()):
return None
px = box[core].astype(np.float32) # (N, 3) BGR core pixels
hi = px.max(axis=1)
lo = px.min(axis=1)
return float(np.median((hi - lo) / (hi + 1.0)))
core, box, _bounds, _peak = sample
pixels = box[core].astype(np.float32)
brightest = pixels.max(axis=1)
darkest = pixels.min(axis=1)
return float(np.median((brightest - darkest) / (brightest + 1.0)))
@functools.lru_cache(maxsize=1)
def _shared_engine() -> GeminiEngine:
"""Process-wide default ``GeminiEngine`` singleton.
The engine holds only constant assets (embedded captures, alpha maps, the
precomputed template ladder) and takes the image as a method argument, so one
instance is reused across every ``detect_sparkle_confidence`` call instead of
reloading assets + recomputing alpha maps + rebuilding the template cache on
each of the ~34k images an ``identify`` batch scans. Output is identical."""
return GeminiEngine()
def detect_sparkle_confidence(image_path: Path, *, image: NDArray[Any] | None = None) -> float | None:
"""Visible-sparkle detection confidence for a file, for provenance use.
Loads the image with cv2 and runs :meth:`GeminiEngine.detect_watermark`.
Returns the NCC confidence in [0, 1], or None if the image cannot be read
(cv2 returns None for unsupported containers such as HEIC). Kept here so the
cv2 dependency stays in this module; callers apply their own threshold.
``image`` lets a caller that has already decoded the file (e.g. ``identify``
running several visible-mark detectors) pass the BGR array to avoid a second
full decode; when None the file is read from ``image_path``.
"""
from remove_ai_watermarks import image_io
img = image if image is not None else image_io.imread(image_path)
if img is None:
"""Return the local sparkle confidence, or None when decoding fails."""
decoded = image if image is not None else image_io.imread(image_path)
if decoded is None:
return None
return float(_shared_engine().detect_watermark(img).confidence)
return float(_shared_engine().detect_watermark(decoded).confidence)
+14 -21
View File
@@ -1,7 +1,7 @@
"""Post-processing filters for the cleaned output.
``apply_analog_humanizer`` injects film grain and chromatic aberration to defeat
digital AI-perfection classifiers (ported from NeuralBleach); ``unsharp_mask``
``apply_analog_humanizer`` injects film grain and chromatic aberration to reduce
overly uniform digital surfaces; ``unsharp_mask``
counters the soft, over-smoothed look that the diffusion pass leaves behind
(itself a common "this is AI" tell).
"""
@@ -16,10 +16,7 @@ from numpy.typing import NDArray
def apply_analog_humanizer(image: NDArray, grain_intensity: float = 4.0, chromatic_shift: int = 1) -> NDArray:
"""
Apply Analog Humanizer (film grain and chromatic aberration) to an image.
This simulates analog film imperfections to defeat digital AI perfection classifiers.
Ported from NeuralBleach.
Apply shared-luminance grain and a small lateral color offset.
Args:
image: BGR image as numpy array (uint8).
@@ -33,26 +30,22 @@ def apply_analog_humanizer(image: NDArray, grain_intensity: float = 4.0, chromat
if len(image.shape) != 3 or image.shape[2] != 3:
return image.copy()
# Split channels (OpenCV uses BGR)
# B = 0, G = 1, R = 2
# Translate the outer color channels without circular edge wrapping.
b, g, r = cv2.split(image)
# 1. Chromatic Aberration
# Shift R channel left, B channel right. np.roll is circular, so it wraps
# the opposite edge into a thin colored fringe at the L/R borders; replicate
# the original edge columns there to keep the intended offset interior-only.
# Clamp so the edge-replication slices below always have a source column: a shift
# >= width would leave them empty and crash the broadcast (r[:, -shift:] = (H, 0)).
shift = min(chromatic_shift, image.shape[1] - 1)
shift = min(max(0, chromatic_shift), max(0, image.shape[1] - 1))
if shift > 0:
r = np.roll(r, -shift, axis=1)
r[:, -shift:] = r[:, -shift - 1 : -shift]
b = np.roll(b, shift, axis=1)
b[:, :shift] = b[:, shift : shift + 1]
shifted_b = np.empty_like(b)
shifted_b[:, :shift] = b[:, :1]
shifted_b[:, shift:] = b[:, :-shift]
b = shifted_b
shifted_r = np.empty_like(r)
shifted_r[:, :-shift] = r[:, shift:]
shifted_r[:, -shift:] = r[:, -1:]
r = shifted_r
merged = cv2.merge((b, g, r))
# 2. Film Grain (Gaussian Noise)
if grain_intensity > 0:
img_f = merged.astype(np.float32)
noise = np.random.normal(0, grain_intensity, img_f.shape).astype(np.float32)
+13 -13
View File
@@ -25,6 +25,18 @@ import logging
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, cast
from remove_ai_watermarks._internal.c2pa import (
c2pa_info_from_manifest_store,
cbor_text_after,
extract_c2pa_info,
soft_binding_vendors_in,
)
from remove_ai_watermarks._internal.constants import (
C2PA_AI_TOOLS,
C2PA_AI_VENDORS,
C2PA_IDENTITY_AI_ORGS,
C2PA_ISSUERS,
)
from remove_ai_watermarks.metadata import (
AI_METADATA_KEYS,
AIGC_MARKERS,
@@ -46,18 +58,6 @@ from remove_ai_watermarks.metadata import (
xai_signature,
xai_signature_pair,
)
from remove_ai_watermarks.noai.c2pa import (
c2pa_info_from_manifest_store,
cbor_text_after,
extract_c2pa_info,
soft_binding_vendors_in,
)
from remove_ai_watermarks.noai.constants import (
C2PA_AI_TOOLS,
C2PA_AI_VENDORS,
C2PA_IDENTITY_AI_ORGS,
C2PA_ISSUERS,
)
from remove_ai_watermarks.watermark_registry import GEMINI_SPARKLE_TRUST_CONF
if TYPE_CHECKING:
@@ -822,7 +822,7 @@ def _identify_from_evidence(
issuers = [info["issuer"]] if info.get("issuer") else _issuers_in(head)
# 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
# _internal.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")
+4 -10
View File
@@ -1,7 +1,4 @@
"""Invisible watermark removal engine.
Wraps the vendored noai-watermark code for removing invisible AI watermarks
(SynthID, StableSignature, TreeRing) via diffusion-based regeneration.
"""Diffusion engine for regenerating images that carry invisible AI watermarks.
This module requires the 'gpu' extra dependencies:
uv pip install 'remove-ai-watermarks[diffusion]'
@@ -19,10 +16,10 @@ import warnings
from pathlib import Path
from typing import TYPE_CHECKING, Any
from .noai.watermark_profiles import (
from ._internal.watermark_profiles import (
DEFAULT_MODEL_ID as DEFAULT_SDXL_MODEL_ID,
)
from .noai.watermark_profiles import (
from ._internal.watermark_profiles import (
resolve_seed,
)
@@ -81,9 +78,6 @@ def _target_size(width: int, height: int, max_resolution: int, min_resolution: i
class InvisibleEngine:
"""Remove invisible AI watermarks using diffusion model regeneration.
Based on noai-watermark by mertizci:
https://github.com/mertizci/noai-watermark
The approach encodes the image into latent space, injects controlled noise
to break watermark patterns, and reconstructs via reverse diffusion.
"""
@@ -124,7 +118,7 @@ class InvisibleEngine:
residency. CUDA only.
"""
from remove_ai_watermarks.noai.watermark_remover import WatermarkRemover
from remove_ai_watermarks._internal.watermark_remover import WatermarkRemover
effective_model = model_id or self.DEFAULT_MODEL_ID
+15 -18
View File
@@ -1,7 +1,4 @@
"""AI metadata detection and removal.
Wraps the noai-watermark metadata handling for stripping AI-generation
metadata (EXIF, PNG text chunks, C2PA provenance) from images.
"""Detect and remove AI provenance metadata from image containers.
For metadata-only operations, the heavy ML dependencies are NOT required.
"""
@@ -104,7 +101,7 @@ IPTC_AI_MARKERS: tuple[bytes, ...] = (
# (Meta / Instagram / MidJourney) use ``trainedAlgorithmicMedia``. Including the bare
# token flagged clean procedural images as AI (is_ai=high + has_invisible_target=True ->
# a diffusion scrub of clean content), contradicting the c2pa layer, which sets
# source_type without ai_source for it (tests/test_noai.py::test_plain_algorithmic_media_not_flagged_ai).
# source_type without ai_source for it (tests/test_metadata_internals.py::test_plain_algorithmic_media_not_flagged_ai).
# It is not a substring of the trained/composite tokens, so its removal does not affect
# their detection.
@@ -218,7 +215,7 @@ def _is_ai_value(value: str) -> bool:
detection: NovelAI stamps a generic ``Title``/``Source`` text chunk (an
AI-shaped value under a non-AI key) that ``_is_ai_key`` alone would keep.
"""
from remove_ai_watermarks.noai.constants import AI_GENERATOR_TOKENS
from remove_ai_watermarks._internal.constants import AI_GENERATOR_TOKENS
value_lower = value.lower()
return any(token in value_lower for token in AI_GENERATOR_TOKENS)
@@ -310,7 +307,7 @@ def _scan_head_impl(image_path: Path, size: int) -> bytes:
with open(image_path, "rb") as f:
head = f.read(size)
# Lazy import: isobmff imports this module's constants at top level.
from remove_ai_watermarks.noai import isobmff
from remove_ai_watermarks._internal import isobmff
if isobmff.is_isobmff(head):
region = isobmff.scan_c2pa_region(image_path)
@@ -348,7 +345,7 @@ def has_ai_metadata(image_path: Path) -> bool:
# Check C2PA — via the official c2pa-python reader first (spec-tracking, every
# container it supports), then a binary scan that also catches AVIF/HEIF/JPEG-XL
# containers and synthetic/partial blobs the validator rejects.
from remove_ai_watermarks.noai.c2pa import read_manifest_store_json
from remove_ai_watermarks._internal.c2pa import read_manifest_store_json
if read_manifest_store_json(image_path) is not None:
return True
@@ -466,7 +463,7 @@ def aigc_label(image_path: Path) -> dict[str, str] | None:
# in ``moov.udta.meta.keys`` and points to a raw JSON value in ``ilst``.
# Read it through the bounded box walker so a tail ``moov`` after a large
# ``mdat`` is found without loading or scanning the media payload.
from remove_ai_watermarks.noai.isobmff import tc260_aigc_payloads
from remove_ai_watermarks._internal.isobmff import tc260_aigc_payloads
isobmff_candidates = tuple(payload.decode("utf-8", "replace") for payload in tc260_aigc_payloads(image_path))
if result := aigc_label_from_metadata(b"", isobmff_candidates):
@@ -475,7 +472,7 @@ def aigc_label(image_path: Path) -> dict[str, str] | None:
# Native MKV/WebM TC260 metadata: ``Segment.Tags.Tag.SimpleTag`` carries
# ``TagName=AIGC`` and the raw JSON in ``TagString``. The EBML walker seeks
# over clusters and reads only bounded metadata values.
from remove_ai_watermarks.noai.ebml import tc260_aigc_payloads as ebml_tc260_aigc_payloads
from remove_ai_watermarks._internal.ebml import tc260_aigc_payloads as ebml_tc260_aigc_payloads
ebml_candidates = tuple(payload.decode("utf-8", "replace") for payload in ebml_tc260_aigc_payloads(image_path))
if result := aigc_label_from_metadata(b"", ebml_candidates):
@@ -486,11 +483,11 @@ def aigc_label(image_path: Path) -> dict[str, str] | None:
# that could collide inside compressed video.
legacy_payloads: tuple[bytes, ...] = ()
if image_path.suffix.lower() == ".avi":
from remove_ai_watermarks.noai.riff import tc260_aigc_payloads as riff_tc260_aigc_payloads
from remove_ai_watermarks._internal.riff import tc260_aigc_payloads as riff_tc260_aigc_payloads
legacy_payloads = riff_tc260_aigc_payloads(image_path)
elif image_path.suffix.lower() == ".flv":
from remove_ai_watermarks.noai.flv import tc260_aigc_payloads as flv_tc260_aigc_payloads
from remove_ai_watermarks._internal.flv import tc260_aigc_payloads as flv_tc260_aigc_payloads
legacy_payloads = flv_tc260_aigc_payloads(image_path)
legacy_candidates = tuple(payload.decode("utf-8", "replace") for payload in legacy_payloads)
@@ -672,7 +669,7 @@ def synthid_source(image_path: Path) -> str | None:
Returns:
Comma-joined vendor name(s) (e.g. ``"OpenAI"``) or None.
"""
from remove_ai_watermarks.noai.c2pa import extract_c2pa_info, synthid_vendors_in
from remove_ai_watermarks._internal.c2pa import extract_c2pa_info, synthid_vendors_in
# PNG: the caBX chunk parser gives a clean, structured issuer.
vendors = extract_c2pa_info(image_path).get("synthid_vendors")
@@ -694,7 +691,7 @@ def synthid_source(image_path: Path) -> str | None:
def generator_from_metadata(candidates: Iterable[str], scan: bytes = b"") -> str | None:
"""Return a known AI generator from collected EXIF, PNG, or XMP values."""
from remove_ai_watermarks.noai.constants import AI_GENERATOR_TOKENS
from remove_ai_watermarks._internal.constants import AI_GENERATOR_TOKENS
creator_tools = (
match.group(1).decode("latin1", "replace")
@@ -844,7 +841,7 @@ def _ai_exif_targets(loaded: dict[str, Any]) -> list[tuple[str, int, bytes, str]
"""
import piexif
from remove_ai_watermarks.noai.constants import AI_GENERATOR_TOKENS
from remove_ai_watermarks._internal.constants import AI_GENERATOR_TOKENS
ifd0: dict[int, Any] = loaded.get("0th") or {}
ifde: dict[int, Any] = loaded.get("Exif") or {}
@@ -903,7 +900,7 @@ def get_ai_metadata(image_path: Path) -> dict[str, str]:
"""
from PIL import Image
from remove_ai_watermarks.noai.c2pa import extract_c2pa_info, soft_binding_vendors_in, synthid_verdict
from remove_ai_watermarks._internal.c2pa import extract_c2pa_info, soft_binding_vendors_in, synthid_verdict
result: dict[str, str] = {}
@@ -923,7 +920,7 @@ def get_ai_metadata(image_path: Path) -> dict[str, str]:
except Exception as exc:
logger.debug("PIL could not open %s for AI-metadata scan: %s", image_path, exc)
# C2PA manifest fields from the single canonical parser (noai/c2pa.py).
# C2PA manifest fields from the single canonical parser (_internal/c2pa.py).
c2pa = extract_c2pa_info(image_path)
for key in (
"c2pa_manifest",
@@ -1215,7 +1212,7 @@ def remove_ai_metadata(
# offset-preserving streaming path; images retain the in-memory item scrub
# needed for XMP/EXIF inside mdat/idat. Route the remaining formats by suffix
# OR by an ``ftyp`` content sniff.
from remove_ai_watermarks.noai.isobmff import (
from remove_ai_watermarks._internal.isobmff import (
blank_ai_exif_tokens,
blank_ai_xmp_packets,
blank_tc260_aigc_tags,
-562
View File
@@ -1,562 +0,0 @@
"""C2PA (Coalition for Content Provenance and Authenticity) metadata handling.
Reading goes through the official c2pa-python ``Reader`` first (any container it
supports), via ``extract_c2pa_info`` / ``read_manifest_store_json``. The
hand-rolled PNG ``caBX`` JUMBF-chunk tools below (``has_c2pa_metadata`` /
``extract_c2pa_chunk`` / ``inject_c2pa_chunk`` and the ``_extract_c2pa_info_png``
fallback) cover raw-chunk extraction, re-injection, and the cases the validator
rejects (synthetic/partial blobs, a broken/absent wheel). Known issuers:
- Google Imagen
- Adobe Firefly
- Microsoft Designer
- OpenAI (ChatGPT, GPT-4o, Sora, DALL-E)
- Truepic (signing authority)
The fallback parser uses byte-level scanning it does not validate JUMBF/CBOR
structure but reliably identifies known signatures, issuers, tools, and actions.
The vendor / source-type / SynthID / soft-binding registry scan
(``_populate_registry_fields``) is shared by both the reader and fallback paths.
"""
from __future__ import annotations
import contextlib
import functools
import json
import logging
import re
import struct
from pathlib import Path
from typing import Any, cast
from remove_ai_watermarks.noai.constants import (
C2PA_ACTIONS,
C2PA_AI_TOOLS,
C2PA_CHUNK_TYPE,
C2PA_ISSUERS,
C2PA_SIGNATURES,
C2PA_SOFT_BINDINGS,
PNG_SIGNATURE,
SYNTHID_C2PA_ISSUERS,
)
logger = logging.getLogger(__name__)
# Official C2PA reader (c2pa-python, a default dependency). It is the primary,
# spec-tracking manifest parser; the hand-rolled caBX/CBOR scanner below stays as
# a fallback for synthetic/partial blobs the validator rejects. The import is
# guarded so a partially-broken install degrades to the byte-scan rather than
# crashing the dependency-light identify path.
_C2paReader: Any = None
with contextlib.suppress(Exception): # broken/absent wheel -> byte-scan fallback
from c2pa import Reader as _C2paReader # pyright: ignore[reportMissingTypeStubs]
_C2PA_READER_AVAILABLE = _C2paReader is not None
def reader_available() -> bool:
"""True when the official c2pa-python Reader imported successfully."""
return _C2PA_READER_AVAILABLE
def read_manifest_store_json(image_path: Path) -> str | None:
"""Return the full C2PA manifest-store JSON for ``image_path``, or None.
Uses the official c2pa-python ``Reader`` (any container it supports: PNG,
JPEG, WebP, AVIF/HEIF, MP4, ...). Returns None when the reader is unavailable,
the file carries no parseable manifest, or parsing fails. The JSON is the
WHOLE store (every manifest plus ingredient manifests), matching the
whole-chunk semantics of the legacy byte scan -- an AI-source marker in a
parent/ingredient manifest (e.g. a ChatGPT edit of a Sora generation) is
still seen.
Memoized per (path, mtime): one identify/get_ai_metadata call invokes the
structured parser ~3 times on the same file, so the cache turns the repeated
crypto-validating reads into one.
"""
if not _C2PA_READER_AVAILABLE:
return None
try:
mtime = image_path.stat().st_mtime_ns
except OSError:
return _read_manifest_store_impl(str(image_path))
return _read_manifest_store_cached(str(image_path), mtime)
@functools.lru_cache(maxsize=8)
def _read_manifest_store_cached(path_str: str, _mtime_ns: int) -> str | None:
"""Cache shim: ``_mtime_ns`` is part of the key only (invalidates on change)."""
return _read_manifest_store_impl(path_str)
def _read_manifest_store_impl(path_str: str) -> str | None:
# try_create returns None when there is no manifest; a default Reader does no
# trust enforcement, so an untrusted signer still yields the manifest content
# (we report what is in the file, we do not gate on certificate trust).
try:
reader = _C2paReader.try_create(path_str)
except Exception as exc: # malformed manifest, unsupported container, etc.
logger.debug("c2pa Reader could not parse %s: %s", path_str, exc)
return None
if reader is None:
return None
try:
with reader:
return reader.json()
except Exception as exc: # pragma: no cover - reader opened but json() failed
logger.debug("c2pa Reader.json() failed on %s: %s", path_str, exc)
return None
def has_c2pa_metadata(image_path: Path) -> bool:
"""
Check if an image contains C2PA metadata.
Args:
image_path: Path to the image file.
Returns:
True if C2PA metadata is detected, False otherwise.
"""
image_path = Path(image_path)
if image_path.suffix.lower() != ".png":
return False
try:
with open(image_path, "rb") as f:
signature = f.read(8)
if signature != PNG_SIGNATURE:
return False
file_size = f.seek(0, 2)
f.seek(8)
while True:
chunk_header = f.read(8)
if len(chunk_header) < 8:
break
length = struct.unpack(">I", chunk_header[:4])[0]
chunk_type = chunk_header[4:8]
# Clamp the attacker-controlled 32-bit length to the bytes that
# actually remain, so a malformed huge length can't allocate GBs.
safe_length = max(0, min(length, file_size - f.tell()))
if chunk_type == C2PA_CHUNK_TYPE:
chunk_data = f.read(safe_length)
# Check for any C2PA signature
for sig in C2PA_SIGNATURES:
if sig in chunk_data:
return True
# Also check if chunk_data itself contains C2PA-like patterns
if b"jumb" in chunk_data.lower() or b"c2pa" in chunk_data.lower():
return True
f.read(4)
else:
f.seek(safe_length + 4, 1)
if chunk_type == b"IEND":
break
except Exception:
pass
return False
def _claim_generator_from_store(store: dict[str, Any]) -> str | None:
"""Structured claim-generator name from the active manifest of a store dict.
Prefers the top-level ``claim_generator`` string (Firefly: "Adobe_Firefly"),
falling back to the first ``claim_generator_info[].name`` (ChatGPT keys it
only there). isprintable() guards against odd binary-ish values.
"""
active = _active_manifest(store)
generator: Any = active.get("claim_generator")
if not (isinstance(generator, str) and generator):
info_list: list[Any] = active.get("claim_generator_info") or []
if info_list and isinstance(first := info_list[0], dict):
generator = cast("dict[str, Any]", first).get("name")
return generator if isinstance(generator, str) and generator and generator.isprintable() else None
def _active_manifest(store: dict[str, Any]) -> dict[str, Any]:
"""The active manifest dict from a manifest-store dict, or {} when absent."""
manifests: Any = store.get("manifests")
if not isinstance(manifests, dict):
return {}
active = cast("dict[str, Any]", manifests).get(store.get("active_manifest", ""))
return cast("dict[str, Any]", active) if isinstance(active, dict) else {}
def _info_from_store(store: dict[str, Any], store_bytes: bytes) -> dict[str, Any]:
"""Build normalized C2PA info from one parsed manifest store."""
c2pa_info: dict[str, Any] = {
"has_c2pa": True,
"type": "C2PA (Coalition for Content Provenance and Authenticity)",
"c2pa_manifest": f"C2PA manifest store ({len(store_bytes)} bytes)",
}
# The whole-store JSON carries every vendor / source-type / SynthID /
# soft-binding signature (across active + ingredient manifests), so the same
# registry scan that runs on the raw caBX chunk applies unchanged here.
_populate_registry_fields(store_bytes, c2pa_info)
if generator := _claim_generator_from_store(store):
c2pa_info["claim_generator"] = generator
sig: Any = _active_manifest(store).get("signature_info")
if isinstance(sig, dict) and (time := cast("dict[str, Any]", sig).get("time")):
c2pa_info["timestamp"] = str(time)
return c2pa_info
def _info_from_store_json(store_json: str) -> dict[str, Any]:
"""Build the C2PA info dict from a c2pa-python manifest-store JSON string."""
store_bytes = store_json.encode("utf-8")
try:
parsed: Any = json.loads(store_json)
except (ValueError, TypeError):
parsed = {}
store = cast("dict[str, Any]", parsed) if isinstance(parsed, dict) else {}
return _info_from_store(store, store_bytes)
def c2pa_info_from_manifest_store(store: str | dict[str, Any]) -> dict[str, Any]:
"""Build normalized C2PA evidence from an externally collected manifest store.
``store`` may be the JSON string returned by ``c2pa.Reader.json()`` or its
decoded dictionary form. This is the non-file-backed counterpart to
:func:`extract_c2pa_info`.
"""
if isinstance(store, dict):
parsed = store
try:
store_json = json.dumps(store, ensure_ascii=False)
except (TypeError, ValueError):
return {}
else:
store_json = store
try:
decoded: Any = json.loads(store_json)
except (TypeError, ValueError):
return {}
if not isinstance(decoded, dict):
return {}
parsed = cast("dict[str, Any]", decoded)
if not store_json or not parsed or parsed.get("error"):
return {}
return _info_from_store(parsed, store_json.encode("utf-8"))
def extract_c2pa_info(image_path: Path) -> dict[str, Any]:
"""
Extract C2PA metadata information from an image.
Uses the official c2pa-python reader first (any supported container), falling
back to the hand-rolled PNG caBX parser when the reader is unavailable or the
file carries no parseable manifest (synthetic/partial blobs).
Args:
image_path: Path to the image file.
Returns:
Dictionary containing C2PA metadata info, or {} when none is found.
"""
image_path = Path(image_path)
if (store_json := read_manifest_store_json(image_path)) is not None:
return _info_from_store_json(store_json)
return _extract_c2pa_info_png(image_path)
def _extract_c2pa_info_png(image_path: Path) -> dict[str, Any]:
"""Fallback PNG caBX parser, used when the c2pa-python reader finds nothing."""
c2pa_info: dict[str, Any] = {}
if not has_c2pa_metadata(image_path):
return c2pa_info
c2pa_info["has_c2pa"] = True
c2pa_info["type"] = "C2PA (Coalition for Content Provenance and Authenticity)"
try:
with open(image_path, "rb") as f:
signature = f.read(8)
if signature != PNG_SIGNATURE:
return c2pa_info
file_size = f.seek(0, 2)
f.seek(8)
while True:
chunk_header = f.read(8)
if len(chunk_header) < 8:
break
length = struct.unpack(">I", chunk_header[:4])[0]
chunk_type = chunk_header[4:8]
# Clamp the attacker-controlled 32-bit length to the bytes that
# actually remain, so a malformed huge length can't allocate GBs.
safe_length = max(0, min(length, file_size - f.tell()))
if chunk_type == C2PA_CHUNK_TYPE:
chunk_data = f.read(safe_length)
_parse_c2pa_chunk(chunk_data, c2pa_info)
f.read(4)
else:
f.seek(safe_length + 4, 1)
if chunk_type == b"IEND":
break
except Exception:
pass
return c2pa_info
def cbor_text_after(payload: bytes, key: bytes) -> str | None:
"""Return the CBOR text-string immediately following ``key`` in ``payload``.
Handles CBOR major-type 3 length prefixes: direct (0x60-0x77), 1-byte
(0x78 NN), and 2-byte (0x79 NN NN). This reads the actual encoded value, so
it avoids the byte-grabbing artifacts a loose regex produces (e.g. the
leading length byte showing up as ``fGPT-4o``).
"""
idx = payload.find(key)
if idx < 0:
return None
p = idx + len(key)
if p >= len(payload):
return None
head = payload[p]
if 0x60 <= head <= 0x77:
length, start = head - 0x60, p + 1
elif head == 0x78 and p + 1 < len(payload):
length, start = payload[p + 1], p + 2
elif head == 0x79 and p + 2 < len(payload):
length, start = (payload[p + 1] << 8) | payload[p + 2], p + 3
else:
return None
raw_str = payload[start : start + length]
try:
return raw_str.decode("utf-8")
except UnicodeDecodeError:
return raw_str.decode("latin1", errors="replace")
def synthid_verdict(vendors: str) -> str:
"""Human-readable SynthID-source verdict, shared by all callers."""
return f"likely present ({vendors} embeds SynthID with C2PA)"
def synthid_vendors_in(buffer: bytes) -> list[str]:
"""Return SynthID-using C2PA issuer names whose signature appears in ``buffer``.
Shared by the PNG caBX parser and the format-agnostic binary scan so both
apply the same SYNTHID_C2PA_ISSUERS rule against their respective bytes.
"""
return sorted({name for sig, name in C2PA_ISSUERS.items() if sig in buffer and sig in SYNTHID_C2PA_ISSUERS})
def soft_binding_vendors_in(buffer: bytes) -> list[str]:
"""Return forensic-watermark vendor names whose C2PA soft-binding ``alg``
identifier appears in ``buffer``.
A ``c2pa.soft-binding`` assertion names the watermark scheme that stamped the
pixels (Adobe TrustMark, Digimarc, Imatag, Steg.AI, ...). Shared by the PNG
caBX parser and the format-agnostic binary scan so both apply the same
C2PA_SOFT_BINDINGS rule against their respective bytes.
"""
return sorted({name for sig, name in C2PA_SOFT_BINDINGS.items() if sig in buffer})
def _populate_registry_fields(buf: bytes, c2pa_info: dict[str, Any]) -> bool:
"""Populate the registry-driven C2PA fields by scanning ``buf``.
Shared by the legacy caBX-chunk parser and the c2pa-python store-JSON path so
both produce an identical dict shape. ``buf`` is the raw manifest bytes for
the former and the manifest-store JSON (UTF-8) for the latter; the vendor /
tool / action / source-type / SynthID / soft-binding signatures appear in
both. Sets ``issuer``, ``ai_tool``, ``actions``, ``source_type``,
``synthid_vendors`` / ``synthid_watermark``, ``soft_binding_vendors`` /
``soft_binding`` when present and returns whether the source type is AI.
"""
if issuers := [name for sig, name in C2PA_ISSUERS.items() if sig in buf]:
c2pa_info["issuer"] = ", ".join(dict.fromkeys(issuers))
if ai_tools := [name for sig, name in C2PA_AI_TOOLS.items() if sig in buf]:
c2pa_info["ai_tool"] = ", ".join(dict.fromkeys(ai_tools))
if actions := [name for sig, name in C2PA_ACTIONS.items() if sig in buf]:
c2pa_info["actions"] = ", ".join(actions)
# 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"compositeWithTrainedAlgorithmicMedia" in buf:
# Checked BEFORE bare ``algorithmicMedia``: a manifest can carry both tokens
# (an AI-enhanced composite with a procedural ingredient), and the bare-token
# branch would otherwise fire first and misclassify the AI composite as non-AI.
c2pa_info["source_type"] = "compositeWithTrainedAlgorithmicMedia (AI-enhanced)"
c2pa_info["ai_source_kind"] = "enhanced"
ai_source = True
elif b"algorithmicMedia" in buf:
c2pa_info["source_type"] = "algorithmicMedia"
# SynthID pixel-watermark proxy: a C2PA manifest from a SynthID-using
# vendor (Google/OpenAI) on AI-generated content implies an invisible
# SynthID watermark in the pixels (see SYNTHID_C2PA_ISSUERS).
synthid_vendors = synthid_vendors_in(buf)
if synthid_vendors and ai_source:
c2pa_info["synthid_vendors"] = synthid_vendors
c2pa_info["synthid_watermark"] = synthid_verdict(", ".join(synthid_vendors))
# Soft-binding: a forensic/third-party watermark vendor named in the
# manifest (Adobe TrustMark, Digimarc, ...), independent of the issuer.
soft_binding_vendors = soft_binding_vendors_in(buf)
if soft_binding_vendors:
c2pa_info["soft_binding_vendors"] = soft_binding_vendors
c2pa_info["soft_binding"] = ", ".join(soft_binding_vendors)
return ai_source
def _parse_c2pa_chunk(chunk_data: bytes, c2pa_info: dict[str, Any]) -> None:
"""Parse a raw caBX chunk payload and populate the info dictionary.
The fallback path, used when the official c2pa-python reader is unavailable
or rejects the file (synthetic/partial blobs, broken installs).
"""
c2pa_info["c2pa_manifest"] = f"C2PA manifest ({len(chunk_data)} bytes)"
_populate_registry_fields(chunk_data, c2pa_info)
# Claim generator and spec version: read the CBOR text-string values
# directly (regex byte-grabbing produced artifacts like ``fGPT-4o``).
# Guard with isprintable(): on some manifests (e.g. Microsoft Designer) the
# first ``name`` key precedes a binary field (a hash), not the generator
# string, which would otherwise surface as control-char garbage.
if (generator := cbor_text_after(chunk_data, b"name")) and generator.isprintable():
c2pa_info["claim_generator"] = generator
if (spec := cbor_text_after(chunk_data, b"specVersion")) and spec.isprintable():
c2pa_info["c2pa_spec"] = spec
# Find timestamps
timestamp_matches = re.findall(rb"(\d{14}Z)", chunk_data)
if timestamp_matches:
c2pa_info["timestamp"] = timestamp_matches[0].decode("utf-8")
if len(timestamp_matches) > 1:
c2pa_info["timestamps"] = [t.decode("utf-8") for t in timestamp_matches[:3]]
def extract_c2pa_chunk(image_path: Path) -> bytes | None:
"""
Extract the raw C2PA JUMBF chunk from a PNG file.
Args:
image_path: Path to the source PNG file.
Returns:
Raw bytes of the C2PA chunk or None.
"""
if image_path.suffix.lower() != ".png":
return None
try:
with open(image_path, "rb") as f:
signature = f.read(8)
if signature != PNG_SIGNATURE:
return None
file_size = f.seek(0, 2)
f.seek(8)
while True:
chunk_header = f.read(8)
if len(chunk_header) < 8:
break
length = struct.unpack(">I", chunk_header[:4])[0]
chunk_type = chunk_header[4:8]
# Clamp the attacker-controlled 32-bit length to the bytes that
# actually remain, so a malformed huge length can't allocate GBs.
safe_length = max(0, min(length, file_size - f.tell()))
if chunk_type == C2PA_CHUNK_TYPE:
chunk_data = f.read(safe_length)
crc = f.read(4)
# Check for any C2PA signature
for sig in C2PA_SIGNATURES:
if sig in chunk_data:
return chunk_header + chunk_data + crc
# Also check lowercase variants
if b"jumb" in chunk_data.lower() or b"c2pa" in chunk_data.lower():
return chunk_header + chunk_data + crc
else:
f.seek(safe_length + 4, 1)
if chunk_type == b"IEND":
break
except Exception:
pass
return None
def inject_c2pa_chunk(target_path: Path, output_path: Path, c2pa_chunk: bytes) -> None:
"""
Inject a C2PA JUMBF chunk into a PNG file.
Args:
target_path: Path to the target PNG file.
output_path: Path where the output file will be saved.
c2pa_chunk: Raw bytes of the C2PA chunk to inject.
Raises:
ValueError: If not PNG files.
"""
if target_path.suffix.lower() != ".png" or output_path.suffix.lower() != ".png":
raise ValueError("C2PA chunk injection is only supported for PNG files")
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(target_path, "rb") as f_in, open(output_path, "wb") as f_out:
f_out.write(f_in.read(8))
c2pa_injected = False
while True:
chunk_header = f_in.read(8)
if len(chunk_header) < 8:
break
length = struct.unpack(">I", chunk_header[:4])[0]
chunk_type = chunk_header[4:8]
chunk_data = f_in.read(length)
crc = f_in.read(4)
if chunk_type == b"IDAT" and not c2pa_injected:
f_out.write(c2pa_chunk)
c2pa_injected = True
if chunk_type == C2PA_CHUNK_TYPE:
continue
f_out.write(chunk_header)
f_out.write(chunk_data)
f_out.write(crc)
if chunk_type == b"IEND":
break
-342
View File
@@ -1,342 +0,0 @@
"""Shared constants for AI metadata detection, C2PA parsing, and format support.
All modules reference these constants rather than hard-coding values,
so adding a new AI tool or metadata key requires updating only this file.
"""
from typing import NamedTuple
# Supported image formats for the pixel/removal path (CLI input validation + batch
# discovery). PNG/JPEG/WebP decode+encode via cv2; HEIC/HEIF/AVIF via the optional
# pillow-heif dep (image_io.imread Pillow fallback + imwrite _pil_write), so batch
# now picks them up and the CLI no longer warns on an iPhone HEIC. JPEG-XL is left
# out on purpose -- it is metadata/strip-only (no pixel decoder without pillow-jxl).
SUPPORTED_FORMATS = {".png", ".jpg", ".jpeg", ".webp", ".heic", ".heif", ".avif"}
# AI-generated image metadata keys (Stable Diffusion, ComfyUI, Midjourney, etc.)
AI_METADATA_KEYS = [
"parameters", # Stable Diffusion WebUI (AUTOMATIC1111, Vladmandic)
"postprocessing", # SD WebUI post-processing info
"extras", # SD WebUI extras
"workflow", # ComfyUI workflow JSON
"prompt", # Some AI tools
"Dream", # DreamStudio
"SD:mode", # Stability AI
"StableDiffusionVersion", # SD version info
"generation_time", # Generation time info
"Model", # Model name
"Model hash", # Model hash
"Seed", # Seed value
]
# Standard PNG metadata keys
PNG_METADATA_KEYS = [
"Author",
"Title",
"Description",
"Copyright",
"Creation Time",
"Software",
"Disclaimer",
"Warning",
"Source",
"Comment",
]
# AI-related keywords for detection
AI_KEYWORDS = [
"prompt",
"negative_prompt",
"sampler",
"cfg_scale",
"lora",
"diffusion",
"comfy",
"midjourney",
"dall-e",
"dalle",
"imagen",
"firefly",
"c2pa",
"chatgpt",
"gpt-4",
"sora",
"openai",
"truepic",
"stable_diffusion",
"invokeai",
]
# C2PA (Coalition for Content Provenance and Authenticity) constants
# Used by Google Imagen, Adobe Firefly, Microsoft Designer, OpenAI, etc.
C2PA_CHUNK_TYPE = b"caBX" # JUMBF container chunk type for C2PA
C2PA_SIGNATURES = [
b"c2pa",
b"C2PA",
b"jumb",
b"jumd",
b"JUMBF",
b"jumbf",
b"cbor",
b"contentcreds",
b"digid",
b"assertions",
b"manifest",
]
# Single source of truth for every C2PA-signing vendor. The three per-vendor
# facts that used to live in separate tables -- the issuer byte signature
# (C2PA_ISSUERS), the SynthID pairing (SYNTHID_C2PA_ISSUERS), and the human
# platform label (identify._ISSUER_PLATFORM) -- are all fields here, so adding a
# new C2PA vendor is a single append below; the views derive automatically.
class C2paAiVendor(NamedTuple):
issuer: bytes # distinctive byte signature scanned in the manifest (cert org / signer)
org: str # resolved issuer/cert-org display name (the old C2PA_ISSUERS value)
# Human platform label for identify; None marks a signing authority / non-generator
# (e.g. Truepic), which never names an AI platform on its own.
platform: str | None
# Substring matched against the joined issuer-org names for platform attribution
# (usually a shorter form of org, e.g. "Google" for "Google LLC"); None when platform is.
needle: str | None
synthid: bool = False # vendor pairs an invisible SynthID pixel watermark with its C2PA manifest
# The vendor's mere presence in the manifest asserts AI generation even without
# a digitalSourceType (``trainedAlgorithmicMedia``) assertion. Set ONLY for a
# pure-generator brand whose issuer/generator byte string is unambiguous (e.g.
# "Dreamina"). Do NOT set for common-word issuers (Adobe/Google/OpenAI/Microsoft):
# those appear incidentally in unrelated XMP/trust-chain bytes, so they stay
# source-type-gated in identify._attribute_platform.
asserts_ai: bool = False
# C2PA known vendors, ORDERED for first-match-wins platform attribution: when a
# manifest names several issuers (Microsoft Designer signs as "OpenAI, Microsoft"),
# the earlier entry wins so the product, not the backend engine, is named.
# Used by Google Imagen, Adobe Firefly, Microsoft Designer, OpenAI, etc.
C2PA_AI_VENDORS: tuple[C2paAiVendor, ...] = (
# Microsoft signs both Designer and Bing Image Creator; Bing now runs its own
# MAI-Image model (not DALL-E), so the label stays model-neutral.
C2paAiVendor(b"Microsoft", "Microsoft", "Microsoft (Bing Image Creator / Designer)", "Microsoft"),
C2paAiVendor(b"Adobe", "Adobe", "Adobe Firefly", "Adobe"),
C2paAiVendor(b"OpenAI", "OpenAI", "OpenAI (ChatGPT / gpt-image / DALL-E / Sora)", "OpenAI", synthid=True),
C2paAiVendor(b"Google", "Google LLC", "Google (Gemini / Imagen)", "Google", synthid=True),
# Stability AI signs C2PA as "Stability AI" (cert org "Stability AI Ltd").
# Verified on a live Brand Studio (DreamStudio successor) output, 2026-05-24.
C2paAiVendor(b"Stability AI", "Stability AI", "Stability AI (Stable Image / DreamStudio)", "Stability AI"),
# Black Forest Labs (FLUX) API output: claim_generator_info "Black Forest
# Labs API" + a c2pa.ai_generated_content assertion + trainedAlgorithmicMedia.
# Verified on a real signed FLUX JPEG, 2026-05-29.
C2paAiVendor(b"Black Forest Labs", "Black Forest Labs", "Black Forest Labs (FLUX)", "Black Forest Labs"),
# ByteDance's Volcano Engine (Volcengine) signs its AI image output with a
# cert from certificate_center@volcengine.com -- the platform behind Doubao /
# Jimeng. Verified on two real signed JPEGs, 2026-05-29.
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. 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 compatible signed samples.
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
# in the file's XMP made it read "Adobe Firefly"). Adding the issuer means the
# clean manifest issuer matches "BytePlus (ByteDance)" directly. The platform
# string mirrors the volcengine row: both share the "ByteDance" needle, so the
# earlier row's label wins anyway -- they normalize together for clash
# detection. Verified on compatible signed samples.
C2paAiVendor(b"Byteplus", "BytePlus (ByteDance)", "ByteDance (Doubao / Jimeng / Volcano Engine)", "ByteDance"),
# Dreamina (ByteDance's international Jimeng brand) signs C2PA as "Bytedance
# Pte. Ltd." with a "Dreamina/x.y" claim generator and, unlike the Volcano
# Engine output, NO digitalSourceType assertion -- so the generator name is the
# only AI signal. It is registered by that generator token (which the caBX /
# store-JSON byte scan sees across active + ingredient manifests, where the
# active manifest is often a plain c2pa-tool transcode). ``asserts_ai`` lets the
# issuer alone flag AI without trainedAlgorithmicMedia; "Dreamina" is a
# distinctive brand string, so it does not risk the incidental-mention problem
# the common-word issuers have. Verified on compatible signed samples.
# Normalizes to the same "ByteDance" needle/platform as the
# volcengine row (they collapse together for clash detection).
C2paAiVendor(
b"Dreamina",
"ByteDance (Dreamina)",
"ByteDance (Doubao / Jimeng / Volcano Engine)",
"ByteDance",
asserts_ai=True,
),
# Canva Magic Media signs AI-generated images as "Canva" with a generic
# c2pa-rs claim generator + trainedAlgorithmicMedia; without this entry the
# source read AI but no platform was attributed. Verified on compatible signed
# samples. 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 on compatible signed samples.
# ElevenLabs does not use SynthID.
C2paAiVendor(b"Eleven Labs", "ElevenLabs", "ElevenLabs", "ElevenLabs"),
# fal.ai (generative inference platform, issuer "fal - Features & Labels
# Inc." / common name "fal.ai", claim generators like "fal-ai/seedvr",
# "fal-ai/gpt-image-2"). The files carry trainedAlgorithmicMedia, so the
# verdict already fired, but the platform stayed unattributed. fal.ai is
# a pure generative platform, so ``asserts_ai`` also covers its output
# that omits the source-type.
C2paAiVendor(b"fal-ai", "fal.ai", "fal.ai", "fal.ai", asserts_ai=True),
# Bria AI (bria.ai, generative platform) signs as "Bria Artificial
# Intelligence" with a "Bria Ai" claim generator and source type
# ``empty`` (NOT trainedAlgorithmicMedia), so a real signed file was
# completely missed by identify. A pure-AI
# vendor with distinctive strings, so ``asserts_ai`` is safe here.
C2paAiVendor(b"Bria", "Bria Artificial Intelligence", "Bria AI", "Bria", asserts_ai=True),
# 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:
# - 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}
# Resolved org names of the vendors whose presence asserts AI generation on its
# own (no digitalSourceType needed) -- see the ``asserts_ai`` field. identify uses
# this to lift the AI verdict for an identity-AI issuer (e.g. Dreamina) that ships
# no trainedAlgorithmicMedia. Derived from the flag -- set it on the vendor, not here.
C2PA_IDENTITY_AI_ORGS: frozenset[str] = frozenset(v.org for v in C2PA_AI_VENDORS if v.asserts_ai)
# C2PA issuers whose signed outputs also carry an invisible SynthID pixel
# watermark -- a metadata proxy for "SynthID is in the pixels":
# - Google (Imagen/Gemini): embeds SynthID, long-standing (DeepMind docs).
# - OpenAI (ChatGPT/Codex/API): pairs SynthID with C2PA since ~2026-05-20.
# Confirmed by OpenAI's Help Center ("C2PA and SynthID in OpenAI-generated
# images", updated 2026-05-21): "Images generated with ChatGPT, Codex, and
# our API include both C2PA metadata and SynthID watermarks." OpenAI also
# notes a signal may be absent if "the image was created before these
# signals were available" -- so OpenAI images from before the rollout can
# carry C2PA without SynthID. For OpenAI the proxy is therefore "likely",
# not certain; the verdict string is hedged accordingly. OpenAI's own oracle
# is openai.com/verify (Google's is the Gemini app "Verify with SynthID").
# The issuer byte ("OpenAI"/"Google") is verified locally against data/fixtures/provenance;
# the SynthID pairing is documented behavior (Google: DeepMind; OpenAI: above).
# Adobe Firefly and Microsoft Designer sign C2PA but do NOT use SynthID, so a
# C2PA manifest alone is not a SynthID signal -- the issuer is. The pixel
# watermark is not locally detectable (proprietary decoder); the C2PA companion
# is the proxy, and only while the manifest is intact.
# Derived from the `synthid` flag on C2PA_AI_VENDORS -- set it there, not here.
SYNTHID_C2PA_ISSUERS: frozenset[bytes] = frozenset(v.issuer for v in C2PA_AI_VENDORS if v.synthid)
# C2PA known AI tools
C2PA_AI_TOOLS = {
b"GPT-4o": "GPT-4o",
b"ChatGPT": "ChatGPT",
b"Sora": "Sora",
b"DALL-E": "DALL-E",
b"DALL": "DALL-E",
b"Imagen": "Imagen",
b"Firefly": "Firefly",
}
# C2PA ``c2pa.soft-binding`` algorithm identifiers -> the forensic-watermark
# vendor that stamped the pixels. The manifest's ``alg`` field names the
# watermark scheme even when the watermark itself cannot be decoded locally, so
# a byte-scan for these (keyed on a distinctive prefix to catch all variants)
# tells us a third-party forensic watermark is present and whose. Verified
# against the official C2PA registry (github.com/c2pa-org/softbinding-algorithm-list).
# Adobe TrustMark is additionally decodable locally (see ``trustmark_detector``);
# the rest (Digimarc, Imatag, Steg.AI, etc.) are proprietary oracle-only decoders.
C2PA_SOFT_BINDINGS = {
b"com.adobe.trustmark": "Adobe TrustMark",
b"com.adobe.icn": "Adobe (content fingerprint)",
b"com.digimarc": "Digimarc",
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"app.overlai": "Overlai",
b"com.markany": "MarkAny",
b"com.mentaport": "Mentaport",
b"es.lumatrace": "LumaTrace",
b"ai.verda": "VerdaAI",
b"ai.contentlens": "ContentLens",
b"io.iscc": "ISCC (content code)",
}
# Lowercased substrings that mark an AI generator when found in an EXIF
# ``Software`` / XMP ``CreatorTool`` value. Conservative on purpose: plain
# editors like "Adobe Photoshop" or "GIMP" must NOT match (no AI token), so only
# generator names land here. Add new generators here, not inline.
AI_GENERATOR_TOKENS: frozenset[str] = frozenset(
{
"firefly",
"dall-e",
"dalle",
"midjourney",
"stable diffusion",
"stable-diffusion",
"stablediffusion",
"comfyui",
"automatic1111",
"invokeai",
"imagen",
"gpt-image",
"nightcafe",
"ideogram",
"leonardo",
"flux",
"dreamstudio",
# Generator stamps without C2PA:
# - NovelAI (anime SD): PNG tEXt Software="NovelAI", Source="NovelAI
# Diffusion V4.5 <hash>", Title="NovelAI generated image".
# - Reve Image (reve.com): EXIF Software / XMP CreatorTool = "reve.com"
# (the bare token "reve" would false-positive on "forever"/"reverie").
# - Aphrodite AI: EXIF Make / Software = "Aphrodite AI[ v1.0]".
"novelai",
"reve.com",
"aphrodite ai",
# Additional verified markers:
# - Apple Photos Clean Up (Apple Intelligence object removal): XMP
# photoshop:Credit / IPTC credit value; composite source-type
# covered detection, this token covers removal parity.
# - fal-ai: generative-platform generator string.
"apple photos clean up",
"fal-ai",
}
)
# C2PA action types
C2PA_ACTIONS = {
b"c2pa.created": "created",
b"c2pa.converted": "converted",
b"c2pa.edited": "edited",
b"c2pa.filtered": "filtered",
b"c2pa.cropped": "cropped",
b"c2pa.resized": "resized",
b"c2pa.opened": "opened",
b"c2pa.placed": "placed",
}
# PNG signature
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
-155
View File
@@ -1,155 +0,0 @@
"""Read-only metadata extraction from PNG and JPEG images.
Provides functions to pull all metadata, AI-only metadata, or a
human-readable summary without modifying the source file.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, cast
if TYPE_CHECKING:
from pathlib import Path
import piexif
from PIL import Image
from remove_ai_watermarks.noai.c2pa import extract_c2pa_chunk, extract_c2pa_info, has_c2pa_metadata
from remove_ai_watermarks.noai.constants import AI_KEYWORDS, AI_METADATA_KEYS, PNG_METADATA_KEYS
def extract_metadata(source_path: Path) -> dict[str, Any]:
"""
Extract all metadata from a PNG or JPG file.
Args:
source_path: Path to the source image file.
Returns:
Dictionary containing all extracted metadata.
"""
metadata: dict[str, Any] = {}
with Image.open(source_path) as img:
# Extract EXIF data
if "exif" in img.info:
try:
exif_dict = piexif.load(img.info["exif"])
metadata["exif"] = exif_dict
except Exception:
metadata["exif_raw"] = img.info["exif"]
# Extract standard PNG metadata
for key in PNG_METADATA_KEYS:
if key in img.info:
metadata[key] = img.info[key]
# Extract all other metadata including AI-specific
for key, value in img.info.items():
if not isinstance(key, str):
continue
if key not in metadata and key not in ["exif"]:
metadata[key] = value
# Extract DPI and gamma if present
if "dpi" in img.info:
metadata["dpi"] = img.info["dpi"]
if "gamma" in img.info:
metadata["gamma"] = img.info["gamma"]
# Check for C2PA metadata
if has_c2pa_metadata(source_path):
metadata["c2pa"] = extract_c2pa_info(source_path)
c2pa_chunk = extract_c2pa_chunk(source_path)
if c2pa_chunk:
metadata["c2pa_chunk"] = c2pa_chunk
return metadata
def extract_ai_metadata(source_path: Path) -> dict[str, Any]:
"""
Extract only AI-generated metadata from a PNG or JPG file.
Args:
source_path: Path to the source image file.
Returns:
Dictionary containing only AI-related metadata.
"""
ai_metadata: dict[str, Any] = {}
with Image.open(source_path) as img:
for key in AI_METADATA_KEYS:
if key in img.info:
ai_metadata[key] = img.info[key]
for key, value in img.info.items():
if not isinstance(key, str):
continue
key_lower = key.lower()
if key not in ai_metadata and any(kw in key_lower for kw in AI_KEYWORDS):
ai_metadata[key] = value
# Check for C2PA metadata
if has_c2pa_metadata(source_path):
ai_metadata["c2pa"] = extract_c2pa_info(source_path)
c2pa_chunk = extract_c2pa_chunk(source_path)
if c2pa_chunk:
ai_metadata["c2pa_chunk"] = c2pa_chunk
return ai_metadata
def has_ai_metadata(image_path: Path) -> bool:
"""
Check if an image contains AI-generated metadata.
Args:
image_path: Path to the image file.
Returns:
True if AI metadata is detected, False otherwise.
"""
with Image.open(image_path) as img:
for key in AI_METADATA_KEYS:
if key in img.info:
return True
return bool(has_c2pa_metadata(image_path))
def get_ai_metadata_summary(source_path: Path) -> str:
"""
Get a human-readable summary of AI metadata.
Args:
source_path: Path to the source image file.
Returns:
Formatted string with AI metadata summary.
"""
ai_meta = extract_ai_metadata(source_path)
if not ai_meta:
return "No AI metadata found."
lines = ["AI Image Metadata:"]
lines.append("-" * 40)
for key, value in ai_meta.items():
if key == "c2pa_chunk":
continue
if key == "c2pa" and isinstance(value, dict):
lines.append("C2PA Metadata:")
for ck, cv in cast("dict[str, Any]", value).items():
lines.append(f" {ck}: {cv}")
elif isinstance(value, str) and len(value) > 100:
value = value[:100] + "..."
lines.append(f"{key}: {value}")
elif isinstance(value, bytes):
lines.append(f"{key}: <binary data ({len(value)} bytes)>")
else:
lines.append(f"{key}: {value}")
return "\n".join(lines)
@@ -1,161 +0,0 @@
"""Img2img pipeline execution with progress monitoring and MPS fallback.
Extracted from ``watermark_remover.py`` to keep the ``WatermarkRemover``
class focused on orchestration.
"""
from __future__ import annotations
import contextlib
import logging
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Callable
from PIL import Image
from remove_ai_watermarks.noai.progress import is_mps_error, make_pipeline_progress
logger = logging.getLogger(__name__)
def run_img2img(
pipeline: Any,
image: Image.Image,
strength: float,
num_inference_steps: int,
guidance_scale: float,
generator: Any,
device: str,
set_progress: Callable[[str], None],
extra_kwargs: dict[str, Any] | None = None,
) -> Image.Image:
"""Execute img2img with live progress and return the generated image.
``extra_kwargs`` overlays additional pipeline arguments (e.g. the ControlNet
``control_image`` / ``controlnet_conditioning_scale`` and a non-empty prompt),
so a ControlNet img2img pass reuses the same progress + fallback machinery.
"""
effective_steps = max(1, int(num_inference_steps * strength))
step_cb, first_step, done_ev, start_updater = make_pipeline_progress(
effective_steps,
device,
set_progress,
)
start_updater()
try:
result = _call_pipeline(
pipeline, image, strength, num_inference_steps, guidance_scale, generator, step_cb, extra_kwargs
)
done_ev.set()
return result.images[0]
except TypeError as exc:
# The only TypeError we retry is the deprecated-callback case: `_call_pipeline`
# passes the legacy `callback`/`callback_steps` kwargs, and a diffusers version
# that removed them raises TypeError("... unexpected keyword argument
# 'callback'"). We then re-run once WITHOUT the progress callback. Any OTHER
# TypeError (e.g. a bad control_image/dtype in the forward pass) is a real error
# -- re-raise it instead of silently re-running the whole diffusion pass and
# masking the cause.
if "callback" not in str(exc):
raise
first_step.set()
result = _call_pipeline(
pipeline, image, strength, num_inference_steps, guidance_scale, generator, None, extra_kwargs
)
done_ev.set()
return result.images[0]
finally:
first_step.set()
done_ev.set()
def run_img2img_with_mps_fallback(
load_pipeline: Callable[[], Any],
image: Image.Image,
strength: float,
num_inference_steps: int,
guidance_scale: float,
generator: Any,
device: str,
set_progress: Callable[[str], None],
*,
reload_on_cpu: Callable[[], Any],
extra_kwargs: dict[str, Any] | None = None,
) -> tuple[Image.Image, str]:
"""Run img2img; on MPS error, fall back to CPU.
``extra_kwargs`` overlays extra pipeline arguments (used by the ControlNet
path). Returns ``(result_image, final_device)`` device may change to
``"cpu"`` on fallback.
"""
pipeline = load_pipeline()
try:
img = run_img2img(
pipeline,
image,
strength,
num_inference_steps,
guidance_scale,
generator,
device,
set_progress,
extra_kwargs,
)
return img, device
except RuntimeError as error:
if device == "mps" and is_mps_error(error):
logger.warning("MPS error detected: %s. Falling back to CPU.", error)
set_progress("MPS error! Clearing cache and retrying on CPU...")
try_empty_device_cache("mps")
pipeline = reload_on_cpu()
img = run_img2img(
pipeline, image, strength, num_inference_steps, guidance_scale, None, "cpu", set_progress, extra_kwargs
)
return img, "cpu"
raise
def _call_pipeline(
pipeline: Any,
image: Image.Image,
strength: float,
num_inference_steps: int,
guidance_scale: float,
generator: Any,
step_callback: Any,
extra_kwargs: dict[str, Any] | None = None,
) -> Any:
kwargs: dict[str, Any] = {
"prompt": "",
"image": image,
"strength": strength,
"num_inference_steps": num_inference_steps,
"guidance_scale": guidance_scale,
"generator": generator,
}
if extra_kwargs:
kwargs.update(extra_kwargs)
if step_callback is not None:
kwargs["callback"] = step_callback
kwargs["callback_steps"] = 1
return pipeline(**kwargs)
def try_empty_device_cache(device: str) -> None:
"""Best-effort free of cached GPU/MPS/XPU memory for ``device``.
``torch.<device>.empty_cache()`` exists for cuda/mps/xpu but not cpu (the
hasattr guard skips the cpu no-op). Never raises -- callers use it as cleanup
(the MPS->CPU fallback here, and the batch loop in watermark_remover).
"""
with contextlib.suppress(Exception):
import torch
backend = getattr(torch, device, None)
if backend is not None and hasattr(backend, "empty_cache"):
backend.empty_cache() # type: ignore[attr-defined]
-332
View File
@@ -1,332 +0,0 @@
"""Terminal progress animation and library output suppression.
This module provides two main capabilities for the CLI:
1. ``run_with_progress`` a styled two-line terminal animation that
displays a bouncing highlight bar, a braille spinner, elapsed time,
and a live operation message while a background task executes.
2. ``silence_library_output`` a wrapper that suppresses noisy log
output produced by third-party ML libraries (transformers, diffusers,
huggingface_hub, tqdm) so the user only sees our own progress messages.
"""
from __future__ import annotations
import contextlib
import io
import os
import sys
import threading
import time
import warnings
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Callable
# ── ANSI color constants ────────────────────────────────────────────
_CYAN = "\033[36m"
_YELLOW = "\033[33m"
_GREEN = "\033[32m"
_DIM = "\033[2m"
_BOLD = "\033[1m"
_RESET = "\033[0m"
# Bar geometry
_BAR_WIDTH = 32
_HIGHLIGHT_WIDTH = 5
def _no_color() -> bool:
"""Respect the NO_COLOR convention (https://no-color.org/)."""
return bool(os.environ.get("NO_COLOR"))
def _truncate(text: str, max_len: int = 72) -> str:
"""Shorten a string with an ellipsis if it exceeds *max_len*."""
return text if len(text) <= max_len else text[: max_len - 1] + ""
def _build_bar(step: int) -> str:
"""Build a flowing highlight bar that bounces across the width.
The highlight segment (5 chars wide) travels leftrightleft
continuously, giving the user a visual "working" signal.
"""
cycle = _BAR_WIDTH * 2 - 2
pos = step % cycle
if pos >= _BAR_WIDTH:
pos = cycle - pos
hl_start = max(0, pos - _HIGHLIGHT_WIDTH // 2)
hl_end = min(_BAR_WIDTH, pos + _HIGHLIGHT_WIDTH // 2 + 1)
before = "" * hl_start
highlight = "" * (hl_end - hl_start)
after = "" * (_BAR_WIDTH - hl_end)
if _no_color():
return before + highlight + after
return f"{_DIM}{before}{_RESET}{_BOLD}{_YELLOW}{highlight}{_RESET}{_DIM}{after}{_RESET}"
def run_with_progress(
task: Callable[[], Any],
progress_state: dict[str, str] | None = None,
) -> Any:
"""Execute *task* in a background thread while showing a progress animation.
The animation renders two lines to ``sys.__stderr__``:
- **Line 1**: braille spinner + bouncing bar + elapsed seconds
- **Line 2**: current operation message from *progress_state*
When the task finishes, a green "Completed" line replaces the animation.
Args:
task: A zero-argument callable to run in the background.
progress_state: Mutable dict whose ``"message"`` key is read
by the animation loop to display the current operation.
Returns:
Whatever *task* returns.
Raises:
Any exception raised by *task* is re-raised after the animation
is cleaned up.
"""
done = threading.Event()
output_holder: dict[str, Any] = {"result": None, "error": None}
def worker() -> None:
try:
output_holder["result"] = task()
except Exception as error: # pragma: no cover - passthrough
output_holder["error"] = error
finally:
done.set()
thread = threading.Thread(target=worker, daemon=True)
thread.start()
spinner_frames = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
idx = 0
start_time = time.time()
no_color = _no_color()
def _get_operation() -> str:
if isinstance(progress_state, dict):
return progress_state.get("message", "Processing...")
return "Processing..."
# ── Animation loop ──────────────────────────────────────────────
while not done.is_set():
spinner = spinner_frames[idx % len(spinner_frames)]
elapsed = int(time.time() - start_time)
bar_str = _build_bar(idx)
operation = _truncate(_get_operation())
if no_color:
line1 = f" {spinner} Processing {bar_str} {elapsed:>3}s"
line2 = f" ╰─ {operation}"
else:
line1 = f" {_CYAN}{spinner}{_RESET} Processing {bar_str} {_BOLD}{_YELLOW}{elapsed:>3}s{_RESET}"
line2 = f" {_DIM}╰─ {operation}{_RESET}"
print(
f"\r\033[2K{line1}\n\033[2K{line2}\033[1A\r",
end="",
flush=True,
file=sys.__stderr__,
)
time.sleep(0.08)
idx += 1
# ── Final "done" frame ──────────────────────────────────────────
thread.join()
total = int(time.time() - start_time)
final_operation = _truncate(_get_operation())
done_bar = "" * _BAR_WIDTH
if no_color:
final_line1 = f" ✓ Completed {done_bar} {total:>3}s"
final_line2 = f" ╰─ {final_operation}"
else:
final_line1 = (
f" {_GREEN}{_BOLD}{_RESET} {_GREEN}Completed{_RESET} "
f"{_GREEN}{done_bar}{_RESET} {_BOLD}{_GREEN}{total:>3}s{_RESET}"
)
final_line2 = f" {_DIM}╰─ {final_operation}{_RESET}"
print(
f"\r\033[2K{final_line1}\n\033[2K{final_line2}",
file=sys.__stderr__,
)
if output_holder["error"] is not None:
raise output_holder["error"]
return output_holder["result"]
def silence_library_output(
run_func: Callable[[], Any],
set_progress: Callable[[str], None] | None = None,
) -> Callable[[], Any]:
"""Return a wrapper that silences noisy ML library output.
The wrapper:
1. Disables HuggingFace Hub progress bars via env var.
2. Sets ``transformers``, ``diffusers``, and ``huggingface_hub``
loggers to *error* level.
3. Redirects ``stdout`` and ``stderr`` to ``io.StringIO`` sinks so
that stray ``tqdm`` bars and model-loading chatter are invisible.
4. Suppresses all Python warnings during the call.
Args:
run_func: The callable to execute silently.
set_progress: Optional callback to report phase changes.
Returns:
A zero-argument callable that, when invoked, runs *run_func*
inside the silent context.
"""
def wrapped() -> Any:
if set_progress:
set_progress("Configuring runtime and suppressing noisy logs...")
os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")
for _silence in (
lambda: __import__("transformers").logging.set_verbosity_error(),
lambda: _silence_diffusers(),
lambda: __import__("huggingface_hub").logging.set_verbosity_error(),
):
with contextlib.suppress(Exception):
_silence()
with warnings.catch_warnings():
warnings.simplefilter("ignore")
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
if set_progress:
set_progress("Executing watermark removal pipeline...")
return run_func()
return wrapped
def _silence_diffusers() -> None:
"""Silence diffusers logging and progress bars."""
from diffusers.utils import logging as diffusers_logging
diffusers_logging.set_verbosity_error()
if hasattr(diffusers_logging, "disable_progress_bar"):
diffusers_logging.disable_progress_bar()
# ── Shared pipeline progress helpers ─────────────────────────────────
_DEFAULT_PRE_PHASES: list[tuple[int, str]] = [
(0, "Encoding image with VAE encoder"),
(3, "Mapping pixel data → latent space"),
(7, "Injecting noise into latent representation"),
(12, "Building denoiser schedule"),
(18, "Starting reverse diffusion sampler"),
(30, "Running first denoising iteration"),
(50, "Still processing — this can take a while"),
(90, "Pipeline running — may take a few minutes"),
]
_DEFAULT_POST_PHASES: list[tuple[int, str]] = [
(0, "Denoising complete · Running VAE decoder"),
(2, "Decoding latent channels → RGB color space"),
(5, "Reconstructing pixel grid from latents"),
(10, "Applying color space conversion and normalization"),
(18, "Finalizing pixel output"),
(30, "Still decoding — large images take longer"),
(60, "Almost done — large images take longer to decode"),
]
def make_pipeline_progress(
effective_steps: int,
device: str,
set_progress: Callable[[str], None],
*,
bar_len: int = 20,
label: str = "Denoising",
pre_phases: list[tuple[int, str]] | None = None,
post_phases: list[tuple[int, str]] | None = None,
) -> tuple[Callable[..., None], threading.Event, threading.Event, Callable[[], threading.Thread]]:
"""Create step callback and background updater for a diffusion pipeline.
Returns:
(step_callback, first_step_event, pipeline_done_event, start_updater)
where ``start_updater()`` launches and returns the background thread.
"""
pre = pre_phases or [(s, f"{m} on {device}") for s, m in _DEFAULT_PRE_PHASES]
post = post_phases or [(s, f"{m} on {device}") for s, m in _DEFAULT_POST_PHASES]
t0_holder: list[float] = [time.monotonic()]
first_step = threading.Event()
pipeline_done = threading.Event()
last_cb_time: list[float] = [t0_holder[0]]
def _background_updater() -> None:
idx = 0
while not first_step.is_set():
elapsed = time.monotonic() - t0_holder[0]
while idx < len(pre) - 1 and elapsed >= pre[idx + 1][0]:
idx += 1
set_progress(pre[idx][1])
first_step.wait(timeout=0.4)
idx = 0
post_start: float | None = None
while not pipeline_done.is_set():
since_cb = time.monotonic() - last_cb_time[0]
if since_cb >= 1.5:
if post_start is None:
post_start = time.monotonic()
elapsed = time.monotonic() - post_start
while idx < len(post) - 1 and elapsed >= post[idx + 1][0]:
idx += 1
set_progress(post[idx][1])
else:
post_start = None
idx = 0
pipeline_done.wait(timeout=0.4)
def step_callback(step: int, timestep: int, latents: Any) -> None:
first_step.set()
last_cb_time[0] = time.monotonic()
elapsed = time.monotonic() - t0_holder[0]
current = step + 1
per_step = elapsed / max(1, current)
remaining = per_step * max(0, effective_steps - current)
filled = int(bar_len * current / max(1, effective_steps))
bar = "" * filled + "" * (bar_len - filled)
set_progress(
f"{label} [{bar}] {current}/{effective_steps} | {elapsed:.0f}s elapsed, ~{remaining:.0f}s left | {device}"
)
def start_updater() -> threading.Thread:
t0_holder[0] = time.monotonic()
last_cb_time[0] = t0_holder[0]
first_step.clear()
pipeline_done.clear()
t = threading.Thread(target=_background_updater, daemon=True)
t.start()
return t
return step_callback, first_step, pipeline_done, start_updater
# ── MPS fallback helper ──────────────────────────────────────────────
def is_mps_error(error: Exception) -> bool:
"""Check whether an exception is an MPS-related runtime error."""
return "mps" in str(error).lower()
-43
View File
@@ -1,43 +0,0 @@
"""Low-level utility helpers used across the metadata pipeline.
Kept deliberately small only format detection lives here so that
higher-level modules can import without circular dependencies.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pathlib import Path
from remove_ai_watermarks.noai.constants import SUPPORTED_FORMATS
def is_supported_format(file_path: Path) -> bool:
"""
Check if the file format is supported.
Args:
file_path: Path to the image file.
Returns:
True if the format is supported, False otherwise.
"""
return file_path.suffix.lower() in SUPPORTED_FORMATS
def get_image_format(file_path: Path) -> str:
"""
Get the image format from file path.
Args:
file_path: Path to the image file.
Returns:
Format string (PNG, JPEG, etc.).
"""
suffix = file_path.suffix.lower()
if suffix in {".jpg", ".jpeg"}:
return "JPEG"
return "PNG"
@@ -1,207 +0,0 @@
"""Watermark removal model profiles and the default strength.
Pure configuration and lookup functions with no ML dependencies.
"""
from __future__ import annotations
import math
from typing import TYPE_CHECKING, Literal
if TYPE_CHECKING:
from pathlib import Path
DEFAULT_MODEL_ID = "stabilityai/stable-diffusion-xl-base-1.0"
# Qwen-Image (20B MMDiT, Apache-2.0 code AND weights) base for the ``qwen`` pipeline:
# an img2img alternative to SDXL with native text rendering (incl. CJK). Loaded only
# when ``--pipeline qwen`` is selected; CUDA/cloud-class (does not fit MPS). CERTIFIED
# oracle floors (2026-06-20): OpenAI **0.10** (seed-robust -- clean on seeds 0-4) and
# Google/Gemini **0.25** (seed 0 verified on 2 images; pin a seed in prod, the Gemini
# oracle rate-limits volume seed-repeat). The Gemini floor (0.25) is HIGHER than the
# certified controlnet Gemini floor (0.15); ``resolve_strength(..., pipeline="qwen")``
# now carries this via ``_QWEN_VENDOR_STRENGTH`` (below), so ``--pipeline qwen`` gets the
# right floor automatically -- the old manual "pass --strength 0.25 for Gemini on qwen"
# workaround is retired.
# (Dispatch uses the bare "qwen" literal, matching the sdxl/controlnet sites, so there
# is no QWEN_PROFILE constant -- only the model id is referenced from code.)
QWEN_MODEL_ID = "Qwen/Qwen-Image"
# Canonical pipeline-profile names + the back-compat alias. The plain SDXL img2img
# profile is ``sdxl``; ``default`` is kept as an accepted alias (it was the profile's
# name before ``controlnet`` became the default-selected pipeline, 2026-06-09).
SDXL_PROFILE = "sdxl"
QWEN_ZIMAGE_PROFILE = "qwen-zimage"
_PROFILE_ALIASES = {
"default": SDXL_PROFILE,
"qwen_zimage": QWEN_ZIMAGE_PROFILE,
}
def normalize_profile(profile: str) -> str:
"""Canonicalize a pipeline-profile name, resolving the ``default`` -> ``sdxl`` alias."""
normalized = profile.strip().lower()
return _PROFILE_ALIASES.get(normalized, normalized)
def resolve_steps(num_inference_steps: int | None, pipeline: str) -> int:
"""Resolve a profile-specific step default while preserving explicit values.
The Lightning LoRA in ``qwen-zimage`` is distilled for four steps. Existing
SDXL and Qwen profiles keep the long-standing 50-step CLI default.
"""
if num_inference_steps is not None:
return num_inference_steps
return 4 if normalize_profile(pipeline) == QWEN_ZIMAGE_PROFILE else 50
def resolve_seed(seed: int | None, pipeline: str) -> int | None:
"""Keep the oracle-verified qwen-zimage profile deterministic by default."""
if seed is not None:
return seed
return 0 if normalize_profile(pipeline) == QWEN_ZIMAGE_PROFILE else None
# The SDXL-native canny ControlNet used by the ``controlnet`` pipeline. The
# ControlNet is an add-on to the SDXL base checkpoint (DEFAULT_MODEL_ID), not a
# separate base model, so both the ``sdxl`` and ``controlnet`` profiles load the
# same base weights and share the same vendor-adaptive strength ladder (see below).
CONTROLNET_CANNY_MODEL = "xinsir/controlnet-canny-sdxl-1.0"
# Vendor-adaptive default denoising strength for the SDXL img2img scrub, overridable
# from the CLI (`--strength`). The right strength depends on which vendor's SynthID is
# present (detected from the C2PA issuer, metadata.synthid_source). The SAME ladder
# applies to BOTH pipelines (`sdxl` plain img2img and `controlnet`) -- see "why one
# ladder" below.
#
# Data basis (see docs/synthid.md sections 2.2 / 5.5): ORACLE-CERTIFIED controlnet floors.
# Oracle re-testing
# LOWERED the ladder back to OpenAI 0.10 / Google 0.15: each output verified on its own
# oracle (openai.com/verify for OpenAI, the Google Gemini app for Google), all clean ->
# - OpenAI 0.10: 2 photoreal images (1402 / 1448 px), SynthID not found on either.
# - Google 0.15: 2 NATIVE-resolution images (both 2816x1536), SynthID not found on
# either -- this directly retires the earlier "native ~2816 likely needs ~0.35+"
# guess, which was speculative and never oracle-checked at that resolution.
# This supersedes the 2026-06-04 cert (OpenAI 0.20 / Google 0.30), whose higher floor a
# pixel-fidelity sweep showed was ~2x the removal floor and over-regenerated for no
# efficacy gain (Google MAE -20% at 0.15 vs 0.30, no SynthID returning). Unknown vendor
# tracks the Google (more robust watermark) value -> 0.15, still safe-by-default and the
# floor that real (no-vendor) photos hit, so it also minimizes damage when there is in
# fact nothing to remove. CAVEAT: the re-test is n=2 per vendor on photoreal / landscape
# content; FLAT-GRAPHIC hard cases (the historical `sdxl` weak spot) were NOT in the
# sample, so if an oracle still reads SynthID on a flat output, raise `--strength`.
#
# Why ONE ladder for both pipelines (2026-06-09): the certification was run on
# controlnet, and it does NOT transfer to `sdxl` by symmetry -- the two pipelines have
# OPPOSITE hard cases (controlnet leaves SynthID on photoreal, `sdxl` leaves it on flat
# graphics; the content-x-pipeline table in docs/synthid.md §5.1). BUT on its OWN hard
# case (flat fills) `sdxl` is the WEAKER remover -- plain img2img at low strength barely
# perturbs a flat region -- so it needs AT LEAST as much strength as controlnet, not
# less. Hence the certified controlnet floor is the right floor for `sdxl` too. The
# higher strength costs little quality where it matters. `controlnet` is now the default
# pipeline and `sdxl` is reached only through an explicit `--pipeline sdxl`. NOTE:
# this is a MARGIN argument for `sdxl`, not a fresh certification -- there is no local
# SynthID detector, so if an oracle still reads SynthID on a flat `sdxl` output, raise
# `--strength`.
OPENAI_STRENGTH = 0.10
GEMINI_STRENGTH = 0.15
UNKNOWN_STRENGTH = 0.15
# Backwards-compatible alias: the vendor-unknown value (what a caller gets without a
# detected vendor). Kept as DEFAULT_STRENGTH for existing references.
DEFAULT_STRENGTH = UNKNOWN_STRENGTH
# Detected-vendor -> default strength. Vendor strings come from `vendor_for_strength`.
_VENDOR_STRENGTH = {"openai": OPENAI_STRENGTH, "google": GEMINI_STRENGTH}
# Qwen has its OWN certified floors (Modal A100-80GB, 2026-06-20), DIFFERENT from the
# SDXL ladder above: OpenAI 0.10 (seed-robust), Gemini 0.25 (HIGHER than controlnet's
# 0.15 -- the 20B MMDiT perturbs less per denoising step, so it needs more strength to
# clear Gemini SynthID). Unknown vendor tracks the higher (Gemini) value, safe-by-default.
# `resolve_strength(..., pipeline="qwen")` uses this table so `--pipeline qwen` carries the
# right floor automatically -- retiring the old manual "pass --strength 0.25 for Gemini on
# qwen" workaround.
QWEN_OPENAI_STRENGTH = 0.10
QWEN_GEMINI_STRENGTH = 0.25
QWEN_UNKNOWN_STRENGTH = 0.25
_QWEN_VENDOR_STRENGTH = {"openai": QWEN_OPENAI_STRENGTH, "google": QWEN_GEMINI_STRENGTH}
def strength_default_help() -> str:
"""One-line description of the vendor-adaptive default, derived from the constants.
Single source of truth for the CLI ``--strength`` help so the numbers can never
drift from the actual ladder (they did once when the per-pipeline split was unified).
"""
return (
f"vendor-adaptive (OpenAI {OPENAI_STRENGTH} / Google {GEMINI_STRENGTH} / "
f"unknown {UNKNOWN_STRENGTH}, from the C2PA issuer; qwen-zimage instead uses "
"resolution-adaptive denoise)"
)
def resolve_strength(strength: float | None, vendor: str | None = None, pipeline: str | None = None) -> float:
"""Resolve the denoising strength, applying the vendor default when unset.
``None`` means "the user did not pass ``--strength``", which resolves
**vendor-adaptively**: ``vendor`` (``"openai"`` / ``"google"`` / None, from
``vendor_for_strength``) selects the per-vendor floor. The ``sdxl`` and ``controlnet``
pipelines share ONE ladder (``OPENAI_STRENGTH`` / ``GEMINI_STRENGTH`` /
``UNKNOWN_STRENGTH`` -- see the module comment for why); ``qwen`` has its OWN higher
ladder (``_QWEN_VENDOR_STRENGTH``, Gemini 0.25 vs controlnet 0.15), selected when
``pipeline`` normalizes to ``"qwen"``. An explicit value always wins (including
``0.0`` -- the check is ``is None``, not falsiness). Shared by the CLI (for display)
and the engine (for execution) so the two never disagree -- both must pass the SAME
``vendor`` and ``pipeline``.
"""
if strength is not None:
return strength
if pipeline is not None and normalize_profile(pipeline) == "qwen":
return _QWEN_VENDOR_STRENGTH.get(vendor or "", QWEN_UNKNOWN_STRENGTH)
return _VENDOR_STRENGTH.get(vendor or "", UNKNOWN_STRENGTH)
def viable_steps(num_inference_steps: int, strength: float) -> int:
"""The smallest step count >= ``num_inference_steps`` that actually denoises.
diffusers derives its img2img timesteps as ``int(steps * strength)``. When that
rounds to ZERO the pipeline builds an empty latent and dies deep inside attention
with ``cannot reshape tensor of 0 elements into shape [0, -1, 1, 512]`` -- an opaque
torch error for what is really "these two options cannot work together".
The combination is reachable with entirely valid CLI arguments: at the default
strength 0.15 every ``--steps`` below 7 crashed, and nothing told the user that
``--steps`` and ``--strength`` interact (found by the release smoke matrix,
2026-07-19). Raising the count to the minimum that denoises keeps the caller's intent
-- they asked for "few steps", not "zero" -- and the engine logs the adjustment.
A non-positive ``strength`` cannot denoise at any step count; return the caller's
value unchanged rather than dividing by zero.
"""
if strength <= 0:
return num_inference_steps
if int(num_inference_steps * strength) >= 1:
return num_inference_steps
return math.ceil(1 / strength)
def vendor_for_strength(image_path: Path) -> Literal["openai", "google"] | None:
"""Detect the SynthID vendor for strength selection: ``"openai"`` / ``"google"`` / None.
Reads the C2PA SynthID proxy (``metadata.synthid_source``) on the ORIGINAL input,
so it must run before any pass that strips metadata. When both issuers appear (a
rare multi-sign anomaly) Google wins -- the more-robust watermark -> safer (higher)
strength. Returns None when metadata is stripped or the issuer is neither vendor,
which maps to ``UNKNOWN_STRENGTH``. Lazy-imports ``metadata`` to keep this module
dependency-light.
"""
try:
from remove_ai_watermarks.metadata import synthid_source
src = (synthid_source(image_path) or "").lower()
except Exception: # metadata unreadable -> treat as unknown vendor
return None
if "google" in src:
return "google"
if "openai" in src:
return "openai"
return None
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -318,7 +318,7 @@ def _select_stable_visible_mark(
def _platform_from_video_metadata(markers: dict[str, str]) -> str | None:
"""Map supported C2PA-derived marker text to its generating platform."""
from remove_ai_watermarks.noai.constants import C2PA_AI_VENDORS
from remove_ai_watermarks._internal.constants import C2PA_AI_VENDORS
marker_text = "\n".join(markers.values()).casefold()
if not marker_text: