mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-09-04 11:26:32 +02:00
Unplug lattice detector from package, expand research docs
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
)
|
||||
Binary file not shown.
@@ -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__)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user