Compare commits

..
15 Commits
71 changed files with 3900 additions and 515 deletions

No files matched your search

+1 -1
View File
@@ -6,7 +6,7 @@
},
"metadata": {
"description": "Agent skill and Claude Code plugin for the remove-ai-watermarks CLI.",
"version": "1.0.8"
"version": "1.0.9"
},
"plugins": [
{
+4
View File
@@ -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
@@ -8,7 +8,7 @@ historical data is sufficient.
| 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`; metadata-derived vendor cohorts and blinded sheets for uncovered labels | `scripts/visible_eval.py`, registered examples, and full-corpus crossfire | `scripts/visible_removal_audit.py`; same-mark redetection plus visual residual review and fill-quality checks |
| 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 |
@@ -42,6 +42,12 @@ 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
+25 -13
View File
@@ -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:
@@ -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})"
+1 -1
View File
@@ -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
+34 -14
View File
@@ -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`, `diffusion`, and `classify-onnx`. 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
@@ -213,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]"
@@ -267,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]"
@@ -375,10 +394,10 @@ 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.
@@ -473,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.
+11 -6
View File
@@ -490,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, unknown, and an explicitly selected Meta cohort |
| `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
@@ -526,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
@@ -552,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.
+2
View File
@@ -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)
+9 -5
View File
@@ -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,7 +121,7 @@ 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 |
@@ -127,6 +129,7 @@ application actually uses:
| `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 |
@@ -154,6 +157,7 @@ flowchart LR
trustmark
classify --> pixels
onnx["classify-onnx"] --> classify
source["source-classify"] --> pixels
```
`heif`, `trustmark`, and `text-draft` are independent branches. Combine them
+52 -24
View File
@@ -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.
+296 -30
View File
@@ -484,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 --
@@ -510,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
@@ -1102,6 +1116,30 @@ 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
@@ -1651,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.
@@ -1824,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
@@ -1839,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.
@@ -1864,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
@@ -1922,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
@@ -1973,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.
@@ -1985,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
@@ -2041,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 |
@@ -2078,7 +2344,7 @@ spread): 0.06 + (0.0525 - 0.015) = 0.0975, rounded up to **0.1**. Shipped as
`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.25. The default
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
+133 -2
View File
@@ -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`. |
+51 -7
View File
@@ -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 |
@@ -739,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:
@@ -754,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.
+20 -8
View File
@@ -92,7 +92,17 @@ 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
@@ -255,13 +265,15 @@ 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. The
`distribute.yml` waiter also has a clock: 0.40.0's node suite finished after
the 15-minute poll, so the job now waits 30 minutes and then accepts a
registry row that already requires this library version. Rerun the failed
`comfyui` job only when the registry still has no such row.
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
+65
View File
@@ -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.
+22
View File
@@ -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
}
}
+42
View File
@@ -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.
+35 -20
View File
@@ -121,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:
@@ -148,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.
@@ -170,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
@@ -180,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`
@@ -243,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 |
+124
View File
@@ -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
+7 -4
View File
@@ -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
+14
View File
@@ -775,6 +775,20 @@ 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)
+6
View File
@@ -501,6 +501,12 @@ 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
+10 -1
View File
@@ -31,6 +31,15 @@ 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
`c2pa.created` action, and `trainedAlgorithmicMedia`; that exact generator now
@@ -39,7 +48,7 @@ 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.
**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 signature scheme (DETECTED by `metadata.xai_signature`, built 2026-05-26, expanded 2026-09-13).**
+9 -2
View File
@@ -4,7 +4,7 @@
# on it, including the ComfyUI node package. The console script below carries
# the same weight, since users have it on PATH.
name = "remove-ai-watermarks"
version = "0.40.2"
version = "0.41.0"
description = "AI watermark remover for visible, invisible, and provenance marks in images and video"
readme = "README.md"
requires-python = ">=3.11,<3.15"
@@ -160,6 +160,13 @@ 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
@@ -205,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"
+8 -3
View File
@@ -9,7 +9,7 @@ explicitly names a tracked canonical result.
| Script | Purpose |
| --- | --- |
| `corpus_gap_scan.py` | Inventory `identify`, metadata markers, unattributed outcomes, errors, and gap candidates over a full or date-bounded corpus. |
| `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. |
@@ -46,13 +46,16 @@ explicitly names a tracked canonical result.
| `vendor_mark_calibrate.py` | Calibrate a candidate vendor text detector. |
| `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.
+44
View File
@@ -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)
+40 -30
View File
@@ -26,9 +26,13 @@ Usage:
--since 2026-09-01 --report .local-eval/detector-report-weekly.csv
Rows stream into ``<report>.progress.jsonl`` and resume by relative path after an
interruption. Use a new report name for a new code/corpus snapshot, or pass
``--restart`` when intentionally replacing the checkpoint. The final CSV is
written atomically after every selected row is present.
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
@@ -40,16 +44,19 @@ import logging
import os
from collections import Counter
from concurrent.futures import FIRST_COMPLETED, Future, ProcessPoolExecutor, wait
from datetime import date
from importlib.metadata import version
from pathlib import Path
from typing import Any
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 _metadata_region as identify_metadata_region
from remove_ai_watermarks.identify import identify
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,
@@ -90,6 +97,7 @@ MARKERS: tuple[bytes, ...] = (
b"Stability AI",
)
REPORT_FIELDS: tuple[str, ...] = (
"report_schema_version",
"path",
"suffix",
"lib_version",
@@ -98,18 +106,24 @@ REPORT_FIELDS: tuple[str, ...] = (
"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 _base_row(path: str, suffix: str, lib_version: str) -> dict[str, str]:
row = dict.fromkeys(REPORT_FIELDS, "")
row.update(path=path, suffix=suffix, lib_version=lib_version)
row.update(report_schema_version=_REPORT_SCHEMA_VERSION, path=path, suffix=suffix, lib_version=lib_version)
return row
@@ -166,23 +180,12 @@ def _candidate_classes(rep: Any, hits: list[str]) -> list[str]:
def _day_of(path: Path, corpus: Path) -> date | None:
"""Read the leading YYYY-MM-DD corpus segment, if this layout has one."""
try:
segment = path.relative_to(corpus).parts[0]
return date.fromisoformat(segment)
except (ValueError, IndexError):
return None
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."""
if since is None:
return sorted(path for path in corpus.rglob("*") if path.is_file())
roots = [
path
for path in corpus.iterdir()
if path.is_dir() and (day := _day_of(path, corpus)) is not None and day >= since
]
return sorted(path for root in roots for path in root.rglob("*") if path.is_file())
return corpus_files(corpus, since)
def _scan_one(args: tuple[str, str, str]) -> dict[str, str]:
@@ -191,7 +194,8 @@ def _scan_one(args: tuple[str, str, str]) -> dict[str, str]:
path = Path(path_str)
suffix = path.suffix.lower()
try:
rep = identify(path, check_visible=False, check_invisible=False)
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)
@@ -199,7 +203,15 @@ def _scan_one(args: tuple[str, str, str]) -> dict[str, str]:
row["error"] = f"{type(exc).__name__}: {exc}"[:300].replace("\n", " ")
else:
row = _row(rep, path=relative_path, suffix=suffix, lib_version=lib_version)
hits = _marker_hits_for_path(path)
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
@@ -223,20 +235,18 @@ def _read_checkpoint(path: Path) -> dict[str, dict[str, str]]:
row = json.loads(line)
except (json.JSONDecodeError, UnicodeDecodeError):
continue
if isinstance(row, dict) and isinstance(row.get("path"), str):
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."""
if not path.exists():
return
with path.open("rb+") as stream:
data = stream.read()
if data and not data.endswith(b"\n"):
last_newline = data.rfind(b"\n")
stream.truncate(last_newline + 1)
repair_jsonl_tail(path)
def _summarize(rows: list[dict[str, str]]) -> None:
+31
View File
@@ -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 ""
+6 -9
View File
@@ -24,6 +24,8 @@ 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"
@@ -92,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:
+73
View File
@@ -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())
+371
View File
@@ -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()
+296
View File
@@ -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()
+5 -2
View File
@@ -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
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
{
"name": "remove-ai-watermarks",
"displayName": "Remove AI Watermarks",
"version": "1.0.8",
"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",
+1 -1
View File
@@ -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.8"
version: "1.0.9"
homepage: https://raiw.cc
repository: https://github.com/wiltodelta/remove-ai-watermarks
---
@@ -89,11 +89,15 @@ 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
+1 -1
View File
@@ -32,7 +32,7 @@ _GPU_MARKERS = ("GPU ", "gpu:")
# 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, 0)
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.
+9 -1
View File
@@ -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.40.2"
__version__ = "0.41.0"
__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",
+3 -1
View File
@@ -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,
)
@@ -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,11 +115,68 @@ 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
@@ -128,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 = {
@@ -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)
@@ -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.
@@ -122,7 +121,7 @@ QWEN_ZIMAGE_MICROSOFT_STRENGTH = 0.15
# 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
@@ -165,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
@@ -177,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,
@@ -209,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,
}
@@ -228,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
@@ -247,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
@@ -273,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.
@@ -288,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")
@@ -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}")
+4 -5
View File
@@ -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."
),
)
+23 -16
View File
@@ -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 (
@@ -631,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)"),
@@ -646,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)"),
)
@@ -743,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:
@@ -1178,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)
@@ -1189,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
@@ -1216,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)
)
+13 -7
View File
@@ -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:
@@ -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,
)
+1 -9
View File
@@ -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.
+238
View File
@@ -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)
+4
View File
@@ -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):
+1 -1
View File
@@ -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
+3 -2
View File
@@ -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
+85 -9
View File
@@ -46,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"
@@ -646,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"
@@ -664,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)
@@ -677,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)"
@@ -687,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 ────────────────────────────
+19 -7
View File
@@ -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
+37
View File
@@ -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 = {
+128
View File
@@ -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
+2 -1
View File
@@ -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
@@ -278,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
+8 -16
View File
@@ -2,29 +2,21 @@
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:
publish = _publish()
from remove_ai_watermarks.classify import _WEIGHT_FILES, CLIP_ONNX_FILE, WEIGHTS_ALLOW_PATTERNS
assert publish.CLIP_FILE in publish.WEIGHT_FILES
@@ -41,7 +33,7 @@ def test_weight_paths_accepts_run1_layout(tmp_path: Path) -> None:
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"
@@ -50,12 +42,12 @@ 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()
+37
View File
@@ -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
+150 -94
View File
@@ -996,6 +996,70 @@ def test_sdxl_zimage_inherits_the_face_stage_rather_than_copying_it():
assert SdxlZImagePipeline._load_global is not QwenZImagePipeline._load_global
@pytest.mark.parametrize(
("pipeline_module", "pipeline_class", "grid"),
[
("sdxl_zimage_pipeline", "SdxlZImagePipeline", 8),
("chroma_zimage_pipeline", "ChromaZImagePipeline", 16),
],
)
def test_diffusers_vae_roundtrip_uses_deterministic_mode_and_restores_native_size(
monkeypatch, pipeline_module, pipeline_class, grid
):
"""Diffusers donors pad without distorting geometry and never sample."""
import importlib
import torch
seen: dict[str, object] = {}
padded_width = 40 if grid == 8 else 48
padded_height = 24 if grid == 8 else 32
latent = torch.ones(1, 4, padded_height // 8, padded_width // 8)
class LatentDistribution:
def mode(self):
seen["mode_calls"] = int(seen.get("mode_calls", 0)) + 1
return latent
def sample(self, *_args, **_kwargs):
raise AssertionError("A verified-text donor must not sample VAE latents")
class FakeVae:
@staticmethod
def encode(tensor):
seen["encoded_shape"] = tuple(tensor.shape)
return type("Encoded", (), {"latent_dist": LatentDistribution()})()
@staticmethod
def decode(received, *, return_dict):
assert received is latent
assert return_dict is False
return (torch.zeros(1, 3, padded_height, padded_width),)
class FakeProcessor:
@staticmethod
def preprocess(image, *, height, width):
seen["preprocess"] = (image.size, height, width)
return torch.zeros(1, 3, height, width)
@staticmethod
def postprocess(decoded, *, output_type):
assert output_type == "pil"
return [Image.new("RGB", (decoded.shape[3], decoded.shape[2]))]
pipe = type("FakePipe", (), {"vae": FakeVae(), "image_processor": FakeProcessor()})()
module = importlib.import_module(f"remove_ai_watermarks._internal.{pipeline_module}")
pipeline = getattr(module, pipeline_class)(device="cpu", torch_dtype=torch.float32)
monkeypatch.setattr(pipeline, "_load_global", lambda: pipe)
result = pipeline._vae_roundtrip(Image.new("RGB", (35, 19)))
assert seen["preprocess"] == ((padded_width, padded_height), padded_height, padded_width)
assert seen["encoded_shape"] == (1, 3, padded_height, padded_width)
assert seen["mode_calls"] == 1
assert result.size == (35, 19)
def test_chroma_zimage_strength_uses_measured_flat_floors():
"""The chroma floors come from the four-cohort oracle calibration; they must not
drift from their derivation in watermark_profiles."""
@@ -1015,7 +1079,7 @@ def test_chroma_zimage_strength_uses_measured_flat_floors():
assert resolve_strength(None, "microsoft", "chroma-zimage") == pytest.approx(CHROMA_ZIMAGE_MICROSOFT_STRENGTH)
assert pytest.approx(0.125) == CHROMA_ZIMAGE_MICROSOFT_STRENGTH
assert resolve_strength(None, "google", "chroma-zimage") == pytest.approx(CHROMA_ZIMAGE_GOOGLE_STRENGTH)
assert pytest.approx(0.40) == CHROMA_ZIMAGE_GOOGLE_STRENGTH
assert pytest.approx(0.50) == CHROMA_ZIMAGE_GOOGLE_STRENGTH
assert resolve_strength(None, "meta", "chroma-zimage") == pytest.approx(CHROMA_ZIMAGE_META_STRENGTH)
assert pytest.approx(0.17) == CHROMA_ZIMAGE_META_STRENGTH
# Unknown follows the strictest measured cohort, as with the other profiles.
@@ -1163,8 +1227,9 @@ def test_chroma_zimage_floors_to_its_own_latent_grid():
assert chroma_target_size(3, 3) == (16, 16)
def test_watermark_remover_accepts_text_manifest_for_chroma_but_not_sdxl(tmp_path, monkeypatch):
"""Chroma owns a VAE donor; SDXL still fails at the public boundary."""
@pytest.mark.parametrize("profile", ["chroma-zimage", "sdxl-zimage"])
def test_watermark_remover_accepts_text_manifest_for_every_diffusers_profile(tmp_path, monkeypatch, profile):
"""Both Diffusers profiles forward reviewed text to their VAE donor."""
from remove_ai_watermarks._internal.text_restoration import VerifiedTextLine, VerifiedTextManifest
from remove_ai_watermarks._internal.watermark_remover import WatermarkRemover
@@ -1173,22 +1238,18 @@ def test_watermark_remover_accepts_text_manifest_for_chroma_but_not_sdxl(tmp_pat
Image.new("RGB", (96, 80)).save(source)
manifest = VerifiedTextManifest("0" * 64, 96, 80, (VerifiedTextLine((4, 4, 20, 16), "x", "alphabetic"),))
chroma = WatermarkRemover(device="cuda", pipeline="chroma-zimage")
chroma_runtime = MagicMock()
chroma_runtime.run.return_value = Image.new("RGB", (96, 80))
monkeypatch.setattr(chroma, "_load_qwen_zimage_pipeline", lambda: chroma_runtime)
remover = WatermarkRemover(device="cuda", pipeline=profile)
runtime = MagicMock()
runtime.run.return_value = Image.new("RGB", (96, 80))
monkeypatch.setattr(remover, "_load_qwen_zimage_pipeline", lambda: runtime)
chroma.remove_watermark(source, text_manifest=manifest)
remover.remove_watermark(source, text_manifest=manifest)
assert chroma_runtime.run.call_count == 1
assert chroma_runtime.run.call_args.kwargs["text_manifest"] is manifest
assert runtime.run.call_count == 1
assert runtime.run.call_args.kwargs["text_manifest"] is manifest
with pytest.raises(ValueError, match=r"fidelity anchor.*qwen-zimage"):
chroma.remove_watermark(source, text_manifest=manifest, fidelity_anchor=True)
assert chroma_runtime.run.call_count == 1
sdxl = WatermarkRemover(device="cuda", pipeline="sdxl-zimage")
with pytest.raises(ValueError, match="not supported by the sdxl-zimage profile"):
sdxl.remove_watermark(source, text_manifest=manifest)
remover.remove_watermark(source, text_manifest=manifest, fidelity_anchor=True)
assert runtime.run.call_count == 1
def test_watermark_remover_dispatches_to_chroma_pipeline(monkeypatch):
@@ -1229,95 +1290,32 @@ def test_chroma_adaptive_polish_defaults_off():
assert resolve_adaptive_polish(True, "chroma-zimage") is True
def test_chroma_vae_roundtrip_uses_deterministic_mode_and_restores_native_size(monkeypatch):
"""The Chroma donor pads without distorting geometry and never samples."""
import torch
def test_chroma_google_uses_one_content_agnostic_floor():
"""Face detection alone cannot safely select a lower Google floor.
from remove_ai_watermarks._internal.chroma_zimage_pipeline import ChromaZImagePipeline
seen: dict[str, object] = {}
latent = torch.ones(1, 4, 4, 6)
class LatentDistribution:
def mode(self):
seen["mode_calls"] = int(seen.get("mode_calls", 0)) + 1
return latent
def sample(self, *_args, **_kwargs):
raise AssertionError("A verified-text donor must not sample VAE latents")
class FakeVae:
@staticmethod
def encode(tensor):
seen["encoded_shape"] = tuple(tensor.shape)
return type("Encoded", (), {"latent_dist": LatentDistribution()})()
@staticmethod
def decode(received, *, return_dict):
assert received is latent
assert return_dict is False
return (torch.zeros(1, 3, 32, 48),)
class FakeProcessor:
@staticmethod
def preprocess(image, *, height, width):
seen["preprocess"] = (image.size, height, width)
return torch.zeros(1, 3, height, width)
@staticmethod
def postprocess(decoded, *, output_type):
assert output_type == "pil"
return [Image.new("RGB", (decoded.shape[3], decoded.shape[2]))]
pipe = type("FakePipe", (), {"vae": FakeVae(), "image_processor": FakeProcessor()})()
pipeline = ChromaZImagePipeline(device="cpu", torch_dtype=torch.float32)
monkeypatch.setattr(pipeline, "_load_global", lambda: pipe)
result = pipeline._vae_roundtrip(Image.new("RGB", (35, 19)))
assert seen["preprocess"] == ((48, 32), 32, 48)
assert seen["encoded_shape"] == (1, 3, 32, 48)
assert seen["mode_calls"] == 1
assert result.size == (35, 19)
def test_chroma_google_face_content_gets_the_lower_adaptive_floor():
"""Face content on Google SynthID clears at 0.125, not 0.40.
The four-fixture calibration showed a clean face-count split: both zero-face
text cards need 0.25, both face fixtures clear at 0.12. The adaptive arm
saves face identity (0.611 vs 0.279 cosine) and 6.4 dB PSNR on face content.
Fresh face-plus-text carriers required more denoise than the old face-only
fixtures, and the runtime has no measured text classifier. Keep every Google
carrier on the replicated global floor until a richer content gate exists.
"""
from remove_ai_watermarks._internal.watermark_profiles import (
CHROMA_ZIMAGE_GOOGLE_FACE_STRENGTH,
CHROMA_ZIMAGE_GOOGLE_STRENGTH,
resolve_strength,
)
assert pytest.approx(0.125) == CHROMA_ZIMAGE_GOOGLE_FACE_STRENGTH
# Face content gets the lower adaptive floor.
assert resolve_strength(None, "google", "chroma-zimage", face_count=17) == pytest.approx(0.125)
assert resolve_strength(None, "google", "chroma-zimage", face_count=1) == pytest.approx(0.125)
# Zero-face content gets the flat floor (text/graphic cohort).
assert resolve_strength(None, "google", "chroma-zimage", face_count=0) == pytest.approx(0.40)
assert pytest.approx(0.40) == CHROMA_ZIMAGE_GOOGLE_STRENGTH
# No face_count hint: conservative (flat floor).
assert resolve_strength(None, "google", "chroma-zimage") == pytest.approx(0.40)
# Other vendors ignore face_count: the split is Google-SynthID-specific.
assert resolve_strength(None, "openai", "chroma-zimage", face_count=5) == pytest.approx(0.20)
assert resolve_strength(None, "meta", "chroma-zimage", face_count=5) == pytest.approx(0.17)
# An explicit strength still wins over the adaptive arm.
assert resolve_strength(0.20, "google", "chroma-zimage", face_count=17) == pytest.approx(0.20)
# Other profiles are untouched by the face_count parameter.
assert resolve_strength(None, "google", "qwen-zimage", size=(2000, 1850), face_count=17) == pytest.approx(0.27)
assert pytest.approx(0.50) == CHROMA_ZIMAGE_GOOGLE_STRENGTH
assert resolve_strength(None, "google", "chroma-zimage") == pytest.approx(0.50)
assert resolve_strength(None, "openai", "chroma-zimage") == pytest.approx(0.20)
assert resolve_strength(None, "meta", "chroma-zimage") == pytest.approx(0.17)
# An explicit strength still wins over the default.
assert resolve_strength(0.20, "google", "chroma-zimage") == pytest.approx(0.20)
def test_auto_profile_routes_to_the_measured_engine_per_vendor():
"""--pipeline auto picks the engine the four-cohort calibration supports:
chroma-zimage for Microsoft, qwen-zimage for OpenAI/Google/Meta/unknown."""
"""--pipeline auto picks the best quality-qualified measured engine."""
from remove_ai_watermarks._internal.watermark_profiles import (
CHROMA_ZIMAGE_PROFILE,
QWEN_ZIMAGE_PROFILE,
SDXL_ZIMAGE_PROFILE,
normalize_profile,
resolve_auto_profile,
)
@@ -1325,12 +1323,21 @@ def test_auto_profile_routes_to_the_measured_engine_per_vendor():
assert normalize_profile("auto") == "auto"
assert resolve_auto_profile("openai") == QWEN_ZIMAGE_PROFILE
assert resolve_auto_profile("microsoft") == CHROMA_ZIMAGE_PROFILE
assert resolve_auto_profile("google") == QWEN_ZIMAGE_PROFILE
assert resolve_auto_profile("google") == SDXL_ZIMAGE_PROFILE
assert resolve_auto_profile("meta") == QWEN_ZIMAGE_PROFILE
assert resolve_auto_profile(None) == QWEN_ZIMAGE_PROFILE
assert resolve_auto_profile("unknown") == QWEN_ZIMAGE_PROFILE
def test_auto_strength_uses_the_effective_vendor_profile():
"""Displayed and executed auto strengths must use the same engine policy."""
from remove_ai_watermarks._internal.watermark_profiles import resolve_strength
assert resolve_strength(None, "google", "auto", size=(64, 48)) == pytest.approx(0.50)
assert resolve_strength(None, "microsoft", "auto", size=(64, 48)) == pytest.approx(0.125)
assert resolve_strength(None, "openai", "auto", size=(64, 48)) == pytest.approx(0.07675)
def test_auto_profile_strength_uses_the_resolved_engine_floors(tmp_path, monkeypatch):
"""Auto resolves OpenAI to Qwen and uses its oracle-verified 0.07675 floor."""
from remove_ai_watermarks._internal.watermark_remover import WatermarkRemover
@@ -1340,7 +1347,8 @@ def test_auto_profile_strength_uses_the_resolved_engine_floors(tmp_path, monkeyp
Image.new("RGB", (64, 48)).save(source)
remover = WatermarkRemover(device="cuda", pipeline="auto")
assert remover.model_profile == "auto"
assert remover.configured_profile == "auto"
assert remover.model_profile == "qwen-zimage"
runtime = MagicMock()
runtime.run.return_value = Image.new("RGB", (64, 48), (50, 60, 70))
@@ -1353,6 +1361,27 @@ def test_auto_profile_strength_uses_the_resolved_engine_floors(tmp_path, monkeyp
assert kwargs["strength"] == pytest.approx(0.07675)
def test_auto_google_switches_to_the_sdxl_floor_and_dtype(tmp_path, monkeypatch):
"""Auto must switch both policy and weights dtype when Google selects SDXL."""
from remove_ai_watermarks._internal import watermark_remover
from remove_ai_watermarks._internal.watermark_remover import WatermarkRemover
_mock_watermark_runtime_deps(monkeypatch)
source = tmp_path / "source.png"
Image.new("RGB", (64, 48)).save(source)
remover = WatermarkRemover(device="cuda", pipeline="auto")
runtime = MagicMock()
runtime.run.return_value = Image.new("RGB", (64, 48), (50, 60, 70))
monkeypatch.setattr(remover, "_load_qwen_zimage_pipeline", lambda: runtime)
remover.remove_watermark(source, vendor="google")
assert remover.model_profile == "sdxl-zimage"
assert remover.torch_dtype == watermark_remover.torch.float16
assert runtime.run.call_args.kwargs["strength"] == pytest.approx(0.50)
def test_auto_profile_text_manifest_uses_the_measured_qwen_engine_once(tmp_path, monkeypatch):
"""A manifest does not override the researched OpenAI-to-Qwen decision."""
from remove_ai_watermarks._internal.text_restoration import VerifiedTextLine, VerifiedTextManifest
@@ -1373,3 +1402,30 @@ def test_auto_profile_text_manifest_uses_the_measured_qwen_engine_once(tmp_path,
assert remover.model_profile == "qwen-zimage"
assert runtime.run.call_count == 1
assert runtime.run.call_args.kwargs["text_manifest"] is manifest
def test_auto_profile_is_resolved_fresh_for_each_image(tmp_path, monkeypatch):
"""A Google image must not pin the next OpenAI image to SDXL."""
from remove_ai_watermarks._internal.watermark_remover import WatermarkRemover
_mock_watermark_runtime_deps(monkeypatch)
source = tmp_path / "source.png"
Image.new("RGB", (64, 48)).save(source)
remover = WatermarkRemover(device="cuda", pipeline="auto")
runtimes = []
def load_runtime():
runtime = MagicMock()
runtime.run.return_value = Image.new("RGB", (64, 48))
runtimes.append(runtime)
return runtime
monkeypatch.setattr(remover, "_load_qwen_zimage_pipeline", load_runtime)
remover.remove_watermark(source, tmp_path / "google.png", vendor="google")
assert remover.model_profile == "sdxl-zimage"
assert runtimes[-1].run.call_args.kwargs["strength"] == pytest.approx(0.50)
remover.remove_watermark(source, tmp_path / "openai.png", vendor="openai")
assert remover.model_profile == "qwen-zimage"
assert runtimes[-1].run.call_args.kwargs["strength"] == pytest.approx(0.07675)
+97 -4
View File
@@ -12,6 +12,8 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
import corpus_gap_scan as gap
import sidecar_regression as regression
import visible_eval
import visible_positives as visible
def _report(**overrides):
@@ -70,22 +72,113 @@ def test_gap_checkpoint_keeps_complete_rows_and_ignores_a_torn_tail(tmp_path):
assert gap._read_checkpoint(checkpoint) == {row["path"]: row, second["path"]: second}
def test_visible_checkpoint_is_repaired_before_resume(tmp_path):
checkpoint = tmp_path / "visible.jsonl"
first = {"path": "/corpus/2026-09-10/first.png", "keys": [], "status": "ok"}
checkpoint.write_text(json.dumps(first) + '\n{"path":"torn', encoding="utf-8")
visible._repair_checkpoint(checkpoint)
with checkpoint.open("a", encoding="utf-8") as stream:
stream.write(json.dumps({"path": "/corpus/2026-09-10/second.png", "keys": [], "status": "ok"}) + "\n")
assert [row["path"] for row in visible._read_records(checkpoint)] == [
"/corpus/2026-09-10/first.png",
"/corpus/2026-09-10/second.png",
]
def test_visible_since_filter_uses_corpus_date_directories(tmp_path):
old = tmp_path / "2026-09-01" / "old.png"
current = tmp_path / "2026-09-10" / "current.png"
nested = tmp_path / "2026-09-10" / "nested" / "current.webp"
undated = tmp_path / "loose.png"
for path in (old, current, nested, undated):
path.parent.mkdir(parents=True, exist_ok=True)
path.touch()
assert visible._files(tmp_path, date(2026, 9, 10)) == [current, nested]
def test_visible_discovery_candidates_include_unmapped_provenance_and_unbiased_quiet_rows():
rows = [
{"path": "known.png", "keys": ["qwen"], "status": "ok", "uscc": "unmapped-a"},
{"path": "a.png", "keys": [], "status": "ok", "uscc": "unmapped-a"},
{"path": "b.png", "keys": [], "status": "ok", "platform": "Google Gemini"},
{"path": "c.png", "keys": [], "status": "ok"},
{"path": "bad.png", "keys": [], "status": "unreadable", "uscc": "unmapped-b"},
]
selected = visible._discovery_candidates(rows, per_cohort=2, random_quiet=10, seed=7)
assert {row["path"] for row in selected} == {"a.png", "b.png", "c.png"}
assert next(row for row in selected if row["path"] == "a.png")["discovery_strata"] == [
"tc260:unmapped-a",
"unbiased-quiet",
]
def test_visible_evaluation_inventory_follows_the_registry():
from remove_ai_watermarks.watermark_registry import mark_keys
assert tuple(mark_keys()) == visible_eval.MARKS
def test_gap_worker_error_is_a_complete_resumable_row(monkeypatch, tmp_path):
path = tmp_path / "bad.bin"
path.write_bytes(b"not an image")
calls = []
def fail(_path, **kwargs):
calls.append(kwargs)
def fail(_path):
calls.append(_path)
raise ValueError("broken\ninput")
monkeypatch.setattr(gap, "identify", fail)
monkeypatch.setattr(gap, "extract_provenance_evidence", fail)
row = gap._scan_one((str(path), "bad.bin", "1.2.3"))
assert set(row) == set(gap.REPORT_FIELDS)
assert row["candidate_classes"] == "identify_error"
assert row["error"] == "ValueError: broken input"
assert calls == [{"check_visible": False, "check_invisible": False}]
assert calls == [path]
def test_gap_worker_records_c2pa_review_fields(monkeypatch, tmp_path):
path = tmp_path / "signed.png"
path.touch()
evidence = SimpleNamespace(
c2pa_info={
"issuer": "Example signer",
"claim_generator": "Example generator",
"actions": "created",
"source_type": "trainedAlgorithmicMedia (AI-generated)",
"c2pa_validation_state": "Valid",
},
scan=b"c2pa",
)
monkeypatch.setattr(gap, "extract_provenance_evidence", lambda _path: evidence)
monkeypatch.setattr(
gap,
"identify_from_evidence",
lambda actual: (
SimpleNamespace(
is_ai_generated=True,
platform="Example",
confidence="high",
watermarks=["C2PA"],
signals=[SimpleNamespace(name="c2pa")],
integrity_clashes=[],
)
if actual is evidence
else None
),
)
row = gap._scan_one((str(path), "signed.png", "1.2.3"))
assert row["c2pa_issuer"] == "Example signer"
assert row["c2pa_claim_generator"] == "Example generator"
assert row["c2pa_actions"] == "created"
assert row["c2pa_source_type"] == "trainedAlgorithmicMedia (AI-generated)"
assert row["c2pa_validation"] == "Valid"
def test_gap_summary_does_not_report_a_negative_hidden_count(capsys):
+123
View File
@@ -0,0 +1,123 @@
"""Unit contracts for the lightweight source-pipeline runtime."""
from __future__ import annotations
import re
from hashlib import sha256
from typing import TYPE_CHECKING
import numpy as np
import pytest
from remove_ai_watermarks import source_classify
if TYPE_CHECKING:
from pathlib import Path
def test_model_identity_is_immutable() -> None:
assert re.fullmatch(r"[0-9a-f]{40}", source_classify.WEIGHTS_REVISION)
assert re.fullmatch(r"[0-9a-f]{64}", source_classify.MODEL_SHA256)
def _artifact(path: Path, *, activation: str = "gelu_exact", output_bias: tuple[float, ...] = (0.0, 0.0, 0.0)) -> None:
np.savez_compressed(
path,
classes=np.asarray(source_classify.CLASSES),
kind=np.asarray("fused"),
mean=np.zeros(source_classify.FEATURE_WIDTH),
scale=np.ones(source_classify.FEATURE_WIDTH),
margins=np.asarray((0.5, 0.5)),
layer0_weight=np.zeros((128, source_classify.FEATURE_WIDTH), dtype=np.float32),
layer0_bias=np.zeros(128, dtype=np.float32),
layer1_weight=np.zeros((64, 128), dtype=np.float32),
layer1_bias=np.zeros(64, dtype=np.float32),
output_weight=np.zeros((3, 64), dtype=np.float32),
output_bias=np.asarray(output_bias, dtype=np.float32),
activation=np.asarray(activation),
)
def test_loader_accepts_the_exact_pickle_free_schema(tmp_path: Path) -> None:
path = tmp_path / source_classify.MODEL_FILE
_artifact(path)
model = source_classify._load_model(path, expected_sha256=None)
assert model.mean.shape == (source_classify.FEATURE_WIDTH,)
assert model.margins.tolist() == pytest.approx([0.5, 0.5])
def test_loader_rejects_the_mislabeled_activation(tmp_path: Path) -> None:
path = tmp_path / source_classify.MODEL_FILE
_artifact(path, activation="gelu_tanh")
with pytest.raises(ValueError, match="activation must be gelu_exact"):
source_classify._load_model(path, expected_sha256=None)
def test_provider_threshold_mutation_changes_the_real_decision(tmp_path: Path) -> None:
path = tmp_path / source_classify.MODEL_FILE
_artifact(path, output_bias=(2.0, 0.0, 0.0))
model = source_classify._load_model(path, expected_sha256=None)
features = np.zeros(source_classify.FEATURE_WIDTH, dtype=np.float32)
scores = source_classify._scores(features, model)
accepted = source_classify._result(scores, np.asarray((1.5, 0.5), dtype=np.float32))
rejected = source_classify._result(scores, np.asarray((2.5, 0.5), dtype=np.float32))
assert accepted.label == "openai"
assert accepted.reason == "classified"
assert rejected.label == "unknown"
assert rejected.reason == "abstained"
def test_a_float32_threshold_tie_preserves_the_inclusive_training_decision() -> None:
scores = np.asarray((2.0, 0.0, 0.0), dtype=np.float32)
result = source_classify._result(scores, np.asarray((2.0, 0.5), dtype=np.float32))
assert result.label == "openai"
assert result.reason == "classified"
def test_small_image_returns_honest_unknown(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
from PIL import Image
weights = tmp_path / "weights"
weights.mkdir()
_artifact(weights / source_classify.MODEL_FILE)
monkeypatch.setenv(source_classify.WEIGHTS_ENV, str(weights))
monkeypatch.setattr(
source_classify,
"MODEL_SHA256",
sha256((weights / source_classify.MODEL_FILE).read_bytes()).hexdigest(),
)
image = tmp_path / "small.png"
Image.new("RGB", (128, 128), "white").save(image)
result = source_classify.classify_source(image)
assert result.label == "unknown"
assert result.reason == "feature_unavailable"
assert result.scores == {}
def test_model_hash_is_a_runtime_precondition(tmp_path: Path) -> None:
path = tmp_path / source_classify.MODEL_FILE
_artifact(path)
with pytest.raises(ValueError, match="model SHA-256 mismatch"):
source_classify._load_model(path, expected_sha256="0" * 64)
def test_spectral_feature_is_finite_and_has_the_frozen_width() -> None:
from PIL import Image
from remove_ai_watermarks._internal.source_spectral import feature_from_image, make_geometry
rng = np.random.default_rng(20260916)
pixels = rng.integers(0, 256, size=(300, 400, 3), dtype=np.uint8)
feature = feature_from_image(Image.fromarray(pixels), make_geometry())
assert feature.shape == (768,)
assert np.all(np.isfinite(feature))
+119
View File
@@ -0,0 +1,119 @@
"""Tests for the abstaining source-pipeline classifier research tool."""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pytest
from click.testing import CliRunner
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
import source_pipeline_classifier as classifier
def test_command_help_exits_cleanly() -> None:
result = CliRunner().invoke(classifier.main, ["--help"])
assert result.exit_code == 0
assert "Usage:" in result.output
assert "train" in result.output
assert "predict" in result.output
def _training_rows() -> tuple[np.ndarray, np.ndarray]:
rng = np.random.default_rng(20260915)
rows = np.vstack(
(
rng.normal((-3.0, 0.0, 0.0), 0.1, size=(12, 3)),
rng.normal((3.0, 0.0, 0.0), 0.1, size=(12, 3)),
rng.normal((0.0, 3.0, 0.0), 0.1, size=(12, 3)),
)
)
labels = np.repeat(np.arange(3), 12)
return rows, labels
def test_fit_and_abstention_separate_provider_rows() -> None:
features, labels = _training_rows()
model = classifier.fit_ridge(features, labels, penalty=1.0)
raw_scores = classifier.scores(features, model)
margin = classifier.calibrate_margin(raw_scores, labels, unknown_fpr=0.01)
predictions = classifier.predict_scores(raw_scores, margin)
assert np.array_equal(predictions[:24], labels[:24])
assert np.all(predictions[24:] == classifier.UNKNOWN_INDEX)
def test_model_round_trip_is_pickle_free_and_strict(tmp_path: Path) -> None:
grid = 4
feature_count = classifier.FEATURE_WIDTH + 3 * grid * grid
model = classifier.RidgeModel(
mean=np.arange(feature_count, dtype=np.float64),
scale=np.full(feature_count, 2.0),
weights=np.arange(feature_count * 3, dtype=np.float64).reshape(feature_count, 3),
margin=0.25,
grid=grid,
penalty=100.0,
)
path = tmp_path / "model.npz"
classifier.save_model(path, model)
loaded = classifier.load_model(path)
assert loaded.grid == grid
assert loaded.margin == model.margin
assert np.array_equal(loaded.mean, model.mean)
assert np.array_equal(loaded.scale, model.scale)
assert np.array_equal(loaded.weights, model.weights)
with np.load(path, allow_pickle=False) as payload:
assert payload.files
def test_model_loader_rejects_extra_fields(tmp_path: Path) -> None:
path = tmp_path / "model.npz"
np.savez(
path,
classes=np.asarray(classifier.CLASSES),
mean=np.zeros(136),
scale=np.ones(136),
weights=np.zeros((136, 3)),
margin=np.asarray(0.1),
spectral_grid=np.asarray(2),
penalty=np.asarray(100.0),
unexpected=np.asarray(1),
)
with pytest.raises(ValueError, match="model fields must be exactly"):
classifier.load_model(path)
def test_model_loader_rejects_nonfinite_margin(tmp_path: Path) -> None:
grid = 2
feature_count = classifier.FEATURE_WIDTH + 3 * grid * grid
path = tmp_path / "model.npz"
np.savez(
path,
classes=np.asarray(classifier.CLASSES),
mean=np.zeros(feature_count),
scale=np.ones(feature_count),
weights=np.zeros((feature_count, 3)),
margin=np.asarray(np.nan),
spectral_grid=np.asarray(grid),
penalty=np.asarray(100.0),
)
with pytest.raises(ValueError, match="margin must be finite"):
classifier.load_model(path)
def test_calibration_requires_unknown_rows() -> None:
with pytest.raises(ValueError, match="must contain unknown"):
classifier.calibrate_margin(
np.zeros((2, 3)),
np.asarray([0, 1]),
unknown_fpr=0.01,
)
+81
View File
@@ -0,0 +1,81 @@
"""Tests for the phase-free spectral pipeline research harness."""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
from PIL import Image
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
import spectral_pipeline_probe as probe
def test_log_spectrum_discards_translation_phase() -> None:
rng = np.random.default_rng(17)
channel = rng.normal(size=(32, 32))
original = probe.phase_free_log_spectrum(channel)
translated = probe.phase_free_log_spectrum(np.roll(channel, shift=(5, -7), axis=(0, 1)))
assert np.allclose(original, translated, rtol=1e-12, atol=1e-12)
def test_spectral_feature_is_finite_and_has_declared_shape() -> None:
geometry = probe.make_geometry(size=32, grid=4)
yy, xx = np.mgrid[:32, :32]
carrier = np.cos(2 * np.pi * (5 * xx + 3 * yy) / 32)
pixels = np.stack((carrier, np.roll(carrier, 1, axis=0), np.roll(carrier, 2, axis=1)), axis=2)
feature = probe.spectral_feature(pixels, geometry)
assert feature.shape == (3 * 4 * 4,)
assert np.all(np.isfinite(feature))
assert np.linalg.norm(feature) > 0
def test_discovery_keeps_only_stems_shared_by_every_class(tmp_path: Path) -> None:
for class_name in ("first", "second"):
directory = tmp_path / class_name
directory.mkdir()
for stem in ("001", "002"):
Image.new("RGB", (8, 8), "white").save(directory / f"{stem}.png")
Image.new("RGB", (8, 8), "black").save(tmp_path / "first" / "unpaired.png")
paths, labels, groups, names = probe.discover_grouped_paths(tmp_path, ("first", "second"))
assert names == ["001", "002"]
assert [path.stem for path in paths] == ["001", "002", "001", "002"]
assert labels.tolist() == [0, 0, 1, 1]
assert groups.tolist() == [0, 1, 0, 1]
def test_grouped_ridge_never_trains_on_the_test_group() -> None:
groups = np.tile(np.arange(14), 3)
labels = np.repeat(np.arange(3), 14)
class_centers = np.eye(3)[labels] * 8.0
group_nuisance = np.column_stack((groups % 2, groups % 3, groups % 5)) * 0.01
features = class_centers + group_nuisance
models = probe.fit_grouped_models(features, labels, groups, class_count=3, folds=5, ridge=1.0)
predictions = probe.predict_grouped(models, features)
assert np.array_equal(predictions, labels)
fold_assignment = np.full(len(groups), -1)
for fold, model in enumerate(models):
assert not np.any(fold_assignment[model.test_rows] >= 0)
fold_assignment[model.test_rows] = fold
assert np.all(fold_assignment >= 0)
for group in np.unique(groups):
assert len(np.unique(fold_assignment[groups == group])) == 1
def test_confusion_matrix_uses_actual_rows_and_predicted_columns() -> None:
labels = np.asarray([0, 0, 1, 1, 2])
predictions = np.asarray([0, 1, 1, 2, 0])
matrix = probe.confusion_matrix(labels, predictions, class_count=3)
assert matrix.tolist() == [[1, 1, 0], [0, 1, 1], [1, 0, 0]]
Generated
+12 -3
View File
@@ -3449,7 +3449,7 @@ wheels = [
[[package]]
name = "remove-ai-watermarks"
version = "0.40.2"
version = "0.41.0"
source = { editable = "." }
dependencies = [
{ name = "c2pa-python" },
@@ -3584,6 +3584,13 @@ qwen-zimage = [
{ name = "torchvision" },
{ name = "transformers" },
]
source-classify = [
{ name = "huggingface-hub" },
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" },
{ name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" },
{ name = "opencv-python-headless", version = "4.11.0.86", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" },
{ name = "opencv-python-headless", version = "4.14.0.94", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" },
]
text-draft = [
{ name = "huggingface-hub" },
{ name = "paddleocr" },
@@ -3637,6 +3644,7 @@ requires-dist = [
{ name = "huggingface-hub", marker = "extra == 'dev'", specifier = ">=0.20.0" },
{ name = "huggingface-hub", marker = "extra == 'lama'", specifier = ">=0.20.0" },
{ name = "huggingface-hub", marker = "extra == 'migan'", specifier = ">=0.20.0" },
{ name = "huggingface-hub", marker = "extra == 'source-classify'", specifier = ">=0.20.0" },
{ name = "huggingface-hub", marker = "extra == 'text-draft'", specifier = ">=0.20.0" },
{ name = "invisible-watermark", marker = "extra == 'dev'", specifier = ">=0.2.0" },
{ name = "numpy", marker = "python_full_version >= '3.13' and extra == 'pixels'", specifier = ">=2" },
@@ -3665,10 +3673,11 @@ requires-dist = [
{ name = "remove-ai-watermarks", extras = ["pixels"], marker = "extra == 'classify'" },
{ name = "remove-ai-watermarks", extras = ["pixels"], marker = "extra == 'detect'" },
{ name = "remove-ai-watermarks", extras = ["pixels"], marker = "extra == 'diffusion'" },
{ name = "remove-ai-watermarks", extras = ["pixels"], marker = "extra == 'source-classify'" },
{ name = "remove-ai-watermarks", extras = ["pixels"], marker = "extra == 'visible'" },
{ name = "remove-ai-watermarks", extras = ["qwen-zimage", "lama"], marker = "extra == 'text-restoration'" },
{ name = "remove-ai-watermarks", extras = ["video"], marker = "extra == 'dev'" },
{ name = "remove-ai-watermarks", extras = ["video", "heif", "detect", "trustmark", "text-restoration", "migan", "classify"], marker = "extra == 'all'" },
{ name = "remove-ai-watermarks", extras = ["video", "heif", "detect", "trustmark", "text-restoration", "migan", "classify", "source-classify"], marker = "extra == 'all'" },
{ name = "remove-ai-watermarks", extras = ["visible"], marker = "extra == 'lama'" },
{ name = "remove-ai-watermarks", extras = ["visible"], marker = "extra == 'migan'" },
{ name = "remove-ai-watermarks", extras = ["visible"], marker = "extra == 'video'" },
@@ -3684,7 +3693,7 @@ requires-dist = [
{ name = "transformers", marker = "extra == 'diffusion'", specifier = ">=5,<6" },
{ name = "trustmark", marker = "python_full_version < '3.13' and extra == 'trustmark'", specifier = ">=0.8.0" },
]
provides-extras = ["pixels", "heif", "visible", "video", "detect", "diffusion", "qwen-zimage", "text-restoration", "text-draft", "trustmark", "classify", "classify-onnx", "lama", "migan", "dev", "all"]
provides-extras = ["pixels", "heif", "visible", "video", "detect", "diffusion", "qwen-zimage", "text-restoration", "text-draft", "trustmark", "classify", "classify-onnx", "source-classify", "lama", "migan", "dev", "all"]
[[package]]
name = "requests"