mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-10-18 23:37:14 +02:00
Compare commits
No files matched your search
@@ -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).
|
||||
@@ -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
@@ -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/
|
||||
@@ -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 |
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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 ""))
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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."""
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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"}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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 == []
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in new issue
Block a user