Compare commits

...
13 Commits
Author SHA1 Message Date
Victor Kuznetsov 4241a01659 Release 0.45.1 2026-10-08 16:42:57 -07:00
Victor Kuznetsov c37378b347 Merge pull request #117 from dichiara19/fix/sdxl-zimage-global-offload
Stream the sdxl-zimage global stack on cards below 12 GiB
2026-10-08 16:34:43 -07:00
Victor Kuznetsov 8cb99b73b2 Fix SDXL offload placement on the current CUDA device 2026-10-08 16:17:11 -07:00
Giuseppe G. Di Chiara c45e6db3e4 Stream the sdxl-zimage global stack on cards below 12 GiB
The SDXL global stack (fp16 UNet, Canny ControlNet, both text encoders, VAE)
is ~8.8 GiB and was always moved to CUDA with .to(device), so it could not
fit an 8 GiB card and --cpu-offload never reached it. Under the Windows
display driver, WSL2 included, the overflow spilled into shared system
memory and the four-step pass ran for over ten minutes instead of failing.

Residency now follows the same rule as the Qwen stack: an explicit request
wins, otherwise the card's total VRAM decides against
RESIDENT_SDXL_GLOBAL_MIN_VRAM_GIB. Below it the Lightning LoRA is fused on
the CPU and the stack is streamed with enable_sequential_cpu_offload, the
fastest placement that completed on every run of an 8 GiB card with 16 GiB
of system RAM. sdxl-zimage joins GLOBAL_OFFLOAD_PROFILES, so the
--cpu-offload warning now fires for chroma-zimage only.
2026-10-08 23:27:55 +02:00
Victor Kuznetsov 37fb080c74 Merge pull request #116 from wiltodelta/dependabot/uv/minor-and-patch-931ef6a6bd 2026-10-08 10:30:09 -07:00
dependabot[bot] 954298aa14 Bump the minor-and-patch group with 3 updates
Bumps the minor-and-patch group with 3 updates: [python-dotenv](https://github.com/theskumar/python-dotenv), [openai](https://github.com/openai/openai-python) and [ruff](https://github.com/astral-sh/ruff).


Updates `python-dotenv` from 1.2.3 to 1.2.4
- [Release notes](https://github.com/theskumar/python-dotenv/releases)
- [Changelog](https://github.com/theskumar/python-dotenv/blob/main/CHANGELOG.md)
- [Commits](https://github.com/theskumar/python-dotenv/compare/v1.2.3...v1.2.4)

Updates `openai` from 3.22.1 to 3.24.0
- [Release notes](https://github.com/openai/openai-python/releases)
- [Changelog](https://github.com/openai/openai-python/blob/main/CHANGELOG.md)
- [Commits](https://github.com/openai/openai-python/compare/v3.22.1...v3.24.0)

Updates `ruff` from 0.16.9 to 0.16.10
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.16.9...0.16.10)

---
updated-dependencies:
- dependency-name: python-dotenv
  dependency-version: 1.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: openai
  dependency-version: 3.24.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: ruff
  dependency-version: 0.16.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-07 08:48:45 +00:00
Victor KuznetsovandClaude Opus 5.5 10c7775990 Hide maintainer skills from public skill discovery
The skills CLI discovers every SKILL.md under .claude/skills, so
provider-oracles and signal-discovery were offered by `npx skills add`
and listed on skills.sh beside the published skill. Mark both
metadata.internal, the CLI's documented hide flag, and fail the suite
on any project skill that lacks it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 20:04:18 -07:00
Victor KuznetsovandClaude Opus 5.5 3f6447b23d Record how to run the gate from an agent session
The full gate outlasts the agent's 10-minute foreground shell cap, so it must
run detached with its exit code written to a file. A stray run is stopped by
its own PID, because a pattern kill also ends other sessions' suites.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 18:25:14 -07:00
Victor KuznetsovandClaude Opus 5.5 cc63bfd7bf Release 0.45.0
Bump the package to 0.45.0 and the agent skill to 1.0.11. The skill now names
the dola and workbuddy image mark keys, which 0.44.0 rejects, so the probe's
MIN_CLI_VERSION rises to 0.45.0 and both keys carry a "since CLI" note that
the floor test reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:45:18 -07:00
Victor Kuznetsov 898f8be789 Add AI disclosure layouts, tighten detection and provenance warnings 2026-10-04 11:03:37 -07:00
Victor KuznetsovandClaude Opus 5.5 72752feb33 Scan uv.lock in CI and add a security policy
Add a CI job running the maintain.sh uv-secure scan, pinned to the gate by
a parity test, and a SECURITY.md with private reporting and dependency
remediation. Refresh the security-gate block record for accelerate 1.15.0.

Closes #114

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:33:50 -07:00
Victor KuznetsovandClaude Opus 5.5 70adddf960 Record why Qwen-Image-2.1 is not a regeneration profile
Open weights remove the hosted-only objection to the 2.x line, but the
Diffusers pipeline has no strength or latent noise-mask input and the Qwen
Research License is non-commercial (issue #111).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:33:49 -07:00
Victor Kuznetsov 01a2665181 Reconcile research follow-up status 2026-10-01 22:10:00 -07:00
65 changed files with 1487 additions and 233 deletions

No files matched your search

+1 -1
View File
@@ -6,7 +6,7 @@
},
"metadata": {
"description": "Agent skill and Claude Code plugin for the remove-ai-watermarks CLI.",
"version": "1.0.10"
"version": "1.0.11"
},
"plugins": [
{
+4 -1
View File
@@ -79,9 +79,12 @@ The same rule applies to install hints: name the extra that actually makes the c
Run `bash maintain.sh` from the repository root. The authoritative type gate is scoped to `src/`; full-project Pyright can exhaust Node memory on the ML dependency graph.
The full gate outlasts the agent's 10-minute foreground shell cap, which kills it mid-suite. Run it detached under `nohup caffeinate -dimu` with its exit code written to a file and poll that file. To stop a stray run, kill its own PID, never `pkill -f pytest`, which also takes down other sessions' suites on the machine.
The security scanner's exit status is authoritative, including a failure after a success
message. `tests/test_maintenance.py` checks clean, vulnerable, and broken scanner
runs through the real shell entry point. Recheck unresolved advisories using the
runs through the real shell entry point, and pins the CI `security` job to the
same scanner line with no `continue-on-error`. Recheck unresolved advisories using the
procedure in `docs/development.md`; do not revive the old text-matching bypass.
Boundary modules for cv2, Torch, and Diffusers may carry narrow per-file relaxations for unknown third-party types. Keep pure-logic files strict, preserve the local piexif stub, and fix real errors before widening a pragma.
+2
View File
@@ -1,6 +1,8 @@
---
name: provider-oracles
description: Run hash-bound Google, OpenAI, Microsoft, or Meta watermark-oracle checks for this repository. Use for provider verification, multi-account Gemini checks, isolated Playwright checks, or recording oracle evidence.
metadata:
internal: true
---
# Provider oracles
+2
View File
@@ -1,6 +1,8 @@
---
name: signal-discovery
description: Discover missing AI provenance and watermark coverage in the remove-ai-watermarks library from fresh or historical corpora, then validate detection and removal parity. Use for corpus gap mining, periodic signal audits, new-provider discovery, or deciding whether old retained data must be replayed. Do not use for ordinary per-file identification or removal.
metadata:
internal: true
---
# Signal discovery
+13
View File
@@ -29,6 +29,19 @@ jobs:
- name: Ruff format check
run: uv run ruff format --check
security:
name: lockfile vulnerability scan
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v7
with:
enable-cache: false
# Policy and triaged blocks: docs/development.md "Known security-gate blocks".
- name: Scan uv.lock with uv-secure
run: uvx uv-secure uv.lock
test:
name: test ${{ matrix.os }} py${{ matrix.python-version }}
runs-on: ${{ matrix.os }}
+7 -6
View File
@@ -370,11 +370,11 @@ remove-ai-watermarks batch ./images --mode all
Visible mark support includes:
- Google Gemini and Nano Banana visible sparkle watermark;
- Doubao, the Jimeng wordmark and top-left `AI生成` pill, Qwen, Kling AI,
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;
- Doubao, the Jimeng wordmark and top-left `AI生成` layouts, Qwen, Kling AI,
Yuanbao, Dola, WorkBuddy, Baidu, LiblibAI's bottom-center wordmark and compact
top-left pill, and RunningHub labels;
- calibrated Microsoft top-right white and beige AI-badge layouts;
- calibrated Italian and Korean Samsung Galaxy AI disclosures;
- 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
@@ -571,7 +571,8 @@ bash maintain.sh
```
See [module internals](docs/module-internals.md) before changing a subsystem
with documented invariants.
with documented invariants. Report vulnerabilities as described in
[SECURITY.md](SECURITY.md).
## License
+25
View File
@@ -0,0 +1,25 @@
# Security policy
## Supported versions
Security fixes go into the latest release. Older releases are not patched;
upgrade through the channel you installed from (PyPI or Homebrew).
## Reporting a vulnerability
Report vulnerabilities privately through
[GitHub private vulnerability reporting](https://github.com/wiltodelta/remove-ai-watermarks/security/advisories/new).
Do not open a public issue for an unpatched vulnerability. Include the affected
version, the command or API call, and a minimal input that reproduces it.
Vulnerabilities in a dependency, such as a CVE in a package from `uv.lock`, can
be reported as a regular issue when the advisory is already public.
## Dependency vulnerabilities
Dependabot raises alerts and update pull requests for `uv.lock` and GitHub
Actions, and CI scans `uv.lock` with
[`uv-secure`](https://github.com/owenlamont/uv-secure) on every push and pull
request. A vulnerable dependency is upgraded to a fixed release; advisories
without one are tracked under
[Known security-gate blocks](docs/development.md#known-security-gate-blocks).
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

+2 -2
View File
@@ -6,9 +6,9 @@ qwen,Alibaba Qwen consumer app,visible text,data/fixtures/visible/qwen/provider-
kling,Kuaishou Kling AI,visible text,data/fixtures/visible/kling/provider-original-direct.png,available,authenticated Kling IMAGE 3.0 original; Latin KlingAI 3.0 mark detected at confidence 0.43; TC260 producer maps to Kuaishou,subject to Kling AI terms; noncommercial fixture
yuanbao,Tencent Yuanbao,visible two-line text,data/fixtures/visible/yuanbao/provider-original.png,available,authenticated Yuanbao original; detected by Yuanbao engine at confidence 0.49; TC260 producer metadata maps to Tencent,repository
samsung,Samsung Galaxy AI,visible Italian text,data/calibration/samsung/samsung_gray_1.png,available,original provider capture and detector calibration,repository
runninghub,RunningHub,visible text,,missing,no redistributable provider original found,
runninghub,RunningHub,visible text,data/fixtures/visible/runninghub/example.png,partial,synthetic detector fixture available; no redistributable provider original found,repository
baidu,Baidu,visible text and tag,data/fixtures/visible/baidu/provider-original.jpeg,available,detected by Baidu engine; distributed under the OpenNoMark repository's CC BY-NC-SA 4.0 license,CC-BY-NC-SA-4.0; excluded from Apache-2.0
liblib,LiblibAI,visible wordmark,data/fixtures/visible/liblib/current-provider-output.png,partial,"authenticated current LiblibAI original carries a top-left AI生成 pill rather than the registered historical bottom-center LiblibAI wordmark; TC260 producer metadata maps to LiblibAI; historical positive remains missing",repository
liblib,LiblibAI,visible wordmark,data/fixtures/visible/liblib/current-provider-output.png,partial,"authenticated current LiblibAI original carries a top-left AI生成 pill rather than the registered historical bottom-center LiblibAI wordmark; TC260 producer metadata maps to LiblibAI; a synthetic historical-wordmark fixture is available at data/fixtures/visible/liblib/example.png, but a historical provider positive remains missing",repository
microsoft,Microsoft image output,visible top-right AI badge,data/fixtures/visible/microsoft/provider-original.png,available,authenticated Copilot original detected by Microsoft engine at confidence 0.54; also has valid C2PA and InvisMark,repository
jimeng_pill,ByteDance Jimeng,visible AI pill,data/fixtures/visible/jimeng_pill/provider-published-example.jpg,available,"real Jimeng output embedded in page 29 of Zhonglun W&D's 2026 Bank Finance Legal Newsletter; direct jimeng_pill detection confidence 0.28; the same image also carries the Jimeng wordmark",fair-use research fixture; excluded from Apache-2.0
google_synthid,Google,image SynthID,data/synthid/originals/Gemini_Generated_Image_633uuy633uuy633u.png,available,provider oracle recorded in data/synthid/manifest.csv,repository
1 key provider watermark_type path status verification license
6 kling Kuaishou Kling AI visible text data/fixtures/visible/kling/provider-original-direct.png available authenticated Kling IMAGE 3.0 original; Latin KlingAI 3.0 mark detected at confidence 0.43; TC260 producer maps to Kuaishou subject to Kling AI terms; noncommercial fixture
7 yuanbao Tencent Yuanbao visible two-line text data/fixtures/visible/yuanbao/provider-original.png available authenticated Yuanbao original; detected by Yuanbao engine at confidence 0.49; TC260 producer metadata maps to Tencent repository
8 samsung Samsung Galaxy AI visible Italian text data/calibration/samsung/samsung_gray_1.png available original provider capture and detector calibration repository
9 runninghub RunningHub visible text data/fixtures/visible/runninghub/example.png missing partial no redistributable provider original found synthetic detector fixture available; no redistributable provider original found repository
10 baidu Baidu visible text and tag data/fixtures/visible/baidu/provider-original.jpeg available detected by Baidu engine; distributed under the OpenNoMark repository's CC BY-NC-SA 4.0 license CC-BY-NC-SA-4.0; excluded from Apache-2.0
11 liblib LiblibAI visible wordmark data/fixtures/visible/liblib/current-provider-output.png partial authenticated current LiblibAI original carries a top-left AI生成 pill rather than the registered historical bottom-center LiblibAI wordmark; TC260 producer metadata maps to LiblibAI; historical positive remains missing authenticated current LiblibAI original carries a top-left AI生成 pill rather than the registered historical bottom-center LiblibAI wordmark; TC260 producer metadata maps to LiblibAI; a synthetic historical-wordmark fixture is available at data/fixtures/visible/liblib/example.png, but a historical provider positive remains missing repository
12 microsoft Microsoft image output visible top-right AI badge data/fixtures/visible/microsoft/provider-original.png available authenticated Copilot original detected by Microsoft engine at confidence 0.54; also has valid C2PA and InvisMark repository
13 jimeng_pill ByteDance Jimeng visible AI pill data/fixtures/visible/jimeng_pill/provider-published-example.jpg available real Jimeng output embedded in page 29 of Zhonglun W&D's 2026 Bank Finance Legal Newsletter; direct jimeng_pill detection confidence 0.28; the same image also carries the Jimeng wordmark fair-use research fixture; excluded from Apache-2.0
14 google_synthid Google image SynthID data/synthid/originals/Gemini_Generated_Image_633uuy633uuy633u.png available provider oracle recorded in data/synthid/manifest.csv repository
+3 -3
View File
@@ -1,8 +1,8 @@
key,provider,watermark_type,path,status,verification,license
sora,OpenAI Sora,visible moving mascot and wordmark,data/fixtures/visible/sora/provider-original.mp4,available,"Apache-2.0 upstream original; detected by Sora engine on 300/300 frames; C2PA claim names Sora and OpenAI/Truepic, with valid integrity and signature but an expired untrusted signer certificate",Apache-2.0
veo,Google Veo,visible fixed text,data/fixtures/visible/veo/provider-original.mp4,available,"authenticated Google Flow Veo 3.1 Fast original; detected by Veo engine on 192/192 frames; valid C2PA and provider-confirmed SynthID",repository
seedance,ByteDance Seedance,visible boxed AI label,,missing,"OpenRouter Seedance 2.0 Mini with watermark=true produced BytePlus C2PA but no visible boxed AI label; direct consumer original still needed",
doubao,ByteDance Doubao,visible fixed text,,missing,no redistributable provider original found,
dola,ByteDance Dola,visible fixed text,,missing,no redistributable provider original found,
seedance,ByteDance Seedance,visible boxed AI label,data/fixtures/visible/seedance/example.mp4,partial,"synthetic detector fixture available; OpenRouter Seedance 2.0 Mini with watermark=true produced BytePlus C2PA but no visible boxed AI label; direct consumer original still needed",repository
doubao,ByteDance Doubao,visible fixed text,data/fixtures/visible/doubao/example.mp4,partial,"synthetic detector fixture available; a real public carrier is retained locally but is not publication-cleared",repository
dola,ByteDance Dola,visible fixed text,data/fixtures/visible/dola/example.mp4,partial,synthetic detector fixture available; no redistributable provider original found,repository
hailuo,MiniMax Hailuo AI,visible composite label,data/fixtures/visible/hailuo/provider-original.mp4,available,"authenticated Hailuo AI MiniMax H3 original downloaded with With Watermark selected; detected by Hailuo engine on 124/124 frames; TC260 AIGC label names MiniMax",repository
kling,Kuaishou Kling AI,visible fixed logo and wordmark,data/fixtures/visible/kling/provider-original.mp4,available,"authenticated Kling AI VIDEO 3.0 original; detected by Kling engine on 121/121 frames; no locally readable provenance metadata",Kling AI terms; noncommercial fixture
1 key provider watermark_type path status verification license
2 sora OpenAI Sora visible moving mascot and wordmark data/fixtures/visible/sora/provider-original.mp4 available Apache-2.0 upstream original; detected by Sora engine on 300/300 frames; C2PA claim names Sora and OpenAI/Truepic, with valid integrity and signature but an expired untrusted signer certificate Apache-2.0
3 veo Google Veo visible fixed text data/fixtures/visible/veo/provider-original.mp4 available authenticated Google Flow Veo 3.1 Fast original; detected by Veo engine on 192/192 frames; valid C2PA and provider-confirmed SynthID repository
4 seedance ByteDance Seedance visible boxed AI label data/fixtures/visible/seedance/example.mp4 missing partial OpenRouter Seedance 2.0 Mini with watermark=true produced BytePlus C2PA but no visible boxed AI label; direct consumer original still needed synthetic detector fixture available; OpenRouter Seedance 2.0 Mini with watermark=true produced BytePlus C2PA but no visible boxed AI label; direct consumer original still needed repository
5 doubao ByteDance Doubao visible fixed text data/fixtures/visible/doubao/example.mp4 missing partial no redistributable provider original found synthetic detector fixture available; a real public carrier is retained locally but is not publication-cleared repository
6 dola ByteDance Dola visible fixed text data/fixtures/visible/dola/example.mp4 missing partial no redistributable provider original found synthetic detector fixture available; no redistributable provider original found repository
7 hailuo MiniMax Hailuo AI visible composite label data/fixtures/visible/hailuo/provider-original.mp4 available authenticated Hailuo AI MiniMax H3 original downloaded with With Watermark selected; detected by Hailuo engine on 124/124 frames; TC260 AIGC label names MiniMax repository
8 kling Kuaishou Kling AI visible fixed logo and wordmark data/fixtures/visible/kling/provider-original.mp4 available authenticated Kling AI VIDEO 3.0 original; detected by Kling engine on 121/121 frames; no locally readable provenance metadata Kling AI terms; noncommercial fixture
+5
View File
@@ -26,6 +26,11 @@ SkillsMP indexes public GitHub `SKILL.md` files and does not need a separate
upload. ClawHub and the Claude community marketplace are explicit publishes;
see [Release and distribution](release-and-distribution.md#agent-skill).
Every maintainer skill under `.claude/skills/` carries `metadata.internal: true`.
The skills CLI discovers every `SKILL.md` in that directory, so without the flag
`npx skills add` offers it and skills.sh lists it beside the published skill.
`tests/test_agent_skill.py` fails on a project skill that lacks it.
## Where the skill can actually be listed
Most skill catalogs are install registries, not discovery engines. A listing
+32
View File
@@ -1597,6 +1597,38 @@ Local reproducibility artifacts are under
`contamination-scan-2026-08-26`, and `taxonomy-continuation-clean-2026-08-26`
run directories.
## Modern frozen-backbone campaign closed, 2026-09-27
A second pre-registered representation campaign tested exact-revision,
Apache-2.0 frozen vision backbones with universal ridge heads against the same
product contract. The candidates were `google/siglip2-base-patch16-224` at
revision `75de2d55ec2d0b4efc50b3e9ad70dba96a7b2fa2` and
`facebook/PE-Core-B16-224` at revision
`a16450b46fef32363459920c2685a1b4ef13dcd9`. The protocol was frozen before
inference and used 28,743 unique source images. Its comparison cells contained
1,873 AI-test images, 3,000 fresh Open Images photographs, 24 Kodak images, and
300 FLUX images.
| Model at its frozen operating point | AI test | Fresh photo false positives | Kodak false positives | FLUX |
| --- | ---: | ---: | ---: | ---: |
| Production Model 1 baseline | 1,676/1,873 (89.5%) | 35/3,000 (1.17%) | 0/24 | 256/300 (85.3%) |
| SigLIP2 universal ridge | 1,602/1,873 (85.5%) | 74/3,000 (2.47%) | 0/24 | 125/300 (41.7%) |
| PE-Core B universal ridge | 1,715/1,873 (91.6%) | 86/3,000 (2.87%) | 0/24 | 170/300 (56.7%) |
SigLIP2 failed the AI-test, fresh-photo, and FLUX gates. PE-Core improved the
AI-test cell but failed the fresh-photo and FLUX gates. Matching the baseline's
exact 35-false-positive photo budget after the fact did not recover the lost
coverage: SigLIP2 reached 1,505/1,873 AI and 98/300 FLUX, PE-Core universal
reached 1,625/1,873 and 133/300, and PE-Core photo-only reached 1,709/1,873 and
97/300. No threshold matched the baseline photo budget, AI coverage, and FLUX
coverage simultaneously.
The bounded newer-backbone hypothesis is therefore closed as a Model 1
replacement. Keep `clip-l-ft.pt`, its ONNX graph, hashes, and Hub revision
unchanged. Reproducibility artifacts are local under
`.local-eval/synthid/ai-photo-2026-08-22/modern-representation-campaign-2026-09-27/`;
the campaign did not enter the publication stage.
### Wild extras, not SynthID
| Hypothesis | 2026-08-23 | Use |
+12 -6
View File
@@ -29,7 +29,7 @@ Do not add advisory ignores or suppress the scanner's exit status. Run and
report Ruff, Pyright scoped to `src/`, and tests separately when security blocks
the gate.
Rechecked on 2026-09-10 against the locked versions and the advisory sources:
Rechecked on 2026-10-02 against the locked versions and the advisory sources:
- `lightning` and `pytorch-lightning`, pulled by the optional `trustmark`
extra, are pinned to 2.6.6. That release clears PYSEC-2026-3624 and
@@ -37,18 +37,19 @@ Rechecked on 2026-09-10 against the locked versions and the advisory sources:
- `accelerate`, required by the diffusion stack, is affected by
[GHSA-4j2p-28q2-5m79](https://github.com/advisories/GHSA-4j2p-28q2-5m79)
and PYSEC-2026-3804. The sharded-checkpoint loader accepts unsafe
`weight_map` paths; neither advisory lists a patched release. 1.15.0 is
on PyPI and is outside the advisory's `<= 1.14.0` range, but its
`weight_map` paths; neither advisory lists a patched release. The lock
carries 1.15.0, which is outside the advisory's `<= 1.14.0` range, so the
scanner no longer blocks the gate. Its
`load_checkpoint_in_model` still joins `weight_map` entries with no
containment check, and the sanitizer PRs
([#4138](https://github.com/huggingface/accelerate/pull/4138),
[#4214](https://github.com/huggingface/accelerate/pull/4214)) did not
land. Do not bump solely to silence the scanner. The project has no
[#4214](https://github.com/huggingface/accelerate/pull/4214)) were closed
without merging. Do not treat the passing scan as a fix. The project has no
direct call to `load_checkpoint_in_model` or
`load_checkpoint_and_dispatch`, but this does not prove indirect
model-loading paths are unaffected.
Before carrying the remaining block forward, rerun `uvx uv-secure uv.lock` and check
When a new block appears, or before carrying one forward, rerun `uvx uv-secure uv.lock` and check
PyPI for a fixed release. Upgrade a fixed package with
`uv lock --upgrade-package <package>` and rerun the gate. Removing the current
dependency would remove a supported optional feature, so that is not a
@@ -98,6 +99,11 @@ run the full-clip video test. Diffusion and model-running tests skip in that
matrix; metadata, identification, visible removal, the DWT-DCT decoder, and the
OpenCV eraser remain covered across operating systems.
A separate `security` job runs the `maintain.sh` lockfile scan, so an advisory
without a released fix turns `main` red; see
[Known security-gate blocks](#known-security-gate-blocks). Vulnerability
reporting is described in [`SECURITY.md`](../SECURITY.md).
Keep `uv.lock` compatible with `uv sync --frozen`. Dependency pull-request checks use GitHub's merge result against current `main`; if `main` moves, merge it locally and rerun the full gate because a newer linter can expose stale directives in later code.
`test.yml` runs one concurrency group per ref with `cancel-in-progress: true`, so a newer push cancels the previous run mid-flight. A cancelled run is not a failure verdict: the superseding run's head includes the cancelled commits as ancestors, so watch that run to completion instead of retrying the push.
+20 -9
View File
@@ -179,9 +179,12 @@ fresh-font and silhouette variants visibly changed typography. The higher-fideli
and line geometry. It builds its donor internally but still requires an independently
clean global anchor. Automatic OCR and line-box proposals are not reliable enough to
remove those requirements, and exact oracle results do not establish a general mask,
seed, runtime, or provider operating range. Qwen-Image-2.0 is hosted-only and exposes
no equivalent low-strength denoise control. Exact experiments, controls, and pass
rates are kept in
seed, runtime, or provider operating range. Qwen-Image-2.0 is hosted-only. Qwen-Image-2.1
(issue #111) has open weights, but its Diffusers pipeline uses `image=` only as
reference conditioning and starts the target latents from noise, with no `strength`
or latent noise-mask input. Its Qwen Research License also limits the weights to
non-commercial research and evaluation, so it is not a supported-profile candidate.
Exact experiments, controls, and pass rates are kept in
[`text-protection-research.md`](text-protection-research.md) and the
[`fidelity` evaluation record](../data/evaluations/fidelity/README.md).
@@ -397,12 +400,20 @@ global Qwen stage is tiled; the face stage runs after tile blending.
every profile, streaming weights instead of pinning them. It reduces CUDA memory
pressure at the cost of speed.
It reaches the global stack of `qwen-zimage` only. That stack is each
profile's own, and `chroma-zimage` and `sdxl-zimage` load theirs with a plain
`.to(device)`. On a card below the face-stage residency floor, where the face
stack was already offloading, the flag changes nothing at all for those two: the
run behaves as if it were absent. The library says so on the way past, as a
warning ahead of the model load.
It reaches the global stack of `qwen-zimage` and `sdxl-zimage`. That stack is
each profile's own, and `chroma-zimage` loads its stack with a plain `.to(device)`.
On a card below the face-stage residency floor, where the face stack was already
offloading, the flag changes nothing at all for that profile: the run behaves as
if it were absent. The library says so on the way past, as a warning ahead of the
model load.
`sdxl-zimage` streams its global stack without being asked on a card below
`RESIDENT_SDXL_GLOBAL_MIN_VRAM_GIB` (12 GiB). Its UNet and Canny ControlNet run
together on every step, so the stack does not fit an 8 GiB card even with
model-level offload. A resident load there does not always fail loudly: under
the Windows display driver, including WSL2, CUDA can spill the overflow into
shared system memory, and the run goes on at a fraction of the speed instead of
raising an out-of-memory error.
Residency is otherwise chosen from the card's total VRAM. On a card large enough
to hold a stack, offloading is pure waste: DiffSynth drops weights to the meta
+90 -30
View File
@@ -743,8 +743,11 @@ Key contracts:
A.1 string (`"Label":"1","ContentProducer":"...","ProduceID":"..."`) under the
default SM2 user ID, and MiniMax's `LabelMataData` spelling is accepted beside
the guide's `Md`. Only the label signature is checked: MiniMax's binding
signature also verifies, but no verdict reads it, and its content hash is not
recomputed because MiniMax names no `CntSel`. The key travels in the file, so
signature also verifies, but no verdict reads it. The guide's appendix A.2
defines `DFT` for non-JPEG/PNG files as all bytes except the metadata label;
MiniMax names no `CntSel`, and measured whole-file, direct DFT byte-range,
metadata-box, and media-payload interpretations do not reproduce its content
hash. The key travels in the file, so
only a key pinned in `TC260_PRODUCERS` (the source of `TC260_SIGNING_KEYS`)
names a signer, and a pinned signer
is checked with the pinned point whatever parity the file claims; an unpinned
@@ -1593,8 +1596,8 @@ their own: the Gemini sparkle has its own higher-confidence path, and the pill
alone is too weak to attribute.
The set of marks that veto the Jimeng pill is derived from the registry rows:
every other product from the same manufacturer. Doubao therefore vetoes the pill
because both products are ByteDance, while Qwen, Kling, Yuanbao, RunningHub,
every other product from the same manufacturer. Doubao and Dola therefore veto the pill
because all three products are ByteDance, while Qwen, Kling, Yuanbao, RunningHub,
Baidu, and LiblibAI do not become siblings merely because they use the same TC260
standard. Marks from other manufacturers cannot enable or veto the
ByteDance-specific arm.
@@ -1653,8 +1656,7 @@ 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
template captures -- is a known, unresolved false-positive class. 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
@@ -1694,6 +1696,11 @@ Regression coverage:
[`_text_mark_engine.py`](../src/remove_ai_watermarks/_text_mark_engine.py)
provides common localization, detection front ends, template caching, rival
comparison, and footprint construction.
[`_text_mark_variants.py`](../src/remove_ai_watermarks/_text_mark_variants.py)
combines independently gated layouts under one registry key while retaining the
winning layout's template and mask geometry. Its bottom-right variant searches
only anchor-valid placements, so an unrelated stronger match elsewhere in the
corner ROI cannot hide the real edge-hugging mark.
Each vendor module supplies a `TextMarkConfig` and only the behavior that cannot
be represented by the shared base:
@@ -1703,6 +1710,8 @@ be represented by the shared base:
- [`qwen_engine.py`](../src/remove_ai_watermarks/qwen_engine.py)
- [`kling_engine.py`](../src/remove_ai_watermarks/kling_engine.py)
- [`yuanbao_engine.py`](../src/remove_ai_watermarks/yuanbao_engine.py)
- [`dola_engine.py`](../src/remove_ai_watermarks/dola_engine.py)
- [`workbuddy_engine.py`](../src/remove_ai_watermarks/workbuddy_engine.py)
- [`samsung_engine.py`](../src/remove_ai_watermarks/samsung_engine.py)
- [`runninghub_engine.py`](../src/remove_ai_watermarks/runninghub_engine.py)
- [`baidu_engine.py`](../src/remove_ai_watermarks/baidu_engine.py)
@@ -1747,7 +1756,10 @@ on 2026-09-04 uses `KlingAI 3.0`. The Latin template scored 0.429 on that 1024 x
across the 94 available neighboring real and synthetic image controls, while an
adversarial solid corner blob reached 0.379, so its strict gate is 0.40. This is
one-positive provisional calibration, intentionally separate from the older
CJK detector rather than presented as broad recall evidence.
CJK detector rather than presented as broad recall evidence. Both layouts now
project row-wide and column-wide structure out of the winning patch before a
second correlation. This preserves glyph detail while rejecting ordinary panel
and image boundaries that previously matched the Latin template.
The measured Microsoft badge variant (2026-08-27 registration) is the first
`tr`-corner mark and the first `long`-side scale basis: the pill tracks the
@@ -1758,7 +1770,8 @@ undersized the template by the aspect ratio (portrait carriers fell to
its synthetic internal shapes knocked out - the holes are what separate it from
any other bright rounded corner element (a plain white pill scores below the gate
in the tests). It does not claim coverage of Microsoft's other documented icon,
wording, or position variants.
wording, or position variants beyond the separately gated beige `Made with AI`
text pill.
The 2026-08-27 rerun used the registered engine through
`scripts/registered_mark_calibrate.py`, rather than a copied detector
@@ -1795,8 +1808,9 @@ box grows), not the whole `footprint_mask`. Baidu extends right to the corner ta
and LiblibAI extends left to the triangle logo; both inherit every guard around
that arithmetic.
Doubao, Kling, and Samsung are deliberate sparse-mask exceptions because their alpha assets
supply more information than a rectangle. Doubao's continuous top-hat response
Doubao, Kling, and Samsung retain deliberate sparse masks for their original
layouts because those alpha assets supply more information than a rectangle.
Doubao's continuous top-hat response
locates the mark, then `DoubaoEngine.footprint_mask` resizes the alpha to that same winning
`match_box` and masks only the glyphs. The canonical 2048-pixel fixture has bright
branch texture behind the bottom-edge wordmark; bounding the thresholded response,
@@ -1806,6 +1820,13 @@ with large triangular wedges. The aligned sparse mask keeps both frame edges
untouched and still clears the detector. `force` cannot align a missing detection,
so it retains the shared geometry-box fallback.
The added outlined Doubao, stacked Yuanbao, Dola, WorkBuddy, Korean Samsung, and
beige Microsoft layouts use solid detector-aligned footprints where a sparse
synthetic glyph mask left visible outlines, sparkles, capsules, or shadows in
rendered removal checks. Each layout keeps its own detection threshold and mask
geometry; selecting one does not replace the calibrated behavior of the older
layout under the same registry key.
Doubao keeps the original low-saturation scan for pale corners. When enough of
its corner is strongly colored and that scan falls below the strict gate, a
wider color filter searches for white glyphs tinted by the background. The
@@ -1933,14 +1954,12 @@ exists.
#### 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
Registered 2026-09-21 after a `visible`-mode run 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").
a bare OpenArt export.
**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
@@ -2004,10 +2023,14 @@ geometry, and until one exists the bare-arm question is settled by this
measurement, not by gate tuning. The local calibration harness is not tracked.
(cohorts A/B/C over the no-signal pool).
The weak Jimeng pill detector lives in
[`pill_engine.py`](../src/remove_ai_watermarks/pill_engine.py). It uses a
synthetic silhouette for detection and a fixed top-left footprint. Its public,
published real-world regression carrier is
The Jimeng pill detector lives in
[`pill_engine.py`](../src/remove_ai_watermarks/pill_engine.py). It combines the
existing weak synthetic-outline path with a high-confidence contrast template
for the measured solid `AI生成` label. A Jimeng provenance match can corroborate
that verified label without the flat-background guard; the weak outline keeps
both the provenance and background gates described above. Removal follows the
matched label when verified and otherwise uses the fixed top-left footprint. Its
public, published real-world regression carrier is
`data/fixtures/visible/jimeng_pill/provider-published-example.jpg`; the direct
detector scores the pill at 0.28 and the same image's Jimeng wordmark at 0.61.
@@ -2162,27 +2185,27 @@ Regression coverage:
### CPU offload
CPU offload is enabled only when requested. Nothing calls Diffusers'
`enable_model_cpu_offload` any more -- that belonged to the deleted single-stage
profiles. `--cpu-offload` sets both residency fields, and they do not travel the
same distance: the face field is read by the shared base and so applies to every
profile, while the global field is read by `QwenZImagePipeline` alone.
`ChromaZImagePipeline` and `SdxlZImagePipeline` load their global stack with a
plain `.to(device)` and never consult it.
Nothing calls Diffusers' `enable_model_cpu_offload` any more -- that belonged to
the deleted single-stage profiles. `--cpu-offload` sets both residency fields, and
they do not travel the same distance: the face field is read by the shared base and
so applies to every profile, while the global field is read by `QwenZImagePipeline`
and `SdxlZImagePipeline`. `ChromaZImagePipeline` loads its global stack with a
plain `.to(device)` and never consults it.
`GLOBAL_OFFLOAD_PROFILES` in `watermark_profiles.py` is the list, and
`global_offload_supported` the question; `WatermarkRemover._warn_if_global_offload_unsupported`
warns ahead of the model load rather than at construction, because `auto` picks
its engine per-image. An explicit `auto` preload uses the qwen-zimage fallback and
therefore keeps global offload active before that resolution. Below
`RESIDENT_FACE_MODEL_MIN_VRAM_GIB` the flag is a no-op for the other two profiles --
the face stack was offloading anyway and the global one does not ask -- which is
what the warning exists to say before a download starts.
`RESIDENT_FACE_MODEL_MIN_VRAM_GIB` the flag is a no-op for `chroma-zimage` -- the
face stack was offloading anyway and the global one does not ask -- which is what
the warning exists to say before a download starts.
Residency is otherwise chosen from the card's total VRAM, once per stack:
`resolve_global_model_residency` gates the mandatory Qwen stack at
`RESIDENT_GLOBAL_MODEL_MIN_VRAM_GIB` and `resolve_face_model_residency` gates the
optional Z-Image stack at `RESIDENT_FACE_MODEL_MIN_VRAM_GIB`.
`RESIDENT_GLOBAL_MODEL_MIN_VRAM_GIB`, `resolve_sdxl_global_residency` gates the
SDXL stack at `RESIDENT_SDXL_GLOBAL_MIN_VRAM_GIB`, and `resolve_face_model_residency`
gates the optional Z-Image stack at `RESIDENT_FACE_MODEL_MIN_VRAM_GIB`.
Below the global floor, `_qwen_vram_config` streams the stack from disk, which is
what makes a 20B model runnable on a consumer card. At or above it, streaming is
@@ -2199,6 +2222,43 @@ The resident config deliberately passes no `"disk"` value anywhere. DiffSynth la
`disk_offload` once, from `offload_dtype`, so leaving the sentinel in place while
pointing every device at CUDA would keep the meta-drop and re-read.
Sequential SDXL offload receives the current CUDA device index explicitly, so
its hooks use the same GPU as the residency probes, input tensors, and seeded
generators. Omitting the index makes Diffusers select GPU 0 even after
`torch.cuda.set_device(1)`. `test_streaming_uses_the_current_cuda_device` in
`test_cpu_offload.py` covers automatic and forced streaming on nonzero devices.
The SDXL stack (fp16 UNet, Canny ControlNet, both text encoders, the fp16-fix VAE)
is ~8.8 GiB of weights, and the UNet and the ControlNet run together on every
denoising step. Below the 12 GiB floor `_load_global` fuses the Lightning LoRA on
the CPU and then calls `enable_sequential_cpu_offload`; the order matters, because
the offload hooks stream their own copy of each weight and a later fuse would never
reach it. Measured on an RTX 5060 Laptop (8 GiB, WSL2, 16 GiB system RAM) in
October 2026, one 1672x941 global pass at strength 0.15:
| Placement | Warm pass | Peak VRAM | Peak system RAM |
|---|---|---|---|
| resident `.to(device)` (previous behaviour) | did not finish in 10 min | 7.9 GiB used, spilling | -- |
| `enable_model_cpu_offload` | 237 s | 9.2 GiB reserved on an 8 GiB card | 12.4 GiB |
| group offload, block level, CUDA stream | 92 s, or hung | 12.4 GiB reserved | 15.6 GiB |
| group offload, leaf level, CUDA stream | 7.8 s, or hung | 5.6 GiB | 15.7 GiB |
| group offload, leaf level, `low_cpu_mem_usage` | 21.4 s | 5.6 GiB | 9.4 GiB |
| `enable_sequential_cpu_offload` | 12.8 s | 3-4 GiB | 8.5 GiB |
The resident and model-offload rows did not fail: under the Windows display driver
CUDA falls back to shared system memory, so PyTorch reserved more than the card
holds and the run crawled instead of raising. The streamed group-offload variants
pin every weight in host memory up front; on a 16 GiB machine that left the run
finishing or hanging depending on what else held RAM, which rules them out as a
default. All completed placements produced bit-identical images.
Fusing on the CPU instead of on CUDA rounds 788 of the UNet's 3256 fused tensors
differently, by at most one fp16 ulp (1.2e-4). On the pass above that moved the
output by a mean of 0.16 levels (PSNR 54.3 dB against a CUDA-fused, equally
streamed run). Cards at or above the floor keep the CUDA fuse and therefore the
previous pixels; only a streamed run, automatic or forced with `--cpu-offload`,
sees the difference.
Regression coverage:
- [`test_cpu_offload.py`](../tests/test_cpu_offload.py)
+61 -25
View File
@@ -33,22 +33,28 @@ This page is an index. Each finding lives in its canonical page.
| Dependencies | TrustMark extra on every supported Python (`trustmark>=0.9.2`) | [installation.md](installation.md) |
| Live captures | Gemini Omni, Grok, Vidu, Wan, Apple, Google Photos, Nano Banana 2 Lite and Gemini Omni Flash through the API; Runway, Higgsfield and Dreamina | [watermarking-landscape.md](watermarking-landscape.md#live-captures-2026-09-23-and-2026-09-24) |
## Open items
## Follow-up status
- **Google Photos Reimagine manifests.** Magic Editor, and so Reimagine, is not
offered in Google Photos on iOS, so no Reimagine file is captured. The
The original follow-up list now mixes completed experiments, deliberately
excluded claims, and acquisition gaps. Each row below names its current status
as of 2026-10-01 so this section can serve as the research backlog without
discarding the measured results.
- **Closed - Google Photos Reimagine manifests.** Magic Editor, and so
Reimagine, is not offered in Google Photos on iOS, so no Reimagine file is
captured. The
question it was meant to answer is settled without one: Google's checker found
SynthID in all four Photos AI edits tested on 2026-09-25, none of which
records SynthID in its manifest.
- **Vidu and Wan marks on more samples.** Both are registered from one real
export each (`vidu` video, `wan` image); their gates are provisional until a
- **Open - Vidu and Wan marks on more samples.** Both are registered from one
real export each (`vidu` video, `wan` image); their gates are provisional until a
wider cohort is measured, and a Wan video label is not yet captured. Public
Vidu feed videos (2026-09-26) carry the TC260 label naming ShengShu, and one
names Tongyi Yunqi as producer with Vidu as propagator, but none shows the
visible `Vidu` mark; Wan's public demo videos show no mark either. Both marks
appear only on account downloads.
- **TC260 practice guides.** The audio placements from TC260-PG-202510A are
now read (the guide PDFs load in a browser from
- **Partially complete - TC260 practice guides.** The audio placements from
TC260-PG-202510A are now read (the guide PDFs load in a browser from
`tc260.org.cn/tc260/sjzn/list.shtml`, not to scripted requests). The text-file
guide (TC260-PG-20258A) covers documents this image and video tool does not
process. The security-protection guide (TC260-PG-202511A) records a
@@ -66,15 +72,25 @@ This page is an index. Each finding lives in its canonical page.
point; changing one `ProduceID` digit fails it. MiniMax's own spellings depart
from the guide: `TBSData.Type` is `LabelMataData` (the guide's `Md`) and
`Binding` with `BType` `0` (its `Bnd`); the second signature verifies over the
compact JSON of the `Bindings` entry, also on all four. That entry's SM3
content hash matched neither the whole file nor the file without its label,
and it names no `CntSel`, so the content binding is unverified. Other producers fill the same
compact JSON of the `Bindings` entry, also on all four. TC260-PG-202511A
appendix A.2 was re-read from the
[official PDF](https://www.tc260.org.cn/tc260/sjzn/202508/63ab55a3265f45c7865e9b8be15d4a29/files/dc379ec776f94abc8f1806affa1cda35.pdf)
on 2026-10-01. MiniMax omits its `CntSel`, and none of the guide-derived
byte selections reproduced its SM3 content hash on four measured videos, so
the content binding remains unverified
([module internals](module-internals.md)). Other producers fill the same
fields with opaque vendor strings (Qwen a base64 digest, Vidu 32 hex digits,
Wan and HappyHorse `L-`/`K-` prefixed values), not `SecurityData`. The key sits
in the file it signs, so a check proves authenticity only against a pinned
MiniMax key. `identify` now verifies it with a built-in SM3/SM2 and a pinned
MiniMax key ([module internals](module-internals.md)).
- **Experiments.** Chroma restoration after regeneration (arXiv:2508.21072) was
MiniMax key. The remaining work is
MiniMax content-binding verification if its byte-selection rule becomes known,
and Honor's unsupported RSA/content form if an original export and signing
contract become available.
- **Partially complete - experiments.** Five of the six follow-ups are closed:
Z-Image-Turbo, Perth and PixelSeal, re-watermarking, VACE/ROSE, and newer
frozen-backbone probes. Chroma
restoration after regeneration (arXiv:2508.21072) was
measured on 2026-09-27 and is not shipped: transplanted source chroma drew one
explicit "inconclusive" and two unclear Gemini answers where the untouched
outputs were all clean, and ghosts redrawn objects
@@ -126,12 +142,20 @@ This page is an index. Each finding lives in its canonical page.
profile; retain its harness and local evidence for future comparison only.
The existing source-conditioned Z-Image face-repair stage remains part of
`qwen-zimage`, `sdxl-zimage`, and `chroma-zimage`.
Still open: linear probes on newer frozen backbones for `classify` and RAVEN
view-synthesis removal
(arXiv:2601.08832, no code released as of 2026-09-24).
The pre-registered modern-backbone campaign found that neither frozen
SigLIP2 Base nor PE-Core B could match the production model's combined photo,
AI, and FLUX constraints, so the replacement hypothesis is closed
([classifier research](ai-generated-image-classifiers.md#modern-frozen-backbone-campaign-closed-2026-09-27)).
Only RAVEN view-synthesis removal remains blocked. RAVEN's
[official repository](https://github.com/fahadshamshad/raven-watermark-removal)
now exists, but its 2026-08-08 README is still a release placeholder: the
authors say they will publish code after their SynthID testing and Google
Security Bounty submission. There is no implementation to evaluate as of
2026-10-01 (arXiv:2601.08832).
MiniMax-Remover (CC-BY-NC-4.0 weights) and ProPainter (S-Lab License,
non-commercial) are excluded on license.
- **New C2PA signers without a vendor row.** The
non-commercial) are deliberately excluded on license and are not backlog
items.
- **Open - new C2PA signers without a vendor row.** The
[C2PA conformance list](https://github.com/c2pa-org/conformance-public/blob/main/conforming-products/conforming-products-list.json)
names generator certificates `Amazon Bedrock`, `Getty Images
AI-Generated Image` and `AI-Modified Image` (and the iStock pair), `Jasper`,
@@ -148,7 +172,7 @@ This page is an index. Each finding lives in its canonical page.
carrying C2PA: their marketing and model-page images are unsigned. A vivo
X300 camera capture carries a vivo device manifest (`digitalCapture`) that
the bundled reader cannot open (`unknown algorithm`).
- **Chinese phone galleries.** Huawei's help pages say the Xiaoyi photo-edit AI
- **Open - Chinese phone galleries.** Huawei's help pages say the Xiaoyi photo-edit AI
watermark can be switched off; vivo's Album setting is already covered.
The public-web follow-up below found Honor YOYO metadata and additional
primary-source labeling evidence, but not a controlled gallery export set. A live
@@ -159,11 +183,12 @@ This page is an index. Each finding lives in its canonical page.
of an unedited control, an ordinary crop, and a generative edit are still needed
per app before selecting an AI attribution rule. Record the device, OS/app
version, region, operation, and original download path with each sample.
- **Claims not supported by a primary source.** Kling embedding SynthID
- **Closed - claims not supported by a primary source.** Kling embedding SynthID
(repeated by remover blogs; Kuaishou is not in Google's adopter list),
Hailuo pixel watermarks, and platforms stripping C2PA on upload. None is used
by the library.
- **Google Photos small edits (further work deferred, 2026-10-01).** The
- **Partially complete - Google Photos small edits (further work deferred,
2026-10-01).** The
maintainer deferred further photographic tiny Magic Eraser captures and
verification retries for the inconclusive small edits. Resume only on an
explicit request; retain the existing evidence and uncertainty. On
@@ -193,8 +218,13 @@ This page is an index. Each finding lives in its canonical page.
negative and one inconclusive reply. The quota refusal, synthetic capture,
pixel comparison, mode caveat, and hash-bound outcomes are recorded in
[synthid.md](synthid.md).
- **Captures blocked by region or device.** Doubao, Jimeng, Samsung. Higgsfield,
Runway and Dreamina were captured on paid plans on 2026-09-24.
- **Partially complete - direct captures blocked by region or device.** Direct,
controlled captures remain blocked for Doubao, Jimeng, and Samsung. The
public-web follow-up below later found one real marked Doubao video and
validated its visible mark and TC260 metadata, but the file remains local
because publication has not been cleared; it is not a controlled account
capture or a redistributable fixture. Higgsfield, Runway, and Dreamina were
captured on paid plans on 2026-09-24.
### Public-web sample search, 2026-10-01 UTC
@@ -251,10 +281,16 @@ provider exports. No third-party photograph was added to tracked fixtures.
[Getty's generator page](https://www.gettyimages.com/ai/generation),
[iStock's generator page](https://www.istockphoto.com/ai/generation), and
[Reface's product site](https://reface.ai/) did not carry locally recognized
C2PA. Jasper explicitly distinguishes input/mask/output examples, but an
unsigned web derivative cannot establish its signed export's manifest shape.
C2PA. A 2026-10-01 recheck of Jasper's current official Image landing page
reached its pre-rendered endpoint outputs directly and again found no C2PA.
Jasper explicitly distinguishes input/mask/output examples, but an unsigned
web derivative cannot establish its signed export's manifest shape.
Conformance certificates alone still do not justify fabricating that shape.
- **Visible-image/video gaps remain.** The downloaded RunningHub workflow
- **Visible-image/video provider-original gaps remain.** Every registered mark
already has a publication-safe synthetic detector fixture in the
[visible-mark gallery](../data/fixtures/visible/README.md); the sample ledgers
now point to those fixtures and distinguish them from provider evidence. The
downloaded RunningHub workflow
illustrations are explanatory/branding assets, not verified output examples;
Liblib API documentation's cat illustration has no visible historical wordmark.
A public [Inkpainting model page](https://www.liblib.art/modelinfo/2acd393c23924ff9b5363c9c1ec9129f)
+8 -6
View File
@@ -11,19 +11,21 @@ The `visible` command registers these mark keys:
| Key | Mark | Expected area | Important limit |
| --- | --- | --- | --- |
| `gemini` | Google Gemini visible watermark (sparkle) | Usually bottom right | Detection includes a false positive gate. |
| `doubao` | `豆包AI生成` | Bottom right | Vendor specific text detector. |
| `doubao` | `豆包AI生成` | Bottom right | Covers filled and outlined text layouts; the weak outlined layout requires matching Doubao provenance. |
| `jimeng` | `★ 即梦AI` | Bottom right | Vendor specific text detector. |
| `qwen` | `千问AI生成` or the three-lobe Qwen symbol | Bottom right | Separate strict templates cover the text and Qwen Create symbol variants. |
| `wan` | Wan logo and `Wan` wordmark | Bottom right, hugging the corner | Provisional: one real Wan 2.7 Pro export. Requires NCC >= 0.55 and a match that ends within 0.015 of the short side from both edges. |
| `kling` | `可灵AI 3.0` or `KlingAI 3.0` | Bottom right | Separate calibrated silhouettes cover the older CJK and current IMAGE 3.0 Latin variants. |
| `yuanbao` | `元宝` over `AI生成` | Bottom right | Standard two-line variant only. |
| `samsung` | `✦ Contenuti generati dall'AI` | Bottom left | Calibrated for the Italian text variant. |
| `kling` | `可灵AI 3.0` or `KlingAI 3.0` | Bottom right | Separate calibrated silhouettes cover the older CJK and current IMAGE 3.0 Latin variants; a glyph-detail gate rejects plain UI boundaries. |
| `yuanbao` | `元宝` over `AI生成` | Bottom right | Separate strict and provenance-corroborated templates cover the measured two-line rasterizations. |
| `dola` | `Dola AI` | Bottom right | Text detector and detector-aligned wordmark footprint. |
| `workbuddy` | `AI生成` over `WORKBUDDY>_` | Bottom right | Text detector and detector-aligned stacked-label footprint. |
| `samsung` | Italian or Korean Galaxy AI disclosure | Bottom left | Independently gated text layouts cover `Contenuti generati dall'AI` and `AI로 생성한 콘텐츠`. |
| `runninghub` | `RunningHub AI生成` | Top left | Strict visual and position gates. |
| `baidu` | `百度 AI生成` | Bottom right | Detector and extended removal footprint. |
| `liblib` | `LiblibAI` | Bottom center | Historical wordmark variant; includes a minimum image size gate. |
| `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. |
| `microsoft` | Microsoft white AI badge or beige `Made with AI` pill | Top right | Each layout has its own strict visual gate; auto can use Microsoft provenance for the original badge's measured [relaxed gate](module-internals.md#visible-mark-removal). |
| `jimeng_pill` | `AI生成` pill | Top left | A verified label layout can bypass the flat-background guard only with Jimeng provenance; the weak outline detector retains the 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). |
+1 -1
View File
@@ -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.44.0"
version = "0.45.1"
description = "AI watermark remover for visible, invisible, and provenance marks in images and video"
readme = "README.md"
requires-python = ">=3.11,<3.15"
@@ -23,7 +23,6 @@ from the provider's canonical name, the provider is named separately.
| `samsung_en_alpha.png` | Samsung Galaxy AI label, English locale ("AI-generated content", bottom-left) | 5 corpus files | POS 0.11-0.30 vs NEG max 0.40 (binary); tophat/gray tried, no separation. The registered Italian engine scores 0.18-0.31 on the same files -- same layout class, wrong glyph template. |
| `gemini_text_alpha.png` | "Generated with Gemini" text label (bottom-right; the registered gemini mark is the sparkle icon) | 3 corpus files | POS 0.07-0.22 vs NEG max 0.32; coverage gate finds the blob (0.29-0.35) but the Arial silhouette misses Google's letterforms. |
| `notebooklm_alpha.png` | NotebookLM wordmark (bottom-right) | 12 corpus files | locate geometry not yet fitted; POS max 0.12. |
| `dola_alpha.png` | DolaAI on images (the video mark is registered) | 12 corpus files | POS 0.11-0.21 vs NEG max 0.30. |
| `mindvideo_alpha.png` | MindVideo.AI (top-right) | 11 corpus files | POS 0.29-0.32 vs NEG max 0.30 -- borderline overlap, not shippable. |
| `higgsfield_alpha.png` | `HIGGSFIELD AI` wordmark (Higgsfield AI, bottom-right; the boxed `AI` variant shares the cohort) | 5 wordmark files (16 in the boxed-AI OCR cluster) | POS max 0.26 vs NEG max 0.22 -- no separation; the mark may be two-part (wordmark + boxed AI) and needs a composed template. |
| `jianying_alpha.png` | `剪映AI` wordmark (Jianying / 剪映, the China product in the CapCut family; bottom-right) | 2 corpus files | POS 0.29 vs NEG max 0.35. |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

+68
View File
@@ -0,0 +1,68 @@
"""Rebuild synthetic silhouettes for additional AI disclosure layouts."""
from __future__ import annotations
import logging
import sys
from pathlib import Path
import cv2
import numpy as np
from PIL import Image
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from scripts.render_vendor_silhouettes import render # noqa: E402
log = logging.getLogger(__name__)
ASSETS = ROOT / "src" / "remove_ai_watermarks" / "assets"
CJK = {"font": "/System/Library/Fonts/Hiragino Sans GB.ttc", "font_index": 2}
def centered_lines(
top: str, bottom: str, *, top_scale: float, bottom_height: float = 1.0, shear: float = 0.0
) -> np.ndarray:
upper, lower = render(top, opts=CJK), render(bottom, opts=CJK)
if bottom_height != 1.0:
lower = cv2.resize(lower, (upper.shape[1], round(upper.shape[0] * bottom_height)))
else:
upper = cv2.resize(upper, (round(lower.shape[1] * top_scale), round(lower.shape[0] * 0.95)))
lines = [upper, lower]
result = np.zeros((sum(line.shape[0] for line in lines) + 2, max(line.shape[1] for line in lines)), np.uint8)
y = 0
for line in lines:
x = (result.shape[1] - line.shape[1]) // 2
result[y : y + line.shape[0], x : x + line.shape[1]] = line
y += line.shape[0] + 2
if shear:
h, w = result.shape
extra = round(abs(shear) * h)
result = cv2.warpAffine(result, np.float32([[1, shear, extra], [0, 1, 0]]), (w + extra, h))
return result
def silhouettes() -> dict[str, np.ndarray]:
return {
"workbuddy_alpha.png": centered_lines("AI生成", "WORKBUDDY>_", top_scale=1.0, bottom_height=0.45),
"yuanbao_compact_alpha.png": centered_lines("元宝", "AI生成", top_scale=0.65, shear=-0.15),
"yuanbao_compact_gray_alpha.png": centered_lines("元宝", "AI生成", top_scale=0.65, shear=-0.15),
"doubao_outline_alpha.png": render("豆包AI生成", opts=CJK),
"samsung_ko_alpha.png": render(
"AI로 생성한 콘텐츠", opts={"font": "/System/Library/Fonts/AppleSDGothicNeo.ttc"}
),
"microsoft_text_alpha.png": render(
"Made with AI", opts={"font": "/System/Library/Fonts/Supplemental/Arial.ttf"}
),
"jimeng_label_alpha.png": render("AI生成", opts={"font": "/System/Library/Fonts/STHeiti Medium.ttc"}),
}
def main() -> None:
logging.basicConfig(level=logging.INFO)
for name, alpha in silhouettes().items():
Image.fromarray(alpha).save(ASSETS / name)
log.info("Rendered %s, %s x %s", name, alpha.shape[1], alpha.shape[0])
if __name__ == "__main__":
main()
+2 -1
View File
@@ -73,7 +73,7 @@ MARKS = {
"gemini_text_alpha.png": "Generated with Gemini",
# Candidate wordmarks measured on a local evaluation corpus (unregistered).
"notebooklm_alpha.png": "NotebookLM",
"dola_alpha.png": "DolaAI",
"dola_alpha.png": "Dola AI",
"mindvideo_alpha.png": "MindVideo.AI",
"higgsfield_alpha.png": "HIGGSFIELD AI",
"capcut_alpha.png": "CapCut AI",
@@ -92,6 +92,7 @@ _REGISTERED = {f"{key}_alpha.png" for key in mark_keys()} & MARKS.keys()
# Per-mark post-processing for the multi-line / slanted stamps (see render()).
MARK_OPTS: dict[str, dict[str, Any]] = {
"dola_alpha.png": {"font": "/System/Library/Fonts/Supplemental/Arial Bold.ttf"},
# Hiragino Sans GB W6, tight leading, dilation, and negative shear match the
# standard Yuanbao stamp without clipping the lower line.
"yuanbao_alpha.png": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "remove-ai-watermarks",
"displayName": "Remove AI Watermarks",
"version": "1.0.10",
"version": "1.0.11",
"description": "Identify and remove AI provenance marks from images and video the user generated or edited, using the remove-ai-watermarks CLI.",
"author": {
"name": "wiltodelta",
+1 -1
View File
@@ -5,7 +5,7 @@ license: Apache-2.0
compatibility: Python 3.11-3.14. Invisible image removal needs NVIDIA CUDA. Some video writes need ffmpeg. Installer may be uv, pipx, or pip.
metadata:
author: wiltodelta
version: "1.0.10"
version: "1.0.11"
homepage: https://raiw.cc
repository: https://github.com/wiltodelta/remove-ai-watermarks
---
@@ -21,8 +21,8 @@ remove-ai-watermarks visible image.png --keep-metadata -o clean.png
```
Image mark keys: `gemini`, `doubao`, `jimeng`, `qwen`, `wan`, `kling`, `yuanbao`,
`samsung`, `runninghub`, `baidu`, `liblib`, `liblib_pill`, `microsoft`,
`generic_ai_label`, `openart`, `jimeng_pill`. `wan` is new (since CLI 0.43.0). Default
`dola`, `workbuddy`, `samsung`, `runninghub`, `baidu`, `liblib`, `liblib_pill`, `microsoft`,
`generic_ai_label`, `openart`, `jimeng_pill`. `dola` and `workbuddy` are new (since CLI 0.45.0). 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
+1 -1
View File
@@ -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, 43, 0)
MIN_CLI_VERSION = (0, 45, 0)
# 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.
+1 -1
View File
@@ -35,7 +35,7 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
_warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*")
__version__ = "0.44.0"
__version__ = "0.45.1"
__all__ = [
"BatchItemResult",
@@ -47,6 +47,12 @@ _LATENT_GRID = 8
# The reference graph loads the Qwen LoRA at 0.8; carrying that number to a different
# LoRA on a different architecture would be imitation, not parity.
SDXL_STEPS = 4
# Below this floor the global stack streams its weights from CPU layer by layer; at or
# above it the stack stays resident. The fp16 UNet, Canny ControlNet, both text
# encoders and the VAE are ~8.8 GiB of weights, and the UNet and the ControlNet run
# together on every step, so model-level offload cannot get under an 8 GiB card.
# Benchmark in docs/module-internals.md, "CPU offload".
RESIDENT_SDXL_GLOBAL_MIN_VRAM_GIB = 12.0
def sdxl_target_size(width: int, height: int) -> tuple[int, int]:
@@ -54,6 +60,17 @@ def sdxl_target_size(width: int, height: int) -> tuple[int, int]:
return _target_size(width, height, _LATENT_GRID)
def resolve_sdxl_global_residency(
requested: bool | None,
*,
total_memory_gib: float,
) -> bool:
"""Keep the SDXL global stack resident when explicitly requested or safely sized."""
if requested is not None:
return requested
return total_memory_gib >= RESIDENT_SDXL_GLOBAL_MIN_VRAM_GIB
@dataclass
class SdxlZImagePipeline(TwoStageZImagePipeline):
"""Lazy runtime for the SDXL global stage plus the inherited face stage."""
@@ -64,6 +81,12 @@ class SdxlZImagePipeline(TwoStageZImagePipeline):
super().__post_init__()
self._sdxl_pipe: Any = None
def _keep_global_models_resident(self) -> bool:
return resolve_sdxl_global_residency(
self.keep_global_models_on_device,
total_memory_gib=self._total_vram_gib(),
)
def _load_global(self) -> Any:
if self._sdxl_pipe is not None:
return self._sdxl_pipe
@@ -89,11 +112,19 @@ class SdxlZImagePipeline(TwoStageZImagePipeline):
variant="fp16",
add_watermarker=False,
**token,
).to(self.device)
)
resident = self._keep_global_models_resident()
if resident:
pipe = pipe.to(self.device)
pipe.load_lora_weights(hf_hub_download(SDXL_LIGHTNING_MODEL_ID, SDXL_LIGHTNING_PATTERN, **token))
pipe.fuse_lora()
# SDXL-Lightning is distilled against trailing timestep spacing.
pipe.scheduler = EulerDiscreteScheduler.from_config(pipe.scheduler.config, timestep_spacing="trailing")
if not resident:
# After the fuse: the offload hooks keep their own CPU copy of each weight
# and stream that copy, so fusing later would never reach what runs.
self._progress("Streaming the SDXL stack from CPU (card below the residency floor)...")
pipe.enable_sequential_cpu_offload(device=self.device, gpu_id=torch.cuda.current_device())
self._sdxl_pipe = pipe
return pipe
@@ -63,13 +63,13 @@ PROFILE_ADAPTIVE_POLISH = {
# Which profiles let ``--cpu-offload`` reach their GLOBAL stack. The face stage
# honours the flag everywhere, because residency lives on the shared base, but the
# global stack is each profile's own: only qwen-zimage streams it, through
# ``_qwen_vram_config``. chroma-zimage and sdxl-zimage load theirs with a plain
# ``.to(device)``, so on a card below the face-stage residency floor the flag has
# nothing left to change and the run behaves as if it were absent. Data rather than
# a subclass attribute, because the caller that must warn cannot import a profile
# module without pulling in Diffusers.
GLOBAL_OFFLOAD_PROFILES = frozenset({QWEN_ZIMAGE_PROFILE})
# global stack is each profile's own: qwen-zimage streams it through
# ``_qwen_vram_config`` and sdxl-zimage through Diffusers sequential offload.
# chroma-zimage loads its stack with a plain ``.to(device)``, so on a card below the
# face-stage residency floor the flag has nothing left to change there and the run
# behaves as if it were absent. Data rather than a subclass attribute, because the
# caller that must warn cannot import a profile module without pulling in Diffusers.
GLOBAL_OFFLOAD_PROFILES = frozenset({QWEN_ZIMAGE_PROFILE, SDXL_ZIMAGE_PROFILE})
SDXL_LIGHTNING_MODEL_ID = "ByteDance/SDXL-Lightning"
SDXL_LIGHTNING_PATTERN = "sdxl_lightning_4step_lora.safetensors"
@@ -175,9 +175,9 @@ class WatermarkRemover:
return
logger.warning(
"--cpu-offload does not reach the global stack of the '%s' pipeline: only "
"qwen-zimage streams its global model between CUDA calls. The face stage "
"still honours the flag, so below the face-stage residency floor this run "
"behaves as if --cpu-offload were absent.",
"qwen-zimage and sdxl-zimage stream their global model between CUDA calls. "
"The face stage still honours the flag, so below the face-stage residency "
"floor this run behaves as if --cpu-offload were absent.",
self.model_profile,
)
@@ -181,6 +181,7 @@ class TextMarkDetection:
# ``footprint_mask`` reuses a threaded detection only when it matches the STRICT
# level its own re-detect would have used -- see TextMarkEngine._strict_detection.
provenance: bool = False
template_asset: str | None = None
def best_detection(*detections: TextMarkDetection) -> TextMarkDetection:
@@ -665,7 +666,7 @@ class TextMarkEngine:
def _verdict(self, scan: TextMarkScan, *, provenance: bool) -> TextMarkDetection:
"""Apply the trust-level-dependent tail to a scan, as a fresh result object."""
c = self.config
det = TextMarkDetection(provenance=provenance)
det = TextMarkDetection(provenance=provenance, template_asset=c.asset_name)
if scan.loc is None or scan.box is None:
return det
det.region = scan.loc.bbox
@@ -0,0 +1,219 @@
"""Combine independently gated text layouts and retain their mask geometry."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import cv2
from remove_ai_watermarks._text_mark_engine import (
BottomRightAnchoredEngine,
TextMarkConfig,
TextMarkDetection,
TextMarkEngine,
TextMarkLocation,
best_detection,
)
if TYPE_CHECKING:
from numpy.typing import NDArray
class TextMarkVariantsMixin(TextMarkEngine):
"""Add independently configured layouts to an existing text-mark engine."""
_variants: tuple[TextMarkEngine, ...] = ()
config: TextMarkConfig
def detect_both(self, image: NDArray[Any] | None) -> tuple[TextMarkDetection, TextMarkDetection]:
pairs = [super().detect_both(image), *(engine.detect_both(image) for engine in self._variants)]
return best_detection(*(pair[0] for pair in pairs)), best_detection(*(pair[1] for pair in pairs))
def detect(self, image: NDArray[Any], *, provenance: bool = False) -> TextMarkDetection:
return self.detect_both(image)[int(provenance)]
def variant_engine(self, detection: TextMarkDetection) -> TextMarkEngine:
for engine in self._variants:
if engine.config.asset_name == detection.template_asset:
return engine
return self
def footprint_mask(
self,
image: NDArray[Any] | None,
*,
force: bool = False,
dilate: int | None = None,
detection: TextMarkDetection | None = None,
) -> NDArray[Any] | None:
if image is None or image.size == 0 or force:
return super().footprint_mask(image, force=force, dilate=dilate, detection=detection)
det = detection if detection is not None else self.detect(image)
engine = self.variant_engine(det)
if engine is self:
return super().footprint_mask(image, force=False, dilate=dilate, detection=det)
return engine.footprint_mask(image, force=False, dilate=dilate, detection=det)
class GlyphTextMarkEngine(TextMarkEngine):
"""Use the accepted template's aligned strokes instead of a textured ROI."""
def footprint_mask(
self,
image: NDArray[Any] | None,
*,
force: bool = False,
dilate: int | None = None,
detection: TextMarkDetection | None = None,
) -> NDArray[Any] | None:
from remove_ai_watermarks._text_mark_engine import load_alpha_template
if force:
return super().footprint_mask(image, force=True, dilate=dilate, detection=detection)
if image is None or image.size == 0:
return None
det = detection if detection is not None else self.detect(image)
alpha = load_alpha_template(self.config.asset_name)
if alpha is None or not det.detected:
return None
return self._aligned_alpha_mask(
image,
det,
alpha,
alpha_floor=0.05,
dilate=2 if dilate is None else max(0, dilate),
)
class BottomRightGlyphTextMarkEngine(GlyphTextMarkEngine, BottomRightAnchoredEngine):
"""Glyph-mask variant whose accepted match must hug the bottom-right corner."""
_ANCHOR_MAX_RIGHT = 0.04
_ANCHOR_MAX_BOTTOM = 0.04
def _ladder_best(
self, image: NDArray[Any], loc: TextMarkLocation
) -> tuple[float, tuple[int, int, int, int] | None]:
"""Find the strongest match among placements that satisfy the anchor."""
response = self._detect_response(image, loc)
silhouette = self._glyph_silhouette()
if response is None or silhouette is None:
return 0.0, None
frame_h, frame_w = image.shape[:2]
anchor_base = min(frame_h, frame_w)
base = self.scale_base(image)
best_score = 0.0
best_box: tuple[int, int, int, int] | None = None
for scale in self.config.ladder:
glyph_w = max(self.config.min_gw, int(self.config.alpha_width_frac * base * scale))
glyph_h = max(4, int(self.config.alpha_height_frac * base * scale))
if glyph_w >= response.shape[1] or glyph_h >= response.shape[0]:
continue
template = cv2.resize(silhouette, (glyph_w, glyph_h), interpolation=cv2.INTER_AREA)
scores = cv2.matchTemplate(response, template, cv2.TM_CCOEFF_NORMED)
min_x = max(0, round(frame_w - loc.x - glyph_w - self._ANCHOR_MAX_RIGHT * anchor_base))
max_x = min(scores.shape[1] - 1, frame_w - loc.x - glyph_w)
min_y = max(0, round(frame_h - loc.y - glyph_h - self._ANCHOR_MAX_BOTTOM * anchor_base))
max_y = min(scores.shape[0] - 1, frame_h - loc.y - glyph_h)
if min_x > max_x or min_y > max_y:
continue
anchored = scores[min_y : max_y + 1, min_x : max_x + 1]
_, score, _, point = cv2.minMaxLoc(anchored)
if score > best_score:
x, y = min_x + int(point[0]), min_y + int(point[1])
best_score = float(score)
best_box = (x, y, x + glyph_w - 1, y + glyph_h - 1)
return best_score, best_box
def _box_footprint(
engine: GlyphTextMarkEngine,
image: NDArray[Any] | None,
*,
force: bool,
dilate: int | None,
detection: TextMarkDetection | None,
pad_left_height: float,
pad_right_height: float,
) -> NDArray[Any] | None:
"""Mask the full winning text rectangle for outlined or stacked layouts."""
if image is None or image.size == 0 or force:
return GlyphTextMarkEngine.footprint_mask(
engine,
image,
force=force,
dilate=dilate,
detection=detection,
)
det = detection if detection is not None else engine.detect(image)
if not det.detected or det.match_box is None:
return None
from remove_ai_watermarks import region_eraser
loc = engine.locate(image)
x0, y0, x1, y1 = det.match_box
extra_left = round((y1 - y0 + 1) * pad_left_height)
extra_right = round((y1 - y0 + 1) * pad_right_height)
pad = 2 if dilate is None else max(0, dilate)
return region_eraser.boxes_to_mask(
image.shape[:2],
[
(
loc.x + x0 - extra_left,
loc.y + y0,
x1 - x0 + 1 + extra_left + extra_right,
y1 - y0 + 1,
)
],
dilate=pad,
)
class BoxTextMarkEngine(GlyphTextMarkEngine):
"""Text detector with a solid detector-aligned removal footprint."""
_BOX_PAD_LEFT_HEIGHT = 0.0
_BOX_PAD_RIGHT_HEIGHT = 0.0
def footprint_mask(
self,
image: NDArray[Any] | None,
*,
force: bool = False,
dilate: int | None = None,
detection: TextMarkDetection | None = None,
) -> NDArray[Any] | None:
return _box_footprint(
self,
image,
force=force,
dilate=dilate,
detection=detection,
pad_left_height=self._BOX_PAD_LEFT_HEIGHT,
pad_right_height=self._BOX_PAD_RIGHT_HEIGHT,
)
class BottomRightBoxTextMarkEngine(BottomRightGlyphTextMarkEngine):
"""Anchored text detector with a solid detector-aligned footprint."""
_BOX_PAD_LEFT_HEIGHT = 0.0
_BOX_PAD_RIGHT_HEIGHT = 0.0
def footprint_mask(
self,
image: NDArray[Any] | None,
*,
force: bool = False,
dilate: int | None = None,
detection: TextMarkDetection | None = None,
) -> NDArray[Any] | None:
return _box_footprint(
self,
image,
force=force,
dilate=dilate,
detection=detection,
pad_left_height=self._BOX_PAD_LEFT_HEIGHT,
pad_right_height=self._BOX_PAD_RIGHT_HEIGHT,
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

+3 -2
View File
@@ -308,8 +308,9 @@ _cpu_offload_option = click.option(
"Offload model components to CPU between CUDA calls instead of keeping the "
"whole pipeline in VRAM, at the cost of speed. Forces the face stack to "
"offload instead of using automatic residency, on every profile. It reaches "
"the global stack of qwen-zimage only: chroma-zimage and sdxl-zimage keep "
"theirs resident either way."
"the global stack of qwen-zimage and sdxl-zimage, which also stream theirs "
"automatically on a card too small to hold it; chroma-zimage keeps its "
"global stack resident either way."
),
)
+35
View File
@@ -0,0 +1,35 @@
"""Detect the bottom-right Dola AI image disclosure, independently of video marks."""
from remove_ai_watermarks._text_mark_engine import TextMarkConfig
from remove_ai_watermarks._text_mark_variants import BoxTextMarkEngine
_CONFIG = TextMarkConfig(
name="Dola AI image disclosure",
asset_name="dola_alpha.png",
corner="br",
margin_floor=4,
width_frac=0.28,
height_frac=0.09,
margin_x_frac=0.002,
margin_bottom_frac=0.002,
max_saturation=255,
logo_min_luma=110,
tophat_delta=8,
morph_open_size=3,
detect_min_coverage=0.01,
detect_ncc_threshold=0.50,
alpha_width_frac=0.10,
alpha_height_frac=0.10 * 86 / 347,
detect_frontend="tophat",
scale_basis="width",
ladder=(0.95, 1.0, 1.05, 1.20, 1.26),
provenance_ncc_factor=1.0,
min_gw=24,
)
class DolaEngine(BoxTextMarkEngine):
"""Detect and localize the Dola AI image wordmark."""
def __init__(self) -> None:
super().__init__(_CONFIG)
+61 -3
View File
@@ -25,10 +25,12 @@ from __future__ import annotations
import dataclasses
from typing import TYPE_CHECKING, Any
import cv2
import numpy as np
from remove_ai_watermarks import _text_mark_engine, image_io
from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkDetection, TextMarkEngine, TextMarkScan
from remove_ai_watermarks._text_mark_variants import BottomRightBoxTextMarkEngine, TextMarkVariantsMixin
if TYPE_CHECKING:
from numpy.typing import NDArray
@@ -117,6 +119,41 @@ _CONFIG = TextMarkConfig(
_TINTED_ENGINE = TextMarkEngine(dataclasses.replace(_CONFIG, max_saturation=_TINTED_MAX_SATURATION))
class _OutlinedDoubaoEngine(BottomRightBoxTextMarkEngine):
# The outlined layout's provenance-relaxed NCC floor overlaps ordinary corner
# texture. Require bright neutral strokes and edge detail in the winning box
# to distinguish the label from unmarked corners. Metadata confirms
# Doubao as the producer, not that the export retained a visible label.
_MIN_BRIGHT_NEUTRAL_FRACTION = 0.29
_MIN_MEAN_LAPLACIAN = 10.0
_BOX_PAD_LEFT_HEIGHT = 1.2
_BOX_PAD_RIGHT_HEIGHT = 0.7
@classmethod
def _outline_pixels_ok(cls, patch: NDArray[Any]) -> bool:
if patch.size == 0:
return False
patch = image_io.to_bgr(patch)
gray = cv2.cvtColor(patch, cv2.COLOR_BGR2GRAY)
saturation = cv2.cvtColor(patch, cv2.COLOR_BGR2HSV)[:, :, 1]
bright_neutral = float(np.mean((gray > 160) & (saturation < 60)))
edge_detail = float(np.mean(np.abs(cv2.Laplacian(gray, cv2.CV_32F))))
return bright_neutral >= cls._MIN_BRIGHT_NEUTRAL_FRACTION and edge_detail >= cls._MIN_MEAN_LAPLACIAN
def _scan(self, image: NDArray[Any] | None) -> TextMarkScan:
scan = super()._scan(image)
if image is None or scan.loc is None or scan.match_box is None or scan.score is None:
return scan
x0, y0, x1, y1 = scan.match_box
patch = image[
scan.loc.y + y0 : scan.loc.y + y1 + 1,
scan.loc.x + x0 : scan.loc.x + x1 + 1,
]
if not self._outline_pixels_ok(patch):
return dataclasses.replace(scan, score=0.0)
return scan
def _alpha_template() -> NDArray[Any] | None:
"""The bundled Doubao alpha template (float [0,1]), or None."""
return _text_mark_engine.load_alpha_template(_CONFIG.asset_name)
@@ -132,11 +169,29 @@ def _template_match_score(box_mask: NDArray[Any], scale_base: int) -> float:
return _text_mark_engine.template_match_score(box_mask, scale_base, _CONFIG)
class DoubaoEngine(TextMarkEngine):
class DoubaoEngine(TextMarkVariantsMixin):
"""Detect/localize the visible Doubao "豆包AI生成" watermark (locate -> mask; mask feeds the fill)."""
def __init__(self) -> None:
super().__init__(_CONFIG)
self._variants = (
_OutlinedDoubaoEngine(
dataclasses.replace(
_CONFIG,
asset_name="doubao_outline_alpha.png",
width_frac=0.28,
height_frac=0.13,
scale_basis="width",
alpha_width_frac=0.14,
alpha_height_frac=0.14 * 0.75 * 77 / 346,
max_saturation=255,
detect_frontend="contrast",
ladder=(0.95, 1.0, 1.05),
detect_ncc_threshold=0.50,
provenance_ncc_factor=0.25,
)
),
)
def _scan(self, image: NDArray[Any] | None) -> TextMarkScan:
base = super()._scan(image)
@@ -179,12 +234,15 @@ class DoubaoEngine(TextMarkEngine):
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()
engine = self.variant_engine(det)
if engine is not self:
return engine.footprint_mask(image, force=False, dilate=dilate, detection=det)
alpha = _text_mark_engine.load_alpha_template(engine.config.asset_name)
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(
return engine._aligned_alpha_mask(
image,
det,
alpha,
+6 -3
View File
@@ -957,7 +957,8 @@ def _integrity_clashes(
clashes.append(
"Conflicting AI-origin attributions from independent signals: "
+ " vs ".join(parts)
+ " -- one provenance set was likely spoofed, transplanted, or laundered."
+ " -- these fields may describe different stages of an edit chain; "
"the available evidence does not establish which attribution describes the final pixels."
)
# Rule 2: a camera-capture C2PA device next to an AI-generation marker. Only
@@ -967,13 +968,15 @@ def _integrity_clashes(
# AI edit in ONE manifest, so the AI vendor is named only from that same
# manifest (C2PA issuer + SynthID provenance) -- a legitimate edit chain, not a
# spoof. An EXIF/XMP generator, IPTC field, TC260 AIGC label, or second
# manifest naming AI on a camera capture is the real laundering tell.
# manifest naming AI on a camera capture records a second stage whose relation
# to the capture cannot be resolved from these fields alone.
independent_ai_marker = any(grp != _C2PA_MANIFEST_SOURCE for grp in source.values())
if camera_label and camera_has_ai_marker and independent_ai_marker:
vendors = ", ".join(sorted(set(ai_vendors.values()))) or "present"
clashes.append(
f"Camera-capture C2PA credentials ({camera_label}) coexist with AI-generation markers "
f"({vendors}) -- a genuine camera capture is not AI-generated, so the provenance is inconsistent."
f"({vendors}) -- these fields may describe a captured image followed by an AI edit; "
"the available evidence does not establish which stage describes the final pixels."
)
return clashes
+2 -1
View File
@@ -172,7 +172,8 @@ class InvisibleEngine:
cpu_offload: Offload model components to CPU between CUDA calls instead
of keeping the whole pipeline in VRAM, at the cost of speed. Forces
the face stack to offload instead of using automatic residency, on
every profile; reaches the global stack of qwen-zimage only.
every profile; reaches the global stack of qwen-zimage and
sdxl-zimage, not chroma-zimage.
CUDA only.
"""
+41 -10
View File
@@ -56,6 +56,7 @@ from dataclasses import replace
from typing import TYPE_CHECKING, Any
import cv2
import numpy as np
from remove_ai_watermarks import _text_mark_engine
from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkDetection, TextMarkEngine
@@ -136,8 +137,12 @@ _CONFIG = TextMarkConfig(
_LATIN_CONFIG = replace(
_CONFIG,
asset_name="kling_latin_alpha.png",
alpha_width_frac=0.095,
alpha_height_frac=0.028,
width_frac=0.22,
height_frac=0.07,
margin_x_frac=0.002,
margin_bottom_frac=0.002,
alpha_width_frac=0.14,
alpha_height_frac=0.032,
ladder=(0.9, 1.0, 1.1),
detect_ncc_threshold=0.40,
)
@@ -153,12 +158,37 @@ def _glyph_silhouette() -> NDArray[Any] | None:
return _text_mark_engine.glyph_silhouette(_CONFIG.asset_name)
class KlingEngine(TextMarkEngine):
class _KlingTextEngine(TextMarkEngine):
"""Require glyph structure beyond a horizontal or vertical background edge."""
def _ladder_best(
self, image: NDArray[Any], loc: _text_mark_engine.TextMarkLocation
) -> tuple[float, tuple[int, int, int, int] | None]:
score, box = super()._ladder_best(image, loc)
response = self._detect_response(image, loc)
silhouette = self._glyph_silhouette()
if box is None or response is None or silhouette is None:
return score, box
x, y, x1, y1 = box
patch = response[y : y1 + 1, x : x1 + 1].astype(np.float32)
template = cv2.resize(silhouette, (patch.shape[1], patch.shape[0]), interpolation=cv2.INTER_AREA)
def glyph_detail(array: NDArray[Any]) -> NDArray[Any]:
array = array.astype(np.float32)
return array - array.mean(axis=0, keepdims=True) - array.mean(axis=1, keepdims=True) + array.mean()
patch, template = glyph_detail(patch), glyph_detail(template)
denominator = float(np.linalg.norm(patch) * np.linalg.norm(template))
detail_score = float((patch * template).sum()) / denominator if denominator > 1e-6 else 0.0
return max(0.0, min(score, detail_score)), box
class KlingEngine(_KlingTextEngine):
"""Detect/localize Kling AI's CJK and Latin 3.0 marks (locate -> mask -> fill)."""
def __init__(self) -> None:
super().__init__(_CONFIG)
self._latin = TextMarkEngine(_LATIN_CONFIG)
self._latin = _KlingTextEngine(_LATIN_CONFIG)
def detect(self, image: NDArray[Any], *, provenance: bool = False) -> TextMarkDetection:
"""Return the strongest CJK or Latin Kling wordmark verdict."""
@@ -195,14 +225,15 @@ class KlingEngine(TextMarkEngine):
return super().footprint_mask(image, force=force, dilate=dilate, detection=detection)
det = detection if detection is not None else self.detect(image)
legacy = super().footprint_mask(image, force=False, dilate=dilate, detection=det)
if not det.detected:
return None
engine = self._latin if det.template_asset == _LATIN_CONFIG.asset_name else self
legacy = TextMarkEngine.footprint_mask(engine, image, force=False, dilate=dilate, detection=det)
radius = _FOOTPRINT_DILATE if dilate is None else max(0, dilate)
result = legacy
for config in (_CONFIG, _LATIN_CONFIG):
alpha = _text_mark_engine.load_alpha_template(config.asset_name)
if alpha is None:
continue
core = self._aligned_alpha_mask(
alpha = _text_mark_engine.load_alpha_template(engine.config.asset_name)
if alpha is not None:
core = engine._aligned_alpha_mask(
image,
det,
alpha,
+1 -1
View File
@@ -170,7 +170,7 @@ class LibLibPillEngine:
"""Detect the compact top-left pill only when LiblibAI is corroborated."""
def __init__(self) -> None:
self._pill = PillEngine()
self._pill = PillEngine(include_label=False)
def _raw(self, image: NDArray[Any]) -> PillDetection:
return self._pill.detect(image)
+58 -2
View File
@@ -38,10 +38,12 @@ status was not adjudicated, and 1200 non-overlapping no-signal controls:
from __future__ import annotations
from dataclasses import replace
from typing import TYPE_CHECKING, Any
from remove_ai_watermarks import _text_mark_engine
from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkEngine
from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkDetection
from remove_ai_watermarks._text_mark_variants import GlyphTextMarkEngine, TextMarkVariantsMixin
if TYPE_CHECKING:
from numpy.typing import NDArray
@@ -101,8 +103,62 @@ def _alpha_template() -> NDArray[Any] | None:
return _text_mark_engine.load_alpha_template(_CONFIG.asset_name)
class MicrosoftEngine(TextMarkEngine):
class MicrosoftEngine(TextMarkVariantsMixin):
"""Detect/localize the measured Microsoft top-right AI badge."""
def __init__(self) -> None:
super().__init__(_CONFIG)
self._variants = (
GlyphTextMarkEngine(
replace(
_CONFIG,
asset_name="microsoft_text_alpha.png",
width_frac=0.20,
height_frac=0.065,
margin_x_frac=0.002,
margin_bottom_frac=0.002,
scale_basis="width",
alpha_width_frac=0.14,
alpha_height_frac=0.14 * 49 / 339,
detect_frontend="contrast",
ladder=(0.95, 1.0, 1.05),
detect_ncc_threshold=0.60,
provenance_ncc_factor=1.0,
max_saturation=255,
)
),
)
def footprint_mask(
self,
image: NDArray[Any] | None,
*,
force: bool = False,
dilate: int | None = None,
detection: TextMarkDetection | None = None,
) -> NDArray[Any] | None:
"""Cover the enclosing beige pill for the text-only layout."""
if force or image is None or image.size == 0:
return super().footprint_mask(image, force=force, dilate=dilate, detection=detection)
det = detection if detection is not None else self.detect(image)
engine = self.variant_engine(det)
if engine is self or not det.detected or det.match_box is None:
return super().footprint_mask(image, force=False, dilate=dilate, detection=det)
from remove_ai_watermarks import region_eraser
loc = engine.locate(image)
x0, y0, x1, y1 = det.match_box
pad_x = int(image.shape[1] * 0.012)
pad_y = int(image.shape[1] * 0.014)
box = (
max(0, loc.x + x0 - pad_x),
max(0, loc.y + y0 - pad_y),
x1 - x0 + 1 + 2 * pad_x,
y1 - y0 + 1 + 2 * pad_y,
)
return region_eraser.boxes_to_mask(
image.shape[:2],
[box],
dilate=0 if dilate is None else max(0, dilate),
)
+1 -2
View File
@@ -3,8 +3,7 @@
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
this repository detects. In the observed portrait layout, 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
+101 -25
View File
@@ -1,18 +1,20 @@
"""Jimeng-basic 'AI生成' pill: a CAPTURE-LESS visible mark (issue #54).
"""Jimeng-basic ``AI生成`` pill visible mark (issue #54).
The Jimeng free-tier TC260 label is a rounded pill with 'AI生成' in the TOP-LEFT
corner -- distinct from the ``jimeng`` "★ 即梦AI" wordmark (bottom-right). It has no
captured alpha map, so unlike the other marks it is detected purely by a synthetic
silhouette; like every mark it is then removed by the shared localize -> fill:
captured alpha map for its outline. Detection therefore has two paths before the
shared localize -> fill step:
* Detect: edge-NCC of a font-rendered SILHOUETTE (``assets/jimeng_pill.png``,
* A high-confidence contrast template for the measured solid ``AI生成`` label
(``assets/jimeng_label_alpha.png``). With Jimeng provenance this verified
label can bypass the flat-background safety gate.
* Edge-NCC of a font-rendered SILHOUETTE (``assets/jimeng_pill.png``,
synthetic, data-safe -- see ``scripts/render_pill_silhouette.py``) against the
top-left ROI, at the pill's known width fraction. The calibrated
``_DETECT_THRESHOLD`` is 0.22.
* Remove: place the pill footprint at the matched location and inpaint it
(MI-GAN / cv2 via the registry). Quality comes from the inpaint backend, so the
silhouette need not be pixel-accurate -- which is why a synthetic render is
sufficient and no source-derived asset is committed.
top-left ROI, at the pill's known width fraction. Its calibrated
``_DETECT_THRESHOLD`` is 0.22 and it retains the provenance and flat-background
product gates.
* Remove: mask the verified label match or the stable outline footprint and
inpaint it (MI-GAN / cv2 via the registry).
Geometry uses width ~0.161*W,
height ~0.091*W, top-left, margins ~0.02-0.05.
@@ -27,6 +29,7 @@ import cv2
import numpy as np
from remove_ai_watermarks import image_io
from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkDetection, TextMarkEngine
if TYPE_CHECKING:
from numpy.typing import NDArray
@@ -74,6 +77,32 @@ class PillDetection(NamedTuple):
detected: bool
confidence: float
region: tuple[int, int, int, int] # x, y, w, h of the matched pill
verified_label: bool = False
_LABEL_CONFIG = TextMarkConfig(
name="Jimeng AI生成 pill label",
asset_name="jimeng_label_alpha.png",
corner="tl",
margin_floor=2,
width_frac=0.34,
height_frac=0.14,
margin_x_frac=0.0,
margin_bottom_frac=0.0,
max_saturation=255,
logo_min_luma=100,
tophat_delta=8,
morph_open_size=3,
detect_min_coverage=0.01,
detect_ncc_threshold=0.55,
detect_frontend="contrast",
scale_basis="width",
ladder=(0.95, 1.0, 1.05),
alpha_width_frac=0.14,
alpha_height_frac=0.14 * 128 / 353,
min_gw=24,
provenance_ncc_factor=1.0,
)
def _load_silhouette() -> NDArray[Any] | None:
@@ -92,7 +121,18 @@ def _grad(gray: NDArray[Any]) -> NDArray[Any]:
class PillEngine:
"""Detect + build the removal mask for the top-left 'AI生成' pill (edge-NCC of a synthetic silhouette)."""
"""Detect the top-left ``AI生成`` label or its synthetic pill outline."""
def __init__(self, *, include_label: bool = True) -> None:
self._label = TextMarkEngine(_LABEL_CONFIG) if include_label else None
@staticmethod
def _label_box(detection: TextMarkDetection) -> tuple[int, int, int, int] | None:
if not detection.detected or detection.match_box is None:
return None
x, y, _width, _height = detection.region
x0, y0, x1, y1 = detection.match_box
return x + x0, y + y0, x1 - x0 + 1, y1 - y0 + 1
def _match(self, image: NDArray[Any]) -> tuple[float, tuple[int, int, int, int]] | None:
sil = _load_silhouette()
@@ -113,12 +153,33 @@ class PillEngine:
_, score, _, loc = cv2.minMaxLoc(res)
return float(score), (int(loc[0]), int(loc[1]), tw, th)
def detect(self, image: NDArray[Any]) -> PillDetection:
m = self._match(image)
if m is None:
return PillDetection(False, 0.0, (0, 0, 0, 0))
score, box = m
return PillDetection(score >= _DETECT_THRESHOLD, score, box)
def _detection(
self,
match: tuple[float, tuple[int, int, int, int]] | None,
label: TextMarkDetection,
) -> PillDetection:
label_box = self._label_box(label)
if match is None:
return PillDetection(label.detected, label.confidence, label_box or label.region, label.detected)
score, box = match
return PillDetection(
score >= _DETECT_THRESHOLD or label.detected,
max(score, label.confidence),
label_box if label.detected and label_box is not None else box,
label.detected,
)
def detect(self, image: NDArray[Any], *, provenance: bool = False) -> PillDetection:
"""Detect the outline, plus the solid label when Jimeng is corroborated."""
label_scan = self._label.detect(image) if self._label is not None else TextMarkDetection()
label = label_scan if provenance else TextMarkDetection()
return self._detection(self._match(image), label)
def detect_both(self, image: NDArray[Any]) -> tuple[PillDetection, PillDetection]:
"""Return strict outline and Jimeng-corroborated label verdicts from one scan."""
match = self._match(image)
label = self._label.detect(image) if self._label is not None else TextMarkDetection()
return self._detection(match, TextMarkDetection()), self._detection(match, label)
def _footprint_box(self, image: NDArray[Any]) -> tuple[int, int, int, int] | None:
h, w = image.shape[:2]
@@ -152,19 +213,34 @@ class PillEngine:
"""True when the top-left footprint is flat enough for an invisible inpaint."""
return self.footprint_texture(image) <= thresh
def footprint_mask(self, image: NDArray[Any], *, force: bool = False) -> NDArray[Any] | None:
def footprint_mask(
self,
image: NDArray[Any],
*,
force: bool = False,
detection: PillDetection | None = None,
) -> NDArray[Any] | None:
"""Full-frame uint8 mask (255 = pill) over the pill's known top-left region.
Uses stable GEOMETRY (a generous fixed box), not the NCC match position: the
synthetic silhouette localizes only approximately, so a match-positioned mask
leaves outline residue, while the top-left corner is negative space, so a
generous geometric box removes the pill cleanly and harmlessly. The caller
A verified label uses its detector-aligned box with measured padding. The
weaker synthetic outline uses stable geometry because its NCC position is
only approximate and a match-positioned mask leaves rim residue. The caller
gates on :meth:`detect`, so a clean corner is never masked. ``force`` is
accepted for a uniform engine signature but ignored (the geometry box is
fixed regardless)."""
accepted for a uniform engine signature but ignored."""
if image is None or image.size == 0:
return None
box = self._footprint_box(image)
if detection is not None and detection.verified_label:
x, y, width, height = detection.region
pad_x = int(image.shape[1] * 0.025)
pad_y = int(image.shape[1] * 0.014)
box = (
max(0, x - pad_x),
max(0, y - pad_y),
min(image.shape[1], x + width + pad_x),
min(image.shape[0], y + height + pad_y),
)
else:
box = self._footprint_box(image)
if box is None:
return None
# Same primitive the shared fill uses, rather than a private zeros/fill copy.
+42 -5
View File
@@ -25,10 +25,12 @@ signal via the registry.
from __future__ import annotations
from dataclasses import replace
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
from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkDetection
from remove_ai_watermarks._text_mark_variants import GlyphTextMarkEngine, TextMarkVariantsMixin
if TYPE_CHECKING:
from numpy.typing import NDArray
@@ -107,11 +109,27 @@ def _template_match_score(box_mask: NDArray[Any], scale_base: int) -> float:
return _text_mark_engine.template_match_score(box_mask, scale_base, _CONFIG)
class SamsungEngine(TextMarkEngine):
class SamsungEngine(TextMarkVariantsMixin):
"""Detect/localize the visible Samsung Galaxy AI text mark (locate -> mask; mask feeds the fill)."""
def __init__(self) -> None:
super().__init__(_CONFIG)
self._variants = (
GlyphTextMarkEngine(
replace(
_CONFIG,
asset_name="samsung_ko_alpha.png",
width_frac=0.28,
height_frac=0.065,
alpha_width_frac=0.20,
alpha_height_frac=0.20 * 49 / 344,
ladder=(0.95, 1.0, 1.05),
max_saturation=255,
detect_frontend="contrast",
detect_ncc_threshold=0.46,
)
),
)
def footprint_mask(
self,
@@ -134,10 +152,29 @@ class SamsungEngine(TextMarkEngine):
image = image_io.to_bgr(image)
det = detection if detection is not None else self.detect(image)
alpha = _alpha_template()
engine = self.variant_engine(det)
radius = _FOOTPRINT_DILATE if dilate is None else max(0, dilate)
if engine is not self and det.detected and det.match_box is not None:
from remove_ai_watermarks import region_eraser
x0, y0, x1, y1 = det.match_box
loc = engine.locate(image)
glyph_height = y1 - y0 + 1
return region_eraser.boxes_to_mask(
image.shape[:2],
[
(
max(0, loc.x + x0 - round(1.3 * glyph_height)),
loc.y + y0,
x1 - x0 + 1 + round(1.3 * glyph_height),
glyph_height,
)
],
dilate=radius,
)
alpha = _text_mark_engine.load_alpha_template(engine.config.asset_name)
if alpha is not None:
radius = _FOOTPRINT_DILATE if dilate is None else max(0, dilate)
aligned = self._aligned_alpha_mask(
aligned = engine._aligned_alpha_mask(
image,
det,
alpha,
+80 -28
View File
@@ -24,13 +24,15 @@ Entries:
- ``qwen`` -- Alibaba Cloud Qwen "千问AI生成" text or three-lobe symbol, bottom-right.
- ``kling`` -- Kuaishou Kling AI "可灵AI 3.0" / "KlingAI 3.0" strip, bottom-right.
- ``yuanbao`` -- Tencent Yuanbao "元宝 / AI生成" two-line mark, bottom-right.
- ``samsung`` -- Samsung Galaxy AI "Contenuti generati dall'AI" strip, bottom-left.
- ``jimeng_pill`` -- Jimeng-basic "AI生成" pill, top-left (capture-less).
- ``dola`` -- ByteDance Dola "Dola AI" image disclosure, bottom-right.
- ``workbuddy`` -- Tencent WorkBuddy "AI生成 / WORKBUDDY" disclosure, bottom-right.
- ``samsung`` -- Samsung Galaxy AI Italian or Korean disclosure, bottom-left.
- ``jimeng_pill`` -- Jimeng-basic "AI生成" pill or solid label, top-left.
- ``runninghub`` -- RunningHub "RunningHub AI生成" text, top-left (gray front-end).
- ``baidu`` -- Baidu "百度 AI生成" text + white tag, bottom-right.
- ``liblib`` -- LiblibAI "LiblibAI" wordmark, bottom-center.
- ``liblib_pill`` -- LiblibAI compact "AI生成" pill, top-left.
- ``microsoft`` -- one measured Microsoft white AI-badge variant, top-right.
- ``microsoft`` -- measured Microsoft white AI badge or beige text pill, 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),
@@ -448,6 +450,8 @@ _ENGINE_CLASS: dict[str, tuple[str, str]] = {
"jimeng": ("jimeng_engine", "JimengEngine"),
"qwen": ("qwen_engine", "QwenEngine"),
"kling": ("kling_engine", "KlingEngine"),
"dola": ("dola_engine", "DolaEngine"),
"workbuddy": ("workbuddy_engine", "WorkBuddyEngine"),
"yuanbao": ("yuanbao_engine", "YuanbaoEngine"),
"samsung": ("samsung_engine", "SamsungEngine"),
"jimeng_pill": ("pill_engine", "PillEngine"),
@@ -641,29 +645,44 @@ def _text_mark(
)
# ── Capture-less mark: the Jimeng-basic "AI生成" pill (top-left) ──
# Detection is edge-NCC of a synthetic silhouette; the mask is a fixed top-left
# geometry box (see pill_engine). Removal is the same localize -> fill as the rest.
# ── Jimeng-basic "AI生成" pill or solid label (top-left) ──
# The weak outline uses synthetic edge-NCC and fixed geometry. The verified solid
# label uses its own contrast template and detector-aligned mask (see pill_engine).
def _pill_detect(image: NDArray[Any], *, provenance: bool = False) -> MarkDetection:
del provenance # the pill detector is provenance-independent; its relaxation lives entirely in _keep_pill
d = _engine("jimeng_pill").detect(image)
return MarkDetection("jimeng_pill", "Jimeng AI生成 pill", "top-left", d.detected, d.confidence, d.region)
d = _engine("jimeng_pill").detect(image, provenance=provenance)
return MarkDetection(
"jimeng_pill",
"Jimeng AI生成 pill",
"top-left",
d.detected,
d.confidence,
d.region,
engine_detection=d,
)
def _pill_detect_both(image: NDArray[Any]) -> tuple[MarkDetection, MarkDetection]:
# The pill detector is provenance-independent (`_pill_detect` discards the flag), so
# one call answers both levels. MarkDetection is frozen, so sharing it is safe.
d = _pill_detect(image)
return d, d
strict, relaxed = _engine("jimeng_pill").detect_both(image)
def wrap(detection: Any) -> MarkDetection:
return MarkDetection(
"jimeng_pill",
"Jimeng AI生成 pill",
"top-left",
detection.detected,
detection.confidence,
detection.region,
engine_detection=detection,
)
return wrap(strict), wrap(relaxed)
def _pill_mask(
image: NDArray[Any], *, force: bool = False, detection: MarkDetection | None = None
) -> NDArray[Any] | None:
# The pill mask is a fixed top-left geometry box, independent of the detection;
# accepted for the uniform _mask signature.
del detection
return _engine("jimeng_pill").footprint_mask(image, force=force)
engine_detection = detection.engine_detection if detection is not None else None
return _engine("jimeng_pill").footprint_mask(image, force=force, detection=engine_detection)
def _pill_features(image: NDArray[Any]) -> dict[str, float]:
@@ -732,6 +751,24 @@ _REGISTRY: tuple[KnownMark, ...] = (
platform="Tencent Yuanbao (visible 元宝 / AI生成 mark detected)",
manufacturer="tencent",
),
_text_mark(
"dola",
"Dola AI image wordmark",
"bottom-right",
platform="Dola AI (visible Dola AI disclosure)",
manufacturer="bytedance",
label_regime=None,
provenance_signals=(),
),
_text_mark(
"workbuddy",
"AI生成 / WORKBUDDY disclosure",
"bottom-right",
platform="WorkBuddy (visible AI content disclosure)",
manufacturer="tencent",
label_regime=None,
provenance_signals=(),
),
# Samsung Galaxy AI is a device editing marker (samsung_genai), not a TC260 label.
_text_mark(
"samsung",
@@ -940,8 +977,14 @@ def _pill_suppressors() -> set[str]:
return {m.key for m in _REGISTRY if m.manufacturer == pill.manufacturer and m.product != pill.product}
def _keep_pill(keys: set[str], *, provenance: frozenset[str], footprint_flat: bool) -> bool:
"""Whether to auto-remove the capture-less 'AI生成' pill given the fired marks.
def _keep_pill(
keys: set[str],
*,
provenance: frozenset[str],
footprint_flat: bool,
verified_label: bool = False,
) -> bool:
"""Whether to auto-remove the ``AI生成`` pill or verified label.
Pure decision (the flatness feature is precomputed at perception time and passed
in). The pill detector is weak and metadata/intent confirms the platform, not pill
@@ -950,23 +993,24 @@ def _keep_pill(keys: set[str], *, provenance: frozenset[str], footprint_flat: bo
* bottom-right "★ 即梦AI" wordmark fired -> ~94% precise, and it survives
metadata-STRIPPED images: remove the pill unrestricted;
* TC260 metadata confirms Jimeng (``"jimeng" in provenance``, no wordmark) -> remove ONLY when the
top-left footprint is flat enough for an invisible fill (``footprint_flat``),
so real flat-scene pills (and harmless flat false fires) are cleaned while the
damaging textured false fires are left untouched.
solid label template verifies the text, or when the weaker outline's top-left
footprint is flat enough for an invisible fill (``footprint_flat``). Real
flat-scene pills are cleaned while damaging textured false fires are left.
A Doubao image is TC260 too but is not Jimeng-basic, so its mark suppresses the
pill. Marks from other TC260 manufacturers are unrelated and do not participate in
this ByteDance-family decision.
pill. The independently attributed LiblibAI pill also wins over the generic
``AI生成`` label shape. Marks from other TC260 manufacturers are unrelated and do
not participate in this ByteDance-family decision.
No confirmation at all -> never remove (blocks false fires on non-Jimeng content).
The suppressor set is derived from the manufacturer and product fields rather than
from the shared TC260 standard. Marks from Google, Samsung, or another TC260
manufacturer cannot enable or veto this ByteDance-specific arm."""
if _pill_suppressors() & keys:
if "liblib_pill" in keys or _pill_suppressors() & keys:
return False
if "jimeng" in keys:
return True
if "jimeng" in provenance:
return footprint_flat
return footprint_flat or verified_label
return False
@@ -990,6 +1034,8 @@ def _build_candidates(image: NDArray[Any]) -> list[Candidate]:
continue
strict, relaxed = m.detect_both(image)
feats = m.features(image) if (strict.detected or relaxed.detected) else {}
if relaxed.engine_detection is not None and getattr(relaxed.engine_detection, "verified_label", False):
feats["verified_label"] = 1.0
cands.append(
Candidate(
m.key,
@@ -1010,7 +1056,7 @@ def decide(candidates: list[Candidate], context: Context) -> list[Decision]:
All policy lives here, in one place: per-mark trust resolution (:func:`resolve_trust`,
which needs the strict-detected siblings for ``auto`` cross-mark corroboration) and
the capture-less pill gate (:func:`_keep_pill`). No image, no I/O -- so it is unit-testable in isolation and
the Jimeng pill/label gate (:func:`_keep_pill`). No image, no I/O -- so it is unit-testable in isolation and
the same decision drives every caller."""
strict_keys = {c.key for c in candidates if c.detected_strict}
fired: list[Decision] = []
@@ -1026,7 +1072,13 @@ def decide(candidates: list[Candidate], context: Context) -> list[Decision]:
if "jimeng_pill" in keys:
pill = next(d for d in fired if d.candidate.key == "jimeng_pill")
flat = bool(pill.candidate.features.get("footprint_flat", 0.0))
if not _keep_pill(keys, provenance=context.provenance, footprint_flat=flat):
verified_label = bool(pill.candidate.features.get("verified_label", 0.0))
if not _keep_pill(
keys,
provenance=context.provenance,
footprint_flat=flat,
verified_label=verified_label,
):
fired = [d for d in fired if d.candidate.key != "jimeng_pill"]
return fired
@@ -0,0 +1,35 @@
"""Detect the corner AI生成 / WORKBUDDY disclosure without inferring a base model."""
from remove_ai_watermarks._text_mark_engine import TextMarkConfig
from remove_ai_watermarks._text_mark_variants import BottomRightBoxTextMarkEngine
_CONFIG = TextMarkConfig(
name="WorkBuddy AI disclosure",
asset_name="workbuddy_alpha.png",
corner="br",
margin_floor=4,
width_frac=0.22,
height_frac=0.13,
margin_x_frac=0.002,
margin_bottom_frac=0.002,
max_saturation=255,
logo_min_luma=110,
tophat_delta=8,
morph_open_size=3,
detect_min_coverage=0.01,
detect_ncc_threshold=0.60,
alpha_width_frac=0.12,
alpha_height_frac=0.12 * 177 / 351,
detect_frontend="tophat",
scale_basis="width",
ladder=(0.95, 1.0, 1.05),
provenance_ncc_factor=1.0,
min_gw=24,
)
class WorkBuddyEngine(BottomRightBoxTextMarkEngine):
"""Detect and localize the two-line WorkBuddy disclosure."""
def __init__(self) -> None:
super().__init__(_CONFIG)
+36 -1
View File
@@ -22,6 +22,7 @@ covered by this silhouette.
from __future__ import annotations
from dataclasses import replace
from typing import TYPE_CHECKING, Any
from remove_ai_watermarks import _text_mark_engine
@@ -29,6 +30,7 @@ from remove_ai_watermarks._text_mark_engine import (
BottomRightAnchoredEngine,
TextMarkConfig,
)
from remove_ai_watermarks._text_mark_variants import BottomRightBoxTextMarkEngine, TextMarkVariantsMixin
if TYPE_CHECKING:
from numpy.typing import NDArray
@@ -79,7 +81,7 @@ def _alpha_template() -> NDArray[Any] | None:
return _text_mark_engine.load_alpha_template(_CONFIG.asset_name)
class YuanbaoEngine(BottomRightAnchoredEngine):
class YuanbaoEngine(TextMarkVariantsMixin, BottomRightAnchoredEngine):
"""Detect and localize the bottom-right Yuanbao mark."""
_ANCHOR_MAX_RIGHT = 0.04
@@ -87,3 +89,36 @@ class YuanbaoEngine(BottomRightAnchoredEngine):
def __init__(self) -> None:
super().__init__(_CONFIG)
self._variants = (
BottomRightBoxTextMarkEngine(
replace(
_CONFIG,
asset_name="yuanbao_compact_alpha.png",
width_frac=0.28,
height_frac=0.13,
scale_basis="width",
alpha_width_frac=0.09,
alpha_height_frac=0.09 * 238 / 387 * 0.75,
ladder=(0.95, 1.0, 1.05),
detect_ncc_threshold=0.50,
provenance_ncc_factor=0.62,
max_saturation=255,
)
),
BottomRightBoxTextMarkEngine(
replace(
_CONFIG,
asset_name="yuanbao_compact_gray_alpha.png",
width_frac=0.28,
height_frac=0.13,
scale_basis="width",
alpha_width_frac=0.06,
alpha_height_frac=0.06 * 238 / 387 * 0.90,
ladder=(0.95, 1.0, 1.05),
detect_frontend="gray",
detect_ncc_threshold=0.50,
provenance_ncc_factor=0.50,
max_saturation=255,
)
),
)
+10
View File
@@ -55,6 +55,16 @@ def test_skill_frontmatter_matches_agent_skills_spec() -> None:
assert "remove-ai-watermarks" in description
def test_project_skills_stay_out_of_public_skill_discovery() -> None:
# `npx skills add` and skills.sh discover every SKILL.md under .claude/skills;
# metadata.internal hides a maintainer-only skill from that listing.
project_skills = sorted((ROOT / ".claude" / "skills").glob("*/SKILL.md"))
assert project_skills
for path in project_skills:
frontmatter = _frontmatter(path.read_text(encoding="utf-8"))
assert re.search(r"(?m)^metadata:\n internal: true$", frontmatter), path
def test_plugin_and_marketplace_names_agree() -> None:
plugin = json.loads(PLUGIN.read_text(encoding="utf-8"))
marketplace = json.loads(MARKETPLACE.read_text(encoding="utf-8"))
+135 -11
View File
@@ -1,20 +1,27 @@
"""Unit tests for how far --cpu-offload travels, per profile.
The flag sets two residency fields. The face one is read by the shared base and
reaches every profile; the global one is read by ``QwenZImagePipeline`` alone, so
on ``chroma-zimage`` and ``sdxl-zimage`` it never touches the stack the user is
trying to fit. ``WatermarkRemover`` says so before the model load.
reaches every profile; the global one is read by ``QwenZImagePipeline`` and
``SdxlZImagePipeline``, so on ``chroma-zimage`` it never touches the stack the user
is trying to fit. ``WatermarkRemover`` says so before the model load.
Both halves are exercised with an uninitialized remover, so the core CI matrix
needs no diffusion dependency, model download, or GPU.
needs no diffusion dependency, model download, or GPU. The SDXL loader placement is
driven through stubbed Diffusers constructors and skips without the diffusion extra.
"""
from __future__ import annotations
import logging
from typing import Any
from unittest.mock import MagicMock
import pytest
from remove_ai_watermarks._internal.sdxl_zimage_pipeline import (
RESIDENT_SDXL_GLOBAL_MIN_VRAM_GIB,
resolve_sdxl_global_residency,
)
from remove_ai_watermarks._internal.watermark_profiles import (
AUTO_PROFILE,
CHROMA_ZIMAGE_PROFILE,
@@ -29,7 +36,7 @@ from remove_ai_watermarks._internal.watermark_remover import WatermarkRemover
OFFLOAD_SUPPORT_CASES = (
(QWEN_ZIMAGE_PROFILE, True),
(CHROMA_ZIMAGE_PROFILE, False),
(SDXL_ZIMAGE_PROFILE, False),
(SDXL_ZIMAGE_PROFILE, True),
# Auto preloads its concrete Qwen fallback before an image vendor is known.
(AUTO_PROFILE, True),
)
@@ -69,18 +76,20 @@ class TestGlobalOffloadSupport:
class TestUnsupportedProfileWarns:
@pytest.mark.parametrize("profile", [CHROMA_ZIMAGE_PROFILE, SDXL_ZIMAGE_PROFILE])
def test_it_names_the_profile_and_the_flag(self, profile: str, caplog: pytest.LogCaptureFixture) -> None:
def test_it_names_the_profile_and_the_flag(self, caplog: pytest.LogCaptureFixture) -> None:
with caplog.at_level(logging.WARNING):
_remover(profile, cpu_offload=True)._warn_if_global_offload_unsupported()
_remover(CHROMA_ZIMAGE_PROFILE, cpu_offload=True)._warn_if_global_offload_unsupported()
assert len(caplog.records) == 1
message = caplog.records[0].getMessage()
assert "--cpu-offload" in message
assert profile in message
assert CHROMA_ZIMAGE_PROFILE in message
def test_qwen_zimage_is_quiet(self, caplog: pytest.LogCaptureFixture) -> None:
@pytest.mark.parametrize("profile", [QWEN_ZIMAGE_PROFILE, SDXL_ZIMAGE_PROFILE])
def test_a_profile_that_streams_its_global_stack_is_quiet(
self, profile: str, caplog: pytest.LogCaptureFixture
) -> None:
with caplog.at_level(logging.WARNING):
_remover(QWEN_ZIMAGE_PROFILE, cpu_offload=True)._warn_if_global_offload_unsupported()
_remover(profile, cpu_offload=True)._warn_if_global_offload_unsupported()
assert caplog.records == []
def test_a_run_that_did_not_ask_for_offload_is_quiet(self, caplog: pytest.LogCaptureFixture) -> None:
@@ -132,3 +141,118 @@ class TestUnsupportedProfileWarns:
with pytest.raises(ImportError):
remover._load_qwen_zimage_pipeline()
assert order == ["warned", "loaded"]
class TestSdxlGlobalResidency:
@pytest.mark.parametrize(
("requested", "total_memory_gib", "expected"),
[
# Unset: the card decides, and an 8 GiB card cannot hold the stack.
(None, 7.6, False),
(None, RESIDENT_SDXL_GLOBAL_MIN_VRAM_GIB, True),
(None, 23.5, True),
# An unreadable card reports 0.0 and must stream rather than spill.
(None, 0.0, False),
# Explicit requests win in both directions.
(False, 79.2, False),
(True, 7.6, True),
],
)
def test_request_wins_and_the_card_decides_otherwise(
self, requested: bool | None, total_memory_gib: float, expected: bool
) -> None:
assert resolve_sdxl_global_residency(requested, total_memory_gib=total_memory_gib) is expected
class _RecordingSdxlPipe:
"""Stands in for the Diffusers SDXL pipeline and records placement calls in order."""
def __init__(self, calls: list[Any]) -> None:
self.calls = calls
self.scheduler = MagicMock()
def to(self, device: str) -> _RecordingSdxlPipe:
self.calls.append(("to", device))
return self
def load_lora_weights(self, _path: str) -> None:
self.calls.append(("load_lora_weights",))
def fuse_lora(self) -> None:
self.calls.append(("fuse_lora",))
def enable_sequential_cpu_offload(self, *, device: str, gpu_id: int | None = None) -> None:
self.calls.append(("enable_sequential_cpu_offload", device, gpu_id))
class TestSdxlGlobalPlacement:
@staticmethod
def _load(
monkeypatch: pytest.MonkeyPatch,
*,
requested: bool | None,
total_memory_gib: float,
current_device: int = 0,
) -> list[Any]:
diffusers = pytest.importorskip("diffusers")
torch = pytest.importorskip("torch")
monkeypatch.setattr(torch.cuda, "current_device", lambda: current_device)
from remove_ai_watermarks._internal.sdxl_zimage_pipeline import SdxlZImagePipeline
calls: list[Any] = []
monkeypatch.setattr(diffusers.ControlNetModel, "from_pretrained", lambda *a, **k: MagicMock())
monkeypatch.setattr(diffusers.AutoencoderKL, "from_pretrained", lambda *a, **k: MagicMock())
monkeypatch.setattr(
diffusers.StableDiffusionXLControlNetImg2ImgPipeline,
"from_pretrained",
lambda *a, **k: _RecordingSdxlPipe(calls),
)
monkeypatch.setattr("huggingface_hub.hf_hub_download", lambda *a, **k: "lora.safetensors")
monkeypatch.setattr(diffusers.EulerDiscreteScheduler, "from_config", lambda *a, **k: MagicMock())
pipeline = SdxlZImagePipeline(device="cuda", torch_dtype=None, keep_global_models_on_device=requested)
monkeypatch.setattr(type(pipeline), "_require_cuda", lambda self: None)
monkeypatch.setattr(type(pipeline), "_total_vram_gib", lambda self: total_memory_gib)
pipeline._load_global()
return calls
def test_a_small_card_streams_the_fused_stack_instead_of_moving_it(self, monkeypatch: pytest.MonkeyPatch) -> None:
calls = self._load(monkeypatch, requested=None, total_memory_gib=7.6)
# The offload hooks stream their own copy of each weight, so they must follow the fuse.
assert calls == [("load_lora_weights",), ("fuse_lora",), ("enable_sequential_cpu_offload", "cuda", 0)]
def test_a_large_card_keeps_the_stack_resident_as_before(self, monkeypatch: pytest.MonkeyPatch) -> None:
calls = self._load(monkeypatch, requested=None, total_memory_gib=23.5)
assert calls == [("to", "cuda"), ("load_lora_weights",), ("fuse_lora",)]
def test_cpu_offload_streams_even_on_a_large_card(self, monkeypatch: pytest.MonkeyPatch) -> None:
calls = self._load(monkeypatch, requested=False, total_memory_gib=79.2)
assert ("to", "cuda") not in calls
assert calls[-1] == ("enable_sequential_cpu_offload", "cuda", 0)
@pytest.mark.parametrize("current_device", [1, 2])
@pytest.mark.parametrize(("requested", "total_memory_gib"), [(None, 7.6), (False, 79.2)])
def test_streaming_uses_the_current_cuda_device(
self, monkeypatch: pytest.MonkeyPatch, current_device: int, requested: bool | None, total_memory_gib: float
) -> None:
calls = self._load(
monkeypatch,
requested=requested,
total_memory_gib=total_memory_gib,
current_device=current_device,
)
assert ("to", "cuda") not in calls
assert calls[-1] == ("enable_sequential_cpu_offload", "cuda", current_device)
def test_cpu_offload_reaches_the_sdxl_stack(self, monkeypatch: pytest.MonkeyPatch) -> None:
from remove_ai_watermarks._internal import sdxl_zimage_pipeline
captured: dict[str, object] = {}
class Recorder:
def __init__(self, **kwargs: object) -> None:
captured.update(kwargs)
monkeypatch.setattr(sdxl_zimage_pipeline, "SdxlZImagePipeline", Recorder)
_remover(SDXL_ZIMAGE_PROFILE, cpu_offload=True)._load_qwen_zimage_pipeline()
assert captured["keep_global_models_on_device"] is False
+26
View File
@@ -0,0 +1,26 @@
"""Clean UI boundaries must not become automatic Kling removal targets."""
import numpy as np
import pytest
from remove_ai_watermarks._text_mark_engine import TextMarkEngine
from remove_ai_watermarks.kling_engine import KlingEngine, _KlingTextEngine
from remove_ai_watermarks.watermark_registry import Context, _build_candidates, decide
def test_horizontal_boundary_is_not_a_kling_wordmark():
image = np.full((2244, 1080, 3), (175, 183, 190), dtype=np.uint8)
image[-40:] = 0
engine = KlingEngine()
strict, relaxed = engine.detect_both(image)
assert not strict.detected
assert not relaxed.detected
assert engine.footprint_mask(image) is None
assert "kling" not in {d.candidate.key for d in decide(_build_candidates(image), Context())}
def test_boundary_fixture_fires_when_glyph_detail_gate_is_removed(monkeypatch: pytest.MonkeyPatch):
image = np.full((2244, 1080, 3), (175, 183, 190), dtype=np.uint8)
image[-40:] = 0
monkeypatch.setattr(_KlingTextEngine, "_ladder_best", TextMarkEngine._ladder_best)
assert KlingEngine().detect(image).detected
+13 -1
View File
@@ -8,8 +8,10 @@ import subprocess
from pathlib import Path
import pytest
import yaml
GATE = Path(__file__).resolve().parents[1] / "maintain.sh"
ROOT = Path(__file__).resolve().parents[1]
GATE = ROOT / "maintain.sh"
BASH = shutil.which("bash")
pytestmark = pytest.mark.skipif(os.name != "posix" or BASH is None, reason="The maintenance entry point requires Bash")
@@ -65,3 +67,13 @@ def test_security_exit_status_controls_the_gate(tmp_path, scanner_output, scanne
else:
assert result.returncode == 0
assert calls[-1] == "uv run pytest -n auto"
def test_ci_runs_the_gate_security_scan_unweakened():
scan = next(line for line in GATE.read_text().splitlines() if line.startswith("uvx uv-secure"))
workflow = yaml.safe_load((ROOT / ".github/workflows/test.yml").read_text())
job = workflow["jobs"]["security"]
runs = [step["run"].strip() for step in job["steps"] if "run" in step]
assert runs == [scan]
assert not job.get("continue-on-error")
assert not any(step.get("continue-on-error") for step in job["steps"])
+1 -1
View File
@@ -192,7 +192,7 @@ class TestNoReembeddedWatermark:
# from_config would otherwise resolve the mock's config as a repo id.
monkeypatch.setattr(diffusers.EulerDiscreteScheduler, "from_config", lambda *a, **k: MagicMock())
pipeline = SdxlZImagePipeline(device="cuda", torch_dtype=None)
pipeline = SdxlZImagePipeline(device="cuda", torch_dtype=None, keep_global_models_on_device=True)
monkeypatch.setattr(type(pipeline), "_require_cuda", lambda self: None)
pipeline._load_global()
+20
View File
@@ -0,0 +1,20 @@
"""Independent vendor fields cannot prove provenance fraud."""
from remove_ai_watermarks.identify import _integrity_clashes
def test_mixed_edit_provenance_preserves_sources_without_alleging_fraud():
warnings = _integrity_clashes({"c2pa": "Google", "xai": "xAI"}, None, camera_has_ai_marker=True)
assert len(warnings) == 1
assert "Google" in warnings[0]
assert "xAI" in warnings[0]
assert "edit" in warnings[0].lower()
assert "spoofed" not in warnings[0]
assert "laundered" not in warnings[0]
def test_camera_and_ai_fields_are_described_as_a_possible_edit_chain():
warnings = _integrity_clashes({"iptc": "Adobe Firefly"}, "Leica", camera_has_ai_marker=True)
assert len(warnings) == 1
assert "captured image followed by an AI edit" in warnings[0]
assert "inconsistent" not in warnings[0]
+106
View File
@@ -0,0 +1,106 @@
"""Synthetic regressions for independently gated text-mark layouts."""
from __future__ import annotations
import cv2
import numpy as np
import pytest
from remove_ai_watermarks import _text_mark_engine
from remove_ai_watermarks.doubao_engine import DoubaoEngine, _OutlinedDoubaoEngine
from remove_ai_watermarks.microsoft_engine import MicrosoftEngine
from remove_ai_watermarks.samsung_engine import SamsungEngine
from remove_ai_watermarks.yuanbao_engine import YuanbaoEngine
def _stamp_variant(engine, variant_index: int, *, width: int = 1280, height: int = 960):
variant = engine._variants[variant_index]
config = variant.config
background = 90 if config.asset_name != "microsoft_text_alpha.png" else 220
foreground = 235 if background < 128 else 35
clean = np.full((height, width, 3), background, np.uint8)
alpha = _text_mark_engine.load_alpha_template(config.asset_name)
assert alpha is not None
base = variant.scale_base(clean)
glyph_width = int(config.alpha_width_frac * base)
glyph_height = int(config.alpha_height_frac * base)
alpha = cv2.resize(alpha, (glyph_width, glyph_height), interpolation=cv2.INTER_LINEAR)
loc = variant.locate(clean)
x = loc.x if config.corner in {"bl", "tl"} else loc.x + loc.w - glyph_width
y = loc.y if config.corner in {"tl", "tr"} else loc.y + loc.h - glyph_height
if config.asset_name == "samsung_ko_alpha.png":
x += round(1.3 * glyph_height)
marked = clean.copy()
if config.asset_name == "microsoft_text_alpha.png":
pad_x, pad_y = int(width * 0.012), int(width * 0.014)
cv2.rectangle(
marked,
(x - pad_x, y - pad_y),
(x + glyph_width + pad_x, y + glyph_height + pad_y),
(242, 238, 232),
-1,
)
region = marked[y : y + glyph_height, x : x + glyph_width].astype(np.float32)
opacity = alpha[:, :, None]
marked[y : y + glyph_height, x : x + glyph_width] = np.clip(
region * (1.0 - opacity) + foreground * opacity, 0, 255
).astype(np.uint8)
return clean, marked, (x, y, glyph_width, glyph_height)
@pytest.mark.parametrize(
("engine", "asset"),
[
(DoubaoEngine(), "doubao_outline_alpha.png"),
(YuanbaoEngine(), "yuanbao_compact_alpha.png"),
(SamsungEngine(), "samsung_ko_alpha.png"),
(MicrosoftEngine(), "microsoft_text_alpha.png"),
],
)
def test_variant_detection_retains_winning_asset(engine, asset: str) -> None:
_clean, marked, _box = _stamp_variant(engine, 0)
detection = engine.detect(marked)
assert detection.detected
assert detection.template_asset == asset
def test_microsoft_text_variant_masks_the_enclosing_pill() -> None:
clean, marked, _box = _stamp_variant(MicrosoftEngine(), 0)
detection = MicrosoftEngine().detect(marked)
mask = MicrosoftEngine().footprint_mask(marked, detection=detection)
assert mask is not None
changed = np.any(clean != marked, axis=2)
assert np.count_nonzero(mask[changed]) / np.count_nonzero(changed) >= 0.98
def test_samsung_korean_variant_masks_the_leading_sparkle_area() -> None:
_clean, marked, (x, y, _width, height) = _stamp_variant(SamsungEngine(), 0)
detection = SamsungEngine().detect(marked)
mask = SamsungEngine().footprint_mask(marked, detection=detection)
assert mask is not None
sparkle_x = x - height // 2
sparkle_y = y + height // 2
assert mask[sparkle_y, sparkle_x] == 255
def test_low_confidence_cjk_layouts_require_matching_provenance() -> None:
doubao = DoubaoEngine()._variants[0].config
yuanbao_contrast, yuanbao_gray = (variant.config for variant in YuanbaoEngine()._variants)
assert (doubao.detect_ncc_threshold, doubao.provenance_ncc_factor) == (0.50, 0.25)
assert (yuanbao_contrast.detect_ncc_threshold, yuanbao_contrast.provenance_ncc_factor) == (0.50, 0.62)
assert (yuanbao_gray.detect_ncc_threshold, yuanbao_gray.provenance_ncc_factor) == (0.50, 0.50)
def test_doubao_provenance_does_not_confirm_a_colored_template_shape(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_clean, marked, _box = _stamp_variant(DoubaoEngine(), 0)
gray = cv2.cvtColor(marked, cv2.COLOR_BGR2GRAY).astype(np.float32)
blend = np.clip((gray - 90) / 145, 0, 1)[:, :, None]
background = np.array([30, 60, 120], np.float32)
foreground = np.array([60, 180, 250], np.float32)
colored_shape = (background * (1 - blend) + foreground * blend).astype(np.uint8)
assert not DoubaoEngine().detect(colored_shape, provenance=True).detected
monkeypatch.setattr(_OutlinedDoubaoEngine, "_outline_pixels_ok", lambda _cls, _patch: True)
assert DoubaoEngine().detect(colored_shape, provenance=True).detected
+2
View File
@@ -24,6 +24,8 @@ _ALTERNATE_SIZE = {
"wan": (1280, 1280),
"kling": (1280, 960),
"yuanbao": (1280, 960),
"dola": (1280, 960),
"workbuddy": (1280, 960),
"samsung": (2304, 1728),
"runninghub": (1280, 960),
"baidu": (1280, 960),
+19 -1
View File
@@ -22,6 +22,8 @@ class TestCatalog:
"wan",
"kling",
"yuanbao",
"dola",
"workbuddy",
"samsung",
"runninghub",
"baidu",
@@ -117,6 +119,8 @@ class TestScan:
"wan",
"kling",
"yuanbao",
"dola",
"workbuddy",
"samsung",
"runninghub",
"baidu",
@@ -722,12 +726,26 @@ class TestPillSuppressors:
m.key for m in reg.known_marks() if m.manufacturer == pill.manufacturer and m.product != pill.product
}
assert reg._pill_suppressors() == expected
assert expected == {"doubao"}
assert expected == {"dola", "doubao"}
def test_other_manufacturers_do_not_suppress_the_pill(self):
assert reg._keep_pill({"qwen"}, provenance=frozenset({"jimeng"}), footprint_flat=1.0)
assert reg._keep_pill({"liblib", "jimeng"}, provenance=frozenset(), footprint_flat=1.0)
def test_verified_label_bypasses_texture_only_with_jimeng_provenance(self):
assert reg._keep_pill(
{"jimeng_pill"},
provenance=frozenset({"jimeng"}),
footprint_flat=False,
verified_label=True,
)
assert not reg._keep_pill(
{"jimeng_pill"},
provenance=frozenset(),
footprint_flat=False,
verified_label=True,
)
def test_pill_survives_gemini_and_samsung(self):
"""Neither is a TC260 labeler, and neither can put "jimeng" into provenance,
so neither may veto the arm it could not have enabled."""
Generated
+26 -26
View File
@@ -2098,7 +2098,7 @@ wheels = [
[[package]]
name = "openai"
version = "3.22.1"
version = "3.24.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@@ -2108,9 +2108,9 @@ dependencies = [
{ name = "sniffio" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e7/db/b135830095e2ae120ea04b0a53d3a1644eb65554385a8ef4c9cb4de3f5fd/openai-3.22.1.tar.gz", hash = "sha256:2575f961e4c1df8761f8eeb4d78e967de3f9b9f91b22f7120d60e393e970094d", size = 1751626, upload-time = "2026-09-30T00:25:55.548Z" }
sdist = { url = "https://files.pythonhosted.org/packages/73/4f/e57670227cb7b61362d8f9bfba54d4e9f7bde799798c342782788bc12d6c/openai-3.24.0.tar.gz", hash = "sha256:1e7463f7d78773ab2ce4fe85710481aa5bd5ffefd54c8de4b067506cd2d42895", size = 1797218, upload-time = "2026-10-02T15:55:18.851Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/74/e3b77d1157c8b9ad815a87d5719545097e5edb06156869c1657fbb6b797b/openai-3.22.1-py3-none-any.whl", hash = "sha256:43ff3de5457a8beb4afa177d4e0ba6841cfadd5219ebc62297fde6c8a9d401a6", size = 2090965, upload-time = "2026-09-30T00:25:53.524Z" },
{ url = "https://files.pythonhosted.org/packages/f2/01/32baec3bdb83d36da27281d7eeac2647241345cf8ff538bc21d1013ddeec/openai-3.24.0-py3-none-any.whl", hash = "sha256:b59703a3e3b5df78b269e90049d383a1dda58c90104cc4be59db21078db4b7a4", size = 2127166, upload-time = "2026-10-02T15:55:16.228Z" },
]
[[package]]
@@ -3065,11 +3065,11 @@ wheels = [
[[package]]
name = "python-dotenv"
version = "1.2.3"
version = "1.2.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" }
sdist = { url = "https://files.pythonhosted.org/packages/74/26/2fbeedb218a787a5eea551c7532cac4e009f83d689dd2faa0d0353473f86/python_dotenv-1.2.4.tar.gz", hash = "sha256:f0d53e69935a851c0dcc78f3ab7aaccd8cabef0b92382b576b824212902873c0", size = 60824, upload-time = "2026-10-01T05:36:10Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" },
{ url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" },
]
[[package]]
@@ -3346,7 +3346,7 @@ wheels = [
[[package]]
name = "remove-ai-watermarks"
version = "0.44.0"
version = "0.45.1"
source = { editable = "." }
dependencies = [
{ name = "c2pa-python" },
@@ -3603,27 +3603,27 @@ wheels = [
[[package]]
name = "ruff"
version = "0.16.9"
version = "0.16.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c4/49/23802c45f093eb14bde54b141d2b2f058edfa63a06db7beed047308cc08f/ruff-0.16.10.tar.gz", hash = "sha256:eff4728c4eaae93f0955cd264d24b2ab348e74bf59986ccf282ba6dc16b3b017", size = 4958724, upload-time = "2026-10-01T18:03:21.697Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" },
{ url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" },
{ url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" },
{ url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" },
{ url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" },
{ url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" },
{ url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" },
{ url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" },
{ url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" },
{ url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" },
{ url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" },
{ url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" },
{ url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" },
{ url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" },
{ url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" },
{ url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" },
{ url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" },
{ url = "https://files.pythonhosted.org/packages/2f/21/ebce22e1d90cdb2cd691026b9c6e9bec6499f0b396089481755b5d49efff/ruff-0.16.10-py3-none-linux_armv6l.whl", hash = "sha256:488b0fe3f3574210e5cf80d9f59b9e3ab17a127a8155de3f307b392589cfb511", size = 10095557, upload-time = "2026-10-01T18:02:36.072Z" },
{ url = "https://files.pythonhosted.org/packages/cb/98/a54de85876a8b2612bfa0d84c7b9abfb39c6a3354aee7800b09c1649b9e3/ruff-0.16.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e748ff95c934c4e978783b8e687bc174e7bd84e8ad24e3243e1ecfcda5e0282d", size = 10340577, upload-time = "2026-10-01T18:02:39.258Z" },
{ url = "https://files.pythonhosted.org/packages/9f/16/1a5a4a2657effe4806110f2b907313802f1367fcdbb29e8122766e407fab/ruff-0.16.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3031a4a2e8e7b8a46f70be45f198c35a11ece509a94b80334d8d397a33c67550", size = 9774282, upload-time = "2026-10-01T18:02:41.79Z" },
{ url = "https://files.pythonhosted.org/packages/6e/fb/470085af734da396e68cd80fb0a3e7c109a459716ae59588c0f6fab8a17d/ruff-0.16.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:494401c86df4c4c25f69b9419605d944467ee98c42fb6ad405ef4fa40b8fb67d", size = 9920895, upload-time = "2026-10-01T18:02:44.458Z" },
{ url = "https://files.pythonhosted.org/packages/57/de/f10cffe4f88a37ea6615ec460f01bf76f0bc1477737a35d9ee61a630a78b/ruff-0.16.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d203abc0ff2b773ee33d00ab8df0bb67046fbc7c07b119332f08b9b345cf8221", size = 9902707, upload-time = "2026-10-01T18:02:47.041Z" },
{ url = "https://files.pythonhosted.org/packages/ff/44/3fdcedf83ae60ef837dd239170e480dce606a9142cfa2db911afdf855fd9/ruff-0.16.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd83d1235a5258d318477bc5b576303974cbdef5df0c01a1bff14efcc23bd12a", size = 10619287, upload-time = "2026-10-01T18:02:49.485Z" },
{ url = "https://files.pythonhosted.org/packages/c1/62/02e76a5574002153618eafbb70e159468addc72a5d2c488e65cbb4639d2d/ruff-0.16.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc2610fb269fa56dd8a68669ae470fa6272902668c0fc2ebc3aa112b2633d5b8", size = 11339942, upload-time = "2026-10-01T18:02:52.008Z" },
{ url = "https://files.pythonhosted.org/packages/6b/c4/cde27d47ad8d4126c587e608c67c46e7feac11763f606d261a1bc8a489e6/ruff-0.16.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e70175e29cc94c26ea296c80e470180b744b7419026898e58f520c6ab32578e", size = 10934316, upload-time = "2026-10-01T18:02:54.811Z" },
{ url = "https://files.pythonhosted.org/packages/e4/03/17234145f302a645a123e8c3bb2411ecf4669fbc350de3b0d803230a1729/ruff-0.16.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f33f43a864a8483eebd160e713336c8bab02c934feaff0a33cf5ccb41546d09a", size = 10387968, upload-time = "2026-10-01T18:02:57.497Z" },
{ url = "https://files.pythonhosted.org/packages/71/29/2493af60240ee7644b38a4b821f5f9c3b5a4fa3178770fe1d0c685217395/ruff-0.16.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1dfc6f0088149fb6a362c1c446bcbb3fd2157b3852fe2fa68409276eab9ad9b3", size = 10537906, upload-time = "2026-10-01T18:03:00.006Z" },
{ url = "https://files.pythonhosted.org/packages/2e/9a/f56b28f3b143bb9e518c34fb89ab191b626bca8b70dbf8af0d2e2d473572/ruff-0.16.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6553498afc35f580f036030795810b9e6bcea31604b0fd9e8d352795473042e3", size = 10012938, upload-time = "2026-10-01T18:03:03.006Z" },
{ url = "https://files.pythonhosted.org/packages/c2/c4/fca37362848ea4d4d80712df13632e645e7c7cfb1bdedf140699ac7a090b/ruff-0.16.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a3b8471dea115d37f123882be852bed13403746d3a76c11de4a19ec5f9ff5a03", size = 9897945, upload-time = "2026-10-01T18:03:05.818Z" },
{ url = "https://files.pythonhosted.org/packages/7d/c7/e0bc57664d6e0c61fd22f620af260fe9662d7e1ddb320ea7f160e76ef165/ruff-0.16.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:92e59a70bcbd9d3a5483656da906ec28edfdacfce00afd99edb8b4e9d15644be", size = 10333134, upload-time = "2026-10-01T18:03:08.25Z" },
{ url = "https://files.pythonhosted.org/packages/21/aa/5c9f3b68737c0e4a33a1db5dfab44f7b786d96ca91a7233112ddab1e6dc2/ruff-0.16.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7ae7375f803b5520dc9f546bed7e3a0acb70b91812e9bb4b19927de22f25b77d", size = 10741702, upload-time = "2026-10-01T18:03:10.775Z" },
{ url = "https://files.pythonhosted.org/packages/78/fa/0f9c2020dc316c53d983be011720b8157cc052b97e3f4dfa7db6d0f880a6/ruff-0.16.10-py3-none-win32.whl", hash = "sha256:2a12e01cb9156c10c466f63b46eaae5ecea28dfbd21b5836353ae498e7d1349a", size = 10139176, upload-time = "2026-10-01T18:03:13.267Z" },
{ url = "https://files.pythonhosted.org/packages/99/29/cfb0df9448d4d4ad48c2de029ada9ebd71baa6da983a6c77ee6c6cd0fe82/ruff-0.16.10-py3-none-win_amd64.whl", hash = "sha256:97f2015c92aa97105b0eab19eb5d224884399281cfc5da86a92db4ab5e7fb2ca", size = 10584734, upload-time = "2026-10-01T18:03:16.006Z" },
{ url = "https://files.pythonhosted.org/packages/fc/05/c16957eb287c3fc062e032619a25868d93d408a844b725bcf514f7a378ff/ruff-0.16.10-py3-none-win_arm64.whl", hash = "sha256:25a65fe998c4e6861ec079ada5826a2fc605e6cbccbe9dcd7fac1f54e791621b", size = 10366440, upload-time = "2026-10-01T18:03:19.04Z" },
]
[[package]]