mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-10-20 16:07:17 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f629a4ea6 | ||
|
|
e3bbe88155 | ||
|
|
be8f24c7cb | ||
|
|
9990f3e6ca | ||
|
|
83be3a8926 | ||
|
|
9d29d2c777 | ||
|
|
7a6491fdc1 | ||
|
|
37431c9aeb | ||
|
|
804735aa48 | ||
|
|
e3f714dcd2 | ||
|
|
7169780dd0 | ||
|
|
c7021b900b | ||
|
|
3f55df6f99 | ||
|
|
4795149d34 | ||
|
|
cf30b4f95a | ||
|
|
8ce8bf14da | ||
|
|
3fb94b220d | ||
|
|
d2c5aa66fc | ||
|
|
380820fa7e | ||
|
|
12e9a87865 |
No files matched your search
@@ -47,6 +47,10 @@ real browser. Proxy credentials are read only through a slot's named environment
|
||||
and never enter a manifest or log. Dotenv interpolation may derive routed proxy URLs from
|
||||
one secret key without mutating the process environment. A selected proxy context may
|
||||
accept that proxy's TLS interception certificate; direct contexts keep normal TLS checks.
|
||||
ThorData rotating endpoints are pinned to one manifest-derived sticky session for the
|
||||
life of a Web batch so one browser context does not change exit identity between page
|
||||
requests. This is route continuity, not account or IP failover; a refusal still ends on
|
||||
the selected slot and never selects another route automatically.
|
||||
Upload readiness is surface-specific: OpenAI reaches it at page load but keeps background
|
||||
requests alive, while Microsoft and Meta require network idle. Preserve exact provider
|
||||
errors as indeterminate evidence rather than retrying or interpreting them as clean.
|
||||
@@ -129,6 +133,10 @@ Do not classify an entire module as untestable because its main path downloads a
|
||||
- the photo-classify gate in `test_classify.py`: DEFINITELY is ridge AND MLP,
|
||||
POSSIBLY does not emit a provider, and `identify` does not import
|
||||
`remove_ai_watermarks.classify`.
|
||||
- the source-export classifier in `test_source_classify.py`: the Hub revision
|
||||
and artifact hash are immutable, the pickle-free schema and exact GELU are
|
||||
validated, threshold mutation changes the real decision, and `identify`
|
||||
never consumes the result as watermark evidence.
|
||||
- the `InvisibleOptions` defaults, in `test_api.py`. When one signature promises to
|
||||
mirror another, compare them field by field rather than pinning the values you happen
|
||||
to know about, so the next field added on one side and not the other fails at the
|
||||
|
||||
@@ -88,6 +88,14 @@ OpenAI, Microsoft, and Meta Web slots may each name one of them through
|
||||
verify egress before relying on it, and never automatically change route after
|
||||
a refusal. Bound a batch before starting.
|
||||
|
||||
ThorData user-and-password endpoints without `sessid` rotate the exit on each
|
||||
request, which breaks a browser page whose resources must share one network
|
||||
identity. The runner therefore derives one sticky `sessid` from the immutable
|
||||
manifest hash and keeps it for that batch. A new prepared batch gets a new
|
||||
session; rerunning or resuming the same batch keeps its session while the proxy
|
||||
retains it. An explicit `sessid` in the configured route is preserved. This is
|
||||
session continuity, not permission to retry a refused batch on another route.
|
||||
|
||||
### OpenAI API
|
||||
|
||||
Use `check-openai` with one explicit upload acknowledgement. Keep named API keys
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
|
||||
# ThorData residential proxies (optional; only for explicitly selected Web oracle routes)
|
||||
# Generate these URLs in the ThorData endpoint generator; do not commit credentials.
|
||||
# The Web runner pins a rotating user-and-password route to one sticky session per manifest.
|
||||
# THORDATA_ROUTE_US=http://td-customer-USERNAME-country-US:PASSWORD@HOST.pr.thordata.net:9999
|
||||
# THORDATA_ROUTE_DE=http://td-customer-USERNAME-country-DE:PASSWORD@HOST.pr.thordata.net:9999
|
||||
# THORDATA_ROUTE_GB=http://td-customer-USERNAME-country-GB:PASSWORD@HOST.pr.thordata.net:9999
|
||||
|
||||
@@ -7,8 +7,8 @@ name: Distribute on release
|
||||
# - HF Space: factory-rebuild the version already pinned in the Space repo.
|
||||
# - ComfyUI: sync, test, and publish the node against the exact library release.
|
||||
# - ClawHub: publish the agent skill when its own version moved.
|
||||
# All wait for the freshly published sdist to appear on PyPI first, since the
|
||||
# Release event fires in parallel with publish.yml's upload.
|
||||
# All wait for the freshly published distribution to appear in both PyPI APIs
|
||||
# first, since the Release event fires in parallel with publish.yml's upload.
|
||||
|
||||
on:
|
||||
release:
|
||||
@@ -22,6 +22,9 @@ on:
|
||||
jobs:
|
||||
resolve:
|
||||
runs-on: ubuntu-latest
|
||||
# Thirty attempts may spend up to 20 seconds each in the JSON request,
|
||||
# simple-index request, and inter-attempt delay. Leave bounded headroom.
|
||||
timeout-minutes: 25
|
||||
outputs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
url: ${{ steps.sdist.outputs.url }}
|
||||
@@ -63,26 +66,35 @@ jobs:
|
||||
path: release-identity.json
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Wait for the sdist on PyPI, capture url + sha256
|
||||
- name: Wait for the PyPI JSON and simple indexes, capture sdist metadata
|
||||
id: sdist
|
||||
run: |
|
||||
set -euo pipefail
|
||||
v="${{ steps.v.outputs.version }}"
|
||||
for i in $(seq 1 30); do
|
||||
if rel=$(curl -fsSL "https://pypi.org/pypi/remove-ai-watermarks/$v/json" 2>/dev/null); then
|
||||
url=$(echo "$rel" | python3 -c "import sys,json;d=json.load(sys.stdin);print(next((u['url'] for u in d['urls'] if u['packagetype']=='sdist'),''))")
|
||||
sha=$(echo "$rel" | python3 -c "import sys,json;d=json.load(sys.stdin);print(next((u['digests']['sha256'] for u in d['urls'] if u['packagetype']=='sdist'),''))")
|
||||
if rel=$(curl --connect-timeout 10 --max-time 20 -fsSL "https://pypi.org/pypi/remove-ai-watermarks/$v/json" 2>/dev/null); then
|
||||
read -r url sha <<< "$(echo "$rel" | python3 -c "import sys,json;u=next((u for u in json.load(sys.stdin)['urls'] if u['packagetype']=='sdist'),{});print(u.get('url',''),u.get('digests',{}).get('sha256',''))")"
|
||||
if [ -n "$url" ] && [ -n "$sha" ]; then
|
||||
echo "url=$url" >> "$GITHUB_OUTPUT"
|
||||
echo "sha=$sha" >> "$GITHUB_OUTPUT"
|
||||
echo "Found sdist for $v"
|
||||
exit 0
|
||||
# PyPI's JSON API can show a freshly uploaded file before the
|
||||
# simple index used by pip has caught up. Downstream release
|
||||
# jobs install by package version, so waiting on JSON alone
|
||||
# creates a release-time race for them.
|
||||
simple=$(curl --connect-timeout 10 --max-time 20 -fsSL https://pypi.org/simple/remove-ai-watermarks/ 2>/dev/null || true)
|
||||
artifact=${url##*/}
|
||||
if [[ "$simple" == *"$artifact"* ]]; then
|
||||
echo "url=$url" >> "$GITHUB_OUTPUT"
|
||||
echo "sha=$sha" >> "$GITHUB_OUTPUT"
|
||||
echo "PyPI JSON and simple indexes list $v"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
echo "sdist for $v not on PyPI yet (attempt $i), waiting..."
|
||||
sleep 20
|
||||
if [ "$i" -lt 30 ]; then
|
||||
echo "PyPI indexes do not both list $v yet (attempt $i), waiting..."
|
||||
sleep 20
|
||||
fi
|
||||
done
|
||||
echo "::error::sdist for $v did not appear on PyPI in time"
|
||||
echo "::error::PyPI JSON and simple indexes did not both list $v in time"
|
||||
exit 1
|
||||
|
||||
homebrew:
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
name: Publish source classifier to Hugging Face
|
||||
|
||||
# Manual only. The derivative model weights live in a GitHub model-freeze
|
||||
# release; the private training corpus and caches never enter this repository.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
confirm:
|
||||
description: "Type publish"
|
||||
required: true
|
||||
weights_tag:
|
||||
description: "GitHub Release tag holding source-pipeline-mlp.npz"
|
||||
required: true
|
||||
default: source-classify-freeze-2026-09-16-r2
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
if: ${{ github.event.inputs.confirm == 'publish' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Download derivative model weights
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ github.event.inputs.weights_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p weights
|
||||
gh release download "$TAG" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--pattern source-pipeline-mlp.npz \
|
||||
--dir weights
|
||||
test -s weights/source-pipeline-mlp.npz
|
||||
|
||||
- name: Verify the frozen artifact
|
||||
run: |
|
||||
set -euo pipefail
|
||||
expected="$(jq -r .artifact_sha256 docs/source-classify-hf/metrics.json)"
|
||||
test "${#expected}" -eq 64
|
||||
echo "$expected weights/source-pipeline-mlp.npz" | sha256sum --check
|
||||
|
||||
- name: Publish to Hugging Face
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${HF_TOKEN:-}" ]; then
|
||||
echo "HF_TOKEN secret is not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
uv run --no-project --with huggingface-hub \
|
||||
python scripts/publish_source_classify_hf.py \
|
||||
--src weights \
|
||||
--message "Source-classify freeze (GitHub ${SHA:0:7})"
|
||||
@@ -21,6 +21,8 @@ jobs:
|
||||
- name: Sync dev environment
|
||||
run: uv sync --frozen --extra dev
|
||||
- name: Check C2PA soft-binding registry snapshot
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: uv run python scripts/sync_c2pa_soft_bindings.py --check
|
||||
- name: Ruff lint
|
||||
run: uv run ruff check
|
||||
|
||||
@@ -43,7 +43,7 @@ The published Agent Skill is [`skills/remove-ai-watermarks/`](skills/remove-ai-w
|
||||
|
||||
[`docs/module-internals.md`](docs/module-internals.md) is the canonical per-module map, including design decisions, thresholds, calibration history, incident records, and regression guards. Read the relevant section before changing a subsystem.
|
||||
|
||||
Research and current constraints are routed through [`docs/index.md`](docs/index.md), especially [`docs/known-limitations.md`](docs/known-limitations.md), [`docs/supported-signals.md`](docs/supported-signals.md), [`docs/synthid.md`](docs/synthid.md), and [`docs/watermarking-landscape.md`](docs/watermarking-landscape.md). Pixel photo classification is [`docs/photo-classify.md`](docs/photo-classify.md); it is not `identify`. Classifier research is split between general [`AI-generated image classifiers`](docs/ai-generated-image-classifiers.md) and [`SynthID source classifiers`](docs/synthid-classifiers.md). Other SynthID campaign logs are [`docs/synthid-detector-research.md`](docs/synthid-detector-research.md) and [`docs/synthid-removal-research.md`](docs/synthid-removal-research.md). The pre-split chronological archive is [`docs/synthid-detector-removal-plan.md`](docs/synthid-detector-removal-plan.md). The benchmark kernel and its pinned local oracles (AudioSeal, VideoSeal) cover the audio/video watermark cohorts and research studies: [`docs/benchmark-kernel.md`](docs/benchmark-kernel.md).
|
||||
Research and current constraints are routed through [`docs/index.md`](docs/index.md), especially [`docs/known-limitations.md`](docs/known-limitations.md), [`docs/supported-signals.md`](docs/supported-signals.md), [`docs/synthid.md`](docs/synthid.md), and [`docs/watermarking-landscape.md`](docs/watermarking-landscape.md). Pixel photo classification is [`docs/photo-classify.md`](docs/photo-classify.md); the separate OpenAI/Google/unknown export-pipeline signal is [`docs/source-classify.md`](docs/source-classify.md). Neither runs inside `identify` or constitutes SynthID detection. Classifier research is split between general [`AI-generated image classifiers`](docs/ai-generated-image-classifiers.md) and [`SynthID source classifiers`](docs/synthid-classifiers.md). Other SynthID campaign logs are [`docs/synthid-detector-research.md`](docs/synthid-detector-research.md) and [`docs/synthid-removal-research.md`](docs/synthid-removal-research.md). The pre-split chronological archive is [`docs/synthid-detector-removal-plan.md`](docs/synthid-detector-removal-plan.md). The benchmark kernel and its pinned local oracles (AudioSeal, VideoSeal) cover the audio/video watermark cohorts and research studies: [`docs/benchmark-kernel.md`](docs/benchmark-kernel.md).
|
||||
|
||||
## Data safety
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ specialized Python API can inspect and disrupt the validated local
|
||||
| --- | --- |
|
||||
| Metadata inspection and stripping | `remove-ai-watermarks` |
|
||||
| Photograph AI-versus-camera classification | `remove-ai-watermarks[classify]` |
|
||||
| OpenAI/Google/unknown source-export classification | `remove-ai-watermarks[source-classify]` |
|
||||
| Visible detection and removal | `remove-ai-watermarks[visible]` |
|
||||
| Visible video processing | `remove-ai-watermarks[video]` |
|
||||
| Video SynthID removal | `remove-ai-watermarks[video,diffusion]` |
|
||||
@@ -68,7 +69,7 @@ specialized Python API can inspect and disrupt the validated local
|
||||
| Every production feature available on the active Python | `remove-ai-watermarks[all]` |
|
||||
|
||||
Lower-level and specialized extras include `pixels`, `heif`, `trustmark`,
|
||||
`migan`, `lama`, `diffusion`, and `classify-onnx`. The
|
||||
`migan`, `lama`, `diffusion`, `classify-onnx`, and `source-classify`. The
|
||||
[installation guide](docs/installation.md#feature-extras) documents their exact
|
||||
dependency composition, Python compatibility, and model requirements.
|
||||
|
||||
@@ -96,6 +97,19 @@ remove-ai-watermarks classify image.png
|
||||
|
||||
Guide: [photo pixel classification](docs/photo-classify.md).
|
||||
|
||||
For a lightweight, abstaining OpenAI/Google/unknown source-export signal after
|
||||
metadata removal, use the separate Python API. It is not a SynthID detector:
|
||||
|
||||
```python
|
||||
import remove_ai_watermarks as raiw
|
||||
|
||||
result = raiw.classify_source("image.png")
|
||||
print(result.label, result.reason)
|
||||
```
|
||||
|
||||
Install `remove-ai-watermarks[source-classify]`. Guide:
|
||||
[source-pipeline classification](docs/source-classify.md).
|
||||
|
||||
Signed provenance is the supported route for SynthID and `identify` reads it.
|
||||
There is no local SynthID pixel detector in the package. Research on a
|
||||
periodic lattice expert is in `scripts/synthid_runtime/` and
|
||||
@@ -354,7 +368,13 @@ Visible mark support includes:
|
||||
Yuanbao, Baidu, LiblibAI's bottom-center wordmark and compact top-left pill,
|
||||
and RunningHub labels;
|
||||
- one calibrated Microsoft top-right white AI-badge variant;
|
||||
- one calibrated Samsung Galaxy AI label variant.
|
||||
- one calibrated Samsung Galaxy AI label variant;
|
||||
- a generic brand-less bottom-right `AI生成` fallback for OS/gallery-level
|
||||
AI-edit stamps that carry no vendor wordmark of their own (vivo and Xiaomi
|
||||
Gallery confirmed); synthetic-only calibration, weaker evidence than the
|
||||
vendor-tuned detectors above;
|
||||
- the OpenArt wordmark, placed at the frame center rather than a corner;
|
||||
uncalibrated (one confirmed real case, no captured corpus).
|
||||
|
||||
Metadata and provenance inspection covers C2PA, EXIF, XMP, IPTC, common
|
||||
generator parameters in image and video containers, China TC260 AIGC labels,
|
||||
|
||||
@@ -64,7 +64,7 @@ the calibration rows below is the race-free variant.
|
||||
## Strength floor calibration (qwen-zimage, seed 0)
|
||||
|
||||
The library resolves strength per vendor with measured floors (OpenAI
|
||||
0.07675 / Google 0.27 / Microsoft InvisMark 0.15 in
|
||||
0.15625 / Google 0.35 / Microsoft InvisMark 0.15 in
|
||||
`_internal/watermark_profiles.py`). Meta Content Seal had no floor before this
|
||||
calibration; these rows measure one by the same methodology: independent
|
||||
generations, each one's first-clean boundary, floor = worst boundary plus the
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
@@ -506,7 +506,8 @@ This invalidates both the 0.09 explicit Chroma floor and the OpenAI auto route.
|
||||
For explicit Chroma, the face-bearing cohort rule gives
|
||||
`0.1375 + (0.1375 - 0.075) = 0.20`; that candidate was subsequently verified
|
||||
clean three times on both holdout carriers. `auto` uses `qwen-zimage` for OpenAI
|
||||
at 0.07675; Chroma remains the Microsoft route.
|
||||
at its separately calibrated floor; Chroma remains the Microsoft route. The
|
||||
then-current Qwen floor was 0.07675, superseded by the source-fresh expansion below.
|
||||
|
||||
## OpenAI zero-face holdout: the content split does not survive real carriers (2026-09-08)
|
||||
|
||||
@@ -548,6 +549,21 @@ wider. A narrow fixture spread produces a small worst-clean-plus-one-spread marg
|
||||
and a false impression of homogeneity, and it was that impression - not any
|
||||
measurement - that made a face split look justified here twice.
|
||||
|
||||
## Qwen OpenAI source-fresh expansion (2026-09-21)
|
||||
|
||||
The same under-sampling defect existed in Qwen's OpenAI floor: two sources first
|
||||
cleared at 0.06225 and 0.0695, so their 0.00725 spread produced the shipped 0.07675
|
||||
margin. A later production miss made that n=2 estimate untenable.
|
||||
|
||||
Six fresh `gpt-image-2` API generations - three YuNet-positive and three
|
||||
YuNet-zero-face - all retained SynthID in decoded-RGB-identical metadata-stripped
|
||||
controls. Their complete Qwen seed-0 ladders expanded the n=8 first-clean range to
|
||||
0.03125-0.09375, so the standard rule gives
|
||||
`0.09375 + (0.09375 - 0.03125) = 0.15625`. The face and no-face groups each span
|
||||
0.05 internally while their worst boundaries differ by only 0.0125, so OpenAI
|
||||
keeps one flat Qwen floor. The per-source table and exact protocol are recorded in
|
||||
[`Measured provider boundaries for qwen-zimage`](module-internals.md#measured-provider-boundaries-for-qwen-zimage).
|
||||
|
||||
## Content-balanced engine selection check (2026-08-31)
|
||||
|
||||
The earlier tables were intentionally carrier-focused and therefore small. A
|
||||
|
||||
+8
-7
@@ -529,9 +529,9 @@ remove-ai-watermarks invisible image.png -o clean.png \
|
||||
--pipeline qwen-zimage --text-manifest verified-lines.json --force
|
||||
```
|
||||
|
||||
Use an explicit `qwen-zimage` or `chroma-zimage` profile with this option. For
|
||||
Google provenance, `auto` resolves to SDXL, whose VAE path has not been calibrated
|
||||
for verified-text restoration and is therefore rejected.
|
||||
The option works with `qwen-zimage`, `sdxl-zimage`, `chroma-zimage`, and `auto`.
|
||||
Each concrete profile reconstructs the donor through its own already loaded VAE;
|
||||
for Google provenance, `auto` therefore keeps the normal SDXL route.
|
||||
|
||||
``verified: true`` may also be set by an automated operator that attests
|
||||
machine-verified geometry: stability-gated detector boxes inside sane caps. Such
|
||||
@@ -556,10 +556,11 @@ container changes remain valid while a resized or edited source fails closed. Th
|
||||
experimental helper
|
||||
`remove_ai_watermarks._internal.text_restoration.source_pixel_sha256` computes it.
|
||||
|
||||
This mode is supported by `qwen-zimage`, `chroma-zimage`, and `auto` at native
|
||||
geometry with `humanize=0`, `unsharp=0`, and adaptive polish disabled. The legacy
|
||||
`sdxl-zimage` profile does not expose a verified-text VAE donor. `all` also accepts the flag,
|
||||
but its manifest must match the pixels entering the invisible stage; if visible-mark
|
||||
This mode is supported by every profile at native geometry with `humanize=0` and
|
||||
`unsharp=0`. An unset adaptive-polish option is forced off so verified text remains
|
||||
the final pixel-writing stage; an explicit request to enable it is rejected. `all`
|
||||
also accepts the flag, but its
|
||||
manifest must match the pixels entering the invisible stage; if visible-mark
|
||||
removal changes those pixels, the hash check rejects the run. One oracle verdict does
|
||||
not certify another manifest, seed, model/runtime version, or output hash.
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ to run the tool. Use the maintainer references only when changing the code.
|
||||
| [Supported signals](supported-signals.md) | You need to know which visible marks, metadata formats, and invisible signals are covered. |
|
||||
| [Known limitations](known-limitations.md) | You need the quality, device, format, or verification boundaries. |
|
||||
| [Photo pixel classification](photo-classify.md) | You want `classify` / `classify_pixels`: AI versus camera, optional provider. |
|
||||
| [Source-pipeline classification](source-classify.md) | You want an abstaining OpenAI/Google/unknown export-pipeline signal after metadata removal. |
|
||||
| [Photo classifier training](photo-classify-training.md) | You need the retrain pack, public sources, and what is not published. |
|
||||
| [Scope, safety, and legal notes](legal-and-safety.md) | You need the intended use and legal context. |
|
||||
|
||||
@@ -42,6 +43,7 @@ The current behavior is defined by the code, tests, README, and user guides.
|
||||
- [AI-generated image classifiers](ai-generated-image-classifiers.md) (photo AI-versus-camera freeze consumed by `classify_pixels`, not `identify`)
|
||||
- [Photo classifier Hugging Face card](photo-classify-hf/README.md) (Hub publication text of the 2026-08-31 freeze)
|
||||
- [SynthID source classifiers](synthid-classifiers.md) (metadata-free OpenAI/Gemini source finding and provider-lineage experiments)
|
||||
- [Source-pipeline classifier model card](source-classify-hf/README.md) (published derivative weights, aggregate evaluation, and intended-use boundary)
|
||||
- [SynthID local detector research](synthid-detector-research.md)
|
||||
- [SynthID mark removal research](synthid-removal-research.md)
|
||||
- [SynthID identity research](synthid-robust-identity-research.md)
|
||||
|
||||
@@ -129,6 +129,7 @@ application actually uses:
|
||||
| `trustmark` | Adobe TrustMark detection on Python 3.11-3.12 | trustmark | Yes |
|
||||
| `classify` | Metadata-free photo AI-versus-camera classifier plus gated provider | `pixels`, Torch, Transformers | Yes |
|
||||
| `classify-onnx` | Optional CPU-only ONNX vision runtime for `classify` | `classify`, ONNX Runtime | Yes |
|
||||
| `source-classify` | Abstaining OpenAI/Google/unknown source-export classifier, not SynthID detection | `pixels`, Hugging Face Hub | Model download, no Torch |
|
||||
| `diffusion` | Torch and Diffusers runtime; video SynthID regeneration | `pixels`, Torch, Diffusers | Yes |
|
||||
| `migan` | MI-GAN ONNX fill backend | `visible`, ONNX Runtime | Model download, no Torch |
|
||||
| `lama` | big-LaMa ONNX fill backend | `visible`, ONNX Runtime | Model download, no Torch |
|
||||
@@ -156,6 +157,7 @@ flowchart LR
|
||||
trustmark
|
||||
classify --> pixels
|
||||
onnx["classify-onnx"] --> classify
|
||||
source["source-classify"] --> pixels
|
||||
```
|
||||
|
||||
`heif`, `trustmark`, and `text-draft` are independent branches. Combine them
|
||||
|
||||
@@ -20,6 +20,14 @@ The command is never started by `identify`. A no-signal provenance result stays
|
||||
unknown. This is not a clean verdict and it does not run cleanup. Full guide:
|
||||
[photo pixel classification](photo-classify.md).
|
||||
|
||||
`classify_source` is a different, lightweight OpenAI/Google/unknown
|
||||
source-export classifier. It is useful only while the pixels still resemble an
|
||||
original export. A 95% resize reduced measured Google recall to 1%, and a JPEG
|
||||
quality-75 round trip made every public evaluation row abstain. Its provider
|
||||
label is not evidence that SynthID is present, and `unknown` is not evidence
|
||||
that SynthID is absent. It is also never started by `identify`. Full guide:
|
||||
[source-pipeline classification](source-classify.md).
|
||||
|
||||
## Visible removal
|
||||
|
||||
### Fill quality depends on the background
|
||||
@@ -260,9 +268,11 @@ The profiles resolve an unset strength differently, because different things
|
||||
were measured for each.
|
||||
|
||||
`qwen-zimage` reads unknown content from the resolution-adaptive denoise curve.
|
||||
Measured provider cohorts instead take flat operating points: OpenAI `0.07675`,
|
||||
Measured provider cohorts instead take flat operating points: OpenAI `0.15625`,
|
||||
Google `0.35`, and Microsoft InvisMark `0.15`. OpenAI and Microsoft add one full
|
||||
observed cross-source boundary spread to the worst clean source; Microsoft's three
|
||||
observed cross-source boundary spread to the worst clean source. OpenAI's eight-source
|
||||
cohort spans first-clean boundaries from `0.03125` to `0.09375`, giving `0.15625`;
|
||||
the face and no-face halves each span `0.05`, so there is no face split. Microsoft's three
|
||||
first-clean boundaries were `0.04125`, `0.055`, and `0.095`, giving `0.14875` before
|
||||
rounding up. Google's source-fresh candidate cleared three independent CJK carrier
|
||||
types after the original content matrix found CJK and Cyrillic to be the limiting
|
||||
@@ -285,8 +295,8 @@ established for that stage.
|
||||
|
||||
- OpenAI: `0.20`. Two withheld carriers expanded the Chroma first-clean boundary
|
||||
to `0.10625` and `0.1375`; adding the face-bearing cohort spread to the worst
|
||||
boundary gives `0.20`, verified clean three times on both, while qwen-zimage
|
||||
cleared both at its existing `0.07675` operating point;
|
||||
boundary gives `0.20`, verified clean three times on both. Qwen cleared those
|
||||
two at its then-current `0.07675`; its later n=8 calibration set `0.15625`;
|
||||
- Microsoft InvisMark: `0.125` (below qwen's `0.15`);
|
||||
- Google: `0.50`. A source-fresh CJK carrier remained detected at `0.40` and
|
||||
cleared at `0.50`; that candidate then cleared independent busy-scene,
|
||||
|
||||
+218
-10
@@ -1116,6 +1116,30 @@ both backends explicitly (`tests/test_classify.py` pins the seam). User guide:
|
||||
[photo-classify.md](photo-classify.md). Hub card:
|
||||
[photo-classify-hf/README.md](photo-classify-hf/README.md).
|
||||
|
||||
### Source-pipeline classifier
|
||||
|
||||
[`source_classify.py`](../src/remove_ai_watermarks/source_classify.py) is a
|
||||
separate, lightweight OpenAI/Google/unknown source-export classifier. It fuses
|
||||
the existing 124-dimensional patch descriptor with the 768-dimensional
|
||||
phase-free spectrum in
|
||||
[`_internal/source_spectral.py`](../src/remove_ai_watermarks/_internal/source_spectral.py),
|
||||
then evaluates the frozen 892 -> 128 -> 64 -> 3 GELU MLP in NumPy. Provider
|
||||
labels clear independent calibrated margins; every other row is `unknown`.
|
||||
The NumPy decision treats values within `1e-5` of the stored margin as the
|
||||
inclusive training threshold, absorbing float32 BLAS differences; this matches
|
||||
the frozen training-runtime decisions on all 27,811 cached internal and
|
||||
external feature rows.
|
||||
|
||||
The runtime pins both the immutable Hub revision and the NPZ SHA-256, loads
|
||||
with `allow_pickle=False`, and rejects any field, shape, class, activation, or
|
||||
non-finite-value drift. `RAIW_SOURCE_CLASSIFY_WEIGHTS` is the offline override.
|
||||
The `source-classify` extra adds `pixels` and Hugging Face Hub but no Torch or
|
||||
Transformers. `identify` never imports or runs this path. Its output names a
|
||||
complete source/export pipeline and is not SynthID detection. Unit contracts
|
||||
and the threshold mutation guard live in `tests/test_source_classify.py`;
|
||||
evaluation and attack limits live in
|
||||
[`synthid-classifiers.md`](synthid-classifiers.md).
|
||||
|
||||
The CLIP loader suppresses discarded random parameter initialization with
|
||||
Transformers' `no_init_weights` context before applying the complete frozen
|
||||
state dict. Normal `CLIPModel(config)` construction initialized 427 million
|
||||
@@ -1386,9 +1410,31 @@ shared fill backend.
|
||||
`detect_sparkle_confidence` uses a process-wide shared engine because its loaded
|
||||
assets and template ladder are immutable.
|
||||
|
||||
A decorative sparkle/glint/star glyph in stylized typography -- a bright,
|
||||
near-white four-point accent, the same silhouette family the engine's own
|
||||
template captures -- is a known, unresolved false-positive class. Incident
|
||||
2026-08-27 (raiw-app, Spaces uid 7e01ef8cb99949bba14ab4607a66ae06): a book-cover
|
||||
title's decorative diamond accent inside a neon-glow letterform scored 0.64, just
|
||||
under `_SPARKLE_FP_CONF`, and the removal step painted a permanent smudge over
|
||||
the artwork. Investigation (margin, gradient, saturation, and a prototyped local
|
||||
surrounding-edge-density check) found no signal that separates this class from a
|
||||
genuine weak Gemini sparkle over a noisy/textured photo background -- a
|
||||
synthetic counter-example lands in the same confidence band with the same
|
||||
clean-margin, clean-gradient, near-white-core signature that the
|
||||
`_SPARKLE_KEEP_CONF` rescue exists to protect (see
|
||||
`test_bright_white_blurred_sparkle_rescued`). Tightening any of those signals to
|
||||
exclude the glyph excludes the legitimate case too. Closing this gap needs a
|
||||
real labeled corpus of decorative-glyph occurrences, the way
|
||||
`watermark_registry.GEMINI_SPARKLE_TRUST_CONF` was calibrated against 954 Google-
|
||||
metadata uploads, not another synthetic guess. `_apply_false_positive_gate` logs
|
||||
`DEBUG` on the exact bypass path this incident hit, to build that corpus from
|
||||
production occurrences.
|
||||
|
||||
Regression coverage:
|
||||
|
||||
- [`test_gemini_engine.py`](../tests/test_gemini_engine.py)
|
||||
- [`test_gemini_engine.py`](../tests/test_gemini_engine.py), including
|
||||
`TestDecorativeGlyphFalsePositive` pinning the confirmed false positive and its
|
||||
blocking counter-example.
|
||||
|
||||
### Text mark engines
|
||||
|
||||
@@ -1409,6 +1455,7 @@ be represented by the shared base:
|
||||
- [`baidu_engine.py`](../src/remove_ai_watermarks/baidu_engine.py)
|
||||
- [`liblib_engine.py`](../src/remove_ai_watermarks/liblib_engine.py)
|
||||
- [`microsoft_engine.py`](../src/remove_ai_watermarks/microsoft_engine.py)
|
||||
- [`generic_ai_label_engine.py`](../src/remove_ai_watermarks/generic_ai_label_engine.py)
|
||||
|
||||
Qwen is the one image registry row that currently covers two layout families.
|
||||
The shared text engine handles `千问AI生成`; `QwenEngine` also scans a square
|
||||
@@ -1557,6 +1604,115 @@ two-line mark can be light on dark scenes or dark on light scenes. Its detector
|
||||
and footprint both use the same best-match box. The separate one-line overlay
|
||||
variant is not covered.
|
||||
|
||||
#### Generic bare "AI生成" label
|
||||
|
||||
Registered 2026-09-21 for a confirmed production miss: a bottom-right "AI生成"
|
||||
text watermark with no brand wordmark in front of it was left completely
|
||||
unremoved because none of the brand-tuned detectors above match a bare,
|
||||
brand-less corner stamp -- `identify` found only the TC260 metadata label, with
|
||||
nothing for `suggested_mode=visible` to localize. No customer image bytes exist
|
||||
for that case in any repository; this engine is built from research plus
|
||||
synthetic fixtures.
|
||||
|
||||
Research found the bare wording is not tied to one dominant generator. vivo's
|
||||
own support documentation confirms its Gallery app stamps a bare "AI生成"
|
||||
watermark after AI消除/AI扩图/大片模式 editing (togglable under Album Settings),
|
||||
and independent reporting documents the same bare watermark on Xiaomi Gallery's
|
||||
AI-edit output. Community reporting suggests Samsung's China-locale Galaxy AI
|
||||
tools render "AI生成" too, distinct from the Italian string the `samsung` engine
|
||||
targets. No independent confirmation of OPPO/Huawei's exact wording was found.
|
||||
Because the label is a compliance stamp mandated by GB 45438-2025 rather than a
|
||||
brand mark, several OEM gallery apps plausibly share the same wording with only
|
||||
styling differences -- which is why this is one generic fallback rather than
|
||||
another per-OEM tuned config.
|
||||
|
||||
Unlike a captured brand mark, there is no vendor screenshot to solve an alpha
|
||||
map from: the mark has no logo element, only literal text, so it shares
|
||||
`scripts/render_vendor_silhouettes.py` -- the same capture-less font-rendering
|
||||
pipeline that already produces Qwen/Baidu/Kling/Yuanbao's own text-only
|
||||
silhouettes -- which renders "AI生成" directly (white on black, already the
|
||||
alpha map) and crops to the glyph body, following `visible_alpha_solve.py`'s
|
||||
asset contract (a grayscale PNG under `assets/`) so it plugs into the same
|
||||
`TextMarkConfig`/`TextMarkEngine` machinery as every captured mark.
|
||||
|
||||
Every brand-specific rival mark in this family literally contains the
|
||||
substring "AI生成" in its own glyphs (Doubao, Qwen, Baidu, Kling, Yuanbao --
|
||||
Jimeng's "★ 即梦AI" does not and is excluded), so this generic template is at
|
||||
real risk of firing a second time on an already-attributed mark. At this
|
||||
asset's glyph shape the usual 0.10 `rival_margin` already separates it from
|
||||
Qwen's own binarized glyph blob once the absolute NCC gate clears 0.45, so
|
||||
`generic_ai_label_engine.py` keeps the shared default rather than widening it
|
||||
per mark.
|
||||
|
||||
CALIBRATION IS SYNTHETIC ONLY. `scripts/calibrate_generic_ai_label.py` sweeps
|
||||
the gate and rival margin over 36 positives (the rendered glyph composited onto
|
||||
generated backgrounds at 4 sizes, 3 scale rungs, 3 opacities), 40 generated
|
||||
clean/textured negatives, and 8 rival marks' own committed gallery examples. At
|
||||
gate 0.45 or 0.50 / the default 0.10 margin the sweep reads the same 67% recall,
|
||||
100% precision, zero rival cross-fires; a lower 0.35-0.40 gate reaches the same
|
||||
67% recall but admits one Qwen cross-fire. 0.50 is the shipped choice: it costs
|
||||
nothing on this corpus and clears a faint inpainting residual the library's own
|
||||
removal leaves behind, which otherwise re-fires detection at 0.45. No real
|
||||
"AI生成"-generator screenshot was available to validate either number against
|
||||
actual vendor output -- treat this
|
||||
detector as weaker evidence than the brand-tuned engines above until it is
|
||||
recalibrated on real captures.
|
||||
|
||||
#### OpenArt wordmark
|
||||
|
||||
Registered 2026-09-21 for a confirmed production miss reported through raiw-app:
|
||||
a free `visible`-mode run (2026-09-11, 1528x2712 portrait, Spaces uid
|
||||
`aefb1e07306f4a7280615a8daea01a39`) left a large, prominent OpenArt wordmark
|
||||
(a bowtie/infinity icon followed by "OpenArt" in bold sans-serif, semi-
|
||||
transparent white) completely unremoved -- pixel-identical before and after.
|
||||
`identify` found nothing at all for that case: no C2PA, EXIF AI tag, or China
|
||||
AIGC/TC260 label, so the visible mark is the only known attribution route for
|
||||
a bare OpenArt export. The user rated the result 0 ("Didn't work").
|
||||
|
||||
**This is the only registered mark placed at the FRAME CENTER rather than a
|
||||
corner.** Every other engine in this family anchors to one of the four corners
|
||||
or a bottom/top edge midpoint; `_text_mark_engine.py` gained a new `"cc"`
|
||||
corner literal (both axes centered) specifically for this row, following the
|
||||
same per-vendor-measured-geometry precedent as the existing `"bc"` (LiblibAI)
|
||||
and `"tr"`/`"long"`-basis (Microsoft) additions. A frame-center locate box sits
|
||||
over far more varied image content than a corner box, which is why its NCC
|
||||
gate (0.62) is set well above Doubao/Jimeng's measured 0.45-0.50 rather than
|
||||
matching them.
|
||||
|
||||
OpenArt (openart.ai) requires an OAuth-authenticated account for image
|
||||
generation -- there is no anonymous or API-key access (confirmed via its own
|
||||
help center and MCP/CLI docs, 2026-09-21) -- so this repository's tooling
|
||||
cannot generate a second real capture to survey placement variance by aspect
|
||||
ratio, watermark configurability, or whether a paid tier still embeds C2PA;
|
||||
only the one customer-reported case above is confirmed. OpenArt's own pricing
|
||||
page lists "Watermark-free" as a paid-plan-only feature, consistent with every
|
||||
free-plan output carrying this mark, and its help center separately confirms
|
||||
free-plan VIDEO exports carry a removable watermark toggle at download --
|
||||
independent evidence that a visible free-tier watermark is real product
|
||||
behavior, not one-off.
|
||||
|
||||
Unlike every captured brand mark in this file, there is no committed real
|
||||
capture to solve an alpha map from (see above -- no account access) and no
|
||||
public example image was reconstructed by hand. `scripts/build_openart_alpha.py`
|
||||
instead PROCEDURALLY DRAWS a reconstruction of OpenArt's own public brand mark
|
||||
(the bowtie icon + wordmark shown on openart.ai's own marketing pages) as a
|
||||
grayscale alpha PNG, the same bundled-asset contract every other engine uses.
|
||||
|
||||
**CALIBRATION IS PROVISIONAL, NOT MEASURED.** There is no recall/precision
|
||||
sweep like Doubao's or the generic-label engine's: no positive corpus exists
|
||||
at all beyond the one production case, real or synthetic-composited. The 0.62
|
||||
NCC threshold is a conservative guess, not a measured operating point; a quick
|
||||
false-fire check against 15 random Gaussian-blurred noise images at plausible
|
||||
aspect ratios and sizes found zero false fires, which is reassurance, not
|
||||
calibration. Do not cite this mark in any auto-scan precision claim, and do
|
||||
not lower its threshold, without a real captured corpus under
|
||||
`data/calibration/openart/`.
|
||||
|
||||
`openart_engine.py`'s module docstring carries the same caveats in code. Its
|
||||
tests (`tests/test_openart_engine.py`) mirror `test_doubao_engine.py`'s
|
||||
structure entirely on synthetic composites of the bundled asset -- there is no
|
||||
`TestRealSample` analogue.
|
||||
|
||||
#### Bare-upload pill arm: measured impossible with the current detector (2026-08-28)
|
||||
|
||||
`_keep_pill` never removes a metadata-bare pill, and the question "can a bare arm
|
||||
@@ -1839,8 +1995,8 @@ verification, and geometry-only operators do not need to invent text or script f
|
||||
|
||||
When enabled, the one profile selected before model loading reconstructs the source
|
||||
once through its already loaded VAE, runs the ordinary global and face stages, and
|
||||
calls the shared restoration compositor. Qwen and Chroma implement this donor hook;
|
||||
SDXL does not. The optional Qwen-only fidelity anchor first blends 15% of the VAE
|
||||
calls the shared restoration compositor. Qwen, SDXL, and Chroma implement this donor
|
||||
hook. The optional Qwen-only fidelity anchor first blends 15% of the VAE
|
||||
reconstruction into the clean result; it is off by default because the blend returned
|
||||
detector-visible OpenAI SynthID in the measured poster fixtures. The compositor derives
|
||||
binary source and candidate silhouettes, groups nearby lines, uses LaMa for the initial
|
||||
@@ -1854,8 +2010,9 @@ extend 8% above and 25% below. The anchored-component gate covers clipped leadin
|
||||
flourishes, trailing punctuation, icons, and descenders without walking into
|
||||
disconnected decoration or background texture.
|
||||
|
||||
The stage is deliberately narrower than the engine: it rejects `sdxl-zimage`, tiles,
|
||||
resolution caps, humanize, unsharp, and adaptive polish. Those combinations change
|
||||
The stage is deliberately narrower than the engine: it rejects tiles, resolution
|
||||
caps, humanize, unsharp, and explicitly enabled adaptive polish. An unset polish
|
||||
value resolves to off in this mode, including for SDXL, because those combinations change
|
||||
geometry or final pixels after the verified layer and have no measured oracle result.
|
||||
It remains opt-in because annotations are manual and provider verdicts apply only to
|
||||
the exact tested output hashes, not to the mechanism in general.
|
||||
@@ -1879,6 +2036,22 @@ the `chroma_text_restoration_study.py` harness (kept outside this repository)
|
||||
under the gitignored
|
||||
`out/text-restoration-engine-study/` directory.
|
||||
|
||||
The 2026-09-15 rerun added the SDXL donor implementation and exercised all three
|
||||
profiles on the same three manifests. SDXL improved text-box MAE from 11.319 to
|
||||
7.970 and from 10.704 to 7.657 on the two OpenAI posters, while PSNR rose from
|
||||
25.83 to 29.24 dB and from 26.17 to 29.04 dB. On the tracked Google CJK carrier
|
||||
(`4affd7f27767a445db6abf741355743ba8d95108ad922c9fff045feed8492236`, already
|
||||
recorded as SynthID-positive), SDXL at the calibrated 0.50 strength improved
|
||||
text-box MAE from 10.738 to 8.564 and PSNR from 23.93 to 24.00 dB. The exact
|
||||
restored output
|
||||
`035e4919e1d84129a347006c98f19eb4431eb5772788f6d2097b94a2c6baec84`
|
||||
was submitted to the Google Gemini oracle after provenance stripping preserved
|
||||
its decoded pixels; the sanitized upload hash was
|
||||
`3e4f06274a7b735f7d06863fd54dc8146f60aeb9cc8315f86294180deb660b82`.
|
||||
The oracle returned `not_detected` on 2026-09-16. This
|
||||
certifies that one carrier, manifest, seed, runtime, and output hash; it does not
|
||||
turn the compositor into a provider-wide guarantee.
|
||||
|
||||
A matched stage-isolation check on the 18-face Gemini portrait grid confirms the
|
||||
division of responsibility. The visible-cleaned, metadata-stripped control and the
|
||||
Z-Image face-only output were both SynthID-positive; Qwen global-only and the full
|
||||
@@ -1959,9 +2132,10 @@ first-clean is still 0.10; botanical seeds 1 and 2 stay DETECTED at
|
||||
that rung, which is the margin the 0.17 floor already holds. A later OpenAI
|
||||
holdout invalidated the flat 0.09 conclusion: two carriers first cleared under
|
||||
Chroma at 0.10625 and 0.1375, while qwen-zimage cleared both at its existing
|
||||
0.07675 operating point. Explicit Chroma now uses the spread-derived 0.20,
|
||||
verified clean three times on both holdout carriers, and `auto` routes
|
||||
OpenAI to qwen-zimage.
|
||||
0.07675 operating point. A later source-fresh n=8 Qwen calibration raised its
|
||||
OpenAI floor to 0.15625; that does not change the engine decision. Explicit
|
||||
Chroma uses the spread-derived 0.20, verified clean three times on both holdout
|
||||
carriers, and `auto` routes OpenAI to qwen-zimage.
|
||||
|
||||
A separate content-balanced check on 2026-08-31 tested the exact production
|
||||
OpenAI and Meta floors over 19 prompt-matched image strata per provider. Each
|
||||
@@ -1986,8 +2160,9 @@ is supported by this pass, so `auto` keeps the measured provenance-cohort table.
|
||||
|
||||
[`_internal/sdxl_zimage_pipeline.py`](../src/remove_ai_watermarks/_internal/sdxl_zimage_pipeline.py)
|
||||
runs the same two-stage recipe on an SDXL global pass. `SdxlZImagePipeline`
|
||||
subclasses `TwoStageZImagePipeline` and implements only the global stage
|
||||
(`_load_global`, `_run_global`), so the face stage is inherited rather than copied
|
||||
subclasses `TwoStageZImagePipeline` and implements the global stage
|
||||
(`_load_global`, `_run_global`) plus the deterministic verified-text VAE donor, so
|
||||
the face stage is inherited rather than copied
|
||||
and cannot drift between the profiles; a test asserts the shared methods are the
|
||||
same objects.
|
||||
|
||||
@@ -2254,6 +2429,39 @@ resolution case -- every verdict so far comes from one fixture and one seed.
|
||||
Both ends of the shipped curve now have oracle verdicts, and the shipped curve clears
|
||||
everything it has been tested at:
|
||||
|
||||
A source-fresh GPT Image 2.5 check on 2026-09-15 exercised the complete shipped
|
||||
profile rather than only a strength sweep. One 1024 x 1024 API generation from
|
||||
Flare and one from Sunburst both validated as current OpenAI C2PA, routed to the
|
||||
flat OpenAI strength, and remained OpenAI SynthID-positive after pixel-identical
|
||||
metadata removal. The complete `qwen-zimage` profile at the then-current
|
||||
0.07675, seed 0, cleared
|
||||
both in the official OpenAI Content Provenance API. The Flare output measured
|
||||
37.48 dB PSNR / 0.9329 SSIM and the Sunburst output 37.01 dB / 0.9179 against
|
||||
their sources. Those fidelity numbers describe two neutral studio-photo
|
||||
carriers, not a content-wide quality estimate, and the source-positive/output-negative
|
||||
oracle pairs do not replace the wider OpenAI calibration that set the floor.
|
||||
|
||||
The source-fresh 2026-09-21 expansion supplied that wider calibration. Six
|
||||
independent `gpt-image-2` API generations - three YuNet-positive and three
|
||||
YuNet-zero-face - each retained SynthID after metadata stripping with decoded RGB
|
||||
unchanged. Per-file Qwen ladders, seed 0, were bracketed to 0.00625 against the
|
||||
official API:
|
||||
|
||||
| content | detected through | first clean |
|
||||
|---|---:|---:|
|
||||
| 1 face, studio portrait | 0.0375 | 0.04375 |
|
||||
| 7 faces, group portrait | 0.0875 | 0.09375 |
|
||||
| 3 faces plus text | 0.05625 | 0.0625 |
|
||||
| zero-face architecture | 0.0375 | 0.04375 |
|
||||
| zero-face typography | 0.025 | 0.03125 |
|
||||
| zero-face watercolor | 0.075 | 0.08125 |
|
||||
|
||||
Together with the earlier first-cleans at 0.06225 and 0.0695, the cohort is n=8.
|
||||
The observed extrema are 0.03125 and 0.09375, so the established rule gives
|
||||
`0.09375 + (0.09375 - 0.03125) = 0.15625`. Both face strata span 0.05 internally,
|
||||
while their worst boundaries differ by 0.0125; face presence therefore does not
|
||||
support separate floors.
|
||||
|
||||
| oracle | fixture size | detected at | clean from |
|
||||
|---|---|---|---|
|
||||
| openai.com/verify | 1.57 MP | 0.06 | 0.08 |
|
||||
|
||||
@@ -386,6 +386,16 @@ navigation before each upload, does not retry, and stores the settled page text.
|
||||
Each completed verdict is persisted immediately with its own observation
|
||||
timestamp. A later navigation or upload failure preserves those completed
|
||||
rows and leaves unobserved rows unresolved.
|
||||
ThorData [user-and-password endpoints without a `sessid` rotate the exit on each
|
||||
request](https://doc.thordata.com/doc/proxies/residential-proxies/session-control).
|
||||
That is unsuitable inside one browser context: page resources can
|
||||
arrive through different exits and trigger the provider's bot checks. For a
|
||||
ThorData hostname, the runner derives a non-secret sticky session id from the
|
||||
immutable manifest hash and appends a 90-minute session window when the
|
||||
configured username has no explicit session. The same batch therefore keeps
|
||||
one route while the provider retains it, a new prepared batch gets a distinct
|
||||
route, and an operator-supplied `sessid` remains unchanged. This pins a selected
|
||||
route; it does not retry or fail over after a refusal.
|
||||
For a selected proxy route it enables certificate handling in that isolated
|
||||
Chromium launch and context; direct launches keep Playwright's normal TLS
|
||||
verification. A 2026-09-07 preflight sent one read-only request through each
|
||||
@@ -423,6 +433,17 @@ for the same processed source hash. Keep each route in its own immutable batch;
|
||||
do not splice an isolated-runner failure and a first-party browser or API verdict into
|
||||
one response.
|
||||
|
||||
On 2026-09-16, a rotating ThorData browser batch demonstrated the failure mode:
|
||||
one upload reached OpenAI's generic error, four encountered Cloudflare, and ten
|
||||
became unreachable after the browser context closed. Five fresh connections to
|
||||
the same rotating endpoint had five distinct exits, while five connections with
|
||||
one sticky session had one exit. After session pinning, one direct route and
|
||||
five independently prepared country routes all reached the upload handler with
|
||||
the same prepared test image, but every route returned OpenAI's exact generic
|
||||
error. Record those responses as `indeterminate`. The result distinguishes the
|
||||
fixed session-continuity defect from the remaining anonymous-Web availability
|
||||
problem; further route changes are not a substitute for an API verdict.
|
||||
|
||||
## OpenAI API
|
||||
|
||||
The one automated path calls the hardened metadata-independent adapter already
|
||||
|
||||
+23
-6
@@ -6,12 +6,29 @@ and pipeline modules are intended for maintainers and specialized workflows.
|
||||
Dependency groups are identical for the CLI and Python API. The default install
|
||||
covers metadata extraction, normalization, verdict logic, and stripping.
|
||||
Array/pixel APIs use `pixels`; visible removal uses `visible`; DWT-DCT detection
|
||||
uses `detect`; pixel photo classification uses `classify`; invisible image removal uses `qwen-zimage` and an NVIDIA GPU; and
|
||||
uses `detect`; pixel photo classification uses `classify`; lightweight source-pipeline classification uses `source-classify`; invisible image removal uses `qwen-zimage` and an NVIDIA GPU; and
|
||||
visible video processing uses `video`. The video-pixel SynthID-removal profile is
|
||||
a separate VAE path that still runs on CPU and combines `video` and `diffusion`. Add `heif`
|
||||
independently when path-based pixel APIs must decode HEIC, HEIF, or AVIF. See
|
||||
the complete [feature-extra matrix](installation.md#feature-extras).
|
||||
|
||||
## Classify a source/export pipeline
|
||||
|
||||
Install `remove-ai-watermarks[source-classify]`, then call the explicit API:
|
||||
|
||||
```python
|
||||
import remove_ai_watermarks as raiw
|
||||
|
||||
result = raiw.classify_source("metadata-stripped.png")
|
||||
print(result.label) # "openai" | "google" | "unknown"
|
||||
print(result.reason) # "classified" | "abstained" | "feature_unavailable"
|
||||
```
|
||||
|
||||
This signal classifies a complete image-generation and export pipeline. It is
|
||||
not a SynthID detector and does not establish watermark presence or absence.
|
||||
See [source-pipeline classification](source-classify.md) for the result fields,
|
||||
model pin, evaluation boundary, and offline weights override.
|
||||
|
||||
## Remove visible marks
|
||||
|
||||
Install `remove-ai-watermarks[visible]` before using the visible-removal API.
|
||||
@@ -780,11 +797,11 @@ engine.remove_watermark(
|
||||
|
||||
Install `remove-ai-watermarks[text-restoration]`. The manifest schema and safety
|
||||
constraints are documented in the CLI guide. The engine verifies its decoded RGB
|
||||
hash before loading the diffusion models. Qwen and Chroma reconstruct the donor with
|
||||
the VAE already loaded for the selected profile; no second generative profile runs.
|
||||
The engine rejects SDXL, including `auto` when Google provenance selects it,
|
||||
downscaling, and postprocessing combinations that were not evaluated. Tiling is
|
||||
also rejected because the combined
|
||||
hash before loading the diffusion models. Qwen, SDXL, and Chroma reconstruct the
|
||||
donor with the VAE already loaded for the selected profile; no second generative
|
||||
profile runs. The engine turns an unset `adaptive_polish` off in verified-text mode
|
||||
and rejects explicit postprocessing or downscaling combinations that were not
|
||||
evaluated. Tiling is also rejected because the combined
|
||||
tiled-restoration path has no provider-oracle calibration. `InvisibleOptions` exposes
|
||||
the same field for `remove_all`; after a visible-stage edit, the manifest must be built
|
||||
against the staged pixels rather than the pristine source.
|
||||
|
||||
@@ -92,7 +92,17 @@ alone does not upload new heads -- bump the pinned Hub revision when a new
|
||||
head must reach installed clients. Pin a Hub revision in production so a
|
||||
later freeze cannot silently change installed clients.
|
||||
|
||||
The workflow can also be started manually with an optional version input.
|
||||
The separate `wiltodelta/raiw-source-classify` model is published with
|
||||
`.github/workflows/publish-source-classify-hf.yml`. The workflow downloads the
|
||||
derivative `source-pipeline-mlp.npz` artifact from the declared GitHub model
|
||||
freeze, verifies its SHA-256, and uploads only that file plus the tracked card
|
||||
and aggregate metrics under `docs/source-classify-hf/`. The private corpus,
|
||||
catalog, paths, hashes, embeddings, and feature caches are outside this flow.
|
||||
|
||||
The workflow is manual and requires the model-freeze release tag plus an
|
||||
explicit `publish` confirmation. Keep model-freeze GitHub releases marked as
|
||||
pre-releases so a model artifact cannot replace the latest package release in
|
||||
GitHub's release UI.
|
||||
|
||||
If a distribution job fails because a repository or Hugging Face credential is
|
||||
invalid, rotate the corresponding GitHub secret and rerun the failed job. A
|
||||
@@ -255,13 +265,15 @@ lists it: uv serves its cached pre-release view of the index. Re-run with a
|
||||
fresh cache (`UV_CACHE_DIR=$(mktemp -d)`) before treating it as a release
|
||||
failure.
|
||||
|
||||
The ComfyUI sync run can fail the same way on its own: it resolves the version
|
||||
from the PyPI JSON API (which updates first) but installs the test dependency
|
||||
with pip against the simple index, whose CDN edges lag by minutes. The
|
||||
`distribute.yml` waiter also has a clock: 0.40.0's node suite finished after
|
||||
the 15-minute poll, so the job now waits 30 minutes and then accepts a
|
||||
registry row that already requires this library version. Rerun the failed
|
||||
`comfyui` job only when the registry still has no such row.
|
||||
The ComfyUI sync resolves the version from the PyPI JSON API but installs its
|
||||
test dependency through pip's simple index, whose CDN edges can lag by
|
||||
minutes. `distribute.yml` gates every downstream job on both APIs listing the
|
||||
release, so it cannot dispatch that race again. The ComfyUI waiter itself has
|
||||
a clock: 0.40.0's node suite finished after the 15-minute poll, so it now
|
||||
waits 30 minutes and then accepts a registry row that already requires this
|
||||
library version. For an older distribution that started before the simple
|
||||
index caught up, rerun its failed `comfyui` job only when the registry still
|
||||
has no such row.
|
||||
|
||||
The automatic `verify-release.yml` run after `distribute.yml` checks the
|
||||
Hugging Face Space pin. When `RAIW_HF_SPACE_TOKEN` is set, that pin lands
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
license: apache-2.0
|
||||
library_name: numpy
|
||||
pipeline_tag: image-classification
|
||||
tags:
|
||||
- image-forensics
|
||||
- source-attribution
|
||||
- open-set-classification
|
||||
---
|
||||
|
||||
# RAIW source-pipeline classifier
|
||||
|
||||
This is a compact, abstaining pixel-forensics classifier for three labels:
|
||||
`openai`, `google`, and `unknown`. It is intended for original-export-looking
|
||||
images whose metadata may have been removed without changing the pixels.
|
||||
|
||||
This model is **not a SynthID detector**. It does not decode or verify a
|
||||
watermark payload. Its labels describe complete generation and export
|
||||
pipelines, so renderer, geometry, encoding, post-processing, and watermark
|
||||
state may be confounded. `unknown` does not mean that SynthID is absent, and a
|
||||
provider label does not prove that SynthID is present.
|
||||
|
||||
## Model
|
||||
|
||||
- Input: a 124-dimensional residual/spectral forensic feature vector plus a
|
||||
768-dimensional phase-free spectral vector.
|
||||
- Head: 892 -> 128 -> 64 -> 3 MLP with GELU activations.
|
||||
- Output: `openai`, `google`, or abstain as `unknown` using independent
|
||||
provider margins.
|
||||
- Artifact: `source-pipeline-mlp.npz`
|
||||
- SHA-256: `6430b0738869ff07b6845b7c6149c2a83bc3f3785878082886c241c6f7ca0c4a`
|
||||
|
||||
The artifact contains only learned parameters, normalization constants,
|
||||
labels, and thresholds. It does not contain training images, paths, image
|
||||
hashes, embeddings, or a training catalog.
|
||||
|
||||
## Evaluation
|
||||
|
||||
The operating point was selected on an internal calibration split. The locked
|
||||
internal test and the public `bigdatamark/synthid-research` dataset were not
|
||||
used for architecture, checkpoint, or threshold selection.
|
||||
|
||||
| Evaluation | Class | Recall | Precision |
|
||||
|---|---|---:|---:|
|
||||
| Locked internal test (5,369 images) | OpenAI | 60.4% | 84.9% |
|
||||
| Locked internal test (5,369 images) | Google | 57.6% | 87.9% |
|
||||
| Locked internal test (5,369 images) | Unknown | 98.6% | 93.7% |
|
||||
| Public external originals (900 images) | OpenAI | 68.7% | 100.0% |
|
||||
| Public external originals (900 images) | Google | 81.3% | 98.8% |
|
||||
| Public external originals (900 images) | Unknown/Flux | 99.0% | 66.4% |
|
||||
|
||||
On the public external set, a 95% resize reduced Google recall to 1.0%, and a
|
||||
JPEG quality-75 round trip caused the model to abstain on every image. These
|
||||
are expected abstentions, not evidence that a watermark was removed.
|
||||
|
||||
## Intended use
|
||||
|
||||
Use this model as one probabilistic signal in a broader forensic report or to
|
||||
suggest an appropriate processing path. Do not use it to claim watermark
|
||||
presence, authorship, policy compliance, fraud, or provenance. Applications
|
||||
should expose the abstention and the model's narrow source-pipeline claim.
|
||||
|
||||
Feature extraction and inference are versioned in the
|
||||
`remove-ai-watermarks` Python project rather than in this model repository.
|
||||
Use its explicit `classify_source` API; `identify` does not run this model.
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"claim": "abstaining OpenAI/Google source-pipeline classification; not SynthID detection",
|
||||
"artifact_sha256": "6430b0738869ff07b6845b7c6149c2a83bc3f3785878082886c241c6f7ca0c4a",
|
||||
"classes": ["openai", "google", "unknown"],
|
||||
"locked_internal_test": {
|
||||
"n": 5369,
|
||||
"confusion_actual_by_predicted": [[230, 6, 145], [0, 217, 160], [41, 24, 4546]],
|
||||
"recall": {"openai": 0.6036745407, "google": 0.5755968170, "unknown": 0.9859032748},
|
||||
"precision": {"openai": 0.8487084871, "google": 0.8785425101, "unknown": 0.9371263657}
|
||||
},
|
||||
"public_external_originals": {
|
||||
"dataset": "bigdatamark/synthid-research",
|
||||
"n": 900,
|
||||
"confusion_actual_by_predicted": [[206, 0, 94], [0, 244, 56], [0, 3, 297]],
|
||||
"recall": {"openai": 0.6866666667, "google": 0.8133333333, "unknown": 0.99},
|
||||
"precision": {"openai": 1.0, "google": 0.9878542510, "unknown": 0.6644295302}
|
||||
},
|
||||
"selection": {
|
||||
"minimum_calibration_precision": {"openai": 0.99, "google": 0.95},
|
||||
"maximum_calibration_unknown_false_positive_rate": 0.01
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
# Source-pipeline classification
|
||||
|
||||
`classify_source` is an opt-in, metadata-free pixel classifier for images that
|
||||
still resemble an original provider export. It returns `openai`, `google`, or
|
||||
an explicit `unknown` abstention.
|
||||
|
||||
This is not a SynthID detector. It does not decode or verify a watermark
|
||||
payload, and it must not be used to claim that SynthID is present or absent.
|
||||
The model describes the complete source/export pipeline, where renderer,
|
||||
geometry, encoding, post-processing, and watermark state can be confounded.
|
||||
|
||||
Install the lightweight runtime:
|
||||
|
||||
```bash
|
||||
uv add "remove-ai-watermarks[source-classify]"
|
||||
```
|
||||
|
||||
Call the Python API explicitly:
|
||||
|
||||
```python
|
||||
import remove_ai_watermarks as raiw
|
||||
|
||||
result = raiw.classify_source("metadata-stripped.png")
|
||||
print(result.label) # "openai" | "google" | "unknown"
|
||||
print(result.reason) # "classified" | "abstained" | "feature_unavailable"
|
||||
print(result.margins) # provider margins before thresholding
|
||||
print(result.thresholds) # frozen provider thresholds
|
||||
```
|
||||
|
||||
`feature_unavailable` is returned for images below the required 256x256
|
||||
geometry or for a degenerate feature vector. `abstained` means neither provider
|
||||
cleared its independent calibrated margin. Both are honest `unknown` results.
|
||||
|
||||
The first call downloads the exact pinned revision of
|
||||
[`wiltodelta/raiw-source-classify`](https://huggingface.co/wiltodelta/raiw-source-classify).
|
||||
Set `RAIW_SOURCE_CLASSIFY_WEIGHTS` to the NPZ itself or a directory containing
|
||||
`source-pipeline-mlp.npz` for an offline deployment.
|
||||
|
||||
The model is useful as a routing suggestion for a processing workflow or as one
|
||||
probabilistic signal in a broader forensic report. Do not use it as evidence of
|
||||
authorship, fraud, policy compliance, provenance, or watermark presence. The
|
||||
published model card records the evaluation and robustness limits.
|
||||
@@ -23,6 +23,8 @@ The `visible` command registers these mark keys:
|
||||
| `liblib_pill` | Compact `AI生成` pill | Top left | Auto removal requires LiblibAI metadata or the bottom-center wordmark, plus a flat background under the pill. |
|
||||
| `microsoft` | One Microsoft white AI-badge variant | Top right | Strict uses the visual gate; auto can use Microsoft provenance for the measured [relaxed gate](module-internals.md#visible-mark-removal). Other documented icon, text, and position variants are not covered. |
|
||||
| `jimeng_pill` | `AI生成` pill | Top left | Weak detector with additional product and background gates. |
|
||||
| `generic_ai_label` | Bare `AI生成` text, no vendor wordmark | Bottom right | Brand-less TC260 compliance-stamp fallback (vivo and Xiaomi Gallery AI-edit output confirmed; see [module-internals.md](module-internals.md#generic-bare-ai生成-label)). Font-rendered synthetic asset; **calibrated on a synthetic corpus only**, not a real captured generator screenshot -- treat as weaker evidence than the vendor-tuned detectors above. |
|
||||
| `openart` | `OpenArt` wordmark (bowtie/infinity icon + brand name) | Frame center, not a corner | Confirmed from one customer-reported production case; no known metadata signal, so the visible mark is the only attribution route. **UNCALIBRATED**: procedurally reconstructed asset, no captured corpus -- see [module-internals.md](module-internals.md#openart-wordmark). |
|
||||
|
||||
`--mark auto` evaluates all registered marks and removes every selected match.
|
||||
Known marks are localized to a mask, then the selected fill backend reconstructs
|
||||
@@ -121,6 +123,10 @@ entries enter the watermark inventory, while a fingerprint remains a
|
||||
re-linkability warning, never selects pixel regeneration, and does not suppress
|
||||
independent SynthID evidence.
|
||||
|
||||
The opt-in `classify_source` API is not an `identify` signal. It can suggest an
|
||||
OpenAI or Google source/export pipeline after metadata removal, but it neither
|
||||
detects nor decodes a watermark and never changes the provenance verdict.
|
||||
|
||||
## File and container formats
|
||||
|
||||
Pixel based image commands discover these extensions:
|
||||
@@ -171,7 +177,11 @@ and `scripts/synthid_runtime/`.
|
||||
The tool recognizes presence from supported provenance: Google AI C2PA
|
||||
under Google's all-media watermark policy, and current OpenAI C2PA carrying an
|
||||
explicit `c2pa.watermarked.*` action. Legacy OpenAI C2PA without that action
|
||||
does not assert SynthID.
|
||||
does not assert SynthID. GPT Image 2.5 Flare and Sunburst were verified on
|
||||
2026-09-15: both API outputs carried valid OpenAI C2PA with
|
||||
`c2pa.watermarked.unbound` and exercised the existing model-independent OpenAI
|
||||
route. Automatic routing selected `qwen-zimage` with the OpenAI strength cohort,
|
||||
and both outputs cleared the official OpenAI SynthID check.
|
||||
|
||||
Provider verifiers and provenance APIs are reserved for development-time
|
||||
calibration. They are not exposed through the installed CLI, the top-level
|
||||
|
||||
@@ -62,6 +62,91 @@ support treating augmentation and architecture shift as first-class tests,
|
||||
rather than reading an untransformed source-classification score as watermark
|
||||
evidence.
|
||||
|
||||
### Abstaining fused source classifier, 2026-09-15
|
||||
|
||||
The follow-up experiment combined the existing 124-d forensic descriptor with
|
||||
the 16x16 phase-free spectral grid in a class-balanced ridge head. Its three
|
||||
outputs are `openai`, `google`, and `unknown`; a provider result is emitted only
|
||||
when it beats both the other provider and `unknown` by a margin selected on a
|
||||
separate calibration split. The public implementation is
|
||||
[`scripts/source_pipeline_classifier.py`](../scripts/source_pipeline_classifier.py).
|
||||
It includes training, strict pickle-free model loading, and JSON inference, but
|
||||
no fitted weights: the experimental training freeze contains inputs that are
|
||||
not redistributable. This keeps the code reproducible without publishing a
|
||||
derivative artifact whose data provenance does not permit redistribution.
|
||||
|
||||
Penalty 100 and margin 0.6392407698660646 were selected using only the training
|
||||
and calibration partitions. The 2,669-image locked internal test and Mark's
|
||||
900-image public set were not used for feature, penalty, or threshold selection.
|
||||
Feature-extraction failures count as `unknown` in every result below.
|
||||
|
||||
| Evaluation | Accuracy | OpenAI recall | Google recall | Unknown recall |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| Locked internal, 2,669 images | 84.2% | 60.9% | 48.8% | 95.8% |
|
||||
| Mark original exports, 900 images | 90.1% | 76.3% | 94.3% | 99.7% |
|
||||
| Mark crop 2 px | 89.8% | 76.7% | 94.3% | 98.3% |
|
||||
| Mark resize 95% | 59.7% | 81.7% | 0.0% | 97.3% |
|
||||
| Mark JPEG quality 75 | 33.3% | 0.0% | 0.0% | 100.0% |
|
||||
| Mark blur radius 0.7 | 68.3% | 42.3% | 63.0% | 99.7% |
|
||||
|
||||
The untouched-export result is useful source-attribution evidence, but the
|
||||
transforms decide the claim. Resizing eliminated the Google branch, JPEG made
|
||||
the classifier abstain on every row, and blur materially reduced both provider
|
||||
recalls. The locked unknown false-attribution rate was 4.2%, versus 0.9% on the
|
||||
calibration split. This is therefore an inspectable research baseline, not a
|
||||
runtime detector or a product watermark verdict.
|
||||
|
||||
Training requires hash-disjoint `train` and `calibration` directories, each
|
||||
with `openai`, `google`, and `unknown` subdirectories:
|
||||
|
||||
```bash
|
||||
uv run python scripts/source_pipeline_classifier.py train \
|
||||
/path/to/train /path/to/calibration --model-out .local-eval/source-pipeline.npz
|
||||
|
||||
uv run python scripts/source_pipeline_classifier.py predict \
|
||||
.local-eval/source-pipeline.npz image.png
|
||||
```
|
||||
|
||||
A stronger public release needs a publication-cleared multi-era training set
|
||||
and a causal marked/unmarked contrast. Mark's same-prompt triples remain an
|
||||
external source/export test, not evidence that the fitted features read the
|
||||
SynthID payload.
|
||||
|
||||
### Full-corpus source-pipeline model, 2026-09-16
|
||||
|
||||
The next experiment trained a compact nonlinear head on the complete available
|
||||
private corpus, including target-provider exports, photographs, and foreign
|
||||
generator negatives. The corpus itself is not published. The released
|
||||
`source-pipeline-mlp.npz` is a derivative artifact containing only learned
|
||||
parameters, normalization constants, labels, and abstention thresholds; it
|
||||
contains no images, paths, image hashes, embeddings, or training catalog.
|
||||
|
||||
The model joins the 124-dimensional forensic descriptor with the
|
||||
768-dimensional phase-free spectral descriptor and applies a
|
||||
892 -> 128 -> 64 -> 3 GELU MLP. Checkpoint and thresholds were selected on a
|
||||
separate calibration partition. The 5,369-image hash-disjoint locked test and
|
||||
Mark's public 900-image originals were not used for architecture, checkpoint,
|
||||
or threshold selection.
|
||||
|
||||
| Evaluation | Class | Recall | Precision |
|
||||
| --- | --- | ---: | ---: |
|
||||
| Locked internal test (5,369 images) | OpenAI | 60.4% | 84.9% |
|
||||
| Locked internal test (5,369 images) | Google | 57.6% | 87.9% |
|
||||
| Locked internal test (5,369 images) | Unknown | 98.6% | 93.7% |
|
||||
| Mark public originals (900 images) | OpenAI | 68.7% | 100.0% |
|
||||
| Mark public originals (900 images) | Google | 81.3% | 98.8% |
|
||||
| Mark public originals (900 images) | Unknown/FLUX | 99.0% | 66.4% |
|
||||
|
||||
A 95% resize reduced Google recall on the public set to 1%, and a JPEG
|
||||
quality-75 round trip made every row abstain. This behavior matches the narrow
|
||||
claim: the model classifies original-looking source/export pipelines after
|
||||
metadata removal. It neither decodes nor detects SynthID, and its `unknown`
|
||||
label does not mean that SynthID is absent. The model card and aggregate
|
||||
metrics are tracked under
|
||||
[`docs/source-classify-hf/`](source-classify-hf/README.md). Runtime integration
|
||||
is provided by the explicit `classify_source` library API and never feeds the
|
||||
`identify` watermark verdict.
|
||||
|
||||
Krawetz's Gemini-chat TPR critique is a verifier-quality claim, not a
|
||||
feature we can ship. [Lead Stories, 2026-07](https://leadstories.com/analysis/2026/07/google-gemini-synthid-detector-confuses-results-within-same-chat.html)
|
||||
documented Gemini repeating the first file's SynthID verdict inside a
|
||||
|
||||
@@ -775,6 +775,23 @@ SynthID. Current OpenAI manifests distinguish the watermarked output with the
|
||||
signed `c2pa.watermarked.unbound` action. The detector requires that action, so
|
||||
old OpenAI C2PA remains an origin signal without becoming a SynthID claim.
|
||||
|
||||
**GPT Image 2.5 compatibility, verified 2026-09-15.** One fresh low-quality
|
||||
1024 x 1024 API generation from each released model, `gpt-image-2.5-flare` and
|
||||
`gpt-image-2.5-sunburst`, carried a valid bound OpenAI C2PA claim generated by
|
||||
`OpenAI Media Service API`, with `trainedAlgorithmicMedia` and the action set
|
||||
`created, converted, watermarked.unbound`. `identify` reported high-confidence
|
||||
OpenAI provenance plus SynthID, `has_invisible_target` returned true, and the
|
||||
automatic strength cohort resolved to OpenAI for both files. After C2PA was
|
||||
removed without changing decoded pixels, the official OpenAI Content Provenance
|
||||
API still returned SynthID `detected` for both sources. The complete then-shipped
|
||||
`qwen-zimage` profile at strength 0.07675 and seed 0 then produced two outputs
|
||||
for which the same metadata-independent API returned SynthID `not_detected`.
|
||||
This is one controlled source per model and proves the current routing and
|
||||
then-current operating point on those two sources; it is not a provider-wide
|
||||
removal rate. A six-source `gpt-image-2` expansion on 2026-09-21 subsequently
|
||||
expanded the Qwen first-clean range to 0.03125-0.09375 and raised the flat OpenAI
|
||||
floor to 0.15625 by the established full-spread rule.
|
||||
|
||||
### 4.3 Other vendors
|
||||
|
||||
- **Kakao** (South Korea): SynthID adopter as of May 2026 (Google announcement)
|
||||
|
||||
+9
-2
@@ -4,7 +4,7 @@
|
||||
# on it, including the ComfyUI node package. The console script below carries
|
||||
# the same weight, since users have it on PATH.
|
||||
name = "remove-ai-watermarks"
|
||||
version = "0.40.4"
|
||||
version = "0.42.0"
|
||||
description = "AI watermark remover for visible, invisible, and provenance marks in images and video"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11,<3.15"
|
||||
@@ -160,6 +160,13 @@ classify-onnx = [
|
||||
"remove-ai-watermarks[classify]",
|
||||
"onnxruntime>=1.24.0",
|
||||
]
|
||||
# Compact OpenAI/Google/unknown source-export classifier. This is intentionally
|
||||
# separate from the heavier photo AI-versus-camera classifier and is not a
|
||||
# SynthID detector. Weights download on first use.
|
||||
source-classify = [
|
||||
"remove-ai-watermarks[pixels]",
|
||||
"huggingface-hub>=0.20.0",
|
||||
]
|
||||
# Universal region eraser backend -- big-LaMa via onnxruntime (Carve/LaMa-ONNX,
|
||||
# Apache-2.0). CPU, no torch. Model (~200 MB) is downloaded on first use and
|
||||
# cached by huggingface_hub; it is never bundled in this repo. The default cv2
|
||||
@@ -205,7 +212,7 @@ dev = [
|
||||
]
|
||||
# ``qwen-zimage`` already pulls ``diffusion``; naming both would suggest diffusion is
|
||||
# independently sufficient for a removal, which it is not.
|
||||
all = ["remove-ai-watermarks[video,heif,detect,trustmark,text-restoration,migan,classify]"]
|
||||
all = ["remove-ai-watermarks[video,heif,detect,trustmark,text-restoration,migan,classify,source-classify]"]
|
||||
|
||||
[project.scripts]
|
||||
remove-ai-watermarks = "remove_ai_watermarks.cli:main"
|
||||
|
||||
@@ -51,9 +51,11 @@ explicitly names a tracked canonical result.
|
||||
| `visible_sheets.py` | Build blinded contact sheets for relaxation candidates. |
|
||||
| `watermark_benchmark_cohort.py` | Build the deterministic synthetic DWT-DCT and TrustMark cohort consumed by `watermark_benchmark.py`. |
|
||||
| `spectral_pipeline_probe.py` | Measure a prompt-grouped, phase-free spectral source-pipeline hypothesis without presenting it as SynthID detection. |
|
||||
| `source_pipeline_classifier.py` | Train or run an abstaining OpenAI/Google/unknown source-export classifier; research-only and not SynthID detection. |
|
||||
| `watermark_benchmark_real_cohort.py` | Build a publication-cleared, provider-paired and content-stratified real-image cohort. |
|
||||
| `retrain_photo_classify.py` | CPU-retrain the 2026-08-31 photo heads from a sha256-keyed cache pack, no images. |
|
||||
| `publish_photo_classify_hf.py` | Upload the photo-classify card and freeze weights to Hugging Face `wiltodelta/raiw-photo-classify`. Manual; the Action `publish-photo-classify-hf.yml` is the write-token path. |
|
||||
| `publish_source_classify_hf.py` | Upload the derivative source-pipeline model and its public card to Hugging Face `wiltodelta/raiw-source-classify`. Manual; the Action `publish-source-classify-hf.yml` is the write-token path. |
|
||||
| `verify_engine_selection_fixtures.py` | Verify hashes, dimensions, and prompt pairing in the tracked auto-engine content matrix. |
|
||||
|
||||
## Research and diagnostic prototypes
|
||||
@@ -86,5 +88,7 @@ running the sync command without `--check` is the explicit snapshot update.
|
||||
|
||||
`_plain_console.py` provides plain-text fallbacks for Rich output, and
|
||||
`_text_eval.py` contains normalization helpers shared by text-evaluation scripts.
|
||||
`hf_publish.py` owns the common authenticated, allowlisted Hugging Face model
|
||||
repository upload used by the two model publishers.
|
||||
`engine_selection_manifest.py` owns the content-matrix schema and validation
|
||||
shared by its integrity check and the real-image benchmark builder.
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Build the PROVISIONAL synthetic OpenArt alpha asset.
|
||||
|
||||
Unlike ``visible_alpha_solve.py``, this does NOT recover an alpha map from a
|
||||
controlled capture: OpenArt (openart.ai) requires an account to generate
|
||||
images (no anonymous or API-key access, confirmed 2026-09-21 via its own
|
||||
help center and MCP docs), and creating an account is outside what this
|
||||
project's tooling is allowed to do on its own. No committed capture exists
|
||||
under ``data/calibration/openart/``.
|
||||
|
||||
This script instead PROCEDURALLY DRAWS a reconstruction of OpenArt's own
|
||||
brand mark (the bowtie/infinity icon followed by the "OpenArt" wordmark, as
|
||||
shown on openart.ai's own marketing pages) as a semi-transparent white
|
||||
overlay, matching the general rendering style (near-white, low-saturation,
|
||||
anti-aliased) that every other bundled text-mark asset in this module was
|
||||
solved from. It is a reconstruction from the public logo, not a recovered
|
||||
watermark alpha map, and ``openart_engine.py`` documents that provenance
|
||||
gap explicitly. Replace this asset (and re-run the engine's calibration)
|
||||
the moment a real captured OpenArt export is available under
|
||||
``data/calibration/openart/``.
|
||||
|
||||
Usage::
|
||||
|
||||
uv run python scripts/build_openart_alpha.py
|
||||
"""
|
||||
|
||||
# cv2/numpy boundary: third-party libs ship no usable element types; relax the
|
||||
# unknown-type rules for this file only (mirrors visible_alpha_solve.py).
|
||||
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownParameterType=false, reportMissingTypeArgument=false, reportMissingTypeStubs=false, reportMissingImports=false, reportArgumentType=false, reportAssignmentType=false, reportReturnType=false, reportCallIssue=false, reportIndexIssue=false, reportOperatorIssue=false
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[1]
|
||||
_ASSET = _ROOT / "src" / "remove_ai_watermarks" / "assets" / "openart_alpha.png"
|
||||
|
||||
# Canvas sized to comfortably fit "OpenArt" set bold at this height plus the
|
||||
# icon and inter-glyph gap, at a resolution close to the other bundled
|
||||
# assets (doubao_alpha.png is 335x83).
|
||||
_W, _H = 360, 90
|
||||
_ALPHA_PEAK = 173 # matches doubao_alpha.png's measured peak (~68% opacity)
|
||||
|
||||
|
||||
def _draw_infinity_icon(canvas: np.ndarray, cx: int, cy: int, size: int) -> None:
|
||||
"""Draw the bowtie/infinity glyph left of the wordmark (two mirrored lobes)."""
|
||||
half = size // 2
|
||||
# Two overlapping filled triangles meeting at the center, echoing the
|
||||
# angular "infinity" mark on openart.ai's own site header.
|
||||
left_lobe = np.array(
|
||||
[[cx - size, cy - half], [cx - size, cy + half], [cx, cy]],
|
||||
dtype=np.int32,
|
||||
)
|
||||
right_lobe = np.array(
|
||||
[[cx + size, cy - half], [cx + size, cy + half], [cx, cy]],
|
||||
dtype=np.int32,
|
||||
)
|
||||
cv2.fillConvexPoly(canvas, left_lobe, (_ALPHA_PEAK,), lineType=cv2.LINE_AA)
|
||||
cv2.fillConvexPoly(canvas, right_lobe, (_ALPHA_PEAK,), lineType=cv2.LINE_AA)
|
||||
|
||||
|
||||
def build() -> np.ndarray:
|
||||
canvas = np.zeros((_H, _W), np.uint8)
|
||||
icon_cx, icon_cy, icon_size = 34, _H // 2, 22
|
||||
_draw_infinity_icon(canvas, icon_cx, icon_cy, icon_size)
|
||||
|
||||
text_x = icon_cx + icon_size + 18
|
||||
cv2.putText(
|
||||
canvas,
|
||||
"OpenArt",
|
||||
(text_x, _H // 2 + 16),
|
||||
cv2.FONT_HERSHEY_DUPLEX,
|
||||
1.3,
|
||||
(_ALPHA_PEAK,),
|
||||
thickness=2,
|
||||
lineType=cv2.LINE_AA,
|
||||
)
|
||||
return canvas
|
||||
|
||||
|
||||
def main() -> None:
|
||||
canvas = build()
|
||||
_ASSET.parent.mkdir(parents=True, exist_ok=True)
|
||||
cv2.imwrite(str(_ASSET), canvas)
|
||||
print(f"Wrote {_ASSET} ({canvas.shape[1]}x{canvas.shape[0]}, peak={int(canvas.max())})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Synthetic recall/precision sweep for generic_ai_label_engine's detection gate.
|
||||
|
||||
SYNTHETIC-ONLY CALIBRATION. There is no captured real "AI生成"-generator screenshot
|
||||
in this repository to calibrate against (see the confirmed bug report this engine was
|
||||
built for: the customer's original image bytes do not exist in any repo). This script
|
||||
instead:
|
||||
|
||||
* builds POSITIVES by compositing the engine's own rendered glyph asset
|
||||
(``assets/generic_ai_label_alpha.png``) onto ``render_visible_examples.py``'s
|
||||
generated base photos, at several sizes / scale rungs / opacities;
|
||||
* builds CLEAN negatives from the same generated base photos (some with unrelated
|
||||
Latin/digit text stamped in the corner, the classical false-positive class for
|
||||
this detector family);
|
||||
* builds RIVAL negatives from the six other bottom-right/br CJK text marks' own
|
||||
committed gallery examples (``data/fixtures/visible/<key>/example.png``) -- every
|
||||
one of Doubao/Qwen/Baidu/Kling/Yuanbao's marks contains the literal substring
|
||||
"AI生成" (Jimeng's "★ 即梦AI" does not and is excluded), so a generic "AI生成"
|
||||
template is at real risk of firing a SECOND time on an already brand-attributed
|
||||
mark unless the rival margin separates them.
|
||||
|
||||
Prints a gate x rival-margin sweep. The value currently shipped in
|
||||
``generic_ai_label_engine.py`` (``DETECT_NCC_THRESHOLD = 0.50``, at the shared default
|
||||
0.10 rival margin) was chosen from this sweep's 100%-precision / best-recall corner,
|
||||
with headroom above 0.45 left for the library's own removal residual (see that
|
||||
constant's comment); re-run and re-choose after any change to the rendered asset or
|
||||
the shared tophat gating constants.
|
||||
|
||||
Usage::
|
||||
|
||||
uv run python scripts/calibrate_generic_ai_label.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(_ROOT / "src"))
|
||||
sys.path.insert(0, str(_ROOT / "scripts"))
|
||||
|
||||
import render_visible_examples as rve # noqa: E402
|
||||
|
||||
from remove_ai_watermarks._text_mark_engine import TextMarkEngine # noqa: E402
|
||||
from remove_ai_watermarks.generic_ai_label_engine import _CONFIG # noqa: E402
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_GALLERY = _ROOT / "data" / "fixtures" / "visible"
|
||||
_RIVAL_KEYS = ["doubao", "jimeng", "qwen", "baidu", "kling", "yuanbao", "samsung", "runninghub"]
|
||||
_SIZES = [(1536, 1152), (2048, 2048), (1080, 1920), (2048, 1536)]
|
||||
_SCALE_RUNGS = (0.85, 1.0, 1.15)
|
||||
_OPACITIES = (0.65, 0.8, 1.0)
|
||||
_N_CLEAN = 40
|
||||
|
||||
|
||||
def _build_positives() -> list[np.ndarray]:
|
||||
"""The rendered glyph composited at varied size/scale/opacity."""
|
||||
positives: list[np.ndarray] = []
|
||||
for i, (w, h) in enumerate(_SIZES):
|
||||
base = rve.base_photo(w, h, seed=100 + i)
|
||||
for scale_mult in _SCALE_RUNGS:
|
||||
for alpha_mult in _OPACITIES:
|
||||
out = rve._stamp_text_mark("generic_ai_label", base, size_mult=scale_mult, alpha_mult=alpha_mult)
|
||||
if out is not None:
|
||||
positives.append(out[0])
|
||||
return positives
|
||||
|
||||
|
||||
def _build_clean_negatives(rng: np.random.Generator) -> list[np.ndarray]:
|
||||
"""Clean generated photos, a third with unrelated corner text (Latin/digits)."""
|
||||
negatives: list[np.ndarray] = []
|
||||
for i in range(_N_CLEAN):
|
||||
w, h = int(rng.uniform(900, 2200)), int(rng.uniform(900, 2200))
|
||||
seed = 500 + i
|
||||
img = rve.base_photo(w, h, seed=seed)
|
||||
if i % 3 == 0:
|
||||
r = np.random.default_rng(seed)
|
||||
txt = f"{r.integers(1000, 9999)} PM"
|
||||
x, y = int(w * 0.75), int(h * 0.90)
|
||||
cv2.putText(img, txt, (x, y), cv2.FONT_HERSHEY_SIMPLEX, w / 1400, (235, 235, 235), 2, cv2.LINE_AA)
|
||||
negatives.append(img)
|
||||
return negatives
|
||||
|
||||
|
||||
def _load_rival_examples() -> dict[str, np.ndarray]:
|
||||
rivals: dict[str, np.ndarray] = {}
|
||||
for key in _RIVAL_KEYS:
|
||||
img = cv2.imread(str(_GALLERY / key / "example.png"))
|
||||
if img is not None:
|
||||
rivals[key] = img
|
||||
return rivals
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
rng = np.random.default_rng(42)
|
||||
positives = _build_positives()
|
||||
clean_negatives = _build_clean_negatives(rng)
|
||||
rival_examples = _load_rival_examples()
|
||||
|
||||
log.info(
|
||||
"corpus: %d synthetic positives, %d clean negatives, %d rival-mark examples",
|
||||
len(positives),
|
||||
len(clean_negatives),
|
||||
len(rival_examples),
|
||||
)
|
||||
log.info("%6s %8s %10s %10s %4s %9s %9s", "gate", "margin", "recall", "precision", "TP", "clean_fp", "rival_fp")
|
||||
for gate in (0.35, 0.40, 0.45, 0.50, 0.55, 0.60, 0.65):
|
||||
for margin in (0.10, 0.25):
|
||||
cfg = replace(_CONFIG, detect_ncc_threshold=gate, rival_margin=margin)
|
||||
eng = TextMarkEngine(cfg)
|
||||
tp = sum(1 for im in positives if eng.detect(im).detected)
|
||||
clean_fp = sum(1 for im in clean_negatives if eng.detect(im).detected)
|
||||
rival_fp = [k for k, im in rival_examples.items() if eng.detect(im).detected]
|
||||
fp = clean_fp + len(rival_fp)
|
||||
recall = tp / len(positives) if positives else float("nan")
|
||||
precision = tp / (tp + fp) if (tp + fp) else float("nan")
|
||||
log.info(
|
||||
"%6.2f %8.2f %9.1f%% %9.1f%% %4d %9d %9d %s",
|
||||
gate,
|
||||
margin,
|
||||
recall * 100,
|
||||
precision * 100,
|
||||
tp,
|
||||
clean_fp,
|
||||
len(rival_fp),
|
||||
rival_fp,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Shared Hugging Face model-repository publication helper."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def publish_model_folder(
|
||||
stage: Path,
|
||||
*,
|
||||
repo_id: str,
|
||||
token: str,
|
||||
message: str,
|
||||
allow_patterns: Iterable[str],
|
||||
) -> str:
|
||||
"""Upload an explicit file allowlist and return the Hub revision."""
|
||||
from huggingface_hub import HfApi, create_repo
|
||||
|
||||
create_repo(repo_id, repo_type="model", exist_ok=True, private=False, token=token)
|
||||
commit = HfApi(token=token).upload_folder(
|
||||
folder_path=str(stage),
|
||||
repo_id=repo_id,
|
||||
repo_type="model",
|
||||
commit_message=message,
|
||||
allow_patterns=list(allow_patterns),
|
||||
)
|
||||
return getattr(commit, "oid", "") or ""
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from time import monotonic
|
||||
@@ -125,8 +126,10 @@ def parse_provider_result(surface: str, page_text: str) -> WebVerdict:
|
||||
def proxy_settings(
|
||||
slot: oracles.ExecutionSlot,
|
||||
environ: Mapping[str, str] = os.environ,
|
||||
*,
|
||||
sticky_session_id: str | None = None,
|
||||
) -> ProxySettings | None:
|
||||
"""Resolve a secret proxy URL without putting it in a manifest or log."""
|
||||
"""Resolve a secret proxy URL and pin rotating ThorData routes per batch."""
|
||||
variable = slot.get("proxy_url_env")
|
||||
if variable is None:
|
||||
return None
|
||||
@@ -142,7 +145,13 @@ def proxy_settings(
|
||||
server = f"{server}:{parsed.port}"
|
||||
settings: ProxySettings = {"server": server}
|
||||
if parsed.username is not None:
|
||||
settings["username"] = unquote(parsed.username)
|
||||
username = unquote(parsed.username)
|
||||
is_thordata = parsed.hostname.endswith((".thordata.net", ".thordata.online"))
|
||||
if is_thordata and sticky_session_id is not None and "-sessid-" not in username.casefold():
|
||||
if re.fullmatch(r"[A-Za-z0-9]{1,32}", sticky_session_id) is None:
|
||||
raise ValueError("sticky_session_id must be 1-32 ASCII letters or digits")
|
||||
username = f"{username}-sessid-{sticky_session_id}-sesstime-90"
|
||||
settings["username"] = username
|
||||
if parsed.password is not None:
|
||||
settings["password"] = unquote(parsed.password)
|
||||
return settings
|
||||
@@ -287,7 +296,11 @@ def run_web_batch(
|
||||
if slot is None:
|
||||
raise ValueError("Playwright batches require an explicit execution slot")
|
||||
secret_values = dict(environ) if env_file is None else oracles.secret_environment(env_file, environ=environ)
|
||||
proxy = proxy_settings(slot, secret_values)
|
||||
proxy = proxy_settings(
|
||||
slot,
|
||||
secret_values,
|
||||
sticky_session_id=f"oracle{results['manifest_sha256'][:16]}",
|
||||
)
|
||||
pending = [
|
||||
(manifest_row, result_row)
|
||||
for manifest_row, result_row in zip(manifest["rows"], results["rows"], strict=True)
|
||||
|
||||
@@ -24,6 +24,8 @@ import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from hf_publish import publish_model_folder
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
HUB_REPO = "wiltodelta/raiw-photo-classify"
|
||||
@@ -92,18 +94,13 @@ def stage_weights(dest: Path, src: Path) -> None:
|
||||
|
||||
|
||||
def publish(stage: Path, *, token: str, message: str) -> str:
|
||||
from huggingface_hub import HfApi, create_repo
|
||||
|
||||
create_repo(HUB_REPO, repo_type="model", exist_ok=True, private=False, token=token)
|
||||
api = HfApi(token=token)
|
||||
commit = api.upload_folder(
|
||||
folder_path=str(stage),
|
||||
return publish_model_folder(
|
||||
stage,
|
||||
repo_id=HUB_REPO,
|
||||
repo_type="model",
|
||||
commit_message=message,
|
||||
token=token,
|
||||
message=message,
|
||||
allow_patterns=["README.md", "operating-point.json", *WEIGHT_FILES, GATE_FILE, GATE_STABLE_FILE],
|
||||
)
|
||||
return getattr(commit, "oid", "") or ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Publish the source-pipeline model to Hugging Face.
|
||||
|
||||
The derivative weight file is downloaded by the GitHub Action from a model
|
||||
freeze release. The private corpus, feature caches, and training catalog never
|
||||
enter this repository or the Hub staging directory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from hf_publish import publish_model_folder
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
HUB_REPO = "wiltodelta/raiw-source-classify"
|
||||
MODEL_FILE = "source-pipeline-mlp.npz"
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
CARD_DIR = REPO_ROOT / "docs" / "source-classify-hf"
|
||||
PUBLIC_FILES = ("README.md", "metrics.json", MODEL_FILE)
|
||||
|
||||
|
||||
def stage_release(dest: Path, src: Path) -> None:
|
||||
"""Stage the two tracked documents and one derivative model artifact."""
|
||||
sources = {
|
||||
"README.md": CARD_DIR / "README.md",
|
||||
"metrics.json": CARD_DIR / "metrics.json",
|
||||
MODEL_FILE: src.expanduser().resolve() / MODEL_FILE,
|
||||
}
|
||||
missing = [str(path) for path in sources.values() if not path.is_file()]
|
||||
if missing:
|
||||
raise SystemExit(f"missing publication files: {', '.join(missing)}")
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
for name, path in sources.items():
|
||||
shutil.copy2(path, dest / name)
|
||||
|
||||
|
||||
def publish(stage: Path, *, token: str, message: str) -> str:
|
||||
"""Upload the exact staged allowlist and return the Hub revision."""
|
||||
return publish_model_folder(
|
||||
stage,
|
||||
repo_id=HUB_REPO,
|
||||
token=token,
|
||||
message=message,
|
||||
allow_patterns=PUBLIC_FILES,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--src", type=Path, required=True)
|
||||
parser.add_argument("--message", default="Publish source-pipeline classifier")
|
||||
args = parser.parse_args()
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
token = os.environ.get("HF_TOKEN", "").strip()
|
||||
if not token:
|
||||
raise SystemExit("HF_TOKEN is not set (write role required)")
|
||||
with tempfile.TemporaryDirectory(prefix="raiw-source-hf-") as tmp:
|
||||
stage = Path(tmp)
|
||||
stage_release(stage, args.src)
|
||||
revision = publish(stage, token=token, message=args.message)
|
||||
log.info("published %s revision=%s", HUB_REPO, revision or "(unknown)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -32,6 +32,10 @@ _CANDIDATE_ASSETS = _ROOT / "scripts" / "assets" / "visible-mark-candidates"
|
||||
_FONT = "/System/Library/Fonts/STHeiti Medium.ttc"
|
||||
|
||||
MARKS = {
|
||||
# Brand-less TC260 fallback (no vendor wordmark exists to tune against -- see
|
||||
# generic_ai_label_engine.py's module docstring for the vivo/Xiaomi/Samsung
|
||||
# evidence). Bare glyph, no prefix, unlike every other row below.
|
||||
"generic_ai_label_alpha.png": "AI生成",
|
||||
"qwen_alpha.png": "千问AI生成",
|
||||
"xinghui_alpha.png": "星绘AI生成",
|
||||
# Yuanbao's stamp is a TWO-LINE block (元宝 over AI生成), left-aligned, tightly
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
"""Train or run an abstaining source/export-pipeline classifier.
|
||||
|
||||
This research tool combines the library's 124-d forensic descriptor with the
|
||||
phase-free spectral descriptor from ``spectral_pipeline_probe.py``. Its labels
|
||||
describe complete generation and export pipelines. It does not decode SynthID
|
||||
or establish that a watermark is present.
|
||||
|
||||
Expected training layout::
|
||||
|
||||
train/openai/*.png
|
||||
train/google/*.png
|
||||
train/unknown/*.png
|
||||
calibration/openai/*.png
|
||||
calibration/google/*.png
|
||||
calibration/unknown/*.png
|
||||
|
||||
Examples::
|
||||
|
||||
uv run python scripts/source_pipeline_classifier.py train \
|
||||
train calibration --model-out model.npz
|
||||
uv run python scripts/source_pipeline_classifier.py predict \
|
||||
model.npz image.png
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, replace
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import click
|
||||
import numpy as np
|
||||
import spectral_pipeline_probe as spectral
|
||||
from numpy.typing import NDArray
|
||||
from PIL import Image
|
||||
from watermark_benchmark import sha256_file
|
||||
|
||||
from remove_ai_watermarks._internal.forensic_124d import FEATURE_WIDTH, PATCH, image_features
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
CLASSES = ("openai", "google", "unknown")
|
||||
UNKNOWN_INDEX = CLASSES.index("unknown")
|
||||
DEFAULT_GRID = 16
|
||||
DEFAULT_PENALTY = 100.0
|
||||
DEFAULT_UNKNOWN_FPR = 0.01
|
||||
CLAIM = "source/export pipeline classification; not SynthID detection"
|
||||
|
||||
FloatArray = NDArray[np.float64]
|
||||
IntArray = NDArray[np.int64]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RidgeModel:
|
||||
"""One standardized three-class ridge head with an abstention margin."""
|
||||
|
||||
mean: FloatArray
|
||||
scale: FloatArray
|
||||
weights: FloatArray
|
||||
margin: float
|
||||
grid: int
|
||||
penalty: float
|
||||
|
||||
|
||||
def discover_labeled_paths(root: Path) -> tuple[list[Path], IntArray, list[str]]:
|
||||
"""Find images under the three fixed label directories."""
|
||||
paths: list[Path] = []
|
||||
labels: list[int] = []
|
||||
hashes: list[str] = []
|
||||
for class_index, class_name in enumerate(CLASSES):
|
||||
directory = root / class_name
|
||||
if not directory.is_dir():
|
||||
raise ValueError(f"class directory does not exist: {directory}")
|
||||
class_paths = sorted(
|
||||
path for path in directory.rglob("*") if path.is_file() and path.suffix.lower() in spectral.IMAGE_SUFFIXES
|
||||
)
|
||||
if not class_paths:
|
||||
raise ValueError(f"class directory has no supported images: {directory}")
|
||||
for path in class_paths:
|
||||
paths.append(path)
|
||||
labels.append(class_index)
|
||||
hashes.append(sha256_file(path))
|
||||
if len(hashes) != len(set(hashes)):
|
||||
raise ValueError(f"duplicate image bytes found inside {root}")
|
||||
return paths, np.asarray(labels, dtype=np.int64), hashes
|
||||
|
||||
|
||||
def _feature_from_path(path: Path, geometry: spectral.SpectralGeometry) -> FloatArray | None:
|
||||
"""Return the fused representation for one path and prepared geometry."""
|
||||
with Image.open(path) as source:
|
||||
image = source.convert("RGB")
|
||||
forensic = image_features(np.asarray(image, dtype=np.uint8))
|
||||
if forensic is None:
|
||||
return None
|
||||
phase_free = spectral.feature_from_image(image, geometry, "original")
|
||||
return np.concatenate(
|
||||
(
|
||||
np.asarray(forensic, dtype=np.float64),
|
||||
np.asarray(phase_free, dtype=np.float64),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def feature_from_path(path: Path, grid: int) -> FloatArray | None:
|
||||
"""Return the fused forensic and phase-free spectral representation."""
|
||||
return _feature_from_path(path, spectral.make_geometry(size=PATCH, grid=grid))
|
||||
|
||||
|
||||
def extract_features(paths: list[Path], labels: IntArray, grid: int) -> tuple[FloatArray, IntArray, list[Path]]:
|
||||
"""Extract valid rows and report files whose forensic feature abstains."""
|
||||
geometry = spectral.make_geometry(size=PATCH, grid=grid)
|
||||
features: list[FloatArray] = []
|
||||
kept_labels: list[int] = []
|
||||
skipped: list[Path] = []
|
||||
for index, (path, label) in enumerate(zip(paths, labels, strict=True), start=1):
|
||||
try:
|
||||
feature = _feature_from_path(path, geometry)
|
||||
except (OSError, ValueError) as error:
|
||||
log.warning("Could not extract %s: %s", path, error)
|
||||
feature = None
|
||||
if feature is None:
|
||||
skipped.append(path)
|
||||
continue
|
||||
features.append(feature)
|
||||
kept_labels.append(int(label))
|
||||
if index % 100 == 0:
|
||||
log.info("Extracted %s/%s images", index, len(paths))
|
||||
if not features:
|
||||
raise ValueError("no image produced a valid feature vector")
|
||||
return np.asarray(features), np.asarray(kept_labels, dtype=np.int64), skipped
|
||||
|
||||
|
||||
def fit_ridge(
|
||||
features: FloatArray,
|
||||
labels: IntArray,
|
||||
penalty: float,
|
||||
grid: int = DEFAULT_GRID,
|
||||
) -> RidgeModel:
|
||||
"""Fit a class-balanced standardized ridge head before calibration."""
|
||||
if features.ndim != 2 or len(features) != len(labels):
|
||||
raise ValueError("features and labels must describe the same rows")
|
||||
counts = np.bincount(labels, minlength=len(CLASSES)).astype(np.float64)
|
||||
if np.any(counts == 0):
|
||||
raise ValueError("training data must contain all three classes")
|
||||
if penalty <= 0:
|
||||
raise ValueError("penalty must be positive")
|
||||
mean = features.mean(axis=0)
|
||||
raw_scale = features.std(axis=0)
|
||||
active = raw_scale > 1e-10
|
||||
scale = np.where(active, raw_scale, 1.0)
|
||||
normalized = (features[:, active] - mean[active]) / scale[active]
|
||||
sample_weight = len(labels) / (len(CLASSES) * counts[labels])
|
||||
root_weight = np.sqrt(sample_weight)[:, None]
|
||||
weighted = normalized * root_weight
|
||||
targets = np.eye(len(CLASSES), dtype=np.float64)[labels] * root_weight
|
||||
gram = weighted.T @ weighted + penalty * np.eye(weighted.shape[1])
|
||||
active_weights = cast("FloatArray", np.linalg.solve(gram, weighted.T @ targets))
|
||||
weights = np.zeros((features.shape[1], len(CLASSES)), dtype=np.float64)
|
||||
weights[active] = active_weights
|
||||
return RidgeModel(mean, scale, weights, 0.0, grid, penalty)
|
||||
|
||||
|
||||
def scores(features: FloatArray, model: RidgeModel) -> FloatArray:
|
||||
"""Return the uncalibrated ridge scores."""
|
||||
return ((features - model.mean) / model.scale) @ model.weights
|
||||
|
||||
|
||||
def decision_margins(raw_scores: FloatArray) -> FloatArray:
|
||||
"""Return each row's best-provider advantage over every alternative."""
|
||||
target_scores = raw_scores[:, :UNKNOWN_INDEX]
|
||||
best = np.argmax(target_scores, axis=1)
|
||||
other = 1 - best
|
||||
rows = np.arange(len(raw_scores))
|
||||
reference = np.maximum(raw_scores[rows, other], raw_scores[:, UNKNOWN_INDEX])
|
||||
return target_scores[rows, best] - reference
|
||||
|
||||
|
||||
def calibrate_margin(
|
||||
calibration_scores: FloatArray,
|
||||
calibration_labels: IntArray,
|
||||
unknown_fpr: float,
|
||||
) -> float:
|
||||
"""Choose the smallest empirical cut meeting the unknown false-positive target."""
|
||||
if not 0 <= unknown_fpr < 1:
|
||||
raise ValueError("unknown_fpr must be in [0, 1)")
|
||||
unknown_scores = calibration_scores[calibration_labels == UNKNOWN_INDEX]
|
||||
if not len(unknown_scores):
|
||||
raise ValueError("calibration data must contain unknown rows")
|
||||
return max(
|
||||
0.0,
|
||||
float(
|
||||
np.quantile(
|
||||
decision_margins(unknown_scores),
|
||||
1.0 - unknown_fpr,
|
||||
method="higher",
|
||||
)
|
||||
+ 1e-12
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def predict_scores(raw_scores: FloatArray, margin: float) -> IntArray:
|
||||
"""Return provider labels only when the calibrated margin is met."""
|
||||
best = np.argmax(raw_scores[:, :UNKNOWN_INDEX], axis=1)
|
||||
accepted = decision_margins(raw_scores) >= margin
|
||||
return np.where(accepted, best, UNKNOWN_INDEX).astype(np.int64)
|
||||
|
||||
|
||||
def save_model(path: Path, model: RidgeModel) -> None:
|
||||
"""Write a pickle-free model artifact."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
np.savez_compressed(
|
||||
path,
|
||||
classes=np.asarray(CLASSES),
|
||||
mean=model.mean,
|
||||
scale=model.scale,
|
||||
weights=model.weights,
|
||||
margin=np.asarray(model.margin),
|
||||
spectral_grid=np.asarray(model.grid),
|
||||
penalty=np.asarray(model.penalty),
|
||||
)
|
||||
|
||||
|
||||
def load_model(path: Path) -> RidgeModel:
|
||||
"""Load and validate a pickle-free model artifact."""
|
||||
with np.load(path, allow_pickle=False) as payload:
|
||||
required = {
|
||||
"classes",
|
||||
"mean",
|
||||
"scale",
|
||||
"weights",
|
||||
"margin",
|
||||
"spectral_grid",
|
||||
"penalty",
|
||||
}
|
||||
if set(payload.files) != required:
|
||||
raise ValueError(f"model fields must be exactly {sorted(required)}")
|
||||
classes = tuple(str(value) for value in payload["classes"])
|
||||
mean = np.asarray(payload["mean"], dtype=np.float64)
|
||||
scale = np.asarray(payload["scale"], dtype=np.float64)
|
||||
weights = np.asarray(payload["weights"], dtype=np.float64)
|
||||
margin = float(payload["margin"])
|
||||
grid = int(payload["spectral_grid"])
|
||||
penalty = float(payload["penalty"])
|
||||
expected_features = FEATURE_WIDTH + 3 * grid * grid
|
||||
if classes != CLASSES:
|
||||
raise ValueError(f"model classes must be {CLASSES}")
|
||||
if mean.shape != (expected_features,) or scale.shape != mean.shape:
|
||||
raise ValueError("model normalization vectors have the wrong shape")
|
||||
if weights.shape != (expected_features, len(CLASSES)):
|
||||
raise ValueError("model weights have the wrong shape")
|
||||
if not np.all(np.isfinite(mean)) or not np.all(np.isfinite(scale)):
|
||||
raise ValueError("model normalization vectors must be finite")
|
||||
if not np.all(np.isfinite(weights)) or np.any(scale <= 0):
|
||||
raise ValueError("model weights must be finite and scales positive")
|
||||
if not np.isfinite(margin) or margin < 0:
|
||||
raise ValueError("model margin must be finite and non-negative")
|
||||
if grid < 2 or PATCH % grid or not np.isfinite(penalty) or penalty <= 0:
|
||||
raise ValueError("model calibration fields are invalid")
|
||||
return RidgeModel(mean, scale, weights, margin, grid, penalty)
|
||||
|
||||
|
||||
@click.group()
|
||||
def main() -> None:
|
||||
"""Train or run the research-only source-pipeline classifier."""
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
||||
|
||||
|
||||
@main.command("train")
|
||||
@click.argument("train_root", type=click.Path(exists=True, file_okay=False, path_type=Path))
|
||||
@click.argument("calibration_root", type=click.Path(exists=True, file_okay=False, path_type=Path))
|
||||
@click.option("--model-out", type=click.Path(dir_okay=False, path_type=Path), required=True)
|
||||
@click.option("--grid", type=click.IntRange(min=2), default=DEFAULT_GRID, show_default=True)
|
||||
@click.option("--penalty", type=click.FloatRange(min=0.0, min_open=True), default=DEFAULT_PENALTY, show_default=True)
|
||||
@click.option(
|
||||
"--unknown-fpr",
|
||||
type=click.FloatRange(min=0.0, max=1.0, max_open=True),
|
||||
default=DEFAULT_UNKNOWN_FPR,
|
||||
show_default=True,
|
||||
)
|
||||
def train_command(
|
||||
train_root: Path,
|
||||
calibration_root: Path,
|
||||
model_out: Path,
|
||||
grid: int,
|
||||
penalty: float,
|
||||
unknown_fpr: float,
|
||||
) -> None:
|
||||
"""Fit on TRAIN_ROOT and calibrate abstention on CALIBRATION_ROOT."""
|
||||
if PATCH % grid:
|
||||
raise click.UsageError(f"--grid must divide {PATCH}")
|
||||
try:
|
||||
train_paths, train_labels, train_hashes = discover_labeled_paths(train_root)
|
||||
calibration_paths, calibration_labels, calibration_hashes = discover_labeled_paths(calibration_root)
|
||||
overlap = set(train_hashes) & set(calibration_hashes)
|
||||
if overlap:
|
||||
raise ValueError(f"train/calibration byte overlap: {len(overlap)} images")
|
||||
train_features, kept_train_labels, train_skipped = extract_features(train_paths, train_labels, grid)
|
||||
calibration_features, kept_calibration_labels, calibration_skipped = extract_features(
|
||||
calibration_paths, calibration_labels, grid
|
||||
)
|
||||
provisional = fit_ridge(train_features, kept_train_labels, penalty, grid)
|
||||
margin = calibrate_margin(
|
||||
scores(calibration_features, provisional),
|
||||
kept_calibration_labels,
|
||||
unknown_fpr,
|
||||
)
|
||||
model = replace(provisional, margin=margin)
|
||||
save_model(model_out, model)
|
||||
except ValueError as error:
|
||||
raise click.ClickException(str(error)) from error
|
||||
click.echo(
|
||||
json.dumps(
|
||||
{
|
||||
"model": str(model_out),
|
||||
"claim": CLAIM,
|
||||
"train_rows": len(train_features),
|
||||
"calibration_rows": len(calibration_features),
|
||||
"feature_abstentions": len(train_skipped) + len(calibration_skipped),
|
||||
"margin": margin,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@main.command("predict")
|
||||
@click.argument("model_path", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
||||
@click.argument("images", nargs=-1, required=True, type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
||||
def predict_command(model_path: Path, images: tuple[Path, ...]) -> None:
|
||||
"""Classify original-export IMAGES with MODEL_PATH."""
|
||||
try:
|
||||
model = load_model(model_path)
|
||||
except (OSError, ValueError) as error:
|
||||
raise click.ClickException(str(error)) from error
|
||||
results: list[dict[str, object]] = []
|
||||
geometry = spectral.make_geometry(size=PATCH, grid=model.grid)
|
||||
for path in images:
|
||||
try:
|
||||
feature = _feature_from_path(path, geometry)
|
||||
except (OSError, ValueError) as error:
|
||||
raise click.ClickException(f"could not extract {path}: {error}") from error
|
||||
if feature is None:
|
||||
results.append(
|
||||
{
|
||||
"path": str(path),
|
||||
"label": "unknown",
|
||||
"reason": "124-d feature unavailable",
|
||||
"claim": CLAIM,
|
||||
}
|
||||
)
|
||||
continue
|
||||
raw_scores = scores(feature[None, :], model)
|
||||
prediction = int(predict_scores(raw_scores, model.margin)[0])
|
||||
results.append(
|
||||
{
|
||||
"path": str(path),
|
||||
"label": CLASSES[prediction],
|
||||
"decision_margin": float(decision_margins(raw_scores)[0]),
|
||||
"required_margin": model.margin,
|
||||
"scores": {name: float(raw_scores[0, index]) for index, name in enumerate(CLASSES)},
|
||||
"claim": CLAIM,
|
||||
}
|
||||
)
|
||||
click.echo(json.dumps(results, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -30,6 +30,14 @@ import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
from PIL import Image, ImageFilter
|
||||
|
||||
from remove_ai_watermarks._internal import source_spectral as _spectral
|
||||
|
||||
SpectralGeometry = _spectral.SpectralGeometry
|
||||
make_geometry = _spectral.make_geometry
|
||||
phase_free_log_spectrum = _spectral.phase_free_log_spectrum
|
||||
spectral_feature = _spectral.spectral_feature
|
||||
_source_feature_from_image = _spectral.feature_from_image
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
IMAGE_SUFFIXES = frozenset({".jpg", ".jpeg", ".png", ".webp"})
|
||||
@@ -40,17 +48,6 @@ IntArray = NDArray[np.int64]
|
||||
BoolArray = NDArray[np.bool_]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SpectralGeometry:
|
||||
"""Precomputed coordinates for one phase-free spectral representation."""
|
||||
|
||||
size: int
|
||||
grid: int
|
||||
radius: NDArray[np.int32]
|
||||
radius_counts: IntArray
|
||||
passband: BoolArray
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FoldModel:
|
||||
"""One standardized dual-ridge model and its held-out rows."""
|
||||
@@ -62,51 +59,6 @@ class FoldModel:
|
||||
weights: FloatArray
|
||||
|
||||
|
||||
def make_geometry(size: int = 256, grid: int = 32) -> SpectralGeometry:
|
||||
"""Build the radial detrending and pooling geometry."""
|
||||
if size < 16 or grid < 2 or size % grid:
|
||||
raise ValueError("size must be at least 16 and divisible by grid")
|
||||
yy, xx = np.mgrid[:size, :size]
|
||||
radius = np.rint(np.hypot(yy - size // 2, xx - size // 2)).astype(np.int32)
|
||||
radius_counts = np.bincount(radius.ravel())
|
||||
passband = (radius >= 4) & (radius <= size * 0.48)
|
||||
return SpectralGeometry(size, grid, radius, radius_counts, passband)
|
||||
|
||||
|
||||
def phase_free_log_spectrum(channel: FloatArray) -> FloatArray:
|
||||
"""Return centered log FFT magnitude, deliberately discarding phase."""
|
||||
if channel.ndim != 2:
|
||||
raise ValueError("channel must be a two-dimensional array")
|
||||
centered = np.asarray(channel, dtype=np.float64) - float(np.mean(channel))
|
||||
return np.log1p(np.abs(np.fft.fftshift(np.fft.fft2(centered))))
|
||||
|
||||
|
||||
def spectral_feature(pixels: FloatArray, geometry: SpectralGeometry) -> FloatArray:
|
||||
"""Extract pooled radial-residual spectra from luma and opponent color."""
|
||||
if pixels.shape != (geometry.size, geometry.size, 3):
|
||||
raise ValueError(f"pixels must have shape {(geometry.size, geometry.size, 3)}, got {pixels.shape}")
|
||||
red, green, blue = np.moveaxis(np.asarray(pixels, dtype=np.float64), 2, 0)
|
||||
channels = (
|
||||
0.299 * red + 0.587 * green + 0.114 * blue,
|
||||
red - green,
|
||||
blue - (red + green) / 2.0,
|
||||
)
|
||||
block = geometry.size // geometry.grid
|
||||
features: list[FloatArray] = []
|
||||
for channel in channels:
|
||||
spectrum = phase_free_log_spectrum(channel)
|
||||
radial_sum = np.bincount(geometry.radius.ravel(), weights=spectrum.ravel())
|
||||
radial_mean = radial_sum / geometry.radius_counts
|
||||
residual = np.where(
|
||||
geometry.passband,
|
||||
spectrum - radial_mean[geometry.radius],
|
||||
0.0,
|
||||
)
|
||||
pooled = residual.reshape(geometry.grid, block, geometry.grid, block).mean(axis=(1, 3))
|
||||
features.append(pooled.ravel())
|
||||
return np.concatenate(features)
|
||||
|
||||
|
||||
def _apply_attack(image: Image.Image, attack: str) -> Image.Image:
|
||||
"""Apply one declared robustness transform before canonical resizing."""
|
||||
if attack == "original":
|
||||
@@ -134,15 +86,13 @@ def _apply_attack(image: Image.Image, attack: str) -> Image.Image:
|
||||
def feature_from_path(path: Path, geometry: SpectralGeometry, attack: str) -> FloatArray:
|
||||
"""Decode one path, apply ATTACK, and return its phase-free feature."""
|
||||
with Image.open(path) as source:
|
||||
return _feature_from_image(source.convert("RGB"), geometry, attack)
|
||||
return feature_from_image(source.convert("RGB"), geometry, attack)
|
||||
|
||||
|
||||
def _feature_from_image(image: Image.Image, geometry: SpectralGeometry, attack: str) -> FloatArray:
|
||||
def feature_from_image(image: Image.Image, geometry: SpectralGeometry, attack: str) -> FloatArray:
|
||||
"""Extract one attacked feature from an already decoded RGB image."""
|
||||
attacked = _apply_attack(image, attack)
|
||||
canonical = attacked.resize((geometry.size, geometry.size), Image.Resampling.LANCZOS)
|
||||
pixels = np.asarray(canonical, dtype=np.float64) / 255.0
|
||||
return spectral_feature(pixels, geometry)
|
||||
return _source_feature_from_image(attacked, geometry)
|
||||
|
||||
|
||||
def discover_grouped_paths(
|
||||
@@ -253,7 +203,7 @@ def _extract_all_attacks(
|
||||
with Image.open(path) as source:
|
||||
image = source.convert("RGB")
|
||||
for attack in attacks:
|
||||
rows[attack].append(_feature_from_image(image, geometry, attack))
|
||||
rows[attack].append(feature_from_image(image, geometry, attack))
|
||||
return {attack: np.asarray(features) for attack, features in rows.items()}
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
@@ -54,7 +55,18 @@ class RegistryRow:
|
||||
def _read_bytes(source: str) -> bytes:
|
||||
parsed = urlparse(source)
|
||||
if parsed.scheme in {"http", "https"}:
|
||||
request = Request(source, headers={"User-Agent": "remove-ai-watermarks-c2pa-registry-sync"})
|
||||
headers = {"User-Agent": "remove-ai-watermarks-c2pa-registry-sync"}
|
||||
# api.github.com rate-limits unauthenticated requests to 60/hour PER SOURCE IP,
|
||||
# which CI runners exhaust fast since many workflows across many repos share
|
||||
# GitHub's runner IP pool -- a 403 "rate limit exceeded" here is that, not an
|
||||
# outage. A GITHUB_TOKEN (Actions injects one into every run; export it as an
|
||||
# env var to reach this process) raises the same endpoint to 5000/hour.
|
||||
# raw.githubusercontent.com is a separate, much higher-limit CDN and does not
|
||||
# need this.
|
||||
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
|
||||
if token and parsed.hostname == "api.github.com":
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
request = Request(source, headers=headers)
|
||||
with urlopen(request, timeout=30) as response:
|
||||
return response.read()
|
||||
return Path(source).read_bytes()
|
||||
|
||||
@@ -22,12 +22,16 @@ remove-ai-watermarks visible image.png --keep-metadata -o clean.png
|
||||
|
||||
Image mark keys: `gemini`, `doubao`, `jimeng`, `qwen`, `kling`, `yuanbao`,
|
||||
`samsung`, `runninghub`, `baidu`, `liblib`, `liblib_pill`, `microsoft`,
|
||||
`jimeng_pill`. Default
|
||||
`generic_ai_label`, `openart`, `jimeng_pill`. Default
|
||||
`--mark auto` removes every selected match. `microsoft` is the top-right AI
|
||||
badge, separate from the invisible InvisMark watermark on the same vendor's
|
||||
images. `jimeng_pill` is a weak detector and needs corroboration. The compact
|
||||
`liblib_pill` likewise requires LiblibAI metadata or the bottom-center wordmark
|
||||
(since CLI 0.39.0).
|
||||
(since CLI 0.39.0). `generic_ai_label` is a brand-less bare "AI生成" fallback for
|
||||
OS/gallery-level AI-edit stamps with no vendor wordmark; synthetic-only
|
||||
calibration, weaker evidence than the vendor-tuned marks above. `openart` sits
|
||||
in the FRAME CENTER, not a corner, and is UNCALIBRATED (one confirmed real
|
||||
case, no captured corpus) -- see `openart_engine.py`'s module docstring.
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks erase image.png --region 1640,1930,400,100 -o clean.png
|
||||
@@ -94,6 +98,10 @@ chroma-zimage for Microsoft, qwen-zimage for OpenAI (since CLI 0.39.0), Meta and
|
||||
-- so prefer it over naming an engine when the user has no reason to care. `sdxl-zimage` is the
|
||||
heavier alternative and `chroma-zimage` is the Apache-2.0 global stage.
|
||||
|
||||
Verified-text manifests work with every profile (since CLI 0.40.5). Each profile
|
||||
uses its own already loaded VAE donor, so `auto` keeps the normal SDXL route for a
|
||||
Google image instead of switching engines.
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks all image.png -o clean.png
|
||||
remove-ai-watermarks batch ./images --mode visible --output-dir ./clean
|
||||
|
||||
@@ -32,7 +32,7 @@ _GPU_MARKERS = ("GPU ", "gpu:")
|
||||
# told an agent its CLI was current right before it typed a value that build
|
||||
# rejects. tests/test_agent_skill.py pins it against the package version and
|
||||
# against every "(since CLI X.Y.Z)" annotation in the skill.
|
||||
MIN_CLI_VERSION = (0, 40, 0)
|
||||
MIN_CLI_VERSION = (0, 40, 5)
|
||||
# Markers a build without the pixel stack prints. The first is the current CLI's own
|
||||
# install hint; the rest are what older builds emit before the guard existed, and the
|
||||
# Homebrew formula ships exactly such a build.
|
||||
|
||||
@@ -14,6 +14,7 @@ High-level API (lazy, so ``import remove_ai_watermarks`` stays cheap)::
|
||||
raiw.remove_video_metadata("in.mp4", "out.mp4") # verified metadata strip
|
||||
raiw.remove_video_invisible("in.mp4", "out.mp4") # calibrated video-pixel regeneration
|
||||
raiw.remove_video_visible("in.mp4", "out.mp4") # stable visible video-mark removal
|
||||
raiw.classify_source("in.png") # source pipeline, not SynthID detection
|
||||
|
||||
For a provenance verdict use the ``identify`` submodule::
|
||||
|
||||
@@ -33,7 +34,7 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
|
||||
_warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*")
|
||||
|
||||
|
||||
__version__ = "0.40.4"
|
||||
__version__ = "0.42.0"
|
||||
|
||||
__all__ = [
|
||||
"BatchItemResult",
|
||||
@@ -41,8 +42,10 @@ __all__ = [
|
||||
"InvisibleOptions",
|
||||
"MetadataStripIncomplete",
|
||||
"RemoveAllResult",
|
||||
"SourceClassification",
|
||||
"VisibleRemovalResult",
|
||||
"__version__",
|
||||
"classify_source",
|
||||
"identify_video",
|
||||
"inspect_video_metadata",
|
||||
"remove_all",
|
||||
@@ -71,6 +74,7 @@ if TYPE_CHECKING:
|
||||
remove_visible_detailed,
|
||||
visible_provenance,
|
||||
)
|
||||
from remove_ai_watermarks.source_classify import SourceClassification, classify_source
|
||||
from remove_ai_watermarks.video import (
|
||||
identify_video,
|
||||
inspect_video_metadata,
|
||||
@@ -101,6 +105,10 @@ def __getattr__(name: str) -> object:
|
||||
from remove_ai_watermarks import api
|
||||
|
||||
return getattr(api, name)
|
||||
if name in ("SourceClassification", "classify_source"):
|
||||
from remove_ai_watermarks import source_classify
|
||||
|
||||
return getattr(source_classify, name)
|
||||
if name in (
|
||||
"identify_video",
|
||||
"inspect_video_metadata",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Generated C2PA soft-binding registry; do not edit by hand.
|
||||
|
||||
Source: https://raw.githubusercontent.com/c2pa-org/softbinding-algorithm-list/main/softbinding-algorithm-list.json
|
||||
Revision: c0218cd628c9163ad62a08c3deff5a10fa0e0d0b
|
||||
Revision: 1b3ef637890ea8228bca13d8941cfd54da941867
|
||||
Source license: CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/)
|
||||
Changes: validated, ordered, and reduced to fields used at runtime; descriptions
|
||||
are shortened to their first sentence for display.
|
||||
@@ -12,7 +12,7 @@ from __future__ import annotations
|
||||
C2PA_SOFT_BINDING_SOURCE_URL = (
|
||||
"https://raw.githubusercontent.com/c2pa-org/softbinding-algorithm-list/main/softbinding-algorithm-list.json"
|
||||
)
|
||||
C2PA_SOFT_BINDING_SOURCE_REVISION = "c0218cd628c9163ad62a08c3deff5a10fa0e0d0b"
|
||||
C2PA_SOFT_BINDING_SOURCE_REVISION = "1b3ef637890ea8228bca13d8941cfd54da941867"
|
||||
C2PA_SOFT_BINDING_SOURCE_LICENSE = "CC BY 4.0"
|
||||
|
||||
C2PA_SOFT_BINDING_ROWS: tuple[
|
||||
@@ -387,7 +387,7 @@ C2PA_SOFT_BINDING_ROWS: tuple[
|
||||
"eu.sasha.softbinding.v1",
|
||||
"watermark",
|
||||
("image",),
|
||||
("image/jpeg", "image/png"),
|
||||
("image/jpeg", "image/png", "image/webp"),
|
||||
(
|
||||
"Invisible watermarking technology for image and video assets, enabling C2PA manifest "
|
||||
"recovery and content provenance verification after metadata stripping or transcoding"
|
||||
|
||||
@@ -36,7 +36,7 @@ from remove_ai_watermarks._internal.two_stage_pipeline import (
|
||||
_prompt_cache_path,
|
||||
_store_prompt_payload,
|
||||
_target_size,
|
||||
edge_pad_to_grid,
|
||||
diffusers_vae_roundtrip,
|
||||
requested_steps,
|
||||
)
|
||||
|
||||
@@ -182,28 +182,12 @@ class ChromaZImagePipeline(TwoStageZImagePipeline):
|
||||
|
||||
def _vae_roundtrip(self, image: Image.Image) -> Image.Image:
|
||||
"""Reconstruct source pixels through the already loaded Chroma VAE."""
|
||||
import torch
|
||||
|
||||
pipe = self._load_global()
|
||||
source_width, source_height = image.size
|
||||
padded = edge_pad_to_grid(image, _LATENT_GRID)
|
||||
|
||||
tensor = pipe.image_processor.preprocess(
|
||||
padded,
|
||||
height=padded.height,
|
||||
width=padded.width,
|
||||
).to(device=self.device, dtype=self.torch_dtype)
|
||||
with torch.inference_mode():
|
||||
encoded = pipe.vae.encode(tensor)
|
||||
if hasattr(encoded, "latent_dist"):
|
||||
# A restoration donor must be deterministic. Diffusers samples this
|
||||
# distribution for img2img noise initialization; its mode is the
|
||||
# faithful VAE reconstruction needed here.
|
||||
latents = encoded.latent_dist.mode()
|
||||
elif hasattr(encoded, "latents"):
|
||||
latents = encoded.latents
|
||||
else:
|
||||
raise AttributeError("Chroma VAE encoder output contains no latents")
|
||||
decoded = pipe.vae.decode(latents, return_dict=False)[0]
|
||||
reconstructed = pipe.image_processor.postprocess(decoded, output_type="pil")[0]
|
||||
return reconstructed.crop((0, 0, source_width, source_height)).convert("RGB")
|
||||
return diffusers_vae_roundtrip(
|
||||
pipe,
|
||||
image,
|
||||
grid=_LATENT_GRID,
|
||||
device=self.device,
|
||||
dtype=self.torch_dtype,
|
||||
profile_name=self.profile_name,
|
||||
)
|
||||
@@ -28,6 +28,7 @@ from remove_ai_watermarks._internal.two_stage_pipeline import (
|
||||
TwoStageZImagePipeline,
|
||||
_target_size,
|
||||
build_canny_control_image,
|
||||
diffusers_vae_roundtrip,
|
||||
requested_steps,
|
||||
)
|
||||
from remove_ai_watermarks._internal.watermark_profiles import (
|
||||
@@ -120,3 +121,15 @@ class SdxlZImagePipeline(TwoStageZImagePipeline):
|
||||
if result.size != image.size:
|
||||
result = result.resize(image.size, Image.Resampling.LANCZOS)
|
||||
return result.convert("RGB")
|
||||
|
||||
def _vae_roundtrip(self, image: Image.Image) -> Image.Image:
|
||||
"""Reconstruct source pixels through the already loaded SDXL VAE."""
|
||||
pipe = self._load_global()
|
||||
return diffusers_vae_roundtrip(
|
||||
pipe,
|
||||
image,
|
||||
grid=_LATENT_GRID,
|
||||
device=self.device,
|
||||
dtype=self.torch_dtype,
|
||||
profile_name=self.profile_name,
|
||||
)
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Phase-free spectral features for source/export-pipeline classification."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from numpy.typing import NDArray
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SpectralGeometry:
|
||||
"""Precomputed coordinates for one phase-free spectral representation."""
|
||||
|
||||
size: int
|
||||
grid: int
|
||||
radius: NDArray[np.int32]
|
||||
radius_counts: NDArray[np.int64]
|
||||
passband: NDArray[np.bool_]
|
||||
|
||||
|
||||
@lru_cache(maxsize=4)
|
||||
def make_geometry(size: int = 256, grid: int = 16) -> SpectralGeometry:
|
||||
"""Build radial detrending and pooling geometry."""
|
||||
if size < 16 or grid < 2 or size % grid:
|
||||
raise ValueError("size must be at least 16 and divisible by grid")
|
||||
yy, xx = np.mgrid[:size, :size]
|
||||
radius = np.rint(np.hypot(yy - size // 2, xx - size // 2)).astype(np.int32)
|
||||
radius_counts = np.bincount(radius.ravel())
|
||||
passband = (radius >= 4) & (radius <= size * 0.48)
|
||||
return SpectralGeometry(size, grid, radius, radius_counts, passband)
|
||||
|
||||
|
||||
def phase_free_log_spectrum(channel: NDArray[np.float64]) -> NDArray[np.float64]:
|
||||
"""Return centered log FFT magnitude, deliberately discarding phase."""
|
||||
if channel.ndim != 2:
|
||||
raise ValueError("channel must be a two-dimensional array")
|
||||
centered = channel - float(np.mean(channel))
|
||||
return np.log1p(np.abs(np.fft.fftshift(np.fft.fft2(centered))))
|
||||
|
||||
|
||||
def spectral_feature(pixels: NDArray[np.float64], geometry: SpectralGeometry) -> NDArray[np.float64]:
|
||||
"""Extract pooled radial-residual spectra from luma and opponent color."""
|
||||
expected = (geometry.size, geometry.size, 3)
|
||||
if pixels.shape != expected:
|
||||
raise ValueError(f"pixels must have shape {expected}, got {pixels.shape}")
|
||||
red, green, blue = np.moveaxis(pixels, 2, 0)
|
||||
channels = (
|
||||
0.299 * red + 0.587 * green + 0.114 * blue,
|
||||
red - green,
|
||||
blue - (red + green) / 2.0,
|
||||
)
|
||||
block = geometry.size // geometry.grid
|
||||
features: list[NDArray[np.float64]] = []
|
||||
for channel in channels:
|
||||
spectrum = phase_free_log_spectrum(channel)
|
||||
radial_sum = np.bincount(geometry.radius.ravel(), weights=spectrum.ravel())
|
||||
radial_mean = radial_sum / geometry.radius_counts
|
||||
residual = np.where(
|
||||
geometry.passband,
|
||||
spectrum - radial_mean[geometry.radius],
|
||||
0.0,
|
||||
)
|
||||
pooled = residual.reshape(geometry.grid, block, geometry.grid, block).mean(axis=(1, 3))
|
||||
features.append(pooled.ravel())
|
||||
return np.concatenate(features)
|
||||
|
||||
|
||||
def feature_from_image(image: Image.Image, geometry: SpectralGeometry) -> NDArray[np.float64]:
|
||||
"""Resize an RGB image canonically and extract its phase-free feature."""
|
||||
canonical = image.resize((geometry.size, geometry.size), Image.Resampling.LANCZOS)
|
||||
pixels = np.asarray(canonical, dtype=np.float64) / 255.0
|
||||
return spectral_feature(pixels, geometry)
|
||||
@@ -96,6 +96,41 @@ def edge_pad_to_grid(image: Image.Image, grid: int) -> Image.Image:
|
||||
)
|
||||
|
||||
|
||||
def diffusers_vae_roundtrip(
|
||||
pipe: Any,
|
||||
image: Image.Image,
|
||||
*,
|
||||
grid: int,
|
||||
device: Any,
|
||||
dtype: Any,
|
||||
profile_name: str,
|
||||
) -> Image.Image:
|
||||
"""Reconstruct pixels deterministically through a Diffusers pipeline VAE."""
|
||||
import torch
|
||||
|
||||
source_width, source_height = image.size
|
||||
padded = edge_pad_to_grid(image, grid)
|
||||
tensor = pipe.image_processor.preprocess(
|
||||
padded,
|
||||
height=padded.height,
|
||||
width=padded.width,
|
||||
).to(device=device, dtype=dtype)
|
||||
with torch.inference_mode():
|
||||
encoded = pipe.vae.encode(tensor)
|
||||
if hasattr(encoded, "latent_dist"):
|
||||
# A restoration donor must be deterministic. Diffusers samples this
|
||||
# distribution for img2img noise initialization; its mode is the
|
||||
# faithful VAE reconstruction needed here.
|
||||
latents = encoded.latent_dist.mode()
|
||||
elif hasattr(encoded, "latents"):
|
||||
latents = encoded.latents
|
||||
else:
|
||||
raise AttributeError(f"{profile_name} VAE encoder output contains no latents")
|
||||
decoded = pipe.vae.decode(latents, return_dict=False)[0]
|
||||
reconstructed = pipe.image_processor.postprocess(decoded, output_type="pil")[0]
|
||||
return reconstructed.crop((0, 0, source_width, source_height)).convert("RGB")
|
||||
|
||||
|
||||
def resolve_face_model_residency(
|
||||
requested: bool | None,
|
||||
*,
|
||||
|
||||
@@ -100,9 +100,15 @@ SDXL_ZIMAGE_UNKNOWN_STRENGTH = SDXL_ZIMAGE_GEMINI_STRENGTH
|
||||
# carriers, so 0.35 is the content-agnostic operating floor.
|
||||
QWEN_ZIMAGE_GOOGLE_STRENGTH = 0.35
|
||||
|
||||
# The two OpenAI sources first cleared at 0.06225 and 0.0695. Add one full observed
|
||||
# cross-source spread (0.00725) to the worst clean boundary: 0.0695 + 0.00725.
|
||||
QWEN_ZIMAGE_OPENAI_STRENGTH = 0.07675
|
||||
# OpenAI source-fresh expansion (official Content Provenance API, 2026-09-21):
|
||||
# six current gpt-image-2 carriers first cleared at 0.04375 / 0.09375 / 0.0625
|
||||
# (YuNet faces) and 0.04375 / 0.03125 / 0.08125 (no faces), each bracketed to
|
||||
# 0.00625 after an RGB-identical metadata-stripped positive control. Together
|
||||
# with the earlier 0.06225 / 0.0695 sources this is n=8. Face and no-face ranges
|
||||
# both span 0.05, while their worst boundaries differ by only 0.0125, so there
|
||||
# is no content split. Add one full observed cross-source spread to the worst
|
||||
# clean boundary: 0.09375 + (0.09375 - 0.03125) = 0.15625.
|
||||
QWEN_ZIMAGE_OPENAI_STRENGTH = 0.15625
|
||||
|
||||
# Microsoft's public detector (https://ai.azure.com/nextgen/validate) returned
|
||||
# Inconclusive rather than an API-level watermark-negative verdict. Three valid
|
||||
@@ -151,8 +157,9 @@ _SDXL_ZIMAGE_STRENGTH_BY_VENDOR: dict[str, float] = {
|
||||
# 0.1375. The face-bearing cohort rule adds its observed spread to the worst
|
||||
# boundary: 0.1375 + (0.1375 - 0.075) = 0.20. Explicit Chroma uses that
|
||||
# operating point, verified clean three times on both withheld carriers.
|
||||
# Qwen cleared both withheld carriers at its existing 0.07675 operating point,
|
||||
# so auto routes OpenAI to Qwen rather than paying Chroma's higher fidelity cost.
|
||||
# Qwen cleared both withheld carriers at its then-existing 0.07675 operating
|
||||
# point; the later n=8 Qwen expansion raised its floor to 0.15625 without
|
||||
# changing the route, which still avoids Chroma's higher fidelity cost.
|
||||
# Three further withheld carriers, all zero-face by the same YuNet detector the
|
||||
# adaptive arms gate on, first cleared at 0.045 / 0.06 / 0.11 -> a zero-face
|
||||
# operating point of 0.11 + (0.11 - 0.045) = 0.175, which 0.20 already covers.
|
||||
|
||||
@@ -250,8 +250,6 @@ class WatermarkRemover:
|
||||
self.model_profile = resolved
|
||||
self.torch_dtype = _dtype_for_profile(resolved)
|
||||
self._qwen_zimage_pipeline = None
|
||||
if text_manifest is not None and self.model_profile == SDXL_ZIMAGE_PROFILE:
|
||||
raise ValueError("Verified text restoration is not supported by the sdxl-zimage profile")
|
||||
if text_manifest is not None and tile:
|
||||
raise ValueError("Verified text restoration is not calibrated with tiled diffusion")
|
||||
if fidelity_anchor and self.model_profile != QWEN_ZIMAGE_PROFILE:
|
||||
|
||||
@@ -89,8 +89,8 @@ class TextMarkConfig:
|
||||
name: str # short label for log lines (e.g. "Doubao")
|
||||
asset_name: str # bundled alpha PNG under assets/ (e.g. "doubao_alpha.png")
|
||||
corner: Literal[
|
||||
"br", "bl", "tl", "tr", "bc"
|
||||
] # br (Doubao/Jimeng), bl (Samsung), tl (RunningHub), tr (Microsoft), bc (LiblibAI)
|
||||
"br", "bl", "tl", "tr", "bc", "cc"
|
||||
] # br (Doubao/Jimeng), bl (Samsung), tl (RunningHub), tr (Microsoft), bc (LiblibAI), cc (OpenArt)
|
||||
margin_floor: int # min margin in px for locate (4 for br marks, 2 for Samsung)
|
||||
# locate geometry (fraction of scale_base -- see scale_base())
|
||||
width_frac: float
|
||||
@@ -242,6 +242,10 @@ _RIVAL_MODULES = {
|
||||
"jimeng_alpha.png": "remove_ai_watermarks.jimeng_engine",
|
||||
"qwen_alpha.png": "remove_ai_watermarks.qwen_engine",
|
||||
"samsung_alpha.png": "remove_ai_watermarks.samsung_engine",
|
||||
"baidu_alpha.png": "remove_ai_watermarks.baidu_engine",
|
||||
"kling_alpha.png": "remove_ai_watermarks.kling_engine",
|
||||
"yuanbao_alpha.png": "remove_ai_watermarks.yuanbao_engine",
|
||||
"generic_ai_label_alpha.png": "remove_ai_watermarks.generic_ai_label_engine",
|
||||
}
|
||||
|
||||
|
||||
@@ -528,12 +532,17 @@ class TextMarkEngine:
|
||||
margin_b = max(c.margin_floor, int(base * c.margin_bottom_frac))
|
||||
if c.corner == "br" or c.corner == "tr":
|
||||
x = max(0, w - margin_x - wm_w)
|
||||
elif c.corner == "bc": # bottom-center: horizontally centered, margin_x unused
|
||||
elif c.corner in ("bc", "cc"): # horizontally centered, margin_x unused
|
||||
x = max(0, (w - wm_w) // 2)
|
||||
else:
|
||||
x = min(margin_x, max(0, w - wm_w))
|
||||
# "tl"/"tr" anchor at the top instead: margin_bottom_frac is then the TOP margin.
|
||||
y = min(margin_b, max(0, h - wm_h)) if c.corner in ("tl", "tr") else max(0, h - margin_b - wm_h)
|
||||
if c.corner in ("tl", "tr"):
|
||||
y = min(margin_b, max(0, h - wm_h))
|
||||
elif c.corner == "cc": # frame-center: vertically centered, margin_bottom_frac unused
|
||||
y = max(0, (h - wm_h) // 2)
|
||||
else:
|
||||
y = max(0, h - margin_b - wm_h)
|
||||
wm_w = min(wm_w, w - x)
|
||||
wm_h = min(wm_h, h - y)
|
||||
return TextMarkLocation(x=x, y=y, w=wm_w, h=wm_h)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
@@ -318,9 +318,8 @@ _text_manifest_option = click.option(
|
||||
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
||||
default=None,
|
||||
help=(
|
||||
"Experimental verified-text restoration manifest. Requires qwen-zimage or "
|
||||
"chroma-zimage, the text-restoration extra, native untiled geometry, and no "
|
||||
"postprocessing."
|
||||
"Experimental verified-text restoration manifest. Requires the text-restoration "
|
||||
"extra, native untiled geometry, and no postprocessing."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -287,6 +287,18 @@ class GeminiEngine:
|
||||
low_margin = margin is not None and margin < self._SPARKLE_FP_MARGIN
|
||||
low_gradient = candidate.gradient < self._SPARKLE_FP_GRAD
|
||||
if not low_margin and not low_gradient:
|
||||
# A bright core AND a sharp shape match clear the gate without further
|
||||
# scrutiny -- including for a decorative sparkle-shaped glyph, not just a
|
||||
# genuine sparkle. See "Gemini sparkle" in docs/module-internals.md
|
||||
# (incident 2026-08-27) for why margin/gradient/saturation can't tell
|
||||
# them apart, and TestDecorativeGlyphFalsePositive for the regression.
|
||||
# Logged so a future fix can be calibrated against real occurrences.
|
||||
logger.debug(
|
||||
"Sparkle candidate cleared FP gate without scrutiny: confidence=%.3f, margin=%s, gradient=%.3f",
|
||||
confidence,
|
||||
margin,
|
||||
candidate.gradient,
|
||||
)
|
||||
return confidence
|
||||
saturation = self._core_saturation(image, alpha, position)
|
||||
neutral_core = not low_margin and saturation is not None and saturation <= self._SPARKLE_WHITE_SAT
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Generic bare "AI生成" text-mark detector/localizer (brand-less TC260 fallback).
|
||||
|
||||
Every other text-mark engine in this package is tuned to one vendor's exact wordmark
|
||||
silhouette ("豆包AI生成" for Doubao, "★ 即梦AI" for Jimeng, and so on). This engine
|
||||
instead catches a BARE "AI生成" (or "AI generated") corner strip with **no brand
|
||||
wordmark in front of it** -- the China GB 45438-2025 / TC260 explicit-AIGC label
|
||||
stamped by OS- and gallery-level AI-edit tools that do not carry a distinct generator
|
||||
brand of their own, as opposed to a named generator product like Doubao or Kling.
|
||||
|
||||
Research evidence (2026-09-21, see the library's task history; no dedicated brand
|
||||
wordmark exists to tune a config against, so this is deliberately the generic
|
||||
catch-all rather than one more brand-pinned engine):
|
||||
|
||||
* vivo's own support documentation confirms its Gallery app stamps a bare "AI生成"
|
||||
watermark after AI消除 (AI object removal) / AI扩图 (AI expand) / 大片模式 (AI
|
||||
scene effects) editing, user-togglable under Album Settings -> "AI编辑后显示AI
|
||||
标识" ("Show AI label after AI edit") -- https://www.vivo.com.cn/service/questions/all?categoryId=170&questionId=2104
|
||||
* Independent reporting (今日头条 2026) documents the same bare "AI生成" watermark
|
||||
on Xiaomi's Gallery AI-edit output, with users asking how to remove it.
|
||||
* Community reporting suggests Samsung's China-locale Galaxy AI gallery tools stamp
|
||||
"AI生成" too, distinct from the Italian "Contenuti generati dall'AI" string this
|
||||
library's ``samsung`` engine already targets (different locale render of the same
|
||||
product, not covered here).
|
||||
* OPPO/Huawei almost certainly ship an equivalent compliance stamp under the same
|
||||
mandate, but no independent confirmation of the exact wording was found.
|
||||
|
||||
The label is compliance-driven, not brand-driven, so it is PLAUSIBLE that several
|
||||
manufacturers' gallery apps render the exact same "AI生成" string with only styling
|
||||
differences (font, position, background pill) -- which is exactly why this is one
|
||||
generic fallback keyed on the bare text, not a family of near-duplicate per-OEM
|
||||
configs.
|
||||
|
||||
Detection reuses the shared physical assumption every other text mark in this family
|
||||
already encodes (``MAX_SATURATION`` / ``LOGO_MIN_LUMA`` / ``TOPHAT_DELTA``): a light,
|
||||
low-saturation glyph rendered brighter than its local background, mandated by GB
|
||||
45438-2025's own "light-colored watermark" house style, not a vendor-specific
|
||||
measurement. Everything else here is UNMEASURED and synthetic: there is no captured
|
||||
vendor screenshot to solve an alpha map from (the standing ``visible_alpha_solve.py``
|
||||
approach), because the mark has no logo element to capture -- only literal text, so
|
||||
``scripts/render_vendor_silhouettes.py`` -- the shared capture-less font-rendering
|
||||
pipeline already used for Qwen/Baidu/Kling/Yuanbao's own text-only marks -- renders
|
||||
"AI生成" directly as the alpha-template asset (``assets/generic_ai_label_alpha.png``),
|
||||
which plugs into the same :class:`TextMarkConfig` machinery every other mark uses.
|
||||
|
||||
CALIBRATION CAVEAT: ``DETECT_NCC_THRESHOLD`` below is fit on a SYNTHETIC corpus only
|
||||
(the rendered glyph composited onto generated backgrounds at varied scale/opacity,
|
||||
scored against generated clean negatives and the six existing brand marks' own
|
||||
composited examples as a false-positive/rival check) -- see
|
||||
``scripts/calibrate_generic_ai_label.py`` for the recall/precision table it produced.
|
||||
No real "AI生成"-generator screenshot (vivo/Xiaomi/other gallery output) was available
|
||||
to calibrate against. Treat this detector as WEAKER evidence than the brand-tuned
|
||||
engines until it is recalibrated on real captures; the ``rivals`` margin below exists
|
||||
specifically so a genuine Doubao/Jimeng/Qwen/Baidu/Kling/Yuanbao mark -- every one of
|
||||
which literally contains the substring "AI生成" in its own glyphs -- is attributed to
|
||||
its own tuned engine instead of double-firing here.
|
||||
"""
|
||||
# The module-level _alpha_template / _glyph_silhouette / _template_match_score below
|
||||
# are thin test-facing shims (imported by tests/), so pyright's src-only pass sees them
|
||||
# as unused; the use is cross-module.
|
||||
# pyright: reportUnusedFunction=false
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from remove_ai_watermarks import _text_mark_engine, image_io
|
||||
from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkDetection, TextMarkEngine
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from numpy.typing import NDArray
|
||||
|
||||
# Locate geometry as a fraction of image WIDTH (unmeasured basis -- the default every
|
||||
# unconfirmed vendor starts from; see scale_basis below). The box must comfortably
|
||||
# exceed the glyph at the TOP of the search ladder (alpha_*_frac * max(ladder)) or the
|
||||
# search crop clips the glyph before NCC ever sees it -- at this asset's shape that
|
||||
# floor is 0.1724*1.25=0.216 / 0.0625*1.25=0.078; these fractions keep ~1.3x headroom
|
||||
# above it, matching the other bottom-right marks' convention.
|
||||
WM_WIDTH_FRAC = 0.28
|
||||
WM_HEIGHT_FRAC = 0.10
|
||||
MARGIN_RIGHT_FRAC = 0.010
|
||||
MARGIN_BOTTOM_FRAC = 0.010
|
||||
|
||||
# Glyph appearance: shared TC260 "light watermark" house style (see module docstring),
|
||||
# not a vendor-specific measurement -- identical to Doubao/Jimeng/Kling.
|
||||
MAX_SATURATION = 55
|
||||
LOGO_MIN_LUMA = 150
|
||||
TOPHAT_DELTA = 12
|
||||
|
||||
# Shape-consistent detection via the continuous top-hat front-end (TM_CCOEFF_NORMED
|
||||
# against the rendered "AI生成" silhouette). Calibrated SYNTHETICALLY (see the module
|
||||
# docstring's caveat) against the scripts/render_vendor_silhouettes.py asset: the
|
||||
# rendered glyph composited onto generated backgrounds at 4 base sizes x 3 scale rungs
|
||||
# x 3 opacities (36 positives), scored against 40 generated clean/textured negatives
|
||||
# and 8 rival marks' own committed examples (each containing "AI生成" or similar CJK
|
||||
# text, so a real risk of double-firing on an already-attributed mark):
|
||||
#
|
||||
# gate margin recall precision TP clean_fp rival_fp
|
||||
# 0.35 0.10 67% 96% 24 0 1 (qwen)
|
||||
# 0.40 0.10 67% 96% 24 0 1 (qwen)
|
||||
# 0.45 0.10 67% 100% 24 0 0
|
||||
# 0.50 0.10 67% 100% 24 0 0 <- chosen
|
||||
# 0.55 0.10 42% 100% 15 0 0
|
||||
#
|
||||
# 0.50 over the tied 0.45 (identical recall/precision): a synthetic clean-background
|
||||
# removal leaves a faint TELEA-inpainting residual that still correlates ~0.45-0.46
|
||||
# against this template (see test_removes_synthetic_mark), so 0.45 leaves detection
|
||||
# re-firing on the library's OWN removal output with no headroom. 0.50 costs nothing
|
||||
# on this corpus and clears that residual.
|
||||
#
|
||||
# The plain margin=0.10 shared by every other mark already separates this template
|
||||
# from Qwen's own "千问AI生成" binarized glyph blob once the absolute gate clears
|
||||
# 0.45, so no widened rival margin is needed.
|
||||
DETECT_MIN_COVERAGE = 0.04
|
||||
DETECT_NCC_THRESHOLD = 0.50
|
||||
|
||||
# Never relaxed under provenance: this mark cannot be attributed to one vendor, so
|
||||
# there is no producer identity that could confirm it the way a TC260 USCC confirms
|
||||
# Doubao/Jimeng/etc, and the synthetic-only calibration has no measured sub-gate band
|
||||
# to relax into safely.
|
||||
PROVENANCE_NCC_FACTOR = 1.0
|
||||
|
||||
# Detection-silhouette geometry, emitted by scripts/render_vendor_silhouettes.py
|
||||
# (font-rendered, not captured) at this reference width.
|
||||
_ALPHA_NATIVE_WIDTH = 2048
|
||||
_ALPHA_WIDTH_FRAC = 0.1724
|
||||
_ALPHA_HEIGHT_FRAC = 0.0625
|
||||
|
||||
# The rendered alpha is also the removal footprint after the detector aligns it,
|
||||
# mirroring Doubao/Kling: a solid enclosing rectangle risks reaching frame edges on
|
||||
# textured backgrounds (see doubao_engine's _FOOTPRINT_ALPHA_FLOOR note), so the sparse
|
||||
# glyph footprint is safer for an as-yet-uncalibrated mark.
|
||||
_FOOTPRINT_ALPHA_FLOOR = 0.05
|
||||
_FOOTPRINT_DILATE = 1
|
||||
|
||||
# Rival marks sharing the bottom-right corner whose OWN glyphs contain "AI生成" as a
|
||||
# substring (Doubao "豆包AI生成", Jimeng's product is a distinct wordmark without that
|
||||
# substring and is excluded on purpose -- "★ 即梦AI" scores low against this template
|
||||
# and does not need the margin. Baidu, Kling, Qwen and Yuanbao's Chinese text runs do
|
||||
# carry the same substring). Without this margin, this generic detector would fire a
|
||||
# SECOND time on every one of their marks, which are already correctly attributed by
|
||||
# their own tuned engines. The plain default margin (0.10, TextMarkConfig's own
|
||||
# default) already separates every rival at DETECT_NCC_THRESHOLD -- see the sweep
|
||||
# table above -- so no per-mark override is needed here.
|
||||
_RIVALS = ("doubao_alpha.png", "qwen_alpha.png", "baidu_alpha.png", "kling_alpha.png", "yuanbao_alpha.png")
|
||||
|
||||
_CONFIG = TextMarkConfig(
|
||||
name="Generic AI-generated label",
|
||||
asset_name="generic_ai_label_alpha.png",
|
||||
corner="br",
|
||||
margin_floor=4,
|
||||
width_frac=WM_WIDTH_FRAC,
|
||||
height_frac=WM_HEIGHT_FRAC,
|
||||
margin_x_frac=MARGIN_RIGHT_FRAC,
|
||||
margin_bottom_frac=MARGIN_BOTTOM_FRAC,
|
||||
max_saturation=MAX_SATURATION,
|
||||
logo_min_luma=LOGO_MIN_LUMA,
|
||||
tophat_delta=TOPHAT_DELTA,
|
||||
morph_open_size=5,
|
||||
detect_min_coverage=DETECT_MIN_COVERAGE,
|
||||
detect_ncc_threshold=DETECT_NCC_THRESHOLD,
|
||||
detect_frontend="tophat",
|
||||
scale_basis="width",
|
||||
rivals=_RIVALS,
|
||||
alpha_width_frac=_ALPHA_WIDTH_FRAC,
|
||||
alpha_height_frac=_ALPHA_HEIGHT_FRAC,
|
||||
min_gw=8,
|
||||
provenance_ncc_factor=PROVENANCE_NCC_FACTOR,
|
||||
)
|
||||
|
||||
|
||||
def _alpha_template() -> NDArray[Any] | None:
|
||||
"""The bundled generic-label alpha template (float [0,1]), or None."""
|
||||
return _text_mark_engine.load_alpha_template(_CONFIG.asset_name)
|
||||
|
||||
|
||||
def _glyph_silhouette() -> NDArray[Any] | None:
|
||||
"""Binary "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 generic-label glyph silhouette against ``box_mask``."""
|
||||
return _text_mark_engine.template_match_score(box_mask, scale_base, _CONFIG)
|
||||
|
||||
|
||||
class GenericAiLabelEngine(TextMarkEngine):
|
||||
"""Detect/localize a brand-less bare "AI生成" text mark (locate -> mask; mask feeds the fill)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(_CONFIG)
|
||||
|
||||
def footprint_mask(
|
||||
self,
|
||||
image: NDArray[Any] | None,
|
||||
*,
|
||||
force: bool = False,
|
||||
dilate: int | None = None,
|
||||
detection: TextMarkDetection | None = None,
|
||||
) -> NDArray[Any] | None:
|
||||
"""Return the detector-aligned generic-label glyph footprint.
|
||||
|
||||
Mirrors :meth:`DoubaoEngine.footprint_mask`: the continuous top-hat response
|
||||
locates the mark, but the rendered alpha resized onto the detector's winning
|
||||
template box is the safer removal mask on textured backgrounds. ``force`` falls
|
||||
back to the shared geometry-box behavior (no trustworthy alignment).
|
||||
"""
|
||||
if force:
|
||||
return super().footprint_mask(image, force=True, dilate=dilate, detection=detection)
|
||||
if image is None or image.size == 0:
|
||||
return None
|
||||
|
||||
image = image_io.to_bgr(image)
|
||||
det = detection if detection is not None else self.detect(image)
|
||||
if not det.detected or det.match_box is None:
|
||||
return super().footprint_mask(image, force=False, dilate=dilate, detection=det)
|
||||
alpha = _alpha_template()
|
||||
if alpha is None:
|
||||
return super().footprint_mask(image, force=False, dilate=dilate, detection=det)
|
||||
|
||||
radius = _FOOTPRINT_DILATE if dilate is None else max(0, dilate)
|
||||
return self._aligned_alpha_mask(
|
||||
image,
|
||||
det,
|
||||
alpha,
|
||||
alpha_floor=_FOOTPRINT_ALPHA_FLOOR,
|
||||
dilate=radius,
|
||||
)
|
||||
@@ -19,7 +19,6 @@ from typing import TYPE_CHECKING, Any
|
||||
from ._internal.watermark_profiles import (
|
||||
DEFAULT_PROFILE,
|
||||
REMOVAL_MODULES,
|
||||
SDXL_ZIMAGE_PROFILE,
|
||||
resolve_adaptive_polish,
|
||||
resolve_effective_profile,
|
||||
resolve_seed,
|
||||
@@ -192,9 +191,9 @@ class InvisibleEngine:
|
||||
def preload(self, *, global_only: bool = False) -> None:
|
||||
"""Eagerly load the pipeline so download progress is visible.
|
||||
|
||||
For ``qwen-zimage``, ``global_only=True`` loads the mandatory Qwen stage
|
||||
and leaves the optional Z-Image and SAM face stack lazy until a face is
|
||||
detected. Other profiles have no optional stage and ignore the flag.
|
||||
For every concrete profile, ``global_only=True`` loads its mandatory
|
||||
global stage and leaves the shared Z-Image and SAM face stack lazy until
|
||||
a face is detected.
|
||||
"""
|
||||
self._remover.preload(global_only=global_only)
|
||||
|
||||
@@ -269,13 +268,18 @@ class InvisibleEngine:
|
||||
|
||||
seed = resolve_seed(seed)
|
||||
effective_profile = resolve_effective_profile(self._remover.configured_profile, vendor)
|
||||
adaptive_polish = resolve_adaptive_polish(adaptive_polish, effective_profile)
|
||||
# Verified text must be the final pixel-writing stage. SDXL normally enables
|
||||
# adaptive polish by profile default, so an unset value becomes False here;
|
||||
# an explicit True remains an error below instead of being silently ignored.
|
||||
adaptive_polish = (
|
||||
False
|
||||
if text_manifest is not None and adaptive_polish is None
|
||||
else resolve_adaptive_polish(adaptive_polish, effective_profile)
|
||||
)
|
||||
|
||||
if fidelity_anchor and text_manifest is None:
|
||||
raise ValueError("fidelity_anchor requires a text manifest")
|
||||
if text_manifest is not None:
|
||||
if effective_profile == SDXL_ZIMAGE_PROFILE:
|
||||
raise ValueError("--text-manifest is not supported by the sdxl-zimage profile")
|
||||
if tile:
|
||||
raise ValueError("--text-manifest is not calibrated with --tile")
|
||||
if max_resolution != 0:
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""OpenArt visible watermark detector/localizer.
|
||||
|
||||
OpenArt (openart.ai) stamps free-plan generations with a semi-transparent white
|
||||
"OpenArt" wordmark (a bowtie/infinity icon followed by the brand name) placed
|
||||
**near the center of the frame**, not in a corner like every other text mark
|
||||
this repository detects. Confirmed from one customer-reported production case
|
||||
(raiw-app, 2026-09-11, 1528x2712 portrait output): the mark sat roughly centered
|
||||
over the subject's torso, and neither C2PA, EXIF AI tags, nor the China AIGC/TC260
|
||||
label were present -- OpenArt apparently ships no machine-readable provenance
|
||||
signal alongside this visible mark, so a bare export was previously reported as
|
||||
``platform: null`` with zero signals. OpenArt's own pricing page lists
|
||||
"Watermark-free" as a paid-plan feature, consistent with every free-plan output
|
||||
carrying this mark; its help center separately confirms free-plan video exports
|
||||
carry a removable watermark toggle. No independent confirmation of placement
|
||||
variance by aspect ratio, of a configurable/opt-out watermark on free tier, or
|
||||
of a paid tier that still embeds C2PA/other metadata is available -- OpenArt
|
||||
requires an account for image generation (no anonymous or API access), so this
|
||||
was not surveyed across multiple generations.
|
||||
|
||||
**Calibration is PROVISIONAL, not measured.** Every other engine in this module
|
||||
ships a recall/precision table from tens to hundreds of hand-labeled real
|
||||
captures (see ``doubao_engine.py``). No such corpus exists for OpenArt: there is
|
||||
no committed real capture (see ``scripts/build_openart_alpha.py`` for why), so
|
||||
``DETECT_NCC_THRESHOLD`` below is set conservatively high rather than measured,
|
||||
specifically because a center-frame locate box (see ``_text_mark_engine.corner
|
||||
== "cc"``) sits over far more varied image content than a corner box, and an
|
||||
uncalibrated false-fire there destroys pixels in the middle of the photo. Do
|
||||
not lower this threshold or add OpenArt to any auto-scan precision claim
|
||||
without a real measured corpus under ``data/calibration/openart/``.
|
||||
|
||||
Detection matches the bundled (procedurally reconstructed, not captured)
|
||||
glyph silhouette against the center-frame candidate; removal is the shared
|
||||
**localize -> fill**, using the default template-free rectangular footprint
|
||||
(no override -- unlike Doubao, this mark does not sit near a frame edge where
|
||||
a solid rectangular fill risks bleeding into un-inpaintable border texture).
|
||||
This module shares :class:`remove_ai_watermarks._text_mark_engine.TextMarkEngine`
|
||||
and supplies only OpenArt's tuned :class:`TextMarkConfig`.
|
||||
"""
|
||||
|
||||
# pyright: reportUnusedFunction=false
|
||||
|
||||
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, TextMarkEngine
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from numpy.typing import NDArray
|
||||
|
||||
# Locate geometry as a fraction of the image SHORT side (portrait is the only
|
||||
# observed carrier so far; "short" keeps the box a sane size on landscape
|
||||
# inputs too, mirroring Doubao's rationale -- see scale_base). Generously
|
||||
# oversized relative to the alpha template below so the scale ladder and NCC
|
||||
# alignment search both have slack; unlike a corner mark this box floats free
|
||||
# in the frame, so accuracy depends entirely on the NCC score, not the corner
|
||||
# anchor.
|
||||
WM_WIDTH_FRAC = 0.55
|
||||
WM_HEIGHT_FRAC = 0.18
|
||||
MARGIN_X_FRAC = 0.0 # unused: "cc" centers horizontally regardless of margin_x_frac
|
||||
MARGIN_BOTTOM_FRAC = 0.0 # unused: "cc" centers vertically regardless of margin_bottom_frac
|
||||
|
||||
# Glyph appearance: a light, low-saturation near-white overlay (white top-hat),
|
||||
# the same rendering class as every other bundled text mark.
|
||||
MAX_SATURATION = 55
|
||||
LOGO_MIN_LUMA = 150
|
||||
TOPHAT_DELTA = 12
|
||||
|
||||
# Inert for the "tophat" front-end (see _text_mark_engine._scan: the coverage
|
||||
# gate only guards the "binary" front-end), kept for the dataclass contract.
|
||||
DETECT_MIN_COVERAGE = 0.02
|
||||
|
||||
# UNCALIBRATED -- see module docstring. Set well above Doubao/Jimeng's measured
|
||||
# 0.45-0.50 specifically because this mark floats in the frame center rather
|
||||
# than a corner, where an unrelated bright/textured subject is far more likely
|
||||
# to produce an incidental top-hat blob than in a corner. Re-measure against a
|
||||
# real captured corpus before relying on this number for a precision claim.
|
||||
DETECT_NCC_THRESHOLD = 0.62
|
||||
|
||||
# No independent provenance signal is known for OpenArt (see module docstring),
|
||||
# so the provenance-relaxed gate can never be legitimately reached today; keep
|
||||
# it at parity with the strict gate rather than inventing a relaxation factor
|
||||
# with nothing to calibrate it against.
|
||||
PROVENANCE_NCC_FACTOR = 1.0
|
||||
|
||||
# Detection-silhouette geometry for the bundled (procedurally reconstructed)
|
||||
# asset -- see scripts/build_openart_alpha.py. The asset canvas is 360x90
|
||||
# (aspect ~4:1). Unlike a solved capture, there is no real photo resolution to
|
||||
# anchor this to, so _ALPHA_NATIVE_WIDTH is simply the round basis the asset's
|
||||
# own fractions are self-consistent against (360/1024 and 90/1024): at
|
||||
# scale_base == _ALPHA_NATIVE_WIDTH the resized template comes back at the
|
||||
# asset's own native pixel size.
|
||||
_ALPHA_NATIVE_WIDTH = 1024
|
||||
_ALPHA_WIDTH_FRAC = 360 / 1024
|
||||
_ALPHA_HEIGHT_FRAC = 90 / 1024
|
||||
|
||||
_CONFIG = TextMarkConfig(
|
||||
name="OpenArt",
|
||||
asset_name="openart_alpha.png",
|
||||
corner="cc",
|
||||
margin_floor=4,
|
||||
width_frac=WM_WIDTH_FRAC,
|
||||
height_frac=WM_HEIGHT_FRAC,
|
||||
margin_x_frac=MARGIN_X_FRAC,
|
||||
margin_bottom_frac=MARGIN_BOTTOM_FRAC,
|
||||
max_saturation=MAX_SATURATION,
|
||||
logo_min_luma=LOGO_MIN_LUMA,
|
||||
tophat_delta=TOPHAT_DELTA,
|
||||
morph_open_size=5,
|
||||
detect_min_coverage=DETECT_MIN_COVERAGE,
|
||||
detect_ncc_threshold=DETECT_NCC_THRESHOLD,
|
||||
provenance_ncc_factor=PROVENANCE_NCC_FACTOR,
|
||||
detect_frontend="tophat",
|
||||
scale_basis="short",
|
||||
alpha_width_frac=_ALPHA_WIDTH_FRAC,
|
||||
alpha_height_frac=_ALPHA_HEIGHT_FRAC,
|
||||
min_gw=12,
|
||||
)
|
||||
|
||||
|
||||
def _alpha_template() -> NDArray[Any] | None:
|
||||
"""The bundled (procedurally reconstructed) OpenArt alpha template (float [0,1]), or None."""
|
||||
return _text_mark_engine.load_alpha_template(_CONFIG.asset_name)
|
||||
|
||||
|
||||
def _glyph_silhouette() -> NDArray[Any] | None:
|
||||
"""Binary "OpenArt" wordmark 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 OpenArt glyph silhouette against ``box_mask``."""
|
||||
return _text_mark_engine.template_match_score(box_mask, scale_base, _CONFIG)
|
||||
|
||||
|
||||
class OpenArtEngine(TextMarkEngine):
|
||||
"""Detect/localize the visible, center-frame OpenArt wordmark (locate -> mask; mask feeds the fill)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(_CONFIG)
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Abstaining source/export-pipeline classification from image pixels.
|
||||
|
||||
This module does not detect or decode SynthID. It labels original-export-looking
|
||||
pixel pipelines as OpenAI, Google, or unknown after metadata may have been
|
||||
removed. Call it explicitly; ``identify`` never imports or runs it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from hashlib import sha256
|
||||
from importlib import import_module
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from numpy.typing import NDArray
|
||||
|
||||
SOURCE_CLASSIFY_EXTRA = "'remove-ai-watermarks[source-classify]'"
|
||||
WEIGHTS_ENV = "RAIW_SOURCE_CLASSIFY_WEIGHTS"
|
||||
WEIGHTS_REPO = "wiltodelta/raiw-source-classify"
|
||||
WEIGHTS_REVISION = "4b8b408d03dac20231d46217977fd164414b6b9e"
|
||||
MODEL_FILE = "source-pipeline-mlp.npz"
|
||||
MODEL_SHA256 = "6430b0738869ff07b6845b7c6149c2a83bc3f3785878082886c241c6f7ca0c4a"
|
||||
CLASSES = ("openai", "google", "unknown")
|
||||
PROVIDERS = CLASSES[:2]
|
||||
FEATURE_WIDTH = 892
|
||||
SPECTRAL_GRID = 16
|
||||
CLAIM = "source/export pipeline classification; not SynthID detection"
|
||||
# NumPy and the training runtime can differ by a few float32 ULPs at a matrix
|
||||
# boundary. Treat values within this tolerance as the stored threshold so the
|
||||
# exported runtime preserves the training runtime's >= decision.
|
||||
THRESHOLD_TOLERANCE = 1e-5
|
||||
|
||||
SourceLabel = Literal["openai", "google", "unknown"]
|
||||
SourceReason = Literal["classified", "abstained", "feature_unavailable"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceClassification:
|
||||
"""One narrow source-pipeline result with an explicit abstention."""
|
||||
|
||||
label: SourceLabel
|
||||
reason: SourceReason
|
||||
scores: dict[str, float]
|
||||
margins: dict[str, float]
|
||||
thresholds: dict[str, float]
|
||||
claim: str = CLAIM
|
||||
model_revision: str = WEIGHTS_REVISION
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"label": self.label,
|
||||
"reason": self.reason,
|
||||
"scores": self.scores,
|
||||
"margins": self.margins,
|
||||
"thresholds": self.thresholds,
|
||||
"claim": self.claim,
|
||||
"model_revision": self.model_revision,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Model:
|
||||
mean: NDArray[Any]
|
||||
scale: NDArray[Any]
|
||||
margins: NDArray[Any]
|
||||
layer0_weight: NDArray[Any]
|
||||
layer0_bias: NDArray[Any]
|
||||
layer1_weight: NDArray[Any]
|
||||
layer1_bias: NDArray[Any]
|
||||
output_weight: NDArray[Any]
|
||||
output_bias: NDArray[Any]
|
||||
|
||||
|
||||
def is_available() -> bool:
|
||||
"""Return whether the lightweight source-classify runtime is installed."""
|
||||
from remove_ai_watermarks.optional_deps import module_available
|
||||
|
||||
return module_available("numpy", "cv2", "huggingface_hub")
|
||||
|
||||
|
||||
def _model_path() -> Path:
|
||||
override = os.environ.get(WEIGHTS_ENV, "").strip()
|
||||
if override:
|
||||
path = Path(override).expanduser()
|
||||
candidate = path / MODEL_FILE if path.is_dir() else path
|
||||
if not candidate.is_file():
|
||||
raise RuntimeError(f"{WEIGHTS_ENV} does not contain {MODEL_FILE}: {path}")
|
||||
return candidate
|
||||
try:
|
||||
hub: Any = import_module("huggingface_hub")
|
||||
except ModuleNotFoundError as error:
|
||||
raise RuntimeError(f"Install {SOURCE_CLASSIFY_EXTRA} to download the source classifier") from error
|
||||
return Path(
|
||||
hub.hf_hub_download(
|
||||
repo_id=WEIGHTS_REPO,
|
||||
filename=MODEL_FILE,
|
||||
revision=WEIGHTS_REVISION,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=2)
|
||||
def _load_model(path: Path, expected_sha256: str | None = MODEL_SHA256) -> _Model:
|
||||
import numpy as np
|
||||
|
||||
if expected_sha256 is not None:
|
||||
digest = sha256(path.read_bytes()).hexdigest()
|
||||
if digest != expected_sha256:
|
||||
raise ValueError(f"model SHA-256 mismatch: expected {expected_sha256}, got {digest}")
|
||||
required = {
|
||||
"classes",
|
||||
"kind",
|
||||
"mean",
|
||||
"scale",
|
||||
"margins",
|
||||
"layer0_weight",
|
||||
"layer0_bias",
|
||||
"layer1_weight",
|
||||
"layer1_bias",
|
||||
"output_weight",
|
||||
"output_bias",
|
||||
"activation",
|
||||
}
|
||||
with np.load(path, allow_pickle=False) as payload:
|
||||
if set(payload.files) != required:
|
||||
raise ValueError(f"model fields must be exactly {sorted(required)}")
|
||||
if tuple(str(value) for value in payload["classes"]) != CLASSES:
|
||||
raise ValueError(f"model classes must be {CLASSES}")
|
||||
if str(payload["kind"]) != "fused":
|
||||
raise ValueError("model kind must be fused")
|
||||
if str(payload["activation"]) != "gelu_exact":
|
||||
raise ValueError("model activation must be gelu_exact")
|
||||
arrays = {
|
||||
name: np.asarray(
|
||||
payload[name],
|
||||
dtype=np.float64 if name in {"mean", "scale", "margins"} else np.float32,
|
||||
)
|
||||
for name in required - {"classes", "kind", "activation"}
|
||||
}
|
||||
shapes = {
|
||||
"mean": (FEATURE_WIDTH,),
|
||||
"scale": (FEATURE_WIDTH,),
|
||||
"margins": (len(PROVIDERS),),
|
||||
"layer0_weight": (128, FEATURE_WIDTH),
|
||||
"layer0_bias": (128,),
|
||||
"layer1_weight": (64, 128),
|
||||
"layer1_bias": (64,),
|
||||
"output_weight": (len(CLASSES), 64),
|
||||
"output_bias": (len(CLASSES),),
|
||||
}
|
||||
for name, expected in shapes.items():
|
||||
if arrays[name].shape != expected:
|
||||
raise ValueError(f"model field {name} must have shape {expected}")
|
||||
if not np.all(np.isfinite(arrays[name])):
|
||||
raise ValueError(f"model field {name} must be finite")
|
||||
if np.any(arrays["scale"] <= 0) or np.any(arrays["margins"] < 0):
|
||||
raise ValueError("model scales must be positive and margins non-negative")
|
||||
return _Model(**arrays)
|
||||
|
||||
|
||||
def _gelu_exact(values: NDArray[Any]) -> NDArray[Any]:
|
||||
import numpy as np
|
||||
|
||||
flat = values.ravel()
|
||||
errors = np.fromiter((math.erf(float(value) / math.sqrt(2.0)) for value in flat), dtype=np.float32)
|
||||
return (0.5 * flat * (1.0 + errors)).reshape(values.shape).astype(np.float32)
|
||||
|
||||
|
||||
def _scores(features: NDArray[Any], model: _Model) -> NDArray[Any]:
|
||||
import numpy as np
|
||||
|
||||
values = np.asarray((features - model.mean) / model.scale, dtype=np.float32)
|
||||
hidden0 = _gelu_exact(model.layer0_weight @ values + model.layer0_bias)
|
||||
hidden1 = _gelu_exact(model.layer1_weight @ hidden0 + model.layer1_bias)
|
||||
return np.asarray(model.output_weight @ hidden1 + model.output_bias, dtype=np.float64)
|
||||
|
||||
|
||||
def _result(raw_scores: NDArray[Any], thresholds: NDArray[Any]) -> SourceClassification:
|
||||
import numpy as np
|
||||
|
||||
margins = np.empty(len(PROVIDERS), dtype=np.float64)
|
||||
for index in range(len(PROVIDERS)):
|
||||
competitors = np.delete(raw_scores, index)
|
||||
margins[index] = raw_scores[index] - float(np.max(competitors))
|
||||
excess = margins - thresholds
|
||||
best = int(np.argmax(excess))
|
||||
accepted = bool(excess[best] >= -THRESHOLD_TOLERANCE)
|
||||
label: SourceLabel = PROVIDERS[best] if accepted else "unknown"
|
||||
return SourceClassification(
|
||||
label=label,
|
||||
reason="classified" if accepted else "abstained",
|
||||
scores={name: float(raw_scores[index]) for index, name in enumerate(CLASSES)},
|
||||
margins={name: float(margins[index]) for index, name in enumerate(PROVIDERS)},
|
||||
thresholds={name: float(thresholds[index]) for index, name in enumerate(PROVIDERS)},
|
||||
)
|
||||
|
||||
|
||||
def _unavailable(model: _Model) -> SourceClassification:
|
||||
return SourceClassification(
|
||||
label="unknown",
|
||||
reason="feature_unavailable",
|
||||
scores={},
|
||||
margins={},
|
||||
thresholds={name: float(model.margins[index]) for index, name in enumerate(PROVIDERS)},
|
||||
)
|
||||
|
||||
|
||||
def classify_source(source: str | os.PathLike[str]) -> SourceClassification:
|
||||
"""Classify an original-looking image export, abstaining as ``unknown``.
|
||||
|
||||
The result describes a complete source/export pipeline. It is not evidence
|
||||
that SynthID is present or absent.
|
||||
"""
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from remove_ai_watermarks import image_io
|
||||
from remove_ai_watermarks._internal.forensic_124d import image_features
|
||||
from remove_ai_watermarks._internal.source_spectral import feature_from_image, make_geometry
|
||||
|
||||
model = _load_model(_model_path(), expected_sha256=MODEL_SHA256)
|
||||
image_io._register_heif() # pyright: ignore[reportPrivateUsage]
|
||||
with Image.open(source) as opened:
|
||||
image = opened.convert("RGB")
|
||||
pixels = np.asarray(image, dtype=np.uint8)
|
||||
forensic = image_features(pixels)
|
||||
if forensic is None:
|
||||
return _unavailable(model)
|
||||
spectral = feature_from_image(image, make_geometry(grid=SPECTRAL_GRID))
|
||||
features = np.concatenate((forensic, spectral))
|
||||
if features.shape != (FEATURE_WIDTH,) or not np.all(np.isfinite(features)):
|
||||
return _unavailable(model)
|
||||
return _result(_scores(features, model), model.margins)
|
||||
@@ -31,6 +31,11 @@ Entries:
|
||||
- ``liblib`` -- LiblibAI "LiblibAI" wordmark, bottom-center.
|
||||
- ``liblib_pill`` -- LiblibAI compact "AI生成" pill, top-left.
|
||||
- ``microsoft`` -- one measured Microsoft white AI-badge variant, top-right.
|
||||
- ``generic_ai_label`` -- brand-less bare "AI生成" TC260 label (no vendor wordmark),
|
||||
bottom-right; the fallback for a compliance stamp not tied to any tuned mark above.
|
||||
- ``openart`` -- OpenArt "OpenArt" wordmark (bowtie/infinity icon + brand name),
|
||||
frame-center; UNCALIBRATED (one confirmed real case, no captured corpus), see
|
||||
``openart_engine.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -450,6 +455,8 @@ _ENGINE_CLASS: dict[str, tuple[str, str]] = {
|
||||
"liblib": ("liblib_engine", "LibLibEngine"),
|
||||
"liblib_pill": ("liblib_engine", "LibLibPillEngine"),
|
||||
"microsoft": ("microsoft_engine", "MicrosoftEngine"),
|
||||
"generic_ai_label": ("generic_ai_label_engine", "GenericAiLabelEngine"),
|
||||
"openart": ("openart_engine", "OpenArtEngine"),
|
||||
}
|
||||
|
||||
|
||||
@@ -786,6 +793,41 @@ _REGISTRY: tuple[KnownMark, ...] = (
|
||||
platform="Microsoft (visible top-right AI badge detected)",
|
||||
provenance_platform_tokens=("microsoft",),
|
||||
),
|
||||
# Brand-less fallback: a bare "AI生成" TC260 label with no vendor wordmark in
|
||||
# front of it (OS/gallery-level AI-edit compliance stamps -- vivo and Xiaomi
|
||||
# Gallery confirmed, see generic_ai_label_engine's module docstring for sources).
|
||||
# label_regime=None and provenance_signals=(): unlike every other `_text_mark`
|
||||
# row, this mark can NEVER declare a tc260_producer_code (there is no one
|
||||
# producer to name), and test_api.py's TestVisibleProvenance invariant requires
|
||||
# every "tc260"-regime mark to declare one except the two companion pills that
|
||||
# inherit a sibling's identity. This mark is neither -- it genuinely cannot be
|
||||
# attributed -- so it opts out of the "tc260" regime tag the way Samsung and
|
||||
# Microsoft already do for their own non-attributable device stamps, and out of
|
||||
# "aigc" provenance relaxation (mirrors provenance_ncc_factor=1.0 in the engine:
|
||||
# never relaxed).
|
||||
_text_mark(
|
||||
"generic_ai_label",
|
||||
"Generic bare AI生成 text (no vendor wordmark)",
|
||||
"bottom-right",
|
||||
platform="Generic AI-generated content label (bare 'AI生成' mark; specific vendor undetermined)",
|
||||
manufacturer="unknown",
|
||||
label_regime=None,
|
||||
provenance_signals=(),
|
||||
),
|
||||
# Frame-center, not a corner -- see the "cc" corner and openart_engine.py's module
|
||||
# docstring for the single confirmed carrier and why this row is UNCALIBRATED.
|
||||
# No China TC260 label and no other known metadata signal: label_regime=None,
|
||||
# provenance_signals=() mirror Microsoft's non-attributable-device shape rather
|
||||
# than the TC260 default every other _text_mark row above inherits.
|
||||
_text_mark(
|
||||
"openart",
|
||||
"OpenArt wordmark",
|
||||
"frame-center",
|
||||
platform="OpenArt (visible OpenArt mark detected)",
|
||||
manufacturer="openart",
|
||||
label_regime=None,
|
||||
provenance_signals=(),
|
||||
),
|
||||
# Same product as the Jimeng wordmark -- the one pair that cross-relaxes.
|
||||
KnownMark(
|
||||
"jimeng_pill",
|
||||
|
||||
@@ -23,6 +23,10 @@ class TestTopLevelExports:
|
||||
assert raiw.remove_visible_detailed is api.remove_visible_detailed
|
||||
assert raiw.VisibleRemovalResult is api.VisibleRemovalResult
|
||||
assert raiw.visible_provenance is api.visible_provenance
|
||||
from remove_ai_watermarks import source_classify
|
||||
|
||||
assert raiw.classify_source is source_classify.classify_source
|
||||
assert raiw.SourceClassification is source_classify.SourceClassification
|
||||
|
||||
def test_unknown_attribute_raises(self):
|
||||
with pytest.raises(AttributeError):
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -112,3 +113,36 @@ def test_packaged_snapshot_preserves_published_resolution_apis():
|
||||
f"upstream softbinding-algorithm-list resolution-API set changed: {sorted(with_apis)}; "
|
||||
"revisit docs/c2pa-resolution-research.md before regenerating"
|
||||
)
|
||||
|
||||
|
||||
class TestReadBytesAuth:
|
||||
"""api.github.com rate-limits unauthenticated requests to 60/hour PER SOURCE IP,
|
||||
which CI runners exhaust fast since many workflows share GitHub's runner IP pool
|
||||
(observed: --check failing with 403 on every platform in one push). A GITHUB_TOKEN
|
||||
raises that to 5000/hour; these pin that the header is sent to api.github.com when
|
||||
a token is present, withheld when it is not, and never sent to the unrelated raw
|
||||
CDN host even with a token set."""
|
||||
|
||||
def _urlopen_headers(self, source: str) -> dict[str, str]:
|
||||
response = MagicMock()
|
||||
response.__enter__.return_value.read.return_value = b"{}"
|
||||
with patch("scripts.sync_c2pa_soft_bindings.urlopen", return_value=response) as mock_urlopen:
|
||||
sync._read_bytes(source)
|
||||
request = mock_urlopen.call_args[0][0]
|
||||
return dict(request.headers)
|
||||
|
||||
def test_sends_bearer_token_to_github_api(self, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "test-token")
|
||||
headers = self._urlopen_headers("https://api.github.com/repos/example/example/commits")
|
||||
assert headers.get("Authorization") == "Bearer test-token"
|
||||
|
||||
def test_omits_authorization_without_a_token(self, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
headers = self._urlopen_headers("https://api.github.com/repos/example/example/commits")
|
||||
assert "Authorization" not in headers
|
||||
|
||||
def test_never_sends_the_token_to_the_raw_cdn_host(self, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "test-token")
|
||||
headers = self._urlopen_headers("https://raw.githubusercontent.com/example/example/main/x.json")
|
||||
assert "Authorization" not in headers
|
||||
@@ -80,11 +80,14 @@ def test_web_results_survive_later_browser_failure(monkeypatch, tmp_path):
|
||||
"slot": {},
|
||||
"rows": [{"artifact_id": "first", "upload_path": "one"}, {"artifact_id": "second", "upload_path": "two"}],
|
||||
}
|
||||
results = {"rows": [{"watermark_result": None}, {"watermark_result": None}]}
|
||||
results = {
|
||||
"manifest_sha256": "0" * 64,
|
||||
"rows": [{"watermark_result": None}, {"watermark_result": None}],
|
||||
}
|
||||
saved = []
|
||||
monkeypatch.setattr(web.oracles, "verify_batch", lambda *a, **k: {})
|
||||
monkeypatch.setattr(web.oracles, "load_batch", lambda *a: (manifest, results))
|
||||
monkeypatch.setattr(web, "proxy_settings", lambda *a: None)
|
||||
monkeypatch.setattr(web, "proxy_settings", lambda *a, **k: None)
|
||||
monkeypatch.setattr(web.oracles, "record_result", lambda *a, **k: saved.append(k))
|
||||
|
||||
def submit(*args, **kwargs):
|
||||
|
||||
@@ -113,18 +113,22 @@ class TestGeminiEngine:
|
||||
# ── Removal: localize -> fill ───────────────────────────────────────
|
||||
|
||||
|
||||
def _composite_sparkle(engine, bg_value: int = 160, size: int = 1400):
|
||||
"""Composite the captured sparkle at the configured position on a textured
|
||||
mid-tone image (texture so the filled footprint no longer NCC-matches the
|
||||
template). Returns ``(watermarked_uint8, (x, y, w, h))``."""
|
||||
yy, xx = np.mgrid[0:size, 0:size].astype(np.float32)
|
||||
base = bg_value + 18 * np.sin(xx / 40.0) + 14 * np.cos(yy / 55.0)
|
||||
img = np.clip(np.stack([base, base * 0.97, base * 1.03], axis=-1), 0, 255)
|
||||
def _composite_sparkle(engine, bg_value: int = 160, size: int = 1400, *, alpha_scale: float = 1.0, background=None):
|
||||
"""Composite the captured sparkle at the configured position. Defaults to a
|
||||
textured mid-tone image (texture so the filled footprint no longer NCC-matches
|
||||
the template); pass ``background`` to blend onto an arbitrary image instead
|
||||
(e.g. photographic noise). Returns ``(watermarked_uint8, (x, y, w, h))``."""
|
||||
if background is not None:
|
||||
img = background.astype(np.float32)
|
||||
else:
|
||||
yy, xx = np.mgrid[0:size, 0:size].astype(np.float32)
|
||||
base = bg_value + 18 * np.sin(xx / 40.0) + 14 * np.cos(yy / 55.0)
|
||||
img = np.clip(np.stack([base, base * 0.97, base * 1.03], axis=-1), 0, 255)
|
||||
config = get_watermark_config(size, size)
|
||||
x, y = config.get_position(size, size)
|
||||
alpha = engine.get_interpolated_alpha(config.logo_size)
|
||||
ah, aw = alpha.shape[:2]
|
||||
a = alpha[:, :, None]
|
||||
a = np.clip(alpha * alpha_scale, 0.0, 1.0)[:, :, None]
|
||||
roi = img[y : y + ah, x : x + aw]
|
||||
img[y : y + ah, x : x + aw] = a * 255.0 + (1.0 - a) * roi
|
||||
return np.clip(img, 0, 255).astype(np.uint8), (x, y, aw, ah)
|
||||
@@ -338,6 +342,74 @@ class TestSparkleFalsePositiveGate:
|
||||
assert not det.detected
|
||||
|
||||
|
||||
class TestDecorativeGlyphFalsePositive:
|
||||
"""Incident 2026-08-27, documented in full in ``docs/module-internals.md``
|
||||
(Gemini sparkle section): a decorative glyph in stylized typography clears the
|
||||
FP gate the same way a genuine weak sparkle does, and no computed signal
|
||||
(margin, gradient, saturation) currently separates the two -- see the
|
||||
counter-example below. This is pinned as a KNOWN, unresolved false-positive
|
||||
class rather than silently "fixed" with an untested threshold nudge.
|
||||
"""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup_engine(self):
|
||||
self.engine = GeminiEngine()
|
||||
|
||||
def _decorative_glyph_scene(self, size: int = 1200) -> np.ndarray:
|
||||
img = np.full((size, size, 3), 25, dtype=np.float32)
|
||||
config = get_watermark_config(size, size)
|
||||
x, y = config.get_position(size, size)
|
||||
s = config.logo_size
|
||||
cx, cy = x + s // 2, y + s // 2
|
||||
# A neon-glow letterform stroke passing close to the glyph, as in the
|
||||
# reported book-cover title.
|
||||
cv2.ellipse(img, (cx, cy), (35, 60), 20, 0, 360, (60, 220, 60), thickness=14)
|
||||
img = cv2.GaussianBlur(img, (0, 0), 3)
|
||||
# A sharp four-point diamond/glint accent: same silhouette family as the
|
||||
# engine's own sparkle template, bright near-white core.
|
||||
canvas = np.zeros((size, size), dtype=np.float32)
|
||||
outer, inner = 18.0, 18.0 * 0.28
|
||||
points = []
|
||||
for i in range(8):
|
||||
angle = np.pi / 2 * (i // 2) + (0 if i % 2 == 0 else np.pi / 4)
|
||||
radius = outer if i % 2 == 0 else inner
|
||||
points.append((cx + radius * np.cos(angle), cy + radius * np.sin(angle)))
|
||||
hull = cv2.convexHull(np.array(points, dtype=np.int32))
|
||||
cv2.fillConvexPoly(canvas, hull, 1.0)
|
||||
canvas = cv2.GaussianBlur(canvas, (0, 0), 1.5)
|
||||
alpha = np.clip(canvas, 0.0, 1.0)[:, :, None]
|
||||
img = alpha * 255.0 + (1.0 - alpha) * img
|
||||
return np.clip(img, 0, 255).astype(np.uint8)
|
||||
|
||||
def test_decorative_glyph_scores_just_under_the_fp_gate(self):
|
||||
"""Pins the confirmed incident signature: a borderline score that the FP
|
||||
gate is not currently able to demote. If this starts failing because the
|
||||
score moved, re-derive the fixture rather than loosening the assertion --
|
||||
the incident value was 0.64."""
|
||||
image = self._decorative_glyph_scene()
|
||||
det = self.engine.detect_watermark(image)
|
||||
assert 0.55 <= det.confidence < self.engine._SPARKLE_FP_CONF
|
||||
assert det.detected # confirmed false positive: not demoted by the gate
|
||||
|
||||
def test_naive_tightening_would_also_reject_a_genuine_weak_sparkle(self):
|
||||
"""Counter-example blocking a naive fix: a genuine weak Gemini sparkle
|
||||
composited over noisy, textured (photographic) content lands in the same
|
||||
confidence band with the same clean-margin, clean-gradient, near-white-core
|
||||
signature as the decorative glyph above. Any tightening that demotes the
|
||||
glyph fixture must not demote this one too without a real precision/recall
|
||||
measurement backing it (see the class docstring and ``docs/module-internals.md``).
|
||||
"""
|
||||
size = 1400
|
||||
rng = np.random.default_rng(1)
|
||||
noise = rng.normal(140, 75, (size, size, 3)).astype(np.float32)
|
||||
noise = cv2.GaussianBlur(noise, (0, 0), 1.2)
|
||||
image, _ = _composite_sparkle(self.engine, size=size, alpha_scale=0.7, background=noise)
|
||||
|
||||
det = self.engine.detect_watermark(image)
|
||||
assert 0.55 <= det.confidence < self.engine._SPARKLE_FP_CONF
|
||||
assert det.detected # a real (weak) sparkle correctly kept, not demoted
|
||||
|
||||
|
||||
class TestCornerPromotion:
|
||||
"""Issue #36: a small sparkle in the corner must not be lost to a larger decoy.
|
||||
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Tests for the generic bare "AI生成" text-mark engine (brand-less TC260 fallback).
|
||||
|
||||
No real sample of this mark is committed -- the confirmed production bug this engine
|
||||
was built for (a bottom-right "AI生成" strip with no vendor wordmark, left completely
|
||||
unremoved because no existing tuned detector matched it) carries no customer image
|
||||
bytes in any repository. Detection/removal is exercised against a watermark
|
||||
synthesized from the engine's own font-rendered alpha asset, mirroring every other
|
||||
text-mark engine's test pattern (see test_doubao_engine.py / test_jimeng_engine.py).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from remove_ai_watermarks import watermark_registry as registry
|
||||
from remove_ai_watermarks.generic_ai_label_engine import (
|
||||
_ALPHA_HEIGHT_FRAC,
|
||||
_ALPHA_NATIVE_WIDTH,
|
||||
_ALPHA_WIDTH_FRAC,
|
||||
DETECT_NCC_THRESHOLD,
|
||||
GenericAiLabelEngine,
|
||||
_alpha_template,
|
||||
_glyph_silhouette,
|
||||
_template_match_score,
|
||||
)
|
||||
|
||||
|
||||
def _compose(w: int, h: int, bg: float = 100.0):
|
||||
"""Composite the rendered alpha (scaled to width ``w``) onto a flat bg.
|
||||
Returns ``(watermarked_uint8, mark_bool_mask)``."""
|
||||
img = np.full((h, w, 3), bg, np.float32)
|
||||
at = _alpha_template()
|
||||
gw, gh = int(_ALPHA_WIDTH_FRAC * w), int(_ALPHA_HEIGHT_FRAC * w)
|
||||
margin = int(0.015 * w)
|
||||
ax = w - margin - gw
|
||||
ay = h - margin - gh
|
||||
amap = np.zeros((h, w), np.float32)
|
||||
amap[ay : ay + gh, ax : ax + gw] = cv2.resize(at, (gw, gh))
|
||||
a3 = amap[:, :, None]
|
||||
wm = (a3 * 255.0 + (1 - a3) * img).clip(0, 255).astype(np.uint8)
|
||||
return wm, amap > 0.2
|
||||
|
||||
|
||||
class TestLocate:
|
||||
def test_box_anchored_bottom_right(self):
|
||||
eng = GenericAiLabelEngine()
|
||||
img = np.zeros((2048, 2048, 3), np.uint8)
|
||||
loc = eng.locate(img)
|
||||
assert 2048 - (loc.x + loc.w) < int(2048 * 0.05)
|
||||
assert 2048 - (loc.y + loc.h) < int(2048 * 0.05)
|
||||
|
||||
def test_box_scales_with_width(self):
|
||||
eng = GenericAiLabelEngine()
|
||||
small = eng.locate(np.zeros((1024, 1024, 3), np.uint8))
|
||||
large = eng.locate(np.zeros((2048, 2048, 3), np.uint8))
|
||||
assert large.w == pytest.approx(small.w * 2, rel=0.1)
|
||||
|
||||
|
||||
class TestDetect:
|
||||
def test_clean_gradient_not_detected(self):
|
||||
eng = GenericAiLabelEngine()
|
||||
ramp = np.tile(np.linspace(0, 255, 1024, dtype=np.uint8), (1024, 1))
|
||||
img = cv2.cvtColor(ramp, cv2.COLOR_GRAY2BGR)
|
||||
assert not eng.detect(img).detected
|
||||
|
||||
def test_solid_blob_corner_not_detected(self):
|
||||
"""A bright blob is not the glyph shape -> low correlation, not detected."""
|
||||
eng = GenericAiLabelEngine()
|
||||
img = np.zeros((1024, 1024, 3), np.uint8)
|
||||
x, y, bw, bh = eng.locate(img).bbox
|
||||
img[y + bh // 4 : y + bh * 3 // 4, x : x + bw // 2] = 200
|
||||
assert not eng.detect(img).detected
|
||||
|
||||
def test_silhouette_loads(self):
|
||||
sil = _glyph_silhouette()
|
||||
assert sil is not None
|
||||
assert set(np.unique(sil)).issubset({0, 255})
|
||||
|
||||
def test_match_score_shape_sensitive(self):
|
||||
"""The rendered glyph silhouette correlates with itself, not with a filled block."""
|
||||
sil = _glyph_silhouette()
|
||||
h, w = sil.shape
|
||||
box = np.zeros((h + 8, int(w / _ALPHA_WIDTH_FRAC * 0.2) + w), np.uint8)
|
||||
box[4 : 4 + h, 4 : 4 + w] = sil
|
||||
assert _template_match_score(box, _ALPHA_NATIVE_WIDTH) >= DETECT_NCC_THRESHOLD
|
||||
solid = np.full_like(box, 255)
|
||||
assert _template_match_score(solid, _ALPHA_NATIVE_WIDTH) < DETECT_NCC_THRESHOLD
|
||||
|
||||
def test_detects_synthetic_mark(self):
|
||||
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
|
||||
eng = GenericAiLabelEngine()
|
||||
det = eng.detect(wm)
|
||||
assert det.detected
|
||||
assert det.confidence >= DETECT_NCC_THRESHOLD
|
||||
|
||||
def test_small_image_guarded_from_false_positive(self):
|
||||
"""Below the shared minimum short side, detection is skipped outright (the
|
||||
same small-image NCC-noise guard every text mark inherits from
|
||||
TextMarkEngine._scan)."""
|
||||
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
|
||||
eng = GenericAiLabelEngine()
|
||||
assert eng.detect(wm).detected # native: real mark detected
|
||||
assert not eng.detect(cv2.resize(wm, (150, 150))).detected # below guard: suppressed
|
||||
|
||||
|
||||
class TestRivalMarks:
|
||||
"""The generic template must not double-fire on a mark already attributed to its
|
||||
own tuned brand engine -- every rival below contains the literal substring
|
||||
"AI生成" in its own glyphs, which is exactly the collision the ``rivals`` guard
|
||||
exists to prevent. See generic_ai_label_engine's DETECT_NCC_THRESHOLD comment for
|
||||
the calibration this pins."""
|
||||
|
||||
@pytest.mark.parametrize("key", ["doubao", "qwen", "baidu", "kling", "yuanbao"])
|
||||
def test_does_not_fire_on_rival_brand_mark(self, key: str):
|
||||
from remove_ai_watermarks import watermark_registry as wr
|
||||
|
||||
marked = wr.get_mark(key)
|
||||
# Build the rival's own canonical composite via the shared example generator,
|
||||
# so this test does not need its own copy of each mark's geometry.
|
||||
from scripts.render_visible_examples import base_photo, stamp_image_mark
|
||||
|
||||
base = base_photo(1536, 1152, seed=13)
|
||||
out = stamp_image_mark(key, base)
|
||||
assert out is not None, key
|
||||
img, _box = out
|
||||
assert marked.detect(img).detected, f"{key}: sanity check -- its own engine should still fire"
|
||||
assert not GenericAiLabelEngine().detect(img).detected, f"generic engine double-fired on {key}"
|
||||
|
||||
|
||||
class TestAlphaAsset:
|
||||
def test_alpha_asset_loads(self):
|
||||
at = _alpha_template()
|
||||
assert at is not None
|
||||
assert at.dtype.kind == "f"
|
||||
assert float(at.min()) >= 0.0
|
||||
assert float(at.max()) <= 1.0
|
||||
|
||||
|
||||
class TestFootprintMaskAndRemoval:
|
||||
def test_footprint_mask_in_bottom_right(self):
|
||||
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
|
||||
mask = GenericAiLabelEngine().footprint_mask(wm)
|
||||
assert mask is not None
|
||||
assert mask.shape == wm.shape[:2]
|
||||
ys, xs = np.where(mask > 0)
|
||||
assert ys.mean() > wm.shape[0] / 2
|
||||
assert xs.mean() > wm.shape[1] / 2
|
||||
|
||||
def test_removes_synthetic_mark(self):
|
||||
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
|
||||
assert GenericAiLabelEngine().detect(wm).detected
|
||||
out, region = registry.get_mark("generic_ai_label").remove(wm, backend="cv2")
|
||||
assert region is not None
|
||||
assert not GenericAiLabelEngine().detect(out).detected
|
||||
|
||||
def test_far_region_untouched(self):
|
||||
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
|
||||
out, _ = registry.get_mark("generic_ai_label").remove(wm, backend="cv2")
|
||||
h, w = wm.shape[:2]
|
||||
assert np.array_equal(wm[: h // 2, : w // 2], out[: h // 2, : w // 2])
|
||||
|
||||
|
||||
class TestDegenerateAndChannelInputs:
|
||||
"""footprint_mask must not crash on degenerate sizes or non-3-channel inputs."""
|
||||
|
||||
@pytest.mark.parametrize(("w", "h"), [(2048, 1), (1, 2048), (2048, 8)])
|
||||
def test_wide_short_does_not_raise(self, w, h):
|
||||
eng = GenericAiLabelEngine()
|
||||
img = np.zeros((h, w, 3), np.uint8)
|
||||
mask = eng.footprint_mask(img, force=True)
|
||||
assert mask is None or mask.shape == (h, w)
|
||||
|
||||
def test_grayscale_2d_does_not_raise(self):
|
||||
eng = GenericAiLabelEngine()
|
||||
gray = np.zeros((2048, 2048), np.uint8)
|
||||
mask = eng.footprint_mask(gray, force=True)
|
||||
assert mask is None or mask.shape == (2048, 2048)
|
||||
|
||||
def test_bgra_4channel_does_not_raise(self):
|
||||
eng = GenericAiLabelEngine()
|
||||
bgra = np.zeros((2048, 2048, 4), np.uint8)
|
||||
mask = eng.footprint_mask(bgra, force=True)
|
||||
assert mask is None or mask.shape == (2048, 2048)
|
||||
|
||||
|
||||
class TestRegistryWiring:
|
||||
"""The mark must actually be reachable through the shared registry, the same way
|
||||
identify/remove_auto_marks reach every other mark."""
|
||||
|
||||
def test_registered_with_generic_key(self):
|
||||
mark = registry.get_mark("generic_ai_label")
|
||||
assert mark.key == "generic_ai_label"
|
||||
assert mark.in_auto
|
||||
|
||||
def test_participates_in_detect_marks(self):
|
||||
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
|
||||
results = registry.detect_marks(wm)
|
||||
hit = next(r for r in results if r.key == "generic_ai_label")
|
||||
assert hit.detected
|
||||
@@ -1212,6 +1212,21 @@ class TestIdentifyVisibleTextMarks:
|
||||
assert "Jimeng" in r.platform
|
||||
assert any(s.name == "visible_jimeng" for s in r.signals)
|
||||
|
||||
def test_openart_promotes_to_medium(self, tmp_clean_png: Path):
|
||||
"""OpenArt has no known TC260/C2PA/EXIF signal (see openart_engine.py's
|
||||
module docstring), so the visible mark is the ONLY route to a platform
|
||||
attribution -- this is the exact production gap the mark closes: a bare
|
||||
OpenArt export previously reported `platform: null` with zero signals."""
|
||||
det = self._detection("openart", "OpenArt wordmark", 0.85)
|
||||
with patch(_SPARKLE_TARGET, return_value=None), patch(_TEXT_MARKS_TARGET, return_value=[det]):
|
||||
r = identify(tmp_clean_png, check_visible=True)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.confidence == "medium"
|
||||
assert r.platform is not None
|
||||
assert "OpenArt" in r.platform
|
||||
signal = next(s for s in r.signals if s.name == "visible_openart")
|
||||
assert signal.confidence == "medium"
|
||||
|
||||
def test_check_visible_false_skips_text_marks(self, tmp_clean_png: Path):
|
||||
det = self._detection("doubao", "Doubao 豆包AI生成 text", 0.99)
|
||||
with patch(_SPARKLE_TARGET, return_value=None), patch(_TEXT_MARKS_TARGET, return_value=[det]) as mock:
|
||||
|
||||
@@ -64,7 +64,6 @@ class TestVerifiedTextMode:
|
||||
manifest = tmp_path / "manifest.json"
|
||||
manifest.write_text("{}", encoding="utf-8")
|
||||
cases = (
|
||||
("sdxl-zimage", {}, "not supported by the sdxl-zimage profile"),
|
||||
("qwen-zimage", {"tile": True}, "not calibrated with --tile"),
|
||||
("qwen-zimage", {"max_resolution": 1024}, "max-resolution 0"),
|
||||
("qwen-zimage", {"humanize": 1.0}, "humanize=0"),
|
||||
@@ -87,19 +86,6 @@ class TestVerifiedTextMode:
|
||||
fidelity_anchor=True,
|
||||
)
|
||||
|
||||
def test_auto_rejects_google_text_manifest_before_model_loading(self, tmp_path):
|
||||
import pytest
|
||||
|
||||
manifest = tmp_path / "manifest.json"
|
||||
manifest.write_text("{}", encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError, match="not supported by the sdxl-zimage profile"):
|
||||
self._engine("auto").remove_watermark(
|
||||
tmp_path / "unused.png",
|
||||
text_manifest=manifest,
|
||||
vendor="google",
|
||||
)
|
||||
|
||||
def test_loads_and_forwards_verified_manifest(self, tmp_path, monkeypatch):
|
||||
import json
|
||||
|
||||
@@ -137,9 +123,10 @@ class TestVerifiedTextMode:
|
||||
engine._remover.remove_watermark = fake_remove
|
||||
monkeypatch.setattr(region_eraser, "lama_available", lambda: True)
|
||||
|
||||
engine.remove_watermark(source, output, text_manifest=manifest)
|
||||
engine.remove_watermark(source, output, text_manifest=manifest, vendor="google")
|
||||
|
||||
assert seen["text_manifest"].lines[0].text == "Exact"
|
||||
assert seen["vendor"] == "google"
|
||||
# Leak-safe default since 0.27.1: the global 15% donor blend is OFF unless
|
||||
# explicitly requested (measured to return detector-visible OpenAI SynthID
|
||||
# on poster-scale manifests; see docs/text-protection-research.md).
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
"""Tests for the OpenArt visible-watermark engine (localize -> fill).
|
||||
|
||||
No real captured OpenArt sample is committed (see ``openart_engine.py``'s module
|
||||
docstring for why), so every fixture here is a synthetic composite of the
|
||||
bundled, procedurally reconstructed alpha asset -- there is no
|
||||
``TestRealSample`` analogue to ``test_doubao_engine.py``'s skip-if-absent class.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from remove_ai_watermarks import watermark_registry as registry
|
||||
from remove_ai_watermarks.openart_engine import (
|
||||
_ALPHA_HEIGHT_FRAC,
|
||||
_ALPHA_NATIVE_WIDTH,
|
||||
_ALPHA_WIDTH_FRAC,
|
||||
DETECT_NCC_THRESHOLD,
|
||||
OpenArtEngine,
|
||||
_alpha_template,
|
||||
_glyph_silhouette,
|
||||
_template_match_score,
|
||||
)
|
||||
|
||||
|
||||
def _compose(w: int, h: int, bg: float = 100.0):
|
||||
"""Composite the bundled alpha (scaled to the image's short side) onto a flat
|
||||
bg, centered in the frame. Returns ``(watermarked_uint8, mark_bool_mask)``."""
|
||||
img = np.full((h, w, 3), bg, np.float32)
|
||||
at = _alpha_template()
|
||||
base = min(w, h)
|
||||
gw, gh = int(_ALPHA_WIDTH_FRAC * base), int(_ALPHA_HEIGHT_FRAC * base)
|
||||
ax = (w - gw) // 2
|
||||
ay = (h - gh) // 2
|
||||
amap = np.zeros((h, w), np.float32)
|
||||
amap[ay : ay + gh, ax : ax + gw] = cv2.resize(at, (gw, gh))
|
||||
a3 = amap[:, :, None]
|
||||
wm = (a3 * 255.0 + (1 - a3) * img).clip(0, 255).astype(np.uint8)
|
||||
return wm, amap > 0.2
|
||||
|
||||
|
||||
class TestLocate:
|
||||
def test_box_centered_in_frame(self):
|
||||
eng = OpenArtEngine()
|
||||
img = np.zeros((2048, 2048, 3), np.uint8)
|
||||
loc = eng.locate(img)
|
||||
cx, cy = loc.x + loc.w / 2, loc.y + loc.h / 2
|
||||
assert cx == pytest.approx(2048 / 2, abs=2048 * 0.02)
|
||||
assert cy == pytest.approx(2048 / 2, abs=2048 * 0.02)
|
||||
|
||||
def test_box_scales_with_short_side(self):
|
||||
eng = OpenArtEngine()
|
||||
small = eng.locate(np.zeros((1024, 1024, 3), np.uint8))
|
||||
large = eng.locate(np.zeros((2048, 2048, 3), np.uint8))
|
||||
assert large.w == pytest.approx(small.w * 2, rel=0.1)
|
||||
|
||||
def test_box_stays_centered_on_portrait(self):
|
||||
"""The one confirmed carrier is a portrait (1528x2712); the box must stay
|
||||
horizontally AND vertically centered, not just anchored to one axis."""
|
||||
eng = OpenArtEngine()
|
||||
loc = eng.locate(np.zeros((2712, 1528, 3), np.uint8))
|
||||
cx, cy = loc.x + loc.w / 2, loc.y + loc.h / 2
|
||||
assert cx == pytest.approx(1528 / 2, abs=1528 * 0.02)
|
||||
assert cy == pytest.approx(2712 / 2, abs=2712 * 0.02)
|
||||
|
||||
|
||||
class TestDetect:
|
||||
def test_clean_gradient_not_detected(self):
|
||||
eng = OpenArtEngine()
|
||||
ramp = np.tile(np.linspace(0, 255, 1024, dtype=np.uint8), (1024, 1))
|
||||
img = cv2.cvtColor(ramp, cv2.COLOR_GRAY2BGR)
|
||||
assert not eng.detect(img).detected
|
||||
|
||||
def test_solid_blob_center_not_detected(self):
|
||||
"""A bright blob is not the glyph shape -> low correlation, not detected."""
|
||||
eng = OpenArtEngine()
|
||||
img = np.zeros((1024, 1024, 3), np.uint8)
|
||||
x, y, bw, bh = eng.locate(img).bbox
|
||||
img[y + bh // 4 : y + bh * 3 // 4, x : x + bw // 2] = 200
|
||||
assert not eng.detect(img).detected
|
||||
|
||||
def test_silhouette_loads(self):
|
||||
sil = _glyph_silhouette()
|
||||
assert sil is not None
|
||||
assert set(np.unique(sil)).issubset({0, 255})
|
||||
|
||||
def test_match_score_shape_sensitive(self):
|
||||
"""The glyph silhouette correlates with itself, not with a filled block."""
|
||||
sil = _glyph_silhouette()
|
||||
h, w = sil.shape
|
||||
box = np.zeros((h + 8, int(w / _ALPHA_WIDTH_FRAC * 0.2) + w), np.uint8)
|
||||
box[4 : 4 + h, 4 : 4 + w] = sil
|
||||
assert _template_match_score(box, _ALPHA_NATIVE_WIDTH) >= DETECT_NCC_THRESHOLD
|
||||
solid = np.full_like(box, 255)
|
||||
assert _template_match_score(solid, _ALPHA_NATIVE_WIDTH) < DETECT_NCC_THRESHOLD
|
||||
|
||||
def test_small_image_guarded_from_false_positive(self):
|
||||
"""Below the shared minimum short side, detection is skipped outright
|
||||
(``_MIN_DETECT_SHORT_SIDE`` in ``_text_mark_engine``) regardless of vendor."""
|
||||
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
|
||||
eng = OpenArtEngine()
|
||||
assert eng.detect(wm).detected # native: synthetic mark detected
|
||||
assert not eng.detect(cv2.resize(wm, (150, 150))).detected # below guard: suppressed
|
||||
|
||||
def test_random_textured_images_do_not_false_fire(self):
|
||||
"""A frame-center locate box sits over far more varied content than a
|
||||
corner box (see the module docstring's calibration caveat); guard against
|
||||
the obvious failure mode on plain photographic texture."""
|
||||
eng = OpenArtEngine()
|
||||
rng = np.random.default_rng(0)
|
||||
for _ in range(15):
|
||||
h, w = int(rng.integers(1000, 2200)), int(rng.integers(1000, 2200))
|
||||
noise = rng.integers(60, 200, (h, w, 3)).astype(np.uint8)
|
||||
img = cv2.GaussianBlur(noise, (0, 0), sigmaX=8)
|
||||
assert not eng.detect(img).detected
|
||||
|
||||
|
||||
class TestAlphaAsset:
|
||||
def test_alpha_asset_loads(self):
|
||||
at = _alpha_template()
|
||||
assert at is not None
|
||||
assert at.dtype.kind == "f"
|
||||
assert float(at.min()) >= 0.0
|
||||
assert float(at.max()) <= 1.0
|
||||
|
||||
|
||||
class TestFootprintMaskAndRemoval:
|
||||
def test_footprint_mask_near_frame_center(self):
|
||||
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
|
||||
mask = OpenArtEngine().footprint_mask(wm)
|
||||
assert mask is not None
|
||||
assert mask.shape == wm.shape[:2]
|
||||
ys, xs = np.where(mask > 0)
|
||||
assert ys.mean() == pytest.approx(wm.shape[0] / 2, abs=wm.shape[0] * 0.15)
|
||||
assert xs.mean() == pytest.approx(wm.shape[1] / 2, abs=wm.shape[1] * 0.15)
|
||||
|
||||
def test_removes_synthetic_mark(self):
|
||||
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
|
||||
assert OpenArtEngine().detect(wm).detected
|
||||
out, region = registry.get_mark("openart").remove(wm, backend="cv2")
|
||||
assert region is not None
|
||||
assert not OpenArtEngine().detect(out).detected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("w", "h"),
|
||||
[
|
||||
(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH), # square
|
||||
(1528, 2712), # the one confirmed carrier's exact aspect/size
|
||||
],
|
||||
)
|
||||
def test_fill_removes_and_leaves_frame_edges(self, w, h):
|
||||
"""The fill lowers re-detect confidence and leaves the untouched corners exact."""
|
||||
wm, mark = _compose(w, h)
|
||||
assert float(np.abs(wm.astype(np.float32)[mark] - 100.0).mean()) > 15 # mark visible
|
||||
before = OpenArtEngine().detect(wm)
|
||||
out, _ = registry.get_mark("openart").remove(wm, backend="cv2")
|
||||
assert OpenArtEngine().detect(out).confidence < before.confidence
|
||||
edge = max(4, min(w, h) // 8)
|
||||
assert np.array_equal(out[:edge, :edge], wm[:edge, :edge])
|
||||
assert np.array_equal(out[-edge:, -edge:], wm[-edge:, -edge:])
|
||||
|
||||
|
||||
class TestDegenerateAndChannelInputs:
|
||||
"""footprint_mask must not crash on degenerate sizes or non-3-channel inputs."""
|
||||
|
||||
@pytest.mark.parametrize(("w", "h"), [(2048, 1), (1, 2048), (2048, 8)])
|
||||
def test_wide_short_does_not_raise(self, w, h):
|
||||
eng = OpenArtEngine()
|
||||
img = np.zeros((h, w, 3), np.uint8)
|
||||
mask = eng.footprint_mask(img, force=True)
|
||||
assert mask is None or mask.shape == (h, w)
|
||||
|
||||
def test_grayscale_2d_does_not_raise(self):
|
||||
eng = OpenArtEngine()
|
||||
gray = np.zeros((2048, 2048), np.uint8)
|
||||
mask = eng.footprint_mask(gray, force=True)
|
||||
assert mask is None or mask.shape == (2048, 2048)
|
||||
|
||||
def test_bgra_4channel_does_not_raise(self):
|
||||
eng = OpenArtEngine()
|
||||
bgra = np.zeros((2048, 2048, 4), np.uint8)
|
||||
mask = eng.footprint_mask(bgra, force=True)
|
||||
assert mask is None or mask.shape == (2048, 2048)
|
||||
|
||||
@pytest.mark.parametrize("shape", [(20, 20, 3), (10, 400, 3), (400, 10, 3), (1, 1, 3), (2000, 2000, 3)])
|
||||
def test_locate_box_stays_in_bounds(self, shape):
|
||||
"""locate() must clamp its geometry box inside the image for ANY size/aspect,
|
||||
including the frame-center ("cc") corner -- alongside br/bl as in Doubao's test."""
|
||||
from remove_ai_watermarks._text_mark_engine import TextMarkEngine
|
||||
from remove_ai_watermarks.doubao_engine import _CONFIG as BR_CONFIG
|
||||
from remove_ai_watermarks.openart_engine import _CONFIG as CC_CONFIG
|
||||
from remove_ai_watermarks.samsung_engine import _CONFIG as BL_CONFIG
|
||||
|
||||
h, w = shape[:2]
|
||||
img = np.zeros(shape, np.uint8)
|
||||
for cfg in (BR_CONFIG, BL_CONFIG, CC_CONFIG):
|
||||
loc = TextMarkEngine(cfg).locate(img)
|
||||
assert loc.x >= 0
|
||||
assert loc.y >= 0
|
||||
assert loc.x + loc.w <= w
|
||||
assert loc.y + loc.h <= h
|
||||
assert loc.w > 0
|
||||
assert loc.h > 0
|
||||
@@ -238,6 +238,7 @@ class TestResolveStrength:
|
||||
assert resolve_strength(None, "openai", "qwen-zimage", size=(2000, 1850)) == pytest.approx(
|
||||
QWEN_ZIMAGE_OPENAI_STRENGTH
|
||||
)
|
||||
assert pytest.approx(0.15625) == QWEN_ZIMAGE_OPENAI_STRENGTH
|
||||
assert resolve_strength(None, None, "qwen-zimage", size=(600, 500)) == pytest.approx(0.084)
|
||||
assert resolve_strength(None, "google", "qwen-zimage", size=(600, 500)) == QWEN_ZIMAGE_GOOGLE_STRENGTH
|
||||
assert QWEN_ZIMAGE_GOOGLE_STRENGTH == 0.35
|
||||
@@ -249,7 +250,10 @@ class TestResolveStrength:
|
||||
with pytest.raises(ValueError, match="size is required"):
|
||||
resolve_strength(None, "openai", "qwen-zimage")
|
||||
|
||||
@pytest.mark.parametrize(("vendor", "expected"), [("microsoft", 0.15), ("openai", 0.07675), ("meta", 0.1)])
|
||||
@pytest.mark.parametrize(
|
||||
("vendor", "expected"),
|
||||
[("microsoft", 0.15), ("openai", QWEN_ZIMAGE_OPENAI_STRENGTH), ("meta", 0.1)],
|
||||
)
|
||||
def test_qwen_zimage_measured_vendors_use_flat_cross_source_margins(self, vendor, expected):
|
||||
"""Measured providers must not fall below their operating points on small files."""
|
||||
assert resolve_strength(None, vendor, "qwen-zimage", size=(600, 500)) == pytest.approx(expected)
|
||||
|
||||
@@ -13,6 +13,18 @@ if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _thordata_slot() -> oracles.ExecutionSlot:
|
||||
return {
|
||||
"name": "openai-thordata-us",
|
||||
"surface": "openai-web",
|
||||
"account_label": None,
|
||||
"browser_profile": None,
|
||||
"google_account_index": None,
|
||||
"network_label": "thordata-us-residential",
|
||||
"proxy_url_env": "THORDATA_ROUTE_US",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("surface", "page_text", "watermark", "provenance"),
|
||||
[
|
||||
@@ -171,6 +183,31 @@ def test_proxy_settings_are_loaded_from_an_environment_reference() -> None:
|
||||
}
|
||||
|
||||
|
||||
def test_thordata_proxy_settings_pin_one_exit_for_the_browser_session() -> None:
|
||||
proxy = web.proxy_settings(
|
||||
_thordata_slot(),
|
||||
{"THORDATA_ROUTE_US": "http://route:secret@gateway.pr.thordata.net:9999"},
|
||||
sticky_session_id="oracle0123456789abcdef",
|
||||
)
|
||||
|
||||
assert proxy == {
|
||||
"server": "http://gateway.pr.thordata.net:9999",
|
||||
"username": "route-sessid-oracle0123456789abcdef-sesstime-90",
|
||||
"password": "secret",
|
||||
}
|
||||
|
||||
|
||||
def test_thordata_proxy_settings_preserve_an_explicit_session() -> None:
|
||||
proxy = web.proxy_settings(
|
||||
_thordata_slot(),
|
||||
{"THORDATA_ROUTE_US": ("http://route-sessid-manual-sesstime-15:secret@gateway.pr.thordata.net:9999")},
|
||||
sticky_session_id="oracle0123456789abcdef",
|
||||
)
|
||||
|
||||
assert proxy is not None
|
||||
assert proxy["username"] == "route-sessid-manual-sesstime-15"
|
||||
|
||||
|
||||
def test_proxy_settings_do_not_treat_a_missing_secret_as_direct_access() -> None:
|
||||
slot: oracles.ExecutionSlot = {
|
||||
"name": "openai-west",
|
||||
@@ -290,27 +327,18 @@ def test_run_web_batch_loads_the_selected_proxy_from_dotenv(
|
||||
tmp_path: Path,
|
||||
tmp_clean_png: Path,
|
||||
) -> None:
|
||||
slot: oracles.ExecutionSlot = {
|
||||
"name": "openai-thordata-us",
|
||||
"surface": "openai-web",
|
||||
"account_label": None,
|
||||
"browser_profile": None,
|
||||
"google_account_index": None,
|
||||
"network_label": "thordata-us-residential",
|
||||
"proxy_url_env": "THORDATA_ROUTE_US",
|
||||
}
|
||||
manifest_path = oracles.prepare_batch(
|
||||
"openai-web",
|
||||
[tmp_clean_png],
|
||||
output_dir=tmp_path / "oracle-batch",
|
||||
repository_root=oracles.REPOSITORY_ROOT,
|
||||
slot=slot,
|
||||
slot=_thordata_slot(),
|
||||
)
|
||||
env_path = tmp_path / ".env"
|
||||
env_path.write_text(
|
||||
"THORDATA_PASSWORD=test-thordata-password\n"
|
||||
"THORDATA_ROUTE_US=http://td-customer-user-country-US:${THORDATA_PASSWORD}"
|
||||
"@proxy.thordata.test:9999\n",
|
||||
"@proxy.pr.thordata.net:9999\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
proxies: list[dict[str, str] | None] = []
|
||||
@@ -339,10 +367,11 @@ def test_run_web_batch_loads_the_selected_proxy_from_dotenv(
|
||||
)
|
||||
|
||||
assert report["watermark_results"] == {"not_detected": 1}
|
||||
manifest_sha256 = json.loads((manifest_path.parent / "results.json").read_text(encoding="utf-8"))["manifest_sha256"]
|
||||
assert proxies == [
|
||||
{
|
||||
"server": "http://proxy.thordata.test:9999",
|
||||
"username": "td-customer-user-country-US",
|
||||
"server": "http://proxy.pr.thordata.net:9999",
|
||||
"username": (f"td-customer-user-country-US-sessid-oracle{manifest_sha256[:16]}-sesstime-90"),
|
||||
"password": "test-thordata-password",
|
||||
}
|
||||
]
|
||||
|
||||
@@ -2,29 +2,21 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _publish() -> Any:
|
||||
path = Path(__file__).resolve().parents[1] / "scripts" / "publish_photo_classify_hf.py"
|
||||
spec = importlib.util.spec_from_file_location("publish_photo_classify_hf", path)
|
||||
assert spec is not None
|
||||
assert spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
import publish_photo_classify_hf as publish # noqa: E402
|
||||
|
||||
|
||||
def test_hub_id_is_the_photo_classify_repo() -> None:
|
||||
assert _publish().HUB_REPO == "wiltodelta/raiw-photo-classify"
|
||||
assert publish.HUB_REPO == "wiltodelta/raiw-photo-classify"
|
||||
|
||||
|
||||
def test_publish_freeze_includes_both_clip_runtimes() -> None:
|
||||
publish = _publish()
|
||||
from remove_ai_watermarks.classify import _WEIGHT_FILES, CLIP_ONNX_FILE, WEIGHTS_ALLOW_PATTERNS
|
||||
|
||||
assert publish.CLIP_FILE in publish.WEIGHT_FILES
|
||||
@@ -41,7 +33,7 @@ def test_weight_paths_accepts_run1_layout(tmp_path: Path) -> None:
|
||||
run1.mkdir()
|
||||
(run1 / "detector.pt").write_bytes(b"det")
|
||||
(run1 / "provider.pt").write_bytes(b"prov")
|
||||
found = _publish().weight_paths(tmp_path)
|
||||
found = publish.weight_paths(tmp_path)
|
||||
assert found["detector.pt"] == run1 / "detector.pt"
|
||||
assert found["clip-l-ft.pt"] == tmp_path / "clip-l-ft.pt"
|
||||
|
||||
@@ -50,12 +42,12 @@ def test_weight_paths_requires_every_file(tmp_path: Path) -> None:
|
||||
(tmp_path / "clip-l-ft.pt").write_bytes(b"clip")
|
||||
(tmp_path / "clip-l-ft-vision-fp32.onnx").write_bytes(b"onnx")
|
||||
with pytest.raises(SystemExit, match=r"missing probe-weights-clip-l-ft\.npz"):
|
||||
_publish().weight_paths(tmp_path)
|
||||
publish.weight_paths(tmp_path)
|
||||
|
||||
|
||||
def test_stage_card_copies_tracked_files(tmp_path: Path) -> None:
|
||||
dest = tmp_path / "stage"
|
||||
_publish().stage_card(dest)
|
||||
publish.stage_card(dest)
|
||||
assert (dest / "README.md").is_file()
|
||||
assert (dest / "operating-point.json").is_file()
|
||||
text = (dest / "operating-point.json").read_text()
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Local staging checks for the source-classifier Hub publisher."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
import publish_source_classify_hf as publish # noqa: E402
|
||||
|
||||
|
||||
def test_hub_id_is_the_source_classify_repo() -> None:
|
||||
assert publish.HUB_REPO == "wiltodelta/raiw-source-classify"
|
||||
|
||||
|
||||
def test_stage_release_copies_only_public_artifacts(tmp_path: Path) -> None:
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
(source / "source-pipeline-mlp.npz").write_bytes(b"weights")
|
||||
destination = tmp_path / "stage"
|
||||
publish.stage_release(destination, source)
|
||||
assert sorted(path.name for path in destination.iterdir()) == sorted(publish.PUBLIC_FILES)
|
||||
|
||||
|
||||
def test_stage_release_requires_the_model(tmp_path: Path) -> None:
|
||||
with pytest.raises(SystemExit, match=r"source-pipeline-mlp\.npz"):
|
||||
publish.stage_release(tmp_path / "stage", tmp_path)
|
||||
|
||||
|
||||
def test_card_sha_matches_the_metrics_manifest() -> None:
|
||||
metrics = json.loads((publish.CARD_DIR / "metrics.json").read_text())
|
||||
card = (publish.CARD_DIR / "README.md").read_text()
|
||||
assert metrics["artifact_sha256"] in card
|
||||
@@ -996,6 +996,70 @@ def test_sdxl_zimage_inherits_the_face_stage_rather_than_copying_it():
|
||||
assert SdxlZImagePipeline._load_global is not QwenZImagePipeline._load_global
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("pipeline_module", "pipeline_class", "grid"),
|
||||
[
|
||||
("sdxl_zimage_pipeline", "SdxlZImagePipeline", 8),
|
||||
("chroma_zimage_pipeline", "ChromaZImagePipeline", 16),
|
||||
],
|
||||
)
|
||||
def test_diffusers_vae_roundtrip_uses_deterministic_mode_and_restores_native_size(
|
||||
monkeypatch, pipeline_module, pipeline_class, grid
|
||||
):
|
||||
"""Diffusers donors pad without distorting geometry and never sample."""
|
||||
import importlib
|
||||
|
||||
import torch
|
||||
|
||||
seen: dict[str, object] = {}
|
||||
padded_width = 40 if grid == 8 else 48
|
||||
padded_height = 24 if grid == 8 else 32
|
||||
latent = torch.ones(1, 4, padded_height // 8, padded_width // 8)
|
||||
|
||||
class LatentDistribution:
|
||||
def mode(self):
|
||||
seen["mode_calls"] = int(seen.get("mode_calls", 0)) + 1
|
||||
return latent
|
||||
|
||||
def sample(self, *_args, **_kwargs):
|
||||
raise AssertionError("A verified-text donor must not sample VAE latents")
|
||||
|
||||
class FakeVae:
|
||||
@staticmethod
|
||||
def encode(tensor):
|
||||
seen["encoded_shape"] = tuple(tensor.shape)
|
||||
return type("Encoded", (), {"latent_dist": LatentDistribution()})()
|
||||
|
||||
@staticmethod
|
||||
def decode(received, *, return_dict):
|
||||
assert received is latent
|
||||
assert return_dict is False
|
||||
return (torch.zeros(1, 3, padded_height, padded_width),)
|
||||
|
||||
class FakeProcessor:
|
||||
@staticmethod
|
||||
def preprocess(image, *, height, width):
|
||||
seen["preprocess"] = (image.size, height, width)
|
||||
return torch.zeros(1, 3, height, width)
|
||||
|
||||
@staticmethod
|
||||
def postprocess(decoded, *, output_type):
|
||||
assert output_type == "pil"
|
||||
return [Image.new("RGB", (decoded.shape[3], decoded.shape[2]))]
|
||||
|
||||
pipe = type("FakePipe", (), {"vae": FakeVae(), "image_processor": FakeProcessor()})()
|
||||
module = importlib.import_module(f"remove_ai_watermarks._internal.{pipeline_module}")
|
||||
pipeline = getattr(module, pipeline_class)(device="cpu", torch_dtype=torch.float32)
|
||||
monkeypatch.setattr(pipeline, "_load_global", lambda: pipe)
|
||||
|
||||
result = pipeline._vae_roundtrip(Image.new("RGB", (35, 19)))
|
||||
|
||||
assert seen["preprocess"] == ((padded_width, padded_height), padded_height, padded_width)
|
||||
assert seen["encoded_shape"] == (1, 3, padded_height, padded_width)
|
||||
assert seen["mode_calls"] == 1
|
||||
assert result.size == (35, 19)
|
||||
|
||||
|
||||
def test_chroma_zimage_strength_uses_measured_flat_floors():
|
||||
"""The chroma floors come from the four-cohort oracle calibration; they must not
|
||||
drift from their derivation in watermark_profiles."""
|
||||
@@ -1005,6 +1069,7 @@ def test_chroma_zimage_strength_uses_measured_flat_floors():
|
||||
CHROMA_ZIMAGE_MICROSOFT_STRENGTH,
|
||||
CHROMA_ZIMAGE_OPENAI_STRENGTH,
|
||||
CHROMA_ZIMAGE_UNKNOWN_STRENGTH,
|
||||
QWEN_ZIMAGE_OPENAI_STRENGTH,
|
||||
normalize_profile,
|
||||
resolve_strength,
|
||||
)
|
||||
@@ -1023,7 +1088,9 @@ def test_chroma_zimage_strength_uses_measured_flat_floors():
|
||||
assert CHROMA_ZIMAGE_UNKNOWN_STRENGTH == CHROMA_ZIMAGE_GOOGLE_STRENGTH
|
||||
# An explicit strength still wins, and other profiles are untouched.
|
||||
assert resolve_strength(0.2, "google", "chroma-zimage") == pytest.approx(0.2)
|
||||
assert resolve_strength(None, "openai", "qwen-zimage", size=(2000, 1850)) == pytest.approx(0.07675)
|
||||
assert resolve_strength(None, "openai", "qwen-zimage", size=(2000, 1850)) == pytest.approx(
|
||||
QWEN_ZIMAGE_OPENAI_STRENGTH
|
||||
)
|
||||
|
||||
|
||||
def test_chroma_zimage_inherits_the_shared_stages_and_only_swaps_the_global():
|
||||
@@ -1163,8 +1230,9 @@ def test_chroma_zimage_floors_to_its_own_latent_grid():
|
||||
assert chroma_target_size(3, 3) == (16, 16)
|
||||
|
||||
|
||||
def test_watermark_remover_accepts_text_manifest_for_chroma_but_not_sdxl(tmp_path, monkeypatch):
|
||||
"""Chroma owns a VAE donor; SDXL still fails at the public boundary."""
|
||||
@pytest.mark.parametrize("profile", ["chroma-zimage", "sdxl-zimage"])
|
||||
def test_watermark_remover_accepts_text_manifest_for_every_diffusers_profile(tmp_path, monkeypatch, profile):
|
||||
"""Both Diffusers profiles forward reviewed text to their VAE donor."""
|
||||
from remove_ai_watermarks._internal.text_restoration import VerifiedTextLine, VerifiedTextManifest
|
||||
from remove_ai_watermarks._internal.watermark_remover import WatermarkRemover
|
||||
|
||||
@@ -1173,22 +1241,18 @@ def test_watermark_remover_accepts_text_manifest_for_chroma_but_not_sdxl(tmp_pat
|
||||
Image.new("RGB", (96, 80)).save(source)
|
||||
manifest = VerifiedTextManifest("0" * 64, 96, 80, (VerifiedTextLine((4, 4, 20, 16), "x", "alphabetic"),))
|
||||
|
||||
chroma = WatermarkRemover(device="cuda", pipeline="chroma-zimage")
|
||||
chroma_runtime = MagicMock()
|
||||
chroma_runtime.run.return_value = Image.new("RGB", (96, 80))
|
||||
monkeypatch.setattr(chroma, "_load_qwen_zimage_pipeline", lambda: chroma_runtime)
|
||||
remover = WatermarkRemover(device="cuda", pipeline=profile)
|
||||
runtime = MagicMock()
|
||||
runtime.run.return_value = Image.new("RGB", (96, 80))
|
||||
monkeypatch.setattr(remover, "_load_qwen_zimage_pipeline", lambda: runtime)
|
||||
|
||||
chroma.remove_watermark(source, text_manifest=manifest)
|
||||
remover.remove_watermark(source, text_manifest=manifest)
|
||||
|
||||
assert chroma_runtime.run.call_count == 1
|
||||
assert chroma_runtime.run.call_args.kwargs["text_manifest"] is manifest
|
||||
assert runtime.run.call_count == 1
|
||||
assert runtime.run.call_args.kwargs["text_manifest"] is manifest
|
||||
with pytest.raises(ValueError, match=r"fidelity anchor.*qwen-zimage"):
|
||||
chroma.remove_watermark(source, text_manifest=manifest, fidelity_anchor=True)
|
||||
assert chroma_runtime.run.call_count == 1
|
||||
|
||||
sdxl = WatermarkRemover(device="cuda", pipeline="sdxl-zimage")
|
||||
with pytest.raises(ValueError, match="not supported by the sdxl-zimage profile"):
|
||||
sdxl.remove_watermark(source, text_manifest=manifest)
|
||||
remover.remove_watermark(source, text_manifest=manifest, fidelity_anchor=True)
|
||||
assert runtime.run.call_count == 1
|
||||
|
||||
|
||||
def test_watermark_remover_dispatches_to_chroma_pipeline(monkeypatch):
|
||||
@@ -1229,58 +1293,6 @@ def test_chroma_adaptive_polish_defaults_off():
|
||||
assert resolve_adaptive_polish(True, "chroma-zimage") is True
|
||||
|
||||
|
||||
def test_chroma_vae_roundtrip_uses_deterministic_mode_and_restores_native_size(monkeypatch):
|
||||
"""The Chroma donor pads without distorting geometry and never samples."""
|
||||
import torch
|
||||
|
||||
from remove_ai_watermarks._internal.chroma_zimage_pipeline import ChromaZImagePipeline
|
||||
|
||||
seen: dict[str, object] = {}
|
||||
latent = torch.ones(1, 4, 4, 6)
|
||||
|
||||
class LatentDistribution:
|
||||
def mode(self):
|
||||
seen["mode_calls"] = int(seen.get("mode_calls", 0)) + 1
|
||||
return latent
|
||||
|
||||
def sample(self, *_args, **_kwargs):
|
||||
raise AssertionError("A verified-text donor must not sample VAE latents")
|
||||
|
||||
class FakeVae:
|
||||
@staticmethod
|
||||
def encode(tensor):
|
||||
seen["encoded_shape"] = tuple(tensor.shape)
|
||||
return type("Encoded", (), {"latent_dist": LatentDistribution()})()
|
||||
|
||||
@staticmethod
|
||||
def decode(received, *, return_dict):
|
||||
assert received is latent
|
||||
assert return_dict is False
|
||||
return (torch.zeros(1, 3, 32, 48),)
|
||||
|
||||
class FakeProcessor:
|
||||
@staticmethod
|
||||
def preprocess(image, *, height, width):
|
||||
seen["preprocess"] = (image.size, height, width)
|
||||
return torch.zeros(1, 3, height, width)
|
||||
|
||||
@staticmethod
|
||||
def postprocess(decoded, *, output_type):
|
||||
assert output_type == "pil"
|
||||
return [Image.new("RGB", (decoded.shape[3], decoded.shape[2]))]
|
||||
|
||||
pipe = type("FakePipe", (), {"vae": FakeVae(), "image_processor": FakeProcessor()})()
|
||||
pipeline = ChromaZImagePipeline(device="cpu", torch_dtype=torch.float32)
|
||||
monkeypatch.setattr(pipeline, "_load_global", lambda: pipe)
|
||||
|
||||
result = pipeline._vae_roundtrip(Image.new("RGB", (35, 19)))
|
||||
|
||||
assert seen["preprocess"] == ((48, 32), 32, 48)
|
||||
assert seen["encoded_shape"] == (1, 3, 32, 48)
|
||||
assert seen["mode_calls"] == 1
|
||||
assert result.size == (35, 19)
|
||||
|
||||
|
||||
def test_chroma_google_uses_one_content_agnostic_floor():
|
||||
"""Face detection alone cannot safely select a lower Google floor.
|
||||
|
||||
@@ -1322,15 +1334,16 @@ def test_auto_profile_routes_to_the_measured_engine_per_vendor():
|
||||
|
||||
def test_auto_strength_uses_the_effective_vendor_profile():
|
||||
"""Displayed and executed auto strengths must use the same engine policy."""
|
||||
from remove_ai_watermarks._internal.watermark_profiles import resolve_strength
|
||||
from remove_ai_watermarks._internal.watermark_profiles import QWEN_ZIMAGE_OPENAI_STRENGTH, resolve_strength
|
||||
|
||||
assert resolve_strength(None, "google", "auto", size=(64, 48)) == pytest.approx(0.50)
|
||||
assert resolve_strength(None, "microsoft", "auto", size=(64, 48)) == pytest.approx(0.125)
|
||||
assert resolve_strength(None, "openai", "auto", size=(64, 48)) == pytest.approx(0.07675)
|
||||
assert resolve_strength(None, "openai", "auto", size=(64, 48)) == pytest.approx(QWEN_ZIMAGE_OPENAI_STRENGTH)
|
||||
|
||||
|
||||
def test_auto_profile_strength_uses_the_resolved_engine_floors(tmp_path, monkeypatch):
|
||||
"""Auto resolves OpenAI to Qwen and uses its oracle-verified 0.07675 floor."""
|
||||
"""Auto resolves OpenAI to Qwen and uses its oracle-verified floor."""
|
||||
from remove_ai_watermarks._internal.watermark_profiles import QWEN_ZIMAGE_OPENAI_STRENGTH
|
||||
from remove_ai_watermarks._internal.watermark_remover import WatermarkRemover
|
||||
|
||||
_mock_watermark_runtime_deps(monkeypatch)
|
||||
@@ -1349,7 +1362,7 @@ def test_auto_profile_strength_uses_the_resolved_engine_floors(tmp_path, monkeyp
|
||||
|
||||
assert remover.model_profile == "qwen-zimage"
|
||||
_, kwargs = runtime.run.call_args
|
||||
assert kwargs["strength"] == pytest.approx(0.07675)
|
||||
assert kwargs["strength"] == pytest.approx(QWEN_ZIMAGE_OPENAI_STRENGTH)
|
||||
|
||||
|
||||
def test_auto_google_switches_to_the_sdxl_floor_and_dtype(tmp_path, monkeypatch):
|
||||
@@ -1397,6 +1410,7 @@ def test_auto_profile_text_manifest_uses_the_measured_qwen_engine_once(tmp_path,
|
||||
|
||||
def test_auto_profile_is_resolved_fresh_for_each_image(tmp_path, monkeypatch):
|
||||
"""A Google image must not pin the next OpenAI image to SDXL."""
|
||||
from remove_ai_watermarks._internal.watermark_profiles import QWEN_ZIMAGE_OPENAI_STRENGTH
|
||||
from remove_ai_watermarks._internal.watermark_remover import WatermarkRemover
|
||||
|
||||
_mock_watermark_runtime_deps(monkeypatch)
|
||||
@@ -1419,4 +1433,4 @@ def test_auto_profile_is_resolved_fresh_for_each_image(tmp_path, monkeypatch):
|
||||
|
||||
remover.remove_watermark(source, tmp_path / "openai.png", vendor="openai")
|
||||
assert remover.model_profile == "qwen-zimage"
|
||||
assert runtimes[-1].run.call_args.kwargs["strength"] == pytest.approx(0.07675)
|
||||
assert runtimes[-1].run.call_args.kwargs["strength"] == pytest.approx(QWEN_ZIMAGE_OPENAI_STRENGTH)
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Unit contracts for the lightweight source-pipeline runtime."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from hashlib import sha256
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from remove_ai_watermarks import source_classify
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_model_identity_is_immutable() -> None:
|
||||
assert re.fullmatch(r"[0-9a-f]{40}", source_classify.WEIGHTS_REVISION)
|
||||
assert re.fullmatch(r"[0-9a-f]{64}", source_classify.MODEL_SHA256)
|
||||
|
||||
|
||||
def _artifact(path: Path, *, activation: str = "gelu_exact", output_bias: tuple[float, ...] = (0.0, 0.0, 0.0)) -> None:
|
||||
np.savez_compressed(
|
||||
path,
|
||||
classes=np.asarray(source_classify.CLASSES),
|
||||
kind=np.asarray("fused"),
|
||||
mean=np.zeros(source_classify.FEATURE_WIDTH),
|
||||
scale=np.ones(source_classify.FEATURE_WIDTH),
|
||||
margins=np.asarray((0.5, 0.5)),
|
||||
layer0_weight=np.zeros((128, source_classify.FEATURE_WIDTH), dtype=np.float32),
|
||||
layer0_bias=np.zeros(128, dtype=np.float32),
|
||||
layer1_weight=np.zeros((64, 128), dtype=np.float32),
|
||||
layer1_bias=np.zeros(64, dtype=np.float32),
|
||||
output_weight=np.zeros((3, 64), dtype=np.float32),
|
||||
output_bias=np.asarray(output_bias, dtype=np.float32),
|
||||
activation=np.asarray(activation),
|
||||
)
|
||||
|
||||
|
||||
def test_loader_accepts_the_exact_pickle_free_schema(tmp_path: Path) -> None:
|
||||
path = tmp_path / source_classify.MODEL_FILE
|
||||
_artifact(path)
|
||||
|
||||
model = source_classify._load_model(path, expected_sha256=None)
|
||||
|
||||
assert model.mean.shape == (source_classify.FEATURE_WIDTH,)
|
||||
assert model.margins.tolist() == pytest.approx([0.5, 0.5])
|
||||
|
||||
|
||||
def test_loader_rejects_the_mislabeled_activation(tmp_path: Path) -> None:
|
||||
path = tmp_path / source_classify.MODEL_FILE
|
||||
_artifact(path, activation="gelu_tanh")
|
||||
|
||||
with pytest.raises(ValueError, match="activation must be gelu_exact"):
|
||||
source_classify._load_model(path, expected_sha256=None)
|
||||
|
||||
|
||||
def test_provider_threshold_mutation_changes_the_real_decision(tmp_path: Path) -> None:
|
||||
path = tmp_path / source_classify.MODEL_FILE
|
||||
_artifact(path, output_bias=(2.0, 0.0, 0.0))
|
||||
model = source_classify._load_model(path, expected_sha256=None)
|
||||
features = np.zeros(source_classify.FEATURE_WIDTH, dtype=np.float32)
|
||||
scores = source_classify._scores(features, model)
|
||||
|
||||
accepted = source_classify._result(scores, np.asarray((1.5, 0.5), dtype=np.float32))
|
||||
rejected = source_classify._result(scores, np.asarray((2.5, 0.5), dtype=np.float32))
|
||||
|
||||
assert accepted.label == "openai"
|
||||
assert accepted.reason == "classified"
|
||||
assert rejected.label == "unknown"
|
||||
assert rejected.reason == "abstained"
|
||||
|
||||
|
||||
def test_a_float32_threshold_tie_preserves_the_inclusive_training_decision() -> None:
|
||||
scores = np.asarray((2.0, 0.0, 0.0), dtype=np.float32)
|
||||
result = source_classify._result(scores, np.asarray((2.0, 0.5), dtype=np.float32))
|
||||
|
||||
assert result.label == "openai"
|
||||
assert result.reason == "classified"
|
||||
|
||||
|
||||
def test_small_image_returns_honest_unknown(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from PIL import Image
|
||||
|
||||
weights = tmp_path / "weights"
|
||||
weights.mkdir()
|
||||
_artifact(weights / source_classify.MODEL_FILE)
|
||||
monkeypatch.setenv(source_classify.WEIGHTS_ENV, str(weights))
|
||||
monkeypatch.setattr(
|
||||
source_classify,
|
||||
"MODEL_SHA256",
|
||||
sha256((weights / source_classify.MODEL_FILE).read_bytes()).hexdigest(),
|
||||
)
|
||||
image = tmp_path / "small.png"
|
||||
Image.new("RGB", (128, 128), "white").save(image)
|
||||
|
||||
result = source_classify.classify_source(image)
|
||||
|
||||
assert result.label == "unknown"
|
||||
assert result.reason == "feature_unavailable"
|
||||
assert result.scores == {}
|
||||
|
||||
|
||||
def test_model_hash_is_a_runtime_precondition(tmp_path: Path) -> None:
|
||||
path = tmp_path / source_classify.MODEL_FILE
|
||||
_artifact(path)
|
||||
|
||||
with pytest.raises(ValueError, match="model SHA-256 mismatch"):
|
||||
source_classify._load_model(path, expected_sha256="0" * 64)
|
||||
|
||||
|
||||
def test_spectral_feature_is_finite_and_has_the_frozen_width() -> None:
|
||||
from PIL import Image
|
||||
|
||||
from remove_ai_watermarks._internal.source_spectral import feature_from_image, make_geometry
|
||||
|
||||
rng = np.random.default_rng(20260916)
|
||||
pixels = rng.integers(0, 256, size=(300, 400, 3), dtype=np.uint8)
|
||||
feature = feature_from_image(Image.fromarray(pixels), make_geometry())
|
||||
|
||||
assert feature.shape == (768,)
|
||||
assert np.all(np.isfinite(feature))
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Tests for the abstaining source-pipeline classifier research tool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
|
||||
|
||||
import source_pipeline_classifier as classifier
|
||||
|
||||
|
||||
def test_command_help_exits_cleanly() -> None:
|
||||
result = CliRunner().invoke(classifier.main, ["--help"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Usage:" in result.output
|
||||
assert "train" in result.output
|
||||
assert "predict" in result.output
|
||||
|
||||
|
||||
def _training_rows() -> tuple[np.ndarray, np.ndarray]:
|
||||
rng = np.random.default_rng(20260915)
|
||||
rows = np.vstack(
|
||||
(
|
||||
rng.normal((-3.0, 0.0, 0.0), 0.1, size=(12, 3)),
|
||||
rng.normal((3.0, 0.0, 0.0), 0.1, size=(12, 3)),
|
||||
rng.normal((0.0, 3.0, 0.0), 0.1, size=(12, 3)),
|
||||
)
|
||||
)
|
||||
labels = np.repeat(np.arange(3), 12)
|
||||
return rows, labels
|
||||
|
||||
|
||||
def test_fit_and_abstention_separate_provider_rows() -> None:
|
||||
features, labels = _training_rows()
|
||||
model = classifier.fit_ridge(features, labels, penalty=1.0)
|
||||
raw_scores = classifier.scores(features, model)
|
||||
margin = classifier.calibrate_margin(raw_scores, labels, unknown_fpr=0.01)
|
||||
predictions = classifier.predict_scores(raw_scores, margin)
|
||||
|
||||
assert np.array_equal(predictions[:24], labels[:24])
|
||||
assert np.all(predictions[24:] == classifier.UNKNOWN_INDEX)
|
||||
|
||||
|
||||
def test_model_round_trip_is_pickle_free_and_strict(tmp_path: Path) -> None:
|
||||
grid = 4
|
||||
feature_count = classifier.FEATURE_WIDTH + 3 * grid * grid
|
||||
model = classifier.RidgeModel(
|
||||
mean=np.arange(feature_count, dtype=np.float64),
|
||||
scale=np.full(feature_count, 2.0),
|
||||
weights=np.arange(feature_count * 3, dtype=np.float64).reshape(feature_count, 3),
|
||||
margin=0.25,
|
||||
grid=grid,
|
||||
penalty=100.0,
|
||||
)
|
||||
path = tmp_path / "model.npz"
|
||||
|
||||
classifier.save_model(path, model)
|
||||
loaded = classifier.load_model(path)
|
||||
|
||||
assert loaded.grid == grid
|
||||
assert loaded.margin == model.margin
|
||||
assert np.array_equal(loaded.mean, model.mean)
|
||||
assert np.array_equal(loaded.scale, model.scale)
|
||||
assert np.array_equal(loaded.weights, model.weights)
|
||||
|
||||
with np.load(path, allow_pickle=False) as payload:
|
||||
assert payload.files
|
||||
|
||||
|
||||
def test_model_loader_rejects_extra_fields(tmp_path: Path) -> None:
|
||||
path = tmp_path / "model.npz"
|
||||
np.savez(
|
||||
path,
|
||||
classes=np.asarray(classifier.CLASSES),
|
||||
mean=np.zeros(136),
|
||||
scale=np.ones(136),
|
||||
weights=np.zeros((136, 3)),
|
||||
margin=np.asarray(0.1),
|
||||
spectral_grid=np.asarray(2),
|
||||
penalty=np.asarray(100.0),
|
||||
unexpected=np.asarray(1),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="model fields must be exactly"):
|
||||
classifier.load_model(path)
|
||||
|
||||
|
||||
def test_model_loader_rejects_nonfinite_margin(tmp_path: Path) -> None:
|
||||
grid = 2
|
||||
feature_count = classifier.FEATURE_WIDTH + 3 * grid * grid
|
||||
path = tmp_path / "model.npz"
|
||||
np.savez(
|
||||
path,
|
||||
classes=np.asarray(classifier.CLASSES),
|
||||
mean=np.zeros(feature_count),
|
||||
scale=np.ones(feature_count),
|
||||
weights=np.zeros((feature_count, 3)),
|
||||
margin=np.asarray(np.nan),
|
||||
spectral_grid=np.asarray(grid),
|
||||
penalty=np.asarray(100.0),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="margin must be finite"):
|
||||
classifier.load_model(path)
|
||||
|
||||
|
||||
def test_calibration_requires_unknown_rows() -> None:
|
||||
with pytest.raises(ValueError, match="must contain unknown"):
|
||||
classifier.calibrate_margin(
|
||||
np.zeros((2, 3)),
|
||||
np.asarray([0, 1]),
|
||||
unknown_fpr=0.01,
|
||||
)
|
||||
@@ -29,6 +29,8 @@ _ALTERNATE_SIZE = {
|
||||
"liblib": (960, 1280),
|
||||
"liblib_pill": (960, 1280),
|
||||
"microsoft": (1280, 960),
|
||||
"generic_ai_label": (1280, 960),
|
||||
"openart": (1280, 960),
|
||||
"jimeng_pill": (960, 1280),
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ class TestCatalog:
|
||||
"liblib",
|
||||
"liblib_pill",
|
||||
"microsoft",
|
||||
"generic_ai_label",
|
||||
"openart",
|
||||
"jimeng_pill",
|
||||
]
|
||||
|
||||
@@ -49,6 +51,7 @@ class TestCatalog:
|
||||
assert by_key["samsung"].location == "bottom-left"
|
||||
assert by_key["liblib_pill"].location == "top-left"
|
||||
assert by_key["jimeng_pill"].location == "top-left"
|
||||
assert by_key["openart"].location == "frame-center"
|
||||
|
||||
def test_get_mark_unknown_raises(self):
|
||||
with pytest.raises(KeyError):
|
||||
@@ -118,6 +121,8 @@ class TestScan:
|
||||
"liblib",
|
||||
"liblib_pill",
|
||||
"microsoft",
|
||||
"generic_ai_label",
|
||||
"openart",
|
||||
"jimeng_pill",
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ resolution-markers = [
|
||||
|
||||
[[package]]
|
||||
name = "accelerate"
|
||||
version = "1.14.0"
|
||||
version = "1.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "huggingface-hub" },
|
||||
@@ -38,9 +38,9 @@ dependencies = [
|
||||
{ name = "safetensors" },
|
||||
{ name = "torch" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8d/75/94cd5d389649578aca399e5aa822637eec18319a1dadc400ffe2f9a7493f/accelerate-1.14.0.tar.gz", hash = "sha256:41b9c4377a54e0b460a959b0defa1b736e4ca0a2373252d9a539964c2afe3c8d", size = 412167, upload-time = "2026-06-11T13:45:52.326Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f5/b5/1d3ed029ac71d3f2961346829a268da923698e9fd63f218f78841f216bfd/accelerate-1.15.0.tar.gz", hash = "sha256:5654f8c5eaa0d4fa68b33e287a97765da6849bf6d51dcac874e73fbbddfb6134", size = 422615, upload-time = "2026-09-09T13:04:49.078Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/db/253133d7e7cb40d3af384bb2f5c0b4a2b7fdcffbc95c688cc67a20a3c103/accelerate-1.14.0-py3-none-any.whl", hash = "sha256:e94390c2863b873be18f623f9df48a0d8fe5eff13ea7f1a00092b0a7904888c6", size = 389246, upload-time = "2026-06-11T13:45:50.477Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/4c/34f0450479d01195027260da68d8a3880683f1640c3ca5adf64acb3185f1/accelerate-1.15.0-py3-none-any.whl", hash = "sha256:97eacca0b73e45cb867dbf8c5d5d4dc32219544300e0c8992c7334dc2ef33cec", size = 394295, upload-time = "2026-09-09T13:04:47.331Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1249,7 +1249,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "huggingface-hub"
|
||||
version = "1.30.0"
|
||||
version = "1.31.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -1262,9 +1262,9 @@ dependencies = [
|
||||
{ name = "tqdm" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/35/97/2eb4abaa5b969ed385066a0496a3823b3ff467fc1082e2202955f1867d60/huggingface_hub-1.30.0.tar.gz", hash = "sha256:e6a6120bc8c8e2723d03648434ee247088cceb55ba7067e7d34d692cad5fdb57", size = 964291, upload-time = "2026-09-03T10:05:14.053Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9b/f0/61159db90b5cd275d55516fe27920828e7d3be4053fdbdb27c3f70e5f1ef/huggingface_hub-1.31.0.tar.gz", hash = "sha256:f8e9e710a210613fa5d0f26bba6da05ef4aef9fba5a0f23f508f5ac4d08b6f90", size = 968039, upload-time = "2026-09-10T10:27:22.724Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/0e/3e45bbe0dd48f4e56b1d46649d342de853cd1c7e815323472ab62687f153/huggingface_hub-1.30.0-py3-none-any.whl", hash = "sha256:96ae0a8e99a234374a6fe43e989ebd21c04640b91ab2927e7e5773ba1131ca59", size = 796795, upload-time = "2026-09-03T10:05:12.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/7f/3f886a625043b77312b80da2f2bf00b5ecbf5a73061af1aa0259cd258c9d/huggingface_hub-1.31.0-py3-none-any.whl", hash = "sha256:9dbb6a503cbe2494ea666695207e7262d410659e09134059deb83e5480864667", size = 798313, upload-time = "2026-09-10T10:27:20.798Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2058,7 +2058,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "onnxruntime"
|
||||
version = "1.29.0"
|
||||
version = "1.30.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "flatbuffers" },
|
||||
@@ -2068,35 +2068,35 @@ dependencies = [
|
||||
{ name = "protobuf" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/a8/0520890321b8ff40b908cf165a93eb58fbc8f85c14db637277ea866c9544/onnxruntime-1.29.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:07c5907474dec4a2792fd7626b753dc66707808385a6d9eecf993db0066a9d0f", size = 21420890, upload-time = "2026-08-17T22:53:33.429Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/77/8bd3e0008ff8d386305351109a7329ea57e51a3ab57bc92340f29c4a5b5d/onnxruntime-1.29.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:16925ef8497e2c07e4b5ae15b504079b3ab3f65e22c58efd10dde0f3caea969a", size = 20803602, upload-time = "2026-08-17T22:53:36.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/91/a66cd77f28379ede419672edda3184f1eb286db215dce1e7b976fae2d63b/onnxruntime-1.29.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:85f8e8406c52658735fe5c7fbfd3ebaa1ed340768324f6252e4274e374580a23", size = 23113193, upload-time = "2026-08-17T22:53:39.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/82/2da968405c42340f03de0bcdb63be09ae1004f820b2295590d48951b5cf2/onnxruntime-1.29.0-cp311-cp311-win_amd64.whl", hash = "sha256:0d4f427afac434b0070fe992b540ddf20a7aff2265f760f314d91331935b6b98", size = 13999253, upload-time = "2026-08-17T22:53:43.184Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/7a/70c9c893bf732ee66124c2d8de6a21fc9361ec62cf378f857043efcbf0eb/onnxruntime-1.29.0-cp311-cp311-win_arm64.whl", hash = "sha256:4eae472cf7dc3107dec1bb53cd6d142d1964616d08aae48654cd4254b2363c4b", size = 13741410, upload-time = "2026-08-17T22:53:45.521Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/80/381c1e9efed9cc32d00aa7cab0547dc84116cec906c3ffe3613686d6963a/onnxruntime-1.29.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3a3814c041251d6a77fdf513fb282056538ee826d2f1178a0df3c549d3fff6ba", size = 21430049, upload-time = "2026-08-17T22:53:48.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/12/4be0e345d38fe707a701ca07e8f63c05b152a2e6285d1e43a7faf63fedd2/onnxruntime-1.29.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:d2fb19e848f7c33ed8d3182b52504aaa11c5e8da438bbb47296f85b133cbcf6b", size = 20816870, upload-time = "2026-08-17T22:53:51.169Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/eb/e6968f5e41aac3125f2ff5708855f09cb0b70d85ed3115b625b0b58305ba/onnxruntime-1.29.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:2b80d8c7ec2cc7438e4da3760b88c24568cba72c9ace96d668800a6c79419acb", size = 23136745, upload-time = "2026-08-17T22:53:53.92Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/80/5b28f1f1111210fc4a336ddbc6950f468ebf9a6a265420568f4f43fa33ce/onnxruntime-1.29.0-cp312-cp312-win_amd64.whl", hash = "sha256:4acf2b4948b7ede87221ca6332344b8facdc8059d6ac751a7d367d04532b02dd", size = 14001407, upload-time = "2026-08-17T22:53:56.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/d6/6883f89ea4b044e6e8447ebfaf9bcecdf457b7d80a683635e130b25498e0/onnxruntime-1.29.0-cp312-cp312-win_arm64.whl", hash = "sha256:dc61a79cb39afd66ab3f01fd2c23591a7f01de89c1668e1fb6315067fc279164", size = 13746981, upload-time = "2026-08-17T22:53:58.977Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/f8/d375facf60edaf41f5732f9f689c98a800fcc52df5cf6ddfb406703eb5a1/onnxruntime-1.29.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:be0f8ed688cfb1d4d5765a137193b7bfab0c8ea214eed99260b380bb525a3a7f", size = 21429708, upload-time = "2026-08-17T22:54:01.44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/17/b9ad04051a8c4f504852ce0e8e10f9a6b2f1a331eedcdcc503df776dd0ea/onnxruntime-1.29.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:d67673c5367727860922c5262d724472f1b5539fb7ccf4c81a638f9b71719803", size = 20816263, upload-time = "2026-08-17T22:54:04.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/2c/d8eb945d2a372149df9705a8d5c8d7c6c46c987c5446dbcea9e1ea7f6556/onnxruntime-1.29.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e2128f31f449e922c62dbe5d8b6b7b079f0bcaf2d56a102fa203cb6e5bb5ab19", size = 23136817, upload-time = "2026-08-17T22:54:06.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/3b/66b424c63fa92dfaa48d1719efaae66fc8c256b9426a832eda51d8dfe1e9/onnxruntime-1.29.0-cp313-cp313-win_amd64.whl", hash = "sha256:2945e1f82f81f27e88decea88c7861f45baea23818950d467bf3909aa303119e", size = 14001310, upload-time = "2026-08-17T22:54:09.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/22/d6a700e3a6322fa3d56fbe7cee9ffc53f35e77ffcd6b7e97f4b7722a27ab/onnxruntime-1.29.0-cp313-cp313-win_arm64.whl", hash = "sha256:4b940b0d777590c7e20bf298f5c16af1ea6ad1b400a1c822a6be192f64f4d954", size = 13747112, upload-time = "2026-08-17T22:54:11.608Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/89/c4af146de3d60a32c89fea48d5d34bfd044faaf8957270043a03bd1b462b/onnxruntime-1.29.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:533f8370ce124304e5cb08ab961836cf755631e3dd77adc5f3bbdab70c2b7d99", size = 20826136, upload-time = "2026-08-17T22:54:14.315Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/f2/e6bbacd11dfe8d070613261a758795ea128b9fc9bea391a2a7da2e4c7a08/onnxruntime-1.29.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:c1ad3f437153fe77f9d01a08fbaac0beb030e09b8a80ace1603bcf69b6c95481", size = 23138951, upload-time = "2026-08-17T22:54:17.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/a3/718e1b83096a1bc7b0fc8014c23d4cf795559fe666961cfac4fc038a4871/onnxruntime-1.29.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e74b278af1d949876f5d91d1268fd6c680e79f2bac194967394eaba9fdf69e7e", size = 21431104, upload-time = "2026-08-17T22:54:20.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/17/c75e78ddc1fe69b6ebaef7fe88ac83f29bfe10955e3a0d2436d93473c91c/onnxruntime-1.29.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:939e5d65f332e6d399774b2bd0d3559fd8fa629c1e77833db29d968d2384f23d", size = 20818488, upload-time = "2026-08-17T22:54:23.147Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/54/9f197c578d3d3d7bea16971e233e5483981228eec73748585cf7b5933403/onnxruntime-1.29.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:6c0c37b92f67ed68dd36221ce0403e1d9bd4f7efce724439978a2597848530e5", size = 23136994, upload-time = "2026-08-17T22:54:26.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/53/4616a55d2495679cfd0195f968feb3d74fe30e26467d168ee243ac97c089/onnxruntime-1.29.0-cp314-cp314-win_amd64.whl", hash = "sha256:4a3129ae56e70d2618ff773920166916310370a7e3cacb60b9e0e8910092725f", size = 14350643, upload-time = "2026-08-17T22:54:28.794Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/0f/c338cb5500a522c7e671a3bb1276f4562404fbecce8a0e274565aa968484/onnxruntime-1.29.0-cp314-cp314-win_arm64.whl", hash = "sha256:e417ef8628dcce310d2d53023e750ea298ec14d4341ae6dc3a572bfd9bc7fa97", size = 14124294, upload-time = "2026-08-17T22:54:31.015Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/e7/61064289a9a1301b25c1f0f574fe98aba31c2d388db3c1dbec664f78621f/onnxruntime-1.29.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:11264bb58f7b7cf6af835ab10d36838d73680580820fd6f51d90124a1ca8f449", size = 20826174, upload-time = "2026-08-17T22:54:34.283Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/21/d0c04b561b46e9bff89b5f500fb7415b8ca0669f7902204f76ab06bb0c7e/onnxruntime-1.29.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1ea91cef3b971506e51ae9c37c16d027774ec64994a524ec1bdfb027d68a9832", size = 23138547, upload-time = "2026-08-17T22:54:37.491Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/b6/e2fa6dfc8c3cd19639d452c3120fb0e02f5176b4e24ecabb24b5362be725/onnxruntime-1.30.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:f36c87ec504f4f328e968ee322e4ba9682abe66280b3425625afcbed5333fed7", size = 21533037, upload-time = "2026-09-10T16:31:03.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/1a/4d197d2676a866f6239cd2d06a95b6db40d47dc29d5bec9fb1493a13dd94/onnxruntime-1.30.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:bac3aee7b40acd805659fd81a2acd6096312d5b0f014c97ddfe75fb4c1644627", size = 21332697, upload-time = "2026-09-10T16:31:08.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/2e/4c96278a99140d0307ccda6fd51e6e9d1ca7c9f6fdd1fa96ff375acfc713/onnxruntime-1.30.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:fd54b314ea385bcecac69ab431f020ba503e3878dad4ebb645fec5a24b041242", size = 23561046, upload-time = "2026-09-10T16:31:11.494Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/68/b8aaade8c1883f569228f4b57201cce922bb7d4af6f977d619520a733430/onnxruntime-1.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:0edd0145a6e3fce8a1276491dc784d615e3c58bcb952c9b4e5c876d5c6a12ad7", size = 14309136, upload-time = "2026-09-10T16:31:14.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/f1/fbda6fb08ac4db6c5c6f7c3e128e4d8fb398b52fe2fc3282c45cadedd18b/onnxruntime-1.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:16506cfa5d218850f2b46e0edce1b952121f2f84f8e0074dafe15f4787f0c8fb", size = 14167408, upload-time = "2026-09-10T16:31:16.505Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/6f/48169f2e62b405bff5053cbd1d73fb5ce41ef7ecd13bb3bfcc191e689b8a/onnxruntime-1.30.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:001ed726c9bd5e2bc92faade7d37d889e9606a350b7d5529f0227df2e3bb57fd", size = 21544867, upload-time = "2026-09-10T16:31:19.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/bd/cbc5b8f91963689fdd622f463508c01d0aa95d3f944747b1e0b1eb2160b8/onnxruntime-1.30.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:6c32a000d5139a38ba9349030b0032e3331acb559d596b22738d9d2b343a2b83", size = 21345202, upload-time = "2026-09-10T16:31:23.361Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/35/e7f862dbacbc99fadd9b14a614e49c99bf0f35fd9927a82f096e3de33531/onnxruntime-1.30.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:fa688e7891a6aa206636fe7372e27ee75fd17713289f6b4fc7b190e0a7de9328", size = 23585654, upload-time = "2026-09-10T16:31:26.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/13/0f1699f6de549c9324bc9112a2a85b14c517904cd11b562a654643b755a1/onnxruntime-1.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:f3501472571f1b1eee50e017851e7929f5ea37312d2d8c2494a19e8fc58b4a38", size = 14311470, upload-time = "2026-09-10T16:31:31.273Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/17/02b13e5f51461f0453b18ab854e2d0bc1b6ec353241b05a2ee6b79e35d87/onnxruntime-1.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:dc4c706f1935ebb62356e6a095b047859badd854482c40560888e95c328ed262", size = 14175072, upload-time = "2026-09-10T16:31:34.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/75/508454c5d01f31641dabc597fe559594c931a520a2673031179319d0afd8/onnxruntime-1.30.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:05e4fc41711d1f4abd19a9124b5be7a65a506cb2670a7f14b16162ef13c58134", size = 21544990, upload-time = "2026-09-10T16:31:37.685Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/06/e603c71f43f4fe3fd156a053af79cbed6e27a2c649f0988a67d97fedd39f/onnxruntime-1.30.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:5327cf6aa15a02bad805fac8bd6882a62571e8b72f6f2938a8f37e6bd1966ce9", size = 21344996, upload-time = "2026-09-10T16:31:40.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/a1/ede48ab5dc54907a2999362777f541e132639fb06628ded1932058aa8a36/onnxruntime-1.30.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:86f940afc801ea9681a4da8af84fbe95e1d9ea7d80903952cc1bfad54faad38f", size = 23585560, upload-time = "2026-09-10T16:31:44.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/dd/c57c529dbc6dd55eca24b12cfbeab1b6a690de72083824eca689085f55b0/onnxruntime-1.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:4b63041bd623a9a9ac5e353948436c6fa7f43edd12d6b4a4ebc340bca959ba93", size = 14311378, upload-time = "2026-09-10T16:31:48.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/2f/ef00b45b911e7f2115273a24ed1b43e73c8c6c9ec17f9bfa13f8492581f8/onnxruntime-1.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:c389b6887fc95e0fcb80e89b156bc2cb18e662c29df55e9326fe64140e7d7b4f", size = 14175134, upload-time = "2026-09-10T16:31:50.799Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/0a/284fd6fe701c9a8aff39dbc119c37582ca40e9cff1ea05425a7cc8606a02/onnxruntime-1.30.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:e5224ba2b00284cb1c48b3edcd303c109245d66df9ec1b861858fd6de672a38e", size = 21349453, upload-time = "2026-09-10T16:31:53.898Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/72/4f4466f8fa1ec267a9ef5e2f3bd175c203d3da0facbdf4049dc93abbe91c/onnxruntime-1.30.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:3f9e002417f1e3bbb31ed43dafa4f22ca1b2b68832244fdee192fcaa1ae19bca", size = 23579837, upload-time = "2026-09-10T16:31:56.82Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/03/05c9a9234688757d2876ddecf80bba908561ee11debf125bc1a427ae6f48/onnxruntime-1.30.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:8b6169c16a48429890d2f4a0c774ebf54dfe9066a998514aad0518a16d398547", size = 21545313, upload-time = "2026-09-10T16:31:59.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/bc/1069e58b24779ba9d2fd479db5ecb3a15a6f49b585107c898819c0789558/onnxruntime-1.30.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:d2184fddb6798136e7c478244391ca82443f5c757f59f15bb9e5ad2da5e03175", size = 21346830, upload-time = "2026-09-10T16:32:02.275Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/38/8138eed225c5bc6ddfc05879ecac7dacc63c34b9b6f99be72839c1f6dc49/onnxruntime-1.30.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:8b611d24db2954545ce6bd9acd4670183cb368e4642450de7a9ab6474eb374ec", size = 23586333, upload-time = "2026-09-10T16:32:04.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/0a/748b86000fbf9518b5dfc5bdc1b924eeaca976203740fd9d30649e568ee6/onnxruntime-1.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bdd1752a8502ac1a7ccc6e878d16db6943df574d54ed7f01058a6806ae05be4", size = 14675319, upload-time = "2026-09-10T16:32:07.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/db/db8e4c0cf6f70f1311060560630fd21763648e01dfbba5195a2592c052ef/onnxruntime-1.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:83d543843cbd352cfa9996a6c7b92f8a480f114a18f95ff2a1acaf6921e85b6d", size = 14569360, upload-time = "2026-09-10T16:32:09.896Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/0a/ec0a9d656e39b43887c378a5388b20c3b1e1ee43bded84580b0936466694/onnxruntime-1.30.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:265de607ba6f9814264e1d5d413fa7069d70f48e3049b5e460b3b04bdfcef294", size = 21348433, upload-time = "2026-09-10T16:32:12.772Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/f0/40f74b7c00077e1e25627067ed98a70df1fef5c0e21b82849190d312554e/onnxruntime-1.30.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:67ad7f03433b6462c627d0f555dece80e6a26bc71e8542ced35cebd32142d1b7", size = 23579340, upload-time = "2026-09-10T16:32:15.532Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openai"
|
||||
version = "3.9.0"
|
||||
version = "3.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
@@ -2106,9 +2106,9 @@ dependencies = [
|
||||
{ name = "sniffio" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/14/77/3508ca0f04f42124bb89ab6e3e853c144660b9a680165beb49cd48634bce/openai-3.9.0.tar.gz", hash = "sha256:fea0fe63d04a27e9da588f673e291183fbf4813767e9df743e351bb548afc60b", size = 1481863, upload-time = "2026-09-08T16:43:15.033Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/40/f1/4874c4f7db30121885e6a763e2670b2fe1d76c79ed4732f8acbb51c8d96e/openai-3.13.0.tar.gz", hash = "sha256:a8f87a9b3b9c08eb446d68bd0a80e8ec907c4c35fdea63f4265c7b34b2de3a60", size = 1624217, upload-time = "2026-09-10T19:38:27.597Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/ec/59307303cdb38d89f73555245cec54ab5fe34628c39b78b24ea48d0bbed8/openai-3.9.0-py3-none-any.whl", hash = "sha256:8e0e9a1abea664507f03fdad204579092d0fdf1aff54e8335c2098041d4c0345", size = 1741583, upload-time = "2026-09-08T16:43:12.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/7f/ee9ebb7c5ab7abf016969faec7c748a91c1fd893078bfe1e5ba5d82e96c2/openai-3.13.0-py3-none-any.whl", hash = "sha256:e35b1f6fe99245e86e37504d9fad1ad2a363807307c424232c1d849bd0666c8e", size = 2009683, upload-time = "2026-09-10T19:38:25.855Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3009,15 +3009,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pyright"
|
||||
version = "1.1.411"
|
||||
version = "1.1.414"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nodeenv" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7e/ab/265f7dc69d28113ebba19092e57b075f41543b2ed048429c5f56e2b88eac/pyright-1.1.411.tar.gz", hash = "sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998", size = 4112861, upload-time = "2026-06-25T02:14:06.37Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e1/1b/244c7b710031ada80f27e579ec20d28a2285dfc318fed0339866b1047f12/pyright-1.1.414.tar.gz", hash = "sha256:523c0a97c60da6333234955c277730c9cf4f5bd6d5399e7b7d2b0fc5d3599524", size = 4154638, upload-time = "2026-09-10T12:26:53.181Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-py3-none-any.whl", hash = "sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9", size = 6181526, upload-time = "2026-06-25T02:14:04.691Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ba/18b6e682ead424ad24bcc134339ae5d1b931cd9ae260540592a058a91279/pyright-1.1.414-py3-none-any.whl", hash = "sha256:2a6b4b3298c9eec174c5ed83bd338de6eee82df2992f3e1930e6199d381be36f", size = 6225049, upload-time = "2026-09-10T12:26:51.427Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3449,7 +3449,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "remove-ai-watermarks"
|
||||
version = "0.40.4"
|
||||
version = "0.42.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "c2pa-python" },
|
||||
@@ -3584,6 +3584,13 @@ qwen-zimage = [
|
||||
{ name = "torchvision" },
|
||||
{ name = "transformers" },
|
||||
]
|
||||
source-classify = [
|
||||
{ name = "huggingface-hub" },
|
||||
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" },
|
||||
{ name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" },
|
||||
{ name = "opencv-python-headless", version = "4.11.0.86", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" },
|
||||
{ name = "opencv-python-headless", version = "4.14.0.94", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" },
|
||||
]
|
||||
text-draft = [
|
||||
{ name = "huggingface-hub" },
|
||||
{ name = "paddleocr" },
|
||||
@@ -3637,6 +3644,7 @@ requires-dist = [
|
||||
{ name = "huggingface-hub", marker = "extra == 'dev'", specifier = ">=0.20.0" },
|
||||
{ name = "huggingface-hub", marker = "extra == 'lama'", specifier = ">=0.20.0" },
|
||||
{ name = "huggingface-hub", marker = "extra == 'migan'", specifier = ">=0.20.0" },
|
||||
{ name = "huggingface-hub", marker = "extra == 'source-classify'", specifier = ">=0.20.0" },
|
||||
{ name = "huggingface-hub", marker = "extra == 'text-draft'", specifier = ">=0.20.0" },
|
||||
{ name = "invisible-watermark", marker = "extra == 'dev'", specifier = ">=0.2.0" },
|
||||
{ name = "numpy", marker = "python_full_version >= '3.13' and extra == 'pixels'", specifier = ">=2" },
|
||||
@@ -3665,10 +3673,11 @@ requires-dist = [
|
||||
{ name = "remove-ai-watermarks", extras = ["pixels"], marker = "extra == 'classify'" },
|
||||
{ name = "remove-ai-watermarks", extras = ["pixels"], marker = "extra == 'detect'" },
|
||||
{ name = "remove-ai-watermarks", extras = ["pixels"], marker = "extra == 'diffusion'" },
|
||||
{ name = "remove-ai-watermarks", extras = ["pixels"], marker = "extra == 'source-classify'" },
|
||||
{ name = "remove-ai-watermarks", extras = ["pixels"], marker = "extra == 'visible'" },
|
||||
{ name = "remove-ai-watermarks", extras = ["qwen-zimage", "lama"], marker = "extra == 'text-restoration'" },
|
||||
{ name = "remove-ai-watermarks", extras = ["video"], marker = "extra == 'dev'" },
|
||||
{ name = "remove-ai-watermarks", extras = ["video", "heif", "detect", "trustmark", "text-restoration", "migan", "classify"], marker = "extra == 'all'" },
|
||||
{ name = "remove-ai-watermarks", extras = ["video", "heif", "detect", "trustmark", "text-restoration", "migan", "classify", "source-classify"], marker = "extra == 'all'" },
|
||||
{ name = "remove-ai-watermarks", extras = ["visible"], marker = "extra == 'lama'" },
|
||||
{ name = "remove-ai-watermarks", extras = ["visible"], marker = "extra == 'migan'" },
|
||||
{ name = "remove-ai-watermarks", extras = ["visible"], marker = "extra == 'video'" },
|
||||
@@ -3684,7 +3693,7 @@ requires-dist = [
|
||||
{ name = "transformers", marker = "extra == 'diffusion'", specifier = ">=5,<6" },
|
||||
{ name = "trustmark", marker = "python_full_version < '3.13' and extra == 'trustmark'", specifier = ">=0.8.0" },
|
||||
]
|
||||
provides-extras = ["pixels", "heif", "visible", "video", "detect", "diffusion", "qwen-zimage", "text-restoration", "text-draft", "trustmark", "classify", "classify-onnx", "lama", "migan", "dev", "all"]
|
||||
provides-extras = ["pixels", "heif", "visible", "video", "detect", "diffusion", "qwen-zimage", "text-restoration", "text-draft", "trustmark", "classify", "classify-onnx", "source-classify", "lama", "migan", "dev", "all"]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
@@ -3725,27 +3734,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.16.6"
|
||||
version = "0.16.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a4/7c/6adb35d70e7c027e308274557901c7e00fb3407750faf3620c184ae058cb/ruff-0.16.6.tar.gz", hash = "sha256:dcf8a73d2ff77e99dde91244b4da16feba7f14e6beeb4015dee7c5a909e99050", size = 4921251, upload-time = "2026-09-03T16:57:29.037Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/bb/5a449b9162e49b139d72f61672bd3ac1d790221f796d3304e2241fff4c58/ruff-0.16.7.tar.gz", hash = "sha256:5f71d004ac1263b22fa39462ac5ae618a4b77d58981af2cc79bf79a29c12b1a6", size = 4924184, upload-time = "2026-09-10T18:04:06.336Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/28/9cc1b79639e284ec103f43c88c644db4eb58cbd0ea1ca11f1193435369ac/ruff-0.16.6-py3-none-linux_armv6l.whl", hash = "sha256:61c368c26bf8e973e5ab14a2772de587bc068ea3f9a277f673380749b4898fb8", size = 10015638, upload-time = "2026-09-03T16:56:40.986Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/11/627d342ef727ea7794edf74fe23d60a074b02c3acc2e9436684e782286ca/ruff-0.16.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ecf4f068e2e123e43a26e9db4e19524cc56563912404e83bbfca375757e45a32", size = 10220762, upload-time = "2026-09-03T16:56:44.681Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/d9/b75668ce41e4c8d073d18d6d08672ba6906ce45d5c06ea4fdb2e84ce3853/ruff-0.16.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:99b62ea33baf130f50368798d841f0d95527b6d817bf31817b65dd058f1d314c", size = 9835082, upload-time = "2026-09-03T16:56:47.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/97/123ab10b05cde889c107c20f5a9774955104b5552796a2a8584b089ae8eb/ruff-0.16.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7fbf89013f2bb3f6835a6038ff658dc8a1b38c98dc8e724b964168ad4e881876", size = 9949304, upload-time = "2026-09-03T16:56:49.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/58/a4a2c59dd2e5b85929c912d9cac3056eb9ee8c7e75e9b9fe3e109174966b/ruff-0.16.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56a67065e22efa6bc4d498299d3bb06c0c90aace8fac2068b5a12f9dc4d8d51d", size = 9840612, upload-time = "2026-09-03T16:56:52.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/6a/ff8c8626a786c4f49d48ced4a752dadbca65f5263005f9c2416578194694/ruff-0.16.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e25cc89174874b176a157e4428d66761c2c0c006654419bf384f967f361ff1b1", size = 10543465, upload-time = "2026-09-03T16:56:55.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/bb/c47535923365f337b82e28192e4e9eef2176511007cfd99a62fc22df5dad/ruff-0.16.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0700580ed5303723cb3c11c2f1d2a8913ce77b7ea86646dddb887f5417a9ba70", size = 11267576, upload-time = "2026-09-03T16:56:57.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/50/e5119a5212b5cd63b51e1f4b25e7bd636a6668fc069a3160b108ad7e3c16/ruff-0.16.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15f1d0b6e165a6e56567befb6629f8209271311d990bae0f37e6d065035ef5f3", size = 10781993, upload-time = "2026-09-03T16:57:00.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/98/083d8b4ef3c51a0d19db84367791cbe9f44e4b53343d19dfa83556e1cd9a/ruff-0.16.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d72c591a96986ee4268860e2b7235082129ca5e4cb9cbba653a4b57c11893757", size = 10317748, upload-time = "2026-09-03T16:57:03.428Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/29/68f7ff2c5ad95f19f00627ac2de95644e25fe47371ea60b2db1fd952315e/ruff-0.16.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:65a006baa18f33324325814c864daef03541d51564b98c517610ea756ab7003e", size = 10540096, upload-time = "2026-09-03T16:57:06.182Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/f9/79a8f6de85968641d68a7863aeec577551924ef066a990a48ff93167beab/ruff-0.16.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:cd02a7bf1a21a8735228a3e8c95a9dc5cf86bd2a52194f4aaae2a5755b4de0f4", size = 10100494, upload-time = "2026-09-03T16:57:09.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/e8/b81a22d9b90c00b892ccf2fa2ac36fa95de4c13ab85aea3e73795cfe4651/ruff-0.16.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:31b36f1e5ad85e0737f09d2be4e512e2e283583c14015da3b9dc07359ac0fc88", size = 9843663, upload-time = "2026-09-03T16:57:12.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/aa/54f516ec5e5a11c4afdceb1c454ebb054ffb96e4f4a1705580b4346abd35/ruff-0.16.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:61029b4ab4aa723fd3064fab96b1d814492596bf0c792679fffcbde1e1679953", size = 10282461, upload-time = "2026-09-03T16:57:15.077Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/0b/38d0aa8aa32372b96dc44f97b22e576c4147808271aab7b2cb1e353d4445/ruff-0.16.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9ac8998457832c2061709d900856b7ad271dace0cb41f346588d540162bfa718", size = 10728808, upload-time = "2026-09-03T16:57:17.797Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/e5/9e274e24eeb027640ffc7442f21239f16d17f47acec15ae34f32e03a5c79/ruff-0.16.6-py3-none-win32.whl", hash = "sha256:0b87d9d16fcb63e8018423ca1d50b7260f15cb2da33e30db4baad4183a948c25", size = 10049212, upload-time = "2026-09-03T16:57:20.55Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/31/72472449414223ed1a2da236b992adbb1a2ae59e34794574810f60ce068e/ruff-0.16.6-py3-none-win_amd64.whl", hash = "sha256:10d21c51c3495d8eaea7b703a16592117ea6eb1d649e36335aa965ff1173eb39", size = 10556402, upload-time = "2026-09-03T16:57:23.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850, upload-time = "2026-09-03T16:57:26.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/b2/c80aeeb7f9e469c0d63a85d2f1ab6e1ebfbe10ea7a8d2438b7e09e3ff09e/ruff-0.16.7-py3-none-linux_armv6l.whl", hash = "sha256:727307773e7c7f9181d3ed3a2484186e56c1fa1874255911c74585eb2c7c19f9", size = 10048917, upload-time = "2026-09-10T18:03:30.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/96/20bb7bcae008004df52afcb7ac83432d4a467f2c17b672fe46d26be231c5/ruff-0.16.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d61c258deabf58f34c67bd4bb4d939c7f2e6b5f0e59c1cdd1cf771b11cde929", size = 10242929, upload-time = "2026-09-10T18:03:32.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/b2/f184b0d5abec02db69cfd7e49b688ae0237554528ca777136c613bf36bee/ruff-0.16.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ab81118df8945e0193d0240712aa4496573595b75185c3636ed825592a0f728", size = 9847245, upload-time = "2026-09-10T18:03:34.509Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/2d/db1633a641866ed801e34cc6b60ef236c5e16f9b2124ab1d49cc24a5fe4f/ruff-0.16.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c196c968874fc8019da8e7163de7a1a370f111e2309b4b7dfea0fce950198d0", size = 9961780, upload-time = "2026-09-10T18:03:36.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/98/edea21e1a3e38dbbc3bf6bb068b863b3b06184cf8533a4c7dbbe208a89d5/ruff-0.16.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac8c3bd0a7e10ad31e6ce51e7a99f3cb772e69aecdd6b9ea7e99b362f62a62c0", size = 9866337, upload-time = "2026-09-10T18:03:38.805Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/11/a15e60d4c87b214646f116ca9d204475bf993ee1047459bc9a360fd4d6d1/ruff-0.16.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:398d3988edde000b5c75dc1b3f584708da9bc990de069c18909142580fec1af9", size = 10562512, upload-time = "2026-09-10T18:03:40.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/42/eaff4c9b6d0c7cdf56df313a17e89ae854f5bbc0b0c8f9cce19be0ab7a8f/ruff-0.16.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce05b62b770a8217c4646a9c4139fca00efe8fe5d71f87df2b243ff20d4584d1", size = 11302938, upload-time = "2026-09-10T18:03:42.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/43/c75aa59a4ec181fe2ec06cab30e198c1c6d107229a9f008ae3a7c16cabd8/ruff-0.16.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af1b576fddb9d9ef2ececfb5fadcd6a624b25070ed85e3cfcfe449fc3ff6a7b9", size = 10840857, upload-time = "2026-09-10T18:03:44.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/33/81f3da371942ea031105ba679d8d6e28ec1660ccd690a45f42d381161356/ruff-0.16.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ce7f8f22df67c93ed96c717f9128eadb797144ac2bad475cf536f31d6100c55", size = 10370001, upload-time = "2026-09-10T18:03:46.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/0b/6345fb4dbf6dd0ed1cfe5d18391dc9c3f59cc81622a7b0a65b84b3e730ba/ruff-0.16.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:06d0e93d04f392996435ebd600c153f65b47d73fbec2415aa99c5ee5756b3a5f", size = 10548735, upload-time = "2026-09-10T18:03:48.658Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/4d/c5576adf511f92a328e5569dda190ecdd430da51f1a649f3a4a2fd73e21e/ruff-0.16.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:142151a5e7b93c1b11111337142f89dd2fbfee92161225c99a97222f22e32656", size = 10108496, upload-time = "2026-09-10T18:03:50.563Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/8c/667d83c16199a17a56adc6b0bd4c3beb5b767a2babcd16a56f76f9be7fd6/ruff-0.16.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e6651f97a342d8b35d54d8991544ca22169b86dc54111cb604666940c431b750", size = 9860136, upload-time = "2026-09-10T18:03:52.621Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/75/78d401106731999a1dd20cc5a6961e37e1eb9397a3b589f73f3a5ce146a3/ruff-0.16.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ef140c6eb935fa9a84c9c607dfb2cb1b85843c192e79265b0c54f35f557ea8e5", size = 10286290, upload-time = "2026-09-10T18:03:55.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/49/56f9c3a8b755df93a0ad318b2147bf4ef5dae9a7e5ec61c460109c67957f/ruff-0.16.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:53e39506a730fadeee0d998ed5946f30671f0db240c6c7c73bdabbe33604bb6f", size = 10745048, upload-time = "2026-09-10T18:03:57.299Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/ea/7f9b938a63ece4bec677ad7f9f7fa02df3383db1949ed93a382441c09a87/ruff-0.16.7-py3-none-win32.whl", hash = "sha256:2ea3470fcebcbc5df2fb0c6f3b90333fa9084c534e0111c038fa4a6ab9f1c4b7", size = 10059082, upload-time = "2026-09-10T18:03:59.632Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/11/480a6973a927aa653e1cead6a6416008640e03a99d05b34c0434b8c6c366/ruff-0.16.7-py3-none-win_amd64.whl", hash = "sha256:7ac26aca826e9e21d0f1cb25b54ac660760a9fdd094d3e4df9848232be98cfc6", size = 10593368, upload-time = "2026-09-10T18:04:01.999Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in new issue
Block a user