mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-10-14 21:40:47 +02:00
Repair image and metadata publication contracts, detection routing, research artifact validation, release verification, and the published agent skill. Refresh the dependency lock and reconcile documentation with verified behavior. Add regression coverage and record the completed audit and corrected VideoSeal measurements. Preserve the existing Accelerate and Lightning versions. Validation: Ruff and Pyright pass; 2358 tests pass with 3 skipped. The maintenance gate exits 2 on the documented Accelerate and Lightning security findings.
851 lines
32 KiB
Python
Executable File
851 lines
32 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Aggregate one or more watermark benchmark JSONL result files.
|
|
|
|
The report keeps detector observations, post-removal observations, fidelity,
|
|
and adapter timing separate. Repeated result files are independent runs.
|
|
Repeated pixels are deduplicated by artifact SHA-256 before case-level counts,
|
|
while raw observation counts remain visible. The first measured adapter call in
|
|
each input file is classified as cold; later calls to that adapter are warm.
|
|
|
|
This is a development-only reader. It calls no detector, model, oracle, or
|
|
provider API and refuses to overwrite an existing report.
|
|
|
|
uv run python scripts/watermark_benchmark_report.py results/*.jsonl \
|
|
--output .local-eval/watermark-benchmark-report.md
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import logging
|
|
import math
|
|
import sys
|
|
from collections import Counter, defaultdict
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Any, Literal, cast
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Mapping, Sequence
|
|
|
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
sys.path.insert(0, str(SCRIPT_DIR))
|
|
|
|
from watermark_benchmark import ( # noqa: E402
|
|
DETECTOR_STATUSES,
|
|
SCHEMA_VERSION,
|
|
parse_strict_json,
|
|
require_nonempty_string,
|
|
require_sha256,
|
|
sha256_file,
|
|
)
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
TimingPhase = Literal["cold", "warm"]
|
|
_TOP_LEVEL_FIELDS = {
|
|
"schema_version",
|
|
"case_id",
|
|
"pair_id",
|
|
"media_type",
|
|
"adapter",
|
|
"arm",
|
|
"state",
|
|
"artifact",
|
|
"reference",
|
|
"transform",
|
|
"run",
|
|
"detection",
|
|
"removal",
|
|
"fidelity",
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ResultRecord:
|
|
"""One minimally validated result row plus its run identity."""
|
|
|
|
source: Path
|
|
line_number: int
|
|
data: dict[str, Any]
|
|
|
|
|
|
def _object(value: object, *, field: str, location: str) -> dict[str, Any]:
|
|
if not isinstance(value, dict):
|
|
raise ValueError(f"{location}: {field} must be an object")
|
|
return cast("dict[str, Any]", value)
|
|
|
|
|
|
def _validate_result(raw: object, *, location: str) -> dict[str, Any]:
|
|
values = _object(raw, field="result", location=location)
|
|
if set(values) != _TOP_LEVEL_FIELDS:
|
|
raise ValueError(f"{location}: result fields do not match benchmark schema v{SCHEMA_VERSION}")
|
|
if values["schema_version"] != SCHEMA_VERSION:
|
|
raise ValueError(f"{location}: unsupported benchmark schema {values['schema_version']!r}")
|
|
for field in ("case_id", "pair_id", "adapter", "arm", "state"):
|
|
require_nonempty_string(values[field], field=field, location=location)
|
|
if values["media_type"] not in ("image", "audio", "video"):
|
|
raise ValueError(f"{location}: media_type must be image, audio, or video")
|
|
|
|
artifact = _object(values["artifact"], field="artifact", location=location)
|
|
require_sha256(artifact.get("sha256"), field="artifact.sha256", location=location)
|
|
require_nonempty_string(artifact.get("source_revision"), field="artifact.source_revision", location=location)
|
|
reference = values["reference"]
|
|
if reference is not None:
|
|
require_sha256(
|
|
_object(reference, field="reference", location=location).get("sha256"),
|
|
field="reference.sha256",
|
|
location=location,
|
|
)
|
|
|
|
transform = _object(values["transform"], field="transform", location=location)
|
|
require_nonempty_string(transform.get("name"), field="transform.name", location=location)
|
|
require_nonempty_string(transform.get("revision"), field="transform.revision", location=location)
|
|
_object(transform.get("parameters"), field="transform.parameters", location=location)
|
|
|
|
run = _object(values["run"], field="run", location=location)
|
|
_object(run.get("repository"), field="run.repository", location=location)
|
|
require_sha256(run.get("kernel_source_sha256"), field="run.kernel_source_sha256", location=location)
|
|
require_sha256(run.get("adapter_source_sha256"), field="run.adapter_source_sha256", location=location)
|
|
|
|
detection = _object(values["detection"], field="detection", location=location)
|
|
status = detection.get("status")
|
|
if status not in DETECTOR_STATUSES:
|
|
raise ValueError(f"{location}: unknown detection status {status!r}")
|
|
elapsed = detection.get("adapter_elapsed_ms")
|
|
invalid_elapsed = (
|
|
not isinstance(elapsed, (int, float)) or isinstance(elapsed, bool) or not math.isfinite(elapsed) or elapsed < 0
|
|
)
|
|
if elapsed is not None and invalid_elapsed:
|
|
raise ValueError(f"{location}: detection.adapter_elapsed_ms must be a finite non-negative number or null")
|
|
_object(values["removal"], field="removal", location=location)
|
|
_object(values["fidelity"], field="fidelity", location=location)
|
|
return values
|
|
|
|
|
|
def load_results(paths: Sequence[Path]) -> list[ResultRecord]:
|
|
"""Read benchmark results in input and line order."""
|
|
if not paths:
|
|
raise ValueError("at least one result file is required")
|
|
resolved = [path.resolve() for path in paths]
|
|
if len(set(resolved)) != len(resolved):
|
|
raise ValueError("duplicate result file")
|
|
|
|
records: list[ResultRecord] = []
|
|
for path in resolved:
|
|
with path.open(encoding="utf-8") as stream:
|
|
for line_number, line in enumerate(stream, 1):
|
|
if not line.strip():
|
|
continue
|
|
location = f"{path}:{line_number}"
|
|
try:
|
|
raw = parse_strict_json(line)
|
|
except (json.JSONDecodeError, ValueError) as exc:
|
|
detail = exc.msg if isinstance(exc, json.JSONDecodeError) else str(exc)
|
|
raise ValueError(f"{location}: invalid JSON: {detail}") from exc
|
|
records.append(
|
|
ResultRecord(
|
|
source=path,
|
|
line_number=line_number,
|
|
data=_validate_result(raw, location=location),
|
|
)
|
|
)
|
|
if not records:
|
|
raise ValueError("result files contain no records")
|
|
return records
|
|
|
|
|
|
def _canonical(value: object) -> str:
|
|
return json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
|
|
|
|
|
def _case_identity(record: ResultRecord) -> tuple[object, ...]:
|
|
row = record.data
|
|
artifact = cast("dict[str, Any]", row["artifact"])
|
|
reference = cast("dict[str, Any] | None", row["reference"])
|
|
detection = cast("dict[str, Any]", row["detection"])
|
|
return (
|
|
row["pair_id"],
|
|
row["media_type"],
|
|
row["adapter"],
|
|
row["arm"],
|
|
row["state"],
|
|
artifact["sha256"],
|
|
artifact["source_revision"],
|
|
reference["sha256"] if reference is not None else None,
|
|
_canonical(row["transform"]),
|
|
detection.get("expected"),
|
|
)
|
|
|
|
|
|
def nearest_rank(ordered: Sequence[float], quantile: float) -> float:
|
|
"""Return a nearest-rank percentile from values sorted in ascending order."""
|
|
if not ordered:
|
|
raise ValueError("cannot calculate a percentile without measurements")
|
|
return ordered[max(0, math.ceil(quantile * len(ordered)) - 1)]
|
|
|
|
|
|
def _timing_stats(adapter: str, phase: TimingPhase, values: list[float]) -> dict[str, Any]:
|
|
ordered = sorted(values)
|
|
return {
|
|
"adapter": adapter,
|
|
"phase": phase,
|
|
"n": len(ordered),
|
|
"p50_ms": nearest_rank(ordered, 0.50),
|
|
"p90_ms": nearest_rank(ordered, 0.90),
|
|
"max_ms": ordered[-1],
|
|
}
|
|
|
|
|
|
def _artifact_status_summary(group: Sequence[ResultRecord]) -> dict[str, int]:
|
|
artifacts: dict[str, set[str]] = defaultdict(set)
|
|
for record in group:
|
|
row = record.data
|
|
artifact = cast("dict[str, Any]", row["artifact"])
|
|
detection = cast("dict[str, Any]", row["detection"])
|
|
artifacts[str(artifact["sha256"])].add(str(detection["status"]))
|
|
counts: dict[str, int] = dict.fromkeys(DETECTOR_STATUSES, 0)
|
|
unstable = 0
|
|
for statuses in artifacts.values():
|
|
if len(statuses) != 1:
|
|
unstable += 1
|
|
continue
|
|
counts[next(iter(statuses))] += 1
|
|
return {
|
|
"unique_artifacts": len(artifacts),
|
|
"observations": len(group),
|
|
**counts,
|
|
"unstable": unstable,
|
|
}
|
|
|
|
|
|
def _detection_groups(records: Sequence[ResultRecord], *, include_transform: bool) -> list[dict[str, Any]]:
|
|
grouped: dict[tuple[str, ...], list[ResultRecord]] = defaultdict(list)
|
|
for record in records:
|
|
row = record.data
|
|
identity = (str(row["adapter"]), str(row["state"]))
|
|
if include_transform:
|
|
transform = cast("dict[str, Any]", row["transform"])
|
|
identity += (str(transform["name"]),)
|
|
grouped[identity].append(record)
|
|
|
|
summaries: list[dict[str, Any]] = []
|
|
for identity, group in sorted(grouped.items()):
|
|
summary: dict[str, Any] = {
|
|
"adapter": identity[0],
|
|
"state": identity[1],
|
|
}
|
|
if include_transform:
|
|
summary["transform"] = identity[2]
|
|
summary.update(_artifact_status_summary(group))
|
|
summaries.append(summary)
|
|
return summaries
|
|
|
|
|
|
def _detection_groups_by_source_field(
|
|
records: Sequence[ResultRecord],
|
|
*,
|
|
field: str,
|
|
) -> list[dict[str, Any]]:
|
|
grouped: dict[tuple[str, str, str], list[ResultRecord]] = defaultdict(list)
|
|
for record in records:
|
|
row = record.data
|
|
transform = cast("dict[str, Any]", row["transform"])
|
|
parameters = cast("dict[str, Any]", transform["parameters"])
|
|
source = parameters.get("source")
|
|
if not isinstance(source, dict):
|
|
continue
|
|
value = cast("dict[str, object]", source).get(field)
|
|
if not isinstance(value, str) or not value:
|
|
continue
|
|
grouped[(str(row["adapter"]), str(row["state"]), value)].append(record)
|
|
|
|
summaries: list[dict[str, Any]] = []
|
|
for (adapter, state, value), group in sorted(grouped.items()):
|
|
summaries.append(
|
|
{
|
|
"adapter": adapter,
|
|
"state": state,
|
|
field: value,
|
|
**_artifact_status_summary(group),
|
|
}
|
|
)
|
|
return summaries
|
|
|
|
|
|
def _watermark_profile(parameters: Mapping[str, Any]) -> str | None:
|
|
scheme = parameters.get("scheme")
|
|
if isinstance(scheme, str) and scheme:
|
|
return scheme
|
|
variant = parameters.get("variant")
|
|
schema = parameters.get("schema")
|
|
if isinstance(variant, str) and variant and isinstance(schema, int) and not isinstance(schema, bool):
|
|
return f"{variant}/schema-{schema}"
|
|
return None
|
|
|
|
|
|
def _detection_groups_by_profile(records: Sequence[ResultRecord]) -> list[dict[str, Any]]:
|
|
grouped: dict[tuple[str, str, str], list[ResultRecord]] = defaultdict(list)
|
|
for record in records:
|
|
row = record.data
|
|
transform = cast("dict[str, Any]", row["transform"])
|
|
parameters = cast("dict[str, Any]", transform["parameters"])
|
|
profile = _watermark_profile(parameters)
|
|
if profile is not None:
|
|
grouped[(str(row["adapter"]), str(row["state"]), profile)].append(record)
|
|
return [
|
|
{
|
|
"adapter": adapter,
|
|
"state": state,
|
|
"profile": profile,
|
|
**_artifact_status_summary(group),
|
|
}
|
|
for (adapter, state, profile), group in sorted(grouped.items())
|
|
]
|
|
|
|
|
|
def _attack_transition_groups(records: Sequence[ResultRecord]) -> list[dict[str, Any]]:
|
|
marked: dict[tuple[str, str], set[str]] = defaultdict(set)
|
|
attacked: dict[tuple[str, str, str], set[str]] = defaultdict(set)
|
|
for record in records:
|
|
row = record.data
|
|
state = str(row["state"])
|
|
if state not in ("marked", "attacked"):
|
|
continue
|
|
adapter = str(row["adapter"])
|
|
pair_id = str(row["pair_id"])
|
|
detection = cast("dict[str, Any]", row["detection"])
|
|
status = str(detection["status"])
|
|
if state == "marked":
|
|
marked[(adapter, pair_id)].add(status)
|
|
else:
|
|
transform = cast("dict[str, Any]", row["transform"])
|
|
attacked[(adapter, pair_id, str(transform["name"]))].add(status)
|
|
|
|
grouped: dict[tuple[str, str], dict[str, int]] = defaultdict(
|
|
lambda: {"retained": 0, "lost": 0, "gained": 0, "still_not_detected": 0, "unmeasured": 0}
|
|
)
|
|
for (adapter, pair_id, transform), attacked_statuses in attacked.items():
|
|
counts = grouped[(adapter, transform)]
|
|
marked_statuses = marked.get((adapter, pair_id), set())
|
|
if len(marked_statuses) != 1 or len(attacked_statuses) != 1:
|
|
counts["unmeasured"] += 1
|
|
continue
|
|
before = next(iter(marked_statuses))
|
|
after = next(iter(attacked_statuses))
|
|
transition = {
|
|
("detected", "detected"): "retained",
|
|
("detected", "not_detected"): "lost",
|
|
("not_detected", "detected"): "gained",
|
|
("not_detected", "not_detected"): "still_not_detected",
|
|
}.get((before, after))
|
|
if transition is None:
|
|
counts["unmeasured"] += 1
|
|
else:
|
|
counts[transition] += 1
|
|
return [
|
|
{"adapter": adapter, "transform": transform, "pairs": sum(counts.values()), **counts}
|
|
for (adapter, transform), counts in sorted(grouped.items())
|
|
]
|
|
|
|
|
|
def _removal_groups(records: Sequence[ResultRecord]) -> list[dict[str, Any]]:
|
|
grouped: dict[str, list[ResultRecord]] = defaultdict(list)
|
|
for record in records:
|
|
if record.data["state"] == "removed":
|
|
grouped[str(record.data["adapter"])].append(record)
|
|
|
|
summaries: list[dict[str, Any]] = []
|
|
for adapter, group in sorted(grouped.items()):
|
|
artifacts: dict[str, set[str]] = defaultdict(set)
|
|
for record in group:
|
|
row = record.data
|
|
removal = cast("dict[str, Any]", row["removal"])
|
|
artifact = cast("dict[str, Any]", row["artifact"])
|
|
artifacts[str(artifact["sha256"])].add(str(removal.get("status")))
|
|
statuses = (
|
|
"signal_detected_after_removal",
|
|
"no_recognized_signal_after_removal",
|
|
"unmeasured",
|
|
)
|
|
counts: dict[str, int] = dict.fromkeys(statuses, 0)
|
|
unstable = 0
|
|
for observed in artifacts.values():
|
|
if len(observed) != 1 or next(iter(observed)) not in counts:
|
|
unstable += 1
|
|
else:
|
|
counts[next(iter(observed))] += 1
|
|
summaries.append(
|
|
{
|
|
"adapter": adapter,
|
|
"unique_artifacts": len(artifacts),
|
|
"observations": len(group),
|
|
**counts,
|
|
"unstable": unstable,
|
|
}
|
|
)
|
|
return summaries
|
|
|
|
|
|
def _fidelity_groups(records: Sequence[ResultRecord]) -> list[dict[str, Any]]:
|
|
grouped: dict[tuple[str, str], list[ResultRecord]] = defaultdict(list)
|
|
for record in records:
|
|
grouped[(str(record.data["adapter"]), str(record.data["state"]))].append(record)
|
|
|
|
summaries: list[dict[str, Any]] = []
|
|
for (adapter, state), group in sorted(grouped.items()):
|
|
pairs: dict[tuple[str, str | None], tuple[str, dict[str, Any]]] = {}
|
|
inconsistent_pairs: set[tuple[str, str | None]] = set()
|
|
for record in group:
|
|
row = record.data
|
|
artifact = cast("dict[str, Any]", row["artifact"])
|
|
reference = cast("dict[str, Any] | None", row["reference"])
|
|
key = (str(artifact["sha256"]), str(reference["sha256"]) if reference is not None else None)
|
|
fidelity = cast("dict[str, Any]", row["fidelity"])
|
|
canonical = _canonical(fidelity)
|
|
previous = pairs.setdefault(key, (canonical, fidelity))
|
|
if previous[0] != canonical:
|
|
inconsistent_pairs.add(key)
|
|
|
|
counts = {"measured": 0, "incomparable": 0, "not_measured": 0, "inconsistent": 0}
|
|
identical = 0
|
|
psnr_values: list[float] = []
|
|
mean_psnr_values: list[float] = []
|
|
snr_values: list[float] = []
|
|
for key, (_, value) in pairs.items():
|
|
if key in inconsistent_pairs:
|
|
counts["inconsistent"] += 1
|
|
continue
|
|
status = str(value.get("status"))
|
|
if status not in ("measured", "incomparable", "not_measured"):
|
|
counts["inconsistent"] += 1
|
|
continue
|
|
counts[status] += 1
|
|
if status == "measured":
|
|
identical += int(value.get("identical") is True)
|
|
psnr = value.get("psnr_db")
|
|
if isinstance(psnr, (int, float)) and not isinstance(psnr, bool) and math.isfinite(psnr):
|
|
psnr_values.append(float(psnr))
|
|
mean_psnr = value.get("mean_psnr_db")
|
|
if isinstance(mean_psnr, (int, float)) and not isinstance(mean_psnr, bool) and math.isfinite(mean_psnr):
|
|
mean_psnr_values.append(float(mean_psnr))
|
|
snr = value.get("snr_db")
|
|
if isinstance(snr, (int, float)) and not isinstance(snr, bool) and math.isfinite(snr):
|
|
snr_values.append(float(snr))
|
|
summaries.append(
|
|
{
|
|
"adapter": adapter,
|
|
"state": state,
|
|
"unique_pairs": len(pairs),
|
|
**counts,
|
|
"identical": identical,
|
|
"finite_psnr": len(psnr_values),
|
|
"p50_psnr_db": nearest_rank(sorted(psnr_values), 0.50) if psnr_values else None,
|
|
"finite_mean_psnr": len(mean_psnr_values),
|
|
"p50_mean_psnr_db": nearest_rank(sorted(mean_psnr_values), 0.50) if mean_psnr_values else None,
|
|
"finite_snr": len(snr_values),
|
|
"p50_snr_db": nearest_rank(sorted(snr_values), 0.50) if snr_values else None,
|
|
}
|
|
)
|
|
return summaries
|
|
|
|
|
|
def summarize(records: Sequence[ResultRecord]) -> dict[str, Any]:
|
|
"""Build a deterministic, artifact-aware summary of benchmark records."""
|
|
if not records:
|
|
raise ValueError("cannot summarize an empty result set")
|
|
|
|
identities: dict[str, tuple[object, ...]] = {}
|
|
case_statuses: dict[tuple[str, str], set[str]] = defaultdict(set)
|
|
mismatches: set[tuple[str, str, str]] = set()
|
|
non_results: set[tuple[str, str, str, str | None]] = set()
|
|
timing_values: dict[tuple[str, TimingPhase], list[float]] = defaultdict(list)
|
|
timed_adapters: set[tuple[Path, str]] = set()
|
|
|
|
for record in records:
|
|
row = record.data
|
|
removal = cast("dict[str, Any]", row["removal"])
|
|
if removal.get("certifies_erasure") is not False:
|
|
raise ValueError(f"{record.source}:{record.line_number}: removal.certifies_erasure must be false")
|
|
case_id = str(row["case_id"])
|
|
identity = _case_identity(record)
|
|
previous = identities.setdefault(case_id, identity)
|
|
if previous != identity:
|
|
raise ValueError(f"case identity drift for {case_id!r} at {record.source}:{record.line_number}")
|
|
|
|
adapter = str(row["adapter"])
|
|
detection = cast("dict[str, Any]", row["detection"])
|
|
status = str(detection["status"])
|
|
case_statuses[(case_id, adapter)].add(status)
|
|
if detection.get("matches_expected") is False:
|
|
mismatches.add((case_id, adapter, status))
|
|
if status in ("error", "unavailable"):
|
|
error = detection.get("error")
|
|
non_results.add((case_id, adapter, status, str(error) if error is not None else None))
|
|
|
|
elapsed = detection.get("adapter_elapsed_ms")
|
|
if isinstance(elapsed, (int, float)) and not isinstance(elapsed, bool):
|
|
timing_key = (record.source, adapter)
|
|
phase: TimingPhase = "warm" if timing_key in timed_adapters else "cold"
|
|
timed_adapters.add(timing_key)
|
|
timing_values[(adapter, phase)].append(float(elapsed))
|
|
|
|
unstable_cases = [
|
|
{"case_id": case_id, "adapter": adapter, "statuses": sorted(statuses)}
|
|
for (case_id, adapter), statuses in sorted(case_statuses.items())
|
|
if len(statuses) > 1
|
|
]
|
|
timing = [_timing_stats(adapter, phase, values) for (adapter, phase), values in sorted(timing_values.items())]
|
|
source_counts = Counter(record.source for record in records)
|
|
inputs = [
|
|
{
|
|
"path": str(source),
|
|
"sha256": sha256_file(source),
|
|
"observations": source_counts[source],
|
|
}
|
|
for source in sorted(source_counts)
|
|
]
|
|
|
|
return {
|
|
"runs": len(source_counts),
|
|
"observations": len(records),
|
|
"unique_cases": len(identities),
|
|
"inputs": inputs,
|
|
"detection": _detection_groups(records, include_transform=False),
|
|
"detection_by_transform": _detection_groups(records, include_transform=True),
|
|
"detection_by_profile": _detection_groups_by_profile(records),
|
|
"attack_transitions": _attack_transition_groups(records),
|
|
"detection_by_source_provider": _detection_groups_by_source_field(records, field="provider"),
|
|
"detection_by_content_stratum": _detection_groups_by_source_field(records, field="content_stratum"),
|
|
"removal": _removal_groups(records),
|
|
"fidelity": _fidelity_groups(records),
|
|
"timing": timing,
|
|
"unstable_cases": unstable_cases,
|
|
"expected_mismatches": [
|
|
{"case_id": case_id, "adapter": adapter, "status": status}
|
|
for case_id, adapter, status in sorted(mismatches)
|
|
],
|
|
"non_results": [
|
|
{"case_id": case_id, "adapter": adapter, "status": status, "error": error}
|
|
for case_id, adapter, status, error in sorted(non_results)
|
|
],
|
|
}
|
|
|
|
|
|
def markdown_cell(value: object, *, missing: str = "-") -> str:
|
|
"""Render one escaped Markdown table cell."""
|
|
if value is None:
|
|
return missing
|
|
if isinstance(value, float):
|
|
return f"{value:.2f}"
|
|
return str(value).replace("|", "\\|").replace("\n", " ")
|
|
|
|
|
|
def markdown_table(
|
|
columns: Sequence[tuple[str, str]],
|
|
rows: Sequence[Mapping[str, Any]],
|
|
*,
|
|
missing: str = "-",
|
|
) -> list[str]:
|
|
"""Render a Markdown table from named columns and mapping rows."""
|
|
header = "| " + " | ".join(label for _, label in columns) + " |"
|
|
separator = "| " + " | ".join("---" for _ in columns) + " |"
|
|
body = [
|
|
"| " + " | ".join(markdown_cell(row.get(key), missing=missing) for key, _ in columns) + " |" for row in rows
|
|
]
|
|
return [header, separator, *body]
|
|
|
|
|
|
def render_markdown(summary: Mapping[str, Any], *, title: str = "Watermark benchmark report") -> str:
|
|
"""Render a standalone report without merging distinct evidence questions."""
|
|
lines = [
|
|
f"# {title}",
|
|
"",
|
|
f"Runs: {summary['runs']}. Observations: {summary['observations']}. Unique cases: {summary['unique_cases']}.",
|
|
"",
|
|
(
|
|
"> `not_detected` means only that the selected adapter did not recognize its signal. "
|
|
"It is not evidence that a watermark is absent. Post-removal rows never certify erasure."
|
|
),
|
|
"",
|
|
"## Detection by state",
|
|
"",
|
|
*markdown_table(
|
|
(
|
|
("adapter", "Adapter"),
|
|
("state", "State"),
|
|
("unique_artifacts", "Unique artifacts"),
|
|
("observations", "Observations"),
|
|
("detected", "Detected"),
|
|
("not_detected", "Not detected"),
|
|
("unavailable", "Unavailable"),
|
|
("error", "Error"),
|
|
("unstable", "Unstable"),
|
|
),
|
|
cast("list[dict[str, Any]]", summary["detection"]),
|
|
),
|
|
"",
|
|
"## Detection by transform",
|
|
"",
|
|
*markdown_table(
|
|
(
|
|
("adapter", "Adapter"),
|
|
("state", "State"),
|
|
("transform", "Transform"),
|
|
("unique_artifacts", "Unique artifacts"),
|
|
("detected", "Detected"),
|
|
("not_detected", "Not detected"),
|
|
("unavailable", "Unavailable"),
|
|
("error", "Error"),
|
|
("unstable", "Unstable"),
|
|
),
|
|
cast("list[dict[str, Any]]", summary["detection_by_transform"]),
|
|
),
|
|
"",
|
|
]
|
|
by_profile = cast("list[dict[str, Any]]", summary["detection_by_profile"])
|
|
if by_profile:
|
|
lines.extend(
|
|
[
|
|
"## Detection by watermark profile",
|
|
"",
|
|
*markdown_table(
|
|
(
|
|
("adapter", "Adapter"),
|
|
("state", "State"),
|
|
("profile", "Profile"),
|
|
("unique_artifacts", "Unique artifacts"),
|
|
("detected", "Detected"),
|
|
("not_detected", "Not detected"),
|
|
("unavailable", "Unavailable"),
|
|
("error", "Error"),
|
|
("unstable", "Unstable"),
|
|
),
|
|
by_profile,
|
|
),
|
|
"",
|
|
]
|
|
)
|
|
attack_transitions = cast("list[dict[str, Any]]", summary["attack_transitions"])
|
|
if attack_transitions:
|
|
lines.extend(
|
|
[
|
|
"## Attack transitions from the matched marked case",
|
|
"",
|
|
*markdown_table(
|
|
(
|
|
("adapter", "Adapter"),
|
|
("transform", "Attack transform"),
|
|
("pairs", "Pairs"),
|
|
("retained", "Detected -> detected"),
|
|
("lost", "Detected -> not detected"),
|
|
("gained", "Not detected -> detected"),
|
|
("still_not_detected", "Not detected -> not detected"),
|
|
("unmeasured", "Unmeasured"),
|
|
),
|
|
attack_transitions,
|
|
),
|
|
"",
|
|
(
|
|
"A gained detection is a threshold-crossing observation after the transform, not evidence "
|
|
"that the attack added a watermark."
|
|
),
|
|
"",
|
|
]
|
|
)
|
|
source_group_columns = (
|
|
("adapter", "Adapter"),
|
|
("state", "State"),
|
|
("unique_artifacts", "Unique artifacts"),
|
|
("detected", "Detected"),
|
|
("not_detected", "Not detected"),
|
|
("unavailable", "Unavailable"),
|
|
("error", "Error"),
|
|
("unstable", "Unstable"),
|
|
)
|
|
by_provider = cast("list[dict[str, Any]]", summary["detection_by_source_provider"])
|
|
if by_provider:
|
|
lines.extend(
|
|
[
|
|
"## Detection by source provider",
|
|
"",
|
|
*markdown_table(
|
|
(*source_group_columns[:2], ("provider", "Provider"), *source_group_columns[2:]), by_provider
|
|
),
|
|
"",
|
|
]
|
|
)
|
|
by_stratum = cast("list[dict[str, Any]]", summary["detection_by_content_stratum"])
|
|
if by_stratum:
|
|
lines.extend(
|
|
[
|
|
"## Detection by content stratum",
|
|
"",
|
|
*markdown_table(
|
|
(*source_group_columns[:2], ("content_stratum", "Content stratum"), *source_group_columns[2:]),
|
|
by_stratum,
|
|
),
|
|
"",
|
|
]
|
|
)
|
|
lines.extend(["## Post-removal observation", ""])
|
|
removal = cast("list[dict[str, Any]]", summary["removal"])
|
|
if removal:
|
|
lines.extend(
|
|
markdown_table(
|
|
(
|
|
("adapter", "Adapter"),
|
|
("unique_artifacts", "Unique artifacts"),
|
|
("signal_detected_after_removal", "Signal remains"),
|
|
("no_recognized_signal_after_removal", "No recognized signal"),
|
|
("unmeasured", "Unmeasured"),
|
|
("unstable", "Unstable"),
|
|
),
|
|
removal,
|
|
)
|
|
)
|
|
else:
|
|
lines.append("No removed-state artifacts were present.")
|
|
|
|
lines.extend(["", "## Adapter timing", ""])
|
|
timing = cast("list[dict[str, Any]]", summary["timing"])
|
|
if timing:
|
|
lines.extend(
|
|
markdown_table(
|
|
(
|
|
("adapter", "Adapter"),
|
|
("phase", "Phase"),
|
|
("n", "n"),
|
|
("p50_ms", "p50 ms"),
|
|
("p90_ms", "p90 ms"),
|
|
("max_ms", "max ms"),
|
|
),
|
|
timing,
|
|
)
|
|
)
|
|
else:
|
|
lines.append("No adapter calls carried timing measurements.")
|
|
|
|
lines.extend(
|
|
[
|
|
"",
|
|
(
|
|
"The first measured call for each adapter in each input file is cold; subsequent measured calls are "
|
|
"warm. Image decode, fidelity calculation, and process startup are outside the interval."
|
|
),
|
|
"",
|
|
"## Fidelity",
|
|
"",
|
|
*markdown_table(
|
|
(
|
|
("adapter", "Adapter"),
|
|
("state", "State"),
|
|
("unique_pairs", "Unique pairs"),
|
|
("measured", "Measured"),
|
|
("identical", "Identical"),
|
|
("incomparable", "Incomparable"),
|
|
("not_measured", "Not measured"),
|
|
("inconsistent", "Inconsistent"),
|
|
("finite_psnr", "Finite PSNR"),
|
|
("p50_psnr_db", "p50 PSNR dB"),
|
|
("finite_mean_psnr", "Finite video mean PSNR"),
|
|
("p50_mean_psnr_db", "p50 video mean PSNR dB"),
|
|
("finite_snr", "Finite SNR"),
|
|
("p50_snr_db", "p50 SNR dB"),
|
|
),
|
|
cast("list[dict[str, Any]]", summary["fidelity"]),
|
|
),
|
|
"",
|
|
"## Stability and non-results",
|
|
"",
|
|
]
|
|
)
|
|
unstable = cast("list[dict[str, Any]]", summary["unstable_cases"])
|
|
mismatches = cast("list[dict[str, Any]]", summary["expected_mismatches"])
|
|
non_results = cast("list[dict[str, Any]]", summary["non_results"])
|
|
lines.append(
|
|
f"Unstable cases: {len(unstable)}. Expected-result mismatches: {len(mismatches)}. "
|
|
f"Errors or unavailable observations: {len(non_results)}."
|
|
)
|
|
if unstable:
|
|
lines.extend(
|
|
[
|
|
"",
|
|
*markdown_table(
|
|
(("case_id", "Unstable case"), ("adapter", "Adapter"), ("statuses", "Statuses")),
|
|
[row | {"statuses": ", ".join(row["statuses"])} for row in unstable],
|
|
),
|
|
]
|
|
)
|
|
if mismatches:
|
|
lines.extend(
|
|
[
|
|
"",
|
|
*markdown_table(
|
|
(("case_id", "Expected-result mismatch"), ("adapter", "Adapter"), ("status", "Observed")),
|
|
mismatches,
|
|
),
|
|
]
|
|
)
|
|
if non_results:
|
|
lines.extend(
|
|
[
|
|
"",
|
|
*markdown_table(
|
|
(
|
|
("case_id", "Non-result case"),
|
|
("adapter", "Adapter"),
|
|
("status", "Status"),
|
|
("error", "Error"),
|
|
),
|
|
non_results,
|
|
),
|
|
]
|
|
)
|
|
|
|
lines.extend(
|
|
[
|
|
"",
|
|
"## Inputs",
|
|
"",
|
|
*markdown_table(
|
|
(("path", "Result file"), ("sha256", "SHA-256"), ("observations", "Observations")),
|
|
cast("list[dict[str, Any]]", summary["inputs"]),
|
|
),
|
|
"",
|
|
]
|
|
)
|
|
return "\n".join(lines)
|
|
|
|
|
|
def write_report(path: Path, report: str) -> None:
|
|
"""Write a complete report once, refusing an existing destination."""
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
with path.open("x", encoding="utf-8") as stream:
|
|
stream.write(report)
|
|
except FileExistsError as exc:
|
|
raise FileExistsError(f"refusing to overwrite existing report: {path}") from exc
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("results", type=Path, nargs="+", help="one or more benchmark result JSONL files")
|
|
parser.add_argument("--output", type=Path, required=True, help="new Markdown report path; never overwritten")
|
|
parser.add_argument("--title", default="Watermark benchmark report")
|
|
args = parser.parse_args()
|
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
|
try:
|
|
records = load_results(args.results)
|
|
summary = summarize(records)
|
|
write_report(args.output, render_markdown(summary, title=args.title))
|
|
except (FileNotFoundError, OSError, ValueError) as exc:
|
|
parser.error(str(exc))
|
|
log.info("Wrote benchmark report for %s observations to %s", summary["observations"], args.output)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|