mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-09 23:50:40 +02:00
Merge pull request #75 from wiltodelta/refactor/detection-path-and-library-pipeline
Collapse the duplicated detection path and lift the image pipeline into the library
This commit is contained in:
@@ -40,4 +40,23 @@ Do not classify an entire module as untestable because its main path downloads a
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
Environment setup, dependency recovery, CI behavior, and fixture policy: [`../../docs/development.md`](../../docs/development.md).
|
||||
|
||||
@@ -53,8 +53,5 @@ data/samsung_capture/captures/samsung_content_*
|
||||
# dir on first use). Runtime artifact, never committed.
|
||||
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-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 |
|
||||
|
||||
@@ -128,6 +128,12 @@ 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.
|
||||
|
||||
## Strip AI metadata
|
||||
|
||||
Inspect metadata:
|
||||
|
||||
@@ -253,9 +253,13 @@ 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
|
||||
@@ -355,6 +359,43 @@ metadata extraction from verdict logic:
|
||||
- `identify` preserves the path-based API and adds the optional registered
|
||||
visible-mark and open invisible-watermark decoders after extraction.
|
||||
|
||||
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 +438,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 +505,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
|
||||
|
||||
+65
-3
@@ -77,6 +77,55 @@ 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,
|
||||
)
|
||||
```
|
||||
|
||||
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
|
||||
@@ -151,9 +200,22 @@ describe the collector rather than the source file. 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.
|
||||
`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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -35,9 +35,15 @@ _warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*")
|
||||
__version__ = "0.25.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)
|
||||
|
||||
@@ -329,14 +329,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":
|
||||
|
||||
@@ -147,3 +147,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"})
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
+494
-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,458 @@ 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.
|
||||
|
||||
Mirrors ``InvisibleEngine.remove_watermark``. 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 | None = None
|
||||
controlnet_scale: float = 1.0
|
||||
cpu_offload: bool = True
|
||||
tile: bool = False
|
||||
tile_size: int = 1024
|
||||
tile_overlap: int = 128
|
||||
# Scrub even when no invisible watermark is locally detectable.
|
||||
force: bool = False
|
||||
|
||||
|
||||
# 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,
|
||||
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.
|
||||
|
||||
``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)
|
||||
|
||||
# ── 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,
|
||||
) -> 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 (opts.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_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,
|
||||
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. ``engine`` is 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, 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,
|
||||
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,
|
||||
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),
|
||||
)
|
||||
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),
|
||||
)
|
||||
|
||||
+144
-376
@@ -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_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_scale,
|
||||
cpu_offload=options.cpu_offload,
|
||||
)
|
||||
|
||||
|
||||
@main.command("batch")
|
||||
@@ -1867,29 +1630,26 @@ 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,
|
||||
adaptive_polish=adaptive_polish,
|
||||
max_resolution=max_resolution,
|
||||
controlnet_scale=controlnet_scale,
|
||||
adaptive_polish=adaptive_polish,
|
||||
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 +1659,36 @@ 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,
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -59,6 +59,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
|
||||
@@ -446,14 +447,22 @@ _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 _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 +483,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 +684,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 +732,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 +759,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 +865,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
|
||||
@@ -1006,7 +1075,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 +1108,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
|
||||
@@ -1078,9 +1147,27 @@ 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(
|
||||
|
||||
@@ -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,7 +15,7 @@ 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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -165,6 +165,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:
|
||||
@@ -270,6 +285,21 @@ def _png_late_metadata(image_path: Path, window: int) -> bytes:
|
||||
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
|
||||
@@ -420,7 +450,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 +489,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 +567,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 +628,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 +665,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 +804,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 +1401,87 @@ 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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+313
-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,264 @@ class TestRemoveVisibleOutputPath:
|
||||
out = tmp_path / "sub" / "out.png"
|
||||
raiw.remove_visible(str(src), str(out), backend="cv2")
|
||||
assert out.exists()
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
|
||||
+6
-1
@@ -552,7 +552,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 +566,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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -1274,3 +1274,76 @@ 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -590,6 +590,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 +784,54 @@ 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"]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
+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]]
|
||||
@@ -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", marker = "python_full_version >= '3.12' or sys_platform != 'darwin'" },
|
||||
]
|
||||
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' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" },
|
||||
{ name = "nvidia-cuda-nvrtc", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" },
|
||||
]
|
||||
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' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" },
|
||||
]
|
||||
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' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" },
|
||||
{ name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" },
|
||||
]
|
||||
cufile = [
|
||||
{ name = "nvidia-cufile", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
|
||||
]
|
||||
cupti = [
|
||||
{ name = "nvidia-cuda-cupti", marker = "(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' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" },
|
||||
]
|
||||
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' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" },
|
||||
]
|
||||
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' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" },
|
||||
{ name = "nvidia-cusolver", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" },
|
||||
{ name = "nvidia-cusparse", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" },
|
||||
{ name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" },
|
||||
]
|
||||
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' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" },
|
||||
{ name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" },
|
||||
]
|
||||
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' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" },
|
||||
]
|
||||
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' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" },
|
||||
]
|
||||
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' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -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]]
|
||||
@@ -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", marker = "python_full_version >= '3.12' or sys_platform != 'darwin'" },
|
||||
]
|
||||
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", marker = "python_full_version >= '3.12' or sys_platform != 'darwin'" },
|
||||
]
|
||||
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", marker = "python_full_version >= '3.12' or sys_platform != 'darwin'" },
|
||||
]
|
||||
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", marker = "python_full_version >= '3.12' or sys_platform != 'darwin'" },
|
||||
{ name = "nvidia-cusparse", marker = "python_full_version >= '3.12' or sys_platform != 'darwin'" },
|
||||
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.12' or sys_platform != 'darwin'" },
|
||||
]
|
||||
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", marker = "python_full_version >= '3.12' or sys_platform != 'darwin'" },
|
||||
]
|
||||
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" },
|
||||
@@ -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]]
|
||||
|
||||
Reference in New Issue
Block a user