Compare commits

...
8 Commits
Author SHA1 Message Date
Victor Kuznetsov 11afe68411 chore(release): v0.19.0 2026-07-23 16:20:27 -07:00
Victor Kuznetsov c11181b9b5 Detect and attribute Bria AI, fal.ai, and Apple Photos Clean Up
Corpus-mined vendor gaps (42k-file metadata scan, 2026-07-23):
- Bria AI signs C2PA as "Bria Artificial Intelligence" with source type
  empty (no trainedAlgorithmicMedia), so identify was completely blind;
  registered with asserts_ai like Dreamina.
- fal.ai ("fal - Features & Labels Inc.", fal-ai/<model> generators)
  was detected via the source type but never attributed; registered,
  also asserts_ai as a pure generative platform.
- Apple Photos Clean Up (Apple Intelligence object removal) was detected
  as a generic made-with-AI tag; now attributed as an AI edit via the
  photoshop:Credit marker, and the credit value joins
  AI_GENERATOR_TOKENS so removal strips it in parity.
Each new test was verified red without its fix.
2026-07-23 16:15:48 -07:00
Victor Kuznetsov eb544806f6 Add AI-generation scorer trained on metadata-labeled corpus
scripts/ai_score.py trains a gradient-boosted classifier on labels
derived from the scan_dataset metadata (C2PA AI generators, TC260,
local pipelines vs camera/screenshot/editor output) and scores every
file from pixel and container statistics alone, so metadata-stripped
files still get a score. Temporal holdout on the production corpus:
AUC 0.966 / AP 0.988. CLIP ViT-L/14 was evaluated as an alternative
and lost on both accuracy (0.82) and cost, so the model uses the
structural features the scanner already collects; scoring is CPU-only
and I/O-bound.
2026-07-23 16:01:35 -07:00
Victor Kuznetsov dfe69772cd Polish the scanner after review: shared b64 helper, tri-state pixel mode
Route the pixel layer's base64 through the capped _b64 helper, replace
the pixels/pixels_full booleans with one pixel_mode parameter, record a
pixel skipped marker on oversized files, read EXIF from the already-read
bytes instead of re-reading the file, chunk the noise residual
convolution and replace the mgrid with 1D broadcast in the FFT features
(bit-identical values, ~300 MB less peak memory at 2048px), and fix the
raw-only docstring to account for the derived pixel layer.
2026-07-23 11:54:08 -07:00
Victor Kuznetsov 68fb3dded6 Fix numpy-free metadata mode after the pixel-layer merge
Guard module-level numpy touchpoints so the metadata-only mode imports
and runs without numpy installed (deferred annotations, lazy DCT basis);
pixel modes now exit early with a clear numpy requirement message.
2026-07-23 11:13:13 -07:00
Victor Kuznetsov 47ef60c2a1 Add optional pixel forensics layer to the dataset scanner
--pixels adds aggregate, non-reconstructable pixel statistics (block-DCT
histograms with Benford deviation, high-pass noise stats, FFT band
energies and CFA peaks, ELA stats, gradient and color histograms) plus
per-section timing for pipeline latency planning. --pixels-full adds the
privacy-lifting artifacts behind an explicit flag (perceptual hash,
128px thumbnail, coarse ELA/noise-residual/FFT-phase maps). Expensive
maps are computed once and shared between the scalar and artifact paths
(2.2x speedup vs the first split implementation). numpy is required only
for the pixel modes; the metadata-only mode stays numpy-free.
2026-07-23 11:06:45 -07:00
Victor Kuznetsov a981eb84b1 Make the dataset scanner raw-only and harden format sniffing
Strip all derived output: the library verdict, the XMP edit-trail
parse, and the IJG quality estimate. The script now collects raw bytes
plus mechanical container decodes only, is fully standalone (no
remove_ai_watermarks import), and resumes interrupted runs. Add GIF,
BMP, and TIFF magic bytes to sniff_format (GIF files in the wild were
reported as unknown). Validated on the full 42k-file corpus: 100%
coverage, zero error records.
2026-07-22 20:32:36 -07:00
Victor Kuznetsov df62800bf1 Add standalone dataset metadata/forensics scanner script
Single-file read-only scanner producing one JSONL record per image:
AI verdict via identify (metadata-only, no visible marks), full EXIF/
IPTC/XMP/PNG/JPEG/WebP/ISOBMFF inventories, C2PA store, JPEG encoder
forensics (quant tables, IJG quality, scan script, Huffman, subsampling),
XMP edit-trail fields, hashes/timestamps, macOS download provenance,
Live Photo pairing id. Runs standalone (pillow/piexif/c2pa-python) with
the library optional; supports gzip output, resume after interruption,
and head-only scanning of oversized files.
2026-07-22 17:53:02 -07:00
9 changed files with 1407 additions and 4 deletions

No files matched your search

+1 -1
View File
@@ -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"
+224
View File
@@ -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
+1 -1
View File
@@ -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"]
+12 -1
View File
@@ -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",
}
)
+31
View File
@@ -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)
+17
View File
@@ -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(
Generated
+1 -1
View File
@@ -2759,7 +2759,7 @@ wheels = [
[[package]]
name = "remove-ai-watermarks"
version = "0.18.0"
version = "0.19.0"
source = { editable = "." }
dependencies = [
{ name = "c2pa-python" },