mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-09-01 18:20:35 +02:00
Unplug lattice detector from package, expand research docs
This commit is contained in:
@@ -13,7 +13,6 @@ 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
|
||||
raiw.verify_openai_synthid("in.png", acknowledge_upload=True) # remote
|
||||
|
||||
For a provenance verdict use the ``identify`` submodule::
|
||||
@@ -43,9 +42,7 @@ __all__ = [
|
||||
"OpenAIProvenanceError",
|
||||
"OpenAISynthIDDetection",
|
||||
"RemoveAllResult",
|
||||
"SynthIDDetection",
|
||||
"__version__",
|
||||
"detect_synthid",
|
||||
"identify_video",
|
||||
"inspect_video_metadata",
|
||||
"remove_all",
|
||||
@@ -76,7 +73,6 @@ if TYPE_CHECKING:
|
||||
OpenAISynthIDDetection,
|
||||
verify_openai_synthid,
|
||||
)
|
||||
from remove_ai_watermarks.synthid_detector import SynthIDDetection, detect_synthid
|
||||
from remove_ai_watermarks.video import (
|
||||
identify_video,
|
||||
inspect_video_metadata,
|
||||
@@ -116,10 +112,6 @@ 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)
|
||||
if name in ("OpenAIProvenanceError", "OpenAISynthIDDetection", "verify_openai_synthid"):
|
||||
from remove_ai_watermarks import openai_provenance
|
||||
|
||||
|
||||
@@ -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 -- the local SynthID detector "
|
||||
"covers one carrier family in a calibrated image-size range)."
|
||||
"skip never claims the image is watermark-free -- this package has no local "
|
||||
"SynthID pixel decoder)."
|
||||
),
|
||||
)
|
||||
_cpu_offload_option = click.option(
|
||||
@@ -455,20 +455,18 @@ 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: 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.
|
||||
clean: Google does not publish the SynthID payload decoder, and this package
|
||||
does not ship one, so a mark can still be present after its metadata proxy
|
||||
is gone. Write no output and exit :data:`EXIT_NO_INVISIBLE_SIGNAL`;
|
||||
``--force`` runs the scrub regardless.
|
||||
"""
|
||||
console.print(
|
||||
" 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"
|
||||
" No supported invisible AI watermark detected (no provenance or 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: this package has no local SynthID\n"
|
||||
" pixel decoder. If you know the image is AI-generated and want the pixels\n"
|
||||
" regenerated regardless, re-run with --force:\n"
|
||||
f" remove-ai-watermarks invisible {source.name} --force"
|
||||
)
|
||||
raise SystemExit(EXIT_NO_INVISIBLE_SIGNAL)
|
||||
@@ -1318,68 +1316,6 @@ 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.")
|
||||
@click.option(
|
||||
"--register-scale/--fixed-period",
|
||||
default=None,
|
||||
help="Force registered production search or the legacy fixed-period diagnostic.",
|
||||
)
|
||||
def cmd_detect_synthid(source: Path, as_json: bool, register_scale: bool | None) -> None:
|
||||
"""Detect a generation-pipeline pixel lattice at calibrated image sizes.
|
||||
|
||||
EXPERIMENTAL. The supported route for SynthID is signed provenance, which
|
||||
`identify` reads and `verify-openai-synthid` confirms against the provider.
|
||||
This command does NOT detect the SynthID watermark. The statistic it reports is
|
||||
destroyed by a seven-pixel crop, while SynthID's published evaluation keeps
|
||||
99.97% of its detection rate under aggressive crop and resize, so what
|
||||
crosses the threshold identifies the generation pipeline rather than the
|
||||
mark. Read a positive as "these pixels came from a pipeline that leaves this
|
||||
lattice", never as "this image is watermarked", and read an indeterminate
|
||||
result as neither.
|
||||
"""
|
||||
from remove_ai_watermarks.synthid_detector import detect_synthid
|
||||
|
||||
source = _validate_image(source)
|
||||
try:
|
||||
result = detect_synthid(source, register_scale=register_scale)
|
||||
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 Generation-pipeline lattice (experimental): {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}")
|
||||
if result.reason is not None:
|
||||
console.print(f" Reason: {result.reason}")
|
||||
if register_scale is True:
|
||||
scale_scope = " Bounded spatial-scale registration was explicitly enabled. An indeterminate or\n"
|
||||
elif register_scale is False:
|
||||
scale_scope = " The legacy fixed-period diagnostic was explicitly enabled. An indeterminate or\n"
|
||||
else:
|
||||
scale_scope = (
|
||||
" The production router selected the calibrated registered or large-image expert. An indeterminate or\n"
|
||||
)
|
||||
console.print(
|
||||
" Scope: experimental. One periodic lattice family in a calibrated image-size range,\n"
|
||||
" secondary to signed provenance, which remains the supported SynthID route. This is a\n"
|
||||
" generation-pipeline signature, not the SynthID watermark: it disappears when the\n"
|
||||
" image is cropped off the tile grid, and it changes when the generator's pipeline\n"
|
||||
" changes. A positive says the pixels came from such a pipeline. It does not say the\n"
|
||||
" image carries a watermark, and it does not say it lacks one.\n"
|
||||
+ scale_scope
|
||||
+ " unsupported result is not proof that SynthID is absent."
|
||||
)
|
||||
|
||||
|
||||
# ── Official OpenAI SynthID verification ──
|
||||
@main.command("verify-openai-synthid")
|
||||
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
||||
@@ -1470,9 +1406,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 the local "
|
||||
"SynthID pixel detector covers one carrier family in a calibrated image-size range. "
|
||||
"See caveats below."
|
||||
"metadata is often stripped by re-encoding, screenshots, or upload, and this "
|
||||
"package has no local SynthID pixel decoder. See caveats below."
|
||||
)
|
||||
|
||||
if report.integrity_clashes:
|
||||
@@ -1565,9 +1500,8 @@ def cmd_all(
|
||||
stage_text = {
|
||||
("invisible", "no-signal"): (
|
||||
"Skipped (no invisible AI watermark detected; pixels left intact).\n"
|
||||
" 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."
|
||||
" Not a clean-image guarantee: this package has no local SynthID pixel\n"
|
||||
" decoder. Re-run with --force to scrub regardless."
|
||||
),
|
||||
("invisible", "unavailable"): (
|
||||
f"Warning: Skipped - GPU dependencies not installed.\n Install them with: pip install {INVISIBLE_EXTRA}"
|
||||
|
||||
@@ -6,14 +6,13 @@ 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 evidence** -- supported C2PA provenance plus a positive-only local
|
||||
detector for one confirmed periodic carrier family in a calibrated image-size range.
|
||||
- **SynthID evidence** -- supported C2PA provenance (Google AI, or current OpenAI
|
||||
with an explicit watermark action). There is no local SynthID pixel decoder.
|
||||
- **Registered visible marks** (optional; needs cv2/numpy, no GPU) through the
|
||||
shared watermark registry.
|
||||
|
||||
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".
|
||||
Hard limit: Google does not publish its payload decoder, and this package does
|
||||
not ship one. Absence of signals is reported as ``Unknown``, never as "clean".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -109,18 +108,7 @@ _STRIP_CAVEAT = (
|
||||
"Absence of metadata is not proof the image is clean: C2PA, EXIF, and PNG "
|
||||
"text chunks are stripped by re-encoding, screenshots, or social-media upload."
|
||||
)
|
||||
_SYNTHID_CAVEAT = (
|
||||
"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."
|
||||
)
|
||||
_PIPELINE_LATTICE_CAVEAT = (
|
||||
"EXPERIMENTAL. Signed provenance is the primary route for SynthID; this pixel result is not a "
|
||||
"watermark at all but a generation-pipeline lattice: it is destroyed by a "
|
||||
"crop of seven pixels, while the published SynthID evaluation survives aggressive crop and resize. "
|
||||
"It accepted 29 of 223 images from other generators, 24% of Adobe Firefly, so it does not identify "
|
||||
"the provider. A negative or unsupported result is not proof of absence."
|
||||
)
|
||||
_SYNTHID_CAVEAT = "SynthID presence comes from supported provenance here. Confirm other cases with the provider oracle."
|
||||
_IPTC_ONLY_CAVEAT = "The IPTC 'Made with AI' tag flags AI provenance but does not identify the specific platform."
|
||||
_INVISIBLE_WM_CAVEAT = (
|
||||
"The open invisible watermark is fragile: it does not survive JPEG re-encoding "
|
||||
@@ -958,21 +946,6 @@ def _trustmark(image_path: Path) -> str | None:
|
||||
return detect_trustmark(image_path)
|
||||
|
||||
|
||||
def _pipeline_lattice(image_path: Path, decode: _SharedDecode) -> bool:
|
||||
"""Whether the supported generation-pipeline lattice is detected.
|
||||
|
||||
Named for what it measures. The underlying expert is still called a SynthID
|
||||
detector in its own module, but its statistic is a lattice anchored at the
|
||||
image origin that a seven-pixel crop removes, so nothing here may present it
|
||||
as a watermark.
|
||||
"""
|
||||
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.
|
||||
|
||||
@@ -1317,18 +1290,6 @@ def _identify_from_evidence(
|
||||
if platform is None:
|
||||
platform = f"{scheme} (open DWT-DCT watermark)"
|
||||
|
||||
# ── Generation-pipeline lattice, experimental ───────────────────
|
||||
# Signed provenance above is the primary SynthID route; this is a secondary
|
||||
# pixel observation and is kept out of the watermark inventory on purpose. This reads a periodic
|
||||
# lattice anchored at the image origin, which identifies the pipeline that
|
||||
# produced the pixels; it is not SynthID and not any watermark, so listing
|
||||
# it beside C2PA watermark assertions would misrepresent both.
|
||||
if check_invisible and pixel_path is not None and _pipeline_lattice(pixel_path, decode):
|
||||
signals.append(Signal("pipeline_lattice", "generation-pipeline lattice (experimental)", "medium"))
|
||||
caveats.append(_PIPELINE_LATTICE_CAVEAT)
|
||||
if platform is None:
|
||||
platform = "generation-pipeline lattice 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
|
||||
@@ -1341,7 +1302,6 @@ def _identify_from_evidence(
|
||||
|
||||
# ── Verdict so far (metadata + embedded watermark) ──────────────
|
||||
invisible_wm = any(s.name == "invisible_watermark" for s in signals)
|
||||
pipeline_lattice = any(s.name == "pipeline_lattice" 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(
|
||||
@@ -1351,7 +1311,6 @@ def _identify_from_evidence(
|
||||
or aigc
|
||||
or local_keys
|
||||
or invisible_wm
|
||||
or pipeline_lattice
|
||||
or exif_gen
|
||||
or xai_sig
|
||||
)
|
||||
@@ -1473,13 +1432,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 or periodic
|
||||
carrier, IPTC, AIGC, local gen params, EXIF/xAI, open DWT-DCT / TrustMark).
|
||||
``report.ai_from_metadata`` (C2PA AI issuer / SynthID provenance, IPTC,
|
||||
AIGC, local gen params, EXIF/xAI, open DWT-DCT / TrustMark).
|
||||
|
||||
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.
|
||||
IMPORTANT -- this cannot prove a pixel SynthID is absent: Google does not
|
||||
publish the payload decoder, and this package does not ship one. 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.
|
||||
|
||||
Reference in New Issue
Block a user