mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-10-14 13:30:55 +02:00
386 lines
15 KiB
Python
386 lines
15 KiB
Python
"""Audit a local image corpus against the library's own ``identify`` detector.
|
|
|
|
Two jobs in one pass:
|
|
|
|
1. **Report** -- run metadata-only ``identify`` over every file and write one CSV
|
|
row per file (verdict, platform, confidence, watermarks, signals, raw metadata
|
|
markers, candidate classes, integrity clashes, and errors). Visible and
|
|
invisible pixel detectors belong to their own corpus audits.
|
|
2. **Gap audit** -- for every ``unknown``-verdict file, scan only its *metadata
|
|
region* (PNG text/eXIf chunks, JPEG APPn segments before SOS, or the file
|
|
head for other containers) for known provenance markers. A marker found there
|
|
on a file the detector calls ``unknown`` is a concrete lib gap: a serialization
|
|
or generator we do not yet parse. Scanning the metadata region -- not the whole
|
|
file -- is deliberate: short tokens collide randomly inside compressed PNG
|
|
``IDAT`` / JPEG scan data, which produced false "xAI/Flux/AIGC" hits when the
|
|
first audit naively scanned the first megabyte.
|
|
|
|
This is how new detector gaps get found (it is what surfaced the JPEG-EXIF
|
|
``{"AIGC":{...}}`` form). Re-run after collecting a fresh evaluation batch.
|
|
|
|
Usage:
|
|
uv run python scripts/corpus_gap_scan.py --corpus .local-eval/originals
|
|
uv run python scripts/corpus_gap_scan.py --corpus .local-eval/originals \\
|
|
--workers 8 --report .local-eval/detector-report.csv
|
|
uv run python scripts/corpus_gap_scan.py --corpus .local-eval/originals \\
|
|
--since 2026-09-01 --report .local-eval/detector-report-weekly.csv
|
|
|
|
Rows stream into ``<report>.progress.jsonl`` and resume by relative path after an
|
|
interruption. A schema version prevents an older checkpoint from silently
|
|
producing blank fields after the report grows. Use a new report name for a new
|
|
code/corpus snapshot, or pass ``--restart`` when intentionally replacing the
|
|
checkpoint. The final CSV is written atomically after every selected row is
|
|
present. C2PA candidates include normalized signer, claim-generator, action,
|
|
source-type, and validation fields for aggregate review without reopening every
|
|
manifest.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import csv
|
|
import itertools
|
|
import json
|
|
import logging
|
|
import os
|
|
from collections import Counter
|
|
from concurrent.futures import FIRST_COMPLETED, Future, ProcessPoolExecutor, wait
|
|
from importlib.metadata import version
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
if TYPE_CHECKING:
|
|
from datetime import date
|
|
|
|
import click
|
|
from _corpus_scan import corpus_day, corpus_files, repair_jsonl_tail
|
|
from _plain_console import Console, Table
|
|
|
|
from remove_ai_watermarks.identify import _metadata_region as identify_metadata_region
|
|
from remove_ai_watermarks.identify import extract_provenance_evidence, identify_from_evidence
|
|
from remove_ai_watermarks.metadata import (
|
|
IPTC_AI_FIELD_MARKERS,
|
|
IPTC_AI_MARKERS,
|
|
aigc_label_from_metadata,
|
|
c2pa_marker_in,
|
|
samsung_genai_in,
|
|
scan_head,
|
|
xai_signature_in_metadata,
|
|
)
|
|
|
|
log = logging.getLogger(__name__)
|
|
console = Console()
|
|
|
|
# Distinctive, multi-byte provenance markers worth flagging when they appear in a
|
|
# file the detector calls `unknown`. Kept long enough that a random collision in a
|
|
# (non-scanned) compressed stream is implausible; the metadata-region restriction
|
|
# below is the primary guard, this list is the second. Group: C2PA/JUMBF infra,
|
|
# AI source-type / labeling schemes, and distinctive generator name strings.
|
|
MARKERS: tuple[bytes, ...] = (
|
|
# AI source-type / labeling schemes that are meaningful by presence.
|
|
b"trainedAlgorithmicMedia",
|
|
b"AISystemUsed",
|
|
b"hf-job-id",
|
|
# Distinctive multi-word generator strings only. Bare single words (Luma,
|
|
# Gemini, Sora, ...) are omitted: they collide with unrelated metadata prose
|
|
# (e.g. "Luma" in Lightroom's EnhanceDenoiseLumaAmount), defeating precision.
|
|
b"Midjourney",
|
|
b"Stable Diffusion",
|
|
b"StableDiffusion",
|
|
b"ComfyUI",
|
|
b"Automatic1111",
|
|
b"DALL-E",
|
|
b"Ideogram AI",
|
|
b"Adobe Firefly",
|
|
b"Black Forest",
|
|
b"volcengine",
|
|
b"Nano Banana",
|
|
b"Stability AI",
|
|
)
|
|
REPORT_FIELDS: tuple[str, ...] = (
|
|
"report_schema_version",
|
|
"path",
|
|
"suffix",
|
|
"lib_version",
|
|
"is_ai",
|
|
"platform",
|
|
"confidence",
|
|
"watermarks",
|
|
"signals",
|
|
"c2pa_issuer",
|
|
"c2pa_claim_generator",
|
|
"c2pa_actions",
|
|
"c2pa_source_type",
|
|
"c2pa_validation",
|
|
"integrity_clashes",
|
|
"markers",
|
|
"candidate_classes",
|
|
"error",
|
|
)
|
|
_REPORT_SCHEMA_VERSION = "2"
|
|
_DISPLAY_CANDIDATE_LIMIT = 50
|
|
_WORKER_BATCH_SIZE = 16
|
|
|
|
|
|
def _base_row(path: str, suffix: str, lib_version: str) -> dict[str, str]:
|
|
row = dict.fromkeys(REPORT_FIELDS, "")
|
|
row.update(report_schema_version=_REPORT_SCHEMA_VERSION, path=path, suffix=suffix, lib_version=lib_version)
|
|
return row
|
|
|
|
|
|
def _row(rep, *, path: str, suffix: str, lib_version: str) -> dict[str, str]: # noqa: ANN001
|
|
return {
|
|
**_base_row(path, suffix, lib_version),
|
|
"is_ai": str(rep.is_ai_generated),
|
|
"platform": rep.platform or "",
|
|
"confidence": rep.confidence,
|
|
"watermarks": "|".join(rep.watermarks),
|
|
"signals": "|".join(s.name for s in rep.signals),
|
|
"integrity_clashes": "|".join(rep.integrity_clashes),
|
|
}
|
|
|
|
|
|
def _marker_hits(region: bytes) -> list[str]:
|
|
"""Return high-precision marker labels found in one metadata region."""
|
|
folded = region.lower()
|
|
hits = {marker.decode("latin-1", "replace") for marker in MARKERS if marker.lower() in folded}
|
|
if c2pa_marker_in(region):
|
|
hits.add("C2PA/JUMBF")
|
|
if aigc_label_from_metadata(region) is not None:
|
|
hits.add("TC260 AIGC")
|
|
if any(marker in region for marker in IPTC_AI_MARKERS + IPTC_AI_FIELD_MARKERS):
|
|
hits.add("IPTC AI disclosure")
|
|
if samsung_genai_in(region) is not None:
|
|
hits.add("Samsung genAIType")
|
|
if xai_signature_in_metadata(region):
|
|
hits.add("xAI signature pair")
|
|
return sorted(hits)
|
|
|
|
|
|
def _marker_hits_for_path(path: Path) -> list[str]:
|
|
"""Return bounded metadata hits without turning an unreadable row into a fatal scan."""
|
|
try:
|
|
return _marker_hits(identify_metadata_region(scan_head(path)))
|
|
except OSError:
|
|
return []
|
|
|
|
|
|
def _candidate_classes(rep: Any, hits: list[str]) -> list[str]:
|
|
"""Classify review-worthy outcomes without treating them as proven bugs."""
|
|
classes: list[str] = []
|
|
if hits and not rep.is_ai_generated and not rep.signals:
|
|
classes.append("blind_marker")
|
|
if rep.is_ai_generated and not rep.platform:
|
|
classes.append("unattributed_ai")
|
|
elif rep.signals and not rep.platform:
|
|
classes.append("unattributed_signal")
|
|
if rep.integrity_clashes:
|
|
classes.append("integrity_clash")
|
|
return classes
|
|
|
|
|
|
def _day_of(path: Path, corpus: Path) -> date | None:
|
|
"""Read the leading YYYY-MM-DD corpus segment, if this layout has one."""
|
|
return corpus_day(path, corpus)
|
|
|
|
|
|
def _files(corpus: Path, since: date | None) -> list[Path]:
|
|
"""Return the deterministic corpus walk, optionally bounded by its date segment."""
|
|
return corpus_files(corpus, since)
|
|
|
|
|
|
def _scan_one(args: tuple[str, str, str]) -> dict[str, str]:
|
|
"""Scan one path in a worker process and always return a report row."""
|
|
path_str, relative_path, lib_version = args
|
|
path = Path(path_str)
|
|
suffix = path.suffix.lower()
|
|
try:
|
|
evidence = extract_provenance_evidence(path)
|
|
rep = identify_from_evidence(evidence)
|
|
except Exception as exc:
|
|
log.warning("identify failed on %s: %s", relative_path, exc)
|
|
row = _base_row(relative_path, suffix, lib_version)
|
|
row["candidate_classes"] = "identify_error"
|
|
row["error"] = f"{type(exc).__name__}: {exc}"[:300].replace("\n", " ")
|
|
else:
|
|
row = _row(rep, path=relative_path, suffix=suffix, lib_version=lib_version)
|
|
c2pa = evidence.c2pa_info
|
|
row.update(
|
|
c2pa_issuer=str(c2pa.get("issuer", "")),
|
|
c2pa_claim_generator=str(c2pa.get("claim_generator", "")),
|
|
c2pa_actions=str(c2pa.get("actions", "")),
|
|
c2pa_source_type=str(c2pa.get("source_type", "")),
|
|
c2pa_validation=str(c2pa.get("c2pa_validation_state", "")),
|
|
)
|
|
hits = _marker_hits(identify_metadata_region(evidence.scan))
|
|
row["markers"] = "|".join(hits)
|
|
row["candidate_classes"] = "|".join(_candidate_classes(rep, hits))
|
|
return row
|
|
row["markers"] = "|".join(_marker_hits_for_path(path))
|
|
return row
|
|
|
|
|
|
def _scan_batch(args: tuple[tuple[str, str, str], ...]) -> list[dict[str, str]]:
|
|
"""Scan a small path batch to amortize process-pool dispatch overhead."""
|
|
return [_scan_one(item) for item in args]
|
|
|
|
|
|
def _read_checkpoint(path: Path) -> dict[str, dict[str, str]]:
|
|
"""Read complete JSONL rows, tolerating an interrupted final write."""
|
|
rows: dict[str, dict[str, str]] = {}
|
|
if not path.exists():
|
|
return rows
|
|
with path.open(encoding="utf-8") as stream:
|
|
for line in stream:
|
|
try:
|
|
row = json.loads(line)
|
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
continue
|
|
if (
|
|
isinstance(row, dict)
|
|
and row.get("report_schema_version") == _REPORT_SCHEMA_VERSION
|
|
and isinstance(row.get("path"), str)
|
|
):
|
|
rows[row["path"]] = {field: str(row.get(field, "")) for field in REPORT_FIELDS}
|
|
return rows
|
|
|
|
|
|
def _repair_checkpoint(path: Path) -> None:
|
|
"""Remove an interrupted final JSONL fragment before another append."""
|
|
repair_jsonl_tail(path)
|
|
|
|
|
|
def _summarize(rows: list[dict[str, str]]) -> None:
|
|
"""Print bounded aggregate output for a completed or resumed report."""
|
|
verdicts: Counter[str] = Counter()
|
|
platforms: Counter[str] = Counter()
|
|
shapes: Counter[str] = Counter()
|
|
errors = 0
|
|
for row in rows:
|
|
shapes[row["suffix"] or "(none)"] += 1
|
|
if row["error"]:
|
|
errors += 1
|
|
elif row["is_ai"] == "True":
|
|
verdicts["ai"] += 1
|
|
platforms[row["platform"] or "?"] += 1
|
|
else:
|
|
verdicts["unknown"] += 1
|
|
|
|
console.print(f"\n[bold]Verdicts:[/bold] AI {verdicts['ai']} | unknown {verdicts['unknown']} | errors {errors}")
|
|
console.print("[bold]Input shapes:[/bold] " + " | ".join(f"{name} {n}" for name, n in shapes.most_common()))
|
|
plat = Table(title="AI platforms", show_header=False)
|
|
for name, count in platforms.most_common():
|
|
plat.add_row(str(count), name)
|
|
console.print(plat)
|
|
|
|
candidates = [row for row in rows if row["candidate_classes"]]
|
|
if not candidates:
|
|
console.print("\n[green]No review candidates in this run.[/green]")
|
|
return
|
|
candidate_counts = Counter(candidate for row in candidates for candidate in row["candidate_classes"].split("|"))
|
|
gap_tokens = Counter(marker for row in rows for marker in row["markers"].split("|") if marker)
|
|
console.print(
|
|
f"\n[bold red]Review candidates[/bold red]: {len(candidates)} file(s) "
|
|
f"({', '.join(f'{name}={n}' for name, n in candidate_counts.most_common())})"
|
|
)
|
|
tok = Table(title="metadata markers seen across the run")
|
|
tok.add_column("count", justify="right")
|
|
tok.add_column("marker")
|
|
for name, count in gap_tokens.most_common():
|
|
tok.add_row(str(count), name)
|
|
console.print(tok)
|
|
for row in candidates[:_DISPLAY_CANDIDATE_LIMIT]:
|
|
detail = f"; markers={row['markers'].replace('|', ', ')}" if row["markers"] else ""
|
|
console.print(f" {row['path']} -> {row['candidate_classes'].replace('|', ', ')}{detail}")
|
|
if (hidden := len(candidates) - _DISPLAY_CANDIDATE_LIMIT) > 0:
|
|
console.print(f" ... {hidden} more candidate row(s); inspect the CSV for the complete set")
|
|
|
|
|
|
@click.command()
|
|
@click.option(
|
|
"--corpus",
|
|
type=click.Path(exists=True, file_okay=False, path_type=Path),
|
|
default=Path(".local-eval/originals"),
|
|
show_default=True,
|
|
help="Directory of images to scan (recursively).",
|
|
)
|
|
@click.option(
|
|
"--report",
|
|
type=click.Path(path_type=Path),
|
|
default=None,
|
|
help="Write the per-file CSV here (default: <corpus>/../detector_report.csv).",
|
|
)
|
|
@click.option(
|
|
"--since",
|
|
type=click.DateTime(formats=["%Y-%m-%d"]),
|
|
default=None,
|
|
help="Only scan files under YYYY-MM-DD corpus directories on or after this date.",
|
|
)
|
|
@click.option("--limit", type=int, default=0, help="Scan at most N files (0 = all).")
|
|
@click.option("--workers", type=click.IntRange(min=1), default=max(1, (os.cpu_count() or 4) - 2), show_default=True)
|
|
@click.option("--restart", is_flag=True, help="Discard this report's checkpoint and scan every selected file again.")
|
|
def main(corpus: Path, report: Path | None, since, limit: int, workers: int, restart: bool) -> None: # noqa: ANN001
|
|
logging.basicConfig(level=logging.WARNING, format="%(message)s")
|
|
report = report or corpus.parent / "detector_report.csv"
|
|
|
|
since_date = since.date() if since is not None else None
|
|
files = _files(corpus, since_date)
|
|
if limit:
|
|
files = files[:limit]
|
|
checkpoint = report.with_suffix(report.suffix + ".progress.jsonl")
|
|
if restart:
|
|
checkpoint.unlink(missing_ok=True)
|
|
report.unlink(missing_ok=True)
|
|
_repair_checkpoint(checkpoint)
|
|
lib_version = version("remove-ai-watermarks")
|
|
selected = [(path, str(path.relative_to(corpus))) for path in files]
|
|
selected_paths = {relative for _, relative in selected}
|
|
completed = {relative: row for relative, row in _read_checkpoint(checkpoint).items() if relative in selected_paths}
|
|
todo = [(path, relative) for path, relative in selected if relative not in completed]
|
|
console.print(
|
|
f"Scanning [bold]{len(files)}[/bold] files under {corpus}: "
|
|
f"[bold]{len(completed)}[/bold] checkpointed, [bold]{len(todo)}[/bold] remaining, workers {workers}"
|
|
)
|
|
|
|
checkpoint.parent.mkdir(parents=True, exist_ok=True)
|
|
with checkpoint.open("a", encoding="utf-8") as stream, ProcessPoolExecutor(max_workers=workers) as executor:
|
|
work = iter((str(path), relative, lib_version) for path, relative in todo)
|
|
|
|
def submit_batch() -> Future[list[dict[str, str]]] | None:
|
|
batch = tuple(itertools.islice(work, _WORKER_BATCH_SIZE))
|
|
return executor.submit(_scan_batch, batch) if batch else None
|
|
|
|
pending = {future for _ in range(workers * 2) if (future := submit_batch()) is not None}
|
|
processed = 0
|
|
next_progress = 500
|
|
while pending:
|
|
ready, pending = wait(pending, return_when=FIRST_COMPLETED)
|
|
for future in ready:
|
|
for row in future.result():
|
|
completed[row["path"]] = row
|
|
stream.write(json.dumps(row, ensure_ascii=False) + "\n")
|
|
processed += 1
|
|
if replacement := submit_batch():
|
|
pending.add(replacement)
|
|
stream.flush()
|
|
if processed >= next_progress:
|
|
console.print(
|
|
f" {processed}/{len(todo)} new; {len(completed)}/{len(files)} total",
|
|
highlight=False,
|
|
)
|
|
next_progress += 500
|
|
|
|
rows = [completed[relative] for _, relative in selected]
|
|
report.parent.mkdir(parents=True, exist_ok=True)
|
|
temporary_report = report.with_suffix(report.suffix + ".tmp")
|
|
with temporary_report.open("w", newline="") as stream:
|
|
writer = csv.DictWriter(stream, fieldnames=REPORT_FIELDS)
|
|
writer.writeheader()
|
|
writer.writerows(rows)
|
|
temporary_report.replace(report)
|
|
console.print(f"\nWrote [bold]{len(rows)}[/bold] rows -> {report}")
|
|
_summarize(rows)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|