mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-31 09:40:38 +02:00
Add production SynthID routing and OpenAI verification
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+6
-1
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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, ...]:
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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},
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user