mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-09-01 02:00:36 +02:00
Add calibrated SynthID pixel detector
This commit is contained in:
@@ -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 @@
|
||||
"""Embedded assets for visible watermark removal."""
|
||||
"""Embedded runtime assets for watermark detection and removal."""
|
||||
|
||||
Binary file not shown.
@@ -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}"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user