Compare commits

..
15 Commits
Author SHA1 Message Date
Victor Kuznetsov 1a38c556f8 Merge pull request #77 from wiltodelta/claude/image-metadata-detection-benchmark-0bb6ec
Add versioned metadata and pixel evidence transports
2026-08-05 21:19:32 -07:00
Victor Kuznetsov a83952e375 Add versioned forensic metadata transports 2026-08-05 21:10:55 -07:00
Victor KuznetsovandClaude Opus 5 1124c591be Match the vendor registries against metadata, not coded pixels
The registries are raw substrings and the shortest tokens are four and five bytes
(`Bria`, `Adobe`, `Canva`). Over a megabyte of compressed pixel data such a sequence
turns up by chance: `Bria` matched inside the entropy-coded scan of 4 of 14,707
corpus JPEGs, in none of which the manifest names Bria. The rate is what a four-byte
pattern predicts on that corpus, and the Bria entry asserts AI, so a chance match can
declare an image AI-generated rather than merely mislabel its signer.

`_metadata_region` gives the registry scans the container's metadata: JPEG marker
segments before the coded scan, PNG chunks other than IDAT, both trailers, and
whatever `scan_head` appended past the window. Every other check keeps the full
buffer -- their markers are long and distinctive. A container that does not parse is
returned whole, since dropping real evidence to avoid a chance match is the wrong
trade. `c2pa_marker_in` already refuses a bare `c2pa` substring for this reason;
this is the same defence for the registries.

Verified the way the rules require for a change that MOVES a verdict: over all 48,905
corpus images, exactly one file changed, the one named in advance, from
"C2PA Content Credentials (Bria Artificial Intelligence)" to "(unknown signer)".
Record-path parity is 0 disagreements, down from 75 when this work started.

The audit's own baseline comparison is fixed here too. It compared confidence and
signals only, and so reported "0 changed" for the run whose single intended
correction was a watermark line -- the change it exists to show.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:10:55 -07:00
Victor KuznetsovandClaude Opus 5 9a29dcac8a Keep the pixel forensics in the library, drop the ai-score tooling
`scripts/ai_score.py` and the dataset scanner that fed it are gone: the detector
they trained is not something this project runs, and the corpus lived outside the
repository anyway. Nothing else referenced them.

The scanner's pixel layer was worth keeping, so it moves into the package as
`pixel_evidence.py` -- six families of scale-robust statistics (block-DCT histograms
and Benford deviation, FFT band energies and CFA peaks, high-pass residual, error
level, gradient, colour) measured in a single shared decode. The arithmetic was
verified against the scanner over 60 corpus images, families and artifacts alike,
before the scanner was removed; that comparison is no longer possible, which is why
the tests now pin behavior instead: determinism, empty-not-wrong on images too small
for a family, and one failing family not taking the others with it.

It has no consumer. Nothing in the package reads it, and the module says so.

`artifacts=True` returns the spatial layer -- perceptual hash, 128px thumbnail,
coarse ELA/residual/phase maps. Those identify the source image rather than describe
it, so they are opt-in and separate: everything else is a scalar or a fixed-length
histogram nothing can be reconstructed from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:10:55 -07:00
Victor KuznetsovandClaude Opus 5 b5da0510c9 Release 0.26.0
Updates the three version sources the release doc names -- `pyproject.toml`,
`__init__.py`, and the root package entry in `uv.lock` -- and carries the marker
simplifications uv produced when it re-resolved the lock.

The release itself is not started here: the tag, push, and GitHub Release are the
remaining steps, and PyPI publishing triggers on the published Release rather than
on a tag push.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:10:38 -07:00
Victor KuznetsovandClaude Opus 5 bebff368fc Decide the SynthID proxy in the verdict, where both extractors meet
A full-corpus audit of the record path against the file path found 75 of 48,905
images disagreeing, and 74 were one gap: the SynthID byte scan for containers whose
manifest no parser reaches lived in `get_ai_metadata`, an extractor the record path
does not run. The record silently reported no SynthID for images `identify` flagged.

Moving the scan into `identify_from_evidence` fixes it by construction rather than by
copying the rule into a second extractor -- the same shape `soft_binding` already
uses. Its byte checks mirror `metadata.synthid_source` literally instead of reusing
the broader `has_c2pa` / `c2pa_source_kind` derived above, so the file path's answers
do not move: verdicts over a 4,000-image sample are byte-identical.

`scripts/record_parity_audit.py` is the audit itself, now repeatable. It walks a
dataset, judges every image through both seams with the record round-tripped through
JSON, and reports disagreements by field and by signal. The rule in
`.claude/rules/development.md` says to re-run both sides of this seam after changing
either; this is what to run.

Both timing and audit scripts now put the package's OWN `src` on the path. From a
worktree an editable install resolves to the main checkout, so the audit imported a
different tree than the one under test -- the failure the same rules file warns about,
reproduced within an hour of writing it down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:10:38 -07:00
Victor KuznetsovandClaude Opus 5 2668f1302d Read WebP metadata past the scan window and surface C2PA reader failures
Three gaps found while measuring the record path against the file path, each one
a signal the library could not see:

WebP stores `XMP ` after the pixels, so on any WebP above the scan window a fixed
read stops short of the label. `_riff_late_metadata` steps over the coded image to
reach it, the RIFF analogue of the existing PNG and ISOBMFF readers. Three corpus
files hid an IPTC "Made with AI" tag and a C2PA `trainedAlgorithmicMedia` there.
The decoder-backed fallback now covers only what it is actually for -- metadata the
raw bytes do not spell, such as a compressed PNG `zTXt` packet.

A C2PA reader failure returned the same `None` as a file with no manifest, so a
verdict could fall back to the raw byte scan with no trace anywhere. Failures now
log at warning and only genuine ones do: a file without credentials never reaches
that branch, and an unsupported container is demoted to debug through the reader's
own `C2paError.NotSupported`. The first corpus run with it found a truncated PNG.

`scan_dataset.py` never registered the pillow-heif opener it declares as a
dependency, so every HEIC was scanned as unreadable -- no EXIF, and a pixel layer
that was 397 of 406 features NaN instead of 136.

`_riff_late_metadata` caps its total like `isobmff.scan_c2pa_region` does. Clamping
each chunk to the bytes remaining is not enough on its own: one chunk can declare a
length spanning most of the file, and this runs on the memoized verdict path over
images from arbitrary sources.

Also lands `identify_metadata_record` and `ProvenanceReport.to_dict()`, the
one-call entry point and the versioned JSON contract for the record path.

Record-vs-file equality holds over 3,478 corpus images, and the eight files these
fixes recovered still report AI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:10:38 -07:00
Victor KuznetsovandClaude Opus 5 0c5961a0ed Add a portable metadata record so collection and verdict can run apart
`collect_metadata_record` returns a JSON-safe record carrying an image's
provenance metadata regions -- never its pixels -- and the existing
`evidence_from_metadata_record` + `identify_from_evidence` build the verdict
from it without opening the file. The contract is equality with
`identify(path, metadata only)`, verified over the tracked fixtures and over a
local corpus of 3,478 images (every file carrying a rare signal, plus a random
slice): zero differences.

Three placements defeated earlier drafts and each is now a rule with a test:
the `scan_head` buffer is the head CONCATENATED with late metadata, so a
structural walk must read the raw head instead; Samsung splits its evidence
between a post-EOI trailer and the coded scan; and PIL's info keys must be
emitted in the file path's candidate order, since the first token match wins.

Also fix a real detection gap found while establishing that equality: a label
the decoder can read but a raw byte scan cannot -- a compressed PNG `zTXt`
packet, or a WebP XMP chunk past the scan window -- was invisible to
`identify`. Eight corpus files carrying a China TC260 AIGC label or an IPTC
"Made with AI" tag were reported as no signal at all.

`scripts/detection_timing.py` and its report script measure the metadata path
per method; they write outside the repository and are read-only over a dataset.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:10:38 -07:00
Victor Kuznetsov f481e6f944 Merge pull request #76 from wiltodelta/refactor/invisible-options-are-engine-knobs
Make InvisibleOptions engine knobs only and pin the forwarding
2026-08-04 19:38:07 -07:00
Victor KuznetsovandClaude Opus 5 4a896cd4b5 Make InvisibleOptions engine knobs only and pin the forwarding
`InvisibleOptions` promises in its docstring that every default mirrors
`InvisibleEngine`. Two fields made that promise cost something to keep: `force` is
not an engine parameter at all, and `controlnet_scale` was a third spelling of the
engine's `controlnet_conditioning_scale`. The mirror test carried an exception
table for each. This removes both, so the comparison needs no exceptions -- a field
that needs one is a field that belongs somewhere else.

`force` decides WHETHER the engine runs, which is settled before it is built, so it
joins `backend` and `sensitivity` as a parameter of `remove_all` and `remove_batch`
and is threaded to `_run_invisible` as its own argument. `controlnet_scale` takes
the engine's own name; the click option stays `--controlnet-scale` and is now
translated exactly once instead of at three forwarding sites.

Safe to do today: both symbols landed after 0.25.0 and have never been published.

The forwarding turned out to be the weaker half. A defaults comparison cannot see a
hardcoded literal at the seam, and `_run_invisible` passed the entire suite with
`controlnet_conditioning_scale` pinned to a constant. Each of the two knobs also
reaches the engine through TWO paths -- `remove_all` versus `remove_batch(mode="all")`
for `force`, `_run_invisible` versus `_batch_engine` for the scale -- and guarding one
left the other free to hardcode with a green suite. So:

  * `test_every_field_arrives_at_the_engine_with_the_caller_s_value` drives the real
    seam with all 13 fields set off their defaults; mutating any one of them to its
    default fails it.
  * `test_force_reaches_the_scrub_gate_in_every_scrubbing_mode` and
    `test_batch_controlnet_scale_flows_to_the_cached_engine` are parametrized over
    both modes, so neither path can be pinned alone.

Also fixes an order-dependent test surfaced by the added tests reshuffling the xdist
shards. `test_visible_path_decodes_file_once` counted every `image_io.imread` in the
process, but the Gemini engine loads its own bundled capture assets on first
construction, so the count was 3 on a cold engine and 1 on a warm one and the test
passed only when an earlier test happened to build the engine first. It now counts
decodes of the SOURCE, which is the invariant it exists for, and still fails when the
shared decode is broken. The production path was never wrong: the source bitmap is
decoded exactly once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 19:33:54 -07:00
Victor KuznetsovandClaude Opus 5 13095fb45c Verify every doc claim against the source and fix what drifted
Every code-referencing claim in the docs, the README and the rules files was
checked against src/, and each finding was re-derived independently before it
was applied. 35 held, 5 were false positives.

Two of them were code, not text. `InvisibleOptions` promises in its docstring to
mirror `InvisibleEngine`, and two defaults had silently stopped:
`max_resolution=None` reached `_target_size`'s `max_resolution > 0` and raised
`TypeError` on every library call that left the options alone, and
`cpu_offload=True` made a library run slower than the identical CLI run. Both are
fixed, and `TestInvisibleOptionsMirrorTheEngine` compares the two signatures
field by field rather than pinning the two values that happen to be known. A
companion assertion in `TestTargetSize` reads the engine's own declared default,
so a drift on the engine side -- which the mirror check alone would accept,
because both sides would still agree -- fails too.

The user-facing docs: README called `invisible` GPU-optional where it raises
without CUDA, and gave the image `metadata` command `video metadata`'s output
rule, promising the source survives a command that overwrites it. Yuanbao was
missing from the supported-mark list. `veo` was listed among the video policies
that require a run anchor, though its row sets no `anchor_iou`.
`known-limitations` called ControlNet the default profile and contradicted
itself ninety lines below. An unescaped pipe truncated the `hailuo` table row.
The `dev` extra, the CI shape, ffmpeg's role, the sdist boundary and the
strength-curve range were corrected, and `remove_all`/`remove_batch`, the pill
gate, `erase --keep-metadata` and `all`'s CUDA failure mode were documented.

Research notes that described removed modules, extras and flags in the present
tense now say so once in the page banner instead of sentence by sentence, which
covers the whole page rather than the lines that happened to be noticed, and one
fixture is referred to by role rather than by name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 11:38:04 -07:00
Victor Kuznetsov 0261ec1bd7 Merge pull request #75 from wiltodelta/refactor/detection-path-and-library-pipeline
Collapse the duplicated detection path and lift the image pipeline into the library
2026-08-04 10:21:43 -07:00
Victor KuznetsovandClaude Opus 5 78d9e81d0f Collapse the duplicated detection path and lift the image pipeline into the library
The visible-mark path had grown three copies of one ladder sweep, four
near-identical `detect` arms, and four hand-rolled `footprint_mask` overrides;
mark knowledge sat in five hand-maintained tables across three modules; and the
flagship `all`/`batch` pipeline existed only in cli.py, written twice with
divergent behavior.

Detection is now one measurement. `_ladder_best` replaces the three sweeps,
`_scan`/`_verdict` replace the four arms, and the winning box travels to the
mask on `TextMarkDetection.match_box` instead of being swept a second time.
`detect_both` returns the strict and relaxed verdicts from one scan, which
halves the arbiter's perception cost (260 -> 130 matchTemplate calls on a 2048²
image, verdicts identical field for field). A per-mark demotion goes in the new
`_post_gate` hook, never in a `detect` override -- an override is invisible to
the single-pass path, which is how the RunningHub and Yuanbao anchor gates
briefly stopped applying.

Everything about a mark is now one registry row: product, label regime, the
platform sentence `identify` reports, the metadata signals that confirm it, and
its TC260 producer codes. `identify._VISIBLE_MARK_PLATFORM`, the signal mapping
in `api.visible_provenance`, `_PRODUCT_OF` and the pill veto are derived from
those rows.

`api.remove_all` / `api.remove_batch` are the library form of the `all` and
`batch` commands; the CLI is a wrapper that owns console text and exit codes.
Progress is a `(stage, detail)` pair of stable tokens, so the CLI keys its
wording off structure rather than parsing the library's prose back.

Two intentional behavior changes, both verified against a recorded 811-image
sample of detector verdicts, removal-mask hashes, arbiter decisions and
`identify` reports:

  * A TC260 label now relaxes the vendor its `ContentProducer` names rather than
    ByteDance's pair on every China-AIGC image. 333 of 811 samples move; on 185
    of them the previously relaxed pair was simply the wrong vendor, and the
    mark actually present never reached the relaxed gate its own
    `provenance_ncc_factor` was calibrated for.
  * A confident LibLibAI detection suppresses the Jimeng pill, like every other
    TC260 product's mark. It was registered alongside RunningHub and Baidu, both
    of which were added to the hand-written veto list, and it was not. 1 sample
    moves, and it is exactly the co-firing case.

Nothing else in that record changes: detector verdicts, mask hashes and
`identify` verdicts are byte-identical, and all 200 calibration constants are
untouched.

Also: `aigc_label` and friends plus `extract_c2pa_info` are memoized on
(path, mtime_ns, size) -- size because this package rewrites in place; the
native TC260 container readers route on magic bytes instead of the file
extension, so a mislabeled AVI or FLV is no longer invisible; `identify` shares
one pixel decode between the DWT-DCT and visible stages (TrustMark keeps its own
Pillow decode, which is not substitutable); and the six `stabilize_*` video
wrappers collapse into one policy table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:49:45 -07:00
Victor KuznetsovandClaude Opus 5 480f478484 Refresh the dependency lockfile
Patch and minor only: cffi, coverage, hf-xet, modelscope-hub, typer. The majors
the resolver held back (numpy 2, opencv 5, tokenizers 0.23) are the ones
.github/dependabot.yml already documents as blocked upstream.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:49:18 -07:00
github-actions[bot] cb4590c61f Sync conda recipe with v0.25.0 2026-08-04 00:03:04 +00:00
80 changed files with 7403 additions and 2809 deletions

No files matched your search

+59 -1
View File
@@ -1,6 +1,6 @@
---
globs: ["src/**/*.py", "tests/**/*.py", "scripts/**/*.py", "pyproject.toml", "uv.lock", "maintain.sh", ".github/workflows/*.yml"]
description: Command contracts, project gate, typing boundaries, and model-adjacent test invariants.
description: Command contracts, project gate, typing boundaries, model-adjacent test invariants, and the detection-path measurement rule.
---
# Development invariants
@@ -23,6 +23,8 @@ Run `bash maintain.sh` from the repository root. The authoritative type gate is
Boundary modules for cv2, Torch, and Diffusers may carry narrow per-file relaxations for unknown third-party types. Keep pure-logic files strict, preserve the local piexif stub, and fix real errors before widening a pragma.
From a worktree, `uv run` imports the package from the MAIN checkout -- that is where the editable install points. A script measuring a worktree's edit must insert that worktree's `src` at `sys.path[0]` and assert `module.__file__` resolves inside it, or it silently compares unmodified code against itself.
## Model-adjacent tests
Do not classify an entire module as untestable because its main path downloads a model. Keep pure behavior covered without downloads, including:
@@ -37,7 +39,63 @@ Do not classify an entire module as untestable because its main path downloads a
stage's; `sdxl-zimage` shipped doing exactly that and crashed on every image with a
face. When one profile inherits another's stage, guard the invariants that stage
relies on, not just the code path.
- the `InvisibleOptions` defaults, in `test_api.py`. When one signature promises to
mirror another, compare them field by field rather than pinning the values you happen
to know about, so the next field added on one side and not the other fails at the
seam. Two of these defaults drifted in practice and neither needed a GPU to catch;
the incident is recorded in `docs/module-internals.md`. Keep the comparison free of
an exception table: a field that needs one is a field that belongs elsewhere, which
is what `force` turned out to be.
A defaults comparison is not a forwarding test, and the two fail differently. Pin the
VALUE at the seam, not just the name -- `_run_invisible` passed the whole suite with
`controlnet_conditioning_scale` hardcoded, because nothing asserted the caller's value
arrived. `test_every_field_arrives_at_the_engine_with_the_caller_s_value` drives the
real seam with every field set off its default, so one test covers the whole bag
instead of one assertion per knob.
Count the seams before believing a knob is covered. Each of `force` and
`controlnet_conditioning_scale` reaches the engine through TWO paths -- `remove_all`
versus `remove_batch(mode="all")` for the first, `_run_invisible` versus `_batch_engine`
for the second -- and in both cases guarding one path left the other free to hardcode a
constant with a green suite. The mode-parametrized guards in
`TestRemoveBatchLibrary::test_force_reaches_the_scrub_gate_in_every_scrubbing_mode` and
`TestBatchCommand::test_batch_controlnet_scale_flows_to_the_cached_engine` exist because
that is what actually happened.
Use availability checks only for paths that actually load large models.
## One measurement, one gate seam
A detector is split into a trust-level-blind scan and a verdict that applies the
threshold, so `detect` and `detect_both` reach the same numbers by construction. Two
rules follow, and both were broken in practice before they were written down:
- A per-mark demotion goes in the `_post_gate` hook (or, for a whole-scan precondition
like LibLibAI's size floor, in `_scan`) -- never in a `detect` override. An override
is invisible to `detect_both`, so the RunningHub and Yuanbao anchor gates silently
stopped applying on the arbiter's perception path. `TestSinglePassPerception` is the
guard: it asserts `detect_both` equals two `detect` calls field for field.
- 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.
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.
A third seam reaches the same verdict: `collect_metadata_record` ->
`evidence_from_metadata_record` -> `identify_from_evidence`, the path a caller uses when
collection and verdict run on different machines. Its contract is equality with
`identify(path, check_visible=False, check_invisible=False)` on the same image, and it
can break from EITHER side -- a region the collector stops walking, or a placement the
file path learns to read and the record does not. `tests/test_metadata_record.py` pins
it over the tracked fixtures; a separate local evaluation corpus catches placements the
fixtures do not cover. Change either side and re-run both.
Before changing anything in the detection path, record the detectors' exact verdicts
over a local sample first and diff them after. A refactor here is only correct if that
record is byte-identical, and a green test suite does not establish that on its own. A
change that is meant to FIX detection is the exception that proves the rule: the diff
must then be exactly the files you intended to change, named in advance.
Environment setup, dependency recovery, CI behavior, and fixture policy: [`../../docs/development.md`](../../docs/development.md).
+21
View File
@@ -45,6 +45,27 @@ jobs:
- name: Run tests
run: uv run pytest -q
transport-contract-python-314:
name: versioned transport contracts py3.14
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v7
with:
python-version: "3.14"
- name: Create clean runtime contract environment
run: uv venv --python 3.14 .venv-contract
- name: Install runtime contract dependencies
run: >-
uv pip install --python .venv-contract/bin/python
".[pixels,heif]" "numpy>=2,<3" "pytest>=8"
- name: Run versioned transport contract tests
run: >-
.venv-contract/bin/python -m pytest -q
tests/test_forensic_metadata.py
tests/test_metadata_record.py
tests/test_pixel_evidence.py
video-e2e:
name: video full-clip end-to-end
runs-on: ubuntu-latest
+4 -14
View File
@@ -20,8 +20,7 @@ Thumbs.db
*.swp
*.swo
# SynthID corpus reference fills (synthetic black/white calibration tiles,
# regenerable; the labeled pos/neg/cleaned images ARE tracked, see README)
# Generated calibration inputs
data/synthid_corpus/refs/
# Reference materials
@@ -35,11 +34,7 @@ yolov8n.pt
.claude/settings.local.json
.claude/scheduled_tasks.lock
# Visible-watermark alpha calibration. The solid black/gray/white CAPTURES are
# committed (content-free: a solid colour + the watermark; the source for
# scripts/visible_alpha_solve.py so the alpha assets are reproducible). The
# synthetic seeds (regenerable) and any real-content validation download (a real
# generated scene, kept local for privacy) are NOT committed.
# Generated calibration inputs and local evaluation artifacts
data/doubao_capture/seeds/
data/jimeng_capture/seeds/
data/jimeng_capture/captures/jimeng_content_*.png
@@ -48,13 +43,8 @@ data/gemini_capture/captures/gemini_content_*.png
data/samsung_capture/seeds/
data/samsung_capture/captures/samsung_content_*
# Leftover GFPGAN weights dir from the retired face-restore experiments
# (GFPGAN wrote RetinaFace/parsing weights to a CWD ./gfpgan/weights/ working
# dir on first use). Runtime artifact, never committed.
# Runtime model artifacts
gfpgan/
# Qwen ControlNet experiment outputs (throwaway eval; never the committed corpus)
scripts/_qwen_exp_out/
# Local-only working data for analysis (not a committed corpus; never tracked)
# Local evaluation data
.local-eval/
+1 -1
View File
@@ -51,4 +51,4 @@ Topic-specific rules live in `.claude/rules/*.md` and are auto-loaded when match
| File | Covers |
|---|---|
| `development.md` | Command contracts, project gate, typing boundaries, and model-adjacent tests |
| `development.md` | Command contracts, project gate, typing boundaries, model-adjacent tests, and the detection-path measurement rule |
+10 -6
View File
@@ -39,7 +39,7 @@ removal.
| Remove a registered visible AI mark from video | `video visible` | No |
| Process a directory of videos | `video batch` | Depends on mode |
| Remove video SynthID with the certified VAE profile | `video invisible` | Recommended |
| Regenerate an image to disrupt invisible watermarks | `invisible` | Recommended |
| Regenerate an image to disrupt invisible watermarks | `invisible` | Required (CUDA) |
| Run visible, invisible, and metadata removal | `all` | Recommended |
| Process a directory | `batch` | Depends on mode |
@@ -92,6 +92,8 @@ Strip metadata without running visible inpainting or diffusion:
remove-ai-watermarks metadata image.png --remove -o clean.png
```
Without `-o` this command overwrites the source in place.
Inspect or remove AI metadata from an MP4, MOV, M4V, WebM, MKV, AVI, or FLV
file:
@@ -100,8 +102,9 @@ remove-ai-watermarks video metadata input.mp4 --check
remove-ai-watermarks video metadata input.mp4 --remove -o clean.mp4
```
The metadata command does not transcode video or audio streams. When `-o` is
omitted it writes `<source>_clean` and preserves the original. MP4 and MOV
The `video metadata` command does not transcode video or audio streams. Unlike
the image command above, when `-o` is omitted it writes `<source>_clean` and
preserves the original. MP4 and MOV
inspection includes the native TC260 `AIGC` tag in
`moov.udta.meta.keys/ilst`, including a `moov` placed after the media payload.
MKV and WebM inspection reads the normative
@@ -288,7 +291,7 @@ remove-ai-watermarks batch ./images --mode all
Visible mark support includes:
- Google Gemini and Nano Banana sparkle;
- Doubao, Jimeng, Qwen, Kling, Baidu, LibLibAI, and RunningHub labels;
- Doubao, Jimeng, Qwen, Kling, Yuanbao, Baidu, LibLibAI, and RunningHub labels;
- one calibrated Samsung Galaxy AI label variant.
Metadata and provenance inspection covers C2PA, EXIF, XMP, IPTC, common
@@ -351,8 +354,9 @@ The high level API accepts a file path or a BGR NumPy array. For path inputs it
also reads provenance metadata, preserves alpha, and can strip AI metadata from
the written result.
See the [Python API guide](docs/python-api.md) for visible removal, provenance
inspection, metadata stripping, and diffusion usage.
See the [Python API guide](docs/python-api.md) for visible removal, the full
`remove_all` and `remove_batch` pipeline, provenance inspection, metadata
stripping, and diffusion usage.
## ComfyUI
+22 -7
View File
@@ -22,8 +22,9 @@ defaults. This page focuses on choosing the right command.
| `visible` or `erase` with big-LaMa | `remove-ai-watermarks[lama]` |
| `invisible` and `all` (needs CUDA) | `remove-ai-watermarks[qwen-zimage]` |
| `video metadata` and `video identify --no-visible` | Default package |
| `video identify`, `video visible`, and visible/all batch modes | `remove-ai-watermarks[video]` |
| `video invisible` and `video all --invisible` | `remove-ai-watermarks[video,diffusion]` |
| `video identify` | `remove-ai-watermarks[video]` |
| `video visible`, `video all`, and visible/all batch modes | `remove-ai-watermarks[video]` plus ffmpeg on PATH |
| `video invisible` and `video all --invisible` | `remove-ai-watermarks[video,diffusion]` plus ffmpeg on PATH |
| HEIC/HEIF/AVIF pixel input | Add `remove-ai-watermarks[heif]` |
| Every production command and backend | `remove-ai-watermarks[all]` |
@@ -67,7 +68,8 @@ remove-ai-watermarks visible image.png -o clean.png
The default behavior:
- checks every registered visible mark;
- removes every detected match;
- removes every detected match, except the weakly detected Jimeng label pill,
which needs corroboration (see [supported signals](supported-signals.md));
- selects the best installed fill backend;
- strips AI metadata from the output.
@@ -128,6 +130,13 @@ remove-ai-watermarks erase image.png \
`erase` accepts `cv2`, `migan`, and `lama`. The corresponding optional extra
must be installed for a learned backend.
Two more knobs tune the fill. `--dilate N` (default 3) grows every box by `N`
pixels before inpainting, which helps when a mark has a soft edge or a drop
shadow just outside the box you measured; it applies to every backend because it
shapes the mask. `--inpaint-method telea|ns` selects the classical algorithm and
only affects the `cv2` backend. Like `visible`, `erase` strips AI metadata from
the output by default; pass `--keep-metadata` to retain it.
## Strip AI metadata
Inspect metadata:
@@ -433,10 +442,16 @@ The command runs:
The visible options and diffusion options are also available on `all`.
If invisible removal is required but the `qwen-zimage` extra is unavailable, `all` still
writes the result of the visible and metadata stages, prints a prominent
warning, and exits with code 1. This prevents a partial result from being
reported as complete.
When the `qwen-zimage` extra is unavailable, `all` still writes the result of
the visible and metadata stages, prints a prominent warning, and exits with code
1. That happens on every run without the extra: the skipped stage is what would
have decided whether a signal was there. This prevents a partial result from
being reported as complete.
If the extra is installed but the machine has no CUDA, which is the usual macOS
case, the run fails at engine construction instead: `all` prints
`Error: Invisible-watermark removal is CUDA-only ...`, writes no output at all,
and exits with code 1.
## Process a directory
+9 -6
View File
@@ -3,7 +3,10 @@
> Research archive. This document records experiments, superseded defaults, and
> deployment considerations from the time of the study. It does not define the
> current CLI or Python API. See `README.md`, `docs/cli.md`, and
> `docs/known-limitations.md` for current behavior.
> `docs/known-limitations.md` for current behavior. Everything it names on the
> face-restoration path was removed after this study: the `--restore-faces` and
> `--face-id` flags, the `instantid`, `photomaker` and `faceid` extras, and their
> modules. So were the `controlnet`, `sdxl`, `qwen` and `default` profiles.
Date: 2026-06-02. Source: a manual primary-source pass (WebSearch + WebFetch over the
watermark-removal-attack and SDXL-ControlNet literature). Prompted by issue #35
@@ -55,8 +58,7 @@ content it protects. Controlnet is the text/structure PRESERVATION pipeline; rem
is set by STRENGTH, separately calibrated, not by the pipeline choice.
This section is the single consolidated reference for the controlnet pipeline's
removal behavior. (Mirrored briefly in `docs/synthid.md` §5.5 and the CLAUDE.md
controlnet / face_restore bullets, which point here.)
removal behavior. (Mirrored briefly in `docs/synthid.md` §5.5, which points here.)
### What we measured (real gpt-image + Gemini originals)
@@ -288,8 +290,8 @@ text-vs-scrub tension as an empirical question to measure, not assume.
**Prototype (runs locally on 32 GB MPS — no dedicated GPU required):**
Compute is NOT the bottleneck. On a 32 GB Apple-silicon machine (M5 here) native SDXL already
runs entirely on MPS with no CPU fallback (~155 s at 1122x1402, verified — see `synthid.md` /
CLAUDE.md). The prototype runs at **1024** (fewer pixels than that) with SDXL base + an SDXL
runs entirely on MPS with no CPU fallback (~155 s at 1122x1402, verified 2026-05-31).
The prototype runs at **1024** (fewer pixels than that) with SDXL base + an SDXL
ControlNet + activations in **fp32** (MPS fp16 decodes to all-black NaN — issue #29 — confirmed
on run 1 below; fp32 is the required default on mps/cpu) — fits the 32 GB budget with vae-tiling +
attention-slicing; ~1-2 min/image, so a coarse sweep is a sub-hour background run. A dedicated GPU
@@ -534,7 +536,8 @@ https://instantid.github.io/ · https://arxiv.org/abs/2308.06721 (IP-Adapter)
### FaceID prototype run 1 -- 2026-06-03 (NEGATIVE on dense small-face groups)
Built and shipped the masked multi-face FaceID layer (`--face-id`, `face_id.py`, `faceid` extra).
Built and shipped the masked multi-face FaceID layer (`--face-id`, `face_id.py`, `faceid` extra;
all three were removed after this run and never reached the tracked tree).
First real run on the gemini_3 group photo (Google, s015, scale 0.6, native 2816 via cap 1536):
insightface detected **17 faces**, the masked multi-face pass composed and ran end-to-end (non-black
output), so the API is correct. **At s015 the result is a clear FAILURE: every face corrupted --
+1 -1
View File
@@ -14,7 +14,7 @@ The optional TrustMark decoder downloads weights into its installed package dire
## CI
`.github/workflows/test.yml` runs Ruff and a cross-platform supported-Python test matrix with default plus development dependencies. Diffusion and model-running tests skip in that matrix; metadata, identification, visible removal, the DWT-DCT decoder, and the OpenCV eraser remain covered across operating systems.
`.github/workflows/test.yml` runs Ruff, a cross-platform test matrix over the supported-Python floor and the latest supported minor with default plus development dependencies, and a separate job that installs ffmpeg on Ubuntu to run the full-clip video test. Diffusion and model-running tests skip in that matrix; metadata, identification, visible removal, the DWT-DCT decoder, and the OpenCV eraser remain covered across operating systems.
Keep `uv.lock` compatible with `uv sync --frozen`. Dependency pull-request checks use GitHub's merge result against current `main`; if `main` moves, merge it locally and rerun the full gate because a newer linter can expose stale directives in later code.
+1
View File
@@ -19,6 +19,7 @@ to run the tool. Use the maintainer references only when changing the code.
| Page | Purpose |
| --- | --- |
| [Module internals](module-internals.md) | Current architecture, invariants, and regression guards by module. |
| [Development](development.md) | Environment setup, dependency recovery, CI behavior, and fixture policy. |
| [Code provenance](code-provenance.md) | Required notices for licensed derivative work. |
| [Verification plan](verification-plan.md) | Verification methods, completed measurements, and remaining validation gaps. |
| [Release and distribution](release-and-distribution.md) | PyPI, Homebrew, Hugging Face Space, and release workflow. |
+9 -4
View File
@@ -48,7 +48,10 @@ uv tool install --force "remove-ai-watermarks[visible,heif]"
## Video processing
Video metadata inspection and stripping work with the default package. Stable
Video metadata inspection works with the default package, and MP4 and MOV
stripping uses the in-tree ISOBMFF box walker. Stripping the non-ISOBMFF
containers (MKV, WebM, AVI, FLV, and the audio formats) and writing any cleaned
video need ffmpeg on PATH, for example `brew install ffmpeg`. Stable
visible-mark identification and removal, full video cleaning, and visible/all
batch modes need the `video` extra:
@@ -102,7 +105,7 @@ application actually uses:
| `lama` | big-LaMa ONNX fill backend | `visible`, ONNX Runtime | Model download, no Torch |
| `qwen-zimage` | Invisible image-watermark removal, both CUDA-only profiles | `diffusion`, DiffSynth | Yes |
| `all` | Every production feature | All rows above | Yes |
| `dev` | Tests, linting, typing, and upstream parity checks | `visible`, `detect`, upstream invisible-watermark | Yes, for parity tests |
| `dev` | Tests, linting, typing, and upstream parity checks | `video`, `detect`, upstream invisible-watermark | Yes, for parity tests |
Dependency composition:
@@ -188,8 +191,10 @@ Run the complete project gate:
bash maintain.sh
```
The script runs dependency checks, linting, formatting checks, type checking,
and the test suite.
The script syncs every optional backend on top of the `dev` environment above,
then runs dependency checks, linting, formatting, type checking, and the test
suite. It applies Ruff fixes and formatting in place rather than only reporting
them.
## Hugging Face authentication
+6 -6
View File
@@ -57,12 +57,12 @@ Invisible removal does not decode and delete a payload. It regenerates the
image through a diffusion pipeline. Faces, text, colors, and fine detail can
change even when the watermark is successfully disrupted.
ControlNet is the default compatibility profile. It conditions on edges to
preserve structure, but edges do not preserve identity or exact texture.
The CUDA only `qwen-zimage` profile adds a separate face stage and is the
highest fidelity option in the current implementation. It is larger, slower,
and still may alter small text or difficult faces.
`qwen-zimage` is the default profile and `sdxl-zimage` the only alternative.
Both are CUDA only and differ only in the global regeneration model: each
conditions that stage on a canny edge map, which preserves structure but not
identity or exact texture, and each then runs the same face stage.
`qwen-zimage` is the higher fidelity of the two. Both are large, slow, and may
still alter small text or difficult faces.
### Removal cannot be verified locally for proprietary SynthID
+209 -15
View File
@@ -75,12 +75,39 @@ Regression coverage:
## High-level Python API
[`api.py`](../src/remove_ai_watermarks/api.py) provides:
[`api.py`](../src/remove_ai_watermarks/api.py) provides the visible-mark entry
points:
- `remove_visible`
- `visible_provenance`
The package root exposes both lazily through
and the image pipeline that the `all` and `batch` commands are thin wrappers
over:
- `remove_all`, returning a `RemoveAllResult` after the visible, invisible, and
metadata stages
- `remove_batch`, returning a `BatchSummary` for one directory and one mode
- `InvisibleOptions`, the invisible stage's knobs as one immutable value. Engine
knobs only, under the engine's own names and defaults, so a bare
`InvisibleOptions()` behaves exactly like calling the engine with no arguments.
The engine takes them across two callables, `__init__` for what shapes the
loaded stack and `remove_watermark` for the per-image ones, so `_run_invisible`
forwards each field to the right one rather than splatting the whole bag. Two
defaults silently stopped
mirroring: `max_resolution=None` reached `_target_size`'s `max_resolution > 0`
and raised `TypeError` on every library call, and `cpu_offload=True` made a
library run slower than the identical CLI run.
`TestInvisibleOptionsMirrorTheEngine` compares the two signatures field by
field, and deliberately keeps no exception table: a field needing one is a
field that belongs elsewhere. `force` was such a field, and it decides whether
the engine runs rather than how, so it is a parameter of `remove_all` and
`remove_batch` next to `backend` and `sensitivity`
- `MetadataStripIncomplete`, raised before any write when AI metadata survives
`remove_all` reports progress as `(stage, detail)` pairs of stable tokens, not
prose the caller has to parse back.
The package root exposes all of them lazily through
[`__getattr__`](../src/remove_ai_watermarks/__init__.py), keeping a plain package
import free of the heavier image and model imports.
@@ -253,13 +280,17 @@ takes the first stable result in specificity order (`sora`, `veo`, `seedance`,
candidate. Removal also collects authoritative per-frame timestamps for the
encoder, while identification omits that unused ffprobe pass.
Every per-frame result is untrusted. The provider-specific stabilization
wrappers share one recurrence implementation, while retaining separate visual
floors and minimum-run policy. Provenance can relax a low-contrast run only
Every per-frame result is untrusted. Each provider's floors, minimum-run policy,
fill padding and mask style are one row in `VISIBLE_MARK_POLICIES`, and every mark
enters the same `stabilize_localizations` entry point; the recurrence
implementation underneath knows nothing about providers. That policy row also
carries `accepts_provenance`, which forces `provenance=False` for Hailuo and Kling
— they have no metadata that could confirm them, and the guarantee used to be
structural (their wrappers took no `provenance` parameter at all). Provenance can relax a low-contrast run only
after recurring visual evidence exists. Sora transition frames follow the
nearest confirmed moving position only with Sora provenance. Veo, Seedance,
Dola, Hailuo, and Kling additionally require candidates to remain anchored to
the start of a run. This rejects slowly drifting scene details that still have
nearest confirmed moving position only with Sora provenance. Seedance, Dola,
Hailuo, and Kling additionally require candidates to remain anchored to the
start of a run. This rejects slowly drifting scene details that still have
high frame-to-frame overlap. Hailuo and Kling do not infer provenance from
technical encoder tags; their confirmed public samples carried no provider
metadata.
@@ -309,7 +340,16 @@ metadata scanners and `remove_ai_metadata`.
Key contracts:
- `scan_head` is the shared cached input for bounded byte scans.
- `scan_head` is the shared cached input for bounded byte scans. It fills the buffer
in two layers. Structural readers first, one per container, each seeking past the
pixel payload to reach metadata placed beyond the window: `isobmff.scan_c2pa_region`,
`_png_late_metadata`, `_riff_late_metadata`. A decoder-backed fallback last,
`_decoder_visible_text`, for metadata the raw bytes do not spell at all — a
zlib-compressed PNG `zTXt` packet is readable only after inflation. The layers are
ordered that way because the structural readers work on files no decoder can open.
- A C2PA reader failure is logged at warning, not debug. It returns the same `None` as
a file with no manifest, so nothing downstream can distinguish "no credentials" from
"the credentials could not be read", and the second silently downgrades a verdict.
- JPEG stripping walks metadata segments and preserves the entropy-coded image
scan.
- ISOBMFF containers use
@@ -348,13 +388,127 @@ metadata extraction from verdict logic:
- `extract_provenance_evidence` reads the supported metadata signals into
`ProvenanceEvidence`.
- `evidence_from_metadata_record` normalizes an externally collected nested
metadata record into the same evidence type without file access. Diagnostic
values under `error` and `kind` are excluded from evidence while nested raw
bytes remain available through encoded binary fields.
- `identify_from_evidence` evaluates that evidence without reopening the source.
metadata record into the same evidence type without file access. Versioned native
records accept only source-derived fields; filenames, hashes, timings, errors,
prior verdicts, and pixel results cannot become evidence. Unknown native schema
versions and other record types are rejected.
- The vendor registries are matched over `_metadata_region(head)`, not the whole scan
buffer: they see the container's metadata and not its coded pixels. The tokens are
raw substrings and the shortest are four and five bytes, so over a megabyte of
compressed data one turns up by chance, and the entry it hits may assert AI. The
trim happens only when the container parses -- a malformed or unknown one is left
whole, because dropping real evidence to avoid a chance match is the wrong trade.
- `identify_from_evidence` evaluates that evidence without reopening the source. Rules
that decide a verdict live here, not in extraction: extraction has two
implementations, and a rule in only one of them is a rule the other lacks. The
SynthID proxy is the worked example — its structured form comes from the manifest,
and the byte-scan fallback for containers no parser reaches runs in the verdict, so
both extractors reach the same answer. It did not, and the record path silently
reported no SynthID for images the file path flagged.
- `identify` preserves the path-based API and adds the optional registered
visible-mark and open invisible-watermark decoders after extraction.
### Portable metadata record
[`metadata_record.py`](../src/remove_ai_watermarks/metadata_record.py) produces the
record `evidence_from_metadata_record` consumes, so collection and verdict can run
on different machines. Its contract is equality with the file path, and the three
defects found while establishing that equality are the reason each rule exists:
- It walks the file's RAW head, never the `scan_head` buffer. That buffer is the
head concatenated with late metadata payloads, so a structural walk runs off the
end of the real head and parses appended bytes as chunks, inflating the record and
creating false signals.
- Samsung Galaxy AI splits its evidence: the `PhotoEditor_Re_Edit_Data` marker sits
in the post-EOI trailer while the `genAIType` value it is gated on can sit inside
the entropy-coded scan. A marked file therefore keeps the whole tail window, not
just the trailer.
- PIL's info keys are emitted in the file path's own candidate order
(`Software`, `Source`, `Title`, `Description`, then EXIF). `generator_from_metadata`
returns the FIRST candidate carrying a known token, so preserving candidate order
is part of verdict equivalence.
The transport is independently versioned as `provenance_metadata` schema 1. Native
records require the exact integer schema version and a `complete` status. Source
read failures are explicit error records and cannot be judged. WebP walks the full
declared RIFF container by seeking over `VP8`, `VP8L`, `ALPH`, and `ANMF`, so late
XMP/C2PA remains visible without shipping coded frames or parsing appended trailer
bytes as chunks.
Pixel forensics are deliberately absent: the provenance path does not read them.
Verdict equivalence is checked over tracked fixtures and a separate local evaluation
corpus.
### Broad forensic metadata
[`forensic_metadata.py`](../src/remove_ai_watermarks/forensic_metadata.py) owns the
wide metadata-only inspection record: hashes and timestamps, full EXIF/IPTC, C2PA,
container inventories, bounded binary metadata, and embedded-thumbnail forensics.
It is a separate `forensic_metadata` record type and is deliberately rejected by the
provenance normalizer. Integration code publishes the strict
`ProvenanceReport.to_dict()` alongside it rather than letting operational fields or
derived results influence detection.
### Pixel forensics
[`pixel_evidence.py`](../src/remove_ai_watermarks/pixel_evidence.py) measures six
families of scale-robust pixel statistics (block-DCT histograms and Benford
deviation, FFT band energies and CFA peaks, high-pass residual, error level,
gradient, color) in a single decode, sharing the intermediate maps between them.
It remains independent of verdict and removal. `PixelEvidence.to_dict()` is the
versioned service boundary: it omits the local path, exposes complete/partial/error
status, keeps exception details in logs, and can include opt-in per-stage timings.
The provenance metadata collector, broad forensic collector, provenance report,
and pixel report all accept an explicit output `schema_version`. Package releases
may add an output schema while retaining older serializers, so a rolling consumer
can keep requesting the version it already understands. Within one schema, changes
are additive; existing fields, types, meanings, signal names, and watermark labels
remain stable. Unsupported selections raise before a different shape is returned.
`artifacts=True` additionally returns the spatial layer: a perceptual hash, a 128px
JPEG thumbnail, and coarse ELA, residual and phase maps. Those identify the source
image rather than describe it, which is why they are opt-in and a separate field: a
caller storing them is handling image content, not statistics about it.
The DWT-DCT detector and the visible-mark stage share a single decode of the
source, held by
a per-call `_SharedDecode`. It exposes two accessors because the two arms need
opposite failure handling: the visible arm swallows a decode failure (no cv2, no
visible marks, metadata verdict untouched), while the invisible arm re-raises it
so `has_invisible_target` reaches its documented fail-safe `True`. Swallowing it
there would skip a diffusion scrub on a file that used to get one. TrustMark
deliberately keeps its own Pillow decode: cv2 and Pillow disagree on EXIF
orientation and on 16-bit PNG, so substituting one for the other is not
behavior-preserving.
The metadata probes (`aigc_label`, `xai_signature`, `iptc_ai_system`,
`huggingface_job`, `samsung_genai`) and `extract_c2pa_info` are memoized on
`(path, mtime_ns, size)`. One `identify` reaches each of them twice, and each
re-walks the container or re-runs the manifest reader. Size is in the key as well
as mtime because this package rewrites files in place, and an in-place rewrite
can land inside one mtime tick. The C2PA key additionally carries the
reader-availability flag: with the official reader the manifest comes back as a
store and without it from the PNG chunk parser, so the answer depends on process
state and not on the file alone.
Native-container TC260 readers (`isobmff`, `ebml`, `riff`, `flv`) all run, in that
order, on every file. Each self-gates on its own magic bytes after a 4-12 byte
read, so gating the AVI and FLV ones on the file extension as well was redundant
and made a correctly formatted container served under the wrong name invisible.
WebP is the one input class the now-unconditional RIFF reader newly touches; its
`AVI ` form check is what rejects it.
`api._SourceEvidence` extracts that metadata once per `remove_all` call and serves
both the visible pass (which vendor is confirmed) and the scrub gate (is there an
invisible target). It is per-call, never module-level: `batch` may write its
output over its input, and a holder that outlived one call would answer the scrub
gate from pre-write evidence. In `batch` each stage builds its own holder after
any write that precedes it, for the same reason. Every accessor fails safe the way
the function it replaces does — no provenance means no relaxation, and an unknown
invisible target means scrub rather than skip.
The `detect` extra composes the shared `pixels` runtime with PyWavelets. Its
in-tree [`dwt_dct.py`](../src/remove_ai_watermarks/dwt_dct.py) decoder preserves
the upstream matrix algorithm without installing Torch or non-headless OpenCV.
@@ -397,6 +551,30 @@ The Jimeng pill has an additional decision gate because its visual detector is
weaker than the other registered marks. Keep that policy in the registry, not
inside unrelated detector engines.
Everything about a mark is one registry row: its product family, its label regime,
the platform sentence `identify` reports for it, and the metadata signals that
confirm its vendor. `identify._VISIBLE_MARK_PLATFORM` and the signal mapping in
`api.visible_provenance` are derived from those rows rather than hand-maintained
beside them, so registering a mark is one edit. Two marks carry no platform of
their own: the Gemini sparkle has its own higher-confidence path, and the
capture-less pill is too weak to attribute.
The set of marks that veto the pill is DERIVED from the registry rows: every mark
under the same label regime (`tc260`) belonging to a different product. It used to
be a hand-written list of keys, and that list drifted -- LibLibAI was registered
alongside RunningHub and Baidu, both of which were added to it, and LibLibAI was
not, so a confident LibLibAI detection did not suppress the pill the way its two
siblings did. Marks outside the TC260 regime (Gemini, Samsung) are deliberately
not vetoers: neither can put `jimeng` into `provenance`, so neither can enable the
arm it would be vetoing.
A TC260 label relaxes the vendor its `ContentProducer` names, resolved through
`KnownMark.tc260_producer_codes`. The label itself is vendor-agnostic, so this used to
relax ByteDance's two products on every China-AIGC image -- which both risked a
false fill on an image carrying some other vendor's mark and denied that vendor's
own mark the relaxed gate its `provenance_ncc_factor` was calibrated for. An
absent or unmapped producer still falls back to the ByteDance pair.
`remove_auto_marks` removes every selected mark, not only the strongest one.
This matters for images that carry marks in more than one corner.
@@ -440,7 +618,23 @@ be represented by the shared base:
The detector and removal mask must use compatible geometry. A detector that
fires while producing an empty or misplaced mask is a removal failure even if
the detection test passes.
the detection test passes. That parity is now structural rather than a
convention: the three continuous front ends share one ladder sweep
(`_ladder_best`), and the winning box travels to the mask on
`TextMarkDetection.match_box` instead of being swept a second time.
Detection is split into a trust-level-blind `_scan` and a `_verdict` that applies
the threshold. `detect_both` returns the strict and relaxed verdicts from one
scan, which is what the arbiter's perception stage calls. A per-mark demotion
belongs in the `_post_gate` hook, never in a `detect` override: an override is
invisible to the single-pass path, and the RunningHub and Yuanbao anchor gates
were briefly skipped there for exactly that reason.
A mark whose removable footprint differs from what the detector localizes
overrides `_footprint_rect` (which policy) and `_extend_match_box` (how far the
box grows), not the whole `footprint_mask`. Baidu extends right to the corner tag
and LibLibAI extends left to the triangle logo; both inherit every guard around
that arithmetic.
Yuanbao uses the polarity-independent `contrast` front end because its standard
two-line mark can be light on dark scenes or dark on light scenes. Its detector
@@ -703,7 +897,7 @@ everything it has been tested at:
| Gemini app | 1.40 MP | -- | 0.1066 (the curve's own value) |
Read the last two rows before concluding the curve's low end is under-driven. Against
the 4.33 MP Gemini boundary the sub-1 MP rungs of 0.084-0.094 look short, but at those
the 4.33 MP Gemini boundary the sub-1 MP rungs of 0.084-0.098 look short, but at those
sizes the curve's own values verify clean, which is what a resolution-scaled
requirement would predict. There is no measured size at which the shipped curve fails,
so it is left alone.
+171 -9
View File
@@ -77,6 +77,61 @@ image = cv2.imread("input.png")
result, removed = raiw.remove_visible(image, backend="cv2")
```
## Run the full pipeline
`remove_all` is the library form of the `all` command: visible marks, then the
invisible watermark, then AI metadata. Stages are chained through a file in the
system temp directory, so a partial result never appears at the output path.
```python
import remove_ai_watermarks as raiw
result = raiw.remove_all("input.png", "clean.png") # -> RemoveAllResult
print(result.output) # the path written
print(result.visible_label) # the marks removed, or None
print(result.invisible) # "removed" | "no-signal" | "unavailable"
```
`invisible` is the field to check. `"unavailable"` means the GPU extra is not
installed, so the output *looks* processed but still carries the watermark;
`"no-signal"` means the scrub was deliberately skipped because nothing was
locally detectable, which is a successful run.
Pass `InvisibleOptions` to tune the diffusion stage, and `engine` to reuse one
loaded model across many calls:
```python
from remove_ai_watermarks import InvisibleOptions
raiw.remove_all(
"input.png",
"clean.png",
invisible=InvisibleOptions(strength=0.35),
force=True,
progress=print,
)
```
`InvisibleOptions` carries only what `InvisibleEngine` itself takes, and uses the
engine's own parameter names and defaults. `force`, which decides whether the
engine runs at all, is a parameter of `remove_all` and `remove_batch` alongside
`backend` and `sensitivity`.
If AI metadata survives the strip, `remove_all` raises `MetadataStripIncomplete`
**before** writing anything: an AI-readable output is worse than no output.
`remove_batch` runs one mode over a directory and never lets a single bad file
end the run:
```python
summary = raiw.remove_batch("in_dir", "out_dir", mode="visible") # -> BatchSummary
print(summary.processed, summary.failed, summary.errors)
print(summary.invisible_unavailable) # outputs that still carry the watermark
```
`mode` is `all`, `visible`, `invisible`, or `metadata`. Pass a constructed
`InvisibleEngine` as `engine` to load the model once for the whole directory.
## Inspect provenance
The default installation evaluates file metadata. Add `visible`, `detect`, or
@@ -127,8 +182,61 @@ evidence = extract_provenance_evidence(Path("input.png"))
report = identify_from_evidence(evidence)
```
If metadata was collected by another component, normalize its nested record
without reopening the original file:
### Collect once, judge elsewhere
`collect_metadata_record` splits the two halves apart: it is the only step that
touches the file, and it returns a JSON-serializable record the verdict can be
built from on another machine, in another process, or later.
```python
import json
from remove_ai_watermarks.identify import identify_metadata_record
from remove_ai_watermarks.metadata_record import collect_metadata_record
record = collect_metadata_record(Path("input.png"), schema_version=1) # reads the file
blob = json.dumps(record) # ship it anywhere
report = identify_metadata_record(json.loads(blob), path=Path("input.png")) # reads nothing
payload = report.to_dict(schema_version=1) # versioned JSON contract
```
The collection record has `record_type="provenance_metadata"`,
`schema_version=1`, and a `status`. A vanished or unreadable source produces an
`error` record with structured `issues`; `identify_metadata_record` rejects that
record instead of turning a collection failure into an unknown-image verdict.
Unknown schema versions, non-integer aliases, and native records without a
`complete` collection status are rejected explicitly.
The verdict is the same one `identify(path, check_visible=False,
check_invisible=False)` returns for that file. That equality is the record's whole
contract and is verified over the tracked provenance fixtures and a separate local
evaluation corpus. `ProvenanceReport.to_dict()` is the stable service boundary: it
adds a `schema_version`, contains only JSON-safe values, and deliberately omits the
local source path.
Package and transport versions evolve independently. Long-lived consumers should
request the schema they implement, as above, instead of assuming the installed
package's latest schema. Within schema 1, existing fields, types, meanings,
`signals[].name` values, and `watermarks[]` labels remain compatible; releases may
add fields that consumers must ignore. A breaking change requires a new schema while
the schema 1 serializer remains available for rolling upgrades. Asking a release for
an unsupported schema raises `ValueError` rather than silently returning another
shape.
A record carries metadata regions, not the primary coded-pixel stream: marker
segments before the JPEG scan, every PNG chunk except `IDAT`, RIFF chunks except the
coded image, the ISOBMFF provenance boxes, the container's trailer, the parsed EXIF tags the
verdict reads by name, PIL's info mapping, and the C2PA manifest store. Record size
is bounded by those metadata regions and trailers; images with large embedded
manifests naturally produce larger records.
The `path` argument is metadata: it labels the report and is never opened by
either function, so a record collected elsewhere can be judged against a path that
does not exist locally.
If metadata was collected by another component instead, normalize its nested
record the same way:
```python
from remove_ai_watermarks.identify import (
@@ -144,16 +252,70 @@ evidence = evidence_from_metadata_record(record, path=Path("input.png"))
report = identify_from_evidence(evidence)
```
The normalizer recursively preserves text and byte values. It also decodes
Unversioned third-party records are normalized recursively for compatibility. The
normalizer preserves text and byte values. It also decodes
strings prefixed with `hex:` and fields named `base64` or ending in
`_base64`. Diagnostic values under `error` and `kind` are ignored because they
describe the collector rather than the source file. Pass a C2PA manifest-store
dictionary in `record["c2pa_store"]`, or through the explicit
`_base64`. Diagnostic, transport, timing, hash, provenance-result, and pixel-result
subtrees are ignored because they describe the collector or a derived result rather
than the source file. A versioned portable record is stricter still: only
`metadata_base64`, `tail_base64`, `pil`, `exif`, and `c2pa_store` are accepted as
source evidence. Other `record_type` values are rejected, so do not pass a broad
forensic inspection record to this API. Pass a C2PA manifest-store dictionary in
`record["c2pa_store"]`, or through the explicit
`c2pa_manifest_store` argument.
`identify_from_evidence` does not reopen the source file. It evaluates metadata
only; registered visible marks and pixel-backed invisible watermarks remain in
the path-based `identify` call.
### Broad metadata inspection
`collect_forensic_metadata` provides the wide metadata-only record used by forensic
inspection and migration adapters. It preserves hashes and timestamps, full EXIF and
IPTC, C2PA, container inventories, bounded raw metadata payloads, and embedded
thumbnail forensics. It does not calculate a provenance verdict or pixel statistics.
```python
from remove_ai_watermarks.forensic_metadata import collect_forensic_metadata
record = collect_forensic_metadata(Path("input.png"), schema_version=1)
assert record["record_type"] == "forensic_metadata"
```
This record is intentionally not accepted by `identify_metadata_record`. Collect the
small strict provenance record separately and publish the resulting
`ProvenanceReport.to_dict()` as the detector contract.
### Pixel evidence
`extract_pixel_evidence` decodes once and calculates the DCT, FFT, residual, ELA,
gradient, and color families. Its versioned `to_dict()` result has a semantic
`status`: `complete`, `partial` when an individual family failed, or `error` when the
source could not be decoded. Transported errors contain only the exception class, so
local paths stay in the caller's logs rather than crossing the service boundary.
```python
from remove_ai_watermarks.pixel_evidence import extract_pixel_evidence
pixels = extract_pixel_evidence(Path("input.png"), artifacts=False, timings=True)
payload = pixels.to_dict(schema_version=1)
```
Timings and spatial artifacts are opt-in. Artifacts include image-identifying data
such as a thumbnail and perceptual hash; aggregate feature families do not.
`identify_from_evidence` does not reopen the source file by default: it evaluates
metadata only, and registered visible marks and pixel-backed invisible watermarks
remain in the path-based `identify` call.
Pass `image_path` together with `check_visible` or `check_invisible` to add those
pixel detectors on top of the SAME evidence. That is how a caller asking one file
two provenance questions — which vendor is confirmed, and is there an invisible
target — pays for the metadata extraction once:
```python
from remove_ai_watermarks.identify import extract_provenance_evidence, identify_from_evidence
evidence = extract_provenance_evidence(source)
metadata_only = identify_from_evidence(evidence)
with_pixels = identify_from_evidence(evidence, image_path=source, check_invisible=True)
```
## Strip metadata
+13 -3
View File
@@ -71,9 +71,10 @@ The source distribution uses an explicit allowlist for `/src`, `/LICENSE`,
`[tool.hatch.build.targets.sdist]` in `pyproject.toml`. It also defensively
excludes `/data`, `/tmp`, and `/.sc`. Keep both controls: calibration captures,
test corpora, generated research outputs, and local session state do not belong
in the published package archive. The matching local-root entries in
`.gitignore` prevent accidental commits, but are not a substitute for the build
boundary because hatchling may include untracked files.
in the published package archive. Hatchling always adds the root `.gitignore` to
the sdist, so keep its comments generic and free of local operational context.
Ignore rules are not the build boundary: `data/` is deliberately tracked, while
the sdist configuration keeps it and the other excluded paths out of the archive.
## Build backend
@@ -96,6 +97,15 @@ write access.
## Release verification
Forensic transports are versioned independently from the package. Before publishing
a change to provenance metadata, provenance reports, broad forensic metadata, or
pixel evidence, run their schema 1 contract tests. Additive fields are compatible;
renaming a field, changing its type or meaning, changing a signal name or watermark
label, or removing a field requires a new output schema. Add the new serializer
without removing schema 1 so long-lived consumers can update separately. A package
release must never silently substitute its latest schema when a caller explicitly
requests an older supported one.
After publication, verify:
- both wheel and source distribution exist on PyPI;
+1 -1
View File
@@ -37,7 +37,7 @@ when you can select the affected area yourself.
| `veo` | Current four-point diamond and legacy `Veo` text | Fixed bottom-right corner | Uses separate silhouettes and requires a recurring match; learned fill is preferable on structured backgrounds. |
| `seedance` | Boxed `AI` label | Fixed bottom-right corner | Requires an anchored recurring match; the full localized box is filled because a thinner synthetic shape mask leaves the real translucent rim behind. |
| `dola` | `Dola AI` text | Fixed bottom-right corner | Requires an anchored recurring match; ByteDance or BytePlus provenance can relax only an existing visual run. |
| `hailuo` | `MINIMAX | hailuo AI` composite label | Fixed lower edge | Uses a synthetic waveform, text, separator, and ring silhouette; the complete recurring label box is filled. |
| `hailuo` | `MINIMAX \| hailuo AI` composite label | Fixed lower edge | Uses a synthetic waveform, text, separator, and ring silhouette; the complete recurring label box is filled. |
| `kling` | Kling swirl, `KLING AI`, version, and optional `PRO` suffix | Fixed bottom-right edge | Combines a synthetic logo rescue with font variants, an edge gate, a white-label gate, and anchored temporal recurrence. |
`video identify`, `video visible`, and `video all` share this registry and the
@@ -1,7 +1,9 @@
# Deep research: SynthID-safe face-identity recovery for SDXL (2026-06-08)
> Research archive. This dated follow-up records evidence available during the
> study. It is not the current command reference.
> study. It is not the current command reference. The `instantid` and `photomaker`
> extras it discusses, the `--restore-faces` flag, and their modules were all removed
> from the library afterwards.
**Stats:** {"angles": 6, "sourcesFetched": 28, "claimsExtracted": 104, "claimsVerified": 25, "confirmed": 19, "killed": 6, "afterSynthesis": 6, "urlDupes": 1, "budgetDropped": 7, "agentCalls": 111}
@@ -149,8 +151,9 @@ on Modal A100 in two phases:
- v3 (tighter ellipse 0.32*bw x 0.42*bh + per-channel mean color match to
local cleaned canvas + softer feather): patchwork visually softened; faces
still read as studio portraits inserted into the scene, not as people
shot in the scene. Single portrait identity drifted (tatsunari -> "round
Asian male" vs original's thin face).
shot in the scene. Single portrait identity drifted (the single-portrait
fixture regenerated as a generic "round Asian male" vs the original's thin
face).
2. **Phase 2: InstantID img2img on cleaned crop.** Switched to the upstream
`pipeline_stable_diffusion_xl_instantid_img2img` (downloaded at first use
from raw.githubusercontent.com; requires `trust_remote_code=True`). Same
+2 -1
View File
@@ -3,7 +3,8 @@
> Research archive. This document evaluates approaches considered during the
> study. It does not describe the current public interface. The current
> pipelines and limits are documented in `docs/cli.md` and
> `docs/known-limitations.md`.
> `docs/known-limitations.md`. The path it calls shipped -- `photomaker_restore.py`
> and the `photomaker` extra -- was removed from the library afterwards.
> **Status (2026-06-08): retired.** Every approach described below was empirically
> tested and rejected -- see `docs/synthid-robust-identity-research-2026-06-08.md`
+17 -9
View File
@@ -448,6 +448,14 @@ the payload, reconstituting SynthID in text. The lesson held and shaped the
current design: **content is preserved by REGENERATING it under structural
conditioning, never by copying original pixels.**
> **Superseded in 0.24.0.** The `controlnet`, `sdxl`, `qwen` and `default` profiles
> were removed, and a retired name is now rejected at parse time rather than routed
> onward. `--pipeline` accepts only `qwen-zimage` (the default) and `sdxl-zimage`;
> both are CUDA-only, both condition their global stage on a canny edge map, and both
> run the same Z-Image face stage. The bullets below are a record of what was measured
> on the removed profiles, kept because the oracle verdicts are still the evidence the
> current design rests on.
- **Text + structure:** `--pipeline controlnet` (SDXL img2img + a canny ControlNet) is
**THE DEFAULT pipeline since 2026-06-09** (`--pipeline default` opts down to plain
SDXL img2img for inputs without text/faces). It conditions the regeneration on the
@@ -480,21 +488,21 @@ conditioning, never by copying original pixels.**
measured at. See §5.2.**
- **Highest-fidelity CUDA option:** `--pipeline qwen-zimage` is the recommended
quality mode when preserving face identity matters more than latency, model size,
and GPU cost. ControlNet remains the default because it is much cheaper and supports
and GPU cost. ControlNet was then the default, because it was much cheaper and ran on
CUDA, XPU, MPS, and CPU, but canny conditioning preserves edges rather than identity.
On two direct upstream comparisons, `qwen-zimage` retained substantially more
ArcFace identity than polished ControlNet. On 2026-07-25 the exact six-output
`visible -> qwen-zimage -> metadata` candidate was negative in the corresponding
OpenAI and Gemini oracles. This is a quality recommendation for the measured content,
not broad removal certification; very small text can still degrade.
See `docs/known-limitations.md` for the metrics, runtime, and validation scope.
- **Face identity:** canny holds face *structure* but not *identity*. The standard
SDXL and ControlNet profiles do not run a separate face-restoration option.
Earlier GFPGAN, PhotoMaker, and FaceID experiments were removed after they
degraded identity or risked reintroducing source pixels. The separate
`qwen-zimage` profile now provides the only shipped face-specific stage:
YuNet and SAM locate faces, then Z-Image regenerates the selected original
face crops before a feathered composite. See
See `docs/qwen-improvement-research.md` for the identity and text metrics and the
validation scope of those comparisons.
- **Face identity:** canny holds face *structure* but not *identity*. The removed
SDXL and ControlNet profiles did not run a separate face-restoration stage, and
earlier GFPGAN, PhotoMaker, and FaceID experiments were dropped after they
degraded identity or risked reintroducing source pixels. Both shipped profiles
now run the same face-specific stage: YuNet and SAM locate faces, then Z-Image
regenerates the selected original face crops before a feathered composite. See
`docs/controlnet-removal-pipeline-research.md` for the historical experiments.
### 5.2 Strength setting
+2
View File
@@ -2,6 +2,8 @@
> Research archive. This page records evaluated ideas, including rejected
> prototypes. Current behavior is documented in the user guides and source.
> The `text_protector.py` module it describes was removed from the library
> afterwards.
Date: 2026-05-29. Source: a deep-research run (104 agents, 5 search angles, sources
fetched and 3-vote adversarially verified). Not committed automatically — saved as a
+2 -2
View File
@@ -36,7 +36,7 @@ AI generator values while retaining unrelated camera and editor EXIF. The
shared `xai_signature_pair` helper is the single source of truth for the
pair. On the ISOBMFF path, `blank_ai_exif_tokens` provides the corresponding
in-place scrub for supported EXIF values, TC260 AIGC blocks, and the xAI pair.
- **China TC260 AIGC label (caught by `AIGC_MARKERS` / `metadata.aigc_label`, surfaced by `identify` as the `aigc` signal):** China-served generators embed an XMP `<TC260:AIGC>{"Label":"1","ContentProducer":...}` block — China's mandatory AI-content labeling (TC260 namespace `tc260.org.cn/ns/AIGC`).
- **China TC260 AIGC label (caught by `AIGC_MARKERS` / `metadata.aigc_label`, surfaced by `identify` as the `aigc` signal):** China-served generators embed an XMP `<TC260:AIGC>{"Label":"1","ContentProducer":...}` block — China's mandatory AI-content labeling (TC260 namespace `tc260.org.cn/ns/AIGC`). The label says only "this is AI", but its `ContentProducer` names the signing entity — `001` + `1` + an 18-char Unified Social Credit Code + a 5-digit product suffix, normalized by `metadata.uscc_of`, or for a few generators a bare product name. `KnownMark.tc260_producer_codes` maps the codes settled per vendor by `scripts/vendor_cohort_harvest.py` to registry mark keys, so an AIGC image relaxes the detector of the vendor it actually carries rather than a guessed pair; an unmapped or absent producer falls back to ByteDance's two products. A code identifies a legal entity, not necessarily one brand, so a hosting or aggregating platform that signs for several apps is a recall bet rather than a proof.
**Doubao** (ByteDance) uses it (verified on a public issue sample; `ContentProducer` `001191110102MACQD9K64010000`, no C2PA/SynthID/imwatermark — the XMP block is the only signal; GitHub attachment upload did NOT strip it). The same standard is mandatory for Jimeng/Kling/Qwen/Ernie etc., so the one marker covers the whole China-AIGC-labeled ecosystem. `aigc_label` reads **four image serializations** through a shared `_parse` helper: the HTML-entity-encoded XMP `TC260:AIGC` block in **either RDF form** — the nested element `<TC260:AIGC>{...}</TC260:AIGC>` (Doubao) or the attribute `TC260:AIGC="{...}"` (**PicWish**, `ContentProducer="picwish"`, verified on compatible samples) — via a container-agnostic raw-byte scan (any JSON object accepted), a raw-JSON PNG `AIGC` tEXt chunk (Doubao also writes the label this way, no namespaced marker at all — confirmed on compatible samples, `ContentProducer="doubao"`), a bare raw-JSON `{"AIGC":{...}}` object embedded in **JPEG EXIF (UserComment)** by some China-served generators, brace-matched from the scan head with `json.JSONDecoder().raw_decode` (no namespaced marker, no PNG chunk — confirmed on compatible samples, `ContentProducer="001191440300708461136T1308L"`), **and** a bare `AIGC{...}` blob (the label glued straight to its JSON, no `"AIGC":` key wrapper) embedded in a **JPEG APP segment near the JFIF header** — confirmed on compatible samples. The two raw-JSON forms are scanned in one loop (`'"AIGC"'` then `AIGC{`) that **falls through on a non-TC260 / undecodable hit instead of returning** — a quoted `"AIGC"` can appear later in an XMP packet while the real label is a bare `AIGC{...}` earlier in the file, so an unconditional early return on the quoted form would shadow the bare form (the exact bug behind the 06-10 misses). Native MP4/MOV is a fifth serialization: TC260-PG-20257A stores an `AIGC` key in `moov.udta.meta.keys` and the raw JSON in the matching `ilst` item. The seeking parser reaches a tail `moov` without reading `mdat`; removal replaces the key with `free` and blanks the validated value at the same length so every box size and stream offset stays fixed. All generic forms are gated on at least one TC260 field (`TC260_AIGC_FIELDS`) so a generic `AIGC` key cannot false-positive; the namespaced XMP element is unambiguous and needs no gate. `TC260_AIGC_FIELDS` covers **two schemas**: the producer-side one (`Label` / `ContentProducer` / `ProduceID` / `ContentPropagator` / `PropagateID`, Doubao and most China gens) and the **service-provider** one (`ServiceProvider` / `ServiceUser`, plus generic `Time` / `ContentId` which are NOT gated on) — **Tencent Cloud's** AIGC variant (`ServiceProvider` = `腾讯云`), embedded in **EXIF `ImageDescription`**, verified on compatible samples. In `identify`, `aigc` fires on the parsed label **or** the `AIGC_MARKERS` byte scan (the latter preserves the laundering-tell case where the JSON payload is truncated).
@@ -68,7 +68,7 @@ payloads. Removal remuxes either container through ffmpeg with stream copy.
Metadata stripping for supported audio containers is a separate implemented
path.
**Box detection window — now handled (v0.6.8):** detection no longer relies on a fixed first-MB read. `metadata.scan_head(path, size)` reads the first `size` bytes and, for ISOBMFF, appends the payloads of late provenance boxes found by `isobmff.scan_c2pa_region` (a file-seeking top-level box walker that skips past `mdat` by size without reading it), so a C2PA/AIGC/IPTC manifest placed AFTER a large `mdat` in a streaming/non-faststart MP4 is now caught. Every C2PA/marker byte scan (`has_ai_metadata`, `aigc_label`, `iptc_ai_system`, `synthid_source`, `exif_generator` XMP, `get_ai_metadata` soft-binding, and `identify`) goes through `scan_head`; it is behavior-neutral for non-ISOBMFF inputs (exactly `f.read(size)`).
**Box detection window — now handled (v0.6.8):** detection no longer relies on a fixed first-MB read. `metadata.scan_head(path, size)` reads the first `size` bytes and, for ISOBMFF, appends the payloads of late provenance boxes found by `isobmff.scan_c2pa_region` (a file-seeking top-level box walker that skips past `mdat` by size without reading it), so a C2PA/AIGC/IPTC manifest placed AFTER a large `mdat` in a streaming/non-faststart MP4 is now caught. Every C2PA/marker byte scan (`has_ai_metadata`, `aigc_label`, `iptc_ai_system`, `synthid_source`, `exif_generator` XMP, `get_ai_metadata` soft-binding, and `identify`) goes through `scan_head`; for PNG it likewise appends the payloads of `tEXt` / `iTXt` / `zTXt` / `eXIf` / `iCCP` chunks that start beyond the window (`_png_late_metadata`, seeking past `IDAT`), which is how a TC260 AIGC label appended after the pixel stream is caught; for WebP it appends the `EXIF` / `XMP ` / `ICCP` / `C2PA` chunks past the window (`_riff_late_metadata`, stepping over the coded image), which is how an IPTC "Made with AI" tag stored after the pixels is caught; and for a file at least `size` bytes long it finally appends the metadata text the decoder reaches but a raw read cannot (`_decoder_visible_text`), which covers a compressed PNG `zTXt` packet no byte scan can spell; for any file that fits inside `size`, it is exactly `f.read(size)`.
Native TC260 MP4/MOV tags do not live in those top-level provenance boxes.
`tc260_aigc_payloads` separately seeks through `moov.udta.meta.keys/ilst`, so
+2 -2
View File
@@ -1,7 +1,7 @@
schema_version: 1
context:
version: "0.24.0"
version: "0.25.0"
python_min: "3.10"
package:
@@ -10,7 +10,7 @@ package:
source:
url: https://pypi.org/packages/source/r/remove-ai-watermarks/remove_ai_watermarks-${{ version }}.tar.gz
sha256: ba2fe5d7a28950d90f72768fa5e6228ee02f74a9638c2ea84c822e1de965221e
sha256: f57ffb8fec813368e84e13b598124e5b2bed6964e778fa53adee0cc2d03513cf
build:
noarch: python
+2 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "remove-ai-watermarks"
version = "0.25.0"
version = "0.26.0"
description = "AI watermark remover for visible, invisible, and provenance marks in images and video"
readme = "README.md"
requires-python = ">=3.10.1"
@@ -37,6 +37,7 @@ classifiers = [
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Multimedia :: Graphics",
"Topic :: Multimedia :: Graphics :: Graphics Conversion",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
-436
View File
@@ -1,436 +0,0 @@
"""AI-generation scorer for scanned datasets.
Trains a gradient-boosted classifier on the metadata-derived labels of a
scan_dataset.py output and scores every file, WITHOUT needing the
metadata to be present at scoring time: the features are pixel and
container statistics, so a metadata-stripped file still gets a score.
The model is distribution-specific by design; evaluate it again before
using it on an unrelated target distribution.
Modes:
uv run --with scikit-learn python scripts/ai_score.py train <scan_glob> <model.pkl> [v1|v2]
uv run --with scikit-learn python scripts/ai_score.py score <scan_glob> <model.pkl> <out.jsonl>
<scan_glob> is a glob of scan_dataset shards, e.g. 'data/scan/part_*.jsonl'.
score output: one JSON line per file with file, sha256, ai_score, and the
label evidence when a metadata label existed (for monitoring drift).
"""
import glob
import json
import math
import pickle
import sys
from collections import defaultdict
from pathlib import Path
from typing import Any, Literal
import numpy as np
# AI-generator names in C2PA claim_generator_info (metadata-level truth).
AI_GENERATORS = (
"openai",
"adobe firefly",
"adobe_firefly",
"microsoft responsible ai",
"microsoft_designer",
"black forest labs",
"fal-ai",
"bria",
"chatgpt",
"stability",
"dreamina",
"canva",
)
# Human-origin software (negative evidence, NOT proof by itself).
HUMAN_SOFTWARE = (
"photoshop",
"lightroom",
"picsart",
"snapseed",
"paint.net",
"gimp",
"capture one",
"meitu",
"xingtu",
"snow",
)
_FEAT_SCALAR = [
("noise", "noise_std"),
("noise", "noise_kurtosis"),
("fft", "cfa_peak"),
("ela", "ela_mean"),
("ela", "ela_p95"),
("gradient", "laplacian_var"),
("color", "saturation_mean"),
("color", "value_mean"),
("dct", "benford_mad"),
]
FeatureSchema = Literal["v1", "v2"]
_FFT_BANDS = 8
_GRADIENT_BINS = 10
_COLOR_BINS = 64
_DCT_AC_POSITIONS = 8
_DCT_AC_BINS = 21
_JPEG_QUANT_TABLES = 2
_JPEG_QUANT_VALUES = 64
_SCORE_BATCH = 4096
_V1_FEATURE_NAMES = (
*(key for _, key in _FEAT_SCALAR),
*(f"fft_band_energy_{index}" for index in range(_FFT_BANDS)),
*(f"gradient_hist_{index}" for index in range(_GRADIENT_BINS)),
*(f"color_hist_4x4x4_{index}" for index in range(_COLOR_BINS)),
"jpeg_444",
"jpeg_progressive",
"megapixels",
"aspect_ratio",
"format_jpeg",
"format_png",
)
_V2_EXTRA_FEATURE_NAMES = (
"cfa_peak_0",
"cfa_peak_1",
*(f"dct_ac_{position}_{bin_index}" for position in range(_DCT_AC_POSITIONS) for bin_index in range(_DCT_AC_BINS)),
*(f"jpeg_quant_{table}_{index}" for table in range(_JPEG_QUANT_TABLES) for index in range(_JPEG_QUANT_VALUES)),
"jpeg_quant_table_count",
"jpeg_huffman_table_count",
"jpeg_huffman_total_bytes",
"jpeg_scan_count",
"jpeg_restart_interval",
"jpeg_precision_bits",
"jpeg_adobe_transform",
"jpeg_jfif_present",
"format_webp",
"format_isobmff",
"format_other",
)
_SCHEMA_FEATURE_NAMES: dict[str, tuple[str, ...]] = {
"v1": _V1_FEATURE_NAMES,
"v2": _V1_FEATURE_NAMES + _V2_EXTRA_FEATURE_NAMES,
}
def parse_schema(value: Any) -> FeatureSchema:
"""Validate a feature-schema token (CLI argument or model-bundle field)."""
if value == "v1" or value == "v2":
return value
raise ValueError(f"Unknown feature schema: {value!r}")
def feature_names(schema: FeatureSchema = "v1") -> tuple[str, ...]:
"""Return the stable ordered feature names for a model schema."""
return _SCHEMA_FEATURE_NAMES[parse_schema(schema)]
def _fixed_values(values: Any, count: int, *, normalize: bool = False) -> list[float]:
"""Return a fixed-width float vector, padding absent values with NaN."""
if not isinstance(values, (list, tuple)):
return [float("nan")] * count
result = [float(value) for value in values[:count]]
if normalize and result:
total = sum(value for value in result if math.isfinite(value))
if total > 0:
result = [value / total for value in result]
return result + [float("nan")] * (count - len(result))
def _optional_float(mapping: dict[str, Any], key: str) -> float:
value = mapping.get(key)
return float(value) if isinstance(value, (int, float)) else float("nan")
def label_of(record: dict[str, Any]) -> int | None:
"""1 = strong metadata AI, 0 = human-origin, None = unlabeled."""
store = record.get("c2pa_store") or {}
for m in (store.get("manifests") or {}).values():
for cgi in m.get("claim_generator_info") or []:
if any(g in str(cgi.get("name", "")).lower() for g in AI_GENERATORS):
return 1
for a in m.get("assertions") or []:
d = a.get("data") if isinstance(a.get("data"), dict) else {}
if "trainedAlgorithmicMedia" in str(d.get("digitalSourceType", "")):
return 1
for c in record.get("png_chunks", []):
t = c.get("text", "")
kw = t.split("\x00")[0]
if kw in ("prompt", "workflow", "parameters") or "AIGC" in t[:80]:
return 1
exif = record.get("exif", {})
z = exif.get("0th") or {}
e = exif.get("Exif") or {}
if z.get("Make") and z.get("Model") and (e.get("MakerNote") or e.get("LensModel") or e.get("LensMake")):
return 0
for c in record.get("png_chunks", []):
if c.get("apple_screenshot_marker"):
return 0
for v in (record.get("iptc") or {}).values():
if str(v).strip() == "Screenshot":
return 0
if any(t in str(z.get("Software", "")).lower() for t in HUMAN_SOFTWARE):
return 0
return None
def features_of(record: dict[str, Any], *, schema: FeatureSchema = "v1") -> list[float]:
"""The structural feature vector (pixel + container stats, no metadata)."""
v = [float((record.get(s) or {}).get(k, np.nan)) for s, k in _FEAT_SCALAR]
fft = record.get("fft") or {}
gradient = record.get("gradient") or {}
color = record.get("color") or {}
v.extend(_fixed_values(fft.get("fft_band_energy"), _FFT_BANDS))
v.extend(_fixed_values(gradient.get("gradient_hist"), _GRADIENT_BINS))
v.extend(_fixed_values(color.get("color_hist_4x4x4"), _COLOR_BINS))
jf = record.get("jpeg_forensics") or {}
v.append(1.0 if jf.get("subsampling") == "4:4:4" else 0.0)
v.append(1.0 if jf.get("progressive") else 0.0)
pil = record.get("pil") or {}
w, h = pil.get("width") or 0, pil.get("height") or 0
v += [float(w * h) / 1e6, float(w) / max(h, 1)]
fmt = record.get("content_format")
v += [1.0 if fmt == "jpeg" else 0.0, 1.0 if fmt == "png" else 0.0]
if schema == "v2":
v.extend(_fixed_values(fft.get("cfa_peaks"), 2))
dct_hist = (record.get("dct") or {}).get("dct_ac_hist")
for position in range(_DCT_AC_POSITIONS):
values = dct_hist[position] if isinstance(dct_hist, list) and position < len(dct_hist) else None
v.extend(_fixed_values(values, _DCT_AC_BINS, normalize=True))
quant_tables = jf.get("quant_tables")
for table in range(_JPEG_QUANT_TABLES):
values = quant_tables.get(str(table)) if isinstance(quant_tables, dict) else None
v.extend(_fixed_values(values, _JPEG_QUANT_VALUES))
is_jpeg = fmt == "jpeg"
huffman_tables = jf.get("huffman_tables_hex")
huffman_values = huffman_tables if isinstance(huffman_tables, list) else []
v.extend(
[
float(len(quant_tables)) if isinstance(quant_tables, dict) else (0.0 if is_jpeg else float("nan")),
float(len(huffman_values)) if is_jpeg else float("nan"),
(
float(sum(len(value) // 2 for value in huffman_values if isinstance(value, str)))
if is_jpeg
else float("nan")
),
_optional_float(jf, "scan_count"),
_optional_float(jf, "restart_interval"),
_optional_float(jf, "precision_bits"),
_optional_float(jf, "adobe_transform"),
1.0 if jf.get("jfif") else (0.0 if is_jpeg else float("nan")),
1.0 if fmt == "webp" else 0.0,
1.0 if isinstance(fmt, str) and fmt.startswith("isobmff:") else 0.0,
(
1.0
if fmt is not None and fmt not in {"jpeg", "png", "webp"} and not str(fmt).startswith("isobmff:")
else 0.0
),
]
)
elif schema != "v1":
raise ValueError(f"Unknown feature schema: {schema}")
expected = len(feature_names(schema))
if len(v) != expected:
raise ValueError(f"Feature schema {schema} produced {len(v)} values; expected {expected}")
return v
def grouped_stratified_split(
labels: np.ndarray,
hashes: np.ndarray,
*,
test_size: float = 0.3,
random_state: int = 0,
) -> tuple[np.ndarray, np.ndarray]:
"""Split records by hash while preserving label balance across hash groups."""
if len(labels) != len(hashes):
raise ValueError("labels and hashes must have equal length")
if not 0 < test_size < 1:
raise ValueError("test_size must be between 0 and 1")
groups: dict[str, list[int]] = defaultdict(list)
group_labels: dict[str, int] = {}
for index, (label, digest) in enumerate(zip(labels, hashes, strict=True)):
key = str(digest) if digest else f"__row_{index}"
int_label = int(label)
if key in group_labels and group_labels[key] != int_label:
raise ValueError(f"Hash {key!r} has conflicting labels")
group_labels[key] = int_label
groups[key].append(index)
keys_by_label: dict[int, list[str]] = defaultdict(list)
for key, label in group_labels.items():
keys_by_label[label].append(key)
rng = np.random.default_rng(random_state)
test_set: set[str] = set()
for class_keys in keys_by_label.values():
if len(class_keys) < 2:
raise ValueError("Each label needs at least two distinct hash groups")
shuffled = np.asarray(class_keys)
rng.shuffle(shuffled)
test_count = min(max(round(len(shuffled) * test_size), 1), len(shuffled) - 1)
test_set.update(shuffled[:test_count].tolist())
train_set = set(groups) - test_set
train = np.asarray([index for key, indices in groups.items() if key in train_set for index in indices])
test = np.asarray([index for key, indices in groups.items() if key in test_set for index in indices])
return np.sort(train), np.sort(test)
def temporal_holdout_split(
dates: np.ndarray,
hashes: np.ndarray,
*,
train_fraction: float = 0.7,
) -> tuple[np.ndarray, np.ndarray, str]:
"""Split chronologically and remove later copies of training hashes."""
if len(dates) != len(hashes):
raise ValueError("dates and hashes must have equal length")
if not 0 < train_fraction < 1:
raise ValueError("train_fraction must be between 0 and 1")
unique_dates = sorted({str(date) for date in dates})
if len(unique_dates) < 2:
raise ValueError("Temporal holdout needs at least two distinct dates")
cutoff_index = min(int(len(unique_dates) * train_fraction), len(unique_dates) - 1)
cutoff = unique_dates[cutoff_index]
train = np.flatnonzero(dates < cutoff)
test = np.flatnonzero((dates >= cutoff) & ~np.isin(hashes, hashes[train]))
return train, test, cutoff
def model_schema(bundle: dict[str, Any]) -> FeatureSchema:
"""Read a model's feature schema, defaulting legacy bundles to v1."""
return parse_schema(bundle.get("feature_schema", "v1"))
def iter_records(pattern: str) -> Any:
for path in sorted(glob.glob(pattern)):
with open(path) as fh:
for line in fh:
yield json.loads(line)
def cmd_train(pattern: str, model_path: str, schema: FeatureSchema = "v2") -> None:
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.impute import SimpleImputer
from sklearn.metrics import average_precision_score, roc_auc_score
# keep the feature vector, not the record: a parsed scan record is an order of
# magnitude larger than the row it collapses to.
labeled: dict[str, tuple[list[float], int, str]] = {}
missing_hash_index = 0
for r in iter_records(pattern):
if "noise" not in r:
continue
lab = label_of(r)
if lab is None:
continue
digest = r.get("sha256")
if not digest:
digest = f"__missing_{missing_hash_index}"
missing_hash_index += 1
date = Path(r["file"]).parent.name
previous = labeled.get(str(digest))
if previous is not None and previous[1] != lab:
raise ValueError(f"Hash {digest!r} has conflicting labels")
if previous is None or date < previous[2]:
labeled[str(digest)] = (features_of(r, schema=schema), lab, date)
rows, labels, row_dates = zip(*labeled.values(), strict=True)
X = np.asarray(rows)
y = np.asarray(labels)
dates = np.asarray(row_dates)
hashes = np.asarray(list(labeled))
print(
f"unique labeled: {len(y)} "
f"(pos {int(y.sum())}, neg {int((1 - y).sum())}, features {X.shape[1]}, schema {schema})"
)
def fit(train: np.ndarray) -> tuple[Any, Any]:
rows = X[train]
imputer = SimpleImputer(strategy="median")
classifier = HistGradientBoostingClassifier(
random_state=0,
max_iter=300,
l2_regularization=5.0,
early_stopping=False,
)
classifier.fit(imputer.fit_transform(rows), y[train])
return imputer, classifier
train, test = grouped_stratified_split(y, hashes, test_size=0.3, random_state=0)
imp, clf = fit(train)
p = clf.predict_proba(imp.transform(X[test]))[:, 1]
print(f"SHA-grouped holdout: AUC {roc_auc_score(y[test], p):.4f} | AP {average_precision_score(y[test], p):.4f}")
temporal_train, temporal_test, cutoff = temporal_holdout_split(dates, hashes)
if len(temporal_train) > 100 and len(temporal_test) > 100:
imp_t, clf_t = fit(temporal_train)
pt = clf_t.predict_proba(imp_t.transform(X[temporal_test]))[:, 1]
auc_t = roc_auc_score(y[temporal_test], pt)
ap_t = average_precision_score(y[temporal_test], pt)
print(f"temporal (<{cutoff}): AUC {auc_t:.4f} | AP {ap_t:.4f}")
all_indices = np.arange(len(y))
imp, clf = fit(all_indices)
bundle = {
"imputer": imp,
"clf": clf,
"feature_schema": schema,
"feature_names": feature_names(schema),
}
with open(model_path, "wb") as f:
pickle.dump(bundle, f)
print(f"model written: {model_path}")
def cmd_score(pattern: str, model_path: str, out_path: str) -> None:
# the model file is produced locally by `train`; do not load third-party pickles
with open(model_path, "rb") as f:
bundle = pickle.load(f) # noqa: S301
imp, clf = bundle["imputer"], bundle["clf"]
schema = model_schema(bundle)
stored_names = bundle.get("feature_names")
if stored_names is not None and tuple(stored_names) != feature_names(schema):
raise ValueError(f"Model feature names do not match schema {schema}")
n = 0
batch_rows: list[list[float]] = []
batch_meta: list[dict[str, Any]] = []
def flush(out: Any) -> None:
"""Score one batch: per-record predict_proba is dominated by call overhead."""
nonlocal n
if not batch_rows:
return
scores = clf.predict_proba(imp.transform(np.asarray(batch_rows)))[:, 1]
for meta, score in zip(batch_meta, scores, strict=True):
out.write(json.dumps({**meta, "ai_score": round(float(score), 4)}) + "\n")
n += len(batch_rows)
batch_rows.clear()
batch_meta.clear()
print(f" {n}", flush=True)
with open(out_path, "w") as out:
for r in iter_records(pattern):
if "noise" not in r:
continue
batch_rows.append(features_of(r, schema=schema))
batch_meta.append({"file": r["file"], "sha256": r.get("sha256"), "metadata_label": label_of(r)})
if len(batch_rows) >= _SCORE_BATCH:
flush(out)
flush(out)
print(f"scored {n} -> {out_path}")
def main() -> None:
if len(sys.argv) < 2 or sys.argv[1] not in ("train", "score"):
print(__doc__)
sys.exit(2)
if sys.argv[1] == "train" and len(sys.argv) in (4, 5):
schema = parse_schema(sys.argv[4]) if len(sys.argv) == 5 else "v2"
cmd_train(sys.argv[2], sys.argv[3], schema)
elif sys.argv[1] == "score" and len(sys.argv) == 5:
cmd_score(sys.argv[2], sys.argv[3], sys.argv[4])
else:
print(__doc__)
sys.exit(2)
if __name__ == "__main__":
main()
+241
View File
@@ -0,0 +1,241 @@
"""Per-method wall time for metadata extraction and the verdict built on it.
WHY THIS EXISTS
The detection path is documented by CAPABILITY -- which signals it reads, in what
order, with what confidence -- but not by COST. Batch throughput and the question
"which probe dominates, and does it depend on container or on file size" have never
been measured.
WHAT IT MEASURES
Only the file-backed half: metadata extraction, then the verdict evaluated on the
extracted evidence. The pixel-backed detectors are deliberately out of scope.
COLD pass: one measurement on a file the process has never touched --
``extract_provenance_evidence``. Only the first read of a file is genuinely cold,
so it buys exactly one number, and that is the one a single-shot run pays.
WARM pass, with the filesystem cache now hot:
1. ``extract_provenance_evidence`` as a whole.
2. Its nine components, timed IN THE ORDER the dataclass constructs them and with
the per-file caches cleared once beforehand -- so ``extract_c2pa_info`` carries
the Rust manifest reader and the later ``get_ai_metadata`` sees the same warm
cache it sees in production. Timing them in any other order moves that cost to
a different row and flatters whichever ran second.
3. ``identify_from_evidence``: pure verdict logic, the source is never reopened.
4. ``identify(check_visible=False, check_invisible=False)`` -- extraction plus
verdict as one call, the cross-check that 1 + 3 is the whole metadata path.
Every ``@lru_cache`` in the metadata and C2PA modules is cleared before each timed
unit. Without that the second measurement of a file answers from the memo and
reports a cost of zero -- the caches are keyed on (path, mtime, size) and this
script reads each file several times.
READING IT
Component times do NOT sum to the ``extract_provenance_evidence`` total for free:
they come from a separate cache-cleared run, so the sum is a cross-check. A gap
means a component is missing from the list. Both numbers are written.
DATA SAFETY
Read-only over a local dataset. Writes only to the given output prefix, which
belongs outside the repository.
uv run python scripts/detection_timing.py <dataset> <prefix> --limit 200
"""
from __future__ import annotations
import argparse
import json
import logging
import sys
import time
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Callable, Iterator
# The package's OWN tree, not the repository root: from a worktree, an editable
# install resolves `remove_ai_watermarks` to the MAIN checkout, so a script measuring
# this tree would silently import a different one.
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from remove_ai_watermarks import identify as identify_mod
from remove_ai_watermarks import metadata as metadata_mod
from remove_ai_watermarks._internal import c2pa as c2pa_mod
log = logging.getLogger(__name__)
SUPPORTED = frozenset({".png", ".jpg", ".jpeg", ".webp", ".heic", ".heif", ".avif"})
# Timed in the order ``ProvenanceEvidence`` is constructed in ``identify.py``. The
# report script imports this to label its columns, so the order is the contract.
COMPONENTS: tuple[tuple[str, Callable[[Path], Any]], ...] = (
("c2pa_info", c2pa_mod.extract_c2pa_info),
("ai_metadata", metadata_mod.get_ai_metadata),
("scan_head", lambda p: metadata_mod.scan_head(p, identify_mod._SCAN_BYTES)),
("iptc_ai_system", metadata_mod.iptc_ai_system),
("aigc_label", metadata_mod.aigc_label),
("exif_generator", metadata_mod.exif_generator),
("xai_signature", metadata_mod.xai_signature),
("huggingface_job", metadata_mod.huggingface_job),
("samsung_genai", metadata_mod.samsung_genai),
)
def _cache_clearers() -> tuple[Callable[[], None], ...]:
"""Every per-file memo in the metadata path, found by attribute, not by name list.
A hand-written list silently goes stale the next time a probe gains a cache, and a
stale entry shows up as a suspiciously fast row rather than as an error.
"""
found: list[Callable[[], None]] = []
for module in (metadata_mod, c2pa_mod):
for name in dir(module):
clear = getattr(getattr(module, name, None), "cache_clear", None)
if callable(clear):
found.append(clear)
return tuple(found)
_CLEARERS = _cache_clearers()
def _clear() -> None:
for clear in _CLEARERS:
clear()
def _ms(fn: Callable[[], Any]) -> tuple[float, Any]:
"""Wall time in milliseconds plus the call's result."""
start = time.perf_counter_ns()
value = fn()
return (time.perf_counter_ns() - start) / 1e6, value
def _pixel_geometry(path: Path) -> tuple[str | None, int | None, int | None]:
"""Container format and pixel dimensions from the header alone."""
try:
from PIL import Image
with Image.open(path) as img:
return img.format, img.width, img.height
except Exception: # unreadable or an unsupported container
return None, None, None
def _warm_breakdown(path: Path, row: dict[str, Any]) -> None:
"""Fill ``row`` with the warm-cache per-method breakdown."""
_clear()
row["extract_evidence_ms"], evidence = _ms(lambda: identify_mod.extract_provenance_evidence(path))
_clear()
component_sum = 0.0
for name, fn in COMPONENTS:
elapsed, _ = _ms(lambda fn=fn: fn(path))
row[f"meta_{name}_ms"] = elapsed
component_sum += elapsed
row["meta_components_sum_ms"] = component_sum
row["verdict_from_evidence_ms"], report = _ms(lambda: identify_mod.identify_from_evidence(evidence))
_clear()
row["identify_metadata_only_ms"], full = _ms(
lambda: identify_mod.identify(path, check_visible=False, check_invisible=False)
)
row["scan_bytes"] = len(evidence.scan)
row["has_c2pa"] = bool(evidence.c2pa_info)
row["has_ai_metadata"] = bool(evidence.ai_metadata)
row["is_ai_generated"] = full.is_ai_generated
row["confidence"] = full.confidence
row["platform"] = full.platform
row["signals"] = [signal.name for signal in full.signals]
# The two verdict paths must agree; a mismatch means the breakdown timed a
# different code path than the end-to-end call and the rows are not comparable.
row["verdict_agrees"] = (report.confidence, report.platform) == (full.confidence, full.platform)
def _measure(path: Path) -> dict[str, Any]:
row: dict[str, Any] = {"path": str(path), "ext": path.suffix.lower()}
try:
row["bytes"] = path.stat().st_size
except OSError as exc:
return {**row, "error": f"stat: {exc}"}
# COLD first: this is the only moment the file is untouched by this process.
_clear()
try:
row["cold_extract_evidence_ms"], _ = _ms(lambda: identify_mod.extract_provenance_evidence(path))
except Exception as exc:
return {**row, "error": f"cold extract: {type(exc).__name__}: {exc}"}
row["format"], row["width"], row["height"] = _pixel_geometry(path)
width, height = row["width"], row["height"]
row["megapixels"] = round(width * height / 1e6, 3) if width and height else None
try:
_warm_breakdown(path, row)
except Exception as exc:
row["error"] = f"warm pass: {type(exc).__name__}: {exc}"
return row
def _iter_images(root: Path) -> Iterator[Path]:
for path in sorted(root.rglob("*")):
if path.is_file() and path.suffix.lower() in SUPPORTED:
yield path
def _done_paths(out_path: Path) -> set[str]:
"""Paths already recorded, so a long run resumes instead of restarting."""
if not out_path.exists():
return set()
done: set[str] = set()
with out_path.open(encoding="utf-8") as handle:
for line in handle:
try:
done.add(json.loads(line)["path"])
except (ValueError, KeyError):
continue
return done
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("dataset", type=Path, help="directory of images, scanned recursively")
parser.add_argument("out_prefix", type=Path, help="output prefix; writes <prefix>.jsonl")
parser.add_argument("--limit", type=int, default=0, help="stop after N files (0 = all)")
parser.add_argument("--progress-every", type=int, default=200)
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
out_path = args.out_prefix.with_suffix(".jsonl")
out_path.parent.mkdir(parents=True, exist_ok=True)
done = _done_paths(out_path)
if done:
log.info("resuming: %d files already recorded", len(done))
processed = 0
started = time.monotonic()
with out_path.open("a", encoding="utf-8") as handle:
for path in _iter_images(args.dataset):
if str(path) in done:
continue
row = _measure(path)
handle.write(json.dumps(row, ensure_ascii=False, default=str) + "\n")
handle.flush()
processed += 1
if processed % args.progress_every == 0:
log.info("%d files, %.2f files/s", processed, processed / (time.monotonic() - started))
if args.limit and processed >= args.limit:
break
elapsed = time.monotonic() - started
rate = processed / max(elapsed, 1e-9)
log.info("done: %d files in %.1f s (%.2f files/s) -> %s", processed, elapsed, rate, out_path)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+232
View File
@@ -0,0 +1,232 @@
"""Aggregate ``detection_timing.py`` records into per-method, per-segment tables.
WHAT IT PRODUCES
Reading it back: the per-file JSONL is one row per image with a millisecond field
per method. This collapses it into percentiles per method, then repeats that per
segment -- container, megapixels, file size, C2PA presence, verdict confidence --
because a single median hides a path whose cost is carried entirely by one
container or by the files that actually have a manifest.
Writes ``<prefix>_summary.csv`` (long form: segment_kind, segment, method, n, p50,
p90, p99, mean) and prints a markdown report.
Percentiles are computed by nearest-rank on the sorted sample, not interpolated:
every reported number is a time some real file actually took.
DATA SAFETY
Reads and writes only the given prefix, which belongs outside the repository.
uv run python scripts/detection_timing_report.py .local-eval/timing/full
"""
from __future__ import annotations
import argparse
import csv
import json
import math
import statistics
import sys
from collections import defaultdict
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Callable, Iterator
sys.path.insert(0, str(Path(__file__).resolve().parent))
from detection_timing import COMPONENTS as _TIMED
# Taken from the script that WROTE the rows, in its order, so a probe added, removed
# or reordered there cannot silently leave a column missing or mislabelled here.
COMPONENTS = tuple(name for name, _ in _TIMED)
METHODS = (
("cold_extract_evidence_ms", "extract_provenance_evidence (cold)"),
("extract_evidence_ms", "extract_provenance_evidence (warm)"),
*((f"meta_{name}_ms", f" {name}") for name in COMPONENTS),
("meta_components_sum_ms", " (sum of components)"),
("verdict_from_evidence_ms", "identify_from_evidence (verdict)"),
("identify_metadata_only_ms", "identify(metadata only), end to end"),
)
# Median-only columns in the per-segment tables; the full percentile set goes to the CSV.
HEADLINE_LABELS = (
("extract_evidence_ms", "extract p50"),
("verdict_from_evidence_ms", "verdict p50"),
("identify_metadata_only_ms", "end-to-end p50"),
)
HEADLINE = tuple(key for key, _ in HEADLINE_LABELS)
def _bucket(value: float | None, edges: tuple[float, ...], labels: tuple[str, ...]) -> str:
if value is None:
return "unknown"
for edge, label in zip(edges, labels, strict=False):
if value < edge:
return label
return labels[-1]
SEGMENTS: tuple[tuple[str, Callable[[dict[str, Any]], str]], ...] = (
("container", lambda r: str(r.get("format") or "unknown")),
(
"megapixels",
lambda r: _bucket(r.get("megapixels"), (1, 4, 12), ("<1 MP", "1-4 MP", "4-12 MP", ">12 MP")),
),
(
"file size",
lambda r: _bucket(
(r["bytes"] / 1e6) if r.get("bytes") is not None else None,
(1, 5, 20),
("<1 MB", "1-5 MB", "5-20 MB", ">20 MB"),
),
),
("c2pa", lambda r: "with C2PA" if r.get("has_c2pa") else "no C2PA"),
("verdict", lambda r: f"confidence={r.get('confidence') or 'n/a'}"),
)
def _numeric(records: list[dict[str, Any]], key: str) -> list[float]:
"""The numeric values of ``key``, skipping rows where it is absent or non-numeric.
One definition of "counts as a measurement", so the printed table and the CSV can
never disagree about which rows a method was measured on.
"""
return [float(r[key]) for r in records if isinstance(r.get(key), (int, float))]
def _percentile(sorted_values: list[float], q: float) -> float:
"""Nearest-rank percentile: the reported value is one a real file produced."""
index = max(0, math.ceil(q * len(sorted_values)) - 1)
return sorted_values[index]
def _stats(values: list[float]) -> dict[str, float | int]:
ordered = sorted(values)
return {
"n": len(ordered),
"p50": _percentile(ordered, 0.50),
"p90": _percentile(ordered, 0.90),
"p99": _percentile(ordered, 0.99),
"mean": statistics.fmean(ordered),
}
def _rows(path: Path) -> Iterator[dict[str, Any]]:
with path.open(encoding="utf-8") as handle:
for line in handle:
try:
yield json.loads(line)
except ValueError: # a truncated tail while the run is still writing
continue
def _table(rows: list[dict[str, Any]], methods: tuple[tuple[str, str], ...]) -> list[str]:
out = ["| method | n | p50 | p90 | p99 | mean |", "|---|---:|---:|---:|---:|---:|"]
for key, label in methods:
values = _numeric(rows, key)
if not values:
continue
s = _stats(values)
out.append(f"| {label} | {s['n']} | {s['p50']:.3f} | {s['p90']:.3f} | {s['p99']:.3f} | {s['mean']:.3f} |")
return out
def _correlation(rows: list[dict[str, Any]], x_key: str, y_key: str) -> float | None:
both = [r for r in rows if isinstance(r.get(x_key), (int, float)) and isinstance(r.get(y_key), (int, float))]
if len(both) < 3:
return None
xs = _numeric(both, x_key)
ys = _numeric(both, y_key)
try:
return statistics.correlation(xs, ys)
except statistics.StatisticsError: # a constant column has no correlation
return None
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("prefix", type=Path, help="prefix given to detection_timing.py")
args = parser.parse_args()
jsonl = args.prefix.with_suffix(".jsonl")
all_rows = list(_rows(jsonl))
failed = [r for r in all_rows if "error" in r]
rows = [r for r in all_rows if "error" not in r]
if not rows:
print(f"no usable records in {jsonl}")
return 1
disagreed = [r for r in rows if r.get("verdict_agrees") is False]
lines = [
f"# Detection timing over {len(rows)} images",
"",
f"Source: `{jsonl}`. Failed rows: {len(failed)}. Verdict-path mismatches: {len(disagreed)}.",
"",
"All times in milliseconds. Percentiles are nearest-rank.",
"",
"## Whole corpus",
"",
*_table(rows, METHODS),
"",
]
csv_rows: list[dict[str, Any]] = []
for key, label in METHODS:
values = _numeric(rows, key)
if values:
csv_rows.append({"segment_kind": "all", "segment": "all", "method": label.strip(), **_stats(values)})
for kind, classify in SEGMENTS:
groups: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in rows:
groups[classify(row)].append(row)
lines += [f"## By {kind}", ""]
header = ["| segment | n | " + " | ".join(label for _, label in HEADLINE_LABELS) + " |"]
header.append("|---|---:|" + "---:|" * len(HEADLINE))
lines += header
for segment, group in sorted(groups.items(), key=lambda item: -len(item[1])):
cells = []
for key in HEADLINE:
values = _numeric(group, key)
cells.append(f"{_percentile(sorted(values), 0.5):.2f}" if values else "-")
lines.append(f"| {segment} | {len(group)} | " + " | ".join(cells) + " |")
for key, label in METHODS:
values = _numeric(group, key)
if values:
csv_rows.append(
{"segment_kind": kind, "segment": segment, "method": label.strip(), **_stats(values)}
)
lines.append("")
corr = _correlation(rows, "scan_bytes", "verdict_from_evidence_ms")
corr_size = _correlation(rows, "bytes", "extract_evidence_ms")
lines += [
"## Scaling",
"",
f"- verdict time vs scan-buffer size: r = {corr:.3f}" if corr is not None else "- verdict correlation: n/a",
(
f"- extraction time vs file size: r = {corr_size:.3f}"
if corr_size is not None
else "- extraction correlation: n/a"
),
"",
]
summary_csv = args.prefix.with_name(args.prefix.name + "_summary.csv")
columns = ["segment_kind", "segment", "method", "n", "p50", "p90", "p99", "mean"]
with summary_csv.open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=columns)
writer.writeheader()
writer.writerows(csv_rows)
report = "\n".join(lines)
args.prefix.with_name(args.prefix.name + "_report.md").write_text(report + "\n", encoding="utf-8")
print(report)
print(f"\nwrote {summary_csv}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+5 -2
View File
@@ -1,7 +1,7 @@
"""How much recall is the coarse scale ladder costing, and what would a denser one cost?
THE FINDING THIS MEASURES
`_tophat_score` sweeps three rungs -- (0.8, 1.0, 1.25) -- and the `binary` front-end
`_ladder_best` sweeps three rungs -- (0.8, 1.0, 1.25) -- and the `binary` front-end
sweeps none at all. Measured on stamped marks over controlled backgrounds
(`scripts/detector_response.py`), the response is a COMB: doubao scores 0.99 exactly at
each rung and collapses to 0.37-0.48 between them, against a 0.50 gate. So a mark whose
@@ -93,7 +93,10 @@ def wilson(k: int, n: int, z: float = 1.96) -> tuple[float, float]:
def score_at_scales(engine: Any, image: np.ndarray, scales: tuple[float, ...]) -> dict[float, float]:
"""`_tophat_score`'s inner loop, opened up so the ladder is a parameter.
"""`_ladder_best`'s inner loop, opened up so the ladder is a parameter.
A DELIBERATE second copy of the sweep: it exists to measure ladders the shipped
engine does not have, so it must not be folded back into `_ladder_best`.
Deliberately reaches into the engine (`tophat_response`, `_glyph_silhouette`): a
measurement script may, product code may not. Kept a faithful copy of the shipped
+224
View File
@@ -0,0 +1,224 @@
"""Audit the record path against the file path over a whole dataset.
WHY THIS EXISTS
Two seams reach the same provenance verdict:
identify(path, check_visible=False, check_invisible=False)
identify_metadata_record(collect_metadata_record(path), path=path)
Their equality is the record's entire contract, and it can break from either side --
a region the collector stops walking, or a placement the file path learns to read and
the record does not. ``tests/test_metadata_record.py`` pins it over the tracked
fixtures; those cover the signal families we already know about. This covers the ones
we do not: every real placement in a real corpus, which is where all three defects
found so far actually came from.
The record is round-tripped through ``json.dumps``/``loads`` before it is judged, so
a value that only survives in memory fails here rather than at a customer.
WHAT IT REPORTS
One JSONL row per image: both verdicts, whether they agree, the record size, and any
exception from either side. The summary counts disagreements by field and by signal,
so "the record lost samsung_genai on 13 files" reads directly off the output instead
of being reconstructed.
Pass ``--baseline`` with an earlier run to also diff against it. That answers the
other question a detection change raises: which files changed verdict, and are they
exactly the ones that were meant to.
DATA SAFETY
Read-only over a local dataset. Writes only the given output path, which belongs
outside the repository. Resumable: rerunning skips files already recorded.
uv run python scripts/record_parity_audit.py data/spaces/originals .local-eval/parity.jsonl
uv run python scripts/record_parity_audit.py <dataset> <out> --baseline .local-eval/previous.jsonl
"""
from __future__ import annotations
import argparse
import collections
import json
import logging
import sys
import time
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Iterator
# The package's OWN tree, not the repository root: from a worktree, an editable
# install resolves `remove_ai_watermarks` to the MAIN checkout, so a script measuring
# this tree would silently import a different one.
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from remove_ai_watermarks.identify import identify, identify_metadata_record
from remove_ai_watermarks.metadata_record import collect_metadata_record
log = logging.getLogger(__name__)
SUPPORTED = frozenset({".png", ".jpg", ".jpeg", ".webp", ".heic", ".heif", ".avif"})
# Every field of the verdict a caller can act on. `path` is excluded: it is extraction
# context, and the two paths are handed the same one by construction.
COMPARED = ("is_ai_generated", "platform", "confidence", "ai_source_kind", "ai_from_metadata")
def _verdict(report: Any) -> dict[str, Any]:
return {
**{field: getattr(report, field) for field in COMPARED},
"signals": sorted(signal.name for signal in report.signals),
"watermarks": sorted(report.watermarks),
}
def _audit(path: Path) -> dict[str, Any]:
row: dict[str, Any] = {"path": str(path)}
try:
row["bytes"] = path.stat().st_size
except OSError as exc:
return {**row, "error": f"stat: {exc}"}
try:
started = time.perf_counter()
record = json.loads(json.dumps(collect_metadata_record(path)))
row["collect_ms"] = (time.perf_counter() - started) * 1000
row["record_bytes"] = len(json.dumps(record))
row["container"] = record.get("container")
via_record = _verdict(identify_metadata_record(record, path=path))
except Exception as exc:
return {**row, "error": f"record path: {type(exc).__name__}: {exc}"}
try:
via_file = _verdict(identify(path, check_visible=False, check_invisible=False))
except Exception as exc:
return {**row, "error": f"file path: {type(exc).__name__}: {exc}"}
row["record"] = via_record
row["file"] = via_file
row["agree"] = via_record == via_file
return row
def _iter_images(root: Path) -> Iterator[Path]:
for path in sorted(root.rglob("*")):
if path.is_file() and path.suffix.lower() in SUPPORTED:
yield path
def _done(out_path: Path) -> set[str]:
if not out_path.exists():
return set()
done: set[str] = set()
with out_path.open(encoding="utf-8") as handle:
for line in handle:
try:
done.add(json.loads(line)["path"])
except (ValueError, KeyError):
continue
return done
def _summarize(rows: list[dict[str, Any]], baseline: Path | None) -> None:
failed = [r for r in rows if "error" in r]
usable = [r for r in rows if "error" not in r]
disagreed = [r for r in usable if not r["agree"]]
print(f"\nimages: {len(rows)} errors: {len(failed)} compared: {len(usable)}")
print(f"record path disagrees with file path: {len(disagreed)}")
for row in failed[:10]:
print(f" ERROR {Path(row['path']).name}: {row['error']}")
fields: collections.Counter[str] = collections.Counter()
for row in disagreed:
fields.update(field for field in COMPARED if row["record"][field] != row["file"][field])
for name in set(row["file"]["signals"]) - set(row["record"]["signals"]):
fields[f"signal missing from record: {name}"] += 1
for name in set(row["record"]["signals"]) - set(row["file"]["signals"]):
fields[f"signal only in record: {name}"] += 1
for label, count in fields.most_common():
print(f" {label}: {count}")
for row in disagreed[:10]:
print(f" {Path(row['path']).name}\n record: {row['record']}\n file: {row['file']}")
if baseline is None:
return
previous = {}
with baseline.open(encoding="utf-8") as handle:
for line in handle:
try:
item = json.loads(line)
except ValueError:
continue
if "error" not in item:
previous[Path(item["path"]).name] = item
changed = []
for row in usable:
was = previous.get(Path(row["path"]).name)
if was is None:
continue
# The WHOLE verdict, not a chosen subset. A first version compared confidence
# and signals only and reported "0 changed" for a run whose single intended
# correction was a watermark line -- the change it existed to show.
before = was.get("file") or {}
if before and before != row["file"]:
changed.append((row["path"], before, row["file"]))
print(f"\nverdicts changed against the baseline: {len(changed)}")
moved: collections.Counter[str] = collections.Counter()
for _, before, after in changed:
for name in set(after["signals"]) - set(before.get("signals") or []):
moved[f"gained signal {name}"] += 1
for name in set(before.get("signals") or []) - set(after["signals"]):
moved[f"LOST signal {name}"] += 1
for field in (*COMPARED, "watermarks"):
if before.get(field) != after.get(field):
moved[f"{field} changed"] += 1
for label, count in moved.most_common():
print(f" {label}: {count}")
for path, before, after in changed[:10]:
print(f" {Path(path).name}\n before: {before}\n after: {after}")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("dataset", type=Path)
parser.add_argument("out", type=Path)
parser.add_argument("--baseline", type=Path, default=None, help="an earlier run to diff verdicts against")
parser.add_argument("--limit", type=int, default=0)
parser.add_argument("--progress-every", type=int, default=2000)
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
args.out.parent.mkdir(parents=True, exist_ok=True)
done = _done(args.out)
if done:
log.info("resuming: %d images already audited", len(done))
processed = 0
started = time.monotonic()
with args.out.open("a", encoding="utf-8") as handle:
for path in _iter_images(args.dataset):
if str(path) in done:
continue
handle.write(json.dumps(_audit(path), ensure_ascii=False, default=str) + "\n")
handle.flush()
processed += 1
if processed % args.progress_every == 0:
log.info("%d images, %.1f/s", processed, processed / (time.monotonic() - started))
if args.limit and processed >= args.limit:
break
log.info("audited %d images in %.1f s", processed, time.monotonic() - started)
with args.out.open(encoding="utf-8") as handle:
rows = [json.loads(line) for line in handle]
_summarize(rows, args.baseline)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+4 -11
View File
@@ -62,17 +62,10 @@ OUT = REPO / ".local-eval" / "vendor-cohorts.jsonl"
FIRED = REPO / ".local-eval" / "visible-positives.jsonl"
SHEET_DIR = REPO / ".local-eval" / "vendor-cohort-sheets"
# A producer code is `001` + `1` + USCC(18) + a 5-digit app/product suffix, so two
# codes sharing the USCC are the same legal entity registering different products.
# Slicing is defensive: anything not matching the layout is grouped by its raw value.
_USCC_START, _USCC_END = 4, 22
def uscc_of(code: str) -> str:
"""The 18-char Unified Social Credit Code embedded in a TC260 producer code."""
if len(code) >= _USCC_END and code[:3] == "001":
return code[_USCC_START:_USCC_END]
return code
# `uscc_of` moved into the library (`metadata.uscc_of`) when the USCC -> vendor table
# started driving `api.visible_provenance`; this script must group by the same rule the
# product uses, so it imports rather than reimplements it.
from remove_ai_watermarks.metadata import uscc_of # noqa: E402
def _one(path_str: str) -> dict[str, Any] | None:
+3 -3
View File
@@ -97,7 +97,7 @@ def _score(args: ScoreArgs) -> dict[str, Any] | None:
eng = TextMarkEngine(build_config(asset, name, basis, overrides))
loc = eng.locate(img)
try:
score, box = eng._tophat_best(img, loc)
score, box = eng._ladder_best(img, loc)
except Exception:
return None
return {"path": path_str, "score": round(float(score), 4), "box": box}
@@ -225,7 +225,7 @@ def sheets(pos: list[dict[str, Any]], name: str, per_sheet: int = 24) -> None:
# genuinely unknown, which is the one case a dense ladder earns its cost -- but it also
# hands clean corners extra chances to match, so it must never set a gate.
_FIT_SCALES = tuple(round(0.4 * (1.03**i), 4) for i in range(80)) # 0.40 .. ~4.1
# The ladder the product actually ships (`_text_mark_engine._tophat_best`).
# The ladder the product actually ships (`_text_mark_engine._ladder_best`).
_SHIPPED_LADDER = (0.8, 1.0, 1.25)
@@ -460,7 +460,7 @@ def _cross_score(args: tuple[str, Any, Any]) -> dict[str, Any] | None:
eng = TextMarkEngine(cfg)
loc = eng.locate(img)
try:
score, _ = eng._tophat_best(img, loc)
score, _ = eng._ladder_best(img, loc)
except Exception:
return None
out[key] = round(float(score), 4)
+27 -3
View File
@@ -32,12 +32,18 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
_warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*")
__version__ = "0.25.0"
__version__ = "0.26.0"
__all__ = [
"BatchSummary",
"InvisibleOptions",
"MetadataStripIncomplete",
"RemoveAllResult",
"__version__",
"identify_video",
"inspect_video_metadata",
"remove_all",
"remove_batch",
"remove_video_all",
"remove_video_batch",
"remove_video_invisible",
@@ -48,7 +54,16 @@ __all__ = [
]
if TYPE_CHECKING:
from remove_ai_watermarks.api import remove_visible, visible_provenance
from remove_ai_watermarks.api import (
BatchSummary,
InvisibleOptions,
MetadataStripIncomplete,
RemoveAllResult,
remove_all,
remove_batch,
remove_visible,
visible_provenance,
)
from remove_ai_watermarks.video import (
identify_video,
inspect_video_metadata,
@@ -63,7 +78,16 @@ if TYPE_CHECKING:
def __getattr__(name: str) -> object:
"""Lazily resolve the high-level API (PEP 562), so the heavy imports (cv2, the
metadata/identify stack) load only when a caller actually reaches for them."""
if name in ("remove_visible", "visible_provenance"):
if name in (
"BatchSummary",
"InvisibleOptions",
"MetadataStripIncomplete",
"RemoveAllResult",
"remove_all",
"remove_batch",
"remove_visible",
"visible_provenance",
):
from remove_ai_watermarks import api
return getattr(api, name)
+50 -4
View File
@@ -29,7 +29,9 @@ if TYPE_CHECKING:
from typing import BinaryIO
_C2paReader: Any = None
_C2paError: Any = None
with contextlib.suppress(Exception):
from c2pa import C2paError as _C2paError # pyright: ignore[reportMissingTypeStubs]
from c2pa import Reader as _C2paReader # pyright: ignore[reportMissingTypeStubs]
_C2PA_READER_AVAILABLE = _C2paReader is not None
@@ -48,10 +50,22 @@ def reader_available() -> bool:
def _manifest_json_uncached(path: str) -> str | None:
"""The manifest store as JSON, or None when this file has no readable manifest.
Two outcomes are routine and stay at debug: a file with no manifest (``try_create``
returns None) and a container the reader does not support. ANY other failure is
logged at warning, because the caller cannot tell the difference from the return
value and the consequence is severe: the verdict silently falls back to the raw
byte scan and can lose a high-confidence signal. The log line preserves the
diagnostic context needed to investigate an intermittent reader failure.
"""
try:
reader = _C2paReader.try_create(path)
except _C2paError.NotSupported as error:
logger.debug("C2PA reader does not support %s: %s", path, error)
return None
except Exception as error:
logger.debug("C2PA reader rejected %s: %s", path, error)
logger.warning("C2PA reader failed to open %s: %s: %s", path, type(error).__name__, error)
return None
if reader is None:
return None
@@ -59,7 +73,9 @@ def _manifest_json_uncached(path: str) -> str | None:
with reader:
return cast("str", reader.json())
except Exception as error:
logger.debug("C2PA reader could not serialize %s: %s", path, error)
# The reader opened the file, so a manifest is there; failing to serialize it
# is never routine.
logger.warning("C2PA reader could not serialize %s: %s: %s", path, type(error).__name__, error)
return None
@@ -329,14 +345,44 @@ def _extract_c2pa_info_png(image_path: Path) -> dict[str, Any]:
return info
def extract_c2pa_info(image_path: Path) -> dict[str, Any]:
"""Return normalized C2PA evidence from the official reader or PNG fallback."""
def _extract_c2pa_info_impl(image_path: Path) -> dict[str, Any]:
store = read_manifest_store_json(Path(image_path))
if store is not None:
return c2pa_info_from_manifest_store(store)
return _extract_c2pa_info_png(Path(image_path))
@functools.lru_cache(maxsize=4)
def _extract_c2pa_info_cached(path_str: str, _mtime_ns: int, _size: int, _reader: bool) -> dict[str, Any]:
"""Cache shim: every argument after the path is key-only.
``_reader`` is in the key because the answer genuinely depends on it -- with the
official reader the manifest comes back as a store, without it from the hand-rolled
PNG chunk parser, and the two produce different ``c2pa_manifest`` labels. Keying on
the file alone handed a reader-path result to a caller that had disabled the reader.
"""
return _extract_c2pa_info_impl(Path(path_str))
def extract_c2pa_info(image_path: Path) -> dict[str, Any]:
"""Return normalized C2PA evidence from the official reader or PNG fallback.
Memoized on ``(path, mtime_ns, size)``: one ``identify`` reaches this twice (once
directly, once inside ``get_ai_metadata``) and each call re-runs the Rust manifest
reader and re-parses its JSON. Size joins mtime in the key because this package
rewrites files in place, and an in-place rewrite can land inside one mtime tick.
"""
try:
stat = image_path.stat()
except OSError:
# No stat (a pipe, or a race): read uncached rather than fail.
return _extract_c2pa_info_impl(image_path)
cached = _extract_c2pa_info_cached(str(image_path), stat.st_mtime_ns, stat.st_size, _C2PA_READER_AVAILABLE)
# Deep-ish copy: values are scalars plus a few lists, and a caller mutating one of
# those lists would otherwise poison every later reader of the same file.
return {key: list(cast("list[Any]", value)) if isinstance(value, list) else value for key, value in cached.items()}
def inject_c2pa_chunk(target_path: Path, output_path: Path, c2pa_chunk: bytes) -> None:
"""Replace any C2PA chunks in a PNG and insert ``c2pa_chunk`` before IDAT."""
if target_path.suffix.casefold() != ".png" or output_path.suffix.casefold() != ".png":
@@ -20,6 +20,9 @@ AI_KEYWORDS = _tokens(
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
C2PA_CHUNK_TYPE = b"caBX"
PNG_METADATA_CHUNKS = frozenset({b"tEXt", b"iTXt", b"zTXt", b"eXIf", b"iCCP"})
RIFF_METADATA_CHUNKS = frozenset({b"EXIF", b"XMP ", b"ICCP", b"C2PA"})
RIFF_CODED_IMAGE_CHUNKS = frozenset({b"VP8 ", b"VP8L", b"ALPH", b"ANMF"})
C2PA_SIGNATURES = tuple(
token.encode() for token in _tokens("c2pa|C2PA|jumb|jumd|JUMBF|jumbf|cbor|contentcreds|digid|assertions|manifest")
)
@@ -147,3 +150,15 @@ AI_GENERATOR_TOKENS = frozenset(
_C2PA_ACTION_NAMES = _tokens("created|converted|edited|filtered|cropped|resized|opened|placed")
C2PA_ACTIONS = {f"c2pa.{action}".encode(): action for action in _C2PA_ACTION_NAMES}
# TC260 producer identity -> the mark key whose vendor signs with it now lives on the
# registry rows (``KnownMark.tc260_producer_codes``, read through
# ``watermark_registry.tc260_producer_vendors``). Keeping the codes beside the mark is
# what stops a newly registered TC260 vendor from silently falling back to ByteDance.
#
# What a TC260 label confirms when its producer is absent or unmapped. Historical
# behaviour, kept as the fallback so an unrecognized producer never regresses to no
# relaxation at all: ByteDance's two products are the ones the relaxed band was
# calibrated on (see _text_mark_engine._DEFAULT_PROVENANCE_NCC_FACTOR).
TC260_FALLBACK_VENDORS: frozenset[str] = frozenset({"doubao", "jimeng"})
@@ -64,7 +64,7 @@ _AI_LABEL_MARKERS: tuple[bytes, ...] = AIGC_MARKERS + IPTC_AI_MARKERS + IPTC_AI_
# blanked in place (see ``blank_ai_xmp_packets``).
_XMP_PACKET_RE = re.compile(rb"<\?xpacket begin=.*?<\?xpacket end=[^>]*?\?>", re.DOTALL)
_STREAM_COPY_BYTES = 1024 * 1024
_STREAM_SCAN_BYTES = 4 * 1024 * 1024
STREAM_SCAN_BYTES = 4 * 1024 * 1024
# TC260-PG-20257A stores an MP4/MOV label as an ``AIGC`` key in
@@ -133,7 +133,7 @@ def _read_box_header(
return end, box_type, payload_off
def _iter_file_boxes(
def iter_file_boxes(
stream: BinaryIO,
start: int,
end: int,
@@ -193,17 +193,17 @@ def _tc260_aigc_regions(
Each tuple is ``(key_start, key_end, value_start, value_end, value)``.
"""
regions: list[tuple[int, int, int, int, bytes]] = []
for _moov_start, moov_end, moov_type, moov_payload in _iter_file_boxes(stream, 0, file_size):
for _moov_start, moov_end, moov_type, moov_payload in iter_file_boxes(stream, 0, file_size):
if moov_type != b"moov":
continue
for _udta_start, udta_end, udta_type, udta_payload in _iter_file_boxes(
for _udta_start, udta_end, udta_type, udta_payload in iter_file_boxes(
stream,
moov_payload,
moov_end,
):
if udta_type != b"udta":
continue
for _meta_start, meta_end, meta_type, meta_payload in _iter_file_boxes(
for _meta_start, meta_end, meta_type, meta_payload in iter_file_boxes(
stream,
udta_payload,
udta_end,
@@ -212,7 +212,7 @@ def _tc260_aigc_regions(
continue
keys: dict[int, tuple[int, int]] = {}
ilst_boxes: list[tuple[int, int]] = []
for _child_start, child_end, child_type, child_payload in _iter_file_boxes(
for _child_start, child_end, child_type, child_payload in iter_file_boxes(
stream,
meta_payload + 4,
meta_end,
@@ -224,7 +224,7 @@ def _tc260_aigc_regions(
if not keys:
continue
for ilst_payload, ilst_end in ilst_boxes:
for _item_start, item_end, item_type, item_payload in _iter_file_boxes(
for _item_start, item_end, item_type, item_payload in iter_file_boxes(
stream,
ilst_payload,
ilst_end,
@@ -233,7 +233,7 @@ def _tc260_aigc_regions(
key_span = keys.get(index)
if key_span is None:
continue
for _data_start, data_end, data_type, data_payload in _iter_file_boxes(
for _data_start, data_end, data_type, data_payload in iter_file_boxes(
stream,
item_payload,
item_end,
@@ -425,7 +425,7 @@ def strip_isobmff_media_file(
source: str | Path,
output: str | Path,
*,
max_box_scan: int = _STREAM_SCAN_BYTES,
max_box_scan: int = STREAM_SCAN_BYTES,
) -> tuple[int, int]:
"""Stream-copy an MP4/MOV/M4A while removing supported AI metadata.
@@ -561,11 +561,18 @@ def _clip_sam_masks_to_boxes(
image_size: tuple[int, int],
) -> list[np.ndarray]:
"""Match Impact Pack by intersecting each SAM mask with its detector box."""
# Same rectangle primitive the shared fill uses, rather than a private zeros/fill
# copy. `dilate=0` because this box CLIPS a SAM mask -- growing it would admit the
# pixels the clip exists to exclude. The function-local import keeps region_eraser's
# module-scope cv2 off this module's import path.
from remove_ai_watermarks.region_eraser import boxes_to_mask
width, height = image_size
clipped: list[np.ndarray] = []
for mask, (x1, y1, x2, y2) in zip(masks, boxes, strict=True):
box_mask = np.zeros((height, width), dtype=np.uint8)
box_mask[max(0, y1) : min(height, y2), max(0, x1) : min(width, x2)] = 255
# (x, y, w, h) with w = x2 - x1 keeps x + w == x2, so a negative origin clamps
# to the same span the explicit max/min pair produced.
box_mask = boxes_to_mask((height, width), [(x1, y1, x2 - x1, y2 - y1)], dilate=0)
clipped.append(np.bitwise_and(mask.astype(np.uint8), box_mask))
return clipped
@@ -0,0 +1,16 @@
"""Shared validation for versioned JSON transport contracts."""
from collections.abc import Collection
def require_schema_version(
value: object,
*,
contract: str,
supported: Collection[int],
) -> int:
"""Return an explicitly supported integer schema version or raise."""
if type(value) is not int or value not in supported:
versions = ", ".join(str(version) for version in sorted(supported))
raise ValueError(f"Unsupported {contract} schema: {value!r}; supported versions: {versions}")
return value
@@ -10,7 +10,6 @@ registry and every identify path still run anywhere.
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal
if TYPE_CHECKING:
@@ -71,19 +70,12 @@ SDXL_ZIMAGE_GEMINI_STRENGTH = 0.25
SDXL_ZIMAGE_UNKNOWN_STRENGTH = SDXL_ZIMAGE_GEMINI_STRENGTH
@dataclass(frozen=True)
class _StrengthPolicy:
unknown: float
by_vendor: dict[str, float]
def choose(self, vendor: str | None) -> float:
return self.by_vendor.get((vendor or "").casefold(), self.unknown)
_SDXL_ZIMAGE_POLICY = _StrengthPolicy(
unknown=SDXL_ZIMAGE_UNKNOWN_STRENGTH,
by_vendor={"openai": SDXL_ZIMAGE_OPENAI_STRENGTH, "google": SDXL_ZIMAGE_GEMINI_STRENGTH},
)
# sdxl-zimage picks its strength from the VENDOR (unlike qwen-zimage, which derives it
# from image area). An unlisted or unknown vendor falls back to the Gemini value.
_SDXL_ZIMAGE_STRENGTH_BY_VENDOR: dict[str, float] = {
"openai": SDXL_ZIMAGE_OPENAI_STRENGTH,
"google": SDXL_ZIMAGE_GEMINI_STRENGTH,
}
_ALIASES = {
"qwen_zimage": QWEN_ZIMAGE_PROFILE,
"sdxl_zimage": SDXL_ZIMAGE_PROFILE,
@@ -134,7 +126,7 @@ def resolve_strength(
if strength is not None:
return strength
if normalize_profile(pipeline or "") == SDXL_ZIMAGE_PROFILE:
return _SDXL_ZIMAGE_POLICY.choose(vendor)
return _SDXL_ZIMAGE_STRENGTH_BY_VENDOR.get((vendor or "").casefold(), SDXL_ZIMAGE_UNKNOWN_STRENGTH)
if size is None:
raise ValueError("qwen-zimage resolves strength from image area, so size is required")
from remove_ai_watermarks._internal.qwen_zimage_pipeline import resolution_adaptive_denoise
+297 -184
View File
@@ -132,7 +132,7 @@ class TextMarkConfig:
# Which image dimension the mark's size and margins scale with. VENDOR-SPECIFIC,
# measured, not assumed -- see TextMarkEngine.scale_base. "short" = min(h, w), "width" = w.
scale_basis: Literal["short", "width"] = "width"
# Scale rungs ``_tophat_best`` sweeps (the detection comb). PER-MARK: a vendor
# Scale rungs ``_ladder_best`` sweeps (the detection comb). PER-MARK: a vendor
# whose stamp sizes do not land on the shared 3-rung comb carries its own ladder
# (measured for 千问, whose marks sit in two size modes ~1.6x apart -- one fraction
# on 3 rungs covers only ~75% of them). Densifying the SHARED ladder for everyone
@@ -170,6 +170,33 @@ class TextMarkDetection:
confidence: float = 0.0
region: tuple[int, int, int, int] = (0, 0, 0, 0)
coverage: float = 0.0 # fraction of the box occupied by glyph pixels
# ROI-local (x0, y0, x1, y1) of the ladder sweep's best match, in the LOCATED BOX's
# coordinates. None for the ``binary`` front-end (it runs no sweep) and whenever no
# rung matched. ``footprint_mask`` bounds the fill with it, so carrying it here is
# what stops the mask path re-running a sweep the detector already ran.
match_box: tuple[int, int, int, int] | None = None
# The trust level this detection was taken at, mirroring detect()'s ``provenance``.
# ``footprint_mask`` reuses a threaded detection only when it matches the STRICT
# level its own re-detect would have used -- see TextMarkEngine._strict_detection.
provenance: bool = False
@dataclass(frozen=True)
class TextMarkScan:
"""The trust-level-BLIND half of text-mark detection, reusable across both levels.
``loc is None`` means detection stopped before any scan (empty or too-small image).
``score is None`` means the binary front-end fell below its coverage gate, which is
a verdict of "not detected, confidence 0.0" without consulting the rival margin.
"""
loc: TextMarkLocation | None
box: NDArray[Any] | None # box-sized binary glyph mask
base: int # scale_base(image)
frame: tuple[int, int] = (0, 0) # (h, w) of the scanned image
coverage: float = 0.0
score: float | None = None
match_box: tuple[int, int, int, int] | None = None
# Alpha / silhouette templates, cached per asset name. This shared cache lets every
@@ -306,6 +333,28 @@ class TextMarkEngine:
# ── Locate ──────────────────────────────────────────────────────────
def _roi_fields(
self, image: NDArray[Any], loc: TextMarkLocation
) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
"""``(luma, saturation, local_background)`` for the located box, all float32.
The ROI is normalized to 3-channel BGR first (grayscale / BGRA would break
``axis=2``).
Local background model: a strong Gaussian blur (sigma ~ box height); the white
top-hat (``luma - local_bg``) lights up bright thin strokes regardless of the
absolute background level.
The callers each keep their own ``bh < 16 or bw < 16`` guard: they return three
different sentinels for a degenerate ROI, so the check cannot move in here.
"""
x, y, bw, bh = loc.bbox
roi = image_io.to_bgr(image[y : y + bh, x : x + bw]).astype(np.float32)
luma = roi.mean(axis=2)
sat = roi.max(axis=2) - roi.min(axis=2)
sigma = max(4.0, bh * 0.4) # 0.4 factor and 4.0 floor are calibrated; do not retune
return luma, sat, cv2.GaussianBlur(luma, (0, 0), sigmaX=sigma, sigmaY=sigma)
def tophat_response(self, image: NDArray[Any], loc: TextMarkLocation) -> NDArray[Any] | None:
"""The CONTINUOUS white top-hat in the located box -- the glyph signal, unbinarized.
@@ -328,26 +377,48 @@ class TextMarkEngine:
Kept per-mark (``detect_frontend``) rather than switched globally, because a
front-end change must be measured per mark before it ships.
"""
return self._residual_response(image, loc, absolute=False)
def _residual_response(self, image: NDArray[Any], loc: TextMarkLocation, *, absolute: bool) -> NDArray[Any] | None:
"""Max-normalized uint8 local-luma residual in the located box, saturation-weighted.
``absolute=False`` keeps only the POSITIVE side -- the white top-hat, for a mark
always rendered brighter than its background. ``absolute=True`` takes the
magnitude, for a renderer that switches between light-on-dark and dark-on-light
while preserving one silhouette; a one-polarity top-hat misses the latter.
"""
c = self.config
x, y, bw, bh = loc.bbox
_x, _y, bw, bh = loc.bbox
if bh < 16 or bw < 16:
return None
roi = image_io.to_bgr(image[y : y + bh, x : x + bw]).astype(np.float32)
luma = roi.mean(axis=2)
sat = roi.max(axis=2) - roi.min(axis=2)
sigma = max(4.0, bh * 0.4)
tophat = luma - cv2.GaussianBlur(luma, (0, 0), sigmaX=sigma, sigmaY=sigma)
resp = np.clip(tophat, 0, None) * (sat < c.max_saturation)
luma, sat, local_bg = self._roi_fields(image, loc)
residual = luma - local_bg
resp = (np.abs(residual) if absolute else np.clip(residual, 0, None)) * (sat < c.max_saturation)
peak = float(resp.max())
if peak <= 1e-6:
return None
return (resp / peak * 255).astype(np.uint8)
def _tophat_best(
def _detect_response(self, image: NDArray[Any], loc: TextMarkLocation) -> NDArray[Any] | None:
"""The uint8 image the ladder sweep correlates the silhouette against, chosen by
``TextMarkConfig.detect_frontend``. ``binary`` runs no sweep and never reaches here."""
frontend = self.config.detect_frontend
if frontend == "gray":
x, y, bw, bh = loc.bbox
if bh < 16 or bw < 16:
return None
return cv2.cvtColor(image_io.to_bgr(image[y : y + bh, x : x + bw]), cv2.COLOR_BGR2GRAY)
if frontend == "contrast":
return self._residual_response(image, loc, absolute=True)
if frontend == "tophat":
return self._residual_response(image, loc, absolute=False)
raise ValueError(f"{frontend!r} has no ladder response (binary runs no sweep)")
def _ladder_best(
self, image: NDArray[Any], loc: TextMarkLocation
) -> tuple[float, tuple[int, int, int, int] | None]:
"""Best TM_CCOEFF_NORMED of a soft template against the continuous response, and
the ROI-local box (x0, y0, x1, y1) where that best match sits.
"""Best TM_CCOEFF_NORMED of the mark's silhouette against its front-end response,
and the ROI-local box (x0, y0, x1, y1) where that best match sits.
Sweeps the mark's scale ladder: the nominal glyph size is derived from the mark's
geometry, but a vendor re-rasterization shifts it by a few percent and the
@@ -358,10 +429,15 @@ class TextMarkEngine:
detection, the box bounds the fill. Sharing it is deliberate: the standing rule is
that detection and the mask use the same front-end, and the way that rule was last
broken was a drift between two separate implementations. One method makes the drift
impossible instead of merely discouraged.
impossible instead of merely discouraged -- which is why the three continuous
front-ends (tophat / contrast / gray) sweep here rather than in a copy each.
Tie-breaking is load-bearing: the ``0.0`` seed plus the STRICT ``>`` means the
EARLIEST ladder rung wins a tie, and a sweep whose maximum is not above 0.0
returns no box at all.
"""
c = self.config
resp = self.tophat_response(image, loc)
resp = self._detect_response(image, loc)
sil = self._glyph_silhouette()
if resp is None or sil is None:
return (0.0, None)
@@ -373,90 +449,12 @@ class TextMarkEngine:
gh = max(4, int(c.alpha_height_frac * base * scale))
if gw >= resp.shape[1] or gh >= resp.shape[0]:
continue
tmpl = cv2.resize(sil, (gw, gh), interpolation=cv2.INTER_AREA).astype(np.float32)
if c.template_blur > 0:
tmpl = cv2.GaussianBlur(tmpl, (0, 0), sigmaX=c.template_blur, sigmaY=c.template_blur)
result = cv2.matchTemplate(resp, tmpl.astype(np.uint8), cv2.TM_CCOEFF_NORMED)
_, score, _, top_left = cv2.minMaxLoc(result)
if score > best_score:
tx, ty = int(top_left[0]), int(top_left[1])
best_score, best_box = float(score), (tx, ty, tx + gw - 1, ty + gh - 1)
return (best_score, best_box)
def _tophat_score(self, image: NDArray[Any], loc: TextMarkLocation) -> float:
"""The detection score alone -- the box the removal mask needs is discarded here."""
return self._tophat_best(image, loc)[0]
def _contrast_best(
self, image: NDArray[Any], loc: TextMarkLocation
) -> tuple[float, tuple[int, int, int, int] | None]:
"""Best silhouette match against the absolute local-luma residual.
Unlike the white top-hat, this response is polarity-independent: the same
watermark can be lighter or darker than its local background. Detection and
removal share the returned box, preserving the front-end parity contract.
"""
c = self.config
x, y, bw, bh = loc.bbox
if bh < 16 or bw < 16:
return (0.0, None)
roi = image_io.to_bgr(image[y : y + bh, x : x + bw]).astype(np.float32)
luma = roi.mean(axis=2)
sat = roi.max(axis=2) - roi.min(axis=2)
sigma = max(4.0, bh * 0.4)
response = np.abs(luma - cv2.GaussianBlur(luma, (0, 0), sigmaX=sigma, sigmaY=sigma))
response *= sat < c.max_saturation
peak = float(response.max())
sil = self._glyph_silhouette()
if peak <= 1e-6 or sil is None:
return (0.0, None)
response = (response / peak * 255).astype(np.uint8)
base = self.scale_base(image)
best_score = 0.0
best_box: tuple[int, int, int, int] | None = None
for scale in c.ladder:
gw = max(c.min_gw, int(c.alpha_width_frac * base * scale))
gh = max(4, int(c.alpha_height_frac * base * scale))
if gw >= response.shape[1] or gh >= response.shape[0]:
continue
template = cv2.resize(sil, (gw, gh), interpolation=cv2.INTER_AREA)
result = cv2.matchTemplate(response, template, cv2.TM_CCOEFF_NORMED)
_, score, _, top_left = cv2.minMaxLoc(result)
if score > best_score:
tx, ty = int(top_left[0]), int(top_left[1])
best_score, best_box = float(score), (tx, ty, tx + gw - 1, ty + gh - 1)
return (best_score, best_box)
def _gray_best(self, image: NDArray[Any], loc: TextMarkLocation) -> tuple[float, tuple[int, int, int, int] | None]:
"""Best TM_CCOEFF_NORMED of the silhouette against the raw GRAYSCALE ROI, and
the ROI-local box (x0, y0, x1, y1) of that best match.
Mirrors :meth:`_tophat_best` (same ladder sweep, same one-method contract so
detection and the removal mask can never drift), but skips the top-hat
entirely: the RunningHub mark is a faint mid-gray text the top-hat's
background subtraction suppresses to clean-arm levels, while raw gray NCC
separates (see ``TextMarkConfig.detect_frontend``). Contrast-DEPENDENT by
construction, so the gate must be picked against the clean arm, which is
what ``scripts/vendor_mark_calibrate.py`` does.
"""
c = self.config
x, y, bw, bh = loc.bbox
if bh < 16 or bw < 16:
return (0.0, None)
roi = cv2.cvtColor(image_io.to_bgr(image[y : y + bh, x : x + bw]), cv2.COLOR_BGR2GRAY)
sil = self._glyph_silhouette()
if sil is None:
return (0.0, None)
base = self.scale_base(image)
best_score = 0.0
best_box: tuple[int, int, int, int] | None = None
for scale in c.ladder:
gw = max(c.min_gw, int(c.alpha_width_frac * base * scale))
gh = max(4, int(c.alpha_height_frac * base * scale))
if gw >= roi.shape[1] or gh >= roi.shape[0]:
continue
tmpl = cv2.resize(sil, (gw, gh), interpolation=cv2.INTER_AREA)
result = cv2.matchTemplate(roi, tmpl, cv2.TM_CCOEFF_NORMED)
if c.detect_frontend == "tophat" and c.template_blur > 0:
tmpl = cv2.GaussianBlur(
tmpl.astype(np.float32), (0, 0), sigmaX=c.template_blur, sigmaY=c.template_blur
).astype(np.uint8)
result = cv2.matchTemplate(resp, tmpl, cv2.TM_CCOEFF_NORMED)
_, score, _, top_left = cv2.minMaxLoc(result)
if score > best_score:
tx, ty = int(top_left[0]), int(top_left[1])
@@ -570,10 +568,33 @@ class TextMarkEngine:
the mark's own ``provenance_ncc_factor`` to recover a faint or slightly
re-rendered mark (per-mark, not shared -- see _DEFAULT_PROVENANCE_NCC_FACTOR).
"""
scan = self._scan(image)
return self._verdict(scan, provenance=provenance)
def detect_both(self, image: NDArray[Any] | None) -> tuple[TextMarkDetection, TextMarkDetection]:
"""``(strict, relaxed)`` from ONE scan of the image.
``provenance`` scales the acceptance THRESHOLD and nothing else -- the locate
box, the glyph mask, the coverage and the front-end ladder score are computed
identically at either trust level. Two ``detect`` calls therefore ran the same
expensive sweep twice to reach two verdicts, which is what the arbiter's
perception pass did for every mark on every image.
Returns two DISTINCT objects: subclasses demote a verdict by mutating it.
"""
scan = self._scan(image)
return self._verdict(scan, provenance=False), self._verdict(scan, provenance=True)
def _scan(self, image: NDArray[Any] | None) -> TextMarkScan:
"""Everything in detection that does not depend on the trust level.
Per-CALL only, never memoized on ``self``: ``remove_auto_marks`` re-invokes each
engine on a progressively cleaned frame inside one process, so a cached scan
would answer for the wrong pixels.
"""
c = self.config
det = TextMarkDetection()
if image is None or image.size == 0:
return det
return TextMarkScan(None, None, 0)
# Guard against the small-image NCC-noise false positive (see
# _MIN_DETECT_SHORT_SIDE): an icon/thumbnail is too small to carry a real
# text label, and the degraded few-pixel template spuriously correlates.
@@ -584,57 +605,57 @@ class TextMarkEngine:
min(image.shape[:2]),
_MIN_DETECT_SHORT_SIDE,
)
return det
return TextMarkScan(None, None, 0)
loc = self.locate(image)
box = self.extract_mask(image, loc) # box-sized mask (== old full-frame cropped to bbox)
_x, _y, bw, bh = loc.bbox
coverage = float((box > 0).sum()) / float(max(1, bw * bh))
det.region = loc.bbox
det.coverage = coverage
if c.detect_frontend == "tophat":
# The continuous front-end does not depend on the binarized blob, so the
# coverage gate (a blob-area heuristic) does not apply to it.
score = self._tophat_score(image, loc)
threshold = c.detect_ncc_threshold * (c.provenance_ncc_factor if provenance else 1.0)
det.confidence = score
det.detected = score >= threshold and self._rival_margin_ok(score, box, self.scale_base(image))
logger.debug("%s detect (tophat): ncc=%.2f thr=%.2f detected=%s", c.name, score, threshold, det.detected)
base = self.scale_base(image)
match_box: tuple[int, int, int, int] | None = None
if c.detect_frontend == "binary":
# The coverage gate is a blob-AREA heuristic, so it applies only to the
# front-end that binarizes; the continuous ones never build a blob. Below
# the gate the detection stays at confidence 0.0 and the rival margin is
# never consulted, so the score stays None here.
score = self._template_match_score(box, base) if coverage >= c.detect_min_coverage else None
else:
score, match_box = self._ladder_best(image, loc)
return TextMarkScan(loc, box, base, frame=image.shape[:2], coverage=coverage, score=score, match_box=match_box)
def _verdict(self, scan: TextMarkScan, *, provenance: bool) -> TextMarkDetection:
"""Apply the trust-level-dependent tail to a scan, as a fresh result object."""
c = self.config
det = TextMarkDetection(provenance=provenance)
if scan.loc is None or scan.box is None:
return det
if c.detect_frontend == "gray":
# Same no-coverage-gate reasoning as tophat: the gray front-end never
# binarizes, so a blob-area heuristic does not apply to it either.
score = self._gray_best(image, loc)[0]
threshold = c.detect_ncc_threshold * (c.provenance_ncc_factor if provenance else 1.0)
det.confidence = score
det.detected = score >= threshold and self._rival_margin_ok(score, box, self.scale_base(image))
logger.debug("%s detect (gray): ncc=%.2f thr=%.2f detected=%s", c.name, score, threshold, det.detected)
det.region = scan.loc.bbox
det.coverage = scan.coverage
det.match_box = scan.match_box
if scan.score is None: # binary front-end below the coverage gate
return det
if c.detect_frontend == "contrast":
score = self._contrast_best(image, loc)[0]
threshold = c.detect_ncc_threshold * (c.provenance_ncc_factor if provenance else 1.0)
det.confidence = score
det.detected = score >= threshold and self._rival_margin_ok(score, box, self.scale_base(image))
logger.debug(
"%s detect (contrast): ncc=%.2f thr=%.2f detected=%s",
c.name,
score,
threshold,
det.detected,
)
return det
if coverage >= c.detect_min_coverage:
score = self._template_match_score(box, self.scale_base(image))
threshold = c.detect_ncc_threshold * (c.provenance_ncc_factor if provenance else 1.0)
det.confidence = score
det.detected = score >= threshold and self._rival_margin_ok(score, box, self.scale_base(image))
logger.debug(
"%s detect: coverage=%.3f ncc=%.2f thr=%.2f detected=%s",
c.name,
coverage,
score,
threshold,
det.detected,
)
threshold = c.detect_ncc_threshold * (c.provenance_ncc_factor if provenance else 1.0)
det.confidence = scan.score
# Short-circuit is load-bearing: _rival_margin_ok scores every rival template
# and logs its own rejection line, so it must stay unevaluated below threshold.
det.detected = scan.score >= threshold and self._rival_margin_ok(scan.score, scan.box, scan.base)
logger.debug(
"%s detect (%s): coverage=%.3f ncc=%.2f thr=%.2f detected=%s",
c.name,
c.detect_frontend,
scan.coverage,
scan.score,
threshold,
det.detected,
)
return self._post_gate(det, scan)
def _post_gate(self, det: TextMarkDetection, scan: TextMarkScan) -> TextMarkDetection:
"""Per-mark demotion applied after the shared threshold, for both trust levels.
OVERRIDABLE. It lives here rather than in a ``detect`` override so a mark's gate
cannot be silently skipped by the single-pass ``detect_both`` path -- which is
exactly what happened while the anchor demotions were ``detect`` overrides.
"""
return det
# ── Inpaint footprint (for the inpaint-fallback removal path) ────────
@@ -644,8 +665,132 @@ class TextMarkEngine:
# to mask. A real strip covers hundreds of pixels.
_MIN_GLYPH_PIXELS = 20
def _strict_detection(self, image: NDArray[Any], detection: TextMarkDetection | None) -> TextMarkDetection:
"""The STRICT detection the footprint is bounded by.
A threaded detection is reused only when it was taken at the same strict level
this method would have used itself. A provenance-RELAXED detection is NOT
reused: a strict re-detect can demote a mark the relaxed gate accepted, which
for a continuous front-end means no mask at all. That is a MEASURED difference,
not a refactor, so the strict semantics stay.
Reuse is safe against ``remove_auto_marks`` chaining marks on a progressively
cleaned frame: the registry re-detects on that same cleaned array before
threading (``KnownMark.localize``), so a threaded detection is never stale.
"""
if detection is not None and not detection.provenance:
return detection
return self.detect(image) # polymorphic: a subclass gate must still apply
def _geometry_rect(self, loc: TextMarkLocation, frame: tuple[int, int]) -> tuple[int, int, int, int]:
"""The whole locate box, clamped to the frame -- the ``force`` footprint."""
bx, by, bw, bh = loc.bbox
h, w = frame
return (bx, by, min(w, bx + bw), min(h, by + bh))
def _extend_match_box(
self, box: tuple[int, int, int, int], loc: TextMarkLocation, frame: tuple[int, int]
) -> tuple[int, int, int, int]:
"""Grow an ROI-local box into the absolute fill rectangle by a symmetric pad.
OVERRIDABLE, and the override contract is specifically the DETECTOR'S MATCH BOX:
a mark whose removable footprint reaches beyond what the NCC localizes -- Baidu's
flat white tag right of the text run, LibLibAI's triangle logo left of the
wordmark -- supplies its own extension here and inherits the rest of the
footprint path. The blob-bbox branch never routes through an override.
"""
gx0, gy0, gx1, gy1 = box
bx, by, _bw, bh = loc.bbox
h, w = frame
pad = max(4, int(0.10 * bh))
return (
max(0, bx + gx0 - pad),
max(0, by + gy0 - pad),
min(w, bx + gx1 + 1 + pad),
min(h, by + gy1 + 1 + pad),
)
def _footprint_rect(
self,
image: NDArray[Any],
loc: TextMarkLocation,
*,
force: bool,
detection: TextMarkDetection | None,
) -> tuple[int, int, int, int] | None:
"""Default footprint policy: the binary glyph blob's bbox, else the detector's
own match box for the front-ends that under-segment, else the geometry box
under ``force``.
The gray front-end exists for marks the top-hat under-segments, so the binary
blob is NOT authoritative there: trusting it first bounded the fill by a PARTIAL
blob (the faint head glyphs dropped out) and left the leftmost "Runni" of
"RunningHub AI生成" unremoved (2026-07-22).
A dark-on-light Yuanbao mark has no WHITE top-hat blob at all, so the contrast
front-end is bounded by the polarity-independent detector's match box too.
A tophat mark found only by the CONTINUOUS front-end has no binary glyph blob to
bound, so the mask came back empty and removal was a silent no-op while
``identify`` still reported the mark. Use the DETECTOR'S OWN best-match box: the
correlation already located the mark at a position and scale, and thresholding
the response was a strictly worse proxy for that. An earlier fix thresholded the
max-normalized uint8 response at 0.5 -- which selects every non-zero pixel, not
"half the peak" as its comment claimed -- and filled ~120% of the corner box on
textured frames (measured: whole corner vs 58.7% for the match box, both
detector-clean). Gated on an actual detection: on a clean corner the box would
be spurious.
"""
ys, xs = np.where(self.extract_mask(image, loc) > 0)
blob = (
(int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max())) if xs.size >= self._MIN_GLYPH_PIXELS else None
)
frontend = self.config.detect_frontend
if frontend in ("gray", "contrast"):
det = self._strict_detection(image, detection)
box = det.match_box if det.detected else blob
elif blob is not None:
box = blob
elif frontend == "tophat":
det = self._strict_detection(image, detection)
box = det.match_box if det.detected else None
else:
box = None
if box is not None:
return self._extend_match_box(box, loc, image.shape[:2])
return self._geometry_rect(loc, image.shape[:2]) if force else None
def _match_box_rect(
self,
image: NDArray[Any],
loc: TextMarkLocation,
*,
force: bool,
detection: TextMarkDetection | None,
) -> tuple[int, int, int, int] | None:
"""Footprint policy for a mark whose fill must be bounded by the DETECTOR's match
box and never by the binary glyph blob.
Baidu's white tag has a flat interior a top-hat cannot answer, and LibLibAI's
blob bleeds up into background structure; in both cases the blob bbox is
measurably wrong and the NCC match box is right. ``force`` takes priority here,
unlike the default policy: a ``--no-detect`` caller named the mark, so the whole
geometry box is the honest footprint.
"""
if force:
return self._geometry_rect(loc, image.shape[:2])
det = self._strict_detection(image, detection)
if not det.detected or det.match_box is None:
return None
return self._extend_match_box(det.match_box, loc, image.shape[:2])
def footprint_mask(
self, image: NDArray[Any], *, force: bool = False, dilate: int | None = None
self,
image: NDArray[Any] | None,
*,
force: bool = False,
dilate: int | None = None,
detection: TextMarkDetection | None = None,
) -> NDArray[Any] | None:
"""Full-frame uint8 mask (255 = mark) of the mark footprint, for the shared
fill removal path (cv2 / MI-GAN / LaMa), or None if no glyph is found.
@@ -662,6 +807,10 @@ class TextMarkEngine:
With ``force`` and no glyph found, falls back to the whole geometry box (the
``--no-detect`` path). The caller gates on detection.
``detection`` is the caller's already-computed detection, threaded in so the
footprint does not re-run a sweep the detector already ran. See
:meth:`_strict_detection` for when it is reused.
"""
if image is None or image.size == 0:
return None # guard before to_bgr (cvtColor raises on an empty Mat); mirror detect()
@@ -670,46 +819,10 @@ class TextMarkEngine:
if h < 32 or w < 64:
return None
loc = self.locate(image)
bx, by, bw, bh = loc.bbox
glyph = self.extract_mask(image, loc) # box-sized, 255 = glyph
ys, xs = np.where(glyph > 0)
box: tuple[int, int, int, int] | None = None
if self.config.detect_frontend == "gray" and self.detect(image).detected:
# The gray front-end exists for marks the top-hat under-segments, so the
# binary blob is NOT authoritative here: trusting it first bounded the
# fill by a PARTIAL blob (the faint head glyphs dropped out) and left the
# leftmost "Runni" of "RunningHub AI生成" unremoved (2026-07-22). Use the
# detector's own best-match box, same as the tophat faint path below.
_, box = self._gray_best(image, loc)
elif self.config.detect_frontend == "contrast" and self.detect(image).detected:
# A dark-on-light Yuanbao mark has no WHITE top-hat blob at all. Bound
# the fill by the polarity-independent detector's own match box.
_, box = self._contrast_best(image, loc)
elif xs.size >= self._MIN_GLYPH_PIXELS:
box = (int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max()))
elif self.config.detect_frontend == "tophat" and self.detect(image).detected:
# A mark found only by the CONTINUOUS front-end has no binary glyph blob to
# bound, so the mask came back empty and removal was a silent no-op while
# `identify` still reported the mark while removal left it untouched.
# Use the DETECTOR'S OWN best-match box: the correlation already located the
# mark at a position and scale, and thresholding the response was a strictly
# worse proxy for that. An earlier fix thresholded the max-normalized uint8
# response at 0.5 -- which selects every non-zero pixel, not "half the peak" as
# its comment claimed -- and filled ~120% of the corner box on textured frames
# (measured: whole corner vs 58.7% for the match box, both detector-clean).
# Gated on an actual detection: on a clean corner the box would be spurious.
_, box = self._tophat_best(image, loc)
if box is not None:
gx0, gy0, gx1, gy1 = box
pad = max(4, int(0.10 * bh))
rx1 = max(0, bx + gx0 - pad)
rx2 = min(w, bx + gx1 + 1 + pad)
ry1 = max(0, by + gy0 - pad)
ry2 = min(h, by + gy1 + 1 + pad)
elif force:
rx1, ry1, rx2, ry2 = bx, by, min(w, bx + bw), min(h, by + bh)
else:
rect = self._footprint_rect(image, loc, force=force, detection=detection)
if rect is None:
return None
rx1, ry1, rx2, ry2 = rect
if rx1 >= rx2 or ry1 >= ry2:
return None
# Rectangular footprint + dilation is exactly region_eraser.boxes_to_mask (the
@@ -717,5 +830,5 @@ class TextMarkEngine:
# zeros/fill/MORPH_ELLIPSE-dilate here.
from remove_ai_watermarks import region_eraser
d = dilate if dilate is not None else max(3, int(0.02 * bw))
d = dilate if dilate is not None else max(3, int(0.02 * loc.w))
return region_eraser.boxes_to_mask((h, w), [(rx1, ry1, rx2 - rx1, ry2 - ry1)], dilate=d)
+513 -13
View File
@@ -16,11 +16,14 @@ Imports stay lazy (inside the functions), so ``import remove_ai_watermarks`` is
from __future__ import annotations
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Literal
if TYPE_CHECKING:
from collections.abc import Callable
from numpy.typing import NDArray
from remove_ai_watermarks.watermark_registry import Backend, Sensitivity
@@ -40,8 +43,17 @@ def visible_provenance(source: str | Path) -> frozenset[str]:
"""Vendor keys that the file's local metadata confirms, the evidence that drives
the ``auto`` sensitivity (relaxing a corroborated mark's detection trust gate).
Mapping: a Google/Gemini C2PA issuer -> ``"gemini"``; a China-AIGC (TC260) label
-> ``"doubao"``/``"jimeng"``; a ``samsung_genai`` marker -> ``"samsung"``.
Mapping: a Google/Gemini C2PA issuer -> ``"gemini"``; a ``samsung_genai`` marker ->
``"samsung"``; a China-AIGC (TC260) label -> the vendor its ``ContentProducer``
names (``KnownMark.tc260_producer_codes``), falling back to ByteDance's two products
when the producer is absent or unmapped.
The TC260 label itself says only "this is AI", so it used to relax Doubao and
Jimeng on EVERY China-AIGC image -- including one carrying a Qwen or Kling mark,
where relaxing the wrong pair is pure false-positive risk and the mark actually
present never reached the relaxed gate its own ``provenance_ncc_factor`` was
calibrated for. The producer code identifies the signing entity, so it can.
Best-effort: any read error yields an empty set (no relaxation). Metadata-only, so
it never loads cv2/torch.
"""
@@ -52,19 +64,33 @@ def visible_provenance(source: str | Path) -> frozenset[str]:
from remove_ai_watermarks import identify
rep = identify.identify(path, check_visible=False, check_invisible=False)
signal_names = {signal.name for signal in rep.signals}
keys: set[str] = set()
platform = (rep.platform or "").lower()
if "google" in platform or "gemini" in platform:
keys.add("gemini")
if "aigc" in signal_names:
keys |= {"doubao", "jimeng"}
if "samsung_genai" in signal_names:
keys.add("samsung")
return frozenset(keys)
return _provenance_from_report(rep, path)
return frozenset()
def _tc260_vendors(path: Path) -> frozenset[str]:
"""Vendor keys a TC260 label confirms, from its ``ContentProducer`` identity.
An absent, unreadable or unmapped producer falls back to the historical pair rather
than to nothing: the caller has already established that the AIGC signal fired, so
the image IS China-AIGC labelled, and dropping to no relaxation would lose the
detections the fallback recovers today. The re-read is deliberately isolated -- a
failure here must narrow the answer, never discard the rest of the provenance.
"""
import contextlib
from remove_ai_watermarks._internal.constants import TC260_FALLBACK_VENDORS
with contextlib.suppress(Exception):
from remove_ai_watermarks.metadata import aigc_label, uscc_of
from remove_ai_watermarks.watermark_registry import tc260_producer_vendors
producer = (aigc_label(path) or {}).get("ContentProducer", "")
if producer and (vendor := tc260_producer_vendors().get(uscc_of(producer))):
return frozenset({vendor})
return TC260_FALLBACK_VENDORS
def _load_visible_input(source: str | Path | NDArray[Any]) -> _VisibleInput:
"""Normalize a path/array source without making the public operation stateful."""
if not isinstance(source, (str, Path)):
@@ -177,3 +203,477 @@ def remove_visible(
write_noop=write_noop,
)
return result, removed
# ── The three-stage image pipeline (visible -> invisible -> metadata) ──
# This is the `all` / `batch` pipeline. It lived only in cli.py, written twice with
# divergences, so a library caller could not run the flagship path at all. The CLI is
# now a thin wrapper: it builds the options, prints the stage lines through
# `progress`, and turns the outcome into console text and an exit code.
@dataclass(frozen=True)
class InvisibleOptions:
"""The invisible stage's knobs, as one value instead of a dozen parameters.
ENGINE KNOBS ONLY, under the engine's own names and defaults, so a bare
``InvisibleOptions()`` behaves exactly like calling the engine with no arguments.
The engine takes them across TWO callables -- ``__init__`` for the ones that shape
the loaded stack, ``remove_watermark`` for the per-image ones -- so this is not a
splat-through bag; ``_run_invisible`` forwards each field to the right one.
``TestInvisibleOptionsMirrorTheEngine`` compares the signatures field by field with
no exception table to maintain: a decision made before the engine runs, like
``force``, is a parameter of ``remove_all`` next to ``backend`` and ``sensitivity``,
not a knob smuggled in here.
Immutable so a batch can build it once and reuse it across every image while the
engine itself is cached separately.
"""
strength: float | None = None
pipeline: str = "qwen-zimage"
seed: int | None = None
hf_token: str | None = None
humanize: float = 0.0
unsharp: float = 0.0
adaptive_polish: bool | None = None
max_resolution: int = 0
controlnet_conditioning_scale: float = 1.0
cpu_offload: bool = False
tile: bool = False
tile_size: int = 1024
tile_overlap: int = 128
# What the invisible stage did. "unavailable" is the one outcome the caller must
# surface loudly: the file looks processed but still carries the watermark.
InvisibleOutcome = Literal["removed", "no-signal", "unavailable"]
@dataclass(frozen=True)
class RemoveAllResult:
"""Outcome of :func:`remove_all` -- what each stage actually did."""
output: Path
visible_label: str | None # the removed mark(s), or None when nothing fired
invisible: InvisibleOutcome
class MetadataStripIncomplete(RuntimeError):
"""AI metadata survived the strip, so no output was produced.
Raised BEFORE the final write, deliberately: the contract is that an incomplete
strip leaves nothing on disk rather than an AI-readable file the caller might ship.
"""
def __init__(self, surviving: set[str]) -> None:
self.surviving = surviving
super().__init__(f"AI metadata survived the strip: {', '.join(sorted(surviving))}")
class _SourceEvidence:
"""One metadata extraction per source file, serving every stage of one call.
``remove_all`` asks the same file two provenance questions -- which vendor the
metadata confirms (for the visible pass) and whether an invisible target exists
(for the scrub gate). Both start from the same file-backed extraction, and running
them independently paid for it twice.
Per-CALL only, never module-level: the pipeline REWRITES its input in place on some
paths (``batch`` with the output directory equal to the input), so a holder that
outlived one call would answer from pre-write evidence. The individual metadata
probes are memoized on content, which is what makes that safe -- this holder only
removes the remaining assembly work.
Every accessor fails safe exactly as the function it replaces does: an extraction or
verdict error yields no provenance (no relaxation) and an invisible target of True
(scrub rather than skip).
"""
__slots__ = ("_evidence", "_extracted", "_path")
def __init__(self, path: Path) -> None:
self._path = path
self._extracted = False
self._evidence: Any | None = None
def _extract(self) -> Any | None:
if not self._extracted:
self._extracted = True
with suppress(Exception):
from remove_ai_watermarks.identify import extract_provenance_evidence
self._evidence = extract_provenance_evidence(self._path)
return self._evidence
def visible_provenance(self) -> frozenset[str]:
"""Vendor keys the metadata confirms; empty on any failure (no relaxation)."""
evidence = self._extract()
if evidence is None:
return frozenset()
# The suppress spans the VERDICT and the mapping, not just the extraction --
# `visible_provenance` fails safe as a whole, and a raise here would escape as a
# traceback where the old code returned an empty set.
with suppress(Exception):
from remove_ai_watermarks.identify import identify_from_evidence
report = identify_from_evidence(evidence)
return _provenance_from_report(report, self._path)
return frozenset()
def has_invisible_target(self) -> bool:
"""Whether a diffusion target exists. True on any failure -- see the fail-safe
note on ``identify.has_invisible_target``: leaving a watermark on a paid removal
is worse than over-regenerating a clean image."""
evidence = self._extract()
if evidence is None:
return True
try:
from remove_ai_watermarks.identify import identify_from_evidence
report = identify_from_evidence(evidence, image_path=self._path, check_invisible=True)
except Exception:
return True
return bool(report.ai_from_metadata)
def _provenance_from_report(report: Any, path: Path) -> frozenset[str]:
"""Map an already-built report to the vendor keys it confirms.
Shared by :func:`visible_provenance` and the evidence holder so the two cannot
drift; which evidence confirms which mark is data on the registry row.
"""
from remove_ai_watermarks.watermark_registry import known_marks
signal_names = {signal.name for signal in report.signals}
platform = (report.platform or "").lower()
keys: set[str] = set()
aigc = False
for mark in known_marks():
if any(token in platform for token in mark.provenance_platform_tokens):
keys.add(mark.key)
if "aigc" in mark.provenance_signals and "aigc" in signal_names:
aigc = True
elif any(name in signal_names for name in mark.provenance_signals):
keys.add(mark.key)
if aigc:
# The TC260 label is vendor-agnostic: which of its marks it confirms comes from
# the producer identity, not from the signal firing.
keys |= _tc260_vendors(path)
return frozenset(keys)
def remove_all(
source: str | Path,
output: str | Path,
*,
backend: Backend = "auto",
sensitivity: Sensitivity = "auto",
invisible: InvisibleOptions | None = None,
force: bool = False,
engine: Any | None = None,
progress: Callable[[str, str], None] | None = None,
) -> RemoveAllResult:
"""Remove visible marks, the invisible watermark, and AI metadata, in that order.
Stages are chained through a file in the SYSTEM temp dir, not next to ``output``:
the point of staging is that the user never sees a partial output file during a long
model download, and writing the partial next to the final defeats that.
``force`` scrubs even when no invisible watermark is locally detectable. It sits
here rather than in ``InvisibleOptions`` because it decides WHETHER the engine runs,
which is settled before the engine is built; the options carry only what the engine
itself takes.
``engine`` accepts an already-constructed ``InvisibleEngine`` so a batch can build
the model once; leave it None to construct one per call.
``progress`` receives ``(stage, detail)`` per step -- ``stage`` is one of
``visible`` / ``invisible`` / ``metadata`` and ``detail`` is a stable token, not
prose: the caller owns the wording. The invisible stage reports its
:data:`InvisibleOutcome`, plus a ``strength=<value>`` line before it runs.
Raises :class:`MetadataStripIncomplete` before writing anything when AI metadata
survives, and ``OSError`` when the output cannot be written.
"""
import os
import tempfile
from remove_ai_watermarks import image_io, watermark_registry
from remove_ai_watermarks.metadata import strip_and_verify
def say(stage: str, detail: str) -> None:
if progress is not None:
progress(stage, detail)
opts = invisible if invisible is not None else InvisibleOptions()
src, out = Path(source), Path(output)
watermark_registry.validate_sensitivity(sensitivity)
image, alpha = image_io.read_bgr_and_alpha(src)
if image is None:
raise ValueError(f"Could not read image: {src}")
# One metadata extraction for the whole pipeline: the visible pass asks which
# vendor is confirmed and the scrub gate asks whether a target exists, and both
# start from the same evidence.
evidence = _SourceEvidence(src)
tmp_fd, tmp_name = tempfile.mkstemp(suffix=src.suffix)
os.close(tmp_fd)
staged = Path(tmp_name)
try:
# ── 1. Visible marks ──
result, removed = watermark_registry.remove_auto_marks(
image,
sensitivity=sensitivity,
provenance=evidence.visible_provenance(),
backend=backend,
)
visible_label = ", ".join(removed) if removed else None
say("visible", visible_label or "")
if not image_io.write_bgr_with_alpha(staged, result, alpha):
raise OSError(f"failed to write the staged intermediate: {staged}")
# ── 2. Invisible watermark ──
outcome = _run_invisible(src, staged, staged, opts, engine, say, evidence, force)
# ── 3. AI metadata ──
# Read the pristine ORIGINAL for provenance above and the STAGED file here:
# the visible pass has already dropped this file's C2PA.
_, leftover = strip_and_verify(staged, staged)
if leftover:
# Before the write, on purpose -- see MetadataStripIncomplete.
raise MetadataStripIncomplete(set(leftover))
say("metadata", "stripped")
# The invisible stage (and the cv2.IMREAD_COLOR paths under it) drops alpha, so
# re-attach the ORIGINAL alpha plane unchanged for transparent formats.
final_bgr, _ = image_io.read_bgr_and_alpha(staged)
if final_bgr is None:
raise OSError(f"failed to read back the staged intermediate: {staged}")
out.parent.mkdir(parents=True, exist_ok=True)
if not image_io.write_bgr_with_alpha(out, final_bgr, alpha):
raise OSError(f"failed to write output (is the destination writable?): {out}")
finally:
if staged.exists():
staged.unlink()
return RemoveAllResult(out, visible_label, outcome)
def _run_invisible(
vendor_source: Path,
in_path: Path,
out_path: Path,
opts: InvisibleOptions,
engine: Any | None,
say: Callable[[str, str], None],
evidence: _SourceEvidence,
force: bool,
) -> InvisibleOutcome:
"""Run, or deliberately skip, the diffusion scrub.
``vendor_source`` is the PRISTINE original: the staged/output file has already lost
its C2PA to the visible pass, so gating or resolving the vendor from it would always
read as "no signal" and "unknown vendor". ``in_path``/``out_path`` are what the
engine reads and writes (the same staged file for ``remove_all``, input->output for
an invisible-only batch).
"""
from remove_ai_watermarks.invisible_engine import is_available
if not is_available():
say("invisible", "unavailable")
return "unavailable"
if not (force or evidence.has_invisible_target()):
say("invisible", "no-signal")
return "no-signal"
from remove_ai_watermarks._internal.watermark_profiles import resolve_strength, vendor_for_strength
vendor = vendor_for_strength(vendor_source)
# Report the strength the engine will actually execute, resolved the same way it
# resolves it, so the reported value cannot drift from the executed one.
with suppress(Exception):
from PIL import Image
with Image.open(vendor_source) as probe:
say("invisible", f"strength={resolve_strength(opts.strength, vendor, opts.pipeline, size=probe.size)}")
if engine is None:
from remove_ai_watermarks.invisible_engine import InvisibleEngine
engine = InvisibleEngine(
pipeline=opts.pipeline,
hf_token=opts.hf_token,
progress_callback=lambda message: say("invisible", message),
controlnet_conditioning_scale=opts.controlnet_conditioning_scale,
cpu_offload=opts.cpu_offload,
)
engine.remove_watermark(
image_path=in_path,
output_path=out_path,
strength=opts.strength,
seed=opts.seed,
humanize=opts.humanize,
unsharp=opts.unsharp,
adaptive_polish=opts.adaptive_polish,
max_resolution=opts.max_resolution,
vendor=vendor,
tile=opts.tile,
tile_size=opts.tile_size,
tile_overlap=opts.tile_overlap,
)
say("invisible", "removed")
return "removed"
BatchMode = Literal["all", "visible", "metadata", "invisible"]
@dataclass(frozen=True)
class BatchSummary:
"""Per-directory outcome of :func:`remove_batch`."""
processed: int
failed: int
# Files whose invisible watermark was left in place because the GPU extra is
# missing. Non-empty means the outputs LOOK processed but still carry it.
invisible_unavailable: list[Path]
errors: list[tuple[Path, str]]
def remove_batch(
directory: str | Path,
output_dir: str | Path,
*,
mode: BatchMode = "all",
backend: Backend = "auto",
sensitivity: Sensitivity = "auto",
invisible: InvisibleOptions | None = None,
force: bool = False,
engine: Any | None = None,
progress: Callable[[Path, str, str], None] | None = None,
) -> BatchSummary:
"""Run one removal ``mode`` over every supported image in ``directory``.
Never raises for a single bad image: a per-file failure is counted and recorded in
``BatchSummary.errors`` so one unreadable file cannot abandon the rest of the
directory. ``force`` and ``engine`` are threaded straight through, so a caller that
passes a constructed ``InvisibleEngine`` loads the model once for the whole run.
``progress`` receives ``(path, stage, detail)``. Every image ends with exactly one
terminal stage -- ``done`` or ``failed`` -- whatever the mode does in between, so a
caller driving a progress bar can advance on that alone.
"""
from remove_ai_watermarks._internal.utils import is_supported_format
src_dir, out_dir = Path(directory), Path(output_dir)
out_dir.mkdir(parents=True, exist_ok=True)
def say(path: Path, stage: str, detail: str) -> None:
if progress is not None:
progress(path, stage, detail)
processed = failed = 0
unavailable: list[Path] = []
errors: list[tuple[Path, str]] = []
for img_path in sorted(p for p in src_dir.iterdir() if is_supported_format(p)):
out_path = out_dir / img_path.name
try:
outcome = _run_batch_one(img_path, out_path, mode, backend, sensitivity, invisible, force, engine, say)
except Exception as exc:
failed += 1
errors.append((img_path, str(exc)))
say(img_path, "failed", str(exc))
continue
processed += 1
if outcome == "unavailable":
unavailable.append(img_path)
# Exactly one terminal event per image, in every mode: a caller's progress bar
# advances on this and nothing else. Keying it off a mode-specific stage line
# left `visible` and `metadata` runs sitting at 0% for the whole batch.
say(img_path, "done", outcome or "")
return BatchSummary(processed, failed, unavailable, errors)
def _run_batch_one(
img_path: Path,
out_path: Path,
mode: BatchMode,
backend: Backend,
sensitivity: Sensitivity,
invisible: InvisibleOptions | None,
force: bool,
engine: Any | None,
say: Callable[[Path, str, str], None],
) -> InvisibleOutcome | None:
"""One image, one mode. Returns the invisible outcome when that stage ran."""
from remove_ai_watermarks import image_io
from remove_ai_watermarks.metadata import strip_and_verify
if mode == "all":
result = remove_all(
img_path,
out_path,
backend=backend,
sensitivity=sensitivity,
invisible=invisible,
force=force,
engine=engine,
progress=lambda stage, detail: say(img_path, stage, detail),
)
return result.invisible
# NOTE on holders in batch: `out_path` may EQUAL `img_path` (nothing forbids
# `-o <the input directory>`), and the visible stage rewrites it. So each stage that
# asks the file a provenance question builds its OWN holder, after any write that
# precedes it. One holder spanning the write would answer the invisible gate from
# pre-write evidence and scrub a file that today is correctly skipped.
if mode == "visible":
# Deliberately NOT `remove_visible`: its no-op branch copies the original bytes
# through when nothing was removed, which is right for a single lossless call
# and wrong here. A batch must produce every output through the one writer, so a
# failed write RAISES and the run is counted and exits non-zero -- a read-only
# output directory once produced zero files and still exited 0 (Tier E).
# Always read the ORIGINAL: a stale out_path from a previous run must not be
# re-processed as if it were the input.
from remove_ai_watermarks import watermark_registry
image, alpha = image_io.read_bgr_and_alpha(img_path)
if image is None:
raise ValueError(f"Could not read image: {img_path}")
result, _ = watermark_registry.remove_auto_marks(
image,
sensitivity=sensitivity,
provenance=visible_provenance(img_path),
backend=backend,
)
if not image_io.write_bgr_with_alpha(out_path, result, alpha):
raise OSError(f"failed to write output (is the destination writable?): {out_path}")
return None
if mode == "metadata":
_, leftover = strip_and_verify(img_path, out_path)
if leftover:
raise MetadataStripIncomplete(set(leftover))
return None
# invisible-only: no preceding visible pass, so out_path does not exist yet, and
# the input IS the pristine original for both the gate and the vendor probe.
outcome = _run_invisible(
img_path,
img_path,
out_path,
invisible if invisible is not None else InvisibleOptions(),
engine,
lambda stage, detail: say(img_path, stage, detail),
_SourceEvidence(img_path),
force,
)
if not out_path.exists():
# Keep the output directory COMPLETE even when the pixels are deliberately
# left alone; a hole the caller cannot see is worse than an unchanged copy.
src_bgr, src_alpha = image_io.read_bgr_and_alpha(img_path)
if src_bgr is None or not image_io.write_bgr_with_alpha(out_path, src_bgr, src_alpha):
raise OSError(f"failed to copy input through to output: {out_path}")
return outcome
+33 -58
View File
@@ -30,11 +30,14 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any
from remove_ai_watermarks import _text_mark_engine
from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkDetection, TextMarkEngine
from remove_ai_watermarks._text_mark_engine import (
TextMarkConfig,
TextMarkDetection,
TextMarkEngine,
TextMarkLocation,
)
if TYPE_CHECKING:
from pathlib import Path
from numpy.typing import NDArray
# Locate geometry as a fraction of the image SHORT side (measured basis). The
@@ -100,79 +103,51 @@ _CONFIG = TextMarkConfig(
provenance_ncc_factor=1.0,
)
BaiduDetection = TextMarkDetection
def _alpha_template() -> NDArray[Any] | None:
"""The bundled Baidu alpha template (float [0,1]), or None."""
return _text_mark_engine.load_alpha_template(_CONFIG.asset_name)
def _glyph_silhouette() -> NDArray[Any] | None:
"""Binary "百度" silhouette (255 = glyph) from the alpha map, or None."""
return _text_mark_engine.glyph_silhouette(_CONFIG.asset_name)
def _template_match_score(box_mask: NDArray[Any], scale_base: int) -> float:
"""TM_CCOEFF_NORMED of the Baidu glyph silhouette against ``box_mask``."""
return _text_mark_engine.template_match_score(box_mask, scale_base, _CONFIG)
class BaiduEngine(TextMarkEngine):
"""Detect/localize the visible Baidu "百度 AI生成" mark (bottom-right; localize -> fill)."""
def __init__(self) -> None:
super().__init__(_CONFIG)
def footprint_mask(
self, image: NDArray[Any] | None, *, force: bool = False, dilate: int | None = None
) -> NDArray[Any] | None:
"""Full-frame mask of the WHOLE mark (text run + the pill tag to its right).
def _footprint_rect(
self,
image: NDArray[Any],
loc: TextMarkLocation,
*,
force: bool,
detection: TextMarkDetection | None,
) -> tuple[int, int, int, int] | None:
"""Bound the fill by the detector's match box, never by the binary glyph blob.
The base class's blob-bbox footprint UNDERCOVERS this mark: the white tag's
flat interior gives no top-hat response (a top-hat answers edges, not flats),
so the blob ends at the text run and the fill leaves the tag's right half as
a ghost (measured 2026-07-22 on the 768x1024 cohort frame: blob bbox x
632..746 vs the tag ending ~758). The layout is measured and fixed -- the
text run is at the left of the locate box, the tag runs to the corner -- so
the footprint is the detector's match box extended RIGHT to the corner.
632..746 vs the tag ending ~758).
"""
if image is None or image.size == 0:
return None
return self._match_box_rect(image, loc, force=force, detection=detection)
from remove_ai_watermarks import image_io, region_eraser
def _extend_match_box(
self, box: tuple[int, int, int, int], loc: TextMarkLocation, frame: tuple[int, int]
) -> tuple[int, int, int, int]:
"""Extend the match box RIGHT to the corner end of the locate box.
image = image_io.to_bgr(image)
h, w = image.shape[:2]
if h < 32 or w < 64:
return None
loc = self.locate(image)
The layout is measured and fixed: the text run is at the left of the locate
box and the tag runs to the corner, so the mark's right edge is the box's.
"""
gx0, gy0, _gx1, gy1 = box
bx, by, bw, bh = loc.bbox
if force:
rx1, ry1, rx2, ry2 = bx, by, min(w, bx + bw), min(h, by + bh)
else:
if not self.detect(image).detected:
return None
_, box = self._tophat_best(image, loc)
if box is None:
return None
gx0, gy0, _gx1, gy1 = box
pad = max(4, int(0.15 * bh))
rx1 = max(0, bx + gx0 - pad)
ry1 = max(0, by + gy0 - pad)
rx2 = min(w, bx + bw) # the tag runs to the corner end of the box
ry2 = min(h, by + gy1 + 1 + pad)
if rx1 >= rx2 or ry1 >= ry2:
return None
d = dilate if dilate is not None else max(3, int(0.02 * bw))
return region_eraser.boxes_to_mask((h, w), [(rx1, ry1, rx2 - rx1, ry2 - ry1)], dilate=d)
def load_image_bgr(path: str | Path) -> NDArray[Any]:
"""Read an image as BGR ndarray (helper for scripts/tests)."""
from remove_ai_watermarks import image_io
img = image_io.imread(path)
if img is None:
raise FileNotFoundError(f"Failed to read image: {path}")
return img
h, w = frame
pad = max(4, int(0.15 * bh))
return (
max(0, bx + gx0 - pad),
max(0, by + gy0 - pad),
min(w, bx + bw), # the tag runs to the corner end of the box
min(h, by + gy1 + 1 + pad),
)
+146 -378
View File
@@ -14,7 +14,6 @@ import contextlib
import json
import logging
import time
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, NoReturn
@@ -44,6 +43,8 @@ if TYPE_CHECKING:
from numpy.typing import NDArray
from remove_ai_watermarks.api import InvisibleOptions
# ── plain-text output layer (replaces rich: no colors, no markup, no boxes) ──
@@ -81,7 +82,10 @@ class _Progress:
def __enter__(self) -> _Progress:
return self
def __exit__(self, *exc: object) -> bool:
def __exit__(self, *exc: object) -> Literal[False]:
# Literal[False], not bool: a plain `bool` tells a type checker this context
# manager MAY suppress an exception, which makes every name bound inside a
# `with` block conditionally bound afterwards. It never suppresses.
return False
def add_task(self, *args: Any, **kwargs: Any) -> int:
@@ -167,6 +171,23 @@ def _resolved_strength_for_display(
return resolve_strength(strength, vendor, pipeline, size=image.size)
# -o/--output is the most-repeated option in this module. The image commands and the
# video commands differ only in the default they describe, so there are two decorators
# rather than one -- same reason as every other shared option here: define it once so
# the help text cannot drift between commands.
_output_option = click.option(
"-o", "--output", type=click.Path(path_type=Path), default=None, help="Output path (default: <source>_clean.<ext>)."
)
_video_output_option = click.option(
"-o",
"--output",
type=click.Path(path_type=Path),
default=None,
help="Output path (default: <source>_clean with the same container).",
)
# Shared option decorator for commands that run the invisible-watermark pipeline.
# Both cmd_invisible and cmd_all expose this flag; defining it once avoids
# copy-paste drift.
@@ -327,38 +348,6 @@ def _visible_provenance(path: Path | None) -> frozenset[str]:
return visible_provenance(path)
def _remove_visible_auto(
image: NDArray[Any],
*,
source_path: Path | None = None,
backend: str = "auto",
sensitivity: str = "auto",
) -> tuple[NDArray[Any], str | None]:
"""Remove every auto-detected visible mark via the registry (localize -> fill).
Routes the ``all``/``batch`` visible step through the same registry path the
standalone ``visible`` command uses, so every registered mark is handled rather
than only the Gemini sparkle.
Returns ``(result, label-or-None)``; when no ``in_auto`` mark fires the image is
returned unchanged with ``None``. ``backend`` selects the shared fill; ``sensitivity``
controls how hard a borderline mark is trusted (auto reads metadata provenance)."""
from remove_ai_watermarks import watermark_registry
bk: watermark_registry.Backend = backend # type: ignore[assignment]
sens = _parse_sensitivity(sensitivity)
provenance = _visible_provenance(source_path)
try:
result, removed = watermark_registry.remove_auto_marks(
image, sensitivity=sens, provenance=provenance, backend=bk
)
except RuntimeError as e: # e.g. a selected migan/lama backend whose extra is absent
console.print(f" Error: {e}")
raise SystemExit(1) from e
if not removed:
return image, None
return result, ", ".join(removed)
def _parse_sensitivity(value: str) -> watermark_registry.Sensitivity:
"""Map the CLI ``--sensitivity`` choice to the registry literal.
@@ -607,7 +596,9 @@ def _run_visible_explicit(
t0 = time.monotonic()
try:
with console.status(f"Removing {chosen.label}... ({resolved_backend})"):
result, _ = chosen.remove(image, backend=backend, provenance=relax, force=not detect)
# Reuse the detection printed above instead of re-detecting inside remove():
# nothing has touched `image` since, and the trust level is the same one.
result, _ = chosen.remove(image, backend=backend, provenance=relax, force=not detect, detection=detection)
except RuntimeError as e: # selected migan/lama backend whose extra is absent
console.print(f" Error: {e}")
raise SystemExit(1) from e
@@ -629,9 +620,7 @@ def _run_visible_explicit(
@main.command("visible")
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option(
"-o", "--output", type=click.Path(path_type=Path), default=None, help="Output path (default: <source>_clean.<ext>)."
)
@_output_option
@click.option("--detect/--no-detect", default=True, help="Detect watermark before removal.")
@click.option(
"--mark",
@@ -713,9 +702,7 @@ def _parse_region(spec: str) -> tuple[int, int, int, int]:
@main.command("erase")
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option("--region", "regions", multiple=True, required=True, help="x,y,w,h box to erase (repeatable).")
@click.option(
"-o", "--output", type=click.Path(path_type=Path), default=None, help="Output path (default: <source>_clean.<ext>)."
)
@_output_option
@click.option(
"--backend",
type=click.Choice(["cv2", "migan", "lama"]),
@@ -787,9 +774,7 @@ def cmd_erase(
# ── Invisible watermark removal ──
@main.command("invisible")
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option(
"-o", "--output", type=click.Path(path_type=Path), default=None, help="Output path (default: <source>_clean.<ext>)."
)
@_output_option
@_strength_option
@_pipeline_option
@_seed_option
@@ -1066,13 +1051,7 @@ def cmd_video_identify(source: Path, no_visible: bool, as_json: bool) -> None:
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option("--check", is_flag=True, help="Check for AI metadata (don't modify).")
@click.option("--remove", is_flag=True, help="Remove AI metadata.")
@click.option(
"-o",
"--output",
type=click.Path(path_type=Path),
default=None,
help="Output path (default: <source>_clean with the same container).",
)
@_video_output_option
@click.option("--keep-standard/--remove-all", default=True, help="Keep standard metadata.")
def cmd_video_metadata(
source: Path,
@@ -1110,13 +1089,7 @@ def cmd_video_metadata(
@cmd_video.command("invisible")
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option(
"-o",
"--output",
type=click.Path(path_type=Path),
default=None,
help="Output path (default: <source>_clean with the same container).",
)
@_video_output_option
@_video_invisible_options
def cmd_video_invisible(
source: Path,
@@ -1158,13 +1131,7 @@ def cmd_video_invisible(
@cmd_video.command("visible")
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option(
"-o",
"--output",
type=click.Path(path_type=Path),
default=None,
help="Output path (default: <source>_clean with the same container).",
)
@_video_output_option
@_video_visible_options
@click.option("--strip-metadata/--keep-metadata", default=True, help="Strip AI metadata from the transcoded output.")
def cmd_video_visible(
@@ -1206,13 +1173,7 @@ def cmd_video_visible(
@cmd_video.command("all")
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option(
"-o",
"--output",
type=click.Path(path_type=Path),
default=None,
help="Output path (default: <source>_clean with the same container).",
)
@_video_output_option
@_video_visible_options
@click.option(
"--invisible/--no-invisible",
@@ -1427,9 +1388,7 @@ def cmd_identify(ctx: click.Context, source: Path, no_visible: bool, as_json: bo
# ── Combined "all" mode ──
@main.command("all")
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option(
"-o", "--output", type=click.Path(path_type=Path), default=None, help="Output path (default: <source>_clean.<ext>)."
)
@_output_option
@_visible_backend_option
@_visible_sensitivity_option
@_strength_option
@@ -1483,131 +1442,81 @@ def cmd_all(
t0 = time.monotonic()
# Tracks whether step 2 (invisible / SynthID removal) was skipped because the
# GPU extra is missing. A skipped step 2 still produces an output file (visible
# mark + metadata stripped), so without a loud end-of-run notice + non-zero exit
# the user mistakes it for a clean result and ships an image that still carries
# the invisible watermark (recurring reports: #14, #47).
synthid_skipped = False
from remove_ai_watermarks.api import InvisibleOptions, MetadataStripIncomplete, remove_all
# Use a temp file for intermediate results so the user doesn't see
# a partial output file during long model downloads.
import tempfile
stage_labels = {
"visible": "\n 1) Visible watermark removal",
"invisible": "\n 2) Invisible watermark removal",
"metadata": "\n 3) AI metadata stripping",
}
# The library reports WHAT happened as a (stage, detail) pair of stable tokens; the
# console wording is the CLI's business. These two skips in particular carry guidance
# a library caller does not need but a user very much does.
stage_text = {
("invisible", "no-signal"): (
"Skipped (no invisible AI watermark detected; pixels left intact).\n"
" Not a clean-image guarantee: a pixel SynthID is undetectable once its\n"
" metadata proxy is gone. Re-run with --force to scrub regardless."
),
("invisible", "unavailable"): (
f"Warning: Skipped - GPU dependencies not installed.\n Install them with: pip install {INVISIBLE_EXTRA}"
),
("invisible", "removed"): "Invisible watermark removed",
("metadata", "stripped"): "AI metadata stripped",
}
seen: set[str] = set()
tmp_fd, tmp_path_str = tempfile.mkstemp(suffix=source.suffix)
tmp_path = Path(tmp_path_str)
try:
import os
os.close(tmp_fd)
# ── Step 1: Visible watermark ──
console.print("\n 1) Visible watermark removal")
image, alpha = image_io.read_bgr_and_alpha(source)
if image is None:
console.print(f"Error: Failed to read image: {source}")
raise SystemExit(1)
h, w = image.shape[:2]
console.print(f" Input: {source.name} ({w}x{h})")
with console.status("Removing visible watermark..."):
result, removed_label = _remove_visible_auto(
image, source_path=source, backend=backend, sensitivity=sensitivity
)
if removed_label is not None:
console.print(f" Visible watermark removed ({removed_label})")
else:
console.print(" Skipped (no visible watermark detected)")
# Save to temp file for invisible engine input (preserve alpha if present)
image_io.write_bgr_with_alpha(tmp_path, result, alpha)
# ── Step 2: Invisible watermark ──
console.print("\n 2) Invisible watermark removal")
from remove_ai_watermarks.invisible_engine import is_available as invisible_available
if not invisible_available():
synthid_skipped = True
def progress(stage: str, detail: str) -> None:
if stage in stage_labels and stage not in seen:
seen.add(stage)
console.print(stage_labels[stage])
if (text := stage_text.get((stage, detail))) is not None:
console.print(f" {text}")
elif stage == "visible":
console.print(
" Warning: Skipped - GPU dependencies not installed.\n"
f" Install them with: pip install {INVISIBLE_EXTRA}"
)
elif _should_skip_invisible_scrub(force, source):
# No locally-detectable invisible watermark -> skip the destructive
# regeneration (it would only degrade the image). The visible-removed
# pixels in tmp_path are kept and step 3 still strips metadata, so this
# is a SUCCESS (exit 0), unlike the GPU-missing skip above. Read the
# pristine `source`, not tmp_path whose C2PA the visible pass already
# dropped. Not a clean-image guarantee; --force overrides.
console.print(
" Skipped (no invisible AI watermark detected; pixels left intact).\n"
" Not a clean-image guarantee: a pixel SynthID is undetectable once its\n"
" metadata proxy is gone. Re-run with --force to scrub regardless."
f" Visible watermark removed ({detail})" if detail else " Skipped (no visible watermark detected)"
)
elif detail.startswith("strength="):
console.print(f" Strength: {detail.removeprefix('strength=')}")
else:
from remove_ai_watermarks.invisible_engine import InvisibleEngine
console.print(f" {detail}")
def progress_cb(msg: str) -> None:
console.print(f" {msg}")
inv_engine = InvisibleEngine(
pipeline=pipeline,
hf_token=hf_token,
progress_callback=progress_cb,
controlnet_conditioning_scale=controlnet_scale,
cpu_offload=cpu_offload,
)
# Detect the vendor from the pristine ORIGINAL (`source`); `tmp_path` has
# already lost its C2PA to the visible-removal pass, so reading it would
# always resolve to the unknown-vendor default.
vendor = vendor_for_strength(source)
console.print(f" Strength: {_resolved_strength_for_display(source, strength, vendor, pipeline)}")
inv_engine.remove_watermark(
image_path=tmp_path,
output_path=tmp_path,
try:
outcome = remove_all(
source,
output,
backend=backend, # type: ignore[arg-type]
sensitivity=_parse_sensitivity(sensitivity),
invisible=InvisibleOptions(
strength=strength,
pipeline=pipeline,
seed=seed,
hf_token=hf_token,
humanize=humanize,
unsharp=unsharp,
adaptive_polish=adaptive_polish,
max_resolution=max_resolution,
vendor=vendor,
controlnet_conditioning_scale=controlnet_scale,
cpu_offload=cpu_offload,
tile=tile,
tile_size=tile_size,
tile_overlap=tile_overlap,
)
console.print(" Invisible watermark removed")
# ── Step 3: Metadata ──
console.print("\n 3) AI metadata stripping")
try:
from remove_ai_watermarks.metadata import strip_and_verify
_, leftover = strip_and_verify(tmp_path, tmp_path)
except Exception as e:
console.print(f" Error: metadata strip failed: {e}")
raise SystemExit(1) from e
if leftover:
console.print(f" Error: metadata stripping was incomplete; {', '.join(sorted(leftover))} survived")
raise SystemExit(1)
console.print(" AI metadata stripped")
# ── Write final result ──
# The invisible step (and downstream cv2.IMREAD_COLOR paths) drops alpha,
# so re-attach the original alpha plane unchanged when writing the final
# output for transparent formats.
final_bgr, _ = image_io.read_bgr_and_alpha(tmp_path)
if final_bgr is None:
console.print(f"Error: Failed to read intermediate file: {tmp_path}")
raise SystemExit(1)
_write_output_or_exit(output, final_bgr, alpha)
finally:
# Clean up temp file if it still exists
if tmp_path.exists():
tmp_path.unlink()
),
force=force,
progress=progress,
)
except MetadataStripIncomplete as e:
console.print(f" Error: metadata stripping was incomplete; {', '.join(sorted(e.surviving))} survived")
raise SystemExit(1) from e
except ValueError as e:
console.print(f"Error: {e}")
raise SystemExit(1) from e
except RuntimeError as e: # a selected migan/lama backend whose extra is absent
console.print(f" Error: {e}")
raise SystemExit(1) from e
except OSError as e:
console.print(f" Error: {e}")
raise SystemExit(1) from e
# ── Done ──
elapsed = time.monotonic() - t0
@@ -1618,7 +1527,7 @@ def cmd_all(
# the output looks processed but still carries the SynthID watermark. Make that
# impossible to miss -- a prominent banner plus a non-zero exit so scripts and
# batch callers can detect the incomplete run instead of trusting the file.
if synthid_skipped:
if outcome.invisible == "unavailable":
console.print(
"\n =====================================================================\n"
" WARNING: the invisible (SynthID) watermark was NOT removed.\n"
@@ -1634,172 +1543,26 @@ def cmd_all(
# ── Batch command ──
def _passthrough_copy(img_path: Path, out_path: Path) -> None:
"""Copy the input's pixels through to ``out_path`` unchanged (the invisible-mode skip
paths), so the output dir stays complete without touching the pixels."""
src_bgr, src_alpha = image_io.read_bgr_and_alpha(img_path)
if src_bgr is not None and not image_io.write_bgr_with_alpha(out_path, src_bgr, src_alpha):
# The point of this copy is to keep the output dir COMPLETE. A silently-dropped
# copy defeats that and leaves a hole the caller cannot see (Tier E, 2026-07-20).
raise OSError(f"failed to copy input through to output: {out_path}")
def _batch_engine(mode: str, options: InvisibleOptions) -> object | None:
"""Build the ONE invisible engine this batch reuses, or None.
@dataclass(frozen=True)
class _BatchOptions:
"""Validated processing options shared by every image in one batch.
Click necessarily exposes these as individual command parameters, but the
processing core should receive one coherent value instead of a long positional
call. Keeping the object immutable also makes it safe to reuse while the
batch caches model instances in ``ctx.obj``.
Called once per run, not per image: ``--pipeline`` is a single CLI value, constant
across the batch, so building the model here and threading it down is what keeps the
diffusion stack from reloading for every file. Modes that never scrub get None, so
nothing is loaded at all.
"""
if mode not in ("all", "invisible"):
return None
from remove_ai_watermarks.invisible_engine import InvisibleEngine, is_available
strength: float | None
pipeline: str
seed: int | None
hf_token: str | None
humanize: float
backend: str = "auto"
sensitivity: str = "auto"
unsharp: float = 0.0
max_resolution: int = 0
controlnet_scale: float = 1.0
# None means "the user did not choose"; the library resolves it per profile.
adaptive_polish: bool | None = None
tile: bool = False
tile_size: int = 1024
tile_overlap: int = 128
force: bool = False
cpu_offload: bool = False
def _run_batch_invisible(
ctx: click.Context,
img_path: Path,
out_path: Path,
mode: str,
options: _BatchOptions,
) -> bool:
"""Run or safely skip the invisible pass for one batch image.
Returns ``True`` only when a detectable target could not be processed because
the GPU dependencies are missing. The availability probe is intentionally
evaluated once so branching cannot observe inconsistent optional-dependency
state.
"""
from remove_ai_watermarks.invisible_engine import is_available as invisible_available
skip_no_signal = _should_skip_invisible_scrub(options.force, img_path)
available = invisible_available()
if available and not skip_no_signal:
from remove_ai_watermarks.invisible_engine import InvisibleEngine
# Cache the engine in ctx.obj so the batch builds it once (pipeline is a
# single CLI value, constant across the run).
engines = ctx.obj.setdefault("_inv_engines", {})
if options.pipeline not in engines:
engines[options.pipeline] = InvisibleEngine(
pipeline=options.pipeline,
hf_token=options.hf_token,
controlnet_conditioning_scale=options.controlnet_scale,
cpu_offload=options.cpu_offload,
)
engines[options.pipeline].remove_watermark(
img_path if mode == "invisible" else out_path,
out_path,
strength=options.strength,
seed=options.seed,
humanize=options.humanize,
unsharp=options.unsharp,
adaptive_polish=options.adaptive_polish,
max_resolution=options.max_resolution,
tile=options.tile,
tile_size=options.tile_size,
tile_overlap=options.tile_overlap,
# Detect the vendor from the pristine original (`img_path`), not the
# visible-processed `out_path` whose C2PA is already gone.
vendor=vendor_for_strength(img_path),
)
return False
# Invisible-only mode has no preceding visible pass to create ``out_path``.
# Preserve a complete output directory while deliberately leaving pixels intact.
if mode == "invisible" and not out_path.exists():
_passthrough_copy(img_path, out_path)
return not available and not skip_no_signal
def _process_batch_image(
ctx: click.Context,
img_path: Path,
out_path: Path,
mode: str,
options: _BatchOptions,
) -> bool:
"""Process a single image for batch mode.
Applies the requested watermark removal steps (visible, invisible,
metadata) to *img_path* and writes the result to *out_path*.
Returns True if the invisible (SynthID) scrub was skipped because the GPU deps
are missing while a signal was present -- so the batch caller can warn + exit
non-zero, mirroring the single ``all`` command.
Raises:
ValueError: If the image cannot be opened.
"""
saved_alpha: NDArray[Any] | None = None
synthid_skipped = False
if mode in ("visible", "all"):
# Always read the ORIGINAL source: the visible pass is the first step, so a
# stale out_path from a previous run must not be re-processed as if it were
# the input. (The invisible step below reads out_path for `all` -- that chain
# is within a single run.)
image, alpha = image_io.read_bgr_and_alpha(img_path)
if image is None:
raise ValueError("Failed to read image")
result, _ = _remove_visible_auto(
image,
source_path=img_path,
backend=options.backend,
sensitivity=options.sensitivity,
)
# RAISE, never SystemExit: the batch loop catches per-image exceptions, counts
# them and exits non-zero. Discarding this flag made a read-only output directory
# produce ZERO files and still exit 0 -- silent data loss that also contradicted
# the documented batch contract (Tier E, 2026-07-20).
if not image_io.write_bgr_with_alpha(out_path, result, alpha):
raise OSError(f"failed to write output (is the destination writable?): {out_path}")
saved_alpha = alpha
if mode in ("invisible", "all"):
# Skip the destructive regeneration when no invisible watermark is locally
# detectable (would only degrade a clean image). Read the pristine `img_path`;
# `out_path` may already be the visible-processed result. --force overrides.
synthid_skipped = _run_batch_invisible(ctx, img_path, out_path, mode, options)
if mode in ("metadata", "all"):
from remove_ai_watermarks.metadata import strip_and_verify
# Same verification the single-image command does: the fail-safe copy-through
# would otherwise leave an AI-reading output and still exit 0, contradicting the
# batch contract that a failed image must make the run exit non-zero.
_, leftover = strip_and_verify(img_path if mode == "metadata" else out_path, out_path)
if leftover:
msg = f"AI metadata survived the strip ({', '.join(sorted(leftover))}); file could not be decoded"
raise RuntimeError(msg)
# In "all" mode, the invisible step (color-only OpenCV paths) drops alpha,
# so re-attach the cached alpha when the input had transparency.
if mode == "all" and saved_alpha is not None:
final_bgr, _ = image_io.read_bgr_and_alpha(out_path)
if final_bgr is not None and not image_io.write_bgr_with_alpha(out_path, final_bgr, saved_alpha):
raise OSError(f"failed to re-attach alpha to output: {out_path}")
return synthid_skipped
if not is_available():
return None
return InvisibleEngine(
pipeline=options.pipeline,
hf_token=options.hf_token,
controlnet_conditioning_scale=options.controlnet_conditioning_scale,
cpu_offload=options.cpu_offload,
)
@main.command("batch")
@@ -1867,29 +1630,25 @@ def cmd_batch(
console.print(f" Found {len(images)} images in {directory}")
console.print(f" Output -> {output_dir}")
console.print(f" Mode: {mode}")
options = _BatchOptions(
from remove_ai_watermarks.api import InvisibleOptions
from remove_ai_watermarks.api import remove_batch as api_remove_batch
invisible_options = InvisibleOptions(
strength=strength,
pipeline=pipeline,
seed=seed,
hf_token=hf_token,
humanize=humanize,
backend=backend,
sensitivity=sensitivity,
unsharp=unsharp,
max_resolution=max_resolution,
controlnet_scale=controlnet_scale,
adaptive_polish=adaptive_polish,
max_resolution=max_resolution,
controlnet_conditioning_scale=controlnet_scale,
cpu_offload=cpu_offload,
tile=tile,
tile_size=tile_size,
tile_overlap=tile_overlap,
force=force,
cpu_offload=cpu_offload,
)
processed = 0
errors = 0
synthid_skipped_count = 0
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
@@ -1899,28 +1658,37 @@ def cmd_batch(
console=console,
) as progress:
task = progress.add_task("Processing...", total=len(images))
done: set[str] = set()
for img_path in images:
out_path = output_dir / img_path.name
progress.update(task, description=f"{img_path.name}")
def on_progress(img: Path, stage: str, detail: str) -> None:
# `remove_batch` emits exactly one terminal stage per image in EVERY mode,
# so the bar advances on that and never on a mode-specific line.
progress.update(task, description=img.name)
if stage in ("done", "failed") and img.name not in done:
done.add(img.name)
progress.advance(task)
if ctx.obj.get("verbose"):
console.print(f" {img.name}: {stage}{f' {detail}' if detail else ''}")
try:
if _process_batch_image(
ctx=ctx,
img_path=img_path,
out_path=out_path,
mode=mode,
options=options,
):
synthid_skipped_count += 1
processed += 1
summary = api_remove_batch(
directory,
output_dir,
mode=mode, # type: ignore[arg-type]
backend=backend, # type: ignore[arg-type]
sensitivity=_parse_sensitivity(sensitivity),
invisible=invisible_options,
force=force,
engine=_batch_engine(mode, invisible_options),
progress=on_progress,
)
progress.update(task, completed=len(images))
except Exception as e:
errors += 1
if ctx.obj.get("verbose"):
console.print(f" {img_path.name}: {e}")
processed, errors = summary.processed, summary.failed
synthid_skipped_count = len(summary.invisible_unavailable)
progress.advance(task)
if errors and ctx.obj.get("verbose"):
for failed_path, message in summary.errors:
console.print(f" {failed_path.name}: {message}")
console.print(f"\n {processed} processed" + (f" {errors} errors" if errors else ""))
+1 -16
View File
@@ -23,11 +23,9 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any
from remove_ai_watermarks import _text_mark_engine
from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkDetection, TextMarkEngine
from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkEngine
if TYPE_CHECKING:
from pathlib import Path
from numpy.typing import NDArray
# Locate geometry as a fraction of image WIDTH (the mark scales with width, anchored
@@ -96,9 +94,6 @@ _CONFIG = TextMarkConfig(
min_gw=8,
)
# Doubao-specific aliases for the shared detection result/engine.
DoubaoDetection = TextMarkDetection
def _alpha_template() -> NDArray[Any] | None:
"""The bundled Doubao alpha template (float [0,1]), or None."""
@@ -120,13 +115,3 @@ class DoubaoEngine(TextMarkEngine):
def __init__(self) -> None:
super().__init__(_CONFIG)
def load_image_bgr(path: str | Path) -> NDArray[Any]:
"""Read an image as BGR ndarray (helper for scripts/tests)."""
from remove_ai_watermarks import image_io
img = image_io.imread(path)
if img is None:
raise FileNotFoundError(f"Failed to read image: {path}")
return img
File diff suppressed because it is too large. Load diff
+63 -10
View File
@@ -6,7 +6,7 @@ from __future__ import annotations
import functools
import logging
from dataclasses import dataclass
from dataclasses import dataclass, replace
from enum import Enum
from pathlib import Path
from typing import TYPE_CHECKING, Any
@@ -72,6 +72,26 @@ class _Candidate:
return self.spatial * 0.50 + self.gradient * 0.30 + self.variance * 0.20
@dataclass(frozen=True, slots=True)
class _SparkleScan:
"""The provenance-BLIND half of sparkle detection, reusable across trust levels.
``source`` is the BGR-normalized image the false-positive gate re-reads, ``best``
the winning candidate, and ``base`` a template result carrying everything the scan
already resolved (``size`` and, when a candidate won, ``region`` and the component
scores). ``best is None`` covers both no-candidate cases; ``base`` distinguishes
them, since an empty image never resolves a ``size`` and a candidate-less one does.
Per-CALL only, never cached on the engine: ``remove_auto_marks`` re-invokes each
engine on a progressively cleaned frame within one process, so a memo on ``self``
would hand back a pre-fill scan of a different image.
"""
source: NDArray[Any] | None
best: _Candidate | None
base: DetectionResult
def get_watermark_size(width: int, height: int) -> WatermarkSize:
"""Return the provider's large tier only when both axes exceed 1024."""
return WatermarkSize.LARGE if width > 1024 and height > 1024 else WatermarkSize.SMALL
@@ -200,29 +220,62 @@ class GeminiEngine:
trust_provenance: bool = False,
) -> DetectionResult:
"""Return the strongest sparkle-shaped bottom-right candidate."""
result = DetectionResult()
scan = self._sparkle_scan(image, force_size)
return self._verdict(scan, trust_provenance=trust_provenance)
def detect_watermark_both(
self, image: NDArray[Any], force_size: WatermarkSize | None = None
) -> tuple[DetectionResult, DetectionResult]:
"""``(strict, relaxed)`` from ONE scan of the image.
The scan -- global candidate search, corner promotion and fused scoring -- is
provenance-blind; ``trust_provenance`` only decides whether the false-positive
gate demotes the confidence afterwards. Two calls therefore repeated the whole
sweep to reach two verdicts.
The two results are DISTINCT objects with genuinely different confidences (the
gate rewrites one of them), and callers mutate them.
"""
scan = self._sparkle_scan(image, force_size)
return (
self._verdict(scan, trust_provenance=False),
self._verdict(scan, trust_provenance=True),
)
def _sparkle_scan(self, image: NDArray[Any], force_size: WatermarkSize | None) -> _SparkleScan:
"""Everything in detection that does not depend on the trust level."""
if image is None or image.size == 0:
return result
return _SparkleScan(None, None, DetectionResult())
source = image_io.to_bgr(image)
height, width = source.shape[:2]
result.size = force_size or get_watermark_size(width, height)
size = force_size or get_watermark_size(width, height)
candidates = self._global_candidates(source)
promoted = self._corner_promote(source, candidates[0].spatial if candidates else -1.0)
if promoted is not None:
candidates.append(_Candidate(promoted[0], promoted[1], promoted[2], promoted[3]))
# The no-candidate result is NOT the empty-image one: `size` is already resolved
# here, and it is a public field the caller can force.
base = DetectionResult(size=size)
if not candidates:
return result
return _SparkleScan(None, None, base)
best = max((self._score_candidate(source, candidate) for candidate in candidates), key=lambda item: item.fused)
result.region = (best.x, best.y, best.scale, best.scale)
result.spatial_score = float(best.spatial)
result.gradient_score = float(best.gradient)
result.variance_score = float(best.variance)
base.region = (best.x, best.y, best.scale, best.scale)
base.spatial_score = float(best.spatial)
base.gradient_score = float(best.gradient)
base.variance_score = float(best.variance)
return _SparkleScan(source, best, base)
def _verdict(self, scan: _SparkleScan, *, trust_provenance: bool) -> DetectionResult:
"""Apply the trust-level-dependent tail to a scan, as a fresh result object."""
result = replace(scan.base)
if scan.best is None or scan.source is None:
return result
best = scan.best
confidence = best.fused
if best.spatial >= 0.25 and confidence < self._SPARKLE_FP_CONF and not trust_provenance:
confidence = self._apply_false_positive_gate(source, best, confidence)
confidence = self._apply_false_positive_gate(scan.source, best, confidence)
result.confidence = float(np.clip(confidence, 0.0, 1.0))
result.detected = result.confidence >= 0.35
return result
+363 -66
View File
@@ -22,6 +22,7 @@ from __future__ import annotations
import base64
import itertools
import logging
import struct
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, cast
@@ -30,6 +31,8 @@ from remove_ai_watermarks._internal.c2pa import (
cbor_text_after,
extract_c2pa_info,
soft_binding_vendors_in,
synthid_vendors_in,
synthid_verdict,
)
from remove_ai_watermarks._internal.constants import (
C2PA_AI_TOOLS,
@@ -37,6 +40,7 @@ from remove_ai_watermarks._internal.constants import (
C2PA_IDENTITY_AI_ORGS,
C2PA_ISSUERS,
)
from remove_ai_watermarks._internal.schema import require_schema_version
from remove_ai_watermarks.metadata import (
AI_METADATA_KEYS,
AIGC_MARKERS,
@@ -59,6 +63,7 @@ from remove_ai_watermarks.metadata import (
xai_signature_pair,
)
from remove_ai_watermarks.watermark_registry import GEMINI_SPARKLE_TRUST_CONF
from remove_ai_watermarks.watermark_registry import known_marks as _known_marks
if TYPE_CHECKING:
from pathlib import Path
@@ -69,6 +74,10 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
# Stable JSON contract for callers that pass a verdict between services. Bump this
# only for a breaking shape or semantic change; adding optional fields is compatible.
PROVENANCE_REPORT_SCHEMA_VERSION = 1
# How much of a non-PNG container to binary-scan for the C2PA issuer.
_SCAN_BYTES = 1024 * 1024
@@ -168,7 +177,32 @@ def _external_metadata(value: Any) -> tuple[list[tuple[str, Any]], bytes]:
"""Index nested metadata and recover common encoded binary values in one pass."""
pairs: list[tuple[str, Any]] = []
parts: list[bytes] = []
diagnostic_keys = {"error", "kind"}
diagnostic_keys = {
"artifacts",
"birthtime",
"color",
"content_format",
"dct",
"ela",
"error",
"extension",
"fft",
"file",
"filename",
"full",
"gradient",
"kind",
"mtime",
"name",
"noise",
"path",
"pixel",
"provenance",
"sha256",
"signals",
"size_bytes",
"timing_ms",
}
def visit(item: Any) -> None:
if isinstance(item, dict):
@@ -176,7 +210,6 @@ def _external_metadata(value: Any) -> tuple[list[tuple[str, Any]], bytes]:
for key, nested in mapping.items():
key_text = str(key)
pairs.append((key_text, nested))
parts.append(key_text.encode("utf-8", "replace"))
if key_text.lower() in diagnostic_keys:
continue
if isinstance(nested, str) and (key_text == "base64" or key_text.endswith("_base64")):
@@ -240,17 +273,69 @@ def _external_exif_generator(pairs: list[tuple[str, Any]], scan: bytes) -> str |
return generator_from_metadata(candidates, scan)
def _metadata_source_kind(info: dict[str, Any], scan: bytes) -> str | None:
"""Normalize the source type wherever it is carried: C2PA or IPTC/XMP.
A composite marker contains ``TrainedAlgorithmicMedia`` as a substring, so it
is removed before looking for a standalone full-generation marker. When a file
genuinely carries both kinds, full generation wins.
"""
structured = info.get("ai_source_kind")
without_composites = scan.replace(b"compositeWithTrainedAlgorithmicMedia", b"").replace(b"compositeSynthetic", b"")
generated = structured == "generated" or any(
marker in without_composites for marker in (b"trainedAlgorithmicMedia", b"TrainedAlgorithmicMedia")
)
if generated:
return "generated"
if structured == "enhanced" or any(
marker in scan for marker in (b"compositeWithTrainedAlgorithmicMedia", b"compositeSynthetic")
):
return "enhanced"
return None
def evidence_from_metadata_record(
record: dict[str, Any], *, path: Path, c2pa_manifest_store: str | dict[str, Any] | None = None
) -> ProvenanceEvidence:
"""Normalize an externally collected metadata record into provenance evidence.
The record may contain arbitrary nested dictionaries and lists. Text, bytes,
hexadecimal values prefixed with ``hex:``, and fields named ``base64`` or
ending in ``_base64`` are included in the shared byte scan. No source file is
opened.
Unversioned external records may contain arbitrary nested dictionaries and
lists. Versioned native records accept only the source-derived fields emitted by
``collect_metadata_record``; other native record types and unknown schema
versions are rejected. No source file is opened.
"""
pairs, scan = _external_metadata(record)
from remove_ai_watermarks.metadata_record import METADATA_RECORD_SCHEMA_VERSION, METADATA_RECORD_TYPE
# Records produced by ``collect_metadata_record`` are a versioned transport
# contract. Only their source-derived fields are evidence: the filename,
# container label and schema bookkeeping describe the collector and must never
# become detector input. Shape-detect the pre-versioned form as well so records
# emitted by 0.26 remain safe and readable.
record_type = record.get("record_type")
if record_type not in (None, METADATA_RECORD_TYPE):
raise ValueError(f"Unsupported metadata record type: {record_type!r}")
if record_type == METADATA_RECORD_TYPE:
require_schema_version(
record.get("schema_version"),
contract="provenance metadata",
supported=(METADATA_RECORD_SCHEMA_VERSION,),
)
status = record.get("status")
if status == "error":
raise ValueError("Provenance metadata collection failed")
if status != "complete":
raise ValueError(f"Unsupported provenance metadata collection status: {status!r}")
is_portable_record = record_type == METADATA_RECORD_TYPE or {
"container",
"metadata_base64",
"tail_base64",
}.issubset(record)
evidence_record = (
{key: record[key] for key in ("metadata_base64", "tail_base64", "pil", "exif") if key in record}
if is_portable_record
else record
)
pairs, scan = _external_metadata(evidence_record)
store = c2pa_manifest_store
if store is None:
candidate = record.get("c2pa_store")
@@ -357,13 +442,13 @@ class ProvenanceReport:
is_ai_generated: bool | None # True / False is never asserted; None = unknown
platform: str | None
confidence: str # "high" | "medium" | "none"
# Coarse AI-origin kind from the C2PA digital-source-type, so a caller can
# branch on full generation vs an AI-touched real photo:
# Coarse AI-origin kind from a C2PA or standalone IPTC/XMP digital-source-type,
# so a caller can branch on full generation vs an AI-touched real photo:
# "generated" -- digitalSourceType trainedAlgorithmicMedia (fully AI).
# "enhanced" -- compositeWithTrainedAlgorithmicMedia (real content with an
# AI-composited region; scrub the AI region, keep the photo).
# None -- no C2PA AI source-type (verdict, if AI, came from another
# signal: IPTC, AIGC, local gen params, xAI, ...).
# None -- no AI digital-source-type (verdict, if AI, came from another
# signal: AIGC, local gen params, xAI, ...).
ai_source_kind: str | None = None
# True when the AI verdict rests on a metadata or embedded-invisible signal
# (C2PA AI issuer / SynthID proxy, IPTC, AIGC, local gen params, EXIF/xAI, or
@@ -382,6 +467,42 @@ class ProvenanceReport:
# inconsistent -- a strong tell of spoofed, transplanted, or laundered metadata.
integrity_clashes: list[str] = field(default_factory=list[str])
def to_dict(
self,
*,
schema_version: int = PROVENANCE_REPORT_SCHEMA_VERSION,
) -> dict[str, Any]:
"""Return the versioned, JSON-safe verdict contract.
``path`` is deliberately omitted. It is extraction context, not part of the
verdict, and local filesystem paths should not cross a service boundary.
Request an explicit schema for a long-lived transport consumer.
"""
schema_version = require_schema_version(
schema_version,
contract="provenance report",
supported=(1,),
)
return {
"schema_version": schema_version,
"is_ai_generated": self.is_ai_generated,
"platform": self.platform,
"confidence": self.confidence,
"ai_source_kind": self.ai_source_kind,
"ai_from_metadata": self.ai_from_metadata,
"watermarks": list(self.watermarks),
"signals": [
{
"name": signal.name,
"detail": signal.detail,
"confidence": signal.confidence,
}
for signal in self.signals
],
"caveats": list(self.caveats),
"integrity_clashes": list(self.integrity_clashes),
}
def extract_provenance_evidence(image_path: Path) -> ProvenanceEvidence:
"""Read all file-backed metadata needed by provenance verdict logic once."""
@@ -446,14 +567,88 @@ _DEVICE_C2PA_PLATFORM: tuple[tuple[bytes, str], ...] = (
)
def _device_platform(head: bytes) -> str | None:
"""Map a distinctive C2PA device/camera token in the manifest bytes to a platform."""
for token, platform in _DEVICE_C2PA_PLATFORM:
def _metadata_region(head: bytes) -> bytes:
"""The part of the scan buffer that can hold metadata, with the coded pixels cut out.
The vendor registries are matched as raw substrings, and the shortest tokens are
four and five bytes (``Bria``, ``Adobe``, ``Canva``). Over a megabyte of compressed
pixel data a four-byte sequence appears by chance about once in three thousand
images -- measured: ``Bria`` matched inside the entropy-coded scan of 4 of 14,707
corpus JPEGs, in none of which the manifest names Bria. That is not a cosmetic
mislabel, because the Bria entry carries ``asserts_ai``: a chance match can declare
an image AI-generated.
``c2pa_marker_in`` already refuses a bare ``c2pa`` substring for the same reason.
This is the same defence for the registries: they see the container's metadata and
not its pixels.
JPEG keeps the marker segments before the entropy-coded scan, PNG every chunk but
``IDAT``, and both keep the trailer past the end marker. Anything ``scan_head``
APPENDED past the window is metadata by construction (late chunks, boxes, decoder
text), so it is always kept and never walked -- walking it is what produced 11 MB
records and a phantom AIGC signal in the record collector.
Trimming happens only when the container actually parses: a JPEG whose marker walk
reaches the coded scan, a PNG whose chunk walk reaches ``IDAT``. Anything else --
a malformed container, a synthetic blob, a format with no walker here -- is
returned whole. Cutting a buffer this function did not understand would drop real
evidence to avoid a chance match, which is the wrong way round.
"""
raw, appended = head[:_SCAN_BYTES], head[_SCAN_BYTES:]
if raw[:2] == b"\xff\xd8":
index, size = 2, len(raw)
while index + 1 < size:
if raw[index] != 0xFF:
return head # not a marker boundary: the walk is lost, keep everything
marker = raw[index + 1]
if marker in (0xDA, 0xD9): # SOS / EOI: the coded scan follows
end = raw.rfind(b"\xff\xd9")
return raw[:index] + (raw[end + 2 :] if end >= index else b"") + appended
if 0xD0 <= marker <= 0xD7 or marker == 0x01:
index += 2
continue
if index + 4 > size:
break
length = int.from_bytes(raw[index + 2 : index + 4], "big")
if length < 2 or index + 2 + length > size:
break
index += 2 + length
return head # ran out of buffer before the scan: nothing was skipped anyway
if raw[:8] == b"\x89PNG\r\n\x1a\n":
out = bytearray()
position, size, saw_idat = 8, len(raw), False
while position + 8 <= size:
(length,) = struct.unpack(">I", raw[position : position + 4])
chunk_type = raw[position + 4 : position + 8]
start = position + 8
if chunk_type == b"IDAT":
saw_idat = True
else:
out += chunk_type + raw[start : start + min(length, size - start)]
position = start + length + 4
if chunk_type == b"IEND":
out += raw[position:]
break
return bytes(out) + appended if saw_idat else head
return head
def _first_token_match(head: bytes, table: tuple[tuple[bytes, str], ...]) -> str | None:
"""First platform in ``table`` whose token appears in ``head``, else None.
Table order is priority: the more specific token must be listed first.
"""
for token, platform in table:
if token in head:
return platform
return None
def _device_platform(head: bytes) -> str | None:
"""Map a distinctive C2PA device/camera token in the manifest bytes to a platform."""
return _first_token_match(head, _DEVICE_C2PA_PLATFORM)
# C2PA signers that are an editing app or AI-capable device rather than a
# verified-capture camera. Unlike `_DEVICE_C2PA_PLATFORM`, these do NOT feed the
# camera-vs-AI integrity clash (rule 2 in `_integrity_clashes`): a Galaxy phone
@@ -474,10 +669,7 @@ _SIGNER_C2PA_PLATFORM: tuple[tuple[bytes, str], ...] = (
def _signer_platform(head: bytes) -> str | None:
"""Map a C2PA editing-app / AI-capable-device signer token to a platform."""
for token, platform in _SIGNER_C2PA_PLATFORM:
if token in head:
return platform
return None
return _first_token_match(head, _SIGNER_C2PA_PLATFORM)
def _attribute_platform(issuers: list[str], *, is_ai: bool = True) -> str | None:
@@ -678,16 +870,18 @@ def _visible_sparkle(image_path: Path, *, image: NDArray[Any] | None = None) ->
# Gemini-sparkle phrasing. These are the stripped-metadata visual fallback for
# the China-served ByteDance generators (normally also caught by the TC260 AIGC
# metadata label); the per-engine detection thresholds live in the registry.
_VISIBLE_MARK_PLATFORM = {
"doubao": "ByteDance Doubao (visible 豆包AI生成 mark detected)",
"jimeng": "ByteDance Jimeng / Dreamina (visible 即梦AI mark detected)",
"qwen": "Alibaba Tongyi Qianwen (visible 千问AI生成 mark detected)",
"kling": "Kuaishou Kling (visible 可灵AI 3.0 mark detected)",
"yuanbao": "Tencent Yuanbao (visible 元宝 / AI生成 mark detected)",
"samsung": "Samsung Galaxy AI (visible 'Contenuti generati dall'AI' mark detected)",
"runninghub": "RunningHub (visible RunningHub AI生成 mark detected)",
"baidu": "Baidu (visible 百度 AI生成 mark detected)",
"liblib": "LibLibAI (visible LibLibAI mark detected)",
# Text mark -> the platform sentence this report prints when that mark is the strongest
# evidence, DERIVED from the registry rows so registering a mark is one edit. It was a
# hand-maintained copy, and that class of copy is how LibLibAI ended up registered but
# missing from the pill veto. Insertion order is the registry's, which is what fixes the
# scan order below. The Gemini sparkle and the capture-less pill carry no platform of
# their own (`KnownMark.platform is None`) and are excluded here: the sparkle has its
# own higher-confidence `_visible_sparkle` path.
#
# Safe at module scope: `watermark_registry` is already imported above for
# GEMINI_SPARKLE_TRUST_CONF, and it is deliberately cv2-free at import time.
_VISIBLE_MARK_PLATFORM: dict[str, str] = {
mark.key: mark.platform for mark in _known_marks() if mark.platform is not None
}
@@ -724,15 +918,20 @@ def _visible_text_marks(image_path: Path, *, image: NDArray[Any] | None = None)
return detections
def _invisible_watermark(image_path: Path) -> str | None:
def _invisible_watermark(image_path: Path, decode: _SharedDecode) -> str | None:
"""Open invisible-watermark scheme name (SD/SDXL/FLUX) or None.
Optional: needs the torch-free DWT-DCT decoder (extra ``detect``). Returns
None if it is not installed or no known watermark decodes.
"""
from remove_ai_watermarks.invisible_watermark import detect_invisible_watermark
from remove_ai_watermarks.invisible_watermark import detect_invisible_watermark, is_available
return detect_invisible_watermark(image_path)
if not is_available():
return None
# `decode.get()` re-raises a decode failure exactly as the old unguarded
# `imread` inside the detector did -- `has_invisible_target` needs that to reach
# its fail-safe rather than silently reporting "no signal".
return detect_invisible_watermark(image_path, image=decode.get())
def _trustmark(image_path: Path) -> str | None:
@@ -746,27 +945,79 @@ def _trustmark(image_path: Path) -> str | None:
return detect_trustmark(image_path)
class _SharedDecode:
"""One decode of the source pixels, shared by every detector in a single report.
``identify`` used to decode the file three times. This holder unifies TWO of
them -- the DWT-DCT detector and the visible-mark stage, whose own docstring
already promised a single shared array. TrustMark keeps its own Pillow decode
on purpose and is NOT served from here: cv2 and Pillow disagree on EXIF
orientation and on 16-bit PNG, so feeding it this array would change what it
decodes. An install carrying the optional ``trustmark`` extra therefore still
pays two decodes, not one.
Two accessors, because the two arms need OPPOSITE failure handling:
* :meth:`get_or_none` swallows a decode failure and logs it. That is the visible
arm's historical behavior -- no cv2, no visible marks, verdict unchanged.
* :meth:`get` RE-RAISES it. The invisible arm never caught a decode error, and
``has_invisible_target`` converts that exception into its documented fail-safe
``True``. Swallowing it here would silently skip a diffusion scrub on a file
that used to get one -- leaving a watermark on a paid removal.
Per-CALL only: constructed inside ``_identify_from_evidence`` and discarded with
it, so an in-place rewrite between calls can never be answered from a stale array.
"""
__slots__ = ("_done", "_error", "_image", "_path")
def __init__(self, path: Path) -> None:
self._path = path
self._done = False
self._image: NDArray[Any] | None = None
self._error: Exception | None = None
def _decode(self) -> None:
if self._done:
return
self._done = True
try:
from remove_ai_watermarks.image_io import imread
self._image = imread(self._path)
except Exception as exc: # cv2 missing / unreadable container
self._error = exc
def get(self) -> NDArray[Any] | None:
"""The decoded array; re-raises a decode failure, None only on a clean miss."""
self._decode()
if self._error is not None:
raise self._error
return self._image
def get_or_none(self) -> NDArray[Any] | None:
"""The decoded array, or None when it could not be decoded at all."""
self._decode()
if self._error is not None:
logger.debug("visible-mark decode unavailable: %s", self._error)
return None
return self._image
def _collect_visible_signals(
image_path: Path,
signals: list[Signal],
watermarks: list[str],
platform: str | None,
decode: _SharedDecode,
) -> str | None:
"""Decode once, append every trusted visible-mark signal, and return platform.
"""Append every trusted visible-mark signal and return platform.
Keeping this stage separate from metadata aggregation makes the optional cv2
boundary explicit and guarantees that all visible detectors share one decoded
BGR array. A decode failure preserves the detectors' historical fallback/no-op
behavior.
All visible detectors share the one decoded BGR array held by ``decode`` (which
the invisible detectors have usually already paid for). A decode failure
preserves the detectors' historical fallback/no-op behavior.
"""
image: NDArray[Any] | None = None
try:
from remove_ai_watermarks.image_io import imread
image = imread(image_path)
except Exception as exc: # cv2 missing - detectors fall back / no-op
logger.debug("visible-mark decode unavailable: %s", exc)
return platform
image = decode.get_or_none()
if image is None:
return platform
@@ -800,6 +1051,10 @@ def _identify_from_evidence(
if (check_visible or check_invisible) and image_path is None:
raise ValueError("Pixel-backed checks require image_path")
pixel_path = image_path
# One decode for every pixel detector in this report. Built here, per call, so it
# dies with the report -- an in-place rewrite between two calls cannot be answered
# from a stale array. Lazy inside, so a metadata-only report never decodes at all.
decode = _SharedDecode(pixel_path) if pixel_path is not None else _SharedDecode(evidence.path)
info = evidence.c2pa_info
meta = evidence.ai_metadata
@@ -814,23 +1069,22 @@ def _identify_from_evidence(
# score, the latter can be a by-product of our own SDXL removal pass, so
# neither is a trustworthy "the generator stamped its identity" claim.
ai_vendor_claims: dict[str, str] = {}
camera_label = _device_platform(head)
signer_label = _signer_platform(head)
# The vendor registries match short raw substrings, so they read the container's
# metadata rather than its pixels -- see `_metadata_region`. Every other check
# below keeps the full buffer: their markers are long and distinctive.
region = _metadata_region(head)
camera_label = _device_platform(region)
signer_label = _signer_platform(region)
# ── C2PA Content Credentials ────────────────────────────────────
has_c2pa = bool(info) or c2pa_marker_in(head)
issuers = [info["issuer"]] if info.get("issuer") else _issuers_in(head)
issuers = [info["issuer"]] if info.get("issuer") else _issuers_in(region)
# Full AI generation (trainedAlgorithmicMedia) vs an AI-enhanced real photo
# (compositeWithTrainedAlgorithmicMedia). The structured kind is parsed once in
# _internal.c2pa._populate_registry_fields (covers PNG + any container the c2pa-python
# reader handles); fall back to a raw head scan for the non-PNG raw-blob path
# where extract_c2pa_info returns {}. Full generation wins when both appear.
c2pa_source_kind = info.get("ai_source_kind")
if c2pa_source_kind is None:
if b"trainedAlgorithmicMedia" in head:
c2pa_source_kind = "generated"
elif b"compositeWithTrainedAlgorithmicMedia" in head:
c2pa_source_kind = "enhanced"
source_kind = _metadata_source_kind(info, head)
# An identity-AI issuer (a pure-generator brand like Dreamina) asserts AI even
# without a digitalSourceType -- some ByteDance/Dreamina manifests ship no
# trainedAlgorithmicMedia, so the registered generator name is the only signal.
@@ -838,7 +1092,7 @@ def _identify_from_evidence(
# does not reopen the incidental-mention problem the common-word issuers have.
issuer_blob = " ".join(issuers)
c2pa_identity_ai = has_c2pa and any(org in issuer_blob for org in C2PA_IDENTITY_AI_ORGS)
c2pa_is_ai = c2pa_source_kind is not None or c2pa_identity_ai
c2pa_is_ai = source_kind is not None or c2pa_identity_ai
# Generator string (for the signal detail): structured for PNG, CBOR-scanned
# for other containers. Best-effort -- some manifests key it as
# `claim_generator_info` (Pixel), so this can be None even when a device is
@@ -846,7 +1100,7 @@ def _identify_from_evidence(
generator = (
info.get("claim_generator")
or cbor_text_after(head, b"claim_generator")
or (", ".join(tools) if (tools := _ai_tools_in(head)) else None)
or (", ".join(tools) if (tools := _ai_tools_in(region)) else None)
)
# Platform: a distinctive device/camera token in the manifest wins (it is the
# signer/producer), then an editing-app/AI-device signer (Samsung Galaxy,
@@ -881,9 +1135,24 @@ def _identify_from_evidence(
platform = f"C2PA signer: {cloud_vendor} (cloud manifest)"
# ── SynthID metadata proxy ──────────────────────────────────────
# get_ai_metadata already sets synthid_watermark for both PNG (caBX parser)
# and non-PNG (its own synthid_source fallback), so no extra scan is needed.
# Structured first (the PNG caBX parser and the manifest store both fill
# `synthid_watermark`), then the byte scan for the containers that keep the
# manifest where no parser reaches it.
#
# The scan lives HERE, in the verdict, and not in extraction, for the same reason
# `soft_binding` below does: extraction has two implementations -- one reading a
# file, one reading a portable record -- and a rule that lives in only one of them
# is a rule the other silently lacks. It did: 74 corpus images reported SynthID
# through `identify` and not through the record, because `get_ai_metadata`'s own
# fallback has no counterpart on the record side. `get_ai_metadata` keeps its copy
# for its own callers; the verdict no longer depends on which extractor ran.
synthid = meta.get("synthid_watermark")
# The literal byte checks mirror `metadata.synthid_source` exactly rather than
# reusing the derived `has_c2pa` / `source_kind` above, which are broader:
# the file path's answer must not move.
trained_source = b"trainedAlgorithmicMedia" in head or b"TrainedAlgorithmicMedia" in head
if not synthid and trained_source and c2pa_marker_in(head) and (vendors := synthid_vendors_in(region)):
synthid = synthid_verdict(", ".join(vendors))
if synthid:
watermarks.append(f"SynthID watermark, inferred from C2PA metadata ({synthid})")
caveats.append(_SYNTHID_CAVEAT)
@@ -895,7 +1164,7 @@ def _identify_from_evidence(
# ── C2PA soft-binding: a named forensic/third-party watermark vendor ─
# (Adobe TrustMark, Digimarc, Imatag, ...). Present in the manifest even when
# the watermark itself can't be decoded; names whose watermark stamped the pixels.
soft_binding = meta.get("soft_binding") or (", ".join(v) if (v := soft_binding_vendors_in(head)) else None)
soft_binding = meta.get("soft_binding") or (", ".join(v) if (v := soft_binding_vendors_in(region)) else None)
if soft_binding:
signals.append(Signal("soft_binding", f"C2PA soft binding: {soft_binding}", "high"))
watermarks.append(f"Forensic watermark soft binding ({soft_binding})")
@@ -1006,7 +1275,7 @@ def _identify_from_evidence(
# ── Open invisible watermark (SD / SDXL / FLUX, dwtDct) ──────────
# Public decoder, no key -- a definitive embedded signal on pristine files.
if check_invisible and pixel_path is not None and (scheme := _invisible_watermark(pixel_path)) is not None:
if check_invisible and pixel_path is not None and (scheme := _invisible_watermark(pixel_path, decode)) is not None:
signals.append(Signal("invisible_watermark", scheme, "high"))
watermarks.append(f"Open invisible watermark: {scheme}")
caveats.append(_INVISIBLE_WM_CAVEAT)
@@ -1039,7 +1308,7 @@ def _identify_from_evidence(
)
if check_visible and pixel_path is not None:
platform = _collect_visible_signals(pixel_path, signals, watermarks, platform)
platform = _collect_visible_signals(pixel_path, signals, watermarks, platform, decode)
visible_only = any(s.name.startswith("visible_") for s in signals) and not ai_from_metadata
hf_only = bool(hf_job) and not ai_from_metadata
@@ -1067,9 +1336,9 @@ def _identify_from_evidence(
is_ai_generated=is_ai,
platform=platform,
confidence=confidence,
# Only meaningful when the AI verdict actually came from the C2PA source
# type; a non-C2PA AI signal (IPTC/AIGC/local gen) leaves it None.
ai_source_kind=c2pa_source_kind if (is_ai and has_c2pa) else None,
# Meaningful for the same digitalSourceType whether carried by C2PA or a
# standalone IPTC/XMP label. Other AI signals leave it None.
ai_source_kind=source_kind if (is_ai and (has_c2pa or iptc)) else None,
ai_from_metadata=ai_from_metadata,
watermarks=watermarks,
signals=signals,
@@ -1078,9 +1347,37 @@ def _identify_from_evidence(
)
def identify_from_evidence(evidence: ProvenanceEvidence) -> ProvenanceReport:
"""Build a metadata-only provenance verdict without reopening the source."""
return _identify_from_evidence(evidence)
def identify_from_evidence(
evidence: ProvenanceEvidence,
*,
image_path: Path | None = None,
check_visible: bool = False,
check_invisible: bool = False,
) -> ProvenanceReport:
"""Build a provenance verdict from already-extracted evidence.
Metadata-only by default -- the source is never reopened. Pass ``image_path`` with
``check_visible`` / ``check_invisible`` to add the pixel-backed detectors on top of
the SAME evidence, which is how a caller that asks the file two provenance questions
(which vendor is confirmed, and is there an invisible target) pays for the metadata
extraction once.
"""
return _identify_from_evidence(
evidence,
image_path=image_path,
check_visible=check_visible,
check_invisible=check_invisible,
)
def identify_metadata_record(record: dict[str, Any], *, path: Path) -> ProvenanceReport:
"""Build a metadata-only verdict from a portable metadata record.
This is the service-integration entry point: the source file is never opened,
and callers receive the same verdict as the explicit
``evidence_from_metadata_record`` / ``identify_from_evidence`` sequence.
"""
return identify_from_evidence(evidence_from_metadata_record(record, path=path))
def identify(
+13
View File
@@ -89,6 +89,19 @@ def _pil_read(path: str | Path, flags: int) -> NDArray[Any] | None:
return None
def load_image_bgr(path: str | Path) -> NDArray[Any]:
"""Read ``path`` as a BGR ndarray, raising instead of returning ``None``.
:func:`imread` keeps cv2's ``None``-on-failure contract because the removal paths
branch on it. Scripts and tests want the opposite -- fail loudly at the read -- so
they call this. Each vendor engine used to carry its own verbatim copy.
"""
image = imread(path)
if image is None:
raise FileNotFoundError(f"Failed to read image: {path}")
return image
def to_bgr(image: NDArray[Any]) -> NDArray[Any]:
"""Return a 3-channel BGR view of ``image``, promoting grayscale and BGRA.
@@ -29,6 +29,9 @@ from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Iterable
from pathlib import Path
from typing import Any
from numpy.typing import NDArray
logger = logging.getLogger(__name__)
@@ -81,7 +84,7 @@ def _bits_to_bytes(bits: Iterable[object], nbytes: int) -> bytes:
return bytes(int(value) for value in packed[:nbytes])
def detect_invisible_watermark(image_path: Path) -> str | None:
def detect_invisible_watermark(image_path: Path, *, image: NDArray[Any] | None = None) -> str | None:
"""Return the embedding scheme name if a known open watermark is decoded.
Returns e.g. ``"Stable Diffusion XL"`` / ``"FLUX.2 (Black Forest Labs)"`` /
@@ -94,7 +97,10 @@ def detect_invisible_watermark(image_path: Path) -> str | None:
from remove_ai_watermarks import image_io
from remove_ai_watermarks.dwt_dct import decode_dwt_dct_lengths
img = image_io.imread(image_path)
# ``image`` lets a caller that has already decoded these pixels hand them in
# (mirrors gemini_engine.detect_sparkle_confidence). The decoder only reads the
# array -- it converts colour spaces into fresh buffers -- so no copy is needed.
img = image if image is not None else image_io.imread(image_path)
if img is None:
return None
+1 -3
View File
@@ -24,7 +24,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any
from remove_ai_watermarks import _text_mark_engine
from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkDetection, TextMarkEngine
from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkEngine
if TYPE_CHECKING:
from numpy.typing import NDArray
@@ -85,8 +85,6 @@ _CONFIG = TextMarkConfig(
min_gw=8,
)
JimengDetection = TextMarkDetection
def _alpha_template() -> NDArray[Any] | None:
"""The bundled Jimeng alpha template (float [0,1]), or None."""
+1 -20
View File
@@ -52,11 +52,9 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any
from remove_ai_watermarks import _text_mark_engine
from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkDetection, TextMarkEngine
from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkEngine
if TYPE_CHECKING:
from pathlib import Path
from numpy.typing import NDArray
# Locate geometry as a fraction of the image SHORT side (measured basis -- see
@@ -114,8 +112,6 @@ _CONFIG = TextMarkConfig(
provenance_ncc_factor=1.0,
)
KlingDetection = TextMarkDetection
def _alpha_template() -> NDArray[Any] | None:
"""The bundled Kling alpha template (float [0,1]), or None."""
@@ -127,23 +123,8 @@ def _glyph_silhouette() -> NDArray[Any] | None:
return _text_mark_engine.glyph_silhouette(_CONFIG.asset_name)
def _template_match_score(box_mask: NDArray[Any], scale_base: int) -> float:
"""TM_CCOEFF_NORMED of the Kling glyph silhouette against ``box_mask``."""
return _text_mark_engine.template_match_score(box_mask, scale_base, _CONFIG)
class KlingEngine(TextMarkEngine):
"""Detect/localize the visible Kling "可灵AI 3.0" watermark (locate -> mask; mask feeds the fill)."""
def __init__(self) -> None:
super().__init__(_CONFIG)
def load_image_bgr(path: str | Path) -> NDArray[Any]:
"""Read an image as BGR ndarray (helper for scripts/tests)."""
from remove_ai_watermarks import image_io
img = image_io.imread(path)
if img is None:
raise FileNotFoundError(f"Failed to read image: {path}")
return img
+47 -64
View File
@@ -25,11 +25,15 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any
from remove_ai_watermarks import _text_mark_engine
from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkDetection, TextMarkEngine
from remove_ai_watermarks._text_mark_engine import (
TextMarkConfig,
TextMarkDetection,
TextMarkEngine,
TextMarkLocation,
TextMarkScan,
)
if TYPE_CHECKING:
from pathlib import Path
from numpy.typing import NDArray
# Locate geometry as a fraction of the image WIDTH (measured basis). The box is
@@ -84,24 +88,12 @@ _CONFIG = TextMarkConfig(
provenance_ncc_factor=1.0,
)
LibLibDetection = TextMarkDetection
def _alpha_template() -> NDArray[Any] | None:
"""The bundled LibLibAI alpha template (float [0,1]), or None."""
return _text_mark_engine.load_alpha_template(_CONFIG.asset_name)
def _glyph_silhouette() -> NDArray[Any] | None:
"""Binary "LibLibAI" silhouette (255 = glyph) from the alpha map, or None."""
return _text_mark_engine.glyph_silhouette(_CONFIG.asset_name)
def _template_match_score(box_mask: NDArray[Any], scale_base: int) -> float:
"""TM_CCOEFF_NORMED of the LibLibAI glyph silhouette against ``box_mask``."""
return _text_mark_engine.template_match_score(box_mask, scale_base, _CONFIG)
class LibLibEngine(TextMarkEngine):
"""Detect/localize the visible LibLibAI wordmark (bottom-center; localize -> fill)."""
@@ -111,60 +103,51 @@ class LibLibEngine(TextMarkEngine):
def __init__(self) -> None:
super().__init__(_CONFIG)
def detect(self, image: NDArray[Any] | None, *, provenance: bool = False) -> TextMarkDetection:
if image is None or not image.size or min(image.shape[:2]) < self._MIN_SHORT_SIDE:
return TextMarkDetection()
return super().detect(image, provenance=provenance)
def _scan(self, image: NDArray[Any] | None) -> TextMarkScan:
"""Skip the scan entirely below the size floor.
def footprint_mask(
self, image: NDArray[Any] | None, *, force: bool = False, dilate: int | None = None
) -> NDArray[Any] | None:
"""Full-frame mask of the logo + wordmark, bounded by the detector's match box.
Gating the SCAN rather than overriding ``detect`` is what keeps the floor on the
single-pass perception path too, and it means a small image costs nothing.
"""
if image is None or not image.size or min(image.shape[:2]) < self._MIN_SHORT_SIDE:
return TextMarkScan(None, None, 0)
return super()._scan(image)
def _footprint_rect(
self,
image: NDArray[Any],
loc: TextMarkLocation,
*,
force: bool,
detection: TextMarkDetection | None,
) -> tuple[int, int, int, int] | None:
"""Bound the fill by the detector's match box, never by the binary glyph blob.
The base class's blob-bbox footprint is wrong in both directions here: the
blob bleeds UP into bright background structure (on the 768x1024 cohort
frame it reached y 931 and the fill ate the shirt's own print) and it does
not own the triangle logo anyway. The match box bounds the wordmark exactly
(that is what the NCC localized); the logo sits its own height to the LEFT
of the text (measured on the cohort zoom: logo ~1.0x the glyph height, gap
~0.3x), so the footprint is the match box extended left by ~1.3 heights.
not own the triangle logo anyway.
"""
if image is None or image.size == 0:
return None
from remove_ai_watermarks import image_io, region_eraser
return self._match_box_rect(image, loc, force=force, detection=detection)
image = image_io.to_bgr(image)
h, w = image.shape[:2]
if h < 32 or w < 64:
return None
loc = self.locate(image)
bx, by, bw, bh = loc.bbox
if force:
rx1, ry1, rx2, ry2 = bx, by, min(w, bx + bw), min(h, by + bh)
else:
if not self.detect(image).detected:
return None
_, box = self._tophat_best(image, loc)
if box is None:
return None
gx0, gy0, gx1, gy1 = box
gh = gy1 - gy0 + 1
pad = max(3, int(0.25 * gh))
rx1 = max(0, bx + gx0 - int(1.3 * gh)) # the triangle logo, left of the text
ry1 = max(0, by + gy0 - pad)
rx2 = min(w, bx + gx1 + 1 + pad)
ry2 = min(h, by + gy1 + 1 + pad)
if rx1 >= rx2 or ry1 >= ry2:
return None
d = dilate if dilate is not None else max(3, int(0.02 * bw))
return region_eraser.boxes_to_mask((h, w), [(rx1, ry1, rx2 - rx1, ry2 - ry1)], dilate=d)
def _extend_match_box(
self, box: tuple[int, int, int, int], loc: TextMarkLocation, frame: tuple[int, int]
) -> tuple[int, int, int, int]:
"""Extend the match box LEFT to take in the triangle logo.
def load_image_bgr(path: str | Path) -> NDArray[Any]:
"""Read an image as BGR ndarray (helper for scripts/tests)."""
from remove_ai_watermarks import image_io
img = image_io.imread(path)
if img is None:
raise FileNotFoundError(f"Failed to read image: {path}")
return img
The match box bounds the wordmark exactly (that is what the NCC localized);
the logo sits its own height to the LEFT of the text (measured on the cohort
zoom: logo ~1.0x the glyph height, gap ~0.3x), so the footprint is the match
box extended left by ~1.3 heights.
"""
gx0, gy0, gx1, gy1 = box
bx, by, _bw, _bh = loc.bbox
h, w = frame
gh = gy1 - gy0 + 1
pad = max(3, int(0.25 * gh))
return (
max(0, bx + gx0 - int(1.3 * gh)), # the triangle logo, left of the text
max(0, by + gy0 - pad),
min(w, bx + gx1 + 1 + pad),
min(h, by + gy1 + 1 + pad),
)
+282 -48
View File
@@ -15,9 +15,14 @@ import struct
from typing import TYPE_CHECKING, Any, cast
if TYPE_CHECKING:
from collections.abc import Iterable
from collections.abc import Callable, Iterable
from pathlib import Path
from remove_ai_watermarks._internal.constants import (
PNG_METADATA_CHUNKS,
RIFF_METADATA_CHUNKS,
)
logger = logging.getLogger(__name__)
# Smaller scan_head window for the cheap marker checks (has_ai_metadata,
@@ -165,6 +170,21 @@ TC260_AIGC_FIELDS: frozenset[str] = frozenset(
MAX_TC260_VALUE_BYTES = 1024 * 1024
# A TC260 producer code is ``001`` + ``1`` + USCC(18) + a 5-digit app/product suffix,
# so two codes sharing the USCC are the same legal entity registering different
# products. Slicing is defensive: anything not matching the layout is returned as-is,
# which also passes through the bare-name forms some generators write ("doubao",
# "picwish").
_USCC_START, _USCC_END = 4, 22
def uscc_of(code: str) -> str:
"""The 18-char Unified Social Credit Code embedded in a TC260 producer code."""
if len(code) >= _USCC_END and code[:3] == "001":
return code[_USCC_START:_USCC_END]
return code
def parse_tc260_aigc_json(value: bytes) -> dict[str, str] | None:
"""Parse a bounded JSON object carrying at least one normative TC260 field."""
if len(value) > MAX_TC260_VALUE_BYTES:
@@ -221,11 +241,6 @@ def _is_ai_value(value: str) -> bool:
return any(token in value_lower for token in AI_GENERATOR_TOKENS)
# PNG ancillary chunks that can carry provenance metadata (XMP, EXIF, text).
# Never IDAT -- that is the compressed pixel stream.
_PNG_META_CHUNKS: frozenset[bytes] = frozenset({b"tEXt", b"iTXt", b"zTXt", b"eXIf", b"iCCP"})
def _png_late_metadata(image_path: Path, window: int) -> bytes:
"""Payloads of PNG metadata chunks that start *beyond* the first ``window``
bytes, found by seeking past the (large) ``IDAT`` pixel stream.
@@ -257,7 +272,7 @@ def _png_late_metadata(image_path: Path, window: int) -> bytes:
# Clamp the attacker-controlled 32-bit length to the bytes that
# actually remain, so a malformed huge length can't allocate GBs.
safe_length = max(0, min(length, file_size - data_start))
if chunk_type in _PNG_META_CHUNKS and data_start >= window:
if chunk_type in PNG_METADATA_CHUNKS and data_start >= window:
f.seek(data_start)
out += f.read(safe_length)
# Advance by the CLAMPED length: a malformed/inflated `length` that
@@ -270,17 +285,86 @@ def _png_late_metadata(image_path: Path, window: int) -> bytes:
return bytes(out)
def _riff_late_metadata(image_path: Path, window: int, *, max_total: int = 4 * 1024 * 1024) -> bytes:
"""Payloads of RIFF metadata chunks that start *beyond* the first ``window``
bytes, found by stepping over the (large) coded-image chunk.
The WebP layout puts ``XMP ``/``EXIF`` AFTER the pixels, so a fixed read can stop
before an IPTC or C2PA AI label. This is the RIFF analogue of
:func:`_png_late_metadata`; it returns only chunks past ``window`` so bytes
already in the head are not duplicated, and empty when there are none.
``max_total`` caps what a metadata scan can pull into memory, the same ceiling
``isobmff.scan_c2pa_region`` applies. Clamping each chunk to the bytes that remain
is not enough on its own: a corrupt or crafted file can declare one ``XMP `` chunk
spanning most of itself, and this runs on the memoized verdict path for images from
arbitrary sources. A label that needs more than 4 MB of XMP does not exist.
"""
out = bytearray()
try:
with open(image_path, "rb") as f:
if f.read(4) != b"RIFF":
return b""
f.seek(0, 2)
file_size = f.tell()
f.seek(4)
declared_size = f.read(4)
if len(declared_size) < 4:
return b""
container_end = min(file_size, 8 + struct.unpack("<I", declared_size)[0])
position = 12 # 'RIFF' + size + form type
while position + 8 <= container_end and len(out) < max_total:
f.seek(position)
header = f.read(8)
if len(header) < 8:
break
chunk_type = header[:4]
(length,) = struct.unpack("<I", header[4:8])
start = position + 8
# Clamp to what remains: a malformed 32-bit length must not push the
# walk past EOF and abandon a genuine label chunk after it.
safe_length = max(0, min(length, container_end - start))
if chunk_type in RIFF_METADATA_CHUNKS and start >= window:
f.seek(start)
out += f.read(min(safe_length, max_total - len(out)))
position = start + safe_length + (safe_length & 1) # chunks are word-aligned
except OSError as exc:
logger.debug("RIFF late-metadata scan failed on %s: %s", image_path, exc)
return b""
return bytes(out)
def _stat_key(image_path: Path) -> tuple[str, int, int] | None:
"""Cache key identifying this file's exact CONTENT, or None when it cannot stat.
``(path, mtime_ns, size)`` -- size as well as mtime because an in-place rewrite
can land inside the same mtime tick on a coarse filesystem, and this package does
rewrite in place (``remove_ai_metadata(p, p)``, the batch output-equals-input
case). A file it cannot stat is read uncached rather than failing.
"""
try:
st = image_path.stat()
except OSError:
return None
return (str(image_path), st.st_mtime_ns, st.st_size)
def scan_head(image_path: Path, size: int = 1024 * 1024) -> bytes:
"""First ``size`` bytes of the file, plus the payloads of any provenance
metadata found beyond that window: ISOBMFF ``uuid`` / ``jumb`` boxes (seeking
past large boxes like ``mdat``) and PNG ``tEXt`` / ``iTXt`` / ``eXIf`` chunks
(seeking past ``IDAT``).
A file at least ``size`` bytes long additionally gets the metadata text its
decoder can reach but a raw read cannot (:func:`_decoder_visible_text`): a
compressed PNG ``zTXt`` packet, or a chunk past the window in a container with no
late-chunk reader here. A file that fits inside ``size`` is exactly
``f.read(size)``, since the raw read already holds every byte.
This is the shared input for every C2PA / AIGC / IPTC byte scan. The
extensions catch a manifest or XMP packet placed AFTER the media data -- a
non-faststart MP4 manifest, or a PNG XMP packet appended after the pixels --
which a fixed first-MB read would miss. For other inputs, and for files that
fit within ``size``, it is exactly ``f.read(size)`` -- behavior-neutral.
which a fixed first-MB read would miss.
The result is memoized per (path, size, mtime): one ``identify``/``get_ai_metadata``
call fans out to ~8 byte-scan detectors that each call this on the same file, so
@@ -317,9 +401,63 @@ def _scan_head_impl(image_path: Path, size: int) -> bytes:
# len(head) == size means the file is at least `size` bytes, so metadata
# chunks may lie beyond the window; otherwise the whole PNG is in `head`.
head += _png_late_metadata(image_path, size)
elif head[:4] == b"RIFF" and head[8:12] == b"WEBP" and len(head) == size:
head += _riff_late_metadata(image_path, size)
if len(head) >= size:
head += _decoder_visible_text(image_path, head)
return head
# Text values the image decoder can reach that a raw byte read cannot. Bounded: a
# packet larger than this is not a provenance label.
_DECODED_TEXT_LIMIT = 512 * 1024
# Decoder values that are binary payloads with their own readers, not metadata text.
# An ICC profile is colour data and can run to hundreds of kilobytes; appending it
# would bloat the buffer every later detector re-scans, for no signal.
_DECODER_BINARY_KEYS = frozenset({"icc_profile"})
def _decoder_visible_text(image_path: Path, head: bytes) -> bytes:
"""Metadata text PIL can decode but the raw window does not contain.
This is the last of two layers, not the first. Metadata placed BEYOND the window
is the structural readers' job (``_png_late_metadata``, ``_riff_late_metadata``,
the ISOBMFF box walk), and they work on a file no decoder can open. What is left
for this one is metadata the bytes do not spell at all:
* COMPRESSED -- a PNG ``zTXt`` chunk is zlib-deflated, so an XMP packet carrying
a TC260 AIGC label is unreadable as bytes while PIL inflates it on open.
It stays container-agnostic on purpose: it is the net under a placement no
structural reader here knows about yet.
Only text ALREADY MISSING from ``head`` is appended, so the common case adds
nothing and no detector sees a value twice. Skipped entirely when the file fits
inside the window, since then the raw read already holds every byte.
"""
try:
from PIL import Image
with Image.open(image_path) as img:
values = [value for key, value in img.info.items() if key not in _DECODER_BINARY_KEYS]
except Exception as exc: # a container PIL cannot open: the raw scan stands alone
logger.debug("decoder-visible text unavailable for %s: %s", image_path, exc)
return b""
out = bytearray()
for value in values:
if isinstance(value, str):
encoded = value.encode("utf-8", "replace")
elif isinstance(value, bytes):
encoded = value
else:
continue
if len(encoded) > _DECODED_TEXT_LIMIT or not encoded or encoded in head:
continue
out += b"\x00" + encoded
return bytes(out)
def has_ai_metadata(image_path: Path) -> bool:
"""Check if an image contains AI-generation metadata.
@@ -420,7 +558,7 @@ def aigc_label_from_metadata(data: bytes, candidates: tuple[str, ...] = ()) -> d
return None
def aigc_label(image_path: Path) -> dict[str, str] | None:
def _aigc_label_impl(image_path: Path) -> dict[str, str] | None:
"""Parse a China TC260 AI-labeling block, if present.
Supported serializations are:
@@ -459,45 +597,44 @@ def aigc_label(image_path: Path) -> dict[str, str] | None:
if isinstance(value, str) and (result := aigc_label_from_metadata(b"", (value,))):
return result
# Native MP4/MOV TC260 metadata (TC260-PG-20257A): the ``AIGC`` key lives
# in ``moov.udta.meta.keys`` and points to a raw JSON value in ``ilst``.
# Read it through the bounded box walker so a tail ``moov`` after a large
# ``mdat`` is found without loading or scanning the media payload.
from remove_ai_watermarks._internal.isobmff import tc260_aigc_payloads
isobmff_candidates = tuple(payload.decode("utf-8", "replace") for payload in tc260_aigc_payloads(image_path))
if result := aigc_label_from_metadata(b"", isobmff_candidates):
return result
# Native MKV/WebM TC260 metadata: ``Segment.Tags.Tag.SimpleTag`` carries
# ``TagName=AIGC`` and the raw JSON in ``TagString``. The EBML walker seeks
# over clusters and reads only bounded metadata values.
from remove_ai_watermarks._internal.ebml import tc260_aigc_payloads as ebml_tc260_aigc_payloads
ebml_candidates = tuple(payload.decode("utf-8", "replace") for payload in ebml_tc260_aigc_payloads(image_path))
if result := aigc_label_from_metadata(b"", ebml_candidates):
return result
# Native AVI and FLV TC260 metadata. Both readers walk their container
# structures and skip media payloads instead of relying on a raw substring
# that could collide inside compressed video.
legacy_payloads: tuple[bytes, ...] = ()
if image_path.suffix.lower() == ".avi":
from remove_ai_watermarks._internal.riff import tc260_aigc_payloads as riff_tc260_aigc_payloads
legacy_payloads = riff_tc260_aigc_payloads(image_path)
elif image_path.suffix.lower() == ".flv":
from remove_ai_watermarks._internal.flv import tc260_aigc_payloads as flv_tc260_aigc_payloads
legacy_payloads = flv_tc260_aigc_payloads(image_path)
legacy_candidates = tuple(payload.decode("utf-8", "replace") for payload in legacy_payloads)
if result := aigc_label_from_metadata(b"", legacy_candidates):
return result
# Native container TC260 metadata. Every reader walks its own container structure
# and skips media payloads instead of relying on a raw substring that could collide
# inside compressed video, and every one of them SELF-GATES on its magic bytes --
# returning () after a 4-12 byte read on anything else. So the route is content, not
# extension: a correctly formatted AVI or FLV served under the wrong suffix used to
# be missed, which contradicts this module's own rule elsewhere ("route on the
# actual content format, not the extension").
for reader in _tc260_container_readers():
candidates = tuple(payload.decode("utf-8", "replace") for payload in reader(image_path))
if result := aigc_label_from_metadata(b"", candidates):
return result
data = scan_head(image_path)
return aigc_label_from_metadata(data)
def _tc260_container_readers() -> tuple[Callable[[Path], tuple[bytes, ...]], ...]:
"""The native-container TC260 readers, most common first.
Static imports rather than ``importlib``: this module carries no pyright pragma, so
a dynamically resolved callable would be ``Any`` and fail the strict gate. They stay
function-local because ``isobmff`` imports this module's constants at import time.
* MP4/MOV -- the ``AIGC`` key in ``moov.udta.meta.keys`` points at raw JSON in
``ilst``; the bounded box walker finds a tail ``moov`` after a large ``mdat``
without loading the media payload.
* MKV/WebM -- ``Segment.Tags.Tag.SimpleTag`` carries ``TagName=AIGC``.
* AVI -- a ``LIST/INFO/AIGC`` chunk.
* FLV -- ``script.onMetaData.AIGC``.
"""
from remove_ai_watermarks._internal.ebml import tc260_aigc_payloads as ebml_payloads
from remove_ai_watermarks._internal.flv import tc260_aigc_payloads as flv_payloads
from remove_ai_watermarks._internal.isobmff import tc260_aigc_payloads as isobmff_payloads
from remove_ai_watermarks._internal.riff import tc260_aigc_payloads as riff_payloads
return (isobmff_payloads, ebml_payloads, riff_payloads, flv_payloads)
# C2PA "Durable Content Credentials" manifest repositories (C2PA 2.4). When the
# embedded manifest is stripped, an XMP ``dcterms:provenance`` URL can still point
# at the vendor's cloud manifest store, from which the credentials are recoverable
@@ -538,7 +675,7 @@ def c2pa_cloud_manifest(image_path: Path) -> str | None:
return c2pa_cloud_manifest_in(scan_head(image_path, _QUICK_SCAN_BYTES))
def huggingface_job(image_path: Path) -> str | None:
def _huggingface_job_impl(image_path: Path) -> str | None:
"""Return the HuggingFace job id if the image carries an ``hf-job-id`` PNG
text chunk, else None.
@@ -599,7 +736,7 @@ def samsung_genai_in(data: bytes) -> int | None:
return int(match.group(1)) or None
def samsung_genai(image_path: Path) -> int | None:
def _samsung_genai_impl(image_path: Path) -> int | None:
"""Return Samsung's non-zero ``genAIType`` value if the image carries the
Galaxy AI editing marker, else None.
@@ -636,7 +773,7 @@ def iptc_ai_system_in(data: bytes) -> str | None:
return "fields present"
def iptc_ai_system(image_path: Path) -> str | None:
def _iptc_ai_system_impl(image_path: Path) -> str | None:
"""Return an IPTC 2025.1 AI-disclosure note if the file carries those XMP
properties, else None.
@@ -775,7 +912,7 @@ def _exif_text(ifd: dict[int, Any], tag: int) -> str:
return value.decode("latin1", "replace").strip() if isinstance(value, bytes) else ""
def xai_signature(image_path: Path) -> bool:
def _xai_signature_impl(image_path: Path) -> bool:
"""Detect xAI / Grok's EXIF provenance signature scheme.
Grok image downloads (Aurora model) carry no C2PA, XMP, SynthID, or IPTC --
@@ -1372,3 +1509,100 @@ def remove_ai_metadata(
logger.info("Stripped AI metadata → %s", output_path)
return output_path
# ── Per-file probe memoization ──────────────────────────────────────────────
# One ``identify`` reaches each of these twice (``get_ai_metadata`` internally, then
# ``extract_provenance_evidence``), and each call re-walks the container -- the
# ISOBMFF/EBML walks in ``aigc_label`` and the file-tail read in ``samsung_genai``
# are the expensive ones. Keyed on (path, mtime_ns, size) so an in-place rewrite
# invalidates; ``maxsize`` bounds memory to a handful of entries.
@functools.lru_cache(maxsize=4)
def _aigc_label_cached(path_str: str, _mtime_ns: int, _size: int) -> dict[str, str] | None:
from pathlib import Path as _Path
return _aigc_label_impl(_Path(path_str))
def aigc_label(image_path: Path) -> dict[str, str] | None:
"""See :func:`_aigc_label_impl`; memoized per file content."""
key = _stat_key(image_path)
if key is None:
return _aigc_label_impl(image_path)
result = _aigc_label_cached(*key)
return dict(result) if result is not None else None
@functools.lru_cache(maxsize=4)
def _huggingface_job_cached(path_str: str, _mtime_ns: int, _size: int) -> str | None:
from pathlib import Path as _Path
return _huggingface_job_impl(_Path(path_str))
def huggingface_job(image_path: Path) -> str | None:
"""See :func:`_huggingface_job_impl`; memoized per file content."""
key = _stat_key(image_path)
if key is None:
return _huggingface_job_impl(image_path)
return _huggingface_job_cached(*key)
@functools.lru_cache(maxsize=4)
def _samsung_genai_cached(path_str: str, _mtime_ns: int, _size: int) -> int | None:
from pathlib import Path as _Path
return _samsung_genai_impl(_Path(path_str))
def samsung_genai(image_path: Path) -> int | None:
"""See :func:`_samsung_genai_impl`; memoized per file content."""
key = _stat_key(image_path)
if key is None:
return _samsung_genai_impl(image_path)
return _samsung_genai_cached(*key)
@functools.lru_cache(maxsize=4)
def _iptc_ai_system_cached(path_str: str, _mtime_ns: int, _size: int) -> str | None:
from pathlib import Path as _Path
return _iptc_ai_system_impl(_Path(path_str))
def iptc_ai_system(image_path: Path) -> str | None:
"""See :func:`_iptc_ai_system_impl`; memoized per file content."""
key = _stat_key(image_path)
if key is None:
return _iptc_ai_system_impl(image_path)
return _iptc_ai_system_cached(*key)
@functools.lru_cache(maxsize=4)
def _xai_signature_cached(path_str: str, _mtime_ns: int, _size: int) -> bool:
from pathlib import Path as _Path
return _xai_signature_impl(_Path(path_str))
def xai_signature(image_path: Path) -> bool:
"""See :func:`_xai_signature_impl`; memoized per file content."""
key = _stat_key(image_path)
if key is None:
return _xai_signature_impl(image_path)
return _xai_signature_cached(*key)
# ── Shared with the portable metadata record ────────────────────────
# `metadata_record` must read exactly the windows and markers the file path reads: a
# record built from a different window is a record whose verdict can disagree with
# `identify` on the same image. Aliased rather than renamed because the private names
# are load-bearing in this module's own tests and in a corpus script.
QUICK_SCAN_BYTES = _QUICK_SCAN_BYTES
SAMSUNG_EDITOR_MARKER = _SAMSUNG_EDITOR_MARKER
read_file_tail = _read_file_tail
png_late_metadata = _png_late_metadata
riff_late_metadata = _riff_late_metadata
exif_text = _exif_text
+404
View File
@@ -0,0 +1,404 @@
"""Collect one image's provenance metadata into a portable, JSON-safe record.
WHY THIS EXISTS
``extract_provenance_evidence`` reads a file and hands back evidence in memory, so
collection and verdict must happen in the same process, on the machine holding the
image. This module splits them: collect here, judge anywhere, from a record that
survives JSON.
record = collect_metadata_record(path) # touches the file
evidence = evidence_from_metadata_record(record, path=path)
report = identify_from_evidence(evidence) # touches nothing
WHAT GOES IN, AND WHY NOT SIMPLY THE FILE HEAD
The verdict reads a scan buffer that ``scan_head`` fills with the first mebibyte of
the file. Shipping that verbatim would make a record larger than a phone photo's
worth of metadata by two orders of magnitude, because for a PNG almost all of that
mebibyte is compressed pixel data in ``IDAT`` -- bytes no provenance token can ever
live in. A record carries the metadata REGIONS instead, walked per container: the
JPEG marker segments before the coded scan, every PNG chunk but ``IDAT``, the RIFF
chunks that are not coded image, the ISOBMFF provenance boxes, and in every case the
container's trailer.
COMPLETENESS IS A MEASURED PROPERTY, NOT A CLAIM
A region walker is only correct if nothing the verdict reads falls outside the
regions it keeps, and no test over fixtures can establish that: the failure mode is
a container placement nobody thought of. The contract is therefore ALSO verified
against the file path over a real corpus -- same image, both paths, identical
``ProvenanceReport``.
The placements that defeated an earlier draft of this collector, and the reason each
rule below exists, are recorded in ``docs/module-internals.md`` under "Portable
metadata record".
"""
from __future__ import annotations
import base64
import logging
import struct
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from pathlib import Path
from remove_ai_watermarks._internal.constants import PNG_SIGNATURE, RIFF_CODED_IMAGE_CHUNKS
from remove_ai_watermarks._internal.schema import require_schema_version
from remove_ai_watermarks.metadata import (
QUICK_SCAN_BYTES,
SAMSUNG_EDITOR_MARKER,
exif_text,
read_file_tail,
)
logger = logging.getLogger(__name__)
# The structural walk covers the same window the file path reads raw, so the two
# cannot disagree about a chunk type inside it. A smaller window would be cheaper but
# opens a blind spot: past the window only ``png_late_metadata``'s ALLOWLIST is
# collected, while the file path still sees every chunk type up to its own window --
# and a C2PA ``caBX`` chunk is in neither that allowlist nor ``IDAT``. Walking here
# costs little because the payload of the pixel stream is skipped, not copied.
HEAD_WINDOW = 1024 * 1024
# The window searched for the container's end marker. Matches the quick-scan window
# the file path uses when it goes looking for a Samsung trailer, so a trailer visible
# to one path is visible to the other.
TAIL_WINDOW = QUICK_SCAN_BYTES
# Kept from the tail when no end marker is found, so an unrecognized container still
# contributes its last bytes without carrying half a photo.
UNKNOWN_TRAILER_WINDOW = 64 * 1024
# PNG text keys the file path reads for a generator tag, in ITS order. NovelAI stamps
# Software/Source/Title rather than EXIF, and the first match wins, so order matters.
_GENERATOR_TEXT_KEYS = ("Software", "Source", "Title", "Description")
# Stable transport contract for records produced by this module. The version is
# deliberately separate from the verdict version: collection and interpretation can
# evolve independently as long as old records remain readable.
METADATA_RECORD_SCHEMA_VERSION = 1
METADATA_RECORD_TYPE = "provenance_metadata"
def _jpeg_regions(data: bytes) -> bytes:
"""Every marker segment up to the entropy-coded scan, plus the trailer after EOI.
The scan itself is skipped by walking to SOS and then jumping to the trailing
EOI, so a 20 MB photo contributes only its markers.
TWIN: ``metadata._strip_jpeg_metadata_lossless`` walks the same marker chain. The
two were left separate on purpose -- that one couples the walk to "return False and
fall back to a PIL re-encode", a decision the lossless strip path owns and this one
must not inherit -- so a fix to marker handling belongs in BOTH.
"""
out = bytearray()
index, size = 2, len(data)
while index + 1 < size:
if data[index] != 0xFF:
break # malformed boundary: keep what was collected, the tail still follows
marker = data[index + 1]
if marker in (0xDA, 0xD9): # SOS / EOI: the coded scan follows
break
if 0xD0 <= marker <= 0xD7 or marker == 0x01: # standalone, no length
index += 2
continue
if index + 4 > size:
break
segment_length = int.from_bytes(data[index + 2 : index + 4], "big")
end = index + 2 + segment_length
if segment_length < 2 or end > size:
break
out += data[index:end]
index = end
return bytes(out)
def _png_regions(data: bytes) -> bytes:
"""Every chunk except the ``IDAT`` payloads, plus whatever follows IEND.
TWIN: ``metadata._png_late_metadata`` walks the same chunk chain by SEEKING over
the file rather than over a buffer, and keeps an allowlist rather than skipping
``IDAT``. Both filters are deliberate: inside the window the file path sees every
chunk type raw, past it only the allowlist survives.
"""
out = bytearray()
size = len(data)
position = len(PNG_SIGNATURE)
while position + 8 <= size:
(length,) = struct.unpack(">I", data[position : position + 4])
chunk_type = data[position + 4 : position + 8]
start = position + 8
# Clamp the length to the bytes that remain: a malformed 32-bit length must
# not push the walk past EOF and abandon a genuine label chunk after it.
safe_length = max(0, min(length, size - start))
if chunk_type != b"IDAT":
out += chunk_type + data[start : start + safe_length]
position = start + safe_length + 4 # payload + CRC
if chunk_type == b"IEND":
out += data[position:] # a trailer past IEND is metadata too
break
return bytes(out)
def _riff_regions(data: bytes) -> bytes:
"""Every RIFF chunk except the coded image payloads.
TWIN: ``metadata._riff_late_metadata`` (seek-based, past the scan window) and
``_internal.riff`` (AVI ``LIST/INFO``). Same chunk-stepping arithmetic, three
input models.
"""
out = bytearray(data[:12]) # 'RIFF' + size + 'WEBP'
declared_end = 8 + struct.unpack("<I", data[4:8])[0] if len(data) >= 12 else len(data)
size = min(len(data), declared_end)
position = 12
while position + 8 <= size:
chunk_type = data[position : position + 4]
(length,) = struct.unpack("<I", data[position + 4 : position + 8])
start = position + 8
safe_length = max(0, min(length, size - start))
if chunk_type not in RIFF_CODED_IMAGE_CHUNKS:
out += chunk_type + data[start : start + safe_length]
position = start + safe_length + (safe_length & 1) # chunks are word-aligned
return bytes(out)
def _isobmff_regions(image_path: Path, head: bytes) -> bytes:
"""Header window plus the provenance regions the bounded box walkers find.
ISOBMFF hides a manifest in a ``uuid``/``jumb`` box that can sit after a
multi-megabyte ``mdat``, and a TC260 label in ``moov.udta``. Both walkers seek
rather than read the media, so neither pulls the payload in.
"""
from remove_ai_watermarks._internal.isobmff import scan_c2pa_region, tc260_aigc_payloads
out = bytearray(head[:HEAD_WINDOW])
try:
out += scan_c2pa_region(image_path)
except Exception as exc:
logger.debug("ISOBMFF C2PA region scan failed on %s: %s", image_path, exc)
try:
for payload in tc260_aigc_payloads(image_path):
out += payload
except Exception as exc:
logger.debug("ISOBMFF TC260 scan failed on %s: %s", image_path, exc)
return bytes(out)
def _container_regions(image_path: Path, head: bytes) -> tuple[str, bytes]:
"""(container label, metadata bytes) for the container ``head`` starts with.
``head`` must be the file's raw first bytes. Handing this the ``scan_head``
buffer instead is a trap: that buffer is the head CONCATENATED with late metadata
payloads, so a structural walk runs off the end of the real head and parses the
appended bytes as chunks, inflating the record and creating false signals.
"""
from remove_ai_watermarks._internal.isobmff import is_isobmff
from remove_ai_watermarks.metadata import png_late_metadata, riff_late_metadata
if head.startswith(b"\xff\xd8"):
return "jpeg", _jpeg_regions(head)
if head.startswith(PNG_SIGNATURE):
# Chunks placed after the pixel stream (an XMP packet at 2.7 MB, say) are
# past the window; the same seek-past-IDAT reader the file path uses gets them.
return "png", _png_regions(head) + png_late_metadata(image_path, HEAD_WINDOW)
if head.startswith(b"RIFF") and head[8:12] == b"WEBP":
return "webp", _riff_regions(head) + riff_late_metadata(image_path, HEAD_WINDOW)
if is_isobmff(head):
return "isobmff", _isobmff_regions(image_path, head)
return "unknown", head
def _raw_head(image_path: Path) -> bytes:
"""The file's first bytes, unmodified -- the input every structural walk needs."""
try:
with open(image_path, "rb") as handle:
return handle.read(HEAD_WINDOW)
except OSError as exc:
logger.debug("head read failed for %s: %s", image_path, exc)
return b""
def _trailer(image_path: Path, container: str) -> bytes:
"""The bytes that follow the container's end marker, and nothing else.
A fixed-size tail read would be almost entirely pixels: the trailer of a 20 MB
photo is a few kilobytes at most. So the end marker is located in the tail window
and only what follows it is kept. When no marker is found (an unknown container,
or one whose end lies before the window) the window is kept as-is, bounded --
that is what a byte scan of the same file would have seen anyway.
"""
if container == "webp":
# RIFF declares its structural end in bytes 4..8. A fixed tail window is
# normally the last animation/frame payload, not a trailer, so preserve
# only bytes appended after the declared RIFF container.
try:
with open(image_path, "rb") as handle:
header = handle.read(12)
if len(header) < 12 or not header.startswith(b"RIFF"):
return b""
declared_end = 8 + struct.unpack("<I", header[4:8])[0]
handle.seek(0, 2)
file_size = handle.tell()
if declared_end < 12 or declared_end >= file_size:
return b""
handle.seek(declared_end)
return handle.read(min(file_size - declared_end, UNKNOWN_TRAILER_WINDOW))
except OSError as exc:
logger.debug("RIFF trailer read failed for %s: %s", image_path, exc)
return b""
if container == "isobmff":
# ISOBMFF has no out-of-container trailer convention. Its bounded box
# walkers already collect late provenance while skipping ``mdat``; keeping
# a blind tail here would carry coded media bytes.
return b""
tail = read_file_tail(image_path, TAIL_WINDOW)
if SAMSUNG_EDITOR_MARKER in tail:
# Galaxy AI splits its evidence: the marker sits in the post-EOI trailer, but
# the `genAIType` value it is gated on can sit INSIDE the entropy-coded scan.
# Keeping only the trailer therefore carries the marker without the value and the
# verdict silently drops the Samsung signal, so a marked file keeps the whole
# window. Only Samsung-marked files pay for it.
return tail
marker = {"jpeg": b"\xff\xd9", "png": b"IEND\xae\x42\x60\x82"}.get(container)
if marker is None:
return tail[-UNKNOWN_TRAILER_WINDOW:]
index = tail.rfind(marker)
return tail[index + len(marker) :] if index >= 0 else tail[-UNKNOWN_TRAILER_WINDOW:]
def _decoder_info(image_path: Path) -> dict[str, Any]:
"""PIL's ``info`` mapping, read once.
One open for both consumers below. They want different parts of the same mapping
(the text keys, and the raw EXIF blob), and opening twice repeats the container
header parse and, for a PNG carrying ``zTXt``, the zlib inflate with it.
"""
try:
from PIL import Image
with Image.open(image_path) as img:
# PIL types this mapping with a non-string key union (a DPI tuple key
# exists), so the keys are normalized here rather than assumed.
return {str(key): value for key, value in img.info.items()}
except Exception as exc: # a container PIL cannot open
logger.debug("PIL info unavailable for %s: %s", image_path, exc)
return {}
def _exif_pairs(info: dict[str, Any]) -> dict[str, str]:
"""The 0th-IFD tags the verdict reads, under their tag NAMES.
Not a convenience: two probes key on names rather than on the raw bytes already
in the regions. ``xai_signature_pair`` wants an (ImageDescription, Artist) pair,
and ``_external_exif_generator`` looks for Software / Make / Artist /
ImageDescription. Ship the bytes alone and both silently return nothing, which
is how a collector can silently lose Grok and NovelAI verdicts.
"""
exif_bytes = info.get("exif")
if not exif_bytes:
return {}
try:
import piexif
tags = piexif.load(exif_bytes).get("0th", {})
except Exception as exc: # malformed EXIF
logger.debug("EXIF parse failed: %s", exc)
return {}
return {
name: text
for name, tag in (
("Software", piexif.ImageIFD.Software),
("Make", piexif.ImageIFD.Make),
("Artist", piexif.ImageIFD.Artist),
("ImageDescription", piexif.ImageIFD.ImageDescription),
)
if (text := exif_text(tags, tag))
}
def _pil_info(info: dict[str, Any]) -> dict[str, str]:
"""PIL's ``info`` mapping as strings, the source of PNG text keys and ``hf-job-id``."""
def text_of(value: Any) -> str:
return value.decode("utf-8", "replace") if isinstance(value, bytes) else str(value)
# Emitted in the file path's own candidate order. ``generator_from_metadata``
# returns the FIRST candidate carrying a known token. A record using PIL's natural
# dict order can therefore choose a different platform string than the file path,
# even though the two paths are supposed to be indistinguishable.
out: dict[str, str] = {}
for key in _GENERATOR_TEXT_KEYS:
value = info.get(key)
if value is not None and not isinstance(value, (dict, list, tuple)):
out[f"info:{key}"] = text_of(value)
for key, value in info.items():
if key in _GENERATOR_TEXT_KEYS or isinstance(value, (dict, list, tuple)):
continue
out[f"info:{key}"] = text_of(value)
return out
def collect_metadata_record(
image_path: Path,
*,
schema_version: int = METADATA_RECORD_SCHEMA_VERSION,
) -> dict[str, Any]:
"""Collect everything the provenance verdict reads, as a JSON-safe record.
The record is the transport format for
:func:`identify.evidence_from_metadata_record`: it carries the metadata regions
(base64), the C2PA manifest store, and PIL's info mapping without carrying the
primary coded-pixel stream. Schema and collection status are explicit so a
consumer cannot mistake a failed read for an unknown provenance verdict.
Args:
image_path: Path to the image.
schema_version: Output schema implemented by the consumer.
Returns:
A versioned JSON-serializable dict. ``metadata_base64`` holds the
concatenated container regions, ``tail_base64`` the file trailer.
"""
schema_version = require_schema_version(
schema_version,
contract="provenance metadata",
supported=(1,),
)
from remove_ai_watermarks._internal.c2pa import read_manifest_store_json
try:
image_path.stat()
status = "complete"
issues: list[dict[str, str]] = []
except OSError as exc:
logger.debug("metadata source unavailable for %s: %s", image_path, exc)
status = "error"
issues = [{"stage": "source", "code": "unavailable"}]
container, regions = _container_regions(image_path, _raw_head(image_path))
info = _decoder_info(image_path)
record: dict[str, Any] = {
"schema_version": schema_version,
"record_type": METADATA_RECORD_TYPE,
"status": status,
"issues": issues,
"container": container,
"name": image_path.name,
"metadata_base64": base64.b64encode(regions).decode("ascii"),
# Always collected: Samsung's Galaxy AI marker is a post-EOI trailer, and a
# record without it loses that verdict outright.
"tail_base64": base64.b64encode(_trailer(image_path, container)).decode("ascii"),
# PIL info BEFORE exif: the file path prefers a PNG text tag over an EXIF
# one, and the normalizer walks the record in insertion order.
"pil": _pil_info(info),
"exif": _exif_pairs(info),
}
store = read_manifest_store_json(image_path)
if store is not None:
record["c2pa_store"] = store
return record
+11 -4
View File
@@ -61,6 +61,10 @@ _MASK_W, _MASK_H = 0.205, 0.115 # width of W, height of W
# invisible inpaint. Threshold = median Sobel magnitude over the footprint box at a
# normalized width. The reliable bottom-right wordmark arm is NOT texture-gated:
# a wordmark-confirmed pill is removed regardless.
#
# Measured through the PRODUCT path (the `_keep_pill` gate), not the raw detector, by
# ``scripts/pill_gate_audit.py`` -- the raw path bypasses the gate and reads as a
# disaster that the shipped behaviour does not have. Re-run it when the gate changes.
_FLAT_TEXTURE_MAX = 6.0
_silhouette: NDArray[Any] | None = None
@@ -163,8 +167,11 @@ class PillEngine:
box = self._footprint_box(image)
if box is None:
return None
# Same primitive the shared fill uses, rather than a private zeros/fill copy.
# `dilate=0` because this footprint is already generous by construction; the
# box is clamped to the frame in _footprint_box and both origins are positive
# fractions, so boxes_to_mask's own clamping is a no-op here.
from remove_ai_watermarks import region_eraser
x0, y0, x1, y1 = box
h, w = image.shape[:2]
mask = np.zeros((h, w), np.uint8)
mask[y0:y1, x0:x1] = 255
return mask
return region_eraser.boxes_to_mask(image.shape[:2], [(x0, y0, x1 - x0, y1 - y0)], dilate=0)
+484
View File
@@ -0,0 +1,484 @@
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportMissingTypeStubs=false
"""The complete pixel-forensics layer for one image.
STATUS
Independent from provenance verdicts, removal, and the CLI. Consumers use the
versioned :meth:`PixelEvidence.to_dict` boundary; feature extraction failures are
reported per family without discarding successful measurements.
WHAT IS MEASURED
One decode, then six families of scale-robust statistics over it:
* ``dct`` -- AC coefficient histograms over the 8x8 block DCT, plus the deviation of
leading digits from Benford's law.
* ``fft`` -- radial band energies of the log-magnitude spectrum, plus the
color-filter-array periodicity peaks a demosaiced camera capture leaves.
* ``noise`` -- standard deviation and kurtosis of a high-pass residual.
* ``ela`` -- error level after a quality-90 JPEG re-save.
* ``gradient`` -- gradient-magnitude histogram and Laplacian variance.
* ``color`` -- 4x4x4 RGB histogram, mean saturation, mean value.
and, in ``artifacts``, the spatial layer those statistics are computed from: a
64-bit perceptual hash, a 128px JPEG thumbnail, and coarse ELA, noise-residual and
FFT-phase maps.
THE ARTIFACTS ARE NOT AGGREGATES
Everything above ``artifacts`` is a scalar or a fixed-length histogram, and an image
cannot be reconstructed from those. ``artifacts`` is different in kind: a thumbnail
is a picture, a perceptual hash identifies one, and the coarse maps carry layout.
Collecting them makes a record that identifies the source image, so a caller storing
or forwarding them is handling image content, not statistics about it. That is why
they are a separate field and not merged into the families.
REQUIREMENTS
Needs the ``pixels`` extra (numpy). Guard a call with :func:`is_available` when the
caller must not hard-depend on it.
"""
from __future__ import annotations
import base64
import io
import logging
import time
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
from remove_ai_watermarks._internal.schema import require_schema_version
if TYPE_CHECKING:
from pathlib import Path
logger = logging.getLogger(__name__)
# Analysis resolution. Every statistic here is scale-robust, and a 2048px cap keeps
# the FFT and the sliding-window residual bounded on a 100 MP input.
MAX_SIDE = 2048
# The eight lowest-frequency AC positions of the 8x8 block DCT, zig-zag order.
AC_POSITIONS = ((0, 1), (1, 0), (1, 1), (0, 2), (2, 0), (2, 1), (1, 2), (0, 3))
FFT_BANDS = 8
# A Bayer CFA shows as symmetric peaks at half the Nyquist on the diagonals.
BAYER_OFFSETS = ((1, 1), (1, -1))
INSTALL_HINT = "install the pixel extra: uv add 'remove-ai-watermarks[pixels]'"
PIXEL_EVIDENCE_SCHEMA_VERSION = 1
@dataclass(frozen=True)
class PixelEvidence:
"""Pixel statistics for one image, and the spatial artifacts behind them.
``decode`` carries the source dimensions, or ``{"error": ...}`` when the image
could not be decoded -- in which case every other field is empty. A family is also
empty when the image is too small for it (the block DCT needs 8x8, the FFT 32x32,
the residual 3x3), so a caller must treat every field as optional rather than
assume a fixed feature width.
"""
path: Path
decode: dict[str, Any]
dct: dict[str, Any] = field(default_factory=dict[str, Any])
fft: dict[str, Any] = field(default_factory=dict[str, Any])
noise: dict[str, Any] = field(default_factory=dict[str, Any])
ela: dict[str, Any] = field(default_factory=dict[str, Any])
gradient: dict[str, Any] = field(default_factory=dict[str, Any])
color: dict[str, Any] = field(default_factory=dict[str, Any])
# Identifies the source image; see the module note. Empty unless asked for.
artifacts: dict[str, Any] = field(default_factory=dict[str, Any])
# Opt-in timings for callers measuring pipeline latency. Empty by default so
# repeated evidence collection remains value-deterministic.
timing_ms: dict[str, float] = field(default_factory=dict[str, float])
@property
def decoded(self) -> bool:
"""False when the source could not be decoded at all."""
return "error" not in self.decode
@property
def status(self) -> str:
"""``complete``, ``partial`` for a failed family, or ``error`` on decode."""
if not self.decoded:
return "error"
sections = (self.dct, self.fft, self.noise, self.ela, self.gradient, self.color, self.artifacts)
return "partial" if any("error" in section for section in sections) else "complete"
def to_dict(
self,
*,
schema_version: int = PIXEL_EVIDENCE_SCHEMA_VERSION,
) -> dict[str, Any]:
"""Return the selected JSON-safe transport schema without a local path."""
schema_version = require_schema_version(
schema_version,
contract="pixel evidence",
supported=(1,),
)
return {
"schema_version": schema_version,
"status": self.status,
"decode": dict(self.decode),
"dct": dict(self.dct),
"fft": dict(self.fft),
"noise": dict(self.noise),
"ela": dict(self.ela),
"gradient": dict(self.gradient),
"color": dict(self.color),
"artifacts": dict(self.artifacts),
"timing_ms": dict(self.timing_ms),
}
def is_available() -> bool:
"""True when the optional pixel dependencies are installed."""
from remove_ai_watermarks.optional_deps import module_available
return module_available("numpy")
def _numpy() -> Any:
from remove_ai_watermarks.optional_deps import module_available
if not module_available("numpy"):
raise RuntimeError(f"Pixel evidence needs numpy -- {INSTALL_HINT}")
import numpy as np
return np
def _dct_matrix(np: Any, n: int = 8) -> Any:
"""Orthonormal n x n DCT-II basis: M[i, j] = cos(pi (2j + 1) i / 2n)."""
i = np.arange(n)[:, None]
j = np.arange(n)[None, :]
m = np.cos(np.pi * (2 * j + 1) * i / (2 * n))
m[0, :] *= 1 / np.sqrt(2)
return m * np.sqrt(2 / n)
def read_gray(image_path: Path) -> tuple[Any, Any, dict[str, Any]]:
"""Decode to float32 grayscale (and RGB for color stats), downscaled.
Pillow, not cv2, and the source dimensions are recorded BEFORE the downscale.
"""
np = _numpy()
from PIL import Image
from remove_ai_watermarks import image_io
try:
image_io._register_heif() # pyright: ignore[reportPrivateUsage]
with Image.open(image_path) as img:
info: dict[str, Any] = {"width": img.width, "height": img.height}
if max(img.size) > MAX_SIDE:
img.thumbnail((MAX_SIDE, MAX_SIDE), Image.Resampling.LANCZOS)
rgb = np.asarray(img.convert("RGB"), dtype=np.float32)
gray = np.asarray(img.convert("L"), dtype=np.float32)
except Exception as exc:
logger.debug("pixel decode failed for %s: %s", image_path, exc)
# Exception text from Pillow commonly embeds the absolute source path.
# Keep that detail in the log, not in the pathless transport contract.
return None, None, {"error": type(exc).__name__}
return gray, rgb, info
def dct_features(gray: Any) -> dict[str, Any]:
"""AC coefficient histograms over the 8x8 block DCT + Benford deviation."""
np = _numpy()
height, width = gray.shape
h8, w8 = height // 8 * 8, width // 8 * 8
if h8 < 8 or w8 < 8:
return {}
basis = _dct_matrix(np)
bins = np.linspace(-20.5, 20.5, 22)
blocks = gray[:h8, :w8].reshape(h8 // 8, 8, w8 // 8, 8).swapaxes(1, 2)
rows = basis[[row for row, _ in AC_POSITIONS]]
columns = basis[[column for _, column in AC_POSITIONS]]
coeff = np.einsum("ki,abij,kj->abk", rows, blocks, columns)
hists = []
lead_vals: list[Any] = []
for index in range(len(AC_POSITIONS)):
values = coeff[:, :, index].ravel()
hists.append(np.histogram(values, bins=bins)[0].tolist())
lead_vals.append(np.abs(values))
out: dict[str, Any] = {"dct_ac_hist": hists}
flat = np.abs(np.concatenate(lead_vals))
flat = flat[flat >= 1]
if flat.size > 100:
leading = (flat / 10 ** np.floor(np.log10(flat))).astype(int)
leading = leading[(leading >= 1) & (leading <= 9)]
if leading.size > 100:
observed = np.bincount(leading, minlength=10)[1:10] / leading.size
benford = np.log10(1 + 1 / np.arange(1, 10))
out["benford_mad"] = float(np.abs(observed - benford).mean())
return out
def noise_residual_map(gray: Any) -> Any:
"""High-pass residual, the map the noise statistics are computed from."""
np = _numpy()
from numpy.lib.stride_tricks import sliding_window_view
if gray.shape[0] < 3 or gray.shape[1] < 3:
return None
kernel = np.array([[-1.0, -1.0, -1.0], [-1.0, 8.0, -1.0], [-1.0, -1.0, -1.0]])
height, width = gray.shape
# kernel is float64, so the residual is float64 like the unchunked form
out = np.empty((height - 2, width - 2), dtype=np.float64)
# Row-chunked: the (window * kernel) temporary is ~150 MB at 2048px if
# materialized whole. Per-element 9-tap sums are computed in the same order,
# so the result is bit-identical to the unchunked form.
for y0 in range(0, height - 2, 256):
y1 = min(y0 + 256, height - 2)
window = sliding_window_view(gray[y0 : y1 + 2], (3, 3))
out[y0:y1] = (window * kernel).sum(axis=(-1, -2))
return out
def noise_features(residual: Any) -> dict[str, Any]:
"""High-pass residual std and kurtosis."""
flat = residual.ravel()
std = float(flat.std())
if std < 1e-9:
return {"noise_std": 0.0, "noise_kurtosis": 0.0}
z = (flat - flat.mean()) / std
return {"noise_std": std, "noise_kurtosis": float((z**4).mean() - 3.0)}
def fft_decompose(gray: Any) -> tuple[Any, Any] | None:
"""Log-magnitude (fftshifted) and phase of the image spectrum."""
np = _numpy()
if min(gray.shape) < 32:
return None
spectrum = np.fft.fftshift(np.fft.fft2(gray - gray.mean()))
return np.log1p(np.abs(spectrum)), np.angle(spectrum)
def fft_features(mag: Any) -> dict[str, Any]:
"""Radial magnitude band energies (no phase) + CFA periodicity peaks."""
np = _numpy()
height, width = mag.shape
cy, cx = height // 2, width // 2
# 1D broadcast instead of an mgrid: saves ~160 MB of int64 temporaries at
# 2048px. The squares are exact in float64 (values < 2^53), so band means
# are identical to the mgrid form.
r2y = (np.arange(height, dtype=np.float64) - cy) ** 2
r2x = (np.arange(width, dtype=np.float64) - cx) ** 2
radius = np.sqrt(r2y[:, None] + r2x[None, :])
r_max = radius.max()
bands = []
for index in range(FFT_BANDS):
mask = (radius >= r_max * index / FFT_BANDS) & (radius < r_max * (index + 1) / FFT_BANDS)
bands.append(float(mag[mask].mean()) if mask.any() else 0.0)
peaks = []
for dy, dx in BAYER_OFFSETS:
y, x = cy + dy * (height // 4), cx + dx * (width // 4)
neighborhood = mag[y - 2 : y + 3, x - 2 : x + 3]
peaks.append(float(neighborhood.max() - mag.mean()))
return {"fft_band_energy": bands, "cfa_peaks": peaks, "cfa_peak": max(peaks)}
def ela_map(rgb: Any) -> Any:
"""Absolute per-pixel error after a quality-90 JPEG re-save."""
np = _numpy()
from PIL import Image
try:
buffer = io.BytesIO()
Image.fromarray(rgb.astype(np.uint8)).save(buffer, "JPEG", quality=90)
buffer.seek(0)
resaved = np.asarray(Image.open(buffer).convert("RGB"), dtype=np.float32)
except Exception as exc:
logger.debug("ELA re-save failed: %s", exc)
return None
if resaved.shape != rgb.shape:
return None
return np.abs(rgb - resaved).mean(axis=-1)
def ela_features(err: Any) -> dict[str, Any]:
"""Error-level stats after a quality-90 JPEG re-save."""
np = _numpy()
return {"ela_mean": float(err.mean()), "ela_p95": float(np.percentile(err, 95))}
def gradient_features(gray: Any) -> dict[str, Any]:
np = _numpy()
gy, gx = np.gradient(gray)
mag = np.sqrt(gx**2 + gy**2)
hist = np.histogram(mag, bins=10, range=(0, 255))[0].tolist()
laplacian = np.gradient(gy, axis=0) + np.gradient(gx, axis=1)
return {"gradient_hist": hist, "laplacian_var": float(laplacian.var())}
def color_features(rgb: Any) -> dict[str, Any]:
np = _numpy()
small = rgb[::4, ::4] # decimate; the histogram is position-blind anyway
bins = (small / 256 * 4).astype(int).clip(0, 3)
index = bins[..., 0] * 16 + bins[..., 1] * 4 + bins[..., 2]
hist = np.bincount(index.ravel(), minlength=64).tolist()
mx = small.max(axis=-1)
mn = small.min(axis=-1)
saturation = np.where(mx > 0, (mx - mn) / np.maximum(mx, 1e-6), 0)
return {
"color_hist_4x4x4": hist,
"saturation_mean": float(saturation.mean()),
"value_mean": float(mx.mean() / 255),
}
def perceptual_hash(gray: Any) -> str:
"""64-bit DCT perceptual hash. Identifies an image; see the module note."""
np = _numpy()
from PIL import Image
small = np.asarray(Image.fromarray(gray.astype(np.float32), mode="F").resize((32, 32), Image.Resampling.LANCZOS))
basis = _dct_matrix(np, 32)
low_basis = basis[:8]
low = (low_basis @ small @ low_basis.T).ravel()[1:] # drop DC
bits = low > np.median(low)
return f"{int(''.join('1' if bit else '0' for bit in bits), 2):016x}"
def _coarse(np: Any, arr: Any, side: int = 64) -> Any:
"""Downscale a 2D map to at most ``side`` on the long edge."""
from PIL import Image
height, width = arr.shape
if max(height, width) <= side:
return arr
img = Image.fromarray(arr.astype(np.float32), mode="F")
img.thumbnail((side, side), Image.Resampling.BILINEAR)
return np.asarray(img)
def _array_payload(arr: Any) -> dict[str, Any]:
return {
"shape": list(arr.shape),
"dtype": str(arr.dtype),
"base64": base64.b64encode(arr.tobytes()).decode("ascii"),
}
def spatial_artifacts(gray: Any, rgb: Any, *, ela: Any, residual: Any, phase: Any) -> dict[str, Any]:
"""Perceptual hash, thumbnail, and coarse ELA / residual / phase maps.
These identify the source image rather than describe it -- see the module note.
The maps are the ones the statistics were computed from, passed in rather than
recomputed.
"""
np = _numpy()
from PIL import Image
out: dict[str, Any] = {"phash": perceptual_hash(gray)}
thumbnail = Image.fromarray(rgb.astype(np.uint8))
thumbnail.thumbnail((128, 128), Image.Resampling.LANCZOS)
buffer = io.BytesIO()
thumbnail.save(buffer, "JPEG", quality=70)
out["thumbnail_jpeg_b64"] = base64.b64encode(buffer.getvalue()).decode("ascii")
if ela is not None:
out["ela_map"] = _array_payload(_coarse(np, ela))
if residual is not None:
clipped = np.clip(residual / 4.0, -1, 1)
out["noise_residual"] = _array_payload(_coarse(np, (clipped * 127).astype(np.int8)))
if phase is not None:
out["fft_phase"] = _array_payload(_coarse(np, phase.astype(np.float32), 32))
return out
def extract_pixel_evidence(image_path: Path, *, artifacts: bool = False, timings: bool = False) -> PixelEvidence:
"""Measure every pixel-statistic family for one image in a single decode.
The image is decoded ONCE and the intermediate maps (high-pass residual, ELA
error, FFT magnitude and phase) are computed once and shared, because the
residual's sliding window and the ELA re-save are the two expensive steps and
each family would otherwise redo them.
A family that fails or does not apply is left empty rather than raising: an
undecodable file, or one too small for the block DCT, still returns a
:class:`PixelEvidence` whose ``decoded`` / empty fields say so. Missing numpy is
the one hard error, since then nothing can be measured at all.
Args:
image_path: Path to the image. Any container Pillow can open.
artifacts: Also return the spatial layer -- perceptual hash, thumbnail and
coarse maps. Off by default: those identify the source image, so asking
for them is a decision the caller makes explicitly.
timings: Measure each stage and include rounded milliseconds in
:attr:`PixelEvidence.timing_ms`.
Returns:
A :class:`PixelEvidence`.
"""
started = time.perf_counter()
stage_started = started
measured: dict[str, float] = {}
gray, rgb, info = read_gray(image_path)
measured["decode"] = time.perf_counter() - stage_started
if gray is None or rgb is None:
measured["total"] = time.perf_counter() - started
timing_ms = {name: round(seconds * 1000, 1) for name, seconds in measured.items()} if timings else {}
return PixelEvidence(path=image_path, decode=info, timing_ms=timing_ms)
families: dict[str, dict[str, Any]] = {}
residual = None
stage_started = time.perf_counter()
try:
residual = noise_residual_map(gray)
families["noise"] = noise_features(residual) if residual is not None else {}
except Exception as exc:
logger.debug("pixel family noise failed for %s: %s", image_path, exc)
families["noise"] = {"error": type(exc).__name__}
measured["noise"] = time.perf_counter() - stage_started
spectrum = None
stage_started = time.perf_counter()
try:
spectrum = fft_decompose(gray)
families["fft"] = fft_features(spectrum[0]) if spectrum is not None else {}
except Exception as exc:
logger.debug("pixel family fft failed for %s: %s", image_path, exc)
families["fft"] = {"error": type(exc).__name__}
measured["fft"] = time.perf_counter() - stage_started
error = None
stage_started = time.perf_counter()
try:
error = ela_map(rgb)
families["ela"] = ela_features(error) if error is not None else {}
except Exception as exc:
logger.debug("pixel family ela failed for %s: %s", image_path, exc)
families["ela"] = {"error": type(exc).__name__}
measured["ela"] = time.perf_counter() - stage_started
for name, compute in (
("dct", lambda: dct_features(gray)),
("gradient", lambda: gradient_features(gray)),
("color", lambda: color_features(rgb)),
):
stage_started = time.perf_counter()
try:
families[name] = compute()
except Exception as exc: # one bad family must not lose the other five
logger.debug("pixel family %s failed for %s: %s", name, image_path, exc)
families[name] = {"error": type(exc).__name__}
measured[name] = time.perf_counter() - stage_started
if artifacts:
stage_started = time.perf_counter()
try:
families["artifacts"] = spatial_artifacts(
gray, rgb, ela=error, residual=residual, phase=spectrum[1] if spectrum is not None else None
)
except Exception as exc:
logger.debug("pixel artifacts failed for %s: %s", image_path, exc)
families["artifacts"] = {"error": type(exc).__name__}
measured["full_artifacts"] = time.perf_counter() - stage_started
measured["total"] = time.perf_counter() - started
timing_ms = {name: round(seconds * 1000, 1) for name, seconds in measured.items()} if timings else {}
return PixelEvidence(path=image_path, decode=info, timing_ms=timing_ms, **families)
+1 -20
View File
@@ -52,11 +52,9 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any
from remove_ai_watermarks import _text_mark_engine
from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkDetection, TextMarkEngine
from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkEngine
if TYPE_CHECKING:
from pathlib import Path
from numpy.typing import NDArray
# Locate geometry as a fraction of the image SHORT side (measured basis -- see
@@ -122,8 +120,6 @@ _CONFIG = TextMarkConfig(
provenance_ncc_factor=1.0,
)
QwenDetection = TextMarkDetection
def _alpha_template() -> NDArray[Any] | None:
"""The bundled Qwen alpha template (float [0,1]), or None."""
@@ -135,23 +131,8 @@ def _glyph_silhouette() -> NDArray[Any] | None:
return _text_mark_engine.glyph_silhouette(_CONFIG.asset_name)
def _template_match_score(box_mask: NDArray[Any], scale_base: int) -> float:
"""TM_CCOEFF_NORMED of the Qwen glyph silhouette against ``box_mask``."""
return _text_mark_engine.template_match_score(box_mask, scale_base, _CONFIG)
class QwenEngine(TextMarkEngine):
"""Detect/localize the visible Qwen "千问AI生成" watermark (locate -> mask; mask feeds the fill)."""
def __init__(self) -> None:
super().__init__(_CONFIG)
def load_image_bgr(path: str | Path) -> NDArray[Any]:
"""Read an image as BGR ndarray (helper for scripts/tests)."""
from remove_ai_watermarks import image_io
img = image_io.imread(path)
if img is None:
raise FileNotFoundError(f"Failed to read image: {path}")
return img
+72 -35
View File
@@ -21,6 +21,10 @@ Backends:
texture but ~200 MB model and ~4.7 GB peak RAM (too heavy for a small host).
The model is downloaded on first use and cached by huggingface_hub; it is
never bundled in this repo.
The per-backend RAM and wall-time figures above are reproduced by
``scripts/resource_ceilings.py`` (fresh subprocess per measurement, synthetic inputs).
Re-run it before changing any of them.
"""
# cv2/numpy boundary: cv2 ships no usable type info, so strict pyright cannot know
@@ -30,6 +34,7 @@ Backends:
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Literal
import cv2
@@ -48,9 +53,38 @@ _LAMA_FILE = "lama_fp32.onnx"
_MIGAN_REPO = "andraniksargsyan/migan"
_MIGAN_FILE = "migan.onnx"
# Cached onnxruntime sessions (loading is expensive; reuse across calls).
_lama_session: object | None = None
_migan_session: object | None = None
# Cached onnxruntime sessions, keyed by backend name (loading is expensive; reuse
# across calls).
_sessions: dict[str, object] = {}
@dataclass(frozen=True)
class _LearnedBackend:
"""One optional model-backed fill: its human label and the module-level names of
its availability probe and erase function.
NAMES, not function objects: the fallback tests monkeypatch these by attribute on
the module, and a table holding bound references would not see the patch.
"""
label: str
available: str
erase: str
# The learned tier, best-quality first. `resolve_backend`'s preference order and the
# CLI's choices are separate literals by design (see FILL_BACKENDS below), but the
# availability probe, install hint and dispatch all read this one table.
_LEARNED_BACKENDS: dict[str, _LearnedBackend] = {
"lama": _LearnedBackend("LaMa", "lama_available", "erase_lama"),
"migan": _LearnedBackend("MI-GAN", "migan_available", "erase_migan"),
}
# Every fill backend this module can execute, plus the caller-facing `auto`. Kept as a
# literal tuple (not derived through an import) so `watermark_registry` and the CLI can
# state their choices without importing this cv2-loading module at their import time --
# a test pins the two in sync.
FILL_BACKENDS: tuple[str, ...] = ("auto", "cv2", "migan", "lama")
def boxes_to_mask(
@@ -119,19 +153,25 @@ def lama_available() -> bool:
return module_available("onnxruntime")
def _get_lama_session() -> object:
"""Load (once) the big-LaMa ONNX session, downloading the model on first use."""
global _lama_session
if _lama_session is not None:
return _lama_session
def _get_session(name: str, repo_id: str, filename: str, label: str) -> object:
"""Load (once) an ONNX session, downloading the model on first use."""
cached = _sessions.get(name)
if cached is not None:
return cached
import onnxruntime as ort
from huggingface_hub import hf_hub_download
model_path = hf_hub_download(repo_id=_LAMA_REPO, filename=_LAMA_FILE)
logger.info("Loading LaMa-ONNX model: %s", model_path)
_lama_session = ort.InferenceSession(model_path, providers=["CPUExecutionProvider"])
return _lama_session
model_path = hf_hub_download(repo_id=repo_id, filename=filename)
logger.info("Loading %s model: %s", label, model_path)
session = ort.InferenceSession(model_path, providers=["CPUExecutionProvider"])
_sessions[name] = session
return session
def _get_lama_session() -> object:
"""The big-LaMa ONNX session (kept as a named seam the tests monkeypatch)."""
return _get_session("lama", _LAMA_REPO, _LAMA_FILE, "LaMa-ONNX")
def erase_lama(image_bgr: NDArray[Any], mask: NDArray[Any]) -> NDArray[Any]:
@@ -196,25 +236,21 @@ def erase_lama(image_bgr: NDArray[Any], mask: NDArray[Any]) -> NDArray[Any]:
def migan_available() -> bool:
"""True when the optional MI-GAN backend can run (onnxruntime installed)."""
"""True when the optional MI-GAN backend can run (onnxruntime installed).
Deliberately a separate function from :func:`lama_available` even though both
currently reduce to the same onnxruntime probe: they are independent capability
questions and `auto` resolves them separately (see
``watermark_registry.preferred_inpaint_backend``).
"""
from .optional_deps import module_available
return module_available("onnxruntime")
def _get_migan_session() -> object:
"""Load (once) the MI-GAN ONNX session, downloading the model on first use."""
global _migan_session
if _migan_session is not None:
return _migan_session
import onnxruntime as ort
from huggingface_hub import hf_hub_download
model_path = hf_hub_download(repo_id=_MIGAN_REPO, filename=_MIGAN_FILE)
logger.info("Loading MI-GAN ONNX model: %s", model_path)
_migan_session = ort.InferenceSession(model_path, providers=["CPUExecutionProvider"])
return _migan_session
"""The MI-GAN ONNX session (kept as a named seam the tests monkeypatch)."""
return _get_session("migan", _MIGAN_REPO, _MIGAN_FILE, "MI-GAN ONNX")
def erase_migan(image_bgr: NDArray[Any], mask: NDArray[Any]) -> NDArray[Any]:
@@ -308,16 +344,17 @@ def erase(
if not mask.any():
return image_bgr.copy()
if backend == "migan":
if not migan_available():
learned = _LEARNED_BACKENDS.get(backend)
if learned is not None:
# Probe and erase by MODULE NAME, not by the captured function object: the
# availability probes and the erase functions are monkeypatched by name in the
# fallback tests, and a table of bound references would not see those patches.
if not globals()[learned.available]():
raise RuntimeError(
"MI-GAN backend requires onnxruntime. Install the extra: pip install 'remove-ai-watermarks[migan]'"
f"{learned.label} backend requires onnxruntime. "
f"Install the extra: pip install 'remove-ai-watermarks[{backend}]'"
)
return erase_migan(image_bgr, mask)
if backend == "lama":
if not lama_available():
raise RuntimeError(
"LaMa backend requires onnxruntime. Install the extra: pip install 'remove-ai-watermarks[lama]'"
)
return erase_lama(image_bgr, mask)
return globals()[learned.erase](image_bgr, mask)
# cv2 and anything unrecognized (including "auto", which a library caller may pass
# straight through) degrade to the classical fill rather than raising.
return erase_cv2(image_bgr, mask, method=cv2_method, radius=cv2_radius)
+17 -33
View File
@@ -48,13 +48,16 @@ import logging
from typing import TYPE_CHECKING, Any
from remove_ai_watermarks import _text_mark_engine
from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkDetection, TextMarkEngine
from remove_ai_watermarks._text_mark_engine import (
TextMarkConfig,
TextMarkDetection,
TextMarkEngine,
TextMarkScan,
)
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from pathlib import Path
from numpy.typing import NDArray
# Locate geometry as a fraction of the image WIDTH (the measured basis: every
@@ -111,24 +114,12 @@ _CONFIG = TextMarkConfig(
provenance_ncc_factor=1.0,
)
RunningHubDetection = TextMarkDetection
def _alpha_template() -> NDArray[Any] | None:
"""The bundled RunningHub alpha template (float [0,1]), or None."""
return _text_mark_engine.load_alpha_template(_CONFIG.asset_name)
def _glyph_silhouette() -> NDArray[Any] | None:
"""Binary "RunningHub AI生成" silhouette (255 = glyph) from the alpha map, or None."""
return _text_mark_engine.glyph_silhouette(_CONFIG.asset_name)
def _template_match_score(box_mask: NDArray[Any], scale_base: int) -> float:
"""TM_CCOEFF_NORMED of the RunningHub glyph silhouette against ``box_mask``."""
return _text_mark_engine.template_match_score(box_mask, scale_base, _CONFIG)
class RunningHubEngine(TextMarkEngine):
"""Detect/localize the visible RunningHub "RunningHub AI生成" mark (top-left; localize -> fill)."""
@@ -140,18 +131,21 @@ class RunningHubEngine(TextMarkEngine):
_ANCHOR_MAX_X = 0.025
_ANCHOR_MAX_Y = 0.015
def detect(self, image: NDArray[Any], *, provenance: bool = False) -> TextMarkDetection:
det = super().detect(image, provenance=provenance)
if not det.detected:
def _post_gate(self, det: TextMarkDetection, scan: TextMarkScan) -> TextMarkDetection:
"""Demote a match that does not hug the top-left corner.
A shared post-gate rather than a ``detect`` override, so the single-pass
perception path (``detect_both``) cannot skip it.
"""
if not det.detected or scan.loc is None:
return det
loc = self.locate(image)
_, box = self._gray_best(image, loc)
box = det.match_box # the sweep the scan already ran on this same loc
if box is None:
det.detected = False
return det
h, w = image.shape[:2]
ax = (loc.x + box[0]) / w
ay = (loc.y + box[1]) / h
h, w = scan.frame
ax = (scan.loc.x + box[0]) / w
ay = (scan.loc.y + box[1]) / h
if ax > self._ANCHOR_MAX_X or ay > self._ANCHOR_MAX_Y:
logger.debug(
"RunningHub detect: score %.3f but match off-anchor (x=%.3f y=%.3f); demoting.",
@@ -161,13 +155,3 @@ class RunningHubEngine(TextMarkEngine):
)
det.detected = False
return det
def load_image_bgr(path: str | Path) -> NDArray[Any]:
"""Read an image as BGR ndarray (helper for scripts/tests)."""
from remove_ai_watermarks import image_io
img = image_io.imread(path)
if img is None:
raise FileNotFoundError(f"Failed to read image: {path}")
return img
+1 -3
View File
@@ -28,7 +28,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any
from remove_ai_watermarks import _text_mark_engine
from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkDetection, TextMarkEngine
from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkEngine
if TYPE_CHECKING:
from numpy.typing import NDArray
@@ -79,8 +79,6 @@ _CONFIG = TextMarkConfig(
min_gw=16,
)
SamsungDetection = TextMarkDetection
def _alpha_template() -> NDArray[Any] | None:
"""The bundled Samsung alpha template (float [0,1]), or None."""
+22 -46
View File
@@ -229,58 +229,34 @@ def _visible_removal_plan(
selected_scan: VideoScan,
markers: dict[str, str],
) -> tuple[list[tuple[int, int, int, int] | None], float, Literal["box", "veo"]]:
"""Resolve one provider's stable frame regions and fill geometry."""
"""Resolve one provider's stable frame regions and fill geometry.
Everything provider-specific -- the confidence floors, the run length, the fill
padding and the mask style -- is data on ``VISIBLE_MARK_POLICIES``. The only thing
left here is WHICH metadata predicate confirms which vendor, which genuinely is a
mapping and not a tuning constant.
"""
from remove_ai_watermarks.video_visible import (
VISIBLE_MARK_POLICIES,
has_bytedance_video_provenance,
has_sora_provenance,
has_veo_provenance,
stabilize_dola_localizations,
stabilize_hailuo_localizations,
stabilize_kling_localizations,
stabilize_seedance_localizations,
stabilize_sora_localizations,
stabilize_veo_localizations,
stabilize_localizations,
)
if selected_mark == "sora":
return (
stabilize_sora_localizations(
selected_scan.detections,
provenance=has_sora_provenance(markers),
),
0.28,
"box",
)
if selected_mark == "veo":
return (
stabilize_veo_localizations(
selected_scan.detections,
provenance=has_veo_provenance(markers),
),
0.18,
"veo",
)
if selected_mark == "seedance":
return (
stabilize_seedance_localizations(
selected_scan.detections,
provenance=has_bytedance_video_provenance(markers),
),
0.0,
"box",
)
if selected_mark == "dola":
return (
stabilize_dola_localizations(
selected_scan.detections,
provenance=has_bytedance_video_provenance(markers),
),
0.20,
"box",
)
if selected_mark == "hailuo":
return stabilize_hailuo_localizations(selected_scan.detections), 0.12, "box"
return stabilize_kling_localizations(selected_scan.detections), 0.12, "box"
confirms = {
"sora": has_sora_provenance,
"veo": has_veo_provenance,
"seedance": has_bytedance_video_provenance,
"dola": has_bytedance_video_provenance,
}.get(selected_mark)
policy = VISIBLE_MARK_POLICIES[selected_mark]
regions = stabilize_localizations(
selected_mark,
selected_scan.detections,
provenance=bool(confirms and confirms(markers)),
)
return regions, policy.padding_fraction, policy.mask_style
def _select_stable_visible_mark(
+105 -82
View File
@@ -402,7 +402,7 @@ def detect_sora_frame(
"""Locate the strongest synthetic Sora-wordmark match in one frame.
The returned candidate is intentionally untrusted. Call
:func:`stabilize_sora_localizations` across the full sequence before building
:func:`stabilize_localizations` across the full sequence before building
any removal mask.
"""
if image_bgr.size == 0:
@@ -883,113 +883,133 @@ def _region_iou(left: Region, right: Region) -> float:
return intersection / union if union > 0 else 0.0
def stabilize_sora_localizations(
detections: tuple[FrameLocalization, ...] | list[FrameLocalization],
*,
provenance: bool,
) -> list[Region | None]:
"""Accept only spatially recurring Sora candidates and bridge short dropouts.
@dataclass(frozen=True)
class VisibleMarkPolicy:
"""One provider's temporal-arbiter tuning, plus the fill geometry it needs.
Metadata never creates a detection. It only allows a stable visual run whose
scores remain below the strict confidence floor, which covers low-contrast
Sora marks while clean metadata-bearing exports stay untouched.
Every value here is MEASURED per provider; the arbiter itself
(:func:`_stabilize_localizations`) is shared and knows nothing about providers.
``accepts_provenance`` is load-bearing rather than cosmetic. Hailuo and Kling have
no metadata that could confirm them, so their rows force ``provenance=False``; that
used to be guaranteed structurally by wrappers that took no ``provenance``
parameter at all, and this flag is what preserves the guarantee now that one entry
point serves every mark.
``padding_fraction`` and ``mask_style`` belong to the removal plan rather than the
arbiter, but they are per-provider constants like the rest, so they live on the same
row instead of in a parallel branch in ``video.py``.
"""
weak_floor = _SORA_PROVENANCE_WEAK_CONFIDENCE if provenance else _SORA_STRICT_WEAK_CONFIDENCE
return _stabilize_localizations(
detections,
provenance=provenance,
weak_floor=weak_floor,
weak_floor: float
strong_floor: float
transition_floor: float
min_stable_frames: int
cover_after_confirmation: bool
padding_fraction: float
mask_style: Literal["box", "veo"]
anchor_iou: float | None = None
accepts_provenance: bool = True
# Weak floor to use when provenance confirms the vendor. None = the mark has no
# relaxed band; metadata never creates a detection, it only lets a stable visual
# run below the strict floor through.
provenance_weak_floor: float | None = None
VISIBLE_MARK_POLICIES: dict[str, VisibleMarkPolicy] = {
"sora": VisibleMarkPolicy(
weak_floor=_SORA_STRICT_WEAK_CONFIDENCE,
provenance_weak_floor=_SORA_PROVENANCE_WEAK_CONFIDENCE,
strong_floor=_SORA_STRONG_CONFIDENCE,
transition_floor=0.45,
min_stable_frames=_MIN_STABLE_FRAMES,
cover_after_confirmation=False,
)
def stabilize_veo_localizations(
detections: tuple[FrameLocalization, ...] | list[FrameLocalization],
*,
provenance: bool,
) -> list[Region | None]:
"""Accept temporally recurring current or legacy Veo candidates."""
weak_floor = _VEO_PROVENANCE_WEAK_CONFIDENCE if provenance else _VEO_STRICT_WEAK_CONFIDENCE
return _stabilize_localizations(
detections,
provenance=provenance,
weak_floor=weak_floor,
padding_fraction=0.28,
mask_style="box",
),
"veo": VisibleMarkPolicy(
weak_floor=_VEO_STRICT_WEAK_CONFIDENCE,
provenance_weak_floor=_VEO_PROVENANCE_WEAK_CONFIDENCE,
strong_floor=_VEO_STRONG_CONFIDENCE,
transition_floor=0.35,
min_stable_frames=_MIN_VEO_STABLE_FRAMES,
cover_after_confirmation=True,
)
def stabilize_seedance_localizations(
detections: tuple[FrameLocalization, ...] | list[FrameLocalization],
*,
provenance: bool,
) -> list[Region | None]:
"""Accept a recurring Seedance boxed-AI mark at a fixed position."""
return _stabilize_localizations(
detections,
provenance=provenance,
padding_fraction=0.18,
mask_style="veo",
),
"seedance": VisibleMarkPolicy(
# One floor at either trust level: provenance still gates the run acceptance
# inside the arbiter, but the confidence bar does not move.
weak_floor=_SEEDANCE_WEAK_CONFIDENCE,
strong_floor=_SEEDANCE_STRONG_CONFIDENCE,
transition_floor=0.30,
min_stable_frames=_MIN_FIXED_MARK_STABLE_FRAMES,
cover_after_confirmation=True,
anchor_iou=0.80,
)
def stabilize_dola_localizations(
detections: tuple[FrameLocalization, ...] | list[FrameLocalization],
*,
provenance: bool,
) -> list[Region | None]:
"""Accept a recurring Dola AI text mark at a fixed position."""
weak_floor = _DOLA_PROVENANCE_WEAK_CONFIDENCE if provenance else _DOLA_STRICT_WEAK_CONFIDENCE
return _stabilize_localizations(
detections,
provenance=provenance,
weak_floor=weak_floor,
padding_fraction=0.0,
mask_style="box",
),
"dola": VisibleMarkPolicy(
weak_floor=_DOLA_STRICT_WEAK_CONFIDENCE,
provenance_weak_floor=_DOLA_PROVENANCE_WEAK_CONFIDENCE,
strong_floor=_DOLA_STRONG_CONFIDENCE,
transition_floor=0.40,
min_stable_frames=_MIN_FIXED_MARK_STABLE_FRAMES,
cover_after_confirmation=True,
anchor_iou=0.80,
)
def stabilize_hailuo_localizations(
detections: tuple[FrameLocalization, ...] | list[FrameLocalization],
) -> list[Region | None]:
"""Accept a recurring MINIMAX/Hailuo label at a fixed position."""
return _stabilize_localizations(
detections,
provenance=False,
padding_fraction=0.20,
mask_style="box",
),
"hailuo": VisibleMarkPolicy(
weak_floor=_HAILUO_WEAK_CONFIDENCE,
strong_floor=_HAILUO_STRONG_CONFIDENCE,
transition_floor=0.28,
min_stable_frames=_MIN_FIXED_MARK_STABLE_FRAMES,
cover_after_confirmation=True,
anchor_iou=0.80,
)
def stabilize_kling_localizations(
detections: tuple[FrameLocalization, ...] | list[FrameLocalization],
) -> list[Region | None]:
"""Accept a recurring versioned Kling label at a fixed position."""
return _stabilize_localizations(
detections,
provenance=False,
padding_fraction=0.12,
mask_style="box",
accepts_provenance=False,
),
"kling": VisibleMarkPolicy(
weak_floor=_KLING_WEAK_CONFIDENCE,
strong_floor=_KLING_STRONG_CONFIDENCE,
transition_floor=0.30,
min_stable_frames=_MIN_FIXED_MARK_STABLE_FRAMES,
cover_after_confirmation=True,
anchor_iou=0.80,
padding_fraction=0.12,
mask_style="box",
accepts_provenance=False,
),
}
def stabilize_localizations(
mark: str,
detections: tuple[FrameLocalization, ...] | list[FrameLocalization],
*,
provenance: bool = False,
) -> list[Region | None]:
"""Accept only spatially/temporally recurring candidates for ``mark``.
Metadata never creates a detection. It only allows a stable visual run whose scores
remain below the strict confidence floor, which covers a low-contrast mark while
clean metadata-bearing exports stay untouched. A mark whose policy sets
``accepts_provenance=False`` ignores the argument entirely.
"""
policy = VISIBLE_MARK_POLICIES[mark]
trusted = provenance and policy.accepts_provenance
weak_floor = policy.provenance_weak_floor if (trusted and policy.provenance_weak_floor is not None) else None
return _stabilize_localizations(
detections,
provenance=trusted,
weak_floor=weak_floor if weak_floor is not None else policy.weak_floor,
strong_floor=policy.strong_floor,
transition_floor=policy.transition_floor,
min_stable_frames=policy.min_stable_frames,
cover_after_confirmation=policy.cover_after_confirmation,
**({"anchor_iou": policy.anchor_iou} if policy.anchor_iou is not None else {}),
)
@@ -1182,8 +1202,8 @@ def _mask_for_region(
) -> NDArray[Any]:
height, width = frame_bgr.shape[:2]
x, y, region_width, region_height = region
mask = np.zeros((height, width), dtype=np.uint8)
if mask_style == "veo" and 0.80 <= region_width / region_height <= 1.25:
mask = np.zeros((height, width), dtype=np.uint8)
diamond_base, _ = _veo_templates()
diamond = cv2.resize(
diamond_base,
@@ -1207,13 +1227,16 @@ def _mask_for_region(
# into the hole, recreating the mascot as a bright blob. The measured clean
# floor on real Sora frames is a full box with roughly 0.28 mark-heights of
# context on every side.
# Padded rectangle + no dilation is exactly region_eraser.boxes_to_mask (the same
# primitive the image fill uses); the padding IS the growth, so `dilate=0`.
from remove_ai_watermarks.region_eraser import boxes_to_mask
padding = max(4, round(region_height * padding_fraction))
x0 = max(0, x - padding)
y0 = max(0, y - padding)
x1 = min(width, x + region_width + padding)
y1 = min(height, y + region_height + padding)
mask[y0:y1, x0:x1] = 255
return mask
return boxes_to_mask(
(height, width),
[(x - padding, y - padding, region_width + 2 * padding, region_height + 2 * padding)],
dilate=0,
)
def _timestamp_time_base(profile_time_base: str | None) -> Fraction:
+305 -105
View File
@@ -33,7 +33,7 @@ Entries:
from __future__ import annotations
import logging
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Literal
if TYPE_CHECKING:
@@ -79,24 +79,8 @@ Sensitivity = Literal["auto", "strict"]
# A third ``assumed`` level existed for ``assume_ai`` and went with it (2026-07-19).
Trust = Literal["strict", "confirmed"]
# Product family per mark, for the ``auto`` cross-mark corroboration: a confidently
# detected mark relaxes only OTHER marks of the SAME product (different corners, one
# product -- the Jimeng wordmark + the Jimeng pill). Doubao and Jimeng are BOTH ByteDance
# but distinct products in the SAME bottom-right corner, so they must NOT cross-relax
# (relaxing Doubao on a Jimeng wordmark would spuriously fire Doubao on it).
_PRODUCT_OF: dict[str, str] = {
"gemini": "gemini",
"doubao": "doubao",
"jimeng": "jimeng",
"jimeng_pill": "jimeng", # same product as the Jimeng wordmark
"qwen": "qwen",
"kling": "kling",
"yuanbao": "yuanbao",
"samsung": "samsung",
"runninghub": "runninghub",
"baidu": "baidu",
"liblib": "liblib",
}
# Product family per mark now lives on the registry row (``KnownMark.product``);
# ``_PRODUCT_OF`` is derived from it right after ``_REGISTRY`` is built.
# Marks whose own detection is too weak to serve as EVIDENCE for a sibling of the
@@ -134,6 +118,11 @@ class MarkDetection:
detected: bool
confidence: float
region: Region
# The engine's OWN detection object, threaded back to this mark's mask builder so it
# does not re-run the detector (the text-mark footprint is bounded by the ladder
# sweep detection already ran). Opaque here: each mask adapter knows its engine's
# type. Excluded from eq/repr so the uniform result stays comparable across engines.
engine_detection: Any | None = field(default=None, compare=False, repr=False)
@dataclass(frozen=True)
@@ -234,12 +223,42 @@ class KnownMark:
label: str
location: str # usual place, human-readable ("bottom-right")
in_auto: bool # participate in `--mark auto` scanning
# Product family, for the `auto` cross-mark corroboration: a confidently detected
# mark relaxes only OTHER marks of the SAME product (different corners, one product
# -- the Jimeng wordmark + the Jimeng pill). Doubao and Jimeng are BOTH ByteDance but
# distinct products in the SAME bottom-right corner, so they must NOT cross-relax
# (relaxing Doubao on a Jimeng wordmark would spuriously fire Doubao on it).
# REQUIRED, deliberately: a defaulted empty product would let two rows that forgot
# the field corroborate each other and silently bypass the trust gate.
product: str
# Which provenance regime this mark's vendor labels under, or None. "tc260" means
# the vendor stamps the China AIGC label, so a confident detection of it names a
# DIFFERENT TC260 product than the Jimeng pill -- see _keep_pill.
label_regime: str | None
# The sentence `identify` reports when THIS mark is the strongest evidence. None for
# a mark that never names a platform on its own: `gemini` has its own higher-
# confidence sparkle path, and the capture-less pill is too weak to attribute.
platform: str | None
_detect: Callable[..., MarkDetection]
_mask: Callable[..., NDArray[Any] | None]
# Optional physical-feature probe: the mark's OWN measurements its gate needs
# (e.g. the pill's footprint flatness), so the perception pass stays uniform and
# does not special-case any mark. None = the mark's gate needs no extra evidence.
_features: Callable[..., dict[str, float]] | None = None
# Metadata signal names that confirm this mark's vendor, and platform substrings
# that do. Both drive `api.visible_provenance`; empty means nothing confirms it.
provenance_signals: tuple[str, ...] = ()
provenance_platform_tokens: tuple[str, ...] = ()
# TC260 ``ContentProducer`` identities that name THIS mark's vendor -- Unified
# Social Credit Codes as normalized by ``metadata.uscc_of``, plus the bare product
# names a few generators write instead. Here rather than in a separate table
# because a newly registered TC260 mark whose codes were forgotten fails SILENTLY:
# it falls back to relaxing ByteDance's pair on an image carrying the new mark,
# the exact false positive the producer code exists to prevent.
tc260_producer_codes: tuple[str, ...] = ()
# Optional single-pass dual verdict for the arbiter's perception stage (see
# `detect_both`). None = fall back to two `_detect` calls.
_detect_both: Callable[..., tuple[MarkDetection, MarkDetection]] | None = None
def features(self, image: NDArray[Any]) -> dict[str, float]:
"""Physical features the mark reports for the arbiter's gate (empty if none)."""
@@ -252,11 +271,35 @@ class KnownMark:
is trusted when provenance says the vendor is present)."""
return self._detect(image, provenance=provenance)
def localize(self, image: NDArray[Any], *, provenance: bool = False, force: bool = False) -> Localization:
def detect_both(self, image: NDArray[Any]) -> tuple[MarkDetection, MarkDetection]:
"""``(strict, relaxed)`` from ONE pass over the image.
The arbiter's perception stage needs a mark's verdict at BOTH trust levels so it
can pick per mark without re-detecting. ``provenance`` never changes what a
detector computes -- only the threshold it compares against, or (Gemini) whether
a false-positive gate demotes the result afterwards -- so the expensive scan is
shared. A mark without a ``_detect_both`` adapter falls back to two calls."""
if self._detect_both is not None:
return self._detect_both(image)
return self.detect(image, provenance=False), self.detect(image, provenance=True)
def localize(
self,
image: NDArray[Any],
*,
provenance: bool = False,
force: bool = False,
detection: MarkDetection | None = None,
) -> Localization:
"""Detect and build the removal mask in one call. Returns a
:class:`Localization`; ``mask`` is None unless the mark is detected (or
``force`` bypasses detection for the mark's usual footprint)."""
det = self.detect(image, provenance=provenance)
``force`` bypasses detection for the mark's usual footprint).
``detection`` lets a caller that has ALREADY detected this mark at this trust
level hand the result in rather than pay for the scan twice -- the explicit
``visible --mark <name>`` path detects once to report the confidence and would
otherwise re-detect here."""
det = detection if detection is not None else self.detect(image, provenance=provenance)
if not (det.detected or force):
return Localization(det.detected, det.confidence, det.region, None)
# Pass the (provenance-aware) detection to the mask builder so it does NOT
@@ -272,6 +315,7 @@ class KnownMark:
backend: Backend = "auto",
provenance: bool = False,
force: bool = False,
detection: MarkDetection | None = None,
) -> tuple[NDArray[Any], Region | None]:
"""Remove this mark by localize -> fill; returns ``(result, region)`` where
``region`` is the removed mark's bbox, or None if nothing was removed.
@@ -282,7 +326,7 @@ class KnownMark:
usual footprint even without a positive detection (the ``--no-detect`` path).
NB: the CLI does NOT use ``region`` to clear alpha on save -- that zeroing
caused the issue-#30 white box."""
loc = self.localize(image, provenance=provenance, force=force)
loc = self.localize(image, provenance=provenance, force=force, detection=detection)
if loc.mask is None or not loc.mask.any():
return image.copy(), None
return fill(image, loc.mask, backend=backend), (loc.region if loc.detected else None)
@@ -349,55 +393,31 @@ _GEMINI_PROVENANCE_MIN_CONF = 0.42
_engines: dict[str, Any] = {}
# key -> (module basename, class name). Only the NAMES live here: ``import_module``
# runs inside :func:`_engine` on first use, so importing this module -- and with it the
# metadata-only ``identify`` / ``visible_provenance`` path -- never pulls cv2 through
# an engine. Same lazy-import shape as ``_text_mark_engine._rival_config`` and ``fill``.
_ENGINE_CLASS: dict[str, tuple[str, str]] = {
"gemini": ("gemini_engine", "GeminiEngine"),
"doubao": ("doubao_engine", "DoubaoEngine"),
"jimeng": ("jimeng_engine", "JimengEngine"),
"qwen": ("qwen_engine", "QwenEngine"),
"kling": ("kling_engine", "KlingEngine"),
"yuanbao": ("yuanbao_engine", "YuanbaoEngine"),
"samsung": ("samsung_engine", "SamsungEngine"),
"jimeng_pill": ("pill_engine", "PillEngine"),
"runninghub": ("runninghub_engine", "RunningHubEngine"),
"baidu": ("baidu_engine", "BaiduEngine"),
"liblib": ("liblib_engine", "LibLibEngine"),
}
def _engine(key: str) -> Any:
if key not in _engines:
if key == "gemini":
from remove_ai_watermarks.gemini_engine import GeminiEngine
from importlib import import_module
_engines[key] = GeminiEngine()
elif key == "doubao":
from remove_ai_watermarks.doubao_engine import DoubaoEngine
_engines[key] = DoubaoEngine()
elif key == "jimeng":
from remove_ai_watermarks.jimeng_engine import JimengEngine
_engines[key] = JimengEngine()
elif key == "qwen":
from remove_ai_watermarks.qwen_engine import QwenEngine
_engines[key] = QwenEngine()
elif key == "kling":
from remove_ai_watermarks.kling_engine import KlingEngine
_engines[key] = KlingEngine()
elif key == "yuanbao":
from remove_ai_watermarks.yuanbao_engine import YuanbaoEngine
_engines[key] = YuanbaoEngine()
elif key == "samsung":
from remove_ai_watermarks.samsung_engine import SamsungEngine
_engines[key] = SamsungEngine()
elif key == "jimeng_pill":
from remove_ai_watermarks.pill_engine import PillEngine
_engines[key] = PillEngine()
elif key == "runninghub":
from remove_ai_watermarks.runninghub_engine import RunningHubEngine
_engines[key] = RunningHubEngine()
elif key == "baidu":
from remove_ai_watermarks.baidu_engine import BaiduEngine
_engines[key] = BaiduEngine()
elif key == "liblib":
from remove_ai_watermarks.liblib_engine import LibLibEngine
_engines[key] = LibLibEngine()
else: # pragma: no cover - guarded by the registry keys
raise KeyError(key)
module_name, class_name = _ENGINE_CLASS[key] # KeyError(key) for an unknown key
_engines[key] = getattr(import_module(f"remove_ai_watermarks.{module_name}"), class_name)()
return _engines[key]
@@ -462,13 +482,21 @@ def fill(image: NDArray[Any], mask: NDArray[Any], *, backend: Backend = "auto")
# detector on the memory-tight identify host), so detection never builds a mask.
def _gemini_detect(image: NDArray[Any], *, provenance: bool = False) -> MarkDetection:
d = _engine("gemini").detect_watermark(image, trust_provenance=provenance)
def _gemini_wrap(d: Any, *, provenance: bool) -> MarkDetection:
gate = _GEMINI_PROVENANCE_MIN_CONF if provenance else _GEMINI_AUTO_MIN_CONF
detected = bool(d.detected) and d.confidence >= gate
return MarkDetection("gemini", "Google Gemini sparkle", "bottom-right", detected, d.confidence, d.region)
def _gemini_detect(image: NDArray[Any], *, provenance: bool = False) -> MarkDetection:
return _gemini_wrap(_engine("gemini").detect_watermark(image, trust_provenance=provenance), provenance=provenance)
def _gemini_detect_both(image: NDArray[Any]) -> tuple[MarkDetection, MarkDetection]:
strict, relaxed = _engine("gemini").detect_watermark_both(image)
return _gemini_wrap(strict, provenance=False), _gemini_wrap(relaxed, provenance=True)
def _gemini_mask(
image: NDArray[Any], *, force: bool = False, detection: MarkDetection | None = None
) -> NDArray[Any] | None:
@@ -487,26 +515,73 @@ def _gemini_mask(
def _text_mark_detect(key: str, label: str, location: str) -> Callable[..., MarkDetection]:
def detect(image: NDArray[Any], *, provenance: bool = False) -> MarkDetection:
d = _engine(key).detect(image, provenance=provenance)
return MarkDetection(key, label, location, d.detected, d.confidence, d.region)
return MarkDetection(key, label, location, d.detected, d.confidence, d.region, engine_detection=d)
return detect
def _text_mark_detect_both(key: str, label: str, location: str) -> Callable[..., tuple[MarkDetection, MarkDetection]]:
def detect_both(image: NDArray[Any]) -> tuple[MarkDetection, MarkDetection]:
strict, relaxed = _engine(key).detect_both(image)
return (
MarkDetection(
key, label, location, strict.detected, strict.confidence, strict.region, engine_detection=strict
),
MarkDetection(
key, label, location, relaxed.detected, relaxed.confidence, relaxed.region, engine_detection=relaxed
),
)
return detect_both
def _text_mark_mask(key: str) -> Callable[..., NDArray[Any] | None]:
def mask(
image: NDArray[Any], *, force: bool = False, detection: MarkDetection | None = None
) -> NDArray[Any] | None:
# Text masks rebuild the glyph blob template-free (no trust gate to re-apply), so
# the detection is not needed here; accepted for the uniform _mask signature.
del detection
return _engine(key).footprint_mask(image, force=force)
# Thread the engine's OWN detection into the mask builder: the footprint is
# bounded by the ladder sweep the detector already ran, so re-detecting here
# repeated locate + extract_mask + an identical sweep. footprint_mask still
# re-detects when nothing is threaded (a direct or --no-detect caller) and when
# the threaded detection was taken at a relaxed trust level.
return _engine(key).footprint_mask(
image, force=force, detection=detection.engine_detection if detection is not None else None
)
return mask
def _text_mark(key: str, label: str, location: str) -> KnownMark:
"""Build a text-mark registry row from its shared detector and mask adapters."""
return KnownMark(key, label, location, True, _text_mark_detect(key, label, location), _text_mark_mask(key))
def _text_mark(
key: str,
label: str,
location: str,
*,
platform: str,
product: str | None = None,
label_regime: str | None = "tc260",
provenance_signals: tuple[str, ...] = ("aigc",),
tc260_producer_codes: tuple[str, ...] = (),
) -> KnownMark:
"""Build a text-mark registry row from its shared detector and mask adapters.
``product`` defaults to the key (one mark, one product); pass it only when two
marks share a product. ``label_regime`` and ``provenance_signals`` default to the
China-AIGC label because every text mark registered so far except Samsung uses it.
"""
return KnownMark(
key,
label,
location,
True,
product or key,
label_regime,
platform,
_text_mark_detect(key, label, location),
_text_mark_mask(key),
provenance_signals=provenance_signals,
tc260_producer_codes=tc260_producer_codes,
_detect_both=_text_mark_detect_both(key, label, location),
)
# ── Capture-less mark: the Jimeng-basic "AI生成" pill (top-left) ──
@@ -518,6 +593,13 @@ def _pill_detect(image: NDArray[Any], *, provenance: bool = False) -> MarkDetect
return MarkDetection("jimeng_pill", "Jimeng AI生成 pill", "top-left", d.detected, d.confidence, d.region)
def _pill_detect_both(image: NDArray[Any]) -> tuple[MarkDetection, MarkDetection]:
# The pill detector is provenance-independent (`_pill_detect` discards the flag), so
# one call answers both levels. MarkDetection is frozen, so sharing it is safe.
d = _pill_detect(image)
return d, d
def _pill_mask(
image: NDArray[Any], *, force: bool = False, detection: MarkDetection | None = None
) -> NDArray[Any] | None:
@@ -534,19 +616,110 @@ def _pill_features(image: NDArray[Any]) -> dict[str, float]:
_REGISTRY: tuple[KnownMark, ...] = (
KnownMark("gemini", "Google Gemini sparkle", "bottom-right", True, _gemini_detect, _gemini_mask),
_text_mark("doubao", "Doubao 豆包AI生成 text", "bottom-right"),
_text_mark("jimeng", "Jimeng 即梦AI wordmark", "bottom-right"),
_text_mark("qwen", "Qwen 千问AI生成 text", "bottom-right"),
_text_mark("kling", "Kling 可灵AI 3.0 text", "bottom-right"),
_text_mark("yuanbao", "Tencent Yuanbao 元宝 / AI生成 mark", "bottom-right"),
_text_mark("samsung", "Samsung Galaxy AI text", "bottom-left"),
_text_mark("runninghub", "RunningHub AI生成 text", "top-left"),
_text_mark("baidu", "Baidu 百度 AI生成 text", "bottom-right"),
_text_mark("liblib", "LibLibAI wordmark", "bottom-center"),
KnownMark("jimeng_pill", "Jimeng AI生成 pill", "top-left", True, _pill_detect, _pill_mask, _pill_features),
# Gemini is a Google C2PA/SynthID product, not a China-AIGC labeller: label_regime
# is None so it can never act as a TC260 sibling in _keep_pill.
KnownMark(
"gemini",
"Google Gemini sparkle",
"bottom-right",
True,
"gemini",
None,
# No platform sentence: the sparkle has its own higher-confidence
# `_visible_sparkle` path in identify, which names the platform itself.
None,
_gemini_detect,
_gemini_mask,
provenance_platform_tokens=("google", "gemini"),
_detect_both=_gemini_detect_both,
),
_text_mark(
"doubao",
"Doubao 豆包AI生成 text",
"bottom-right",
platform="ByteDance Doubao (visible 豆包AI生成 mark detected)",
tc260_producer_codes=("91110102MACQD9K640", "doubao"),
),
_text_mark(
"jimeng",
"Jimeng 即梦AI wordmark",
"bottom-right",
platform="ByteDance Jimeng / Dreamina (visible 即梦AI mark detected)",
tc260_producer_codes=("9144030008867405X2",),
),
_text_mark(
"qwen",
"Qwen 千问AI生成 text",
"bottom-right",
platform="Alibaba Tongyi Qianwen (visible 千问AI生成 mark detected)",
tc260_producer_codes=("91440101MA9Y9T4H7A",),
),
_text_mark(
"kling",
"Kling 可灵AI 3.0 text",
"bottom-right",
platform="Kuaishou Kling (visible 可灵AI 3.0 mark detected)",
tc260_producer_codes=("91110108335469089C",),
),
_text_mark(
"yuanbao",
"Tencent Yuanbao 元宝 / AI生成 mark",
"bottom-right",
platform="Tencent Yuanbao (visible 元宝 / AI生成 mark detected)",
tc260_producer_codes=("91440300708461136T",),
),
# Samsung Galaxy AI is a device editing marker (samsung_genai), not a TC260 label.
_text_mark(
"samsung",
"Samsung Galaxy AI text",
"bottom-left",
label_regime=None,
provenance_signals=("samsung_genai",),
platform="Samsung Galaxy AI (visible 'Contenuti generati dall'AI' mark detected)",
),
_text_mark(
"runninghub",
"RunningHub AI生成 text",
"top-left",
platform="RunningHub (visible RunningHub AI生成 mark detected)",
tc260_producer_codes=("91340100MAEB4N8H76", "RunningHub"),
),
_text_mark(
"baidu",
"Baidu 百度 AI生成 text",
"bottom-right",
platform="Baidu (visible 百度 AI生成 mark detected)",
tc260_producer_codes=("91110000802100433B",),
),
_text_mark(
"liblib",
"LibLibAI wordmark",
"bottom-center",
platform="LibLibAI (visible LibLibAI mark detected)",
tc260_producer_codes=("91110105MACJ6K1C8A",),
),
# Same product as the Jimeng wordmark -- the one pair that cross-relaxes.
KnownMark(
"jimeng_pill",
"Jimeng AI生成 pill",
"top-left",
True,
"jimeng",
"tc260",
# The capture-less pill is too weak a detector to attribute a platform on its
# own; the Jimeng wordmark is what names ByteDance.
None,
_pill_detect,
_pill_mask,
_pill_features,
_detect_both=_pill_detect_both,
),
)
# Product family per mark, derived from the registry rows so registering a mark is one
# edit. See KnownMark.product for why Doubao and Jimeng must not cross-relax.
_PRODUCT_OF: dict[str, str] = {m.key: m.product for m in _REGISTRY}
def known_marks() -> tuple[KnownMark, ...]:
"""All registered known visible watermarks."""
@@ -606,6 +779,30 @@ def resolve_trust(
return "confirmed" if confirmed else "strict"
def tc260_producer_vendors() -> dict[str, str]:
"""TC260 ``ContentProducer`` identity -> the mark key whose vendor signs with it.
Derived from the registry rows, so registering a TC260 mark and its producer codes
is one edit. A mark registered without codes falls through to
:data:`TC260_FALLBACK_VENDORS`, which relaxes ByteDance's pair -- a silent wrong
answer on an image carrying the new mark, which is why the codes belong on the row
next to ``label_regime`` rather than in a table someone must remember to update.
"""
return {code: mark.key for mark in _REGISTRY for code in mark.tc260_producer_codes}
def _pill_suppressors() -> set[str]:
"""Marks whose detection vetoes the capture-less pill: same label regime as the
pill, different product. Derived so a newly registered TC260 mark cannot be
forgotten here -- which is exactly how LibLibAI ended up missing."""
pill = get_mark("jimeng_pill")
return {
m.key
for m in _REGISTRY
if m.label_regime is not None and m.label_regime == pill.label_regime and m.product != pill.product
}
def _keep_pill(keys: set[str], *, provenance: frozenset[str], footprint_flat: bool) -> bool:
"""Whether to auto-remove the capture-less 'AI生成' pill given the fired marks.
@@ -620,17 +817,18 @@ def _keep_pill(keys: set[str], *, provenance: frozenset[str], footprint_flat: bo
so real flat-scene pills (and harmless flat false fires) are cleaned while the
damaging textured false fires are left untouched.
A Doubao image is TC260 too but is not Jimeng-basic, so the pill never rides on a
Doubao detection; Qwen, Kling, Yuanbao, RunningHub, and Baidu detections likewise
name other products and suppress the pill.
No confirmation at all -> never remove (blocks false fires on non-Jimeng content)."""
if (
"doubao" in keys
or "qwen" in keys
or "kling" in keys
or "yuanbao" in keys
or "runninghub" in keys
or "baidu" in keys
):
Doubao detection; every other TC260 product's mark likewise names a different
product and suppresses the pill.
No confirmation at all -> never remove (blocks false fires on non-Jimeng content).
The suppressor set is DERIVED from the registry (same label regime, different
product), not hand-listed. The hand-written list had drifted: LibLibAI was
registered alongside RunningHub and Baidu but never added to it, so a confident
LibLibAI detection did not veto the pill the way its two siblings did. Marks
outside the TC260 regime (Gemini, Samsung) are deliberately NOT suppressors --
neither can put ``"jimeng"`` into ``provenance``, so neither can enable the arm
they would be vetoing."""
if _pill_suppressors() & keys:
return False
if "jimeng" in keys:
return True
@@ -647,15 +845,17 @@ def _build_candidates(image: NDArray[Any]) -> list[Candidate]:
Each mark is detected at the strict AND the relaxed (``provenance=True``) level so
:func:`decide` can pick per mark without re-running detection; a relaxed gate is
monotonically more permissive, so this reproduces the old strict-then-relax pass
exactly. The loop is uniform -- it knows nothing about any specific mark: each mark
reports its own gate features via :meth:`KnownMark.features` (computed only when the
mark is detected, so a clean image pays nothing extra)."""
exactly. Both levels come from ONE scan per mark (:meth:`KnownMark.detect_both`):
the trust level moves a threshold, never the measurement, so running the detector
twice was doing the expensive half of the work for a second time. The loop is
uniform -- it knows nothing about any specific mark: each mark reports its own gate
features via :meth:`KnownMark.features` (computed only when the mark is detected, so
a clean image pays nothing extra)."""
cands: list[Candidate] = []
for m in _REGISTRY:
if not m.in_auto:
continue
strict = m.detect(image, provenance=False)
relaxed = m.detect(image, provenance=True)
strict, relaxed = m.detect_both(image)
feats = m.features(image) if (strict.detected or relaxed.detected) else {}
cands.append(Candidate(m.key, m.label, strict.detected, relaxed.detected, feats))
return cands
+23 -41
View File
@@ -26,13 +26,16 @@ import logging
from typing import TYPE_CHECKING, Any
from remove_ai_watermarks import _text_mark_engine
from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkDetection, TextMarkEngine
from remove_ai_watermarks._text_mark_engine import (
TextMarkConfig,
TextMarkDetection,
TextMarkEngine,
TextMarkScan,
)
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from pathlib import Path
from numpy.typing import NDArray
WM_WIDTH_FRAC = 0.20
@@ -75,24 +78,12 @@ _CONFIG = TextMarkConfig(
provenance_ncc_factor=1.0,
)
YuanbaoDetection = TextMarkDetection
def _alpha_template() -> NDArray[Any] | None:
"""The bundled Yuanbao alpha template (float [0,1]), or None."""
return _text_mark_engine.load_alpha_template(_CONFIG.asset_name)
def _glyph_silhouette() -> NDArray[Any] | None:
"""Binary two-line Yuanbao silhouette (255 = glyph), or None."""
return _text_mark_engine.glyph_silhouette(_CONFIG.asset_name)
def _template_match_score(box_mask: NDArray[Any], scale_base: int) -> float:
"""TM_CCOEFF_NORMED of the Yuanbao silhouette against ``box_mask``."""
return _text_mark_engine.template_match_score(box_mask, scale_base, _CONFIG)
class YuanbaoEngine(TextMarkEngine):
"""Detect and localize the bottom-right Yuanbao mark."""
@@ -102,37 +93,28 @@ class YuanbaoEngine(TextMarkEngine):
def __init__(self) -> None:
super().__init__(_CONFIG)
def detect(self, image: NDArray[Any] | None, *, provenance: bool = False) -> TextMarkDetection:
if image is None or not image.size:
return TextMarkDetection()
detection = super().detect(image, provenance=provenance)
if not detection.detected:
return detection
location = self.locate(image)
_, box = self._contrast_best(image, location)
def _post_gate(self, det: TextMarkDetection, scan: TextMarkScan) -> TextMarkDetection:
"""Demote a match that does not hug the bottom-right corner.
A shared post-gate rather than a ``detect`` override, so the single-pass
perception path (``detect_both``) cannot skip it.
"""
if not det.detected or scan.loc is None:
return det
box = det.match_box # the sweep the scan already ran on this same loc
if box is None:
detection.detected = False
return detection
h, w = image.shape[:2]
det.detected = False
return det
h, w = scan.frame
base = min(h, w)
right = (w - (location.x + box[2] + 1)) / base
bottom = (h - (location.y + box[3] + 1)) / base
right = (w - (scan.loc.x + box[2] + 1)) / base
bottom = (h - (scan.loc.y + box[3] + 1)) / base
if not (0 <= right <= self._ANCHOR_MAX_RIGHT and 0 <= bottom <= self._ANCHOR_MAX_BOTTOM):
logger.debug(
"Yuanbao detect: score %.3f but match off-anchor (right=%.3f bottom=%.3f); demoting.",
detection.confidence,
det.confidence,
right,
bottom,
)
detection.detected = False
return detection
def load_image_bgr(path: str | Path) -> NDArray[Any]:
"""Read an image as a BGR ndarray."""
from remove_ai_watermarks import image_io
image = image_io.imread(path)
if image is None:
raise FileNotFoundError(f"Failed to read image: {path}")
return image
det.detected = False
return det
-126
View File
@@ -1,126 +0,0 @@
"""Tests for the standalone structural AI-generation scorer."""
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
import ai_score
def _complete_record() -> dict[str, Any]:
return {
"noise": {"noise_std": 1.0, "noise_kurtosis": 2.0},
"fft": {
"cfa_peak": 3.0,
"cfa_peaks": [2.5, 3.0],
"fft_band_energy": list(range(8)),
},
"ela": {"ela_mean": 4.0, "ela_p95": 5.0},
"gradient": {"laplacian_var": 6.0, "gradient_hist": list(range(10))},
"color": {
"saturation_mean": 0.5,
"value_mean": 0.75,
"color_hist_4x4x4": list(range(64)),
},
"dct": {
"benford_mad": 0.1,
"dct_ac_hist": [[1] * 21 for _ in range(8)],
},
"jpeg_forensics": {
"subsampling": "4:4:4",
"progressive": True,
"quant_tables": {
"0": list(range(1, 65)),
"1": list(range(65, 129)),
},
"huffman_tables_hex": ["00ff", "abcd12"],
"scan_count": 10,
"restart_interval": 4,
"precision_bits": 8,
"adobe_transform": 1,
"jfif": {"version": "1.1"},
},
"pil": {"width": 2000, "height": 1000},
"content_format": "jpeg",
}
def test_v1_feature_schema_is_fixed_for_sparse_records() -> None:
assert len(ai_score.feature_names("v1")) == 97
assert len(ai_score.features_of({}, schema="v1")) == 97
def test_v2_feature_schema_includes_existing_forensic_data() -> None:
record = _complete_record()
names = ai_score.feature_names("v2")
values = ai_score.features_of(record, schema="v2")
by_name = dict(zip(names, values, strict=True))
assert len(names) == len(values) == 406
assert by_name["cfa_peak_0"] == 2.5
assert by_name["cfa_peak_1"] == 3.0
assert by_name["dct_ac_0_0"] == 1 / 21
assert by_name["dct_ac_7_20"] == 1 / 21
assert by_name["jpeg_quant_0_0"] == 1.0
assert by_name["jpeg_quant_1_63"] == 128.0
assert by_name["jpeg_quant_table_count"] == 2.0
assert by_name["jpeg_huffman_table_count"] == 2.0
assert by_name["jpeg_huffman_total_bytes"] == 5.0
assert by_name["jpeg_scan_count"] == 10.0
assert by_name["jpeg_jfif_present"] == 1.0
assert by_name["format_webp"] == 0.0
assert by_name["format_isobmff"] == 0.0
assert by_name["format_other"] == 0.0
def test_v2_feature_schema_is_fixed_when_forensics_are_missing() -> None:
names = ai_score.feature_names("v2")
values = ai_score.features_of({}, schema="v2")
assert len(names) == len(values) == 406
assert np.isnan(values[names.index("dct_ac_0_0")])
assert np.isnan(values[names.index("jpeg_quant_0_0")])
assert np.isnan(values[names.index("jpeg_scan_count")])
def test_grouped_stratified_split_keeps_hashes_on_one_side() -> None:
labels = np.asarray([1, 1, 1, 0, 0, 0, 1, 0])
hashes = np.asarray(["a", "a", "b", "c", "c", "d", "e", "f"])
train, test = ai_score.grouped_stratified_split(labels, hashes, test_size=0.5, random_state=7)
assert set(hashes[train]).isdisjoint(set(hashes[test]))
assert set(labels[train]) == {0, 1}
assert set(labels[test]) == {0, 1}
assert sorted(np.concatenate([train, test]).tolist()) == list(range(len(labels)))
def test_grouped_stratified_split_rejects_conflicting_labels() -> None:
labels = np.asarray([0, 1, 0, 1])
hashes = np.asarray(["same", "same", "negative", "positive"])
with np.testing.assert_raises_regex(ValueError, "conflicting labels"):
ai_score.grouped_stratified_split(labels, hashes)
def test_temporal_holdout_excludes_hashes_seen_during_training() -> None:
dates = np.asarray(["2026-01-01", "2026-01-01", "2026-01-02", "2026-01-03", "2026-01-04", "2026-01-04"])
hashes = np.asarray(["repeated", "old", "middle", "new-a", "repeated", "new-b"])
train, test, cutoff = ai_score.temporal_holdout_split(dates, hashes, train_fraction=0.5)
assert cutoff == "2026-01-03"
assert set(hashes[train]).isdisjoint(set(hashes[test]))
assert set(hashes[test]) == {"new-a", "new-b"}
def test_legacy_model_bundle_defaults_to_v1_schema() -> None:
assert ai_score.model_schema({}) == "v1"
assert ai_score.model_schema({"feature_schema": "v2"}) == "v2"
+433 -3
View File
@@ -91,10 +91,59 @@ class TestNoOpPreservesOriginal:
class TestVisibleProvenance:
@pytest.mark.skipif(not DOUBAO.exists(), reason="doubao sample not present")
def test_doubao_tc260_maps_to_bytedance(self):
def test_doubao_tc260_maps_to_the_producer_it_names(self):
"""The TC260 producer identifies the vendor, so only Doubao is relaxed.
This used to relax Doubao AND Jimeng on every China-AIGC image, because the
label alone does not say which vendor made it. Its ``ContentProducer`` does.
"""
prov = raiw.visible_provenance(DOUBAO)
# TC260 label -> ByteDance family (both doubao and jimeng)
assert {"doubao", "jimeng"} <= prov
assert "doubao" in prov
assert "jimeng" not in prov
def test_unmapped_tc260_producer_falls_back_to_the_bytedance_pair(self, monkeypatch, tmp_path):
"""An unrecognized producer must not lose the relaxation entirely: the label is
still evidence that some China-AIGC vendor made the image."""
from types import SimpleNamespace
from remove_ai_watermarks import identify, metadata
monkeypatch.setattr(
identify,
"identify",
lambda *a, **k: SimpleNamespace(platform=None, signals=[SimpleNamespace(name="aigc")]),
)
monkeypatch.setattr(metadata, "aigc_label", lambda _p: {"ContentProducer": "0011999999999999999999999"})
assert raiw.visible_provenance(tmp_path / "x.png") == frozenset({"doubao", "jimeng"})
def test_known_tc260_producer_names_a_single_vendor(self, monkeypatch, tmp_path):
from types import SimpleNamespace
from remove_ai_watermarks import identify, metadata
monkeypatch.setattr(
identify,
"identify",
lambda *a, **k: SimpleNamespace(platform=None, signals=[SimpleNamespace(name="aigc")]),
)
# 001 + 1 + USCC(18) + 5-digit product suffix, the Qwen entity.
monkeypatch.setattr(metadata, "aigc_label", lambda _p: {"ContentProducer": "001191440101MA9Y9T4H7A00001"})
assert raiw.visible_provenance(tmp_path / "x.png") == frozenset({"qwen"})
def test_every_mapped_producer_names_a_registered_mark(self):
"""The table drives the arbiter's provenance set, so a typo'd key would relax
nothing and fail silently. Checked here, not at import: importing the registry
from _internal.constants would drag it into every metadata-only path."""
from remove_ai_watermarks._internal.constants import TC260_FALLBACK_VENDORS
from remove_ai_watermarks.watermark_registry import known_marks, mark_keys, tc260_producer_vendors
keys = set(mark_keys())
assert set(tc260_producer_vendors().values()) <= keys
assert keys >= TC260_FALLBACK_VENDORS
# Every TC260 mark should name its producer, or it silently falls back to the
# ByteDance pair on an image carrying that mark.
unmapped = {m.key for m in known_marks() if m.label_regime == "tc260" and not m.tc260_producer_codes}
assert unmapped == {"jimeng_pill"}, f"TC260 marks with no producer code: {unmapped}"
@pytest.mark.skipif(not CHATGPT.exists(), reason="chatgpt sample not present")
def test_openai_image_has_no_visible_vendor(self):
@@ -144,3 +193,384 @@ class TestRemoveVisibleOutputPath:
out = tmp_path / "sub" / "out.png"
raiw.remove_visible(str(src), str(out), backend="cv2")
assert out.exists()
class TestInvisibleOptionsMirrorTheEngine:
"""``InvisibleOptions`` forwards to ``InvisibleEngine`` and promises every NAME and
default mirrors it. Compare the signatures field by field rather than pinning the
values we happen to know about, so the next field added on one side and not the
other fails here. The comparison deliberately has no exception table: a field that
needs one is a field that belongs somewhere else, which is why ``force`` is a
parameter of ``remove_all``. See the incident record in ``docs/module-internals.md``."""
def test_every_default_matches_the_engine(self):
import dataclasses
import inspect
from remove_ai_watermarks.invisible_engine import InvisibleEngine
engine = {
name: p.default
for method in (InvisibleEngine.__init__, InvisibleEngine.remove_watermark)
for name, p in inspect.signature(method).parameters.items()
if p.default is not inspect.Parameter.empty
}
options = {f.name: f.default for f in dataclasses.fields(api.InvisibleOptions)}
assert options == {name: engine.get(name, "<not an engine parameter>") for name in options}
@pytest.mark.skipif(not CHATGPT.exists(), reason="sample image not present")
def test_every_field_arrives_at_the_engine_with_the_caller_s_value(self, monkeypatch, tmp_path):
"""A defaults comparison is not a forwarding test. `_run_invisible` hands each
field to one of TWO engine callables by hand, and a hardcoded literal there is
invisible to the mirror above -- `controlnet_conditioning_scale` shipped that way
and the whole suite stayed green. Drive the real seam with every field set OFF
its default and assert the caller's value arrives, whichever callable takes it."""
import dataclasses
from remove_ai_watermarks import invisible_engine
# Every value differs from the default, so a hardcoded default cannot pass.
opts = api.InvisibleOptions(
strength=0.42,
pipeline="sdxl-zimage",
seed=7,
hf_token="token",
humanize=0.3,
unsharp=0.2,
adaptive_polish=True,
max_resolution=1536,
controlnet_conditioning_scale=0.65,
cpu_offload=True,
tile=True,
tile_size=768,
tile_overlap=64,
)
seen: dict[str, object] = {}
class FakeEngine:
def __init__(self, **kwargs):
seen.update(kwargs)
def remove_watermark(self, **kwargs):
seen.update(kwargs)
monkeypatch.setattr(invisible_engine, "is_available", lambda: True)
monkeypatch.setattr(invisible_engine, "InvisibleEngine", FakeEngine)
api._run_invisible(
CHATGPT,
CHATGPT,
tmp_path / "out.png",
opts,
None,
lambda _stage, _detail: None,
api._SourceEvidence(CHATGPT),
True,
)
missing = {f.name: getattr(opts, f.name) for f in dataclasses.fields(opts) if f.name not in seen}
assert not missing, f"never forwarded to the engine: {missing}"
wrong = {
f.name: (getattr(opts, f.name), seen[f.name])
for f in dataclasses.fields(opts)
if seen[f.name] != getattr(opts, f.name)
}
assert not wrong, f"forwarded a value the caller did not pass (want, got): {wrong}"
class TestRemoveAllLibrary:
"""The three-stage pipeline is a library function, not CLI-only.
It used to live only in ``cli.py``, written twice (once for ``all``, once for
``batch``) with divergent behavior, so no library caller could reach it.
"""
def _patched_engine(self, monkeypatch, calls: list):
from remove_ai_watermarks import invisible_engine
class FakeEngine:
def remove_watermark(self, *args, **kwargs):
calls.append(kwargs.get("image_path") or args[0])
monkeypatch.setattr(invisible_engine, "is_available", lambda: True)
return FakeEngine()
@pytest.mark.skipif(not DOUBAO.exists(), reason="doubao sample not present")
def test_runs_all_three_stages_and_reports_each(self, monkeypatch, tmp_path):
from remove_ai_watermarks import api
calls: list = []
engine = self._patched_engine(monkeypatch, calls)
monkeypatch.setattr(api._SourceEvidence, "has_invisible_target", lambda _self: True)
out = tmp_path / "clean.png"
events: list[tuple[str, str]] = []
result = api.remove_all(DOUBAO, out, backend="cv2", engine=engine, progress=lambda s, d: events.append((s, d)))
assert out.exists()
assert result.invisible == "removed"
assert result.visible_label is not None # the Doubao mark fired
assert calls, "the invisible engine was never invoked"
# Progress is (stage, stable-token), never prose the caller has to parse back.
assert any(stage == "visible" for stage, _ in events)
assert ("invisible", "removed") in events
assert ("metadata", "stripped") in events
@pytest.mark.skipif(not DOUBAO.exists(), reason="doubao sample not present")
def test_no_signal_skips_the_scrub_but_still_writes(self, monkeypatch, tmp_path):
from remove_ai_watermarks import api
calls: list = []
engine = self._patched_engine(monkeypatch, calls)
monkeypatch.setattr(api._SourceEvidence, "has_invisible_target", lambda _self: False)
out = tmp_path / "clean.png"
result = api.remove_all(DOUBAO, out, backend="cv2", engine=engine)
assert result.invisible == "no-signal"
assert not calls
assert out.exists() # a deliberate skip is still a successful run
@pytest.mark.skipif(not DOUBAO.exists(), reason="doubao sample not present")
def test_missing_gpu_extra_is_reported_not_raised(self, monkeypatch, tmp_path):
from remove_ai_watermarks import api, invisible_engine
monkeypatch.setattr(invisible_engine, "is_available", lambda: False)
out = tmp_path / "clean.png"
result = api.remove_all(DOUBAO, out, backend="cv2")
assert result.invisible == "unavailable"
assert out.exists() # it LOOKS processed -- which is why the caller must warn
@pytest.mark.skipif(not DOUBAO.exists(), reason="doubao sample not present")
def test_incomplete_strip_leaves_no_output_file(self, monkeypatch, tmp_path):
"""The contract the CLI depends on: an AI-readable output plus a non-zero exit
is worse than no output at all, so the raise happens BEFORE the final write."""
from remove_ai_watermarks import api, invisible_engine, metadata
monkeypatch.setattr(invisible_engine, "is_available", lambda: False)
monkeypatch.setattr(metadata, "strip_and_verify", lambda src, dst: (dst, {"c2pa"}))
out = tmp_path / "clean.png"
with pytest.raises(api.MetadataStripIncomplete, match="c2pa"):
api.remove_all(DOUBAO, out, backend="cv2")
assert not out.exists()
def test_unreadable_source_raises_valueerror(self, tmp_path):
from remove_ai_watermarks import api
with pytest.raises(ValueError, match="Could not read image"):
api.remove_all(tmp_path / "nope.png", tmp_path / "out.png")
@pytest.mark.skipif(not DOUBAO.exists(), reason="doubao sample not present")
def test_stages_through_the_system_temp_dir_not_the_output_dir(self, monkeypatch, tmp_path):
"""Staging next to the output would defeat the point: the user must not see a
partial file there during a long model download."""
from remove_ai_watermarks import api, invisible_engine
monkeypatch.setattr(invisible_engine, "is_available", lambda: False)
out_dir = tmp_path / "out"
out_dir.mkdir()
seen: list[set[str]] = []
real = api.remove_all
def spy(*args, **kwargs):
result = real(*args, **kwargs)
seen.append({p.name for p in out_dir.iterdir()})
return result
spy(DOUBAO, out_dir / "clean.png", backend="cv2")
assert seen == [{"clean.png"}], "an intermediate was left in the output directory"
class TestRemoveBatchLibrary:
@pytest.mark.skipif(not DOUBAO.exists(), reason="doubao sample not present")
def test_visible_mode_writes_every_image(self, tmp_path):
import shutil
from remove_ai_watermarks import api
src = tmp_path / "in"
src.mkdir()
for i in range(3):
shutil.copyfile(DOUBAO, src / f"img{i}.png")
out = tmp_path / "out"
summary = api.remove_batch(src, out, mode="visible", backend="cv2")
assert summary.processed == 3
assert summary.failed == 0
assert sorted(p.name for p in out.iterdir()) == ["img0.png", "img1.png", "img2.png"]
def test_one_bad_file_does_not_abandon_the_rest(self, tmp_path):
from remove_ai_watermarks import api
src = tmp_path / "in"
src.mkdir()
(src / "broken.png").write_bytes(b"not a png at all")
image = np.full((64, 64, 3), 120, np.uint8)
raiw.remove_visible(image) # sanity: the registry is importable here
from remove_ai_watermarks import image_io
image_io.imwrite(src / "good.png", image)
out = tmp_path / "out"
summary = api.remove_batch(src, out, mode="visible", backend="cv2")
assert summary.processed == 1
assert summary.failed == 1
assert [p.name for p, _ in summary.errors] == ["broken.png"]
assert (out / "good.png").exists()
def test_a_failed_write_is_counted_not_swallowed(self, tmp_path, monkeypatch):
"""Tier E: a read-only output directory once produced zero files and exit 0."""
from remove_ai_watermarks import api, image_io
src = tmp_path / "in"
src.mkdir()
image_io.imwrite(src / "a.png", np.full((64, 64, 3), 120, np.uint8))
monkeypatch.setattr(image_io, "write_bgr_with_alpha", lambda *a, **k: False)
summary = api.remove_batch(src, tmp_path / "out", mode="visible", backend="cv2")
assert summary.processed == 0
assert summary.failed == 1
@pytest.mark.parametrize("mode", ["all", "invisible"])
@pytest.mark.parametrize(("force", "expected"), [(True, "removed"), (False, "no-signal")])
def test_force_reaches_the_scrub_gate_in_every_scrubbing_mode(self, monkeypatch, tmp_path, mode, force, expected):
"""``force`` reaches the gate through a DIFFERENT seam per mode: ``all`` re-enters
``remove_all``, ``invisible`` calls ``_run_invisible`` directly. Only the second
was guarded, and pinning the first to False passed the whole suite while every
output kept its watermark and the run still reported the files as processed."""
from remove_ai_watermarks import api, image_io, invisible_engine
src = tmp_path / "in"
src.mkdir()
for i in range(2):
image_io.imwrite(src / f"img{i}.png", np.full((64, 64, 3), 120, np.uint8))
scrubbed: list[Path] = []
class FakeEngine:
def remove_watermark(self, **kwargs):
scrubbed.append(kwargs["image_path"])
image_io.imwrite(kwargs["output_path"], np.full((64, 64, 3), 120, np.uint8))
monkeypatch.setattr(invisible_engine, "is_available", lambda: True)
events: list[tuple[str, str]] = []
summary = api.remove_batch(
src,
tmp_path / "out",
mode=mode,
backend="cv2",
force=force,
engine=FakeEngine(),
progress=lambda _p, stage, detail: events.append((stage, detail)),
)
assert summary.processed == 2
assert ("invisible", expected) in events
assert len(scrubbed) == (2 if force else 0)
class TestSourceEvidenceHolder:
"""One metadata extraction per source file, per call.
``remove_all`` asks the same file two provenance questions (which vendor is
confirmed, and is there an invisible target); both start from the same extraction.
"""
@pytest.mark.skipif(not DOUBAO.exists(), reason="doubao sample not present")
def test_remove_all_extracts_evidence_once(self, monkeypatch, tmp_path):
import shutil
from remove_ai_watermarks import api, identify, invisible_engine
class Fake:
def remove_watermark(self, *args, **kwargs):
pass
source = tmp_path / "in.png"
shutil.copyfile(DOUBAO, source)
calls: list[int] = []
real = identify.extract_provenance_evidence
monkeypatch.setattr(identify, "extract_provenance_evidence", lambda p: (calls.append(1), real(p))[1])
monkeypatch.setattr(invisible_engine, "is_available", lambda: True)
api.remove_all(source, tmp_path / "out.png", backend="cv2", engine=Fake())
assert len(calls) == 1
@pytest.mark.skipif(not DOUBAO.exists(), reason="doubao sample not present")
def test_holder_agrees_with_the_standalone_functions(self):
from remove_ai_watermarks import api, identify
holder = api._SourceEvidence(DOUBAO)
assert holder.visible_provenance() == api.visible_provenance(DOUBAO)
assert holder.has_invisible_target() == identify.has_invisible_target(DOUBAO)
def test_extraction_failure_fails_safe_in_both_directions(self, monkeypatch, tmp_path):
"""No provenance means no relaxation; an unknown invisible target means SCRUB.
Leaving a watermark on a paid removal is worse than over-regenerating."""
from remove_ai_watermarks import api, identify
def boom(_path):
raise OSError("extract exploded")
monkeypatch.setattr(identify, "extract_provenance_evidence", boom)
holder = api._SourceEvidence(tmp_path / "x.png")
assert holder.visible_provenance() == frozenset()
assert holder.has_invisible_target() is True
@pytest.mark.skipif(not DOUBAO.exists(), reason="doubao sample not present")
def test_a_verdict_failure_also_fails_safe(self, monkeypatch):
"""The suppress must span the VERDICT and the mapping, not just the extraction:
a raise here used to escape as a traceback where the old code returned empty."""
from remove_ai_watermarks import api, identify
def boom(*args, **kwargs):
raise RuntimeError("verdict exploded")
monkeypatch.setattr(identify, "identify_from_evidence", boom)
holder = api._SourceEvidence(DOUBAO)
assert holder.visible_provenance() == frozenset()
assert holder.has_invisible_target() is True
class TestBatchProgressIsStructured:
"""`remove_batch` emits exactly one terminal event per image, in every mode.
Regression: the CLI advanced its progress bar by string-matching a stage line that
only ``mode="all"`` ever emitted, so a `visible` or `metadata` batch sat at 0% for
the whole run and jumped to 100% at the end.
"""
def _run(self, tmp_path, mode: str) -> list[tuple[str, str, str]]:
from remove_ai_watermarks import api, image_io
src = tmp_path / "in"
src.mkdir()
for i in range(3):
image_io.imwrite(src / f"img{i}.png", np.full((64, 64, 3), 120, np.uint8))
events: list[tuple[str, str, str]] = []
api.remove_batch(
src,
tmp_path / "out",
mode=mode, # type: ignore[arg-type]
backend="cv2",
progress=lambda p, stage, detail: events.append((p.name, stage, detail)),
)
return events
@pytest.mark.parametrize("mode", ["visible", "metadata"])
def test_one_terminal_event_per_image(self, tmp_path, mode):
events = self._run(tmp_path, mode)
terminal = [name for name, stage, _ in events if stage in ("done", "failed")]
assert sorted(terminal) == ["img0.png", "img1.png", "img2.png"]
def test_progress_is_a_token_not_prose(self, tmp_path):
"""The CLI keys console text off these tokens; free text would break it."""
events = self._run(tmp_path, "visible")
assert {stage for _, stage, _ in events} <= {"visible", "invisible", "metadata", "done", "failed"}
+15
View File
@@ -119,6 +119,21 @@ class TestDetectAndMask:
img = np.full((1024, 1024, 3), 100, np.uint8)
assert eng.footprint_mask(img) is None
def test_force_masks_the_whole_locate_box_on_a_clean_frame(self):
"""``force`` takes priority over detection for this mark, unlike the base
policy: a --no-detect caller named the mark, so the honest footprint is the
whole geometry box even though nothing was detected."""
eng = BaiduEngine()
img = np.full((1024, 1024, 3), 100, np.uint8)
mask = eng.footprint_mask(img, force=True)
assert mask is not None
bx, by, bw, bh = eng.locate(img).bbox
ys, xs = np.where(mask > 0)
assert xs.min() <= bx
assert xs.max() >= bx + bw - 1
assert ys.min() <= by
assert ys.max() >= by + bh - 1
class TestPillInteraction:
def test_confident_baidu_detection_suppresses_the_jimeng_pill(self):
+46 -1
View File
@@ -485,6 +485,26 @@ class TestAllCommand:
assert result.exit_code == 0, result.output
assert mock_cls.call_args.kwargs["cpu_offload"] is True
def test_all_controlnet_scale_flows_to_engine(self, runner, sample_png, tmp_path):
"""The click option is `--controlnet-scale`, the engine parameter is
`controlnet_conditioning_scale`, and `InvisibleOptions` now uses the engine's
spelling so the translation happens once. Pin the value, not just the name:
hardcoding the constant in `_run_invisible` passed the whole suite before."""
mock_cls, _mock_engine = _mock_invisible_engine()
output = tmp_path / "clean.png"
with (
patch("remove_ai_watermarks.cli.InvisibleEngine", mock_cls, create=True),
patch("remove_ai_watermarks.invisible_engine.InvisibleEngine", mock_cls),
patch("remove_ai_watermarks.invisible_engine.is_available", return_value=True),
):
result = runner.invoke(
main,
["all", str(sample_png), "-o", str(output), "--controlnet-scale", "0.65", "--force"],
)
assert result.exit_code == 0, result.output
assert mock_cls.call_args.kwargs["controlnet_conditioning_scale"] == pytest.approx(0.65)
def test_all_nonexistent_file(self, runner):
result = runner.invoke(main, ["all", "/nonexistent/file.png"])
assert result.exit_code != 0
@@ -552,7 +572,9 @@ class TestAllCommand:
output = tmp_path / "clean.png"
with (
patch("remove_ai_watermarks.invisible_engine.is_available", return_value=True),
patch("remove_ai_watermarks.cli._should_skip_invisible_scrub", return_value=True),
# The no-signal gate lives in the library now; patch it there, not on the
# CLI module, or the diffusion stage runs for real.
patch("remove_ai_watermarks.api._SourceEvidence.has_invisible_target", return_value=False),
patch(
"remove_ai_watermarks.metadata.strip_and_verify",
return_value=(tmp_path / "intermediate.png", {"c2pa": True}),
@@ -564,6 +586,9 @@ class TestAllCommand:
assert "metadata" in result.output.lower()
assert "survived" in result.output.lower()
assert "AI metadata stripped" not in result.output
# An incomplete strip must leave NOTHING on disk: an AI-readable output plus a
# non-zero exit is the failure mode the pre-write raise exists to prevent.
assert not output.exists()
def test_all_preserves_rgba_across_invisible_step(self, runner, tmp_path):
"""Regression: ``all`` must keep transparency even when the invisible
@@ -797,6 +822,26 @@ class TestBatchCommand:
assert result.exit_code == 0, result.output
assert mock_cls.call_args.kwargs["cpu_offload"] is True
@pytest.mark.parametrize("mode", ["invisible", "all"])
def test_batch_controlnet_scale_flows_to_the_cached_engine(self, runner, tmp_path, mode):
"""`_batch_engine` is a SECOND engine-construction site: `all` builds its engine
inside `_run_invisible`, batch prebuilds one for the whole directory. Pinning the
value on the `all` path alone leaves this one free to hardcode a constant."""
input_dir = _make_batch_dir(tmp_path)
output_dir = tmp_path / "output"
mock_cls, _mock_engine = _mock_invisible_engine()
args = ["batch", str(input_dir), "-o", str(output_dir), "--mode", mode]
with (
patch("remove_ai_watermarks.cli.InvisibleEngine", mock_cls, create=True),
patch("remove_ai_watermarks.invisible_engine.InvisibleEngine", mock_cls),
patch("remove_ai_watermarks.cli.invisible_available", return_value=True, create=True),
patch("remove_ai_watermarks.invisible_engine.is_available", return_value=True),
):
result = runner.invoke(main, [*args, "--controlnet-scale", "0.65", "--force"])
assert result.exit_code == 0, result.output
assert mock_cls.call_args.kwargs["controlnet_conditioning_scale"] == pytest.approx(0.65)
def test_batch_invisible_skips_no_signal_and_copies_through(self, runner, tmp_path):
"""P0#5: batch invisible mode skips the scrub on signal-less images (no
--force) and copies the input through, so the output dir is complete with the
+1 -1
View File
@@ -18,8 +18,8 @@ from remove_ai_watermarks.doubao_engine import (
_alpha_template,
_glyph_silhouette,
_template_match_score,
load_image_bgr,
)
from remove_ai_watermarks.image_io import load_image_bgr
SAMPLE = Path(__file__).resolve().parents[1] / "data" / "fixtures" / "provenance" / "doubao-1.png"
+236
View File
@@ -0,0 +1,236 @@
"""Tests for the metadata-only forensic collector."""
from __future__ import annotations
import base64
import json
import zlib
from typing import TYPE_CHECKING
import piexif
import pytest
from PIL import Image
from PIL.PngImagePlugin import PngInfo
from remove_ai_watermarks.forensic_metadata import (
FORENSIC_METADATA_RECORD_TYPE,
FORENSIC_METADATA_SCHEMA_VERSION,
SUPPORTED_EXTENSIONS,
_b64,
_decode_exif_value,
_jpeg_forensics_bytes,
_png_text_decode,
_safe_str,
apple_live_photo_id,
collect_forensic_metadata,
read_full_exif,
read_isobmff_inventory,
read_isobmff_provenance_path,
read_jpeg_segments,
read_pil_info,
read_png_chunks,
read_png_late_metadata_path,
read_webp_chunks,
sha256_of,
sniff_format,
xattr_quarantine,
xattr_where_from,
)
if TYPE_CHECKING:
from pathlib import Path
def _jpeg(path: Path) -> Path:
Image.new("RGB", (48, 32), (20, 80, 160)).save(path, "JPEG", quality=87)
return path
def _png_chunk(chunk_type: bytes, payload: bytes) -> bytes:
crc = zlib.crc32(chunk_type + payload).to_bytes(4, "big")
return len(payload).to_bytes(4, "big") + chunk_type + payload + crc
def test_supported_extensions_are_media_not_documents():
assert {".jpg", ".png", ".webp", ".heic", ".mp4"}.issubset(SUPPORTED_EXTENSIONS)
assert ".pdf" not in SUPPORTED_EXTENSIONS
def test_json_helpers_and_format_sniffer():
class BadString:
def __str__(self):
raise RuntimeError("no string")
assert _safe_str("ok") == "ok"
assert "BadString" in _safe_str(BadString())
assert _b64(b"abc") == base64.b64encode(b"abc").decode("ascii")
assert _b64(b"x" * 20, cap=4) == "eHh4eA==...TRUNCATED(20 bytes total)"
assert _decode_exif_value(b"ascii") == "ascii"
assert _decode_exif_value(b"\xff").startswith("hex:")
assert _decode_exif_value((1, b"two")) == [1, "two"]
assert sniff_format(b"\x89PNG\r\n\x1a\n") == "png"
assert sniff_format(b"\xff\xd8\xff\xe0") == "jpeg"
assert sniff_format(b"RIFF....WEBP") == "webp"
assert sniff_format(b"....ftypheic").startswith("isobmff:")
assert sniff_format(b"unknown").startswith("unknown:")
def test_png_text_and_container_metadata_are_preserved(tmp_path: Path):
info = PngInfo()
info.add_text("parameters", "Steps: 20, Model: SDXL", zip=True)
path = tmp_path / "workflow.png"
Image.new("RGB", (32, 32)).save(path, pnginfo=info)
trailer = b'<TC260:AIGC>{"Label":"1"}</TC260:AIGC>'
path.write_bytes(path.read_bytes() + trailer)
record = collect_forensic_metadata(path)
assert record["schema_version"] == FORENSIC_METADATA_SCHEMA_VERSION == 1
assert record["record_type"] == FORENSIC_METADATA_RECORD_TYPE == "forensic_metadata"
assert record["content_format"] == "png"
assert any(chunk.get("type") == "zTXt" for chunk in record["png_chunks"])
assert record["png_post_iend_bytes"] == len(trailer)
assert base64.b64decode(record["png_post_iend_base64"]) == trailer
assert "Steps: 20" in json.dumps(record)
assert json.loads(json.dumps(record, allow_nan=False)) == record
def test_png_text_decoders_and_direct_chunk_reader(tmp_path: Path):
assert "hello" in _png_text_decode("tEXt", b"key\x00hello")
compressed = b"prompt\x00\x00" + zlib.compress(b"workflow")
assert "workflow" in _png_text_decode("zTXt", compressed)
assert "value" in _png_text_decode("iTXt", b"key\x00\x00\x00\x00\x00value")
path = tmp_path / "plain.png"
Image.new("RGB", (8, 8)).save(path)
chunks, trailer = read_png_chunks(path.read_bytes())
assert chunks[0]["type"] == "IHDR"
assert trailer == b""
def test_jpeg_exif_segments_encoder_and_trailer(tmp_path: Path):
path = tmp_path / "camera.jpg"
exif = piexif.dump(
{
"0th": {
piexif.ImageIFD.Make: b"Camera Corp",
piexif.ImageIFD.Software: b"Camera Firmware",
},
"Exif": {},
"GPS": {},
"1st": {},
}
)
Image.new("RGB", (64, 48)).save(path, "JPEG", exif=exif, quality=82)
trailer = b'PhotoEditor_Re_Edit_Data{"genAIType":1}'
path.write_bytes(path.read_bytes() + trailer)
record = collect_forensic_metadata(path)
assert record["exif"]["0th"]["Make"] == "Camera Corp"
assert record["jpeg"]["post_eoi_bytes"] == len(trailer)
assert base64.b64decode(record["jpeg"]["post_eoi_base64"]) == trailer
assert record["jpeg_forensics"]["quant_tables"]
assert sha256_of(path.read_bytes()) == record["sha256"]
def test_direct_exif_pil_and_jpeg_readers(tmp_path: Path):
path = _jpeg(tmp_path / "plain.jpg")
exif, thumbnail = read_full_exif(path)
pil, iptc, exif_blob = read_pil_info(path)
segments = read_jpeg_segments(path.read_bytes())
assert isinstance(exif, dict)
assert thumbnail is None
assert pil["width"] == 48
assert pil["height"] == 32
assert isinstance(iptc, dict)
assert exif_blob is None or isinstance(exif_blob, bytes)
assert isinstance(segments["segments"], list)
assert _jpeg_forensics_bytes(path.read_bytes())["quant_tables"]
assert _jpeg_forensics_bytes(b"not a jpeg") == {}
def test_webp_inventory_keeps_metadata_but_not_frame_pixels(tmp_path: Path):
path = tmp_path / "image.webp"
xmp = b"<x:xmpmeta>metadata</x:xmpmeta>"
Image.new("RGB", (32, 32), (30, 40, 50)).save(path, "WEBP", xmp=xmp)
chunks = read_webp_chunks(path.read_bytes())
xmp_chunk = next(chunk for chunk in chunks if chunk["type"] == "XMP ")
assert xmp_chunk["text"] == xmp.decode()
assert all("base64" not in chunk for chunk in chunks if chunk["type"] in {"VP8 ", "VP8L", "ANMF"})
def test_isobmff_inventory_and_streaming_provenance(tmp_path: Path):
path = tmp_path / "signed.mp4"
ftyp = b"\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00mp42isom"
payload = b"jumb c2pa trainedAlgorithmicMedia"
uuid_box = (8 + len(payload)).to_bytes(4, "big") + b"uuid" + payload
path.write_bytes(ftyp + b"\x00\x00\x00\x08mdat" + uuid_box)
inventory = read_isobmff_inventory(path.read_bytes())
streamed = read_isobmff_provenance_path(path)
assert "ftyp" in inventory["boxes"]
assert base64.b64decode(inventory["provenance_boxes"][0]["base64"]) == payload
assert base64.b64decode(streamed["provenance_boxes"][0]["base64"]) == payload
def test_oversized_path_keeps_bounded_windows_and_late_png_metadata(tmp_path: Path, monkeypatch):
path = tmp_path / "late.png"
Image.new("RGB", (16, 16)).save(path)
source = path.read_bytes()
iend = source.rfind(b"\x00\x00\x00\x00IEND")
padding = _png_chunk(b"vpAg", b"\x00" * ((1 << 20) + 1))
metadata = b'AIGC\x00{"Label":"1"}'
path.write_bytes(source[:iend] + padding + _png_chunk(b"tEXt", metadata) + source[iend:])
monkeypatch.setattr("remove_ai_watermarks.forensic_metadata._MAX_FULL_READ", 1)
record = collect_forensic_metadata(path)
assert record["oversized"]["head_scanned_bytes"] == path.stat().st_size
assert base64.b64decode(record["raw_metadata_windows"]["head_base64"])
assert base64.b64decode(record["png_late_metadata_chunks"][0]["base64"]) == metadata
def test_collection_registers_optional_heif_and_missing_file_raises(tmp_path: Path, monkeypatch):
registered = False
def mark_registered():
nonlocal registered
registered = True
monkeypatch.setattr("remove_ai_watermarks.image_io._register_heif", mark_registered)
collect_forensic_metadata(_jpeg(tmp_path / "plain.jpg"))
assert registered is True
with pytest.raises(FileNotFoundError):
collect_forensic_metadata(tmp_path / "missing.jpg")
@pytest.mark.parametrize("schema_version", [2, True, 1.0])
def test_collection_rejects_unsupported_output_schema_before_reading(tmp_path: Path, schema_version: object):
with pytest.raises(ValueError, match="Unsupported forensic metadata schema"):
collect_forensic_metadata(
tmp_path / "missing.jpg",
schema_version=schema_version, # type: ignore[arg-type]
)
def test_xattrs_and_live_photo_probe_are_safe_on_plain_file(tmp_path: Path):
path = _jpeg(tmp_path / "plain.jpg")
assert xattr_where_from(path) == [] or isinstance(xattr_where_from(path), list)
assert xattr_quarantine(path) is None or isinstance(xattr_quarantine(path), str)
assert apple_live_photo_id(path.read_bytes()) is None
def test_late_png_reader_soft_fails_on_non_png(tmp_path: Path):
path = tmp_path / "plain.bin"
path.write_bytes(b"not png")
assert read_png_late_metadata_path(path) == []
+226 -2
View File
@@ -112,6 +112,36 @@ class TestProvenanceEvidence:
assert evidence.exif_generator == "NovelAI"
@pytest.mark.parametrize(
"record",
[
{"name": "trainedAlgorithmicMedia.jpg"},
{"sha256": "jumb-c2pa-OpenAI-trainedAlgorithmicMedia"},
{"pil": {"trainedAlgorithmicMedia": "plain"}},
{"signals": {"provenance": {"is_ai_generated": True}}},
{"pixel": {"error": "trainedAlgorithmicMedia"}},
],
)
def test_external_diagnostics_and_arbitrary_keys_are_not_evidence(self, tmp_path: Path, record: dict):
report = identify_from_evidence(evidence_from_metadata_record(record, path=tmp_path / "plain.jpg"))
assert report.is_ai_generated is None
assert report.signals == []
def test_external_metadata_value_is_evidence(self, tmp_path: Path):
record = {
"exif": {
"0th": {
"ImageDescription": "digitalSourceType=trainedAlgorithmicMedia",
}
}
}
report = identify_from_evidence(evidence_from_metadata_record(record, path=tmp_path / "generated.jpg"))
assert report.is_ai_generated is True
assert report.ai_source_kind == "generated"
@pytest.mark.parametrize(
"filename",
[
@@ -443,6 +473,18 @@ class TestIdentifyRealSamples:
r = identify(p, check_visible=False, check_invisible=False)
assert r.is_ai_generated is True
assert r.platform == "Apple Photos (Clean Up AI edit)"
assert r.ai_source_kind == "enhanced"
def test_standalone_iptc_composite_synthetic_is_enhanced(self, tmp_path: Path):
p = tmp_path / "composite.jpg"
p.write_bytes(
b'\xff\xd8\xff\xe1<x:xmpmeta Iptc4xmpExt:DigitalSourceType="compositeSynthetic"></x:xmpmeta>\xff\xd9'
)
r = identify(p, check_visible=False, check_invisible=False)
assert r.is_ai_generated is True
assert r.ai_source_kind == "enhanced"
def test_flux_bfl_c2pa_png(self):
# flux-1.png: real Black Forest Labs FLUX.2 Playground output (signed C2PA).
@@ -782,13 +824,19 @@ class TestIdentifyVisibleTextMarks:
"""The web path identify(check_visible=True, check_invisible=False) must
decode the image exactly once and share the array across the sparkle +
text-mark detectors. Two decodes of the same bitmap spiked memory on the
small web worker (the OOM the decode-once refactor addresses)."""
small web worker (the OOM the decode-once refactor addresses).
Count decodes OF THE SOURCE, not every ``imread`` in the process: the Gemini
engine loads its own bundled capture assets on first construction, so a
process-wide count was 3 on a cold engine and 1 on a warm one, and the test
passed only when some earlier test happened to build the engine first."""
import remove_ai_watermarks.image_io as image_io
real_imread = image_io.imread
with patch.object(image_io, "imread", side_effect=real_imread) as mock_imread:
identify(tmp_clean_png, check_visible=True, check_invisible=False)
assert mock_imread.call_count == 1
source_decodes = [c for c in mock_imread.call_args_list if Path(c.args[0]) == Path(tmp_clean_png)]
assert len(source_decodes) == 1
def test_missing_pixel_extra_preserves_metadata_verdict(self, tmp_png_with_ai_metadata: Path):
import remove_ai_watermarks.image_io as image_io
@@ -1274,3 +1322,179 @@ class TestRealSamplesHaveNoClash:
pytest.skip(f"{fixture} not present")
r = identify(path, check_visible=False, check_invisible=False)
assert r.integrity_clashes == []
class TestSharedPixelDecode:
"""Every pixel detector in one report reads ONE decode of the source.
``identify`` used to decode the file three times -- the DWT-DCT detector, the
TrustMark detector and the visible-mark stage each opened it. TrustMark still
keeps its own PIL decode on purpose (cv2 and PIL disagree on EXIF orientation and
on 16-bit PNG, so substituting one for the other is not behavior-preserving).
"""
SAMPLE = SAMPLES_DIR / "chatgpt-1.png"
def _count_source_decodes(self, **kwargs) -> int:
from unittest.mock import patch
from remove_ai_watermarks import image_io
real = image_io.imread
# Warm the bundled-asset caches first: alpha templates and sparkle captures
# also go through imread, and they are one-time loads, not source decodes.
identify(self.SAMPLE, check_visible=True, check_invisible=True)
seen: list[str] = []
def counted(path, *args, **kw):
seen.append(str(path))
return real(path, *args, **kw)
with patch.object(image_io, "imread", counted):
identify(self.SAMPLE, **kwargs)
return sum(1 for s in seen if s == str(self.SAMPLE))
@pytest.mark.skipif(not (SAMPLES_DIR / "chatgpt-1.png").exists(), reason="sample not present")
def test_visible_and_invisible_share_one_decode(self):
assert self._count_source_decodes(check_visible=True, check_invisible=True) == 1
@pytest.mark.skipif(not (SAMPLES_DIR / "chatgpt-1.png").exists(), reason="sample not present")
def test_metadata_only_report_never_decodes(self):
assert self._count_source_decodes(check_visible=False, check_invisible=False) == 0
@pytest.mark.skipif(not (SAMPLES_DIR / "chatgpt-1.png").exists(), reason="sample not present")
def test_a_decode_failure_still_propagates_on_the_invisible_arm(self):
"""Load-bearing: ``has_invisible_target`` turns this exception into its
documented fail-safe ``True``. Swallowing it would skip a diffusion scrub on a
file that used to get one -- a watermark left on a paid removal."""
from unittest.mock import patch
from remove_ai_watermarks import identify as identify_mod
from remove_ai_watermarks import image_io
def boom(*args, **kwargs):
raise OSError("decode exploded")
with patch.object(image_io, "imread", boom):
with pytest.raises(OSError, match="decode exploded"):
identify(self.SAMPLE, check_visible=False, check_invisible=True)
assert identify_mod.has_invisible_target(self.SAMPLE) is True
@pytest.mark.skipif(not (SAMPLES_DIR / "chatgpt-1.png").exists(), reason="sample not present")
def test_a_decode_failure_is_swallowed_on_the_visible_arm(self):
"""The visible arm's historical behavior: no cv2, no visible marks, and the
metadata verdict is untouched."""
from unittest.mock import patch
from remove_ai_watermarks import image_io
def boom(*args, **kwargs):
raise OSError("decode exploded")
with patch.object(image_io, "imread", boom):
report = identify(self.SAMPLE, check_visible=True, check_invisible=False)
assert not any(s.name.startswith("visible_") for s in report.signals)
assert report.is_ai_generated is True # the C2PA verdict survives the decode failure
class TestSynthIdProxyIsDecidedInTheVerdict:
"""The SynthID byte scan belongs to the verdict, not to extraction.
Extraction has two implementations -- one reading a file, one reading a portable
record -- so a rule that lives in only one of them is a rule the other silently
lacks. This one did: 74 corpus images reported SynthID through ``identify`` and
not through the record."""
# A JUMBF-wrapped manifest from a SynthID-pairing signer on AI-generated content:
# the exact shape `synthid_source`'s byte scan is gated on. Spliced into a real
# JPEG as a well-formed APP11 segment, because a malformed one is skipped by the
# record's structural walk and the test would compare two different inputs.
MANIFEST = b"jumb c2pa Google LLC trainedAlgorithmicMedia"
def _jpeg_with_manifest(self, path: Path) -> Path:
import numpy as np
from PIL import Image
Image.fromarray(np.zeros((32, 32, 3), dtype=np.uint8)).save(path, "JPEG")
data = path.read_bytes()
segment = b"\xff\xeb" + (len(self.MANIFEST) + 2).to_bytes(2, "big") + self.MANIFEST
path.write_bytes(data[:2] + segment + data[2:])
return path
def test_both_paths_infer_it_from_the_same_bytes(self, tmp_path: Path):
from remove_ai_watermarks.identify import identify_metadata_record
from remove_ai_watermarks.metadata_record import collect_metadata_record
path = self._jpeg_with_manifest(tmp_path / "gemini.jpg")
via_file = identify(path, check_visible=False, check_invisible=False)
via_record = identify_metadata_record(collect_metadata_record(path), path=path)
assert any("SynthID" in mark for mark in via_file.watermarks)
assert via_record.watermarks == via_file.watermarks
def test_it_needs_a_manifest_and_an_ai_source_type(self, tmp_path: Path):
"""The vendor name alone is not evidence: an ordinary photo mentioning
"Google LLC" in EXIF must not acquire a SynthID verdict."""
import numpy as np
from PIL import Image
path = tmp_path / "photo.jpg"
Image.fromarray(np.zeros((32, 32, 3), dtype=np.uint8)).save(path, "JPEG")
data = path.read_bytes()
note = b"Google LLC Pixel"
path.write_bytes(data[:2] + b"\xff\xeb" + (len(note) + 2).to_bytes(2, "big") + note + data[2:])
report = identify(path, check_visible=False, check_invisible=False)
assert not any("SynthID" in mark for mark in report.watermarks)
class TestRegistryScansSkipTheCodedPixels:
"""The vendor registries match short raw substrings -- the shortest are four and
five bytes. Over a megabyte of compressed pixel data such a sequence turns up by
chance: `Bria` matched inside the entropy-coded scan of 4 of 14,707 corpus JPEGs,
and that entry asserts AI, so a chance match can declare an image AI-generated.
`c2pa_marker_in` already refuses a bare `c2pa` substring for the same reason."""
def _jpeg(self, path: Path, *, in_segment: bytes = b"", in_scan: bytes = b"") -> Path:
import numpy as np
from PIL import Image
Image.fromarray(np.zeros((32, 32, 3), dtype=np.uint8)).save(path, "JPEG")
data = path.read_bytes()
if in_segment:
payload = b"jumb c2pa trainedAlgorithmicMedia " + in_segment
data = data[:2] + b"\xff\xeb" + (len(payload) + 2).to_bytes(2, "big") + payload + data[2:]
if in_scan:
# After SOS, i.e. inside the entropy-coded scan the walk skips.
sos = data.index(b"\xff\xda")
data = data[: sos + 16] + in_scan + data[sos + 16 :]
path.write_bytes(data)
return path
def test_a_token_in_a_marker_segment_is_attributed(self, tmp_path: Path):
from remove_ai_watermarks.identify import _issuers_in, _metadata_region
from remove_ai_watermarks.metadata import scan_head
path = self._jpeg(tmp_path / "signed.jpg", in_segment=b"Bria")
assert _issuers_in(_metadata_region(scan_head(path))) == ["Bria Artificial Intelligence"]
def test_a_token_in_the_coded_scan_is_not(self, tmp_path: Path):
from remove_ai_watermarks.identify import _issuers_in, _metadata_region
from remove_ai_watermarks.metadata import scan_head
path = self._jpeg(tmp_path / "chance.jpg", in_segment=b"OpenAI", in_scan=b"Bria")
region = _metadata_region(scan_head(path))
assert _issuers_in(region) == ["OpenAI"]
def test_a_container_that_does_not_parse_is_left_whole(self, tmp_path: Path):
"""Cutting a buffer the walk did not understand would drop real evidence to
avoid a chance match, which is the wrong way round."""
from remove_ai_watermarks.identify import _metadata_region
blob = b"\xff\xd8" + b"not really a jpeg, no valid marker chain here" * 4
assert _metadata_region(blob) == blob
+9
View File
@@ -79,6 +79,15 @@ class TestTargetSize:
assert _target_size(4096, 4096, 0) is None
assert _target_size(123, 456, 0) is None
def test_the_engine_default_is_itself_a_valid_input(self):
# Read the default off the signature rather than restating 0: a caller forwards
# whatever ``remove_watermark`` declares, and ``None`` there would reach the
# ``max_resolution > 0`` test below as a TypeError instead of "no cap".
import inspect
default = inspect.signature(InvisibleEngine.remove_watermark).parameters["max_resolution"].default
assert _target_size(1024, 768, default) is None
def test_negative_cap_treated_as_native(self):
assert _target_size(4096, 4096, -1) is None
+23
View File
@@ -110,3 +110,26 @@ class TestDetectAndMask:
eng = LibLibEngine()
img = np.full((2400, 1792, 3), 100, np.uint8)
assert eng.footprint_mask(img) is None
def test_confident_liblib_detection_suppresses_the_jimeng_pill(self):
# A LibLibAI image is TC260 too but is not Jimeng-basic: like Doubao/Qwen/
# Kling/RunningHub/Baidu, a confident LibLibAI detection must veto the pill.
# It was the one mark the hand-written veto list in ``_keep_pill`` missed.
from remove_ai_watermarks.watermark_registry import _keep_pill
assert not _keep_pill({"liblib"}, provenance=frozenset({"jimeng"}), footprint_flat=1.0)
def test_force_masks_the_whole_locate_box_on_a_clean_frame(self):
"""``force`` takes priority over detection for this mark, unlike the base
policy: a --no-detect caller named the mark, so the honest footprint is the
whole geometry box even though nothing was detected."""
eng = LibLibEngine()
img = np.full((2400, 1792, 3), 100, np.uint8)
mask = eng.footprint_mask(img, force=True)
assert mask is not None
bx, by, bw, bh = eng.locate(img).bbox
ys, xs = np.where(mask > 0)
assert xs.min() <= bx
assert xs.max() >= bx + bw - 1
assert ys.min() <= by
assert ys.max() >= by + bh - 1
+24
View File
@@ -1316,6 +1316,30 @@ class TestAIGCLabel:
assert b"ContentProducer" in _png_late_metadata(p, 8)
assert b"ContentProducer" in scan_head(p, 8)
def test_scan_head_collects_webp_metadata_past_window(self, tmp_path: Path):
"""WebP stores ``XMP `` AFTER the pixels, so on any WebP above the window a
fixed read can stop short of an IPTC "Made with AI" tag."""
from remove_ai_watermarks.metadata import _riff_late_metadata, scan_head
p = tmp_path / "late.webp"
xmp = (
b"<x:xmpmeta><photoshop:DigitalSourceType>trainedAlgorithmicMedia</photoshop:DigitalSourceType></x:xmpmeta>"
)
Image.new("RGB", (16, 16)).save(p, "WEBP", xmp=xmp)
assert b"trainedAlgorithmicMedia" in _riff_late_metadata(p, 12)
assert b"trainedAlgorithmicMedia" in scan_head(p, 12)
def test_riff_late_metadata_ignores_the_coded_image(self, tmp_path: Path):
"""The point of stepping chunk by chunk rather than reading through: the
pixel payload never enters the scan buffer."""
from remove_ai_watermarks.metadata import _riff_late_metadata
p = tmp_path / "plain.webp"
Image.new("RGB", (64, 64), (200, 30, 30)).save(p, "WEBP")
assert _riff_late_metadata(p, 12) == b""
class TestHuggingFaceJob:
"""HuggingFace-hosted job marker (``hf-job-id`` PNG text chunk)."""
+270
View File
@@ -3,10 +3,12 @@ consolidated metadata strip (formerly legacy metadata helper)."""
from __future__ import annotations
import logging
import struct
from pathlib import Path
import pytest
from PIL import Image
from remove_ai_watermarks._internal.c2pa import (
_parse_c2pa_chunk,
@@ -590,6 +592,188 @@ class TestC2paBufferScans:
assert synthid_verdict("Google LLC") == "likely present (Google LLC embeds SynthID with C2PA)"
def _amf0_str(value: bytes, *, long: bool = False) -> bytes:
marker = b"\x0c" if long else b"\x02"
return marker + len(value).to_bytes(4 if long else 2, "big") + value
def _amf0_property(name: bytes, value: bytes) -> bytes:
return len(name).to_bytes(2, "big") + name + value
_AMF0_OBJECT_END = b"\x00\x00\x09"
# A minimal TC260-PG-20257A label: the reader validates the JSON before accepting it,
# so the walker tests need a value that actually parses.
_TC260_AIGC_VALUE = (
b'{"Label":"1","ContentProducer":"00119144030008867405X210002",'
b'"ProduceID":"sample-001","ReservedCode1":"","ContentPropagator":"",'
b'"PropagateID":"","ReservedCode2":""}'
)
class TestFlvAmf0Walker:
"""``_skip_amf0`` is what lets the FLV reader step over every property that is not
``AIGC``. Each AMF0 type it does not walk correctly aborts the scan, so a label that
sits after an unhandled type is silently missed. Pure byte parsing -- no media file
and no decoder is involved, so every branch is reachable from synthetic bytes."""
@pytest.mark.parametrize(
("name", "encoded"),
[
("number", b"\x00" + b"\x00" * 8),
("boolean", b"\x01\x01"),
("string", _amf0_str(b"a string")),
("null", b"\x05"),
("undefined", b"\x06"),
("reference", b"\x07\x00\x01"),
("date", b"\x0b" + b"\x00" * 10),
("long-string", _amf0_str(b"a long string", long=True)),
("strict-array", b"\x0a\x00\x00\x00\x02" + b"\x00" + b"\x00" * 8 + b"\x01\x00"),
("object", b"\x03" + _amf0_property(b"inner", b"\x01\x00") + _AMF0_OBJECT_END),
("ecma-array", b"\x08\x00\x00\x00\x01" + _amf0_property(b"inner", b"\x05") + _AMF0_OBJECT_END),
],
)
def test_every_walkable_type_is_stepped_over(self, name: str, encoded: bytes):
"""A property of this type, sitting before the AIGC one, must not stop the walk."""
from remove_ai_watermarks._internal.flv import _script_payloads
payload = (
_amf0_str(b"onMetaData")
+ b"\x03"
+ _amf0_property(name.encode(), encoded)
+ _amf0_property(b"AIGC", _amf0_str(_TC260_AIGC_VALUE))
+ _AMF0_OBJECT_END
)
assert _script_payloads(payload) == (_TC260_AIGC_VALUE,)
def test_unknown_type_marker_stops_the_walk(self):
"""An unrecognized marker has an unknown width, so the reader cannot guess where
the next property starts. It must give up rather than resynchronize on garbage."""
from remove_ai_watermarks._internal.flv import _script_payloads
payload = (
_amf0_str(b"onMetaData")
+ b"\x03"
+ _amf0_property(b"mystery", b"\x7f")
+ _amf0_property(b"AIGC", _amf0_str(_TC260_AIGC_VALUE))
+ _AMF0_OBJECT_END
)
assert _script_payloads(payload) == ()
def test_truncated_value_stops_the_walk(self):
"""A declared length running past the buffer must return empty, not raise."""
from remove_ai_watermarks._internal.flv import _script_payloads
payload = _amf0_str(b"onMetaData") + b"\x03" + _amf0_property(b"trunc", b"\x02\x00\xff")
assert _script_payloads(payload) == ()
def test_nesting_deeper_than_the_depth_cap_is_refused(self):
"""The depth cap bounds work on hostile input; past it the walker returns None."""
from remove_ai_watermarks._internal.flv import _skip_amf0
nested = b"\x05"
for _ in range(12):
nested = b"\x03" + _amf0_property(b"n", nested) + _AMF0_OBJECT_END
assert _skip_amf0(nested, 0) is None
def test_long_string_aigc_value_is_read(self):
"""TC260 values large enough to need the 4-byte long-string form still parse."""
from remove_ai_watermarks._internal.flv import _script_payloads
payload = (
_amf0_str(b"onMetaData")
+ b"\x03"
+ _amf0_property(b"AIGC", _amf0_str(_TC260_AIGC_VALUE, long=True))
+ _AMF0_OBJECT_END
)
assert _script_payloads(payload) == (_TC260_AIGC_VALUE,)
def test_non_onmetadata_script_tag_is_ignored(self):
"""Only ``onMetaData`` carries the normative label; other script tags are skipped."""
from remove_ai_watermarks._internal.flv import _script_payloads
payload = (
_amf0_str(b"onCuePoint")
+ b"\x03"
+ _amf0_property(b"AIGC", _amf0_str(_TC260_AIGC_VALUE))
+ _AMF0_OBJECT_END
)
assert _script_payloads(payload) == ()
def test_missing_file_reads_as_no_payloads(self, tmp_path: Path):
from remove_ai_watermarks._internal.flv import tc260_aigc_payloads
assert tc260_aigc_payloads(tmp_path / "absent.flv") == ()
def test_non_flv_signature_reads_as_no_payloads(self, tmp_path: Path):
from remove_ai_watermarks._internal.flv import tc260_aigc_payloads
path = tmp_path / "fake.flv"
path.write_bytes(b"NOTFLV\x00\x00\x09" + b"\x00" * 32)
assert tc260_aigc_payloads(path) == ()
class TestProbeMemoization:
"""The per-file probes are cached on (path, mtime_ns, size).
Their only real failure mode is staleness after an IN-PLACE rewrite, which this
package does (``remove_ai_metadata(p, p)``, and the batch case where the output
directory is the input directory). mtime alone can land inside one tick on a
coarse filesystem, hence size in the key too.
"""
def test_in_place_strip_invalidates_the_label_cache(self, tmp_path: Path):
import shutil
from remove_ai_watermarks.metadata import aigc_label, remove_ai_metadata
source = Path(__file__).resolve().parents[1] / "data" / "fixtures" / "provenance" / "doubao-1.png"
if not source.exists():
pytest.skip("doubao sample not present")
target = tmp_path / "in_place.png"
shutil.copyfile(source, target)
assert aigc_label(target) is not None # populates the cache
remove_ai_metadata(target, target)
assert aigc_label(target) is None, "the cache answered from the pre-strip content"
def test_caller_cannot_mutate_the_cached_label(self, tmp_path: Path):
"""``aigc_label`` returns a dict; a caller editing it must not poison the cache."""
import shutil
from remove_ai_watermarks.metadata import aigc_label
source = Path(__file__).resolve().parents[1] / "data" / "fixtures" / "provenance" / "doubao-1.png"
if not source.exists():
pytest.skip("doubao sample not present")
target = tmp_path / "mutate.png"
shutil.copyfile(source, target)
first = aigc_label(target)
assert first is not None
first["ContentProducer"] = "TAMPERED"
second = aigc_label(target)
assert second is not None
assert second["ContentProducer"] != "TAMPERED"
def test_unstattable_path_bypasses_the_cache_and_behaves_as_before(self, tmp_path: Path):
"""A path that cannot be stat'ed has no cache key, so it must fall through to the
uncached implementation -- same outcome as before memoization, whatever that is."""
from remove_ai_watermarks import metadata
missing = tmp_path / "absent.png"
assert metadata._stat_key(missing) is None
def outcome(fn):
try:
return ("value", fn(missing))
except Exception as exc:
return ("raised", type(exc).__name__)
assert outcome(metadata.aigc_label) == outcome(metadata._aigc_label_impl)
class TestC2PAInvalidSignature:
"""A .png file that is not actually PNG-signed must read as clean, not crash."""
@@ -602,3 +786,89 @@ class TestC2PAInvalidSignature:
fake = tmp_path / "fake.png"
fake.write_bytes(b"\xff\xd8\xff\xe0 not a png at all, just garbage bytes")
assert extract_c2pa_chunk(fake) is None
class TestTc260ContainerRouting:
"""The native-container readers route on CONTENT, not on the file extension.
Every reader self-gates on its own magic bytes after a 4-12 byte read, so gating
the AVI and FLV ones on the suffix as well was redundant -- and it made a
correctly-formatted container served under the wrong name invisible, contradicting
this module's own rule that format detection reads the bytes.
"""
@staticmethod
def _riff_chunk(chunk_id: bytes, payload: bytes) -> bytes:
return chunk_id + len(payload).to_bytes(4, "little") + payload + (b"\x00" if len(payload) & 1 else b"")
def _labelled_avi(self) -> bytes:
info = self._riff_chunk(b"AIGC", _TC260_AIGC_VALUE)
body = b"AVI " + self._riff_chunk(b"LIST", b"INFO" + info)
return b"RIFF" + len(body).to_bytes(4, "little") + body
def test_a_mislabeled_avi_is_still_read(self, tmp_path: Path):
from remove_ai_watermarks.metadata import aigc_label
target = tmp_path / "clip.bin" # correct AVI bytes, wrong suffix
target.write_bytes(self._labelled_avi())
label = aigc_label(target)
assert label is not None
assert label["Label"] == "1"
def test_a_correctly_named_avi_still_works(self, tmp_path: Path):
from remove_ai_watermarks.metadata import aigc_label
target = tmp_path / "clip.avi"
target.write_bytes(self._labelled_avi())
assert aigc_label(target) is not None
def test_webp_yields_nothing_from_the_riff_reader(self, tmp_path: Path):
"""WebP is the one input class the now-unconditional RIFF reader newly touches,
and it shares the ``RIFF`` prefix -- the ``AVI `` form check is what rejects it."""
from remove_ai_watermarks._internal.riff import tc260_aigc_payloads
body = b"WEBP" + self._riff_chunk(b"VP8L", b"\x00" * 16)
target = tmp_path / "pic.webp"
target.write_bytes(b"RIFF" + len(body).to_bytes(4, "little") + body)
assert tc260_aigc_payloads(target) == ()
def test_every_reader_is_reached_in_a_stable_order(self):
from remove_ai_watermarks.metadata import _tc260_container_readers
readers = _tc260_container_readers()
assert [r.__module__.rsplit(".", 1)[-1] for r in readers] == ["isobmff", "ebml", "riff", "flv"]
class TestC2paReaderFailureIsVisible:
"""A reader failure and a file with no manifest both return None, so the caller
cannot tell them apart -- and the consequence is not symmetric. A file with no
manifest is a normal verdict; a reader that could not read a file it was handed
can silently downgrade one, so the log level must make the failure observable."""
def _records(self, caplog, path: str) -> list[str]:
from remove_ai_watermarks._internal import c2pa
with caplog.at_level(logging.DEBUG, logger="remove_ai_watermarks._internal.c2pa"):
assert c2pa._manifest_json_uncached(path) is None
return [f"{r.levelname} {r.getMessage()}" for r in caplog.records]
def test_an_unreadable_file_warns(self, caplog):
records = self._records(caplog, "/nonexistent/definitely-not-here.png")
assert any(r.startswith("WARNING") for r in records), records
def test_an_unsupported_container_stays_quiet(self, caplog, tmp_path: Path):
target = tmp_path / "notes.txt"
target.write_text("plain text, not a container the reader handles")
records = self._records(caplog, str(target))
assert not any(r.startswith("WARNING") for r in records), records
def test_a_plain_image_without_a_manifest_logs_nothing(self, caplog, tmp_path: Path):
target = tmp_path / "plain.png"
Image.new("RGB", (8, 8)).save(target)
records = self._records(caplog, str(target))
assert records == []
+333
View File
@@ -0,0 +1,333 @@
"""Tests for the portable metadata record.
The contract is one property: a verdict built from the record equals the verdict
built from the file. Everything here exists to pin that, or to pin a placement the
record could plausibly drop.
"""
from __future__ import annotations
import json
import struct
import zlib
from pathlib import Path
import numpy as np
import pytest
from PIL import Image
from remove_ai_watermarks.identify import (
PROVENANCE_REPORT_SCHEMA_VERSION,
ProvenanceReport,
evidence_from_metadata_record,
identify,
identify_from_evidence,
identify_metadata_record,
)
from remove_ai_watermarks.metadata_record import (
HEAD_WINDOW,
METADATA_RECORD_SCHEMA_VERSION,
METADATA_RECORD_TYPE,
collect_metadata_record,
)
FIXTURES = Path(__file__).resolve().parent.parent / "data" / "fixtures" / "provenance"
COMPARED = ("is_ai_generated", "platform", "confidence", "ai_source_kind", "ai_from_metadata")
def _verdict_via_record(path: Path) -> ProvenanceReport:
"""The contractor's path: collect, serialize, judge -- without the file."""
record = json.loads(json.dumps(collect_metadata_record(path)))
return identify_metadata_record(record, path=path)
def _assert_same_verdict(path: Path) -> None:
via_record = _verdict_via_record(path)
via_file = identify(path, check_visible=False, check_invisible=False)
for field in COMPARED:
assert getattr(via_record, field) == getattr(via_file, field), field
assert sorted(s.name for s in via_record.signals) == sorted(s.name for s in via_file.signals)
assert sorted(via_record.watermarks) == sorted(via_file.watermarks)
def _noise_png(path: Path, size: tuple[int, int] = (700, 700)) -> Path:
"""A PNG whose IDAT is incompressible, so the file exceeds the head window."""
rng = np.random.default_rng(0)
Image.fromarray(rng.integers(0, 255, (size[1], size[0], 3), dtype=np.uint8)).save(path)
return path
def _insert_png_chunk(path: Path, chunk_type: bytes, payload: bytes) -> None:
"""Splice a chunk in just before IEND, i.e. AFTER the whole pixel stream."""
data = path.read_bytes()
end = data.rindex(b"IEND") - 4
chunk = struct.pack(">I", len(payload)) + chunk_type + payload
chunk += struct.pack(">I", zlib.crc32(chunk_type + payload) & 0xFFFFFFFF)
path.write_bytes(data[:end] + chunk + data[end:])
class TestRecordReproducesTheFileVerdict:
"""The whole point of the record. Every tracked provenance fixture is compared,
which covers C2PA, the China TC260 label, the xAI EXIF pair and the IPTC tag."""
@pytest.mark.skipif(not FIXTURES.is_dir(), reason="provenance fixtures not present")
@pytest.mark.parametrize("name", sorted(p.name for p in FIXTURES.iterdir()) if FIXTURES.is_dir() else [])
def test_fixture(self, name: str):
_assert_same_verdict(FIXTURES / name)
@pytest.mark.skipif(not FIXTURES.is_dir(), reason="provenance fixtures not present")
def test_the_fixtures_actually_exercise_several_signals(self):
"""Guards the test above: if the fixture set ever narrows to one signal
family, equality across it stops meaning much."""
found = {
signal.name
for path in FIXTURES.iterdir()
for signal in identify(path, check_visible=False, check_invisible=False).signals
}
assert len(found) >= 4, found
class TestPlacementsTheRecordCouldDrop:
def test_a_trailer_after_eoi_survives(self, tmp_path: Path):
"""Samsung Galaxy AI appends its marker past the JPEG EOI, and the value it is
gated on can sit further back still. A record that stopped at the last
structural marker would report no signal at all."""
path = tmp_path / "edited.jpg"
Image.fromarray(np.zeros((64, 64, 3), dtype=np.uint8)).save(path, "JPEG")
with path.open("ab") as handle:
handle.write(b'PhotoEditor_Re_Edit_Data{"genAIType":17}')
assert identify(path, check_visible=False, check_invisible=False).confidence == "medium"
_assert_same_verdict(path)
def test_a_metadata_chunk_past_the_head_window_survives(self, tmp_path: Path):
"""A PNG encoder may put the label chunk after the pixels. The file is larger
than the record's head window, so only the seek-past-IDAT reader finds it."""
path = _noise_png(tmp_path / "late.png")
assert path.stat().st_size > HEAD_WINDOW
# An XMP packet in the namespaced TC260 form, which is how the label travels
# when it is not a bare ``AIGC`` keyword chunk.
label = json.dumps({"Label": "1", "ContentProducer": "001191110102MACQD9K64010000"})
xmp = (
b'<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF><rdf:Description '
b'xmlns:TC260="http://www.tc260.org.cn/ns/AIGC/1.0/"><TC260:AIGC>'
+ label.encode()
+ b"</TC260:AIGC></rdf:Description></rdf:RDF></x:xmpmeta>"
)
_insert_png_chunk(path, b"iTXt", b"XML:com.adobe.xmp\x00\x00\x00\x00\x00" + xmp)
assert "aigc" in {s.name for s in identify(path, check_visible=False, check_invisible=False).signals}
_assert_same_verdict(path)
def test_an_exif_pair_survives(self, tmp_path: Path):
"""xAI is recognized from an (ImageDescription, Artist) PAIR, and both are read
by tag NAME. A record carrying only raw bytes loses it."""
import piexif
path = tmp_path / "grok.jpg"
Image.fromarray(np.zeros((64, 64, 3), dtype=np.uint8)).save(path, "JPEG")
exif = {
"0th": {
piexif.ImageIFD.ImageDescription: b"Signature: " + b"A" * 80,
piexif.ImageIFD.Artist: b"3f2504e0-4f89-11d3-9a0c-0305e82c3301",
}
}
piexif.insert(piexif.dump(exif), str(path))
assert "xai_signature" in {s.name for s in identify(path, check_visible=False, check_invisible=False).signals}
_assert_same_verdict(path)
class TestRecordShape:
def test_the_record_survives_json(self, tmp_path: Path):
record = collect_metadata_record(_noise_png(tmp_path / "plain.png"))
assert json.loads(json.dumps(record, allow_nan=False))["container"] == "png"
assert record["schema_version"] == METADATA_RECORD_SCHEMA_VERSION == 1
assert record["record_type"] == METADATA_RECORD_TYPE == "provenance_metadata"
@pytest.mark.parametrize("keep_version", [True, False])
def test_transport_filename_is_not_evidence(self, tmp_path: Path, keep_version: bool):
"""The path labels a record; detector tokens in it do not describe pixels."""
path = tmp_path / "jumb-c2pa-OpenAI-trainedAlgorithmicMedia.jpg"
Image.fromarray(np.zeros((64, 64, 3), dtype=np.uint8)).save(path, "JPEG")
record = collect_metadata_record(path)
if not keep_version:
record.pop("schema_version")
record.pop("record_type")
report = identify_metadata_record(record, path=path)
assert report.is_ai_generated is None
assert report.platform is None
assert report.confidence == "none"
def test_pixels_are_not_carried(self, tmp_path: Path):
"""The reason the record walks regions instead of shipping the head: a record
that carried the pixel stream would be the size of the image."""
path = _noise_png(tmp_path / "big.png", size=(1200, 1200))
record = collect_metadata_record(path)
idat = path.read_bytes()
start = idat.index(b"IDAT") + 4
assert idat[start : start + 512] not in json.dumps(record).encode()
assert len(json.dumps(record)) < path.stat().st_size // 10
def test_an_unreadable_container_still_yields_a_record(self, tmp_path: Path):
path = tmp_path / "junk.bin"
path.write_bytes(b"\x00\x01\x02not an image at all" * 100)
record = collect_metadata_record(path)
assert record["container"] == "unknown"
assert json.dumps(record) # serializable, and no exception on the way here
_assert_same_verdict(path)
def test_a_missing_file_does_not_raise(self, tmp_path: Path):
"""Collection runs over whatever a caller hands it, including a path that
vanished between listing and reading."""
record = collect_metadata_record(tmp_path / "gone.png")
assert record["container"] == "unknown"
assert record["metadata_base64"] == ""
assert record["status"] == "error"
assert record["issues"] == [{"stage": "source", "code": "unavailable"}]
def test_unknown_record_schema_is_rejected(self, tmp_path: Path):
path = _noise_png(tmp_path / "plain.png")
record = collect_metadata_record(path)
record["schema_version"] = 2
with pytest.raises(ValueError, match="Unsupported provenance metadata schema"):
identify_metadata_record(record, path=path)
@pytest.mark.parametrize("schema_version", [True, 1.0, "1", None])
def test_native_record_schema_requires_the_integer_one(self, tmp_path: Path, schema_version: object):
path = _noise_png(tmp_path / "plain.png")
record = collect_metadata_record(path)
record["schema_version"] = schema_version
with pytest.raises(ValueError, match="Unsupported provenance metadata schema"):
identify_metadata_record(record, path=path)
@pytest.mark.parametrize("status", [None, "partial", "unknown", True])
def test_native_record_requires_complete_collection_status(self, tmp_path: Path, status: object):
path = _noise_png(tmp_path / "plain.png")
record = collect_metadata_record(path)
if status is None:
record.pop("status")
else:
record["status"] = status
with pytest.raises(ValueError, match="collection status"):
identify_metadata_record(record, path=path)
@pytest.mark.parametrize("schema_version", [2, True, 1.0])
def test_collection_rejects_unsupported_output_schema_before_reading(self, tmp_path: Path, schema_version: object):
with pytest.raises(ValueError, match="Unsupported provenance metadata schema"):
collect_metadata_record(
tmp_path / "missing.png",
schema_version=schema_version, # type: ignore[arg-type]
)
def test_broad_forensic_record_is_not_detector_input(self, tmp_path: Path):
path = _noise_png(tmp_path / "plain.png")
with pytest.raises(ValueError, match="Unsupported metadata record type"):
identify_metadata_record(
{"record_type": "forensic_metadata", "schema_version": 1},
path=path,
)
def test_failed_collection_cannot_be_judged_as_an_unknown_image(self, tmp_path: Path):
path = tmp_path / "missing.png"
with pytest.raises(ValueError, match="collection failed"):
identify_metadata_record(collect_metadata_record(path), path=path)
class TestReportTransport:
def test_report_contract_is_versioned_json_and_omits_local_path(self, tmp_path: Path):
path = _noise_png(tmp_path / "plain.png")
payload = identify_metadata_record(collect_metadata_record(path), path=path).to_dict()
assert payload["schema_version"] == PROVENANCE_REPORT_SCHEMA_VERSION == 1
assert "path" not in payload
assert json.loads(json.dumps(payload, allow_nan=False)) == payload
@pytest.mark.parametrize("schema_version", [2, True, 1.0])
def test_report_rejects_unsupported_output_schema(self, tmp_path: Path, schema_version: object):
path = _noise_png(tmp_path / "plain.png")
report = identify_metadata_record(collect_metadata_record(path), path=path)
with pytest.raises(ValueError, match="Unsupported provenance report schema"):
report.to_dict(schema_version=schema_version) # type: ignore[arg-type]
def test_convenience_entry_point_matches_explicit_sequence(self, tmp_path: Path):
path = _noise_png(tmp_path / "plain.png")
record = collect_metadata_record(path)
explicit = identify_from_evidence(evidence_from_metadata_record(record, path=path))
convenience = identify_metadata_record(record, path=path)
assert convenience == explicit
def test_a_webp_record_matches(tmp_path: Path):
"""RIFF has its own walk; a chunk kept or dropped wrongly shows up here."""
path = tmp_path / "image.webp"
Image.fromarray(np.zeros((64, 64, 3), dtype=np.uint8)).save(path, "WEBP", xmp=b"<x:xmpmeta>plain</x:xmpmeta>")
assert collect_metadata_record(path)["container"] == "webp"
_assert_same_verdict(path)
def test_a_webp_record_collects_metadata_after_a_large_frame(tmp_path: Path):
"""The RIFF walker seeks past coded pixels instead of stopping at the head window."""
path = tmp_path / "late.webp"
rng = np.random.default_rng(7)
pixels = rng.integers(0, 255, (900, 900, 3), dtype=np.uint8)
xmp = b"<x:xmpmeta><photoshop:DigitalSourceType>trainedAlgorithmicMedia</photoshop:DigitalSourceType></x:xmpmeta>"
Image.fromarray(pixels).save(path, "WEBP", lossless=True, xmp=xmp)
assert path.stat().st_size > HEAD_WINDOW
_assert_same_verdict(path)
def test_a_webp_record_does_not_carry_animation_frame_pixels(tmp_path: Path):
"""ANMF is a coded-frame container, not a metadata chunk."""
from remove_ai_watermarks.metadata_record import _riff_regions
frame = b"jumb c2pa OpenAI trainedAlgorithmicMedia" * 20
riff = b"RIFF" + (4 + 8 + len(frame)).to_bytes(4, "little") + b"WEBP"
riff += b"ANMF" + len(frame).to_bytes(4, "little") + frame
assert frame not in _riff_regions(riff)
def test_an_invalid_short_riff_size_does_not_turn_the_container_into_a_trailer(tmp_path: Path):
from remove_ai_watermarks.metadata_record import _trailer
path = tmp_path / "invalid.webp"
path.write_bytes(b"RIFF\x00\x00\x00\x00WEBPjumb c2pa trainedAlgorithmicMedia")
assert _trailer(path, "webp") == b""
def test_the_record_never_reopens_the_source(tmp_path: Path, monkeypatch):
"""The reason the record exists: the verdict must run where the file is not."""
path = FIXTURES / "chatgpt-1.png" if (FIXTURES / "chatgpt-1.png").exists() else _noise_png(tmp_path / "x.png")
record = json.loads(json.dumps(collect_metadata_record(path)))
def fail_if_called(*args, **kwargs):
raise AssertionError("the record path must not open the source file")
monkeypatch.setattr("builtins.open", fail_if_called)
monkeypatch.setattr(Path, "open", fail_if_called)
monkeypatch.setattr(Image, "open", fail_if_called)
report = identify_from_evidence(evidence_from_metadata_record(record, path=path))
assert isinstance(report, ProvenanceReport)
+21 -3
View File
@@ -113,7 +113,15 @@ class TestPillGate:
flatness matters, so tests pass a flat or textured frame."""
@staticmethod
def _fakes(monkeypatch: pytest.MonkeyPatch, keys: set[str]) -> None:
def _fakes(monkeypatch: pytest.MonkeyPatch, keys: set[str]) -> list[str]:
"""Fake every mark's verdict. Returns the list the fake appends each call to.
BOTH entry points must be faked: the arbiter's perception pass goes through
``detect_both`` (one scan, two verdicts) while the removal path still calls
``detect``. Patching only one leaves the real detectors running on a synthetic
frame, where they find nothing -- so a "dropped" assertion would pass for
entirely the wrong reason. The returned call log is what pins that.
"""
from remove_ai_watermarks.watermark_registry import KnownMark, MarkDetection
labels = {
@@ -122,13 +130,21 @@ class TestPillGate:
"jimeng_pill": "Jimeng AI生成 pill",
}
monkeypatch.setattr(registry, "preferred_inpaint_backend", lambda: "cv2")
calls: list[str] = []
def fake_detect(self: KnownMark, image: object, *, provenance: bool = False) -> MarkDetection:
calls.append(self.key)
return MarkDetection(
self.key, labels.get(self.key, self.key), "loc", self.key in keys, 0.6, (10, 10, 40, 40)
)
def fake_detect_both(self: KnownMark, image: object) -> tuple[MarkDetection, MarkDetection]:
d = fake_detect(self, image)
return d, d
monkeypatch.setattr(registry.KnownMark, "detect", fake_detect)
monkeypatch.setattr(registry.KnownMark, "detect_both", fake_detect_both)
return calls
def test_pill_kept_with_metadata_on_flat_footprint(self, monkeypatch: pytest.MonkeyPatch) -> None:
# jimeng provenance (TC260) + flat background -> safe fill, remove
@@ -138,8 +154,9 @@ class TestPillGate:
def test_pill_dropped_with_metadata_on_textured_footprint(self, monkeypatch: pytest.MonkeyPatch) -> None:
# jimeng provenance + textured background (ceiling-like) -> fill would smear, skip
self._fakes(monkeypatch, {"jimeng_pill"})
calls = self._fakes(monkeypatch, {"jimeng_pill"})
_, removed = registry.remove_auto_marks(_textured_frame(), provenance=frozenset({"jimeng"}))
assert "jimeng_pill" in calls, "the fake detector never ran -- this would pass vacuously"
assert "Jimeng AI生成 pill" not in removed
def test_pill_kept_via_wordmark_ignores_texture(self, monkeypatch: pytest.MonkeyPatch) -> None:
@@ -151,8 +168,9 @@ class TestPillGate:
def test_pill_dropped_on_textured_footprint(self, monkeypatch: pytest.MonkeyPatch) -> None:
# The metadata arm keeps the flatness guard: textured false fires visibly smear.
self._fakes(monkeypatch, {"jimeng_pill"})
calls = self._fakes(monkeypatch, {"jimeng_pill"})
_, removed = registry.remove_auto_marks(_textured_frame(), provenance=frozenset({"jimeng"}))
assert "jimeng_pill" in calls, "the fake detector never ran -- this would pass vacuously"
assert "Jimeng AI生成 pill" not in removed
def test_pill_dropped_without_metadata_or_wordmark(self, monkeypatch: pytest.MonkeyPatch) -> None:
+253
View File
@@ -0,0 +1,253 @@
"""Tests for the pixel-forensics collector.
These pin the service contract and the edge cases a consumer cannot infer safely:
a family is empty rather than wrong when the image is too small for it, one failing
family does not lose the other five, and artifacts that identify the source image
stay behind their opt-in.
"""
from __future__ import annotations
import dataclasses
from typing import TYPE_CHECKING
import numpy as np
import pytest
from PIL import Image
from remove_ai_watermarks.pixel_evidence import (
AC_POSITIONS,
PIXEL_EVIDENCE_SCHEMA_VERSION,
PixelEvidence,
_dct_matrix,
dct_features,
extract_pixel_evidence,
is_available,
perceptual_hash,
)
if TYPE_CHECKING:
from pathlib import Path
FAMILIES = ("dct", "fft", "noise", "ela", "gradient", "color")
def _textured(path: Path, size: tuple[int, int] = (192, 160), *, seed: int = 0) -> Path:
"""A textured image. Flat color would make several families degenerate (zero
residual, empty gradient histogram) and hide a real break."""
rng = np.random.default_rng(seed)
base = rng.integers(0, 255, (size[1], size[0], 3), dtype=np.uint8)
ramp = np.linspace(0, 255, size[0], dtype=np.uint8)[None, :, None]
Image.fromarray(np.clip(base // 2 + ramp // 2, 0, 255).astype(np.uint8)).save(path)
return path
class TestFamilies:
def test_every_family_is_measured_on_a_textured_image(self, tmp_path: Path):
evidence = extract_pixel_evidence(_textured(tmp_path / "textured.png"))
assert evidence.decoded
for family in FAMILIES:
assert getattr(evidence, family), family
def test_the_same_image_measures_the_same_twice(self, tmp_path: Path):
"""Determinism is what makes these comparable across runs and machines; the
residual is computed in row chunks, which is exactly the kind of optimization
that can perturb the last bits."""
path = _textured(tmp_path / "textured.png")
first, second = extract_pixel_evidence(path), extract_pixel_evidence(path)
assert dataclasses.asdict(first) == dataclasses.asdict(second)
def test_source_dimensions_survive_the_downscale(self, tmp_path: Path):
"""The recorded size is the SOURCE size, read before the 2048px cap. Recording
the analysed size instead would still pass every statistic check, because
those run on the downscaled array either way."""
path = tmp_path / "oversize.png"
Image.fromarray(np.zeros((80, 3000, 3), dtype=np.uint8)).save(path)
assert extract_pixel_evidence(path).decode == {"width": 3000, "height": 80}
def test_selected_dct_coefficients_match_the_full_transform(self):
gray = np.random.default_rng(7).uniform(0, 255, (24, 32)).astype(np.float32)
basis = _dct_matrix(np)
blocks = gray.reshape(3, 8, 4, 8).swapaxes(1, 2)
full = np.einsum("ij,abjk,lk->abil", basis, blocks, basis)
bins = np.linspace(-20.5, 20.5, 22)
expected = [
np.histogram(full[:, :, row, column].ravel(), bins=bins)[0].tolist() for row, column in AC_POSITIONS
]
assert dct_features(gray)["dct_ac_hist"] == expected
def test_perceptual_hash_matches_the_full_transform(self):
gray = np.random.default_rng(8).uniform(0, 255, (32, 32)).astype(np.float32)
basis = _dct_matrix(np, 32)
coefficients = basis @ gray @ basis.T
low = coefficients[:8, :8].ravel()[1:]
bits = low > np.median(low)
expected = f"{int(''.join('1' if bit else '0' for bit in bits), 2):016x}"
assert perceptual_hash(gray) == expected
class TestDegenerateInputs:
def test_undecodable_file_reports_the_error_and_stays_empty(self, tmp_path: Path):
path = tmp_path / "broken.png"
path.write_bytes(b"not an image")
evidence = extract_pixel_evidence(path, artifacts=True)
assert evidence.decoded is False
assert "error" in evidence.decode
assert all(getattr(evidence, family) == {} for family in FAMILIES)
assert evidence.artifacts == {}
def test_image_too_small_for_a_family_leaves_it_empty(self, tmp_path: Path):
"""8x8 is below the FFT's 32px floor but at the block DCT's. A consumer must
not assume a fixed feature width, so the narrow case is pinned."""
path = tmp_path / "tiny.png"
Image.fromarray(np.arange(8 * 8 * 3, dtype=np.uint8).reshape(8, 8, 3)).save(path)
evidence = extract_pixel_evidence(path)
assert evidence.decoded is True
assert evidence.fft == {}
assert evidence.color != {}
def test_a_failing_family_does_not_lose_the_others(self, tmp_path: Path, monkeypatch):
path = _textured(tmp_path / "textured.png")
def boom(*args, **kwargs):
raise ValueError(f"family failed for {path}")
monkeypatch.setattr("remove_ai_watermarks.pixel_evidence.color_features", boom)
evidence = extract_pixel_evidence(path)
assert evidence.color == {"error": "ValueError"}
assert evidence.status == "partial"
assert evidence.to_dict()["status"] == "partial"
assert evidence.dct != {}
assert evidence.gradient != {}
class TestArtifactsAreOptIn:
"""The artifacts identify the source image -- a thumbnail is a picture, a
perceptual hash matches one. Everything else is a scalar or a fixed-length
histogram. That difference in kind is the reason for the flag, so the flag is
what these guard."""
def test_off_by_default(self, tmp_path: Path):
assert extract_pixel_evidence(_textured(tmp_path / "textured.png")).artifacts == {}
def test_on_request_it_returns_the_spatial_layer(self, tmp_path: Path):
evidence = extract_pixel_evidence(_textured(tmp_path / "textured.png"), artifacts=True)
assert set(evidence.artifacts) == {"phash", "thumbnail_jpeg_b64", "ela_map", "noise_residual", "fft_phase"}
assert len(evidence.artifacts["phash"]) == 16
def test_the_thumbnail_is_a_readable_image_of_the_source(self, tmp_path: Path):
"""Stated plainly because it is the privacy claim: this field reconstructs
the picture, at 128px."""
import base64
import io
evidence = extract_pixel_evidence(_textured(tmp_path / "textured.png"), artifacts=True)
with Image.open(io.BytesIO(base64.b64decode(evidence.artifacts["thumbnail_jpeg_b64"]))) as thumb:
assert max(thumb.size) <= 128
def test_a_different_image_hashes_differently(self, tmp_path: Path):
one = extract_pixel_evidence(_textured(tmp_path / "a.png", seed=1), artifacts=True)
two = extract_pixel_evidence(_textured(tmp_path / "b.png", seed=2), artifacts=True)
assert one.artifacts["phash"] != two.artifacts["phash"]
def test_the_statistics_carry_no_array_payloads(self, tmp_path: Path):
"""Without the flag nothing array-shaped may appear: that is what makes the
default set aggregates rather than content."""
evidence = extract_pixel_evidence(_textured(tmp_path / "textured.png"))
for family in FAMILIES:
for key, value in getattr(evidence, family).items():
assert isinstance(value, (int, float, str, list)), (family, key)
if isinstance(value, list):
for item in value:
assert isinstance(item, (int, float, list)), (family, key)
def test_is_available_reports_the_optional_dependency():
assert is_available() is True # the test environment installs the pixels extra
def test_evidence_is_frozen(tmp_path: Path):
evidence = extract_pixel_evidence(_textured(tmp_path / "textured.png"))
assert isinstance(evidence, PixelEvidence)
with pytest.raises(dataclasses.FrozenInstanceError):
evidence.decode = {} # type: ignore[misc]
def test_transport_contract_is_versioned_json_and_omits_path(tmp_path: Path):
import json
evidence = extract_pixel_evidence(_textured(tmp_path / "textured.png"), timings=True)
payload = evidence.to_dict()
assert payload["schema_version"] == PIXEL_EVIDENCE_SCHEMA_VERSION == 1
assert payload["status"] == "complete"
assert "path" not in payload
assert payload["timing_ms"]["total"] >= 0
assert json.loads(json.dumps(payload, allow_nan=False)) == payload
@pytest.mark.parametrize("schema_version", [2, True, 1.0])
def test_transport_rejects_unsupported_output_schema(tmp_path: Path, schema_version: object):
evidence = extract_pixel_evidence(_textured(tmp_path / "textured.png"))
with pytest.raises(ValueError, match="Unsupported pixel evidence schema"):
evidence.to_dict(schema_version=schema_version) # type: ignore[arg-type]
def test_decode_error_transport_does_not_leak_the_local_path(tmp_path: Path):
import json
path = tmp_path / "broken.png"
path.write_bytes(b"not an image")
payload = extract_pixel_evidence(path).to_dict()
assert payload["status"] == "error"
assert payload["decode"]["error"] == "UnidentifiedImageError"
assert str(path) not in json.dumps(payload)
def test_timings_are_opt_in(tmp_path: Path):
path = _textured(tmp_path / "textured.png")
assert extract_pixel_evidence(path).timing_ms == {}
assert set(extract_pixel_evidence(path, artifacts=True, timings=True).timing_ms) == {
"decode",
"noise",
"fft",
"ela",
"dct",
"gradient",
"color",
"full_artifacts",
"total",
}
def test_pixel_decode_registers_optional_heif_opener(tmp_path: Path, monkeypatch):
registered = False
def mark_registered():
nonlocal registered
registered = True
monkeypatch.setattr("remove_ai_watermarks.image_io._register_heif", mark_registered)
extract_pixel_evidence(_textured(tmp_path / "textured.png"))
assert registered is True
+40
View File
@@ -117,6 +117,46 @@ class TestNonBgrInputs:
assert np.array_equal(out[..., 3], bgra[..., 3])
class TestBackendTable:
"""The fill-backend names are stated in several places; they must agree.
They are deliberately separate LITERALS rather than one derived list: deriving the
CLI choices or the registry's ``Backend`` from ``region_eraser`` would give
``watermark_registry`` (and therefore every ``--help`` and every metadata-only
``identify``) a module-level cv2 import. This test keeps the copies in sync instead.
"""
def test_registry_literal_matches_the_eraser_table(self):
import typing
from remove_ai_watermarks import region_eraser, watermark_registry
assert set(typing.get_args(watermark_registry.Backend)) == set(region_eraser.FILL_BACKENDS)
def test_executable_backends_are_the_table_minus_auto(self):
import typing
from remove_ai_watermarks import region_eraser
assert set(typing.get_args(region_eraser.Backend)) == set(region_eraser.FILL_BACKENDS) - {"auto"}
def test_learned_backends_name_real_module_attributes(self):
from remove_ai_watermarks import region_eraser
for name, row in region_eraser._LEARNED_BACKENDS.items():
assert name in region_eraser.FILL_BACKENDS
assert callable(getattr(region_eraser, row.available))
assert callable(getattr(region_eraser, row.erase))
def test_unknown_backend_degrades_to_cv2_instead_of_raising(self):
"""``erase`` is public: a library caller passing ``"auto"`` (or a typo) must get
the classical fill, not a KeyError."""
img = np.full((64, 64, 3), 100, np.uint8)
mask = np.zeros((64, 64), np.uint8)
mask[20:40, 20:40] = 255
assert erase(img, mask=mask, backend="auto").shape == img.shape # type: ignore[arg-type]
class TestLamaBackend:
def test_lama_raises_when_unavailable(self):
img = np.full((100, 100, 3), 50, np.uint8)
+29
View File
@@ -18,10 +18,14 @@ from __future__ import annotations
import struct
import tracemalloc
from typing import TYPE_CHECKING
from remove_ai_watermarks import metadata
from remove_ai_watermarks._internal import c2pa, isobmff
if TYPE_CHECKING:
from pathlib import Path
PNG_SIG = b"\x89PNG\r\n\x1a\n"
_HUGE = 0x7FFFFFFF # ~2 GiB declared length on a tiny file
@@ -128,3 +132,28 @@ class TestIsobmffStripFailSafe:
assert stripped == 0
assert cleaned == data
assert len(cleaned) == len(data)
class TestRiffLateMetadataIsBounded:
"""A metadata scan must not become a near-full-file read because one chunk lied
about its length. This runs on the memoized verdict path, over images from
arbitrary sources."""
def _webp_with_declared_length(self, path: Path, declared: int, payload: bytes) -> Path:
chunk = b"XMP " + declared.to_bytes(4, "little") + payload
body = b"WEBP" + b"VP8 " + (4).to_bytes(4, "little") + b"\x00\x00\x00\x00" + chunk
path.write_bytes(b"RIFF" + len(body).to_bytes(4, "little") + body)
return path
def test_a_chunk_claiming_the_whole_file_is_clamped_to_what_remains(self, tmp_path: Path):
target = self._webp_with_declared_length(tmp_path / "liar.webp", 1 << 30, b"AI" * 64)
collected = metadata._riff_late_metadata(target, 12)
assert collected == b"AI" * 64
def test_the_total_is_capped(self, tmp_path: Path):
payload = b"x" * 4096
target = self._webp_with_declared_length(tmp_path / "big.webp", len(payload), payload)
assert len(metadata._riff_late_metadata(target, 12, max_total=512)) == 512
+51 -48
View File
@@ -1625,14 +1625,14 @@ class TestSoraTemporalArbiter:
_BOX = (40, 60, 150, 54)
def test_four_frame_lookalike_run_is_too_short(self):
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_sora_localizations
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_localizations
detections = [FrameLocalization(index, 0.70, self._BOX) for index in range(4)]
assert stabilize_sora_localizations(detections, provenance=False) == [None] * 4
assert stabilize_localizations("sora", detections, provenance=False) == [None] * 4
def test_provenance_accepts_recurring_low_contrast_visual_match(self):
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_sora_localizations
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_localizations
detections = [
FrameLocalization(0, 0.59, self._BOX),
@@ -1642,10 +1642,10 @@ class TestSoraTemporalArbiter:
FrameLocalization(4, 0.61, self._BOX),
]
assert stabilize_sora_localizations(detections, provenance=True) == [self._BOX] * 5
assert stabilize_localizations("sora", detections, provenance=True) == [self._BOX] * 5
def test_confirmed_provenance_run_covers_transition_frames(self):
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_sora_localizations
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_localizations
detections = [
FrameLocalization(0, 0.30, (500, 300, 54, 54)),
@@ -1657,10 +1657,10 @@ class TestSoraTemporalArbiter:
FrameLocalization(6, 0.30, (300, 100, 54, 54)),
]
assert stabilize_sora_localizations(detections, provenance=True) == [self._BOX] * 7
assert stabilize_localizations("sora", detections, provenance=True) == [self._BOX] * 7
def test_transition_prefers_low_score_match_at_a_confirmed_position(self):
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_sora_localizations
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_localizations
other_box = (500, 300, 150, 54)
detections = [
@@ -1678,13 +1678,13 @@ class TestSoraTemporalArbiter:
FrameLocalization(11, 0.62, other_box),
]
stabilized = stabilize_sora_localizations(detections, provenance=True)
stabilized = stabilize_localizations("sora", detections, provenance=True)
assert stabilized[6] == self._BOX
assert stabilized[7:] == [other_box] * 5
def test_transition_without_a_match_keeps_previous_stable_position(self):
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_sora_localizations
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_localizations
other_box = (500, 300, 150, 54)
detections = [
@@ -1702,12 +1702,12 @@ class TestSoraTemporalArbiter:
FrameLocalization(11, 0.62, other_box),
]
stabilized = stabilize_sora_localizations(detections, provenance=True)
stabilized = stabilize_localizations("sora", detections, provenance=True)
assert stabilized[5:7] == [self._BOX, self._BOX]
def test_unproven_weak_run_is_rejected(self):
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_sora_localizations
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_localizations
detections = [
FrameLocalization(0, 0.61, self._BOX),
@@ -1717,10 +1717,10 @@ class TestSoraTemporalArbiter:
FrameLocalization(4, 0.61, self._BOX),
]
assert stabilize_sora_localizations(detections, provenance=False) == [None] * 5
assert stabilize_localizations("sora", detections, provenance=False) == [None] * 5
def test_strong_recurring_visual_run_needs_no_metadata(self):
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_sora_localizations
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_localizations
detections = [
FrameLocalization(0, 0.61, self._BOX),
@@ -1730,10 +1730,10 @@ class TestSoraTemporalArbiter:
FrameLocalization(4, 0.62, self._BOX),
]
assert stabilize_sora_localizations(detections, provenance=False) == [self._BOX] * 5
assert stabilize_localizations("sora", detections, provenance=False) == [self._BOX] * 5
def test_isolated_lookalikes_at_different_positions_are_rejected(self):
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_sora_localizations
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_localizations
detections = [
FrameLocalization(0, 0.70, (10, 10, 150, 54)),
@@ -1741,10 +1741,10 @@ class TestSoraTemporalArbiter:
FrameLocalization(2, 0.70, (650, 400, 150, 54)),
]
assert stabilize_sora_localizations(detections, provenance=True) == [None, None, None]
assert stabilize_localizations("sora", detections, provenance=True) == [None, None, None]
def test_short_dropout_between_matching_boxes_is_filled(self):
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_sora_localizations
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_localizations
detections = [
FrameLocalization(0, 0.66, self._BOX),
@@ -1755,40 +1755,40 @@ class TestSoraTemporalArbiter:
FrameLocalization(5, 0.66, self._BOX),
]
assert stabilize_sora_localizations(detections, provenance=False) == [self._BOX] * 6
assert stabilize_localizations("sora", detections, provenance=False) == [self._BOX] * 6
class TestVeoTemporalArbiter:
_BOX = (1132, 572, 56, 56)
def test_eleven_frame_lookalike_run_is_too_short(self):
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_veo_localizations
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_localizations
detections = [FrameLocalization(index, 0.70, self._BOX) for index in range(11)]
assert stabilize_veo_localizations(detections, provenance=False) == [None] * 11
assert stabilize_localizations("veo", detections, provenance=False) == [None] * 11
def test_strong_fixed_run_covers_video_without_metadata(self):
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_veo_localizations
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_localizations
detections = [FrameLocalization(index, 0.60, self._BOX) for index in range(12)]
detections.extend(FrameLocalization(index, 0.20, (300, 200, 48, 48)) for index in range(12, 15))
assert stabilize_veo_localizations(detections, provenance=False) == [self._BOX] * 15
assert stabilize_localizations("veo", detections, provenance=False) == [self._BOX] * 15
def test_google_provenance_accepts_recurring_low_contrast_diamond(self):
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_veo_localizations
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_localizations
detections = [FrameLocalization(index, 0.47, self._BOX) for index in range(12)]
assert stabilize_veo_localizations(detections, provenance=True) == [self._BOX] * 12
assert stabilize_localizations("veo", detections, provenance=True) == [self._BOX] * 12
def test_unproven_weak_run_is_rejected(self):
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_veo_localizations
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_localizations
detections = [FrameLocalization(index, 0.52, self._BOX) for index in range(12)]
assert stabilize_veo_localizations(detections, provenance=False) == [None] * 12
assert stabilize_localizations("veo", detections, provenance=False) == [None] * 12
class TestByteDanceTemporalArbiter:
@@ -1796,47 +1796,47 @@ class TestByteDanceTemporalArbiter:
_DOLA_BOX = (1160, 680, 96, 22)
def test_seedance_requires_twelve_recurring_frames(self):
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_seedance_localizations
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_localizations
detections = [FrameLocalization(index, 0.50, self._SEEDANCE_BOX) for index in range(11)]
assert stabilize_seedance_localizations(detections, provenance=False) == [None] * 11
assert stabilize_localizations("seedance", detections, provenance=False) == [None] * 11
def test_seedance_strong_run_covers_low_contrast_frames(self):
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_seedance_localizations
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_localizations
detections = [FrameLocalization(index, 0.45, self._SEEDANCE_BOX) for index in range(12)]
detections.extend(FrameLocalization(index, 0.20, (200, 100, 80, 60)) for index in range(12, 15))
assert stabilize_seedance_localizations(detections, provenance=False) == [self._SEEDANCE_BOX] * 15
assert stabilize_localizations("seedance", detections, provenance=False) == [self._SEEDANCE_BOX] * 15
def test_seedance_rejects_a_slowly_drifting_scene_detail(self):
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_seedance_localizations
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_localizations
detections = [FrameLocalization(index, 0.46, (1110 - index * 3, 610, 90, 66)) for index in range(14)]
assert stabilize_seedance_localizations(detections, provenance=False) == [None] * 14
assert stabilize_localizations("seedance", detections, provenance=False) == [None] * 14
def test_dola_requires_twelve_recurring_frames(self):
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_dola_localizations
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_localizations
detections = [FrameLocalization(index, 0.60, self._DOLA_BOX) for index in range(11)]
assert stabilize_dola_localizations(detections, provenance=True) == [None] * 11
assert stabilize_localizations("dola", detections, provenance=True) == [None] * 11
def test_dola_provenance_accepts_recurring_low_contrast_text(self):
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_dola_localizations
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_localizations
detections = [FrameLocalization(index, 0.49, self._DOLA_BOX) for index in range(12)]
assert stabilize_dola_localizations(detections, provenance=True) == [self._DOLA_BOX] * 12
assert stabilize_localizations("dola", detections, provenance=True) == [self._DOLA_BOX] * 12
def test_dola_without_provenance_needs_a_strong_frame(self):
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_dola_localizations
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_localizations
detections = [FrameLocalization(index, 0.51, self._DOLA_BOX) for index in range(12)]
assert stabilize_dola_localizations(detections, provenance=False) == [None] * 12
assert stabilize_localizations("dola", detections, provenance=False) == [None] * 12
def test_bytedance_provenance_requires_ai_source_type(self):
from remove_ai_watermarks.video_visible import has_bytedance_video_provenance
@@ -1855,23 +1855,24 @@ class TestAdditionalProviderTemporalArbiter:
_KLING_BOX = (1018, 664, 245, 42)
@pytest.mark.parametrize(
("stabilizer_name", "box", "weak_score", "strong_score"),
("mark", "box", "weak_score", "strong_score"),
[
("stabilize_hailuo_localizations", _HAILUO_BOX, 0.31, 0.35),
("stabilize_kling_localizations", _KLING_BOX, 0.21, 0.25),
("hailuo", _HAILUO_BOX, 0.31, 0.35),
("kling", _KLING_BOX, 0.21, 0.25),
],
)
def test_requires_a_strong_anchored_twelve_frame_run(
self,
stabilizer_name: str,
mark: str,
box: tuple[int, int, int, int],
weak_score: float,
strong_score: float,
):
from remove_ai_watermarks import video_visible
from remove_ai_watermarks.video_visible import FrameLocalization
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_localizations
def stabilize(dets):
return stabilize_localizations(mark, dets)
stabilize = getattr(video_visible, stabilizer_name)
weak = [FrameLocalization(index, weak_score, box) for index in range(12)]
strong = [FrameLocalization(index, strong_score, box) for index in range(12)]
@@ -2165,7 +2166,7 @@ class TestVideoVisibleFullClip:
def test_removes_complete_clip_and_preserves_sequence_and_audio(self, tmp_path: Path):
from remove_ai_watermarks.metadata import get_ai_metadata
from remove_ai_watermarks.video import remove_video_all, remove_video_metadata, remove_video_visible
from remove_ai_watermarks.video_visible import scan_video_marks, stabilize_sora_localizations
from remove_ai_watermarks.video_visible import scan_video_marks, stabilize_localizations
ffmpeg, ffprobe = _ffmpeg_test_tools()
frame_count = 18
@@ -2273,14 +2274,16 @@ class TestVideoVisibleFullClip:
all_output_scan = scan_video_marks(all_output, ("sora",))["sora"]
assert all(
region is None
for region in stabilize_sora_localizations(
for region in stabilize_localizations(
"sora",
output_scan.detections,
provenance=False,
)
)
assert all(
region is None
for region in stabilize_sora_localizations(
for region in stabilize_localizations(
"sora",
all_output_scan.detections,
provenance=False,
)
+189
View File
@@ -51,6 +51,52 @@ class TestCatalog:
with pytest.raises(KeyError):
reg.get_mark("nope")
def test_every_registered_key_has_an_engine_row(self):
assert set(reg._ENGINE_CLASS) == set(reg.mark_keys())
def test_unknown_engine_key_raises(self):
with pytest.raises(KeyError):
reg._engine("nope")
class TestEngineImportsStayLazy:
"""The engine table holds NAMES, not imports.
``identify`` imports ``watermark_registry`` at module scope for a metadata-only
scan, and the engines pull cv2. If the table were ever built from real imports,
every ``--help`` and every metadata-only ``identify`` would pay for cv2 -- the
exact regression a name table can silently introduce. Run in a subprocess because
the in-process ``sys.modules`` is already polluted by the rest of the suite.
"""
def _modules_after(self, statements: str) -> set[str]:
import json
import subprocess
import sys
script = f"import sys, json\n{statements}\nprint(json.dumps(sorted(sys.modules)))"
out = subprocess.run( # noqa: S603 - argv is this interpreter plus a literal script
[sys.executable, "-c", script], capture_output=True, text=True, check=True
)
return set(json.loads(out.stdout))
def test_importing_the_registry_pulls_no_engine_and_no_cv2(self):
loaded = self._modules_after("import remove_ai_watermarks.watermark_registry")
assert "remove_ai_watermarks.gemini_engine" not in loaded
assert "remove_ai_watermarks.doubao_engine" not in loaded
assert "cv2" not in loaded
def test_importing_identify_pulls_no_engine_and_no_cv2(self):
# identify.py imports watermark_registry at module scope, so this -- not the
# registry alone -- is the real dependency-light surface.
loaded = self._modules_after("import remove_ai_watermarks.identify")
assert "remove_ai_watermarks.gemini_engine" not in loaded
assert "cv2" not in loaded
def test_resolving_an_engine_imports_it(self):
loaded = self._modules_after("from remove_ai_watermarks import watermark_registry as r\nr._engine('gemini')")
assert "remove_ai_watermarks.gemini_engine" in loaded
class TestScan:
def test_detect_marks_scans_all(self):
@@ -410,6 +456,149 @@ class TestArbiter:
assert fired == {"jimeng", "jimeng_pill"}
class TestSinglePassPerception:
"""``detect_both`` must equal two ``detect`` calls, for strictly less work.
The arbiter's perception pass runs every detector at both trust levels. The trust
level only moves a threshold (Gemini: whether a false-positive gate demotes the
result afterwards), never the measurement -- so the expensive scan is shared. If
that ever stops being true, this test is what says so.
"""
@pytest.mark.parametrize(
"image",
[
pytest.param(np.full((256, 256, 3), 100, np.uint8), id="clean"),
pytest.param(np.zeros((40, 40, 3), np.uint8), id="below-the-size-floor"),
],
)
def test_dual_verdict_matches_two_separate_detects(self, image):
for m in reg.known_marks():
strict, relaxed = m.detect_both(image)
for one, two, level in (
(strict, m.detect(image, provenance=False), "strict"),
(relaxed, m.detect(image, provenance=True), "relaxed"),
):
assert one.detected == two.detected, f"{m.key} {level}"
assert one.confidence == two.confidence, f"{m.key} {level}"
assert one.region == two.region, f"{m.key} {level}"
@pytest.mark.skipif(not DOUBAO_SAMPLE.exists(), reason="doubao sample not present")
def test_dual_verdict_matches_on_a_real_positive(self):
from remove_ai_watermarks import image_io
image = image_io.imread(DOUBAO_SAMPLE)
for m in reg.known_marks():
strict, relaxed = m.detect_both(image)
assert (strict.detected, strict.confidence) == (
m.detect(image, provenance=False).detected,
m.detect(image, provenance=False).confidence,
), m.key
assert (relaxed.detected, relaxed.confidence) == (
m.detect(image, provenance=True).detected,
m.detect(image, provenance=True).confidence,
), m.key
@pytest.mark.skipif(not DOUBAO_SAMPLE.exists(), reason="doubao sample not present")
def test_perception_scans_each_mark_once(self, monkeypatch: pytest.MonkeyPatch):
"""The whole point: one template sweep per mark, not two."""
import cv2
from remove_ai_watermarks import image_io
image = image_io.imread(DOUBAO_SAMPLE)
calls = [0]
real = cv2.matchTemplate
def counted(*args, **kwargs):
calls[0] += 1
return real(*args, **kwargs)
monkeypatch.setattr(cv2, "matchTemplate", counted)
reg._build_candidates(image)
single = calls[0]
calls[0] = 0
for m in reg.known_marks():
m.detect(image, provenance=False)
m.detect(image, provenance=True)
assert single * 2 <= calls[0] + 2, f"perception did {single} sweeps vs {calls[0]} for two passes"
class TestMarkKnowledgeIsOnTheRow:
"""Registering a mark is ONE edit: the row carries everything about it.
Product family, label regime, the platform sentence and the metadata signals that
confirm the vendor all used to live in separate hand-maintained tables across
``watermark_registry``, ``identify`` and ``api``. That is how LibLibAI ended up
registered but absent from the pill veto.
"""
def test_identify_platform_table_is_derived_from_the_rows(self):
from remove_ai_watermarks.identify import _VISIBLE_MARK_PLATFORM
assert {m.key: m.platform for m in reg.known_marks() if m.platform is not None} == _VISIBLE_MARK_PLATFORM
def test_the_platformless_marks_are_the_two_with_their_own_paths(self):
"""Gemini has the higher-confidence sparkle path; the pill is too weak to
attribute. Everything else must name a platform or `identify` reports none."""
assert {m.key for m in reg.known_marks() if m.platform is None} == {"gemini", "jimeng_pill"}
def test_platform_scan_order_follows_the_registry(self):
"""`identify` takes the FIRST platform match, so the order is load-bearing and
must stay the registry's specificity order rather than a dict literal's."""
from remove_ai_watermarks.identify import _VISIBLE_MARK_PLATFORM
assert list(_VISIBLE_MARK_PLATFORM) == [m.key for m in reg.known_marks() if m.platform is not None]
def test_every_tc260_mark_declares_the_aigc_signal(self):
for mark in reg.known_marks():
if mark.label_regime == "tc260" and mark.key != "jimeng_pill":
assert "aigc" in mark.provenance_signals, mark.key
def test_only_gemini_claims_platform_tokens(self):
by_token = {m.key for m in reg.known_marks() if m.provenance_platform_tokens}
assert by_token == {"gemini"}
class TestPillSuppressors:
"""The pill veto is derived from the registry, not hand-listed.
The hand-written list drifted: LibLibAI was registered in the same commit as
RunningHub and Baidu, both of which were added to the veto, and it was not. A
derived set cannot be forgotten by the next registration.
"""
def test_every_other_tc260_product_suppresses_the_pill(self):
expected = {
m.key
for m in reg.known_marks()
if m.label_regime == "tc260" and m.product != reg.get_mark("jimeng_pill").product
}
assert reg._pill_suppressors() == expected
assert "liblib" in expected
def test_pill_dropped_on_liblib(self):
assert not reg._keep_pill({"liblib"}, provenance=frozenset({"jimeng"}), footprint_flat=1.0)
def test_pill_dropped_on_liblib_even_with_the_jimeng_wordmark(self):
"""The veto precedes the wordmark arm, so a co-firing LibLibAI wins.
This is the broader half of the change: it needs neither TC260 provenance nor
a flat footprint, so it is reachable on more inputs than the metadata arm.
"""
assert not reg._keep_pill({"liblib", "jimeng"}, provenance=frozenset(), footprint_flat=1.0)
def test_pill_survives_gemini_and_samsung(self):
"""Neither is a TC260 labeller, and neither can put "jimeng" into provenance,
so neither may veto the arm it could not have enabled."""
assert reg._keep_pill({"gemini", "jimeng"}, provenance=frozenset(), footprint_flat=1.0)
assert reg._keep_pill({"samsung", "jimeng"}, provenance=frozenset(), footprint_flat=1.0)
def test_product_map_is_derived_from_the_rows(self):
assert {m.key: m.product for m in reg.known_marks()} == reg._PRODUCT_OF
assert reg._PRODUCT_OF["jimeng_pill"] == "jimeng" # the one shared product
class TestProvenanceMaskThreading:
"""Regression for the provenance-relaxed Gemini no-op (#1) and the false 'removed'
label (#2). Before the fix, footprint_mask re-detected WITHOUT trust_provenance, the
Generated
+294 -294
View File
@@ -394,112 +394,112 @@ wheels = [
[[package]]
name = "cffi"
version = "2.1.0"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" }
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/e9/6d7724983b3d5a0908dbf74f64038ade77c18646ff6636ec7894fd392ce1/cffi-2.1.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0", size = 183837, upload-time = "2026-07-06T21:32:09.655Z" },
{ url = "https://files.pythonhosted.org/packages/69/aa/24580a278de21fd7322635556334d9b535f1cbc00b0a3919447cdf464c65/cffi-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd", size = 184226, upload-time = "2026-07-06T21:32:11.196Z" },
{ url = "https://files.pythonhosted.org/packages/88/a9/02cae418ec4beb282ace11958d9d4737793439d561fadc7e6d56f2e2b354/cffi-2.1.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46", size = 211107, upload-time = "2026-07-06T21:32:12.328Z" },
{ url = "https://files.pythonhosted.org/packages/3b/30/c806937ed5e4c2c7ac30d9d6b76b5dc57ff8b75d83800d9bb11a8253cf2a/cffi-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2", size = 218733, upload-time = "2026-07-06T21:32:13.67Z" },
{ url = "https://files.pythonhosted.org/packages/f9/cf/398272b8bbfd58aa314fda5a7f1cdbb26d1d78ae324a11211521315dd1f0/cffi-2.1.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd", size = 205543, upload-time = "2026-07-06T21:32:15.148Z" },
{ url = "https://files.pythonhosted.org/packages/45/ca/f91641185cdd90c36d317a9dc7f85e88ef8682d8b300977baff5e23c35d8/cffi-2.1.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3", size = 205460, upload-time = "2026-07-06T21:32:16.479Z" },
{ url = "https://files.pythonhosted.org/packages/38/66/04781a77b411f0bb5b234d62c1814754ab75ebe455ccff1b08e8d7aae98f/cffi-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0", size = 218760, upload-time = "2026-07-06T21:32:17.98Z" },
{ url = "https://files.pythonhosted.org/packages/d0/9a/bb1d5ed9c3fcae158e9f6391bf309c95d98c2ac37ed56573228471d0af5e/cffi-2.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43", size = 221230, upload-time = "2026-07-06T21:32:19.407Z" },
{ url = "https://files.pythonhosted.org/packages/41/aa/3c1409cdd26094efacd1c36c66e0a6eb9d4296e4fd4f9901b8b2042f4323/cffi-2.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c", size = 213524, upload-time = "2026-07-06T21:32:20.828Z" },
{ url = "https://files.pythonhosted.org/packages/fa/75/74dfb7c3fc6ebbd408038476bd4c1d7e925c62614e7b9c534ecc34218288/cffi-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd", size = 220341, upload-time = "2026-07-06T21:32:21.9Z" },
{ url = "https://files.pythonhosted.org/packages/70/b6/9003c33a3e7d2c1306f5962e646457dcfe5a8cd8fce6bbe02d7af25db783/cffi-2.1.0-cp310-cp310-win32.whl", hash = "sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f", size = 174578, upload-time = "2026-07-06T21:32:23.073Z" },
{ url = "https://files.pythonhosted.org/packages/8a/26/710688310447531c7a22f857c7f79d9855ec18b03e04494ced723fb37e2f/cffi-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da", size = 185071, upload-time = "2026-07-06T21:32:24.671Z" },
{ url = "https://files.pythonhosted.org/packages/d3/67/85c89a59ba36a671e79638f44d466749f08179266a57e4f2ffdf92174072/cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc", size = 183845, upload-time = "2026-07-06T21:32:26.32Z" },
{ url = "https://files.pythonhosted.org/packages/ea/dd/e3b0baa2d3d6a857ac72b7efbf18e32e487c9cdafcc13049ad765495b15e/cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7", size = 184186, upload-time = "2026-07-06T21:32:28.025Z" },
{ url = "https://files.pythonhosted.org/packages/65/68/9f3ef890cf3c6ab97bd531c5677f67613d302165d16f8142b2811782a614/cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93", size = 211892, upload-time = "2026-07-06T21:32:29.565Z" },
{ url = "https://files.pythonhosted.org/packages/22/d7/1a74539db16d8bfd839ff1515948948efbb162e574650fd3d846896eea95/cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2", size = 218793, upload-time = "2026-07-06T21:32:30.951Z" },
{ url = "https://files.pythonhosted.org/packages/ec/d1/9a5b7169499e8e8d8e636de70b97ac7c9447104d2ff1a2cd94790cea5162/cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c", size = 205737, upload-time = "2026-07-06T21:32:32.216Z" },
{ url = "https://files.pythonhosted.org/packages/ba/b0/e131a9c41f10607926278453d9596163594fe1c4ebc46efe3b5e5b34eb84/cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f", size = 204909, upload-time = "2026-07-06T21:32:33.655Z" },
{ url = "https://files.pythonhosted.org/packages/fb/d2/4398416cd699b35167947c6e22aca52c47e69ad5695073c9f1f2c52e04aa/cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565", size = 217883, upload-time = "2026-07-06T21:32:35.173Z" },
{ url = "https://files.pythonhosted.org/packages/a2/a5/d4fe77b589e5e82d43ebc809bf2e6474afe8e48e32ea050b9357645b6471/cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c", size = 221251, upload-time = "2026-07-06T21:32:36.527Z" },
{ url = "https://files.pythonhosted.org/packages/22/f0/a2fc43084c0433caf7f461bccc013e28f848d04ee1c5ed7fce71423cf4d9/cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02", size = 214250, upload-time = "2026-07-06T21:32:37.852Z" },
{ url = "https://files.pythonhosted.org/packages/04/8c/b925975448cf20634a9fbd5efceb807219db452653648d2897c0989cab2d/cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e", size = 219441, upload-time = "2026-07-06T21:32:39.146Z" },
{ url = "https://files.pythonhosted.org/packages/eb/da/5c4918a2d61d86fa927d716cb3d8e4626ef8dc8f605a599d32f33897f59a/cffi-2.1.0-cp311-cp311-win32.whl", hash = "sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479", size = 174496, upload-time = "2026-07-06T21:32:40.467Z" },
{ url = "https://files.pythonhosted.org/packages/f9/c8/6c2de1d55cf35ef8b92885d5ef280790f0fb9634d87ea1cc315176aecd61/cffi-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458", size = 185113, upload-time = "2026-07-06T21:32:41.761Z" },
{ url = "https://files.pythonhosted.org/packages/9e/4e/e8d7cb5783f1841a3c8fb3a7735838d7484d08ec08c9f984b14cac1ac0e9/cffi-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d", size = 179927, upload-time = "2026-07-06T21:32:42.961Z" },
{ url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" },
{ url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" },
{ url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" },
{ url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" },
{ url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" },
{ url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" },
{ url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" },
{ url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" },
{ url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" },
{ url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" },
{ url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" },
{ url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" },
{ url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" },
{ url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" },
{ url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" },
{ url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" },
{ url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" },
{ url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" },
{ url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" },
{ url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" },
{ url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" },
{ url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" },
{ url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" },
{ url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" },
{ url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" },
{ url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" },
{ url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" },
{ url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" },
{ url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" },
{ url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" },
{ url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" },
{ url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" },
{ url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" },
{ url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" },
{ url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" },
{ url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" },
{ url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" },
{ url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" },
{ url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" },
{ url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" },
{ url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" },
{ url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" },
{ url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" },
{ url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" },
{ url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" },
{ url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" },
{ url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" },
{ url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" },
{ url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" },
{ url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" },
{ url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" },
{ url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" },
{ url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" },
{ url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" },
{ url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" },
{ url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" },
{ url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" },
{ url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" },
{ url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" },
{ url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" },
{ url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" },
{ url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" },
{ url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" },
{ url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" },
{ url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" },
{ url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" },
{ url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" },
{ url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" },
{ url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" },
{ url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" },
{ url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" },
{ url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" },
{ url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" },
{ url = "https://files.pythonhosted.org/packages/b6/d2/2cde336b375f55c76ca670f0be3978cc048e31e24f3b4d7ce8473150a388/cffi-2.1.1-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be", size = 183779, upload-time = "2026-08-03T21:19:15.602Z" },
{ url = "https://files.pythonhosted.org/packages/94/1a/4b2f7c92293ba05cbd4a9a1b28faaf0326272d9488e6354657571c48a7aa/cffi-2.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b", size = 184178, upload-time = "2026-08-03T21:19:16.67Z" },
{ url = "https://files.pythonhosted.org/packages/17/0b/ba385d8ccedf926c3cd06e8e2f327027da5afe5f0eb30f1f7bc43ac55125/cffi-2.1.1-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004", size = 211037, upload-time = "2026-08-03T21:19:17.705Z" },
{ url = "https://files.pythonhosted.org/packages/a3/b9/0f2e58b2cefa33255bff36935d42b13180fe559bba82596540eb404bde7d/cffi-2.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9", size = 218652, upload-time = "2026-08-03T21:19:18.735Z" },
{ url = "https://files.pythonhosted.org/packages/37/15/180e0dab27b9312c7479003d14c9e547634b7dcb934e2cc4650e1b131a7a/cffi-2.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98", size = 205422, upload-time = "2026-08-03T21:19:19.96Z" },
{ url = "https://files.pythonhosted.org/packages/18/d4/03026f0c850cbbaa9030750490225b4a7f4d524ea4df72c3cc740a90f4ef/cffi-2.1.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9", size = 205444, upload-time = "2026-08-03T21:19:21.246Z" },
{ url = "https://files.pythonhosted.org/packages/75/77/60bebf6f818bec84210ac5b6979ce4eeadce6fbbaabc9c7ab23e506d1ce5/cffi-2.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6", size = 218742, upload-time = "2026-08-03T21:19:22.523Z" },
{ url = "https://files.pythonhosted.org/packages/b0/ae/679bf47e73fd77b352171727f07de559a003f14de5d02b904a6ec1fa73ca/cffi-2.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf", size = 221054, upload-time = "2026-08-03T21:19:23.694Z" },
{ url = "https://files.pythonhosted.org/packages/09/b8/eefc0e06913b70aa153bf74c946094a18f58fd4aff11b7f372bfdfdca050/cffi-2.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659", size = 213489, upload-time = "2026-08-03T21:19:24.922Z" },
{ url = "https://files.pythonhosted.org/packages/6f/13/4e56852824a03cdf68523a35686f1c28eacd4bd30a7b0a78e682e6e6e1d3/cffi-2.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9", size = 220241, upload-time = "2026-08-03T21:19:26.214Z" },
{ url = "https://files.pythonhosted.org/packages/99/7f/040f9e163e4acac3ee3d85b02d00b2576e7ca980d8785f0a3a5f1a9bf7f5/cffi-2.1.1-cp310-cp310-win32.whl", hash = "sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41", size = 174578, upload-time = "2026-08-03T21:19:27.338Z" },
{ url = "https://files.pythonhosted.org/packages/ba/0b/644a2ec1a4eaba49c2939410bb1eb1d25b09d6d0582f5d2f95c537043725/cffi-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1", size = 185082, upload-time = "2026-08-03T21:19:28.409Z" },
{ url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" },
{ url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" },
{ url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" },
{ url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" },
{ url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" },
{ url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" },
{ url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" },
{ url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" },
{ url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" },
{ url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" },
{ url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" },
{ url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" },
{ url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" },
{ url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" },
{ url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" },
{ url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" },
{ url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" },
{ url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" },
{ url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" },
{ url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" },
{ url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" },
{ url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" },
{ url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" },
{ url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" },
{ url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" },
{ url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" },
{ url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" },
{ url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" },
{ url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" },
{ url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" },
{ url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" },
{ url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" },
{ url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" },
{ url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" },
{ url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" },
{ url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" },
{ url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" },
{ url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" },
{ url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" },
{ url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" },
{ url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" },
{ url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" },
{ url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" },
{ url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" },
{ url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" },
{ url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" },
{ url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" },
{ url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" },
{ url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" },
{ url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" },
{ url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" },
{ url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" },
{ url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" },
{ url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" },
{ url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" },
{ url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" },
{ url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" },
{ url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" },
{ url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" },
{ url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" },
{ url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" },
{ url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" },
{ url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" },
{ url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" },
{ url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" },
{ url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" },
{ url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" },
{ url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" },
{ url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" },
{ url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" },
{ url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" },
{ url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" },
{ url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" },
{ url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" },
{ url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" },
{ url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" },
{ url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" },
{ url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" },
{ url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" },
{ url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" },
{ url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" },
{ url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" },
{ url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" },
{ url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" },
{ url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" },
]
[[package]]
@@ -615,7 +615,7 @@ name = "coloredlogs"
version = "15.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "humanfriendly", marker = "python_full_version < '3.11'" },
{ name = "humanfriendly" },
]
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 = [
@@ -624,100 +624,100 @@ wheels = [
[[package]]
name = "coverage"
version = "7.15.2"
version = "7.15.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f4/45/78dbf9604ee5b3db24efbf26bed1cb58862fb40480cba821963c69348751/coverage-7.15.3.tar.gz", hash = "sha256:ae7ea5a4614acf399ef0483c4cb34f8f8f01df848d8fcbe7d3ce0865733f1c4d", size = 935592, upload-time = "2026-08-02T18:50:17.006Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/03/060ce69008ac97bbc01b1411b3e55b61f6f015659400b46749b662107831/coverage-7.15.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9b5bd92ff1ec22e535eab0de75fa6db021992791f461a2aceb7822c625a1187d", size = 221284, upload-time = "2026-07-15T18:53:29.52Z" },
{ url = "https://files.pythonhosted.org/packages/fc/a3/d936e8b53edd9684100a6aefaf3fcabaa54728fe33324436c8d279c047aa/coverage-7.15.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:44826758cfe73fcd0e6af5deb4ba6d5417cc1d13df3acb35c93484a11160f846", size = 221799, upload-time = "2026-07-15T18:53:31.708Z" },
{ url = "https://files.pythonhosted.org/packages/ae/a3/ca234b06aec7ee28226f11d39a696b4481fe5eddfce8e03bf39979bb8ffb/coverage-7.15.2-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:09f5c6ec5901f667bd97dd140b5b9a2586b10efec66f46fb1e6d8135f8b95bdf", size = 248544, upload-time = "2026-07-15T18:53:33.212Z" },
{ url = "https://files.pythonhosted.org/packages/2b/89/dda79527bb7573ba91828b2fb91b3105d87378d6a2749ca0c0924ce0addd/coverage-7.15.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1d16e3a7104ea84f03e614611b3edbf6fb6892554b3ab0fe7fbb3f2b2ef04376", size = 250374, upload-time = "2026-07-15T18:53:34.683Z" },
{ url = "https://files.pythonhosted.org/packages/67/c6/c33755a34572f81f49a8c0cdf6b622f35ccb3238b136e1909daf0cdd4319/coverage-7.15.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d46e62cb35d91e6e2589fda6d28074426b0e276422b5d2ebef2c6b11dc60dbfd", size = 252239, upload-time = "2026-07-15T18:53:36.205Z" },
{ url = "https://files.pythonhosted.org/packages/b9/6f/dc341741b375be53a5baeee5b4bf0f0e525d38caed428f7932d23bb7bcb1/coverage-7.15.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dfd3db045e95960ae3683059571e597fda7cc610106a8916f77c5839048c1deb", size = 254150, upload-time = "2026-07-15T18:53:37.863Z" },
{ url = "https://files.pythonhosted.org/packages/e9/8d/966a18a5b195cb4e77b14c53f5f3dce22b5da05e6de7fafd1e08f2d2067a/coverage-7.15.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:affd532502d34c0472d0cdb181325c89f1d2c44992fef0c17e88e7b1576259a1", size = 249234, upload-time = "2026-07-15T18:53:39.394Z" },
{ url = "https://files.pythonhosted.org/packages/c5/8b/8b2e367496ab48484d48e79984fec76cdc1b7cb5d3a00ee799a5602e3ec9/coverage-7.15.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d17d7512151fedfcc64c1821a8977fc9be0dbf495754669afcab7b57abc98ae9", size = 250276, upload-time = "2026-07-15T18:53:41.027Z" },
{ url = "https://files.pythonhosted.org/packages/63/92/1199318a200eb6c8c6ce0192c892c8710ac791abbe0f35099294620bbfda/coverage-7.15.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e26ff680768b8095e8874aabe0e9d3a47a2a9f176a8340d05f8604c56457c23a", size = 248283, upload-time = "2026-07-15T18:53:42.557Z" },
{ url = "https://files.pythonhosted.org/packages/56/da/be284a55c5619bda891a89c27dfd59324a2c6a14d755cf6aac6960ceebeb/coverage-7.15.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:7e8f27131dc7cd53de2c137dd207b3720919320b3c20d499dc30aa9ee6173287", size = 252093, upload-time = "2026-07-15T18:53:44.271Z" },
{ url = "https://files.pythonhosted.org/packages/d4/53/ee112da833ddd77b73c6d781a98029b45b584b136615b4900ed0569f887e/coverage-7.15.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:728a33676d4c3f0db977990a4bd421dcaa3be3e53b5b6273036fff6666008e89", size = 248552, upload-time = "2026-07-15T18:53:45.7Z" },
{ url = "https://files.pythonhosted.org/packages/82/6a/802cfc802e9113494c80bf3f284cd4d72faeb1f24e244f61046af364f2ca/coverage-7.15.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:29c052f7c83ccfcc5c577eaae025d2e4a9bb80daf03c0ac31c996e83b000ce88", size = 249154, upload-time = "2026-07-15T18:53:47.256Z" },
{ url = "https://files.pythonhosted.org/packages/2c/65/529808e91d651147edae408fd9e894abc3b8cad7f3e594bbc36719a3e13a/coverage-7.15.2-cp310-cp310-win32.whl", hash = "sha256:1268ac8fb9ddcd783d3948dbabaf80a5d53bfdaa0575e873e2139a692f797443", size = 223334, upload-time = "2026-07-15T18:53:48.768Z" },
{ url = "https://files.pythonhosted.org/packages/68/0f/0e1829d7001130876dfbc0b4e1c737ea7c155b809e3e4a98a0aa268e2369/coverage-7.15.2-cp310-cp310-win_amd64.whl", hash = "sha256:9f4432898c4bf2fba0435bbe35dd4437d7264565e5a88a21f5b49d8662a6b629", size = 223959, upload-time = "2026-07-15T18:53:50.429Z" },
{ url = "https://files.pythonhosted.org/packages/7d/3a/54536704f507d4573bf9161c4d0dd3dd59b6d85e48c664e901b6844d8e33/coverage-7.15.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f1ec6f304b156669cfde653b4e9a953f5de87e247ea02ac599bce0ab2744036", size = 221414, upload-time = "2026-07-15T18:53:51.941Z" },
{ url = "https://files.pythonhosted.org/packages/b6/d9/8ba925d29743e3577b21e4d8c11a702b76bc93c41e7fdfd1177af63d4b8d/coverage-7.15.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d3361879d736f469f45723c11ea1a5bbdaf1f6928f0e632c940378b5aa9b660", size = 221913, upload-time = "2026-07-15T18:53:53.682Z" },
{ url = "https://files.pythonhosted.org/packages/09/54/a855f3aa0187f2b431ade4e4791b77b56282cfb5d201c83ec26a31b5b36a/coverage-7.15.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c6a98d698f9e2c8008d0370ec7fc452ebfcc530002ae2d0061170d768b992589", size = 252332, upload-time = "2026-07-15T18:53:55.467Z" },
{ url = "https://files.pythonhosted.org/packages/8e/d3/13ac97b4370640ba3452fc8559b06cc2f479ce3ba4a0b632a73e44c38a7d/coverage-7.15.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d50dd325e18ec25bfcc10cd7f99b04df1ab9ec76b0918c260e60817ad0643dee", size = 254243, upload-time = "2026-07-15T18:53:57.055Z" },
{ url = "https://files.pythonhosted.org/packages/88/83/5eca144942d8d0659d3f55176517f4a59cdc65eefd17146a0770935a3ebd/coverage-7.15.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67d7602480a47bdf5b675635403625553ebaa70d5a62a657c035149fd401cea0", size = 256352, upload-time = "2026-07-15T18:53:58.83Z" },
{ url = "https://files.pythonhosted.org/packages/4e/ba/d3db2e01a50fc88cdb4c0f19542bcf6f61489e34dc9aa3538413e2459a38/coverage-7.15.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cee0f89f4767a6057c8fbf168f8135f18be651300496086bd873e3189fed0487", size = 258313, upload-time = "2026-07-15T18:54:00.497Z" },
{ url = "https://files.pythonhosted.org/packages/78/b3/aba83416e9177df28e5186d856c19158c59fc0e7e814aaa61a4a2354ad1b/coverage-7.15.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a29ec5305a7335aacee2d799e3422e91e1c8a12474986e2b3b07e315c91be82f", size = 252449, upload-time = "2026-07-15T18:54:02.456Z" },
{ url = "https://files.pythonhosted.org/packages/6a/a5/4b00ecac0194431ab451b0f6710f8e2517d04cef60f821b14dec4637d575/coverage-7.15.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:48ccc6395958eda89093ecdc35644c86f23a8b23a7f4d44958812b721aad67c1", size = 254043, upload-time = "2026-07-15T18:54:04.072Z" },
{ url = "https://files.pythonhosted.org/packages/75/b6/cfa209b4313ee7f1b34da47efcd789ea51c024ad35af390e00f5a3c10a2e/coverage-7.15.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:81f382c5a94b434ec1f6da607edb904c76d7212e618cd4d1bc9f97bed4120ef5", size = 252107, upload-time = "2026-07-15T18:54:06.745Z" },
{ url = "https://files.pythonhosted.org/packages/36/67/e8cac5a6954038c98d7fe7eb9802afe7ab3ecb637bb7cc00e69b4148b56d/coverage-7.15.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bbc808daf4f5cd567af8075ecc72d21c6dfef9a254709a621a84c217c935ebc0", size = 255873, upload-time = "2026-07-15T18:54:08.48Z" },
{ url = "https://files.pythonhosted.org/packages/2c/92/395cca9f330a86c3fe3471d73e2c102116c4c58fdc619dbbc125c6e93a54/coverage-7.15.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a4c46b247b5d4b78f613bd89fea926d32b25c6cc61a50bd1e99ba310348f3dad", size = 251826, upload-time = "2026-07-15T18:54:10.083Z" },
{ url = "https://files.pythonhosted.org/packages/51/60/3e91b20295439652424f426b7086ec5bf4fbe3f604c73eda22b986c4fd6b/coverage-7.15.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:094dd37f3ef7b2da8b068b583d1f4c40f91c65197e16c52a71962d5d537fc5db", size = 252735, upload-time = "2026-07-15T18:54:11.878Z" },
{ url = "https://files.pythonhosted.org/packages/a5/eb/8c07839005e5e3c6b3877d3a6e2a80ce766589f31dd2b6882b78d59a7b8c/coverage-7.15.2-cp311-cp311-win32.whl", hash = "sha256:a63b9e190711134d581c4d703df5df09851b1acf99792c7aacbbe9f41f0283c9", size = 223500, upload-time = "2026-07-15T18:54:13.525Z" },
{ url = "https://files.pythonhosted.org/packages/2e/98/59d83c257cd59f0fbaf9d9ddb26b744a576760dfd1ae16e516408894a02b/coverage-7.15.2-cp311-cp311-win_amd64.whl", hash = "sha256:8bb9f4b4279187560796a4cdaca3b0a93dd97e48ee667df005f4ed9a97403688", size = 223973, upload-time = "2026-07-15T18:54:15.163Z" },
{ url = "https://files.pythonhosted.org/packages/ea/09/2d285c8bef5c4f695d120c1c96dc11715638aa8e134069f210bb6a62a9fe/coverage-7.15.2-cp311-cp311-win_arm64.whl", hash = "sha256:8c726b232659cbd2ae57ade46509eb068c9bd7a06df9fcbff6fe484870006934", size = 223519, upload-time = "2026-07-15T18:54:16.803Z" },
{ url = "https://files.pythonhosted.org/packages/6a/50/eb5bf42e531611a9f8d272556b1ed4de503f84a91413584094487cf69f8f/coverage-7.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9", size = 221587, upload-time = "2026-07-15T18:54:18.439Z" },
{ url = "https://files.pythonhosted.org/packages/06/d1/da99af464c335d4e023a6efcd7ec30f63b88a43c93745154ab74ffb31cea/coverage-7.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73", size = 221943, upload-time = "2026-07-15T18:54:20.062Z" },
{ url = "https://files.pythonhosted.org/packages/5b/8a/13c42723d61ca447eafa18732e8141dd6a63f2732e1c7e1502c182dd88d7/coverage-7.15.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d", size = 253450, upload-time = "2026-07-15T18:54:21.765Z" },
{ url = "https://files.pythonhosted.org/packages/d7/29/99021303f98fbdcb63504b4d07bea4cc025b9b2dd907c4f07c85d50a0dab/coverage-7.15.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b", size = 256187, upload-time = "2026-07-15T18:54:23.4Z" },
{ url = "https://files.pythonhosted.org/packages/f9/a8/fd503715ed6ca9c5d742923aa5209257340b367a867b2ced0c7d4ba8a0b9/coverage-7.15.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296", size = 257301, upload-time = "2026-07-15T18:54:25.183Z" },
{ url = "https://files.pythonhosted.org/packages/da/40/3f4b8fb409810036ebc2857d36adc0498c6e957b5df0290c5036b2e143f1/coverage-7.15.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6", size = 259562, upload-time = "2026-07-15T18:54:27.204Z" },
{ url = "https://files.pythonhosted.org/packages/0b/8a/9bdffbef47db77cce3d6b02a28f7e919b19f0106c4b080c2c2246040f885/coverage-7.15.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098", size = 253841, upload-time = "2026-07-15T18:54:29.134Z" },
{ url = "https://files.pythonhosted.org/packages/1b/1e/9031efde019d31a06646261fce6dfc5c3c74e951e27a71e5c9a424563178/coverage-7.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a", size = 255221, upload-time = "2026-07-15T18:54:31.142Z" },
{ url = "https://files.pythonhosted.org/packages/56/db/787acde872389fc84a9ef9d8cd1ccc658e391ab4cb5b28092a714426a394/coverage-7.15.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b", size = 253366, upload-time = "2026-07-15T18:54:32.886Z" },
{ url = "https://files.pythonhosted.org/packages/2f/9b/6f57bc4b93c842eef1695f8cdaf2318e35e7ba54f5ba80d84be213ab7858/coverage-7.15.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2", size = 257434, upload-time = "2026-07-15T18:54:34.7Z" },
{ url = "https://files.pythonhosted.org/packages/88/26/b3186a21b2acc83e451118978905c81c7072c3333707804db09a78c096a2/coverage-7.15.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440", size = 252935, upload-time = "2026-07-15T18:54:36.548Z" },
{ url = "https://files.pythonhosted.org/packages/20/c2/c9f3376b2e717ea69ed7a6e9a5fcab968fb0b290db6cf4bd9a1fc7541b75/coverage-7.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e", size = 254807, upload-time = "2026-07-15T18:54:38.296Z" },
{ url = "https://files.pythonhosted.org/packages/f0/e1/dfc15401f4a8aaeb486e1ba3e9e3c40522a6e38bd0ecf0b3f29cb8082957/coverage-7.15.2-cp312-cp312-win32.whl", hash = "sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd", size = 223641, upload-time = "2026-07-15T18:54:40.103Z" },
{ url = "https://files.pythonhosted.org/packages/91/40/81b6d809d320cd366ec5bdf8176575e897dcb8efe7fb4b489ef9e93e4d13/coverage-7.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40", size = 224172, upload-time = "2026-07-15T18:54:41.882Z" },
{ url = "https://files.pythonhosted.org/packages/ef/28/9f14ec438149f7de557f45518f09b4a7917b795cc37083aa7db482693f8c/coverage-7.15.2-cp312-cp312-win_arm64.whl", hash = "sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3", size = 223556, upload-time = "2026-07-15T18:54:43.674Z" },
{ url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" },
{ url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" },
{ url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" },
{ url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569, upload-time = "2026-07-15T18:54:51.098Z" },
{ url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806, upload-time = "2026-07-15T18:54:53.145Z" },
{ url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936, upload-time = "2026-07-15T18:54:55.015Z" },
{ url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178, upload-time = "2026-07-15T18:54:57.527Z" },
{ url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934, upload-time = "2026-07-15T18:54:59.41Z" },
{ url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898, upload-time = "2026-07-15T18:55:01.338Z" },
{ url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056, upload-time = "2026-07-15T18:55:03.243Z" },
{ url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718, upload-time = "2026-07-15T18:55:05.029Z" },
{ url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490, upload-time = "2026-07-15T18:55:06.889Z" },
{ url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647, upload-time = "2026-07-15T18:55:08.941Z" },
{ url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190, upload-time = "2026-07-15T18:55:10.789Z" },
{ url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583, upload-time = "2026-07-15T18:55:12.678Z" },
{ url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" },
{ url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" },
{ url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" },
{ url = "https://files.pythonhosted.org/packages/6c/58/821b533b8db9e44cf1d8a97bd525149ced40dde1d0093da02cb78e715244/coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", size = 255536, upload-time = "2026-07-15T18:55:21.027Z" },
{ url = "https://files.pythonhosted.org/packages/f1/f2/7aa06604c389d32ea7f0a6a988359a7eafc3cd3f8e7bc2e88cd2fdf0b877/coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984", size = 256881, upload-time = "2026-07-15T18:55:23.125Z" },
{ url = "https://files.pythonhosted.org/packages/a2/4f/1ef342339c7916d0096bc5888cc0f653882cc7bc8f897d5cb89143287c9b/coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7", size = 259196, upload-time = "2026-07-15T18:55:25.099Z" },
{ url = "https://files.pythonhosted.org/packages/fe/f4/7ed055d7a9c5ec13b161773a115a5ccc6b0081d568c31fad830806306cc7/coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3", size = 253036, upload-time = "2026-07-15T18:55:27.018Z" },
{ url = "https://files.pythonhosted.org/packages/14/79/ea82cca18c242a3a38b6c017da39726aa62dcb64aa635abf79b92009975c/coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee", size = 254887, upload-time = "2026-07-15T18:55:29.084Z" },
{ url = "https://files.pythonhosted.org/packages/a4/ba/a136db3c0d9562b00e10b72540dbf3a33cd3bc5b95060c9308e247494623/coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1", size = 252852, upload-time = "2026-07-15T18:55:31.184Z" },
{ url = "https://files.pythonhosted.org/packages/17/17/ea334246b16b7d059953fad6fdefa11e33c68efbd3fe37b1098120a1fac2/coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a", size = 257128, upload-time = "2026-07-15T18:55:33.163Z" },
{ url = "https://files.pythonhosted.org/packages/ed/c3/074fb66d46d607855f710876b117cbda562c5ab08363528e78820449f937/coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145", size = 252668, upload-time = "2026-07-15T18:55:35.063Z" },
{ url = "https://files.pythonhosted.org/packages/e1/c1/f620850ada9b36435921c9a3a8057013422b1d964eb4bf37fe138724d192/coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446", size = 254325, upload-time = "2026-07-15T18:55:37.125Z" },
{ url = "https://files.pythonhosted.org/packages/cc/31/a729ca3689404493af82ef8e6ff70bd88bdda8da89aeef6ca9b387aeb2b4/coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243", size = 223844, upload-time = "2026-07-15T18:55:39.078Z" },
{ url = "https://files.pythonhosted.org/packages/c6/83/5d809dc808fb1698c671f3e372259bb9158e64b7ea526fc6ab7de64de9fe/coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc", size = 224331, upload-time = "2026-07-15T18:55:41.346Z" },
{ url = "https://files.pythonhosted.org/packages/16/4e/35e488548e952795829e129995c4174df33bf432b591d1aa42c8d9e4e7ad/coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635", size = 223760, upload-time = "2026-07-15T18:55:43.518Z" },
{ url = "https://files.pythonhosted.org/packages/ed/49/dd2c86cd6374038f6e415fb5bfb86db5218553209c081384a020369dee79/coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be", size = 222384, upload-time = "2026-07-15T18:55:45.569Z" },
{ url = "https://files.pythonhosted.org/packages/d3/74/173ff17a1c0808e5a438f549f6f145d5ac7528f2791310b63523e3200ac7/coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072", size = 222647, upload-time = "2026-07-15T18:55:47.544Z" },
{ url = "https://files.pythonhosted.org/packages/84/f8/b8cba872162356fb44ac79c10309d987206a4461e32072fc29228dad7331/coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328", size = 264013, upload-time = "2026-07-15T18:55:49.768Z" },
{ url = "https://files.pythonhosted.org/packages/ee/67/a807a7586d0b8cae485308ddd55756f0806c92f8e0b411bacbf23c48edf3/coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a", size = 266135, upload-time = "2026-07-15T18:55:51.941Z" },
{ url = "https://files.pythonhosted.org/packages/ce/67/cd78771dc985f7e4ebdcc82b1a96d9a932af9e806f01f2f91a89f4c72e80/coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0", size = 268555, upload-time = "2026-07-15T18:55:54.065Z" },
{ url = "https://files.pythonhosted.org/packages/18/3e/10134cf81275188c58568f324fc74aedff32c63ca4d5bbc513a91944a6f0/coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5", size = 269674, upload-time = "2026-07-15T18:55:56.066Z" },
{ url = "https://files.pythonhosted.org/packages/75/4a/771b77de446cba985dc414bbc5844bd21604da05dbc044286df8318a48a7/coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743", size = 263101, upload-time = "2026-07-15T18:55:58.107Z" },
{ url = "https://files.pythonhosted.org/packages/5f/b5/70a7011da15f4071943361183aefa27847f3e3aec4fd335f1cb3d3a622b1/coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c", size = 266007, upload-time = "2026-07-15T18:56:00.468Z" },
{ url = "https://files.pythonhosted.org/packages/b4/0d/f9547e804ce7ad49646ffeffac26699510efbe6c0f751b66fdc960c4e825/coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071", size = 263611, upload-time = "2026-07-15T18:56:02.615Z" },
{ url = "https://files.pythonhosted.org/packages/ac/59/f576a396659c0efd351f5c1544f67c3560e89c7761cabf7f65e412beeda5/coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a", size = 267344, upload-time = "2026-07-15T18:56:04.622Z" },
{ url = "https://files.pythonhosted.org/packages/7c/5d/c2e4fce3579c0cb635024293f1a32bbe26df101b3e3a69f22243d1352b6c/coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d", size = 262456, upload-time = "2026-07-15T18:56:06.641Z" },
{ url = "https://files.pythonhosted.org/packages/bb/dd/956287d69436b66094bc4b57ac2da71e43bfd2a5524e958900b9f582fcf8/coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c", size = 264771, upload-time = "2026-07-15T18:56:08.795Z" },
{ url = "https://files.pythonhosted.org/packages/2c/5a/6f979530c2734c575de77cf58f5f28d51f7123a94b5030fd9156fe5f363c/coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688", size = 224151, upload-time = "2026-07-15T18:56:10.856Z" },
{ url = "https://files.pythonhosted.org/packages/54/7e/27f6b2a74d484742f4017553e710b01e396b23d809df3e95ca0bb9a2824b/coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199", size = 224981, upload-time = "2026-07-15T18:56:12.928Z" },
{ url = "https://files.pythonhosted.org/packages/b1/48/284863423aa474240f6842bd00d680da22f4e6ea2e466618ef7c9c9e69a9/coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658", size = 224294, upload-time = "2026-07-15T18:56:15.156Z" },
{ url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" },
{ url = "https://files.pythonhosted.org/packages/90/d9/01d8e19b2c0e55903bfb540c9f6bd32326f1d5b2fcb5a7dd8648ae2dd9c5/coverage-7.15.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3a82b2ceee91ba353e59fe2436d8a9eae799ff9825e5385423ea205d693e2949", size = 222202, upload-time = "2026-08-02T18:47:25.951Z" },
{ url = "https://files.pythonhosted.org/packages/1e/92/1c23aeb83c7239af07061abc6e96f00f9b62deec8fae022cab1b353e6d46/coverage-7.15.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3088cce65e54c2eefc08e7e1ca0b0acec1e95e8cf084ac848599103ed0367f74", size = 222723, upload-time = "2026-08-02T18:47:28.359Z" },
{ url = "https://files.pythonhosted.org/packages/b9/76/186f60bae815941553b70877d814c45994db8198bb76933bd062c18ee437/coverage-7.15.3-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a65e09efb0b5ab21fc54a8a65c5b2e533c0a4c0d064af0259a005dc656dc1b13", size = 249461, upload-time = "2026-08-02T18:47:29.802Z" },
{ url = "https://files.pythonhosted.org/packages/88/70/53e010accfea3340905c5bb9207a2e461bba9b372621f1b88c1bd0e1392a/coverage-7.15.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b51f279a2477b0e1f288b98f141fd227acfdd1d3f0370400e473788879b47871", size = 251290, upload-time = "2026-08-02T18:47:31.445Z" },
{ url = "https://files.pythonhosted.org/packages/5b/13/d916056137fb6969e9d9f58ee11d1ef56778673843828315030733a3a0b6/coverage-7.15.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7835176988cbcf1f014db683bc33aa15e0558e412bf08deaa99757335b88df15", size = 253156, upload-time = "2026-08-02T18:47:33.033Z" },
{ url = "https://files.pythonhosted.org/packages/cf/72/0a4198d82e765f3351a91714d42526eb765e5c97776f7674489acbe7d062/coverage-7.15.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f24896dc8863167f6732f4142f5d37e6195eccc8fe5fe528d35d49597d29fdb3", size = 255068, upload-time = "2026-08-02T18:47:34.852Z" },
{ url = "https://files.pythonhosted.org/packages/00/d9/ebe4e0751e3637d87162887d0d3cdf4716f96782ab6face09a295e74cba4/coverage-7.15.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9490d43e5d041fdf376770a886a29722adb05f6b9c21a65c48c81fc8f1c33fd7", size = 250142, upload-time = "2026-08-02T18:47:36.588Z" },
{ url = "https://files.pythonhosted.org/packages/a2/a2/12977c74fcf92f9b1da45fb9576c5f593a2c47bde04e937a8bb32dd56bfa/coverage-7.15.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:225e359bd5dedaff6d68e36091af20555866c557d968167308b677379bf575c3", size = 251195, upload-time = "2026-08-02T18:47:38.168Z" },
{ url = "https://files.pythonhosted.org/packages/2f/c8/42bd9aa40386c0fbcc7af221ab4737dad10d27bb4971ca2783676619a79b/coverage-7.15.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:22119e2e3b2ac5ac024d50131fdd4b22ab4c6cf8aa2fc792cce73c0d94c5812d", size = 249200, upload-time = "2026-08-02T18:47:39.773Z" },
{ url = "https://files.pythonhosted.org/packages/0a/52/f1ce0dd8a2ec5c3911f1bc98b859be09cc4bbd705ed74ceb905729837c79/coverage-7.15.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:12d555badc462b0f6037ce8bec8b4af8d71f90eb55b57d0a358731f7ee7883e2", size = 253013, upload-time = "2026-08-02T18:47:41.372Z" },
{ url = "https://files.pythonhosted.org/packages/fd/2c/9a642c4cf7b6992b2eba75359b6cb548bd437001d6083fd0ffe492b80d38/coverage-7.15.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c4e2cf9cf774939b3dc581c6e31dfe7e8d7608b24f0f17524d6161f8235c3d2c", size = 249470, upload-time = "2026-08-02T18:47:42.997Z" },
{ url = "https://files.pythonhosted.org/packages/89/32/271d85639ac5de099046f7418e850047b1e964f893535128997b5cddde8d/coverage-7.15.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cea1b3e19d710f67e2ba9ce0b0b51032c2a9b4808a65ced48ddf336ef7e58058", size = 250073, upload-time = "2026-08-02T18:47:44.576Z" },
{ url = "https://files.pythonhosted.org/packages/15/71/6216430095c5437f83d7bfa7c1adb0965e26ada88d9fff49bf55e2cab154/coverage-7.15.3-cp310-cp310-win32.whl", hash = "sha256:25c77560309f157e7b7ee8fe0bf78d047ba900b7ae42f0e50e559305b366fea2", size = 224263, upload-time = "2026-08-02T18:47:46.078Z" },
{ url = "https://files.pythonhosted.org/packages/53/8a/f1032fb2714c28fedf00d73562c4bb9f713fa8a90593ed577bbb708a7de1/coverage-7.15.3-cp310-cp310-win_amd64.whl", hash = "sha256:179fbf847e6c3d90ea71bfd570fe57f1ddb1c51474754894871c1e11099efaa0", size = 224886, upload-time = "2026-08-02T18:47:47.668Z" },
{ url = "https://files.pythonhosted.org/packages/b3/9c/c8a3a923c24f631695cea2d5e2f02e776bc0af6e03800626e13a6c05a615/coverage-7.15.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5f3f854ab4599d98f7799ac9b91e34e8ec9ebc9a6372ee8c1f3413a68cc8b5e9", size = 222328, upload-time = "2026-08-02T18:47:49.228Z" },
{ url = "https://files.pythonhosted.org/packages/92/51/dda77f34cbd2513d6ffb898c901d19e9ca55f48c0cbc4a1eb173a97d157a/coverage-7.15.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75268348fee1f199653b8a846262aec5581c6bb008c4f58824959fb708cc688f", size = 222832, upload-time = "2026-08-02T18:47:51.219Z" },
{ url = "https://files.pythonhosted.org/packages/78/59/e0faafc4c6e23bd76c76148875ee9ec5781b8f1cd62cea2bc4ca0f0f0e5d/coverage-7.15.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:21081739f6264cc594cad2d42b62befbd17633824022866c68720eb0c4b8d6b4", size = 253250, upload-time = "2026-08-02T18:47:52.737Z" },
{ url = "https://files.pythonhosted.org/packages/14/e2/4b1e0eeb727ffb471e411c1bd3402184b5dd54a77a762b0e55e87cdf9ae3/coverage-7.15.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:718d366251b060c10731c7dd359de6caea72250036eb94576aa56dacbf830a11", size = 255160, upload-time = "2026-08-02T18:47:54.404Z" },
{ url = "https://files.pythonhosted.org/packages/e9/9e/a602d2d48f9db9f795e578a86aa914f7b20008e9330902defcfb73d17b3a/coverage-7.15.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fa1bbaa502a6e877f3ee67cbac3eba2bb637f623e454e6c37b81b38896dbd48f", size = 257269, upload-time = "2026-08-02T18:47:56.157Z" },
{ url = "https://files.pythonhosted.org/packages/22/fa/bf6db13df2fcee00d2671849fe58c99232ee79a01fec7478c2bf7839b9e1/coverage-7.15.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:494880c9e60782610683f4eb9b65cce4f886673596b8f3cb2dfa079fc551c743", size = 259231, upload-time = "2026-08-02T18:47:57.76Z" },
{ url = "https://files.pythonhosted.org/packages/89/37/8118f13b17fa7d9a3aa2c301d93f2d5ffeef70fa7e27e639a74bdacd3fea/coverage-7.15.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3db264ea689f9e8f9fa4fb9005fee4048c3bff4a547f4cfa27f5086cb0804ec0", size = 253357, upload-time = "2026-08-02T18:47:59.261Z" },
{ url = "https://files.pythonhosted.org/packages/97/6d/c7b94fb03962f4d6f0fe13d01c4eb9c4c6e2e714a20d074516ec7582b110/coverage-7.15.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4e869d4799674d67778e76ddbe2e26cf1673369262e231a8ec259421b1015fea", size = 254961, upload-time = "2026-08-02T18:48:00.901Z" },
{ url = "https://files.pythonhosted.org/packages/87/f9/fe0bd415fa56e36b62b649017c8fc98330858be4c7593789efb78cd24178/coverage-7.15.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:696fc7a28bbf717aba8d2c6963d26702945c7832cb313ba3b323aa5b1afb3156", size = 253024, upload-time = "2026-08-02T18:48:02.745Z" },
{ url = "https://files.pythonhosted.org/packages/c1/7c/ffa53506d63ba8a77f5b9557dd6f5a5a5ad85adc680d7857410138f82bd9/coverage-7.15.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:3fe9be1c527497d047f770d88a0110189714c36383bb88384508f750c302bffa", size = 256792, upload-time = "2026-08-02T18:48:04.377Z" },
{ url = "https://files.pythonhosted.org/packages/1f/c6/df42458e72c18a49fe87e40ccd3fb0314210915256cf4a5593e1b3250e04/coverage-7.15.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2400591f4b2e33746c70846388f8bb4c7e33b820e31cb8c6cb2f25305310438b", size = 252744, upload-time = "2026-08-02T18:48:06.154Z" },
{ url = "https://files.pythonhosted.org/packages/f1/14/8bf18a4b10a44f8ba5f604b00e102f37daf49d581d66a37dc33fa267e1a6/coverage-7.15.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2e557178799282269412a672e5753f2179edfe1b3f0f19b0c98f8e72d482326a", size = 253652, upload-time = "2026-08-02T18:48:07.955Z" },
{ url = "https://files.pythonhosted.org/packages/27/e6/e530c9bb94e4155817cbd149034105b062a6913bc356ae08f454d155de53/coverage-7.15.3-cp311-cp311-win32.whl", hash = "sha256:68ea6c947375982ae907e19e9d2ef156bd6e68e11f3566dd568d7f4ec974e715", size = 224428, upload-time = "2026-08-02T18:48:09.845Z" },
{ url = "https://files.pythonhosted.org/packages/b4/98/0050c692d120988f1973a15196f52dee4ae221848b760281461a2005b613/coverage-7.15.3-cp311-cp311-win_amd64.whl", hash = "sha256:28743dad31622e8c474b17446118037361f5b1f4f2ecdf72d4f6fde246d64446", size = 224906, upload-time = "2026-08-02T18:48:11.611Z" },
{ url = "https://files.pythonhosted.org/packages/b0/ae/c0ef3e2ba3f35fc1c6985811a40edd9331e5b8978c9ecf84699de3edacbe/coverage-7.15.3-cp311-cp311-win_arm64.whl", hash = "sha256:c4398918c4fda32718191239e451fd86ac5ad1e8979b592f1921ee2d1f038965", size = 224448, upload-time = "2026-08-02T18:48:13.304Z" },
{ url = "https://files.pythonhosted.org/packages/d1/6c/bac99d9d4c6abe856e93bf3f5212982ac0bfac126dd4a042753bd53bc5af/coverage-7.15.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:79a3e32e83227d83d9684459ed579769b56c369ac2d7313099b2d9e031d2e10f", size = 222499, upload-time = "2026-08-02T18:48:15.018Z" },
{ url = "https://files.pythonhosted.org/packages/aa/bc/cb9a39b083bc1aa70586482dab25c9be20bab0ec6c155340e50d9066bb1e/coverage-7.15.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:767feb87c5886d781d0a69fafd450a20826ddab7b79bce1665deb64d21441b60", size = 222866, upload-time = "2026-08-02T18:48:16.884Z" },
{ url = "https://files.pythonhosted.org/packages/58/fb/beaa453d62000a0a5b39838bee2a137afe609a50a71f55e83c73461e513b/coverage-7.15.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:50951e37033c40548d777b8a8454a2cd622dba1136780065678dccaec307c47f", size = 254367, upload-time = "2026-08-02T18:48:18.507Z" },
{ url = "https://files.pythonhosted.org/packages/66/64/43e72500ed6815cef189f9193f29d7af4b078830337c95ea976cd0c0d427/coverage-7.15.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:63a4ff67364afb2cac826b8bbd78a5c50ce656a7b7137436b44d7b96a9271088", size = 257103, upload-time = "2026-08-02T18:48:20.172Z" },
{ url = "https://files.pythonhosted.org/packages/66/3a/2893e2937adfe02f45fd38e4a8a0a0d8b7a02ff9e012ac3d009bee3c4f16/coverage-7.15.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e95e42856509675fe26560310313a6117640e96f9a1e19bb3d220116a27c94c", size = 258220, upload-time = "2026-08-02T18:48:21.963Z" },
{ url = "https://files.pythonhosted.org/packages/30/b4/d5e6e2eb1a62961083734291304b1f85df72e2abe95c76eb88a7f472afd0/coverage-7.15.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:abad631cba27094b4631993f4c72e89ac0ca1b3a0236c7abaf8ca79aea619851", size = 260481, upload-time = "2026-08-02T18:48:23.682Z" },
{ url = "https://files.pythonhosted.org/packages/dc/c9/9b72c5c6a9798a9a12cf65f66e077cc1fdd396e61915c862688f9afe1cae/coverage-7.15.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2b0807f1f051dd82a234ad6acdb6f1425baede60be1e84e862496c8cc9262ab9", size = 254749, upload-time = "2026-08-02T18:48:25.32Z" },
{ url = "https://files.pythonhosted.org/packages/92/20/e1c2f759e2dbce559ba85c40c0e4acfecc6cff4b740c294c88e41ccc6111/coverage-7.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8d6df7aeb5bc464040bbc9ae173d875785d3677ebc4307817997d622d74225e", size = 256138, upload-time = "2026-08-02T18:48:27.064Z" },
{ url = "https://files.pythonhosted.org/packages/a5/ab/48cc7e760f769e86ae290a125ea6e7209dfbdbbbb7ff4f5d9d1ee7a45d57/coverage-7.15.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:974471c506c9f5758808b47c1ebf7949ecd0848f5c1020e78675fefe5ff46866", size = 254283, upload-time = "2026-08-02T18:48:29.082Z" },
{ url = "https://files.pythonhosted.org/packages/15/26/39529a68154f99b3a1829debd8b25eac384effeec890a293b5bbdcb49186/coverage-7.15.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5cba0c9c13e35c86df7998f1afaf6b1da224a3a39e4da59bdabf60c148046dcb", size = 258352, upload-time = "2026-08-02T18:48:30.892Z" },
{ url = "https://files.pythonhosted.org/packages/91/2f/55b82aa3d8d7dd8023a56e7c5c2a70e39a3c44b3353c6cf3faec9ad51566/coverage-7.15.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4d608dc36a364dce33acbf4fc3a50f9d2054c945f233bb0a2cdb4b90bfa17646", size = 253852, upload-time = "2026-08-02T18:48:32.934Z" },
{ url = "https://files.pythonhosted.org/packages/6a/6d/839f4045124cd3518ecf2c58967e58a911202834e7c5a03cfdf2ab0b29f6/coverage-7.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2395869280554a1941da904423c12660c39f721315e1c02d076a7fe0971382f0", size = 255725, upload-time = "2026-08-02T18:48:34.848Z" },
{ url = "https://files.pythonhosted.org/packages/75/21/d25e3e2a9e327798078c877f469dfb6def860bf6e25036529046227d3e15/coverage-7.15.3-cp312-cp312-win32.whl", hash = "sha256:24f3b21840c3eb76cef3cc70b2bf6649010c64471a84a446538a39306e1ba04d", size = 224566, upload-time = "2026-08-02T18:48:36.661Z" },
{ url = "https://files.pythonhosted.org/packages/b1/0f/df90cc1e8d095ce263968a93e04829821b2afb31ac2752c06a2e0a8e3c13/coverage-7.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:fa7b17902c3c1dd8a7adb52679b7f6340bba08443d710c8838e04db8cf62be2a", size = 225098, upload-time = "2026-08-02T18:48:38.941Z" },
{ url = "https://files.pythonhosted.org/packages/65/c7/ec49e43c58967a07163e2d1c6bbd58112b825b2772ab66784afd6a5400ba/coverage-7.15.3-cp312-cp312-win_arm64.whl", hash = "sha256:fcbe83fb7258eacd293bf5322d88807acb35ed12a5cfa99dd8215c083e3b0235", size = 224485, upload-time = "2026-08-02T18:48:40.682Z" },
{ url = "https://files.pythonhosted.org/packages/68/6e/62ae61e1fc434956bec38ed1d5b1c494f58cf579dbd998e77abffe7b3e6b/coverage-7.15.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1182eed05674c63d40951fae27c43e822749f04d25f75df64c2e4fa3168678de", size = 222522, upload-time = "2026-08-02T18:48:42.476Z" },
{ url = "https://files.pythonhosted.org/packages/13/ff/c74c673d81e0e77b6608c3d21331e3db42e30daeb3c8a0a8860d4c9e2e14/coverage-7.15.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c0c4b0d7c4cd56e470d0c9d8441f42e8a96cdfd95050fec027f1d4dd9f11006c", size = 222894, upload-time = "2026-08-02T18:48:44.274Z" },
{ url = "https://files.pythonhosted.org/packages/a1/91/ccb30f5ffafd7d69d0b18e5162f9b711a5654e807b7b0c13497f0826b33f/coverage-7.15.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5c9fce9f4998b0d50a753da765b9215a14decc7863822c89d72da7a89ca625b3", size = 253890, upload-time = "2026-08-02T18:48:46.097Z" },
{ url = "https://files.pythonhosted.org/packages/29/c6/e92a66cda49a2751b09826d51258f199b92aa0cb005bc5f34e9729a52a9c/coverage-7.15.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7a47e2a0a0ace9241e70ee00e44520f88b843094603dd54303f1bafecd929c30", size = 256484, upload-time = "2026-08-02T18:48:47.846Z" },
{ url = "https://files.pythonhosted.org/packages/96/7a/730929164b457cf25cf76c23898b90f9039a104a647890801b6586797b14/coverage-7.15.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:95bad94f83807ae60ed76f3ac012f69b2605ac9ea81bee959a5a483f7fa09c10", size = 257723, upload-time = "2026-08-02T18:48:49.664Z" },
{ url = "https://files.pythonhosted.org/packages/9e/be/04cb5672cb19f5c389eda81ba22d89807699a949653d3625b0e0fda169da/coverage-7.15.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:228e172a76c428bb17d1ab78a2ff188990b0597e5dbd291f52a4edf7412de049", size = 259854, upload-time = "2026-08-02T18:48:51.413Z" },
{ url = "https://files.pythonhosted.org/packages/96/25/5e7fd6af39f6507071455944b8906dd1fe5b7b6bffb6a163ceb20afa0d13/coverage-7.15.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cea9fb33887c99349996266f1fd60abe5af3577a90633392001d27ef46b4b66e", size = 254085, upload-time = "2026-08-02T18:48:53.158Z" },
{ url = "https://files.pythonhosted.org/packages/23/c8/55e58a853f1e61163a6e755897bd14a059d78411e86560f39d9951c019b5/coverage-7.15.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:81760de3155d7f52c21860c4046628dc6bed182f72e3c028e2b4fd46f65aa040", size = 255850, upload-time = "2026-08-02T18:48:55.031Z" },
{ url = "https://files.pythonhosted.org/packages/be/74/8bcec66dbcf3d22bea2a0b2b77ee2fa6f766a647d0023d4eabbc4f2b2756/coverage-7.15.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b47ea0a1d3a3d089826c6cbfad8429d7d8872e28e86baa95ddef330f6875da21", size = 253818, upload-time = "2026-08-02T18:48:57.163Z" },
{ url = "https://files.pythonhosted.org/packages/ce/06/450b673fdfece0997b4e16a31d6bde6b18889c578f1013ddd34c962ac6f9/coverage-7.15.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5459ba486b2a5d58a6c05254779ecdf525e7f20174d0210ceda75ba40fdb8f2c", size = 257973, upload-time = "2026-08-02T18:48:59.098Z" },
{ url = "https://files.pythonhosted.org/packages/56/fd/3ec7409aec0ddc943132452b65672f065f043b844f1830e1fe173c98b3ab/coverage-7.15.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c59209f80a08dbfcdd5109a80dc623cd3b9d22895c85757d34f57a6e6e95570f", size = 253638, upload-time = "2026-08-02T18:49:01.199Z" },
{ url = "https://files.pythonhosted.org/packages/75/20/30a8dabb194123631c93f860fdd86401ad405d56cfb1841873afbfe4e92b/coverage-7.15.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f863856c1779d4a5bb6a94698a2f9073e09c6706501f76f3e7780e72df97d21c", size = 255407, upload-time = "2026-08-02T18:49:03.143Z" },
{ url = "https://files.pythonhosted.org/packages/13/4d/e14365b1953b43653341412f9088b0d752614c626a73a705ff9af400f3a3/coverage-7.15.3-cp313-cp313-win32.whl", hash = "sha256:00cbdc5e322927dc30c5e42b863819b1bb867cc66f26ab5372c585850876ab93", size = 224575, upload-time = "2026-08-02T18:49:05.011Z" },
{ url = "https://files.pythonhosted.org/packages/1c/64/88f762ea80de2070207246faef514513be874486b2773528f2cc2b4b515c/coverage-7.15.3-cp313-cp313-win_amd64.whl", hash = "sha256:835528518a1d823cf336740324b2f335f7c01e609e74abcb5d5163b3e66661e3", size = 225116, upload-time = "2026-08-02T18:49:06.894Z" },
{ url = "https://files.pythonhosted.org/packages/ab/66/03c34c53a319f522554cd29d4f2e16c5eab61aa4cdcf55753129fd7d926c/coverage-7.15.3-cp313-cp313-win_arm64.whl", hash = "sha256:0d2e1f2cbbf36b842f3e2aff8d118c60d677adb498bc6c7fa9c6838738f82767", size = 224509, upload-time = "2026-08-02T18:49:09.129Z" },
{ url = "https://files.pythonhosted.org/packages/35/6f/8c2dc014357618b3226c90f731b8282766c3685786f422558991dc49fbf2/coverage-7.15.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1e3bb08ad574bd9fb6a991f645728f70d333c1c1958dd5fcde65e24cb862813d", size = 222571, upload-time = "2026-08-02T18:49:11.242Z" },
{ url = "https://files.pythonhosted.org/packages/07/50/d867c7ceae9d56b7e74ee61ea834f1aa4f9a1e1c7f0ce39393ba573b1c12/coverage-7.15.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9e5860eaff02a0b7f1b73304bdf846596ee62ab3a78d25c68044ebf684cb1fef", size = 222902, upload-time = "2026-08-02T18:49:13.448Z" },
{ url = "https://files.pythonhosted.org/packages/62/77/4f6dfc490c5f2bcacb2d296d9aa4d1e128c43b48e94ad313fec7f49f09ad/coverage-7.15.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:60874e5bd67f0b1bdbe42ab42c7bafa66a6fb8de88721af6df3f7a02713960cd", size = 253947, upload-time = "2026-08-02T18:49:15.304Z" },
{ url = "https://files.pythonhosted.org/packages/16/8a/6777f192af264165103e2a3d3768dbadb9894a0a2359a16877141d9ae8f5/coverage-7.15.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f9147be876e9d83765e0b82176674dc248a6b9283e25e01e7462611b97e9b731", size = 256452, upload-time = "2026-08-02T18:49:17.801Z" },
{ url = "https://files.pythonhosted.org/packages/7d/7b/3d7ac46a0234bc684f41ee42be95e29b2b6525695adb04083609d5ac2149/coverage-7.15.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61a01f8c3804760fcc5a3d31c4f3cab792d660d44e17bf7adeaf0ea51e07821e", size = 257798, upload-time = "2026-08-02T18:49:19.878Z" },
{ url = "https://files.pythonhosted.org/packages/ff/1e/c6ee59c29afcb5fdb35f936381340d1a06429a07c48f20e809646647acbe/coverage-7.15.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:95bf3e7f26f792e25eb185f85a5a659d48479265176dcfe22b6f334fd0081b5c", size = 260112, upload-time = "2026-08-02T18:49:21.858Z" },
{ url = "https://files.pythonhosted.org/packages/c1/e1/e8ea39a46e89e3a143312ee5f80336e992e3ae8fe44bf9c76b83fefeed42/coverage-7.15.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:44c41eff9e413fed8740eca75d5438ebeb9d3e45e7cd37c67329213e7a72c764", size = 253944, upload-time = "2026-08-02T18:49:23.926Z" },
{ url = "https://files.pythonhosted.org/packages/95/67/31ab5f6a37fd887d1386f81f0da9306851ad2264e9baaa9c7f606e0b3e17/coverage-7.15.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54146bafb61f3ba9895b43af0dd17eba01561d586d44ce84ea221b0cbbee5a9e", size = 255805, upload-time = "2026-08-02T18:49:25.973Z" },
{ url = "https://files.pythonhosted.org/packages/fb/6a/ee505a80c8fd89620fb337c0596daecff87f33171fbb4ee3015fc3d7331f/coverage-7.15.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:af000dd1bb859ff8066fda4c79512ff938c798116540307226b373099c7b151f", size = 253769, upload-time = "2026-08-02T18:49:27.883Z" },
{ url = "https://files.pythonhosted.org/packages/b0/41/6ab0f81c9e89660230d8f3f581d4732e5ddb75a885b0a5dfc73d315dc94f/coverage-7.15.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a1b82490577f3889950b5a04f18712aef0207243e0749d60fe28c3c73ebfd5fd", size = 258045, upload-time = "2026-08-02T18:49:30.201Z" },
{ url = "https://files.pythonhosted.org/packages/bc/62/c995e91cae28cf31d6defab3bfb553dda5ac83ac7381b0f2b121264c307a/coverage-7.15.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:c4fc90a60154c3e4b8a2dc206d6dbe852f1c235c249e0dc0cef909d032c9591a", size = 253587, upload-time = "2026-08-02T18:49:32.349Z" },
{ url = "https://files.pythonhosted.org/packages/84/df/f2049980f82d6890321f2065f9e66216eabbf4b2001815db958bc543f40a/coverage-7.15.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f25bb884814a892948b4c20394db3f2364dd452d9492736479e7a493e63b0eb6", size = 255243, upload-time = "2026-08-02T18:49:34.324Z" },
{ url = "https://files.pythonhosted.org/packages/1d/82/2c841b67a978c0eb9c3707630b68f93f9e7585d78bb906bc8823ec6b07a5/coverage-7.15.3-cp314-cp314-win32.whl", hash = "sha256:722dbf8e7828fbcfe0dc8586167dc0a5ce85ad6ea171dbb21ed3f8d6581d3cb8", size = 224759, upload-time = "2026-08-02T18:49:36.326Z" },
{ url = "https://files.pythonhosted.org/packages/b3/78/5c93ec43784fd3e404ca23cd0584ae24bc1732de4a3fc194b68c3be88db0/coverage-7.15.3-cp314-cp314-win_amd64.whl", hash = "sha256:64d0845f9c3ed47302bed265c15ab4dbb64aa4ec1490839b8e328f4e7fa914d2", size = 225246, upload-time = "2026-08-02T18:49:38.366Z" },
{ url = "https://files.pythonhosted.org/packages/9d/77/813a054371f3b018cc63c6bdb46a3c35d5e95d4e3ed4f1449d4196106db5/coverage-7.15.3-cp314-cp314-win_arm64.whl", hash = "sha256:69bc14684f8fbbee9f9dbaa4fe79719b0da9725fc37956785c06ec365acf6926", size = 224673, upload-time = "2026-08-02T18:49:40.552Z" },
{ url = "https://files.pythonhosted.org/packages/8f/63/8c9f36cc71178d26db930baa03a4494abcc516d8d41bf820d0d85ef1d80b/coverage-7.15.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f92df943c24b96cb215ca26b4f6a2283e63c5db80f1635aceea7fff11311917b", size = 223298, upload-time = "2026-08-02T18:49:42.634Z" },
{ url = "https://files.pythonhosted.org/packages/54/66/211f24d058ce9f56ebf1420d55b7574fdae924f6da3836f83c8bd4793e38/coverage-7.15.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:66591c46bdd2971d3ae2bc503a5f0459c2edcaf6b7e045b292000cc95bc6cb95", size = 223568, upload-time = "2026-08-02T18:49:44.706Z" },
{ url = "https://files.pythonhosted.org/packages/dd/bb/9c2ad5574a0d6420a96c6cade4f8a683931b9e79fe609f8924d7b6964616/coverage-7.15.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa64458b81b18bfc67cdf1f6dc02b23e3edc672f2f8e11771fad75865415a43", size = 264932, upload-time = "2026-08-02T18:49:47.153Z" },
{ url = "https://files.pythonhosted.org/packages/ba/91/938c39e77bdd5a0a440412f975609ce3702dabbda6ac715719d93ca45a7b/coverage-7.15.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:447f5421ccf5475956cf516d4ca1d575f487947b6f4e11f9d80c6aefe24b3dc8", size = 267052, upload-time = "2026-08-02T18:49:49.324Z" },
{ url = "https://files.pythonhosted.org/packages/b0/a3/7b431a98af35d9cc6394e54cde9435b33b8591672fbece6a4931267d7a8e/coverage-7.15.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a0c77ef8cd483a4987a5d12d1d9d5f7ee598dfdc6c0844417d847e5768dc779", size = 269473, upload-time = "2026-08-02T18:49:51.599Z" },
{ url = "https://files.pythonhosted.org/packages/32/58/dbc9951dce46be47a732823a1c571f62bcabdd54a68d8c281489a1a55cfb/coverage-7.15.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0b273f4ff657446a06c2d85bf80e134fa869a92852ba5f87854a70e1fb44da77", size = 270591, upload-time = "2026-08-02T18:49:53.865Z" },
{ url = "https://files.pythonhosted.org/packages/71/bd/1d610772c7c0889bfe477a59c46ee66ea53e271f3f06951e9d55b317f7c6/coverage-7.15.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daea8c4fafa22488600405be2c2be525a9406fba3fc0a83acc726db3e14e2005", size = 264007, upload-time = "2026-08-02T18:49:55.875Z" },
{ url = "https://files.pythonhosted.org/packages/69/97/852eb3dcdba156b1a9078503f098499916bf889f964b61ad4a08223ac169/coverage-7.15.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:93ff57c530f3fa7aa69f92fb9b8892b8aa82712aa970842f4abf28657f42fb57", size = 266926, upload-time = "2026-08-02T18:49:57.944Z" },
{ url = "https://files.pythonhosted.org/packages/52/f8/b72cd238757fba2b587fc7dee047efe6e10b0c18343509faaaf502dd4680/coverage-7.15.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4df21bef8b800eebda9018f53d49c9ace3aeb0090c850139b27923aafcb83e91", size = 264529, upload-time = "2026-08-02T18:50:00.035Z" },
{ url = "https://files.pythonhosted.org/packages/b8/0a/6c52ec4b7fb007cb6433d1fcfda4080cb15d75ad37ef9c31025f3427293e/coverage-7.15.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:db567b02685f26034adcbd85055f80d12cdf02111b8ed00886093d98b2874ce2", size = 268263, upload-time = "2026-08-02T18:50:02.161Z" },
{ url = "https://files.pythonhosted.org/packages/c0/4e/f1f9aa3efd109a04353563a43fb5155340c1fdcdeaa6296ebed3b6f510ea/coverage-7.15.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5318dd51b8600b947e058cf5a4fe54d183d9d13c49b97b64ca7be05a34df9bef", size = 263377, upload-time = "2026-08-02T18:50:04.243Z" },
{ url = "https://files.pythonhosted.org/packages/dd/fb/6b268a0b2728ef1c379ad656b899274477a5f6bed1bf6765b4b387fb0601/coverage-7.15.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c995bfa383c54704839b6c4c2627a1c00895597ada0e5e8190c81d8bd620555c", size = 265688, upload-time = "2026-08-02T18:50:06.428Z" },
{ url = "https://files.pythonhosted.org/packages/29/54/1a3ea96e5d5e7cd41dc432597bfc60692910e635d05e1cc25a8ccc243581/coverage-7.15.3-cp314-cp314t-win32.whl", hash = "sha256:6433fafb8da0e1d02eb53411e0ecdadb6b88f0224fdc23317e703c0e88937d42", size = 225066, upload-time = "2026-08-02T18:50:08.533Z" },
{ url = "https://files.pythonhosted.org/packages/31/9d/a7b0d9afd18ed5274dd00651a78e7810a931c70d94b79996f150bec1a30f/coverage-7.15.3-cp314-cp314t-win_amd64.whl", hash = "sha256:fe578952b1b29fe8c777f43f241d49efac4b56724a3434f5d22ebe3c208df429", size = 225897, upload-time = "2026-08-02T18:50:10.572Z" },
{ url = "https://files.pythonhosted.org/packages/ca/11/34c5ae40b945e69aa72b87dc268135b7049905f3824af573b7073acbb946/coverage-7.15.3-cp314-cp314t-win_arm64.whl", hash = "sha256:d2e1acb7aee29dfa8f3e48c23f36670898baca1209d9bdd3985a50c7f982165e", size = 225212, upload-time = "2026-08-02T18:50:12.63Z" },
{ url = "https://files.pythonhosted.org/packages/37/e7/7069b3d6c018917f49ba2e1c5fb910e498c7fefa3a1b78cb1b79e61ff45d/coverage-7.15.3-py3-none-any.whl", hash = "sha256:da78fa6fc7dafe4212839173133ee85afcf42c5cd5f3e47fa7c1c210453b445e", size = 214297, upload-time = "2026-08-02T18:50:14.709Z" },
]
[package.optional-dependencies]
@@ -787,7 +787,7 @@ name = "cuda-bindings"
version = "13.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ 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')" },
{ name = "cuda-pathfinder" },
]
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 = "(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')" },
{ 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'" },
]
cudart = [
{ 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')" },
{ name = "nvidia-cuda-runtime", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
]
cufft = [
{ 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')" },
{ name = "nvidia-cufft", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
{ name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
]
cufile = [
{ name = "nvidia-cufile", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
{ name = "nvidia-cufile", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
]
cupti = [
{ 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')" },
{ name = "nvidia-cuda-cupti", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
]
curand = [
{ 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')" },
{ name = "nvidia-curand", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
]
cusolver = [
{ 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')" },
{ 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'" },
]
cusparse = [
{ 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')" },
{ name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
{ name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
]
nvjitlink = [
{ 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')" },
{ name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
]
nvrtc = [
{ 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')" },
{ name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
]
nvtx = [
{ 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')" },
{ name = "nvidia-nvtx", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
]
[[package]]
@@ -974,8 +974,8 @@ name = "email-validator"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "dnspython", marker = "python_full_version >= '3.12'" },
{ name = "idna", marker = "python_full_version >= '3.12'" },
{ name = "dnspython" },
{ name = "idna" },
]
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 +987,7 @@ name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
{ name = "typing-extensions" },
]
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 = [
@@ -1178,26 +1178,26 @@ wheels = [
[[package]]
name = "hf-xet"
version = "1.5.2"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/63/39/67be8d71f900d9a55761b6022821d6679fb56c64f1b6063d5af2c2606727/hf_xet-1.5.2.tar.gz", hash = "sha256:73044bd31bae33c984af832d19c752a0dffb67518fee9ddbd91d616e1101cf47", size = 903674, upload-time = "2026-07-16T17:29:56.833Z" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/ab/522a2ab67f27971a9d48ca666d4fca85ef7d5282d142e31fd087e27b1bbe/hf_xet-1.6.0.tar.gz", hash = "sha256:2e58454a340b3556dfa4972d5451aff4fba8dd42a236600ba1a1d2b1514f0fef", size = 920527, upload-time = "2026-08-03T22:33:13.243Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/29/be/525eabac5d1736b679c39e342ecd4292534012546a2d18f0043c8e3b6021/hf_xet-1.5.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:4a5ecb9cda8512ba2aa8ee5d37c87a1422992165892d653098c7b90247481c3b", size = 4064284, upload-time = "2026-07-16T17:29:29.907Z" },
{ url = "https://files.pythonhosted.org/packages/c5/3f/699749dd78442480eda4e4fca494284b0e3542e4063cc37654d5fdc929e6/hf_xet-1.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8764488197c1d7b1378c8438c18d2eea902e150dbca0b0f0d2d32603fb9b5576", size = 3828537, upload-time = "2026-07-16T17:29:31.549Z" },
{ url = "https://files.pythonhosted.org/packages/22/d7/2658ac0a5b9f4664ca27ce31bd015044fe9dea50ed455fb5197aba819c11/hf_xet-1.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d7446f72abbf7e01ca5ff131786bc2e74a56393462c17a6bf1e303fbab81db4", size = 4417133, upload-time = "2026-07-16T17:29:33.391Z" },
{ url = "https://files.pythonhosted.org/packages/d9/58/8343f3cb63c8fa058d576136df3871550f7d5214a8f048a7ea2eab6ac906/hf_xet-1.5.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:580e59e29bf37aece1f2b68537de1e3fb04f43a23d910dcf6f128280b5bfbba4", size = 4212613, upload-time = "2026-07-16T17:29:34.989Z" },
{ url = "https://files.pythonhosted.org/packages/0c/33/a968f4e4535037b36941ec00714625fb60e026302407e7e26ca9f3e65f4e/hf_xet-1.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bee28c619622d36968056532fd49cf2b35ca75099b1d616c31a618a893491380", size = 4412710, upload-time = "2026-07-16T17:29:36.646Z" },
{ url = "https://files.pythonhosted.org/packages/7b/d9/9e33981173dbaf194ba0015202b02d467b624d44d4eba89e1bf06c0d2995/hf_xet-1.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e396ab0faf6298199ad7a95305c3ca8498cb825978a6485be6d00587ee4ec577", size = 4628455, upload-time = "2026-07-16T17:29:38.352Z" },
{ url = "https://files.pythonhosted.org/packages/e9/4b/cc682832de4264a03880a2d1b5ec3e1fab3bf307f508817250baafdb9996/hf_xet-1.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fd3add255549e8ef58fa35b2e42dc016961c050600444e7d77d030ba6b57120e", size = 3979044, upload-time = "2026-07-16T17:29:40.329Z" },
{ url = "https://files.pythonhosted.org/packages/ea/09/b2cdf2a0fb39a08af3222b96092a36bd3b40c54123eef07de4422e870971/hf_xet-1.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d6f9c58549407b84b9a5383afd68db0acc42345326a3159990b36a5ca8a20e4e", size = 3808037, upload-time = "2026-07-16T17:29:42.357Z" },
{ url = "https://files.pythonhosted.org/packages/de/ba/2b70603c7552db82baeb2623e2336898304a17328845151be4fe1f48d420/hf_xet-1.5.2-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f922b8f5fb84f1dd3d7ab7a1316354a1bca9b1c73ecfc19c76e51a2a49d29799", size = 4033760, upload-time = "2026-07-16T17:29:43.884Z" },
{ url = "https://files.pythonhosted.org/packages/60/ac/b097a86a1e4a6098f3a79382643ab09d5733d87ccc864877ad1e12b49b70/hf_xet-1.5.2-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:045f84440c55cdeb659cf1a1dd48c77bcd0d2e93632e2fea8f2c3bdee79f38ed", size = 3841438, upload-time = "2026-07-16T17:29:45.539Z" },
{ url = "https://files.pythonhosted.org/packages/d3/35/db860aa3a0780660324a506ad4b3d322ddc6ecbba4b9340aed0942cbf21c/hf_xet-1.5.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db78c39c83d6279daddc98e2238f373ab8980685556d42472b4ec51abcf03e8c", size = 4428006, upload-time = "2026-07-16T17:29:46.996Z" },
{ url = "https://files.pythonhosted.org/packages/af/6b/832dd980af4b0c3ae0660e309285f2ffcdff2faa38129390dbb47aa4a3f9/hf_xet-1.5.2-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7db73c810500c54c6760be8c39d4b2e476974de85424c50063efc22fdda13025", size = 4221099, upload-time = "2026-07-16T17:29:48.525Z" },
{ url = "https://files.pythonhosted.org/packages/9e/05/ae50f0d34e3254e6c3e208beb2519f6b8673016fc4b3643badaf6450d186/hf_xet-1.5.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6395cfe3c9cbead4f16b31808b0e67eac428b66c656f856e99636adaddea878f", size = 4420766, upload-time = "2026-07-16T17:29:50.092Z" },
{ url = "https://files.pythonhosted.org/packages/07/a9/c050bc2743a2bcd68928bfee157b08681667a164a24ec95fbfcfcd717e08/hf_xet-1.5.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:cde8cd167126bb6109b2ceb19b844433a4988643e8f3e01dd9dd0e4a34535097", size = 4636716, upload-time = "2026-07-16T17:29:51.62Z" },
{ url = "https://files.pythonhosted.org/packages/e9/f8/68b01c5c2edb56ac9a67b3d076ffddcb90867abaee923923eb34e7a14e76/hf_xet-1.5.2-cp38-abi3-win_amd64.whl", hash = "sha256:ecf63d1cb69a9a7319910f8f83fcf9b46e7a32dfcf4b8f8eeddb55f647306e65", size = 3988373, upload-time = "2026-07-16T17:29:53.395Z" },
{ url = "https://files.pythonhosted.org/packages/39/c6/988383e9dc17294d536fcbcd6fd16eed882e411ad16c954984a53e47b09c/hf_xet-1.5.2-cp38-abi3-win_arm64.whl", hash = "sha256:1da28519496eb7c8094c11e4d25509b4a468457a0302d58136099db2fd9a671d", size = 3816957, upload-time = "2026-07-16T17:29:54.991Z" },
{ url = "https://files.pythonhosted.org/packages/41/62/3c062f593bd92ef4e77a0ef39541e3d82a0a1d3947c8a777a02a13a27828/hf_xet-1.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:70cbb9c896901600128cb9b6f06e132954fbede1db30f31f7c6c63f84cb7c31d", size = 4074584, upload-time = "2026-08-03T22:32:47.364Z" },
{ url = "https://files.pythonhosted.org/packages/bb/1e/c0ad437dd267a8e435bef594acf781bbc3874ff0b6435b4962d03ecf7cc4/hf_xet-1.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:23379c2f9ec8696d952b16414a2bae72cad86a52df869b050698ba60f538c675", size = 3867381, upload-time = "2026-08-03T22:32:49.049Z" },
{ url = "https://files.pythonhosted.org/packages/d5/ee/7c0d7b6ab336167531b1c30af2af003f054af4c749becbd7209ae33a77c3/hf_xet-1.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f7278c05c22fd60cb436cda1269649b3e81db65ecdc8496e5e164aa4143e7b", size = 4453982, upload-time = "2026-08-03T22:32:50.568Z" },
{ url = "https://files.pythonhosted.org/packages/63/06/ad8eab1c9525246650cbaa821caa3cdbaca734ab1a5b8c91bea09cbd8d69/hf_xet-1.6.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:948f15d3a9545cfe5932f6bd8b440f6ae630aee108f14b7bd6c561f7c2dcc522", size = 4249445, upload-time = "2026-08-03T22:32:52.391Z" },
{ url = "https://files.pythonhosted.org/packages/d8/26/1eee8aedb0dafc1ab9717dc9ac602cde33361b232dc06803f1f6ed18b58c/hf_xet-1.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5153e6bb103ad49d6ea9f1b2e230db5a2ea32551ad09a706d2f61d7c7c80d80e", size = 4451099, upload-time = "2026-08-03T22:32:54.114Z" },
{ url = "https://files.pythonhosted.org/packages/67/57/0b88af1f194ab6c9c650547d9cc06bfeaab836ae4dcdb331676bfb8be95a/hf_xet-1.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:35cec30d75c6f9eb9c16a77cef68e85a103b72e24d4b473714ec9ff06428bab9", size = 4664712, upload-time = "2026-08-03T22:32:55.547Z" },
{ url = "https://files.pythonhosted.org/packages/53/a0/26b717a9d1840e8abf48dcec64b5ed8fbe472671d38ad28d30e147132b33/hf_xet-1.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5789835d7c6bc9436962853192082374297fb72d7eff7e7762ec25ceb7e25338", size = 4025906, upload-time = "2026-08-03T22:32:57.391Z" },
{ url = "https://files.pythonhosted.org/packages/49/f6/4a9966633c6fef83af997e2cff68ec1963676d412bdfd096df2a93b8e185/hf_xet-1.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:75765820ce4700db3750c94acc8fe27c5fae4c9ec000a0dbac3ca082acf97765", size = 3849221, upload-time = "2026-08-03T22:32:59.123Z" },
{ url = "https://files.pythonhosted.org/packages/a2/50/7afa2c9c787405864fc47a0d1bbc02c62e9101947ed43c1f43899fc7d91d/hf_xet-1.6.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:633dc0cd71d32da58ab8c03ad38e2fac452c15c2b0a2866ebf6ededfe0a5061d", size = 4071729, upload-time = "2026-08-03T22:33:00.721Z" },
{ url = "https://files.pythonhosted.org/packages/4b/69/55b8dcf636142ae660fec1869fcac14c4da2e8412e14d6eee1523be77e9f/hf_xet-1.6.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:f0906082d9932ae0c0057fa194041c22b4e2cdb46b2592ef3b91f020d62a081a", size = 3876287, upload-time = "2026-08-03T22:33:02.251Z" },
{ url = "https://files.pythonhosted.org/packages/67/4e/a28359bf1c1ecf11eba22123168c138698f7cb576ac678f5a2e16cd5da08/hf_xet-1.6.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d62671bb130879cef0ee4c9ebe47a14af6c66ec53e6d84dc15936e5ffdfac82f", size = 4464663, upload-time = "2026-08-03T22:33:03.802Z" },
{ url = "https://files.pythonhosted.org/packages/9a/69/1f0cbc2fb22ae6082d094f743d1b8945a3f36f6089cb95f42b7ee348cda7/hf_xet-1.6.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0e6e21fa3cdfcdcd76748564bf593870a5e013f47d97cf10aed63aa222cff5b7", size = 4262538, upload-time = "2026-08-03T22:33:05.287Z" },
{ url = "https://files.pythonhosted.org/packages/d1/3a/4f4f2301ade26e404462d3336fa11f7958d914cabbabdd6e03c3c5d5658c/hf_xet-1.6.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4fc74352a17015bd0ee90038bc9efe38db894cde45f268b6712b04fce8cd0acb", size = 4460520, upload-time = "2026-08-03T22:33:06.81Z" },
{ url = "https://files.pythonhosted.org/packages/ab/5f/311725e2a905534dfee2dcb5b08414f249147f1f12252bfc2bd24caa075c/hf_xet-1.6.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8fb4f71cba6129110c3374a33f919001ff130488fc23553698e34cc1c2a1198c", size = 4675937, upload-time = "2026-08-03T22:33:08.616Z" },
{ url = "https://files.pythonhosted.org/packages/98/b7/8c59a66d15205024662f1d66968136f13893f96df1ddc5087e2e281fc95f/hf_xet-1.6.0-cp38-abi3-win_amd64.whl", hash = "sha256:fb4fadde1b2b70bf4c0c14a6dccbe7194b1c28947fefd5bbe3fed9d940676c3b", size = 4033128, upload-time = "2026-08-03T22:33:10.171Z" },
{ url = "https://files.pythonhosted.org/packages/73/63/ca511b6f802f28cf3489b280fe77475bcca8de85e81a6299d7916b5b5555/hf_xet-1.6.0-cp38-abi3-win_arm64.whl", hash = "sha256:3dc3e35441ba395006af5aaacc40ef2e603c51ef46c3530b9156185f00935ea3", size = 3859359, upload-time = "2026-08-03T22:33:11.725Z" },
]
[[package]]
@@ -1253,7 +1253,7 @@ name = "humanfriendly"
version = "10.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyreadline3", marker = "python_full_version < '3.11' and sys_platform == 'win32'" },
{ name = "pyreadline3", marker = "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 +1328,8 @@ name = "inflect"
version = "7.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "more-itertools", marker = "python_full_version >= '3.12'" },
{ name = "typeguard", marker = "python_full_version >= '3.12'" },
{ name = "more-itertools" },
{ name = "typeguard" },
]
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 = [
@@ -1532,7 +1532,7 @@ wheels = [
[[package]]
name = "modelscope-hub"
version = "0.1.9"
version = "0.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "filelock" },
@@ -1540,9 +1540,9 @@ dependencies = [
{ name = "tqdm" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/40/62/96d04c7618489461b92fb5e6ffabfea6617dc57b0a9b4fb67d4b29961ca2/modelscope_hub-0.1.9.tar.gz", hash = "sha256:e42564e7d132d440c3b2162f6746b510697eabe6ac18b3dd0ef52c18fc6e8742", size = 148342, upload-time = "2026-07-31T11:18:08.554Z" }
sdist = { url = "https://files.pythonhosted.org/packages/76/9d/3fabfd2b147d94e1980d670fecd3c183999db04a71b80a546bcf37cc6f89/modelscope_hub-0.2.0.tar.gz", hash = "sha256:00288d3e08ad7fadfdd7539f4dffa9d75f9006c43c73abd33ea51767317f001c", size = 163316, upload-time = "2026-08-01T15:00:08.694Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/39/56/3162f656c02ecd7118e249f19d2673712cbefd852fbe805e508a9739608d/modelscope_hub-0.1.9-py3-none-any.whl", hash = "sha256:d3d66b83aef7e5de131edf88206353e5f9122949ff3cd273ac57ab919abc763c", size = 152618, upload-time = "2026-07-31T11:18:06.975Z" },
{ url = "https://files.pythonhosted.org/packages/ab/be/3b0e14c296a622c920287fc6d2c1b5a8668c8e0d98662f7b2bd8f3fcc779/modelscope_hub-0.2.0-py3-none-any.whl", hash = "sha256:84668c80c1d36d1fb1ea1ce17b707d77e044b92255288618c3b7de5b13834418", size = 156023, upload-time = "2026-08-01T15:00:07.335Z" },
]
[[package]]
@@ -1810,7 +1810,7 @@ name = "nvidia-cublas"
version = "13.1.1.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ 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')" },
{ name = "nvidia-cuda-nvrtc" },
]
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 +1849,7 @@ name = "nvidia-cudnn-cu13"
version = "9.20.0.48"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ 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-cublas" },
]
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 +1861,7 @@ name = "nvidia-cufft"
version = "12.0.0.61"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ 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')" },
{ name = "nvidia-nvjitlink" },
]
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 +1891,9 @@ name = "nvidia-cusolver"
version = "12.0.4.66"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ 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')" },
{ name = "nvidia-cublas" },
{ name = "nvidia-cusparse" },
{ name = "nvidia-nvjitlink" },
]
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 +1905,7 @@ name = "nvidia-cusparse"
version = "12.6.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ 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')" },
{ name = "nvidia-nvjitlink" },
]
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 +1980,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", 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'" },
{ name = "coloredlogs" },
{ name = "flatbuffers" },
{ name = "numpy" },
{ name = "packaging" },
{ name = "protobuf" },
{ name = "sympy" },
]
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 +2034,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", 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 = "flatbuffers" },
{ name = "numpy" },
{ name = "packaging" },
{ name = "protobuf" },
]
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" },
@@ -2205,10 +2205,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", 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'" },
{ name = "numpy" },
{ name = "python-dateutil" },
{ name = "pytz" },
{ name = "tzdata" },
]
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 +2278,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", 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')" },
{ name = "numpy" },
{ name = "python-dateutil" },
{ name = "tzdata", marker = "sys_platform == 'emscripten' or 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 = [
@@ -2759,10 +2759,10 @@ name = "pydantic"
version = "2.13.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types", marker = "python_full_version >= '3.12'" },
{ name = "pydantic-core", marker = "python_full_version >= '3.12'" },
{ name = "typing-extensions", marker = "python_full_version >= '3.12'" },
{ name = "typing-inspection", marker = "python_full_version >= '3.12'" },
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
wheels = [
@@ -2771,7 +2771,7 @@ wheels = [
[package.optional-dependencies]
email = [
{ name = "email-validator", marker = "python_full_version >= '3.12'" },
{ name = "email-validator" },
]
[[package]]
@@ -2779,7 +2779,7 @@ name = "pydantic-core"
version = "2.46.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version >= '3.12'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
wheels = [
@@ -3025,7 +3025,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", marker = "python_full_version < '3.11'" },
{ name = "numpy" },
]
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 +3090,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", marker = "python_full_version >= '3.11'" },
{ name = "numpy" },
]
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 = [
@@ -3331,7 +3331,7 @@ wheels = [
[[package]]
name = "remove-ai-watermarks"
version = "0.25.0"
version = "0.26.0"
source = { editable = "." }
dependencies = [
{ name = "c2pa-python" },
@@ -3659,7 +3659,7 @@ name = "stamina"
version = "26.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tenacity", marker = "python_full_version >= '3.12'" },
{ name = "tenacity" },
]
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 = [
@@ -3959,7 +3959,7 @@ name = "typeguard"
version = "4.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version >= '3.12'" },
{ name = "typing-extensions" },
]
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 = [
@@ -3968,7 +3968,7 @@ wheels = [
[[package]]
name = "typer"
version = "0.27.0"
version = "0.27.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
@@ -3976,9 +3976,9 @@ dependencies = [
{ name = "rich" },
{ name = "shellingham" },
]
sdist = { url = "https://files.pythonhosted.org/packages/37/78/fda3361b56efc27944f24225f6ecd13d96d6fcfe37bd0eb34e2f4c63f9fc/typer-0.27.0.tar.gz", hash = "sha256:629bd12ea5d13a17148125d9a264f949eb171fb3f120f9b04d85873cab054fa5", size = 203430, upload-time = "2026-07-15T19:21:07.007Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ae/40/4a3db7990d1f62a53182aa96eaef57aeb2886a27f90a195bc66713565d31/typer-0.27.1.tar.gz", hash = "sha256:a79bef8469a79c45498e7b814ecf8d603cc7644e9acbd9e19cac0334240b18df", size = 203994, upload-time = "2026-08-03T14:41:03.438Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/40/03/26a383c9e58c213199d1aad1c3d353cfc22d4444ec6d2c0bf8ad02523843/typer-0.27.0-py3-none-any.whl", hash = "sha256:6f4b27631e47f077871b7dc30e933ec0131c1390fbe0e387ea5574b5bac9ccf1", size = 122716, upload-time = "2026-07-15T19:21:05.553Z" },
{ url = "https://files.pythonhosted.org/packages/43/89/9518bc0c3929bee36b3a4a8e3daddd6e03f92f9961c66d4983b837160543/typer-0.27.1-py3-none-any.whl", hash = "sha256:53150287edd11baeb4e4722c8e394fcdf8181c0ae89485cba8d25c778d5edd56", size = 122874, upload-time = "2026-08-03T14:41:04.391Z" },
]
[[package]]
@@ -3995,7 +3995,7 @@ name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version >= '3.12'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
@@ -4025,10 +4025,10 @@ name = "uv-outdated"
version = "1.0.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ 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'" },
{ name = "packaging" },
{ name = "pydantic" },
{ name = "rich" },
{ name = "typer" },
]
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 +4040,18 @@ name = "uv-secure"
version = "0.17.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ 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'" },
{ 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" },
]
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 = [