mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-10-19 15:37:16 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11afe68411 | ||
|
|
c11181b9b5 | ||
|
|
eb544806f6 | ||
|
|
dfe69772cd | ||
|
|
68fb3dded6 | ||
|
|
47ef60c2a1 | ||
|
|
a981eb84b1 | ||
|
|
df62800bf1 |
No files matched your search
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "remove-ai-watermarks"
|
||||
version = "0.18.0"
|
||||
version = "0.19.0"
|
||||
description = "AI watermark remover: strip visible and invisible AI watermarks (Gemini / Nano Banana sparkle, SynthID) and provenance metadata (C2PA, EXIF) from images"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
"""AI-generation scorer for scanned datasets.
|
||||
|
||||
Trains a gradient-boosted classifier on the metadata-derived labels of a
|
||||
scan_dataset.py output and scores every file, WITHOUT needing the
|
||||
metadata to be present at scoring time: the features are pixel and
|
||||
container statistics, so a metadata-stripped file still gets a score.
|
||||
|
||||
Measured on the production corpus (temporal holdout, honest for drift):
|
||||
structural-feature GBM reaches AUC ~0.94-0.96 on-distribution. The model
|
||||
is distribution-specific by design (it is trained on this service's
|
||||
traffic); do not expect the same numbers on unrelated data. CLIP-ViT-L/14
|
||||
features were evaluated as an alternative and scored lower (0.82) while
|
||||
being ~30x slower, so the shipped model uses the structural features the
|
||||
scanner already collects.
|
||||
|
||||
Modes:
|
||||
uv run --with scikit-learn python scripts/ai_score.py train <scan_glob> <model.pkl>
|
||||
uv run --with scikit-learn python scripts/ai_score.py score <scan_glob> <model.pkl> <out.jsonl>
|
||||
|
||||
<scan_glob> is a glob of scan_dataset shards, e.g. 'data/scan/part_*.jsonl'.
|
||||
score output: one JSON line per file with file, sha256, ai_score, and the
|
||||
label evidence when a metadata label existed (for monitoring drift).
|
||||
"""
|
||||
|
||||
import glob
|
||||
import json
|
||||
import pickle
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
# AI-generator names in C2PA claim_generator_info (metadata-level truth).
|
||||
AI_GENERATORS = (
|
||||
"openai",
|
||||
"adobe firefly",
|
||||
"adobe_firefly",
|
||||
"microsoft responsible ai",
|
||||
"microsoft_designer",
|
||||
"black forest labs",
|
||||
"fal-ai",
|
||||
"bria",
|
||||
"chatgpt",
|
||||
"stability",
|
||||
"dreamina",
|
||||
"canva",
|
||||
)
|
||||
# Human-origin software (negative evidence, NOT proof by itself).
|
||||
HUMAN_SOFTWARE = (
|
||||
"photoshop",
|
||||
"lightroom",
|
||||
"picsart",
|
||||
"snapseed",
|
||||
"paint.net",
|
||||
"gimp",
|
||||
"capture one",
|
||||
"meitu",
|
||||
"xingtu",
|
||||
"snow",
|
||||
)
|
||||
|
||||
_FEAT_SCALAR = [
|
||||
("noise", "noise_std"),
|
||||
("noise", "noise_kurtosis"),
|
||||
("fft", "cfa_peak"),
|
||||
("ela", "ela_mean"),
|
||||
("ela", "ela_p95"),
|
||||
("gradient", "laplacian_var"),
|
||||
("color", "saturation_mean"),
|
||||
("color", "value_mean"),
|
||||
("dct", "benford_mad"),
|
||||
]
|
||||
|
||||
|
||||
def label_of(record: dict[str, Any]) -> int | None:
|
||||
"""1 = strong metadata AI, 0 = human-origin, None = unlabeled."""
|
||||
store = record.get("c2pa_store") or {}
|
||||
for m in (store.get("manifests") or {}).values():
|
||||
for cgi in m.get("claim_generator_info") or []:
|
||||
if any(g in str(cgi.get("name", "")).lower() for g in AI_GENERATORS):
|
||||
return 1
|
||||
for a in m.get("assertions") or []:
|
||||
d = a.get("data") if isinstance(a.get("data"), dict) else {}
|
||||
if "trainedAlgorithmicMedia" in str(d.get("digitalSourceType", "")):
|
||||
return 1
|
||||
for c in record.get("png_chunks", []):
|
||||
t = c.get("text", "")
|
||||
kw = t.split("\x00")[0]
|
||||
if kw in ("prompt", "workflow", "parameters") or "AIGC" in t[:80]:
|
||||
return 1
|
||||
exif = record.get("exif", {})
|
||||
z = exif.get("0th") or {}
|
||||
e = exif.get("Exif") or {}
|
||||
if z.get("Make") and z.get("Model") and (e.get("MakerNote") or e.get("LensModel") or e.get("LensMake")):
|
||||
return 0
|
||||
for c in record.get("png_chunks", []):
|
||||
if c.get("apple_screenshot_marker"):
|
||||
return 0
|
||||
for v in (record.get("iptc") or {}).values():
|
||||
if str(v).strip() == "Screenshot":
|
||||
return 0
|
||||
if any(t in str(z.get("Software", "")).lower() for t in HUMAN_SOFTWARE):
|
||||
return 0
|
||||
return None
|
||||
|
||||
|
||||
def features_of(record: dict[str, Any]) -> list[float]:
|
||||
"""The structural feature vector (pixel + container stats, no metadata)."""
|
||||
v = [float((record.get(s) or {}).get(k, np.nan)) for s, k in _FEAT_SCALAR]
|
||||
for b in (record.get("fft") or {}).get("fft_band_energy", []):
|
||||
v.append(float(b))
|
||||
for h in (record.get("gradient") or {}).get("gradient_hist", []):
|
||||
v.append(float(h))
|
||||
for h in (record.get("color") or {}).get("color_hist_4x4x4", []):
|
||||
v.append(float(h))
|
||||
jf = record.get("jpeg_forensics") or {}
|
||||
v.append(1.0 if jf.get("subsampling") == "4:4:4" else 0.0)
|
||||
v.append(1.0 if jf.get("progressive") else 0.0)
|
||||
pil = record.get("pil") or {}
|
||||
w, h = pil.get("width") or 0, pil.get("height") or 0
|
||||
v += [float(w * h) / 1e6, float(w) / max(h, 1)]
|
||||
fmt = record.get("content_format")
|
||||
v += [1.0 if fmt == "jpeg" else 0.0, 1.0 if fmt == "png" else 0.0]
|
||||
return v
|
||||
|
||||
|
||||
def iter_records(pattern: str) -> Any:
|
||||
for path in sorted(glob.glob(pattern)):
|
||||
with open(path) as fh:
|
||||
for line in fh:
|
||||
yield json.loads(line)
|
||||
|
||||
|
||||
def cmd_train(pattern: str, model_path: str) -> None:
|
||||
from sklearn.ensemble import HistGradientBoostingClassifier
|
||||
from sklearn.impute import SimpleImputer
|
||||
from sklearn.metrics import average_precision_score, roc_auc_score
|
||||
from sklearn.model_selection import train_test_split
|
||||
|
||||
X, y, dates = [], [], []
|
||||
for r in iter_records(pattern):
|
||||
if "noise" not in r:
|
||||
continue
|
||||
lab = label_of(r)
|
||||
if lab is None:
|
||||
continue
|
||||
X.append(features_of(r))
|
||||
y.append(lab)
|
||||
dates.append(Path(r["file"]).parent.name)
|
||||
X, y = np.array(X), np.array(y)
|
||||
print(f"labeled: {len(y)} (pos {int(y.sum())}, neg {int((1 - y).sum())}, features {X.shape[1]})")
|
||||
|
||||
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.3, random_state=0, stratify=y)
|
||||
imp = SimpleImputer(strategy="median").fit(Xtr)
|
||||
clf = HistGradientBoostingClassifier(random_state=0, max_iter=300)
|
||||
clf.fit(imp.transform(Xtr), ytr)
|
||||
p = clf.predict_proba(imp.transform(Xte))[:, 1]
|
||||
print(f"holdout: AUC {roc_auc_score(yte, p):.4f} | AP {average_precision_score(yte, p):.4f}")
|
||||
|
||||
uniq = sorted(set(dates))
|
||||
cutoff = uniq[int(len(uniq) * 0.7)]
|
||||
tr = np.array([d < cutoff for d in dates])
|
||||
if tr.sum() > 100 and (~tr).sum() > 100:
|
||||
clf_t = HistGradientBoostingClassifier(random_state=0, max_iter=300)
|
||||
imp_t = SimpleImputer(strategy="median").fit(X[tr])
|
||||
clf_t.fit(imp_t.transform(X[tr]), y[tr])
|
||||
pt = clf_t.predict_proba(imp_t.transform(X[~tr]))[:, 1]
|
||||
auc_t = roc_auc_score(y[~tr], pt)
|
||||
ap_t = average_precision_score(y[~tr], pt)
|
||||
print(f"temporal (<{cutoff}): AUC {auc_t:.4f} | AP {ap_t:.4f}")
|
||||
|
||||
imp = SimpleImputer(strategy="median").fit(X)
|
||||
clf = HistGradientBoostingClassifier(random_state=0, max_iter=300)
|
||||
clf.fit(imp.transform(X), y)
|
||||
with open(model_path, "wb") as f:
|
||||
pickle.dump({"imputer": imp, "clf": clf}, f)
|
||||
print(f"model written: {model_path}")
|
||||
|
||||
|
||||
def cmd_score(pattern: str, model_path: str, out_path: str) -> None:
|
||||
# the model file is produced locally by `train`; do not load third-party pickles
|
||||
with open(model_path, "rb") as f:
|
||||
bundle = pickle.load(f) # noqa: S301
|
||||
imp, clf = bundle["imputer"], bundle["clf"]
|
||||
n = 0
|
||||
with open(out_path, "w") as out:
|
||||
for r in iter_records(pattern):
|
||||
if "noise" not in r:
|
||||
continue
|
||||
score = float(clf.predict_proba(imp.transform([features_of(r)]))[0, 1])
|
||||
lab = label_of(r)
|
||||
out.write(
|
||||
json.dumps(
|
||||
{
|
||||
"file": r["file"],
|
||||
"sha256": r.get("sha256"),
|
||||
"ai_score": round(score, 4),
|
||||
"metadata_label": lab,
|
||||
}
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
n += 1
|
||||
if n % 5000 == 0:
|
||||
print(f" {n}", flush=True)
|
||||
print(f"scored {n} -> {out_path}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) < 2 or sys.argv[1] not in ("train", "score"):
|
||||
print(__doc__)
|
||||
sys.exit(2)
|
||||
if sys.argv[1] == "train" and len(sys.argv) == 4:
|
||||
cmd_train(sys.argv[2], sys.argv[3])
|
||||
elif sys.argv[1] == "score" and len(sys.argv) == 5:
|
||||
cmd_score(sys.argv[2], sys.argv[3], sys.argv[4])
|
||||
else:
|
||||
print(__doc__)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large.
Load diff
@@ -25,7 +25,7 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
|
||||
_warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*")
|
||||
|
||||
|
||||
__version__ = "0.18.0"
|
||||
__version__ = "0.19.0"
|
||||
|
||||
__all__ = ["__version__", "remove_visible", "visible_provenance"]
|
||||
|
||||
|
||||
@@ -312,6 +312,9 @@ _AI_VENDOR_TOKENS: tuple[tuple[str, str], ...] = (
|
||||
("elevenlabs", "ElevenLabs"),
|
||||
("eleven labs", "ElevenLabs"),
|
||||
("black forest", "Black Forest Labs"),
|
||||
("fal-ai", "fal.ai"),
|
||||
("bria", "Bria"),
|
||||
("apple photos clean up", "Apple"),
|
||||
)
|
||||
|
||||
|
||||
@@ -673,7 +676,15 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
|
||||
watermarks.append("IPTC digitalSourceType (Made with AI)")
|
||||
caveats.append(_IPTC_ONLY_CAVEAT)
|
||||
if platform is None:
|
||||
platform = "Made-with-AI tag (e.g. Meta AI); platform not specified"
|
||||
# Apple Photos Clean Up (Apple Intelligence object removal) marks
|
||||
# the edit with photoshop:Credit / IPTC "Apple Photos Clean Up"
|
||||
# next to compositeWithTrainedAlgorithmicMedia -- corpus-measured
|
||||
# 2026-07-23 (35 files); it was detected but never attributed.
|
||||
platform = (
|
||||
"Apple Photos (Clean Up AI edit)"
|
||||
if b"Apple Photos Clean Up" in head
|
||||
else "Made-with-AI tag (e.g. Meta AI); platform not specified"
|
||||
)
|
||||
|
||||
# ── IPTC 2025.1 AI-disclosure fields (Iptc4xmpExt:AISystemUsed etc.) ─
|
||||
iptc_ai = any(m in head for m in IPTC_AI_FIELD_MARKERS)
|
||||
|
||||
@@ -184,6 +184,20 @@ C2PA_AI_VENDORS: tuple[C2paAiVendor, ...] = (
|
||||
# manifest alone marks AI generation. Verified against the mined retained
|
||||
# corpus, 2026-06-20. ElevenLabs does not use SynthID.
|
||||
C2paAiVendor(b"Eleven Labs", "ElevenLabs", "ElevenLabs", "ElevenLabs"),
|
||||
# fal.ai (generative inference platform, issuer "fal - Features & Labels
|
||||
# Inc." / common name "fal.ai", claim generators like "fal-ai/seedvr",
|
||||
# "fal-ai/gpt-image-2"). Corpus-measured 2026-07-23 on the retained
|
||||
# uploads (17 files): the files carry trainedAlgorithmicMedia, so the
|
||||
# verdict already fired, but the platform stayed unattributed. fal.ai is
|
||||
# a pure generative platform, so ``asserts_ai`` also covers its output
|
||||
# that omits the source-type.
|
||||
C2paAiVendor(b"fal-ai", "fal.ai", "fal.ai", "fal.ai", asserts_ai=True),
|
||||
# Bria AI (bria.ai, generative platform) signs as "Bria Artificial
|
||||
# Intelligence" with a "Bria Ai" claim generator and source type
|
||||
# ``empty`` (NOT trainedAlgorithmicMedia), so a real signed file was
|
||||
# completely missed by identify -- corpus-found 2026-07-23. A pure-AI
|
||||
# vendor with distinctive strings, so ``asserts_ai`` is safe here.
|
||||
C2paAiVendor(b"Bria", "Bria Artificial Intelligence", "Bria AI", "Bria", asserts_ai=True),
|
||||
# Truepic is a C2PA signing authority, not an AI generator: no platform label,
|
||||
# never asserts is_ai (the verdict comes from the digital-source-type).
|
||||
C2paAiVendor(b"Truepic", "Truepic", None, None),
|
||||
@@ -307,6 +321,13 @@ AI_GENERATOR_TOKENS: frozenset[str] = frozenset(
|
||||
"novelai",
|
||||
"reve.com",
|
||||
"aphrodite ai",
|
||||
# Corpus-mined 2026-07-23:
|
||||
# - Apple Photos Clean Up (Apple Intelligence object removal): XMP
|
||||
# photoshop:Credit / IPTC credit value; composite source-type
|
||||
# covered detection, this token covers removal parity (35 files).
|
||||
# - fal-ai: generative-platform generator string (17 files).
|
||||
"apple photos clean up",
|
||||
"fal-ai",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -171,6 +171,23 @@ class TestIdentifyNonPng:
|
||||
assert r.platform == "ElevenLabs"
|
||||
assert not any("SynthID" in w for w in r.watermarks) # ElevenLabs does not use SynthID
|
||||
|
||||
def test_fal_ai_attributed(self, tmp_path: Path):
|
||||
# fal.ai signs as "fal - Features & Labels Inc." with a "fal-ai/<model>"
|
||||
# claim generator; corpus-measured 2026-07-23 (17 files).
|
||||
path = self._c2pa_jpeg(tmp_path, b"fal - Features & Labels Inc. fal-ai/seedvr trainedAlgorithmicMedia")
|
||||
r = identify(path, check_visible=False, check_invisible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.platform == "fal.ai"
|
||||
|
||||
def test_bria_attributed_without_source_type(self, tmp_path: Path):
|
||||
# Bria signs as "Bria Artificial Intelligence" with source type ``empty``
|
||||
# (NO trainedAlgorithmicMedia) -- a pure-generator asserts_ai vendor, so
|
||||
# the issuer/generator strings alone must flag AI. Corpus-found 2026-07-23.
|
||||
path = self._c2pa_jpeg(tmp_path, b"Bria Artificial Intelligence Bria Ai c2pa.created c2pa.edited")
|
||||
r = identify(path, check_visible=False, check_invisible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.platform == "Bria AI"
|
||||
|
||||
def test_stability_ai_issuer_attributed_no_synthid(self, tmp_path: Path):
|
||||
path = self._c2pa_jpeg(tmp_path, b"Stability AI ... trainedAlgorithmicMedia")
|
||||
r = identify(path, check_visible=False)
|
||||
@@ -285,6 +302,20 @@ class TestIdentifyRealSamples:
|
||||
assert r.is_ai_generated is True
|
||||
assert any("IPTC" in w for w in r.watermarks)
|
||||
|
||||
def test_apple_clean_up_attributed(self, tmp_path: Path):
|
||||
# Apple Photos Clean Up (Apple Intelligence object removal) marks the
|
||||
# AI edit via photoshop:Credit next to compositeWithTrainedAlgorithmicMedia
|
||||
# -- it must be attributed, not reported as a generic made-with-AI tag.
|
||||
# Corpus-measured 2026-07-23 (35 files).
|
||||
p = tmp_path / "apple_cleanup.jpg"
|
||||
p.write_bytes(
|
||||
b'\xff\xd8\xff\xe1<x:xmpmeta photoshop:Credit="Apple Photos Clean Up" '
|
||||
b"Iptc4xmpExt:DigitalSourceType=compositeWithTrainedAlgorithmicMedia></x:xmpmeta>\xff\xd9"
|
||||
)
|
||||
r = identify(p, check_visible=False, check_invisible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.platform == "Apple Photos (Clean Up AI edit)"
|
||||
|
||||
def test_flux_bfl_c2pa_png(self):
|
||||
# flux-1.png: real Black Forest Labs FLUX.2 Playground output (signed C2PA).
|
||||
r = identify(SAMPLES_DIR / "flux-1.png", check_visible=False)
|
||||
|
||||
@@ -777,6 +777,23 @@ class TestExifGenerator:
|
||||
remove_ai_metadata(src, out)
|
||||
assert exif_generator(out) is None
|
||||
|
||||
def test_apple_clean_up_removal_parity(self, tmp_path: Path):
|
||||
# The "Apple Photos Clean Up" credit is an AI-edit VALUE under a
|
||||
# non-AI key, so removal must drop it by value too (corpus 2026-07-23).
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
from remove_ai_watermarks.metadata import remove_ai_metadata
|
||||
|
||||
info = PngInfo()
|
||||
info.add_text("Software", "Apple Photos Clean Up")
|
||||
src = tmp_path / "apple.png"
|
||||
Image.new("RGB", (64, 64)).save(src, pnginfo=info)
|
||||
assert exif_generator(src) is not None
|
||||
|
||||
out = tmp_path / "clean.png"
|
||||
remove_ai_metadata(src, out)
|
||||
assert exif_generator(out) is None
|
||||
|
||||
def test_imagedescription_tag_ai_tool_detected(self, tmp_path: Path):
|
||||
# ...and the EXIF ImageDescription field.
|
||||
exif = piexif.dump(
|
||||
|
||||
Reference in new issue
Block a user