Complete corpus signal discovery fixes

This commit is contained in:
Victor Kuznetsov committed 2026-09-13 16:54:58 -07:00
1 parent c4edc2820d
commit bd14f40c7d
14 files changed
+627 -194

No files matched your search

+4 -1
View File
@@ -58,7 +58,10 @@ finding.
3. Run the applicable fresh and historical discovery modes from the reference.
Use `corpus_gap_scan.py --since YYYY-MM-DD` for the overlapping fresh window
and omit `--since` for the complete history. Report counts per input shape
and per outcome; an example does not establish coverage.
and per outcome; an example does not establish coverage. Give every code and
corpus snapshot a distinct `--report` path. The adjacent
`.progress.jsonl` checkpoint is the live progress source and makes the same
command resumable; use `--restart` only when intentionally discarding it.
4. Triage every candidate into one of: parser/serialization gap, missing vendor
attribution, new visible mark, possible invisible signal, format/read error,
expected non-AI provenance, or false candidate. Preserve unresolved items as
+3 -2
View File
@@ -92,8 +92,9 @@ The inspection and stripping code handles signals in these groups:
MP4/MOV `moov.udta.meta.keys/ilst`, MKV/WebM
`Segment.Tags.Tag.SimpleTag`, AVI `LIST/INFO/AIGC`, and FLV
`script.onMetaData.AIGC` placements;
- xAI and Grok EXIF signature fields in ordinary EXIF and ImageMagick PNG raw
EXIF profiles;
- xAI and Grok signature-plus-UUID pairs in ordinary EXIF, ImageMagick PNG raw
EXIF profiles, XMP description/creator fields, PNG Description/Author text,
and IPTC Caption-Abstract/By-line fields;
- Samsung AI editing markers;
- Hugging Face job metadata;
- open Stable Diffusion style DWT-DCT watermarks with the `detect` extra;
+3
View File
@@ -498,6 +498,9 @@ the metadata-serialization probe inside that workflow. It now records raw
markers, input shapes, identify errors, integrity clashes, unattributed results,
and blind-marker candidates, but it does not cover visible marks, proprietary
invisible marks, detection/removal parity, or historical crossfire.
The scan runs files in parallel and checkpoints each completed row beside the
requested CSV as `.progress.jsonl`; repeat the same command to resume an
interrupted run, and use a distinct report path for every code/corpus snapshot.
Run a fresh-delta discovery review weekly after the retained corpus is
refreshed, using `corpus_gap_scan.py --since` with an overlap from the preceding
+10 -10
View File
@@ -42,20 +42,20 @@ routes, not a claim that every Krea or Riverflow export is metadata-free.
**ByteDance Volcano Engine (Volcengine)** — the cloud behind Doubao / Jimeng — signs its AI image output with a cert from `certificate_center@volcengine.com` + `trainedAlgorithmicMedia` (issuer `b"volcengine"` → "ByteDance (Volcano Engine)", platform "ByteDance Volcano Engine"); note this is the C2PA-signed surface, distinct from the XMP/PNG TC260 `AIGC` label Doubao also uses. ByteDance's **international brand (BytePlus / Seedream / Seededit)** signs the same content as **"Byteplus Pte. Ltd."**. The bare `volcengine` needle missed it, so BytePlus output was mis-attributed to "Adobe Firefly" through an incidental "Adobe XMP" toolkit string. Issuer `b"Byteplus"` maps directly to "BytePlus (ByteDance)". ByteDance's consumer app **Dreamina** (the international Jimeng brand) signs as **"Bytedance Pte. Ltd."** with a `Dreamina/x.y` claim generator but, unlike the Volcano Engine surface, ships **no `trainedAlgorithmicMedia`**. Issuer `b"Dreamina"` maps to "ByteDance Dreamina" with **`asserts_ai=True`**. Registering the broader **issuer** `b"Bytedance Pte"` was deliberately avoided because that same entity also signs non-AI CapCut edits; keying on the `Dreamina` generator token is precise.
- **EXIF/XMP/PNG-text generator tag (caught by `exif_generator`):** **Ideogram** writes EXIF `Make="Ideogram AI"` (collected 2026-05-24 — no C2PA, no SynthID, no imwatermark; the Make tag is the only signal). Additional verified generator stamps include **NovelAI** (`Software`, `Source`, and `Title` PNG text chunks), **Reve** (`Software` or XMP `CreatorTool` = `reve.com`), and **Aphrodite AI** (`Make` or `Software` = `Aphrodite AI`). A JSON `generationParams.modelName` value is parsed structurally and reduced to a recognized model name, rather than exposing its surrounding prompt JSON as a platform label.
- **App-export provenance and AIGC JSON:** supported ByteDance-family exports can place a JSON object in EXIF `ImageDescription` or `UserComment`, independently of C2PA or TC260. Exact `product` values for Doubao, Xinghui, and Dreamina are removable product provenance, but do not alone prove that the pixels were generated. Dreamina additionally requires `exportType=generation` for that verdict. A nested Aweme `aigc_type=1` or private ByteDance `aigc_label_type=1` / `2` is an AIGC disclosure; `0` is inconclusive and can occur on a Dreamina generation export. Plain Aweme, retouch, and `lv` exports are preserved. The lower-case private field is deliberately not interpreted as the normative TC260 `Label`, whose values `1` / `2` / `3` mean generated / possibly generated / suspected generated under [GB 45438-2025](https://www.tc260.org.cn/upload/2025-03-15/1742009439794081593.pdf).
- **xAI / Grok legacy JPEG EXIF signature scheme (DETECTED by `metadata.xai_signature`, built 2026-05-26).**
- **xAI / Grok signature scheme (DETECTED by `metadata.xai_signature`, built 2026-05-26, expanded 2026-09-13).**
The sampled Grok JPEG downloads from the Aurora route carry **no C2PA, no XMP, no SynthID, no IPTC** — only EXIF `Artist` = a UUID and EXIF `ImageDescription` = `Signature: <base64>` (a crypto signature, unverifiable locally without xAI's public key). This is route-specific, not a product-wide no-C2PA claim: newer Grok Imagine output can carry a valid AI-source C2PA claim whose `claim_generator` is `Grok Imagine`. `exif_generator` misses the legacy pair (neither field holds an `AI_GENERATOR_TOKENS` token), so a dedicated detector `xai_signature(path)` matches it (`ImageDescription ~ ^Signature: [A-Za-z0-9+/=]{64,}` AND UUID `Artist`); wired into `has_ai_metadata`, `get_ai_metadata` (key `xai_signature`), and `identify` (signal `xai_signature`, platform "xAI (Grok / Aurora)").
The sampled Grok JPEG downloads from the Aurora route carry **no C2PA, no XMP, no SynthID, no IPTC** — only EXIF `Artist` = a UUID and EXIF `ImageDescription` = `Signature: <base64>` (a crypto signature, unverifiable locally without xAI's public key). This is route-specific, not a product-wide no-C2PA claim: newer Grok Imagine output can carry a valid AI-source C2PA claim whose `claim_generator` is `Grok Imagine`. `exif_generator` misses the legacy pair (neither field holds an `AI_GENERATOR_TOKENS` token), so a dedicated detector `xai_signature(path)` requires both the long signature shape and a UUID; it is wired into `has_ai_metadata`, `get_ai_metadata` (key `xai_signature`), and `identify` (signal `xai_signature`, platform "xAI (Grok / Aurora)").
Metadata-preserving transcodes can move the same two values out of EXIF. Confirmed equivalents are XMP `dc:description` + `dc:creator`, PNG `Description` + `Author`, and IPTC `Caption-Abstract` + `By-line`. Detection requires one of those exact field pairs and keeps XMP values within the same RDF description, so an unrelated document UUID cannot complete a signature-shaped caption. A signature-shaped caption without its paired creator UUID, or a UUID without the signature blob, remains ordinary metadata.
**Format confirmed stable across n=3 genuine generations:** exactly three EXIF tags (`Artist`, `ExifOffset`, `ImageDescription`), `Signature:` prefix constant, base64 payload 300-1004 chars. Two capture facts: (a) the `Artist` UUID **equals the public image id** in the asset URL (`https://imagine-public.x.ai/imagine-public/images/<uuid>.jpg`), so it is NOT a private per-user secret — only the `Signature` blob is; (b) the Grok web-UI image is a re-encoded **WebP with no signature** — the EXIF survives only in the *original* JPEG (download button or that public tokenless URL), which is why screenshots / re-encodes are metadata-stripped. A real fixture `data/fixtures/provenance/grok-1.jpg` plus **synthetic** JPEG fixtures (fake UUID + fake `Signature:` blob) cover the detector; never add a real Grok image carrying private content (the repo is public).
**Stripped on removal too:** `remove_ai_metadata` calls `_scrub_ai_exif` on
JPEG EXIF, which deletes the xAI Signature and UUID Artist pair plus supported
AI generator values while retaining unrelated camera and editor EXIF. The
shared `xai_signature_pair` helper is the single source of truth for the
pair. Converted PNGs can retain the same pair in an ImageMagick raw EXIF
profile; Pillow exposes that form through `getexif()` even when `info["exif"]`
is absent, so detection, removal, and portable records share that fallback. On
the ISOBMFF path, `blank_ai_exif_tokens` provides the corresponding
**Stripped on removal too:** `remove_ai_metadata` deletes both members of the
pair from EXIF, XMP, PNG text, or IPTC while retaining unrelated camera and
editor metadata. Converted PNGs can retain the original pair in an ImageMagick
raw EXIF profile; Pillow exposes that form through `getexif()` even when
`info["exif"]` is absent, so detection, removal, and portable records share that
fallback. On the ISOBMFF path, `blank_ai_exif_tokens` provides the corresponding
in-place scrub for supported EXIF values, TC260 AIGC blocks, and the xAI pair.
- **China TC260 AIGC label (caught by `AIGC_MARKERS` / `metadata.aigc_label`, surfaced by `identify` as the `aigc` signal):** China-served generators embed an XMP `<TC260:AIGC>{"Label":"1","ContentProducer":...}` block — China's mandatory AI-content labeling (TC260 namespace `tc260.org.cn/ns/AIGC`). The label says only "this is AI", but its `ContentProducer` names the signing entity — `001` + `1` + an 18-char Unified Social Credit Code + a 5-digit product suffix, normalized by `metadata.uscc_of`, or for a few generators a bare product name. `KnownMark.tc260_producer_codes` maps the codes settled per vendor by `scripts/vendor_cohort_harvest.py` to registry mark keys, so an AIGC image relaxes only the detector of the product it actually carries; an unmapped or absent producer leaves every product strict. The same mapping supplies the manufacturer for provenance-conflict checks. A code identifies a legal entity, not necessarily one brand, so a hosting or aggregating platform that signs for several apps is a recall bet rather than a proof.
+1 -1
View File
@@ -4,7 +4,7 @@
# on it, including the ComfyUI node package. The console script below carries
# the same weight, since users have it on PATH.
name = "remove-ai-watermarks"
version = "0.40.1"
version = "0.40.2"
description = "AI watermark remover for visible, invisible, and provenance marks in images and video"
readme = "README.md"
requires-python = ">=3.11,<3.15"
+167 -75
View File
@@ -2,9 +2,10 @@
Two jobs in one pass:
1. **Report** -- run ``identify`` over every image and write one CSV row per file
(verdict, platform, confidence, watermarks, signals, raw metadata markers,
candidate classes, integrity clashes, and errors).
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
@@ -20,17 +21,25 @@ This is how new detector gaps get found (it is what surfaced the JPEG-EXIF
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 \\
--report .local-eval/detector-report.csv
--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. 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.
"""
from __future__ import annotations
import csv
import itertools
import json
import logging
import re
import os
from collections import Counter
from concurrent.futures import FIRST_COMPLETED, Future, ProcessPoolExecutor, wait
from datetime import date
from importlib.metadata import version
from pathlib import Path
@@ -48,6 +57,7 @@ from remove_ai_watermarks.metadata import (
c2pa_marker_in,
samsung_genai_in,
scan_head,
xai_signature_in_metadata,
)
log = logging.getLogger(__name__)
@@ -79,9 +89,6 @@ MARKERS: tuple[bytes, ...] = (
b"Nano Banana",
b"Stability AI",
)
_XAI_SIGNATURE_RE = re.compile(rb"Signature:\s*[A-Za-z0-9+/=]{64,}")
_UUID_RE = re.compile(rb"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", re.IGNORECASE)
REPORT_FIELDS: tuple[str, ...] = (
"path",
"suffix",
@@ -96,6 +103,8 @@ REPORT_FIELDS: tuple[str, ...] = (
"candidate_classes",
"error",
)
_DISPLAY_CANDIDATE_LIMIT = 50
_WORKER_BATCH_SIZE = 16
def _base_row(path: str, suffix: str, lib_version: str) -> dict[str, str]:
@@ -128,7 +137,7 @@ def _marker_hits(region: bytes) -> list[str]:
hits.add("IPTC AI disclosure")
if samsung_genai_in(region) is not None:
hits.add("Samsung genAIType")
if _XAI_SIGNATURE_RE.search(region) and _UUID_RE.search(region):
if xai_signature_in_metadata(region):
hits.add("xAI signature pair")
return sorted(hits)
@@ -176,6 +185,106 @@ def _files(corpus: Path, since: date | None) -> list[Path]:
return sorted(path for root in roots for path in root.rglob("*") if path.is_file())
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:
rep = identify(path, check_visible=False, check_invisible=False)
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)
hits = _marker_hits_for_path(path)
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 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."""
if not path.exists():
return
with path.open("rb+") as stream:
data = stream.read()
if data and not data.endswith(b"\n"):
last_newline = data.rfind(b"\n")
stream.truncate(last_newline + 1)
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",
@@ -197,7 +306,9 @@ def _files(corpus: Path, since: date | None) -> list[Path]:
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).")
def main(corpus: Path, report: Path | None, since, limit: int) -> None: # noqa: ANN001
@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"
@@ -205,78 +316,59 @@ def main(corpus: Path, report: Path | None, since, limit: int) -> None: # noqa:
files = _files(corpus, since_date)
if limit:
files = files[:limit]
console.print(f"Scanning [bold]{len(files)}[/bold] files under {corpus} ...")
verdicts: Counter[str] = Counter()
platforms: Counter[str] = Counter()
shapes: Counter[str] = Counter()
rows: list[dict[str, str]] = []
errors = 0
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}"
)
with click.progressbar(files, label="identify") as bar:
for p in bar:
rel = str(p.relative_to(corpus))
shapes[p.suffix.lower() or "(none)"] += 1
try:
rep = identify(p)
except Exception as exc:
log.warning("identify failed on %s: %s", rel, exc)
errors += 1
row = _base_row(rel, p.suffix.lower(), lib_version)
row["candidate_classes"] = "identify_error"
row["error"] = f"{type(exc).__name__}: {exc}"[:300]
hits = _marker_hits_for_path(p)
row["markers"] = "|".join(hits)
rows.append(row)
continue
row = _row(rep, path=rel, suffix=p.suffix.lower(), lib_version=lib_version)
# identify() has already populated scan_head's bounded cache, so this
# reuses the exact metadata region rather than reading the file again.
hits = _marker_hits_for_path(p)
classes = _candidate_classes(rep, hits)
row["markers"] = "|".join(hits)
row["candidate_classes"] = "|".join(classes)
rows.append(row)
if rep.is_ai_generated:
verdicts["ai"] += 1
platforms[rep.platform or "?"] += 1
continue
verdicts["unknown"] += 1
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)
with report.open("w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=REPORT_FIELDS)
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}")
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, n in platforms.most_common():
plat.add_row(str(n), name)
console.print(plat)
candidates = [row for row in rows if row["candidate_classes"]]
if candidates:
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, n in gap_tokens.most_common():
tok.add_row(str(n), name)
console.print(tok)
for row in candidates:
detail = f"; markers={row['markers'].replace('|', ', ')}" if row["markers"] else ""
console.print(f" {row['path']} -> {row['candidate_classes'].replace('|', ', ')}{detail}")
else:
console.print("\n[green]No review candidates in this run.[/green]")
_summarize(rows)
if __name__ == "__main__":
+1 -1
View File
@@ -33,7 +33,7 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
_warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*")
__version__ = "0.40.1"
__version__ = "0.40.2"
__all__ = [
"BatchItemResult",
+17 -11
View File
@@ -66,6 +66,7 @@ from remove_ai_watermarks.metadata import (
samsung_genai_in,
scan_head,
xai_signature,
xai_signature_in_metadata,
xai_signature_pair,
)
from remove_ai_watermarks.watermark_registry import GEMINI_SPARKLE_TRUST_CONF
@@ -419,10 +420,16 @@ def evidence_from_metadata_record(
values_by_key: dict[str, str] = {}
for key, value in pairs:
if isinstance(value, (bytes, str)):
values_by_key.setdefault(key.lower(), _external_text(value))
description = values_by_key.get("imagedescription", "")
artist = values_by_key.get("artist", "")
xai = xai_signature_pair(description, artist)
values_by_key.setdefault(key.lower().removeprefix("info:"), _external_text(value))
xai = any(
xai_signature_pair(values_by_key.get(description_key, ""), values_by_key.get(creator_key, ""))
for description_key, creator_key in (
("imagedescription", "artist"),
("description", "author"),
("description", "creator"),
("caption-abstract", "by-line"),
)
) or xai_signature_in_metadata(scan)
hf_job = next(
(
@@ -446,7 +453,7 @@ def evidence_from_metadata_record(
f"China AIGC label (TC260){f'; producer {producer}' if producer else ''}",
)
if xai:
ai_metadata.setdefault("xai_signature", "xAI/Grok EXIF signature (Artist UUID + Signature blob)")
ai_metadata.setdefault("xai_signature", "xAI/Grok signature (UUID + Signature blob)")
if iptc_system:
ai_metadata.setdefault("ai_system", f"IPTC 2025.1 AI disclosure ({iptc_system})")
if hf_job:
@@ -1424,13 +1431,12 @@ def _identify_from_evidence(
if v := _vendor_of(generator_tag):
ai_vendor_claims["exif_generator"] = v
# ── xAI / Grok EXIF signature scheme (no C2PA/SynthID/IPTC) ──────
# Grok's only provenance signal: EXIF ImageDescription "Signature: <base64>"
# + a UUID Artist. Distinct from exif_generator (which matches generator
# tokens); verified stable across 3 generations. See CLAUDE.md.
# ── xAI / Grok signature scheme ───────────────────────────────────
# The original EXIF pair can move into equivalent XMP, PNG text, or IPTC
# fields during a metadata-preserving transcode. Neither shape alone is enough.
if evidence.xai_signature:
signals.append(Signal("xai_signature", "EXIF Signature blob + UUID Artist", "high"))
watermarks.append("xAI/Grok EXIF signature")
signals.append(Signal("xai_signature", "Signature blob + UUID provenance pair", "high"))
watermarks.append("xAI/Grok provenance signature")
if platform is None:
platform = "xAI (Grok / Aurora)"
ai_vendor_claims["xai"] = "xAI"
+194 -25
View File
@@ -527,7 +527,7 @@ def has_ai_metadata(image_path: Path) -> bool:
# Hugging Face-hosted job marker (hf-job-id PNG text chunk).
if huggingface_job(image_path):
return True
# xAI / Grok: no C2PA/IPTC/XMP -- only the EXIF Signature + UUID-Artist pair.
# xAI / Grok signature + UUID pair, including container-native transcodes.
return xai_signature(image_path)
@@ -1052,13 +1052,122 @@ def exif_generator(image_path: Path) -> str | None:
# pair xAI-specific -- both required keeps the false-positive rate near zero.
_XAI_SIGNATURE_RE = re.compile(r"Signature:\s*[A-Za-z0-9+/=]{64,}")
_UUID_RE = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", re.IGNORECASE)
_RDF_DESCRIPTION_RE = re.compile(rb"<rdf:Description\b[^>]*>.*?</rdf:Description>", re.IGNORECASE | re.DOTALL)
_XMP_DESCRIPTION_RE = re.compile(
rb"(?:<dc:description\b[^>]*>(.*?)</dc:description>|\bdc:description=[\"']([^\"']*)[\"'])",
re.IGNORECASE | re.DOTALL,
)
_XMP_CREATOR_RE = re.compile(
rb"(?:<dc:creator\b[^>]*>(.*?)</dc:creator>|\bdc:creator=[\"']([^\"']*)[\"'])",
re.IGNORECASE | re.DOTALL,
)
def xai_signature_pair(description: str, artist: str) -> bool:
"""True if an EXIF (ImageDescription, Artist) pair is xAI/Grok's scheme."""
"""True if a description/creator pair has xAI/Grok's two value shapes."""
return _XAI_SIGNATURE_RE.match(description) is not None and _UUID_RE.fullmatch(artist) is not None
def _decoded_xai_signature_pair(description: bytes, creator: bytes) -> bool:
description = re.sub(rb"<[^>]+>", b"", description).strip()
creator = re.sub(rb"<[^>]+>", b"", creator).strip()
return xai_signature_pair(
description.decode("utf-8", "replace").strip(),
creator.decode("utf-8", "replace").strip(),
)
def _xmp_xai_signature_pair(data: bytes) -> bool:
"""Match the two xAI values only inside the same RDF description."""
for block_match in _RDF_DESCRIPTION_RE.finditer(data):
block = block_match.group()
descriptions = [
next(value for value in match.groups() if value is not None)
for match in _XMP_DESCRIPTION_RE.finditer(block)
]
creators = [
next(value for value in match.groups() if value is not None) for match in _XMP_CREATOR_RE.finditer(block)
]
if any(
_decoded_xai_signature_pair(description, creator) for description in descriptions for creator in creators
):
return True
return False
def _iptc_dataset_values(data: bytes, dataset: int) -> list[bytes]:
"""Return bounded IPTC application-record values for one dataset number."""
values: list[bytes] = []
marker = bytes((0x1C, 0x02, dataset))
start = 0
while (index := data.find(marker, start)) >= 0:
length_start = index + len(marker)
if length_start + 2 > len(data):
break
length = int.from_bytes(data[length_start : length_start + 2], "big")
value_start = length_start + 2
value_end = value_start + length
if value_end <= len(data) and length <= 4096:
values.append(data[value_start:value_end])
start = length_start
return values
def _iptc_xai_signature_pair(data: bytes) -> bool:
descriptions = _iptc_dataset_values(data, 120) # Caption-Abstract
creators = _iptc_dataset_values(data, 80) # By-line
# Some metadata exports retain the field names instead of the binary dataset
# headers. Keep that observed representation precise by anchoring each value to
# its field label.
descriptions += re.findall(rb"Caption-Abstract\x00([^\x00]{1,4096})", data, re.IGNORECASE)
creators += re.findall(rb"By-line\x00([^\x00]{1,4096})", data, re.IGNORECASE)
return any(
_decoded_xai_signature_pair(description, creator) for description in descriptions for creator in creators
)
def _jpeg_app_payloads(data: bytes) -> list[tuple[int, bytes]]:
"""Extract complete XMP/IPTC APP payloads from a JPEG or concatenated regions."""
payloads: list[tuple[int, bytes]] = []
position = 2 if data.startswith(b"\xff\xd8") else 0
while position + 4 <= len(data):
if data[position] != 0xFF:
break
marker = data[position + 1]
if marker in (0xDA, 0xD9): # SOS / EOI
break
if 0xD0 <= marker <= 0xD7 or marker == 0x01:
position += 2
continue
length = int.from_bytes(data[position + 2 : position + 4], "big")
end = position + 2 + length
if length >= 2 and end <= len(data):
if marker in (0xE1, 0xED):
payloads.append((marker, data[position + 4 : end]))
position = end
else:
break
return payloads
def xai_signature_in_metadata(data: bytes) -> bool:
"""True when one metadata record carries xAI's paired provenance fields.
Editors preserve the same two values under equivalent container-native fields:
XMP ``dc:description``/``dc:creator`` and IPTC
``Caption-Abstract``/``By-line``. Values must be attached to the confirmed field
names inside one APP segment or RDF description; an unrelated XMP UUID must not
complete the pair.
"""
payloads = _jpeg_app_payloads(data)
if payloads:
return any(
_xmp_xai_signature_pair(payload) if marker == 0xE1 else _iptc_xai_signature_pair(payload)
for marker, payload in payloads
)
return _xmp_xai_signature_pair(data) or _iptc_xai_signature_pair(data)
def _exif_text(ifd: dict[int, Any], tag: int) -> str:
"""Decode a piexif 0th-IFD byte tag to a stripped string ('' if absent)."""
value = ifd.get(tag)
@@ -1066,34 +1175,52 @@ def _exif_text(ifd: dict[int, Any], tag: int) -> str:
def _xai_signature_impl(image_path: Path) -> bool:
"""Detect xAI / Grok's EXIF provenance signature scheme.
"""Detect xAI / Grok's provenance signature scheme.
Grok image downloads (Aurora model) carry no C2PA, XMP, SynthID, or IPTC --
their only provenance signal is a private EXIF pair: ``ImageDescription`` =
``"Signature: <base64>"`` together with ``Artist`` = the image UUID. Verified
stable across three independent generations (2026-05-26; see CLAUDE.md). The
signature is xAI's and is not locally verifiable (no public key); detection
keys on this distinctive, low-false-positive shape, not on the signature's
validity. It survives only on the *original* JPEG download -- the web-UI
image is a re-encoded WebP that drops EXIF.
Legacy Grok/Aurora downloads use a private EXIF pair: ``ImageDescription`` =
``"Signature: <base64>"`` together with ``Artist`` = the image UUID. Metadata-
preserving editors can move that exact pair into XMP, PNG text, or IPTC fields.
The signature is not locally verifiable (no public key); detection keys on both
distinctive shapes, not on the signature's validity.
"""
decoded_pair = False
try:
import piexif
from PIL import Image
from PIL.IptcImagePlugin import getiptcinfo
with Image.open(image_path) as img:
exif_bytes = exif_bytes_from_image(img)
if not exif_bytes:
return False
tags = piexif.load(exif_bytes).get("0th", {})
descriptions = [img.info.get(key) for key in ("Description", "ImageDescription")]
creators = [img.info.get(key) for key in ("Author", "Artist", "Creator")]
decoded_pair = any(
xai_signature_pair(description, creator)
for description in descriptions
if isinstance(description, str)
for creator in creators
if isinstance(creator, str)
)
if iptc := getiptcinfo(img):
caption = iptc.get((2, 120), b"")
byline = iptc.get((2, 80), b"")
if isinstance(caption, bytes) and isinstance(byline, bytes):
decoded_pair = decoded_pair or _decoded_xai_signature_pair(caption, byline)
if exif_bytes:
tags = piexif.load(exif_bytes).get("0th", {})
decoded_pair = decoded_pair or xai_signature_pair(
_exif_text(tags, piexif.ImageIFD.ImageDescription),
_exif_text(tags, piexif.ImageIFD.Artist),
)
except Exception as exc: # unopenable format / malformed EXIF
logger.debug("xAI-signature EXIF read failed for %s: %s", image_path, exc)
if decoded_pair:
return True
try:
return xai_signature_in_metadata(scan_head(image_path))
except OSError as exc:
logger.debug("xAI-signature metadata scan failed for %s: %s", image_path, exc)
return False
return xai_signature_pair(
_exif_text(tags, piexif.ImageIFD.ImageDescription), _exif_text(tags, piexif.ImageIFD.Artist)
)
def _is_aigc_exif_value(raw: object) -> bool:
"""Whether an EXIF tag value carries a China TC260 AIGC producer/service block.
@@ -1276,9 +1403,9 @@ def get_ai_metadata(image_path: Path) -> dict[str, str]:
if app_generator:
result.setdefault("app_aigc", f"App AIGC disclosure ({app_generator})")
# xAI / Grok EXIF signature scheme (its only provenance signal).
# xAI / Grok signature pair in EXIF or an equivalent container-native field.
if xai_signature(image_path):
result.setdefault("xai_signature", "xAI/Grok EXIF signature (Artist UUID + Signature blob)")
result.setdefault("xai_signature", "xAI/Grok signature (UUID + Signature blob)")
# IPTC 2025.1 AI-disclosure XMP fields (Iptc4xmpExt:AISystemUsed etc.).
if system := iptc_ai_system(image_path):
@@ -1359,12 +1486,15 @@ def _jpeg_app_carries_ai(marker: int, payload: bytes) -> bool:
or any(m in payload for m in AIGC_MARKERS)
or any(m in payload for m in IPTC_AI_MARKERS)
or any(m in payload for m in IPTC_AI_FIELD_MARKERS)
or xai_signature_in_metadata(payload)
)
):
return True
# IPTC "Made with AI" record (APP13).
if marker == 0xED and (
any(m in payload for m in IPTC_AI_MARKERS) or any(m in payload for m in IPTC_AI_FIELD_MARKERS)
any(m in payload for m in IPTC_AI_MARKERS)
or any(m in payload for m in IPTC_AI_FIELD_MARKERS)
or xai_signature_in_metadata(payload)
):
return True
# A bare / wrapped China TC260 AIGC block (``AIGC{...}`` or ``{"AIGC":{...}}``) glued
@@ -1480,6 +1610,7 @@ def _strip_png_metadata_lossless(source_path: Path, output_path: Path, keep_stan
data = source_path.read_bytes()
if not data.startswith(PNG_SIGNATURE):
return False
source_has_xai_pair = xai_signature(source_path)
out = bytearray(PNG_SIGNATURE)
view = memoryview(data)
pos, n = 8, len(data)
@@ -1502,7 +1633,13 @@ def _strip_png_metadata_lossless(source_path: Path, output_path: Path, keep_stan
+ scrubbed
+ struct.pack(">I", zlib.crc32(b"eXIf" + scrubbed))
)
elif not _png_chunk_should_be_dropped(chunk_type, payload, keep_standard, png_text_decode):
elif not _png_chunk_should_be_dropped(
chunk_type,
payload,
keep_standard,
png_text_decode,
xai_pair_present=source_has_xai_pair,
):
out += view[pos:end]
if chunk_type == b"IEND":
break
@@ -1534,13 +1671,32 @@ def _scrubbed_png_exif(payload: bytes) -> bytes | None:
return None
def _keep_standard_text_metadata(key: str, value: object, keep_standard: bool) -> bool:
def _xai_text_pair_member(key: str, value: object) -> bool:
"""Whether one standard text item is a member of an already-proven xAI pair."""
if not isinstance(value, str):
return False
normalized = key.lower().replace("-", "").replace("_", "")
if normalized in {"description", "imagedescription", "captionabstract"}:
return _XAI_SIGNATURE_RE.search(value) is not None
if normalized in {"author", "artist", "creator", "byline"}:
return _UUID_RE.fullmatch(value.strip()) is not None
return False
def _keep_standard_text_metadata(
key: str,
value: object,
keep_standard: bool,
*,
xai_pair_present: bool = False,
) -> bool:
"""Whether a text metadata item is standard and free of AI provenance."""
return (
keep_standard
and key in STANDARD_METADATA_KEYS
and not _is_ai_key(key)
and not (isinstance(value, str) and (_is_ai_value(value) or _is_aigc_exif_value(value)))
and not (xai_pair_present and _xai_text_pair_member(key, value))
)
@@ -1549,6 +1705,8 @@ def _png_chunk_should_be_dropped(
payload: bytes,
keep_standard: bool,
decode: Callable[[str, bytes], str],
*,
xai_pair_present: bool = False,
) -> bool:
"""True when a PNG chunk must be dropped by the lossless strip.
@@ -1565,7 +1723,12 @@ def _png_chunk_should_be_dropped(
if chunk_type not in (b"tEXt", b"zTXt", b"iTXt"):
return False
keyword, _, text = decode(chunk_type.decode("ascii"), payload).partition("\x00")
return not _keep_standard_text_metadata(keyword, text, keep_standard)
return not _keep_standard_text_metadata(
keyword,
text,
keep_standard,
xai_pair_present=xai_pair_present,
)
# Fallback extension -> PIL save format, used only when the content sniff is
@@ -1771,6 +1934,7 @@ def remove_ai_metadata(
# Read image and filter metadata
with Image.open(source_path) as source_image:
source_exif_bytes = exif_bytes_from_image(source_image)
source_has_xai_pair = xai_signature(source_path)
img = source_image.copy()
# Pick the save format. Honor the caller's output extension (so a deliberate
# source.png -> output.jpg conversion still works) UNLESS the SOURCE is misnamed
@@ -1815,7 +1979,12 @@ def remove_ai_metadata(
continue
# NovelAI and TC260 can stamp AI provenance into standard Title/Source/
# Description fields. Share this decision with the lossless PNG walker.
if _keep_standard_text_metadata(key, value, keep_standard):
if _keep_standard_text_metadata(
key,
value,
keep_standard,
xai_pair_present=source_has_xai_pair,
):
kept_meta[key] = str(value) if not isinstance(value, str) else value
if source_exif_bytes:
+3 -2
View File
@@ -281,8 +281,9 @@ def _exif_pairs(info: dict[str, Any]) -> dict[str, str]:
"""The 0th-IFD tags the verdict reads, under their tag NAMES.
Not a convenience: two probes key on names rather than on the raw bytes already
in the regions. ``xai_signature_pair`` wants an (ImageDescription, Artist) pair,
and ``_external_exif_generator`` looks for Software / Make / Artist /
in the regions. The original xAI shape needs an (ImageDescription, Artist) pair
(container-native pairs arrive through ``pil`` or structured regions), and
``_external_exif_generator`` looks for Software / Make / Artist /
ImageDescription. Ship the bytes alone and both silently return nothing, which
is how a collector can silently lose Grok and NovelAI verdicts.
"""
+104 -1
View File
@@ -1021,7 +1021,25 @@ def _grok_imagemagick_png(tmp_path: Path) -> Path:
class TestXaiSignature:
"""xAI / Grok's EXIF Signature + UUID-Artist provenance scheme."""
"""xAI / Grok's Signature + UUID provenance scheme across metadata containers."""
@staticmethod
def _png_text_pair(tmp_path: Path) -> Path:
info = PngInfo()
info.add_text("Description", f"Signature: {_FAKE_SIG}")
info.add_text("Author", _FAKE_UUID)
path = tmp_path / "grok-text.png"
Image.new("RGB", (64, 64), (30, 40, 50)).save(path, pnginfo=info)
return path
@staticmethod
def _jpeg_app_pair(tmp_path: Path, marker: bytes, payload: bytes, name: str) -> Path:
path = tmp_path / name
Image.new("RGB", (64, 64), (30, 40, 50)).save(path, "JPEG")
data = path.read_bytes()
segment = marker + (len(payload) + 2).to_bytes(2, "big") + payload
path.write_bytes(data[:2] + segment + data[2:])
return path
def test_signature_plus_uuid_detected(self, tmp_path: Path):
assert xai_signature(_grok_jpeg(tmp_path)) is True
@@ -1036,6 +1054,79 @@ class TestXaiSignature:
assert xai_signature(path) is True
assert "xai_signature" in get_ai_metadata(path)
def test_png_description_and_author_pair_detected_and_stripped(self, tmp_path: Path):
path = self._png_text_pair(tmp_path)
assert xai_signature(path) is True
assert "xai_signature" in get_ai_metadata(path)
out = tmp_path / "clean.png"
remove_ai_metadata(path, out)
assert xai_signature(out) is False
with Image.open(out) as image:
assert "Description" not in image.info
assert "Author" not in image.info
def test_xmp_description_and_creator_pair_detected_and_stripped(self, tmp_path: Path):
payload = (
b"http://ns.adobe.com/xap/1.0/\x00"
b'<rdf:Description xmlns:dc="http://purl.org/dc/elements/1.1/">'
b"<dc:description>Signature: "
+ _FAKE_SIG.encode()
+ b"</dc:description><dc:creator>"
+ _FAKE_UUID.encode()
+ b"</dc:creator></rdf:Description>"
)
path = self._jpeg_app_pair(tmp_path, b"\xff\xe1", payload, "grok-xmp.jpg")
assert xai_signature(path) is True
out = tmp_path / "clean-xmp.jpg"
remove_ai_metadata(path, out)
assert xai_signature(out) is False
assert _FAKE_SIG.encode() not in out.read_bytes()
assert _FAKE_UUID.encode() not in out.read_bytes()
def test_xmp_signature_does_not_pair_with_an_unrelated_uuid(self, tmp_path: Path):
payload = (
b"http://ns.adobe.com/xap/1.0/\x00"
b'<rdf:Description xmlns:dc="http://purl.org/dc/elements/1.1/" '
b'xmp:InstanceID="xmp.iid:'
+ _FAKE_UUID.encode()
+ b'"><dc:description>Signature: '
+ _FAKE_SIG.encode()
+ b"</dc:description></rdf:Description>"
)
path = self._jpeg_app_pair(tmp_path, b"\xff\xe1", payload, "unrelated-xmp-uuid.jpg")
assert xai_signature(path) is False
def test_xmp_values_in_different_descriptions_do_not_form_a_pair(self, tmp_path: Path):
payload = (
b"http://ns.adobe.com/xap/1.0/\x00"
b"<rdf:Description><dc:description>Signature: "
+ _FAKE_SIG.encode()
+ b"</dc:description></rdf:Description>"
b"<rdf:Description><dc:creator>" + _FAKE_UUID.encode() + b"</dc:creator></rdf:Description>"
)
path = self._jpeg_app_pair(tmp_path, b"\xff\xe1", payload, "split-xmp-pair.jpg")
assert xai_signature(path) is False
def test_iptc_caption_and_byline_pair_detected_and_stripped(self, tmp_path: Path):
payload = b"Photoshop 3.0\x00Caption-Abstract\x00Signature: " + _FAKE_SIG.encode()
payload += b"\x00By-line\x00" + _FAKE_UUID.encode()
path = self._jpeg_app_pair(tmp_path, b"\xff\xed", payload, "grok-iptc.jpg")
assert xai_signature(path) is True
out = tmp_path / "clean-iptc.jpg"
remove_ai_metadata(path, out)
assert xai_signature(out) is False
assert _FAKE_SIG.encode() not in out.read_bytes()
assert _FAKE_UUID.encode() not in out.read_bytes()
def test_real_grok_sample_detected(self):
# Real committed Grok download (data/fixtures/provenance/grok-1.jpg); the EXIF
# Signature + UUID-Artist pair is the only AI signal it carries.
@@ -1056,6 +1147,18 @@ class TestXaiSignature:
# Incidental short "Signature: ..." text is below the 64-char base64 bar.
assert xai_signature(_grok_jpeg(tmp_path, signature="ok")) is False
def test_png_signature_without_uuid_is_preserved(self, tmp_path: Path):
info = PngInfo()
info.add_text("Description", f"Signature: {_FAKE_SIG}")
path = tmp_path / "signature-only.png"
Image.new("RGB", (64, 64)).save(path, pnginfo=info)
assert xai_signature(path) is False
out = tmp_path / "signature-only-clean.png"
remove_ai_metadata(path, out)
with Image.open(out) as image:
assert image.info["Description"] == f"Signature: {_FAKE_SIG}"
def test_clean_image_is_false(self, tmp_clean_png: Path):
assert xai_signature(tmp_clean_png) is False
+11
View File
@@ -183,6 +183,17 @@ class TestPlacementsTheRecordCouldDrop:
assert "xai_signature" in {s.name for s in identify(path, check_visible=False, check_invisible=False).signals}
_assert_same_verdict(path)
def test_a_container_native_xai_pair_survives(self, tmp_path: Path):
"""The portable record must preserve xAI pairs moved out of EXIF."""
info = PngInfo()
info.add_text("Description", "Signature: " + "A" * 80)
info.add_text("Author", "3f2504e0-4f89-11d3-9a0c-0305e82c3301")
path = tmp_path / "grok-text.png"
Image.fromarray(np.zeros((64, 64, 3), dtype=np.uint8)).save(path, "PNG", pnginfo=info)
assert "xai_signature" in {s.name for s in identify(path, check_visible=False, check_invisible=False).signals}
_assert_same_verdict(path)
def test_isobmff_generation_tags_survive(self, tmp_path: Path):
def box(kind: bytes, payload: bytes) -> bytes:
return (8 + len(payload)).to_bytes(4, "big") + kind + payload
+44
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import json
import sys
from datetime import date
from pathlib import Path
@@ -54,6 +55,49 @@ def test_since_filter_uses_corpus_date_directories(tmp_path):
assert gap._files(tmp_path, date(2026, 9, 10)) == [current]
def test_gap_checkpoint_keeps_complete_rows_and_ignores_a_torn_tail(tmp_path):
checkpoint = tmp_path / "report.csv.progress.jsonl"
row = gap._base_row("2026-09-10/example.png", ".png", "1.2.3")
checkpoint.write_text(json.dumps(row) + '\n{"path":"torn', encoding="utf-8")
assert gap._read_checkpoint(checkpoint) == {row["path"]: row}
gap._repair_checkpoint(checkpoint)
second = gap._base_row("2026-09-10/second.png", ".png", "1.2.3")
with checkpoint.open("a", encoding="utf-8") as stream:
stream.write(json.dumps(second) + "\n")
assert gap._read_checkpoint(checkpoint) == {row["path"]: row, second["path"]: second}
def test_gap_worker_error_is_a_complete_resumable_row(monkeypatch, tmp_path):
path = tmp_path / "bad.bin"
path.write_bytes(b"not an image")
calls = []
def fail(_path, **kwargs):
calls.append(kwargs)
raise ValueError("broken\ninput")
monkeypatch.setattr(gap, "identify", fail)
row = gap._scan_one((str(path), "bad.bin", "1.2.3"))
assert set(row) == set(gap.REPORT_FIELDS)
assert row["candidate_classes"] == "identify_error"
assert row["error"] == "ValueError: broken input"
assert calls == [{"check_visible": False, "check_invisible": False}]
def test_gap_summary_does_not_report_a_negative_hidden_count(capsys):
row = gap._base_row("candidate.png", ".png", "1.2.3")
row["candidate_classes"] = "blind_marker"
gap._summarize([row])
output = capsys.readouterr()
assert "more candidate row" not in output.out + output.err
def test_sidecar_regression_prefers_stable_signal_names():
sidecar = {"signals": ["visible_qwen"], "watermarks": ["wording may change"]}
assert regression.sidecar_families(sidecar) == {"visible_qwen"}
Generated
+65 -65
View File
@@ -800,7 +800,7 @@ name = "cuda-bindings"
version = "13.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cuda-pathfinder", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "cuda-pathfinder" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/6b/457ca12dad3ee9bfcc9a545cfd6b64b359ba49de40f776f6e028e678f262/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474", size = 6053539, upload-time = "2026-05-29T23:11:43.19Z" },
@@ -833,43 +833,43 @@ wheels = [
[package.optional-dependencies]
cublas = [
{ name = "nvidia-cublas", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
{ name = "nvidia-cuda-nvrtc", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
{ name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
{ name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
]
cudart = [
{ name = "nvidia-cuda-runtime", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
{ name = "nvidia-cuda-runtime", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
]
cufft = [
{ name = "nvidia-cufft", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
{ name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
{ name = "nvidia-cufft", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
{ name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
]
cufile = [
{ name = "nvidia-cufile", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
{ name = "nvidia-cufile", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
]
cupti = [
{ name = "nvidia-cuda-cupti", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
{ name = "nvidia-cuda-cupti", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
]
curand = [
{ name = "nvidia-curand", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
{ name = "nvidia-curand", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
]
cusolver = [
{ name = "nvidia-cublas", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
{ name = "nvidia-cusolver", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
{ name = "nvidia-cusparse", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
{ name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
{ name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
{ name = "nvidia-cusolver", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
{ name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
{ name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
]
cusparse = [
{ name = "nvidia-cusparse", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
{ name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
{ name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
{ name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
]
nvjitlink = [
{ name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
{ name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
]
nvrtc = [
{ name = "nvidia-cuda-nvrtc", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
{ name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
]
nvtx = [
{ name = "nvidia-nvtx", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
{ name = "nvidia-nvtx", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
]
[[package]]
@@ -1066,7 +1066,7 @@ wheels = [
[package.optional-dependencies]
http = [
{ name = "aiohttp", marker = "python_full_version < '3.13'" },
{ name = "aiohttp" },
]
[[package]]
@@ -1198,8 +1198,8 @@ name = "httpcore2"
version = "2.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "h11", marker = "sys_platform != 'emscripten'" },
{ name = "truststore", marker = "sys_platform != 'emscripten'" },
{ name = "h11" },
{ name = "truststore" },
]
sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" }
wheels = [
@@ -1461,15 +1461,15 @@ name = "lightning"
version = "2.6.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "fsspec", extra = ["http"], marker = "python_full_version < '3.13'" },
{ name = "lightning-utilities", marker = "python_full_version < '3.13'" },
{ name = "packaging", marker = "python_full_version < '3.13'" },
{ name = "pytorch-lightning", marker = "python_full_version < '3.13'" },
{ name = "pyyaml", marker = "python_full_version < '3.13'" },
{ name = "torch", marker = "python_full_version < '3.13'" },
{ name = "torchmetrics", marker = "python_full_version < '3.13'" },
{ name = "tqdm", marker = "python_full_version < '3.13'" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
{ name = "fsspec", extra = ["http"] },
{ name = "lightning-utilities" },
{ name = "packaging" },
{ name = "pytorch-lightning" },
{ name = "pyyaml" },
{ name = "torch" },
{ name = "torchmetrics" },
{ name = "tqdm" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/39/b8/fbf1469d917c3ed3b22e00ab3a852fb9a5f85b9dc7f7060eedc3449098e7/lightning-2.6.6.tar.gz", hash = "sha256:1c6329e241f8a85f29fedead89c1ee138b36caa08de60c260c6a7eaee81c8295", size = 659902, upload-time = "2026-09-10T09:41:03.009Z" }
wheels = [
@@ -1481,8 +1481,8 @@ name = "lightning-utilities"
version = "0.15.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging", marker = "python_full_version < '3.13'" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
{ name = "packaging" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f1/45/7fa8f56b17dc0f0a41ec70dd307ecd6787254483549843bef4c30ab5adce/lightning_utilities-0.15.3.tar.gz", hash = "sha256:792ae0204c79f6859721ac7f386c237a33b0ed06ba775009cb894e010a842033", size = 33553, upload-time = "2026-02-22T14:48:53.348Z" }
wheels = [
@@ -1896,7 +1896,7 @@ name = "nvidia-cublas"
version = "13.1.1.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-cuda-nvrtc", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "nvidia-cuda-nvrtc" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" },
@@ -1935,7 +1935,7 @@ name = "nvidia-cudnn-cu13"
version = "9.24.0.43"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-cublas", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "nvidia-cublas" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/ca/30/7c257e3d5cb4fecb147b93895c66e29c93f8e76d74b45bb418ff0587c4ec/nvidia_cudnn_cu13-9.24.0.43-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:a6812a554a1ff0413e9c52b84c26c050380649ab9615f9c16bded368ce9f421f", size = 650976863, upload-time = "2026-07-02T16:23:39.248Z" },
@@ -1947,7 +1947,7 @@ name = "nvidia-cufft"
version = "12.0.0.61"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-nvjitlink", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "nvidia-nvjitlink" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" },
@@ -1977,9 +1977,9 @@ name = "nvidia-cusolver"
version = "12.0.4.66"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-cublas", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "nvidia-cusparse", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "nvidia-nvjitlink", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "nvidia-cublas" },
{ name = "nvidia-cusparse" },
{ name = "nvidia-nvjitlink" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" },
@@ -1991,7 +1991,7 @@ name = "nvidia-cusparse"
version = "12.6.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-nvjitlink", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "nvidia-nvjitlink" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" },
@@ -2146,7 +2146,7 @@ resolution-markers = [
"(python_full_version < '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.12' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')",
]
dependencies = [
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" },
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } },
]
sdist = { url = "https://files.pythonhosted.org/packages/17/06/68c27a523103dad5837dc5b87e71285280c4f098c60e4fe8a8db6486ab09/opencv-python-4.11.0.86.tar.gz", hash = "sha256:03d60ccae62304860d232272e4a4fda93c39d595780cb40b161b310244b736a4", size = 95171956, upload-time = "2025-01-16T13:52:24.737Z" }
wheels = [
@@ -2175,7 +2175,7 @@ resolution-markers = [
"(python_full_version == '3.13.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')",
]
dependencies = [
{ name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" },
{ name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" } },
]
sdist = { url = "https://files.pythonhosted.org/packages/ed/e8/c2959c0f969ea4ae96b55ad10c108c0410de4e8b3550d9941a4cfdd52703/opencv_python-4.14.0.94.tar.gz", hash = "sha256:afadc76a38a07713a4c2b4702da917cf0ed489be57e2a40a34e670189731de7d", size = 96400225, upload-time = "2026-07-28T19:23:09.546Z" }
wheels = [
@@ -2206,7 +2206,7 @@ resolution-markers = [
"(python_full_version < '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.12' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')",
]
dependencies = [
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" },
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } },
]
sdist = { url = "https://files.pythonhosted.org/packages/36/2f/5b2b3ba52c864848885ba988f24b7f105052f68da9ab0e693cc7c25b0b30/opencv-python-headless-4.11.0.86.tar.gz", hash = "sha256:996eb282ca4b43ec6a3972414de0e2331f5d9cda2b41091a49739c19fb843798", size = 95177929, upload-time = "2025-01-16T13:53:40.22Z" }
wheels = [
@@ -2235,7 +2235,7 @@ resolution-markers = [
"(python_full_version == '3.13.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')",
]
dependencies = [
{ name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" },
{ name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" } },
]
sdist = { url = "https://files.pythonhosted.org/packages/67/8a/51ff1c6287be0b6cd3070cf095875da4ff7d6eda38507e6ada5608786a78/opencv_python_headless-4.14.0.94.tar.gz", hash = "sha256:4afa2ea1214453648be88259f035712454faa9039b686de7753569ba8eec1577", size = 96405664, upload-time = "2026-07-28T19:23:48.758Z" }
wheels = [
@@ -3179,14 +3179,14 @@ name = "pytorch-lightning"
version = "2.6.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "fsspec", extra = ["http"], marker = "python_full_version < '3.13'" },
{ name = "lightning-utilities", marker = "python_full_version < '3.13'" },
{ name = "packaging", marker = "python_full_version < '3.13'" },
{ name = "pyyaml", marker = "python_full_version < '3.13'" },
{ name = "torch", marker = "python_full_version < '3.13'" },
{ name = "torchmetrics", marker = "python_full_version < '3.13'" },
{ name = "tqdm", marker = "python_full_version < '3.13'" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
{ name = "fsspec", extra = ["http"] },
{ name = "lightning-utilities" },
{ name = "packaging" },
{ name = "pyyaml" },
{ name = "torch" },
{ name = "torchmetrics" },
{ name = "tqdm" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/97/e4/5025cb42311b4f8cc6d1b12cd81399ca433159f8e3831ab229c5a6c5177e/pytorch_lightning-2.6.6.tar.gz", hash = "sha256:52c52b20522dd3d3feb3bb3aae2dbe3f291e8e836d4111a74cfd72e9df018834", size = 662507, upload-time = "2026-09-10T09:41:16.684Z" }
wheels = [
@@ -3205,7 +3205,7 @@ resolution-markers = [
"(python_full_version < '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.12' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')",
]
dependencies = [
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } },
]
sdist = { url = "https://files.pythonhosted.org/packages/5a/75/50581633d199812205ea8cdd0f6d52f12a624886b74bf1486335b67f01ff/pywavelets-1.9.0.tar.gz", hash = "sha256:148d12203377772bea452a59211d98649c8ee4a05eff019a9021853a36babdc8", size = 3938340, upload-time = "2025-08-04T16:20:04.978Z" }
wheels = [
@@ -3281,7 +3281,7 @@ resolution-markers = [
"(python_full_version == '3.12.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')",
]
dependencies = [
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.12.*'" },
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" },
{ name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/25/84/a9e80e33eb114e78bd2f1829a90d72d8d4d660109c721a8aa2f0d58d7592/pywavelets-1.10.0.tar.gz", hash = "sha256:f3cb8640225a8f3fdcbc3f6f3bd112c78927c9407cbfa65a5ff303990a6a3e32", size = 3948163, upload-time = "2026-09-07T17:15:15.637Z" }
@@ -3449,7 +3449,7 @@ wheels = [
[[package]]
name = "remove-ai-watermarks"
version = "0.40.1"
version = "0.40.2"
source = { editable = "." }
dependencies = [
{ name = "c2pa-python" },
@@ -4067,10 +4067,10 @@ name = "torchmetrics"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "lightning-utilities", marker = "python_full_version < '3.13'" },
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" },
{ name = "packaging", marker = "python_full_version < '3.13'" },
{ name = "torch", marker = "python_full_version < '3.13'" },
{ name = "lightning-utilities" },
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } },
{ name = "packaging" },
{ name = "torch" },
]
sdist = { url = "https://files.pythonhosted.org/packages/81/34/39b8b749333db56c0585d7a11fa62a283c087bb1dfc897d69fb8cedbefb1/torchmetrics-1.9.0.tar.gz", hash = "sha256:a488609948600df52d3db4fcdab02e62aab2a85ef34da67037dc3e65b8512faa", size = 581765, upload-time = "2026-03-09T17:41:22.443Z" }
wheels = [
@@ -4165,13 +4165,13 @@ name = "trustmark"
version = "0.9.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "einops", marker = "python_full_version < '3.13'" },
{ name = "lightning", marker = "python_full_version < '3.13'" },
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" },
{ name = "omegaconf", marker = "python_full_version < '3.13'" },
{ name = "six", marker = "python_full_version < '3.13'" },
{ name = "torch", marker = "python_full_version < '3.13'" },
{ name = "torchvision", marker = "python_full_version < '3.13'" },
{ name = "einops" },
{ name = "lightning" },
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } },
{ name = "omegaconf" },
{ name = "six" },
{ name = "torch" },
{ name = "torchvision" },
]
sdist = { url = "https://files.pythonhosted.org/packages/53/41/32e84596af94e8696c55060d55eb9eed9d99722a673d12212a1c0c4ef1d2/trustmark-0.9.2.tar.gz", hash = "sha256:bcaa3bc38ead1c0558df81932672f34e306efaed31389da57dbed100e236b28a", size = 63780, upload-time = "2026-09-08T10:58:11.601Z" }