diff --git a/.claude/rules/development.md b/.claude/rules/development.md index c2a95aa..034acb6 100644 --- a/.claude/rules/development.md +++ b/.claude/rules/development.md @@ -79,6 +79,10 @@ 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. 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/.env.example b/.env.example index 3f7e0e8..8cd9964 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,7 @@ # HuggingFace token (optional; only needed for gated/private models) # Get yours at: https://huggingface.co/settings/tokens # HF_TOKEN= + +# OpenAI API key (optional; only for explicit verify-openai-synthid uploads) +# Create and manage keys at: https://platform.openai.com/api-keys +# OPENAI_API_KEY= diff --git a/README.md b/README.md index 49ff821..398df61 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ removal. | --- | --- | --- | | Find provenance signals and watermarks | `identify` | No | | Detect the SynthID pixel carrier in the calibrated image-size range | `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 | | Strip AI metadata | `metadata` | No | @@ -50,6 +51,7 @@ removal. | --- | --- | | 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]` | | Video SynthID removal | `remove-ai-watermarks[video,diffusion]` | @@ -86,14 +88,35 @@ remove-ai-watermarks detect-synthid resized.png --register-scale This detector is positive-only and limited to one measured carrier family in the [calibrated image-size range](docs/synthid.md#32-how-our-tool-detects-the-supported-carrier). -The fast default expects the recovered carrier at its measured 16-pixel -sampling scale. `--register-scale` opts into a much slower bounded scale search +The native default uses the fixed fold through 10 megapixels and a separately +challenged opponent-color large-image branch above 10 through 18 megapixels; +the large branch requires both sides to be at least 2,048 pixels. Both expect +the recovered carrier at its measured 16-pixel sampling scale. +`--register-scale` opts into a much slower bounded scale search for resized images from 250,000 through 10,000,000 decoded pixels, with both sides at least 64 pixels. Its measured positive scale range is approximately 0.65 through 1.5; 0.5x resizes remain outside reliable detection. `identify` keeps the fast default. `not_detected` or `unsupported` is not a clean-image guarantee. +The large native branch is not recompression-robust: all seven official large +positives fell below its frozen threshold after same-size JPEG-95 and JPEG-90 +re-encoding. Use it 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: + +```bash +uv tool install --force "remove-ai-watermarks[verify]" +remove-ai-watermarks verify-openai-synthid image.png --acknowledge-upload +``` + +The command removes AI provenance metadata from a temporary copy, verifies that +the decoded pixels are unchanged, uploads only that copy to OpenAI, and consumes +only the independent SynthID response. It never runs implicitly from `identify`. +An API key, endpoint access, and explicit upload acknowledgement are required. + For visible watermark removal, install the pixel dependencies: ```bash @@ -356,6 +379,9 @@ 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) + provenance = raiw.identify_video("input.mp4") report = raiw.inspect_video_metadata("input.mp4") complete = raiw.remove_video_all("input.mp4", "clean.mp4") diff --git a/docs/cli.md b/docs/cli.md index bafee3b..b00250b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -15,6 +15,7 @@ defaults. This page focuses on choosing the right command. | --- | --- | | `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]` | | Adobe TrustMark signals in `identify` | `remove-ai-watermarks[trustmark]` | @@ -70,17 +71,48 @@ remove-ai-watermarks detect-synthid resized.png --register-scale The command returns one of `detected`, `not_detected`, or `unsupported`. The runtime detector covers one frozen periodic carrier family in the [calibrated image-size range](synthid.md#32-how-our-tool-detects-the-supported-carrier) -and needs the `pixels` extra. The default never resizes the input and does not +and needs the `pixels` extra. The native default uses the fixed fold from +1,000,000 through 10,000,000 decoded pixels and the separately challenged +opponent-color large branch above 10,000,000 through 18,000,000 pixels when +both sides are at least 2,048 pixels. It never resizes the input and does not register a carrier whose sampling period changed through spatial resampling. `--register-scale` enables a substantially slower bounded search over measured carrier periods for images from 250,000 through 10,000,000 decoded pixels, with both sides at least 64 pixels. It is opt-in and is not used by `identify`. The measured positive scale range is approximately 0.65 through 1.5; 0.5x resizes are not reliably detected. +The native large branch is also codec-sensitive: same-size JPEG-95 and JPEG-90 +re-encoding reduced its seven official large positives from 7/7 to 0/7. A miss +on a lossy re-encode is therefore inconclusive. It is positive-only: `not_detected` 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. +## Verify OpenAI SynthID from pixels + +```bash +uv tool install --force "remove-ai-watermarks[verify]" +remove-ai-watermarks verify-openai-synthid image.png --acknowledge-upload +remove-ai-watermarks verify-openai-synthid image.png --acknowledge-upload --json +``` + +This is an explicit remote check against OpenAI's official Content Provenance +API, not the incomplete local OpenAI carrier research model. Before upload, the +command writes a temporary copy with AI provenance metadata removed and aborts +unless the decoded RGBA pixels are identical to the source. It then reads only +the API's independent `synthid` entry; a C2PA-only response cannot become a +SynthID detection. The source is never modified. + +The API supports PNG, JPEG, and WebP files up to 50 MiB. The command requires +`OPENAI_API_KEY` and an organization with endpoint access. Because the sanitized +raster is uploaded to OpenAI and the endpoint is not eligible for Zero Data +Retention, `--acknowledge-upload` is mandatory. This command is never called by +`identify`. `not_detected` means only that OpenAI's verifier did not recognize a +supported watermark in this file; it is not proof of human authorship. + +The Python API enforces the same boundary with the required explicit intent +flag `verify_openai_synthid(path, acknowledge_upload=True)`. + ## Remove known visible marks Install `remove-ai-watermarks[visible]` before using `visible` or `erase`. diff --git a/docs/installation.md b/docs/installation.md index 1669e38..e93da44 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -100,6 +100,7 @@ application actually uses: | `video` | Visible video identification/removal and timestamp preservation | `visible`, PyAV | No | | `detect` | Open DWT-DCT detection for Stable Diffusion, SDXL, and FLUX | `pixels`, PyWavelets | No | | `trustmark` | Adobe TrustMark detection | trustmark | Yes | +| `verify` | Official remote OpenAI SynthID verification | OpenAI SDK | No | | `diffusion` | Torch and Diffusers runtime; video SynthID regeneration | `pixels`, Torch, Diffusers | Yes | | `migan` | MI-GAN ONNX fill backend | `visible`, ONNX Runtime | Model download, no Torch | | `lama` | big-LaMa ONNX fill backend | `visible`, ONNX Runtime | Model download, no Torch | @@ -120,9 +121,10 @@ flowchart LR qwen["qwen-zimage"] --> diffusion heif trustmark + verify ``` -`heif` and `trustmark` are independent branches. Combine them explicitly with +`heif`, `trustmark`, and `verify` are independent branches. Combine them explicitly with another feature when required. The `all` bundle contains every production branch but never includes `dev`. @@ -141,6 +143,9 @@ uv tool install --force "remove-ai-watermarks[video]" # DWT-DCT and TrustMark detection without diffusion removal uv tool install --force "remove-ai-watermarks[detect,trustmark]" +# Official OpenAI SynthID verification +uv tool install --force "remove-ai-watermarks[verify]" + # Every production capability uv tool install --force "remove-ai-watermarks[all]" @@ -153,6 +158,13 @@ do not install libheif. `detect` uses the in-tree torch-free decoder and does not install the upstream `invisible-watermark` package. Optional models download their weights on first use. +The `verify` extra makes an explicit remote request. The +`verify-openai-synthid` command first removes AI provenance metadata from a +temporary copy, checks that its decoded pixels are unchanged, and then uploads +that copy to OpenAI. It needs `OPENAI_API_KEY`; the command never runs from +`identify` and refuses to upload without `--acknowledge-upload`. The Python API +requires the equivalent explicit `acknowledge_upload=True` argument. + The old `gpu` and `remove` aliases are intentionally not provided. Use `diffusion` and `visible` respectively. diff --git a/docs/module-internals.md b/docs/module-internals.md index 8fca511..47a0f90 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -466,11 +466,11 @@ without resize. Channels are filtered and folded sequentially, and partial edge blocks are accumulated without a full-frame padding buffer so the 18-megapixel ceiling does not require multiple three-channel float workspaces. The model hash is pinned by a test, and the unchanged operating threshold is -`0.17357069773071196`. +`0.17357069773071196` through 10 megapixels. The direct API returns `detected`, `not_detected`, or `unsupported`; the last is -distinct because no resize is performed. Support is based on a calibrated range -of 1,000,000 through 18,000,000 decoded pixels. The frozen threshold accepted +distinct because no resize is performed. The fixed branch is selected from +1,000,000 through 10,000,000 decoded pixels. The frozen threshold accepted none of 5,000 public COCO views balanced across every observed target geometry, and none of a separate 5,000-view challenge over 256 generated geometries covering every pair of modulo-16 edge remainders. The original 2048x2048 @@ -478,6 +478,35 @@ verdicts and exact scores remain unchanged. Runtime matches do not attribute a provider. `identify` adds only positive matches as high-confidence evidence and never turns a local negative into a clean verdict. +The native default selects `synthid-periodic-tile-large-v1` above 10 through 18 +megapixels when both dimensions are at least 2,048 pixels. It evaluates all +phase-aligned 2,048-square windows and combines the minimum fixed-template, +Red-minus-Green, and Blue-minus-Yellow spatial correlations with the most +negative Blue-minus-Yellow mid-band correlation. The 3072x5504 portrait +geometry also applies a Green mid-band alias veto. Each component is normalized +to its frozen gate and the public threshold is `1.0`. + +All 37 inferred large candidates cross the rule, and all seven metadata-free, +pixel-identical candidates checked by the official Gemini verifier were +detected. The constants rejected all 17,417 exposed external controls. A +post-freeze production-path challenge then rejected all 2,637 decoded-pixel- +unique controls drawn from 2,000 COCO images excluded from the earlier large +color-phase challenge and 637 deduplicated Picsum controls. Four large +geometries and four resampling kernels were balanced; the maximum score was +`0.0592777965`. The source collections were not freshly acquired, so this is a +feature-unseen holdout rather than a fresh-source estimate. + +A separate post-freeze Open Images download yielded 41 completed, +decoded-pixel-unique controls after excluding incomplete `.aria2` files and all +prior Open Images hashes. The frozen production path accepted 0/41 and reached +a maximum score of `0.4083013324`. This source-fresh audit is too small to +replace the main holdout interval but checks the acquisition boundary. + +The same seven official positives were then re-encoded at unchanged dimensions. +JPEG-95 and JPEG-90 each reduced detection from 7/7 native files to 0/7. The +large operating point is therefore native-pixel and lossless-copy support, not +a codec-robust claim. + Arbitrary geometry is not the same as arbitrary spatial resampling. On a stratified 80-image fixed-positive sample, one-step resizes at seven nonidentity scales from 0.5 through 1.5 reduced the unchanged 16x16 detector from 80 accepted @@ -521,12 +550,142 @@ retained 229 of 355 source-disjoint transformed positives: 0/65 at scale 0.5 and 229/290 from scale 0.65 through 1.5. The explicit period-8 rescue is rejected because resize lattices fully overlap its positive distribution. +A subsequently frozen 1,000-image Open Images reserve accepted zero in +registered mode. The fixed expert supported only 81 of those geometries and +accepted seven, so fixed and registered results cannot safely be unioned. In +overlapping geometry the registered decision remains the validated path; +fixed-only evidence is a diagnostic rather than a universal-cascade positive. + +The research-only router in `scripts/synthid_routed_expert_bank.py` encodes that +precedence and always abstains on fixed-only evidence. Its three-observation +schema keeps the fixed, registered, and large identities explicit. Registered +and large crossings are positive routes only in their disjoint calibrated +ranges; the bank never returns a clean-image verdict. + +An unchanged registered challenge from 10 to 18 megapixels retained only 1 of +37 Google candidates and zero of 89 non-Google controls. Twenty-eight positives +cleared amplitude, 21 had matching spatial and spectral periods, but only three +cleared high-band agreement. The 10-megapixel ceiling therefore remains. +Phase-aligned 2,048-square fixed windows did not provide a fallback: median +consensus retained 36 positives and accepted 10 controls, while all-window +consensus retained 36 and accepted eight. +One frozen full-frame pre-resize to eight megapixels also retained only the same +1 of 37 positives and zero controls; just three positives cleared high-band +agreement. Large images therefore cannot be routed through a canonical-size +registered fallback; the later native opponent-color branch is separate. +The remaining phase-aligned window variants closed this branch: a single center +2,048-square registered window retained 1 of 37 positives and zero of 89 +controls, while accepting any phase-aligned 2,048-square window retained 2 of +37 and zero controls. The latter control maximum was already 0.968 against the +1.0 decision threshold. Neither the coverage nor the exposed specificity +margin supports a registered-window expert; these results do not apply to the +later native opponent-color branch. + +A separate half-scale patch-consensus branch initially looked promising. Its +64-pixel, 90th-percentile patch statistic retained 33 of 49 validation positives +and zero of 166 controls, then 27 of 52 locked-test positives and zero of 140 +controls. The frozen broad challenge rejected it: 61 of 10,906 symmetrically +half-scaled Spaces controls and 22 of 1,000 independent Open Images controls +crossed the unchanged threshold. A post-challenge Green negative-phase spread +gate removed those errors but retained only 2 of 234 later Google rows and zero +of 689 later controls. Half-scale support therefore remains explicitly absent. + +An independent reproduction of the public `aloshdenny/reverse-SynthID` V4 +cross-color codebook did not provide another expert. Its best-of-two-model phase +rule retained 141 of 355 positives but accepted 191 of 499 controls and 386 of +a fresh 1,000-control reserve; AUC was 0.517. The repository's stricter +aspect-ratio routing reduced coverage but preserved the same approximately 38% +positive and control acceptance within supported rows. Its older V3 phase score +had AUC 0.473, and two prespecified amplitude-aware V4 rerankings reached only +0.521 and 0.524 AUC. These external rules are research baselines only. Their useful +contribution is the solid-reference phase-consensus construction, not either +published single-image threshold. Full methodology and the DALL-E reference-set +confound are recorded in the detector research plan. +A direct exact-1024 reproduction of the same V4 artifact confirmed rather than +rescued that verdict: the union of its two published profiles accepted 177 of +443 Google rows and 60 of 162 controls. The artifact's floor left only about two +active bins in the inspected profile/channel, explaining why its score acts +as a weak generic phase-coherence test rather than a specific codebook match. + +An exact-1086x1448 OpenAI follow-up also rejected the solid-reference transfer. +A low-texture 256-bin phase codebook achieved native and JPEG-95 test AUCs of +0.512, and independent train halves shared only two exact bins. The paper's +content-dependent alternative was tested with 60 OKLab masking-alignment +features. Its native/JPEG minimum reached test AUC 0.593 and 15/562 positives +versus 1/228 controls at the frozen boundary, but deterministic half-fits shared +no positive decision and had coefficient cosine 0.148. Neither branch is a +runtime OpenAI expert. + +An exact-1254 assertion-contrast search selected an sRGB period-8 Blue-channel +direction at 45 of 259 validation asserted rows and zero of 16 same- or +other-provider validation rows. It failed its locked specificity check at 39 of +233 asserted rows and one of 18 controls. Across the complete exact-geometry +audit it accepted 185 of 992 asserted rows, one of 55 same-provider rows without +an assertion, and one of 13 other-provider rows. Raising the threshold above +the wider controls reduced recall to a small minority, so this branch remains +research-only. + +Three attempted veto or missing-codeword rescues also failed. The official +InvisMark decoder checkpoint passed its own embedded-watermark self-test at +0.997 confidence and 0.97 bit accuracy, but all OpenAI and provider-control +cohorts clustered around 0.18 confidence with no useful separation. DTCWT +modulus before periodic folding retained 5 of 158 locked-test OpenAI rows and +zero of 135 controls, then zero 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. They neither explain the known oracle-positive +miss nor justify runtime routing. + +The separate OpenAI period-8 DTCWT component is persistent rather than tied to +one short rollout: exact-generator asserted hits were 3/16, 46/365, and 26/200 +from May through July, with 75/581 overall versus 1/52 same-generator rows +without an assertion. Its native/JPEG minimum score reached 0.721 AUC between +those indeterminate strata. Sorting all 64 cyclic correlation scores removed +absolute phase but also removed locked-test discrimination at 0/158 positives +and 0/135 controls. This remains research evidence for a weak signed carrier, +not a runtime OpenAI detector. + +A four-family open-proxy challenge also failed to justify a generic neural +watermark expert. A fixed residual frontend and cross-family residual mixing +were trained on three of TrustMark P, VideoSeal, DWT-DCT, and WAM while the +fourth encoder and its test sources remained unseen. Held-out AUCs ranged from +0.437 to 0.562. Equal-power phase-scrambled hard negatives prevented simple +spectral-energy shortcuts, but did not produce architecture transfer. A +separate translation-invariant Gemini bicoherence search selected none of 20 +development positives and finished at 0/50 positives, 0/199 controls, and AUC +0.374. Neither branch is part of runtime routing; full split and oracle details +are in the detector research plan. + The separately measured geometry range remains 250,000 through 10,000,000 decoded pixels with both sides at least 64 pixels. The default path and -`identify` remain the native fold. A 20-image real-corpus drift check was +`identify` remain native-only and select either the fixed or large branch by +geometry; scale registration stays opt-in. A 20-image real-corpus drift check was byte-identical after integration. The calibration history and caveats are in the linked detector research plan. +### Official OpenAI SynthID verifier + +[`openai_provenance.py`](../src/remove_ai_watermarks/openai_provenance.py) +provides the explicit remote production backend exposed as +`verify-openai-synthid`. It is intentionally separate from `identify`, because +one invocation uploads a sanitized raster to OpenAI. The CLI requires +`--acknowledge-upload`, and the optional OpenAI SDK lives in the independent +`verify` extra. + +The backend accepts only PNG, JPEG, and WebP. It computes a decoded RGBA pixel +fingerprint, removes AI provenance metadata into a temporary file through +`metadata.strip_and_verify`, recomputes the fingerprint, and aborts before any +request if metadata survived, the format changed, the pixels changed, or the +sanitized file exceeds the endpoint's 50 MiB limit. It then sends exactly one +multipart file to `content_provenance_checks.create` and parses exactly one +`type == "synthid"` result. The independent C2PA entry is never returned or +used as fallback evidence. Missing, duplicate, or unknown SynthID outcomes are +errors rather than negative detections. + +The result remains provider-scoped and positive-evidence-only. `not_detected` +does not mean human-created, and the official endpoint's published prohibition +on repeated reverse-engineering or evasion queries prevents using this backend +as an adaptive training or removal oracle. + ### Portable metadata record [`metadata_record.py`](../src/remove_ai_watermarks/metadata_record.py) produces the diff --git a/docs/supported-signals.md b/docs/supported-signals.md index 7437a19..17faf00 100644 --- a/docs/supported-signals.md +++ b/docs/supported-signals.md @@ -132,8 +132,16 @@ calibrated image-size range, available through `detect-synthid` and the default pixel pass in `identify` when the `pixels` extra is installed. The unchanged fixed threshold accepted none of the public COCO views in both an observed-geometry challenge and a generated-geometry challenge covering all -modulo-16 edge cases. Arbitrary dimensions in the default calibrated range are -accepted, but the input must retain the measured 16-pixel carrier scale. The +modulo-16 edge cases. Above 10 through 18 megapixels, the native default 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. Arbitrary dimensions in +the default calibrated ranges are accepted, but the input must retain the +measured 16-pixel carrier scale. The large branch retained 0/7 official +positives after either JPEG-95 or JPEG-90 re-encoding, so its native-size scope +does not include lossy retranscodes. The opt-in `detect-synthid --register-scale` mode performs a slower bounded scale search over its separately measured 250,000-through-10,000,000-pixel range and requires both sides to be at least 64 pixels. Its measured positive scale range @@ -147,6 +155,14 @@ explicit `c2pa.watermarked.*` action. Legacy OpenAI C2PA without that action does not assert SynthID. A pixel result of `not_detected` or `unsupported` remains inconclusive for other sizes, epochs, codecs, and payloads. +The optional `verify-openai-synthid` command is a separate official remote +verifier for supported OpenAI watermarks. It strips AI provenance metadata from +a temporary PNG, JPEG, or WebP copy, proves that decoded RGBA pixels are +unchanged, and uses only the API's SynthID result. It is therefore independent +of C2PA for its decision, but it is not local: the sanitized raster is uploaded +to OpenAI after explicit acknowledgement. It is intentionally excluded from +`identify` and its negative result remains inconclusive. + For MP4, MOV, and M4V, `video invisible` or the explicit `video all --invisible` option can regenerate the video through a VAE and strip source metadata. The shipped profile is oracle-certified, but it is not a local @@ -166,7 +182,7 @@ not a universal clean verdict. | --- | --- | --- | --- | | Google Gemini | Sparkle | Local positive-only calibrated-size detector; 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 | Diffusion regeneration for supported invisible signals | C2PA and generator provenance | +| 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 | | FLUX | None registered | Diffusion regeneration; optional open decoder | C2PA for supported sources | | Adobe Firefly | None registered | No proprietary local decoder | C2PA; optional TrustMark decoder | diff --git a/docs/synthid-detector-removal-plan.md b/docs/synthid-detector-removal-plan.md index c945ac7..ba0e3c6 100644 --- a/docs/synthid-detector-removal-plan.md +++ b/docs/synthid-detector-removal-plan.md @@ -577,6 +577,60 @@ deduplicated positives, expand the negative set to at least the rule-of-three minimum, and add same-provider hard negatives before fitting or publishing an operating point. +### 2026-08-13: external V4 cross-color codebook audit + +The later `aloshdenny/reverse-SynthID` V4 artifact was audited independently at +commit `b110836`. The audit read only numeric NPY members from its NPZ ZIP with +pickle disabled and reproduced the published native-resolution phase score; +no third-party Python or pickle payload was executed. Its bundled +`sample_watermarked.png` crossed the documented `phase_match > 0.52` threshold +at `0.535`, confirming that the reproduction follows the intended path. + +The operating point did not transfer. Taking the better of the two published +model profiles detected 141 of 355 Google positives and 191 of 499 controls. +On a later frozen 1,000-image Open Images reserve it accepted 386 controls. The +phase score had AUC 0.517 on the paired corpus. Reproducing the July 2026 GUI's +0.3%-aspect-ratio gate reduced coverage without adding discrimination: it +supported 121 positives and detected 46, while supporting 343 controls and +detecting 132; it also detected 284 of 685 supported fresh controls. + +The failure follows directly from the metric. Per-bin phase similarity is +`1 - abs(wrapped_difference) / pi`, whose expectation under independent uniform +phase is 0.5, only 0.02 below the threshold. The detector selects bins by +cross-color phase coherence alone and does not require image-side carrier +amplitude or a content-baseline excess. Its selected bins span nearly the full +spectrum, with a typical spatial period around 2.5 pixels. Cross-color +coherence is useful for discovering candidates from genuinely low-texture +references, but this single-image score is not a calibrated detector. +Two prespecified rerankings did not rescue it. Restricting carrier periods to +4-128 pixels and ranking by either coherence times reference magnitude or the +stored carrier weight times reference magnitude produced best-of-model AUCs of +0.521 and 0.524. They were evaluated as continuous scores without selecting a +new test-set threshold and are also rejected. + +The repository's older V3 phases also failed this external challenge. Their +best dark/white phase score had AUC 0.473; the documented 0.78 phase threshold +accepted 5 of 355 positives, 5 of 499 controls, and 6 of the later 1,000 fresh +controls. The repository's current bundled sample and four validation sources +all remained below that phase threshold. The README's accuracy claim is +therefore not accepted as evidence for the current corpora. + +The linked Hugging Face dataset cannot repair OpenAI calibration as published. +Its DALL-E 3 `black` and `white` buckets are not solid-color probes: per-image +mean luminance ranged from 11.66 to 143.74 and 124.94 to 235.66 respectively. +Across ten 1024-square images per bucket, only 39 and 32 RGB frequency bins had +phase coherence above 0.9, versus 20,443 and 100,252 for the corresponding +Gemini 3.1 buckets. Only four DALL-E bins cleared 0.9 in both colors and all +four were trivial DC or self-conjugate-axis coordinates; Gemini had 11,478 +nontrivial joint bins and 3,341 also had cross-color absolute-cosine agreement +above 0.9. Those DALL-E folders measure heterogeneous content phase, not an +OpenAI solid-reference codebook. The official SynthID-Image paper also says +nearly uniform corner cases may be excluded from watermarking, so generating +flatter references does not guarantee a stronger watermark. A useful next +external experiment requires newly collected low-content OpenAI outputs whose +pixel watermark is separately confirmed, plus same-provider oracle-negative +controls, before freezing any phase bins. + ### 2026-08-09: independently fitted phase-carrier pilot An exact-geometry phase model was then fitted from four of our own @@ -1125,6 +1179,16 @@ sparse-phase result, the tile score evaluates every image without a weak carrier-support abstention. The challenge is still not native provider data and cannot replace the required oracle-negative calibration. +A later exact-1024 content audit found that perceptual siblings can cross an +image-level fit split even when file and decoded-pixel hashes differ. The local +2048 reports retain aggregate counts and model hashes but not the 111 fitting +paths, so the old train/validation/test split cannot now be audited for that +specific leakage mode. This does not invalidate the independently collected +general-image challenges or justify changing the frozen runtime model, but the +reported positive rates remain conditional on the historical split. Any next +model calibration must retain content-group membership and prove that no group +crosses train, validation, test, or temporal partitions. + Symmetric attack evaluation established the robustness boundary. The fixed tile accepted 29 of 30 original test positives, all 30 after a 75% downscale round trip, 21 after JPEG-95, three after JPEG-85, and none after a 5% crop, @@ -1176,6 +1240,88 @@ norm-matched controls before any removal claim. The reproducible local harness is `scripts/synthid_periodic_tile_ablation.py`; its report records input and model hashes without writing derivative images. +### 2026-08-12: fast adaptive carrier projection + +The fixed twice-median subtraction was intentionally conservative but spent +substantially more pixel norm than many images needed. A follow-up tested a +one-dimensional per-image solver. For source pixels `x`, frozen unit carrier +`t`, and scalar amplitude `a`, the candidate is +`clip(round(x - a * tile(t)))`. A bounded scalar search selects the smallest +`a` that reaches the local target. This is a direct pixel edit: it uses no +generative decoder, does not resize the image, and searches one scalar rather +than optimizing a full-resolution tensor. + +The first challenge reused the 30 historical 2048x2048 causal-ablation +sources. Three were not simultaneously positive under the fixed-tile and +independently fitted phase rules and were excluded before counting a reversal. +All 27 conjunctive positives cleared both local rules after adaptive +subtraction. Median PSNR was 59.83 dB and median SSIM was 0.99890; the minima +were 55.19 dB and 0.99705. Repeating the same selected amplitude with a +one-row, one-column cyclic shift cleared zero of 27. Sixteen original images +remained conjunctively positive after JPEG-95. The aligned candidates cleared +all 16 after the same round trip, while the shifted controls cleared one. This +improves materially on the earlier fixed-strength median of 53.74 dB without +weakening the local causal control. + +A three-image pilot also compared subtracting the complete per-image folded +high-pass residual and a regularized inverse-high-pass reconstruction. Both +could clear the two local scores, but their selected candidates retained only +about 54-57 dB PSNR, compared with 59-62 dB for the frozen carrier projection +on the same images. Removing the whole folded residual therefore spends pixel +norm on content-correlated periodic structure and was rejected in favor of the +narrow carrier direction. + +The geometry challenge then selected three `first_only` carrier positives from +each of the 20 most frequent native Spaces geometries. The edit repeats the +same 16x16 carrier past the image boundaries and crops the repetition to the +decoded dimensions, so it does not require dimensions divisible by 16. A zero +score target cleared 60 of 60 aligned candidates and 5 of 60 matched shifted +controls. Median PSNR and SSIM were 60.37 dB and 0.99919; the minima were +53.56 dB and 0.99581. These samples come from an already inspected detector +corpus, so they measure mechanism transfer across geometry rather than fresh +detector or removal generalization. + +The zero target left one WebP-95 and eight resize survivors among transformed +sources that were still locally positive. A frozen follow-up used a target +score of -0.25. It cleared all 60 native candidates at median 57.70 dB PSNR and +0.99863 SSIM, with minima of 52.38 dB and 0.99443. Conditional on the matching +transformed source remaining above the fixed carrier threshold, the aligned +candidate cleared 32 of 32 JPEG-95, 13 of 13 JPEG-90, 8 of 8 JPEG-85, 15 of 15 +WebP-95, and 54 of 54 0.75x resize round trips. The corresponding one-pixel +shifted controls cleared 7, 1, 1, 5, and 17. The increased shifted-control rate +is the measured price of adding transform margin. + +The frozen -0.25 recipe was finally run once on one deterministic first-carrier +positive from every represented native Spaces geometry. All 647 images at 647 +distinct decoded sizes reached the target, with no maximum-amplitude failure. +Median fidelity was 57.67 dB PSNR and 0.99867 SSIM; the worst case was 51.78 dB +and 0.99400. Of 323 source images whose carrier survived JPEG-95, 323 aligned +candidates and 58 shifted controls cleared after the same round trip. Natively, +153 of 647 shifted controls also cleared at this deeper margin. The consistent +aligned result supports geometry-independent control, while the increased +shifted rate shows that the conservative edit perturbs a wider neighborhood of +the periodic carrier. Because the all-geometry corpus had already influenced +detector research, it is a coverage challenge rather than a fresh holdout. + +This is a fast local carrier suppressor, not a verified SynthID remover. The +earlier Google oracle remained positive after analytical edits that already +looked favorable to local models, and the adaptive scalar recipe has not been +submitted to a matching-provider oracle. Provider terms and the project oracle +policy also prohibit turning verifier responses into an adaptive search loop. +The next valid test is one preregistered source/re-encode/aligned/shifted batch +at a healthy authorized oracle, with the amplitude rule and -0.25 target frozen +before submission. Until that batch returns a matching-provider negative result, +no production command exposes this method as removal. + +The reproducible implementation is +`scripts/synthid_adaptive_carrier_suppress.py`. It writes only lossless PNG, +refuses overwrite and locally negative input, and records source/output hashes, +the selected scalar amplitude, local scores, fidelity metrics, and runtime in a +sidecar JSON report. Unit tests cover non-divisible geometry, target attainment, +an unreachable target, and the negative-input guard. A real CLI smoke test on a +Spaces source reproduced its reported score exactly through the runtime detector +after saving. It remains a research script rather than a routed package command. + ### 2026-08-10: confirmatory oracle-batch readiness The corpus audit found no new 2048x2048 positive collected after the tile rule @@ -1424,6 +1570,369 @@ zero-control operating point retained a period-8 positive. The calibrated runtime therefore uses the top-candidate, period-bin, and high-band gates and makes no 0.5x detection claim. +An exploratory amplitude-times-high-band rescue later tested whether the two +existing gates could trade evidence instead of both passing independently. A +threshold frozen above all 341 top-1 development controls recovered 11 of the +126 current false negatives and accepted none of a separate 499-image Open +Images reserve; the reserve maximum was 0.100583 against the 0.118168 +threshold. The decisive symmetric challenge rejected it: 19 of all 11,506 +Spaces controls after 0.5x resizing crossed the frozen threshold, with a +maximum of 0.179989. The errors spanned OpenAI, Firefly, Microsoft, hosted +jobs, and other generators. Raising the threshold post hoc above that maximum +would recover only seven false negatives, none at scale 0.65 or 0.8. The joint +rescue is rejected; the independent amplitude and high-band gates remain the +measured operating point. + +### 2026-08-13: public bypass corpus and inverse-carrier audit + +The public `cebeuq/Synthid-Bypass` V2 comparison set supplied 12 external +before/after pairs produced by a diffusion reconstruction workflow. The fixed +runtime carrier accepted 10 of 12 `before` images and none of the 12 `after` +images. The two misses were not assumed negative: Gemini Verify AI reported +SynthID on `before/03.webp` and `before/07.png`, and no SynthID on both paired +outputs. Outputs `01` through `06` were also oracle-negative; the verifier's +daily limit stopped the same-session sweep at `08`. + +`07.png` is a low-SNR instance of the shipped positive direction. Its full-frame +score was `0.1248368819`; its signed RGB correlations were 0.073, 0.183, and +0.111, and all nine aligned region scores were positive. A proposed rescue at +`score >= 0.12` with Green dominance and spatial agreement separated it from +the two highest transformed-COCO controls, but failed a fresh exact-1024 Open +Images challenge. A 2009 natural photograph scored `0.2123730698`, had stronger +Green dominance and nine-region agreement, and Gemini returned `SynthID not +detected`. The downloaded bytes matched a fresh fetch from the official Open +Images validation bucket. This is a confirmed runtime false positive, not a +provider-label inference. + +The failure is broader than that one JPEG. Symmetrically resizing the 24 Kodak +PNG controls to 1536 by 2752 with Lanczos produced fixed scores as high as +`0.3768339306`. Those synthetic views were not used as independent clean +samples, but they prove that rational resize lattices can create a stronger +signed 16-by-16 match than the runtime threshold. The existing 5,000-view +observed-geometry and 5,000-view generated-geometry COCO challenges remain +valid for their exact source-and-transform assignments; they do not justify a +universal transformation claim. No threshold relaxation for `07` is allowed, +and the current direct detector needs a fresh source-and-transform-grouped +specificity epoch before its support range can be broadened. + +`03.webp` tested a different hypothesis. The two content-group-separated +alternate-Gemini experts both rejected it. Its shipped-template score was +instead `-0.3263447029`, all three channel scores were below `-0.28`, and every +one of nine aligned regions was negative. The paired oracle-negative output +weakened to `-0.1531420136`. Neither 5,000-view COCO geometry challenge nor the +fresh Open Images blocks contained a score at or below `-0.20`, making the +opposite-polarity response a plausible additional codeword or provider state. +It is not specific enough to ship: 40 of 11,506 heterogeneous non-Google-labeled +AI controls crossed `-0.20`, with examples from OpenAI, Microsoft, multiple +other generators, and unassigned made-with-AI files. An inverse-template expert +therefore remains a research hypothesis until matching-provider oracle labels +and a new untouched natural and AI control split exist. + +A follow-up decomposed the two high-frequency rings instead of taking their +minimum. The 19 joint-rule false positives were broad-band, with median +`min(ring) / max(ring)` of 0.84, while the registered false negatives had a +median of 0.27. A development-selected rescue therefore multiplied normalized +amplitude by the stronger ring and required ring balance at or below 0.5. A +threshold above 341 development controls recovered 11 of 65 top-1 false +negatives and accepted none of the 499-image Open Images reserve. The full +symmetric challenge again rejected it: 13 of 11,506 resized Spaces controls +passed, with a maximum score of 0.694818 against the 0.308683 threshold. Strong +single-ring lattices occurred in Firefly, OpenAI, hosted jobs, China-labeled +generators, Canva, and other controls. Radial imbalance is therefore another +general renderer or resize feature, not a SynthID-specific rescue. + +A multi-codeword hypothesis then projected the shared 16-by-16 template out of +each normalized residual tile and fitted source-disjoint spherical codebooks to +the remaining 768-dimensional vectors. Five prototypes selected on 300 +positives retained 366 of 600 held-out positives. Three prototypes were highly +reproducible across an independent 300-positive fit, with matched correlations +of 0.88 through 0.95, while the additional states were less stable. A threshold +above 2,500 development natural-image controls accepted none of a separate +2,500-image reserve and selected 225 of 770 metadata-labeled Google images that +the shared-template threshold missed. The broader challenge decisively rejected +the feature: 828 of 11,506 metadata-labeled non-Google Spaces controls crossed +the frozen threshold. Errors occurred across OpenAI, Microsoft, Firefly, +China-labeled generators, hosted jobs, Stable Diffusion, and other sources. A +post-hoc intersection above every control that also passed the registered +min(R, B) gate retained 138 Google candidates, but none passed that independent +carrier gate. The residual clusters are reproducible generator or resampling +structure, not a specific additional SynthID codebook, and are excluded from +runtime. + +A related spatial-payload pilot stopped averaging all 16-by-16 blocks and +measured carrier and codebook responses block by block. The positive carrier +response was spatially coherent: median adjacent-block correlation was 0.61 on +both axes, compared with 0.20 and 0.24 in a heterogeneous Spaces sample and +0.04 and 0.06 among codebook false positives. The development-selected rescue +was instead the standard deviation of the best codebook response across +blocks. It retained 11 of 60 reserve positives and none of 180 reserve controls, +but accepted six of 1,000 fresh heterogeneous controls. Those errors included +OpenAI, hosted, and China-labeled generators; raising the threshold above their +maximum retained only four of the 120 pilot positives. Per-block carrier or +codeword coherence is therefore diagnostic but not a usable rescue. + +A geometry-stratified follow-up found a second reproducible native 16-by-16 +carrier in the fixed-template misses. Templates fitted independently from two +halves of the 1024-by-1024 misses correlated at 0.869, and two independent fits +from the non-1024 misses correlated at 0.956. The latter correlated 0.82-0.84 +with the 1024 template but only about 0.28 with the shipped carrier. This rules +out a single-resolution averaging accident and supports a distinct carrier +direction or state. + +The initial 1024 template retained 8 of 91 held-out misses and accepted none of +81 exact-geometry reserve controls or 500 natural-image reserve controls. Seven +of those eight positives survived JPEG 95, six survived JPEG 85 and 70, and +three survived a 0.75x resize round trip; WebP 95 retained none. A threshold +frozen above the exact-geometry development controls accepted 25 of 11,506 +heterogeneous controls when applied at arbitrary native geometries, so an +unrestricted second native template is rejected. Canonicalizing every control +to 1024 by 1024 eliminated all 11,506 errors, and the same path accepted only +the native 1024-by-1024 Google misses. The signal has a fixed native period, +not a size-normalized frequency. + +A stricter chronological fit used only 1024-by-1024 misses through mid-June and +tested July misses. Its template correlated 0.839 with the independently fitted +July template, retained 5 of 95 temporal misses, and accepted none of 81 reserve +exact-geometry controls. At that frozen threshold, canonicalization accepted 18 +of all 770 fixed-template Google misses and none of all 11,506 heterogeneous +controls; every accepted miss was natively 1024 by 1024. Requiring agreement +between two independently fitted early templates retained three temporal +misses and no reserve controls. This is credible evidence for a second carrier +direction within the Google cohort, but the positive and exact-geometry control +counts are still too small for a runtime operating point. The later external +natural-image challenge below further shows that this repeatability is not yet +specific enough for blind detection. + +Projecting the shipped carrier out of every normalized tile did not remove the +second direction. The early orthogonal template was numerically orthogonal to the +shipped template, correlated 0.834 with an independently fitted July orthogonal +template, and retained the same 5 of 95 temporal misses with none of 81 reserve +exact-geometry controls. Its threshold was frozen above the development controls +before the decisive 1024-by-1024 canonical challenge; none of all 11,506 +heterogeneous controls crossed it, and the maximum remained just below the +frozen boundary. The second direction is therefore not a weak projection of the +shipped carrier. This strengthens the research finding but does not enlarge the +small temporal-positive or exact-geometry control reserves required for runtime. + +Mapping both frozen scores over all 4,698 Google-labeled images changed the +interpretation from an epoch replacement to coexisting carrier states. The map +contained 3,825 first-only, 69 second-only, 103 both, and 701 neither cases; +the two scores correlated at -0.563. Both directions occurred throughout the +collection interval and across multiple native geometries, while the presence +of both in 103 images rules out a simple dated switch from one template to the +other. The second native threshold remains exploratory outside exact +1024-by-1024 inputs because 8 of 11,506 heterogeneous native controls crossed +the orthogonal version's threshold; only the canonical exact-geometry challenge +retained the zero-of-11,506 result. + +Freezing a stricter native threshold above the maximum of 5,753 alternating +development controls left zero crossings in the other 5,753 controls and 26 +Google images missed by the first carrier above threshold. That retrospective +split is promising but not a certified operating point: the corpus had already +been inspected while developing the second carrier, so a fresh source- and +time-disjoint challenge is still required. + +An external natural-image challenge then rejected that strict native threshold. +It accepted none of 1,500 unique Open Images controls and none of 12 Wikimedia +controls, but one of 637 content-deduplicated Picsum controls. The original +Picsum directory contained 1,000 filenames but only 637 unique hashes; the one +crossing appeared twice under different ids and is counted once. Its RGB and +quadrant contributions were highly uneven, unlike the small temporal-positive +set, but that post-error observation cannot retroactively define a gate. The +strict native threshold remains excluded from runtime. + +The original exact-1024 threshold also failed on those external controls: four +of 1,500 Open Images and four of 637 unique Picsum images crossed it, including +native 1024-by-1024 cases. The earlier zero-of-11,506 canonical result was +therefore specific to that heterogeneous control composition, not evidence that +exact geometry alone makes the carrier specific. Requiring agreement between +the two independently fitted early templates did not close the gap; it retained +three temporal positives but accepted one Open Images and two unique Picsum +controls. The second direction remains a reproducible Google-cohort signal, not +a blind detector. + +Requiring the orthogonal score to survive a JPEG-95 round trip was not a +specificity gate either. It retained four of the five temporal positives but +also retained all four Open Images and all four unique Picsum crossings at the +original threshold. The natural-image confound is itself transport-stable. +Together with the failed low-rank, two-template, channel-balance, quadrant, and +two-carrier-plane variants, this exhausts mean-template consensus for the +second direction on the current data. + +The orthogonal carrier is not a cyclic shift or color permutation of the +shipped template. Its best absolute cyclic correlation was 0.212, Fourier +magnitude correlation was 0.480, and weighted phase lock was 0.200. Dominant +frequencies were axial, led by `(0, +/-8)`, `(0, +/-3)`, and `(0, +/-5)` rather +than the first direction's broader structure. A horizontal-axis-only ablation +retained all five temporal detections and no reserve exact-geometry control, but +then accepted three of the 11,506 canonicalized heterogeneous controls. The +errors included an OpenAI-labeled image and two watermark-remover outputs. The +full orthogonal template is more specific than its strongest axial component. + +A final low-rank variant tested whether that direction contains several payload +states rather than one mean carrier. SVD bases of ranks 1, 2, 3, 4, 6, 8, 12, +and 16 were fitted only on early 1024-by-1024 misses. Rank was selected on a +separate June interval and a separate control third, then reported on July +misses and the final control third. The selected rank 12 retained 2 of 38 +validation misses and 4 of 95 test misses, with no control error. Rank 2 happened +to retain 6 test misses but only one validation miss and therefore could not be +selected without post-test tuning. A multi-state subspace did not improve the +evidence-grade operating point. + +Deflation then tested whether a third stable linear carrier remained. After +projecting both the shipped and second orthogonal templates out of every +normalized tile, early and July residual averages correlated only 0.0003. The +early residual template retained none of 95 temporal misses and none of 81 +reserve exact-geometry controls. Within this 1024-by-1024 temporal corpus, the +linear periodic model therefore supports two reproducible carrier directions, +not an open-ended sequence of mean templates. + +Treating those directions as a single two-dimensional carrier plane did not +provide a safer weak-signal rescue. A positive-quadrant Euclidean norm used the +two independently frozen carrier thresholds as axis scales, then froze its own +boundary above 81 exact-geometry development controls. It rescued none of the +Google images below both component thresholds and accepted one of 81 reserve +controls. The two directions must retain separate operating points; combined +subthreshold energy is rejected. + +The five temporal positives above the original second-carrier threshold also +had unusually balanced normalized R, G, and B contributions: their worst +channel ranged from 0.297 to 0.320, while seven of eight native control errors +were below 0.297 and were commonly blue-heavy with a weak green contribution. +A clean chronological validation did not justify turning that observation into +a gate. A template fitted on half of the early positives produced no accepted +examples in the other early half at the threshold frozen from development +controls, despite accepting six July examples and no reserve control. The +channel-balance rule is therefore a post-test hypothesis, not detector logic. + +A phase-only follow-up removed observed Fourier magnitude and averaged phase +agreement using only the early template's fixed spectral-energy weights. The +phase threshold alone retained 2 of 31 early validation misses and 10 of 95 +July misses, but accepted one of 81 reserve exact-geometry controls. Requiring +both phase agreement and the independently calibrated linear projection +removed that control error and all external natural-image errors. The frozen +conjunction retained 1 of 31 early validation misses and 3 of 95 July misses, +accepted none of 162 heterogeneous exact-1024 controls, none of 1,500 unique +Open Images, none of 637 unique Picsum images, and none of 12 Wikimedia images. +Applied outside its declared geometry it accepted 3 of 11,506 heterogeneous +native controls, all at non-1024 sizes. This is the first independently +challenged phase-and-amplitude candidate for the second direction, but three +temporal positives are not enough to ship a runtime branch. + +Across all 443 exact-1024 Google images, the frozen conjunction found 11 that +the shipped carrier missed and 16 that both branches accepted; 244 were found +only by the shipped carrier and 172 by neither. The candidate therefore adds +signal rather than merely confirming the first direction, but its incremental +native recall is still small. + +Swapping the two early fit halves and recalibrating both thresholds retained 4 +of 31 opposite-half validation misses and 4 of 95 July misses, again with none +of 81 reserve exact-geometry controls passing the conjunction. This supports +fit-split signal reproducibility, but the swapped operating point accepted one +of 92 unique exact-1024 Open Images controls in its external challenge; none of +637 unique exact-1024 Picsum controls crossed. A post-hoc consensus of the two +fit halves would require another untouched control set, so it is not selected +from these exposed results. The candidate remains research-only. + +An initial two-expert rule required both independently fitted templates to pass +their own separately calibrated linear and phase thresholds. The next +nonoverlapping Open Images validation-id block was downloaded without scoring; +2,000 candidates yielded 100 unique native 1024-by-1024 controls. The first +expert accepted none, the second accepted one, and their conjunction accepted +none. That rule appeared to add 5 detections across the 443 exact-1024 Google +files, including 2 of 95 July fixed-carrier misses. + +A subsequent content-group audit invalidated those recall numbers. The 443 +files contained three exact decoded-pixel duplicate pairs and ten +perceptual-hash groups at a conservative 16-of-256-bit screening distance. One +early near-duplicate pair had been split between the two expert fits, so the +experts were not content-independent. After allocating whole groups to one +side, selecting one representative per group, and recalibrating both experts +from the same untouched development controls produced templates correlated at +0.720, and the consensus retained only 6 of 443 Google files. It added 2 files +missed by the shipped carrier and retained only 1 of 95 July misses. It still +accepted no conjunction among 81 reserve exact-geometry +controls, 92 exact-1024 Open Images, 637 exact-1024 Picsum images, or the 100 +new Open Images controls. The corrected result is specific but far too small +to ship. + +Removing the exact-geometry restriction invalidated even that corrected +consensus. Across all 11,506 heterogeneous native controls, the group-separated +experts accepted 5 and 6 respectively, and their conjunction accepted 2. Both +consensus errors were at non-1024 geometries. The result is explicitly an +exact-1024 research candidate and does not supply a universal-size detector. + +A post-hoc margin above both arbitrary-geometry collisions retained the two +corrected incremental exact-1024 positives. Applying that frozen margin to all +4,698 Google files accepted 28 and added 16 files missed by the shipped carrier +across 11 native geometries. A full-corpus perceptual audit assigned those 16 +files to 16 separate content groups, none containing a shipped-carrier-positive +variant. The same margin accepted none of 1,500 Open Images, 637 unique Picsum, +or 12 Wikimedia native views. This is the first concrete all-size hypothesis +for the second direction, but the margin was selected after all 11,506 +heterogeneous controls were exposed and the natural corpora had already +challenged related experts. It requires a new content-deduplicated AI-control +corpus before it can be treated as validation or runtime logic. + +The next source-independent model-cohort challenge sharply bounded that +hypothesis. With no exact-byte overlap against the Spaces inventory, the +original group-separated conjunction accepted 2 of 589 public Gemini 3.1 Flash +Image Preview images, none of 520 Nano Banana Pro Preview, and none of 280 +DALL-E 3 images. The two Gemini hits were visually distinct diverse images at +1408 by 768. Each Google cohort also included solid-color and gradient probes +over several native geometries. The post-hoc 1.033 strict margin rejected every +cohort image: Gemini reached 1.011, Nano Banana 0.941, and DALL-E 3 0.514. The +base rule therefore has weak transfer to a current Gemini cohort but still +collides with 2 of 11,506 arbitrary-size controls; the strict rule removes both +the controls and the current-Gemini transfer only through a post-test threshold. +Neither is a validated universal Google-model or cross-provider SynthID +detector. The 16 Spaces all-size hits and two public Gemini base hits are useful +hard positives for epoch analysis, not justification for implementation. + +The near-duplicate audit also provided a small mechanistic diagnostic. In all +three perceptually matched pairs where only one variant crossed the shipped +carrier, subtracting the shipped variant from the non-shipped variant reduced +the shipped-template direction and increased both independently refitted +second-template directions. The normalized mean paired difference correlated +-0.568 with the shipped template and 0.252 and 0.264 with the two second +templates. Three pairs are insufficient for inference; the two-sided sign-test +result is 0.25. The paired result supports a carrier-state interpretation but +is not detector validation. + +All three temporal detections from the original first-expert conjunction survived JPEG round trips +at qualities 95, 85, and 70. None survived WebP 95 or a 0.75x down-and-up resize +round trip. A symmetric transform challenge prevented promoting that positive +retention to a JPEG claim: one of 162 heterogeneous exact-1024 controls crossed +the unchanged rule after JPEG 85. The 92 exact-1024 Open Images and 637 unique +exact-1024 Picsum controls stayed below threshold in every view. The candidate +was native-only; fixed-lattice JPEG retention is diagnostic, not a certified +transport operating point. The transform result applies only to that +first-expert rule. The corrected group-separated experts have not been +transport-calibrated, so it supplies no robustness claim for them. + +The corrected carrier was then tested causally. The normalized sum of the two +content-group-separated expert directions correlated 0.927 with each expert. +Subtracting it from all 16 strict incremental hits cleared every linear and +phase component on every image; the same amplitudes with a one-pixel cyclic +shift cleared none. Selected amplitudes were 5.0 through 7.75 integer levels, +with median 6.5. Fidelity ranged from 58.92 through 69.97 dB PSNR and 0.99961 +through 0.99996 SSIM. The carrier itself was transform-fragile: only one of 16 +sources remained strict after JPEG 95 or 90, and the aligned edit cleared that +one in both views while the shifted edit did not. No source remained strict +after JPEG 85, WebP 95, or a 0.75x resize round trip. + +A joint ablation then started from all 28 strict second-carrier hits. It first +suppressed the shipped carrier to the frozen -0.25 target where required, then +suppressed the second direction until all four expert components were below +their base thresholds. The original cohort contained 12 first-carrier and 28 +strict second-carrier detections; the aligned candidates contained zero of +either. The shifted controls left one first-carrier and seven strict +second-carrier detections. Median fidelity was 61.69 dB PSNR and 0.99984 SSIM; +the minima were 58.15 dB and 0.99910. The two edits did not reactivate one +another. Together with the failed third-carrier fit, the current linear native +16x16 hypothesis is locally exhausted as two jointly controllable states. +These are detector-score interventions, not Google-oracle removals. + ### 2026-08-12: Registered color and phase-lock challenge A crop-specific research branch tested whether the recovered 16x16 carrier is @@ -1568,11 +2077,165 @@ existing detector. This channel-discordance rule is the first zero-control rescue for the oracle-confirmed hard positives, but it is not added to runtime. Its form was motivated after inspecting the attack, only three examples carry external -oracle labels, and its synthetic rescue sensitivity is 19 of 58. A valid next -gate must preregister this unchanged rule, obtain new oracle-confirmed -Green-perturbed positives, and challenge a newly collected control set plus -resize, crop, and codec views. Until then it is an attack diagnostic, not a -universal SynthID detector. +oracle labels, and its synthetic rescue sensitivity is 19 of 58. + +A follow-up challenge registered that frozen rule over all 256 modulo-16 phase +offsets and six fixed carrier periods: 8, 10.4, 12, 16, 20, and 24 pixels. The +Red-only form accepted one of 3,000 COCO controls during phase registration; +period search added no further acceptance. The accepted control scored 0.395 +in Red but only 0.267 in Blue. This rejects Red-only registered rescue at the +existing threshold. + +The previously frozen `min(Red, Blue) >= 0.3770237083693908` candidate, still +combined with negative Green, accepted none of the same 3,000 controls under +phase-only or phase-and-period registration. Its maximum registered control +score was 0.342. A symmetric transform stress test then evaluated all twelve +identity, crop, JPEG, and resize views for 100 fixed Open Images controls. None +of the 1,200 inputs was accepted; the maximum was 0.332. + +The stricter rule retained all three oracle-confirmed hard positives at native +scale and after offsets of 1x1 and 7x11 pixels or a centered 5% crop. 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 qualities 95, 85, +and 70. Every source and RMS-noise sham remained negative in all twelve views. +The small-control zero was not sufficient for the complete period bank. A +later five-period search over all 11,506 metadata-labeled non-Google controls +accepted 16 at the original 0.377 threshold: two at period 20 and 14 at period +24. The maximum `min(Red, Blue)` score was 0.435813. This rejects one common +threshold across periods 10.4 through 24. + +A conservative high-score branch was then frozen strictly above that measured +maximum. It retained all three hard positives at periods 12, 16, 20, and 24, +corresponding to 0.75x through 1.5x resizing, identity, and the three crop +views. A symmetric full-corpus resize challenge accepted none of 11,506 +controls at 0.75x, 1.25x, or 1.5x; the respective maxima were 0.431352, +0.393949, and 0.372036. The 0.65x path needed separate treatment. Its native +period-10.4 null maximum was 0.361367, below all three hard positives, but a +symmetric 0.65x resize round trip raised one control to 0.397396, above the +weakest hard positive at 0.394911. + +That collision motivated an exploratory low-period channel-ratio gate: +`min(Red, Blue) >= 0.3770237083693908`, negative Green, and +`Blue >= 1.1 * Red`, only at period 10.4. It retained all three 0.65x hard +positives. Among all 11,506 controls, the largest score satisfying the ratio +gate was 0.355165 natively and 0.354955 after the symmetric 0.65x round trip, +so neither challenge accepted a control. The combined exploratory rule also +retained all three hard positives in the identity, three crop, 0.65x, 0.75x, +1.25x, and 1.5x views, while every corresponding source and RMS-noise sham +remained negative. + +Applying the combined rule without further tuning to the 100-image synthetic +Green-inversion cohort exposed its low sensitivity. Among the 58 attacks that +moved a natively detected source below the native detector threshold, the 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 members of that cohort; only one additional +synthetic source passed the 0.65x branch. The rule is therefore a +high-specificity rescue for a strong residual R/B carrier, not a general +solution to Green-phase perturbation. + +A source-split Fisher projection then tested whether a learned three-channel +direction could improve that sensitivity. Red, Green, and Blue correlations +were standardized separately by scale, and the projection was fitted from 34 +training attacks and 5,719 training controls. Per-scale thresholds were frozen +above every training-control score. They accepted 11 of 23,148 views from the +5,787 test controls. On the seven source-held-out attacks, the projection +recovered 0 at 0.65x, 0 at 0.75x, 2 at 1.25x, and 2 at 1.5x. It is both less +specific and less sensitive than the explicit rule and is rejected. + +Spatial consistency at 0.65x was also insufficient. At the globally selected +period-10.4 phase, a 2x2 and 4x4 aligned grid measured local R/B strength, +negative-G coverage, and `Blue / Red` coverage on all 537 controls in the +global-score tail at or above 0.20 and on all 58 synthetic native-detector +misses. Every individual feature overlapped the control tail. Within a small +fixed grid of global floors and integer coverage counts, the best zero-control +conjunction recovered four attacks, no more than the simpler global ratio +rule. Lowering the global floor to 0.30 recovered five but accepted two +controls. The spatial branch is rejected as non-improving. + +This establishes bounded transform behavior for the attack diagnostic, not a +universal detector. The 0.65x ratio was selected after seeing the full-corpus +collision, the conservative threshold was derived from an exposed control +corpus, the period bank is discrete, only three recovered images have oracle +labels, and JPEG sensitivity is zero in this probe. The next valid gate remains +new oracle-confirmed Green-perturbed positives plus a preregistered untouched +control corpus. The branch stays outside runtime until that gate passes. + +An exploratory channel-contrast extension then tested whether the magnitude of +the negative Green response could recover the failed JPEG and 0.5x views. The +metrics were fixed as `min(Red, Blue) - Green` and +`mean(Red, Blue) - Green`; 50 Open Images controls across all twelve transforms +set thresholds before the remaining 50 controls were scored. Calibration over +600 views required thresholds of 0.655 and 0.695. Neither metric accepted an +evaluation control, but both still recovered zero of three JPEG cases at every +quality and only one of three 0.5x cases. A relaxed R/B-only threshold produced +the same result. Negative-G magnitude therefore adds no useful transform +coverage at the measured low-false-positive boundary and is rejected. + +Codec-specific templates were then fitted from 50 other public positives after +JPEG 95, 85, and 70 round trips, excluding all three oracle hard-positive +sources. Neither a quality-matched template nor a three-quality bank recovered +any of the nine JPEG hard-positive views. They accepted respectively three and +two of 150 evaluation-control views. The templates primarily captured the JPEG +8x8 grid and were rejected. + +Filtering the unchanged 16x16 template to radial frequency 0.5 through 4.5 +exposed a real JPEG-surviving component. At a threshold above the first 300 +Open Images JPEG views it retained all three hard positives at qualities 95, +85, and 70. The next frozen challenge rejected it: phase search accepted 35 +native, 50 JPEG-95, 48 JPEG-85, and 46 JPEG-70 views among 3,000 COCO images. +Cross-band phase agreement did not separate the nine hard-positive views from +those 179 false positives. + +A spatial gate required negative Green in all four quadrants and a median +quadrant R/B score above the COCO false-positive maximum. It retained all three +hard positives at JPEG 95 and 85 but none at JPEG 70. On 11,506 +metadata-labeled non-Google controls it nevertheless accepted 35 native, 66 +JPEG-95, and 88 JPEG-85 views. The errors included genuine camera images and +multiple unrelated generators. Scoring only the unchanged phase also failed, +accepting 38 native and 210 JPEG views among 3,000 COCO images; its maximum +control score of 0.673 exceeded the 0.464-0.609 hard-positive range. The +JPEG-stable band is a general low-frequency or codec lattice, not a +SynthID-specific rescue, and all of these branches are rejected. + +The 0.5x boundary was challenged separately with a physically reduced 8x8 +template. Projecting the dominant 2x2 lattice out of both observation and +template before scoring R/B with negative Green removed the known nuisance but +not the overlap. Across all 11,506 symmetrically downscaled controls, the +zero-control threshold was 0.396. The three oracle hard positives scored only +0.277-0.288. Channel separation therefore cannot rescue the rejected period-8 +branch. + +An untouched 2026-08-07 through 2026-08-10 temporal cohort then challenged the +runtime template and thresholds. Cohorts were recomputed from explicit +provenance because a provider-only split incorrectly placed mixed Google/OpenAI +lineage in the negative set. The frozen native detector accepted 183 of 252 +files with explicit Google LLC SynthID provenance and 3 of 488 with explicit +OpenAI-only SynthID provenance. Scale registration accepted 130 and 1, +respectively. Among 170 files with no SynthID provenance assertion, native and +registered modes accepted eight and two. These operational errors are not +provider-oracle negatives and cannot establish the true false-positive rate. + +The native detector remained positive on 153 of 252 explicit-Google images +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. This fresh temporal result +confirms a real, partly codec-tolerant Google pixel signal across many stored +sizes, while rejecting the stronger claim that the fixed-phase runtime score +is a universal SynthID decoder. The post-hoc strict second carrier did not +generalize: it accepted only 2 of 234 Google-platform positives and none of 689 +provider-split controls. Applying the already frozen scale-registration path +after the crop recovered only 8 of 252 explicit-Google files and accepted none +of 488 explicit OpenAI-only files. The existing scale search does not solve the +crop phase boundary. + +A diagnostic joint-scale-and-phase probe then reversed the known 0.95 crop +scale and scored the carrier at the cyclic phase predicted from the removed +border. It reached 0.859 AUC for explicit Google provenance against all other +temporal cohorts. A post-hoc threshold above all 671 other rows retained 37 of +252 explicit-Google rows. Exhaustively searching all 256 tile phases was weaker +at 0.839 AUC and retained 41 rows above its own zero-control threshold. The +fixed geometric phase supplies a plausible crop mechanism and a candidate for +the next preregistered temporal challenge, not a calibrated runtime mode: both +the transform and reported operating point have now observed this cohort. A blind three-level `db2` SWT probe then soft-thresholded only the Green horizontal, vertical, or diagonal detail bands at 0.25, 0.5, 1, and 2 times the @@ -1585,6 +2248,223 @@ score reduction exceeded diagonal reduction on 97 of 100 paired images therefore rejected; the stronger vertical response is consistent with the separately measured axis-aligned green-opponent carrier component. +### 2026-08-13: patent architecture and amplitude confound audit + +The official paper intentionally leaves the neural network architecture +unspecified, but a related DeepMind patent family with overlapping authors gives +more specific architectural evidence. Its image example constructs a +content-dependent residual as `x' = x + g(x)`, describes a U-Net-like encoder +and convolutional decoder, injects a message or secret into intermediate layers, +and trains clean and watermarked pairs under the same sampled transformations. +It also permits a bank of paired networks in which one decoder is deliberately +unable to recognize another pair's mark. These patent alternatives are not +proof of the exact production implementation, but they invalidate the working +assumption that SynthID must reduce to one provider-independent, fixed +spread-spectrum key. Multiple Google states and a distinct OpenAI carrier are +compatible with one technology family. + +This changes the role of the shipped 16-by-16 template. It is a validated linear +expert for one observable encoder state, not a surrogate for the official +nonlinear detection logit. A universal local detector should be designed as a +versioned union of independently calibrated experts, with family-wise +false-positive control and an explicit abstention region. New experts still +need source-matched clean counterfactuals or provider-oracle labels; metadata +absence is not a negative label. + +A symmetric transform-stability experiment challenged a simpler rescue. The +weak oracle-positive `07.png` scored 0.1248 natively, 0.0288 after JPEG 95, +-0.0023 after JPEG 85, 0.1179 after a 0.99x Lanczos round trip, and 0.0791 after +an eight-pixel crop round trip. The oracle-negative Open Images chestnut scored +0.2124, 0.1322, 0.0482, 0.2274, and 0.0601 under the same views. Both signals +degrade similarly under codec and crop operations. Multi-view stability does +not distinguish the weak positive from this natural lattice confound and is +rejected as a rescue gate. + +Raw folded-tile amplitude was also tested because `07.png` had a norm of 6.924, +compared with 3.017 for the chestnut and 0.874 for the strongest tested resized +Kodak confound. The full corpus rejects a general amplitude gate. Among 4,698 +Google rows, the current score threshold accepts 3,928, but accepted norms run +as low as 0.712. A post-hoc branch at `score >= 0.121` and `norm >= 5` would add +190 rows below the current threshold, yet an observed-geometry development +control already reaches score 0.120911 at norm 15.339. The margin to `07.png` +is under 0.004 and there is no untouched validation set. Amplitude is useful as +a support diagnostic, but neither a veto nor this low-score rescue is eligible +for runtime. + +The next defensible Google experiment is consequently a preregistered +multi-expert cascade: preserve the current carrier unchanged; train candidate +experts only on source-grouped paired or oracle-labeled data; calibrate every +expert and their union on untouched natural, resize, codec, and provider +controls; and return `unknown` when neither the watermarked nor unwatermarked +hypothesis clears its calibrated boundary. + +The newly published MarkNull work supplies an independent no-box removal +baseline. It reports 100% attack success on 20 Imagen-3 images checked through +Gemini's verification flow. The attack uses a clean Stable Diffusion proxy to +invert each image, optimize a bounded latent against a noise-latent alignment +loss plus LPIPS, MSE, and SSIM, and decode it again. Its milder SynthID setting +still reports only 25.36 dB PSNR and 0.80 SSIM. It therefore strengthens the +case for latent regeneration as a detector-independent fallback, but it does +not meet this project's pixel-only or fidelity gates. + +The associated [public repository](https://github.com/JieJayCao/MarkNull) +contributed 50 metadata-free 512-by-512 Gemini/Imagen source candidates, but no +attacked SynthID pairs in the inspected Git tree. The paper reports only 20 +oracle-evaluated sources and does not map those verdicts to filenames, so all 50 +remain source-asserted candidates rather than individual oracle labels. Both +shipped experts detected zero of 50. More precisely, the fixed runtime expert +reported unsupported because 512 by 512 is below its calibrated pixel-count +floor. Its raw diagnostic score ranged from -0.1044 to 0.1058. Scale +registration supported all 50 and accepted none, with its highest decision +statistic at 0.9574 below the frozen 1.0 threshold. The shared scorer in +`scripts/synthid_runtime_expert_scores.py` now records this distinction and +exports both observations from one pixel decode. + +An external-source split nevertheless exposed a highly reproducible period-4 +component. Templates from the first and second 25 images correlated at 0.958. +A template fitted only on the first half accepted 22 of 25 held-out candidates +at a threshold above 250 canonicalized COCO development controls and accepted +zero of a separate 250 COCO controls. The broader challenge rejected watermark +specificity: it accepted two of 300 heterogeneous AI controls and five of 282 +content-deduplicated internal negative-test controls at the same frozen +threshold. + +The mechanism diagnostics also identify the confound. The period-4 mean placed +82.4% of its non-DC spectral power in the horizontal Nyquist bin, and stable +correlations at larger even periods vanished after projecting out all period-4 +harmonics. Its strongest channel was Blue rather than Green, and its best +phase-aware correlation with a fourfold-reduced shipped carrier was only 0.375. +This is an axial renderer or resampling signature, not evidence for another +validated diagonal SynthID carrier. It is retained as a hard-negative feature +and excluded from the runtime expert bank. + +A schema-versioned conformal cascade now implements the required aggregation in +`scripts/synthid_conformal_cascade.py`. Every configured expert must emit an +explicit supported score or unsupported result. For presence, the smallest +empirical clean-null upper-tail p-value receives a Bonferroni correction over +the supported expert bank. For absence, the largest lower-tail p-value against +each expert's positive calibration is used, so every possible encoder state +must be rejected. `not_detected` is disabled unless the calibration manifest +explicitly declares complete version coverage and every expert supports the +query. Input and output manifests are hash-pinned for reproducibility. + +The first source-disjoint smoke test used only the unchanged fixed 16-by-16 +expert. Alternating content hashes supplied 2,347 source-asserted Google +calibration rows and 2,346 evaluation rows. The existing observed-geometry COCO +partitions supplied 3,000 development controls and 2,000 final controls. At a +two-sided alpha of 0.001 with incomplete-version coverage, the cascade detected +2,084 of 2,346 Google rows and one of 2,000 final natural controls; all other +rows abstained and none returned `not_detected`. The corresponding frozen +runtime threshold detected 1,954 Google rows and zero final controls. The +conformal point therefore recovers 130 additional source-asserted positives at +the cost of one natural false positive. It is statistically compatible with +the target 0.1% FPR, but it is not a runtime replacement: the corpus has already +influenced detector development, Google labels are provenance assertions rather +than per-file oracle verdicts, and a multi-expert union still needs a fresh +family-wise challenge. + +The fixed and scale-registered experts were then evaluated together on their +frozen source-disjoint resize challenge. Current production scores were +recomputed from pixels for all 355 transformed Google candidates and 499 +untouched Open Images controls. Sorting each label by source identifier and +alternating rows produced 178 positive and 250 negative registered-calibration +examples, followed by 177 positive and 249 negative evaluation examples. The +fixed calibration used 4,516 Google sources after excluding every evaluation +source and 2,980 observed-geometry development controls. At the unchanged +runtime thresholds, fixed accepted 8 evaluation positives, registered accepted +116, and their union accepted 117 of 177 with zero of 249 controls. On the full +descriptive corpus the corresponding counts were 15, 229, and 231 of 355, again +with zero of 499 controls. Registered scale search therefore supplies 109 +source-disjoint evaluation positives that the fixed expert misses; the fixed +expert adds one that registered misses. + +Applying the empirical cascade at alpha 0.01 was less specific. It accepted 122 +of 177 evaluation positives but also five of 249 controls. All five control hits +came from the registered branch below its frozen decision threshold. The +250-control calibration provides only 0.00398 p-value granularity, and lowering +the operating point merely traded five additional positives for five false +positives. This conformal operating point is rejected. The implementation is +retained as versioned calibration infrastructure, while the independently +frozen production thresholds remain the valid two-expert decisions. + +A wider calibration reused the already saved component reports for 11,273 +supported, symmetrically resized Spaces controls. The stored high-band value +made the current registered decision score exactly reconstructible throughout +the relevant upper tail; rows screened below the historical 0.1182 amplitude +ratio cannot affect this tail. At alpha 0.001, the two-expert cascade accepted +119 of 177 evaluation positives and zero of 249 controls. The two additions +over the runtime union had registered scores 0.9830 and 0.9731, with corrected +clean-null p-values of 0.000710. This initially appeared to be a non-failing +multi-expert calibration point, but the positive challenge had already been +exposed. The alpha, expert bank, and thresholds were frozen before the fresh +test below. + +The decisive reserve used the next 1,500 sorted Open Images validation IDs, +3,500 through 4,999, frozen before scoring. Download and geometry checks yielded +1,000 registered-supported controls with no decoded-pixel hash overlap against +the prior 1,499 Open Images controls. Registered scale search accepted zero. +The fixed runtime threshold accepted 7 of the 81 controls in its supported +geometry, or 7 of 1,000 overall, so a simple OR of the two experts also accepted +seven. The frozen alpha-0.001 cascade was worse: it accepted nine controls, +eight through the fixed tail and one registered-only row at score 0.8690. +The candidate is rejected. In overlapping geometry, fixed-only evidence cannot +be added to registered evidence without a new specificity mechanism; keeping +the fixed detector available as a separately scoped diagnostic does not make +their union safe. + +The fixed-only geometry does not provide a fallback. Among 89 saved non-Google +Spaces controls from 10 to 18 megapixels, where registered mode is unsupported +but fixed mode is supported, 10 crossed the fixed threshold. The errors span +multiple generator families. A geometry router therefore cannot extend recall +by selecting fixed whenever registered is unavailable. + +`scripts/synthid_routed_expert_bank.py` implements the surviving conservative +policy without changing the standalone detectors. When registered mode is +supported, only its frozen threshold can produce `detected`; a fixed-only +crossing is retained in the report as `fixed_only_ambiguous`. When registered +mode is unsupported, fixed evidence is likewise reported but the bank abstains. +The bank never returns absence because encoder-version coverage is incomplete. +On all 355 transformed positives and 499 reserve controls it detected 229 and +zero. On the fresh 1,000-image reserve it detected zero and explicitly routed +the seven fixed crossings to abstention. This is a safe expert-bank baseline, +not a universal SynthID decoder: scale 0.5, unmeasured encoder states, and the +separate OpenAI family still require additional validated experts. + +Directly enlarging registered mode's pixel ceiling also failed. The unchanged +registered components were computed on every saved 10-to-18-megapixel example: +37 Google candidates and 89 non-Google Spaces controls. The frozen threshold +accepted one positive and zero controls. Amplitude passed for 28 positives and +the selected spatial and spectral periods agreed for 21, but only three cleared +the high-band gate. Large full-frame spectra therefore lose almost all recall; +the absence of control errors does not justify widening the declared geometry. +Phase-aligned window aggregation inside the calibrated range is the next +bounded large-image hypothesis. That hypothesis was then challenged with every +phase-aligned 2,048-square window. Median-window scoring retained 36 of 37 +positives but also 10 of 89 controls; requiring every window to pass retained +36 positives and eight controls. The false lattices are spatially coherent, so +window consensus cannot validate a large-image fixed fallback. Large images +outside registered scope remain abstentions. + +A final frozen full-frame alternative isotropically reduced each large image to +eight megapixels with area interpolation before applying the unchanged +registered detector. This maps a native period 16 into approximately 10.7-14.3 +pixels, inside the calibrated period range, without discarding spatial regions. +It still accepted only 1 of 37 positives and zero of 89 controls, and the one +hit was the same 4096-square image detected natively. Amplitude passed for 26 +positives, period agreement for 16, and high-band agreement for only three. +Pre-resizing therefore does not recover the missing evidence and adds harmonic +ambiguity; no target-size sweep is justified on this exposed cohort. + +The scale-0.5 failure was also rechecked without the registered detector's +sequential amplitude gate. High-band correlation was computed for all 65 +period-8 positives and all 499 reserve controls. Positive and control medians +were 0.0184 and 0.0213, with ranges -0.2381 to 0.1930 and -0.1734 to 0.2148. +Normalized amplitude also overlapped: positive median and maximum were 0.4611 +and 0.9259, compared with 0.3824 and 1.6296 for controls. Requiring amplitude +and high-band evidence jointly therefore cannot recover this scale at the +frozen specificity. The 0.5x case remains an explicit blind spot rather than an +eligible lower-threshold branch. + ### 2026-08-10: OpenAI periodic-carrier challenge The OpenAI track repeated the Google carrier method without runtime provenance @@ -1627,6 +2507,203 @@ OpenAI-oracle `detected` and `not_detected` pairs, or provider-authorized decode labels, followed by a fresh temporal holdout. Source provenance alone cannot distinguish a true watermark carrier from the measured generator lattice. +### 2026-08-12: OpenAI opponent-color carrier challenge + +The RGB periodic and learned branches did not test the hypothesis that a weak +carrier is clearer after the nonlinear LMS transform used by OKLab. A new +validation-only screen compared sRGB, linear RGB, cube-root LMS, OKLab, and an +orthonormal linear opponent basis. For each representation it tested periods +4, 8, and 16 plus whole-vector, single-channel, and chroma-vector folds. Every +candidate fitted a positive-minus-control direction independently in the native +and JPEG-95 views. A positive had to cross both thresholds, with each threshold +strictly above every validation control in the corresponding view. + +The development corpus was limited to the three OpenAI native geometries: +1024x1024, 1024x1536, and 1536x1024. It contained 283/140/158 OpenAI +train/validation/test images and 252/145/135 heterogeneous controls. The best +validation candidate was the third cube-root LMS component at period 8. It +accepted 19 of 140 validation positives. The frozen locked test accepted 16 of +158 positives and none of 135 controls in the conjunctive native-plus-JPEG +rule. Native alone accepted 43 positives and one control; JPEG-95 alone accepted +22 positives and a different control. The native and JPEG templates correlated +at 0.890. + +The 16 test hits were not a single date, geometry, or content family. They +spanned 11 collection dates; the 1024x1024, 1024x1536, and 1536x1024 cohorts +contributed 2, 8, and 6 hits. A 256-bit DCT-hash audit found two multi-member +perceptual groups in the complete 581-image OpenAI native cohort, but neither +connected an accepted test image to another split. Thresholding against the +union of heterogeneous validation controls and 140 matched 0.75x-down-and-up +OpenAI views did not change the operating point. The 158 matched test views +accepted none while their 16 corresponding source views remained accepted. + +The transform boundary is mixed. All 16 source hits remained conjunctively +accepted after WebP-95; the complete test set then contained 26 accepted OpenAI +images and one accepted control. JPEG-90 retained four of the 16. Crop and 0.75x +resize round trips retained none. Searching all 64 cyclic period-8 phases did +not restore geometric robustness: resize retained 2 of 158 positives and one +of 135 controls, while crop retained no positive and one control. The loss is +not merely an unknown modulo origin. + +The candidate was also scored without refitting on all 52 action-derived +same-provider controls at these geometries. It accepted one of 52 and none of +the 12 controls explicitly naming `gpt-image 2.0`. The exact-version subset is +only 8/2/2 train/validation/test by the native geometry restriction, and the +absence of `c2pa.watermarked.unbound` remains an action-derived candidate label, +not a provider-oracle negative. These counts cannot establish an OpenAI-specific +false-positive rate. + +The dominant exporter lattice was removed explicitly. The raw period-8 +template placed 84-90% of its energy in period-2 or period-4 subspaces. For +each image, projecting out the complete period-4 component before refitting +reduced native/JPEG template correlation to 0.564 but still accepted 18 of 140 +validation and 15 of 158 test positives with zero test controls. The useful +residual therefore does not collapse to the previously documented 2x2/4x4 +generator lattice. + +A direct pixel ablation tested causality against the frozen local score. The +normalized native period-8 direction was subtracted only from the sRGB Blue +channel, with a scalar search for the smallest amplitude that cleared both the +native and JPEG-95 thresholds. All 16 locked-test sources cleared. Repeating +the same selected amplitude at the least-correlated cyclic phase cleared none +of 16. Median amplitude was two Blue-channel integer levels, median PSNR was +52.24 dB, and median SSIM was 0.99639. The minima were 40.62 dB and 0.97184, +so even as a local ablation this is not yet a production-quality remover. +More importantly, score causality does not establish that the candidate is the +OpenAI SynthID decoder surface. + +An all-size map then evaluated the same fixed native period through partial +modulo folds, so dimensions did not need to be divisible by eight. It accepted +94 of all 7,259 OpenAI-labeled files across 13 of 605 geometries and 9 of 8,670 +heterogeneous controls across 7 of 1,534 geometries. The three fit geometries +contributed 76 positives. The largest exported geometries, including 2,265 +files at 1086x1448 and 992 at 1254x1254, contributed none. Two corpus rows were +excluded because their decoded geometry no longer matched the indexed metadata. + +A scale search was frozen to integer periods 4 through 16 before examining the +all-size results. Its score was the maximum, across periods, of the minimum +native and JPEG-95 correlations. The common threshold was set above every +period-searched validation control, thereby charging the search itself against +specificity. It accepted 13 of 1,854 validation positives and none of 2,227 +validation controls. On test it accepted 14 of 1,839 positives and one of 2,162 +controls. Every accepted OpenAI image selected period 8; no other period added +a hit. The carrier does not scale continuously with stored resolution. + +This is the strongest local OpenAI periodic candidate so far: it is codec +tolerant, survives explicit subperiod deflation, has a phase-specific causal +ablation, and is not explained by date, native geometry, or hash-group leakage. +It is still research-only. It has low sensitivity, fails geometric transforms, +has one all-size test-control collision, and lacks a matching-provider oracle. +The next independent branch should look for a resampling-invariant, +content-dependent residual on source-grouped data rather than broadening the +same period search. No runtime OpenAI pixel expert or remover is justified. + +The next branch removed phase from the folded LMS spectrum and required three +views: native pixels, JPEG 95, and a 0.75x down-and-up resize. It screened +magnitude, power, and log-magnitude profiles at periods 4 through 16 on +validation only. The selected period-16 power candidate used a separately +fitted template for each view. It accepted 8 of 140 validation positives and 8 +of 158 locked-test positives, with no native-geometry control accepted in +either split. On the complete wide challenge it accepted none of 2,227 +validation and 2,162 test Spaces controls, one of all 261 action-derived +same-provider controls, and none of the 118 exact-version `gpt-image 2.0` +controls. Five of its eight test hits overlapped the signed period-8 candidate; +three were additional. + +Subperiod deflation bounded that result. Removing the exact period-8 Fourier +support from each period-16 spectrum improved native-to-resize template +correlation from 0.061 to 0.925, but the locked test then retained five +positives and accepted two of 135 controls. Removing period-4 support retained +no test positive. The transform-tolerant energy is therefore part of the same +harmonic family rather than a clearly independent carrier. + +Finally, a stricter experiment required one shared phase-free template across +all three views. Validation selected period-15 magnitude, whose independently +measured view directions correlated 0.992 for native versus JPEG and 0.900 for +native versus resize. It retained 7 of 158 native-geometry locked-test +positives and none of 135 controls. That apparent invariance failed on the +wide geometry challenge: the same thresholds accepted 11 of 2,227 validation +and 14 of 2,162 test Spaces controls, plus seven of 261 same-provider controls +and two of 118 exact-version controls. It is a general renderer or resampling +spectrum and is rejected. + +The separately fitted period-16 three-view ensemble remains a useful research +diagnostic because it survived the broader control challenge, but it is not a +single codebook, has only eight native-geometry test positives, and derives +most of its signal from the same subperiod family. It does not rescue the +universal-detector claim or justify runtime code. + +A non-learned fractal hypothesis was tested last. For each image it compared +the phase-free cube-root LMS spectrum with the same image after JPEG 95 and +after a physical 0.75x reduction. Candidate native periods 8, 12, 16, 20, and +24 were paired with scaled periods 6, 9, 12, 15, and 18. The score was the +minimum of native-to-JPEG and native-to-scaled spectral cosine, so no provider +template or fixed phase participated. Validation selected period 12-to-9 +log-magnitude and retained 3 of 140 positives above all 145 controls. The +locked test accepted none of 158 OpenAI positives and one of 135 controls; the +OpenAI median was 0.515 versus 0.555 for controls. Simple phase-free spectral +self-similarity is not an OpenAI watermark feature and the fractal branch is +rejected. + +The scale hypothesis was then challenged against the common non-native export +geometries. A nearest-native inverse-raster surrogate recovered none of 1,321 +test OpenAI images and accepted two of 1,485 symmetrically resampled control +views. That mapping was intentionally treated as incomplete because it changed +aspect ratio for important cohorts. The largest cohort, 1086x1448, is instead +consistent with an isotropic `1/sqrt(2)` reduction from 1536x2048. Restoring +that exact hypothesized source raster still recovered none of 579 validation +or 562 test positives. A direct fractional-phase fold avoided inverse +interpolation and searched two raster-coordinate conventions plus all 64 cyclic +origins, charging the complete registration search against validation +controls. It retained 2 of 579 validation and 4 of 562 test positives while +accepting 2 of 135 test controls. Its median score was lower for OpenAI than +for controls. The fractional-carrier explanation for this dominant export +geometry is rejected. + +A nonlinear cyclostationary screen next tested whether content-dependent or +payload-varying signs cancel the cross-image mean while leaving a periodic +variance. It compared folded magnitude, energy, locally normalized energy, +and cross-channel products in cube-root LMS and OKLab. Validation selected the +absolute residual of the third cube-root LMS component at period 4. It retained +33 of 140 validation and 43 of 158 locked-test positives, but also accepted 2 +of 135 locked-test controls. The frozen wide challenge confirmed a renderer +confound: it accepted 48 of 1,854 validation and 52 of 1,839 test OpenAI images, +against 7 of 2,227 validation and 5 of 2,162 test Spaces controls. It also +accepted 10 of 261 action-derived same-provider controls, including 5 of 118 +that explicitly name `gpt-image 2.0`. Requiring spatial agreement did not +repair specificity; the validation-selected median over four quadrants kept +41 of 158 test positives and the same two native-geometry test controls. + +Finally, a second-order cyclic screen folded spatial products of the residual +with lags 1, 2, 4, and 8 along axial and diagonal directions. It evaluated 360 +fixed LMS/OKLab, channel, period, lag, and normalization combinations. The +validation winner used the second cube-root LMS component, period 16, and a +vertical four-pixel lag. It retained 36 of 140 validation and 43 of 158 test +positives, but accepted 4 of 135 locked-test controls. Periodic magnitude, +patch consistency, and lag autocorrelation therefore expose reproducible +OpenAI renderer structure but do not meet the SynthID specificity gate. + +These negative results bound the current manual feature search. Signed phase, +phase-free power, subperiods, integer and fractional scale, inverse raster +registration, spectral self-similarity, nonlinear periodic variance, spatial +consistency, and second-order lag products have all either lost sensitivity or +crossed held-out controls. Adding further hand-selected transforms to the same +exposed corpus would increase adaptive overfitting rather than confidence. A +defensible next OpenAI epoch requires provider-authorized oracle labels or +clean same-content counterfactual pairs and a fresh temporal holdout. + +An untouched 2026-08-07 through 2026-08-10 temporal cohort supplied that +holdout for validation, but not the missing oracle negatives. It contained 486 +explicit OpenAI-provenance positives and 504 heterogeneous controls. Only seven +additional files were pure OpenAI C2PA candidates without a SynthID assertion; +the remaining same-platform candidates contained inferred or mixed Google and +OpenAI watermark provenance and were not treated as clean negatives. The frozen +signed period-8 candidate accepted 5 of 486 positives and 1 of 504 controls. +The phase-free period-16 candidate accepted 2 of 40 supported positives and 1 +of 50 supported controls. The nonlinear period-4 candidate accepted 14 of 486 +positives and 8 of 504 controls. The manual carrier branches fail temporal +transfer. + ### 2026-08-10: OpenAI content-dependent decoder challenge The periodic result did not exhaust the neural-watermark hypothesis. OpenAI's @@ -1722,6 +2799,471 @@ defensible next epoch needs provider-authorized labels or clean same-content before/after watermark pairs, plus a fresh temporal holdout that has not influenced representation or threshold selection. +The same untouched August cohort then challenged the frozen 512-square forensic +model and its unchanged threshold. It accepted 82 of 486 explicit OpenAI +positives, one of 504 heterogeneous controls, and one of seven pure OpenAI C2PA +candidate controls. The aggregate score separation was real, but transformation +testing resolved its interpretation: all 82 native detections disappeared after +both JPEG-95 and WebP-95 round trips. A 0.75x resize retained 46, and a 5% crop +retained 23. The model therefore generalizes as a fragile OpenAI export +noiseprint, not as the documented robust SynthID signal. + +The provider-key alternative was then tested directly. OpenAI training folds +were clustered into 1, 2, 4, 8, 16, or 32 spherical period-8 cube-root-LMS +directions, and the cluster count was selected on validation only. Eight +directions raised the development-test result from 16 to 27 of 158, with one +of 135 heterogeneous controls and two of 52 action-derived same-provider +controls accepted. This did not reveal eight independent codewords: the two +dominant clusters contained 234 of 283 training rows, and their templates +correlated at 0.982 natively and 0.924 after JPEG-95. + +The frozen multi-direction model accepted none of 443 Google native-geometry +files, but accepted 5 of 39 Microsoft Designer files and 123 of 674 OpenAI- +platform files. All five Microsoft hits had joint `Microsoft, OpenAI` +provenance, one explicitly asserted an OpenAI SynthID watermark, and all five +selected the same dominant direction as 111 OpenAI hits. On the +fresh August native-geometry cohort, SHA-256 grouping reduced seven OpenAI row +hits to five unique content groups; two unique Microsoft controls crossed the +same dominant direction. A sign-invariant carrier-subspace fit retained only 1 +of 158 development-test positives and none of 40 fresh positives. A final +OpenAI direction orthogonal to Google, Microsoft, and other-provider train +means likewise retained 1 of 158 and none of 40. The measured period-8 family +is distinct from Google but is shared with a Microsoft/OpenAI lineage. + +Regrouping by the signed watermark assertion found 115 of 589 explicit OpenAI- +SynthID files accepted, against 13 of 117 OpenAI-lineage files without an +assertion, none of 443 Google files, and one of 89 other-provider files. The +aggregate one-sided exact-test result was `p = 0.0179`; the already exposed +development-test split was not independently significant at 27 of 159 versus +2 of 25 (`p = 0.202`). Missing watermark action is not an oracle-negative +label, so this is compatible with a provider-keyed family but does not prove +that the measured component is the watermark. + +The frozen score did exhibit real codec persistence. WebP-95 retained 24 of 27 +native development-test detections and added nine other positives, with the +same one of 135 controls retained. JPEG-90 retained eight of 27 and added no +positive, with zero controls. A 0.75x resize and 5% crop retained none. The +result is stronger than the full-image noiseprint and supports a genuine +OpenAI/Microsoft raster-phase component, but its geometric fragility excludes +it as the documented complete robust SynthID decoder surface. + +The broader SynthID architecture permits provider-specific codes without +requiring one shared pixel template. The SynthID-Image paper separates binary +detection from payload recovery, identifies payloads as a mechanism for +distinguishing customers of one service, and assigns watermark versions to the +encoder. OpenAI and Google can therefore use the same watermark family while +deploying different encoder versions or payload distributions. + +The narrower same-carrier/different-phase prediction was challenged directly. +Period-8 cube-root LMS folded residuals were collected from 581 byte-unique +OpenAI-asserted files, 443 Google-asserted files, 93 OpenAI-lineage files with +no watermark assertion, and 82 other-provider controls. Both provider +contrasts were internally stable across deterministic halves. Raw phase-free +power means had cross-provider cosine 0.480, but this shared imaging background +disappeared after subtracting the respective controls: positive power support +had cosine 0.0123, bootstrap median 0.0232 with a 95% interval of 0.0009 to +0.0856, and no overlap among the 12 strongest coordinates. The three +per-channel cosines were all at most 0.0155. The signed contrasts were negative, +not phase-locked. The evidence therefore rejects a simple Google template with +an OpenAI phase or payload substitution. It remains compatible with a distinct, +content-dependent SynthID encoder version and motivates separate learned +experts rather than further rotation of the Google carrier. + +A fixed three-query OpenAI Verify validation then established attribution while +also falsifying completeness. An asserted OpenAI image accepted by the frozen +eight-direction period-8 candidate was submitted both in its original form and +as a pixel-identical PNG re-encoded without metadata. Both returned `SynthID +detected` and `Content Credentials not detected`. A second asserted OpenAI +image rejected by the local candidate, with native/JPEG scores 0.709/0.657 +against thresholds 0.918/0.923, was also re-encoded without metadata; it +returned the same pixel-only SynthID verdict. The period-8 family is therefore +a genuine OpenAI SynthID component with known oracle-positive false negatives, +not merely an export fingerprint and not a complete detector. No adaptive +oracle sweep followed: OpenAI's Content Provenance documentation explicitly +disallows repeated queries to reverse-engineer, remove, or evade a watermark. +The next local epoch must treat period-8 as one expert and discover complementary +content-dependent experts using frozen corpus splits, without oracle-guided +feature selection. + +Relaxing the eight-direction model to a separate validation-control threshold +for every direction increased validation recall to 36 of 140 with zero of 145 +controls, but it did not preserve specificity. The locked development test +accepted 46 of 158 OpenAI rows and 4 of 135 controls. A wider provider challenge +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. +The fresh temporal result was 13 of 40 OpenAI rows, 4 of 50 general controls, +and 1 of 3 same-provider controls. The oracle-positive local false negative did +cross direction 4 under this relaxed calibration, but that direction survived +only 1 of 4 leave-one-positive-out refits; the oracle-positive row itself then +failed its JPEG threshold. Direction-specific thresholds therefore expose +unstable minority clusters rather than additional validated codewords. + +A train-only bilinear follow-up tested whether a content-dependent or keyed +phase varies between images while retaining native-to-JPEG covariance within +each image. It subtracted the control within-class cross-covariance from the +OpenAI cross-covariance, selected the positive-eigenvalue rank on validation, +and kept the threshold above every validation control. Rank 1 retained only 1 +of 158 locked-test OpenAI rows and zero of 135 controls, then zero of 40 fresh +OpenAI rows and zero of 53 fresh controls. A single codec-stable variable-phase +subspace is therefore rejected as the missing complementary expert. + +Taking local spectral magnitude before spatial averaging produced a superficially +stronger complementary signal. Eighteen period, block-normalization, and spectral +variants were screened on validation; period-8 unit-block log-magnitude retained +8 of 158 locked-test OpenAI rows and zero of 135 controls, then 3 of 40 fresh +OpenAI rows and zero of 53 fresh controls. None of the three fresh hits overlapped +the frozen global eight-direction hits, and only one overlapped the unsafe +per-direction union. Both oracle-positive images, including the known local +false negative, remained below this expert's thresholds. + +The full provider challenge rejected watermark attribution despite that clean +development transfer. The local-spectrum expert 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 other-provider hits were +Microsoft. Explicit watermark assertion was not enriched over same-lineage +non-assertion (`p = 0.966`, one-sided exact test in the required direction). +Recalibrating both frozen view thresholds above the 32 same-lineage validation +maxima reduced locked-test recall from 8 of 158 to zero. Local pre-aggregation +magnitude therefore reveals another OpenAI/Microsoft raster component, not a +defensible complementary SynthID expert. + +The proposed directional-wavelet branch was also tested directly on OpenAI. +A three-level DTCWT over the period-8 cube-root-LMS residual screened all six +orientations, the diagonal and axis-near groups, individual levels, and complex, +magnitude, and phase statistics. Validation selected all-orientation normalized +complex correlation at 19 of 140 OpenAI rows and zero of 145 controls. It +retained 16 of 158 locked-test OpenAI rows and zero of 135 controls, followed +by 2 of 40 fresh OpenAI rows and zero of 53 fresh controls. + +This is a stronger representation of the known phase carrier, not a new expert. +Both fresh hits were already accepted by the frozen eight-direction model, the +known oracle-positive local false negative remained negative, and the other +oracle-positive row missed only the DTCWT JPEG threshold at 0.894 versus 0.897. +The provider challenge accepted 77 of 603 explicit OpenAI-SynthID rows, 3 of 80 +OpenAI-lineage non-assertions, zero of 443 Google rows, and 2 of 112 other rows, +both Microsoft. The explicit-assertion enrichment was real (`p = 0.00879`), but +72 of the 77 hits overlapped the signed phase expert and their union increased +its explicit count only from 115 to 120. Same-lineage calibration retained 4 +of 158 locked-test and 1 of 40 fresh OpenAI rows, while one Microsoft test row +still crossed. + +The transform challenge then falsified operational shift invariance. Cyclic +rolls by `(1, 1)` and `(3, 5)`, a 0.75x resize round trip, and a 5% crop round +trip each retained zero of the 16 baseline detections. JPEG-90 retained four. +WebP-95 retained 15, accepted 25 OpenAI rows in total, and introduced one of +135 controls. DTCWT therefore improves codec tolerance for the already measured +raster phase but does not solve registration, scale, arbitrary resolution, or +the missing OpenAI payload variants. + +Selecting the best DTCWT magnitude and phase candidates within their own +families did not change that conclusion. The off-diagonal 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. All three fresh hits were already +signed-phase hits. The near-vertical phase candidate retained 9 of 158 and zero +of 135, but failed temporally at 1 of 40 OpenAI rows versus 3 of 50 general +controls. Magnitude also retained zero of its ten baseline detections after a +one-pixel roll, `(3, 5)` roll, resize, or crop; JPEG-90 retained one and WebP-95 +retained two. Taking magnitude only after periodic complex folding preserves +the phase-origin dependency and does not realize DTCWT's intended shift +invariance. + +A final DTCWT screen removed periodic folding entirely and summarized global +energy over six orientations and three levels. Nine validation-screened +translation-insensitive magnitude, power, orientation, and robust-quantile +statistics selected median magnitude proportions within each level. The locked +test accepted only 5 of 158 OpenAI rows and 1 of 135 controls; the fresh holdout +accepted zero of 40 OpenAI rows and zero of 53 controls. DTCWT therefore presents +a measured tradeoff in this corpus: preserving the periodic complex field +retains the real OpenAI carrier but its phase origin, while pooling enough to +be translation insensitive removes the transferable separation. + +A metadata-stratified audit then tested whether the 77 provider-challenge hits +were confined to one encoder rollout or geometry. Within the exact `OpenAI +Media Service API` cohort, the frozen DTCWT expert accepted 75 of 581 asserted +rows and 1 of 52 same-generator rows without an assertion. Asserted-row rates +were 3/16 in May, 46/365 in June, and 26/200 in July, and 6/47, 44/320, and +25/214 across the three native geometries. The native/JPEG minimum score had +0.721 AUC against the same-generator non-assertion stratum. The component is +therefore continuous across time and geometry rather than a short-lived export +cohort, although missing assertion remains an indeterminate label rather than +a clean watermark negative. + +A prespecified shift-invariant follow-up sorted all 64 cyclic period-8 +template correlations within each image. This preserves the shape of the +correlation orbit while discarding its absolute phase; one train-only contrast +was fitted for native and JPEG-95 views, with both thresholds placed above all +145 validation controls. The individual views retained 5 and 7 of 140 +validation positives, but their conjunction retained none of 158 locked-test +positives and none of 135 controls. JPEG, WebP, resize, and crop could not +rescue a baseline detection because there was none. Thus the transferable +OpenAI evidence is carried by signed raster phase, not merely by a +shift-invariant matched-filter peak shape. + +Content-hash grouping also corrected the temporal denominators. The 486 OpenAI +rows represented 417 unique byte groups, and the 234 Google rows represented +211. The signed OpenAI period-8 result became 4 of 417 unique positives versus +1 of 454 general-control groups; the full-image noiseprint became 68 of 417; +the Google runtime result became 153 of 211. Repeated uploads did not change the +decision, but row-level counts must not be treated as independent evidence. + +### 2026-08-13: OpenAI low-texture phase and masking-alignment challenge + +The dominant 1086x1448 export geometry supplied 2,265 byte-unique images with +an explicit OpenAI SynthID assertion, 830 general controls at the same geometry, +and 26 OpenAI-lineage files without a watermark assertion. The exact-geometry +split contained 1,124/579/562 asserted OpenAI train/validation/test rows and +399/203/228 general controls. The lineage rows remained an indeterminate +stratum rather than being relabeled as negatives. + +A train-only cross-image phase model selected 256 RGB bins from the 20 +lowest-texture references in each of three luminance groups. It used +polarity-invariant axial phase and scored both native pixels and a JPEG-95 view +after canonicalizing to 512 square. Independent train halves shared only two +of 256 exact channel-frequency bins and three spatial coordinates. The full +model produced native AUC 0.526 on validation and 0.512 on test; JPEG-95 AUCs +were 0.521 and 0.512. A threshold above every validation control accepted none +of 562 test positives, while a validation 1% FPR threshold accepted four +positives and two of 228 test controls. Texture quartiles did not reproduce a +useful exception. The common absolute-phase hypothesis is rejected for this +OpenAI geometry. + +The official architecture points to a different mechanism: watermark +generation is deliberately content-dependent to resist watermark exchange, +and nearly uniform content can be filtered rather than watermarked. A second +frozen probe therefore discarded absolute phase and global residual amplitude. +It measured 60 OKLab relations between blockwise high-frequency energy and +local texture, contrast, and chroma at two filter scales and two block sizes. +One train-only shrinkage-Fisher direction was scored in native and JPEG-95 +views, and the joint decision used their minimum above every validation +control. Joint AUC was 0.563 on validation and 0.593 on test. The frozen test +count was 15 of 562 asserted OpenAI rows and one of 228 controls. + +That weak enrichment was not a stable expert. Directions fitted on deterministic +train halves had raw-coordinate cosine 0.148. One half accepted 13 test +positives and two controls, the other accepted none; their test score +correlations were 0.335 on positives and 0.127 on controls, with no overlapping +positive decision. The branch is rejected as an unstable content or export +correlate. Together these experiments reinforce the existing boundary: a new +OpenAI epoch needs pixel-oracle labels or clean same-content counterfactuals, +not further adaptive feature selection on provenance-labeled exports. + +### 2026-08-13: multi-scheme domain generalization and Gemini bicoherence + +A deliberately broader experiment tested whether SynthID could inherit a +generic post-hoc watermark signature instead of a provider carrier. Ninety +source-matched COCO pairs were prepared for each of TrustMark P, Meta VideoSeal +Y-256, classical 64-bit DWT-DCT, and Meta WAM-MIT, always with random payloads. +The official VideoSeal decoder recovered a mean 99.6% of bits across identity, +JPEG-90, 0.8x resize round trip, and 5% crop round trip; its mean pair PSNR was +45.04 dB. WAM recovered 99.5-99.6% across the same views at mean PSNR 38.44 dB. +DWT-DCT supplied an architecture-diverse control, but only identity and resize +were retained as positives because its official decode collapsed to chance +after JPEG and crop. + +A sign-symmetric 3,900-feature spatial rich model first learned residual +co-occurrences in luminance, opponent-color, and Lab channels. Its linear and +quadratic paired-delta variants recognized familiar schemes but did not transfer +to a held-out scheme. A stronger neural follow-up used a fixed residual bank, +worst-family training, cross-family residual transplantation, random sign, +phase, and amplitude, plus phase-scrambled equal-power residuals as hard +negatives. Each of four runs withheld one complete encoder family and all test +source images. Held-out aggregate AUCs were 0.562 for TrustMark, 0.474 for +VideoSeal, 0.497 for DWT-DCT, and 0.437 for WAM. The corresponding strict +true-positive rates were 5.0%, 3.3%, 0%, and 0%. This rejects the available +open-proxy path to a universal single-image detector: even causal paired data +from three distinct schemes did not generalize to a fourth. + +A separate Gemini experiment tested a translation-invariant higher-order +signature. Block bicoherence measured normalized phase coupling at period-16 +triplets satisfying `f1 + f2 = f3`; unlike ordinary phase correlation, the +global image-shift phase cancels from this product. Six Green, cube-root LMS, +and OKLab representations, two residual scales, two block sizes, and three +train-only harmonic counts produced 72 candidates. The data were separated +into harmonic-fit, candidate-selection, threshold-calibration, and final +splits: 30/20/50 Gemini positives and 100/100/100/199 Open Images controls. +The best selection candidate accepted none of 20 positives at the selection +control maximum and had AUC 0.523. After independent recalibration, the final +result was 0 of 50 Gemini positives and 0 of 199 controls, with AUC 0.374. +Period-16 bicoherence is therefore rejected as a scale or origin rescue. The +measured Gemini carrier remains a linear, registered periodic tile rather than +a transferable harmonic cascade. + +### 2026-08-14: remaining Gemini resolution challenges + +The 0.5x gap was tested with a local-consensus detector rather than another +global period-8 score. Each half-scaled image was divided into 64-pixel patches; +the 90th percentile of full-template patch correlations was selected without +using the locked test. It accepted 33 of 49 validation positives and zero of 166 +validation controls at the frozen 0.552176 threshold, then 27 of 52 locked-test +positives and zero of 140 controls. This looked like a useful low-resolution +expert but did not survive scale-matched specificity testing. + +The frozen challenge symmetrically resized every remaining Spaces control and a +separate 1,000-image Open Images reserve to 0.5x. The expert accepted 61 of +10,906 Spaces controls and 22 of 1,000 Open Images controls. All 83 errors were +rescored with the prespecified feature inventory. A negative-Green patch-spread +gate could remove them post hoc, but retained only 12 of 106 base-positive +development hits. On the later August 7-10 cohort the original patch expert +accepted 67 of 234 Google-provenance rows and 8 of 689 controls, while the new +conjunction retained only 2 Google rows and zero controls. The broad challenge +rejects the base expert and the temporal result rejects the post-hoc repair. +No 0.5x claim is added. + +The large-image window family was completed separately. One centered, +phase-aligned 2,048-square registered window accepted 1 of 37 candidates above +10 megapixels and zero of 89 controls. Scoring every phase-aligned 2,048-square +window and accepting the maximum raised recall to only 2 of 37, still with zero +controls; the maximum control decision score was already 0.9677 against the 1.0 +runtime boundary. Together with the previously rejected fixed-window consensus +and full-frame resize, these results leave the 10-megapixel ceiling intact. + +Near-ceiling windows, color spaces, and JPEG-paired phase directions did not +provide a missing large-image expert. Three aligned windows of approximately +10 megapixels retained 1 of 37 candidates and zero of 89 controls. A native +fixed-score plus Green mid-band rule retained 15 of 19 candidates in four exact +recurring geometries and accepted one of 11,417 resized Spaces controls, but +collapsed to 0 of 19 after JPEG 90. A codebook fitted to the minimum native/JPEG +period-16 correlation selected RGB Green mid-band on the pre-July split. It +retained 23 of 26 candidates and zero of 40 controls there, then 10 of 11 and 7 +of 49 on July. Lab and OKLab candidates failed on the same hard control. The +apparent native signal is a codec or upscaler lattice rather than a robust +watermark invariant. + +Those 37 candidate labels came from Google C2PA and an inference that Google +media is likely watermarked, not an individual Google pixel-decoder result. +They cannot support a production recall claim. The next large-image step is an +immutable SHA-to-verdict Gemini batch, followed by fitting only on +oracle-detected files. The first live attempt on 2026-08-14 was stopped by +Google reCAPTCHA before the first new file submission; no CAPTCHA bypass was +attempted. + +### 2026-08-14: official large oracle and opponent-color runtime branch + +The frozen Gemini batch was subsequently submitted without bypassing CAPTCHA. +Every submitted file had AI provenance removed while preserving the decoded +RGBA fingerprint. Google returned a pixel-positive verdict for seven of seven +usable submissions across 3072x5504, 5504x3072, 5632x3072, 6336x2688, +3584x4800, 5016x3328, and 4096x4096. An eighth case was indeterminate after the +verifier quota ended. This is direct evidence for seven positives, not a label +upgrade for the remaining 30 C2PA-inferred candidates. + +Revisiting the native period-16 fold by opponent color exposed the missing +separation. Google candidates preserve strong Red-minus-Green and +Blue-minus-Yellow phase agreement across every phase-aligned 2048-square +window, while natural resize aliases do not preserve the complete conjunction. +The frozen large-v1 rule requires minimum fixed score `0.14`, minimum +Red-minus-Green spatial correlation `0.90`, minimum Blue-minus-Yellow spatial +correlation `0.70`, and a minimum signed Blue-minus-Yellow mid-band correlation +at or below `-0.15`. The 3072x5504 portrait geometry additionally requires the +maximum Green mid-band correlation to stay at or below `0.06`. Its public score +is the minimum normalized gate margin and crosses at `1.0`. + +The rule retained all 37 inferred candidates, including all seven official +pixel positives. It accepted none of 17,417 exposed COCO, Open Images, and +Spaces controls after the portrait alias gate. Because those controls informed +the final gate, they do not certify the operating point. The constants were +then challenged through the actual runtime on a frozen, decoded-pixel-deduped +local holdout: 2,000 COCO val2017 images excluded from the earlier 3,000-image +large color-phase challenge plus 637 unique Picsum controls deduplicated from +1,000 files. Four large geometries and four resampling kernels were balanced. +The result was 0 of 2,637 detections, with maximum score `0.0592777965`; the 95% +zero-error upper bound is about 0.114%. These source collections existed before +the freeze and supported other experiments, so the result is feature-unseen +rather than a fresh-acquisition holdout. + +The post-freeze Open Images acquisition was too slow to complete the planned +3,000-image challenge, but its completed subset provides an independent source +audit. Incomplete `.aria2` files, all prior Open Images decoded-pixel hashes, +and duplicates were excluded before scoring, leaving 41 controls whose IDs and +URLs had been frozen after the constants. The unchanged runtime accepted 0/41; +the maximum score was `0.4083013324`. This small audit supports the source +boundary but does not replace the 2,637-control feature-unseen interval. + +A prespecified post-freeze codec stress test re-encoded the seven official +positives at unchanged dimensions. Native detection was 7/7, while JPEG-95 and +JPEG-90 were each 0/7. Thresholds were not refitted. Large-v1 is therefore a +native-pixel and lossless-copy detector, not a lossy-transcode detector. + +The old fixed-only rule still crosses on four locally saved rows representing +three unique pixel groups. One group is an Adobe file whose C2PA explicitly +reports Google SynthID present. The other two remain queued for an official +Gemini verdict and are not counted as clean negatives. The runtime branch is +therefore positive-only and does not turn a miss into an absence claim. + +Production routing now selects the fixed expert from 1 through 10 megapixels +and the large expert above 10 through 18 megapixels when both sides are at least +2048 pixels. The score exporter emits fixed, registered, and large observations +separately, preventing the large result from being mislabeled as a fixed score. +The router accepts a registered or large threshold crossing only within that +expert's measured scope and otherwise abstains. + +### 2026-08-14: final OpenAI local rescue checks + +An assertion-contrast probe at the dominant exact 1254-square geometry tested +RGB, OKLab, and cube-root-LMS period-8 channel directions without treating +missing assertion as a clean label. Validation selected `srgb:p8:c2`, accepting +45 of 259 asserted rows and zero of 13 same-provider plus three other-provider +rows. The locked test accepted 39 of 233 asserted rows, zero of 15 same-provider +rows, and one of three other-provider rows. The all-row audit accepted 185 of +992 asserted rows, one of 55 same-provider rows without an assertion, and one +of 13 other-provider rows. A safe boundary above either wider control maximum +retained only a small minority of the asserted cohort. Exact geometry therefore +does not turn the weak OpenAI component into a production detector. + +An independent published-decoder veto was then tested with the official +InvisMark checkpoint. The downloaded checkpoint hash was fixed before use, and +its bundled self-test reached 0.997 mean confidence with 0.97 recovered-bit +accuracy. In contrast, OpenAI DTCWT hits, OpenAI misses, exact-1254 hits and +misses, same-provider controls, Google, Microsoft, Canva, and unrelated controls +all had median confidence near 0.18. The decoder is working, but its watermark +family is absent from these cohorts; it cannot distinguish SynthID-bearing +OpenAI pixels from renderer confounds. + +Two representation-level missing-codeword hypotheses were also rejected. +DTCWT modulus computed before spatial period folding selected off-diagonal +phase coherence and retained 5 of 158 locked-test OpenAI rows versus zero of 135 +controls, but zero of 40 fresh OpenAI rows. Whitening the period-8 feature space +and clustering up to 16 codewords retained 5 of 158 and zero controls, then one +of 40 fresh OpenAI rows and one of 50 fresh controls. Neither captured the known +oracle-positive false negative. These were the remaining prespecified phase, +wavelet, codeword, and external-decoder variants; none earns a local runtime +route. + +### 2026-08-14: production OpenAI verifier boundary + +OpenAI's official Content Provenance API now supplies the production-grade +OpenAI pixel verdict that the local experiments could not justify. The runtime +integration is deliberately a separate `verify-openai-synthid` command, never +an implicit `identify` call. It requires the independent `verify` extra, +`OPENAI_API_KEY`, endpoint access, and `--acknowledge-upload`. + +The implementation establishes metadata independence before the request. It +computes a decoded RGBA fingerprint, strips AI provenance metadata to a +temporary copy, verifies that no AI markers survived and that the format and +pixel fingerprint stayed identical, enforces the documented 50 MiB limit, and +uploads only the temporary PNG, JPEG, or WebP. It parses exactly one `synthid` +entry and deliberately ignores the independent C2PA outcome. C2PA-only +positives, missing or duplicate SynthID fields, altered pixels, surviving +metadata, unsupported formats, and 400/404/429 failures are covered by mocked +tests. No credentialed API request was made during implementation because no +API key was available. + +A separate live smoke used OpenAI's public web verifier after running the exact +production metadata-stripping and decoded-RGBA equality checks. Two OpenAI +images at opposite 3:2 orientations returned `SynthID detected` and `Content +Credentials not detected`. A Google SynthID oracle-positive control and a COCO +natural control returned OpenAI `SynthID not detected`, again with Content +Credentials absent. This confirms pixel-only, provider-specific semantics for +the integration, but four fixed cases do not estimate statistical error rates +and do not constitute a live SDK/API transport test. + +This closes the production OpenAI detection surface through an official remote +backend, not by relabeling the incomplete local period-8 expert. It does not +expand the research oracle authority. The endpoint is not eligible for Zero +Data Retention, and its documentation prohibits repeated reverse-engineering or +evasion queries, so it cannot supply adaptive labels for detector or remover +optimization without separate authorization. + ## Decision record The program has four possible honest outcomes per provider: @@ -1736,11 +3278,10 @@ The program has four possible honest outcomes per provider: Stopping at a failed gate is a result. It prevents a local surrogate, export fingerprint, or quality metric from being mistaken for control over SynthID. -## Immediate first milestone +## Historical first milestone -The first milestone produces no shipping code. Harness code already exists for -items 1 and 5; milestone delivery means a completed evidence-bearing artifact, -not merely an available script. It delivers: +The first local-research milestone was defined to produce evidence rather than +shipping code. Its scope was: 1. the private-corpus schema and auditor; 2. an OpenAI authorization decision for use of the remote provenance verifier; @@ -1750,7 +3291,10 @@ not merely an available script. It delivers: 6. the D2 low-texture carrier report with leave-one-group-out results; 7. a go or no-go decision for real-image detector training. -Only after this milestone should implementation of D3 and D4 begin. +The completed experiments produced a no-go decision for a local universal +OpenAI detector. The later official remote backend is a separate production +route and does not retroactively turn provenance-labeled exports into pixel +oracle training data. ## Primary sources @@ -1758,3 +3302,5 @@ Only after this milestone should implementation of D3 and D4 begin. - OpenAI, [ChatGPT Images 2.0 system card](https://deploymentsafety.openai.com/chatgpt-images-2-0/automated-evaluations-and-adversarial-testing). - Google, [Verify AI-generated images, videos, and audio](https://support.google.com/gemini/answer/16722517?hl=en). - Gowal et al., [SynthID-Image: Image watermarking at internet scale](https://arxiv.org/abs/2510.09263). +- Meta, [VideoSeal](https://github.com/facebookresearch/videoseal). +- Meta, [Watermark Anything](https://github.com/facebookresearch/watermark-anything). diff --git a/docs/synthid.md b/docs/synthid.md index 33fdf57..e3f45e3 100644 --- a/docs/synthid.md +++ b/docs/synthid.md @@ -61,7 +61,39 @@ The paper does not disclose the internal architecture of the encoder/decoder networks (layer types, capacity). The external variant SynthID-O is available to partners; the production internal variant is not published. -### 1.2 How it differs from classical DWT-DCT watermarks +### 1.2 Patent-backed architecture clues + +A related [DeepMind patent family](https://patents.google.com/patent/US12094474B1/en), +filed in 2023 and naming several authors of the SynthID-Image paper, describes +the likely architectural design space in more detail. Patent alternatives are +not proof that every option is deployed in production SynthID, so the following +items are constraints and research clues rather than implementation claims. + +The patent's image example forms a content-dependent residual, `x' = x + g(x)`. +It describes a U-Net-like watermark generator with convolution, attention, and +skip connections; a separately trained convolutional decoder; optional message +or secret-key injection into intermediate layers; and paired training on clean +and watermarked images under sampled differentiable transformations. It also +allows an image to be resized to a trained target size for watermarking and then +resized back to its original dimensions. + +Most importantly for blind detection, a +[continuation patent](https://patents.google.com/patent/US20250149048A1/en) +describes groups or ensembles of paired encoder/decoder networks. A decoder can +be trained not to recognize marks from another pair, while deployment can +select one pair or combine several outputs. This supplies a concrete mechanism +for coexisting watermark versions, provider-specific states, or distinct +codewords without requiring one universal pixel template. + +The fixed periodic carriers measured in this project should therefore be +treated as linear experts for particular observable states, not as the +definition of SynthID. A broader detector needs independently validated experts +for additional encoder states and transformations, followed by joint +false-positive calibration. The paper's separate detection logit and two-sided +conformal decision provide the correct target behavior: present, absent, or +abstain when neither hypothesis is supported. + +### 1.3 How it differs from classical DWT-DCT watermarks The open watermarks used by Stable Diffusion / SDXL / FLUX (via the `imwatermark` library) use classical **DWT-DCT** frequency-domain embedding: a @@ -85,7 +117,7 @@ embeds in fixed, predictable frequency bins, making it brittle to any operation that hits those bins (e.g., JPEG re-quantization wipes it cleanly at quality <= 90). -### 1.3 Payload capacity +### 1.4 Payload capacity SynthID-O (the external/partnership variant) encodes: @@ -105,7 +137,7 @@ paper separates watermark **detection** (is this watermarked?) from payload **recovery** (what does the payload say?): the detection path is what oracles like the Gemini app's "Verify with SynthID" exercise. -### 1.4 Where in the pipeline it lives +### 1.5 Where in the pipeline it lives ``` [Diffusion model] @@ -268,6 +300,17 @@ chance, and fixed phase scoring collapsed under crop. Wavelets therefore remain a gated late-fusion ablation, while complex spectral analysis remains a residual-discovery tool rather than a presence score. +Expanding that proxy to four causally paired watermark families did not produce +a universal residual detector. TrustMark P, VideoSeal Y-256, DWT-DCT, and WAM +each used 90 source-matched images and random payloads; official decoders +validated which transformed positives still carried readable marks. A fixed +steganalysis residual bank and a neural model trained with cross-family +residual mixing, sign and phase augmentation, and equal-power phase-scrambled +hard negatives were evaluated leave-one-family-out. The four unseen-family +AUCs were 0.562, 0.474, 0.497, and 0.437. A network can therefore learn each +available encoder without learning a transferable class called "neural +watermark." This open-proxy route is not evidence for Google or OpenAI SynthID. + The same conclusion held on a larger temporally split closed corpus. A three-seed normalized-patch ensemble reached 0.884 AUC, but its threshold above all validation negatives produced a test false positive and detected only @@ -308,6 +351,14 @@ limited JPEG and crop robustness. The pickle-free research implementation is `scripts/synthid_periodic_tile_probe.py`; exact evidence and caveats are in the [`2048 periodic-tile experiment`](synthid-detector-removal-plan.md#2026-08-10-2048-periodic-tile-detector). +The historical 2048 reports retain aggregate counts and model hashes, but not +the 111 fitting paths. A later exact-1024 audit demonstrated that perceptual +siblings can cross an image-level split despite different file and decoded-pixel +hashes, so the old 2048 train/validation/test split cannot be retrospectively +cleared of that leakage mode. Its external control challenges remain valid; +its positive rates are conditional on the historical split. The next model +calibration must preserve content-group membership across every partition. + An aligned-subtraction ablation strengthened the mechanism finding without clearing the oracle gate. At a discovery-selected amplitude, it reversed both the fixed-tile and independently fitted phase decisions on all 30 test images @@ -318,6 +369,64 @@ only one accepted image, so these local reversals remain surrogate evidence, not verified SynthID removal. The exact controls and caveats are recorded in the linked experiment section. +A later adaptive projection reduced that local cost substantially. It repeats +the frozen unit-norm 16x16 carrier over the decoded image and uses a bounded +scalar search for the smallest subtraction amplitude that reaches a requested +fixed-tile score. There is no generative model, spatial optimizer, or change of +image dimensions. Among the 30 historical 2048x2048 ablation sources, 27 were +positive under both the fixed-tile and independent phase rules before editing. +Searching only the scalar amplitude cleared both rules on all 27 at a median +59.83 dB PSNR and 0.99890 SSIM; the minima were 55.19 dB and 0.99705. Applying +the same selected amplitudes to a one-pixel cyclic shift of the carrier cleared +none of the 27. Of the 16 sources whose carrier survived an otherwise identical +JPEG-95 round trip, aligned projection cleared all 16 after JPEG-95 while the +shifted control cleared one. + +The same native-period edit works without resizing when dimensions are not +multiples of 16: repeat the carrier past both image boundaries and crop it to +the decoded geometry. A deterministic development challenge selected three +first-carrier-positive Spaces images from each of the 20 most frequent native +geometries, for 60 images total. Searching to a zero fixed-tile target cleared +60 of 60, compared with 5 of 60 norm-matched one-pixel-shifted controls. Median +fidelity was 60.37 dB PSNR and 0.99919 SSIM; the minima were 53.56 dB and +0.99581. A more conservative target score of -0.25 retained 60 of 60 native +local clearances at median 57.70 dB PSNR and 0.99863 SSIM, with minima of +52.38 dB and 0.99443. + +The frozen -0.25 rule was then evaluated once on one deterministic first-carrier +positive from every native geometry represented by that Spaces subset: 647 +images at 647 distinct decoded sizes. It reached the local target on all 647, +with no maximum-amplitude failure. Median fidelity was 57.67 dB PSNR and +0.99867 SSIM; the worst case was 51.78 dB and 0.99400. Among the 323 images +whose source carrier survived JPEG-95, the aligned candidate cleared 323 of +323 after the same codec round trip, compared with 58 of 323 one-pixel-shifted +controls. At the deeper transform margin, shifted controls also clear more +often natively, 153 of 647, so exact carrier phase remains causal but the edit +is no longer phase-exclusive on every content. The all-geometry corpus was +already inspected during detector development and contributes mechanism +coverage, not an independent generalization split. + +The conservative target also survived every tested transform for which the +same transformed source remained locally positive: 32 of 32 JPEG-95, 13 of 13 +JPEG-90, 8 of 8 JPEG-85, 15 of 15 WebP-95, and 54 of 54 0.75x resize round +trips. The shifted controls cleared 7, 1, 1, 5, and 17 of those respective +source-positive subsets. These results establish fast, geometry-independent +control over the recovered carrier and quantify the quality-versus-transport +margin. They do not establish SynthID removal. The previous provider check +showed that Google can remain positive after a locally favorable analytical +edit, and this adaptive recipe has not received a matching-provider negative +oracle verdict. It therefore remains a research candidate rather than a public +removal path. + +The reproducible local tool is +`scripts/synthid_adaptive_carrier_suppress.py`. It accepts one locally positive +image, writes a lossless PNG without overwriting existing files, and records +the input/output hashes, target, selected amplitude, scores, fidelity, and +runtime in JSON. Its default -0.25 target is the transform-margin setting above. +The tool intentionally remains outside the public package CLI and refuses +locally negative inputs; its output status names carrier suppression rather +than provider-verified removal. + The immutable oracle-batch and result evaluator are implemented. On 2026-08-10, four new 2048x2048 Gemini images were generated after the rule was frozen and registered as a 20-request confirmatory batch. The first source group exhausted @@ -422,6 +531,24 @@ notable because it is also the most powerful removal attack. The paper notes that diffusion regeneration "leaves a strong reconstruction signature from the diffusion prior." +A newer [MarkNull study](https://arxiv.org/abs/2608.10166), accepted at USENIX +Security 2026, evaluates a no-box latent-space attack on 20 Imagen-3 images and +checks the outputs with Gemini's SynthID verification flow. MarkNull, its +amortized variant, and most advanced regeneration or adversarial baselines +reached 100% attack success in that small evaluation; a simple VAE round trip +reached 0%. The per-image method inverts the input with a clean public diffusion +proxy, optimizes its latent to decorrelate the reconstructed initial noise, and +constrains LPIPS, MSE, and SSIM before decoding it again. + +This is important independent evidence that removal need not know SynthID's +carrier or decoder, but it is still a generative latent reconstruction, not the +pixel-only analytical path pursued here. The paper's milder SynthID setting +reports PSNR 25.36 dB and SSIM 0.80 at 100% attack success. Those values do not +meet this project's 40 dB median PSNR and 0.99 median SSIM release gate, despite +the paper's favorable composite quality score. MarkNull-A's reported 0.50-second +runtime and 6.3 GB VRAM are attractive as a future optional fallback, but they +do not establish a fast, visually lossless pixel-only remover. + --- ## 3. Detectability and verifier access @@ -462,15 +589,51 @@ codecs, video, or future epochs. The evidence and gates are documented in 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. The fixed threshold +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. Runtime support is therefore the -challenged interval of 1,000,000 through 18,000,000 decoded pixels rather than +16; it also produced no accepted view. Fixed-branch support is therefore the +challenged interval of 1,000,000 through 10,000,000 decoded pixels rather than an enumerated width-by-height registry. The original 2048x2048 scores remain exactly unchanged. +Above 10,000,000 through 18,000,000 decoded pixels, the native 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 @@ -502,10 +665,276 @@ The measured input range is 250,000 through 10,000,000 decoded pixels. Both dimensions must also be at least 64 pixels so every canonical quadrant can retain one 16-by-16 carrier tile. +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. + +The research router in `scripts/synthid_routed_expert_bank.py` makes this policy +explicit and always abstains on fixed-only evidence. It records fixed, +registered, and large observations separately: the registered threshold is the +positive route in its measured scale-search range, while the large threshold is +the positive route in its disjoint native large-image range. It never 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. + This mode is not the default because the bounded search is materially slower; scale registration is explicit and `identify` keeps the native detector. The registered mode does not reliably detect 0.5x carriers and does not make the -detector universal across crop, codecs, carrier epochs, or providers. +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 @@ -538,6 +967,99 @@ 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 @@ -548,6 +1070,118 @@ 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. @@ -580,7 +1214,370 @@ 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). -### 3.3 How our tool recognizes SynthID from provenance +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. + +### 3.3 Official OpenAI pixel verification + +`remove-ai-watermarks verify-openai-synthid image.png +--acknowledge-upload` uses OpenAI's official Content Provenance API for the +OpenAI half of the production detector. This is not the weak local period-8 +research expert and it does not use C2PA as a proxy. + +Before making one request, the command strips AI provenance metadata into a +temporary PNG, JPEG, or WebP file and compares hashes of the decoded RGBA raster +before and after. It refuses the upload if any AI marker survived, if the format +or pixels changed, or if the sanitized file exceeds 50 MiB. It then reads +exactly one independent `type == "synthid"` response entry and ignores the C2PA +entry. The source is not modified. Tests deliberately cover C2PA-only positive +responses, pixel mutation, surviving metadata, malformed response shapes, and +documented access and rate-limit failures. + +A live 2026-08-14 web-verifier smoke used the same sanitization invariant. Two +metadata-stripped, pixel-identical OpenAI images at 1536 by 1024 and 1024 by +1536 both returned `SynthID detected` with `Content Credentials not detected`. +An oracle-positive Google SynthID image and a COCO photograph, sanitized through +the same path, both returned OpenAI `SynthID not detected` and no Content +Credentials. This directly validates pixel-only and provider-specific behavior +for the production design. Four files are only a functional smoke test, not a +false-positive calibration, and the credentialed SDK endpoint itself was not +called because no API key was available. + +This backend is deliberately excluded from `identify`: verification uploads +the sanitized raster to OpenAI, the endpoint is not eligible for Zero Data +Retention, and explicit acknowledgement is required. It is suitable for +individual supported OpenAI provenance checks, not adaptive detector fitting or +removal search. The API documentation prohibits repeated queries for watermark +reverse engineering or evasion. As with every detector in this project, +`not_detected` is absence of recognized evidence, not proof of human authorship. + +### 3.4 How our tool recognizes SynthID from provenance We recognize SynthID indirectly from supported C2PA evidence; this is not a pixel watermark decode. Google states that all media generated by its tools is @@ -601,7 +1598,7 @@ This is why: - A quiet `identify` output is not proof that SynthID was removed -- it only means the metadata signal is gone. -### 3.4 Oracle scope: each vendor detects only their own +### 3.5 Oracle scope: each vendor detects only their own OpenAI's current Content Provenance API documentation says it checks supported OpenAI signals and is not a general-purpose AI detector. Google's current Gemini @@ -620,7 +1617,7 @@ A Google-SynthID image reads clean on openai.com/verify. An OpenAI image reads clean in the Gemini oracle. They are different payloads within the same framework. -### 3.5 Video verification and attack harness +### 3.6 Video verification and attack harness Gemini's built-in verification flow reports whether and where it detects Google SynthID in a video. This remains a proprietary oracle: invoke `@synthid`, use @@ -1064,3 +2061,7 @@ reproducible verification requires a fixed seed. 9. OpenAI. **ChatGPT Images 2.0 system card.** https://deploymentsafety.openai.com/chatgpt-images-2-0/automated-evaluations-and-adversarial-testing + +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 diff --git a/pyproject.toml b/pyproject.toml index df686a9..0847d90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -123,6 +123,11 @@ qwen-zimage = [ trustmark = [ "trustmark>=0.8.0", ] +# Official remote OpenAI SynthID verification. The command strips AI provenance +# metadata and proves pixel identity before upload; it is never called implicitly. +verify = [ + "openai>=2.52.0", +] # Universal region eraser backend -- big-LaMa via onnxruntime (Carve/LaMa-ONNX, # Apache-2.0). CPU, no torch. Model (~200 MB) is downloaded on first use and # cached by huggingface_hub; it is never bundled in this repo. The default cv2 @@ -163,7 +168,7 @@ dev = [ ] # ``qwen-zimage`` already pulls ``diffusion``; naming both would suggest diffusion is # independently sufficient for a removal, which it is not. -all = ["remove-ai-watermarks[video,heif,detect,trustmark,qwen-zimage,lama,migan]"] +all = ["remove-ai-watermarks[video,heif,detect,trustmark,qwen-zimage,lama,migan,verify]"] [project.scripts] remove-ai-watermarks = "remove_ai_watermarks.cli:main" diff --git a/scripts/synthid_adaptive_carrier_suppress.py b/scripts/synthid_adaptive_carrier_suppress.py new file mode 100644 index 0000000..10b8626 --- /dev/null +++ b/scripts/synthid_adaptive_carrier_suppress.py @@ -0,0 +1,195 @@ +"""Suppress the recovered periodic carrier without image regeneration. + +This research tool controls the project's local fixed-template score. A local +score reversal is not evidence that a provider SynthID verifier will stop +detecting the image. +""" + +from __future__ import annotations + +import json +import logging +import math +import time +from pathlib import Path +from typing import TYPE_CHECKING, Any + +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 ( + TILE_THRESHOLD, + _geometry_supported, # pyright: ignore[reportPrivateUsage] + _load_template, # pyright: ignore[reportPrivateUsage] + folded_template_score, +) + +if TYPE_CHECKING: + from numpy.typing import NDArray + +log = logging.getLogger(__name__) + + +def apply_template(pixels: NDArray[Any], template: NDArray[Any], *, amplitude: float) -> NDArray[Any]: + """Subtract AMPLITUDE times periodic TEMPLATE from arbitrary RGB PIXELS.""" + return subtract_tiled_template(pixels, template, strength=amplitude) + + +def carrier_score(pixels: NDArray[Any], template: NDArray[Any], sigma: float) -> float: + """Return the local fixed-template carrier score for PIXELS.""" + score, _folded = folded_template_score(pixels, template, sigma) + return score + + +def find_minimum_amplitude( + pixels: NDArray[Any], + template: NDArray[Any], + sigma: float, + *, + target_score: float, + maximum_amplitude: float, + iterations: int, +) -> tuple[float, NDArray[Any], float]: + """Return the smallest searched amplitude whose score reaches TARGET_SCORE.""" + if not math.isfinite(target_score): + raise ValueError("target score must be finite") + if not math.isfinite(maximum_amplitude) or maximum_amplitude <= 0.0: + raise ValueError("maximum amplitude must be finite and positive") + if iterations < 1: + raise ValueError("iterations must be positive") + maximum_pixels = apply_template(pixels, template, amplitude=maximum_amplitude) + maximum_score = carrier_score(maximum_pixels, template, sigma) + if maximum_score > target_score: + raise ValueError( + f"maximum amplitude {maximum_amplitude:g} reached score {maximum_score:.6f}, " + f"above target {target_score:.6f}" + ) + + low = 0.0 + high = maximum_amplitude + best_pixels = maximum_pixels + best_score = maximum_score + for _iteration in range(iterations): + middle = (low + high) / 2.0 + candidate = apply_template(pixels, template, amplitude=middle) + candidate_score = carrier_score(candidate, template, sigma) + if candidate_score <= target_score: + high = middle + best_pixels = candidate + best_score = candidate_score + else: + low = middle + return high, best_pixels, best_score + + +def suppress_carrier( + pixels: NDArray[Any], + *, + target_score: float = -0.25, + maximum_amplitude: float = 40.0, + iterations: int = 8, +) -> tuple[NDArray[Any], dict[str, float | int | str]]: + """Suppress a locally detected carrier and return pixels plus measurements.""" + height, width = pixels.shape[:2] + if not _geometry_supported(width, height): + raise ValueError(f"unsupported decoded geometry: {width}x{height}") + if target_score >= TILE_THRESHOLD: + raise ValueError(f"target score must be below the detector threshold {TILE_THRESHOLD:.6f}") + template, sigma, _model_height, _model_width, tile_height, tile_width = _load_template() + original_score = carrier_score(pixels, template, sigma) + if original_score < TILE_THRESHOLD: + raise ValueError( + f"local carrier is not detected: score {original_score:.6f} is below threshold {TILE_THRESHOLD:.6f}" + ) + + started = time.perf_counter() + amplitude, candidate, candidate_score = find_minimum_amplitude( + pixels, + template, + sigma, + target_score=target_score, + maximum_amplitude=maximum_amplitude, + iterations=iterations, + ) + quality = measure(pixels, candidate, name="adaptive-carrier", path=Path("")) + return candidate, { + "status": "local_carrier_suppressed", + "detector_scope": "local fixed-template carrier, not provider-verified SynthID removal", + "width": width, + "height": height, + "tile_height": tile_height, + "tile_width": tile_width, + "threshold": TILE_THRESHOLD, + "target_score": target_score, + "original_score": original_score, + "candidate_score": candidate_score, + "amplitude": amplitude, + "maximum_amplitude": maximum_amplitude, + "iterations": iterations, + "residual_rms": quality.residual_rms, + "psnr_db": quality.psnr_db, + "ssim": quality.ssim, + "changed_pixel_fraction": quality.changed_pixel_fraction, + "elapsed_seconds": time.perf_counter() - started, + } + + +@click.command() +@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path)) +@click.argument("output", type=click.Path(dir_okay=False, path_type=Path)) +@click.option("--target-score", type=float, default=-0.25, show_default=True) +@click.option("--maximum-amplitude", type=click.FloatRange(min=0.0, min_open=True), default=40.0, show_default=True) +@click.option("--iterations", type=click.IntRange(min=1), default=8, show_default=True) +@click.option("--report-out", type=click.Path(dir_okay=False, path_type=Path)) +def main( + source: Path, + output: Path, + target_score: float, + maximum_amplitude: float, + iterations: int, + report_out: Path | None, +) -> None: + """Write a lossless PNG with the recovered local carrier suppressed.""" + logging.basicConfig(level=logging.INFO, format="%(message)s") + if output.suffix.lower() != ".png": + raise click.BadParameter("output must use the .png extension", param_hint="output") + report_path = report_out or output.with_suffix(".json") + for path in (output, report_path): + if path.exists(): + raise click.ClickException(f"refusing to overwrite existing file: {path}") + try: + candidate, report = suppress_carrier( + load_rgb(source), # pyright: ignore[reportUnknownArgumentType] + target_score=target_score, + maximum_amplitude=maximum_amplitude, + iterations=iterations, + ) + except ValueError as error: + raise click.ClickException(str(error)) from error + output.parent.mkdir(parents=True, exist_ok=True) + report_path.parent.mkdir(parents=True, exist_ok=True) + Image.fromarray(candidate, mode="RGB").save(output, format="PNG", compress_level=9) + report.update( + { + "source": str(source.resolve()), + "source_sha256": artifact_sha256(source), + "output": str(output.resolve()), + "output_sha256": artifact_sha256(output), + } + ) + report_path.write_text(json.dumps(report, indent=2, allow_nan=False) + "\n", encoding="utf-8") + log.info( + "Suppressed local carrier %.6f -> %.6f at %.2f dB PSNR; wrote %s", + report["original_score"], + report["candidate_score"], + report["psnr_db"], + output, + ) + log.info("Research caveat: this is not provider-verified SynthID removal") + + +if __name__ == "__main__": + main() diff --git a/scripts/synthid_conformal_cascade.py b/scripts/synthid_conformal_cascade.py new file mode 100644 index 0000000..f68a846 --- /dev/null +++ b/scripts/synthid_conformal_cascade.py @@ -0,0 +1,352 @@ +"""Calibrate a versioned SynthID expert bank without forcing binary verdicts. + +This research utility combines already-computed pixel-only expert scores. It +does not inspect provenance, metadata, filenames, or provider labels at +inference. Expert support must be determined from predeclared geometry or model +scope, never from the observed score. + +The clean null is a union test: any supported expert may provide positive +evidence, so its smallest empirical upper-tail p-value receives a Bonferroni +correction. The watermarked hypothesis is itself a union over possible encoder +states and can be rejected only when every configured expert has complete +coverage and gives a small empirical lower-tail p-value. +""" + +from __future__ import annotations + +import bisect +import json +import logging +import math +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Literal, cast + +import click +from synthid_research_manifest import artifact_sha256 + +log = logging.getLogger(__name__) + +CascadeVerdict = Literal["detected", "not_detected", "abstain"] + + +@dataclass(frozen=True) +class ExpertCalibration: + """Frozen positive and negative score distributions for one expert.""" + + name: str + positive_scores: tuple[float, ...] + negative_scores: tuple[float, ...] + higher_is_positive: bool = True + + def __post_init__(self) -> None: + if not self.name: + raise ValueError("expert name must not be empty") + if not self.positive_scores or not self.negative_scores: + raise ValueError(f"expert {self.name!r} needs positive and negative calibration scores") + if not all(math.isfinite(score) for score in (*self.positive_scores, *self.negative_scores)): + raise ValueError(f"expert {self.name!r} contains a non-finite calibration score") + direction = 1.0 if self.higher_is_positive else -1.0 + object.__setattr__(self, "positive_scores", tuple(sorted(direction * score for score in self.positive_scores))) + object.__setattr__(self, "negative_scores", tuple(sorted(direction * score for score in self.negative_scores))) + + def orient(self, score: float) -> float: + """Return SCORE in the common higher-means-more-positive direction.""" + return score if self.higher_is_positive else -score + + +@dataclass(frozen=True) +class CascadeConfig: + """Calibration distributions and two-sided decision levels.""" + + experts: tuple[ExpertCalibration, ...] + positive_alpha: float + negative_alpha: float + coverage_complete: bool + scope: str + + def __post_init__(self) -> None: + if not self.experts: + raise ValueError("at least one expert is required") + names = [expert.name for expert in self.experts] + if len(set(names)) != len(names): + raise ValueError("expert names must be unique") + for label, value in (("positive_alpha", self.positive_alpha), ("negative_alpha", self.negative_alpha)): + if not 0.0 < value <= 1.0: + raise ValueError(f"{label} must be in (0, 1]") + if not self.scope: + raise ValueError("detector scope must not be empty") + + +@dataclass(frozen=True) +class ExpertObservation: + """One expert score, or an explicit unsupported result.""" + + name: str + supported: bool + score: float | None + + def __post_init__(self) -> None: + if not self.name: + raise ValueError("observation expert name must not be empty") + if self.supported: + if self.score is None or not math.isfinite(self.score): + raise ValueError(f"supported expert {self.name!r} needs a finite score") + elif self.score is not None: + raise ValueError(f"unsupported expert {self.name!r} must not provide a score") + + +@dataclass(frozen=True) +class ExpertEvidence: + """Two empirical p-values for one supported expert.""" + + name: str + score: float + clean_null_p_value: float + watermarked_p_value: float + positive_calibration_count: int + negative_calibration_count: int + + +@dataclass(frozen=True) +class CascadeResult: + """Auditable tri-state verdict for one observation record.""" + + verdict: CascadeVerdict + reason: str + clean_null_p_value: float | None + watermarked_p_value: float | None + supported_expert_count: int + configured_expert_count: int + coverage_complete: bool + evidence: tuple[ExpertEvidence, ...] + + +def _upper_tail_p_value(sorted_scores: tuple[float, ...], score: float) -> float: + """Smoothed empirical probability of a calibration score at least SCORE.""" + tail_count = len(sorted_scores) - bisect.bisect_left(sorted_scores, score) + return (tail_count + 1.0) / (len(sorted_scores) + 1.0) + + +def _lower_tail_p_value(sorted_scores: tuple[float, ...], score: float) -> float: + """Smoothed empirical probability of a calibration score at most SCORE.""" + tail_count = bisect.bisect_right(sorted_scores, score) + return (tail_count + 1.0) / (len(sorted_scores) + 1.0) + + +def classify_observations(config: CascadeConfig, observations: tuple[ExpertObservation, ...]) -> CascadeResult: + """Combine one explicit observation from every configured expert.""" + calibration_by_name = {expert.name: expert for expert in config.experts} + observation_by_name = {observation.name: observation for observation in observations} + if len(observation_by_name) != len(observations): + raise ValueError("observation expert names must be unique") + if observation_by_name.keys() != calibration_by_name.keys(): + missing = sorted(calibration_by_name.keys() - observation_by_name.keys()) + unknown = sorted(observation_by_name.keys() - calibration_by_name.keys()) + raise ValueError(f"observations must cover the configured bank; missing={missing}, unknown={unknown}") + + evidence: list[ExpertEvidence] = [] + for calibration in config.experts: + observation = observation_by_name[calibration.name] + if not observation.supported: + continue + if observation.score is None: + raise RuntimeError("validated supported observation lost its score") + oriented_score = calibration.orient(observation.score) + evidence.append( + ExpertEvidence( + name=calibration.name, + score=observation.score, + clean_null_p_value=_upper_tail_p_value(calibration.negative_scores, oriented_score), + watermarked_p_value=_lower_tail_p_value(calibration.positive_scores, oriented_score), + positive_calibration_count=len(calibration.positive_scores), + negative_calibration_count=len(calibration.negative_scores), + ) + ) + + if not evidence: + return CascadeResult( + verdict="abstain", + reason="unsupported", + clean_null_p_value=None, + watermarked_p_value=None, + supported_expert_count=0, + configured_expert_count=len(config.experts), + coverage_complete=config.coverage_complete, + evidence=(), + ) + + supported_count = len(evidence) + clean_null_p_value = min(1.0, supported_count * min(item.clean_null_p_value for item in evidence)) + watermarked_p_value = max(item.watermarked_p_value for item in evidence) + rejects_clean_null = clean_null_p_value <= config.positive_alpha + full_support = supported_count == len(config.experts) + rejects_watermarked = config.coverage_complete and full_support and watermarked_p_value <= config.negative_alpha + + if rejects_clean_null and rejects_watermarked: + verdict: CascadeVerdict = "abstain" + reason = "conflicting_evidence" + elif rejects_clean_null: + verdict = "detected" + reason = "watermarked_hypothesis_supported" + elif rejects_watermarked: + verdict = "not_detected" + reason = "unwatermarked_hypothesis_supported" + elif config.coverage_complete and not full_support: + verdict = "abstain" + reason = "incomplete_support" + elif not config.coverage_complete and watermarked_p_value <= config.negative_alpha: + verdict = "abstain" + reason = "incomplete_coverage" + else: + verdict = "abstain" + reason = "insufficient_evidence" + + return CascadeResult( + verdict=verdict, + reason=reason, + clean_null_p_value=clean_null_p_value, + watermarked_p_value=watermarked_p_value, + supported_expert_count=supported_count, + configured_expert_count=len(config.experts), + coverage_complete=config.coverage_complete, + evidence=tuple(evidence), + ) + + +def _mapping(value: object, label: str) -> dict[str, object]: + if not isinstance(value, dict): + raise ValueError(f"{label} must be an object") + return cast("dict[str, object]", value) + + +def _sequence(value: object, label: str) -> list[object]: + if not isinstance(value, list): + raise ValueError(f"{label} must be an array") + return cast("list[object]", value) + + +def _scores(value: object, label: str) -> tuple[float, ...]: + scores: list[float] = [] + for index, score in enumerate(_sequence(value, label)): + if isinstance(score, bool) or not isinstance(score, (int, float)): + raise ValueError(f"{label}[{index}] must be a number") + scores.append(float(score)) + return tuple(scores) + + +def _number(value: object, label: str) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f"{label} must be a number") + return float(value) + + +def _boolean(value: object, label: str) -> bool: + if not isinstance(value, bool): + raise ValueError(f"{label} must be a boolean") + return value + + +def _string(value: object, label: str) -> str: + if not isinstance(value, str) or not value: + raise ValueError(f"{label} must be a non-empty string") + return value + + +def load_config(path: Path) -> CascadeConfig: + """Load a schema-versioned calibration manifest.""" + payload = _mapping(json.loads(path.read_text(encoding="utf-8")), "calibration manifest") + if payload.get("schema_version") != 1: + raise ValueError("unsupported calibration manifest schema") + experts: list[ExpertCalibration] = [] + for index, raw_expert in enumerate(_sequence(payload.get("experts"), "experts")): + expert = _mapping(raw_expert, f"experts[{index}]") + experts.append( + ExpertCalibration( + name=_string(expert.get("name"), f"experts[{index}].name"), + positive_scores=_scores(expert.get("positive_scores"), f"experts[{index}].positive_scores"), + negative_scores=_scores(expert.get("negative_scores"), f"experts[{index}].negative_scores"), + higher_is_positive=_boolean( + expert.get("higher_is_positive", True), + f"experts[{index}].higher_is_positive", + ), + ) + ) + return CascadeConfig( + experts=tuple(experts), + positive_alpha=_number(payload.get("positive_alpha"), "positive_alpha"), + negative_alpha=_number(payload.get("negative_alpha"), "negative_alpha"), + coverage_complete=_boolean(payload.get("coverage_complete", False), "coverage_complete"), + scope=_string(payload.get("scope"), "scope"), + ) + + +def load_observation_records(path: Path) -> list[tuple[str, tuple[ExpertObservation, ...]]]: + """Load named score records with explicit support for every expert.""" + payload = _mapping(json.loads(path.read_text(encoding="utf-8")), "observation manifest") + if payload.get("schema_version") != 1: + raise ValueError("unsupported observation manifest schema") + records: list[tuple[str, tuple[ExpertObservation, ...]]] = [] + for record_index, raw_record in enumerate(_sequence(payload.get("records"), "records")): + record = _mapping(raw_record, f"records[{record_index}]") + record_id = _string(record.get("id"), f"records[{record_index}].id") + observations: list[ExpertObservation] = [] + for observation_index, raw_observation in enumerate( + _sequence(record.get("observations"), f"records[{record_index}].observations") + ): + observation = _mapping(raw_observation, f"records[{record_index}].observations[{observation_index}]") + raw_score = observation.get("score") + observations.append( + ExpertObservation( + name=_string( + observation.get("name"), + f"records[{record_index}].observations[{observation_index}].name", + ), + supported=_boolean( + observation.get("supported", False), + f"records[{record_index}].observations[{observation_index}].supported", + ), + score=None + if raw_score is None + else _number(raw_score, f"records[{record_index}].observations[{observation_index}].score"), + ) + ) + records.append((record_id, tuple(observations))) + return records + + +@click.command() +@click.argument("calibration_path", type=click.Path(exists=True, dir_okay=False, path_type=Path)) +@click.argument("observation_path", type=click.Path(exists=True, dir_okay=False, path_type=Path)) +@click.option("--report-out", type=click.Path(dir_okay=False, path_type=Path), required=True) +def main(calibration_path: Path, observation_path: Path, report_out: Path) -> None: + """Classify precomputed expert scores using CALIBRATION_PATH.""" + logging.basicConfig(level=logging.INFO, format="%(message)s") + config = load_config(calibration_path) + rows: list[dict[str, object]] = [] + verdict_counts: dict[CascadeVerdict, int] = {"detected": 0, "not_detected": 0, "abstain": 0} + for record_id, observations in load_observation_records(observation_path): + result = classify_observations(config, observations) + verdict_counts[result.verdict] += 1 + rows.append({"id": record_id, "result": asdict(result)}) + report_out.parent.mkdir(parents=True, exist_ok=True) + report_out.write_text( + json.dumps( + { + "schema_version": 1, + "scope": config.scope, + "calibration_sha256": artifact_sha256(calibration_path), + "observation_sha256": artifact_sha256(observation_path), + "counts": verdict_counts, + "records": rows, + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + log.info("Wrote %d conformal cascade verdicts: %s", len(rows), report_out) + + +if __name__ == "__main__": + main() diff --git a/scripts/synthid_routed_detector.py b/scripts/synthid_routed_detector.py new file mode 100644 index 0000000..02081cb --- /dev/null +++ b/scripts/synthid_routed_detector.py @@ -0,0 +1,69 @@ +"""Score images and apply the conservative SynthID expert-bank router.""" + +from __future__ import annotations + +import json +import logging +from dataclasses import asdict +from pathlib import Path +from typing import TypedDict + +import click +from synthid_conformal_cascade import ExpertObservation +from synthid_routed_expert_bank import classify_routed +from synthid_runtime_expert_scores import ExpertScore, score_path + +log = logging.getLogger(__name__) + + +class RoutedImage(TypedDict): + """One scored image and its conservative routed result.""" + + id: str + path: str + width: int + height: int + observations: list[ExpertScore] + result: dict[str, object] + + +def detect_path(path: Path) -> RoutedImage: + """Score and conservatively route one image PATH.""" + scored = score_path(path) + observations = tuple( + ExpertObservation( + name=observation["name"], + supported=observation["supported"], + score=observation["score"], + ) + for observation in scored["observations"] + ) + return {**scored, "result": asdict(classify_routed(observations))} + + +@click.command() +@click.argument("images", nargs=-1, required=True, type=click.Path(exists=True, dir_okay=False, path_type=Path)) +@click.option("--report-out", type=click.Path(dir_okay=False, path_type=Path), required=True) +def main(images: tuple[Path, ...], report_out: Path) -> None: + """Score and route IMAGES through the conservative pixel expert bank.""" + logging.basicConfig(level=logging.INFO, format="%(message)s") + records = [detect_path(path) for path in images] + detected = sum(record["result"].get("verdict") == "detected" for record in records) + report_out.parent.mkdir(parents=True, exist_ok=True) + report_out.write_text( + json.dumps( + { + "schema_version": 1, + "counts": {"detected": detected, "abstain": len(records) - detected}, + "records": records, + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + log.info("Wrote %d routed image verdicts: %s", len(records), report_out) + + +if __name__ == "__main__": + main() diff --git a/scripts/synthid_routed_expert_bank.py b/scripts/synthid_routed_expert_bank.py new file mode 100644 index 0000000..d146426 --- /dev/null +++ b/scripts/synthid_routed_expert_bank.py @@ -0,0 +1,151 @@ +"""Route SynthID pixel experts without an unsafe union of overlapping positives. + +The registered expert owns its measured scale-search range and the large expert +owns its separately challenged native large-image range. A fixed-only crossing +remains auditable evidence but cannot produce a bank-level detection. The bank +never claims absence because encoder-version coverage is incomplete. +""" + +from __future__ import annotations + +import json +import logging +import sys +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Literal + +import click + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT_ROOT / "src")) + +from synthid_conformal_cascade import ( # noqa: E402 + ExpertObservation, + load_observation_records, +) +from synthid_research_manifest import artifact_sha256 # noqa: E402 + +from remove_ai_watermarks import synthid_detector # noqa: E402 + +log = logging.getLogger(__name__) + +RoutedVerdict = Literal["detected", "abstain"] + + +@dataclass(frozen=True) +class RoutedBankResult: + """One conservative bank-level decision with every expert score retained.""" + + verdict: RoutedVerdict + reason: str + selected_expert: str | None + fixed_supported: bool + fixed_score: float | None + registered_supported: bool + registered_score: float | None + large_supported: bool + large_score: float | None + + +def classify_routed(observations: tuple[ExpertObservation, ...]) -> RoutedBankResult: + """Route explicit fixed, registered, and large observations without an OR rule.""" + by_name = {observation.name: observation for observation in observations} + if len(by_name) != len(observations): + raise ValueError("observation expert names must be unique") + expected = { + synthid_detector.DETECTOR_ID, + synthid_detector.REGISTERED_DETECTOR_ID, + synthid_detector.LARGE_DETECTOR_ID, + } + if by_name.keys() != expected: + missing = sorted(expected - by_name.keys()) + unknown = sorted(by_name.keys() - expected) + raise ValueError(f"observations must cover the routed bank; missing={missing}, unknown={unknown}") + + fixed = by_name[synthid_detector.DETECTOR_ID] + registered = by_name[synthid_detector.REGISTERED_DETECTOR_ID] + large = by_name[synthid_detector.LARGE_DETECTOR_ID] + if large.supported: + if large.score is None: + raise RuntimeError("validated large observation lost its score") + if large.score >= synthid_detector.LARGE_THRESHOLD: + verdict: RoutedVerdict = "detected" + reason = "large_threshold_crossed" + selected_expert: str | None = large.name + else: + verdict = "abstain" + reason = "large_below_threshold" + selected_expert = None + elif registered.supported: + if registered.score is None: + raise RuntimeError("validated registered observation lost its score") + if registered.score >= synthid_detector.REGISTERED_THRESHOLD: + verdict = "detected" + reason = "registered_threshold_crossed" + selected_expert = registered.name + else: + verdict = "abstain" + reason = ( + "fixed_only_ambiguous" + if fixed.supported and fixed.score is not None and fixed.score >= synthid_detector.TILE_THRESHOLD + else "registered_below_threshold" + ) + selected_expert = None + elif fixed.supported: + verdict = "abstain" + reason = ( + "fixed_only_geometry_uncalibrated" + if fixed.score is not None and fixed.score >= synthid_detector.TILE_THRESHOLD + else "registered_unsupported" + ) + selected_expert = None + else: + verdict = "abstain" + reason = "unsupported" + selected_expert = None + + return RoutedBankResult( + verdict=verdict, + reason=reason, + selected_expert=selected_expert, + fixed_supported=fixed.supported, + fixed_score=fixed.score, + registered_supported=registered.supported, + registered_score=registered.score, + large_supported=large.supported, + large_score=large.score, + ) + + +@click.command() +@click.argument("observation_path", type=click.Path(exists=True, dir_okay=False, path_type=Path)) +@click.option("--report-out", type=click.Path(dir_okay=False, path_type=Path), required=True) +def main(observation_path: Path, report_out: Path) -> None: + """Route a three-expert pixel score manifest from OBSERVATION_PATH.""" + logging.basicConfig(level=logging.INFO, format="%(message)s") + counts: dict[RoutedVerdict, int] = {"detected": 0, "abstain": 0} + rows: list[dict[str, object]] = [] + for record_id, observations in load_observation_records(observation_path): + result = classify_routed(observations) + counts[result.verdict] += 1 + rows.append({"id": record_id, "result": asdict(result)}) + report_out.parent.mkdir(parents=True, exist_ok=True) + report_out.write_text( + json.dumps( + { + "schema_version": 1, + "observation_sha256": artifact_sha256(observation_path), + "counts": counts, + "records": rows, + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + log.info("Wrote %d routed expert-bank verdicts: %s", len(rows), report_out) + + +if __name__ == "__main__": + main() diff --git a/scripts/synthid_runtime_expert_scores.py b/scripts/synthid_runtime_expert_scores.py new file mode 100644 index 0000000..c363cf1 --- /dev/null +++ b/scripts/synthid_runtime_expert_scores.py @@ -0,0 +1,119 @@ +"""Export fixed, large, and scale-registered SynthID observations for images. + +The output is an input manifest for ``synthid_conformal_cascade.py``. All +experts consume decoded RGB pixels only. Unsupported geometry is recorded +explicitly and never represented by a synthetic score. +""" + +from __future__ import annotations + +import json +import logging +import sys +from pathlib import Path +from typing import TYPE_CHECKING, TypedDict + +import click +import numpy as np + +if TYPE_CHECKING: + from numpy.typing import NDArray + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT_ROOT / "src")) + +from synthid_pixel_attack import load_rgb # noqa: E402 +from synthid_research_manifest import artifact_sha256 # noqa: E402 + +from remove_ai_watermarks import synthid_detector # noqa: E402 + +log = logging.getLogger(__name__) + +FIXED_EXPERT_NAME = synthid_detector.DETECTOR_ID +REGISTERED_EXPERT_NAME = synthid_detector.REGISTERED_DETECTOR_ID +LARGE_EXPERT_NAME = synthid_detector.LARGE_DETECTOR_ID + + +class ExpertScore(TypedDict): + """One JSON-safe runtime expert observation.""" + + name: str + supported: bool + score: float | None + + +class ScoredImage(TypedDict): + """One hash-pinned image with every runtime expert observation.""" + + id: str + path: str + width: int + height: int + observations: list[ExpertScore] + + +def _observation(name: str, supported: bool, score: float | None) -> ExpertScore: + return {"name": name, "supported": supported, "score": score} + + +def score_pixels(pixels: NDArray[np.uint8]) -> list[ExpertScore]: + """Return explicit fixed, registered, and large observations for RGB PIXELS.""" + if pixels.ndim != 3 or pixels.shape[2] != 3 or pixels.dtype != np.uint8: + raise ValueError("pixels must be an RGB uint8 array") + bgr_pixels = np.ascontiguousarray(pixels[:, :, ::-1]) + native = synthid_detector.detect_synthid("decoded-image", image=bgr_pixels) + registered = synthid_detector.detect_synthid("decoded-image", image=bgr_pixels, register_scale=True) + fixed = _observation(FIXED_EXPERT_NAME, False, None) + large = _observation(LARGE_EXPERT_NAME, False, None) + native_observation = _observation(native.detector, native.status != "unsupported", native.score) + if native.detector == FIXED_EXPERT_NAME: + fixed = native_observation + elif native.detector == LARGE_EXPERT_NAME: + large = native_observation + else: + raise RuntimeError(f"unexpected default SynthID expert: {native.detector}") + return [ + fixed, + _observation(REGISTERED_EXPERT_NAME, registered.status != "unsupported", registered.score), + large, + ] + + +def score_path(path: Path) -> ScoredImage: + """Decode PATH once and return one hash-pinned observation record.""" + pixels = load_rgb(path) + height, width = pixels.shape[:2] + return { + "id": artifact_sha256(path), + "path": str(path), + "width": width, + "height": height, + "observations": score_pixels(pixels), + } + + +@click.command() +@click.argument("images", nargs=-1, required=True, type=click.Path(exists=True, dir_okay=False, path_type=Path)) +@click.option("--report-out", type=click.Path(dir_okay=False, path_type=Path), required=True) +def main(images: tuple[Path, ...], report_out: Path) -> None: + """Score IMAGES with every shipped pixel expert.""" + logging.basicConfig(level=logging.INFO, format="%(message)s") + records = [score_path(path) for path in images] + report_out.parent.mkdir(parents=True, exist_ok=True) + report_out.write_text( + json.dumps( + { + "schema_version": 1, + "experts": [FIXED_EXPERT_NAME, REGISTERED_EXPERT_NAME, LARGE_EXPERT_NAME], + "records": records, + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + log.info("Wrote %d three-expert score records: %s", len(records), report_out) + + +if __name__ == "__main__": + main() diff --git a/scripts/synthid_tile_attack.py b/scripts/synthid_tile_attack.py index e2737a3..61a96b7 100644 --- a/scripts/synthid_tile_attack.py +++ b/scripts/synthid_tile_attack.py @@ -25,15 +25,26 @@ log = logging.getLogger(__name__) def subtract_tiled_template(pixels: np.ndarray, template: np.ndarray, *, strength: float) -> np.ndarray: """Subtract STRENGTH times TEMPLATE repeated over PIXELS.""" - if strength < 0.0: - raise ValueError("strength must be nonnegative") + if not np.isfinite(strength) or strength < 0.0: + raise ValueError("strength must be finite and nonnegative") + if pixels.ndim != 3 or pixels.shape[2] != 3: + raise ValueError("pixels must have shape (height, width, 3)") + if template.ndim != 3 or template.shape[2] != 3: + raise ValueError("template must have shape (tile height, tile width, 3)") height, width = pixels.shape[:2] tile_height, tile_width = template.shape[:2] - if template.shape[2:] != (3,) or height % tile_height != 0 or width % tile_width != 0: - raise ValueError("template does not tile the pixel geometry") - repeated = np.tile(template, (height // tile_height, width // tile_width, 1)) - result = pixels.astype(np.float64) - strength * repeated - return np.clip(np.rint(result), 0, 255).astype(np.uint8) + if tile_height == 0 or tile_width == 0: + raise ValueError("template dimensions must be positive") + + result = np.empty_like(pixels, dtype=np.uint8) + repeats_x = (width + tile_width - 1) // tile_width + for top in range(0, height, 256): + bottom = min(top + 256, height) + template_rows = template[np.arange(top, bottom) % tile_height] + repeated = np.tile(template_rows, (1, repeats_x, 1))[:, :width] + stripe = pixels[top:bottom].astype(np.float64) - strength * repeated + result[top:bottom] = np.clip(np.rint(stripe), 0, 255).astype(np.uint8) + return result def parse_positive_floats(value: str, *, option_name: str) -> tuple[float, ...]: diff --git a/src/remove_ai_watermarks/__init__.py b/src/remove_ai_watermarks/__init__.py index 4ee4f8c..031a188 100644 --- a/src/remove_ai_watermarks/__init__.py +++ b/src/remove_ai_watermarks/__init__.py @@ -14,6 +14,7 @@ High-level API (lazy, so ``import remove_ai_watermarks`` stays cheap):: raiw.remove_video_invisible("in.mp4", "out.mp4") # oracle-certified SynthID removal raiw.remove_video_visible("in.mp4", "out.mp4") # stable visible video-mark removal raiw.detect_synthid("in.png") # -> SynthIDDetection + raiw.verify_openai_synthid("in.png", acknowledge_upload=True) # remote For a provenance verdict use the ``identify`` submodule:: @@ -39,6 +40,7 @@ __all__ = [ "BatchSummary", "InvisibleOptions", "MetadataStripIncomplete", + "OpenAISynthIDDetection", "RemoveAllResult", "SynthIDDetection", "__version__", @@ -53,6 +55,7 @@ __all__ = [ "remove_video_metadata", "remove_video_visible", "remove_visible", + "verify_openai_synthid", "visible_provenance", ] @@ -67,6 +70,7 @@ if TYPE_CHECKING: remove_visible, visible_provenance, ) + from remove_ai_watermarks.openai_provenance import OpenAISynthIDDetection, verify_openai_synthid from remove_ai_watermarks.synthid_detector import SynthIDDetection, detect_synthid from remove_ai_watermarks.video import ( identify_video, @@ -111,4 +115,8 @@ def __getattr__(name: str) -> object: from remove_ai_watermarks import synthid_detector return getattr(synthid_detector, name) + if name in ("OpenAISynthIDDetection", "verify_openai_synthid"): + from remove_ai_watermarks import openai_provenance + + return getattr(openai_provenance, name) raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/remove_ai_watermarks/cli.py b/src/remove_ai_watermarks/cli.py index e2219a3..2bdaad2 100644 --- a/src/remove_ai_watermarks/cli.py +++ b/src/remove_ai_watermarks/cli.py @@ -1363,6 +1363,52 @@ def cmd_detect_synthid(source: Path, as_json: bool, register_scale: bool) -> Non ) +# ── Official OpenAI SynthID verification ── +@main.command("verify-openai-synthid") +@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path)) +@click.option( + "--acknowledge-upload", + is_flag=True, + help="Confirm upload of a pixel-identical, AI-metadata-stripped copy to OpenAI.", +) +@click.option("--json", "as_json", is_flag=True, help="Emit the verifier result as JSON.") +def cmd_verify_openai_synthid(source: Path, acknowledge_upload: bool, as_json: bool) -> None: + """Use OpenAI's official verifier on pixels, independently of C2PA. + + The command strips AI provenance metadata from a temporary copy, proves the + decoded pixels are unchanged, and uploads that copy to OpenAI. It reads only + the SynthID result. The source file is never modified. + """ + if not acknowledge_upload: + raise click.ClickException( + "this command uploads a temporary pixel-identical copy to OpenAI; pass --acknowledge-upload to continue" + ) + from remove_ai_watermarks.openai_provenance import verify_openai_synthid + + source = _validate_image(source) + try: + result = verify_openai_synthid(source, acknowledge_upload=True) + except (OSError, RuntimeError, ValueError) 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 OpenAI SynthID pixel watermark: {result.status}") + console.print(" Detector: official OpenAI Content Provenance API") + if result.model is not None: + console.print(f" Model: {result.model}") + if result.generated_at is not None: + console.print(f" Generated at: {result.generated_at}") + console.print( + " Input: AI provenance metadata was stripped and decoded pixels were preserved.\n" + " Scope: supported OpenAI SynthID only. A not_detected result is not proof\n" + " that the image is human-created or contains no other watermark." + ) + + # ── Provenance identification ── @main.command("identify") @click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path)) diff --git a/src/remove_ai_watermarks/openai_provenance.py b/src/remove_ai_watermarks/openai_provenance.py new file mode 100644 index 0000000..e78a799 --- /dev/null +++ b/src/remove_ai_watermarks/openai_provenance.py @@ -0,0 +1,232 @@ +"""Official OpenAI SynthID verification with metadata-independent input. + +The Content Provenance API returns C2PA and SynthID outcomes independently. +This module removes AI provenance metadata before upload, proves that the +decoded RGBA raster did not change, and then consumes only the SynthID result. +It is intentionally separate from :func:`identify`: calling it uploads one +sanitized raster to OpenAI and therefore always requires an explicit user +action. + +The OpenAI SDK is optional. Imports remain lazy so local and metadata-only +paths do not acquire a network client dependency. +""" + +from __future__ import annotations + +import hashlib +import importlib +import json +import logging +import tempfile +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Literal, cast + +log = logging.getLogger(__name__) + +OpenAISynthIDStatus = Literal["detected", "not_detected"] + +DETECTOR_ID = "openai-content-provenance-synthid-v1" +INSTALL_HINT = "install the verification extra: uv add 'remove-ai-watermarks[verify]'" +MAX_UPLOAD_BYTES = 50 * 1024 * 1024 +_FORMAT_DETAILS = { + "JPEG": ("image/jpeg", ".jpg"), + "PNG": ("image/png", ".png"), + "WEBP": ("image/webp", ".webp"), +} + + +@dataclass(frozen=True) +class OpenAISynthIDDetection: + """One official OpenAI pixel-watermark verdict.""" + + status: OpenAISynthIDStatus + model: str | None + generated_at: str | None + api_created_at: int | None + detector: str = DETECTOR_ID + ai_metadata_stripped: bool = True + pixels_preserved: bool = True + + @property + def detected(self) -> bool: + """Whether the official verifier recognized an OpenAI SynthID signal.""" + return self.status == "detected" + + def to_dict(self) -> dict[str, str | int | bool | None]: + """Return a JSON-safe result without a local path or C2PA outcome.""" + return { + "status": self.status, + "model": self.model, + "generated_at": self.generated_at, + "api_created_at": self.api_created_at, + "detector": self.detector, + "ai_metadata_stripped": self.ai_metadata_stripped, + "pixels_preserved": self.pixels_preserved, + } + + +def is_available() -> bool: + """True when the optional OpenAI SDK is installed.""" + from remove_ai_watermarks.optional_deps import module_available + + return module_available("openai") + + +def _pixel_fingerprint(path: Path) -> tuple[str, str]: + """Return the PIL format and a bounded-memory hash of decoded RGBA pixels.""" + from PIL import Image + + with Image.open(path) as image: + image.load() + image_format = image.format + if image_format not in _FORMAT_DETAILS: + supported = ", ".join(sorted(_FORMAT_DETAILS)) + actual = image_format or "unknown" + raise ValueError(f"OpenAI SynthID verification supports {supported} images; got {actual}") + + digest = hashlib.sha256() + digest.update(f"{image.width}x{image.height}:RGBA\0".encode()) + # Hash bands instead of materializing a second full-image byte string. + for top in range(0, image.height, 128): + bottom = min(top + 128, image.height) + digest.update(image.crop((0, top, image.width, bottom)).convert("RGBA").tobytes()) + return image_format, digest.hexdigest() + + +def _response_mapping(response: Any) -> Mapping[str, Any]: + """Normalize an SDK model or test double to the documented response mapping.""" + if isinstance(response, Mapping): + return cast("Mapping[str, Any]", response) + model_dump = getattr(response, "model_dump", None) + if callable(model_dump): + dumped = model_dump(mode="json") + if isinstance(dumped, Mapping): + return cast("Mapping[str, Any]", dumped) + raise RuntimeError("OpenAI Content Provenance returned an unexpected response type") + + +def _optional_string(entry: Mapping[str, Any], field: str) -> str | None: + value = entry.get(field) + if value is None or isinstance(value, str): + return value + raise RuntimeError(f"OpenAI SynthID result has an invalid {field!r} field") + + +def _parse_synthid_result(payload: Mapping[str, Any]) -> OpenAISynthIDDetection: + """Read exactly one SynthID entry and deliberately ignore C2PA entries.""" + raw_results = payload.get("results") + if not isinstance(raw_results, list): + raise RuntimeError("OpenAI Content Provenance response has no results list") + results = cast("list[Any]", raw_results) + synthid_entries: list[Mapping[str, Any]] = [] + for raw_entry in results: + if isinstance(raw_entry, Mapping): + entry = cast("Mapping[str, Any]", raw_entry) + if entry.get("type") == "synthid": + synthid_entries.append(entry) + if len(synthid_entries) != 1: + raise RuntimeError(f"OpenAI Content Provenance returned {len(synthid_entries)} SynthID results; expected one") + + synthid = synthid_entries[0] + outcome = synthid.get("outcome") + if outcome not in ("detected", "not_detected"): + raise RuntimeError(f"OpenAI SynthID result has an unsupported outcome: {outcome!r}") + created_at = payload.get("created_at") + if created_at is not None and (not isinstance(created_at, int) or isinstance(created_at, bool)): + raise RuntimeError("OpenAI Content Provenance response has an invalid 'created_at' field") + return OpenAISynthIDDetection( + status=outcome, + model=_optional_string(synthid, "model"), + generated_at=_optional_string(synthid, "generated_at"), + api_created_at=created_at, + ) + + +def _default_client() -> Any: + if not is_available(): + raise RuntimeError(f"OpenAI SynthID verification needs the OpenAI SDK; {INSTALL_HINT}") + openai_module = importlib.import_module("openai") + client_factory = cast("Callable[[], Any]", openai_module.OpenAI) + try: + client = client_factory() + except Exception as exc: + raise RuntimeError(f"could not initialize the OpenAI client: {exc}") from exc + if not hasattr(client, "content_provenance_checks"): + raise RuntimeError(f"OpenAI SynthID verification needs openai>=2.52.0; {INSTALL_HINT}") + return client + + +def _request_error(exc: Exception) -> RuntimeError: + status_code = getattr(exc, "status_code", None) + if status_code == 400: + detail = "OpenAI rejected the image as malformed, unsupported, or blocked" + elif status_code == 404: + detail = "the OpenAI organization does not have Content Provenance API access" + elif status_code == 429: + detail = "the OpenAI Content Provenance API rate limit was exceeded" + else: + detail = f"OpenAI Content Provenance request failed: {exc}" + return RuntimeError(detail) + + +def verify_openai_synthid( + image_path: str | Path, + *, + acknowledge_upload: bool = False, + client: Any | None = None, +) -> OpenAISynthIDDetection: + """Verify OpenAI SynthID after stripping AI metadata without changing pixels. + + This function performs one remote request and uploads a temporary sanitized + copy of the image. It never uses C2PA as a fallback and never interprets a + negative result as proof that the image is human-created. + """ + if not acknowledge_upload: + raise ValueError( + "OpenAI SynthID verification uploads a temporary pixel-identical copy; " + "pass acknowledge_upload=True to continue" + ) + source = Path(image_path) + source_format, source_fingerprint = _pixel_fingerprint(source) + media_type, suffix = _FORMAT_DETAILS[source_format] + + with tempfile.TemporaryDirectory(prefix="remove-ai-watermarks-openai-") as directory: + sanitized = Path(directory) / f"upload{suffix}" + from remove_ai_watermarks.metadata import strip_and_verify + + stripped, remaining = strip_and_verify(source, sanitized, keep_standard=True) + if remaining: + fields = ", ".join(sorted(remaining)) + raise RuntimeError(f"refusing upload because AI provenance metadata survived stripping: {fields}") + stripped_format, stripped_fingerprint = _pixel_fingerprint(stripped) + if stripped_format != source_format or stripped_fingerprint != source_fingerprint: + raise RuntimeError("refusing upload because metadata stripping changed the decoded pixels") + upload_bytes = stripped.stat().st_size + if upload_bytes > MAX_UPLOAD_BYTES: + raise ValueError("sanitized image exceeds the OpenAI Content Provenance 50 MiB upload limit") + + api_client = client if client is not None else _default_client() + if not hasattr(api_client, "content_provenance_checks"): + raise RuntimeError("OpenAI client does not expose content_provenance_checks; openai>=2.52.0 is required") + request_context = { + "endpoint": "/v1/content_provenance_checks", + "filename": sanitized.name, + "media_type": media_type, + "bytes": upload_bytes, + "pixel_sha256": source_fingerprint, + } + log.info("OpenAI Content Provenance request: %s", json.dumps(request_context, sort_keys=True)) + try: + with stripped.open("rb") as upload: + response = api_client.content_provenance_checks.create( + file=(sanitized.name, upload, media_type), + ) + except Exception as exc: + log.exception("OpenAI Content Provenance request failed: %s", json.dumps(request_context, sort_keys=True)) + raise _request_error(exc) from exc + + payload = _response_mapping(response) + log.info("OpenAI Content Provenance response: %s", json.dumps(payload, default=str, sort_keys=True)) + return _parse_synthid_result(payload) diff --git a/src/remove_ai_watermarks/synthid_detector.py b/src/remove_ai_watermarks/synthid_detector.py index ee79500..220a80a 100644 --- a/src/remove_ai_watermarks/synthid_detector.py +++ b/src/remove_ai_watermarks/synthid_detector.py @@ -26,6 +26,7 @@ SynthIDDetectionStatus = Literal["detected", "not_detected", "unsupported"] DETECTOR_ID = "synthid-periodic-tile-v2" REGISTERED_DETECTOR_ID = "synthid-periodic-tile-registered-v2" +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: @@ -42,6 +43,20 @@ REGISTERED_MIN_SIDE = 64 # The registered score is the minimum normalized margin across its amplitude, # spectral-candidate, and high-frequency agreement gates. REGISTERED_THRESHOLD = 1.0 +# 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 the pixel extra: uv add 'remove-ai-watermarks[pixels]'" @@ -73,6 +88,32 @@ class SynthIDDetection: } +@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 the optional numeric runtime is installed.""" from remove_ai_watermarks.optional_deps import module_available @@ -222,6 +263,12 @@ def _registered_geometry_supported(width: int, height: int) -> bool: ) +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 folded_template_score( pixels: NDArray[Any], template: NDArray[Any], @@ -239,6 +286,98 @@ def folded_template_score( 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, *, @@ -258,11 +397,19 @@ def detect_synthid( if image.ndim != 3 or image.shape[2] != 3: raise ValueError("image must be a three-channel BGR array") height, width = image.shape[:2] - geometry_supported = ( - _registered_geometry_supported(width, height) if register_scale else _geometry_supported(width, height) - ) - threshold = REGISTERED_THRESHOLD if register_scale else TILE_THRESHOLD - detector_id = REGISTERED_DETECTOR_ID if register_scale else DETECTOR_ID + large_mode = not register_scale and width * height > LARGE_MIN_PIXELS + if register_scale: + geometry_supported = _registered_geometry_supported(width, height) + threshold = REGISTERED_THRESHOLD + detector_id = REGISTERED_DETECTOR_ID + elif large_mode: + geometry_supported = _large_geometry_supported(width, height) + threshold = LARGE_THRESHOLD + detector_id = LARGE_DETECTOR_ID + else: + geometry_supported = _geometry_supported(width, height) + threshold = TILE_THRESHOLD + detector_id = DETECTOR_ID if not geometry_supported: return SynthIDDetection( status="unsupported", @@ -290,6 +437,8 @@ def detect_synthid( from remove_ai_watermarks._synthid_registered import registered_score score = registered_score(pixels, template, sigma) + elif large_mode: + score = large_image_components(pixels, template, sigma).decision_score else: score, _folded = folded_template_score(pixels, template, sigma) return SynthIDDetection( diff --git a/tests/test_api.py b/tests/test_api.py index 6b59986..35802ef 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -18,12 +18,14 @@ CHATGPT = SAMPLES / "chatgpt-1.png" class TestTopLevelExports: def test_lazy_reexports_resolve(self): - from remove_ai_watermarks import synthid_detector + from remove_ai_watermarks import openai_provenance, synthid_detector 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.OpenAISynthIDDetection is openai_provenance.OpenAISynthIDDetection def test_unknown_attribute_raises(self): with pytest.raises(AttributeError): diff --git a/tests/test_cli.py b/tests/test_cli.py index 149b8ce..d842c88 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -785,6 +785,82 @@ class TestDetectSynthIDCommand: assert "Bounded spatial-scale registration was enabled" in result.output +class TestVerifyOpenAISynthIDCommand: + def test_help_names_upload_and_pixel_independence(self, runner): + result = runner.invoke(main, ["verify-openai-synthid", "--help"]) + + assert result.exit_code == 0 + assert "--acknowledge-upload" in result.output + assert "independently of C2PA" in result.output + + def test_upload_requires_explicit_acknowledgement(self, runner, tmp_clean_png, monkeypatch): + from remove_ai_watermarks import openai_provenance + + called = False + + def verify(_source, *, acknowledge_upload): + nonlocal called + assert acknowledge_upload is True + called = True + + monkeypatch.setattr(openai_provenance, "verify_openai_synthid", verify) + + result = runner.invoke(main, ["verify-openai-synthid", str(tmp_clean_png)]) + + assert result.exit_code != 0 + assert "pass --acknowledge-upload" in result.output + assert called is False + + def test_json_result_is_machine_readable(self, runner, tmp_clean_png, monkeypatch): + from remove_ai_watermarks import openai_provenance + + expected = openai_provenance.OpenAISynthIDDetection( + status="not_detected", + model=None, + generated_at=None, + api_created_at=1_778_000_000, + ) + monkeypatch.setattr( + openai_provenance, + "verify_openai_synthid", + lambda _source, *, acknowledge_upload: expected if acknowledge_upload else None, + ) + + result = runner.invoke( + main, + ["verify-openai-synthid", str(tmp_clean_png), "--acknowledge-upload", "--json"], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload == expected.to_dict() + assert "c2pa" not in payload + + def test_text_result_preserves_negative_scope(self, runner, tmp_clean_png, monkeypatch): + from remove_ai_watermarks import openai_provenance + + expected = openai_provenance.OpenAISynthIDDetection( + status="not_detected", + model=None, + generated_at=None, + api_created_at=None, + ) + monkeypatch.setattr( + openai_provenance, + "verify_openai_synthid", + lambda _source, *, acknowledge_upload: expected if acknowledge_upload else None, + ) + + result = runner.invoke( + main, + ["verify-openai-synthid", str(tmp_clean_png), "--acknowledge-upload"], + ) + + assert result.exit_code == 0, result.output + assert "AI provenance metadata was stripped" in result.output + assert "not proof" in result.output + + class TestBatchCommand: """Tests for the 'batch' subcommand.""" diff --git a/tests/test_openai_provenance.py b/tests/test_openai_provenance.py new file mode 100644 index 0000000..667e67d --- /dev/null +++ b/tests/test_openai_provenance.py @@ -0,0 +1,272 @@ +"""Contract tests for metadata-independent official OpenAI SynthID verification.""" + +from __future__ import annotations + +import io +from types import SimpleNamespace +from typing import TYPE_CHECKING, Any + +import pytest +from PIL import Image + +from remove_ai_watermarks import openai_provenance as provenance + +if TYPE_CHECKING: + from pathlib import Path + + +class _Checks: + def __init__(self, response: Any) -> None: + self.response = response + self.calls: list[tuple[str, bytes, str]] = [] + + def create(self, *, file: tuple[str, Any, str]) -> Any: + filename, stream, media_type = file + self.calls.append((filename, stream.read(), media_type)) + return self.response + + +def _client(response: Any) -> tuple[Any, _Checks]: + checks = _Checks(response) + return SimpleNamespace(content_provenance_checks=checks), checks + + +def _response(*, synthid: str, c2pa: str = "not_detected") -> dict[str, Any]: + return { + "object": "content_provenance_check", + "created_at": 1_778_000_000, + "results": [ + { + "type": "c2pa", + "outcome": c2pa, + "validation_state": "trusted" if c2pa == "detected" else "not_present", + "issuer": "OpenAI OpCo, LLC" if c2pa == "detected" else None, + "model": "metadata-model" if c2pa == "detected" else None, + "generated_at": "2026-07-27T18:34:12Z" if c2pa == "detected" else None, + }, + { + "type": "synthid", + "outcome": synthid, + "model": "pixel-model" if synthid == "detected" else None, + "generated_at": "2026-07-28T18:34:12Z" if synthid == "detected" else None, + }, + ], + } + + +def _verify(image_path: Path, *, client: Any | None = None) -> provenance.OpenAISynthIDDetection: + return provenance.verify_openai_synthid(image_path, acknowledge_upload=True, client=client) + + +def test_upload_requires_explicit_library_acknowledgement(tmp_clean_png: Path) -> None: + with pytest.raises(ValueError, match="acknowledge_upload=True"): + provenance.verify_openai_synthid(tmp_clean_png) + + +def test_c2pa_only_response_is_not_a_synthid_detection(tmp_png_with_ai_metadata: Path) -> None: + client, checks = _client(_response(synthid="not_detected", c2pa="detected")) + + result = _verify(tmp_png_with_ai_metadata, client=client) + + assert result.status == "not_detected" + assert result.model is None + assert result.generated_at is None + assert result.ai_metadata_stripped is True + assert result.pixels_preserved is True + assert len(checks.calls) == 1 + filename, uploaded, media_type = checks.calls[0] + assert filename == "upload.png" + assert media_type == "image/png" + with Image.open(io.BytesIO(uploaded)) as image: + image.load() + assert image.convert("RGBA").getpixel((0, 0)) == (128, 128, 128, 255) + assert "parameters" not in image.info + assert "prompt" not in image.info + + +def test_detected_result_uses_only_synthid_fields(tmp_clean_png: Path) -> None: + client, _checks = _client(_response(synthid="detected", c2pa="not_detected")) + + result = _verify(tmp_clean_png, client=client) + + assert result.detected is True + assert result.model == "pixel-model" + assert result.generated_at == "2026-07-28T18:34:12Z" + assert result.api_created_at == 1_778_000_000 + assert "c2pa" not in result.to_dict() + + +def test_sdk_model_response_is_normalized(tmp_clean_png: Path) -> None: + class SDKModel: + def model_dump(self, *, mode: str) -> dict[str, Any]: + assert mode == "json" + return _response(synthid="detected") + + client, _checks = _client(SDKModel()) + + result = _verify(tmp_clean_png, client=client) + + assert result.status == "detected" + + +@pytest.mark.parametrize( + ("image_format", "suffix", "media_type"), + [("PNG", ".png", "image/png"), ("JPEG", ".jpg", "image/jpeg"), ("WEBP", ".webp", "image/webp")], +) +def test_all_documented_image_formats_preserve_decoded_pixels( + tmp_path: Path, + image_format: str, + suffix: str, + media_type: str, +) -> None: + source = tmp_path / f"source{suffix}" + image = Image.new("RGB", (19, 17)) + image.putdata([((x * 13) % 256, (x * 29) % 256, (x * 47) % 256) for x in range(19 * 17)]) + image.save(source, format=image_format, quality=91) + with Image.open(source) as decoded: + expected = decoded.convert("RGBA").tobytes() + client, checks = _client(_response(synthid="not_detected")) + + _verify(source, client=client) + + filename, uploaded, actual_media_type = checks.calls[0] + assert filename == f"upload{suffix}" + assert actual_media_type == media_type + with Image.open(io.BytesIO(uploaded)) as decoded: + assert decoded.convert("RGBA").tobytes() == expected + + +@pytest.mark.parametrize("results", [[], [{"type": "c2pa", "outcome": "detected"}]]) +def test_missing_synthid_result_is_an_error(tmp_clean_png: Path, results: list[dict[str, str]]) -> None: + client, _checks = _client({"results": results}) + + with pytest.raises(RuntimeError, match="0 SynthID results"): + _verify(tmp_clean_png, client=client) + + +def test_duplicate_synthid_results_are_an_error(tmp_clean_png: Path) -> None: + client, _checks = _client( + { + "results": [ + {"type": "synthid", "outcome": "detected"}, + {"type": "synthid", "outcome": "not_detected"}, + ] + } + ) + + with pytest.raises(RuntimeError, match="2 SynthID results"): + _verify(tmp_clean_png, client=client) + + +def test_pixel_mutation_aborts_before_remote_request( + monkeypatch: pytest.MonkeyPatch, + tmp_clean_png: Path, +) -> None: + from remove_ai_watermarks import metadata + + client, checks = _client(_response(synthid="detected")) + + def mutate(source: Path, output: Path, *, keep_standard: bool) -> tuple[Path, dict[str, str]]: + assert keep_standard is True + with Image.open(source) as image: + changed = image.convert("RGB") + changed.putpixel((0, 0), (0, 0, 0)) + changed.save(output) + return output, {} + + monkeypatch.setattr(metadata, "strip_and_verify", mutate) + + with pytest.raises(RuntimeError, match="changed the decoded pixels"): + _verify(tmp_clean_png, client=client) + assert checks.calls == [] + + +def test_surviving_ai_metadata_aborts_before_remote_request( + monkeypatch: pytest.MonkeyPatch, + tmp_clean_png: Path, +) -> None: + from remove_ai_watermarks import metadata + + client, checks = _client(_response(synthid="detected")) + + def survive(source: Path, output: Path, *, keep_standard: bool) -> tuple[Path, dict[str, str]]: + assert keep_standard is True + output.write_bytes(source.read_bytes()) + return output, {"C2PA": "present"} + + monkeypatch.setattr(metadata, "strip_and_verify", survive) + + with pytest.raises(RuntimeError, match="metadata survived"): + _verify(tmp_clean_png, client=client) + assert checks.calls == [] + + +def test_unsupported_image_format_is_rejected_before_remote_request(tmp_path: Path) -> None: + source = tmp_path / "image.bmp" + Image.new("RGB", (16, 16), color=(1, 2, 3)).save(source) + client, checks = _client(_response(synthid="detected")) + + with pytest.raises(ValueError, match="supports JPEG, PNG, WEBP"): + _verify(source, client=client) + assert checks.calls == [] + + +def test_upload_limit_is_checked_after_sanitizing( + monkeypatch: pytest.MonkeyPatch, + tmp_clean_png: Path, +) -> None: + client, checks = _client(_response(synthid="detected")) + monkeypatch.setattr(provenance, "MAX_UPLOAD_BYTES", 1) + + with pytest.raises(ValueError, match="50 MiB"): + _verify(tmp_clean_png, client=client) + assert checks.calls == [] + + +def test_missing_optional_sdk_has_install_hint( + monkeypatch: pytest.MonkeyPatch, + tmp_clean_png: Path, +) -> None: + monkeypatch.setattr(provenance, "is_available", lambda: False) + + with pytest.raises(RuntimeError, match=r"remove-ai-watermarks\[verify\]"): + _verify(tmp_clean_png) + + +def test_client_configuration_error_is_actionable( + monkeypatch: pytest.MonkeyPatch, + tmp_clean_png: Path, +) -> None: + def fail() -> None: + raise ValueError("OPENAI_API_KEY is missing") + + monkeypatch.setattr(provenance, "is_available", lambda: True) + monkeypatch.setattr(provenance.importlib, "import_module", lambda _name: SimpleNamespace(OpenAI=fail)) + + with pytest.raises(RuntimeError, match=r"could not initialize.*OPENAI_API_KEY"): + _verify(tmp_clean_png) + + +@pytest.mark.parametrize( + ("status_code", "message"), + [(400, "rejected"), (404, "does not have"), (429, "rate limit")], +) +def test_documented_api_errors_are_actionable( + tmp_clean_png: Path, + status_code: int, + message: str, +) -> None: + class APIError(Exception): + pass + + error = APIError("details") + error.status_code = status_code # type: ignore[attr-defined] + + class FailingChecks: + def create(self, *, file: tuple[str, Any, str]) -> None: + raise error + + client = SimpleNamespace(content_provenance_checks=FailingChecks()) + + with pytest.raises(RuntimeError, match=message): + _verify(tmp_clean_png, client=client) diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 1b52e6b..b10609e 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -53,12 +53,13 @@ def test_video_extra_owns_timestamp_dependency(): def test_file_format_and_detector_dependencies_are_independent(): assert "pillow-heif" in _requirement_names("heif") assert "pywavelets" in _requirement_names("detect") + assert "openai" in _requirement_names("verify") def test_extras_use_capability_names_without_legacy_aliases(): extras = set(metadata("remove-ai-watermarks").get_all("Provides-Extra") or []) - assert {"pixels", "heif", "visible", "video", "detect", "diffusion"} <= extras + assert {"pixels", "heif", "visible", "video", "detect", "diffusion", "verify"} <= extras assert {"gpu", "remove", "detect-pywavelets"}.isdisjoint(extras) diff --git a/tests/test_synthid_adaptive_carrier_suppress.py b/tests/test_synthid_adaptive_carrier_suppress.py new file mode 100644 index 0000000..0db0a65 --- /dev/null +++ b/tests/test_synthid_adaptive_carrier_suppress.py @@ -0,0 +1,77 @@ +"""Tests for the research adaptive periodic-carrier suppressor.""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import numpy as np +import pytest + +if TYPE_CHECKING: + from numpy.typing import NDArray + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) + +import synthid_adaptive_carrier_suppress as suppressor # pyright: ignore[reportMissingImports] + + +def repeated_template(height: int, width: int, amplitude: float) -> NDArray[Any]: + """Return a synthetic uint8 image carrying the bundled periodic template.""" + template, _sigma, *_model = suppressor._load_template() + repeats_y = (height + template.shape[0] - 1) // template.shape[0] + repeats_x = (width + template.shape[1] - 1) // template.shape[1] + carrier = np.tile(template, (repeats_y, repeats_x, 1))[:height, :width] + return np.clip(np.rint(128.0 + amplitude * carrier), 0, 255).astype(np.uint8) + + +def test_apply_template_handles_nondivisible_geometry() -> None: + template, _sigma, *_model = suppressor._load_template() + pixels = np.full((65, 67, 3), 128, dtype=np.uint8) + + candidate = suppressor.apply_template(pixels, template, amplitude=8.0) + + assert candidate.shape == pixels.shape + assert candidate.dtype == np.uint8 + assert np.any(candidate != pixels) + + +def test_find_minimum_amplitude_reaches_target() -> None: + template, sigma, *_model = suppressor._load_template() + pixels = repeated_template(128, 130, 80.0) + + amplitude, candidate, score = suppressor.find_minimum_amplitude( + pixels, + template, + sigma, + target_score=-0.25, + maximum_amplitude=160.0, + iterations=10, + ) + + assert 0.0 < amplitude <= 160.0 + assert score <= -0.25 + assert suppressor.carrier_score(candidate, template, sigma) == pytest.approx(score) + + +def test_find_minimum_amplitude_rejects_unreachable_target() -> None: + template, sigma, *_model = suppressor._load_template() + pixels = repeated_template(128, 128, 80.0) + + with pytest.raises(ValueError, match="maximum amplitude"): + suppressor.find_minimum_amplitude( + pixels, + template, + sigma, + target_score=-0.25, + maximum_amplitude=1.0, + iterations=8, + ) + + +def test_suppress_carrier_refuses_local_negative() -> None: + pixels = np.full((1000, 1000, 3), 128, dtype=np.uint8) + + with pytest.raises(ValueError, match="not detected"): + suppressor.suppress_carrier(pixels) diff --git a/tests/test_synthid_conformal_cascade.py b/tests/test_synthid_conformal_cascade.py new file mode 100644 index 0000000..8965802 --- /dev/null +++ b/tests/test_synthid_conformal_cascade.py @@ -0,0 +1,240 @@ +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest +from click.testing import CliRunner + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) + +import synthid_conformal_cascade as cascade + + +def _scores(value: float, count: int = 1999) -> tuple[float, ...]: + return (value,) * count + + +def _expert(name: str, *, higher_is_positive: bool = True) -> cascade.ExpertCalibration: + return cascade.ExpertCalibration( + name=name, + positive_scores=_scores(1.0), + negative_scores=_scores(0.0), + higher_is_positive=higher_is_positive, + ) + + +def _config(*experts: cascade.ExpertCalibration, coverage_complete: bool = False) -> cascade.CascadeConfig: + return cascade.CascadeConfig( + experts=experts, + positive_alpha=0.001, + negative_alpha=0.001, + coverage_complete=coverage_complete, + scope="synthetic test bank", + ) + + +def _observation(name: str, score: float | None, *, supported: bool = True) -> cascade.ExpertObservation: + return cascade.ExpertObservation(name=name, supported=supported, score=score) + + +def test_empirical_tail_p_values_include_ties_and_smoothing() -> None: + scores = (0.1, 0.2, 0.3) + + assert cascade._upper_tail_p_value(scores, 0.3) == 0.5 + assert cascade._upper_tail_p_value(scores, 0.31) == 0.25 + assert cascade._lower_tail_p_value(scores, 0.1) == 0.5 + assert cascade._lower_tail_p_value(scores, 0.09) == 0.25 + + +def test_any_expert_can_detect_with_familywise_correction() -> None: + config = _config(_expert("fixed"), _expert("registered")) + observations = (_observation("fixed", 2.0), _observation("registered", 0.5)) + + result = cascade.classify_observations(config, observations) + + assert result.verdict == "detected" + assert result.reason == "watermarked_hypothesis_supported" + assert result.clean_null_p_value == 0.001 + assert result.watermarked_p_value is not None + assert result.watermarked_p_value > config.negative_alpha + + +def test_familywise_correction_blocks_bank_wide_false_alarm() -> None: + config = _config(_expert("fixed"), _expert("registered"), _expert("version-3")) + + result = cascade.classify_observations( + config, + ( + _observation("fixed", 2.0), + _observation("registered", 0.5), + _observation("version-3", 0.5), + ), + ) + + assert result.verdict == "abstain" + assert result.reason == "insufficient_evidence" + assert result.clean_null_p_value == 0.0015 + + +def test_incomplete_version_coverage_never_claims_absence() -> None: + config = _config(_expert("fixed"), coverage_complete=False) + + result = cascade.classify_observations(config, (_observation("fixed", -1.0),)) + + assert result.verdict == "abstain" + assert result.reason == "incomplete_coverage" + assert result.watermarked_p_value == 0.0005 + + +def test_complete_bank_can_reject_every_watermarked_expert() -> None: + config = _config(_expert("fixed"), _expert("registered"), coverage_complete=True) + + result = cascade.classify_observations( + config, + (_observation("fixed", -1.0), _observation("registered", -1.0)), + ) + + assert result.verdict == "not_detected" + assert result.reason == "unwatermarked_hypothesis_supported" + assert result.watermarked_p_value == 0.0005 + + +def test_watermarked_union_survives_when_one_version_remains_plausible() -> None: + ambiguous = cascade.ExpertCalibration( + name="registered", + positive_scores=_scores(0.0), + negative_scores=_scores(0.0), + ) + config = _config(_expert("fixed"), ambiguous, coverage_complete=True) + + result = cascade.classify_observations( + config, + (_observation("fixed", -1.0), _observation("registered", 0.0)), + ) + + assert result.verdict == "abstain" + assert result.reason == "insufficient_evidence" + assert result.watermarked_p_value == 1.0 + + +def test_missing_geometry_support_prevents_negative_verdict() -> None: + config = _config(_expert("fixed"), _expert("registered"), coverage_complete=True) + + result = cascade.classify_observations( + config, + (_observation("fixed", -1.0), _observation("registered", None, supported=False)), + ) + + assert result.verdict == "abstain" + assert result.reason == "incomplete_support" + + +def test_out_of_distribution_gap_abstains_on_conflicting_evidence() -> None: + config = _config(_expert("fixed"), coverage_complete=True) + + result = cascade.classify_observations(config, (_observation("fixed", 0.5),)) + + assert result.verdict == "abstain" + assert result.reason == "conflicting_evidence" + assert result.clean_null_p_value == 0.0005 + assert result.watermarked_p_value == 0.0005 + + +def test_lower_scores_can_be_oriented_as_positive() -> None: + expert = cascade.ExpertCalibration( + name="inverse", + positive_scores=_scores(-1.0), + negative_scores=_scores(0.0), + higher_is_positive=False, + ) + + result = cascade.classify_observations(_config(expert), (_observation("inverse", -2.0),)) + + assert result.verdict == "detected" + + +def test_observations_must_explicitly_cover_the_expert_bank() -> None: + config = _config(_expert("fixed"), _expert("registered")) + + with pytest.raises(ValueError, match=r"missing=\['registered'\]"): + cascade.classify_observations(config, (_observation("fixed", 2.0),)) + + +def test_cli_writes_hash_pinned_tri_state_report(tmp_path: Path) -> None: + calibration_path = tmp_path / "calibration.json" + observation_path = tmp_path / "observations.json" + report_path = tmp_path / "report.json" + calibration_path.write_text( + json.dumps( + { + "schema_version": 1, + "scope": "synthetic CLI test", + "positive_alpha": 0.001, + "negative_alpha": 0.001, + "coverage_complete": False, + "experts": [ + { + "name": "fixed", + "higher_is_positive": True, + "positive_scores": list(_scores(1.0)), + "negative_scores": list(_scores(0.0)), + } + ], + } + ), + encoding="utf-8", + ) + observation_path.write_text( + json.dumps( + { + "schema_version": 1, + "records": [ + { + "id": "candidate-1", + "observations": [{"name": "fixed", "supported": True, "score": 2.0}], + } + ], + } + ), + encoding="utf-8", + ) + + result = CliRunner().invoke( + cascade.main, + [str(calibration_path), str(observation_path), "--report-out", str(report_path)], + ) + + assert result.exit_code == 0, result.output + report = json.loads(report_path.read_text(encoding="utf-8")) + assert report["scope"] == "synthetic CLI test" + assert report["counts"] == {"detected": 1, "not_detected": 0, "abstain": 0} + assert len(report["calibration_sha256"]) == 64 + assert report["records"][0]["result"]["verdict"] == "detected" + + +def test_loader_rejects_string_boolean_for_complete_coverage(tmp_path: Path) -> None: + calibration_path = tmp_path / "calibration.json" + calibration_path.write_text( + json.dumps( + { + "schema_version": 1, + "scope": "invalid test", + "positive_alpha": 0.001, + "negative_alpha": 0.001, + "coverage_complete": "false", + "experts": [ + { + "name": "fixed", + "positive_scores": [1.0], + "negative_scores": [0.0], + } + ], + } + ), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="coverage_complete must be a boolean"): + cascade.load_config(calibration_path) diff --git a/tests/test_synthid_detector.py b/tests/test_synthid_detector.py index 885a264..8dd952c 100644 --- a/tests/test_synthid_detector.py +++ b/tests/test_synthid_detector.py @@ -93,6 +93,88 @@ def test_registered_geometry_uses_its_measured_pixel_count_range( assert detector._registered_geometry_supported(width, height) is supported +@pytest.mark.parametrize( + ("width", "height", "supported"), + [ + (4883, 2048, True), + (3072, 5504, True), + (2048, 4882, False), + (2047, 6000, False), + (3001, 6000, False), + ], +) +def test_large_geometry_requires_multiple_calibrated_windows( + width: int, + height: int, + supported: bool, +) -> None: + assert detector._large_geometry_supported(width, height) is supported + + +def test_large_window_starts_cover_both_edges_on_carrier_phase() -> None: + starts = detector._large_window_starts(5504) + + assert starts == (0, 2048, 3456) + assert all(start % detector.LARGE_PHASE == 0 for start in starts) + assert starts[-1] + detector.LARGE_WINDOW == 5504 + + +def test_large_components_apply_the_portrait_alias_guard_only_to_its_geometry() -> None: + values = { + "minimum_fixed_score": 0.28, + "minimum_red_green_spatial": 0.95, + "minimum_blue_yellow_spatial": 0.85, + "minimum_blue_yellow_mid_band": -0.30, + "maximum_green_mid_band": 0.061, + } + portrait = detector.LargeImageComponents(width=3072, height=5504, **values) + landscape = detector.LargeImageComponents(width=5504, height=3072, **values) + + assert portrait.decision_score < detector.LARGE_THRESHOLD + assert landscape.decision_score > detector.LARGE_THRESHOLD + + +def test_large_red_green_gate_mutation_changes_the_real_verdict( + monkeypatch: pytest.MonkeyPatch, +) -> None: + width, height = 4883, 2048 + image = np.broadcast_to(np.zeros((1, 1, 3), dtype=np.uint8), (height, width, 3)) + components = detector.LargeImageComponents( + width=width, + height=height, + minimum_fixed_score=0.28, + minimum_red_green_spatial=detector.LARGE_RED_GREEN_SPATIAL_MIN, + minimum_blue_yellow_spatial=0.85, + minimum_blue_yellow_mid_band=-0.30, + maximum_green_mid_band=0.0, + ) + monkeypatch.setattr(detector, "is_available", lambda: True) + monkeypatch.setattr(detector, "_load_template", lambda: (np.zeros((16, 16, 3)), 1.0, 0, 0, 0, 0)) + monkeypatch.setattr(detector, "large_image_components", lambda *_args: components) + + baseline = detector.detect_synthid("unused.png", image=image) + monkeypatch.setattr( + detector, + "LARGE_RED_GREEN_SPATIAL_MIN", + float(np.nextafter(components.minimum_red_green_spatial, np.inf)), + ) + mutated = detector.detect_synthid("unused.png", image=image) + + assert baseline.status == "detected" + assert baseline.detector == detector.LARGE_DETECTOR_ID + assert mutated.status == "not_detected" + + +def test_uncalibrated_narrow_large_geometry_is_unsupported() -> None: + image = np.broadcast_to(np.zeros((1, 1, 3), dtype=np.uint8), (11_000, 1000, 3)) + + result = detector.detect_synthid("unused.png", image=image) + + assert result.status == "unsupported" + assert result.detector == detector.LARGE_DETECTOR_ID + assert result.score is None + + def test_registered_mode_rejects_a_side_too_short_for_quadrants(tmp_path: Path) -> None: path = tmp_path / "too-narrow.png" Image.new("RGB", (32, 7813), "white").save(path) diff --git a/tests/test_synthid_routed_detector.py b/tests/test_synthid_routed_detector.py new file mode 100644 index 0000000..d9d72cc --- /dev/null +++ b/tests/test_synthid_routed_detector.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import json +import sys +from pathlib import Path + +from click.testing import CliRunner + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) + +import synthid_routed_detector as detector +import synthid_routed_expert_bank as bank + + +def test_detect_path_routes_one_scored_record(monkeypatch, tmp_path: Path) -> None: + image_path = tmp_path / "image.png" + image_path.write_bytes(b"fixture") + + def score_path(path: Path) -> dict[str, object]: + assert path == image_path + return { + "id": "a" * 64, + "path": str(path), + "width": 1024, + "height": 1024, + "observations": [ + { + "name": bank.synthid_detector.DETECTOR_ID, + "supported": True, + "score": 0.5, + }, + { + "name": bank.synthid_detector.REGISTERED_DETECTOR_ID, + "supported": True, + "score": 0.0, + }, + { + "name": bank.synthid_detector.LARGE_DETECTOR_ID, + "supported": False, + "score": None, + }, + ], + } + + monkeypatch.setattr(detector, "score_path", score_path) + + result = detector.detect_path(image_path) + + assert result["result"]["verdict"] == "abstain" + assert result["result"]["reason"] == "fixed_only_ambiguous" + + +def test_cli_writes_combined_hash_pinned_report(monkeypatch, tmp_path: Path) -> None: + image_path = tmp_path / "image.png" + report_path = tmp_path / "report.json" + image_path.write_bytes(b"fixture") + monkeypatch.setattr( + detector, + "detect_path", + lambda path: { + "id": "b" * 64, + "path": str(path), + "width": 1024, + "height": 1024, + "observations": [], + "result": {"verdict": "detected", "reason": "registered_threshold_crossed"}, + }, + ) + + result = CliRunner().invoke( + detector.main, + [str(image_path), "--report-out", str(report_path)], + ) + + assert result.exit_code == 0, result.output + report = json.loads(report_path.read_text(encoding="utf-8")) + assert report["counts"] == {"detected": 1, "abstain": 0} + assert report["records"][0]["id"] == "b" * 64 diff --git a/tests/test_synthid_routed_expert_bank.py b/tests/test_synthid_routed_expert_bank.py new file mode 100644 index 0000000..6589db1 --- /dev/null +++ b/tests/test_synthid_routed_expert_bank.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest +from click.testing import CliRunner + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) + +import synthid_routed_expert_bank as bank +from synthid_conformal_cascade import ExpertObservation + + +def _observations( + fixed_score: float | None, + registered_score: float | None, + *, + fixed_supported: bool = True, + registered_supported: bool = True, + large_score: float | None = None, + large_supported: bool = False, +) -> tuple[ExpertObservation, ...]: + return ( + ExpertObservation(bank.synthid_detector.DETECTOR_ID, fixed_supported, fixed_score), + ExpertObservation( + bank.synthid_detector.REGISTERED_DETECTOR_ID, + registered_supported, + registered_score, + ), + ExpertObservation( + bank.synthid_detector.LARGE_DETECTOR_ID, + large_supported, + large_score, + ), + ) + + +def test_registered_crossing_is_the_only_positive_route() -> None: + result = bank.classify_routed(_observations(-1.0, 1.1)) + + assert result.verdict == "detected" + assert result.reason == "registered_threshold_crossed" + assert result.selected_expert == bank.synthid_detector.REGISTERED_DETECTOR_ID + + +def test_large_crossing_is_a_separate_positive_route() -> None: + result = bank.classify_routed( + _observations( + None, + None, + fixed_supported=False, + registered_supported=False, + large_score=1.1, + large_supported=True, + ) + ) + + assert result.verdict == "detected" + assert result.reason == "large_threshold_crossed" + assert result.selected_expert == bank.synthid_detector.LARGE_DETECTOR_ID + + +def test_fixed_crossing_in_overlapping_geometry_abstains() -> None: + result = bank.classify_routed(_observations(0.5, 0.0)) + + assert result.verdict == "abstain" + assert result.reason == "fixed_only_ambiguous" + + +def test_fixed_crossing_outside_registered_geometry_abstains() -> None: + result = bank.classify_routed( + _observations(0.5, None, registered_supported=False), + ) + + assert result.verdict == "abstain" + assert result.reason == "fixed_only_geometry_uncalibrated" + + +def test_unsupported_bank_abstains() -> None: + result = bank.classify_routed( + _observations(None, None, fixed_supported=False, registered_supported=False), + ) + + assert result.verdict == "abstain" + assert result.reason == "unsupported" + + +def test_observations_must_cover_the_exact_routed_bank() -> None: + with pytest.raises(ValueError, match=r"missing=.*synthid-periodic-tile-large-v1"): + bank.classify_routed((ExpertObservation(bank.synthid_detector.DETECTOR_ID, True, 0.5),)) + + +def test_cli_writes_hash_pinned_report(tmp_path: Path) -> None: + observations_path = tmp_path / "observations.json" + report_path = tmp_path / "report.json" + observations_path.write_text( + json.dumps( + { + "schema_version": 1, + "records": [ + { + "id": "candidate-1", + "observations": [ + { + "name": bank.synthid_detector.DETECTOR_ID, + "supported": True, + "score": 0.5, + }, + { + "name": bank.synthid_detector.REGISTERED_DETECTOR_ID, + "supported": True, + "score": 0.0, + }, + { + "name": bank.synthid_detector.LARGE_DETECTOR_ID, + "supported": False, + "score": None, + }, + ], + } + ], + } + ), + encoding="utf-8", + ) + + result = CliRunner().invoke( + bank.main, + [str(observations_path), "--report-out", str(report_path)], + ) + + assert result.exit_code == 0, result.output + report = json.loads(report_path.read_text(encoding="utf-8")) + assert len(report["observation_sha256"]) == 64 + assert report["counts"] == {"detected": 0, "abstain": 1} + assert report["records"][0]["result"]["reason"] == "fixed_only_ambiguous" diff --git a/tests/test_synthid_runtime_expert_scores.py b/tests/test_synthid_runtime_expert_scores.py new file mode 100644 index 0000000..71ba852 --- /dev/null +++ b/tests/test_synthid_runtime_expert_scores.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import numpy as np +from click.testing import CliRunner +from PIL import Image + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) + +import synthid_runtime_expert_scores as scorer + + +def test_unsupported_geometry_emits_no_synthetic_scores() -> None: + observations = scorer.score_pixels(np.zeros((64, 64, 3), dtype=np.uint8)) + + assert observations == [ + {"name": scorer.FIXED_EXPERT_NAME, "supported": False, "score": None}, + {"name": scorer.REGISTERED_EXPERT_NAME, "supported": False, "score": None}, + {"name": scorer.LARGE_EXPERT_NAME, "supported": False, "score": None}, + ] + + +def test_supported_image_scores_each_expert_once(monkeypatch) -> None: + calls = {"fixed": 0, "registered": 0} + + def detect(path, *, image, register_scale=False): + branch = "registered" if register_scale else "fixed" + calls[branch] += 1 + return scorer.synthid_detector.SynthIDDetection( + status="detected", + width=1024, + height=1024, + score=1.5 if register_scale else 0.25, + threshold=1.0 if register_scale else 0.17, + ) + + monkeypatch.setattr(scorer.synthid_detector, "detect_synthid", detect) + + observations = scorer.score_pixels(np.zeros((1024, 1024, 3), dtype=np.uint8)) + + assert observations == [ + {"name": scorer.FIXED_EXPERT_NAME, "supported": True, "score": 0.25}, + {"name": scorer.REGISTERED_EXPERT_NAME, "supported": True, "score": 1.5}, + {"name": scorer.LARGE_EXPERT_NAME, "supported": False, "score": None}, + ] + assert calls == {"fixed": 1, "registered": 1} + + +def test_pixels_must_be_rgb_uint8() -> None: + with np.testing.assert_raises_regex(ValueError, "RGB uint8"): + scorer.score_pixels(np.zeros((64, 64, 3), dtype=np.float32)) + + +def test_cli_writes_hash_pinned_observation_manifest(tmp_path: Path) -> None: + image_path = tmp_path / "small.png" + report_path = tmp_path / "scores.json" + Image.new("RGB", (64, 64), (1, 2, 3)).save(image_path) + + result = CliRunner().invoke(scorer.main, [str(image_path), "--report-out", str(report_path)]) + + assert result.exit_code == 0, result.output + report = json.loads(report_path.read_text(encoding="utf-8")) + assert report["schema_version"] == 1 + assert report["experts"] == [ + scorer.FIXED_EXPERT_NAME, + scorer.REGISTERED_EXPERT_NAME, + scorer.LARGE_EXPERT_NAME, + ] + assert len(report["records"][0]["id"]) == 64 + assert report["records"][0]["width"] == 64 + assert all(not observation["supported"] for observation in report["records"][0]["observations"]) + + +def test_large_default_is_not_mislabeled_as_fixed(monkeypatch) -> None: + def detect(path, *, image, register_scale=False): + detector_id = scorer.REGISTERED_EXPERT_NAME if register_scale else scorer.LARGE_EXPERT_NAME + return scorer.synthid_detector.SynthIDDetection( + status="unsupported" if register_scale else "detected", + width=4096, + height=4096, + score=None if register_scale else 1.2, + threshold=1.0, + detector=detector_id, + ) + + monkeypatch.setattr(scorer.synthid_detector, "detect_synthid", detect) + + observations = scorer.score_pixels(np.zeros((4096, 4096, 3), dtype=np.uint8)) + + assert observations == [ + {"name": scorer.FIXED_EXPERT_NAME, "supported": False, "score": None}, + {"name": scorer.REGISTERED_EXPERT_NAME, "supported": False, "score": None}, + {"name": scorer.LARGE_EXPERT_NAME, "supported": True, "score": 1.2}, + ] diff --git a/tests/test_synthid_tile_attack.py b/tests/test_synthid_tile_attack.py index cab4897..12aa09e 100644 --- a/tests/test_synthid_tile_attack.py +++ b/tests/test_synthid_tile_attack.py @@ -55,3 +55,14 @@ def test_folding_accepts_nondivisible_geometry() -> None: assert folded.shape == (8, 16, 3) assert np.count_nonzero(folded) == 0 + + +def test_subtraction_accepts_nondivisible_geometry() -> None: + pixels = np.full((5, 7, 3), 100, dtype=np.uint8) + template = np.arange(2 * 3 * 3, dtype=np.float64).reshape(2, 3, 3) + + result = attack.subtract_tiled_template(pixels, template, strength=1.0) + + expected_template = np.tile(template, (3, 3, 1))[:5, :7] + expected = np.rint(100.0 - expected_template).astype(np.uint8) + np.testing.assert_array_equal(result, expected) diff --git a/uv.lock b/uv.lock index 9cdaff2..65fb6bc 100644 --- a/uv.lock +++ b/uv.lock @@ -615,7 +615,7 @@ name = "coloredlogs" version = "15.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "humanfriendly" }, + { name = "humanfriendly", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520, upload-time = "2021-06-11T10:22:45.202Z" } wheels = [ @@ -787,7 +787,7 @@ name = "cuda-bindings" version = "13.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-pathfinder" }, + { name = "cuda-pathfinder", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/a9/21/8464d133752951c154feafb3b65c297e7d80f301183d220bec4c830f1441/cuda_bindings-13.3.1-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:120fcc53d57903df529c3486962c56528cba5b7d6c57c99537320ed9922c8b86", size = 6073403, upload-time = "2026-05-29T23:11:36.22Z" }, @@ -822,43 +822,43 @@ wheels = [ [package.optional-dependencies] cublas = [ - { name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cublas", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cuda-nvrtc", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cudart = [ - { name = "nvidia-cuda-runtime", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cuda-runtime", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cufft = [ - { name = "nvidia-cufft", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cufft", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cufile = [ - { name = "nvidia-cufile", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cufile", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cupti = [ - { name = "nvidia-cuda-cupti", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cuda-cupti", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] curand = [ - { name = "nvidia-curand", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-curand", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cusolver = [ - { name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-cusolver", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cublas", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cusolver", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cusparse", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] cusparse = [ - { name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cusparse", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] nvjitlink = [ - { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] nvrtc = [ - { name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cuda-nvrtc", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] nvtx = [ - { name = "nvidia-nvtx", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-nvtx", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] [[package]] @@ -951,6 +951,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" }, ] +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + [[package]] name = "dnspython" version = "2.8.0" @@ -974,8 +983,8 @@ name = "email-validator" version = "2.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "dnspython" }, - { name = "idna" }, + { name = "dnspython", marker = "python_full_version >= '3.12'" }, + { name = "idna", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } wheels = [ @@ -987,7 +996,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1213,6 +1222,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httpcore2" +version = "2.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11", marker = "python_full_version < '3.11' or sys_platform != 'emscripten'" }, + { name = "truststore", marker = "python_full_version < '3.11' or sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/83/a896fc59940fc5a6e2aff3a4be1d92fa890112936803b331cae75a993c34/httpcore2-2.10.0.tar.gz", hash = "sha256:13c0cc3d1919d4f28457f60cd2c2abe04113a8af184ccf1142811beba936f9dc", size = 67427, upload-time = "2026-08-09T09:11:32.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/4f/d149104195a35e2853a2fc203a8e3477747e58c80e17dda686dace174383/httpcore2-2.10.0-py3-none-any.whl", hash = "sha256:7df06cfb34070cae4f7c89be69dc1095eca138e9704ceffb98d25c1912ab6f01", size = 83000, upload-time = "2026-08-09T09:11:29.555Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -1228,6 +1250,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "httpx2" +version = "2.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/3d/f9a8c07a3884f3e5b26205e8436a18b3af61c5d53192c3bea235574dbbec/httpx2-2.10.0.tar.gz", hash = "sha256:8741d7329fe2c7885fc9ceb61c8217acfb87a85f75723714b89ebf7ad7196338", size = 98749, upload-time = "2026-08-09T09:11:33.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/6d/a637d52449d98a6892d9a4dc0262587afdb6a66f201871842dce5a97b1c1/httpx2-2.10.0-py3-none-any.whl", hash = "sha256:5e3194a432701e1cc6f69a8b1b2fa199ef907013fede8d9a09a2c5b7b8141a18", size = 94355, upload-time = "2026-08-09T09:11:30.882Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, +] + [[package]] name = "huggingface-hub" version = "1.26.0" @@ -1253,7 +1301,7 @@ name = "humanfriendly" version = "10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyreadline3", marker = "sys_platform == 'win32'" }, + { name = "pyreadline3", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" } wheels = [ @@ -1328,8 +1376,8 @@ name = "inflect" version = "7.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "more-itertools" }, - { name = "typeguard" }, + { name = "more-itertools", marker = "python_full_version >= '3.12'" }, + { name = "typeguard", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/78/c6/943357d44a21fd995723d07ccaddd78023eace03c1846049a2645d4324a3/inflect-7.5.0.tar.gz", hash = "sha256:faf19801c3742ed5a05a8ce388e0d8fe1a07f8d095c82201eb904f5d27ad571f", size = 73751, upload-time = "2024-12-28T17:11:18.897Z" } wheels = [ @@ -1373,6 +1421,105 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "jiter" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/1f/10936e16d8860c70698a1aa939a46aa0224813b782bce4e000e637da0b2d/jiter-0.16.0.tar.gz", hash = "sha256:7b24c3492c5f4f84a37946ad9cf504910cf6a782d6a4e0689b6673c5894b4a1c", size = 176431, upload-time = "2026-06-29T13:05:13.657Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/d8/b959609e44012a42b1f3e5ba98ea3b33c7e41e6d4b77cd8f00fd19b1d3ad/jiter-0.16.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:c5fc4f8def331036a7b8e981b4347ebe409981edbc8308a5ea842b8c3614fa6c", size = 310082, upload-time = "2026-06-29T13:02:31.356Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3d/4d7f5667ea0e0548534ba880b84bb3d12924fd133aa83ad6c6c80fca3d76/jiter-0.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5a71d0d2014c3275043e1170bf3d4e771493cb0dcf07be54c567155f4d8ee64b", size = 315643, upload-time = "2026-06-29T13:02:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/9b/83/bed2dcb5c9f3e1ccfcbc67dda48265fe7d5ad0c9cadda5fe95f6e3b87f94/jiter-0.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:741eed508c233a76313a1c7b001f8f21b82f14327e9196ae8bd29a2cc164ae84", size = 341363, upload-time = "2026-06-29T13:02:34.853Z" }, + { url = "https://files.pythonhosted.org/packages/f4/2f/6bb3c3dda668ebc0445689c81a2b0f26a82b10843d67ed9c9b2c3edc177f/jiter-0.16.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3fb7bc819187b56dc48aa5c833aaf92257da8e07efdb9306156667bd2eeb491c", size = 365483, upload-time = "2026-06-29T13:02:36.295Z" }, + { url = "https://files.pythonhosted.org/packages/92/35/8a045ccb39164e70dcdae696413b661771f148b68b12b175c3a04d901937/jiter-0.16.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c9610fd25ebccb43fca584136f5c2fbb26802447eccd430dfdbab95a0fd5126", size = 461219, upload-time = "2026-06-29T13:02:38.116Z" }, + { url = "https://files.pythonhosted.org/packages/e7/99/22292dbbf0ed0c610cfe5ddc7f3bd67237a412f121318f865196e62a07bd/jiter-0.16.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4a1d68ff7ca1d3b5dee20a97a3decda7d5f15003823bf6d140c81f8561d3bc5c", size = 374905, upload-time = "2026-06-29T13:02:40.357Z" }, + { url = "https://files.pythonhosted.org/packages/29/ac/2f55ccb1f0eeafa6d89d24caf52f6f0944a59290ee199e9ade62177dca42/jiter-0.16.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb08c276dd02dac3a284acdd02cacc630d2e3cd6572a4b85519f35cbd133c3de", size = 348320, upload-time = "2026-06-29T13:02:41.923Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/7d88b9174c40064fabc07c84a9b62e6b10f5644562ec0e0a29392edbe978/jiter-0.16.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:8fc4d94713c4697347e38faf7d6ef91547c142219bdcfc7220c4870879974244", size = 356519, upload-time = "2026-06-29T13:02:43.436Z" }, + { url = "https://files.pythonhosted.org/packages/27/57/c4a33aeef513a9d5e26e31534e0bcc752d6ea0e54c94ddb7b68bade669c2/jiter-0.16.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a0f05e229edb29e68cdd0ccb83cea13b64263416120cf943767a6fd72e6787f", size = 394204, upload-time = "2026-06-29T13:02:44.987Z" }, + { url = "https://files.pythonhosted.org/packages/9d/70/c6c23e76ebb3766b111bc399437bbc9f870a76e2a92e10b2a5f561d57372/jiter-0.16.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:2c842cbf374a8daf50b2c04212995bee34ca2ac2cdc29a901b4cdb072c9c4131", size = 521477, upload-time = "2026-06-29T13:02:46.724Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d3/0001c8c0c5976af2625bb1cfb1895e8ec693b6589fe4574b8e6fc2c85501/jiter-0.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5ed466aee31294d7cdcd4d37dfe5c42c97bc29d9a5f00eacf24504358309cb9b", size = 552187, upload-time = "2026-06-29T13:02:48.144Z" }, + { url = "https://files.pythonhosted.org/packages/f6/76/311b718e07e85740e48619c0632b36f7e0b8d113984499e436452ed13a9a/jiter-0.16.0-cp310-cp310-win32.whl", hash = "sha256:b42e9ff5376819c053da25809a8d4b6fa6e473b4856ebe42e298ac958be3d7f9", size = 206513, upload-time = "2026-06-29T13:02:49.515Z" }, + { url = "https://files.pythonhosted.org/packages/db/7f/ac680eeb0777dc0eb7dc824800ba27880d7f6bc712e362d34ad8ee559f36/jiter-0.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:10438939205546132189c8e74a2d536a707841f3a25cd7c74ee91fe503407a26", size = 199505, upload-time = "2026-06-29T13:02:50.829Z" }, + { url = "https://files.pythonhosted.org/packages/4e/3f/fae6cc967d120ec89e31c5418a51176d8278b3087fbb384a9176754f353c/jiter-0.16.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:67fddeda1688f0cce2d2ae83ccf8a80f79936f2d2997d6cc2261f82fdb54a4d3", size = 309289, upload-time = "2026-06-29T13:02:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e3/97c6c3562c077f6247d6e6ce5c82562500b6316c0d928e97e106b7a1321a/jiter-0.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c90c0f63df322be920eda6ce622e3083d8906ba267f8220fe7873213b8b4430e", size = 315181, upload-time = "2026-06-29T13:02:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/7b/89/d8d073f8aa2667e46c6c0873f86fe4a512bba4293cc730f626a076211a62/jiter-0.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64c0203212098470032aabcde9356fc168f377aade3e43def61dfe17e92f2037", size = 340939, upload-time = "2026-06-29T13:02:55.412Z" }, + { url = "https://files.pythonhosted.org/packages/87/c9/db4fda3ed73fb864139305e935e5b8b38a5a24692a5a9dd356c22f1b9c8d/jiter-0.16.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12288303c9844e61e1651d02a9a6f6633e47d39f897d6991d1427161ce6b746e", size = 364932, upload-time = "2026-06-29T13:02:57.28Z" }, + { url = "https://files.pythonhosted.org/packages/a2/74/52b5e86241057f52ddd7c9a580f90effb51f9d06239f6fc612279b91a838/jiter-0.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cf109d010b4b05a105afb3d43be36a21322d345ad3111e13d15f680afef0e5b", size = 461132, upload-time = "2026-06-29T13:02:58.994Z" }, + { url = "https://files.pythonhosted.org/packages/a9/87/544a700f7447c1f31c5d7833821a4daa5683165c2d5a094fbf5b5800c3dc/jiter-0.16.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62c1b7fe1f77925acf5af68b6140b8810fa87dfd4dc0a9c8568ec2fa2a10429c", size = 374857, upload-time = "2026-06-29T13:03:00.455Z" }, + { url = "https://files.pythonhosted.org/packages/40/cd/0fcc3f7d39183674d5bfa9ec640faaeb506c60be7c8f94625dfba366e37c/jiter-0.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8597d23c87f59294f83bcb6229b9ed1fccee13dbba967b46930d2f1759466fee", size = 347053, upload-time = "2026-06-29T13:03:02.045Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ae/c7e64e7932ad597fa395b61440b249ada6366716e25c6e08dd2afbd021e6/jiter-0.16.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:3126a5dbad56401989ac769aca0cb56005bfb3e2366eea0ca99d1a91c3c1ee03", size = 356153, upload-time = "2026-06-29T13:03:03.706Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/1c719044f14da814e1a060191ab19b96f3e99207bc5b4bfc6d6be34b3f80/jiter-0.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c4b4717bdb35ae456f831a6b08d01880fff399887a6bbc526a583a406e484eea", size = 393956, upload-time = "2026-06-29T13:03:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/3b/dc/7b2f303a2847207e265503853a2d964a55354cffd62a5f2936c155486798/jiter-0.16.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:adff21bc78edfe086c15eb495b900306076de378dc2337c132401fc39bd79c91", size = 521081, upload-time = "2026-06-29T13:03:06.886Z" }, + { url = "https://files.pythonhosted.org/packages/c2/5f/501cf6e1e09caeb420195179ffc6f62aca603f1220ec53fd80d0d70b3e56/jiter-0.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dab907db06fc593645e73109acf4581ba5b548897d28b9348dc41ddc8343b2d3", size = 552085, upload-time = "2026-06-29T13:03:08.339Z" }, + { url = "https://files.pythonhosted.org/packages/79/54/aa5be86520113b79455c3877f3d1f07a348098df4083ba3688e9537e52dd/jiter-0.16.0-cp311-cp311-win32.whl", hash = "sha256:560b2cf3fb03240cd34f27409a238547488708f05b7c3924f571a60422251ec7", size = 206755, upload-time = "2026-06-29T13:03:09.653Z" }, + { url = "https://files.pythonhosted.org/packages/64/ec/2feb893eb330bd69b413866f4d5daada33c3962f1c6f270c91ca2d87fdf9/jiter-0.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:e431cfc9caf44c1d5459ff77d4e64cbf85fddb6a35dad836a15c6a9ec23087c1", size = 199155, upload-time = "2026-06-29T13:03:10.979Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9c/ca040d94415048a3666fc237774df8151c96f8d2b661cbe3b184acc95876/jiter-0.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:2a8e9e39cf083016137aa5cadafe3188adc2ba6ba1fbf1e5d18889ad3e9ad056", size = 194403, upload-time = "2026-06-29T13:03:12.341Z" }, + { url = "https://files.pythonhosted.org/packages/83/2b/52ace16ed031354f0539749a49e4bf33797d82bea5137910835fa4b09793/jiter-0.16.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:67c3bc1760f8c99d805dcab4e644027142a53b1d5d861f18780ebdbd5d40b72a", size = 306943, upload-time = "2026-06-29T13:03:14.035Z" }, + { url = "https://files.pythonhosted.org/packages/94/2e/34957c2c1b661c252ba9bcc60ae0bddc27e0f7202c6073326a13c5390eec/jiter-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5af7780e4a26bd7d0d989592bf9ef12ebf806b74ab709223ecca37c749872ea9", size = 307779, upload-time = "2026-06-29T13:03:15.418Z" }, + { url = "https://files.pythonhosted.org/packages/88/6c/59bd309cab4460c54cf1079f3eb7fe7af6a4c895c5c957a53378693bad2b/jiter-0.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5bf78d0e05e45cfdd66558893938d59afe3d1b1a824a202039b20e607d25a72", size = 335826, upload-time = "2026-06-29T13:03:17.11Z" }, + { url = "https://files.pythonhosted.org/packages/3b/8c/f5ef7b65f0df47afa16596969defb281ebb86e96df346d62be6fd853d620/jiter-0.16.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4444a83f946605990c98f625cdd3d2725bfb818158760c5748c653170a20e0e", size = 362573, upload-time = "2026-06-29T13:03:18.781Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0b/ace4354da061ee38844a0c27dc2c21eecd27aea119e8da324bea987522d0/jiter-0.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a23f0e4f957e1be65752d2dfac9a5a06b1917af8dc85deb639c3b9d02e31290", size = 457979, upload-time = "2026-06-29T13:03:20.293Z" }, + { url = "https://files.pythonhosted.org/packages/55/40/c0253d3772eb9dcd8e6606ee9b2d53ec8e5b814589c47f140aa585f21eaa/jiter-0.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c22a488f7b9218e245a0025a9ba6b100e2e54700831cf4cf16833a27fba3ad01", size = 372302, upload-time = "2026-06-29T13:03:21.739Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d2/4839422241aa12860ce597b20068727094ba0bc480723c74924ca5bad483/jiter-0.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46add52f4ad47a08bfb1219f3e673da972191489a33016edefdb5ea55bfa8c48", size = 343805, upload-time = "2026-06-29T13:03:23.384Z" }, + { url = "https://files.pythonhosted.org/packages/e2/59/e196888a05befdda7dbe299b722d56f2f6eec65402bc34c0a3306d595feb/jiter-0.16.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:9c8a956fd72c2cf1e730d01ea080341f13aa0a97a4a33b51abebe725b7ae9ca9", size = 351107, upload-time = "2026-06-29T13:03:24.815Z" }, + { url = "https://files.pythonhosted.org/packages/ec/74/4cd9e0fca65232136400354b630fbfcd2de634e22ccbb96567725981b548/jiter-0.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:561926e0573ffe4a32498420a76d64b16c513e1ab413b9d28158a8764ac701e5", size = 388441, upload-time = "2026-06-29T13:03:26.266Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8c/554691e48bc711299c0a293dd8a6179e24b2d66a54dc295421fcf64569c0/jiter-0.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:44d019fa8cdaf89bf29c71b39e3712143fdd0ac76725c6ef954f9957a5ea8730", size = 516354, upload-time = "2026-06-29T13:03:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/a4/cb/01e9d69dc2cc6759d4f91e230b34489c4fdb2518992650633f9e20bece89/jiter-0.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0df91907609837f33341b8e6fe73b95991fdaa57caf1a0fbd343dffe826f386f", size = 547880, upload-time = "2026-06-29T13:03:29.534Z" }, + { url = "https://files.pythonhosted.org/packages/79/70/2953195f1c6ad00f49fa67e13df7e60acb3dd4f387101bc15abccddd905e/jiter-0.16.0-cp312-cp312-win32.whl", hash = "sha256:51d7b836acb0108d7c77df1742332cac2a1fa04a74d6dacec46e7091f0e91274", size = 203473, upload-time = "2026-06-29T13:03:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/05/2909a8b10699a4d560f8c502b6b2c5f3991b682b1922c1eedda242b225bd/jiter-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:1878349266f8ee36ecb1375cc5ba2f115f35fd9f0a1a4119e725e379126647f7", size = 196905, upload-time = "2026-06-29T13:03:32.472Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a9/6b82bb1c8d7790d602489b967b982a909e5d092875a6c2ade96444c8dfc5/jiter-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:2ed5738ae4af18271a51a528b8811b0cbfa4a1858de9d83359e4169855d6a331", size = 190618, upload-time = "2026-06-29T13:03:34.672Z" }, + { url = "https://files.pythonhosted.org/packages/91/c0/555fc60473d30d66894ba825e63615e3be7524fac23858356afa7a38906c/jiter-0.16.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:41977aa5654023948c2dae2a81cbf9c43343954bef1cd59a154dd15a4d84c195", size = 306203, upload-time = "2026-06-29T13:03:36.243Z" }, + { url = "https://files.pythonhosted.org/packages/d0/2b/c3eaf16f5d7c9bad66ea32f40a95bd169b29a91217fcc7f081375157e99c/jiter-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d28bb3c26762358dadf3e5bf0bccd29ae987d65e6988d2e6f49829c76b003c09", size = 306489, upload-time = "2026-06-29T13:03:37.846Z" }, + { url = "https://files.pythonhosted.org/packages/96/3f/02fdfc6705cad96127d883af5c34e4867f554f29ec7705ec1a46156400a9/jiter-0.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0542a7189c26920778658fc8fcf2af8bae05bae9924577f71804acef37996536", size = 335453, upload-time = "2026-06-29T13:03:39.221Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a6/e4bda5920d4b0d7c5dfb7174ce4a6b2e4d3e11c9162c452ef0eab4cdbdbd/jiter-0.16.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8fb8de1e23a0cb2a7f53c335049c7b72b6db41aa6227cdcc0972a1de5cb39450", size = 361625, upload-time = "2026-06-29T13:03:40.597Z" }, + { url = "https://files.pythonhosted.org/packages/b7/97/4e6b59b2c6e55cbb3e183595f81ad65dcfb21c915fee5e19e335df21bc55/jiter-0.16.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b72d0b2990ca754a9102779ac98d8597b7cb31678958562214a007f909eab78e", size = 456958, upload-time = "2026-06-29T13:03:42.074Z" }, + { url = "https://files.pythonhosted.org/packages/15/e0/97e9557686d2f94f4b93786eccb7eed28e9228ad132ea8237f44727314a7/jiter-0.16.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f91b1c27fc22a57993d5a5cb8a627cb8ed4b10502716fac1ffbfe1d19d84e8", size = 372017, upload-time = "2026-06-29T13:03:43.658Z" }, + { url = "https://files.pythonhosted.org/packages/0f/94/db768b6938e0df35c86beeba3dfbbb025c9ee5c19e1aa271f2396e50864d/jiter-0.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c682bea068a90b764577bdb78a60a4c1d1606daf9cd4c893832a37c7cc9d9026", size = 343320, upload-time = "2026-06-29T13:03:45.226Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d6/5a59d938244a30735fe62d9433fd325f9021ea29d89780ea4596ea93bc89/jiter-0.16.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:8d031aabecc4f1b6276adfb42e3aabb77c89d468bf616600e8d3a11328929053", size = 350520, upload-time = "2026-06-29T13:03:46.671Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/c4a857f49c9af125f6bbcac7e3eee7f7978ed89682833062e2dbf62576b1/jiter-0.16.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eab2cd170150e70153de16896a1774e3a1dca80154c56b54d7a812c479a7165e", size = 387550, upload-time = "2026-06-29T13:03:48.361Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d6/5fbc2f7d6b67b754caa61a993a2e626e815dec47ffc2f9e35f01adfebec7/jiter-0.16.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6edb63a46e65a82c26800a868e49b2cac30dd5a4218b88d74bc2c848c8ad60bb", size = 515424, upload-time = "2026-06-29T13:03:49.881Z" }, + { url = "https://files.pythonhosted.org/packages/ed/54/284f0164b64a5fed915fea6ba7e9ba9b3d8d37c67d59cf2e3bb99d45cdfe/jiter-0.16.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:659039cc50b5addcc35fcc87ae2c1833b7c0a8e5326ef631a75e4478447bcf84", size = 546981, upload-time = "2026-06-29T13:03:51.363Z" }, + { url = "https://files.pythonhosted.org/packages/13/c5/2a467585a576594384e1d2c43e1224deaafc085f24e243529cf98beef8e1/jiter-0.16.0-cp313-cp313-win32.whl", hash = "sha256:c9c53be232c2e206ef9cdbad81a48bfa74c3d3f08bcf8124630a8a748aad993e", size = 202853, upload-time = "2026-06-29T13:03:53.015Z" }, + { url = "https://files.pythonhosted.org/packages/88/6a/de61d04b9eec69c71719968d2f716532a3bc121170c44a39e14979c6be81/jiter-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:baad945ed47f163ad833314f8e3288c396118934f94e7bbb9e243ce4b341a4fd", size = 196160, upload-time = "2026-06-29T13:03:54.447Z" }, + { url = "https://files.pythonhosted.org/packages/19/4b/b390ed59bafb3f31d008d1218578f10327714484b334439947f7e5b11e7f/jiter-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:3c1fd2dbe1b0af19e987f03fe66c5f5bd105a2229c1aff4ab14890b24f41d21a", size = 189862, upload-time = "2026-06-29T13:03:55.754Z" }, + { url = "https://files.pythonhosted.org/packages/a7/89/bc4f1b57d5da938fd344a466396541e586d161320d70bffd929aaafcd8f4/jiter-0.16.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b2c61484666ad42726029af0c00ef4541f0f3b5cdc550221f56c2343208018ee", size = 308239, upload-time = "2026-06-29T13:03:57.205Z" }, + { url = "https://files.pythonhosted.org/packages/65/7a/c415453e5213001bf3b411ff65dec3d303b0e76a4a2cfea9768cd4960994/jiter-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:63efadc657488f45db1c676d81e704cac2abf3fdb892def1faea61db053127e2", size = 308928, upload-time = "2026-06-29T13:03:58.643Z" }, + { url = "https://files.pythonhosted.org/packages/11/fc/1f4fb7ebf9a724c7741994f4aae18fba1e2f3133df14521a79194952c34a/jiter-0.16.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf0d73f50e7b6935677854f6e8e31d499ca7064dd24734f703e060f5b237d883", size = 336998, upload-time = "2026-06-29T13:04:00.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/8d/72cadaac05ccfa7cc3a0a2232862e6c72443ca40cf300ba8b57f9f18b69b/jiter-0.16.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3ea07d9bc8e7d03a9fbc051295462e6dbc295b894fd72457c3136e3e43d898", size = 362112, upload-time = "2026-06-29T13:04:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/58/4a/c4b0d5f651fda90a24ffce9f8d56cde462a2e09d31ae3de3c68cef34c04e/jiter-0.16.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:26798522707abb47d767db536e4148ceac1b14446bf028ee85e579a2e043cfe5", size = 459807, upload-time = "2026-06-29T13:04:03.214Z" }, + { url = "https://files.pythonhosted.org/packages/80/58/ef77879ea9aa56b50824edc5a445e226422c7a8d211f3fd2a56bcb9493cf/jiter-0.16.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bc837c1b9631be10abfe0191537fe8009838204cec7e44827401ace390ddb567", size = 373181, upload-time = "2026-06-29T13:04:04.629Z" }, + { url = "https://files.pythonhosted.org/packages/49/2e/ffbc3f254e4d8a66da3062c624a7df4b7c2b2cf9e1fe43cf394b3e104041/jiter-0.16.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49060fd70737fad59d33ba9dcc0d83247dc9e77187de26053a19c16c9f32bd69", size = 344927, upload-time = "2026-06-29T13:04:06.067Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f6/0be5dc6d64a89f80aa8fec984f94dedb2973e251edcae55841d60786d578/jiter-0.16.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:adbb8edeadd431bc4477879d5d371ece7cb1334486584e0f252656dd7ffada29", size = 352754, upload-time = "2026-06-29T13:04:07.477Z" }, + { url = "https://files.pythonhosted.org/packages/da/6e/7d31243b3b91cd261dd19e9d3557fc3251a80883d3d8049c86174e7ab7af/jiter-0.16.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:31aaee5b80f672c1dc21272bcfb9cbdcfc1ea04ff50f00ed5af500b80c44fa93", size = 390553, upload-time = "2026-06-29T13:04:08.92Z" }, + { url = "https://files.pythonhosted.org/packages/25/33/51ae371fde3c88897520f62b4d5f8b27ad7103e2bb10812ff52195609853/jiter-0.16.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:6722bcef4ffc86c835574b1b2fac6b33b9fb4a889c781e67950e891591f3c55a", size = 516900, upload-time = "2026-06-29T13:04:10.407Z" }, + { url = "https://files.pythonhosted.org/packages/a0/45/6449b3d123ea439ba79507c657288f461d55049e7bcbdc2cf8eb8210f491/jiter-0.16.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:5ab4f50ff971b611d656554ea10b75f80097392c827bc32923c6eeb6386c8b00", size = 548754, upload-time = "2026-06-29T13:04:12.046Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e7/fd2fb11ae3e2649333da3aa170d04d7b3000bbdc3b270f6513382fdf4e04/jiter-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:710cc51d4ebdcd3c1f70b232c1db1ea1344a075770422bbd4bede5708335acbe", size = 122381, upload-time = "2026-06-29T13:04:13.413Z" }, + { url = "https://files.pythonhosted.org/packages/26/80/f0b147a62c315a164ed2168908286ca302310824c218d3aae52b06c0c9a9/jiter-0.16.0-cp314-cp314-win32.whl", hash = "sha256:57b37fc887a32d44798e4d8ebfa7c9683ff3da1d5bf38f08d1bb3573ccb39106", size = 204578, upload-time = "2026-06-29T13:04:14.813Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e6/4758a14304b4523a6f5adb2419340086aa3593bd4327c2b25b5948a90548/jiter-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:cbd18dd5e2df96b580487b5745adf57ef64ad89ba2d9662fc3c19386acce7db8", size = 198154, upload-time = "2026-06-29T13:04:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/26/be/41fa54a2e7ea41d6c99f1dc5b1f0fd4cb474680304b5d268dd518e81da3a/jiter-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:a32d2027a9fa67f109ff245a3252ece3ccc32cc56703e1deab6cc846a59e0585", size = 191458, upload-time = "2026-06-29T13:04:17.707Z" }, + { url = "https://files.pythonhosted.org/packages/81/6b/59127338b86d9fe4d99418f5a15118bea778103ee0fe9d9dd7e0af174e95/jiter-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2577196f4474ef3fc4779a088a23b0897bbf86f9ea3679c372d45b8383b43207", size = 316739, upload-time = "2026-06-29T13:04:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/2d/95/49461034d5388196d3dabf98748935f017b7785d8f3f5349f834bcc4ed0d/jiter-0.16.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:616e89e008a93c01104161c75b4988e58716b01d62307ebfe161e52a56d2a818", size = 340911, upload-time = "2026-06-29T13:04:21.257Z" }, + { url = "https://files.pythonhosted.org/packages/cd/97/a4369f2fb82cb3dda13b98622f31249b2e014b223fe64ee534413ad72294/jiter-0.16.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e2e9efbe042210df657bade597f66d6d75723e3d8f45a12ea6d8167ff8bbce3", size = 361747, upload-time = "2026-06-29T13:04:22.677Z" }, + { url = "https://files.pythonhosted.org/packages/28/51/49b6ed456261646e1906016a6760367a28aacd3c24805e4e5fe64116c1db/jiter-0.16.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f4d9e473a5ce7d27fef8b848df4dc16e283893d3f53b4a585e72c9595f3c284", size = 460225, upload-time = "2026-06-29T13:04:24.441Z" }, + { url = "https://files.pythonhosted.org/packages/33/b5/5689aff4f66c5b60be63106e591dbfcba2190df97d2c9c7cf052361ddb98/jiter-0.16.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d30a4a1c87713060c8d1cc59a7b6c8fb6b8ef0a6900368014c76c87922a2929", size = 373169, upload-time = "2026-06-29T13:04:25.884Z" }, + { url = "https://files.pythonhosted.org/packages/a2/96/3ae1b85ee0d6d6cab254fb7f8da018272b932bbf2d69b07e98aa2a96c746/jiter-0.16.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bae96332410f866e5900d809298b1ed82735932986c672495f9701daacd80620", size = 350332, upload-time = "2026-06-29T13:04:27.302Z" }, + { url = "https://files.pythonhosted.org/packages/15/32/c99d7bafd78986556c95bf60ce84c6cc98786eac56066c12d7f828bb6747/jiter-0.16.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:da3d7ec75dc83bb18bca888b5edfae0656a26849056c59e05a7728badd17e7af", size = 353377, upload-time = "2026-06-29T13:04:28.731Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/f99a8e571287c3dec766bcc18528bbe8e8fb5365522ab5e6d64c93e87066/jiter-0.16.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ee6162b77d49a9939229df666dfa8af3e656b6701b54c4c84966d740e189264e", size = 387746, upload-time = "2026-06-29T13:04:30.319Z" }, + { url = "https://files.pythonhosted.org/packages/75/69/c78a5b3f71040e34eb5917df26fb7ae9a2174cad1ccbf277512507c53a6e/jiter-0.16.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:63ffdbdae7d4499f4cda14eadc12ddcabef0fc0c081191bdc2247489cb698077", size = 517292, upload-time = "2026-06-29T13:04:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f7/095b38eda4c70d03651c403f29a5590f16d12ddc5d544aac9f9cddf72277/jiter-0.16.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a111256a7193bea0759267b10385e5870949c239ed7b6ddbaaf57573edb38734", size = 549259, upload-time = "2026-06-29T13:04:33.721Z" }, + { url = "https://files.pythonhosted.org/packages/2e/c5/6a0207d90e5f656d95af98ebd0934f382d37674416f215aeda2ff8063e51/jiter-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:de5ba8763e56b793561f43bed197c9ea55776daa5e9a6b91eed68a909bc9cdbf", size = 206523, upload-time = "2026-06-29T13:04:35.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/31/c757d5f30a8980fd945ce7b98be10be9e4ff59c7c42f5fd86804c2e87db8/jiter-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b8a3f9a6008048fe9def7bf465180564a6e458047d2ce499149cfbe73c3ae9db", size = 200366, upload-time = "2026-06-29T13:04:36.61Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a2/d88de6d313d734a544a7901353ad5db67cb38dcfcd91713b7979dafc345d/jiter-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0fa25b09b13075c46f5bc174f2690525a925a4fc2f7c82969a2bbabff22386ce", size = 190516, upload-time = "2026-06-29T13:04:38.004Z" }, + { url = "https://files.pythonhosted.org/packages/06/d3/8e278946d43eeca2585b4dd0834a887cd71136329b837f3a16ed86a8b4b0/jiter-0.16.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:850ccb1d7eedb4200f4014b1c0e8a577de114fc3cd88faad646dcc9bc4bb12ad", size = 304518, upload-time = "2026-06-29T13:05:00.172Z" }, + { url = "https://files.pythonhosted.org/packages/72/43/28d4ef495028bf0506a413d4db3f4eb3e7288a382e0f065f306a17bbeb5e/jiter-0.16.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:e34e97bda77eb63242a410243c071e28ac7e0d8c0948c5ee658498690a4b2f2f", size = 310207, upload-time = "2026-06-29T13:05:02.123Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ca/c366b1012da1d640de975d9683acd44e4d150d9068845d0ca2610435253f/jiter-0.16.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7dc85ea77d4abbae8bad0d3538678aedee75bceec4e2f6c8dfb1c74772e5aa5", size = 342771, upload-time = "2026-06-29T13:05:03.55Z" }, + { url = "https://files.pythonhosted.org/packages/16/52/50cc4056fc1ae02e7154704e7ecc89df0afb8300222cfe8a52d3f67e4730/jiter-0.16.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17ca7fae79f6d99cd9a042b75f917eaada7b895cfc7dd2ee3a16089dcaec7a85", size = 346468, upload-time = "2026-06-29T13:05:05.452Z" }, + { url = "https://files.pythonhosted.org/packages/98/ab/664fd8c4be028b2bedd3d2ff08769c4ede23d0dbc87a77c62384a0515b5d/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:f17d61a28b4b3e0e3e2ba98490c70501403b4d196f78732439160e7fd3678127", size = 303106, upload-time = "2026-06-29T13:05:07.118Z" }, + { url = "https://files.pythonhosted.org/packages/1a/07/421f1d5b65493a76e16027b848aba6a7d28073ae75944fa4289cc914d39f/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:96e38eea538c8ddf853a35727c7be0741c76c13f04148ac5c116222f50ece3b3", size = 304658, upload-time = "2026-06-29T13:05:08.708Z" }, + { url = "https://files.pythonhosted.org/packages/0a/db/bba1155f01a01c3c37a89425d571da751bbedf5c54247b831a04cb971798/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d284fb8d94d5855d60c44fefcab4bf966f1da6fada73992b01f6f0c9bc0c6702", size = 339719, upload-time = "2026-06-29T13:05:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/78/f7/18a1afcd64f35314b68c1f23afcd9994d0bc13e65cc77517afff4e83986d/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d613743df53199b1aa256a7d328340da6d7078aac7705a7db9d7a791e9cfd2", size = 343885, upload-time = "2026-06-29T13:05:12.087Z" }, +] + [[package]] name = "lightning" version = "2.6.5" @@ -1810,7 +1957,7 @@ name = "nvidia-cublas" version = "13.1.1.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cuda-nvrtc" }, + { name = "nvidia-cuda-nvrtc", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" }, @@ -1849,7 +1996,7 @@ name = "nvidia-cudnn-cu13" version = "9.20.0.48" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas" }, + { name = "nvidia-cublas", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" }, @@ -1861,7 +2008,7 @@ name = "nvidia-cufft" version = "12.0.0.61" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink" }, + { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, @@ -1891,9 +2038,9 @@ name = "nvidia-cusolver" version = "12.0.4.66" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas" }, - { name = "nvidia-cusparse" }, - { name = "nvidia-nvjitlink" }, + { name = "nvidia-cublas", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "nvidia-cusparse", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, @@ -1905,7 +2052,7 @@ name = "nvidia-cusparse" version = "12.6.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink" }, + { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, @@ -1980,12 +2127,12 @@ resolution-markers = [ "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", ] dependencies = [ - { name = "coloredlogs" }, - { name = "flatbuffers" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "protobuf" }, - { name = "sympy" }, + { name = "coloredlogs", marker = "python_full_version < '3.11'" }, + { name = "flatbuffers", marker = "python_full_version < '3.11'" }, + { name = "numpy", marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "protobuf", marker = "python_full_version < '3.11'" }, + { name = "sympy", marker = "python_full_version < '3.11'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/35/d6/311b1afea060015b56c742f3531168c1644650767f27ef40062569960587/onnxruntime-1.23.2-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:a7730122afe186a784660f6ec5807138bf9d792fa1df76556b27307ea9ebcbe3", size = 17195934, upload-time = "2025-10-27T23:06:14.143Z" }, @@ -2034,10 +2181,10 @@ resolution-markers = [ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", ] dependencies = [ - { name = "flatbuffers" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "protobuf" }, + { name = "flatbuffers", marker = "python_full_version >= '3.11'" }, + { name = "numpy", marker = "python_full_version >= '3.11'" }, + { name = "packaging", marker = "python_full_version >= '3.11'" }, + { name = "protobuf", marker = "python_full_version >= '3.11'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/17/4d/5014667e2a3a77d6e1b74cc3d88948d06163b8e0a33a84c85073322b5dec/onnxruntime-1.28.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:f5c5daabd28aad610f83fdcf32acec8fb57e6adc6c6a39fe2a3c755db957b410", size = 19130506, upload-time = "2026-07-25T01:22:34.489Z" }, @@ -2066,6 +2213,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/a9/68707e1ce345cbdbcd4df65932ebc82a673e917d63eda0007ebcff948691/onnxruntime-1.28.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f6e92367ddce1e4d33cf295024f40192be6c6171a09208f515ba169ced06c8e", size = 19222976, upload-time = "2026-07-25T01:22:12.474Z" }, ] +[[package]] +name = "openai" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx2" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/54/8c/2f500e8be09d1ae98c530467962535198b02cd4550cd418bbbaedc8b2910/openai-3.0.0.tar.gz", hash = "sha256:ffd00ef1678d70957e1f1ed98d5bfcf1d661f41ea4482f22e7d0144a66435a49", size = 1123740, upload-time = "2026-08-12T01:55:50.849Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/0d/9850e7eddb5e66da4439ed503e78e09ad1fd0195e6df51e4236c75763581/openai-3.0.0-py3-none-any.whl", hash = "sha256:8d32ac3a6647a66910d6cb8a64f0fa5a6c823604b6e82db83d9d055c6709bd51", size = 1665775, upload-time = "2026-08-12T01:55:48.678Z" }, +] + [[package]] name = "opencv-python" version = "4.11.0.86" @@ -2205,10 +2371,10 @@ resolution-markers = [ "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", ] dependencies = [ - { name = "numpy" }, - { name = "python-dateutil" }, - { name = "pytz" }, - { name = "tzdata" }, + { name = "numpy", marker = "python_full_version < '3.11' or python_full_version >= '3.14'" }, + { name = "python-dateutil", marker = "python_full_version < '3.11' or python_full_version >= '3.14'" }, + { name = "pytz", marker = "python_full_version < '3.11' or python_full_version >= '3.14'" }, + { name = "tzdata", marker = "python_full_version < '3.11' or python_full_version >= '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } wheels = [ @@ -2278,9 +2444,9 @@ resolution-markers = [ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", ] dependencies = [ - { name = "numpy" }, - { name = "python-dateutil" }, - { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, + { name = "numpy", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "python-dateutil", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, + { name = "tzdata", marker = "(python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'emscripten') or (python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" } wheels = [ @@ -2771,7 +2937,7 @@ wheels = [ [package.optional-dependencies] email = [ - { name = "email-validator" }, + { name = "email-validator", marker = "python_full_version >= '3.12'" }, ] [[package]] @@ -3025,7 +3191,7 @@ resolution-markers = [ "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", ] dependencies = [ - { name = "numpy" }, + { name = "numpy", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/48/45/bfaaab38545a33a9f06c61211fc3bea2e23e8a8e00fedeb8e57feda722ff/pywavelets-1.8.0.tar.gz", hash = "sha256:f3800245754840adc143cbc29534a1b8fc4b8cff6e9d403326bd52b7bb5c35aa", size = 3935274, upload-time = "2024-12-04T19:54:20.593Z" } wheels = [ @@ -3090,7 +3256,7 @@ resolution-markers = [ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", ] dependencies = [ - { name = "numpy" }, + { name = "numpy", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5a/75/50581633d199812205ea8cdd0f6d52f12a624886b74bf1486335b67f01ff/pywavelets-1.9.0.tar.gz", hash = "sha256:148d12203377772bea452a59211d98649c8ee4a05eff019a9021853a36babdc8", size = 3938340, upload-time = "2025-08-04T16:20:04.978Z" } wheels = [ @@ -3352,6 +3518,7 @@ all = [ { name = "numpy" }, { name = "onnxruntime", version = "1.23.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "onnxruntime", version = "1.28.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "openai" }, { name = "opencv-python-headless" }, { name = "pillow-heif" }, { name = "pywavelets", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -3432,6 +3599,9 @@ qwen-zimage = [ trustmark = [ { name = "trustmark" }, ] +verify = [ + { name = "openai" }, +] video = [ { name = "av", version = "16.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "av", version = "18.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, @@ -3460,6 +3630,7 @@ requires-dist = [ { name = "onnxruntime", marker = "python_full_version >= '3.11' and extra == 'migan'", specifier = ">=1.16.0" }, { name = "onnxruntime", marker = "python_full_version < '3.11' and extra == 'lama'", specifier = ">=1.16.0,<1.24" }, { name = "onnxruntime", marker = "python_full_version < '3.11' and extra == 'migan'", specifier = ">=1.16.0,<1.24" }, + { name = "openai", marker = "extra == 'verify'", specifier = ">=2.52.0" }, { name = "opencv-python-headless", marker = "extra == 'pixels'", specifier = ">=4.8.0" }, { name = "packaging", marker = "extra == 'dev'", specifier = ">=24.0" }, { name = "piexif", specifier = ">=1.1.3" }, @@ -3477,7 +3648,7 @@ requires-dist = [ { name = "remove-ai-watermarks", extras = ["pixels"], marker = "extra == 'diffusion'" }, { name = "remove-ai-watermarks", extras = ["pixels"], marker = "extra == 'visible'" }, { name = "remove-ai-watermarks", extras = ["video"], marker = "extra == 'dev'" }, - { name = "remove-ai-watermarks", extras = ["video", "heif", "detect", "trustmark", "qwen-zimage", "lama", "migan"], marker = "extra == 'all'" }, + { name = "remove-ai-watermarks", extras = ["video", "heif", "detect", "trustmark", "qwen-zimage", "lama", "migan", "verify"], marker = "extra == 'all'" }, { name = "remove-ai-watermarks", extras = ["visible"], marker = "extra == 'lama'" }, { name = "remove-ai-watermarks", extras = ["visible"], marker = "extra == 'migan'" }, { name = "remove-ai-watermarks", extras = ["visible"], marker = "extra == 'video'" }, @@ -3491,7 +3662,7 @@ requires-dist = [ { name = "uv-outdated", marker = "python_full_version >= '3.12' and extra == 'dev'", specifier = ">=0.1.0" }, { name = "uv-secure", marker = "python_full_version >= '3.12' and extra == 'dev'", specifier = ">=0.12.0" }, ] -provides-extras = ["pixels", "heif", "visible", "video", "detect", "diffusion", "qwen-zimage", "trustmark", "lama", "migan", "dev", "all"] +provides-extras = ["pixels", "heif", "visible", "video", "detect", "diffusion", "qwen-zimage", "trustmark", "verify", "lama", "migan", "dev", "all"] [[package]] name = "requests" @@ -3654,12 +3825,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + [[package]] name = "stamina" version = "26.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "tenacity" }, + { name = "tenacity", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/80/bd/b2f71ae14368a066f103d182f25bbc6c3bf4aa695889f3ed3cba026d6f36/stamina-26.1.0.tar.gz", hash = "sha256:0214d05fdf5102c518194a4aac7520ce53cf660550ae3b940701aad88cf50c17", size = 568171, upload-time = "2026-04-13T17:44:31.012Z" } wheels = [ @@ -3954,12 +4134,21 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/87/0a/0a4232030c6a62d12b6a02ae73bdce6e99c8532bc8f05a5a2e6ce103da82/trustmark-0.9.1.tar.gz", hash = "sha256:dc79e3fb070f5d94765acf8868a51f50a612cc05b53223cf1e6b605d4ff1e0ae", size = 63949, upload-time = "2026-04-09T08:59:52.472Z" } +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typeguard" version = "4.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b4/de/4420db493fa8fc0856d5e5c1b159c63a323d2de2317babe36b01568928e8/typeguard-4.6.0.tar.gz", hash = "sha256:e7414f09111317de3e335de92cd397c5c0ca00b1cc1676de12e1d444a79b3f21", size = 82330, upload-time = "2026-07-26T08:40:23.207Z" } wheels = [ @@ -4025,10 +4214,10 @@ name = "uv-outdated" version = "1.0.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "packaging" }, - { name = "pydantic" }, - { name = "rich" }, - { name = "typer" }, + { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "pydantic", marker = "python_full_version >= '3.12'" }, + { name = "rich", marker = "python_full_version >= '3.12'" }, + { name = "typer", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/38/84/78736b81c0e6ebefd3810b04a3bc6cb82bf7ea63474821b02d5cd9040439/uv_outdated-1.0.4.tar.gz", hash = "sha256:126745028823d8d452a82faaf53ea1d4ab5cdea7bba3159fc2ce7e5d0443146c", size = 19176, upload-time = "2025-12-25T10:54:22.77Z" } wheels = [ @@ -4040,18 +4229,18 @@ name = "uv-secure" version = "0.17.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "cvss" }, - { name = "httpx" }, - { name = "humanize" }, - { name = "inflect" }, - { name = "orjson" }, - { name = "packaging" }, - { name = "pydantic", extra = ["email"] }, - { name = "rich" }, - { name = "stamina" }, - { name = "tomlkit" }, - { name = "typer" }, + { name = "anyio", marker = "python_full_version >= '3.12'" }, + { name = "cvss", marker = "python_full_version >= '3.12'" }, + { name = "httpx", marker = "python_full_version >= '3.12'" }, + { name = "humanize", marker = "python_full_version >= '3.12'" }, + { name = "inflect", marker = "python_full_version >= '3.12'" }, + { name = "orjson", marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "pydantic", extra = ["email"], marker = "python_full_version >= '3.12'" }, + { name = "rich", marker = "python_full_version >= '3.12'" }, + { name = "stamina", marker = "python_full_version >= '3.12'" }, + { name = "tomlkit", marker = "python_full_version >= '3.12'" }, + { name = "typer", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/71/99/29318cedfc5583cf2d503f0eedb9c4e96829541c356ce5d2aacfe09ef67f/uv_secure-0.17.2.tar.gz", hash = "sha256:e394939e0872df392d8f650d15ac1571b9267fc2f3671a183aa73c0977f0f402", size = 47240, upload-time = "2026-04-18T08:45:38.185Z" } wheels = [