Files
remove-ai-watermarks/scripts/detection_timing.py
T
Victor KuznetsovandClaude Opus 5 bebff368fc Decide the SynthID proxy in the verdict, where both extractors meet
A full-corpus audit of the record path against the file path found 75 of 48,905
images disagreeing, and 74 were one gap: the SynthID byte scan for containers whose
manifest no parser reaches lived in `get_ai_metadata`, an extractor the record path
does not run. The record silently reported no SynthID for images `identify` flagged.

Moving the scan into `identify_from_evidence` fixes it by construction rather than by
copying the rule into a second extractor -- the same shape `soft_binding` already
uses. Its byte checks mirror `metadata.synthid_source` literally instead of reusing
the broader `has_c2pa` / `c2pa_source_kind` derived above, so the file path's answers
do not move: verdicts over a 4,000-image sample are byte-identical.

`scripts/record_parity_audit.py` is the audit itself, now repeatable. It walks a
dataset, judges every image through both seams with the record round-tripped through
JSON, and reports disagreements by field and by signal. The rule in
`.claude/rules/development.md` says to re-run both sides of this seam after changing
either; this is what to run.

Both timing and audit scripts now put the package's OWN `src` on the path. From a
worktree an editable install resolves to the main checkout, so the audit imported a
different tree than the one under test -- the failure the same rules file warns about,
reproduced within an hour of writing it down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:10:38 -07:00

242 lines
9.5 KiB
Python

"""Per-method wall time for metadata extraction and the verdict built on it.
WHY THIS EXISTS
The detection path is documented by CAPABILITY -- which signals it reads, in what
order, with what confidence -- but not by COST. Batch throughput and the question
"which probe dominates, and does it depend on container or on file size" have never
been measured.
WHAT IT MEASURES
Only the file-backed half: metadata extraction, then the verdict evaluated on the
extracted evidence. The pixel-backed detectors are deliberately out of scope.
COLD pass: one measurement on a file the process has never touched --
``extract_provenance_evidence``. Only the first read of a file is genuinely cold,
so it buys exactly one number, and that is the one a single-shot run pays.
WARM pass, with the filesystem cache now hot:
1. ``extract_provenance_evidence`` as a whole.
2. Its nine components, timed IN THE ORDER the dataclass constructs them and with
the per-file caches cleared once beforehand -- so ``extract_c2pa_info`` carries
the Rust manifest reader and the later ``get_ai_metadata`` sees the same warm
cache it sees in production. Timing them in any other order moves that cost to
a different row and flatters whichever ran second.
3. ``identify_from_evidence``: pure verdict logic, the source is never reopened.
4. ``identify(check_visible=False, check_invisible=False)`` -- extraction plus
verdict as one call, the cross-check that 1 + 3 is the whole metadata path.
Every ``@lru_cache`` in the metadata and C2PA modules is cleared before each timed
unit. Without that the second measurement of a file answers from the memo and
reports a cost of zero -- the caches are keyed on (path, mtime, size) and this
script reads each file several times.
READING IT
Component times do NOT sum to the ``extract_provenance_evidence`` total for free:
they come from a separate cache-cleared run, so the sum is a cross-check. A gap
means a component is missing from the list. Both numbers are written.
DATA SAFETY
Read-only over a local dataset. Writes only to the given output prefix, which
belongs outside the repository.
uv run python scripts/detection_timing.py <dataset> <prefix> --limit 200
"""
from __future__ import annotations
import argparse
import json
import logging
import sys
import time
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Callable, Iterator
# The package's OWN tree, not the repository root: from a worktree, an editable
# install resolves `remove_ai_watermarks` to the MAIN checkout, so a script measuring
# this tree would silently import a different one.
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from remove_ai_watermarks import identify as identify_mod
from remove_ai_watermarks import metadata as metadata_mod
from remove_ai_watermarks._internal import c2pa as c2pa_mod
log = logging.getLogger(__name__)
SUPPORTED = frozenset({".png", ".jpg", ".jpeg", ".webp", ".heic", ".heif", ".avif"})
# Timed in the order ``ProvenanceEvidence`` is constructed in ``identify.py``. The
# report script imports this to label its columns, so the order is the contract.
COMPONENTS: tuple[tuple[str, Callable[[Path], Any]], ...] = (
("c2pa_info", c2pa_mod.extract_c2pa_info),
("ai_metadata", metadata_mod.get_ai_metadata),
("scan_head", lambda p: metadata_mod.scan_head(p, identify_mod._SCAN_BYTES)),
("iptc_ai_system", metadata_mod.iptc_ai_system),
("aigc_label", metadata_mod.aigc_label),
("exif_generator", metadata_mod.exif_generator),
("xai_signature", metadata_mod.xai_signature),
("huggingface_job", metadata_mod.huggingface_job),
("samsung_genai", metadata_mod.samsung_genai),
)
def _cache_clearers() -> tuple[Callable[[], None], ...]:
"""Every per-file memo in the metadata path, found by attribute, not by name list.
A hand-written list silently goes stale the next time a probe gains a cache, and a
stale entry shows up as a suspiciously fast row rather than as an error.
"""
found: list[Callable[[], None]] = []
for module in (metadata_mod, c2pa_mod):
for name in dir(module):
clear = getattr(getattr(module, name, None), "cache_clear", None)
if callable(clear):
found.append(clear)
return tuple(found)
_CLEARERS = _cache_clearers()
def _clear() -> None:
for clear in _CLEARERS:
clear()
def _ms(fn: Callable[[], Any]) -> tuple[float, Any]:
"""Wall time in milliseconds plus the call's result."""
start = time.perf_counter_ns()
value = fn()
return (time.perf_counter_ns() - start) / 1e6, value
def _pixel_geometry(path: Path) -> tuple[str | None, int | None, int | None]:
"""Container format and pixel dimensions from the header alone."""
try:
from PIL import Image
with Image.open(path) as img:
return img.format, img.width, img.height
except Exception: # unreadable or an unsupported container
return None, None, None
def _warm_breakdown(path: Path, row: dict[str, Any]) -> None:
"""Fill ``row`` with the warm-cache per-method breakdown."""
_clear()
row["extract_evidence_ms"], evidence = _ms(lambda: identify_mod.extract_provenance_evidence(path))
_clear()
component_sum = 0.0
for name, fn in COMPONENTS:
elapsed, _ = _ms(lambda fn=fn: fn(path))
row[f"meta_{name}_ms"] = elapsed
component_sum += elapsed
row["meta_components_sum_ms"] = component_sum
row["verdict_from_evidence_ms"], report = _ms(lambda: identify_mod.identify_from_evidence(evidence))
_clear()
row["identify_metadata_only_ms"], full = _ms(
lambda: identify_mod.identify(path, check_visible=False, check_invisible=False)
)
row["scan_bytes"] = len(evidence.scan)
row["has_c2pa"] = bool(evidence.c2pa_info)
row["has_ai_metadata"] = bool(evidence.ai_metadata)
row["is_ai_generated"] = full.is_ai_generated
row["confidence"] = full.confidence
row["platform"] = full.platform
row["signals"] = [signal.name for signal in full.signals]
# The two verdict paths must agree; a mismatch means the breakdown timed a
# different code path than the end-to-end call and the rows are not comparable.
row["verdict_agrees"] = (report.confidence, report.platform) == (full.confidence, full.platform)
def _measure(path: Path) -> dict[str, Any]:
row: dict[str, Any] = {"path": str(path), "ext": path.suffix.lower()}
try:
row["bytes"] = path.stat().st_size
except OSError as exc:
return {**row, "error": f"stat: {exc}"}
# COLD first: this is the only moment the file is untouched by this process.
_clear()
try:
row["cold_extract_evidence_ms"], _ = _ms(lambda: identify_mod.extract_provenance_evidence(path))
except Exception as exc:
return {**row, "error": f"cold extract: {type(exc).__name__}: {exc}"}
row["format"], row["width"], row["height"] = _pixel_geometry(path)
width, height = row["width"], row["height"]
row["megapixels"] = round(width * height / 1e6, 3) if width and height else None
try:
_warm_breakdown(path, row)
except Exception as exc:
row["error"] = f"warm pass: {type(exc).__name__}: {exc}"
return row
def _iter_images(root: Path) -> Iterator[Path]:
for path in sorted(root.rglob("*")):
if path.is_file() and path.suffix.lower() in SUPPORTED:
yield path
def _done_paths(out_path: Path) -> set[str]:
"""Paths already recorded, so a long run resumes instead of restarting."""
if not out_path.exists():
return set()
done: set[str] = set()
with out_path.open(encoding="utf-8") as handle:
for line in handle:
try:
done.add(json.loads(line)["path"])
except (ValueError, KeyError):
continue
return done
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("dataset", type=Path, help="directory of images, scanned recursively")
parser.add_argument("out_prefix", type=Path, help="output prefix; writes <prefix>.jsonl")
parser.add_argument("--limit", type=int, default=0, help="stop after N files (0 = all)")
parser.add_argument("--progress-every", type=int, default=200)
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
out_path = args.out_prefix.with_suffix(".jsonl")
out_path.parent.mkdir(parents=True, exist_ok=True)
done = _done_paths(out_path)
if done:
log.info("resuming: %d files already recorded", len(done))
processed = 0
started = time.monotonic()
with out_path.open("a", encoding="utf-8") as handle:
for path in _iter_images(args.dataset):
if str(path) in done:
continue
row = _measure(path)
handle.write(json.dumps(row, ensure_ascii=False, default=str) + "\n")
handle.flush()
processed += 1
if processed % args.progress_every == 0:
log.info("%d files, %.2f files/s", processed, processed / (time.monotonic() - started))
if args.limit and processed >= args.limit:
break
elapsed = time.monotonic() - started
rate = processed / max(elapsed, 1e-9)
log.info("done: %d files in %.1f s (%.2f files/s) -> %s", processed, elapsed, rate, out_path)
return 0
if __name__ == "__main__":
raise SystemExit(main())