mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-10-18 23:37:14 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
804735aa48 | ||
|
|
e3f714dcd2 | ||
|
|
7169780dd0 | ||
|
|
c7021b900b | ||
|
|
3f55df6f99 | ||
|
|
4795149d34 | ||
|
|
cf30b4f95a | ||
|
|
8ce8bf14da | ||
|
|
3fb94b220d | ||
|
|
d2c5aa66fc | ||
|
|
380820fa7e | ||
|
|
12e9a87865 | ||
|
|
19fccf27cb | ||
|
|
a385aab433 | ||
|
|
1d7a6270d4 | ||
|
|
b5cd16aef0 | ||
|
|
bf63e8e187 | ||
|
|
d4181f3277 | ||
|
|
bd14f40c7d | ||
|
|
c4edc2820d | ||
|
|
927429d349 | ||
|
|
df94f2c908 | ||
|
|
b2e46ee8db | ||
|
|
b03a65bcb3 | ||
|
|
f2f840197e | ||
|
|
242d78957b | ||
|
|
78426aeef7 | ||
|
|
d914e3ac08 | ||
|
|
c903346a6b | ||
|
|
7cf067d3a5 | ||
|
|
15cae6fc91 | ||
|
|
d3a042209c | ||
|
|
1386708dec | ||
|
|
67665c5cea | ||
|
|
fc54f25948 |
No files matched your search
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Agent skill and Claude Code plugin for the remove-ai-watermarks CLI.",
|
||||
"version": "1.0.6"
|
||||
"version": "1.0.9"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
|
||||
@@ -129,6 +129,10 @@ Do not classify an entire module as untestable because its main path downloads a
|
||||
- the photo-classify gate in `test_classify.py`: DEFINITELY is ridge AND MLP,
|
||||
POSSIBLY does not emit a provider, and `identify` does not import
|
||||
`remove_ai_watermarks.classify`.
|
||||
- the source-export classifier in `test_source_classify.py`: the Hub revision
|
||||
and artifact hash are immutable, the pickle-free schema and exact GELU are
|
||||
validated, threshold mutation changes the real decision, and `identify`
|
||||
never consumes the result as watermark evidence.
|
||||
- the `InvisibleOptions` defaults, in `test_api.py`. When one signature promises to
|
||||
mirror another, compare them field by field rather than pinning the values you happen
|
||||
to know about, so the next field added on one side and not the other fails at the
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"WebFetch",
|
||||
"WebSearch"
|
||||
]
|
||||
},
|
||||
"enabledPlugins": {
|
||||
"pyright-lsp@claude-plugins-official": true,
|
||||
"code-simplifier@claude-plugins-official": true,
|
||||
"claude-md-management@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
name: signal-discovery
|
||||
description: Discover missing AI provenance and watermark coverage in the remove-ai-watermarks library from fresh or historical corpora, then validate detection and removal parity. Use for corpus gap mining, periodic signal audits, new-provider discovery, or deciding whether old retained data must be replayed. Do not use for ordinary per-file identification or removal.
|
||||
---
|
||||
|
||||
# Signal discovery
|
||||
|
||||
Work from the `remove-ai-watermarks` repository root. This skill discovers and
|
||||
closes library coverage gaps. Implementation, tests, fixtures, and
|
||||
documentation changes belong in this library; do not patch a downstream
|
||||
consumer to compensate for a detector or cleaner gap.
|
||||
|
||||
Treat retained inputs as sensitive and read-only. Keep reports, contact sheets,
|
||||
sidecars, and derived samples under the library's gitignored `.local-eval/`.
|
||||
Never commit a retained input or an artifact derived from one.
|
||||
Use only publication-cleared or synthetic files for tracked fixtures.
|
||||
|
||||
Before acting, read:
|
||||
|
||||
- `CLAUDE.md` and `.claude/rules/development.md`;
|
||||
- `docs/verification-plan.md`;
|
||||
- `docs/supported-signals.md` and `docs/watermarking-landscape.md`;
|
||||
- [references/coverage-and-cadence.md](references/coverage-and-cadence.md).
|
||||
|
||||
## Choose the run
|
||||
|
||||
Do not call `corpus_gap_scan.py` a complete signal audit. Select every mode the
|
||||
question requires:
|
||||
|
||||
1. **Fresh discovery** finds new providers, serializations, labels, and formats
|
||||
in objects added since the preceding review.
|
||||
2. **Historical replay** reruns current code on the complete retained history.
|
||||
Use it after detection-path or format-support changes and on the periodic
|
||||
backstop defined in the reference.
|
||||
3. **Removal parity** proves that every detected removable signal is cleared
|
||||
without violating the signal family's fidelity invariant.
|
||||
4. **Calibration or provider oracle** is required where the corpus has no
|
||||
self-evident answer key. Never treat a local negative as proof that a
|
||||
proprietary watermark is absent.
|
||||
|
||||
Start by inventorying the current code, not a remembered list. Enumerate image
|
||||
and video visible-mark registries, metadata readers and strippers, local
|
||||
invisible decoders, explicit invisible vendor routes, supported containers, and
|
||||
the stable signal families emitted by `identify`. Reconcile that inventory with
|
||||
the coverage matrix before interpreting any green result. A hand-written marker
|
||||
list or report family map that omits a current registry member is itself a
|
||||
finding.
|
||||
|
||||
## Run protocol
|
||||
|
||||
1. Record the library commit, version, enabled extras, corpus date range, and
|
||||
exact commands in an untracked run directory. Preserve the pre-run Git
|
||||
status so unrelated work is not attributed to the audit.
|
||||
2. Refresh the local retained corpus through its owning, external collection
|
||||
process when the task calls for fresh data. Use an explicit gitignored
|
||||
destination, do not delete local objects, and finish with a zero-delta pass.
|
||||
Fetch paired outputs only for a removal or complaint investigation.
|
||||
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. 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
|
||||
unresolved.
|
||||
5. Reproduce an actionable gap with a failing test before changing code. Derive
|
||||
the smallest publication-safe fixture from a controlled source or construct
|
||||
it synthetically; do not move the retained file into a tracked path.
|
||||
6. Implement the fix in the library subsystem that owns it. Update the library
|
||||
registry, parser, remover, public docs, and published agent skill when their
|
||||
contracts move. Updating a downstream consumer to take a released version is
|
||||
a later, separately scoped task.
|
||||
7. Rerun the affected focused audit and the complete historical regression
|
||||
mode. A detection change must name the files expected to move before the
|
||||
after-run; classify lost signals separately from intended new detections.
|
||||
8. Run removal parity for every newly detected removable signal. A quiet
|
||||
post-removal detector is necessary but not always sufficient: check
|
||||
pixel/stream invariants and visually inspect residual-prone visible marks.
|
||||
9. Run `bash maintain.sh`. Report evidence limits, incomplete rows, crashes,
|
||||
unsupported formats, and oracle-unavailable cohorts explicitly.
|
||||
|
||||
External oracle uploads are separate mutations. Use the `provider-oracles`
|
||||
skill only for a bounded, already-selected candidate, and obtain the required
|
||||
action-time upload approval. Do not adapt candidates against an oracle, retry
|
||||
automatically, or substitute one provider's verdict for another's.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Coverage and cadence
|
||||
|
||||
Use this reference to choose the audit modes and decide whether current-only or
|
||||
historical data is sufficient.
|
||||
|
||||
## Coverage matrix
|
||||
|
||||
| Surface | Discovery evidence | Regression check | Removal proof |
|
||||
| --- | --- | --- | --- |
|
||||
| C2PA, EXIF, XMP, IPTC, PNG text, TC260, soft bindings, and container metadata | `scripts/corpus_gap_scan.py` plus review of unattributed and partially attributed reports | `scripts/sidecar_regression.py`; compare record-based and file-based identification when collection changes | `scripts/metadata_removal_audit.py`; signal absent after strip and decoded pixels/streams unchanged |
|
||||
| Registered visible image marks | `scripts/visible_positives.py` records strict and metadata-corroborated results, metadata cohorts, and blinded native-detail bands for uncovered cohorts plus an unbiased quiet sample | `scripts/visible_eval.py` exposes registry members without adjudicated scope, registered examples, and full-corpus crossfire | `scripts/visible_removal_audit.py`; same-mark redetection plus visual residual review and fill-quality checks |
|
||||
| Candidate China-AIGC visible marks | `scripts/vendor_cohort_harvest.py` partitions by producer identity without using pixels; inspect full-width top and bottom sheets | `scripts/vendor_mark_calibrate.py` against independently labelled positives, clean negatives, and neighbouring marks | Register only after the candidate gate survives full-corpus crossfire; then use the normal visible-removal audit |
|
||||
| Registered visible video marks | Review provider-labelled or controlled complete clips, not isolated frames alone | Full-clip tests and the local real-provider audit described in `docs/verification-plan.md` | Preserve sequence, timing, duration, and audio; second-pass detection must be quiet |
|
||||
| Locally decodable invisible image marks, including open DWT-DCT and TrustMark | Positive-control-gated corpus or constructed carriers; a negative on an uncontrolled carrier is inconclusive | `scripts/watermark_benchmark.py` and the relevant decoder tests with the required extras installed | Re-run the same decoder on output and retain a positive control in the same run |
|
||||
| Proprietary image marks: Google SynthID, Microsoft InvisMark, Meta Content Seal | Provider provenance can select a cohort but is not a local pixel decode; use bounded provider-labelled candidates | Hash-bound provider-oracle batches when a new route, profile, or claimed signal is evaluated | Source-positive and output-negative verdicts from that same provider; record C2PA separately from the pixel watermark |
|
||||
| Invisible video and audio marks | Matched provider or open-model cohorts with codec and degradation controls | Benchmark-kernel audio/video rows and temporal aggregation checks | Preserve the non-target stream; use the matching oracle and a control-positive run |
|
||||
| Explicit vendor regeneration with no detectable local signal | This is a user assertion or research cohort, not a discovered signal | Verify routing and profile selection separately from detection | Quality and provider-oracle evidence may certify a cohort, but must not create a local detection claim |
|
||||
|
||||
`classify` is a pixel-origin guess, not provenance, and must not be counted as a
|
||||
new signal. `erase --region` is user-directed editing, not automatic watermark
|
||||
detection, and is outside this audit.
|
||||
|
||||
## Gaps the legacy scanner cannot settle alone
|
||||
|
||||
`corpus_gap_scan.py` is a metadata serialization probe. It reports raw markers,
|
||||
input shapes, identify errors, integrity clashes, unattributed results, and
|
||||
blind-marker candidates, but it still cannot settle other useful classes, so
|
||||
inspect them separately:
|
||||
|
||||
- a known generic signal with missing or wrong vendor attribution;
|
||||
- a newly coexisting marker on a file that already has another AI verdict;
|
||||
- an entirely novel marker absent from the scanner's hand-written list;
|
||||
- visible image or video labels;
|
||||
- proprietary invisible marks without a local decoder;
|
||||
- detection/removal parity failures;
|
||||
- decoder crashes, unsupported extensions, and incomplete reads;
|
||||
- drift between stored identify sidecars and the current library;
|
||||
- crossfire where a new detector fires on an older neighbouring cohort.
|
||||
|
||||
Marker matching must remain metadata-region bounded to avoid random hits in
|
||||
compressed pixels, but its inventory should be reconciled with the current
|
||||
metadata constants and registry on every audit. Do not infer completeness from
|
||||
`No gap candidates` until the other rows of the matrix also ran.
|
||||
|
||||
For a visible discovery pass, use a fresh report path and pass `--sheets` to
|
||||
`visible_positives.py`. Review the numbered PNG files before opening
|
||||
`MANIFEST_DO_NOT_OPEN.csv`; metadata cohorts select where a mark may occur but do
|
||||
not label the pixels. The separate `unbiased-quiet` stratum is required because a
|
||||
new provider may carry neither a mapped producer code nor readable provenance.
|
||||
|
||||
## When to replay old data
|
||||
|
||||
Yes, old data must be rerun. Current code can learn to parse an old
|
||||
serialization, and a new detector can crossfire on an old provider cohort even
|
||||
when no new object demonstrates either problem. The canonical intervals,
|
||||
change triggers, and sampling rules live in
|
||||
[`docs/verification-plan.md#signal-discovery-cadence`](../../../../docs/verification-plan.md#signal-discovery-cadence).
|
||||
|
||||
Keep dated reports immutable enough to compare runs. Record the library SHA and
|
||||
version, corpus minimum and maximum dates, input counts per media/format shape,
|
||||
enabled extras, command line, and output paths. Never commit operational corpus
|
||||
counts or retained identifiers; summarize only durable conclusions in the
|
||||
canonical research document.
|
||||
@@ -7,8 +7,8 @@ name: Distribute on release
|
||||
# - HF Space: factory-rebuild the version already pinned in the Space repo.
|
||||
# - ComfyUI: sync, test, and publish the node against the exact library release.
|
||||
# - ClawHub: publish the agent skill when its own version moved.
|
||||
# All wait for the freshly published sdist to appear on PyPI first, since the
|
||||
# Release event fires in parallel with publish.yml's upload.
|
||||
# All wait for the freshly published distribution to appear in both PyPI APIs
|
||||
# first, since the Release event fires in parallel with publish.yml's upload.
|
||||
|
||||
on:
|
||||
release:
|
||||
@@ -22,6 +22,9 @@ on:
|
||||
jobs:
|
||||
resolve:
|
||||
runs-on: ubuntu-latest
|
||||
# Thirty attempts may spend up to 20 seconds each in the JSON request,
|
||||
# simple-index request, and inter-attempt delay. Leave bounded headroom.
|
||||
timeout-minutes: 25
|
||||
outputs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
url: ${{ steps.sdist.outputs.url }}
|
||||
@@ -63,26 +66,35 @@ jobs:
|
||||
path: release-identity.json
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Wait for the sdist on PyPI, capture url + sha256
|
||||
- name: Wait for the PyPI JSON and simple indexes, capture sdist metadata
|
||||
id: sdist
|
||||
run: |
|
||||
set -euo pipefail
|
||||
v="${{ steps.v.outputs.version }}"
|
||||
for i in $(seq 1 30); do
|
||||
if rel=$(curl -fsSL "https://pypi.org/pypi/remove-ai-watermarks/$v/json" 2>/dev/null); then
|
||||
url=$(echo "$rel" | python3 -c "import sys,json;d=json.load(sys.stdin);print(next((u['url'] for u in d['urls'] if u['packagetype']=='sdist'),''))")
|
||||
sha=$(echo "$rel" | python3 -c "import sys,json;d=json.load(sys.stdin);print(next((u['digests']['sha256'] for u in d['urls'] if u['packagetype']=='sdist'),''))")
|
||||
if rel=$(curl --connect-timeout 10 --max-time 20 -fsSL "https://pypi.org/pypi/remove-ai-watermarks/$v/json" 2>/dev/null); then
|
||||
read -r url sha <<< "$(echo "$rel" | python3 -c "import sys,json;u=next((u for u in json.load(sys.stdin)['urls'] if u['packagetype']=='sdist'),{});print(u.get('url',''),u.get('digests',{}).get('sha256',''))")"
|
||||
if [ -n "$url" ] && [ -n "$sha" ]; then
|
||||
echo "url=$url" >> "$GITHUB_OUTPUT"
|
||||
echo "sha=$sha" >> "$GITHUB_OUTPUT"
|
||||
echo "Found sdist for $v"
|
||||
exit 0
|
||||
# PyPI's JSON API can show a freshly uploaded file before the
|
||||
# simple index used by pip has caught up. Downstream release
|
||||
# jobs install by package version, so waiting on JSON alone
|
||||
# creates a release-time race for them.
|
||||
simple=$(curl --connect-timeout 10 --max-time 20 -fsSL https://pypi.org/simple/remove-ai-watermarks/ 2>/dev/null || true)
|
||||
artifact=${url##*/}
|
||||
if [[ "$simple" == *"$artifact"* ]]; then
|
||||
echo "url=$url" >> "$GITHUB_OUTPUT"
|
||||
echo "sha=$sha" >> "$GITHUB_OUTPUT"
|
||||
echo "PyPI JSON and simple indexes list $v"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
echo "sdist for $v not on PyPI yet (attempt $i), waiting..."
|
||||
sleep 20
|
||||
if [ "$i" -lt 30 ]; then
|
||||
echo "PyPI indexes do not both list $v yet (attempt $i), waiting..."
|
||||
sleep 20
|
||||
fi
|
||||
done
|
||||
echo "::error::sdist for $v did not appear on PyPI in time"
|
||||
echo "::error::PyPI JSON and simple indexes did not both list $v in time"
|
||||
exit 1
|
||||
|
||||
homebrew:
|
||||
@@ -114,10 +126,91 @@ jobs:
|
||||
needs: resolve
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Factory-rebuild the HuggingFace Space
|
||||
- name: Bump the Space pin, then factory-rebuild
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.RAIW_HF_SPACE_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
VERSION: ${{ needs.resolve.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
repo=wiltodelta/raiw-hf-space
|
||||
workflow=pin-library.yml
|
||||
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "RAIW_HF_SPACE_TOKEN secret is not set; skipping the pin bump."
|
||||
else
|
||||
dispatched_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
gh workflow run "$workflow" \
|
||||
--repo "$repo" \
|
||||
--ref main \
|
||||
--raw-field library_version="$VERSION"
|
||||
|
||||
run_id=""
|
||||
for attempt in $(seq 1 30); do
|
||||
run_id=$(
|
||||
gh run list \
|
||||
--repo "$repo" \
|
||||
--workflow "$workflow" \
|
||||
--event workflow_dispatch \
|
||||
--limit 10 \
|
||||
--json databaseId,createdAt \
|
||||
| python3 -c "import json,sys; t=sys.argv[1]; print(next((r['databaseId'] for r in json.load(sys.stdin) if r['createdAt']>=t), ''))" \
|
||||
"$dispatched_at"
|
||||
)
|
||||
if [ -n "$run_id" ]; then
|
||||
break
|
||||
fi
|
||||
echo "Space pin run not visible yet (attempt $attempt), waiting..."
|
||||
sleep 2
|
||||
done
|
||||
if [ -z "$run_id" ]; then
|
||||
echo "::error::The dispatched Space pin run did not appear"
|
||||
exit 1
|
||||
fi
|
||||
for attempt in $(seq 1 180); do
|
||||
read -r status conclusion <<< "$(
|
||||
gh run view "$run_id" \
|
||||
--repo "$repo" \
|
||||
--json status,conclusion \
|
||||
--jq '[.status, (.conclusion // "")] | @tsv'
|
||||
)"
|
||||
if [ "$status" = "completed" ]; then
|
||||
if [ "$conclusion" = "success" ]; then
|
||||
echo "Space pin run $run_id succeeded"
|
||||
break
|
||||
fi
|
||||
echo "::error::Space pin run $run_id completed with $conclusion"
|
||||
exit 1
|
||||
fi
|
||||
echo "Space pin run $run_id is $status (attempt $attempt), waiting..."
|
||||
sleep 5
|
||||
done
|
||||
if [ "${status:-}" != "completed" ]; then
|
||||
echo "::error::Space pin run $run_id did not finish in time"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Waiting for the Space requirements pin to show $VERSION..."
|
||||
pinned=""
|
||||
for attempt in $(seq 1 40); do
|
||||
reqs=$(curl -fsSL \
|
||||
"https://huggingface.co/spaces/wiltodelta/remove-ai-watermarks/raw/main/requirements.txt" \
|
||||
|| true)
|
||||
pinned=$(printf '%s\n' "$reqs" \
|
||||
| sed -n 's/^remove-ai-watermarks==\([0-9][0-9a-zA-Z.]*\).*/\1/p' \
|
||||
| head -1)
|
||||
echo " attempt $attempt: Space pin=$pinned"
|
||||
if [ "$pinned" = "$VERSION" ]; then
|
||||
break
|
||||
fi
|
||||
sleep 15
|
||||
done
|
||||
if [ "$pinned" != "$VERSION" ]; then
|
||||
echo "::error::Space requirements.txt still pinned to ${pinned:-<none>}, not $VERSION"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
python3 -m pip install --quiet huggingface_hub
|
||||
python3 - <<'PY'
|
||||
import os
|
||||
@@ -217,7 +310,12 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for attempt in $(seq 1 180); do
|
||||
# 0.40.0's node sync finished after this waiter: 180 * 5s is 15 min,
|
||||
# and the node suite plus registry upload ran longer. Keep the
|
||||
# workflow wait, then accept a registry row for this library version
|
||||
# so a late-but-published node is not reported as a missing release.
|
||||
version="${{ needs.resolve.outputs.version }}"
|
||||
for attempt in $(seq 1 360); do
|
||||
read -r status conclusion <<< "$(
|
||||
gh run view "$run_id" \
|
||||
--repo "$repo" \
|
||||
@@ -235,5 +333,39 @@ jobs:
|
||||
echo "ComfyUI sync run $run_id is $status (attempt $attempt), waiting..."
|
||||
sleep 5
|
||||
done
|
||||
echo "::error::ComfyUI sync run $run_id did not finish in time"
|
||||
echo "ComfyUI sync run $run_id did not finish in time; checking the registry."
|
||||
python3 -m pip install --quiet packaging
|
||||
if curl -fsSL "https://api.comfy.org/nodes/remove-ai-watermarks/versions" \
|
||||
| VERSION="$version" python3 -c '
|
||||
import json, os, sys
|
||||
from packaging.requirements import InvalidRequirement, Requirement
|
||||
from packaging.utils import canonicalize_name
|
||||
from packaging.version import Version
|
||||
version = os.environ["VERSION"]
|
||||
target = Version(version)
|
||||
def requires_release(value):
|
||||
try:
|
||||
requirement = Requirement(value)
|
||||
except InvalidRequirement:
|
||||
return False
|
||||
return (
|
||||
canonicalize_name(requirement.name) == "remove-ai-watermarks"
|
||||
and requirement.url is None
|
||||
and requirement.marker is None
|
||||
and target in requirement.specifier
|
||||
and any(s.operator == ">=" and Version(s.version) == target for s in requirement.specifier)
|
||||
)
|
||||
matches = [
|
||||
v for v in json.load(sys.stdin)
|
||||
if any(requires_release(d) for d in (v.get("dependencies") or []))
|
||||
]
|
||||
if not matches:
|
||||
sys.exit(1)
|
||||
newest = max(matches, key=lambda v: v.get("createdAt") or "")
|
||||
print("%s %s" % (newest.get("version"), newest.get("status")))
|
||||
'; then
|
||||
echo "Registry already lists a node for $version; the wait timed out after publish."
|
||||
exit 0
|
||||
fi
|
||||
echo "::error::ComfyUI sync run $run_id did not finish in time and the registry has no node for $version"
|
||||
exit 1
|
||||
@@ -6,8 +6,9 @@ name: Publish photo classifier to Hugging Face
|
||||
# Hub model: wiltodelta/raiw-photo-classify
|
||||
# Secret: HF_TOKEN, write-role Hugging Face token (same secret Space sync uses).
|
||||
#
|
||||
# Weights are not in git. Full mode downloads them from a GitHub Release
|
||||
# of this repository, then uploads with the card from docs/photo-classify-hf/.
|
||||
# Weights are not in git. Full mode downloads the frozen PyTorch files from a
|
||||
# GitHub Release, exports the vision-only ONNX graph, then uploads everything
|
||||
# with the card from docs/photo-classify-hf/.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -52,6 +53,14 @@ jobs:
|
||||
gh release download "$TAG" --repo "${{ github.repository }}" --dir weights
|
||||
ls -l weights
|
||||
|
||||
- name: Export the vision-only ONNX graph
|
||||
if: ${{ github.event.inputs.mode == 'full' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
uv run --extra classify --with onnx python scripts/export_photo_classify_onnx.py \
|
||||
--checkpoint weights/clip-l-ft.pt \
|
||||
--out weights/clip-l-ft-vision-fp32.onnx
|
||||
|
||||
- name: Publish to Hugging Face
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
name: Publish source classifier to Hugging Face
|
||||
|
||||
# Manual only. The derivative model weights live in a GitHub model-freeze
|
||||
# release; the private training corpus and caches never enter this repository.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
confirm:
|
||||
description: "Type publish"
|
||||
required: true
|
||||
weights_tag:
|
||||
description: "GitHub Release tag holding source-pipeline-mlp.npz"
|
||||
required: true
|
||||
default: source-classify-freeze-2026-09-16-r2
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
if: ${{ github.event.inputs.confirm == 'publish' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Download derivative model weights
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ github.event.inputs.weights_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p weights
|
||||
gh release download "$TAG" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--pattern source-pipeline-mlp.npz \
|
||||
--dir weights
|
||||
test -s weights/source-pipeline-mlp.npz
|
||||
|
||||
- name: Verify the frozen artifact
|
||||
run: |
|
||||
set -euo pipefail
|
||||
expected="$(jq -r .artifact_sha256 docs/source-classify-hf/metrics.json)"
|
||||
test "${#expected}" -eq 64
|
||||
echo "$expected weights/source-pipeline-mlp.npz" | sha256sum --check
|
||||
|
||||
- name: Publish to Hugging Face
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${HF_TOKEN:-}" ]; then
|
||||
echo "HF_TOKEN secret is not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
uv run --no-project --with huggingface-hub \
|
||||
python scripts/publish_source_classify_hf.py \
|
||||
--src weights \
|
||||
--message "Source-classify freeze (GitHub ${SHA:0:7})"
|
||||
@@ -31,7 +31,7 @@ Optional features and installation groups are documented in [`docs/installation.
|
||||
|
||||
`maintain.sh` runs dependency freshness and security checks, Ruff, Pyright scoped to `src/`, and the parallel test suite. Full-project Pyright is not the project gate because the ML dependency graph can exhaust Node memory.
|
||||
|
||||
The gate does not pass `--ignore-unfixed` to uv-secure, so a transitive CVE with no released fix stops it before Ruff, Pyright and the tests run, which a sibling repository that ignores unfixed findings would not do. The global rule applies: run and report those three separately. Scanner failures are fatal too. Triaged blockers and the recheck procedure: `Known security-gate blocks` in [`docs/development.md`](docs/development.md).
|
||||
The gate does not pass `--ignore-unfixed` to uv-secure, so a transitive CVE with no released fix stops it before Ruff, Pyright and the tests run. This matches the global rule: run and report those three separately. Scanner failures are fatal too. Triaged blockers and the recheck procedure: `Known security-gate blocks` in [`docs/development.md`](docs/development.md).
|
||||
|
||||
Command, gate, typing, and model-test invariants auto-load from [`.claude/rules/development.md`](.claude/rules/development.md). Environment recovery, CI behavior, and fixture policy live in [`docs/development.md`](docs/development.md).
|
||||
|
||||
@@ -43,7 +43,7 @@ The published Agent Skill is [`skills/remove-ai-watermarks/`](skills/remove-ai-w
|
||||
|
||||
[`docs/module-internals.md`](docs/module-internals.md) is the canonical per-module map, including design decisions, thresholds, calibration history, incident records, and regression guards. Read the relevant section before changing a subsystem.
|
||||
|
||||
Research and current constraints are routed through [`docs/index.md`](docs/index.md), especially [`docs/known-limitations.md`](docs/known-limitations.md), [`docs/supported-signals.md`](docs/supported-signals.md), [`docs/synthid.md`](docs/synthid.md), and [`docs/watermarking-landscape.md`](docs/watermarking-landscape.md). Pixel photo classification is [`docs/photo-classify.md`](docs/photo-classify.md); it is not `identify`. Classifier research is split between general [`AI-generated image classifiers`](docs/ai-generated-image-classifiers.md) and [`SynthID source classifiers`](docs/synthid-classifiers.md). Other SynthID campaign logs are [`docs/synthid-detector-research.md`](docs/synthid-detector-research.md) and [`docs/synthid-removal-research.md`](docs/synthid-removal-research.md). The pre-split chronological archive is [`docs/synthid-detector-removal-plan.md`](docs/synthid-detector-removal-plan.md). The benchmark kernel and its pinned local oracles (AudioSeal, VideoSeal) cover the audio/video watermark cohorts and research studies: [`docs/benchmark-kernel.md`](docs/benchmark-kernel.md).
|
||||
Research and current constraints are routed through [`docs/index.md`](docs/index.md), especially [`docs/known-limitations.md`](docs/known-limitations.md), [`docs/supported-signals.md`](docs/supported-signals.md), [`docs/synthid.md`](docs/synthid.md), and [`docs/watermarking-landscape.md`](docs/watermarking-landscape.md). Pixel photo classification is [`docs/photo-classify.md`](docs/photo-classify.md); the separate OpenAI/Google/unknown export-pipeline signal is [`docs/source-classify.md`](docs/source-classify.md). Neither runs inside `identify` or constitutes SynthID detection. Classifier research is split between general [`AI-generated image classifiers`](docs/ai-generated-image-classifiers.md) and [`SynthID source classifiers`](docs/synthid-classifiers.md). Other SynthID campaign logs are [`docs/synthid-detector-research.md`](docs/synthid-detector-research.md) and [`docs/synthid-removal-research.md`](docs/synthid-removal-research.md). The pre-split chronological archive is [`docs/synthid-detector-removal-plan.md`](docs/synthid-detector-removal-plan.md). The benchmark kernel and its pinned local oracles (AudioSeal, VideoSeal) cover the audio/video watermark cohorts and research studies: [`docs/benchmark-kernel.md`](docs/benchmark-kernel.md).
|
||||
|
||||
## Data safety
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Remove AI provenance marks from images and video you generated yourself:
|
||||
|
||||
- known visible labels such as the Google Gemini sparkle watermark and vendor text marks;
|
||||
- invisible pixel watermarks through diffusion regeneration;
|
||||
- invisible pixel watermarks through direct local-format disruption or diffusion regeneration;
|
||||
- C2PA, EXIF, XMP, IPTC, and related AI metadata.
|
||||
|
||||
Video support covers provenance identification, complete visible-plus-metadata
|
||||
@@ -49,7 +49,9 @@ removal.
|
||||
|
||||
Microsoft Paint and Photos InvisMark declarations are routed automatically to
|
||||
pixel regeneration. The `all` command removes both the hidden pixel watermark and
|
||||
its linked C2PA manifest; metadata stripping alone removes only the manifest.
|
||||
its linked C2PA manifest; metadata stripping alone removes only the manifest. A
|
||||
specialized Python API can inspect and disrupt the validated local
|
||||
`Watermarker.dll` payload without diffusion.
|
||||
|
||||
## Installation modes
|
||||
|
||||
@@ -57,15 +59,17 @@ its linked C2PA manifest; metadata stripping alone removes only the manifest.
|
||||
| --- | --- |
|
||||
| Metadata inspection and stripping | `remove-ai-watermarks` |
|
||||
| Photograph AI-versus-camera classification | `remove-ai-watermarks[classify]` |
|
||||
| OpenAI/Google/unknown source-export classification | `remove-ai-watermarks[source-classify]` |
|
||||
| Visible detection and removal | `remove-ai-watermarks[visible]` |
|
||||
| Visible video processing | `remove-ai-watermarks[video]` |
|
||||
| Video SynthID removal | `remove-ai-watermarks[video,diffusion]` |
|
||||
| Torch-free DWT-DCT detection | `remove-ai-watermarks[detect]` |
|
||||
| Direct local Paint InvisMark disruption | `remove-ai-watermarks[pixels]` |
|
||||
| Invisible image removal (needs CUDA) | `remove-ai-watermarks[qwen-zimage]` |
|
||||
| Every production feature available on the active Python | `remove-ai-watermarks[all]` |
|
||||
|
||||
Lower-level and specialized extras include `pixels`, `heif`, `trustmark`,
|
||||
`migan`, `lama`, and `diffusion`. The
|
||||
`migan`, `lama`, `diffusion`, `classify-onnx`, and `source-classify`. The
|
||||
[installation guide](docs/installation.md#feature-extras) documents their exact
|
||||
dependency composition, Python compatibility, and model requirements.
|
||||
|
||||
@@ -93,6 +97,19 @@ remove-ai-watermarks classify image.png
|
||||
|
||||
Guide: [photo pixel classification](docs/photo-classify.md).
|
||||
|
||||
For a lightweight, abstaining OpenAI/Google/unknown source-export signal after
|
||||
metadata removal, use the separate Python API. It is not a SynthID detector:
|
||||
|
||||
```python
|
||||
import remove_ai_watermarks as raiw
|
||||
|
||||
result = raiw.classify_source("image.png")
|
||||
print(result.label, result.reason)
|
||||
```
|
||||
|
||||
Install `remove-ai-watermarks[source-classify]`. Guide:
|
||||
[source-pipeline classification](docs/source-classify.md).
|
||||
|
||||
Signed provenance is the supported route for SynthID and `identify` reads it.
|
||||
There is no local SynthID pixel detector in the package. Research on a
|
||||
periodic lattice expert is in `scripts/synthid_runtime/` and
|
||||
@@ -133,6 +150,8 @@ inspection includes the native TC260 `AIGC` tag in
|
||||
`moov.udta.meta.keys/ilst`, including a `moov` placed after the media payload,
|
||||
plus the QuickTime-form `meta` variants Doubao's iOS export writes (a bare
|
||||
`meta` box as a direct `moov` child, and a keyless `hdlr=mdir` metadata list).
|
||||
Keyed `workflow` and `prompt` entries in the same metadata-list structure are
|
||||
also detected and removed, including ComfyUI exports.
|
||||
MKV and WebM inspection reads the normative
|
||||
`Segment.Tags.Tag.SimpleTag` placement. AVI uses `LIST/INFO/AIGC`, while FLV
|
||||
uses `script.onMetaData.AIGC`. The non-ISOBMFF formats are remuxed with stream
|
||||
@@ -211,8 +230,10 @@ is not a product result state. The CLI therefore describes video-pixel
|
||||
regeneration instead of claiming a per-file SynthID verdict, and reports the
|
||||
copied audio watermark as `UNVERIFIED`.
|
||||
|
||||
For invisible watermark removal, install the `qwen-zimage` extra. **An NVIDIA GPU
|
||||
is required**: all profiles are CUDA-only, and there is no CPU or MPS fallback.
|
||||
For invisible watermark removal through the CLI or high-level API, install the
|
||||
`qwen-zimage` extra. **An NVIDIA GPU is required**: all diffusion profiles are
|
||||
CUDA-only, and there is no CPU or MPS fallback. The specialized local Paint API
|
||||
uses the `pixels` extra without CUDA.
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[qwen-zimage]"
|
||||
@@ -265,8 +286,8 @@ ControlNet, followed by SAM-masked Z-Image repair of any detected face. The
|
||||
alternative, `sdxl-zimage`, swaps the global stage for SDXL and keeps the same face
|
||||
stage. A third profile, `chroma-zimage`, uses the Apache-2.0 Chroma1 global pass
|
||||
with its own flat vendor floors; see `docs/chroma1-engine-research.md` for the
|
||||
calibration. `--pipeline auto` picks chroma-zimage for Microsoft
|
||||
provenance and qwen-zimage otherwise. All are CUDA only.
|
||||
calibration. `--pipeline auto` picks sdxl-zimage for Google, chroma-zimage for
|
||||
Microsoft, and qwen-zimage otherwise. All are CUDA only.
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[qwen-zimage]"
|
||||
@@ -350,9 +371,9 @@ Visible mark support includes:
|
||||
- one calibrated Samsung Galaxy AI label variant.
|
||||
|
||||
Metadata and provenance inspection covers C2PA, EXIF, XMP, IPTC, common
|
||||
generator parameters, China TC260 AIGC labels, and several vendor specific
|
||||
signals. Optional decoders add support for open DWT-DCT watermarks and Adobe
|
||||
TrustMark.
|
||||
generator parameters in image and video containers, China TC260 AIGC labels,
|
||||
and several vendor specific signals. Optional decoders add support for open
|
||||
DWT-DCT watermarks and Adobe TrustMark.
|
||||
|
||||
The exact support matrix, including important locale and detector limits, lives
|
||||
in [supported signals](docs/supported-signals.md). The
|
||||
@@ -369,13 +390,14 @@ Visible removal follows three steps:
|
||||
|
||||
Metadata removal uses format aware stripping. JPEG metadata removal preserves
|
||||
the encoded image scan instead of recompressing it. Native MP4/MOV TC260 values
|
||||
are blanked without changing box sizes or media offsets. Other supported
|
||||
containers use their corresponding metadata path.
|
||||
and keyed generation parameters are blanked without changing box sizes or
|
||||
media offsets. Other supported containers use their corresponding metadata
|
||||
path.
|
||||
|
||||
Invisible removal is different. It regenerates the image through a diffusion
|
||||
pipeline to disrupt pixel and frequency domain watermarks. This changes the
|
||||
image and cannot guarantee that a proprietary verifier will reject every
|
||||
output.
|
||||
Invisible removal through the CLI and high-level API regenerates the image through
|
||||
a diffusion pipeline. Specialized callers can instead decode and disrupt the
|
||||
positively identified local Microsoft Paint format with much smaller pixel changes.
|
||||
Neither path can guarantee that every proprietary verifier will reject every output.
|
||||
|
||||
See [supported signals](docs/supported-signals.md) and
|
||||
[known limitations](docs/known-limitations.md) for the full technical boundary.
|
||||
@@ -470,9 +492,10 @@ invisible removal.
|
||||
The shipped profile is oracle-certified, but no public local decoder can
|
||||
certify an arbitrary output at runtime. Recheck unusually important outputs
|
||||
after provider changes.
|
||||
- Invisible-watermark removal requires CUDA. All profiles refuse any other
|
||||
device at construction rather than falling back to one that cannot run them.
|
||||
Visible removal, metadata stripping and `identify` still run anywhere.
|
||||
- Invisible-watermark removal through the CLI and high-level API requires CUDA.
|
||||
All diffusion profiles refuse any other device at construction rather than
|
||||
falling back to one that cannot run them. The specialized local Paint API,
|
||||
visible removal, metadata stripping and `identify` run without CUDA.
|
||||
- Provider watermark systems can change. Validate important outputs with the
|
||||
provider's own verifier when one is available.
|
||||
|
||||
|
||||
@@ -63,16 +63,16 @@ the vendors' direct consumer services.
|
||||
`qwen-create-qwen-image-2.png` is the original 2048 x 2048 PNG downloaded from
|
||||
an authenticated Qwen Create generation on 2026-09-03. It was generated by
|
||||
`Qwen-Image 2.0` at 1:1 using the neutral still-life prompt recorded above.
|
||||
The current global consumer surface differs from the supported Chinese Qwen
|
||||
text mark: it places a small Qwen symbol at the bottom right, not
|
||||
`千问AI生成`. The existing Qwen text detector therefore correctly does not
|
||||
claim this new shape (measured strict confidence 0.178).
|
||||
The current global consumer surface differs from the Chinese Qwen text mark: it
|
||||
places a small Qwen symbol at the bottom right, not `千问AI生成`. The Qwen
|
||||
engine has separate text and symbol templates; the symbol path scores 0.848 on
|
||||
this fixture while the text path alone scores 0.178.
|
||||
|
||||
- SHA-256: `60c39ed8aedb081193c62289be549785e74d31fc8d071961003fabd74712429d`
|
||||
- Expected metadata signal: TC260 AIGC label
|
||||
- Expected `ContentProducer`: `001191440101MA9Y9T4H7A00000`
|
||||
- Expected visible result: no registered visible mark; the Qwen symbol is a
|
||||
newly observed, currently unsupported variant
|
||||
- Expected visible result: Qwen symbol detected and removable under the `qwen`
|
||||
registry key
|
||||
- License: repository license; generated and explicitly cleared by the
|
||||
maintainer
|
||||
- Qwen Create terms: <https://terms.alicdn.com/legal-agreement/terms/c_end_product_protocol/20260519200335649/20260519200335649.html>
|
||||
|
||||
+55
-21
@@ -41,50 +41,61 @@ Do these in order after the skill is on the default branch.
|
||||
Cursor, OpenCode, Pi, Codex, and Copilot have no separate skill store; they
|
||||
consume this repo or a copied `SKILL.md`.
|
||||
2. **SkillsMP.** Aggregates public GitHub `SKILL.md` files. No submit form. A
|
||||
search for `remove-ai-watermarks` was empty before the first public push.
|
||||
Neighboring `watermark` hits are PDF-add and detection skills, not removal.
|
||||
search for `remove-ai-watermarks` now returns this skill. Neighboring
|
||||
`watermark` hits are PDF-add and detection skills, not removal.
|
||||
3. **skills.sh.** No submit form. A row appears on the leaderboard only after
|
||||
people run `npx skills add`. The README install line is the seed, not a
|
||||
registration.
|
||||
people run `npx skills add`. The listing is live and has recorded real
|
||||
installs; the README install line remains the seed, not a registration.
|
||||
4. **Awesome-list PRs.** Hand-curated GitHub lists are how people browse skills
|
||||
today. One accurate PR per list, with the scope sentence intact.
|
||||
- `ComposioHQ/awesome-claude-skills`: submitted 2026-09-05 as PR 1841
|
||||
(Creative & Media); no maturity bar in its contribution rules.
|
||||
(Creative & Media); no maturity bar in its contribution rules. Rechecked
|
||||
2026-09-11: still open, CI green, no review comments, waiting on the
|
||||
list maintainers.
|
||||
- `heilcheng/awesome-agent-skills`: submitted 2026-09-09 as PR 483 under
|
||||
Community Skills > Media. The skill's three concrete input/output examples
|
||||
satisfy the list's working-example requirement. Rechecked 2026-09-11:
|
||||
still open; the only check failure is their Vercel deploy authorization,
|
||||
which a contributor cannot grant.
|
||||
- `VoltAgent/awesome-agent-skills`: DEFERRED. Its CONTRIBUTING rejects
|
||||
brand-new skills ("give your skill time to mature and gain users before
|
||||
submitting"). Re-attempt after the skill has real installs and a
|
||||
release that ships it (0.38.0+).
|
||||
submitting"). The skill now has real installs and shipped releases, but
|
||||
adoption remains too small to make the list's maturity claim comfortably.
|
||||
- `VoltAgent/awesome-openclaw-skills`: DEFERRED on adoption only. Entries
|
||||
are indexed via clawskills.sh, and the ClawHub prerequisite in step 6 is
|
||||
now met. The list still asks contributors not to submit skills "created
|
||||
3 hours ago", and `clawhub search` reports 0 installs over 60 days.
|
||||
Re-attempt once the listing shows real installs.
|
||||
now met. The list still requires proven real-world usage. Re-attempt once
|
||||
the ClawHub listing shows organic installs.
|
||||
5. **Claude community marketplace.** Form at
|
||||
[platform.claude.com/plugins/submit](https://platform.claude.com/plugins/submit)
|
||||
(individuals) or the claude.ai directory form (Team/Enterprise). Validate
|
||||
with `claude plugin validate` first. SUBMITTED 2026-09-07 for Claude Code
|
||||
only (strict validation passed; Apache 2.0; description carries the
|
||||
scope boundary; platform selection limited to Claude Code because Cowork
|
||||
was not tested). Pending review; the team may reach out by email. The
|
||||
official Anthropic marketplace is
|
||||
curated and has no application.
|
||||
was not tested). Pending review; the team may reach out by email.
|
||||
Rechecked 2026-09-11: `platform.claude.com/plugins` is 404, and
|
||||
`claude.ai/directory` is the MCP connector catalog, not plugins; a search
|
||||
there does not list this skill. Do not open a second submission. The
|
||||
official Anthropic marketplace is curated and has no application.
|
||||
6. **ClawHub.** `clawhub login` then `clawhub skill publish` from
|
||||
`skills/remove-ai-watermarks/`. LISTED 2026-09-09: version 1.0.5, submitted
|
||||
by the `clawhub` job in `distribute.yml` during the 0.39.0 release, is
|
||||
public. A green job proves only a submission, so read the catalog itself:
|
||||
`skills/remove-ai-watermarks/`. LISTED 2026-09-09: versions 1.0.5, 1.0.6,
|
||||
and 1.0.8, submitted by the `clawhub` job in `distribute.yml` during the
|
||||
0.39.0, 0.39.1, and 0.40.0 releases, are public. A green job proves only a
|
||||
submission, so read the catalog itself:
|
||||
`clawhub search remove-ai-watermarks` returns the row and
|
||||
`https://clawhub.ai/api/v1/skills/remove-ai-watermarks` reports
|
||||
`tags.latest` 1.0.5 with a single published version. The earlier 1.0.3 and
|
||||
1.0.4 submissions never became public and do not appear in that version
|
||||
list, so they were bypassed rather than recovered. The issue that tracked
|
||||
them, openclaw/clawhub#3643, was closed by the reporter on 2026-09-09 once
|
||||
`latestVersion.version` 1.0.8, while its `/versions` endpoint returns
|
||||
1.0.5, 1.0.6, and 1.0.8. The earlier 1.0.3 and 1.0.4 submissions never became
|
||||
public and do not appear in that version list, so they were bypassed rather
|
||||
than recovered. 1.0.7 was a post-0.39.1 bump that never reached ClawHub.
|
||||
The issue that tracked 1.0.3 and 1.0.4, openclaw/clawhub#3643, was closed
|
||||
by the reporter on 2026-09-09 once
|
||||
1.0.5 went through; nothing upstream was confirmed fixed, and it is the
|
||||
fifth report of this shape (#3624, #3466, #3351, #3284), so treat a stalled
|
||||
submission as recurring and file a fresh one rather than assuming the
|
||||
pipeline is repaired. Release-time publishing is automated (see
|
||||
[release-and-distribution.md](release-and-distribution.md)), and the job
|
||||
fires only on a published GitHub Release: a skill version bumped after a
|
||||
release, as 1.0.6 was, reaches ClawHub with the next one. Do not publish by
|
||||
release reaches ClawHub with the next one. Do not publish by
|
||||
hand to close that gap. The listing records the license as MIT-0 while
|
||||
`SKILL.md` declares Apache-2.0, and that is not a listing error to repair:
|
||||
MIT-0 is a platform-wide constant in ClawHub's own client
|
||||
@@ -107,6 +118,29 @@ Do these in order after the skill is on the default branch.
|
||||
execution, arbitrary file access, and optional hardware access, which the
|
||||
official guide routes through that product-specific review. Convert with
|
||||
[Submit your Claude Code plugin to OpenAI](https://developers.openai.com/plugins/guides/submit-claude-plugin).
|
||||
8. **AgenticSkills.** SUBMITTED 2026-09-09 as review issue 174 under AI/ML
|
||||
Development. The submission carries the user-owned-content boundary and
|
||||
links to the repository rather than hosting a separate copy. Rechecked
|
||||
2026-09-11: the public index still does not list it (`/skills/remove-ai-watermarks`
|
||||
is 404; search for `wiltodelta` returns 0 of 192). Their form promises a
|
||||
48-hour review; that window has passed. Issue 174 is not a public GitHub
|
||||
issue. Do not open a second submission. Followed up 2026-09-11 through
|
||||
their contact form as a listing correction.
|
||||
9. **AI Skills Catalog.** SUBMITTED 2026-09-09 as reference 2 under Video Media,
|
||||
with Codex as the primary agent and every supported harness marked compatible.
|
||||
Rechecked 2026-09-11: the public catalog at skillscatalog.ai shows 0 skills
|
||||
from 0 publishers, `/api/featured` returns 401, and `/api/auth/session`
|
||||
returns 500, so a stranger cannot confirm the listing. Do not open a
|
||||
duplicate while that initial review is pending. Once accepted, treat
|
||||
material skill-version updates as manual because the catalog stores
|
||||
compatible packages in addition to source metadata and does not promise
|
||||
that a GitHub change refreshes those packages. Use the update route
|
||||
supplied with acceptance; if none is supplied, contact the catalog or
|
||||
resubmit, then verify the live result.
|
||||
|
||||
The conditional update actions for every catalog are part of the canonical
|
||||
release checklist in
|
||||
[Release and distribution](release-and-distribution.md#agent-skill).
|
||||
|
||||
Do not treat Smithery or other MCP directories as skill catalogs. Do not open
|
||||
a second near-identical skill to farm installs. The conversion path that
|
||||
|
||||
+22
-13
@@ -78,6 +78,9 @@ no-signal metadata scan. Install the extra first:
|
||||
uv tool install --force "remove-ai-watermarks[classify]"
|
||||
```
|
||||
|
||||
The optional CPU ONNX backend is library-only
|
||||
(`classify_pixels(..., backend="onnx")`, extra `classify-onnx`), not a CLI flag.
|
||||
|
||||
Then:
|
||||
|
||||
```bash
|
||||
@@ -313,10 +316,11 @@ delegates to the same verified metadata scanner and stripper as the generic
|
||||
streams are not transcoded. For MP4 and MOV, this includes the native TC260
|
||||
`AIGC` key and JSON value stored in `moov.udta.meta.keys/ilst`, plus the
|
||||
QuickTime-form `meta` variants Doubao's iOS export writes (a bare `meta` box
|
||||
as a direct `moov` child, and a keyless `hdlr=mdir` metadata list). The inspector
|
||||
seeks past a large `mdat` to find a tail `moov`. Removal stream-copies the
|
||||
as a direct `moov` child, and a keyless `hdlr=mdir` metadata list). Keyed
|
||||
`workflow` and `prompt` entries in the same metadata list are also inspected.
|
||||
The inspector seeks past a large `mdat` to find a tail `moov`. Removal stream-copies the
|
||||
container in bounded chunks, converts supported top-level provenance boxes to
|
||||
same-size `free` boxes, and blanks the TC260 key/value in place. Box sizes,
|
||||
same-size `free` boxes, and blanks the TC260 or generation key/value in place. Box sizes,
|
||||
media offsets, and encoded stream bytes do not move; the result is atomically
|
||||
published only after the complete copy succeeds.
|
||||
|
||||
@@ -465,10 +469,10 @@ remove-ai-watermarks invisible image.png -o clean.png --force
|
||||
### Choose a strength cohort
|
||||
|
||||
`--vendor` selects the cohort the default strength resolves from. `auto`
|
||||
(the default) derives it from provenance: the C2PA issuer for
|
||||
OpenAI/Google/Microsoft, and the standalone AI IPTC tag for Meta Content Seal
|
||||
(Muse output carries no C2PA; the tag is a standard code, so C2PA evidence
|
||||
always wins first). An explicit value both names the cohort for stripped files
|
||||
(the default) derives it from product-specific C2PA provenance for
|
||||
OpenAI/Google/Microsoft. The standalone AI IPTC tag is a shared standard and
|
||||
does not select Meta; use `--vendor meta` only when the source is independently
|
||||
known. An explicit value both names the cohort for stripped files
|
||||
and implies the scrub runs -- naming the cohort asserts the pixel watermark is
|
||||
present -- exactly like `--force` plus a measured floor:
|
||||
|
||||
@@ -486,7 +490,7 @@ The same option exists on `all` and `batch`, and as
|
||||
| `qwen-zimage` | Default. Qwen-Image-2512 global pass plus a SAM-masked Z-Image face stage |
|
||||
| `sdxl-zimage` | The same recipe and face stage on an SDXL global pass, at a higher denoise |
|
||||
| `chroma-zimage` | The same face stage on an Apache-2.0 Chroma1 global pass; explicit OpenAI use needs a higher floor than qwen-zimage |
|
||||
| `auto` | Pick the engine from provenance: chroma-zimage for Microsoft, qwen-zimage for OpenAI, Google, Meta, and unknown |
|
||||
| `auto` | Pick the engine from provenance: sdxl-zimage for Google, chroma-zimage for Microsoft, qwen-zimage for OpenAI, unknown, and an explicitly selected Meta cohort |
|
||||
|
||||
**All four are CUDA-only.** There is no CPU or MPS profile for invisible-watermark
|
||||
removal. The former `controlnet`, `sdxl`, `qwen` and `default` profiles were removed
|
||||
@@ -522,9 +526,13 @@ Install the combined extra and run only with an operator-verified manifest:
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[text-restoration]"
|
||||
remove-ai-watermarks invisible image.png -o clean.png \
|
||||
--pipeline auto --text-manifest verified-lines.json --force
|
||||
--pipeline qwen-zimage --text-manifest verified-lines.json --force
|
||||
```
|
||||
|
||||
The option works with `qwen-zimage`, `sdxl-zimage`, `chroma-zimage`, and `auto`.
|
||||
Each concrete profile reconstructs the donor through its own already loaded VAE;
|
||||
for Google provenance, `auto` therefore keeps the normal SDXL route.
|
||||
|
||||
``verified: true`` may also be set by an automated operator that attests
|
||||
machine-verified geometry: stability-gated detector boxes inside sane caps. Such
|
||||
operators should use the geometry-only schema 2, which carries no transcription
|
||||
@@ -548,10 +556,11 @@ container changes remain valid while a resized or edited source fails closed. Th
|
||||
experimental helper
|
||||
`remove_ai_watermarks._internal.text_restoration.source_pixel_sha256` computes it.
|
||||
|
||||
This mode is supported by `qwen-zimage`, `chroma-zimage`, and `auto` at native
|
||||
geometry with `humanize=0`, `unsharp=0`, and adaptive polish disabled. The legacy
|
||||
`sdxl-zimage` profile does not expose a verified-text VAE donor. `all` also accepts the flag,
|
||||
but its manifest must match the pixels entering the invisible stage; if visible-mark
|
||||
This mode is supported by every profile at native geometry with `humanize=0` and
|
||||
`unsharp=0`. An unset adaptive-polish option is forced off so verified text remains
|
||||
the final pixel-writing stage; an explicit request to enable it is rejected. `all`
|
||||
also accepts the flag, but its
|
||||
manifest must match the pixels entering the invisible stage; if visible-mark
|
||||
removal changes those pixels, the hash check rejects the run. One oracle verdict does
|
||||
not certify another manifest, seed, model/runtime version, or output hash.
|
||||
|
||||
|
||||
+19
-16
@@ -29,26 +29,29 @@ Do not add advisory ignores or suppress the scanner's exit status. Run and
|
||||
report Ruff, Pyright scoped to `src/`, and tests separately when security blocks
|
||||
the gate.
|
||||
|
||||
Rechecked on 2026-09-08 against PyPI release metadata and the advisory sources:
|
||||
Rechecked on 2026-09-10 against the locked versions and the advisory sources:
|
||||
|
||||
- `lightning`, pulled by the optional `trustmark` extra, remains affected by
|
||||
[PYSEC-2026-3624](https://github.com/pypa/advisory-database/blob/main/vulns/lightning/PYSEC-2026-3624.yaml)
|
||||
and its alias [GHSA-qqmf-gpg7-g8gw](https://github.com/advisories/GHSA-qqmf-gpg7-g8gw).
|
||||
These describe one checkpoint-loading vulnerability. The latest PyPI release
|
||||
is still affected; the advisory's `2022.6.15` fix entry is not a newer release
|
||||
on the supported 2.x line. The adapter selects TrustMark's supplied weights,
|
||||
rather than accepting a caller-supplied Lightning checkpoint.
|
||||
- `lightning` and `pytorch-lightning`, pulled by the optional `trustmark`
|
||||
extra, are pinned to 2.6.6. That release clears PYSEC-2026-3624 and
|
||||
PYSEC-2026-3967; do not reintroduce an older transitive resolution.
|
||||
- `accelerate`, required by the diffusion stack, is affected by
|
||||
[GHSA-4j2p-28q2-5m79](https://github.com/advisories/GHSA-4j2p-28q2-5m79).
|
||||
The sharded-checkpoint loader accepts unsafe `weight_map` paths; the advisory
|
||||
lists no patched release. The project has no direct call to
|
||||
`load_checkpoint_in_model` or `load_checkpoint_and_dispatch`, but this does
|
||||
not prove indirect model-loading paths are unaffected.
|
||||
[GHSA-4j2p-28q2-5m79](https://github.com/advisories/GHSA-4j2p-28q2-5m79)
|
||||
and PYSEC-2026-3804. The sharded-checkpoint loader accepts unsafe
|
||||
`weight_map` paths; neither advisory lists a patched release. 1.15.0 is
|
||||
on PyPI and is outside the advisory's `<= 1.14.0` range, but its
|
||||
`load_checkpoint_in_model` still joins `weight_map` entries with no
|
||||
containment check, and the sanitizer PRs
|
||||
([#4138](https://github.com/huggingface/accelerate/pull/4138),
|
||||
[#4214](https://github.com/huggingface/accelerate/pull/4214)) did not
|
||||
land. Do not bump solely to silence the scanner. The project has no
|
||||
direct call to `load_checkpoint_in_model` or
|
||||
`load_checkpoint_and_dispatch`, but this does not prove indirect
|
||||
model-loading paths are unaffected.
|
||||
|
||||
Before carrying either block forward, rerun `uvx uv-secure uv.lock` and check
|
||||
Before carrying the remaining block forward, rerun `uvx uv-secure uv.lock` and check
|
||||
PyPI for a fixed release. Upgrade a fixed package with
|
||||
`uv lock --upgrade-package <package>` and rerun the gate. Removing either
|
||||
current dependency would remove a supported optional feature, so that is not a
|
||||
`uv lock --upgrade-package <package>` and rerun the gate. Removing the current
|
||||
dependency would remove a supported optional feature, so that is not a
|
||||
maintenance-only repair.
|
||||
|
||||
Minor-only lock refreshes must retain each package's current major and lower
|
||||
|
||||
@@ -13,6 +13,7 @@ to run the tool. Use the maintainer references only when changing the code.
|
||||
| [Supported signals](supported-signals.md) | You need to know which visible marks, metadata formats, and invisible signals are covered. |
|
||||
| [Known limitations](known-limitations.md) | You need the quality, device, format, or verification boundaries. |
|
||||
| [Photo pixel classification](photo-classify.md) | You want `classify` / `classify_pixels`: AI versus camera, optional provider. |
|
||||
| [Source-pipeline classification](source-classify.md) | You want an abstaining OpenAI/Google/unknown export-pipeline signal after metadata removal. |
|
||||
| [Photo classifier training](photo-classify-training.md) | You need the retrain pack, public sources, and what is not published. |
|
||||
| [Scope, safety, and legal notes](legal-and-safety.md) | You need the intended use and legal context. |
|
||||
|
||||
@@ -42,6 +43,7 @@ The current behavior is defined by the code, tests, README, and user guides.
|
||||
- [AI-generated image classifiers](ai-generated-image-classifiers.md) (photo AI-versus-camera freeze consumed by `classify_pixels`, not `identify`)
|
||||
- [Photo classifier Hugging Face card](photo-classify-hf/README.md) (Hub publication text of the 2026-08-31 freeze)
|
||||
- [SynthID source classifiers](synthid-classifiers.md) (metadata-free OpenAI/Gemini source finding and provider-lineage experiments)
|
||||
- [Source-pipeline classifier model card](source-classify-hf/README.md) (published derivative weights, aggregate evaluation, and intended-use boundary)
|
||||
- [SynthID local detector research](synthid-detector-research.md)
|
||||
- [SynthID mark removal research](synthid-removal-research.md)
|
||||
- [SynthID identity research](synthid-robust-identity-research.md)
|
||||
|
||||
+13
-6
@@ -94,11 +94,13 @@ Transformers cannot construct the Chroma tokenizer. It includes the `diffusion`
|
||||
dependencies; `diffusion` on its own covers the torch and diffusers imports but
|
||||
not the face stage or Chroma tokenizer, so it is not enough to run a removal.
|
||||
|
||||
**An NVIDIA GPU is required.** `qwen-zimage`, `sdxl-zimage` and `chroma-zimage`
|
||||
are CUDA-only, and
|
||||
**An NVIDIA GPU is required for the diffusion profiles.** `qwen-zimage`,
|
||||
`sdxl-zimage` and `chroma-zimage` are CUDA-only, and
|
||||
construction refuses any other device rather than falling back to a slow or broken
|
||||
one. There is no CPU, MPS or XPU path for invisible-watermark removal. Visible-mark
|
||||
removal, metadata stripping and every `identify` command still run anywhere.
|
||||
one. There is no CPU, MPS or XPU path for those profiles. The specialized API for
|
||||
the validated local Microsoft Paint format uses the `pixels` extra and no GPU.
|
||||
Visible-mark removal, metadata stripping and every `identify` command still run
|
||||
anywhere.
|
||||
|
||||
Video SynthID regeneration is a separate VAE path and does still run on CPU or MPS;
|
||||
it needs the `diffusion` extra, not this one.
|
||||
@@ -119,13 +121,15 @@ application actually uses:
|
||||
|
||||
| Extra | Capability | Automatically includes | Torch or model download |
|
||||
| --- | --- | --- | --- |
|
||||
| `pixels` | Shared BGR image runtime | NumPy, headless OpenCV | No |
|
||||
| `pixels` | Shared BGR image runtime and direct local Paint InvisMark disruption | NumPy, headless OpenCV | No |
|
||||
| `heif` | HEIC, HEIF, and AVIF pixel decoding | pillow-heif | No |
|
||||
| `visible` | Visible mark detection, OpenCV inpainting, and manual erasing | `pixels` | No |
|
||||
| `video` | Visible video identification/removal and timestamp preservation | `visible`, PyAV | No |
|
||||
| `detect` | Open DWT-DCT detection for Stable Diffusion, SDXL, and FLUX | `pixels`, PyWavelets | No |
|
||||
| `trustmark` | Adobe TrustMark detection on Python 3.11-3.12 | trustmark | Yes |
|
||||
| `classify` | Metadata-free photo AI-versus-camera classifier plus gated provider | `pixels`, Torch, Transformers | Yes |
|
||||
| `classify-onnx` | Optional CPU-only ONNX vision runtime for `classify` | `classify`, ONNX Runtime | Yes |
|
||||
| `source-classify` | Abstaining OpenAI/Google/unknown source-export classifier, not SynthID detection | `pixels`, Hugging Face Hub | Model download, no Torch |
|
||||
| `diffusion` | Torch and Diffusers runtime; video SynthID regeneration | `pixels`, Torch, Diffusers | Yes |
|
||||
| `migan` | MI-GAN ONNX fill backend | `visible`, ONNX Runtime | Model download, no Torch |
|
||||
| `lama` | big-LaMa ONNX fill backend | `visible`, ONNX Runtime | Model download, no Torch |
|
||||
@@ -152,12 +156,15 @@ flowchart LR
|
||||
heif
|
||||
trustmark
|
||||
classify --> pixels
|
||||
onnx["classify-onnx"] --> classify
|
||||
source["source-classify"] --> pixels
|
||||
```
|
||||
|
||||
`heif`, `trustmark`, and `text-draft` are independent branches. Combine them
|
||||
explicitly with another feature when required. `text-draft` is excluded from
|
||||
`all` because it proposes unverified OCR annotations and is not a production
|
||||
removal path. The TrustMark branch remains available only on Python 3.11-3.12.
|
||||
removal path. `classify-onnx` is excluded from `all`; install it only for `backend="onnx"`.
|
||||
The TrustMark branch remains available only on Python 3.11-3.12.
|
||||
Its supported dependency floor includes releases requiring NumPy 1.x, which has
|
||||
no CPython 3.13 or 3.14 wheels; newer TrustMark releases relaxing that requirement
|
||||
do not by themselves widen this package's declared support range. The `all` bundle contains every production branch compatible with
|
||||
|
||||
+55
-29
@@ -20,6 +20,14 @@ The command is never started by `identify`. A no-signal provenance result stays
|
||||
unknown. This is not a clean verdict and it does not run cleanup. Full guide:
|
||||
[photo pixel classification](photo-classify.md).
|
||||
|
||||
`classify_source` is a different, lightweight OpenAI/Google/unknown
|
||||
source-export classifier. It is useful only while the pixels still resemble an
|
||||
original export. A 95% resize reduced measured Google recall to 1%, and a JPEG
|
||||
quality-75 round trip made every public evaluation row abstain. Its provider
|
||||
label is not evidence that SynthID is present, and `unknown` is not evidence
|
||||
that SynthID is absent. It is also never started by `identify`. Full guide:
|
||||
[source-pipeline classification](source-classify.md).
|
||||
|
||||
## Visible removal
|
||||
|
||||
### Fill quality depends on the background
|
||||
@@ -87,12 +95,20 @@ detections in the removed experimental mode.
|
||||
|
||||
## Invisible removal
|
||||
|
||||
### Regeneration is lossy
|
||||
### Invisible processing changes pixels
|
||||
|
||||
Invisible removal does not decode and delete a payload. It regenerates the
|
||||
Most invisible removal does not decode and delete a payload. It regenerates the
|
||||
image through a diffusion pipeline. Faces, text, colors, and fine detail can
|
||||
change even when the watermark is successfully disrupted.
|
||||
|
||||
The local Microsoft Paint `Watermarker.dll` format is a narrower exception. Its
|
||||
direct path validates the repeated message and can require agreement with the
|
||||
signed C2PA UUID before rewriting confirmed carriers without redrawing the image.
|
||||
It still changes many pixels slightly, deliberately leaves an inverted periodic
|
||||
signal, and has no external detector that recognizes the positive control. The
|
||||
supported claim is therefore local payload invalidation, not certified removal by
|
||||
Microsoft.
|
||||
|
||||
Text, tables, and UI screenshots are the worst case for a forced scrub: the
|
||||
model redraws glyphs as plausible-but-wrong shapes. Do not run the invisible
|
||||
stage on such content unless a locally-detectable invisible signal exists.
|
||||
@@ -107,17 +123,29 @@ frozen text regions would keep the watermark
|
||||
([text protection research](text-protection-research.md)).
|
||||
|
||||
`qwen-zimage` is the default profile. `sdxl-zimage` and `chroma-zimage` keep
|
||||
the same face stage and swap the global model. `auto` picks chroma-zimage for
|
||||
Microsoft provenance and qwen-zimage otherwise. All are CUDA only.
|
||||
the same face stage and swap the global model. `auto` picks sdxl-zimage for
|
||||
Google provenance, chroma-zimage for Microsoft, and qwen-zimage otherwise.
|
||||
All are CUDA only.
|
||||
Qwen and SDXL condition the global stage on a canny edge map, which preserves
|
||||
structure but not identity or exact texture. Chroma1 is a plain strength pass
|
||||
without Canny.
|
||||
Existing face evaluations favor `qwen-zimage`, but there is no blanket fidelity
|
||||
ordering across content types. A fixed-seed, three-scene text comparison at the
|
||||
profile defaults found no stable winner: SDXL won one poster, Qwen won one, and
|
||||
the Chinese sign tied. Both are large, slow, and may still alter small text or
|
||||
difficult faces. The measurements and their OCR and oracle caveats are tracked
|
||||
in [`data/evaluations/fidelity/`](../data/evaluations/fidelity/README.md).
|
||||
There is no blanket fidelity ordering across providers or content types. At the
|
||||
current Google operating points, however, the 2026-09-15 three-carrier replication
|
||||
favored `sdxl-zimage` in whole-image metrics, localized face-crop LPIPS, and seven
|
||||
manually verified prominent CJK text regions. It retained 13/14 target lines exactly,
|
||||
versus 7/14 for Qwen and 4/14 for Chroma. ArcFace identity was less decisive against
|
||||
Qwen: SDXL won three of four scene-seed pairs and lost one, too few pairs for a
|
||||
significant result. A separate 18-face carrier at the current floors favored SDXL
|
||||
on ArcFace identity and whole-frame similarity in both seeds, while Chroma narrowly
|
||||
won face-crop LPIPS. SDXL also won a no-face/no-text photographic carrier, but Qwen
|
||||
won LPIPS on a flat graphic; one carrier cannot support content-adaptive routing.
|
||||
All three profiles regenerated fine newspaper body copy as pseudotext, so the result
|
||||
selects a relative Google default rather than promising exact face or text
|
||||
preservation. Both models are large, slow, and may still alter small text or difficult
|
||||
faces. The general fidelity corpus and its OCR caveats are tracked in
|
||||
[`data/evaluations/fidelity/`](../data/evaluations/fidelity/README.md), while the
|
||||
current-floor Google replication is recorded in
|
||||
[`module-internals.md`](module-internals.md#google-boundary-replication-2026-09-15).
|
||||
A global Z-Image Turbo prototype preserved text substantially better at low
|
||||
strength, but it has no useful cross-provider operating point and is not a
|
||||
supported profile. Automatic text restorers also remain research-only:
|
||||
@@ -241,18 +269,18 @@ were measured for each.
|
||||
|
||||
`qwen-zimage` reads unknown content from the resolution-adaptive denoise curve.
|
||||
Measured provider cohorts instead take flat operating points: OpenAI `0.07675`,
|
||||
Google `0.27`, and Microsoft InvisMark `0.15`. OpenAI and Microsoft add one full
|
||||
Google `0.35`, and Microsoft InvisMark `0.15`. OpenAI and Microsoft add one full
|
||||
observed cross-source boundary spread to the worst clean source; Microsoft's three
|
||||
first-clean boundaries were `0.04125`, `0.055`, and `0.095`, giving `0.14875` before
|
||||
rounding up. Google's candidate was separately repeated across three valid sources
|
||||
and three accounts. The small corpora make these operating points, not universal
|
||||
thresholds.
|
||||
rounding up. Google's source-fresh candidate cleared three independent CJK carrier
|
||||
types after the original content matrix found CJK and Cyrillic to be the limiting
|
||||
strata. The small corpora make these operating points, not universal thresholds.
|
||||
|
||||
`sdxl-zimage` reads it from the C2PA issuer, on a flat ladder:
|
||||
|
||||
- OpenAI: `0.15`;
|
||||
- Google: `0.25`;
|
||||
- unknown: `0.25`, following the stricter of the two.
|
||||
- Google: `0.50`;
|
||||
- unknown: `0.50`, following the stricter of the two.
|
||||
|
||||
An SDXL global pass needs more denoise than Qwen at the same fidelity, and the
|
||||
values are flat rather than a curve because flat values are what was measured: each
|
||||
@@ -268,13 +296,13 @@ established for that stage.
|
||||
boundary gives `0.20`, verified clean three times on both, while qwen-zimage
|
||||
cleared both at its existing `0.07675` operating point;
|
||||
- Microsoft InvisMark: `0.125` (below qwen's `0.15`);
|
||||
- Google: `0.40` for zero-face content (above qwen's `0.27`; at this floor the
|
||||
regeneration destroys dense text and collapses face identity -- the tradeoff
|
||||
that motivates the adaptive arm below), or `0.125` when YuNet detects at
|
||||
least one face (both measured face fixtures clear at 0.12 with zero
|
||||
cross-source spread; oracle-verified 2026-08-30);
|
||||
- Google: `0.50`. A source-fresh CJK carrier remained detected at `0.40` and
|
||||
cleared at `0.50`; that candidate then cleared independent busy-scene,
|
||||
flat-text, and face-plus-text carriers. The old YuNet-only `0.125` face arm
|
||||
was removed because face detection cannot distinguish easy face-only images
|
||||
from harder face-plus-text content;
|
||||
- Meta Content Seal: `0.17` (above qwen's `0.1`);
|
||||
- unknown: `0.40`, following the strictest measured cohort.
|
||||
- unknown: `0.50`, following the strictest measured cohort.
|
||||
|
||||
An explicit `--strength` overrides all three. The defaults are operating points,
|
||||
not universal guarantees. Near a removal threshold, different content or a
|
||||
@@ -288,8 +316,8 @@ are certified at a fixed seed. The live resolver is
|
||||
| --- | --- |
|
||||
| `qwen-zimage` | CUDA only, large model stack, and limited broad certification across seeds and content. |
|
||||
| `sdxl-zimage` | CUDA only. Its strength ladder is flat per vendor, not a resolution curve, because flat values are what was measured. |
|
||||
| `chroma-zimage` | CUDA only. Higher Google and Meta floors than qwen; Google face content uses 0.125 instead of 0.40. |
|
||||
| `auto` | CUDA only. Routes Microsoft to chroma-zimage and everything else to qwen-zimage. |
|
||||
| `chroma-zimage` | CUDA only. Higher Google and Meta floors than qwen; no safe content-adaptive Google arm exists yet. |
|
||||
| `auto` | CUDA only. Routes Google to sdxl-zimage, Microsoft to chroma-zimage, and everything else to qwen-zimage. |
|
||||
|
||||
Only manually verified `vae-glyphs` is an optional production stage, and it is
|
||||
experimental rather than a default.
|
||||
@@ -516,11 +544,9 @@ remain identical because isolated decoder hits can otherwise be content noise.
|
||||
It accepts Variant P schemas 0-2. Variant Q requires a different model, and
|
||||
schema 3 is rejected at the measured precision threshold. Its NumPy 1.x runtime
|
||||
limits the extra to Python 3.11-3.12; the rest of the package remains supported
|
||||
through Python 3.14. The current TrustMark dependency line also resolves lightning 2.6.5
|
||||
(PYSEC-2026-3624, no fixed release yet); the vulnerable `load_from_checkpoint`
|
||||
path is unreachable here because TrustMark loads its checksummed checkpoints
|
||||
with plain `torch.load`. Bump lightning and cut a patch release when a fix
|
||||
ships. The calibration history is in
|
||||
through Python 3.14. The current TrustMark dependency line resolves
|
||||
`lightning` and `pytorch-lightning` 2.6.6, which contain the published fixes
|
||||
for their checkpoint-loading advisories. The calibration history is in
|
||||
[module internals](module-internals.md#metadata-and-provenance).
|
||||
|
||||
A generic metadata-free AI-generated-image classifier is not shipped. It is a
|
||||
|
||||
+390
-58
@@ -222,7 +222,9 @@ alone is spaced out). The MP4/MOV/M4V/M4A removal path first validates the top-l
|
||||
box walk, then copies the source to a sibling temporary file in bounded chunks.
|
||||
Supported C2PA/JUMBF/AI-label boxes become same-size `free` boxes with blank
|
||||
payloads; TC260 removal changes the four-byte key to `free` and blanks only the
|
||||
validated JSON value with same-length spaces. This preserves every box size,
|
||||
validated JSON value with same-length spaces. Keyed generation metadata such
|
||||
as `workflow` and `prompt` uses the same structural walk; removal blanks the
|
||||
complete key and value in place. This preserves every box size,
|
||||
`stco`/`co64` offset, encoded stream byte, and source-sized memory bound.
|
||||
Publication is atomic, and a malformed top-level walk is copied unchanged. A
|
||||
generic `AIGC` key whose value has no TC260 field is ignored.
|
||||
@@ -482,6 +484,19 @@ not added to the watermark inventory, do not trigger pixel regeneration, and do
|
||||
not suppress independently established SynthID watermark evidence. A registered
|
||||
watermark or an unknown soft-binding algorithm keeps that inference fail-safe.
|
||||
|
||||
The local Paint reader treats C2PA as corroboration, not as the pixel detector.
|
||||
It extracts 4-by-4 blocks from the luminance Haar LL plane, reads the largest
|
||||
singular-value residue of the 15 DCT AC coefficients, and votes over the repeating
|
||||
12-by-12 map. A payload is actionable only when every bit has at least three
|
||||
supporting carriers and the `0x4c` prefix and GUID checksum validate. A caller can
|
||||
also require the decoded Windows-order GUID to equal the one in pristine C2PA.
|
||||
Direct disruption moves each confirmed carrier to the opposite residue center,
|
||||
encodes to a temporary file, and re-runs the reader before atomically publishing
|
||||
it. Any missing or ambiguous
|
||||
condition returns no detection so a caller can retain its existing fallback. The
|
||||
implementation contains no Microsoft binary, PDB data, or extracted writer-quality
|
||||
forest.
|
||||
|
||||
The SDK default enables trust verification but supplies no production trust
|
||||
anchors. Consequently, an installation without an explicitly maintained C2PA
|
||||
trust bundle reports every otherwise valid signer chain as untrusted --
|
||||
@@ -508,13 +523,14 @@ convert `signingCredential.untrusted` into trusted based on a vendor-name match.
|
||||
Vendor attribution comes from the registry in
|
||||
[`_internal/constants.py`](../src/remove_ai_watermarks/_internal/constants.py). Derived
|
||||
issuer and platform maps should not be maintained separately.
|
||||
For an AI C2PA claim, a recognized product in `claim_generator` takes precedence
|
||||
over the certificate issuer: an application can sign through an upstream model
|
||||
provider without becoming that provider's product. Only exact product mappings
|
||||
receive this precedence; an unknown claim generator still falls back to issuer
|
||||
attribution. An unmapped issuer org reads as unknown-signer C2PA with no platform;
|
||||
that is how Ideogram was surfaced (4 private-corpus files signed "Ideogram, Inc",
|
||||
2026-08-08) before its vendor row was added on 2026-08-27.
|
||||
A recognized product in `claim_generator` takes precedence over the certificate
|
||||
issuer: an application can sign through an upstream model provider without
|
||||
becoming that provider's product. Exact products are also useful provenance on a
|
||||
non-AI edit, such as CapCut; recognizing the product does not itself change the AI
|
||||
verdict. An unknown claim generator falls back to issuer attribution. An unmapped
|
||||
issuer org reads as unknown-signer C2PA with no platform; that is how Ideogram was
|
||||
surfaced (4 private-corpus files signed "Ideogram, Inc", 2026-08-08) before its
|
||||
vendor row was added on 2026-08-27.
|
||||
|
||||
### Metadata scanning and stripping
|
||||
|
||||
@@ -541,10 +557,18 @@ Key contracts:
|
||||
asserting that the pixels were generated; explicit `aigc_info` discriminator
|
||||
values and Dreamina `exportType=generation` do assert AI origin. Ordinary
|
||||
Aweme, retouch, and `lv` editor exports are preserved.
|
||||
- EXIF reads fall back to Pillow's decoded `getexif()` mapping when a container
|
||||
exposes no ordinary `info["exif"]` blob. This covers ImageMagick PNG raw EXIF
|
||||
profiles and keeps portable-record identification aligned with the file path.
|
||||
- Structured `generationParams.modelName` JSON is reduced to its recognized
|
||||
model name before attribution, so prompts and the surrounding JSON never
|
||||
become a platform label.
|
||||
- ISOBMFF containers use
|
||||
[`_internal/isobmff.py`](../src/remove_ai_watermarks/_internal/isobmff.py).
|
||||
- Native MP4/MOV TC260 `AIGC` entries are read from
|
||||
`moov.udta.meta.keys/ilst` and blanked without changing box sizes.
|
||||
- Native MP4/MOV `workflow` and `prompt` entries use that keyed metadata-list
|
||||
reader and the same offset-preserving removal path.
|
||||
- Native MKV/WebM TC260 `AIGC` entries are read from
|
||||
`Segment.Tags.Tag.SimpleTag` and removed through the ffmpeg stream-copy path.
|
||||
- Native AVI and FLV TC260 entries are read from `LIST/INFO/AIGC` and
|
||||
@@ -1084,13 +1108,77 @@ gate without downloads and pin that `identify` does not import this
|
||||
module.
|
||||
|
||||
Weights stay out of git. The Hub snapshot is `wiltodelta/raiw-photo-classify`.
|
||||
`RAIW_CLASSIFY_WEIGHTS` overrides it. The extra is `classify`. The runtime
|
||||
resolves the snapshot through `WEIGHTS_ALLOW_PATTERNS`, exported so an
|
||||
offline deploy's pre-cache installs the exact runtime request as one name
|
||||
(`tests/test_classify.py` pins the seam). User guide:
|
||||
`RAIW_CLASSIFY_WEIGHTS` overrides it. The extra is `classify`. The optional
|
||||
CPU ONNX vision runtime is the `classify-onnx` extra. The runtime
|
||||
narrows its snapshot download to the selected backend's model and shared heads.
|
||||
`WEIGHTS_ALLOW_PATTERNS` exports the union so an offline deploy can pre-cache
|
||||
both backends explicitly (`tests/test_classify.py` pins the seam). User guide:
|
||||
[photo-classify.md](photo-classify.md). Hub card:
|
||||
[photo-classify-hf/README.md](photo-classify-hf/README.md).
|
||||
|
||||
### Source-pipeline classifier
|
||||
|
||||
[`source_classify.py`](../src/remove_ai_watermarks/source_classify.py) is a
|
||||
separate, lightweight OpenAI/Google/unknown source-export classifier. It fuses
|
||||
the existing 124-dimensional patch descriptor with the 768-dimensional
|
||||
phase-free spectrum in
|
||||
[`_internal/source_spectral.py`](../src/remove_ai_watermarks/_internal/source_spectral.py),
|
||||
then evaluates the frozen 892 -> 128 -> 64 -> 3 GELU MLP in NumPy. Provider
|
||||
labels clear independent calibrated margins; every other row is `unknown`.
|
||||
The NumPy decision treats values within `1e-5` of the stored margin as the
|
||||
inclusive training threshold, absorbing float32 BLAS differences; this matches
|
||||
the frozen training-runtime decisions on all 27,811 cached internal and
|
||||
external feature rows.
|
||||
|
||||
The runtime pins both the immutable Hub revision and the NPZ SHA-256, loads
|
||||
with `allow_pickle=False`, and rejects any field, shape, class, activation, or
|
||||
non-finite-value drift. `RAIW_SOURCE_CLASSIFY_WEIGHTS` is the offline override.
|
||||
The `source-classify` extra adds `pixels` and Hugging Face Hub but no Torch or
|
||||
Transformers. `identify` never imports or runs this path. Its output names a
|
||||
complete source/export pipeline and is not SynthID detection. Unit contracts
|
||||
and the threshold mutation guard live in `tests/test_source_classify.py`;
|
||||
evaluation and attack limits live in
|
||||
[`synthid-classifiers.md`](synthid-classifiers.md).
|
||||
|
||||
The CLIP loader suppresses discarded random parameter initialization with
|
||||
Transformers' `no_init_weights` context before applying the complete frozen
|
||||
state dict. Normal `CLIPModel(config)` construction initialized 427 million
|
||||
parameters that the checkpoint immediately replaced. On 2026-09-10, three
|
||||
fresh local CPU processes over the same synthetic 512x384 PNG fell from
|
||||
9.024/7.558/7.783 seconds to 2.124/2.059/2.065 seconds. This is a process-cold,
|
||||
OS-cache-warm comparison, not a hosted-container startup estimate. All 42
|
||||
tracked image fixtures kept byte-identical classification records before and
|
||||
after. `tests/test_clip_l_ft.py` rejects a loader that invokes random Linear
|
||||
initialization and verifies that checkpoint parameters and constructor-created
|
||||
buffers are fully materialized.
|
||||
|
||||
The optional CPU-only `backend="onnx"` path reads a static batch-one,
|
||||
vision-only FP32 graph produced by `scripts/export_photo_classify_onnx.py`.
|
||||
The graph is published in the pinned Hub snapshot; PyTorch remains the default.
|
||||
It is 1,216,102,506 bytes versus the 1,710,697,163-byte full
|
||||
PyTorch checkpoint; the removed text tower alone is 494,601,216 bytes. A
|
||||
2026-09-10 stress comparison covered 500 locally available catalog photographs
|
||||
closest to either Model 1 threshold plus 500 deterministic family-stratified
|
||||
photographs. The minimum embedding cosine was 0.999999979, mean absolute delta
|
||||
was 1.54e-7, and no detector or receipt-gate decision changed. All 42 tracked
|
||||
fixtures also kept byte-identical public records.
|
||||
|
||||
Three alternating fresh-process CPU pairs on the same synthetic 512x384 PNG
|
||||
gave PyTorch 7.037/5.235/5.003 seconds and ONNX 6.058/4.956/4.528 seconds under
|
||||
the then-current machine load: ONNX won all three pairs, but its 4.956-second
|
||||
median was only 5% below PyTorch's 5.235 seconds. Median peak RSS fell from
|
||||
3.78 GB to 1.92 GB. These are process-cold, OS-cache-warm local results, not a
|
||||
hosted-container estimate; the memory reduction is the stronger result. ONNX
|
||||
Runtime graph optimization is disabled because rebuilding the already
|
||||
constant-folded 1.2 GB graph increased session startup. CoreML is not selected:
|
||||
its static session took 18.8 seconds to construct and its dynamic-batch probe
|
||||
failed. Dynamic INT8 reduced the graph to 305,462,280 bytes but changed 3 of the
|
||||
42 fixture verdicts in both directions across the public decision boundary, so
|
||||
it remains rejected without retraining and a new operating point. Splitting
|
||||
Model 2 does not address cold start: its detector and provider files total about
|
||||
2 MB and loaded in roughly 3 ms in the same stage profile; CLIP-L Model 1
|
||||
dominates.
|
||||
|
||||
### Pixel forensics
|
||||
|
||||
[`pixel_evidence.py`](../src/remove_ai_watermarks/pixel_evidence.py) measures six
|
||||
@@ -1253,29 +1341,31 @@ The Jimeng pill has an additional decision gate because its visual detector is
|
||||
weaker than the other registered marks. Keep that policy in the registry, not
|
||||
inside unrelated detector engines.
|
||||
|
||||
Everything about a mark is one registry row: its product family, its label regime,
|
||||
the platform sentence `identify` reports for it, and the metadata signals that
|
||||
confirm its vendor. `identify._VISIBLE_MARK_PLATFORM` and the signal mapping in
|
||||
Everything about a mark is one registry row: its product family, manufacturer,
|
||||
label regime, the platform sentence `identify` reports for it, and the metadata
|
||||
signals that confirm its vendor. The manufacturer is distinct from the label
|
||||
regime: Alibaba, ByteDance, Kuaishou, Tencent, and other companies all use TC260,
|
||||
but their marks do not belong to one manufacturer family.
|
||||
`identify._VISIBLE_MARK_PLATFORM` and the signal mapping in
|
||||
`api.visible_provenance` are derived from those rows rather than hand-maintained
|
||||
beside them, so registering a mark is one edit. Two marks carry no platform of
|
||||
their own: the Gemini sparkle has its own higher-confidence path, and the pill
|
||||
alone is too weak to attribute.
|
||||
|
||||
The set of marks that veto the pill is DERIVED from the registry rows: every mark
|
||||
under the same label regime (`tc260`) belonging to a different product. It used to
|
||||
be a hand-written list of keys, and that list drifted -- LiblibAI was registered
|
||||
alongside RunningHub and Baidu, both of which were added to it, and LiblibAI was
|
||||
not, so a confident LiblibAI detection did not suppress the pill the way its two
|
||||
siblings did. Marks outside the TC260 regime (Gemini, Samsung) are deliberately
|
||||
not vetoers: neither can put `jimeng` into `provenance`, so neither can enable the
|
||||
arm it would be vetoing.
|
||||
The set of marks that veto the Jimeng pill is derived from the registry rows:
|
||||
every other product from the same manufacturer. Doubao therefore vetoes the pill
|
||||
because both products are ByteDance, while Qwen, Kling, Yuanbao, RunningHub,
|
||||
Baidu, and LiblibAI do not become siblings merely because they use the same TC260
|
||||
standard. Marks from other manufacturers cannot enable or veto the
|
||||
ByteDance-specific arm.
|
||||
|
||||
A TC260 label relaxes the vendor its `ContentProducer` names, resolved through
|
||||
`KnownMark.tc260_producer_codes`. The label itself is vendor-agnostic, so this used to
|
||||
relax ByteDance's two products on every China-AIGC image -- which both risked a
|
||||
false fill on an image carrying some other vendor's mark and denied that vendor's
|
||||
own mark the relaxed gate its `provenance_ncc_factor` was calibrated for. An
|
||||
absent or unmapped producer still falls back to the ByteDance pair.
|
||||
absent or unmapped producer confirms no particular product and leaves every detector
|
||||
strict.
|
||||
|
||||
`remove_auto_marks` removes every selected mark, not only the strongest one.
|
||||
This matters for images that carry marks in more than one corner.
|
||||
@@ -1344,6 +1434,25 @@ be represented by the shared base:
|
||||
- [`liblib_engine.py`](../src/remove_ai_watermarks/liblib_engine.py)
|
||||
- [`microsoft_engine.py`](../src/remove_ai_watermarks/microsoft_engine.py)
|
||||
|
||||
Qwen is the one image registry row that currently covers two layout families.
|
||||
The shared text engine handles `千问AI生成`; `QwenEngine` also scans a square
|
||||
bottom-right region for Qwen Create's three-lobe symbol. Its 0.65 NCC gate was
|
||||
measured against the cleared provider fixture (0.848) and 132 decoded tracked
|
||||
controls other than that positive (maximum 0.516 on 2026-09-10, zero fires).
|
||||
NCC is not portable across OpenCV builds: issue 108 reported 0.81 on
|
||||
`data/fixtures/provenance/chatgpt-1.png` (sand and denim, no glyph) while this
|
||||
tree's OpenCV 4.11 scores 0.437 on the same file. A fire also requires
|
||||
binarized IoU of the bright residual with the three-lobe silhouette at or
|
||||
above 0.70; the true fixture stays at or above 0.75 down to 512 px, and
|
||||
chatgpt-1 sits at 0.55. `provenance_ncc_factor` remains 1.0, so OpenAI C2PA
|
||||
cannot relax this mark.
|
||||
The removal mask reuses the aligned three-lobe alpha and expands it by 6% of
|
||||
the detected side; a solid square was rejected because classical inpainting
|
||||
left a visible rectangular blur.
|
||||
`scripts/visible_alpha_solve.py qwen_symbol` rebuilds the template from the
|
||||
cleared fixture with a cubic local-background fit and retains only the three
|
||||
interior lobes.
|
||||
|
||||
LiblibAI has two registry entries under one product. `liblib` retains the
|
||||
historical bottom-center wordmark detector. `liblib_pill` covers the compact
|
||||
top-left `AI生成` pill and never fires at strict trust: the shape is generic, so
|
||||
@@ -1580,7 +1689,8 @@ The current profiles are `qwen-zimage` (the default), `sdxl-zimage`,
|
||||
`qwen` and `default` were removed rather than kept as a CPU path, and are
|
||||
rejected rather than aliased onward. `auto` is a deterministic per-cohort
|
||||
selection policy:
|
||||
chroma-zimage for Microsoft, qwen-zimage for OpenAI, Google, Meta, and unknown.
|
||||
sdxl-zimage for Google, chroma-zimage for Microsoft, and qwen-zimage for
|
||||
OpenAI, Meta, and unknown.
|
||||
It does not run a learned router or classify the image's genre.
|
||||
|
||||
`qwen-zimage` normally resolves global denoise from image area for unknown content.
|
||||
@@ -1753,8 +1863,8 @@ verification, and geometry-only operators do not need to invent text or script f
|
||||
|
||||
When enabled, the one profile selected before model loading reconstructs the source
|
||||
once through its already loaded VAE, runs the ordinary global and face stages, and
|
||||
calls the shared restoration compositor. Qwen and Chroma implement this donor hook;
|
||||
SDXL does not. The optional Qwen-only fidelity anchor first blends 15% of the VAE
|
||||
calls the shared restoration compositor. Qwen, SDXL, and Chroma implement this donor
|
||||
hook. The optional Qwen-only fidelity anchor first blends 15% of the VAE
|
||||
reconstruction into the clean result; it is off by default because the blend returned
|
||||
detector-visible OpenAI SynthID in the measured poster fixtures. The compositor derives
|
||||
binary source and candidate silhouettes, groups nearby lines, uses LaMa for the initial
|
||||
@@ -1768,8 +1878,9 @@ extend 8% above and 25% below. The anchored-component gate covers clipped leadin
|
||||
flourishes, trailing punctuation, icons, and descenders without walking into
|
||||
disconnected decoration or background texture.
|
||||
|
||||
The stage is deliberately narrower than the engine: it rejects `sdxl-zimage`, tiles,
|
||||
resolution caps, humanize, unsharp, and adaptive polish. Those combinations change
|
||||
The stage is deliberately narrower than the engine: it rejects tiles, resolution
|
||||
caps, humanize, unsharp, and explicitly enabled adaptive polish. An unset polish
|
||||
value resolves to off in this mode, including for SDXL, because those combinations change
|
||||
geometry or final pixels after the verified layer and have no measured oracle result.
|
||||
It remains opt-in because annotations are manual and provider verdicts apply only to
|
||||
the exact tested output hashes, not to the mechanism in general.
|
||||
@@ -1793,6 +1904,22 @@ the `chroma_text_restoration_study.py` harness (kept outside this repository)
|
||||
under the gitignored
|
||||
`out/text-restoration-engine-study/` directory.
|
||||
|
||||
The 2026-09-15 rerun added the SDXL donor implementation and exercised all three
|
||||
profiles on the same three manifests. SDXL improved text-box MAE from 11.319 to
|
||||
7.970 and from 10.704 to 7.657 on the two OpenAI posters, while PSNR rose from
|
||||
25.83 to 29.24 dB and from 26.17 to 29.04 dB. On the tracked Google CJK carrier
|
||||
(`4affd7f27767a445db6abf741355743ba8d95108ad922c9fff045feed8492236`, already
|
||||
recorded as SynthID-positive), SDXL at the calibrated 0.50 strength improved
|
||||
text-box MAE from 10.738 to 8.564 and PSNR from 23.93 to 24.00 dB. The exact
|
||||
restored output
|
||||
`035e4919e1d84129a347006c98f19eb4431eb5772788f6d2097b94a2c6baec84`
|
||||
was submitted to the Google Gemini oracle after provenance stripping preserved
|
||||
its decoded pixels; the sanitized upload hash was
|
||||
`3e4f06274a7b735f7d06863fd54dc8146f60aeb9cc8315f86294180deb660b82`.
|
||||
The oracle returned `not_detected` on 2026-09-16. This
|
||||
certifies that one carrier, manifest, seed, runtime, and output hash; it does not
|
||||
turn the compositor into a provider-wide guarantee.
|
||||
|
||||
A matched stage-isolation check on the 18-face Gemini portrait grid confirms the
|
||||
division of responsibility. The visible-cleaned, metadata-stripped control and the
|
||||
Z-Image face-only output were both SynthID-positive; Qwen global-only and the full
|
||||
@@ -1851,22 +1978,20 @@ prompt, which was never calibrated against Chroma1), guidance 5.0, and the
|
||||
calibrated `ceil(4 / strength)` requested-step schedule.
|
||||
`CHROMA_NOMINAL_STEPS = 4` is a nominal count: Chroma rounds the start
|
||||
index, so strengths 0.09 and 0.17 execute five steps, while 0.20, 0.125,
|
||||
and 0.40 execute four. Preserve those requests rather than changing the
|
||||
and 0.50 execute four. Preserve those requests rather than changing the
|
||||
calibration to force four effective steps. There is no Canny
|
||||
conditioning: the floors were measured on a plain strength pass.
|
||||
|
||||
The flat vendor floors (`CHROMA_ZIMAGE_*_STRENGTH` in watermark_profiles):
|
||||
OpenAI 0.20 and Microsoft 0.125, Google 0.40 for zero-face content / 0.125 when
|
||||
faces are detected (a content-adaptive arm from the clean face-count split
|
||||
in the calibration: both text cards need 0.25, both face fixtures clear at
|
||||
0.12, so the YuNet detector -- already loaded for the face stage -- routes
|
||||
the operating point), and Meta 0.17 (ABOVE qwen's, because Chroma1's
|
||||
per-fixture boundaries scatter wider). The matched-strength addendum in the
|
||||
research doc is the honest read: at the strength each image actually needs,
|
||||
Chroma1 regenerates better almost everywhere except face identity (which the
|
||||
inherited Z-Image face stage supplies); at the flat worst-case floors it
|
||||
destroys dense text and face identity. The Google face-content arm is the
|
||||
first shipped piece of the content-adaptive policy. A Meta arm was measured
|
||||
OpenAI 0.20, Microsoft 0.125, Google 0.50, and Meta 0.17. The source-fresh
|
||||
Google sweep supersedes the older 0.40 zero-face / 0.125 face split: fresh CJK
|
||||
remained detected at 0.40, and a face-plus-text carrier did not support treating
|
||||
face presence as evidence for the easy arm. The runtime has no independently
|
||||
measured text-content gate, so every Google carrier now uses the replicated global
|
||||
floor. The matched-strength addendum in the research doc remains useful history:
|
||||
at the strength each image actually needs, Chroma1 regenerates better almost
|
||||
everywhere except face identity (which the inherited Z-Image face stage supplies),
|
||||
but high flat floors can destroy dense text and face identity. A Meta arm was measured
|
||||
on 2026-08-30/31 (docs/chroma1-engine-research.md, expansion section) and
|
||||
does not ship: first-cleans from 0.03 to 0.10 form a continuum,
|
||||
`flat_ratio` overlaps the hard and easy clusters, and a high-flat
|
||||
@@ -1902,8 +2027,9 @@ is supported by this pass, so `auto` keeps the measured provenance-cohort table.
|
||||
|
||||
[`_internal/sdxl_zimage_pipeline.py`](../src/remove_ai_watermarks/_internal/sdxl_zimage_pipeline.py)
|
||||
runs the same two-stage recipe on an SDXL global pass. `SdxlZImagePipeline`
|
||||
subclasses `TwoStageZImagePipeline` and implements only the global stage
|
||||
(`_load_global`, `_run_global`), so the face stage is inherited rather than copied
|
||||
subclasses `TwoStageZImagePipeline` and implements the global stage
|
||||
(`_load_global`, `_run_global`) plus the deterministic verified-text VAE donor, so
|
||||
the face stage is inherited rather than copied
|
||||
and cannot drift between the profiles; a test asserts the shared methods are the
|
||||
same objects.
|
||||
|
||||
@@ -1914,13 +2040,213 @@ Four things are architecture-bound and swap with the model: the ControlNet
|
||||
AuraFlow shift), and the latent grid (8 px against Qwen's 16).
|
||||
|
||||
**Strength is architecture-bound too, and that is the easy mistake.** An SDXL global
|
||||
pass leaves SynthID at the strength Qwen needs: verified through the Gemini app on a
|
||||
native 2816x1536 original, 0.154 is FOUND while 0.20, 0.25 and 0.30 are clean. So this
|
||||
pass can leave SynthID at the strength Qwen needs. The source-fresh 2026-09-15
|
||||
matrix found its limiting Google CJK carrier detected through 0.45 and clean at
|
||||
0.50, with independent flat-text and face-plus-text replication at 0.50. So this
|
||||
profile takes a vendor policy (`SDXL_ZIMAGE_OPENAI_STRENGTH` 0.15,
|
||||
`SDXL_ZIMAGE_GEMINI_STRENGTH` 0.25, unknown following Gemini) rather than
|
||||
`SDXL_ZIMAGE_GEMINI_STRENGTH` 0.50, unknown following Google) rather than
|
||||
`resolution_adaptive_denoise`. Flat values are what was measured; no size dependence
|
||||
has been established for this stage, so none is asserted.
|
||||
|
||||
#### Cross-profile strength sweep, 2026-09-14
|
||||
|
||||
A fresh single-source, seed-0 sweep compared qwen-zimage, chroma-zimage, and
|
||||
sdxl-zimage on the same provider-specific carriers. The original content matrix covered
|
||||
photorealistic scenes, flat low-detail art, single and multiple faces, dense Latin,
|
||||
CJK and Cyrillic text, multilingual posters, and faces plus text. Meta completed
|
||||
48/48 real variants, Microsoft 12/12, the original OpenAI sweep 36/36, and Google
|
||||
44/48. Thirteen unique adaptive OpenAI extension candidates later closed the two
|
||||
missing content quadrants. The minimum
|
||||
tested global strengths that cleared every completed stratum were:
|
||||
|
||||
| Provider | qwen-zimage | chroma-zimage | sdxl-zimage |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| OpenAI | 0.06 | 0.12 | 0.06 |
|
||||
| Microsoft | 0.10 | <= 0.04 | <= 0.08 |
|
||||
| Meta | 0.06 | 0.10 | <= 0.08 |
|
||||
| Google | 0.35 | 0.50 | 0.50 |
|
||||
|
||||
`<=` means the first tested rung already cleared, not that its lower boundary is
|
||||
known. Google qwen cleared both CJK and Cyrillic only at 0.35; its face series was
|
||||
non-monotonic. Google SDXL cleared Cyrillic at 0.35 after detections through 0.28,
|
||||
but CJK remained detected at 0.35, 0.40, and 0.45 before clearing at 0.50. Google
|
||||
Chroma CJK remained detected through 0.40 and cleared at 0.50; Cyrillic cleared at
|
||||
0.40. Chroma face+text first cleared at
|
||||
0.22 after detections at 0.08 and 0.16; the face carrier cleared at 0.22 after a
|
||||
detection at 0.08. A recovered-quota recheck on 2026-09-15 also detected the face
|
||||
carrier at 0.16, closing that tested bracket at `0.16 detected -> 0.22 clean` and
|
||||
showing that the shipped 0.125 face arm is too low for this carrier. These are
|
||||
candidate rungs at this stage of the study, not replacement defaults: there is one
|
||||
source and one seed per case. The independent three-carrier replication below later
|
||||
closed the production seed-0 decision; size, crop/resize, JPEG, local-edit, and broader
|
||||
multi-seed robustness remain separate holdouts.
|
||||
|
||||
The original OpenAI set had faces-only and multilingual text-only carriers but no
|
||||
explicit no-face/no-text carrier. A fresh 1536x1024 photorealistic rocky-coast scene
|
||||
closed that stratum on 2026-09-15. The official OpenAI verifier found Qwen 0.04 and
|
||||
Chroma 0.10, then cleared Qwen 0.06 and Chroma 0.12. It found SDXL 0.04 and cleared
|
||||
0.06. These are 0.02-grid brackets, not mathematical minima. The public Web verifier
|
||||
settled the earlier points and exposed SynthID separately from C2PA. A later Chroma
|
||||
0.12 Web request remained in `Verifying` for more than 90 seconds, so it was recorded
|
||||
as indeterminate; the preferred official Content Provenance API returned
|
||||
`not_detected` for that same processed source hash. Three independent official API
|
||||
requests returned HTTP 200 for Chroma 0.12, SDXL 0.04, and SDXL 0.06 respectively. No source control was
|
||||
submitted.
|
||||
|
||||
The other missing OpenAI stratum, one face plus multilingual text, was added with a
|
||||
second 1536x1024 carrier. The official API found Qwen 0.04 and cleared 0.06, while
|
||||
Chroma 0.10 and SDXL 0.04 already cleared. All responses were HTTP 200 with C2PA
|
||||
`not_present`, and original-size review found the face identity, Latin, CJK, Cyrillic,
|
||||
and date text preserved at every generated rung. This stratum does not raise any
|
||||
global OpenAI candidate; the no-face/no-text scene remains the limiting case for
|
||||
Chroma and SDXL.
|
||||
|
||||
At the original non-Google candidate rungs, Chroma had the best mean quality in all
|
||||
three provider cohorts. For Meta, mean LPIPS/SSIM was 0.0320/0.8882 for Chroma 0.10,
|
||||
0.0357/0.8595 for Qwen 0.06, and 0.0531/0.8503 for SDXL 0.08. For OpenAI it was
|
||||
0.0183/0.8763, 0.0192/0.8595, and 0.0304/0.8459 respectively over the original three
|
||||
carriers. Recomputed over all five OpenAI carriers at the freshly verified candidates,
|
||||
Qwen 0.06 has the best LPIPS, SSIM, and edge F1 at 0.03073, 0.82461, and 0.89141.
|
||||
Chroma 0.12 has the best MAE by a narrow margin at 6.42, but its LPIPS/SSIM is worse
|
||||
at 0.05057/0.80940 and Qwen narrowly leads PSNR 27.99 to 27.97 dB; Chroma's
|
||||
no-face/no-text scene is the main loss.
|
||||
SDXL 0.06 is third on the aggregate perceptual and structural metrics at
|
||||
0.03766/0.77202, while keeping the mean Laplacian ratio closest to one. Original-size
|
||||
review agrees that there is no single winner on every criterion, but Qwen is the best
|
||||
overall OpenAI candidate when perceptual similarity, structure, edges, faces, and text
|
||||
are weighted together. Microsoft's
|
||||
single flat carrier likewise favored Chroma 0.04 at 0.00265/0.99621 over Qwen
|
||||
0.10 at 0.00347/0.99426 and SDXL 0.08 at 0.00324/0.99566; this is a carrier
|
||||
comparison, not a multi-source mean. At Google 0.35, SDXL preserved substantially
|
||||
more quality than Qwen (mean LPIPS 0.0655 versus 0.1576, SSIM 0.7285 versus
|
||||
0.5457), although SDXL needed 0.50 rather than 0.35 on the limiting CJK carrier.
|
||||
On that carrier, SDXL 0.50 measured LPIPS/SSIM/PSNR 0.1041/0.7437/23.93 dB,
|
||||
substantially better than Chroma 0.50 at 0.2129/0.5853/20.74 dB and Qwen 0.35 at
|
||||
0.1834/0.5550/18.93 dB. The newly cleared Chroma 0.22 face and face+text points also lost to
|
||||
SDXL at its corresponding tested rungs on every reported metric: LPIPS/SSIM was
|
||||
0.0476/0.6657 versus 0.0318/0.7036 for faces, and 0.0506/0.6359 versus
|
||||
0.0426/0.7016 for face+text. Original-resolution visual review agreed with that
|
||||
direction: Qwen
|
||||
0.35 changed the last glyph of the Cyrillic `УЛЫБКОЙ` into a Latin-looking `N` and
|
||||
altered substantially more wood grain and background structure; Chroma 0.40 and
|
||||
SDXL 0.35 retained the word, with SDXL visually closest to the source. Chroma 0.22
|
||||
preserved foreground face identity and text legibility but visibly changed
|
||||
background people, room structure, lighting, and confetti. The
|
||||
official-oracle access and rolling-quota caveats are recorded in
|
||||
[`provider-oracles.md`](provider-oracles.md).
|
||||
|
||||
The original Google carriers omitted the explicit no-face/no-text stratum. A
|
||||
2026-09-14 extension added two real Gemini-generated 2752x1536 carriers: a
|
||||
photorealistic alpine lake and a low-detail geometric desert. At the current
|
||||
candidate rungs, the flat carrier remained close under all three profiles;
|
||||
LPIPS/SSIM was 0.0237/0.9654 for Qwen 0.35, 0.0120/0.9706 for Chroma 0.40,
|
||||
and 0.0134/0.9655 for SDXL 0.35. On the photographic carrier, every profile
|
||||
visibly regenerated scene detail. SDXL 0.35 was least destructive at
|
||||
0.1174/0.4933, ahead of Qwen 0.35 at 0.2070/0.4120 and Chroma 0.40 at
|
||||
0.2216/0.4229. Original-resolution review found major mountain, shoreline,
|
||||
vegetation, rock, and reflection changes in Qwen and Chroma; SDXL retained the
|
||||
main composition and geometry but still changed fine detail. Full four-rung
|
||||
ladders produced 24 variants across the two carriers and three profiles. After the
|
||||
independent session allowances recovered on 2026-09-15, the official verifier cleared
|
||||
both Qwen carriers at the first 0.15 rung. Chroma's flat carrier cleared at 0.12,
|
||||
while its photographic carrier closed at `0.20 detected -> 0.30 clean`. SDXL's
|
||||
photographic carrier cleared at 0.15 and its quality-qualified flat 0.35 carrier
|
||||
cleared directly. These scene cases therefore do not raise the global Google
|
||||
candidate above the text-bound values in the table.
|
||||
The SDXL flat ladder was quality-non-monotonic: 0.15 introduced severe texture
|
||||
and scored LPIPS 0.1283, while 0.35 looked cleaner and scored 0.0134. Repeating
|
||||
0.15 and 0.22 with adaptive polish disabled changed the polished outputs by only
|
||||
0.51 and 0.57 mean byte levels per channel and left LPIPS effectively unchanged.
|
||||
The artifact therefore comes from the low-step SDXL reconstruction, not polish.
|
||||
The 0.15 flat variant is rejected before oracle use; 0.35 is the
|
||||
quality-qualified SynthID candidate for that one path.
|
||||
|
||||
A Chroma extension was run on an isolated CUDA research worker from the same
|
||||
`c4edc282` checkout. Strength 0.50 retained readable
|
||||
CJK and Cyrillic but was already borderline: LPIPS/SSIM/PSNR was
|
||||
0.2129/0.5853/20.74 dB and 0.2268/0.5490/20.83 dB respectively. At 0.60 both
|
||||
scripts were visibly destroyed and LPIPS exceeded 0.42. The official verifier
|
||||
subsequently detected CJK 0.40 and cleared 0.50, making the borderline 0.50 rung the
|
||||
fresh global Chroma candidate. Strength 0.60 remains rejected on fidelity.
|
||||
|
||||
The corresponding SDXL CJK extension used the same checkout and an isolated CUDA
|
||||
research worker. The official verifier detected 0.40 and 0.45 and cleared 0.50.
|
||||
At 0.50, LPIPS/SSIM/PSNR was 0.1041/0.7437/23.93 dB; original-resolution review
|
||||
found the CJK text fully legible and the scene recognizably preserved. SDXL is the
|
||||
best-quality Google candidate on the limiting carrier.
|
||||
|
||||
#### Google boundary replication, 2026-09-15
|
||||
|
||||
Three new Gemini-generated controls replicated the hard boundary on independent
|
||||
content: a busy night market with dense CJK and many faces, a flat CJK poster without
|
||||
faces, and a portrait reading a dense CJK newspaper. An isolated CUDA run generated
|
||||
both seed 0 and seed 1 at the candidate and neighboring lower rungs, 36 variants total. Production
|
||||
uses the fixed seed 0, and only processed seed-0 variants were sent to the official
|
||||
verifier; source controls were not uploaded.
|
||||
|
||||
At the candidate rungs, the official verifier returned `not_detected` for all nine
|
||||
profile-by-carrier cells: Qwen 0.35, Chroma 0.50, and SDXL 0.50. A separate lower-rung
|
||||
night-market check also cleared Chroma 0.40 and SDXL 0.45, but those two results do
|
||||
not lower the policy because the original fresh CJK carrier remained detected there.
|
||||
Three quota failures and one duplicate post-success tool-call failure were recorded
|
||||
as `unreachable` in separate immutable batches and excluded from the watermark
|
||||
count. Every settled batch passed source, upload, manifest, and result hash
|
||||
verification.
|
||||
|
||||
Quality over all three new carriers and both seeds selected SDXL for Google auto
|
||||
routing. Mean LPIPS/SSIM/PSNR was 0.0609/0.8178/25.15 dB for SDXL 0.50,
|
||||
0.0980/0.7055/21.85 dB for Qwen 0.35, and 0.1269/0.6909/22.53 dB for Chroma 0.50.
|
||||
Original-resolution review agreed: SDXL best preserved the night-market headline,
|
||||
newspaper layout, faces, and the flat poster. Therefore the production Google floors
|
||||
are Qwen 0.35, Chroma 0.50, and SDXL 0.50, and `auto` routes Google to SDXL.
|
||||
|
||||
A localized follow-up checked that aggregate image metrics were not hiding face or
|
||||
text failures. InsightFace `buffalo_l` matched three faces in the newspaper scene and
|
||||
15 in the night market one-to-one. Across the four scene-seed pairs, SDXL beat Qwen
|
||||
on ArcFace cosine in three and lost one, and beat Chroma in all four. That identity
|
||||
result is directional but small: the two-sided sign-test p-values are 0.625 and
|
||||
0.125, respectively. Face-crop LPIPS favored SDXL in all four pairs against both
|
||||
other profiles, also p=0.125 with only four non-tied pairs. This supports, but does
|
||||
not by itself prove, an identity advantage over Qwen.
|
||||
|
||||
Text was scored separately in seven manually verified fixed regions whose originals
|
||||
all produced an exact OCR line: the newspaper masthead, headline, two halves of its
|
||||
subhead, the night-market headline, and two poster lines. The metric was the closest
|
||||
OCR-line normalized edit distance to that fixed target, across both seeds. SDXL's
|
||||
mean was 0.0119 with 13/14 exact region-seed results, versus 0.3770 and 7/14 for Qwen
|
||||
and 0.5374 and 4/14 for Chroma. In paired sign tests SDXL was better than Qwen in
|
||||
seven non-tied cells and worse in none (two-sided p=0.015625), and better than Chroma
|
||||
in ten and worse in none (p=0.001953125). These results cover the selected prominent
|
||||
lines, not arbitrary text preservation: fine newspaper body copy was regenerated as
|
||||
pseudotext by every profile. The safe claim is therefore that SDXL was the relative
|
||||
quality winner on these replicated Google carriers, not that it preserves faces or
|
||||
text losslessly.
|
||||
|
||||
Two current-floor extensions closed the remaining content quadrants. On the
|
||||
face-only carrier, rerun at Qwen 0.35, Chroma 0.50, and SDXL 0.50 for seeds 0 and
|
||||
1, InsightFace matched all 18 faces. SDXL had the highest mean ArcFace cosine in
|
||||
both seeds, 0.828 and 0.842, versus 0.780/0.794 for Chroma and 0.744/0.773 for
|
||||
Qwen. Chroma narrowly won face-crop LPIPS at 0.059/0.058, versus 0.060/0.063 for
|
||||
SDXL, while SDXL was much closer over the whole frame. Its face Laplacian ratios
|
||||
of 1.152/1.176 also show slight texture amplification; Qwen and Chroma instead
|
||||
smoothed face detail to roughly half the source level. Original-resolution review
|
||||
therefore favors SDXL for overall composition and identity, but not every local
|
||||
face metric.
|
||||
|
||||
The no-face/no-text photographic and flat-graphic carriers were also rerun at the
|
||||
same current floors and two seeds. On the photographic scene, SDXL had the best
|
||||
LPIPS/SSIM in both seeds: 0.345/0.457 and 0.339/0.461, ahead of Qwen at
|
||||
0.413/0.412 and 0.404/0.414 and Chroma at 0.568/0.395 and 0.579/0.401.
|
||||
Original-resolution review likewise found SDXL closest, although every profile
|
||||
regenerated major landscape detail. The flat graphic reversed the LPIPS result:
|
||||
Qwen scored 0.045/0.042, Chroma 0.068/0.065, and SDXL 0.099/0.088; all SSIM values
|
||||
were 0.962-0.965. Visual review found model-specific gradient, object, or sun-halo
|
||||
artifacts in every output. One carrier and two seeds do not justify a new
|
||||
content-adaptive route. Together with the text-only and face-plus-text replication,
|
||||
these extensions cover all four primary face/text quadrants at the shipped Google
|
||||
floors, while also showing why `auto` is a relative default rather than a universal
|
||||
per-image optimum.
|
||||
|
||||
`requested_steps` is shared by SDXL and Chroma in `two_stage_pipeline.py`,
|
||||
which also owns `_target_size` with each profile's grid passed explicitly.
|
||||
The step helper exists because the two runtimes truncate differently. DiffSynth sets
|
||||
@@ -1970,6 +2296,17 @@ resolution case -- every verdict so far comes from one fixture and one seed.
|
||||
Both ends of the shipped curve now have oracle verdicts, and the shipped curve clears
|
||||
everything it has been tested at:
|
||||
|
||||
A source-fresh GPT Image 2.5 check on 2026-09-15 exercised the complete shipped
|
||||
profile rather than only a strength sweep. One 1024 x 1024 API generation from
|
||||
Flare and one from Sunburst both validated as current OpenAI C2PA, routed to the
|
||||
flat OpenAI strength, and remained OpenAI SynthID-positive after pixel-identical
|
||||
metadata removal. The complete `qwen-zimage` profile at 0.07675, seed 0, cleared
|
||||
both in the official OpenAI Content Provenance API. The Flare output measured
|
||||
37.48 dB PSNR / 0.9329 SSIM and the Sunburst output 37.01 dB / 0.9179 against
|
||||
their sources. Those fidelity numbers describe two neutral studio-photo
|
||||
carriers, not a content-wide quality estimate, and the source-positive/output-negative
|
||||
oracle pairs do not replace the wider OpenAI calibration that set the floor.
|
||||
|
||||
| oracle | fixture size | detected at | clean from |
|
||||
|---|---|---|---|
|
||||
| openai.com/verify | 1.57 MP | 0.06 | 0.08 |
|
||||
@@ -1988,10 +2325,9 @@ so it is left alone.
|
||||
Measured 2026-08-26 against the anonymous oracle `meta.ai/identification`
|
||||
(verdicts read from the settled page text; corpus and full row-level provenance in
|
||||
`data/contentseal/manifest.csv`). The original measurement used the
|
||||
default resolution-adaptive curve. Current routing can infer the Meta cohort
|
||||
from a retained standalone AI IPTC tag, after checking C2PA vendor evidence
|
||||
first. Stripped files need explicit `--vendor meta` to select the measured
|
||||
Meta floor; absence of C2PA alone does not prevent automatic attribution.
|
||||
default resolution-adaptive curve. The retained standalone AI IPTC tag is a
|
||||
shared standard and no longer infers Meta. Files need explicit `--vendor meta`
|
||||
to select the measured Meta floor from independently known provenance.
|
||||
|
||||
| source (2.56 MP generation) | detected at | clean from |
|
||||
|---|---|---|
|
||||
@@ -2005,14 +2341,10 @@ Full spread: worst first-clean boundary (0.0525, 0.06] on lighthouse, easiest
|
||||
source already clean at 0.015. Following the same derivation as the OpenAI and
|
||||
Microsoft floors (worst clean boundary plus one full observed cross-source
|
||||
spread): 0.06 + (0.0525 - 0.015) = 0.0975, rounded up to **0.1**. Shipped as
|
||||
`QWEN_ZIMAGE_META_STRENGTH` with two routing paths: auto mode routes a file
|
||||
whose only provenance is the standalone AI IPTC tag onto the cohort
|
||||
(`vendor_for_strength` checks C2PA issuers first, so Google/OpenAI/Microsoft
|
||||
evidence always wins, and the tag's other users ship no invisible watermark
|
||||
this profile targets), and `--vendor meta` / `InvisibleOptions.vendor` names the
|
||||
cohort explicitly on stripped files, implying the scrub runs (naming the cohort
|
||||
asserts the watermark is present). sdxl-zimage has no measured Meta rung and an
|
||||
explicit meta vendor there falls to the conservative unknown 0.25. The default
|
||||
`QWEN_ZIMAGE_META_STRENGTH`; `--vendor meta` / `InvisibleOptions.vendor` names
|
||||
the cohort explicitly, implying the scrub runs (naming the cohort asserts the
|
||||
watermark is present). sdxl-zimage has no measured Meta rung and an
|
||||
explicit meta vendor there falls to the conservative unknown 0.50. The default
|
||||
resolution-adaptive curve (~0.1305 at 2.56 MP) also clears every measured
|
||||
source, so default behavior needed no change. Oracle verdicts carry a
|
||||
generation ID and creation timestamp embedded in the watermark payload; both
|
||||
|
||||
@@ -258,6 +258,7 @@ silently change the heads.
|
||||
| File | Role |
|
||||
| --- | --- |
|
||||
| `clip-l-ft.pt` | HeadedCLIP state dict (CLIP-L plus unused linear head) |
|
||||
| `clip-l-ft-vision-fp32.onnx` | Static batch-one vision tower and projection for the optional CPU backend |
|
||||
| `probe-weights-clip-l-ft.npz` | Ridge mean, scale, weights, `thr_oi_1pct` |
|
||||
| `detector.pt` | Freeze MLP |
|
||||
| `provider.pt` | Focal heads keyed `openai`, `google`, `bytedance`, `tc260`, `meta_muse_image`, `no_ai` |
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
},
|
||||
"files": [
|
||||
"clip-l-ft.pt",
|
||||
"clip-l-ft-vision-fp32.onnx",
|
||||
"probe-weights-clip-l-ft.npz",
|
||||
"detector.pt",
|
||||
"provider.pt"
|
||||
|
||||
+17
-4
@@ -38,7 +38,11 @@ print(result.label, result.detector, result.provider)
|
||||
|
||||
Missing extra raises `RuntimeError` with
|
||||
`pip install 'remove-ai-watermarks[classify]'`. `device` is a library
|
||||
parameter (`None` / `"auto"` / `"cpu"` / `"cuda"`), not a CLI option.
|
||||
parameter (`None` / `"auto"` / `"cpu"` / `"cuda"`), not a CLI option. The
|
||||
explicit `backend="onnx"` path is CPU-only and requires
|
||||
`clip-l-ft-vision-fp32.onnx` in `RAIW_CLASSIFY_WEIGHTS` or the pinned Hub
|
||||
snapshot. PyTorch remains the default. Install the optional runtime with
|
||||
`pip install 'remove-ai-watermarks[classify-onnx]'`.
|
||||
|
||||
Weights are not in git. First call downloads
|
||||
[`wiltodelta/raiw-photo-classify`](https://huggingface.co/wiltodelta/raiw-photo-classify)
|
||||
@@ -47,9 +51,18 @@ at the freeze revision pinned in `classify.py`, or reads
|
||||
`probe-weights-clip-l-ft.npz`, `detector.pt`, and `provider.pt`. The training
|
||||
catalog is not published with the package. Deployments that pre-cache the
|
||||
snapshot for offline resolution must request exactly
|
||||
`classify.WEIGHTS_ALLOW_PATTERNS` (the runtime resolves that same list under
|
||||
`HF_HUB_OFFLINE=1`; a partial pre-cache fails the whole resolution, even for
|
||||
files the runtime would fall back on).
|
||||
`classify.WEIGHTS_ALLOW_PATTERNS`. That exported list covers both backends;
|
||||
the online runtime narrows its download to the selected model plus the shared
|
||||
heads. A partial offline pre-cache fails resolution, even for files the runtime
|
||||
would otherwise fall back on.
|
||||
|
||||
Generate the optional vision-only graph from the exact frozen checkpoint:
|
||||
|
||||
```bash
|
||||
uv run --with onnx python scripts/export_photo_classify_onnx.py \
|
||||
--checkpoint /path/to/clip-l-ft.pt \
|
||||
--out /path/to/clip-l-ft-vision-fp32.onnx
|
||||
```
|
||||
|
||||
## What one call returns
|
||||
|
||||
|
||||
+133
-2
@@ -42,6 +42,112 @@ The old Google SynthID Detector portal does not work. On 2026-09-07, the
|
||||
official `labs.google/synthid` entry point redirected a signed-in browser to a
|
||||
Google 404. Historical corpus rows may still name `synthid-portal`; that value
|
||||
describes how an old result was obtained and is not an operational surface.
|
||||
Google's newer Search verification entry point did not provide an independent
|
||||
oracle allowance in the session tested on 2026-09-13. Lens accepted the
|
||||
metadata-stripped, previously Gemini-positive image
|
||||
`sha256:c86feaf788df2c423eeb53fcaee52b74f12a8e83829f94414e6dec2966e6e62e`,
|
||||
but ordinary Lens results only described and matched the visible content. An
|
||||
explicit `Is this made with AI? Check whether it contains SynthID.` request in
|
||||
Search AI Mode invoked the technical verifier and returned `The verification
|
||||
tool's daily limit has been exceeded.` after that session's Gemini image
|
||||
verification allowance was already exhausted. Record this as a quota refusal,
|
||||
not a watermark verdict and not a second quota pool.
|
||||
|
||||
A second Search check on 2026-09-15 used a real Qwen 0.15 no-face/no-text
|
||||
variant rather than a control. Lens and Search AI Mode accepted the upload, but
|
||||
both answered from visible-content reasoning and Web matches instead of
|
||||
returning a SynthID result. The exact Google-AI verification question even
|
||||
misattributed the file to an Imagen 4 Fast demonstration and invented a matching
|
||||
prompt. Search is therefore neither a usable independent oracle nor a substitute
|
||||
verdict when its response does not explicitly report the verification tool's
|
||||
SynthID outcome.
|
||||
|
||||
Fresh independent-session rechecks on 2026-09-14 split: one returned `detected`
|
||||
for the same control while another returned `unable to process your request due
|
||||
to a tool error or quota limit`. A later session returned five usable real-variant
|
||||
verdicts and refused the sixth, while the other tested sessions still refused real
|
||||
variants. Recovery is therefore not a global calendar event. Google [documents approximately ten
|
||||
image checks in a rolling 24-hour window](https://support.google.com/gemini/answer/16722517?hl=en-GB),
|
||||
but that approximation is orientation rather than a per-session reset schedule.
|
||||
When a campaign is conserving that allowance, probe each explicitly selected
|
||||
session with its next real pending variant and record a refusal without assigning
|
||||
a watermark verdict. Reserve a positive-control probe for surface diagnosis, not
|
||||
ordinary batch allocation.
|
||||
|
||||
A second independent-session sweep used one real pending variant per session and
|
||||
again obtained no watermark verdict: three responses explicitly reported a temporary
|
||||
verification quota, while one reported a verification tool-call error. Four completed immutable result batches preserve
|
||||
the exact uploads and settled responses. Twenty-four newly generated
|
||||
no-face/no-text scene variants form six four-rung ladders; the six lowest rungs
|
||||
were prepared in immutable batches, and none had been submitted at that point.
|
||||
The later completed sweep followed the planned lowest-rung-first escalation. Do
|
||||
not treat batch preparation or visual quality scores as SynthID results.
|
||||
|
||||
A session recovered earlier than the estimate derived from the oldest usable call
|
||||
preserved locally. Its next real pending
|
||||
variant, Chroma 0.16 on the face carrier, returned `detected`; the immutable batch
|
||||
is complete and hash-verified. This demonstrates why estimated rolling-window
|
||||
times are retry guidance rather than reset timestamps, and closes the tested
|
||||
face bracket at `0.16 detected -> 0.22 not_detected` without spending a duplicate
|
||||
request on the already settled 0.22 file.
|
||||
|
||||
The same recovered session returned `not_detected` for Chroma 0.40 on the Cyrillic
|
||||
carrier, then explicitly quota-refused SDXL CJK 0.35. The other selected sessions
|
||||
were checked with real pending CJK variants, not controls, and returned quota or
|
||||
tool limitations. One settled response exposed a request-specific instruction to
|
||||
wait 18 hours. Treat that as the retry point for that session and request class, not as a replacement
|
||||
for Google's documented approximate rolling 24-hour allowance or a universal
|
||||
reset time. All four sessions were unavailable again at their latest checks.
|
||||
Because one response did not distinguish service failure from quota, its real
|
||||
Chroma CJK 0.40 candidate was retried. This time the
|
||||
verifier explicitly named a temporary image-analysis quota. The refusal was
|
||||
recorded and the same bytes were prepared in a new immutable retry batch; no
|
||||
watermark verdict was inferred. One session was probed once more with a
|
||||
real Qwen 0.15 no-face/no-text candidate. It returned a verification-tool
|
||||
technical error rather than a SynthID result. That failure was recorded as
|
||||
`refused`, the batch was hash-verified complete, and a fresh immutable retry
|
||||
batch was prepared for the evidence-based session window.
|
||||
|
||||
All four sessions returned usable results again on 2026-09-15, earlier than the
|
||||
previous retry guidance. The remaining real
|
||||
variants were submitted adaptively without controls. SDXL CJK remained detected
|
||||
at 0.35, 0.40, and 0.45 and cleared at 0.50. Chroma CJK remained detected at
|
||||
0.40 and cleared at 0.50. The two no-face/no-text carriers cleared Qwen at 0.15;
|
||||
Chroma cleared the flat carrier at 0.12 and the photographic carrier at 0.30
|
||||
after detections at 0.12 and 0.20; SDXL cleared the photographic carrier at 0.15
|
||||
and the quality-qualified flat carrier at 0.35. One quota refusal and one tool-call
|
||||
failure encountered while routing SDXL CJK 0.40 were kept as separate completed
|
||||
batches; another session then returned the usable `detected` result for the same
|
||||
upload hash. Every settled batch passed immutable-source, upload, manifest, and
|
||||
result verification. The observed early recovery reinforces that a retry time is
|
||||
guidance for the next real request, not a promised reset.
|
||||
|
||||
A same-day production-boundary replication then used three new independent
|
||||
Gemini-generated controls: busy CJK plus many faces, flat CJK without faces, and a
|
||||
portrait plus dense CJK. Only processed variants were submitted. Across Qwen 0.35,
|
||||
Chroma 0.50, and SDXL 0.50, all nine profile-by-carrier cells returned
|
||||
`not_detected`. The night-market lower rungs Chroma 0.40 and SDXL 0.45 also returned
|
||||
`not_detected`; they do not supersede the earlier harder-source detections at those
|
||||
rungs. Two sessions returned explicit quota failures during this pass, one
|
||||
candidate retry also hit quota, and a byte-normalized duplicate after the usable
|
||||
SDXL result returned a tool-call error. Those four calls were recorded as
|
||||
`unreachable`, not spliced into the nine usable verdicts. All result batches were
|
||||
hash-verified.
|
||||
Google did not expose a reset timestamp: the returned quota text supports only
|
||||
waiting and retrying, not a universal 24-hour claim.
|
||||
|
||||
The documented Vertex AI Media Studio verification flow was also unavailable
|
||||
in the live console on 2026-09-14. Its old console URL redirected to Agent Media
|
||||
Studio after the Vertex-to-Agent Platform rename. The current Image playground
|
||||
was inspected in eligible configurations; its task menu exposed generation and image
|
||||
editing but no watermark upload or validation action. The deprecated Python SDK
|
||||
can still resolve `imageverification@001` to its publisher-model endpoint. A
|
||||
2026-09-15 request reached `predict`, but the real Qwen 0.15 variant returned
|
||||
HTTP 403 `The caller does not have permission` in both tested configurations. The current
|
||||
Python reference still documents `WatermarkVerificationModel.verify_image`, but
|
||||
the backing model was access-restricted in both tested configurations and the SDK path is
|
||||
past its announced removal date. Recheck the live console and a real pending
|
||||
variant before adding Search or Agent Media Studio to the surface table.
|
||||
Adobe Inspect and local C2PA, DWT-DCT, TrustMark, and research detectors are
|
||||
useful evidence readers, not interchangeable provider pixel-watermark oracles.
|
||||
|
||||
@@ -306,6 +412,17 @@ response settles as `indeterminate`, not clean. Meta's live positive wording
|
||||
changed from `AI signatures from Meta were found` to `AI signatures were found`
|
||||
by 2026-09-07; both exact forms remain accepted.
|
||||
|
||||
Isolation is a transport property, not a verdict requirement. On 2026-09-15,
|
||||
the isolated OpenAI runner returned one generic error and two Cloudflare
|
||||
refusals for a prepared three-file batch. Uploading those exact prepared hashes
|
||||
through an authenticated first-party browser session reached the official verifier
|
||||
and returned explicit SynthID results. A later Chroma 0.12 upload remained in
|
||||
`Verifying` for more than 90 seconds and was recorded as indeterminate rather
|
||||
than clean. The official API then returned HTTP 200 and SynthID `not_detected`
|
||||
for the same processed source hash. Keep each route in its own immutable batch;
|
||||
do not splice an isolated-runner failure and a first-party browser or API verdict into
|
||||
one response.
|
||||
|
||||
## OpenAI API
|
||||
|
||||
The one automated path calls the hardened metadata-independent adapter already
|
||||
@@ -332,6 +449,20 @@ OpenAI documents `not_detected` as absence of a supported signal, not proof that
|
||||
content was not AI-generated. The API must not become an adaptive removal loss
|
||||
or automatic search loop. See [`synthid.md`](synthid.md) for the oracle boundary.
|
||||
|
||||
The fresh no-face/no-text calibration on 2026-09-15 used three independent
|
||||
official API requests after the public Web surface stalled. All three returned HTTP 200
|
||||
with C2PA `not_present`: Chroma 0.12 and SDXL 0.06 were SynthID `not_detected`,
|
||||
while SDXL 0.04 was `detected`. Together with settled Web results for the same
|
||||
carrier, the tested 0.02-grid brackets are Qwen `0.04 detected -> 0.06
|
||||
not_detected`, Chroma `0.10 detected -> 0.12 not_detected`, and SDXL `0.04
|
||||
detected -> 0.06 not_detected`. The generated source control was not uploaded.
|
||||
|
||||
A separate face-plus-multilingual-text carrier then returned Qwen `0.04
|
||||
detected -> 0.06 not_detected`, Chroma 0.10 `not_detected`, and SDXL 0.04
|
||||
`not_detected`. The lower Chroma and SDXL results did not change the global
|
||||
candidate because the no-face/no-text carrier remained the limiting OpenAI
|
||||
stratum.
|
||||
|
||||
## Microsoft API
|
||||
|
||||
The direct Microsoft path uses the Azure Content Provenance Detection preview
|
||||
@@ -376,8 +507,8 @@ error rate estimates:
|
||||
|
||||
| Surface | Route and repetitions | Recorded result |
|
||||
| --- | --- | --- |
|
||||
| Gemini Web | User's real Chrome, three authenticated account slots | 3/3 `not_detected`; Gemini reported no reliable Google AI signal. |
|
||||
| OpenAI API | Three named API-key slots, direct transport | 3/3 SynthID `not_detected`; C2PA `not_present`; all responses were HTTP 200. |
|
||||
| Gemini Web | Official browser verifier, three independent authenticated sessions | 3/3 `not_detected`; Gemini reported no reliable Google AI signal. |
|
||||
| OpenAI API | Official API, three independent requests | 3/3 SynthID `not_detected`; C2PA `not_present`; all responses were HTTP 200. |
|
||||
| OpenAI Web | ThorData GB, isolated Playwright | `indeterminate`; after accepting the prepared upload, the provider returned `Something went wrong. Please try again.` |
|
||||
| Microsoft API | Direct API, private hash-matched Azure blob | HTTP 202, operation `Succeeded`; Watermark `not_detected`, C2PA `absent`, provider outcome `NoProvenanceDetected`. |
|
||||
| Microsoft Web | ThorData US, isolated Playwright | `not_detected`; the page's exact settled label was `Inconclusive`. |
|
||||
|
||||
+60
-9
@@ -6,12 +6,29 @@ and pipeline modules are intended for maintainers and specialized workflows.
|
||||
Dependency groups are identical for the CLI and Python API. The default install
|
||||
covers metadata extraction, normalization, verdict logic, and stripping.
|
||||
Array/pixel APIs use `pixels`; visible removal uses `visible`; DWT-DCT detection
|
||||
uses `detect`; pixel photo classification uses `classify`; invisible image removal uses `qwen-zimage` and an NVIDIA GPU; and
|
||||
uses `detect`; pixel photo classification uses `classify`; lightweight source-pipeline classification uses `source-classify`; invisible image removal uses `qwen-zimage` and an NVIDIA GPU; and
|
||||
visible video processing uses `video`. The video-pixel SynthID-removal profile is
|
||||
a separate VAE path that still runs on CPU and combines `video` and `diffusion`. Add `heif`
|
||||
independently when path-based pixel APIs must decode HEIC, HEIF, or AVIF. See
|
||||
the complete [feature-extra matrix](installation.md#feature-extras).
|
||||
|
||||
## Classify a source/export pipeline
|
||||
|
||||
Install `remove-ai-watermarks[source-classify]`, then call the explicit API:
|
||||
|
||||
```python
|
||||
import remove_ai_watermarks as raiw
|
||||
|
||||
result = raiw.classify_source("metadata-stripped.png")
|
||||
print(result.label) # "openai" | "google" | "unknown"
|
||||
print(result.reason) # "classified" | "abstained" | "feature_unavailable"
|
||||
```
|
||||
|
||||
This signal classifies a complete image-generation and export pipeline. It is
|
||||
not a SynthID detector and does not establish watermark presence or absence.
|
||||
See [source-pipeline classification](source-classify.md) for the result fields,
|
||||
model pin, evaluation boundary, and offline weights override.
|
||||
|
||||
## Remove visible marks
|
||||
|
||||
Install `remove-ai-watermarks[visible]` before using the visible-removal API.
|
||||
@@ -153,6 +170,32 @@ engine's own parameter names and defaults. `force`, which decides whether the
|
||||
engine runs at all, is a parameter of `remove_all` and `remove_batch` alongside
|
||||
`backend` and `sensitivity`.
|
||||
|
||||
Specialized callers can inspect or apply the clean-room local Paint pixel
|
||||
operation directly. This is deliberately separate from the high-level diffusion
|
||||
route. Passing the UUID signed in pristine C2PA makes agreement with that UUID a
|
||||
write precondition:
|
||||
|
||||
```python
|
||||
from remove_ai_watermarks.microsoft_invismark import (
|
||||
detect_local_invismark,
|
||||
disrupt_local_invismark,
|
||||
invismark_id_from_c2pa,
|
||||
)
|
||||
|
||||
expected = invismark_id_from_c2pa("paint.png")
|
||||
detection = detect_local_invismark("paint.png")
|
||||
if expected is not None and detection is not None:
|
||||
result = disrupt_local_invismark(
|
||||
"paint.png",
|
||||
"clean.png",
|
||||
expected_watermark_id=expected,
|
||||
)
|
||||
```
|
||||
|
||||
The direct call needs the `pixels` extra. It writes atomically only after the
|
||||
encoded output no longer decodes as a valid local payload. The result does not
|
||||
claim acceptance by a proprietary Microsoft detector.
|
||||
|
||||
The complete field set, with the shipped defaults:
|
||||
|
||||
| Field | Default | Effect |
|
||||
@@ -226,7 +269,9 @@ head abstains to `None`. Otherwise it is `None`, including
|
||||
when 124-d extraction refuses the file.
|
||||
|
||||
`device` is a library parameter: `None` / `"auto"` detect, `"cpu"` or `"cuda"`
|
||||
pin. It is not a CLI option.
|
||||
pin. It is not a CLI option. `backend="onnx"` selects the optional CPU-only
|
||||
FP32 vision runtime and requires `remove-ai-watermarks[classify-onnx]`;
|
||||
`backend="torch"` remains the default.
|
||||
|
||||
Missing extra raises `RuntimeError` with the quoted install command
|
||||
`'remove-ai-watermarks[classify]'`. Guide:
|
||||
@@ -245,6 +290,10 @@ import remove_ai_watermarks as raiw
|
||||
vendors = raiw.visible_provenance("input.png")
|
||||
```
|
||||
|
||||
A known TC260 `ContentProducer` maps to one registered product. A missing or
|
||||
unmapped producer, and the shared standalone IPTC AI tag, return no vendor key;
|
||||
neither standard identifies a manufacturer by itself.
|
||||
|
||||
Get the full provenance report:
|
||||
|
||||
```python
|
||||
@@ -562,7 +611,8 @@ field records that source audio is copied if present and not watermark-verified.
|
||||
MP4/MOV
|
||||
inspection recognizes the native TC260 `AIGC` entry in
|
||||
`moov.udta.meta.keys/ilst` and the QuickTime-form `meta` variants Doubao's iOS
|
||||
export writes; its removal preserves container size and encoded
|
||||
export writes. It also recognizes keyed `workflow` and `prompt` generation
|
||||
metadata in the same metadata-list structure; removal preserves container size and encoded
|
||||
stream bytes. MP4/MOV/M4V are copied in bounded chunks, so a large `mdat` is not
|
||||
loaded into memory; publication is atomic. MKV/WebM inspection recognizes the corresponding
|
||||
`Segment.Tags.Tag.SimpleTag` representation; its removal requires ffmpeg for a
|
||||
@@ -732,8 +782,8 @@ engine = InvisibleEngine(pipeline="sdxl-zimage")
|
||||
|
||||
The `qwen-zimage` extra is required for every profile, including `auto`: each
|
||||
concrete engine runs the same DiffSynth Z-Image face stage. `pipeline="auto"`
|
||||
selects chroma-zimage for Microsoft provenance and qwen-zimage
|
||||
otherwise, after the vendor is known and before strength resolution.
|
||||
selects sdxl-zimage for Google provenance, chroma-zimage for Microsoft, and
|
||||
qwen-zimage otherwise, after the vendor is known and before strength resolution.
|
||||
|
||||
The opt-in verified-text stage uses the same `text_manifest` argument as the CLI:
|
||||
|
||||
@@ -747,10 +797,11 @@ engine.remove_watermark(
|
||||
|
||||
Install `remove-ai-watermarks[text-restoration]`. The manifest schema and safety
|
||||
constraints are documented in the CLI guide. The engine verifies its decoded RGB
|
||||
hash before loading the diffusion models. Qwen and Chroma reconstruct the donor with
|
||||
the VAE already loaded for the one profile selected by `auto`; no second generative
|
||||
profile runs. The engine rejects SDXL, downscaling, and postprocessing combinations
|
||||
that were not evaluated. Tiling is also rejected because the combined
|
||||
hash before loading the diffusion models. Qwen, SDXL, and Chroma reconstruct the
|
||||
donor with the VAE already loaded for the selected profile; no second generative
|
||||
profile runs. The engine turns an unset `adaptive_polish` off in verified-text mode
|
||||
and rejects explicit postprocessing or downscaling combinations that were not
|
||||
evaluated. Tiling is also rejected because the combined
|
||||
tiled-restoration path has no provider-oracle calibration. `InvisibleOptions` exposes
|
||||
the same field for `remove_all`; after a visible-stage edit, the manifest must be built
|
||||
against the staged pixels rather than the pristine source.
|
||||
|
||||
@@ -11,7 +11,7 @@ A release is complete only after all four published surfaces are verified:
|
||||
|---|---|---|
|
||||
| PyPI | The `remove-ai-watermarks` wheel and source distribution | `publish.yml` |
|
||||
| Homebrew | The `remove-ai-watermarks` formula in `wiltodelta/homebrew-tap` | `distribute.yml` |
|
||||
| Hugging Face Space | The Space demo deployed on the new release through a requirements-pin bump in `wiltodelta/raiw-hf-space` | `raiw-hf-space` sync workflow; the pin bump is manual, `distribute.yml` only re-installs the pinned version |
|
||||
| Hugging Face Space | The Space demo deployed on the new release through a requirements-pin bump in `wiltodelta/raiw-hf-space` | `distribute.yml` dispatches `pin-library.yml` in that repo, waits for the Hub `requirements.txt` pin, then factory-rebuilds; `sync-to-hf.yml` mirrors the pin onto the Space |
|
||||
| ComfyUI Registry | A compatible release of `wiltodelta/ComfyUI-remove-ai-watermarks` with its own node version | `distribute.yml` and the node repository's workflows |
|
||||
|
||||
The GitHub Release is the trigger and release record for this flow. Conda is
|
||||
@@ -62,16 +62,20 @@ PyPI API token from the repository.
|
||||
waits for the matching source distribution to appear on PyPI, then:
|
||||
|
||||
- updates the Homebrew tap formula URL and SHA-256;
|
||||
- triggers a factory rebuild of the Hugging Face Space: this re-installs the
|
||||
version pinned in the Space repo, it does NOT upgrade the demo;
|
||||
- bumps the Hugging Face Space pin and factory-rebuilds the demo on that pin;
|
||||
- synchronizes, tests, versions, and publishes the ComfyUI nodes.
|
||||
|
||||
Upgrading the Hugging Face Space is a separate, manual step: bump
|
||||
`remove-ai-watermarks[visible,heif]` in `wiltodelta/raiw-hf-space` (`pyproject.toml`,
|
||||
`uv lock`, and the re-exported `requirements.txt`), then push. That repository's
|
||||
`sync-to-hf.yml` mirrors the files onto the Space, which rebuilds on the new
|
||||
pin. Its callback smoke tests run in that repository's CI on every push; they
|
||||
are the scenario check for the demo surface.
|
||||
Upgrading the Hugging Face Space is dispatched from `distribute.yml`: it runs
|
||||
`pin-library.yml` in `wiltodelta/raiw-hf-space`, which bumps
|
||||
`remove-ai-watermarks[visible,heif]` in `pyproject.toml`, refreshes `uv.lock`,
|
||||
and pushes. That repository's `sync-to-hf.yml` then mirrors the files onto the
|
||||
Space. `distribute.yml` waits for the Hub `requirements.txt` pin to match,
|
||||
then factory-rebuilds. The `RAIW_HF_SPACE_TOKEN` repository secret is a
|
||||
fine-grained token limited to the Space repository, with Contents write and
|
||||
Actions write. Without the secret the pin bump is skipped and only the
|
||||
factory rebuild runs, which reinstalls whatever version is already pinned.
|
||||
The Space repo's callback smoke tests still run on every push; they are the
|
||||
scenario check for the demo surface.
|
||||
|
||||
The Hugging Face **model** `wiltodelta/raiw-photo-classify` is not part of that
|
||||
release fan-out. It holds the photo-classify freeze weights. The library extra
|
||||
@@ -79,15 +83,26 @@ release fan-out. It holds the photo-classify freeze weights. The library extra
|
||||
`RAIW_CLASSIFY_WEIGHTS`. Update the Hub repo only with
|
||||
`.github/workflows/publish-photo-classify-hf.yml` (`workflow_dispatch`,
|
||||
type `publish`). Full mode downloads the four weight files from the GitHub
|
||||
Release tag `photo-classify-freeze-2026-08-31` (or the tag you pass) and
|
||||
uploads them with the card in `docs/photo-classify-hf/`. Card mode updates
|
||||
Release tag `photo-classify-freeze-2026-08-31` (or the tag you pass), exports
|
||||
the static FP32 vision-only ONNX graph from `clip-l-ft.pt`, and uploads all five
|
||||
files with the card in `docs/photo-classify-hf/`. Card mode updates
|
||||
the README, `operating-point.json`, and the receipt-gate head staged from
|
||||
the package assets; freeze weights are untouched. A library version bump
|
||||
alone does not upload new heads -- bump the pinned Hub revision when a new
|
||||
head must reach installed clients. Pin a Hub revision in production so a
|
||||
later freeze cannot silently change installed clients.
|
||||
|
||||
The workflow can also be started manually with an optional version input.
|
||||
The separate `wiltodelta/raiw-source-classify` model is published with
|
||||
`.github/workflows/publish-source-classify-hf.yml`. The workflow downloads the
|
||||
derivative `source-pipeline-mlp.npz` artifact from the declared GitHub model
|
||||
freeze, verifies its SHA-256, and uploads only that file plus the tracked card
|
||||
and aggregate metrics under `docs/source-classify-hf/`. The private corpus,
|
||||
catalog, paths, hashes, embeddings, and feature caches are outside this flow.
|
||||
|
||||
The workflow is manual and requires the model-freeze release tag plus an
|
||||
explicit `publish` confirmation. Keep model-freeze GitHub releases marked as
|
||||
pre-releases so a model artifact cannot replace the latest package release in
|
||||
GitHub's release UI.
|
||||
|
||||
If a distribution job fails because a repository or Hugging Face credential is
|
||||
invalid, rotate the corresponding GitHub secret and rerun the failed job. A
|
||||
@@ -160,6 +175,36 @@ surface complete. The Claude community marketplace remains a manual,
|
||||
authenticated publish. Do not treat any listing as complete until the live
|
||||
catalog shows the skill.
|
||||
|
||||
When `metadata.version` in `SKILL.md` changes, finish the catalog follow-up as
|
||||
part of the release that carries that version:
|
||||
|
||||
1. Verify the automatic ClawHub submission and then the live version. Do not
|
||||
publish it a second time by hand.
|
||||
2. If the Claude community directory listing is live, publish the updated
|
||||
plugin through its authenticated submission surface and verify the listing.
|
||||
If the original submission is still under review, leave it pending rather
|
||||
than opening a duplicate.
|
||||
3. If the AI Skills Catalog listing is live, send the new skill version through
|
||||
the update route supplied with its acceptance. The catalog stores compatible
|
||||
packages as well as source metadata, so a GitHub push alone is not a verified
|
||||
update. If no update control was supplied, contact the catalog or resubmit
|
||||
and verify the resulting listing. Do not duplicate a pending initial review.
|
||||
4. If the OpenAI plugin directory listing has been approved, upload the new
|
||||
plugin archive through the Platform portal and verify the published version.
|
||||
Until the initial product-specific review is approved, there is no release
|
||||
action beyond tracking that review.
|
||||
5. SkillsMP, skills.sh, AgenticSkills, and the awesome lists point at GitHub or
|
||||
refresh from it. Do not resubmit a routine version bump. Contact them or open
|
||||
a new PR only when their public description, category, tags, or repository
|
||||
URL must change.
|
||||
|
||||
The automated release workflow cannot perform the authenticated directory
|
||||
steps. A release with a changed skill version is not fully distributed until
|
||||
each applicable manual step is verified or explicitly marked pending because
|
||||
the catalog has not accepted the initial submission. The catalog-specific
|
||||
submission state and links live in
|
||||
[`docs/agent-skill.md`](agent-skill.md#where-the-skill-can-actually-be-listed).
|
||||
|
||||
## Release verification
|
||||
|
||||
Forensic transports are versioned independently from the package. Before publishing
|
||||
@@ -210,6 +255,9 @@ version, or any other status, fails. The full checklist for completeness:
|
||||
rebuild, and one live `identify` and one live `visible` API call succeed
|
||||
against the running Space;
|
||||
- a clean install can run `remove-ai-watermarks --version`.
|
||||
- when the Agent Skill version changed, every applicable manual catalog update
|
||||
above is verified, or its initial submission remains pending without a
|
||||
duplicate submission.
|
||||
|
||||
A clean-install check run immediately after publication can fail with "no
|
||||
version of remove-ai-watermarks==X.Y.Z" even though the simple index already
|
||||
@@ -217,11 +265,22 @@ lists it: uv serves its cached pre-release view of the index. Re-run with a
|
||||
fresh cache (`UV_CACHE_DIR=$(mktemp -d)`) before treating it as a release
|
||||
failure.
|
||||
|
||||
The ComfyUI sync run can fail the same way on its own: it resolves the version
|
||||
from the PyPI JSON API (which updates first) but installs the test dependency
|
||||
with pip against the simple index, whose CDN edges lag by minutes. Rerun the
|
||||
failed `distribute.yml` comfyui job once the simple index lists the release;
|
||||
nothing about the release itself is wrong.
|
||||
The ComfyUI sync resolves the version from the PyPI JSON API but installs its
|
||||
test dependency through pip's simple index, whose CDN edges can lag by
|
||||
minutes. `distribute.yml` gates every downstream job on both APIs listing the
|
||||
release, so it cannot dispatch that race again. The ComfyUI waiter itself has
|
||||
a clock: 0.40.0's node suite finished after the 15-minute poll, so it now
|
||||
waits 30 minutes and then accepts a registry row that already requires this
|
||||
library version. For an older distribution that started before the simple
|
||||
index caught up, rerun its failed `comfyui` job only when the registry still
|
||||
has no such row.
|
||||
|
||||
The automatic `verify-release.yml` run after `distribute.yml` checks the
|
||||
Hugging Face Space pin. When `RAIW_HF_SPACE_TOKEN` is set, that pin lands
|
||||
during distribution. Without the secret, bump the pin by dispatching
|
||||
`pin-library.yml` in `wiltodelta/raiw-hf-space` and then dispatch
|
||||
`verify-release.yml` with the release version. Do not read a missing Space
|
||||
pin as a broken PyPI or Homebrew publish.
|
||||
|
||||
If the release changed CLI routing, extras, exit codes, mark keys, or the
|
||||
intended-use boundary, confirm `skills/remove-ai-watermarks/` still matches.
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
license: apache-2.0
|
||||
library_name: numpy
|
||||
pipeline_tag: image-classification
|
||||
tags:
|
||||
- image-forensics
|
||||
- source-attribution
|
||||
- open-set-classification
|
||||
---
|
||||
|
||||
# RAIW source-pipeline classifier
|
||||
|
||||
This is a compact, abstaining pixel-forensics classifier for three labels:
|
||||
`openai`, `google`, and `unknown`. It is intended for original-export-looking
|
||||
images whose metadata may have been removed without changing the pixels.
|
||||
|
||||
This model is **not a SynthID detector**. It does not decode or verify a
|
||||
watermark payload. Its labels describe complete generation and export
|
||||
pipelines, so renderer, geometry, encoding, post-processing, and watermark
|
||||
state may be confounded. `unknown` does not mean that SynthID is absent, and a
|
||||
provider label does not prove that SynthID is present.
|
||||
|
||||
## Model
|
||||
|
||||
- Input: a 124-dimensional residual/spectral forensic feature vector plus a
|
||||
768-dimensional phase-free spectral vector.
|
||||
- Head: 892 -> 128 -> 64 -> 3 MLP with GELU activations.
|
||||
- Output: `openai`, `google`, or abstain as `unknown` using independent
|
||||
provider margins.
|
||||
- Artifact: `source-pipeline-mlp.npz`
|
||||
- SHA-256: `6430b0738869ff07b6845b7c6149c2a83bc3f3785878082886c241c6f7ca0c4a`
|
||||
|
||||
The artifact contains only learned parameters, normalization constants,
|
||||
labels, and thresholds. It does not contain training images, paths, image
|
||||
hashes, embeddings, or a training catalog.
|
||||
|
||||
## Evaluation
|
||||
|
||||
The operating point was selected on an internal calibration split. The locked
|
||||
internal test and the public `bigdatamark/synthid-research` dataset were not
|
||||
used for architecture, checkpoint, or threshold selection.
|
||||
|
||||
| Evaluation | Class | Recall | Precision |
|
||||
|---|---|---:|---:|
|
||||
| Locked internal test (5,369 images) | OpenAI | 60.4% | 84.9% |
|
||||
| Locked internal test (5,369 images) | Google | 57.6% | 87.9% |
|
||||
| Locked internal test (5,369 images) | Unknown | 98.6% | 93.7% |
|
||||
| Public external originals (900 images) | OpenAI | 68.7% | 100.0% |
|
||||
| Public external originals (900 images) | Google | 81.3% | 98.8% |
|
||||
| Public external originals (900 images) | Unknown/Flux | 99.0% | 66.4% |
|
||||
|
||||
On the public external set, a 95% resize reduced Google recall to 1.0%, and a
|
||||
JPEG quality-75 round trip caused the model to abstain on every image. These
|
||||
are expected abstentions, not evidence that a watermark was removed.
|
||||
|
||||
## Intended use
|
||||
|
||||
Use this model as one probabilistic signal in a broader forensic report or to
|
||||
suggest an appropriate processing path. Do not use it to claim watermark
|
||||
presence, authorship, policy compliance, fraud, or provenance. Applications
|
||||
should expose the abstention and the model's narrow source-pipeline claim.
|
||||
|
||||
Feature extraction and inference are versioned in the
|
||||
`remove-ai-watermarks` Python project rather than in this model repository.
|
||||
Use its explicit `classify_source` API; `identify` does not run this model.
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"claim": "abstaining OpenAI/Google source-pipeline classification; not SynthID detection",
|
||||
"artifact_sha256": "6430b0738869ff07b6845b7c6149c2a83bc3f3785878082886c241c6f7ca0c4a",
|
||||
"classes": ["openai", "google", "unknown"],
|
||||
"locked_internal_test": {
|
||||
"n": 5369,
|
||||
"confusion_actual_by_predicted": [[230, 6, 145], [0, 217, 160], [41, 24, 4546]],
|
||||
"recall": {"openai": 0.6036745407, "google": 0.5755968170, "unknown": 0.9859032748},
|
||||
"precision": {"openai": 0.8487084871, "google": 0.8785425101, "unknown": 0.9371263657}
|
||||
},
|
||||
"public_external_originals": {
|
||||
"dataset": "bigdatamark/synthid-research",
|
||||
"n": 900,
|
||||
"confusion_actual_by_predicted": [[206, 0, 94], [0, 244, 56], [0, 3, 297]],
|
||||
"recall": {"openai": 0.6866666667, "google": 0.8133333333, "unknown": 0.99},
|
||||
"precision": {"openai": 1.0, "google": 0.9878542510, "unknown": 0.6644295302}
|
||||
},
|
||||
"selection": {
|
||||
"minimum_calibration_precision": {"openai": 0.99, "google": 0.95},
|
||||
"maximum_calibration_unknown_false_positive_rate": 0.01
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
# Source-pipeline classification
|
||||
|
||||
`classify_source` is an opt-in, metadata-free pixel classifier for images that
|
||||
still resemble an original provider export. It returns `openai`, `google`, or
|
||||
an explicit `unknown` abstention.
|
||||
|
||||
This is not a SynthID detector. It does not decode or verify a watermark
|
||||
payload, and it must not be used to claim that SynthID is present or absent.
|
||||
The model describes the complete source/export pipeline, where renderer,
|
||||
geometry, encoding, post-processing, and watermark state can be confounded.
|
||||
|
||||
Install the lightweight runtime:
|
||||
|
||||
```bash
|
||||
uv add "remove-ai-watermarks[source-classify]"
|
||||
```
|
||||
|
||||
Call the Python API explicitly:
|
||||
|
||||
```python
|
||||
import remove_ai_watermarks as raiw
|
||||
|
||||
result = raiw.classify_source("metadata-stripped.png")
|
||||
print(result.label) # "openai" | "google" | "unknown"
|
||||
print(result.reason) # "classified" | "abstained" | "feature_unavailable"
|
||||
print(result.margins) # provider margins before thresholding
|
||||
print(result.thresholds) # frozen provider thresholds
|
||||
```
|
||||
|
||||
`feature_unavailable` is returned for images below the required 256x256
|
||||
geometry or for a degenerate feature vector. `abstained` means neither provider
|
||||
cleared its independent calibrated margin. Both are honest `unknown` results.
|
||||
|
||||
The first call downloads the exact pinned revision of
|
||||
[`wiltodelta/raiw-source-classify`](https://huggingface.co/wiltodelta/raiw-source-classify).
|
||||
Set `RAIW_SOURCE_CLASSIFY_WEIGHTS` to the NPZ itself or a directory containing
|
||||
`source-pipeline-mlp.npz` for an offline deployment.
|
||||
|
||||
The model is useful as a routing suggestion for a processing workflow or as one
|
||||
probabilistic signal in a broader forensic report. Do not use it as evidence of
|
||||
authorship, fraud, policy compliance, provenance, or watermark presence. The
|
||||
published model card records the evaluation and robustness limits.
|
||||
+47
-36
@@ -13,7 +13,7 @@ The `visible` command registers these mark keys:
|
||||
| `gemini` | Google Gemini visible watermark (sparkle) | Usually bottom right | Detection includes a false positive gate. |
|
||||
| `doubao` | `豆包AI生成` | Bottom right | Vendor specific text detector. |
|
||||
| `jimeng` | `★ 即梦AI` | Bottom right | Vendor specific text detector. |
|
||||
| `qwen` | `千问AI生成` | Bottom right | Strict visual gate. |
|
||||
| `qwen` | `千问AI生成` or the three-lobe Qwen symbol | Bottom right | Separate strict templates cover the text and Qwen Create symbol variants. |
|
||||
| `kling` | `可灵AI 3.0` or `KlingAI 3.0` | Bottom right | Separate calibrated silhouettes cover the older CJK and current IMAGE 3.0 Latin variants. |
|
||||
| `yuanbao` | `元宝` over `AI生成` | Bottom right | Standard two-line variant only. |
|
||||
| `samsung` | `✦ Contenuti generati dall'AI` | Bottom left | Calibrated for the Italian text variant. |
|
||||
@@ -80,17 +80,21 @@ The learned backends download model files on first use.
|
||||
The inspection and stripping code handles signals in these groups:
|
||||
|
||||
- C2PA Content Credentials and supported cloud manifest references;
|
||||
- EXIF and XMP generator fields;
|
||||
- EXIF and XMP generator fields, including structured
|
||||
`generationParams.modelName` values from supported generators;
|
||||
- exact app-export provenance and AIGC disclosures from supported
|
||||
ByteDance-family products, with product-only provenance excluded from the
|
||||
generated-image verdict;
|
||||
- IPTC AI disclosure fields;
|
||||
- PNG text chunks and embedded generation parameters;
|
||||
- PNG text chunks and embedded generation parameters, including keyed
|
||||
`workflow` and `prompt` tags in MP4/MOV metadata lists;
|
||||
- China TC260 AIGC labels in supported image placements and the normative
|
||||
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;
|
||||
- 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;
|
||||
@@ -117,6 +121,10 @@ entries enter the watermark inventory, while a fingerprint remains a
|
||||
re-linkability warning, never selects pixel regeneration, and does not suppress
|
||||
independent SynthID evidence.
|
||||
|
||||
The opt-in `classify_source` API is not an `identify` signal. It can suggest an
|
||||
OpenAI or Google source/export pipeline after metadata removal, but it neither
|
||||
detects nor decodes a watermark and never changes the provenance verdict.
|
||||
|
||||
## File and container formats
|
||||
|
||||
Pixel based image commands discover these extensions:
|
||||
@@ -144,16 +152,17 @@ use their format specific paths.
|
||||
|
||||
## Invisible watermarks
|
||||
|
||||
The `invisible` command uses diffusion regeneration. It targets watermark
|
||||
patterns by changing the image rather than decoding and deleting a known
|
||||
payload.
|
||||
The `invisible` command uses diffusion regeneration. A specialized Python API can
|
||||
instead apply decoder-guided disruption to a positively identified local
|
||||
Microsoft Paint payload.
|
||||
|
||||
Current pipeline values, all CUDA-only:
|
||||
Current diffusion pipeline values, all CUDA-only:
|
||||
|
||||
- `qwen-zimage`, the default;
|
||||
- `sdxl-zimage`, the same recipe and the same face stage on an SDXL global pass;
|
||||
- `chroma-zimage`, the same face stage on a Chroma1 global pass;
|
||||
- `auto`, chroma-zimage for Microsoft provenance, otherwise qwen-zimage.
|
||||
- `auto`, sdxl-zimage for Google provenance, chroma-zimage for Microsoft,
|
||||
otherwise qwen-zimage.
|
||||
|
||||
The `controlnet`, `sdxl`, `qwen` and `default` values were removed. A retired name
|
||||
is rejected at parse time rather than remapped onto a surviving profile.
|
||||
@@ -166,7 +175,11 @@ and `scripts/synthid_runtime/`.
|
||||
The tool recognizes presence from supported provenance: Google AI C2PA
|
||||
under Google's all-media watermark policy, and current OpenAI C2PA carrying an
|
||||
explicit `c2pa.watermarked.*` action. Legacy OpenAI C2PA without that action
|
||||
does not assert SynthID.
|
||||
does not assert SynthID. GPT Image 2.5 Flare and Sunburst were verified on
|
||||
2026-09-15: both API outputs carried valid OpenAI C2PA with
|
||||
`c2pa.watermarked.unbound` and exercised the existing model-independent OpenAI
|
||||
route. Automatic routing selected `qwen-zimage` with the OpenAI strength cohort,
|
||||
and both outputs cleared the official OpenAI SynthID check.
|
||||
|
||||
Provider verifiers and provenance APIs are reserved for development-time
|
||||
calibration. They are not exposed through the installed CLI, the top-level
|
||||
@@ -176,19 +189,24 @@ Microsoft Paint can name `com.microsoft.invismark.1` in a C2PA soft-binding
|
||||
assertion. Inspection reports both that exact algorithm and its signed `value`,
|
||||
which Paint uses as the identifier carried by the pixel watermark, and emits an
|
||||
additive `invismark` signal so callers can select pixel removal without parsing
|
||||
the generic `soft_binding` detail. Photos uses a parallel local writer path.
|
||||
Metadata stripping removes only the embedded manifest; `invisible` and `all`
|
||||
guarantee the supported InvisMark removal contract by regenerating the pixel
|
||||
layer as well. The project has no validated local InvisMark decoder, so local
|
||||
inspection cannot independently verify the output. Microsoft's official
|
||||
[Content Provenance Detection API](https://learn.microsoft.com/en-us/azure/ai-services/content-safety/how-to/how-to-provenance-detection)
|
||||
is the external oracle: it reports pixel `Watermark` and embedded `C2PA` results
|
||||
separately; a control-positive, output-negative pair is the available per-file
|
||||
verification path. The API needs Azure credentials; the page a human can check
|
||||
without an account is <https://ai.azure.com/nextgen/validate>. That page is the
|
||||
public provider oracle used for the measured removal certification. Its clean
|
||||
outcome is rendered as `Inconclusive`; the authenticated API exposes separate
|
||||
watermark and C2PA fields but is not a different class of certification.
|
||||
the generic `soft_binding` detail.
|
||||
|
||||
For Paint's local `Watermarker.dll` format, the reader recovers the repeated
|
||||
144-bit message and requires its prefix, GUID byte order, checksum, and per-bit
|
||||
support. A caller can additionally require agreement with the UUID signed in
|
||||
pristine C2PA. Only a positive local payload uses decoder-guided bit disruption;
|
||||
the encoded output is read back and must no longer contain a valid local payload
|
||||
before it is published. A Microsoft
|
||||
soft-binding declaration without a matching local payload, including the
|
||||
committed cloud Image Creator fixture, is rejected by this decoder. The CLI and
|
||||
high-level API retain their Chroma diffusion route. Metadata stripping alone still
|
||||
removes only the manifest.
|
||||
|
||||
This is local payload invalidation, not external removal certification. The
|
||||
available Azure Content Provenance Detection API did not detect a known-positive
|
||||
local-format control, so it cannot certify this direct output. Photos and future
|
||||
Paint builds may use another pixel format and therefore remain on the regeneration
|
||||
fallback unless this decoder positively identifies them.
|
||||
|
||||
Meta Muse Image stamps every output with Content Seal, a proprietary invisible
|
||||
pixel watermark, and ships no visible mark (the legacy `Imagined with AI`
|
||||
@@ -197,12 +215,7 @@ This project has no local Content Seal decoder. Meta Model API outputs and
|
||||
Meta CDN copies carry an XMP `iptcExt:DigitalSourceType =
|
||||
trainedAlgorithmicMedia` companion tag, which `identify` reports through the
|
||||
existing Made-with-AI path; that IPTC code is a standard, not a Meta-exclusive
|
||||
signal, so it cannot key a strength cohort the way the C2PA issuer does. Since
|
||||
0.33.0 a standalone AI digital-source tag (no C2PA manifest) additionally emits
|
||||
the additive `content_seal` signal - the strength router's Meta bet as evidence,
|
||||
medium confidence, with the same caveat - so clients select pixel removal from
|
||||
the signal list exactly the way InvisMark is additive over `soft_binding`. It
|
||||
is an attribution, never a decode. The
|
||||
signal, so it neither asserts Content Seal nor selects a strength cohort. The
|
||||
external oracle is `https://meta.ai/identification`: anonymous, no login,
|
||||
accepts image, video, and audio, enforces an unspecified daily identification
|
||||
limit, and answers with model attribution (`Muse Image 1 - Meta`) plus a
|
||||
@@ -215,12 +228,9 @@ resolution-adaptive strength (oracle-verified on 2.56 MP generations); measured
|
||||
strength boundaries are recorded in `data/contentseal/manifest.csv` and
|
||||
[module internals](module-internals.md#meta-content-seal-boundaries-for-qwen-zimage).
|
||||
The derived Meta floor (0.1 by the standard spread method) ships as a measured
|
||||
cohort: auto mode routes a file whose only provenance is the standalone AI IPTC
|
||||
tag to it (the tag is not Meta-exclusive; other tag users ship no invisible
|
||||
watermark this profile targets, and Google/OpenAI/Microsoft C2PA evidence
|
||||
always wins first), and `invisible --vendor meta` (also `all` and `batch`, and
|
||||
`InvisibleOptions.vendor` in the API) names the cohort explicitly on stripped
|
||||
files. An explicit vendor implies the scrub runs: naming the cohort asserts the
|
||||
cohort. `invisible --vendor meta` (also `all` and `batch`, and
|
||||
`InvisibleOptions.vendor` in the API) selects it when the source is independently
|
||||
known. An explicit vendor implies the scrub runs: naming the cohort asserts the
|
||||
pixel watermark is present.
|
||||
The seal survives resizing, JPEG recompression, and metadata stripping; it dies
|
||||
to center crops of a third to a half, matching the Reuters 2026-07-11 finding
|
||||
@@ -247,7 +257,8 @@ not a universal clean verdict.
|
||||
| Google Veo video | Veo diamond and legacy text | Oracle-certified VAE removal for SynthID | C2PA and related source signals |
|
||||
| OpenAI image generators | None registered | Diffusion regeneration for supported invisible signals | C2PA and generator provenance |
|
||||
| Meta Muse Image | None on Muse output (legacy `Imagined with AI` unregistered) | Diffusion regeneration for Content Seal, oracle-verified on the default profile | XMP IPTC `trainedAlgorithmicMedia` companion tag; no local Content Seal decoder |
|
||||
| Microsoft Paint and Photos | None registered | External Microsoft oracle for InvisMark; no validated local decoder | Paint C2PA soft-binding algorithm and identifier |
|
||||
| Microsoft Paint local `Watermarker.dll` format | None registered | Local repeated-payload decoder and decoder-guided disruption; no usable external oracle for this format | Paint C2PA soft-binding algorithm and matching UUID |
|
||||
| Microsoft cloud and other InvisMark variants | None registered | Chroma regeneration fallback | Microsoft C2PA attribution and soft-binding declaration |
|
||||
| Microsoft image outputs (measured variant) | One top-right white AI-badge variant | No registered pixel decoder | C2PA attribution |
|
||||
| Stable Diffusion and SDXL | None registered | Diffusion regeneration; optional open decoder | Embedded parameters and text metadata |
|
||||
| FLUX | None registered | Diffusion regeneration; optional open decoder | C2PA for supported sources |
|
||||
|
||||
@@ -23,6 +23,130 @@ are explicit negatives, a strict rule can abstain, and any watermark claim uses
|
||||
an independent oracle. CLIP content embeddings and the 124-d origin-locked
|
||||
residual bank are different features for different jobs.
|
||||
|
||||
### Phase-free spectral pipeline probe, 2026-09-15
|
||||
|
||||
Mark's public
|
||||
[`bigdatamark/synthid-research`](https://huggingface.co/datasets/bigdatamark/synthid-research)
|
||||
corpus pairs 300 prompts across Gemini 2048-pixel JPEG, OpenAI 1254-pixel PNG,
|
||||
and FLUX 1280-pixel PNG exports. It is useful for testing source-pipeline
|
||||
features, but it is not a causal SynthID corpus: renderer, dimensions, encoder,
|
||||
and watermark state all change together.
|
||||
|
||||
The paper-inspired probe in [`scripts/spectral_pipeline_probe.py`](../scripts/spectral_pipeline_probe.py)
|
||||
discards Fourier phase, subtracts each image's radial log-magnitude envelope,
|
||||
pools luma plus two opponent-color spectra, and fits a dual ridge classifier.
|
||||
Matching prompt ids stay together in a grouped five-fold evaluation. The
|
||||
256-pixel / 32x32-grid / ridge-1000 configuration was selected after an
|
||||
exploratory comparison on these same folds, so this is a development result,
|
||||
not blind validation. Its complete-corpus confusion matrix was:
|
||||
|
||||
| Actual pipeline | Gemini | OpenAI | FLUX |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| Gemini | 220 | 25 | 55 |
|
||||
| OpenAI | 33 | 219 | 48 |
|
||||
| FLUX | 75 | 48 | 177 |
|
||||
|
||||
Accuracy was 616/900 (68.4%), against 33.3% chance. Applying each transform
|
||||
only to held-out rows gave 67.3% after a two-pixel top-left crop, 67.8% after a
|
||||
95% resize, 67.9% after JPEG quality 75, and 68.4% after Gaussian blur radius
|
||||
0.7. This is materially more transform-stable than the origin-locked lattice,
|
||||
but it remains a source/export classifier. There are no photographs, foreign
|
||||
generators beyond FLUX, independently generated 1K Gemini files, or same-image
|
||||
SynthID on/off pairs in the corpus. No runtime detector, threshold, or product
|
||||
verdict is justified by this result.
|
||||
|
||||
The representation follows the frequency-domain premise of Zhang et al.,
|
||||
[*Detecting and Simulating Artifacts in GAN Fake Images*](https://arxiv.org/abs/1907.06515),
|
||||
not their ResNet-34 implementation. Their own post-processing results also
|
||||
support treating augmentation and architecture shift as first-class tests,
|
||||
rather than reading an untransformed source-classification score as watermark
|
||||
evidence.
|
||||
|
||||
### Abstaining fused source classifier, 2026-09-15
|
||||
|
||||
The follow-up experiment combined the existing 124-d forensic descriptor with
|
||||
the 16x16 phase-free spectral grid in a class-balanced ridge head. Its three
|
||||
outputs are `openai`, `google`, and `unknown`; a provider result is emitted only
|
||||
when it beats both the other provider and `unknown` by a margin selected on a
|
||||
separate calibration split. The public implementation is
|
||||
[`scripts/source_pipeline_classifier.py`](../scripts/source_pipeline_classifier.py).
|
||||
It includes training, strict pickle-free model loading, and JSON inference, but
|
||||
no fitted weights: the experimental training freeze contains inputs that are
|
||||
not redistributable. This keeps the code reproducible without publishing a
|
||||
derivative artifact whose data provenance does not permit redistribution.
|
||||
|
||||
Penalty 100 and margin 0.6392407698660646 were selected using only the training
|
||||
and calibration partitions. The 2,669-image locked internal test and Mark's
|
||||
900-image public set were not used for feature, penalty, or threshold selection.
|
||||
Feature-extraction failures count as `unknown` in every result below.
|
||||
|
||||
| Evaluation | Accuracy | OpenAI recall | Google recall | Unknown recall |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| Locked internal, 2,669 images | 84.2% | 60.9% | 48.8% | 95.8% |
|
||||
| Mark original exports, 900 images | 90.1% | 76.3% | 94.3% | 99.7% |
|
||||
| Mark crop 2 px | 89.8% | 76.7% | 94.3% | 98.3% |
|
||||
| Mark resize 95% | 59.7% | 81.7% | 0.0% | 97.3% |
|
||||
| Mark JPEG quality 75 | 33.3% | 0.0% | 0.0% | 100.0% |
|
||||
| Mark blur radius 0.7 | 68.3% | 42.3% | 63.0% | 99.7% |
|
||||
|
||||
The untouched-export result is useful source-attribution evidence, but the
|
||||
transforms decide the claim. Resizing eliminated the Google branch, JPEG made
|
||||
the classifier abstain on every row, and blur materially reduced both provider
|
||||
recalls. The locked unknown false-attribution rate was 4.2%, versus 0.9% on the
|
||||
calibration split. This is therefore an inspectable research baseline, not a
|
||||
runtime detector or a product watermark verdict.
|
||||
|
||||
Training requires hash-disjoint `train` and `calibration` directories, each
|
||||
with `openai`, `google`, and `unknown` subdirectories:
|
||||
|
||||
```bash
|
||||
uv run python scripts/source_pipeline_classifier.py train \
|
||||
/path/to/train /path/to/calibration --model-out .local-eval/source-pipeline.npz
|
||||
|
||||
uv run python scripts/source_pipeline_classifier.py predict \
|
||||
.local-eval/source-pipeline.npz image.png
|
||||
```
|
||||
|
||||
A stronger public release needs a publication-cleared multi-era training set
|
||||
and a causal marked/unmarked contrast. Mark's same-prompt triples remain an
|
||||
external source/export test, not evidence that the fitted features read the
|
||||
SynthID payload.
|
||||
|
||||
### Full-corpus source-pipeline model, 2026-09-16
|
||||
|
||||
The next experiment trained a compact nonlinear head on the complete available
|
||||
private corpus, including target-provider exports, photographs, and foreign
|
||||
generator negatives. The corpus itself is not published. The released
|
||||
`source-pipeline-mlp.npz` is a derivative artifact containing only learned
|
||||
parameters, normalization constants, labels, and abstention thresholds; it
|
||||
contains no images, paths, image hashes, embeddings, or training catalog.
|
||||
|
||||
The model joins the 124-dimensional forensic descriptor with the
|
||||
768-dimensional phase-free spectral descriptor and applies a
|
||||
892 -> 128 -> 64 -> 3 GELU MLP. Checkpoint and thresholds were selected on a
|
||||
separate calibration partition. The 5,369-image hash-disjoint locked test and
|
||||
Mark's public 900-image originals were not used for architecture, checkpoint,
|
||||
or threshold selection.
|
||||
|
||||
| Evaluation | Class | Recall | Precision |
|
||||
| --- | --- | ---: | ---: |
|
||||
| Locked internal test (5,369 images) | OpenAI | 60.4% | 84.9% |
|
||||
| Locked internal test (5,369 images) | Google | 57.6% | 87.9% |
|
||||
| Locked internal test (5,369 images) | Unknown | 98.6% | 93.7% |
|
||||
| Mark public originals (900 images) | OpenAI | 68.7% | 100.0% |
|
||||
| Mark public originals (900 images) | Google | 81.3% | 98.8% |
|
||||
| Mark public originals (900 images) | Unknown/FLUX | 99.0% | 66.4% |
|
||||
|
||||
A 95% resize reduced Google recall on the public set to 1%, and a JPEG
|
||||
quality-75 round trip made every row abstain. This behavior matches the narrow
|
||||
claim: the model classifies original-looking source/export pipelines after
|
||||
metadata removal. It neither decodes nor detects SynthID, and its `unknown`
|
||||
label does not mean that SynthID is absent. The model card and aggregate
|
||||
metrics are tracked under
|
||||
[`docs/source-classify-hf/`](source-classify-hf/README.md). Runtime integration
|
||||
is provided by the explicit `classify_source` library API and never feeds the
|
||||
`identify` watermark verdict.
|
||||
|
||||
Krawetz's Gemini-chat TPR critique is a verifier-quality claim, not a
|
||||
feature we can ship. [Lead Stories, 2026-07](https://leadstories.com/analysis/2026/07/google-gemini-synthid-detector-confuses-results-within-same-chat.html)
|
||||
documented Gemini repeating the first file's SynthID verdict inside a
|
||||
|
||||
@@ -95,10 +95,13 @@ Two real but non-local verifier paths surfaced. The historical Vertex SDK
|
||||
exposed `WatermarkVerificationModel.from_pretrained("imageverification@001")`.
|
||||
The class still exists in `google-cloud-aiplatform` 1.165.1, but warns that it
|
||||
was deprecated 2025-06-24 with removal scheduled for 2026-06-24. Calls in two
|
||||
projects with Vertex enabled now reject the publisher model id as invalid. A
|
||||
repository using `response.watermark_detected` is also stale: the actual SDK
|
||||
property was `watermark_verification_result`, backed by the prediction's
|
||||
`decision` field.
|
||||
eligible Vertex configurations were rechecked on 2026-09-15 with a real processed
|
||||
variant and correctly attributed user credentials. The publisher
|
||||
model resolved, but both `predict` calls returned HTTP 403 `The caller does not
|
||||
have permission`. The surviving class reference therefore does not establish
|
||||
public access to the backing model. A repository using
|
||||
`response.watermark_detected` is also stale: the actual SDK property was
|
||||
`watermark_verification_result`, backed by the prediction's `decision` field.
|
||||
|
||||
A newer unlisted upload route,
|
||||
`POST https://synthid.googleapis.com/upload/v1:verifyContent`, is live enough
|
||||
|
||||
+16
-1
@@ -775,11 +775,26 @@ SynthID. Current OpenAI manifests distinguish the watermarked output with the
|
||||
signed `c2pa.watermarked.unbound` action. The detector requires that action, so
|
||||
old OpenAI C2PA remains an origin signal without becoming a SynthID claim.
|
||||
|
||||
**GPT Image 2.5 compatibility, verified 2026-09-15.** One fresh low-quality
|
||||
1024 x 1024 API generation from each released model, `gpt-image-2.5-flare` and
|
||||
`gpt-image-2.5-sunburst`, carried a valid bound OpenAI C2PA claim generated by
|
||||
`OpenAI Media Service API`, with `trainedAlgorithmicMedia` and the action set
|
||||
`created, converted, watermarked.unbound`. `identify` reported high-confidence
|
||||
OpenAI provenance plus SynthID, `has_invisible_target` returned true, and the
|
||||
automatic strength cohort resolved to OpenAI for both files. After C2PA was
|
||||
removed without changing decoded pixels, the official OpenAI Content Provenance
|
||||
API still returned SynthID `detected` for both sources. The complete shipped
|
||||
`qwen-zimage` profile at strength 0.07675 and seed 0 then produced two outputs
|
||||
for which the same metadata-independent API returned SynthID `not_detected`.
|
||||
This is one controlled source per model and proves the current routing and
|
||||
operating point on those two sources; it is not a provider-wide removal rate.
|
||||
|
||||
### 4.3 Other vendors
|
||||
|
||||
- **Kakao** (South Korea): SynthID adopter as of May 2026 (Google announcement)
|
||||
- **NVIDIA Cosmos**: SynthID for video (not still images; different pipeline)
|
||||
- **Meta AI**: does NOT use SynthID; uses IPTC `digitalSourceType` marker instead
|
||||
- **Meta AI**: does NOT use SynthID; Muse uses Content Seal and may retain a shared
|
||||
IPTC `digitalSourceType` companion tag that does not identify Meta by itself
|
||||
|
||||
### 4.4 Version evolution (v1 vs v2 hardening)
|
||||
|
||||
|
||||
@@ -490,6 +490,41 @@ bridge or reset its start PTS must fail this gate.
|
||||
A separate constant-rate clip with the same non-zero start guards the
|
||||
start-offset routing without relying on the VFR branch.
|
||||
|
||||
## Signal discovery cadence
|
||||
|
||||
The project skill [signal-discovery](../.claude/skills/signal-discovery/SKILL.md)
|
||||
orchestrates the corpus audits in this plan. `scripts/corpus_gap_scan.py` is only
|
||||
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.
|
||||
The visible counterpart, `scripts/visible_positives.py`, records both strict and
|
||||
metadata-corroborated registry results in a resumable JSONL. With `--sheets` it
|
||||
adds blinded, native-resolution top and bottom bands from quiet metadata cohorts
|
||||
and from an unbiased quiet sample. The latter keeps a provider with no mapped
|
||||
metadata from being excluded by construction; neither stratum is ground truth
|
||||
until the pixels are adjudicated.
|
||||
|
||||
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
|
||||
run. Replay the complete historical corpus before releases that change a
|
||||
detection, metadata, container, registry, invisible-routing, or removal seam,
|
||||
after every detector registration or recalibration, and monthly as a backstop.
|
||||
The historical pass is required because newer code can expose an old
|
||||
serialization and a new detector can crossfire on an old neighbouring cohort.
|
||||
A stratified sidecar sample is a useful preflight but does not replace a
|
||||
triggered full replay.
|
||||
|
||||
Removal-parity sweeps are change-triggered rather than weekly: run the matching
|
||||
metadata, visible, video, or invisible audit whenever either its detector or
|
||||
cleaner moves. External provider oracles are claim-triggered, never
|
||||
calendar-triggered. Keep private inputs and reports untracked, and implement an
|
||||
actionable detector or cleaner gap in this library rather than in a downstream
|
||||
consumer.
|
||||
|
||||
## Standing gap
|
||||
|
||||
None of this is in `maintain.sh`, and it should not all be -- the sweeps take hours. But
|
||||
|
||||
@@ -27,6 +27,18 @@ provider. For AI claims, exact product mappings in `claim_generator` therefore
|
||||
take precedence over issuer attribution. Supported mappings include Higgsfield
|
||||
AI, Recraft, Topaz Labs Image API, and TikTok Ad Creative Toolbox; an unknown
|
||||
claim generator still falls back to the certificate issuer.
|
||||
Exact claim-generator mappings also cover `Grok Imagine` as xAI Grok Imagine
|
||||
and `Suno` as Suno. Both can carry a valid AI source claim without a separately
|
||||
readable issuer, so issuer-only attribution leaves the platform blank.
|
||||
|
||||
Some signers expose the product in `signature_info` while retaining a generic
|
||||
claim generator. xAI Grok Imagine is recognized from that signed identity even
|
||||
when the generator is only `c2pa-rs`; Producer.ai is handled the same way.
|
||||
FastVid has an exact claim-generator mapping. SPRING (SG) PTE. LTD. is retained
|
||||
as the signer on AI-source claims without guessing which of its products created
|
||||
the file. TikTok, ByteDance, CapCut, Anthropic Claude file provenance, and vivo
|
||||
Camera are neutral signer or capture attributions: without an AI source assertion,
|
||||
none of them changes the AI verdict.
|
||||
|
||||
**OpenRouter raster metadata survey (2026-09-02):** a paid Recraft V3 API
|
||||
output carried a valid C2PA claim with `claim_generator` `recraft.ai`, a
|
||||
@@ -36,22 +48,25 @@ Riverflow V2 Fast API outputs carried no locally readable C2PA, XMP, EXIF, or
|
||||
container metadata. Their absence is evidence about those exact files and API
|
||||
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`).
|
||||
**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`**. The broader **issuer** `b"Bytedance Pte"` is registered only as a neutral signer because that same entity also signs non-AI CapCut edits; it has no AI platform and cannot assert an AI verdict. Keying the product attribution on the `Dreamina` generator token remains the precise path.
|
||||
- **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 — its own EXIF signature scheme, NOT C2PA (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).**
|
||||
|
||||
Grok JPEG downloads (Aurora model) 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 empirically kills the earlier unverified "xAI signs C2PA as xAI" lead — xAI is not even a C2PA member. `exif_generator` misses it (neither field holds an `AI_GENERATOR_TOKENS` token), so a dedicated detector `xai_signature(path)` matches the pair (`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. 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 the detector of the vendor it actually carries rather than a guessed pair; an unmapped or absent producer falls back to ByteDance's two products. 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.
|
||||
- **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.
|
||||
|
||||
**Doubao** (ByteDance) uses it (verified on a public issue sample; `ContentProducer` `001191110102MACQD9K64010000`, no C2PA/SynthID/imwatermark — the XMP block is the only signal; GitHub attachment upload did NOT strip it). The same standard is mandatory for Jimeng/Kling/Qwen/Ernie etc., so the one marker covers the whole China-AIGC-labeled ecosystem. `aigc_label` reads **four image serializations** through a shared `_parse` helper: the HTML-entity-encoded XMP `TC260:AIGC` block in **either RDF form** — the nested element `<TC260:AIGC>{...}</TC260:AIGC>` (Doubao) or the attribute `TC260:AIGC="{...}"` (**PicWish**, `ContentProducer="picwish"`, verified on compatible samples) — via a container-agnostic raw-byte scan (any JSON object accepted), a raw-JSON PNG `AIGC` tEXt chunk (Doubao also writes the label this way, no namespaced marker at all — confirmed on compatible samples, `ContentProducer="doubao"`), a bare raw-JSON `{"AIGC":{...}}` object embedded in **JPEG EXIF (UserComment)** by some China-served generators, brace-matched from the scan head with `json.JSONDecoder().raw_decode` (no namespaced marker, no PNG chunk — confirmed on compatible samples, `ContentProducer="001191440300708461136T1308L"`), **and** a bare `AIGC{...}` blob (the label glued straight to its JSON, no `"AIGC":` key wrapper) embedded in a **JPEG APP segment near the JFIF header** — confirmed on compatible samples. The two raw-JSON forms are scanned in one loop (`'"AIGC"'` then `AIGC{`) that **falls through on a non-TC260 / undecodable hit instead of returning** — a quoted `"AIGC"` can appear later in an XMP packet while the real label is a bare `AIGC{...}` earlier in the file, so an unconditional early return on the quoted form would shadow the bare form (the exact bug behind the 06-10 misses). Native MP4/MOV is a fifth serialization: TC260-PG-20257A stores an `AIGC` key in `moov.udta.meta.keys` and the raw JSON in the matching `ilst` item; Doubao's iOS export additionally writes two QuickTime-form variants (a bare `meta` box as a direct `moov` child, and a keyless `hdlr=mdir` metadata list whose `ilst` data items carry the JSON with no key name) — both verified on retained carriers (2026-08-17). The seeking parser reaches a tail `moov` without reading `mdat`; removal replaces the key with `free` and blanks the validated value at the same length so every box size and stream offset stays fixed. All generic forms are gated on at least one TC260 field (`TC260_AIGC_FIELDS`) so a generic `AIGC` key cannot false-positive; the namespaced XMP element is unambiguous and needs no gate. `TC260_AIGC_FIELDS` covers **two schemas**: the producer-side one (`Label` / `ContentProducer` / `ProduceID` / `ContentPropagator` / `PropagateID`, Doubao and most China gens) and the **service-provider** one (`ServiceProvider` / `ServiceUser`, plus generic `Time` / `ContentId` which are NOT gated on) — **Tencent Cloud's** AIGC variant (`ServiceProvider` = `腾讯云`), embedded in **EXIF `ImageDescription`**, verified on compatible samples. In `identify`, `aigc` fires on the parsed label **or** the `AIGC_MARKERS` byte scan (the latter preserves the laundering-tell case where the JSON payload is truncated).
|
||||
|
||||
@@ -88,7 +103,10 @@ payloads. Removal remuxes either container through ffmpeg with stream copy.
|
||||
|
||||
Native TC260 MP4/MOV tags do not live in those top-level provenance boxes.
|
||||
`tc260_aigc_payloads` separately seeks through `moov.udta.meta.keys/ilst`, so
|
||||
the normative tag is also found when a large `mdat` precedes `moov`.
|
||||
the normative tag is also found when a large `mdat` precedes `moov`. The parser
|
||||
canonicalizes known fields across PascalCase and lowerCamelCase serializations.
|
||||
The same keyed metadata-list walk exposes generation `workflow` and `prompt`
|
||||
entries, including ComfyUI exports, and blanks them in place during removal.
|
||||
|
||||
**Meta-box XMP and EXIF removal are handled in place:** an AI-label XMP packet
|
||||
stored as a meta-box `mime` item is blanked by
|
||||
@@ -213,7 +231,7 @@ not ground truth.
|
||||
|
||||
Two consequences we can exploit: (1) the 5% floor is a **scale prior** -- a compliant CN mark's glyphs are large (>= 51 px on a 1024² image), so a CN silhouette ladder can be anchored at ~5-10% of the short side instead of swept broadly, which should cut false fires; (2) every compliant string shares the tail `AI生成` / `AI合成`, so a shared suffix silhouette plus a per-vendor prefix may beat five independent templates. NOT established: whether 文字高度 means cap height, em box, or rendered bounding box (a ~1.3x spread in template scale). Sources: `https://www.tc260.org.cn/upload/2025-03-15/1742009439794081593.pdf`, parent CAC measure `https://www.cac.gov.cn/2025-03/14/c_1743654684782215.htm` (the CAC text itself specifies no size or corner).
|
||||
|
||||
**Alibaba Cloud Qwen -- two surfaces that differ (API tier VERIFIED (a)).** Model Studio docs state verbatim that the API adds a `Qwen-Image` watermark 在图像右下角 and 默认值为 false -- so API output is **unwatermarked by default**, and when enabled the mark is a LATIN wordmark, not CJK, and not GB-compliant in wording. The consumer app's `千问AI生成` (bottom-right) is (b) secondary only -- no Alibaba primary page states it. So Qwen needs TWO templates, and its absence is never evidence of a clean image. Source: `https://help.aliyun.com/zh/model-studio/qwen-image-api`.
|
||||
**Alibaba Cloud Qwen -- several surfaces differ (API tier VERIFIED (a)).** Model Studio docs state verbatim that the API adds a `Qwen-Image` watermark 在图像右下角 and 默认值为 false -- so API output is **unwatermarked by default**, and when enabled the documented mark is a LATIN wordmark, not CJK, and not GB-compliant in wording. The Chinese consumer app's `千问AI生成` (bottom-right) is (b) secondary only -- no Alibaba primary page states it. A cleared Qwen Create `Qwen-Image 2.0` output from 2026-09-03 instead carries the three-lobe Qwen symbol alone; that symbol and the CJK text are registered under one `qwen` key with separate templates. The documented API Latin wordmark remains unsupported, so absence of either registered shape is never evidence of a clean image. Source: `https://help.aliyun.com/zh/model-studio/qwen-image-api`.
|
||||
|
||||
**星绘 is ByteDance (VERIFIED (a): Baidu Baike + App Store listing, now branded 豆包旗下, team folded into Doubao April 2025).** So `星绘AI生成` is very likely the Doubao house style -- same typeface, same corner, possibly the same top-left `AI生成` pill. Starting from the Doubao `TextMarkConfig` and swapping the two lead glyphs is the cheap path. String/position themselves are (c) inferred.
|
||||
|
||||
@@ -221,7 +239,7 @@ Two consequences we can exploit: (1) the 5% floor is a **scale prior** -- a comp
|
||||
|
||||
**Tencent Yuanbao: RESOLVED 2026-07-25, registered (`yuanbao_engine.py`).** The standard mark is a compact two-line italic `元宝` over `AI生成` block at bottom-right. It switches between light and dark strokes with the scene, so detection uses polarity-independent local contrast rather than a white top-hat. The separate one-line overlay variant remains evidence-limited to one example.
|
||||
|
||||
**Meta Muse Image / Content Seal: current state VERIFIED 2026-08-26 against live artifacts.** Muse Image (launched 2026-07-07, first image model from Meta Superintelligence Labs) puts no visible mark on output. Every output carries Content Seal, a proprietary invisible pixel watermark; research lineage is open (`github.com/facebookresearch/content-seal`, Pixel Seal / VideoSeal / Watermark Anything), but the deployed implementation is proprietary and unpublished. API outputs and Meta CDN copies also carry XMP `iptcExt:DigitalSourceType = trainedAlgorithmicMedia` (a standard IPTC code, not Meta-exclusive). The only reader is the anonymous web oracle `meta.ai/identification`, which returns model attribution plus a per-generation ID and timestamp embedded in the payload. Verified robustness (our corpus, `data/contentseal/`): seal survives CDN WebP transcode, 512 px resize, JPEG q85, and full metadata stripping; it is lost to center crops of 33-50% linear size (Reuters measured 55% detector misses on cropped images, 2026-07-11). Removal is fully supported and oracle-verified on the default profile, with a measured Meta strength floor (0.1) shipped behind auto IPTC routing and an explicit `--vendor meta` override; see [supported signals](supported-signals.md) and [module internals](module-internals.md). Legacy paragraph retained below.
|
||||
**Meta Muse Image / Content Seal: current state VERIFIED 2026-08-26 against live artifacts.** Muse Image (launched 2026-07-07, first image model from Meta Superintelligence Labs) puts no visible mark on output. Every output carries Content Seal, a proprietary invisible pixel watermark; research lineage is open (`github.com/facebookresearch/content-seal`, Pixel Seal / VideoSeal / Watermark Anything), but the deployed implementation is proprietary and unpublished. API outputs and Meta CDN copies also carry XMP `iptcExt:DigitalSourceType = trainedAlgorithmicMedia` (a standard IPTC code, not Meta-exclusive). The only reader is the anonymous web oracle `meta.ai/identification`, which returns model attribution plus a per-generation ID and timestamp embedded in the payload. Verified robustness (our corpus, `data/contentseal/`): seal survives CDN WebP transcode, 512 px resize, JPEG q85, and full metadata stripping; it is lost to center crops of 33-50% linear size (Reuters measured 55% detector misses on cropped images, 2026-07-11). Removal is fully supported and oracle-verified on the default profile, with a measured Meta strength floor (0.1) selected through an explicit `--vendor meta` override; the shared IPTC tag alone neither identifies Meta nor asserts Content Seal. See [supported signals](supported-signals.md) and [module internals](module-internals.md). Legacy paragraph retained below.
|
||||
|
||||
**Meta `Imagined with AI` (string VERIFIED (a) from Meta's own newsroom; POSITION NOT VERIFIED).** Sources conflict on placement. Do not encode a corner without a verified sample. A dedicated 2026-08-27 sample hunt failed to obtain one: the Feb-2024 newsroom images are UI mockups whose photos carry no in-pixel mark; community posts (Threads `C8_rS_MuEId` titled "lower left corner", a Facebook share) corroborate bottom-LEFT verbally but their files defeat pixel verification; press screenshots predate the mark (Dec 2023); `imagine.meta.com` is dead (redirects to meta.ai) and its Wayback captures are broken SPA error pages. The mark's generator no longer exists (Muse output has no visible mark), so no fresh sample can be made. Removal stays on the generic `erase --region` path until a legacy capture surfaces. Source: `https://about.fb.com/news/2024/02/labeling-ai-generated-images-on-facebook-instagram-and-threads/`.
|
||||
|
||||
|
||||
+15
-2
@@ -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.39.1"
|
||||
version = "0.41.1"
|
||||
description = "AI watermark remover for visible, invisible, and provenance marks in images and video"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11,<3.15"
|
||||
@@ -154,6 +154,19 @@ classify = [
|
||||
"tokenizers>=0.22,<0.23",
|
||||
"huggingface-hub>=0.20.0",
|
||||
]
|
||||
# Optional vision-only CPU runtime for the photo classifier. Keep it separate
|
||||
# because PyTorch remains the default and ONNX Runtime is needed only on opt-in.
|
||||
classify-onnx = [
|
||||
"remove-ai-watermarks[classify]",
|
||||
"onnxruntime>=1.24.0",
|
||||
]
|
||||
# Compact OpenAI/Google/unknown source-export classifier. This is intentionally
|
||||
# separate from the heavier photo AI-versus-camera classifier and is not a
|
||||
# SynthID detector. Weights download on first use.
|
||||
source-classify = [
|
||||
"remove-ai-watermarks[pixels]",
|
||||
"huggingface-hub>=0.20.0",
|
||||
]
|
||||
# Universal region eraser backend -- big-LaMa via onnxruntime (Carve/LaMa-ONNX,
|
||||
# Apache-2.0). CPU, no torch. Model (~200 MB) is downloaded on first use and
|
||||
# cached by huggingface_hub; it is never bundled in this repo. The default cv2
|
||||
@@ -199,7 +212,7 @@ dev = [
|
||||
]
|
||||
# ``qwen-zimage`` already pulls ``diffusion``; naming both would suggest diffusion is
|
||||
# independently sufficient for a removal, which it is not.
|
||||
all = ["remove-ai-watermarks[video,heif,detect,trustmark,text-restoration,migan,classify]"]
|
||||
all = ["remove-ai-watermarks[video,heif,detect,trustmark,text-restoration,migan,classify,source-classify]"]
|
||||
|
||||
[project.scripts]
|
||||
remove-ai-watermarks = "remove_ai_watermarks.cli:main"
|
||||
|
||||
+9
-4
@@ -9,7 +9,7 @@ explicitly names a tracked canonical result.
|
||||
|
||||
| Script | Purpose |
|
||||
| --- | --- |
|
||||
| `corpus_gap_scan.py` | Compare a local image corpus with the library's `identify` results. |
|
||||
| `corpus_gap_scan.py` | Inventory `identify`, metadata markers, normalized C2PA review fields, unattributed outcomes, errors, and gap candidates over a full or date-bounded corpus. Its schema-versioned checkpoint resumes safely after interruption. |
|
||||
| `detection_timing.py` | Record per-method metadata and verdict timings. |
|
||||
| `detection_timing_report.py` | Aggregate timing records by method and segment. |
|
||||
| `fidelity_metrics.py` | Compute objective image-fidelity metrics for paired outputs. |
|
||||
@@ -26,7 +26,7 @@ explicitly names a tracked canonical result.
|
||||
| `sidecar_regression.py` | Compare current identification with recorded sidecars. |
|
||||
| `smoke_matrix.py` | Exercise CLI parameter choices on real local data. |
|
||||
| `video_fidelity_probe.py` | Compare delivered video fidelity with its source. |
|
||||
| `visible_eval.py` | Benchmark registered visible-mark detectors. |
|
||||
| `visible_eval.py` | Benchmark the adjudicated registry subset and expose unmeasured marks as scope-zero rows. |
|
||||
| `visible_removal_audit.py` | Audit visible-removal results over a local corpus. |
|
||||
| `watermark_benchmark.py` | Run hash-pinned image cases while keeping detection, removal observation, and fidelity separate. |
|
||||
| `watermark_benchmark_report.py` | Aggregate repeated benchmark results with artifact-aware counts and separate cold/warm timing. |
|
||||
@@ -44,15 +44,18 @@ explicitly names a tracked canonical result.
|
||||
| `synthid_corpus.py` | Ingest and inspect the local SynthID reference corpus. |
|
||||
| `vendor_cohort_harvest.py` | Partition TC260 carriers by producer code. |
|
||||
| `vendor_mark_calibrate.py` | Calibrate a candidate vendor text detector. |
|
||||
| `visible_alpha_solve.py` | Rebuild visible-watermark alpha assets from controlled captures. |
|
||||
| `visible_alpha_solve.py` | Rebuild visible-watermark alpha assets from controlled captures and the cleared Qwen symbol fixture. |
|
||||
| `visible_groundtruth.py` | Consolidate blinded contact-sheet labels into ground truth. |
|
||||
| `visible_positives.py` | List corpus images carrying a registered visible mark. |
|
||||
| `visible_positives.py` | Resumably scan registered marks plus metadata cohorts, and build blinded native-detail bands for uncovered and unbiased quiet samples. |
|
||||
| `visible_recall_sample.py` | Build an unbiased blinded sample for recall measurement. |
|
||||
| `visible_sheets.py` | Build blinded contact sheets for relaxation candidates. |
|
||||
| `watermark_benchmark_cohort.py` | Build the deterministic synthetic DWT-DCT and TrustMark cohort consumed by `watermark_benchmark.py`. |
|
||||
| `spectral_pipeline_probe.py` | Measure a prompt-grouped, phase-free spectral source-pipeline hypothesis without presenting it as SynthID detection. |
|
||||
| `source_pipeline_classifier.py` | Train or run an abstaining OpenAI/Google/unknown source-export classifier; research-only and not SynthID detection. |
|
||||
| `watermark_benchmark_real_cohort.py` | Build a publication-cleared, provider-paired and content-stratified real-image cohort. |
|
||||
| `retrain_photo_classify.py` | CPU-retrain the 2026-08-31 photo heads from a sha256-keyed cache pack, no images. |
|
||||
| `publish_photo_classify_hf.py` | Upload the photo-classify card and freeze weights to Hugging Face `wiltodelta/raiw-photo-classify`. Manual; the Action `publish-photo-classify-hf.yml` is the write-token path. |
|
||||
| `publish_source_classify_hf.py` | Upload the derivative source-pipeline model and its public card to Hugging Face `wiltodelta/raiw-source-classify`. Manual; the Action `publish-source-classify-hf.yml` is the write-token path. |
|
||||
| `verify_engine_selection_fixtures.py` | Verify hashes, dimensions, and prompt pairing in the tracked auto-engine content matrix. |
|
||||
|
||||
## Research and diagnostic prototypes
|
||||
@@ -85,5 +88,7 @@ running the sync command without `--check` is the explicit snapshot update.
|
||||
|
||||
`_plain_console.py` provides plain-text fallbacks for Rich output, and
|
||||
`_text_eval.py` contains normalization helpers shared by text-evaluation scripts.
|
||||
`hf_publish.py` owns the common authenticated, allowlisted Hugging Face model
|
||||
repository upload used by the two model publishers.
|
||||
`engine_selection_manifest.py` owns the content-matrix schema and validation
|
||||
shared by its integrity check and the real-image benchmark builder.
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Shared deterministic corpus walks and append-safe JSONL checkpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Collection
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def corpus_day(path: Path, corpus: Path) -> datetime.date | None:
|
||||
"""Read the leading YYYY-MM-DD corpus segment, if present."""
|
||||
try:
|
||||
return datetime.date.fromisoformat(path.relative_to(corpus).parts[0])
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def corpus_files(corpus: Path, since: datetime.date | None, *, suffixes: Collection[str] | None = None) -> list[Path]:
|
||||
"""Return a deterministic recursive walk, optionally date- and suffix-bounded."""
|
||||
if since is None:
|
||||
candidates = corpus.rglob("*")
|
||||
else:
|
||||
roots = [
|
||||
path
|
||||
for path in corpus.iterdir()
|
||||
if path.is_dir() and (day := corpus_day(path, corpus)) is not None and day >= since
|
||||
]
|
||||
candidates = (path for root in roots for path in root.rglob("*"))
|
||||
return sorted(
|
||||
path for path in candidates if path.is_file() and (suffixes is None or path.suffix.lower() in suffixes)
|
||||
)
|
||||
|
||||
|
||||
def repair_jsonl_tail(path: Path) -> None:
|
||||
"""Truncate 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"):
|
||||
stream.truncate(data.rfind(b"\n") + 1)
|
||||
+281
-110
@@ -2,8 +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, integrity clashes).
|
||||
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
|
||||
@@ -19,21 +21,51 @@ 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. A schema version prevents an older checkpoint from silently
|
||||
producing blank fields after the report grows. Use a new report name for a new
|
||||
code/corpus snapshot, or pass ``--restart`` when intentionally replacing the
|
||||
checkpoint. The final CSV is written atomically after every selected row is
|
||||
present. C2PA candidates include normalized signer, claim-generator, action,
|
||||
source-type, and validation fields for aggregate review without reopening every
|
||||
manifest.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import itertools
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from collections import Counter
|
||||
from concurrent.futures import FIRST_COMPLETED, Future, ProcessPoolExecutor, wait
|
||||
from importlib.metadata import version
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import date
|
||||
|
||||
import click
|
||||
from _corpus_scan import corpus_day, corpus_files, repair_jsonl_tail
|
||||
from _plain_console import Console, Table
|
||||
|
||||
from remove_ai_watermarks.identify import identify
|
||||
from remove_ai_watermarks.metadata import _png_late_metadata
|
||||
from remove_ai_watermarks.identify import _metadata_region as identify_metadata_region
|
||||
from remove_ai_watermarks.identify import extract_provenance_evidence, identify_from_evidence
|
||||
from remove_ai_watermarks.metadata import (
|
||||
IPTC_AI_FIELD_MARKERS,
|
||||
IPTC_AI_MARKERS,
|
||||
aigc_label_from_metadata,
|
||||
c2pa_marker_in,
|
||||
samsung_genai_in,
|
||||
scan_head,
|
||||
xai_signature_in_metadata,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
console = Console()
|
||||
@@ -44,22 +76,10 @@ console = Console()
|
||||
# below is the primary guard, this list is the second. Group: C2PA/JUMBF infra,
|
||||
# AI source-type / labeling schemes, and distinctive generator name strings.
|
||||
MARKERS: tuple[bytes, ...] = (
|
||||
# C2PA / JUMBF infrastructure and AI source-type / labeling schemes.
|
||||
b"c2pa",
|
||||
b"jumbf",
|
||||
b"contentauth",
|
||||
# AI source-type / labeling schemes that are meaningful by presence.
|
||||
b"trainedAlgorithmicMedia",
|
||||
b"digitalSourceType",
|
||||
b'"AIGC"',
|
||||
b"<TC260:AIGC>",
|
||||
b"TC260:AIGC",
|
||||
b"tc260.org.cn",
|
||||
b"AISystemUsed",
|
||||
b"SynthID",
|
||||
b"hf-job-id",
|
||||
b"genAIType",
|
||||
b"PhotoEditor_Re_Edit",
|
||||
b"Signature:",
|
||||
# Distinctive multi-word generator strings only. Bare single words (Luma,
|
||||
# Gemini, Sora, ...) are omitted: they collide with unrelated metadata prose
|
||||
# (e.g. "Luma" in Lightroom's EnhanceDenoiseLumaAmount), defeating precision.
|
||||
@@ -73,42 +93,43 @@ MARKERS: tuple[bytes, ...] = (
|
||||
b"Adobe Firefly",
|
||||
b"Black Forest",
|
||||
b"volcengine",
|
||||
b"Doubao",
|
||||
b"\xe8\xb1\x86\xe5\x8c\x85",
|
||||
b"Nano Banana",
|
||||
b"Stability AI",
|
||||
b"Samsung Galaxy",
|
||||
)
|
||||
REPORT_FIELDS: tuple[str, ...] = (
|
||||
"report_schema_version",
|
||||
"path",
|
||||
"suffix",
|
||||
"lib_version",
|
||||
"is_ai",
|
||||
"platform",
|
||||
"confidence",
|
||||
"watermarks",
|
||||
"signals",
|
||||
"c2pa_issuer",
|
||||
"c2pa_claim_generator",
|
||||
"c2pa_actions",
|
||||
"c2pa_source_type",
|
||||
"c2pa_validation",
|
||||
"integrity_clashes",
|
||||
"markers",
|
||||
"candidate_classes",
|
||||
"error",
|
||||
)
|
||||
_REPORT_SCHEMA_VERSION = "2"
|
||||
_DISPLAY_CANDIDATE_LIMIT = 50
|
||||
_WORKER_BATCH_SIZE = 16
|
||||
|
||||
|
||||
def _metadata_region(path: Path) -> bytes:
|
||||
"""Return only the bytes where provenance metadata can live, never the
|
||||
compressed pixel stream (which produces random short-token collisions)."""
|
||||
try:
|
||||
head = path.read_bytes()
|
||||
except OSError:
|
||||
return b""
|
||||
if head[:8] == b"\x89PNG\r\n\x1a\n":
|
||||
# All ancillary metadata chunks (window=0), via the library's own walker.
|
||||
return _png_late_metadata(path, 0)
|
||||
if head[:2] == b"\xff\xd8": # JPEG: APPn segments up to Start-Of-Scan
|
||||
out = bytearray()
|
||||
p = 2
|
||||
n = len(head)
|
||||
while p + 4 <= n and head[p] == 0xFF:
|
||||
marker = head[p + 1]
|
||||
if marker == 0xDA: # SOS -> compressed scan data follows
|
||||
break
|
||||
seg_len = (head[p + 2] << 8) | head[p + 3]
|
||||
out += head[p + 4 : p + 2 + seg_len]
|
||||
p += 2 + seg_len
|
||||
return bytes(out)
|
||||
return head[:65536] # webp/avif/heif/jxl: metadata sits near the head
|
||||
def _base_row(path: str, suffix: str, lib_version: str) -> dict[str, str]:
|
||||
row = dict.fromkeys(REPORT_FIELDS, "")
|
||||
row.update(report_schema_version=_REPORT_SCHEMA_VERSION, path=path, suffix=suffix, lib_version=lib_version)
|
||||
return row
|
||||
|
||||
|
||||
def _row(rep) -> dict[str, str]: # noqa: ANN001 (ProvenanceReport)
|
||||
def _row(rep, *, path: str, suffix: str, lib_version: str) -> dict[str, str]: # noqa: ANN001
|
||||
return {
|
||||
"path": "", # filled by caller (relative)
|
||||
**_base_row(path, suffix, lib_version),
|
||||
"is_ai": str(rep.is_ai_generated),
|
||||
"platform": rep.platform or "",
|
||||
"confidence": rep.confidence,
|
||||
@@ -118,6 +139,162 @@ def _row(rep) -> dict[str, str]: # noqa: ANN001 (ProvenanceReport)
|
||||
}
|
||||
|
||||
|
||||
def _marker_hits(region: bytes) -> list[str]:
|
||||
"""Return high-precision marker labels found in one metadata region."""
|
||||
folded = region.lower()
|
||||
hits = {marker.decode("latin-1", "replace") for marker in MARKERS if marker.lower() in folded}
|
||||
if c2pa_marker_in(region):
|
||||
hits.add("C2PA/JUMBF")
|
||||
if aigc_label_from_metadata(region) is not None:
|
||||
hits.add("TC260 AIGC")
|
||||
if any(marker in region for marker in IPTC_AI_MARKERS + IPTC_AI_FIELD_MARKERS):
|
||||
hits.add("IPTC AI disclosure")
|
||||
if samsung_genai_in(region) is not None:
|
||||
hits.add("Samsung genAIType")
|
||||
if xai_signature_in_metadata(region):
|
||||
hits.add("xAI signature pair")
|
||||
return sorted(hits)
|
||||
|
||||
|
||||
def _marker_hits_for_path(path: Path) -> list[str]:
|
||||
"""Return bounded metadata hits without turning an unreadable row into a fatal scan."""
|
||||
try:
|
||||
return _marker_hits(identify_metadata_region(scan_head(path)))
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def _candidate_classes(rep: Any, hits: list[str]) -> list[str]:
|
||||
"""Classify review-worthy outcomes without treating them as proven bugs."""
|
||||
classes: list[str] = []
|
||||
if hits and not rep.is_ai_generated and not rep.signals:
|
||||
classes.append("blind_marker")
|
||||
if rep.is_ai_generated and not rep.platform:
|
||||
classes.append("unattributed_ai")
|
||||
elif rep.signals and not rep.platform:
|
||||
classes.append("unattributed_signal")
|
||||
if rep.integrity_clashes:
|
||||
classes.append("integrity_clash")
|
||||
return classes
|
||||
|
||||
|
||||
def _day_of(path: Path, corpus: Path) -> date | None:
|
||||
"""Read the leading YYYY-MM-DD corpus segment, if this layout has one."""
|
||||
return corpus_day(path, corpus)
|
||||
|
||||
|
||||
def _files(corpus: Path, since: date | None) -> list[Path]:
|
||||
"""Return the deterministic corpus walk, optionally bounded by its date segment."""
|
||||
return corpus_files(corpus, since)
|
||||
|
||||
|
||||
def _scan_one(args: tuple[str, str, str]) -> dict[str, str]:
|
||||
"""Scan one path in a worker process and always return a report row."""
|
||||
path_str, relative_path, lib_version = args
|
||||
path = Path(path_str)
|
||||
suffix = path.suffix.lower()
|
||||
try:
|
||||
evidence = extract_provenance_evidence(path)
|
||||
rep = identify_from_evidence(evidence)
|
||||
except Exception as exc:
|
||||
log.warning("identify failed on %s: %s", relative_path, exc)
|
||||
row = _base_row(relative_path, suffix, lib_version)
|
||||
row["candidate_classes"] = "identify_error"
|
||||
row["error"] = f"{type(exc).__name__}: {exc}"[:300].replace("\n", " ")
|
||||
else:
|
||||
row = _row(rep, path=relative_path, suffix=suffix, lib_version=lib_version)
|
||||
c2pa = evidence.c2pa_info
|
||||
row.update(
|
||||
c2pa_issuer=str(c2pa.get("issuer", "")),
|
||||
c2pa_claim_generator=str(c2pa.get("claim_generator", "")),
|
||||
c2pa_actions=str(c2pa.get("actions", "")),
|
||||
c2pa_source_type=str(c2pa.get("source_type", "")),
|
||||
c2pa_validation=str(c2pa.get("c2pa_validation_state", "")),
|
||||
)
|
||||
hits = _marker_hits(identify_metadata_region(evidence.scan))
|
||||
row["markers"] = "|".join(hits)
|
||||
row["candidate_classes"] = "|".join(_candidate_classes(rep, hits))
|
||||
return row
|
||||
row["markers"] = "|".join(_marker_hits_for_path(path))
|
||||
return row
|
||||
|
||||
|
||||
def _scan_batch(args: tuple[tuple[str, str, str], ...]) -> list[dict[str, str]]:
|
||||
"""Scan a small path batch to amortize process-pool dispatch overhead."""
|
||||
return [_scan_one(item) for item in args]
|
||||
|
||||
|
||||
def _read_checkpoint(path: Path) -> dict[str, dict[str, str]]:
|
||||
"""Read complete JSONL rows, tolerating an interrupted final write."""
|
||||
rows: dict[str, dict[str, str]] = {}
|
||||
if not path.exists():
|
||||
return rows
|
||||
with path.open(encoding="utf-8") as stream:
|
||||
for line in stream:
|
||||
try:
|
||||
row = json.loads(line)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
continue
|
||||
if (
|
||||
isinstance(row, dict)
|
||||
and row.get("report_schema_version") == _REPORT_SCHEMA_VERSION
|
||||
and isinstance(row.get("path"), str)
|
||||
):
|
||||
rows[row["path"]] = {field: str(row.get(field, "")) for field in REPORT_FIELDS}
|
||||
return rows
|
||||
|
||||
|
||||
def _repair_checkpoint(path: Path) -> None:
|
||||
"""Remove an interrupted final JSONL fragment before another append."""
|
||||
repair_jsonl_tail(path)
|
||||
|
||||
|
||||
def _summarize(rows: list[dict[str, str]]) -> None:
|
||||
"""Print bounded aggregate output for a completed or resumed report."""
|
||||
verdicts: Counter[str] = Counter()
|
||||
platforms: Counter[str] = Counter()
|
||||
shapes: Counter[str] = Counter()
|
||||
errors = 0
|
||||
for row in rows:
|
||||
shapes[row["suffix"] or "(none)"] += 1
|
||||
if row["error"]:
|
||||
errors += 1
|
||||
elif row["is_ai"] == "True":
|
||||
verdicts["ai"] += 1
|
||||
platforms[row["platform"] or "?"] += 1
|
||||
else:
|
||||
verdicts["unknown"] += 1
|
||||
|
||||
console.print(f"\n[bold]Verdicts:[/bold] AI {verdicts['ai']} | unknown {verdicts['unknown']} | errors {errors}")
|
||||
console.print("[bold]Input shapes:[/bold] " + " | ".join(f"{name} {n}" for name, n in shapes.most_common()))
|
||||
plat = Table(title="AI platforms", show_header=False)
|
||||
for name, count in platforms.most_common():
|
||||
plat.add_row(str(count), name)
|
||||
console.print(plat)
|
||||
|
||||
candidates = [row for row in rows if row["candidate_classes"]]
|
||||
if not candidates:
|
||||
console.print("\n[green]No review candidates in this run.[/green]")
|
||||
return
|
||||
candidate_counts = Counter(candidate for row in candidates for candidate in row["candidate_classes"].split("|"))
|
||||
gap_tokens = Counter(marker for row in rows for marker in row["markers"].split("|") if marker)
|
||||
console.print(
|
||||
f"\n[bold red]Review candidates[/bold red]: {len(candidates)} file(s) "
|
||||
f"({', '.join(f'{name}={n}' for name, n in candidate_counts.most_common())})"
|
||||
)
|
||||
tok = Table(title="metadata markers seen across the run")
|
||||
tok.add_column("count", justify="right")
|
||||
tok.add_column("marker")
|
||||
for name, count in gap_tokens.most_common():
|
||||
tok.add_row(str(count), name)
|
||||
console.print(tok)
|
||||
for row in candidates[:_DISPLAY_CANDIDATE_LIMIT]:
|
||||
detail = f"; markers={row['markers'].replace('|', ', ')}" if row["markers"] else ""
|
||||
console.print(f" {row['path']} -> {row['candidate_classes'].replace('|', ', ')}{detail}")
|
||||
if (hidden := len(candidates) - _DISPLAY_CANDIDATE_LIMIT) > 0:
|
||||
console.print(f" ... {hidden} more candidate row(s); inspect the CSV for the complete set")
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--corpus",
|
||||
@@ -132,82 +309,76 @@ def _row(rep) -> dict[str, str]: # noqa: ANN001 (ProvenanceReport)
|
||||
default=None,
|
||||
help="Write the per-file CSV here (default: <corpus>/../detector_report.csv).",
|
||||
)
|
||||
@click.option(
|
||||
"--since",
|
||||
type=click.DateTime(formats=["%Y-%m-%d"]),
|
||||
default=None,
|
||||
help="Only scan files under YYYY-MM-DD corpus directories on or after this date.",
|
||||
)
|
||||
@click.option("--limit", type=int, default=0, help="Scan at most N files (0 = all).")
|
||||
def main(corpus: Path, report: Path | None, limit: int) -> None:
|
||||
@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"
|
||||
|
||||
files = sorted(p for p in corpus.rglob("*") if p.is_file())
|
||||
since_date = since.date() if since is not None else None
|
||||
files = _files(corpus, since_date)
|
||||
if limit:
|
||||
files = files[:limit]
|
||||
console.print(f"Scanning [bold]{len(files)}[/bold] files under {corpus} ...")
|
||||
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}"
|
||||
)
|
||||
|
||||
verdicts: Counter[str] = Counter()
|
||||
platforms: Counter[str] = Counter()
|
||||
gap_tokens: Counter[str] = Counter()
|
||||
gaps: list[tuple[str, list[str]]] = []
|
||||
rows: list[dict[str, str]] = []
|
||||
errors = 0
|
||||
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)
|
||||
|
||||
with click.progressbar(files, label="identify") as bar:
|
||||
for p in bar:
|
||||
rel = str(p.relative_to(corpus))
|
||||
try:
|
||||
rep = identify(p)
|
||||
except Exception as exc:
|
||||
log.warning("identify failed on %s: %s", rel, exc)
|
||||
errors += 1
|
||||
continue
|
||||
row = _row(rep)
|
||||
row["path"] = rel
|
||||
rows.append(row)
|
||||
if rep.is_ai_generated:
|
||||
verdicts["ai"] += 1
|
||||
platforms[rep.platform or "?"] += 1
|
||||
continue
|
||||
verdicts["unknown"] += 1
|
||||
# A gap candidate is a file identify is *blind* to (no signal at all)
|
||||
# yet whose metadata carries a known marker. A file that produced a
|
||||
# signal but no AI verdict (e.g. an ASUS Gallery C2PA signer, which we
|
||||
# attribute but do not call AI) is handled correctly -- not a gap.
|
||||
if rep.signals:
|
||||
continue
|
||||
region = _metadata_region(p)
|
||||
hits = sorted({m.decode("latin-1", "replace") for m in MARKERS if m in region})
|
||||
if hits:
|
||||
gaps.append((rel, hits))
|
||||
gap_tokens.update(hits)
|
||||
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
|
||||
|
||||
with report.open("w", newline="") as f:
|
||||
writer = csv.DictWriter(
|
||||
f,
|
||||
fieldnames=["path", "is_ai", "platform", "confidence", "watermarks", "signals", "integrity_clashes"],
|
||||
)
|
||||
pending = {future for _ in range(workers * 2) if (future := submit_batch()) is not None}
|
||||
processed = 0
|
||||
next_progress = 500
|
||||
while pending:
|
||||
ready, pending = wait(pending, return_when=FIRST_COMPLETED)
|
||||
for future in ready:
|
||||
for row in future.result():
|
||||
completed[row["path"]] = row
|
||||
stream.write(json.dumps(row, ensure_ascii=False) + "\n")
|
||||
processed += 1
|
||||
if replacement := submit_batch():
|
||||
pending.add(replacement)
|
||||
stream.flush()
|
||||
if processed >= next_progress:
|
||||
console.print(
|
||||
f" {processed}/{len(todo)} new; {len(completed)}/{len(files)} total",
|
||||
highlight=False,
|
||||
)
|
||||
next_progress += 500
|
||||
|
||||
rows = [completed[relative] for _, relative in selected]
|
||||
report.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary_report = report.with_suffix(report.suffix + ".tmp")
|
||||
with temporary_report.open("w", newline="") as stream:
|
||||
writer = csv.DictWriter(stream, fieldnames=REPORT_FIELDS)
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
temporary_report.replace(report)
|
||||
console.print(f"\nWrote [bold]{len(rows)}[/bold] rows -> {report}")
|
||||
|
||||
console.print(f"\n[bold]Verdicts:[/bold] AI {verdicts['ai']} | unknown {verdicts['unknown']} | errors {errors}")
|
||||
plat = Table(title="AI platforms", show_header=False)
|
||||
for name, n in platforms.most_common():
|
||||
plat.add_row(str(n), name)
|
||||
console.print(plat)
|
||||
|
||||
if gaps:
|
||||
console.print(
|
||||
f"\n[bold red]Gap candidates[/bold red]: {len(gaps)} unknown files carry a known "
|
||||
f"marker in their metadata region (potential undetected serialization/generator):"
|
||||
)
|
||||
tok = Table(title="markers seen in unknown files")
|
||||
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 rel, hits in gaps:
|
||||
console.print(f" {rel} -> {', '.join(hits)}")
|
||||
else:
|
||||
console.print("\n[green]No gap candidates: every unknown file is metadata-free.[/green]")
|
||||
_summarize(rows)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Export the frozen CLIP-L vision tower as a static FP32 ONNX graph.
|
||||
|
||||
Run from the repository root with the ONNX exporter installed temporarily:
|
||||
|
||||
uv run --with onnx python scripts/export_photo_classify_onnx.py \
|
||||
--checkpoint /path/to/clip-l-ft.pt \
|
||||
--out /path/to/clip-l-ft-vision-fp32.onnx
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import inspect
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
from torch import nn
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VisionEmbedding(nn.Module):
|
||||
"""Only the vision modules used by the classifier's embedding path."""
|
||||
|
||||
def __init__(self, headed: Any) -> None:
|
||||
super().__init__()
|
||||
self.vision_model = headed.clip.vision_model
|
||||
self.visual_projection = headed.clip.visual_projection
|
||||
|
||||
def forward(self, pixel_values: Any) -> Any:
|
||||
pooled = self.vision_model(pixel_values=pixel_values).pooler_output
|
||||
return nn.functional.normalize(self.visual_projection(pooled), dim=-1)
|
||||
|
||||
|
||||
def export_onnx(checkpoint: Path, output: Path) -> None:
|
||||
"""Write the static batch-one graph consumed by the ONNX runtime backend."""
|
||||
from remove_ai_watermarks._internal.clip_l_ft import SIZE, load_headed_clip
|
||||
|
||||
checkpoint = checkpoint.expanduser().resolve()
|
||||
output = output.expanduser().resolve()
|
||||
if not checkpoint.is_file():
|
||||
raise SystemExit(f"missing checkpoint: {checkpoint}")
|
||||
if output.exists():
|
||||
raise SystemExit(f"refusing to overwrite existing output: {output}")
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
model = VisionEmbedding(load_headed_clip(checkpoint, torch.device("cpu"))).eval()
|
||||
export_options: dict[str, Any] = {}
|
||||
if "dynamo" in inspect.signature(torch.onnx.export).parameters:
|
||||
export_options["dynamo"] = False
|
||||
with torch.inference_mode():
|
||||
torch.onnx.export(
|
||||
model,
|
||||
(torch.zeros(1, 3, SIZE, SIZE, dtype=torch.float32),),
|
||||
output,
|
||||
input_names=["pixel_values"],
|
||||
output_names=["embedding"],
|
||||
opset_version=17,
|
||||
do_constant_folding=True,
|
||||
**export_options,
|
||||
)
|
||||
with output.open("rb") as handle:
|
||||
digest = hashlib.file_digest(handle, "sha256").hexdigest()
|
||||
log.info("wrote %s bytes=%d sha256=%s", output, output.stat().st_size, digest)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--checkpoint", type=Path, required=True)
|
||||
parser.add_argument("--out", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
export_onnx(args.checkpoint, args.out)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Shared Hugging Face model-repository publication helper."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def publish_model_folder(
|
||||
stage: Path,
|
||||
*,
|
||||
repo_id: str,
|
||||
token: str,
|
||||
message: str,
|
||||
allow_patterns: Iterable[str],
|
||||
) -> str:
|
||||
"""Upload an explicit file allowlist and return the Hub revision."""
|
||||
from huggingface_hub import HfApi, create_repo
|
||||
|
||||
create_repo(repo_id, repo_type="model", exist_ok=True, private=False, token=token)
|
||||
commit = HfApi(token=token).upload_folder(
|
||||
folder_path=str(stage),
|
||||
repo_id=repo_id,
|
||||
repo_type="model",
|
||||
commit_message=message,
|
||||
allow_patterns=list(allow_patterns),
|
||||
)
|
||||
return getattr(commit, "oid", "") or ""
|
||||
@@ -2,8 +2,8 @@
|
||||
"""Publish the photo-classify freeze to Hugging Face wiltodelta/raiw-photo-classify.
|
||||
|
||||
Manual. Not a library release step. Weights are not in git: pass --src, a
|
||||
directory that holds clip-l-ft.pt and the probe, with detector.pt and
|
||||
provider.pt either at the root or in run1/.
|
||||
directory that holds clip-l-ft.pt, clip-l-ft-vision-fp32.onnx, and the probe,
|
||||
with detector.pt and provider.pt either at the root or in run1/.
|
||||
|
||||
uv run python scripts/publish_photo_classify_hf.py --src DIR
|
||||
uv run python scripts/publish_photo_classify_hf.py --card-only
|
||||
@@ -24,14 +24,17 @@ import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from hf_publish import publish_model_folder
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
HUB_REPO = "wiltodelta/raiw-photo-classify"
|
||||
CLIP_FILE = "clip-l-ft.pt"
|
||||
CLIP_ONNX_FILE = "clip-l-ft-vision-fp32.onnx"
|
||||
PROBE_FILE = "probe-weights-clip-l-ft.npz"
|
||||
DETECTOR_FILE = "detector.pt"
|
||||
PROVIDER_FILE = "provider.pt"
|
||||
WEIGHT_FILES = (CLIP_FILE, PROBE_FILE, DETECTOR_FILE, PROVIDER_FILE)
|
||||
WEIGHT_FILES = (CLIP_FILE, CLIP_ONNX_FILE, PROBE_FILE, DETECTOR_FILE, PROVIDER_FILE)
|
||||
GATE_FILE = "receipt-gate-2026-09-02.npz"
|
||||
GATE_STABLE_FILE = "receipt-gate.npz"
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -68,7 +71,7 @@ def stage_card(dest: Path) -> None:
|
||||
shutil.copy2(readme, dest / "README.md")
|
||||
shutil.copy2(operating, dest / "operating-point.json")
|
||||
# The gate head versions with the model: card mode carries it even when
|
||||
# the four freeze weight files are not being re-uploaded.
|
||||
# the five freeze weight files are not being re-uploaded.
|
||||
shutil.copy2(GATE_SOURCE, dest / GATE_FILE)
|
||||
|
||||
|
||||
@@ -91,18 +94,13 @@ def stage_weights(dest: Path, src: Path) -> None:
|
||||
|
||||
|
||||
def publish(stage: Path, *, token: str, message: str) -> str:
|
||||
from huggingface_hub import HfApi, create_repo
|
||||
|
||||
create_repo(HUB_REPO, repo_type="model", exist_ok=True, private=False, token=token)
|
||||
api = HfApi(token=token)
|
||||
commit = api.upload_folder(
|
||||
folder_path=str(stage),
|
||||
return publish_model_folder(
|
||||
stage,
|
||||
repo_id=HUB_REPO,
|
||||
repo_type="model",
|
||||
commit_message=message,
|
||||
token=token,
|
||||
message=message,
|
||||
allow_patterns=["README.md", "operating-point.json", *WEIGHT_FILES, GATE_FILE, GATE_STABLE_FILE],
|
||||
)
|
||||
return getattr(commit, "oid", "") or ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Publish the source-pipeline model to Hugging Face.
|
||||
|
||||
The derivative weight file is downloaded by the GitHub Action from a model
|
||||
freeze release. The private corpus, feature caches, and training catalog never
|
||||
enter this repository or the Hub staging directory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from hf_publish import publish_model_folder
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
HUB_REPO = "wiltodelta/raiw-source-classify"
|
||||
MODEL_FILE = "source-pipeline-mlp.npz"
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
CARD_DIR = REPO_ROOT / "docs" / "source-classify-hf"
|
||||
PUBLIC_FILES = ("README.md", "metrics.json", MODEL_FILE)
|
||||
|
||||
|
||||
def stage_release(dest: Path, src: Path) -> None:
|
||||
"""Stage the two tracked documents and one derivative model artifact."""
|
||||
sources = {
|
||||
"README.md": CARD_DIR / "README.md",
|
||||
"metrics.json": CARD_DIR / "metrics.json",
|
||||
MODEL_FILE: src.expanduser().resolve() / MODEL_FILE,
|
||||
}
|
||||
missing = [str(path) for path in sources.values() if not path.is_file()]
|
||||
if missing:
|
||||
raise SystemExit(f"missing publication files: {', '.join(missing)}")
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
for name, path in sources.items():
|
||||
shutil.copy2(path, dest / name)
|
||||
|
||||
|
||||
def publish(stage: Path, *, token: str, message: str) -> str:
|
||||
"""Upload the exact staged allowlist and return the Hub revision."""
|
||||
return publish_model_folder(
|
||||
stage,
|
||||
repo_id=HUB_REPO,
|
||||
token=token,
|
||||
message=message,
|
||||
allow_patterns=PUBLIC_FILES,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--src", type=Path, required=True)
|
||||
parser.add_argument("--message", default="Publish source-pipeline classifier")
|
||||
args = parser.parse_args()
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
token = os.environ.get("HF_TOKEN", "").strip()
|
||||
if not token:
|
||||
raise SystemExit("HF_TOKEN is not set (write role required)")
|
||||
with tempfile.TemporaryDirectory(prefix="raiw-source-hf-") as tmp:
|
||||
stage = Path(tmp)
|
||||
stage_release(stage, args.src)
|
||||
revision = publish(stage, token=token, message=args.message)
|
||||
log.info("published %s revision=%s", HUB_REPO, revision or "(unknown)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -59,7 +59,7 @@ OUT = REPO / ".local-eval" / "sidecar-regression.jsonl"
|
||||
|
||||
# Map a watermark description to a stable behavior family. Order matters: the first
|
||||
# matching pattern wins, so put the specific tokens above the generic ones.
|
||||
_FAMILIES: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
_LEGACY_FAMILIES: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("synthid", ("synthid",)),
|
||||
("visible_sparkle", ("sparkle",)),
|
||||
("visible_doubao", ("豆包", "doubao")),
|
||||
@@ -71,27 +71,61 @@ _FAMILIES: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("trustmark", ("trustmark",)),
|
||||
("open_invisible", ("open invisible", "dwt", "stable diffusion xl")),
|
||||
("xai_signature", ("xai", "grok signature")),
|
||||
("samsung_genai", ("genaitype", "photoeditor_re_edit")),
|
||||
("soft_binding", ("soft binding", "soft-binding", "invismark")),
|
||||
("c2pa_cloud", ("cloud manifest",)),
|
||||
("hf_job", ("hf-job", "hugging face job")),
|
||||
("exif_generator", ("exif", "software tag", "png text")),
|
||||
)
|
||||
|
||||
|
||||
def _registered_visible_family(description: str) -> str | None:
|
||||
"""Map current mark labels from the registry instead of copying its keys here."""
|
||||
from remove_ai_watermarks.watermark_registry import known_marks
|
||||
|
||||
low = description.lower()
|
||||
matches = [
|
||||
(mark.label.lower() in low, len(mark.label), mark)
|
||||
for mark in known_marks()
|
||||
if mark.key != "gemini" and (mark.label.lower() in low or mark.key.replace("_", " ") in low)
|
||||
]
|
||||
if not matches:
|
||||
return None
|
||||
_, _, mark = max(matches, key=lambda candidate: candidate[:2])
|
||||
return f"visible_{mark.key}"
|
||||
|
||||
|
||||
def family_of(description: str) -> str:
|
||||
low = description.lower()
|
||||
for name, tokens in _FAMILIES:
|
||||
if family := _registered_visible_family(description):
|
||||
return family
|
||||
for name, tokens in _LEGACY_FAMILIES:
|
||||
if any(t in low for t in tokens):
|
||||
return name
|
||||
return "other"
|
||||
|
||||
|
||||
def families(descriptions: list[str]) -> set[str]:
|
||||
return {family_of(d) for d in descriptions or []}
|
||||
return {family for description in descriptions or [] if (family := family_of(description)) != "other"}
|
||||
|
||||
|
||||
def sidecar_families(sidecar: dict) -> set[str]:
|
||||
"""Combine stable names with legacy-only families such as SynthID."""
|
||||
signals = sidecar.get("signals") or []
|
||||
return {str(signal) for signal in signals} | families(sidecar.get("watermarks") or [])
|
||||
|
||||
|
||||
def report_families(report: object) -> set[str]:
|
||||
"""Combine current Signal.name values with watermark-only families."""
|
||||
signal_names = {str(signal.name) for signal in getattr(report, "signals", []) or []}
|
||||
return signal_names | families(list(getattr(report, "watermarks", []) or []))
|
||||
|
||||
|
||||
def compare(sidecar: dict, report: object) -> dict:
|
||||
"""Classify the difference between a recorded verdict and a fresh one."""
|
||||
old_ai, new_ai = sidecar.get("is_ai_generated"), getattr(report, "is_ai_generated", None)
|
||||
old_fam = families(sidecar.get("watermarks") or [])
|
||||
new_fam = families(list(getattr(report, "watermarks", []) or []))
|
||||
old_fam = sidecar_families(sidecar)
|
||||
new_fam = report_families(report)
|
||||
old_plat, new_plat = sidecar.get("platform"), getattr(report, "platform", None)
|
||||
old_conf, new_conf = sidecar.get("confidence"), getattr(report, "confidence", None)
|
||||
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
"""Train or run an abstaining source/export-pipeline classifier.
|
||||
|
||||
This research tool combines the library's 124-d forensic descriptor with the
|
||||
phase-free spectral descriptor from ``spectral_pipeline_probe.py``. Its labels
|
||||
describe complete generation and export pipelines. It does not decode SynthID
|
||||
or establish that a watermark is present.
|
||||
|
||||
Expected training layout::
|
||||
|
||||
train/openai/*.png
|
||||
train/google/*.png
|
||||
train/unknown/*.png
|
||||
calibration/openai/*.png
|
||||
calibration/google/*.png
|
||||
calibration/unknown/*.png
|
||||
|
||||
Examples::
|
||||
|
||||
uv run python scripts/source_pipeline_classifier.py train \
|
||||
train calibration --model-out model.npz
|
||||
uv run python scripts/source_pipeline_classifier.py predict \
|
||||
model.npz image.png
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, replace
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import click
|
||||
import numpy as np
|
||||
import spectral_pipeline_probe as spectral
|
||||
from numpy.typing import NDArray
|
||||
from PIL import Image
|
||||
from watermark_benchmark import sha256_file
|
||||
|
||||
from remove_ai_watermarks._internal.forensic_124d import FEATURE_WIDTH, PATCH, image_features
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
CLASSES = ("openai", "google", "unknown")
|
||||
UNKNOWN_INDEX = CLASSES.index("unknown")
|
||||
DEFAULT_GRID = 16
|
||||
DEFAULT_PENALTY = 100.0
|
||||
DEFAULT_UNKNOWN_FPR = 0.01
|
||||
CLAIM = "source/export pipeline classification; not SynthID detection"
|
||||
|
||||
FloatArray = NDArray[np.float64]
|
||||
IntArray = NDArray[np.int64]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RidgeModel:
|
||||
"""One standardized three-class ridge head with an abstention margin."""
|
||||
|
||||
mean: FloatArray
|
||||
scale: FloatArray
|
||||
weights: FloatArray
|
||||
margin: float
|
||||
grid: int
|
||||
penalty: float
|
||||
|
||||
|
||||
def discover_labeled_paths(root: Path) -> tuple[list[Path], IntArray, list[str]]:
|
||||
"""Find images under the three fixed label directories."""
|
||||
paths: list[Path] = []
|
||||
labels: list[int] = []
|
||||
hashes: list[str] = []
|
||||
for class_index, class_name in enumerate(CLASSES):
|
||||
directory = root / class_name
|
||||
if not directory.is_dir():
|
||||
raise ValueError(f"class directory does not exist: {directory}")
|
||||
class_paths = sorted(
|
||||
path for path in directory.rglob("*") if path.is_file() and path.suffix.lower() in spectral.IMAGE_SUFFIXES
|
||||
)
|
||||
if not class_paths:
|
||||
raise ValueError(f"class directory has no supported images: {directory}")
|
||||
for path in class_paths:
|
||||
paths.append(path)
|
||||
labels.append(class_index)
|
||||
hashes.append(sha256_file(path))
|
||||
if len(hashes) != len(set(hashes)):
|
||||
raise ValueError(f"duplicate image bytes found inside {root}")
|
||||
return paths, np.asarray(labels, dtype=np.int64), hashes
|
||||
|
||||
|
||||
def _feature_from_path(path: Path, geometry: spectral.SpectralGeometry) -> FloatArray | None:
|
||||
"""Return the fused representation for one path and prepared geometry."""
|
||||
with Image.open(path) as source:
|
||||
image = source.convert("RGB")
|
||||
forensic = image_features(np.asarray(image, dtype=np.uint8))
|
||||
if forensic is None:
|
||||
return None
|
||||
phase_free = spectral.feature_from_image(image, geometry, "original")
|
||||
return np.concatenate(
|
||||
(
|
||||
np.asarray(forensic, dtype=np.float64),
|
||||
np.asarray(phase_free, dtype=np.float64),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def feature_from_path(path: Path, grid: int) -> FloatArray | None:
|
||||
"""Return the fused forensic and phase-free spectral representation."""
|
||||
return _feature_from_path(path, spectral.make_geometry(size=PATCH, grid=grid))
|
||||
|
||||
|
||||
def extract_features(paths: list[Path], labels: IntArray, grid: int) -> tuple[FloatArray, IntArray, list[Path]]:
|
||||
"""Extract valid rows and report files whose forensic feature abstains."""
|
||||
geometry = spectral.make_geometry(size=PATCH, grid=grid)
|
||||
features: list[FloatArray] = []
|
||||
kept_labels: list[int] = []
|
||||
skipped: list[Path] = []
|
||||
for index, (path, label) in enumerate(zip(paths, labels, strict=True), start=1):
|
||||
try:
|
||||
feature = _feature_from_path(path, geometry)
|
||||
except (OSError, ValueError) as error:
|
||||
log.warning("Could not extract %s: %s", path, error)
|
||||
feature = None
|
||||
if feature is None:
|
||||
skipped.append(path)
|
||||
continue
|
||||
features.append(feature)
|
||||
kept_labels.append(int(label))
|
||||
if index % 100 == 0:
|
||||
log.info("Extracted %s/%s images", index, len(paths))
|
||||
if not features:
|
||||
raise ValueError("no image produced a valid feature vector")
|
||||
return np.asarray(features), np.asarray(kept_labels, dtype=np.int64), skipped
|
||||
|
||||
|
||||
def fit_ridge(
|
||||
features: FloatArray,
|
||||
labels: IntArray,
|
||||
penalty: float,
|
||||
grid: int = DEFAULT_GRID,
|
||||
) -> RidgeModel:
|
||||
"""Fit a class-balanced standardized ridge head before calibration."""
|
||||
if features.ndim != 2 or len(features) != len(labels):
|
||||
raise ValueError("features and labels must describe the same rows")
|
||||
counts = np.bincount(labels, minlength=len(CLASSES)).astype(np.float64)
|
||||
if np.any(counts == 0):
|
||||
raise ValueError("training data must contain all three classes")
|
||||
if penalty <= 0:
|
||||
raise ValueError("penalty must be positive")
|
||||
mean = features.mean(axis=0)
|
||||
raw_scale = features.std(axis=0)
|
||||
active = raw_scale > 1e-10
|
||||
scale = np.where(active, raw_scale, 1.0)
|
||||
normalized = (features[:, active] - mean[active]) / scale[active]
|
||||
sample_weight = len(labels) / (len(CLASSES) * counts[labels])
|
||||
root_weight = np.sqrt(sample_weight)[:, None]
|
||||
weighted = normalized * root_weight
|
||||
targets = np.eye(len(CLASSES), dtype=np.float64)[labels] * root_weight
|
||||
gram = weighted.T @ weighted + penalty * np.eye(weighted.shape[1])
|
||||
active_weights = cast("FloatArray", np.linalg.solve(gram, weighted.T @ targets))
|
||||
weights = np.zeros((features.shape[1], len(CLASSES)), dtype=np.float64)
|
||||
weights[active] = active_weights
|
||||
return RidgeModel(mean, scale, weights, 0.0, grid, penalty)
|
||||
|
||||
|
||||
def scores(features: FloatArray, model: RidgeModel) -> FloatArray:
|
||||
"""Return the uncalibrated ridge scores."""
|
||||
return ((features - model.mean) / model.scale) @ model.weights
|
||||
|
||||
|
||||
def decision_margins(raw_scores: FloatArray) -> FloatArray:
|
||||
"""Return each row's best-provider advantage over every alternative."""
|
||||
target_scores = raw_scores[:, :UNKNOWN_INDEX]
|
||||
best = np.argmax(target_scores, axis=1)
|
||||
other = 1 - best
|
||||
rows = np.arange(len(raw_scores))
|
||||
reference = np.maximum(raw_scores[rows, other], raw_scores[:, UNKNOWN_INDEX])
|
||||
return target_scores[rows, best] - reference
|
||||
|
||||
|
||||
def calibrate_margin(
|
||||
calibration_scores: FloatArray,
|
||||
calibration_labels: IntArray,
|
||||
unknown_fpr: float,
|
||||
) -> float:
|
||||
"""Choose the smallest empirical cut meeting the unknown false-positive target."""
|
||||
if not 0 <= unknown_fpr < 1:
|
||||
raise ValueError("unknown_fpr must be in [0, 1)")
|
||||
unknown_scores = calibration_scores[calibration_labels == UNKNOWN_INDEX]
|
||||
if not len(unknown_scores):
|
||||
raise ValueError("calibration data must contain unknown rows")
|
||||
return max(
|
||||
0.0,
|
||||
float(
|
||||
np.quantile(
|
||||
decision_margins(unknown_scores),
|
||||
1.0 - unknown_fpr,
|
||||
method="higher",
|
||||
)
|
||||
+ 1e-12
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def predict_scores(raw_scores: FloatArray, margin: float) -> IntArray:
|
||||
"""Return provider labels only when the calibrated margin is met."""
|
||||
best = np.argmax(raw_scores[:, :UNKNOWN_INDEX], axis=1)
|
||||
accepted = decision_margins(raw_scores) >= margin
|
||||
return np.where(accepted, best, UNKNOWN_INDEX).astype(np.int64)
|
||||
|
||||
|
||||
def save_model(path: Path, model: RidgeModel) -> None:
|
||||
"""Write a pickle-free model artifact."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
np.savez_compressed(
|
||||
path,
|
||||
classes=np.asarray(CLASSES),
|
||||
mean=model.mean,
|
||||
scale=model.scale,
|
||||
weights=model.weights,
|
||||
margin=np.asarray(model.margin),
|
||||
spectral_grid=np.asarray(model.grid),
|
||||
penalty=np.asarray(model.penalty),
|
||||
)
|
||||
|
||||
|
||||
def load_model(path: Path) -> RidgeModel:
|
||||
"""Load and validate a pickle-free model artifact."""
|
||||
with np.load(path, allow_pickle=False) as payload:
|
||||
required = {
|
||||
"classes",
|
||||
"mean",
|
||||
"scale",
|
||||
"weights",
|
||||
"margin",
|
||||
"spectral_grid",
|
||||
"penalty",
|
||||
}
|
||||
if set(payload.files) != required:
|
||||
raise ValueError(f"model fields must be exactly {sorted(required)}")
|
||||
classes = tuple(str(value) for value in payload["classes"])
|
||||
mean = np.asarray(payload["mean"], dtype=np.float64)
|
||||
scale = np.asarray(payload["scale"], dtype=np.float64)
|
||||
weights = np.asarray(payload["weights"], dtype=np.float64)
|
||||
margin = float(payload["margin"])
|
||||
grid = int(payload["spectral_grid"])
|
||||
penalty = float(payload["penalty"])
|
||||
expected_features = FEATURE_WIDTH + 3 * grid * grid
|
||||
if classes != CLASSES:
|
||||
raise ValueError(f"model classes must be {CLASSES}")
|
||||
if mean.shape != (expected_features,) or scale.shape != mean.shape:
|
||||
raise ValueError("model normalization vectors have the wrong shape")
|
||||
if weights.shape != (expected_features, len(CLASSES)):
|
||||
raise ValueError("model weights have the wrong shape")
|
||||
if not np.all(np.isfinite(mean)) or not np.all(np.isfinite(scale)):
|
||||
raise ValueError("model normalization vectors must be finite")
|
||||
if not np.all(np.isfinite(weights)) or np.any(scale <= 0):
|
||||
raise ValueError("model weights must be finite and scales positive")
|
||||
if not np.isfinite(margin) or margin < 0:
|
||||
raise ValueError("model margin must be finite and non-negative")
|
||||
if grid < 2 or PATCH % grid or not np.isfinite(penalty) or penalty <= 0:
|
||||
raise ValueError("model calibration fields are invalid")
|
||||
return RidgeModel(mean, scale, weights, margin, grid, penalty)
|
||||
|
||||
|
||||
@click.group()
|
||||
def main() -> None:
|
||||
"""Train or run the research-only source-pipeline classifier."""
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
||||
|
||||
|
||||
@main.command("train")
|
||||
@click.argument("train_root", type=click.Path(exists=True, file_okay=False, path_type=Path))
|
||||
@click.argument("calibration_root", type=click.Path(exists=True, file_okay=False, path_type=Path))
|
||||
@click.option("--model-out", type=click.Path(dir_okay=False, path_type=Path), required=True)
|
||||
@click.option("--grid", type=click.IntRange(min=2), default=DEFAULT_GRID, show_default=True)
|
||||
@click.option("--penalty", type=click.FloatRange(min=0.0, min_open=True), default=DEFAULT_PENALTY, show_default=True)
|
||||
@click.option(
|
||||
"--unknown-fpr",
|
||||
type=click.FloatRange(min=0.0, max=1.0, max_open=True),
|
||||
default=DEFAULT_UNKNOWN_FPR,
|
||||
show_default=True,
|
||||
)
|
||||
def train_command(
|
||||
train_root: Path,
|
||||
calibration_root: Path,
|
||||
model_out: Path,
|
||||
grid: int,
|
||||
penalty: float,
|
||||
unknown_fpr: float,
|
||||
) -> None:
|
||||
"""Fit on TRAIN_ROOT and calibrate abstention on CALIBRATION_ROOT."""
|
||||
if PATCH % grid:
|
||||
raise click.UsageError(f"--grid must divide {PATCH}")
|
||||
try:
|
||||
train_paths, train_labels, train_hashes = discover_labeled_paths(train_root)
|
||||
calibration_paths, calibration_labels, calibration_hashes = discover_labeled_paths(calibration_root)
|
||||
overlap = set(train_hashes) & set(calibration_hashes)
|
||||
if overlap:
|
||||
raise ValueError(f"train/calibration byte overlap: {len(overlap)} images")
|
||||
train_features, kept_train_labels, train_skipped = extract_features(train_paths, train_labels, grid)
|
||||
calibration_features, kept_calibration_labels, calibration_skipped = extract_features(
|
||||
calibration_paths, calibration_labels, grid
|
||||
)
|
||||
provisional = fit_ridge(train_features, kept_train_labels, penalty, grid)
|
||||
margin = calibrate_margin(
|
||||
scores(calibration_features, provisional),
|
||||
kept_calibration_labels,
|
||||
unknown_fpr,
|
||||
)
|
||||
model = replace(provisional, margin=margin)
|
||||
save_model(model_out, model)
|
||||
except ValueError as error:
|
||||
raise click.ClickException(str(error)) from error
|
||||
click.echo(
|
||||
json.dumps(
|
||||
{
|
||||
"model": str(model_out),
|
||||
"claim": CLAIM,
|
||||
"train_rows": len(train_features),
|
||||
"calibration_rows": len(calibration_features),
|
||||
"feature_abstentions": len(train_skipped) + len(calibration_skipped),
|
||||
"margin": margin,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@main.command("predict")
|
||||
@click.argument("model_path", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
||||
@click.argument("images", nargs=-1, required=True, type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
||||
def predict_command(model_path: Path, images: tuple[Path, ...]) -> None:
|
||||
"""Classify original-export IMAGES with MODEL_PATH."""
|
||||
try:
|
||||
model = load_model(model_path)
|
||||
except (OSError, ValueError) as error:
|
||||
raise click.ClickException(str(error)) from error
|
||||
results: list[dict[str, object]] = []
|
||||
geometry = spectral.make_geometry(size=PATCH, grid=model.grid)
|
||||
for path in images:
|
||||
try:
|
||||
feature = _feature_from_path(path, geometry)
|
||||
except (OSError, ValueError) as error:
|
||||
raise click.ClickException(f"could not extract {path}: {error}") from error
|
||||
if feature is None:
|
||||
results.append(
|
||||
{
|
||||
"path": str(path),
|
||||
"label": "unknown",
|
||||
"reason": "124-d feature unavailable",
|
||||
"claim": CLAIM,
|
||||
}
|
||||
)
|
||||
continue
|
||||
raw_scores = scores(feature[None, :], model)
|
||||
prediction = int(predict_scores(raw_scores, model.margin)[0])
|
||||
results.append(
|
||||
{
|
||||
"path": str(path),
|
||||
"label": CLASSES[prediction],
|
||||
"decision_margin": float(decision_margins(raw_scores)[0]),
|
||||
"required_margin": model.margin,
|
||||
"scores": {name: float(raw_scores[0, index]) for index, name in enumerate(CLASSES)},
|
||||
"claim": CLAIM,
|
||||
}
|
||||
)
|
||||
click.echo(json.dumps(results, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,296 @@
|
||||
"""Measure a phase-free spectral source-pipeline hypothesis.
|
||||
|
||||
This is a research harness, not a SynthID detector. It classifies complete
|
||||
generation/export pipelines whose renderer, output geometry, encoding, and
|
||||
watermark state may all be confounded. A watermark claim requires a causal
|
||||
marked/clean contrast and an independent oracle.
|
||||
|
||||
The input directory contains one subdirectory per class. Matching filename
|
||||
stems are treated as one prompt group and always stay in the same fold.
|
||||
|
||||
Example:
|
||||
uv run python scripts/spectral_pipeline_probe.py DATASET \
|
||||
--class-dir gemini --class-dir openai --class-dir flux \
|
||||
--attack original --attack crop2 --attack resize95 \
|
||||
--attack jpeg75 --attack blur \
|
||||
--report-out .local-eval/spectral-pipeline-report.json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import click
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
from PIL import Image, ImageFilter
|
||||
|
||||
from remove_ai_watermarks._internal import source_spectral as _spectral
|
||||
|
||||
SpectralGeometry = _spectral.SpectralGeometry
|
||||
make_geometry = _spectral.make_geometry
|
||||
phase_free_log_spectrum = _spectral.phase_free_log_spectrum
|
||||
spectral_feature = _spectral.spectral_feature
|
||||
_source_feature_from_image = _spectral.feature_from_image
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
IMAGE_SUFFIXES = frozenset({".jpg", ".jpeg", ".png", ".webp"})
|
||||
ATTACKS = ("original", "crop2", "resize95", "jpeg75", "blur")
|
||||
|
||||
FloatArray = NDArray[np.float64]
|
||||
IntArray = NDArray[np.int64]
|
||||
BoolArray = NDArray[np.bool_]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FoldModel:
|
||||
"""One standardized dual-ridge model and its held-out rows."""
|
||||
|
||||
test_rows: BoolArray
|
||||
mean: FloatArray
|
||||
scale: FloatArray
|
||||
active: BoolArray
|
||||
weights: FloatArray
|
||||
|
||||
|
||||
def _apply_attack(image: Image.Image, attack: str) -> Image.Image:
|
||||
"""Apply one declared robustness transform before canonical resizing."""
|
||||
if attack == "original":
|
||||
return image
|
||||
if attack == "crop2":
|
||||
if image.width <= 4 or image.height <= 4:
|
||||
raise ValueError("crop2 requires both image dimensions to exceed four pixels")
|
||||
return image.crop((2, 2, image.width, image.height))
|
||||
if attack == "resize95":
|
||||
return image.resize(
|
||||
(max(1, round(image.width * 0.95)), max(1, round(image.height * 0.95))),
|
||||
Image.Resampling.LANCZOS,
|
||||
)
|
||||
if attack == "jpeg75":
|
||||
encoded = io.BytesIO()
|
||||
image.save(encoded, format="JPEG", quality=75)
|
||||
encoded.seek(0)
|
||||
with Image.open(encoded) as decoded:
|
||||
return decoded.convert("RGB")
|
||||
if attack == "blur":
|
||||
return image.filter(ImageFilter.GaussianBlur(0.7))
|
||||
raise ValueError(f"unsupported attack: {attack}")
|
||||
|
||||
|
||||
def feature_from_path(path: Path, geometry: SpectralGeometry, attack: str) -> FloatArray:
|
||||
"""Decode one path, apply ATTACK, and return its phase-free feature."""
|
||||
with Image.open(path) as source:
|
||||
return feature_from_image(source.convert("RGB"), geometry, attack)
|
||||
|
||||
|
||||
def feature_from_image(image: Image.Image, geometry: SpectralGeometry, attack: str) -> FloatArray:
|
||||
"""Extract one attacked feature from an already decoded RGB image."""
|
||||
attacked = _apply_attack(image, attack)
|
||||
return _source_feature_from_image(attacked, geometry)
|
||||
|
||||
|
||||
def discover_grouped_paths(
|
||||
root: Path,
|
||||
class_dirs: tuple[str, ...],
|
||||
) -> tuple[list[Path], IntArray, IntArray, list[str]]:
|
||||
"""Return class-major paths over filename stems shared by every class."""
|
||||
if len(class_dirs) < 2 or len(set(class_dirs)) != len(class_dirs):
|
||||
raise ValueError("at least two unique class directories are required")
|
||||
per_class: list[dict[str, Path]] = []
|
||||
for class_dir in class_dirs:
|
||||
directory = root / class_dir
|
||||
if not directory.is_dir():
|
||||
raise ValueError(f"class directory does not exist: {directory}")
|
||||
mapping: dict[str, Path] = {}
|
||||
for path in sorted(directory.iterdir()):
|
||||
if path.is_file() and path.suffix.lower() in IMAGE_SUFFIXES:
|
||||
if path.stem in mapping:
|
||||
raise ValueError(f"duplicate filename stem in {directory}: {path.stem}")
|
||||
mapping[path.stem] = path
|
||||
per_class.append(mapping)
|
||||
common_names: set[str] = set(per_class[0])
|
||||
for mapping in per_class[1:]:
|
||||
common_names.intersection_update(mapping)
|
||||
group_names = sorted(common_names)
|
||||
if not group_names:
|
||||
raise ValueError("class directories have no shared filename stems")
|
||||
paths: list[Path] = []
|
||||
labels: list[int] = []
|
||||
groups: list[int] = []
|
||||
for class_index, mapping in enumerate(per_class):
|
||||
for group_index, group_name in enumerate(group_names):
|
||||
paths.append(mapping[group_name])
|
||||
labels.append(class_index)
|
||||
groups.append(group_index)
|
||||
return paths, np.asarray(labels), np.asarray(groups), group_names
|
||||
|
||||
|
||||
def fit_grouped_models(
|
||||
features: FloatArray,
|
||||
labels: IntArray,
|
||||
groups: IntArray,
|
||||
*,
|
||||
class_count: int,
|
||||
folds: int = 5,
|
||||
ridge: float = 1000.0,
|
||||
) -> list[FoldModel]:
|
||||
"""Fit prompt-grouped dual ridge models without test-fold leakage."""
|
||||
if features.ndim != 2 or len(features) != len(labels) or len(labels) != len(groups):
|
||||
raise ValueError("features, labels, and groups must describe the same rows")
|
||||
if folds < 2 or len(np.unique(groups)) < folds:
|
||||
raise ValueError("fold count must be between two and the number of groups")
|
||||
if class_count < 2 or ridge <= 0:
|
||||
raise ValueError("class_count must be at least two and ridge must be positive")
|
||||
targets = np.eye(class_count, dtype=np.float64)[labels]
|
||||
models: list[FoldModel] = []
|
||||
for fold in range(folds):
|
||||
test_rows = groups % folds == fold
|
||||
train_rows = ~test_rows
|
||||
train = features[train_rows]
|
||||
mean = np.mean(train, axis=0)
|
||||
scale = np.std(train, axis=0)
|
||||
active = scale > 1e-8
|
||||
train_features = (train[:, active] - mean[active]) / scale[active]
|
||||
kernel = train_features @ train_features.T
|
||||
dual_weights = cast(
|
||||
"FloatArray",
|
||||
np.linalg.solve(
|
||||
kernel + ridge * np.eye(len(kernel)),
|
||||
targets[train_rows],
|
||||
),
|
||||
)
|
||||
weights = train_features.T @ dual_weights
|
||||
models.append(FoldModel(test_rows, mean, scale, active, weights))
|
||||
return models
|
||||
|
||||
|
||||
def predict_grouped(models: list[FoldModel], features: FloatArray) -> IntArray:
|
||||
"""Predict every row only with the model that held out its group."""
|
||||
predictions = np.full(len(features), -1, dtype=np.int64)
|
||||
for model in models:
|
||||
test_features = (features[model.test_rows][:, model.active] - model.mean[model.active]) / model.scale[
|
||||
model.active
|
||||
]
|
||||
scores = test_features @ model.weights
|
||||
predictions[model.test_rows] = np.argmax(scores, axis=1)
|
||||
if np.any(predictions < 0):
|
||||
raise ValueError("fold models did not cover every row")
|
||||
return predictions
|
||||
|
||||
|
||||
def confusion_matrix(labels: IntArray, predictions: IntArray, class_count: int) -> IntArray:
|
||||
"""Count actual rows by predicted class."""
|
||||
matrix = np.zeros((class_count, class_count), dtype=np.int64)
|
||||
np.add.at(matrix, (labels, predictions), 1)
|
||||
return matrix
|
||||
|
||||
|
||||
def _extract_all_attacks(
|
||||
paths: list[Path],
|
||||
geometry: SpectralGeometry,
|
||||
attacks: tuple[str, ...],
|
||||
) -> dict[str, FloatArray]:
|
||||
"""Decode each path once and extract every requested attack feature."""
|
||||
log.info("Extracting %s attack views from %s images", len(attacks), len(paths))
|
||||
rows: dict[str, list[FloatArray]] = {attack: [] for attack in attacks}
|
||||
for path in paths:
|
||||
with Image.open(path) as source:
|
||||
image = source.convert("RGB")
|
||||
for attack in attacks:
|
||||
rows[attack].append(feature_from_image(image, geometry, attack))
|
||||
return {attack: np.asarray(features) for attack, features in rows.items()}
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("dataset", type=click.Path(exists=True, file_okay=False, path_type=Path))
|
||||
@click.option(
|
||||
"class_dirs",
|
||||
"--class-dir",
|
||||
multiple=True,
|
||||
required=True,
|
||||
help="Class subdirectory; repeat in label order.",
|
||||
)
|
||||
@click.option(
|
||||
"attacks",
|
||||
"--attack",
|
||||
multiple=True,
|
||||
type=click.Choice(ATTACKS),
|
||||
default=("original",),
|
||||
show_default=True,
|
||||
)
|
||||
@click.option("--size", type=click.IntRange(min=16), default=256, show_default=True)
|
||||
@click.option("--grid", type=click.IntRange(min=2), default=32, show_default=True)
|
||||
@click.option("--folds", type=click.IntRange(min=2), default=5, show_default=True)
|
||||
@click.option("--ridge", type=click.FloatRange(min=0.0, min_open=True), default=1000.0, show_default=True)
|
||||
@click.option("--report-out", type=click.Path(dir_okay=False, path_type=Path), required=True)
|
||||
def main(
|
||||
dataset: Path,
|
||||
class_dirs: tuple[str, ...],
|
||||
attacks: tuple[str, ...],
|
||||
size: int,
|
||||
grid: int,
|
||||
folds: int,
|
||||
ridge: float,
|
||||
report_out: Path,
|
||||
) -> None:
|
||||
"""Run grouped spectral pipeline classification over DATASET."""
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
||||
if "original" not in attacks:
|
||||
raise click.UsageError("--attack original is required because every model trains on original exports")
|
||||
unique_attacks = tuple(dict.fromkeys(attacks))
|
||||
try:
|
||||
geometry = make_geometry(size, grid)
|
||||
paths, labels, groups, group_names = discover_grouped_paths(dataset, class_dirs)
|
||||
features_by_attack = _extract_all_attacks(paths, geometry, unique_attacks)
|
||||
original = features_by_attack["original"]
|
||||
models = fit_grouped_models(
|
||||
original,
|
||||
labels,
|
||||
groups,
|
||||
class_count=len(class_dirs),
|
||||
folds=folds,
|
||||
ridge=ridge,
|
||||
)
|
||||
except ValueError as error:
|
||||
raise click.ClickException(str(error)) from error
|
||||
|
||||
results: dict[str, object] = {}
|
||||
for attack in unique_attacks:
|
||||
features = features_by_attack[attack]
|
||||
predictions = predict_grouped(models, features)
|
||||
matrix = confusion_matrix(labels, predictions, len(class_dirs))
|
||||
results[attack] = {
|
||||
"accuracy": float(np.trace(matrix) / np.sum(matrix)),
|
||||
"confusion_matrix": matrix.tolist(),
|
||||
}
|
||||
|
||||
report = {
|
||||
"claim": "phase-free generation/export pipeline classification; not SynthID detection",
|
||||
"causal_limit": (
|
||||
"Renderer, output geometry, encoding, and watermark state vary together. "
|
||||
"Only a marked/clean causal contrast plus an independent oracle can isolate the watermark."
|
||||
),
|
||||
"evaluation_limit": (
|
||||
"Grouped cross-validation estimates this corpus only. Hyperparameters selected on these folds "
|
||||
"need an independent locked corpus before they support a generalization claim."
|
||||
),
|
||||
"dataset": str(dataset),
|
||||
"classes": list(class_dirs),
|
||||
"group_count": len(group_names),
|
||||
"image_count": len(paths),
|
||||
"configuration": {"size": size, "grid": grid, "folds": folds, "ridge": ridge},
|
||||
"results": results,
|
||||
}
|
||||
report_out.parent.mkdir(parents=True, exist_ok=True)
|
||||
report_out.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
|
||||
log.info("Wrote spectral pipeline report: %s", report_out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -23,11 +23,18 @@ edges:
|
||||
engine reads ``alpha = max(R,G,B)/255`` directly (no background fit). Its assets are
|
||||
the sparkle-on-black capture cropped to two fixed logo sizes (``gemini_bg_{96,48}.png``).
|
||||
|
||||
**Qwen symbol** is solved from the cleared Qwen Create provider fixture. The symbol
|
||||
occupies a recorded square in a smooth linen region. A cubic surface fitted outside
|
||||
that square estimates the local background; the three interior components of the
|
||||
positive residual are the symbol. Keeping the extraction as code makes the bundled
|
||||
asset reproducible instead of a hand-cropped copy of the fixture.
|
||||
|
||||
Usage::
|
||||
|
||||
uv run python scripts/visible_alpha_solve.py doubao
|
||||
uv run python scripts/visible_alpha_solve.py jimeng
|
||||
uv run python scripts/visible_alpha_solve.py gemini
|
||||
uv run python scripts/visible_alpha_solve.py qwen_symbol
|
||||
uv run python scripts/visible_alpha_solve.py all
|
||||
"""
|
||||
|
||||
@@ -115,6 +122,12 @@ _GEMINI_ASSETS: dict[int, Path] = {
|
||||
48: _ROOT / "src" / "remove_ai_watermarks" / "assets" / "gemini_bg_48.png",
|
||||
}
|
||||
|
||||
_QWEN_SYMBOL_CAPTURE = _ROOT / "data" / "fixtures" / "provenance" / "qwen-create-qwen-image-2.png"
|
||||
_QWEN_SYMBOL_ASSET = _ROOT / "src" / "remove_ai_watermarks" / "assets" / "qwen_symbol_alpha.png"
|
||||
# Recorded provider-symbol box (x, y, width, height) in the 2048-square fixture.
|
||||
_QWEN_SYMBOL_BOX = (1925, 1917, 70, 70)
|
||||
_QWEN_SYMBOL_BG_PAD = 45
|
||||
|
||||
|
||||
def _union_bbox(mask: NDArray[np.uint8], err: str) -> tuple[int, int, int, int]:
|
||||
"""Union bbox ``(x0, x1, y0, y1)`` of ``mask``'s connected components with area
|
||||
@@ -246,10 +259,48 @@ def solve_gemini() -> dict[int, NDArray[np.uint8]]:
|
||||
return {size: cv2.resize(crop, (size, size), interpolation=cv2.INTER_AREA) for size in _GEMINI_ASSETS}
|
||||
|
||||
|
||||
def solve_qwen_symbol() -> NDArray[np.uint8]:
|
||||
"""Recover the three-lobe Qwen symbol from its cleared provider fixture."""
|
||||
source = image_io.imread(str(_QWEN_SYMBOL_CAPTURE), cv2.IMREAD_COLOR)
|
||||
if source is None:
|
||||
raise FileNotFoundError(f"missing Qwen symbol capture {_QWEN_SYMBOL_CAPTURE}")
|
||||
x, y, width, height = _QWEN_SYMBOL_BOX
|
||||
pad = _QWEN_SYMBOL_BG_PAD
|
||||
crop = source[y - pad : y + height + pad, x - pad : x + width + pad].astype(np.float32)
|
||||
excluded = np.zeros(crop.shape[:2], dtype=bool)
|
||||
excluded[pad - 8 : pad + height + 8, pad - 8 : pad + width + 8] = True
|
||||
background = _cubic_background(crop, excluded)
|
||||
alpha = np.clip(
|
||||
((crop - background) / np.clip(255.0 - background, 1e-3, None)).mean(axis=2),
|
||||
0.0,
|
||||
1.0,
|
||||
)[pad : pad + height, pad : pad + width]
|
||||
|
||||
body = (alpha > 0.10).astype(np.uint8)
|
||||
count, labels, stats, _centroids = cv2.connectedComponentsWithStats(body, connectivity=8)
|
||||
parts = [
|
||||
index
|
||||
for index in range(1, count)
|
||||
if stats[index, cv2.CC_STAT_AREA] >= 300
|
||||
and stats[index, cv2.CC_STAT_LEFT] > 0
|
||||
and stats[index, cv2.CC_STAT_TOP] > 0
|
||||
and stats[index, cv2.CC_STAT_LEFT] + stats[index, cv2.CC_STAT_WIDTH] < width
|
||||
and stats[index, cv2.CC_STAT_TOP] + stats[index, cv2.CC_STAT_HEIGHT] < height
|
||||
]
|
||||
if len(parts) != 3:
|
||||
raise ValueError(f"expected three Qwen symbol lobes, found {len(parts)}")
|
||||
selected = np.isin(labels, parts)
|
||||
ys, xs = np.where(selected)
|
||||
tight = np.where(selected, alpha, 0.0)[ys.min() : ys.max() + 1, xs.min() : xs.max() + 1]
|
||||
tight[tight < 0.08] = 0.0
|
||||
log.info("qwen-symbol: alpha %dx%d max %.3f", tight.shape[1], tight.shape[0], float(tight.max()))
|
||||
return (tight * 255.0).astype(np.uint8)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("engine", type=click.Choice([*_SPECS, "gemini", "all"]))
|
||||
@click.argument("engine", type=click.Choice([*_SPECS, "gemini", "qwen_symbol", "all"]))
|
||||
def main(engine: str) -> None:
|
||||
"""Rebuild the alpha asset(s) for ENGINE (doubao / jimeng / gemini / all)."""
|
||||
"""Rebuild the alpha asset(s) for ENGINE."""
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
|
||||
def _write(path: Path, img: NDArray[np.uint8], label: str) -> None:
|
||||
@@ -265,6 +316,8 @@ def main(engine: str) -> None:
|
||||
if engine in ("gemini", "all"):
|
||||
for size, img in solve_gemini().items():
|
||||
_write(_GEMINI_ASSETS[size], img, f"gemini-{size}")
|
||||
if engine in ("qwen_symbol", "all"):
|
||||
_write(_QWEN_SYMBOL_ASSET, solve_qwen_symbol(), "qwen-symbol")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -41,7 +41,10 @@ from remove_ai_watermarks.image_io import imread
|
||||
|
||||
GT = Path(".local-eval/textmark-relaxation/groundtruth.jsonl")
|
||||
SNAP = Path(".local-eval/textmark-relaxation/snapshots")
|
||||
MARKS = ("gemini", "doubao", "jimeng", "samsung", "jimeng_pill")
|
||||
# Ground truth currently adjudicates only a subset, but inventory follows the
|
||||
# registry. A newly registered mark therefore appears with scope zero instead of
|
||||
# silently vanishing from the evaluation harness.
|
||||
MARKS = tuple(wr.mark_keys())
|
||||
|
||||
|
||||
def wilson(k: int, n: int) -> tuple[float, float]:
|
||||
@@ -122,7 +125,7 @@ def report(res: dict, prev: dict | None = None) -> None:
|
||||
lo, hi = wilson(tp, n)
|
||||
delta = ""
|
||||
if prev:
|
||||
pc = prev["per"][m]
|
||||
pc = prev["per"].get(m, {})
|
||||
pn = pc.get("tp", 0) + pc.get("fp", 0)
|
||||
if pn:
|
||||
d = tp / n - pc.get("tp", 0) / pn
|
||||
|
||||
+230
-34
@@ -1,4 +1,4 @@
|
||||
"""Parallel detection pass: list every corpus image carrying a known visible mark.
|
||||
"""Scan a retained corpus for registered and still-uncovered visible marks.
|
||||
|
||||
Why this exists separately from `visible_removal_audit.py`: that audit is single-process,
|
||||
so repeated full-dataset sweeps waste time. Its
|
||||
@@ -18,22 +18,43 @@ CRASH TOLERANCE IS NOT OPTIONAL AT THIS SCALE
|
||||
|
||||
Treat input datasets as sensitive and read-only, and keep output gitignored.
|
||||
|
||||
uv run python scripts/visible_positives.py --jobs 6
|
||||
The JSONL is also the input to visible-mark discovery. Each row records strict and
|
||||
metadata-corroborated detector results plus the metadata cohort independently of
|
||||
the pixels. ``--sheets`` then makes blinded, native-detail top/bottom bands from:
|
||||
|
||||
* quiet TC260 producer cohorts, including producer identities absent from the
|
||||
visible-mark registry;
|
||||
* quiet named-platform cohorts;
|
||||
* an unbiased sample of every other quiet image, so a completely novel mark with
|
||||
no useful metadata is not excluded by construction.
|
||||
|
||||
The sheets are candidates for human adjudication, not evidence that a watermark is
|
||||
present. Their manifest is deliberately separate and must stay closed until the
|
||||
image review finishes.
|
||||
|
||||
uv run python scripts/visible_positives.py --corpus .local-eval/originals --jobs 6
|
||||
uv run python scripts/visible_positives.py --since 2026-09-01 --sheets .local-eval/visible-fresh-sheets
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import date
|
||||
from importlib.metadata import version
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from _corpus_scan import corpus_files, repair_jsonl_tail
|
||||
from _isolated_image_workers import run_batch
|
||||
|
||||
# The package's own format set. An inlined copy here silently skipped .heif, which
|
||||
@@ -43,20 +64,66 @@ from remove_ai_watermarks._internal.constants import SUPPORTED_FORMATS as _EXTS
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
CORPUS = REPO / ".local-eval" / "originals"
|
||||
OUT = REPO / ".local-eval" / "visible-positives.jsonl"
|
||||
PATHS = REPO / ".local-eval" / "visible-positives.txt"
|
||||
_SHEET_BAND_FRACTION = 0.12
|
||||
_SCHEMA_VERSION = 2
|
||||
|
||||
|
||||
def _one(path: str) -> dict[str, object]:
|
||||
from remove_ai_watermarks.api import _provenance_from_report
|
||||
from remove_ai_watermarks.identify import identify
|
||||
from remove_ai_watermarks.image_io import imread
|
||||
from remove_ai_watermarks.watermark_registry import detect_marks
|
||||
from remove_ai_watermarks.metadata import aigc_label, uscc_of
|
||||
from remove_ai_watermarks.watermark_registry import _provenance_confirms_product, known_marks
|
||||
|
||||
try:
|
||||
img = imread(path)
|
||||
source = Path(path)
|
||||
img = imread(source)
|
||||
if img is None:
|
||||
return {"path": path, "keys": [], "status": "unreadable"}
|
||||
return {"path": path, "keys": [d.key for d in detect_marks(img) if d.detected], "status": "ok"}
|
||||
return _error_record(path, "unreadable")
|
||||
report = identify(source, check_visible=False, check_invisible=False)
|
||||
provenance = _provenance_from_report(report, source)
|
||||
strict = []
|
||||
accepted = []
|
||||
for mark in known_marks():
|
||||
strict_detection, relaxed_detection = mark.detect_both(img)
|
||||
if strict_detection.detected:
|
||||
strict.append(mark.key)
|
||||
detection = (
|
||||
relaxed_detection if _provenance_confirms_product(mark.product, provenance) else strict_detection
|
||||
)
|
||||
if detection.detected:
|
||||
accepted.append(mark.key)
|
||||
label = aigc_label(source) or {}
|
||||
producer = str(label.get("ContentProducer") or "")
|
||||
return {
|
||||
"schema_version": _SCHEMA_VERSION,
|
||||
"path": path,
|
||||
"keys": accepted,
|
||||
"strict_keys": strict,
|
||||
"provenance": sorted(provenance),
|
||||
"platform": report.platform or "",
|
||||
"producer": producer,
|
||||
"uscc": uscc_of(producer) if producer else "",
|
||||
"status": "ok",
|
||||
"error": "",
|
||||
}
|
||||
except Exception as e:
|
||||
return {"path": path, "keys": [], "status": f"error:{type(e).__name__}"}
|
||||
return _error_record(path, f"error:{type(e).__name__}", str(e))
|
||||
|
||||
|
||||
def _error_record(path: str, status: str, error: str = "") -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": _SCHEMA_VERSION,
|
||||
"path": path,
|
||||
"keys": [],
|
||||
"strict_keys": [],
|
||||
"provenance": [],
|
||||
"platform": "",
|
||||
"producer": "",
|
||||
"uscc": "",
|
||||
"status": status,
|
||||
"error": error[:300].replace("\n", " "),
|
||||
}
|
||||
|
||||
|
||||
def _run_batch(batch: list[str], jobs: int, timeout: int) -> list[dict[str, object]]:
|
||||
@@ -64,65 +131,194 @@ def _run_batch(batch: list[str], jobs: int, timeout: int) -> list[dict[str, obje
|
||||
return run_batch(Path(__file__), batch, jobs=jobs, timeout=timeout)
|
||||
|
||||
|
||||
def _files(corpus: Path, since: date | None) -> list[Path]:
|
||||
return corpus_files(corpus, since, suffixes=_EXTS)
|
||||
|
||||
|
||||
def _read_records(path: Path) -> list[dict[str, Any]]:
|
||||
rows: dict[str, dict[str, Any]] = {}
|
||||
if not path.exists():
|
||||
return []
|
||||
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"]] = row
|
||||
return list(rows.values())
|
||||
|
||||
|
||||
def _repair_checkpoint(path: Path) -> None:
|
||||
"""Drop a torn tail before append, otherwise the next row joins invalid JSON."""
|
||||
repair_jsonl_tail(path)
|
||||
|
||||
|
||||
def _discovery_candidates(
|
||||
rows: list[dict[str, Any]], *, per_cohort: int, random_quiet: int, seed: int
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Stratify quiet rows without treating provenance as a pixel-level label."""
|
||||
rng = random.Random(seed) # noqa: S311 -- reproducible review sampling
|
||||
quiet = [row for row in rows if row.get("status") == "ok" and not row.get("keys")]
|
||||
strata: dict[str, list[dict[str, Any]]] = collections.defaultdict(list)
|
||||
for row in quiet:
|
||||
if row.get("uscc"):
|
||||
strata[f"tc260:{row['uscc']}"].append(row)
|
||||
if row.get("platform"):
|
||||
strata[f"platform:{row['platform']}"].append(row)
|
||||
|
||||
selected: dict[str, dict[str, Any]] = {}
|
||||
labels: dict[str, set[str]] = collections.defaultdict(set)
|
||||
for label, members in sorted(strata.items()):
|
||||
for row in rng.sample(members, k=min(per_cohort, len(members))):
|
||||
selected[row["path"]] = row
|
||||
labels[row["path"]].add(label)
|
||||
|
||||
for row in rng.sample(quiet, k=min(random_quiet, len(quiet))):
|
||||
selected[row["path"]] = row
|
||||
labels[row["path"]].add("unbiased-quiet")
|
||||
|
||||
return [{**row, "discovery_strata": sorted(labels[path])} for path, row in sorted(selected.items())]
|
||||
|
||||
|
||||
def _bands(path: str) -> tuple[Any, Any] | None:
|
||||
from remove_ai_watermarks.image_io import imread
|
||||
|
||||
image = imread(path)
|
||||
if image is None:
|
||||
return None
|
||||
height = image.shape[0]
|
||||
band_height = max(24, int(height * _SHEET_BAND_FRACTION))
|
||||
return image[:band_height], image[height - band_height :]
|
||||
|
||||
|
||||
def _write_discovery_sheets(rows: list[dict[str, Any]], output: Path) -> int:
|
||||
import csv
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
if output.exists() and any(output.iterdir()):
|
||||
raise ValueError(f"discovery output must be empty: {output}")
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
manifest: list[dict[str, Any]] = []
|
||||
written = 0
|
||||
for row in rows:
|
||||
bands = _bands(row["path"])
|
||||
if bands is None:
|
||||
continue
|
||||
top, bottom = bands
|
||||
width = max(top.shape[1], bottom.shape[1])
|
||||
divider = np.full((6, width, 3), 90, np.uint8)
|
||||
header = np.full((28, width, 3), 40, np.uint8)
|
||||
cv2.putText(header, f"{written:05d}", (8, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (255, 255, 255), 1)
|
||||
cv2.imwrite(str(output / f"{written:05d}.png"), np.vstack((header, top, divider, bottom)))
|
||||
manifest.append({"idx": written, **row})
|
||||
written += 1
|
||||
if manifest:
|
||||
fields = list(manifest[0])
|
||||
with (output / "MANIFEST_DO_NOT_OPEN.csv").open("w", newline="", encoding="utf-8") as stream:
|
||||
writer = csv.DictWriter(stream, fieldnames=fields, extrasaction="ignore")
|
||||
writer.writeheader()
|
||||
writer.writerows(manifest)
|
||||
return written
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--corpus", type=Path, default=CORPUS)
|
||||
ap.add_argument("--since", type=date.fromisoformat, help="include dated corpus directories on or after YYYY-MM-DD")
|
||||
ap.add_argument("--jobs", type=int, default=max(1, (os.cpu_count() or 4) - 2))
|
||||
ap.add_argument("--limit", type=int, default=0)
|
||||
ap.add_argument("--batch", type=int, default=200)
|
||||
ap.add_argument("--timeout", type=int, default=600, help="seconds per image before terminating its isolated worker")
|
||||
ap.add_argument("--out", type=Path, default=OUT)
|
||||
ap.add_argument("--restart", action="store_true")
|
||||
ap.add_argument("--sheets", type=Path, help="write blinded discovery bands after the scan")
|
||||
ap.add_argument("--per-cohort", type=int, default=12, help="quiet images sampled from each metadata cohort")
|
||||
ap.add_argument("--random-quiet", type=int, default=400, help="unbiased quiet images sampled across the corpus")
|
||||
ap.add_argument("--seed", type=int, default=2026)
|
||||
a = ap.parse_args()
|
||||
|
||||
files = sorted(p for p in glob.glob(str(CORPUS / "*" / "*")) if Path(p).suffix.lower() in _EXTS)
|
||||
files = [str(path) for path in _files(a.corpus, a.since)]
|
||||
if a.limit:
|
||||
files = files[: a.limit]
|
||||
|
||||
done: set[str] = set()
|
||||
if a.restart and a.out.exists():
|
||||
a.out.unlink() # --restart must TRUNCATE; the file is reopened in append mode below
|
||||
_repair_checkpoint(a.out)
|
||||
if a.out.exists() and not a.restart:
|
||||
with open(a.out, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
try:
|
||||
done.add(json.loads(line)["path"])
|
||||
except Exception: # noqa: S112 -- tolerate a torn last line
|
||||
continue
|
||||
done = {row["path"] for row in _read_records(a.out) if row.get("schema_version") == _SCHEMA_VERSION}
|
||||
todo = [p for p in files if p not in done]
|
||||
print(f"images {len(files)} already done {len(done)} to do {len(todo)} jobs {a.jobs}", flush=True)
|
||||
|
||||
counts: collections.Counter[str] = collections.Counter()
|
||||
status: collections.Counter[str] = collections.Counter()
|
||||
seen = 0
|
||||
a.out.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(a.out, "a", encoding="utf-8") as fh:
|
||||
for start in range(0, len(todo), a.batch):
|
||||
for rec in _run_batch(todo[start : start + a.batch], a.jobs, a.timeout):
|
||||
fh.write(json.dumps(rec) + "\n")
|
||||
status[str(rec["status"])] += 1
|
||||
for k in rec["keys"]: # type: ignore[union-attr]
|
||||
counts[str(k)] += 1
|
||||
seen += 1
|
||||
fh.flush()
|
||||
print(f" {seen}/{len(todo)} {dict(counts)} {dict(status)}", flush=True)
|
||||
print(f" {seen}/{len(todo)}", flush=True)
|
||||
|
||||
# Rebuild the paths file from the FULL record set, not just this run's slice.
|
||||
hits = []
|
||||
with open(a.out, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
try:
|
||||
r = json.loads(line)
|
||||
except Exception: # noqa: S112
|
||||
continue
|
||||
if r.get("keys"):
|
||||
hits.append(r["path"])
|
||||
records = _read_records(a.out)
|
||||
selected_paths = set(files)
|
||||
records = [row for row in records if row["path"] in selected_paths]
|
||||
hits = [row["path"] for row in records if row.get("keys")]
|
||||
counts = collections.Counter(str(key) for row in records for key in row.get("keys", []))
|
||||
strict_counts = collections.Counter(str(key) for row in records for key in row.get("strict_keys", []))
|
||||
status = collections.Counter(str(row.get("status")) for row in records)
|
||||
# Derive the paths file from --out so a trial run with a scratch --out cannot
|
||||
# overwrite the shared list a full sweep produced.
|
||||
paths_out = a.out.with_suffix(".txt")
|
||||
paths_out.write_text("\n".join(sorted(set(hits))) + "\n", encoding="utf-8")
|
||||
paths_tmp = paths_out.with_suffix(paths_out.suffix + ".tmp")
|
||||
paths_tmp.write_text("\n".join(sorted(set(hits))) + ("\n" if hits else ""), encoding="utf-8")
|
||||
paths_tmp.replace(paths_out)
|
||||
print(f"\npositives: {len(set(hits))} images")
|
||||
for k, v in counts.most_common():
|
||||
print(f" {v:6d} {k}")
|
||||
print(f" {v:6d} {k} ({strict_counts[k]} strict)")
|
||||
print(f"statuses: {dict(status)}\npaths -> {paths_out}\nrecords -> {a.out}")
|
||||
if a.sheets:
|
||||
candidates = _discovery_candidates(
|
||||
records,
|
||||
per_cohort=a.per_cohort,
|
||||
random_quiet=a.random_quiet,
|
||||
seed=a.seed,
|
||||
)
|
||||
written = _write_discovery_sheets(candidates, a.sheets)
|
||||
print(f"discovery candidates: {len(candidates)} sheets written: {written} -> {a.sheets}")
|
||||
|
||||
from remove_ai_watermarks.watermark_registry import mark_keys
|
||||
|
||||
git = shutil.which("git")
|
||||
commit = (
|
||||
subprocess.run( # noqa: S603 -- resolved git binary and fixed read-only arguments
|
||||
[git, "rev-parse", "HEAD"],
|
||||
cwd=REPO,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
).stdout.strip()
|
||||
if git
|
||||
else ""
|
||||
)
|
||||
metadata = {
|
||||
"command": [str(Path(__file__).relative_to(REPO)), *sys.argv[1:]],
|
||||
"library_commit": commit or None,
|
||||
"library_version": version("remove-ai-watermarks"),
|
||||
"corpus": str(a.corpus.resolve()),
|
||||
"since": a.since.isoformat() if a.since else None,
|
||||
"selected_images": len(files),
|
||||
"complete_records": len(records),
|
||||
"registry_keys": mark_keys(),
|
||||
"schema_version": _SCHEMA_VERSION,
|
||||
}
|
||||
a.out.with_suffix(a.out.suffix + ".run.json").write_text(
|
||||
json.dumps(metadata, indent=2, sort_keys=True) + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "remove-ai-watermarks",
|
||||
"displayName": "Remove AI Watermarks",
|
||||
"version": "1.0.6",
|
||||
"version": "1.0.9",
|
||||
"description": "Identify and remove AI provenance marks from images and video the user generated or edited, using the remove-ai-watermarks CLI.",
|
||||
"author": {
|
||||
"name": "wiltodelta",
|
||||
|
||||
@@ -5,7 +5,7 @@ license: Apache-2.0
|
||||
compatibility: Python 3.11-3.14. Invisible image removal needs NVIDIA CUDA. Some video writes need ffmpeg. Installer may be uv, pipx, or pip.
|
||||
metadata:
|
||||
author: wiltodelta
|
||||
version: "1.0.6"
|
||||
version: "1.0.9"
|
||||
homepage: https://raiw.cc
|
||||
repository: https://github.com/wiltodelta/remove-ai-watermarks
|
||||
---
|
||||
@@ -164,6 +164,52 @@ watermark" or "this is clean".
|
||||
verdict, because a pixel watermark such as SynthID cannot be detected here once
|
||||
its metadata proxy is gone. Repeat that caveat; do not summarize it away.
|
||||
|
||||
## Examples
|
||||
|
||||
### Identify without overstating the result
|
||||
|
||||
Input: "Was `photo.png` AI-generated?"
|
||||
|
||||
Action: probe the machine, then run:
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks identify photo.png
|
||||
```
|
||||
|
||||
Output after exit 2 from a command that reached the image:
|
||||
|
||||
> Origin unknown. The command found no local provenance signal. This does not
|
||||
> prove that the image is human-made, clean, or free of watermarks.
|
||||
|
||||
### Remove a user-named visible AI label
|
||||
|
||||
Input: "Remove the Gemini sparkle from my `generated.png`."
|
||||
|
||||
Action: after a successful probe, run:
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks visible generated.png --mark gemini -o generated-clean.png
|
||||
```
|
||||
|
||||
Output after exit 0 and confirming that the file exists:
|
||||
|
||||
> Removed the targeted Gemini mark. Output: `generated-clean.png`.
|
||||
|
||||
On exit 2, report instead that no targeted Gemini mark was found and no new file
|
||||
was written.
|
||||
|
||||
### Route an unavailable invisible removal safely
|
||||
|
||||
Input: "Remove SynthID from my image."
|
||||
|
||||
Action: run the probe first. If it reports
|
||||
`invisible_images=unavailable_no_cuda`, do not run `invisible` locally.
|
||||
|
||||
Output:
|
||||
|
||||
> Invisible image removal needs NVIDIA CUDA, which is unavailable on this
|
||||
> machine. No local removal ran. You can use https://raiw.cc instead.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Without CUDA, `all` does one of two opposite things, and only the probe knows
|
||||
@@ -186,9 +232,8 @@ its metadata proxy is gone. Repeat that caveat; do not summarize it away.
|
||||
- `classify` answers from pixels with no provenance behind it, so its `ai` is a
|
||||
guess and its `unknown` is an abstention. Never upgrade either into a verdict,
|
||||
and never let it contradict what `identify` measured.
|
||||
- A Meta AI (Muse Image) export can retain standalone IPTC metadata that enables
|
||||
heuristic attribution and automatic routing. The IPTC code is not unique to Meta
|
||||
and does not detect Content Seal in pixels. If metadata was stripped, use
|
||||
- A Meta AI (Muse Image) export can retain standalone IPTC metadata, but the shared
|
||||
code does not identify Meta or detect Content Seal in pixels. Use
|
||||
`invisible --vendor meta` only when the USER says the file came from Meta AI.
|
||||
Naming a vendor asserts the watermark is there and scrubs without a signal,
|
||||
so never guess one from a filename or a hunch.
|
||||
|
||||
@@ -44,7 +44,9 @@ remove-ai-watermarks classify image.png --json
|
||||
```
|
||||
|
||||
Pixel classifier, needs the `classify` extra. It is NOT provenance: it guesses
|
||||
from pixels alone and `identify` never runs it. Labels are `ai` (both models
|
||||
from pixels alone and `identify` never runs it. The CLI has no ONNX flag; the
|
||||
optional CPU ONNX runtime is library-only (`backend="onnx"`) and needs
|
||||
`classify-onnx` (since CLI 0.40.0). Labels are `ai` (both models
|
||||
agree), `human`, and `unknown` -- `unknown` is an abstention, not a negative.
|
||||
Photographed receipts abstain to `unknown` through the bundled receipt gate
|
||||
(since CLI 0.38.0), so a receipt is not reported as `ai`. Offer `classify`
|
||||
@@ -78,21 +80,24 @@ cohort asserts the watermark IS present, so it also runs the scrub without a
|
||||
local signal, exactly like `--force`. Set it only when the USER says where the
|
||||
file came from. `--vendor` is on `invisible`, `all` and `batch`.
|
||||
|
||||
Meta Muse Image exports can retain standalone IPTC metadata that enables
|
||||
heuristic attribution and automatic routing. That generic code is not unique
|
||||
to Meta and does not detect Content Seal in pixels. After metadata is stripped,
|
||||
`--vendor meta` supplies the route only when the user confirms the source.
|
||||
Meta Muse Image exports can retain standalone IPTC metadata, but that generic
|
||||
code does not identify Meta or detect Content Seal in pixels. `--vendor meta`
|
||||
supplies the route only when the user confirms the source.
|
||||
There is no `--model`, `--steps`, or `--guidance-scale` flag, and no `--device`
|
||||
on the image path: auto-detection already finds the only device these profiles
|
||||
run on.
|
||||
|
||||
Pipeline profiles: `qwen-zimage` (the default), `sdxl-zimage`, `chroma-zimage`,
|
||||
`auto`. All are CUDA-only and all install from the same `qwen-zimage` extra.
|
||||
`auto` picks the engine from the file's own provenance -- chroma-zimage for
|
||||
Microsoft, qwen-zimage for OpenAI (since CLI 0.39.0), Google, Meta and unknown
|
||||
`auto` picks the engine from the file's own provenance -- sdxl-zimage for Google,
|
||||
chroma-zimage for Microsoft, qwen-zimage for OpenAI (since CLI 0.39.0), Meta and unknown
|
||||
-- so prefer it over naming an engine when the user has no reason to care. `sdxl-zimage` is the
|
||||
heavier alternative and `chroma-zimage` is the Apache-2.0 global stage.
|
||||
|
||||
Verified-text manifests work with every profile (since CLI 0.40.5). Each profile
|
||||
uses its own already loaded VAE donor, so `auto` keeps the normal SDXL route for a
|
||||
Google image instead of switching engines.
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks all image.png -o clean.png
|
||||
remove-ai-watermarks batch ./images --mode visible --output-dir ./clean
|
||||
|
||||
@@ -66,6 +66,7 @@ ffmpeg is missing, or the user asked about Homebrew, pipx, or pip.
|
||||
| Verified text restoration (`--text-manifest`) | add `text-restoration` |
|
||||
| Open DWT-DCT detect | add `detect` |
|
||||
| Pixel classifier (`classify`) | add `classify` |
|
||||
| CPU ONNX photo classifier, library `backend="onnx"` only (since CLI 0.40.0) | add `classify-onnx` |
|
||||
| Adobe TrustMark on Python 3.11-3.12 | add `trustmark` |
|
||||
| Every production extra on this Python | `all` |
|
||||
|
||||
|
||||
@@ -24,14 +24,15 @@ from typing import Any
|
||||
_GPU_MARKERS = ("GPU ", "gpu:")
|
||||
# Below this, the CLI predates flags and behavior this skill documents. Raise it in
|
||||
# the same change as any reference that names a flag the previous release lacked:
|
||||
# 0.39.0 is here because the `liblib_pill` mark and `auto` routing OpenAI
|
||||
# provenance to qwen-zimage arrived with it; 0.38.0 added the receipt gate;
|
||||
# `classify` needed 0.37.0 (`--pipeline auto` 0.36.0; chroma-zimage and the
|
||||
# doubao video mark 0.35.0; the `microsoft` mark 0.34.0; `--vendor` 0.32.0),
|
||||
# and a stale floor told an agent its CLI was current right before it typed a
|
||||
# value that build rejects. tests/test_agent_skill.py pins it against the
|
||||
# package version and against every "(since CLI X.Y.Z)" annotation in the skill.
|
||||
MIN_CLI_VERSION = (0, 39, 0)
|
||||
# 0.40.0 is here because the skill names the `classify-onnx` extra; 0.39.0 is
|
||||
# here because the `liblib_pill` mark and `auto` routing OpenAI provenance to
|
||||
# qwen-zimage arrived with it; 0.38.0 added the receipt gate; `classify` needed
|
||||
# 0.37.0 (`--pipeline auto` 0.36.0; chroma-zimage and the doubao video mark
|
||||
# 0.35.0; the `microsoft` mark 0.34.0; `--vendor` 0.32.0), and a stale floor
|
||||
# told an agent its CLI was current right before it typed a value that build
|
||||
# rejects. tests/test_agent_skill.py pins it against the package version and
|
||||
# against every "(since CLI X.Y.Z)" annotation in the skill.
|
||||
MIN_CLI_VERSION = (0, 40, 5)
|
||||
# Markers a build without the pixel stack prints. The first is the current CLI's own
|
||||
# install hint; the rest are what older builds emit before the guard existed, and the
|
||||
# Homebrew formula ships exactly such a build.
|
||||
|
||||
@@ -14,6 +14,7 @@ High-level API (lazy, so ``import remove_ai_watermarks`` stays cheap)::
|
||||
raiw.remove_video_metadata("in.mp4", "out.mp4") # verified metadata strip
|
||||
raiw.remove_video_invisible("in.mp4", "out.mp4") # calibrated video-pixel regeneration
|
||||
raiw.remove_video_visible("in.mp4", "out.mp4") # stable visible video-mark removal
|
||||
raiw.classify_source("in.png") # source pipeline, not SynthID detection
|
||||
|
||||
For a provenance verdict use the ``identify`` submodule::
|
||||
|
||||
@@ -33,7 +34,7 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
|
||||
_warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*")
|
||||
|
||||
|
||||
__version__ = "0.39.1"
|
||||
__version__ = "0.41.1"
|
||||
|
||||
__all__ = [
|
||||
"BatchItemResult",
|
||||
@@ -41,8 +42,10 @@ __all__ = [
|
||||
"InvisibleOptions",
|
||||
"MetadataStripIncomplete",
|
||||
"RemoveAllResult",
|
||||
"SourceClassification",
|
||||
"VisibleRemovalResult",
|
||||
"__version__",
|
||||
"classify_source",
|
||||
"identify_video",
|
||||
"inspect_video_metadata",
|
||||
"remove_all",
|
||||
@@ -71,6 +74,7 @@ if TYPE_CHECKING:
|
||||
remove_visible_detailed,
|
||||
visible_provenance,
|
||||
)
|
||||
from remove_ai_watermarks.source_classify import SourceClassification, classify_source
|
||||
from remove_ai_watermarks.video import (
|
||||
identify_video,
|
||||
inspect_video_metadata,
|
||||
@@ -101,6 +105,10 @@ def __getattr__(name: str) -> object:
|
||||
from remove_ai_watermarks import api
|
||||
|
||||
return getattr(api, name)
|
||||
if name in ("SourceClassification", "classify_source"):
|
||||
from remove_ai_watermarks import source_classify
|
||||
|
||||
return getattr(source_classify, name)
|
||||
if name in (
|
||||
"identify_video",
|
||||
"inspect_video_metadata",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Generated C2PA soft-binding registry; do not edit by hand.
|
||||
|
||||
Source: https://raw.githubusercontent.com/c2pa-org/softbinding-algorithm-list/main/softbinding-algorithm-list.json
|
||||
Revision: c0218cd628c9163ad62a08c3deff5a10fa0e0d0b
|
||||
Revision: 1b3ef637890ea8228bca13d8941cfd54da941867
|
||||
Source license: CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/)
|
||||
Changes: validated, ordered, and reduced to fields used at runtime; descriptions
|
||||
are shortened to their first sentence for display.
|
||||
@@ -12,7 +12,7 @@ from __future__ import annotations
|
||||
C2PA_SOFT_BINDING_SOURCE_URL = (
|
||||
"https://raw.githubusercontent.com/c2pa-org/softbinding-algorithm-list/main/softbinding-algorithm-list.json"
|
||||
)
|
||||
C2PA_SOFT_BINDING_SOURCE_REVISION = "c0218cd628c9163ad62a08c3deff5a10fa0e0d0b"
|
||||
C2PA_SOFT_BINDING_SOURCE_REVISION = "1b3ef637890ea8228bca13d8941cfd54da941867"
|
||||
C2PA_SOFT_BINDING_SOURCE_LICENSE = "CC BY 4.0"
|
||||
|
||||
C2PA_SOFT_BINDING_ROWS: tuple[
|
||||
@@ -387,7 +387,7 @@ C2PA_SOFT_BINDING_ROWS: tuple[
|
||||
"eu.sasha.softbinding.v1",
|
||||
"watermark",
|
||||
("image",),
|
||||
("image/jpeg", "image/png"),
|
||||
("image/jpeg", "image/png", "image/webp"),
|
||||
(
|
||||
"Invisible watermarking technology for image and video assets, enabling C2PA manifest "
|
||||
"recovery and content provenance verification after metadata stripping or transcoding"
|
||||
|
||||
@@ -18,6 +18,7 @@ from remove_ai_watermarks._internal.constants import (
|
||||
C2PA_AI_TOOLS,
|
||||
C2PA_AI_VENDORS,
|
||||
C2PA_CHUNK_TYPE,
|
||||
C2PA_IDENTITY_AI_ORGS,
|
||||
C2PA_ISSUERS,
|
||||
C2PA_SIGNATURES,
|
||||
C2PA_SOFT_BINDING_REGISTRY,
|
||||
@@ -529,7 +530,8 @@ def _structured_manifest_fields(store: dict[str, Any]) -> dict[str, Any]:
|
||||
info["ai_tool"] = ", ".join(tools)
|
||||
if actions:
|
||||
info["actions"] = ", ".join(actions)
|
||||
if claim_generator_asserts_ai:
|
||||
signer_identity_asserts_ai = not C2PA_IDENTITY_AI_ORGS.isdisjoint(issuers)
|
||||
if claim_generator_asserts_ai or signer_identity_asserts_ai:
|
||||
info["c2pa_identity_ai"] = True
|
||||
|
||||
generated = any(
|
||||
|
||||
@@ -36,7 +36,7 @@ from remove_ai_watermarks._internal.two_stage_pipeline import (
|
||||
_prompt_cache_path,
|
||||
_store_prompt_payload,
|
||||
_target_size,
|
||||
edge_pad_to_grid,
|
||||
diffusers_vae_roundtrip,
|
||||
requested_steps,
|
||||
)
|
||||
|
||||
@@ -182,28 +182,12 @@ class ChromaZImagePipeline(TwoStageZImagePipeline):
|
||||
|
||||
def _vae_roundtrip(self, image: Image.Image) -> Image.Image:
|
||||
"""Reconstruct source pixels through the already loaded Chroma VAE."""
|
||||
import torch
|
||||
|
||||
pipe = self._load_global()
|
||||
source_width, source_height = image.size
|
||||
padded = edge_pad_to_grid(image, _LATENT_GRID)
|
||||
|
||||
tensor = pipe.image_processor.preprocess(
|
||||
padded,
|
||||
height=padded.height,
|
||||
width=padded.width,
|
||||
).to(device=self.device, dtype=self.torch_dtype)
|
||||
with torch.inference_mode():
|
||||
encoded = pipe.vae.encode(tensor)
|
||||
if hasattr(encoded, "latent_dist"):
|
||||
# A restoration donor must be deterministic. Diffusers samples this
|
||||
# distribution for img2img noise initialization; its mode is the
|
||||
# faithful VAE reconstruction needed here.
|
||||
latents = encoded.latent_dist.mode()
|
||||
elif hasattr(encoded, "latents"):
|
||||
latents = encoded.latents
|
||||
else:
|
||||
raise AttributeError("Chroma VAE encoder output contains no latents")
|
||||
decoded = pipe.vae.decode(latents, return_dict=False)[0]
|
||||
reconstructed = pipe.image_processor.postprocess(decoded, output_type="pil")[0]
|
||||
return reconstructed.crop((0, 0, source_width, source_height)).convert("RGB")
|
||||
return diffusers_vae_roundtrip(
|
||||
pipe,
|
||||
image,
|
||||
grid=_LATENT_GRID,
|
||||
device=self.device,
|
||||
dtype=self.torch_dtype,
|
||||
profile_name=self.profile_name,
|
||||
)
|
||||
@@ -52,12 +52,19 @@ class HeadedCLIP(nn.Module):
|
||||
|
||||
|
||||
def load_headed_clip(checkpoint: Path, device: torch.device) -> HeadedCLIP:
|
||||
"""Build CLIP-L from config and load the freeze state dict."""
|
||||
"""Build CLIP-L without discarded random weights and load the freeze state dict."""
|
||||
import torch
|
||||
from transformers import CLIPConfig, CLIPModel
|
||||
from transformers.initialization import no_init_weights
|
||||
|
||||
clip = CLIPModel(CLIPConfig.from_pretrained(BACKBONE))
|
||||
model = HeadedCLIP(clip)
|
||||
# The frozen checkpoint replaces every parameter. Normal construction spends
|
||||
# most of a CPU cold start randomly initializing 427M parameters first.
|
||||
# ``no_init_weights`` preserves constructor-created buffers while suppressing
|
||||
# only those redundant parameter fills; unlike a meta-device construction,
|
||||
# it cannot leave non-persistent CLIP buffers unmaterialized.
|
||||
with no_init_weights():
|
||||
clip = CLIPModel(CLIPConfig.from_pretrained(BACKBONE))
|
||||
model = HeadedCLIP(clip)
|
||||
state = torch.load(checkpoint, map_location="cpu", weights_only=True)
|
||||
model.load_state_dict(state, strict=True)
|
||||
return model.to(device).eval()
|
||||
@@ -72,6 +79,18 @@ def load_processor() -> Any:
|
||||
return processor
|
||||
|
||||
|
||||
def load_onnx_vision(model: Path) -> Any:
|
||||
"""Load the portable CPU session for the vision-only FP32 export."""
|
||||
import onnxruntime as ort
|
||||
|
||||
options = ort.SessionOptions()
|
||||
# Runtime graph optimization rebuilds this 1.2 GB static graph on every cold
|
||||
# process. The export is already constant-folded; disabling that repeated pass
|
||||
# reduced local session creation without changing the graph or its outputs.
|
||||
options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_DISABLE_ALL
|
||||
return ort.InferenceSession(str(model), sess_options=options, providers=["CPUExecutionProvider"])
|
||||
|
||||
|
||||
def embed_image(model: HeadedCLIP, processor: Any, image: Image.Image, device: torch.device) -> NDArray[Any]:
|
||||
"""Return a 768-d L2-normalized CLIP-L-ft vector."""
|
||||
import numpy as np
|
||||
@@ -84,3 +103,14 @@ def embed_image(model: HeadedCLIP, processor: Any, image: Image.Image, device: t
|
||||
with torch.inference_mode():
|
||||
vector = model.embed(pixels).float().cpu().numpy()[0]
|
||||
return np.asarray(vector, dtype=np.float64)
|
||||
|
||||
|
||||
def embed_image_onnx(session: Any, processor: Any, image: Image.Image) -> NDArray[Any]:
|
||||
"""Return the vision-only ONNX export's normalized 768-d vector."""
|
||||
import numpy as np
|
||||
|
||||
boxed = letterbox(image)
|
||||
pixels = processor(images=[boxed], return_tensors="np")["pixel_values"]
|
||||
inputs = {"pixel_values": np.asarray(pixels, dtype=np.float32)}
|
||||
vector = session.run(["embedding"], inputs)[0][0]
|
||||
return np.asarray(vector, dtype=np.float64)
|
||||
@@ -32,7 +32,7 @@ C2PA_SIGNATURES = tuple(
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class C2paAiVendor:
|
||||
"""One issuer signature and its normalized product attribution."""
|
||||
"""One C2PA identity and its AI-product or neutral-signer attribution."""
|
||||
|
||||
issuer: bytes
|
||||
org: str
|
||||
@@ -41,6 +41,8 @@ class C2paAiVendor:
|
||||
synthid: bool = False
|
||||
asserts_ai: bool = False
|
||||
synthid_requires_watermark_action: bool = False
|
||||
signer_platform: str | None = None
|
||||
raw_signer_platforms: tuple[tuple[bytes, str], ...] = ()
|
||||
|
||||
|
||||
def _vendor(
|
||||
@@ -52,6 +54,8 @@ def _vendor(
|
||||
synthid: bool = False,
|
||||
asserts_ai: bool = False,
|
||||
synthid_requires_watermark_action: bool = False,
|
||||
signer_platform: str | None = None,
|
||||
raw_signer_platforms: tuple[tuple[bytes, str], ...] = (),
|
||||
) -> C2paAiVendor:
|
||||
token = issuer.encode() if isinstance(issuer, str) else issuer
|
||||
return C2paAiVendor(
|
||||
@@ -62,6 +66,8 @@ def _vendor(
|
||||
synthid=synthid,
|
||||
asserts_ai=asserts_ai,
|
||||
synthid_requires_watermark_action=synthid_requires_watermark_action,
|
||||
signer_platform=signer_platform,
|
||||
raw_signer_platforms=raw_signer_platforms,
|
||||
)
|
||||
|
||||
|
||||
@@ -109,16 +115,75 @@ C2PA_AI_VENDORS: tuple[C2paAiVendor, ...] = (
|
||||
# "Bria Artificial Intelligence"). Found as an unmapped signer on 4 corpus
|
||||
# uploads 2026-08-08 that identify reported as unknown-signer C2PA.
|
||||
_vendor(b"Ideogram", "Ideogram", "Ideogram", "Ideogram", asserts_ai=True),
|
||||
_vendor(b"xAI Grok Imagine", "xAI Grok Imagine", "xAI Grok Imagine", "xAI", asserts_ai=True),
|
||||
_vendor(b"Producer.ai", "Producer.ai", "Producer.ai", "Producer.ai", asserts_ai=True),
|
||||
# This signature names only SPRING's legal entity, not a product. Preserve
|
||||
# that signer identity without guessing which product produced a file.
|
||||
_vendor(b"SPRING (SG) PTE. LTD.", "SPRING (SG) PTE. LTD.", "SPRING (SG) PTE. LTD.", "SPRING"),
|
||||
# These companies also sign ordinary edits and publications. Register the
|
||||
# signer identity for provenance display, but provide no AI platform and do
|
||||
# not let the identity assert an AI verdict.
|
||||
_vendor(
|
||||
b"TikTok Inc.",
|
||||
"TikTok",
|
||||
None,
|
||||
None,
|
||||
signer_platform="TikTok (C2PA signer)",
|
||||
),
|
||||
_vendor(
|
||||
b"Bytedance Pte",
|
||||
"ByteDance",
|
||||
None,
|
||||
None,
|
||||
signer_platform="ByteDance (C2PA signer)",
|
||||
raw_signer_platforms=(
|
||||
(b"Bytedance Pte. Ltd.", "ByteDance (C2PA signer)"),
|
||||
(b"CapCut/", "CapCut (C2PA signer)"),
|
||||
(b"capcut c2pa-rs", "CapCut (C2PA signer)"),
|
||||
),
|
||||
),
|
||||
_vendor(
|
||||
b"Anthropic Claude Content Signing",
|
||||
"Anthropic Claude",
|
||||
None,
|
||||
None,
|
||||
signer_platform="Anthropic Claude (C2PA signer)",
|
||||
),
|
||||
_vendor(
|
||||
b"Samsung Galaxy",
|
||||
"Samsung Galaxy",
|
||||
None,
|
||||
None,
|
||||
signer_platform="Samsung Galaxy (C2PA)",
|
||||
),
|
||||
_vendor(
|
||||
b"com.asus.gallery",
|
||||
"ASUS Gallery",
|
||||
None,
|
||||
None,
|
||||
signer_platform="ASUS Gallery (C2PA signer)",
|
||||
),
|
||||
_vendor(b"Truepic", "Truepic", None, None),
|
||||
)
|
||||
|
||||
C2PA_ISSUERS = {vendor.issuer: vendor.org for vendor in C2PA_AI_VENDORS}
|
||||
C2PA_IDENTITY_AI_ORGS = frozenset(vendor.org for vendor in C2PA_AI_VENDORS if vendor.asserts_ai)
|
||||
C2PA_SIGNER_PLATFORM_BY_ORG = {
|
||||
vendor.org: vendor.signer_platform for vendor in C2PA_AI_VENDORS if vendor.signer_platform is not None
|
||||
}
|
||||
C2PA_SIGNER_PLATFORMS = tuple(
|
||||
pair
|
||||
for vendor in C2PA_AI_VENDORS
|
||||
if vendor.signer_platform is not None
|
||||
for pair in (vendor.raw_signer_platforms or ((vendor.issuer, vendor.signer_platform),))
|
||||
)
|
||||
|
||||
# Product-specific claim generators can sign through a different upstream issuer.
|
||||
# Keep this attribution beside the issuer registry so every C2PA consumer has one
|
||||
# canonical source rather than maintaining a derived product map in identify.py.
|
||||
C2PA_CLAIM_GENERATOR_PLATFORMS: tuple[tuple[str, str], ...] = (
|
||||
("grok imagine", "xAI Grok Imagine"),
|
||||
("suno", "Suno"),
|
||||
("adobe_firefly", "Adobe Firefly"),
|
||||
("firefly", "Adobe Firefly"),
|
||||
("dreamina", "ByteDance Dreamina"),
|
||||
@@ -126,6 +191,8 @@ C2PA_CLAIM_GENERATOR_PLATFORMS: tuple[tuple[str, str], ...] = (
|
||||
("recraft.ai", "Recraft"),
|
||||
("topaz labs image api", "Topaz Labs"),
|
||||
("tiktok ad creative toolbox", "TikTok Ad Creative Toolbox"),
|
||||
("fastvid", "FastVid"),
|
||||
("capcut", "CapCut (C2PA signer)"),
|
||||
)
|
||||
|
||||
C2PA_AI_TOOLS = {
|
||||
@@ -211,6 +278,7 @@ AI_GENERATOR_TOKENS = frozenset(
|
||||
"invokeai",
|
||||
"imagen",
|
||||
"gpt-image",
|
||||
"nano banana",
|
||||
"nightcafe",
|
||||
"ideogram",
|
||||
"leonardo",
|
||||
@@ -231,15 +299,3 @@ AI_GENERATOR_TOKENS = frozenset(
|
||||
|
||||
_C2PA_ACTION_NAMES = _tokens("created|converted|edited|filtered|cropped|resized|opened|placed|watermarked.unbound")
|
||||
C2PA_ACTIONS = {f"c2pa.{action}".encode(): action for action in _C2PA_ACTION_NAMES}
|
||||
|
||||
|
||||
# TC260 producer identity -> the mark key whose vendor signs with it now lives on the
|
||||
# registry rows (``KnownMark.tc260_producer_codes``, read through
|
||||
# ``watermark_registry.tc260_producer_vendors``). Keeping the codes beside the mark is
|
||||
# what stops a newly registered TC260 vendor from silently falling back to ByteDance.
|
||||
#
|
||||
# What a TC260 label confirms when its producer is absent or unmapped. Historical
|
||||
# behavior, kept as the fallback so an unrecognized producer never regresses to no
|
||||
# relaxation at all: ByteDance's two products are the ones the relaxed band was
|
||||
# calibrated on (see _text_mark_engine._DEFAULT_PROVENANCE_NCC_FACTOR).
|
||||
TC260_FALLBACK_VENDORS: frozenset[str] = frozenset({"doubao", "jimeng"})
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Small shared numerical transforms used by independent pixel analyzers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def dct_matrix(np: Any, size: int = 8) -> Any:
|
||||
"""Return an orthonormal ``size`` by ``size`` DCT-II basis."""
|
||||
frequencies = np.arange(size)[:, None]
|
||||
samples = np.arange(size)[None, :]
|
||||
matrix = np.cos(np.pi * (2 * samples + 1) * frequencies / (2 * size))
|
||||
matrix[0, :] *= 1 / np.sqrt(2)
|
||||
return matrix * np.sqrt(2 / size)
|
||||
@@ -43,6 +43,7 @@ from remove_ai_watermarks.metadata import (
|
||||
C2PA_UUID,
|
||||
IPTC_AI_FIELD_MARKERS,
|
||||
IPTC_AI_MARKERS,
|
||||
LOCAL_GENERATION_METADATA_KEYS,
|
||||
MAX_TC260_VALUE_BYTES,
|
||||
parse_tc260_aigc_json,
|
||||
)
|
||||
@@ -152,12 +153,12 @@ def iter_file_boxes(
|
||||
pos = box_end
|
||||
|
||||
|
||||
def _tc260_key_indices(
|
||||
def _metadata_key_indices(
|
||||
stream: BinaryIO,
|
||||
payload_off: int,
|
||||
box_end: int,
|
||||
) -> dict[int, tuple[int, int]]:
|
||||
"""Map every exact ``AIGC`` key index to its byte span."""
|
||||
) -> dict[int, tuple[bytes, tuple[int, int]]]:
|
||||
"""Map bounded metadata key indices to their names and byte spans."""
|
||||
if payload_off + 8 > box_end:
|
||||
return {}
|
||||
stream.seek(payload_off)
|
||||
@@ -166,7 +167,7 @@ def _tc260_key_indices(
|
||||
return {}
|
||||
entry_count = struct.unpack(">I", prefix[4:8])[0]
|
||||
pos = payload_off + 8
|
||||
found: dict[int, tuple[int, int]] = {}
|
||||
found: dict[int, tuple[bytes, tuple[int, int]]] = {}
|
||||
for index in range(1, entry_count + 1):
|
||||
if pos + 8 > box_end:
|
||||
return {}
|
||||
@@ -179,10 +180,12 @@ def _tc260_key_indices(
|
||||
if entry_size < 8 or entry_end > box_end:
|
||||
return {}
|
||||
name_start = pos + 8
|
||||
if entry_end - name_start == 4:
|
||||
name_size = entry_end - name_start
|
||||
if 0 < name_size <= 128:
|
||||
stream.seek(name_start)
|
||||
if stream.read(4) == b"AIGC":
|
||||
found[index] = (name_start, entry_end)
|
||||
name = stream.read(name_size)
|
||||
if len(name) == name_size:
|
||||
found[index] = (name, (name_start, entry_end))
|
||||
pos = entry_end
|
||||
return found
|
||||
|
||||
@@ -248,12 +251,23 @@ def _tc260_aigc_regions(
|
||||
iOS export writes, where the JSON sits in a bare ``ilst`` data item with no
|
||||
key name to blank.
|
||||
"""
|
||||
regions: list[tuple[tuple[int, int] | None, int, int, bytes]] = []
|
||||
return [
|
||||
(key_span, value_start, value_end, value)
|
||||
for key_name, key_span, value_start, value_end, value in _metadata_items(stream, file_size)
|
||||
if (key_name is None or key_name == b"AIGC") and parse_tc260_aigc_json(value) is not None
|
||||
]
|
||||
|
||||
|
||||
def _metadata_items(
|
||||
stream: BinaryIO,
|
||||
file_size: int,
|
||||
) -> Iterator[tuple[bytes | None, tuple[int, int] | None, int, int, bytes]]:
|
||||
"""Yield bounded keyed or keyless metadata-list values from a media file."""
|
||||
for _moov_start, moov_end, moov_type, moov_payload in iter_file_boxes(stream, 0, file_size):
|
||||
if moov_type != b"moov":
|
||||
continue
|
||||
for meta_payload, meta_end in _iter_tc260_meta_boxes(stream, moov_payload, moov_end):
|
||||
keys: dict[int, tuple[int, int]] = {}
|
||||
keys: dict[int, tuple[bytes, tuple[int, int]]] = {}
|
||||
ilst_boxes: list[tuple[int, int]] = []
|
||||
keyed = False
|
||||
for _child_start, child_end, child_type, child_payload in _meta_child_boxes(
|
||||
@@ -263,7 +277,7 @@ def _tc260_aigc_regions(
|
||||
):
|
||||
if child_type == b"keys":
|
||||
keyed = True
|
||||
keys.update(_tc260_key_indices(stream, child_payload, child_end))
|
||||
keys.update(_metadata_key_indices(stream, child_payload, child_end))
|
||||
elif child_type == b"ilst":
|
||||
ilst_boxes.append((child_payload, child_end))
|
||||
if not ilst_boxes:
|
||||
@@ -275,8 +289,8 @@ def _tc260_aigc_regions(
|
||||
ilst_end,
|
||||
):
|
||||
index = int.from_bytes(item_type, "big")
|
||||
key_span = keys.get(index)
|
||||
if keyed and key_span is None:
|
||||
key = keys.get(index)
|
||||
if keyed and key is None:
|
||||
# A keyed (ISO) meta box maps items through ``keys``;
|
||||
# an unmapped index is not an AIGC entry, and reading
|
||||
# its value would pull arbitrary metadata (e.g. cover
|
||||
@@ -284,6 +298,7 @@ def _tc260_aigc_regions(
|
||||
# keyless QuickTime list falls through to content
|
||||
# validation below.
|
||||
continue
|
||||
key_name, key_span = key if key is not None else (None, None)
|
||||
for _data_start, data_end, data_type, data_payload in iter_file_boxes(
|
||||
stream,
|
||||
item_payload,
|
||||
@@ -295,9 +310,8 @@ def _tc260_aigc_regions(
|
||||
continue
|
||||
stream.seek(value_start)
|
||||
value = stream.read(value_size)
|
||||
if len(value) == value_size and parse_tc260_aigc_json(value) is not None:
|
||||
regions.append((key_span, value_start, data_end, value))
|
||||
return regions
|
||||
if len(value) == value_size:
|
||||
yield key_name, key_span, value_start, data_end, value
|
||||
|
||||
|
||||
def tc260_aigc_payloads(path: str | Path, *, strict: bool = False) -> tuple[bytes, ...]:
|
||||
@@ -315,6 +329,42 @@ def tc260_aigc_payloads(path: str | Path, *, strict: bool = False) -> tuple[byte
|
||||
return ()
|
||||
|
||||
|
||||
def _ai_metadata_regions(
|
||||
stream: BinaryIO,
|
||||
file_size: int,
|
||||
) -> list[tuple[tuple[int, int], int, int, str, bytes]]:
|
||||
"""Locate keyed generation metadata without reading media payloads."""
|
||||
regions: list[tuple[tuple[int, int], int, int, str, bytes]] = []
|
||||
for name_bytes, key_span, value_start, value_end, value in _metadata_items(stream, file_size):
|
||||
if name_bytes is None or key_span is None:
|
||||
continue
|
||||
try:
|
||||
name = name_bytes.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
if name.lower() in LOCAL_GENERATION_METADATA_KEYS:
|
||||
regions.append((key_span, value_start, value_end, name, value))
|
||||
return regions
|
||||
|
||||
|
||||
def ai_metadata_tags(path: str | Path, *, strict: bool = False) -> dict[str, str]:
|
||||
"""Read keyed AI-generation metadata from an ISOBMFF container."""
|
||||
try:
|
||||
with open(path, "rb") as stream:
|
||||
if not is_isobmff(stream.read(8)):
|
||||
return {}
|
||||
stream.seek(0, 2)
|
||||
file_size = stream.tell()
|
||||
return {
|
||||
name: value.decode("utf-8", "replace")
|
||||
for _key_span, _value_start, _value_end, name, value in _ai_metadata_regions(stream, file_size)
|
||||
}
|
||||
except OSError:
|
||||
if strict:
|
||||
raise
|
||||
return {}
|
||||
|
||||
|
||||
def blank_tc260_aigc_tags(data: bytes) -> tuple[bytes, int]:
|
||||
"""Blank native TC260 values in place while preserving every box offset.
|
||||
|
||||
@@ -576,7 +626,9 @@ def strip_isobmff_media_file(
|
||||
max_scan=max_box_scan,
|
||||
)
|
||||
tc260_regions = _tc260_aigc_regions(stream, file_size) if targets is not None else []
|
||||
ai_metadata_regions = _ai_metadata_regions(stream, file_size) if targets is not None else []
|
||||
tc260_key_spans = {region[0] for region in tc260_regions if region[0] is not None}
|
||||
ai_metadata_key_spans = {region[0] for region in ai_metadata_regions}
|
||||
|
||||
with atomic_video_output(output_path) as temporary_path:
|
||||
with source_path.open("rb") as source_stream, temporary_path.open("r+b") as temporary:
|
||||
@@ -591,6 +643,10 @@ def strip_isobmff_media_file(
|
||||
temporary.seek(key_span[0])
|
||||
temporary.write(b"free")
|
||||
_overwrite_range(temporary, value_start, value_end, byte=b" ")
|
||||
for key_span, value_start, value_end, _name, _value in ai_metadata_regions:
|
||||
temporary.seek(key_span[0])
|
||||
temporary.write(b"free" + b" " * (key_span[1] - key_span[0] - 4))
|
||||
_overwrite_range(temporary, value_start, value_end, byte=b" ")
|
||||
temporary.flush()
|
||||
os.fsync(temporary.fileno())
|
||||
shutil.copymode(source_path, temporary_path)
|
||||
@@ -601,7 +657,7 @@ def strip_isobmff_media_file(
|
||||
source_path,
|
||||
)
|
||||
return 0, 0
|
||||
return len(targets), len(tc260_key_spans)
|
||||
return len(targets), len(tc260_key_spans | ai_metadata_key_spans)
|
||||
|
||||
|
||||
def strip_c2pa_boxes(data: bytes) -> tuple[bytes, int]:
|
||||
|
||||
@@ -28,6 +28,7 @@ from remove_ai_watermarks._internal.two_stage_pipeline import (
|
||||
TwoStageZImagePipeline,
|
||||
_target_size,
|
||||
build_canny_control_image,
|
||||
diffusers_vae_roundtrip,
|
||||
requested_steps,
|
||||
)
|
||||
from remove_ai_watermarks._internal.watermark_profiles import (
|
||||
@@ -120,3 +121,15 @@ class SdxlZImagePipeline(TwoStageZImagePipeline):
|
||||
if result.size != image.size:
|
||||
result = result.resize(image.size, Image.Resampling.LANCZOS)
|
||||
return result.convert("RGB")
|
||||
|
||||
def _vae_roundtrip(self, image: Image.Image) -> Image.Image:
|
||||
"""Reconstruct source pixels through the already loaded SDXL VAE."""
|
||||
pipe = self._load_global()
|
||||
return diffusers_vae_roundtrip(
|
||||
pipe,
|
||||
image,
|
||||
grid=_LATENT_GRID,
|
||||
device=self.device,
|
||||
dtype=self.torch_dtype,
|
||||
profile_name=self.profile_name,
|
||||
)
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Phase-free spectral features for source/export-pipeline classification."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from numpy.typing import NDArray
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SpectralGeometry:
|
||||
"""Precomputed coordinates for one phase-free spectral representation."""
|
||||
|
||||
size: int
|
||||
grid: int
|
||||
radius: NDArray[np.int32]
|
||||
radius_counts: NDArray[np.int64]
|
||||
passband: NDArray[np.bool_]
|
||||
|
||||
|
||||
@lru_cache(maxsize=4)
|
||||
def make_geometry(size: int = 256, grid: int = 16) -> SpectralGeometry:
|
||||
"""Build radial detrending and pooling geometry."""
|
||||
if size < 16 or grid < 2 or size % grid:
|
||||
raise ValueError("size must be at least 16 and divisible by grid")
|
||||
yy, xx = np.mgrid[:size, :size]
|
||||
radius = np.rint(np.hypot(yy - size // 2, xx - size // 2)).astype(np.int32)
|
||||
radius_counts = np.bincount(radius.ravel())
|
||||
passband = (radius >= 4) & (radius <= size * 0.48)
|
||||
return SpectralGeometry(size, grid, radius, radius_counts, passband)
|
||||
|
||||
|
||||
def phase_free_log_spectrum(channel: NDArray[np.float64]) -> NDArray[np.float64]:
|
||||
"""Return centered log FFT magnitude, deliberately discarding phase."""
|
||||
if channel.ndim != 2:
|
||||
raise ValueError("channel must be a two-dimensional array")
|
||||
centered = channel - float(np.mean(channel))
|
||||
return np.log1p(np.abs(np.fft.fftshift(np.fft.fft2(centered))))
|
||||
|
||||
|
||||
def spectral_feature(pixels: NDArray[np.float64], geometry: SpectralGeometry) -> NDArray[np.float64]:
|
||||
"""Extract pooled radial-residual spectra from luma and opponent color."""
|
||||
expected = (geometry.size, geometry.size, 3)
|
||||
if pixels.shape != expected:
|
||||
raise ValueError(f"pixels must have shape {expected}, got {pixels.shape}")
|
||||
red, green, blue = np.moveaxis(pixels, 2, 0)
|
||||
channels = (
|
||||
0.299 * red + 0.587 * green + 0.114 * blue,
|
||||
red - green,
|
||||
blue - (red + green) / 2.0,
|
||||
)
|
||||
block = geometry.size // geometry.grid
|
||||
features: list[NDArray[np.float64]] = []
|
||||
for channel in channels:
|
||||
spectrum = phase_free_log_spectrum(channel)
|
||||
radial_sum = np.bincount(geometry.radius.ravel(), weights=spectrum.ravel())
|
||||
radial_mean = radial_sum / geometry.radius_counts
|
||||
residual = np.where(
|
||||
geometry.passband,
|
||||
spectrum - radial_mean[geometry.radius],
|
||||
0.0,
|
||||
)
|
||||
pooled = residual.reshape(geometry.grid, block, geometry.grid, block).mean(axis=(1, 3))
|
||||
features.append(pooled.ravel())
|
||||
return np.concatenate(features)
|
||||
|
||||
|
||||
def feature_from_image(image: Image.Image, geometry: SpectralGeometry) -> NDArray[np.float64]:
|
||||
"""Resize an RGB image canonically and extract its phase-free feature."""
|
||||
canonical = image.resize((geometry.size, geometry.size), Image.Resampling.LANCZOS)
|
||||
pixels = np.asarray(canonical, dtype=np.float64) / 255.0
|
||||
return spectral_feature(pixels, geometry)
|
||||
@@ -96,6 +96,41 @@ def edge_pad_to_grid(image: Image.Image, grid: int) -> Image.Image:
|
||||
)
|
||||
|
||||
|
||||
def diffusers_vae_roundtrip(
|
||||
pipe: Any,
|
||||
image: Image.Image,
|
||||
*,
|
||||
grid: int,
|
||||
device: Any,
|
||||
dtype: Any,
|
||||
profile_name: str,
|
||||
) -> Image.Image:
|
||||
"""Reconstruct pixels deterministically through a Diffusers pipeline VAE."""
|
||||
import torch
|
||||
|
||||
source_width, source_height = image.size
|
||||
padded = edge_pad_to_grid(image, grid)
|
||||
tensor = pipe.image_processor.preprocess(
|
||||
padded,
|
||||
height=padded.height,
|
||||
width=padded.width,
|
||||
).to(device=device, dtype=dtype)
|
||||
with torch.inference_mode():
|
||||
encoded = pipe.vae.encode(tensor)
|
||||
if hasattr(encoded, "latent_dist"):
|
||||
# A restoration donor must be deterministic. Diffusers samples this
|
||||
# distribution for img2img noise initialization; its mode is the
|
||||
# faithful VAE reconstruction needed here.
|
||||
latents = encoded.latent_dist.mode()
|
||||
elif hasattr(encoded, "latents"):
|
||||
latents = encoded.latents
|
||||
else:
|
||||
raise AttributeError(f"{profile_name} VAE encoder output contains no latents")
|
||||
decoded = pipe.vae.decode(latents, return_dict=False)[0]
|
||||
reconstructed = pipe.image_processor.postprocess(decoded, output_type="pil")[0]
|
||||
return reconstructed.crop((0, 0, source_width, source_height)).convert("RGB")
|
||||
|
||||
|
||||
def resolve_face_model_residency(
|
||||
requested: bool | None,
|
||||
*,
|
||||
|
||||
@@ -55,9 +55,8 @@ PROFILE_ADAPTIVE_POLISH = {
|
||||
QWEN_ZIMAGE_PROFILE: False,
|
||||
SDXL_ZIMAGE_PROFILE: True,
|
||||
CHROMA_ZIMAGE_PROFILE: False,
|
||||
# Auto resolves to qwen-zimage or chroma-zimage, both of which keep the
|
||||
# input's detail level. If it ever resolves to an engine that needs polish,
|
||||
# the resolved profile's own default applies.
|
||||
# This fallback applies only before a vendor is known. The engine resolves
|
||||
# auto first so Google-to-SDXL receives SDXL's enabled default.
|
||||
AUTO_PROFILE: False,
|
||||
}
|
||||
|
||||
@@ -80,26 +79,26 @@ SDXL_LIGHTNING_PATTERN = "sdxl_lightning_4step_lora.safetensors"
|
||||
PROFILE_SEED = 0
|
||||
|
||||
# sdxl-zimage runs the qwen-zimage recipe on an SDXL global stage, and strength is
|
||||
# architecture-bound: at Qwen's 0.154 an SDXL global pass leaves SynthID on a native
|
||||
# 2816x1536 Gemini original, while 0.20, 0.25 and 0.30 all read clean in the Gemini
|
||||
# app. 0.25 keeps a rung of margin over that boundary, which the historical SDXL
|
||||
# certification argues for -- it recorded 0.20 as DETECTED against Gemini on an
|
||||
# older SDXL pipeline. OpenAI is the easier oracle: the profile already cleared
|
||||
# openai.com/verify at 0.1102, so 0.15 sits above what was verified rather than on
|
||||
# it. Unknown follows Gemini, the stricter of the two.
|
||||
# architecture-bound. The source-fresh 2026-09-15 calibration found Google CJK
|
||||
# detectable through 0.45 and clean at 0.50. The 0.50 candidate was then replicated
|
||||
# clean on independent busy, flat-text, and face-plus-text carriers. OpenAI is
|
||||
# the easier oracle: the profile cleared openai.com/verify at 0.1102, so 0.15
|
||||
# sits above what was verified rather than on it. Unknown follows Google, the
|
||||
# stricter measured cohort.
|
||||
#
|
||||
# Unlike qwen-zimage this is a flat vendor policy rather than a resolution curve,
|
||||
# because flat values are what was measured. Every verdict above comes from a fixed
|
||||
# strength at one size; no size dependence has been established for this stage.
|
||||
SDXL_ZIMAGE_OPENAI_STRENGTH = 0.15
|
||||
SDXL_ZIMAGE_GEMINI_STRENGTH = 0.25
|
||||
SDXL_ZIMAGE_GEMINI_STRENGTH = 0.50
|
||||
SDXL_ZIMAGE_UNKNOWN_STRENGTH = SDXL_ZIMAGE_GEMINI_STRENGTH
|
||||
|
||||
# qwen-zimage keeps its resolution curve for unknown content, but measured vendor
|
||||
# cohorts bypass it. Google remained detectable through 0.24375; 0.25 cleared all
|
||||
# three valid sources and 0.27 was separately repeated clean across them and three
|
||||
# accounts, so the independently checked 0.27 candidate is the operating floor.
|
||||
QWEN_ZIMAGE_GOOGLE_STRENGTH = 0.27
|
||||
# cohorts bypass it. A source-fresh 2026-09-15 sweep found Latin scenes and faces
|
||||
# clean at 0.15 but CJK and Cyrillic text requiring 0.35. The 0.35 candidate was
|
||||
# then independently replicated clean on busy, flat-text, and face-plus-text CJK
|
||||
# carriers, so 0.35 is the content-agnostic operating floor.
|
||||
QWEN_ZIMAGE_GOOGLE_STRENGTH = 0.35
|
||||
|
||||
# The two OpenAI sources first cleared at 0.06225 and 0.0695. Add one full observed
|
||||
# cross-source spread (0.00725) to the worst clean boundary: 0.0695 + 0.00725.
|
||||
@@ -113,17 +112,16 @@ QWEN_ZIMAGE_OPENAI_STRENGTH = 0.07675
|
||||
# measured corpus margin, not a universal InvisMark threshold.
|
||||
QWEN_ZIMAGE_MICROSOFT_STRENGTH = 0.15
|
||||
|
||||
# Meta Muse Image's retained standalone IPTC AI tag selects this floor through
|
||||
# vendor_for_strength. That shared standard tag is a routing heuristic, not a
|
||||
# vendor-unique signature or a Content Seal pixel detection. Stripped outputs
|
||||
# need an explicit --vendor meta override when their origin is known.
|
||||
# Meta Muse Image's retained standalone IPTC AI tag is not a vendor-unique
|
||||
# signature or a Content Seal pixel detection, so this floor requires an explicit
|
||||
# --vendor meta override when the origin is independently known.
|
||||
# Derivation (oracle meta.ai/identification, 2026-08-26/27, corpus in
|
||||
# data/contentseal/): five independent 2.56 MP generations bracketed at
|
||||
# lighthouse (0.0525, 0.06], fox (0.03, 0.0375], night_city (0.03, 0.0375],
|
||||
# mug <= 0.03, text <= 0.015. Worst clean boundary plus one full observed
|
||||
# cross-source spread: 0.06 + (0.0525 - 0.015) = 0.0975, rounded up to 0.1.
|
||||
# sdxl-zimage has no measured Meta floor; its vendor map stays without a meta
|
||||
# entry so an explicit --vendor meta there falls to the unknown 0.25, which is
|
||||
# entry so an explicit --vendor meta there falls to the unknown 0.50, which is
|
||||
# above this floor and therefore conservative.
|
||||
QWEN_ZIMAGE_META_STRENGTH = 0.1
|
||||
|
||||
@@ -166,11 +164,12 @@ _SDXL_ZIMAGE_STRENGTH_BY_VENDOR: dict[str, float] = {
|
||||
# - Microsoft InvisMark: paint-1 (0.06, 0.08], paint-2 <= 0.04, paint-3
|
||||
# (0.06, 0.08] -> 0.08 + (0.08 - 0.04) = 0.12, rounded up to the measured
|
||||
# rung 0.125, oracle-verified clean on both worst sources. BELOW qwen's 0.15.
|
||||
# - Google: 633uuy (0.20, 0.25], akdbei (0.20, 0.25], y48j3c (0.08, 0.12],
|
||||
# 3mc4t9 (0.08, 0.12] -> 0.25 + (0.25 - 0.12) = 0.38, rounded up to 0.40,
|
||||
# oracle-verified clean on the worst fixture. ABOVE qwen's 0.27; at 0.40 the
|
||||
# regeneration destroys dense text and collapses face identity, which is why
|
||||
# the matched-strength addendum recommends the adaptive-strength follow-up.
|
||||
# - Google: the source-fresh 2026-09-15 sweep superseded the four-fixture 0.40
|
||||
# derivation: CJK remained detected at 0.40 and cleared at 0.50. The 0.50
|
||||
# candidate was independently replicated clean on busy, flat-text, and
|
||||
# face-plus-text CJK carriers. Face detection cannot select the older 0.125
|
||||
# arm safely because fresh face-plus-text carriers require more denoise and the
|
||||
# runtime has no measured text-content gate.
|
||||
# - Meta Content Seal: lighthouse (0.08, 0.10], fox (0.06, 0.08], night_city
|
||||
# (0.045, 0.06], studio_mug (0.03, 0.045], text_poster <= 0.03 ->
|
||||
# 0.10 + (0.10 - 0.03) = 0.17. ABOVE qwen's 0.1; the floors exist because
|
||||
@@ -178,23 +177,10 @@ _SDXL_ZIMAGE_STRENGTH_BY_VENDOR: dict[str, float] = {
|
||||
# worse per unit strength.
|
||||
CHROMA_ZIMAGE_OPENAI_STRENGTH = 0.20
|
||||
CHROMA_ZIMAGE_MICROSOFT_STRENGTH = 0.125
|
||||
CHROMA_ZIMAGE_GOOGLE_STRENGTH = 0.40
|
||||
CHROMA_ZIMAGE_GOOGLE_STRENGTH = 0.50
|
||||
CHROMA_ZIMAGE_META_STRENGTH = 0.17
|
||||
CHROMA_ZIMAGE_UNKNOWN_STRENGTH = CHROMA_ZIMAGE_GOOGLE_STRENGTH
|
||||
|
||||
# Content-adaptive Google floor: the four-fixture calibration showed a clean
|
||||
# face-count split. Both zero-face fixtures (dense-text cards 633uuy and akdbei)
|
||||
# need 0.25 first-clean; both face fixtures (y48j3c with 17 detected faces and
|
||||
# 3mc4t9 with 7) clear at 0.12. The flat 0.40 floor is the zero-face policy
|
||||
# (worst 0.25 + spread 0.13 = 0.38, rounded to 0.40). Face content can use the
|
||||
# measured 0.125 rung instead: both face fixtures first-cleaned at 0.12, and
|
||||
# with identical boundaries the cross-source spread is zero, so 0.125 (the next
|
||||
# measured rung above 0.12) is the operating point. This split is
|
||||
# Google-SynthID-specific: OpenAI's face fixture was HARDER than its text
|
||||
# fixture (0.075 vs 0.06), so no other cohort gets an adaptive arm.
|
||||
# Oracle-verified 2026-08-30; see docs/chroma1-engine-research.md.
|
||||
CHROMA_ZIMAGE_GOOGLE_FACE_STRENGTH = 0.125
|
||||
|
||||
_CHROMA_ZIMAGE_STRENGTH_BY_VENDOR: dict[str, float] = {
|
||||
"openai": CHROMA_ZIMAGE_OPENAI_STRENGTH,
|
||||
"google": CHROMA_ZIMAGE_GOOGLE_STRENGTH,
|
||||
@@ -210,10 +196,11 @@ _ALIASES = {
|
||||
|
||||
# The deterministic per-cohort selection policy: which profile wins on which vendor, from the
|
||||
# 2026-08-29/30 four-cohort calibration (docs/chroma1-engine-research.md).
|
||||
# chroma-zimage has the lower measured Microsoft floor. qwen-zimage wins on
|
||||
# OpenAI, Google, and Meta. Unknown stays on qwen (the shipped default,
|
||||
# conservative).
|
||||
# chroma-zimage has the lower measured Microsoft floor. SDXL wins Google on
|
||||
# paired quality at the independently clean operating points. Qwen wins OpenAI
|
||||
# and Meta; unknown stays on Qwen, the shipped conservative fallback.
|
||||
_ENGINE_BY_VENDOR: dict[str, str] = {
|
||||
"google": SDXL_ZIMAGE_PROFILE,
|
||||
"microsoft": CHROMA_ZIMAGE_PROFILE,
|
||||
}
|
||||
|
||||
@@ -229,6 +216,14 @@ def normalize_profile(profile: str) -> str:
|
||||
return _ALIASES.get(value, value)
|
||||
|
||||
|
||||
def resolve_effective_profile(profile: str, vendor: str | None) -> str:
|
||||
"""Resolve a configured profile to the concrete engine for one image."""
|
||||
normalized = normalize_profile(profile)
|
||||
if normalized == AUTO_PROFILE:
|
||||
return resolve_auto_profile(vendor)
|
||||
return normalized
|
||||
|
||||
|
||||
def resolve_seed(seed: int | None) -> int:
|
||||
"""Keep every profile reproducible by default."""
|
||||
return PROFILE_SEED if seed is None else seed
|
||||
@@ -248,9 +243,7 @@ def global_offload_supported(pipeline: str) -> bool:
|
||||
the only unresolved ``auto`` load, and that path deliberately uses qwen-zimage
|
||||
as its fallback, so it has the same offload support as qwen-zimage.
|
||||
"""
|
||||
profile = normalize_profile(pipeline)
|
||||
if profile == AUTO_PROFILE:
|
||||
profile = resolve_auto_profile(None)
|
||||
profile = resolve_effective_profile(pipeline, None)
|
||||
return profile in GLOBAL_OFFLOAD_PROFILES
|
||||
|
||||
|
||||
@@ -274,7 +267,6 @@ def resolve_strength(
|
||||
pipeline: str | None = None,
|
||||
*,
|
||||
size: tuple[int, int] | None = None,
|
||||
face_count: int | None = None,
|
||||
) -> float:
|
||||
"""Resolve a user override or the calibrated policy for a profile and vendor.
|
||||
|
||||
@@ -289,13 +281,11 @@ def resolve_strength(
|
||||
"""
|
||||
if strength is not None:
|
||||
return strength
|
||||
normalized = normalize_profile(pipeline or "")
|
||||
normalized = resolve_effective_profile(pipeline or "", vendor)
|
||||
if normalized == SDXL_ZIMAGE_PROFILE:
|
||||
return _SDXL_ZIMAGE_STRENGTH_BY_VENDOR.get((vendor or "").casefold(), SDXL_ZIMAGE_UNKNOWN_STRENGTH)
|
||||
if normalized == CHROMA_ZIMAGE_PROFILE:
|
||||
vendor_key = (vendor or "").casefold()
|
||||
if vendor_key == "google" and face_count is not None and face_count > 0:
|
||||
return CHROMA_ZIMAGE_GOOGLE_FACE_STRENGTH
|
||||
return _CHROMA_ZIMAGE_STRENGTH_BY_VENDOR.get(vendor_key, CHROMA_ZIMAGE_UNKNOWN_STRENGTH)
|
||||
if size is None:
|
||||
raise ValueError("qwen-zimage resolves strength from image area, so size is required")
|
||||
@@ -310,15 +300,9 @@ def resolve_strength(
|
||||
def vendor_for_strength(image_path: Path) -> Literal["openai", "google", "microsoft", "meta"] | None:
|
||||
"""Select the strength cohort from non-invalid pixel-watermark provenance.
|
||||
|
||||
OpenAI / Google / Microsoft come from their C2PA issuers. Meta is the
|
||||
fallback cohort: Muse Image carries no C2PA at all, and its only readable
|
||||
companion is the IPTC ``trainedAlgorithmicMedia`` XMP tag -- a standard code
|
||||
other platforms also use. Attributing that tag to Meta is a measured bet,
|
||||
not an identification: the other tag users in this project's model (ByteDance
|
||||
products, X) ship no invisible pixel watermark this profile targets, so the
|
||||
worst misroute spends the Meta floor (0.1) where the resolution curve would
|
||||
have spent a similar amount, and Google/OpenAI files never reach this arm
|
||||
because their C2PA matched first."""
|
||||
OpenAI / Google / Microsoft come from product-specific C2PA evidence. Meta has no
|
||||
automatic route because its companion IPTC ``trainedAlgorithmicMedia`` value is a
|
||||
shared standard, not manufacturer evidence; callers can select it explicitly."""
|
||||
try:
|
||||
from remove_ai_watermarks._internal.c2pa import (
|
||||
c2pa_info_has_invalid_credential,
|
||||
@@ -337,25 +321,4 @@ def vendor_for_strength(image_path: Path) -> Literal["openai", "google", "micros
|
||||
return "openai"
|
||||
if not c2pa_info_has_invalid_credential(info) and c2pa_info_has_invismark(info):
|
||||
return "microsoft"
|
||||
if _standalone_iptc_ai_tag(image_path):
|
||||
return "meta"
|
||||
return None
|
||||
|
||||
|
||||
def _standalone_iptc_ai_tag(image_path: Path) -> bool:
|
||||
"""True when the file carries an AI IPTC marker with no C2PA around it.
|
||||
|
||||
Mirrors identify's ``standalone_iptc`` condition (the tag is only
|
||||
trustworthy as platform evidence when no manifest supersedes it) without
|
||||
importing the heavy identify module: the shared chunk-aware
|
||||
:func:`metadata.scan_head` window -- Muse WebP outputs place their XMP
|
||||
packet in a tail chunk up to hundreds of KB past a plain head read, which
|
||||
is exactly what scan_head's extensions exist to catch.
|
||||
"""
|
||||
try:
|
||||
from remove_ai_watermarks.metadata import IPTC_AI_MARKERS, c2pa_marker_in, scan_head
|
||||
|
||||
scan = scan_head(image_path)
|
||||
except Exception:
|
||||
return False
|
||||
return any(marker in scan for marker in IPTC_AI_MARKERS) and not c2pa_marker_in(scan)
|
||||
@@ -11,7 +11,6 @@ from typing import TYPE_CHECKING, Any
|
||||
from PIL import Image
|
||||
|
||||
from remove_ai_watermarks._internal.watermark_profiles import (
|
||||
AUTO_PROFILE,
|
||||
CHROMA_ZIMAGE_PROFILE,
|
||||
DEFAULT_PROFILE,
|
||||
INVISIBLE_EXTRA,
|
||||
@@ -21,7 +20,7 @@ from remove_ai_watermarks._internal.watermark_profiles import (
|
||||
SDXL_ZIMAGE_PROFILE,
|
||||
global_offload_supported,
|
||||
normalize_profile,
|
||||
resolve_auto_profile,
|
||||
resolve_effective_profile,
|
||||
resolve_seed,
|
||||
resolve_strength,
|
||||
)
|
||||
@@ -43,6 +42,14 @@ except ImportError:
|
||||
torch = None # type: ignore[assignment]
|
||||
_HAS_TORCH = False
|
||||
|
||||
|
||||
def _dtype_for_profile(profile: str) -> Any:
|
||||
"""Return the weight dtype bound to a concrete regeneration profile."""
|
||||
if profile == SDXL_ZIMAGE_PROFILE:
|
||||
return torch.float16 # type: ignore[union-attr]
|
||||
return torch.bfloat16 # type: ignore[union-attr]
|
||||
|
||||
|
||||
# Probed once at import. ``torch`` is imported above rather than probed because this
|
||||
# module needs the object, not just the answer.
|
||||
_HAS_REMOVAL_MODULES = module_available(*(name for name in REMOVAL_MODULES if name != "torch"))
|
||||
@@ -111,13 +118,14 @@ class WatermarkRemover:
|
||||
controlnet_conditioning_scale: float = 1.0,
|
||||
cpu_offload: bool = False,
|
||||
) -> None:
|
||||
self.model_profile = normalize_profile(pipeline)
|
||||
if self.model_profile not in PROFILE_CHOICES:
|
||||
self.configured_profile = normalize_profile(pipeline)
|
||||
if self.configured_profile not in PROFILE_CHOICES:
|
||||
raise ValueError(f"Unsupported pipeline '{pipeline}'. Use one of: {', '.join(PROFILE_CHOICES)}.")
|
||||
# The auto profile resolves to a concrete engine per-image in
|
||||
# remove_watermark, once the provenance vendor is known. It never
|
||||
# resolves to sdxl-zimage, so the dtype below is correct either way.
|
||||
self._auto = self.model_profile == AUTO_PROFILE
|
||||
# Auto starts on the concrete Qwen fallback for preload, then resolves
|
||||
# from the immutable configured profile for every image. Keeping the
|
||||
# configured and active profiles separate prevents one image's vendor
|
||||
# from becoming the next image's policy input.
|
||||
self.model_profile = resolve_effective_profile(self.configured_profile, None)
|
||||
# There is no ``model_id`` parameter and no ``model_id`` attribute: each
|
||||
# profile pins a fixed model stack, and the dtype below is bound to that
|
||||
# stack's weights. Both used to be constructor overrides that existed only to
|
||||
@@ -136,13 +144,10 @@ class WatermarkRemover:
|
||||
"every identify command still run on CPU."
|
||||
)
|
||||
|
||||
if self.model_profile == SDXL_ZIMAGE_PROFILE:
|
||||
# SDXL ships fp16 weights and an fp16-safe VAE; bf16 would give up the
|
||||
# variant without buying anything on this architecture.
|
||||
self.torch_dtype = torch.float16 # type: ignore[union-attr]
|
||||
else:
|
||||
# qwen-zimage, chroma-zimage, and auto all resolve to bf16 engines.
|
||||
self.torch_dtype = torch.bfloat16 # type: ignore[union-attr]
|
||||
# SDXL ships fp16 weights and an fp16-safe VAE. Qwen and Chroma use
|
||||
# bf16; auto starts on the concrete Qwen fallback and switches together
|
||||
# with its active profile per image.
|
||||
self.torch_dtype = _dtype_for_profile(self.model_profile)
|
||||
|
||||
self.cpu_offload = cpu_offload
|
||||
self.controlnet_conditioning_scale = controlnet_conditioning_scale
|
||||
@@ -212,26 +217,6 @@ class WatermarkRemover:
|
||||
|
||||
remove_ai_metadata(output_path, output_path, keep_standard=True)
|
||||
|
||||
def _resolve_chroma_strength(
|
||||
self,
|
||||
strength: float | None,
|
||||
vendor: str | None,
|
||||
source: Image.Image,
|
||||
) -> float:
|
||||
"""Resolve strength, running face detection for the chroma Google arm.
|
||||
|
||||
The content-adaptive Google floor needs a face count. YuNet is fast
|
||||
(~50 ms) and the model download is already managed by the shared base.
|
||||
"""
|
||||
if self.model_profile != CHROMA_ZIMAGE_PROFILE or (vendor or "").casefold() != "google":
|
||||
return resolve_strength(strength, vendor, self.model_profile, size=source.size)
|
||||
if strength is not None:
|
||||
return strength
|
||||
from remove_ai_watermarks._internal.two_stage_pipeline import detect_faces
|
||||
|
||||
face_count = len(detect_faces(source))
|
||||
return resolve_strength(strength, vendor, self.model_profile, size=source.size, face_count=face_count)
|
||||
|
||||
def remove_watermark(
|
||||
self,
|
||||
image_path: Path,
|
||||
@@ -257,21 +242,19 @@ class WatermarkRemover:
|
||||
with Image.open(image_path) as opened:
|
||||
source = opened.convert("RGB")
|
||||
|
||||
# The auto policy resolves per-image once the provenance vendor is
|
||||
# known, BEFORE the strength resolution so the right engine's floors
|
||||
# are used. If the vendor changed the engine, reset the cached pipeline.
|
||||
if self._auto:
|
||||
resolved = resolve_auto_profile(vendor)
|
||||
if resolved != self.model_profile:
|
||||
self.model_profile = resolved
|
||||
self._qwen_zimage_pipeline = None
|
||||
if text_manifest is not None and self.model_profile == SDXL_ZIMAGE_PROFILE:
|
||||
raise ValueError("Verified text restoration is not supported by the sdxl-zimage profile")
|
||||
# Resolve from the configured policy for every image, before strength
|
||||
# resolution, so a previous image's vendor cannot pin the next one to
|
||||
# its engine. Reset cached weights only when the concrete engine changes.
|
||||
resolved = resolve_effective_profile(self.configured_profile, vendor)
|
||||
if resolved != self.model_profile:
|
||||
self.model_profile = resolved
|
||||
self.torch_dtype = _dtype_for_profile(resolved)
|
||||
self._qwen_zimage_pipeline = None
|
||||
if text_manifest is not None and tile:
|
||||
raise ValueError("Verified text restoration is not calibrated with tiled diffusion")
|
||||
if fidelity_anchor and self.model_profile != QWEN_ZIMAGE_PROFILE:
|
||||
raise ValueError("The fidelity anchor is supported only by the qwen-zimage profile")
|
||||
resolved_strength = self._resolve_chroma_strength(strength, vendor, source)
|
||||
resolved_strength = resolve_strength(strength, vendor, self.model_profile, size=source.size)
|
||||
if not 0.0 <= resolved_strength <= 1.0:
|
||||
raise ValueError(f"Strength must be between 0.0 and 1.0, got {resolved_strength}")
|
||||
|
||||
|
||||
@@ -182,6 +182,11 @@ class TextMarkDetection:
|
||||
provenance: bool = False
|
||||
|
||||
|
||||
def best_detection(*detections: TextMarkDetection) -> TextMarkDetection:
|
||||
"""Prefer an accepted variant, then retain the strongest rejected score."""
|
||||
return max(detections, key=lambda detection: (detection.detected, detection.confidence))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TextMarkScan:
|
||||
"""The trust-level-BLIND half of text-mark detection, reusable across both levels.
|
||||
|
||||
@@ -61,8 +61,9 @@ def visible_provenance(source: str | Path) -> frozenset[str]:
|
||||
|
||||
Mapping: a Google/Gemini C2PA issuer -> ``"gemini"``; a ``samsung_genai`` marker ->
|
||||
``"samsung"``; a China-AIGC (TC260) label -> the vendor its ``ContentProducer``
|
||||
names (``KnownMark.tc260_producer_codes``), falling back to ByteDance's two products
|
||||
when the producer is absent or unmapped.
|
||||
names (``KnownMark.tc260_producer_codes``). An absent or unmapped producer confirms
|
||||
no particular product: the TC260 standard identifies neither a manufacturer nor a
|
||||
product.
|
||||
|
||||
The TC260 label itself says only "this is AI", so it used to relax Doubao and
|
||||
Jimeng on EVERY China-AIGC image -- including one carrying a Qwen or Kling mark,
|
||||
@@ -87,24 +88,21 @@ def visible_provenance(source: str | Path) -> frozenset[str]:
|
||||
def _tc260_vendors(path: Path) -> frozenset[str]:
|
||||
"""Vendor keys a TC260 label confirms, from its ``ContentProducer`` identity.
|
||||
|
||||
An absent, unreadable or unmapped producer falls back to the historical pair rather
|
||||
than to nothing: the caller has already established that the AIGC signal fired, so
|
||||
the image IS China-AIGC labeled, and dropping to no relaxation would lose the
|
||||
detections the fallback recovers today. The re-read is deliberately isolated -- a
|
||||
failure here must narrow the answer, never discard the rest of the provenance.
|
||||
An absent, unreadable or unmapped producer confirms no particular product. The
|
||||
caller has established only that the AIGC signal fired; a shared standard cannot
|
||||
safely relax one manufacturer's detector. The re-read is deliberately isolated --
|
||||
a failure here must narrow the answer, never discard the rest of the provenance.
|
||||
"""
|
||||
import contextlib
|
||||
|
||||
from remove_ai_watermarks._internal.constants import TC260_FALLBACK_VENDORS
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
from remove_ai_watermarks.metadata import aigc_label, uscc_of
|
||||
from remove_ai_watermarks.watermark_registry import tc260_producer_vendors
|
||||
from remove_ai_watermarks.watermark_registry import tc260_producer_mark
|
||||
|
||||
producer = (aigc_label(path) or {}).get("ContentProducer", "")
|
||||
if producer and (vendor := tc260_producer_vendors().get(uscc_of(producer))):
|
||||
return frozenset({vendor})
|
||||
return TC260_FALLBACK_VENDORS
|
||||
if producer and (mark := tc260_producer_mark(uscc_of(producer))):
|
||||
return frozenset({mark.key})
|
||||
return frozenset()
|
||||
|
||||
|
||||
def _load_visible_input(source: str | Path | NDArray[Any]) -> _VisibleInput:
|
||||
@@ -175,7 +173,7 @@ def remove_visible(
|
||||
"""Remove every detected known visible AI mark through localize then fill.
|
||||
|
||||
The registry currently covers the Gemini visible watermark; Doubao, Jimeng,
|
||||
Qwen, Kling AI, Yuanbao, Samsung, RunningHub, and Baidu text marks; the
|
||||
Qwen text or symbol mark, Kling AI, Yuanbao, Samsung, RunningHub, and Baidu text marks; the
|
||||
LiblibAI wordmark and compact pill; one Microsoft top-right AI-badge variant;
|
||||
and the Jimeng pill. Returns ``(result_bgr, [labels removed])``.
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
@@ -19,15 +19,18 @@ from typing import Any, Literal
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
CLASSIFY_EXTRA = "'remove-ai-watermarks[classify]'"
|
||||
CLASSIFY_ONNX_EXTRA = "'remove-ai-watermarks[classify-onnx]'"
|
||||
WEIGHTS_ENV = "RAIW_CLASSIFY_WEIGHTS"
|
||||
WEIGHTS_REPO = "wiltodelta/raiw-photo-classify"
|
||||
WEIGHTS_REVISION = "4c2763766dd1c8c212d64e01e1cc3f166243c47d"
|
||||
WEIGHTS_REVISION = "de668f0d8a53412923478cc21382a56890748310"
|
||||
CLIP_FILE = "clip-l-ft.pt"
|
||||
CLIP_ONNX_FILE = "clip-l-ft-vision-fp32.onnx"
|
||||
PROBE_FILE = "probe-weights-clip-l-ft.npz"
|
||||
DETECTOR_FILE = "detector.pt"
|
||||
PROVIDER_FILE = "provider.pt"
|
||||
OPERATING_POINT_FILE = "operating-point.json"
|
||||
_WEIGHT_FILES = (CLIP_FILE, PROBE_FILE, DETECTOR_FILE, PROVIDER_FILE)
|
||||
_COMMON_WEIGHT_FILES = (PROBE_FILE, DETECTOR_FILE, PROVIDER_FILE)
|
||||
_WEIGHT_FILES = (CLIP_FILE, *_COMMON_WEIGHT_FILES)
|
||||
|
||||
# 2026-09-02 receipt-document gate: linear head on the same CLIP-L-ft
|
||||
# vector, trained on CORD-v2 train (800, CC BY 4.0) plus 200 synthetic
|
||||
@@ -55,6 +58,7 @@ RECEIPT_GATE_THRESHOLD = 2.0432573877459697
|
||||
# drifting the next time a release adds one.
|
||||
WEIGHTS_ALLOW_PATTERNS: tuple[str, ...] = (
|
||||
*_WEIGHT_FILES,
|
||||
CLIP_ONNX_FILE,
|
||||
OPERATING_POINT_FILE,
|
||||
RECEIPT_GATE_STABLE_FILE,
|
||||
RECEIPT_GATE_FILE,
|
||||
@@ -87,6 +91,7 @@ DetectorLevel = Literal["definitely", "possibly", "likely_human"]
|
||||
PixelLabel = Literal["ai", "human", "unknown"]
|
||||
PixelDomain = Literal["photo"]
|
||||
PixelProvider = Literal["openai", "google", "muse-image", "bytedance"]
|
||||
ClassifierBackend = Literal["torch", "onnx"]
|
||||
PUBLIC_PROVIDER: dict[str, PixelProvider | None] = {
|
||||
"openai": "openai",
|
||||
"google": "google",
|
||||
@@ -255,7 +260,12 @@ def classify_from_scores(
|
||||
return PixelClassification(label=label, domain="photo", detector=level, provider=provider)
|
||||
|
||||
|
||||
def classify_pixels(path: Path, *, device: str | None = None) -> PixelClassification:
|
||||
def classify_pixels(
|
||||
path: Path,
|
||||
*,
|
||||
device: str | None = None,
|
||||
backend: ClassifierBackend = "torch",
|
||||
) -> PixelClassification:
|
||||
"""Run Model 1 then, on DEFINITELY, the receipt gate and Model 2.
|
||||
|
||||
Never called by ``identify``. A DEFINITELY verdict that the receipt gate
|
||||
@@ -263,7 +273,14 @@ def classify_pixels(path: Path, *, device: str | None = None) -> PixelClassifica
|
||||
"""
|
||||
if not is_available():
|
||||
raise RuntimeError(f"pixel classification requires the classify extra. Install: pip install {CLASSIFY_EXTRA}")
|
||||
runtime = _get_runtime(device)
|
||||
if backend == "onnx":
|
||||
from remove_ai_watermarks.optional_deps import module_available
|
||||
|
||||
if not module_available("onnxruntime"):
|
||||
raise RuntimeError(
|
||||
f"the ONNX photo-classifier backend requires ONNX Runtime. Install: pip install {CLASSIFY_ONNX_EXTRA}"
|
||||
)
|
||||
runtime = _get_runtime(device, backend)
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(path) as image:
|
||||
@@ -304,6 +321,7 @@ def classify_pixels(path: Path, *, device: str | None = None) -> PixelClassifica
|
||||
@dataclass
|
||||
class _Runtime:
|
||||
device: Any
|
||||
backend: ClassifierBackend
|
||||
clip_model: Any
|
||||
processor: Any
|
||||
detector: Any
|
||||
@@ -331,7 +349,26 @@ def _resolve_device(device: str | None) -> Any:
|
||||
raise ValueError(f"unsupported classify device {device!r}")
|
||||
|
||||
|
||||
def _weights_dir() -> Path:
|
||||
def _resolve_backend(backend: ClassifierBackend, device: Any) -> ClassifierBackend:
|
||||
if backend == "onnx" and device.type != "cpu":
|
||||
raise ValueError("the ONNX photo-classifier backend supports CPU only")
|
||||
if backend not in ("torch", "onnx"):
|
||||
raise ValueError(f"unsupported classify backend {backend!r}")
|
||||
return backend
|
||||
|
||||
|
||||
def _backend_allow_patterns(backend: ClassifierBackend) -> tuple[str, ...]:
|
||||
model_file = CLIP_ONNX_FILE if backend == "onnx" else CLIP_FILE
|
||||
return (
|
||||
model_file,
|
||||
*_COMMON_WEIGHT_FILES,
|
||||
OPERATING_POINT_FILE,
|
||||
RECEIPT_GATE_STABLE_FILE,
|
||||
RECEIPT_GATE_FILE,
|
||||
)
|
||||
|
||||
|
||||
def _weights_dir(backend: ClassifierBackend = "torch") -> Path:
|
||||
override = os.environ.get(WEIGHTS_ENV)
|
||||
if override:
|
||||
path = Path(override)
|
||||
@@ -345,7 +382,7 @@ def _weights_dir() -> Path:
|
||||
snapshot_download(
|
||||
repo_id=WEIGHTS_REPO,
|
||||
revision=WEIGHTS_REVISION,
|
||||
allow_patterns=list(WEIGHTS_ALLOW_PATTERNS),
|
||||
allow_patterns=list(_backend_allow_patterns(backend)),
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
@@ -373,8 +410,10 @@ def _weight_path(folder: Path, name: str) -> Path:
|
||||
return found
|
||||
|
||||
|
||||
def _require_files(folder: Path) -> None:
|
||||
missing = [name for name in _WEIGHT_FILES if _find_weight(folder, name) is None]
|
||||
def _require_files(folder: Path, *, backend: ClassifierBackend = "torch") -> None:
|
||||
model_file = CLIP_ONNX_FILE if backend == "onnx" else CLIP_FILE
|
||||
required = (model_file, *_COMMON_WEIGHT_FILES)
|
||||
missing = [name for name in required if _find_weight(folder, name) is None]
|
||||
if missing:
|
||||
raise RuntimeError(
|
||||
"pixel classification weights are missing "
|
||||
@@ -417,14 +456,14 @@ def _build_provider_head() -> Any:
|
||||
)
|
||||
|
||||
|
||||
def _load_runtime(device: Any) -> _Runtime:
|
||||
def _load_runtime(device: Any, backend: ClassifierBackend = "torch") -> _Runtime:
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from remove_ai_watermarks._internal.clip_l_ft import load_headed_clip, load_processor
|
||||
from remove_ai_watermarks._internal.clip_l_ft import load_headed_clip, load_onnx_vision, load_processor
|
||||
|
||||
folder = _weights_dir()
|
||||
_require_files(folder)
|
||||
folder = _weights_dir(backend)
|
||||
_require_files(folder, backend=backend)
|
||||
probe = np.load(_weight_path(folder, PROBE_FILE))
|
||||
detector = _build_detector()
|
||||
detector.load_state_dict(torch.load(_weight_path(folder, DETECTOR_FILE), map_location="cpu", weights_only=True))
|
||||
@@ -441,9 +480,15 @@ def _load_runtime(device: Any) -> _Runtime:
|
||||
ridge_threshold = float(probe["thr_oi_1pct"])
|
||||
mlp_threshold, provider_margin = _operating_point(folder)
|
||||
log.info("loaded photo-classify freeze from %s", folder)
|
||||
clip_model = (
|
||||
load_onnx_vision(_weight_path(folder, CLIP_ONNX_FILE))
|
||||
if backend == "onnx"
|
||||
else load_headed_clip(_weight_path(folder, CLIP_FILE), device)
|
||||
)
|
||||
return _Runtime(
|
||||
device=device,
|
||||
clip_model=load_headed_clip(_weight_path(folder, CLIP_FILE), device),
|
||||
backend=backend,
|
||||
clip_model=clip_model,
|
||||
processor=load_processor(),
|
||||
detector=detector,
|
||||
provider_heads=heads,
|
||||
@@ -457,18 +502,21 @@ def _load_runtime(device: Any) -> _Runtime:
|
||||
)
|
||||
|
||||
|
||||
def _get_runtime(device: str | None) -> _Runtime:
|
||||
def _get_runtime(device: str | None, backend: ClassifierBackend = "torch") -> _Runtime:
|
||||
global _runtime
|
||||
resolved = _resolve_device(device)
|
||||
resolved_backend = _resolve_backend(backend, resolved)
|
||||
with _runtime_lock:
|
||||
if _runtime is None or _runtime.device != resolved:
|
||||
_runtime = _load_runtime(resolved)
|
||||
if _runtime is None or _runtime.device != resolved or _runtime.backend != resolved_backend:
|
||||
_runtime = _load_runtime(resolved, resolved_backend)
|
||||
return _runtime
|
||||
|
||||
|
||||
def _embed(runtime: _Runtime, image: Any) -> Any:
|
||||
from remove_ai_watermarks._internal.clip_l_ft import embed_image
|
||||
from remove_ai_watermarks._internal.clip_l_ft import embed_image, embed_image_onnx
|
||||
|
||||
if runtime.backend == "onnx":
|
||||
return embed_image_onnx(runtime.clip_model, runtime.processor, image)
|
||||
return embed_image(runtime.clip_model, runtime.processor, image, runtime.device)
|
||||
|
||||
|
||||
|
||||
@@ -227,8 +227,8 @@ _PIPELINE_HELP = (
|
||||
"followed by SAM-masked Z-Image face repair; sdxl-zimage = the same recipe and the "
|
||||
"same face stage on an SDXL global pass, which needs more denoise; chroma-zimage = "
|
||||
"the same face stage on an Apache-2.0 Chroma1 global pass; auto = pick the engine "
|
||||
"from the provenance (chroma-zimage for Microsoft, qwen-zimage for "
|
||||
"OpenAI/Google/Meta/unknown). "
|
||||
"from the provenance (sdxl-zimage for Google, chroma-zimage for Microsoft, "
|
||||
"qwen-zimage for OpenAI/Meta/unknown). "
|
||||
"All are CUDA-ONLY -- install the qwen-zimage extra. There is no CPU or MPS profile for "
|
||||
"invisible-watermark removal."
|
||||
)
|
||||
@@ -318,9 +318,8 @@ _text_manifest_option = click.option(
|
||||
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
||||
default=None,
|
||||
help=(
|
||||
"Experimental verified-text restoration manifest. Requires qwen-zimage or "
|
||||
"chroma-zimage, the text-restoration extra, native untiled geometry, and no "
|
||||
"postprocessing."
|
||||
"Experimental verified-text restoration manifest. Requires the text-restoration "
|
||||
"extra, native untiled geometry, and no postprocessing."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -44,6 +44,8 @@ from remove_ai_watermarks._internal.constants import (
|
||||
C2PA_CLAIM_GENERATOR_PLATFORMS,
|
||||
C2PA_IDENTITY_AI_ORGS,
|
||||
C2PA_ISSUERS,
|
||||
C2PA_SIGNER_PLATFORM_BY_ORG,
|
||||
C2PA_SIGNER_PLATFORMS,
|
||||
)
|
||||
from remove_ai_watermarks._internal.schema import require_schema_version
|
||||
from remove_ai_watermarks.metadata import (
|
||||
@@ -51,6 +53,7 @@ from remove_ai_watermarks.metadata import (
|
||||
AIGC_MARKERS,
|
||||
IPTC_AI_FIELD_MARKERS,
|
||||
IPTC_AI_MARKERS,
|
||||
LOCAL_GENERATION_METADATA_KEYS,
|
||||
aigc_label,
|
||||
aigc_label_from_metadata,
|
||||
c2pa_cloud_manifest_in,
|
||||
@@ -65,6 +68,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
|
||||
@@ -106,9 +110,7 @@ _ISSUER_PLATFORM: tuple[tuple[str, str], ...] = tuple(
|
||||
|
||||
# PNG-text / EXIF keys that indicate a local diffusion pipeline (vs. a hosted
|
||||
# platform's C2PA). Subset of AI_METADATA_KEYS; excludes the C2PA/Software keys.
|
||||
_LOCAL_GEN_KEYS = frozenset(
|
||||
AI_METADATA_KEYS & {"parameters", "prompt", "negative_prompt", "workflow", "comfyui", "invokeai_metadata", "dream"}
|
||||
)
|
||||
_LOCAL_GEN_KEYS = LOCAL_GENERATION_METADATA_KEYS
|
||||
|
||||
_STRIP_CAVEAT = (
|
||||
"Absence of metadata is not proof the image is clean: C2PA, EXIF, and PNG "
|
||||
@@ -136,11 +138,6 @@ _C2PA_INVALID_CAVEAT = (
|
||||
"are retained only as removal hints, not as verified provenance."
|
||||
)
|
||||
_IPTC_ONLY_CAVEAT = "The IPTC 'Made with AI' tag flags AI provenance but does not identify the specific platform."
|
||||
_CONTENT_SEAL_CAVEAT = (
|
||||
"Meta Muse Image outputs carry the invisible Content Seal pixel watermark, which has no "
|
||||
"local decoder; `invisible` removes it (auto when this tag is present, or `--vendor meta` "
|
||||
"on stripped files) and meta.ai/identification verifies it."
|
||||
)
|
||||
_INVISIBLE_WM_CAVEAT = (
|
||||
"The open invisible watermark is fragile: it does not survive JPEG re-encoding "
|
||||
"or resizing, so it confirms origin only on a pristine (un-re-encoded) file."
|
||||
@@ -425,10 +422,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(
|
||||
(
|
||||
@@ -452,7 +455,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:
|
||||
@@ -630,7 +633,8 @@ def _ai_tools_in(data: bytes) -> list[str]:
|
||||
# Only tokens verified against a real signed file are listed (Leica, Nikon,
|
||||
# Sony, Truepic, Google Pixel); add Canon/Bria as real samples are captured.
|
||||
# Samsung Galaxy is an AI-capable editing device, not a pure-capture camera, so
|
||||
# it lives in `_SIGNER_C2PA_PLATFORM` below (it must not feed the camera clash).
|
||||
# it lives in the neutral `C2PA_SIGNER_PLATFORMS` registry (it must not feed the
|
||||
# camera clash).
|
||||
_DEVICE_C2PA_PLATFORM: tuple[tuple[bytes, str], ...] = (
|
||||
(b"lc_c2pa", "Leica (camera, C2PA capture)"),
|
||||
(b"Leica Camera", "Leica (camera, C2PA capture)"),
|
||||
@@ -645,6 +649,8 @@ _DEVICE_C2PA_PLATFORM: tuple[tuple[bytes, str], ...] = (
|
||||
# Truepic is a C2PA signing authority whose name appears in the trust chain
|
||||
# of unrelated manifests (e.g. OpenAI), so the bare token mis-attributes.
|
||||
(b"Truepic_Lens", "Truepic Lens (verified capture)"),
|
||||
(b"vivo Camera", "vivo (camera, C2PA capture)"),
|
||||
(b"vivo X300 Pro", "vivo (camera, C2PA capture)"),
|
||||
)
|
||||
|
||||
|
||||
@@ -742,15 +748,14 @@ def _device_platform(head: bytes) -> str | None:
|
||||
# (distinct from the EXIF "SM-xxxx" model string on ordinary Samsung photos).
|
||||
# com.asus.gallery -- ASUS Gallery claim_generator (a C2PA-signed edit, no AI
|
||||
# source type or genAIType on the samples, so it never asserts is_ai).
|
||||
_SIGNER_C2PA_PLATFORM: tuple[tuple[bytes, str], ...] = (
|
||||
(b"Samsung Galaxy", "Samsung Galaxy (C2PA)"),
|
||||
(b"com.asus.gallery", "ASUS Gallery (C2PA signer)"),
|
||||
)
|
||||
|
||||
|
||||
def _signer_platform(head: bytes) -> str | None:
|
||||
"""Map a C2PA editing-app / AI-capable-device signer token to a platform."""
|
||||
return _first_token_match(head, _SIGNER_C2PA_PLATFORM)
|
||||
def _signer_platform(head: bytes, issuers: list[str]) -> str | None:
|
||||
"""Map a raw or normalized C2PA signer identity to a neutral platform."""
|
||||
raw = _first_token_match(head, C2PA_SIGNER_PLATFORMS)
|
||||
normalized = next(
|
||||
(C2PA_SIGNER_PLATFORM_BY_ORG[org] for org in issuers if org in C2PA_SIGNER_PLATFORM_BY_ORG),
|
||||
None,
|
||||
)
|
||||
return raw or normalized
|
||||
|
||||
|
||||
def _attribute_platform(issuers: list[str], *, is_ai: bool = True) -> str | None:
|
||||
@@ -822,6 +827,16 @@ _AI_VENDOR_TOKENS: tuple[tuple[str, str], ...] = (
|
||||
("dreamina", "ByteDance"),
|
||||
("volcengine", "ByteDance"),
|
||||
("volcano engine", "ByteDance"),
|
||||
("qwen", "Alibaba"),
|
||||
("alibaba", "Alibaba"),
|
||||
("kling", "Kuaishou"),
|
||||
("kuaishou", "Kuaishou"),
|
||||
("yuanbao", "Tencent"),
|
||||
("tencent", "Tencent"),
|
||||
("runninghub", "RunningHub"),
|
||||
("baidu", "Baidu"),
|
||||
("ernie", "Baidu"),
|
||||
("liblib", "LiblibAI"),
|
||||
("canva", "Canva"),
|
||||
("elevenlabs", "ElevenLabs"),
|
||||
("eleven labs", "ElevenLabs"),
|
||||
@@ -860,16 +875,16 @@ def _vendor_of(text: str | None) -> str | None:
|
||||
_C2PA_MANIFEST_SOURCE = "c2pa_manifest"
|
||||
_CLASH_SOURCE: dict[str, str] = {"c2pa": _C2PA_MANIFEST_SOURCE, "synthid": _C2PA_MANIFEST_SOURCE}
|
||||
|
||||
# The generic China TC260 AIGC vendor label -- a COUNTRY-LEVEL regulatory "this is AI"
|
||||
# stamp any Chinese generator applies to its own output, naming no specific vendor.
|
||||
_GENERIC_AIGC_VENDOR = "China AIGC (TC260)"
|
||||
# Vendors that apply the TC260 label to their OWN output. When one is co-attributed with
|
||||
# the generic AIGC label, the label is that vendor's own stamp (not an independent
|
||||
# competing origin), so the clash check attributes the AIGC label to it -- else a legit
|
||||
# ByteDance/Doubao image (C2PA "ByteDance" + its own TC260 label) would false-clash once
|
||||
# ByteDance normalizes via _vendor_of. Chinese generators only (Canva/BFL/ElevenLabs,
|
||||
# also added to _vendor_of, are NOT TC260 appliers).
|
||||
_TC260_VENDORS: frozenset[str] = frozenset({"ByteDance"})
|
||||
|
||||
def _tc260_manufacturer_of(producer: str) -> str | None:
|
||||
"""Resolve a TC260 producer to a normalized manufacturer, if registered."""
|
||||
if not producer:
|
||||
return None
|
||||
from remove_ai_watermarks.metadata import uscc_of
|
||||
from remove_ai_watermarks.watermark_registry import tc260_producer_mark
|
||||
|
||||
mark = tc260_producer_mark(uscc_of(producer))
|
||||
return _vendor_of(mark.manufacturer if mark else None)
|
||||
|
||||
|
||||
def _integrity_clashes(
|
||||
@@ -895,18 +910,6 @@ def _integrity_clashes(
|
||||
# families clash only when they belong to different provenance sources (see
|
||||
# _CLASH_SOURCE) AND name different vendors -- so multiple vendors named within
|
||||
# one C2PA manifest (C2PA issuer + SynthID provenance) do not flag.
|
||||
# The generic TC260 AIGC label is a Chinese regulatory "this is AI" stamp. When a
|
||||
# Chinese TC260-applying vendor (ByteDance) is ALSO attributed, the label is that
|
||||
# vendor's own stamp on its own output, so attribute it to that vendor -- a legit
|
||||
# Doubao image carries BOTH a ByteDance C2PA manifest and its own TC260 label and
|
||||
# must not clash. Against a NON-TC260 vendor (OpenAI, Google, ...) the label stays
|
||||
# generic and still clashes as a laundering tell (a foreign-vendor image carrying a
|
||||
# Chinese TC260 label names two different origins).
|
||||
if ai_vendors.get("aigc") == _GENERIC_AIGC_VENDOR:
|
||||
own = next((v for f, v in ai_vendors.items() if f != "aigc" and v in _TC260_VENDORS), None)
|
||||
if own:
|
||||
ai_vendors = {**ai_vendors, "aigc": own} # copy co-located with the relabel
|
||||
|
||||
source = {fam: _CLASH_SOURCE.get(fam, fam) for fam in ai_vendors}
|
||||
independent_conflict = any(
|
||||
source[a] != source[b] and ai_vendors[a] != ai_vendors[b] for a, b in itertools.combinations(ai_vendors, 2)
|
||||
@@ -960,15 +963,13 @@ def _visible_sparkle(image_path: Path, *, image: NDArray[Any] | None = None) ->
|
||||
|
||||
# Visible text marks (registry keys) -> human-readable platform, mirroring the
|
||||
# Gemini-sparkle phrasing. These are the stripped-metadata visual fallback for
|
||||
# the China-served ByteDance generators (normally also caught by the TC260 AIGC
|
||||
# metadata label); the per-engine detection thresholds live in the registry.
|
||||
# several China-served generators (normally also caught by the TC260 AIGC metadata
|
||||
# label); the per-engine detection thresholds live in the registry.
|
||||
# Text mark -> the platform sentence this report prints when that mark is the strongest
|
||||
# evidence, DERIVED from the registry rows so registering a mark is one edit. It was a
|
||||
# hand-maintained copy, and that class of copy is how LiblibAI ended up registered but
|
||||
# missing from the pill veto. Insertion order is the registry's, which is what fixes the
|
||||
# scan order below. The Gemini sparkle and the capture-less pill carry no platform of
|
||||
# their own (`KnownMark.platform is None`) and are excluded here: the sparkle has its
|
||||
# own higher-confidence `_visible_sparkle` path.
|
||||
# evidence, derived from the registry rows so registering a mark is one edit. Insertion
|
||||
# order is the registry's, which fixes the scan order below. The Gemini sparkle and the
|
||||
# capture-less pill carry no platform of their own (`KnownMark.platform is None`) and
|
||||
# are excluded here: the sparkle has its own higher-confidence `_visible_sparkle` path.
|
||||
#
|
||||
# Safe at module scope: `watermark_registry` is already imported above for
|
||||
# GEMINI_SPARKLE_TRUST_CONF, and it is deliberately cv2-free at import time.
|
||||
@@ -1181,7 +1182,6 @@ def _identify_from_evidence(
|
||||
# below keeps the full buffer: their markers are long and distinctive.
|
||||
region = _metadata_region(head)
|
||||
camera_label = _device_platform(region)
|
||||
signer_label = _signer_platform(region)
|
||||
|
||||
# ── C2PA Content Credentials ────────────────────────────────────
|
||||
has_c2pa = bool(info) or c2pa_marker_in(head)
|
||||
@@ -1192,6 +1192,7 @@ def _identify_from_evidence(
|
||||
# by substring made the displayed reason a second, looser rule than the verdict.
|
||||
failed_c2pa_codes = [str(code) for code in cast("list[object]", info.get("c2pa_failed_codes", []))]
|
||||
issuers = [info["issuer"]] if info.get("issuer") else _issuers_in(region)
|
||||
signer_label = _signer_platform(region, issuers)
|
||||
# Full AI generation (trainedAlgorithmicMedia) vs an AI-enhanced real photo
|
||||
# (compositeWithTrainedAlgorithmicMedia). The structured kind is parsed once in
|
||||
# _internal.c2pa._structured_manifest_fields (covers PNG + any container the c2pa-python
|
||||
@@ -1219,15 +1220,18 @@ def _identify_from_evidence(
|
||||
or (", ".join(tools) if (tools := _ai_tools_in(region)) else None)
|
||||
)
|
||||
# Platform: a distinctive device/camera token in the manifest wins (it is the
|
||||
# signer/producer), then an editing-app/AI-device signer (Samsung Galaxy,
|
||||
# ASUS Gallery), with the issuer byte-scan only as fallback. The issuer scan
|
||||
# alone mis-attributed real samples (Leica->Truepic timestamp authority,
|
||||
# Nikon->Adobe namespace, Pixel->Google Gemini) -- the token scans fix that.
|
||||
# signer/producer), then an exact product generator, then an editing-app or
|
||||
# AI-device signer (Samsung Galaxy, ASUS Gallery), with the issuer byte-scan
|
||||
# only as fallback. The issuer scan alone mis-attributed real samples
|
||||
# (Leica->Truepic timestamp authority, Nikon->Adobe namespace, Pixel->Google
|
||||
# Gemini) -- the token scans fix that.
|
||||
platform = (
|
||||
(
|
||||
camera_label
|
||||
# Exact product generators are useful provenance even when the
|
||||
# signed operation is a non-AI edit (for example, CapCut).
|
||||
or _claim_generator_platform(generator)
|
||||
or signer_label
|
||||
or (_claim_generator_platform(generator) if c2pa_is_ai else None)
|
||||
or (_claim_generator_platform(str(info.get("ai_tool"))) if c2pa_is_ai and info.get("ai_tool") else None)
|
||||
or _attribute_platform(issuers, is_ai=c2pa_is_ai)
|
||||
)
|
||||
@@ -1378,38 +1382,12 @@ def _identify_from_evidence(
|
||||
if standalone_iptc:
|
||||
signals.append(Signal("iptc", "digitalSourceType (Made with AI)", "high"))
|
||||
watermarks.append("IPTC digitalSourceType (Made with AI)")
|
||||
# Muse Image stamps every output with the invisible Content Seal, and this
|
||||
# tag is the only provenance such a file carries - the same measured bet
|
||||
# the strength router makes (vendor_for_strength -> "meta"). Emit the seal
|
||||
# as its own stable signal, the way InvisMark is additive over
|
||||
# soft_binding, so clients select pixel removal from the signal list
|
||||
# instead of parsing caveats. It is an attribution, not a decode: no
|
||||
# public Content Seal decoder exists, hence "medium".
|
||||
signals.append(
|
||||
Signal(
|
||||
"content_seal",
|
||||
"Meta Muse Content Seal pixel watermark (attributed by the standalone AI digital-source tag)",
|
||||
"medium",
|
||||
)
|
||||
)
|
||||
watermarks.append("Invisible Content Seal watermark (Meta Muse attribution)")
|
||||
caveats.append(_IPTC_ONLY_CAVEAT)
|
||||
caveats.append(_CONTENT_SEAL_CAVEAT)
|
||||
if platform is None:
|
||||
# Apple Photos Clean Up (Apple Intelligence object removal) marks
|
||||
# the edit with photoshop:Credit / IPTC "Apple Photos Clean Up"
|
||||
# next to compositeWithTrainedAlgorithmicMedia. It was detected but
|
||||
# previously never attributed.
|
||||
if b"Apple Photos Clean Up" in head:
|
||||
platform = "Apple Photos (Clean Up AI edit)"
|
||||
else:
|
||||
# The platform line follows the same measured bet the seal signal
|
||||
# and the strength router make: Muse Image is the tag writer whose
|
||||
# outputs this profile targets, so a hedged Muse attribution is
|
||||
# more useful than "platform not specified" while the panel below
|
||||
# already prices the Content Seal removal. The hedge stays in the
|
||||
# wording - it names the attribution basis, not a detection.
|
||||
platform = "Meta Muse Image (attributed by the standalone AI digital-source tag)"
|
||||
# Apple Photos Clean Up carries an explicit product credit beside the shared
|
||||
# IPTC value. Without such product-specific evidence the standard names no
|
||||
# platform and must not imply a vendor watermark.
|
||||
if platform is None and b"Apple Photos Clean Up" in head:
|
||||
platform = "Apple Photos (Clean Up AI edit)"
|
||||
|
||||
# ── IPTC 2025.1 AI-disclosure fields (Iptc4xmpExt:AISystemUsed etc.) ─
|
||||
iptc_ai = any(m in head for m in IPTC_AI_FIELD_MARKERS)
|
||||
@@ -1437,8 +1415,9 @@ def _identify_from_evidence(
|
||||
signals.append(Signal("aigc", f"TC260 AIGC label{f' (producer {producer})' if producer else ''}", "high"))
|
||||
watermarks.append("China AIGC label (TC260 standard)")
|
||||
if platform is None:
|
||||
platform = "China AIGC-labeled generator (TC260; e.g. Doubao)"
|
||||
ai_vendor_claims["aigc"] = _GENERIC_AIGC_VENDOR
|
||||
platform = "China AIGC-labeled content (TC260 standard)"
|
||||
if manufacturer := _tc260_manufacturer_of(producer):
|
||||
ai_vendor_claims["aigc"] = manufacturer
|
||||
|
||||
# ── Local diffusion parameters (Stable Diffusion / ComfyUI) ──────
|
||||
local_keys = sorted(k for k in meta if k.lower() in _LOCAL_GEN_KEYS)
|
||||
@@ -1459,13 +1438,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"
|
||||
|
||||
@@ -19,8 +19,8 @@ from typing import TYPE_CHECKING, Any
|
||||
from ._internal.watermark_profiles import (
|
||||
DEFAULT_PROFILE,
|
||||
REMOVAL_MODULES,
|
||||
SDXL_ZIMAGE_PROFILE,
|
||||
resolve_adaptive_polish,
|
||||
resolve_effective_profile,
|
||||
resolve_seed,
|
||||
)
|
||||
|
||||
@@ -191,9 +191,9 @@ class InvisibleEngine:
|
||||
def preload(self, *, global_only: bool = False) -> None:
|
||||
"""Eagerly load the pipeline so download progress is visible.
|
||||
|
||||
For ``qwen-zimage``, ``global_only=True`` loads the mandatory Qwen stage
|
||||
and leaves the optional Z-Image and SAM face stack lazy until a face is
|
||||
detected. Other profiles have no optional stage and ignore the flag.
|
||||
For every concrete profile, ``global_only=True`` loads its mandatory
|
||||
global stage and leaves the shared Z-Image and SAM face stack lazy until
|
||||
a face is detected.
|
||||
"""
|
||||
self._remover.preload(global_only=global_only)
|
||||
|
||||
@@ -267,13 +267,19 @@ class InvisibleEngine:
|
||||
import tempfile
|
||||
|
||||
seed = resolve_seed(seed)
|
||||
adaptive_polish = resolve_adaptive_polish(adaptive_polish, self._remover.model_profile)
|
||||
effective_profile = resolve_effective_profile(self._remover.configured_profile, vendor)
|
||||
# Verified text must be the final pixel-writing stage. SDXL normally enables
|
||||
# adaptive polish by profile default, so an unset value becomes False here;
|
||||
# an explicit True remains an error below instead of being silently ignored.
|
||||
adaptive_polish = (
|
||||
False
|
||||
if text_manifest is not None and adaptive_polish is None
|
||||
else resolve_adaptive_polish(adaptive_polish, effective_profile)
|
||||
)
|
||||
|
||||
if fidelity_anchor and text_manifest is None:
|
||||
raise ValueError("fidelity_anchor requires a text manifest")
|
||||
if text_manifest is not None:
|
||||
if self._remover.model_profile == SDXL_ZIMAGE_PROFILE:
|
||||
raise ValueError("--text-manifest is not supported by the sdxl-zimage profile")
|
||||
if tile:
|
||||
raise ValueError("--text-manifest is not calibrated with --tile")
|
||||
if max_resolution != 0:
|
||||
|
||||
@@ -160,14 +160,9 @@ class KlingEngine(TextMarkEngine):
|
||||
super().__init__(_CONFIG)
|
||||
self._latin = TextMarkEngine(_LATIN_CONFIG)
|
||||
|
||||
@staticmethod
|
||||
def _best(*detections: TextMarkDetection) -> TextMarkDetection:
|
||||
"""Prefer an accepted variant, then retain the strongest rejected score."""
|
||||
return max(detections, key=lambda detection: (detection.detected, detection.confidence))
|
||||
|
||||
def detect(self, image: NDArray[Any], *, provenance: bool = False) -> TextMarkDetection:
|
||||
"""Return the strongest CJK or Latin Kling wordmark verdict."""
|
||||
return self._best(
|
||||
return _text_mark_engine.best_detection(
|
||||
super().detect(image, provenance=provenance),
|
||||
self._latin.detect(image, provenance=provenance),
|
||||
)
|
||||
@@ -176,7 +171,9 @@ class KlingEngine(TextMarkEngine):
|
||||
"""Scan each immutable variant once and combine strict/relaxed verdicts."""
|
||||
cjk_strict, cjk_relaxed = super().detect_both(image)
|
||||
latin_strict, latin_relaxed = self._latin.detect_both(image)
|
||||
return self._best(cjk_strict, latin_strict), self._best(cjk_relaxed, latin_relaxed)
|
||||
return _text_mark_engine.best_detection(cjk_strict, latin_strict), _text_mark_engine.best_detection(
|
||||
cjk_relaxed, latin_relaxed
|
||||
)
|
||||
|
||||
def footprint_mask(
|
||||
self,
|
||||
|
||||
@@ -59,6 +59,9 @@ AI_METADATA_KEYS: frozenset[str] = frozenset(
|
||||
"Software",
|
||||
]
|
||||
)
|
||||
LOCAL_GENERATION_METADATA_KEYS: frozenset[str] = frozenset(
|
||||
AI_METADATA_KEYS & {"parameters", "prompt", "negative_prompt", "workflow", "comfyui", "invokeai_metadata", "dream"}
|
||||
)
|
||||
|
||||
AI_KEYWORDS: tuple[str, ...] = (
|
||||
"stable_diffusion",
|
||||
@@ -169,6 +172,7 @@ TC260_AIGC_FIELDS: frozenset[str] = frozenset(
|
||||
"ServiceUser",
|
||||
}
|
||||
)
|
||||
_TC260_CANONICAL_FIELDS: dict[str, str] = {field.casefold(): field for field in TC260_AIGC_FIELDS}
|
||||
MAX_TC260_VALUE_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
@@ -197,7 +201,10 @@ def parse_tc260_aigc_json(value: bytes) -> dict[str, str] | None:
|
||||
return None
|
||||
if not isinstance(parsed, dict):
|
||||
return None
|
||||
fields = {str(key): str(item) for key, item in cast("dict[object, object]", parsed).items()}
|
||||
fields = {
|
||||
_TC260_CANONICAL_FIELDS.get(str(key).casefold(), str(key)): str(item)
|
||||
for key, item in cast("dict[object, object]", parsed).items()
|
||||
}
|
||||
return fields if TC260_AIGC_FIELDS & fields.keys() else None
|
||||
|
||||
|
||||
@@ -520,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)
|
||||
|
||||
|
||||
@@ -877,11 +884,47 @@ def generator_from_metadata(candidates: Iterable[str], scan: bytes = b"") -> str
|
||||
for value in itertools.chain(candidates, creator_tools):
|
||||
if app_generator := app_generator_from_metadata(value):
|
||||
return app_generator
|
||||
if structured_generator := _structured_generator_from_metadata(value):
|
||||
return structured_generator
|
||||
if any(token in value.lower() for token in AI_GENERATOR_TOKENS):
|
||||
return value.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _structured_generator_from_metadata(value: str) -> str | None:
|
||||
"""Extract a bounded generator name without returning its surrounding JSON."""
|
||||
from remove_ai_watermarks._internal.constants import AI_GENERATOR_TOKENS
|
||||
|
||||
if len(value) > MAX_TC260_VALUE_BYTES:
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except ValueError:
|
||||
return None
|
||||
if not isinstance(parsed, dict):
|
||||
return None
|
||||
generation_params = cast("dict[object, object]", parsed).get("generationParams")
|
||||
if not isinstance(generation_params, dict):
|
||||
return None
|
||||
model_name = cast("dict[object, object]", generation_params).get("modelName")
|
||||
if not isinstance(model_name, str) or not model_name.strip() or len(model_name) > 200:
|
||||
return None
|
||||
normalized = model_name.strip()
|
||||
return normalized if any(token in normalized.lower() for token in AI_GENERATOR_TOKENS) else None
|
||||
|
||||
|
||||
def exif_bytes_from_image(image: Any) -> bytes | None:
|
||||
"""Return EXIF bytes from either a normal EXIF field or a decoded raw profile."""
|
||||
value = image.info.get("exif")
|
||||
if isinstance(value, bytes) and value:
|
||||
return value
|
||||
with contextlib.suppress(Exception):
|
||||
exif = image.getexif()
|
||||
if exif:
|
||||
return exif.tobytes()
|
||||
return None
|
||||
|
||||
|
||||
_APP_PROVENANCE_PRODUCTS: dict[str, str] = {
|
||||
"doubao": "ByteDance Doubao",
|
||||
"xinghui": "ByteDance Xinghui",
|
||||
@@ -894,12 +937,17 @@ _APP_AIGC_LABEL_RE = re.compile(r'"aigc_label_type"\s*:\s*[12](?=\s*[,}])', re.I
|
||||
_APP_AIGC_TYPE_RE = re.compile(r'"aigc_type"\s*:\s*1(?=\s*[,}])', re.IGNORECASE)
|
||||
|
||||
|
||||
def _normalized_app_metadata(value: str | bytes) -> str:
|
||||
text = value.decode("latin-1", "ignore") if isinstance(value, bytes) else value
|
||||
def _normalized_app_metadata(value: object) -> str:
|
||||
if isinstance(value, bytes):
|
||||
text = value.decode("latin-1", "ignore")
|
||||
elif isinstance(value, str):
|
||||
text = value
|
||||
else:
|
||||
return ""
|
||||
return text.replace('\\"', '"')
|
||||
|
||||
|
||||
def _app_metadata_evidence(value: str | bytes) -> tuple[str | None, str | None]:
|
||||
def _app_metadata_evidence(value: object) -> tuple[str | None, str | None]:
|
||||
"""Return removable product provenance and stronger AI-origin evidence."""
|
||||
normalized = _normalized_app_metadata(value)
|
||||
products = tuple(match.group(1).lower() for match in _APP_PRODUCT_RE.finditer(normalized))
|
||||
@@ -962,7 +1010,7 @@ def exif_generator(image_path: Path) -> str | None:
|
||||
|
||||
with Image.open(image_path) as img:
|
||||
info = img.info
|
||||
exif_bytes = info.get("exif")
|
||||
exif_bytes = exif_bytes_from_image(img)
|
||||
# PNG tEXt/iTXt chunks land in img.info too (same idiom as the other
|
||||
# PNG-text readers in this module); NovelAI stamps Software/Source/Title.
|
||||
for key in ("Software", "Source", "Title", "Description"):
|
||||
@@ -1004,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)
|
||||
@@ -1018,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 = img.info.get("exif")
|
||||
if not exif_bytes:
|
||||
return False
|
||||
tags = piexif.load(exif_bytes).get("0th", {})
|
||||
exif_bytes = exif_bytes_from_image(img)
|
||||
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.
|
||||
@@ -1063,8 +1238,7 @@ def _is_aigc_exif_value(raw: object) -> bool:
|
||||
return False
|
||||
if b"AIGC" not in raw:
|
||||
return False
|
||||
text = bytes(raw).decode("latin-1", "ignore")
|
||||
return any(field in text for field in TC260_AIGC_FIELDS)
|
||||
return aigc_label_from_metadata(bytes(raw)) is not None
|
||||
|
||||
|
||||
def _ai_exif_targets(loaded: dict[str, Any]) -> list[tuple[str, int, bytes, str]]:
|
||||
@@ -1100,15 +1274,18 @@ def _ai_exif_targets(loaded: dict[str, Any]) -> list[tuple[str, int, bytes, str]
|
||||
if xai_signature_pair(_exif_text(ifd0, piexif.ImageIFD.ImageDescription), _exif_text(ifd0, piexif.ImageIFD.Artist)):
|
||||
add("0th", ifd0, piexif.ImageIFD.ImageDescription, "ImageDescription")
|
||||
add("0th", ifd0, piexif.ImageIFD.Artist, "Artist")
|
||||
# (b) known AI generator token in a 0th text tag.
|
||||
for tag, name in (
|
||||
(piexif.ImageIFD.Software, "Software"),
|
||||
(piexif.ImageIFD.Make, "Make"),
|
||||
(piexif.ImageIFD.Artist, "Artist"),
|
||||
(piexif.ImageIFD.ImageDescription, "ImageDescription"),
|
||||
# (b) known AI generator token or structured generator name in a text tag.
|
||||
for ifd_key, ifd, tag, name in (
|
||||
("0th", ifd0, piexif.ImageIFD.Software, "Software"),
|
||||
("0th", ifd0, piexif.ImageIFD.Make, "Make"),
|
||||
("0th", ifd0, piexif.ImageIFD.Artist, "Artist"),
|
||||
("0th", ifd0, piexif.ImageIFD.ImageDescription, "ImageDescription"),
|
||||
("Exif", ifde, piexif.ExifIFD.UserComment, "UserComment"),
|
||||
):
|
||||
if any(token in _exif_text(ifd0, tag).lower() for token in AI_GENERATOR_TOKENS):
|
||||
add("0th", ifd0, tag, name)
|
||||
value = ifd.get(tag)
|
||||
text = value.decode("latin-1", "replace") if isinstance(value, bytes) else ""
|
||||
if any(token in text.lower() for token in AI_GENERATOR_TOKENS) or _structured_generator_from_metadata(text):
|
||||
add(ifd_key, ifd, tag, name)
|
||||
# (c) TC260 AIGC block in ImageDescription (0th) or UserComment (Exif sub-IFD).
|
||||
if _is_aigc_exif_value(ifd0.get(piexif.ImageIFD.ImageDescription)):
|
||||
add("0th", ifd0, piexif.ImageIFD.ImageDescription, "ImageDescription")
|
||||
@@ -1171,6 +1348,11 @@ def get_ai_metadata(image_path: Path) -> dict[str, str]:
|
||||
except Exception as exc:
|
||||
logger.debug("PIL could not open %s for AI-metadata scan: %s", image_path, exc)
|
||||
|
||||
from remove_ai_watermarks._internal.isobmff import ai_metadata_tags
|
||||
|
||||
for key, value in ai_metadata_tags(image_path).items():
|
||||
result.setdefault(key, value[:200] + ("…" if len(value) > 200 else ""))
|
||||
|
||||
# C2PA manifest fields from the single canonical parser (_internal/c2pa.py).
|
||||
c2pa = extract_c2pa_info(image_path)
|
||||
for key in (
|
||||
@@ -1221,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):
|
||||
@@ -1304,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
|
||||
@@ -1425,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)
|
||||
@@ -1447,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
|
||||
@@ -1479,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))
|
||||
)
|
||||
|
||||
|
||||
@@ -1494,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.
|
||||
|
||||
@@ -1510,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
|
||||
@@ -1632,11 +1850,11 @@ def remove_ai_metadata(
|
||||
with open(source_path, "rb") as f:
|
||||
head = f.read(26)
|
||||
if source_path.suffix.lower() in _STREAMING_ISOBMFF_EXTS and is_isobmff(head):
|
||||
stripped, tc260_blanked = strip_isobmff_media_file(source_path, output_path)
|
||||
stripped, native_ai_blanked = strip_isobmff_media_file(source_path, output_path)
|
||||
logger.info(
|
||||
"Stream-blanked %d AI-provenance box(es) and %d native TC260 tag(s) → %s",
|
||||
"Stream-blanked %d AI-provenance box(es) and %d native AI metadata tag(s) → %s",
|
||||
stripped,
|
||||
tc260_blanked,
|
||||
native_ai_blanked,
|
||||
output_path,
|
||||
)
|
||||
return output_path
|
||||
@@ -1714,8 +1932,10 @@ def remove_ai_metadata(
|
||||
return output_path
|
||||
|
||||
# Read image and filter metadata
|
||||
with Image.open(source_path) as img:
|
||||
img = img.copy()
|
||||
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
|
||||
# -- a lossless PNG/WebP whose extension lies (served as .jpg). There the output
|
||||
@@ -1759,9 +1979,18 @@ 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:
|
||||
with contextlib.suppress(Exception):
|
||||
exif_data = piexif.load(source_exif_bytes)
|
||||
|
||||
# Apply cleaned metadata
|
||||
if save_kwargs["format"] == "PNG" and kept_meta:
|
||||
pnginfo = PngInfo()
|
||||
|
||||
@@ -50,6 +50,7 @@ from remove_ai_watermarks._internal.schema import require_schema_version
|
||||
from remove_ai_watermarks.metadata import (
|
||||
QUICK_SCAN_BYTES,
|
||||
SAMSUNG_EDITOR_MARKER,
|
||||
exif_bytes_from_image,
|
||||
exif_text,
|
||||
read_file_tail,
|
||||
)
|
||||
@@ -267,7 +268,10 @@ def _decoder_info(image_path: Path) -> dict[str, Any]:
|
||||
with Image.open(image_path) as img:
|
||||
# PIL types this mapping with a non-string key union (a DPI tuple key
|
||||
# exists), so the keys are normalized here rather than assumed.
|
||||
return {str(key): value for key, value in img.info.items()}
|
||||
info = {str(key): value for key, value in img.info.items()}
|
||||
if "exif" not in info and (exif_bytes := exif_bytes_from_image(img)):
|
||||
info["exif"] = exif_bytes
|
||||
return info
|
||||
except (UnidentifiedImageError, ImportError) as exc: # unsupported optional decoder
|
||||
logger.debug("PIL info unavailable for %s: %s", image_path, exc)
|
||||
return {}
|
||||
@@ -277,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.
|
||||
"""
|
||||
@@ -392,6 +397,10 @@ def collect_metadata_record(
|
||||
issues.append({"stage": "trailer", "code": "collection-failed"})
|
||||
try:
|
||||
info = _decoder_info(image_path)
|
||||
if container == "isobmff":
|
||||
from remove_ai_watermarks._internal.isobmff import ai_metadata_tags
|
||||
|
||||
info.update(ai_metadata_tags(image_path, strict=True))
|
||||
exif = _exif_pairs(info)
|
||||
except Exception as exc:
|
||||
logger.debug("metadata decoder collection failed for %s: %s", image_path, exc)
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
"""Detect and disrupt the pixel payload written by local Microsoft Paint.
|
||||
|
||||
This is a clean-room implementation of the observed ``Watermarker.dll`` wire
|
||||
format. It carries no Microsoft binary, symbols, or writer-quality model. The
|
||||
reader validates the repeated 144-bit message before the remover touches pixels;
|
||||
callers with pristine C2PA should also pass its signed watermark UUID.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from numpy.typing import NDArray
|
||||
|
||||
_BLOCK_SIZE = 4
|
||||
_MAP_SIZE = 12
|
||||
_PAYLOAD_BITS = _MAP_SIZE * _MAP_SIZE
|
||||
_QUANTUM = 24.0
|
||||
_CENTERS = (6.0, 18.0)
|
||||
_MAX_CENTER_ERROR = 2.0
|
||||
_MIN_SUPPORT = 3
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LocalInvisMarkDetection:
|
||||
"""A validated local Paint payload recovered from repeated pixel carriers."""
|
||||
|
||||
watermark_id: uuid.UUID
|
||||
payload: bytes
|
||||
minimum_support: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LocalInvisMarkRemoval(LocalInvisMarkDetection):
|
||||
"""A disrupted local Paint payload and the number of rewritten carriers."""
|
||||
|
||||
changed_blocks: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Analysis:
|
||||
ll: NDArray[Any]
|
||||
bits: NDArray[Any]
|
||||
eligible: NDArray[Any]
|
||||
bit_indices: NDArray[Any]
|
||||
|
||||
|
||||
def _dct_matrix() -> NDArray[Any]:
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks._internal.image_math import dct_matrix
|
||||
|
||||
return dct_matrix(np, _BLOCK_SIZE)
|
||||
|
||||
|
||||
def _luminance(rgb: NDArray[Any]) -> NDArray[Any]:
|
||||
import numpy as np
|
||||
|
||||
return rgb.astype(np.float64) @ np.array((0.299, 0.587, 0.114), dtype=np.float64)
|
||||
|
||||
|
||||
def _ll_plane(luminance: NDArray[Any]) -> NDArray[Any]:
|
||||
height = luminance.shape[0] - luminance.shape[0] % 2
|
||||
width = luminance.shape[1] - luminance.shape[1] % 2
|
||||
plane = luminance[:height, :width]
|
||||
return (plane[0::2, 0::2] + plane[0::2, 1::2] + plane[1::2, 0::2] + plane[1::2, 1::2]) * 0.5
|
||||
|
||||
|
||||
def _analyze(rgb: NDArray[Any]) -> _Analysis:
|
||||
import numpy as np
|
||||
|
||||
ll = _ll_plane(_luminance(rgb))
|
||||
block_rows = ll.shape[0] // _BLOCK_SIZE
|
||||
block_columns = ll.shape[1] // _BLOCK_SIZE
|
||||
blocks = (
|
||||
ll[: block_rows * _BLOCK_SIZE, : block_columns * _BLOCK_SIZE]
|
||||
.reshape(block_rows, _BLOCK_SIZE, block_columns, _BLOCK_SIZE)
|
||||
.transpose(0, 2, 1, 3)
|
||||
)
|
||||
transform = _dct_matrix()
|
||||
coefficients = transform @ blocks @ transform.T
|
||||
matrices = coefficients.reshape(block_rows, block_columns, -1)[..., 1:].reshape(block_rows, block_columns, 3, 5)
|
||||
singular_values = np.linalg.svd(matrices, compute_uv=False)[..., 0]
|
||||
residue = singular_values % _QUANTUM
|
||||
center_errors = np.stack(
|
||||
[np.minimum(abs(residue - center), _QUANTUM - abs(residue - center)) for center in _CENTERS],
|
||||
axis=-1,
|
||||
)
|
||||
bits = np.argmin(center_errors, axis=-1).astype(np.uint8)
|
||||
eligible = np.min(center_errors, axis=-1) <= _MAX_CENTER_ERROR
|
||||
block_y, block_x = np.indices((block_rows, block_columns))
|
||||
bit_indices = (block_y % _MAP_SIZE) * _MAP_SIZE + block_x % _MAP_SIZE
|
||||
return _Analysis(ll=ll, bits=bits, eligible=eligible, bit_indices=bit_indices)
|
||||
|
||||
|
||||
def _validated_detection(rgb: NDArray[Any]) -> tuple[LocalInvisMarkDetection, _Analysis] | None:
|
||||
import numpy as np
|
||||
|
||||
analysis = _analyze(rgb)
|
||||
support = np.zeros((_PAYLOAD_BITS, 2), dtype=np.int32)
|
||||
np.add.at(support, (analysis.bit_indices[analysis.eligible], analysis.bits[analysis.eligible]), 1)
|
||||
winning_support = support.max(axis=1)
|
||||
if np.any(winning_support < _MIN_SUPPORT) or np.any(support[:, 0] == support[:, 1]):
|
||||
return None
|
||||
|
||||
bits = (support[:, 1] > support[:, 0]).astype(np.uint8)
|
||||
payload = np.packbits(bits).tobytes()
|
||||
if len(payload) != 18 or payload[0] != 0x4C or payload[-1] != sum(payload[1:17]) & 0xFF:
|
||||
return None
|
||||
watermark_id = uuid.UUID(bytes_le=payload[1:17])
|
||||
if watermark_id.int == 0:
|
||||
return None
|
||||
detection = LocalInvisMarkDetection(
|
||||
watermark_id=watermark_id,
|
||||
payload=payload,
|
||||
minimum_support=int(winning_support.min()),
|
||||
)
|
||||
return detection, analysis
|
||||
|
||||
|
||||
def _read_rgb(source: str | Path) -> tuple[NDArray[Any], NDArray[Any] | None]:
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
bgr, alpha = image_io.read_bgr_and_alpha(source)
|
||||
if bgr is None:
|
||||
raise ValueError(f"Could not read image: {source}")
|
||||
return bgr[..., ::-1], alpha
|
||||
|
||||
|
||||
def detect_local_invismark(source: str | Path) -> LocalInvisMarkDetection | None:
|
||||
"""Return a validated local Paint payload, or ``None`` for an uncertain image."""
|
||||
rgb, _alpha = _read_rgb(source)
|
||||
recovered = _validated_detection(rgb)
|
||||
return recovered[0] if recovered is not None else None
|
||||
|
||||
|
||||
def invismark_id_from_c2pa_info(info: dict[str, Any]) -> uuid.UUID | None:
|
||||
"""Return the one signed UUID for a Microsoft InvisMark soft binding."""
|
||||
algorithms = {value.strip() for value in str(info.get("soft_binding_algorithm") or "").split(",") if value.strip()}
|
||||
if "com.microsoft.invismark.1" not in algorithms:
|
||||
return None
|
||||
values = [value.strip() for value in str(info.get("soft_binding_value") or "").split(",") if value.strip()]
|
||||
parsed: list[uuid.UUID] = []
|
||||
for value in values:
|
||||
try:
|
||||
parsed.append(uuid.UUID(value))
|
||||
except ValueError:
|
||||
continue
|
||||
unique = tuple(dict.fromkeys(parsed))
|
||||
return unique[0] if len(unique) == 1 else None
|
||||
|
||||
|
||||
def invismark_id_from_c2pa(source: str | Path) -> uuid.UUID | None:
|
||||
"""Read the signed Microsoft InvisMark UUID from a pristine image."""
|
||||
from remove_ai_watermarks._internal.c2pa import extract_c2pa_info
|
||||
|
||||
try:
|
||||
return invismark_id_from_c2pa_info(extract_c2pa_info(Path(source)))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def disrupt_local_invismark(
|
||||
source: str | Path,
|
||||
output: str | Path,
|
||||
*,
|
||||
expected_watermark_id: str | uuid.UUID | None = None,
|
||||
) -> LocalInvisMarkRemoval | None:
|
||||
"""Invert confirmed local Paint carriers and atomically write the result.
|
||||
|
||||
No output is written unless the payload validates, an optional signed UUID
|
||||
agrees, and the encoded output no longer contains a valid local payload.
|
||||
This invalidates the observed message; it is not a claim about an unavailable
|
||||
external Microsoft detector.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
source_path, output_path = Path(source), Path(output)
|
||||
rgb, alpha = _read_rgb(source_path)
|
||||
recovered = _validated_detection(rgb)
|
||||
if recovered is None:
|
||||
return None
|
||||
detection, analysis = recovered
|
||||
if expected_watermark_id is not None:
|
||||
try:
|
||||
expected = uuid.UUID(str(expected_watermark_id))
|
||||
except ValueError:
|
||||
return None
|
||||
if detection.watermark_id != expected:
|
||||
return None
|
||||
|
||||
luminance = _luminance(rgb)
|
||||
ll = analysis.ll
|
||||
original_ll = ll.copy()
|
||||
transform = _dct_matrix()
|
||||
payload_bits = np.unpackbits(np.frombuffer(detection.payload, dtype=np.uint8))
|
||||
selected = analysis.eligible & (analysis.bits == payload_bits[analysis.bit_indices])
|
||||
selected_y, selected_x = np.nonzero(selected)
|
||||
changed_blocks = len(selected_y)
|
||||
block_rows, block_columns = analysis.bits.shape
|
||||
block_grid = (
|
||||
ll[: block_rows * _BLOCK_SIZE, : block_columns * _BLOCK_SIZE]
|
||||
.reshape(block_rows, _BLOCK_SIZE, block_columns, _BLOCK_SIZE)
|
||||
.transpose(0, 2, 1, 3)
|
||||
.copy()
|
||||
)
|
||||
for start in range(0, changed_blocks, 4096):
|
||||
batch_y = selected_y[start : start + 4096]
|
||||
batch_x = selected_x[start : start + 4096]
|
||||
coefficients = transform @ block_grid[batch_y, batch_x] @ transform.T
|
||||
matrices = coefficients.reshape(-1, 16)[:, 1:].reshape(-1, 3, 5)
|
||||
target_bits = 1 - analysis.bits[batch_y, batch_x]
|
||||
u, singular, vh = np.linalg.svd(matrices, full_matrices=False)
|
||||
singular[:, 0] = (np.floor(singular[:, 0] / _QUANTUM) + 0.25 + 0.5 * target_bits) * _QUANTUM
|
||||
rewritten = coefficients.reshape(-1, 16).copy()
|
||||
rewritten[:, 1:] = ((u * singular[:, None, :]) @ vh).reshape(-1, 15)
|
||||
block_grid[batch_y, batch_x] = transform.T @ rewritten.reshape(-1, 4, 4) @ transform
|
||||
ll[: block_rows * _BLOCK_SIZE, : block_columns * _BLOCK_SIZE] = block_grid.transpose(0, 2, 1, 3).reshape(
|
||||
block_rows * _BLOCK_SIZE, block_columns * _BLOCK_SIZE
|
||||
)
|
||||
|
||||
height, width = original_ll.shape
|
||||
delta = (ll - original_ll) * 0.5
|
||||
pixel_delta = np.zeros(luminance.shape, dtype=np.float64)
|
||||
pixel_delta[: height * 2 : 2, : width * 2 : 2] = delta
|
||||
pixel_delta[: height * 2 : 2, 1 : width * 2 : 2] = delta
|
||||
pixel_delta[1 : height * 2 : 2, : width * 2 : 2] = delta
|
||||
pixel_delta[1 : height * 2 : 2, 1 : width * 2 : 2] = delta
|
||||
cleaned_rgb = np.clip(np.rint(rgb.astype(np.float64) + pixel_delta[..., None]), 0, 255).astype(np.uint8)
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_descriptor, temporary_name = tempfile.mkstemp(
|
||||
prefix=f".{output_path.stem}-", suffix=output_path.suffix, dir=output_path.parent
|
||||
)
|
||||
os.close(file_descriptor)
|
||||
temporary_path = Path(temporary_name)
|
||||
try:
|
||||
if not image_io.write_bgr_with_alpha(
|
||||
temporary_path,
|
||||
cleaned_rgb[..., ::-1],
|
||||
alpha,
|
||||
display_tags_from=source_path,
|
||||
orientation_applied=False,
|
||||
):
|
||||
raise OSError(f"failed to write output (is the destination writable?): {output_path}")
|
||||
if detect_local_invismark(temporary_path) is not None:
|
||||
return None
|
||||
os.replace(temporary_path, output_path)
|
||||
finally:
|
||||
if temporary_path.exists():
|
||||
temporary_path.unlink()
|
||||
|
||||
return LocalInvisMarkRemoval(
|
||||
watermark_id=detection.watermark_id,
|
||||
payload=detection.payload,
|
||||
minimum_support=detection.minimum_support,
|
||||
changed_blocks=changed_blocks,
|
||||
)
|
||||
@@ -48,6 +48,7 @@ import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from remove_ai_watermarks._internal.image_math import dct_matrix as _dct_matrix
|
||||
from remove_ai_watermarks._internal.schema import require_schema_version
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -148,15 +149,6 @@ def _numpy() -> Any:
|
||||
return np
|
||||
|
||||
|
||||
def _dct_matrix(np: Any, n: int = 8) -> Any:
|
||||
"""Orthonormal n x n DCT-II basis: M[i, j] = cos(pi (2j + 1) i / 2n)."""
|
||||
i = np.arange(n)[:, None]
|
||||
j = np.arange(n)[None, :]
|
||||
m = np.cos(np.pi * (2 * j + 1) * i / (2 * n))
|
||||
m[0, :] *= 1 / np.sqrt(2)
|
||||
return m * np.sqrt(2 / n)
|
||||
|
||||
|
||||
def read_gray(image_path: Path) -> tuple[Any, Any, dict[str, Any]]:
|
||||
"""Decode to float32 grayscale (and RGB for color stats), downscaled.
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"""Qwen (Alibaba) visible watermark detector/localizer.
|
||||
|
||||
Qwen stamps its generations with a visible "千问AI生成" text strip in the
|
||||
bottom-right corner -- the explicit AIGC label mandated by China's GB 45438-2025
|
||||
Qwen stamps its generations with either a visible "千问AI生成" text strip or a
|
||||
small three-lobe symbol in the bottom-right corner. The text is the explicit AIGC
|
||||
label mandated by China's GB 45438-2025
|
||||
(the same 6-glyph house style as Doubao's "豆包AI生成": a 2-glyph vendor prefix
|
||||
plus the shared `AI生成` tail), preceded by the vendor's tri-lobe logo (not part
|
||||
of the detection silhouette -- logos vary between releases, the CJK run is what
|
||||
discriminates).
|
||||
plus the shared `AI生成` tail), preceded by the vendor's tri-lobe logo. Qwen
|
||||
Create's global consumer surface uses the symbol alone; it has its own square
|
||||
template and search geometry.
|
||||
|
||||
Detection matches the bundled glyph silhouette against the corner; removal is the
|
||||
shared **localize -> fill** (the glyph-bbox :meth:`footprint_mask` feeds
|
||||
@@ -49,10 +50,14 @@ whose producer USCC 91440101MA9Y9T4H7A names the entity, 2026-07-21; harness
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from remove_ai_watermarks import _text_mark_engine
|
||||
from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkEngine
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks import _text_mark_engine, image_io
|
||||
from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkDetection, TextMarkEngine
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from numpy.typing import NDArray
|
||||
@@ -94,6 +99,28 @@ _ALPHA_HEIGHT_FRAC = 0.0416
|
||||
# its nearest rung).
|
||||
_LADDER = (0.78, 1.27)
|
||||
|
||||
# Qwen Create symbol-only mark, measured on the cleared 2048-square provider
|
||||
# fixture. The mark body is ~2.7% of the short side, inset ~3% from both edges.
|
||||
# The strict 0.65 gate leaves 0.13 NCC margin over the tracked-control maximum
|
||||
# (0.516 across 132 decoded images other than the positive on 2026-09-10).
|
||||
_SYMBOL_ASSET = "qwen_symbol_alpha.png"
|
||||
_SYMBOL_SEARCH_FRAC = 0.11
|
||||
_SYMBOL_MARGIN_FRAC = 0.012
|
||||
_SYMBOL_SIZE_FRAC = 0.03125
|
||||
_SYMBOL_LADDER = (0.75, 0.875, 1.0, 1.125, 1.25)
|
||||
_SYMBOL_NCC_THRESHOLD = 0.65
|
||||
# NCC alone is not portable: issue 108 reported 0.81 on
|
||||
# data/fixtures/provenance/chatgpt-1.png (sand/denim, no glyph) while this
|
||||
# OpenCV build scores 0.437 on the same file. True Qwen Create matches keep
|
||||
# binarized IoU >= 0.75 down to 512 px; that fixture sits at 0.55. Require
|
||||
# both the NCC gate and this shape overlap so a high-NCC blob cannot fire.
|
||||
_SYMBOL_SHAPE_IOU_THRESHOLD = 0.70
|
||||
_SYMBOL_TEMPLATE_MASK_FRAC = 0.25
|
||||
_SYMBOL_RESPONSE_MASK_FRAC = 0.35
|
||||
_SYMBOL_MIN_SHORT_SIDE = 256
|
||||
_SYMBOL_MASK_ALPHA = 4 / 255
|
||||
_SYMBOL_MASK_DILATE_FRAC = 0.06
|
||||
|
||||
_CONFIG = TextMarkConfig(
|
||||
name="Qwen",
|
||||
asset_name="qwen_alpha.png",
|
||||
@@ -131,8 +158,133 @@ def _glyph_silhouette() -> NDArray[Any] | None:
|
||||
return _text_mark_engine.glyph_silhouette(_CONFIG.asset_name)
|
||||
|
||||
|
||||
class QwenSymbolDetection(TextMarkDetection):
|
||||
"""Explicit marker for the symbol variant's distinct footprint policy."""
|
||||
|
||||
|
||||
class QwenEngine(TextMarkEngine):
|
||||
"""Detect/localize the visible Qwen "千问AI生成" watermark (locate -> mask; mask feeds the fill)."""
|
||||
"""Detect and localize Qwen text and symbol visible marks."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(_CONFIG)
|
||||
template = _text_mark_engine.load_alpha_template(_SYMBOL_ASSET)
|
||||
if template is None:
|
||||
raise RuntimeError(f"failed to decode embedded asset: {_SYMBOL_ASSET}")
|
||||
self._symbol_template = template
|
||||
self._symbol_template_scales: dict[int, NDArray[Any]] = {}
|
||||
|
||||
@staticmethod
|
||||
def _symbol_shape_iou(response_crop: NDArray[Any], template: NDArray[Any]) -> float:
|
||||
"""Overlap of the bright residual with the three-lobe silhouette."""
|
||||
if response_crop.size == 0 or template.size == 0:
|
||||
return 0.0
|
||||
template_peak = float(template.max())
|
||||
response_peak = float(response_crop.max())
|
||||
if template_peak <= 0.0 or response_peak <= 0.0:
|
||||
return 0.0
|
||||
template_mask = template > (template_peak * _SYMBOL_TEMPLATE_MASK_FRAC)
|
||||
response_mask = response_crop > (response_peak * _SYMBOL_RESPONSE_MASK_FRAC)
|
||||
union = int(np.logical_or(template_mask, response_mask).sum())
|
||||
if union == 0:
|
||||
return 0.0
|
||||
intersection = int(np.logical_and(template_mask, response_mask).sum())
|
||||
return intersection / union
|
||||
|
||||
def _scaled_symbol_template(self, side: int) -> NDArray[Any]:
|
||||
"""Return a cached square symbol template for a measured ladder size."""
|
||||
template = self._symbol_template_scales.get(side)
|
||||
if template is None:
|
||||
if len(self._symbol_template_scales) >= len(_SYMBOL_LADDER) * 4:
|
||||
self._symbol_template_scales.clear()
|
||||
template = cv2.resize(self._symbol_template, (side, side), interpolation=cv2.INTER_AREA)
|
||||
self._symbol_template_scales[side] = template
|
||||
return template
|
||||
|
||||
def _symbol_scan(self, image: NDArray[Any] | None) -> QwenSymbolDetection:
|
||||
"""Locate the strongest symbol-shaped candidate in the lower-right search box."""
|
||||
det = QwenSymbolDetection()
|
||||
if image is None or image.size == 0:
|
||||
return det
|
||||
height, width = image.shape[:2]
|
||||
base = min(height, width)
|
||||
if base < _SYMBOL_MIN_SHORT_SIDE:
|
||||
return det
|
||||
search_side = max(80, int(base * _SYMBOL_SEARCH_FRAC))
|
||||
margin = int(base * _SYMBOL_MARGIN_FRAC)
|
||||
origin_x = max(0, width - margin - search_side)
|
||||
origin_y = max(0, height - margin - search_side)
|
||||
roi = image_io.to_bgr(image[origin_y : origin_y + search_side, origin_x : origin_x + search_side])
|
||||
luma: NDArray[Any] = roi.mean(axis=2, dtype=np.float32)
|
||||
background: NDArray[Any] = cv2.GaussianBlur(luma, (0, 0), sigmaX=max(4.0, search_side * 0.20))
|
||||
response: NDArray[Any] = np.clip(luma - background, 0.0, None)
|
||||
|
||||
best_score = 0.0
|
||||
best_region = (0, 0, 0, 0)
|
||||
best_iou = 0.0
|
||||
for rung in _SYMBOL_LADDER:
|
||||
side = max(12, int(base * _SYMBOL_SIZE_FRAC * rung))
|
||||
if side >= search_side:
|
||||
continue
|
||||
template = self._scaled_symbol_template(side)
|
||||
scores = cv2.matchTemplate(response, template, cv2.TM_CCOEFF_NORMED)
|
||||
_minimum, score, _min_location, location = cv2.minMaxLoc(scores)
|
||||
ncc = float(score)
|
||||
if ncc <= best_score:
|
||||
continue
|
||||
crop = response[location[1] : location[1] + side, location[0] : location[0] + side]
|
||||
best_score = ncc
|
||||
best_region = (origin_x + location[0], origin_y + location[1], side, side)
|
||||
best_iou = self._symbol_shape_iou(crop, template)
|
||||
det.confidence = best_score
|
||||
det.detected = best_score >= _SYMBOL_NCC_THRESHOLD and best_iou >= _SYMBOL_SHAPE_IOU_THRESHOLD
|
||||
det.region = best_region
|
||||
return det
|
||||
|
||||
def detect(self, image: NDArray[Any], *, provenance: bool = False) -> TextMarkDetection:
|
||||
"""Return the stronger text or symbol variant."""
|
||||
text = super().detect(image, provenance=provenance)
|
||||
symbol = self._symbol_scan(image)
|
||||
symbol.provenance = provenance
|
||||
return _text_mark_engine.best_detection(text, symbol)
|
||||
|
||||
def detect_both(self, image: NDArray[Any] | None) -> tuple[TextMarkDetection, TextMarkDetection]:
|
||||
"""Return both trust verdicts from one text scan and one symbol scan."""
|
||||
strict_text, relaxed_text = super().detect_both(image)
|
||||
strict_symbol = self._symbol_scan(image)
|
||||
relaxed_symbol = replace(strict_symbol, provenance=True)
|
||||
return _text_mark_engine.best_detection(strict_text, strict_symbol), _text_mark_engine.best_detection(
|
||||
relaxed_text, relaxed_symbol
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_symbol_detection(detection: TextMarkDetection | None) -> bool:
|
||||
return isinstance(detection, QwenSymbolDetection) and detection.detected
|
||||
|
||||
def footprint_mask(
|
||||
self,
|
||||
image: NDArray[Any] | None,
|
||||
*,
|
||||
force: bool = False,
|
||||
dilate: int | None = None,
|
||||
detection: TextMarkDetection | None = None,
|
||||
) -> NDArray[Any] | None:
|
||||
"""Mask the detected symbol box or delegate to the text-mark footprint."""
|
||||
if image is None or image.size == 0:
|
||||
return None
|
||||
resolved = detection if detection is not None else self.detect(image)
|
||||
if self._is_symbol_detection(resolved):
|
||||
x, y, width, height = resolved.region
|
||||
alpha = cv2.resize(self._symbol_template, (width, height), interpolation=cv2.INTER_LINEAR)
|
||||
radius = dilate if dilate is not None else max(2, int(max(width, height) * _SYMBOL_MASK_DILATE_FRAC))
|
||||
local = np.pad((alpha > _SYMBOL_MASK_ALPHA).astype(np.uint8) * 255, radius)
|
||||
if radius > 0:
|
||||
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * radius + 1, 2 * radius + 1))
|
||||
local = cv2.dilate(local, kernel)
|
||||
mask = np.zeros(image.shape[:2], dtype=np.uint8)
|
||||
target_x, target_y = x - radius, y - radius
|
||||
x1, y1 = max(0, target_x), max(0, target_y)
|
||||
x2, y2 = min(image.shape[1], target_x + local.shape[1]), min(image.shape[0], target_y + local.shape[0])
|
||||
local_x, local_y = x1 - target_x, y1 - target_y
|
||||
mask[y1:y2, x1:x2] = local[local_y : local_y + y2 - y1, local_x : local_x + x2 - x1]
|
||||
return mask
|
||||
return super().footprint_mask(image, force=force, dilate=dilate, detection=resolved)
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Abstaining source/export-pipeline classification from image pixels.
|
||||
|
||||
This module does not detect or decode SynthID. It labels original-export-looking
|
||||
pixel pipelines as OpenAI, Google, or unknown after metadata may have been
|
||||
removed. Call it explicitly; ``identify`` never imports or runs it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from hashlib import sha256
|
||||
from importlib import import_module
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from numpy.typing import NDArray
|
||||
|
||||
SOURCE_CLASSIFY_EXTRA = "'remove-ai-watermarks[source-classify]'"
|
||||
WEIGHTS_ENV = "RAIW_SOURCE_CLASSIFY_WEIGHTS"
|
||||
WEIGHTS_REPO = "wiltodelta/raiw-source-classify"
|
||||
WEIGHTS_REVISION = "4b8b408d03dac20231d46217977fd164414b6b9e"
|
||||
MODEL_FILE = "source-pipeline-mlp.npz"
|
||||
MODEL_SHA256 = "6430b0738869ff07b6845b7c6149c2a83bc3f3785878082886c241c6f7ca0c4a"
|
||||
CLASSES = ("openai", "google", "unknown")
|
||||
PROVIDERS = CLASSES[:2]
|
||||
FEATURE_WIDTH = 892
|
||||
SPECTRAL_GRID = 16
|
||||
CLAIM = "source/export pipeline classification; not SynthID detection"
|
||||
# NumPy and the training runtime can differ by a few float32 ULPs at a matrix
|
||||
# boundary. Treat values within this tolerance as the stored threshold so the
|
||||
# exported runtime preserves the training runtime's >= decision.
|
||||
THRESHOLD_TOLERANCE = 1e-5
|
||||
|
||||
SourceLabel = Literal["openai", "google", "unknown"]
|
||||
SourceReason = Literal["classified", "abstained", "feature_unavailable"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceClassification:
|
||||
"""One narrow source-pipeline result with an explicit abstention."""
|
||||
|
||||
label: SourceLabel
|
||||
reason: SourceReason
|
||||
scores: dict[str, float]
|
||||
margins: dict[str, float]
|
||||
thresholds: dict[str, float]
|
||||
claim: str = CLAIM
|
||||
model_revision: str = WEIGHTS_REVISION
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"label": self.label,
|
||||
"reason": self.reason,
|
||||
"scores": self.scores,
|
||||
"margins": self.margins,
|
||||
"thresholds": self.thresholds,
|
||||
"claim": self.claim,
|
||||
"model_revision": self.model_revision,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Model:
|
||||
mean: NDArray[Any]
|
||||
scale: NDArray[Any]
|
||||
margins: NDArray[Any]
|
||||
layer0_weight: NDArray[Any]
|
||||
layer0_bias: NDArray[Any]
|
||||
layer1_weight: NDArray[Any]
|
||||
layer1_bias: NDArray[Any]
|
||||
output_weight: NDArray[Any]
|
||||
output_bias: NDArray[Any]
|
||||
|
||||
|
||||
def is_available() -> bool:
|
||||
"""Return whether the lightweight source-classify runtime is installed."""
|
||||
from remove_ai_watermarks.optional_deps import module_available
|
||||
|
||||
return module_available("numpy", "cv2", "huggingface_hub")
|
||||
|
||||
|
||||
def _model_path() -> Path:
|
||||
override = os.environ.get(WEIGHTS_ENV, "").strip()
|
||||
if override:
|
||||
path = Path(override).expanduser()
|
||||
candidate = path / MODEL_FILE if path.is_dir() else path
|
||||
if not candidate.is_file():
|
||||
raise RuntimeError(f"{WEIGHTS_ENV} does not contain {MODEL_FILE}: {path}")
|
||||
return candidate
|
||||
try:
|
||||
hub: Any = import_module("huggingface_hub")
|
||||
except ModuleNotFoundError as error:
|
||||
raise RuntimeError(f"Install {SOURCE_CLASSIFY_EXTRA} to download the source classifier") from error
|
||||
return Path(
|
||||
hub.hf_hub_download(
|
||||
repo_id=WEIGHTS_REPO,
|
||||
filename=MODEL_FILE,
|
||||
revision=WEIGHTS_REVISION,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=2)
|
||||
def _load_model(path: Path, expected_sha256: str | None = MODEL_SHA256) -> _Model:
|
||||
import numpy as np
|
||||
|
||||
if expected_sha256 is not None:
|
||||
digest = sha256(path.read_bytes()).hexdigest()
|
||||
if digest != expected_sha256:
|
||||
raise ValueError(f"model SHA-256 mismatch: expected {expected_sha256}, got {digest}")
|
||||
required = {
|
||||
"classes",
|
||||
"kind",
|
||||
"mean",
|
||||
"scale",
|
||||
"margins",
|
||||
"layer0_weight",
|
||||
"layer0_bias",
|
||||
"layer1_weight",
|
||||
"layer1_bias",
|
||||
"output_weight",
|
||||
"output_bias",
|
||||
"activation",
|
||||
}
|
||||
with np.load(path, allow_pickle=False) as payload:
|
||||
if set(payload.files) != required:
|
||||
raise ValueError(f"model fields must be exactly {sorted(required)}")
|
||||
if tuple(str(value) for value in payload["classes"]) != CLASSES:
|
||||
raise ValueError(f"model classes must be {CLASSES}")
|
||||
if str(payload["kind"]) != "fused":
|
||||
raise ValueError("model kind must be fused")
|
||||
if str(payload["activation"]) != "gelu_exact":
|
||||
raise ValueError("model activation must be gelu_exact")
|
||||
arrays = {
|
||||
name: np.asarray(
|
||||
payload[name],
|
||||
dtype=np.float64 if name in {"mean", "scale", "margins"} else np.float32,
|
||||
)
|
||||
for name in required - {"classes", "kind", "activation"}
|
||||
}
|
||||
shapes = {
|
||||
"mean": (FEATURE_WIDTH,),
|
||||
"scale": (FEATURE_WIDTH,),
|
||||
"margins": (len(PROVIDERS),),
|
||||
"layer0_weight": (128, FEATURE_WIDTH),
|
||||
"layer0_bias": (128,),
|
||||
"layer1_weight": (64, 128),
|
||||
"layer1_bias": (64,),
|
||||
"output_weight": (len(CLASSES), 64),
|
||||
"output_bias": (len(CLASSES),),
|
||||
}
|
||||
for name, expected in shapes.items():
|
||||
if arrays[name].shape != expected:
|
||||
raise ValueError(f"model field {name} must have shape {expected}")
|
||||
if not np.all(np.isfinite(arrays[name])):
|
||||
raise ValueError(f"model field {name} must be finite")
|
||||
if np.any(arrays["scale"] <= 0) or np.any(arrays["margins"] < 0):
|
||||
raise ValueError("model scales must be positive and margins non-negative")
|
||||
return _Model(**arrays)
|
||||
|
||||
|
||||
def _gelu_exact(values: NDArray[Any]) -> NDArray[Any]:
|
||||
import numpy as np
|
||||
|
||||
flat = values.ravel()
|
||||
errors = np.fromiter((math.erf(float(value) / math.sqrt(2.0)) for value in flat), dtype=np.float32)
|
||||
return (0.5 * flat * (1.0 + errors)).reshape(values.shape).astype(np.float32)
|
||||
|
||||
|
||||
def _scores(features: NDArray[Any], model: _Model) -> NDArray[Any]:
|
||||
import numpy as np
|
||||
|
||||
values = np.asarray((features - model.mean) / model.scale, dtype=np.float32)
|
||||
hidden0 = _gelu_exact(model.layer0_weight @ values + model.layer0_bias)
|
||||
hidden1 = _gelu_exact(model.layer1_weight @ hidden0 + model.layer1_bias)
|
||||
return np.asarray(model.output_weight @ hidden1 + model.output_bias, dtype=np.float64)
|
||||
|
||||
|
||||
def _result(raw_scores: NDArray[Any], thresholds: NDArray[Any]) -> SourceClassification:
|
||||
import numpy as np
|
||||
|
||||
margins = np.empty(len(PROVIDERS), dtype=np.float64)
|
||||
for index in range(len(PROVIDERS)):
|
||||
competitors = np.delete(raw_scores, index)
|
||||
margins[index] = raw_scores[index] - float(np.max(competitors))
|
||||
excess = margins - thresholds
|
||||
best = int(np.argmax(excess))
|
||||
accepted = bool(excess[best] >= -THRESHOLD_TOLERANCE)
|
||||
label: SourceLabel = PROVIDERS[best] if accepted else "unknown"
|
||||
return SourceClassification(
|
||||
label=label,
|
||||
reason="classified" if accepted else "abstained",
|
||||
scores={name: float(raw_scores[index]) for index, name in enumerate(CLASSES)},
|
||||
margins={name: float(margins[index]) for index, name in enumerate(PROVIDERS)},
|
||||
thresholds={name: float(thresholds[index]) for index, name in enumerate(PROVIDERS)},
|
||||
)
|
||||
|
||||
|
||||
def _unavailable(model: _Model) -> SourceClassification:
|
||||
return SourceClassification(
|
||||
label="unknown",
|
||||
reason="feature_unavailable",
|
||||
scores={},
|
||||
margins={},
|
||||
thresholds={name: float(model.margins[index]) for index, name in enumerate(PROVIDERS)},
|
||||
)
|
||||
|
||||
|
||||
def classify_source(source: str | os.PathLike[str]) -> SourceClassification:
|
||||
"""Classify an original-looking image export, abstaining as ``unknown``.
|
||||
|
||||
The result describes a complete source/export pipeline. It is not evidence
|
||||
that SynthID is present or absent.
|
||||
"""
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from remove_ai_watermarks import image_io
|
||||
from remove_ai_watermarks._internal.forensic_124d import image_features
|
||||
from remove_ai_watermarks._internal.source_spectral import feature_from_image, make_geometry
|
||||
|
||||
model = _load_model(_model_path(), expected_sha256=MODEL_SHA256)
|
||||
image_io._register_heif() # pyright: ignore[reportPrivateUsage]
|
||||
with Image.open(source) as opened:
|
||||
image = opened.convert("RGB")
|
||||
pixels = np.asarray(image, dtype=np.uint8)
|
||||
forensic = image_features(pixels)
|
||||
if forensic is None:
|
||||
return _unavailable(model)
|
||||
spectral = feature_from_image(image, make_geometry(grid=SPECTRAL_GRID))
|
||||
features = np.concatenate((forensic, spectral))
|
||||
if features.shape != (FEATURE_WIDTH,) or not np.all(np.isfinite(features)):
|
||||
return _unavailable(model)
|
||||
return _result(_scores(features, model), model.margins)
|
||||
@@ -21,7 +21,7 @@ Entries:
|
||||
- ``gemini`` -- Google Gemini / Nano Banana visible watermark (sparkle), bottom-right.
|
||||
- ``doubao`` -- ByteDance Doubao "豆包AI生成" text strip, bottom-right.
|
||||
- ``jimeng`` -- ByteDance Jimeng / Dreamina "★ 即梦AI" wordmark, bottom-right.
|
||||
- ``qwen`` -- Alibaba Cloud Qwen "千问AI生成" text strip, bottom-right.
|
||||
- ``qwen`` -- Alibaba Cloud Qwen "千问AI生成" text or three-lobe symbol, bottom-right.
|
||||
- ``kling`` -- Kuaishou Kling AI "可灵AI 3.0" / "KlingAI 3.0" strip, bottom-right.
|
||||
- ``yuanbao`` -- Tencent Yuanbao "元宝 / AI生成" two-line mark, bottom-right.
|
||||
- ``samsung`` -- Samsung Galaxy AI "Contenuti generati dall'AI" strip, bottom-left.
|
||||
@@ -265,9 +265,12 @@ class KnownMark:
|
||||
# REQUIRED, deliberately: a defaulted empty product would let two rows that forgot
|
||||
# the field corroborate each other and silently bypass the trust gate.
|
||||
product: str
|
||||
# The company responsible for the product. This is deliberately separate from the
|
||||
# labeling standard: Alibaba, ByteDance, Kuaishou, Tencent, and other manufacturers
|
||||
# all use TC260, but that does not make their marks one product family.
|
||||
manufacturer: str
|
||||
# Which provenance regime this mark's vendor labels under, or None. "tc260" means
|
||||
# the vendor stamps the China AIGC label, so a confident detection of it names a
|
||||
# DIFFERENT TC260 product than the Jimeng pill -- see _keep_pill.
|
||||
# the vendor stamps the China AIGC label; it does not identify the manufacturer.
|
||||
label_regime: str | None
|
||||
# The sentence `identify` reports when THIS mark is the strongest evidence. None for
|
||||
# a mark that never names a platform on its own: `gemini` has its own higher-
|
||||
@@ -287,8 +290,7 @@ class KnownMark:
|
||||
# Social Credit Codes as normalized by ``metadata.uscc_of``, plus the bare product
|
||||
# names a few generators write instead. Here rather than in a separate table
|
||||
# because a newly registered TC260 mark whose codes were forgotten fails SILENTLY:
|
||||
# it falls back to relaxing ByteDance's pair on an image carrying the new mark,
|
||||
# the exact false positive the producer code exists to prevent.
|
||||
# its own detector remains strict even on an image carrying that producer's label.
|
||||
tc260_producer_codes: tuple[str, ...] = ()
|
||||
# Optional single-pass dual verdict for the arbiter's perception stage (see
|
||||
# `detect_both`). None = fall back to two `_detect` calls.
|
||||
@@ -598,6 +600,7 @@ def _text_mark(
|
||||
location: str,
|
||||
*,
|
||||
platform: str,
|
||||
manufacturer: str,
|
||||
product: str | None = None,
|
||||
label_regime: str | None = "tc260",
|
||||
provenance_signals: tuple[str, ...] = ("aigc",),
|
||||
@@ -607,9 +610,10 @@ def _text_mark(
|
||||
"""Build a text-mark registry row from its shared detector and mask adapters.
|
||||
|
||||
``product`` defaults to the key (one mark, one product); pass it only when two
|
||||
marks share a product. ``label_regime`` and ``provenance_signals`` default to the
|
||||
China-AIGC label because every text mark registered so far except Samsung and
|
||||
Microsoft uses it.
|
||||
marks share a product. ``manufacturer`` is separate because unrelated companies
|
||||
use the same TC260 standard. ``label_regime`` and ``provenance_signals`` default
|
||||
to the China-AIGC label because every text mark registered so far except Samsung
|
||||
and Microsoft uses it.
|
||||
"""
|
||||
return KnownMark(
|
||||
key,
|
||||
@@ -617,6 +621,7 @@ def _text_mark(
|
||||
location,
|
||||
True,
|
||||
product or key,
|
||||
manufacturer,
|
||||
label_regime,
|
||||
platform,
|
||||
_engine_mark_detect(key, label, location),
|
||||
@@ -660,14 +665,14 @@ def _pill_features(image: NDArray[Any]) -> dict[str, float]:
|
||||
|
||||
|
||||
_REGISTRY: tuple[KnownMark, ...] = (
|
||||
# Gemini is a Google C2PA/SynthID product, not a China-AIGC labeler: label_regime
|
||||
# is None so it can never act as a TC260 sibling in _keep_pill.
|
||||
# Gemini is a Google C2PA/SynthID product, not a China-AIGC labeler.
|
||||
KnownMark(
|
||||
"gemini",
|
||||
"Google Gemini visible watermark (sparkle)",
|
||||
"bottom-right",
|
||||
True,
|
||||
"gemini",
|
||||
"google",
|
||||
None,
|
||||
# No platform sentence: the sparkle has its own higher-confidence
|
||||
# `_visible_sparkle` path in identify, which names the platform itself.
|
||||
@@ -682,6 +687,7 @@ _REGISTRY: tuple[KnownMark, ...] = (
|
||||
"Doubao 豆包AI生成 text",
|
||||
"bottom-right",
|
||||
platform="ByteDance Doubao (visible 豆包AI生成 mark detected)",
|
||||
manufacturer="bytedance",
|
||||
tc260_producer_codes=("91110102MACQD9K640", "doubao"),
|
||||
),
|
||||
_text_mark(
|
||||
@@ -689,13 +695,15 @@ _REGISTRY: tuple[KnownMark, ...] = (
|
||||
"Jimeng 即梦AI wordmark",
|
||||
"bottom-right",
|
||||
platform="ByteDance Jimeng / Dreamina (visible 即梦AI mark detected)",
|
||||
manufacturer="bytedance",
|
||||
tc260_producer_codes=("9144030008867405X2",),
|
||||
),
|
||||
_text_mark(
|
||||
"qwen",
|
||||
"Qwen 千问AI生成 text",
|
||||
"Qwen 千问AI生成 text or three-lobe symbol",
|
||||
"bottom-right",
|
||||
platform="Alibaba Cloud Qwen (visible 千问AI生成 mark detected)",
|
||||
platform="Alibaba Cloud Qwen (visible text or symbol mark detected)",
|
||||
manufacturer="alibaba",
|
||||
tc260_producer_codes=("91440101MA9Y9T4H7A",),
|
||||
),
|
||||
_text_mark(
|
||||
@@ -703,6 +711,7 @@ _REGISTRY: tuple[KnownMark, ...] = (
|
||||
"Kling AI 可灵AI / KlingAI 3.0 text",
|
||||
"bottom-right",
|
||||
platform="Kuaishou Kling AI (visible 可灵AI / KlingAI 3.0 mark detected)",
|
||||
manufacturer="kuaishou",
|
||||
tc260_producer_codes=("91110108335469089C",),
|
||||
),
|
||||
_text_mark(
|
||||
@@ -710,6 +719,7 @@ _REGISTRY: tuple[KnownMark, ...] = (
|
||||
"Tencent Yuanbao 元宝 / AI生成 mark",
|
||||
"bottom-right",
|
||||
platform="Tencent Yuanbao (visible 元宝 / AI生成 mark detected)",
|
||||
manufacturer="tencent",
|
||||
tc260_producer_codes=("91440300708461136T",),
|
||||
),
|
||||
# Samsung Galaxy AI is a device editing marker (samsung_genai), not a TC260 label.
|
||||
@@ -717,6 +727,7 @@ _REGISTRY: tuple[KnownMark, ...] = (
|
||||
"samsung",
|
||||
"Samsung Galaxy AI text",
|
||||
"bottom-left",
|
||||
manufacturer="samsung",
|
||||
label_regime=None,
|
||||
provenance_signals=("samsung_genai",),
|
||||
platform="Samsung Galaxy AI (visible 'Contenuti generati dall'AI' mark detected)",
|
||||
@@ -726,6 +737,7 @@ _REGISTRY: tuple[KnownMark, ...] = (
|
||||
"RunningHub AI生成 text",
|
||||
"top-left",
|
||||
platform="RunningHub (visible RunningHub AI生成 mark detected)",
|
||||
manufacturer="runninghub",
|
||||
tc260_producer_codes=("91340100MAEB4N8H76", "RunningHub"),
|
||||
),
|
||||
_text_mark(
|
||||
@@ -733,6 +745,7 @@ _REGISTRY: tuple[KnownMark, ...] = (
|
||||
"Baidu 百度 AI生成 text",
|
||||
"bottom-right",
|
||||
platform="Baidu (visible 百度 AI生成 mark detected)",
|
||||
manufacturer="baidu",
|
||||
tc260_producer_codes=("91110000802100433B",),
|
||||
),
|
||||
_text_mark(
|
||||
@@ -740,6 +753,7 @@ _REGISTRY: tuple[KnownMark, ...] = (
|
||||
"LiblibAI wordmark",
|
||||
"bottom-center",
|
||||
platform="LiblibAI (visible LiblibAI mark detected)",
|
||||
manufacturer="liblib",
|
||||
tc260_producer_codes=("91110105MACJ6K1C8A",),
|
||||
),
|
||||
KnownMark(
|
||||
@@ -748,6 +762,7 @@ _REGISTRY: tuple[KnownMark, ...] = (
|
||||
"top-left",
|
||||
True,
|
||||
"liblib",
|
||||
"liblib",
|
||||
"tc260",
|
||||
# The compact pill is generic on its own; the bottom wordmark or LiblibAI
|
||||
# metadata is what makes the relaxed verdict actionable.
|
||||
@@ -765,6 +780,7 @@ _REGISTRY: tuple[KnownMark, ...] = (
|
||||
"microsoft",
|
||||
"Microsoft top-right AI badge",
|
||||
"top-right",
|
||||
manufacturer="microsoft",
|
||||
label_regime=None,
|
||||
provenance_signals=(),
|
||||
platform="Microsoft (visible top-right AI badge detected)",
|
||||
@@ -777,6 +793,7 @@ _REGISTRY: tuple[KnownMark, ...] = (
|
||||
"top-left",
|
||||
True,
|
||||
"jimeng",
|
||||
"bytedance",
|
||||
"tc260",
|
||||
# The capture-less pill is too weak a detector to attribute a platform on its
|
||||
# own; the Jimeng wordmark is what names ByteDance.
|
||||
@@ -868,28 +885,15 @@ def resolve_trust(
|
||||
return "confirmed" if confirmed else "strict"
|
||||
|
||||
|
||||
def tc260_producer_vendors() -> dict[str, str]:
|
||||
"""TC260 ``ContentProducer`` identity -> the mark key whose vendor signs with it.
|
||||
|
||||
Derived from the registry rows, so registering a TC260 mark and its producer codes
|
||||
is one edit. A mark registered without codes falls through to
|
||||
:data:`TC260_FALLBACK_VENDORS`, which relaxes ByteDance's pair -- a silent wrong
|
||||
answer on an image carrying the new mark, which is why the codes belong on the row
|
||||
next to ``label_regime`` rather than in a table someone must remember to update.
|
||||
"""
|
||||
return {code: mark.key for mark in _REGISTRY for code in mark.tc260_producer_codes}
|
||||
def tc260_producer_mark(code: str) -> KnownMark | None:
|
||||
"""Return the registry row for one TC260 ``ContentProducer`` identity."""
|
||||
return next((mark for mark in _REGISTRY if code in mark.tc260_producer_codes), None)
|
||||
|
||||
|
||||
def _pill_suppressors() -> set[str]:
|
||||
"""Marks whose detection vetoes the capture-less pill: same label regime as the
|
||||
pill, different product. Derived so a newly registered TC260 mark cannot be
|
||||
forgotten here -- which is exactly how LiblibAI ended up missing."""
|
||||
"""Other products from the pill's manufacturer whose detection vetoes it."""
|
||||
pill = get_mark("jimeng_pill")
|
||||
return {
|
||||
m.key
|
||||
for m in _REGISTRY
|
||||
if m.label_regime is not None and m.label_regime == pill.label_regime and m.product != pill.product
|
||||
}
|
||||
return {m.key for m in _REGISTRY if m.manufacturer == pill.manufacturer and m.product != pill.product}
|
||||
|
||||
|
||||
def _keep_pill(keys: set[str], *, provenance: frozenset[str], footprint_flat: bool) -> bool:
|
||||
@@ -905,18 +909,14 @@ def _keep_pill(keys: set[str], *, provenance: frozenset[str], footprint_flat: bo
|
||||
top-left footprint is flat enough for an invisible fill (``footprint_flat``),
|
||||
so real flat-scene pills (and harmless flat false fires) are cleaned while the
|
||||
damaging textured false fires are left untouched.
|
||||
A Doubao image is TC260 too but is not Jimeng-basic, so the pill never rides on a
|
||||
Doubao detection; every other TC260 product's mark likewise names a different
|
||||
product and suppresses the pill.
|
||||
A Doubao image is TC260 too but is not Jimeng-basic, so its mark suppresses the
|
||||
pill. Marks from other TC260 manufacturers are unrelated and do not participate in
|
||||
this ByteDance-family decision.
|
||||
No confirmation at all -> never remove (blocks false fires on non-Jimeng content).
|
||||
|
||||
The suppressor set is DERIVED from the registry (same label regime, different
|
||||
product), not hand-listed. The hand-written list had drifted: LiblibAI was
|
||||
registered alongside RunningHub and Baidu but never added to it, so a confident
|
||||
LiblibAI detection did not veto the pill the way its two siblings did. Marks
|
||||
outside the TC260 regime (Gemini, Samsung) are deliberately NOT suppressors --
|
||||
neither can put ``"jimeng"`` into ``provenance``, so neither can enable the arm
|
||||
they would be vetoing."""
|
||||
The suppressor set is derived from the manufacturer and product fields rather than
|
||||
from the shared TC260 standard. Marks from Google, Samsung, or another TC260
|
||||
manufacturer cannot enable or veto this ByteDance-specific arm."""
|
||||
if _pill_suppressors() & keys:
|
||||
return False
|
||||
if "jimeng" in keys:
|
||||
|
||||
@@ -86,6 +86,19 @@ def test_skill_stays_under_the_500_line_budget() -> None:
|
||||
assert lines <= 500, f"SKILL.md is {lines} lines; move detail into references/"
|
||||
|
||||
|
||||
def test_skill_keeps_concrete_input_output_examples() -> None:
|
||||
text = SKILL.read_text(encoding="utf-8")
|
||||
examples = text.split("## Examples\n", maxsplit=1)[1].split("\n## ", maxsplit=1)[0]
|
||||
cases = re.split(r"(?m)^### ", examples)[1:]
|
||||
|
||||
assert len(cases) == 3
|
||||
for case in cases:
|
||||
assert re.search(r'(?m)^Input: "[^"]+"$', case)
|
||||
assert re.search(r"(?m)^Action:", case)
|
||||
assert re.search(r"(?m)^Output(?: [^:\n]+)?:$", case)
|
||||
assert re.search(r"(?m)^> ", case)
|
||||
|
||||
|
||||
def test_probe_min_cli_version_never_exceeds_the_package_version() -> None:
|
||||
import tomllib
|
||||
|
||||
|
||||
+14
-16
@@ -23,6 +23,10 @@ class TestTopLevelExports:
|
||||
assert raiw.remove_visible_detailed is api.remove_visible_detailed
|
||||
assert raiw.VisibleRemovalResult is api.VisibleRemovalResult
|
||||
assert raiw.visible_provenance is api.visible_provenance
|
||||
from remove_ai_watermarks import source_classify
|
||||
|
||||
assert raiw.classify_source is source_classify.classify_source
|
||||
assert raiw.SourceClassification is source_classify.SourceClassification
|
||||
|
||||
def test_unknown_attribute_raises(self):
|
||||
with pytest.raises(AttributeError):
|
||||
@@ -136,9 +140,9 @@ class TestVisibleProvenance:
|
||||
assert "doubao" in prov
|
||||
assert "jimeng" not in prov
|
||||
|
||||
def test_unmapped_tc260_producer_falls_back_to_the_bytedance_pair(self, monkeypatch, tmp_path):
|
||||
"""An unrecognized producer must not lose the relaxation entirely: the label is
|
||||
still evidence that some China-AIGC vendor made the image."""
|
||||
@pytest.mark.parametrize("producer", ["0011999999999999999999999", ""])
|
||||
def test_tc260_without_a_mapped_producer_does_not_confirm_a_product(self, monkeypatch, tmp_path, producer):
|
||||
"""A shared labeling standard cannot identify a product by itself."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from remove_ai_watermarks import identify, metadata
|
||||
@@ -148,8 +152,8 @@ class TestVisibleProvenance:
|
||||
"identify",
|
||||
lambda *a, **k: SimpleNamespace(platform=None, signals=[SimpleNamespace(name="aigc")]),
|
||||
)
|
||||
monkeypatch.setattr(metadata, "aigc_label", lambda _p: {"ContentProducer": "0011999999999999999999999"})
|
||||
assert raiw.visible_provenance(tmp_path / "x.png") == frozenset({"doubao", "jimeng"})
|
||||
monkeypatch.setattr(metadata, "aigc_label", lambda _p: {"ContentProducer": producer})
|
||||
assert raiw.visible_provenance(tmp_path / "x.png") == frozenset()
|
||||
|
||||
def test_known_tc260_producer_names_a_single_vendor(self, monkeypatch, tmp_path):
|
||||
from types import SimpleNamespace
|
||||
@@ -165,16 +169,10 @@ class TestVisibleProvenance:
|
||||
monkeypatch.setattr(metadata, "aigc_label", lambda _p: {"ContentProducer": "001191440101MA9Y9T4H7A00001"})
|
||||
assert raiw.visible_provenance(tmp_path / "x.png") == frozenset({"qwen"})
|
||||
|
||||
def test_every_mapped_producer_names_a_registered_mark(self):
|
||||
"""The table drives the arbiter's provenance set, so a typo'd key would relax
|
||||
nothing and fail silently. Checked here, not at import: importing the registry
|
||||
from _internal.constants would drag it into every metadata-only path."""
|
||||
from remove_ai_watermarks._internal.constants import TC260_FALLBACK_VENDORS
|
||||
from remove_ai_watermarks.watermark_registry import known_marks, mark_keys, tc260_producer_vendors
|
||||
def test_every_primary_tc260_mark_declares_a_producer_code(self):
|
||||
"""Only companion pills may inherit a primary mark's producer identity."""
|
||||
from remove_ai_watermarks.watermark_registry import known_marks
|
||||
|
||||
keys = set(mark_keys())
|
||||
assert set(tc260_producer_vendors().values()) <= keys
|
||||
assert keys >= TC260_FALLBACK_VENDORS
|
||||
# Primary TC260 marks name their producer. Companion pills inherit the primary
|
||||
# mark's product-level provenance rather than duplicating an external identity.
|
||||
unmapped = {m.key for m in known_marks() if m.label_regime == "tc260" and not m.tc260_producer_codes}
|
||||
@@ -189,7 +187,7 @@ class TestVisibleProvenance:
|
||||
assert raiw.visible_provenance(tmp_path / "missing.png") == frozenset()
|
||||
|
||||
def test_uses_report_signals_for_falsy_metadata_values(self, monkeypatch, tmp_path):
|
||||
"""An empty TC260 object and Samsung genAIType=0 are still present signals.
|
||||
"""An empty TC260 object names no product; Samsung genAIType=0 is present.
|
||||
|
||||
The report has already normalized those values, so the public API must not
|
||||
re-read the file and accidentally discard them by truthiness.
|
||||
@@ -204,7 +202,7 @@ class TestVisibleProvenance:
|
||||
)
|
||||
monkeypatch.setattr(identify, "identify", lambda *args, **kwargs: report)
|
||||
|
||||
assert raiw.visible_provenance(tmp_path / "synthetic.png") == frozenset({"doubao", "jimeng", "samsung"})
|
||||
assert raiw.visible_provenance(tmp_path / "synthetic.png") == frozenset({"samsung"})
|
||||
|
||||
|
||||
class TestRemoveVisibleOutputPath:
|
||||
|
||||
@@ -32,8 +32,8 @@ def _engine(run=None):
|
||||
engine = object.__new__(invisible_engine.InvisibleEngine)
|
||||
engine._progress_callback = None
|
||||
remover = object.__new__(WatermarkRemover)
|
||||
remover.configured_profile = "qwen-zimage"
|
||||
remover.model_profile = "qwen-zimage"
|
||||
remover._auto = False
|
||||
remover._qwen_zimage_pipeline = SimpleNamespace(run=run or (lambda source, **kwargs: source))
|
||||
engine._remover = remover
|
||||
return engine
|
||||
|
||||
@@ -133,12 +133,3 @@ class TestDetectAndMask:
|
||||
assert xs.max() >= bx + bw - 1
|
||||
assert ys.min() <= by
|
||||
assert ys.max() >= by + bh - 1
|
||||
|
||||
|
||||
class TestPillInteraction:
|
||||
def test_confident_baidu_detection_suppresses_the_jimeng_pill(self):
|
||||
# A Baidu image is TC260 too but is not Jimeng-basic: like Doubao/Qwen/
|
||||
# Kling, a confident Baidu detection must veto the pill (``_keep_pill``).
|
||||
from remove_ai_watermarks.watermark_registry import _keep_pill
|
||||
|
||||
assert not _keep_pill({"baidu"}, provenance=frozenset({"jimeng"}), footprint_flat=1.0)
|
||||
+65
-9
@@ -13,6 +13,7 @@ from click.testing import CliRunner
|
||||
from PIL import Image
|
||||
|
||||
from remove_ai_watermarks.classify import (
|
||||
CLIP_ONNX_FILE,
|
||||
MLP_THRESHOLD,
|
||||
PROVIDER_MARGIN,
|
||||
RECEIPT_GATE_FILE,
|
||||
@@ -54,16 +55,14 @@ def _scores(
|
||||
|
||||
def test_hub_snapshot_is_the_freeze_revision() -> None:
|
||||
assert WEIGHTS_REPO == "wiltodelta/raiw-photo-classify"
|
||||
assert WEIGHTS_REVISION == "4c2763766dd1c8c212d64e01e1cc3f166243c47d"
|
||||
assert WEIGHTS_REVISION == "de668f0d8a53412923478cc21382a56890748310"
|
||||
|
||||
|
||||
def test_runtime_requests_the_exported_allow_patterns(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The exported pre-cache list and the runtime request must stay one list.
|
||||
def test_runtime_requests_only_the_selected_backend_model(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Online downloads stay narrow while the exported pre-cache covers both.
|
||||
|
||||
An offline deploy pre-caches WEIGHTS_ALLOW_PATTERNS from the Hub; the
|
||||
runtime then resolves the same snapshot offline. If the runtime's request
|
||||
grows a file the export misses, every offline deployment breaks on a
|
||||
four-fifths-present cache, so pin the seam in both directions.
|
||||
runtime requests a subset of that snapshot for its selected backend.
|
||||
"""
|
||||
from remove_ai_watermarks.classify import _WEIGHT_FILES, _weights_dir
|
||||
|
||||
@@ -81,8 +80,65 @@ def test_runtime_requests_the_exported_allow_patterns(monkeypatch: pytest.Monkey
|
||||
assert _weights_dir() == Path("/cached/snapshot")
|
||||
assert captured[0]["repo_id"] == WEIGHTS_REPO
|
||||
assert captured[0]["revision"] == WEIGHTS_REVISION
|
||||
assert captured[0]["allow_patterns"] == list(WEIGHTS_ALLOW_PATTERNS)
|
||||
torch_patterns = captured[0]["allow_patterns"]
|
||||
assert "clip-l-ft.pt" in torch_patterns
|
||||
assert CLIP_ONNX_FILE not in torch_patterns
|
||||
assert set(_WEIGHT_FILES) <= set(WEIGHTS_ALLOW_PATTERNS)
|
||||
assert CLIP_ONNX_FILE in WEIGHTS_ALLOW_PATTERNS
|
||||
|
||||
captured.clear()
|
||||
assert _weights_dir("onnx") == Path("/cached/snapshot")
|
||||
onnx_patterns = captured[0]["allow_patterns"]
|
||||
assert CLIP_ONNX_FILE in onnx_patterns
|
||||
assert "clip-l-ft.pt" not in onnx_patterns
|
||||
assert set(torch_patterns) | set(onnx_patterns) == set(WEIGHTS_ALLOW_PATTERNS)
|
||||
|
||||
|
||||
def test_backend_requires_its_own_model_file(tmp_path: Path) -> None:
|
||||
from remove_ai_watermarks.classify import DETECTOR_FILE, PROBE_FILE, PROVIDER_FILE, _require_files
|
||||
|
||||
for name in (CLIP_ONNX_FILE, PROBE_FILE, DETECTOR_FILE, PROVIDER_FILE):
|
||||
(tmp_path / name).touch()
|
||||
|
||||
_require_files(tmp_path, backend="onnx")
|
||||
with pytest.raises(RuntimeError, match=r"clip-l-ft\.pt"):
|
||||
_require_files(tmp_path, backend="torch")
|
||||
|
||||
|
||||
def test_onnx_backend_is_cpu_only() -> None:
|
||||
import torch
|
||||
|
||||
from remove_ai_watermarks.classify import _resolve_backend
|
||||
|
||||
assert _resolve_backend("onnx", torch.device("cpu")) == "onnx"
|
||||
with pytest.raises(ValueError, match=r"ONNX.*CPU"):
|
||||
_resolve_backend("onnx", torch.device("cuda"))
|
||||
|
||||
|
||||
def test_onnx_backend_names_its_install_extra(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from remove_ai_watermarks import classify as classify_mod
|
||||
from remove_ai_watermarks import optional_deps
|
||||
|
||||
monkeypatch.setattr(classify_mod, "is_available", lambda: True)
|
||||
monkeypatch.setattr(optional_deps, "module_available", lambda *_names: False)
|
||||
|
||||
with pytest.raises(RuntimeError, match=r"remove-ai-watermarks\[classify-onnx\]"):
|
||||
classify_mod.classify_pixels(Path("unused.png"), backend="onnx")
|
||||
|
||||
|
||||
def test_embed_dispatches_to_the_selected_backend(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from remove_ai_watermarks import classify as classify_mod
|
||||
from remove_ai_watermarks._internal import clip_l_ft
|
||||
|
||||
calls: list[str] = []
|
||||
monkeypatch.setattr(clip_l_ft, "embed_image", lambda *_args: calls.append("torch") or "torch-vector")
|
||||
monkeypatch.setattr(clip_l_ft, "embed_image_onnx", lambda *_args: calls.append("onnx") or "onnx-vector")
|
||||
runtime = SimpleNamespace(backend="onnx", clip_model=object(), processor=object(), device=object())
|
||||
|
||||
assert classify_mod._embed(runtime, object()) == "onnx-vector"
|
||||
runtime.backend = "torch"
|
||||
assert classify_mod._embed(runtime, object()) == "torch-vector"
|
||||
assert calls == ["onnx", "torch"]
|
||||
|
||||
|
||||
def test_shipped_operating_point_matches_the_runtime_defaults() -> None:
|
||||
@@ -332,7 +388,7 @@ def _patch_definitely_runtime(monkeypatch: pytest.MonkeyPatch, *, forensic_calls
|
||||
weights_dir=None,
|
||||
)
|
||||
monkeypatch.setattr("remove_ai_watermarks.classify.is_available", lambda: True)
|
||||
monkeypatch.setattr("remove_ai_watermarks.classify._get_runtime", lambda device: runtime)
|
||||
monkeypatch.setattr("remove_ai_watermarks.classify._get_runtime", lambda device, backend="torch": runtime)
|
||||
monkeypatch.setattr("remove_ai_watermarks.classify._embed", lambda *args, **kwargs: np.full(768, 100.0))
|
||||
monkeypatch.setattr("remove_ai_watermarks.classify._mlp_score", lambda *args, **kwargs: 10.0)
|
||||
monkeypatch.setattr(
|
||||
@@ -402,7 +458,7 @@ def test_124d_is_not_extracted_unless_definitely(monkeypatch: pytest.MonkeyPatch
|
||||
weights_dir=None,
|
||||
)
|
||||
monkeypatch.setattr("remove_ai_watermarks.classify.is_available", lambda: True)
|
||||
monkeypatch.setattr("remove_ai_watermarks.classify._get_runtime", lambda device: runtime)
|
||||
monkeypatch.setattr("remove_ai_watermarks.classify._get_runtime", lambda device, backend="torch": runtime)
|
||||
monkeypatch.setattr("remove_ai_watermarks.classify._embed", lambda *args, **kwargs: np.zeros(768))
|
||||
monkeypatch.setattr("remove_ai_watermarks.classify._mlp_score", lambda *args, **kwargs: 0.0)
|
||||
monkeypatch.setattr(
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""CLIP-L checkpoint loading without redundant parameter initialization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_load_headed_clip_skips_random_initialization_and_materializes_checkpoint(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
torch = pytest.importorskip("torch")
|
||||
transformers = pytest.importorskip("transformers")
|
||||
|
||||
from remove_ai_watermarks._internal import clip_l_ft
|
||||
|
||||
config = transformers.CLIPConfig(
|
||||
projection_dim=2,
|
||||
text_config={
|
||||
"vocab_size": 8,
|
||||
"bos_token_id": 0,
|
||||
"eos_token_id": 1,
|
||||
"pad_token_id": 1,
|
||||
"hidden_size": 4,
|
||||
"intermediate_size": 8,
|
||||
"num_hidden_layers": 1,
|
||||
"num_attention_heads": 1,
|
||||
"max_position_embeddings": 8,
|
||||
},
|
||||
vision_config={
|
||||
"hidden_size": 4,
|
||||
"intermediate_size": 8,
|
||||
"num_hidden_layers": 1,
|
||||
"num_attention_heads": 1,
|
||||
"image_size": 4,
|
||||
"patch_size": 2,
|
||||
"num_channels": 3,
|
||||
},
|
||||
)
|
||||
expected = clip_l_ft.HeadedCLIP(transformers.CLIPModel(config))
|
||||
with torch.no_grad():
|
||||
for index, parameter in enumerate(expected.parameters(), start=1):
|
||||
parameter.fill_(float(index))
|
||||
checkpoint = tmp_path / "tiny-clip.pt"
|
||||
torch.save(expected.state_dict(), checkpoint)
|
||||
|
||||
def reject_random_initialization(*_args: Any, **_kwargs: Any) -> None:
|
||||
raise AssertionError("checkpoint loading must not initialize discarded random weights")
|
||||
|
||||
monkeypatch.setattr(transformers.CLIPConfig, "from_pretrained", lambda _backbone: config)
|
||||
monkeypatch.setattr(torch.nn.init, "kaiming_uniform_", reject_random_initialization)
|
||||
|
||||
loaded = clip_l_ft.load_headed_clip(checkpoint, torch.device("cpu"))
|
||||
|
||||
assert not any(value.is_meta for value in (*loaded.parameters(), *loaded.buffers()))
|
||||
expected_state = expected.state_dict()
|
||||
loaded_state = loaded.state_dict()
|
||||
for name, expected_value in expected_state.items():
|
||||
assert torch.equal(loaded_state[name], expected_value)
|
||||
for (name, buffer), (expected_name, expected_buffer) in zip(
|
||||
loaded.named_buffers(), expected.named_buffers(), strict=True
|
||||
):
|
||||
assert name == expected_name
|
||||
assert torch.equal(buffer, expected_buffer)
|
||||
|
||||
|
||||
def test_load_onnx_vision_uses_the_portable_cpu_provider(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
from remove_ai_watermarks._internal import clip_l_ft
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
class FakeOptions:
|
||||
graph_optimization_level: Any = None
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self, path: str, *, sess_options: FakeOptions, providers: list[str]) -> None:
|
||||
captured.update(path=path, optimization=sess_options.graph_optimization_level, providers=providers)
|
||||
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"onnxruntime",
|
||||
SimpleNamespace(
|
||||
GraphOptimizationLevel=SimpleNamespace(ORT_DISABLE_ALL="disabled"),
|
||||
InferenceSession=FakeSession,
|
||||
SessionOptions=FakeOptions,
|
||||
),
|
||||
)
|
||||
model = tmp_path / "vision.onnx"
|
||||
|
||||
assert isinstance(clip_l_ft.load_onnx_vision(model), FakeSession)
|
||||
assert captured == {
|
||||
"path": str(model),
|
||||
"optimization": "disabled",
|
||||
"providers": ["CPUExecutionProvider"],
|
||||
}
|
||||
|
||||
|
||||
def test_embed_image_onnx_uses_the_freeze_preprocessor() -> None:
|
||||
from remove_ai_watermarks._internal import clip_l_ft
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
class FakeProcessor:
|
||||
def __call__(self, *, images: list[Image.Image], return_tensors: str) -> dict[str, np.ndarray]:
|
||||
captured["image_size"] = images[0].size
|
||||
captured["return_tensors"] = return_tensors
|
||||
return {"pixel_values": np.ones((1, 3, 224, 224), dtype=np.float64)}
|
||||
|
||||
class FakeSession:
|
||||
def run(self, outputs: list[str], inputs: dict[str, np.ndarray]) -> list[np.ndarray]:
|
||||
captured["outputs"] = outputs
|
||||
captured["input_dtype"] = inputs["pixel_values"].dtype
|
||||
return [np.array([[3.0, 4.0]], dtype=np.float32)]
|
||||
|
||||
vector = clip_l_ft.embed_image_onnx(FakeSession(), FakeProcessor(), Image.new("RGB", (40, 20)))
|
||||
|
||||
assert captured == {
|
||||
"image_size": (224, 224),
|
||||
"return_tensors": "np",
|
||||
"outputs": ["embedding"],
|
||||
"input_dtype": np.dtype(np.float32),
|
||||
}
|
||||
assert vector.dtype == np.float64
|
||||
assert vector.tolist() == [3.0, 4.0]
|
||||
@@ -30,14 +30,15 @@ OFFLOAD_SUPPORT_CASES = (
|
||||
(QWEN_ZIMAGE_PROFILE, True),
|
||||
(CHROMA_ZIMAGE_PROFILE, False),
|
||||
(SDXL_ZIMAGE_PROFILE, False),
|
||||
# Unresolved auto appears only in preload, which builds qwen-zimage.
|
||||
# Auto preloads its concrete Qwen fallback before an image vendor is known.
|
||||
(AUTO_PROFILE, True),
|
||||
)
|
||||
|
||||
|
||||
def _remover(profile: str, cpu_offload: bool) -> WatermarkRemover:
|
||||
remover = WatermarkRemover.__new__(WatermarkRemover)
|
||||
remover.model_profile = profile
|
||||
remover.configured_profile = profile
|
||||
remover.model_profile = QWEN_ZIMAGE_PROFILE if profile == AUTO_PROFILE else profile
|
||||
remover.cpu_offload = cpu_offload
|
||||
remover.device = "cuda"
|
||||
remover.torch_dtype = None
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Vision-only photo-classifier export helpers, without the real checkpoint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _exporter() -> Any:
|
||||
path = Path(__file__).resolve().parents[1] / "scripts" / "export_photo_classify_onnx.py"
|
||||
spec = importlib.util.spec_from_file_location("export_photo_classify_onnx", path)
|
||||
assert spec is not None
|
||||
assert spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_vision_embedding_drops_the_text_tower_and_normalizes() -> None:
|
||||
torch = pytest.importorskip("torch")
|
||||
|
||||
class FakeVision(torch.nn.Module):
|
||||
def forward(self, *, pixel_values: Any) -> Any:
|
||||
return SimpleNamespace(pooler_output=pixel_values)
|
||||
|
||||
clip = SimpleNamespace(
|
||||
vision_model=FakeVision(),
|
||||
visual_projection=torch.nn.Identity(),
|
||||
text_model=object(),
|
||||
)
|
||||
model = _exporter().VisionEmbedding(SimpleNamespace(clip=clip))
|
||||
|
||||
output = model(torch.tensor([[3.0, 4.0]]))
|
||||
|
||||
assert set(dict(model.named_children())) == {"vision_model", "visual_projection"}
|
||||
assert torch.equal(output, torch.tensor([[0.6, 0.8]]))
|
||||
+113
-67
@@ -30,6 +30,7 @@ from remove_ai_watermarks.identify import (
|
||||
_attribute_platform,
|
||||
_integrity_clashes,
|
||||
_issuers_in,
|
||||
_tc260_manufacturer_of,
|
||||
_vendor_of,
|
||||
evidence_from_metadata_record,
|
||||
extract_provenance_evidence,
|
||||
@@ -45,6 +46,12 @@ _SPARKLE_TARGET = "remove_ai_watermarks.gemini_engine.detect_sparkle_confidence"
|
||||
SAMPLES_DIR = Path(__file__).resolve().parent.parent / "data" / "fixtures" / "provenance"
|
||||
|
||||
|
||||
def _write_c2pa_jpeg(tmp_path: Path, name: str, blob: bytes) -> Path:
|
||||
path = tmp_path / name
|
||||
path.write_bytes(b"\xff\xd8\xff\xe1jumbc2pa" + blob + b"\xff\xd9")
|
||||
return path
|
||||
|
||||
|
||||
class TestProvenanceEvidence:
|
||||
def test_exact_ai_claim_generator_can_assert_ai_without_source_type(self, tmp_path: Path):
|
||||
path = tmp_path / "firefly.png"
|
||||
@@ -463,6 +470,8 @@ class TestIdentifyNonPng:
|
||||
@pytest.mark.parametrize(
|
||||
("claim_generator", "platform"),
|
||||
[
|
||||
("Grok Imagine", "xAI Grok Imagine"),
|
||||
("Suno", "Suno"),
|
||||
("Higgsfield AI", "Higgsfield AI"),
|
||||
("recraft.ai", "Recraft"),
|
||||
("Topaz Labs Image API", "Topaz Labs"),
|
||||
@@ -643,13 +652,8 @@ class TestIdentifySamsungGalaxy:
|
||||
so the signer attribution must NOT trip the camera-vs-AI integrity clash.
|
||||
"""
|
||||
|
||||
def _jpeg(self, tmp_path: Path, name: str, blob: bytes) -> Path:
|
||||
path = tmp_path / name
|
||||
path.write_bytes(b"\xff\xd8\xff\xe1jumbc2pa" + blob + b"\xff\xd9")
|
||||
return path
|
||||
|
||||
def test_galaxy_trained_source_is_unverified_ai(self, tmp_path: Path):
|
||||
path = self._jpeg(tmp_path, "s25.jpg", b"Samsung Galaxy Galaxy S25 c2pa-rs trainedAlgorithmicMedia")
|
||||
path = _write_c2pa_jpeg(tmp_path, "s25.jpg", b"Samsung Galaxy Galaxy S25 c2pa-rs trainedAlgorithmicMedia")
|
||||
r = identify(path, check_visible=False, check_invisible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.confidence == "medium"
|
||||
@@ -661,7 +665,7 @@ class TestIdentifySamsungGalaxy:
|
||||
def test_galaxy_genai_only_is_medium_ai(self, tmp_path: Path):
|
||||
# The Galaxy S24 case: no trainedAlgorithmicMedia, genAIType is the only
|
||||
# AI marker -- previously missed, now a medium-confidence verdict.
|
||||
path = self._jpeg(
|
||||
path = _write_c2pa_jpeg(
|
||||
tmp_path, "s24.jpg", b'Samsung Galaxy Galaxy S24 c2pa-rs PhotoEditor_Re_Edit_Data{"genAIType":1}'
|
||||
)
|
||||
r = identify(path, check_visible=False, check_invisible=False)
|
||||
@@ -674,7 +678,7 @@ class TestIdentifySamsungGalaxy:
|
||||
def test_asus_gallery_signer_not_ai(self, tmp_path: Path):
|
||||
# ASUS Gallery signs edited photos; no AI source-type or genAIType, so the
|
||||
# platform is attributed but the verdict stays unknown.
|
||||
path = self._jpeg(tmp_path, "asus.jpg", b"/com.asus.gallery/3.8.0.98 c2pa-rs no ai marker")
|
||||
path = _write_c2pa_jpeg(tmp_path, "asus.jpg", b"/com.asus.gallery/3.8.0.98 c2pa-rs no ai marker")
|
||||
r = identify(path, check_visible=False, check_invisible=False)
|
||||
assert r.is_ai_generated is None
|
||||
assert r.platform == "ASUS Gallery (C2PA signer)"
|
||||
@@ -684,13 +688,88 @@ class TestIdentifySamsungGalaxy:
|
||||
# A genuine Galaxy phone capture carries Samsung Galaxy C2PA provenance but
|
||||
# NO AI source-type / genAIType. It must stay is_ai=None -- the device cert
|
||||
# is authenticity provenance of a real photo, not an AI-generation signal.
|
||||
path = self._jpeg(tmp_path, "s25_capture.jpg", b"Samsung Galaxy Galaxy S25 c2pa-rs no ai marker")
|
||||
path = _write_c2pa_jpeg(tmp_path, "s25_capture.jpg", b"Samsung Galaxy Galaxy S25 c2pa-rs no ai marker")
|
||||
r = identify(path, check_visible=False, check_invisible=False)
|
||||
assert r.is_ai_generated is None
|
||||
assert r.platform == "Samsung Galaxy (C2PA)"
|
||||
assert any("C2PA" in w for w in r.watermarks)
|
||||
|
||||
|
||||
class TestIdentifyDiscoveredC2paSigners:
|
||||
@pytest.mark.parametrize(
|
||||
("blob", "platform"),
|
||||
[
|
||||
(b"Anthropic Claude Content Signing", "Anthropic Claude (C2PA signer)"),
|
||||
(b"CapCut/ c2pa-rs", "CapCut (C2PA signer)"),
|
||||
(b"TikTok Inc.", "TikTok (C2PA signer)"),
|
||||
(b"vivo Camera vivo X300 Pro", "vivo (camera, C2PA capture)"),
|
||||
],
|
||||
)
|
||||
def test_non_ai_signer_is_attributed_without_asserting_ai(self, tmp_path: Path, blob: bytes, platform: str):
|
||||
path = _write_c2pa_jpeg(tmp_path, "signed.jpg", blob)
|
||||
|
||||
report = identify(path, check_visible=False, check_invisible=False)
|
||||
|
||||
assert report.is_ai_generated is None
|
||||
assert report.platform == platform
|
||||
|
||||
def test_structured_non_ai_capcut_generator_is_attributed(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
path = tmp_path / "edited.png"
|
||||
from PIL import Image
|
||||
|
||||
Image.new("RGB", (32, 32)).save(path)
|
||||
monkeypatch.setattr(
|
||||
"remove_ai_watermarks.identify.extract_c2pa_info",
|
||||
lambda _path: {
|
||||
"has_c2pa": True,
|
||||
"issuer": "ByteDance",
|
||||
"claim_generator": "CapCut/ c2pa-tool/0.1.0 c2pa-rs/0.31.3",
|
||||
},
|
||||
)
|
||||
|
||||
report = identify(path, check_visible=False, check_invisible=False)
|
||||
|
||||
assert report.is_ai_generated is None
|
||||
assert report.platform == "CapCut (C2PA signer)"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("issuer", "claim_generator", "platform"),
|
||||
[
|
||||
("xAI Grok Imagine", "c2pa-rs", "xAI Grok Imagine"),
|
||||
("Producer.ai", "producer_ai", "Producer.ai"),
|
||||
("", "FastVid", "FastVid"),
|
||||
("SPRING (SG) PTE. LTD.", "c2pa-rs", "SPRING (SG) PTE. LTD."),
|
||||
],
|
||||
)
|
||||
def test_ai_signer_or_generator_is_attributed(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
issuer: str,
|
||||
claim_generator: str,
|
||||
platform: str,
|
||||
):
|
||||
path = tmp_path / "generated.png"
|
||||
from PIL import Image
|
||||
|
||||
Image.new("RGB", (32, 32)).save(path)
|
||||
monkeypatch.setattr(
|
||||
"remove_ai_watermarks.identify.extract_c2pa_info",
|
||||
lambda _path: {
|
||||
"has_c2pa": True,
|
||||
"issuer": issuer,
|
||||
"claim_generator": claim_generator,
|
||||
"source_type": "trainedAlgorithmicMedia (AI-generated)",
|
||||
"ai_source_kind": "generated",
|
||||
},
|
||||
)
|
||||
|
||||
report = identify(path, check_visible=False, check_invisible=False)
|
||||
|
||||
assert report.is_ai_generated is True
|
||||
assert report.platform == platform
|
||||
|
||||
|
||||
# ── End-to-end verdicts on real fixtures ────────────────────────────
|
||||
|
||||
|
||||
@@ -731,6 +810,8 @@ class TestIdentifyRealSamples:
|
||||
assert r.is_ai_generated is True
|
||||
assert r.platform == "Apple Photos (Clean Up AI edit)"
|
||||
assert r.ai_source_kind == "enhanced"
|
||||
assert "content_seal" not in [signal.name for signal in r.signals]
|
||||
assert not any("Meta" in watermark for watermark in r.watermarks)
|
||||
|
||||
def test_standalone_iptc_composite_synthetic_is_enhanced(self, tmp_path: Path):
|
||||
p = tmp_path / "composite.jpg"
|
||||
@@ -743,15 +824,8 @@ class TestIdentifyRealSamples:
|
||||
assert r.is_ai_generated is True
|
||||
assert r.ai_source_kind == "enhanced"
|
||||
|
||||
def test_standalone_ai_tag_attributes_the_content_seal(self, tmp_path: Path):
|
||||
"""A standalone AI digital-source tag emits the seal as its own signal.
|
||||
|
||||
Muse Image outputs carry no C2PA; this tag is their only provenance, and
|
||||
Muse stamps every output with the invisible Content Seal. The signal is
|
||||
the strength router's Meta bet as evidence - an attribution, not a decode
|
||||
(no public decoder exists), so its confidence is medium and the caveat
|
||||
still points at the oracle.
|
||||
"""
|
||||
def test_standalone_ai_tag_does_not_claim_a_vendor_watermark(self, tmp_path: Path):
|
||||
"""The shared IPTC standard proves neither Meta nor Content Seal."""
|
||||
p = tmp_path / "muse-tag.jpg"
|
||||
p.write_bytes(
|
||||
b'\xff\xd8\xff\xe1<x:xmpmeta Iptc4xmpExt:DigitalSourceType="trainedAlgorithmicMedia"></x:xmpmeta>\xff\xd9'
|
||||
@@ -761,38 +835,9 @@ class TestIdentifyRealSamples:
|
||||
|
||||
names = [s.name for s in r.signals]
|
||||
assert "iptc" in names
|
||||
assert "content_seal" in names
|
||||
seal = next(s for s in r.signals if s.name == "content_seal")
|
||||
assert seal.confidence == "medium"
|
||||
assert "Invisible Content Seal watermark (Meta Muse attribution)" in r.watermarks
|
||||
assert any("meta.ai/identification" in c for c in r.caveats)
|
||||
|
||||
def test_seal_platform_attribution_follows_the_signal(self, tmp_path: Path):
|
||||
"""The Likely-source line follows the same bet the seal signal makes.
|
||||
|
||||
Apple keeps its own attribution; every other standalone-tag file gets the
|
||||
hedged Muse attribution instead of "platform not specified", so the panel
|
||||
that prices the Content Seal and the source line agree.
|
||||
"""
|
||||
muse = tmp_path / "muse.jpg"
|
||||
muse.write_bytes(
|
||||
b'\xff\xd8\xff\xe1<x:xmpmeta Iptc4xmpExt:DigitalSourceType="trainedAlgorithmicMedia"></x:xmpmeta>\xff\xd9'
|
||||
)
|
||||
apple = tmp_path / "apple.jpg"
|
||||
apple.write_bytes(
|
||||
b'\xff\xd8\xff\xe1<x:xmpmeta Iptc4xmpExt:DigitalSourceType="compositeWithTrainedAlgorithmicMedia" '
|
||||
b'photoshop:Credit="Apple Photos Clean Up"></x:xmpmeta>\xff\xd9'
|
||||
)
|
||||
|
||||
assert identify(muse, check_visible=False, check_invisible=False).platform == (
|
||||
"Meta Muse Image (attributed by the standalone AI digital-source tag)"
|
||||
)
|
||||
assert identify(apple, check_visible=False, check_invisible=False).platform == "Apple Photos (Clean Up AI edit)"
|
||||
|
||||
def test_c2pa_backed_file_gets_no_content_seal_attribution(self):
|
||||
"""C2PA issuers win first: a manifest-backed file is not Meta-routed."""
|
||||
r = identify(SAMPLES_DIR / "flux-1.png", check_visible=False, check_invisible=False)
|
||||
assert "content_seal" not in [s.name for s in r.signals]
|
||||
assert "content_seal" not in names
|
||||
assert r.platform is None
|
||||
assert not any("Content Seal" in watermark for watermark in r.watermarks)
|
||||
|
||||
def test_flux_bfl_c2pa_png(self):
|
||||
# flux-1.png: real Black Forest Labs FLUX.2 Playground output (signed C2PA).
|
||||
@@ -1541,7 +1586,7 @@ class TestIdentifyInvisibleWatermark:
|
||||
|
||||
|
||||
class TestIdentifyAIGC:
|
||||
"""China TC260 AIGC label is detected and attributed (e.g. Doubao)."""
|
||||
"""China TC260 AIGC labels are detected without guessing the product."""
|
||||
|
||||
def _aigc_png(self, tmp_path: Path) -> Path:
|
||||
from PIL import Image
|
||||
@@ -1644,6 +1689,11 @@ class TestVendorOf:
|
||||
|
||||
|
||||
class TestIntegrityClashesHelper:
|
||||
def test_tc260_manufacturer_comes_from_the_registered_producer(self):
|
||||
assert _tc260_manufacturer_of("001191440101MA9Y9T4H7A00001") == "Alibaba"
|
||||
assert _tc260_manufacturer_of("0011999999999999999999999") is None
|
||||
assert _tc260_manufacturer_of("") is None
|
||||
|
||||
def test_two_ai_vendors_clash(self):
|
||||
clashes = _integrity_clashes({"c2pa": "OpenAI", "exif_generator": "Ideogram"}, None, camera_has_ai_marker=True)
|
||||
assert len(clashes) == 1
|
||||
@@ -1665,19 +1715,11 @@ class TestIntegrityClashesHelper:
|
||||
== []
|
||||
)
|
||||
|
||||
def test_bytedance_c2pa_plus_own_aigc_no_clash(self):
|
||||
# A legit ByteDance/Doubao image carries BOTH a ByteDance C2PA manifest and its
|
||||
# own China TC260 AIGC label. The label is ByteDance's own regulatory stamp, so
|
||||
# it must be attributed to ByteDance and NOT read as a competing origin.
|
||||
assert (
|
||||
_integrity_clashes({"c2pa": "ByteDance", "aigc": "China AIGC (TC260)"}, None, camera_has_ai_marker=True)
|
||||
== []
|
||||
)
|
||||
def test_matching_tc260_manufacturer_no_clash(self):
|
||||
assert _integrity_clashes({"c2pa": "ByteDance", "aigc": "ByteDance"}, None, camera_has_ai_marker=True) == []
|
||||
|
||||
def test_foreign_vendor_plus_aigc_still_clashes(self):
|
||||
# But a NON-Chinese vendor's C2PA next to a China TC260 label names two different
|
||||
# origins -- a laundering tell that must still fire (the generic label stays generic).
|
||||
clashes = _integrity_clashes({"c2pa": "OpenAI", "aigc": "China AIGC (TC260)"}, None, camera_has_ai_marker=True)
|
||||
def test_different_tc260_manufacturer_clashes(self):
|
||||
clashes = _integrity_clashes({"c2pa": "OpenAI", "aigc": "ByteDance"}, None, camera_has_ai_marker=True)
|
||||
assert len(clashes) == 1
|
||||
assert "Conflicting AI-origin" in clashes[0]
|
||||
|
||||
@@ -1754,9 +1796,13 @@ class TestIntegrityClashEndToEnd:
|
||||
|
||||
def test_two_generator_stamps_clash(self, tmp_path: Path):
|
||||
# An OpenAI C2PA manifest (AI source) on an image that ALSO carries a
|
||||
# China TC260 AIGC label = two independent generator stamps naming
|
||||
# different origins -> a laundering tell.
|
||||
path = self._c2pa_jpeg(tmp_path, b"OpenAI ... trainedAlgorithmicMedia ... TC260:AIGC label")
|
||||
# Qwen TC260 AIGC label = two independent generator stamps naming
|
||||
# different manufacturers -> a laundering tell.
|
||||
path = self._c2pa_jpeg(
|
||||
tmp_path,
|
||||
b"OpenAI ... trainedAlgorithmicMedia ... "
|
||||
b'<TC260:AIGC>{"Label":"1","ContentProducer":"91440101MA9Y9T4H7A"}</TC260:AIGC>',
|
||||
)
|
||||
r = identify(path, check_visible=False, check_invisible=False)
|
||||
assert r.integrity_clashes
|
||||
assert any("Conflicting AI-origin" in c for c in r.integrity_clashes)
|
||||
@@ -1773,7 +1819,7 @@ class TestIntegrityClashEndToEnd:
|
||||
# the provenance is inconsistent (a laundering / spoofing tell).
|
||||
path = self._c2pa_jpeg(
|
||||
tmp_path,
|
||||
b'Pixel Camera ... <TC260:AIGC>{"Label":"1","ContentProducer":"BYTEDANCE001"}</TC260:AIGC>',
|
||||
b'Pixel Camera ... <TC260:AIGC>{"Label":"1","ContentProducer":"91110102MACQD9K640"}</TC260:AIGC>',
|
||||
)
|
||||
r = identify(path, check_visible=False, check_invisible=False)
|
||||
assert r.platform == "Google Pixel (camera, C2PA capture)"
|
||||
|
||||
@@ -55,7 +55,7 @@ class TestVerifiedTextMode:
|
||||
def _engine(profile: str = "qwen-zimage") -> InvisibleEngine:
|
||||
engine = object.__new__(InvisibleEngine)
|
||||
engine._progress_callback = None
|
||||
engine._remover = SimpleNamespace(model_profile=profile)
|
||||
engine._remover = SimpleNamespace(configured_profile=profile, model_profile=profile)
|
||||
return engine
|
||||
|
||||
def test_rejects_incompatible_pipeline_options(self, tmp_path):
|
||||
@@ -64,7 +64,6 @@ class TestVerifiedTextMode:
|
||||
manifest = tmp_path / "manifest.json"
|
||||
manifest.write_text("{}", encoding="utf-8")
|
||||
cases = (
|
||||
("sdxl-zimage", {}, "not supported by the sdxl-zimage profile"),
|
||||
("qwen-zimage", {"tile": True}, "not calibrated with --tile"),
|
||||
("qwen-zimage", {"max_resolution": 1024}, "max-resolution 0"),
|
||||
("qwen-zimage", {"humanize": 1.0}, "humanize=0"),
|
||||
@@ -124,9 +123,10 @@ class TestVerifiedTextMode:
|
||||
engine._remover.remove_watermark = fake_remove
|
||||
monkeypatch.setattr(region_eraser, "lama_available", lambda: True)
|
||||
|
||||
engine.remove_watermark(source, output, text_manifest=manifest)
|
||||
engine.remove_watermark(source, output, text_manifest=manifest, vendor="google")
|
||||
|
||||
assert seen["text_manifest"].lines[0].text == "Exact"
|
||||
assert seen["vendor"] == "google"
|
||||
# Leak-safe default since 0.27.1: the global 15% donor blend is OFF unless
|
||||
# explicitly requested (measured to return detector-visible OpenAI SynthID
|
||||
# on poster-scale manifests; see docs/text-protection-research.md).
|
||||
@@ -149,7 +149,11 @@ class TestNativeOutputSize:
|
||||
Image.open(image_path).crop((0, 0, 24, 16)).save(out)
|
||||
return out
|
||||
|
||||
engine._remover = SimpleNamespace(remove_watermark=_remove_watermark, model_profile="qwen-zimage")
|
||||
engine._remover = SimpleNamespace(
|
||||
remove_watermark=_remove_watermark,
|
||||
configured_profile="qwen-zimage",
|
||||
model_profile="qwen-zimage",
|
||||
)
|
||||
engine._progress_callback = None
|
||||
src = tmp_path / "src.png"
|
||||
out = tmp_path / "out.png"
|
||||
@@ -262,10 +266,10 @@ class TestEngineResolvesThePolishPerProfile:
|
||||
|
||||
engine = object.__new__(InvisibleEngine)
|
||||
engine._progress_callback = None
|
||||
engine._remover = MagicMock(model_profile=profile)
|
||||
engine._remover = MagicMock(configured_profile=profile, model_profile=profile)
|
||||
return engine
|
||||
|
||||
def _polish_used(self, profile: str, requested, tmp_path, monkeypatch) -> bool:
|
||||
def _polish_used(self, profile: str, requested, tmp_path, monkeypatch, *, vendor: str | None = None) -> bool:
|
||||
seen: list[bool] = []
|
||||
monkeypatch.setattr(
|
||||
"remove_ai_watermarks.humanizer.adaptive_polish",
|
||||
@@ -278,13 +282,21 @@ class TestEngineResolvesThePolishPerProfile:
|
||||
Image.open(kw["image_path"]).save(kw["output_path"]),
|
||||
kw["output_path"],
|
||||
)[1]
|
||||
engine.remove_watermark(src, tmp_path / f"out_{profile}_{requested}.png", adaptive_polish=requested)
|
||||
engine.remove_watermark(
|
||||
src,
|
||||
tmp_path / f"out_{profile}_{requested}.png",
|
||||
adaptive_polish=requested,
|
||||
vendor=vendor,
|
||||
)
|
||||
return bool(seen)
|
||||
|
||||
def test_unset_follows_the_profile_not_the_signature_default(self, tmp_path, monkeypatch):
|
||||
assert self._polish_used("qwen-zimage", None, tmp_path, monkeypatch) is False
|
||||
assert self._polish_used("sdxl-zimage", None, tmp_path, monkeypatch) is True
|
||||
|
||||
def test_auto_google_uses_the_resolved_sdxl_default(self, tmp_path, monkeypatch):
|
||||
assert self._polish_used("auto", None, tmp_path, monkeypatch, vendor="google") is True
|
||||
|
||||
def test_an_explicit_value_still_wins_on_both_profiles(self, tmp_path, monkeypatch):
|
||||
assert self._polish_used("qwen-zimage", True, tmp_path, monkeypatch) is True
|
||||
assert self._polish_used("sdxl-zimage", False, tmp_path, monkeypatch) is False
|
||||
|
||||
@@ -90,13 +90,6 @@ class TestConfig:
|
||||
assert "可灵AI" in mark.label
|
||||
assert mark.in_auto
|
||||
|
||||
def test_confident_kling_detection_suppresses_the_jimeng_pill(self):
|
||||
# A Kling image is TC260 too but is not Jimeng-basic: like Doubao and Qwen,
|
||||
# a confident Kling detection must veto the pill (``_keep_pill``).
|
||||
from remove_ai_watermarks.watermark_registry import _keep_pill
|
||||
|
||||
assert not _keep_pill({"kling"}, provenance=frozenset({"jimeng"}), footprint_flat=1.0)
|
||||
|
||||
|
||||
class TestDetect:
|
||||
def test_clean_gradient_not_detected(self):
|
||||
|
||||
@@ -162,14 +162,6 @@ class TestDetectAndMask:
|
||||
img = np.full((2400, 1792, 3), 100, np.uint8)
|
||||
assert eng.footprint_mask(img) is None
|
||||
|
||||
def test_confident_liblib_detection_suppresses_the_jimeng_pill(self):
|
||||
# A LiblibAI image is TC260 too but is not Jimeng-basic: like Doubao/Qwen/
|
||||
# Kling/RunningHub/Baidu, a confident LiblibAI detection must veto the pill.
|
||||
# It was the one mark the hand-written veto list in ``_keep_pill`` missed.
|
||||
from remove_ai_watermarks.watermark_registry import _keep_pill
|
||||
|
||||
assert not _keep_pill({"liblib"}, provenance=frozenset({"jimeng"}), footprint_flat=1.0)
|
||||
|
||||
def test_force_masks_the_whole_locate_box_on_a_clean_frame(self):
|
||||
"""``force`` takes priority over detection for this mark, unlike the base
|
||||
policy: a --no-detect caller named the mark, so the honest footprint is the
|
||||
|
||||
+210
-1
@@ -945,6 +945,30 @@ class TestExifGenerator:
|
||||
def test_clean_image_is_none(self, tmp_clean_png: Path):
|
||||
assert exif_generator(tmp_clean_png) is None
|
||||
|
||||
def test_nested_generation_model_in_webp_user_comment(self, tmp_path: Path):
|
||||
import json
|
||||
|
||||
payload = json.dumps(
|
||||
{
|
||||
"generationParams": {
|
||||
"modelName": "Nano Banana synthetic fixture",
|
||||
"prompt": "private prompt must not become the platform label",
|
||||
}
|
||||
}
|
||||
)
|
||||
exif = piexif.dump({"Exif": {piexif.ExifIFD.UserComment: payload.encode("utf-8")}})
|
||||
source = tmp_path / "generated.webp"
|
||||
Image.new("RGB", (32, 32)).save(source, "WEBP", lossless=True, exif=exif)
|
||||
|
||||
assert exif_generator(source) == "Nano Banana synthetic fixture"
|
||||
assert (
|
||||
identify(source, check_visible=False).platform == "Nano Banana synthetic fixture (embedded generator tag)"
|
||||
)
|
||||
|
||||
output = tmp_path / "clean.webp"
|
||||
remove_ai_metadata(source, output)
|
||||
assert exif_generator(output) is None
|
||||
|
||||
|
||||
_FAKE_SIG = "A" * 120 # 64+ base64 chars; real Grok payloads are 300-1004
|
||||
_FAKE_UUID = "12345678-1234-1234-1234-123456789abc"
|
||||
@@ -970,12 +994,139 @@ def _grok_jpeg(tmp_path: Path, *, signature: str = _FAKE_SIG, artist: str = _FAK
|
||||
return path
|
||||
|
||||
|
||||
def _grok_imagemagick_png(tmp_path: Path) -> Path:
|
||||
"""Write the ImageMagick raw-profile EXIF form seen on converted PNGs."""
|
||||
exif = piexif.dump(
|
||||
{
|
||||
"0th": {
|
||||
piexif.ImageIFD.ImageDescription: f"Signature: {_FAKE_SIG}".encode("latin1"),
|
||||
piexif.ImageIFD.Artist: _FAKE_UUID.encode("latin1"),
|
||||
piexif.ImageIFD.Make: b"Canon",
|
||||
},
|
||||
"Exif": {},
|
||||
"GPS": {},
|
||||
"1st": {},
|
||||
}
|
||||
)
|
||||
profile = (
|
||||
"\nexif\n"
|
||||
+ f"{len(exif):8d}\n"
|
||||
+ "\n".join(exif.hex()[start : start + 72] for start in range(0, len(exif) * 2, 72))
|
||||
)
|
||||
info = PngInfo()
|
||||
info.add_text("Raw profile type exif", profile)
|
||||
path = tmp_path / "grok-imagemagick.png"
|
||||
Image.new("RGB", (64, 64), (70, 80, 90)).save(path, pnginfo=info)
|
||||
return 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
|
||||
|
||||
def test_imagemagick_png_raw_exif_profile_detected(self, tmp_path: Path):
|
||||
path = _grok_imagemagick_png(tmp_path)
|
||||
|
||||
with Image.open(path) as image:
|
||||
assert "exif" not in image.info
|
||||
assert image.getexif()
|
||||
|
||||
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.
|
||||
@@ -996,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
|
||||
|
||||
@@ -1169,6 +1332,21 @@ class TestRemoveAiExif:
|
||||
assert xai_signature(out) is False
|
||||
assert has_ai_metadata(out) is False
|
||||
|
||||
def test_grok_raw_profile_stripped_on_png_output(self, tmp_path: Path):
|
||||
src = _grok_imagemagick_png(tmp_path)
|
||||
out = tmp_path / "clean.png"
|
||||
|
||||
remove_ai_metadata(src, out)
|
||||
|
||||
assert xai_signature(out) is False
|
||||
with Image.open(out) as image:
|
||||
assert image.getexif().get(piexif.ImageIFD.Make) == "Canon"
|
||||
|
||||
def test_non_text_exif_value_does_not_break_app_metadata_scrub(self):
|
||||
from remove_ai_watermarks.metadata import _app_metadata_evidence
|
||||
|
||||
assert _app_metadata_evidence((1, 2)) == (None, None)
|
||||
|
||||
def test_generator_make_token_stripped(self, tmp_path: Path):
|
||||
# Ideogram's EXIF Make="Ideogram AI" must be scrubbed on removal.
|
||||
exif = piexif.dump({"0th": {piexif.ImageIFD.Make: b"Ideogram AI"}, "Exif": {}, "GPS": {}, "1st": {}})
|
||||
@@ -1391,6 +1569,37 @@ class TestAIGCLabel:
|
||||
assert info["Label"] == "1"
|
||||
assert info["ContentProducer"] == "001191440300708461136T1308L"
|
||||
|
||||
def test_parses_lower_camel_case_tc260_fields(self, tmp_path: Path):
|
||||
import json
|
||||
|
||||
from remove_ai_watermarks.metadata import aigc_label
|
||||
|
||||
p = tmp_path / "aigc-lower-camel.jpg"
|
||||
payload = json.dumps(
|
||||
{
|
||||
"data": {
|
||||
"AIGC": {
|
||||
"label": "1",
|
||||
"contentProducer": "synthetic-producer",
|
||||
"produceId": "synthetic-id",
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
exif = {"Exif": {piexif.ExifIFD.UserComment: payload.encode("ascii")}}
|
||||
Image.new("RGB", (32, 32)).save(p, exif=piexif.dump(exif))
|
||||
|
||||
info = aigc_label(p)
|
||||
|
||||
assert info == {
|
||||
"Label": "1",
|
||||
"ContentProducer": "synthetic-producer",
|
||||
"ProduceID": "synthetic-id",
|
||||
}
|
||||
out = tmp_path / "clean-lower-camel.jpg"
|
||||
remove_ai_metadata(p, out)
|
||||
assert aigc_label(out) is None
|
||||
|
||||
def test_has_ai_metadata_detects_raw_json_exif_form(self, tmp_path: Path):
|
||||
assert has_ai_metadata(self._aigc_exif_jpeg(tmp_path))
|
||||
|
||||
|
||||
@@ -211,6 +211,43 @@ class TestC2PA:
|
||||
assert info["ai_tool"] == "Dreamina"
|
||||
assert info["c2pa_identity_ai"] is True
|
||||
|
||||
def test_structured_xai_signer_identity_is_normalized(self):
|
||||
store = {
|
||||
"active_manifest": "active",
|
||||
"manifests": {
|
||||
"active": {
|
||||
"signature_info": {
|
||||
"issuer": "Self-signed ephemeral certificate (Content Authenticity SDK) -- LOCAL USE ONLY",
|
||||
"common_name": "xAI Grok Imagine",
|
||||
},
|
||||
"assertions": [],
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
info = c2pa_info_from_manifest_store(store)
|
||||
|
||||
assert info["issuer"] == "xAI Grok Imagine"
|
||||
assert info["c2pa_identity_ai"] is True
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("signature", "issuer"),
|
||||
[
|
||||
({"issuer": "Bytedance Pte. Ltd."}, "ByteDance"),
|
||||
({"issuer": "TikTok Inc."}, "TikTok"),
|
||||
],
|
||||
)
|
||||
def test_structured_publisher_identity_does_not_assert_ai(self, signature, issuer):
|
||||
store = {
|
||||
"active_manifest": "active",
|
||||
"manifests": {"active": {"signature_info": signature, "assertions": []}},
|
||||
}
|
||||
|
||||
info = c2pa_info_from_manifest_store(store)
|
||||
|
||||
assert info["issuer"] == issuer
|
||||
assert "c2pa_identity_ai" not in info
|
||||
|
||||
def test_structured_invismark_exposes_algorithm_and_watermark_id(self):
|
||||
watermark_id = "83424621-03cb-40e3-9808-a9fae837156d"
|
||||
store = {
|
||||
|
||||
@@ -15,6 +15,7 @@ from pathlib import Path
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
from remove_ai_watermarks.identify import (
|
||||
PROVENANCE_REPORT_SCHEMA_VERSION,
|
||||
@@ -157,6 +158,65 @@ 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_an_imagemagick_raw_exif_profile_survives(self, tmp_path: Path):
|
||||
"""Pillow decodes this PNG EXIF profile through getexif, not img.info."""
|
||||
import piexif
|
||||
|
||||
exif = piexif.dump(
|
||||
{
|
||||
"0th": {
|
||||
piexif.ImageIFD.ImageDescription: b"Signature: " + b"A" * 80,
|
||||
piexif.ImageIFD.Artist: b"3f2504e0-4f89-11d3-9a0c-0305e82c3301",
|
||||
}
|
||||
}
|
||||
)
|
||||
profile = (
|
||||
"\nexif\n"
|
||||
+ f"{len(exif):8d}\n"
|
||||
+ "\n".join(exif.hex()[start : start + 72] for start in range(0, len(exif) * 2, 72))
|
||||
)
|
||||
info = PngInfo()
|
||||
info.add_text("Raw profile type exif", profile)
|
||||
path = tmp_path / "grok-converted.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_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
|
||||
|
||||
def key(name: bytes) -> bytes:
|
||||
return box(b"mdta", name)
|
||||
|
||||
def value(index: int, payload: bytes) -> bytes:
|
||||
return box(index.to_bytes(4, "big"), box(b"data", b"\x00" * 8 + payload))
|
||||
|
||||
keys = box(b"keys", b"\x00" * 4 + (2).to_bytes(4, "big") + key(b"workflow") + key(b"title"))
|
||||
ilst = box(b"ilst", value(1, b'{"app":"ComfyUI"}') + value(2, b"standard title"))
|
||||
meta = box(b"meta", b"\x00" * 4 + keys + ilst)
|
||||
path = tmp_path / "workflow.mp4"
|
||||
path.write_bytes(
|
||||
b"\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00mp42isom"
|
||||
+ box(b"mdat", b"synthetic-video")
|
||||
+ box(b"moov", box(b"udta", meta))
|
||||
)
|
||||
|
||||
assert "gen_params" in {s.name for s in identify(path, check_visible=False, check_invisible=False).signals}
|
||||
_assert_same_verdict(path)
|
||||
|
||||
|
||||
class TestRecordShape:
|
||||
def test_the_record_survives_json(self, tmp_path: Path):
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Clean-room tests for Paint's local ``Watermarker.dll`` pixel format."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from remove_ai_watermarks.microsoft_invismark import (
|
||||
detect_local_invismark,
|
||||
disrupt_local_invismark,
|
||||
invismark_id_from_c2pa_info,
|
||||
)
|
||||
|
||||
_WATERMARK_ID = uuid.UUID("83424621-03cb-40e3-9808-a9fae837156d")
|
||||
|
||||
|
||||
def _dct_matrix() -> np.ndarray:
|
||||
matrix = np.empty((4, 4), dtype=np.float64)
|
||||
for frequency in range(4):
|
||||
scale = np.sqrt(1 / 4) if frequency == 0 else np.sqrt(2 / 4)
|
||||
for sample in range(4):
|
||||
matrix[frequency, sample] = scale * np.cos(np.pi * (2 * sample + 1) * frequency / 8)
|
||||
return matrix
|
||||
|
||||
|
||||
def _synthetic_writer(image: np.ndarray, watermark_id: uuid.UUID, *, valid_checksum: bool = True) -> np.ndarray:
|
||||
"""Independent format producer; it deliberately does not call production helpers."""
|
||||
guid = watermark_id.bytes_le
|
||||
checksum = sum(guid) & 0xFF
|
||||
if not valid_checksum:
|
||||
checksum ^= 1
|
||||
payload = bytes((0x4C,)) + guid + bytes((checksum,))
|
||||
bits = np.unpackbits(np.frombuffer(payload, dtype=np.uint8))
|
||||
|
||||
rgb = image.astype(np.float64)
|
||||
luminance = rgb @ np.array((0.299, 0.587, 0.114), dtype=np.float64)
|
||||
ll = (luminance[0::2, 0::2] + luminance[0::2, 1::2] + luminance[1::2, 0::2] + luminance[1::2, 1::2]) * 0.5
|
||||
original_ll = ll.copy()
|
||||
dct_matrix = _dct_matrix()
|
||||
|
||||
for block_y in range(ll.shape[0] // 4):
|
||||
for block_x in range(ll.shape[1] // 4):
|
||||
top, left = block_y * 4, block_x * 4
|
||||
block = ll[top : top + 4, left : left + 4]
|
||||
coefficients = dct_matrix @ block @ dct_matrix.T
|
||||
ac = coefficients.reshape(-1)[1:].reshape(3, 5)
|
||||
u, singular, vh = np.linalg.svd(ac, full_matrices=False)
|
||||
bit = int(bits[(block_y % 12) * 12 + (block_x % 12)])
|
||||
singular[0] = (np.floor(singular[0] / 24) + 0.25 + 0.5 * bit) * 24
|
||||
rewritten = coefficients.reshape(-1).copy()
|
||||
rewritten[1:] = (u @ np.diag(singular) @ vh).reshape(-1)
|
||||
ll[top : top + 4, left : left + 4] = dct_matrix.T @ rewritten.reshape(4, 4) @ dct_matrix
|
||||
|
||||
delta = (ll - original_ll) * 0.5
|
||||
pixel_delta = np.repeat(np.repeat(delta, 2, axis=0), 2, axis=1)
|
||||
return np.clip(np.rint(rgb + pixel_delta[..., None]), 0, 255).astype(np.uint8)
|
||||
|
||||
|
||||
def _write_carrier(path: Path, *, valid_checksum: bool = True) -> np.ndarray:
|
||||
original = np.random.default_rng(20260913).integers(24, 232, size=(192, 192, 3), dtype=np.uint8)
|
||||
encoded = _synthetic_writer(original, _WATERMARK_ID, valid_checksum=valid_checksum)
|
||||
Image.fromarray(encoded, "RGB").save(path)
|
||||
return original
|
||||
|
||||
|
||||
def test_detects_repeated_payload_and_windows_guid_order(tmp_path: Path) -> None:
|
||||
source = tmp_path / "paint.png"
|
||||
_write_carrier(source)
|
||||
|
||||
detection = detect_local_invismark(source)
|
||||
|
||||
assert detection is not None
|
||||
assert detection.watermark_id == _WATERMARK_ID
|
||||
assert detection.payload == b"\x4c" + _WATERMARK_ID.bytes_le + bytes((sum(_WATERMARK_ID.bytes_le) & 0xFF,))
|
||||
assert detection.minimum_support >= 3
|
||||
|
||||
|
||||
def test_rejects_clean_and_malformed_payloads(tmp_path: Path) -> None:
|
||||
clean = tmp_path / "clean.png"
|
||||
malformed = tmp_path / "malformed.png"
|
||||
Image.fromarray(
|
||||
np.random.default_rng(20260913).integers(24, 232, size=(192, 192, 3), dtype=np.uint8),
|
||||
"RGB",
|
||||
).save(clean)
|
||||
_write_carrier(malformed, valid_checksum=False)
|
||||
|
||||
assert detect_local_invismark(clean) is None
|
||||
assert detect_local_invismark(malformed) is None
|
||||
|
||||
|
||||
def test_disruption_requires_expected_guid_and_invalidates_payload(tmp_path: Path) -> None:
|
||||
source = tmp_path / "paint.png"
|
||||
wrong_output = tmp_path / "wrong.png"
|
||||
output = tmp_path / "clean.png"
|
||||
original = _write_carrier(source)
|
||||
|
||||
assert disrupt_local_invismark(source, wrong_output, expected_watermark_id=uuid.uuid4()) is None
|
||||
assert not wrong_output.exists()
|
||||
|
||||
result = disrupt_local_invismark(source, output, expected_watermark_id=_WATERMARK_ID)
|
||||
|
||||
assert result is not None
|
||||
assert result.watermark_id == _WATERMARK_ID
|
||||
assert result.changed_blocks >= 432
|
||||
assert output.exists()
|
||||
assert detect_local_invismark(output) is None
|
||||
cleaned = np.asarray(Image.open(output).convert("RGB"))
|
||||
assert np.mean(np.abs(cleaned.astype(np.int16) - original.astype(np.int16))) < 2.0
|
||||
|
||||
|
||||
def test_cloud_image_creator_fixture_is_not_the_local_dll_format() -> None:
|
||||
fixture = Path(__file__).parents[1] / "data/fixtures/provenance/microsoft-paint-invismark.png"
|
||||
|
||||
assert detect_local_invismark(fixture) is None
|
||||
|
||||
|
||||
def test_c2pa_uuid_requires_exact_algorithm_and_one_valid_value() -> None:
|
||||
info = {
|
||||
"soft_binding_algorithm": "com.microsoft.invismark.1",
|
||||
"soft_binding_value": str(_WATERMARK_ID),
|
||||
}
|
||||
|
||||
assert invismark_id_from_c2pa_info(info) == _WATERMARK_ID
|
||||
assert invismark_id_from_c2pa_info({**info, "soft_binding_algorithm": "io.iscc.v0"}) is None
|
||||
assert invismark_id_from_c2pa_info({**info, "soft_binding_value": f"{_WATERMARK_ID}, {uuid.uuid4()}"}) is None
|
||||
@@ -76,7 +76,7 @@ def test_trustmark_is_limited_to_its_numpy_compatible_python_range():
|
||||
def test_extras_use_capability_names_without_legacy_aliases():
|
||||
extras = set(metadata("remove-ai-watermarks").get_all("Provides-Extra") or [])
|
||||
|
||||
assert {"pixels", "heif", "visible", "video", "detect", "diffusion", "classify"} <= extras
|
||||
assert {"pixels", "heif", "visible", "video", "detect", "diffusion", "classify", "classify-onnx"} <= extras
|
||||
assert {"gpu", "remove", "detect-pywavelets", "verify"}.isdisjoint(extras)
|
||||
|
||||
|
||||
@@ -85,6 +85,11 @@ def test_classify_extra_owns_the_photo_heads() -> None:
|
||||
assert {"torch", "transformers", "huggingface-hub", "tokenizers"} <= names
|
||||
|
||||
|
||||
def test_classify_onnx_extra_extends_the_default_classifier() -> None:
|
||||
names = _requirement_names("classify-onnx")
|
||||
assert {"torch", "transformers", "huggingface-hub", "tokenizers", "onnxruntime"} <= names
|
||||
|
||||
|
||||
def test_qwen_zimage_extra_owns_every_profile_tokenizer_backend() -> None:
|
||||
assert "sentencepiece" in _requirement_names("qwen-zimage")
|
||||
|
||||
|
||||
+10
-7
@@ -240,6 +240,7 @@ class TestResolveStrength:
|
||||
)
|
||||
assert resolve_strength(None, None, "qwen-zimage", size=(600, 500)) == pytest.approx(0.084)
|
||||
assert resolve_strength(None, "google", "qwen-zimage", size=(600, 500)) == QWEN_ZIMAGE_GOOGLE_STRENGTH
|
||||
assert QWEN_ZIMAGE_GOOGLE_STRENGTH == 0.35
|
||||
# The floor holds at every size, not only above the curve's top rung.
|
||||
assert resolve_strength(None, "google", "qwen-zimage", size=(2000, 1850)) == QWEN_ZIMAGE_GOOGLE_STRENGTH
|
||||
|
||||
@@ -263,16 +264,13 @@ class TestResolveStrength:
|
||||
assert resolve_strength(None, "meta", "qwen-zimage", size=size) == pytest.approx(QWEN_ZIMAGE_META_STRENGTH)
|
||||
assert resolve_strength(None, "meta", "sdxl-zimage") == SDXL_ZIMAGE_UNKNOWN_STRENGTH
|
||||
|
||||
def test_vendor_for_strength_routes_standalone_iptc_to_meta(self, tmp_path):
|
||||
"""Auto mode: a file whose only provenance is the AI IPTC tag routes to the
|
||||
meta cohort (Muse carries no C2PA; the tag is its fallback companion), while
|
||||
a file without the tag stays on the resolution curve and a C2PA issuer still
|
||||
wins over the tag."""
|
||||
def test_vendor_for_strength_does_not_treat_shared_iptc_as_meta(self, tmp_path):
|
||||
"""A standard AI tag does not identify the Meta strength cohort."""
|
||||
from remove_ai_watermarks._internal.watermark_profiles import vendor_for_strength
|
||||
|
||||
tagged = tmp_path / "tagged.webp"
|
||||
tagged.write_bytes(_MUSE_WEBP_WITH_IPTC_TAG)
|
||||
assert vendor_for_strength(tagged) == "meta"
|
||||
assert vendor_for_strength(tagged) is None
|
||||
|
||||
stripped = tmp_path / "stripped.webp"
|
||||
stripped.write_bytes(b"RIFF\x24\x00\x00\x00WEBPVP8 \x18\x00\x00\x00" + b"\x00" * 16)
|
||||
@@ -281,7 +279,7 @@ class TestResolveStrength:
|
||||
def test_sdxl_zimage_uses_its_flat_vendor_ladder(self):
|
||||
|
||||
assert SDXL_ZIMAGE_OPENAI_STRENGTH == 0.15
|
||||
assert SDXL_ZIMAGE_GEMINI_STRENGTH == 0.25
|
||||
assert SDXL_ZIMAGE_GEMINI_STRENGTH == 0.50
|
||||
assert SDXL_ZIMAGE_UNKNOWN_STRENGTH == SDXL_ZIMAGE_GEMINI_STRENGTH
|
||||
assert resolve_strength(None, "openai", "sdxl-zimage") == SDXL_ZIMAGE_OPENAI_STRENGTH
|
||||
assert resolve_strength(None, "google", "sdxl-zimage") == SDXL_ZIMAGE_GEMINI_STRENGTH
|
||||
@@ -460,6 +458,7 @@ class TestPlatformPaths:
|
||||
asset_dir = Path(__file__).parent.parent / "src" / "remove_ai_watermarks" / "assets"
|
||||
assert (asset_dir / "gemini_bg_48.png").exists()
|
||||
assert (asset_dir / "gemini_bg_96.png").exists()
|
||||
assert (asset_dir / "qwen_symbol_alpha.png").exists()
|
||||
|
||||
def test_asset_loading_works(self):
|
||||
"""Verify embedded assets load correctly (critical for packaging)."""
|
||||
@@ -469,3 +468,7 @@ class TestPlatformPaths:
|
||||
# If we get here without error, asset loading works
|
||||
assert engine._alpha_small.shape == (48, 48)
|
||||
assert engine._alpha_large.shape == (96, 96)
|
||||
|
||||
from remove_ai_watermarks.qwen_engine import QwenEngine
|
||||
|
||||
assert QwenEngine()._symbol_template.shape == (53, 53)
|
||||
@@ -2,48 +2,52 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _publish() -> Any:
|
||||
path = Path(__file__).resolve().parents[1] / "scripts" / "publish_photo_classify_hf.py"
|
||||
spec = importlib.util.spec_from_file_location("publish_photo_classify_hf", path)
|
||||
assert spec is not None
|
||||
assert spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
import publish_photo_classify_hf as publish # noqa: E402
|
||||
|
||||
|
||||
def test_hub_id_is_the_photo_classify_repo() -> None:
|
||||
assert _publish().HUB_REPO == "wiltodelta/raiw-photo-classify"
|
||||
assert publish.HUB_REPO == "wiltodelta/raiw-photo-classify"
|
||||
|
||||
|
||||
def test_publish_freeze_includes_both_clip_runtimes() -> None:
|
||||
from remove_ai_watermarks.classify import _WEIGHT_FILES, CLIP_ONNX_FILE, WEIGHTS_ALLOW_PATTERNS
|
||||
|
||||
assert publish.CLIP_FILE in publish.WEIGHT_FILES
|
||||
assert publish.CLIP_ONNX_FILE in publish.WEIGHT_FILES
|
||||
assert set(publish.WEIGHT_FILES) == {*_WEIGHT_FILES, CLIP_ONNX_FILE}
|
||||
assert set(publish.WEIGHT_FILES) <= set(WEIGHTS_ALLOW_PATTERNS)
|
||||
|
||||
|
||||
def test_weight_paths_accepts_run1_layout(tmp_path: Path) -> None:
|
||||
(tmp_path / "clip-l-ft.pt").write_bytes(b"clip")
|
||||
(tmp_path / "clip-l-ft-vision-fp32.onnx").write_bytes(b"onnx")
|
||||
(tmp_path / "probe-weights-clip-l-ft.npz").write_bytes(b"probe")
|
||||
run1 = tmp_path / "run1"
|
||||
run1.mkdir()
|
||||
(run1 / "detector.pt").write_bytes(b"det")
|
||||
(run1 / "provider.pt").write_bytes(b"prov")
|
||||
found = _publish().weight_paths(tmp_path)
|
||||
found = publish.weight_paths(tmp_path)
|
||||
assert found["detector.pt"] == run1 / "detector.pt"
|
||||
assert found["clip-l-ft.pt"] == tmp_path / "clip-l-ft.pt"
|
||||
|
||||
|
||||
def test_weight_paths_requires_every_file(tmp_path: Path) -> None:
|
||||
(tmp_path / "clip-l-ft.pt").write_bytes(b"clip")
|
||||
(tmp_path / "clip-l-ft-vision-fp32.onnx").write_bytes(b"onnx")
|
||||
with pytest.raises(SystemExit, match=r"missing probe-weights-clip-l-ft\.npz"):
|
||||
_publish().weight_paths(tmp_path)
|
||||
publish.weight_paths(tmp_path)
|
||||
|
||||
|
||||
def test_stage_card_copies_tracked_files(tmp_path: Path) -> None:
|
||||
dest = tmp_path / "stage"
|
||||
_publish().stage_card(dest)
|
||||
publish.stage_card(dest)
|
||||
assert (dest / "README.md").is_file()
|
||||
assert (dest / "operating-point.json").is_file()
|
||||
text = (dest / "operating-point.json").read_text()
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Local staging checks for the source-classifier Hub publisher."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
import publish_source_classify_hf as publish # noqa: E402
|
||||
|
||||
|
||||
def test_hub_id_is_the_source_classify_repo() -> None:
|
||||
assert publish.HUB_REPO == "wiltodelta/raiw-source-classify"
|
||||
|
||||
|
||||
def test_stage_release_copies_only_public_artifacts(tmp_path: Path) -> None:
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
(source / "source-pipeline-mlp.npz").write_bytes(b"weights")
|
||||
destination = tmp_path / "stage"
|
||||
publish.stage_release(destination, source)
|
||||
assert sorted(path.name for path in destination.iterdir()) == sorted(publish.PUBLIC_FILES)
|
||||
|
||||
|
||||
def test_stage_release_requires_the_model(tmp_path: Path) -> None:
|
||||
with pytest.raises(SystemExit, match=r"source-pipeline-mlp\.npz"):
|
||||
publish.stage_release(tmp_path / "stage", tmp_path)
|
||||
|
||||
|
||||
def test_card_sha_matches_the_metrics_manifest() -> None:
|
||||
metrics = json.loads((publish.CARD_DIR / "metrics.json").read_text())
|
||||
card = (publish.CARD_DIR / "README.md").read_text()
|
||||
assert metrics["artifact_sha256"] in card
|
||||
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user