Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc63bfd7bf | ||
|
|
898f8be789 | ||
|
|
72752feb33 | ||
|
|
70adddf960 | ||
|
|
01a2665181 | ||
|
|
0c3e0fa415 | ||
|
|
b3b868153f | ||
|
|
304bd071c0 | ||
|
|
2e26df1f21 | ||
|
|
d800ec9455 | ||
|
|
ef7b972ed1 | ||
|
|
b96ede6f44 |
No files matched your search
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
@@ -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,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
|
||||
|
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
|
Before Width: | Height: | Size: 3.4 KiB |
@@ -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()
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"))
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
),
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"])
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]
|
||||
@@ -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__]))
|
||||
@@ -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})
|
||||
@@ -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
|
||||
@@ -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),
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"
|
||||