mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-31 09:40:38 +02:00
Merge remote-tracking branch 'origin/main' into docs/arxiv-paper-review
# Conflicts: # .claude/settings.json # docs/installation.md # docs/supported-signals.md # docs/synthid.md # docs/verification-plan.md # docs/watermarking-landscape.md # pyproject.toml # src/remove_ai_watermarks/identify.py # uv.lock
This commit is contained in:
+68
-5
@@ -17,7 +17,7 @@ defaults. This page focuses on choosing the right command.
|
||||
| `verify-openai-synthid` | `remove-ai-watermarks[verify]`, API access, and `OPENAI_API_KEY` |
|
||||
| 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]` |
|
||||
@@ -27,7 +27,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]`.
|
||||
@@ -41,7 +41,15 @@ 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. Confidence follows the
|
||||
binding: an intact asset binding and claim signature is high confidence, and an
|
||||
unanchored or expired signer is reported as a caveat rather than a lower score,
|
||||
because no trust anchor list ships with the reader. A failed asset binding or
|
||||
signature, or a revoked signing credential, 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:
|
||||
|
||||
@@ -191,6 +199,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.
|
||||
@@ -259,11 +272,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,
|
||||
@@ -432,6 +451,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:
|
||||
@@ -463,7 +526,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`.
|
||||
|
||||
@@ -524,7 +524,7 @@ held by its masked ArcFace vector -- no original pixel copied.
|
||||
drift, NOT a perfect face swap. Set expectations; PuLID/InstantID are the higher-fidelity (heavier)
|
||||
paths if needed.
|
||||
- **Value scales with strength:** at low strength (OpenAI 0.10) faces barely drift, so FaceID is
|
||||
marginal; at the higher strength a hard vendor (Google 0.30) needs, FaceID earns its keep.
|
||||
marginal; at the higher strength then used for a hard vendor (Google 0.30), FaceID earned its keep.
|
||||
|
||||
### Build plan (staged)
|
||||
|
||||
|
||||
+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, provenance, robustness, regeneration. |
|
||||
|
||||
|
||||
+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,13 +108,14 @@ 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 |
|
||||
| `verify` | Official remote OpenAI SynthID verification | OpenAI SDK | No |
|
||||
| `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:
|
||||
@@ -119,14 +129,18 @@ flowchart LR
|
||||
migan --> visible
|
||||
lama --> visible
|
||||
qwen["qwen-zimage"] --> diffusion
|
||||
text["text-restoration"] --> qwen
|
||||
text --> lama
|
||||
heif
|
||||
trustmark
|
||||
verify
|
||||
```
|
||||
|
||||
`heif`, `trustmark`, and `verify` 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:
|
||||
|
||||
@@ -146,7 +160,7 @@ uv tool install --force "remove-ai-watermarks[detect,trustmark]"
|
||||
# Official OpenAI SynthID verification
|
||||
uv tool install --force "remove-ai-watermarks[verify]"
|
||||
|
||||
# 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).
|
||||
|
||||
### There is no local SynthID pixel detector in the package
|
||||
|
||||
@@ -114,6 +144,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`
|
||||
@@ -134,9 +179,14 @@ path has not yet answered.
|
||||
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.
|
||||
`qwen-zimage` reads unknown content from the resolution-adaptive denoise curve.
|
||||
Measured provider cohorts instead take flat operating points: OpenAI `0.07675`,
|
||||
Google `0.27`, and Microsoft InvisMark `0.15`. OpenAI and Microsoft add one full
|
||||
observed cross-source boundary spread to the worst clean source; Microsoft's three
|
||||
first-clean boundaries were `0.04125`, `0.055`, and `0.095`, giving `0.14875` before
|
||||
rounding up. Google's candidate was separately repeated across three valid sources
|
||||
and three accounts. The small corpora make these operating points, not universal
|
||||
thresholds.
|
||||
|
||||
`sdxl-zimage` reads it from the C2PA issuer, on a flat ladder:
|
||||
|
||||
@@ -162,6 +212,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`,
|
||||
@@ -346,8 +405,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).
|
||||
|
||||
A generic metadata-free AI-generated-image classifier is not shipped. It is a
|
||||
separate open research task from provenance detection: the current Model 1
|
||||
|
||||
+208
-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,6 +375,75 @@ 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, so those stay separate fields and separate
|
||||
caveats -- but they do not lower confidence. `identify` assigns high confidence
|
||||
when the asset binding and the claim signature both validate, medium confidence
|
||||
to a claim that validated nothing (fallback parsing, unknown dimensions), and no
|
||||
origin verdict to a binding failure, a signature failure, or a revoked signing
|
||||
credential. 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.
|
||||
|
||||
Revocation reaches the disqualifying branch through `signer_validity`, not through
|
||||
binding or signature. A check that read only the latter two returned a confident AI
|
||||
verdict off a credential the issuer had disowned, with an empty `integrity_clashes`
|
||||
-- quieter than a hash mismatch on the same file. Certificate expiry is deliberately
|
||||
not disqualifying: an expired certificate does not imply the signed bytes changed,
|
||||
and a signature actually made outside validity already arrives as
|
||||
`claimSignature.outsideValidity`.
|
||||
|
||||
One rule, one place. `_validation_fields` maps status codes to the four dimensions
|
||||
and also emits `c2pa_failed_codes`, the subset of failures that actually drove a
|
||||
dimension to invalid; `c2pa_info_has_invalid_credential` maps those dimensions to
|
||||
disqualified, and both the ingredient-reachability walk and the report consume that
|
||||
single path. The displayed reason comes from `c2pa_failed_codes` rather than a
|
||||
substring rescan of the full code list, so what a caller is shown as the cause
|
||||
cannot become a looser rule than the verdict it explains.
|
||||
|
||||
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 every otherwise valid signer chain as untrusted --
|
||||
`signingCredential.trusted` appears in no default installation, from any vendor.
|
||||
Confidence therefore must not depend on it. It did from 0.27.0 through 0.30.0, which made
|
||||
the high-confidence branch unreachable in production while a hand-built fixture
|
||||
stamping `signingCredential.trusted` kept it green in the suite; measured on the
|
||||
committed provenance fixtures, every C2PA file from OpenAI, Adobe and Black Forest
|
||||
Labs came back untrusted, and an intact manifest scored the same medium as a
|
||||
fallback parse that validated nothing. That collapsed the one distinction the
|
||||
official reader exists to draw.
|
||||
`tests/test_identify.py::TestIdentifyRealSamples::test_no_committed_fixture_reports_a_trusted_signer`
|
||||
is the reachability guard: it asserts the fixtures really are untrusted and still
|
||||
reach high confidence.
|
||||
|
||||
Read `untrusted` here as a missing input, not a finding: nothing was checked,
|
||||
because there was nothing to check against. If a maintained trust bundle is ever
|
||||
configured, that stops being true and the confidence mapping in
|
||||
`_c2pa_credential_level` must be re-read, because only then does a failed trust
|
||||
check mean the signer was rejected. 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.
|
||||
@@ -930,22 +1010,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
|
||||
|
||||
@@ -1111,6 +1251,11 @@ are CUDA-only. `controlnet`, `sdxl`, `qwen` and `default` were removed rather th
|
||||
kept as a CPU path, and are rejected rather than aliased onward. There is no
|
||||
content-dependent automatic router.
|
||||
|
||||
`qwen-zimage` normally resolves global denoise from image area for unknown content.
|
||||
Measured provider cohorts bypass that curve with flat operating points. The values,
|
||||
measurement derivations, and corpus limits are canonical in
|
||||
[`known-limitations.md`](known-limitations.md#strength-is-content-and-seed-dependent).
|
||||
|
||||
For serverless cold starts, `InvisibleEngine.preload(global_only=True)` loads the
|
||||
mandatory global stage and YuNet while leaving the optional Z-Image and SAM face
|
||||
stack lazy until a face is detected. The default `preload()` still loads every
|
||||
@@ -1240,6 +1385,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)
|
||||
|
||||
+85
-1
@@ -172,8 +172,34 @@ 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 is a high-confidence signed claim; an
|
||||
unanchored or expired signer appears in `caveats` and in these fields, not as a
|
||||
lower confidence, because the reader ships no trust anchors to check against and
|
||||
`signer_trust` is therefore a missing input rather than a finding. A hash or
|
||||
signature failure, or a revoked signing credential, does not confirm the claimed
|
||||
platform or AI origin.
|
||||
|
||||
A consumer must read `integrity_clashes`. When a credential fails validation,
|
||||
`is_ai_generated` becomes `None`, because a claim that cannot be tied to these
|
||||
bytes cannot establish origin -- the manifest may have been transplanted from a
|
||||
real AI image onto anything -- and the failure is reported in
|
||||
`integrity_clashes` instead. That is a different question from whether an AI
|
||||
watermark is physically present in the pixels, which is what
|
||||
`has_invisible_target` answers, and it stays fail-safe `True` on the same file.
|
||||
Reading only `is_ai_generated` turns a broken vendor manifest into silence.
|
||||
|
||||
`c2pa_validation["state"]` is the reader's own aggregate and is carried for
|
||||
diagnostics only; no verdict is derived from it, because it collapses a
|
||||
transplanted manifest and a merely expired certificate into one `Invalid`, and
|
||||
its `Trusted` level depends on anchors no default installation has. 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:
|
||||
|
||||
@@ -241,6 +267,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
|
||||
@@ -439,7 +470,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
|
||||
@@ -604,6 +636,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,7 +58,16 @@ PyPI API token from the repository.
|
||||
waits for the matching source distribution to appear on PyPI, then:
|
||||
|
||||
- updates the Homebrew tap formula URL and SHA-256;
|
||||
- triggers a factory rebuild of the Hugging Face Space.
|
||||
- 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.
|
||||
|
||||
@@ -100,4 +124,20 @@ After publication, verify:
|
||||
- the Homebrew formula points to the new source distribution;
|
||||
- the distribution workflow completed successfully;
|
||||
- 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
|
||||
@@ -80,11 +80,19 @@ 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 an intact asset binding and
|
||||
claim signature; signer trust and certificate expiry are reported as their own
|
||||
dimensions and as caveats, since no trust anchor list ships to evaluate them.
|
||||
Fallback claims, which validate nothing, remain medium-confidence, while a failed
|
||||
binding or signature or a revoked credential contributes no origin verdict.
|
||||
|
||||
## File and container formats
|
||||
|
||||
@@ -143,6 +151,20 @@ of C2PA for its decision, but it is not local: the sanitized raster is uploaded
|
||||
to OpenAI after explicit acknowledgement. It is intentionally excluded from
|
||||
`identify` and its negative result remains 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
|
||||
@@ -162,11 +184,13 @@ not a universal clean verdict.
|
||||
| --- | --- | --- | --- |
|
||||
| Google Gemini | Sparkle | Diffusion regeneration | 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 | Official remote pixel verifier; diffusion regeneration | C2PA and generator provenance |
|
||||
| OpenAI image generators | None registered | Official remote pixel verifier; 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 |
|
||||
| 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 |
|
||||
|
||||
+5
-5
@@ -891,8 +891,8 @@ study (section 2.2) gives empirical floors:
|
||||
> were removed, and `OPENAI_STRENGTH` / `GEMINI_STRENGTH` / `UNKNOWN_STRENGTH` went
|
||||
> with them. Everything from here to the end of this section is a record of what was
|
||||
> measured on those profiles, kept because the oracle verdicts are still the evidence
|
||||
> base. For the strength policy in force now see `module-internals.md`: `qwen-zimage`
|
||||
> uses `resolution_adaptive_denoise`, `sdxl-zimage` a flat vendor ladder.
|
||||
> base. For the strength policy in force now see
|
||||
> [`known-limitations.md`](known-limitations.md#strength-is-content-and-seed-dependent).
|
||||
|
||||
The default was **vendor-adaptive** (`watermark_profiles.resolve_strength` +
|
||||
`vendor_for_strength`): the tool read the C2PA issuer on the original input and picked
|
||||
@@ -1048,9 +1048,9 @@ solid at 0.10 but at 0.15 it is near-threshold noise; (2) for reliable removal p
|
||||
a strength with MARGIN above the borderline (controlnet >= 0.20), not exactly on
|
||||
it; (3) **historical engineering conclusion:** this dated run argued for a
|
||||
higher ControlNet strength than the then-current default. That proposal was
|
||||
later superseded. The current resolver intentionally shares the 0.10/0.15
|
||||
ladder between SDXL and ControlNet and uses a separate Qwen ladder; see
|
||||
`_internal/watermark_profiles.py`.
|
||||
later superseded. The resolver of that period shared the 0.10/0.15 ladder between
|
||||
SDXL and ControlNet; the current policies are recorded in
|
||||
[`known-limitations.md`](known-limitations.md#strength-is-content-and-seed-dependent).
|
||||
Source images are private (faces / product shots), not committed; reproduce on any
|
||||
photoreal + flat-graphic gpt-image pair, varying the seed, and re-checking the
|
||||
oracle.
|
||||
|
||||
@@ -20,8 +20,8 @@ filter applied to every candidate below.
|
||||
## Problem recap
|
||||
|
||||
The `invisible` pipeline is SDXL base 1.0 img2img to defeat SynthID. The default
|
||||
strength has risen over time as Google hardens SynthID (0.05 -> 0.10 -> **~0.30**, the
|
||||
current threshold for fresh Gemini output); higher strength deforms text more, which is
|
||||
strength rose over time as Google hardened SynthID (0.05 -> 0.10 -> **~0.30** in
|
||||
this experiment; the later measured operating point is 0.27); higher strength deforms text more, which is
|
||||
exactly why text protection matters. Text is protected via Differential Diffusion with a
|
||||
per-pixel change map (`preserve` ~0.9) driven by the PP-OCRv3 DB detector
|
||||
(`text_protector.py`). Large text survives; **small text (sub ~8 px strokes) softens or
|
||||
@@ -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,11 +186,14 @@ report recall from the detector-sampled set.
|
||||
|
||||
## Tier D -- external oracles
|
||||
|
||||
SynthID removal cannot currently be verified locally because no public decoder weights
|
||||
exist. Each vendor has its own oracle and it covers only that vendor's content. OpenAI
|
||||
documents a synchronous Content Provenance API with a distinct SynthID result, while the
|
||||
Gemini app provides a manual, quota-limited Google verifier. 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.
|
||||
|
||||
OpenAI's API documentation says not to use repeated queries to reverse-engineer, remove,
|
||||
or evade a watermark. Using it as an adaptive research oracle therefore requires explicit
|
||||
@@ -189,6 +201,7 @@ authorization. Without that authorization it must not become a training loss, se
|
||||
or automated removal gate. The provider-specific detector and pixel-only removal research
|
||||
protocol is in [`synthid-detector-removal-plan.md`](synthid-detector-removal-plan.md).
|
||||
|
||||
|
||||
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
|
||||
@@ -28,7 +28,7 @@ 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 / 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.
|
||||
**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).**
|
||||
@@ -45,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
|
||||
@@ -64,12 +64,9 @@ payloads. Removal remuxes either container through ffmpeg with stream copy.
|
||||
may carry IPTC metadata but no registered C2PA or pixel watermark. The open
|
||||
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):**
|
||||
Amazon Titan Image Generator + Nova Canvas (Bedrock
|
||||
`DetectGeneratedContent` API), Kakao (new SynthID image adopter, May 2026),
|
||||
and NVIDIA Cosmos (SynthID video). No public payload decoder is available;
|
||||
these signals have no measured local payload decoder here.
|
||||
- **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).
|
||||
- **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, 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
|
||||
|
||||
Reference in New Issue
Block a user