Compare commits

..
12 Commits
Author SHA1 Message Date
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
Victor Kuznetsov 0c3e0fa415 Release 0.44.0 2026-10-01 14:24:27 -07:00
Victor Kuznetsov b3b868153f Record Google Photos checks and continue across provider accounts 2026-10-01 13:42:21 -07:00
Victor Kuznetsov 304bd071c0 Unify TC260 producers and strengthen provenance regression coverage
Share producer identities across mark routing, attribution, and pinned signers.
Preserve nested Honor TC260 fields and report unsupported RSA signatures.
Document the Google Photos checks and public sample search.

Use an SDK-signed synthetic carrier for negative EKU checks after the original
Microsoft certificates expired. Skip boundary regex scans when no C2PA
soft-binding identifier is present.
2026-10-01 11:30:32 -07:00
Victor Kuznetsov 2e26df1f21 Restore C2PA certificate validation and refresh compatible dependencies 2026-09-30 16:58:43 -07:00
Victor KuznetsovandClaude Opus 5.5 d800ec9455 Verify TC260 SecurityData label signatures against a pinned MiniMax key
MiniMax signs its TC260 label with SM3withSM2 per TC260-PG-202511A. A
verification-only SM3/SM2 (no dependency provides the SM2 curve) checks the
appendix A.1 label message; a signature under the pinned MiniMax key is a
high-confidence aigc_signature signal, a failing one is a caveat, and every
result is the aigc_signature metadata marker. Pinned by the GB/T 32905 SM3
vectors, OpenSSL's SM2 verify vectors and real MiniMax files. Over 411 local
files the only verdict-record change is the marker on nine MiniMax videos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:36:08 -07:00
Victor KuznetsovandClaude Opus 5.5 ef7b972ed1 Sync the C2PA soft-binding snapshot to d36d1c1
The upstream list widened com.museblossom.contentsdefence.1 from image to
audio, image and video. Verdicts over 411 local files are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:36:08 -07:00
Victor KuznetsovandClaude Opus 5.5 b96ede6f44 Record the verified MiniMax TC260 SecurityData signatures
MiniMax writes a TC260-PG-202511A SecurityData object with SM3withSM2
signatures. The label signature verifies on all four local MiniMax videos
against the guide's appendix A.1 message, and the Binding signature over the
compact Bindings entry; the SM3 content hash does not match a naive selection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:16:42 -07:00
79 changed files with 3215 additions and 769 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": [
{
+8 -5
View File
@@ -27,10 +27,12 @@ top-level Python API, and runtime detection/removal pipelines.
The unified maintainer entry point is `scripts/provider_oracles.py`; its workflow and
slot schema live in `docs/provider-oracles.md`. Multi-account and multi-network work
selects one named slot explicitly from the gitignored repository-local
`.oracle-slots.json` by default and snapshots it into the immutable batch manifest.
Never commit the local slot file.
Do not add account or IP rotation, rate-limit failover, or cross-session result merging.
selects each named slot explicitly from the gitignored repository-local
`.oracle-slots.json` by default and snapshots it into a separate immutable batch
manifest. A bounded provider check may continue through its finite set of authorized
account slots after a refusal; do not add new logins, IP rotation, network-route
failover, unbounded retries, or cross-session result merging. Never commit the local
slot file.
Every manual result stays bound to the exact sanitized upload hash and preserves the
provider response verbatim.
@@ -79,7 +81,8 @@ Run `bash maintain.sh` from the repository root. The authoritative type gate is
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.
+29 -10
View File
@@ -14,9 +14,24 @@ Named slots live in the gitignored repository-local
pass `--slots PATH` only to override the local path.
Never substitute one provider's negative verdict for another provider's signal.
Do not optimize candidates adaptively against an external oracle. A retry,
different account, or different network is a new explicit operator decision,
not automatic fallback.
Do not optimize candidates adaptively against an external oracle.
## Account continuation
A request to verify a bounded set of files with a provider authorizes using that
provider's configured account slots unless the user limits the account scope.
Before starting, identify the finite set of available authenticated slots and
prepare one immutable batch per slot. After a quota refusal or service failure,
record the exact response, leave that slot's remaining rows unsubmitted, and
continue automatically through the remaining authorized slots. Do not stop to
request permission for each account or file already covered by the task.
Submit each frozen artifact at most once per slot in that pass. Preserve
inconclusive results and disagreements between accounts. Stop when the bounded
plan is complete or no authorized usable slot remains; report remaining rows.
New logins, another provider, a new network route, or another pass after the plan
is exhausted require authorization unless already requested. Account
continuation does not authorize changing candidate pixels or erasing failures.
## Default surface priority
@@ -50,8 +65,10 @@ Playwright CLI runner, copied cookies, or a new login.
AI metadata. Inspect the prepared upload again and do not submit it if a visible
provider mark remains. A model answer based on a visible logo is not a SynthID
result.
4. Before each upload, request the action-time confirmation required for sending
that exact prepared file to Gemini. Do not upload the source path directly.
4. Confirm that the prepared files and provider are covered by the user's
request or existing upload approval. Ask only if that scope is missing or
changed; do not request confirmation again for each file or account in the
authorized plan. Do not upload the source path directly.
5. Attach one prepared upload and ask: `Does this image/video/audio contain a
SynthID watermark? Use the SynthID verification tool only; do not infer from
visible content, logos, text, or metadata.` Do not substitute a general
@@ -62,9 +79,10 @@ Playwright CLI runner, copied cookies, or a new login.
clean.
7. Record the verbatim response and verify the completed batch.
If the selected Chrome profile or `/u/N/` account is not already authenticated,
stop and ask the user to select the intended existing session. Do not sign in or
switch to another account implicitly.
If a planned Chrome profile or `/u/N/` account is not already authenticated,
record that it is unavailable and continue with another authorized existing
session. Ask the user only when no usable authorized session remains. Do not
start a new login.
### OpenAI, Microsoft, and Meta Web
@@ -110,8 +128,9 @@ session continuity, not permission to retry a refused batch on another route.
Use `check-openai` with one explicit upload acknowledgement. Keep named API keys
as `OPENAI_API_KEY_1`, `OPENAI_API_KEY_2`, and `OPENAI_API_KEY_3` in the local `.env`, and select
only their variable names through an `openai-api` slot's `api_key_env`. Never
print a key, copy one into a slot or manifest, or fall back automatically to a
second key. API slots use a direct SDK transport that ignores environment proxy
print a key or copy one into a slot or manifest. Continue through authorized
named API slots according to the bounded account plan above; keep a separate
batch for each. API slots use a direct SDK transport that ignores environment proxy
settings. The current adapter is image-only.
### Microsoft API
+8 -1
View File
@@ -18,11 +18,18 @@ updates:
# extra) requires NumPy <2.4, mirrored by `constraint-dependencies` in
# pyproject.toml, and this project keeps tokenizers below 0.23 for the stable
# Transformers path. Keep Dependabot on the compatible lines until those
# constraints move.
# constraints move. Transformers 5.16+ requires tokenizers 0.23.1+,
# and Diffusers requires huggingface-hub <2. Mirror the manifest bounds.
ignore:
- dependency-name: "numpy"
versions:
- ">=2.4"
- dependency-name: "transformers"
versions:
- ">=5.16"
- dependency-name: "huggingface-hub"
versions:
- ">=2"
- dependency-name: "tokenizers"
versions:
- ">=0.23"
+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).
+25
View File
@@ -3,6 +3,31 @@
These files exercise real container, metadata, and pixel-provenance formats.
They are test inputs, not evaluation corpora.
## Honor YOYO metadata reconstruction
`synthetic/honor-yoyo-tc260.json` reconstructs the EXIF `UserComment` JSON
structure observed in two public YOYO edits in this
[Honor Club thread](https://club.honor.com/cn/thread-29898091-1-1.html), fetched
on 2026-10-01 UTC. The downloaded JPEGs have SHA-256
`99aa5cdf42cd4c3849401a32fc1f0738c229cc3ed903eba00a080e7eb29542d5` and
`af779adba954926bb15bb9cb70ff597cb11e715b53e204fce5e64b1edf971aa1`.
They remain untracked because public redistribution has not been cleared.
The fixture retains the observed producer `001191440300MA5G49LC9K1YO01`,
nested `ReservedCode1`/`ReservedCode2` objects, `PubSd` spelling, null hash
binding, and RSA/SHA-256 algorithm identifiers. Generation IDs, signature,
and public key are explicit synthetic placeholders. It contains no photograph,
account handle, real signature, or claim of a valid asset binding. The producer's
USCC matches Honor Device Co., Ltd. in its
[official privacy statement](https://agreement.itsec.honor.com/asm/agrFile/getHtmlFile?agrNo=1477&branchId=0&country=ru&langCode=en-US&version=20250517).
`tests/test_web_sample_fixtures.py` is the carrier recipe: it embeds this JSON
in a solid-color JPEG, checks Honor attribution and preservation of nested JSON,
reports the content signature as unsupported, and verifies removal preserves
decoded pixels. This synthetic test input
is distributed under the repository license. It does not establish gallery
control/crop behavior, device/app versions, or a real verified RSA signature.
## OpenRouter metadata survey
The following files are original byte streams returned by OpenRouter's Image
@@ -0,0 +1,53 @@
{
"AIGC": {
"ContentProducer": "001191440300MA5G49LC9K1YO01",
"ContentPropagator": "001191440300MA5G49LC9K1YO01",
"Label": "1",
"ProduceID": "synthetic-yoyo-fixture",
"PropagateID": "synthetic-yoyo-fixture",
"ReservedCode1": {
"SecurityData": {
"Type": "TC260PG",
"Version": 1,
"Bindings": [
{"Type": "Hash", "AlgID": "2.16.840.1.101.3.4.2.1", "Value": null}
],
"PubSd": [
{
"Type": "DS",
"AlgID": "1.2.840.113549.1.1.11",
"TBSData": {"Type": "Content"},
"Signature": "synthetic-signature-not-valid"
},
{
"Type": "PubKey",
"AlgID": "1.2.840.113549.1.1.11",
"KeyValue": "synthetic-public-key-not-valid"
}
]
}
},
"ReservedCode2": {
"SecurityData": {
"Type": "TC260PG",
"Version": 1,
"Bindings": [
{"Type": "Hash", "AlgID": "2.16.840.1.101.3.4.2.1", "Value": null}
],
"PubSd": [
{
"Type": "DS",
"AlgID": "1.2.840.113549.1.1.11",
"TBSData": {"Type": "Content"},
"Signature": "synthetic-signature-not-valid"
},
{
"Type": "PubKey",
"AlgID": "1.2.840.113549.1.1.11",
"KeyValue": "synthetic-public-key-not-valid"
}
]
}
}
}
}
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
+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 |
+40 -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
@@ -61,6 +62,34 @@ Python-dependent branches, so apply separate constraints on either side of
Python 3.13. A blanket upgrade can both cross a NumPy major and downgrade a
neighboring OCR package to satisfy the new graph.
### Dependency compatibility bounds
The published extras and Dependabot configuration carry matching bounds:
- Transformers stays below 5.16 while tokenizers stays on the verified 0.22
line. [PyPI metadata for Transformers 5.16](https://pypi.org/pypi/transformers/5.16.0/json)
and [5.17](https://pypi.org/pypi/transformers/5.17.0/json) requires tokenizers
`>=0.23.1,<0.24`, so updating Transformers alone cannot resolve. Revalidate
tokenizer construction and the model-loading paths before lifting both bounds.
- Hugging Face Hub stays below 2 in every extra that names it. Diffusers 0.40
[requires `huggingface-hub>=1.23,<2`](https://pypi.org/pypi/diffusers/0.40.0/json);
a Hub-only extra must remain composable with the diffusion extra. Recheck
Diffusers' declared requirements before lifting the Hub bound.
- C2PA requires 0.38.0 or newer and uses an explicit per-reader certificate
profile. Its Rust SDK 0.91.0 clears the bundled EKUs when loading empty trust
settings, which otherwise rejects the OpenAI and Microsoft signing
certificates in five committed fixtures. The profile restores those EKUs
without installing trust anchors or bypassing certificate validation.
Cause, upstream sources, and regression guards are in
[Module internals, C2PA](module-internals.md#c2pa). Recheck both positive
certificate acceptance and negative credential, signature, and asset-binding
validation when upgrading the reader; retain every SDK validation status.
Dependabot ignore entries mirror these compatibility bounds. Its failed
2026-09-30 run named the active Python 3.11 interpreter in a generic resolver
hint, but the actual conflicts were the tokenizer and Hub requirements above;
reducing the supported Python range does not fix them.
## CI
`.github/workflows/test.yml` runs Ruff, a test matrix over every supported Python
@@ -70,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.
+6 -3
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).
+163 -24
View File
@@ -451,12 +451,38 @@ them is geometry over time: the real overlay's box is identical on every frame,
texture matches drift, so the Kling policy tightens `anchor_iou` from 0.80 to
0.95.
A video TC260 label names its platform through `_tc260_video_platform`: the
Kling, Doubao, Qwen and Wan registry codes, the Vidu constant, and the bare
`MiniMax`. The labels name the producer organization, because the Tongyi Yunqi
code signs both Wan and HappyHorse. An unregistered producer keeps the generic
"China AIGC-labeled content (TC260 standard)". The image path keeps that generic
platform for every producer and records the manufacturer only for clash checks.
TC260 producer identities live in
[`_internal/tc260_producers.py`](../src/remove_ai_watermarks/_internal/tc260_producers.py).
Each `TC260_PRODUCERS` row records measured bare names/USCCs, the normalized
vendor, image/video mark keys, established image/video platforms, and any pinned
signing identity/key. Whole-code lookup is casefolded and strips surrounding
whitespace; structured `001` producer codes resolve through the same USCC
normalizer, still exported as `metadata.uscc_of`. Substrings cannot identify a
producer. The table imports neither pixel nor ML dependencies.
`KnownMark.tc260_producer_codes`, visible-video confirmation, video platform
labels, and `TC260_SIGNING_KEYS` are derived from these rows. The signature path
still validates the pinned even-y SM2 point. `identify` uses the registered
vendor for provenance-conflict checks, with a verified pinned signer supplying
the vendor even when the text lookup yields nothing. Verification under an
embedded, unpinned key cannot supply this identity. Unknown producers remain
unattributed and cannot relax a particular mark. Image platforms default to the
generic TC260 label; a measured row can supply a more specific label, currently
Honor. The company USCC does not identify a specific app: YOYO exports and
other product suffixes share it. Vendor attribution is also used for conflict checks. Video
platforms preserve the established organization labels, including Tongyi Yunqi
for both Wan and HappyHorse.
`tests/test_tc260_producers.py` covers all registered identities across image,
video, and mark routing, normalizes code spellings, and checks every neighboring
producer against the Doubao, Hailuo, and Vidu confirmation gates.
`tests/test_sm2.py` exercises the real signed label with text lookup disabled,
then removes the pinned key and requires vendor attribution to disappear.
The 2026-09-30 migration comparison covered 411 local images and videos with
identical input hashes: C2PA metadata and complete metadata-only `identify`
reports were unchanged. This comparison excludes pixel detection, removal,
GPU paths, and the video identification pipeline; video routing is covered by
the consumer tests.
Removal runs in a second decode pass. Sora, legacy Veo text, Dola text,
Seedance, Hailuo AI, and Kling AI use box masks. Seedance deliberately fills the
@@ -507,6 +533,59 @@ Regression coverage:
official `c2pa-python` reader first. Its byte-level PNG parser remains a fallback
for partial and synthetic fixtures that the official reader rejects.
Each read owns a `Context` with the six EKUs from the SDK's
[bundled certificate profile](https://github.com/contentauth/c2pa-rs/blob/c2pa-v0.91.0/sdk/src/crypto/cose/valid_eku_oids.cfg).
This sets permitted certificate purposes, not trusted signer identities. It
adds neither trust anchors nor a certificate allowlist and leaves signature,
asset-binding, and certificate verification enabled. The context outlives the
reader and JSON serialization, then both native resources close.
The 2026-09-30 upgrade from c2pa-python 0.37.10 to 0.38.0 exposed a default-profile
regression in Rust SDK 0.91.0:
[`Store::from_context`](https://github.com/contentauth/c2pa-rs/blob/c2pa-v0.91.0/sdk/src/store.rs)
now calls `ctp.clear()`, which clears EKUs as well as trust anchors, while
production `Trust::default()` supplies no replacement `trust_config`.
[SDK 0.90.19](https://github.com/contentauth/c2pa-rs/blob/c2pa-v0.90.19/sdk/src/store.rs)
kept the bundled profile. This produced `signingCredential.invalid` despite
valid signatures and asset hashes on `higgsfield-gpt-image.jpg`, the Bing,
InvisMark, and Paint Microsoft provenance fixtures, and the Microsoft visible
provider original. Their leaf certificates contain the C2PA/document-signing
or Microsoft C2PA-signing EKUs that were cleared. Explicit per-reader settings
restore the previous acceptance policy; the signers remain `untrusted`.
`test_c2pa_signing_ekus_validate_without_trusting_the_signer` exercises all five
through the real reader. The negative EKU check uses a neutral image signed by
the SDK with a local synthetic certificate, fixed validity dates, and no trust
anchors. Replacing permitted EKUs with an unrelated OID must reject that
credential while preserving its signature and asset hash; the AI claim then
loses attribution. Removing the reader context fails the synthetic positive case.
The Bing and Microsoft visible fixtures' leaf certificate expired on
2026-10-01 at 17:43:59 UTC. The SDK checks expiry before permitted EKUs, so an
expired original cannot distinguish the negative profile mutation: it returns
`signingCredential.expired` for both profiles. Keep originals in attribution
checks and use the generated certificate for that mutation. Expiry does not
invalidate a signature made inside validity or prove that its asset changed.
Microsoft's same fixtures also report `claim.malformed` under the newer SDK.
Their soft-binding block stores a GUID as a text string, whereas
[C2PA 2.4, section 18.10](https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#soft_binding_assertion)
requires a CBOR byte string. The newer
[`SoftBindingBlock`](https://github.com/contentauth/c2pa-rs/blob/c2pa-v0.91.0/sdk/src/assertions/soft_binding.rs)
uses bytes instead of the older SDK's `String`. Keep this failure in the
validation codes and retain the raw signed assertion for marker inventory.
A readable algorithm/GUID declaration does not establish schema validity or
prove a pixel watermark. This does not bypass credential failures, and the
asset-binding and signature dimensions remain independent of assertion schema
validation.
The before/after metadata-only comparison for that upgrade preserved origin,
platform, confidence, source kind, and all four validation dimensions across
the retained corpus, with unchanged input hashes. SDK JSON and diagnostic codes
changed: Microsoft gained the malformed-assertion status, other files gained
OCSP-skipped or BMFF-exclusion notices, and some existing hash failures were
reported twice. These diagnostics remain visible; report bytes are not identical.
The comparison did not exercise pixel detection, removal, or GPU inference.
Structured extraction is limited to the active manifest and the ingredient
manifests reachable from it. Validation is preserved as separate dimensions:
asset binding integrity, claim signature, signer trust, and signer certificate
@@ -542,6 +621,12 @@ manifest names only `c2pa-tool` while a validated ingredient names Dreamina, and
Firefly chains that identify `Adobe_Firefly` without repeating a digital source
type. Unreachable manifests remain excluded.
The fallback soft-binding scan checks literal identifier presence before its
ASCII token-boundary regex. This avoids running every registry regex on files
without algorithm names and preserves exact matching, including rejection of
prefixes and version suffixes. Tests pin boundaries for every generated registry
entry and assert that an identifier-free buffer runs no regex searches.
Reachable `c2pa.soft-binding*` assertions retain their exact `alg` and bounded,
printable block `value` in addition to the normalized vendor label. A block value
without its algorithm is not surfaced because it cannot be attributed to a
@@ -650,6 +735,32 @@ Key contracts:
[`_internal/isobmff.py`](../src/remove_ai_watermarks/_internal/isobmff.py).
- Native MP4/MOV TC260 `AIGC` entries are read from
`moov.udta.meta.keys/ilst` and blanked without changing box sizes.
- TC260 `SecurityData` signatures are checked by
[`_internal/tc260_signature.py`](../src/remove_ai_watermarks/_internal/tc260_signature.py)
on top of a verification-only SM3/SM2 in
[`_internal/sm2.py`](../src/remove_ai_watermarks/_internal/sm2.py), because no
dependency provides the SM2 curve. The label message is the guide's appendix
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. 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
key that verifies proves integrity only and adds no signal. Both reserved
fields preserve nested objects as JSON. Honor YOYO uses `PubSd`, RSA/SHA-256
and `TBSData.Type=Content` with a null binding hash and no content selection;
this form is recognized and reported as unsupported, not absent or verified.
Only the producer slot is checked; Honor's repeated propagator slot adds no
independent verification. Verifications
are memoized, since `get_ai_metadata` and the verdict check the same label.
`tests/test_sm2.py` pins the GB/T 32905 SM3 vectors, OpenSSL's SM2 verify
vectors, and the real MiniMax signatures. Over 411 local images and videos the
only verdict-record change was the added marker on the nine MiniMax videos.
- Native MP4/MOV `workflow` and `prompt` entries use that keyed metadata-list
reader and the same offset-preserving removal path.
- Native MKV/WebM TC260 `AIGC` entries are read from
@@ -737,6 +848,12 @@ metadata extraction from verdict logic:
reported no SynthID for images the file path flagged.
- `identify` preserves the path-based API and adds the optional registered
visible-mark and open invisible-watermark detectors after extraction.
- The Google Photos SynthID caveat distinguishes the measured cohort from a
pixel verdict on the current file. Four earlier edits were oracle-positive
(2026-09-25); a large eraser and a restyle were positive while two small edits
were indeterminate (2026-10-01 UTC). Their C2PA shape does not distinguish
these outcomes. See [the Photos measurements](synthid.md) for the inference's
limits; the CLI does not call the provider verifier.
- When the pixel stack is absent, the visible arm still no-ops (that is the
historical `get_or_none` contract), but the report now carries a caveat saying
the detectors did NOT run. Without it, an install lacking the `visible` extra
@@ -1473,19 +1590,21 @@ regime: Alibaba, ByteDance, Kuaishou, Tencent, and other companies all use TC260
but their marks do not belong to one manufacturer family.
`identify._VISIBLE_MARK_PLATFORM` and the signal mapping in
`api.visible_provenance` are derived from those rows rather than hand-maintained
beside them, so registering a mark is one edit. Two marks carry no platform of
beside them. A TC260 mark also needs its producer row in `TC260_PRODUCERS`;
registry-coverage tests reject missing mappings. Two marks carry no platform of
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.
A TC260 label relaxes the vendor its `ContentProducer` names, resolved through
`KnownMark.tc260_producer_codes`. The label itself is vendor-agnostic, so this used to
the shared producer table, which also supplies `KnownMark.tc260_producer_codes`.
The label itself is vendor-agnostic, so this used to
relax ByteDance's two products on every China-AIGC image -- which both risked a
false fill on an image carrying some other vendor's mark and denied that vendor's
own mark the relaxed gate its `provenance_ncc_factor` was calibrated for. An
@@ -1537,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
@@ -1578,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:
@@ -1587,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)
@@ -1631,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
@@ -1642,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
@@ -1679,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,
@@ -1690,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
@@ -1817,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
@@ -1888,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.
+16 -5
View File
@@ -266,11 +266,22 @@ API slot must name its account or project and cannot name a browser profile. The
tool records the network route that the operator has already established.
Non-Google Web slots may name `proxy_url_env`; Playwright reads the secret proxy
URL from that environment variable without writing or logging it. The tool does
not rotate accounts or networks, retry through another IP after throttling, or
merge sessions. Record a throttled response as `refused`; choosing another
authorized slot is a separate operator decision and a separate batch. This
preserves per-account and per-IP history without turning provider limits into an
automatic evasion mechanism.
not merge sessions or automatically change network routes. Account continuation
is orchestrated by the skill, not by an individual adapter call. A request to
verify a bounded set of files with a provider covers its configured account slots
unless the operator restricts the account scope. Identify the finite set of
available authenticated slots before starting and prepare one immutable batch
per slot. Record quota refusals as `refused` and service failures according to
their actual response, leave remaining rows in that slot unsubmitted, and
continue automatically with the next authorized slot. Do not request permission
again for files or accounts already covered by the task.
Submit each frozen artifact at most once per slot in that pass. Keep every
verbatim refusal, inconclusive reply, and disagreement between accounts. Stop
when the planned checks finish or no authorized usable slot remains, and report
unsubmitted rows. A new login, another provider, a new network route, or a new
pass after the bounded plan is exhausted needs authorization unless already
requested. Candidate pixels remain frozen throughout account continuation.
Each `openai-api` slot must name exactly one `api_key_env`. Put the corresponding
values in the gitignored local `.env`, never in the slot JSON:
+200 -29
View File
@@ -25,7 +25,7 @@ This page is an index. Each finding lives in its canonical page.
| Video labels | Sora discontinued; opening-only labels no longer filled over the whole clip; any video mark vetoed by another vendor's C2PA AI claim, Kling also by a non-Kling TC260 producer; TC260 video producers named | [supported-signals.md](supported-signals.md), [known-limitations.md](known-limitations.md) |
| Apple | Image Playground and Photos Clean Up credits attributed | [supported-signals.md](supported-signals.md) |
| New visible marks | `vidu` video mark and `wan` image mark, each calibrated on one real export | [supported-signals.md](supported-signals.md), [known-limitations.md](known-limitations.md) |
| Google Photos | AI edits reported as "Google Photos (AI edit)" rather than Gemini, with SynthID present: Google's checker found it in 4 of 4 Photos AI edits (Ask, eraser and two others) | [synthid.md](synthid.md), [supported-signals.md](supported-signals.md) |
| Google Photos | AI edits attributed to "Google Photos (AI edit)". Four earlier edits and two later large edits were SynthID-positive; two later small edits were indeterminate. Presence inferred from provenance needs file-specific confirmation | [synthid.md](synthid.md), [supported-signals.md](supported-signals.md) |
| YouTube | Upload test: C2PA-driven "Made with AI" label, public streams without C2PA, Studio downloads re-signed by YouTube; re-encoded non-Google video no longer read as Google AI with SynthID | [supported-signals.md](supported-signals.md), [legal-and-safety.md](legal-and-safety.md), [module-internals.md](module-internals.md) |
| Audio | Generated audio from OpenAI, ElevenLabs and Microsoft passes through the video path unchanged | [known-limitations.md](known-limitations.md) |
| Attack literature | Fidelity band on open encoders now 30-45 dB; WmForger and WMCopier status | [synthid-removal-research.md](synthid-removal-research.md), [watermark-forgery-study.md](watermark-forgery-study.md) |
@@ -33,29 +33,64 @@ 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
`SecurityData` JSON object, a digital signature over the label and optionally
the content, in `ReservedCode1` and `ReservedCode2`. The library accepts those
fields as label evidence but does not verify the signature.
- **Experiments.** Chroma restoration after regeneration (arXiv:2508.21072) was
the content, in `ReservedCode1` and `ReservedCode2`. MiniMax was the one
producer in the local corpus writing a guide-shaped object when checked
on 2026-09-29 (Honor RSA/content samples were added in the public-web
follow-up below): four MiniMax videos served by Higgsfield and Runway, two of them
tracked (`higgsfield-hailuo-2-3.mp4`, `higgsfield-minimax-h3.mp4`), carry
`SecurityData` with two SM3withSM2 (`1.2.156.10197.1.501`) signatures and one
public key, `KeyValue` `00a0b3b0...47e4fd` in all four. The label signature
verifies on all four against the guide's appendix A.1 message, the ASCII string
`"Label":"1","ContentProducer":"MiniMax","ProduceID":"<id>"`, with the default
SM2 user ID `1234567812345678` and the key's x coordinate lifted to the even-y
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. 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. 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
@@ -107,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`,
@@ -129,22 +172,150 @@ 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
watermark can be switched off; Honor, OPPO and Xiaomi label behavior is not
documented in any primary source found. vivo's Album setting is already
covered.
- **Claims not supported by a primary source.** Kling embedding SynthID
- **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
conformance-list recheck on 2026-09-30 also found `Xiaomi MediaEditor` as a
generator product, `Xiaomi Gallery` as a validator, and both generator and
validator entries for `vivo Albums` / `JOVI Albums`. These roles describe C2PA
production and validation, not whether an edit is generative. Original exports
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.
- **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 (deferred).** The "SynthID present" rule for
Photos AI edits rests on four checked edits, none of them tiny. Still to
measure on the phone app: a tiny eraser edit, a large one, a small "Help me
edit" change, a restyle, and a crop-only control, each checked in Gemini. iPhone
Mirroring does not show the Photos editing toolbar, and the web editor has no
generative tool, so this needs edits made on the device.
- **Captures blocked by region or device.** Doubao, Jimeng, Samsung. Higgsfield,
Runway and Dreamina were captured on paid plans on 2026-09-24.
- **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
2026-09-30, iPhone Mirroring successfully exposed the iOS
editing toolbar. One source and five separate copies were captured: a large
Magic eraser edit, a small removal through `Help me edit`, a small color
change through the same tool, a watercolor restyle, and a crop-only control.
The four AI exports carry Google Photos C2PA with valid signatures and asset
bindings; the signer remains untrusted without a configured trust-anchor list.
Neither the unchanged source nor the crop control has detected AI metadata.
Original-quality downloads, hashes, operation records, and metadata reports
are retained privately outside the repository. The restyle was exported at
768 x 1365; the other AI copies retain the source's displayed dimensions.
The hash-bound Gemini batch passed complete verification on 2026-10-01 UTC:
large Magic Eraser and watercolor restyle were `detected`; small removal and
recoloring through `Help me edit` were `indeterminate`, with a possibly
too-small edit cited; source and crop were `not_detected`. AI metadata was
stripped without changing decoded pixels. All six settled replies were
recorded, one file per new chat. No quota refusal occurred and no request was
repeated. The Photos-specific report caveat now names the inconclusive small
edits rather than describing every tested AI edit as positive. A tiny photographic
Magic Eraser mask remains unmeasured: its automatic selection expanded to a
large region, so the small removal used `Help me edit` instead. One source and
one export per operation cannot establish representative or repeatable behavior.
Follow-up checks across two additional accounts left recoloring and the
synthetic tiny Magic Eraser control inconclusive; small removal returned one
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).
- **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
The follow-up searched official product/model pages, support articles,
provider communities, public share links, and public sample repositories.
Candidates were downloaded unchanged to a gitignored local evidence directory,
with source URLs, referring pages, SHA-256 hashes, metadata inspection, and
contact sheets. Website assets include icons, UI screenshots, source images,
outputs, and duplicate size variants; their count is not a count of genuine
provider exports. No third-party photograph was added to tracked fixtures.
- **Honor YOYO: metadata sample found.** Two JPEGs in this
[author's edit thread](https://club.honor.com/cn/thread-29898091-1-1.html)
retain EXIF `UserComment` containing `AIGC`, producer
`001191440300MA5G49LC9K1YO01`, and nested `TC260PG` blocks with RSA/SHA-256
OID `1.2.840.113549.1.1.11`. The author's post reports a Magic6 Pro;
OS/app versions are unknown. The downloaded files are 1312 x 736 and smaller
than the attachment sizes displayed by the forum, so original export fidelity
is unproven. Their visible overlay includes a community account attribution,
which is not evidence of an AI disclosure badge. The reader now preserves
nested reserved fields as JSON and attributes the measured code to Honor.
It reports the RSA/content signature as unsupported.
Public-key exponentiation on both signatures yields PKCS#1 type-1 padding
followed by a bare 32-byte value, without the SHA-256 DigestInfo prefix;
that value differs from the downloaded file hash. Together with null
`Bindings.Value`, `TBSData.Type=Content`, no content-selection method, and
forum resizing, this leaves asset integrity unverified. An embedded public
key alone would not establish manufacturer identity either.
Honor's [official privacy statement](https://agreement.itsec.honor.com/asm/agrFile/getHtmlFile?agrNo=1477&branchId=0&country=ru&langCode=en-US&version=20250517)
independently confirms the producer's USCC. Historical replay also found
that company code with a different product suffix; attribution names Honor,
because the USCC alone does not establish the application. A publication-safe
structural fixture and lossless metadata-removal test are documented in
[the fixture catalog](../data/fixtures/README.md#honor-yoyo-metadata-reconstruction).
- **vivo and Huawei: UI/source evidence, not original gallery exports.**
[vivo technical support](https://bbs.vivo.com.cn/newbbs/thread/39038829)
enumerates image/video AI operations that add the label, while a
[hands-on guide](https://bbs.vivo.com.cn/newbbs/thread/39038546?show_title=1)
locates `AI生成` at the upper right. Downloaded community illustrations show
settings and editing UI, not a verified signed Albums export.
[Huawei's Xiaoyi support article](https://consumer.huawei.com/cn/support/content/zh-cn16070847/)
supplies editing/save UI illustrations; those do not establish exported metadata.
- **OPPO: primary labeling statement found.** Its
[imaging product manager interview](https://www.oppo.com/en/newsroom/stories/empowering-creative-freedom-and-expression-in-mobile-photography-with-ai/)
states that AI-generated content is labeled. It does not specify the export
label's shape or metadata. Its downloadable press kit supplies before/after
JPEG pairs for Clarity Enhancer, Reflection Remover, and Unblur, with several
3072 x 4096 images. These fetched pairs had no recognized C2PA/AIGC label;
they are useful local comparison material but not verified generative-edit
exports. Camera AI enhancement and ordinary camera-brand
watermarks remain separate from generative-edit provenance.
- **Jasper, Getty/iStock, RefaceAI: C2PA gaps remain.** Downloaded assets from
[Jasper's endpoint examples](https://images.jasper.ai/cleanup),
[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. 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 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)
explicitly attributes its example generation to Liblib; its read-only model
lookup supplied nine full PNG examples. These show ink-style illustrations
and a model cover, without the historical LiblibAI wordmark.
A public Vidu homepage MP4 was obtainable, but the inspected frame has no Vidu
mark. Seedance and Wan marketing assets, Dola extension screenshots, and
the Doubao share page did not initially yield marked consumer videos. A
subsequent public `samantha/media/get_play_info` lookup returned a 720 x 960,
10.08-second H.264/AAC MP4 (SHA-256
`0a000352e6f9b3e8417e57403572ef4a7a0e94fc8840217162e90c199a478def`).
Its decoded frame visibly carries `豆包AI生成` at bottom right; the file
remains local because publication has not been cleared. `identify_video`
attributes it to ByteDance Doubao and detects the mark on all 241 frames;
its MP4 TC260 producer is `001191110102MACQD9K64010000`.
Samsung's support illustrations are UI evidence, not a controlled original
export set. No new visible template or proprietary-watermark verdict follows
from these candidates. Xiaomi labeling tutorials surfaced, but their attribution
and original exports remain unverified.
Next acquisition targets remain a signed export per missing C2PA signer, an
unedited/crop/generative-edit set per gallery, and original marked consumer
videos. Honor's nested RSA signature form is a separate verification gap; the
synthetic fixture deliberately does not pretend to close it.
## Decisions
+23 -9
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). |
@@ -102,6 +104,14 @@ The inspection and stripping code handles signals in these groups:
`script.onMetaData.AIGC` placements, plus the audio placements of
TC260-PG-202510A: a RIFF `AIGC` chunk in WAV, an ID3v2 `TXXX` frame described
`AIGC` in MP3, and an `AIGC=` Vorbis comment in OGG, Opus and FLAC;
- TC260-PG-202511A `SecurityData` label signatures (SM3withSM2) in a label's
`ReservedCode1`: a signature that verifies against a pinned producer key is a
high-confidence `aigc_signature` signal (MiniMax is the one pinned signer), a
signature that does not verify is a caveat, and every result appears as the
`aigc_signature` metadata marker. Nested JSON reserved fields are preserved;
Honor YOYO RSA/content signatures are reported as unsupported, with no
integrity or signer-trust claim. Its measured producer code attributes images
to Honor;
- xAI and Grok signature-plus-UUID pairs in ordinary EXIF, ImageMagick PNG raw
EXIF profiles, XMP description/creator fields, PNG Description/Author text,
and IPTC Caption-Abstract/By-line fields;
@@ -193,8 +203,11 @@ its chain, as a Gemini ingredient carries; with no AI claim in the chain,
"Google Photos (AI edit)" with SynthID present, although the Photos manifest
records no SynthID action and Google documents the mark only for Reimagine:
Google's checker found SynthID in all four Photos AI edits tested on 2026-09-25
(Ask, eraser and two other edits), and a caveat says the claim rests on that
measurement. Legacy OpenAI C2PA without that action
(Ask, eraser and two other edits). On 2026-10-01 UTC, a large Magic Eraser edit
and a watercolor restyle were positive, but two small `Help me edit` changes
were indeterminate; the source and crop control were negative. A caveat keeps
this provenance inference separate from a confirmed pixel watermark on the
particular file. Legacy OpenAI C2PA without that action
does not assert SynthID. GPT Image 2.5 Flare and Sunburst were verified on
2026-09-15: both API outputs carried valid OpenAI C2PA with
`c2pa.watermarked.unbound` and exercised the existing model-independent OpenAI
@@ -296,8 +309,9 @@ not a universal clean verdict.
| Baidu | Baidu mark | No registered pixel decoder | TC260 AIGC |
| LiblibAI | LiblibAI wordmark and compact pill | No registered pixel decoder | TC260 AIGC |
| RunningHub | RunningHub mark | No registered pixel decoder | TC260 AIGC |
| Honor YOYO AI editing | No registered AI mark; forum account overlay is separate | No registered pixel decoder | EXIF UserComment TC260 AIGC with nested RSA/content signature, reported unsupported |
| Samsung Galaxy AI | One locale specific mark | No registered pixel decoder | C2PA and Samsung markers |
| Google Photos AI edits | None | SynthID, reported present: Google's checker found it in 4 of 4 Photos AI edits (2026-09-25), though the manifest records no SynthID action | C2PA signed "Google Photos" with a `compositeWithTrainedAlgorithmicMedia` action, plus XMP `photoshop:Credit` `Edited with Google AI` |
| Google Photos AI edits | None | SynthID provenance inference: 4/4 earlier edits positive (2026-09-25); large eraser and restyle positive, two small edits indeterminate (2026-10-01 UTC). No SynthID action in the manifest | C2PA signed "Google Photos" with a `compositeWithTrainedAlgorithmicMedia` action, plus XMP `photoshop:Credit` `Edited with Google AI` |
| Apple Image Playground and Photos Clean Up | None | No registered pixel decoder; SynthID announced by Apple for a later update | XMP `photoshop:Credit` (`Apple Image Playground`, `Apple Photos Generative Edit: Clean Up`) plus IPTC digitalSourceType; no C2PA on measured 2026-09 output |
For detector thresholds, measured limits, and incident history, see
+44 -1
View File
@@ -666,6 +666,48 @@ with their metadata stripped, found SynthID in all four on 2026-09-25: an Ask
edit, an eraser edit and two other AI edits. The tool therefore reports SynthID
as present for a Photos-signed AI edit and adds a caveat that the claim rests on
that measurement, since Google notes a very small edit may not carry the mark.
An independent six-file check on 2026-10-01 UTC used one landscape source and
five iOS copies captured on 2026-09-30. The prepared uploads retained identical
decoded pixels and stripped AI provenance metadata. Large Magic Eraser and
watercolor restyle edits were positive; small object removal and recoloring
through `Help me edit` were indeterminate, with the checker noting a possibly
too-small edit. The unchanged source and square crop control were negative.
The four AI exports all carried Google Photos C2PA before stripping, so that
manifest shape does not distinguish a detectable watermark from an inconclusive
small edit. Indeterminate is not evidence of absence. Each operation has one
export; a genuinely small photographic Magic Eraser mask remains untested.
A later same-day repeat of the small-removal bytes was explicitly quota-refused before any new
watermark verdict. The recoloring repeat was left unsubmitted after that refusal,
so neither original indeterminate outcome changed. A follow-up iOS capture
attempt again expanded the small-object selection to the foreground, including
in `Refine selection`; it was cancelled without saving a new export.
A separate synthetic control then isolated a small red disk on a 2048 x 2048
gray image. The real iOS Magic Eraser selected the disk and saved a separate
copy with valid C2PA signature and asset binding. Against the downloaded Photos
source, 7,399 pixels (0.1764%) differed by more than three RGB channel levels,
inside a 98 x 98 bounding box. Lower-level differences extended across the
image, so this measures visible change, not the internal mask. The Photos source
download also differed from the locally generated JPEG; the downloaded source
is the comparison baseline. After the operator authorized multiple existing
accounts, two additional authenticated sessions checked the same four frozen
uploads: small photographic removal, recoloring, synthetic source, and tiny
Magic Eraser export. Both four-row batches passed complete hash verification;
neither session quota-refused. The first returned `not_detected` for small
removal and the synthetic source, and `indeterminate` for recoloring and the
tiny export. The second returned `indeterminate` for all four. Both tiny-export
replies cited insufficient image detail; the second also gave that reason for
the synthetic source. A flat control therefore cannot isolate the effect of
edit size on verification. Small photographic removal now has one negative and
two inconclusive observations, including the original check; recoloring has
three inconclusive observations. Preserve the earlier quota refusal separately.
The additional sessions used their existing Flash and Pro modes respectively,
so the discrepancy is not attributable to account identity alone. These
bounded checks establish observed outcomes, not repeatability or watermark
absence. Further photographic tiny Magic Eraser captures and verification
retries for these inconclusive small edits were deferred by the maintainer on
2026-10-01. Resume only on an explicit request; the observations remain valid
as recorded, with their uncertainty unresolved.
Private files and hash-bound oracle records remain outside the public repository.
The Google Photos web editor is outside that rule: it has no generative tool and
writes no Content Credentials, and Enhance, Sky and crop edits made there carried
no SynthID in the same checker (2026-09-26,
@@ -692,7 +734,8 @@ statements to "every Photos AI edit" is this project's inference, not Google's:
"Edited with AI tools" appears on edits "with generative AI like Magic Eraser
where new pixels are created", and an original without Content Credentials gets
them "when an AI edit is saved". No Google page states whether Magic Eraser
applies SynthID; the eraser capture above is the only evidence here.
applies SynthID; the earlier eraser capture and the large eraser check above
are the measured evidence here.
- The checker's own caveat ([Gemini Apps Help](https://support.google.com/gemini/answer/16722517)):
"Likely too small an edit" (minor alterations might not carry a detectable
watermark).
+10 -1
View File
@@ -18,6 +18,13 @@ Italian `✦ Contenuti generati dall'AI` bottom-left variant. Removal follows th
same localize-then-fill path as other registered text marks. Other locales and
icon-only variants need separate calibrated silhouettes.
**Honor YOYO AI editing** retains an EXIF `UserComment` TC260 label in two
public community JPEGs measured on 2026-10-01 UTC. The producer code now
attributes these images to Honor. Nested RSA/content signatures are recognized
but unsupported; forum resizing and unspecified content selection prevent an
asset-integrity verdict. Details and acquisition limits are in
[the public-web search](research-sweep-2026-09.md#public-web-sample-search-2026-10-01-utc).
**ASUS Gallery** also signs edited photos as C2PA (`com.asus.gallery`) but with no AI source type — a signer, not an AI marker.
**Black Forest Labs (FLUX)** API output signs C2PA: `claim_generator_info "Black Forest Labs API"` + a `c2pa.ai_generated_content` assertion + `trainedAlgorithmicMedia` (issuer `b"Black Forest Labs"` added to `C2PA_ISSUERS`, platform "Black Forest Labs (FLUX)").
@@ -66,7 +73,7 @@ raw EXIF profile; Pillow exposes that form through `getexif()` even when
`info["exif"]` is absent, so detection, removal, and portable records share that
fallback. On the ISOBMFF path, `blank_ai_exif_tokens` provides the corresponding
in-place scrub for supported EXIF values, TC260 AIGC blocks, and the xAI pair.
- **China TC260 AIGC label (caught by `AIGC_MARKERS` / `metadata.aigc_label`, surfaced by `identify` as the `aigc` signal):** China-served generators embed an XMP `<TC260:AIGC>{"Label":"1","ContentProducer":...}` block — China's mandatory AI-content labeling (TC260 namespace `tc260.org.cn/ns/AIGC`). The label says only "this is AI", but its `ContentProducer` names the signing entity — `001` + `1` + an 18-char Unified Social Credit Code + a 5-digit product suffix, normalized by `metadata.uscc_of`, or for a few generators a bare product name. `KnownMark.tc260_producer_codes` maps the codes settled per vendor by `scripts/vendor_cohort_harvest.py` to registry mark keys, so an AIGC image relaxes only the detector of the product it actually carries; an unmapped or absent producer leaves every product strict. The same mapping supplies the manufacturer for provenance-conflict checks. A code identifies a legal entity, not necessarily one brand, so a hosting or aggregating platform that signs for several apps is a recall bet rather than a proof.
- **China TC260 AIGC label (caught by `AIGC_MARKERS` / `metadata.aigc_label`, surfaced by `identify` as the `aigc` signal):** China-served generators embed an XMP `<TC260:AIGC>{"Label":"1","ContentProducer":...}` block — China's mandatory AI-content labeling (TC260 namespace `tc260.org.cn/ns/AIGC`). The label says only "this is AI", but its `ContentProducer` names the signing entity — `001` + `1` + an 18-char Unified Social Credit Code + a 5-digit product suffix, normalized by `metadata.uscc_of`, or for a few generators a bare product name. The shared `TC260_PRODUCERS` table maps the codes settled per vendor by `scripts/vendor_cohort_harvest.py` to registry mark keys and supplies `KnownMark.tc260_producer_codes`, so an AIGC image relaxes only the detector of the product it actually carries; an unmapped or absent producer leaves every product strict. The same mapping supplies the manufacturer for provenance-conflict checks, including the identity of a verified pinned TC260 signer; a signature under an unpinned embedded key cannot supply that identity. A code identifies a legal entity, not necessarily one brand, so a hosting or aggregating platform that signs for several apps is a recall bet rather than a proof.
**Doubao** (ByteDance) uses it (verified on a public issue sample; `ContentProducer` `001191110102MACQD9K64010000`, no C2PA/SynthID/imwatermark — the XMP block is the only signal; GitHub attachment upload did NOT strip it). The same standard is mandatory for Jimeng/Kling/Qwen/Ernie etc., so the one marker covers the whole China-AIGC-labeled ecosystem. `aigc_label` reads **four image serializations** through a shared `_parse` helper: the HTML-entity-encoded XMP `TC260:AIGC` block in **either RDF form** — the nested element `<TC260:AIGC>{...}</TC260:AIGC>` (Doubao) or the attribute `TC260:AIGC="{...}"` (**PicWish**, `ContentProducer="picwish"`, verified on compatible samples) — via a container-agnostic raw-byte scan (any JSON object accepted), a raw-JSON PNG `AIGC` tEXt chunk (Doubao also writes the label this way, no namespaced marker at all — confirmed on compatible samples, `ContentProducer="doubao"`), a bare raw-JSON `{"AIGC":{...}}` object embedded in **JPEG EXIF (UserComment)** by some China-served generators, brace-matched from the scan head with `json.JSONDecoder().raw_decode` (no namespaced marker, no PNG chunk — confirmed on compatible samples, `ContentProducer="001191440300708461136T1308L"`), **and** a bare `AIGC{...}` blob (the label glued straight to its JSON, no `"AIGC":` key wrapper) embedded in a **JPEG APP segment near the JFIF header** — confirmed on compatible samples. The two raw-JSON forms are scanned in one loop (`'"AIGC"'` then `AIGC{`) that **falls through on a non-TC260 / undecodable hit instead of returning** — a quoted `"AIGC"` can appear later in an XMP packet while the real label is a bare `AIGC{...}` earlier in the file, so an unconditional early return on the quoted form would shadow the bare form (the exact bug behind the 06-10 misses). Native MP4/MOV is a fifth serialization: TC260-PG-20257A stores an `AIGC` key in `moov.udta.meta.keys` and the raw JSON in the matching `ilst` item; Doubao's iOS export additionally writes two QuickTime-form variants (a bare `meta` box as a direct `moov` child, and a keyless `hdlr=mdir` metadata list whose `ilst` data items carry the JSON with no key name) — both verified on retained carriers (2026-08-17). The seeking parser reaches a tail `moov` without reading `mdat`; removal replaces the key with `free` and blanks the validated value at the same length so every box size and stream offset stays fixed. All generic forms are gated on at least one TC260 field (`TC260_AIGC_FIELDS`) so a generic `AIGC` key cannot false-positive; the namespaced XMP element is unambiguous and needs no gate. `TC260_AIGC_FIELDS` covers **two schemas**: the producer-side one (`Label` / `ContentProducer` / `ProduceID` / `ContentPropagator` / `PropagateID`, Doubao and most China gens) and the **service-provider** one (`ServiceProvider` / `ServiceUser`, plus generic `Time` / `ContentId` which are NOT gated on) — **Tencent Cloud's** AIGC variant (`ServiceProvider` = `腾讯云`), embedded in **EXIF `ImageDescription`**, verified on compatible samples. In `identify`, `aigc` fires on the parsed label **or** the `AIGC_MARKERS` byte scan (the latter preserves the laundering-tell case where the JSON payload is truncated).
@@ -270,6 +277,8 @@ models used are paid-tier only.
| YouTube, unlisted upload of the xAI Grok video | No AI label seen | Studio download re-signed the same way; the Grok manifest is self-signed and dropped by the reader | Was Google AI with SynthID; now unknown (committed fixture) |
| Google Photos edit on iOS (object removal) | None | C2PA signed `Google Photos` (Google C2PA SDK for iOS), actions `c2pa.opened` and `c2pa.deleted` typed `compositeWithTrainedAlgorithmicMedia`, no SynthID action; XMP `photoshop:Credit` `Edited with Google AI` | Was reported as Gemini with SynthID present; now "Google Photos (AI edit)" with SynthID present. this file was not checked, but Google's checker found SynthID in four other Photos AI edits (Ask, eraser and two others, 2026-09-25) |
| Google Photos on iOS: Ask (dog replaced by a cat), eraser and two other AI edits, 2026-09-24 and 2026-09-25 (`data/captures/2026-09/google-photos/`) | None | Same manifest shape, no action description and no SynthID action; Magic Editor, and so Reimagine, is not offered on iOS | "Google Photos (AI edit)" with SynthID present; Google's checker in Gemini found SynthID in all four with the metadata stripped |
| Google Photos on iOS: one source, large eraser, small Help me edit removal and recoloring, watercolor restyle, crop; captured 2026-09-30, checked 2026-10-01 UTC (private evidence) | None | All four AI exports carry Google Photos C2PA; source and crop have no AI metadata. Oracle uploads stripped AI metadata and preserved decoded pixels | Gemini: large eraser and restyle `detected`, both small edits `indeterminate` with a possibly too-small edit cited, source and crop `not_detected`. One export per operation; photographic tiny Magic Eraser mask remains untested. A later retry was quota-refused. Two additional accounts then returned `not_detected`/`indeterminate` for small removal and `indeterminate`/`indeterminate` for recoloring on identical upload bytes; existing Flash/Pro modes differ |
| Google Photos on iOS: synthetic 2048 x 2048 red disk on gray, small Magic Eraser, captured 2026-10-01 | None | Real edit export has valid C2PA signature and asset binding; signer trust unverified. Downloaded-source RGB comparison above three channel levels changes 0.1764% within 98 x 98; lower-level differences extend across the image | Two additional accounts: tiny export `indeterminate` in both, citing insufficient detail; source `not_detected`/`indeterminate`. Both immutable four-file batches complete and hash-verified. Source download differs from local JPEG; flat-image ambiguity prevents isolating edit size, and this is not a photographic replication |
| Apple Photos Clean Up | None | XMP `photoshop:Credit` `Apple Photos Generative Edit: Clean Up` plus `compositeWithTrainedAlgorithmicMedia`; no C2PA | Attributed to Apple Photos Clean Up |
| Runway Gen-4 image and Gen-4.5 video (Standard plan, 2026-09-24) | None on either | C2PA signed `RUNWAY AI, INC.`, `c2pa.created` from `trainedAlgorithmicMedia` with software agent "Runway Image Generation" 4.0.0 or "Runway Video Generation" 4.5.0 | Was AI with an unknown signer and no platform; now Runway (committed fixtures) |
| Nano Banana 2 run inside Runway | None | The original Google manifest with its SynthID action; Runway does not re-sign third-party models | Google with SynthID present, correct |
+15 -12
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.43.0"
version = "0.45.0"
description = "AI watermark remover for visible, invisible, and provenance marks in images and video"
readme = "README.md"
requires-python = ">=3.11,<3.15"
@@ -59,7 +59,7 @@ dependencies = [
# synthetic/partial blobs the validator rejects. Binary wheel (Rust), but the
# import is light (no torch/numpy) so it fits the dependency-light identify
# host. Prebuilt wheels cover the full CI matrix (linux/macos/windows).
"c2pa-python>=0.35.0",
"c2pa-python>=0.38.0",
]
[project.optional-dependencies]
@@ -104,9 +104,11 @@ diffusion = [
# 5.x but the tokenizers *release candidate* (0.23.0rc0) that the global
# ``prerelease = "allow"`` drags in: its CLIP tokenizer raises
# ``RobertaProcessing.__new__() got an unexpected keyword argument 'cls'``.
# Cap tokenizers to the stable 0.22 line (transformers 5.x accepts
# >=0.22,<=0.23.0) so the rc is excluded while SDXL still loads.
"transformers>=5,<6",
# Cap tokenizers to the verified stable 0.22 line so the rc is excluded
# while SDXL still loads.
# Transformers 5.16+ requires tokenizers 0.23.1+, outside the verified
# stable tokenizer line below. Keep the published extras jointly resolvable.
"transformers>=5,<5.16",
"tokenizers>=0.22,<0.23",
"accelerate>=0.25.0",
"safetensors",
@@ -131,10 +133,11 @@ text-restoration = [
# Draft-only OCR proposals for verified-text manifests. CPU, no torch:
# PaddleOCR detection plus three script-chosen recognition engines. The models
# download on first use and are cached by huggingface_hub; never bundled.
# Keep every Hub extra below 2 so it remains composable with Diffusers (<2).
text-draft = [
"paddleocr>=3.3.3",
"paddlepaddle>=3.0.0",
"huggingface-hub>=0.20.0",
"huggingface-hub>=0.20.0,<2",
]
# Adobe TrustMark decoder -- the open, keyless watermark behind Adobe Durable
# Content Credentials (soft-binding alg ``com.adobe.trustmark.P``). Optional
@@ -150,9 +153,9 @@ trustmark = [
classify = [
"remove-ai-watermarks[pixels]",
"torch>=2.0.0",
"transformers>=5,<6",
"transformers>=5,<5.16",
"tokenizers>=0.22,<0.23",
"huggingface-hub>=0.20.0",
"huggingface-hub>=0.20.0,<2",
]
# Optional vision-only CPU runtime for the photo classifier. Keep it separate
# because PyTorch remains the default and ONNX Runtime is needed only on opt-in.
@@ -165,7 +168,7 @@ classify-onnx = [
# SynthID detector. Weights download on first use.
source-classify = [
"remove-ai-watermarks[pixels]",
"huggingface-hub>=0.20.0",
"huggingface-hub>=0.20.0,<2",
]
# Universal region eraser backend -- big-LaMa via onnxruntime (Carve/LaMa-ONNX,
# Apache-2.0). CPU, no torch. Model (~200 MB) is downloaded on first use and
@@ -174,7 +177,7 @@ source-classify = [
lama = [
"remove-ai-watermarks[visible]",
"onnxruntime>=1.24.0",
"huggingface-hub>=0.20.0",
"huggingface-hub>=0.20.0,<2",
]
# Lightweight inpaint backend -- MI-GAN via onnxruntime (andraniksargsyan/migan,
# MIT). CPU, no torch. Model (~28 MB) downloaded on first use and cached by
@@ -184,7 +187,7 @@ lama = [
migan = [
"remove-ai-watermarks[visible]",
"onnxruntime>=1.24.0",
"huggingface-hub>=0.20.0",
"huggingface-hub>=0.20.0,<2",
]
dev = [
"remove-ai-watermarks[video]",
@@ -196,7 +199,7 @@ dev = [
# scripts/audioseal_experiment.py provenance study. Not part of the
# installed CLI, the public Python API, or any runtime pipeline.
"audioseal>=0.2.0",
"huggingface-hub>=0.20.0",
"huggingface-hub>=0.20.0,<2",
# Direct Microsoft Content Provenance API transport. The adapter uses an
# Azure CLI Entra token and ignores process proxy settings.
"httpx>=0.28,<1",
@@ -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.43.0"
__version__ = "0.45.0"
__all__ = [
"BatchItemResult",
@@ -1,7 +1,7 @@
"""Generated C2PA soft-binding registry; do not edit by hand.
Source: https://raw.githubusercontent.com/c2pa-org/softbinding-algorithm-list/main/softbinding-algorithm-list.json
Revision: 1b3ef637890ea8228bca13d8941cfd54da941867
Revision: d36d1c1742b1753ca064bb3307abfd95db912a28
Source license: CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/)
Changes: validated, ordered, and reduced to fields used at runtime; descriptions
are shortened to their first sentence for display.
@@ -12,7 +12,7 @@ from __future__ import annotations
C2PA_SOFT_BINDING_SOURCE_URL = (
"https://raw.githubusercontent.com/c2pa-org/softbinding-algorithm-list/main/softbinding-algorithm-list.json"
)
C2PA_SOFT_BINDING_SOURCE_REVISION = "1b3ef637890ea8228bca13d8941cfd54da941867"
C2PA_SOFT_BINDING_SOURCE_REVISION = "d36d1c1742b1753ca064bb3307abfd95db912a28"
C2PA_SOFT_BINDING_SOURCE_LICENSE = "CC BY 4.0"
C2PA_SOFT_BINDING_ROWS: tuple[
@@ -575,7 +575,7 @@ C2PA_SOFT_BINDING_ROWS: tuple[
49,
"com.museblossom.contentsdefence.1",
"watermark",
("image",),
("audio", "image", "video"),
(),
(
"Contents Defence invisible image watermark, used to recover the associated C2PA "
+43 -25
View File
@@ -35,13 +35,28 @@ if TYPE_CHECKING:
from collections.abc import Iterable
from typing import BinaryIO
_C2paContext: Any = None
_C2paReader: Any = None
_C2paError: Any = None
with contextlib.suppress(Exception):
from c2pa import C2paError as _C2paError # pyright: ignore[reportMissingTypeStubs]
from c2pa import Context as _C2paContext # pyright: ignore[reportMissingTypeStubs]
from c2pa import Reader as _C2paReader # pyright: ignore[reportMissingTypeStubs]
_C2PA_READER_AVAILABLE = _C2paReader is not None
_C2PA_READER_AVAILABLE = _C2paReader is not None and _C2paContext is not None
# Preserve the SDK's bundled certificate profile explicitly: c2pa-rs 0.91.0
# clears its default EKUs when applying empty trust settings. No trust anchors
# or certificate allowlist are installed. See docs/module-internals.md, C2PA.
_C2PA_ALLOWED_EKUS = "\n".join(
(
"1.3.6.1.5.5.7.3.4", # email protection
"1.3.6.1.5.5.7.3.36", # document signing
"1.3.6.1.5.5.7.3.8", # timestamping
"1.3.6.1.5.5.7.3.9", # OCSP signing
"1.3.6.1.4.1.311.76.59.1.9", # Microsoft C2PA signing
"1.3.6.1.4.1.62558.2.1", # C2PA signing
)
)
_PNG_HEADER = struct.Struct(">I4s")
_CONTENT_BINDING_MATCHES = (
@@ -101,28 +116,30 @@ def _manifest_json_uncached(path: str, *, strict: bool = False) -> str | None:
byte scan and can lose a high-confidence signal. The log line preserves the
diagnostic context needed to investigate an intermittent reader failure.
"""
try:
reader = _C2paReader.try_create(path)
except _C2paError.NotSupported as error:
logger.debug("C2PA reader does not support %s: %s", path, error)
return None
except Exception as error:
logger.warning("C2PA reader failed to open %s: %s: %s", path, type(error).__name__, error)
if strict:
raise
return None
if reader is None:
return None
try:
with reader:
return cast("str", reader.json())
except Exception as error:
# The reader opened the file, so a manifest is there; failing to serialize it
# is never routine.
logger.warning("C2PA reader could not serialize %s: %s: %s", path, type(error).__name__, error)
if strict:
raise
return None
with contextlib.ExitStack() as resources:
try:
context = resources.enter_context(_C2paContext.from_dict({"trust": {"trust_config": _C2PA_ALLOWED_EKUS}}))
reader = _C2paReader.try_create(path, context=context)
except _C2paError.NotSupported as error:
logger.debug("C2PA reader does not support %s: %s", path, error)
return None
except Exception as error:
logger.warning("C2PA reader failed to open %s: %s: %s", path, type(error).__name__, error)
if strict:
raise
return None
if reader is None:
return None
try:
with reader:
return cast("str", reader.json())
except Exception as error:
# The reader opened the file, so a manifest is there; failing to serialize it
# is never routine.
logger.warning("C2PA reader could not serialize %s: %s: %s", path, type(error).__name__, error)
if strict:
raise
return None
@functools.lru_cache(maxsize=8)
@@ -737,8 +754,9 @@ def soft_binding_registry_entries_in(buffer: bytes) -> tuple[C2paSoftBindingAlgo
return tuple(
entry
for entry in C2PA_SOFT_BINDING_REGISTRY
if re.search(
rb"(?<![A-Za-z0-9.-])" + re.escape(entry.algorithm.encode()) + rb"(?![A-Za-z0-9.-])",
if (algorithm := entry.algorithm.encode()) in buffer
and re.search(
rb"(?<![A-Za-z0-9.-])" + re.escape(algorithm) + rb"(?![A-Za-z0-9.-])",
buffer,
)
)
@@ -197,6 +197,8 @@ C2PA_ISSUERS = {vendor.issuer: vendor.org for vendor in C2PA_AI_VENDORS}
# absent: its manifests record no SynthID action, yet Google's checker found
# SynthID on all four Photos AI edits tested (Ask, eraser and two other edits,
# 2026-09-25), although Google documents the mark only for Reimagine.
# Two small edits were oracle-indeterminate on 2026-10-01 UTC; Photos inference
# therefore carries a file-specific confirmation caveat in the report.
SYNTHID_EDIT_SIGNERS: tuple[bytes, ...] = (b"YouTube",)
C2PA_IDENTITY_AI_ORGS = frozenset(vendor.org for vendor in C2PA_AI_VENDORS if vendor.asserts_ai)
C2PA_SIGNER_PLATFORM_BY_ORG = {
+137
View File
@@ -0,0 +1,137 @@
"""SM3 hashing and SM2 signature verification (GB/T 32905, GB/T 32918).
Verification only: no key generation, signing, or secret material, so none of the
constant-time concerns of a signing implementation apply. The TC260 security
guide (TC260-PG-202511A) names SM3withSM2 for AI-label signatures, and no
dependency of this package provides SM2 (``cryptography`` has SM3 but not the
curve). Correctness is pinned by the standard's SM3 vectors, OpenSSL's SM2 verify
vectors, and real MiniMax label signatures in ``tests/test_sm2.py``.
"""
from __future__ import annotations
import struct
Point = tuple[int, int]
# The SM2 recommended 256-bit curve (GB/T 32918.5).
P = 0xFFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000FFFFFFFFFFFFFFFF
A = 0xFFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000FFFFFFFFFFFFFFFC
B = 0x28E9FA9E9D9F5E344D5A9E4BCF6509A7F39789F515AB8F92DDBCBD414D940E93
N = 0xFFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFF7203DF6B21C6052B53BBF40939D54123
G: Point = (
0x32C4AE2C1F1981195F9904466A39C9948FE30BBFF2660BE1715A4589334C74C7,
0xBC3736A2F4F6779C59BDCEE36B692153D0A9877CC62A474002DF32E52139F0A0,
)
# GB/T 32918.2's default signer identity, used when a signature names none.
DEFAULT_USER_ID = b"1234567812345678"
_SM3_IV = (0x7380166F, 0x4914B2B9, 0x172442D7, 0xDA8A0600, 0xA96F30BC, 0x163138AA, 0xE38DEE4D, 0xB0FB0E4E)
_MASK = 0xFFFFFFFF
def _rotl(x: int, n: int) -> int:
n %= 32
return ((x << n) | (x >> (32 - n))) & _MASK
def _p0(x: int) -> int:
return x ^ _rotl(x, 9) ^ _rotl(x, 17)
def _p1(x: int) -> int:
return x ^ _rotl(x, 15) ^ _rotl(x, 23)
def sm3(data: bytes) -> bytes:
"""SM3 digest of ``data`` (32 bytes)."""
bit_length = len(data) * 8
padded = data + b"\x80" + b"\x00" * ((55 - len(data)) % 64) + bit_length.to_bytes(8, "big")
v = list(_SM3_IV)
for offset in range(0, len(padded), 64):
w = list(struct.unpack(">16I", padded[offset : offset + 64]))
for j in range(16, 68):
w.append(_p1(w[j - 16] ^ w[j - 9] ^ _rotl(w[j - 3], 15)) ^ _rotl(w[j - 13], 7) ^ w[j - 6])
w1 = [w[j] ^ w[j + 4] for j in range(64)]
a, b, c, d, e, f, g, h = v
for j in range(64):
t = 0x79CC4519 if j < 16 else 0x7A879D8A
ss1 = _rotl((_rotl(a, 12) + e + _rotl(t, j)) & _MASK, 7)
ss2 = ss1 ^ _rotl(a, 12)
if j < 16:
ff, gg = a ^ b ^ c, e ^ f ^ g
else:
ff, gg = (a & b) | (a & c) | (b & c), (e & f) | (~e & g)
tt1 = (ff + d + ss2 + w1[j]) & _MASK
tt2 = (gg + h + ss1 + w[j]) & _MASK
a, b, c, d, e, f, g, h = tt1, a, _rotl(b, 9), c, _p0(tt2), e, _rotl(f, 19), g
v = [x ^ y for x, y in zip(v, (a, b, c, d, e, f, g, h), strict=True)]
return struct.pack(">8I", *v)
def _add(p: Point | None, q: Point | None) -> Point | None:
if p is None:
return q
if q is None:
return p
if p[0] == q[0] and (p[1] + q[1]) % P == 0:
return None
numerator, denominator = (3 * p[0] * p[0] + A, 2 * p[1]) if p == q else (q[1] - p[1], q[0] - p[0])
slope = numerator * pow(denominator, -1, P) % P
x = (slope * slope - p[0] - q[0]) % P
return x, (slope * (p[0] - x) - p[1]) % P
def _multiply(k: int, point: Point) -> Point | None:
result: Point | None = None
addend: Point | None = point
while k:
if k & 1:
result = _add(result, addend)
addend = _add(addend, addend)
k >>= 1
return result
def on_curve(point: Point) -> bool:
x, y = point
return 0 <= x < P and 0 <= y < P and (y * y - (x * x * x + A * x + B)) % P == 0
def lift_x(x: int, *, odd: bool) -> Point | None:
"""The curve point with this x coordinate and the requested y parity, if any."""
if not 0 <= x < P:
return None
y_squared = (x * x * x + A * x + B) % P
y = pow(y_squared, (P + 1) // 4, P) # P % 4 == 3
if y * y % P != y_squared:
return None
return (x, y) if (y & 1) == odd else (x, P - y)
def signer_digest(public_key: Point, message: bytes, user_id: bytes = DEFAULT_USER_ID) -> bytes:
"""``e = SM3(Z_A || M)``, the value an SM3withSM2 signature signs."""
identity_bits = (len(user_id) * 8).to_bytes(2, "big")
fields = (A, B, G[0], G[1], public_key[0], public_key[1])
z = sm3(identity_bits + user_id + b"".join(value.to_bytes(32, "big") for value in fields))
return sm3(z + message)
def verify_digest(public_key: Point, digest: bytes, r: int, s: int) -> bool:
"""Verify an SM2 signature ``(r, s)`` over an already computed digest ``e``."""
if not (1 <= r < N and 1 <= s < N) or not on_curve(public_key):
return False
t = (r + s) % N
if t == 0:
return False
point = _add(_multiply(s, G), _multiply(t, public_key))
return point is not None and (int.from_bytes(digest, "big") + point[0]) % N == r
def verify(public_key: Point, message: bytes, signature: bytes, user_id: bytes = DEFAULT_USER_ID) -> bool:
"""Verify a raw 64-byte ``r || s`` SM3withSM2 signature over ``message``."""
if len(signature) != 64:
return False
r, s = int.from_bytes(signature[:32], "big"), int.from_bytes(signature[32:], "big")
return verify_digest(public_key, signer_digest(public_key, message, user_id), r, s)
@@ -0,0 +1,85 @@
"""Registered TC260 producer identities, mark routing, and pinned signing keys."""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Iterable
@dataclass(frozen=True)
class Tc260Producer:
"""One producer's measured codes and independently established identity."""
key: str
codes: tuple[str, ...]
vendor: str
image_mark: str | None = None
video_mark: str | None = None
video_platform: str | None = None
signer: str | None = None
signing_key_x: int | None = None
image_platform: str | None = None
TC260_PRODUCERS = (
# YOYO JPEG exports; USCC independently verified in Honor's privacy statement.
Tc260Producer("honor", ("91440300MA5G49LC9K",), "Honor", image_platform="Honor"),
Tc260Producer("doubao", ("91110102MACQD9K640", "doubao"), "ByteDance", "doubao", "doubao", "ByteDance Doubao"),
Tc260Producer("jimeng", ("9144030008867405X2",), "ByteDance", "jimeng"),
Tc260Producer("qwen", ("91440101MA9Y9T4H7A",), "Alibaba", "qwen", video_platform="Alibaba Cloud Qwen"),
# Tongyi Yunqi (Hangzhou), owned by Alibaba Cloud and Tongyi Lab, signs
# both Wan and HappyHorse exports, not just one model.
Tc260Producer("wan", ("91330106MA2CFLDG4R",), "Alibaba", "wan", video_platform="Alibaba Tongyi (Wan, HappyHorse)"),
# Kling 3.0 Turbo served by Higgsfield (2026-09-24) writes the bare "kling".
Tc260Producer("kling", ("91110108335469089C", "kling"), "Kuaishou", "kling", "kling", "Kuaishou Kling AI"),
Tc260Producer("yuanbao", ("91440300708461136T",), "Tencent", "yuanbao"),
Tc260Producer("runninghub", ("91340100MAEB4N8H76", "RunningHub"), "RunningHub", "runninghub"),
Tc260Producer("baidu", ("91110000802100433B",), "Baidu", "baidu"),
Tc260Producer("liblib", ("91110105MACJ6K1C8A",), "LiblibAI", "liblib"),
# ShengShu's USCC, verified against the Beijing municipal list of 2025-03-03.
Tc260Producer("vidu", ("91110108MACC4D63XF",), "ShengShu", video_mark="vidu", video_platform="ShengShu Vidu"),
# Even-y SM2 point for the x coordinate carried by measured MiniMax exports
# (Hailuo 2.3, H3 and H3 Max, 2026-09-29). Source: docs/research-sweep-2026-09.md.
Tc260Producer(
"minimax",
("MiniMax",),
"MiniMax",
video_mark="hailuo",
video_platform="MiniMax Hailuo AI",
signer="MiniMax",
signing_key_x=0xA0B3B0B6A0C9B0C89CAB328342AF4E8221EC5B40799CBE835AB4251F7B47E4FD,
),
)
def uscc_of(code: str) -> str:
"""Extract the USCC from ``001`` + type digit + USCC + product suffix."""
return code[4:22] if len(code) >= 22 and code[:3] == "001" else code
_PRODUCERS_BY_CODE = {code.casefold(): row for row in TC260_PRODUCERS for code in row.codes}
def tc260_producer_in(producer: str, codes: Iterable[str]) -> bool:
"""Match a whole producer identity, USCC-normalized and casefolded."""
code = uscc_of(producer.strip()).casefold()
return bool(code) and any(code == candidate.casefold() for candidate in codes)
def producer_for_code(code: str) -> Tc260Producer | None:
"""Resolve a producer field; partial names never establish an identity."""
return _PRODUCERS_BY_CODE.get(uscc_of(code.strip()).casefold())
def producer_for_signer(signer: str) -> Tc260Producer | None:
"""Resolve the identity returned by successful pinned-key verification."""
return next((row for row in TC260_PRODUCERS if row.signer == signer), None)
def producer_codes_for_mark(mark: str) -> tuple[str, ...]:
"""Measured producer identities for an image or video mark, if registered."""
row = next((row for row in TC260_PRODUCERS if mark in (row.image_mark, row.video_mark)), None)
return row.codes if row else ()
@@ -0,0 +1,165 @@
"""Verify TC260 ``SecurityData`` label signatures (TC260-PG-202511A).
The guide lets a label producer record a ``SecurityData`` JSON object in the
label's ``ReservedCode1``: SM3withSM2 signatures (``PubSD`` entries of type
``DS``), the public key (``PubKey``), and content bindings (``Bindings``). Only
the producer side is read here. MiniMax writes it on every video in the local
corpus. Honor YOYO JPEGs carry nested RSA/content signatures with ``PubSd``
spelling in both reserved slots; those signatures are reported as unsupported.
Other measured producers use opaque vendor strings rather than ``SecurityData``.
Only the label signature is checked. MiniMax spells its type ``LabelMataData``
where the guide says ``Md``; both are accepted. MiniMax's second signature, over
its ``Bindings`` entry, also verifies (docs/research-sweep-2026-09.md), but no
verdict reads it, and the content hash inside that entry is not recomputed
because MiniMax names no content-selection method. The key travels inside the
file it signs, so a signature proves only integrity unless the key matches a
pinned signer in ``TC260_SIGNING_KEYS``.
"""
from __future__ import annotations
import functools
import json
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Literal, cast
from remove_ai_watermarks._internal import sm2
from remove_ai_watermarks._internal.tc260_producers import TC260_PRODUCERS
if TYPE_CHECKING:
from collections.abc import Mapping
Status = Literal["verified", "failed", "absent", "unsupported"]
SM3_WITH_SM2 = "1.2.156.10197.1.501"
def _pinned(x: int) -> sm2.Point:
point = sm2.lift_x(x, odd=False)
if point is None:
raise ValueError(f"pinned TC260 key x={x:#x} is not on the SM2 curve")
return point
# Derived from the same producer rows used by metadata and visible-mark routing.
TC260_SIGNING_KEYS: dict[str, sm2.Point] = {
row.signer: _pinned(row.signing_key_x)
for row in TC260_PRODUCERS
if row.signer is not None and row.signing_key_x is not None
}
_LABEL_TBS_TYPES = frozenset({"Md", "LabelMataData"})
# The producer's signed fields, in GB 45438 appendix E order ("Label" to value3).
_PRODUCER_FIELDS = ("Label", "ContentProducer", "ProduceID")
@dataclass(frozen=True)
class Tc260Signature:
"""Outcome of checking one label's ``SecurityData`` label signature."""
label: Status
signer: str | None
def describe(self) -> str:
if self.label == "verified":
if self.signer:
return f"TC260 label signature verified (pinned {self.signer} key)"
return "TC260 label signature verified with its own embedded key (no pinned signer)"
if self.label == "failed":
return "TC260 label signature does not verify"
if self.label == "unsupported":
return (
"TC260 SecurityData signature algorithm or signed-content selection "
"is not supported; integrity unverified"
)
return "TC260 SecurityData without a label signature"
def _candidate_keys(key_hex: str) -> tuple[list[sm2.Point], str | None]:
"""The points a ``KeyValue`` can denote, and the pinned signer when it matches one."""
try:
raw = bytes.fromhex(key_hex)
except ValueError:
return [], None
if len(raw) == 65 and raw[0] == 4:
raw = raw[1:]
if len(raw) == 33 and raw[0] == 0:
raw = raw[1:] # MiniMax pads a bare x coordinate with 00
if len(raw) == 64:
points = [(int.from_bytes(raw[:32], "big"), int.from_bytes(raw[32:], "big"))]
elif len(raw) == 33 and raw[0] in (2, 3):
points = [p for p in [sm2.lift_x(int.from_bytes(raw[1:], "big"), odd=raw[0] == 3)] if p]
elif len(raw) == 32:
x = int.from_bytes(raw, "big") # a bare x coordinate: either y may be meant
points = [p for p in (sm2.lift_x(x, odd=False), sm2.lift_x(x, odd=True)) if p]
else:
return [], None
for signer, pinned in TC260_SIGNING_KEYS.items():
if any(p[0] == pinned[0] for p in points):
return [pinned], signer
return points, None
def _label_message(label: Mapping[str, str]) -> bytes:
"""Appendix A.1: ``"Label":"1","ContentProducer":"...","ProduceID":"..."``."""
return ",".join(
f"{json.dumps(field)}:{json.dumps(label.get(field, ''), ensure_ascii=False)}" for field in _PRODUCER_FIELDS
).encode()
def _object(value: object) -> dict[str, Any]:
"""``value`` as a JSON object, or empty when it is anything else."""
return cast("dict[str, Any]", value) if isinstance(value, dict) else {}
def _objects(value: object) -> list[dict[str, Any]]:
"""The JSON objects in a JSON array, or none when ``value`` is not an array."""
items = cast("list[object]", value) if isinstance(value, list) else []
return [cast("dict[str, Any]", item) for item in items if isinstance(item, dict)]
@functools.lru_cache(maxsize=256)
def _verified(point: sm2.Point, message: bytes, signature: bytes) -> bool:
# get_ai_metadata and the identify verdict both check the same label, and
# removal re-reads metadata; the pure-Python curve math runs once per triple.
return sm2.verify(point, message, signature)
def check_tc260_signature(label: Mapping[str, str]) -> Tc260Signature | None:
"""Check the producer's ``SecurityData`` in ``ReservedCode1``; None when it has none."""
try:
parsed: object = json.loads(label.get("ReservedCode1", ""))
except ValueError:
return None
security = _object(_object(parsed).get("SecurityData"))
if security.get("Type") != "TC260PG":
return None
public = _objects(security.get("PubSD", security.get("PubSd")))
keys = {
str(entry.get("KeyID", 0)): _candidate_keys(str(entry.get("KeyValue", "")))
for entry in reversed(public) # reversed: the first entry per KeyID wins
if entry.get("Type") == "PubKey" and entry.get("AlgID") == SM3_WITH_SM2
}
message = _label_message(label)
results: list[bool] = []
unsupported = False
signer: str | None = None
for entry in public:
if entry.get("Type") != "DS":
continue
if entry.get("AlgID") != SM3_WITH_SM2 or _object(entry.get("TBSData")).get("Type") not in _LABEL_TBS_TYPES:
unsupported = True
continue
points, key_signer = keys.get(str(entry.get("KeyID", 0)), ([], None))
try:
signature = bytes.fromhex(str(entry.get("Signature", "")))
except ValueError:
signature = b""
verified = any(_verified(point, message, signature) for point in points)
results.append(verified)
if verified:
signer = signer or key_signer
if results:
return Tc260Signature(label="verified" if any(results) else "failed", signer=signer)
return Tc260Signature(label="unsupported" if unsupported else "absent", signer=None)
@@ -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,
)
+3 -3
View File
@@ -61,7 +61,7 @@ def visible_provenance(source: str | Path) -> frozenset[str]:
Mapping: a Google/Gemini C2PA issuer -> ``"gemini"``; a ``samsung_genai`` marker ->
``"samsung"``; a China-AIGC (TC260) label -> the vendor its ``ContentProducer``
names (``KnownMark.tc260_producer_codes``). An absent or unmapped producer confirms
names (the shared ``TC260_PRODUCERS`` registry). An absent or unmapped producer confirms
no particular product: the TC260 standard identifies neither a manufacturer nor a
product.
@@ -96,11 +96,11 @@ def _tc260_vendors(path: Path) -> frozenset[str]:
import contextlib
with contextlib.suppress(Exception):
from remove_ai_watermarks.metadata import aigc_label, uscc_of
from remove_ai_watermarks.metadata import aigc_label
from remove_ai_watermarks.watermark_registry import tc260_producer_mark
producer = (aigc_label(path) or {}).get("ContentProducer", "")
if producer and (mark := tc260_producer_mark(uscc_of(producer))):
if producer and (mark := tc260_producer_mark(producer)):
return frozenset({mark.key})
return frozenset()
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

+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,
+28 -18
View File
@@ -49,6 +49,8 @@ from remove_ai_watermarks._internal.constants import (
C2PA_SIGNER_PLATFORMS,
)
from remove_ai_watermarks._internal.schema import require_schema_version
from remove_ai_watermarks._internal.tc260_producers import producer_for_code, producer_for_signer
from remove_ai_watermarks._internal.tc260_signature import check_tc260_signature
from remove_ai_watermarks.metadata import (
AI_METADATA_KEYS,
AIGC_MARKERS,
@@ -128,8 +130,9 @@ _SYNTHID_CAVEAT = (
_GOOGLE_PHOTOS_EDIT = "Google Photos (AI edit)"
_GOOGLE_PHOTOS_SYNTHID_CAVEAT = (
"SynthID on a Google Photos AI edit rests on measurement, not on the manifest: Google's checker "
"found it on every Photos AI edit tested (Ask, eraser and two other edits, 2026-09-25), but Google "
"notes a very small edit may not carry it. Confirm via the Gemini app."
"found it on four edits on 2026-09-25 and on large eraser and restyle edits on 2026-10-01 UTC, "
"but returned indeterminate results for two small edits. This provenance inference does not "
"confirm a detectable pixel watermark on this file. Confirm via the Gemini app."
)
_C2PA_UNTRUSTED_CAVEAT = (
"The C2PA claim signature and asset binding validate, but no trust anchor list is configured here, "
@@ -159,6 +162,10 @@ _HF_JOB_CAVEAT = (
"medium-confidence signal. Higgsfield adds the tag after signing, which invalidates any "
"C2PA manifest the upstream model embedded."
)
_TC260_SIGNATURE_FAILED_CAVEAT = (
"The TC260 label carries a SecurityData signature that does not verify against its own "
"fields, so the label was altered after signing or copied from another file."
)
_C2PA_CLOUD_CAVEAT = (
"The embedded C2PA manifest is absent but an XMP provenance pointer to the "
"vendor's cloud manifest store survives, so the Content Credentials remain "
@@ -915,17 +922,6 @@ _C2PA_MANIFEST_SOURCE = "c2pa_manifest"
_CLASH_SOURCE: dict[str, str] = {"c2pa": _C2PA_MANIFEST_SOURCE, "synthid": _C2PA_MANIFEST_SOURCE}
def _tc260_manufacturer_of(producer: str) -> str | None:
"""Resolve a TC260 producer to a normalized manufacturer, if registered."""
if not producer:
return None
from remove_ai_watermarks.metadata import uscc_of
from remove_ai_watermarks.watermark_registry import tc260_producer_mark
mark = tc260_producer_mark(uscc_of(producer))
return _vendor_of(mark.manufacturer if mark else None)
def _integrity_clashes(
ai_vendors: dict[str, str], camera_label: str | None, *, camera_has_ai_marker: bool
) -> list[str]:
@@ -961,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
@@ -971,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
@@ -1484,8 +1483,19 @@ def _identify_from_evidence(
watermarks.append("China AIGC label (TC260 standard)")
if platform is None:
platform = "China AIGC-labeled content (TC260 standard)"
if manufacturer := _tc260_manufacturer_of(producer):
ai_vendor_claims["aigc"] = manufacturer
if tc260_producer := producer_for_code(producer):
ai_vendor_claims["aigc"] = tc260_producer.vendor
if tc260_producer.image_platform and platform == "China AIGC-labeled content (TC260 standard)":
platform = tc260_producer.image_platform
if aigc_data and (signature := check_tc260_signature(aigc_data)):
if signature.label == "verified" and signature.signer:
signals.append(Signal("aigc_signature", signature.describe(), "high"))
if signed_producer := producer_for_signer(signature.signer):
ai_vendor_claims["aigc"] = signed_producer.vendor
elif signature.label == "failed":
caveats.append(_TC260_SIGNATURE_FAILED_CAVEAT)
elif signature.label == "unsupported":
caveats.append(signature.describe())
# ── Local diffusion parameters (Stable Diffusion / ComfyUI) ──────
local_keys = sorted(k for k in meta if k.lower() in _LOCAL_GEN_KEYS)
+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)
+16 -18
View File
@@ -14,6 +14,8 @@ import re
import struct
from typing import TYPE_CHECKING, Any, cast
from remove_ai_watermarks._internal.tc260_producers import uscc_of as uscc_of
if TYPE_CHECKING:
from collections.abc import Callable, Iterable
from pathlib import Path
@@ -177,19 +179,14 @@ _TC260_CANONICAL_FIELDS: dict[str, str] = {field.casefold(): field for field in
MAX_TC260_VALUE_BYTES = 1024 * 1024
# A TC260 producer code is ``001`` + ``1`` + USCC(18) + a 5-digit app/product suffix,
# so two codes sharing the USCC are the same legal entity registering different
# products. Slicing is defensive: anything not matching the layout is returned as-is,
# which also passes through the bare-name forms some generators write ("doubao",
# "picwish").
_USCC_START, _USCC_END = 4, 22
def uscc_of(code: str) -> str:
"""The 18-char Unified Social Credit Code embedded in a TC260 producer code."""
if len(code) >= _USCC_END and code[:3] == "001":
return code[_USCC_START:_USCC_END]
return code
def _tc260_fields(parsed: dict[object, object]) -> dict[str, str]:
"""Keep structured reserved fields as JSON rather than Python repr strings."""
return {
_TC260_CANONICAL_FIELDS.get(str(key).casefold(), str(key)): (
json.dumps(item, ensure_ascii=False, separators=(",", ":")) if isinstance(item, (dict, list)) else str(item)
)
for key, item in parsed.items()
}
def parse_tc260_aigc_json(value: bytes) -> dict[str, str] | None:
@@ -202,10 +199,7 @@ def parse_tc260_aigc_json(value: bytes) -> dict[str, str] | None:
return None
if not isinstance(parsed, dict):
return None
fields = {
_TC260_CANONICAL_FIELDS.get(str(key).casefold(), str(key)): str(item)
for key, item in cast("dict[object, object]", parsed).items()
}
fields = _tc260_fields(cast("dict[object, object]", parsed))
return fields if TC260_AIGC_FIELDS & fields.keys() else None
@@ -553,7 +547,7 @@ def aigc_label_from_metadata(data: bytes, candidates: tuple[str, ...] = ()) -> d
return None
if not isinstance(parsed, dict):
return None
return {str(k): str(v) for k, v in cast("dict[object, object]", parsed).items()}
return _tc260_fields(cast("dict[object, object]", parsed))
for candidate in candidates:
if result := _parse(candidate, require_tc260_field=True):
@@ -1424,6 +1418,10 @@ def get_ai_metadata(image_path: Path) -> dict[str, str]:
# sentence above, whose wording can change without notice.
if producer:
result["aigc_producer"] = producer
from remove_ai_watermarks._internal.tc260_signature import check_tc260_signature
if signature := check_tc260_signature(aigc):
result["aigc_signature"] = signature.describe()
app_scan = scan_head(image_path)
app_provenance, app_generator = _app_metadata_evidence(app_scan)
+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,
+10 -27
View File
@@ -13,6 +13,7 @@ from pathlib import Path
from tempfile import TemporaryDirectory
from typing import TYPE_CHECKING, Literal
from remove_ai_watermarks._internal.tc260_producers import TC260_PRODUCERS
from remove_ai_watermarks.video_synthid import (
DEFAULT_VIDEO_SYNTHID_FPS,
DEFAULT_VIDEO_SYNTHID_LONG_SIDE,
@@ -218,11 +219,12 @@ _VISIBLE_PLATFORM = {
"sora": "OpenAI Sora",
"veo": "Google Veo",
"seedance": "ByteDance Seedance",
"doubao": "ByteDance Doubao",
"dola": "ByteDance Dola",
"hailuo": "MiniMax Hailuo AI",
"vidu": "ShengShu Vidu",
"kling": "Kuaishou Kling AI",
**{
row.video_mark: row.video_platform
for row in TC260_PRODUCERS
if row.video_mark is not None and row.video_platform is not None
},
}
@@ -379,30 +381,11 @@ def _platform_from_video_metadata(markers: dict[str, str]) -> str | None:
def _tc260_video_platform(producer: str) -> str | None:
"""Name the organization a TC260 ``ContentProducer`` identifies, if registered.
"""Name a registered TC260 producer, preserving multi-model organization labels."""
from remove_ai_watermarks._internal.tc260_producers import producer_for_code
Codes come from the registry rows (plus the Vidu constant); the labels name
the producer, not one product, because one code can cover several models (the
Tongyi Yunqi code signs both Wan and HappyHorse video, measured 2026-09-24).
MiniMax writes its bare name instead of a USCC.
"""
from remove_ai_watermarks.video_visible import VIDU_TC260_PRODUCER_CODES, tc260_producer_in
from remove_ai_watermarks.watermark_registry import get_mark
labels = {
"kling": _VISIBLE_PLATFORM["kling"],
"doubao": _VISIBLE_PLATFORM["doubao"],
"qwen": "Alibaba Cloud Qwen",
"wan": "Alibaba Tongyi (Wan, HappyHorse)",
}
for key, label in labels.items():
if tc260_producer_in(producer, get_mark(key).tc260_producer_codes):
return label
if tc260_producer_in(producer, VIDU_TC260_PRODUCER_CODES):
return _VISIBLE_PLATFORM["vidu"]
if tc260_producer_in(producer, ("MiniMax",)):
return _VISIBLE_PLATFORM["hailuo"]
return None
row = producer_for_code(producer)
return row.video_platform if row else None
def inspect_video_metadata(source: str | Path) -> VideoMetadataReport:
+6 -20
View File
@@ -36,6 +36,8 @@ import cv2
import numpy as np
from PIL import Image, ImageDraw, ImageFont
from remove_ai_watermarks._internal.tc260_producers import producer_codes_for_mark
from remove_ai_watermarks._internal.tc260_producers import tc260_producer_in as tc260_producer_in
from remove_ai_watermarks.video import VIDEO_VISIBLE_MARKS
from remove_ai_watermarks.video_encoding import (
abort_raw_video_encoder,
@@ -50,7 +52,6 @@ from remove_ai_watermarks.video_encoding import (
from remove_ai_watermarks.video_temporal import stabilize_filled_frame
if TYPE_CHECKING:
from collections.abc import Iterable
from pathlib import Path
from numpy.typing import NDArray
@@ -110,9 +111,6 @@ _DOLA_RELATIVE_HEIGHTS = tuple(value / 1000 for value in range(22, 41))
_HAILUO_RELATIVE_HEIGHTS = tuple(value / 1000 for value in range(28, 56, 3))
_KLING_RELATIVE_HEIGHTS = tuple(value / 1000 for value in range(24, 49, 3))
_VIDU_RELATIVE_HEIGHTS = tuple(value / 1000 for value in range(34, 61, 3))
# ShengShu (北京生数科技有限公司), Vidu's maker, as the USCC inside a TC260
# ContentProducer. Verified against the Beijing municipal list of 2025-03-03.
VIDU_TC260_PRODUCER_CODES = ("91110108MACC4D63XF",)
_HDR_TRANSFERS = frozenset({"smpte2084", "arib-std-b67"})
@@ -1641,19 +1639,9 @@ def has_bytedance_video_provenance(markers: dict[str, str]) -> bool:
return ("bytedance" in identity or "byteplus" in identity) and "trainedalgorithmicmedia" in source_type
def tc260_producer_in(producer: str, codes: Iterable[str]) -> bool:
"""Whether a TC260 ``ContentProducer`` names one of ``codes`` (USCC-normalized, casefolded)."""
from remove_ai_watermarks.metadata import uscc_of
code = uscc_of(producer.strip()).casefold()
return bool(code) and code in {candidate.casefold() for candidate in codes}
def has_doubao_video_provenance(markers: dict[str, str]) -> bool:
"""Whether the structural TC260 producer identifies registered Doubao provenance."""
from remove_ai_watermarks.watermark_registry import get_mark
return tc260_producer_in(markers.get("aigc_producer", ""), get_mark("doubao").tc260_producer_codes)
return tc260_producer_in(markers.get("aigc_producer", ""), producer_codes_for_mark("doubao"))
# Tokens of the C2PA issuer each mark's own vendor signs with, compared casefolded.
@@ -1708,9 +1696,7 @@ def contradicts_video_provenance(mark: str, markers: dict[str, str]) -> bool:
return False
producer = markers.get("aigc_producer", "").strip()
if mark == "kling" and producer:
from remove_ai_watermarks.watermark_registry import get_mark
return not tc260_producer_in(producer, get_mark("kling").tc260_producer_codes)
return not tc260_producer_in(producer, producer_codes_for_mark("kling"))
from remove_ai_watermarks._internal.c2pa import c2pa_credential_level
issuer = markers.get("issuer", "").casefold()
@@ -1725,7 +1711,7 @@ def contradicts_video_provenance(mark: str, markers: dict[str, str]) -> bool:
def has_vidu_video_provenance(markers: dict[str, str]) -> bool:
"""Whether a TC260 label names ShengShu, Vidu's maker, as the producer."""
return tc260_producer_in(markers.get("aigc_producer", ""), VIDU_TC260_PRODUCER_CODES)
return tc260_producer_in(markers.get("aigc_producer", ""), producer_codes_for_mark("vidu"))
def has_hailuo_video_provenance(markers: dict[str, str]) -> bool:
@@ -1737,4 +1723,4 @@ def has_hailuo_video_provenance(markers: dict[str, str]) -> bool:
``metadata.get_ai_metadata``), so this matches the field exactly rather
than parsing the human-readable ``aigc_label`` sentence.
"""
return markers.get("aigc_producer", "").strip().lower() == "minimax"
return tc260_producer_in(markers.get("aigc_producer", ""), producer_codes_for_mark("hailuo"))
+87 -47
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),
@@ -49,6 +51,8 @@ if TYPE_CHECKING:
from numpy.typing import NDArray
from remove_ai_watermarks._internal.tc260_producers import producer_codes_for_mark, producer_for_code
logger = logging.getLogger(__name__)
Region = tuple[int, int, int, int]
@@ -293,9 +297,8 @@ class KnownMark:
provenance_platform_tokens: tuple[str, ...] = ()
# TC260 ``ContentProducer`` identities that name THIS mark's vendor -- Unified
# Social Credit Codes as normalized by ``metadata.uscc_of``, plus the bare product
# names a few generators write instead. Here rather than in a separate table
# because a newly registered TC260 mark whose codes were forgotten fails SILENTLY:
# its own detector remains strict even on an image carrying that producer's label.
# names a few generators write instead. Derived from TC260_PRODUCERS; the
# registry-coverage test prevents a TC260 mark from silently omitting its codes.
tc260_producer_codes: tuple[str, ...] = ()
# Optional single-pass dual verdict for the arbiter's perception stage (see
# `detect_both`). None = fall back to two `_detect` calls.
@@ -447,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"),
@@ -612,7 +617,6 @@ def _text_mark(
product: str | None = None,
label_regime: str | None = "tc260",
provenance_signals: tuple[str, ...] = ("aigc",),
tc260_producer_codes: tuple[str, ...] = (),
provenance_platform_tokens: tuple[str, ...] = (),
) -> KnownMark:
"""Build a text-mark registry row from its shared detector and mask adapters.
@@ -635,35 +639,50 @@ def _text_mark(
_engine_mark_detect(key, label, location),
_engine_mark_mask(key),
provenance_signals=provenance_signals,
tc260_producer_codes=tc260_producer_codes,
tc260_producer_codes=producer_codes_for_mark(key),
provenance_platform_tokens=provenance_platform_tokens,
_detect_both=_engine_mark_detect_both(key, label, location),
)
# ── 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]:
@@ -696,7 +715,6 @@ _REGISTRY: tuple[KnownMark, ...] = (
"bottom-right",
platform="ByteDance Doubao (visible 豆包AI生成 mark detected)",
manufacturer="bytedance",
tc260_producer_codes=("91110102MACQD9K640", "doubao"),
),
_text_mark(
"jimeng",
@@ -704,7 +722,6 @@ _REGISTRY: tuple[KnownMark, ...] = (
"bottom-right",
platform="ByteDance Jimeng / Dreamina (visible 即梦AI mark detected)",
manufacturer="bytedance",
tc260_producer_codes=("9144030008867405X2",),
),
_text_mark(
"qwen",
@@ -712,7 +729,6 @@ _REGISTRY: tuple[KnownMark, ...] = (
"bottom-right",
platform="Alibaba Cloud Qwen (visible text or symbol mark detected)",
manufacturer="alibaba",
tc260_producer_codes=("91440101MA9Y9T4H7A",),
),
_text_mark(
"wan",
@@ -720,9 +736,6 @@ _REGISTRY: tuple[KnownMark, ...] = (
"bottom-right",
platform="Alibaba Wan (visible Wan mark detected)",
manufacturer="alibaba",
# Tongyi Yunqi (Hangzhou) Information Technology, owned by Alibaba Cloud and
# Tongyi Lab: the ContentProducer on the measured Wan export.
tc260_producer_codes=("91330106MA2CFLDG4R",),
),
_text_mark(
"kling",
@@ -730,9 +743,6 @@ _REGISTRY: tuple[KnownMark, ...] = (
"bottom-right",
platform="Kuaishou Kling AI (visible 可灵AI / KlingAI 3.0 mark detected)",
manufacturer="kuaishou",
# Kling 3.0 Turbo video served by Higgsfield (2026-09-24) names the
# producer by the bare string "kling" instead of a USCC.
tc260_producer_codes=("91110108335469089C", "kling"),
),
_text_mark(
"yuanbao",
@@ -740,7 +750,24 @@ _REGISTRY: tuple[KnownMark, ...] = (
"bottom-right",
platform="Tencent Yuanbao (visible 元宝 / AI生成 mark detected)",
manufacturer="tencent",
tc260_producer_codes=("91440300708461136T",),
),
_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(
@@ -758,7 +785,6 @@ _REGISTRY: tuple[KnownMark, ...] = (
"top-left",
platform="RunningHub (visible RunningHub AI生成 mark detected)",
manufacturer="runninghub",
tc260_producer_codes=("91340100MAEB4N8H76", "RunningHub"),
),
_text_mark(
"baidu",
@@ -766,7 +792,6 @@ _REGISTRY: tuple[KnownMark, ...] = (
"bottom-right",
platform="Baidu (visible 百度 AI生成 mark detected)",
manufacturer="baidu",
tc260_producer_codes=("91110000802100433B",),
),
_text_mark(
"liblib",
@@ -774,7 +799,6 @@ _REGISTRY: tuple[KnownMark, ...] = (
"bottom-center",
platform="LiblibAI (visible LiblibAI mark detected)",
manufacturer="liblib",
tc260_producer_codes=("91110105MACJ6K1C8A",),
),
KnownMark(
"liblib_pill",
@@ -943,7 +967,8 @@ def resolve_trust(
def tc260_producer_mark(code: str) -> KnownMark | None:
"""Return the registry row for one TC260 ``ContentProducer`` identity."""
return next((mark for mark in _REGISTRY if code in mark.tc260_producer_codes), None)
producer = producer_for_code(code)
return get_mark(producer.image_mark) if producer and producer.image_mark else None
def _pill_suppressors() -> set[str]:
@@ -952,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
@@ -962,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
@@ -1002,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,
@@ -1022,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] = []
@@ -1038,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
@@ -146,3 +146,13 @@ class TestReadBytesAuth:
monkeypatch.setenv("GITHUB_TOKEN", "test-token")
headers = self._urlopen_headers("https://raw.githubusercontent.com/example/example/main/x.json")
assert "Authorization" not in headers
@pytest.mark.parametrize("entry", C2PA_SOFT_BINDING_REGISTRY, ids=lambda entry: entry.algorithm)
def test_every_soft_binding_algorithm_keeps_exact_token_boundaries(entry):
from remove_ai_watermarks._internal.c2pa import soft_binding_registry_entries_in
token = entry.algorithm.encode()
assert entry in soft_binding_registry_entries_in(b'"' + token + b'"')
for prefix, suffix in ((b"prefix", b""), (b"", b".suffix"), (b"-", b""), (b"", b"1")):
assert entry not in soft_binding_registry_entries_in(prefix + token + suffix)
+9 -4
View File
@@ -30,7 +30,6 @@ from remove_ai_watermarks.identify import (
_attribute_platform,
_integrity_clashes,
_issuers_in,
_tc260_manufacturer_of,
_vendor_of,
evidence_from_metadata_record,
extract_provenance_evidence,
@@ -1870,9 +1869,13 @@ class TestVendorOf:
class TestIntegrityClashesHelper:
def test_tc260_manufacturer_comes_from_the_registered_producer(self):
assert _tc260_manufacturer_of("001191440101MA9Y9T4H7A00001") == "Alibaba"
assert _tc260_manufacturer_of("0011999999999999999999999") is None
assert _tc260_manufacturer_of("") is None
from remove_ai_watermarks._internal.tc260_producers import producer_for_code
row = producer_for_code("001191440101MA9Y9T4H7A00001")
assert row is not None
assert row.vendor == "Alibaba"
assert producer_for_code("0011999999999999999999999") is None
assert producer_for_code("") is None
def test_two_ai_vendors_clash(self):
clashes = _integrity_clashes({"c2pa": "OpenAI", "exif_generator": "Ideogram"}, None, camera_has_ai_marker=True)
@@ -2352,6 +2355,8 @@ class TestGooglePhotosSynthIdScope:
Google documents SynthID only for Reimagine (blog.google, 2025-08-20), but its
checker found the mark on all four Photos AI edits tested on 2026-09-25.
On 2026-10-01 UTC, large eraser and restyle edits were positive, while two
small edits were indeterminate. The report remains a provenance inference.
The manifest shape mirrors a real Google Photos iOS object-removal edit captured
2026-09-23: signer common name "Google Photos", actions opened + deleted, the
+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"])
+118 -1
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import json
import shutil
import subprocess
from datetime import UTC
from pathlib import Path
import piexif
@@ -225,7 +226,7 @@ def test_c2pa_reader_failure_is_partial_even_after_tolerant_cached_read(tmp_path
class BrokenReader:
@staticmethod
def try_create(path):
def try_create(path, *, context):
calls.append(path)
if not serialization_failure:
raise RuntimeError("synthetic C2PA open failure")
@@ -265,3 +266,119 @@ def test_exif_parser_failure_is_partial(tmp_path, monkeypatch):
assert calls
assert record["status"] == "partial"
assert record["issues"] == [{"stage": "decoder", "code": "collection-failed"}]
@pytest.mark.parametrize(
"relative_path",
[
"provenance/higgsfield-gpt-image.jpg",
"provenance/microsoft-bing-image-creator.jpg",
"provenance/microsoft-invismark.png",
"provenance/microsoft-paint-invismark.png",
"visible/microsoft/provider-original.png",
],
)
def test_c2pa_signing_ekus_validate_without_trusting_the_signer(relative_path):
from remove_ai_watermarks._internal import c2pa
source = Path(__file__).resolve().parents[1] / "data" / "fixtures" / relative_path
c2pa._manifest_json_cached.cache_clear()
c2pa._extract_c2pa_info_cached.cache_clear()
info = c2pa.extract_c2pa_info(source)
assert info["c2pa_integrity"] == info["c2pa_signature"] == "valid"
assert info["c2pa_signer_validity"] != "invalid"
assert info["c2pa_signer_trust"] == "untrusted"
assert "signingCredential.invalid" not in info["c2pa_validation_codes"]
report = identify(source, check_visible=False, check_invisible=False)
assert report.confidence == "high"
assert report.platform is not None
if "microsoft" in relative_path:
assert "claim.malformed" in info["c2pa_validation_codes"]
assert "com.microsoft.invismark.1" in info["soft_binding_algorithm"]
def _signed_c2pa_profile_sample(tmp_path):
"""Sign a neutral carrier with a local certificate whose dates we control."""
from datetime import datetime
from c2pa import Builder, C2paSignerInfo, Context, Signer
from cryptography import x509
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ed25519
key = ed25519.Ed25519PrivateKey.generate()
name = x509.Name([x509.NameAttribute(x509.NameOID.COMMON_NAME, "Synthetic C2PA profile test")])
document_signing = "1.3.6.1.5.5.7.3.36"
certificate = (
x509.CertificateBuilder()
.subject_name(name)
.issuer_name(name)
.public_key(key.public_key())
.serial_number(1)
.not_valid_before(datetime(2020, 1, 1, tzinfo=UTC))
.not_valid_after(datetime(2120, 1, 1, tzinfo=UTC))
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
.add_extension(x509.KeyUsage(True, False, False, False, False, False, False, None, None), critical=True)
.add_extension(x509.ExtendedKeyUsage([x509.ObjectIdentifier(document_signing)]), critical=False)
.add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(key.public_key()), critical=False)
.sign(key, None)
)
signer_info = C2paSignerInfo(
"ed25519",
certificate.public_bytes(serialization.Encoding.PEM),
key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()),
None,
)
source = tmp_path / "neutral.jpg"
output = tmp_path / "signed.jpg"
Image.new("RGB", (32, 32), (50, 100, 150)).save(source)
manifest = {
"claim_generator": "Synthetic C2PA profile test",
"assertions": [
{
"label": "c2pa.actions",
"data": {
"actions": [
{
"action": "c2pa.created",
"digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia",
}
]
},
}
],
}
with (
Context.from_dict({"trust": {"trust_config": document_signing}}) as context,
Signer.from_info(signer_info) as signer,
Builder(manifest, context=context) as builder,
source.open("rb") as input_stream,
output.open("wb") as output_stream,
):
builder.sign(signer, "image/jpeg", input_stream, output_stream)
return output
def test_c2pa_wrong_eku_rejects_a_real_synthetic_signature(tmp_path, monkeypatch):
from remove_ai_watermarks._internal import c2pa
source = _signed_c2pa_profile_sample(tmp_path)
accepted = c2pa.extract_c2pa_info(source)
assert accepted["c2pa_integrity"] == accepted["c2pa_signature"] == "valid"
assert accepted["c2pa_signer_validity"] == "valid"
assert accepted["c2pa_signer_trust"] == "untrusted"
assert identify(source, check_visible=False, check_invisible=False).is_ai_generated is True
# Exercise the real reader with an unrelated EKU, not a mocked validation result.
monkeypatch.setattr(c2pa, "_C2PA_ALLOWED_EKUS", "1.2.3.4.5.99999")
c2pa._manifest_json_cached.cache_clear()
c2pa._extract_c2pa_info_cached.cache_clear()
try:
rejected = c2pa.extract_c2pa_info(source)
assert rejected["c2pa_integrity"] == rejected["c2pa_signature"] == "valid"
assert rejected["c2pa_signer_validity"] == "invalid"
assert "signingCredential.invalid" in rejected["c2pa_validation_codes"]
assert identify(source, check_visible=False, check_invisible=False).is_ai_generated is None
finally:
c2pa._manifest_json_cached.cache_clear()
c2pa._extract_c2pa_info_cached.cache_clear()
+19 -1
View File
@@ -849,7 +849,7 @@ class TestC2paBufferScans:
assert c2pa_info_from_manifest_store(store).get("synthid_vendors") == expected
def test_google_photos_ai_edit_establishes_synthid_without_an_action(self):
"""Byte-scan path: Google's checker found SynthID on every Photos AI edit tested."""
"""Byte-scan path keeps the measured Photos inference, not a pixel verdict."""
from remove_ai_watermarks._internal.c2pa import synthid_evidence_vendors_in
edit = b"c2pa Google LLC Google Photos c2pa.deleted compositeWithTrainedAlgorithmicMedia"
@@ -1265,3 +1265,21 @@ def test_byte_fallback_reads_ai_disclosure_label():
info: dict = {}
assert _populate_registry_fields(b"jumb c2pa c2pa.ai-disclosure modelType human_validated", info) is True
assert info["ai_source_kind"] == "enhanced"
def test_soft_binding_scan_skips_regex_when_no_identifier_is_present(monkeypatch):
from remove_ai_watermarks._internal import c2pa
calls = []
original_search = c2pa.re.search
def recording_search(pattern, buffer, *args, **kwargs):
calls.append(pattern)
return original_search(pattern, buffer, *args, **kwargs)
monkeypatch.setattr(c2pa.re, "search", recording_search)
assert c2pa.soft_binding_registry_entries_in(b"ordinary metadata without an algorithm identifier") == ()
assert calls == []
entries = c2pa.soft_binding_registry_entries_in(b'"com.microsoft.invismark.1"')
assert [entry.algorithm for entry in entries] == ["com.microsoft.invismark.1"]
assert len(calls) == 1
+1 -1
View File
@@ -348,7 +348,7 @@ class TestVendorForStrength:
@pytest.mark.parametrize(("integrity", "expected"), [("valid", "google"), ("invalid", None)])
def test_google_photos_ai_edit_keeps_the_google_cohort(self, integrity, expected):
"""A Photos AI edit carries SynthID although its manifest records no SynthID action."""
"""A Photos AI edit keeps the inferred cohort without a recorded SynthID action."""
from remove_ai_watermarks._internal.c2pa import c2pa_info_from_manifest_store
from remove_ai_watermarks._internal.watermark_profiles import vendor_for_strength
+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]
+254
View File
@@ -0,0 +1,254 @@
"""SM3/SM2 verification and TC260 SecurityData label signatures."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from remove_ai_watermarks._internal import sm2, tc260_signature
from remove_ai_watermarks._internal.tc260_signature import check_tc260_signature
from remove_ai_watermarks.metadata import aigc_label
ROOT = Path(__file__).resolve().parents[1]
MINIMAX_VIDEOS = (
ROOT / "data" / "fixtures" / "provenance" / "higgsfield-hailuo-2-3.mp4",
ROOT / "data" / "fixtures" / "provenance" / "higgsfield-minimax-h3.mp4",
)
# OpenSSL test/recipes/30-test_evp_data/evppkey_sm2.txt, key SM2_key1: the public
# point of its PKCS#8 key and two "Verify" rows (digest e, DER signature r, s).
OPENSSL_KEY = (
0x393A2AD9E27E43ACAAAB85A19D3B85591E14406157175ADDD37BF77FF0CAF9ED,
0x37FCF3A8B4F55C038523261FCA6636AFE6A7AFA2A1482A606F5CB3A18B2E2A1E,
)
OPENSSL_VERIFY = (
(
"D7AD397F6FFA5D4F7F11E7217F241607DC30618C236D2C09C1B9EA8FDADEE2E8",
0xAB1DB64DE7C40EDBDE6651C9B8EBDB804673DB836E5D5C7FE15DCF9ED2725037,
0xEBA714451FF69B0BB930B379E192E7CD5FA6E3C41C7FBD8303B799AB54A54621,
),
(
"B1139602C6ECC9E15E2F3F9C635A1AFE737058BC15387479C1EA0D0B3D90E9E5",
0xE6E0414EBD3A656C35602AF14AB20287DBF30D57AF75C49A188ED4B42391F224,
0x2F54F277C606F4605E1CE9514947FFDDF94C67A539804A4ED17F852288BDBE2E,
),
)
def _minimax_label() -> dict[str, str]:
label = aigc_label(MINIMAX_VIDEOS[0])
assert label is not None
return label
def _with_security(label: dict[str, str], edit: object) -> dict[str, str]:
security = json.loads(label["ReservedCode1"])
edit(security["SecurityData"]) # type: ignore[operator]
return {**label, "ReservedCode1": json.dumps(security, separators=(",", ":"))}
class TestSm3:
@pytest.mark.parametrize(
("message", "digest"),
[
(b"abc", "66c7f0f462eeedd9d1f2d46bdc10e4e24167c4875cf2f7a2297da02b8f4ba8e0"),
(b"abcd" * 16, "debe9ff92275b8a138604889c18e5a4d6fdb70e5387e5765293dcba39c0c5732"),
],
)
def test_standard_vectors(self, message: bytes, digest: str) -> None:
# GB/T 32905-2016 appendix A examples 1 and 2.
assert sm2.sm3(message).hex() == digest
class TestSm2:
@pytest.mark.parametrize(("digest", "r", "s"), OPENSSL_VERIFY)
def test_openssl_vectors_verify(self, digest: str, r: int, s: int) -> None:
assert sm2.verify_digest(OPENSSL_KEY, bytes.fromhex(digest), r, s)
@pytest.mark.parametrize(("digest", "r", "s"), OPENSSL_VERIFY)
def test_a_changed_digest_or_signature_fails(self, digest: str, r: int, s: int) -> None:
changed = bytes.fromhex(digest)[:-1] + b"\x00"
assert not sm2.verify_digest(OPENSSL_KEY, changed, r, s)
assert not sm2.verify_digest(OPENSSL_KEY, bytes.fromhex(digest), r, s ^ 1)
def test_lift_x_returns_the_requested_parity(self) -> None:
for odd in (False, True):
point = sm2.lift_x(OPENSSL_KEY[0], odd=odd)
assert point is not None
assert sm2.on_curve(point)
assert (point[1] & 1) == odd
assert OPENSSL_KEY in {sm2.lift_x(OPENSSL_KEY[0], odd=False), sm2.lift_x(OPENSSL_KEY[0], odd=True)}
def test_an_off_curve_key_is_rejected(self) -> None:
digest, r, s = OPENSSL_VERIFY[0]
assert not sm2.verify_digest((OPENSSL_KEY[0], OPENSSL_KEY[1] + 1), bytes.fromhex(digest), r, s)
# Congruent to the real key mod P, so the point arithmetic alone would accept it.
assert not sm2.verify_digest((OPENSSL_KEY[0] + sm2.P, OPENSSL_KEY[1]), bytes.fromhex(digest), r, s)
class TestTc260Signature:
@pytest.mark.parametrize("path", MINIMAX_VIDEOS, ids=lambda p: p.name)
def test_real_minimax_labels_verify_against_the_pinned_key(self, path: Path) -> None:
label = aigc_label(path)
assert label is not None
result = check_tc260_signature(label)
assert result == tc260_signature.Tc260Signature(label="verified", signer="MiniMax")
assert result.describe() == "TC260 label signature verified (pinned MiniMax key)"
def test_a_changed_produce_id_fails_the_label_signature(self) -> None:
label = _minimax_label()
produce_id = label["ProduceID"]
altered = {**label, "ProduceID": produce_id[:-1] + ("0" if produce_id[-1] != "0" else "1")}
result = check_tc260_signature(altered)
assert result is not None
assert (result.label, result.signer) == ("failed", None)
def test_a_repeated_check_verifies_once(self, monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[bytes] = []
real_verify = sm2.verify
def counting(point: sm2.Point, message: bytes, signature: bytes) -> bool:
calls.append(message)
return real_verify(point, message, signature)
tc260_signature._verified.cache_clear()
monkeypatch.setattr(sm2, "verify", counting)
label = _minimax_label()
first, second = check_tc260_signature(label), check_tc260_signature(dict(label))
assert first == second
assert first is not None
assert first.label == "verified"
assert len(calls) == 1
def test_an_unpinned_key_proves_integrity_only(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(tc260_signature, "TC260_SIGNING_KEYS", {})
result = check_tc260_signature(_minimax_label())
assert result is not None
assert (result.label, result.signer) == ("verified", None)
assert "no pinned signer" in result.describe()
def test_a_pinned_signer_is_checked_with_the_pinned_point(self) -> None:
# The file's own parity claim is untrusted: an odd-y spelling of MiniMax's
# x coordinate still verifies, because the pinned even-y point is used.
def edit(security: dict[str, object]) -> None:
for entry in security["PubSD"]: # type: ignore[union-attr]
if entry["Type"] == "PubKey":
entry["KeyValue"] = "03" + entry["KeyValue"][-64:]
result = check_tc260_signature(_with_security(_minimax_label(), edit))
assert result is not None
assert (result.label, result.signer) == ("verified", "MiniMax")
def test_the_first_key_with_an_id_wins(self) -> None:
def edit(security: dict[str, object]) -> None:
foreign = {
"Type": "PubKey",
"AlgID": tc260_signature.SM3_WITH_SM2,
"KeyValue": "04" + f"{OPENSSL_KEY[0]:064x}{OPENSSL_KEY[1]:064x}",
}
security["PubSD"].append(foreign) # type: ignore[union-attr]
result = check_tc260_signature(_with_security(_minimax_label(), edit))
assert result is not None
assert (result.label, result.signer) == ("verified", "MiniMax")
def test_a_foreign_key_does_not_verify_the_label(self) -> None:
def edit(security: dict[str, object]) -> None:
for entry in security["PubSD"]: # type: ignore[union-attr]
if entry["Type"] == "PubKey":
entry["KeyValue"] = "04" + f"{OPENSSL_KEY[0]:064x}{OPENSSL_KEY[1]:064x}"
result = check_tc260_signature(_with_security(_minimax_label(), edit))
assert result is not None
assert (result.label, result.signer) == ("failed", None)
@pytest.mark.parametrize(
"reserved",
[
"",
"S7Wr/Evf+Mq+T3SWymUoO7naAs0WPNLrahOqG9WzR+o=",
'{"SecurityData":{"Type":"OTHER","Version":1}}',
"[1, 2]",
],
)
def test_reserved_codes_without_security_data_are_not_signatures(self, reserved: str) -> None:
assert check_tc260_signature({"Label": "1", "ReservedCode1": reserved}) is None
def _labeled_png(path: Path, label: dict[str, str], text: dict[str, str] | None = None) -> Path:
from PIL import Image
from PIL.PngImagePlugin import PngInfo
info = PngInfo()
info.add_text("AIGC", json.dumps(label, separators=(",", ":")))
for key, value in (text or {}).items():
info.add_text(key, value)
Image.new("RGB", (64, 64), (90, 120, 150)).save(path, pnginfo=info)
return path
class TestIdentifyIntegration:
def test_a_verified_pinned_signature_is_a_high_confidence_signal(self, tmp_path: Path) -> None:
from remove_ai_watermarks.identify import identify
report = identify(
_labeled_png(tmp_path / "signed.png", _minimax_label()), check_visible=False, check_invisible=False
)
signatures = [signal for signal in report.signals if signal.name == "aigc_signature"]
assert [(signal.detail, signal.confidence) for signal in signatures] == [
("TC260 label signature verified (pinned MiniMax key)", "high")
]
assert not any("does not verify" in caveat for caveat in report.caveats)
def test_pinned_signer_supplies_vendor_when_the_producer_lookup_is_unavailable(self, tmp_path, monkeypatch):
import remove_ai_watermarks.identify as identification
path = _labeled_png(
tmp_path / "mixed.png",
_minimax_label(),
{"XML:com.adobe.xmp": "<x:Iptc4xmpExt:AISystemUsed>OpenAI</x:Iptc4xmpExt:AISystemUsed>"},
)
monkeypatch.setattr(identification, "producer_for_code", lambda code: None)
report = identification.identify(path, check_visible=False, check_invisible=False)
assert any(s.name == "aigc_signature" for s in report.signals)
assert any("MiniMax" in clash and "OpenAI" in clash for clash in report.integrity_clashes)
# The same real signature under its embedded key cannot name a vendor.
monkeypatch.setattr(tc260_signature, "TC260_SIGNING_KEYS", {})
unpinned = identification.identify(path, check_visible=False, check_invisible=False)
assert not any(s.name == "aigc_signature" for s in unpinned.signals)
assert not unpinned.integrity_clashes
def test_a_failed_signature_is_a_caveat_not_a_signal(self, tmp_path: Path) -> None:
from remove_ai_watermarks.identify import identify
label = _minimax_label()
altered = {**label, "ProduceID": "0" * len(label["ProduceID"])}
report = identify(_labeled_png(tmp_path / "altered.png", altered), check_visible=False, check_invisible=False)
assert not any(signal.name == "aigc_signature" for signal in report.signals)
assert any("does not verify" in caveat for caveat in report.caveats)
def test_the_metadata_record_path_matches_the_file_path(self, tmp_path: Path) -> None:
from remove_ai_watermarks.identify import identify, identify_metadata_record
from remove_ai_watermarks.metadata import get_ai_metadata
from remove_ai_watermarks.metadata_record import collect_metadata_record
path = _labeled_png(tmp_path / "signed.png", _minimax_label())
record = json.loads(json.dumps(collect_metadata_record(path)))
from_record = identify_metadata_record(record, path=path)
from_file = identify(path, check_visible=False, check_invisible=False)
assert from_record.signals == from_file.signals
assert from_record.caveats == from_file.caveats
assert get_ai_metadata(path)["aigc_signature"] == "TC260 label signature verified (pinned MiniMax key)"
def test_a_minimax_video_carries_the_signature_marker(self) -> None:
from remove_ai_watermarks.video import identify_video
report = identify_video(MINIMAX_VIDEOS[0], check_visible=False)
assert report.metadata_markers["aigc_signature"] == "TC260 label signature verified (pinned MiniMax key)"
if __name__ == "__main__":
raise SystemExit(pytest.main([__file__]))
+51
View File
@@ -0,0 +1,51 @@
"""Shared TC260 producer identity across signatures, metadata, and visible marks."""
from __future__ import annotations
import pytest
from remove_ai_watermarks._internal.tc260_producers import TC260_PRODUCERS, producer_for_code
@pytest.mark.parametrize("producer", TC260_PRODUCERS, ids=lambda row: row.key)
def test_every_identity_resolves_across_consumers(producer):
from remove_ai_watermarks.video import _tc260_video_platform
from remove_ai_watermarks.watermark_registry import tc260_producer_mark
for code in producer.codes:
for spelling in (code, code.swapcase(), f" {code} "):
assert producer_for_code(spelling) is producer
assert _tc260_video_platform(spelling) == producer.video_platform
mark = tc260_producer_mark(spelling)
assert (mark.key if mark else None) == producer.image_mark
if len(code) == 18:
assert producer_for_code(f"0011{code}00001") is producer
assert producer_for_code(f"prefix-{producer.codes[0]}-suffix") is None
def test_producer_identities_and_image_marks_are_unambiguous():
from remove_ai_watermarks.watermark_registry import get_mark, known_marks
codes = [code.casefold() for row in TC260_PRODUCERS for code in row.codes]
assert len(codes) == len(set(codes))
assert len({row.key for row in TC260_PRODUCERS}) == len(TC260_PRODUCERS)
assert all((row.signer is None) == (row.signing_key_x is None) for row in TC260_PRODUCERS)
image_marks = [row.image_mark for row in TC260_PRODUCERS if row.image_mark]
assert len(image_marks) == len(set(image_marks))
for row in TC260_PRODUCERS:
if row.image_mark:
assert get_mark(row.image_mark).tc260_producer_codes == row.codes
assert all(
mark.key in image_marks for mark in known_marks() if mark.label_regime == "tc260" and mark.tc260_producer_codes
)
@pytest.mark.parametrize("mark", ["doubao", "hailuo", "vidu"])
def test_visible_video_confirmation_rejects_every_neighbor(mark):
from remove_ai_watermarks.video_visible import confirms_video_provenance
for row in TC260_PRODUCERS:
for code in row.codes:
assert confirms_video_provenance(mark, {"aigc_producer": code}) == (row.video_mark == mark)
for code in ("", "unknown", f"prefix-{mark}-suffix"):
assert not confirms_video_provenance(mark, {"aigc_producer": code})
+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."""
+96
View File
@@ -0,0 +1,96 @@
"""Publication-safe reconstructions of metadata observed in public web samples."""
from __future__ import annotations
import json
from pathlib import Path
import piexif
from PIL import Image
from remove_ai_watermarks._internal.tc260_signature import check_tc260_signature
from remove_ai_watermarks.identify import identify, identify_metadata_record
from remove_ai_watermarks.metadata import aigc_label, remove_ai_metadata
from remove_ai_watermarks.metadata_record import collect_metadata_record
def test_honor_yoyo_nested_security_data_round_trip(tmp_path: Path) -> None:
"""Nested RSA-shaped fields remain readable and removable without a trust claim."""
fixture = Path(__file__).resolve().parents[1] / "data/fixtures/synthetic/honor-yoyo-tc260.json"
payload = json.loads(fixture.read_text())
source = tmp_path / "synthetic-yoyo.jpg"
exif = piexif.dump({"Exif": {piexif.ExifIFD.UserComment: json.dumps(payload).encode()}})
Image.new("RGB", (32, 32), (50, 100, 150)).save(source, exif=exif)
label = aigc_label(source)
assert label is not None
assert label["ContentProducer"] == payload["AIGC"]["ContentProducer"]
assert "PubSd" in label["ReservedCode1"]
report = identify(source, check_visible=False, check_invisible=False)
assert report.is_ai_generated is True
signature = check_tc260_signature(label)
assert signature is not None
assert signature.label == "unsupported"
assert signature.signer is None # Synthetic RSA fields do not establish trust.
with Image.open(source) as image:
before = image.tobytes()
cleaned = remove_ai_metadata(source, tmp_path / "cleaned.jpg")
assert aigc_label(cleaned) is None
assert identify(cleaned, check_visible=False, check_invisible=False).is_ai_generated is None
with Image.open(cleaned) as image:
assert image.tobytes() == before
def test_nested_security_data_is_preserved_as_json() -> None:
from remove_ai_watermarks.metadata import parse_tc260_aigc_json
fixture = Path(__file__).resolve().parents[1] / "data/fixtures/synthetic/honor-yoyo-tc260.json"
payload = json.loads(fixture.read_text())
label = parse_tc260_aigc_json(json.dumps(payload["AIGC"]).encode())
assert label is not None
assert json.loads(label["ReservedCode1"]) == payload["AIGC"]["ReservedCode1"]
def test_honor_attribution_and_unsupported_content_signature(tmp_path: Path) -> None:
fixture = Path(__file__).resolve().parents[1] / "data/fixtures/synthetic/honor-yoyo-tc260.json"
payload = json.loads(fixture.read_text())
source = tmp_path / "yoyo.jpg"
exif = piexif.dump({"Exif": {piexif.ExifIFD.UserComment: json.dumps(payload).encode()}})
Image.new("RGB", (32, 32), (50, 100, 150)).save(source, exif=exif)
report = identify(source, check_visible=False, check_invisible=False)
assert report.platform == "Honor"
payload["AIGC"]["ContentProducer"] = "001191440300MA5G49LC9K1TK01"
exif = piexif.dump({"Exif": {piexif.ExifIFD.UserComment: json.dumps(payload).encode()}})
other_product = tmp_path / "other-honor-product.jpg"
Image.new("RGB", (32, 32), (50, 100, 150)).save(other_product, exif=exif)
assert identify(other_product, check_visible=False, check_invisible=False).platform == "Honor"
assert any("not supported" in caveat for caveat in report.caveats)
record = json.loads(json.dumps(collect_metadata_record(source)))
assert identify_metadata_record(record, path=source) == report
label = aigc_label(source)
assert label is not None
signature = check_tc260_signature(label)
assert signature is not None
assert signature.label == "unsupported"
assert signature.signer is None
def test_namespaced_nested_security_data_is_preserved_as_json() -> None:
from remove_ai_watermarks.metadata import aigc_label_from_metadata
fixture = Path(__file__).resolve().parents[1] / "data/fixtures/synthetic/honor-yoyo-tc260.json"
payload = json.loads(fixture.read_text())["AIGC"]
packet = b"<TC260:AIGC>" + json.dumps(payload).encode() + b"</TC260:AIGC>"
label = aigc_label_from_metadata(packet)
assert label is not None
assert json.loads(label["ReservedCode1"]) == payload["ReservedCode1"]
def test_unsupported_signature_is_distinct_from_absent_signature() -> None:
fixture = Path(__file__).resolve().parents[1] / "data/fixtures/synthetic/honor-yoyo-tc260.json"
payload = json.loads(fixture.read_text())["AIGC"]
payload["ReservedCode1"]["SecurityData"]["PubSd"] = []
result = check_tc260_signature({"ReservedCode1": json.dumps(payload["ReservedCode1"])})
assert result is not None
assert result.label == "absent"
Generated
+455 -423
View File
File diff suppressed because it is too large. Load diff