From d22872f84def6e6e4578622a1ada3e806180fef2 Mon Sep 17 00:00:00 2001 From: Victor Kuznetsov Date: Sun, 23 Aug 2026 11:13:56 -0700 Subject: [PATCH] Unplug lattice detector from package, expand research docs --- .claude/rules/development.md | 9 +- README.md | 59 +- docs/cli.md | 57 +- docs/index.md | 2 +- docs/known-limitations.md | 73 +- docs/module-internals.md | 23 +- docs/python-api.md | 47 +- docs/supported-signals.md | 55 +- docs/synthid-classifiers.md | 55 +- docs/synthid-detector-research.md | 365 ++++- docs/synthid-oracle-ladders.md | 12 +- docs/synthid-removal-research.md | 82 +- docs/synthid.md | 1236 ++--------------- docs/watermarking-landscape.md | 1 - pyproject.toml | 4 +- scripts/synthid_adaptive_carrier_suppress.py | 8 +- scripts/synthid_affine_lattice_probe.py | 10 +- scripts/synthid_periodic_tile.py | 7 +- scripts/synthid_routed_expert_bank.py | 4 +- scripts/synthid_runtime/__init__.py | 5 + .../synthid_runtime/_synthid_confirmation.py | 288 ++++ .../synthid_runtime/_synthid_registered.py | 664 +++++++++ scripts/synthid_runtime/synthid_detector.py | 542 ++++++++ .../synthid_periodic_tile_2048_v1.npz | Bin 0 -> 7501 bytes scripts/synthid_runtime_expert_scores.py | 4 +- src/remove_ai_watermarks/__init__.py | 8 - src/remove_ai_watermarks/cli.py | 98 +- src/remove_ai_watermarks/identify.py | 63 +- tests/test_api.py | 10 +- tests/test_cli.py | 51 +- tests/test_identify.py | 34 - tests/test_synthid_affine_lattice_probe.py | 3 +- tests/test_synthid_confirmation.py | 3 +- tests/test_synthid_detector.py | 37 +- 34 files changed, 2210 insertions(+), 1709 deletions(-) create mode 100644 scripts/synthid_runtime/__init__.py create mode 100644 scripts/synthid_runtime/_synthid_confirmation.py create mode 100644 scripts/synthid_runtime/_synthid_registered.py create mode 100644 scripts/synthid_runtime/synthid_detector.py create mode 100644 scripts/synthid_runtime/synthid_periodic_tile_2048_v1.npz diff --git a/.claude/rules/development.md b/.claude/rules/development.md index 034acb6..7d55a74 100644 --- a/.claude/rules/development.md +++ b/.claude/rules/development.md @@ -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. diff --git a/README.md b/README.md index f9f8c3b..72ffc54 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/docs/cli.md b/docs/cli.md index c9edb13..eaabecb 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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)`. diff --git a/docs/index.md b/docs/index.md index 2f22099..18d505d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 diff --git a/docs/known-limitations.md b/docs/known-limitations.md index 669d0ed..c62cb88 100644 --- a/docs/known-limitations.md +++ b/docs/known-limitations.md @@ -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: diff --git a/docs/module-internals.md b/docs/module-internals.md index be74fbe..7c825be 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -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. diff --git a/docs/python-api.md b/docs/python-api.md index d73fabc..1b6bfef 100644 --- a/docs/python-api.md +++ b/docs/python-api.md @@ -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 diff --git a/docs/supported-signals.md b/docs/supported-signals.md index 8cb8eb2..4d4b061 100644 --- a/docs/supported-signals.md +++ b/docs/supported-signals.md @@ -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 | diff --git a/docs/synthid-classifiers.md b/docs/synthid-classifiers.md index bbec2e5..2e60ae7 100644 --- a/docs/synthid-classifiers.md +++ b/docs/synthid-classifiers.md @@ -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. diff --git a/docs/synthid-detector-research.md b/docs/synthid-detector-research.md index 5e0746f..20db417 100644 --- a/docs/synthid-detector-research.md +++ b/docs/synthid-detector-research.md @@ -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. diff --git a/docs/synthid-oracle-ladders.md b/docs/synthid-oracle-ladders.md index 980c97a..a124a41 100644 --- a/docs/synthid-oracle-ladders.md +++ b/docs/synthid-oracle-ladders.md @@ -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) | diff --git a/docs/synthid-removal-research.md b/docs/synthid-removal-research.md index 7874abc..262296e 100644 --- a/docs/synthid-removal-research.md +++ b/docs/synthid-removal-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 diff --git a/docs/synthid.md b/docs/synthid.md index 250b33d..02831b2 100644 --- a/docs/synthid.md +++ b/docs/synthid.md @@ -589,1146 +589,22 @@ Google's SynthID Detector service is: > -- deepmind.google/models/synthid/ The external variant SynthID-O is available "through partnerships" only. This -project instead detects one empirically recovered periodic carrier family in a -calibrated image-size range. It does not decode the proprietary payload or -generalize that local signal to unsupported sizes, -codecs, video, or future epochs. The evidence and gates are documented in -[`synthid-detector-removal-plan.md`](synthid-detector-removal-plan.md). +package does not ship a local payload decoder. Research on a periodic lattice +expert is in [synthid-detector-research.md](synthid-detector-research.md). +A 2026-08-23 literature map (Gowal, AWPD/FSNet, PRC, reverse-SynthID) is +in that page's external-literature section. The product routes are signed +provenance and `verify-openai-synthid`. ### 3.2 How our tool detects the supported carrier -The heading is kept because README, `cli.md` and `python-api.md` link to this -anchor, but the name is inherited and inaccurate. What the local experts read -is a periodic lattice anchored at the image origin, measured on 2026-08-16 to -vanish under a seven-pixel crop that the published SynthID evaluation survives -at 99.97% TPR. Everything in this section describes that pipeline signature, -not watermark recovery; the measurement is in the empirical log of -[`synthid-detector-removal-plan.md`](synthid-detector-removal-plan.md). - -`remove-ai-watermarks detect-synthid image.png --fixed-period` exposes the -legacy fixed-v2 diagnostic. It folds the image residual modulo 16x16 and -compares it with a frozen float64 template. It evaluates only native input, -without resize. Exact-multiple dimensions retain the original folding path; -non-divisible dimensions use count-correct modulo folding. The model and -threshold remain frozen from the 2048x2048 experiment. Through 10 megapixels, -the fixed threshold -accepted none of 5,000 public COCO views balanced across every observed target -geometry. A separate 5,000-view challenge used 256 generated geometries from one -through 18 megapixels and covered every pair of width/height remainders modulo -16; it also produced no accepted view. Later fresh-source challenges invalidated -that precision claim: fixed-v2 accepted 5/211 supported controls in one Open -Images cohort, and a proposed `0.28` threshold still accepted 1/213 in the -second. The branch is therefore diagnostic only and is never unioned into the -production positive route. The original 2048x2048 scores remain exactly -unchanged. - -Above 10,000,000 through 18,000,000 decoded pixels, the production default selects -the separate `synthid-periodic-tile-large-v1` branch when both sides are at -least 2,048 pixels. It scores every phase-aligned 2,048-square window without -resizing. Every window must retain the combined template, Red-minus-Green, and -Blue-minus-Yellow spatial agreement; at least one window must also carry the -expected signed Blue-minus-Yellow mid-band phase. The 3072x5504 portrait alias -has an additional Green mid-band upper gate. The public score is the smallest -normalized gate margin and crosses at `1.0`. - -The branch retained all 37 C2PA-inferred large candidates. Seven were then -submitted as metadata-free, decoded-pixel-identical files to Google's official -Gemini pixel verifier, and all seven were detected; an eighth submission was -indeterminate because the verifier quota ended. The final constants accepted -none of 17,417 exposed COCO, Open Images, and Spaces controls. After the -constants were frozen, the production path accepted none of a separate 2,637- -image feature-unseen local holdout: 2,000 previously excluded COCO images and -637 decoded-pixel-unique Picsum controls, balanced over four large geometries -and four resampling kernels. Its maximum score was `0.0592777965` against the -`1.0` boundary, giving a 95% zero-error upper bound of about 0.114% for that -holdout. The source collections predated the freeze and supported other -experiments, so this is not a fresh-acquisition estimate. - -The slow post-freeze Open Images download supplied a separate source-fresh -audit. Excluding incomplete `.aria2` files, every prior Open Images decoded- -pixel hash, and duplicates left 41 complete controls from IDs frozen after the -large-v1 constants. The unchanged runtime accepted 0/41; the maximum score was -`0.4083013324`. This sample is too small to replace the 2,637-control interval, -but it independently checks the source boundary. - -Codec robustness is outside that operating point. Re-encoding the same seven -official positives at their original dimensions as JPEG-95 or JPEG-90 reduced -the frozen large detector from 7/7 native detections to 0/7 for either quality. -The branch supports original or losslessly copied native pixels; a negative on -a lossy retranscode is explicitly inconclusive. - -This geometry support does not imply arbitrary resize robustness. The fixed -carrier has a 16-pixel sampling lattice. In a stratified 80-image positive -sample, direct detection fell from 80 accepted originals to zero after each of -seven nonidentity resizes from 0.5 through 1.5. Scaling the template and folding -period to matching integers recovered the signal, and a conservative threshold -above 3,000 resized COCO development controls accepted 672 of 800 source-disjoint -provider positives with no acceptance in 2,000 final controls. That branch is -not shipped: noninteger periods from ordinary scale factors collapsed, and -less conservative per-period thresholds accepted five final controls. The -runtime therefore detects arbitrary decoded dimensions only when the carrier -retains its measured 16-pixel scale. - -Through 10,000,000 decoded pixels, `remove-ai-watermarks detect-synthid -image.png` selects the registered-v3 detector by default. `--register-scale` -forces this branch even where the router would otherwise select large-v1. The -registered expert samples the 30 strongest -template harmonics over fractional periods, reconstructs the three strongest -canonical candidates, and combines full-frame correlation with quadrant and -three-level scale-space consistency. A positive result additionally requires -the canonical winner to be the strongest spectral candidate, a calibrated -threshold for its recovered period, and agreement in two independent -high-frequency template bands. Registered-v3 then confirms base crossings on -disjoint patch groups using origin-corrected harmonic phase, cyclic amplitude, -and a held-out unknown-codeword statistic. The reported score preserves the -base normalized margin only after confirmation; its threshold is `1.0`. - -The final rule retained 229 of 355 source-disjoint transformed positives and -accepted none of 499 previously untouched Open Images controls. It retained -229 of 290 positives from scale 0.65 through 1.5, but none of 65 at scale 0.5. -A separate period-8 rescue was rejected because symmetric 0.5x control resizing -reproduced the same lattice and fully overlapped the positive feature range. -The measured input range is 250,000 through 10,000,000 decoded pixels. -Both dimensions must also be at least 256 pixels because the frozen positive -confirmation requires complete disjoint 256-pixel patch groups. Narrower -geometries need a separately calibrated adaptive-patch expert. - -A later frozen 1,000-image Open Images reserve preserved zero registered -detections. Only 81 of those images were large enough for the fixed expert, and -seven crossed its standalone threshold. The two experts therefore must not be -combined with an OR rule: in their overlapping geometry, the registered gate -is the validated decision and a fixed-only crossing remains ambiguous. The -fixed expert is retained as a separately scoped carrier diagnostic, not as an -independent recall addition to registered mode. - -Registered-v2 later accepted 4/2,997 supported controls in a source-fresh Open -Images test cohort. The frozen v3 confirmation rejected all four while retaining -147/148 base-accepted source-diverse positives and all 359 base-accepted views -in a dense 0.65-1.50 scale matrix. Without changing the rule, v3 then accepted -0/2,996 controls from a second nonoverlapping Open Images cohort and 0/2,366 -supported controls from a 3,000-image COCO second-family challenge. The two Open -Images cohorts give a one-sided 95% zero-error false-positive upper bound of -about 0.050%. These results qualify a precision-first positive route, not an -absence verdict; the v2 sensitivity benchmark and scale-0.5 exclusion remain. - -Those control rates are photographic. On 223 corpus images whose C2PA names a -non-Google generator, the unchanged production entry point accepted 29, a rate -of `0.130`, with `0.241` on Adobe Firefly and a highest foreign score of `3.01` -against the `1.0` threshold. Every acceptance came from registered-v3. The -lattice this branch reads is therefore shared across generation pipelines, and -the photographic upper bound above does not describe the rate a user meets on -AI-generated input. The branch is also phase-locked to the image origin: a -two-pixel diagonal crop killed all 28 in-geometry foreign detections and all 8 -detected Google provenance positives, with the signal recovering only at -offsets that are multiples of four. A mark that survived cropping would not do -that; the route detects pipeline lattice on ordinary sizes as well as large -ones. - -An exact runtime-parity check subsequently applied the recorded transform to -each of the 226 source-diverse assignments before invoking registered-v3. Two -half-scale outputs became too small for the research confirmation grid; 224 -were scored. Registered-v3 detected 147/224 overall. All 40 supported 0.5x -views missed, while the measured 0.65x-1.5x range detected 147/184 (79.9%). The -unchanged registered-v2 base accepted 148 of those 184; v3 confirmation removed -only one true base crossing. The remaining sensitivity loss is therefore -period selection, amplitude, or high-band failure after resampling, not an -overly strict confirmation gate. - -A preregistered same-image-null follow-up compares the selected carrier on the -two patch checkerboards with 16 neighboring periods from plus or minus 0.35 to -2.0 pixels. On two combined Open Images development cohorts, a near-threshold -base floor of 0.8, the existing frozen confirmation, and minimum joint -coherence excess `0.05695968150082637` rescued 7 of the 36 sub-threshold -0.65x-1.5x views. The frozen rule accepted none of the first 3,000 COCO -controls. It remains research-only until the untouched final control partition -and new positive parents pass unchanged. The final 2,000 COCO files contained -1,598 registered-supported images and seven near-threshold candidates. Four -candidates could not form the required two 256-pixel patch groups; the three -scored candidates had same-image excess at most zero. The frozen recovery rule -therefore accepted 0/1,598, as did registered-v3 itself. The negative-side gate -passed, but the lack of unseen positive parents still keeps the rescue outside -runtime. - -Same-image contrast also failed to rescue spatial/spectral period mismatches. -Across the two 3,000-file Open Images cohorts, 469 controls passed the -preregistered mismatch prefilters and 212 passed frozen split confirmation. -Their maximum joint excess, `0.1762448251`, exceeded the `0.1464860382` maximum -of the five eligible transformed positive mismatches. The requirement that the -canonical and spectral period winners agree remains part of registered-v3. - -A later content-adaptive research branch uses the registered tile only as a -synchronization basis. It standardizes every patch against all cyclic tile -shifts, chooses one phase on a checkerboard of selection patches, and confirms -the same phase on the disjoint checkerboard. A frozen joint standardized score -of `2.5`, combined with matching periods, the existing confirmation, both base -gate margins at least `0.45`, and one margin at least `1.0`, recovered 5 of 18 -scored amplitude/high-band failures. It accepted none of 88 first-cohort gate -failures and none of 334 previously scored control candidates, but those are -development results. Its separately started Picsum feature-holdout then -accepted 0/350 decoded-pixel-unique controls unchanged. Only two controls -reached the mechanism pre-gates and their maximum joint patch-shift score was -`0.7029283` against `2.5`. The branch remains research-only pending unseen -positive parents. That check then failed: 21 resized views at 0.65, 0.70, and -0.75 from seven separate official pixel-positive large Gemini parents produced -0/21 registered-v3 detections and 0/21 patch-phase rescues. Their surviving -amplitude but inverted or weak RGB high-band response points to a different -carrier epoch or representation. The patch branch is not promoted; the next -test registers the separately measured large-v1 opponent-color signal. - -The first opponent-color registration experiment then recovered 21/21 of -those views and selected the exact transformed period every time. It searches -7.5-14.5 pixels in the Red-minus-Green and Blue-minus-Yellow spectrum, then -requires fixed RGB, Red-minus-Green spatial, and Blue-minus-Yellow spatial -scores of at least `0.16`, `0.60`, and `0.55`. The same frozen candidate -accepted 0/21 symmetrically resized views from seven large Open Images -controls. The frozen candidate then accepted 0/350 decoded-pixel-unique Picsum -controls with a maximum decision margin of `0.7778322`. - -The unrestricted branch was not production-safe. It accepted 605/2,997 and -587/2,996 controls from two wider Open Images cohorts; almost every crossing -selected the period-8 or period-12.8 codec lattice. It also accepted one of -3,964 COCO controls at period 10, below one megapixel, and 28/3,772 -non-Google/non-OpenAI Spaces controls, all at period 8 or 12.8. Lossy -re-encoding destroyed the measured positive route: raw lossless resize retained -21/21 views at scales 0.5, 0.625, and 0.75, while JPEG-95, JPEG-85, and WebP-95 -retained 0/63. - -Those failures defined a narrower precision-first expert before a new control -holdout: 1-10 megapixels, both sides at least 768 pixels, and a selected period -from 8.1 through 12.0. It keeps the original three score thresholds and does -not require the strongest spectral alias to equal the spatially reranked -period. The latter restriction was removed after a public-API test showed that -it reduced the 0.55x cohort from 7/7 to 1/7 while the geometry gate already -excluded its only reserve control crossing. The final rule then accepted -0/1,000 untouched Picsum controls, with a maximum unrestricted decision margin -of `0.7953734`, and the production API detected 42/42 lossless views at scales -0.55 through 0.75 from the seven official-positive parents. It now ships as -`synthid-periodic-tile-opponent-registered-v1` after registered-v3 abstains. - -A later period-8 codec-confound experiment extended that frozen expert without -mistaking the JPEG grid for a watermark. Half-scale lossless views retained 7/7 -official positives at period 8, but ordinary JPEG controls produced the same -large template correlation. Their decoded opponent-color edge geometry was -different: every positive had both the Red-Green and Blue-Yellow native -8-pixel edge ratio at or below `1.05`. The threshold was selected against 600 -development codec crossings, then rejected all 582 crossings in the held-out -Open Images cohort, all 601 in the feature-unseen frozen reserve, and all seven -in the heterogeneous Spaces challenge. Separately, 350 controls passed through -the same lossless 0.5x resize had no base carrier crossing. The runtime now -accepts periods 7.9-12.0 and applies this pixel-only veto through period 8.1. -The production API consequently detects 49/49 measured lossless views at scales -0.5-0.75 from the seven official-positive parents. - -The same work did not recover lossy retranscodes. JPEG 4:2:0 creates a strong -inverted period-8 template in positives and natural controls alike. A -matched-transform 512-feature codec-residual discriminant accepted up to -86/525 holdout controls and did not generalize across positive parents. Eighty -block-DCT coherence variants also failed the precision-first gate: the best -zero-holdout-error statistic retained only 2/21 JPEG views. These are rejected -hypotheses, not hidden fallback branches. - -The wider provider challenge produced a useful but non-attributive observation: -the unrestricted score accepted 95/7,254 OpenAI files with an explicit SynthID -provenance assertion, all at periods 10.70-10.80. It also accepted 4/287 OpenAI -lineage files without that assertion, including three in the production period -band. Missing assertion is not an oracle-negative watermark label, and the -feature does not identify a provider. All 95 decoded rasters were unique and -none overlapped the 94 hits from the earlier period-8 OpenAI candidate, so the -union covered 189/7,259 asserted files. The new component was nevertheless -codec-fragile: JPEG-95 and WebP-95 each reduced 95/95 native hits to 0/95. -It may be a complementary carrier epoch or an associated export/resampling -lattice; current labels cannot distinguish those causes. The local detector -therefore remains provider-neutral; OpenAI-wide coverage still comes from the -official pixel verifier. - -Runtime routing now applies these policies directly and always abstains on -fixed-only evidence. The research bank in -`scripts/synthid_routed_expert_bank.py` still records fixed, registered, and -large observations separately for audits. No route claims absence because -encoder-version coverage remains incomplete. - -Running the unchanged registered components directly above the declared range -did not justify a larger ceiling. From 10 to 18 megapixels it accepted 1 of 37 -Google candidates and zero of 89 non-Google controls. High-band agreement, not -amplitude, was the main failure: only 3 of 37 positives cleared that gate. -Phase-aligned 2,048-square fixed-window consensus recovered 36 positives but -also accepted 10 controls by median score and eight when every window had to -pass. The natural and generator confounds are themselves spatially coherent. -Isotropic full-frame area reduction to one preregistered eight-megapixel target -also retained only 1 of 37 positives and zero controls; it was the same -4096-square native hit. Only three positives cleared high-band agreement after -the resize, so whole-image pre-resizing is not a large-image rescue. - -The two remaining registered-window variants closed the registered large-image -branch. -One centered 2,048-square window retained 1 of 37 positives and zero of 89 -controls. Accepting the best of every phase-aligned 2,048-square window retained -2 of 37 and zero controls, while the control maximum already reached 0.968 -against the 1.0 threshold. This did not justify extending the registered -detector's 10-megapixel ceiling. - -Three later checks did not change that boundary. Three near-10-megapixel aligned -windows per image retained 1 of 37 Google-labeled candidates and zero of 89 -controls. A native fixed-window plus Green mid-band rule retained 15 of the 19 -candidates in four recurring large geometries, but accepted one of 11,417 -symmetrically resized Spaces controls and then retained none of those 19 after a -JPEG-90 re-encode. A JPEG-conjunctive period-16 codebook selected an RGB Green -mid-band direction on the pre-July split, retaining 23 of 26 candidates and zero -of 40 controls, but accepted 7 of 49 controls while retaining 10 of 11 candidates -on July. Lab and OKLab variants shared the same hard false positive. These -features are codec or renderer lattices, not robust large-image experts. - -Those failures motivated the opponent-color branch documented above rather -than extending registered mode. The full 37-image cohort still has inferred -labels, so `37/37` is not an oracle recall estimate. The seven official Gemini -verdicts establish pixel-only positives at seven large geometries, but they are -still a small convenience sample. Three local records in the earlier 89-row -"control" set also crossed the final rule after decoded-pixel deduplication: -one is an Adobe file whose C2PA explicitly reports Google SynthID present, and -the other two remain pending official Gemini verdicts. They are not counted as -clean negatives. - -A 64-pixel patch-consensus statistic for 0.5x inputs initially retained 33 of -49 validation positives and zero of 166 controls, followed by 27 of 52 locked -positives and zero of 140 controls. Its untouched broad challenge failed: 61 of -10,906 half-scaled Spaces controls and 22 of 1,000 half-scaled Open Images -controls crossed the frozen threshold. A post-challenge negative-Green spread -gate removed those false positives but retained only 2 of 234 later Google rows -and zero of 689 later controls. The result rejects patch consensus as a -half-scale runtime expert. - -Two later rescue attempts did not improve that operating point. Trading -normalized amplitude against the minimum high-band correlation recovered 11 -false negatives but accepted 19 of 11,506 symmetrically resized Spaces -controls. Using only the stronger high-frequency ring while vetoing balanced -broad-band matches also recovered 11 development false negatives and accepted -none of 499 reserve controls, but then accepted 13 of the 11,506 resized Spaces -controls. The respective full-corpus maxima exceeded their frozen thresholds. -Both forms measure general renderer or resize lattices and are rejected. - -Projecting out the common 16-by-16 template and clustering the remaining tile -residual also failed the heterogeneous-control gate. A five-prototype codebook -retained 366 of 600 source-disjoint positives and accepted none of two separate -2,500-image natural-control splits, but accepted 828 of 11,506 metadata-labeled -non-Google Spaces controls. Its reproducible clusters occur across many image -generators and are not a SynthID-specific payload signature. Intersecting the -codebook with the registered min(R, B) carrier gate rescued none of the 138 -remaining Google candidates. The codebook is not part of the detector. - -Block-level spatial statistics did not make that codebook specific. Adjacent -carrier responses were more coherent in positives, but the best -development-selected block-dispersion feature retained only 11 of 60 reserve -positives and then accepted six of 1,000 fresh heterogeneous controls. A -threshold above the fresh-control maximum retained four of 120 pilot positives. -This spatial-payload branch is also excluded from runtime. - -A separate geometry-stratified analysis found a second reproducible native -16-by-16 carrier. Independently fitted 1024-by-1024 miss templates correlated -at 0.869; independently fitted non-1024 miss templates correlated at 0.956 and -matched the 1024 template at 0.82-0.84, while matching the shipped carrier only -weakly. The signal also transferred across time: an early-only template -correlated 0.839 with a July-only template and retained five July misses. - -The second carrier is not shipped. At arbitrary native geometries its first -frozen threshold accepted 25 of 11,506 heterogeneous controls. Requiring a -1024-by-1024 canonical view removed all 11,506 errors and retained 18 of 770 -fixed-template Google misses, all originally 1024 by 1024. A two-template -consensus retained only three temporal misses. This initially established -repeatability within the Google cohort, but the external natural-image -challenge below shows that it is not specific enough to freeze a runtime -operating point. - -The second carrier remained after projecting the shipped carrier out of every -normalized tile. Early and July orthogonal templates correlated at 0.834, the -early template retained five of 95 temporal misses, and its frozen operating -point accepted none of 81 reserve exact-geometry controls or 11,506 -canonicalized heterogeneous controls. This supports an independent carrier -direction rather than a weak component of the shipped template, but does not yet -provide enough temporal positives for runtime calibration. - -Mapping both scores over all 4,698 Google-labeled images found 3,825 first-only, -69 second-only, 103 both, and 701 neither cases. The scores correlated at --0.563, and both directions occurred throughout the collection interval and -across multiple geometries. The second direction is therefore a coexisting -carrier state, not evidence of a dated replacement epoch. Its native threshold -is still unsafe outside exact 1024-by-1024 inputs: 8 of 11,506 heterogeneous -native controls crossed it. The zero-of-11,506 control result applies only to -the canonical exact-geometry challenge. A stricter threshold frozen above half -of those native controls had no crossing in the other half and retained 26 -first-carrier misses, but the already inspected corpus cannot certify that -retrospective result. - -A separate natural-image challenge rejected that stricter native threshold. It -accepted zero of 1,500 unique Open Images controls and zero of 12 Wikimedia -controls, but one of 637 content-deduplicated Picsum controls. Two apparent -Picsum crossings were byte-identical copies of the same image and count as one; -the collision still prevents a universal native operating point. - -The original exact-1024 threshold also accepted four of 1,500 Open Images and -four of 637 unique Picsum controls, including native 1024-by-1024 images. A -previously defined consensus of two independent early templates still accepted -one Open Images and two unique Picsum controls while retaining three temporal -positives. Exact geometry and template consensus therefore do not make the -second direction specific enough for blind detection. - -JPEG-95 agreement retained four of the five temporal positives but also all -eight external natural-image crossings at the original threshold. The confound -is transport-stable, so codec consensus does not rescue this branch. - -The second direction is not a shifted or recolored copy of the first: its best -cyclic correlation was 0.212, its Fourier-magnitude correlation was 0.480, and -its strongest peaks were axial. A horizontal-axis-only ablation retained the -five temporal detections but accepted three of 11,506 canonicalized controls, -so the complete orthogonal template remains the more specific research feature. - -A chronologically split low-rank subspace did not improve the second carrier. -The validation-selected rank retained two of 38 validation misses and four of -95 temporal-test misses with no error in its held-out control third. The -multi-state variant is rejected rather than selected from a better-looking -post-test rank. - -No third stable linear carrier remained after deflating the first two. Early -and July residual averages correlated at 0.0003 and retained none of 95 -temporal misses. The current linear periodic evidence supports two carrier -directions in the examined 1024-by-1024 corpus. - -A positive-quadrant Euclidean norm in that two-carrier plane rescued no Google -image below both component thresholds and accepted one of 81 reserve -exact-geometry controls. Subthreshold component energy is therefore not -combined; each direction requires its own independently calibrated gate. - -The five temporal positives at the original second-carrier threshold had much -more balanced normalized RGB contributions than seven of the eight native -control errors. A clean early-fit/early-validation split retained no validation -positive at its development-control threshold, however, even though it happened -to retain six later positives. Channel balance remains a post-test research -hypothesis and is not a detector gate. - -A phase-only score then separated spectral phase from observed magnitude. Its -development threshold retained 10 of 95 July misses but accepted one of 81 -reserve exact-geometry controls. Conjoining it with a separately calibrated -linear projection retained 1 of 31 early validation misses and 3 of 95 July -misses, with zero crossings among 162 heterogeneous exact-1024 controls, 1,500 -unique Open Images, 637 unique Picsum images, and 12 Wikimedia images. The same -rule accepted three of 11,506 heterogeneous controls outside exact geometry. -This is a promising exact-1024 research candidate, but the temporal-positive -count is too small for runtime and the swapped-fit result below is not stable. - -Across all 443 exact-1024 Google images, the candidate added 11 detections that -the shipped carrier missed and overlapped it on 16; 244 were found only by the -shipped carrier and 172 by neither. - -Swapping the early fit halves and recalibrating both gates retained four of 31 -opposite-half validation misses and four of 95 July misses, again with no -conjunction crossing among 81 reserve exact-geometry controls. The swapped -operating point then accepted one of 92 unique exact-1024 Open Images controls, -though none of 637 unique exact-1024 Picsum controls. Fit-split signal -repeatability does not yet provide a stable operating point. - -An initial two-expert rule accepted none of a newly collected 100-image native -exact-1024 Open Images block and appeared to add five of 443 Google files, -including two of 95 July misses. A later content audit found three exact -decoded-pixel duplicate pairs and ten near-duplicate groups in the 443 files. -One early near-duplicate pair had been split between the two expert fits, which -invalidated the apparent independence and the five/two recall result. - -Refitting after allocating whole content groups to one side retained six Google -files and added only two files missed by the shipped carrier, including one of -95 July misses. Its conjunction accepted none of 81 reserve exact-geometry -controls, 92 exact-1024 Open Images, 637 exact-1024 Picsum images, or the 100 -new Open Images controls. At arbitrary native geometries, however, the two -corrected experts accepted 5 and 6 of 11,506 heterogeneous controls and their -conjunction still accepted 2. Both consensus errors were outside 1024 by 1024. -The result remains research-only and does not generalize into a universal-size -second-carrier detector. - -A stricter post-hoc joint margin above both arbitrary-size control collisions -retained the two corrected incremental exact-1024 positives. Across all 4,698 -Google files it accepted 28 and added 16 files missed by the shipped carrier in -11 geometries. All 16 belonged to separate perceptual content groups with no -shipped-positive sibling. It accepted none of 1,500 Open Images, 637 unique -Picsum, or 12 Wikimedia native views. This is an all-size research hypothesis, -not validation: the margin used the complete 11,506-control result, and the -natural corpora had already been exposed to related experts. A new -content-deduplicated AI-control corpus is required before implementation. - -A source-independent public model cohort gave the original group-separated -conjunction 2 of 589 Gemini 3.1 Flash Image Preview images, zero of 520 Nano -Banana Pro Preview, and zero of 280 DALL-E 3 images. The two Gemini hits were -visually distinct diverse images at 1408 by 768; the cohorts also included -solid-color and gradient probes. The post-hoc strict margin accepted none: the -family maxima were 1.011 and 0.941 against its 1.033 boundary, while DALL-E 3 -reached 0.514. Thus the base rule has weak current-Gemini transfer but retains -the two arbitrary-size control collisions, while the strict rule removes both -through a post-test threshold. Neither is a validated universal current-Google -or cross-provider SynthID signature. - -Three perceptually matched pairs contained one shipped-carrier crossing and one -non-crossing variant. In all three, the matched difference reduced the shipped -template while increasing both corrected second-template directions. This is -mechanistic support for coexisting carrier states, but three pairs give a -two-sided sign-test result of 0.25 and do not validate a detector. - -All three temporal detections from the original first-expert rule survived JPEG -95, 85, and 70, but none survived -WebP 95 or a 0.75x down-and-up resize round trip. A symmetric challenge found -one JPEG-85 crossing among 162 heterogeneous exact-1024 controls, while 92 -exact-1024 Open Images and 637 unique exact-1024 Picsum controls stayed below -threshold in every view. That transform result applies only to the first-expert -rule. The corrected group-separated experts have not been transport-calibrated; -no JPEG claim transfers to them. - -A later causal test used all 16 strict incremental hits available at that -stage. Subtracting the normalized sum of the two content-group-separated -expert directions cleared every linear and phase component on all 16 images; -the same amplitude with a one-pixel cyclic shift cleared none. Median fidelity -was 63.86 dB PSNR and 0.99989 SSIM, with minima of 58.92 dB and 0.99961. The -source carrier was fragile under transform: only one source remained strict at -JPEG 95 and 90, and aligned suppression cleared that one in both views. This is -strong local evidence that the second score follows a real pixel carrier, but -not evidence that it is a robust or provider-specific watermark. - -Joint suppression was then tested on all 28 strict second-carrier hits. The -first carrier was suppressed only when present, followed by the second carrier. -The result left zero first-carrier and zero second-carrier survivors; neither -edit reactivated the other direction. Median fidelity was 61.69 dB PSNR and -0.99984 SSIM, with minima of 58.15 dB and 0.99910. Shifted controls left one -first-carrier and seven strict second-carrier survivors rather than reproducing -the aligned result. Together with the failed third-carrier fit, this exhausts -the current linear native 16-by-16 Google hypothesis as two jointly controllable -states. It still does not replace a matching-provider oracle result. - -The bounded search is materially slower, but registered-v3 is now the default -ordinary-size route because fixed-v2 failed its fresh-source precision gate. -Registered-v3 itself does not reliably detect 0.5x carriers. The later bounded -opponent fallback covers the measured lossless 0.5x case with its codec-grid -veto, but does not make the detector universal across crop, codecs, carrier -states, or providers. - -A crop-specific follow-up tested cube-root LMS and OKLab projections, all six -DTCWT orientations, and explicit FFT phase-lock metrics after period-and-phase -registration. Each representation exposed additional positive signal, but its -development-selected candidate failed a fresh control challenge. The strongest -registered green-channel phase candidate accepted five of 1,000 COCO controls; -a threshold above their maximum retained only eight of 100 positives. These -features remain research diagnostics and are not part of the runtime detector. -See the -[`Registered color and phase-lock challenge`](synthid-detector-removal-plan.md#2026-08-12-registered-color-and-phase-lock-challenge). - -Green-channel inversion of the 32 strongest conjugate carrier pairs then -cleared 58 of 59 locally detected public positives while preserving at least -43.36 dB PSNR and 0.99048 SSIM. This demonstrates precise control over the -local periodic correlate, not the proprietary decoder. A fixed Google-oracle -challenge made that distinction explicit: all three phase candidates cleared -the local threshold at 55.41-57.86 dB PSNR, while Gemini still reported Google -AI signals for all three. The recoverable source controls and RMS-matched shams -were also positive. One of nine submitted chats did not persist, but every -phase candidate produced a verdict. Blind Green-channel SWT-HH thresholding -cleared none of the 59 local positives. See the -[`Green phase perturbation and blind SWT removal`](synthid-detector-removal-plan.md#2026-08-12-green-phase-perturbation-and-blind-swt-removal) -challenge. - -Those three oracle-confirmed phase candidates are now adversarial hard -positives: both the native and registered runtime modes miss all three. A -phase-invariant Blue-carrier magnitude rescue recovered all three and happened -to reject 3,000 COCO controls when combined with a native-score floor, but it -then accepted 90 of 11,506 metadata-labeled non-Google controls. The branch is -therefore a shared generator or renderer correlate, not a SynthID-specific -rescue, and is not included in runtime. - -A public `cebeuq/Synthid-Bypass` V2 corpus supplied a separate external check. -The native detector accepted 10 of 12 `before` images and none of the 12 -diffusion-reconstructed `after` images. Gemini Verify AI independently reported -SynthID on both missed sources `03.webp` and `07.png`, and no SynthID on their -paired outputs. It also reported no SynthID on outputs `01` through `06`; the -daily verifier limit prevented completing `08` through `12` in the same -session. - -A separate audit of the later `aloshdenny/reverse-SynthID` V4 codebook -reproduced its published phase score without executing third-party code or -pickle payloads. The bundled positive sample crossed its `0.52` threshold, but -the same detector accepted 141 of 355 Google positives, 191 of 499 controls, -and 386 of a later 1,000-image Open Images reserve. Its paired-corpus AUC was -0.517. The stricter 0.3%-aspect-ratio router still detected 132 of 343 supported -controls and 284 of 685 supported fresh controls. The older V3 dark/white phase -score had AUC 0.473 and, at its documented 0.78 phase threshold, accepted 5 of -355 positives, 5 of 499 controls, and 6 of 1,000 fresh controls. Neither -external operating point is part of runtime. Prespecified amplitude-aware -reranking within a 4-128-pixel carrier band also failed, with best-of-model AUC -at most 0.524. - -An exact-1024 rerun of the numeric V4 artifact provided a direct confirmation. -Its Gemini 3.1 profile accepted 117 of 443 Google rows and 28 of 162 controls; -the Nano Banana profile accepted 92 and 39, and their union accepted 177 and -60. This is the same nonspecific operating point on a cleanly bounded geometry, -not a missing resolution route. - -The useful finding is narrower: truly flat Gemini references have widespread -cross-image phase coherence, so cross-color consensus remains a valid discovery -tool. The published DALL-E 3 `black` and `white` folders do not provide the same -OpenAI experiment. Their image means vary widely and only 39 and 32 RGB bins -respectively exceed 0.9 phase coherence across ten 1024-square images, compared -with 20,443 and 100,252 for the corresponding Gemini 3.1 buckets. Only four -bins crossed 0.9 in both DALL-E color cohorts, all at trivial DC or -self-conjugate-axis coordinates. These are heterogeneous content examples, not -solid OpenAI references, and cannot calibrate an OpenAI pixel detector. More -fundamentally, the official SynthID-Image paper says nearly uniform corner cases -may be excluded from watermarking. A flat output is therefore not automatically -a stronger or even positive watermark reference. - -A later exact-geometry challenge used 1,124/579/562 train/validation/test -OpenAI-asserted images at 1086x1448 and 399/203/228 same-size general controls. -A 256-bin polarity-invariant phase codebook learned from the lowest-texture -train images was not reproducible: deterministic train halves shared only two -exact bins. Native test AUC was 0.512 and JPEG-95 test AUC was 0.512. The -zero-validation-control threshold accepted none of 562 test positives; a 1% -validation-FPR threshold accepted four positives and two controls. This rejects -the hypothesis that low texture reveals one shared OpenAI absolute phase. - -Testing the paper's content-dependent mechanism directly also failed to produce -a stable expert. A 60-feature OKLab model measured only within-image alignment -between blockwise residual energy and local texture, contrast, and chroma, with -no global phase or amplitude. Its native/JPEG minimum reached test AUC 0.593 and -accepted 15 of 562 OpenAI rows versus one of 228 controls at the frozen -validation boundary. Independent train halves had coefficient cosine 0.148; -one accepted 13 positives and two controls while the other accepted none, with -no overlapping positive. This is an unstable content or export correlate, not -a detector. - -The same assertion-contrast method was then applied to the exact 1254-square -cohort. Validation selected an sRGB period-8 Blue-channel candidate at 45 of -259 asserted rows and zero of 16 same- or other-provider rows. Its locked test -accepted 39 of 233 asserted rows and one of 18 controls. Across all 992 asserted -rows at that geometry it accepted 185, alongside one of 55 same-provider rows -without an assertion and one of 13 other-provider rows. A threshold raised -after those errors retained only a small minority of positives and cannot be -called a new codeword. This geometry-specific branch is rejected. - -The two misses expose different boundaries. `07.png` contains the usual -positive carrier at low amplitude: its fixed score is `0.12484`, Green is the -strongest signed channel, and all nine aligned spatial regions are positive. -Lowering the threshold or adding that channel-and-spatial conjunction is not -safe. A fresh, source-verified 1024-by-1024 Open Images photograph scored -`0.21237`, had an even stronger Green-dominant and nine-region response, and -Gemini reported `SynthID not detected`. The current runtime therefore has a -confirmed natural-image false positive outside the corpora used to freeze its -threshold. Separately, Lanczos-resized natural Kodak PNGs reached `0.37683`, -showing that some rational resampling lattices can imitate the signed carrier -even inside the challenged pixel-count range. The earlier zero-of-5,000 COCO -results remain true for those exact transformations, but they do not establish -universal specificity. - -`03.webp` is not recovered by the two group-separated alternate-Gemini experts. -Instead it has a spatially uniform opposite-polarity response: fixed correlation -`-0.32634`, all RGB channel correlations below `-0.28`, and all nine aligned -regions negative. Its oracle-negative paired output weakens to `-0.15314`. -No image crossed `-0.20` in either 5,000-view COCO geometry challenge or in the -fresh natural-image blocks, but 40 of 11,506 heterogeneous non-Google-labeled AI -controls did. Those crossings include multiple generator families, so the -negative direction is a plausible additional codeword or shared renderer state, -not a provider-specific detector. Both hard positives remain research cases; -neither a lower positive threshold nor an inverse-template branch is shipped. - -Separating signed channel correlations produced a more specific research -candidate. The three oracle-confirmed Green-phase hard positives retained Red -correlations of 0.781-0.804 while their Green correlations became negative. A -frozen `Red >= 0.377 and Green < 0` conjunction accepted all three and none of -15,005 controls across three independent corpora. It recovered 19 of -58 synthetic Green inversions that cleared the native detector. This remains -research-only because the rule was motivated after inspecting the attack, -only three recovered images have external oracle labels, and no untouched -control corpus remains for a final challenge. - -A registered transform challenge exposed one false positive among 3,000 COCO -controls when the Red-only rule searched all 256 phase offsets. Searching six -fixed carrier periods added no further false positives. A stricter candidate, -already frozen before that result, requires both unchanged color channels: -`min(Red, Blue) >= 0.377` and `Green < 0`. It accepted none of the 3,000 -controls after either phase-only or phase-and-period registration; the largest -registered control score was 0.342. It also accepted none of 1,200 transformed -views made from 100 fixed Open Images controls, whose largest score was 0.332. - -On the three oracle-confirmed hard positives, the stricter rule retained all -three at native scale and after three crops. Six-period registration retained -all three after 0.65x, 0.75x, 1.25x, and 1.5x resizing. It retained none after -0.5x resizing or JPEG round trips at quality 95, 85, or 70. All corresponding -source and RMS-noise sham views remained negative. This is evidence for a -bounded phase-and-scale rescue, not arbitrary-resolution or codec robustness. - -A later five-period challenge over all 11,506 metadata-labeled non-Google -controls invalidated the common 0.377 threshold: it accepted 16 controls, two -at period 20 and 14 at period 24, with a maximum score of 0.435813. A -conservative threshold strictly above that maximum retained all three hard -positives from 0.75x through 1.5x, and accepted none of the same 11,506 controls -after symmetric 0.75x, 1.25x, or 1.5x resize round trips. The control maxima -were 0.431352, 0.393949, and 0.372036. - -At 0.65x, the symmetric control maximum of 0.397396 exceeded the weakest hard -positive at 0.394911. An exploratory period-10.4 gate added -`Blue >= 1.1 * Red` to the original R/B and negative-G conjunction. It retained -all three hard positives while the largest qualifying score among all 11,506 -controls was 0.355165 natively and 0.354955 after the same 0.65x round trip. -The combined candidate retains all three hard positives after identity, three -crops, and resizing from 0.65x through 1.5x, while rejecting their source and -noise-sham counterparts. - -Sensitivity remained narrow on the broader synthetic attack cohort. Of 58 -Green inversions that cleared the native detector, the combined rule recovered -4 at 0.65x, 5 at 0.75x, 16 at identity, 11 at 1.25x, and 10 at 1.5x. The three -oracle source images are included in that cohort, and only one additional -synthetic source passed at 0.65x. This is a high-specificity residual-carrier -rescue, not a universal phase-attack detector. - -A source-split Fisher projection of the three channel correlations did not -improve the tradeoff. Period-specific thresholds fitted on 5,719 training -controls accepted 11 of 23,148 views from 5,787 test controls, while recovering -only 4 of 28 scale views from seven source-held-out attacks. The learned linear -branch is rejected. - -Aligned 2x2 and 4x4 spatial-consistency grids also failed to improve the 0.65x -frontier. Across all 537 controls with global scores at or above 0.20, local -R/B strength, negative-G coverage, and `Blue / Red` coverage all overlapped the -58 synthetic detector misses. The best zero-control conjunction still -recovered only four attacks; a five-attack rule accepted two controls. The -spatial branch is rejected. - -The same three attack-derived positives still provide all external oracle -labels, the 0.65x ratio was selected after observing a control collision, and -the conservative threshold came from an already exposed corpus. The branch -therefore remains outside runtime pending new preregistered oracle positives -and an untouched control challenge. - -An exploratory attempt to rescue JPEG and 0.5x cases by adding the magnitude -of negative Green correlation to the R/B score did not improve this boundary. -Transform-wide calibration raised the safe thresholds to 0.655-0.695; both -discordance metrics still retained no JPEG case and only one of three 0.5x -cases, matching a relaxed R/B-only score. They are rejected as redundant. - -Three further attempts did not extend that boundary. JPEG-specific templates -learned from 50 other public positives recovered none of the nine JPEG hard -positive views and accepted two to three of 150 evaluation controls. A frozen -low-to-middle radial band did retain all three hard positives after JPEG 95, -85, and 70, but phase search accepted 179 of 12,000 COCO views. Adding -four-quadrant consistency still accepted 189 of 34,518 views from 11,506 -metadata-labeled non-Google controls, including camera images. Removing phase -search did not solve the confound: the fixed-phase band accepted 248 of 12,000 -COCO views, whose maximum score exceeded every hard positive. The -JPEG-surviving component is therefore a general low-frequency or codec lattice, -not a SynthID-specific rescue. - -For 0.5x inputs, a physically reduced 8x8 template was tested after projecting -the shared 2x2 lattice out of both template and observation. A threshold above -all 11,506 symmetrically downscaled controls was 0.396, while the three hard -positives scored only 0.277-0.288. Channel separation therefore does not rescue -the already rejected period-8 branch. - -An untouched 2026-08-07 through 2026-08-10 temporal cohort then challenged the -runtime detector without refitting its template or threshold. After correcting -the evaluation cohorts for mixed-provider provenance, native detection accepted -183 of 252 files with an explicit Google LLC SynthID assertion and 3 of 488 with -an explicit OpenAI-only assertion. Scale registration accepted 130 and 1, -respectively. Among 170 files with no SynthID provenance assertion, the native -and registered modes accepted eight and two. These are not oracle-negative -labels, so they bound operational specificity without establishing it. - -The same frozen native detector retained 153 of 252 explicit-Google files after -a symmetric 0.75x resize round trip, 66 after JPEG-95, and 54 after WebP-95. A -5% center-crop round trip retained none. The temporal result confirms a real, -partly codec-tolerant Google pixel signal across many sizes, but also confirms -that the current fixed-phase statistic is not a universal SynthID decoder. -Applying the already frozen scale-registration path after the same crop -recovered only 8 of 252 explicit-Google files while accepting none of 488 -explicit OpenAI-only files. Crop robustness therefore needs a different, -independently calibrated phase-and-support model; enabling the existing scale -search is not a fix. - -A diagnostic joint transform explains part of the failure. Undoing the known -0.95 crop scale and scoring the tile at the cyclic phase predicted from the -removed border reached 0.859 AUC against every other temporal cohort. A -post-hoc threshold above all 671 non-Google rows retained 37 of 252 -explicit-Google files. Searching all 256 phases was weaker at 0.839 AUC and -retained 41. The geometry-predicted phase is therefore a plausible crop -mechanism, but its threshold has observed the temporal controls and cannot enter -runtime until a new future holdout confirms it. - -A positive result identifies the carrier but does not attribute a provider. -Provider identity still comes from provenance. - -The command reports `indeterminate` separately from `unsupported`. Both are -inconclusive: the first means no qualified local expert crossed its threshold, -and the second means no local expert covers the geometry. - -The same modulo-folding method has been tested separately on a large, -temporally split OpenAI-labeled corpus. Its strongest native-size template was -dominated by a generic 2x2 generator lattice that also appeared in multiple -non-OpenAI controls. Removing that nuisance component left a sparse, -time-limited signal with inadequate sensitivity. OpenAI pixel detection is -therefore not part of the runtime expert. - -Learned residual, forensic, ensemble, and canonical 512x512 representations -were also tested against 261 same-provider candidate controls. A later -software-agent audit found that only 118 of those controls explicitly name -`gpt-image 2.0`; the rest come from earlier or unknown versions. The strongest -native ensemble accepted 243 of 1,364 development-test positives with no -accepted controls, but accepted none of those positives after a JPEG-95 round -trip. A model trained after equalizing every image through JPEG-95 accepted -only six development-test positives and five COCO controls. The apparent -native signal is therefore treated as an export noiseprint rather than a -validated robust watermark. A local CLIP search over the exact-version subset -found no clean same-content before/after pair, and a published third-party CNN -surrogate mislabeled 83.4% of held-out COCO controls at its stated threshold. -A transform-augmented 512x512 RGB-plus-residual CNN then accepted zero of 1,364 -development-test positives in native, JPEG-95, and resize views; its crop view -accepted one positive and one difficult control. Detailed counts and rejected -alternatives are in -the [`OpenAI periodic-carrier challenge`](synthid-detector-removal-plan.md#2026-08-10-openai-periodic-carrier-challenge) -and [`OpenAI content-dependent decoder challenge`](synthid-detector-removal-plan.md#2026-08-10-openai-content-dependent-decoder-challenge). - -A later opponent-color screen found a narrower OpenAI pixel candidate that the -RGB experiments had not tested. Across 75 color-space, period, and channel -variants selected on validation only, the winner was the third cube-root LMS -component at period 8. It required the same image to clear independently fitted -native and JPEG-95 thresholds above all validation controls. On the locked test -it accepted 16 of 158 OpenAI images and none of 135 heterogeneous controls. -Those 16 spanned 11 collection dates and all three native geometries, and none -shared a perceptual-hash group across splits. - -The signal is not merely the old 2x2/4x4 export lattice. Although 84-90% of the -raw period-8 template energy projected onto a period-2 or period-4 subspace, -subtracting the complete period-4 projection before refitting still accepted -15 of 158 test positives and none of 135 controls. The frozen candidate -retained all 16 source detections after WebP-95 and four after JPEG-90, but none -after crop or resize round trips. Exhaustive cyclic phase registration did not -rescue those geometric transforms. Among 52 action-derived same-provider -OpenAI controls it accepted one, and none of the 12 controls explicitly naming -`gpt-image 2.0`; those counts remain far too small for provider specificity. - -A direct pixel ablation supplied local causal evidence. Subtracting the frozen -period-8 direction from only the sRGB Blue channel cleared both native and -JPEG-95 scores on all 16 sources, while the least-correlated cyclic shift -cleared none. Median amplitude was two Blue levels, with median 52.24 dB PSNR -and 0.99639 SSIM; the worst case fell to 40.62 dB and 0.97184. This controls the -local score but is not an OpenAI-oracle removal result. - -The candidate does not make OpenAI detection universal. A frozen all-size map -accepted 94 of all 7,259 OpenAI-labeled images and 9 of 8,670 heterogeneous -controls. The three training geometries supplied 76 of those 94 positives, -while the common 1086x1448, 1254x1254, and 1122x1402 exports supplied none. -A preregistered search over integer periods 4 through 16, calibrated above the -maximum period-searched validation control, retained 14 of 1,839 test OpenAI -images and one of 2,162 test controls. Every accepted positive selected period -8; no scaled period added a detection. The measured feature is therefore a -real, codec-tolerant, raster-phase carrier candidate in a narrow native subset, -not a universal OpenAI SynthID detector. Its attribution still requires an -authorized OpenAI oracle or adequate same-model oracle-negative pairs. - -A phase-free follow-up tested whether geometric resampling destroys carrier -phase but leaves a usable spectral envelope. It fitted LMS periodic magnitude, -power, and log-magnitude profiles at periods 4 through 16 and required native, -JPEG-95, and 0.75x-resize agreement. The validation-selected period-16 power -ensemble accepted 8 of 158 native-geometry test positives and none of 135 -controls. Without refitting, it also accepted none of 4,389 wider Spaces -validation/test controls and none of 118 exact-version same-provider controls; -one of all 261 mixed-version same-provider candidates crossed. Five of the -eight detections overlapped the signed period-8 carrier and three were new. - -This does not supply an independent universal expert. Removing exact period-8 -Fourier support left five test positives but admitted two of 135 controls; -removing the broader period-4 support removed all test positives. A stronger -requirement that one period-15 magnitude template serve native, JPEG, and -resize views looked promising on native geometries, then accepted 14 of 2,162 -wide test controls and two of 118 exact-version same-provider controls. The -safe period-16 result instead uses three separately fitted view templates, whose -native and resize directions correlate only 0.061. It is a transport-consensus -diagnostic of the same harmonic family, not a single resampling-invariant -codebook and not a runtime detector. - -A final scale-covariance test evaluated the proposed fractal interpretation -without fitting any provider template. It compared each image's phase-free LMS -spectrum with its own JPEG-95 spectrum and with a physically reduced 0.75x -view, scaling candidate periods from 8, 12, 16, 20, and 24 accordingly. The -best validation choice retained three positives, then accepted zero of 158 -locked-test OpenAI images and one of 135 controls. Median self-similarity was -lower for OpenAI than for controls. Simple spectral fractal or scale-covariant -self-similarity is rejected. - -Higher-order phase coupling did not rescue the fractal interpretation for -Gemini. A block-bicoherence probe used period-16 harmonic triplets whose -combined phase is mathematically invariant to global translation. Seventy-two -Green, cube-root LMS, OKLab, residual-scale, block-size, and harmonic-count -variants were charged to separate fit and selection splits, followed by an -independent control calibration. The selected variant accepted none of 20 -selection positives. Its untouched result was 0 of 50 Gemini positives and 0 -of 199 controls, with AUC 0.374. The known Gemini signal is not a detectable -multiscale harmonic cascade in this representation. - -The common export sizes were then tested explicitly rather than treated as -unknown arbitrary geometries. For the dominant 1086x1448 cohort, an isotropic -inverse mapping to the plausible 1536x2048 source raster recovered none of 579 -validation or 562 test positives. Direct fractional registration at the -corresponding approximately 5.66-pixel output period retained only 4 of 562 -test positives and accepted 2 of 135 controls; the median OpenAI score was -lower than the control median. The candidate is not hidden at a simple -`1/sqrt(2)`-scaled period. - -Nonlinear tests also failed the specificity boundary. Folding the magnitude, -energy, normalized energy, and cross-channel products of LMS and OKLab -residuals selected a period-4 absolute LMS residual. It retained 43 of 158 -native-geometry test positives but accepted 2 of 135 controls. On the frozen -wide challenge it accepted 52 of 1,839 test OpenAI images, 5 of 2,162 test -Spaces controls, and 10 of 261 action-derived same-provider controls, including -5 of 118 explicit `gpt-image 2.0` controls. Four-quadrant patch consistency -retained 41 positives without removing the two native-geometry control errors. -A separate 360-candidate lag-product screen selected a period-16 vertical-lag -feature and retained 43 positives, but accepted four controls. - -The current manual OpenAI search is therefore bounded rather than successful. -Signed phase, phase-free power, subperiod deflation, scale and inverse-raster -registration, spectral self-similarity, nonlinear periodic variance, patch -consistency, and lag autocorrelation either have low recall or fail held-out -specificity. These features remain research measurements of renderer structure; -none is a runtime OpenAI SynthID detector. Further feature selection needs -provider-authorized oracle labels or clean same-content counterfactual pairs -and a fresh temporal holdout. - -That temporal holdout is now available for validation, but it did not rescue the -OpenAI claim. The untouched 2026-08-07 through 2026-08-10 cohort contained 486 -explicit OpenAI-provenance positives, 504 heterogeneous controls, and only seven -pure OpenAI C2PA files without a SynthID assertion. The frozen signed period-8, -phase-free period-16, and nonlinear period-4 candidates accepted 5 of 486, 2 of -40 supported geometries, and 14 of 486 positives, respectively. Their control -counts were 1 of 504, 1 of 50 supported geometries, and 8 of 504. None transfers -as an OpenAI SynthID detector. - -The previously frozen 512-square full-image forensic model transferred better: -82 of 486 positives crossed its unchanged threshold, against one of 504 general -controls and one of seven pure OpenAI C2PA candidate controls. Its positive -scores separated strongly in aggregate, but all 82 native detections disappeared -after both JPEG-95 and WebP-95 round trips. Resize retained 46 and crop retained -23. This temporal and transformation result supports the existing export- -noiseprint interpretation rather than watermark attribution. Further adaptive -feature selection on the same corpus is not justified without provider-oracle -labels or clean counterfactual pairs. - -A provider-key hypothesis was tested explicitly rather than assuming that -OpenAI and Google share one fixed template. Spherical clustering fitted up to -32 period-8 cube-root-LMS carrier directions on OpenAI training images, with -cluster count selected on validation only. Eight directions increased the -native-geometry development-test count from 16 to 27 of 158, but accepted one -of 135 heterogeneous controls and two of 52 action-derived same-provider -controls. The two dominant directions contained 234 of 283 training images and -correlated at 0.982 natively and 0.924 after JPEG; they are not independent -codewords. - -A complete provider challenge clarified the confound. The frozen eight- -direction model accepted none of 443 Google files, but accepted 5 of 39 -Microsoft Designer files and 123 of 674 OpenAI-platform files. Every Microsoft -hit had joint `Microsoft, OpenAI` provenance, one explicitly asserted an OpenAI -SynthID watermark, and all five selected the same dominant direction as 111 of -the 123 OpenAI hits. On the August temporal native- -geometry cohort it accepted five unique OpenAI content groups and two Microsoft -groups after SHA-256 deduplication. A sign-invariant carrier-subspace model -retained only 1 of 158 development-test positives and none of 40 fresh rows. -After fitting an OpenAI direction orthogonal to the Google, Microsoft, and -other-provider training means, only 1 of 158 development-test positives and -none of 40 fresh rows remained. - -Regrouping by the signed watermark assertion makes the interpretation narrower -but not purely negative. The model accepted 115 of 589 files explicitly -asserting an OpenAI SynthID watermark, 13 of 117 files with OpenAI lineage but -no watermark assertion, none of 443 Google files, and one of 89 other-provider -files. Aggregate enrichment over OpenAI lineage without an assertion was -significant by a one-sided exact test (`p = 0.0179`), but it was not -independently significant in the already exposed development-test split -(`27/159` versus `2/25`, `p = 0.202`). Missing C2PA action is not an -oracle-negative watermark label, so either attribution remains provisional. - -The frozen multi-direction score had more codec persistence than the rejected -full-image noiseprint. Of 27 native development-test detections, WebP-95 -retained 24 and added nine other positives; JPEG-90 retained eight and added -none. The corresponding control counts were one of 135 under WebP and zero -under JPEG. A 0.75x resize and 5% crop retained none. The best current -interpretation is a real raster-phase component associated with the -OpenAI/Microsoft lineage and distinct from the measured Google carrier. It may -be one projection of a provider-keyed watermark family, but it is neither the -complete robust SynthID surface nor a universal OpenAI detector. - -The provider-specific-code hypothesis is architecturally plausible but has two -different forms. The SynthID-Image paper explicitly separates binary watermark -detection from payload recovery, describes payloads as a way to distinguish -customers of the same service, and makes the encoder determine the watermark -version. Different providers can therefore use one SynthID family without -sharing encoder versions, payloads, or observable carrier templates. This does -not imply that provider identity is a fixed FFT phase offset: the encoder and -payload are content-dependent and the detector can be updated across multiple -encoder versions. - -A direct carrier-family comparison rejected the narrowest form of that -hypothesis. It compared 581 byte-unique OpenAI-asserted images, 443 Google- -asserted images, 93 OpenAI-lineage images without a watermark assertion, and 82 -other-provider controls. In period-8 cube-root LMS residuals, the signed phase -and phase-free power contrasts were stable between deterministic cohort halves: -OpenAI stability was 0.949 and 0.957, while Google stability was 0.779 and -0.877. Raw cross-provider power had cosine 0.480, consistent with common image -and export structure. After subtracting the respective control means, however, -positive power support had cosine 0.0123, a bootstrap median of 0.0232 with a -95% interval of 0.0009 to 0.0856, zero overlap among the 12 strongest -coordinates, and per-channel cosines of 0.0085 to 0.0155. Signed cross-provider -contrasts were negative rather than phase-locked. The measured OpenAI and -Google components are therefore not one fixed frequency support carrying two -different phases. They remain compatible with distinct SynthID encoder -versions or content-dependent nonlinear codes, which require separate learned -experts and provider-oracle validation. - -A frozen, non-adaptive check with OpenAI's public verifier then resolved the -most important attribution question. One C2PA-asserted image accepted by the -eight-direction period-8 candidate was checked both as its original PNG and as -a newly encoded pixel-identical PNG. OpenAI Verify reported `SynthID detected` -and `Content Credentials not detected` for both files. The local component -therefore overlaps a genuine OpenAI SynthID watermark rather than relying on -metadata. A second asserted OpenAI image that the local candidate rejected at -native/JPEG scores 0.709/0.657, below its 0.918/0.923 thresholds, was also -re-encoded without metadata. The oracle again reported `SynthID detected` and -`Content Credentials not detected`. This is direct evidence that the current -period-8 model is one real but incomplete OpenAI SynthID expert, not a universal -decoder. The oracle check stopped after these preselected validation cases; -OpenAI's Content Provenance documentation explicitly disallows repeated -queries to reverse-engineer, remove, or evade a watermark. - -Per-direction calibration does not safely recover that false negative. Giving -each of the eight directions its own threshold above the corresponding -validation-control maximum raised validation recall to 36 of 140 with zero of -145 controls, then accepted 46 of 158 locked-test OpenAI rows and 4 of 135 -controls. On the wider provider challenge it accepted 180 of 603 explicit -OpenAI-SynthID rows, 15 of 80 OpenAI-lineage rows without an assertion, 6 of -443 Google rows, and 7 of 112 other-provider rows. Fresh temporal counts were -13 of 40 OpenAI rows, 4 of 50 general controls, and 1 of 3 same-provider -controls. The oracle-positive false negative selected direction 4, but that -direction retained only 1 of 4 positives under leave-one-positive-out refits; -the oracle-positive row then failed its JPEG threshold. The weak directions -are unstable clusters, not defensible additional codewords. - -A complementary train-only bilinear probe looked for carrier directions whose -within-image variation remains correlated between native pixels and a JPEG-95 -round trip. It selected a rank-1 positive covariance contrast on validation and -accepted only 1 of 158 locked-test OpenAI rows, zero of 135 controls, zero of -40 fresh OpenAI rows, and zero of 53 fresh controls. Variable payload phase is -therefore not recoverable as one shared codec-stable covariance subspace in the -current period-8 LMS representation. - -A local phase-cancellation probe then computed the spectrum of every spatial -block before averaging magnitudes across the image. Of 18 validation-screened -variants, period-8 unit-normalized block log-magnitude accepted 8 of 158 locked- -test OpenAI rows and zero of 135 controls, followed by 3 of 40 fresh OpenAI rows -and zero of 53 fresh controls. The fresh hits were complementary to the frozen -global eight-direction model, but neither OpenAI-oracle-positive image crossed -the new thresholds. - -The broader provider distribution identifies the feature as another renderer -component. It accepted 17 of 603 explicit OpenAI-SynthID rows, 5 of 80 OpenAI- -lineage rows without an assertion, zero of 443 Google rows, and 2 of 112 other- -provider rows, both from Microsoft. An explicit assertion was not enriched over -same-lineage non-assertion (`p = 0.966`, one-sided exact test in the required -direction). Raising the frozen native and JPEG thresholds above the 32 same- -lineage validation maxima left zero of 158 locked-test positives. The feature -is independent of the fixed phase score but is not independently attributable -to SynthID. - -An OpenAI-specific DTCWT screen then tested the shift-tolerant directional -wavelet proposal. It compared all six orientations, grouped diagonal and axis- -near bands, individual levels, and complex, magnitude, and phase statistics in -the period-8 cube-root-LMS residual. Validation selected all-orientation complex -correlation at 19 of 140 OpenAI rows and zero of 145 controls. The locked test -accepted 16 of 158 OpenAI rows and zero of 135 controls; the fresh holdout -accepted 2 of 40 OpenAI rows and zero of 53 controls. - -The result strengthens the existing carrier attribution without supplying a -new decoder. Both fresh hits were already global phase hits, neither oracle- -positive image passed both thresholds, and 72 of 77 provider-challenge hits -overlapped the signed phase expert. The provider counts were 77 of 603 explicit -OpenAI-SynthID rows, 3 of 80 same-lineage non-assertions, zero of 443 Google -rows, and 2 of 112 other rows, both Microsoft. Assertion enrichment was -significant (`p = 0.00879`), but the union improved the phase expert only from -115 to 120 explicit rows. Calibration above the same-lineage validation maxima -left 4 of 158 locked-test and 1 of 40 fresh OpenAI rows, plus one Microsoft -test hit. - -Most importantly, the frozen DTCWT decision was not shift invariant. One-pixel -and `(3, 5)` cyclic shifts, a 0.75x resize round trip, and a 5% crop round trip -all retained zero of 16 baseline detections. JPEG-90 retained four; WebP-95 -retained 15 but accepted 25 OpenAI rows in total and introduced one of 135 -controls. The wavelet representation is a more codec-tolerant view of the same -fixed raster phase, not a universal resolution or payload expert. - -Family-wise selection did not uncover a shift-invariant exception. The best -magnitude candidate retained 10 of 158 locked-test OpenAI rows and zero of 135 -controls, then 3 of 40 fresh OpenAI rows and zero of 53 controls; every fresh -hit was already a signed-phase hit. One-pixel and `(3, 5)` rolls, resize, and -crop each retained zero of the ten baseline magnitude detections. JPEG-90 kept -one and WebP-95 kept two. The best phase-only candidate failed the fresh test at -1 of 40 OpenAI rows versus 3 of 50 controls. Magnitude computed after periodic -complex folding still inherits the fixed phase origin and is not the intended -translation-insensitive DTCWT statistic. - -Removing periodic folding altogether produced the intended translation- -insensitive statistic but lost discrimination. Nine global six-orientation, -three-level energy summaries selected median magnitude proportions within each -level on validation. The locked test accepted 5 of 158 OpenAI rows and 1 of 135 -controls, and the fresh holdout accepted zero of 40 OpenAI rows and zero of 53 -controls. In the measured representation, retaining the carrier also retains -its raster origin; pooling away that origin removes the transferable signal. - -The 77 DTCWT provider-challenge hits were not a single temporal or geometric -rollout. Restricting both labels to `OpenAI Media Service API` gave 75/581 -asserted hits versus 1/52 same-generator rows without an assertion. Asserted -rates stayed at 3/16, 46/365, and 26/200 from May through July and at 6/47, -44/320, and 25/214 over the three native geometries. The continuous -native/JPEG minimum score had 0.721 AUC between those two strata. This supports -a persistent but weak OpenAI component, not one obsolete encoder cohort; -non-assertion is still not an oracle-negative label. - -Discarding absolute phase did not recover the missing variants. One -prespecified model sorted all 64 cyclic period-8 template correlations for -each image, fitted the resulting orbit shape on train only, and calibrated -native and JPEG-95 thresholds above all 145 validation controls. The two views -individually retained 5 and 7 of 140 validation positives, but their locked -conjunction accepted 0/158 OpenAI rows and 0/135 controls. The OpenAI evidence -that transfers is therefore signed raster phase; a generic shift-invariant -matched-filter shape is insufficient. - -Three additional rescue families also failed. The official InvisMark decoder -checkpoint first passed its own watermark self-test at 0.997 confidence and -0.97 bit accuracy, but OpenAI hits, OpenAI misses, Google, Microsoft, Canva, and -same-provider controls all clustered near 0.18 confidence. It cannot veto -generic renderer matches. Applying DTCWT modulus before period folding retained -5 of 158 locked-test OpenAI rows and zero of 135 controls, then none of 40 fresh -OpenAI rows. A 16-codeword whitened period-8 model retained 5 of 158 and zero -controls, then 1 of 40 fresh OpenAI rows and 1 of 50 fresh controls. Neither -branch recovers the known oracle-positive miss or supports runtime routing. - -The same content-hash audit found repeated uploads in every fresh temporal -report. Unique-group counts were 417 OpenAI positives rather than 486 rows and -211 Google positives rather than 234 rows. The conclusions were unchanged: -the signed OpenAI period-8 candidate accepted 4 of 417 unique positives and 1 -of 454 general-control groups, the OpenAI full-image noiseprint accepted 68 of -417, and the Google runtime detector accepted 153 of 211 unique positives. - -A previously prepared image-level patch-bag hypothesis was then executed. It -aggregates the mean, standard deviation, minimum, and maximum of nine learned -embeddings from normalized high-pass luminance and opponent-color patches. The -validation-selected model accepted 285 of 1,280 OpenAI rows above every -validation control. Without changing its weights or threshold, it accepted 324 -of 1,364 development-test rows, 3 of 1,373 difficult controls, and 1 of 2,000 -COCO controls. The later August temporal challenge accepted 120 of 486 OpenAI -rows, 8 of 504 general controls, and 5 of 33 same-provider rows without a -watermark assertion. Assertion enrichment over that same-provider stratum was -not independently significant (`p = 0.151`, one-sided exact test). Content -grouping reduced those counts to 107 of 417, 4 of 453, and 4 of 30, -respectively. A JPEG-95 conjunction retained only 1 of 486 -OpenAI rows and no controls. Its native hits did not overlap the frozen signed -period-8 temporal hits. The model therefore reveals a complementary OpenAI -export noiseprint, not a codec-robust or watermark-specific decoder, and does -not enter runtime. +This heading is kept because older README and CLI links pointed here. The +package does not ship a local SynthID pixel detector. The periodic-lattice +expert is research-only under `scripts/synthid_runtime/` and +[synthid-detector-research.md](synthid-detector-research.md). It is not a +payload decoder and is not called from `identify` or the CLI. + +The product routes for SynthID are signed provenance, documented in the next +sections, and `verify-openai-synthid`. ### 3.3 Official OpenAI pixel verification @@ -2257,3 +1133,89 @@ reproducible verification requires a fixed seed. 10. Cao et al. (2026). **MarkNull: Model-Agnostic Watermark Removal in AI-Generated Images via On-Manifold Latent Manipulation.** USENIX Security 2026, arXiv:2608.10166. https://arxiv.org/abs/2608.10166 + +11. Ao, Du, Wang, Chen, Lu (2026). **AWPD: Frequency Shield Network for + Agnostic Watermark Presence Detection.** arXiv:2603.06723. + https://arxiv.org/abs/2603.06723 + +12. Gunn, Zhao, Song (2025). **An Undetectable Watermark for Generative + Image Models.** ICLR 2025, arXiv:2410.07369. + https://arxiv.org/abs/2410.07369 + +13. Francati, Goonatilake, Pawar, Venturi, Ateniese (2026). **The Coding + Limits of Robust Watermarking for Generative Models.** IEEE EuroS&P 2026, + arXiv:2509.10577. https://arxiv.org/abs/2509.10577 + +14. Kassis and Hengartner (2025). **UnMarker: A Universal Attack on + Defensive Image Watermarking.** IEEE S&P 2025, arXiv:2405.08363. + https://arxiv.org/abs/2405.08363 + +15. Liu et al. (2025). **Image Watermarks are Removable Using Controllable + Regeneration from Clean Noise.** ICLR 2025, arXiv:2410.05470. + https://arxiv.org/abs/2410.05470 + +16. An et al. (2024). **WAVES: Benchmarking the Robustness of Image + Watermarks.** ICML 2024, arXiv:2401.08573. + https://arxiv.org/abs/2401.08573 + +17. Bui, Agarwal, Collomosse (2023). **TrustMark: Universal Watermarking + for Arbitrary Resolution Images.** arXiv:2311.18297. + https://arxiv.org/abs/2311.18297 + +18. Dathathri et al. (2024). **Scalable watermarking for identifying large + language model outputs.** Nature 634, 818-823. + https://www.nature.com/articles/s41586-024-08025-4 + +19. DeepMind. **US12094474B1** and continuation **US20250149048A1**. + https://patents.google.com/patent/US12094474B1/en + +20. Weatherbed (2026-04-14). **Has Google's AI watermarking system been + reverse-engineered?** The Verge. + https://www.theverge.com/ai-artificial-intelligence/911579/google-synthid-ai-watermarking-system-reverse-engineered + +21. Cox, Kilian, Leighton, Shamoon (1997). **Secure spread spectrum + watermarking for multimedia.** IEEE Trans. Image Process. 6(12). + +22. Wen, Kirchenbauer, Geiping, Goldstein (2023). **Tree-Ring Watermarks.** + NeurIPS 2023, arXiv:2305.20030. https://arxiv.org/abs/2305.20030 + +23. Ojha, Li, Lee (2023). **Towards Universal Fake Image Detectors that + Generalize Across Generative Models.** CVPR 2023, arXiv:2302.10174. + https://arxiv.org/abs/2302.10174 + +24. Corvi, Cozzolino, Poggi, Nagano, Verdoliva (2023). **Intriguing + properties of synthetic images.** CVPRW 2023, arXiv:2304.06408. + https://arxiv.org/abs/2304.06408 + +25. Zhong, Xu, Zou (2026). **Color Matters: Demosaicing-Guided Color + Correlation Training.** arXiv:2601.22778. + https://arxiv.org/abs/2601.22778 + +26. Baluja (2017). **Hiding Images in Plain Sight: Deep Steganography.** + NeurIPS 2017. + +27. Holub, Fridrich, Denemark (2014). **Universal distortion function for + steganography in an arbitrary domain.** EURASIP J. Information Security. + +28. Jing et al. (2021). **HiNet: Deep Image Hiding by Invertible Network.** + ICCV 2021. + +29. Yang et al. (2024). **Gaussian Shading: Provable Performance-Lossless + Image Watermarking for Diffusion Models.** CVPR 2024, arXiv:2404.04956. + https://arxiv.org/abs/2404.04956 + +30. Farid (2009). **Exposing digital forgeries from JPEG ghosts.** IEEE + Trans. Information Forensics and Security 4(1). + +31. Wang et al. (2023). **DIRE for Diffusion-Generated Image Detection.** + ICCV 2023, arXiv:2303.09295. https://arxiv.org/abs/2303.09295 + +32. Wang, Wang, Zhang, Owens, Efros (2020). **CNN-generated images are + surprisingly easy to spot... for now.** CVPR 2020, arXiv:1912.11035. + https://arxiv.org/abs/1912.11035 + +33. Ó Ruanaidh and Pun (1998). **Rotation, scale and translation invariant + spread spectrum digital image watermarking.** Signal Processing 66(3). + +34. Réfrégier and Javidi (1995). **Optical image encryption based on input + plane and Fourier plane random encoding.** Optics Letters 20(7). diff --git a/docs/watermarking-landscape.md b/docs/watermarking-landscape.md index e959a84..40be6a0 100644 --- a/docs/watermarking-landscape.md +++ b/docs/watermarking-landscape.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 0847d90..88588b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 diff --git a/scripts/synthid_adaptive_carrier_suppress.py b/scripts/synthid_adaptive_carrier_suppress.py index 10b8626..8eae6a6 100644 --- a/scripts/synthid_adaptive_carrier_suppress.py +++ b/scripts/synthid_adaptive_carrier_suppress.py @@ -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 diff --git a/scripts/synthid_affine_lattice_probe.py b/scripts/synthid_affine_lattice_probe.py index f16738b..683649c 100644 --- a/scripts/synthid_affine_lattice_probe.py +++ b/scripts/synthid_affine_lattice_probe.py @@ -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 diff --git a/scripts/synthid_periodic_tile.py b/scripts/synthid_periodic_tile.py index 147d922..49b994b 100644 --- a/scripts/synthid_periodic_tile.py +++ b/scripts/synthid_periodic_tile.py @@ -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"] diff --git a/scripts/synthid_routed_expert_bank.py b/scripts/synthid_routed_expert_bank.py index d146426..d7c4f2a 100644 --- a/scripts/synthid_routed_expert_bank.py +++ b/scripts/synthid_routed_expert_bank.py @@ -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__) diff --git a/scripts/synthid_runtime/__init__.py b/scripts/synthid_runtime/__init__.py new file mode 100644 index 0000000..5b14168 --- /dev/null +++ b/scripts/synthid_runtime/__init__.py @@ -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"] diff --git a/scripts/synthid_runtime/_synthid_confirmation.py b/scripts/synthid_runtime/_synthid_confirmation.py new file mode 100644 index 0000000..a0e159b --- /dev/null +++ b/scripts/synthid_runtime/_synthid_confirmation.py @@ -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, + ) diff --git a/scripts/synthid_runtime/_synthid_registered.py b/scripts/synthid_runtime/_synthid_registered.py new file mode 100644 index 0000000..1d02a12 --- /dev/null +++ b/scripts/synthid_runtime/_synthid_registered.py @@ -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 diff --git a/scripts/synthid_runtime/synthid_detector.py b/scripts/synthid_runtime/synthid_detector.py new file mode 100644 index 0000000..579687f --- /dev/null +++ b/scripts/synthid_runtime/synthid_detector.py @@ -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", + ) diff --git a/scripts/synthid_runtime/synthid_periodic_tile_2048_v1.npz b/scripts/synthid_runtime/synthid_periodic_tile_2048_v1.npz new file mode 100644 index 0000000000000000000000000000000000000000..33bbf4cb6817e65edc72c919065b278abdaca6b7 GIT binary patch literal 7501 zcmchcWl&t(nudWO!JP#6;2r`&1C55@78+^XT^nf#!Gc3@4epv?jZ4tr5ZtYC4Q>N< zPiF2pXXe)Ym^*9N-fQow_3rnrZ+*Ykqa^nPkqr(G?&-r#4L9sK6Mg^3z=I=zGqrQH zF>nUCnm9UH*x9n%+PlNU;lTMntbH6(|Cr!XcAssN;`H_?bt#pme+H)NVViTWz0mXhs!*i4CXHq~4d1gjOl!I_dk}QB$jYa^^E`^MY45##`B$yH| z48N10JtQ$Vu`n}t{2DP#+SwSr|K;|9=Kx7WZ-gT>|Wf0L~WHCZKvxQQZ!!Lx6x26Lw7-ji`;fxe#MaKj$pqwNVP<3SdsjaF(U8*rx-m^N|F?~a z%z2@Y{g=*^4e}5Eodn~t9ws*S)&|Zdze9X@{ttJvXaAVf;?G41BhI@|^CW@H*)oL^ z^t3!=Cg@CRGQ>W_J_{;5@-U#ptY z_)aI;OI_(SRYh4}-7hWErY5+!_o46gw@v(tu84GBL(JZHOA9;OP@$sB$q8o{+kWWI z)s)DEs@;GyzS?v&8(&l~LRyLZ9Et<4nzx4DDSd_JJ!f}Bx14yDVAI1G0DS?dMK$H$ zHY*h;So_UPTV(C+8RKoD~jq}Tn-7hxbCW&Uqc^s83WY+Q)^ z3C}IW?>bt0-41jHE}k-uW(qSM4EfjCSv3nJceY=wM_)!$CJocYWVDD2qJmu4i7lF6 zh%NXyMZ{^7aZadi$W@KXGq&&u!mPE?eBOSihi$jFRxWVeuX)iq8soXF4J3hw^5ox` zW4BJ$4x!}vNQsF-;WCeW@%vIeRgO=bn7Pz9TY zV^!BYOWmj`tmivd!r9t4JMty>CtvB0lM=3r0$IbXDX?y{fQZ$WI!v@A#5fHy4#slv zZ?YP;E_{ zI~-h*2U;%J{ER>PO-G_{B4_o$qpUZ2)`8Qwqk4EKZ)xDxP?S)!;KI^&G~#O}k!*dp z)CL061vejCHw+HdU&FP?9TB`VU%fGe(rhf=YwaD6UP@p|)_;~8pbpqv>s=22$$B9& zq3KaXMUtl@tnC*`(4PHsTX02HnDem0<5bCuTrch$q4s-d_&u*(9J^p|SBY|Yw2eQ; zMUBUp^uP+E43Uri!fq#Gwtd0}MKfCE>4x*qMk!C;y}EYC48V;R-Wb=%OZd1}LHi`O zS8cPWQL$k=J>b=DPweT(pC8-+AG;zy8)z}8E?IkHTE}Su*&5gUppJrR;gZM0rb>rf z77nVLtQHvSEp2d^U#>N+s#vJbk}SKbSco+m(Df?nO)xdWL!q-R+;1*-m^sLo33BEt z(`IU$a2s^O_AX>TEwf1G7k5}5%6h1M(Xl1^HPDi9w?_E{6Q-AZNysh6&#vnzDO&5P|Gnp+M!21LQ3ENv`HOYtEgVJnJ-t4x?#P1r>Q-s_s+Y90Rc@ z>%?(<`=c&YWk24YYAbdDG-H8TtRajYS)cYNp{ZE5s)(jF4b!w^l+{+-a$HfFa z5mgsEcAJLYqn+q0D}5-QrXmrI;6wE&DgSPe4(sbM<_;m#;Q#zgEO4V$!^0cOSHF%{ zViEHR$uC*OCHuT{Q~gXO)QpV~NysxLqY05CG*T;lVz6zxvy9YpKhSTgHM!^xQ|+cf z>9Vk&o9rbmsMssCzw4%#hn79^^YSMTtKsW-XD>GPg_R`*X$bghDIoUK&X6`2M&eeY z3Szl1w-Gh(slrXl0AdNpg1w<7EulnY5p(dwPv6G-2vr+CEhQ1>kx?Zs)b>|H;ZbfQ zT#8=%n(n`l#>vWs!7?0#{gdqb6|6Gtn@(5tk%(vTFn0H4yN!s77Y(PaJ7;8dX$d+) zv&M*&V5~Vv&#G_t4+hrp!BJmn*{voBfZ#4s*NNkOE2+joklhGuE}dbN=bFPo7J(y)hV|CDi^c)4ZTW& zDIS=6uPZImgac^$;6#8(hy zzE0s(VZYN9nW;;sFXzE^cyrw(*UcFOk-dF;6rb-f7>lC}8p@1)*HR}FXfTPMY9{an zRV39hL>4v-QwC5b5PGC;oJ7Upn%6_?pJUqy)@#%B4(~k=B3xYvF9Dl z%NI6SK5q$~DcO)|91x?l9?KT*_M;`V26&p=Cwwz!lSeL;CWn;|=zf_0NO-A!6FuBE zF7GP56FgHxGS@=h+cS5PB|X;sHmw4*9(RvGX!X6(z)1u%@<;DH>oRzu`$L(XXnNBUbZi? zE?a50*85)SBQ4Ck!CQc-*9pYNi=LicrS<`@CcDGppeUz?MPlk+7BQ@$7|6ocU^&Z} zHrNcPccF}?=hJpfc<-_Fx3+b~K|j5U=G+@*6A1H0n(p;vZg7PE3B++nxWnOXJ^i(t zGuMP({Ro3kS~zblJ7Hwb%VqG-pP<>%AHy0ddv6>#$i4d&k8evtkvvlRS72W-sOnIA zYdHK@DJ?|O$YRVZu&9n>_xsx2lqH{`VdbnC)Vi3=o<%Jc-FDMXc)x7qp-|B?>JXf0-sqkoqh2WsWW}mGk(Q>-{KFW_!C*=6)yS zlVZY;(cG1F3PD3s3Y>VLSE9)nqjG)$n|DR>FDx#Syuw%BmIp2H9f2Zh2--VaRMCUL zcJOl3nSHz*2(;^w3iqeXt;tKT3;SK4S@Yl0`P#+8Cz;GNjrvR<27EialFHXvN}s!p zs23nVRdi|I@pQ7*`3S77uUu9^E zXpW(V1<5cl((LM(5&C`z*bUZxE)avprF8 z^TG5WU0dKHu@rB>AM2Vlep?~cqeR0XjH#ejzmb-9^kJluL#&v6r1hIlV)ko6q)YWW z6t+~n<+9m&V4)uA4V$1u2t}@#MtgIG@tw!hc@cRv686BPV?m-xQQX!neIb3zc;DMF zh9kjHz_8VQ>0&OK7H6N82D=9r9JUS%tNa`>vR+cgu|k1{B+TN`T9QjHdXGGRs_TGHE!Ewbk^m%OZ>dLZW&^Ml)s>i*SKEqmF5uRZ$UVK zcHnt;*>}L2pHGNtmf!!n4nzbXI8ezBPvY25XlB|u5B3l7_9<+`-mA10;%QucjhTIe zoDSK;Xj^pj%r6hiU!6_!5vfXN6pQ|17F+6GrmyIHRJ9I=`J_n3#w~QuSJkd*IDDT5 zW4?fl((4uO6NXtde)?nI#o;QgVJ&i<6G+VKF}ye4=OU%u5}pK$JF1SSlFZg>yziHm z+yJu~8M6USg{{-b<8?_Y!wgezD$Xk;{9=+R=}Gv^TEfGkNBezDN%* zVfnbvg5(92zqF74>8dPKL||IhrhT+PqoeETMPHEcsf=b#o#CxTU3RTrqKek1a<>yA z96B57wc?!38d>Iron?9c&!h-|1D2>eppL`T@K+J# zY3X;90HGuO@~Q}aI8>)f~$IfD^w9I_zZs`qNa&~XKzH| zN=L)lmNjH6PFK&UQ#>Z??s;{orhUmckPXU7`7%KQ0rW*KK*~ZSD1d@Q2iqOSx`yZH zr`Cyb_MLM|$7FqGIn_@a;O{PQF%~UTr{J+B;lJ7mIXK@QsZ1BF@StgWy3omWS7s&u zgspk)S7)B(<_b(?^vrMb~7j*P+qjy3xxiL(b+DZUu3VkB;x}oArgd_@oQV z^7Owz&Xz}3ol{FR3q%`s{iCHB2*}Dse+!&4oN%_k_nFrJJbp`+%Zo&m zBN8zm!-z0)AL4Z?yu#IbwG~`mOqNkK?Xo>3hqrL9$ss>vLKRWXb4zihf8JiWQ48z@ z@Aa*B7k02#eGva93lwf9gBjiwnPgpa&`R<~dc7wdriJ=#VefX7X{_xc^t?TrwWy9k zayxN3gJ2f;TU#(g>w^%G1FH)iEgTNFt8|A0)(w|1XVU8q2T0j^OBS^mia2lj@p-d% zr3f}>Zxm=z)?{(jO!$zpTzMocEYJzhj0tDTH6tJhYlx&@@KH8nl zu76p52bNBnymk~soj)6F9FH=WXlqN0)x-RL6g1yS+N_p+30Z+;x=b%=aqBN}6JWan zx6yD{0yq>tgvi4tPF_n8P8a+cgve5_r&FBg|Z!4hl%BtQysdVTNj@5(L-w zB6bDWTuKacZ;PYG{oad}vy^h>xI2o0=-9LS_zlzawa1DfLVOPswG0?YlHS8qn((wj*rG>|R_EIUGEEGO)R=Odd zly*2)Ba?MM9i_P|M?X6l_-TxIVP&ubF-2<+L-qtb)kkr_-VGrB)AxFy&dW&tSB^lHFZ&6bU*EyqT;=|C;d*s!!@<97e80*g=17bkuhZ=ltsgq#N6heJ6LMtXx6U3cTx(nosrUq zbp{yR$MF~JdkIOzze0-i>?KSGS|9q3d*Ib!)Nl%YiUv!?Y+4#_V){e?1<=u6 z8@dLC`JS3{_d83;-Pt@9|7@@md=k#L#9i5Wor#W~{L{NP`1xi==m#B{;3^i1Z5nXl zyU_}0G7p^B$04jTNz2CD|l%pCIM z(>$OsyY3$@SmuN))51dm2M*V*)OVz%(j3YTvmwKa%Hl-dIob2d2{SU_+d}B4i7|SC zG&8oAlJ?c^%eKBIG-SYo2tm+>SlN-}`g0r4x#0%aS-&$nevKw~eyScylp&o@7yHY; zr0!wRlq$xIYP%Et;kfr|k>2rTU+M8RbsaFFmrw$?ArNG11~0VNz*dhV46W_vYWTa( zI|^3W^f<02C4@a1WH~;Bz9U=kDy)y;wancSc&<1iK$h9-dyv=qdo#)XMTxTxnCgP> zt|3vEo|E1TZ7h5`B?$30Ib+Fe!<)843EQ5X3tTQFgHl#IAJ11F_+c(%=CtWE-E)q6 z#oNl)xIcV|`B^=2D)yH~3Pvuh8*v&(WG-0sa%V9k z3%qh70hF`fuToReOyaNw@1iuxcC!Hzb1xS)e27PDwYSbdAYW_7*7 z@&F`waiJqN?f!;8vKv#1LdH(ZWT=%%Y93w^PthhU|!h5(Ba zcwO`g8zhQpapqkby|u&j7zn=&pUBfOBSD z*;MD_D$+_R77Dul_Y>J7DLCA8y3J=@;{<@kM3NxbvJxvpvu!EI@kg8hyj9e0H8NUG zWMM;Y<&chLJYXDMK)$a0U4am^d$Ep=NGjEK zOOZCFmj6XMH=Uy4G_ub=s1Q7jl8$VQ_7lBnJU?r!!#=bA)LEv%O-kwf9dc^@S}M;^ zHMDz_KjWhjh1r7NhxCuQzyxe>V&rUM46=P7TE9=~f8zqV|0`Tj`J?gr)h{^#xCdka zkHGfi&tUBlmp!60yg%$7rw{Ot&%a}~zv>@x*l#`BqvVhA0Y2p6?Z2SVztW-p7Cp=# z;4vPY|C8eURrbhye%oRHNf!Pf`wNKrS7NL`i3t$?5dV+HkC^9?-aKv%?w_>D58A&$ zpTAl^vYAKj^2lH?{v@Y+@?iZ>q~@>6$A)?AgTEX1lED4FeU#*okRSj5^x;~1D2XQN H 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 diff --git a/src/remove_ai_watermarks/cli.py b/src/remove_ai_watermarks/cli.py index 8420950..25eaebf 100644 --- a/src/remove_ai_watermarks/cli.py +++ b/src/remove_ai_watermarks/cli.py @@ -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}" diff --git a/src/remove_ai_watermarks/identify.py b/src/remove_ai_watermarks/identify.py index 2fe5916..12195db 100644 --- a/src/remove_ai_watermarks/identify.py +++ b/src/remove_ai_watermarks/identify.py @@ -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. diff --git a/tests/test_api.py b/tests/test_api.py index b3a2bb4..691324c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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 diff --git a/tests/test_cli.py b/tests/test_cli.py index 0e36137..c5bce23 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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: diff --git a/tests/test_identify.py b/tests/test_identify.py index 8d76268..02b894f 100644 --- a/tests/test_identify.py +++ b/tests/test_identify.py @@ -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): diff --git a/tests/test_synthid_affine_lattice_probe.py b/tests/test_synthid_affine_lattice_probe.py index 37edad6..397be0e 100644 --- a/tests/test_synthid_affine_lattice_probe.py +++ b/tests/test_synthid_affine_lattice_probe.py @@ -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: diff --git a/tests/test_synthid_confirmation.py b/tests/test_synthid_confirmation.py index f15b641..70fd7c4 100644 --- a/tests/test_synthid_confirmation.py +++ b/tests/test_synthid_confirmation.py @@ -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, ) diff --git a/tests/test_synthid_detector.py b/tests/test_synthid_detector.py index 2d79b91..f547215 100644 --- a/tests/test_synthid_detector.py +++ b/tests/test_synthid_detector.py @@ -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)