Unplug lattice detector from package, expand research docs

This commit is contained in:
Victor Kuznetsov
2026-08-23 11:13:56 -07:00
parent 2445086fa4
commit d22872f84d
34 changed files with 2210 additions and 1709 deletions
+5 -4
View File
@@ -79,10 +79,11 @@ rules follow, and both were broken in practice before they were written down:
- Detection and the removal mask must read ONE sweep. The winning box travels on
`TextMarkDetection.match_box` and the registry threads the detection into the mask
builder; a mask path that re-runs its own sweep is how the two drift apart.
- When the default SynthID detector routes by image geometry, preserve the returned
`SynthIDDetection.detector` in score manifests and downstream routers. Record an
inactive expert as explicitly unsupported; never attribute a routed large-image
score to the fixed expert.
- When the research lattice runtime in `scripts/synthid_runtime/` routes by image
geometry, preserve the returned `SynthIDDetection.detector` in score manifests
and downstream routers. Record an inactive expert as explicitly unsupported;
never attribute a routed large-image score to the fixed expert. This expert is
not part of the public package.
The C2PA manifest-store JSON is NOT stable across reads: the reader regenerates manifest
URNs and instance ids. Compare the derived `c2pa_info`, never the raw store.
+5 -54
View File
@@ -30,7 +30,6 @@ removal.
| Goal | Command | GPU |
| --- | --- | --- |
| Find provenance signals and watermarks | `identify` | No |
| Detect a generation-pipeline pixel lattice, experimental, not the watermark | `detect-synthid` | No |
| Verify supported OpenAI SynthID from pixels with the official remote API | `verify-openai-synthid` | No |
| Remove known visible AI marks | `visible` | No |
| Erase a region you select | `erase` | No |
@@ -50,7 +49,6 @@ removal.
| Need | Install |
| --- | --- |
| Metadata inspection and stripping | `remove-ai-watermarks` |
| Local SynthID carrier detection in the calibrated size range | `remove-ai-watermarks[pixels]` |
| Official remote OpenAI SynthID verification | `remove-ai-watermarks[verify]` |
| Visible detection and removal | `remove-ai-watermarks[visible]` |
| Visible video processing | `remove-ai-watermarks[video]` |
@@ -79,56 +77,12 @@ remove-ai-watermarks identify image.png
```
Signed provenance is the supported route for SynthID and `identify` reads it.
The pixel route below is experimental and answers a different question. Install
the pixel runtime first:
There is no local SynthID pixel detector in the package. Research on a
periodic lattice expert is in `scripts/synthid_runtime/` and
[synthid-detector-research.md](docs/synthid-detector-research.md).
```bash
uv tool install --force "remove-ai-watermarks[pixels]"
remove-ai-watermarks detect-synthid image.png
remove-ai-watermarks detect-synthid native-period.png --fixed-period
```
This is experimental and does not detect the SynthID watermark. The statistic it reports is
destroyed by cropping off the tile grid — a seven-pixel crop on the large branch
and a two-pixel crop on the ordinary-size registered-v3 route — while SynthID's
published evaluation keeps 99.97% of its detection rate under aggressive crop
and resize, so a positive identifies the generation pipeline rather than the
mark. Measured on 2026-08-16: it accepted 8 of 11 fresh large Google generations
and 29 of 223 images from other generators, and 0 of 6 when the same positives
were cropped off the tile grid; a two-pixel crop also removed all 36 tested
registered-v3 detections across foreign-generator and Google images. It is
positive-only
and limited to one measured lattice family in
the [calibrated image-size range](docs/synthid.md#32-how-our-tool-detects-the-supported-carrier).
The production default uses registered-v3 from 250,000 through 10,000,000
decoded pixels with both sides at least 256 pixels. When that route abstains,
a narrower opponent-registered-v1 fallback covers 1 through 10 megapixels,
both sides at least 768 pixels, and carrier periods 7.9 through 12.0. Period-8
candidates additionally require a decoded-pixel block-edge veto that separates
the measured carrier from ordinary JPEG lattices. The
separately challenged opponent-color large-v1 branch runs
above 10 through 18 megapixels; large-v1 requires both sides to be at least
2,048 pixels. Registered-v3 is a bounded scale search with a measured positive
scale range of approximately 0.65 through 1.5. The fallback recovered 49/49
losslessly resized views at scales 0.5 through 0.75 from seven official
pixel-positive parents. The period-8 veto rejected all 1,790 previously crossing
codec-lattice controls, while 350 matched 0.5x Picsum controls had no base
crossing; the earlier period-band rule also accepted 0/1,000 frozen controls.
`--fixed-period` explicitly selects the faster legacy 16-pixel diagnostic; it
is not a production positive route. `--register-scale` forces the registered
expert, including on geometry where the default would choose large-v1.
`identify` uses the production router. `indeterminate` means no qualified local
expert crossed its threshold; `unsupported` means no expert covers the input.
Neither is a clean-image guarantee.
The large native and opponent-registered branches are not recompression-robust.
The large branch lost all seven official positives after same-size JPEG-95 and
JPEG-90; the fallback retained 0/63 JPEG-95, JPEG-85, and WebP-95 views. Use
them for original or losslessly copied pixels, and treat a miss after lossy
transcoding as inconclusive.
For supported OpenAI images, the optional official verifier provides a broader
pixel-watermark verdict than the incomplete local OpenAI research signal:
For supported OpenAI images, the optional official verifier provides a pixel
watermark verdict:
```bash
uv tool install --force "remove-ai-watermarks[verify]"
@@ -399,9 +353,6 @@ import remove_ai_watermarks as raiw
result, removed = raiw.remove_visible("watermarked.png", "clean.png")
print(removed)
synthid = raiw.detect_synthid("image.png")
print(synthid.status, synthid.score)
openai_synthid = raiw.verify_openai_synthid("image.png", acknowledge_upload=True)
print(openai_synthid.status)
+3 -54
View File
@@ -14,7 +14,6 @@ defaults. This page focuses on choosing the right command.
| Command or signal | Required installation |
| --- | --- |
| `metadata` and metadata-only `identify` | Default package |
| `detect-synthid` and the calibrated-size SynthID pixel signal in `identify` | `remove-ai-watermarks[pixels]` |
| `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]` |
@@ -57,58 +56,8 @@ remove-ai-watermarks identify image.png --no-visible
```
Despite the historical option name, `--no-visible` skips all pixel detectors,
including the pipeline lattice described below, visible marks, open DWT-DCT, and
TrustMark. Metadata inspection still runs.
## Detect the generation-pipeline pixel lattice (experimental)
```bash
remove-ai-watermarks detect-synthid image.png
remove-ai-watermarks detect-synthid image.png --json
remove-ai-watermarks detect-synthid native-period.png --fixed-period
```
This route is experimental. Signed provenance, read by `identify` and confirmed
against the provider by `verify-openai-synthid`, remains the supported way to
establish SynthID. The command returns one of `detected`, `indeterminate`, or
`unsupported`, and it does not detect the SynthID watermark: its statistic disappears when the image
is cropped off the tile grid, and it changes when the generator's pipeline
changes, so read a positive as evidence about the pipeline and never as a
watermark claim. The JSON carries `identifies_watermark` and
`tile_aligned_crop_required` for exactly this reason. The
runtime detector covers one frozen periodic lattice family in the
[calibrated image-size range](synthid.md#32-how-our-tool-detects-the-supported-carrier)
and needs the `pixels` extra. The production default uses registered-v3 from
250,000 through 10,000,000 decoded pixels with both sides at least 256 pixels.
An opponent-registered-v1 fallback covers 1 through 10 megapixels, both sides
at least 768 pixels, and selected carrier periods 7.9 through 12.0. Period-8
candidates must also pass an opponent-color block-edge veto for the native JPEG
lattice. The
separately challenged opponent-color large-v1 branch covers above 10,000,000
through 18,000,000 pixels when both sides are at least 2,048 pixels.
Registered-v3 performs a bounded carrier-period search and independent split-
patch confirmation. Its measured positive
scale range is approximately 0.65 through 1.5. The fallback recovered 49/49
lossless 0.5x-0.75x views from seven official positives. Its period-8 veto
rejected 1,790 codec-lattice crossings, and 350 matched 0.5x controls produced no
base crossing. The earlier period-band rule accepted 0/1,000 post-freeze Picsum
controls. `identify` uses this production router.
`--fixed-period` explicitly selects the faster legacy fixed-v2 diagnostic below
10 megapixels. It does not resize or register the carrier and is not a
production positive route. `--register-scale` forces the registered-v3 cascade,
including its opponent fallback, on geometry where the default would select
large-v1.
The native large and opponent-registered branches are codec-sensitive. The
large branch fell from 7/7 to 0/7 after same-size JPEG-95 or JPEG-90; the
fallback retained 0/63 JPEG-95, JPEG-85, and WebP-95 views. A miss on a lossy
re-encode is therefore inconclusive.
It is positive-only: `indeterminate` means the score stayed below this
detector's threshold, while `unsupported` means the image geometry is outside
its scope. Neither result proves that another SynthID epoch or payload is
absent.
JSON output includes the exact reason plus provider scope, backend, pixel
preservation, and metadata-use audit fields.
including visible marks, open DWT-DCT, and TrustMark. Metadata inspection still
runs.
## Verify OpenAI SynthID from pixels
@@ -140,7 +89,7 @@ error code, request id, `Retry-After`, and whether an explicit caller-controlled
retry is appropriate through `OpenAIProvenanceError`; the verifier itself never
retries an upload.
The JSON result uses the same provider-scope, backend, pixel-preservation, and
metadata-use audit fields as the local detector.
metadata-use audit fields as `verify_openai_synthid`.
The Python API enforces the same boundary with the required explicit intent
flag `verify_openai_synthid(path, acknowledge_upload=True)`.
+1 -1
View File
@@ -24,7 +24,7 @@ to run the tool. Use the maintainer references only when changing the code.
| [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. |
| [Watermarking landscape](watermarking-landscape.md) | Vendor signals and detection approaches. |
| [SynthID technical reference](synthid.md) | Mechanism, shipped lattice and provenance, robustness, regeneration. |
| [SynthID technical reference](synthid.md) | Mechanism, provenance, robustness, regeneration. |
## Research archive
+7 -66
View File
@@ -64,73 +64,14 @@ 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.
### The experimental pixel route reads a pipeline lattice, not the watermark
### There is no local SynthID pixel detector in the package
Signed provenance is the primary and supported route for SynthID in this
project; the pixel route described here is experimental. Google does not
publish the proprietary SynthID payload decoder, and nothing
in this project reproduces one. What the local pixel route detects is a
periodic lattice anchored at the image origin: it is destroyed by a crop of
seven pixels on the large branch and by a crop of two pixels on the
ordinary-size registered-v3 route, whereas the published SynthID evaluation
retains 99.97% of its detection rate under aggressive crop and resize. A
positive therefore
identifies the generation pipeline that leaves the lattice, and says nothing
about whether the image carries a watermark. It also changes when a provider
changes pipeline: output from `gemini-2.5-flash-image` scores at the level of
unrelated photographs. The project additionally offers provenance-based
recognition from Google AI C2PA or current OpenAI C2PA with an explicit
watermark action, and that provenance route is the only one that speaks about
a watermark at all.
The ordinary-size route shares the same phase lock. On 2026-08-16 a two-pixel
diagonal crop removed all 28 in-geometry foreign-generator detections and all 8
detected Google provenance positives from registered-v3, with the statistic
recovering only at crop offsets that are multiples of four. The route therefore
fires on other vendors' images because those pipelines leave a matching
origin-anchored lattice, not because a watermark is present.
The published control rates for this route were all measured on photographs.
Against other generators' output, which is the population a provenance tool is
actually pointed at, the shipped runtime accepted 29 of 223 signed non-Google
images on 2026-08-16: 13.0% overall, 24.1% on Adobe Firefly, with a top score
of 3.01 against a threshold of 1.0. Treat a positive as evidence only when the
alternative generators have been ruled out by other means.
Sensitivity has only ever been reported on images the route was calibrated
against. The first out-of-distribution measurement was taken on 2026-08-16: 11
images generated at 5632x3072 by `gemini-3.1-flash-image`, a model that did not
exist when the large branch was calibrated, written straight from the API with
no lossy-codec history and watermarked by Google's documented policy that all
generated images carry SynthID. Eight were detected and three returned
indeterminate, a rate of 72.7% with a one-sided 95% lower bound of 43.6%. The
sample is small and one provider wide, so read it as an order of magnitude
rather than an operating point.
Cropping the same images to 4096x2560 on the tile grid retained 4 of 6. Taking
the identical crop seven pixels off the grid retained 0 of 6, with every score
negative. A crop that is not tile-aligned removes the signal entirely, which is
the sharpest way to see that this route does not read the watermark: the
published SynthID evaluation keeps 99.97% of its detection rate under
aggressive crop and resize.
The route's declared scope is narrower still. It does not cover images outside
the selected mode's size range, crop, strong
JPEG compression, video, or future carrier epochs. The production default
searches a bounded fractional-period range from 250,000 through 10,000,000
pixels and requires both sides to be at least 256 pixels. Its measured positive
range is approximately scale 0.65 through 1.5. A precision-first opponent-color
fallback covers only 1 through 10 megapixels, sides of at least 768 pixels, and
periods 7.9 through 12.0; it recovered the measured lossless 0.5x-0.75x cohort.
At period 8, an additional block-edge veto rejects the ordinary JPEG lattice;
this does not make arbitrary crops or codecs supported. The fallback retained
0/63 lossy JPEG/WebP views, so a lossy miss remains inconclusive. Above 10
through 18
megapixels, the separate large-v1 route requires native, lossless pixels and
both sides at least 2,048 pixels. The fixed-v2 branch is retained only behind
the explicit `detect-synthid --fixed-period` diagnostic. A
`indeterminate` or `unsupported` result is not a negative universal verdict, and
removal still requires the matching provider oracle for confirmation.
Google does not publish the proprietary SynthID payload decoder, and the
package does not ship one. Signed provenance is the supported route:
Google AI C2PA or current OpenAI C2PA with an explicit watermark action.
`verify-openai-synthid` is the official remote pixel check for OpenAI.
Research on a periodic lattice expert is in
[synthid-detector-research.md](synthid-detector-research.md).
For important outputs:
+13 -10
View File
@@ -451,16 +451,20 @@ metadata extraction from verdict logic:
both extractors reach the same answer. It did not, and the record path silently
reported no SynthID for images the file path flagged.
- `identify` preserves the path-based API and adds the optional registered
visible-mark, open invisible-watermark, and generation-pipeline lattice
detectors after extraction.
visible-mark and open invisible-watermark detectors after extraction.
### SynthID periodic carrier detector
### SynthID periodic carrier detector (research only)
[`synthid_detector.py`](../src/remove_ai_watermarks/synthid_detector.py) is the
The local lattice expert is not part of the public package. Runtime code lives
in [`scripts/synthid_runtime/`](../scripts/synthid_runtime/) and the campaign
log is [`synthid-detector-research.md`](synthid-detector-research.md). The
notes below are the calibration history of that research expert.
[`synthid_detector.py`](../scripts/synthid_runtime/synthid_detector.py) is the
runtime form of the frozen 2048x2048 periodic-tile experiment. It folds a
Gaussian high-pass residual modulo 16x16 within a calibrated pixel-count range
and compares the normalized RGB tile with the bundled float64 template
`assets/synthid_periodic_tile_2048_v1.npz`. Exact multiples use the original
`scripts/synthid_runtime/synthid_periodic_tile_2048_v1.npz`. Exact multiples use the original
reshape-and-mean path; other sizes use count-correct modulo folding,
without resize. Channels are filtered and folded sequentially, and partial edge
blocks are accumulated without a full-frame padding buffer so the 18-megapixel
@@ -644,8 +648,8 @@ Every one of those control rates is photographic. Against 223 corpus images
whose C2PA names a non-Google generator, the unchanged entry point accepted 29
(`0.130`, Adobe Firefly `0.241`, highest foreign score `3.01`), all from
registered-v3. The branch reads a lattice shared across generation pipelines,
which is why `identify` reports it as the experimental `pipeline_lattice`
signal and never as a watermark.
which is why it must not be reported as a watermark. The public `identify`
path no longer calls this expert.
The branch is also phase-locked to the image origin, exactly like the large
expert. A two-pixel diagonal crop killed all 28 in-geometry foreign detections
@@ -668,9 +672,8 @@ rule accepted 0/1,000 post-freeze Picsum controls. Period 12.8 remains excluded,
and lossy JPEG/WebP views remain inconclusive.
The runtime precedence is registered-v3, the bounded opponent fallback, then
large-v1 above 10 megapixels. Passing
`register_scale=False`, or the CLI's `--fixed-period`, selects the legacy fixed
diagnostic explicitly. The research bank in
large-v1 above 10 megapixels. Passing `register_scale=False` selects the legacy
fixed diagnostic explicitly. The research bank in
`scripts/synthid_routed_expert_bank.py` keeps all three observations for audits.
Neither runtime nor research routing returns a clean-image verdict.
+6 -41
View File
@@ -12,52 +12,18 @@ path that still runs on CPU and combines `video` and `diffusion`. Add `heif`
independently when path-based pixel APIs must decode HEIC, HEIF, or AVIF. See
the complete [feature-extra matrix](installation.md#feature-extras).
## Detect the generation-pipeline lattice (experimental)
Install `remove-ai-watermarks[pixels]`, then call the lazy top-level API:
## Verify OpenAI SynthID
```python
import remove_ai_watermarks as raiw
result = raiw.detect_synthid("input.png")
print(result.status) # "detected" | "indeterminate" | "unsupported"
print(result.score) # float for a supported image size, otherwise None
print(result.threshold) # frozen operating point
print(result.reason) # support or non-detection reason, otherwise None
# Force the legacy native-period diagnostic only when auditing that expert.
fixed_diagnostic = raiw.detect_synthid("native-period.png", register_scale=False)
result = raiw.verify_openai_synthid("input.png", acknowledge_upload=True)
print(result.status)
```
This is experimental and is not a watermark detector. Signed provenance through
`identify` is the supported SynthID route. Its statistic is destroyed by a seven-pixel
crop while the published SynthID evaluation survives aggressive crop and
resize, so a positive identifies the generation pipeline. The result exposes
`identifies_watermark` and `tile_aligned_crop_required` so a caller cannot
reach the wrong conclusion from `status` alone. It is positive-only and covers
one measured periodic lattice family
in the [calibrated image-size range](synthid.md#32-how-our-tool-detects-the-supported-carrier).
The default and `identify` use registered-v3 over 250,000 through 10,000,000
decoded pixels, with both sides at least 256 pixels. An opponent-registered-v1
fallback covers 1 through 10 megapixels, sides of at least 768 pixels, and
periods 7.9 through 12.0; period-8 candidates also require the opponent-color
block-edge codec veto. Large-v1 covers 10 through 18 megapixels. Each score
uses a threshold of `1.0`; registered-v3 also requires independent split-patch
confirmation. The fallback is qualified for measured lossless 0.5x-0.75x
views, not lossy retranscodes.
`register_scale=True` forces the registered-v3 cascade, including its bounded
opponent fallback, while `False` explicitly selects the legacy native-period
fixed-v2 diagnostic below the large-image boundary.
`indeterminate` means only that the selected local model did not find its
carrier; `unsupported` means no local expert covers the input geometry. Neither
is proof that the image contains no SynthID watermark.
Both local and official OpenAI JSON results expose `signal_family`,
Official OpenAI JSON results expose `signal_family`,
`provider_scope`, `backend`, `metadata_used_for_verdict`, and
`pixels_preserved`; the local result adds `identifies_watermark` and
`tile_aligned_crop_required`, so callers do not need to infer the evidence boundary from
the detector name.
Remote transport and response failures raise `OpenAIProvenanceError`. Its
`pixels_preserved`. Remote transport and response failures raise `OpenAIProvenanceError`. Its
`status_code`, `error_code`, `request_id`, `retry_after`, and `retryable`
attributes let a caller implement bounded backoff or a circuit breaker without
turning an API outage into a false `not_detected` result. One function call still
@@ -353,8 +319,7 @@ such as a thumbnail and perceptual hash; aggregate feature families do not.
`identify_from_evidence` does not reopen the source file by default: it evaluates
metadata only, and the pixel-backed checks remain in the path-based `identify`
call: registered visible marks, open invisible-watermark decoders, and the
experimental generation-pipeline lattice signal.
call: registered visible marks and open invisible-watermark decoders.
Pass `image_path` together with `check_visible` or `check_invisible` to add those
pixel detectors on top of the SAME evidence. That is how a caller asking one file
+7 -48
View File
@@ -79,9 +79,6 @@ The inspection and stripping code handles signals in these groups:
- xAI and Grok EXIF signature fields;
- Samsung AI editing markers;
- Hugging Face job metadata;
- one positive-only generation-pipeline pixel lattice in a calibrated image-size
range, experimental, which identifies the pipeline and not the SynthID
watermark; signed provenance remains the supported SynthID route;
- open Stable Diffusion style DWT-DCT watermarks with the `detect` extra;
- Adobe TrustMark with the `trustmark` extra.
@@ -128,53 +125,15 @@ Current pipeline values, both CUDA-only:
The `controlnet`, `sdxl`, `qwen` and `default` values were removed. A retired name
is rejected at parse time rather than remapped onto a surviving profile.
Google does not publish the SynthID payload decoder. This project ships a
positive-only detector for one measured periodic image-lattice family in a
calibrated image-size range, available through `detect-synthid`
and the default pixel pass in `identify` when the `pixels` extra is installed.
That lattice is not the watermark. It is anchored at the image origin: a
seven-pixel crop removes it from the large branch and a two-pixel crop removes
it from registered-v3 (all 36 tested detections across foreign-generator and
Google images), while the published SynthID evaluation survives aggressive crop
and resize, so every control rate below describes a generation-pipeline
signature and not watermark detection. Every rate quoted
below was also measured on photographs; on 223 signed non-Google generator
images the same runtime accepted 29, a rate of 13.0%. Sensitivity outside the
calibration distribution was measured once, on 11 fresh 5632x3072 images from
`gemini-3.1-flash-image`: 8 detected, 72.7%, one-sided 95% lower bound 43.6%.
The same images cropped seven pixels off the tile grid returned 0 of 6.
The default ordinary-size route uses registered-v3, including independent
split-patch phase and codeword confirmation. It accepted none of 5,993
supported controls across two nonoverlapping Open Images test cohorts and none
of 2,366 supported controls in a second-family COCO challenge. A precision-first
opponent-registered-v1 fallback covers 1 through 10 megapixels, sides of at
least 768 pixels, and carrier periods 7.9 through 12.0. Period-8 candidates must
also pass an opponent-color block-edge codec veto. It recovered 49/49 lossless
0.5x-0.75x views from seven separate official positives. The veto rejected all
1,790 measured period-8 codec crossings, 350 matched 0.5x controls had no base
crossing, and the earlier period-band rule accepted 0/1,000 post-freeze
controls. Above 10 through
18 megapixels, the production router uses a
separately challenged large branch over phase-aligned windows and opponent-color
phase agreement; both sides must be at least 2,048 pixels. It retained all seven
officially verified large Google pixel positives and accepted none of 2,637
feature-unseen, decoded-pixel-unique natural controls. A smaller post-freeze
Open Images acquisition also produced 0/41 detections. Registered-v3 has a
measured scale range of approximately 0.65 through 1.5; the narrower fallback
adds the measured lossless 0.5x-0.75x range. The large branch retained 0/7
official positives after either
JPEG-95 or JPEG-90 re-encoding, and the opponent-registered fallback retained
0/63 JPEG-95, JPEG-85, and WebP-95 views. Their scope does not include lossy
retranscodes. `detect-synthid --fixed-period` exposes
fixed-v2 only as a legacy diagnostic; its fresh-source false-positive rate
disqualified it as a production positive route. No local production expert
attributes a provider.
Google does not publish the SynthID payload decoder. This package does not
ship a local pixel detector for that watermark. Research on a periodic
lattice expert is in [synthid-detector-research.md](synthid-detector-research.md)
and `scripts/synthid_runtime/`.
The tool also recognizes presence from supported provenance: Google AI C2PA
The tool recognizes presence from supported provenance: Google AI C2PA
under Google's all-media watermark policy, and current OpenAI C2PA carrying an
explicit `c2pa.watermarked.*` action. Legacy OpenAI C2PA without that action
does not assert SynthID. A local pixel result of `indeterminate` or `unsupported`
remains inconclusive for other sizes, epochs, codecs, and payloads.
does not assert SynthID.
The optional `verify-openai-synthid` command is a separate official remote
verifier for supported OpenAI watermarks. It strips AI provenance metadata from
@@ -201,7 +160,7 @@ not a universal clean verdict.
| Provider or family | Visible | Invisible path | Metadata or provenance |
| --- | --- | --- | --- |
| Google Gemini | Sparkle | Local positive-only calibrated-size detector; diffusion regeneration | C2PA and related source signals |
| 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 |
| Stable Diffusion and SDXL | None registered | Diffusion regeneration; optional open decoder | Embedded parameters and text metadata |
+53 -2
View File
@@ -71,8 +71,59 @@ Open, if this head is ever considered for a product cut: a graphics/CGI
abstain. CLIP treats non-camera imagery as generation; that is the remaining
error, not Gemini contamination.
### Wild extras, not SynthID
| Hypothesis | 2026-08-23 | Use |
| --- | --- | --- |
| Missing camera PRNU | Gray `gpt-image-2` highpass RMS 0.25 vs COCO 14.6 | Texture confound. A Wiener PRNU residual on *photographs* vs Model 1 errors is the real test |
| JPEG ELA | COCO 3.13, s1 1.97, gray stamp 0.49 | Export history, leaks PNG vs JPEG, not a provider |
| CFA / Bayer presence | Untested as a *detector* | Camera photos demosaic; many generators do not. Inverse of the Bayer remover arm |
| Double-JPEG ghosts | Untested | ChatGPT download codec fingerprint, not a payload |
| Perfect-circle / text-edge rate | Untested | Graphics abstain for Model 1, not Gemini-vs-OpenAI |
None of these should be named a SynthID score.
## External literature (surveyed 2026-08-23)
AWPD / FSNet ([arXiv:2603.06723](https://arxiv.org/abs/2603.06723)) is
the published "is there any invisible watermark" task. Leave-one-algorithm-out
SynthID Acc 0.894 is *not* Model 1 and *not* a payload decoder. UniFreq's
SynthID split is 2,000 Imagen-API AIGC crops at 256x256, no photographs,
no Firefly, no OpenAI. A head trained that way can pass as watermark
presence while actually reading generator/size texture, which is the L1
failure mode.
Model 1 remains AI-versus-camera on CLIP-L-ft. That is a published
task, not a watermark task. Adjacent papers:
| Source | Claim | Map to Model 1 |
| --- | --- | --- |
| Ojha, Li, Lee, [arXiv:2302.10174](https://arxiv.org/abs/2302.10174) (CVPR 2023, UnivFD) | A classifier trained to see "fake" treats unseen generators as the real sink. Frozen CLIP + nearest neighbor / linear probe generalizes better than a trained CNN | This is the architecture. We finetuned the last two CLIP-L vision blocks instead of freezing, and put Firefly and a locked Open Images fresh set in the gate |
| Cozzolino et al., [arXiv:2312.00195](https://arxiv.org/abs/2312.00195) | CLIP linear probe, few shots from one generator, holds on DALL-E 3 / Midjourney / Firefly | Firefly is the cell we required. Their paper is why Firefly belongs in the test, not as a surprise |
| Corvi et al., [arXiv:2304.06408](https://arxiv.org/abs/2304.06408) | Spectral peaks and mid-high power differences, GAN and diffusion | Generator fingerprint, not a payload. Explains why a Fourier codebook lights up Google *and* Open Images |
| Zhong, Xu, Zou, [arXiv:2601.22778](https://arxiv.org/abs/2601.22778) (DCCT) | Self-supervised color-channel prediction under a Bayer mask; theoretical gap between photo CFA correlations and AIGC | The published "CFA as a camera vote". Untested here. Inverse of the Bayer remover arm |
| Klier and Baier, DFRWS EU 2026 | AI noise is not predominantly additive. Standard PCE vs smartphone PRNU: FPR 61% Firefly Image 4, 100% ChatGPT 5. Center crop kills those false positives without hurting true camera matches | Do not call missing PRNU a SynthID score. If we ever add a Wiener residual, crop and a recorded PCE threshold come with it |
| Popescu and Farid, IEEE Trans. Signal Process. 2005 | CFA interpolation leaves neighbor correlations; splicing breaks them | Classical forgery localization, not generation detection |
| Wang, Wang, Zhang, Owens, Efros, [arXiv:1912.11035](https://arxiv.org/abs/1912.11035) (CVPR 2020, CNNDetect) | Classifier on ProGAN + JPEG/crop aug transfers to many CNNs | The "one generator is enough" claim. Ojha is the correction once diffusion exists |
| Wang et al., DIRE, [arXiv:2303.09295](https://arxiv.org/abs/2303.09295) (ICCV 2023) | Reconstruction error under a frozen diffusion model | Open Model 1 sibling. Needs a GPU diffusion pass per image. Not a watermark |
They do not substitute for `verify-openai-synthid`.
Krawetz's Gemini-chat TPR critique is a verifier-quality claim, not a
feature we can ship. [Lead Stories, 2026-07](https://leadstories.com/analysis/2026/07/google-gemini-synthid-detector-confuses-results-within-same-chat.html)
documented Gemini repeating the first file's SynthID verdict inside a
chat; Google said that was fixed 2026-07-16. The OpenAI provenance API is
a different endpoint.
## Closed: provider names from pixels
After the keyless mark hunt closed, the remaining ask was: given a file
with no metadata, is this OpenAI, Gemini, or not AI, with almost no
errors on camera photographs. That is this section. It is not a SynthID
detector. Firefly, PixelBin, and other generators have to sit in the
test, because a head that only sees OpenAI versus Gemini versus COCO
will call Firefly a provider.
Three-way `openai` / `google` / `other` on Model 1 embeddings fails the
Firefly gate. CLIP-L-ft test accuracy 0.53; Firefly 35/31/18. CLIP-H 0.57;
Firefly 36/33/15. OpenAI versus Gemini AUC on CLIP-L-ft is 0.845; on the
@@ -128,9 +179,9 @@ PixelBin and HuggingFace jobs lean `google` (shared renderer lineage).
FLUX, NovelAI, and Reve stay `no_ai`. Local probe:
`uv run python .local-eval/synthid/prc-oklab-attack-2026-08-15/classify_openai_gemini.py image.png`.
## Production `pipeline_lattice` (google-lineage renderer)
## Research lattice expert (google-lineage renderer)
Experimental signal in `identify`, never a watermark. Production
Not a watermark and not in `identify`. `scripts/synthid_runtime/`
`detect_synthid` re-check on 628 frozen holdouts, seed 20260822, threshold
1.0.
+355 -10
View File
@@ -23,26 +23,363 @@ scene. Keyless energy, TrustMark transfer, L1 distillation, a flat-field
stamp, and a 16-32 band student all failed to read the official oracle
contrast on photographs.
S4, 2026-08-15: the two providers are not doing the same thing. Cross-image
correlation of the folded residual is high for Google (tile16 pos-pos
`+0.326`, chance `0.036`) and at chance for OpenAI (`+0.032`). Google
shares one fixed phase-coherent pattern, also present in its controls at
about half the amplitude. OpenAI shares nothing, which is what a
content-dependent post-hoc encoder produces. Comb experts that work on
Google therefore cannot be reused as an OpenAI watermark detector.
M2, 2026-08-15, re-verified 2026-08-16: Google unwatermarked pairs cannot
be minted. `imagen-*` ids 404 from the model garden. `gemini-*-image`
rejects `addWatermark` (`Cannot find field`). Current Gemini API docs
state that all generated images include a SynthID watermark. There is no
encoder-off Google path.
What the product uses for the *watermark* is signed provenance and
`verify-openai-synthid`. The experimental pixel route in `identify` is
`pipeline_lattice`, a generation-pipeline ID, not SynthID. Lineage rates
for that route are in [classifier models](synthid-classifiers.md).
`verify-openai-synthid`. The periodic-lattice expert is research-only under
`scripts/synthid_runtime/` and is not called from `identify` or the CLI.
Lineage measurements of that expert are in
[classifier models](synthid-classifiers.md).
## Closed detector routes
| Route | Close | Why |
| --- | --- | --- |
| Wavelets / FFT / cepstrum as a single-image detector | 2026-08-09 | TrustMark proxy: 318-d wavelet/spectral summary AUC 0.653 and 0 TPR at a clean calibration cut. Complex FFT maps AUC 0.516. Spatial RGB still won |
| `aloshdenny/reverse-SynthID` V3 phase codebook | 2026-08-09 | Pickle-free numeric audit. 5/5 Google positives, 0/194 then-available negatives. Discovery only: not 0.1% FPR, no same-provider hard negatives |
| `aloshdenny/reverse-SynthID` V4 | 2026-08-13 | Commit `b110836`. Better-of-two profiles: 141/355 Google positives and 191/499 controls. Frozen 1,000 Open Images: 386 accepted. Paired AUC 0.517. Threshold sits 0.02 above chance phase similarity |
| `cebeuq/Synthid-Bypass` as a local decoder | 2026-08-13 | 12 public before/after diffusion pairs. Fixed carrier 10/12 before, 0/12 after. Gemini Verify still saw SynthID on two `before` files the carrier missed. Reconstruction removal, not a payload read |
| S4: one content-independent pattern for both providers | 2026-08-15 | Google has a shared phase-coherent tile. OpenAI is at chance. See Result |
| M2: mint Google encoder-off pairs | 2026-08-15 | No current Google path emits an unwatermarked image. See Result |
| U1: one feature space for both providers | 2026-08-15 | Transfer 0.535 and 0.469 |
| Hand statistics vs a perceptually masked keyed mark (M1b) | 2026-08-15 | At equal RMS, fixed 0.670, keyed 0.669, keyed+masked 0.546. Masking, not keying, defeats hand features |
| Decoder-substitution / foreign VAE as a verified negative | 2026-08-17 | OpenAI oracle stayed `detected` at 22.3 dB. Attacking a positive does not mint a labelled negative |
| `aoxo/reverse-synthid` as extra Google templates | 2026-08-19 | Hugging Face dump scored against the frozen era bank. Extra family means raised foreign scores instead of isolating a mark |
| Origin-locked lattice as SynthID | 2026-08-16 | Two-pixel crop kills it; published SynthID keeps 99.97% TPR under aggressive crop. See classifiers for `google_lineage_renderer` |
| Keyless energy in 16-32 px | 2026-08-20 | L1 AUC 0.53; official decoder reads phase structure, not energy |
| TrustMark / open-encoder transfer | 2026-08-20 | E3 leave-one-encoder-out at chance on TrustMark (0.505 ridge, 0.510 conv) |
| L1 distillation of oracle bits | 2026-08-20 | Geometry-only AUC 0.78-0.83 beats pixel 124-d (0.64). ChatGPT export and `opened` C2PA predict `not_detected`. Inside 1254x1254, permutation p=0.45 |
| Flat 16-32 matched filter | 2026-08-21 | Leave-one-out residual correlation 0.645 on gray flats; 0.007-0.025 on COCO photo residuals. L1 AUC 0.70 was a size confound (size-matched 0.59 / 0.44) |
| `gpt-image-1` as encoder-off pair | 2026-08-21 | Does not stamp. `gpt-image-2` does |
| CNN on raw L1 bits | 2026-08-21 | Do not train. Labels are export geometry and presentation, not the mark |
| Origin-locked lattice as SynthID | 2026-08-16 | Two-pixel crop kills it; published SynthID keeps 99.97% TPR under aggressive crop. See classifiers for the honest `google_lineage_renderer` name |
| 128-photo causal wavelet student on the flat template | 2026-08-21 | Photo 16-32 jam flips only at a=24 / 18.6 dB (`a*_photo < 0.53` failed). A student cannot beat a matched filter if the photo-domain mark is that quiet |
| Photo 16-32 amplitude without the flat-arm template | 2026-08-23 | 11 COCO `gpt-image-2` edits plus the red-dot pair. Residual is the camera JPEG resized onto the edit raster, top-left 32 px dropped. Median 16-32 residual RMS 12.2, same order as 8-16 (9.0) and 32-64 (16.3). On the five tightest pairs (full residual RMS < 16): 16-32 median 2.69, still ~24x the 0.11 flat stamp and not louder than 8-16 (3.81). The photo residual does not isolate a 16-32 codeword |
| Sort-by-luma 1D chroma stego | 2026-08-23 | Flat LOO 0.914 but a luma-order shuffle is already 0.792. COCO plus tiny noise: 0.642 vs shuffle 0.640. The sort invents the correlation |
| Retinex / local-gain then 16-32 LOO | 2026-08-23 | 0.742 vs plain 16-32 LOO 0.752 on the same eight gray flats. No new carrier |
| Distant 16-32 patch coupling | 2026-08-23 | Near lag 0.13 of a tile: -0.013. Far lag ~500 px: +0.054. No nonlocal lock |
| Bispectrum of the 16-32 ring | 2026-08-23 | Degenerate: 1.0 on flats and 1.0 on COCO. Not a statistic |
| Length-255 m-sequence vs 16x16 fold | 2026-08-23 | Max abs corr 0.059. Chance for n=255 is `1/sqrt(255) ≈ 0.063` |
| Row-as-audio pitch peak | 2026-08-23 | Peak-to-mean 51 on flats, 46 on COCO noise residuals. Shared FFT peakiness, not a pitch |
| 16x16 origin search of the flat 16-32 template on photos | 2026-08-23 | Zero-shift NCC 0.006-0.024, best of 256 shifts 0.013-0.026. Origin is not hiding a lock |
Open: Google (no oracle); `chatgpt-image-latest` once the organization is
verified. A size-specific whitened 16-32 template on photographs is not
justified until amplitude is measured without the flat-arm G.
## Information budget on photographs (2026-08-21)
On 11 `gpt-image-2` photo edits, the flat-derived 16-32 template at full
amplitude against per-image whitening has median `d' = 0.93` (range
0.68-2.46). That is a best-case single-image AUC of about 0.75 even with a
perfect, perfectly aligned template. A P5 gate (FPR 0.1%, TPR 90%) needs
`d' >= 4.37`. The deficit is 4.7x in amplitude, 13.4 dB, *unless* the
photo-domain mark is louder than the flat measurement. Titration said it
is not: fish 16-32 additive jam stays `detected` through a=16 / 21.9 dB.
2026-08-23, without using that flat stamp as `G`: the aligned photo
residual in 16-32 is redraw, not a codeword. Median RMS 12.2 across 12
pairs; 2.69 on the five tightest. Neighboring octaves are as loud.
`.local-eval/synthid/prc-oklab-attack-2026-08-15/photo-band-amplitude-2026-08-23.json`.
Do not report an AUC from those 11 pairs as a detector result. With n=11
the standard error on AUC is about 0.12. Notes:
`.local-eval/synthid/prc-oklab-attack-2026-08-15/agent-detector-claude.md`.
## External literature (surveyed 2026-08-23)
Primary sources, not abstracts. Each row is mapped onto a closed or open
route in this campaign. Mechanism detail stays in
[synthid.md](synthid.md). Removal papers are on
[mark removal](synthid-removal-research.md). Classifier papers are on
[classifier models](synthid-classifiers.md).
### Official mark, not a public decoder
| Source | What it is | Map to this campaign |
| --- | --- | --- |
| Gowal et al., [arXiv:2510.09263](https://arxiv.org/abs/2510.09263) | Post-hoc encoder `f` / decoder `g`. Detection logit is not payload recovery. SynthID-O (partner variant) 136 bits at 512x512. TPR at 0.1% FPR 99.98% aggregated, 99.97% on the hardest spatial crop+resize. Trains against sampled semantics-preserving transforms, including weak VAE regeneration. Production decoder unpublished | Matches the architecture we treat as keyed `x' = x + g(x)`. Explains why a two-pixel crop kills `pipeline_lattice` but not the official oracle, and why a 22.3 dB foreign VAE still reads `detected` |
| DeepMind [US12094474B1](https://patents.google.com/patent/US12094474B1/en) and continuation [US20250149048A1](https://patents.google.com/patent/US20250149048A1/en) | Residual U-Net encoder, separate decoder, optional key, encoder/decoder ensembles that need not recognize each other | Constraint, not a recipe. Ensemble non-recognition is why one recovered Google tile cannot be reused as an OpenAI detector (S4) |
| Dathathri et al., [Nature 634:818-823 (2024)](https://www.nature.com/articles/s41586-024-08025-4) | SynthID-Text: tournament sampling of LLM tokens, open-source | Different system. Image/audio/video remain proprietary |
| OpenAI, [advancing content provenance](https://openai.com/index/advancing-content-provenance/) (2026-05-19, audio 2026-07-31) and [content provenance API](https://developers.openai.com/api/docs/guides/content-provenance) | ChatGPT / API / Codex images carry C2PA plus SynthID. Audio from 2026-07-31. `POST /v1/content_provenance_checks`. `not_detected` does not rule out another vendor | This is the oracle. C2PA and SynthID are independent entries. Do not abuse the endpoint as an adaptive reverse-engineering loop |
### Keyless presence detectors in the literature
| Source | Claim | Caveat against our gates |
| --- | --- | --- |
| Ao et al., [arXiv:2603.06723](https://arxiv.org/abs/2603.06723) (AWPD / FSNet, SAFE@CVPR 2026) | Leave-one-algorithm-out presence detection. SynthID held out: FSNet Acc 0.894 / F1 0.886, ResNet-50 Acc 0.845 / F1 0.812, ConvNeXt V2 Acc 0.866. LSB and Patchwork both fail below 60%. Hypothesis: modern invisible marks share dense high-frequency spectral anomalies | UniFreq Table 3: SynthID is 2,000 images, all AIGC, zero photographs, from `imagen-4.0-fast-generate-001` only, resized to 256x256. No OpenAI. No 0.1% FPR. Closest published analog to E3/L1 distillation. Our TrustMark leave-one-encoder-out was chance (0.505 ridge). Their high-frequency commonality is the same energy that L1 failed to read as an OpenAI payload |
| `aloshdenny/reverse-SynthID` V3/V4; Google to [The Verge, 2026-04-14](https://www.theverge.com/ai-artificial-intelligence/911579/google-synthid-ai-watermarking-system-reverse-engineered) | Spectral codebook from averaged Gemini flats. Spokesperson Myriam Khan: "It is incorrect to say this tool can systematically remove SynthID watermarks." The author also said the bypass confuses the decoder rather than deleting a payload | Our pickle-free V4 audit: paired AUC 0.517, frozen Open Images 386/1000 accepted. Not 0.1% FPR |
| Krawetz, [Hacker Factor "Reversing SynthID"](https://www.hackerfactor.com/blog/index.php?/archives/1092-Reversing-SynthID.html) and ["Meta's Un-Stable Signature"](https://hackerfactor.com/blog/index.php?/archives/1098-Metas-Un-Stable-Signature.html) | Gemini chat TPR closer to 1/20 than the paper's 99.97%. Detector weak on flats. reverse-SynthID field accuracy ~70% vs the author's 90% | Gemini-app chat is not the OpenAI provenance API. Our gray `gpt-image-2` flats are `detected` on the official OpenAI oracle. Do not mix the two verifiers |
| vitotitto LAB-a logistic (community, tiny holdout) | Reported 97.7% AUC on 20/20 | Not a P5 gate. Ignore as a detector claim |
### Different embedding loci (not post-hoc SynthID)
These papers are often cited as if they were SynthID. They are not.
| Source | Locus | Why it is not this mark |
| --- | --- | --- |
| Gunn, Zhao, Song, [arXiv:2410.07369](https://arxiv.org/abs/2410.07369) (PRC, ICLR 2025) | Pseudorandom error-correcting code in the *initial diffusion latent* | Cryptographic undetectability is a latent-code property. SynthID-Image is applied after the VAE has already decoded pixels. Our OKLab "PRC-style" noise was a category error |
| Francati et al., [arXiv:2509.10577](https://arxiv.org/abs/2509.10577) (EuroS&P 2026) | Crop-and-resize flipped about half of PRC latent signs and blocked belief-propagation decode | Confirms PRC is origin-locked in latent space. Matches why a 2 px shift kills `pipeline_lattice` and does not kill OpenAI SynthID |
| Fernandez et al. Stable Signature; Wen et al. Tree-Ring | Fine-tuned VAE decoder, or ring constraints on initial noise | In-generation. Google's paper is explicit that SynthID-Image does not modify the generator |
| TrustMark ([arXiv:2311.18297](https://arxiv.org/abs/2311.18297)), HiDDeN, StegaStamp | Open post-hoc encoder/decoder pairs | Transfer from these is E3: chance on TrustMark. WAVES ([arXiv:2401.08573](https://arxiv.org/abs/2401.08573)) stress-tests this family, not production SynthID |
Do not train a student on AWPD's Imagen-only 256 crop and call it an
OpenAI SynthID detector. That would repeat L1's contamination: a
generator/size cue, not a payload.
### Adjacent literature, not SynthID
These papers are the published versions of the wild hypotheses and of
Model 1. They detect *something else*: a camera pipeline, a generator
fingerprint, an open watermark, or steganography. None of them is a
keyless OpenAI payload decoder.
**Classical spread-spectrum and open neural marks**
| Source | What it is | Map |
| --- | --- | --- |
| Cox, Kilian, Leighton, Shamoon, IEEE TIP 1997 | i.i.d. Gaussian vector in perceptually significant spectral components. Informed detector. Collusion-resistant by construction | Ancestor of keyed CDMA. Our length-255 m-sequence vs 16x16 fold was at chance because we did not have the key, and because OpenAI is content-adaptive, not a fixed Gold code |
| Zhu et al., HiDDeN, ECCV 2018 | Joint encoder / noise layer / decoder | Open pair. AWPD leave-one-out Acc 0.985. Transfer to SynthID is E3 |
| Tancik, Mildenhall, Ng, StegaStamp, 2020 | 100 bits at 400x400, trained through print-scan | Open pair. Survives recapture. WAVES: TPR at 1% FPR collapses from 1.00 to 0.01 under regeneration |
| Wen et al., [arXiv:2305.20030](https://arxiv.org/abs/2305.20030) (Tree-Ring, NeurIPS 2023) | Pattern in the *initial diffusion noise*, recovered by DDIM inversion | In-generation, origin-locked in latent Fourier space. Crop-invariant by design. Not post-hoc SynthID |
| Fernandez et al., Stable Signature, ICCV 2023 | Fine-tune the LDM VAE decoder so every decode carries a bit string | In-generation. Gowal is explicit that SynthID-Image does not modify the generator |
| Lin and Juarez, [arXiv:2506.10502](https://arxiv.org/abs/2506.10502) (USENIX 2025) | Public-knowledge attack that removes Tree-Ring | Confirms Tree-Ring is a different object with a different kill |
**Steganalysis as a presence detector**
Fridrich and Kodovsky Spatial Rich Models (TIFS 2012) and Boroumand,
Chen, Fridrich SRNet (TIFS 2018) detect sub-bit-per-pixel spatial
stego by high-pass residuals, with pooling disabled in the front of
SRNet so the weak signal is not averaged away. AWPD cites both and
says they drift on modern deep / generative marks. That matches our
wavelet/FFT single-image detector (AUC 0.653, 0 TPR at a clean cut)
and the 16-32 energy miss on photographs: a residual energy detector
without the matching key is steganalysis of a mark that was trained
not to look like LSB.
**Generator fingerprints in the Fourier domain**
Corvi, Cozzolino, Poggi, Nagano, Verdoliva,
[arXiv:2304.06408](https://arxiv.org/abs/2304.06408) (CVPRW 2023):
GAN, diffusion, and VQ-GAN images show spectral peaks and anomalous
autocorrelation; real vs synthetic differ in mid-high radial and
angular power. reverse-SynthID averaged Gemini flats and called the
peak a watermark codebook. Corvi's result says many generators leave
*some* peak. Our V4 Open Images 386/1000 is what a generator-fingerprint
detector looks like when you calibrate it as if it were a payload.
Yao and Juarez, [arXiv:2512.11771](https://arxiv.org/abs/2512.11771)
("Smudged Fingerprints"): 14 fingerprinting methods across RGB,
frequency, and learned features; removal attacks >80% white-box, >50%
black-box. A fingerprint you can see without a key is a fingerprint
you can wipe without a key.
### Image investigation and data hiding (any method)
These are not SynthID papers. They are the rest of the toolkit: how
people hide bits in pictures, and how people tell a picture was
touched. Several of our wild hypotheses already had a published form
here.
Hiding is not one problem. Cover modification (change an existing
image), coverless / generative (sample an image that already carries
the bits), and signed metadata (C2PA) fail under different attacks.
**Cover modification, classical**
| Source | Hide how | Detect / limit |
| --- | --- | --- |
| LSB, Patchwork (Bender et al., IBM SJ 1996) | Flip low bits, or luminance of random pixel pairs | AWPD Acc < 0.60. Sparse or ±1 amplitude. SRNet / FSNet average it away |
| Westfeld F5 (2001), Fridrich nsF5 | JPEG DCT coefficients, matrix embedding | Histogram attacks on F5; nsF5 was the shrinkage fix. Domain is the codec, not a mid-band residual |
| Pevny, Filler, Bas HUGO (2010); Holub and Fridrich WOW (2012); Holub, Fridrich, Denemark UNIWARD (IH&MMSec 2013 / EURASIP 2014) | Content-adaptive costs, bits placed in texture via syndrome-trellis codes (Filler, IH 2011) | The modern spatial/JPEG floor. Distortion is *designed* to look like the cover. A 16-32 energy detector is the wrong statistic |
| Cox et al. 1997 | Spread-spectrum in significant DCT | Keyed. Already mapped above |
**Cover modification, neural (hide a whole image, not 32 bits)**
| Source | What it does | Map |
| --- | --- | --- |
| Baluja, NeurIPS 2017, "Hiding Images in Plain Sight" | Full-resolution secret image distributed across all bits of a same-size cover | Capacity is the point. Not robust, not keyed provenance. HiDDeN later added a noise layer so the secret survives JPEG |
| Jing et al., HiNet, ICCV 2021 | Invertible neural net: hide and recover as one bijection | High-capacity lossless-ish hiding. Recovery needs the exact inverse, not an official oracle |
| Yang et al., PRIS, [arXiv:2309.13620](https://arxiv.org/abs/2309.13620) | Invertible net plus robustness modules | Same family, trained through distortion |
A HiNet-style student on OpenAI pairs would learn the *edit*, not a
SynthID codeword. That is the L1 contamination again.
**Coverless / generative hiding**
The cover is never modified because there is no cover. The sampler
emits an image whose latents already encode the message.
| Source | Locus | Map |
| --- | --- | --- |
| Yang et al., Gaussian Shading, [arXiv:2404.04956](https://arxiv.org/abs/2404.04956) (CVPR 2024) | Map bits onto Gaussian latents indistinguishable from ordinary noise. Recover by DDIM inversion | Sibling of Tree-Ring / PRC. Training-free, performance-lossless *for the generator*. Not a post-hoc pixel stamp |
| Peng et al., StegaDDPM (ACM MM 2023) and later LDStega | Bits in the diffusion sampling distribution | Spatial SRNet is the wrong detector (NS-DSer, [arXiv:2602.10219](https://arxiv.org/abs/2602.10219): move steganalysis into noise space) |
| CRoSS, Pulsar, MDDM | Message-to-noise projections | Same locus. A pixel scramble does not invert the sampler |
**Passive forensics (the picture was touched, no secret assumed)**
| Source | Cue | Map |
| --- | --- | --- |
| Krawetz, "A Picture's Worth", 2007 (ELA) | Re-JPEG at lower quality, subtract | Already measured: COCO 3.13, s1 1.97, gray stamp 0.49. Codec history, not a payload. Farid publicly called ELA as likely to mislabel originals as it is to catch edits |
| Farid, IEEE TIFS 2009, JPEG ghosts | Difference energy vs a sweep of JPEG qualities; spliced regions ghost at their original Q | Untested here. The remaining JPEG check after ELA. Only defined on quantized JPEG |
| Popescu and Farid, TR2004-515 | Copy-move via duplicated regions | Not generation, not a watermark |
| Popescu and Farid, IEEE TSP 2005 | Resampling periodic correlations | Affine search cousin. A rotated SynthID residual is a different question |
| Wang et al., DIRE, [arXiv:2303.09295](https://arxiv.org/abs/2303.09295) (ICCV 2023) | Diffusion reconstruction error: generated images reconstruct, cameras do not | Model 1 sibling, needs a diffusion model. Inverse of our VAE round-trip: there the mark survived 22.3 dB; here the *error map* is the feature |
| Wang, Wang, Zhang, Owens, Efros, [arXiv:1912.11035](https://arxiv.org/abs/1912.11035) (CVPR 2020, CNNDetect) | One ProGAN classifier, heavy JPEG/crop aug, transfers to many CNNs | Ancestor of "train on one generator". Ojha showed the sink-class failure once diffusion arrived. We required Firefly for that reason |
C2PA is the non-pixel stack: a signed manifest, stripped by
`metadata --remove`. Durable Content Credentials (spec 2.4) add a
soft binding that can re-link a stripped file to a repository. That
is provenance, not hiding.
Do not train on ELA, JPEG ghosts, DIRE, or a HiNet reconstruction and
name the score SynthID.
### Waveforms that can live in a picture
A mark is a function on the pixel lattice. The literature does not
use one wave. It picks a basis whose symmetries match the attack it
fears, then hides a keyed coefficient vector in that basis. Cartesian
16-32 is one shell. Polar, scale, and diffraction are different
shells.
**Standing waves on a rectangle.** DFT / DCT / DST. A 2-D sinusoid
`cos(2π(ux + vy)/N)`. JPEG lives here. Our octave scramble destroys
one annular *radius* of these frequencies, not one orientation. A
Gabor packet is the same sinusoid windowed in space.
**Circular and log-radial waves.** Functions of `(r, θ)`, not
`(x, y)`.
| Basis | Wave | Invariance it buys |
| --- | --- | --- |
| Fourier-Mellin / log-polar (Ó Ruanaidh and Pun, Signal Processing 1998) | `r^{iα} exp(ikθ)` after a DFT magnitude | Rotation and scale become translations |
| Logarithmic radial harmonics (IH 2002) | Same family, added in pixels, detected by complex correlation | RST without going through the unstable log-polar resample |
| Polar harmonic transforms: PCET, PCT, PST | `exp(±i2π n r²)` and polar cos/sin | Rotation. Moments, not a dense codebook |
| Zernike / pseudo-Zernike | Orthogonal polynomials on the disk, radial part related to Bessel | Rotation. Classical moment watermark |
| Circular chirp (SPIE 6072, 2006) | Polar map of a 1-D chirp onto a ring | JPEG (tune chirp rate) plus rotation (the ring) |
| Tree-Ring (Wen 2023) | Concentric rings in the *latent* Fourier plane | Crop/flip by construction. Not a pixel wave |
Our prepared polar 16-32 scramble is the attack on this family: keep
the Cartesian octave energy, randomize angle. If the official decoder
still reads `detected`, the OpenAI mark is not a polar harmonic. If it
flips near 25.6 dB, angular structure mattered.
**Scale chirps.** Hyperbolic / Mellin monomials (arXiv:1208.5842):
real 1-D Mellin patterns tiled in 2-D, detected after a Radon-like
projection. Affine search of a 16-32 template is the cheap version.
Not yet run.
**Directional packets.** Dual-tree complex wavelets (approximate
analytic wave), Gabor/Morlet, ridgelets, curvelets, shearlets,
contourlets, bandelets. Multiplicative spread-spectrum on curvelet
coefficients is a published detector-design paper, not a SynthID
decoder. Our wavelet summary AUC 0.653 already said a *generic*
packet energy is not the OpenAI payload.
**Optical diffraction, actual wave physics.**
| Transform | What the wave is | Map |
| --- | --- | --- |
| Fresnel | Quadratic phase `exp(iπ r² / λz)`, a radial chirp. Energy concentrates with distance | Holographic watermarks embed the diffracted field, not the picture |
| Fractional Fourier | Rotation of the time-frequency plane. Order is an extra key | Extra key space on top of DFT |
| Double random phase encoding (Réfrégier and Javidi, Optics Letters 1995) | Random phase at input *and* Fourier plane. Ciphertext is stationary white noise | Closest optical analog of a keyed `x' = x + g(x)` that looks like noise without both screens |
| Cascaded Fresnel / CGH | Two holograms at different depths | Hardware key. Not a pixel residual we can match-filter |
A Fresnel/FrFT student without the distance or the fractional order
is another unmatched filter.
**Cyclostationary and clock-warped waves.** Periodic clock change
(Martin, Chabert, Lacaze, EUSIPCO 2005) turns a stationary host into
a cyclostationary one by a periodic time warp. Our H2 multiplicative
cyclostationarity test already put official positives *below*
controls. Do not rerun the same statistic in a new basis and call it
new evidence.
**How to read a candidate wave.** Name the coordinates `(x,y)` vs
`(r,θ)` vs `log r`, the generating function (sinusoid, chirp,
Bessel, Zernike), additive vs multiplicative, and the symmetry it
claims. Then pick one attack that kills that symmetry and one that
should not. Cartesian 16-32 scramble, polar 16-32 scramble, 2 px
shift, and a small rotation are the four cheap probes. Shift already
left OpenAI `detected`. Polar scramble is prepared. Affine/rotation
search is not.
## Still open or incomplete
- Google: no public payload decoder and no usable programmatic oracle.
The Gemini app remains quota-limited. Vertex was 403.
- `chatgpt-image-latest` encoder-off pairs: `models.retrieve` returns the
id, `images.generate` is 403 until the organization is verified
(re-checked 2026-08-23). `gpt-image-2` remains the working pair mint.
- Adding unmarked context (frame, pad, hstack, collage) to hunt a
residual is a presentation gate, not a mark statistic. Those rungs
belong with identity and layout below, and with
[mark removal](synthid-removal-research.md).
- Camera-to-ChatGPT "unchanged crop" pairs are dirty counterfactuals.
The instructed edit redraws; aligned PSNR on the frame pair was
16.6 dB. Compare pixels only after registration and a frozen residual
bound, as in the `gpt-image-2` red-dot row.
A Gemini app caption that an image "looks like a photograph" is not a
SynthID verdict. Use the provider pixel check or signed provenance.
## Wild hypotheses
Battery 2026-08-23, no official oracle. Local numbers:
`.local-eval/synthid/prc-oklab-attack-2026-08-15/wild-hypotheses-2026-08-23.json`.
Prepared attack rasters wait in `wild-attacks-2026-08-23/` for a later
verifier window. Jacobian / adaptive queries against
`verify-openai-synthid` stay out: the endpoint forbids reverse-engineering.
### Tested locally, not a mark
| Hypothesis | Result |
| --- | --- |
| Chroma ordered by luma is a 1D payload | Correlation is an artifact of the sort |
| Mark is `g(x)` of a Retinex envelope | LOO unchanged vs the raw 16-32 residual |
| Nonlocal patch agreement in 16-32 | Far patches are not coupled |
| Quadratic phase coupling (bispectrum) | Statistic saturates on camera images too |
| CDMA Gold / m-sequence in the 16x16 fold | At chance for length 255 |
| Rows as a pitched waveform | COCO noise has the same peak-to-mean |
| The photo mark is the flat stamp at an unknown 16-phase | Exhaustive shift does not lift NCC |
| Self-keyed predictor from a 16 px luma thumb | Already 0.48 vs L1, 0.50 vs COCO (`self-keyed.json`) |
| Global multiplicative cyclostationarity (H2) | Official positives below controls, 2026-08-14 |
### Not a watermark, might still be an AI-vs-camera extra
| Hypothesis | Result |
| --- | --- |
| Absence of camera PRNU | Gray `gpt-image-2` highpass RMS 0.25 vs COCO 14.6, that is missing texture, not a missing sensor. A Wiener PRNU residual on photographs is still open as a Model 1 sibling, not as SynthID |
| JPEG ELA | Flat 0.49, s1 1.97, COCO 3.13. Compression history, not a payload |
### Still crazy, not yet measured
- Content-locked affine search (small rotation plus 16-32 NCC), not origin-locked. Published analog: Fourier-Mellin / log-radial harmonics (Ó Ruanaidh and Pun 1998). Polar 16-32 scramble is the remover twin, already rastered at 25.6 dB.
- CFA presence as a camera vote (Bayer as a *detector* feature, the inverse of the remover arm). Published analog: Zhong, Xu, Zou [arXiv:2601.22778](https://arxiv.org/abs/2601.22778) (DCCT). Farid's 2005 CFA-interpolation test is the classical form.
- Wavelet Wiener PRNU on `gpt-image-2` photo edits versus Kodak. Published analog: Klier and Baier, DFRWS EU 2026, PCE vs smartphone fingerprints (ChatGPT-5 FPR 100% until a center crop).
- PNG Paeth / filter-byte residual after a lossless rewrite.
- Double-JPEG ghosting as an export fingerprint. Farid TIFS 2009. ELA already failed as a payload; the ghost sweep is the remaining codec check, JPEG-only.
- Face-gated 16-32: maybe the encoder spends budget off faces.
- Baker-map / area-preserving scramble of the mid band.
- Print-scan (physical).
- Nested open watermark plus SynthID.
- Mark in ICC / cICP / gamma, not in pixels. Strip-and-reencode already used for the oracle path; a pixel-preserving ICC rewrite is the remaining check.
- Encoder ensemble that does not recognize itself (patent). Untestable without that pair.
- Photon-shot / Poisson-domain embedding.
- CLIP-space watermark. Would show up as Model 1, not as a payload.
Do not train on official decoder bits beyond the frozen L1 study. Do not
mint a 128-photo student on the flat stamp. The information budget and
the 2026-08-23 residual measurement both say the photo-domain mark is
under the scene.
## Oracle and seeds
@@ -61,7 +398,8 @@ All three `detected` before attacks.
## Source-matched pairs (2026-08-21)
`gpt-image-2` (ChatGPT Images 2.0) can mint encoder-on/off pairs.
`chatgpt-image-latest` is 403 until the org is verified.
`chatgpt-image-latest` still 403 on generate (org verification),
re-checked 2026-08-23; retrieve of the model id succeeds.
| Input | C2PA | Oracle | Aligned PSNR |
| --- | --- | --- | ---: |
@@ -105,6 +443,14 @@ canvas `not_detected` (the same 0.20x file uploaded alone was `detected`).
## L1 is not a mark task
A missing `watermarked.unbound` assertion is not a clean negative. On
2026-08-16 the official verifier called 65 of 94 OpenAI rows without that
assertion `detected` (69%), with 9 of 9 interleaved health positives
detected so the endpoint was answering. Google's own API emits watermarked
images with no assertion, per its documentation. Corpus AUCs that treat
"no assertion" as unmarked rest on a negative class that is roughly
two-thirds positive.
Control-only 283 rows: 203 detected, 80 not_detected. Forward-temporal
nested ridge on 124 pixel features: AUC 0.649 / 0.641. Geometry-only:
0.781 / 0.826. All 21 `claim_generator=ChatGPT` controls are
@@ -119,5 +465,4 @@ not see the oracle mark contrast.
## Product remainder for the watermark
Signed provenance (`identify`) and `verify-openai-synthid` (remote, explicit
upload). A local `indeterminate` from `detect-synthid` is not a clean
SynthID negative.
upload). A research lattice miss is not a clean SynthID negative.
+11 -1
View File
@@ -13,7 +13,7 @@
| [SynthID local detector research](synthid-detector-research.md) | Hunt for a keyless local mark detector. Closed. |
| [Classifier models](synthid-classifiers.md) | Model 1 AI-versus-camera result, rejected provider CLIP, `pipeline_lattice` as google-lineage. |
| [SynthID mark removal research](synthid-removal-research.md) | Quiet-remover hunt. Closed except ~19-24 dB 16-32 scramble and blur sigma 7. |
| [Mechanism reference](synthid.md) | How SynthID works, provenance, shipped lattice, regeneration. |
| [Mechanism reference](synthid.md) | How SynthID works, provenance, robustness, regeneration. |
| [Chronological plan archive](synthid-detector-removal-plan.md) | Dated H-gates, corpora, and session notes in original order. |
## Where former sections went
@@ -28,3 +28,13 @@
| `pipeline_lattice` re-check, Spaces census | [classifiers](synthid-classifiers.md) |
| Attack / kill / flip / add / surround ladders | [removal](synthid-removal-research.md) |
| 16-32 titration, E2 scramble, Bayer and geometry | [removal](synthid-removal-research.md) |
| S4 provider split, M2 Imagen `addWatermark`, reverse-SynthID, Bypass | [detector](synthid-detector-research.md) |
| Photo `d'` budget 13.4 dB, 128-photo student, 16-32 residual without flat `G` | [detector](synthid-detector-research.md) |
| OKLab codeword replacement, add-context as presentation | [removal](synthid-removal-research.md) |
| Three-class OpenAI / Gemini / photo ask | [classifiers](synthid-classifiers.md) |
| Wild hypotheses 2026-08-23 (sort, CDMA, bispectrum, PRNU, affine-not-run) | [detector](synthid-detector-research.md) |
| Prepared polar / band-transplant / palette64 | [removal](synthid-removal-research.md) |
| External literature 2026-08-23 (Gowal, AWPD, PRC, Zhao, UnMarker, CtrlRegen, MarkNull, reverse-SynthID) | [detector](synthid-detector-research.md), [removal](synthid-removal-research.md), [classifiers](synthid-classifiers.md) |
| Adjacent literature, not SynthID (Cox, HiDDeN, StegaStamp, Tree-Ring, Ojha CLIP, Corvi Fourier, DCCT CFA, PRNU PCE) | [detector](synthid-detector-research.md), [classifiers](synthid-classifiers.md), [removal](synthid-removal-research.md) |
| Image investigation and data hiding (LSB, UNIWARD, Baluja, HiNet, Gaussian Shading, ELA, JPEG ghosts, DIRE, CNNDetect) | [detector](synthid-detector-research.md), [classifiers](synthid-classifiers.md) |
| Waveforms in a picture (DFT, Fourier-Mellin, Zernike, chirps, Fresnel, DRPE, cyclostationary) | [detector](synthid-detector-research.md) |
+65 -17
View File
@@ -12,8 +12,9 @@
## Result
The quality-preserving OpenAI SynthID remover hunt closed 2026-08-20.
Bayer, VNG demosaic, upscale-then-Bayer, barrel distortion, and scanline
jitter closed 2026-08-22: they leave the official oracle `detected`.
Bayer, VNG demosaic, upscale-then-Bayer, barrel distortion, scanline
jitter, and a 2 px shift closed 2026-08-22 on s1/s2 and 2026-08-23 on s3
and fish: they leave the official oracle `detected`.
Working residual kills on photographs cost about 19-24 dB:
@@ -41,9 +42,13 @@ presentation gate, not residual damage. Those rungs are in
| --- | --- | --- |
| Quality-preserving photo remover | 2026-08-20 | Every residual `not_detected` that is not a collage is below usable quality |
| Additive in-band jam as a quiet remover | 2026-08-21 | Fish flips only at 18.6 dB; 4-8 px at the same PSNR stays `detected` |
| Bayer bilinear / VNG / upscale-Bayer | 2026-08-22 | s1/s2 still `detected`; VNG on s2 is dirtier than scramble (18.5 vs 19.0 dB) and the mark remains |
| Barrel k1=0.06 and scanline ±0.8 px | 2026-08-22 | s1/s2 `detected` even at 14-20 dB barrel |
| Bayer bilinear / VNG / upscale-Bayer | 2026-08-23 | s1/s2/s3 and fish still `detected`. s3 bilinear 37.1 dB, fish 34.7 dB. VNG on s2 is dirtier than scramble (18.5 vs 19.0 dB) and the mark remains |
| Barrel k1=0.06, scanline ±0.8 px, and shift 2 px | 2026-08-23 | s1/s2/s3/fish `detected` even at 14-20 dB barrel. Fish scanline 32.5 dB and shift 22.7 dB still `detected` |
| TrustMark-style micro-warp | 2026-08-21 | 0.25 px / 32 dB still 100% detect on TrustMark P; OpenAI elastic ~21 dB unreplicated |
| OKLab random-codeword replacement as a quiet wipe | 2026-08-15 | On four public Google-oracle positives, replacing the period-16 tile at 0.95 dropped the *local* lattice score below 0.173 at ~54 dB. Gemini pixel verify never accepted the candidates (`Connecting to Verify AI`). Local-score intervention, not an official wipe |
| PRC-style OKLab a/b noise plus spectral peak cleaning | 2026-08-15 | Independent high-pass OKLab noise at 32.6 dB moved the median local score by only `-0.0219`. Comb suppression moved one of four below threshold at 58 dB. Not a quiet official kill |
| Add unmarked context (frame, pad, hstack, collage) | 2026-08-19 | Official `not_detected` is a presentation gate. White pad 40% linear stays `detected`; photo fill at the same geometry is `not_detected`. See detector identity/layout |
| `cebeuq/Synthid-Bypass` diffusion reconstruction | 2026-08-13 | 12 public pairs. Lattice and Gemini Verify go quiet on the *after* images because the image was redrawn, not because a payload was deleted. Faces and text are a quality claim for regeneration, not a local wipe |
Oracle: `verify_openai_synthid` after AI-metadata strip. Seeds s1, s2, s3
as in [detector research](synthid-detector-research.md). Raw files:
@@ -85,23 +90,24 @@ Preregistered close `a*_photo < 0.53` failed (`a* > 16`).
## Bayer and geometry (2026-08-22)
Frozen one-pass batch. Lattice scores on these OpenAI sources are already
`indeterminate` and are not the label. s3 and the fish photo were queued
after an OpenAI 429.
`indeterminate` and are not the label.
| Attack | s1 PSNR / oracle | s2 PSNR / oracle |
| --- | --- | --- |
| source | detected | detected |
| Bayer bilinear | 32.7 detected | 28.4 detected |
| Bayer VNG | 25.0 detected | 18.5 detected |
| upscale 1.15 then Bayer | 33.9 detected | 29.8 detected |
| barrel k1=0.06 | 19.9 detected | 14.2 detected |
| scanline ±0.8 px | 31.5 detected | 26.7 detected |
| shift 2 px | 23.1 detected | (429, not submitted) |
| 16-32 phase scramble | 24.6 not_detected | 19.0 not_detected |
| Attack | s1 | s2 | s3 | fish |
| --- | --- | --- | --- | --- |
| source | detected | detected | detected | detected |
| Bayer bilinear | 32.7 detected | 28.4 detected | 37.1 detected | 34.7 detected |
| Bayer VNG | 25.0 detected | 18.5 detected | 26.4 detected | 25.4 detected |
| upscale 1.15 then Bayer | 33.9 detected | 29.8 detected | 38.4 detected | 36.0 detected |
| barrel k1=0.06 | 19.9 detected | 14.2 detected | 20.2 detected | 18.5 detected |
| scanline ±0.8 px | 31.5 detected | 26.7 detected | 35.7 detected | 32.5 detected |
| shift 2 px | 23.1 detected | 15.9 detected | 24.7 detected | 22.7 detected |
| 16-32 phase scramble | 24.6 not_detected | 19.0 not_detected | 25.2 not_detected | 23.2 not_detected |
Camera pipeline and mild geometry do not hit the decoder basis. A 2 px
shift, which kills `pipeline_lattice`, left s1 `detected`.
shift, which kills `pipeline_lattice`, left s1/s2/s3/fish `detected`.
Scramble remains the residual kill on 4/4 sources.
`.local-eval/synthid/prc-oklab-attack-2026-08-15/bayer-geometry-2026-08-22/`.
Manifest `status` is `oracle_complete` (2026-08-22 22:07).
## Attack ladders (OpenAI oracle)
@@ -134,6 +140,48 @@ Surround: photo fill at the white-pad geometries is `not_detected` on
between 25% width (`detected`) and 35% (`not_detected`). Photo frame 15%
each side is `not_detected` on 3/3; 12% does not hold.
## Wild removers, oracle not yet run (2026-08-23)
Prepared on s1. PSNR only. Bayer fish is complete. Submit these when the
rate limit allows, not as a new training loop against the verifier.
| Attack | PSNR | Note |
| --- | ---: | --- |
| Replace 16-32 with a COCO photo's 16-32 | 25.2 | Band transplant. If this ever reads `not_detected` near 25 dB it is the quietest residual kill so far; if `detected`, the mark is not a replaceable additive layer in that annulus |
| Polar phase scramble of 16-32 | 25.6 | Same band as the cartesian scramble (24.6 dB `not_detected`). Tests circular / Fourier-Mellin / polar-harmonic families: energy stays, angle dies |
| 64-color median-cut palette | 35.6 | Quieter than scramble. Posterize-4 already stayed `detected`; this is a smarter quantizer |
| PIL RGB to CMYK to RGB | inf | No-op on this PNG. Not an attack |
Files: `.local-eval/synthid/prc-oklab-attack-2026-08-15/wild-attacks-2026-08-23/`.
Also untested: seam carving of the mid band, CMYK via a real ICC printer
profile, HEIF/AV1 round-trip, background-only 16-32 scramble (face mask),
print-scan.
## External literature (surveyed 2026-08-23)
Primary sources. Detector papers live in
[detector research](synthid-detector-research.md). Forensic stealth of
regeneration is already in [synthid.md](synthid.md#23-removal-attacks-and-forensic-detectability).
| Source | Attack | Against SynthID? | Map to this campaign |
| --- | --- | --- | --- |
| Zhao et al., [arXiv:2306.01953](https://arxiv.org/abs/2306.01953) (NeurIPS 2024) | Add noise, then denoise or regenerate (VAE / diffusion). Pixel-level invisible marks are provably removable. Semantic watermarks proposed as the alternative | Open post-hoc schemes, not production SynthID | This is the family our product uses (`qwen-zimage` / `sdxl-zimage`). Gowal trains SynthID-O against *weak* VAE regeneration. Our foreign-VAE round-trip at 22.3 dB stayed `detected`. Regeneration works when it redraws, not when it is a light codec |
| Liu et al., [arXiv:2410.05470](https://arxiv.org/abs/2410.05470) (CtrlRegen, ICLR 2025) | Controllable diffusion from clean noise, with a knob on how many noise steps to add | SOTA open watermarks | Same family. Goonatilake later finds CtrlRegen+ the *most* forensically detectable remover (AUROC 0.9999) |
| Kassis and Hengartner, [arXiv:2405.08363](https://arxiv.org/abs/2405.08363) (UnMarker, IEEE S&P 2025) | No decoder feedback. Two adversarial spectral optimizations. Breaks even some semantic watermarks (best remaining detection 43%) | Not production SynthID | Spectral disruption without an oracle is the honest analog of our 16-32 scramble, except UnMarker is optimized and we used a one-octave phase shuffle. Goonatilake: UnMarker TPR 98.28% at 0.1% FPR as a *forensic* leftover |
| Tallam et al., [arXiv:2505.08234](https://arxiv.org/abs/2505.08234) (SemanticRegen) | Partial, label-free regeneration of main objects | Tree-Ring, StegaStamp, StableSig, DWT/DCT. Not SynthID | Partial redraw. Our collage / photo-pad `not_detected` is a presentation gate, not this attack |
| Cao et al., [arXiv:2608.10166](https://arxiv.org/abs/2608.10166) (MarkNull, USENIX Security 2026) | On-manifold latent decorrelation via a public diffusion proxy. Claims 100% on 20 Imagen-3 Gemini-verify images. PSNR 25.36 dB, SSIM 0.80 | Small Gemini-verify set | Independent evidence that a no-box latent reconstruction can confuse Gemini. Does not meet this project's 40 dB / 0.99 SSIM release gate. Still generation, not a pixel-only wipe |
| Goonatilake and Ateniese, [arXiv:2605.09203](https://arxiv.org/abs/2605.09203) | Six removers all leave a forensic residue a ResNet-50 sees at >98% TPR @ 1% FPR | Applies to UnMarker, Zhao's WatermarkAttacker, CtrlRegen+ | Defeating `verify-openai-synthid` is not deniability. This is the product remainder |
| An et al., [arXiv:2401.08573](https://arxiv.org/abs/2401.08573) (WAVES, ICML 2024) | 26 attacks on StegaStamp, Stable Signature, Tree-Ring. Regeneration, not JPEG, is the attack that matters. StegaStamp TPR@1%FPR 1.00 to 0.01; Tree-Ring 0.99 to 0.12 | Open watermarks | Protocol. Our blur-sigma-7 and 16-32 scramble are closer to WAVES "distortion" than to regeneration |
| Wen et al., [arXiv:2305.20030](https://arxiv.org/abs/2305.20030) (Tree-Ring) | In-generation Fourier pattern in initial noise | Not SynthID | Kill is DDIM inversion plus latent wipe, not a pixel scramble. Lin and Juarez [arXiv:2506.10502](https://arxiv.org/abs/2506.10502) (USENIX 2025) remove it from public knowledge |
| Fernandez et al., Stable Signature, ICCV 2023 | Fine-tuned VAE decoder | Not SynthID | Regeneration that replaces the decoder is in-family. Our foreign VAE at 22.3 dB did not kill OpenAI SynthID, which is the expected mismatch |
| Google to [The Verge, 2026-04-14](https://www.theverge.com/ai-artificial-intelligence/911579/google-synthid-ai-watermarking-system-reverse-engineered) | reverse-SynthID does not systematically remove SynthID | Google spokesperson on the spectral bypass | Agrees with our V4 audit and with Bypass: reconstruction or decoder confusion, not a payload delete |
Quiet pixel-only removal is not a published result on production
OpenAI or Google SynthID. Every paper that "beats" a watermark either
redraws the image, attacks an open encoder, or reports PSNR in the same
19-25 dB band as our 16-32 scramble.
## Product remainder for removal
Invisible removal in this package regenerates through `qwen-zimage` or
+99 -1137
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -68,7 +68,6 @@ payloads. Removal remuxes either container through ffmpeg with stream copy.
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;
unlike the project's calibrated-size generation-pipeline lattice expert,
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).
- **Built in the dated batch:** soft-binding vendor detection, IPTC Photo
+2 -2
View File
@@ -201,7 +201,7 @@ exclude = [
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
pythonpath = ["src", "scripts"]
addopts = "-v --tb=short"
[tool.ruff]
@@ -218,7 +218,7 @@ ignore = [
]
[tool.ruff.lint.per-file-ignores]
"scripts/*.py" = ["G004", "S108", "S310", "T20"]
"scripts/**/*.py" = ["G004", "S108", "S310", "T20"]
"tests/*.py" = ["ANN", "S101", "S105", "S106", "S108"]
"src/remove_ai_watermarks/_internal/watermark_remover.py" = ["S603", "S606", "S607"] # nvidia-smi capability probe
"src/remove_ai_watermarks/_internal/c2pa.py" = ["S110"] # try-except-pass for corrupt file handling
+5 -3
View File
@@ -10,22 +10,24 @@ from __future__ import annotations
import json
import logging
import math
import sys
import time
from pathlib import Path
from typing import TYPE_CHECKING, Any
sys.path.insert(0, str(Path(__file__).resolve().parent))
import click
from PIL import Image
from synthid_pixel_attack import load_rgb, measure # pyright: ignore[reportUnknownVariableType]
from synthid_research_manifest import artifact_sha256
from synthid_tile_attack import subtract_tiled_template
from remove_ai_watermarks.synthid_detector import (
from synthid_runtime.synthid_detector import (
TILE_THRESHOLD,
_geometry_supported, # pyright: ignore[reportPrivateUsage]
_load_template, # pyright: ignore[reportPrivateUsage]
folded_template_score,
)
from synthid_tile_attack import subtract_tiled_template
if TYPE_CHECKING:
from numpy.typing import NDArray
+6 -4
View File
@@ -12,20 +12,22 @@ from __future__ import annotations
import json
import logging
import math
import sys
from dataclasses import asdict, dataclass, replace
from pathlib import Path
from typing import TYPE_CHECKING, Any
sys.path.insert(0, str(Path(__file__).resolve().parent))
import click
import cv2
import numpy as np
from synthid_pixel_attack import jpeg_round_trip, load_rgb
from remove_ai_watermarks._synthid_confirmation import (
from synthid_runtime._synthid_confirmation import (
registered_confirmation_passes as runtime_registered_confirmation_passes,
)
from remove_ai_watermarks._synthid_registered import RegisteredComponents, registered_components
from remove_ai_watermarks.synthid_detector import fold_residual_template, folded_template_score, unit_tile
from synthid_runtime._synthid_registered import RegisteredComponents, registered_components
from synthid_runtime.synthid_detector import fold_residual_template, folded_template_score, unit_tile
if TYPE_CHECKING:
from numpy.typing import NDArray
+6 -1
View File
@@ -2,9 +2,14 @@
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
from remove_ai_watermarks.synthid_detector import fold_residual_template, unit_tile
sys.path.insert(0, str(Path(__file__).resolve().parent))
from synthid_runtime.synthid_detector import fold_residual_template, unit_tile
__all__ = ["cyclic_tile_correlations", "fold_residual_template", "unit_tile"]
+2 -2
View File
@@ -18,6 +18,7 @@ from typing import Literal
import click
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT / "scripts"))
sys.path.insert(0, str(PROJECT_ROOT / "src"))
from synthid_conformal_cascade import ( # noqa: E402
@@ -25,8 +26,7 @@ from synthid_conformal_cascade import ( # noqa: E402
load_observation_records,
)
from synthid_research_manifest import artifact_sha256 # noqa: E402
from remove_ai_watermarks import synthid_detector # noqa: E402
from synthid_runtime import synthid_detector # noqa: E402
log = logging.getLogger(__name__)
+5
View File
@@ -0,0 +1,5 @@
"""Research-only periodic lattice detector, not a package export."""
from synthid_runtime.synthid_detector import SynthIDDetection, detect_synthid, is_available
__all__ = ["SynthIDDetection", "detect_synthid", "is_available"]
@@ -0,0 +1,288 @@
"""Independent split-patch confirmation for the registered SynthID carrier."""
# The optional numeric libraries do not provide complete types for this path.
# pyright: reportMissingTypeStubs=false, reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
import cv2
import numpy as np
from synthid_runtime.synthid_detector import fold_residual_template, unit_tile
if TYPE_CHECKING:
from numpy.typing import NDArray
MIN_PERIOD = 10.0
MIN_COHERENCE = 0.30
MIN_AMPLITUDE = 0.0
H5_PERIOD = (18.0, 18.6)
H5_MIN = 0.13
STRONG_COHERENCE_PERIOD = (18.6, 20.0)
STRONG_COHERENCE_MIN = 0.40
WEAK_H5_PERIOD = (20.0, 22.0)
WEAK_H5_MIN = 0.02
PATCH_SIZE = 256
GRID_SIZE = 4
HARMONIC_COUNT = 16
@dataclass(frozen=True)
class RegisteredConfirmationComponents:
"""Auditable split-patch confirmation components for one fixed period."""
period: float
joint_coherence: float
joint_amplitude: float
unknown_codeword_fixed_confirmation: float
selection_patches: int
confirmation_patches: int
@property
def passes(self) -> bool:
"""Whether every frozen period-aware confirmation gate passes."""
return registered_confirmation_passes(
self.period,
self.joint_coherence,
self.joint_amplitude,
self.unknown_codeword_fixed_confirmation,
)
def registered_confirmation_passes(
period: float,
joint_coherence: float,
joint_amplitude: float,
unknown_codeword_fixed_confirmation: float,
) -> bool:
"""Apply the single frozen registered-carrier confirmation rule."""
if period < MIN_PERIOD:
return False
if joint_coherence < MIN_COHERENCE or joint_amplitude < MIN_AMPLITUDE:
return False
if H5_PERIOD[0] <= period < H5_PERIOD[1]:
return unknown_codeword_fixed_confirmation >= H5_MIN
if STRONG_COHERENCE_PERIOD[0] <= period < STRONG_COHERENCE_PERIOD[1]:
return joint_coherence >= STRONG_COHERENCE_MIN
if WEAK_H5_PERIOD[0] <= period < WEAK_H5_PERIOD[1]:
return unknown_codeword_fixed_confirmation >= WEAK_H5_MIN
return True
def _opponent_channels(values: NDArray[Any]) -> NDArray[Any]:
red = values[:, :, 0]
green = values[:, :, 1]
blue = values[:, :, 2]
return np.stack((green, red - green, blue - 0.5 * (red + green)), axis=2)
def _template_harmonics(template: NDArray[Any]) -> tuple[NDArray[Any], NDArray[Any]]:
opponent = _opponent_channels(np.asarray(template, dtype=np.float64))
spectrum = np.fft.fft2(opponent, axes=(0, 1))
height, width = template.shape[:2]
candidates: list[tuple[float, int, int]] = []
for row in range(height):
signed_row = row if row <= height // 2 else row - height
for column in range(width):
signed_column = column if column <= width // 2 else column - width
if signed_row < 0 or (signed_row == 0 and signed_column <= 0):
continue
power = float(np.sum(np.abs(spectrum[row, column]) ** 2))
candidates.append((power, signed_row, signed_column))
candidates.sort(reverse=True)
selected = candidates[:HARMONIC_COUNT]
harmonics = np.asarray([(row, column) for _power, row, column in selected], dtype=np.float64)
coefficients = np.asarray([spectrum[int(row) % height, int(column) % width] for row, column in harmonics])
weights = np.abs(coefficients)
weight_sum = float(np.sum(weights))
if weight_sum <= 0.0:
raise ValueError("template has no nonzero periodic harmonics")
return harmonics, weights / weight_sum
def _patch_origins(height: int, width: int) -> list[tuple[int, int, int]]:
if height < PATCH_SIZE or width < PATCH_SIZE:
raise ValueError("registered confirmation needs both image sides to be at least 256 pixels")
y_values = np.linspace(0, height - PATCH_SIZE, min(GRID_SIZE, height // PATCH_SIZE), dtype=np.int64)
x_values = np.linspace(0, width - PATCH_SIZE, min(GRID_SIZE, width // PATCH_SIZE), dtype=np.int64)
origins = [
(int(y), int(x), (y_index + x_index) % 2)
for y_index, y in enumerate(np.unique(y_values))
for x_index, x in enumerate(np.unique(x_values))
]
if {group for _y, _x, group in origins} != {0, 1}:
raise ValueError("registered confirmation needs two independent patch groups")
return origins
def _bilinear_sample(spectrum: NDArray[Any], y: NDArray[Any], x: NDArray[Any]) -> NDArray[Any]:
height, width = spectrum.shape
y_floor = np.floor(y)
x_floor = np.floor(x)
y0 = y_floor.astype(np.int64) % height
x0 = x_floor.astype(np.int64) % width
y1 = (y0 + 1) % height
x1 = (x0 + 1) % width
dy = y - y_floor
dx = x - x_floor
return (
spectrum[y0, x0] * (1.0 - dy) * (1.0 - dx)
+ spectrum[y1, x0] * dy * (1.0 - dx)
+ spectrum[y0, x1] * (1.0 - dy) * dx
+ spectrum[y1, x1] * dy * dx
)
def _patch_unit_values(
pixels: NDArray[Any],
origin_y: int,
origin_x: int,
period: float,
harmonics: NDArray[Any],
denoise_sigma: float,
) -> NDArray[Any]:
patch = np.asarray(
pixels[origin_y : origin_y + PATCH_SIZE, origin_x : origin_x + PATCH_SIZE],
dtype=np.float32,
)
channels = _opponent_channels(patch)
window_1d = np.hanning(PATCH_SIZE).astype(np.float32)
window = window_1d[:, None] * window_1d[None, :]
frequencies_y = harmonics[:, 0] / period
frequencies_x = harmonics[:, 1] / period
sample_y = frequencies_y * PATCH_SIZE
sample_x = frequencies_x * PATCH_SIZE
sampled = np.empty((len(harmonics), 3), dtype=np.complex128)
for channel in range(3):
residual = channels[:, :, channel]
residual -= cv2.GaussianBlur(
residual,
(0, 0),
sigmaX=denoise_sigma,
sigmaY=denoise_sigma,
borderType=cv2.BORDER_REFLECT_101,
)
sampled[:, channel] = _bilinear_sample(np.fft.fft2(residual * window), sample_y, sample_x)
sampled *= np.exp(-2j * math.pi * (frequencies_y * origin_y + frequencies_x * origin_x))[:, None]
magnitudes = np.abs(sampled)
return np.divide(sampled, magnitudes, out=np.zeros_like(sampled), where=magnitudes > 1e-12)
def _coherence(values: list[NDArray[Any]], weights: NDArray[Any]) -> float:
coherence = np.abs(np.mean(np.stack(values), axis=0))
return float(np.sum(coherence * weights))
def _unknown_codeword_fixed_confirmation(
selection_values: list[NDArray[Any]],
confirmation_values: list[NDArray[Any]],
weights: NDArray[Any],
) -> float:
cross_codeword = np.mean(np.stack(confirmation_values), axis=0) * np.conj(
np.mean(np.stack(selection_values), axis=0)
)
confirmation_mask = np.arange(len(weights)) % 2 == 1
masked_weights = weights[confirmation_mask]
return float(np.abs(np.sum(cross_codeword[confirmation_mask] * masked_weights)) / np.sum(masked_weights))
def _canonical_pixels(pixels: NDArray[Any], template: NDArray[Any], period: float) -> NDArray[Any]:
width = max(template.shape[1], round(pixels.shape[1] * template.shape[1] / period))
height = max(template.shape[0], round(pixels.shape[0] * template.shape[0] / period))
if (height, width) == pixels.shape[:2]:
return pixels
interpolation = cv2.INTER_AREA if width < pixels.shape[1] else cv2.INTER_CUBIC
return np.asarray(cv2.resize(pixels, (width, height), interpolation=interpolation))
def _cyclic_correlations(template: NDArray[Any], tile: NDArray[Any]) -> NDArray[Any]:
template_spectrum = np.fft.fft2(template, axes=(0, 1))
tile_spectrum = np.fft.fft2(tile, axes=(0, 1))
return np.fft.ifft2(np.sum(template_spectrum * np.conj(tile_spectrum), axis=2)).real
def _joint_amplitude(
pixels: NDArray[Any],
template: NDArray[Any],
period: float,
denoise_sigma: float,
) -> tuple[float, int, int]:
canonical = _canonical_pixels(pixels, template, period)
tile_height, tile_width = template.shape[:2]
grouped_units: dict[int, list[NDArray[Any]]] = {0: [], 1: []}
origins = _patch_origins(*canonical.shape[:2])
for origin_y, origin_x, group in origins:
aligned_y = (origin_y // tile_height) * tile_height
aligned_x = (origin_x // tile_width) * tile_width
folded = fold_residual_template(
canonical[aligned_y : aligned_y + PATCH_SIZE, aligned_x : aligned_x + PATCH_SIZE],
tile_height=tile_height,
tile_width=tile_width,
denoise_sigma=denoise_sigma,
)
unit, _norm = unit_tile(folded)
grouped_units[group].append(unit)
selection_tile, _selection_norm = unit_tile(np.mean(grouped_units[0], axis=0))
confirmation_tile, _confirmation_norm = unit_tile(np.mean(grouped_units[1], axis=0))
selection_correlations = _cyclic_correlations(template, selection_tile)
confirmation_correlations = _cyclic_correlations(template, confirmation_tile)
shift_y, shift_x = np.unravel_index(int(np.argmax(selection_correlations)), selection_correlations.shape)
return (
min(
float(selection_correlations[shift_y, shift_x]),
float(confirmation_correlations[shift_y, shift_x]),
),
len(grouped_units[0]),
len(grouped_units[1]),
)
def registered_confirmation_components(
pixels: NDArray[Any],
template: NDArray[Any],
period: float,
denoise_sigma: float,
) -> RegisteredConfirmationComponents:
"""Measure the frozen split-patch gates at one registered carrier period."""
if pixels.ndim != 3 or pixels.shape[2] != 3:
raise ValueError("pixels must have shape (height, width, 3)")
if not math.isfinite(period) or period <= 0.0:
raise ValueError("registered period must be finite and positive")
harmonics, weights = _template_harmonics(template)
grouped_values: dict[int, list[NDArray[Any]]] = {0: [], 1: []}
for origin_y, origin_x, group in _patch_origins(*pixels.shape[:2]):
grouped_values[group].append(
_patch_unit_values(
pixels,
origin_y,
origin_x,
period,
harmonics,
denoise_sigma,
)
)
amplitude, selection_patches, confirmation_patches = _joint_amplitude(
pixels,
template,
period,
denoise_sigma,
)
return RegisteredConfirmationComponents(
period=period,
joint_coherence=min(
_coherence(grouped_values[0], weights),
_coherence(grouped_values[1], weights),
),
joint_amplitude=amplitude,
unknown_codeword_fixed_confirmation=_unknown_codeword_fixed_confirmation(
grouped_values[0],
grouped_values[1],
weights,
),
selection_patches=selection_patches,
confirmation_patches=confirmation_patches,
)
@@ -0,0 +1,664 @@
"""Opt-in scale registration for the measured periodic SynthID carrier."""
# The optional numeric libraries do not provide complete types for this path.
# pyright: reportMissingTypeStubs=false, reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false
from __future__ import annotations
import itertools
import math
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
import cv2
import numpy as np
from synthid_runtime._synthid_confirmation import (
RegisteredConfirmationComponents,
registered_confirmation_components,
)
from synthid_runtime.synthid_detector import folded_template_score
if TYPE_CHECKING:
from numpy.typing import NDArray
_PYRAMID_SCALES = (0.75, 1.0, 1.25)
_SEARCH_PERIODS = np.linspace(5.0, 32.0, 541, dtype=np.float64)
_CANONICAL_PERIODS = np.linspace(7.5, 24.5, 1701, dtype=np.float64)
_OPPONENT_SEARCH_PERIODS = np.linspace(7.5, 14.5, 141, dtype=np.float64)
_FINE_OPPONENT_COARSE_PERIODS = np.linspace(7.5, 9.0, 31, dtype=np.float64)
_FINE_OPPONENT_PROBE_SIZE = 384
_PERIOD_THRESHOLDS = (
(7.5, 8.5, 0.3770629524888979),
(8.5, 10.0, 0.25174716660523494),
(10.0, 12.0, 0.284692023502354),
(12.0, 14.0, 0.19794247706938645),
(14.0, 16.0, 0.33930082812296375),
(16.0, 18.0, 0.28915284982686323),
(18.0, 20.0, 0.22885510746595789),
(20.0, 22.0, 0.24570317032768269),
(22.0, 24.5, 0.3142958338390489),
)
REGISTERED_HIGH_BAND_THRESHOLD = 0.075
OPPONENT_REGISTERED_MIN_PERIOD = 7.9
OPPONENT_REGISTERED_MAX_PERIOD = 12.0
OPPONENT_REGISTERED_CODEC_VETO_MAX_PERIOD = 8.1
OPPONENT_REGISTERED_MAX_P8_EDGE_RATIO = 1.05
FINE_OPPONENT_REGISTERED_MIN_PERIOD = 7.5
FINE_OPPONENT_REGISTERED_MAX_PERIOD = 9.0
OPPONENT_REGISTERED_FIXED_MIN = 0.16
OPPONENT_REGISTERED_RED_GREEN_MIN = 0.60
OPPONENT_REGISTERED_BLUE_YELLOW_MIN = 0.55
@dataclass(frozen=True)
class RegisteredComponents:
"""Calibrated components of one scale-registered decision."""
raw_score: float
amplitude_threshold: float
selected_period: float
spectral_period: float
high_band_score: float
confirmation: RegisteredConfirmationComponents | None = None
@property
def base_decision_score(self) -> float:
"""Return the unchanged registered-v2 decision statistic."""
if self.selected_period != self.spectral_period:
return 0.0
return min(
self.raw_score / self.amplitude_threshold,
self.high_band_score / REGISTERED_HIGH_BAND_THRESHOLD,
)
@property
def decision_score(self) -> float:
"""Return the base score only after split confirmation passes."""
base_score = self.base_decision_score
if base_score < 1.0:
return base_score
if self.confirmation is None or not self.confirmation.passes:
return 0.0
return base_score
@dataclass(frozen=True)
class OpponentRegisteredComponents:
"""Auditable margins for the bounded opponent-color fallback."""
selected_period: float
spectral_period: float
spectral_score: float
fixed_score: float
red_green_spatial: float
blue_yellow_spatial: float
candidate_count: int
red_green_p8_edge_ratio: float | None
blue_yellow_p8_edge_ratio: float | None
@property
def base_decision_score(self) -> float:
"""Return the minimum normalized color-carrier margin."""
return min(
self.fixed_score / OPPONENT_REGISTERED_FIXED_MIN,
self.red_green_spatial / OPPONENT_REGISTERED_RED_GREEN_MIN,
self.blue_yellow_spatial / OPPONENT_REGISTERED_BLUE_YELLOW_MIN,
)
@property
def decision_score(self) -> float:
"""Return the margin only inside the independently challenged period band."""
if not OPPONENT_REGISTERED_MIN_PERIOD <= self.selected_period <= OPPONENT_REGISTERED_MAX_PERIOD:
return 0.0
if self.selected_period <= OPPONENT_REGISTERED_CODEC_VETO_MAX_PERIOD:
ratios = (self.red_green_p8_edge_ratio, self.blue_yellow_p8_edge_ratio)
if any(value is None or value > OPPONENT_REGISTERED_MAX_P8_EDGE_RATIO for value in ratios):
return 0.0
return self.base_decision_score
@property
def fine_decision_score(self) -> float:
"""Return the margin for the separately calibrated fine-period expert."""
if not FINE_OPPONENT_REGISTERED_MIN_PERIOD <= self.selected_period <= FINE_OPPONENT_REGISTERED_MAX_PERIOD:
return 0.0
if self.selected_period <= OPPONENT_REGISTERED_CODEC_VETO_MAX_PERIOD:
ratios = (self.red_green_p8_edge_ratio, self.blue_yellow_p8_edge_ratio)
if any(value is None or value > OPPONENT_REGISTERED_MAX_P8_EDGE_RATIO for value in ratios):
return 0.0
return self.base_decision_score
def _resize(pixels: NDArray[Any], width: int, height: int) -> NDArray[Any]:
interpolation = cv2.INTER_AREA if width < pixels.shape[1] else cv2.INTER_CUBIC
return np.asarray(cv2.resize(pixels, (width, height), interpolation=interpolation))
def _template_frequency_features(
template: NDArray[Any],
) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
spectrum = np.fft.fft2(template, axes=(0, 1))
power = np.sum(np.abs(spectrum) ** 2, axis=2)
power[0, 0] = 0.0
indices = np.argsort(power.ravel())[::-1][:30]
rows, columns = np.unravel_index(indices, power.shape)
height, width = template.shape[:2]
signed_rows = np.where(rows <= height // 2, rows, rows - height)
signed_columns = np.where(columns <= width // 2, columns, columns - width)
harmonics = np.column_stack((signed_rows, signed_columns)).astype(np.float64)
return harmonics, spectrum[rows, columns], spectrum
def _bilinear_sample(
spectrum: NDArray[Any],
y: NDArray[Any],
x: NDArray[Any],
) -> NDArray[Any]:
height, width = spectrum.shape
y_floor = np.floor(y)
x_floor = np.floor(x)
y0 = y_floor.astype(np.int64) % height
x0 = x_floor.astype(np.int64) % width
y1 = (y0 + 1) % height
x1 = (x0 + 1) % width
dy = y - y_floor
dx = x - x_floor
return (
spectrum[y0, x0] * (1.0 - dy) * (1.0 - dx)
+ spectrum[y1, x0] * dy * (1.0 - dx)
+ spectrum[y0, x1] * (1.0 - dy) * dx
+ spectrum[y1, x1] * dy * dx
)
def _spectral_curve(
pixels: NDArray[Any],
periods: NDArray[Any],
harmonics: NDArray[Any],
coefficients: NDArray[Any],
) -> NDArray[Any]:
height, width = pixels.shape[:2]
y = (periods[:, None] ** -1) * harmonics[None, :, 0] * height
x = (periods[:, None] ** -1) * harmonics[None, :, 1] * width
sampled = np.empty((len(periods), len(harmonics), 3), dtype=np.complex128)
for channel in range(3):
residual = pixels[:, :, channel].astype(np.float32)
residual -= cv2.GaussianBlur(
residual,
(0, 0),
sigmaX=1.0,
sigmaY=1.0,
borderType=cv2.BORDER_REFLECT_101,
)
spectrum = np.fft.fft2(residual)
sampled[:, :, channel] = _bilinear_sample(spectrum, y % height, x % width)
numerator = np.real(np.sum(np.conj(coefficients)[None, :, :] * sampled, axis=(1, 2)))
denominator = np.linalg.norm(coefficients) * np.linalg.norm(sampled, axis=(1, 2))
return np.divide(
numerator,
denominator,
out=np.zeros_like(numerator),
where=denominator > 0.0,
)
def _period_candidates(
periods: NDArray[Any],
scores: NDArray[Any],
count: int = 3,
) -> list[float]:
candidates: list[float] = []
for index in np.argsort(scores)[::-1]:
period = float(periods[index])
if any(abs(period - existing_period) < 0.25 for existing_period in candidates):
continue
candidates.append(period)
if len(candidates) == count:
break
return candidates
def _period_threshold(period: float) -> float:
for index, (lower, upper, threshold) in enumerate(_PERIOD_THRESHOLDS):
if lower <= period < upper or (index == len(_PERIOD_THRESHOLDS) - 1 and period == upper):
return threshold
raise ValueError(f"registered period {period} is outside the calibrated range")
def _high_band_score(
folded: NDArray[Any],
template_spectrum: NDArray[Any],
) -> float:
folded_spectrum = np.fft.fft2(folded, axes=(0, 1))
tile_height, tile_width = template_spectrum.shape[:2]
y_coordinates = np.minimum(np.arange(tile_height), tile_height - np.arange(tile_height))
x_coordinates = np.minimum(np.arange(tile_width), tile_width - np.arange(tile_width))
radius = np.sqrt(y_coordinates[:, None] ** 2 + x_coordinates[None, :] ** 2)
correlations = []
for lower, upper in ((4.5, 6.5), (6.5, 12.0)):
mask = (radius >= lower) & (radius < upper)
selected_folded = folded_spectrum[mask]
selected_template = template_spectrum[mask]
denominator = np.linalg.norm(selected_folded) * np.linalg.norm(selected_template)
correlations.append(
float(np.real(np.vdot(selected_template, selected_folded)) / denominator) if denominator > 0.0 else 0.0
)
return min(correlations)
def _best_canonical(
pixels: NDArray[Any],
periods: list[float],
template: NDArray[Any],
sigma: float,
) -> tuple[float, NDArray[Any], NDArray[Any], float]:
best_score = -math.inf
best_canonical: NDArray[Any] | None = None
best_folded: NDArray[Any] | None = None
best_period: float | None = None
for period in periods:
predicted_width = round(pixels.shape[1] * template.shape[1] / period)
for delta in range(-4, 5):
width = predicted_width + delta
height = round(pixels.shape[0] * width / pixels.shape[1])
canonical = _resize(pixels, width, height)
score, folded = folded_template_score(canonical, template, sigma)
if score > best_score:
best_score = score
best_canonical = canonical
best_folded = folded
best_period = period
if best_canonical is None or best_folded is None or best_period is None:
raise RuntimeError("scale registration produced no canonical view")
return float(best_score), best_canonical, best_folded, best_period
def _quadrant_median(
canonical: NDArray[Any],
template: NDArray[Any],
sigma: float,
) -> float:
tile_height, tile_width = template.shape[:2]
split_y = max(tile_height, (canonical.shape[0] // (2 * tile_height)) * tile_height)
split_x = max(tile_width, (canonical.shape[1] // (2 * tile_width)) * tile_width)
scores = []
for region in (
canonical[:split_y, :split_x],
canonical[:split_y, split_x:],
canonical[split_y:, :split_x],
canonical[split_y:, split_x:],
):
score, _folded = folded_template_score(region, template, sigma)
scores.append(score)
return float(np.median(scores))
def _pyramid_locked_mean(
pixels: NDArray[Any],
harmonics: NDArray[Any],
coefficients: NDArray[Any],
base_curve: NDArray[Any],
) -> float:
curves = []
candidates = []
for scale in _PYRAMID_SCALES:
if scale == 1.0:
curve = base_curve
else:
level = _resize(
pixels,
max(16, round(pixels.shape[1] * scale)),
max(16, round(pixels.shape[0] * scale)),
)
curve = _spectral_curve(level, _SEARCH_PERIODS, harmonics, coefficients)
curves.append(curve)
candidates.append(_period_candidates(_SEARCH_PERIODS, curve))
combinations = itertools.product(*candidates)
def spread(combination: tuple[float, ...]) -> float:
normalized_periods = [
candidate / scale
for candidate, scale in zip(
combination,
_PYRAMID_SCALES,
strict=True,
)
]
return float(np.std(np.log(normalized_periods)))
best = min(
combinations,
key=spread,
)
base_period = float(np.median([candidate / scale for candidate, scale in zip(best, _PYRAMID_SCALES, strict=True)]))
locked = [
float(np.interp(base_period * scale, _SEARCH_PERIODS, curve))
for curve, scale in zip(curves, _PYRAMID_SCALES, strict=True)
]
return float(np.mean(locked))
def _opponent_pair(values: NDArray[Any]) -> NDArray[Any]:
"""Return Red-minus-Green and Blue-minus-Yellow color planes."""
red = values[:, :, 0]
green = values[:, :, 1]
blue = values[:, :, 2]
return np.stack((red - green, blue - 0.5 * (red + green)), axis=2)
def _opponent_period_curve(
pixels: NDArray[Any],
template: NDArray[Any],
periods: NDArray[Any] = _OPPONENT_SEARCH_PERIODS,
) -> NDArray[Any]:
"""Return signed opponent-color coherence across the frozen search grid."""
template_opponent = _opponent_pair(np.asarray(template, dtype=np.float64))
template_spectrum = np.fft.fft2(template_opponent, axes=(0, 1))
power = np.sum(np.abs(template_spectrum) ** 2, axis=2)
power[0, 0] = 0.0
indices = np.argsort(power.ravel())[::-1][:30]
rows, columns = np.unravel_index(indices, power.shape)
height, width = template.shape[:2]
signed_rows = np.where(rows <= height // 2, rows, rows - height)
signed_columns = np.where(columns <= width // 2, columns, columns - width)
harmonics = np.column_stack((signed_rows, signed_columns)).astype(np.float64)
coefficients = template_spectrum[rows, columns]
image_height, image_width = pixels.shape[:2]
sample_y = periods[:, None] ** -1 * harmonics[None, :, 0] * image_height
sample_x = periods[:, None] ** -1 * harmonics[None, :, 1] * image_width
sampled = np.empty((len(periods), len(harmonics), 2), dtype=np.complex128)
image_opponent = _opponent_pair(np.asarray(pixels, dtype=np.float32))
for channel in range(2):
residual = image_opponent[:, :, channel]
residual -= cv2.GaussianBlur(
residual,
(0, 0),
sigmaX=1.0,
sigmaY=1.0,
borderType=cv2.BORDER_REFLECT_101,
)
sampled[:, :, channel] = _bilinear_sample(
np.fft.fft2(residual),
sample_y % image_height,
sample_x % image_width,
)
numerator = np.real(np.sum(np.conj(coefficients)[None, :, :] * sampled, axis=(1, 2)))
denominator = np.linalg.norm(coefficients) * np.linalg.norm(sampled, axis=(1, 2))
return np.divide(numerator, denominator, out=np.zeros_like(numerator), where=denominator > 0.0)
def _opponent_period_candidates(scores: NDArray[Any], count: int = 3) -> list[int]:
"""Return separated period indices in descending spectral-score order."""
candidates: list[int] = []
for index in np.argsort(scores)[::-1]:
period = float(_OPPONENT_SEARCH_PERIODS[index])
if any(abs(period - float(_OPPONENT_SEARCH_PERIODS[prior])) < 0.5 for prior in candidates):
continue
candidates.append(int(index))
if len(candidates) == count:
break
return candidates
def _canonical_at_period(
pixels: NDArray[Any],
template: NDArray[Any],
period: float,
) -> NDArray[Any]:
"""Resample PIXELS so PERIOD maps to the frozen template period."""
width = max(template.shape[1], round(pixels.shape[1] * template.shape[1] / period))
height = max(template.shape[0], round(pixels.shape[0] * template.shape[0] / period))
if (height, width) == pixels.shape[:2]:
return pixels
return _resize(pixels, width, height)
def _correlation(left: NDArray[Any], right: NDArray[Any]) -> float:
"""Return the signed real cosine between equal-shaped arrays."""
denominator = float(np.linalg.norm(left) * np.linalg.norm(right))
return float(np.real(np.vdot(right, left)) / denominator) if denominator > 0.0 else 0.0
def _period8_edge_ratio(values: NDArray[Any]) -> float:
"""Measure native 8-pixel block edges relative to non-block phases."""
phase_values = np.zeros(8, dtype=np.float64)
for axis in (0, 1):
differences = np.abs(np.diff(values, axis=axis))
indices = np.arange(differences.shape[axis])
for phase in range(8):
selected = indices[(indices + 1) % 8 == phase]
phase_values[phase] += 0.5 * float(np.take(differences, selected, axis=axis).mean())
baseline = float(np.median(phase_values[[1, 2, 3, 5, 6, 7]]))
return float(phase_values[0] / baseline) if baseline > 1e-9 else math.inf
def _period8_opponent_edge_ratios(pixels: NDArray[Any]) -> tuple[float, float]:
"""Return codec-grid ratios for the two opponent-color planes."""
opponent = _opponent_pair(np.asarray(pixels, dtype=np.float32))
return _period8_edge_ratio(opponent[:, :, 0]), _period8_edge_ratio(opponent[:, :, 1])
def _opponent_components_at_period(
pixels: NDArray[Any],
template: NDArray[Any],
sigma: float,
period: float,
*,
spectral_period: float,
spectral_score: float,
candidate_count: int,
period8_edge_ratios: tuple[float, float] | None = None,
) -> OpponentRegisteredComponents:
"""Measure one period without selecting it from the image being scored."""
canonical = _canonical_at_period(pixels, template, period)
fixed_score, folded = folded_template_score(canonical, template, sigma)
folded_opponent = _opponent_pair(folded)
template_opponent = _opponent_pair(template)
red_green_p8_edge_ratio, blue_yellow_p8_edge_ratio = period8_edge_ratios or (None, None)
return OpponentRegisteredComponents(
selected_period=period,
spectral_period=spectral_period,
spectral_score=spectral_score,
fixed_score=fixed_score,
red_green_spatial=_correlation(folded_opponent[:, :, 0], template_opponent[:, :, 0]),
blue_yellow_spatial=_correlation(folded_opponent[:, :, 1], template_opponent[:, :, 1]),
candidate_count=candidate_count,
red_green_p8_edge_ratio=red_green_p8_edge_ratio,
blue_yellow_p8_edge_ratio=blue_yellow_p8_edge_ratio,
)
def opponent_registered_components(
pixels: NDArray[Any],
template: NDArray[Any],
sigma: float,
) -> OpponentRegisteredComponents:
"""Measure the bounded lossless-resize carrier in opponent-color space."""
curve = _opponent_period_curve(pixels, template)
candidate_indices = _opponent_period_candidates(curve)
observations: list[OpponentRegisteredComponents] = []
period8_edge_ratios: tuple[float, float] | None = None
for index in candidate_indices:
period = float(_OPPONENT_SEARCH_PERIODS[index])
if period <= OPPONENT_REGISTERED_CODEC_VETO_MAX_PERIOD and period8_edge_ratios is None:
period8_edge_ratios = _period8_opponent_edge_ratios(pixels)
observations.append(
_opponent_components_at_period(
pixels,
template,
sigma,
period,
spectral_period=float(_OPPONENT_SEARCH_PERIODS[int(np.argmax(curve))]),
spectral_score=float(curve[index]),
candidate_count=len(candidate_indices),
period8_edge_ratios=period8_edge_ratios,
)
)
if not observations:
raise RuntimeError("opponent-color registration produced no candidates")
return max(observations, key=lambda observation: observation.base_decision_score)
def _fine_opponent_period_groups(curve: NDArray[Any]) -> list[list[float]]:
"""Return fine period grids around separated absolute spectral peaks."""
centers: list[float] = []
for index in np.argsort(np.abs(curve))[::-1]:
period = float(_FINE_OPPONENT_COARSE_PERIODS[index])
if any(abs(period - existing) < 0.2 for existing in centers):
continue
centers.append(period)
if len(centers) == 3:
break
return [
sorted(
{
round(float(period), 2)
for period in np.arange(center - 0.36, center + 0.361, 0.01)
if FINE_OPPONENT_REGISTERED_MIN_PERIOD <= period <= FINE_OPPONENT_REGISTERED_MAX_PERIOD
}
)
for center in centers
]
def fine_opponent_registered_components(
pixels: NDArray[Any],
template: NDArray[Any],
sigma: float,
) -> OpponentRegisteredComponents:
"""Select and score the calibrated fine-period lossless-resize expert."""
curve = _opponent_period_curve(pixels, template, _FINE_OPPONENT_COARSE_PERIODS)
spectral_index = int(np.argmax(np.abs(curve)))
spectral_period = float(_FINE_OPPONENT_COARSE_PERIODS[spectral_index])
period_groups = _fine_opponent_period_groups(curve)
probe = pixels[
: min(_FINE_OPPONENT_PROBE_SIZE, pixels.shape[0]),
: min(_FINE_OPPONENT_PROBE_SIZE, pixels.shape[1]),
]
candidate_count = sum(len(group) for group in period_groups)
unique_periods = sorted({period for group in period_groups for period in group})
probe_by_period = {
period: _opponent_components_at_period(
probe,
template,
sigma,
period,
spectral_period=spectral_period,
spectral_score=float(np.interp(period, _FINE_OPPONENT_COARSE_PERIODS, curve)),
candidate_count=candidate_count,
)
for period in unique_periods
}
probe_groups = [[probe_by_period[period] for period in group] for group in period_groups]
probe_observations = [observation for group in probe_groups for observation in group]
finalist_periods = {
observation.selected_period
for group in probe_groups
for observation in sorted(group, key=lambda value: value.base_decision_score, reverse=True)[:2]
}
finalist_periods.update(
observation.selected_period
for observation in sorted(
probe_observations,
key=lambda value: value.base_decision_score,
reverse=True,
)[:5]
)
period8_edge_ratios = (
_period8_opponent_edge_ratios(pixels)
if any(period <= OPPONENT_REGISTERED_CODEC_VETO_MAX_PERIOD for period in finalist_periods)
else None
)
observations = [
_opponent_components_at_period(
pixels,
template,
sigma,
period,
spectral_period=spectral_period,
spectral_score=float(np.interp(period, _FINE_OPPONENT_COARSE_PERIODS, curve)),
candidate_count=len(probe_observations),
period8_edge_ratios=period8_edge_ratios,
)
for period in sorted(finalist_periods)
]
if not observations:
raise RuntimeError("fine opponent-color registration produced no candidates")
return max(observations, key=lambda observation: observation.base_decision_score)
def registered_components(
pixels: NDArray[Any],
template: NDArray[Any],
sigma: float,
) -> RegisteredComponents:
"""Measure a carrier after bounded scale registration."""
harmonics, coefficients, template_spectrum = _template_frequency_features(template)
combined_periods = np.concatenate((_SEARCH_PERIODS, _CANONICAL_PERIODS))
combined_curve = _spectral_curve(pixels, combined_periods, harmonics, coefficients)
base_curve = combined_curve[: len(_SEARCH_PERIODS)]
canonical_curve = combined_curve[len(_SEARCH_PERIODS) :]
candidates = _period_candidates(_CANONICAL_PERIODS, canonical_curve)
baseline, canonical, folded, selected_period = _best_canonical(pixels, candidates, template, sigma)
quadrant = _quadrant_median(canonical, template, sigma)
pyramid = _pyramid_locked_mean(
pixels,
harmonics,
coefficients,
base_curve,
)
raw_score = float((baseline + quadrant + pyramid) / 3.0)
components = RegisteredComponents(
raw_score=raw_score,
amplitude_threshold=_period_threshold(selected_period),
selected_period=selected_period,
spectral_period=candidates[0],
high_band_score=_high_band_score(folded, template_spectrum),
)
if components.base_decision_score < 1.0:
return components
try:
confirmation = registered_confirmation_components(
pixels,
template,
selected_period,
sigma,
)
except ValueError:
return components
return RegisteredComponents(
raw_score=components.raw_score,
amplitude_threshold=components.amplitude_threshold,
selected_period=components.selected_period,
spectral_period=components.spectral_period,
high_band_score=components.high_band_score,
confirmation=confirmation,
)
def registered_score(
pixels: NDArray[Any],
template: NDArray[Any],
sigma: float,
) -> float:
"""Return the calibrated registered decision statistic."""
return registered_components(pixels, template, sigma).decision_score
def opponent_registered_score(
pixels: NDArray[Any],
template: NDArray[Any],
sigma: float,
) -> float:
"""Return the bounded opponent-color fallback decision statistic."""
return opponent_registered_components(pixels, template, sigma).decision_score
def fine_opponent_registered_score(
pixels: NDArray[Any],
template: NDArray[Any],
sigma: float,
) -> float:
"""Return the separately calibrated fine-period decision statistic."""
return fine_opponent_registered_components(pixels, template, sigma).fine_decision_score
+542
View File
@@ -0,0 +1,542 @@
"""Detect the confirmed periodic SynthID image carrier at calibrated image sizes.
This is a positive-only detector for one measured carrier epoch, not Google's
private payload decoder. A positive result is strong local evidence for the
carrier. An indeterminate result means only that the selected detector did not
find it; image sizes outside that mode's calibrated range are reported separately.
Research runtime. Not exported by ``remove_ai_watermarks``. Needs numpy and OpenCV.
"""
# The optional numeric libraries do not provide complete types for this path.
# pyright: reportMissingTypeStubs=false, reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false
from __future__ import annotations
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal
if TYPE_CHECKING:
from numpy.typing import NDArray
SynthIDDetectionStatus = Literal["detected", "indeterminate", "unsupported"]
DETECTOR_ID = "synthid-periodic-tile-v2"
REGISTERED_DETECTOR_ID = "synthid-periodic-tile-registered-v3"
OPPONENT_REGISTERED_DETECTOR_ID = "synthid-periodic-tile-opponent-registered-v1"
FINE_OPPONENT_REGISTERED_DETECTOR_ID = "synthid-periodic-tile-opponent-fine-registered-v1"
LARGE_DETECTOR_ID = "synthid-periodic-tile-large-v1"
MODEL_FILENAME = "synthid_periodic_tile_2048_v1.npz"
# The template remains frozen at this model geometry. Runtime images are never
# resized. The supported pixel-count interval is the separately challenged domain:
# below it too few repetitions make the positive-only statistic unreliable, and
# above it resource use and specificity have not been calibrated.
MODEL_WIDTH = 2048
MODEL_HEIGHT = 2048
MIN_SUPPORTED_PIXELS = 1_000_000
MAX_SUPPORTED_PIXELS = 18_000_000
TILE_THRESHOLD = 0.17357069773071196
REGISTERED_MIN_SUPPORTED_PIXELS = 250_000
REGISTERED_MAX_SUPPORTED_PIXELS = 10_000_000
# Registered-v3 can confirm a positive only when both disjoint checkerboard
# groups contain a complete frozen 256-pixel patch. Narrower geometries need a
# separately calibrated adaptive-patch expert and must not masquerade as misses.
REGISTERED_MIN_SIDE = 256
# The registered score preserves the minimum normalized v2 margin only after
# independent split-patch phase, amplitude, and held-out codeword confirmation.
REGISTERED_THRESHOLD = 1.0
# The opponent-color fallback is a narrower precision-first route for lossless
# scale changes. Smaller rasters retained a natural period-10 false positive.
OPPONENT_REGISTERED_THRESHOLD = 1.0
OPPONENT_REGISTERED_MIN_PIXELS = 1_000_000
OPPONENT_REGISTERED_MIN_SIDE = 768
# Fine-period registration is separately frozen for the dense 0.47-0.55
# lossless-resize challenge. Its more expensive selector is bounded to the
# geometry range covered by the locked and reserve negative sets.
FINE_OPPONENT_REGISTERED_THRESHOLD = 1.05
FINE_OPPONENT_REGISTERED_MIN_PIXELS = 1_000_000
FINE_OPPONENT_REGISTERED_MAX_PIXELS = 5_000_000
FINE_OPPONENT_REGISTERED_MIN_SIDE = 768
# The large-image score combines all-window fixed and spatial opponent gates
# with an any-window signed opponent mid-band gate. The one vulnerable portrait
# geometry has an additional Green mid-band upper gate.
LARGE_THRESHOLD = 1.0
LARGE_MIN_PIXELS = 10_000_000
LARGE_MAX_PIXELS = 18_000_000
LARGE_WINDOW = 2_048
LARGE_PHASE = 16
LARGE_FIXED_SCORE_MIN = 0.14
LARGE_RED_GREEN_SPATIAL_MIN = 0.90
LARGE_BLUE_YELLOW_SPATIAL_MIN = 0.70
LARGE_BLUE_YELLOW_MID_BAND_MAX = -0.15
LARGE_PORTRAIT_GEOMETRY = (3_072, 5_504)
LARGE_PORTRAIT_GREEN_MID_BAND_MAX = 0.06
INSTALL_HINT = "install numpy and opencv-python-headless"
@dataclass(frozen=True)
class SynthIDDetection:
"""One local periodic-lattice verdict.
The family this reports is NOT the watermark, and the field names say so. The
statistic is destroyed by a crop of seven pixels, while SynthID's published
evaluation retains 99.97% TPR under aggressive crop and resize, so what
crosses the threshold is a generation-pipeline lattice anchored at the image
origin. It identifies the pipeline, not the mark. The measurement is in
``docs/synthid-detector-research.md`` and ``docs/synthid-classifiers.md``.
"""
status: SynthIDDetectionStatus
width: int
height: int
score: float | None
threshold: float
detector: str = DETECTOR_ID
reason: str | None = None
signal_family: str = "generation-pipeline-lattice"
provider_scope: str = "provider-neutral"
backend: str = "local-pixel"
metadata_used_for_verdict: bool = False
pixels_preserved: bool = True
# Consumers cannot be expected to read a caveat in prose, so the two measured
# failure modes travel with every verdict.
tile_aligned_crop_required: bool = True
identifies_watermark: bool = False
@property
def detected(self) -> bool:
"""Whether the supported carrier crossed its frozen threshold."""
return self.status == "detected"
def to_dict(self) -> dict[str, str | int | float | bool | None]:
"""Return a JSON-safe result without a local file path."""
return {
"status": self.status,
"width": self.width,
"height": self.height,
"score": self.score,
"threshold": self.threshold,
"detector": self.detector,
"reason": self.reason,
"signal_family": self.signal_family,
"provider_scope": self.provider_scope,
"backend": self.backend,
"metadata_used_for_verdict": self.metadata_used_for_verdict,
"pixels_preserved": self.pixels_preserved,
"tile_aligned_crop_required": self.tile_aligned_crop_required,
"identifies_watermark": self.identifies_watermark,
}
@dataclass(frozen=True)
class LargeImageComponents:
"""Auditable margins for the calibrated large-image carrier branch."""
width: int
height: int
minimum_fixed_score: float
minimum_red_green_spatial: float
minimum_blue_yellow_spatial: float
minimum_blue_yellow_mid_band: float
maximum_green_mid_band: float
@property
def decision_score(self) -> float:
"""Return the minimum normalized gate margin; one is the boundary."""
margins = [
self.minimum_fixed_score / LARGE_FIXED_SCORE_MIN,
self.minimum_red_green_spatial / LARGE_RED_GREEN_SPATIAL_MIN,
self.minimum_blue_yellow_spatial / LARGE_BLUE_YELLOW_SPATIAL_MIN,
self.minimum_blue_yellow_mid_band / LARGE_BLUE_YELLOW_MID_BAND_MAX,
]
if (self.width, self.height) == LARGE_PORTRAIT_GEOMETRY:
margins.append(1.0 + LARGE_PORTRAIT_GREEN_MID_BAND_MAX - self.maximum_green_mid_band)
return min(margins)
def is_available() -> bool:
"""True when numpy and OpenCV import."""
import importlib.util
return importlib.util.find_spec("cv2") is not None and importlib.util.find_spec("numpy") is not None
@lru_cache(maxsize=1)
def _load_template() -> tuple[NDArray[Any], float, int, int, int, int]:
"""Load and validate the bundled pickle-free detector model."""
import numpy as np
model_path = Path(__file__).resolve().parent / MODEL_FILENAME
with np.load(model_path, allow_pickle=False) as artifact:
if int(artifact["format_version"]) != 1:
raise RuntimeError("unsupported SynthID detector model format")
height = int(artifact["height"])
width = int(artifact["width"])
tile_height = int(artifact["tile_height"])
tile_width = int(artifact["tile_width"])
denoise_sigma = float(artifact["denoise_sigma"])
template = np.asarray(artifact["template"], dtype=np.float64)
if not _geometry_supported(width, height):
raise RuntimeError("bundled SynthID detector has unexpected geometry")
if template.shape != (tile_height, tile_width, 3):
raise RuntimeError("bundled SynthID detector has an invalid template shape")
if not np.all(np.isfinite(template)) or not np.isclose(np.linalg.norm(template), 1.0):
raise RuntimeError("bundled SynthID detector has an invalid template")
if not np.isfinite(denoise_sigma) or denoise_sigma <= 0.0:
raise RuntimeError("bundled SynthID detector has an invalid denoise sigma")
return template, denoise_sigma, height, width, tile_height, tile_width
def fold_residual_template(
pixels: NDArray[Any],
*,
tile_height: int,
tile_width: int,
denoise_sigma: float,
) -> NDArray[Any]:
"""Estimate a zero-mean periodic residual template by modulo folding."""
import cv2
import numpy as np
if pixels.ndim != 3 or pixels.shape[2] != 3:
raise ValueError("pixels must have shape (height, width, 3)")
if tile_height < 1 or tile_width < 1 or denoise_sigma <= 0.0:
raise ValueError("tile dimensions and denoise sigma must be positive")
height, width = pixels.shape[:2]
if height < tile_height or width < tile_width:
raise ValueError("image geometry must be at least as large as the tile geometry")
divisible = height % tile_height == 0 and width % tile_width == 0
full_height = height - height % tile_height
full_width = width - width % tile_width
repeats_y = full_height // tile_height
repeats_x = full_width // tile_width
remaining_height = height - full_height
remaining_width = width - full_width
counts = np.full((tile_height, tile_width), repeats_y * repeats_x, dtype=np.int64)
counts[:remaining_height] += repeats_x
counts[:, :remaining_width] += repeats_y
counts[:remaining_height, :remaining_width] += 1
# OpenCV filters channels independently. Processing one channel at a time
# keeps the 18 MP upper bound from requiring two full three-channel float32
# buffers in addition to the decoded image.
folded = np.empty((tile_height, tile_width, 3), dtype=np.float64)
for channel in range(3):
residual = pixels[:, :, channel].astype(np.float32)
residual -= cv2.GaussianBlur(
residual,
(0, 0),
sigmaX=denoise_sigma,
sigmaY=denoise_sigma,
borderType=cv2.BORDER_REFLECT_101,
)
if divisible:
folded[:, :, channel] = residual.reshape(
repeats_y,
tile_height,
repeats_x,
tile_width,
).mean(axis=(0, 2), dtype=np.float64)
continue
folded_sum = (
residual[:full_height, :full_width]
.reshape(
repeats_y,
tile_height,
repeats_x,
tile_width,
)
.sum(axis=(0, 2), dtype=np.float64)
)
if remaining_height:
bottom = residual[full_height:, :full_width].reshape(
remaining_height,
repeats_x,
tile_width,
)
folded_sum[:remaining_height] += bottom.sum(axis=1, dtype=np.float64)
if remaining_width:
right = residual[:full_height, full_width:].reshape(
repeats_y,
tile_height,
remaining_width,
)
folded_sum[:, :remaining_width] += right.sum(axis=0, dtype=np.float64)
if remaining_height and remaining_width:
folded_sum[:remaining_height, :remaining_width] += residual[
full_height:,
full_width:,
]
folded[:, :, channel] = folded_sum / counts
return folded - np.mean(folded, axis=(0, 1), keepdims=True)
def unit_tile(tile: NDArray[Any]) -> tuple[NDArray[Any], float]:
"""Return TILE normalized by its L2 norm and the original norm."""
import numpy as np
norm = float(np.linalg.norm(tile))
if norm == 0.0:
return np.zeros_like(tile, dtype=np.float64), 0.0
return np.asarray(tile, dtype=np.float64) / norm, norm
def _image_size(image_path: Path) -> tuple[int, int]:
from PIL import Image
with Image.open(image_path) as image:
return image.size
def _geometry_supported(width: int, height: int) -> bool:
"""Whether the image has a calibrated number of periodic-tile samples."""
pixels = width * height
return MIN_SUPPORTED_PIXELS <= pixels <= MAX_SUPPORTED_PIXELS
def _registered_geometry_supported(width: int, height: int) -> bool:
"""Whether scale registration was challenged at this decoded size."""
pixels = width * height
return (
min(width, height) >= REGISTERED_MIN_SIDE
and REGISTERED_MIN_SUPPORTED_PIXELS <= pixels <= REGISTERED_MAX_SUPPORTED_PIXELS
)
def _large_geometry_supported(width: int, height: int) -> bool:
"""Whether fixed phase-aligned windows cover the calibrated large range."""
pixels = width * height
return min(width, height) >= LARGE_WINDOW and LARGE_MIN_PIXELS < pixels <= LARGE_MAX_PIXELS
def _opponent_registered_geometry_supported(width: int, height: int) -> bool:
"""Whether the opponent-color fallback passed its frozen geometry challenge."""
pixels = width * height
return (
min(width, height) >= OPPONENT_REGISTERED_MIN_SIDE
and OPPONENT_REGISTERED_MIN_PIXELS <= pixels <= REGISTERED_MAX_SUPPORTED_PIXELS
)
def _fine_opponent_registered_geometry_supported(width: int, height: int) -> bool:
"""Whether the fine-period selector passed its frozen geometry challenge."""
pixels = width * height
return (
min(width, height) >= FINE_OPPONENT_REGISTERED_MIN_SIDE
and FINE_OPPONENT_REGISTERED_MIN_PIXELS <= pixels <= FINE_OPPONENT_REGISTERED_MAX_PIXELS
)
def folded_template_score(
pixels: NDArray[Any],
template: NDArray[Any],
denoise_sigma: float,
) -> tuple[float, NDArray[Any]]:
"""Fold PIXELS at the model geometry and score the normalized tile."""
tile_height, tile_width = template.shape[:2]
folded = fold_residual_template(
pixels,
tile_height=tile_height,
tile_width=tile_width,
denoise_sigma=denoise_sigma,
)
normalized, _norm = unit_tile(folded)
return float((template * normalized).sum()), folded
def _large_window_starts(length: int) -> tuple[int, ...]:
"""Return phase-aligned starts that cover both edges without resampling."""
if length < LARGE_WINDOW:
raise ValueError("large-image sides must be at least 2,048 pixels")
last = ((length - LARGE_WINDOW) // LARGE_PHASE) * LARGE_PHASE
starts = list(range(0, last + 1, LARGE_WINDOW))
if starts[-1] != last:
starts.append(last)
return tuple(starts)
def _correlation(left: NDArray[Any], right: NDArray[Any]) -> float:
import numpy as np
denominator = float(np.linalg.norm(left) * np.linalg.norm(right))
return float(np.real(np.vdot(right, left)) / denominator) if denominator > 0.0 else 0.0
def _large_window_components(
folded: NDArray[Any],
template: NDArray[Any],
) -> tuple[float, float, float, float]:
"""Measure the four color-phase features used by the large branch."""
import numpy as np
folded_red_green = folded[:, :, 0] - folded[:, :, 1]
template_red_green = template[:, :, 0] - template[:, :, 1]
folded_blue_yellow = folded[:, :, 2] - 0.5 * (folded[:, :, 0] + folded[:, :, 1])
template_blue_yellow = template[:, :, 2] - 0.5 * (template[:, :, 0] + template[:, :, 1])
height, width = folded.shape[:2]
y_coordinates = np.minimum(np.arange(height), height - np.arange(height))
x_coordinates = np.minimum(np.arange(width), width - np.arange(width))
radius = np.sqrt(y_coordinates[:, None] ** 2 + x_coordinates[None, :] ** 2)
mid_band = (radius >= 4.5) & (radius < 6.5)
blue_yellow_mid = _correlation(
np.fft.fft2(folded_blue_yellow)[mid_band],
np.fft.fft2(template_blue_yellow)[mid_band],
)
green_mid = _correlation(
np.fft.fft2(folded[:, :, 1])[mid_band],
np.fft.fft2(template[:, :, 1])[mid_band],
)
return (
_correlation(folded_red_green, template_red_green),
_correlation(folded_blue_yellow, template_blue_yellow),
blue_yellow_mid,
green_mid,
)
def large_image_components(
pixels: NDArray[Any],
template: NDArray[Any],
denoise_sigma: float,
) -> LargeImageComponents:
"""Score all phase-aligned 2,048-pixel windows of one large RGB image."""
if pixels.ndim != 3 or pixels.shape[2] != 3:
raise ValueError("pixels must have shape (height, width, 3)")
height, width = pixels.shape[:2]
if not _large_geometry_supported(width, height):
raise ValueError("image geometry is outside the calibrated large-image range")
minimum_fixed = float("inf")
minimum_red_green = float("inf")
minimum_blue_yellow = float("inf")
minimum_blue_yellow_mid = float("inf")
maximum_green_mid = -float("inf")
for y in _large_window_starts(height):
for x in _large_window_starts(width):
window = pixels[y : y + LARGE_WINDOW, x : x + LARGE_WINDOW]
fixed_score, folded = folded_template_score(window, template, denoise_sigma)
red_green, blue_yellow, blue_yellow_mid, green_mid = _large_window_components(
folded,
template,
)
minimum_fixed = min(minimum_fixed, fixed_score)
minimum_red_green = min(minimum_red_green, red_green)
minimum_blue_yellow = min(minimum_blue_yellow, blue_yellow)
minimum_blue_yellow_mid = min(minimum_blue_yellow_mid, blue_yellow_mid)
maximum_green_mid = max(maximum_green_mid, green_mid)
return LargeImageComponents(
width=width,
height=height,
minimum_fixed_score=minimum_fixed,
minimum_red_green_spatial=minimum_red_green,
minimum_blue_yellow_spatial=minimum_blue_yellow,
minimum_blue_yellow_mid_band=minimum_blue_yellow_mid,
maximum_green_mid_band=maximum_green_mid,
)
def detect_synthid(
image_path: str | Path,
*,
image: NDArray[Any] | None = None,
register_scale: bool | None = None,
) -> SynthIDDetection:
"""Detect the supported periodic carrier in IMAGE_PATH.
``indeterminate`` means that the frozen periodic carrier did not cross its
calibrated threshold; it is not a clean-image guarantee. The default
production router uses scale registration through 10 megapixels and the
native large-image expert above that boundary. Set ``register_scale`` to
``True`` to force registration or ``False`` to run the legacy fixed-period
diagnostic below the large-image boundary.
"""
path = Path(image_path)
if image is None:
width, height = _image_size(path)
else:
if image.ndim != 3 or image.shape[2] != 3:
raise ValueError("image must be a three-channel BGR array")
height, width = image.shape[:2]
large_mode = register_scale is not True and width * height > LARGE_MIN_PIXELS
registered_mode = register_scale is True or (register_scale is None and not large_mode)
if registered_mode:
geometry_supported = _registered_geometry_supported(width, height)
threshold = REGISTERED_THRESHOLD
detector_id = REGISTERED_DETECTOR_ID
unsupported_reason = (
"registered-v3 requires 250,000-10,000,000 decoded pixels and both dimensions to be at least 256 pixels"
)
elif large_mode:
geometry_supported = _large_geometry_supported(width, height)
threshold = LARGE_THRESHOLD
detector_id = LARGE_DETECTOR_ID
unsupported_reason = (
"large-v1 requires more than 10,000,000 through 18,000,000 decoded pixels "
"and at least two phase-aligned 2048-pixel windows"
)
else:
geometry_supported = _geometry_supported(width, height)
threshold = TILE_THRESHOLD
detector_id = DETECTOR_ID
unsupported_reason = "fixed-v2 requires 1,000,000-18,000,000 decoded pixels"
if not geometry_supported:
return SynthIDDetection(
status="unsupported",
width=width,
height=height,
score=None,
threshold=threshold,
detector=detector_id,
reason=unsupported_reason,
)
if not is_available():
raise RuntimeError(f"SynthID pixel detection needs numpy and OpenCV; {INSTALL_HINT}")
import numpy as np
from PIL import Image
template, sigma, *_model = _load_template()
if image is None:
with Image.open(path) as source:
pixels = np.asarray(source.convert("RGB"), dtype=np.uint8)
else:
pixels = np.asarray(image[:, :, ::-1], dtype=np.uint8)
if pixels.shape != (height, width, 3):
raise RuntimeError("decoded image geometry does not match its header")
if registered_mode:
from synthid_runtime._synthid_registered import (
fine_opponent_registered_score,
opponent_registered_score,
registered_score,
)
score = registered_score(pixels, template, sigma)
if score < REGISTERED_THRESHOLD and _opponent_registered_geometry_supported(width, height):
opponent_score = opponent_registered_score(pixels, template, sigma)
if opponent_score >= OPPONENT_REGISTERED_THRESHOLD:
score = opponent_score
threshold = OPPONENT_REGISTERED_THRESHOLD
detector_id = OPPONENT_REGISTERED_DETECTOR_ID
if score < threshold and _fine_opponent_registered_geometry_supported(width, height):
fine_score = fine_opponent_registered_score(pixels, template, sigma)
if fine_score >= FINE_OPPONENT_REGISTERED_THRESHOLD:
score = fine_score
threshold = FINE_OPPONENT_REGISTERED_THRESHOLD
detector_id = FINE_OPPONENT_REGISTERED_DETECTOR_ID
elif large_mode:
score = large_image_components(pixels, template, sigma).decision_score
else:
score, _folded = folded_template_score(pixels, template, sigma)
detected = score >= threshold
return SynthIDDetection(
status="detected" if detected else "indeterminate",
width=width,
height=height,
score=score,
threshold=threshold,
detector=detector_id,
reason=None if detected else "the selected carrier expert did not cross every calibrated gate",
)
+2 -2
View File
@@ -20,12 +20,12 @@ if TYPE_CHECKING:
from numpy.typing import NDArray
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT / "scripts"))
sys.path.insert(0, str(PROJECT_ROOT / "src"))
from synthid_pixel_attack import load_rgb # noqa: E402
from synthid_research_manifest import artifact_sha256 # noqa: E402
from remove_ai_watermarks import synthid_detector # noqa: E402
from synthid_runtime import synthid_detector # noqa: E402
log = logging.getLogger(__name__)
-8
View File
@@ -13,7 +13,6 @@ High-level API (lazy, so ``import remove_ai_watermarks`` stays cheap)::
raiw.remove_video_metadata("in.mp4", "out.mp4") # verified metadata strip
raiw.remove_video_invisible("in.mp4", "out.mp4") # oracle-certified SynthID removal
raiw.remove_video_visible("in.mp4", "out.mp4") # stable visible video-mark removal
raiw.detect_synthid("in.png") # -> SynthIDDetection
raiw.verify_openai_synthid("in.png", acknowledge_upload=True) # remote
For a provenance verdict use the ``identify`` submodule::
@@ -43,9 +42,7 @@ __all__ = [
"OpenAIProvenanceError",
"OpenAISynthIDDetection",
"RemoveAllResult",
"SynthIDDetection",
"__version__",
"detect_synthid",
"identify_video",
"inspect_video_metadata",
"remove_all",
@@ -76,7 +73,6 @@ if TYPE_CHECKING:
OpenAISynthIDDetection,
verify_openai_synthid,
)
from remove_ai_watermarks.synthid_detector import SynthIDDetection, detect_synthid
from remove_ai_watermarks.video import (
identify_video,
inspect_video_metadata,
@@ -116,10 +112,6 @@ def __getattr__(name: str) -> object:
from remove_ai_watermarks import video
return getattr(video, name)
if name in ("SynthIDDetection", "detect_synthid"):
from remove_ai_watermarks import synthid_detector
return getattr(synthid_detector, name)
if name in ("OpenAIProvenanceError", "OpenAISynthIDDetection", "verify_openai_synthid"):
from remove_ai_watermarks import openai_provenance
+16 -82
View File
@@ -297,8 +297,8 @@ _force_option = click.option(
help=(
"Run the diffusion scrub even when no invisible AI watermark is locally "
"detectable. Default: skip it (regeneration only degrades a clean image; a "
"skip never claims the image is watermark-free -- the local SynthID detector "
"covers one carrier family in a calibrated image-size range)."
"skip never claims the image is watermark-free -- this package has no local "
"SynthID pixel decoder)."
),
)
_cpu_offload_option = click.option(
@@ -455,20 +455,18 @@ def _no_invisible_signal_exit(source: Path) -> NoReturn:
:func:`identify` finds no locally-detectable invisible AI signal, running it
anyway would damage a clean image for nothing -- the dominant paid score-0
cause on no-watermark uploads. So skip it, but do NOT imply the image is
clean: only one SynthID carrier family in a calibrated image-size range has
a local detector, so other sizes or epochs can still be present after
their metadata proxy is gone. Write no output and exit
:data:`EXIT_NO_INVISIBLE_SIGNAL`; ``--force`` runs the scrub regardless.
clean: Google does not publish the SynthID payload decoder, and this package
does not ship one, so a mark can still be present after its metadata proxy
is gone. Write no output and exit :data:`EXIT_NO_INVISIBLE_SIGNAL`;
``--force`` runs the scrub regardless.
"""
console.print(
" No supported invisible AI watermark detected (no provenance, supported\n"
" SynthID carrier, or open watermark). Skipped the diffusion scrub --\n"
" regenerating the pixels would only degrade the image with nothing to\n"
" remove, so no output was written.\n"
" This does NOT prove the image is clean: the local SynthID detector covers\n"
" one carrier family in a calibrated image-size range. If you know the image\n"
" is AI-generated and want the pixels regenerated regardless, re-run with\n"
" --force:\n"
" No supported invisible AI watermark detected (no provenance or open\n"
" watermark). Skipped the diffusion scrub -- regenerating the pixels would\n"
" only degrade the image with nothing to remove, so no output was written.\n"
" This does NOT prove the image is clean: this package has no local SynthID\n"
" pixel decoder. If you know the image is AI-generated and want the pixels\n"
" regenerated regardless, re-run with --force:\n"
f" remove-ai-watermarks invisible {source.name} --force"
)
raise SystemExit(EXIT_NO_INVISIBLE_SIGNAL)
@@ -1318,68 +1316,6 @@ def cmd_video_batch(
raise SystemExit(1)
# ── SynthID pixel detection ──
@main.command("detect-synthid")
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option("--json", "as_json", is_flag=True, help="Emit the detector result as JSON.")
@click.option(
"--register-scale/--fixed-period",
default=None,
help="Force registered production search or the legacy fixed-period diagnostic.",
)
def cmd_detect_synthid(source: Path, as_json: bool, register_scale: bool | None) -> None:
"""Detect a generation-pipeline pixel lattice at calibrated image sizes.
EXPERIMENTAL. The supported route for SynthID is signed provenance, which
`identify` reads and `verify-openai-synthid` confirms against the provider.
This command does NOT detect the SynthID watermark. The statistic it reports is
destroyed by a seven-pixel crop, while SynthID's published evaluation keeps
99.97% of its detection rate under aggressive crop and resize, so what
crosses the threshold identifies the generation pipeline rather than the
mark. Read a positive as "these pixels came from a pipeline that leaves this
lattice", never as "this image is watermarked", and read an indeterminate
result as neither.
"""
from remove_ai_watermarks.synthid_detector import detect_synthid
source = _validate_image(source)
try:
result = detect_synthid(source, register_scale=register_scale)
except RuntimeError as exc:
raise click.ClickException(str(exc)) from exc
if as_json:
click.echo(json.dumps(result.to_dict(), indent=2))
return
_banner()
console.print(f"\n Generation-pipeline lattice (experimental): {result.status}")
console.print(f" Geometry: {result.width}x{result.height}")
if result.score is not None:
console.print(f" Score: {result.score:.6f} (threshold: {result.threshold:.6f})")
console.print(f" Detector: {result.detector}")
if result.reason is not None:
console.print(f" Reason: {result.reason}")
if register_scale is True:
scale_scope = " Bounded spatial-scale registration was explicitly enabled. An indeterminate or\n"
elif register_scale is False:
scale_scope = " The legacy fixed-period diagnostic was explicitly enabled. An indeterminate or\n"
else:
scale_scope = (
" The production router selected the calibrated registered or large-image expert. An indeterminate or\n"
)
console.print(
" Scope: experimental. One periodic lattice family in a calibrated image-size range,\n"
" secondary to signed provenance, which remains the supported SynthID route. This is a\n"
" generation-pipeline signature, not the SynthID watermark: it disappears when the\n"
" image is cropped off the tile grid, and it changes when the generator's pipeline\n"
" changes. A positive says the pixels came from such a pipeline. It does not say the\n"
" image carries a watermark, and it does not say it lacks one.\n"
+ scale_scope
+ " unsupported result is not proof that SynthID is absent."
)
# ── Official OpenAI SynthID verification ──
@main.command("verify-openai-synthid")
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@@ -1470,9 +1406,8 @@ def cmd_identify(ctx: click.Context, source: Path, no_visible: bool, as_json: bo
if report.is_ai_generated is None:
console.print(
" No locally-readable AI signal found. This is not the same as 'clean': "
"metadata is often stripped by re-encoding, screenshots, or upload, and the local "
"SynthID pixel detector covers one carrier family in a calibrated image-size range. "
"See caveats below."
"metadata is often stripped by re-encoding, screenshots, or upload, and this "
"package has no local SynthID pixel decoder. See caveats below."
)
if report.integrity_clashes:
@@ -1565,9 +1500,8 @@ def cmd_all(
stage_text = {
("invisible", "no-signal"): (
"Skipped (no invisible AI watermark detected; pixels left intact).\n"
" Not a clean-image guarantee: the local SynthID detector covers one\n"
" carrier family in a calibrated image-size range. Re-run with --force\n"
" to scrub regardless."
" Not a clean-image guarantee: this package has no local SynthID pixel\n"
" decoder. Re-run with --force to scrub regardless."
),
("invisible", "unavailable"): (
f"Warning: Skipped - GPU dependencies not installed.\n Install them with: pip install {INVISIBLE_EXTRA}"
+11 -52
View File
@@ -6,14 +6,13 @@ Aggregates every locally-readable signal into a single :class:`ProvenanceReport`
the signing platform (OpenAI, Google, Adobe, Microsoft).
- **IPTC ``digitalSourceType``** "Made with AI" marker (Meta, X, others).
- **PNG text / EXIF generation parameters** (Stable Diffusion, ComfyUI, InvokeAI).
- **SynthID evidence** -- supported C2PA provenance plus a positive-only local
detector for one confirmed periodic carrier family in a calibrated image-size range.
- **SynthID evidence** -- supported C2PA provenance (Google AI, or current OpenAI
with an explicit watermark action). There is no local SynthID pixel decoder.
- **Registered visible marks** (optional; needs cv2/numpy, no GPU) through the
shared watermark registry.
Hard limit: Google does not publish its payload decoder. The local pixel detector
covers only one measured carrier family in a calibrated image-size range, so
absence of signals is reported as ``Unknown``, never as "clean".
Hard limit: Google does not publish its payload decoder, and this package does
not ship one. Absence of signals is reported as ``Unknown``, never as "clean".
"""
from __future__ import annotations
@@ -109,18 +108,7 @@ _STRIP_CAVEAT = (
"Absence of metadata is not proof the image is clean: C2PA, EXIF, and PNG "
"text chunks are stripped by re-encoding, screenshots, or social-media upload."
)
_SYNTHID_CAVEAT = (
"SynthID presence comes from supported provenance here. The separate local pixel detector "
"covers one measured carrier family in a calibrated image-size range; confirm other cases with "
"the provider oracle."
)
_PIPELINE_LATTICE_CAVEAT = (
"EXPERIMENTAL. Signed provenance is the primary route for SynthID; this pixel result is not a "
"watermark at all but a generation-pipeline lattice: it is destroyed by a "
"crop of seven pixels, while the published SynthID evaluation survives aggressive crop and resize. "
"It accepted 29 of 223 images from other generators, 24% of Adobe Firefly, so it does not identify "
"the provider. A negative or unsupported result is not proof of absence."
)
_SYNTHID_CAVEAT = "SynthID presence comes from supported provenance here. Confirm other cases with the provider oracle."
_IPTC_ONLY_CAVEAT = "The IPTC 'Made with AI' tag flags AI provenance but does not identify the specific platform."
_INVISIBLE_WM_CAVEAT = (
"The open invisible watermark is fragile: it does not survive JPEG re-encoding "
@@ -958,21 +946,6 @@ def _trustmark(image_path: Path) -> str | None:
return detect_trustmark(image_path)
def _pipeline_lattice(image_path: Path, decode: _SharedDecode) -> bool:
"""Whether the supported generation-pipeline lattice is detected.
Named for what it measures. The underlying expert is still called a SynthID
detector in its own module, but its statistic is a lattice anchored at the
image origin that a seven-pixel crop removes, so nothing here may present it
as a watermark.
"""
from remove_ai_watermarks.synthid_detector import detect_synthid, is_available
if not is_available() or (image := decode.get()) is None:
return False
return detect_synthid(image_path, image=image).detected
class _SharedDecode:
"""One decode of the source pixels, shared by every detector in a single report.
@@ -1317,18 +1290,6 @@ def _identify_from_evidence(
if platform is None:
platform = f"{scheme} (open DWT-DCT watermark)"
# ── Generation-pipeline lattice, experimental ───────────────────
# Signed provenance above is the primary SynthID route; this is a secondary
# pixel observation and is kept out of the watermark inventory on purpose. This reads a periodic
# lattice anchored at the image origin, which identifies the pipeline that
# produced the pixels; it is not SynthID and not any watermark, so listing
# it beside C2PA watermark assertions would misrepresent both.
if check_invisible and pixel_path is not None and _pipeline_lattice(pixel_path, decode):
signals.append(Signal("pipeline_lattice", "generation-pipeline lattice (experimental)", "medium"))
caveats.append(_PIPELINE_LATTICE_CAVEAT)
if platform is None:
platform = "generation-pipeline lattice detected (provider not attributed locally)"
# ── Adobe TrustMark invisible watermark (open decoder, no key) ───
# The watermark behind Adobe Durable Content Credentials. Decoded locally,
# but it binds provenance for human-authored content too, so it enriches the
@@ -1341,7 +1302,6 @@ def _identify_from_evidence(
# ── Verdict so far (metadata + embedded watermark) ──────────────
invisible_wm = any(s.name == "invisible_watermark" for s in signals)
pipeline_lattice = any(s.name == "pipeline_lattice" for s in signals)
exif_gen = any(s.name == "exif_generator" for s in signals)
xai_sig = any(s.name == "xai_signature" for s in signals)
ai_from_metadata = bool(
@@ -1351,7 +1311,6 @@ def _identify_from_evidence(
or aigc
or local_keys
or invisible_wm
or pipeline_lattice
or exif_gen
or xai_sig
)
@@ -1473,13 +1432,13 @@ def has_invisible_target(image_path: Path) -> bool:
to remove. Runs :func:`identify` with ``check_visible=False`` -- a visible mark
is handled by the separate visible pass and is NOT a diffusion target -- and
``check_invisible=True`` so an open watermark counts. Returns
``report.ai_from_metadata`` (C2PA AI issuer / SynthID provenance or periodic
carrier, IPTC, AIGC, local gen params, EXIF/xAI, open DWT-DCT / TrustMark).
``report.ai_from_metadata`` (C2PA AI issuer / SynthID provenance, IPTC,
AIGC, local gen params, EXIF/xAI, open DWT-DCT / TrustMark).
IMPORTANT -- this cannot prove a pixel SynthID is absent: the local detector
covers one carrier family in a calibrated image-size range. A False therefore
means "no supported locally-detectable invisible target", not "clean". Callers
must NOT present a skip as a finished clean result.
IMPORTANT -- this cannot prove a pixel SynthID is absent: Google does not
publish the payload decoder, and this package does not ship one. A False
therefore means "no supported locally-detectable invisible target", not
"clean". Callers must NOT present a skip as a finished clean result.
Fail-safe: any error resolves to True so the removal still runs -- leaving a
watermark on a paid removal is worse than over-regenerating a clean image.
+7 -3
View File
@@ -18,12 +18,10 @@ CHATGPT = SAMPLES / "chatgpt-1.png"
class TestTopLevelExports:
def test_lazy_reexports_resolve(self):
from remove_ai_watermarks import openai_provenance, synthid_detector
from remove_ai_watermarks import openai_provenance
assert raiw.remove_visible is api.remove_visible
assert raiw.visible_provenance is api.visible_provenance
assert raiw.detect_synthid is synthid_detector.detect_synthid
assert raiw.SynthIDDetection is synthid_detector.SynthIDDetection
assert raiw.verify_openai_synthid is openai_provenance.verify_openai_synthid
assert raiw.OpenAIProvenanceError is openai_provenance.OpenAIProvenanceError
assert raiw.OpenAISynthIDDetection is openai_provenance.OpenAISynthIDDetection
@@ -32,6 +30,12 @@ class TestTopLevelExports:
with pytest.raises(AttributeError):
_ = raiw.does_not_exist
def test_local_synthid_detector_is_not_a_package_export(self):
with pytest.raises(AttributeError):
_ = raiw.detect_synthid
with pytest.raises(AttributeError):
_ = raiw.SynthIDDetection
def test_bare_import_is_light(self):
# importing the package must not pull the heavy cv2/torch stack (PEP 562 lazy).
# Checked in a FRESH interpreter -- another test in this process may already
+4 -47
View File
@@ -736,54 +736,11 @@ class TestIdentifyCommand:
assert result.exit_code != 0
class TestDetectSynthIDCommand:
def test_help(self, runner):
class TestDetectSynthIDCommandRemoved:
def test_command_is_not_registered(self, runner):
result = runner.invoke(main, ["detect-synthid", "--help"])
assert result.exit_code == 0
assert "calibrated image sizes" in result.output
assert "--register-scale" in result.output
assert "--fixed-period" in result.output
def test_unsupported_geometry_is_machine_readable(self, runner, tmp_clean_png):
result = runner.invoke(main, ["detect-synthid", str(tmp_clean_png), "--json"])
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert payload["status"] == "unsupported"
assert payload["score"] is None
def test_registered_scale_mode_is_machine_readable(self, runner, tmp_clean_png):
from remove_ai_watermarks.synthid_detector import (
REGISTERED_DETECTOR_ID,
REGISTERED_THRESHOLD,
)
result = runner.invoke(
main,
["detect-synthid", str(tmp_clean_png), "--register-scale", "--json"],
)
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert payload["status"] == "unsupported"
assert payload["threshold"] == REGISTERED_THRESHOLD
assert payload["detector"] == REGISTERED_DETECTOR_ID
def test_non_json_output_preserves_negative_scope(self, runner, tmp_clean_png):
result = runner.invoke(main, ["detect-synthid", str(tmp_clean_png)])
assert result.exit_code == 0, result.output
assert "unsupported" in result.output
assert "not proof that SynthID is absent" in result.output
def test_registered_non_json_output_names_the_bounded_search(self, runner, tmp_clean_png):
result = runner.invoke(
main,
["detect-synthid", str(tmp_clean_png), "--register-scale"],
)
assert result.exit_code == 0, result.output
assert "Bounded spatial-scale registration was explicitly enabled" in result.output
assert result.exit_code != 0
assert "No such command" in result.output
class TestVerifyOpenAISynthIDCommand:
-34
View File
@@ -886,40 +886,6 @@ class TestIdentifyVisibleTextMarks:
# ── Caveats and serialization ───────────────────────────────────────
class TestGenerationPipelineLattice:
def test_positive_lattice_is_ai_evidence_but_never_a_watermark(self, tmp_clean_png: Path):
"""The lattice may support an AI verdict; it may not enter the watermark list.
It accepts 24% of Adobe Firefly output and dies on a seven-pixel crop, so
reporting it beside C2PA watermark assertions would misrepresent both. The
watermark assertion is checked by absence, because that is the failure that
actually shipped.
"""
with (
patch("remove_ai_watermarks.identify._invisible_watermark", return_value=None),
patch("remove_ai_watermarks.identify._pipeline_lattice", return_value=True),
patch("remove_ai_watermarks.identify._trustmark", return_value=None),
):
report = identify(tmp_clean_png, check_visible=False, check_invisible=True)
assert report.is_ai_generated is True
assert any(signal.name == "pipeline_lattice" for signal in report.signals)
assert not any("synthid" in watermark.lower() for watermark in report.watermarks)
assert not any("watermark" in watermark.lower() for watermark in report.watermarks)
assert any("not a watermark" in caveat for caveat in report.caveats)
def test_negative_lattice_does_not_claim_clean(self, tmp_clean_png: Path):
with (
patch("remove_ai_watermarks.identify._invisible_watermark", return_value=None),
patch("remove_ai_watermarks.identify._pipeline_lattice", return_value=False),
patch("remove_ai_watermarks.identify._trustmark", return_value=None),
):
report = identify(tmp_clean_png, check_visible=False, check_invisible=True)
assert report.is_ai_generated is None
assert not any(signal.name == "pipeline_lattice" for signal in report.signals)
@pytest.mark.skipif(not SAMPLES_DIR.exists(), reason="data/fixtures/provenance not present")
class TestIdentifyCaveats:
def test_legacy_openai_has_no_synthid_claim(self):
+1 -2
View File
@@ -14,8 +14,7 @@ from PIL import Image
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
import synthid_affine_lattice_probe as probe
from remove_ai_watermarks._synthid_confirmation import RegisteredConfirmationComponents
from synthid_runtime._synthid_confirmation import RegisteredConfirmationComponents
def test_webp_lossless_round_trip_preserves_pixels() -> None:
+1 -2
View File
@@ -11,8 +11,7 @@ import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
import synthid_affine_lattice_probe as research_probe
from remove_ai_watermarks._synthid_confirmation import (
from synthid_runtime._synthid_confirmation import (
RegisteredConfirmationComponents,
registered_confirmation_components,
)
+18 -19
View File
@@ -7,10 +7,9 @@ from pathlib import Path
import numpy as np
import pytest
import synthid_runtime.synthid_detector as detector
from PIL import Image
import remove_ai_watermarks.synthid_detector as detector
@pytest.fixture(scope="module")
def supported_images(tmp_path_factory: pytest.TempPathFactory) -> tuple[Path, Path]:
@@ -105,7 +104,7 @@ def fine_opponent_registered_positive(tmp_path_factory: pytest.TempPathFactory)
def test_bundled_model_is_the_frozen_calibrated_artifact() -> None:
model = Path(detector.__file__).parent / "assets" / detector.MODEL_FILENAME
model = Path(detector.__file__).parent / detector.MODEL_FILENAME
assert hashlib.sha256(model.read_bytes()).hexdigest() == (
"ee7838da8542c206c3403284b68e98f0ac99429e82f262c1a438f50a638b488b"
@@ -330,7 +329,7 @@ def test_registered_mode_falls_back_to_the_opponent_color_expert(
monkeypatch: pytest.MonkeyPatch,
opponent_registered_positive: Path,
) -> None:
import remove_ai_watermarks._synthid_registered as registered_detector
import synthid_runtime._synthid_registered as registered_detector
monkeypatch.setattr(registered_detector, "registered_score", lambda *_args: 0.0)
@@ -346,7 +345,7 @@ def test_opponent_registered_threshold_mutation_changes_the_real_verdict(
monkeypatch: pytest.MonkeyPatch,
opponent_registered_positive: Path,
) -> None:
import remove_ai_watermarks._synthid_registered as registered_detector
import synthid_runtime._synthid_registered as registered_detector
monkeypatch.setattr(registered_detector, "registered_score", lambda *_args: 0.0)
baseline = detector.detect_synthid(opponent_registered_positive, register_scale=True)
@@ -368,7 +367,7 @@ def test_opponent_fallback_recovers_period8_without_codec_grid(
monkeypatch: pytest.MonkeyPatch,
opponent_period8_positive: Path,
) -> None:
import remove_ai_watermarks._synthid_registered as registered_detector
import synthid_runtime._synthid_registered as registered_detector
monkeypatch.setattr(registered_detector, "registered_score", lambda *_args: 0.0)
@@ -382,7 +381,7 @@ def test_fine_opponent_fallback_recovers_off_grid_period(
monkeypatch: pytest.MonkeyPatch,
fine_opponent_registered_positive: Path,
) -> None:
import remove_ai_watermarks._synthid_registered as registered_detector
import synthid_runtime._synthid_registered as registered_detector
monkeypatch.setattr(registered_detector, "registered_score", lambda *_args: 0.0)
monkeypatch.setattr(registered_detector, "opponent_registered_score", lambda *_args: 0.0)
@@ -399,7 +398,7 @@ def test_fine_opponent_threshold_mutation_changes_the_real_verdict(
monkeypatch: pytest.MonkeyPatch,
fine_opponent_registered_positive: Path,
) -> None:
import remove_ai_watermarks._synthid_registered as registered_detector
import synthid_runtime._synthid_registered as registered_detector
monkeypatch.setattr(registered_detector, "registered_score", lambda *_args: 0.0)
monkeypatch.setattr(registered_detector, "opponent_registered_score", lambda *_args: 0.0)
@@ -421,7 +420,7 @@ def test_fine_opponent_threshold_mutation_changes_the_real_verdict(
def test_fine_opponent_selector_recovers_the_fractional_period(
fine_opponent_registered_positive: Path,
) -> None:
import remove_ai_watermarks._synthid_registered as registered_detector
import synthid_runtime._synthid_registered as registered_detector
template, sigma, *_model = detector._load_template()
pixels = np.asarray(Image.open(fine_opponent_registered_positive).convert("RGB"), dtype=np.uint8)
@@ -436,7 +435,7 @@ def test_period8_codec_veto_threshold_mutation_changes_real_components(
monkeypatch: pytest.MonkeyPatch,
opponent_period8_positive: Path,
) -> None:
import remove_ai_watermarks._synthid_registered as registered_detector
import synthid_runtime._synthid_registered as registered_detector
template, sigma, *_model = detector._load_template()
pixels = np.asarray(Image.open(opponent_period8_positive).convert("RGB"), dtype=np.uint8)
@@ -450,7 +449,7 @@ def test_period8_codec_veto_threshold_mutation_changes_real_components(
def test_opponent_registered_period_band_and_codec_veto_are_required() -> None:
from remove_ai_watermarks._synthid_registered import OpponentRegisteredComponents
from synthid_runtime._synthid_registered import OpponentRegisteredComponents
values = {
"spectral_score": 0.8,
@@ -503,7 +502,7 @@ def test_registered_threshold_mutation_changes_the_real_verdict(
def test_registered_period_thresholds_cover_the_bounded_search() -> None:
from remove_ai_watermarks._synthid_registered import _period_threshold
from synthid_runtime._synthid_registered import _period_threshold
assert _period_threshold(7.5) == pytest.approx(0.3770629524888979)
assert _period_threshold(12.0) == pytest.approx(0.19794247706938645)
@@ -516,7 +515,7 @@ def test_registered_amplitude_threshold_mutation_changes_the_real_verdict(
monkeypatch: pytest.MonkeyPatch,
registered_scale_positive: Path,
) -> None:
import remove_ai_watermarks._synthid_registered as registered_detector
import synthid_runtime._synthid_registered as registered_detector
baseline = detector.detect_synthid(registered_scale_positive, register_scale=True)
assert baseline.status == "detected"
@@ -532,8 +531,8 @@ def test_registered_amplitude_threshold_mutation_changes_the_real_verdict(
def test_registered_spectral_candidate_disagreement_blocks_decision() -> None:
from remove_ai_watermarks._synthid_confirmation import RegisteredConfirmationComponents
from remove_ai_watermarks._synthid_registered import RegisteredComponents
from synthid_runtime._synthid_confirmation import RegisteredConfirmationComponents
from synthid_runtime._synthid_registered import RegisteredComponents
confirmation = RegisteredConfirmationComponents(12.8, 0.5, 0.2, 0.5, 8, 8)
matching = RegisteredComponents(0.5, 0.25, 12.8, 12.8, 0.15, confirmation)
@@ -550,7 +549,7 @@ def test_registered_high_band_mutation_changes_the_real_verdict(
monkeypatch: pytest.MonkeyPatch,
registered_scale_positive: Path,
) -> None:
import remove_ai_watermarks._synthid_registered as registered_detector
import synthid_runtime._synthid_registered as registered_detector
components = registered_detector.registered_components(
np.asarray(Image.open(registered_scale_positive).convert("RGB"), dtype=np.uint8),
@@ -573,8 +572,8 @@ def test_registered_confirmation_mutation_changes_the_real_verdict(
monkeypatch: pytest.MonkeyPatch,
registered_scale_positive: Path,
) -> None:
import remove_ai_watermarks._synthid_confirmation as confirmation_detector
import remove_ai_watermarks._synthid_registered as registered_detector
import synthid_runtime._synthid_confirmation as confirmation_detector
import synthid_runtime._synthid_registered as registered_detector
components = registered_detector.registered_components(
np.asarray(Image.open(registered_scale_positive).convert("RGB"), dtype=np.uint8),
@@ -656,7 +655,7 @@ def test_supported_geometry_requires_pixel_dependencies(
_positive, negative = supported_images
monkeypatch.setattr(detector, "is_available", lambda: False)
with pytest.raises(RuntimeError, match="pixel extra"):
with pytest.raises(RuntimeError, match="needs numpy and OpenCV"):
detector.detect_synthid(negative, register_scale=False)