mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-09 23:50:40 +02:00
`collect_metadata_record` returns a JSON-safe record carrying an image's provenance metadata regions -- never its pixels -- and the existing `evidence_from_metadata_record` + `identify_from_evidence` build the verdict from it without opening the file. The contract is equality with `identify(path, metadata only)`, verified over the tracked fixtures and over a local corpus of 3,478 images (every file carrying a rare signal, plus a random slice): zero differences. Three placements defeated earlier drafts and each is now a rule with a test: the `scan_head` buffer is the head CONCATENATED with late metadata, so a structural walk must read the raw head instead; Samsung splits its evidence between a post-EOI trailer and the coded scan; and PIL's info keys must be emitted in the file path's candidate order, since the first token match wins. Also fix a real detection gap found while establishing that equality: a label the decoder can read but a raw byte scan cannot -- a compressed PNG `zTXt` packet, or a WebP XMP chunk past the scan window -- was invisible to `identify`. Eight corpus files carrying a China TC260 AIGC label or an IPTC "Made with AI" tag were reported as no signal at all. `scripts/detection_timing.py` and its report script measure the metadata path per method; they write outside the repository and are read-only over a dataset. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
233 lines
8.5 KiB
Python
233 lines
8.5 KiB
Python
"""Aggregate ``detection_timing.py`` records into per-method, per-segment tables.
|
|
|
|
WHAT IT PRODUCES
|
|
Reading it back: the per-file JSONL is one row per image with a millisecond field
|
|
per method. This collapses it into percentiles per method, then repeats that per
|
|
segment -- container, megapixels, file size, C2PA presence, verdict confidence --
|
|
because a single median hides a path whose cost is carried entirely by one
|
|
container or by the files that actually have a manifest.
|
|
|
|
Writes ``<prefix>_summary.csv`` (long form: segment_kind, segment, method, n, p50,
|
|
p90, p99, mean) and prints a markdown report.
|
|
|
|
Percentiles are computed by nearest-rank on the sorted sample, not interpolated:
|
|
every reported number is a time some real file actually took.
|
|
|
|
DATA SAFETY
|
|
Reads and writes only the given prefix, which belongs outside the repository.
|
|
|
|
uv run python scripts/detection_timing_report.py .local-eval/timing/full
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import csv
|
|
import json
|
|
import math
|
|
import statistics
|
|
import sys
|
|
from collections import defaultdict
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable, Iterator
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
|
|
from detection_timing import COMPONENTS as _TIMED
|
|
|
|
# Taken from the script that WROTE the rows, in its order, so a probe added, removed
|
|
# or reordered there cannot silently leave a column missing or mislabelled here.
|
|
COMPONENTS = tuple(name for name, _ in _TIMED)
|
|
METHODS = (
|
|
("cold_extract_evidence_ms", "extract_provenance_evidence (cold)"),
|
|
("extract_evidence_ms", "extract_provenance_evidence (warm)"),
|
|
*((f"meta_{name}_ms", f" {name}") for name in COMPONENTS),
|
|
("meta_components_sum_ms", " (sum of components)"),
|
|
("verdict_from_evidence_ms", "identify_from_evidence (verdict)"),
|
|
("identify_metadata_only_ms", "identify(metadata only), end to end"),
|
|
)
|
|
# Median-only columns in the per-segment tables; the full percentile set goes to the CSV.
|
|
HEADLINE_LABELS = (
|
|
("extract_evidence_ms", "extract p50"),
|
|
("verdict_from_evidence_ms", "verdict p50"),
|
|
("identify_metadata_only_ms", "end-to-end p50"),
|
|
)
|
|
HEADLINE = tuple(key for key, _ in HEADLINE_LABELS)
|
|
|
|
|
|
def _bucket(value: float | None, edges: tuple[float, ...], labels: tuple[str, ...]) -> str:
|
|
if value is None:
|
|
return "unknown"
|
|
for edge, label in zip(edges, labels, strict=False):
|
|
if value < edge:
|
|
return label
|
|
return labels[-1]
|
|
|
|
|
|
SEGMENTS: tuple[tuple[str, Callable[[dict[str, Any]], str]], ...] = (
|
|
("container", lambda r: str(r.get("format") or "unknown")),
|
|
(
|
|
"megapixels",
|
|
lambda r: _bucket(r.get("megapixels"), (1, 4, 12), ("<1 MP", "1-4 MP", "4-12 MP", ">12 MP")),
|
|
),
|
|
(
|
|
"file size",
|
|
lambda r: _bucket(
|
|
(r["bytes"] / 1e6) if r.get("bytes") is not None else None,
|
|
(1, 5, 20),
|
|
("<1 MB", "1-5 MB", "5-20 MB", ">20 MB"),
|
|
),
|
|
),
|
|
("c2pa", lambda r: "with C2PA" if r.get("has_c2pa") else "no C2PA"),
|
|
("verdict", lambda r: f"confidence={r.get('confidence') or 'n/a'}"),
|
|
)
|
|
|
|
|
|
def _numeric(records: list[dict[str, Any]], key: str) -> list[float]:
|
|
"""The numeric values of ``key``, skipping rows where it is absent or non-numeric.
|
|
|
|
One definition of "counts as a measurement", so the printed table and the CSV can
|
|
never disagree about which rows a method was measured on.
|
|
"""
|
|
return [float(r[key]) for r in records if isinstance(r.get(key), (int, float))]
|
|
|
|
|
|
def _percentile(sorted_values: list[float], q: float) -> float:
|
|
"""Nearest-rank percentile: the reported value is one a real file produced."""
|
|
index = max(0, math.ceil(q * len(sorted_values)) - 1)
|
|
return sorted_values[index]
|
|
|
|
|
|
def _stats(values: list[float]) -> dict[str, float | int]:
|
|
ordered = sorted(values)
|
|
return {
|
|
"n": len(ordered),
|
|
"p50": _percentile(ordered, 0.50),
|
|
"p90": _percentile(ordered, 0.90),
|
|
"p99": _percentile(ordered, 0.99),
|
|
"mean": statistics.fmean(ordered),
|
|
}
|
|
|
|
|
|
def _rows(path: Path) -> Iterator[dict[str, Any]]:
|
|
with path.open(encoding="utf-8") as handle:
|
|
for line in handle:
|
|
try:
|
|
yield json.loads(line)
|
|
except ValueError: # a truncated tail while the run is still writing
|
|
continue
|
|
|
|
|
|
def _table(rows: list[dict[str, Any]], methods: tuple[tuple[str, str], ...]) -> list[str]:
|
|
out = ["| method | n | p50 | p90 | p99 | mean |", "|---|---:|---:|---:|---:|---:|"]
|
|
for key, label in methods:
|
|
values = _numeric(rows, key)
|
|
if not values:
|
|
continue
|
|
s = _stats(values)
|
|
out.append(f"| {label} | {s['n']} | {s['p50']:.3f} | {s['p90']:.3f} | {s['p99']:.3f} | {s['mean']:.3f} |")
|
|
return out
|
|
|
|
|
|
def _correlation(rows: list[dict[str, Any]], x_key: str, y_key: str) -> float | None:
|
|
both = [r for r in rows if isinstance(r.get(x_key), (int, float)) and isinstance(r.get(y_key), (int, float))]
|
|
if len(both) < 3:
|
|
return None
|
|
xs = _numeric(both, x_key)
|
|
ys = _numeric(both, y_key)
|
|
try:
|
|
return statistics.correlation(xs, ys)
|
|
except statistics.StatisticsError: # a constant column has no correlation
|
|
return None
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument("prefix", type=Path, help="prefix given to detection_timing.py")
|
|
args = parser.parse_args()
|
|
|
|
jsonl = args.prefix.with_suffix(".jsonl")
|
|
all_rows = list(_rows(jsonl))
|
|
failed = [r for r in all_rows if "error" in r]
|
|
rows = [r for r in all_rows if "error" not in r]
|
|
if not rows:
|
|
print(f"no usable records in {jsonl}")
|
|
return 1
|
|
|
|
disagreed = [r for r in rows if r.get("verdict_agrees") is False]
|
|
|
|
lines = [
|
|
f"# Detection timing over {len(rows)} images",
|
|
"",
|
|
f"Source: `{jsonl}`. Failed rows: {len(failed)}. Verdict-path mismatches: {len(disagreed)}.",
|
|
"",
|
|
"All times in milliseconds. Percentiles are nearest-rank.",
|
|
"",
|
|
"## Whole corpus",
|
|
"",
|
|
*_table(rows, METHODS),
|
|
"",
|
|
]
|
|
|
|
csv_rows: list[dict[str, Any]] = []
|
|
for key, label in METHODS:
|
|
values = _numeric(rows, key)
|
|
if values:
|
|
csv_rows.append({"segment_kind": "all", "segment": "all", "method": label.strip(), **_stats(values)})
|
|
|
|
for kind, classify in SEGMENTS:
|
|
groups: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
for row in rows:
|
|
groups[classify(row)].append(row)
|
|
lines += [f"## By {kind}", ""]
|
|
header = ["| segment | n | " + " | ".join(label for _, label in HEADLINE_LABELS) + " |"]
|
|
header.append("|---|---:|" + "---:|" * len(HEADLINE))
|
|
lines += header
|
|
for segment, group in sorted(groups.items(), key=lambda item: -len(item[1])):
|
|
cells = []
|
|
for key in HEADLINE:
|
|
values = _numeric(group, key)
|
|
cells.append(f"{_percentile(sorted(values), 0.5):.2f}" if values else "-")
|
|
lines.append(f"| {segment} | {len(group)} | " + " | ".join(cells) + " |")
|
|
for key, label in METHODS:
|
|
values = _numeric(group, key)
|
|
if values:
|
|
csv_rows.append(
|
|
{"segment_kind": kind, "segment": segment, "method": label.strip(), **_stats(values)}
|
|
)
|
|
lines.append("")
|
|
|
|
corr = _correlation(rows, "scan_bytes", "verdict_from_evidence_ms")
|
|
corr_size = _correlation(rows, "bytes", "extract_evidence_ms")
|
|
lines += [
|
|
"## Scaling",
|
|
"",
|
|
f"- verdict time vs scan-buffer size: r = {corr:.3f}" if corr is not None else "- verdict correlation: n/a",
|
|
(
|
|
f"- extraction time vs file size: r = {corr_size:.3f}"
|
|
if corr_size is not None
|
|
else "- extraction correlation: n/a"
|
|
),
|
|
"",
|
|
]
|
|
|
|
summary_csv = args.prefix.with_name(args.prefix.name + "_summary.csv")
|
|
columns = ["segment_kind", "segment", "method", "n", "p50", "p90", "p99", "mean"]
|
|
with summary_csv.open("w", encoding="utf-8", newline="") as handle:
|
|
writer = csv.DictWriter(handle, fieldnames=columns)
|
|
writer.writeheader()
|
|
writer.writerows(csv_rows)
|
|
|
|
report = "\n".join(lines)
|
|
args.prefix.with_name(args.prefix.name + "_report.md").write_text(report + "\n", encoding="utf-8")
|
|
print(report)
|
|
print(f"\nwrote {summary_csv}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|