Add production SynthID routing and OpenAI verification

This commit is contained in:
Victor Kuznetsov
2026-08-14 13:35:22 -07:00
parent faf976d53e
commit 2d018d32ab
32 changed files with 5518 additions and 118 deletions
+8
View File
@@ -14,6 +14,7 @@ High-level API (lazy, so ``import remove_ai_watermarks`` stays cheap)::
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
raiw.verify_openai_synthid("in.png", acknowledge_upload=True) # remote
For a provenance verdict use the ``identify`` submodule::
@@ -39,6 +40,7 @@ __all__ = [
"BatchSummary",
"InvisibleOptions",
"MetadataStripIncomplete",
"OpenAISynthIDDetection",
"RemoveAllResult",
"SynthIDDetection",
"__version__",
@@ -53,6 +55,7 @@ __all__ = [
"remove_video_metadata",
"remove_video_visible",
"remove_visible",
"verify_openai_synthid",
"visible_provenance",
]
@@ -67,6 +70,7 @@ if TYPE_CHECKING:
remove_visible,
visible_provenance,
)
from remove_ai_watermarks.openai_provenance import OpenAISynthIDDetection, verify_openai_synthid
from remove_ai_watermarks.synthid_detector import SynthIDDetection, detect_synthid
from remove_ai_watermarks.video import (
identify_video,
@@ -111,4 +115,8 @@ def __getattr__(name: str) -> object:
from remove_ai_watermarks import synthid_detector
return getattr(synthid_detector, name)
if name in ("OpenAISynthIDDetection", "verify_openai_synthid"):
from remove_ai_watermarks import openai_provenance
return getattr(openai_provenance, name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+46
View File
@@ -1363,6 +1363,52 @@ def cmd_detect_synthid(source: Path, as_json: bool, register_scale: bool) -> Non
)
# ── Official OpenAI SynthID verification ──
@main.command("verify-openai-synthid")
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option(
"--acknowledge-upload",
is_flag=True,
help="Confirm upload of a pixel-identical, AI-metadata-stripped copy to OpenAI.",
)
@click.option("--json", "as_json", is_flag=True, help="Emit the verifier result as JSON.")
def cmd_verify_openai_synthid(source: Path, acknowledge_upload: bool, as_json: bool) -> None:
"""Use OpenAI's official verifier on pixels, independently of C2PA.
The command strips AI provenance metadata from a temporary copy, proves the
decoded pixels are unchanged, and uploads that copy to OpenAI. It reads only
the SynthID result. The source file is never modified.
"""
if not acknowledge_upload:
raise click.ClickException(
"this command uploads a temporary pixel-identical copy to OpenAI; pass --acknowledge-upload to continue"
)
from remove_ai_watermarks.openai_provenance import verify_openai_synthid
source = _validate_image(source)
try:
result = verify_openai_synthid(source, acknowledge_upload=True)
except (OSError, RuntimeError, ValueError) 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 OpenAI SynthID pixel watermark: {result.status}")
console.print(" Detector: official OpenAI Content Provenance API")
if result.model is not None:
console.print(f" Model: {result.model}")
if result.generated_at is not None:
console.print(f" Generated at: {result.generated_at}")
console.print(
" Input: AI provenance metadata was stripped and decoded pixels were preserved.\n"
" Scope: supported OpenAI SynthID only. A not_detected result is not proof\n"
" that the image is human-created or contains no other watermark."
)
# ── Provenance identification ──
@main.command("identify")
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@@ -0,0 +1,232 @@
"""Official OpenAI SynthID verification with metadata-independent input.
The Content Provenance API returns C2PA and SynthID outcomes independently.
This module removes AI provenance metadata before upload, proves that the
decoded RGBA raster did not change, and then consumes only the SynthID result.
It is intentionally separate from :func:`identify`: calling it uploads one
sanitized raster to OpenAI and therefore always requires an explicit user
action.
The OpenAI SDK is optional. Imports remain lazy so local and metadata-only
paths do not acquire a network client dependency.
"""
from __future__ import annotations
import hashlib
import importlib
import json
import logging
import tempfile
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal, cast
log = logging.getLogger(__name__)
OpenAISynthIDStatus = Literal["detected", "not_detected"]
DETECTOR_ID = "openai-content-provenance-synthid-v1"
INSTALL_HINT = "install the verification extra: uv add 'remove-ai-watermarks[verify]'"
MAX_UPLOAD_BYTES = 50 * 1024 * 1024
_FORMAT_DETAILS = {
"JPEG": ("image/jpeg", ".jpg"),
"PNG": ("image/png", ".png"),
"WEBP": ("image/webp", ".webp"),
}
@dataclass(frozen=True)
class OpenAISynthIDDetection:
"""One official OpenAI pixel-watermark verdict."""
status: OpenAISynthIDStatus
model: str | None
generated_at: str | None
api_created_at: int | None
detector: str = DETECTOR_ID
ai_metadata_stripped: bool = True
pixels_preserved: bool = True
@property
def detected(self) -> bool:
"""Whether the official verifier recognized an OpenAI SynthID signal."""
return self.status == "detected"
def to_dict(self) -> dict[str, str | int | bool | None]:
"""Return a JSON-safe result without a local path or C2PA outcome."""
return {
"status": self.status,
"model": self.model,
"generated_at": self.generated_at,
"api_created_at": self.api_created_at,
"detector": self.detector,
"ai_metadata_stripped": self.ai_metadata_stripped,
"pixels_preserved": self.pixels_preserved,
}
def is_available() -> bool:
"""True when the optional OpenAI SDK is installed."""
from remove_ai_watermarks.optional_deps import module_available
return module_available("openai")
def _pixel_fingerprint(path: Path) -> tuple[str, str]:
"""Return the PIL format and a bounded-memory hash of decoded RGBA pixels."""
from PIL import Image
with Image.open(path) as image:
image.load()
image_format = image.format
if image_format not in _FORMAT_DETAILS:
supported = ", ".join(sorted(_FORMAT_DETAILS))
actual = image_format or "unknown"
raise ValueError(f"OpenAI SynthID verification supports {supported} images; got {actual}")
digest = hashlib.sha256()
digest.update(f"{image.width}x{image.height}:RGBA\0".encode())
# Hash bands instead of materializing a second full-image byte string.
for top in range(0, image.height, 128):
bottom = min(top + 128, image.height)
digest.update(image.crop((0, top, image.width, bottom)).convert("RGBA").tobytes())
return image_format, digest.hexdigest()
def _response_mapping(response: Any) -> Mapping[str, Any]:
"""Normalize an SDK model or test double to the documented response mapping."""
if isinstance(response, Mapping):
return cast("Mapping[str, Any]", response)
model_dump = getattr(response, "model_dump", None)
if callable(model_dump):
dumped = model_dump(mode="json")
if isinstance(dumped, Mapping):
return cast("Mapping[str, Any]", dumped)
raise RuntimeError("OpenAI Content Provenance returned an unexpected response type")
def _optional_string(entry: Mapping[str, Any], field: str) -> str | None:
value = entry.get(field)
if value is None or isinstance(value, str):
return value
raise RuntimeError(f"OpenAI SynthID result has an invalid {field!r} field")
def _parse_synthid_result(payload: Mapping[str, Any]) -> OpenAISynthIDDetection:
"""Read exactly one SynthID entry and deliberately ignore C2PA entries."""
raw_results = payload.get("results")
if not isinstance(raw_results, list):
raise RuntimeError("OpenAI Content Provenance response has no results list")
results = cast("list[Any]", raw_results)
synthid_entries: list[Mapping[str, Any]] = []
for raw_entry in results:
if isinstance(raw_entry, Mapping):
entry = cast("Mapping[str, Any]", raw_entry)
if entry.get("type") == "synthid":
synthid_entries.append(entry)
if len(synthid_entries) != 1:
raise RuntimeError(f"OpenAI Content Provenance returned {len(synthid_entries)} SynthID results; expected one")
synthid = synthid_entries[0]
outcome = synthid.get("outcome")
if outcome not in ("detected", "not_detected"):
raise RuntimeError(f"OpenAI SynthID result has an unsupported outcome: {outcome!r}")
created_at = payload.get("created_at")
if created_at is not None and (not isinstance(created_at, int) or isinstance(created_at, bool)):
raise RuntimeError("OpenAI Content Provenance response has an invalid 'created_at' field")
return OpenAISynthIDDetection(
status=outcome,
model=_optional_string(synthid, "model"),
generated_at=_optional_string(synthid, "generated_at"),
api_created_at=created_at,
)
def _default_client() -> Any:
if not is_available():
raise RuntimeError(f"OpenAI SynthID verification needs the OpenAI SDK; {INSTALL_HINT}")
openai_module = importlib.import_module("openai")
client_factory = cast("Callable[[], Any]", openai_module.OpenAI)
try:
client = client_factory()
except Exception as exc:
raise RuntimeError(f"could not initialize the OpenAI client: {exc}") from exc
if not hasattr(client, "content_provenance_checks"):
raise RuntimeError(f"OpenAI SynthID verification needs openai>=2.52.0; {INSTALL_HINT}")
return client
def _request_error(exc: Exception) -> RuntimeError:
status_code = getattr(exc, "status_code", None)
if status_code == 400:
detail = "OpenAI rejected the image as malformed, unsupported, or blocked"
elif status_code == 404:
detail = "the OpenAI organization does not have Content Provenance API access"
elif status_code == 429:
detail = "the OpenAI Content Provenance API rate limit was exceeded"
else:
detail = f"OpenAI Content Provenance request failed: {exc}"
return RuntimeError(detail)
def verify_openai_synthid(
image_path: str | Path,
*,
acknowledge_upload: bool = False,
client: Any | None = None,
) -> OpenAISynthIDDetection:
"""Verify OpenAI SynthID after stripping AI metadata without changing pixels.
This function performs one remote request and uploads a temporary sanitized
copy of the image. It never uses C2PA as a fallback and never interprets a
negative result as proof that the image is human-created.
"""
if not acknowledge_upload:
raise ValueError(
"OpenAI SynthID verification uploads a temporary pixel-identical copy; "
"pass acknowledge_upload=True to continue"
)
source = Path(image_path)
source_format, source_fingerprint = _pixel_fingerprint(source)
media_type, suffix = _FORMAT_DETAILS[source_format]
with tempfile.TemporaryDirectory(prefix="remove-ai-watermarks-openai-") as directory:
sanitized = Path(directory) / f"upload{suffix}"
from remove_ai_watermarks.metadata import strip_and_verify
stripped, remaining = strip_and_verify(source, sanitized, keep_standard=True)
if remaining:
fields = ", ".join(sorted(remaining))
raise RuntimeError(f"refusing upload because AI provenance metadata survived stripping: {fields}")
stripped_format, stripped_fingerprint = _pixel_fingerprint(stripped)
if stripped_format != source_format or stripped_fingerprint != source_fingerprint:
raise RuntimeError("refusing upload because metadata stripping changed the decoded pixels")
upload_bytes = stripped.stat().st_size
if upload_bytes > MAX_UPLOAD_BYTES:
raise ValueError("sanitized image exceeds the OpenAI Content Provenance 50 MiB upload limit")
api_client = client if client is not None else _default_client()
if not hasattr(api_client, "content_provenance_checks"):
raise RuntimeError("OpenAI client does not expose content_provenance_checks; openai>=2.52.0 is required")
request_context = {
"endpoint": "/v1/content_provenance_checks",
"filename": sanitized.name,
"media_type": media_type,
"bytes": upload_bytes,
"pixel_sha256": source_fingerprint,
}
log.info("OpenAI Content Provenance request: %s", json.dumps(request_context, sort_keys=True))
try:
with stripped.open("rb") as upload:
response = api_client.content_provenance_checks.create(
file=(sanitized.name, upload, media_type),
)
except Exception as exc:
log.exception("OpenAI Content Provenance request failed: %s", json.dumps(request_context, sort_keys=True))
raise _request_error(exc) from exc
payload = _response_mapping(response)
log.info("OpenAI Content Provenance response: %s", json.dumps(payload, default=str, sort_keys=True))
return _parse_synthid_result(payload)
+154 -5
View File
@@ -26,6 +26,7 @@ SynthIDDetectionStatus = Literal["detected", "not_detected", "unsupported"]
DETECTOR_ID = "synthid-periodic-tile-v2"
REGISTERED_DETECTOR_ID = "synthid-periodic-tile-registered-v2"
LARGE_DETECTOR_ID = "synthid-periodic-tile-large-v1"
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:
@@ -42,6 +43,20 @@ REGISTERED_MIN_SIDE = 64
# The registered score is the minimum normalized margin across its amplitude,
# spectral-candidate, and high-frequency agreement gates.
REGISTERED_THRESHOLD = 1.0
# The large-image score combines all-window fixed and spatial opponent gates
# with an any-window signed opponent mid-band gate. The one vulnerable portrait
# geometry has an additional Green mid-band upper gate.
LARGE_THRESHOLD = 1.0
LARGE_MIN_PIXELS = 10_000_000
LARGE_MAX_PIXELS = 18_000_000
LARGE_WINDOW = 2_048
LARGE_PHASE = 16
LARGE_FIXED_SCORE_MIN = 0.14
LARGE_RED_GREEN_SPATIAL_MIN = 0.90
LARGE_BLUE_YELLOW_SPATIAL_MIN = 0.70
LARGE_BLUE_YELLOW_MID_BAND_MAX = -0.15
LARGE_PORTRAIT_GEOMETRY = (3_072, 5_504)
LARGE_PORTRAIT_GREEN_MID_BAND_MAX = 0.06
INSTALL_HINT = "install the pixel extra: uv add 'remove-ai-watermarks[pixels]'"
@@ -73,6 +88,32 @@ class SynthIDDetection:
}
@dataclass(frozen=True)
class LargeImageComponents:
"""Auditable margins for the calibrated large-image carrier branch."""
width: int
height: int
minimum_fixed_score: float
minimum_red_green_spatial: float
minimum_blue_yellow_spatial: float
minimum_blue_yellow_mid_band: float
maximum_green_mid_band: float
@property
def decision_score(self) -> float:
"""Return the minimum normalized gate margin; one is the boundary."""
margins = [
self.minimum_fixed_score / LARGE_FIXED_SCORE_MIN,
self.minimum_red_green_spatial / LARGE_RED_GREEN_SPATIAL_MIN,
self.minimum_blue_yellow_spatial / LARGE_BLUE_YELLOW_SPATIAL_MIN,
self.minimum_blue_yellow_mid_band / LARGE_BLUE_YELLOW_MID_BAND_MAX,
]
if (self.width, self.height) == LARGE_PORTRAIT_GEOMETRY:
margins.append(1.0 + LARGE_PORTRAIT_GREEN_MID_BAND_MAX - self.maximum_green_mid_band)
return min(margins)
def is_available() -> bool:
"""True when the optional numeric runtime is installed."""
from remove_ai_watermarks.optional_deps import module_available
@@ -222,6 +263,12 @@ def _registered_geometry_supported(width: int, height: int) -> bool:
)
def _large_geometry_supported(width: int, height: int) -> bool:
"""Whether fixed phase-aligned windows cover the calibrated large range."""
pixels = width * height
return min(width, height) >= LARGE_WINDOW and LARGE_MIN_PIXELS < pixels <= LARGE_MAX_PIXELS
def folded_template_score(
pixels: NDArray[Any],
template: NDArray[Any],
@@ -239,6 +286,98 @@ def folded_template_score(
return float((template * normalized).sum()), folded
def _large_window_starts(length: int) -> tuple[int, ...]:
"""Return phase-aligned starts that cover both edges without resampling."""
if length < LARGE_WINDOW:
raise ValueError("large-image sides must be at least 2,048 pixels")
last = ((length - LARGE_WINDOW) // LARGE_PHASE) * LARGE_PHASE
starts = list(range(0, last + 1, LARGE_WINDOW))
if starts[-1] != last:
starts.append(last)
return tuple(starts)
def _correlation(left: NDArray[Any], right: NDArray[Any]) -> float:
import numpy as np
denominator = float(np.linalg.norm(left) * np.linalg.norm(right))
return float(np.real(np.vdot(right, left)) / denominator) if denominator > 0.0 else 0.0
def _large_window_components(
folded: NDArray[Any],
template: NDArray[Any],
) -> tuple[float, float, float, float]:
"""Measure the four color-phase features used by the large branch."""
import numpy as np
folded_red_green = folded[:, :, 0] - folded[:, :, 1]
template_red_green = template[:, :, 0] - template[:, :, 1]
folded_blue_yellow = folded[:, :, 2] - 0.5 * (folded[:, :, 0] + folded[:, :, 1])
template_blue_yellow = template[:, :, 2] - 0.5 * (template[:, :, 0] + template[:, :, 1])
height, width = folded.shape[:2]
y_coordinates = np.minimum(np.arange(height), height - np.arange(height))
x_coordinates = np.minimum(np.arange(width), width - np.arange(width))
radius = np.sqrt(y_coordinates[:, None] ** 2 + x_coordinates[None, :] ** 2)
mid_band = (radius >= 4.5) & (radius < 6.5)
blue_yellow_mid = _correlation(
np.fft.fft2(folded_blue_yellow)[mid_band],
np.fft.fft2(template_blue_yellow)[mid_band],
)
green_mid = _correlation(
np.fft.fft2(folded[:, :, 1])[mid_band],
np.fft.fft2(template[:, :, 1])[mid_band],
)
return (
_correlation(folded_red_green, template_red_green),
_correlation(folded_blue_yellow, template_blue_yellow),
blue_yellow_mid,
green_mid,
)
def large_image_components(
pixels: NDArray[Any],
template: NDArray[Any],
denoise_sigma: float,
) -> LargeImageComponents:
"""Score all phase-aligned 2,048-pixel windows of one large RGB image."""
if pixels.ndim != 3 or pixels.shape[2] != 3:
raise ValueError("pixels must have shape (height, width, 3)")
height, width = pixels.shape[:2]
if not _large_geometry_supported(width, height):
raise ValueError("image geometry is outside the calibrated large-image range")
minimum_fixed = float("inf")
minimum_red_green = float("inf")
minimum_blue_yellow = float("inf")
minimum_blue_yellow_mid = float("inf")
maximum_green_mid = -float("inf")
for y in _large_window_starts(height):
for x in _large_window_starts(width):
window = pixels[y : y + LARGE_WINDOW, x : x + LARGE_WINDOW]
fixed_score, folded = folded_template_score(window, template, denoise_sigma)
red_green, blue_yellow, blue_yellow_mid, green_mid = _large_window_components(
folded,
template,
)
minimum_fixed = min(minimum_fixed, fixed_score)
minimum_red_green = min(minimum_red_green, red_green)
minimum_blue_yellow = min(minimum_blue_yellow, blue_yellow)
minimum_blue_yellow_mid = min(minimum_blue_yellow_mid, blue_yellow_mid)
maximum_green_mid = max(maximum_green_mid, green_mid)
return LargeImageComponents(
width=width,
height=height,
minimum_fixed_score=minimum_fixed,
minimum_red_green_spatial=minimum_red_green,
minimum_blue_yellow_spatial=minimum_blue_yellow,
minimum_blue_yellow_mid_band=minimum_blue_yellow_mid,
maximum_green_mid_band=maximum_green_mid,
)
def detect_synthid(
image_path: str | Path,
*,
@@ -258,11 +397,19 @@ def detect_synthid(
if image.ndim != 3 or image.shape[2] != 3:
raise ValueError("image must be a three-channel BGR array")
height, width = image.shape[:2]
geometry_supported = (
_registered_geometry_supported(width, height) if register_scale else _geometry_supported(width, height)
)
threshold = REGISTERED_THRESHOLD if register_scale else TILE_THRESHOLD
detector_id = REGISTERED_DETECTOR_ID if register_scale else DETECTOR_ID
large_mode = not register_scale and width * height > LARGE_MIN_PIXELS
if register_scale:
geometry_supported = _registered_geometry_supported(width, height)
threshold = REGISTERED_THRESHOLD
detector_id = REGISTERED_DETECTOR_ID
elif large_mode:
geometry_supported = _large_geometry_supported(width, height)
threshold = LARGE_THRESHOLD
detector_id = LARGE_DETECTOR_ID
else:
geometry_supported = _geometry_supported(width, height)
threshold = TILE_THRESHOLD
detector_id = DETECTOR_ID
if not geometry_supported:
return SynthIDDetection(
status="unsupported",
@@ -290,6 +437,8 @@ def detect_synthid(
from remove_ai_watermarks._synthid_registered import registered_score
score = registered_score(pixels, template, sigma)
elif large_mode:
score = large_image_components(pixels, template, sigma).decision_score
else:
score, _folded = folded_template_score(pixels, template, sigma)
return SynthIDDetection(