mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-09-03 19:10:41 +02:00
Add production SynthID routing and OpenAI verification
This commit is contained in:
@@ -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}")
|
||||
|
||||
@@ -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)
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user