mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-10-18 23:37:14 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f83da5aee4 | ||
|
|
251ba9ac79 | ||
|
|
c5764661b4 | ||
|
|
97cba11efe | ||
|
|
809d980fb7 | ||
|
|
10da166f3e | ||
|
|
8871eb3f67 | ||
|
|
44ec203c22 | ||
|
|
4beb61463a | ||
|
|
0d11e4099c | ||
|
|
fa83c1444f | ||
|
|
f52e40ae24 | ||
|
|
0aa03c4e89 | ||
|
|
91aa49b03a | ||
|
|
ee865e95e4 | ||
|
|
d84fd1a125 | ||
|
|
e315eacb14 | ||
|
|
3ba4ace7e0 | ||
|
|
13888704f8 | ||
|
|
e938b57f8c | ||
|
|
3cd1e47935 | ||
|
|
2298282b8b | ||
|
|
42d427731f | ||
|
|
50d9f44829 | ||
|
|
2e1428406e | ||
|
|
21ce1dfadd | ||
|
|
2369d351a9 | ||
|
|
432b63b6d7 | ||
|
|
8c00525946 | ||
|
|
f8003f505a | ||
|
|
2eab24a2e1 | ||
|
|
8201ada070 | ||
|
|
b7d44ff1a3 | ||
|
|
e8f65022cc | ||
|
|
4855586834 | ||
|
|
37789e02f6 | ||
|
|
2d03a00a39 | ||
|
|
acbf6afac3 | ||
|
|
25eaa7458c | ||
|
|
58146dbc6d | ||
|
|
b08899dc00 | ||
|
|
b1319b1d5a | ||
|
|
353bc5f12c | ||
|
|
370556ffe6 | ||
|
|
14a27d224d | ||
|
|
015df40f49 | ||
|
|
f9beef365f | ||
|
|
9b656513f1 | ||
|
|
ab03ecc507 |
No files matched your search
@@ -98,6 +98,15 @@ record is byte-identical, and a green test suite does not establish that on its
|
||||
change that is meant to FIX detection is the exception that proves the rule: the diff
|
||||
must then be exactly the files you intended to change, named in advance.
|
||||
|
||||
Two corollaries in `dwt_dct.py`, where "close enough" has an exact meaning. The bit test
|
||||
is `peak % 36 > 18.0` and for uint8 input the exact Haar value is a multiple of 0.5, so it
|
||||
lands ON the threshold once per 72 blocks and a 1-ulp difference flips real bits: leave the
|
||||
transform to `pywt` however slow it looks, because its C convolution contracts into an FMA
|
||||
that numpy has no ufunc for (`docs/module-internals.md` carries what that costs). And
|
||||
`tests/test_invisible_watermark.py` is `skipif(not is_available())` -- without the `detect`
|
||||
extra the upstream-parity test never runs, so a green suite is not evidence here and the
|
||||
verdict record is not optional.
|
||||
|
||||
## A certified operating point is data, not a constant
|
||||
|
||||
The video SynthID default is only meaningful as a row in
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"WebSearch",
|
||||
"WebFetch"
|
||||
]
|
||||
},
|
||||
"enabledPlugins": {
|
||||
"pyright-lsp@claude-plugins-official": true,
|
||||
"context7@claude-plugins-official": true,
|
||||
"code-simplifier@claude-plugins-official": true,
|
||||
"claude-md-management@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
name: Distribute on release
|
||||
|
||||
# Fans a published GitHub Release out to the channels that need a nudge.
|
||||
# PyPI is handled by publish.yml; the conda-forge channel is handled by its
|
||||
# autotick bot. This workflow updates the repository recipe and event-drives
|
||||
# the three channels that would otherwise be manual:
|
||||
# PyPI is handled by publish.yml. This workflow event-drives the three channels
|
||||
# that would otherwise be manual:
|
||||
# - Homebrew tap: rewrite the formula's url + sha256 to the new sdist.
|
||||
# - HF Space: factory-rebuild so it reinstalls the latest sdist from PyPI.
|
||||
# - ComfyUI: sync, test, and publish the node against the exact library release.
|
||||
@@ -89,34 +88,6 @@ jobs:
|
||||
git commit -m "remove-ai-watermarks $VERSION" || { echo "Formula already current"; exit 0; }
|
||||
git push
|
||||
|
||||
conda-recipe:
|
||||
needs: resolve
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: main
|
||||
- name: Bump the repository conda recipe
|
||||
env:
|
||||
SHA: ${{ needs.resolve.outputs.sha }}
|
||||
VERSION: ${{ needs.resolve.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python scripts/sync_conda_recipe.py --version "$VERSION" --sha256 "$SHA"
|
||||
git diff --check
|
||||
if git diff --quiet -- packaging/conda/recipe.yaml; then
|
||||
echo "Conda recipe already current"
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add packaging/conda/recipe.yaml
|
||||
git commit -m "Sync conda recipe with v$VERSION"
|
||||
git push
|
||||
|
||||
hf-space:
|
||||
needs: resolve
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -28,13 +28,26 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Cross-OS x Python-floor/recent. The default-install surface only
|
||||
# needs the core deps plus dev tooling (no GPU extra): the model-running
|
||||
# paths are availability-gated and skip when torch/diffusers are absent,
|
||||
# so this matrix exercises metadata, identify, visible, and the cv2
|
||||
# eraser on every OS without pulling the heavy ML stack.
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
python-version: ["3.10", "3.12"]
|
||||
# Exercise every supported minor. The floor and latest minor run on all
|
||||
# operating systems; intermediate minors run on Linux to bound the
|
||||
# matrix without leaving a declared Python version untested.
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
python-version: "3.11"
|
||||
- os: macos-latest
|
||||
python-version: "3.11"
|
||||
- os: windows-latest
|
||||
python-version: "3.11"
|
||||
- os: ubuntu-latest
|
||||
python-version: "3.12"
|
||||
- os: ubuntu-latest
|
||||
python-version: "3.13"
|
||||
- os: ubuntu-latest
|
||||
python-version: "3.14"
|
||||
- os: macos-latest
|
||||
python-version: "3.14"
|
||||
- os: windows-latest
|
||||
python-version: "3.14"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
|
||||
+10
@@ -48,3 +48,13 @@ gfpgan/
|
||||
|
||||
# Local evaluation data
|
||||
.local-eval/
|
||||
|
||||
# Other harnesses' generated config. Claude's sync owns these; a committed copy
|
||||
# is a second source nobody reconciles. Anchored so a same-named source file
|
||||
# deeper in the tree is not caught.
|
||||
/.agents/
|
||||
/.codex/
|
||||
/sch_*.yaml
|
||||
/.opencode/
|
||||
/opencode.json
|
||||
/opencode.jsonc
|
||||
@@ -33,7 +33,7 @@ Optional features and installation groups are documented in [`docs/installation.
|
||||
|
||||
Command, gate, typing, and model-test invariants auto-load from [`.claude/rules/development.md`](.claude/rules/development.md). Environment recovery, CI behavior, and fixture policy live in [`docs/development.md`](docs/development.md).
|
||||
|
||||
Before a release, read [`docs/release-and-distribution.md`](docs/release-and-distribution.md). Keep the source-distribution public allowlist.
|
||||
Before a release, read [`docs/release-and-distribution.md`](docs/release-and-distribution.md). Treat the release as complete only after PyPI, Homebrew, the Hugging Face Space, and the ComfyUI Registry are verified; conda is not published. Keep the source-distribution public allowlist.
|
||||
|
||||
## Module architecture
|
||||
|
||||
|
||||
@@ -11,8 +11,9 @@ cleaning, directory batches, visible Sora, Veo, Seedance, Dola, Hailuo, and
|
||||
Kling mark removal, and oracle-certified VAE regeneration for video SynthID
|
||||
removal.
|
||||
|
||||
> Try it online at [raiw.cc](https://raiw.cc) if you do not want to install Python
|
||||
> or run diffusion models locally.
|
||||
> [raiw.cc](https://raiw.cc) runs this library as a hosted service, with the GPU
|
||||
> included and nothing to install. Visible mark and metadata removal are free
|
||||
> there; invisible watermark removal is paid.
|
||||
|
||||
[](https://pypi.org/project/remove-ai-watermarks/)
|
||||
[](https://pypi.org/project/remove-ai-watermarks/)
|
||||
@@ -43,6 +44,10 @@ removal.
|
||||
| Run visible, invisible, and metadata removal | `all` | Recommended |
|
||||
| Process a directory | `batch` | Depends on mode |
|
||||
|
||||
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.
|
||||
|
||||
## Installation modes
|
||||
|
||||
| Need | Install |
|
||||
@@ -53,12 +58,12 @@ removal.
|
||||
| Video SynthID removal | `remove-ai-watermarks[video,diffusion]` |
|
||||
| Torch-free DWT-DCT detection | `remove-ai-watermarks[detect]` |
|
||||
| Invisible image removal (needs CUDA) | `remove-ai-watermarks[qwen-zimage]` |
|
||||
| Every production feature | `remove-ai-watermarks[all]` |
|
||||
| Every production feature available on the active Python | `remove-ai-watermarks[all]` |
|
||||
|
||||
Lower-level and specialized extras include `pixels`, `heif`, `trustmark`,
|
||||
`migan`, `lama`, and `diffusion`. The
|
||||
[installation guide](docs/installation.md#feature-extras) documents their exact
|
||||
dependency composition and model requirements.
|
||||
dependency composition, Python compatibility, and model requirements.
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -106,7 +111,9 @@ The `video metadata` command does not transcode video or audio streams. Unlike
|
||||
the image command above, when `-o` is omitted it writes `<source>_clean` and
|
||||
preserves the original. MP4 and MOV
|
||||
inspection includes the native TC260 `AIGC` tag in
|
||||
`moov.udta.meta.keys/ilst`, including a `moov` placed after the media payload.
|
||||
`moov.udta.meta.keys/ilst`, including a `moov` placed after the media payload,
|
||||
plus the QuickTime-form `meta` variants Doubao's iOS export writes (a bare
|
||||
`meta` box as a direct `moov` child, and a keyless `hdlr=mdir` metadata list).
|
||||
MKV and WebM inspection reads the normative
|
||||
`Segment.Tags.Tag.SimpleTag` placement. AVI uses `LIST/INFO/AIGC`, while FLV
|
||||
uses `script.onMetaData.AIGC`. The non-ISOBMFF formats are remuxed with stream
|
||||
@@ -198,6 +205,20 @@ image came from an AI generator, add `--force`:
|
||||
remove-ai-watermarks invisible image.png -o clean.png --force
|
||||
```
|
||||
|
||||
Typography-heavy images can opt into the experimental verified-text post-pass.
|
||||
It accepts manually reviewed strings and line boxes or an operator-verified
|
||||
geometry-only manifest; it never treats raw OCR output as ground truth or runs
|
||||
automatically:
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[text-restoration]"
|
||||
remove-ai-watermarks invisible image.png -o clean.png \
|
||||
--text-manifest verified-lines.json --force
|
||||
```
|
||||
|
||||
See the [CLI guide](docs/cli.md#restore-operator-verified-text) for the manifest
|
||||
schema, compatibility restrictions, and oracle caveats.
|
||||
|
||||
See the [installation guide](docs/installation.md) for Homebrew, uv, optional
|
||||
features, and development setup.
|
||||
|
||||
|
||||
+4
-1
@@ -6,6 +6,7 @@ Tracked data is organized by purpose:
|
||||
data/
|
||||
fixtures/
|
||||
provenance/ Real format and provenance fixtures used by tests
|
||||
(source records live in fixtures/README.md)
|
||||
calibration/
|
||||
<vendor>/ Minimal controlled inputs needed to rebuild detector assets
|
||||
synthid/
|
||||
@@ -31,7 +32,9 @@ data/
|
||||
commands, hashes, and oracle verdicts instead of committing another corpus
|
||||
copy. A small curated before-and-after example may live in `docs/images/`
|
||||
when it is part of the public documentation.
|
||||
6. Runtime detector assets belong in `src/remove_ai_watermarks/assets/`.
|
||||
6. Keep third-party fixture license notices in `licenses/`, outside directories
|
||||
that tests enumerate as media inputs.
|
||||
7. Runtime detector assets belong in `src/remove_ai_watermarks/assets/`.
|
||||
Unregistered research candidates belong in
|
||||
`scripts/assets/visible-mark-candidates/` so they are not shipped in the
|
||||
wheel.
|
||||
|
||||
@@ -7,31 +7,394 @@ ground truth and instructions.
|
||||
|
||||
| Original | Provider | Content | Exercises |
|
||||
| --- | --- | --- | --- |
|
||||
| `ChatGPT Image May 31, 2026, 02_02_23 PM.png` | OpenAI | Light multilingual typography | Text preservation |
|
||||
| `ChatGPT Image May 31, 2026, 02_03_55 PM.png` | OpenAI | Multilingual typography | Text preservation |
|
||||
| `Gemini_Generated_Image_633uuy633uuy633u.png` | Google | Landscape with a Chinese sign | CJK text preservation |
|
||||
| `Gemini_Generated_Image_y48j3cy48j3cy48j.png` | Google | Portrait grid | Face identity and skin texture |
|
||||
|
||||
## Text ground truth
|
||||
|
||||
`ground-truth.json` contains hand-verified OCR for the two text-bearing
|
||||
`ground-truth.json` contains hand-verified OCR for the three text-bearing
|
||||
originals. To regenerate an OCR seed:
|
||||
|
||||
```bash
|
||||
uv run scripts/fidelity_metrics.py ocr \
|
||||
"data/synthid/originals/ChatGPT Image May 31, 2026, 02_03_55 PM.png" \
|
||||
"data/synthid/originals/ChatGPT Image May 31, 2026, 02_02_23 PM.png" \
|
||||
data/synthid/originals/Gemini_Generated_Image_633uuy633uuy633u.png \
|
||||
--langs en,ru,ch \
|
||||
--out data/evaluations/fidelity/ground-truth.json
|
||||
```
|
||||
|
||||
Verify and correct the generated text by hand before using it as ground truth.
|
||||
`text-lines.json` contains the verified per-line strings and source-space boxes
|
||||
used by the evaluation-only selective-restoration experiment. It is not an
|
||||
automatic scene-text annotation set.
|
||||
|
||||
`scripts/infer_text_lines.py` can generate a draft from source pixels without
|
||||
existing annotations. On the two posters it proposed 20 and 18 lines at the
|
||||
default threshold, but exact-text precision was only 90.0% and 94.4% because
|
||||
high-confidence OCR still dropped an English comma and replaced a Chinese comma
|
||||
with ASCII. Its output therefore requires manual verification of every line;
|
||||
`accepted` means crop-stable, not ground-truth-correct.
|
||||
|
||||
## Text-preservation benchmark
|
||||
|
||||
`text-preservation-2026-08-13.csv` records a fixed-seed comparison of the two
|
||||
current profiles and a global Z-Image Turbo prototype on all three text
|
||||
fixtures. Each candidate ran through the
|
||||
complete `visible -> invisible -> metadata` route with its profile default
|
||||
strength and adaptive-polish setting. The Z-Image prototype instead sweeps
|
||||
0.08, 0.10, 0.15, 0.20, and 0.25 without polish because its provider-specific
|
||||
removal floor is not known. The output hash identifies the exact bytes
|
||||
measured; generated outputs remain outside the repository.
|
||||
|
||||
The character-weighted added CER is 0.262 for `qwen-zimage` and 0.256 for
|
||||
`sdxl-zimage`. That 0.006 absolute difference is not a
|
||||
stable ordering: SDXL wins the light poster, Qwen wins the dark poster, and the
|
||||
Chinese sign is tied. A paired sign test on the two non-ties is 1-1 (`p=1.0`).
|
||||
The measured sample therefore does not support a general text-preservation
|
||||
winner. Both profiles substantially degrade the smallest multilingual poster
|
||||
text and preserve the larger Chinese sign.
|
||||
|
||||
Z-Image Turbo is the clear fidelity lead. At strength 0.10 its
|
||||
character-weighted CER is 0.093, against the unchanged sources' 0.124 OCR
|
||||
floor, while whole-image LPIPS stays between 0.047 and 0.086. Visual inspection
|
||||
still finds substitutions in the smallest Cyrillic and Chinese poster lines,
|
||||
so a favorable OCR score does not mean pixel-exact text. Quality drops quickly
|
||||
above 0.15; at 0.25 the weighted CER is 0.428.
|
||||
|
||||
Provider-oracle checks on 2026-08-13 bracket the OpenAI requirement at no more
|
||||
than 0.10 for this sample. Both original OpenAI controls were detected by
|
||||
`openai.com/verify`; the light poster was clean at 0.08, while the dark poster
|
||||
was detected at 0.08 and clean at 0.10. The light 0.10 output was not separately
|
||||
checked. Google fails the entire tested sweep. The original Gemini image was
|
||||
detected through C2PA, and a pixel-identical copy with AI metadata stripped was
|
||||
separately detected by Gemini's built-in SynthID verifier. Z-Image outputs at
|
||||
0.08, 0.10, 0.15, 0.20, and 0.25 were all still detected. At 0.25 the weighted
|
||||
CER has already risen to 0.428, worse than the raw weighted CER of both current
|
||||
profiles (Qwen 0.387, SDXL 0.381). Increasing strength beyond the measured grid
|
||||
would therefore no longer serve the text-preservation objective without a new
|
||||
mechanism or hypothesis. Z-Image is not a viable global replacement on this
|
||||
evidence: no clean Google operating point was found before it lost its fidelity
|
||||
advantage.
|
||||
|
||||
Qwen-Image-2.0 was not added to the numeric comparison. Its weights are not
|
||||
published, and its hosted edit API exposes an editing instruction and seed but
|
||||
no low-strength denoise control. It can be evaluated as a separate hosted edit
|
||||
strategy when credentials are available, but it is not a drop-in replacement
|
||||
for the partial-regeneration mechanism measured here.
|
||||
|
||||
The OCR floor is the source image scored against the hand-verified text. Use
|
||||
`added_cer = text_cer - ocr_floor` when interpreting pipeline damage, because the
|
||||
unchanged poster sources already score 0.127 CER. `oracle_rechecked=true` marks
|
||||
the exact Z-Image bytes checked above; the remaining rows were not rechecked.
|
||||
The table does not certify other seeds, content classes, or strengths beyond
|
||||
the recorded provider verdicts.
|
||||
|
||||
## Text-restoration prototype
|
||||
|
||||
`text-restoration-2026-08-13.csv` evaluates an OCR-driven post-pass on the exact
|
||||
Qwen outputs above. The prototype recognizes English and Russian with macOS
|
||||
Vision and CJK with PaddleOCR, derives glyph masks independently from the
|
||||
source and Qwen output, removes both sets of glyphs with block-wise LaMa, and
|
||||
draws the recognized strings with new system-font pixels. It never composites
|
||||
source pixels back into the result.
|
||||
|
||||
On the two multilingual posters, character-weighted CER fell from 0.338 and
|
||||
0.305 to 0.007 on both. OpenAI Verify reported no OpenAI signals for both Qwen
|
||||
controls and both restored outputs in the same run. The improvement comes with
|
||||
a substantial whole-image fidelity cost: LPIPS rose from 0.107 to 0.174 and
|
||||
from 0.095 to 0.162, while PSNR fell by about 10.5-11.4 dB. Visual inspection
|
||||
found one residual shadow in the smallest English line of the light poster;
|
||||
the dark poster was clean but the substitute fonts visibly changed typography.
|
||||
|
||||
The Chinese sign did not improve: CER rose from 0.074 to 0.111 because the OCR
|
||||
and renderer changed punctuation. The Gemini verifier returned detected for
|
||||
both the restored output and its byte-identical Qwen control on a third work
|
||||
account, although that Qwen hash had previously returned clean on another work
|
||||
account. This run therefore does not isolate a restoration-stage regression;
|
||||
its Google verdict is inconclusive until a source-positive, Qwen-negative
|
||||
control can be reproduced in the same available account.
|
||||
|
||||
This prototype is not ready to ship. Its strongest result establishes that
|
||||
fresh-glyph reconstruction can recover literal text without reintroducing an
|
||||
OpenAI signal, but portable OCR, font/style reconstruction, a tighter mask, and
|
||||
a reproducible Google oracle control remain prerequisites.
|
||||
|
||||
### Selective restoration follow-up
|
||||
|
||||
`selective-text-restoration-2026-08-13.csv` compares that full compositor with
|
||||
a selective prototype on the same two posters. The prototype leaves a Qwen line
|
||||
unchanged when padded source and output recognition agree, and applies the same
|
||||
LaMa plus fresh-system-font reconstruction only to lines whose recognized text
|
||||
changed. This reduced the edited area from 15.4% and 17.0% to 5.5% on both
|
||||
posters.
|
||||
|
||||
Under one consistent Paddle `en+ru+ch` measurement route, selective restoration
|
||||
reduced Qwen CER from 0.378 to 0.101 on the light poster and from 0.413 to 0.112
|
||||
on the dark poster. Its image LPIPS was 0.120 and 0.103, substantially closer to
|
||||
Qwen than the full compositor's 0.174 and 0.162. These CER values must not be
|
||||
mixed with the preceding table's Vision/Paddle hybrid values: the comparison
|
||||
file remeasures all three variants through Paddle so their relative result is
|
||||
valid on one OCR route.
|
||||
|
||||
OpenAI Verify returned `No OpenAI signals detected` for both selective outputs,
|
||||
then detected the original light poster as `Generated with OpenAI tools` in the
|
||||
same Chrome sequence. Visual inspection found the dark output clean, but the
|
||||
light output still retained a local shadow around one replaced fine-text line.
|
||||
Selective restoration is therefore the strongest current direction, not a
|
||||
production-ready default. The next implementation needs automatic line matching
|
||||
and a tighter source-plus-candidate glyph mask before it can be proposed for the
|
||||
pipeline.
|
||||
|
||||
A mask-only follow-up added two pixels of dilation around every selected glyph.
|
||||
It visually removed the light poster's shadow and improved its CER from 0.101 to
|
||||
0.081 with effectively unchanged LPIPS, but increased the edited area from 5.5%
|
||||
to 7.0%. Applying the same rule to the dark poster worsened CER from 0.112 to
|
||||
0.147. These expanded-mask rows were not resubmitted to the provider oracle.
|
||||
The opposing result rules out one global dilation constant and makes per-line
|
||||
residual detection the next fidelity gate.
|
||||
|
||||
An automatic follow-up removed the hand-selected line list. For each known
|
||||
source line, it runs a recognition-only model on padded, upscaled source and
|
||||
Qwen crops. It preserves a line only when both confidence scores are at least
|
||||
0.75, the source recognition is within 0.25 normalized edit distance of the
|
||||
verified line, and normalized source and candidate recognition match exactly.
|
||||
This reproduced the manual dark-poster line selection and additionally kept one
|
||||
correct large Russian line on the light poster.
|
||||
|
||||
The automatic prototype also replaced uniform dilation with a second measured
|
||||
LaMa pass. After the first erase, it extracts contrast remaining at the original
|
||||
glyph positions, dilates only that residual, and inpaints it before drawing fresh
|
||||
text. Visual inspection found no remaining double glyphs on either output. The
|
||||
effective edited fractions were 4.1% and 5.7%. Image LPIPS was 0.113 and 0.104,
|
||||
against 0.120 and 0.103 for the manual selective version. Detector-based page CER
|
||||
was 0.123 and 0.119; the light value is inflated by the detector splitting the
|
||||
preserved Cyrillic line into Latin-like fragments even though the padded Russian
|
||||
recognizer reads it exactly. This is another reason that line decisions use
|
||||
recognition-only crops rather than the page detector's aggregate CER.
|
||||
|
||||
Both exact automatic output hashes returned `No OpenAI signals detected`, and
|
||||
the original light poster returned `Generated with OpenAI tools` immediately
|
||||
afterward in the same Chrome sequence. The tracked script reproduced the dark
|
||||
hash exactly. Its light output differed from the initial prototype in only 386
|
||||
pixels by at most one channel value because separate ONNX environments rounded
|
||||
differently; the script's exact `900def5a...` hash was therefore submitted and
|
||||
also returned clean before the positive source control.
|
||||
|
||||
The script and its required verified line annotations are tracked as
|
||||
`scripts/selective_text_restoration.py` and `text-lines.json`. For example:
|
||||
|
||||
```bash
|
||||
PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK=True \
|
||||
uv run scripts/selective_text_restoration.py \
|
||||
data/synthid/originals/Gemini_Generated_Image_633uuy633uuy633u.png \
|
||||
path/to/qwen-candidate.png \
|
||||
--restoration vae-glyphs \
|
||||
--glyph-donor path/to/qwen-vae-reconstruction.png \
|
||||
--glyph-feather 0.5 \
|
||||
--output path/to/selective-output.png \
|
||||
--manifest path/to/manifest.json
|
||||
```
|
||||
|
||||
The restoration mode is deliberately explicit. `vae-glyphs` requires a
|
||||
separately generated VAE reconstruction in `--glyph-donor`; it takes exact
|
||||
thresholded glyph cores from that donor, uses a fresh silhouette beneath them,
|
||||
and defaults to a narrow 0.5-pixel donor edge. The donor and the resulting exact bytes still require
|
||||
full-pipeline oracle evaluation. `rerender` reproduces the tracked
|
||||
fresh-system-font experiment. `source-glyphs` is only for compositing a
|
||||
separately regenerated, oracle-evaluated layer; feeding it the watermarked
|
||||
original would paste provenance-bearing pixels back into the result. The
|
||||
`--keep-background --composite-mask boxes` combination is an aligned-layer
|
||||
experiment and is not a production text restorer.
|
||||
|
||||
The `source-silhouette` follow-up keeps the source glyph geometry but discards
|
||||
its pixel amplitudes: it thresholds each source line to a binary shape, samples
|
||||
one foreground color, and synthesizes new antialiasing over the scrubbed image.
|
||||
On a 24-image matrix spanning serif, Latin-diacritic, Cyrillic, CJK, tiny-UI,
|
||||
and rotated text, the original outer-feather compositor preserved all 34
|
||||
source-readable lines exactly under crop OCR, with median text-box SSIM 0.859.
|
||||
An inner-antialias variant raised median text-box SSIM to 0.902 and left
|
||||
whole-image SSIM unchanged at 0.672, but preserved 33/34 lines: OCR read one
|
||||
middle dot as a colon. The higher-fidelity antialiasing is retained in the
|
||||
evaluation script, with that punctuation miss recorded as a caveat. Its exact
|
||||
outputs were not submitted to the provider oracle because the
|
||||
public verifier reached its request limit; a clean verdict from the earlier
|
||||
outer-feather bytes does not transfer to the new hashes.
|
||||
|
||||
Follow-up visual review on a typography-rich control rejected both silhouette
|
||||
compositors despite their OCR and SSIM scores. They preserved literal content
|
||||
and approximate glyph geometry, but changed stroke weight, color variation,
|
||||
edge antialiasing, and small decorative details enough to be plainly different
|
||||
from the source. OCR exactness and text-box SSIM are therefore screening metrics,
|
||||
not acceptance gates for source-typography preservation. A candidate must also
|
||||
retain source-like edge pixels and pass direct visual comparison at native size.
|
||||
|
||||
A narrower Qwen-VAE donor follow-up keeps the scrubbed background, then copies
|
||||
only VAE-reconstructed pixels through the source-silhouette mask with a
|
||||
0.5-pixel feather. Across the 48-case typography matrix (548 annotated lines),
|
||||
text-box SSIM improved in 47/48 cases and glyph-edge error improved in 48/48.
|
||||
Median text-box SSIM rose from 0.854 to 0.914, while median glyph-edge MAE fell
|
||||
from 37.59 to 32.42. The median nonzero alpha area was 3.62%; on dense or large
|
||||
text this is still substantially wider than the target control. These are
|
||||
fidelity results only; the 48 matrix outputs were not submitted to the provider
|
||||
oracle. On a separate untracked dense-typography control, the exact core-only
|
||||
Qwen-VAE donor with a 1.43% source-silhouette area returned `No OpenAI signals
|
||||
detected`. Its 0.5-pixel feathered sibling, which raises the nonzero donor area
|
||||
to 2.75%, returned the same verdict and improved mean text-box SSIM from 0.957
|
||||
to 0.960. Crop OCR recovered from 7/15 exact lines on the raw pass to 14/15,
|
||||
matching the source OCR floor. Whole-image LPIPS was 0.082, but only 0.108% of
|
||||
pixels were exactly unchanged and the detected face retained 0.670 of source
|
||||
Laplacian variance. The tracked script reproduced the feathered file byte for
|
||||
byte. These two exact-byte verdicts do not certify other images or the larger
|
||||
matrix masks, and the global smoothing fails a strict unchanged-image criterion.
|
||||
|
||||
The opt-in production port was rechecked separately on 2026-08-15. Its current
|
||||
LaMa runtime did not reproduce the earlier evaluation PNG byte for byte, but all
|
||||
changed pixels were confined to the erased background outside the donor glyph
|
||||
core. The exact production artifact returned `No OpenAI signals detected` in
|
||||
3/3 OpenAI Verify runs, while the matched source control returned `Generated
|
||||
with OpenAI tools` in 2/2 runs in the same Chrome session; expanded details
|
||||
identified SynthID and no C2PA manifest on the control. The private control and
|
||||
artifact hashes remain outside the public repository. This certifies only that
|
||||
runtime, verified manifest, and output, not arbitrary text masks or images.
|
||||
|
||||
The Google result is negative. On the synthetic CJK sign case, two separate
|
||||
work-account runs both detected SynthID in the resaved source control and in the
|
||||
exact Qwen-VAE donor output. The candidate improved mean text-box SSIM from
|
||||
0.791 to 0.835 and glyph-edge MAE from 35.48 to 22.49 with a 3.67% donor area,
|
||||
but Gemini still detected it. The intermediate Qwen silhouette base and the
|
||||
earlier original-sign Qwen baseline were also detected in the same account.
|
||||
This isolates the current blocker upstream of text restoration: the global
|
||||
Google pass did not reach the SynthID removal floor, so the OpenAI-clean
|
||||
`vae-glyphs` configuration cannot be used as a Google operating point.
|
||||
|
||||
On the large Chinese-sign control, source and candidate recognition agreed on
|
||||
all three lines. The script selected no changed lines, emitted a zero mask, and
|
||||
copied the candidate byte for byte instead of re-encoding it. This corrects the
|
||||
earlier unnecessary CJK rerender and proves the no-edit branch. It does not add
|
||||
a Google-negative oracle result: the available Google account still cannot
|
||||
reproduce a source-positive, Qwen-negative control sequence.
|
||||
|
||||
The earlier automatic rerender was the first variant in the experiment to pass
|
||||
the recorded visual, fidelity, and OpenAI-oracle gates on its two poster
|
||||
fixtures. The later typography-rich control shows that result does not
|
||||
generalize to source-typography preservation. It remains evaluation-only: it
|
||||
depends on verified source text and source line boxes, uses macOS system fonts,
|
||||
and has not been validated on natural scene text, rotated text, or automatic
|
||||
line-box discovery.
|
||||
|
||||
An opt-in `--detect-boxes` follow-up tested automatic geometry. Grouping Paddle
|
||||
word detections by vertical overlap found exactly 20/20, 20/20, and 3/3 lines;
|
||||
mean IoU with verified boxes was 0.857, 0.847, and 1.000. Reusing the annotation
|
||||
crop padding was unstable and preserved only 4/20 dark-poster lines. Reducing
|
||||
vertical recognition padding to 10% restored the exact 8/20 and 7/20 selection
|
||||
decisions, but detector CER was 0.127 and 0.154 rather than 0.123 and 0.119. The
|
||||
dark regression failed the fidelity gate, so these hashes were not submitted to
|
||||
the provider oracle. The flag is retained only to reproduce that negative
|
||||
evaluation and still requires verified strings and an exact line-count match.
|
||||
|
||||
### AnyText2 glyph-conditioned follow-up
|
||||
|
||||
`anytext2-restoration-2026-08-13.csv` tests the official Apache-2.0
|
||||
AnyText2 checkpoint as a local text-editing pass over the exact Qwen Chinese-sign
|
||||
output. The checkpoint hash was verified against ModelScope. Its own edit example
|
||||
successfully replaced a masked blackboard line with the requested `DADDY`, so the
|
||||
runtime and checkpoint were functional before the tracked fixture was measured.
|
||||
|
||||
AnyText2 failed the fidelity gate. The standard full-image detector scored the
|
||||
default edit at CER 0.185 and the source-font mimic at 0.222, against 0.074 for
|
||||
Qwen; image LPIPS moved from 0.289 to 0.338 and 0.345. A padded crop-recognition
|
||||
cross-check removed the detector's punctuation misses: Qwen scored 0.000, the
|
||||
default edit still scored 0.074 with two substituted characters, and the mimic
|
||||
edit stayed at 0.222. Visual inspection agreed, with additional line-shape and
|
||||
punctuation errors in the mimic output. No provider-oracle submission was made
|
||||
because neither candidate preserved the requested content better than its
|
||||
already-clean Qwen input.
|
||||
|
||||
This result covers the published SD1.5 checkpoint, three Chinese lines of no more
|
||||
than 20 characters, one seed, and one fixture. The repository still describes
|
||||
AnyText2XL as unreleased, and the inference wrapper truncates each requested line
|
||||
after 20 characters, so the published model cannot directly cover the longer
|
||||
English and Russian poster lines.
|
||||
|
||||
The padded cross-check also changes the interpretation of this fixture's earlier
|
||||
restoration result. Its Qwen image already contains all three correct Chinese
|
||||
lines; the reported 0.074 came from the detector cropping the final punctuation
|
||||
from two lines. A deterministic rerender with horizontally padded recognition
|
||||
and a mask-matched Hiragino Sans GB W6 font reached detector CER 0.000, but image
|
||||
LPIPS worsened from 0.289 to 0.327 and the font was visibly heavier. The correct
|
||||
policy on this large-text fixture is therefore to leave a Qwen line unchanged
|
||||
when padded recognition confirms the source and candidate strings agree.
|
||||
|
||||
## Face-stage isolation
|
||||
|
||||
`face-stage-isolation-2026-08-13.csv` separates the two stages on the 18-face
|
||||
Gemini portrait-grid fixture. All variants use the same visible-cleaned,
|
||||
metadata-stripped input and seed 0. The prepared input remained positive in the
|
||||
Gemini SynthID verifier, so the surrounding visible and metadata stages did not
|
||||
create the clean verdicts.
|
||||
|
||||
| Route | Gemini oracle | ID cosine | Face LPIPS | Laplacian ratio | Image LPIPS |
|
||||
| --- | --- | ---: | ---: | ---: | ---: |
|
||||
| Prepared control | detected | - | - | - | - |
|
||||
| Z-Image face stage only | detected | 0.927 | 0.028 | 0.612 | 0.013 |
|
||||
| Qwen global stage only | clean | 0.589 | 0.217 | 0.331 | 0.288 |
|
||||
| Full Qwen then Z-Image | clean | 0.852 | 0.050 | 0.492 | 0.252 |
|
||||
|
||||
This isolates the roles: the Qwen global pass removes the watermark, while the
|
||||
masked Z-Image pass restores face identity and detail without reintroducing a
|
||||
detectable whole-image signal. Z-Image face-only does not remove SynthID. The
|
||||
result explains why the face stage succeeded while global Z-Image failed: the
|
||||
earlier clean verdict belonged to the two-stage pipeline, not to Z-Image as a
|
||||
standalone remover. This is one fixture, one seed, and one oracle pass per
|
||||
variant; it does not establish a general reintroduction threshold for mask size.
|
||||
|
||||
## Upstream Synthid-Bypass v2 reproduction
|
||||
|
||||
`upstream-v2-reproduction-2026-08-13.csv` records a source-level audit and a
|
||||
close reproduction of the public
|
||||
[`Synthid-Bypass-v2.0.json`](https://github.com/cebeuq/Synthid-Bypass/blob/3007d0351596ae0a78b7074dae7ad179710b1e48/Synthid-Bypass-v2.0.json).
|
||||
The audited workflow is SHA-256
|
||||
`41911b3b8e01bf51450361dc8beccd84c9513f78ddb160430cdd9bebc294adf6` at
|
||||
upstream commit `3007d0351596ae0a78b7074dae7ad179710b1e48`.
|
||||
|
||||
Tracing links backward from `SaveImage` confirms that the active global stage is
|
||||
Qwen-Image-2512 Q4 with the Lightning four-step LoRA at 0.8, DiffSynth Canny at
|
||||
1.0, AuraFlow shift 3, `dpmpp_2m` plus `sgm_uniform`, CFG 1, and four steps. Its
|
||||
resolution helper uses adaptive level 6 over 0.08..0.15, which resolves to the
|
||||
0.154 ceiling for the 2816x1536 portrait fixture. Z-Image Turbo is used only by
|
||||
the face detailer: eight steps, CFG 1, `res_2s` plus `bong_tangent`, a 768 px
|
||||
guide, 1024 px cap, and direct adaptive denoise
|
||||
`clamp(0.10 * largest_face_ratio / 0.03, 0.05, 0.28)`. The active face regions
|
||||
come from YOLOv8-face plus SAM. Although the README describes MediaPipe as part
|
||||
of the strict path, the MediaPipe nodes do not reach the saved output. The
|
||||
1.2-megapixel scaler is also bypassed (`mode=4`).
|
||||
|
||||
The published upstream pair 12 changed from SynthID-positive to clean in the
|
||||
Gemini verifier and scored 0.975 face identity. On the project portrait fixture,
|
||||
the prepared control was positive, while both the close global reproduction and
|
||||
the close full reproduction were clean. The global stage scored 0.589 identity
|
||||
and 0.217 face LPIPS; the upstream-strength face stage improved those to 0.783
|
||||
and 0.083. The maintained profile's weaker face pass scored 0.852 and 0.050 on
|
||||
the same control, so copying upstream's roughly doubled face strength would be a
|
||||
quality regression on this fixture.
|
||||
|
||||
The close reproduction is intentionally labeled rather than presented as an
|
||||
exact ComfyUI run. It uses full-precision DiffSynth bf16 weights instead of the
|
||||
Q4 GGUF files, the maintained DiffSynth Lightning scheduler approximation
|
||||
instead of the ComfyUI sampler pair, YuNet plus SAM instead of YOLOv8-face plus
|
||||
SAM, and fixed seed 0 instead of randomized seeds. The two independent oracle
|
||||
controls and the published upstream pair establish the direction of the result;
|
||||
they do not make the local output byte-equivalent to upstream.
|
||||
|
||||
## Compare
|
||||
|
||||
```bash
|
||||
uv run scripts/fidelity_metrics.py compare \
|
||||
--original data/synthid/originals/Gemini_Generated_Image_y48j3cy48j3cy48j.png \
|
||||
--variant controlnet=<out>.png \
|
||||
--variant qwen=<out>.png \
|
||||
--variant qwen-zimage=<qwen-out>.png \
|
||||
--variant sdxl-zimage=<sdxl-out>.png \
|
||||
--ocr-langs ""
|
||||
```
|
||||
@@ -0,0 +1,4 @@
|
||||
date,source_commit,fixture,variant,model,model_revision,model_sha256,ocr_route,text_cer,img_lpips,ssim,psnr_db,output_sha256,provider_oracle,notes
|
||||
2026-08-13,2d03a00,Gemini_Generated_Image_633uuy633uuy633u.png,qwen-zimage,,,,"Paddle ch",0.074,0.289,0.696,23.3,4b05489c7a79f993d35d32528de792baad5a3e33ee950afa147caf5d3e0621,not_run,Exact Qwen baseline reused from text-restoration benchmark
|
||||
2026-08-13,2d03a00,Gemini_Generated_Image_633uuy633uuy633u.png,qwen-zimage-anytext2-default,AnyText2-SD1.5,b06c583a583818f3679665ef67b51363f107853c,7d5d593928d842500e0da101ab6a1ae28a8d21403441ca62c813c4f92529ace8,"Paddle ch",0.185,0.338,0.642,18.2,87729fe7e0482d2cead6d3b7c7dd386ecc75ab3d8658131c54e67528b8f9feeb,not_run,Fidelity gate failed; padded crop recognition CER 0.074 still shows two substituted characters; locally composited three-line edit; 20 DDIM steps; seed 0
|
||||
2026-08-13,2d03a00,Gemini_Generated_Image_633uuy633uuy633u.png,qwen-zimage-anytext2-mimic,AnyText2-SD1.5,b06c583a583818f3679665ef67b51363f107853c,7d5d593928d842500e0da101ab6a1ae28a8d21403441ca62c813c4f92529ace8,"Paddle ch",0.222,0.345,0.644,17.9,f5198d8f2365b79880aba8ea6b0c3885142b9de21c9f7f4f6712ac4a0db8dd85,not_run,Fidelity gate failed; source glyph masks supplied as font hints; 20 DDIM steps; seed 0
|
||||
|
@@ -0,0 +1,5 @@
|
||||
date,source_commit,fixture,route,seed,global_strength,face_strength,face_count,id_cos,face_lpips,lapvar_ratio,img_lpips,ssim,psnr_db,output_sha256,gemini_oracle
|
||||
2026-08-13,2d03a00,Gemini_Generated_Image_y48j3cy48j3cy48j.png,visible-metadata-control,0,,,18,,,,,,,9f67264beb1da66a710885464a11f2ae85989094149f6145521d302cd2db940b,synthid_detected
|
||||
2026-08-13,2d03a00,Gemini_Generated_Image_y48j3cy48j3cy48j.png,zimage-face-only,0,,0.06604512532552083,18,0.927,0.028,0.612,0.013,0.959,34.7,c388e9bbf737bb9b22a7cb2216ffb8026321ca632f9db29e1e456b7c14a8553f,synthid_detected
|
||||
2026-08-13,2d03a00,Gemini_Generated_Image_y48j3cy48j3cy48j.png,qwen-global-only,0,0.153999996,,18,0.589,0.217,0.331,0.288,0.646,24.2,281a1d28d786d157cbbd13d00a32b215de4dfb3174a3c5e4e83c843e5402138f,clean
|
||||
2026-08-13,2d03a00,Gemini_Generated_Image_y48j3cy48j3cy48j.png,qwen-zimage-full,0,0.153999996,0.06604512532552083,18,0.852,0.050,0.492,0.252,0.671,24.7,69de191bddbfbf53348c7638bdba661b6d45d257c00a8a2646b2558670c5a4c2,clean
|
||||
|
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"ChatGPT Image May 31, 2026, 02_02_23 PM.png": "This is a longer sample text in English.\nTypography can flow smoothly from large to medium size.\nSmaller lines help demonstrate hierarchy, rhythm, and clarity.\nEven the finest text should remain clean and readable.\n这是一段较长的中文示例文本。\n排版可以从大字号逐渐过渡到中字号。\n更小的文字能够展示层次、节奏与清晰度。\n即使是最小的一行,也应该保持清楚易读。\nЭто более длинный пример текста на русском языке.\nТипографика может плавно переходить от крупного размера к среднему.\nБолее мелкие строки показывают иерархию, ритм и ясность.\nДаже самый маленький текст должен оставаться чистым и читаемым.",
|
||||
"ChatGPT Image May 31, 2026, 02_03_55 PM.png": "This is a longer sample text in English.\nTypography can flow smoothly from large to medium size.\nSmaller lines help demonstrate hierarchy, rhythm, and clarity.\nEven the finest text should remain clean and readable.\n这是一段较长的中文示例文本。\n排版可以从大字号逐渐过渡到中字号。\n更小的文字能够展示层次、节奏与清晰度。\n即使是最小的一行,也应该保持清楚易读。\nЭто более длинный пример текста на русском языке.\nТипографика может плавно переходить от крупного размера к среднему.\nБолее мелкие строки показывают иерархию, ритм и ясность.\nДаже самый маленький текст должен оставаться чистым и читаемым.",
|
||||
"Gemini_Generated_Image_633uuy633uuy633u.png": "每天都是一个新的机会。\n用微笑开始它。\n世界也会向你微笑。"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
date,source_commit,fixture,variant,ocr_route,text_cer,img_lpips,ssim,psnr_db,mask_fraction,output_sha256,provider_oracle,oracle_control
|
||||
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_02_23 PM.png",qwen-zimage,"Paddle en+ru+ch",0.378,0.107,0.832,24.4,,bed68cf79a023cad582a11b41c4c2962bd90a218037ce931a2b3a8697e3bf4bd,no_openai_signals_detected,source-positive control established earlier
|
||||
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_02_23 PM.png",qwen-zimage-text-restored,"Paddle en+ru+ch",0.048,0.174,0.703,13.9,0.154,3221b2694f39420d509dd37cdaf69581432f9a189e87397346de0c78762402c6,no_openai_signals_detected,qwen-zimage clean in same run
|
||||
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_02_23 PM.png",qwen-zimage-text-selective,"Paddle en+ru+ch",0.101,0.120,0.796,19.5,0.054799,15b40b8cdf9e97558c66d4acdcb97d46769e64f04f143f8061746bc731fbbc21,no_openai_signals_detected,source detected in same browser sequence
|
||||
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_02_23 PM.png",qwen-zimage-text-selective-expanded-mask,"Paddle en+ru+ch",0.081,0.119,0.796,19.5,0.070267,5a8a0bcbe5763d1b1af0d64149cec7e35b16c5751e995d3bacf17581d64177ec,not_checked,fidelity-only follow-up
|
||||
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_02_23 PM.png",qwen-zimage-text-selective-automatic,"Paddle detector en+ru+ch; padded recognizer selection",0.123,0.113,0.808,21.2,0.041279,900def5a433c039aa474789eddc4dc4c63b8126ba1aff6829d2e8d600b9f9c71,no_openai_signals_detected,source detected immediately afterward in the same Chrome sequence
|
||||
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_03_55 PM.png",qwen-zimage,"Paddle en+ru+ch",0.413,0.095,0.818,26.0,,83e777ad28bf80a8e5aa7d2e6ef2348337c667212e2c71075460352ad7423713,no_openai_signals_detected,source-positive control established earlier
|
||||
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_03_55 PM.png",qwen-zimage-text-restored,"Paddle en+ru+ch",0.086,0.162,0.682,14.6,0.170,95812399424feb70fc56d3e518d1ae0a3a50a3c2049d5902b00686ede9d3022d,no_openai_signals_detected,qwen-zimage clean in same run
|
||||
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_03_55 PM.png",qwen-zimage-text-selective,"Paddle en+ru+ch",0.112,0.103,0.780,20.8,0.054912,846f39db0ad4a48a734a5019f0b0ff5d110eecb3210223407e72529dfa4fcf54,no_openai_signals_detected,source detected in same browser sequence
|
||||
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_03_55 PM.png",qwen-zimage-text-selective-expanded-mask,"Paddle en+ru+ch",0.147,0.104,0.779,20.8,0.072556,8ae76e1dedeec26bebb8d35e71fd92d77f142dbb0f3440b330cdf33b79558afe,not_checked,fidelity-only follow-up
|
||||
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_03_55 PM.png",qwen-zimage-text-selective-automatic,"Paddle detector en+ru+ch; padded recognizer selection",0.119,0.104,0.779,20.8,0.057110,ac4201bd2e342b1a37737319438cedb2203f6bb58cd9113f825cf9b80d913755,no_openai_signals_detected,source detected in same browser sequence
|
||||
2026-08-13,2d03a00,Gemini_Generated_Image_633uuy633uuy633u.png,qwen-zimage-text-selective-automatic-passthrough,"Paddle detector ch; horizontal-only padded recognizer selection",0.074,0.289,0.696,23.3,0.000000,4b05489c7a79f993d35d32528de792baad5a3e33ee950afa147caf5d3e0621a6,not_checked,all three source and candidate recognitions agreed exactly; output is byte-identical to candidate
|
||||
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_02_23 PM.png",qwen-zimage-text-selective-detected-boxes,"Paddle ch detector boxes; 0.10-height recognizer padding",0.127,0.113,0.805,20.9,0.041280,70c52fed8b8a1d98f4cda8558ae08825313e510dfb372e448370d8dcf1da73b0,not_checked,fidelity-only automatic-box follow-up; same 8/20 preserve decisions
|
||||
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_03_55 PM.png",qwen-zimage-text-selective-detected-boxes,"Paddle ch detector boxes; 0.10-height recognizer padding",0.154,0.104,0.778,20.8,0.057146,1fa01ce706042c003a61429d5d97fc6c928ec32278071a91926d588f1e760b00,not_checked,fidelity-only automatic-box follow-up; same 7/20 preserve decisions but worse CER
|
||||
|
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"ChatGPT Image May 31, 2026, 02_02_23 PM.png": [
|
||||
{"box": [156, 206, 709, 296], "text": "This is a longer", "script": "alphabetic"},
|
||||
{"box": [157, 293, 939, 379], "text": "sample text in English.", "script": "alphabetic"},
|
||||
{"box": [165, 393, 774, 434], "text": "Typography can flow smoothly from", "script": "alphabetic"},
|
||||
{"box": [161, 434, 521, 477], "text": "large to medium size.", "script": "alphabetic"},
|
||||
{"box": [165, 495, 566, 522], "text": "Smaller lines help demonstrate hierarchy,", "script": "alphabetic"},
|
||||
{"box": [165, 524, 354, 550], "text": "rhythm, and clarity.", "script": "alphabetic"},
|
||||
{"box": [165, 564, 448, 585], "text": "Even the finest text should remain", "script": "alphabetic"},
|
||||
{"box": [165, 593, 330, 613], "text": "clean and readable.", "script": "alphabetic"},
|
||||
{"box": [158, 673, 903, 740], "text": "这是一段较长的中文示例文本。", "script": "cjk"},
|
||||
{"box": [162, 753, 697, 793], "text": "排版可以从大字号逐渐过渡到中字号。", "script": "cjk"},
|
||||
{"box": [164, 807, 524, 829], "text": "更小的文字能够展示层次、节奏与清晰度。", "script": "cjk"},
|
||||
{"box": [163, 845, 483, 868], "text": "即使是最小的一行,也应该保持清楚易读。", "script": "cjk"},
|
||||
{"box": [162, 951, 780, 1009], "text": "Это более длинный пример", "script": "alphabetic"},
|
||||
{"box": [161, 1019, 723, 1062], "text": "текста на русском языке.", "script": "alphabetic"},
|
||||
{"box": [165, 1076, 639, 1104], "text": "Типографика может плавно переходить от", "script": "alphabetic"},
|
||||
{"box": [165, 1111, 505, 1133], "text": "крупного размера к среднему.", "script": "alphabetic"},
|
||||
{"box": [165, 1151, 513, 1172], "text": "Более мелкие строки показывают иерархию,", "script": "alphabetic"},
|
||||
{"box": [167, 1178, 287, 1192], "text": "ритм и ясность.", "script": "alphabetic"},
|
||||
{"box": [165, 1210, 417, 1227], "text": "Даже самый маленький текст должен", "script": "alphabetic"},
|
||||
{"box": [165, 1235, 381, 1247], "text": "оставаться чистым и читаемым.", "script": "alphabetic"}
|
||||
],
|
||||
"ChatGPT Image May 31, 2026, 02_03_55 PM.png": [
|
||||
{"box": [165, 204, 754, 299], "text": "This is a longer", "script": "alphabetic"},
|
||||
{"box": [167, 295, 1006, 385], "text": "sample text in English.", "script": "alphabetic"},
|
||||
{"box": [171, 401, 831, 446], "text": "Typography can flow smoothly from", "script": "alphabetic"},
|
||||
{"box": [169, 446, 560, 491], "text": "large to medium size.", "script": "alphabetic"},
|
||||
{"box": [173, 507, 605, 536], "text": "Smaller lines help demonstrate hierarchy,", "script": "alphabetic"},
|
||||
{"box": [173, 538, 379, 567], "text": "rhythm, and clarity.", "script": "alphabetic"},
|
||||
{"box": [175, 583, 481, 603], "text": "Even the finest text should remain", "script": "alphabetic"},
|
||||
{"box": [173, 611, 352, 632], "text": "clean and readable.", "script": "alphabetic"},
|
||||
{"box": [170, 693, 956, 757], "text": "这是一段较长的中文示例文本。", "script": "cjk"},
|
||||
{"box": [174, 775, 740, 812], "text": "排版可以从大字号逐渐过渡到中字号。", "script": "cjk"},
|
||||
{"box": [175, 829, 561, 852], "text": "更小的文字能够展示层次、节奏与清晰度。", "script": "cjk"},
|
||||
{"box": [174, 869, 534, 892], "text": "即使是最小的一行,也应该保持清楚易读。", "script": "cjk"},
|
||||
{"box": [169, 958, 824, 1022], "text": "Это более длинный пример", "script": "alphabetic"},
|
||||
{"box": [171, 1029, 764, 1074], "text": "текста на русском языке.", "script": "alphabetic"},
|
||||
{"box": [175, 1090, 678, 1119], "text": "Типографика может плавно переходить от", "script": "alphabetic"},
|
||||
{"box": [175, 1125, 546, 1151], "text": "крупного размера к среднему.", "script": "alphabetic"},
|
||||
{"box": [175, 1169, 552, 1192], "text": "Более мелкие строки показывают иерархию,", "script": "alphabetic"},
|
||||
{"box": [175, 1196, 312, 1217], "text": "ритм и ясность.", "script": "alphabetic"},
|
||||
{"box": [175, 1233, 479, 1253], "text": "Даже самый маленький текст должен", "script": "alphabetic"},
|
||||
{"box": [177, 1259, 440, 1272], "text": "оставаться чистым и читаемым.", "script": "alphabetic"}
|
||||
],
|
||||
"Gemini_Generated_Image_633uuy633uuy633u.png": [
|
||||
{"box": [1281, 650, 2357, 818], "text": "每天都是一个新的机会。", "script": "cjk"},
|
||||
{"box": [1466, 788, 2167, 957], "text": "用微笑开始它。", "script": "cjk"},
|
||||
{"box": [1367, 910, 2264, 1112], "text": "世界也会向你微笑。", "script": "cjk"}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
date,source_commit,fixture,profile,route,seed,strength,adaptive_polish,text_cer,ocr_floor,added_cer,img_lpips,ssim,psnr_db,output_sha256,oracle_rechecked
|
||||
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_02_23 PM.png",original,reference,,,,0.127,0.127,0.000,0.000,1.000,inf,,not_applicable
|
||||
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_02_23 PM.png",qwen-zimage,visible-invisible-metadata,0,0.11020972541176471,false,0.378,0.127,0.251,0.107,0.832,24.4,bed68cf79a023cad582a11b41c4c2962bd90a218037ce931a2b3a8697e3bf4bd,false
|
||||
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_02_23 PM.png",sdxl-zimage,visible-invisible-metadata,0,0.15,true,0.356,0.127,0.229,0.125,0.707,25.3,9bb932d6e66b52f34ff07c3fcdbdb9c33eb601ae2cd84bec6b8c7b5e30df9926,false
|
||||
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_03_55 PM.png",original,reference,,,,0.127,0.127,0.000,0.000,1.000,inf,,not_applicable
|
||||
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_03_55 PM.png",qwen-zimage,visible-invisible-metadata,0,0.11020972541176471,false,0.413,0.127,0.286,0.095,0.818,26.0,83e777ad28bf80a8e5aa7d2e6ef2348337c667212e2c71075460352ad7423713,false
|
||||
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_03_55 PM.png",sdxl-zimage,visible-invisible-metadata,0,0.15,true,0.422,0.127,0.295,0.099,0.754,25.9,7605b1175b0c99f481ae6929acc488f34d98bc0f2571d42a1d2972b671d9b8cb,false
|
||||
2026-08-13,2d03a00,Gemini_Generated_Image_633uuy633uuy633u.png,original,reference,,,,0.037,0.037,0.000,0.000,1.000,inf,,not_applicable
|
||||
2026-08-13,2d03a00,Gemini_Generated_Image_633uuy633uuy633u.png,qwen-zimage,visible-invisible-metadata,0,0.153999996,false,0.074,0.037,0.037,0.289,0.696,23.3,4b05489c7a79f993d35d32528de792baad5a3e33ee950afa147caf5d3e0621a6,false
|
||||
2026-08-13,2d03a00,Gemini_Generated_Image_633uuy633uuy633u.png,sdxl-zimage,visible-invisible-metadata,0,0.25,true,0.074,0.037,0.037,0.176,0.769,26.1,d9f89465c31bd2d38ffb111775b9bbea4577f40713fcbebf421e05d344ab27ad,false
|
||||
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_02_23 PM.png",zimage-global,visible-invisible-metadata,0,0.08,false,0.068,0.127,-0.059,0.042,0.874,31.4,534de6422a3a03cfde44377aa9bf824a8267db3b73511240f8f07fb8dcb95eff,true
|
||||
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_02_23 PM.png",zimage-global,visible-invisible-metadata,0,0.10,false,0.086,0.127,-0.041,0.047,0.862,30.8,4dba8211e8d84ebbfff72de00f54d4365d0d202394acd412d1419ae93d1e9f71,false
|
||||
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_02_23 PM.png",zimage-global,visible-invisible-metadata,0,0.15,false,0.177,0.127,0.050,0.065,0.838,29.1,fc45c5fff6d33f15d30e05dd02580fe0addd97098b735b2894015f9abdaa43a7,false
|
||||
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_02_23 PM.png",zimage-global,visible-invisible-metadata,0,0.20,false,0.248,0.127,0.121,0.093,0.813,27.3,1e55e11f0e289a752098370e9f1d53fed6cd4fab7ceff46fe86dd20c927f9ffb,false
|
||||
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_02_23 PM.png",zimage-global,visible-invisible-metadata,0,0.25,false,0.343,0.127,0.216,0.120,0.787,25.7,f5a2d2829e88a790cff5bedc50fca1f878a92cdb392d530afb49d0d95c23b884,false
|
||||
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_03_55 PM.png",zimage-global,visible-invisible-metadata,0,0.08,false,0.143,0.127,0.016,0.042,0.877,31.5,5cfa466e276ab37cf532f7458d6dd86ea4f0807e2cbdb6788b7c01ca6ee35a1a,true
|
||||
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_03_55 PM.png",zimage-global,visible-invisible-metadata,0,0.10,false,0.101,0.127,-0.026,0.047,0.864,30.9,ade869ec7c5b08098495d9b9d67ee900773f189f94bbb411b043f242f5c2fa37,true
|
||||
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_03_55 PM.png",zimage-global,visible-invisible-metadata,0,0.15,false,0.208,0.127,0.081,0.060,0.839,29.3,a11976963e3f2ca856ce66278e22064d51345d449fa6d672029039a79517d60a,false
|
||||
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_03_55 PM.png",zimage-global,visible-invisible-metadata,0,0.20,false,0.341,0.127,0.214,0.070,0.819,27.6,1ab1ae075565e9d39960a80cf5d61e5b0c50d7e23283493ea0277056b3e964ab,false
|
||||
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_03_55 PM.png",zimage-global,visible-invisible-metadata,0,0.25,false,0.538,0.127,0.411,0.084,0.799,25.6,5ff5a364d84f738de4c56ead7d4950a2f9a1aaf4bec16e97bda6a65915744a57,false
|
||||
2026-08-13,2d03a00,Gemini_Generated_Image_633uuy633uuy633u.png,zimage-global,visible-invisible-metadata,0,0.08,false,0.074,0.037,0.037,0.068,0.865,30.8,81419eddc3e5150956859dd10c26dd841bb93d9c8b34c01f344e6d69bca6edbf,true
|
||||
2026-08-13,2d03a00,Gemini_Generated_Image_633uuy633uuy633u.png,zimage-global,visible-invisible-metadata,0,0.10,false,0.074,0.037,0.037,0.086,0.844,29.8,4d1f32bd0dac0e05885ff943e7c10230b06de7d8547e6215f8e2941f732a1df5,true
|
||||
2026-08-13,2d03a00,Gemini_Generated_Image_633uuy633uuy633u.png,zimage-global,visible-invisible-metadata,0,0.15,false,0.000,0.037,-0.037,0.135,0.793,27.6,526c7987b34896f9513f7439b63b8a87573b59d15930fa307f3bb924435f3cec,true
|
||||
2026-08-13,2d03a00,Gemini_Generated_Image_633uuy633uuy633u.png,zimage-global,visible-invisible-metadata,0,0.20,false,0.000,0.037,-0.037,0.179,0.746,25.9,325364cf7a3b5d39ac0c44af82181753ca4d6fde0f1b522c896414191725e8fc,true
|
||||
2026-08-13,2d03a00,Gemini_Generated_Image_633uuy633uuy633u.png,zimage-global,visible-invisible-metadata,0,0.25,false,0.000,0.037,-0.037,0.218,0.703,24.5,f01d64dc0cabd45d66a93c1be39b819b988f29cda1f0a7bc0d5a1954571d807e,true
|
||||
|
@@ -0,0 +1,7 @@
|
||||
date,source_commit,fixture,variant,ocr_route,text_cer,img_lpips,ssim,psnr_db,mask_fraction,output_sha256,provider_oracle,oracle_control
|
||||
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_02_23 PM.png",qwen-zimage,"Vision en+ru; Paddle ch",0.338,0.107,0.832,24.4,,bed68cf79a023cad582a11b41c4c2962bd90a218037ce931a2b3a8697e3bf4bd,no_openai_signals_detected,source-positive control established earlier
|
||||
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_02_23 PM.png",qwen-zimage-text-restored,"Vision en+ru; Paddle ch",0.007,0.174,0.703,13.9,0.154,3221b2694f39420d509dd37cdaf69581432f9a189e87397346de0c78762402c6,no_openai_signals_detected,qwen-zimage clean in same run
|
||||
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_03_55 PM.png",qwen-zimage,"Vision en+ru; Paddle ch",0.305,0.095,0.818,26.0,,83e777ad28bf80a8e5aa7d2e6ef2348337c667212e2c71075460352ad7423713,no_openai_signals_detected,source-positive control established earlier
|
||||
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_03_55 PM.png",qwen-zimage-text-restored,"Vision en+ru; Paddle ch",0.007,0.162,0.682,14.6,0.170,95812399424feb70fc56d3e518d1ae0a3a50a3c2049d5902b00686ede9d3022d,no_openai_signals_detected,qwen-zimage clean in same run
|
||||
2026-08-13,2d03a00,Gemini_Generated_Image_633uuy633uuy633u.png,qwen-zimage,"Paddle ch",0.074,0.289,0.696,23.3,,4b05489c7a79f993d35d32528de792baad5a3e33ee950afa147caf5d3e0621a6,synthid_detected,inconclusive account: clean baseline not reproduced
|
||||
2026-08-13,2d03a00,Gemini_Generated_Image_633uuy633uuy633u.png,qwen-zimage-text-restored,"Paddle ch",0.111,0.328,0.653,18.1,0.051,2ab7666a4161ff0e12c53d9412b23d013970c7613a8a55a1b1ae8a2744e3290d,synthid_detected,inconclusive account: qwen-zimage control also detected
|
||||
|
@@ -0,0 +1,6 @@
|
||||
date,upstream_commit,workflow_sha256,fixture,route,seed,global_strength,face_strength,face_count,id_cos,face_lpips,lapvar_ratio,img_lpips,ssim,psnr_db,output_sha256,gemini_oracle,implementation
|
||||
2026-08-13,3007d0351596ae0a78b7074dae7ad179710b1e48,41911b3b8e01bf51450361dc8beccd84c9513f78ddb160430cdd9bebc294adf6,comparison/before/12.png,official-before,,,,10,,,,,,,056276464387c883f5fd89bd2f1415faeb425abb3153cd10efb25d1b9053bdc2,synthid_detected,upstream-published-bytes
|
||||
2026-08-13,3007d0351596ae0a78b7074dae7ad179710b1e48,41911b3b8e01bf51450361dc8beccd84c9513f78ddb160430cdd9bebc294adf6,comparison/after/12.png,official-after,,,,10,0.975,0.093,0.354,0.172,0.820,23.8,a822d805666224adb3e360b3ee89e14f20a341f1e7c4d3a5d3d5251e9286aefe,clean,upstream-published-bytes
|
||||
2026-08-13,3007d0351596ae0a78b7074dae7ad179710b1e48,41911b3b8e01bf51450361dc8beccd84c9513f78ddb160430cdd9bebc294adf6,Gemini_Generated_Image_y48j3cy48j3cy48j.png,visible-metadata-control,0,,,18,,,,,,,9f67264beb1da66a710885464a11f2ae85989094149f6145521d302cd2db940b,synthid_detected,project-runtime-control
|
||||
2026-08-13,3007d0351596ae0a78b7074dae7ad179710b1e48,41911b3b8e01bf51450361dc8beccd84c9513f78ddb160430cdd9bebc294adf6,Gemini_Generated_Image_y48j3cy48j3cy48j.png,upstream-global-close-reproduction,0,0.153999996,,18,0.589,0.217,0.331,0.286,0.647,24.3,281a1d28d786d157cbbd13d00a32b215de4dfb3174a3c5e4e83c843e5402138f,clean,diffsynth-bf16-yunet-sam
|
||||
2026-08-13,3007d0351596ae0a78b7074dae7ad179710b1e48,41911b3b8e01bf51450361dc8beccd84c9513f78ddb160430cdd9bebc294adf6,Gemini_Generated_Image_y48j3cy48j3cy48j.png,upstream-full-close-reproduction,0,0.153999996,0.13209025065104166,18,0.783,0.083,0.470,0.260,0.659,24.7,2d7a8f76f98b905f14d69e5f9e11979610c72c6be7b6ac298dec4659f17bd819,clean,diffsynth-bf16-yunet-sam
|
||||
|
@@ -1,3 +1,3 @@
|
||||
date,source_url,source_sha256,source_width,source_height,source_fps,duration_seconds,source_verdict,vae,noise_std,long_side,fps,seed,output_sha256,output_verdict,output_verdict_text,output_detected_range,track,session_id,stratum,psnr_db,temporal_residual_ratio
|
||||
2026-07-31,https://storage.googleapis.com/gdm-deepmind-com-prod-public/media/media/veo__veo-3__off-road.mp4,79a552b9406a079682440c31f14d33a10ba8e1b8b2e96425f5de70f63350299d,,,,8,detected_all_frames,,0.10,512,12,0,079165105d4c56e1612091987c08c2627049423025f74c0d4e245fb47c2ff0e3,detected,,,,,,26.2932,1.0072
|
||||
2026-07-31,https://storage.googleapis.com/gdm-deepmind-com-prod-public/media/media/veo__veo-3__off-road.mp4,79a552b9406a079682440c31f14d33a10ba8e1b8b2e96425f5de70f63350299d,,,,8,detected_all_frames,,0.15,512,12,0,1c4046bcfdead138353b4e2a73339ba227bb5e544878d80c5bc6cd8427c7b00e,not_detected,,,,,,25.3911,1.0578
|
||||
2026-07-31,https://storage.googleapis.com/gdm-deepmind-com-prod-public/media/media/veo__veo-3__off-road.mp4,79a552b9406a079682440c31f14d33a10ba8e1b8b2e96425f5de70f63350299d,1920,1080,24,8,detected_all_frames,,0.10,512,12,0,079165105d4c56e1612091987c08c2627049423025f74c0d4e245fb47c2ff0e3,detected,,,,,,26.2932,1.0072
|
||||
2026-07-31,https://storage.googleapis.com/gdm-deepmind-com-prod-public/media/media/veo__veo-3__off-road.mp4,79a552b9406a079682440c31f14d33a10ba8e1b8b2e96425f5de70f63350299d,1920,1080,24,8,detected_all_frames,,0.15,512,12,0,1c4046bcfdead138353b4e2a73339ba227bb5e544878d80c5bc6cd8427c7b00e,not_detected,,,,,,25.3911,1.0578
|
||||
|
@@ -0,0 +1,15 @@
|
||||
# Provenance fixtures
|
||||
|
||||
These files exercise real container, metadata, and pixel-provenance formats.
|
||||
They are test inputs, not evaluation corpora.
|
||||
|
||||
## Adobe TrustMark
|
||||
|
||||
`adobe-trustmark-p.png` is Adobe's official `images/ghost_P.png` Variant P
|
||||
example from [`adobe/trustmark`](https://github.com/adobe/trustmark) at commit
|
||||
`0ed40cbe8188f664fd9cbbeacd969807de27440a`.
|
||||
|
||||
- Source: `https://raw.githubusercontent.com/adobe/trustmark/0ed40cbe8188f664fd9cbbeacd969807de27440a/images/ghost_P.png`
|
||||
- SHA-256: `e58c5825ed7e5d9fb04710ea541b61bd55879cad65554c0d46260aa24b3d0755`
|
||||
- Expected signal: TrustMark Variant P, schema 1
|
||||
- License: MIT, reproduced in `../licenses/adobe-trustmark-MIT.txt`
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 360 KiB |
@@ -0,0 +1,25 @@
|
||||
Copyright 2023 Adobe
|
||||
All Rights Reserved.
|
||||
|
||||
NOTICE: Adobe permits you to use, modify, and distribute this file in
|
||||
accordance with the terms of the license agreement accompanying it.
|
||||
|
||||
MIT License
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+66
-5
@@ -16,7 +16,7 @@ defaults. This page focuses on choosing the right command.
|
||||
| `metadata` and metadata-only `identify` | Default package |
|
||||
| Visible signals in `identify` | `remove-ai-watermarks[visible]` (`pixels` is the minimal runtime) |
|
||||
| Open DWT-DCT signals in `identify` | `remove-ai-watermarks[detect]` |
|
||||
| Adobe TrustMark signals in `identify` | `remove-ai-watermarks[trustmark]` |
|
||||
| Adobe TrustMark signals in `identify` on Python 3.11-3.12 | `remove-ai-watermarks[trustmark]` |
|
||||
| `visible` and `erase` with OpenCV | `remove-ai-watermarks[visible]` (`pixels` is the minimal runtime) |
|
||||
| `visible` or `erase` with MI-GAN | `remove-ai-watermarks[migan]` |
|
||||
| `visible` or `erase` with big-LaMa | `remove-ai-watermarks[lama]` |
|
||||
@@ -26,7 +26,7 @@ defaults. This page focuses on choosing the right command.
|
||||
| `video visible`, `video all`, and visible/all batch modes | `remove-ai-watermarks[video]` plus ffmpeg on PATH |
|
||||
| `video invisible` and `video all --invisible` | `remove-ai-watermarks[video,diffusion]` plus ffmpeg on PATH |
|
||||
| HEIC/HEIF/AVIF pixel input | Add `remove-ai-watermarks[heif]` |
|
||||
| Every production command and backend | `remove-ai-watermarks[all]` |
|
||||
| Every production command and Python-compatible backend | `remove-ai-watermarks[all]` |
|
||||
|
||||
`batch` requires the same extra as its selected mode. Extras can be combined in
|
||||
one installation, for example `remove-ai-watermarks[visible,detect,heif]`.
|
||||
@@ -40,7 +40,13 @@ remove-ai-watermarks identify image.png
|
||||
`identify` always inspects supported metadata. When pixel extras are installed,
|
||||
it also evaluates supported visible and invisible pixel signals. When no signal
|
||||
is found, it reports the origin as unknown. It does not claim the image is
|
||||
clean.
|
||||
clean. For C2PA files, the text report shows asset integrity, claim-signature,
|
||||
signer-trust, and signer-validity results separately. An intact claim from an
|
||||
untrusted or expired signer is reported at medium confidence; a failed asset
|
||||
binding or signature does not confirm the claimed origin. When a structured
|
||||
C2PA soft binding is present, the report also names its exact algorithm and
|
||||
signed value; removing the manifest does not remove the referenced pixel
|
||||
watermark or content fingerprint.
|
||||
|
||||
Machine readable output:
|
||||
|
||||
@@ -154,6 +160,11 @@ remove-ai-watermarks metadata image.png --remove -o clean.png
|
||||
When `-o` is omitted, removal overwrites the source. Standard metadata is kept
|
||||
unless you pass `--remove-all`.
|
||||
|
||||
A quiet `--check` or a successful `--remove` is not a clean verdict. The command
|
||||
only inspects and strips embedded AI metadata; a pixel watermark such as SynthID
|
||||
has no local decoder once that metadata proxy is gone. `identify` reports the
|
||||
same limit.
|
||||
|
||||
The command also supports the audio and video containers listed in
|
||||
[supported signals](supported-signals.md). ffmpeg must be available for the
|
||||
non-ISOBMFF audio and video path.
|
||||
@@ -222,11 +233,17 @@ remove-ai-watermarks video metadata input.mp4 --check
|
||||
remove-ai-watermarks video metadata input.mp4 --remove -o clean.mp4
|
||||
```
|
||||
|
||||
As with the generic `metadata` command, a quiet check or a successful strip is
|
||||
not a clean verdict: video SynthID is not decoded locally after the metadata
|
||||
proxy is gone.
|
||||
|
||||
Supported containers are MP4, MOV, M4V, WebM, MKV, AVI, and FLV. The operation
|
||||
delegates to the same verified metadata scanner and stripper as the generic
|
||||
`metadata` command, so detection and removal stay in parity. Video and audio
|
||||
streams are not transcoded. For MP4 and MOV, this includes the native TC260
|
||||
`AIGC` key and JSON value stored in `moov.udta.meta.keys/ilst`. The inspector
|
||||
`AIGC` key and JSON value stored in `moov.udta.meta.keys/ilst`, plus the
|
||||
QuickTime-form `meta` variants Doubao's iOS export writes (a bare `meta` box
|
||||
as a direct `moov` child, and a keyless `hdlr=mdir` metadata list). The inspector
|
||||
seeks past a large `mdat` to find a tail `moov`. Removal stream-copies the
|
||||
container in bounded chunks, converts supported top-level provenance boxes to
|
||||
same-size `free` boxes, and blanks the TC260 key/value in place. Box sizes,
|
||||
@@ -395,6 +412,50 @@ schedule, CFG 1.0 and CUDA, so every one of those flags existed only to be refus
|
||||
several layers down. They are not parsed at all now, which fails at the point the
|
||||
user can act on rather than after a model load.
|
||||
|
||||
### Restore operator-verified text
|
||||
|
||||
`--text-manifest` enables the experimental `vae-glyphs` post-pass. It reconstructs
|
||||
the source with the Qwen VAE, blends 15% of that reconstruction into the normal
|
||||
`qwen-zimage` result, erases the annotated candidate glyphs with LaMa, and composites
|
||||
only the reconstructed glyph cores through source-derived silhouettes. It does not
|
||||
run OCR or choose which strings are correct.
|
||||
|
||||
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 qwen-zimage --text-manifest verified-lines.json --force
|
||||
```
|
||||
|
||||
``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
|
||||
or script metadata.
|
||||
|
||||
Since 0.27.1 the global 15% Qwen-VAE fidelity-anchor blend is off by default: it
|
||||
was measured to return detector-visible OpenAI SynthID on poster-scale manifests
|
||||
(official Content Provenance API, 2026-08-19). `--fidelity-anchor` restores the
|
||||
0.27.0 research behavior; text-box fidelity lost by the default is well under one
|
||||
MAE point on the measured fixtures.
|
||||
|
||||
The manifest is a JSON object with `verified: true`, decoded RGB dimensions,
|
||||
`source_pixel_sha256`, and a non-empty `lines` array. Schema 1 is retained for
|
||||
manually reviewed annotations: each line has an integer `[x1, y1, x2, y2]` box,
|
||||
exact `text`, a non-empty `script`, and an optional angle from -30 to 30 degrees.
|
||||
Schema 2 is geometry-only: each line has the box and optional angle, with no
|
||||
required `text` or `script`. Lines must be in top-to-bottom, left-to-right order.
|
||||
The hash binds the annotations to decoded RGB geometry and pixels, so metadata-only
|
||||
container changes remain valid while a resized or edited source fails closed. The
|
||||
experimental helper
|
||||
`remove_ai_watermarks._internal.text_restoration.source_pixel_sha256` computes it.
|
||||
|
||||
This mode is supported only by `qwen-zimage` at native geometry with
|
||||
`humanize=0`, `unsharp=0`, and adaptive polish disabled. `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.
|
||||
|
||||
### Work with limited memory
|
||||
|
||||
Lower CUDA memory pressure:
|
||||
@@ -426,7 +487,7 @@ It is a memory strategy, not a guarantee of better quality.
|
||||
|
||||
The `all` command and the `all` installation extra are separate concepts. The
|
||||
command runs every applicable stage. Installing `remove-ai-watermarks[all]`
|
||||
makes every production backend available; a smaller installation such as
|
||||
makes every backend compatible with the active Python available; a smaller installation such as
|
||||
`remove-ai-watermarks[visible,qwen-zimage]` can also run the command with fewer
|
||||
optional backends.
|
||||
|
||||
|
||||
@@ -7,3 +7,15 @@ This page records notices required by source dependencies and licensed derivativ
|
||||
- The DWT-DCT implementation derives from ShieldMnt's
|
||||
[`invisible-watermark`](https://github.com/ShieldMnt/invisible-watermark), licensed
|
||||
under MIT. Its notice ships in `src/remove_ai_watermarks/licenses/invisible-watermark-MIT.txt`.
|
||||
The decode path is a vectorized reformulation, not a transcription: it produces
|
||||
the same bits and is checked against upstream's own decoder, but it no longer
|
||||
corresponds line by line to `imwatermark/maxDct.py`. Diffing the two files will
|
||||
show structurally different code, which is expected and does not mean the
|
||||
derivation notice is stale.
|
||||
|
||||
## Licensed test fixtures
|
||||
|
||||
- `data/fixtures/provenance/adobe-trustmark-p.png` is Adobe's official
|
||||
TrustMark Variant P example, licensed under MIT. Its source commit, digest,
|
||||
and reproduced license are recorded beside the fixture in
|
||||
`data/fixtures/README.md` and `data/licenses/adobe-trustmark-MIT.txt`.
|
||||
+10
-1
@@ -12,9 +12,18 @@ Read this reference for environment setup, dependency recovery, CI behavior, and
|
||||
|
||||
The optional TrustMark decoder downloads weights into its installed package directory. After pruning that extra, a leftover weights directory can make availability checks see an empty namespace package. If Pyright reports an unknown `TrustMark` import and `find_spec("trustmark")` returns a loader-less spec, remove that regenerable remnant from the active virtual environment and resync.
|
||||
|
||||
### Known security-gate blocks
|
||||
|
||||
`maintain.sh` fails while PyPI ships no fixed release for a transitive CVE. Current case (triaged 2026-08-17): `lightning` 2.6.5, pulled only by the optional `trustmark` extra, carries PYSEC-2026-3624 (RCE via `load_from_checkpoint` on an attacker-crafted checkpoint). The vulnerable API is unreachable here, because the TrustMark decoder loads only its own pinned weights downloaded from the TrustMark release, never a user-supplied checkpoint. The upstream fix is merged but unreleased, and ignores are never added, so run and report the remaining core checks (Ruff, Pyright, tests) separately until a fixed `lightning` release lands; bump it with `uv lock --upgrade-package lightning` as soon as one does.
|
||||
|
||||
## CI
|
||||
|
||||
`.github/workflows/test.yml` runs Ruff, a cross-platform test matrix over the supported-Python floor and the latest supported minor with default plus development dependencies, and a separate job that installs ffmpeg on Ubuntu to run the full-clip video test. Diffusion and model-running tests skip in that matrix; metadata, identification, visible removal, the DWT-DCT decoder, and the OpenCV eraser remain covered across operating systems.
|
||||
`.github/workflows/test.yml` runs Ruff, a test matrix over every supported Python
|
||||
minor with default plus development dependencies, cross-platform coverage at the
|
||||
oldest and newest minors, and a separate job that installs ffmpeg on Ubuntu to
|
||||
run the full-clip video test. Diffusion and model-running tests skip in that
|
||||
matrix; metadata, identification, visible removal, the DWT-DCT decoder, and the
|
||||
OpenCV eraser remain covered across operating systems.
|
||||
|
||||
Keep `uv.lock` compatible with `uv sync --frozen`. Dependency pull-request checks use GitHub's merge result against current `main`; if `main` moves, merge it locally and rerun the full gate because a newer linter can expose stale directives in later code.
|
||||
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ to run the tool. Use the maintainer references only when changing the code.
|
||||
| [Development](development.md) | Environment setup, dependency recovery, CI behavior, and fixture policy. |
|
||||
| [Code provenance](code-provenance.md) | Required notices for licensed derivative work. |
|
||||
| [Verification plan](verification-plan.md) | Verification methods, completed measurements, and remaining validation gaps. |
|
||||
| [Release and distribution](release-and-distribution.md) | PyPI, Homebrew, Hugging Face Space, and release workflow. |
|
||||
| [Release and distribution](release-and-distribution.md) | PyPI, Homebrew, Hugging Face Space, ComfyUI Registry, and the release workflow. |
|
||||
| [Watermarking landscape](watermarking-landscape.md) | Vendor signals and detection approaches. |
|
||||
| [SynthID technical reference](synthid.md) | Mechanism, detector access, robustness, and implications for this project. |
|
||||
|
||||
|
||||
+20
-6
@@ -1,6 +1,6 @@
|
||||
# Installation
|
||||
|
||||
Python 3.10.1 or newer is required.
|
||||
Python 3.11 through 3.14 are supported.
|
||||
|
||||
## Default metadata mode
|
||||
|
||||
@@ -87,6 +87,15 @@ 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.
|
||||
|
||||
The experimental verified-text post-pass additionally needs LaMa:
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[text-restoration]"
|
||||
```
|
||||
|
||||
That extra includes `qwen-zimage` and `lama`; it does not add OCR. Text strings and
|
||||
line boxes must be reviewed before the run.
|
||||
|
||||
## Feature extras
|
||||
|
||||
Extras are composable. Install only the capabilities and file formats the
|
||||
@@ -99,12 +108,13 @@ application actually uses:
|
||||
| `visible` | Visible mark detection, OpenCV inpainting, and manual erasing | `pixels` | No |
|
||||
| `video` | Visible video identification/removal and timestamp preservation | `visible`, PyAV | No |
|
||||
| `detect` | Open DWT-DCT detection for Stable Diffusion, SDXL, and FLUX | `pixels`, PyWavelets | No |
|
||||
| `trustmark` | Adobe TrustMark detection | trustmark | Yes |
|
||||
| `trustmark` | Adobe TrustMark detection on Python 3.11-3.12 | trustmark | Yes |
|
||||
| `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 |
|
||||
| `qwen-zimage` | Invisible image-watermark removal, both CUDA-only profiles | `diffusion`, DiffSynth | Yes |
|
||||
| `all` | Every production feature | All rows above | Yes |
|
||||
| `text-restoration` | Opt-in verified Qwen-VAE glyph restoration | `qwen-zimage`, `lama` | Yes |
|
||||
| `all` | Every production feature available on the active Python | All compatible rows above | Yes |
|
||||
| `dev` | Tests, linting, typing, and upstream parity checks | `video`, `detect`, upstream invisible-watermark | Yes, for parity tests |
|
||||
|
||||
Dependency composition:
|
||||
@@ -118,13 +128,17 @@ flowchart LR
|
||||
migan --> visible
|
||||
lama --> visible
|
||||
qwen["qwen-zimage"] --> diffusion
|
||||
text["text-restoration"] --> qwen
|
||||
text --> lama
|
||||
heif
|
||||
trustmark
|
||||
```
|
||||
|
||||
`heif` and `trustmark` are independent branches. Combine them explicitly with
|
||||
another feature when required. The `all` bundle contains every production
|
||||
branch but never includes `dev`.
|
||||
another feature when required. TrustMark requires NumPy 1.x, which has no
|
||||
CPython 3.13 or 3.14 wheels, so that branch is available only on Python
|
||||
3.11-3.12. The `all` bundle contains every production branch compatible with
|
||||
the active Python and never includes `dev`.
|
||||
|
||||
Examples:
|
||||
|
||||
@@ -141,7 +155,7 @@ uv tool install --force "remove-ai-watermarks[video]"
|
||||
# DWT-DCT and TrustMark detection without diffusion removal
|
||||
uv tool install --force "remove-ai-watermarks[detect,trustmark]"
|
||||
|
||||
# Every production capability
|
||||
# Every production capability compatible with this Python
|
||||
uv tool install --force "remove-ai-watermarks[all]"
|
||||
|
||||
# An arbitrary minimal combination
|
||||
|
||||
@@ -57,12 +57,42 @@ 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.
|
||||
|
||||
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.
|
||||
The default `all`, `invisible`, and `batch` commands already skip it then, and
|
||||
`visible` (which strips AI metadata by default) or `metadata` alone never
|
||||
redraws a glyph outside the filled watermark box. `--force` is for content you
|
||||
know carries a pixel watermark despite no local signal; on a clean screenshot
|
||||
it only buys distortion. Preserving or pasting back the original text pixels
|
||||
during a real scrub is deliberately not offered: an invisible watermark such
|
||||
as SynthID lives in the pixels everywhere, including inside the glyphs, so
|
||||
frozen text regions would keep the watermark
|
||||
([text protection research](text-protection-research.md)).
|
||||
|
||||
`qwen-zimage` is the default profile and `sdxl-zimage` the only alternative.
|
||||
Both are CUDA only and differ only in the global regeneration model: each
|
||||
conditions that stage on a canny edge map, which preserves structure but not
|
||||
identity or exact texture, and each then runs the same face stage.
|
||||
`qwen-zimage` is the higher fidelity of the two. Both are large, slow, and may
|
||||
still alter small text or difficult faces.
|
||||
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).
|
||||
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:
|
||||
fresh-font and silhouette variants visibly changed typography. The higher-fidelity
|
||||
`vae-glyphs` route is available only as an experimental opt-in with verified strings
|
||||
and line geometry. It builds its donor internally but still requires an independently
|
||||
clean global anchor. Automatic OCR and line-box proposals are not reliable enough to
|
||||
remove those requirements, and exact oracle results do not establish a general mask,
|
||||
seed, runtime, or provider operating range. Qwen-Image-2.0 is hosted-only and exposes
|
||||
no equivalent low-strength denoise control. Exact experiments, controls, and pass
|
||||
rates are kept in
|
||||
[`text-protection-research.md`](text-protection-research.md) and the
|
||||
[`fidelity` evaluation record](../data/evaluations/fidelity/README.md).
|
||||
|
||||
### Removal cannot be verified locally for proprietary SynthID
|
||||
|
||||
@@ -112,6 +142,21 @@ measured 25.39 dB paired PSNR and a 1.058 motion-compensated temporal-residual
|
||||
ratio. This is one carrier, not a universal guarantee; hashes and exact verdicts
|
||||
are tracked in `data/evaluations/video-synthid-oracle.csv`.
|
||||
|
||||
### The invisible video path does not touch the audio track
|
||||
|
||||
`remove_video_invisible` regenerates the video stream and copies the source audio
|
||||
verbatim: the extracted audio bitstream of input and output has an identical
|
||||
sha256, measured on two carriers. It also strips every metadata marker, so the
|
||||
output can report clean from `get_ai_metadata` and `identify_video` while carrying
|
||||
an untouched generated audio track. Google's verifier scores audio and visual
|
||||
tracks separately, and a track this path never modifies is a track it cannot have
|
||||
cleaned.
|
||||
|
||||
Whether a given carrier's audio actually holds a mark the verifier reads has not
|
||||
been established -- that needs a provider verdict, which has not been obtained.
|
||||
Treat a clean local report on a clip with generated audio as unproven, not as a
|
||||
guarantee, and check the audio separately when it matters.
|
||||
|
||||
The shipped engine streams sampled frames in bounded batches, computes its
|
||||
fidelity metrics incrementally, and pipes regenerated pixels directly to
|
||||
ffmpeg. Its frame and latent memory is therefore bounded by `--batch-size`
|
||||
@@ -133,8 +178,12 @@ The two profiles resolve an unset strength differently, because different things
|
||||
were measured for each.
|
||||
|
||||
`qwen-zimage` reads it from image area, through the resolution-adaptive denoise
|
||||
curve. The vendor is deliberately ignored: the curve, not the issuer, is what was
|
||||
calibrated.
|
||||
curve. The vendor is deliberately ignored for openai/unknown content: the curve,
|
||||
not the issuer, is what was calibrated. One measured exception (0.27.2):
|
||||
Google-provenance content takes the flat `QWEN_ZIMAGE_GOOGLE_STRENGTH` 0.30 floor
|
||||
- the curve's 0.154 top left a 4.33 MP fixture oracle-detected on the full
|
||||
production path (2026-08-18), while 0.30 anchors measured clean on two fixtures
|
||||
in two accounts.
|
||||
|
||||
`sdxl-zimage` reads it from the C2PA issuer, on a flat ladder:
|
||||
|
||||
@@ -160,6 +209,15 @@ 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. |
|
||||
|
||||
Only manually verified `vae-glyphs` is an optional production stage, and it is
|
||||
experimental rather than a default.
|
||||
OCR plus LaMa recovered literal poster text but changed fonts and worsened whole-image
|
||||
fidelity. Restricting it to OCR-mismatched lines improved the tradeoff but still
|
||||
left a local shadow on one poster. The published AnyText2 SD1.5 checkpoint
|
||||
substituted Chinese characters and increased CER on the sign fixture. AnyText2XL
|
||||
is not published, and the released wrapper truncates individual text lines after
|
||||
20 characters.
|
||||
|
||||
The `controlnet`, `sdxl`, `qwen` and `default` profiles were removed, not aliased
|
||||
onward: a retired name is rejected at parse time rather than routed into a profile
|
||||
the caller never chose. There is no `--model`, `--steps`, `--guidance-scale`,
|
||||
@@ -344,8 +402,17 @@ Diffusion, SDXL, and FLUX workflows. That decoder is sensitive to the carrier
|
||||
and transformations. A negative result is not a universal negative.
|
||||
|
||||
The `trustmark` extra adds Adobe TrustMark decoding. The implementation retains
|
||||
an additional JPEG re-encode gate because isolated decoder hits can otherwise
|
||||
be content noise.
|
||||
an additional JPEG re-encode gate and requires the binary payload and schema to
|
||||
remain identical because isolated decoder hits can otherwise be content noise.
|
||||
It accepts Variant P schemas 0-2. Variant Q requires a different model, and
|
||||
schema 3 is rejected at the measured precision threshold. Its NumPy 1.x runtime
|
||||
limits the extra to Python 3.11-3.12; the rest of the package remains supported
|
||||
through Python 3.14. Through 0.29.0 the extra also resolves lightning 2.6.5
|
||||
(PYSEC-2026-3624, no fixed release yet); the vulnerable `load_from_checkpoint`
|
||||
path is unreachable here because TrustMark loads its checksummed checkpoints
|
||||
with plain `torch.load`. Bump lightning and cut a patch release when a fix
|
||||
ships. The calibration history is in
|
||||
[module internals](module-internals.md#metadata-and-provenance).
|
||||
|
||||
External AI versus real image classifiers are out of scope. The project
|
||||
identifies concrete local provenance signals instead of shipping a generic
|
||||
|
||||
+179
-6
@@ -42,6 +42,10 @@ Important contracts:
|
||||
- `invisible` writes no output when no supported local signal is found, unless
|
||||
`--force` is supplied.
|
||||
- The two no-signal conditions currently share exit code `2`.
|
||||
- A quiet `metadata --check` and a successful `metadata --remove` (same for
|
||||
`video metadata`) end by repeating the `identify` limit: the pixel channel is
|
||||
untouched and a watermark such as SynthID has no local decoder once its
|
||||
metadata proxy is gone, so neither outcome is a clean verdict.
|
||||
- Hard processing and write failures exit with code `1`.
|
||||
- `all` can still write the completed visible and metadata stages when the
|
||||
diffusion dependencies are unavailable, but exits with code `1` so the
|
||||
@@ -158,7 +162,14 @@ Native MP4/MOV TC260 labels follow TC260-PG-20257A:
|
||||
`moov.udta.meta.keys` maps an `AIGC` key to a raw JSON value in `ilst`.
|
||||
[`_internal/isobmff.py`](../src/remove_ai_watermarks/_internal/isobmff.py) walks those
|
||||
nested boxes by seeking, so detection reaches a tail `moov` without reading the
|
||||
preceding `mdat`. The MP4/MOV/M4V/M4A removal path first validates the top-level
|
||||
preceding `mdat`. Two Doubao iOS variants sit outside that normative placement
|
||||
and are covered by the same walker (2026-08-17 corpus findings, both previously
|
||||
undetected): a QuickTime-form `meta` box as a *direct* `moov` child (no FullBox
|
||||
header, disambiguated by probing the child-box offset), and a QuickTime
|
||||
`hdlr=mdir` metadata list under `udta.meta` whose `ilst` data items carry the
|
||||
validated JSON with no `keys` box at all (content-validated, so only genuine
|
||||
TC260 JSON matches; the keyless entry has no key name to blank, so its value
|
||||
alone is spaced out). The MP4/MOV/M4V/M4A removal path first validates the top-level
|
||||
box walk, then copies the source to a sibling temporary file in bounded chunks.
|
||||
Supported C2PA/JUMBF/AI-label boxes become same-size `free` boxes with blank
|
||||
payloads; TC260 removal changes the four-byte key to `free` and blanks only the
|
||||
@@ -364,9 +375,49 @@ Regression coverage:
|
||||
official `c2pa-python` reader first. Its byte-level PNG parser remains a fallback
|
||||
for partial and synthetic fixtures that the official reader rejects.
|
||||
|
||||
Structured extraction is limited to the active manifest and the ingredient
|
||||
manifests reachable from it. Validation is preserved as separate dimensions:
|
||||
asset binding integrity, claim signature, signer trust, and signer certificate
|
||||
validity. A matching asset hash and valid claim signature do not make an
|
||||
untrusted or expired signer trusted. `identify` therefore assigns high confidence
|
||||
only when all four dimensions validate, medium confidence to an intact but
|
||||
untrusted or unvalidated claim, and no origin verdict to a hash/signature failure.
|
||||
The failed claim remains in the marker inventory and keeps the removal gate
|
||||
fail-safe because a post-signing container edit can invalidate C2PA without
|
||||
removing a declared pixel watermark.
|
||||
|
||||
The structured walk also treats an exact known AI product in a reachable
|
||||
`claim_generator` as an AI assertion. This covers update chains where the active
|
||||
manifest names only `c2pa-tool` while a validated ingredient names Dreamina, and
|
||||
Firefly chains that identify `Adobe_Firefly` without repeating a digital source
|
||||
type. Unreachable manifests remain excluded.
|
||||
|
||||
Reachable `c2pa.soft-binding*` assertions retain their exact `alg` and bounded,
|
||||
printable block `value` in addition to the normalized vendor label. A block value
|
||||
without its algorithm is not surfaced because it cannot be attributed to a
|
||||
decoder or registry entry. `com.microsoft.invismark.1` uses that value as the
|
||||
pixel-watermark identifier in Microsoft Paint output. An InvisMark soft binding
|
||||
keeps the invisible-removal gate fail-safe even when the C2PA asset binding has
|
||||
since become invalid, because metadata damage does not prove the pixel carrier
|
||||
disappeared. `identify` retains the generic `soft_binding` signal for schema-1
|
||||
compatibility and adds `invismark` as the stable pixel-removal signal.
|
||||
Content-fingerprint soft bindings do not trigger pixel regeneration.
|
||||
|
||||
The SDK default enables trust verification but supplies no production trust
|
||||
anchors. Consequently, an installation without an explicitly maintained C2PA
|
||||
trust bundle reports otherwise valid signer chains as untrusted and keeps their
|
||||
attribution at medium confidence. Shipping or fetching an official trust bundle
|
||||
requires a separate update, provenance, and availability policy; do not silently
|
||||
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.
|
||||
|
||||
### Metadata scanning and stripping
|
||||
|
||||
@@ -387,6 +438,11 @@ Key contracts:
|
||||
"the credentials could not be read", and the second silently downgrades a verdict.
|
||||
- JPEG stripping walks metadata segments and preserves the entropy-coded image
|
||||
scan.
|
||||
- Exact app-export JSON disclosures in EXIF `ImageDescription` or `UserComment`
|
||||
share one parser. Known AI-product provenance is removable without by itself
|
||||
asserting that the pixels were generated; explicit `aigc_info` discriminator
|
||||
values and Dreamina `exportType=generation` do assert AI origin. Ordinary
|
||||
Aweme, retouch, and `lv` editor exports are preserved.
|
||||
- ISOBMFF containers use
|
||||
[`_internal/isobmff.py`](../src/remove_ai_watermarks/_internal/isobmff.py).
|
||||
- Native MP4/MOV TC260 `AIGC` entries are read from
|
||||
@@ -545,22 +601,82 @@ the function it replaces does — no provenance means no relaxation, and an unkn
|
||||
invisible target means scrub rather than skip.
|
||||
|
||||
The `detect` extra composes the shared `pixels` runtime with PyWavelets. Its
|
||||
in-tree [`dwt_dct.py`](../src/remove_ai_watermarks/dwt_dct.py) decoder preserves
|
||||
the upstream matrix algorithm without installing Torch or non-headless OpenCV.
|
||||
The upstream MIT notice ships inside the wheel under `licenses/`.
|
||||
in-tree [`dwt_dct.py`](../src/remove_ai_watermarks/dwt_dct.py) decoder reproduces
|
||||
the upstream algorithm's output bit for bit without installing Torch or
|
||||
non-headless OpenCV; the block scan is vectorized rather than transcribed, so
|
||||
the file no longer reads line by line against `maxDct.py`. The upstream MIT
|
||||
notice ships inside the wheel under `licenses/`.
|
||||
|
||||
`is_ai_generated` is `True` or `None`; absence of evidence is not reported as a
|
||||
human-made verdict. `ai_source_kind` distinguishes fully generated content from
|
||||
AI-enhanced composites when the source metadata provides that distinction.
|
||||
|
||||
TrustMark is reported as a watermark signal but does not by itself assert AI
|
||||
origin because it can also protect human-authored content.
|
||||
origin because it can also protect human-authored content. The decoder requests
|
||||
binary mode, matching Adobe's Durable Content Credentials example, then requires
|
||||
the same payload and schema after a quality-95 JPEG round-trip. Only Variant P
|
||||
schemas 0-2 count as positives. Schema 3 is below the precision threshold: all
|
||||
38 measured historical false-positive candidates used it, and six retained the
|
||||
same false payload after re-encoding. The official Adobe Variant P schema-1
|
||||
fixture in `data/fixtures/provenance/` is the positive regression control.
|
||||
|
||||
#### Why the DWT-DCT decoder looks the way it does
|
||||
|
||||
Two things in `dwt_dct.py` are load-bearing and neither is obvious from the code.
|
||||
|
||||
`_approximation` calls `pywt.dwt` twice instead of `pywt.dwt2`, and transposes
|
||||
before each pass. A factorial ablation over both axes separates the two:
|
||||
skipping the three detail bands `dwt2` computes and this code discards is worth
|
||||
**4%**, while the transposes are worth **2.8x**, because pywt walks the axis it
|
||||
transforms and on axis 0 of a C-contiguous plane that is a column walk. The
|
||||
arithmetic saving is the intuitive explanation and it is the small term; four
|
||||
independent profiles named it as the mechanism before the ablation contradicted
|
||||
them.
|
||||
|
||||
Bit-identity is a hard requirement, not a preference. For uint8 input the exact
|
||||
Haar LL value is a multiple of 0.5 and the bit test is `peak % 36 > 18.0`, a
|
||||
threshold sitting exactly on a representable value that ~1 block in 72 lands on,
|
||||
so a 1-ulp difference deterministically flips real bits. That is why the ~16x
|
||||
available from a hand-rolled numpy Haar is unreachable rather than merely
|
||||
untaken: pywt's C convolution contracts into an FMA that numpy has no ufunc for,
|
||||
and `np.longdouble` is 64-bit on arm64 macOS.
|
||||
|
||||
Each pass is one flat `pywt.downcoef` call over a raveled strip rather than
|
||||
`pywt.dwt(..., axis=1)[0]`, and the plane is processed in strips of `_STRIP`
|
||||
block-rows so no full-plane float64 intermediate is ever materialized. The strip
|
||||
height is not a tuned value: 8 through 64 all scored inside each other's noise
|
||||
with unstable ordering, and only "strips at all" versus whole-plane matters.
|
||||
|
||||
`_approximation`'s even-last-axis check is load-bearing, not defensive. Haar's
|
||||
filter is length 2, so an even row length keeps every pair inside its own row;
|
||||
on an odd width the pairs walk across row boundaries and the reshape still
|
||||
succeeds whenever the total is even, which would be wrong bits with no
|
||||
exception. Both call sites are even by construction today, and
|
||||
`TestRaveledHaarPass` pins both halves -- the `downcoef`/`dwt` equivalence,
|
||||
which a pywt upgrade could take away, and the raise on an odd width.
|
||||
|
||||
Measured on a 1536x2816 image, all arms timed in one process: the decoder went
|
||||
0.112 s (the original per-block Python loop) to 0.011 s vectorized to 0.007 s
|
||||
with strips, and a warm `identify()` 1.757 s to 1.365 s on the first step and a
|
||||
further 0.4% on the second. That last figure is the point at which this target
|
||||
is finished: the decoder is now under 2% of `identify()`, so speed here has
|
||||
stopped buying anything. What the strips buy is peak RSS in the stage, 111 MB to
|
||||
21 MB on a 4.3 MP image, which is what matters on the memory-limited Space.
|
||||
|
||||
Both steps were verified by recording decoder output and detector verdict over
|
||||
200 sampled `data/` images plus two synthesized carriers before and after: the
|
||||
record is byte-identical, as are seven degenerate shapes (`1x65536` through
|
||||
`8x8192`, plus an odd width) that clear the caller's area check.
|
||||
|
||||
Regression coverage:
|
||||
|
||||
- [`test_identify.py`](../tests/test_identify.py)
|
||||
- [`test_trustmark_detector.py`](../tests/test_trustmark_detector.py)
|
||||
- [`test_invisible_watermark.py`](../tests/test_invisible_watermark.py)
|
||||
- [`test_invisible_watermark.py`](../tests/test_invisible_watermark.py) --
|
||||
note `test_in_tree_decoder_matches_upstream` is the parity guard against
|
||||
upstream's own decoder, and the whole module is `skipif(not is_available())`.
|
||||
A green run without the `detect` extra installed has not checked parity at
|
||||
all, so a decoder change still owes the before/after verdict record.
|
||||
|
||||
## Visible mark removal
|
||||
|
||||
@@ -855,6 +971,63 @@ orchestration, YuNet integration, SAM selection, masks, sizing helpers, and pixe
|
||||
compositing are implemented for this runtime. Changing a calibrated model input
|
||||
requires the same provider-oracle and identity evaluation as a model change.
|
||||
|
||||
#### Verified text restoration
|
||||
|
||||
[`_internal/text_restoration.py`](../src/remove_ai_watermarks/_internal/text_restoration.py)
|
||||
implements the opt-in `vae-glyphs` stage. A versioned manifest carries manually
|
||||
reviewed strings and source-space line boxes in schema 1, or verified source-space
|
||||
geometry alone in schema 2, plus a SHA-256 over decoded RGB width, height, and pixels.
|
||||
Validation happens before model loading. The library never treats OCR confidence as
|
||||
verification, and geometry-only operators do not need to invent text or script fields.
|
||||
|
||||
When enabled, `QwenZImagePipeline` reconstructs the source once through its already
|
||||
loaded Qwen VAE, runs the ordinary global and face stages, blends 15% of the VAE
|
||||
reconstruction into that clean result, and calls the shared restoration compositor.
|
||||
The compositor derives binary source and candidate silhouettes, groups nearby lines,
|
||||
uses LaMa for the initial and residual-glyph erase passes, paints fresh silhouette
|
||||
edges, then copies the Qwen-VAE core with a 0.5-pixel feather. The evaluation script
|
||||
imports these same mask and compositing helpers so the two implementations cannot
|
||||
silently drift. Silhouette crops start 12% of line height beyond each horizontal
|
||||
side, then expand each side independently while a foreground component anchored
|
||||
inside the detector box still reaches that boundary, up to one line height. They
|
||||
extend 8% above and 25% below. The anchored-component gate covers clipped leading
|
||||
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
|
||||
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.
|
||||
|
||||
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
|
||||
Qwen-then-Z-Image output were both clean. The face stage raised identity cosine from
|
||||
0.589 to 0.852 and reduced face LPIPS from 0.217 to 0.050 without reintroducing a
|
||||
detectable whole-image signal. Thus Z-Image is a masked fidelity repair stage here,
|
||||
not the watermark-removal stage. Exact hashes, metrics, strengths, and the one-fixture,
|
||||
one-seed caveat are recorded in
|
||||
[`data/evaluations/fidelity/face-stage-isolation-2026-08-13.csv`](../data/evaluations/fidelity/face-stage-isolation-2026-08-13.csv).
|
||||
|
||||
The public Synthid-Bypass v2 graph was subsequently audited at upstream commit
|
||||
`3007d035`. Its saved-output path confirms the same division: Qwen-Image-2512
|
||||
Lightning plus Canny is global, and Z-Image Turbo exists only inside the masked
|
||||
face detailer. The connected face path is YOLOv8-face plus SAM; the MediaPipe nodes
|
||||
described by the upstream README and the 1.2-megapixel normalization node do not
|
||||
reach `SaveImage`. Upstream also applies its adaptive face strength directly,
|
||||
whereas this implementation multiplies it by `FACE_DENOISE_SCALE = 0.5`.
|
||||
|
||||
A close reproduction on the same portrait fixture kept the control positive and
|
||||
made both Qwen global-only and full Qwen-then-Z-Image outputs clean. Applying the
|
||||
upstream-strength face pass raised identity from 0.589 to 0.783 and reduced face
|
||||
LPIPS from 0.217 to 0.083, but remained worse than this profile's 0.852 and 0.050.
|
||||
The published upstream pair 12 was also independently checked positive before and
|
||||
clean after, with 0.975 identity. Exact workflow provenance, hashes, metrics,
|
||||
oracle outcomes, and the DiffSynth/GGUF, scheduler, detector, and seed caveats are
|
||||
recorded in
|
||||
[`data/evaluations/fidelity/upstream-v2-reproduction-2026-08-13.csv`](../data/evaluations/fidelity/upstream-v2-reproduction-2026-08-13.csv).
|
||||
|
||||
### SDXL plus Z-Image
|
||||
|
||||
[`_internal/sdxl_zimage_pipeline.py`](../src/remove_ai_watermarks/_internal/sdxl_zimage_pipeline.py)
|
||||
|
||||
+68
-1
@@ -155,8 +155,17 @@ from remove_ai_watermarks.identify import identify
|
||||
report = identify(Path("input.png"))
|
||||
print(report.platform)
|
||||
print(report.signals)
|
||||
print(report.c2pa_validation)
|
||||
```
|
||||
|
||||
`c2pa_validation`, when present, reports `integrity`, `signature`,
|
||||
`signer_trust`, and `signer_validity` independently, plus the reader status
|
||||
codes. A valid hash and signature with an untrusted or expired signer is a
|
||||
medium-confidence signed claim. A hash or signature failure does not confirm the
|
||||
claimed platform or AI origin. Fallback parsing reports unknown validation
|
||||
dimensions, while a raw marker in an unsupported or malformed container can
|
||||
leave `c2pa_validation` as `None`.
|
||||
|
||||
Use `check_visible=False` and `check_invisible=False` for metadata-only
|
||||
inspection through the compatible path-based API:
|
||||
|
||||
@@ -224,6 +233,11 @@ the schema 1 serializer remains available for rolling upgrades. Asking a release
|
||||
an unsupported schema raises `ValueError` rather than silently returning another
|
||||
shape.
|
||||
|
||||
Microsoft InvisMark declarations emit both the compatible generic `soft_binding`
|
||||
signal and the additive `invismark` signal. Consumers should use `invismark` to route
|
||||
the image through pixel removal; the generic signal also covers content fingerprints
|
||||
that must not trigger regeneration.
|
||||
|
||||
A record carries metadata regions, not the primary coded-pixel stream: marker
|
||||
segments before the JPEG scan, every PNG chunk except `IDAT`, RIFF chunks except the
|
||||
coded image, the ISOBMFF provenance boxes, the container's trailer, the parsed EXIF tags the
|
||||
@@ -422,7 +436,8 @@ must use the same container extension as the source.
|
||||
The returned `VideoMetadataResult` records the source, output, metadata detected
|
||||
before removal, and any markers remaining after the verified strip. MP4/MOV
|
||||
inspection recognizes the native TC260 `AIGC` entry in
|
||||
`moov.udta.meta.keys/ilst`; its removal preserves container size and encoded
|
||||
`moov.udta.meta.keys/ilst` and the QuickTime-form `meta` variants Doubao's iOS
|
||||
export writes; its removal preserves container size and encoded
|
||||
stream bytes. MP4/MOV/M4V are copied in bounded chunks, so a large `mdat` is not
|
||||
loaded into memory; publication is atomic. MKV/WebM inspection recognizes the corresponding
|
||||
`Segment.Tags.Tag.SimpleTag` representation; its removal requires ffmpeg for a
|
||||
@@ -587,6 +602,58 @@ engine = InvisibleEngine(pipeline="sdxl-zimage")
|
||||
The `qwen-zimage` extra is required for both profiles: each runs the same
|
||||
DiffSynth Z-Image face stage.
|
||||
|
||||
The opt-in verified-text stage uses the same `text_manifest` argument as the CLI:
|
||||
|
||||
```python
|
||||
engine.remove_watermark(
|
||||
Path("watermarked.png"),
|
||||
Path("clean.png"),
|
||||
text_manifest=Path("verified-lines.json"),
|
||||
)
|
||||
```
|
||||
|
||||
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 and rejects SDXL, downscaling, and
|
||||
postprocessing combinations that were not evaluated. Tiling is allowed: the VAE
|
||||
donor uses the same overlapping tiles as the global pass. `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.
|
||||
|
||||
Use manifest schema 1 for manually reviewed text plus script metadata. Automated
|
||||
operators that verify only text-region geometry should emit schema 2 lines with a
|
||||
`box` and optional `angle`; no placeholder transcription or script is required.
|
||||
|
||||
Since 0.27.1 the mode's global 15% Qwen-VAE fidelity-anchor blend is **off by
|
||||
default** (`fidelity_anchor=False`): that whole-frame blend was measured to
|
||||
return detector-visible OpenAI SynthID on poster-scale manifests (official
|
||||
Content Provenance API, 2026-08-19 - detected x6 with the anchor, clean x6
|
||||
without it, controls and base outputs validated in the same sessions). Pass
|
||||
`fidelity_anchor=True` to reproduce the 0.27.0 research behavior.
|
||||
|
||||
### Drafting manifest lines
|
||||
|
||||
`remove_ai_watermarks.text_draft` proposes lines for a manifest; it never
|
||||
produces verified ones:
|
||||
|
||||
```python
|
||||
from remove_ai_watermarks.text_draft import draft_text_lines
|
||||
|
||||
draft = draft_text_lines(Path("watermarked.png"))
|
||||
for line in draft.accepted:
|
||||
print(line.box, line.script, line.min_score, line.text)
|
||||
```
|
||||
|
||||
Install `remove-ai-watermarks[text-draft]` (CPU, no torch: PaddleOCR detection
|
||||
plus three script-chosen recognition engines). A line lands in `accepted` only
|
||||
when three crop paddings normalize identically and every confidence clears
|
||||
`min_score` (default 0.85); `accepted` means crop-stable, NOT ground-truth-
|
||||
correct - on the reference posters the draft's exact-text precision was 90.0%
|
||||
and 94.4% because high-confidence OCR still lost punctuation. Every accepted
|
||||
line needs a human yes/no before a manifest may claim `verified: true`.
|
||||
`source_pixel_sha256` is re-exported here for building the manifest's
|
||||
pixel-binding hash against the exact source the engine will decode.
|
||||
|
||||
`remove_watermark` takes strength, seed, tiling, resolution, and postprocessing
|
||||
controls. It takes no model id, step count or guidance scale, and neither does the
|
||||
constructor: each profile pins its model stack, its per-stage schedule and CFG
|
||||
|
||||
@@ -3,6 +3,21 @@
|
||||
This page describes the release behavior defined in this repository. External
|
||||
registry state can change independently, so verify it during a release.
|
||||
|
||||
## Published surfaces
|
||||
|
||||
A release is complete only after all four published surfaces are verified:
|
||||
|
||||
| Surface | Published result | Automation |
|
||||
|---|---|---|
|
||||
| PyPI | The `remove-ai-watermarks` wheel and source distribution | `publish.yml` |
|
||||
| Homebrew | The `remove-ai-watermarks` formula in `wiltodelta/homebrew-tap` | `distribute.yml` |
|
||||
| Hugging Face Space | The Space demo deployed on the new release through a requirements-pin bump in `wiltodelta/raiw-hf-space` | `raiw-hf-space` sync workflow; the pin bump is manual, `distribute.yml` only re-installs the pinned version |
|
||||
| ComfyUI Registry | A compatible release of `wiltodelta/ComfyUI-remove-ai-watermarks` with its own node version | `distribute.yml` and the node repository's workflows |
|
||||
|
||||
The GitHub Release is the trigger and release record for this flow. Conda is
|
||||
not a supported publishing surface: this repository has no conda recipe or
|
||||
conda publication job.
|
||||
|
||||
## Release sources of truth
|
||||
|
||||
The package version appears in:
|
||||
@@ -43,25 +58,23 @@ PyPI API token from the repository.
|
||||
waits for the matching source distribution to appear on PyPI, then:
|
||||
|
||||
- updates the Homebrew tap formula URL and SHA-256;
|
||||
- updates the repository conda recipe version and source-distribution SHA-256;
|
||||
- triggers a factory rebuild of the Hugging Face Space.
|
||||
- triggers a factory rebuild of the Hugging Face Space: this re-installs the
|
||||
version pinned in the Space repo, it does NOT upgrade the demo;
|
||||
- synchronizes, tests, versions, and publishes the ComfyUI nodes.
|
||||
|
||||
Upgrading the Hugging Face Space is a separate, manual step: bump
|
||||
`remove-ai-watermarks[visible,heif]` in `wiltodelta/raiw-hf-space` (`pyproject.toml`,
|
||||
`uv lock`, and the re-exported `requirements.txt`), then push. That repository's
|
||||
`sync-to-hf.yml` mirrors the files onto the Space, which rebuilds on the new
|
||||
pin. Its callback smoke tests run in that repository's CI on every push; they
|
||||
are the scenario check for the demo surface.
|
||||
|
||||
The workflow can also be started manually with an optional version input.
|
||||
Conda-forge updates are outside this workflow.
|
||||
|
||||
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
|
||||
manual Homebrew formula update is the fallback when its automation is blocked.
|
||||
|
||||
The conda job uses the published artifact rather than a locally built archive
|
||||
as the hash source and commits the resulting recipe change to `main`. Runtime
|
||||
dependency mapping remains review-controlled: keep it aligned with the default
|
||||
metadata dependencies in `pyproject.toml`, do not copy optional pixel extras
|
||||
into the default recipe, and document any conda-forge package that is
|
||||
unavailable and must be omitted.
|
||||
The optional `video` extra carries PyAV with Python-version-specific bounds; it
|
||||
does not belong in the default metadata-focused conda recipe.
|
||||
|
||||
## Source distribution boundary
|
||||
|
||||
The wheel includes the package under `src/`.
|
||||
@@ -83,8 +96,6 @@ The package uses hatchling through the unpinned `hatchling` build requirement in
|
||||
|
||||
## Other channels
|
||||
|
||||
The repository includes a conda recipe under `packaging/conda/recipe.yaml`.
|
||||
|
||||
The ComfyUI nodes are maintained and versioned in their own repository. After
|
||||
the matching source distribution appears on PyPI, `distribute.yml` dispatches
|
||||
that repository's sync workflow with the exact library version and waits for it
|
||||
@@ -112,7 +123,21 @@ After publication, verify:
|
||||
- the package version matches the tag;
|
||||
- the Homebrew formula points to the new source distribution;
|
||||
- the distribution workflow completed successfully;
|
||||
- the repository's conda recipe matches the published version and source
|
||||
distribution;
|
||||
- the ComfyUI Registry node requires the new library version;
|
||||
- the Hugging Face Space serves the new release: its `requirements.txt` pin
|
||||
matches (raw file via the HF API), the runtime stage is `RUNNING` after the
|
||||
rebuild, and one live `identify` and one live `visible` API call succeed
|
||||
against the running Space;
|
||||
- a clean install can run `remove-ai-watermarks --version`.
|
||||
|
||||
A clean-install check run immediately after publication can fail with "no
|
||||
version of remove-ai-watermarks==X.Y.Z" even though the simple index already
|
||||
lists it: uv serves its cached pre-release view of the index. Re-run with a
|
||||
fresh cache (`UV_CACHE_DIR=$(mktemp -d)`) before treating it as a release
|
||||
failure.
|
||||
|
||||
The ComfyUI sync run can fail the same way on its own: it resolves the version
|
||||
from the PyPI JSON API (which updates first) but installs the test dependency
|
||||
with pip against the simple index, whose CDN edges lag by minutes. Rerun the
|
||||
failed `distribute.yml` comfyui job once the simple index lists the release;
|
||||
nothing about the release itself is wrong.
|
||||
@@ -37,7 +37,7 @@ when you can select the affected area yourself.
|
||||
| `veo` | Current four-point diamond and legacy `Veo` text | Fixed bottom-right corner | Uses separate silhouettes and requires a recurring match; learned fill is preferable on structured backgrounds. |
|
||||
| `seedance` | Boxed `AI` label | Fixed bottom-right corner | Requires an anchored recurring match; the full localized box is filled because a thinner synthetic shape mask leaves the real translucent rim behind. |
|
||||
| `dola` | `Dola AI` text | Fixed bottom-right corner | Requires an anchored recurring match; ByteDance or BytePlus provenance can relax only an existing visual run. |
|
||||
| `hailuo` | `MINIMAX \| hailuo AI` composite label | Fixed lower edge | Uses a synthetic waveform, text, separator, and ring silhouette; the complete recurring label box is filled. |
|
||||
| `hailuo` | `MINIMAX \| hailuo AI` composite label | Fixed lower edge | Uses a synthetic waveform, text, separator, and ring silhouette; the complete recurring label box is filled. A TC260 label naming MiniMax as producer can relax only an existing stable run. |
|
||||
| `kling` | Kling swirl, `KLING AI`, version, and optional `PRO` suffix | Fixed bottom-right edge | Combines a synthetic logo rescue with font variants, an edge gate, a white-label gate, and anchored temporal recurrence. |
|
||||
|
||||
`video identify`, `video visible`, and `video all` share this registry and the
|
||||
@@ -67,6 +67,9 @@ The inspection and stripping code handles signals in these groups:
|
||||
|
||||
- C2PA Content Credentials and supported cloud manifest references;
|
||||
- EXIF and XMP generator fields;
|
||||
- exact app-export provenance and AIGC disclosures from supported
|
||||
ByteDance-family products, with product-only provenance excluded from the
|
||||
generated-image verdict;
|
||||
- IPTC AI disclosure fields;
|
||||
- PNG text chunks and embedded generation parameters;
|
||||
- China TC260 AIGC labels in supported image placements and the normative
|
||||
@@ -77,11 +80,17 @@ The inspection and stripping code handles signals in these groups:
|
||||
- Samsung AI editing markers;
|
||||
- Hugging Face job metadata;
|
||||
- open Stable Diffusion style DWT-DCT watermarks with the `detect` extra;
|
||||
- Adobe TrustMark with the `trustmark` extra.
|
||||
- Adobe TrustMark Variant P schemas 0-2 with the `trustmark` extra. Variant Q
|
||||
needs a different model, while schema 3 is deliberately rejected because it
|
||||
produced persistent false positives on unrelated generators.
|
||||
|
||||
`identify` combines detected signals into a `ProvenanceReport`. It reports
|
||||
unknown when evidence is absent. It never treats missing metadata as proof that
|
||||
an image is human made.
|
||||
an image is human made. C2PA presence alone is not a verified identity: the
|
||||
report distinguishes asset binding, claim signature, signer trust, and signer
|
||||
validity. High-confidence C2PA attribution requires all four; intact but
|
||||
untrusted or fallback claims are medium-confidence, while a failed binding or
|
||||
signature contributes no origin verdict.
|
||||
|
||||
## File and container formats
|
||||
|
||||
@@ -129,6 +138,20 @@ all-media watermark policy, and current OpenAI C2PA carrying an explicit
|
||||
assert SynthID. After provenance metadata is removed, a local negative result
|
||||
is still inconclusive.
|
||||
|
||||
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.
|
||||
|
||||
For MP4, MOV, and M4V, `video invisible` or the explicit
|
||||
`video all --invisible` option can regenerate the video through a VAE and strip
|
||||
source metadata. The shipped profile is oracle-certified, but it is not a local
|
||||
@@ -149,11 +172,13 @@ not a universal clean verdict.
|
||||
| Google Gemini | Sparkle | Diffusion regeneration for SynthID | C2PA and related source signals |
|
||||
| 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 |
|
||||
| Microsoft Paint and Photos | None registered | External Microsoft oracle for InvisMark; no validated local decoder | Paint C2PA soft-binding algorithm and identifier |
|
||||
| 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 |
|
||||
| Adobe Firefly | None registered | No proprietary local decoder | C2PA; optional TrustMark decoder |
|
||||
| Adobe Firefly | None registered | Optional TrustMark Variant P decoder | C2PA |
|
||||
| Midjourney | None registered | No registered pixel decoder | EXIF, XMP, and IPTC signals |
|
||||
| ByteDance generators | Doubao and Jimeng marks | No registered pixel decoder | TC260 AIGC and supported C2PA signals |
|
||||
| Luma AI | None registered | No registered pixel decoder | PNG text generator tags (Uni-1) |
|
||||
| ByteDance generators | Doubao and Jimeng marks | No registered pixel decoder | TC260 AIGC, supported C2PA, and exact app-export AIGC disclosures |
|
||||
| Qwen | Qwen mark | No registered pixel decoder | TC260 AIGC |
|
||||
| Kling | Kling image and video marks | No registered pixel decoder | TC260 AIGC |
|
||||
| Hailuo / MiniMax video | Hailuo composite video label | No registered pixel decoder | TC260 AIGC where present |
|
||||
|
||||
@@ -769,3 +769,15 @@ reproducible verification requires a fixed seed.
|
||||
8. Jiang et al. (2025). **VideoMarkBench: Benchmarking Robustness of Video
|
||||
Watermarking.** arXiv:2505.21620.
|
||||
https://arxiv.org/abs/2505.21620
|
||||
|
||||
**Google floor on qwen-zimage (0.27.2, 2026-08-19).** The bracket above did not
|
||||
survive the full production path: on the 4.33 MP CJK-sign fixture (visible-stage
|
||||
sparkle removal -> qwen-zimage seed 0 at the curve's 0.154 top -> resize-back ->
|
||||
metadata strip), the Gemini verifier detected SynthID x3 with a valid
|
||||
pixel-identical stripped control in the same session. Google-provenance content
|
||||
now takes a flat `QWEN_ZIMAGE_GOOGLE_STRENGTH = 0.30` floor instead of the area
|
||||
curve - 0.30 anchors measured clean in Gemini on two fixtures (CJK sign +
|
||||
18-face) at 3/3 checks across two work accounts, and stayed clean with the
|
||||
vae-glyphs donor layer on top; openai/unknown content keeps the resolution
|
||||
curve. An explicit strength still wins. Certification artifacts: raiw-app
|
||||
`data/certification/text-restoration-2026-08-18/` (the failing re-baseline).
|
||||
@@ -143,3 +143,229 @@ Deep-research workflow run `wf_118b9a03-3eb` (2026-05-29). Findings adversariall
|
||||
(2/3 refutes required to kill a claim). This note records research only; no code change is
|
||||
implied until a prototype validates fidelity and the SynthID-scrub guarantee on the
|
||||
restored output.
|
||||
|
||||
## 2026-08-13 OCR plus LaMa prototype
|
||||
|
||||
A lightweight version of the recommended two-stage architecture was tested on
|
||||
the three tracked text fixtures. It used the already-clean Qwen result as the
|
||||
base, recognized the source text, removed source and Qwen glyph contours with
|
||||
LaMa, and rendered the recognized strings as fresh pixels. No source pixels
|
||||
were pasted back.
|
||||
|
||||
The result validates only part of the hypothesis. Character-weighted CER on the
|
||||
two multilingual posters fell from 0.338/0.305 to 0.007/0.007, and OpenAI Verify
|
||||
remained clean for both matched Qwen and restored pairs. However, replacement
|
||||
fonts changed the design, whole-image LPIPS worsened by 0.067 on each poster,
|
||||
and the light poster retained a shadow in one small English line. On the Chinese
|
||||
sign, punctuation errors moved CER from 0.074 to 0.111. The Google verifier run
|
||||
was inconclusive because the available account detected both the restored image
|
||||
and the byte-identical Qwen control that a previous account had called clean.
|
||||
|
||||
The experiment supports a proper glyph-conditioned restorer, not shipping the
|
||||
plain OCR/system-font compositor. Exact hashes, metrics, masks, and provider
|
||||
verdicts are tracked in
|
||||
[`data/evaluations/fidelity/text-restoration-2026-08-13.csv`](../data/evaluations/fidelity/text-restoration-2026-08-13.csv).
|
||||
|
||||
## 2026-08-13 AnyText2 follow-up
|
||||
|
||||
The official AnyText2 SD1.5 checkpoint was then tested as the glyph-conditioned
|
||||
restorer. Its ModelScope entry and code are Apache 2.0, and the downloaded
|
||||
checkpoint matched the published SHA-256. An official blackboard edit completed
|
||||
successfully before the fixture run, establishing that the runtime reproduced
|
||||
the model rather than silently exercising a fallback.
|
||||
|
||||
On the Chinese sign, a three-line local edit over the exact Qwen output scored
|
||||
CER 0.185 under the standard detector. Font mimic from the source glyph masks
|
||||
scored 0.222. Whole-image LPIPS changed from 0.289 to 0.338 and 0.345 respectively.
|
||||
A padded crop-recognition check showed why the comparison matters: Qwen contained
|
||||
the full correct text and scored 0.000, while default AnyText2 still scored 0.074
|
||||
with two substituted characters and mimic remained at 0.222. The provider oracle
|
||||
was deliberately not called because these variants had already failed the
|
||||
content-fidelity gate.
|
||||
|
||||
AnyText2 is therefore not the missing production restorer in its published form.
|
||||
The negative result is narrower than the model family: only the SD1.5 checkpoint
|
||||
is public, while AnyText2XL remains listed as future work. The current wrapper
|
||||
also truncates each quoted text line to 20 characters, which prevents a direct
|
||||
test of several tracked English and Russian poster lines. Exact model provenance,
|
||||
output hashes, and metrics are recorded in
|
||||
[`data/evaluations/fidelity/anytext2-restoration-2026-08-13.csv`](../data/evaluations/fidelity/anytext2-restoration-2026-08-13.csv).
|
||||
|
||||
The same cross-check exposes a measurement bug in the earlier lightweight
|
||||
restoration result. Paddle detection made tight boxes that omitted the final
|
||||
Chinese full stop from two lines, producing Qwen CER 0.074 although the pixels
|
||||
and padded recognition were correct. Adding 16-32 pixels of horizontal context
|
||||
restored every punctuation mark. A deterministic rerender using the full OCR
|
||||
strings and the closest of nine mask-scored CJK fonts also reached detector CER
|
||||
0.000, but its heavier Hiragino Sans GB W6 glyphs raised LPIPS from 0.289 to 0.327.
|
||||
The actionable design is selective restoration: compare padded source and output
|
||||
recognition first, and preserve the Qwen output when they already match.
|
||||
|
||||
That policy was tested on the two multilingual posters. A manual prototype kept
|
||||
the lines whose padded source and Qwen recognition agreed and rerendered only the
|
||||
changed lines. The edited mask fell from 15.4%/17.0% in the full compositor to
|
||||
5.5%/5.5%. On a single Paddle `en+ru+ch` route, CER changed from 0.378/0.413 for
|
||||
Qwen to 0.101/0.112, while LPIPS was only 0.120/0.103 rather than the full
|
||||
compositor's 0.174/0.162. The different OCR route is intentional and all three
|
||||
variants were remeasured through it; these CER values are not directly
|
||||
comparable with the earlier Vision/Paddle hybrid results.
|
||||
|
||||
Both selective outputs returned `No OpenAI signals detected` in OpenAI Verify,
|
||||
and the original light poster returned `Generated with OpenAI tools` immediately
|
||||
afterward as the positive control. The dark poster was visually clean. The light
|
||||
poster still showed one local old-text shadow, so mask construction remains the
|
||||
blocking defect. Exact hashes, metrics, mask fractions, and oracle controls are
|
||||
in
|
||||
[`data/evaluations/fidelity/selective-text-restoration-2026-08-13.csv`](../data/evaluations/fidelity/selective-text-restoration-2026-08-13.csv).
|
||||
|
||||
Uniformly expanding every selected glyph mask by two pixels removed that light
|
||||
poster shadow and moved CER from 0.101 to 0.081 without a meaningful LPIPS cost.
|
||||
It also expanded the edited area from 5.5% to 7.0%, and the identical rule made
|
||||
the dark poster worse at CER 0.147 rather than 0.112. Those follow-up bytes have
|
||||
not been oracle-checked. The next prototype should detect residual source glyphs
|
||||
per line and expand only the failing component, rather than introducing another
|
||||
poster-wide tuning constant.
|
||||
|
||||
The manual selection was then replaced with per-line padded recognition. A line
|
||||
is left untouched only when the source recognizer is sufficiently consistent
|
||||
with the verified line and normalized source/candidate recognition agree
|
||||
exactly. The automatic rule reproduced the manual selection on the dark poster
|
||||
and correctly kept one additional large Russian line on the light poster. It
|
||||
reduced that poster's effective edited area from 5.5% to 4.1%.
|
||||
|
||||
Mask cleanup became a measured second pass rather than a global dilation. After
|
||||
the first LaMa erase, the prototype finds contrast that remains specifically at
|
||||
the original glyph positions, dilates only that residual, and erases it before
|
||||
fresh text is drawn. This removed the visible double glyphs from both posters.
|
||||
The automatic outputs scored LPIPS 0.113/0.104 and detector CER 0.123/0.119, with
|
||||
effective edited fractions of 4.1%/5.7%. The light aggregate CER is pessimistic:
|
||||
the page detector fragments its untouched Cyrillic line into Latin-like pieces,
|
||||
while the padded Russian recognizer returns the exact expected text.
|
||||
|
||||
OpenAI Verify returned `No OpenAI signals detected` for both new hashes and then
|
||||
`Generated with OpenAI tools` for the original light poster in the same browser
|
||||
sequence. Reproducing the prototype from the tracked script exposed harmless
|
||||
ONNX rounding on the light image: 386 pixels differed by at most one channel
|
||||
value from the initially checked hash. The exact tracked output hash
|
||||
`900def5a...` was therefore submitted separately, returned clean, and was
|
||||
followed immediately by another positive source result. The dark tracked output
|
||||
was byte-identical to the initially checked hash.
|
||||
|
||||
The Chinese sign provides a no-edit control for the selector. Detector boxes for
|
||||
its three large lines overlap vertically, so Latin-style vertical padding
|
||||
incorrectly mixed neighboring lines. Horizontal-only CJK padding captured the
|
||||
terminal punctuation without mixing neighbors and made source and candidate
|
||||
recognition agree exactly on all three lines.
|
||||
The script then produced a zero mask and copied the Qwen candidate byte for byte.
|
||||
This prevents the heavier-font regression seen in the earlier CJK compositor,
|
||||
but does not resolve the unavailable Google-negative oracle control.
|
||||
|
||||
The result clears the measured OpenAI gate without a manual line selection, but
|
||||
is not a production design yet. It still assumes verified source strings and
|
||||
line boxes, relies on macOS system fonts, and needs evaluation on uncurated
|
||||
layouts, rotated text, false OCR matches, automatic line-box discovery, and a
|
||||
reproducible Google-negative control.
|
||||
|
||||
The later source-silhouette experiment removed the font lookup but did not meet
|
||||
the actual visual requirement. Native-size review found changed stroke weight,
|
||||
color variation, edge antialiasing, and decorative details even where crop OCR
|
||||
was exact. A narrower `vae-glyphs` experiment uses the silhouette only as a
|
||||
support mask: exact pixels come from a Qwen-VAE reconstruction, while a fresh
|
||||
flat-color silhouette supplies only the outer edge beneath that core. Across 48
|
||||
typography cases (548 annotated lines), this improved glyph-edge MAE in 48/48
|
||||
and text-box SSIM in 47/48. The median values moved from 37.59 to 32.42 and from
|
||||
0.854 to 0.914 respectively. One separate untracked core-only output with a
|
||||
1.43% donor area returned `No OpenAI signals detected`; the tracked script then
|
||||
reproduced those exact bytes. A 0.5-pixel feathered sibling with 2.75% nonzero
|
||||
donor area improved mean text-box SSIM from 0.957 to 0.960, with a 0.918 minimum
|
||||
across 15 verified lines, while full-image RGB SSIM reached 0.864. Crop OCR
|
||||
recovered from 7/15 exact lines on the raw pass to 14/15, equal to the source's
|
||||
own recognizer floor. Whole-image LPIPS was 0.082, but only 0.108% of pixels
|
||||
remained exactly equal to the source and the detected face's Laplacian-variance
|
||||
ratio was 0.670, confirming residual global smoothing. A same-session
|
||||
OpenAI Verify sequence detected the exact source control in 1/1 check, returned
|
||||
clean for the raw global pass and clean-fidelity anchor in 1/1 each, and returned
|
||||
clean for the feathered output in 3/3 repeats. The tracked script reproduced
|
||||
that feathered file byte for byte. This proves two materially better exact-output
|
||||
Pareto points, not a general operating range. The 48 matrix outputs and other
|
||||
mask sizes remain oracle-unverified.
|
||||
|
||||
The first Google oracle sample did not transfer. For the synthetic CJK sign,
|
||||
two separate work accounts detected SynthID in both the resaved positive source
|
||||
control and the exact Qwen-VAE donor candidate. The candidate's text-box SSIM
|
||||
improved from 0.791 to 0.835 and its glyph-edge MAE from 35.48 to 22.49, but its
|
||||
3.67% donor layer did not create a clean operating point. The Qwen silhouette
|
||||
base was already detected, as was the earlier original-sign Qwen baseline in the
|
||||
same account. The measured cause is therefore insufficient global Google
|
||||
regeneration, not a demonstrated watermark regression from the text donor.
|
||||
Google needs a stronger oracle-clean global anchor before the narrow donor can
|
||||
be evaluated there.
|
||||
|
||||
A targeted follow-up supplied that missing anchor for one CJK case. An explicit
|
||||
Qwen global pass at strength 0.30 returned no reliable SynthID signal in Gemini,
|
||||
while the matched source control was detected. Applying the 0.5-pixel
|
||||
`vae-glyphs` layer to that clean anchor changed 1.17% of the image and raised
|
||||
mean text-box SSIM from 0.765 to 0.964 (minimum 0.963), with mean text-box MAE
|
||||
falling from 20.74 to 5.38. The post-donor output then returned clean in 3/3
|
||||
Gemini checks across two work accounts. A separate 18-face fixture with four
|
||||
tiny UI-text lines also had a clean strength-0.30 anchor; its donor layer changed
|
||||
0.70% of the image, raised mean text-box SSIM from 0.617 to 0.976 (minimum
|
||||
0.972), and returned clean in 3/3 checks across the same two accounts. One
|
||||
quota-exceeded response is excluded from both pass rates. These are two exact
|
||||
oracle-certified outputs, not a general operating range: other layouts, masks,
|
||||
strengths, seeds, and donor areas remain unverified.
|
||||
|
||||
An automatic-box follow-up merged Paddle word detections by vertical overlap.
|
||||
It found exactly 20/20 poster lines and 3/3 sign lines, with mean IoU 0.857,
|
||||
0.847, and 1.000 against the verified boxes. That structural match was not
|
||||
sufficient: reusing the annotation crop padding changed recognition decisions,
|
||||
reduced preserved dark-poster lines from 7 to 4, and expanded its edited
|
||||
fraction from 5.7% to 11.2%.
|
||||
|
||||
A recognition-only sweep isolated the crop sensitivity. Limiting vertical
|
||||
padding around detector boxes to 8-12% of line height reproduced the verified-
|
||||
box decision vectors on both posters; 10% was used for a full follow-up. It kept
|
||||
8/20 and 7/20 lines with edited fractions of 4.1% and 5.7%, and visual inspection
|
||||
found no double glyphs. Whole-image LPIPS remained 0.113/0.104, but detector CER
|
||||
was 0.127/0.154 instead of 0.123/0.119. The dark regression failed the fidelity
|
||||
gate, so these hashes were not submitted to the provider oracle. The opt-in flag
|
||||
remains only to reproduce the negative result. Count, IoU, and matching decision
|
||||
vectors are therefore insufficient gates for automatic boxes; the next design
|
||||
needs box rectification or recognition stability under crop jitter before it
|
||||
can replace verified geometry.
|
||||
|
||||
Removing verified strings was tested separately with an annotation-seed dry
|
||||
run. It detects boxes, chooses `en`, `ru`, or `ch` from Unicode script, and
|
||||
accepts a draft only when three crop paddings normalize identically and every
|
||||
confidence is at least 0.85. One execution proposed 20 and 18 poster lines, but
|
||||
exact-text precision against the held-out annotations was only 90.0% and 94.4%.
|
||||
The stable errors were punctuation: one lost English comma and an ideographic
|
||||
comma consistently replaced by ASCII. Thus crop agreement and confidence do
|
||||
not verify literal text. The helper is retained only to seed annotations; every
|
||||
proposal needs manual review before it can enter the restoration compositor.
|
||||
|
||||
## Production-path anchor leak and fix (2026-08-19)
|
||||
|
||||
The engine `text_manifest=` path (unlike the research compositor above) blends a
|
||||
15% Qwen-VAE fidelity anchor across the WHOLE frame before glyph restoration.
|
||||
Certified through the official OpenAI Content Provenance API on the exact
|
||||
production path (qwen-zimage, seed 0, no strength override, untiled, LANCZOS
|
||||
resize-back, metadata strip; library 0.27.0 @ 50d9f448):
|
||||
|
||||
- controls (pixel-identical metadata-stripped sources): `synthid: detected` -
|
||||
the pixel channel was proven live before every session;
|
||||
- base outputs: `clean` x6 (two 20-line posters, 3 attempts each);
|
||||
- text-restored WITH the anchor: `synthid: detected` x6, deterministic;
|
||||
- text-restored with `FIDELITY_BLEND_ALPHA = 0` (runtime ablation, base bytes
|
||||
identical between runs): `clean` x6.
|
||||
|
||||
The anchor was therefore the leak channel: a 15% global donor admixture over a
|
||||
scrubbed frame returns enough OpenAI SynthID for the provider detector. Fidelity
|
||||
cost of dropping it (source-referenced text-box MAE): 11.60->11.72, 7.79->7.86,
|
||||
and 7.57->8.13 across the two posters and the CJK sign - well under one point
|
||||
against base MAEs of 24.7/16.8/10.2. Since 0.27.1 the anchor is off by default
|
||||
(`fidelity_anchor` parameter on `remove_watermark` / `InvisibleOptions` /
|
||||
`--fidelity-anchor`); Google rows were not re-queried because the default
|
||||
strength leaves that fixture's base detected (the vendor-strength lane tracks
|
||||
that separately). Certification artifacts: raiw-app
|
||||
`data/certification/text-restoration-2026-08-19-A1/` (sha256-stamped rows).
|
||||
@@ -39,6 +39,15 @@ confidence), reviewed once, then re-baselined. Lost detections are the alarm.
|
||||
|
||||
Implemented as `scripts/sidecar_regression.py` (resumable, ~1.5 h at 8 workers).
|
||||
|
||||
A 2026-08-15 metadata-only C2PA regression audit over the local historical
|
||||
corpus caught two structured-parser gaps before release: an exact Firefly claim
|
||||
generator without a repeated source type, and a Dreamina generator carried by a
|
||||
reachable ingredient under a generic update manifest. Both now have focused
|
||||
tests. The same audit confirmed that invalid hash/signature claims lose origin
|
||||
attribution without losing the C2PA inventory, and exposed the absence of
|
||||
production trust anchors in the SDK defaults. Dataset-derived counts and
|
||||
identifiers remain in the gitignored audit output.
|
||||
|
||||
#### Local run protocol
|
||||
|
||||
Re-run `identify` against locally recorded sidecars, classify losses separately from intended new detections, and keep generated reports under `.local-eval/`. Do not commit dataset-derived counts or identifiers.
|
||||
@@ -177,10 +186,14 @@ report recall from the detector-sampled set.
|
||||
|
||||
## Tier D -- external oracles (manual, not automatable here)
|
||||
|
||||
SynthID removal cannot be verified locally by design -- no public decoder exists. Each
|
||||
vendor has its own oracle and it covers only that vendor's content: `openai.com/verify` for
|
||||
OpenAI (more accessible, the automation candidate), the Gemini app for Google (manual,
|
||||
rate-limited). A quiet metadata proxy is **not** proof the pixel watermark is gone.
|
||||
Proprietary watermark removal cannot be verified locally by design -- no public decoder
|
||||
exists. Each vendor has its own oracle and it covers only that vendor's content:
|
||||
`openai.com/verify` for OpenAI, the Gemini app for Google, and Microsoft's
|
||||
[Content Provenance Detection API](https://learn.microsoft.com/en-us/azure/ai-services/content-safety/how-to/how-to-provenance-detection)
|
||||
for InvisMark. The Microsoft API reports `Watermark` and `C2PA` separately. It therefore
|
||||
needs a pixel-identical metadata-stripped control: the control must lose `C2PA` while
|
||||
remaining `Watermark`-positive before a candidate's negative result can be attributed to
|
||||
pixel regeneration. A quiet metadata proxy is **not** proof the pixel watermark is gone.
|
||||
|
||||
Scope honestly: this tier certifies strength floors on a handful of images per vendor, and
|
||||
that is all it can do. See `docs/synthid.md`.
|
||||
|
||||
@@ -171,6 +171,120 @@ Three readings, all MEASURED, none of them about SynthID:
|
||||
so the `max(temporal_baseline, 1e-6)` denominator collapses and the ratio
|
||||
inflates. This is the predicted defect, now observed rather than argued.
|
||||
|
||||
## Measurements on the real carrier (2026-08-06)
|
||||
|
||||
The public Veo 3 off-road sample was downloaded and its sha256 matched the manifest
|
||||
exactly, so everything below is measured on the same file the 2026-07-31
|
||||
calibration used. Its properties, none of which the manifest recorded at the time:
|
||||
H.264 1920x1080 at 24 fps, 8.000 s, 5,155,063 bps video, plus an **AAC 48 kHz
|
||||
stereo 192,241 bps audio track of 7.914 s**.
|
||||
|
||||
The shipped profile therefore delivers 512x288 at 12 fps: **7.1% of the frame area
|
||||
and 3.6% of the pixels per second.**
|
||||
|
||||
### The geometry prize, measured end to end
|
||||
|
||||
Scored with `scripts/video_fidelity_probe.py` against the untouched 1920x1080
|
||||
source, all at `noise_std=0.15`:
|
||||
|
||||
| Geometry | Frame area | PSNR | SSIM | Temporal | Bitrate |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 512x288 @12 (shipped) | 7.1% | 25.3971 | 0.8215 | 1.0407 | 2187 kbps |
|
||||
| 768x432 @12 | 16.0% | 27.3796 | 0.8556 | 1.0323 | 3782 kbps |
|
||||
| 1024x576 @12 | 28.4% | 28.8542 | 0.8819 | 1.0262 | 5604 kbps |
|
||||
| 512x288 @24 | 7.1% | 25.6142 | 0.8270 | 1.0995 | 3181 kbps |
|
||||
|
||||
**512 to 1024 is worth +3.46 dB, against 1.92 dB for the entire `noise_std` axis.**
|
||||
The geometry lever is roughly twice the noise lever, which is the first hard number
|
||||
this investigation has for the size of the prize. What it still costs in removal is
|
||||
unknown: none of these was ever submitted.
|
||||
|
||||
Two readings that are not obvious from the table:
|
||||
|
||||
- **Temporal residual improves as resolution rises** (1.0407 to 1.0262). The shared
|
||||
noise field lives on the latent grid, so at 1920 it is four times finer relative
|
||||
to the frame than at 512, where it stretches into low-frequency blotches. Higher
|
||||
resolution plausibly makes the perturbation *less* visible, not more.
|
||||
- **The frame-rate arm looks bad here and the metric is the wrong judge.** 24 fps
|
||||
buys +0.22 dB and worsens the temporal ratio to 1.0995, but what 24 fps actually
|
||||
buys is smoothness, which neither PSNR nor SSIM measures at all. The metric sees
|
||||
only the cost. Do not decide the fps question from this table.
|
||||
|
||||
Encoder cost on an M-series MPS device, 8 seconds of source: 51 s at 512/12, 180 s
|
||||
at 768/12, 380 s at 1024/12, 190 s at 512/24. Native 1920x1080 at 24 fps
|
||||
extrapolates to roughly 46 minutes for the same 8 seconds, around 350x real time. A
|
||||
native run was started and killed before producing anything, so that figure remains
|
||||
an extrapolation.
|
||||
|
||||
### The certified row does not reproduce
|
||||
|
||||
Re-running the certified configuration (512/12/0.15, seed 0) on this machine gave
|
||||
25.5002 dB and 1.0675 against the manifest's 25.3911 and 1.0578, and a different
|
||||
output sha256. The manifest records neither device nor dtype, and the original was
|
||||
almost certainly CUDA fp16 while this was MPS fp32. **The oracle certified a
|
||||
specific file that cannot be rebuilt**, so any re-run produces an artifact the
|
||||
verifier never saw. Device and dtype belong in the schema.
|
||||
|
||||
### Audio passes through untouched, and the output still reads clean
|
||||
|
||||
Established on two carriers, by comparing the extracted audio bitstream sha256 of
|
||||
input and output:
|
||||
|
||||
- Veo carrier: `674063df...` in, `674063df...` out.
|
||||
- A Lyria 3 track generated for this purpose: `6ee4c041...` in, `6ee4c041...` out.
|
||||
|
||||
The Lyria case is the sharper one because its provenance is known. Google's own
|
||||
generator stamps it: `get_ai_metadata` reads a C2PA manifest naming
|
||||
`Google C2PA Core Generator Library`, issuer `Google LLC`, `trainedAlgorithmicMedia`.
|
||||
After the shipped path runs, that output reports `{}` -- every metadata marker
|
||||
stripped, `remaining_metadata` empty, visuals regenerated -- while the audio the
|
||||
generator produced is bit-for-bit intact.
|
||||
|
||||
So the pipeline can produce a file that **our own detector calls clean while an
|
||||
untouched Google-generated audio track sits inside it**. The mechanism is proven;
|
||||
whether that audio carries an audible-to-the-verifier SynthID mark is not, and only
|
||||
the oracle can say. If it does, this is a shipped defect rather than a quality
|
||||
question, and it outranks everything else in this document.
|
||||
|
||||
Worth recording alongside it: the Veo carrier itself returns `{}` from
|
||||
`get_ai_metadata`. The public sample carries no C2PA at all, which is exactly why it
|
||||
needed an oracle verdict in the first place.
|
||||
|
||||
## State when this work was parked (2026-08-06)
|
||||
|
||||
Landed in `main` through PR #78: the manifest-backed default pin, the
|
||||
scaling-factor gate and its single seam, the sweep loading through the engine's
|
||||
loader, `scripts/video_fidelity_probe.py` with its tests, the extended manifest
|
||||
schema, and this document. **The shipped operating point did not move**: it is
|
||||
still 512 px / 12 fps / `noise_std=0.15`.
|
||||
|
||||
A product requirement arrived late and reframes the target: output resolution,
|
||||
frame rate and bitrate must not be reduced. Resolution, frame rate and crf are
|
||||
parameters and can be preserved, but zero quality loss is not achievable by this
|
||||
mechanism at all -- removal works by rebuilding pixels, so a regeneration can never
|
||||
return its input. The honest goal is to preserve container properties and make the
|
||||
perceptual loss imperceptible, judged by DISTS and VMAF rather than PSNR. Two
|
||||
consequences follow: crf 18 on this path against crf 14 on the visible path is an
|
||||
unexplained asymmetry and a free quality win, and the oracle's 100 MB limit means a
|
||||
higher-quality profile is *harder* to certify than the one it replaces.
|
||||
|
||||
Nothing is blocked on analysis. Six oracle submissions were prepared and never run,
|
||||
because file upload to the verifier failed at the tool level:
|
||||
|
||||
1. A Lyria carrier and the pipeline's output from it, answering the audio question.
|
||||
2. A multiplexed control-plus-candidate file at the shipped geometry, validating
|
||||
whether the verifier reads a concatenated pair by time range. Verified locally
|
||||
frame by frame: half one is bit-identical to the control, half two to the
|
||||
candidate.
|
||||
3. Three more multiplexed pairs for 768, 1024 and 24 fps, worth three queries
|
||||
instead of six if the instrument validates.
|
||||
|
||||
Every generated artifact for those submissions has since been lost with its
|
||||
scratch directory, which is the intended lifecycle for generated media. Rebuilding
|
||||
costs one download and roughly 25 minutes of encoding; the Lyria track is the
|
||||
exception, since regenerating it would produce a different track, and the copies
|
||||
that survive are the only ones.
|
||||
|
||||
## The single most important unknown
|
||||
|
||||
**How much the 512 px downscale contributes to removal.** Every quality gain routes
|
||||
|
||||
@@ -10,7 +10,7 @@ Who embeds what, and whether it is locally detectable (so we know which gaps are
|
||||
**Root cause and external confirmation (deep-research 2026-06-19, adversarially verified).** This is the SCHEME's ceiling, not our usage — there is no better decoder to adopt. The imwatermark maintainers state verbatim (both the ShieldMnt and Stability-AI READMEs) that the algorithm "cannot guarantee to decode the original watermarks 100% accurately even though we don't apply any attack." Independent measurement (WMAdapter, arXiv:2406.08337 Table 2) puts dwtDct at only **~0.79 bit accuracy on CLEAN images (~38/48 bits — already below our 44 gate)**, collapsing to ~0.50 (chance) under crop/JPEG. Two code-verified + locally-reproduced mechanisms drive the content-dependent failures: (1) the decoder reads each bit as the **highest-magnitude DCT coefficient per block**, so any content coefficient exceeding the encoded target flips the bit; (2) the default embed is in the **YUV chroma channel, which 8-bit-clamps on white/bright pixels** (a +36 chroma delta survives a white-fill round-trip as only +4, ~89% loss) — this is the mechanism behind the bright-flat / minimalist failures and the all-ones degenerate decode. No maintained fork or detector decodes this scheme reliably: the WAVES benchmark (arXiv:2401.08573) relegates DWT-DCT to supplementary appendix G.5 and targets Stable Signature / Tree-Ring / StegaStamp instead; learned encoder/decoder schemes reach ~0.98-0.99 clean but are a DIFFERENT watermark class (not what SDXL/FLUX stamp). `dwtDctSvd` does not help (SDXL embeds `dwtDct`; dwtDctSvd cannot decode it, and its clean accuracy ~0.72 is lower). **Authoritative conclusion: the open DWT-DCT mark cannot be turned from positive-only into a reliable real-world detector; keep it positive-only and rely on C2PA.** (Refuted along the way: that the library is unmaintained, and that it is robust to JPEG but only fails on geometric attacks — both did not survive verification.)
|
||||
|
||||
Consequence for the FLUX hosted-output question (BFL Playground, FLUX.2 [pro] + FLUX.1 [dev], 2026-06-19): all samples carry the signed C2PA manifest (issuer "Black Forest Labs"); the open DWT-DCT decode returned `None`, but every available FLUX carrier (textured fox AND a minimalist-flat generation) failed the positive control (28/48), so the detector is blind on them and **whether BFL hosted output embeds the open pixel watermark is UNRESOLVED** (an earlier note here wrongly asserted it absent — overstated; a later note blamed "high texture" — also wrong, flat carriers fail too). What IS established: C2PA is the reliable FLUX identifier; the `_BITS_48` pattern is correct (round-trips on chatgpt/firefly/random). Resolving the hosted question needs a hosted FLUX carrier that first passes a >=44/48 positive control, which neither a textured nor a flat prompt produced — low priority (the open mark is only a stripped-metadata fallback).
|
||||
- **C2PA / IPTC (covered by the issuer/marker scan):** OpenAI, Google, Adobe Firefly, Microsoft (Designer + **Bing Image Creator** — collected 2026-05-24; Bing now runs Microsoft's own **MAI-Image** model, signs C2PA as "Microsoft", NOT OpenAI/DALL-E), **Stability AI** (collected from Brand Studio / DreamStudio successor; signs C2PA as "Stability AI Ltd", no SynthID, no imwatermark on its current Stable Image model — issuer added to `C2PA_ISSUERS`), and **Canva** (Magic Media signs C2PA as "Canva" + `trainedAlgorithmicMedia` with a generic `c2pa-rs` claim generator, no SynthID — issuer `b"Canva"` → "Canva (Magic Media)"; verified samples disproved the earlier assumption that Canva downloads always strip C2PA). Still unsampled: Getty, Shutterstock. Midjourney embeds NO C2PA and no invisible watermark (our `mj-*` sample carried only the IPTC tag).
|
||||
- **C2PA / IPTC (covered by the issuer/marker scan):** OpenAI, Google, Adobe Firefly, Microsoft (Copilot + Designer; Bing Image Creator collected 2026-05-24 still signs as "Microsoft" and now runs **MAI-Image**, NOT OpenAI/DALL-E), **Stability AI** (collected from Brand Studio / DreamStudio successor; signs C2PA as "Stability AI Ltd", no SynthID, no imwatermark on its current Stable Image model — issuer added to `C2PA_ISSUERS`), and **Canva** (Magic Media signs C2PA as "Canva" + `trainedAlgorithmicMedia` with a generic `c2pa-rs` claim generator, no SynthID — issuer `b"Canva"` → "Canva (Magic Media)"; verified samples disproved the earlier assumption that Canva downloads always strip C2PA). Still unsampled: Getty, Shutterstock. Midjourney embeds NO C2PA and no invisible watermark (our `mj-*` sample carried only the IPTC tag).
|
||||
|
||||
**Samsung Galaxy AI** signs supported edits with C2PA and may carry the
|
||||
proprietary `genAIType` marker. The registered visible detector covers the
|
||||
@@ -22,8 +22,15 @@ icon-only variants need separate calibrated silhouettes.
|
||||
|
||||
**Black Forest Labs (FLUX)** API output signs C2PA: `claim_generator_info "Black Forest Labs API"` + a `c2pa.ai_generated_content` assertion + `trainedAlgorithmicMedia` (issuer `b"Black Forest Labs"` added to `C2PA_ISSUERS`, platform "Black Forest Labs (FLUX)").
|
||||
|
||||
**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 (Doubao / Jimeng / 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"` now 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.
|
||||
Some applications sign C2PA through an upstream model or infrastructure
|
||||
provider. For AI claims, exact product mappings in `claim_generator` therefore
|
||||
take precedence over issuer attribution. Supported mappings include Higgsfield
|
||||
AI, Topaz Labs Image API, and TikTok Ad Creative Toolbox; an unknown claim
|
||||
generator still falls back to the certificate issuer.
|
||||
|
||||
**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 (Doubao / Jimeng / Dreamina / 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"` now maps directly to "BytePlus (ByteDance)". ByteDance's consumer app **Dreamina** (the international Jimeng brand) signs as **"Bytedance Pte. Ltd."** with a `Dreamina/x.y` claim generator but, unlike the Volcano Engine surface, ships **no `trainedAlgorithmicMedia`**. Issuer `b"Dreamina"` maps to "ByteDance (Dreamina)" with **`asserts_ai=True`**. Registering the broader **issuer** `b"Bytedance Pte"` was deliberately avoided because that same entity also signs non-AI CapCut edits; keying on the `Dreamina` generator token is precise.
|
||||
- **EXIF/XMP/PNG-text generator tag (caught by `exif_generator`):** **Ideogram** writes EXIF `Make="Ideogram AI"` (collected 2026-05-24 — no C2PA, no SynthID, no imwatermark; the Make tag is the only signal). Additional verified generator stamps include **NovelAI** (`Software`, `Source`, and `Title` PNG text chunks), **Reve** (`Software` or XMP `CreatorTool` = `reve.com`), and **Aphrodite AI** (`Make` or `Software` = `Aphrodite AI`).
|
||||
- **App-export provenance and AIGC JSON:** supported ByteDance-family exports can place a JSON object in EXIF `ImageDescription` or `UserComment`, independently of C2PA or TC260. Exact `product` values for Doubao, Xinghui, and Dreamina are removable product provenance, but do not alone prove that the pixels were generated. Dreamina additionally requires `exportType=generation` for that verdict. A nested Aweme `aigc_type=1` or private ByteDance `aigc_label_type=1` / `2` is an AIGC disclosure; `0` is inconclusive and can occur on a Dreamina generation export. Plain Aweme, retouch, and `lv` exports are preserved. The lower-case private field is deliberately not interpreted as the normative TC260 `Label`, whose values `1` / `2` / `3` mean generated / possibly generated / suspected generated under [GB 45438-2025](https://www.tc260.org.cn/upload/2025-03-15/1742009439794081593.pdf).
|
||||
- **xAI / Grok — its own EXIF signature scheme, NOT C2PA (DETECTED by `metadata.xai_signature`, built 2026-05-26).**
|
||||
|
||||
Grok JPEG downloads (Aurora model) carry **no C2PA, no XMP, no SynthID, no IPTC** — only EXIF `Artist` = a UUID and EXIF `ImageDescription` = `Signature: <base64>` (a crypto signature, unverifiable locally without xAI's public key). This empirically kills the earlier unverified "xAI signs C2PA as xAI" lead — xAI is not even a C2PA member. `exif_generator` misses it (neither field holds an `AI_GENERATOR_TOKENS` token), so a dedicated detector `xai_signature(path)` matches the pair (`ImageDescription ~ ^Signature: [A-Za-z0-9+/=]{64,}` AND UUID `Artist`); wired into `has_ai_metadata`, `get_ai_metadata` (key `xai_signature`), and `identify` (signal `xai_signature`, platform "xAI (Grok / Aurora)").
|
||||
@@ -38,7 +45,7 @@ pair. On the ISOBMFF path, `blank_ai_exif_tokens` provides the corresponding
|
||||
in-place scrub for supported EXIF values, TC260 AIGC blocks, and the xAI pair.
|
||||
- **China TC260 AIGC label (caught by `AIGC_MARKERS` / `metadata.aigc_label`, surfaced by `identify` as the `aigc` signal):** China-served generators embed an XMP `<TC260:AIGC>{"Label":"1","ContentProducer":...}` block — China's mandatory AI-content labeling (TC260 namespace `tc260.org.cn/ns/AIGC`). The label says only "this is AI", but its `ContentProducer` names the signing entity — `001` + `1` + an 18-char Unified Social Credit Code + a 5-digit product suffix, normalized by `metadata.uscc_of`, or for a few generators a bare product name. `KnownMark.tc260_producer_codes` maps the codes settled per vendor by `scripts/vendor_cohort_harvest.py` to registry mark keys, so an AIGC image relaxes the detector of the vendor it actually carries rather than a guessed pair; an unmapped or absent producer falls back to ByteDance's two products. A code identifies a legal entity, not necessarily one brand, so a hosting or aggregating platform that signs for several apps is a recall bet rather than a proof.
|
||||
|
||||
**Doubao** (ByteDance) uses it (verified on a public issue sample; `ContentProducer` `001191110102MACQD9K64010000`, no C2PA/SynthID/imwatermark — the XMP block is the only signal; GitHub attachment upload did NOT strip it). The same standard is mandatory for Jimeng/Kling/Qwen/Ernie etc., so the one marker covers the whole China-AIGC-labeled ecosystem. `aigc_label` reads **four image serializations** through a shared `_parse` helper: the HTML-entity-encoded XMP `TC260:AIGC` block in **either RDF form** — the nested element `<TC260:AIGC>{...}</TC260:AIGC>` (Doubao) or the attribute `TC260:AIGC="{...}"` (**PicWish**, `ContentProducer="picwish"`, verified on compatible samples) — via a container-agnostic raw-byte scan (any JSON object accepted), a raw-JSON PNG `AIGC` tEXt chunk (Doubao also writes the label this way, no namespaced marker at all — confirmed on compatible samples, `ContentProducer="doubao"`), a bare raw-JSON `{"AIGC":{...}}` object embedded in **JPEG EXIF (UserComment)** by some China-served generators, brace-matched from the scan head with `json.JSONDecoder().raw_decode` (no namespaced marker, no PNG chunk — confirmed on compatible samples, `ContentProducer="001191440300708461136T1308L"`), **and** a bare `AIGC{...}` blob (the label glued straight to its JSON, no `"AIGC":` key wrapper) embedded in a **JPEG APP segment near the JFIF header** — confirmed on compatible samples. The two raw-JSON forms are scanned in one loop (`'"AIGC"'` then `AIGC{`) that **falls through on a non-TC260 / undecodable hit instead of returning** — a quoted `"AIGC"` can appear later in an XMP packet while the real label is a bare `AIGC{...}` earlier in the file, so an unconditional early return on the quoted form would shadow the bare form (the exact bug behind the 06-10 misses). Native MP4/MOV is a fifth serialization: TC260-PG-20257A stores an `AIGC` key in `moov.udta.meta.keys` and the raw JSON in the matching `ilst` item. The seeking parser reaches a tail `moov` without reading `mdat`; removal replaces the key with `free` and blanks the validated value at the same length so every box size and stream offset stays fixed. All generic forms are gated on at least one TC260 field (`TC260_AIGC_FIELDS`) so a generic `AIGC` key cannot false-positive; the namespaced XMP element is unambiguous and needs no gate. `TC260_AIGC_FIELDS` covers **two schemas**: the producer-side one (`Label` / `ContentProducer` / `ProduceID` / `ContentPropagator` / `PropagateID`, Doubao and most China gens) and the **service-provider** one (`ServiceProvider` / `ServiceUser`, plus generic `Time` / `ContentId` which are NOT gated on) — **Tencent Cloud's** AIGC variant (`ServiceProvider` = `腾讯云`), embedded in **EXIF `ImageDescription`**, verified on compatible samples. In `identify`, `aigc` fires on the parsed label **or** the `AIGC_MARKERS` byte scan (the latter preserves the laundering-tell case where the JSON payload is truncated).
|
||||
**Doubao** (ByteDance) uses it (verified on a public issue sample; `ContentProducer` `001191110102MACQD9K64010000`, no C2PA/SynthID/imwatermark — the XMP block is the only signal; GitHub attachment upload did NOT strip it). The same standard is mandatory for Jimeng/Kling/Qwen/Ernie etc., so the one marker covers the whole China-AIGC-labeled ecosystem. `aigc_label` reads **four image serializations** through a shared `_parse` helper: the HTML-entity-encoded XMP `TC260:AIGC` block in **either RDF form** — the nested element `<TC260:AIGC>{...}</TC260:AIGC>` (Doubao) or the attribute `TC260:AIGC="{...}"` (**PicWish**, `ContentProducer="picwish"`, verified on compatible samples) — via a container-agnostic raw-byte scan (any JSON object accepted), a raw-JSON PNG `AIGC` tEXt chunk (Doubao also writes the label this way, no namespaced marker at all — confirmed on compatible samples, `ContentProducer="doubao"`), a bare raw-JSON `{"AIGC":{...}}` object embedded in **JPEG EXIF (UserComment)** by some China-served generators, brace-matched from the scan head with `json.JSONDecoder().raw_decode` (no namespaced marker, no PNG chunk — confirmed on compatible samples, `ContentProducer="001191440300708461136T1308L"`), **and** a bare `AIGC{...}` blob (the label glued straight to its JSON, no `"AIGC":` key wrapper) embedded in a **JPEG APP segment near the JFIF header** — confirmed on compatible samples. The two raw-JSON forms are scanned in one loop (`'"AIGC"'` then `AIGC{`) that **falls through on a non-TC260 / undecodable hit instead of returning** — a quoted `"AIGC"` can appear later in an XMP packet while the real label is a bare `AIGC{...}` earlier in the file, so an unconditional early return on the quoted form would shadow the bare form (the exact bug behind the 06-10 misses). Native MP4/MOV is a fifth serialization: TC260-PG-20257A stores an `AIGC` key in `moov.udta.meta.keys` and the raw JSON in the matching `ilst` item; Doubao's iOS export additionally writes two QuickTime-form variants (a bare `meta` box as a direct `moov` child, and a keyless `hdlr=mdir` metadata list whose `ilst` data items carry the JSON with no key name) — both verified on retained carriers (2026-08-17). The seeking parser reaches a tail `moov` without reading `mdat`; removal replaces the key with `free` and blanks the validated value at the same length so every box size and stream offset stays fixed. All generic forms are gated on at least one TC260 field (`TC260_AIGC_FIELDS`) so a generic `AIGC` key cannot false-positive; the namespaced XMP element is unambiguous and needs no gate. `TC260_AIGC_FIELDS` covers **two schemas**: the producer-side one (`Label` / `ContentProducer` / `ProduceID` / `ContentPropagator` / `PropagateID`, Doubao and most China gens) and the **service-provider** one (`ServiceProvider` / `ServiceUser`, plus generic `Time` / `ContentId` which are NOT gated on) — **Tencent Cloud's** AIGC variant (`ServiceProvider` = `腾讯云`), embedded in **EXIF `ImageDescription`**, verified on compatible samples. In `identify`, `aigc` fires on the parsed label **or** the `AIGC_MARKERS` byte scan (the latter preserves the laundering-tell case where the JSON payload is truncated).
|
||||
|
||||
Native MKV/WebM is a sixth serialization. TC260-PG-20257A stores
|
||||
`TagName=AIGC` and the raw JSON `TagString` in
|
||||
@@ -58,7 +65,8 @@ payloads. Removal remuxes either container through ffmpeg with stream copy.
|
||||
DWT-DCT decoder only applies when the producing pipeline actually ran its
|
||||
encoder and the carrier remains decodable.
|
||||
- **Invisible but NOT locally detectable (proprietary, API/oracle only — same wall as SynthID):** Amazon Titan Image Generator + Nova Canvas (Bedrock `DetectGeneratedContent` API), Kakao (new SynthID image adopter, May 2026), NVIDIA Cosmos (SynthID video). No local detector possible; treat like SynthID.
|
||||
- **C2PA 2.4 "Durable Content Credentials" (April 2026; verified against the spec) raise the bar for metadata stripping.** 2.4 defines soft bindings (an invisible watermark or a content fingerprint) plus a server-side manifest repository and a new `c2pa.repository-receipt` assertion. Per the spec: "if a C2PA manifest is removed from an asset, but a copy of that manifest remains in a provenance store elsewhere, the manifest and asset may be matched using available soft bindings." So our local `metadata --remove` deletes the *embedded* manifest, but a fingerprint/watermark soft binding can still re-link the image to its manifest in a repository server-side. Stripping the file is becoming necessary-but-not-sufficient against durable provenance. (Our parsers target the stable embedded-manifest format documented in C2PA 2.1 §11; that format is unchanged in 2.4 -- the new pieces are repository/soft-binding infra, not the on-file box layout, so no parser change is implied.) Spec: https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html We now READ the soft-binding `alg` (`C2PA_SOFT_BINDINGS` / `soft_binding_vendors_in`) to name the forensic-watermark vendor, and locally DECODE the one open scheme, Adobe TrustMark (`trustmark_detector`); the rest (Digimarc/Imatag/Steg.AI/...) stay name-only (proprietary decoders).
|
||||
- **C2PA 2.4 "Durable Content Credentials" (April 2026; verified against the spec) raise the bar for metadata stripping.** 2.4 defines soft bindings (an invisible watermark or a content fingerprint) plus a server-side manifest repository and a new `c2pa.repository-receipt` assertion. Per the spec: "if a C2PA manifest is removed from an asset, but a copy of that manifest remains in a provenance store elsewhere, the manifest and asset may be matched using available soft bindings." So our local `metadata --remove` deletes the *embedded* manifest, but a fingerprint/watermark soft binding can still re-link the image to its manifest in a repository server-side. Stripping the file is becoming necessary-but-not-sufficient against durable provenance. (Our parsers target the stable embedded-manifest format documented in C2PA 2.1 §11; that format is unchanged in 2.4 -- the new pieces are repository/soft-binding infra, not the on-file box layout, so no parser change is implied.) Spec: https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html We now READ the soft-binding `alg` (`C2PA_SOFT_BINDINGS` / `soft_binding_vendors_in`) to name the forensic-watermark vendor, preserve the structured assertion's signed `value`, and locally DECODE the one open scheme, Adobe TrustMark (`trustmark_detector`); the rest stay name-only unless a compatible decoder is independently verified.
|
||||
- **Microsoft Paint and Photos InvisMark (reverse-engineered 2026-08-20):** Paint receives a per-generation GUID from remote prompt moderation, embeds it into locally generated pixels, and records the same value in `c2pa.soft-binding` under `com.microsoft.invismark.1`. The C2PA soft-binding registry independently identifies that algorithm as Microsoft Responsible AI InvisMark for image and video. Paint's 144-bit writer framing does not match the public repository's 100-bit pretrained checkpoint interface, so compatibility is not assumed. The parser reports the signed identifier; there is no validated local pixel decoder. Microsoft's external Content Provenance Detection API is the removal oracle because it reports `Watermark` separately from `C2PA`; a metadata-stripped, pixel-identical control must remain watermark-positive before an output-negative result is attributed to pixel removal. Sources: https://xusheng.dev/posts/reversing/mspaint_invisible_watermark/main/, https://github.com/c2pa-org/softbinding-algorithm-list/blob/main/softbinding-algorithm-list.json, and https://learn.microsoft.com/en-us/azure/ai-services/content-safety/how-to/how-to-provenance-detection
|
||||
- **Built in the dated batch:** soft-binding vendor detection, IPTC Photo
|
||||
Metadata AI-disclosure fields, C2PA detection and stripping for supported
|
||||
ISOBMFF video, the optional Adobe TrustMark decoder, and temporally stabilized
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
schema_version: 1
|
||||
|
||||
context:
|
||||
version: "0.26.0"
|
||||
python_min: "3.10"
|
||||
|
||||
package:
|
||||
name: remove-ai-watermarks
|
||||
version: ${{ version }}
|
||||
|
||||
source:
|
||||
url: https://pypi.org/packages/source/r/remove-ai-watermarks/remove_ai_watermarks-${{ version }}.tar.gz
|
||||
sha256: 0a8c8126d74cd7f818e5595eef2f269db5cefb8d90febd424f991c5850c8025b
|
||||
|
||||
build:
|
||||
noarch: python
|
||||
number: 0
|
||||
script: python -m pip install . -vv --no-deps --no-build-isolation
|
||||
|
||||
requirements:
|
||||
host:
|
||||
- python ${{ python_min }}.*
|
||||
- pip
|
||||
- hatchling
|
||||
run:
|
||||
- python >=${{ python_min }}
|
||||
- pillow >=10.0.0
|
||||
- piexif >=1.1.3
|
||||
- click >=8.0.0
|
||||
- python-dotenv >=1.0.0
|
||||
# c2pa-python is a default PyPI dependency but is not packaged on conda-forge.
|
||||
# The guarded import falls back to the built-in C2PA byte scanner when it is
|
||||
# absent. Add it here once a c2pa-python feedstock exists.
|
||||
|
||||
tests:
|
||||
- python:
|
||||
imports:
|
||||
- remove_ai_watermarks
|
||||
- remove_ai_watermarks.identify
|
||||
- remove_ai_watermarks.metadata
|
||||
python_version: ${{ python_min }}.*
|
||||
# pip_check defaults to true; disable it explicitly. `pip check` fails on
|
||||
# the conda-forge piexif `py_2` build, whose stale 2019 metadata pip reads
|
||||
# as "piexif 1.1.3 is not supported on this platform" -- even though the
|
||||
# package installs, imports, and works. The imports test verifies loading.
|
||||
pip_check: false
|
||||
|
||||
about:
|
||||
homepage: https://github.com/wiltodelta/remove-ai-watermarks
|
||||
summary: Inspect and strip AI provenance metadata from media
|
||||
description: |
|
||||
Inspect and strip AI-provenance metadata (C2PA, EXIF, IPTC, and PNG text
|
||||
chunks) from images and supported video containers. Optional pip extras add
|
||||
visible watermark removal, video processing, SynthID diffusion removal, and
|
||||
additional invisible-watermark detectors.
|
||||
license: Apache-2.0
|
||||
license_file: LICENSE
|
||||
repository: https://github.com/wiltodelta/remove-ai-watermarks
|
||||
|
||||
extra:
|
||||
recipe-maintainers:
|
||||
- wiltodelta
|
||||
+33
-22
@@ -1,9 +1,13 @@
|
||||
[project]
|
||||
# This name is the published PyPI distribution, not a local label: changing it
|
||||
# strands every `pip install remove-ai-watermarks` and every pinned dependency
|
||||
# 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.26.1"
|
||||
version = "0.30.1"
|
||||
description = "AI watermark remover for visible, invisible, and provenance marks in images and video"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10.1"
|
||||
requires-python = ">=3.11,<3.15"
|
||||
license = {text = "Apache-2.0"}
|
||||
keywords = [
|
||||
"ai-watermark",
|
||||
@@ -33,7 +37,6 @@ classifiers = [
|
||||
"License :: OSI Approved :: Apache Software License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3 :: Only",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
@@ -61,7 +64,10 @@ dependencies = [
|
||||
|
||||
[project.optional-dependencies]
|
||||
pixels = [
|
||||
"numpy>=1.24.0",
|
||||
"numpy>=1.24.0; python_version < '3.13'",
|
||||
# NumPy 1.x has no CPython 3.13+ wheels. Requiring 2.x prevents installers
|
||||
# from attempting an unsupported source build on the newer Python lines.
|
||||
"numpy>=2; python_version >= '3.13'",
|
||||
"opencv-python-headless>=4.8.0",
|
||||
]
|
||||
# Optional HEIC/AVIF pixel decode. Metadata scanning handles these containers
|
||||
@@ -71,12 +77,10 @@ heif = [
|
||||
]
|
||||
visible = ["remove-ai-watermarks[pixels]"]
|
||||
# Video visible removal uses the shared pixel runtime. PyAV packetizes processed
|
||||
# VFR frames with explicit PTS before system ffmpeg encodes them. PyAV 18 requires
|
||||
# Python 3.11; the 16.x wheel line still covers Python 3.10.
|
||||
# VFR frames with explicit PTS before system ffmpeg encodes them.
|
||||
video = [
|
||||
"remove-ai-watermarks[visible]",
|
||||
"av>=16,<17; python_version < '3.11'",
|
||||
"av>=18,<19; python_version >= '3.11'",
|
||||
"av>=18,<19",
|
||||
]
|
||||
# Open DWT-DCT watermarks used by Stable Diffusion / SDXL / FLUX. The in-tree
|
||||
# decoder avoids the upstream invisible-watermark package's mandatory torch and
|
||||
@@ -116,12 +120,27 @@ qwen-zimage = [
|
||||
"diffsynth>=2.0.17,<3",
|
||||
"torchvision>=0.20.0",
|
||||
]
|
||||
# Opt-in verified-text reconstruction over qwen-zimage. LaMa removes the changed
|
||||
# candidate glyphs before exact Qwen-VAE cores are composited back.
|
||||
text-restoration = [
|
||||
"remove-ai-watermarks[qwen-zimage,lama]",
|
||||
]
|
||||
# Draft-only OCR proposals for verified-text manifests. CPU, no torch:
|
||||
# PaddleOCR detection plus three script-chosen recognition engines. The models
|
||||
# download on first use and are cached by huggingface_hub; never bundled.
|
||||
text-draft = [
|
||||
"paddleocr>=3.3.3",
|
||||
"paddlepaddle>=3.0.0",
|
||||
"huggingface-hub>=0.20.0",
|
||||
]
|
||||
# Adobe TrustMark decoder -- the open, keyless watermark behind Adobe Durable
|
||||
# Content Credentials (soft-binding alg ``com.adobe.trustmark.P``). Optional
|
||||
# because it pulls torch and downloads model weights on first use. identify()
|
||||
# guards the import and skips the TrustMark signal when absent.
|
||||
trustmark = [
|
||||
"trustmark>=0.8.0",
|
||||
# TrustMark requires NumPy <2, whose last release has no CPython 3.13+
|
||||
# wheels. Keep this feature installable only on its real runtime range.
|
||||
"trustmark>=0.8.0; python_version < '3.13'",
|
||||
]
|
||||
# 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
|
||||
@@ -129,10 +148,7 @@ trustmark = [
|
||||
# eraser backend needs none of this.
|
||||
lama = [
|
||||
"remove-ai-watermarks[visible]",
|
||||
# ONNX Runtime 1.24 dropped CPython 3.10 wheels; keep the project's
|
||||
# supported 3.10 line on the last compatible release series.
|
||||
"onnxruntime>=1.16.0,<1.24; python_version < '3.11'",
|
||||
"onnxruntime>=1.16.0; python_version >= '3.11'",
|
||||
"onnxruntime>=1.24.0",
|
||||
"huggingface-hub>=0.20.0",
|
||||
]
|
||||
# Lightweight inpaint backend -- MI-GAN via onnxruntime (andraniksargsyan/migan,
|
||||
@@ -142,8 +158,7 @@ lama = [
|
||||
# LaMa, the quality-first `auto` choice, is too large. Same runtime as `lama`.
|
||||
migan = [
|
||||
"remove-ai-watermarks[visible]",
|
||||
"onnxruntime>=1.16.0,<1.24; python_version < '3.11'",
|
||||
"onnxruntime>=1.16.0; python_version >= '3.11'",
|
||||
"onnxruntime>=1.24.0",
|
||||
"huggingface-hub>=0.20.0",
|
||||
]
|
||||
dev = [
|
||||
@@ -156,14 +171,10 @@ dev = [
|
||||
"ruff>=0.4.0",
|
||||
"pyright>=1.1.0",
|
||||
"invisible-watermark>=0.2.0",
|
||||
# maintain.sh helpers; they only support newer Pythons, so gate them by
|
||||
# marker to keep the py3.10 resolution (and CI matrix) solvable.
|
||||
"uv-outdated>=0.1.0; python_version >= '3.12'",
|
||||
"uv-secure>=0.12.0; python_version >= '3.12'",
|
||||
]
|
||||
# ``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,qwen-zimage,lama,migan]"]
|
||||
all = ["remove-ai-watermarks[video,heif,detect,trustmark,text-restoration,migan]"]
|
||||
|
||||
[project.scripts]
|
||||
remove-ai-watermarks = "remove_ai_watermarks.cli:main"
|
||||
@@ -200,7 +211,7 @@ pythonpath = ["src"]
|
||||
addopts = "-v --tb=short"
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py310"
|
||||
target-version = "py311"
|
||||
line-length = 120
|
||||
exclude = ["_refs"]
|
||||
extend-exclude = ["*.md"]
|
||||
@@ -223,7 +234,7 @@ quote-style = "double"
|
||||
indent-style = "space"
|
||||
|
||||
[tool.pyright]
|
||||
pythonVersion = "3.10"
|
||||
pythonVersion = "3.11"
|
||||
typeCheckingMode = "strict"
|
||||
exclude = ["_refs"]
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Pure text-normalization helpers shared by evaluation scripts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unicodedata
|
||||
|
||||
|
||||
def normalize_text(text: str) -> str:
|
||||
"""Normalize text for layout-independent evaluation comparisons."""
|
||||
return "".join(unicodedata.normalize("NFC", text).casefold().split())
|
||||
|
||||
|
||||
def levenshtein_normalized(left: str, right: str) -> float:
|
||||
"""Return normalized Levenshtein distance without changing either input."""
|
||||
if not left and not right:
|
||||
return 0.0
|
||||
previous = list(range(len(right) + 1))
|
||||
for left_index, left_character in enumerate(left, start=1):
|
||||
current = [left_index]
|
||||
for right_index, right_character in enumerate(right, start=1):
|
||||
current.append(
|
||||
min(
|
||||
current[-1] + 1,
|
||||
previous[right_index] + 1,
|
||||
previous[right_index - 1] + (left_character != right_character),
|
||||
)
|
||||
)
|
||||
previous = current
|
||||
return previous[-1] / max(len(left), len(right))
|
||||
|
||||
|
||||
def normalized_edit_distance(left: str, right: str) -> float:
|
||||
"""Normalize text, then return Levenshtein distance over the result."""
|
||||
return levenshtein_normalized(normalize_text(left), normalize_text(right))
|
||||
@@ -1,12 +1,11 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = [
|
||||
# "click",
|
||||
# "numpy",
|
||||
# "opencv-python-headless",
|
||||
# "opencv-python-headless<5",
|
||||
# "pillow",
|
||||
# "scikit-image",
|
||||
# "rapidfuzz",
|
||||
# "torch",
|
||||
# "lpips",
|
||||
# "paddleocr",
|
||||
@@ -29,7 +28,7 @@ metrics run only where faces are detected, text metrics only where text is.
|
||||
|
||||
Two subcommands:
|
||||
|
||||
ocr -- OCR images (PaddleOCR PP-OCRv6) into a JSON {basename: text} file.
|
||||
ocr -- OCR images (PaddleOCR defaults) into a JSON {basename: text} file.
|
||||
Run this on the ORIGINALS, hand-verify/correct the file, and it
|
||||
becomes the ground truth for ``compare --ground-truth`` -- the clean
|
||||
way to score text, since OCR-vs-OCR is doubly noisy (errors on both
|
||||
@@ -54,6 +53,7 @@ Usage:
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import unicodedata
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
@@ -65,7 +65,10 @@ import numpy as np
|
||||
from _plain_console import Console, Table
|
||||
|
||||
console = Console()
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from scripts._text_eval import levenshtein_normalized # noqa: E402
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -89,7 +92,7 @@ def _norm(text: str) -> str:
|
||||
return "".join(unicodedata.normalize("NFC", text).split())
|
||||
|
||||
|
||||
# ── text: PaddleOCR (PP-OCRv6) ───────────────────────────────────────
|
||||
# ── text: PaddleOCR defaults ─────────────────────────────────────────
|
||||
|
||||
# Our lang codes -> PaddleOCR lang. The 'ch' model also reads Latin; 'ru' reads
|
||||
# Cyrillic + Latin. Multiple langs in one image -> run each model, union detections.
|
||||
@@ -165,9 +168,7 @@ def _ocr_lines(bgr: np.ndarray, langs: list[str], min_score: float = 0.5) -> lis
|
||||
|
||||
|
||||
def _cer(ref: str, hyp: str) -> float:
|
||||
from rapidfuzz.distance import Levenshtein
|
||||
|
||||
return Levenshtein.normalized_distance(_norm(ref), _norm(hyp))
|
||||
return levenshtein_normalized(_norm(ref), _norm(hyp))
|
||||
|
||||
|
||||
# ── face: detection + ArcFace + texture ──────────────────────────────
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = [
|
||||
# "click",
|
||||
# "huggingface-hub>=0.20.0",
|
||||
# "numpy",
|
||||
# "onnxruntime>=1.24.0",
|
||||
# "opencv-python-headless<5",
|
||||
# "paddleocr>=3.3.3",
|
||||
# "paddlepaddle",
|
||||
# "pillow",
|
||||
# ]
|
||||
# ///
|
||||
"""Infer stable source-text lines without modifying an image.
|
||||
|
||||
This evaluation-only dry run proposes line annotations for selective text
|
||||
restoration. Every proposal still needs human verification: stable OCR can lose
|
||||
punctuation with high confidence. It separately flags lines whose recognition
|
||||
changes under crop jitter or whose minimum confidence is below the threshold.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
# Dogfoods the packaged draft API (remove_ai_watermarks.text_draft); this script
|
||||
# keeps only the CLI wrapper so the package stays the one home for the logic.
|
||||
from remove_ai_watermarks.text_draft import ( # noqa: E402
|
||||
_detect_line_boxes,
|
||||
_recognize,
|
||||
choose_language,
|
||||
stable_recognition,
|
||||
)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
||||
@click.option("--out", required=True, type=click.Path(dir_okay=False, path_type=Path))
|
||||
@click.option("--min-score", default=0.85, show_default=True, type=click.FloatRange(0.0, 1.0))
|
||||
def main(source: Path, out: Path, min_score: float) -> None:
|
||||
"""Write draft line text for SOURCE; manually verify every proposal."""
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
||||
os.environ["PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK"] = "True"
|
||||
from paddleocr import PaddleOCR, TextRecognition
|
||||
|
||||
source_rgb = np.asarray(Image.open(source).convert("RGB"))
|
||||
detector = PaddleOCR(
|
||||
lang="ch",
|
||||
use_doc_orientation_classify=False,
|
||||
use_doc_unwarping=False,
|
||||
use_textline_orientation=False,
|
||||
)
|
||||
engines = {
|
||||
"en": TextRecognition(model_name="en_PP-OCRv5_mobile_rec"),
|
||||
"ru": TextRecognition(model_name="eslav_PP-OCRv5_mobile_rec"),
|
||||
"ch": TextRecognition(model_name="PP-OCRv5_server_rec"),
|
||||
}
|
||||
boxes = _detect_line_boxes(detector, source_rgb)
|
||||
accepted = []
|
||||
rejected = []
|
||||
for box in boxes:
|
||||
probes = {}
|
||||
for language, engine in engines.items():
|
||||
script = "cjk" if language == "ch" else "alphabetic"
|
||||
probes[language] = _recognize(engine, source_rgb, box, script, 0.1)
|
||||
language = choose_language(probes)
|
||||
script = "cjk" if language == "ch" else "alphabetic"
|
||||
reads = [_recognize(engines[language], source_rgb, box, script, ratio) for ratio in (0.08, 0.12, 0.2)]
|
||||
text = stable_recognition(reads, min_score)
|
||||
result = {
|
||||
"box": box,
|
||||
"script": script,
|
||||
"language": language,
|
||||
"reads": [{"text": value, "score": score} for value, score in reads],
|
||||
}
|
||||
if text is None:
|
||||
rejected.append(result)
|
||||
else:
|
||||
accepted.append({"box": box, "text": text, "script": script, "min_score": min(score for _, score in reads)})
|
||||
payload = {"source": source.name, "accepted": accepted, "rejected": rejected}
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
log.info("Accepted %s lines and rejected %s uncertain lines", len(accepted), len(rejected))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,5 +1,5 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = [
|
||||
# "diffusers>=0.35.0",
|
||||
# "transformers>=4.51.0",
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = [
|
||||
# "click",
|
||||
# "huggingface-hub>=0.20.0",
|
||||
# "numpy",
|
||||
# "onnxruntime>=1.24.0",
|
||||
# "opencv-python-headless<5",
|
||||
# "paddleocr>=3.3.3",
|
||||
# "paddlepaddle",
|
||||
# "pillow",
|
||||
# ]
|
||||
# ///
|
||||
"""Evaluation-only text restoration over a scrubbed image.
|
||||
|
||||
``vae-glyphs`` composites only thresholded glyph-core pixels from a separately
|
||||
generated VAE reconstruction over a fresh silhouette edge. ``source-glyphs``
|
||||
can preserve typeface, layout, color, and antialiasing when
|
||||
SOURCE is itself a regenerated layer. It must not be treated as safe when SOURCE
|
||||
is the watermarked original: a provider oracle detected SynthID after that exact
|
||||
paste-back experiment. ``source-silhouette`` instead transfers only a
|
||||
thresholded glyph shape, then synthesizes fresh flat-color pixels and
|
||||
antialiasing. ``rerender`` retains the system-font negative control. This is
|
||||
not a production stage and does not add PaddleOCR to the package graph.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import unicodedata
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from remove_ai_watermarks import region_eraser # noqa: E402
|
||||
from remove_ai_watermarks._internal.text_restoration import VerifiedTextLine as TextLine # noqa: E402
|
||||
from remove_ai_watermarks._internal.text_restoration import ( # noqa: E402
|
||||
composite_fresh_text_edges,
|
||||
composite_reconstructed_glyphs,
|
||||
group_text_lines,
|
||||
residual_glyph_mask,
|
||||
source_silhouette_mask,
|
||||
)
|
||||
from scripts._text_eval import normalize_text, normalized_edit_distance # noqa: E402
|
||||
|
||||
if ROOT not in Path(region_eraser.__file__).resolve().parents:
|
||||
raise RuntimeError("selective_text_restoration imported outside the current worktree")
|
||||
|
||||
REGULAR_FONT = Path("/System/Library/Fonts/Supplemental/Arial.ttf")
|
||||
BOLD_FONT = Path("/System/Library/Fonts/Supplemental/Arial Bold.ttf")
|
||||
CJK_FONT = Path("/System/Library/Fonts/STHeiti Medium.ttc")
|
||||
|
||||
|
||||
def should_preserve_line(
|
||||
expected: str,
|
||||
source_text: str,
|
||||
source_score: float,
|
||||
candidate_text: str,
|
||||
candidate_score: float,
|
||||
) -> bool:
|
||||
if min(source_score, candidate_score) < 0.75:
|
||||
return False
|
||||
if normalized_edit_distance(expected, source_text) > 0.25:
|
||||
return False
|
||||
return normalize_text(source_text) == normalize_text(candidate_text)
|
||||
|
||||
|
||||
def composite_source_glyphs(
|
||||
source_rgb: np.ndarray,
|
||||
background_rgb: np.ndarray,
|
||||
glyph_mask: np.ndarray,
|
||||
*,
|
||||
feather: float = 0.7,
|
||||
) -> np.ndarray:
|
||||
"""Composite exact source pixels inside a glyph mask with an outer feather."""
|
||||
if source_rgb.shape != background_rgb.shape or source_rgb.shape[:2] != glyph_mask.shape:
|
||||
raise ValueError("source, background, and glyph mask dimensions must match")
|
||||
blurred = cv2.GaussianBlur(glyph_mask, (0, 0), feather) if feather > 0 else glyph_mask
|
||||
alpha = np.maximum(glyph_mask, blurred).astype(np.float32) / 255.0
|
||||
alpha = alpha[..., None]
|
||||
combined = source_rgb.astype(np.float32) * alpha + background_rgb.astype(np.float32) * (1.0 - alpha)
|
||||
return np.clip(combined, 0, 255).astype(np.uint8)
|
||||
|
||||
|
||||
def source_box_mask(
|
||||
shape: tuple[int, int],
|
||||
boxes: list[tuple[int, int, int, int]],
|
||||
) -> np.ndarray:
|
||||
"""Build a padded text-line mask for aligned regenerated layer compositing."""
|
||||
height, width = shape
|
||||
mask = np.zeros((height, width), dtype=np.uint8)
|
||||
for x1, y1, x2, y2 in boxes:
|
||||
pad = max(8, (y2 - y1) // 4)
|
||||
x1, y1, x2, y2 = _clip_box((x1, y1, x2, y2), width, height, pad=pad)
|
||||
mask[y1:y2, x1:x2] = 255
|
||||
return mask
|
||||
|
||||
|
||||
def _clip_box(box: tuple[int, int, int, int], width: int, height: int, pad: int = 0) -> tuple[int, int, int, int]:
|
||||
x1, y1, x2, y2 = box
|
||||
return max(0, x1 - pad), max(0, y1 - pad), min(width, x2 + pad), min(height, y2 + pad)
|
||||
|
||||
|
||||
def foreground_mask(source_rgb: np.ndarray, box: tuple[int, int, int, int]) -> np.ndarray:
|
||||
height, width = source_rgb.shape[:2]
|
||||
line_height = box[3] - box[1]
|
||||
x1, y1, x2, y2 = _clip_box(box, width, height, pad=max(6, int(line_height * 0.12)))
|
||||
gray = cv2.cvtColor(source_rgb[y1:y2, x1:x2], cv2.COLOR_RGB2GRAY)
|
||||
ring_pad = max(8, min(24, (y2 - y1) // 5))
|
||||
rx1, ry1, rx2, ry2 = _clip_box((x1, y1, x2, y2), width, height, pad=ring_pad)
|
||||
context = cv2.cvtColor(source_rgb[ry1:ry2, rx1:rx2], cv2.COLOR_RGB2GRAY)
|
||||
ring = np.ones(context.shape, dtype=bool)
|
||||
ring[y1 - ry1 : y2 - ry1, x1 - rx1 : x2 - rx1] = False
|
||||
background_luma = float(np.median(context[ring])) if ring.any() else float(np.median(gray))
|
||||
low, high = float(np.percentile(gray, 4)), float(np.percentile(gray, 96))
|
||||
dark_contrast, light_contrast = background_luma - low, high - background_luma
|
||||
contrast = max(light_contrast, dark_contrast)
|
||||
threshold = max(24.0, min(72.0, contrast * 0.32))
|
||||
if light_contrast > dark_contrast:
|
||||
mask = (gray.astype(np.float32) >= background_luma + threshold).astype(np.uint8) * 255
|
||||
else:
|
||||
mask = (gray.astype(np.float32) <= background_luma - threshold).astype(np.uint8) * 255
|
||||
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, np.ones((2, 2), np.uint8))
|
||||
dilation = 5 if line_height >= 48 else 3
|
||||
mask = cv2.dilate(mask, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * dilation + 1,) * 2))
|
||||
result = np.zeros((height, width), dtype=np.uint8)
|
||||
result[y1:y2, x1:x2] = mask
|
||||
return result
|
||||
|
||||
|
||||
def _line_language(line: TextLine) -> str:
|
||||
if line.script == "cjk":
|
||||
return "ch"
|
||||
return "ru" if any("CYRILLIC" in unicodedata.name(character, "") for character in line.text) else "en"
|
||||
|
||||
|
||||
def _recognition_box(
|
||||
line: TextLine,
|
||||
width: int,
|
||||
height: int,
|
||||
vertical_pad_ratio: float | None = None,
|
||||
) -> tuple[int, int, int, int]:
|
||||
x1, y1, x2, y2 = line.box
|
||||
line_height = y2 - y1
|
||||
if line.script == "cjk":
|
||||
left_pad = max(16, round(line_height * 0.2))
|
||||
right_pad = max(16, round(line_height * 0.6))
|
||||
return max(0, x1 - left_pad), y1, min(width, x2 + right_pad), y2
|
||||
pad_x = max(16, line_height)
|
||||
pad_y = max(8, line_height // 3) if vertical_pad_ratio is None else max(8, round(line_height * vertical_pad_ratio))
|
||||
return max(0, x1 - pad_x), max(0, y1 - pad_y), min(width, x2 + pad_x), min(height, y2 + pad_y)
|
||||
|
||||
|
||||
def _recognize(
|
||||
engine: Any,
|
||||
image: np.ndarray,
|
||||
line: TextLine,
|
||||
vertical_pad_ratio: float | None = None,
|
||||
) -> tuple[str, float]:
|
||||
height, width = image.shape[:2]
|
||||
x1, y1, x2, y2 = _recognition_box(line, width, height, vertical_pad_ratio)
|
||||
crop = image[y1:y2, x1:x2]
|
||||
if crop.shape[0] < 64:
|
||||
scale = 64 / crop.shape[0]
|
||||
crop = cv2.resize(crop, None, fx=scale, fy=scale, interpolation=cv2.INTER_CUBIC)
|
||||
result = next(iter(engine.predict(crop)))
|
||||
return str(result.get("rec_text", "")), float(result.get("rec_score", 0.0))
|
||||
|
||||
|
||||
def _sample_text_color(
|
||||
source_rgb: np.ndarray,
|
||||
mask: np.ndarray,
|
||||
box: tuple[int, int, int, int],
|
||||
) -> tuple[int, int, int]:
|
||||
height, width = source_rgb.shape[:2]
|
||||
x1, y1, x2, y2 = _clip_box(box, width, height, pad=2)
|
||||
crop = source_rgb[y1:y2, x1:x2]
|
||||
active = mask[y1:y2, x1:x2] > 0
|
||||
pixels = crop[active]
|
||||
luma = pixels.mean(axis=1)
|
||||
background_luma = float(crop[[0, -1], :, :].reshape(-1, 3).mean(axis=1).mean())
|
||||
if background_luma >= 128:
|
||||
selected = pixels[luma <= np.percentile(luma, 20)]
|
||||
else:
|
||||
selected = pixels[luma >= np.percentile(luma, 80)]
|
||||
return tuple(int(value) for value in np.median(selected, axis=0))
|
||||
|
||||
|
||||
def _render_line(image: Image.Image, line: TextLine, color: tuple[int, int, int]) -> None:
|
||||
font_path = CJK_FONT if line.script == "cjk" else (BOLD_FONT if line.box[3] - line.box[1] >= 55 else REGULAR_FONT)
|
||||
target_width, target_height = line.box[2] - line.box[0], line.box[3] - line.box[1]
|
||||
draw = ImageDraw.Draw(image)
|
||||
low, high = 4, max(8, target_height * 2)
|
||||
font = ImageFont.truetype(str(font_path), low)
|
||||
while low <= high:
|
||||
size = (low + high) // 2
|
||||
candidate = ImageFont.truetype(str(font_path), size)
|
||||
bounds = draw.textbbox((0, 0), line.text, font=candidate)
|
||||
if bounds[2] - bounds[0] <= target_width * 1.03 and bounds[3] - bounds[1] <= target_height * 1.08:
|
||||
font, low = candidate, size + 1
|
||||
else:
|
||||
high = size - 1
|
||||
bounds = draw.textbbox((0, 0), line.text, font=font)
|
||||
y = line.box[1] + math.floor((target_height - (bounds[3] - bounds[1])) / 2) - bounds[1]
|
||||
draw.text((line.box[0], y), line.text, fill=color, font=font)
|
||||
|
||||
|
||||
def _write_manifest(path: Path | None, payload: dict[str, Any]) -> None:
|
||||
if path is None:
|
||||
return
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def _vertical_overlap_ratio(left: tuple[int, int, int, int], right: tuple[int, int, int, int]) -> float:
|
||||
overlap = max(0, min(left[3], right[3]) - max(left[1], right[1]))
|
||||
return overlap / max(1, min(left[3] - left[1], right[3] - right[1]))
|
||||
|
||||
|
||||
def group_word_boxes(boxes: list[tuple[int, int, int, int]]) -> list[tuple[int, int, int, int]]:
|
||||
groups: list[tuple[int, int, int, int]] = []
|
||||
for box in sorted(boxes, key=lambda item: ((item[1] + item[3]) / 2, item[0])):
|
||||
matches = []
|
||||
for index, group in enumerate(groups):
|
||||
if _vertical_overlap_ratio(box, group) < 0.45:
|
||||
continue
|
||||
horizontal_gap = max(0, max(box[0], group[0]) - min(box[2], group[2]))
|
||||
line_height = min(box[3] - box[1], group[3] - group[1])
|
||||
if horizontal_gap <= max(24, line_height * 3):
|
||||
matches.append(index)
|
||||
if not matches:
|
||||
groups.append(box)
|
||||
continue
|
||||
index = max(matches, key=lambda item: _vertical_overlap_ratio(box, groups[item]))
|
||||
x1, y1, x2, y2 = groups[index]
|
||||
groups[index] = min(x1, box[0]), min(y1, box[1]), max(x2, box[2]), max(y2, box[3])
|
||||
return sorted(groups, key=lambda item: ((item[1] + item[3]) / 2, item[0]))
|
||||
|
||||
|
||||
def detect_line_boxes(
|
||||
engine: Any,
|
||||
source_rgb: np.ndarray,
|
||||
expected_count: int | None = None,
|
||||
) -> list[tuple[int, int, int, int]]:
|
||||
boxes: list[tuple[int, int, int, int]] = []
|
||||
for page in engine.predict(source_rgb):
|
||||
detected = page.get("rec_boxes", None)
|
||||
if detected is None or len(detected) == 0:
|
||||
detected = page.get("rec_polys", [])
|
||||
for score, raw_box in zip(page.get("rec_scores", []), detected, strict=False):
|
||||
if float(score) < 0.5:
|
||||
continue
|
||||
points = np.asarray(raw_box, dtype=np.float32).reshape(-1)
|
||||
if points.size == 4:
|
||||
x1, y1, x2, y2 = points
|
||||
else:
|
||||
points = points.reshape(-1, 2)
|
||||
x1, y1 = points.min(axis=0)
|
||||
x2, y2 = points.max(axis=0)
|
||||
boxes.append((round(float(x1)), round(float(y1)), round(float(x2)), round(float(y2))))
|
||||
lines = group_word_boxes(boxes)
|
||||
if expected_count is not None and len(lines) != expected_count:
|
||||
raise click.ClickException(f"detected {len(lines)} source lines; expected exactly {expected_count}")
|
||||
return lines
|
||||
|
||||
|
||||
def _load_lines(path: Path, key: str) -> list[TextLine]:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
return [
|
||||
TextLine(tuple(item["box"]), item["text"], item["script"], float(item.get("angle", 0.0)))
|
||||
for item in payload[key]
|
||||
]
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
||||
@click.argument("candidate", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
||||
@click.option("--output", required=True, type=click.Path(dir_okay=False, path_type=Path))
|
||||
@click.option(
|
||||
"--lines-json",
|
||||
default=ROOT / "data/evaluations/fidelity/text-lines.json",
|
||||
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
||||
)
|
||||
@click.option("--source-key", help="Key in lines JSON; defaults to source basename.")
|
||||
@click.option(
|
||||
"--detect-boxes",
|
||||
is_flag=True,
|
||||
help="Detect source boxes; fail unless their count matches verified lines.",
|
||||
)
|
||||
@click.option(
|
||||
"--restoration",
|
||||
type=click.Choice(("vae-glyphs", "source-glyphs", "source-silhouette", "rerender")),
|
||||
required=True,
|
||||
help="Choose VAE glyph cores, regenerated pixels, fresh source shapes, or the system-font control.",
|
||||
)
|
||||
@click.option(
|
||||
"--glyph-donor",
|
||||
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
||||
help="VAE reconstruction used only by --restoration vae-glyphs.",
|
||||
)
|
||||
@click.option(
|
||||
"--glyph-feather",
|
||||
type=click.FloatRange(min=0.0),
|
||||
default=0.5,
|
||||
show_default=True,
|
||||
help="Outer donor-edge feather used only by --restoration vae-glyphs.",
|
||||
)
|
||||
@click.option(
|
||||
"--selection",
|
||||
type=click.Choice(("all", "changed")),
|
||||
default="all",
|
||||
show_default=True,
|
||||
help="Restore every verified line or only OCR-confirmed changes.",
|
||||
)
|
||||
@click.option(
|
||||
"--erase-background/--keep-background",
|
||||
default=True,
|
||||
show_default=True,
|
||||
help="Erase candidate glyphs before compositing, or directly blend an aligned regenerated glyph layer.",
|
||||
)
|
||||
@click.option(
|
||||
"--composite-mask",
|
||||
type=click.Choice(("glyphs", "boxes")),
|
||||
default="glyphs",
|
||||
show_default=True,
|
||||
help="Composite isolated glyphs or complete aligned text-line boxes.",
|
||||
)
|
||||
@click.option("--manifest", type=click.Path(dir_okay=False, path_type=Path))
|
||||
def main(
|
||||
source: Path,
|
||||
candidate: Path,
|
||||
output: Path,
|
||||
lines_json: Path,
|
||||
source_key: str | None,
|
||||
detect_boxes: bool,
|
||||
restoration: str,
|
||||
glyph_donor: Path | None,
|
||||
glyph_feather: float,
|
||||
selection: str,
|
||||
erase_background: bool,
|
||||
composite_mask: str,
|
||||
manifest: Path | None,
|
||||
) -> None:
|
||||
"""Restore SOURCE text over the scrubbed CANDIDATE."""
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
||||
if restoration == "rerender":
|
||||
for font in (REGULAR_FONT, BOLD_FONT, CJK_FONT):
|
||||
if not font.exists():
|
||||
raise click.ClickException(f"required evaluation font is unavailable: {font}")
|
||||
if restoration == "vae-glyphs" and glyph_donor is None:
|
||||
raise click.ClickException("--glyph-donor is required for --restoration vae-glyphs")
|
||||
if restoration != "vae-glyphs" and glyph_donor is not None:
|
||||
raise click.ClickException("--glyph-donor is only valid with --restoration vae-glyphs")
|
||||
os.environ["PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK"] = "True"
|
||||
source_rgb = np.asarray(Image.open(source).convert("RGB"))
|
||||
candidate_rgb = np.asarray(Image.open(candidate).convert("RGB"))
|
||||
if candidate_rgb.shape != source_rgb.shape:
|
||||
raise click.ClickException("source and candidate dimensions must match")
|
||||
donor_rgb = np.asarray(Image.open(glyph_donor).convert("RGB")) if glyph_donor else None
|
||||
if donor_rgb is not None and donor_rgb.shape != source_rgb.shape:
|
||||
raise click.ClickException("source and glyph donor dimensions must match")
|
||||
lines = _load_lines(lines_json, source_key or source.name)
|
||||
annotation_boxes = [line.box for line in lines]
|
||||
if detect_boxes:
|
||||
from paddleocr import PaddleOCR
|
||||
|
||||
page_engine = PaddleOCR(
|
||||
lang="ch",
|
||||
use_doc_orientation_classify=False,
|
||||
use_doc_unwarping=False,
|
||||
use_textline_orientation=False,
|
||||
)
|
||||
boxes = detect_line_boxes(page_engine, source_rgb, len(lines))
|
||||
lines = [TextLine(box, line.text, line.script) for line, box in zip(lines, boxes, strict=True)]
|
||||
vertical_pad_ratio = 0.1 if detect_boxes else None
|
||||
decisions = []
|
||||
selected = list(lines)
|
||||
if selection == "all":
|
||||
decisions = [{"line": asdict(line), "selected": True, "reason": "all-lines"} for line in lines]
|
||||
else:
|
||||
from paddleocr import TextRecognition
|
||||
|
||||
engines = {
|
||||
"en": TextRecognition(model_name="en_PP-OCRv5_mobile_rec"),
|
||||
"ru": TextRecognition(model_name="eslav_PP-OCRv5_mobile_rec"),
|
||||
"ch": TextRecognition(model_name="PP-OCRv5_server_rec"),
|
||||
}
|
||||
selected = []
|
||||
for line in lines:
|
||||
language = _line_language(line)
|
||||
source_text, source_score = _recognize(engines[language], source_rgb, line, vertical_pad_ratio)
|
||||
candidate_text, candidate_score = _recognize(engines[language], candidate_rgb, line, vertical_pad_ratio)
|
||||
preserve = should_preserve_line(line.text, source_text, source_score, candidate_text, candidate_score)
|
||||
decisions.append(
|
||||
{
|
||||
"line": asdict(line),
|
||||
"source_text": source_text,
|
||||
"source_score": source_score,
|
||||
"candidate_text": candidate_text,
|
||||
"candidate_score": candidate_score,
|
||||
"preserve": preserve,
|
||||
"selected": not preserve,
|
||||
}
|
||||
)
|
||||
if not preserve:
|
||||
selected.append(line)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
mask_path = output.with_name(output.stem + "_mask.png")
|
||||
manifest_common = {
|
||||
"source": source.name,
|
||||
"candidate": candidate.name,
|
||||
"output": output.name,
|
||||
"mask": mask_path.name,
|
||||
"glyph_donor": glyph_donor.name if glyph_donor else None,
|
||||
"glyph_feather": glyph_feather if restoration == "vae-glyphs" else None,
|
||||
"restoration": restoration,
|
||||
"selection": selection,
|
||||
"erase_background": erase_background,
|
||||
"composite_mask": composite_mask,
|
||||
"box_source": "detector" if detect_boxes else "verified_annotations",
|
||||
"annotation_boxes": annotation_boxes,
|
||||
"decisions": decisions,
|
||||
}
|
||||
if not selected:
|
||||
combined = np.zeros(source_rgb.shape[:2], dtype=np.uint8)
|
||||
shutil.copyfile(candidate, output)
|
||||
Image.fromarray(combined).save(mask_path)
|
||||
payload = {
|
||||
**manifest_common,
|
||||
"mask_fraction": 0.0,
|
||||
"source_glyph_fraction": 0.0,
|
||||
"source_layer_fraction": 0.0,
|
||||
}
|
||||
_write_manifest(manifest, payload)
|
||||
log.info("Copied %s unchanged because every line passed", output)
|
||||
return
|
||||
if restoration in {"source-silhouette", "vae-glyphs"}:
|
||||
source_masks = [source_silhouette_mask(source_rgb, line.box, line.angle) for line in selected]
|
||||
candidate_masks = [source_silhouette_mask(candidate_rgb, line.box, line.angle) for line in selected]
|
||||
line_masks = []
|
||||
for line, source_mask, candidate_mask in zip(selected, source_masks, candidate_masks, strict=True):
|
||||
radius = 5 if line.box[3] - line.box[1] >= 48 else 3
|
||||
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * radius + 1,) * 2)
|
||||
line_masks.append(cv2.dilate(np.maximum(source_mask, candidate_mask), kernel))
|
||||
masks = line_masks
|
||||
else:
|
||||
source_masks = [foreground_mask(source_rgb, line.box) for line in selected]
|
||||
candidate_masks = [foreground_mask(candidate_rgb, line.box) for line in selected]
|
||||
masks = [np.maximum(left, right) for left, right in zip(source_masks, candidate_masks, strict=True)]
|
||||
del candidate_masks
|
||||
groups = group_text_lines(selected)
|
||||
if erase_background:
|
||||
background = cv2.cvtColor(candidate_rgb, cv2.COLOR_RGB2BGR)
|
||||
for group in groups:
|
||||
background = region_eraser.erase_lama(
|
||||
background,
|
||||
np.maximum.reduce([masks[index] for index in group]),
|
||||
)
|
||||
background_rgb = cv2.cvtColor(background, cv2.COLOR_BGR2RGB)
|
||||
residual_masks = [
|
||||
residual_glyph_mask(background_rgb, mask, line.box) for line, mask in zip(selected, masks, strict=True)
|
||||
]
|
||||
for group in groups:
|
||||
residual = np.maximum.reduce([residual_masks[index] for index in group])
|
||||
if np.any(residual):
|
||||
background = region_eraser.erase_lama(background, residual)
|
||||
background_rgb = cv2.cvtColor(background, cv2.COLOR_BGR2RGB)
|
||||
else:
|
||||
background_rgb = candidate_rgb
|
||||
residual_masks = []
|
||||
source_glyph_mask = np.maximum.reduce(source_masks)
|
||||
source_layer_mask = source_glyph_mask
|
||||
if restoration == "source-glyphs":
|
||||
source_layer_mask = (
|
||||
source_box_mask(source_rgb.shape[:2], [line.box for line in selected])
|
||||
if composite_mask == "boxes"
|
||||
else source_glyph_mask
|
||||
)
|
||||
restored = composite_source_glyphs(source_rgb, background_rgb, source_layer_mask, feather=3.0)
|
||||
Image.fromarray(restored).save(output)
|
||||
elif restoration in {"source-silhouette", "vae-glyphs"}:
|
||||
restored = composite_fresh_text_edges(source_rgb, background_rgb, selected, source_masks)
|
||||
if restoration == "vae-glyphs":
|
||||
if donor_rgb is None:
|
||||
raise RuntimeError("VAE glyph restoration requires a loaded donor")
|
||||
restored = composite_reconstructed_glyphs(
|
||||
donor_rgb,
|
||||
restored,
|
||||
source_layer_mask,
|
||||
feather=glyph_feather,
|
||||
)
|
||||
Image.fromarray(restored).save(output)
|
||||
else:
|
||||
rendered = Image.fromarray(background_rgb)
|
||||
for line, source_mask in zip(selected, source_masks, strict=True):
|
||||
_render_line(rendered, line, _sample_text_color(source_rgb, source_mask, line.box))
|
||||
rendered.save(output)
|
||||
combined = np.maximum.reduce([*masks, *residual_masks])
|
||||
if restoration == "source-glyphs" and not erase_background:
|
||||
combined = source_layer_mask
|
||||
Image.fromarray(combined).save(mask_path)
|
||||
payload = {
|
||||
**manifest_common,
|
||||
"mask_fraction": float((combined > 0).mean()),
|
||||
"source_glyph_fraction": float((source_glyph_mask > 0).mean()),
|
||||
"source_layer_fraction": float((source_layer_mask > 0).mean()),
|
||||
}
|
||||
_write_manifest(manifest, payload)
|
||||
log.info("Wrote %s with %.4f edited fraction", output, payload["mask_fraction"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,53 +0,0 @@
|
||||
"""Update the conda recipe to a published package version and sdist hash."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_VERSION_LINE = re.compile(r'^ version: "[^"]+"$', re.MULTILINE)
|
||||
_SHA256_LINE = re.compile(r"^ sha256: [0-9a-f]{64}$", re.MULTILINE)
|
||||
|
||||
|
||||
def update_recipe(text: str, *, version: str, sha256: str) -> str:
|
||||
"""Return recipe text with exactly one context version and source hash updated."""
|
||||
if not version or any(char.isspace() for char in version):
|
||||
raise ValueError("version must be a non-empty value without whitespace")
|
||||
if re.fullmatch(r"[0-9a-f]{64}", sha256) is None:
|
||||
raise ValueError("sha256 must be 64 lowercase hexadecimal characters")
|
||||
|
||||
updated, version_count = _VERSION_LINE.subn(f' version: "{version}"', text)
|
||||
updated, sha_count = _SHA256_LINE.subn(f" sha256: {sha256}", updated)
|
||||
if version_count != 1 or sha_count != 1:
|
||||
raise ValueError(
|
||||
"expected exactly one context version and one source sha256 "
|
||||
f"(found version={version_count}, sha256={sha_count})"
|
||||
)
|
||||
return updated
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Update the selected recipe in place."""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--version", required=True)
|
||||
parser.add_argument("--sha256", required=True)
|
||||
parser.add_argument(
|
||||
"--recipe",
|
||||
type=Path,
|
||||
default=Path("packaging/conda/recipe.yaml"),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
original = args.recipe.read_text()
|
||||
updated = update_recipe(original, version=args.version, sha256=args.sha256)
|
||||
args.recipe.write_text(updated)
|
||||
log.info("Updated %s to version %s", args.recipe, args.version)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
||||
main()
|
||||
@@ -22,7 +22,7 @@ import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from collections import Counter
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
@@ -180,7 +180,7 @@ def ingest(
|
||||
"c2pa_issuer": issuer,
|
||||
"synthid_metadata": "yes" if synthid else "",
|
||||
"verified_via": verified_via,
|
||||
"added": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"added": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"notes": notes,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -32,7 +32,7 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
|
||||
_warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*")
|
||||
|
||||
|
||||
__version__ = "0.26.1"
|
||||
__version__ = "0.30.1"
|
||||
|
||||
__all__ = [
|
||||
"BatchSummary",
|
||||
|
||||
@@ -8,6 +8,7 @@ import json
|
||||
import logging
|
||||
import re
|
||||
import struct
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
@@ -37,6 +38,31 @@ with contextlib.suppress(Exception):
|
||||
_C2PA_READER_AVAILABLE = _C2paReader is not None
|
||||
_PNG_HEADER = struct.Struct(">I4s")
|
||||
|
||||
_CONTENT_BINDING_MATCHES = (
|
||||
"assertion.dataHash.match",
|
||||
"assertion.bmffHash.match",
|
||||
"assertion.boxesHash.match",
|
||||
"assertion.collectionHash.match",
|
||||
"assertion.multiAssetHash.match",
|
||||
)
|
||||
_CONTENT_BINDING_FAILURE_MARKERS = (
|
||||
"Hash.incorrectFileCount",
|
||||
"Hash.malformed",
|
||||
"Hash.mismatch",
|
||||
"Hash.missingPart",
|
||||
"assertion.hashedURI.mismatch",
|
||||
"claim.hardBindings.missing",
|
||||
"hashedUri.mismatch",
|
||||
"ingredient.manifest.mismatch",
|
||||
)
|
||||
_SIGNATURE_FAILURES = frozenset(
|
||||
{
|
||||
"claimSignature.mismatch",
|
||||
"claimSignature.missing",
|
||||
"claimSignature.outsideValidity",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _PngChunk:
|
||||
@@ -185,6 +211,165 @@ def _active_manifest(store: dict[str, Any]) -> dict[str, Any]:
|
||||
return cast("dict[str, Any]", active) if isinstance(active, dict) else {}
|
||||
|
||||
|
||||
def _manifest_chain(store: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Return the active manifest followed by credential-usable reachable ingredients."""
|
||||
manifests = store.get("manifests")
|
||||
active_label = store.get("active_manifest")
|
||||
if not isinstance(manifests, dict) or not isinstance(active_label, str):
|
||||
return []
|
||||
typed_manifests = cast("dict[object, object]", manifests)
|
||||
pending = deque([active_label])
|
||||
seen: set[str] = set()
|
||||
chain: list[dict[str, Any]] = []
|
||||
while pending:
|
||||
label = pending.popleft()
|
||||
if label in seen:
|
||||
continue
|
||||
seen.add(label)
|
||||
manifest_value = typed_manifests.get(label)
|
||||
if not isinstance(manifest_value, dict):
|
||||
continue
|
||||
manifest = cast("dict[str, Any]", manifest_value)
|
||||
chain.append(manifest)
|
||||
ingredients = manifest.get("ingredients")
|
||||
if not isinstance(ingredients, list):
|
||||
continue
|
||||
for ingredient_value in cast("list[object]", ingredients):
|
||||
if not isinstance(ingredient_value, dict):
|
||||
continue
|
||||
ingredient = cast("dict[object, object]", ingredient_value)
|
||||
child = ingredient.get("active_manifest")
|
||||
if (
|
||||
isinstance(child, str)
|
||||
and child not in seen
|
||||
and not _store_has_invalid_credential(cast("dict[str, Any]", ingredient))
|
||||
):
|
||||
pending.append(child)
|
||||
return chain
|
||||
|
||||
|
||||
def _status_codes(store: dict[str, Any]) -> tuple[list[str], list[str], list[str]]:
|
||||
"""Return ordered status codes for this store's active credential."""
|
||||
successes: list[str] = []
|
||||
informationals: list[str] = []
|
||||
failures: list[str] = []
|
||||
|
||||
def extend(status_set: object) -> None:
|
||||
if not isinstance(status_set, dict):
|
||||
return
|
||||
mapping = cast("dict[object, object]", status_set)
|
||||
for key, target in (
|
||||
("success", successes),
|
||||
("informational", informationals),
|
||||
("failure", failures),
|
||||
):
|
||||
values = mapping.get(key)
|
||||
if not isinstance(values, list):
|
||||
continue
|
||||
for value in cast("list[object]", values):
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
code = cast("dict[object, object]", value).get("code")
|
||||
if isinstance(code, str) and code:
|
||||
target.append(code)
|
||||
|
||||
validation_results = store.get("validation_results")
|
||||
if isinstance(validation_results, dict):
|
||||
results = cast("dict[object, object]", validation_results)
|
||||
extend(results.get("activeManifest"))
|
||||
|
||||
if not (successes or informationals or failures):
|
||||
# Older readers expose only the flat non-success list. It still carries the
|
||||
# exact codes needed to reject a mismatched binding or invalid credential.
|
||||
values = store.get("validation_status")
|
||||
if isinstance(values, list):
|
||||
for value in cast("list[object]", values):
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
code = cast("dict[object, object]", value).get("code")
|
||||
if isinstance(code, str) and code:
|
||||
failures.append(code)
|
||||
return successes, informationals, failures
|
||||
|
||||
|
||||
def _store_has_invalid_credential(store: dict[str, Any]) -> bool:
|
||||
"""Return whether this store's own active credential failed validation."""
|
||||
_, _, failures = _status_codes(store)
|
||||
return any(marker in code for code in failures for marker in _CONTENT_BINDING_FAILURE_MARKERS) or any(
|
||||
code in _SIGNATURE_FAILURES for code in failures
|
||||
)
|
||||
|
||||
|
||||
def c2pa_info_has_invalid_credential(info: dict[str, Any]) -> bool:
|
||||
"""Return whether parsed C2PA info reports a failed binding or signature."""
|
||||
return info.get("c2pa_integrity") == "invalid" or info.get("c2pa_signature") == "invalid"
|
||||
|
||||
|
||||
def c2pa_info_has_invismark(info: dict[str, Any]) -> bool:
|
||||
"""Return whether parsed C2PA info declares Microsoft InvisMark."""
|
||||
soft_bindings = info.get("soft_binding_vendors")
|
||||
return isinstance(soft_bindings, list) and "Microsoft InvisMark" in soft_bindings
|
||||
|
||||
|
||||
def c2pa_info_has_removal_hint(info: dict[str, Any]) -> bool:
|
||||
"""Return whether a C2PA AI or watermark claim should keep removal fail-safe."""
|
||||
return bool(info.get("ai_source_kind") or info.get("synthid_watermark") or c2pa_info_has_invismark(info))
|
||||
|
||||
|
||||
def _has_suffix(codes: list[str], suffixes: tuple[str, ...]) -> bool:
|
||||
return any(code.endswith(suffix) for code in codes for suffix in suffixes)
|
||||
|
||||
|
||||
def _validation_fields(store: dict[str, Any]) -> dict[str, Any]:
|
||||
successes, informationals, failures = _status_codes(store)
|
||||
all_codes = list(dict.fromkeys([*successes, *informationals, *failures]))
|
||||
|
||||
binding_failed = any(marker in code for code in failures for marker in _CONTENT_BINDING_FAILURE_MARKERS)
|
||||
binding_matched = _has_suffix(successes, _CONTENT_BINDING_MATCHES)
|
||||
signature_failed = any(code in _SIGNATURE_FAILURES for code in failures)
|
||||
signature_validated = "claimSignature.validated" in successes
|
||||
|
||||
if binding_failed:
|
||||
integrity = "invalid"
|
||||
elif binding_matched:
|
||||
integrity = "valid"
|
||||
else:
|
||||
integrity = "unknown"
|
||||
if signature_failed:
|
||||
signature = "invalid"
|
||||
elif signature_validated:
|
||||
signature = "valid"
|
||||
else:
|
||||
signature = "unknown"
|
||||
|
||||
if "signingCredential.trusted" in successes:
|
||||
signer_trust = "trusted"
|
||||
elif "signingCredential.untrusted" in failures:
|
||||
signer_trust = "untrusted"
|
||||
else:
|
||||
signer_trust = "unknown"
|
||||
|
||||
if any(code in failures for code in ("signingCredential.invalid", "signingCredential.ocsp.revoked")):
|
||||
signer_validity = "invalid"
|
||||
elif "signingCredential.expired" in failures:
|
||||
signer_validity = "expired"
|
||||
elif "claimSignature.insideValidity" in successes:
|
||||
signer_validity = "valid"
|
||||
else:
|
||||
signer_validity = "unknown"
|
||||
|
||||
state = store.get("validation_state")
|
||||
return {
|
||||
"c2pa_validation_source": "reader",
|
||||
"c2pa_validation_state": state if isinstance(state, str) and state else "unknown",
|
||||
"c2pa_integrity": integrity,
|
||||
"c2pa_signature": signature,
|
||||
"c2pa_signer_trust": signer_trust,
|
||||
"c2pa_signer_validity": signer_validity,
|
||||
"c2pa_validation_codes": all_codes,
|
||||
}
|
||||
|
||||
|
||||
def _claim_generator_from_store(store: dict[str, Any]) -> str | None:
|
||||
active = _active_manifest(store)
|
||||
direct = active.get("claim_generator")
|
||||
@@ -199,6 +384,141 @@ def _claim_generator_from_store(store: dict[str, Any]) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _structured_manifest_fields(store: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Extract provenance only from the active manifest and its ingredient graph."""
|
||||
chain = _manifest_chain(store)
|
||||
if not chain:
|
||||
return {}
|
||||
|
||||
info: dict[str, Any] = {}
|
||||
issuers: list[str] = []
|
||||
tools: list[str] = []
|
||||
actions: list[str] = []
|
||||
source_types: list[str] = []
|
||||
soft_binding_algorithms: list[str] = []
|
||||
soft_binding_values: list[str] = []
|
||||
claim_generator_asserts_ai = False
|
||||
|
||||
def add_tool_matches(value: str, *, asserts_ai: bool = False) -> None:
|
||||
nonlocal claim_generator_asserts_ai
|
||||
matches = _ordered_matches(value.encode(), C2PA_AI_TOOLS)
|
||||
tools.extend(matches)
|
||||
if asserts_ai and matches:
|
||||
claim_generator_asserts_ai = True
|
||||
|
||||
for manifest in chain:
|
||||
signature_value = manifest.get("signature_info")
|
||||
if isinstance(signature_value, dict):
|
||||
signature = cast("dict[object, object]", signature_value)
|
||||
for key in ("issuer", "common_name", "certificate_issuer"):
|
||||
value = signature.get(key)
|
||||
if isinstance(value, str):
|
||||
issuers.extend(_ordered_matches(value.encode(), C2PA_ISSUERS))
|
||||
|
||||
direct_generator = manifest.get("claim_generator")
|
||||
if isinstance(direct_generator, str):
|
||||
add_tool_matches(direct_generator, asserts_ai=True)
|
||||
|
||||
candidates = manifest.get("claim_generator_info")
|
||||
if isinstance(candidates, list):
|
||||
for candidate_value in cast("list[object]", candidates):
|
||||
if not isinstance(candidate_value, dict):
|
||||
continue
|
||||
name = cast("dict[object, object]", candidate_value).get("name")
|
||||
if isinstance(name, str):
|
||||
add_tool_matches(name, asserts_ai=True)
|
||||
|
||||
assertions = manifest.get("assertions")
|
||||
if not isinstance(assertions, list):
|
||||
continue
|
||||
for assertion_value in cast("list[object]", assertions):
|
||||
if not isinstance(assertion_value, dict):
|
||||
continue
|
||||
assertion = cast("dict[object, object]", assertion_value)
|
||||
label = assertion.get("label")
|
||||
data = assertion.get("data")
|
||||
if isinstance(label, str) and label.startswith("c2pa.soft-binding") and isinstance(data, dict):
|
||||
soft_binding = cast("dict[object, object]", data)
|
||||
algorithm = soft_binding.get("alg")
|
||||
if isinstance(algorithm, str):
|
||||
soft_binding_algorithms.append(algorithm)
|
||||
blocks = soft_binding.get("blocks")
|
||||
if isinstance(blocks, list):
|
||||
for block_value in cast("list[object]", blocks):
|
||||
if not isinstance(block_value, dict):
|
||||
continue
|
||||
value = cast("dict[object, object]", block_value).get("value")
|
||||
if isinstance(value, str) and value.isprintable() and len(value) <= 256:
|
||||
soft_binding_values.append(value)
|
||||
if not (isinstance(label, str) and label.startswith("c2pa.actions") and isinstance(data, dict)):
|
||||
continue
|
||||
action_values = cast("dict[object, object]", data).get("actions")
|
||||
if not isinstance(action_values, list):
|
||||
continue
|
||||
for action_value in cast("list[object]", action_values):
|
||||
if not isinstance(action_value, dict):
|
||||
continue
|
||||
action = cast("dict[object, object]", action_value)
|
||||
action_name = action.get("action")
|
||||
if isinstance(action_name, str):
|
||||
actions.append(C2PA_ACTIONS.get(action_name.encode(), action_name.removeprefix("c2pa.")))
|
||||
source_type = action.get("digitalSourceType")
|
||||
if isinstance(source_type, str):
|
||||
source_types.append(source_type)
|
||||
software_agent = action.get("softwareAgent")
|
||||
if isinstance(software_agent, dict):
|
||||
name = cast("dict[object, object]", software_agent).get("name")
|
||||
if isinstance(name, str):
|
||||
add_tool_matches(name)
|
||||
|
||||
issuers = list(dict.fromkeys(issuers))
|
||||
tools = list(dict.fromkeys(tools))
|
||||
actions = list(dict.fromkeys(actions))
|
||||
if issuers:
|
||||
info["issuer"] = ", ".join(issuers)
|
||||
if tools:
|
||||
info["ai_tool"] = ", ".join(tools)
|
||||
if actions:
|
||||
info["actions"] = ", ".join(actions)
|
||||
if claim_generator_asserts_ai:
|
||||
info["c2pa_identity_ai"] = True
|
||||
|
||||
generated = any(
|
||||
"trainedAlgorithmicMedia" in value and "compositeWithTrainedAlgorithmicMedia" not in value
|
||||
for value in source_types
|
||||
)
|
||||
enhanced = any("compositeWithTrainedAlgorithmicMedia" in value for value in source_types)
|
||||
procedural = any("algorithmicMedia" in value for value in source_types)
|
||||
if generated:
|
||||
info.update(source_type="trainedAlgorithmicMedia (AI-generated)", ai_source_kind="generated")
|
||||
elif enhanced:
|
||||
info.update(source_type="compositeWithTrainedAlgorithmicMedia (AI-enhanced)", ai_source_kind="enhanced")
|
||||
elif procedural:
|
||||
info["source_type"] = "algorithmicMedia"
|
||||
|
||||
has_watermark_action = any(action.startswith("watermarked") for action in actions)
|
||||
if has_watermark_action:
|
||||
info["watermarked"] = True
|
||||
if info.get("ai_source_kind"):
|
||||
selected_bytes = json.dumps(chain, ensure_ascii=False).encode()
|
||||
synthid = synthid_evidence_vendors_in(selected_bytes, has_watermark_action=has_watermark_action)
|
||||
if synthid:
|
||||
info["synthid_vendors"] = synthid
|
||||
info["synthid_watermark"] = synthid_verdict(", ".join(synthid))
|
||||
|
||||
if soft_binding_algorithms:
|
||||
soft_binding_algorithms = list(dict.fromkeys(soft_binding_algorithms))
|
||||
algorithms = "\n".join(soft_binding_algorithms).encode()
|
||||
soft_bindings = soft_binding_vendors_in(algorithms)
|
||||
if soft_bindings:
|
||||
info["soft_binding_vendors"] = soft_bindings
|
||||
info["soft_binding"] = ", ".join(soft_bindings)
|
||||
info["soft_binding_algorithm"] = ", ".join(soft_binding_algorithms)
|
||||
if soft_binding_values:
|
||||
info["soft_binding_value"] = ", ".join(dict.fromkeys(soft_binding_values))
|
||||
return info
|
||||
|
||||
|
||||
def synthid_verdict(vendors: str) -> str:
|
||||
"""Describe why supported provenance establishes a SynthID watermark."""
|
||||
return f"present according to {vendors} provenance"
|
||||
@@ -276,16 +596,28 @@ def _populate_registry_fields(buffer: bytes, info: dict[str, Any]) -> bool:
|
||||
|
||||
def _base_info(byte_count: int, *, fallback: bool = False) -> dict[str, Any]:
|
||||
container = "C2PA manifest" if fallback else "C2PA manifest store"
|
||||
return {
|
||||
info: dict[str, Any] = {
|
||||
"has_c2pa": True,
|
||||
"type": "C2PA (Coalition for Content Provenance and Authenticity)",
|
||||
"c2pa_manifest": f"{container} ({byte_count} bytes)",
|
||||
}
|
||||
if fallback:
|
||||
info.update(
|
||||
c2pa_validation_source="fallback",
|
||||
c2pa_validation_state="unknown",
|
||||
c2pa_integrity="unknown",
|
||||
c2pa_signature="unknown",
|
||||
c2pa_signer_trust="unknown",
|
||||
c2pa_signer_validity="unknown",
|
||||
c2pa_validation_codes=[],
|
||||
)
|
||||
return info
|
||||
|
||||
|
||||
def _info_from_store(store: dict[str, Any], encoded: bytes) -> dict[str, Any]:
|
||||
info = _base_info(len(encoded))
|
||||
_populate_registry_fields(encoded, info)
|
||||
info.update(_structured_manifest_fields(store))
|
||||
info.update(_validation_fields(store))
|
||||
generator = _claim_generator_from_store(store)
|
||||
if generator is not None:
|
||||
info["claim_generator"] = generator
|
||||
|
||||
@@ -65,7 +65,7 @@ def _vendor(
|
||||
|
||||
# Order is product priority when a manifest mentions more than one organization.
|
||||
C2PA_AI_VENDORS: tuple[C2paAiVendor, ...] = (
|
||||
_vendor(b"Microsoft", "Microsoft", "Microsoft (Bing Image Creator / Designer)", "Microsoft"),
|
||||
_vendor(b"Microsoft", "Microsoft", "Microsoft (Copilot / Designer)", "Microsoft"),
|
||||
_vendor(b"Adobe", "Adobe", "Adobe Firefly", "Adobe"),
|
||||
_vendor(
|
||||
b"OpenAI",
|
||||
@@ -78,18 +78,25 @@ C2PA_AI_VENDORS: tuple[C2paAiVendor, ...] = (
|
||||
_vendor(b"Google", "Google LLC", "Google (Gemini / Imagen)", "Google", synthid=True),
|
||||
_vendor(b"Stability AI", "Stability AI", "Stability AI (Stable Image / DreamStudio)", "Stability AI"),
|
||||
_vendor(b"Black Forest Labs", "Black Forest Labs", "Black Forest Labs (FLUX)", "Black Forest Labs"),
|
||||
_vendor(b"volcengine", "ByteDance (Volcano Engine)", "ByteDance (Doubao / Jimeng / Volcano Engine)", "ByteDance"),
|
||||
_vendor(
|
||||
b"volcengine",
|
||||
"ByteDance (Volcano Engine)",
|
||||
"ByteDance (Doubao / Jimeng / Dreamina / Volcano Engine)",
|
||||
"ByteDance",
|
||||
),
|
||||
_vendor(
|
||||
"北京火山引擎科技有限公司",
|
||||
"ByteDance (Volcano Engine)",
|
||||
"ByteDance (Doubao / Jimeng / Volcano Engine)",
|
||||
"ByteDance (Doubao / Jimeng / Dreamina / Volcano Engine)",
|
||||
"ByteDance",
|
||||
),
|
||||
_vendor(b"Byteplus", "BytePlus (ByteDance)", "ByteDance (Doubao / Jimeng / Volcano Engine)", "ByteDance"),
|
||||
_vendor(
|
||||
b"Byteplus", "BytePlus (ByteDance)", "ByteDance (Doubao / Jimeng / Dreamina / Volcano Engine)", "ByteDance"
|
||||
),
|
||||
_vendor(
|
||||
b"Dreamina",
|
||||
"ByteDance (Dreamina)",
|
||||
"ByteDance (Doubao / Jimeng / Volcano Engine)",
|
||||
"ByteDance (Doubao / Jimeng / Dreamina / Volcano Engine)",
|
||||
"ByteDance",
|
||||
asserts_ai=True,
|
||||
),
|
||||
@@ -103,6 +110,18 @@ C2PA_AI_VENDORS: tuple[C2paAiVendor, ...] = (
|
||||
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)
|
||||
|
||||
# Product-specific claim generators can sign through a different upstream issuer.
|
||||
# Keep this attribution beside the issuer registry so every C2PA consumer has one
|
||||
# canonical source rather than maintaining a derived product map in identify.py.
|
||||
C2PA_CLAIM_GENERATOR_PLATFORMS: tuple[tuple[str, str], ...] = (
|
||||
("adobe_firefly", "Adobe Firefly"),
|
||||
("firefly", "Adobe Firefly"),
|
||||
("dreamina", "ByteDance (Doubao / Jimeng / Dreamina / Volcano Engine)"),
|
||||
("higgsfield ai", "Higgsfield AI"),
|
||||
("topaz labs image api", "Topaz Labs"),
|
||||
("tiktok ad creative toolbox", "TikTok Ad Creative Toolbox"),
|
||||
)
|
||||
|
||||
C2PA_AI_TOOLS = {
|
||||
token.encode(): label
|
||||
for token, label in (
|
||||
@@ -113,6 +132,7 @@ C2PA_AI_TOOLS = {
|
||||
("DALL", "DALL-E"),
|
||||
("Imagen", "Imagen"),
|
||||
("Firefly", "Firefly"),
|
||||
("Dreamina", "Dreamina"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -158,6 +178,11 @@ AI_GENERATOR_TOKENS = frozenset(
|
||||
"dreamstudio",
|
||||
"novelai",
|
||||
"reve.com",
|
||||
# Luma AI stamps PNG tEXt Source="Luma AI" / Comment="Generated by
|
||||
# Luma AI's Uni-1 model (https://lumalabs.ai)"; the space-bearing token
|
||||
# avoids matching incidental "luma" runs (luma/chroma key names etc.).
|
||||
"luma ai",
|
||||
"lumalabs",
|
||||
"aphrodite ai",
|
||||
"apple photos clean up",
|
||||
"fal-ai",
|
||||
|
||||
@@ -12,7 +12,10 @@ so pixel, video, and audio data is preserved bit-for-bit.
|
||||
TC260-PG-20257A video metadata is nested instead:
|
||||
``moov.udta.meta.keys/ilst``. Its detector seeks through those boxes without
|
||||
reading media payloads, and its stripper blanks the validated key/value in
|
||||
place so fast-start media offsets remain valid.
|
||||
place so fast-start media offsets remain valid. Two serialization variants are
|
||||
covered: the ISO form (``meta`` as a FullBox under ``udta``) and the QuickTime
|
||||
form Doubao's iOS export writes (a bare ``meta`` box as a direct ``moov`` child,
|
||||
no FullBox header).
|
||||
|
||||
This file intentionally avoids dependencies on format-specific libraries
|
||||
(pillow-heif, pillow-jxl, pymp4) so it works on systems where they aren't
|
||||
@@ -184,68 +187,116 @@ def _tc260_key_indices(
|
||||
return found
|
||||
|
||||
|
||||
# The box types a TC260-bearing ``meta`` box opens with or contains: ISO files
|
||||
# have ``hdlr`` then ``keys``/``ilst``; QuickTime metadata lists have ``hdlr``
|
||||
# and ``ilst`` only. Which payload offset (0 vs 4) yields such children is what
|
||||
# disambiguates the QuickTime form (no FullBox header) from the ISO one.
|
||||
_META_CHILD_TYPES = frozenset({b"hdlr", b"keys", b"ilst"})
|
||||
|
||||
|
||||
def _meta_child_boxes(
|
||||
stream: BinaryIO,
|
||||
meta_payload: int,
|
||||
meta_end: int,
|
||||
) -> Iterator[tuple[int, int, bytes, int]]:
|
||||
"""Yield the child boxes of one ``meta`` box in either serialized form.
|
||||
|
||||
ISO serializes ``meta`` as a FullBox, so its children start 4 bytes into
|
||||
the payload; QuickTime writes a bare box, so they start at 0. Both real
|
||||
forms open with a recognized child (``hdlr``), so the form is picked by
|
||||
which offset's first box type is one of ``_META_CHILD_TYPES``; a wrong
|
||||
probe reads garbage header bytes that match no known type.
|
||||
"""
|
||||
for offset in (0, 4):
|
||||
boxes = iter_file_boxes(stream, meta_payload + offset, meta_end)
|
||||
first = next(boxes, None)
|
||||
if first is not None and first[2] in _META_CHILD_TYPES:
|
||||
yield first
|
||||
yield from boxes
|
||||
return
|
||||
|
||||
|
||||
def _iter_tc260_meta_boxes(
|
||||
stream: BinaryIO,
|
||||
moov_payload: int,
|
||||
moov_end: int,
|
||||
) -> Iterator[tuple[int, int]]:
|
||||
"""Yield ``(payload, end)`` of every ``meta`` box that may hold a TC260 label.
|
||||
|
||||
The normative ISO placement is ``moov.udta.meta``; Doubao's iOS MOV export
|
||||
instead stores the label in a QuickTime-form ``meta`` box that hangs
|
||||
directly off ``moov``. Both are yielded so one consumer covers them.
|
||||
"""
|
||||
for _start, end, box_type, payload in iter_file_boxes(stream, moov_payload, moov_end):
|
||||
if box_type == b"udta":
|
||||
for _udta_start, udta_end, udta_type, udta_payload in iter_file_boxes(stream, payload, end):
|
||||
if udta_type == b"meta":
|
||||
yield udta_payload, udta_end
|
||||
elif box_type == b"meta":
|
||||
yield payload, end
|
||||
|
||||
|
||||
def _tc260_aigc_regions(
|
||||
stream: BinaryIO,
|
||||
file_size: int,
|
||||
) -> list[tuple[int, int, int, int, bytes]]:
|
||||
) -> list[tuple[tuple[int, int] | None, int, int, bytes]]:
|
||||
"""Locate validated native TC260 entries without reading media payloads.
|
||||
|
||||
Each tuple is ``(key_start, key_end, value_start, value_end, value)``.
|
||||
Each tuple is ``(key_span, value_start, value_end, value)``; ``key_span`` is
|
||||
the byte span of the ``AIGC`` key when the normative ``keys`` box maps the
|
||||
item, or None for the QuickTime metadata-list form (``hdlr=mdir``) Doubao's
|
||||
iOS export writes, where the JSON sits in a bare ``ilst`` data item with no
|
||||
key name to blank.
|
||||
"""
|
||||
regions: list[tuple[int, int, int, int, bytes]] = []
|
||||
regions: list[tuple[tuple[int, int] | None, int, int, bytes]] = []
|
||||
for _moov_start, moov_end, moov_type, moov_payload in iter_file_boxes(stream, 0, file_size):
|
||||
if moov_type != b"moov":
|
||||
continue
|
||||
for _udta_start, udta_end, udta_type, udta_payload in iter_file_boxes(
|
||||
stream,
|
||||
moov_payload,
|
||||
moov_end,
|
||||
):
|
||||
if udta_type != b"udta":
|
||||
continue
|
||||
for _meta_start, meta_end, meta_type, meta_payload in iter_file_boxes(
|
||||
for meta_payload, meta_end in _iter_tc260_meta_boxes(stream, moov_payload, moov_end):
|
||||
keys: dict[int, tuple[int, int]] = {}
|
||||
ilst_boxes: list[tuple[int, int]] = []
|
||||
keyed = False
|
||||
for _child_start, child_end, child_type, child_payload in _meta_child_boxes(
|
||||
stream,
|
||||
udta_payload,
|
||||
udta_end,
|
||||
meta_payload,
|
||||
meta_end,
|
||||
):
|
||||
if meta_type != b"meta" or meta_payload + 4 > meta_end:
|
||||
continue
|
||||
keys: dict[int, tuple[int, int]] = {}
|
||||
ilst_boxes: list[tuple[int, int]] = []
|
||||
for _child_start, child_end, child_type, child_payload in iter_file_boxes(
|
||||
if child_type == b"keys":
|
||||
keyed = True
|
||||
keys.update(_tc260_key_indices(stream, child_payload, child_end))
|
||||
elif child_type == b"ilst":
|
||||
ilst_boxes.append((child_payload, child_end))
|
||||
if not ilst_boxes:
|
||||
continue
|
||||
for ilst_payload, ilst_end in ilst_boxes:
|
||||
for _item_start, item_end, item_type, item_payload in iter_file_boxes(
|
||||
stream,
|
||||
meta_payload + 4,
|
||||
meta_end,
|
||||
ilst_payload,
|
||||
ilst_end,
|
||||
):
|
||||
if child_type == b"keys":
|
||||
keys.update(_tc260_key_indices(stream, child_payload, child_end))
|
||||
elif child_type == b"ilst":
|
||||
ilst_boxes.append((child_payload, child_end))
|
||||
if not keys:
|
||||
continue
|
||||
for ilst_payload, ilst_end in ilst_boxes:
|
||||
for _item_start, item_end, item_type, item_payload in iter_file_boxes(
|
||||
index = int.from_bytes(item_type, "big")
|
||||
key_span = keys.get(index)
|
||||
if keyed and key_span is None:
|
||||
# A keyed (ISO) meta box maps items through ``keys``;
|
||||
# an unmapped index is not an AIGC entry, and reading
|
||||
# its value would pull arbitrary metadata (e.g. cover
|
||||
# art) through the JSON parser on every scan. Only the
|
||||
# keyless QuickTime list falls through to content
|
||||
# validation below.
|
||||
continue
|
||||
for _data_start, data_end, data_type, data_payload in iter_file_boxes(
|
||||
stream,
|
||||
ilst_payload,
|
||||
ilst_end,
|
||||
item_payload,
|
||||
item_end,
|
||||
):
|
||||
index = int.from_bytes(item_type, "big")
|
||||
key_span = keys.get(index)
|
||||
if key_span is None:
|
||||
value_start = data_payload + 8
|
||||
value_size = data_end - value_start
|
||||
if data_type != b"data" or value_size < 0 or value_size > MAX_TC260_VALUE_BYTES:
|
||||
continue
|
||||
for _data_start, data_end, data_type, data_payload in iter_file_boxes(
|
||||
stream,
|
||||
item_payload,
|
||||
item_end,
|
||||
):
|
||||
value_start = data_payload + 8
|
||||
value_size = data_end - value_start
|
||||
if data_type != b"data" or value_size < 0 or value_size > MAX_TC260_VALUE_BYTES:
|
||||
continue
|
||||
stream.seek(value_start)
|
||||
value = stream.read(value_size)
|
||||
if len(value) == value_size and parse_tc260_aigc_json(value) is not None:
|
||||
regions.append((*key_span, value_start, data_end, value))
|
||||
stream.seek(value_start)
|
||||
value = stream.read(value_size)
|
||||
if len(value) == value_size and parse_tc260_aigc_json(value) is not None:
|
||||
regions.append((key_span, value_start, data_end, value))
|
||||
return regions
|
||||
|
||||
|
||||
@@ -257,7 +308,7 @@ def tc260_aigc_payloads(path: str | Path) -> tuple[bytes, ...]:
|
||||
return ()
|
||||
stream.seek(0, 2)
|
||||
file_size = stream.tell()
|
||||
return tuple(region[4] for region in _tc260_aigc_regions(stream, file_size))
|
||||
return tuple(region[3] for region in _tc260_aigc_regions(stream, file_size))
|
||||
except OSError:
|
||||
return ()
|
||||
|
||||
@@ -268,6 +319,8 @@ def blank_tc260_aigc_tags(data: bytes) -> tuple[bytes, int]:
|
||||
Removing a nested ``ilst`` item would shift ``mdat`` in a fast-start MP4 and
|
||||
invalidate its chunk offsets. Replacing the four-byte key with ``free`` and
|
||||
the JSON value with spaces keeps every box size and media offset unchanged.
|
||||
A keyless QuickTime metadata-list entry has no key name, so only its value
|
||||
is blanked.
|
||||
"""
|
||||
if not is_isobmff(data):
|
||||
return data, 0
|
||||
@@ -276,9 +329,10 @@ def blank_tc260_aigc_tags(data: bytes) -> tuple[bytes, int]:
|
||||
return data, 0
|
||||
out = bytearray(data)
|
||||
key_spans: set[tuple[int, int]] = set()
|
||||
for key_start, key_end, value_start, value_end, _value in regions:
|
||||
key_spans.add((key_start, key_end))
|
||||
out[key_start:key_end] = b"free"
|
||||
for key_span, value_start, value_end, _value in regions:
|
||||
if key_span is not None:
|
||||
key_spans.add(key_span)
|
||||
out[key_span[0] : key_span[1]] = b"free"
|
||||
out[value_start:value_end] = b" " * (value_end - value_start)
|
||||
return bytes(out), len(key_spans)
|
||||
|
||||
@@ -456,7 +510,7 @@ def strip_isobmff_media_file(
|
||||
max_scan=max_box_scan,
|
||||
)
|
||||
tc260_regions = _tc260_aigc_regions(stream, file_size) if targets is not None else []
|
||||
tc260_key_spans = {(region[0], region[1]) for region in tc260_regions}
|
||||
tc260_key_spans = {region[0] for region in tc260_regions if region[0] is not None}
|
||||
|
||||
with atomic_video_output(output_path) as temporary_path:
|
||||
with source_path.open("rb") as source_stream, temporary_path.open("r+b") as temporary:
|
||||
@@ -466,9 +520,10 @@ def strip_isobmff_media_file(
|
||||
temporary.seek(box_start + 4)
|
||||
temporary.write(b"free")
|
||||
_overwrite_range(temporary, payload_start, box_end, byte=b"\x00")
|
||||
for key_start, _key_end, value_start, value_end, _value in tc260_regions:
|
||||
temporary.seek(key_start)
|
||||
temporary.write(b"free")
|
||||
for key_span, value_start, value_end, _value in tc260_regions:
|
||||
if key_span is not None:
|
||||
temporary.seek(key_span[0])
|
||||
temporary.write(b"free")
|
||||
_overwrite_range(temporary, value_start, value_end, byte=b" ")
|
||||
temporary.flush()
|
||||
os.fsync(temporary.fileno())
|
||||
|
||||
@@ -30,6 +30,8 @@ from remove_ai_watermarks._internal.watermark_profiles import resolve_seed
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from remove_ai_watermarks._internal.text_restoration import VerifiedTextManifest
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
QWEN_IMAGE_2512_MODEL_ID = "Qwen/Qwen-Image-2512"
|
||||
@@ -973,6 +975,30 @@ class QwenZImagePipeline:
|
||||
result = result.resize(image.size, Image.Resampling.LANCZOS)
|
||||
return result.convert("RGB")
|
||||
|
||||
def _qwen_vae_roundtrip(self, image: Image.Image) -> Image.Image:
|
||||
"""Reconstruct source pixels through the already loaded Qwen VAE."""
|
||||
import torch
|
||||
|
||||
pipe, _controlnet_input_cls = self._load_qwen()
|
||||
source_width, source_height = image.size
|
||||
pad_width = (-source_width) % 8
|
||||
pad_height = (-source_height) % 8
|
||||
padded = image.convert("RGB")
|
||||
if pad_width or pad_height:
|
||||
padded = Image.fromarray(
|
||||
np.pad(
|
||||
np.asarray(padded),
|
||||
((0, pad_height), (0, pad_width), (0, 0)),
|
||||
mode="edge",
|
||||
)
|
||||
)
|
||||
pipe.load_models_to_device(["vae"])
|
||||
tensor = pipe.preprocess_image(padded).to(device=self.device, dtype=self.torch_dtype)
|
||||
with torch.inference_mode():
|
||||
latents = pipe.vae.encode(tensor)
|
||||
decoded = pipe.vae.decode(latents)
|
||||
return pipe.vae_output_to_image(decoded).crop((0, 0, source_width, source_height)).convert("RGB")
|
||||
|
||||
@staticmethod
|
||||
def _detail_size(
|
||||
crop_size: tuple[int, int],
|
||||
@@ -1044,10 +1070,33 @@ class QwenZImagePipeline:
|
||||
tile: bool = False,
|
||||
tile_size: int = 1024,
|
||||
tile_overlap: int = 128,
|
||||
text_manifest: VerifiedTextManifest | None = None,
|
||||
fidelity_anchor: bool = False,
|
||||
) -> Image.Image:
|
||||
"""Execute global regeneration and masked face repair."""
|
||||
self._require_cuda()
|
||||
seed = resolve_seed(seed)
|
||||
donor = None
|
||||
if text_manifest is not None:
|
||||
self._progress("Reconstructing the verified text donor with the Qwen VAE...")
|
||||
if tile and max(image.size) > tile_size:
|
||||
from remove_ai_watermarks._internal.tiling import run_tiled
|
||||
|
||||
donor = run_tiled(
|
||||
self._qwen_vae_roundtrip,
|
||||
image,
|
||||
tile_size,
|
||||
tile_overlap,
|
||||
lambda message: self._progress(
|
||||
message.replace(
|
||||
"Tiled diffusion",
|
||||
"Reconstructing the verified text donor",
|
||||
1,
|
||||
)
|
||||
),
|
||||
)
|
||||
else:
|
||||
donor = self._qwen_vae_roundtrip(image)
|
||||
global_strength = (
|
||||
resolution_adaptive_denoise(image.width, image.height) if strength is None else float(strength)
|
||||
)
|
||||
@@ -1068,14 +1117,37 @@ class QwenZImagePipeline:
|
||||
boxes = detect_faces(image)
|
||||
if not boxes:
|
||||
self._progress("No faces detected; keeping the Qwen global result.")
|
||||
return global_result
|
||||
masks = self._sam_masks(image, boxes)
|
||||
face_strength = largest_face_denoise(boxes, image.size) * FACE_DENOISE_SCALE
|
||||
return self._run_faces(
|
||||
image,
|
||||
global_result,
|
||||
boxes,
|
||||
masks,
|
||||
strength=face_strength,
|
||||
seed=seed,
|
||||
result = global_result
|
||||
else:
|
||||
masks = self._sam_masks(image, boxes)
|
||||
face_strength = largest_face_denoise(boxes, image.size) * FACE_DENOISE_SCALE
|
||||
result = self._run_faces(
|
||||
image,
|
||||
global_result,
|
||||
boxes,
|
||||
masks,
|
||||
strength=face_strength,
|
||||
seed=seed,
|
||||
)
|
||||
if text_manifest is None:
|
||||
return result
|
||||
if donor is None:
|
||||
raise RuntimeError("Verified text restoration requires a Qwen-VAE donor")
|
||||
from remove_ai_watermarks._internal.text_restoration import (
|
||||
blend_fidelity_anchor,
|
||||
restore_verified_text,
|
||||
)
|
||||
|
||||
# The anchor is OFF by default since 0.27.1: blending 15% of the Qwen-VAE
|
||||
# donor ACROSS THE WHOLE FRAME returned detector-visible OpenAI SynthID on
|
||||
# poster-scale manifests (official Content Provenance API, 2026-08-19:
|
||||
# detected x6 with the anchor, clean x6 without it, base clean throughout;
|
||||
# see docs/text-protection-research.md). ``fidelity_anchor=True`` keeps the
|
||||
# 0.27.0 research behavior for reproduction.
|
||||
if fidelity_anchor:
|
||||
self._progress("Blending the Qwen-VAE fidelity anchor...")
|
||||
anchor = blend_fidelity_anchor(result, donor)
|
||||
else:
|
||||
anchor = result
|
||||
self._progress(f"Restoring {len(text_manifest.lines)} verified text lines...")
|
||||
return restore_verified_text(image, anchor, donor, text_manifest.lines)
|
||||
@@ -0,0 +1,446 @@
|
||||
"""Opt-in restoration of verified text from a Qwen VAE reconstruction."""
|
||||
|
||||
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownParameterType=false, reportMissingTypeArgument=false, reportMissingTypeStubs=false, reportMissingImports=false, reportArgumentType=false, reportAssignmentType=false, reportReturnType=false, reportCallIssue=false, reportIndexIssue=false, reportOperatorIssue=false
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from remove_ai_watermarks._internal.schema import require_schema_version
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
from numpy.typing import NDArray
|
||||
|
||||
TEXT_MANIFEST_SCHEMA = 2
|
||||
_SUPPORTED_TEXT_MANIFEST_SCHEMAS = frozenset({1, TEXT_MANIFEST_SCHEMA})
|
||||
FIDELITY_BLEND_ALPHA = 0.15
|
||||
GLYPH_FEATHER = 0.5
|
||||
GLYPH_SIDE_PAD_RATIO = 0.12
|
||||
GLYPH_SIDE_PAD_LIMIT = 1.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VerifiedTextLine:
|
||||
"""One operator-verified source region in source-pixel coordinates."""
|
||||
|
||||
box: tuple[int, int, int, int]
|
||||
text: str | None = None
|
||||
script: str | None = None
|
||||
angle: float = 0.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VerifiedTextManifest:
|
||||
"""Verified text regions cryptographically bound to one decoded RGB source."""
|
||||
|
||||
source_pixel_sha256: str
|
||||
width: int
|
||||
height: int
|
||||
lines: tuple[VerifiedTextLine, ...]
|
||||
|
||||
|
||||
def source_pixel_sha256(image: Image.Image) -> str:
|
||||
"""Hash decoded RGB geometry and bytes, independent of container metadata."""
|
||||
rgb = image.convert("RGB")
|
||||
digest = hashlib.sha256()
|
||||
digest.update(rgb.width.to_bytes(8, "big"))
|
||||
digest.update(rgb.height.to_bytes(8, "big"))
|
||||
digest.update(rgb.tobytes())
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def load_verified_text_manifest(path: Path, source: Image.Image) -> VerifiedTextManifest:
|
||||
"""Load and validate an operator-verified manifest for exactly ``source``."""
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise ValueError(f"Cannot read text manifest {path}: {exc}") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("Text manifest must be a JSON object")
|
||||
schema_version = require_schema_version(
|
||||
payload.get("schema_version"),
|
||||
contract="text manifest",
|
||||
supported=_SUPPORTED_TEXT_MANIFEST_SCHEMAS,
|
||||
)
|
||||
if payload.get("verified") is not True:
|
||||
raise ValueError("Text manifest must contain verified=true after operator verification")
|
||||
|
||||
rgb = source.convert("RGB")
|
||||
width = _manifest_integer(payload, "width")
|
||||
height = _manifest_integer(payload, "height")
|
||||
if (width, height) != rgb.size:
|
||||
raise ValueError(f"Text manifest dimensions {width}x{height} do not match source {rgb.width}x{rgb.height}")
|
||||
expected_hash = payload.get("source_pixel_sha256")
|
||||
if not isinstance(expected_hash, str) or len(expected_hash) != 64:
|
||||
raise ValueError("Text manifest source_pixel_sha256 must be a 64-character SHA-256")
|
||||
actual_hash = source_pixel_sha256(rgb)
|
||||
if expected_hash.casefold() != actual_hash:
|
||||
raise ValueError("Text manifest source_pixel_sha256 does not match the decoded source pixels")
|
||||
|
||||
raw_lines = payload.get("lines")
|
||||
if not isinstance(raw_lines, list) or not raw_lines:
|
||||
raise ValueError("Text manifest lines must be a non-empty list")
|
||||
lines = tuple(_load_line(item, width, height, index, schema_version) for index, item in enumerate(raw_lines))
|
||||
if list(lines) != sorted(lines, key=lambda line: (line.box[1], line.box[0])):
|
||||
raise ValueError("Text manifest lines must be in top-to-bottom, left-to-right reading order")
|
||||
return VerifiedTextManifest(actual_hash, width, height, lines)
|
||||
|
||||
|
||||
def _manifest_integer(payload: dict[str, Any], key: str) -> int:
|
||||
value = payload.get(key)
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
||||
raise ValueError(f"Text manifest {key} must be a positive integer")
|
||||
return value
|
||||
|
||||
|
||||
def _load_line(item: Any, width: int, height: int, index: int, schema_version: int) -> VerifiedTextLine:
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError(f"Text manifest line {index} must be an object")
|
||||
raw_box = item.get("box")
|
||||
if (
|
||||
not isinstance(raw_box, list)
|
||||
or len(raw_box) != 4
|
||||
or any(isinstance(value, bool) or not isinstance(value, int) for value in raw_box)
|
||||
):
|
||||
raise ValueError(f"Text manifest line {index} box must contain four integers")
|
||||
box = tuple(raw_box)
|
||||
x1, y1, x2, y2 = box
|
||||
if not (0 <= x1 < x2 <= width and 0 <= y1 < y2 <= height):
|
||||
raise ValueError(f"Text manifest line {index} box is outside the source dimensions")
|
||||
text: str | None = None
|
||||
script: str | None = None
|
||||
if schema_version == 1:
|
||||
text = item.get("text")
|
||||
script = item.get("script")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError(f"Text manifest line {index} text must be non-empty")
|
||||
if not isinstance(script, str) or not script.strip():
|
||||
raise ValueError(f"Text manifest line {index} script must be non-empty")
|
||||
angle_value = item.get("angle", 0.0)
|
||||
if isinstance(angle_value, bool) or not isinstance(angle_value, int | float):
|
||||
raise ValueError(f"Text manifest line {index} angle must be numeric")
|
||||
angle = float(angle_value)
|
||||
if not math.isfinite(angle) or abs(angle) > 30.0:
|
||||
raise ValueError(f"Text manifest line {index} angle must be between -30 and 30 degrees")
|
||||
return VerifiedTextLine(box, text, script, angle)
|
||||
|
||||
|
||||
def blend_fidelity_anchor(clean: Image.Image, donor: Image.Image) -> Image.Image:
|
||||
"""Blend 15% Qwen-VAE reconstruction into the oracle-clean pipeline output."""
|
||||
clean_rgb = np.asarray(clean.convert("RGB"), dtype=np.float32)
|
||||
donor_rgb = np.asarray(donor.convert("RGB"), dtype=np.float32)
|
||||
if clean_rgb.shape != donor_rgb.shape:
|
||||
raise ValueError("Clean result and Qwen-VAE donor dimensions must match")
|
||||
blended = np.rint(clean_rgb * (1.0 - FIDELITY_BLEND_ALPHA) + donor_rgb * FIDELITY_BLEND_ALPHA)
|
||||
return Image.fromarray(np.clip(blended, 0, 255).astype(np.uint8))
|
||||
|
||||
|
||||
def restore_verified_text(
|
||||
source: Image.Image,
|
||||
candidate: Image.Image,
|
||||
donor: Image.Image,
|
||||
lines: tuple[VerifiedTextLine, ...],
|
||||
) -> Image.Image:
|
||||
"""Erase candidate glyphs, then composite verified Qwen-VAE glyph cores."""
|
||||
from remove_ai_watermarks import region_eraser
|
||||
|
||||
if not region_eraser.lama_available():
|
||||
raise RuntimeError(
|
||||
"Verified text restoration requires LaMa. Install: pip install 'remove-ai-watermarks[text-restoration]'"
|
||||
)
|
||||
source_rgb = np.asarray(source.convert("RGB"))
|
||||
candidate_rgb = np.asarray(candidate.convert("RGB"))
|
||||
donor_rgb = np.asarray(donor.convert("RGB"))
|
||||
if source_rgb.shape != candidate_rgb.shape or source_rgb.shape != donor_rgb.shape:
|
||||
raise ValueError("Source, candidate, and Qwen-VAE donor dimensions must match")
|
||||
|
||||
source_masks = [source_silhouette_mask(source_rgb, line.box, line.angle) for line in lines]
|
||||
for index, mask in enumerate(source_masks):
|
||||
if not np.any(mask):
|
||||
raise ValueError(f"Verified text line {index} produced no source glyph pixels")
|
||||
candidate_masks = [source_silhouette_mask(candidate_rgb, line.box, line.angle) for line in lines]
|
||||
erase_masks = []
|
||||
for line, source_mask, candidate_mask in zip(lines, source_masks, candidate_masks, strict=True):
|
||||
radius = 5 if line.box[3] - line.box[1] >= 48 else 3
|
||||
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * radius + 1,) * 2)
|
||||
erase_masks.append(cv2.dilate(np.maximum(source_mask, candidate_mask), kernel))
|
||||
del candidate_masks
|
||||
|
||||
groups = group_text_lines(lines)
|
||||
background = cv2.cvtColor(candidate_rgb, cv2.COLOR_RGB2BGR)
|
||||
for group in groups:
|
||||
background = region_eraser.erase_lama(background, np.maximum.reduce([erase_masks[index] for index in group]))
|
||||
background_rgb = cv2.cvtColor(background, cv2.COLOR_BGR2RGB)
|
||||
residual_masks = [
|
||||
residual_glyph_mask(background_rgb, mask, line.box) for line, mask in zip(lines, erase_masks, strict=True)
|
||||
]
|
||||
for group in groups:
|
||||
residual = np.maximum.reduce([residual_masks[index] for index in group])
|
||||
if np.any(residual):
|
||||
background = region_eraser.erase_lama(background, residual)
|
||||
del erase_masks, residual_masks
|
||||
restored = cv2.cvtColor(background, cv2.COLOR_BGR2RGB)
|
||||
restored = composite_fresh_text_edges(source_rgb, restored, lines, source_masks)
|
||||
source_glyph_mask = np.maximum.reduce(source_masks)
|
||||
restored = composite_reconstructed_glyphs(donor_rgb, restored, source_glyph_mask)
|
||||
return Image.fromarray(restored)
|
||||
|
||||
|
||||
def _glyph_crop_box(
|
||||
box: tuple[int, int, int, int],
|
||||
width: int,
|
||||
height: int,
|
||||
left_pad: int,
|
||||
right_pad: int,
|
||||
) -> tuple[int, int, int, int]:
|
||||
"""Widen the detector box so glyph edges stay inside the silhouette crop.
|
||||
|
||||
Detector boxes can stop inside a leading flourish or trailing punctuation,
|
||||
and Paddle line boxes sit 2-5 px above the true ink bottom on the poster
|
||||
fixtures. The crop is otherwise the box itself, so those pixels never reach
|
||||
the donor composite. Start 12% sideways, expand each side independently
|
||||
while foreground reaches its boundary, and pad 8% up and 25% down.
|
||||
"""
|
||||
x1, y1, x2, y2 = box
|
||||
line_h = max(1, y2 - y1)
|
||||
pad_top = max(1, round(line_h * 0.08))
|
||||
pad_bot = max(2, round(line_h * 0.25))
|
||||
return (
|
||||
max(0, x1 - left_pad),
|
||||
max(0, y1 - pad_top),
|
||||
min(width, x2 + right_pad),
|
||||
min(height, y2 + pad_bot),
|
||||
)
|
||||
|
||||
|
||||
def _silhouette_crop_mask(
|
||||
source_rgb: NDArray[Any],
|
||||
crop_box: tuple[int, int, int, int],
|
||||
angle: float,
|
||||
) -> NDArray[Any]:
|
||||
"""Threshold one candidate crop so its horizontal boundaries can be tested."""
|
||||
height, width = source_rgb.shape[:2]
|
||||
x1, y1, x2, y2 = crop_box
|
||||
gray = cv2.cvtColor(source_rgb[y1:y2, x1:x2], cv2.COLOR_RGB2GRAY)
|
||||
support = np.ones(gray.shape, dtype=np.uint8)
|
||||
if angle:
|
||||
box_width, box_height = x2 - x1, y2 - y1
|
||||
theta = math.radians(abs(angle))
|
||||
cosine, sine = math.cos(theta), math.sin(theta)
|
||||
denominator = cosine * cosine - sine * sine
|
||||
rect_width = (box_width * cosine - box_height * sine) / denominator
|
||||
rect_height = (box_height * cosine - box_width * sine) / denominator
|
||||
rotated = cv2.boxPoints(
|
||||
((box_width / 2, box_height / 2), (max(1.0, rect_width * 0.92), max(1.0, rect_height * 0.62)), -angle)
|
||||
)
|
||||
support.fill(0)
|
||||
cv2.fillConvexPoly(support, np.rint(rotated).astype(np.int32), 1)
|
||||
values = gray[support > 0]
|
||||
background_luma = float(np.median(values))
|
||||
else:
|
||||
ring_pad = max(6, min(20, (y2 - y1) // 4))
|
||||
rx1, ry1, rx2, ry2 = _clip_box(crop_box, width, height, pad=ring_pad)
|
||||
context = cv2.cvtColor(source_rgb[ry1:ry2, rx1:rx2], cv2.COLOR_RGB2GRAY)
|
||||
ring = np.ones(context.shape, dtype=bool)
|
||||
ring[y1 - ry1 : y2 - ry1, x1 - rx1 : x2 - rx1] = False
|
||||
background_luma = float(np.median(context[ring])) if ring.any() else float(np.median(gray))
|
||||
values = gray.reshape(-1)
|
||||
low, high = float(np.percentile(values, 2)), float(np.percentile(values, 98))
|
||||
dark_contrast, light_contrast = background_luma - low, high - background_luma
|
||||
threshold = max(16.0, min(56.0, max(light_contrast, dark_contrast) * 0.22))
|
||||
if light_contrast > dark_contrast:
|
||||
crop_mask = (gray.astype(np.float32) >= background_luma + threshold).astype(np.uint8) * 255
|
||||
else:
|
||||
crop_mask = (gray.astype(np.float32) <= background_luma - threshold).astype(np.uint8) * 255
|
||||
crop_mask[support == 0] = 0
|
||||
return crop_mask
|
||||
|
||||
|
||||
def _anchored_components_reach_sides(
|
||||
crop_mask: NDArray[Any],
|
||||
anchor_box: tuple[int, int, int, int],
|
||||
) -> tuple[bool, bool]:
|
||||
"""Whether anchored foreground reaches the left and right crop sides."""
|
||||
count, labels, stats, _centroids = cv2.connectedComponentsWithStats(crop_mask, 8)
|
||||
x1, y1, x2, y2 = anchor_box
|
||||
anchor_counts = np.bincount(labels[y1:y2, x1:x2].reshape(-1), minlength=count)
|
||||
anchor_counts[0] = 0
|
||||
|
||||
def reaches(edge_labels: NDArray[Any]) -> bool:
|
||||
return any(
|
||||
label != 0 and anchor_counts[label] >= max(3, round(stats[label, cv2.CC_STAT_AREA] * 0.1))
|
||||
for label in np.unique(edge_labels)
|
||||
)
|
||||
|
||||
return reaches(labels[y1:y2, :2]), reaches(labels[y1:y2, -2:])
|
||||
|
||||
|
||||
def source_silhouette_mask(
|
||||
source_rgb: NDArray[Any],
|
||||
box: tuple[int, int, int, int],
|
||||
angle: float = 0.0,
|
||||
) -> NDArray[Any]:
|
||||
"""Recover a thresholded glyph shape without retaining source amplitudes."""
|
||||
height, width = source_rgb.shape[:2]
|
||||
box_x1, box_y1, box_x2, box_y2 = box
|
||||
line_h = max(1, box_y2 - box_y1)
|
||||
step = max(2, round(line_h * GLYPH_SIDE_PAD_RATIO))
|
||||
limit = max(step, round(line_h * GLYPH_SIDE_PAD_LIMIT))
|
||||
left_pad = right_pad = step
|
||||
while True:
|
||||
x1, y1, x2, y2 = _glyph_crop_box(box, width, height, left_pad, right_pad)
|
||||
crop_mask = _silhouette_crop_mask(source_rgb, (x1, y1, x2, y2), angle)
|
||||
core_y1 = max(0, box_y1 - y1)
|
||||
core_y2 = min(y2 - y1, box_y2 - y1)
|
||||
anchor_box = (max(0, box_x1 - x1), core_y1, min(x2 - x1, box_x2 - x1), core_y2)
|
||||
can_expand_left = x1 > 0 and left_pad < limit
|
||||
can_expand_right = x2 < width and right_pad < limit
|
||||
left_anchored, right_anchored = (
|
||||
_anchored_components_reach_sides(crop_mask, anchor_box)
|
||||
if can_expand_left or can_expand_right
|
||||
else (False, False)
|
||||
)
|
||||
left_touches = can_expand_left and left_anchored
|
||||
right_touches = can_expand_right and right_anchored
|
||||
if not left_touches and not right_touches:
|
||||
break
|
||||
if left_touches:
|
||||
left_pad = min(limit, left_pad + step)
|
||||
if right_touches:
|
||||
right_pad = min(limit, right_pad + step)
|
||||
result = np.zeros((height, width), dtype=np.uint8)
|
||||
result[y1:y2, x1:x2] = crop_mask
|
||||
return result
|
||||
|
||||
|
||||
def residual_glyph_mask(
|
||||
background_rgb: NDArray[Any],
|
||||
original_mask: NDArray[Any],
|
||||
box: tuple[int, int, int, int],
|
||||
) -> NDArray[Any]:
|
||||
"""Find glyph-like contrast left after the first inpaint pass."""
|
||||
residual = _foreground_mask(background_rgb, box)
|
||||
residual = cv2.bitwise_and(residual, original_mask)
|
||||
return cv2.dilate(residual, np.ones((5, 5), np.uint8), iterations=1)
|
||||
|
||||
|
||||
def composite_fresh_text_edges(
|
||||
source_rgb: NDArray[Any],
|
||||
background_rgb: NDArray[Any],
|
||||
lines: tuple[VerifiedTextLine, ...],
|
||||
masks: list[NDArray[Any]],
|
||||
) -> NDArray[Any]:
|
||||
"""Render fresh antialiased edges for source-derived glyph masks."""
|
||||
restored = background_rgb
|
||||
for line, mask in zip(lines, masks, strict=True):
|
||||
color = _sample_text_color(source_rgb, mask, line.box)
|
||||
restored = composite_fresh_silhouette(restored, mask, color)
|
||||
return restored
|
||||
|
||||
|
||||
def composite_reconstructed_glyphs(
|
||||
donor_rgb: NDArray[Any],
|
||||
background_rgb: NDArray[Any],
|
||||
glyph_mask: NDArray[Any],
|
||||
*,
|
||||
feather: float = GLYPH_FEATHER,
|
||||
) -> NDArray[Any]:
|
||||
"""Composite an exact reconstructed core with a narrow donor edge."""
|
||||
if donor_rgb.shape != background_rgb.shape or donor_rgb.shape[:2] != glyph_mask.shape:
|
||||
raise ValueError("donor, background, and glyph mask dimensions must match")
|
||||
blurred = cv2.GaussianBlur(glyph_mask, (0, 0), feather) if feather > 0 else glyph_mask
|
||||
alpha = np.maximum(glyph_mask, blurred).astype(np.float32) / 255.0
|
||||
combined = donor_rgb.astype(np.float32) * alpha[..., None] + background_rgb.astype(np.float32) * (
|
||||
1.0 - alpha[..., None]
|
||||
)
|
||||
return np.clip(np.rint(combined), 0, 255).astype(np.uint8)
|
||||
|
||||
|
||||
def composite_fresh_silhouette(
|
||||
background_rgb: NDArray[Any],
|
||||
glyph_mask: NDArray[Any],
|
||||
color: tuple[int, int, int],
|
||||
*,
|
||||
feather: float = 0.35,
|
||||
) -> NDArray[Any]:
|
||||
"""Render a binary source shape with fresh color and antialiasing."""
|
||||
if background_rgb.shape[:2] != glyph_mask.shape:
|
||||
raise ValueError("background and glyph mask dimensions must match")
|
||||
antialiased = cv2.GaussianBlur(glyph_mask, (0, 0), feather) if feather > 0 else glyph_mask
|
||||
alpha = antialiased.astype(np.float32)[..., None] / 255.0
|
||||
foreground = np.empty_like(background_rgb)
|
||||
foreground[:, :] = color
|
||||
combined = foreground.astype(np.float32) * alpha + background_rgb.astype(np.float32) * (1.0 - alpha)
|
||||
return np.clip(combined, 0, 255).astype(np.uint8)
|
||||
|
||||
|
||||
def _clip_box(box: tuple[int, int, int, int], width: int, height: int, pad: int = 0) -> tuple[int, int, int, int]:
|
||||
x1, y1, x2, y2 = box
|
||||
return max(0, x1 - pad), max(0, y1 - pad), min(width, x2 + pad), min(height, y2 + pad)
|
||||
|
||||
|
||||
def _foreground_mask(source_rgb: NDArray[Any], box: tuple[int, int, int, int]) -> NDArray[Any]:
|
||||
height, width = source_rgb.shape[:2]
|
||||
line_height = box[3] - box[1]
|
||||
x1, y1, x2, y2 = _clip_box(box, width, height, pad=max(6, int(line_height * 0.12)))
|
||||
gray = cv2.cvtColor(source_rgb[y1:y2, x1:x2], cv2.COLOR_RGB2GRAY)
|
||||
ring_pad = max(8, min(24, (y2 - y1) // 5))
|
||||
rx1, ry1, rx2, ry2 = _clip_box((x1, y1, x2, y2), width, height, pad=ring_pad)
|
||||
context = cv2.cvtColor(source_rgb[ry1:ry2, rx1:rx2], cv2.COLOR_RGB2GRAY)
|
||||
ring = np.ones(context.shape, dtype=bool)
|
||||
ring[y1 - ry1 : y2 - ry1, x1 - rx1 : x2 - rx1] = False
|
||||
background_luma = float(np.median(context[ring])) if ring.any() else float(np.median(gray))
|
||||
low, high = float(np.percentile(gray, 4)), float(np.percentile(gray, 96))
|
||||
dark_contrast, light_contrast = background_luma - low, high - background_luma
|
||||
threshold = max(24.0, min(72.0, max(light_contrast, dark_contrast) * 0.32))
|
||||
if light_contrast > dark_contrast:
|
||||
mask = (gray.astype(np.float32) >= background_luma + threshold).astype(np.uint8) * 255
|
||||
else:
|
||||
mask = (gray.astype(np.float32) <= background_luma - threshold).astype(np.uint8) * 255
|
||||
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, np.ones((2, 2), np.uint8))
|
||||
dilation = 5 if line_height >= 48 else 3
|
||||
mask = cv2.dilate(mask, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * dilation + 1,) * 2))
|
||||
result = np.zeros((height, width), dtype=np.uint8)
|
||||
result[y1:y2, x1:x2] = mask
|
||||
return result
|
||||
|
||||
|
||||
def _sample_text_color(
|
||||
source_rgb: NDArray[Any], mask: NDArray[Any], box: tuple[int, int, int, int]
|
||||
) -> tuple[int, int, int]:
|
||||
height, width = source_rgb.shape[:2]
|
||||
x1, y1, x2, y2 = _clip_box(box, width, height, pad=2)
|
||||
crop = source_rgb[y1:y2, x1:x2]
|
||||
pixels = crop[mask[y1:y2, x1:x2] > 0]
|
||||
luma = pixels.mean(axis=1)
|
||||
background_luma = float(crop[[0, -1], :, :].reshape(-1, 3).mean(axis=1).mean())
|
||||
selected = (
|
||||
pixels[luma <= np.percentile(luma, 20)] if background_luma >= 128 else pixels[luma >= np.percentile(luma, 80)]
|
||||
)
|
||||
return tuple(int(value) for value in np.median(selected, axis=0))
|
||||
|
||||
|
||||
def group_text_lines(lines: Sequence[VerifiedTextLine]) -> list[list[int]]:
|
||||
"""Group nearby same-script lines for a shared LaMa erase pass."""
|
||||
groups: list[list[int]] = []
|
||||
for index, line in enumerate(lines):
|
||||
if not groups:
|
||||
groups.append([index])
|
||||
continue
|
||||
previous = lines[groups[-1][-1]]
|
||||
gap = line.box[1] - previous.box[3]
|
||||
if line.script != previous.script or gap > max(60, int((previous.box[3] - previous.box[1]) * 1.1)):
|
||||
groups.append([index])
|
||||
else:
|
||||
groups[-1].append(index)
|
||||
return groups
|
||||
@@ -69,6 +69,14 @@ SDXL_ZIMAGE_OPENAI_STRENGTH = 0.15
|
||||
SDXL_ZIMAGE_GEMINI_STRENGTH = 0.25
|
||||
SDXL_ZIMAGE_UNKNOWN_STRENGTH = SDXL_ZIMAGE_GEMINI_STRENGTH
|
||||
|
||||
# qwen-zimage keeps its resolution curve for openai/unknown content, but Google
|
||||
# content takes this flat oracle-measured floor instead of the curve (whose top,
|
||||
# 0.154, left a 4.33 MP CJK-sign fixture detected x3 on the full production path
|
||||
# with a valid pixel control, 2026-08-18). 0.30 anchors measured clean in Gemini
|
||||
# across two fixtures and two work accounts (CJK sign + 18-face, 3/3 checks each,
|
||||
# 2026-08 research) and stayed clean under the vae-glyphs donor layer on top.
|
||||
QWEN_ZIMAGE_GOOGLE_STRENGTH = 0.30
|
||||
|
||||
|
||||
# sdxl-zimage picks its strength from the VENDOR (unlike qwen-zimage, which derives it
|
||||
# from image area). An unlisted or unknown vendor falls back to the Gemini value.
|
||||
@@ -103,7 +111,8 @@ def resolve_adaptive_polish(adaptive_polish: bool | None, pipeline: str) -> bool
|
||||
def strength_default_help() -> str:
|
||||
"""Describe the live default policy without duplicating its values."""
|
||||
return (
|
||||
"profile-adaptive (qwen-zimage uses resolution-adaptive denoise; sdxl-zimage "
|
||||
"profile-adaptive (qwen-zimage uses resolution-adaptive denoise, with a "
|
||||
f"flat oracle-measured Google floor of {QWEN_ZIMAGE_GOOGLE_STRENGTH}; sdxl-zimage "
|
||||
f"uses OpenAI {SDXL_ZIMAGE_OPENAI_STRENGTH} / Google {SDXL_ZIMAGE_GEMINI_STRENGTH} / "
|
||||
f"unknown {SDXL_ZIMAGE_UNKNOWN_STRENGTH}, from the C2PA issuer)"
|
||||
)
|
||||
@@ -118,10 +127,14 @@ def resolve_strength(
|
||||
) -> float:
|
||||
"""Resolve a user override or the calibrated policy for a profile and vendor.
|
||||
|
||||
Total by design. qwen-zimage picks its strength from image area rather than from
|
||||
the vendor, so it needs ``size``; returning ``None`` for it instead would push that
|
||||
Total by design. qwen-zimage picks its strength from image area rather than
|
||||
from the vendor, so it needs ``size``; returning ``None`` for it instead would push that
|
||||
branch onto every caller and move one of the two strength policies outside this
|
||||
module. ``size`` is required for qwen-zimage without an explicit strength.
|
||||
One measured exception since 0.27.2: Google-provenance content on qwen-zimage
|
||||
takes the flat ``QWEN_ZIMAGE_GOOGLE_STRENGTH`` floor instead of the area curve
|
||||
(the curve's 0.154 top left a 4.33 MP fixture oracle-detected on the full
|
||||
production path; see the constant's comment).
|
||||
"""
|
||||
if strength is not None:
|
||||
return strength
|
||||
@@ -129,6 +142,8 @@ def resolve_strength(
|
||||
return _SDXL_ZIMAGE_STRENGTH_BY_VENDOR.get((vendor or "").casefold(), SDXL_ZIMAGE_UNKNOWN_STRENGTH)
|
||||
if size is None:
|
||||
raise ValueError("qwen-zimage resolves strength from image area, so size is required")
|
||||
if (vendor or "").casefold() == "google":
|
||||
return QWEN_ZIMAGE_GOOGLE_STRENGTH
|
||||
from remove_ai_watermarks._internal.qwen_zimage_pipeline import resolution_adaptive_denoise
|
||||
|
||||
return resolution_adaptive_denoise(*size)
|
||||
|
||||
@@ -26,6 +26,8 @@ if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from remove_ai_watermarks._internal.text_restoration import VerifiedTextManifest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
@@ -187,6 +189,8 @@ class WatermarkRemover:
|
||||
tile: bool = False,
|
||||
tile_size: int = 1024,
|
||||
tile_overlap: int = 128,
|
||||
text_manifest: VerifiedTextManifest | None = None,
|
||||
fidelity_anchor: bool = False,
|
||||
) -> Path:
|
||||
"""Regenerate image pixels and write the result without AI metadata.
|
||||
|
||||
@@ -203,6 +207,10 @@ class WatermarkRemover:
|
||||
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}")
|
||||
if text_manifest is not None and self.model_profile == SDXL_ZIMAGE_PROFILE:
|
||||
raise ValueError("Verified text restoration is supported only by the qwen-zimage profile")
|
||||
if text_manifest is not None and tile:
|
||||
raise ValueError("Verified text restoration is not calibrated with tiled diffusion")
|
||||
|
||||
result = self._load_qwen_zimage_pipeline().run(
|
||||
source,
|
||||
@@ -211,6 +219,8 @@ class WatermarkRemover:
|
||||
tile=tile,
|
||||
tile_size=tile_size,
|
||||
tile_overlap=tile_overlap,
|
||||
text_manifest=text_manifest,
|
||||
fidelity_anchor=fidelity_anchor,
|
||||
)
|
||||
self._write_output(result, destination)
|
||||
return destination
|
||||
@@ -243,6 +243,8 @@ class InvisibleOptions:
|
||||
tile: bool = False
|
||||
tile_size: int = 1024
|
||||
tile_overlap: int = 128
|
||||
text_manifest: Path | None = None
|
||||
fidelity_anchor: bool = False
|
||||
|
||||
|
||||
# What the invisible stage did. "unavailable" is the one outcome the caller must
|
||||
@@ -329,12 +331,13 @@ class _SourceEvidence:
|
||||
if evidence is None:
|
||||
return True
|
||||
try:
|
||||
from remove_ai_watermarks._internal.c2pa import c2pa_info_has_removal_hint
|
||||
from remove_ai_watermarks.identify import identify_from_evidence
|
||||
|
||||
report = identify_from_evidence(evidence, image_path=self._path, check_invisible=True)
|
||||
except Exception:
|
||||
return True
|
||||
return bool(report.ai_from_metadata)
|
||||
return report.ai_from_metadata or c2pa_info_has_removal_hint(evidence.c2pa_info)
|
||||
|
||||
|
||||
def _provenance_from_report(report: Any, path: Path) -> frozenset[str]:
|
||||
@@ -522,6 +525,8 @@ def _run_invisible(
|
||||
tile=opts.tile,
|
||||
tile_size=opts.tile_size,
|
||||
tile_overlap=opts.tile_overlap,
|
||||
text_manifest=opts.text_manifest,
|
||||
fidelity_anchor=opts.fidelity_anchor,
|
||||
)
|
||||
say("invisible", "removed")
|
||||
return "removed"
|
||||
|
||||
@@ -311,6 +311,26 @@ _cpu_offload_option = click.option(
|
||||
),
|
||||
)
|
||||
|
||||
_text_manifest_option = click.option(
|
||||
"--text-manifest",
|
||||
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
||||
default=None,
|
||||
help=(
|
||||
"Experimental verified-text restoration manifest. Requires qwen-zimage, "
|
||||
"the text-restoration extra, native untiled geometry, and no postprocessing."
|
||||
),
|
||||
)
|
||||
|
||||
_fidelity_anchor_option = click.option(
|
||||
"--fidelity-anchor/--no-fidelity-anchor",
|
||||
default=False,
|
||||
help=(
|
||||
"With --text-manifest: blend 15% of the Qwen-VAE donor across the whole "
|
||||
"frame. Off by default - the global blend was measured to return "
|
||||
"detector-visible OpenAI SynthID on poster-scale manifests."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
_visible_backend_option = click.option(
|
||||
"--backend",
|
||||
@@ -787,6 +807,8 @@ def cmd_erase(
|
||||
@_tile_options
|
||||
@_force_option
|
||||
@_cpu_offload_option
|
||||
@_text_manifest_option
|
||||
@_fidelity_anchor_option
|
||||
@click.pass_context
|
||||
def cmd_invisible(
|
||||
ctx: click.Context,
|
||||
@@ -806,6 +828,8 @@ def cmd_invisible(
|
||||
tile_overlap: int,
|
||||
force: bool,
|
||||
cpu_offload: bool,
|
||||
text_manifest: Path | None,
|
||||
fidelity_anchor: bool,
|
||||
) -> None:
|
||||
"""Remove invisible AI watermarks (SynthID, StableSignature, TreeRing).
|
||||
|
||||
@@ -853,20 +877,26 @@ def cmd_invisible(
|
||||
console.print(f" Strength: {_resolved_strength_for_display(source, strength, vendor, pipeline)}")
|
||||
|
||||
t0 = time.monotonic()
|
||||
result_path = engine.remove_watermark(
|
||||
image_path=source,
|
||||
output_path=output,
|
||||
strength=strength,
|
||||
seed=seed,
|
||||
humanize=humanize,
|
||||
unsharp=unsharp,
|
||||
adaptive_polish=adaptive_polish,
|
||||
max_resolution=max_resolution,
|
||||
vendor=vendor,
|
||||
tile=tile,
|
||||
tile_size=tile_size,
|
||||
tile_overlap=tile_overlap,
|
||||
)
|
||||
try:
|
||||
result_path = engine.remove_watermark(
|
||||
image_path=source,
|
||||
output_path=output,
|
||||
strength=strength,
|
||||
seed=seed,
|
||||
humanize=humanize,
|
||||
unsharp=unsharp,
|
||||
adaptive_polish=adaptive_polish,
|
||||
max_resolution=max_resolution,
|
||||
vendor=vendor,
|
||||
tile=tile,
|
||||
tile_size=tile_size,
|
||||
tile_overlap=tile_overlap,
|
||||
text_manifest=text_manifest,
|
||||
fidelity_anchor=fidelity_anchor,
|
||||
)
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
console.print(f" Error: {exc}")
|
||||
raise SystemExit(1) from exc
|
||||
elapsed = time.monotonic() - t0
|
||||
|
||||
size_kb = result_path.stat().st_size / 1024
|
||||
@@ -874,10 +904,27 @@ def cmd_invisible(
|
||||
|
||||
|
||||
# ── Metadata operations ──
|
||||
def _print_metadata_not_a_clean_verdict() -> None:
|
||||
"""Repeat the identify empty-scan limit on metadata check and strip success.
|
||||
|
||||
``metadata --check`` and ``metadata --remove`` answer a narrower question than
|
||||
``identify``: they report embedded AI metadata only. A quiet result used to
|
||||
stop at "No AI metadata found" / "AI metadata stripped", which readers treat
|
||||
as a clean-image verdict. The pixel channel is unchanged, and this project has
|
||||
no local SynthID decoder, so the command must say so in the same words
|
||||
``identify`` already uses.
|
||||
"""
|
||||
console.print(
|
||||
" This is not the same as 'clean': a pixel watermark such as SynthID cannot be\n"
|
||||
" detected here once its metadata proxy is absent."
|
||||
)
|
||||
|
||||
|
||||
def _print_metadata_report(source: Path, has_ai: bool, metadata: dict[str, str]) -> None:
|
||||
"""Render one metadata inspection result for the generic and video commands."""
|
||||
if not has_ai:
|
||||
console.print(f" No AI metadata found in {source.name}")
|
||||
_print_metadata_not_a_clean_verdict()
|
||||
return
|
||||
|
||||
console.print(f" Warning: AI metadata detected in {source.name}:")
|
||||
@@ -944,6 +991,7 @@ def cmd_metadata(
|
||||
console.print(" the file could not be decoded, so it was copied through unchanged")
|
||||
raise SystemExit(1)
|
||||
console.print(f" AI metadata stripped -> {out}")
|
||||
_print_metadata_not_a_clean_verdict()
|
||||
|
||||
|
||||
# ── Video pipeline ──
|
||||
@@ -1085,6 +1133,7 @@ def cmd_video_metadata(
|
||||
console.print(f" still present: {', '.join(sorted(result.remaining))}")
|
||||
raise SystemExit(1)
|
||||
console.print(f" AI metadata stripped -> {result.output}")
|
||||
_print_metadata_not_a_clean_verdict()
|
||||
|
||||
|
||||
@cmd_video.command("invisible")
|
||||
@@ -1356,6 +1405,13 @@ def cmd_identify(ctx: click.Context, source: Path, no_visible: bool, as_json: bo
|
||||
verdict = "AI-generated (fully synthetic)"
|
||||
console.print(f"\n Verdict: {verdict} (confidence: {report.confidence})")
|
||||
console.print(f" Platform: {report.platform or 'undetermined'}")
|
||||
if report.c2pa_validation is not None:
|
||||
validation = report.c2pa_validation
|
||||
console.print(
|
||||
" C2PA validation: "
|
||||
f"integrity={validation['integrity']}, signature={validation['signature']}, "
|
||||
f"signer trust={validation['signer_trust']}, signer validity={validation['signer_validity']}"
|
||||
)
|
||||
|
||||
if report.is_ai_generated is None:
|
||||
console.print(
|
||||
@@ -1403,6 +1459,8 @@ def cmd_identify(ctx: click.Context, source: Path, no_visible: bool, as_json: bo
|
||||
@_tile_options
|
||||
@_force_option
|
||||
@_cpu_offload_option
|
||||
@_text_manifest_option
|
||||
@_fidelity_anchor_option
|
||||
@click.pass_context
|
||||
def cmd_all(
|
||||
ctx: click.Context,
|
||||
@@ -1424,6 +1482,8 @@ def cmd_all(
|
||||
tile_overlap: int,
|
||||
force: bool,
|
||||
cpu_offload: bool,
|
||||
text_manifest: Path | None,
|
||||
fidelity_anchor: bool,
|
||||
) -> None:
|
||||
"""Remove ALL watermarks: visible + invisible + metadata.
|
||||
|
||||
@@ -1501,6 +1561,8 @@ def cmd_all(
|
||||
tile=tile,
|
||||
tile_size=tile_size,
|
||||
tile_overlap=tile_overlap,
|
||||
text_manifest=text_manifest,
|
||||
fidelity_anchor=fidelity_anchor,
|
||||
),
|
||||
force=force,
|
||||
progress=progress,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
"""DWT-DCT decoder compatible with invisible-watermark's ``dwtDct`` path.
|
||||
|
||||
Derived from ShieldMnt/invisible-watermark ``imwatermark/maxDct.py`` (MIT),
|
||||
trimmed to the matrix path used by Stable Diffusion, SDXL, and FLUX.
|
||||
trimmed to the matrix path used by Stable Diffusion, SDXL, and FLUX. The block
|
||||
scan is vectorized rather than transcribed, so the file no longer reads line by
|
||||
line against upstream; what it preserves is the output, bit for bit. See
|
||||
[`docs/module-internals.md`](../../docs/module-internals.md) for the
|
||||
measurements and for why a faster hand-rolled transform is not available.
|
||||
|
||||
Copyright (c) 2021 ShieldMnt
|
||||
|
||||
@@ -23,7 +27,39 @@ if TYPE_CHECKING:
|
||||
from numpy.typing import NDArray
|
||||
|
||||
_DEFAULT_SCALES = (0, 36, 36)
|
||||
_DEFAULT_BLOCK = 4
|
||||
# Fixed by the format being decoded, and the 4-way fold chains in `_frame_bits`
|
||||
# are written for it. It was a constructor parameter while the block scan was a
|
||||
# generic Python loop; nothing ever passed another value, and a knob that now
|
||||
# silently returns wrong bits is worse than no knob.
|
||||
_BLOCK = 4
|
||||
|
||||
# Block-rows of the approximation band handled per strip. Not a tuned value:
|
||||
# every height measured landed inside the others' noise. What matters is strips
|
||||
# at all rather than a full-plane intermediate, not this number.
|
||||
_STRIP = 16
|
||||
|
||||
|
||||
def _approximation(rows: NDArray[Any]) -> NDArray[Any]:
|
||||
"""One Haar pass along the last axis, approximation band only.
|
||||
|
||||
``pywt.dwt(x, "haar", axis=1)[0]`` computes and allocates the detail band as
|
||||
well, and dispatches per row. Flattening lets one ``downcoef`` call do the
|
||||
whole plane, and it is the same numbers in the same order **only while the
|
||||
last axis is even**: Haar's filter is length 2, so an even row length makes
|
||||
every pair fall inside its own row with no boundary extension. On an odd
|
||||
width the pairs walk across row boundaries and the reshape below still
|
||||
succeeds whenever the total is even -- wrong bits, no exception, and the
|
||||
upstream-parity test is `skipif`-gated. Hence the explicit check.
|
||||
|
||||
The transform itself stays inside pywt however slow that looks. Its C
|
||||
convolution contracts into an FMA that no numpy expression reproduces, and
|
||||
the caller's threshold is ``peak % 36 > 18.0`` against values that are exact
|
||||
multiples of 0.5, so a 1-ulp difference flips real bits.
|
||||
"""
|
||||
height, width = rows.shape
|
||||
if width % 2:
|
||||
raise RuntimeError(f"row length {width} is odd; the raveled Haar pass requires an even last axis")
|
||||
return pywt.downcoef("a", rows.ravel(), "haar").reshape(height, width // 2)
|
||||
|
||||
|
||||
class _DecodeMaxDct:
|
||||
@@ -33,54 +69,85 @@ class _DecodeMaxDct:
|
||||
self,
|
||||
wm_lengths: tuple[int, ...],
|
||||
scales: tuple[int, int, int] = _DEFAULT_SCALES,
|
||||
block: int = _DEFAULT_BLOCK,
|
||||
) -> None:
|
||||
self._wm_lengths = wm_lengths
|
||||
self._scales = scales
|
||||
self._block = block
|
||||
|
||||
def decode(self, bgr: NDArray[Any]) -> dict[int, NDArray[Any]]:
|
||||
row, col, _channels = bgr.shape
|
||||
yuv = cv2.cvtColor(bgr, cv2.COLOR_BGR2YUV)
|
||||
trimmed = yuv[: row // 4 * 4, : col // 4 * 4]
|
||||
|
||||
scores_by_length = {wm_len: ([0] * wm_len, [0] * wm_len) for wm_len in self._wm_lengths}
|
||||
for channel in range(2):
|
||||
if self._scales[channel] <= 0:
|
||||
continue
|
||||
ca1, _detail = pywt.dwt2(yuv[: row // 4 * 4, : col // 4 * 4, channel], "haar")
|
||||
self._decode_frame(ca1, self._scales[channel], scores_by_length)
|
||||
per_channel = [
|
||||
self._plane_bits(trimmed, channel, self._scales[channel])
|
||||
for channel in range(2)
|
||||
if self._scales[channel] > 0
|
||||
]
|
||||
# Each channel restarts the bit index at 0, so the buckets come from a
|
||||
# per-channel arange rather than one running counter.
|
||||
index = np.concatenate([np.arange(bits.size) for bits in per_channel] or [np.zeros(0, dtype=np.int64)])
|
||||
weights = np.concatenate(per_channel or [np.zeros(0)])
|
||||
|
||||
return {
|
||||
wm_len: np.asarray(sums) * 255 > np.asarray(counts) * 127
|
||||
for wm_len, (sums, counts) in scores_by_length.items()
|
||||
}
|
||||
decoded: dict[int, NDArray[Any]] = {}
|
||||
for wm_len in self._wm_lengths:
|
||||
bucket = index % wm_len
|
||||
sums = np.bincount(bucket, weights=weights, minlength=wm_len)
|
||||
counts = np.bincount(bucket, minlength=wm_len)
|
||||
decoded[wm_len] = sums * 255 > counts * 127
|
||||
return decoded
|
||||
|
||||
def _decode_frame(
|
||||
self,
|
||||
frame: NDArray[Any],
|
||||
scale: int,
|
||||
scores_by_length: dict[int, tuple[list[int], list[int]]],
|
||||
) -> None:
|
||||
row, col = frame.shape
|
||||
bit_index = 0
|
||||
for i in range(row // self._block):
|
||||
for j in range(col // self._block):
|
||||
block = frame[
|
||||
i * self._block : i * self._block + self._block,
|
||||
j * self._block : j * self._block + self._block,
|
||||
]
|
||||
inferred = self._infer_bit(block, scale)
|
||||
for wm_len, (sums, counts) in scores_by_length.items():
|
||||
bucket = bit_index % wm_len
|
||||
sums[bucket] += inferred
|
||||
counts[bucket] += 1
|
||||
bit_index += 1
|
||||
def _plane_bits(self, trimmed: NDArray[Any], channel: int, scale: int) -> NDArray[Any]:
|
||||
"""Block bits for one colour plane, a strip of block-rows at a time.
|
||||
|
||||
def _infer_bit(self, block: NDArray[Any], scale: int) -> int:
|
||||
position = int(np.argmax(np.abs(block.flatten()[1:]))) + 1
|
||||
i, j = position // self._block, position % self._block
|
||||
value = abs(float(block[i][j]))
|
||||
return int((value % scale) > 0.5 * scale)
|
||||
``dwt2`` is ``dwtn``: it transforms along axis 0, then along axis 1 over
|
||||
both halves, and three of the four bands it returns are discarded here.
|
||||
Only the approximation is ever asked for, and transposing between the
|
||||
two passes lets pywt walk a contiguous axis instead of a column -- that
|
||||
access pattern, not the arithmetic saved, is where the time goes.
|
||||
|
||||
Strips mean no full-plane float64 intermediate is ever materialized, and
|
||||
they are seam-free for the reason ``_approximation`` documents: output
|
||||
row ``k`` reads input rows ``2k`` and ``2k + 1`` only. Strips start on
|
||||
multiples of ``2 * _BLOCK``, so neither a pair nor a 4x4 block straddles
|
||||
one.
|
||||
"""
|
||||
if trimmed.shape[0] == 0 or trimmed.shape[1] == 0:
|
||||
# Reachable: a 1x65536 image clears the caller's area check and
|
||||
# trims to an empty plane. Left to dwt2 so the exception stays the
|
||||
# one this module has always raised -- returning empty bits here
|
||||
# instead would silently turn a raise into an all-false verdict.
|
||||
return pywt.dwt2(trimmed[:, :, channel], "haar")[0]
|
||||
rows = trimmed.shape[0] // (2 * _BLOCK)
|
||||
cols = trimmed.shape[1] // (2 * _BLOCK)
|
||||
if rows == 0 or cols == 0:
|
||||
return np.zeros(0, dtype=np.float64)
|
||||
|
||||
width = cols * 2 * _BLOCK
|
||||
pieces: list[NDArray[Any]] = []
|
||||
for start in range(0, rows, _STRIP):
|
||||
stop = min(start + _STRIP, rows)
|
||||
strip = trimmed[start * 2 * _BLOCK : stop * 2 * _BLOCK, :width]
|
||||
columns = cv2.transpose(cv2.extractChannel(strip, channel))
|
||||
band = _approximation(cv2.transpose(_approximation(columns)))
|
||||
pieces.append(self._frame_bits(band, scale))
|
||||
return np.concatenate(pieces)
|
||||
|
||||
def _frame_bits(self, frame: NDArray[Any], scale: int) -> NDArray[Any]:
|
||||
"""One bit per 4x4 block, in row-major block order.
|
||||
|
||||
Upstream's per-block loop, said to numpy once instead of to the
|
||||
interpreter ~135k times per image. Zeroing the DC term in the absolute
|
||||
band says "ignore index 0" without materializing a ``(nblocks, 16)``
|
||||
copy, and the 4-way ``np.maximum`` chains reduce over contiguous rows.
|
||||
"""
|
||||
rows = frame.shape[0] // _BLOCK
|
||||
cols = frame.shape[1] // _BLOCK
|
||||
band = np.abs(frame[: rows * _BLOCK, : cols * _BLOCK])
|
||||
band[::_BLOCK, ::_BLOCK] = 0.0
|
||||
folded = np.maximum(np.maximum(band[0::4], band[1::4]), np.maximum(band[2::4], band[3::4]))
|
||||
folded = folded.reshape(rows * cols, _BLOCK)
|
||||
peak = np.maximum(np.maximum(folded[:, 0], folded[:, 1]), np.maximum(folded[:, 2], folded[:, 3]))
|
||||
return ((peak % scale) > 0.5 * scale).astype(np.float64)
|
||||
|
||||
|
||||
def decode_dwt_dct(bgr: NDArray[Any], wm_len: int) -> NDArray[Any]:
|
||||
|
||||
@@ -28,6 +28,9 @@ from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from remove_ai_watermarks._internal.c2pa import (
|
||||
c2pa_info_from_manifest_store,
|
||||
c2pa_info_has_invalid_credential,
|
||||
c2pa_info_has_invismark,
|
||||
c2pa_info_has_removal_hint,
|
||||
cbor_text_after,
|
||||
extract_c2pa_info,
|
||||
soft_binding_vendors_in,
|
||||
@@ -37,6 +40,7 @@ from remove_ai_watermarks._internal.c2pa import (
|
||||
from remove_ai_watermarks._internal.constants import (
|
||||
C2PA_AI_TOOLS,
|
||||
C2PA_AI_VENDORS,
|
||||
C2PA_CLAIM_GENERATOR_PLATFORMS,
|
||||
C2PA_IDENTITY_AI_ORGS,
|
||||
C2PA_ISSUERS,
|
||||
)
|
||||
@@ -113,6 +117,18 @@ _SYNTHID_CAVEAT = (
|
||||
"SynthID presence comes from supported provenance here; the pixel watermark is not locally "
|
||||
"decoded (proprietary decoder). Confirm via the Gemini app or openai.com/verify."
|
||||
)
|
||||
_C2PA_UNTRUSTED_CAVEAT = (
|
||||
"The C2PA claim signature and asset binding validate, but the signer identity is not anchored "
|
||||
"to a trusted credential here; treat the named platform as a signed claim, not a verified identity."
|
||||
)
|
||||
_C2PA_UNVALIDATED_CAVEAT = (
|
||||
"The C2PA marker was parsed without cryptographic validation; treat its origin and watermark "
|
||||
"assertions as unverified claims."
|
||||
)
|
||||
_C2PA_INVALID_CAVEAT = (
|
||||
"The embedded C2PA claim no longer validates against this asset. Its origin and watermark assertions "
|
||||
"are retained only as removal hints, not as verified provenance."
|
||||
)
|
||||
_IPTC_ONLY_CAVEAT = "The IPTC 'Made with AI' tag flags AI provenance but does not identify the specific platform."
|
||||
_INVISIBLE_WM_CAVEAT = (
|
||||
"The open invisible watermark is fragile: it does not survive JPEG re-encoding "
|
||||
@@ -130,6 +146,10 @@ _C2PA_CLOUD_CAVEAT = (
|
||||
"It marks Content Credentials, not AI origin: the cloud manifest may describe a "
|
||||
"human edit, and reading it needs a network fetch this tool does not make."
|
||||
)
|
||||
_SOFT_BINDING_CAVEAT = (
|
||||
"Removing the embedded C2PA manifest does not remove its soft binding: the named "
|
||||
"watermark or fingerprint may remain in the pixels and re-link the asset to provenance."
|
||||
)
|
||||
_SAMSUNG_GENAI_CAVEAT = (
|
||||
"Samsung's genAIType marker shows a Galaxy AI editing tool (Generative Edit, "
|
||||
"Sketch to Image, ...) touched the image; it is an undocumented proprietary "
|
||||
@@ -260,6 +280,7 @@ def _external_exif_generator(pairs: list[tuple[str, Any]], scan: bytes) -> str |
|
||||
"title",
|
||||
"description",
|
||||
"creatortool",
|
||||
"usercomment",
|
||||
}
|
||||
candidates = [
|
||||
_external_text(value)
|
||||
@@ -372,6 +393,8 @@ def evidence_from_metadata_record(
|
||||
"actions",
|
||||
"synthid_watermark",
|
||||
"soft_binding",
|
||||
"soft_binding_algorithm",
|
||||
"soft_binding_value",
|
||||
):
|
||||
if key in c2pa_info:
|
||||
ai_metadata.setdefault(key, str(c2pa_info[key]))
|
||||
@@ -448,11 +471,12 @@ class ProvenanceReport:
|
||||
ai_source_kind: str | None = None
|
||||
# True when the AI verdict rests on a metadata or embedded-invisible signal
|
||||
# (C2PA AI issuer / SynthID provenance, IPTC, AIGC, local gen params, EXIF/xAI, or
|
||||
# an open DWT-DCT / TrustMark decode) -- as opposed to a visible mark or a
|
||||
# weak medium-confidence hint (hf-job, Samsung genAIType). It is exactly the
|
||||
# set of signals an invisible/diffusion scrub targets: a visible-only or
|
||||
# no-signal image has it False. Equivalent to ``confidence == "high"``;
|
||||
# surfaced as a field so callers gate on intent, not on the string.
|
||||
# an open DWT-DCT decode) -- as opposed to a visible mark, provenance-only
|
||||
# TrustMark, or a weak medium-confidence hint (hf-job, Samsung genAIType). This
|
||||
# is the set of signals an invisible/diffusion scrub targets: a visible-only
|
||||
# or no-signal image has it False. An intact C2PA AI claim from an untrusted
|
||||
# signer is deliberately medium-confidence while this remains True; callers
|
||||
# should gate on intent, not on the confidence string.
|
||||
ai_from_metadata: bool = False
|
||||
watermarks: list[str] = field(default_factory=list[str])
|
||||
signals: list[Signal] = field(default_factory=list["Signal"])
|
||||
@@ -462,6 +486,11 @@ class ProvenanceReport:
|
||||
# AI-generation markers). Non-empty means the provenance is internally
|
||||
# inconsistent -- a strong tell of spoofed, transplanted, or laundered metadata.
|
||||
integrity_clashes: list[str] = field(default_factory=list[str])
|
||||
# Orthogonal C2PA checks. A valid content binding does not make an untrusted
|
||||
# signer identity trusted, and an expired credential does not by itself mean the
|
||||
# signed bytes changed. Fallback parsing reports each dimension as unknown;
|
||||
# None means no C2PA result exists.
|
||||
c2pa_validation: dict[str, Any] | None = None
|
||||
|
||||
def to_dict(
|
||||
self,
|
||||
@@ -497,9 +526,40 @@ class ProvenanceReport:
|
||||
],
|
||||
"caveats": list(self.caveats),
|
||||
"integrity_clashes": list(self.integrity_clashes),
|
||||
"c2pa_validation": self.c2pa_validation,
|
||||
}
|
||||
|
||||
|
||||
def _c2pa_validation(info: dict[str, Any]) -> dict[str, Any] | None:
|
||||
source = info.get("c2pa_validation_source")
|
||||
if not isinstance(source, str):
|
||||
return None
|
||||
codes = info.get("c2pa_validation_codes")
|
||||
return {
|
||||
"source": source,
|
||||
"state": str(info.get("c2pa_validation_state", "unknown")),
|
||||
"integrity": str(info.get("c2pa_integrity", "unknown")),
|
||||
"signature": str(info.get("c2pa_signature", "unknown")),
|
||||
"signer_trust": str(info.get("c2pa_signer_trust", "unknown")),
|
||||
"signer_validity": str(info.get("c2pa_signer_validity", "unknown")),
|
||||
"codes": list(cast("list[object]", codes)) if isinstance(codes, list) else [],
|
||||
}
|
||||
|
||||
|
||||
def _c2pa_credential_level(info: dict[str, Any]) -> str:
|
||||
"""Return invalid, verified, or unverified for provenance attribution."""
|
||||
if c2pa_info_has_invalid_credential(info):
|
||||
return "invalid"
|
||||
if (
|
||||
info.get("c2pa_integrity") == "valid"
|
||||
and info.get("c2pa_signature") == "valid"
|
||||
and info.get("c2pa_signer_trust") == "trusted"
|
||||
and info.get("c2pa_signer_validity") == "valid"
|
||||
):
|
||||
return "verified"
|
||||
return "unverified"
|
||||
|
||||
|
||||
def extract_provenance_evidence(image_path: Path) -> ProvenanceEvidence:
|
||||
"""Read all file-backed metadata needed by provenance verdict logic once."""
|
||||
return ProvenanceEvidence(
|
||||
@@ -691,6 +751,14 @@ def _attribute_platform(issuers: list[str], *, is_ai: bool = True) -> str | None
|
||||
return None
|
||||
|
||||
|
||||
def _claim_generator_platform(generator: str | None) -> str | None:
|
||||
"""Resolve a distinctive C2PA claim generator to its user-facing product."""
|
||||
if not generator:
|
||||
return None
|
||||
lowered = generator.lower()
|
||||
return next((platform for token, platform in C2PA_CLAIM_GENERATOR_PLATFORMS if token in lowered), None)
|
||||
|
||||
|
||||
# Coarse origin-vendor normalization for integrity-clash detection. Two signals
|
||||
# that resolve to the SAME key are consistent (a C2PA "Google (Gemini)" issuer
|
||||
# and Google SynthID provenance, or Adobe Firefly + its Adobe TrustMark soft
|
||||
@@ -709,6 +777,7 @@ _AI_VENDOR_TOKENS: tuple[tuple[str, str], ...] = (
|
||||
("google", "Google"),
|
||||
("firefly", "Adobe"),
|
||||
("adobe", "Adobe"),
|
||||
("copilot", "Microsoft"),
|
||||
("bing", "Microsoft"),
|
||||
("designer", "Microsoft"),
|
||||
("microsoft", "Microsoft"),
|
||||
@@ -735,6 +804,8 @@ _AI_VENDOR_TOKENS: tuple[tuple[str, str], ...] = (
|
||||
("fal-ai", "fal.ai"),
|
||||
("bria", "Bria"),
|
||||
("apple photos clean up", "Apple"),
|
||||
("luma ai", "Luma AI"),
|
||||
("lumalabs", "Luma AI"),
|
||||
)
|
||||
|
||||
|
||||
@@ -1074,10 +1145,18 @@ def _identify_from_evidence(
|
||||
|
||||
# ── C2PA Content Credentials ────────────────────────────────────
|
||||
has_c2pa = bool(info) or c2pa_marker_in(head)
|
||||
c2pa_validation = _c2pa_validation(info)
|
||||
c2pa_level = _c2pa_credential_level(info)
|
||||
c2pa_usable = c2pa_level != "invalid"
|
||||
failed_c2pa_codes = [
|
||||
str(code)
|
||||
for code in cast("list[object]", info.get("c2pa_validation_codes", []))
|
||||
if "mismatch" in str(code) or "invalid" in str(code)
|
||||
]
|
||||
issuers = [info["issuer"]] if info.get("issuer") else _issuers_in(region)
|
||||
# Full AI generation (trainedAlgorithmicMedia) vs an AI-enhanced real photo
|
||||
# (compositeWithTrainedAlgorithmicMedia). The structured kind is parsed once in
|
||||
# _internal.c2pa._populate_registry_fields (covers PNG + any container the c2pa-python
|
||||
# _internal.c2pa._structured_manifest_fields (covers PNG + any container the c2pa-python
|
||||
# reader handles); fall back to a raw head scan for the non-PNG raw-blob path
|
||||
# where extract_c2pa_info returns {}. Full generation wins when both appear.
|
||||
source_kind = _metadata_source_kind(info, head)
|
||||
@@ -1087,8 +1166,11 @@ def _identify_from_evidence(
|
||||
# Restricted to the ``asserts_ai`` vendors (distinctive brand strings), so it
|
||||
# does not reopen the incidental-mention problem the common-word issuers have.
|
||||
issuer_blob = " ".join(issuers)
|
||||
c2pa_identity_ai = has_c2pa and any(org in issuer_blob for org in C2PA_IDENTITY_AI_ORGS)
|
||||
c2pa_is_ai = source_kind is not None or c2pa_identity_ai
|
||||
c2pa_identity_ai = has_c2pa and (
|
||||
bool(info.get("c2pa_identity_ai")) or any(org in issuer_blob for org in C2PA_IDENTITY_AI_ORGS)
|
||||
)
|
||||
c2pa_claims_ai = source_kind is not None or c2pa_identity_ai
|
||||
c2pa_is_ai = c2pa_usable and c2pa_claims_ai
|
||||
# Generator string (for the signal detail): structured for PNG, CBOR-scanned
|
||||
# for other containers. Best-effort -- some manifests key it as
|
||||
# `claim_generator_info` (Pixel), so this can be None even when a device is
|
||||
@@ -1103,16 +1185,46 @@ def _identify_from_evidence(
|
||||
# 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 or signer_label or _attribute_platform(issuers, is_ai=c2pa_is_ai)) if has_c2pa else None
|
||||
platform = (
|
||||
(
|
||||
camera_label
|
||||
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)
|
||||
)
|
||||
if has_c2pa and c2pa_usable
|
||||
else None
|
||||
)
|
||||
if has_c2pa:
|
||||
detail = ", ".join(filter(None, [", ".join(issuers), generator, info.get("source_type")]))
|
||||
signals.append(Signal("c2pa", detail or "C2PA manifest present", "high"))
|
||||
watermarks.append(f"C2PA Content Credentials ({', '.join(issuers) or 'unknown signer'})")
|
||||
if c2pa_level == "invalid":
|
||||
suffix = f"; {', '.join(failed_c2pa_codes)}" if failed_c2pa_codes else ""
|
||||
signals.append(Signal("c2pa", f"C2PA manifest present, credential integrity invalid{suffix}", "medium"))
|
||||
watermarks.append("C2PA Content Credentials (invalid asset binding or signature)")
|
||||
caveats.append(_C2PA_INVALID_CAVEAT)
|
||||
else:
|
||||
signals.append(
|
||||
Signal("c2pa", detail or "C2PA manifest present", "high" if c2pa_level == "verified" else "medium")
|
||||
)
|
||||
watermarks.append(f"C2PA Content Credentials ({', '.join(issuers) or 'unknown signer'})")
|
||||
if c2pa_level == "unverified":
|
||||
caveats.append(
|
||||
_C2PA_UNTRUSTED_CAVEAT
|
||||
if info.get("c2pa_integrity") == "valid" and info.get("c2pa_signature") == "valid"
|
||||
else _C2PA_UNVALIDATED_CAVEAT
|
||||
)
|
||||
# Record the AI-origin vendor for clash detection only when the source is
|
||||
# actually AI -- classify the issuer attribution / generator, NOT the
|
||||
# resolved `platform` (which may be a camera device token whose label,
|
||||
# e.g. "Google Pixel", would mis-normalize to an AI vendor).
|
||||
if c2pa_is_ai and (v := (_vendor_of(_attribute_platform(issuers, is_ai=True)) or _vendor_of(generator))):
|
||||
if c2pa_is_ai and (
|
||||
v := (
|
||||
_vendor_of(_attribute_platform(issuers, is_ai=True))
|
||||
or _vendor_of(generator)
|
||||
or _vendor_of(str(info.get("ai_tool", "")))
|
||||
)
|
||||
):
|
||||
ai_vendor_claims["c2pa"] = v
|
||||
|
||||
# ── C2PA cloud-manifest reference (Durable Content Credentials) ─
|
||||
@@ -1150,9 +1262,13 @@ def _identify_from_evidence(
|
||||
if not synthid and trained_source and c2pa_marker_in(head) and (vendors := synthid_evidence_vendors_in(region)):
|
||||
synthid = synthid_verdict(", ".join(vendors))
|
||||
if synthid:
|
||||
watermarks.append(f"SynthID watermark ({synthid})")
|
||||
watermarks.append(
|
||||
f"SynthID watermark ({synthid})"
|
||||
if c2pa_usable
|
||||
else f"SynthID watermark claimed by invalid C2PA credentials ({synthid})"
|
||||
)
|
||||
caveats.append(_SYNTHID_CAVEAT)
|
||||
if v := _vendor_of(synthid):
|
||||
if c2pa_usable and (v := _vendor_of(synthid)):
|
||||
ai_vendor_claims["synthid"] = v
|
||||
|
||||
# ── C2PA soft-binding: a named forensic/third-party watermark vendor ─
|
||||
@@ -1160,12 +1276,36 @@ def _identify_from_evidence(
|
||||
# the watermark itself can't be decoded; names whose watermark stamped the pixels.
|
||||
soft_binding = meta.get("soft_binding") or (", ".join(v) if (v := soft_binding_vendors_in(region)) else None)
|
||||
if soft_binding:
|
||||
signals.append(Signal("soft_binding", f"C2PA soft binding: {soft_binding}", "high"))
|
||||
watermarks.append(f"Forensic watermark soft binding ({soft_binding})")
|
||||
soft_binding_algorithm = meta.get("soft_binding_algorithm") or info.get("soft_binding_algorithm")
|
||||
soft_binding_value = meta.get("soft_binding_value") or info.get("soft_binding_value")
|
||||
soft_binding_details = "; ".join(
|
||||
str(value) for value in (soft_binding, soft_binding_algorithm, soft_binding_value) if value
|
||||
)
|
||||
signals.append(
|
||||
Signal(
|
||||
"soft_binding",
|
||||
f"C2PA soft binding: {soft_binding_details}",
|
||||
"high" if c2pa_level == "verified" else "medium",
|
||||
)
|
||||
)
|
||||
if c2pa_info_has_invismark(info):
|
||||
# Keep the generic soft-binding row for schema-1 compatibility, and add
|
||||
# the pixel-removal target as its own stable signal for clients that need
|
||||
# to select diffusion without parsing human-readable detail.
|
||||
signals.append(
|
||||
Signal(
|
||||
"invismark",
|
||||
f"Microsoft InvisMark pixel watermark: {soft_binding_details}",
|
||||
"high" if c2pa_level == "verified" else "medium",
|
||||
)
|
||||
)
|
||||
watermarks.append(f"Forensic watermark soft binding ({soft_binding_details})")
|
||||
caveats.append(_SOFT_BINDING_CAVEAT)
|
||||
|
||||
# ── IPTC "Made with AI" (Meta etc.), only meaningful without C2PA ─
|
||||
iptc = any(m in head for m in IPTC_AI_MARKERS)
|
||||
if iptc and not has_c2pa:
|
||||
standalone_iptc = iptc and not has_c2pa
|
||||
if standalone_iptc:
|
||||
signals.append(Signal("iptc", "digitalSourceType (Made with AI)", "high"))
|
||||
watermarks.append("IPTC digitalSourceType (Made with AI)")
|
||||
caveats.append(_IPTC_ONLY_CAVEAT)
|
||||
@@ -1291,8 +1431,18 @@ def _identify_from_evidence(
|
||||
exif_gen = any(s.name == "exif_generator" for s in signals)
|
||||
xai_sig = any(s.name == "xai_signature" for s in signals)
|
||||
ai_from_metadata = bool(
|
||||
(has_c2pa and (c2pa_is_ai or synthid))
|
||||
or iptc
|
||||
(has_c2pa and c2pa_usable and (c2pa_is_ai or synthid))
|
||||
or standalone_iptc
|
||||
or iptc_ai
|
||||
or aigc
|
||||
or local_keys
|
||||
or invisible_wm
|
||||
or exif_gen
|
||||
or xai_sig
|
||||
)
|
||||
high_ai_from_metadata = bool(
|
||||
(has_c2pa and c2pa_level == "verified" and (c2pa_is_ai or synthid))
|
||||
or standalone_iptc
|
||||
or iptc_ai
|
||||
or aigc
|
||||
or local_keys
|
||||
@@ -1310,7 +1460,7 @@ def _identify_from_evidence(
|
||||
|
||||
if ai_from_metadata:
|
||||
is_ai: bool | None = True
|
||||
confidence = "high"
|
||||
confidence = "high" if high_ai_from_metadata else "medium"
|
||||
elif visible_only or hf_only or samsung_only:
|
||||
is_ai = True
|
||||
confidence = "medium"
|
||||
@@ -1319,7 +1469,17 @@ def _identify_from_evidence(
|
||||
confidence = "none"
|
||||
|
||||
# ── Integrity clashes: contradictions between independent signals ─
|
||||
clashes = _integrity_clashes(ai_vendor_claims, camera_label, camera_has_ai_marker=bool(ai_vendor_claims))
|
||||
clashes = _integrity_clashes(
|
||||
ai_vendor_claims,
|
||||
camera_label if c2pa_usable else None,
|
||||
camera_has_ai_marker=bool(ai_vendor_claims),
|
||||
)
|
||||
if c2pa_level == "invalid":
|
||||
clashes.insert(
|
||||
0,
|
||||
"C2PA credentials failed integrity validation"
|
||||
+ (f": {', '.join(failed_c2pa_codes)}" if failed_c2pa_codes else "."),
|
||||
)
|
||||
|
||||
caveats.append(_STRIP_CAVEAT)
|
||||
# De-duplicate while preserving order.
|
||||
@@ -1332,12 +1492,13 @@ def _identify_from_evidence(
|
||||
confidence=confidence,
|
||||
# Meaningful for the same digitalSourceType whether carried by C2PA or a
|
||||
# standalone IPTC/XMP label. Other AI signals leave it None.
|
||||
ai_source_kind=source_kind if (is_ai and (has_c2pa or iptc)) else None,
|
||||
ai_source_kind=(source_kind if (is_ai and ((has_c2pa and c2pa_usable) or standalone_iptc)) else None),
|
||||
ai_from_metadata=ai_from_metadata,
|
||||
watermarks=watermarks,
|
||||
signals=signals,
|
||||
caveats=caveats,
|
||||
integrity_clashes=clashes,
|
||||
c2pa_validation=c2pa_validation,
|
||||
)
|
||||
|
||||
|
||||
@@ -1412,13 +1573,16 @@ def has_invisible_target(image_path: Path) -> bool:
|
||||
"""True when a locally-detectable invisible/metadata AI signal is present.
|
||||
|
||||
The decision gate for the diffusion scrub (``invisible`` / ``all`` / ``batch``):
|
||||
regenerating pixels removes an invisible watermark (SynthID, open DWT-DCT,
|
||||
TrustMark) but degrades a real photo, so it must not run when there is nothing
|
||||
to remove. Runs :func:`identify` with ``check_visible=False`` -- a visible mark
|
||||
is handled by the separate visible pass and is NOT a diffusion target -- and
|
||||
``check_invisible=True`` so an open watermark counts. Returns
|
||||
``report.ai_from_metadata`` (C2PA AI issuer / SynthID provenance, IPTC, AIGC, local
|
||||
gen params, EXIF/xAI, open DWT-DCT / TrustMark).
|
||||
regenerating pixels removes an AI-specific invisible watermark (SynthID,
|
||||
open DWT-DCT) but degrades a real photo, so it must not run when there is
|
||||
nothing to remove. It runs the same evidence pipeline as :func:`identify`
|
||||
with visible checks disabled and invisible checks enabled, so a visible mark
|
||||
is handled by the separate visible pass and is NOT a diffusion target. Returns
|
||||
True for ``report.ai_from_metadata`` (C2PA AI issuer / SynthID provenance,
|
||||
IPTC, AIGC, local gen params, EXIF/xAI, or open DWT-DCT), and also when an
|
||||
invalid C2PA claim retains an AI or watermark removal hint. TrustMark alone
|
||||
does not trigger the scrub because it also protects human-authored work and
|
||||
therefore is not an AI signal by itself.
|
||||
|
||||
IMPORTANT -- this cannot prove a pixel SynthID is absent: SynthID is detectable
|
||||
only through its C2PA proxy, so a metadata-stripped AI image reads as no signal
|
||||
@@ -1429,8 +1593,17 @@ def has_invisible_target(image_path: Path) -> bool:
|
||||
watermark on a paid removal is worse than over-regenerating a clean image.
|
||||
"""
|
||||
try:
|
||||
report = identify(image_path, check_visible=False, check_invisible=True)
|
||||
evidence = extract_provenance_evidence(image_path)
|
||||
report = _identify_from_evidence(
|
||||
evidence,
|
||||
image_path=image_path,
|
||||
check_visible=False,
|
||||
check_invisible=True,
|
||||
)
|
||||
except Exception: # unreadable / detector error -> do not skip the removal
|
||||
logger.debug("has_invisible_target: identify failed, defaulting to run", exc_info=True)
|
||||
return True
|
||||
return report.ai_from_metadata
|
||||
# An asset edit can invalidate the C2PA binding while leaving the declared pixel
|
||||
# watermark intact. Keep the removal gate fail-safe without promoting that broken
|
||||
# claim back into the provenance verdict.
|
||||
return report.ai_from_metadata or c2pa_info_has_removal_hint(evidence.c2pa_info)
|
||||
@@ -18,6 +18,7 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from ._internal.watermark_profiles import (
|
||||
DEFAULT_PROFILE,
|
||||
QWEN_ZIMAGE_PROFILE,
|
||||
REMOVAL_MODULES,
|
||||
resolve_adaptive_polish,
|
||||
resolve_seed,
|
||||
@@ -148,6 +149,8 @@ class InvisibleEngine:
|
||||
tile: bool = False,
|
||||
tile_size: int = 1024,
|
||||
tile_overlap: int = 128,
|
||||
text_manifest: Path | None = None,
|
||||
fidelity_anchor: bool = False,
|
||||
) -> Path:
|
||||
"""Remove invisible watermark from an image.
|
||||
|
||||
@@ -180,6 +183,20 @@ class InvisibleEngine:
|
||||
Engages only when the long side exceeds ``tile_size``.
|
||||
tile_size: Tile dimension in px (default 1024).
|
||||
tile_overlap: Overlap between adjacent tiles in px (default 128).
|
||||
text_manifest: Operator-verified text lines bound to the decoded source
|
||||
pixels. Enables the experimental Qwen-VAE ``vae-glyphs`` post-pass.
|
||||
Requires the ``text-restoration`` extra and the ``qwen-zimage``
|
||||
profile. Incompatible with downscaling, humanize, unsharp, and
|
||||
adaptive polish. Tiling is supported: the VAE donor uses the same
|
||||
overlapping tiles as the global pass, then glyph restore runs on
|
||||
the blended full frame.
|
||||
fidelity_anchor: Blend 15% of the Qwen-VAE donor across the whole frame
|
||||
before glyph restoration. OFF by default since 0.27.1: that global
|
||||
blend was measured to return detector-visible OpenAI SynthID on
|
||||
poster-scale manifests (detected x6 with the anchor vs clean x6
|
||||
without it, base clean; official Content Provenance API,
|
||||
2026-08-19 - docs/text-protection-research.md). ``True`` reproduces
|
||||
the 0.27.0 research behavior. Requires ``text_manifest``.
|
||||
|
||||
Returns:
|
||||
Path to the cleaned image.
|
||||
@@ -189,6 +206,23 @@ class InvisibleEngine:
|
||||
seed = resolve_seed(seed)
|
||||
adaptive_polish = resolve_adaptive_polish(adaptive_polish, self._remover.model_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 != QWEN_ZIMAGE_PROFILE:
|
||||
raise ValueError("--text-manifest is supported only by the qwen-zimage profile")
|
||||
if max_resolution != 0:
|
||||
raise ValueError("--text-manifest requires --max-resolution 0")
|
||||
if humanize > 0.0 or unsharp > 0.0 or adaptive_polish:
|
||||
raise ValueError("--text-manifest requires humanize=0, unsharp=0, and adaptive polish disabled")
|
||||
from remove_ai_watermarks import region_eraser
|
||||
|
||||
if not region_eraser.lama_available():
|
||||
raise RuntimeError(
|
||||
"Verified text restoration requires LaMa. Install: "
|
||||
"pip install 'remove-ai-watermarks[text-restoration]'"
|
||||
)
|
||||
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
# Resolution policy: a max_resolution cap (0 = none) bounds memory on huge
|
||||
@@ -205,6 +239,11 @@ class InvisibleEngine:
|
||||
# Full-res original, kept for the adaptive-polish detail target (image is
|
||||
# reassigned to the resized copy below; PIL resize returns a new object).
|
||||
reference_pil = image
|
||||
verified_text = None
|
||||
if text_manifest is not None:
|
||||
from remove_ai_watermarks._internal.text_restoration import load_verified_text_manifest
|
||||
|
||||
verified_text = load_verified_text_manifest(text_manifest, reference_pil)
|
||||
|
||||
# Both profiles run at the input's native geometry, so only the explicit max
|
||||
# cap can move it, and it can only ever scale down.
|
||||
@@ -240,6 +279,8 @@ class InvisibleEngine:
|
||||
tile=tile,
|
||||
tile_size=tile_size,
|
||||
tile_overlap=tile_overlap,
|
||||
text_manifest=verified_text,
|
||||
fidelity_anchor=fidelity_anchor,
|
||||
)
|
||||
|
||||
# Post-processing chain: decode the diffusion output ONCE, apply the
|
||||
|
||||
@@ -238,7 +238,8 @@ def _is_ai_value(value: str) -> bool:
|
||||
from remove_ai_watermarks._internal.constants import AI_GENERATOR_TOKENS
|
||||
|
||||
value_lower = value.lower()
|
||||
return any(token in value_lower for token in AI_GENERATOR_TOKENS)
|
||||
provenance, generator = _app_metadata_evidence(value)
|
||||
return provenance is not None or generator is not None or any(token in value_lower for token in AI_GENERATOR_TOKENS)
|
||||
|
||||
|
||||
def _png_late_metadata(image_path: Path, window: int) -> bytes:
|
||||
@@ -477,6 +478,9 @@ def has_ai_metadata(image_path: Path) -> bool:
|
||||
for key in img.info:
|
||||
if isinstance(key, str) and _is_ai_key(key):
|
||||
return True
|
||||
exif_bytes = img.info.get("exif")
|
||||
if exif_bytes and any(_app_metadata_evidence(exif_bytes)):
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.debug("PIL could not open %s for metadata scan: %s", image_path, exc)
|
||||
|
||||
@@ -500,6 +504,8 @@ def has_ai_metadata(image_path: Path) -> bool:
|
||||
# IPTC 2025.1 AI-disclosure XMP properties (their presence flags AI content).
|
||||
if any(marker in data for marker in IPTC_AI_FIELD_MARKERS):
|
||||
return True
|
||||
if any(_app_metadata_evidence(data)):
|
||||
return True
|
||||
# China TC260 AIGC label as a PNG text chunk (the byte scan above catches
|
||||
# only the XMP form; the raw-JSON tEXt chunk needs the PIL-based parse).
|
||||
if aigc_label(image_path) is not None:
|
||||
@@ -806,10 +812,19 @@ def synthid_source(image_path: Path) -> str | None:
|
||||
Returns:
|
||||
Comma-joined vendor name(s) (e.g. ``"OpenAI"``) or None.
|
||||
"""
|
||||
from remove_ai_watermarks._internal.c2pa import extract_c2pa_info, synthid_evidence_vendors_in
|
||||
from remove_ai_watermarks._internal.c2pa import (
|
||||
c2pa_info_has_invalid_credential,
|
||||
extract_c2pa_info,
|
||||
synthid_evidence_vendors_in,
|
||||
)
|
||||
|
||||
# PNG: the caBX chunk parser gives a clean, structured issuer.
|
||||
vendors = extract_c2pa_info(image_path).get("synthid_vendors")
|
||||
# Prefer the official reader's structured result. A failed asset binding or
|
||||
# signature cannot establish the claim, and the raw fallback below must not
|
||||
# resurrect it from the same damaged manifest bytes.
|
||||
c2pa = extract_c2pa_info(image_path)
|
||||
if c2pa_info_has_invalid_credential(c2pa):
|
||||
return None
|
||||
vendors = c2pa.get("synthid_vendors")
|
||||
if vendors:
|
||||
return ", ".join(vendors)
|
||||
|
||||
@@ -830,16 +845,79 @@ def generator_from_metadata(candidates: Iterable[str], scan: bytes = b"") -> str
|
||||
"""Return a known AI generator from collected EXIF, PNG, or XMP values."""
|
||||
from remove_ai_watermarks._internal.constants import AI_GENERATOR_TOKENS
|
||||
|
||||
if app_generator := app_generator_from_metadata(scan):
|
||||
return app_generator
|
||||
|
||||
creator_tools = (
|
||||
match.group(1).decode("latin1", "replace")
|
||||
for match in re.finditer(rb"CreatorTool[>\"'=\s]{1,4}([^<\"']{1,80})", scan)
|
||||
)
|
||||
for value in itertools.chain(candidates, creator_tools):
|
||||
if app_generator := app_generator_from_metadata(value):
|
||||
return app_generator
|
||||
if any(token in value.lower() for token in AI_GENERATOR_TOKENS):
|
||||
return value.strip()
|
||||
return None
|
||||
|
||||
|
||||
_APP_PROVENANCE_PRODUCTS: dict[str, str] = {
|
||||
"doubao": "ByteDance Doubao",
|
||||
"xinghui": "ByteDance Xinghui",
|
||||
"dreamina": "ByteDance Dreamina",
|
||||
"dreamina_oversea": "ByteDance Dreamina",
|
||||
}
|
||||
_APP_PRODUCT_RE = re.compile(r'"product"\s*:\s*"([a-z0-9_]+)"', re.IGNORECASE)
|
||||
_APP_EXPORT_TYPE_RE = re.compile(r'"exportType"\s*:\s*"([a-z0-9_]+)"', re.IGNORECASE)
|
||||
_APP_AIGC_LABEL_RE = re.compile(r'"aigc_label_type"\s*:\s*[12](?=\s*[,}])', re.IGNORECASE)
|
||||
_APP_AIGC_TYPE_RE = re.compile(r'"aigc_type"\s*:\s*1(?=\s*[,}])', re.IGNORECASE)
|
||||
|
||||
|
||||
def _normalized_app_metadata(value: str | bytes) -> str:
|
||||
text = value.decode("latin-1", "ignore") if isinstance(value, bytes) else value
|
||||
return text.replace('\\"', '"')
|
||||
|
||||
|
||||
def _app_metadata_evidence(value: str | bytes) -> tuple[str | None, str | None]:
|
||||
"""Return removable product provenance and stronger AI-origin evidence."""
|
||||
normalized = _normalized_app_metadata(value)
|
||||
products = tuple(match.group(1).lower() for match in _APP_PRODUCT_RE.finditer(normalized))
|
||||
provenance = next(
|
||||
(_APP_PROVENANCE_PRODUCTS[product] for product in products if product in _APP_PROVENANCE_PRODUCTS), None
|
||||
)
|
||||
export_types = {match.group(1).lower() for match in _APP_EXPORT_TYPE_RE.finditer(normalized)}
|
||||
|
||||
generator = None
|
||||
if set(products).intersection({"dreamina", "dreamina_oversea"}) and "generation" in export_types:
|
||||
generator = "ByteDance Dreamina"
|
||||
elif '"aigc_info"' in normalized.lower():
|
||||
if "aweme" in products and _APP_AIGC_TYPE_RE.search(normalized):
|
||||
generator = "ByteDance Aweme AI"
|
||||
elif _APP_AIGC_LABEL_RE.search(normalized):
|
||||
generator = "Embedded app AIGC disclosure"
|
||||
return provenance, generator
|
||||
|
||||
|
||||
def app_provenance_from_metadata(value: str | bytes) -> str | None:
|
||||
"""Return exact AI-product provenance that is safe to scrub.
|
||||
|
||||
An exporting product does not by itself prove that the pixels were generated.
|
||||
That stronger interpretation stays in :func:`app_generator_from_metadata`.
|
||||
"""
|
||||
return _app_metadata_evidence(value)[0]
|
||||
|
||||
|
||||
def app_generator_from_metadata(value: str | bytes) -> str | None:
|
||||
"""Identify exact app-export AI disclosures embedded in JSON-shaped metadata.
|
||||
|
||||
ByteDance-family apps write a second provenance object beside C2PA or TC260.
|
||||
Its keys are ordinary EXIF fields, so generic key matching misses it and a
|
||||
metadata-preserving JPEG scrub keeps it. Only explicit AIGC discriminators or
|
||||
a Dreamina generation export assert AI origin. Product provenance alone remains
|
||||
removable without changing the image's origin verdict.
|
||||
"""
|
||||
return _app_metadata_evidence(value)[1]
|
||||
|
||||
|
||||
def exif_generator(image_path: Path) -> str | None:
|
||||
"""Return an AI-generator name from the EXIF ``Software`` / XMP ``CreatorTool``
|
||||
field (or a PNG text chunk), if it matches a known generator (see
|
||||
@@ -870,7 +948,9 @@ def exif_generator(image_path: Path) -> str | None:
|
||||
if isinstance(value, str) and value:
|
||||
candidates.append(value)
|
||||
if exif_bytes:
|
||||
tags = piexif.load(exif_bytes).get("0th", {})
|
||||
loaded = piexif.load(exif_bytes)
|
||||
tags = loaded.get("0th", {})
|
||||
exif_tags: dict[int, Any] = loaded.get("Exif") or {}
|
||||
# Make catches camera-style tags AI tools reuse (Ideogram writes
|
||||
# Make="Ideogram AI"); real cameras put "Apple"/"Canon" there, which
|
||||
# carry no AI token, so this stays low-false-positive.
|
||||
@@ -883,6 +963,9 @@ def exif_generator(image_path: Path) -> str | None:
|
||||
value = tags.get(tag)
|
||||
if isinstance(value, bytes):
|
||||
candidates.append(value.decode("latin1", "replace"))
|
||||
user_comment = exif_tags.get(piexif.ExifIFD.UserComment)
|
||||
if isinstance(user_comment, bytes):
|
||||
candidates.append(user_comment.decode("latin1", "replace"))
|
||||
except Exception as exc: # unopenable format / malformed EXIF
|
||||
logger.debug("EXIF generator read failed for %s: %s", image_path, exc)
|
||||
|
||||
@@ -1009,6 +1092,15 @@ def _ai_exif_targets(loaded: dict[str, Any]) -> list[tuple[str, int, bytes, str]
|
||||
add("0th", ifd0, piexif.ImageIFD.ImageDescription, "ImageDescription")
|
||||
if _is_aigc_exif_value(ifde.get(piexif.ExifIFD.UserComment)):
|
||||
add("Exif", ifde, piexif.ExifIFD.UserComment, "UserComment")
|
||||
# (d) ByteDance-family app JSON. Exact AI-product provenance is removable even
|
||||
# when it does not by itself assert generated pixels. Ordinary Aweme/retouch/lv
|
||||
# exports remain untouched.
|
||||
for ifd_key, ifd, tag, name in (
|
||||
("0th", ifd0, piexif.ImageIFD.ImageDescription, "ImageDescription"),
|
||||
("Exif", ifde, piexif.ExifIFD.UserComment, "UserComment"),
|
||||
):
|
||||
if any(_app_metadata_evidence(ifd.get(tag, b""))):
|
||||
add(ifd_key, ifd, tag, name)
|
||||
|
||||
return targets
|
||||
|
||||
@@ -1068,6 +1160,14 @@ def get_ai_metadata(image_path: Path) -> dict[str, str]:
|
||||
"actions",
|
||||
"synthid_watermark",
|
||||
"soft_binding",
|
||||
"soft_binding_algorithm",
|
||||
"soft_binding_value",
|
||||
"c2pa_validation_source",
|
||||
"c2pa_validation_state",
|
||||
"c2pa_integrity",
|
||||
"c2pa_signature",
|
||||
"c2pa_signer_trust",
|
||||
"c2pa_signer_validity",
|
||||
):
|
||||
if key in c2pa:
|
||||
result.setdefault(key, str(c2pa[key]))
|
||||
@@ -1086,6 +1186,18 @@ def get_ai_metadata(image_path: Path) -> dict[str, str]:
|
||||
if (aigc := aigc_label(image_path)) is not None:
|
||||
producer = aigc.get("ContentProducer", "")
|
||||
result["aigc_label"] = f"China AIGC label (TC260){f'; producer {producer}' if producer else ''}"
|
||||
# The structural producer beside its display rendering: a machine
|
||||
# consumer (video-visible provenance) must not parse the formatted
|
||||
# sentence above, whose wording can change without notice.
|
||||
if producer:
|
||||
result["aigc_producer"] = producer
|
||||
|
||||
app_scan = scan_head(image_path)
|
||||
app_provenance, app_generator = _app_metadata_evidence(app_scan)
|
||||
if app_provenance:
|
||||
result.setdefault("app_provenance", f"App export provenance ({app_provenance})")
|
||||
if app_generator:
|
||||
result.setdefault("app_aigc", f"App AIGC disclosure ({app_generator})")
|
||||
|
||||
# xAI / Grok EXIF signature scheme (its only provenance signal).
|
||||
if xai_signature(image_path):
|
||||
@@ -1181,7 +1293,7 @@ def _jpeg_app_carries_ai(marker: int, payload: bytes) -> bool:
|
||||
# (detection<->removal parity). Skip APP1-EXIF (0xE1 ``Exif``): its camera tags are
|
||||
# scrubbed tag-by-tag via piexif, not dropped wholesale.
|
||||
if not (marker == 0xE1 and payload.startswith(b"Exif")):
|
||||
return _is_aigc_exif_value(payload)
|
||||
return _is_aigc_exif_value(payload) or any(_app_metadata_evidence(payload))
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -302,12 +302,14 @@ def _exif_pairs(info: dict[str, Any]) -> dict[str, str]:
|
||||
try:
|
||||
import piexif
|
||||
|
||||
tags = piexif.load(exif_bytes).get("0th", {})
|
||||
loaded = piexif.load(exif_bytes)
|
||||
tags = loaded.get("0th", {})
|
||||
exif_tags = loaded.get("Exif", {})
|
||||
except Exception as exc: # malformed EXIF
|
||||
logger.debug("EXIF parse failed: %s", exc)
|
||||
return {}
|
||||
|
||||
return {
|
||||
pairs = {
|
||||
name: text
|
||||
for name, tag in (
|
||||
("Software", piexif.ImageIFD.Software),
|
||||
@@ -317,6 +319,9 @@ def _exif_pairs(info: dict[str, Any]) -> dict[str, str]:
|
||||
)
|
||||
if (text := exif_text(tags, tag))
|
||||
}
|
||||
if text := exif_text(exif_tags, piexif.ExifIFD.UserComment):
|
||||
pairs["UserComment"] = text
|
||||
return pairs
|
||||
|
||||
|
||||
def _pil_info(info: dict[str, Any]) -> dict[str, str]:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Qwen (Tongyi Qianwen, Alibaba) visible watermark detector/localizer.
|
||||
"""Qwen (Alibaba) visible watermark detector/localizer.
|
||||
|
||||
Qwen stamps its generations with a visible "千问AI生成" text strip in the
|
||||
bottom-right corner -- the explicit AIGC label mandated by China's GB 45438-2025
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
"""Draft operator-verifiable text lines for verified-text manifests.
|
||||
|
||||
Proposal-only OCR: PaddleOCR detection plus three script-chosen recognition
|
||||
engines (Latin / Cyrillic / CJK), accepted only when three crop paddings
|
||||
normalize identically and every confidence clears the floor. ``accepted``
|
||||
means crop-stable, NEVER ground-truth-correct: on the reference posters the
|
||||
draft's exact-text precision was 90.0% and 94.4% because high-confidence OCR
|
||||
still lost punctuation (one English comma dropped, one ideographic comma
|
||||
replaced with ASCII). Every accepted line needs a human yes/no before it may
|
||||
enter a manifest with ``verified: true``.
|
||||
|
||||
Heavy imports (paddle, and the numpy/cv2/PIL pixel stack) stay inside the
|
||||
call so importing this module costs nothing without the ``text-draft`` extra;
|
||||
``draft_available()`` reports whether the extra is installed.
|
||||
"""
|
||||
|
||||
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownParameterType=false, reportMissingTypeArgument=false, reportMissingTypeStubs=false, reportMissingImports=false, reportArgumentType=false, reportAssignmentType=false, reportReturnType=false, reportCallIssue=false, reportIndexIssue=false, reportOperatorIssue=false
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unicodedata
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from remove_ai_watermarks._internal.text_restoration import source_pixel_sha256
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
__all__ = [
|
||||
"DraftLine",
|
||||
"RejectedLine",
|
||||
"TextDraft",
|
||||
"choose_language",
|
||||
"draft_available",
|
||||
"draft_text_lines",
|
||||
"group_word_boxes",
|
||||
"source_pixel_sha256",
|
||||
"stable_recognition",
|
||||
]
|
||||
|
||||
# Crop paddings probed per line: recognition must be invariant across them.
|
||||
JITTER_RATIOS: tuple[float, ...] = (0.08, 0.12, 0.2)
|
||||
DETECT_SCORE_FLOOR = 0.5
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DraftLine:
|
||||
"""One crop-stable proposal. ``accepted`` means stable, not correct."""
|
||||
|
||||
box: tuple[int, int, int, int]
|
||||
text: str
|
||||
script: str
|
||||
language: str
|
||||
min_score: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RejectedLine:
|
||||
"""A detected line whose recognition was unstable or low-confidence."""
|
||||
|
||||
box: tuple[int, int, int, int]
|
||||
script: str
|
||||
language: str
|
||||
reads: tuple[tuple[str, float], ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TextDraft:
|
||||
"""The full proposal set for one image."""
|
||||
|
||||
accepted: tuple[DraftLine, ...] = ()
|
||||
rejected: tuple[RejectedLine, ...] = ()
|
||||
|
||||
|
||||
def normalize_text(text: str) -> str:
|
||||
"""Normalize text for layout-independent comparison (casefold + no spaces)."""
|
||||
return "".join(unicodedata.normalize("NFC", text).casefold().split())
|
||||
|
||||
|
||||
def _has_script(text: str, script: str) -> bool:
|
||||
return any(script in unicodedata.name(character, "") for character in text)
|
||||
|
||||
|
||||
def choose_language(probes: dict[str, tuple[str, float]]) -> str:
|
||||
"""Pick ``ch``/``ru``/``en`` from what each probe engine actually read."""
|
||||
if _has_script(probes["ch"][0], "CJK"):
|
||||
return "ch"
|
||||
if _has_script(probes["ru"][0], "CYRILLIC"):
|
||||
return "ru"
|
||||
return "en"
|
||||
|
||||
|
||||
def stable_recognition(reads: list[tuple[str, float]], min_score: float = 0.85) -> str | None:
|
||||
"""The proposal text when every read normalizes identically and clears the floor."""
|
||||
normalized = {normalize_text(text) for text, _score in reads}
|
||||
if len(normalized) != 1 or min(score for _text, score in reads) < min_score:
|
||||
return None
|
||||
return reads[0][0]
|
||||
|
||||
|
||||
def _vertical_overlap_ratio(left: tuple[int, int, int, int], right: tuple[int, int, int, int]) -> float:
|
||||
overlap = max(0, min(left[3], right[3]) - max(left[1], right[1]))
|
||||
return overlap / max(1, min(left[3] - left[1], right[3] - right[1]))
|
||||
|
||||
|
||||
def _row_center(box: tuple[int, int, int, int]) -> float:
|
||||
return (box[1] + box[3]) / 2
|
||||
|
||||
|
||||
def group_word_boxes(boxes: list[tuple[int, int, int, int]]) -> list[tuple[int, int, int, int]]:
|
||||
"""Merge word detections into line boxes by vertical overlap and gap."""
|
||||
groups: list[tuple[int, int, int, int]] = []
|
||||
for box in sorted(boxes, key=lambda item: (_row_center(item), item[0])):
|
||||
matches: list[int] = []
|
||||
for index, group in enumerate(groups):
|
||||
if _vertical_overlap_ratio(box, group) < 0.45:
|
||||
continue
|
||||
horizontal_gap = max(0, max(box[0], group[0]) - min(box[2], group[2]))
|
||||
line_height = min(box[3] - box[1], group[3] - group[1])
|
||||
if horizontal_gap <= max(24, line_height * 3):
|
||||
matches.append(index)
|
||||
if not matches:
|
||||
groups.append(box)
|
||||
continue
|
||||
index = max(matches, key=lambda item: _vertical_overlap_ratio(box, groups[item]))
|
||||
x1, y1, x2, y2 = groups[index]
|
||||
groups[index] = min(x1, box[0]), min(y1, box[1]), max(x2, box[2]), max(y2, box[3])
|
||||
return sorted(groups, key=_row_center)
|
||||
|
||||
|
||||
def _recognition_box(
|
||||
box: tuple[int, int, int, int],
|
||||
script: str,
|
||||
width: int,
|
||||
height: int,
|
||||
vertical_pad_ratio: float | None = None,
|
||||
) -> tuple[int, int, int, int]:
|
||||
x1, y1, x2, y2 = box
|
||||
line_height = y2 - y1
|
||||
if script == "cjk":
|
||||
left_pad = max(16, round(line_height * 0.2))
|
||||
right_pad = max(16, round(line_height * 0.6))
|
||||
return max(0, x1 - left_pad), y1, min(width, x2 + right_pad), y2
|
||||
pad_x = max(16, line_height)
|
||||
pad_y = max(8, line_height // 3) if vertical_pad_ratio is None else max(8, round(line_height * vertical_pad_ratio))
|
||||
return max(0, x1 - pad_x), max(0, y1 - pad_y), min(width, x2 + pad_x), min(height, y2 + pad_y)
|
||||
|
||||
|
||||
def _recognize(
|
||||
engine: Any,
|
||||
image: Any,
|
||||
box: tuple[int, int, int, int],
|
||||
script: str,
|
||||
vertical_pad_ratio: float | None = None,
|
||||
) -> tuple[str, float]:
|
||||
import cv2
|
||||
|
||||
height, width = image.shape[:2]
|
||||
x1, y1, x2, y2 = _recognition_box(box, script, width, height, vertical_pad_ratio)
|
||||
crop = image[y1:y2, x1:x2]
|
||||
if crop.shape[0] < 64:
|
||||
scale = 64 / crop.shape[0]
|
||||
crop = cv2.resize(crop, None, fx=scale, fy=scale, interpolation=cv2.INTER_CUBIC)
|
||||
result = next(iter(engine.predict(crop)))
|
||||
return str(result.get("rec_text", "")), float(result.get("rec_score", 0.0))
|
||||
|
||||
|
||||
def _detect_line_boxes(engine: Any, source_rgb: Any) -> list[tuple[int, int, int, int]]:
|
||||
import numpy as np
|
||||
|
||||
boxes: list[tuple[int, int, int, int]] = []
|
||||
for page in engine.predict(source_rgb):
|
||||
detected = page.get("rec_boxes", None)
|
||||
if detected is None or len(detected) == 0:
|
||||
detected = page.get("rec_polys", [])
|
||||
for score, raw_box in zip(page.get("rec_scores", []), detected, strict=False):
|
||||
if float(score) < DETECT_SCORE_FLOOR:
|
||||
continue
|
||||
points = np.asarray(raw_box, dtype=np.float32).reshape(-1)
|
||||
if points.size == 4:
|
||||
x1, y1, x2, y2 = points
|
||||
else:
|
||||
points = points.reshape(-1, 2)
|
||||
x1, y1 = points.min(axis=0)
|
||||
x2, y2 = points.max(axis=0)
|
||||
boxes.append((round(float(x1)), round(float(y1)), round(float(x2)), round(float(y2))))
|
||||
return group_word_boxes(boxes)
|
||||
|
||||
|
||||
def draft_available() -> bool:
|
||||
"""True when the ``text-draft`` extra (paddleocr + paddle) can run."""
|
||||
from remove_ai_watermarks.optional_deps import module_available
|
||||
|
||||
return module_available("paddleocr") and module_available("paddle")
|
||||
|
||||
|
||||
def _build_engines() -> tuple[Any, dict[str, Any]]:
|
||||
from paddleocr import PaddleOCR, TextRecognition
|
||||
|
||||
# enable_mkldnn=False is load-bearing on linux: paddle's oneDNN PIR executor
|
||||
# raises NotImplementedError on the text-detection graph there (seen on the
|
||||
# Modal draft container, 2026-08-19), while the reference CPU path runs. The
|
||||
# eval scripts never hit it because macOS pads take a different path.
|
||||
detector = PaddleOCR(
|
||||
lang="ch",
|
||||
use_doc_orientation_classify=False,
|
||||
use_doc_unwarping=False,
|
||||
use_textline_orientation=False,
|
||||
enable_mkldnn=False,
|
||||
)
|
||||
engines = {
|
||||
"en": TextRecognition(model_name="en_PP-OCRv5_mobile_rec", enable_mkldnn=False),
|
||||
"ru": TextRecognition(model_name="eslav_PP-OCRv5_mobile_rec", enable_mkldnn=False),
|
||||
"ch": TextRecognition(model_name="PP-OCRv5_server_rec", enable_mkldnn=False),
|
||||
}
|
||||
return detector, engines
|
||||
|
||||
|
||||
def draft_text_lines(
|
||||
image: Path,
|
||||
*,
|
||||
min_score: float = 0.85,
|
||||
detector: Any | None = None,
|
||||
engines: dict[str, Any] | None = None,
|
||||
stable: bool = True,
|
||||
) -> TextDraft:
|
||||
"""Propose verified-text manifest lines for ``image``; never verified ones.
|
||||
|
||||
Args:
|
||||
image: path of the source image (draft boxes are in ITS pixel space).
|
||||
min_score: recognition confidence floor. With ``stable=True`` every
|
||||
jittered read must clear it; with ``stable=False`` the single
|
||||
probe for the chosen language must.
|
||||
detector/engines: injectable Paddle objects (tests use fakes); when
|
||||
None they are built from the ``text-draft`` extra, which must be
|
||||
installed (``draft_available()`` reports it).
|
||||
stable: when True (default), accept a line only if three crop paddings
|
||||
normalize identically. When False, take one recognition trio and
|
||||
accept on score alone. Automatic restoration uses box/script only,
|
||||
so the jitter gate is optional there.
|
||||
|
||||
Returns:
|
||||
``TextDraft`` with crop-stable ``accepted`` proposals and unstable
|
||||
``rejected`` lines. Both lists need human review before any manifest
|
||||
may claim ``verified: true``; accepted means crop-stable, NOT
|
||||
ground-truth-correct.
|
||||
"""
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
if detector is None or engines is None:
|
||||
if not draft_available():
|
||||
raise RuntimeError(
|
||||
"Text drafting requires PaddleOCR. Install: pip install 'remove-ai-watermarks[text-draft]'"
|
||||
)
|
||||
os.environ.setdefault("PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK", "True")
|
||||
detector, engines = _build_engines()
|
||||
|
||||
source_rgb = np.asarray(Image.open(image).convert("RGB"))
|
||||
accepted: list[DraftLine] = []
|
||||
rejected: list[RejectedLine] = []
|
||||
for box in _detect_line_boxes(detector, source_rgb):
|
||||
probes: dict[str, tuple[str, float]] = {}
|
||||
for language, engine in engines.items():
|
||||
script = "cjk" if language == "ch" else "alphabetic"
|
||||
probes[language] = _recognize(engine, source_rgb, box, script, 0.1)
|
||||
language = choose_language(probes)
|
||||
script = "cjk" if language == "ch" else "alphabetic"
|
||||
if stable:
|
||||
reads = [_recognize(engines[language], source_rgb, box, script, ratio) for ratio in JITTER_RATIOS]
|
||||
text = stable_recognition(reads, min_score)
|
||||
else:
|
||||
text, score = probes[language]
|
||||
reads = [(text, score)]
|
||||
if not text.strip() or score < min_score:
|
||||
text = None
|
||||
if text is None:
|
||||
rejected.append(
|
||||
RejectedLine(
|
||||
box=box,
|
||||
script=script,
|
||||
language=language,
|
||||
reads=tuple((value, score) for value, score in reads),
|
||||
)
|
||||
)
|
||||
else:
|
||||
accepted.append(
|
||||
DraftLine(
|
||||
box=box,
|
||||
text=text,
|
||||
script=script,
|
||||
language=language,
|
||||
min_score=min(score for _value, score in reads),
|
||||
)
|
||||
)
|
||||
return TextDraft(accepted=tuple(accepted), rejected=tuple(rejected))
|
||||
@@ -32,6 +32,9 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# Adobe ships Variant P in production (com.adobe.trustmark.P).
|
||||
_MODEL_TYPE = "P"
|
||||
# Schema 3 (BCH_3) is below the detector's measured precision threshold; the
|
||||
# calibration history is canonical in docs/module-internals.md.
|
||||
_SUPPORTED_SCHEMAS = frozenset({0, 1, 2})
|
||||
# Lazily constructed singleton -- model load + first-use download is expensive.
|
||||
# Guarded by a lock so concurrent callers don't double-construct/double-download.
|
||||
_tm: Any = None
|
||||
@@ -57,8 +60,9 @@ def _decoder() -> Any:
|
||||
|
||||
|
||||
# JPEG quality for the false-positive durability gate (see detect_trustmark).
|
||||
# Deliberately mild: a genuine TrustMark survives far harsher, while every
|
||||
# observed false positive collapsed even at this quality.
|
||||
# Deliberately mild: a genuine TrustMark survives far harsher. The round-trip
|
||||
# still needs payload and schema validation because content-correlated false
|
||||
# positives can survive this compression level.
|
||||
_REENCODE_QUALITY = 95
|
||||
|
||||
|
||||
@@ -79,8 +83,8 @@ def detect_trustmark(image_path: Path) -> str | None:
|
||||
cannot carry Adobe's watermark, and decoded a random-bytes secret). A genuine
|
||||
TrustMark is a *durable* soft binding engineered to survive re-encoding (that
|
||||
is its entire purpose once C2PA is stripped), so we re-decode after a mild
|
||||
JPEG round-trip and require the same schema both times. Every observed false
|
||||
positive collapsed under this gate.
|
||||
JPEG round-trip and require the same binary payload and schema both times.
|
||||
Only the calibrated schemas 0-2 count as high-precision positives.
|
||||
"""
|
||||
if not is_available():
|
||||
return None
|
||||
@@ -90,10 +94,17 @@ def detect_trustmark(image_path: Path) -> str | None:
|
||||
with Image.open(image_path) as img:
|
||||
cover = img.convert("RGB")
|
||||
decoder = _decoder()
|
||||
_wm_secret, wm_present, wm_schema = decoder.decode(cover)
|
||||
wm_secret, wm_present, wm_schema = decoder.decode(cover, "binary")
|
||||
if not wm_present:
|
||||
return None
|
||||
if not _survives_reencode(decoder, cover, wm_schema):
|
||||
if wm_schema not in _SUPPORTED_SCHEMAS:
|
||||
logger.debug(
|
||||
"TrustMark decode for %s used weak schema %s; treating as false positive",
|
||||
image_path,
|
||||
wm_schema,
|
||||
)
|
||||
return None
|
||||
if not _survives_reencode(decoder, cover, wm_secret, wm_schema):
|
||||
logger.debug("TrustMark decode for %s did not survive re-encode; treating as false positive", image_path)
|
||||
return None
|
||||
except Exception as exc: # model download / decode failure / unreadable image
|
||||
@@ -102,10 +113,8 @@ def detect_trustmark(image_path: Path) -> str | None:
|
||||
return f"Adobe TrustMark (variant {_MODEL_TYPE}, schema {wm_schema})"
|
||||
|
||||
|
||||
def _survives_reencode(decoder: Any, cover: Any, schema: int) -> bool:
|
||||
"""True if the watermark re-decodes with the same schema after a mild JPEG
|
||||
round-trip -- the durability a genuine TrustMark guarantees, which a BCH
|
||||
false positive (content noise) does not."""
|
||||
def _survives_reencode(decoder: Any, cover: Any, payload: str, schema: int) -> bool:
|
||||
"""True if the same watermark re-decodes after a mild JPEG round-trip."""
|
||||
import io
|
||||
|
||||
from PIL import Image
|
||||
@@ -114,5 +123,5 @@ def _survives_reencode(decoder: Any, cover: Any, schema: int) -> bool:
|
||||
cover.save(buffer, "JPEG", quality=_REENCODE_QUALITY)
|
||||
buffer.seek(0)
|
||||
with Image.open(buffer) as reencoded:
|
||||
_secret, present, reencoded_schema = decoder.decode(reencoded.convert("RGB"))
|
||||
return bool(present) and reencoded_schema == schema
|
||||
reencoded_payload, present, reencoded_schema = decoder.decode(reencoded.convert("RGB"), "binary")
|
||||
return bool(present) and reencoded_schema == schema and reencoded_payload == payload
|
||||
@@ -239,6 +239,7 @@ def _visible_removal_plan(
|
||||
from remove_ai_watermarks.video_visible import (
|
||||
VISIBLE_MARK_POLICIES,
|
||||
has_bytedance_video_provenance,
|
||||
has_hailuo_video_provenance,
|
||||
has_sora_provenance,
|
||||
has_veo_provenance,
|
||||
stabilize_localizations,
|
||||
@@ -249,6 +250,7 @@ def _visible_removal_plan(
|
||||
"veo": has_veo_provenance,
|
||||
"seedance": has_bytedance_video_provenance,
|
||||
"dola": has_bytedance_video_provenance,
|
||||
"hailuo": has_hailuo_video_provenance,
|
||||
}.get(selected_mark)
|
||||
policy = VISIBLE_MARK_POLICIES[selected_mark]
|
||||
regions = stabilize_localizations(
|
||||
|
||||
@@ -890,11 +890,14 @@ class VisibleMarkPolicy:
|
||||
Every value here is MEASURED per provider; the arbiter itself
|
||||
(:func:`_stabilize_localizations`) is shared and knows nothing about providers.
|
||||
|
||||
``accepts_provenance`` is load-bearing rather than cosmetic. Hailuo and Kling have
|
||||
no metadata that could confirm them, so their rows force ``provenance=False``; that
|
||||
used to be guaranteed structurally by wrappers that took no ``provenance``
|
||||
parameter at all, and this flag is what preserves the guarantee now that one entry
|
||||
point serves every mark.
|
||||
``accepts_provenance`` is load-bearing rather than cosmetic. A vendor with no
|
||||
metadata that could confirm it forces ``provenance=False``; that used to be
|
||||
guaranteed structurally by wrappers that took no ``provenance`` parameter at
|
||||
all, and this flag is what preserves the guarantee now that one entry point
|
||||
serves every mark. Kling keeps the flag off (its TC260 producer code is image
|
||||
evidence; no kling video corpus row ties a producer to the moving mark), while
|
||||
Hailuo accepts it through the MiniMax TC260 label
|
||||
(:func:`has_hailuo_video_provenance`).
|
||||
|
||||
``padding_fraction`` and ``mask_style`` belong to the removal plan rather than the
|
||||
arbiter, but they are per-provider constants like the rest, so they live on the same
|
||||
@@ -969,7 +972,9 @@ VISIBLE_MARK_POLICIES: dict[str, VisibleMarkPolicy] = {
|
||||
anchor_iou=0.80,
|
||||
padding_fraction=0.12,
|
||||
mask_style="box",
|
||||
accepts_provenance=False,
|
||||
# No provenance_weak_floor: the measured weak floor stays the entry bar;
|
||||
# a MiniMax TC260 label only drops the strong-frame requirement for an
|
||||
# already-stable run (see has_hailuo_video_provenance).
|
||||
),
|
||||
"kling": VisibleMarkPolicy(
|
||||
weak_floor=_KLING_WEAK_CONFIDENCE,
|
||||
@@ -1458,3 +1463,15 @@ def has_bytedance_video_provenance(markers: dict[str, str]) -> bool:
|
||||
).lower()
|
||||
source_type = markers.get("source_type", "").lower()
|
||||
return ("bytedance" in identity or "byteplus" in identity) and "trainedalgorithmicmedia" in source_type
|
||||
|
||||
|
||||
def has_hailuo_video_provenance(markers: dict[str, str]) -> bool:
|
||||
"""Whether a TC260 label names MiniMax, Hailuo's maker, as the producer.
|
||||
|
||||
Hailuo video exports carry no C2PA; their TC260 ``ContentProducer`` is the
|
||||
bare name ``MiniMax`` (verified on retained MiniMax-hailuo clips). The
|
||||
producer travels as its own structural marker (``aigc_producer``, set in
|
||||
``metadata.get_ai_metadata``), so this matches the field exactly rather
|
||||
than parsing the human-readable ``aigc_label`` sentence.
|
||||
"""
|
||||
return markers.get("aigc_producer", "").strip().lower() == "minimax"
|
||||
@@ -20,7 +20,7 @@ Entries:
|
||||
- ``gemini`` -- Google Gemini / Nano Banana sparkle, bottom-right.
|
||||
- ``doubao`` -- ByteDance Doubao "豆包AI生成" text strip, bottom-right.
|
||||
- ``jimeng`` -- ByteDance Jimeng / Dreamina "★ 即梦AI" wordmark, bottom-right.
|
||||
- ``qwen`` -- Alibaba Tongyi Qianwen "千问AI生成" text strip, bottom-right.
|
||||
- ``qwen`` -- Alibaba Qwen "千问AI生成" text strip, bottom-right.
|
||||
- ``kling`` -- Kuaishou Kling "可灵AI 3.0" text strip, bottom-right.
|
||||
- ``yuanbao`` -- Tencent Yuanbao "元宝 / AI生成" two-line mark, bottom-right.
|
||||
- ``samsung`` -- Samsung Galaxy AI "Contenuti generati dall'AI" strip, bottom-left.
|
||||
@@ -651,7 +651,7 @@ _REGISTRY: tuple[KnownMark, ...] = (
|
||||
"qwen",
|
||||
"Qwen 千问AI生成 text",
|
||||
"bottom-right",
|
||||
platform="Alibaba Tongyi Qianwen (visible 千问AI生成 mark detected)",
|
||||
platform="Alibaba Qwen (visible 千问AI生成 mark detected)",
|
||||
tc260_producer_codes=("91440101MA9Y9T4H7A",),
|
||||
),
|
||||
_text_mark(
|
||||
|
||||
+22
-4
@@ -2,7 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
import struct
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
@@ -10,9 +12,6 @@ import pytest
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clean_photo(tmp_path: Path) -> Path:
|
||||
@@ -76,3 +75,22 @@ def tmp_clean_png(tmp_path: Path) -> Path:
|
||||
path = tmp_path / "clean.png"
|
||||
img.save(path, pnginfo=pnginfo)
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tampered_chatgpt_png(tmp_path: Path) -> Path:
|
||||
"""Add valid PNG metadata after signing so the C2PA asset hash no longer matches."""
|
||||
source = Path(__file__).resolve().parents[1] / "data" / "fixtures" / "provenance" / "chatgpt-1.png"
|
||||
data = source.read_bytes()
|
||||
iend = data.rfind(b"\x00\x00\x00\x00IEND")
|
||||
assert iend >= 0
|
||||
|
||||
kind = b"tEXt"
|
||||
payload = b"c2pa-test\x00benign post-signing metadata mutation"
|
||||
chunk = (
|
||||
struct.pack(">I", len(payload)) + kind + payload + struct.pack(">I", zlib.crc32(kind + payload) & 0xFFFFFFFF)
|
||||
)
|
||||
target = tmp_path / "tampered-chatgpt.png"
|
||||
target.write_bytes(data[:iend] + chunk + data[iend:])
|
||||
assert target.read_bytes() != data
|
||||
return target
|
||||
@@ -245,6 +245,7 @@ class TestInvisibleOptionsMirrorTheEngine:
|
||||
tile=True,
|
||||
tile_size=768,
|
||||
tile_overlap=64,
|
||||
text_manifest=tmp_path / "verified-lines.json",
|
||||
)
|
||||
seen: dict[str, object] = {}
|
||||
|
||||
@@ -511,6 +512,15 @@ class TestSourceEvidenceHolder:
|
||||
assert holder.visible_provenance() == api.visible_provenance(DOUBAO)
|
||||
assert holder.has_invisible_target() == identify.has_invisible_target(DOUBAO)
|
||||
|
||||
def test_holder_preserves_invalid_c2pa_removal_hint(self, tampered_chatgpt_png):
|
||||
from remove_ai_watermarks import api, identify
|
||||
|
||||
holder = api._SourceEvidence(tampered_chatgpt_png)
|
||||
|
||||
assert identify.identify(tampered_chatgpt_png, check_visible=False).ai_from_metadata is False
|
||||
assert holder.has_invisible_target() is True
|
||||
assert holder.has_invisible_target() == identify.has_invisible_target(tampered_chatgpt_png)
|
||||
|
||||
def test_extraction_failure_fails_safe_in_both_directions(self, monkeypatch, tmp_path):
|
||||
"""No provenance means no relaxation; an unknown invisible target means SCRUB.
|
||||
Leaving a watermark on a paid removal is worse than over-regenerating."""
|
||||
|
||||
@@ -629,11 +629,13 @@ class TestMetadataCommand:
|
||||
result = runner.invoke(main, ["metadata", str(tmp_clean_png), "--check"])
|
||||
assert result.exit_code == 0
|
||||
assert "No AI metadata" in result.output
|
||||
assert "not the same as 'clean'" in result.output
|
||||
|
||||
def test_metadata_check_ai(self, runner, tmp_png_with_ai_metadata):
|
||||
result = runner.invoke(main, ["metadata", str(tmp_png_with_ai_metadata), "--check"])
|
||||
assert result.exit_code == 0
|
||||
assert "AI metadata detected" in result.output
|
||||
assert "not the same as 'clean'" not in result.output
|
||||
|
||||
def test_metadata_remove(self, runner, tmp_png_with_ai_metadata, tmp_path):
|
||||
output = tmp_path / "stripped.png"
|
||||
@@ -649,6 +651,7 @@ class TestMetadataCommand:
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "stripped" in result.output
|
||||
assert "not the same as 'clean'" in result.output
|
||||
|
||||
def test_metadata_remove_reports_failure_when_the_strip_was_a_no_op(self, runner, tmp_path):
|
||||
"""A file PIL cannot decode is copied through UNCHANGED by the fail-safe.
|
||||
@@ -723,6 +726,8 @@ class TestIdentifyCommand:
|
||||
result = runner.invoke(main, ["identify", str(sample), "--no-visible"])
|
||||
assert result.exit_code == 0
|
||||
assert "AI-generated (fully synthetic)" in result.output
|
||||
assert "C2PA validation: integrity=valid, signature=valid" in result.output
|
||||
assert "signer trust=untrusted, signer validity=expired" in result.output
|
||||
|
||||
def test_identify_json_is_valid(self, runner, tmp_png_with_ai_metadata):
|
||||
result = runner.invoke(main, ["identify", str(tmp_png_with_ai_metadata), "--no-visible", "--json"])
|
||||
|
||||
@@ -20,3 +20,13 @@ def test_sdist_has_explicit_public_boundary() -> None:
|
||||
|
||||
assert _array_values(sdist_config, "include") == {"/src", "/LICENSE", "/README.md", "/pyproject.toml"}
|
||||
assert {"/data", "/tmp", "/.sc"} <= _array_values(sdist_config, "exclude")
|
||||
|
||||
|
||||
def test_release_guide_names_every_published_surface() -> None:
|
||||
"""A release checklist must not silently forget a downstream surface."""
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
guide = (root / "docs" / "release-and-distribution.md").read_text(encoding="utf-8")
|
||||
|
||||
for surface in ("PyPI", "Homebrew", "Hugging Face Space", "ComfyUI Registry"):
|
||||
assert surface in guide
|
||||
assert re.search(r"Conda is\s+not a supported publishing surface", guide)
|
||||
@@ -20,7 +20,7 @@ import pytest
|
||||
_SCRIPTS = Path(__file__).resolve().parent.parent / "scripts"
|
||||
|
||||
|
||||
def _load_assign():
|
||||
def _load_module():
|
||||
# fidelity_metrics is a standalone PEP723 script, not an installed module; load it by
|
||||
# path with scripts/ on sys.path so its `_plain_console` shim import resolves.
|
||||
sys.path.insert(0, str(_SCRIPTS))
|
||||
@@ -35,7 +35,15 @@ def _load_assign():
|
||||
pytest.skip(f"fidelity_metrics import deps missing: {exc}")
|
||||
finally:
|
||||
sys.path.remove(str(_SCRIPTS))
|
||||
return mod.assign_faces_one_to_one
|
||||
return mod
|
||||
|
||||
|
||||
def _load_assign():
|
||||
return _load_module().assign_faces_one_to_one
|
||||
|
||||
|
||||
def test_cer_remains_case_sensitive() -> None:
|
||||
assert _load_module()._cer("A", "a") == 1.0
|
||||
|
||||
|
||||
def test_distinct_faces_match_nearest() -> None:
|
||||
|
||||
+186
-7
@@ -16,6 +16,7 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from remove_ai_watermarks._internal.constants import C2PA_AI_VENDORS, C2PA_CLAIM_GENERATOR_PLATFORMS
|
||||
from remove_ai_watermarks.identify import (
|
||||
ProvenanceEvidence,
|
||||
ProvenanceReport,
|
||||
@@ -39,6 +40,79 @@ SAMPLES_DIR = Path(__file__).resolve().parent.parent / "data" / "fixtures" / "pr
|
||||
|
||||
|
||||
class TestProvenanceEvidence:
|
||||
def test_exact_ai_claim_generator_can_assert_ai_without_source_type(self, tmp_path: Path):
|
||||
path = tmp_path / "firefly.png"
|
||||
info = {
|
||||
"has_c2pa": True,
|
||||
"issuer": "Adobe",
|
||||
"claim_generator": "Adobe_Firefly",
|
||||
"ai_tool": "Firefly",
|
||||
"c2pa_identity_ai": True,
|
||||
"c2pa_validation_source": "reader",
|
||||
"c2pa_validation_state": "Valid",
|
||||
"c2pa_integrity": "valid",
|
||||
"c2pa_signature": "valid",
|
||||
"c2pa_signer_trust": "untrusted",
|
||||
"c2pa_signer_validity": "valid",
|
||||
"c2pa_validation_codes": ["assertion.dataHash.match", "claimSignature.validated"],
|
||||
}
|
||||
evidence = ProvenanceEvidence(
|
||||
path=path,
|
||||
c2pa_info=info,
|
||||
ai_metadata={},
|
||||
scan=b"jumb c2pa Adobe_Firefly",
|
||||
iptc_ai_system=None,
|
||||
aigc_label=None,
|
||||
exif_generator=None,
|
||||
xai_signature=False,
|
||||
huggingface_job=None,
|
||||
samsung_genai=None,
|
||||
)
|
||||
|
||||
report = identify_from_evidence(evidence)
|
||||
|
||||
assert report.is_ai_generated is True
|
||||
assert report.confidence == "medium"
|
||||
assert report.platform == "Adobe Firefly"
|
||||
|
||||
def test_fully_validated_c2pa_claim_is_high_confidence(self, tmp_path: Path):
|
||||
path = tmp_path / "validated.png"
|
||||
info = {
|
||||
"has_c2pa": True,
|
||||
"issuer": "OpenAI",
|
||||
"source_type": "trainedAlgorithmicMedia (AI-generated)",
|
||||
"ai_source_kind": "generated",
|
||||
"c2pa_validation_source": "reader",
|
||||
"c2pa_validation_state": "Valid",
|
||||
"c2pa_integrity": "valid",
|
||||
"c2pa_signature": "valid",
|
||||
"c2pa_signer_trust": "trusted",
|
||||
"c2pa_signer_validity": "valid",
|
||||
"c2pa_validation_codes": [
|
||||
"assertion.dataHash.match",
|
||||
"claimSignature.validated",
|
||||
"signingCredential.trusted",
|
||||
],
|
||||
}
|
||||
evidence = ProvenanceEvidence(
|
||||
path=path,
|
||||
c2pa_info=info,
|
||||
ai_metadata={},
|
||||
scan=b"jumb c2pa OpenAI trainedAlgorithmicMedia",
|
||||
iptc_ai_system=None,
|
||||
aigc_label=None,
|
||||
exif_generator=None,
|
||||
xai_signature=False,
|
||||
huggingface_job=None,
|
||||
samsung_genai=None,
|
||||
)
|
||||
|
||||
report = identify_from_evidence(evidence)
|
||||
|
||||
assert report.is_ai_generated is True
|
||||
assert report.confidence == "high"
|
||||
assert report.platform == "OpenAI (ChatGPT / gpt-image / DALL-E / Sora)"
|
||||
|
||||
def test_external_metadata_record_builds_equivalent_evidence(self, tmp_path: Path):
|
||||
path = tmp_path / "external.jpg"
|
||||
signature = "A" * 64
|
||||
@@ -313,6 +387,38 @@ class TestIdentifyNonPng:
|
||||
assert r.is_ai_generated is True
|
||||
assert "ByteDance" in (r.platform or "")
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("claim_generator", "platform"),
|
||||
[
|
||||
("Higgsfield AI", "Higgsfield AI"),
|
||||
("Topaz Labs Image API", "Topaz Labs"),
|
||||
("TIKTOK AD Creative Toolbox", "TikTok Ad Creative Toolbox"),
|
||||
],
|
||||
)
|
||||
def test_claim_generator_wins_over_upstream_issuer(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, 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": "OpenAI",
|
||||
"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
|
||||
assert claim_generator in next(s.detail for s in report.signals if s.name == "c2pa")
|
||||
|
||||
def test_dreamina_attributed_without_source_type(self, tmp_path: Path):
|
||||
# Dreamina (ByteDance's international Jimeng brand) signs C2PA as
|
||||
# "Bytedance Pte. Ltd." with a "Dreamina/x.y" claim generator and NO
|
||||
@@ -396,12 +502,14 @@ class TestIdentifySamsungGalaxy:
|
||||
path.write_bytes(b"\xff\xd8\xff\xe1jumbc2pa" + blob + b"\xff\xd9")
|
||||
return path
|
||||
|
||||
def test_galaxy_trained_source_is_high_ai(self, tmp_path: 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")
|
||||
r = identify(path, check_visible=False, check_invisible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.confidence == "high"
|
||||
assert r.confidence == "medium"
|
||||
assert r.platform == "Samsung Galaxy (C2PA)"
|
||||
assert r.c2pa_validation is None
|
||||
assert any("without cryptographic validation" in caveat for caveat in r.caveats)
|
||||
assert r.integrity_clashes == [] # device cert + AI source-type is legitimate, not a clash
|
||||
|
||||
def test_galaxy_genai_only_is_medium_ai(self, tmp_path: Path):
|
||||
@@ -445,7 +553,7 @@ class TestIdentifyRealSamples:
|
||||
def test_openai_chatgpt(self):
|
||||
r = identify(SAMPLES_DIR / "chatgpt-1.png", check_visible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.confidence == "high"
|
||||
assert r.confidence == "medium"
|
||||
assert r.platform
|
||||
assert "OpenAI" in r.platform
|
||||
assert any("C2PA" in w for w in r.watermarks)
|
||||
@@ -514,9 +622,39 @@ class TestIdentifyRealSamples:
|
||||
# both invisible/metadata targets, so the diffusion scrub should run.
|
||||
assert has_invisible_target(SAMPLES_DIR / "chatgpt-1.png") is True
|
||||
assert has_invisible_target(SAMPLES_DIR / "mj-1.png") is True
|
||||
# ai_from_metadata mirrors confidence == "high" and backs the helper.
|
||||
# ai_from_metadata records scrub intent even when an untrusted signer
|
||||
# makes the provenance verdict medium-confidence.
|
||||
assert identify(SAMPLES_DIR / "chatgpt-1.png", check_visible=False).ai_from_metadata is True
|
||||
|
||||
def test_untrusted_but_intact_c2pa_is_medium_confidence(self):
|
||||
report = identify(SAMPLES_DIR / "chatgpt-1.png", check_visible=False, check_invisible=False)
|
||||
|
||||
assert report.is_ai_generated is True
|
||||
assert report.confidence == "medium"
|
||||
assert report.ai_from_metadata is True
|
||||
assert report.c2pa_validation is not None
|
||||
assert report.c2pa_validation["source"] == "reader"
|
||||
assert report.c2pa_validation["state"] == "Invalid"
|
||||
assert report.c2pa_validation["integrity"] == "valid"
|
||||
assert report.c2pa_validation["signature"] == "valid"
|
||||
assert report.c2pa_validation["signer_trust"] == "untrusted"
|
||||
assert report.c2pa_validation["signer_validity"] == "expired"
|
||||
assert "assertion.dataHash.match" in report.c2pa_validation["codes"]
|
||||
assert any("not anchored" in caveat for caveat in report.caveats)
|
||||
|
||||
def test_hash_mismatch_does_not_confirm_origin_but_keeps_scrub_fail_safe(self, tampered_chatgpt_png: Path):
|
||||
report = identify(tampered_chatgpt_png, check_visible=False, check_invisible=False)
|
||||
|
||||
assert report.is_ai_generated is None
|
||||
assert report.platform is None
|
||||
assert report.confidence == "none"
|
||||
assert report.ai_source_kind is None
|
||||
assert report.ai_from_metadata is False
|
||||
assert report.c2pa_validation is not None
|
||||
assert report.c2pa_validation["integrity"] == "invalid"
|
||||
assert any("dataHash.mismatch" in clash for clash in report.integrity_clashes)
|
||||
assert has_invisible_target(tampered_chatgpt_png) is True
|
||||
|
||||
def test_has_invisible_target_false_on_clean_photo(self, clean_photo: Path):
|
||||
# No detectable invisible signal -> skip the scrub (do not degrade a clean image).
|
||||
assert has_invisible_target(clean_photo) is False
|
||||
@@ -534,13 +672,13 @@ class TestHasInvisibleTargetFailSafe:
|
||||
"""The scrub gate fails SAFE: when a detector errors, it runs the removal."""
|
||||
|
||||
def test_detector_error_defaults_to_run(self, tmp_path: Path):
|
||||
# If identify raises (a detector crash), the gate must return True so the
|
||||
# If evidence evaluation raises (a detector crash), the gate must return True so the
|
||||
# caller still attempts removal -- leaving a watermark on a paid removal is
|
||||
# worse than over-regenerating. (Garbage bytes do NOT raise; identify returns
|
||||
# a clean None verdict there, so that path correctly skips -- see below.)
|
||||
bad = tmp_path / "x.png"
|
||||
bad.write_bytes(b"not image bytes")
|
||||
with patch("remove_ai_watermarks.identify.identify", side_effect=RuntimeError("boom")):
|
||||
with patch("remove_ai_watermarks.identify._identify_from_evidence", side_effect=RuntimeError("boom")):
|
||||
assert has_invisible_target(bad) is True
|
||||
|
||||
def test_unreadable_bytes_are_not_a_target(self, tmp_path: Path):
|
||||
@@ -1006,6 +1144,39 @@ class TestIdentifySoftBinding:
|
||||
assert any("Digimarc" in w for w in r.watermarks)
|
||||
assert any(s.name == "soft_binding" for s in r.signals)
|
||||
|
||||
def test_invismark_signal_lists_signed_watermark_id(self, tmp_path: Path):
|
||||
watermark_id = "83424621-03cb-40e3-9808-a9fae837156d"
|
||||
record = {
|
||||
"c2pa_store": {
|
||||
"active_manifest": "paint",
|
||||
"manifests": {
|
||||
"paint": {
|
||||
"assertions": [
|
||||
{
|
||||
"label": "c2pa.soft-binding",
|
||||
"data": {
|
||||
"alg": "com.microsoft.invismark.1",
|
||||
"blocks": [{"scope": "the entire image", "value": watermark_id}],
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
evidence = evidence_from_metadata_record(record, path=tmp_path / "paint.png")
|
||||
|
||||
report = identify_from_evidence(evidence)
|
||||
|
||||
assert evidence.ai_metadata["soft_binding_value"] == watermark_id
|
||||
signal = next(signal for signal in report.signals if signal.name == "soft_binding")
|
||||
assert "com.microsoft.invismark.1" in signal.detail
|
||||
assert watermark_id in signal.detail
|
||||
invismark = next(signal for signal in report.signals if signal.name == "invismark")
|
||||
assert "com.microsoft.invismark.1" in invismark.detail
|
||||
assert watermark_id in invismark.detail
|
||||
assert any("may remain in the pixels" in caveat for caveat in report.caveats)
|
||||
|
||||
|
||||
class TestIdentifyIptcAi:
|
||||
"""IPTC 2025.1 AISystemUsed drives an AI verdict + platform attribution."""
|
||||
@@ -1163,12 +1334,20 @@ class TestVendorOf:
|
||||
def test_registered_vendors_normalize(self):
|
||||
# Regression: these registered C2PA vendors returned None, so their claims never
|
||||
# entered clash detection (a coverage hole). They now normalize to one origin.
|
||||
assert _vendor_of("ByteDance (Doubao / Jimeng / Volcano Engine)") == "ByteDance"
|
||||
assert _vendor_of("Microsoft (Copilot / Designer)") == "Microsoft"
|
||||
assert _vendor_of("Copilot") == "Microsoft"
|
||||
assert _vendor_of("ByteDance (Doubao / Jimeng / Dreamina / Volcano Engine)") == "ByteDance"
|
||||
assert _vendor_of("Dreamina/1.2") == "ByteDance"
|
||||
assert _vendor_of("Canva (Magic Media)") == "Canva"
|
||||
assert _vendor_of("Black Forest Labs (FLUX)") == "Black Forest Labs"
|
||||
assert _vendor_of("Eleven Labs Inc.") == "ElevenLabs"
|
||||
|
||||
def test_bytedance_issuers_share_one_platform(self):
|
||||
expected = "ByteDance (Doubao / Jimeng / Dreamina / Volcano Engine)"
|
||||
platforms = {vendor.platform for vendor in C2PA_AI_VENDORS if vendor.needle == "ByteDance"}
|
||||
assert platforms == {expected}
|
||||
assert ("dreamina", expected) in C2PA_CLAIM_GENERATOR_PLATFORMS
|
||||
|
||||
|
||||
class TestIntegrityClashesHelper:
|
||||
def test_two_ai_vendors_clash(self):
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = Path(__file__).parents[1] / "scripts/infer_text_lines.py"
|
||||
SPEC = importlib.util.spec_from_file_location("infer_text_lines", SCRIPT)
|
||||
assert SPEC is not None
|
||||
assert SPEC.loader is not None
|
||||
module = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[SPEC.name] = module
|
||||
SPEC.loader.exec_module(module)
|
||||
|
||||
|
||||
def test_choose_language_prefers_observed_unicode_script() -> None:
|
||||
probes = {"en": ("gibberish", 0.9), "ru": ("пример", 0.9), "ch": ("example", 0.9)}
|
||||
assert module.choose_language(probes) == "ru"
|
||||
|
||||
probes["ch"] = ("示例", 0.9)
|
||||
assert module.choose_language(probes) == "ch"
|
||||
|
||||
|
||||
def test_stable_recognition_requires_agreement_and_confidence() -> None:
|
||||
assert module.stable_recognition([("Sample text", 0.9), ("sample text", 0.95)]) == "Sample text"
|
||||
assert module.stable_recognition([("Sample", 0.9), ("Simple", 0.95)]) is None
|
||||
assert module.stable_recognition([("Sample", 0.8), ("Sample", 0.95)]) is None
|
||||
@@ -43,6 +43,96 @@ class TestInvisibleEngineInit:
|
||||
assert engine._preload_kwargs == {"global_only": True}
|
||||
|
||||
|
||||
class TestVerifiedTextMode:
|
||||
"""The experimental mode must fail before loading models on unmeasured inputs."""
|
||||
|
||||
@staticmethod
|
||||
def _engine(profile: str = "qwen-zimage") -> InvisibleEngine:
|
||||
engine = object.__new__(InvisibleEngine)
|
||||
engine._progress_callback = None
|
||||
engine._remover = SimpleNamespace(model_profile=profile)
|
||||
return engine
|
||||
|
||||
def test_rejects_incompatible_pipeline_options(self, tmp_path):
|
||||
import pytest
|
||||
|
||||
manifest = tmp_path / "manifest.json"
|
||||
manifest.write_text("{}", encoding="utf-8")
|
||||
cases = (
|
||||
("sdxl-zimage", {}, "qwen-zimage"),
|
||||
("qwen-zimage", {"max_resolution": 1024}, "max-resolution 0"),
|
||||
("qwen-zimage", {"humanize": 1.0}, "humanize=0"),
|
||||
("qwen-zimage", {"adaptive_polish": True}, "polish disabled"),
|
||||
)
|
||||
for profile, kwargs, message in cases:
|
||||
with pytest.raises(ValueError, match=message):
|
||||
self._engine(profile).remove_watermark(
|
||||
tmp_path / "unused.png",
|
||||
text_manifest=manifest,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def test_rejects_fidelity_anchor_without_manifest(self, tmp_path):
|
||||
import pytest
|
||||
|
||||
with pytest.raises(ValueError, match="fidelity_anchor requires a text manifest"):
|
||||
self._engine().remove_watermark(
|
||||
tmp_path / "unused.png",
|
||||
fidelity_anchor=True,
|
||||
)
|
||||
|
||||
def test_loads_and_forwards_verified_manifest(self, tmp_path, monkeypatch):
|
||||
import json
|
||||
|
||||
from remove_ai_watermarks import region_eraser
|
||||
from remove_ai_watermarks._internal.text_restoration import source_pixel_sha256
|
||||
|
||||
source = tmp_path / "source.png"
|
||||
output = tmp_path / "output.png"
|
||||
image = Image.new("RGB", (48, 32), (10, 20, 30))
|
||||
image.save(source)
|
||||
manifest = tmp_path / "manifest.json"
|
||||
manifest.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"verified": True,
|
||||
"source_pixel_sha256": source_pixel_sha256(image),
|
||||
"width": 48,
|
||||
"height": 32,
|
||||
"lines": [{"box": [8, 8, 40, 24], "text": "Exact", "script": "alphabetic"}],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
seen = {}
|
||||
|
||||
def fake_remove(**kwargs):
|
||||
seen.update(kwargs)
|
||||
Image.open(kwargs["image_path"]).save(kwargs["output_path"])
|
||||
return kwargs["output_path"]
|
||||
|
||||
engine = self._engine()
|
||||
engine._remover.remove_watermark = fake_remove
|
||||
monkeypatch.setattr(region_eraser, "lama_available", lambda: True)
|
||||
|
||||
engine.remove_watermark(source, output, text_manifest=manifest)
|
||||
|
||||
assert seen["text_manifest"].lines[0].text == "Exact"
|
||||
# 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).
|
||||
assert seen["fidelity_anchor"] is False
|
||||
|
||||
engine.remove_watermark(source, output, text_manifest=manifest, fidelity_anchor=True)
|
||||
|
||||
assert seen["fidelity_anchor"] is True
|
||||
|
||||
engine.remove_watermark(source, output, text_manifest=manifest, tile=True)
|
||||
|
||||
assert seen["tile"] is True
|
||||
|
||||
|
||||
class TestNativeOutputSize:
|
||||
"""Model-side latent-grid rounding must not change the public output size."""
|
||||
|
||||
|
||||
@@ -59,6 +59,47 @@ class TestHelpers:
|
||||
assert _bytes_match_frac(b"abc", b"abcd") == 0.0
|
||||
|
||||
|
||||
class TestRaveledHaarPass:
|
||||
"""The precondition that makes the decoder's flat Haar pass legitimate.
|
||||
|
||||
`_approximation` replaces `pywt.dwt(x, "haar", axis=1)[0]` with one
|
||||
`downcoef` call over `x.ravel()`. That is exact only while the last axis is
|
||||
even. Neither half of this is checked anywhere else: the equivalence is a
|
||||
property of pywt's implementation that an upgrade could take away, and an
|
||||
odd width produces wrong bits with no exception, since the reshape still
|
||||
succeeds whenever the total length is even.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize("shape", [(64, 64), (7, 128), (129, 2), (2, 2), (33, 400)])
|
||||
def test_matches_pywt_dwt_on_even_widths(self, shape: tuple[int, int]):
|
||||
import pywt
|
||||
|
||||
from remove_ai_watermarks.dwt_dct import _approximation
|
||||
|
||||
rng = np.random.default_rng(0)
|
||||
# uint8 is what the FIRST production pass receives -- `decode` builds its
|
||||
# plane with cvtColor, and extractChannel and transpose preserve the
|
||||
# dtype -- so a divergence in how downcoef coerces integers would be
|
||||
# invisible to a float-only parametrization.
|
||||
for array in (
|
||||
rng.random(shape),
|
||||
rng.integers(0, 256, shape).astype(np.float64),
|
||||
rng.integers(0, 256, shape).astype(np.uint8),
|
||||
):
|
||||
expected = pywt.dwt(array, "haar", axis=1)[0]
|
||||
got = _approximation(array)
|
||||
assert got.shape == expected.shape
|
||||
assert np.array_equal(got, expected), "downcoef diverged from dwt -- a pywt upgrade may have changed it"
|
||||
|
||||
def test_odd_width_raises_instead_of_returning_wrong_bits(self):
|
||||
from remove_ai_watermarks.dwt_dct import _approximation
|
||||
|
||||
# 4x6 ravels to 24, an even total, so the reshape would happily produce
|
||||
# a 4x3 array of numbers that pair across row boundaries.
|
||||
with pytest.raises(RuntimeError, match="odd"):
|
||||
_approximation(np.zeros((4, 6))[:, :5])
|
||||
|
||||
|
||||
class TestDetect:
|
||||
def test_in_tree_decoder_matches_upstream(self, tmp_path: Path):
|
||||
from imwatermark import WatermarkDecoder
|
||||
|
||||
@@ -12,6 +12,7 @@ import pytest
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
from remove_ai_watermarks.identify import identify
|
||||
from remove_ai_watermarks.metadata import (
|
||||
C2PA_UUID,
|
||||
_is_ai_key,
|
||||
@@ -499,6 +500,10 @@ class TestGetAiMetadataRealSample:
|
||||
assert "OpenAI" in meta["issuer"]
|
||||
assert "synthid_watermark" not in meta
|
||||
assert "trainedAlgorithmicMedia" in meta["source_type"]
|
||||
assert meta["c2pa_integrity"] == "valid"
|
||||
assert meta["c2pa_signature"] == "valid"
|
||||
assert meta["c2pa_signer_trust"] == "untrusted"
|
||||
assert meta["c2pa_signer_validity"] == "expired"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -818,6 +823,42 @@ class TestExifGenerator:
|
||||
path = _img_with_software(tmp_path, "jpg", "Forever Editor 2.0")
|
||||
assert exif_generator(path) is None
|
||||
|
||||
def test_luma_ai_png_text_chunks_detected(self, tmp_path: Path):
|
||||
# Luma AI stamps tEXt Software="Uni-1" (model name, not a token) plus
|
||||
# Source/Comment values carrying "Luma AI"; the Source value must match.
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
info = PngInfo()
|
||||
info.add_text("Software", "Uni-1")
|
||||
info.add_text("Source", "Luma AI")
|
||||
info.add_text("Comment", "Generated by Luma AI's Uni-1 model (https://lumalabs.ai)")
|
||||
path = tmp_path / "luma.png"
|
||||
Image.new("RGB", (64, 64)).save(path, pnginfo=info)
|
||||
assert exif_generator(path) == "Luma AI"
|
||||
|
||||
def test_luma_token_not_overmatched(self, tmp_path: Path):
|
||||
# The token is "luma ai" with the space: a bare "luma" (e.g. a luma
|
||||
# chart tool) must not fire.
|
||||
path = _img_with_software(tmp_path, "jpg", "Luma Chart Export 2.0")
|
||||
assert exif_generator(path) is None
|
||||
|
||||
def test_luma_removal_parity(self, tmp_path: Path):
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
from remove_ai_watermarks.metadata import remove_ai_metadata
|
||||
|
||||
info = PngInfo()
|
||||
info.add_text("Software", "Uni-1")
|
||||
info.add_text("Source", "Luma AI")
|
||||
info.add_text("Comment", "Generated by Luma AI's Uni-1 model (https://lumalabs.ai)")
|
||||
src = tmp_path / "luma.png"
|
||||
Image.new("RGB", (64, 64)).save(src, pnginfo=info)
|
||||
assert exif_generator(src) == "Luma AI"
|
||||
|
||||
out = tmp_path / "clean.png"
|
||||
remove_ai_metadata(src, out)
|
||||
assert exif_generator(out) is None
|
||||
|
||||
def test_aphrodite_make_detected(self, tmp_path: Path):
|
||||
# Aphrodite AI writes EXIF Make="Aphrodite AI".
|
||||
exif = piexif.dump({"0th": {piexif.ImageIFD.Make: b"Aphrodite AI"}, "Exif": {}, "GPS": {}, "1st": {}})
|
||||
@@ -951,6 +992,158 @@ class TestXaiSignature:
|
||||
class TestRemoveAiExif:
|
||||
"""remove_ai_metadata scrubs AI-provenance EXIF tags but keeps genuine EXIF."""
|
||||
|
||||
@staticmethod
|
||||
def _app_aigc_jpeg(tmp_path: Path, name: str, payload: dict) -> Path:
|
||||
import json
|
||||
|
||||
exif = piexif.dump(
|
||||
{
|
||||
"0th": {
|
||||
piexif.ImageIFD.ImageDescription: json.dumps(payload, separators=(",", ":")).encode(),
|
||||
piexif.ImageIFD.Make: b"Canon",
|
||||
},
|
||||
"Exif": {},
|
||||
"GPS": {},
|
||||
"1st": {},
|
||||
}
|
||||
)
|
||||
path = tmp_path / name
|
||||
Image.new("RGB", (64, 64)).save(path, exif=exif)
|
||||
return path
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("payload", "generator"),
|
||||
[
|
||||
(
|
||||
{
|
||||
"data": {
|
||||
"product": "dreamina_oversea",
|
||||
"source_type": "dreamina_oversea",
|
||||
"exportType": "generation",
|
||||
}
|
||||
},
|
||||
"Dreamina",
|
||||
),
|
||||
(
|
||||
{
|
||||
"data": {
|
||||
"product": "aweme",
|
||||
"aigc_info": {
|
||||
"aigc_type": 1,
|
||||
"is_sticker_aigc": 0,
|
||||
},
|
||||
}
|
||||
},
|
||||
"Aweme",
|
||||
),
|
||||
({"data": {"aigc_info": {"aigc_label_type": 1}}}, "AIGC disclosure"),
|
||||
({"data": {"aigc_info": {"aigc_label_type": 2}}}, "AIGC disclosure"),
|
||||
],
|
||||
)
|
||||
def test_embedded_app_aigc_detected_and_stripped(self, tmp_path: Path, payload: dict, generator: str):
|
||||
src = self._app_aigc_jpeg(tmp_path, "app-aigc.jpg", payload)
|
||||
|
||||
assert generator in (exif_generator(src) or "")
|
||||
assert has_ai_metadata(src) is True
|
||||
assert "app_aigc" in get_ai_metadata(src)
|
||||
|
||||
out = tmp_path / "clean.jpg"
|
||||
_, remaining = strip_and_verify(src, out)
|
||||
|
||||
assert remaining == {}
|
||||
assert exif_generator(out) is None
|
||||
kept = piexif.load(Image.open(out).info["exif"])["0th"]
|
||||
assert kept.get(piexif.ImageIFD.ImageDescription) is None
|
||||
assert kept.get(piexif.ImageIFD.Make) == b"Canon"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
{"data": {"product": "aweme"}},
|
||||
{"data": {"product": "retouch"}},
|
||||
{"data": {"product": "lv", "exportType": "image_export"}},
|
||||
{"data": {"aigc_info": {"aigc_label_type": 0}}},
|
||||
{"data": {"aigc_info": {"aigc_label_type": 9}}},
|
||||
{"data": {"aigc_info": {"aigc_label_type": 10}}},
|
||||
{"data": {"product": "aweme", "aigc_info": {"aigc_type": 0}}},
|
||||
{"data": {"product": "aweme", "aigc_info": {"aigc_type": 2}}},
|
||||
{"data": {"product": "aweme", "aigc_info": {"aigc_type": 10}}},
|
||||
],
|
||||
)
|
||||
def test_neighboring_app_metadata_is_preserved(self, tmp_path: Path, payload: dict):
|
||||
src = self._app_aigc_jpeg(tmp_path, "ordinary-app.jpg", payload)
|
||||
|
||||
assert exif_generator(src) is None
|
||||
assert has_ai_metadata(src) is False
|
||||
assert get_ai_metadata(src) == {}
|
||||
|
||||
out = tmp_path / "kept.jpg"
|
||||
remove_ai_metadata(src, out)
|
||||
|
||||
kept = piexif.load(Image.open(out).info["exif"])["0th"]
|
||||
assert kept.get(piexif.ImageIFD.ImageDescription) is not None
|
||||
assert kept.get(piexif.ImageIFD.Make) == b"Canon"
|
||||
|
||||
@pytest.mark.parametrize("product", ["doubao", "xinghui", "dreamina", "dreamina_oversea"])
|
||||
def test_ai_product_provenance_is_stripped_without_asserting_generation(self, tmp_path: Path, product: str):
|
||||
src = self._app_aigc_jpeg(tmp_path, "app-provenance.jpg", {"data": {"product": product}})
|
||||
|
||||
assert exif_generator(src) is None
|
||||
assert has_ai_metadata(src) is True
|
||||
assert "app_provenance" in get_ai_metadata(src)
|
||||
assert identify(src, check_visible=False, check_invisible=False).is_ai_generated is None
|
||||
|
||||
out = tmp_path / "clean-provenance.jpg"
|
||||
_, remaining = strip_and_verify(src, out)
|
||||
|
||||
assert remaining == {}
|
||||
kept = piexif.load(Image.open(out).info["exif"])["0th"]
|
||||
assert kept.get(piexif.ImageIFD.ImageDescription) is None
|
||||
assert kept.get(piexif.ImageIFD.Make) == b"Canon"
|
||||
|
||||
@pytest.mark.parametrize("product", ["dreamina", "dreamina_oversea"])
|
||||
def test_dreamina_requires_generation_export_for_ai_verdict(self, tmp_path: Path, product: str):
|
||||
generated = self._app_aigc_jpeg(
|
||||
tmp_path,
|
||||
"dreamina-generation.jpg",
|
||||
{"data": {"product": product, "exportType": "generation"}},
|
||||
)
|
||||
edited = self._app_aigc_jpeg(
|
||||
tmp_path,
|
||||
"dreamina-edit.jpg",
|
||||
{"data": {"product": product, "exportType": "image_export"}},
|
||||
)
|
||||
|
||||
assert "Dreamina" in (exif_generator(generated) or "")
|
||||
assert identify(generated, check_visible=False, check_invisible=False).is_ai_generated is True
|
||||
assert exif_generator(edited) is None
|
||||
assert has_ai_metadata(edited) is True
|
||||
|
||||
def test_nested_app_aigc_user_comment_is_detected_and_stripped(self, tmp_path: Path):
|
||||
import json
|
||||
|
||||
payload = {"data": {"aigc_info": json.dumps({"aigc_label_type": 2})}}
|
||||
exif = piexif.dump(
|
||||
{
|
||||
"0th": {piexif.ImageIFD.Make: b"Canon"},
|
||||
"Exif": {piexif.ExifIFD.UserComment: json.dumps(payload, separators=(",", ":")).encode()},
|
||||
"GPS": {},
|
||||
"1st": {},
|
||||
}
|
||||
)
|
||||
src = tmp_path / "app-aigc-user-comment.jpg"
|
||||
Image.new("RGB", (64, 64)).save(src, exif=exif)
|
||||
|
||||
assert has_ai_metadata(src) is True
|
||||
|
||||
out = tmp_path / "clean-user-comment.jpg"
|
||||
_, remaining = strip_and_verify(src, out)
|
||||
|
||||
assert remaining == {}
|
||||
kept = piexif.load(Image.open(out).info["exif"])
|
||||
assert kept["Exif"].get(piexif.ExifIFD.UserComment) is None
|
||||
assert kept["0th"].get(piexif.ImageIFD.Make) == b"Canon"
|
||||
|
||||
def test_grok_signature_stripped_on_jpeg_output(self, tmp_path: Path):
|
||||
src = _grok_jpeg(tmp_path)
|
||||
assert xai_signature(src) is True
|
||||
|
||||
@@ -12,6 +12,9 @@ from PIL import Image
|
||||
|
||||
from remove_ai_watermarks._internal.c2pa import (
|
||||
_parse_c2pa_chunk,
|
||||
c2pa_info_from_manifest_store,
|
||||
c2pa_info_has_invismark,
|
||||
c2pa_info_has_removal_hint,
|
||||
cbor_text_after,
|
||||
extract_c2pa_chunk,
|
||||
extract_c2pa_info,
|
||||
@@ -153,6 +156,171 @@ class TestC2PA:
|
||||
def test_c2pa_returns_false_for_non_png(self, tmp_jpeg_path):
|
||||
assert not has_c2pa_metadata(tmp_jpeg_path)
|
||||
|
||||
def test_structured_extraction_ignores_unreachable_manifests(self):
|
||||
store = {
|
||||
"active_manifest": "active",
|
||||
"manifests": {
|
||||
"active": {
|
||||
"signature_info": {"issuer": "Adobe"},
|
||||
"assertions": [],
|
||||
},
|
||||
"unreachable": {
|
||||
"signature_info": {"issuer": "OpenAI"},
|
||||
"assertions": [
|
||||
{
|
||||
"label": "c2pa.actions.v2",
|
||||
"data": {
|
||||
"actions": [
|
||||
{
|
||||
"action": "c2pa.created",
|
||||
"digitalSourceType": "trainedAlgorithmicMedia",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
info = c2pa_info_from_manifest_store(store)
|
||||
|
||||
assert info["issuer"] == "Adobe"
|
||||
assert "source_type" not in info
|
||||
assert "ai_source_kind" not in info
|
||||
assert "c2pa_identity_ai" not in info
|
||||
|
||||
def test_reachable_ingredient_claim_generator_can_assert_ai(self):
|
||||
store = {
|
||||
"active_manifest": "update",
|
||||
"manifests": {
|
||||
"update": {
|
||||
"claim_generator": "c2pa-tool/0.1.0",
|
||||
"ingredients": [{"active_manifest": "created"}],
|
||||
"assertions": [],
|
||||
},
|
||||
"created": {
|
||||
"claim_generator": "Dreamina/7.5.0",
|
||||
"assertions": [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
info = c2pa_info_from_manifest_store(store)
|
||||
|
||||
assert info["ai_tool"] == "Dreamina"
|
||||
assert info["c2pa_identity_ai"] is True
|
||||
|
||||
def test_structured_invismark_exposes_algorithm_and_watermark_id(self):
|
||||
watermark_id = "83424621-03cb-40e3-9808-a9fae837156d"
|
||||
store = {
|
||||
"active_manifest": "paint",
|
||||
"manifests": {
|
||||
"paint": {
|
||||
"assertions": [
|
||||
{
|
||||
"label": "c2pa.soft-binding",
|
||||
"data": {
|
||||
"alg": "com.microsoft.invismark.1",
|
||||
"blocks": [
|
||||
{
|
||||
"scope": "the entire image",
|
||||
"value": watermark_id,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
info = c2pa_info_from_manifest_store(store)
|
||||
|
||||
assert info["soft_binding"] == "Microsoft InvisMark"
|
||||
assert info["soft_binding_algorithm"] == "com.microsoft.invismark.1"
|
||||
assert info["soft_binding_value"] == watermark_id
|
||||
|
||||
def test_soft_binding_value_requires_its_algorithm(self):
|
||||
store = {
|
||||
"active_manifest": "broken",
|
||||
"manifests": {
|
||||
"broken": {
|
||||
"assertions": [
|
||||
{
|
||||
"label": "c2pa.soft-binding",
|
||||
"data": {"blocks": [{"value": "not-attributable"}]},
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
assert "soft_binding_value" not in c2pa_info_from_manifest_store(store)
|
||||
|
||||
def test_soft_binding_keeps_invisible_removal_fail_safe(self):
|
||||
info = {"soft_binding_vendors": ["Microsoft InvisMark"]}
|
||||
|
||||
assert c2pa_info_has_invismark(info) is True
|
||||
assert c2pa_info_has_removal_hint(info) is True
|
||||
|
||||
def test_content_fingerprint_does_not_trigger_invisible_removal(self):
|
||||
info = {
|
||||
"soft_binding": "Adobe (content fingerprint)",
|
||||
"soft_binding_vendors": ["Adobe (content fingerprint)"],
|
||||
}
|
||||
|
||||
assert c2pa_info_has_removal_hint(info) is False
|
||||
|
||||
def test_invalid_ingredient_does_not_taint_active_validation_or_supply_claims(self):
|
||||
store = {
|
||||
"active_manifest": "update",
|
||||
"validation_results": {
|
||||
"activeManifest": {
|
||||
"success": [
|
||||
{"code": "assertion.dataHash.match"},
|
||||
{"code": "claimSignature.validated"},
|
||||
],
|
||||
"failure": [{"code": "signingCredential.untrusted"}],
|
||||
},
|
||||
"ingredientDeltas": [
|
||||
{
|
||||
"validationDeltas": {
|
||||
"failure": [{"code": "assertion.dataHash.mismatch"}],
|
||||
}
|
||||
}
|
||||
],
|
||||
},
|
||||
"manifests": {
|
||||
"update": {
|
||||
"claim_generator": "c2pa-tool/0.1.0",
|
||||
"ingredients": [
|
||||
{
|
||||
"active_manifest": "created",
|
||||
"validation_results": {
|
||||
"activeManifest": {
|
||||
"failure": [{"code": "assertion.dataHash.mismatch"}],
|
||||
}
|
||||
},
|
||||
}
|
||||
],
|
||||
"assertions": [],
|
||||
},
|
||||
"created": {
|
||||
"claim_generator": "Dreamina/7.5.0",
|
||||
"assertions": [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
info = c2pa_info_from_manifest_store(store)
|
||||
|
||||
assert info["c2pa_integrity"] == "valid"
|
||||
assert info["c2pa_signature"] == "valid"
|
||||
assert info["c2pa_signer_trust"] == "untrusted"
|
||||
assert "ai_tool" not in info
|
||||
assert "c2pa_identity_ai" not in info
|
||||
|
||||
|
||||
SAMPLES_DIR = Path(__file__).resolve().parent.parent / "data" / "fixtures" / "provenance"
|
||||
CURRENT_OPENAI_SAMPLE = (
|
||||
@@ -227,6 +395,22 @@ class TestC2PARealSamples:
|
||||
# Structured claim generator is exact, not a CBOR-scanned best-effort.
|
||||
assert info["claim_generator"] == "ChatGPT"
|
||||
|
||||
def test_reader_reports_intact_but_untrusted_credentials(self):
|
||||
info = extract_c2pa_info(SAMPLES_DIR / "chatgpt-1.png")
|
||||
|
||||
assert info["c2pa_integrity"] == "valid"
|
||||
assert info["c2pa_signature"] == "valid"
|
||||
assert info["c2pa_signer_trust"] == "untrusted"
|
||||
assert info["c2pa_signer_validity"] == "expired"
|
||||
assert "assertion.dataHash.match" in info["c2pa_validation_codes"]
|
||||
|
||||
def test_reader_reports_post_signing_container_mutation(self, tampered_chatgpt_png):
|
||||
info = extract_c2pa_info(tampered_chatgpt_png)
|
||||
|
||||
assert info["c2pa_integrity"] == "invalid"
|
||||
assert info["c2pa_signature"] == "valid"
|
||||
assert "assertion.dataHash.mismatch" in info["c2pa_validation_codes"]
|
||||
|
||||
def test_fallback_to_png_parser_when_reader_unavailable(self, monkeypatch):
|
||||
"""With the reader disabled, the hand-rolled PNG parser still works."""
|
||||
from remove_ai_watermarks._internal import c2pa
|
||||
@@ -237,6 +421,8 @@ class TestC2PARealSamples:
|
||||
assert "OpenAI" in info["issuer"]
|
||||
assert "trainedAlgorithmicMedia" in info["source_type"]
|
||||
assert "synthid_watermark" not in info
|
||||
assert info["c2pa_integrity"] == "unknown"
|
||||
assert info["c2pa_validation_source"] == "fallback"
|
||||
|
||||
|
||||
class TestC2PAInjectValidation:
|
||||
|
||||
@@ -89,6 +89,25 @@ class TestRecordReproducesTheFileVerdict:
|
||||
|
||||
|
||||
class TestPlacementsTheRecordCouldDrop:
|
||||
def test_app_aigc_user_comment_survives(self, tmp_path: Path):
|
||||
"""The file and record paths must both inspect EXIF UserComment."""
|
||||
import piexif
|
||||
|
||||
path = tmp_path / "app-aigc.jpg"
|
||||
payload = json.dumps({"data": {"aigc_info": {"aigc_label_type": 1}}}, separators=(",", ":"))
|
||||
exif = piexif.dump(
|
||||
{
|
||||
"0th": {piexif.ImageIFD.Make: b"Canon"},
|
||||
"Exif": {piexif.ExifIFD.UserComment: payload.encode()},
|
||||
"GPS": {},
|
||||
"1st": {},
|
||||
}
|
||||
)
|
||||
Image.fromarray(np.zeros((64, 64, 3), dtype=np.uint8)).save(path, "JPEG", exif=exif)
|
||||
|
||||
assert identify(path, check_visible=False, check_invisible=False).is_ai_generated is True
|
||||
_assert_same_verdict(path)
|
||||
|
||||
def test_a_trailer_after_eoi_survives(self, tmp_path: Path):
|
||||
"""Samsung Galaxy AI appends its marker past the JPEG EOI, and the value it is
|
||||
gated on can sit further back still. A record that stopped at the last
|
||||
@@ -274,6 +293,14 @@ class TestReportTransport:
|
||||
|
||||
assert convenience == explicit
|
||||
|
||||
def test_c2pa_validation_survives_the_portable_record(self, tampered_chatgpt_png: Path):
|
||||
direct = identify(tampered_chatgpt_png, check_visible=False, check_invisible=False)
|
||||
portable = identify_metadata_record(collect_metadata_record(tampered_chatgpt_png), path=tampered_chatgpt_png)
|
||||
|
||||
assert portable == direct
|
||||
assert portable.c2pa_validation is not None
|
||||
assert portable.c2pa_validation["integrity"] == "invalid"
|
||||
|
||||
|
||||
def test_a_webp_record_matches(tmp_path: Path):
|
||||
"""RIFF has its own walk; a chunk kept or dropped wrongly shows up here."""
|
||||
|
||||
+15
-2
@@ -5,19 +5,27 @@ from __future__ import annotations
|
||||
from importlib.metadata import metadata, requires
|
||||
|
||||
from packaging.requirements import Requirement
|
||||
from packaging.specifiers import SpecifierSet
|
||||
from packaging.utils import canonicalize_name
|
||||
|
||||
|
||||
def _requirement_names(extra: str | None = None) -> set[str]:
|
||||
def _requirement_names(extra: str | None = None, *, python_version: str | None = None) -> set[str]:
|
||||
selected_extra = extra or ""
|
||||
environment = {"extra": selected_extra}
|
||||
if python_version is not None:
|
||||
environment["python_version"] = python_version
|
||||
parsed = (Requirement(value) for value in requires("remove-ai-watermarks") or [])
|
||||
return {
|
||||
canonicalize_name(requirement.name)
|
||||
for requirement in parsed
|
||||
if requirement.marker is None or requirement.marker.evaluate({"extra": selected_extra})
|
||||
if requirement.marker is None or requirement.marker.evaluate(environment)
|
||||
}
|
||||
|
||||
|
||||
def test_supported_python_floor_is_published():
|
||||
assert SpecifierSet(metadata("remove-ai-watermarks")["Requires-Python"]) == SpecifierSet(">=3.11,<3.15")
|
||||
|
||||
|
||||
def test_default_install_is_metadata_focused():
|
||||
default = _requirement_names()
|
||||
|
||||
@@ -55,6 +63,11 @@ def test_file_format_and_detector_dependencies_are_independent():
|
||||
assert "pywavelets" in _requirement_names("detect")
|
||||
|
||||
|
||||
def test_trustmark_is_limited_to_its_numpy_compatible_python_range():
|
||||
assert "trustmark" in _requirement_names("trustmark", python_version="3.12")
|
||||
assert "trustmark" not in _requirement_names("trustmark", python_version="3.13")
|
||||
|
||||
|
||||
def test_extras_use_capability_names_without_legacy_aliases():
|
||||
extras = set(metadata("remove-ai-watermarks").get_all("Provides-Extra") or [])
|
||||
|
||||
|
||||
+10
-4
@@ -14,6 +14,7 @@ import pytest
|
||||
from remove_ai_watermarks._internal.utils import get_image_format, is_supported_format
|
||||
from remove_ai_watermarks._internal.watermark_profiles import (
|
||||
PROFILE_CHOICES,
|
||||
QWEN_ZIMAGE_GOOGLE_STRENGTH,
|
||||
REMOVAL_MODULES,
|
||||
SDXL_ZIMAGE_GEMINI_STRENGTH,
|
||||
SDXL_ZIMAGE_OPENAI_STRENGTH,
|
||||
@@ -204,16 +205,21 @@ class TestResolveStrength:
|
||||
|
||||
qwen-zimage picks strength from image area, so it takes the size. Returning
|
||||
None for it would push that branch onto every caller and leave one of the two
|
||||
strength policies living outside this module. The vendor is ignored here on
|
||||
purpose - the curve, not the issuer, is what was calibrated.
|
||||
strength policies living outside this module. Google content is the one
|
||||
measured exception since 0.27.2: it takes the flat oracle-measured floor
|
||||
instead of the curve (the curve's 0.154 top left a 4.33 MP fixture
|
||||
oracle-detected on the full production path).
|
||||
"""
|
||||
assert resolve_strength(None, "google", "qwen-zimage", size=(2000, 1850)) == pytest.approx(0.154)
|
||||
assert resolve_strength(None, "openai", "qwen-zimage", size=(2000, 1850)) == pytest.approx(0.154)
|
||||
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
|
||||
# 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
|
||||
|
||||
def test_qwen_zimage_without_a_size_fails_loudly(self):
|
||||
"""A missing size must not silently fall back to some vendor value."""
|
||||
with pytest.raises(ValueError, match="size is required"):
|
||||
resolve_strength(None, "google", "qwen-zimage")
|
||||
resolve_strength(None, "openai", "qwen-zimage")
|
||||
|
||||
def test_sdxl_zimage_uses_its_flat_vendor_ladder(self):
|
||||
|
||||
|
||||
@@ -579,6 +579,127 @@ def test_cli_qwen_zimage_keeps_profile_postprocess_default(tmp_image_path, monke
|
||||
assert mock_engine.remove_watermark.call_args.kwargs["adaptive_polish"] is True
|
||||
|
||||
|
||||
def test_cli_forwards_verified_text_manifest(tmp_image_path, tmp_path, monkeypatch):
|
||||
from remove_ai_watermarks import cli
|
||||
|
||||
manifest = tmp_path / "manifest.json"
|
||||
manifest.write_text("{}", encoding="utf-8")
|
||||
mock_engine = MagicMock()
|
||||
mock_engine.remove_watermark.return_value = tmp_image_path
|
||||
monkeypatch.setattr("remove_ai_watermarks.invisible_engine.is_available", lambda: True)
|
||||
monkeypatch.setattr("remove_ai_watermarks.invisible_engine.InvisibleEngine", MagicMock(return_value=mock_engine))
|
||||
|
||||
result = CliRunner().invoke(
|
||||
cli.main,
|
||||
["invisible", str(tmp_image_path), "--text-manifest", str(manifest), "--force"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert mock_engine.remove_watermark.call_args.kwargs["text_manifest"] == manifest
|
||||
|
||||
|
||||
def test_cli_reports_verified_text_manifest_errors(tmp_image_path, tmp_path, monkeypatch):
|
||||
from remove_ai_watermarks import cli
|
||||
|
||||
manifest = tmp_path / "manifest.json"
|
||||
manifest.write_text("{}", encoding="utf-8")
|
||||
mock_engine = MagicMock()
|
||||
mock_engine.remove_watermark.side_effect = ValueError("manifest pixels do not match")
|
||||
monkeypatch.setattr("remove_ai_watermarks.invisible_engine.is_available", lambda: True)
|
||||
monkeypatch.setattr("remove_ai_watermarks.invisible_engine.InvisibleEngine", MagicMock(return_value=mock_engine))
|
||||
|
||||
result = CliRunner().invoke(
|
||||
cli.main,
|
||||
["invisible", str(tmp_image_path), "--text-manifest", str(manifest), "--force"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "manifest pixels do not match" in result.output
|
||||
|
||||
|
||||
def test_no_face_path_still_runs_verified_text_restoration(monkeypatch):
|
||||
from remove_ai_watermarks._internal import qwen_zimage_pipeline, text_restoration
|
||||
from remove_ai_watermarks._internal.qwen_zimage_pipeline import QwenZImagePipeline
|
||||
from remove_ai_watermarks._internal.text_restoration import VerifiedTextLine, VerifiedTextManifest
|
||||
|
||||
pipeline = object.__new__(QwenZImagePipeline)
|
||||
pipeline.device = "cuda"
|
||||
pipeline.progress_callback = None
|
||||
source = Image.new("RGB", (32, 32), (10, 20, 30))
|
||||
donor = Image.new("RGB", (32, 32), (40, 50, 60))
|
||||
global_result = Image.new("RGB", (32, 32), (70, 80, 90))
|
||||
anchor = Image.new("RGB", (32, 32), (100, 110, 120))
|
||||
restored = Image.new("RGB", (32, 32), (130, 140, 150))
|
||||
pipeline._qwen_vae_roundtrip = MagicMock(return_value=donor)
|
||||
pipeline._run_global = MagicMock(return_value=global_result)
|
||||
monkeypatch.setattr(qwen_zimage_pipeline, "detect_faces", lambda _image: [])
|
||||
blend = MagicMock(return_value=anchor)
|
||||
restore = MagicMock(return_value=restored)
|
||||
monkeypatch.setattr(text_restoration, "blend_fidelity_anchor", blend)
|
||||
monkeypatch.setattr(text_restoration, "restore_verified_text", restore)
|
||||
manifest = VerifiedTextManifest(
|
||||
"0" * 64,
|
||||
32,
|
||||
32,
|
||||
(VerifiedTextLine((4, 4, 20, 16), "Exact", "alphabetic"),),
|
||||
)
|
||||
|
||||
result = pipeline.run(source, strength=0.1, seed=0, text_manifest=manifest)
|
||||
|
||||
assert result is restored
|
||||
# Off by default since 0.27.1 (leak finding, docs/text-protection-research.md):
|
||||
# no whole-frame donor blend; the raw global result feeds restoration.
|
||||
blend.assert_not_called()
|
||||
restore.assert_called_once_with(source, global_result, donor, manifest.lines)
|
||||
|
||||
result = pipeline.run(source, strength=0.1, seed=0, text_manifest=manifest, fidelity_anchor=True)
|
||||
|
||||
assert result is restored
|
||||
blend.assert_called_once_with(global_result, donor)
|
||||
restore.assert_called_with(source, anchor, donor, manifest.lines)
|
||||
|
||||
|
||||
def test_tiled_verified_text_runs_vae_donor_per_tile(monkeypatch):
|
||||
from remove_ai_watermarks._internal import qwen_zimage_pipeline, text_restoration
|
||||
from remove_ai_watermarks._internal.qwen_zimage_pipeline import QwenZImagePipeline
|
||||
from remove_ai_watermarks._internal.text_restoration import VerifiedTextLine, VerifiedTextManifest
|
||||
|
||||
pipeline = object.__new__(QwenZImagePipeline)
|
||||
pipeline.device = "cuda"
|
||||
pipeline.progress_callback = None
|
||||
source = Image.new("RGB", (96, 80), (10, 20, 30))
|
||||
restored = Image.new("RGB", (96, 80), (130, 140, 150))
|
||||
pipeline._qwen_vae_roundtrip = MagicMock(side_effect=lambda tile: tile)
|
||||
pipeline._run_global = MagicMock(side_effect=lambda tile, _strength, _seed: tile)
|
||||
monkeypatch.setattr(qwen_zimage_pipeline, "detect_faces", lambda _image: [])
|
||||
restore = MagicMock(return_value=restored)
|
||||
monkeypatch.setattr(text_restoration, "restore_verified_text", restore)
|
||||
manifest = VerifiedTextManifest(
|
||||
"0" * 64,
|
||||
96,
|
||||
80,
|
||||
(VerifiedTextLine((4, 4, 20, 16), "Exact", "alphabetic"),),
|
||||
)
|
||||
|
||||
result = pipeline.run(
|
||||
source,
|
||||
strength=0.1,
|
||||
seed=0,
|
||||
tile=True,
|
||||
tile_size=64,
|
||||
tile_overlap=16,
|
||||
text_manifest=manifest,
|
||||
)
|
||||
|
||||
assert result is restored
|
||||
assert pipeline._qwen_vae_roundtrip.call_count > 1
|
||||
assert pipeline._run_global.call_count > 1
|
||||
restore.assert_called_once()
|
||||
assert restore.call_args.args[0].size == (96, 80)
|
||||
assert restore.call_args.args[1].size == (96, 80)
|
||||
assert restore.call_args.args[2].size == (96, 80)
|
||||
|
||||
|
||||
def test_watermark_remover_dispatches_to_full_pipeline(tmp_path, monkeypatch):
|
||||
from remove_ai_watermarks._internal.watermark_remover import WatermarkRemover
|
||||
|
||||
@@ -601,6 +722,7 @@ def test_watermark_remover_dispatches_to_full_pipeline(tmp_path, monkeypatch):
|
||||
_, kwargs = runtime.run.call_args
|
||||
assert kwargs["strength"] == pytest.approx(0.084)
|
||||
assert kwargs["seed"] == 0
|
||||
assert kwargs["text_manifest"] is None
|
||||
assert output.exists()
|
||||
|
||||
|
||||
@@ -785,6 +907,7 @@ def test_invisible_engine_passes_the_seed_but_never_a_step_count(tmp_image_path,
|
||||
def test_sdxl_zimage_strength_is_vendor_adaptive_and_leaves_other_profiles_alone():
|
||||
"""An SDXL global pass needs more strength than Qwen, so it gets its own policy."""
|
||||
from remove_ai_watermarks._internal.watermark_profiles import (
|
||||
QWEN_ZIMAGE_GOOGLE_STRENGTH,
|
||||
SDXL_ZIMAGE_GEMINI_STRENGTH,
|
||||
SDXL_ZIMAGE_OPENAI_STRENGTH,
|
||||
resolve_strength,
|
||||
@@ -794,11 +917,14 @@ def test_sdxl_zimage_strength_is_vendor_adaptive_and_leaves_other_profiles_alone
|
||||
assert resolve_strength(None, "google", "sdxl-zimage") == pytest.approx(SDXL_ZIMAGE_GEMINI_STRENGTH)
|
||||
# Unknown provenance takes the stricter of the two.
|
||||
assert resolve_strength(None, None, "sdxl-zimage") == pytest.approx(SDXL_ZIMAGE_GEMINI_STRENGTH)
|
||||
# An explicit value still wins, and qwen-zimage is untouched by this ladder: it
|
||||
# defers to its resolution curve rather than to a vendor value.
|
||||
# An explicit value still wins, and qwen-zimage's curve is untouched by this
|
||||
# ladder - with the one measured exception: Google content takes the flat
|
||||
# oracle floor (0.27.2), not the area curve.
|
||||
assert resolve_strength(0.4, "google", "sdxl-zimage") == pytest.approx(0.4)
|
||||
assert resolve_strength(None, "openai", "qwen-zimage", size=(2000, 1850)) == pytest.approx(0.154)
|
||||
assert resolve_strength(None, "google", "qwen-zimage", size=(2000, 1850)) == pytest.approx(0.154)
|
||||
assert resolve_strength(None, "google", "qwen-zimage", size=(2000, 1850)) == pytest.approx(
|
||||
QWEN_ZIMAGE_GOOGLE_STRENGTH
|
||||
)
|
||||
|
||||
|
||||
def test_sdxl_zimage_shares_the_fixed_seed_contract():
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from remove_ai_watermarks._internal import text_restoration
|
||||
|
||||
SCRIPT = Path(__file__).parents[1] / "scripts" / "selective_text_restoration.py"
|
||||
SPEC = importlib.util.spec_from_file_location("selective_text_restoration", SCRIPT)
|
||||
assert SPEC is not None
|
||||
assert SPEC.loader is not None
|
||||
module = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[SPEC.name] = module
|
||||
SPEC.loader.exec_module(module)
|
||||
|
||||
|
||||
def test_normalized_edit_distance_ignores_case_and_whitespace() -> None:
|
||||
assert module.normalized_edit_distance(" Sample text ", "sample\ntext") == 0.0
|
||||
|
||||
|
||||
def test_preserve_requires_source_candidate_agreement() -> None:
|
||||
assert module.should_preserve_line("clean text", "clean text", 0.9, "clean text", 0.9)
|
||||
assert not module.should_preserve_line("clean text", "clean text", 0.9, "damaged", 0.9)
|
||||
|
||||
|
||||
def test_preserve_rejects_unreliable_source_recognition() -> None:
|
||||
assert not module.should_preserve_line("expected", "unrelated", 0.9, "unrelated", 0.9)
|
||||
assert not module.should_preserve_line("expected", "expected", 0.7, "expected", 0.9)
|
||||
|
||||
|
||||
def test_cjk_recognition_box_excludes_overlapping_neighbor_lines() -> None:
|
||||
line = module.TextLine((1281, 650, 2357, 818), "每天都是一个新的机会。", "cjk")
|
||||
|
||||
assert module._recognition_box(line, 2816, 1536) == (1247, 650, 2458, 818)
|
||||
|
||||
|
||||
def test_latin_recognition_box_keeps_context_padding() -> None:
|
||||
line = module.TextLine((100, 200, 300, 260), "Sample text", "latin")
|
||||
|
||||
assert module._recognition_box(line, 1000, 1000) == (40, 180, 360, 280)
|
||||
assert module._recognition_box(line, 1000, 1000, 0.1) == (40, 192, 360, 268)
|
||||
|
||||
|
||||
def test_verified_lines_cover_each_ground_truth_string() -> None:
|
||||
root = Path(__file__).parents[1]
|
||||
lines = json.loads((root / "data/evaluations/fidelity/text-lines.json").read_text(encoding="utf-8"))
|
||||
ground_truth = json.loads((root / "data/evaluations/fidelity/ground-truth.json").read_text(encoding="utf-8"))
|
||||
|
||||
assert lines.keys() == ground_truth.keys()
|
||||
for source, expected in ground_truth.items():
|
||||
observed = " ".join(line["text"] for line in lines[source])
|
||||
assert module.normalize_text(observed) == module.normalize_text(expected)
|
||||
|
||||
|
||||
def test_group_word_boxes_merges_words_but_not_neighboring_lines() -> None:
|
||||
boxes = [(10, 10, 30, 30), (32, 12, 60, 29), (10, 35, 50, 55)]
|
||||
|
||||
assert module.group_word_boxes(boxes) == [(10, 10, 60, 30), (10, 35, 50, 55)]
|
||||
|
||||
|
||||
def test_group_word_boxes_does_not_merge_distant_columns() -> None:
|
||||
boxes = [(10, 10, 60, 30), (500, 11, 560, 31)]
|
||||
|
||||
assert module.group_word_boxes(boxes) == boxes
|
||||
|
||||
|
||||
def test_source_glyph_composite_keeps_masked_pixels_exact() -> None:
|
||||
source = np.zeros((9, 9, 3), dtype=np.uint8)
|
||||
source[:, :] = (220, 180, 40)
|
||||
background = np.zeros((9, 9, 3), dtype=np.uint8)
|
||||
background[:, :] = (10, 20, 30)
|
||||
mask = np.zeros((9, 9), dtype=np.uint8)
|
||||
mask[3:6, 3:6] = 255
|
||||
|
||||
result = module.composite_source_glyphs(source, background, mask, feather=0.7)
|
||||
|
||||
np.testing.assert_array_equal(result[3:6, 3:6], source[3:6, 3:6])
|
||||
np.testing.assert_array_equal(result[0, 0], background[0, 0])
|
||||
|
||||
|
||||
def test_fresh_silhouette_uses_new_color_instead_of_source_pixels() -> None:
|
||||
background = np.zeros((9, 9, 3), dtype=np.uint8)
|
||||
background[:, :] = (10, 20, 30)
|
||||
mask = np.zeros((9, 9), dtype=np.uint8)
|
||||
mask[3:6, 3:6] = 255
|
||||
|
||||
result = text_restoration.composite_fresh_silhouette(background, mask, (220, 180, 40), feather=0)
|
||||
|
||||
assert np.all(result[3:6, 3:6] == (220, 180, 40))
|
||||
np.testing.assert_array_equal(result[0, 0], background[0, 0])
|
||||
|
||||
|
||||
def test_fresh_silhouette_antialiasing_softens_binary_edges() -> None:
|
||||
background = np.zeros((9, 9, 3), dtype=np.uint8)
|
||||
background[:, :] = (10, 20, 30)
|
||||
mask = np.zeros((9, 9), dtype=np.uint8)
|
||||
mask[3:6, 3:6] = 255
|
||||
|
||||
result = text_restoration.composite_fresh_silhouette(background, mask, (220, 180, 40), feather=1.0)
|
||||
|
||||
assert np.all(result[3, 3] > background[3, 3])
|
||||
assert np.all(result[3, 3] < (220, 180, 40))
|
||||
|
||||
|
||||
def test_reconstructed_glyphs_keep_exact_donor_core_and_fresh_edge() -> None:
|
||||
donor = np.zeros((9, 9, 3), dtype=np.uint8)
|
||||
donor[:, :] = (180, 140, 60)
|
||||
background = np.zeros((9, 9, 3), dtype=np.uint8)
|
||||
background[:, :] = (10, 20, 30)
|
||||
mask = np.zeros((9, 9), dtype=np.uint8)
|
||||
mask[3:6, 3:6] = 255
|
||||
|
||||
fresh_edge = text_restoration.composite_fresh_silhouette(background, mask, (220, 180, 40))
|
||||
result = module.composite_reconstructed_glyphs(donor, fresh_edge, mask, feather=0.5)
|
||||
|
||||
np.testing.assert_array_equal(result[3:6, 3:6], donor[3:6, 3:6])
|
||||
assert np.any(result[2, 3] != fresh_edge[2, 3])
|
||||
np.testing.assert_array_equal(result[0, 0], background[0, 0])
|
||||
|
||||
|
||||
def test_source_silhouette_discards_foreground_amplitudes() -> None:
|
||||
source = np.full((15, 15, 3), 20, dtype=np.uint8)
|
||||
source[5:10, 6:9] = 230
|
||||
source[6:9, 7] = 180
|
||||
|
||||
mask = module.source_silhouette_mask(source, (4, 4, 11, 11))
|
||||
|
||||
assert mask.dtype == np.uint8
|
||||
assert set(np.unique(mask)) <= {0, 255}
|
||||
assert mask[7, 7] == 255
|
||||
assert mask[4, 4] == 0
|
||||
|
||||
|
||||
def test_rotated_source_silhouette_excludes_axis_aligned_corners() -> None:
|
||||
source = np.full((80, 160, 3), 20, dtype=np.uint8)
|
||||
source[10:70, 10:150] = 230
|
||||
|
||||
mask = module.source_silhouette_mask(source, (0, 0, 160, 80), angle=12)
|
||||
|
||||
assert mask[0, 0] == 0
|
||||
assert mask[79, 159] == 0
|
||||
|
||||
|
||||
def test_source_box_mask_pads_and_clips_boxes() -> None:
|
||||
mask = module.source_box_mask((20, 30), [(1, 2, 11, 10), (25, 15, 30, 20)])
|
||||
|
||||
assert mask.shape == (20, 30)
|
||||
assert mask[0, 0] == 255
|
||||
assert mask[19, 29] == 255
|
||||
assert mask[0, 22] == 0
|
||||
|
||||
|
||||
def test_detect_line_boxes_fails_closed_on_count_mismatch() -> None:
|
||||
class Engine:
|
||||
def predict(self, _image):
|
||||
return [{"rec_scores": [0.9], "rec_boxes": [[10, 10, 30, 30]]}]
|
||||
|
||||
with pytest.raises(module.click.ClickException, match="detected 1 source lines; expected exactly 2"):
|
||||
module.detect_line_boxes(Engine(), np.zeros((50, 50, 3), dtype=np.uint8), expected_count=2)
|
||||
|
||||
|
||||
def test_residual_mask_is_limited_to_original_glyph_positions(monkeypatch) -> None:
|
||||
from remove_ai_watermarks._internal import text_restoration
|
||||
|
||||
background = np.zeros((8, 8, 3), dtype=np.uint8)
|
||||
original = np.zeros((8, 8), dtype=np.uint8)
|
||||
original[3, 3] = 255
|
||||
detected = np.zeros((8, 8), dtype=np.uint8)
|
||||
detected[3, 3] = 255
|
||||
detected[6, 6] = 255
|
||||
monkeypatch.setattr(text_restoration, "_foreground_mask", lambda _image, _box: detected)
|
||||
|
||||
residual = module.residual_glyph_mask(background, original, (0, 0, 8, 8))
|
||||
|
||||
assert residual[3, 3] == 255
|
||||
assert residual[6, 6] == 0
|
||||
@@ -1,49 +0,0 @@
|
||||
"""Tests for the release-time conda recipe synchronizer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts.sync_conda_recipe import update_recipe
|
||||
|
||||
_OLD_SHA = "a" * 64
|
||||
_NEW_SHA = "b" * 64
|
||||
_RECIPE = f"""\
|
||||
schema_version: 1
|
||||
|
||||
context:
|
||||
version: "1.2.3"
|
||||
|
||||
source:
|
||||
sha256: {_OLD_SHA}
|
||||
"""
|
||||
|
||||
|
||||
def test_update_recipe_replaces_version_and_hash() -> None:
|
||||
updated = update_recipe(_RECIPE, version="2.0.0", sha256=_NEW_SHA)
|
||||
|
||||
assert ' version: "2.0.0"' in updated
|
||||
assert f" sha256: {_NEW_SHA}" in updated
|
||||
assert "1.2.3" not in updated
|
||||
assert _OLD_SHA not in updated
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sha256", ["", "A" * 64, "f" * 63, "not-a-hash"])
|
||||
def test_update_recipe_rejects_invalid_hash(sha256: str) -> None:
|
||||
with pytest.raises(ValueError, match="sha256"):
|
||||
update_recipe(_RECIPE, version="2.0.0", sha256=sha256)
|
||||
|
||||
|
||||
def test_update_recipe_rejects_ambiguous_recipe() -> None:
|
||||
duplicate = _RECIPE + f"\nsource_two:\n sha256: {_OLD_SHA}\n"
|
||||
|
||||
with pytest.raises(ValueError, match="exactly one"):
|
||||
update_recipe(duplicate, version="2.0.0", sha256=_NEW_SHA)
|
||||
|
||||
|
||||
def test_repository_recipe_stays_metadata_only() -> None:
|
||||
recipe = Path("packaging/conda/recipe.yaml").read_text(encoding="utf-8")
|
||||
|
||||
assert " - av >=16\n" not in recipe
|
||||
@@ -0,0 +1,158 @@
|
||||
"""text_draft: proposal-only OCR for verified-text manifests (no paddle needed)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from remove_ai_watermarks.text_draft import (
|
||||
TextDraft,
|
||||
choose_language,
|
||||
draft_available,
|
||||
draft_text_lines,
|
||||
group_word_boxes,
|
||||
stable_recognition,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class TestPureHelpers:
|
||||
def test_choose_language_follows_unicode_script(self):
|
||||
assert choose_language({"ch": ("你好", 0.9), "ru": ("xxx", 0.1), "en": ("yyy", 0.1)}) == "ch"
|
||||
assert choose_language({"ch": ("??", 0.1), "ru": ("привет", 0.9), "en": ("yyy", 0.1)}) == "ru"
|
||||
assert choose_language({"ch": ("??", 0.1), "ru": ("xxx", 0.1), "en": ("hello", 0.9)}) == "en"
|
||||
|
||||
def test_stable_recognition_requires_identical_normalizations(self):
|
||||
# Same text modulo spacing/case (comma kept): stable.
|
||||
assert (
|
||||
stable_recognition([("Hello, World", 0.97), ("hello,world", 0.96), ("HELLO, WORLD", 0.95)])
|
||||
== "Hello, World"
|
||||
)
|
||||
# Different texts: rejected (None).
|
||||
assert stable_recognition([("Hello", 0.97), ("Hallo", 0.96), ("Hello", 0.95)]) is None
|
||||
# Identical text but one read under the floor: rejected.
|
||||
assert stable_recognition([("Hello", 0.97), ("Hello", 0.60), ("Hello", 0.95)]) is None
|
||||
assert stable_recognition([("Hello", 0.97), ("Hello", 0.96), ("Hello", 0.95)], min_score=0.99) is None
|
||||
|
||||
def test_group_word_boxes_merges_a_line_and_keeps_rows_apart(self):
|
||||
# Two words on one baseline merge; a distant lower line stays separate.
|
||||
merged = group_word_boxes([(10, 100, 60, 140), (70, 100, 120, 140)])
|
||||
assert len(merged) == 1
|
||||
assert merged[0] == (10, 100, 120, 140)
|
||||
apart = group_word_boxes([(10, 100, 60, 140), (10, 300, 60, 340)])
|
||||
assert len(apart) == 2
|
||||
|
||||
|
||||
class TestDraftAvailable:
|
||||
def test_returns_a_bool_without_importing_paddle(self):
|
||||
assert isinstance(draft_available(), bool)
|
||||
|
||||
|
||||
class _FakeDetector:
|
||||
"""Emits Paddle-style pages for one synthetic poster."""
|
||||
|
||||
def __init__(self, boxes: list[tuple[int, int, int, int]]) -> None:
|
||||
self._boxes = boxes
|
||||
|
||||
def predict(self, _image: Any) -> Any:
|
||||
import numpy as np
|
||||
|
||||
yield {
|
||||
"rec_boxes": np.asarray(self._boxes, dtype=np.float32),
|
||||
"rec_scores": [0.99] * len(self._boxes),
|
||||
}
|
||||
|
||||
|
||||
class _FakeEngine:
|
||||
"""Returns a fixed text at high score regardless of the crop."""
|
||||
|
||||
def __init__(self, text: str) -> None:
|
||||
self._text = text
|
||||
|
||||
def predict(self, _crop: Any) -> Any:
|
||||
yield {"rec_text": self._text, "rec_score": 0.97}
|
||||
|
||||
|
||||
class _JitterEngine:
|
||||
"""Changes its answer with the crop height - exactly what the gate rejects."""
|
||||
|
||||
def __init__(self, texts: list[str]) -> None:
|
||||
self._texts = texts
|
||||
self._calls = 0
|
||||
|
||||
def predict(self, crop: Any) -> Any:
|
||||
text = self._texts[self._calls % len(self._texts)]
|
||||
self._calls += 1
|
||||
yield {"rec_text": text, "rec_score": 0.97}
|
||||
|
||||
|
||||
class TestDraftTextLines:
|
||||
@staticmethod
|
||||
def _poster(tmp_path: Path) -> Path:
|
||||
path = tmp_path / "poster.png"
|
||||
Image.new("RGB", (400, 300), (255, 255, 255)).save(path)
|
||||
return path
|
||||
|
||||
def test_accepts_crop_stable_and_rejects_jittered(self, tmp_path: Path):
|
||||
path = self._poster(tmp_path)
|
||||
stable = _FakeEngine("Invoice 42")
|
||||
jitter = _JitterEngine(["Hello", "Hallo", "Hullo"])
|
||||
# All three probe engines read the same string, so language = en; the
|
||||
# jitter engine then serves as the en engine and flips across crops.
|
||||
draft = draft_text_lines(
|
||||
path,
|
||||
detector=_FakeDetector([(20, 20, 200, 60), (20, 80, 200, 120)]),
|
||||
engines={"en": jitter, "ru": stable, "ch": stable},
|
||||
)
|
||||
assert isinstance(draft, TextDraft)
|
||||
assert len(draft.rejected) == 2
|
||||
assert all(line.language == "en" for line in draft.rejected)
|
||||
assert draft.accepted == ()
|
||||
|
||||
def test_accepted_line_carries_box_text_script_and_floor(self, tmp_path: Path):
|
||||
path = self._poster(tmp_path)
|
||||
engine = _FakeEngine("Total: 1,234.56")
|
||||
draft = draft_text_lines(
|
||||
path,
|
||||
detector=_FakeDetector([(20, 20, 260, 60)]),
|
||||
engines={"en": engine, "ru": engine, "ch": engine},
|
||||
)
|
||||
(line,) = draft.accepted
|
||||
assert line.box == (20, 20, 260, 60)
|
||||
assert line.text == "Total: 1,234.56"
|
||||
assert line.script == "alphabetic"
|
||||
assert line.language == "en"
|
||||
assert line.min_score >= 0.85
|
||||
|
||||
def test_cjk_probe_switches_script_and_language(self, tmp_path: Path):
|
||||
path = self._poster(tmp_path)
|
||||
cjk = _FakeEngine("每天都是一个新的机会。")
|
||||
latin = _FakeEngine("hello")
|
||||
draft = draft_text_lines(
|
||||
path,
|
||||
detector=_FakeDetector([(30, 30, 300, 90)]),
|
||||
engines={"en": latin, "ru": latin, "ch": cjk},
|
||||
)
|
||||
(line,) = draft.accepted
|
||||
assert line.language == "ch"
|
||||
assert line.script == "cjk"
|
||||
|
||||
def test_unstable_geometry_draft_accepts_a_single_high_score_read(self, tmp_path: Path):
|
||||
path = self._poster(tmp_path)
|
||||
jitter = _JitterEngine(["Hello", "Hallo", "Hullo"])
|
||||
stable = _FakeEngine("Hello")
|
||||
# Probes disagree across engines so language is en; jitter would fail
|
||||
# the stable gate. Geometry mode uses the one en probe and accepts.
|
||||
draft = draft_text_lines(
|
||||
path,
|
||||
min_score=0.75,
|
||||
stable=False,
|
||||
detector=_FakeDetector([(20, 20, 200, 60)]),
|
||||
engines={"en": jitter, "ru": stable, "ch": stable},
|
||||
)
|
||||
(line,) = draft.accepted
|
||||
assert line.text == "Hello"
|
||||
assert line.min_score >= 0.75
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Verified-text manifest and compositor tests without model downloads."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image, PngImagePlugin
|
||||
|
||||
from remove_ai_watermarks._internal.text_restoration import (
|
||||
FIDELITY_BLEND_ALPHA,
|
||||
VerifiedTextLine,
|
||||
blend_fidelity_anchor,
|
||||
load_verified_text_manifest,
|
||||
restore_verified_text,
|
||||
source_pixel_sha256,
|
||||
source_silhouette_mask,
|
||||
)
|
||||
|
||||
|
||||
def _manifest(image: Image.Image) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"verified": True,
|
||||
"source_pixel_sha256": source_pixel_sha256(image),
|
||||
"width": image.width,
|
||||
"height": image.height,
|
||||
"lines": [
|
||||
{
|
||||
"box": [8, 8, 40, 24],
|
||||
"text": "Exact text",
|
||||
"script": "alphabetic",
|
||||
"angle": 0.0,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _geometry_manifest(image: Image.Image) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": 2,
|
||||
"verified": True,
|
||||
"source_pixel_sha256": source_pixel_sha256(image),
|
||||
"width": image.width,
|
||||
"height": image.height,
|
||||
"lines": [{"box": [8, 8, 40, 24], "angle": 0.0}],
|
||||
}
|
||||
|
||||
|
||||
def test_pixel_hash_ignores_container_metadata(tmp_path) -> None:
|
||||
image = Image.new("RGB", (48, 32), (10, 20, 30))
|
||||
plain = tmp_path / "plain.png"
|
||||
tagged = tmp_path / "tagged.png"
|
||||
image.save(plain)
|
||||
metadata = PngImagePlugin.PngInfo()
|
||||
metadata.add_text("note", "different container bytes")
|
||||
image.save(tagged, pnginfo=metadata)
|
||||
|
||||
with Image.open(plain) as left, Image.open(tagged) as right:
|
||||
assert plain.read_bytes() != tagged.read_bytes()
|
||||
assert source_pixel_sha256(left) == source_pixel_sha256(right)
|
||||
|
||||
|
||||
def test_verified_manifest_is_bound_to_source_pixels(tmp_path) -> None:
|
||||
source = Image.new("RGB", (48, 32), (10, 20, 30))
|
||||
path = tmp_path / "lines.json"
|
||||
path.write_text(json.dumps(_manifest(source)), encoding="utf-8")
|
||||
|
||||
loaded = load_verified_text_manifest(path, source)
|
||||
|
||||
assert loaded.width == 48
|
||||
assert loaded.height == 32
|
||||
assert loaded.lines == (VerifiedTextLine((8, 8, 40, 24), "Exact text", "alphabetic", 0.0),)
|
||||
|
||||
|
||||
def test_geometry_manifest_needs_no_transcription_or_script(tmp_path) -> None:
|
||||
source = Image.new("RGB", (48, 32), (10, 20, 30))
|
||||
path = tmp_path / "regions.json"
|
||||
path.write_text(json.dumps(_geometry_manifest(source)), encoding="utf-8")
|
||||
|
||||
loaded = load_verified_text_manifest(path, source)
|
||||
|
||||
assert loaded.lines == (VerifiedTextLine((8, 8, 40, 24), angle=0.0),)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["text", "script"])
|
||||
def test_schema_one_still_requires_text_metadata(tmp_path, field) -> None:
|
||||
source = Image.new("RGB", (48, 32), (10, 20, 30))
|
||||
payload = _manifest(source)
|
||||
del payload["lines"][0][field]
|
||||
path = tmp_path / "lines.json"
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError, match=field):
|
||||
load_verified_text_manifest(path, source)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("schema_version", [True, 0, 3])
|
||||
def test_manifest_rejects_unsupported_schema_versions(tmp_path, schema_version) -> None:
|
||||
source = Image.new("RGB", (48, 32), (10, 20, 30))
|
||||
payload = _geometry_manifest(source)
|
||||
payload["schema_version"] = schema_version
|
||||
path = tmp_path / "lines.json"
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError, match="Unsupported text manifest schema"):
|
||||
load_verified_text_manifest(path, source)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mutation", "message"),
|
||||
[
|
||||
({"verified": False}, "verified=true"),
|
||||
({"source_pixel_sha256": "0" * 64}, "does not match"),
|
||||
({"width": 49}, "dimensions"),
|
||||
({"lines": []}, "non-empty"),
|
||||
],
|
||||
)
|
||||
def test_manifest_rejects_unverified_or_unbound_input(tmp_path, mutation, message) -> None:
|
||||
source = Image.new("RGB", (48, 32), (10, 20, 30))
|
||||
payload = _manifest(source)
|
||||
payload.update(mutation)
|
||||
path = tmp_path / "lines.json"
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError, match=message):
|
||||
load_verified_text_manifest(path, source)
|
||||
|
||||
|
||||
def test_fidelity_anchor_uses_the_calibrated_rounding() -> None:
|
||||
clean = Image.fromarray(np.array([[[1, 2, 3], [100, 150, 200]]], dtype=np.uint8))
|
||||
donor = Image.fromarray(np.array([[[255, 254, 253], [200, 100, 50]]], dtype=np.uint8))
|
||||
|
||||
result = np.asarray(blend_fidelity_anchor(clean, donor))
|
||||
expected = np.rint(
|
||||
np.asarray(clean, dtype=np.float32) * (1.0 - FIDELITY_BLEND_ALPHA)
|
||||
+ np.asarray(donor, dtype=np.float32) * FIDELITY_BLEND_ALPHA
|
||||
).astype(np.uint8)
|
||||
|
||||
assert np.array_equal(result, expected)
|
||||
|
||||
|
||||
def test_restoration_uses_lama_and_qwen_vae_core(monkeypatch) -> None:
|
||||
from remove_ai_watermarks import region_eraser
|
||||
|
||||
source = np.full((40, 64, 3), 20, dtype=np.uint8)
|
||||
source[12:24, 12:44] = 235
|
||||
candidate = np.full_like(source, 30)
|
||||
candidate[12:24, 12:44] = 150
|
||||
donor = np.full_like(source, 40)
|
||||
donor[12:24, 12:44] = (210, 220, 230)
|
||||
calls: list[np.ndarray] = []
|
||||
|
||||
def fake_erase(image_bgr, mask):
|
||||
calls.append(mask.copy())
|
||||
output = image_bgr.copy()
|
||||
output[mask > 0] = (30, 30, 30)
|
||||
return output
|
||||
|
||||
monkeypatch.setattr(region_eraser, "lama_available", lambda: True)
|
||||
monkeypatch.setattr(region_eraser, "erase_lama", fake_erase)
|
||||
|
||||
result = restore_verified_text(
|
||||
Image.fromarray(source),
|
||||
Image.fromarray(candidate),
|
||||
Image.fromarray(donor),
|
||||
(VerifiedTextLine((8, 8, 48, 28)),),
|
||||
)
|
||||
|
||||
restored = np.asarray(result)
|
||||
assert calls
|
||||
assert np.all(restored[16, 20] == donor[16, 20])
|
||||
assert np.all(restored[0, 0] == candidate[0, 0])
|
||||
|
||||
|
||||
def test_silhouette_includes_descender_below_the_detector_box() -> None:
|
||||
source = np.full((40, 50, 3), 240, dtype=np.uint8)
|
||||
source[10:22, 18:24] = 20
|
||||
source[22:27, 18:22] = 20 # tail of a y / Cyrillic u, under the box
|
||||
|
||||
mask = source_silhouette_mask(source, (10, 10, 40, 22))
|
||||
|
||||
assert mask[24, 20] == 255
|
||||
assert mask[16, 20] == 255
|
||||
|
||||
|
||||
def test_silhouette_includes_glyph_edges_beside_the_detector_box() -> None:
|
||||
source = np.full((40, 70, 3), 240, dtype=np.uint8)
|
||||
source[10:22, 20:50] = 20
|
||||
source[14:18, 10:20] = 20 # leading flourish, left of the detector box
|
||||
source[14:18, 50:58] = 20 # punctuation or icon edge, right of the box
|
||||
source[14:18, 2:5] = 20 # separate decoration must stay outside the crop
|
||||
|
||||
mask = source_silhouette_mask(source, (20, 10, 50, 22))
|
||||
|
||||
assert mask[16, 12] == 255
|
||||
assert mask[16, 56] == 255
|
||||
assert mask[16, 3] == 0
|
||||
@@ -8,15 +8,19 @@ absent/error behaviour: detect must return None, never raise.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from remove_ai_watermarks import trustmark_detector
|
||||
from remove_ai_watermarks.identify import identify
|
||||
from remove_ai_watermarks.trustmark_detector import detect_trustmark, is_available
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
_OFFICIAL_FIXTURE = (
|
||||
Path(__file__).resolve().parent.parent / "data" / "fixtures" / "provenance" / "adobe-trustmark-p.png"
|
||||
)
|
||||
_OFFICIAL_FIXTURE_SHA256 = "e58c5825ed7e5d9fb04710ea541b61bd55879cad65554c0d46260aa24b3d0755"
|
||||
|
||||
|
||||
class _FakeDecoder:
|
||||
@@ -24,11 +28,12 @@ class _FakeDecoder:
|
||||
``(secret, present, schema)`` tuples -- the first for the original image, the
|
||||
second for the re-encoded copy used by the false-positive durability gate."""
|
||||
|
||||
def __init__(self, *results: tuple[bytes, bool, int]):
|
||||
def __init__(self, *results: tuple[str, bool, int]):
|
||||
self._results = list(results)
|
||||
self.calls = 0
|
||||
|
||||
def decode(self, _img: object) -> tuple[bytes, bool, int]:
|
||||
def decode(self, _img: object, mode: str = "binary") -> tuple[str, bool, int]:
|
||||
assert mode == "binary"
|
||||
result = self._results[min(self.calls, len(self._results) - 1)]
|
||||
self.calls += 1
|
||||
return result
|
||||
@@ -64,7 +69,7 @@ class TestFalsePositiveGate:
|
||||
monkeypatch.setattr(trustmark_detector, "_decoder", lambda: decoder)
|
||||
|
||||
def test_durable_watermark_survives_and_is_reported(self, monkeypatch, tmp_clean_png: Path):
|
||||
decoder = _FakeDecoder((b"secret", True, 2), (b"secret", True, 2))
|
||||
decoder = _FakeDecoder(("secret", True, 2), ("secret", True, 2))
|
||||
self._patch_decoder(monkeypatch, decoder)
|
||||
result = detect_trustmark(tmp_clean_png)
|
||||
assert result == "Adobe TrustMark (variant P, schema 2)"
|
||||
@@ -72,18 +77,46 @@ class TestFalsePositiveGate:
|
||||
|
||||
def test_false_positive_collapsing_on_reencode_is_dropped(self, monkeypatch, tmp_clean_png: Path):
|
||||
# Present on the original, absent after re-encode -> content-noise FP.
|
||||
decoder = _FakeDecoder((b"\x00\x01", True, 3), (b"", False, -1))
|
||||
decoder = _FakeDecoder(("01", True, 2), ("", False, -1))
|
||||
self._patch_decoder(monkeypatch, decoder)
|
||||
assert detect_trustmark(tmp_clean_png) is None
|
||||
|
||||
def test_schema_drift_on_reencode_is_dropped(self, monkeypatch, tmp_clean_png: Path):
|
||||
# Present both times but the schema changes -> not a stable watermark.
|
||||
decoder = _FakeDecoder((b"\x00", True, 2), (b"\x00", True, 3))
|
||||
decoder = _FakeDecoder(("01", True, 2), ("01", True, 3))
|
||||
self._patch_decoder(monkeypatch, decoder)
|
||||
assert detect_trustmark(tmp_clean_png) is None
|
||||
|
||||
def test_payload_drift_on_reencode_is_dropped(self, monkeypatch, tmp_clean_png: Path):
|
||||
decoder = _FakeDecoder(("first", True, 2), ("second", True, 2))
|
||||
self._patch_decoder(monkeypatch, decoder)
|
||||
assert detect_trustmark(tmp_clean_png) is None
|
||||
|
||||
def test_weak_schema_three_is_dropped(self, monkeypatch, tmp_clean_png: Path):
|
||||
decoder = _FakeDecoder(("secret", True, 3))
|
||||
self._patch_decoder(monkeypatch, decoder)
|
||||
assert detect_trustmark(tmp_clean_png) is None
|
||||
assert decoder.calls == 1
|
||||
|
||||
def test_absent_skips_reencode(self, monkeypatch, tmp_clean_png: Path):
|
||||
decoder = _FakeDecoder((b"", False, -1))
|
||||
decoder = _FakeDecoder(("", False, -1))
|
||||
self._patch_decoder(monkeypatch, decoder)
|
||||
assert detect_trustmark(tmp_clean_png) is None
|
||||
assert decoder.calls == 1 # no second decode when the first is absent
|
||||
|
||||
|
||||
@pytest.mark.skipif(not is_available(), reason="trustmark not installed")
|
||||
def test_official_adobe_variant_p_fixture():
|
||||
assert detect_trustmark(_OFFICIAL_FIXTURE) == "Adobe TrustMark (variant P, schema 1)"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not is_available(), reason="trustmark not installed")
|
||||
def test_official_adobe_variant_p_fixture_is_provenance_not_ai():
|
||||
report = identify(_OFFICIAL_FIXTURE, check_visible=False)
|
||||
assert report.is_ai_generated is None
|
||||
assert report.ai_from_metadata is False
|
||||
assert any(signal.name == "trustmark" for signal in report.signals)
|
||||
|
||||
|
||||
def test_official_adobe_variant_p_fixture_digest():
|
||||
assert hashlib.sha256(_OFFICIAL_FIXTURE.read_bytes()).hexdigest() == _OFFICIAL_FIXTURE_SHA256
|
||||
@@ -65,6 +65,47 @@ def _video_with_tc260(path: Path, *, media_payload: bytes = _VIDEO_PAYLOAD) -> P
|
||||
return path
|
||||
|
||||
|
||||
def _hdlr(handler: bytes) -> bytes:
|
||||
# version/flags + pre_defined + handler_type + reserved[3]; the TC260 walker
|
||||
# never parses it, but a real-shaped hdlr keeps the fixture honest.
|
||||
return _box(b"hdlr", b"\x00\x00\x00\x00" + b"\x00\x00\x00\x00" + handler + b"\x00" * 12)
|
||||
|
||||
|
||||
def _quicktime_meta(*children: bytes) -> bytes:
|
||||
# QuickTime form: no 4-byte FullBox header before the children.
|
||||
return _box(b"meta", b"".join(children))
|
||||
|
||||
|
||||
def _video_with_tc260_moov_meta(path: Path, *, media_payload: bytes = _VIDEO_PAYLOAD) -> Path:
|
||||
"""Doubao's iOS export form: a QuickTime ``meta`` box as a direct ``moov`` child."""
|
||||
keys = _box(
|
||||
b"keys",
|
||||
b"\x00\x00\x00\x00"
|
||||
+ (2).to_bytes(4, "big")
|
||||
+ _metadata_key(b"com.apple.quicktime.artwork")
|
||||
+ _metadata_key(b"AIGC"),
|
||||
)
|
||||
ilst = _box(
|
||||
b"ilst",
|
||||
_metadata_value(1, b'{"source_type":"","data":{"product":"doubao"}}') + _metadata_value(2, _TC260_AIGC),
|
||||
)
|
||||
meta = _quicktime_meta(_hdlr(b"mdta"), keys, ilst)
|
||||
path.write_bytes(_MP4_FTYP + _box(b"mdat", media_payload) + _box(b"moov", meta))
|
||||
return path
|
||||
|
||||
|
||||
def _video_with_tc260_mdir_list(path: Path, *, media_payload: bytes = _VIDEO_PAYLOAD) -> Path:
|
||||
"""Doubao's iOS QuickTime metadata list: ``udta.meta(hdlr=mdir)/ilst`` data
|
||||
items with numeric indices and no ``keys`` box at all."""
|
||||
ilst = _box(
|
||||
b"ilst",
|
||||
_metadata_value(0, b"vid:standard-video-id") + _metadata_value(0, _TC260_AIGC),
|
||||
)
|
||||
meta = _quicktime_meta(_hdlr(b"mdir"), ilst)
|
||||
path.write_bytes(_MP4_FTYP + _box(b"mdat", media_payload) + _box(b"moov", _box(b"udta", meta)))
|
||||
return path
|
||||
|
||||
|
||||
def _ebml_size(value: int) -> bytes:
|
||||
for length in range(1, 9):
|
||||
if value < (1 << (7 * length)) - 1:
|
||||
@@ -612,6 +653,67 @@ class TestVideoMetadataApi:
|
||||
assert b"AIGC" not in cleaned
|
||||
assert _TC260_AIGC not in cleaned
|
||||
|
||||
@pytest.mark.parametrize("suffix", [".mp4", ".mov"])
|
||||
def test_inspects_native_tc260_in_quicktime_moov_meta(self, tmp_path: Path, suffix: str):
|
||||
# Doubao's iOS export stores the label in a QuickTime-form meta box
|
||||
# (no FullBox header) hanging directly off moov, not under udta.
|
||||
from remove_ai_watermarks.video import inspect_video_metadata
|
||||
|
||||
source = _video_with_tc260_moov_meta(tmp_path / f"source{suffix}")
|
||||
|
||||
report = inspect_video_metadata(source)
|
||||
|
||||
assert report.has_ai_metadata is True
|
||||
assert report.markers["aigc_label"].endswith("producer 00119144030008867405X210002")
|
||||
|
||||
def test_removes_native_tc260_in_quicktime_moov_meta(self, tmp_path: Path):
|
||||
from remove_ai_watermarks.video import remove_video_metadata
|
||||
|
||||
source = _video_with_tc260_moov_meta(tmp_path / "source.mov")
|
||||
output = tmp_path / "clean.mov"
|
||||
|
||||
result = remove_video_metadata(source, output)
|
||||
cleaned = output.read_bytes()
|
||||
|
||||
assert result.detected["aigc_label"].startswith("China AIGC label")
|
||||
# The artwork item's Doubao product JSON is app-export provenance, a
|
||||
# separate signal the TC260 blanker does not touch -- as on the real
|
||||
# Doubao iOS export this fixture mirrors.
|
||||
assert result.remaining == {"app_provenance": "App export provenance (ByteDance Doubao)"}
|
||||
assert len(cleaned) == source.stat().st_size
|
||||
assert _VIDEO_PAYLOAD in cleaned
|
||||
assert _TC260_AIGC not in cleaned
|
||||
|
||||
def test_inspects_native_tc260_in_quicktime_mdir_metadata_list(self, tmp_path: Path):
|
||||
# The second Doubao iOS variant: a QuickTime metadata list under
|
||||
# udta.meta with hdlr=mdir and ilst data items but no keys box.
|
||||
from remove_ai_watermarks.video import inspect_video_metadata
|
||||
|
||||
source = _video_with_tc260_mdir_list(tmp_path / "source.mov")
|
||||
|
||||
report = inspect_video_metadata(source)
|
||||
|
||||
assert report.has_ai_metadata is True
|
||||
assert "aigc_label" in report.markers
|
||||
|
||||
def test_removes_native_tc260_in_quicktime_mdir_metadata_list(self, tmp_path: Path):
|
||||
from remove_ai_watermarks.video import remove_video_metadata
|
||||
|
||||
source = _video_with_tc260_mdir_list(tmp_path / "source.mov")
|
||||
output = tmp_path / "clean.mov"
|
||||
|
||||
result = remove_video_metadata(source, output)
|
||||
cleaned = output.read_bytes()
|
||||
|
||||
assert result.detected["aigc_label"].startswith("China AIGC label")
|
||||
assert result.remaining == {}
|
||||
assert len(cleaned) == source.stat().st_size
|
||||
assert _VIDEO_PAYLOAD in cleaned
|
||||
# The keyless entry has no key name to blank; only the JSON is spaced
|
||||
# out, and the neighboring standard ilst item survives untouched.
|
||||
assert _TC260_AIGC not in cleaned
|
||||
assert b"vid:standard-video-id" in cleaned
|
||||
|
||||
def test_streams_large_isobmff_without_full_file_read(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
@@ -849,6 +951,7 @@ class TestVideoMetadataCli:
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "AI metadata detected" in result.output
|
||||
assert "not the same as 'clean'" not in result.output
|
||||
|
||||
def test_remove_reports_output(self, tmp_path: Path):
|
||||
runner = CliRunner()
|
||||
@@ -859,6 +962,7 @@ class TestVideoMetadataCli:
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "AI metadata stripped" in result.output
|
||||
assert "not the same as 'clean'" in result.output
|
||||
assert C2PA_UUID not in output.read_bytes()
|
||||
|
||||
def test_rejects_image_input(self, tmp_clean_png: Path):
|
||||
@@ -1894,6 +1998,25 @@ class TestAdditionalProviderTemporalArbiter:
|
||||
assert stabilize(weak) == [None] * 12
|
||||
assert stabilize(strong) == [box] * 12
|
||||
|
||||
def test_hailuo_provenance_accepts_sub_strong_stable_run(self):
|
||||
# A TC260 label naming MiniMax relaxes only the strong-frame requirement:
|
||||
# the entry bar stays the measured weak floor (0.30), so a stable run
|
||||
# between weak and strong (0.34) passes with provenance, fails without.
|
||||
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_localizations
|
||||
|
||||
detections = [FrameLocalization(index, 0.31, self._HAILUO_BOX) for index in range(12)]
|
||||
|
||||
assert stabilize_localizations("hailuo", detections, provenance=False) == [None] * 12
|
||||
assert stabilize_localizations("hailuo", detections, provenance=True) == [self._HAILUO_BOX] * 12
|
||||
|
||||
def test_hailuo_provenance_requires_a_minimax_producer_label(self):
|
||||
from remove_ai_watermarks.video_visible import has_hailuo_video_provenance
|
||||
|
||||
assert has_hailuo_video_provenance({"aigc_producer": "MiniMax"})
|
||||
assert not has_hailuo_video_provenance({"aigc_producer": "001191110102MACQD9K64010000"})
|
||||
assert not has_hailuo_video_provenance({"aigc_label": "China AIGC label (TC260); producer MiniMax"})
|
||||
assert not has_hailuo_video_provenance({"issuer": "Some other vendor"})
|
||||
|
||||
|
||||
class TestVideoVisibleScan:
|
||||
def test_auto_prepares_each_frame_once_for_every_detector(
|
||||
|
||||
Reference in new issue
Block a user