Add calibrated SynthID pixel detector

This commit is contained in:
Victor Kuznetsov
2026-08-11 11:09:55 -07:00
parent 7091d73f2e
commit 8a648794ad
32 changed files with 2394 additions and 146 deletions
+8
View File
@@ -13,6 +13,7 @@ High-level API (lazy, so ``import remove_ai_watermarks`` stays cheap)::
raiw.remove_video_metadata("in.mp4", "out.mp4") # verified metadata strip
raiw.remove_video_invisible("in.mp4", "out.mp4") # oracle-certified SynthID removal
raiw.remove_video_visible("in.mp4", "out.mp4") # stable visible video-mark removal
raiw.detect_synthid("in.png") # -> SynthIDDetection
For a provenance verdict use the ``identify`` submodule::
@@ -39,7 +40,9 @@ __all__ = [
"InvisibleOptions",
"MetadataStripIncomplete",
"RemoveAllResult",
"SynthIDDetection",
"__version__",
"detect_synthid",
"identify_video",
"inspect_video_metadata",
"remove_all",
@@ -64,6 +67,7 @@ if TYPE_CHECKING:
remove_visible,
visible_provenance,
)
from remove_ai_watermarks.synthid_detector import SynthIDDetection, detect_synthid
from remove_ai_watermarks.video import (
identify_video,
inspect_video_metadata,
@@ -103,4 +107,8 @@ def __getattr__(name: str) -> object:
from remove_ai_watermarks import video
return getattr(video, name)
if name in ("SynthIDDetection", "detect_synthid"):
from remove_ai_watermarks import synthid_detector
return getattr(synthid_detector, name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+1 -1
View File
@@ -1 +1 @@
"""Embedded assets for visible watermark removal."""
"""Embedded runtime assets for watermark detection and removal."""
+54 -16
View File
@@ -297,8 +297,8 @@ _force_option = click.option(
help=(
"Run the diffusion scrub even when no invisible AI watermark is locally "
"detectable. Default: skip it (regeneration only degrades a clean image; a "
"skip never claims the image is watermark-free -- a pixel SynthID is "
"undetectable once its metadata proxy is gone)."
"skip never claims the image is watermark-free -- the local SynthID detector "
"covers one carrier family in a calibrated image-size range)."
),
)
_cpu_offload_option = click.option(
@@ -455,18 +455,20 @@ def _no_invisible_signal_exit(source: Path) -> NoReturn:
:func:`identify` finds no locally-detectable invisible AI signal, running it
anyway would damage a clean image for nothing -- the dominant paid score-0
cause on no-watermark uploads. So skip it, but do NOT imply the image is
clean: a pixel SynthID is undetectable here once its metadata proxy is gone.
Write no output and exit :data:`EXIT_NO_INVISIBLE_SIGNAL`; ``--force`` runs
the scrub regardless.
clean: only one SynthID carrier family in a calibrated image-size range has
a local detector, so other sizes or epochs can still be present after
their metadata proxy is gone. Write no output and exit
:data:`EXIT_NO_INVISIBLE_SIGNAL`; ``--force`` runs the scrub regardless.
"""
console.print(
" No invisible AI watermark detected (no C2PA/SynthID provenance, no open\n"
" watermark). Skipped the diffusion scrub -- regenerating the pixels would\n"
" only degrade the image with nothing to remove, so no output was written.\n"
" This does NOT prove the image is clean: a pixel watermark such as SynthID\n"
" cannot be detected here once its metadata proxy is absent (it may have\n"
" been stripped earlier). If you know the image is AI-generated and want the\n"
" pixels regenerated regardless, re-run with --force:\n"
" No supported invisible AI watermark detected (no provenance, supported\n"
" SynthID carrier, or open watermark). Skipped the diffusion scrub --\n"
" regenerating the pixels would only degrade the image with nothing to\n"
" remove, so no output was written.\n"
" This does NOT prove the image is clean: the local SynthID detector covers\n"
" one carrier family in a calibrated image-size range. If you know the image\n"
" is AI-generated and want the pixels regenerated regardless, re-run with\n"
" --force:\n"
f" remove-ai-watermarks invisible {source.name} --force"
)
raise SystemExit(EXIT_NO_INVISIBLE_SIGNAL)
@@ -1316,6 +1318,41 @@ def cmd_video_batch(
raise SystemExit(1)
# ── SynthID pixel detection ──
@main.command("detect-synthid")
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option("--json", "as_json", is_flag=True, help="Emit the detector result as JSON.")
def cmd_detect_synthid(source: Path, as_json: bool) -> None:
"""Detect the SynthID periodic pixel carrier at calibrated image sizes.
A negative result means this detector did not find its supported carrier; it
is not proof that the image contains no SynthID watermark.
"""
from remove_ai_watermarks.synthid_detector import detect_synthid
source = _validate_image(source)
try:
result = detect_synthid(source)
except RuntimeError as exc:
raise click.ClickException(str(exc)) from exc
if as_json:
click.echo(json.dumps(result.to_dict(), indent=2))
return
_banner()
console.print(f"\n SynthID pixel carrier: {result.status}")
console.print(f" Geometry: {result.width}x{result.height}")
if result.score is not None:
console.print(f" Score: {result.score:.6f} (threshold: {result.threshold:.6f})")
console.print(f" Detector: {result.detector}")
console.print(
" Scope: one confirmed periodic carrier family in a calibrated image-size range.\n"
" Arbitrary spatial resampling is not registered. A negative or\n"
" unsupported result is not proof that SynthID is absent."
)
# ── Provenance identification ──
@main.command("identify")
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@@ -1360,8 +1397,8 @@ def cmd_identify(ctx: click.Context, source: Path, no_visible: bool, as_json: bo
if report.is_ai_generated is None:
console.print(
" No locally-readable AI signal found. This is not the same as 'clean': "
"metadata is often stripped by re-encoding, screenshots, or upload, and SynthID-class "
"pixel watermarks (Gemini / Nano Banana / gpt-image) have no local detector. "
"metadata is often stripped by re-encoding, screenshots, or upload, and the local "
"SynthID pixel detector covers one carrier family in a calibrated image-size range. "
"See caveats below."
)
@@ -1455,8 +1492,9 @@ def cmd_all(
stage_text = {
("invisible", "no-signal"): (
"Skipped (no invisible AI watermark detected; pixels left intact).\n"
" Not a clean-image guarantee: a pixel SynthID is undetectable once its\n"
" metadata proxy is gone. Re-run with --force to scrub regardless."
" Not a clean-image guarantee: the local SynthID detector covers one\n"
" carrier family in a calibrated image-size range. Re-run with --force\n"
" to scrub regardless."
),
("invisible", "unavailable"): (
f"Warning: Skipped - GPU dependencies not installed.\n Install them with: pip install {INVISIBLE_EXTRA}"
+43 -17
View File
@@ -6,15 +6,14 @@ Aggregates every locally-readable signal into a single :class:`ProvenanceReport`
the signing platform (OpenAI, Google, Adobe, Microsoft).
- **IPTC ``digitalSourceType``** "Made with AI" marker (Meta, X, others).
- **PNG text / EXIF generation parameters** (Stable Diffusion, ComfyUI, InvokeAI).
- **SynthID provenance evidence** -- Google AI C2PA follows Google's all-media
policy; current OpenAI C2PA explicitly declares a watermark action.
- **SynthID evidence** -- supported C2PA provenance plus a positive-only local
detector for one confirmed periodic carrier family in a calibrated image-size range.
- **Registered visible marks** (optional; needs cv2/numpy, no GPU) through the
shared watermark registry.
Hard limit: a stripped image (re-encoded, screenshotted, social-media upload)
loses all metadata, and the SynthID *pixel* watermark is not locally decodable
(proprietary decoder). Absence of signals is therefore reported as ``Unknown``,
never as "clean". See CLAUDE.md "SynthID detection is metadata-only".
Hard limit: Google does not publish its payload decoder. The local pixel detector
covers only one measured carrier family in a calibrated image-size range, so
absence of signals is reported as ``Unknown``, never as "clean".
"""
from __future__ import annotations
@@ -111,8 +110,14 @@ _STRIP_CAVEAT = (
"text chunks are stripped by re-encoding, screenshots, or social-media upload."
)
_SYNTHID_CAVEAT = (
"SynthID presence comes from supported provenance here; the pixel watermark is not locally "
"decoded (proprietary decoder). Confirm via the Gemini app or openai.com/verify."
"SynthID presence comes from supported provenance here. The separate local pixel detector "
"covers one measured carrier family in a calibrated image-size range; confirm other cases with "
"the provider oracle."
)
_SYNTHID_PIXEL_CAVEAT = (
"The local SynthID pixel result is a positive-only match to one measured periodic carrier family "
"in a calibrated image-size range, not a proprietary payload decode. A negative or unsupported "
"result is not proof of absence."
)
_IPTC_ONLY_CAVEAT = "The IPTC 'Made with AI' tag flags AI provenance but does not identify the specific platform."
_INVISIBLE_WM_CAVEAT = (
@@ -951,6 +956,15 @@ def _trustmark(image_path: Path) -> str | None:
return detect_trustmark(image_path)
def _synthid_pixel_watermark(image_path: Path, decode: _SharedDecode) -> bool:
"""Whether the supported positive-only SynthID carrier is detected."""
from remove_ai_watermarks.synthid_detector import detect_synthid, is_available
if not is_available() or (image := decode.get()) is None:
return False
return detect_synthid(image_path, image=image).detected
class _SharedDecode:
"""One decode of the source pixels, shared by every detector in a single report.
@@ -1295,6 +1309,17 @@ def _identify_from_evidence(
if platform is None:
platform = f"{scheme} (open DWT-DCT watermark)"
# ── Positive-only SynthID periodic carrier ──────────────────────
# This is deliberately separate from C2PA provenance. It survives lossless
# metadata stripping, but covers only one carrier family and a calibrated
# image-size range.
if check_invisible and pixel_path is not None and _synthid_pixel_watermark(pixel_path, decode):
signals.append(Signal("synthid_pixel", "calibrated periodic carrier", "high"))
watermarks.append("SynthID periodic pixel carrier (calibrated image size)")
caveats.append(_SYNTHID_PIXEL_CAVEAT)
if platform is None:
platform = "SynthID carrier detected (provider not attributed locally)"
# ── Adobe TrustMark invisible watermark (open decoder, no key) ───
# The watermark behind Adobe Durable Content Credentials. Decoded locally,
# but it binds provenance for human-authored content too, so it enriches the
@@ -1306,7 +1331,7 @@ def _identify_from_evidence(
platform = "Adobe (TrustMark / Content Credentials)"
# ── Verdict so far (metadata + embedded watermark) ──────────────
invisible_wm = any(s.name == "invisible_watermark" for s in signals)
invisible_wm = any(s.name in {"invisible_watermark", "synthid_pixel"} for s in signals)
exif_gen = any(s.name == "exif_generator" for s in signals)
xai_sig = any(s.name == "xai_signature" for s in signals)
ai_from_metadata = bool(
@@ -1405,8 +1430,9 @@ def identify(
image_path: Path to the image (PNG, JPEG, WebP, or ISOBMFF container).
check_visible: Also run the registered visible-mark detectors through cv2.
Set False for a metadata-only, dependency-light scan.
check_invisible: Also decode optional open invisible watermarks
(SD/SDXL/FLUX). No-op when the decoder extra is not installed.
check_invisible: Also run optional pixel detectors for the supported
SynthID carrier and open SD/SDXL/FLUX watermarks. No-op when their
numeric extras are not installed.
File-backed metadata extraction runs first. The extracted evidence is then
evaluated independently, followed by the optional pixel-backed visible and
@@ -1436,13 +1462,13 @@ def has_invisible_target(image_path: Path) -> bool:
to remove. Runs :func:`identify` with ``check_visible=False`` -- a visible mark
is handled by the separate visible pass and is NOT a diffusion target -- and
``check_invisible=True`` so an open watermark counts. Returns
``report.ai_from_metadata`` (C2PA AI issuer / SynthID provenance, IPTC, AIGC, local
gen params, EXIF/xAI, open DWT-DCT / TrustMark).
``report.ai_from_metadata`` (C2PA AI issuer / SynthID provenance or periodic
carrier, IPTC, AIGC, local gen params, EXIF/xAI, open DWT-DCT / TrustMark).
IMPORTANT -- this cannot prove a pixel SynthID is absent: SynthID is detectable
only through its C2PA proxy, so a metadata-stripped AI image reads as no signal
here. A False therefore means "no locally-detectable invisible target", not
"clean". Callers must NOT present a skip as a finished clean result.
IMPORTANT -- this cannot prove a pixel SynthID is absent: the local detector
covers one carrier family in a calibrated image-size range. A False therefore
means "no supported locally-detectable invisible target", not "clean". Callers
must NOT present a skip as a finished clean result.
Fail-safe: any error resolves to True so the removal still runs -- leaving a
watermark on a paid removal is worse than over-regenerating a clean image.
+3 -2
View File
@@ -803,8 +803,9 @@ def synthid_source(image_path: Path) -> str | None:
None.
The evidence is readable only while the C2PA manifest is intact. Absence is
not proof: C2PA can be stripped while the pixel watermark survives, and the
pixel watermark itself is not locally detectable (proprietary decoder).
not proof: C2PA can be stripped while the pixel watermark survives. This
metadata helper does not call the separate, geometry-limited local carrier
detector.
Args:
image_path: Path to the image (PNG, JPEG, WebP, or ISOBMFF container).
@@ -0,0 +1,258 @@
"""Detect the confirmed periodic SynthID image carrier at calibrated image sizes.
This is a positive-only detector for one measured carrier epoch, not Google's
private payload decoder. A positive result is strong local evidence for the
carrier. A negative result means only that this exact detector did not find it;
image sizes outside the calibrated pixel-count range are reported separately.
The numeric runtime requires the ``pixels`` extra. Imports remain lazy so the
package's metadata-only paths stay dependency-light.
"""
# The optional numeric libraries do not provide complete types for this path.
# pyright: reportMissingTypeStubs=false, reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false
from __future__ import annotations
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal
if TYPE_CHECKING:
from numpy.typing import NDArray
SynthIDDetectionStatus = Literal["detected", "not_detected", "unsupported"]
DETECTOR_ID = "synthid-periodic-tile-v2"
MODEL_FILENAME = "synthid_periodic_tile_2048_v1.npz"
# The template remains frozen at this model geometry. Runtime images are never
# resized. The supported pixel-count interval is the separately challenged domain:
# below it too few repetitions make the positive-only statistic unreliable, and
# above it resource use and specificity have not been calibrated.
MODEL_WIDTH = 2048
MODEL_HEIGHT = 2048
MIN_SUPPORTED_PIXELS = 1_000_000
MAX_SUPPORTED_PIXELS = 18_000_000
TILE_THRESHOLD = 0.17357069773071196
INSTALL_HINT = "install the pixel extra: uv add 'remove-ai-watermarks[pixels]'"
@dataclass(frozen=True)
class SynthIDDetection:
"""One local periodic-carrier verdict."""
status: SynthIDDetectionStatus
width: int
height: int
score: float | None
threshold: float
detector: str = DETECTOR_ID
@property
def detected(self) -> bool:
"""Whether the supported carrier crossed its frozen threshold."""
return self.status == "detected"
def to_dict(self) -> dict[str, str | int | float | None]:
"""Return a JSON-safe result without a local file path."""
return {
"status": self.status,
"width": self.width,
"height": self.height,
"score": self.score,
"threshold": self.threshold,
"detector": self.detector,
}
def is_available() -> bool:
"""True when the optional numeric runtime is installed."""
from remove_ai_watermarks.optional_deps import module_available
return module_available("cv2", "numpy")
@lru_cache(maxsize=1)
def _load_template() -> tuple[NDArray[Any], float, int, int, int, int]:
"""Load and validate the bundled pickle-free detector model."""
import numpy as np
model_path = Path(__file__).parent / "assets" / MODEL_FILENAME
with np.load(model_path, allow_pickle=False) as artifact:
if int(artifact["format_version"]) != 1:
raise RuntimeError("unsupported SynthID detector model format")
height = int(artifact["height"])
width = int(artifact["width"])
tile_height = int(artifact["tile_height"])
tile_width = int(artifact["tile_width"])
denoise_sigma = float(artifact["denoise_sigma"])
template = np.asarray(artifact["template"], dtype=np.float64)
if not _geometry_supported(width, height):
raise RuntimeError("bundled SynthID detector has unexpected geometry")
if template.shape != (tile_height, tile_width, 3):
raise RuntimeError("bundled SynthID detector has an invalid template shape")
if not np.all(np.isfinite(template)) or not np.isclose(np.linalg.norm(template), 1.0):
raise RuntimeError("bundled SynthID detector has an invalid template")
if not np.isfinite(denoise_sigma) or denoise_sigma <= 0.0:
raise RuntimeError("bundled SynthID detector has an invalid denoise sigma")
return template, denoise_sigma, height, width, tile_height, tile_width
def fold_residual_template(
pixels: NDArray[Any],
*,
tile_height: int,
tile_width: int,
denoise_sigma: float,
) -> NDArray[Any]:
"""Estimate a zero-mean periodic residual template by modulo folding."""
import cv2
import numpy as np
if pixels.ndim != 3 or pixels.shape[2] != 3:
raise ValueError("pixels must have shape (height, width, 3)")
if tile_height < 1 or tile_width < 1 or denoise_sigma <= 0.0:
raise ValueError("tile dimensions and denoise sigma must be positive")
height, width = pixels.shape[:2]
if height < tile_height or width < tile_width:
raise ValueError("image geometry must be at least as large as the tile geometry")
divisible = height % tile_height == 0 and width % tile_width == 0
full_height = height - height % tile_height
full_width = width - width % tile_width
repeats_y = full_height // tile_height
repeats_x = full_width // tile_width
remaining_height = height - full_height
remaining_width = width - full_width
counts = np.full((tile_height, tile_width), repeats_y * repeats_x, dtype=np.int64)
counts[:remaining_height] += repeats_x
counts[:, :remaining_width] += repeats_y
counts[:remaining_height, :remaining_width] += 1
# OpenCV filters channels independently. Processing one channel at a time
# keeps the 18 MP upper bound from requiring two full three-channel float32
# buffers in addition to the decoded image.
folded = np.empty((tile_height, tile_width, 3), dtype=np.float64)
for channel in range(3):
residual = pixels[:, :, channel].astype(np.float32)
residual -= cv2.GaussianBlur(
residual,
(0, 0),
sigmaX=denoise_sigma,
sigmaY=denoise_sigma,
borderType=cv2.BORDER_REFLECT_101,
)
if divisible:
folded[:, :, channel] = residual.reshape(
repeats_y,
tile_height,
repeats_x,
tile_width,
).mean(axis=(0, 2), dtype=np.float64)
continue
folded_sum = (
residual[:full_height, :full_width]
.reshape(
repeats_y,
tile_height,
repeats_x,
tile_width,
)
.sum(axis=(0, 2), dtype=np.float64)
)
if remaining_height:
bottom = residual[full_height:, :full_width].reshape(
remaining_height,
repeats_x,
tile_width,
)
folded_sum[:remaining_height] += bottom.sum(axis=1, dtype=np.float64)
if remaining_width:
right = residual[:full_height, full_width:].reshape(
repeats_y,
tile_height,
remaining_width,
)
folded_sum[:, :remaining_width] += right.sum(axis=0, dtype=np.float64)
if remaining_height and remaining_width:
folded_sum[:remaining_height, :remaining_width] += residual[
full_height:,
full_width:,
]
folded[:, :, channel] = folded_sum / counts
return folded - np.mean(folded, axis=(0, 1), keepdims=True)
def unit_tile(tile: NDArray[Any]) -> tuple[NDArray[Any], float]:
"""Return TILE normalized by its L2 norm and the original norm."""
import numpy as np
norm = float(np.linalg.norm(tile))
if norm == 0.0:
return np.zeros_like(tile, dtype=np.float64), 0.0
return np.asarray(tile, dtype=np.float64) / norm, norm
def _image_size(image_path: Path) -> tuple[int, int]:
from PIL import Image
with Image.open(image_path) as image:
return image.size
def _geometry_supported(width: int, height: int) -> bool:
"""Whether the image has a calibrated number of periodic-tile samples."""
pixels = width * height
return MIN_SUPPORTED_PIXELS <= pixels <= MAX_SUPPORTED_PIXELS
def detect_synthid(image_path: str | Path, *, image: NDArray[Any] | None = None) -> SynthIDDetection:
"""Detect the supported periodic carrier in IMAGE_PATH.
``not_detected`` is not a clean-image guarantee. It means only that the
frozen periodic carrier did not cross its calibrated threshold.
"""
path = Path(image_path)
if image is None:
width, height = _image_size(path)
else:
if image.ndim != 3 or image.shape[2] != 3:
raise ValueError("image must be a three-channel BGR array")
height, width = image.shape[:2]
if not _geometry_supported(width, height):
return SynthIDDetection(
status="unsupported",
width=width,
height=height,
score=None,
threshold=TILE_THRESHOLD,
)
if not is_available():
raise RuntimeError(f"SynthID pixel detection needs numpy and OpenCV; {INSTALL_HINT}")
import numpy as np
from PIL import Image
template, sigma, _model_height, _model_width, tile_height, tile_width = _load_template()
if image is None:
with Image.open(path) as source:
pixels = np.asarray(source.convert("RGB"), dtype=np.uint8)
else:
pixels = np.asarray(image[:, :, ::-1], dtype=np.uint8)
if pixels.shape != (height, width, 3):
raise RuntimeError("decoded image geometry does not match its header")
folded = fold_residual_template(
pixels,
tile_height=tile_height,
tile_width=tile_width,
denoise_sigma=sigma,
)
normalized, _norm = unit_tile(folded)
score = float(np.sum(template * normalized))
return SynthIDDetection(
status="detected" if score >= TILE_THRESHOLD else "not_detected",
width=width,
height=height,
score=score,
threshold=TILE_THRESHOLD,
)