Add production SynthID routing and OpenAI verification

This commit is contained in:
Victor Kuznetsov
2026-08-14 13:35:22 -07:00
parent faf976d53e
commit 2d018d32ab
32 changed files with 5518 additions and 118 deletions
+4
View File
@@ -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.
+4
View File
@@ -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=
+28 -2
View File
@@ -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")
+33 -1
View File
@@ -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`.
+13 -1
View File
@@ -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.
+163 -4
View File
@@ -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
+19 -3
View File
@@ -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 |
File diff suppressed because it is too large Load Diff
+1011 -10
View File
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -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"
@@ -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("<memory>"))
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()
+352
View File
@@ -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()
+69
View File
@@ -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()
+151
View File
@@ -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()
+119
View File
@@ -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()
+18 -7
View File
@@ -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, ...]:
+8
View File
@@ -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}")
+46
View File
@@ -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))
@@ -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)
+154 -5
View File
@@ -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(
+3 -1
View File
@@ -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):
+76
View File
@@ -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."""
+272
View File
@@ -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)
+2 -1
View File
@@ -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)
@@ -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)
+240
View File
@@ -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)
+82
View File
@@ -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)
+78
View File
@@ -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
+138
View File
@@ -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"
@@ -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},
]
+11
View File
@@ -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)
Generated
+261 -72
View File
@@ -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 = [