Compare commits
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.9"
|
||||
"version": "1.0.12"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
|
||||
@@ -27,21 +27,26 @@ 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 provider check may use the finite set of configured account slots and surfaces
|
||||
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.
|
||||
|
||||
Provider surface selection is API-first. OpenAI and Microsoft use their official API
|
||||
adapters before their Web surfaces when the requested media and local configuration are
|
||||
supported. Google and Meta currently use Web because this tooling has no usable API route
|
||||
for them. Web remains an explicit second surface, never an automatic retry after an API
|
||||
refusal, quota response, transport error, or indeterminate verdict.
|
||||
for them. A request to use a provider covers every configured surface, including Web,
|
||||
without a second confirmation. An API refusal, quota response, transport error, or
|
||||
indeterminate verdict stays on that surface and never silently submits the file to Web.
|
||||
|
||||
Google Web oracle automation uses only the user's existing authenticated real Chrome
|
||||
session and explicit `/u/N/` account index. OpenAI, Microsoft, and Meta Web automation
|
||||
session. Gemini uses an explicit `/u/N/` account index. The SynthID Detector at
|
||||
`https://synthid.com/` (`synthid-web`) uses the same Chrome profile and does not take a
|
||||
`/u/N/` index; its terms forbid driving the page as an automated API. OpenAI, Microsoft, and Meta Web automation
|
||||
uses isolated Playwright contexts; it must never attach to or copy state from the user's
|
||||
real browser. Proxy credentials are read only through a slot's named environment variable
|
||||
and never enter a manifest or log. Dotenv interpolation may derive routed proxy URLs from
|
||||
@@ -77,9 +82,12 @@ The same rule applies to install hints: name the extra that actually makes the c
|
||||
|
||||
Run `bash maintain.sh` from the repository root. The authoritative type gate is scoped to `src/`; full-project Pyright can exhaust Node memory on the ML dependency graph.
|
||||
|
||||
The full gate outlasts the agent's 10-minute foreground shell cap, which kills it mid-suite. Run it detached under `nohup caffeinate -dimu` with its exit code written to a file and poll that file. To stop a stray run, kill its own PID, never `pkill -f pytest`, which also takes down other sessions' suites on the machine.
|
||||
|
||||
The security scanner's exit status is authoritative, including a failure after a success
|
||||
message. `tests/test_maintenance.py` checks clean, vulnerable, and broken scanner
|
||||
runs through the real shell entry point. Recheck unresolved advisories using the
|
||||
runs through the real shell entry point, and pins the CI `security` job to the
|
||||
same scanner line with no `continue-on-error`. Recheck unresolved advisories using the
|
||||
procedure in `docs/development.md`; do not revive the old text-matching bypass.
|
||||
|
||||
Boundary modules for cv2, Torch, and Diffusers may carry narrow per-file relaxations for unknown third-party types. Keep pure-logic files strict, preserve the local piexif stub, and fix real errors before widening a pragma.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
name: provider-oracles
|
||||
description: Run hash-bound Google, OpenAI, Microsoft, or Meta watermark-oracle checks for this repository. Use for provider verification, multi-account Gemini checks, isolated Playwright checks, or recording oracle evidence.
|
||||
metadata:
|
||||
internal: true
|
||||
---
|
||||
|
||||
# Provider oracles
|
||||
@@ -14,22 +16,39 @@ 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, or a new network route require authorization.
|
||||
The oracles and slots already configured for the requested provider may be used
|
||||
again, including their Web surfaces, without a second confirmation. Account
|
||||
continuation does not authorize changing candidate pixels or erasing failures.
|
||||
|
||||
## Default surface priority
|
||||
|
||||
Prefer an official API adapter whenever that provider, media type, and configured
|
||||
slot support it. Use Web only when no usable API route exists or the operator
|
||||
explicitly requests the Web surface as a second check. For OpenAI and Microsoft,
|
||||
the default plans are API then Web. Google and Meta currently have only usable
|
||||
Web routes in this tooling. Inspect the order with `plan PROVIDER`.
|
||||
slot support it. For OpenAI and Microsoft, the default plans are API then Web.
|
||||
Google and Meta currently have only usable Web routes in this tooling. Google's
|
||||
routes are `gemini-web` and `synthid-web`. Inspect the order with `plan PROVIDER`.
|
||||
The same request may use every configured surface.
|
||||
|
||||
The priority is a selection policy, not automatic failover. An API refusal,
|
||||
quota response, transport error, or indeterminate verdict must be recorded and
|
||||
reported. Do not submit the same artifact to Web unless the operator separately
|
||||
authorizes that next surface.
|
||||
reported. The same request already covers every configured surface of that
|
||||
provider, including Web. Run Web as its own acknowledged check and keep its
|
||||
verdict separate. Do not replace the API result with the Web result.
|
||||
|
||||
## Route by surface
|
||||
|
||||
@@ -44,18 +63,51 @@ Playwright CLI runner, copied cookies, or a new login.
|
||||
real Chrome profile and navigate directly to
|
||||
`https://gemini.google.com/u/<google_account_index>/app`.
|
||||
3. Prepare an immutable `gemini-web` batch outside the repository.
|
||||
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.
|
||||
5. Attach one prepared upload and ask: `Was this image/video/audio created or
|
||||
edited by Google AI?`
|
||||
For every image check, visually inspect the source first. Remove every visible
|
||||
AI-provider logo or label with the project's visible-mark remover, derive all
|
||||
paired controls and candidates from that cleaned image, and let `prepare` strip
|
||||
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. 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
|
||||
Google-AI-origin question; it permits visual-content reasoning instead of a
|
||||
SynthID verdict.
|
||||
6. Read the settled SynthID tool outcome, not the surrounding model reasoning.
|
||||
Preserve unclear, quota, and refusal wording rather than interpreting it as
|
||||
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.
|
||||
|
||||
### SynthID Detector
|
||||
|
||||
Use the user's real authenticated Chrome. Do not use isolated Playwright: the
|
||||
portal's additional terms forbid using the page as an automated API, bulk
|
||||
verification, and quota evasion.
|
||||
|
||||
1. Validate a `synthid-web` slot. It names `browser_profile` and `account_label`.
|
||||
It does not use `google_account_index` or a proxy variable.
|
||||
2. Prepare an immutable batch outside the repository. Inspect images the same
|
||||
way as for Gemini, and do not submit a file that still shows a provider logo.
|
||||
3. Open `https://synthid.com/` in the named Chrome profile. Accept the terms
|
||||
dialog when it is shown. Do not start a new login. If the page requires
|
||||
sign-in and that profile has no session, leave the row unsubmitted.
|
||||
4. Attach one prepared upload through the page's own file control.
|
||||
5. Read the settled label. `Watermark detected` is `detected`. `Watermark not
|
||||
detected` is `not_detected`. Quota, sign-in, and unclear wording stay
|
||||
`refused` or `indeterminate`.
|
||||
6. Record the verbatim response. The result covers partner SynthID from Google
|
||||
DeepMind, Nvidia, OpenAI, and Kakao, and it does not name which partner. Do
|
||||
not record it as a Gemini-only or OpenAI-only verdict.
|
||||
|
||||
### OpenAI, Microsoft, and Meta Web
|
||||
|
||||
@@ -101,8 +153,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
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
name: signal-discovery
|
||||
description: Discover missing AI provenance and watermark coverage in the remove-ai-watermarks library from fresh or historical corpora, then validate detection and removal parity. Use for corpus gap mining, periodic signal audits, new-provider discovery, or deciding whether old retained data must be replayed. Do not use for ordinary per-file identification or removal.
|
||||
metadata:
|
||||
internal: true
|
||||
---
|
||||
|
||||
# Signal discovery
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "remove-ai-watermarks",
|
||||
"version": "1.0.12",
|
||||
"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",
|
||||
"url": "https://github.com/wiltodelta"
|
||||
},
|
||||
"homepage": "https://raiw.cc",
|
||||
"repository": "https://github.com/wiltodelta/remove-ai-watermarks",
|
||||
"license": "Apache-2.0",
|
||||
"keywords": [
|
||||
"ai-watermark",
|
||||
"watermark-removal",
|
||||
"c2pa",
|
||||
"synthid",
|
||||
"provenance",
|
||||
"gemini",
|
||||
"sora"
|
||||
],
|
||||
"skills": "./skills/",
|
||||
"interface": {
|
||||
"displayName": "Remove AI Watermarks",
|
||||
"shortDescription": "Identify and remove AI watermarks and provenance metadata",
|
||||
"longDescription": "Runs the open-source remove-ai-watermarks CLI locally to identify C2PA, IPTC, EXIF and TC260 provenance, visible AI-generator labels and supported invisible watermarks, then removes them from images and video the user generated or edited. It does not remove stock, agency or other third-party marks.",
|
||||
"developerName": "wiltodelta",
|
||||
"category": "Productivity",
|
||||
"capabilities": ["Read", "Write"],
|
||||
"websiteURL": "https://github.com/wiltodelta/remove-ai-watermarks",
|
||||
"defaultPrompt": [
|
||||
"Check this image for AI watermarks and provenance metadata",
|
||||
"Remove the AI metadata from my generated video without re-encoding it",
|
||||
"Remove the visible AI label from the corner of my image"
|
||||
],
|
||||
"brandColor": "#A78BFA",
|
||||
"composerIcon": "./assets/icon.svg",
|
||||
"logo": "./assets/icon.svg"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
# Local state and secrets never belong in a plugin bundle.
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.oracle-slots.json
|
||||
.local-eval/
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
# Generated media and model artifacts.
|
||||
data/
|
||||
*.mp4
|
||||
*.onnx
|
||||
*.safetensors
|
||||
*.pt
|
||||
@@ -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"
|
||||
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
PYTHON
|
||||
|
||||
- name: Upload distributed release identity
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: distributed-release-${{ github.run_attempt }}
|
||||
path: release-identity.json
|
||||
@@ -104,7 +104,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the tap
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: wiltodelta/homebrew-tap
|
||||
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
@@ -227,7 +227,7 @@ jobs:
|
||||
needs: resolve
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Publish the agent skill when its version moved
|
||||
env:
|
||||
CLAWHUB_TOKEN: ${{ secrets.CLAWHUB_TOKEN }}
|
||||
|
||||
@@ -21,8 +21,8 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Download derivative weights
|
||||
|
||||
@@ -36,9 +36,9 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
|
||||
@@ -22,9 +22,9 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
|
||||
@@ -15,9 +15,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
environment: pypi
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
|
||||
- name: Verify release tag matches package version
|
||||
run: |
|
||||
|
||||
@@ -32,8 +32,8 @@ jobs:
|
||||
skill:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
- name: Sync dev environment
|
||||
run: uv sync --frozen --extra dev
|
||||
- name: Agent skill packaging tests
|
||||
|
||||
@@ -16,8 +16,8 @@ jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
- name: Sync dev environment
|
||||
run: uv sync --frozen --extra dev
|
||||
- name: Check C2PA soft-binding registry snapshot
|
||||
@@ -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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
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 }}
|
||||
@@ -56,8 +69,8 @@ jobs:
|
||||
- os: windows-latest
|
||||
python-version: "3.14"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Sync dev environment
|
||||
@@ -69,8 +82,8 @@ jobs:
|
||||
name: versioned transport contracts py3.14
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
with:
|
||||
python-version: "3.14"
|
||||
- name: Create clean runtime contract environment
|
||||
@@ -91,8 +104,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Install ffmpeg
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
steps:
|
||||
- name: Download distributed release identity
|
||||
if: ${{ github.event_name == 'workflow_run' }}
|
||||
uses: actions/download-artifact@v8
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
pattern: distributed-release-*
|
||||
path: distribution-identities
|
||||
|
||||
@@ -40,11 +40,13 @@ Before a release, read [`docs/release-and-distribution.md`](docs/release-and-dis
|
||||
|
||||
The published Agent Skill is [`skills/remove-ai-watermarks/`](skills/remove-ai-watermarks/). Update it in the same change as any CLI edit that alters commands, extras, exit codes, mark keys, or the intended-use boundary. Install and publish steps: [`docs/agent-skill.md`](docs/agent-skill.md).
|
||||
|
||||
The same skill tree is the Codex plugin (`.codex-plugin/plugin.json`). `remove-ai-watermarks mcp` is a standard-library MCP server that runs one CLI command per tool call; keep it dependency-free, because the MCP registry launches it as a bare `uvx remove-ai-watermarks mcp` that cannot add extras ([`docs/mcp-server.md`](docs/mcp-server.md)).
|
||||
|
||||
## Module architecture
|
||||
|
||||
[`docs/module-internals.md`](docs/module-internals.md) is the canonical per-module map, including design decisions, thresholds, calibration history, incident records, and regression guards. Read the relevant section before changing a subsystem.
|
||||
|
||||
Research and current constraints are routed through [`docs/index.md`](docs/index.md), especially [`docs/known-limitations.md`](docs/known-limitations.md), [`docs/supported-signals.md`](docs/supported-signals.md), [`docs/synthid.md`](docs/synthid.md), and [`docs/watermarking-landscape.md`](docs/watermarking-landscape.md). Pixel photo classification is [`docs/photo-classify.md`](docs/photo-classify.md); the separate OpenAI/Google/unknown export-pipeline signal is [`docs/source-classify.md`](docs/source-classify.md). Neither runs inside `identify` or constitutes SynthID detection. Classifier research is split between general [`AI-generated image classifiers`](docs/ai-generated-image-classifiers.md) and [`SynthID source classifiers`](docs/synthid-classifiers.md). Other SynthID campaign logs are [`docs/synthid-detector-research.md`](docs/synthid-detector-research.md) and [`docs/synthid-removal-research.md`](docs/synthid-removal-research.md). The pre-split chronological archive is [`docs/synthid-detector-removal-plan.md`](docs/synthid-detector-removal-plan.md). The benchmark kernel and its pinned local oracles (AudioSeal, VideoSeal) cover the audio/video watermark cohorts and research studies: [`docs/benchmark-kernel.md`](docs/benchmark-kernel.md).
|
||||
Research and current constraints are routed through [`docs/index.md`](docs/index.md), especially [`docs/known-limitations.md`](docs/known-limitations.md), [`docs/supported-signals.md`](docs/supported-signals.md), [`docs/synthid.md`](docs/synthid.md), and [`docs/watermarking-landscape.md`](docs/watermarking-landscape.md). Pixel photo classification is [`docs/photo-classify.md`](docs/photo-classify.md); the separate OpenAI/Google/unknown export-pipeline signal is [`docs/source-classify.md`](docs/source-classify.md). Neither runs inside `identify` or constitutes SynthID detection. Classifier research is split between general [`AI-generated image classifiers`](docs/ai-generated-image-classifiers.md) and [`SynthID source classifiers`](docs/synthid-classifiers.md). Other SynthID campaign logs are [`docs/synthid-detector-research.md`](docs/synthid-detector-research.md) and [`docs/synthid-removal-research.md`](docs/synthid-removal-research.md). The pre-split chronological archive is [`docs/synthid-detector-removal-plan.md`](docs/synthid-detector-removal-plan.md). The benchmark kernel and its pinned local oracles (AudioSeal, Perth, PixelSeal, VideoSeal) cover image, audio, and video watermark cohorts and research studies: [`docs/benchmark-kernel.md`](docs/benchmark-kernel.md).
|
||||
|
||||
## Data safety
|
||||
|
||||
|
||||
@@ -6,6 +6,15 @@ Remove AI provenance marks from images and video you generated yourself:
|
||||
- invisible pixel watermarks through direct local-format disruption or diffusion regeneration;
|
||||
- C2PA, EXIF, XMP, IPTC, and related AI metadata.
|
||||
|
||||
| Before | After |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|
||||
```bash
|
||||
uv tool install "remove-ai-watermarks[visible]" # CLI for visible marks and metadata
|
||||
npx skills add wiltodelta/remove-ai-watermarks # agent skill for Claude Code, Codex, Cursor, and other agents
|
||||
```
|
||||
|
||||
Video support covers provenance identification, complete visible-plus-metadata
|
||||
cleaning, directory batches, visible Sora, Veo, Seedance, Doubao, Dola,
|
||||
Hailuo AI, and Kling AI mark removal, and oracle-certified VAE regeneration for video SynthID
|
||||
@@ -274,11 +283,8 @@ features, and development setup.
|
||||
|
||||
### Visible Gemini mark
|
||||
|
||||
| Before | After |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|
||||
The `after` raster is generated from the tracked `before` raster by the public path:
|
||||
The `after` raster at the top of this page is generated from the tracked `before`
|
||||
raster by the public path:
|
||||
|
||||
```bash
|
||||
uv run remove-ai-watermarks visible demo_banana_before.png \
|
||||
@@ -370,11 +376,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
|
||||
@@ -476,9 +482,23 @@ Claude Code can add this repository as a plugin marketplace:
|
||||
/plugin install remove-ai-watermarks@remove-ai-watermarks
|
||||
```
|
||||
|
||||
Codex reads the same skill through [`.codex-plugin/plugin.json`](.codex-plugin/plugin.json).
|
||||
The skill refuses stock-agency and other third-party paid-asset marks. See
|
||||
[the agent skill guide](docs/agent-skill.md).
|
||||
|
||||
## MCP server
|
||||
|
||||
`remove-ai-watermarks mcp` serves `identify`, `remove` and `batch` as Model
|
||||
Context Protocol tools over stdio, each a thin wrapper around one CLI command:
|
||||
|
||||
```json
|
||||
{"mcpServers": {"remove-ai-watermarks": {"command": "uvx", "args": ["remove-ai-watermarks", "mcp"]}}}
|
||||
```
|
||||
|
||||
See [the MCP server guide](docs/mcp-server.md).
|
||||
|
||||
<!-- mcp-name: io.github.wiltodelta/remove-ai-watermarks -->
|
||||
|
||||
## ComfyUI
|
||||
|
||||
The separate
|
||||
@@ -490,6 +510,8 @@ invisible removal.
|
||||
|
||||
- A missing local signal means unknown, not clean. Proprietary pixel
|
||||
watermarks may remain after metadata has been stripped.
|
||||
[What each path leaves behind](docs/known-limitations.md#what-each-path-leaves-behind)
|
||||
lists, per command, what may remain and how to check it.
|
||||
- Visible removal reconstructs a small region. Results depend on the background
|
||||
and selected fill backend.
|
||||
- Invisible removal changes the whole image and may alter faces, text, or fine
|
||||
@@ -571,7 +593,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).
|
||||
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="512" height="512" fill="none" stroke="url(#lg)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<!--
|
||||
The raiw.cc brand mark: the Lucide "sparkle" and "x" glyphs.
|
||||
Lucide, https://lucide.dev, ISC License, Copyright (c) Lucide Contributors.
|
||||
Permission to use, copy, modify, and/or distribute this software for any purpose
|
||||
with or without fee is hereby granted, provided that the above copyright notice
|
||||
and this permission notice appear in all copies.
|
||||
-->
|
||||
<path d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z" fill="url(#lg)" fill-opacity="0.15"/>
|
||||
<g transform="translate(1 17) scale(0.3)" stroke-width="5">
|
||||
<path d="M18 6 6 18"/>
|
||||
<path d="m6 6 12 12"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="lg" x1="0" y1="0" x2="24" y2="24">
|
||||
<stop stop-color="#a78bfa"/>
|
||||
<stop offset="1" stop-color="#06b6d4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -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
|
||||
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 container provenance metadata, but the Kling H.264 SEI tag (kling_sei) is readable",Kling AI terms; noncommercial fixture
|
||||
|
@@ -26,6 +26,11 @@ SkillsMP indexes public GitHub `SKILL.md` files and does not need a separate
|
||||
upload. ClawHub and the Claude community marketplace are explicit publishes;
|
||||
see [Release and distribution](release-and-distribution.md#agent-skill).
|
||||
|
||||
Every maintainer skill under `.claude/skills/` carries `metadata.internal: true`.
|
||||
The skills CLI discovers every `SKILL.md` in that directory, so without the flag
|
||||
`npx skills add` offers it and skills.sh lists it beside the published skill.
|
||||
`tests/test_agent_skill.py` fails on a project skill that lacks it.
|
||||
|
||||
## Where the skill can actually be listed
|
||||
|
||||
Most skill catalogs are install registries, not discovery engines. A listing
|
||||
@@ -65,6 +70,17 @@ Do these in order after the skill is on the default branch.
|
||||
are indexed via clawskills.sh, and the ClawHub prerequisite in step 6 is
|
||||
now met. The list still requires proven real-world usage. Re-attempt once
|
||||
the ClawHub listing shows organic installs.
|
||||
- `hashgraph-online/awesome-codex-plugins`: READY, not submitted. It lists
|
||||
only Codex plugins, so it needs `.codex-plugin/plugin.json` and the icon in
|
||||
`assets/icon.svg`. Its contribution CI runs `scripts/validate-plugin-pr.py`
|
||||
against the repository's `HEAD.zip`, then the HOL plugin scanner with a
|
||||
minimum score of 80 and no high finding; that scan job is
|
||||
`continue-on-error`, so it reports rather than blocks. The scanner's `\beval\s*\(`
|
||||
rule also matches PyTorch's `.eval()`, which is why the code calls
|
||||
`.train(False)`. On 2026-10-17 `plugin-scanner` 3.49.0 scored the tree 86
|
||||
with no high finding. The remaining loss is a tracked video over the
|
||||
scanner's 16 MiB file budget. Recheck with
|
||||
`uvx --from plugin-scanner==3.49.0 plugin-scanner scan . --min-score 80 --fail-on-severity high`.
|
||||
5. **Claude community marketplace.** Form at
|
||||
[platform.claude.com/plugins/submit](https://platform.claude.com/plugins/submit)
|
||||
(individuals) or the claude.ai directory form (Team/Enterprise). Validate
|
||||
@@ -186,8 +202,8 @@ that names a command, flag or value the previous release lacked. Left behind, it
|
||||
CLI is current right before it types an option that build rejects.
|
||||
|
||||
Bump `metadata.version` in `SKILL.md` plus `version` in
|
||||
`skills/.claude-plugin/plugin.json` and `.claude-plugin/marketplace.json` when
|
||||
the skill instructions change. That version is independent of the PyPI package
|
||||
`skills/.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json` and
|
||||
`.codex-plugin/plugin.json` when the skill instructions change. That version is independent of the PyPI package
|
||||
version.
|
||||
|
||||
## Behavior evals
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
`scripts/watermark_benchmark.py` is a development-only runner for reproducible
|
||||
image-, audio-, and video-watermark experiments. It calls the existing local
|
||||
DWT-DCT and TrustMark detectors plus the revision-pinned AudioSeal and
|
||||
VideoSeal oracles through thin adapters. It does not add a runtime command,
|
||||
download a corpus, or contact a provenance oracle or provider API. The
|
||||
DWT-DCT and TrustMark detectors plus the revision-pinned AudioSeal, Perth,
|
||||
PixelSeal, and VideoSeal oracles through thin adapters. It does not add a
|
||||
runtime command, download a corpus, or contact a provenance oracle or provider API. The
|
||||
optional TrustMark package can fetch its official Adobe model weights when its
|
||||
local package cache is incomplete, the audioseal adapter fetches its pinned
|
||||
checkpoints the same way, and the videoseal adapter downloads one pinned
|
||||
TorchScript file into a user cache; prepare those dependencies before an
|
||||
offline run.
|
||||
checkpoints the same way, and the PixelSeal and VideoSeal adapters download
|
||||
pinned weights into a user cache; prepare those dependencies before an offline
|
||||
run. Perth's checkpoint is bundled in its pinned source revision.
|
||||
|
||||
The kernel answers three different questions and never merges their answers:
|
||||
|
||||
@@ -41,9 +41,9 @@ The fixed fields are:
|
||||
| `case_id` | Unique case identity. |
|
||||
| `pair_id` | Groups related clean, marked, attacked, and removed cases. |
|
||||
| `media_type` | `image`, `audio`, or `video` in schema v1. |
|
||||
| `adapter` | `dwt-dct` or `trustmark` for `image`; `audioseal` for `audio`; `videoseal` for `video`. The loader rejects a known adapter named against the wrong media type. |
|
||||
| `adapter` | `dwt-dct`, `trustmark`, or `pixelseal` for `image`; `audioseal` or `perth` for `audio`; `videoseal` for `video`. The loader rejects a known adapter named against the wrong media type. |
|
||||
| `arm` | `positive`, `matched_negative`, `wrong_key`, or `hard_negative`. |
|
||||
| `state` | `clean`, `marked`, `attacked`, `removed`, or `forged`. `forged` names an artifact carrying a watermark with a message different from the adapter oracle's fixed one. Expected detection depends on the adapter: VideoSeal checks the fixed message, while AudioSeal's presence rule can still report `detected` for a foreign message. The study records message accuracy separately. |
|
||||
| `state` | `clean`, `marked`, `attacked`, `removed`, or `forged`. `forged` names an artifact carrying a watermark with a message different from the adapter oracle's fixed one. Expected detection depends on the adapter: PixelSeal and VideoSeal check the fixed message, while AudioSeal's presence rule can still report `detected` for a foreign message. Perth has an implicit fixed mark and no message payload, so it has no meaningful forged arm. The study records message accuracy separately where the scheme exposes a message. |
|
||||
| `path`, `sha256` | Artifact path and pinned content digest. |
|
||||
| `reference_path`, `reference_sha256` | Both strings or both `null`; the fidelity reference. |
|
||||
| `source_revision` | Corpus, generator, or acquisition revision. |
|
||||
@@ -74,6 +74,10 @@ dirty, and SHA-256 digests of the benchmark kernel and adapter source. Detector
|
||||
dependency absence is `unavailable`; an undecodable artifact or adapter
|
||||
exception is `error`. Neither is counted as `not_detected`.
|
||||
|
||||
Message-matched adapters record `detection.message_bit_accuracy`. Score-based
|
||||
presence adapters record `detection.score`. A missing field means the adapter
|
||||
does not expose that quantity; it is not a numeric zero.
|
||||
|
||||
`detection.adapter_elapsed_ms` measures the wall time of the named detector
|
||||
adapter call only. Image decoding and fidelity calculation are outside that
|
||||
interval. The value is `null` when the adapter was unavailable or the artifact
|
||||
@@ -111,6 +115,21 @@ as a binary string, so a case whose payload differs from the embedded one is
|
||||
still `detected` with a different label, never `not_detected`. A `wrong_key`
|
||||
arm for this adapter is therefore a different-message row, not a clean row.
|
||||
|
||||
The `perth` adapter reads `scripts/perth_oracle.py`, which pins Resemble AI's
|
||||
[Perth source](https://github.com/resemble-ai/Perth/tree/ff1c8ac55a976971245cdd53c18d6131ca00d993)
|
||||
at commit `ff1c8ac` and verifies the bundled
|
||||
`perth_net_250000.pth.tar` checkpoint as
|
||||
`sha256:a15bce457ebc53ce5e6c9c3f11df78cf7ee2bf9cdab0a798902135b4c4027670`.
|
||||
The upstream package name is `resemble-perth`, the pinned source declares
|
||||
version 1.1.0, and its [license is MIT](https://github.com/resemble-ai/Perth/blob/ff1c8ac55a976971245cdd53c18d6131ca00d993/LICENSE).
|
||||
That permits a public, development-only oracle. The published 1.0.1 wheel
|
||||
pins an obsolete Torch stack, so the benchmark installs the exact source
|
||||
revision without adding it to project or runtime dependencies. Perth v1 is an
|
||||
implicit presence mark: the detector exposes one confidence and upstream's
|
||||
strictly-above-0.5 decision rule, but no user message. The kernel therefore
|
||||
records `score`, uses `label: perth-v1` only above threshold, and leaves
|
||||
message accuracy not applicable.
|
||||
|
||||
Audio fidelity compares the decoded sample arrays against the explicit
|
||||
reference: identical decoded samples produce `snr_db: null` with
|
||||
`snr_status: unbounded_identical`, never a misleading numeric zero; a silent
|
||||
@@ -118,6 +137,91 @@ reference reports `zero_reference_power`; a length mismatch stays an explicit
|
||||
`incomparable` state. SNR here is reference signal power over error power, the
|
||||
sample-domain analogue of the image PSNR contract.
|
||||
|
||||
## Perth and PixelSeal local oracle study
|
||||
|
||||
`scripts/perth_pixelseal_study.py` builds a local-only study from three
|
||||
deterministic synthetic audio carriers, three macOS system-voice speech
|
||||
carriers, and two publication-cleared tracked image carriers. It never calls a
|
||||
provider oracle or uploads an artifact. Generated media, the strict manifest,
|
||||
and results stay outside git.
|
||||
|
||||
PixelSeal comes from Meta's
|
||||
[VideoSeal source tree](https://github.com/facebookresearch/videoseal/tree/870ca7fb33578b90f14c602016b6c2788096226e),
|
||||
package `videoseal`, at the same exact `870ca7f` revision as the VideoSeal
|
||||
oracle. The project and weights are
|
||||
[MIT licensed](https://github.com/facebookresearch/videoseal/blob/870ca7fb33578b90f14c602016b6c2788096226e/LICENSE).
|
||||
The adapter verifies the official
|
||||
[PixelSeal model card](https://github.com/facebookresearch/videoseal/blob/870ca7fb33578b90f14c602016b6c2788096226e/videoseal/cards/pixelseal.yaml)
|
||||
as `sha256:96c4a448f7d81810780e5bde5a51b039db544b7ec2baf65a605302c1b17362c9`
|
||||
and its 1,237,429,197-byte `checkpoint.pth` as
|
||||
`sha256:0c5665cff20eb6ce1b5aaa7d91c19dafb418bfee32d02dd3344e4ed60d9d75bd`.
|
||||
The source metadata declares package version 1.0. The 1.0.1 PyPI wheel omits
|
||||
the PixelSeal card, so this development oracle likewise requires the exact
|
||||
source revision and remains outside installed CLI and library paths. Its
|
||||
explicit matched-message decision is 256-bit accuracy at or above 0.9, aligned
|
||||
with the VideoSeal adapter; this is the benchmark's rule, not a general
|
||||
watermark-presence classifier.
|
||||
|
||||
```bash
|
||||
uv run python scripts/perth_pixelseal_study.py \
|
||||
--output-dir .local-eval/perth-pixelseal-2026-09-28-v4
|
||||
```
|
||||
|
||||
### Development baseline, 2026-09-28
|
||||
|
||||
The final local run used recipe
|
||||
`sha256:b43953678294d97a72bb323623797ec8d7b8e476b101485e9ecfefbe02353c0e`,
|
||||
kernel
|
||||
`sha256:26dce20f42f7131daa4885eed073baf7372ebb07c0e8ccb4ec50cd603da082bf`,
|
||||
Perth oracle
|
||||
`sha256:a99b9b64a7667b2ee46ac8b57e7c29eec6b13646e3297856e67816984f1a3a9c`,
|
||||
and PixelSeal oracle
|
||||
`sha256:fc15c444e21404c1413d94251e7746964a347d9596faecfdd94b5e45ab2d0949`.
|
||||
Its manifest is
|
||||
`sha256:ecdcca929a73b127db3682636f8a2322fc128abb632f3b779e415a7130e0150d`,
|
||||
case rows are
|
||||
`sha256:ec0a794f5a6580e7bc50e122e1ebae0ea193c9b35b69c7408126d00304c45b38`,
|
||||
and study metadata is
|
||||
`sha256:5906d8ef48132f0fb371e052870a67a682d4b85c7c5c0e23730a951dca970cc1`.
|
||||
All asserted rows matched their expected result; unresolved synthetic Perth
|
||||
positives remain measurements rather than hidden failures.
|
||||
|
||||
| Adapter and state | Cases | Detected | Not detected | Message accuracy or score |
|
||||
| --- | ---: | ---: | ---: | --- |
|
||||
| PixelSeal clean | 2 | 0 | 2 | 0.473-0.504 fixed-message accuracy |
|
||||
| PixelSeal marked | 2 | 2 | 0 | 1.000 on both |
|
||||
| PixelSeal JPEG q90 | 2 | 2 | 0 | 0.996-1.000 |
|
||||
| PixelSeal foreign-message forged | 2 | 0 | 2 | 0.469-0.473 against the fixed message |
|
||||
| Perth speech clean | 3 | 0 | 3 | 0-0.016 confidence |
|
||||
| Perth speech marked | 3 | 3 | 0 | 1.000 confidence |
|
||||
| Perth synthetic clean | 3 | 0 | 3 | 0-0.000083 confidence |
|
||||
| Perth synthetic marked | 3 | 1 | 2 | 0.190, 0.246, and 1.000 confidence |
|
||||
|
||||
PixelSeal embedding measured 46.15-49.46 dB PSNR against the two clean
|
||||
carriers. JPEG q90 retained the mark at 37.19-39.87 dB PSNR. The fixed-message
|
||||
verifier rejected both images carrying the foreign message; that proves only
|
||||
matched-key rejection, not absence of a PixelSeal payload.
|
||||
|
||||
Perth was reliable on the in-domain speech sample but carrier-dependent on the
|
||||
deliberately out-of-domain synthetic set: only white noise crossed the rule,
|
||||
while the tone stack and pinkish noise remained at 0.190 and 0.246. The three
|
||||
synthetic marked rows measured 12.91-14.98 dB SNR. Perth's internal 16 to 32 to
|
||||
16 kHz resampling shortened the three marked speech outputs by 53-104 samples,
|
||||
so the kernel correctly records their SNR as `incomparable` rather than
|
||||
silently trimming them.
|
||||
|
||||
The production visible-video cleaning path removed the visible mark from all
|
||||
24 frames while copying the marked AAC packets byte-for-byte. Both the source
|
||||
and cleaned audio scored 1.000, and the source and output packet streams share
|
||||
`sha256:d6be870baea63ef8dceea97b2c04b27a1a35ba3bd1500cd022ccb167fd0795c6`.
|
||||
This directly confirms that the current video pipeline does not affect Perth.
|
||||
|
||||
PixelSeal removal was not run on this Apple Silicon host. The applicable
|
||||
`qwen-zimage`, `sdxl-zimage`, and `chroma-zimage` invisible-removal profiles
|
||||
require an NVIDIA CUDA device; MPS is not a supported execution path. The
|
||||
missing removed-state rows are therefore an explicit hardware gap, not a clean
|
||||
or failed result.
|
||||
|
||||
## Audio cohort v1
|
||||
|
||||
`scripts/watermark_benchmark_audio_cohort.py` builds the audio analogue of the
|
||||
|
||||
@@ -712,3 +712,102 @@ configuration, since `qwen-zimage` includes its source-based face stage and
|
||||
klein ran global-only; the CJK loss is not. Outputs, reports and
|
||||
oracle records: `.local-eval/flux2-klein-2026-09-27/`.
|
||||
|
||||
## Z-Image-Turbo as a global stage (2026-09-29)
|
||||
|
||||
Verdict: **do not replace `qwen-zimage` with Z-Image-Turbo globally**. It is a
|
||||
promising research direction at the lower OpenAI no-face operating point, but
|
||||
it is not ready as a general no-face complement: the Google 0.40 outputs have
|
||||
visible non-face defects. A face detector alone is not a sufficient safety
|
||||
gate. The narrower route needs a completed Google boundary and a broader
|
||||
content-fidelity study before it can ship. **Decision: do not integrate it; keep
|
||||
the harness and measurements as research evidence only.** This decision is
|
||||
limited to a new global or mflux profile. It does not remove or change the
|
||||
existing source-conditioned Z-Image face-repair stage shared by
|
||||
`qwen-zimage`, `sdxl-zimage`, and `chroma-zimage`.
|
||||
|
||||
The package already uses `Tongyi-MAI/Z-Image-Turbo` through DiffSynth's
|
||||
`ZImagePipeline` for source-conditioned face-crop repair after the qwen global
|
||||
stage. This experiment instead ran the same official Apache-2.0 checkpoint at
|
||||
revision `f332072aa78be7aecdf3ee76d5c247082da564a6` through diffusers 0.40.0
|
||||
`ZImageImg2ImgPipeline`. The stock diffusers path supports strength-controlled
|
||||
img2img and uses the Z-Image scheduler's static shift of 3; unlike klein
|
||||
inpainting, it does not re-inject clean source latents as reference tokens.
|
||||
Primary sources: the
|
||||
[checkpoint](https://huggingface.co/Tongyi-MAI/Z-Image-Turbo) and
|
||||
[diffusers pipeline documentation](https://huggingface.co/docs/diffusers/api/pipelines/z_image).
|
||||
|
||||
The global-only H100 ladder used seed 0, the neutral scrub prompt, guidance
|
||||
0.0, eight effective distilled steps, and strengths 0.025, 0.05, 0.10, 0.15,
|
||||
0.20, 0.30, and 0.40 on the same seven tracked originals as the klein study.
|
||||
The real shifted start sigmas were 0.0714, 0.1364, 0.25, 0.375, 0.4286, 0.60,
|
||||
and 0.6667. The 49 generations took 264.5 seconds of inference in total,
|
||||
about 2.4 seconds per OpenAI image and 7.6 seconds per larger Google image.
|
||||
The rough Modal H100 cost was $0.29 for inference or $0.40 including the cold
|
||||
model load. Harness: `z_image_turbo_ladder.py`, kept outside this repository.
|
||||
|
||||
The official OpenAI oracle gave first-clean strengths 0.10, 0.10, and 0.15.
|
||||
The project margin rule therefore selects 0.20, versus the shipped
|
||||
`qwen-zimage` floor of 0.15625. At those respective operating points,
|
||||
`scripts/fidelity_metrics.py` found:
|
||||
|
||||
| Carrier | `qwen-zimage` | Z-Image-Turbo |
|
||||
| --- | --- | --- |
|
||||
| 9-face grid | ID 0.853, face LPIPS 0.054, image LPIPS 0.091 | ID 0.658, face LPIPS 0.092, image LPIPS 0.101 |
|
||||
| Typography 1 | NED 0.545, image LPIPS 0.123, PSNR 22.5 dB | NED 0.311, image LPIPS 0.086, PSNR 27.3 dB |
|
||||
| Typography 2 | NED 0.562, image LPIPS 0.116, PSNR 24.0 dB | NED 0.291, image LPIPS 0.074, PSNR 27.7 dB |
|
||||
|
||||
Z-Image-Turbo preserves the two no-face typography cards better on every
|
||||
listed metric, but the face-grid identity loss is materially worse. That is
|
||||
enough to reject it as the universal OpenAI stage.
|
||||
|
||||
Google bracketing is incomplete on one carrier. The party and crowd images
|
||||
were detected at 0.15 and clean at 0.20. The Cyrillic sign was detected at
|
||||
0.20 and clean at 0.30. The CJK sign remained detected through 0.15; its 0.20
|
||||
checks produced two explicit quota failures, and the remaining authenticated
|
||||
surface could not accept the prepared upload while its activity setting was
|
||||
off. Those are unreachable results, not clean verdicts. If that carrier first
|
||||
cleans at 0.20 or 0.30, the margin rule selects 0.40; if it first cleans at
|
||||
0.40, the rule selects 0.60. Consequently, 0.40 is only a provisional fidelity
|
||||
probe, not an oracle-established Google operating point.
|
||||
|
||||
Against `qwen-zimage` at 0.35, that provisional 0.40 probe shows the same
|
||||
content split as OpenAI:
|
||||
|
||||
| Carrier | Faces | `qwen-zimage` | Z-Image-Turbo |
|
||||
| --- | ---: | --- | --- |
|
||||
| Party scene | 5 | ID 0.933, NED 0.386, image LPIPS 0.367 | ID 0.236, NED 0.286, image LPIPS 0.326 |
|
||||
| Crowd | 18 | ID 0.752, image LPIPS 0.339 | ID 0.448, image LPIPS 0.297 |
|
||||
| Cyrillic sign | 0 | NED 0.367, LPIPS 0.424, SSIM 0.523 | NED 0.245, LPIPS 0.356, SSIM 0.547 |
|
||||
| CJK sign | 0 | NED 0.074, LPIPS 0.380, SSIM 0.555 | NED 0.000, LPIPS 0.320, SSIM 0.587 |
|
||||
|
||||
Visual inspection agrees with the metrics. At 0.40 Z-Image-Turbo changes age,
|
||||
facial structure, and skin texture and can introduce doubled facial contours.
|
||||
The no-face images are not safe either. The CJK output has NED 0.000, but that
|
||||
only says OCR recovered the same character sequence: the model visibly redraws
|
||||
the path, foliage, lighting, sign geometry, wood grain, post, and fasteners.
|
||||
Both engines also make visible Cyrillic errors. The package's existing Z-Image
|
||||
face stage does not contradict this result. It takes an expanded crop from the
|
||||
original image, enlarges the detected face toward 768 px, runs DiffSynth at an
|
||||
adaptive strength of only 0.025-0.14, and composites only the SAM face mask into
|
||||
the already-clean global result. The global experiment instead ran diffusers at
|
||||
up to 0.40 over the full frame, where each face was small and every pixel could
|
||||
be redrawn. The face stage also does not have to remove SynthID: a matched
|
||||
isolation test found its output alone still detected, while adding it after the
|
||||
clean Qwen pass improved identity cosine from 0.589 to 0.852. It is a low-noise
|
||||
masked fidelity repair, not a watermark-removal stage.
|
||||
|
||||
As an Apple Silicon side probe, [mflux](https://github.com/mflux-community/mflux)
|
||||
0.20.0 successfully ran img2img with
|
||||
`filipstrand/Z-Image-Turbo-mflux-4bit` on an M5 with 32 GB. At 1440x1072,
|
||||
image strength 0.8 (equivalent denoise fraction 0.20), 40 requested steps and
|
||||
eight effective steps, model load took 5.3 seconds and two deterministic runs
|
||||
took 301.5 and 380.0 seconds. The pixel outputs were identical, but there was
|
||||
no warm-run speedup. This is slow for an interactive default, but it is also the
|
||||
only measured native Apple Silicon image-regeneration path: every shipped
|
||||
invisible-image profile is CUDA-only and refuses MPS or CPU. The mflux route
|
||||
therefore proves M5 feasibility, but the visible defects at the tested Google
|
||||
strength keep it from being a usable fallback. Shipping it would require a
|
||||
lower safe operating point, the missing Google boundary, broader content
|
||||
fidelity, packaging, and product-level tests. Outputs, fidelity logs,
|
||||
manifests, and the local timing report are under
|
||||
`.local-eval/z-image-turbo-2026-09-27/`.
|
||||
@@ -28,6 +28,7 @@ defaults. This page focuses on choosing the right command.
|
||||
| `video invisible` and `video all --invisible` | `remove-ai-watermarks[video,diffusion]` plus ffmpeg on PATH |
|
||||
| HEIC/HEIF/AVIF pixel input | Add `remove-ai-watermarks[heif]` |
|
||||
| Every production command and Python-compatible backend | `remove-ai-watermarks[all]` |
|
||||
| `mcp` | Default package; each tool needs the extra of the command it runs |
|
||||
|
||||
`batch` requires the same extra as its selected mode. Extras can be combined in
|
||||
one installation, for example `remove-ai-watermarks[visible,detect,heif]`.
|
||||
@@ -469,6 +470,11 @@ detected. Use `--force` when you know the image should be processed:
|
||||
remove-ai-watermarks invisible image.png -o clean.png --force
|
||||
```
|
||||
|
||||
For transparent inputs, use PNG or WebP output to retain transparency. The engine
|
||||
preserves the original alpha channel, including partially transparent edges,
|
||||
while regenerating RGB pixels. JPEG output cannot preserve transparency. This
|
||||
also applies to `batch --mode invisible`.
|
||||
|
||||
### Choose a strength cohort
|
||||
|
||||
`--vendor` selects the cohort the default strength resolves from. `auto`
|
||||
@@ -685,6 +691,16 @@ For `visible` and `all` modes, the final summary includes counts for
|
||||
validation results are printed explicitly, but the once-filled outputs are kept
|
||||
and the batch does not retry them automatically.
|
||||
|
||||
## Serve the CLI to agent clients
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks mcp
|
||||
```
|
||||
|
||||
Runs a Model Context Protocol server on stdin and stdout whose `identify`,
|
||||
`remove` and `batch` tools each run one command of this CLI. Client setup and
|
||||
the tool contract are in the [MCP server guide](mcp-server.md).
|
||||
|
||||
## Exit behavior
|
||||
|
||||
The CLI uses nonzero exit codes for meaningful incomplete outcomes, including
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -27,7 +27,8 @@ to run the tool. Use the maintainer references only when changing the code.
|
||||
| [Code provenance](code-provenance.md) | Required notices for licensed derivative work. |
|
||||
| [Verification plan](verification-plan.md) | Verification methods, completed measurements, and remaining validation gaps. |
|
||||
| [Release and distribution](release-and-distribution.md) | PyPI, Homebrew, Hugging Face Space, photo-classify Hub model, ComfyUI Registry, and the release workflow. |
|
||||
| [Agent skill](agent-skill.md) | Install and maintain the published Agent Skill and Claude plugin. |
|
||||
| [Agent skill](agent-skill.md) | Install and maintain the published Agent Skill, the Claude plugin and the Codex plugin. |
|
||||
| [MCP server](mcp-server.md) | Connect an MCP client to `remove-ai-watermarks mcp`, or publish it to the MCP registry. |
|
||||
| [Watermarking landscape](watermarking-landscape.md) | Vendor signals and detection approaches. |
|
||||
| [SynthID technical reference](synthid.md) | Mechanism, provenance, robustness, regeneration. |
|
||||
|
||||
@@ -56,6 +57,7 @@ The current behavior is defined by the code, tests, README, and user guides.
|
||||
- [Text protection research](text-protection-research.md)
|
||||
- [Audio provenance experiment](audio-provenance-experiment.md) (local AudioSeal matched-oracle study of video audio tracks across visual cleaning)
|
||||
- [VideoSeal temporal evaluation](videoseal-temporal-evaluation.md) (per-frame scores, aggregation matrix, and crf decay on synthetic and real clips)
|
||||
- [Temporal video inpainting evaluation](video-inpainting-research.md) (VACE and ROSE against the shipped visible-video fills)
|
||||
- [Watermark forgery study](watermark-forgery-study.md) (four-state model and double-embedding: the last writer wins under both oracles)
|
||||
- [C2PA resolution research](c2pa-resolution-research.md) (measured resolver contracts, the privacy gradient, and the decision to never resolve over the network)
|
||||
- [External benchmark licensing](external-benchmark-licensing.md) (ETI license-blocked, W-Bench dataset MIT with non-commercial code, verified 2026-09-07)
|
||||
|
||||
@@ -4,6 +4,21 @@ This page describes current product limits. Historical measurements and
|
||||
superseded experiments live in the research archive listed in
|
||||
[the documentation index](index.md).
|
||||
|
||||
## What each path leaves behind
|
||||
|
||||
No command produces a "clean" verdict. Each one removes a specific layer, and
|
||||
the remaining layers need their own check.
|
||||
|
||||
| Path | Removes | May remain | How to check what remains |
|
||||
| --- | --- | --- | --- |
|
||||
| `metadata --remove`, `video metadata --remove` | Supported AI metadata: C2PA manifests, TC260 labels, IPTC AI fields, app-export JSON, generation parameters | Every pixel and audio watermark (SynthID, Meta Content Seal, InvisMark, TrustMark, DWT-DCT). A C2PA soft binding can still lead a resolver to a manifest stored remotely. Strings inside encoded video streams, such as H.264 SEI messages, stay too, except Kling's SEI tag, which `video metadata --remove` blanks | `identify` re-reads the metadata. Only the provider's verifier can confirm a pixel watermark. The pixels are not regenerated, so an AI-image classifier scores the output the same as the input |
|
||||
| `visible`, `erase`, `video visible` | The selected label region, refilled. AI metadata too, unless `--keep-metadata` is given | Any invisible watermark across the rest of the frame. The fill can leave a visible patch on structured backgrounds | Look at the output. Use `identify` for metadata and the provider's verifier for invisible marks |
|
||||
| `invisible`, `all`, `video invisible` | Regenerates pixels to disrupt invisible watermarks. `all` also removes visible marks and metadata | No local decoder confirms that SynthID is gone from a given file, and the outcome depends on content and seed. `video invisible` copies the audio track unchanged, so an audio watermark survives | The provider's verifier, such as Google's SynthID Detector or OpenAI's provenance check. A pass there says nothing about AI-image classifiers |
|
||||
| `identify`, `video identify` | Nothing | Not applicable | `unknown` means no supported local signal was found. It is not a clean verdict |
|
||||
|
||||
AI-image classifiers are a separate question from provenance. They score how
|
||||
the pixels look rather than read a mark, and none of these paths targets them.
|
||||
|
||||
## Pixel classification
|
||||
|
||||
`classify` / `classify_pixels` is AI versus camera, not a universal AI detector.
|
||||
@@ -164,17 +179,27 @@ faces. The general fidelity corpus and its OCR caveats are tracked in
|
||||
[`data/evaluations/fidelity/`](../data/evaluations/fidelity/README.md), while the
|
||||
current-floor Google replication is recorded in
|
||||
[`module-internals.md`](module-internals.md#google-boundary-replication-2026-09-15).
|
||||
A global Z-Image Turbo prototype preserved text substantially better at low
|
||||
strength, but it has no useful cross-provider operating point and is not a
|
||||
supported profile. Automatic text restorers also remain research-only:
|
||||
A global Z-Image Turbo prototype preserved text substantially better on the
|
||||
no-face carriers but lost face identity, so it is not a universal replacement
|
||||
and is not a supported profile. Its mflux arm is the only measured native Apple
|
||||
Silicon image-regeneration path; it ran on an M5 with 32 GB but took 301-380
|
||||
seconds per image. The Google no-face outputs at 0.40 visibly redraw scene and
|
||||
object details despite better OCR metrics, so absence of faces is not a
|
||||
sufficient safety gate. The mflux result proves technical feasibility rather
|
||||
than a usable fallback; it still lacks one Google boundary, a lower safe
|
||||
operating point, and broad content-fidelity validation. Automatic text
|
||||
restorers also remain research-only:
|
||||
fresh-font and silhouette variants visibly changed typography. The higher-fidelity
|
||||
`vae-glyphs` route is available only as an experimental opt-in with verified strings
|
||||
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).
|
||||
|
||||
@@ -253,7 +278,9 @@ guarantee, and check the audio separately when it matters. The question is no
|
||||
longer Google-only: since 2026 OpenAI (ChatGPT and API audio) and ElevenLabs
|
||||
also embed SynthID in generated audio, and Microsoft lists provenance for its
|
||||
Azure speech and GPT audio models, so voice-over or music from those services
|
||||
muxed into any video survives this path unchanged.
|
||||
muxed into any video survives this path unchanged. Suno announced audio
|
||||
watermarking and fingerprinting on 2026-08-06 without naming the technology, so
|
||||
Suno music belongs on the same list.
|
||||
|
||||
The public video results make that boundary explicit without decoding audio:
|
||||
`visual_invisible_action` records whether video pixels were regenerated, while
|
||||
@@ -265,9 +292,12 @@ A local matched-oracle study has now measured the boundary concretely:
|
||||
into synthetic carriers, runs the visible video-cleaning path, and shows the
|
||||
audio packets byte-identical and the watermark verdict invariant across
|
||||
cleaning, while additive noise on the audio path is the effective lever. The
|
||||
boundary above still stands for provider marks: this tool still does not
|
||||
decode them, and a `copied_if_present` audio track remains `unverified` in
|
||||
runtime output.
|
||||
[Perth and PixelSeal benchmark study](benchmark-kernel.md#perth-and-pixelseal-local-oracle-study)
|
||||
independently repeats the pass-through check with Resemble Perth: all 24 visual
|
||||
frames are cleaned, the marked AAC packets remain byte-identical, and Perth's
|
||||
score stays 1.0. The boundary above still stands for provider marks: this tool
|
||||
still does not decode them, and a `copied_if_present` audio track remains
|
||||
`unverified` in runtime output.
|
||||
|
||||
The shipped engine streams sampled frames in bounded batches, computes its
|
||||
fidelity metrics incrementally, and pipes regenerated pixels directly to
|
||||
@@ -387,12 +417,20 @@ global Qwen stage is tiled; the face stage runs after tile blending.
|
||||
every profile, streaming weights instead of pinning them. It reduces CUDA memory
|
||||
pressure at the cost of speed.
|
||||
|
||||
It reaches the global stack of `qwen-zimage` only. That stack is each
|
||||
profile's own, and `chroma-zimage` and `sdxl-zimage` load theirs with a plain
|
||||
`.to(device)`. On a card below the face-stage residency floor, where the face
|
||||
stack was already offloading, the flag changes nothing at all for those two: the
|
||||
run behaves as if it were absent. The library says so on the way past, as a
|
||||
warning ahead of the model load.
|
||||
It reaches the global stack of `qwen-zimage` and `sdxl-zimage`. That stack is
|
||||
each profile's own, and `chroma-zimage` loads its stack with a plain `.to(device)`.
|
||||
On a card below the face-stage residency floor, where the face stack was already
|
||||
offloading, the flag changes nothing at all for that profile: the run behaves as
|
||||
if it were absent. The library says so on the way past, as a warning ahead of the
|
||||
model load.
|
||||
|
||||
`sdxl-zimage` streams its global stack without being asked on a card below
|
||||
`RESIDENT_SDXL_GLOBAL_MIN_VRAM_GIB` (12 GiB). Its UNet and Canny ControlNet run
|
||||
together on every step, so the stack does not fit an 8 GiB card even with
|
||||
model-level offload. A resident load there does not always fail loudly: under
|
||||
the Windows display driver, including WSL2, CUDA can spill the overflow into
|
||||
shared system memory, and the run goes on at a fraction of the speed instead of
|
||||
raising an out-of-memory error.
|
||||
|
||||
Residency is otherwise chosen from the card's total VRAM. On a card large enough
|
||||
to hold a stack, offloading is pure waste: DiffSynth drops weights to the meta
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# MCP server
|
||||
|
||||
`remove-ai-watermarks mcp` runs a [Model Context Protocol](https://modelcontextprotocol.io/)
|
||||
server on stdin and stdout, so an agent client can identify and clean the
|
||||
user's own images and video without a shell. It is a thin wrapper: each tool
|
||||
runs one command of this CLI in a child process and returns that command's exit
|
||||
code, output path and text.
|
||||
|
||||
The subcommand ships since 0.46.0. The server uses only the Python standard
|
||||
library, so the default package runs it. The commands behind a tool need the
|
||||
same extras they need on the command line
|
||||
([CLI guide](cli.md#command-dependency-map)). A missing extra comes back as a
|
||||
tool error carrying the CLI's install hint.
|
||||
|
||||
## Client configuration
|
||||
|
||||
Most clients take a command and arguments. With [uv](https://docs.astral.sh/uv/):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"remove-ai-watermarks": {
|
||||
"command": "uvx",
|
||||
"args": ["remove-ai-watermarks", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To make visible-mark removal and pixel detectors available, name the extras:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"remove-ai-watermarks": {
|
||||
"command": "uvx",
|
||||
"args": ["--from", "remove-ai-watermarks[visible,video]", "remove-ai-watermarks", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
An existing installation can be started directly as `remove-ai-watermarks mcp`.
|
||||
Paths in tool calls are read and written on the machine that runs the server.
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Runs | Writes |
|
||||
| --- | --- | --- |
|
||||
| `identify` | `identify --json`, or `video identify --json` for a video suffix; `check_visible: false` adds `--no-visible` | Nothing |
|
||||
| `remove` | `metadata --remove`, `visible` or `all` for an image; `video metadata --remove`, `video visible` or `video all` for a video | One output file, `<name>_clean` beside the source unless `output` is given |
|
||||
| `batch` | `batch --mode` for images or `video batch --mode` for video | A separate output directory, `<dir>_clean` unless `output_dir` is given |
|
||||
|
||||
`mode` is `metadata` (the default), `visible` or `all`. For an image, `all`
|
||||
adds the invisible-watermark scrub where the installed extras and a CUDA GPU
|
||||
allow it; `video all` removes visible marks and metadata only. The server never
|
||||
writes over an input: an `output` equal to the source, or an `output_dir` equal
|
||||
to the input directory, is refused before anything runs.
|
||||
|
||||
Exit codes keep their CLI meaning. `0` and `2` are results, not tool errors, and
|
||||
`2` means no supported signal was found, which is unknown rather than clean.
|
||||
Click also exits `2` on a usage error such as a missing file; its `Usage:` line on
|
||||
stderr marks that case as a tool error. Every other nonzero exit code is returned
|
||||
with `isError: true` and the command's stderr.
|
||||
|
||||
## Scope
|
||||
|
||||
The server carries the project's intended-use boundary in its `instructions`:
|
||||
it is for content the user generated or edited themselves, and not for stock,
|
||||
agency, marketplace or other marks that protect a third party's asset. See
|
||||
[legal and safety](legal-and-safety.md).
|
||||
|
||||
## Protocol
|
||||
|
||||
The server answers the initialize handshake for protocol versions 2025-11-25,
|
||||
2025-06-18, 2025-03-26 and 2024-11-05, and lists its tools with input schemas
|
||||
and annotations: `identify` is read-only, and `remove` and `batch` write new
|
||||
files without destroying inputs. It does not implement `server/discover`. A
|
||||
client that probes it first, such as the official Python SDK's `Client` in its
|
||||
default mode, receives method-not-found and falls back to the handshake.
|
||||
`tests/test_mcp_server.py` runs that client against the real subcommand.
|
||||
|
||||
## Registry listing
|
||||
|
||||
[`server.json`](../server.json) describes the PyPI package for the
|
||||
[official MCP registry](https://registry.modelcontextprotocol.io/) under
|
||||
`io.github.wiltodelta/remove-ai-watermarks`. The registry verifies ownership by
|
||||
the `mcp-name:` marker in `README.md`, which becomes the PyPI description, so it
|
||||
can only verify a release whose README carries that marker.
|
||||
[`glama.json`](../glama.json) names the maintainer for Glama. Publishing steps
|
||||
are in [release and distribution](release-and-distribution.md#mcp-registry).
|
||||
@@ -47,6 +47,9 @@ Important contracts:
|
||||
`EXIT_NO_VISIBLE_MARK`.
|
||||
- `invisible` writes no output when no supported local signal is found, unless
|
||||
`--force` is supplied.
|
||||
It decodes the source before the scrub gate and exits `1` when it cannot,
|
||||
because the gate reads undecodable bytes as "no target" and would otherwise
|
||||
report the no-signal code for a file it never read (fixed 2026-10-17).
|
||||
- The two no-signal conditions currently share exit code `2`.
|
||||
- A quiet `metadata --check` and a successful `metadata --remove` (same for
|
||||
`video metadata`) end by repeating the `identify` limit: the pixel channel is
|
||||
@@ -236,10 +239,46 @@ payloads; TC260 removal changes the four-byte key to `free` and blanks only the
|
||||
validated JSON value with same-length spaces. Keyed generation metadata such
|
||||
as `workflow` and `prompt` uses the same structural walk; removal blanks the
|
||||
complete key and value in place. This preserves every box size,
|
||||
`stco`/`co64` offset, encoded stream byte, and source-sized memory bound.
|
||||
`stco`/`co64` offset, encoded stream byte apart from the Kling SEI tag below, and
|
||||
source-sized memory bound.
|
||||
Publication is atomic, and a malformed top-level walk is copied unchanged. A
|
||||
generic `AIGC` key whose value has no TC260 field is ignored.
|
||||
|
||||
App-export JSON in a keyed or keyless metadata item is blanked the same way.
|
||||
Dreamina's MP4 export writes it twice, under `LvMetaInfo` and
|
||||
`com.apple.quicktime.artwork`. The file-level scan read both from the raw head
|
||||
bytes while the stripper walked only TC260 and generation keys, so
|
||||
`video metadata --remove` exited 1 with both markers still present on the
|
||||
tracked Dreamina fixture (found 2026-10-17). Its `UserComment`
|
||||
(`{"aigc_label_type":0,"source_info":"dreamina"}`) and `comment` (`vid:...`)
|
||||
items carry no marker the scan counts, so they stay.
|
||||
|
||||
`test_no_flagged_metadata_value_survives_in_the_output_bytes` strips every
|
||||
tracked MP4 fixture and searches the output byte for byte for each flagged
|
||||
source value, after the pattern of byte-level leftover checks in public video
|
||||
scrubbers. The walk reaches container metadata only. Strings inside the encoded
|
||||
streams are out of its reach with one exception: x264 writes its version and
|
||||
options as an H.264 SEI message, which is not AI provenance and survives the
|
||||
stream copy; only a path that re-encodes the video stream (`video visible`,
|
||||
`video invisible`) replaces it.
|
||||
|
||||
The exception is Kling's encoder tag. Every Kling video in the tracked fixtures
|
||||
and local captures (Kling 2.6, 3.0, 3.0 Omni and O3, through Higgsfield, Runway
|
||||
and Kling itself) carries one H.264 `user_data_unregistered` SEI message, payload
|
||||
type 5, whose 25-byte payload is UUID `91ca6061-4aee-3854-8614-2d5f73f4ae2e`,
|
||||
`kling-ai` and a NUL. `get_ai_metadata` reports it as `kling_sei` from the head
|
||||
window, and `identify_video` names Kling from it when no TC260 label does, as on
|
||||
`data/fixtures/visible/kling/provider-original.mp4`, which carries no other
|
||||
signal. The streaming stripper finds every copy of the UUID plus `kling-ai` in
|
||||
each top-level `mdat` during the copy pass it already makes, so the file is still
|
||||
read once, and overwrites those 24 bytes with spaces. The NAL length,
|
||||
SEI header and payload size stay, and a run of 0x20 cannot form an
|
||||
emulation-prevention sequence, so the message stays well-formed and no decoder
|
||||
reads it: `ffmpeg -f framemd5` hashes of every frame match before and after on
|
||||
both tracked Kling fixtures (found and fixed 2026-10-17). The QuickTime `©cmt` item in
|
||||
the Grok fixture (`Signature: <base64>`) also survives, because the xAI
|
||||
signature rule requires the UUID half of the pair and that half is absent here.
|
||||
|
||||
[`_internal/ebml.py`](../src/remove_ai_watermarks/_internal/ebml.py) provides the
|
||||
corresponding bounded Matroska/WebM reader. It seeks over clusters and accepts
|
||||
only a `Segment.Tags.Tag.SimpleTag` pairing `TagName=AIGC` with a JSON
|
||||
@@ -451,12 +490,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
|
||||
@@ -472,6 +537,14 @@ backgrounds need MI-GAN or LaMa for better reconstruction. Invisible video
|
||||
stages must continue to reuse the image and metadata implementations rather
|
||||
than copying their logic.
|
||||
|
||||
Apache-2.0 temporal video inpainting was measured on 2026-09-27 and is not a
|
||||
backend: VACE and ROSE had higher masked frame-to-frame error than MI-GAN on
|
||||
all three publication-cleared Sora, Veo, and Kling clips. VACE left a gray
|
||||
block on Veo, while ROSE visibly reproduced the wordmark and made the Veo
|
||||
detector accept 17/17 output frames. The model pins, mask-conditioning audit,
|
||||
decoded metrics, H100 cost, and frame review are in the
|
||||
[temporal video inpainting evaluation](video-inpainting-research.md).
|
||||
|
||||
Regression coverage:
|
||||
|
||||
- [`test_video.py`](../tests/test_video.py), including a real ffmpeg full-clip
|
||||
@@ -499,6 +572,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
|
||||
@@ -534,6 +660,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
|
||||
@@ -544,6 +676,10 @@ the official C2PA registry. Refresh it with
|
||||
upstream schema invariants, records the exact source revision and writes a
|
||||
deterministic module. Runtime inspection remains offline, and registry membership
|
||||
is name-only evidence rather than proof that a compatible decoder ships.
|
||||
`--check`, run by `maintain.sh` and the CI lint job ahead of Ruff, compares the
|
||||
snapshot with the live upstream revision, so an upstream registry commit fails
|
||||
lint on `main` and on every pull request until the snapshot is refreshed, and the
|
||||
Ruff step behind it does not run until then.
|
||||
The GitHub API revision lookup sends `GITHUB_TOKEN` (or `GH_TOKEN`) when set,
|
||||
because the unauthenticated 60 requests/hour per IP limit is exhausted on
|
||||
shared CI runners; `.env.example` documents the variable.
|
||||
@@ -642,6 +778,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
|
||||
@@ -729,6 +891,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
|
||||
@@ -1376,6 +1544,15 @@ schemas 0-2 count as positives. Schema 3 is below the precision threshold: all
|
||||
same false payload after re-encoding. The official Adobe Variant P schema-1
|
||||
fixture in `data/fixtures/provenance/` is the positive regression control.
|
||||
|
||||
An independent benchmark supports gating beyond a valid codeword. Kazempourradi
|
||||
et al. ([arXiv:2610.04151](https://arxiv.org/abs/2610.04151), section 4.4)
|
||||
found that the blind detectors of TrustMark B, C, P and Q in BCH_5 mode reported
|
||||
a valid codeword on at least one of four decodes for 5.9% (P) to 15.3% (C) of 85
|
||||
unmarked images, and on 3 of 8 images in a 16-bit HDR subset. Testing the same
|
||||
decodes against the expected payload produced no false match. This detector is
|
||||
blind and has no expected payload, so the re-encode stability and schema
|
||||
restriction above stand in for that test.
|
||||
|
||||
#### Why the DWT-DCT decoder looks the way it does
|
||||
|
||||
Two things in `dwt_dct.py` are load-bearing and neither is obvious from the code.
|
||||
@@ -1465,19 +1642,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
|
||||
@@ -1529,8 +1708,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
|
||||
@@ -1570,6 +1748,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:
|
||||
@@ -1579,6 +1762,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)
|
||||
@@ -1623,7 +1808,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
|
||||
@@ -1634,7 +1822,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
|
||||
@@ -1671,8 +1860,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,
|
||||
@@ -1682,6 +1872,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
|
||||
@@ -1809,14 +2006,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
|
||||
@@ -1880,10 +2075,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.
|
||||
|
||||
@@ -2009,6 +2208,13 @@ image sizing, postprocessing, and the public engine
|
||||
interface. It delegates model execution to
|
||||
[`_internal/watermark_remover.py`](../src/remove_ai_watermarks/_internal/watermark_remover.py).
|
||||
|
||||
The engine preserves source transparency for alpha-capable outputs. It captures
|
||||
alpha after EXIF orientation is applied and before resizing, then rejoins the
|
||||
unchanged alpha plane after restoring the RGB result to the original dimensions
|
||||
and applying postprocessing. This also handles palette and colour-key PNG
|
||||
transparency; diffusion itself still receives RGB. Regression coverage lives in
|
||||
`test_invisible_engine.py::TestTransparency`.
|
||||
|
||||
`get_device` in that module answers only `cuda` or `cpu`. An `mps` or `xpu` answer
|
||||
would travel one frame to the same CUDA-only refusal while costing a device probe,
|
||||
and reporting it implied an Apple-silicon or Intel-GPU path that does not exist.
|
||||
@@ -2038,27 +2244,27 @@ Regression coverage:
|
||||
|
||||
### CPU offload
|
||||
|
||||
CPU offload is enabled only when requested. Nothing calls Diffusers'
|
||||
`enable_model_cpu_offload` any more -- that belonged to the deleted single-stage
|
||||
profiles. `--cpu-offload` sets both residency fields, and they do not travel the
|
||||
same distance: the face field is read by the shared base and so applies to every
|
||||
profile, while the global field is read by `QwenZImagePipeline` alone.
|
||||
`ChromaZImagePipeline` and `SdxlZImagePipeline` load their global stack with a
|
||||
plain `.to(device)` and never consult it.
|
||||
Nothing calls Diffusers' `enable_model_cpu_offload` any more -- that belonged to
|
||||
the deleted single-stage profiles. `--cpu-offload` sets both residency fields, and
|
||||
they do not travel the same distance: the face field is read by the shared base and
|
||||
so applies to every profile, while the global field is read by `QwenZImagePipeline`
|
||||
and `SdxlZImagePipeline`. `ChromaZImagePipeline` loads its global stack with a
|
||||
plain `.to(device)` and never consults it.
|
||||
|
||||
`GLOBAL_OFFLOAD_PROFILES` in `watermark_profiles.py` is the list, and
|
||||
`global_offload_supported` the question; `WatermarkRemover._warn_if_global_offload_unsupported`
|
||||
warns ahead of the model load rather than at construction, because `auto` picks
|
||||
its engine per-image. An explicit `auto` preload uses the qwen-zimage fallback and
|
||||
therefore keeps global offload active before that resolution. Below
|
||||
`RESIDENT_FACE_MODEL_MIN_VRAM_GIB` the flag is a no-op for the other two profiles --
|
||||
the face stack was offloading anyway and the global one does not ask -- which is
|
||||
what the warning exists to say before a download starts.
|
||||
`RESIDENT_FACE_MODEL_MIN_VRAM_GIB` the flag is a no-op for `chroma-zimage` -- the
|
||||
face stack was offloading anyway and the global one does not ask -- which is what
|
||||
the warning exists to say before a download starts.
|
||||
|
||||
Residency is otherwise chosen from the card's total VRAM, once per stack:
|
||||
`resolve_global_model_residency` gates the mandatory Qwen stack at
|
||||
`RESIDENT_GLOBAL_MODEL_MIN_VRAM_GIB` and `resolve_face_model_residency` gates the
|
||||
optional Z-Image stack at `RESIDENT_FACE_MODEL_MIN_VRAM_GIB`.
|
||||
`RESIDENT_GLOBAL_MODEL_MIN_VRAM_GIB`, `resolve_sdxl_global_residency` gates the
|
||||
SDXL stack at `RESIDENT_SDXL_GLOBAL_MIN_VRAM_GIB`, and `resolve_face_model_residency`
|
||||
gates the optional Z-Image stack at `RESIDENT_FACE_MODEL_MIN_VRAM_GIB`.
|
||||
|
||||
Below the global floor, `_qwen_vram_config` streams the stack from disk, which is
|
||||
what makes a 20B model runnable on a consumer card. At or above it, streaming is
|
||||
@@ -2075,6 +2281,43 @@ The resident config deliberately passes no `"disk"` value anywhere. DiffSynth la
|
||||
`disk_offload` once, from `offload_dtype`, so leaving the sentinel in place while
|
||||
pointing every device at CUDA would keep the meta-drop and re-read.
|
||||
|
||||
Sequential SDXL offload receives the current CUDA device index explicitly, so
|
||||
its hooks use the same GPU as the residency probes, input tensors, and seeded
|
||||
generators. Omitting the index makes Diffusers select GPU 0 even after
|
||||
`torch.cuda.set_device(1)`. `test_streaming_uses_the_current_cuda_device` in
|
||||
`test_cpu_offload.py` covers automatic and forced streaming on nonzero devices.
|
||||
|
||||
The SDXL stack (fp16 UNet, Canny ControlNet, both text encoders, the fp16-fix VAE)
|
||||
is ~8.8 GiB of weights, and the UNet and the ControlNet run together on every
|
||||
denoising step. Below the 12 GiB floor `_load_global` fuses the Lightning LoRA on
|
||||
the CPU and then calls `enable_sequential_cpu_offload`; the order matters, because
|
||||
the offload hooks stream their own copy of each weight and a later fuse would never
|
||||
reach it. Measured on an RTX 5060 Laptop (8 GiB, WSL2, 16 GiB system RAM) in
|
||||
October 2026, one 1672x941 global pass at strength 0.15:
|
||||
|
||||
| Placement | Warm pass | Peak VRAM | Peak system RAM |
|
||||
|---|---|---|---|
|
||||
| resident `.to(device)` (previous behaviour) | did not finish in 10 min | 7.9 GiB used, spilling | -- |
|
||||
| `enable_model_cpu_offload` | 237 s | 9.2 GiB reserved on an 8 GiB card | 12.4 GiB |
|
||||
| group offload, block level, CUDA stream | 92 s, or hung | 12.4 GiB reserved | 15.6 GiB |
|
||||
| group offload, leaf level, CUDA stream | 7.8 s, or hung | 5.6 GiB | 15.7 GiB |
|
||||
| group offload, leaf level, `low_cpu_mem_usage` | 21.4 s | 5.6 GiB | 9.4 GiB |
|
||||
| `enable_sequential_cpu_offload` | 12.8 s | 3-4 GiB | 8.5 GiB |
|
||||
|
||||
The resident and model-offload rows did not fail: under the Windows display driver
|
||||
CUDA falls back to shared system memory, so PyTorch reserved more than the card
|
||||
holds and the run crawled instead of raising. The streamed group-offload variants
|
||||
pin every weight in host memory up front; on a 16 GiB machine that left the run
|
||||
finishing or hanging depending on what else held RAM, which rules them out as a
|
||||
default. All completed placements produced bit-identical images.
|
||||
|
||||
Fusing on the CPU instead of on CUDA rounds 788 of the UNet's 3256 fused tensors
|
||||
differently, by at most one fp16 ulp (1.2e-4). On the pass above that moved the
|
||||
output by a mean of 0.16 levels (PSNR 54.3 dB against a CUDA-fused, equally
|
||||
streamed run). Cards at or above the floor keep the CUDA fuse and therefore the
|
||||
previous pixels; only a streamed run, automatic or forced with `--cpu-offload`,
|
||||
sees the difference.
|
||||
|
||||
Regression coverage:
|
||||
|
||||
- [`test_cpu_offload.py`](../tests/test_cpu_offload.py)
|
||||
|
||||
@@ -14,6 +14,7 @@ through the installed CLI or public Python API.
|
||||
| `openai-api` | OpenAI SynthID | Official Content Provenance API | Images |
|
||||
| `microsoft-api` | Microsoft InvisMark | Azure Content Provenance Detection API | Images |
|
||||
| `gemini-web` | Google SynthID | Signed-in Gemini web account | Images and videos |
|
||||
| `synthid-web` | Partner SynthID | SynthID Detector at synthid.com, real Chrome | None recorded |
|
||||
| `meta-web` | Meta Content Seal | Anonymous Meta identification page | Images |
|
||||
| `microsoft-web` | Microsoft InvisMark | Public Azure AI validation page | Images |
|
||||
| `openai-web` | OpenAI SynthID | Public web verifier | Images |
|
||||
@@ -23,26 +24,46 @@ through the installed CLI or public Python API.
|
||||
The default selection order is API first, then Web. OpenAI therefore plans
|
||||
`openai-api` before `openai-web`, and Microsoft plans `microsoft-api` before
|
||||
`microsoft-web`. Google and Meta currently have no usable API adapter in this
|
||||
tooling, so their Web surfaces are primary rather than fallback routes.
|
||||
tooling, so each of their Web surfaces is primary. Google has two: `gemini-web`
|
||||
and `synthid-web`.
|
||||
|
||||
Inspect the machine-readable order before preparing a batch:
|
||||
|
||||
```bash
|
||||
uv run python scripts/provider_oracles.py plan openai --json
|
||||
uv run python scripts/provider_oracles.py plan microsoft
|
||||
uv run python scripts/provider_oracles.py plan google
|
||||
```
|
||||
|
||||
API-first is a selection policy, not automatic failover. Use Web only when the
|
||||
provider, requested media, or local configuration has no usable API route, or
|
||||
when the operator explicitly requests a second surface. An API refusal, quota
|
||||
response, transport failure, or indeterminate verdict is recorded as-is and
|
||||
never silently triggers a Web upload.
|
||||
A request to use a provider covers every configured surface of that provider,
|
||||
including Web, without a second confirmation. API-first is still the order to
|
||||
try, not a lock on the other surfaces. An API refusal, quota response, transport
|
||||
failure, or indeterminate verdict is recorded as-is. It does not silently
|
||||
upload the same file to Web; run that surface as its own acknowledged check and
|
||||
keep the two verdicts separate.
|
||||
|
||||
The old Google SynthID Detector portal does not work. On 2026-09-07, the
|
||||
official `labs.google/synthid` entry point redirected a signed-in browser to a
|
||||
Google 404; on 2026-09-23 it redirected to
|
||||
`deepmind.google.com/frontiers/synthid`, which also returned 404. Historical corpus rows may still name `synthid-portal`; that value
|
||||
describes how an old result was obtained and is not an operational surface.
|
||||
The old Google SynthID Detector portal at `labs.google/synthid` does not work.
|
||||
On 2026-09-07 that entry point redirected a signed-in browser to a Google 404;
|
||||
on 2026-09-23 it redirected to `deepmind.google.com/frontiers/synthid`, which
|
||||
also returned 404. Historical corpus rows may still name `synthid-portal`; that
|
||||
value describes how an old result was obtained and is not an operational surface.
|
||||
|
||||
The public SynthID Detector is a different site, [synthid.com](https://synthid.com/),
|
||||
and it is the `synthid-web` surface. On 2026-10-17 the page title was
|
||||
`SynthID Detector`. After the terms dialog, the uploader was present without a
|
||||
completed sign-in in that session. The Account menu still offered Sign in, and
|
||||
no file was submitted, so this check does not show whether a result requires an
|
||||
account. The live FAQ named the partners as Google DeepMind, Nvidia, OpenAI,
|
||||
and Kakao, and it did not list Apple. It defined two settled labels:
|
||||
`Watermark detected` and `Watermark not detected`. The file input accepted
|
||||
`.jpg`, `.jpeg`, `.png`, `.bmp`, `.webp`, `.avif`, `.heic`, `.heif`, `.tiff`,
|
||||
`.tif`, `.gif`, `.wav`, `.mp3`, `.ogg`, `.flac`, `.aac`, `.m4a`, `.mp4`,
|
||||
`.mov`, and `.webm`. This tooling still prepares only the project's existing
|
||||
JPEG, PNG, WebP, MP4, MP3, and WAV suffixes. The portal's additional terms
|
||||
forbid using the page as an automated API, bulk verification, and quota evasion,
|
||||
so `run-web` does not drive it. Use the real Chrome workflow below. A
|
||||
`Watermark detected` result is partner SynthID, not a Gemini-only or OpenAI-only
|
||||
verdict.
|
||||
Google's newer Search verification entry point did not provide an independent
|
||||
oracle allowance in the session tested on 2026-09-13. Lens accepted the
|
||||
metadata-stripped, previously Gemini-positive image
|
||||
@@ -161,9 +182,12 @@ Adobe Inspect and local C2PA, DWT-DCT, TrustMark, and research detectors are
|
||||
useful evidence readers, not interchangeable provider pixel-watermark oracles.
|
||||
|
||||
The surface capability and this project's evidence coverage are separate. The
|
||||
current Google, Microsoft, and Meta pages accept image, video, and audio inputs;
|
||||
the OpenAI API accepts image and audio. A media type does not become certified
|
||||
merely because a surface accepts it. Run `list --json` to read both fields.
|
||||
current Google, Microsoft, and Meta pages, including `https://synthid.com/`,
|
||||
accept image, video, and audio inputs; the OpenAI API accepts image and audio.
|
||||
A media type does not become certified merely because a surface accepts it.
|
||||
`synthid-web` therefore lists image, video, and audio as accepted media and
|
||||
records no project evidence until a batch is verified. Run `list --json` to read
|
||||
both fields.
|
||||
|
||||
## Execution slots
|
||||
|
||||
@@ -193,6 +217,13 @@ default; `--slots PATH` explicitly overrides it.
|
||||
"google_account_index": 2,
|
||||
"network_label": "office"
|
||||
},
|
||||
{
|
||||
"name": "synthid-personal",
|
||||
"surface": "synthid-web",
|
||||
"account_label": "google-account-a",
|
||||
"browser_profile": "Your Chrome",
|
||||
"network_label": "home"
|
||||
},
|
||||
{
|
||||
"name": "openai-web-thordata-us",
|
||||
"surface": "openai-web",
|
||||
@@ -261,16 +292,30 @@ default; `--slots PATH` explicitly overrides it.
|
||||
```
|
||||
|
||||
A routed batch selects one slot explicitly. A Gemini slot must name the Google
|
||||
account, its real Chrome profile, and its nonnegative `/u/N/` account index; an
|
||||
account, its real Chrome profile, and its nonnegative `/u/N/` account index. A
|
||||
SynthID Detector slot names the account and the same real Chrome profile, and it
|
||||
does not use `google_account_index`. An
|
||||
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, or a new network route needs
|
||||
authorization. The oracles and slots already configured for the requested
|
||||
provider may be used again, including their Web surfaces, without a second
|
||||
confirmation. 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:
|
||||
@@ -327,6 +372,17 @@ byte for byte; the manifest explicitly records that their metadata was not
|
||||
stripped. Use the matched-control protocols in [`synthid.md`](synthid.md) for
|
||||
video claims.
|
||||
|
||||
Before preparing any image for a SynthID oracle, visually inspect it and remove
|
||||
every visible AI-provider logo or label with the project's visible-mark remover.
|
||||
Derive both the positive control and every processed candidate from that same
|
||||
visible-clean source, then run `prepare` to strip metadata losslessly. Inspect the
|
||||
prepared upload again and refuse the request if a visible provider mark remains.
|
||||
This separation is mandatory: Gemini can answer from a visible sparkle or from
|
||||
image-content reasoning without returning a SynthID tool verdict. Record such an
|
||||
answer as `indeterminate`, never `detected`. The source-to-visible-clean pixel
|
||||
change belongs in the experiment report; `prepare`'s pixel-preservation claim is
|
||||
only from that cleaned source to the metadata-stripped upload.
|
||||
|
||||
The batch contains:
|
||||
|
||||
- `manifest.json`, immutable surface, slot, source, upload, and hash identities;
|
||||
@@ -363,11 +419,23 @@ uv sync --extra dev
|
||||
uv run playwright install chromium
|
||||
```
|
||||
|
||||
Google is intentionally excluded from the standalone runner because its oracle
|
||||
requires the user's existing authenticated Chrome session. Invoke the project
|
||||
`provider-oracles` skill to prepare the batch, select the named real Chrome
|
||||
profile and `/u/N/` Google account, upload each prepared artifact, capture the
|
||||
settled SynthID tool outcome, and record it.
|
||||
Google is intentionally excluded from the standalone runner. Both `gemini-web`
|
||||
and `synthid-web` use the user's existing real Chrome profile, and the SynthID
|
||||
Detector terms forbid driving that page as an automated API. Invoke the project
|
||||
`provider-oracles` skill to prepare the batch and record the settled outcome.
|
||||
For Gemini, select the named profile and `/u/N/` account. For SynthID Detector,
|
||||
open `https://synthid.com/` in that profile. The portal has no `/u/N/` index.
|
||||
Accept the terms dialog when it is shown, attach one prepared upload, and read
|
||||
the explicit `Watermark detected` or `Watermark not detected` label. If the page
|
||||
requires sign-in and that profile has no session, record the row unsubmitted
|
||||
and do not start a new login.
|
||||
|
||||
For Gemini, ask the direct question stored in the batch manifest: `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.` A
|
||||
general question about whether Google AI created or edited the media is not
|
||||
equivalent: it may elicit origin or visual-content reasoning without a SynthID
|
||||
verdict. SynthID Detector has no prompt; its settled labels are the verdict.
|
||||
|
||||
OpenAI Web, Microsoft Web, and Meta run entirely through a fresh isolated
|
||||
Playwright browser. A direct-route example:
|
||||
|
||||
@@ -24,9 +24,10 @@ The package version appears in:
|
||||
|
||||
- `pyproject.toml`;
|
||||
- `src/remove_ai_watermarks/__init__.py`;
|
||||
- both `version` fields in `server.json`;
|
||||
- the root package entry generated in `uv.lock`.
|
||||
|
||||
Update the first two, then refresh the lock file with uv. Do not edit a
|
||||
Update the first three, then refresh the lock file with uv. Do not edit a
|
||||
line-number-specific location in `uv.lock`; its package order changes.
|
||||
|
||||
Before choosing a version, verify it is absent from git tags, GitHub Releases,
|
||||
@@ -159,7 +160,10 @@ The Agent Skill and Claude plugin live in this repository:
|
||||
is the Claude plugin manifest, with source `./skills` so an install copies
|
||||
only the skill tree;
|
||||
- [`.claude-plugin/marketplace.json`](../.claude-plugin/marketplace.json)
|
||||
makes the repository a Claude Code marketplace.
|
||||
makes the repository a Claude Code marketplace;
|
||||
- [`.codex-plugin/plugin.json`](../.codex-plugin/plugin.json) is the Codex
|
||||
plugin manifest, pointing at the same `./skills/` tree, with its icon in
|
||||
[`assets/icon.svg`](../assets/icon.svg).
|
||||
|
||||
They are not a fifth required release surface. A CLI change that affects
|
||||
command routing, extras, exit codes, mark keys, or the intended-use boundary
|
||||
@@ -215,6 +219,26 @@ the catalog has not accepted the initial submission. The catalog-specific
|
||||
submission state and links live in
|
||||
[`docs/agent-skill.md`](agent-skill.md#where-the-skill-can-actually-be-listed).
|
||||
|
||||
## MCP registry
|
||||
|
||||
[`server.json`](../server.json) lists the PyPI package in the
|
||||
[official MCP registry](https://registry.modelcontextprotocol.io/) as
|
||||
`io.github.wiltodelta/remove-ai-watermarks`, started as
|
||||
`uvx remove-ai-watermarks mcp`. The registry accepts it only after PyPI serves a
|
||||
release whose description, the README, carries the `mcp-name:` marker, and
|
||||
`tests/test_mcp_server.py` pins `server.json` to the package version. The listing
|
||||
is not a release surface that blocks a release; after PyPI publishes a version:
|
||||
|
||||
1. `mcp-publisher login github` with an account that owns `wiltodelta`;
|
||||
2. `mcp-publisher validate` and then `mcp-publisher publish` from the
|
||||
repository root;
|
||||
3. read the entry back from
|
||||
`https://registry.modelcontextprotocol.io/v0/servers?search=io.github.wiltodelta/remove-ai-watermarks`
|
||||
and check its version.
|
||||
|
||||
[`glama.json`](../glama.json) names the maintainer for Glama's claim flow.
|
||||
Client setup and the tool contract are in [the MCP server guide](mcp-server.md).
|
||||
|
||||
## Release verification
|
||||
|
||||
Forensic transports are versioned independently from the package. Before publishing
|
||||
|
||||
@@ -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
|
||||
@@ -65,16 +100,62 @@ This page is an index. Each finding lives in its canonical page.
|
||||
them it clears both vendors only at lower face and CJK-text fidelity than
|
||||
`qwen-zimage`
|
||||
([Chroma1 research](chroma1-engine-research.md#flux2-klein-as-a-global-stage-2026-09-27)).
|
||||
Still open:
|
||||
Z-Image-Turbo as a global stage (with an Apple Silicon arm through mflux),
|
||||
linear probes on newer frozen backbones for `classify`, Resemble Perth and
|
||||
Meta PixelSeal as benchmark oracles, re-watermarking as a removal primitive
|
||||
(arXiv:2605.16796, MIT code), RAVEN view-synthesis removal
|
||||
(arXiv:2601.08832, no code released as of 2026-09-24), and Apache-2.0 video
|
||||
inpainting (VACE, ROSE) against the per-frame visible video fill.
|
||||
Apache-2.0 temporal video inpainting was also measured: VACE and ROSE were
|
||||
less stable than MI-GAN on all three Sora, Veo, and Kling clips, VACE left a
|
||||
gray block on Veo, and ROSE retained the Veo wordmark on 17/17 frames. They
|
||||
are not shipped
|
||||
([video inpainting research](video-inpainting-research.md)).
|
||||
Resemble Perth and Meta PixelSeal were added as exact-revision, MIT-licensed
|
||||
local benchmark oracles. The 2026-09-28 study found Perth on all three speech
|
||||
positives and confirmed that visible-video cleaning copies its marked AAC
|
||||
packets unchanged; PixelSeal survived JPEG q90 on both cleared image
|
||||
carriers, while its CUDA-only removal profiles could not run on this host
|
||||
([benchmark study](benchmark-kernel.md#perth-and-pixelseal-local-oracle-study)).
|
||||
Re-watermarking (arXiv:2605.16796) was reproduced locally on 2026-09-27:
|
||||
same-encoder message B replaced A on all 12/12 valid TrustMark and 12/12
|
||||
VideoSeal image controls, both VideoSeal video clips, and all 6/6 valid
|
||||
controls in each DWT-DCT direction. Median image clean-to-output PSNR was
|
||||
39.73-44.15 dB; the DWT-DCT controls worked on only half the carriers.
|
||||
This is an open-encoder result, not a SynthID result
|
||||
([removal research](synthid-removal-research.md#re-watermarking-on-open-encoders-2026-09-27)).
|
||||
A one-carrier production probe subsequently kept OpenAI SynthID `detected`
|
||||
on both its metadata-free control and 43.04 dB VideoSeal-overlaid image;
|
||||
a visible-clean, metadata-free Google control and its 42.86 dB VideoSeal
|
||||
overlay also remained `detected` under direct SynthID-tool checks. The Google
|
||||
control and candidate positives came from different approved account slots,
|
||||
so this remains a one-carrier coexistence result
|
||||
([removal research](synthid-removal-research.md#production-synthid-overmark-probe-2026-09-27-local)).
|
||||
Z-Image-Turbo was measured on 2026-09-29. It is not a global replacement:
|
||||
at its OpenAI margin point it improves both no-face typography carriers but
|
||||
loses face identity, and the same face/text split is stronger in the Google
|
||||
fidelity probe. The Google no-face outputs at 0.40 also visibly redraw scene
|
||||
geometry and object detail, despite better OCR and aggregate metrics, so a
|
||||
face detector alone cannot make a safe complement. The last Google boundary
|
||||
is unresolved because the available oracle checks were quota-blocked or
|
||||
could not accept the prepared upload.
|
||||
The mflux arm works on an M5 with 32 GB and is the only measured native Apple
|
||||
Silicon image-regeneration path, since every shipped image profile is
|
||||
CUDA-only. It took 301-380 seconds per image. This proves M5 feasibility but
|
||||
is not a usable fallback at the measured quality
|
||||
([Chroma1 research](chroma1-engine-research.md#z-image-turbo-as-a-global-stage-2026-09-29)).
|
||||
The experiment is closed with a decision not to add a global or mflux
|
||||
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`.
|
||||
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-17, when the repository still held only that README (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`,
|
||||
@@ -91,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). |
|
||||
|
||||
@@ -79,6 +81,9 @@ independent frame fill.
|
||||
| `auto` | Depends on installed extras | Selects LaMa, then MI-GAN, then OpenCV |
|
||||
|
||||
The learned backends download model files on first use.
|
||||
[VACE and ROSE were evaluated](video-inpainting-research.md) on the same
|
||||
production masks and were not adopted: neither improved temporal stability
|
||||
over MI-GAN, and ROSE retained the Veo mark.
|
||||
|
||||
## Metadata and provenance
|
||||
|
||||
@@ -90,6 +95,8 @@ The inspection and stripping code handles signals in these groups:
|
||||
- exact app-export provenance and AIGC disclosures from supported
|
||||
ByteDance-family products, with product-only provenance excluded from the
|
||||
generated-image verdict;
|
||||
- Kling's H.264 `user_data_unregistered` SEI tag (`kling-ai`) in MP4/MOV
|
||||
video, blanked in place by `video metadata --remove` without re-encoding;
|
||||
- IPTC AI disclosure fields;
|
||||
- PNG text chunks and embedded generation parameters, including keyed
|
||||
`workflow` and `prompt` tags in MP4/MOV metadata lists;
|
||||
@@ -99,6 +106,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;
|
||||
@@ -190,8 +205,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
|
||||
@@ -284,7 +302,7 @@ not a universal clean verdict.
|
||||
| Luma AI | None registered | No registered pixel decoder | PNG text generator tags (Uni-1) |
|
||||
| ByteDance generators | Doubao and Jimeng marks | No registered pixel decoder | TC260 AIGC, supported C2PA, and exact app-export AIGC disclosures |
|
||||
| Qwen | Qwen mark | No registered pixel decoder | TC260 AIGC |
|
||||
| Kling AI | Kling AI image and video marks | No registered pixel decoder | TC260 AIGC |
|
||||
| Kling AI | Kling AI image and video marks | No registered pixel decoder | TC260 AIGC and the video SEI tag |
|
||||
| Vidu (ShengShu) | Vidu video mark | No registered pixel decoder | TC260 AIGC in the MP4 `AIGC` key |
|
||||
| Runway | None measured on paid-plan output | No registered pixel decoder | C2PA signed `RUNWAY AI, INC.` on its own models; third-party models keep their vendor's manifest |
|
||||
| Amazon Bedrock (Nova Canvas, Nova Reel) | None | Invisible watermark readable only through the Bedrock API | C2PA signed `Amazon Web Services, Inc.`, claim generator `Amazon Bedrock` |
|
||||
@@ -293,8 +311,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
|
||||
|
||||
@@ -200,7 +200,7 @@ route in this campaign. Mechanism detail stays in
|
||||
| --- | --- | --- |
|
||||
| Gowal et al., [arXiv:2510.09263](https://arxiv.org/abs/2510.09263) | Post-hoc encoder `f` / decoder `g`. Detection logit is not payload recovery. SynthID-O (partner variant) 136 bits at 512x512. TPR at 0.1% FPR 99.98% aggregated, 99.97% on the hardest spatial crop+resize. Trains against sampled semantics-preserving transforms, including weak VAE regeneration. Production decoder unpublished | Matches the architecture we treat as keyed `x' = x + g(x)`. Explains why a two-pixel crop kills `pipeline_lattice` but not the official oracle, and why a 22.3 dB foreign VAE still reads `detected` |
|
||||
| DeepMind [US12094474B1](https://patents.google.com/patent/US12094474B1/en) and continuation [US20250149048A1](https://patents.google.com/patent/US20250149048A1/en) | Residual U-Net encoder, separate decoder, optional key, encoder/decoder ensembles that need not recognize each other | Constraint, not a recipe. Ensemble non-recognition is why one recovered Google tile cannot be reused as an OpenAI detector (S4) |
|
||||
| Dathathri et al., [Nature 634:818-823 (2024)](https://www.nature.com/articles/s41586-024-08025-4) | SynthID-Text: tournament sampling of LLM tokens, open-source | Different system. Image/audio/video remain proprietary |
|
||||
| Dathathri et al., [Nature 634:818-823 (2024)](https://www.nature.com/articles/s41586-024-08025-4) | SynthID-Text: tournament sampling of LLM tokens, open-source. Anthropic's [Claude text watermark](https://www.anthropic.com/news/claude-text-watermark) (announced 2026-08-14 for future Claude models, updated 2026-09-01) "is a version of the SynthID-Text approach", with its detection API in private preview | Different system. Image/audio/video remain proprietary |
|
||||
| OpenAI, [advancing content provenance](https://openai.com/index/advancing-content-provenance/) (2026-05-19, audio 2026-07-31) and [content provenance API](https://developers.openai.com/api/docs/guides/content-provenance) | ChatGPT / API / Codex images carry C2PA plus SynthID. Audio from 2026-07-31. `POST /v1/content_provenance_checks`. `not_detected` does not rule out another vendor | This is the oracle. C2PA and SynthID are independent entries. Do not abuse the endpoint as an adaptive reverse-engineering loop |
|
||||
|
||||
### Keyless presence detectors in the literature
|
||||
|
||||
@@ -228,19 +228,29 @@ regeneration is already in [synthid.md](synthid.md#23-removal-attacks-and-forens
|
||||
| Cao et al., [arXiv:2608.10166](https://arxiv.org/abs/2608.10166) (MarkNull, USENIX Security 2026) | On-manifold latent decorrelation via a public diffusion proxy. Claims 100% on 20 Imagen-3 Gemini-verify images. PSNR 25.36 dB, SSIM 0.80 | Small Gemini-verify set | Independent evidence that a no-box latent reconstruction can confuse Gemini. Does not meet this project's 40 dB / 0.99 SSIM release gate. Still generation, not a pixel-only wipe |
|
||||
| Goonatilake and Ateniese, [arXiv:2605.09203](https://arxiv.org/abs/2605.09203) | Six removers all leave a forensic residue a ResNet-50 sees at >98% TPR @ 1% FPR | Applies to UnMarker, Zhao's WatermarkAttacker, CtrlRegen+ | Defeating a provider oracle is not deniability. This is the product remainder |
|
||||
| An et al., [arXiv:2401.08573](https://arxiv.org/abs/2401.08573) (WAVES, ICML 2024) | 26 attacks on StegaStamp, Stable Signature, Tree-Ring. Regeneration, not JPEG, is the attack that matters. StegaStamp TPR@1%FPR 1.00 to 0.01; Tree-Ring 0.99 to 0.12 | Open watermarks | Protocol. Our blur-sigma-7 and 16-32 scramble are closer to WAVES "distortion" than to regeneration |
|
||||
| Bulychev et al., [arXiv:2605.16796](https://arxiv.org/abs/2605.16796) (Watermarks Attack Watermarks) | Apply a second watermark, usually with the victim's own post-processing encoder and a different message. On MS-COCO, its end-to-end pipeline lowers Video Seal victim bit accuracy from 0.998 to 0.520 (TPR@1%FPR 0.046) and Pixel Seal from 0.999 to 0.514 (TPR 0.029); the same-method attacker message recovers at 0.99 bit accuracy for both schemes. Quality is reported as an eight-metric normalized composite, not a raw PSNR operating point | Eight open image schemes, not production SynthID; Video Seal is tested on images | Directly motivated the [local open-encoder replication](#re-watermarking-on-open-encoders-2026-09-27). Its MIT [code](https://github.com/MariaBulychev/Watermarks-Attack-Watermarks/tree/0444f52ad972da9e009555f2dc1028d9abe0610e) calls external encoder packages and weights; the top-level license does not license those dependencies |
|
||||
| Yang et al., [arXiv:2609.01249](https://arxiv.org/abs/2609.01249) (One Prompt Is Enough) | One reconstruction prompt to a closed image editor (GPT Image 1, 1.5 and 2; Nano Banana, Pro and 2) launders the payload over 1,800 outputs; OpenAI models disrupt it most | DwtDct, DwtDctSvd and RivaGAN only. "SynthID was not tested" | Regeneration through a provider's own model. It breaks the scheme our DWT-DCT detector reads, and says nothing about production SynthID, which the same editors add to their output |
|
||||
| Yao et al., [arXiv:2610.11290](https://arxiv.org/abs/2610.11290) (ProxyEraseAgent) | Ranks a pool of open decoders by calibrated response and attacks the strongest as proxy boundary estimators; 94.8% success over 11 systems against 79.5% for the best fixed Regen-VAE | Open schemes, TrustMark and VideoSeal among them. Not SynthID | Needs a decoder that responds to the mark. No public decoder responds to production SynthID, so it does not transfer to an oracle-only target |
|
||||
| Bao et al., [arXiv:2609.08213](https://arxiv.org/abs/2609.08213) (DRIFT) | Deflects the diffusion trajectory. The adaptive variant searches a strength ladder per image for "the first verifier-rejected rung" from binary verifier feedback; 98-100% success on nine marks | In-generation diffusion marks (Tree-Ring family, Gaussian Shading and others). Not SynthID | The ladder stop is our [attack ladders](#attack-ladders-openai-oracle) run per image. Against a provider verifier that is the adaptive query loop its terms forbid, so it applies to local oracles only |
|
||||
| Ye et al., [arXiv:2610.03166](https://arxiv.org/abs/2610.03166) (LiBRA) | White-box latent optimization with the key and decoder, steering decoding confidence toward chance from both sides so the mark is not left inverted but detectable; Stable Signature detection 1.00 to 0.03 | Released checkpoints of Stable Signature, the YU1 GAN fingerprint, HiDDeN and MBRS | Needs the decoder, so it is out of reach here. The inverted-mark point applies to any bit-flip attack |
|
||||
| Aistov et al., [arXiv:2610.02010](https://arxiv.org/abs/2610.02010) (Latent Frequency Masking, ICDM 2026) | Replaces selected Fourier coefficients of the latent; [code](https://github.com/alvitrioliks/lfm) | Stable Signature, Tree-Ring, RingID, Gaussian Shading (resists), Maxsive, METR. Not SynthID | In-generation latent marks. Not the post-hoc encoder family Gowal describes |
|
||||
| Li et al., [arXiv:2609.31693](https://arxiv.org/abs/2609.31693) (Disentangle and Drop) | Splits the image into a grayscale carrier and a residual branch, then suppresses the residual, with one shared operating setting | SSL, DwtDct, DwtDctSvd, RivaGAN, StegaStamp, Stable Signature, Tree-Ring | Open schemes only |
|
||||
| Yang et al., [arXiv:2609.31722](https://arxiv.org/abs/2609.31722) (Push-Pull) | Key-free and decoder-free at deployment; average BER 0.3958 at 31.07 dB PSNR on a four-method sweep | Seven open methods. Not SynthID | A BER near 0.40 leaves much of the payload intact, as with SPFM-Net below |
|
||||
| Ding et al., [arXiv:2609.34003](https://arxiv.org/abs/2609.34003) (DeMark, audio) | Query-free black box that suppresses the time-frequency artifacts the embedding leaves; average attack success 0.92 and 0.96 on two speech sets | AudioSeal, WavMark, Timbre, AudioMarkNet. Not audio SynthID | A candidate cohort for the pinned AudioSeal oracle in the [benchmark kernel](benchmark-kernel.md). No code located |
|
||||
| Wen et al., [arXiv:2305.20030](https://arxiv.org/abs/2305.20030) (Tree-Ring) | In-generation Fourier pattern in initial noise | Not SynthID | Kill is DDIM inversion plus latent wipe, not a pixel scramble. Lin and Juarez [arXiv:2506.10502](https://arxiv.org/abs/2506.10502) (USENIX 2025) remove it from public knowledge |
|
||||
| Fernandez et al., Stable Signature, ICCV 2023 | Fine-tuned VAE decoder | Not SynthID | Regeneration that replaces the decoder is in-family. Our foreign VAE at 22.3 dB did not kill OpenAI SynthID, which is the expected mismatch |
|
||||
| Google to [The Verge, 2026-04-14](https://www.theverge.com/ai-artificial-intelligence/911579/google-synthid-ai-watermarking-system-reverse-engineered) | reverse-SynthID does not systematically remove SynthID | Google spokesperson on the spectral bypass | Agrees with our V4 audit and with Bypass: reconstruction or decoder confusion, not a payload delete |
|
||||
|
||||
Quiet pixel-only removal is not a published result on production
|
||||
OpenAI or Google SynthID (still true in the 2026-09-22 literature sweep).
|
||||
Against open encoders the fidelity band has moved: re-watermarking
|
||||
([arXiv:2605.16796](https://arxiv.org/abs/2605.16796)), WmForger
|
||||
OpenAI or Google SynthID (still true in the 2026-10-17 literature sweep).
|
||||
Against open encoders the fidelity band has moved: WmForger
|
||||
([arXiv:2510.20468](https://arxiv.org/abs/2510.20468)) and Hide&Seek
|
||||
([arXiv:2603.01067](https://arxiv.org/abs/2603.01067)) report roughly 30 dB,
|
||||
and SPFM-Net and FDDWAN (arXiv:2607.27811, 2607.27800) claim 40-45 dB, but on
|
||||
small images and with bit error rates that leave much of the payload intact.
|
||||
None of these is a result on a production closed watermark.
|
||||
The re-watermarking paper reports normalized quality degradation, not a raw
|
||||
PSNR aggregate; our separate local measurements are below. None of these is a
|
||||
result on a production closed watermark.
|
||||
|
||||
## Product remainder for removal
|
||||
|
||||
@@ -248,6 +258,146 @@ Invisible removal in this package regenerates through `qwen-zimage` or
|
||||
`sdxl-zimage`. It is lossy. There is no shipped pixel-only OpenAI or
|
||||
Google SynthID wipe. Do not add Bayer or geometry as remover arms.
|
||||
|
||||
### Re-watermarking on open encoders (2026-09-27)
|
||||
|
||||
The CC BY 4.0 [paper](https://arxiv.org/abs/2605.16796), v1 submitted on
|
||||
2026-05-16, tests eight open image watermarks: Stable Signature and Tree-Ring
|
||||
(in-generation), and StegaStamp, RoSteALS, ZoDiac, Pixel Seal, WAM, and Video
|
||||
Seal (post-processing). Its policy
|
||||
reapplies the victim encoder with a different message for post-processing
|
||||
marks and uses ZoDiac against in-generation marks. Its Video Seal evaluation
|
||||
is on images, not video. The [MIT repository](https://github.com/MariaBulychev/Watermarks-Attack-Watermarks/tree/0444f52ad972da9e009555f2dc1028d9abe0610e)
|
||||
is pinned at `0444f52` and imports separate upstream encoders. We reproduced
|
||||
the same-encoder, different-message operation with the project's pinned
|
||||
oracles, not the paper's full WAVES dataset, classifier, or exact dependency
|
||||
stack. TrustMark and DWT-DCT are project extensions, not paper subjects.
|
||||
|
||||
Twelve publication-cleared, provider-paired images from six content strata
|
||||
were fitted to 512 x 512 pixels. Message A was embedded and serialized to PNG;
|
||||
message B was then embedded into that decoded artifact and serialized again.
|
||||
All decoder and fidelity readings used the saved RGB bytes. A successful case
|
||||
requires a positive A control, an A-negative final verdict, and a B-positive
|
||||
final verdict under that scheme's pinned rule. The table reports mean final
|
||||
bit accuracy and median clean-to-final PSNR over valid A controls only.
|
||||
|
||||
| Encoder and direction | Valid A controls / all | B replaces A / valid | Final accuracy A / B | Median total PSNR |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| DWT-DCT, FLUX codeword to SDXL codeword | 6 / 12 | 6 / 6 | 0.563 / 1.000 | 39.80 dB |
|
||||
| DWT-DCT, SDXL codeword to FLUX codeword | 6 / 12 | 6 / 6 | 0.566 / 0.997 | 39.73 dB |
|
||||
| TrustMark P, 61-bit messages | 12 / 12 | 12 / 12 | 0.541 / 1.000 | 44.15 dB |
|
||||
| VideoSeal image mode, 256-bit messages | 12 / 12 | 12 / 12 | 0.479 / 0.990 | 43.89 dB |
|
||||
|
||||
The DWT-DCT codewords differ at only 21 of 48 bits, so A accuracy near 0.56
|
||||
after B is compatible with complete replacement; it is not evidence of a
|
||||
surviving A signal. Conversely, its 6/12 A-positive rate in each direction
|
||||
precludes an unconditional success claim. These are fixed messages and a
|
||||
small, deliberately stratified cohort, not a calibrated TPR@1%FPR estimate.
|
||||
The worst-valid-case contact sheet shows no obvious coarse artifacts at
|
||||
256-pixel viewing size, but PSNR and that sheet do not establish full-resolution
|
||||
perceptual, face, or text fidelity.
|
||||
|
||||
The [corrected forgery study](watermark-forgery-study.md#corrected-videoseal-measurements-2026-09-08)
|
||||
already showed VideoSeal replacement on saved video. A fresh run added
|
||||
decoded-artifact fidelity on two 64-frame, 256 x 256, H.264 CRF 8 clips:
|
||||
the synthetic moving gradient went from A accuracy 1.000 to 0.469 and B
|
||||
accuracy 1.000 at 42.88 dB mean clean-to-final PSNR; the publication-cleared
|
||||
Sora clip went from A 1.000 to 0.473 and B 0.996 at 37.60 dB. Both meet the
|
||||
fixed-message replacement rule. This is an extension to video, not a paper
|
||||
replication or a guarantee across codecs and sources.
|
||||
|
||||
`scripts/rewatermarking_study.py` and `scripts/rewatermarking_video_study.py`
|
||||
write case rows, artifact hashes, and reports under the untracked
|
||||
`.local-eval/rewatermarking-2026-09-27/` and
|
||||
`.local-eval/rewatermarking-video-2026-09-27/`. Case-row SHA-256 values are
|
||||
`3270df31320c0c58091f34b9c2e1f4885190fe65577e0dbbe03446b2b8bda890`
|
||||
and `150e6584d5f253e52ddb0024ab9ad17f94b03b64dbfd4c293d57f6c6510607c6`.
|
||||
The retained PNGs and MP4s were independently rehashed and decoded for PSNR
|
||||
verification. Both runs used the local CPU; no Modal GPU was used and there
|
||||
was no cloud GPU charge. This establishes a local open-encoder overwrite
|
||||
primitive, not a production SynthID remover. An open encoder's foreign message
|
||||
may coexist with a secret-keyed provider mark.
|
||||
|
||||
### Production SynthID overmark probe (2026-09-27 local)
|
||||
|
||||
One previously oracle-positive, publication-cleared original per provider
|
||||
was selected from `data/synthid/manifest.csv`. The initial preparation made a
|
||||
native-resolution RGB pixel control and a VideoSeal image-mode 256-bit
|
||||
message-B overlay. It stripped AI metadata without changing decoded pixels,
|
||||
but that rule was insufficient: the Google source retained its visible Gemini
|
||||
sparkle. Its Gemini Web checks therefore cannot serve as pixel-only SynthID
|
||||
positive controls. The OpenAI case had no visible mark; it remains 1122 x 1402
|
||||
at 43.04 dB control-to-overlay PSNR, with B decoded at 1.000. The pair is
|
||||
deliberately small and tests coexistence with an *unrelated* open watermark,
|
||||
not application of a secret SynthID encoder.
|
||||
|
||||
The official OpenAI Content Provenance API reported SynthID `detected` on
|
||||
both the metadata-free pixel control and its VideoSeal overlay, with C2PA
|
||||
`not_present` for each (2026-09-28 UTC). Thus the successful open-encoder
|
||||
overwrite did **not** remove OpenAI SynthID on this carrier.
|
||||
|
||||
The separately approved Google control upload reached Gemini Web's exact
|
||||
`Connecting to Verify AI` state on 2026-09-28 and remained there well beyond
|
||||
the project's documented 90-second indeterminate threshold. A separately
|
||||
approved retry of the same prepared SHA-256 reached that state, then returned
|
||||
only a generic visual-content answer: Gemini attributed the image to Google AI
|
||||
from a visible four-pointed logo and text-rendering characteristics. It exposed
|
||||
no settled SynthID tool outcome. Both observations are watermark
|
||||
`indeterminate` and provenance `unavailable`, not clean or `detected`. The
|
||||
VideoSeal-overlaid Google candidate was not uploaded because the positive
|
||||
control gate was not satisfied. Therefore these runs supply no Google SynthID
|
||||
verdict and do not test re-watermarking against production Google SynthID.
|
||||
|
||||
The preparation protocol now removes detected visible AI marks and AI metadata
|
||||
before deriving either arm. On this Google source, the project-native Gemini
|
||||
remover validated removal of the sparkle and changed 0.213% of pixels. VideoSeal
|
||||
was then applied to that cleaned control: B accuracy is 0.992 and
|
||||
control-to-overlay PSNR is 42.86 dB. Visual inspection found no remaining logo;
|
||||
the local sparkle detector scored the prepared control 0.300, below its 0.35
|
||||
detection threshold. The corrected control SHA-256 is
|
||||
`14ed25fa58a0abc928ea869431248b450892facdf85b4621b3bf959c16a78cf3`;
|
||||
the corrected candidate is
|
||||
`86000c8e143339adde675522dc7709830c8ff230977e276c748cabb5d7cfadb8`.
|
||||
The separately approved corrected-control upload first received the generic
|
||||
question about whether Google AI created or edited the image. Gemini said the
|
||||
amount of text made reliable confirmation difficult and could not confirm its
|
||||
origin. That origin question was the wrong contract for a SynthID experiment;
|
||||
its answer remains `indeterminate`. A confirmed follow-up asked directly
|
||||
whether the same attached image contains a SynthID watermark, required the
|
||||
SynthID verification tool, and prohibited inference from visible content,
|
||||
logos, text, or metadata. Gemini then returned its settled verification-tool
|
||||
positive: the image was created or edited with Google AI, with the Verify AI
|
||||
SynthID help link. The corrected control is therefore watermark `detected` and
|
||||
provenance `unavailable`. The separately approved candidate upload then used
|
||||
the same direct SynthID question on 2026-09-29. Gemini explicitly refused it
|
||||
because the verification-tool quota had been exceeded. That result is
|
||||
watermark `refused` and provenance `unavailable`, not clean, `detected`, or
|
||||
`indeterminate`. With separate approval, the exact same candidate upload was
|
||||
then checked once in the `/u/2` slot. Its settled verification-tool response
|
||||
said the image contained signals indicating Google-AI generation or editing.
|
||||
That batch is watermark `detected` and provenance `unavailable`. Thus the
|
||||
42.86 dB VideoSeal overlay did **not** remove Google SynthID on this carrier.
|
||||
The control and usable candidate verdict came from different authenticated
|
||||
account slots because `/u/1` exhausted its rolling quota; this is a one-carrier
|
||||
coexistence result, not an account-invariance or broad production guarantee.
|
||||
|
||||
Files, hashes, API response payloads, and result verification live under the
|
||||
untracked `.local-eval/rewatermarking-synthid-2026-09-27/`. The OpenAI batch's
|
||||
two prepared upload SHA-256 values are
|
||||
`f79189bcc98c6e2c0bf9737fa033b83870475560f3bff897c6e80508d90a3336`
|
||||
and `6949efa56bbfb285a580994536f8ec623af801187e26ba0f27da895488571340`.
|
||||
That two-result immutable batch passed `verify --require-complete`. The first
|
||||
Google batch passed hash verification with 1/2 rows recorded. A one-row retry
|
||||
batch for the same control passed `verify --require-complete` with one
|
||||
`indeterminate` result. The submitted control SHA-256 in both batches is
|
||||
`fdb34c868e533a2c3847da868c708b0c6bd5e586523ee7e6d7ee1773b321fbc4`.
|
||||
Those batches are retained as evidence of the flawed visible-logo control, not
|
||||
as SynthID controls. The corrected local artifacts and direct-SynthID-question
|
||||
batches live under `.local-eval/rewatermarking-synthid-logo-clean-2026-09-28/`.
|
||||
The control batch's single `detected` record and the candidate batch's single
|
||||
`refused` record in `/u/1`, plus the separately approved `/u/2` candidate
|
||||
batch's single `detected` record, each passed `verify --require-complete`. No
|
||||
cloud GPU was used.
|
||||
|
||||
### Color restoration after regeneration (2026-09-27)
|
||||
|
||||
The NeurIPS 2024 "Erasing the Invisible" winner
|
||||
|
||||
@@ -575,7 +575,11 @@ The SynthID decoder is proprietary and not released:
|
||||
> -- Gowal et al., arXiv:2510.09263
|
||||
|
||||
There are no released payload-decoder weights or public algorithm. Google
|
||||
provides verification in Gemini. The old limited SynthID Detector portal does
|
||||
provides verification in Gemini and, since the public SynthID Detector opened,
|
||||
at [synthid.com](https://synthid.com/). That site is the `synthid-web` oracle
|
||||
surface in [`provider-oracles.md`](provider-oracles.md). On 2026-10-17 its FAQ
|
||||
named Google DeepMind, Nvidia, OpenAI, and Kakao, and its settled labels were
|
||||
`Watermark detected` and `Watermark not detected`. The old limited portal does
|
||||
not work: on 2026-09-07, its official `labs.google/synthid` entry point
|
||||
redirected a signed-in browser to a Google 404. On 2026-09-23 the same entry
|
||||
point redirected to `deepmind.google.com/frontiers/synthid`, which also returned
|
||||
@@ -666,6 +670,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 +738,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).
|
||||
|
||||
@@ -262,8 +262,8 @@ and implemented by `scripts/provider_oracles.py`.
|
||||
|
||||
Proprietary watermark removal cannot be verified locally by design -- no public decoder
|
||||
exists. Each vendor has its own oracle and it covers only that vendor's content:
|
||||
the Content Provenance API first and `openai.com/verify` as an explicit fallback for
|
||||
OpenAI, the Gemini app for Google, and Microsoft's
|
||||
the Content Provenance API first and `openai.com/verify` for
|
||||
OpenAI, the Gemini app and the SynthID Detector at `synthid.com` for Google, and Microsoft's
|
||||
[Content Provenance Detection API](https://learn.microsoft.com/en-us/azure/ai-services/content-safety/how-to/how-to-provenance-detection)
|
||||
for InvisMark. The Microsoft API reports `Watermark` and `C2PA` separately. It therefore
|
||||
needs a pixel-identical metadata-stripped control: the control must lose `C2PA` while
|
||||
@@ -449,10 +449,12 @@ audio-path attacks separately. Its carriers are synthetic non-speech audio
|
||||
and its oracle is AudioSeal, so it validates the copy semantics, not provider
|
||||
audio marks. The audio and video arms are now first-class benchmark rows:
|
||||
[benchmark kernel](benchmark-kernel.md) accepts `media_type: audio` and
|
||||
`media_type: video` with the revision-pinned `audioseal` and `videoseal`
|
||||
adapters, and both cohorts carry matched negatives, codec, and degradation
|
||||
arms as strict manifest cases. The temporal layer is closed too: videoseal
|
||||
detection records carry per-frame bit accuracy, and
|
||||
`media_type: video` with the revision-pinned `audioseal`, `perth`, and
|
||||
`videoseal` adapters, plus the `pixelseal` image adapter. The Perth and
|
||||
PixelSeal study records matched negatives, marked rows, attacks, fixed-message
|
||||
accuracy, and the copied-audio path as strict manifest cases. The temporal
|
||||
layer is closed too: videoseal detection records carry per-frame bit accuracy,
|
||||
and
|
||||
[VideoSeal temporal evaluation](videoseal-temporal-evaluation.md) measures
|
||||
the aggregation matrix and the crf decay curve on synthetic and real clips.
|
||||
What remains open here is a wider real-content sweep and the learned temporal
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
# Temporal video inpainting evaluation
|
||||
|
||||
Development-only study run on 2026-09-27. It asks whether a permissively
|
||||
licensed temporal model improves visible video-mark removal enough to justify
|
||||
an optional backend. The answer on this corpus is no: VACE and ROSE were less
|
||||
temporally stable than the best shipped per-frame backend on every clip, VACE
|
||||
left an obvious block on Veo, and ROSE reproduced the Veo wordmark strongly
|
||||
enough for the production detector to accept all 17 output frames.
|
||||
|
||||
No provider oracle or private input was used. All source clips are tracked,
|
||||
publication-cleared fixtures. Generated videos, masks, reports, and contact
|
||||
sheets remain outside the repository.
|
||||
|
||||
## Models, licenses, and runtime requirements
|
||||
|
||||
| Model | Evaluated code and weights | License evidence | Official inference requirements |
|
||||
| --- | --- | --- | --- |
|
||||
| VACE | [`ali-vilab/VACE`](https://github.com/ali-vilab/VACE) at `48eb44f`, [`Wan-AI/Wan2.1-VACE-1.3B-diffusers`](https://huggingface.co/Wan-AI/Wan2.1-VACE-1.3B-diffusers) at `ec4d2cb` | Code and 1.3B weights are Apache-2.0. | The upstream repository tested Python 3.10.13, CUDA 12.4, and PyTorch 2.5.1. Its 1.3B profile targets about 81 x 480 x 832. The official [input contract](https://github.com/ali-vilab/VACE/blob/main/UserGuide.md) requires missing pixels to be gray and a separate white-generate/black-retain mask. |
|
||||
| ROSE | [`Kunbyte-AI/ROSE`](https://github.com/Kunbyte-AI/ROSE) at `6be41c5`, [`Kunbyte/ROSE`](https://huggingface.co/Kunbyte/ROSE) at `8a5e57c`, with [`alibaba-pai/Wan2.1-Fun-1.3B-InP`](https://huggingface.co/alibaba-pai/Wan2.1-Fun-1.3B-InP) at `9bcbbcf` | The top-level repository license, tuned transformer, and base model declare Apache-2.0. The separate `hugging_face/` demo vendors/imports ProPainter code and was excluded; a product package would have to keep that non-commercial path out. | The upstream recipe uses Python 3.12, CUDA 12.4, PyTorch 2.6.0, and torchvision 0.21.0. It requires `16n+1` frames, defaults to 480 x 720 and 50 steps, and combines the tuned 1.3B transformer with the Wan2.1-Fun base model. |
|
||||
|
||||
MiniMax-Remover and ProPainter were not evaluated because their weights or
|
||||
code are non-commercial. ROSE's core `inference.py` imports only the `rose/`
|
||||
Wan2.1-Fun path used here; the ProPainter-derived interactive demo is a
|
||||
separate license boundary, not evidence that the complete checkout is safe to
|
||||
redistribute as one Apache-only dependency.
|
||||
|
||||
## Design
|
||||
|
||||
The production `scan_video_marks` and `stabilize_localizations` paths selected
|
||||
the first continuous 17-frame accepted run for each mark. The production
|
||||
`_mask_for_region` then built every per-frame mask, including the mark-specific
|
||||
box or silhouette policy and padding. Seventeen frames satisfy ROSE's `16n+1`
|
||||
constraint and keep the cloud run small.
|
||||
|
||||
| Case | Tracked source | Frames | Source to working size | Mask pixels per frame |
|
||||
| --- | --- | ---: | --- | ---: |
|
||||
| Sora moving | `data/fixtures/visible/sora/provider-original.mp4` (`7dd8a9d4`) | 150-166 | 704 x 1280 to 448 x 832 | 4,508 |
|
||||
| Veo fixed | `data/fixtures/visible/veo/provider-original.mp4` (`e778194b`) | 0-16 | 1280 x 720 to 832 x 464 | 209 |
|
||||
| Kling fixed | `data/fixtures/visible/kling/provider-original.mp4` (`f3e8d6a7`) | 0-16 | 1280 x 720 to 832 x 464 | 2,968 |
|
||||
|
||||
The aspect-preserving working sizes are multiples of 16 and stay near the
|
||||
1.3B models' 480p operating point. `cv2`, MI-GAN, and LaMa ran through the
|
||||
library's own `fill` and `stabilize_filled_frame` calls on those same decoded
|
||||
frames and masks. VACE used 30 steps and the required RGB-128 hole. ROSE used
|
||||
its official unmasked-video-plus-mask conditioning at 50 steps. Seed 0 and an
|
||||
empty removal prompt were fixed for both models.
|
||||
|
||||
This is a deliberately small decision study, not a population estimate: one
|
||||
seed, one 17-frame window from each of three real exports, and no ground truth
|
||||
for the pixels hidden by the marks. It spans one moving mark and two fixed
|
||||
marks, but not every registered provider. Hailuo was screened and omitted
|
||||
because its real fixture did not produce an accepted visual run without
|
||||
provenance, so it could not satisfy the experiment's requirement to derive the
|
||||
mask from production detection.
|
||||
|
||||
Only generated pixels inside the production mask were composited into the
|
||||
source. This protects the rest of the image even though the raw VACE output
|
||||
changed outside-mask pixels by 1.10-2.51 levels of RGB MAE and ROSE by
|
||||
2.72-3.22. Every delivered arm was then encoded as H.264 CRF 14 at 16 fps and
|
||||
decoded again for measurement.
|
||||
|
||||
The primary temporal metric is mean absolute frame-to-frame RGB difference
|
||||
inside the union of adjacent masks. `excess` subtracts the same reading from
|
||||
the marked source. Lower is smoother, but it is not ground-truth recovery: a
|
||||
blurred patch can score well. Outside-mask MAE is measured after delivery, so
|
||||
its small non-zero value includes H.264 error even though the compositor kept
|
||||
the source pixels. The last two columns run the library's own detector on the
|
||||
delivered working-resolution video.
|
||||
|
||||
## Results
|
||||
|
||||
| Case | Method | Mask temporal MAD | Excess vs source | Outside-mask MAE | Detector frames / 17, max score | Run seconds |
|
||||
| --- | --- | ---: | ---: | ---: | --- | ---: |
|
||||
| Sora | OpenCV | 1.023 | -1.081 | 0.2613 | 0, 0.336 | 0.125 |
|
||||
| Sora | MI-GAN | 1.072 | -1.032 | 0.2559 | 0, 0.336 | 12.865 |
|
||||
| Sora | LaMa | 1.481 | -0.623 | 0.2577 | 0, 0.336 | 97.539 |
|
||||
| Sora | VACE | 1.619 | -0.485 | 0.2520 | 0, 0.336 | 12.226 |
|
||||
| Sora | ROSE | 1.581 | -0.523 | 0.2655 | 0, 0.336 | 14.051 |
|
||||
| Veo | OpenCV | 0.150 | +0.097 | 0.0047 | 0, 0.449 | 0.105 |
|
||||
| Veo | MI-GAN | 0.040 | -0.013 | 0.0007 | 0, 0.434 | 11.180 |
|
||||
| Veo | LaMa | 0.049 | -0.004 | 0.0039 | 0, 0.434 | 98.762 |
|
||||
| Veo | VACE | 1.338 | +1.286 | 0.0132 | 0, 0.491 | 11.301 |
|
||||
| Veo | ROSE | 0.330 | +0.277 | 0.0043 | **17, 0.663** | 13.000 |
|
||||
| Kling | OpenCV | 0.433 | +0.102 | 0.2653 | 0, 0.000 | 0.296 |
|
||||
| Kling | MI-GAN | 0.234 | -0.097 | 0.2687 | 0, 0.000 | 30.799 |
|
||||
| Kling | LaMa | 0.386 | +0.055 | 0.2756 | 0, 0.000 | 102.011 |
|
||||
| Kling | VACE | 0.737 | +0.406 | 0.2740 | 0, 0.000 | 12.709 |
|
||||
| Kling | ROSE | 0.840 | +0.510 | 0.2554 | 0, 0.000 | 13.045 |
|
||||
|
||||
OpenCV was narrowly lowest on Sora; MI-GAN was the best learned backend there
|
||||
and the lowest-scoring method on Veo and Kling. Relative to MI-GAN, VACE was
|
||||
1.51x, 33.65x, and 3.15x higher on Sora, Veo, and Kling; ROSE was 1.47x, 8.29x,
|
||||
and 3.60x higher. The resized Sora and Veo source controls themselves
|
||||
fell below the temporal acceptance rule (maximum scores 0.627 and 0.522), so a
|
||||
zero accepted-frame count cannot prove removal for those two cases. It remains
|
||||
useful as a mutation check: ROSE raised the resized Veo from 0 accepted frames
|
||||
to 17. The resized Kling source remained accepted on 17/17 frames at 0.675,
|
||||
and every output cleared that detector.
|
||||
|
||||
CPU runtimes were remeasured serially after other experiments stopped, with
|
||||
88.96 percent host CPU idle immediately before the run. They exclude model
|
||||
setup (MI-GAN 1.5-4.1 seconds; LaMa 3.4-13.1 seconds). GPU rows are clean
|
||||
pipeline-call time on one H100, excluding container start and model loading.
|
||||
VACE peaked at 22.60-22.87 GiB allocated and ROSE at 19.71-19.83 GiB.
|
||||
|
||||
At Modal's [2026-09-27 H100 list rate](https://modal.com/pricing) of
|
||||
$0.001097 per second, the measured VACE calls cost about $0.0398 total and the
|
||||
ROSE calls $0.0440, or $0.0837 for all six 17-frame calls. That is an
|
||||
inference-only floor. Cold starts, weight loading, warm-idle time, CPU, memory,
|
||||
and storage make the billed experiment higher.
|
||||
|
||||
## Frame review
|
||||
|
||||
- **Sora:** OpenCV produced a moving gray smear. MI-GAN, LaMa, correctly
|
||||
conditioned VACE, and ROSE all left a small bright sliver near the mascot's
|
||||
former edge; the temporal models did not reconstruct the deck seam better
|
||||
enough to offset their higher temporal MAD.
|
||||
- **Veo:** MI-GAN and LaMa blended the tiny translucent label into the flat
|
||||
background. VACE replaced it with a conspicuous gray rectangular block.
|
||||
ROSE visibly reproduced the `Veo` text on frames 0, 8, and 16, matching the
|
||||
detector's 17/17 result.
|
||||
- **Kling:** every method removed the wordmark, but the temporal models left a
|
||||
darker rectangular water patch. Their frame-to-frame MAD was 3.15-3.60x the
|
||||
MI-GAN result, so the patch moved in intensity rather than providing the
|
||||
expected temporal advantage.
|
||||
|
||||
An initial VACE control mistakenly passed the original marked pixels under the
|
||||
mask. It visibly reconstructed `Sora` and raised the detector score to 0.427.
|
||||
The official VACE preprocessing code replaces the hole with gray first; the
|
||||
control is retained in the external report as `vace`, marked
|
||||
`excluded_from_verdict`, while every tabled VACE row is the corrected
|
||||
`vace-gray` arm. This is direct evidence that a future VACE integration must
|
||||
pin the model's conditioning contract, not merely its mask polarity.
|
||||
|
||||
## Verdict and reproducibility
|
||||
|
||||
Do not add VACE or ROSE as an optional visible-video backend. Neither model
|
||||
beat the practical CPU MI-GAN tier on temporal stability, both introduced
|
||||
visible artifacts on simple backgrounds, and ROSE failed the basic Veo
|
||||
removal case. Their roughly 20-23 GiB H100 footprint, multi-model downloads,
|
||||
cold-start cost, and ROSE demo-license boundary add deployment complexity
|
||||
without a measured quality gain. Revisit only with a temporally trained model
|
||||
that can beat MI-GAN on a wider real corpus while preserving tiny-mask
|
||||
conditioning. This one-seed result rejects an optional backend now; it does not
|
||||
claim that no prompt or seed can improve either model. Any revisit should
|
||||
predefine that tuning budget and still beat the unchanged shipped controls.
|
||||
|
||||
The external harness SHA-256 is
|
||||
`e9bb2232bac2b719ae72d62e395699949609c3cdfdcd46e38f4d681ed3edee29`.
|
||||
The retained JSON report SHA-256 is
|
||||
`76da749eb0410cebb402914082f128ab339f943cc2596f05ae8c86d92cfedb41`.
|
||||
The run is bound to library commit `742a0dc`. The report records the complete
|
||||
model revisions, source and output hashes, masks, per-method metrics, and the
|
||||
invalid VACE control; full-frame and enlarged mask contact sheets were reviewed
|
||||
from the retained artifacts.
|
||||
@@ -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)").
|
||||
@@ -30,6 +37,12 @@ claim generator still falls back to the certificate issuer.
|
||||
Exact claim-generator mappings also cover `Grok Imagine` as xAI Grok Imagine
|
||||
and `Suno` as Suno. Both can carry a valid AI source claim without a separately
|
||||
readable issuer, so issuer-only attribution leaves the platform blank.
|
||||
Suno also [announced on 2026-08-06](https://suno.com/blog/building-the-future-of-music-responsibly)
|
||||
that it would adopt "new audio watermarking and fingerprinting technology" in
|
||||
the coming weeks. It did not name the technology
|
||||
([TechCrunch](https://techcrunch.com/2026/08/06/amid-legal-battles-suno-says-it-will-start-watermarking-songs/)
|
||||
asked whether it would be SynthID and got no answer), so there is no local
|
||||
detector, and metadata stripping leaves any such mark in the audio.
|
||||
|
||||
Some signers expose the product in `signature_info` while retaining a generic
|
||||
claim generator. xAI Grok Imagine is recognized from that signed identity even
|
||||
@@ -66,7 +79,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).
|
||||
|
||||
@@ -119,7 +132,7 @@ against primary sources before adding jurisdiction-specific claims.
|
||||
|
||||
## Visible AI-generation marks + detection methods (deep-research 2026-07-10, adversarially verified)
|
||||
|
||||
**Google Gemini visible "sparkle" -- tier-dependent, and spec-undocumented by Google.** Google primary sources (the Nano Banana Pro blog and the gemini.google image-generation page, both WebFetch-verified) confirm Gemini images carry BOTH the invisible SynthID (on ALL Google-AI media) AND a visible sparkle, but the visible mark is **tier-gated**: applied for FREE and Google AI **Pro** users, and **REMOVED** for Google AI **Ultra** subscribers, inside **Google AI Studio**, and on **API / dev** output. Update 2026-09-23: since 2026-08-14 a Gemini "Media watermark" setting lets any consumer tier switch the visible mark off for images, Omni video and Lyria music, except work accounts and India, South Korea and Vietnam without Ultra (Gemini help 17405358; the setting was observed in the Gemini web app on 2026-09-23). The tier-gating above is therefore no longer the only reason a sparkle is missing. So a Google-C2PA image with NO visible sparkle is expected (setting off, Ultra, API), not evidence it is clean -- this reinforces the `identify` "no visible mark != clean" rule. The ONLY official verifier is the SynthID flow (upload to the Gemini app, ask if it is AI-generated), which reads the INVISIBLE mark; there is **no official visible-sparkle detector**, and Google publishes **no** glyph geometry / size / opacity / color / locale / placement spec. So our capture-based sparkle template is the only source of truth and cannot be validated against a vendor spec -- keep reverse-engineering from real captures (do not expect a published spec).
|
||||
**Google Gemini visible "sparkle" -- tier-dependent, and spec-undocumented by Google.** Google primary sources (the Nano Banana Pro blog and the gemini.google image-generation page, both WebFetch-verified) confirm Gemini images carry BOTH the invisible SynthID (on ALL Google-AI media) AND a visible sparkle, but the visible mark is **tier-gated**: applied for FREE and Google AI **Pro** users, and **REMOVED** for Google AI **Ultra** subscribers, inside **Google AI Studio**, and on **API / dev** output. Update 2026-09-23: since 2026-08-14 a Gemini "Media watermark" setting lets any consumer tier switch the visible mark off for images, Omni video and Lyria music, except work accounts and India, South Korea and Vietnam without Ultra (Gemini help 17405358; the setting was observed in the Gemini web app on 2026-09-23). The tier-gating above is therefore no longer the only reason a sparkle is missing. So a Google-C2PA image with NO visible sparkle is expected (setting off, Ultra, API), not evidence it is clean -- this reinforces the `identify` "no visible mark != clean" rule. The ONLY official verifier is the SynthID flow: upload to the Gemini app and ask directly whether the file contains a SynthID watermark, requiring the SynthID verification tool and prohibiting inference from visible content, logos, text, or metadata. A generic question about AI generation is not an oracle result. The tool reads the INVISIBLE mark; there is **no official visible-sparkle detector**, and Google publishes **no** glyph geometry / size / opacity / color / locale / placement spec. So our capture-based sparkle template is the only source of truth and cannot be validated against a vendor spec -- keep reverse-engineering from real captures (do not expect a published spec).
|
||||
|
||||
**Google Veo video marks use two incompatible visible designs.** Public raw
|
||||
clips verify the current four-point diamond and the legacy bottom-right `Veo`
|
||||
@@ -270,6 +283,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 |
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "https://glama.ai/mcp/schemas/server.json",
|
||||
"maintainers": ["wiltodelta"]
|
||||
}
|
||||
@@ -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.42.0"
|
||||
version = "0.46.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,12 +199,15 @@ 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",
|
||||
# Development-only isolated browser automation for public provider oracles.
|
||||
"playwright>=1.62,<2",
|
||||
# The official MCP client, used only to test `remove-ai-watermarks mcp`
|
||||
# against the reference implementation. The server itself is stdlib-only.
|
||||
"mcp>=2.3,<3",
|
||||
"pytest>=8.0.0",
|
||||
"pytest-cov>=4.1.0",
|
||||
"pytest-xdist>=3.5.0",
|
||||
|
||||
@@ -70,6 +70,9 @@ explicitly names a tracked canonical result.
|
||||
| `qwen_scrub_prototype.py` | Probe low-strength Qwen regeneration on a GPU. |
|
||||
| `selective_text_restoration.py` | Evaluate text restoration over a scrubbed image. |
|
||||
| `synthid_pixel_probe.py` | Run the experimental local SynthID carrier probe. |
|
||||
| `rewatermarking_study.py` | Measure same-encoder message replacement and decoded-pixel fidelity on publication-cleared images with the open DWT-DCT, TrustMark, and VideoSeal image encoders. |
|
||||
| `rewatermarking_video_study.py` | Measure VideoSeal message replacement and mean PSNR on saved synthetic and publication-cleared MP4 clips. |
|
||||
| `rewatermarking_synthid_candidates.py` | Remove visible AI marks and metadata, then prepare native-resolution controls and VideoSeal-overlaid variants from two cleared SynthID-positive originals without making provider requests. |
|
||||
| `video_synthid_sweep.py` | Build oracle-gated video regeneration candidates. |
|
||||
|
||||
## Generated assets
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from datetime import date
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -10,15 +10,15 @@ if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def corpus_day(path: Path, corpus: Path) -> datetime.date | None:
|
||||
def corpus_day(path: Path, corpus: Path) -> date | None:
|
||||
"""Read the leading YYYY-MM-DD corpus segment, if present."""
|
||||
try:
|
||||
return datetime.date.fromisoformat(path.relative_to(corpus).parts[0])
|
||||
return date.fromisoformat(path.relative_to(corpus).parts[0])
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def corpus_files(corpus: Path, since: datetime.date | None, *, suffixes: Collection[str] | None = None) -> list[Path]:
|
||||
def corpus_files(corpus: Path, since: date | None, *, suffixes: Collection[str] | None = None) -> list[Path]:
|
||||
"""Return a deterministic recursive walk, optionally date- and suffix-bounded."""
|
||||
if since is None:
|
||||
candidates = corpus.rglob("*")
|
||||
|
||||
@@ -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 |
@@ -121,7 +121,7 @@ def load_pinned_models() -> tuple[object, object]:
|
||||
raw = convert_state_dict_for_scriptable_model(raw)
|
||||
model = create(config, device="cpu") # type: ignore[call-arg]
|
||||
load_state_dict(model, state_dict=raw)
|
||||
model.eval()
|
||||
model.train(False)
|
||||
return model
|
||||
|
||||
generator = load(GENERATOR_CARD, "generator_base.pth", AudioSealWMConfig, create_generator)
|
||||
|
||||
@@ -51,7 +51,7 @@ def export_onnx(checkpoint: Path, output: Path) -> None:
|
||||
if output.exists():
|
||||
raise SystemExit(f"refusing to overwrite existing output: {output}")
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
model = VisionEmbedding(load_headed_clip(checkpoint, torch.device("cpu"))).eval()
|
||||
model = VisionEmbedding(load_headed_clip(checkpoint, torch.device("cpu"))).train(False)
|
||||
export_options: dict[str, Any] = {}
|
||||
if "dynamo" in inspect.signature(torch.onnx.export).parameters:
|
||||
export_options["dynamo"] = False
|
||||
|
||||
@@ -252,7 +252,7 @@ def _lpips_model() -> tuple[Any, Any]:
|
||||
import torch
|
||||
|
||||
model = lpips.LPIPS(net="alex", verbose=False)
|
||||
model.eval()
|
||||
model.train(False)
|
||||
return model, torch
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Revision-pinned local Perth oracle for development-only benchmarks.
|
||||
|
||||
Perth v1 is an implicit audio watermark: its public model returns one presence
|
||||
confidence, not a user-selected payload. This adapter therefore exposes a
|
||||
thresholded presence reading and deliberately has no message label or forgery
|
||||
operation.
|
||||
|
||||
The official ``resemble-perth`` wheel predates current PyTorch releases and
|
||||
pins an obsolete stack. Development runs install the exact source revision
|
||||
below without dependency resolution, then verify the bundled checkpoint before
|
||||
loading it. Nothing here ships in the installed CLI or public Python API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import importlib.metadata
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
SOURCE_COMMIT = "ff1c8ac55a976971245cdd53c18d6131ca00d993"
|
||||
PACKAGE_NAME = "resemble-perth"
|
||||
PACKAGE_VERSION = "1.1.0"
|
||||
CHECKPOINT_SHA256 = "a15bce457ebc53ce5e6c9c3f11df78cf7ee2bf9cdab0a798902135b4c4027670"
|
||||
CHECKPOINT_RELATIVE_PATH = Path("perth_net/pretrained/implicit/perth_net_250000.pth.tar")
|
||||
SAMPLE_RATE = 16_000
|
||||
DETECTION_THRESHOLD = 0.5
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PerthReading:
|
||||
"""One implicit-watermark presence score."""
|
||||
|
||||
confidence: float
|
||||
|
||||
@property
|
||||
def detected(self) -> bool:
|
||||
"""Apply the upstream round-at-one-half decision rule."""
|
||||
return self.confidence > DETECTION_THRESHOLD
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1 << 20), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def import_failure() -> Exception | None:
|
||||
"""Return the missing or incompatible dependency error, if any."""
|
||||
try:
|
||||
import librosa # noqa: F401
|
||||
import perth
|
||||
import torch # noqa: F401
|
||||
import torchaudio # noqa: F401
|
||||
|
||||
if importlib.metadata.version(PACKAGE_NAME) != PACKAGE_VERSION:
|
||||
raise RuntimeError(f"{PACKAGE_NAME} must be installed at version {PACKAGE_VERSION}")
|
||||
if perth.PerthImplicitWatermarker is None:
|
||||
raise RuntimeError("resemble-perth imported without PerthImplicitWatermarker")
|
||||
except (ImportError, importlib.metadata.PackageNotFoundError, RuntimeError) as exc:
|
||||
return exc
|
||||
return None
|
||||
|
||||
|
||||
def available() -> bool:
|
||||
"""Whether the pinned Perth source stack is importable."""
|
||||
return import_failure() is None
|
||||
|
||||
|
||||
def checkpoint_path() -> Path:
|
||||
"""Return the package checkpoint after verifying its pinned digest."""
|
||||
failure = import_failure()
|
||||
if failure is not None:
|
||||
raise RuntimeError(
|
||||
"Perth oracle requires the pinned development source and its local dependencies; "
|
||||
f"install resemble-ai/Perth@{SOURCE_COMMIT} without adding it to runtime dependencies"
|
||||
) from failure
|
||||
import perth
|
||||
|
||||
path = Path(perth.__file__).resolve().parent / CHECKPOINT_RELATIVE_PATH
|
||||
if not path.is_file():
|
||||
raise RuntimeError(f"pinned Perth checkpoint is missing: {path}")
|
||||
digest = _sha256_file(path)
|
||||
if digest != CHECKPOINT_SHA256:
|
||||
raise RuntimeError(f"pinned Perth checkpoint digest mismatch: {digest} != {CHECKPOINT_SHA256}")
|
||||
return path
|
||||
|
||||
|
||||
def load_model() -> object:
|
||||
"""Load the pinned Perth implicit watermarker on CPU."""
|
||||
checkpoint_path()
|
||||
import perth
|
||||
|
||||
return perth.PerthImplicitWatermarker(device="cpu")
|
||||
|
||||
|
||||
def embed(model: object, samples: object) -> object:
|
||||
"""Embed Perth's fixed implicit watermark into mono float samples."""
|
||||
import numpy as np
|
||||
|
||||
array = np.asarray(samples, dtype=np.float32).reshape(-1)
|
||||
return np.asarray(model.apply_watermark(array, sample_rate=SAMPLE_RATE), dtype=np.float32).reshape(-1)
|
||||
|
||||
|
||||
def read(model: object, samples: object) -> PerthReading:
|
||||
"""Return the raw implicit-watermark confidence for mono float samples."""
|
||||
import numpy as np
|
||||
|
||||
array = np.asarray(samples, dtype=np.float32).reshape(-1)
|
||||
raw = np.asarray(model.get_watermark(array, sample_rate=SAMPLE_RATE, round=False), dtype=np.float32).reshape(-1)
|
||||
if raw.size != 1 or not np.isfinite(raw).all():
|
||||
raise RuntimeError(f"Perth returned an invalid confidence shape/value: {raw!r}")
|
||||
return PerthReading(confidence=float(raw[0]))
|
||||
@@ -0,0 +1,485 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build and run the local Perth and PixelSeal benchmark study.
|
||||
|
||||
The study uses only deterministic synthetic audio and the two publication-
|
||||
cleared engine-selection carriers named below. It measures Perth before and
|
||||
after the production visible-video cleaning path, and PixelSeal before and
|
||||
after JPEG plus a foreign-message embed. Invisible image-removal profiles are
|
||||
recorded as unavailable when the host has no CUDA device; this script never
|
||||
falls back to a provider oracle or uploads an artifact.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import gc
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO / "src"))
|
||||
sys.path.insert(0, str(REPO / "scripts"))
|
||||
|
||||
import perth_oracle # noqa: E402
|
||||
import pixelseal_oracle # noqa: E402
|
||||
from audioseal_experiment import ( # noqa: E402
|
||||
FRAME_COUNT,
|
||||
SPEECH_VOICES,
|
||||
aac_packet_bytes,
|
||||
assert_distinct_voices,
|
||||
carrier_seed,
|
||||
decode_audio_f32,
|
||||
encode_video,
|
||||
ffmpeg_version,
|
||||
require_tools,
|
||||
run_ffmpeg,
|
||||
sora_like_mark,
|
||||
speech_provenance,
|
||||
stamped_frame,
|
||||
synth_carrier,
|
||||
synthesize_speech,
|
||||
wav_pcm16_bytes,
|
||||
)
|
||||
from watermark_benchmark import SCHEMA_VERSION, run_benchmark, sha256_file, write_jsonl # noqa: E402
|
||||
|
||||
from remove_ai_watermarks.video import remove_video_visible # noqa: E402
|
||||
|
||||
log = logging.getLogger("perth_pixelseal_study")
|
||||
|
||||
RECIPE_VERSION = "perth-pixelseal-study-v2"
|
||||
IMAGE_SIZE = 512
|
||||
IMAGE_CARRIERS: tuple[Path, ...] = (
|
||||
REPO / "data/evaluations/engine-selection/originals/openai/00.png",
|
||||
REPO / "data/evaluations/engine-selection/originals/meta/00.png",
|
||||
)
|
||||
AUDIO_CARRIERS: tuple[str, ...] = ("tone_stack", "pinkish_noise", "white_noise")
|
||||
AUDIO_DURATION_S = 8.0
|
||||
|
||||
|
||||
def _row(
|
||||
*,
|
||||
root: Path,
|
||||
case_id: str,
|
||||
pair_id: str,
|
||||
media_type: str,
|
||||
adapter: str,
|
||||
arm: str,
|
||||
state: str,
|
||||
path: Path,
|
||||
reference: Path | None,
|
||||
source_revision: str,
|
||||
transform_name: str,
|
||||
transform_revision: str,
|
||||
parameters: dict[str, object],
|
||||
seed: int | None,
|
||||
expected: str,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"case_id": case_id,
|
||||
"pair_id": pair_id,
|
||||
"media_type": media_type,
|
||||
"adapter": adapter,
|
||||
"arm": arm,
|
||||
"state": state,
|
||||
"path": path.relative_to(root).as_posix(),
|
||||
"sha256": sha256_file(path),
|
||||
"reference_path": reference.relative_to(root).as_posix() if reference is not None else None,
|
||||
"reference_sha256": sha256_file(reference) if reference is not None else None,
|
||||
"source_revision": source_revision,
|
||||
"transform": {
|
||||
"name": transform_name,
|
||||
"revision": transform_revision,
|
||||
"parameters": parameters,
|
||||
},
|
||||
"seed": seed,
|
||||
"expected": expected,
|
||||
}
|
||||
|
||||
|
||||
def _save_rgb(path: Path, values: np.ndarray) -> None:
|
||||
pixels = np.rint(np.clip(values, 0.0, 1.0) * 255.0).astype(np.uint8)
|
||||
Image.fromarray(pixels, "RGB").save(path)
|
||||
|
||||
|
||||
def _build_pixelseal_rows(output_dir: Path, artifacts: Path) -> list[dict[str, object]]:
|
||||
if not pixelseal_oracle.available():
|
||||
failure = pixelseal_oracle.import_failure()
|
||||
raise SystemExit(f"PixelSeal development stack is unavailable: {failure}")
|
||||
model = pixelseal_oracle.load_model()
|
||||
fixed = pixelseal_oracle.message_bits()
|
||||
foreign = pixelseal_oracle.message_bits(seed=8)
|
||||
pins = {
|
||||
"source_commit": pixelseal_oracle.SOURCE_COMMIT,
|
||||
"checkpoint_sha256": pixelseal_oracle.CHECKPOINT_SHA256,
|
||||
"model_card_sha256": pixelseal_oracle.MODEL_CARD_SHA256,
|
||||
"message": list(fixed),
|
||||
}
|
||||
rows: list[dict[str, object]] = []
|
||||
source_manifest = REPO / "data/evaluations/engine-selection/carrier-manifest.csv"
|
||||
source_revision = f"engine-selection@sha256:{sha256_file(source_manifest)}"
|
||||
for source in IMAGE_CARRIERS:
|
||||
provider = source.parent.name
|
||||
pair_id = f"pixelseal-{provider}-00"
|
||||
clean_path = artifacts / f"{pair_id}-clean.png"
|
||||
clean = Image.open(source).convert("RGB").resize((IMAGE_SIZE, IMAGE_SIZE), Image.Resampling.LANCZOS)
|
||||
clean.save(clean_path)
|
||||
clean_values = np.asarray(clean, dtype=np.float32) / 255.0
|
||||
|
||||
marked_path = artifacts / f"{pair_id}-marked.png"
|
||||
_save_rgb(marked_path, np.asarray(pixelseal_oracle.embed(model, clean_values, fixed)))
|
||||
attacked_path = artifacts / f"{pair_id}-jpeg-q90.jpg"
|
||||
Image.open(marked_path).save(attacked_path, quality=90, subsampling=0)
|
||||
forged_path = artifacts / f"{pair_id}-forged.png"
|
||||
_save_rgb(forged_path, np.asarray(pixelseal_oracle.embed(model, clean_values, foreign)))
|
||||
|
||||
common = {
|
||||
"root": output_dir,
|
||||
"pair_id": pair_id,
|
||||
"media_type": "image",
|
||||
"adapter": "pixelseal",
|
||||
"source_revision": source_revision,
|
||||
}
|
||||
rows.extend(
|
||||
(
|
||||
_row(
|
||||
**common,
|
||||
case_id=f"{pair_id}-clean",
|
||||
arm="matched_negative",
|
||||
state="clean",
|
||||
path=clean_path,
|
||||
reference=None,
|
||||
transform_name="standardize-carrier",
|
||||
transform_revision=RECIPE_VERSION,
|
||||
parameters={"source_sha256": sha256_file(source), "size": IMAGE_SIZE},
|
||||
seed=None,
|
||||
expected="not_detected",
|
||||
),
|
||||
_row(
|
||||
**common,
|
||||
case_id=f"{pair_id}-marked",
|
||||
arm="positive",
|
||||
state="marked",
|
||||
path=marked_path,
|
||||
reference=clean_path,
|
||||
transform_name="pixelseal-embed",
|
||||
transform_revision=f"videoseal@{pixelseal_oracle.SOURCE_COMMIT}",
|
||||
parameters=pins,
|
||||
seed=pixelseal_oracle.MESSAGE_SEED,
|
||||
expected="detected",
|
||||
),
|
||||
_row(
|
||||
**common,
|
||||
case_id=f"{pair_id}-jpeg-q90",
|
||||
arm="positive",
|
||||
state="attacked",
|
||||
path=attacked_path,
|
||||
reference=clean_path,
|
||||
transform_name="jpeg",
|
||||
transform_revision="pillow",
|
||||
parameters={"quality": 90, "subsampling": 0, **pins},
|
||||
seed=None,
|
||||
expected="unresolved",
|
||||
),
|
||||
_row(
|
||||
**common,
|
||||
case_id=f"{pair_id}-forged",
|
||||
arm="wrong_key",
|
||||
state="forged",
|
||||
path=forged_path,
|
||||
reference=clean_path,
|
||||
transform_name="pixelseal-embed-foreign-message",
|
||||
transform_revision=f"videoseal@{pixelseal_oracle.SOURCE_COMMIT}",
|
||||
parameters={**pins, "foreign_message": list(foreign), "foreign_message_seed": 8},
|
||||
seed=8,
|
||||
expected="not_detected",
|
||||
),
|
||||
)
|
||||
)
|
||||
del model
|
||||
gc.collect()
|
||||
return rows
|
||||
|
||||
|
||||
def _extract_audio(ffmpeg: str, source: Path, target: Path) -> None:
|
||||
run_ffmpeg(
|
||||
ffmpeg,
|
||||
[
|
||||
"-i",
|
||||
str(source),
|
||||
"-map",
|
||||
"0:a:0",
|
||||
"-ac",
|
||||
"1",
|
||||
"-ar",
|
||||
str(perth_oracle.SAMPLE_RATE),
|
||||
"-c:a",
|
||||
"pcm_s16le",
|
||||
str(target),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _build_perth_rows(
|
||||
output_dir: Path,
|
||||
artifacts: Path,
|
||||
ffmpeg: str,
|
||||
) -> tuple[list[dict[str, object]], dict[str, object]]:
|
||||
if not perth_oracle.available():
|
||||
failure = perth_oracle.import_failure()
|
||||
raise SystemExit(f"Perth development stack is unavailable: {failure}")
|
||||
model = perth_oracle.load_model()
|
||||
pins = {
|
||||
"source_commit": perth_oracle.SOURCE_COMMIT,
|
||||
"checkpoint_sha256": perth_oracle.CHECKPOINT_SHA256,
|
||||
"implicit_payload": True,
|
||||
}
|
||||
source_revision = f"{RECIPE_VERSION}@sha256:{sha256_file(Path(__file__))}"
|
||||
rows: list[dict[str, object]] = []
|
||||
samantha_marked_path: Path | None = None
|
||||
for name in AUDIO_CARRIERS:
|
||||
executed_seed = carrier_seed(name)
|
||||
clean_samples = synth_carrier(name, AUDIO_DURATION_S, seed=executed_seed)
|
||||
clean_path = artifacts / f"perth-{name}-clean.wav"
|
||||
clean_path.write_bytes(wav_pcm16_bytes(clean_samples, perth_oracle.SAMPLE_RATE))
|
||||
marked_path = artifacts / f"perth-{name}-marked.wav"
|
||||
marked = np.asarray(perth_oracle.embed(model, clean_samples), dtype=np.float32)
|
||||
marked_path.write_bytes(wav_pcm16_bytes(marked, perth_oracle.SAMPLE_RATE))
|
||||
common = {
|
||||
"root": output_dir,
|
||||
"pair_id": f"perth-{name}",
|
||||
"media_type": "audio",
|
||||
"adapter": "perth",
|
||||
"source_revision": source_revision,
|
||||
}
|
||||
rows.extend(
|
||||
(
|
||||
_row(
|
||||
**common,
|
||||
case_id=f"perth-{name}-clean",
|
||||
arm="matched_negative",
|
||||
state="clean",
|
||||
path=clean_path,
|
||||
reference=None,
|
||||
transform_name="synthesize-carrier",
|
||||
transform_revision=RECIPE_VERSION,
|
||||
parameters={"carrier": name, "duration_s": AUDIO_DURATION_S},
|
||||
seed=executed_seed,
|
||||
expected="not_detected",
|
||||
),
|
||||
_row(
|
||||
**common,
|
||||
case_id=f"perth-{name}-marked",
|
||||
arm="positive",
|
||||
state="marked",
|
||||
path=marked_path,
|
||||
reference=clean_path,
|
||||
transform_name="perth-embed",
|
||||
transform_revision=f"resemble-ai/Perth@{perth_oracle.SOURCE_COMMIT}",
|
||||
parameters=pins,
|
||||
seed=None,
|
||||
# Perth is speech-trained; these deliberately out-of-domain
|
||||
# carriers measure dependence without asserting detection.
|
||||
expected="unresolved",
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
raw_speech = {voice: synthesize_speech(voice, artifacts) for voice in SPEECH_VOICES}
|
||||
assert_distinct_voices({voice: path.read_bytes() for voice, path in raw_speech.items()})
|
||||
tts = speech_provenance()
|
||||
for voice, aiff in raw_speech.items():
|
||||
pair_id = f"perth-speech-{voice.casefold()}"
|
||||
clean_samples = decode_audio_f32(ffmpeg, aiff)
|
||||
clean_path = artifacts / f"{pair_id}-clean.wav"
|
||||
clean_path.write_bytes(wav_pcm16_bytes(clean_samples, perth_oracle.SAMPLE_RATE))
|
||||
marked = np.asarray(perth_oracle.embed(model, clean_samples), dtype=np.float32)
|
||||
marked_path = artifacts / f"{pair_id}-marked.wav"
|
||||
marked_path.write_bytes(wav_pcm16_bytes(marked, perth_oracle.SAMPLE_RATE))
|
||||
if voice == "Samantha":
|
||||
samantha_marked_path = marked_path
|
||||
common = {
|
||||
"root": output_dir,
|
||||
"pair_id": pair_id,
|
||||
"media_type": "audio",
|
||||
"adapter": "perth",
|
||||
"source_revision": source_revision,
|
||||
}
|
||||
provenance = {**tts, "voice": voice, "duration_s": round(clean_samples.shape[0] / perth_oracle.SAMPLE_RATE, 6)}
|
||||
rows.extend(
|
||||
(
|
||||
_row(
|
||||
**common,
|
||||
case_id=f"{pair_id}-clean",
|
||||
arm="matched_negative",
|
||||
state="clean",
|
||||
path=clean_path,
|
||||
reference=None,
|
||||
transform_name="synthesize-speech",
|
||||
transform_revision=RECIPE_VERSION,
|
||||
parameters={"tts": provenance},
|
||||
seed=None,
|
||||
expected="not_detected",
|
||||
),
|
||||
_row(
|
||||
**common,
|
||||
case_id=f"{pair_id}-marked",
|
||||
arm="positive",
|
||||
state="marked",
|
||||
path=marked_path,
|
||||
reference=clean_path,
|
||||
transform_name="perth-embed",
|
||||
transform_revision=f"resemble-ai/Perth@{perth_oracle.SOURCE_COMMIT}",
|
||||
parameters={**pins, "tts": provenance},
|
||||
seed=None,
|
||||
expected="detected",
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
mark, mark_x, mark_y = sora_like_mark()
|
||||
frames = [stamped_frame(index, mark, mark_x, mark_y) for index in range(FRAME_COUNT)]
|
||||
if samantha_marked_path is None:
|
||||
raise RuntimeError("the configured speech carriers must include Samantha")
|
||||
source_video = artifacts / "perth-video-source.mp4"
|
||||
encode_video(
|
||||
ffmpeg,
|
||||
source_video,
|
||||
frames,
|
||||
samantha_marked_path,
|
||||
audio_codec="aac",
|
||||
audio_rate=perth_oracle.SAMPLE_RATE,
|
||||
)
|
||||
cleaned_video = artifacts / "perth-video-cleaned.mp4"
|
||||
result = remove_video_visible(source_video, cleaned_video, mark="sora", backend="cv2")
|
||||
if result.output is None:
|
||||
raise SystemExit("visible-video cleaning did not produce an output")
|
||||
source_packets = aac_packet_bytes(ffmpeg, source_video)
|
||||
cleaned_packets = aac_packet_bytes(ffmpeg, cleaned_video)
|
||||
source_audio = artifacts / "perth-video-source-audio.wav"
|
||||
cleaned_audio = artifacts / "perth-video-cleaned-audio.wav"
|
||||
_extract_audio(ffmpeg, source_video, source_audio)
|
||||
_extract_audio(ffmpeg, cleaned_video, cleaned_audio)
|
||||
packet_identity = source_packets == cleaned_packets
|
||||
rows.extend(
|
||||
(
|
||||
_row(
|
||||
root=output_dir,
|
||||
case_id="perth-video-source-audio",
|
||||
pair_id="perth-video-pass-through",
|
||||
media_type="audio",
|
||||
adapter="perth",
|
||||
arm="positive",
|
||||
state="marked",
|
||||
path=source_audio,
|
||||
reference=None,
|
||||
source_revision=source_revision,
|
||||
transform_name="mux-aac",
|
||||
transform_revision=ffmpeg_version(ffmpeg),
|
||||
parameters={"codec": "aac", "bitrate": "128k", **pins},
|
||||
seed=None,
|
||||
expected="detected",
|
||||
),
|
||||
_row(
|
||||
root=output_dir,
|
||||
case_id="perth-video-cleaned-audio",
|
||||
pair_id="perth-video-pass-through",
|
||||
media_type="audio",
|
||||
adapter="perth",
|
||||
arm="positive",
|
||||
state="attacked",
|
||||
path=cleaned_audio,
|
||||
reference=source_audio,
|
||||
source_revision=source_revision,
|
||||
transform_name="remove-video-visible-audio-pass-through",
|
||||
transform_revision=source_revision,
|
||||
parameters={
|
||||
"audio_packets_identical": packet_identity,
|
||||
"visual_removed_frames": result.removed_frames,
|
||||
**pins,
|
||||
},
|
||||
seed=None,
|
||||
expected="detected",
|
||||
),
|
||||
)
|
||||
)
|
||||
del model
|
||||
gc.collect()
|
||||
return rows, {
|
||||
"audio_packets_identical": packet_identity,
|
||||
"source_audio_packet_sha256": hashlib.sha256(source_packets).hexdigest(),
|
||||
"cleaned_audio_packet_sha256": hashlib.sha256(cleaned_packets).hexdigest(),
|
||||
"visual_removed_frames": result.removed_frames,
|
||||
}
|
||||
|
||||
|
||||
def _cuda_available() -> bool:
|
||||
try:
|
||||
import torch
|
||||
|
||||
return bool(torch.cuda.is_available())
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
def build_and_run(output_dir: Path) -> Path:
|
||||
"""Create the study artifacts, manifest, metadata, and kernel results."""
|
||||
if output_dir.exists():
|
||||
raise FileExistsError(f"output directory already exists: {output_dir}")
|
||||
ffmpeg, _ffprobe = require_tools()
|
||||
output_dir.mkdir(parents=True)
|
||||
artifacts = output_dir / "artifacts"
|
||||
artifacts.mkdir()
|
||||
rows = _build_pixelseal_rows(output_dir, artifacts)
|
||||
perth_rows, pass_through = _build_perth_rows(output_dir, artifacts, ffmpeg)
|
||||
rows.extend(perth_rows)
|
||||
manifest = output_dir / "manifest.jsonl"
|
||||
write_jsonl(manifest, rows)
|
||||
metadata = {
|
||||
"schema_version": 1,
|
||||
"recipe": RECIPE_VERSION,
|
||||
"pixelseal": {
|
||||
"source_commit": pixelseal_oracle.SOURCE_COMMIT,
|
||||
"checkpoint_sha256": pixelseal_oracle.CHECKPOINT_SHA256,
|
||||
"removal_profiles": {
|
||||
"status": "not_run" if not _cuda_available() else "available_not_automatic",
|
||||
"reason": "qwen-zimage, sdxl-zimage, and chroma-zimage require an NVIDIA CUDA device",
|
||||
},
|
||||
},
|
||||
"perth": {
|
||||
"source_commit": perth_oracle.SOURCE_COMMIT,
|
||||
"checkpoint_sha256": perth_oracle.CHECKPOINT_SHA256,
|
||||
"message": "not_applicable_fixed_implicit_mark",
|
||||
"video_visible_path": pass_through,
|
||||
},
|
||||
}
|
||||
(output_dir / "study-metadata.json").write_text(
|
||||
json.dumps(metadata, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
results = output_dir / "results.jsonl"
|
||||
run_benchmark(manifest, results)
|
||||
return results
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--output-dir", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
||||
try:
|
||||
results = build_and_run(args.output_dir.resolve())
|
||||
except (FileNotFoundError, OSError, RuntimeError, ValueError) as exc:
|
||||
parser.error(str(exc))
|
||||
log.info("Wrote study results to %s", results)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Revision-pinned local PixelSeal oracle for development-only benchmarks.
|
||||
|
||||
Meta publishes PixelSeal in the VideoSeal source tree, but not as a standalone
|
||||
TorchScript artifact. This loader uses the exact upstream source revision and
|
||||
official checkpoint, verifies both the model card and checkpoint digests, and
|
||||
builds only the image inference graph. It avoids the upstream convenience
|
||||
loader because that loader imports training datasets and writes downloads
|
||||
relative to the current working directory.
|
||||
|
||||
Nothing here ships in the installed CLI or public Python API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import importlib.metadata
|
||||
import logging
|
||||
import os
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
SOURCE_COMMIT = "870ca7fb33578b90f14c602016b6c2788096226e"
|
||||
PACKAGE_NAME = "videoseal"
|
||||
PACKAGE_VERSION = "1.0"
|
||||
CHECKPOINT_URL = "https://dl.fbaipublicfiles.com/videoseal/pixelseal/checkpoint.pth"
|
||||
CHECKPOINT_SHA256 = "0c5665cff20eb6ce1b5aaa7d91c19dafb418bfee32d02dd3344e4ed60d9d75bd"
|
||||
MODEL_CARD_SHA256 = "96c4a448f7d81810780e5bde5a51b039db544b7ec2baf65a605302c1b17362c9"
|
||||
N_BITS = 256
|
||||
MESSAGE_SEED = 7
|
||||
DETECTION_BIT_ACCURACY_THRESHOLD = 0.9
|
||||
CACHE_DIR = Path(
|
||||
os.environ.get(
|
||||
"PIXELSEAL_CACHE_DIR",
|
||||
str(Path.home() / ".cache" / "remove-ai-watermarks-dev" / "pixelseal"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PixelSealReading:
|
||||
"""Decoded bits and matched-message accuracy for one image."""
|
||||
|
||||
bit_accuracy: float
|
||||
decoded_bits: tuple[int, ...]
|
||||
|
||||
@property
|
||||
def detected(self) -> bool:
|
||||
"""Apply this adapter's explicit matched-message rule."""
|
||||
return self.bit_accuracy >= DETECTION_BIT_ACCURACY_THRESHOLD
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
return "".join(f"{byte:02x}" for byte in _bytes(self.decoded_bits))
|
||||
|
||||
|
||||
def _bytes(bits: Sequence[int]) -> bytes:
|
||||
return bytes(sum(bit << (7 - index) for index, bit in enumerate(bits[i : i + 8])) for i in range(0, len(bits), 8))
|
||||
|
||||
|
||||
def message_bits(seed: int = MESSAGE_SEED) -> tuple[int, ...]:
|
||||
"""The oracle's fixed 256-bit message, deterministic across processes."""
|
||||
import numpy as np
|
||||
|
||||
rng = np.random.default_rng(seed)
|
||||
return tuple(int(bit) for bit in rng.integers(0, 2, N_BITS))
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1 << 20), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def import_failure() -> Exception | None:
|
||||
"""Return the missing or incompatible dependency error, if any."""
|
||||
try:
|
||||
import omegaconf # noqa: F401
|
||||
import timm # noqa: F401
|
||||
import torch # noqa: F401
|
||||
import torchvision # noqa: F401
|
||||
import videoseal # noqa: F401
|
||||
|
||||
if importlib.metadata.version(PACKAGE_NAME) != PACKAGE_VERSION:
|
||||
raise RuntimeError(
|
||||
f"{PACKAGE_NAME} must be installed from source commit {SOURCE_COMMIT} "
|
||||
f"(package version {PACKAGE_VERSION})"
|
||||
)
|
||||
except (ImportError, importlib.metadata.PackageNotFoundError, RuntimeError) as exc:
|
||||
return exc
|
||||
return None
|
||||
|
||||
|
||||
def available() -> bool:
|
||||
"""Whether the pinned PixelSeal source stack is importable."""
|
||||
return import_failure() is None
|
||||
|
||||
|
||||
def checkpoint_path() -> Path:
|
||||
"""Return the pinned local checkpoint, downloading and verifying it once."""
|
||||
target = CACHE_DIR / "checkpoint.pth"
|
||||
if target.is_file() and _sha256_file(target) == CHECKPOINT_SHA256:
|
||||
return target
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
temporary = CACHE_DIR / "checkpoint.pth.part"
|
||||
log.info("Downloading pinned PixelSeal checkpoint from %s", CHECKPOINT_URL)
|
||||
urllib.request.urlretrieve(CHECKPOINT_URL, temporary)
|
||||
digest = _sha256_file(temporary)
|
||||
if digest != CHECKPOINT_SHA256:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise RuntimeError(f"pinned PixelSeal checkpoint digest mismatch: {digest} != {CHECKPOINT_SHA256}")
|
||||
temporary.replace(target)
|
||||
return target
|
||||
|
||||
|
||||
def load_model() -> object:
|
||||
"""Build PixelSeal from the pinned official source and weights on CPU."""
|
||||
failure = import_failure()
|
||||
if failure is not None:
|
||||
raise RuntimeError(
|
||||
"PixelSeal oracle requires facebookresearch/videoseal at source commit "
|
||||
f"{SOURCE_COMMIT}, plus torch, torchvision, timm, and omegaconf"
|
||||
) from failure
|
||||
|
||||
import torch
|
||||
import videoseal
|
||||
from omegaconf import OmegaConf
|
||||
from videoseal.augmentation.augmenter import get_dummy_augmenter
|
||||
from videoseal.models import Videoseal, build_embedder, build_extractor
|
||||
from videoseal.modules.jnd import JND
|
||||
|
||||
package_root = Path(videoseal.__file__).resolve().parent
|
||||
card_path = package_root / "cards" / "pixelseal.yaml"
|
||||
if not card_path.is_file() or _sha256_file(card_path) != MODEL_CARD_SHA256:
|
||||
raise RuntimeError("installed videoseal source does not carry the pinned PixelSeal model card")
|
||||
config = OmegaConf.load(card_path)
|
||||
args = config.args
|
||||
embedder = build_embedder(
|
||||
config.embedder.model,
|
||||
config.embedder.params,
|
||||
args.nbits,
|
||||
args.hidden_size_multiplier,
|
||||
)
|
||||
extractor = build_extractor(
|
||||
config.extractor.model,
|
||||
config.extractor.params,
|
||||
args.img_size_proc,
|
||||
args.nbits,
|
||||
)
|
||||
model = Videoseal(
|
||||
embedder,
|
||||
extractor,
|
||||
get_dummy_augmenter(),
|
||||
attenuation=JND(in_channels=1, out_channels=1),
|
||||
scaling_w=args.scaling_w,
|
||||
scaling_i=args.scaling_i,
|
||||
img_size=args.img_size_proc,
|
||||
chunk_size=args.videowam_chunk_size,
|
||||
step_size=args.videowam_step_size,
|
||||
)
|
||||
checkpoint = torch.load(checkpoint_path(), map_location="cpu", weights_only=True)
|
||||
loaded = model.load_state_dict(checkpoint["model"], strict=False)
|
||||
if loaded.missing_keys or loaded.unexpected_keys:
|
||||
raise RuntimeError(
|
||||
f"PixelSeal checkpoint/source mismatch: missing={loaded.missing_keys}, unexpected={loaded.unexpected_keys}"
|
||||
)
|
||||
model.train(False)
|
||||
return model
|
||||
|
||||
|
||||
def _image_tensor(image: object) -> object:
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
array = np.asarray(image)
|
||||
if array.ndim != 3 or array.shape[2] != 3:
|
||||
raise ValueError(f"PixelSeal expects HxWx3 RGB pixels, got {array.shape}")
|
||||
values = array.astype(np.float32) / 255.0 if array.dtype == np.uint8 else np.asarray(array, dtype=np.float32)
|
||||
if not np.isfinite(values).all() or float(values.min()) < 0.0 or float(values.max()) > 1.0:
|
||||
raise ValueError("PixelSeal RGB pixels must be finite and in [0, 1]")
|
||||
return torch.from_numpy(np.ascontiguousarray(values)).permute(2, 0, 1).unsqueeze(0)
|
||||
|
||||
|
||||
def embed(model: object, image: object, message: Sequence[int]) -> object:
|
||||
"""Embed one message into an RGB image, returning float RGB in [0, 1]."""
|
||||
import torch
|
||||
|
||||
tensor = _image_tensor(image)
|
||||
msg = torch.tensor([list(message)], dtype=torch.float32)
|
||||
with torch.inference_mode():
|
||||
marked = model.embed(tensor, msgs=msg, is_video=False)["imgs_w"]
|
||||
return marked[0].permute(1, 2, 0).numpy()
|
||||
|
||||
|
||||
def read(model: object, image: object, *, message: Sequence[int] | None = None) -> PixelSealReading:
|
||||
"""Decode one image and compare its bits with the oracle's fixed message."""
|
||||
import torch
|
||||
|
||||
expected = message_bits() if message is None else tuple(message)
|
||||
tensor = _image_tensor(image)
|
||||
with torch.inference_mode():
|
||||
predictions = model.detect(tensor)["preds"]
|
||||
decoded = tuple(int(bit) for bit in (predictions[0, 1:] > 0).to(torch.int64).tolist())
|
||||
accuracy = sum(int(bit == want) for bit, want in zip(decoded, expected, strict=True)) / N_BITS
|
||||
return PixelSealReading(bit_accuracy=accuracy, decoded_bits=decoded)
|
||||
@@ -288,8 +288,8 @@ def run_web_batch(
|
||||
oracles.verify_batch(manifest_path)
|
||||
manifest, results = oracles.load_batch(manifest_path)
|
||||
surface = manifest["surface"]
|
||||
if surface == "gemini-web":
|
||||
raise ValueError("gemini-web requires the real Chrome workflow, not isolated Playwright")
|
||||
if oracles.SURFACES[surface].automation == "real_browser":
|
||||
raise ValueError(f"{surface} requires the real Chrome workflow, not isolated Playwright")
|
||||
if surface not in PLAYWRIGHT_SURFACES:
|
||||
raise ValueError(f"surface {surface} is not a Playwright Web oracle")
|
||||
slot = manifest["slot"]
|
||||
|
||||
@@ -161,11 +161,34 @@ SURFACES: dict[str, OracleSurface] = {
|
||||
url="https://gemini.google.com/",
|
||||
media_types=("image", "video", "audio"),
|
||||
project_evidence=("image", "video"),
|
||||
prompt="Was this image/video/audio created or edited by Google AI?",
|
||||
prompt=(
|
||||
"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."
|
||||
),
|
||||
clean_result_note="Use not_detected only for an explicit SynthID-negative result; preserve unclear results.",
|
||||
account_slots=True,
|
||||
network_slots=True,
|
||||
),
|
||||
"synthid-web": OracleSurface(
|
||||
key="synthid-web",
|
||||
provider_key="google",
|
||||
provider="Google",
|
||||
signal_family="synthid",
|
||||
surface="SynthID Detector",
|
||||
automation="real_browser",
|
||||
url="https://synthid.com/",
|
||||
media_types=("image", "video", "audio"),
|
||||
project_evidence=(),
|
||||
prompt=None,
|
||||
clean_result_note=(
|
||||
"Record detected only for the explicit label Watermark detected, and not_detected only for "
|
||||
"Watermark not detected. The portal reports partner SynthID from Google DeepMind, Nvidia, "
|
||||
"OpenAI, and Kakao without naming which partner. A not_detected result is not proof of "
|
||||
"human authorship. Preserve quota, sign-in, and unclear wording."
|
||||
),
|
||||
account_slots=True,
|
||||
network_slots=True,
|
||||
),
|
||||
"meta-web": OracleSurface(
|
||||
key="meta-web",
|
||||
provider_key="meta",
|
||||
@@ -275,6 +298,11 @@ def ordered_surfaces() -> list[OracleSurface]:
|
||||
return sorted(SURFACES.values(), key=_surface_order)
|
||||
|
||||
|
||||
def _automation_keys(automation: str) -> frozenset[str]:
|
||||
"""Return the surface keys that use one automation kind."""
|
||||
return frozenset(key for key, surface in SURFACES.items() if surface.automation == automation)
|
||||
|
||||
|
||||
def _inside(path: Path, parent: Path) -> bool:
|
||||
"""Return whether resolved PATH is inside resolved PARENT."""
|
||||
try:
|
||||
@@ -361,6 +389,8 @@ def load_slots(path: Path) -> dict[str, ExecutionSlot]:
|
||||
"api_key_env",
|
||||
}
|
||||
slots: dict[str, ExecutionSlot] = {}
|
||||
real_browser = _automation_keys("real_browser")
|
||||
playwright = _automation_keys("playwright")
|
||||
for index, raw_slot in enumerate(slot_items):
|
||||
if not isinstance(raw_slot, dict):
|
||||
raise ValueError(f"slot {index} must be a JSON object")
|
||||
@@ -404,19 +434,17 @@ def load_slots(path: Path) -> dict[str, ExecutionSlot]:
|
||||
raise ValueError(f"surface {surface_key} requires an account_label")
|
||||
if account_label is not None and not SURFACES[surface_key].account_slots:
|
||||
raise ValueError(f"surface {surface_key} does not use account slots")
|
||||
if surface_key == "gemini-web" and browser_profile is None:
|
||||
raise ValueError("surface gemini-web requires a browser_profile")
|
||||
if surface_key in real_browser and browser_profile is None:
|
||||
raise ValueError(f"surface {surface_key} requires a browser_profile")
|
||||
if surface_key == "gemini-web" and google_account_index is None:
|
||||
raise ValueError("surface gemini-web requires a google_account_index")
|
||||
if surface_key != "gemini-web" and google_account_index is not None:
|
||||
raise ValueError("only gemini-web uses google_account_index")
|
||||
if SURFACES[surface_key].automation == "api" and browser_profile is not None:
|
||||
raise ValueError(f"API surface {surface_key} does not use a browser_profile")
|
||||
if surface_key != "gemini-web" and browser_profile is not None:
|
||||
raise ValueError("only gemini-web uses a real browser_profile")
|
||||
if surface_key == "gemini-web" and proxy_url_env is not None:
|
||||
raise ValueError("surface gemini-web does not use proxy_url_env")
|
||||
if proxy_url_env is not None and surface_key not in {"openai-web", "microsoft-web", "meta-web"}:
|
||||
if surface_key not in real_browser and browser_profile is not None:
|
||||
raise ValueError("only a real-browser surface uses a browser_profile")
|
||||
if proxy_url_env is not None and surface_key not in playwright:
|
||||
raise ValueError(f"surface {surface_key} does not use proxy_url_env")
|
||||
if surface_key == "openai-api" and api_key_env is None:
|
||||
raise ValueError("surface openai-api requires an api_key_env")
|
||||
@@ -855,18 +883,17 @@ def list_oracles(as_json: bool) -> None:
|
||||
def plan(provider: str, as_json: bool) -> None:
|
||||
"""Show the API-first surface order for one provider."""
|
||||
definitions = provider_plan(provider)
|
||||
has_api = any(definition.automation == "api" for definition in definitions)
|
||||
rows = []
|
||||
for index, definition in enumerate(definitions):
|
||||
for definition in definitions:
|
||||
row = definition.to_dict()
|
||||
row["preference"] = "primary" if index == 0 else "fallback"
|
||||
row["preference"] = "fallback" if has_api and definition.automation != "api" else "primary"
|
||||
rows.append(row)
|
||||
if as_json:
|
||||
click.echo(json.dumps(rows, indent=2, sort_keys=True))
|
||||
return
|
||||
for row in rows:
|
||||
click.echo(f"{row['preference']}\t{row['key']}\t{row['automation']}\t{row['surface']}")
|
||||
if len(rows) > 1:
|
||||
click.echo("Web fallback requires a separate operator decision; failures do not trigger it automatically.")
|
||||
|
||||
|
||||
@cli.command()
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -98,7 +98,7 @@ def _train_detector(emb: dict[str, np.ndarray], catalog: dict) -> tuple[nn.Modul
|
||||
optimizer.zero_grad()
|
||||
loss_fn(model(features).squeeze(-1), labels).backward()
|
||||
optimizer.step()
|
||||
model.eval()
|
||||
model.train(False)
|
||||
dev = np.stack(
|
||||
[
|
||||
emb[row["sha256"]]
|
||||
@@ -161,7 +161,7 @@ def _train_provider(feat: dict[str, np.ndarray], catalog: dict) -> dict[str, nn.
|
||||
optimizer.zero_grad()
|
||||
_focal_loss(model(features).squeeze(-1), labels).backward()
|
||||
optimizer.step()
|
||||
heads[name] = model.eval()
|
||||
heads[name] = model.train(False)
|
||||
log.info("provider %s pos=%d", name, len(positive))
|
||||
return heads
|
||||
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
"""Measure same-encoder re-watermarking on the project's open image marks.
|
||||
|
||||
This development-only study applies message A to publication-cleared images,
|
||||
serializes the result as PNG, then applies message B with the same encoder to
|
||||
the decoded A-marked artifact. It measures both messages after each write and
|
||||
keeps clean-to-output and marginal attack fidelity separate.
|
||||
|
||||
The default cohort is twelve images: both providers from six preselected
|
||||
content strata in the tracked engine-selection matrix. Generated artifacts
|
||||
and reports belong outside the repository under ``.local-eval/``.
|
||||
|
||||
uv run --extra dev --extra trustmark python \
|
||||
scripts/rewatermarking_study.py \
|
||||
--output-dir /path/to/.local-eval/rewatermarking-2026-09-27
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import statistics
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageOps
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO / "src"))
|
||||
sys.path.insert(0, str(REPO / "scripts"))
|
||||
|
||||
import engine_selection_manifest # noqa: E402
|
||||
import videoseal_oracle # noqa: E402
|
||||
from engine_selection_manifest import ContentFixture # noqa: E402
|
||||
from watermark_benchmark import repository_state, sha256_file # noqa: E402
|
||||
from watermark_benchmark_cohort import trustmark_runtime # noqa: E402
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
STUDY_VERSION = "rewatermarking-study-v1"
|
||||
DEFAULT_SIZE = 512
|
||||
DEFAULT_PAIR_IDS = ("00", "02", "07", "08", "11", "17")
|
||||
DWT_MATCH_THRESHOLD = 44 / 48
|
||||
TRUSTMARK_BITS = 61
|
||||
|
||||
|
||||
def bit_accuracy(decoded: Sequence[int], expected: Sequence[int]) -> float:
|
||||
"""Return the exact matching-bit fraction for equal-length messages."""
|
||||
if not decoded or len(decoded) != len(expected):
|
||||
raise ValueError("bit strings must be non-empty and have equal length")
|
||||
return sum(int(actual == wanted) for actual, wanted in zip(decoded, expected, strict=True)) / len(expected)
|
||||
|
||||
|
||||
def image_fidelity(
|
||||
reference: np.ndarray[Any, np.dtype[np.uint8]],
|
||||
artifact: np.ndarray[Any, np.dtype[np.uint8]],
|
||||
) -> dict[str, Any]:
|
||||
"""Return the benchmark kernel's decoded 8-bit image-fidelity metrics."""
|
||||
if reference.shape != artifact.shape:
|
||||
raise ValueError(f"image shape mismatch: {reference.shape} != {artifact.shape}")
|
||||
if reference.dtype != np.uint8 or artifact.dtype != np.uint8:
|
||||
raise ValueError("image fidelity requires uint8 decoded pixels")
|
||||
delta = artifact.astype(np.float64) - reference.astype(np.float64)
|
||||
absolute = np.abs(delta)
|
||||
mse = float(np.mean(np.square(delta)))
|
||||
changed = np.any(reference != artifact, axis=-1)
|
||||
identical = mse == 0.0
|
||||
return {
|
||||
"mae_8bit": float(np.mean(absolute)),
|
||||
"changed_fraction": float(np.mean(changed)),
|
||||
"psnr_db": None if identical else 20.0 * math.log10(255.0) - 10.0 * math.log10(mse),
|
||||
"psnr_status": "unbounded_identical" if identical else "measured",
|
||||
}
|
||||
|
||||
|
||||
def seeded_bits(seed: int, count: int) -> tuple[int, ...]:
|
||||
"""Return a deterministic binary message without touching global RNG state."""
|
||||
return tuple(int(bit) for bit in np.random.default_rng(seed).integers(0, 2, count))
|
||||
|
||||
|
||||
def message_sha256(bits: Sequence[int]) -> str:
|
||||
return hashlib.sha256("".join(str(bit) for bit in bits).encode()).hexdigest()
|
||||
|
||||
|
||||
def _standardize(carrier: ContentFixture, size: int) -> Image.Image:
|
||||
with Image.open(carrier.path) as source:
|
||||
return ImageOps.fit(
|
||||
source.convert("RGB"),
|
||||
(size, size),
|
||||
method=Image.Resampling.LANCZOS,
|
||||
centering=(0.5, 0.5),
|
||||
)
|
||||
|
||||
|
||||
def _save_png(image: Image.Image | np.ndarray[Any, Any], path: Path) -> np.ndarray[Any, np.dtype[np.uint8]]:
|
||||
if isinstance(image, Image.Image):
|
||||
output = image.convert("RGB")
|
||||
else:
|
||||
pixels = np.asarray(image)
|
||||
if pixels.dtype != np.uint8:
|
||||
pixels = np.rint(np.clip(pixels, 0.0, 1.0) * 255.0).astype(np.uint8)
|
||||
output = Image.fromarray(pixels, "RGB")
|
||||
output.save(path, format="PNG")
|
||||
with Image.open(path) as decoded:
|
||||
return np.asarray(decoded.convert("RGB"), dtype=np.uint8).copy()
|
||||
|
||||
|
||||
def _artifact_record(path: Path) -> dict[str, str]:
|
||||
return {"path": path.name, "sha256": sha256_file(path)}
|
||||
|
||||
|
||||
def _dwt_messages() -> tuple[tuple[int, ...], tuple[int, ...]]:
|
||||
from remove_ai_watermarks.invisible_watermark import _BITS_48
|
||||
|
||||
return tuple(int(bit) for bit in format(_BITS_48["FLUX.2 (Black Forest Labs)"], "048b")), tuple(
|
||||
int(bit) for bit in format(_BITS_48["Stable Diffusion XL"], "048b")
|
||||
)
|
||||
|
||||
|
||||
def _dwt_embed(
|
||||
pixels: np.ndarray[Any, np.dtype[np.uint8]], message: Sequence[int]
|
||||
) -> np.ndarray[Any, np.dtype[np.uint8]]:
|
||||
import cv2
|
||||
from imwatermark import WatermarkEncoder
|
||||
|
||||
cv2.setNumThreads(2)
|
||||
encoder = WatermarkEncoder()
|
||||
encoder.set_watermark("bits", list(message))
|
||||
marked = encoder.encode(cv2.cvtColor(pixels, cv2.COLOR_RGB2BGR), "dwtDct")
|
||||
return cv2.cvtColor(np.asarray(marked, dtype=np.uint8), cv2.COLOR_BGR2RGB)
|
||||
|
||||
|
||||
def _dwt_decode(pixels: np.ndarray[Any, np.dtype[np.uint8]]) -> tuple[int, ...]:
|
||||
import cv2
|
||||
from imwatermark import WatermarkDecoder
|
||||
|
||||
decoder = WatermarkDecoder("bits", 48)
|
||||
decoded = decoder.decode(cv2.cvtColor(pixels, cv2.COLOR_RGB2BGR), "dwtDct")
|
||||
return tuple(int(bit) for bit in decoded)
|
||||
|
||||
|
||||
def _videoseal_embed(
|
||||
model: object,
|
||||
pixels: np.ndarray[Any, np.dtype[np.uint8]],
|
||||
message: Sequence[int],
|
||||
) -> np.ndarray[Any, np.dtype[np.float32]]:
|
||||
return np.asarray(videoseal_oracle.embed_image(model, pixels.astype(np.float32) / 255.0, message))
|
||||
|
||||
|
||||
def _videoseal_decode(model: object, pixels: np.ndarray[Any, np.dtype[np.uint8]]) -> tuple[int, ...]:
|
||||
return videoseal_oracle.decode_image(model, pixels.astype(np.float32) / 255.0)
|
||||
|
||||
|
||||
def _trustmark_decode(runtime: Any, pixels: np.ndarray[Any, np.dtype[np.uint8]]) -> tuple[tuple[int, ...], bool, int]:
|
||||
payload, present, schema = runtime.decode(Image.fromarray(pixels, "RGB"), "binary")
|
||||
bits = tuple(int(bit) for bit in payload) if payload and set(payload) <= {"0", "1"} else ()
|
||||
return bits, bool(present), int(schema)
|
||||
|
||||
|
||||
def _case_row(
|
||||
*,
|
||||
scheme: str,
|
||||
carrier: ContentFixture,
|
||||
message_a: Sequence[int],
|
||||
message_b: Sequence[int],
|
||||
clean: np.ndarray[Any, np.dtype[np.uint8]],
|
||||
marked_path: Path,
|
||||
rewatermarked_path: Path,
|
||||
marked: np.ndarray[Any, np.dtype[np.uint8]],
|
||||
rewatermarked: np.ndarray[Any, np.dtype[np.uint8]],
|
||||
decoded_marked: Sequence[int],
|
||||
decoded_rewatermarked: Sequence[int],
|
||||
marked_detected: bool,
|
||||
rewatermarked_detected_a: bool,
|
||||
rewatermarked_detected_b: bool,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"scheme": scheme,
|
||||
"carrier": carrier.name,
|
||||
"pair_id": carrier.pair_id,
|
||||
"provider": carrier.provider,
|
||||
"content_stratum": carrier.content_stratum,
|
||||
"source_sha256": carrier.sha256,
|
||||
"reuse_basis": carrier.reuse_basis,
|
||||
"messages": {
|
||||
"a_sha256": message_sha256(message_a),
|
||||
"b_sha256": message_sha256(message_b),
|
||||
"hamming_fraction": 1.0 - bit_accuracy(message_a, message_b),
|
||||
},
|
||||
"marked": {
|
||||
"bit_accuracy_vs_a": bit_accuracy(decoded_marked, message_a),
|
||||
"bit_accuracy_vs_b": bit_accuracy(decoded_marked, message_b),
|
||||
"detected_as_a": marked_detected,
|
||||
"artifact": _artifact_record(marked_path),
|
||||
"fidelity_from_clean": image_fidelity(clean, marked),
|
||||
},
|
||||
"rewatermarked": {
|
||||
"bit_accuracy_vs_a": bit_accuracy(decoded_rewatermarked, message_a),
|
||||
"bit_accuracy_vs_b": bit_accuracy(decoded_rewatermarked, message_b),
|
||||
"detected_as_a": rewatermarked_detected_a,
|
||||
"detected_as_b": rewatermarked_detected_b,
|
||||
"artifact": _artifact_record(rewatermarked_path),
|
||||
"fidelity_from_clean": image_fidelity(clean, rewatermarked),
|
||||
"fidelity_from_marked": image_fidelity(marked, rewatermarked),
|
||||
},
|
||||
"attack_success": marked_detected and not rewatermarked_detected_a and rewatermarked_detected_b,
|
||||
}
|
||||
|
||||
|
||||
def _run_dwt(carrier: ContentFixture, clean_path: Path, artifacts: Path) -> list[dict[str, Any]]:
|
||||
message_flux, message_sdxl = _dwt_messages()
|
||||
with Image.open(clean_path) as image:
|
||||
clean = np.asarray(image.convert("RGB"), dtype=np.uint8)
|
||||
rows: list[dict[str, Any]] = []
|
||||
for victim_name, message_a, attacker_name, message_b in (
|
||||
("flux", message_flux, "sdxl", message_sdxl),
|
||||
("sdxl", message_sdxl, "flux", message_flux),
|
||||
):
|
||||
marked_path = artifacts / f"{carrier.name}--dwt-{victim_name}--marked-a.png"
|
||||
marked = _save_png(_dwt_embed(clean, message_a), marked_path)
|
||||
rewatermarked_path = artifacts / f"{carrier.name}--dwt-{victim_name}-to-{attacker_name}--rewatermarked-b.png"
|
||||
rewatermarked = _save_png(_dwt_embed(marked, message_b), rewatermarked_path)
|
||||
decoded_marked = _dwt_decode(marked)
|
||||
decoded_rewatermarked = _dwt_decode(rewatermarked)
|
||||
rows.append(
|
||||
_case_row(
|
||||
scheme=f"dwt-dct-{victim_name}-to-{attacker_name}",
|
||||
carrier=carrier,
|
||||
message_a=message_a,
|
||||
message_b=message_b,
|
||||
clean=clean,
|
||||
marked_path=marked_path,
|
||||
rewatermarked_path=rewatermarked_path,
|
||||
marked=marked,
|
||||
rewatermarked=rewatermarked,
|
||||
decoded_marked=decoded_marked,
|
||||
decoded_rewatermarked=decoded_rewatermarked,
|
||||
marked_detected=bit_accuracy(decoded_marked, message_a) >= DWT_MATCH_THRESHOLD,
|
||||
rewatermarked_detected_a=bit_accuracy(decoded_rewatermarked, message_a) >= DWT_MATCH_THRESHOLD,
|
||||
rewatermarked_detected_b=bit_accuracy(decoded_rewatermarked, message_b) >= DWT_MATCH_THRESHOLD,
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _run_trustmark(runtime: Any, carrier: ContentFixture, clean_path: Path, artifacts: Path) -> dict[str, Any]:
|
||||
message_a = seeded_bits(7, TRUSTMARK_BITS)
|
||||
message_b = seeded_bits(8, TRUSTMARK_BITS)
|
||||
with Image.open(clean_path) as image:
|
||||
clean_image = image.convert("RGB")
|
||||
clean = np.asarray(clean_image, dtype=np.uint8)
|
||||
marked_image = runtime.encode(clean_image, "".join(str(bit) for bit in message_a), MODE="binary")
|
||||
marked_path = artifacts / f"{carrier.name}--trustmark--marked-a.png"
|
||||
marked = _save_png(marked_image, marked_path)
|
||||
rewatermarked_image = runtime.encode(
|
||||
Image.fromarray(marked, "RGB"),
|
||||
"".join(str(bit) for bit in message_b),
|
||||
MODE="binary",
|
||||
)
|
||||
rewatermarked_path = artifacts / f"{carrier.name}--trustmark--rewatermarked-b.png"
|
||||
rewatermarked = _save_png(rewatermarked_image, rewatermarked_path)
|
||||
decoded_marked, marked_present, marked_schema = _trustmark_decode(runtime, marked)
|
||||
decoded_rewatermarked, rewatermarked_present, rewatermarked_schema = _trustmark_decode(runtime, rewatermarked)
|
||||
marked_detected = marked_present and marked_schema == 1 and decoded_marked == message_a
|
||||
detected_a = rewatermarked_present and rewatermarked_schema == 1 and decoded_rewatermarked == message_a
|
||||
detected_b = rewatermarked_present and rewatermarked_schema == 1 and decoded_rewatermarked == message_b
|
||||
return _case_row(
|
||||
scheme="trustmark-p-schema1",
|
||||
carrier=carrier,
|
||||
message_a=message_a,
|
||||
message_b=message_b,
|
||||
clean=clean,
|
||||
marked_path=marked_path,
|
||||
rewatermarked_path=rewatermarked_path,
|
||||
marked=marked,
|
||||
rewatermarked=rewatermarked,
|
||||
decoded_marked=decoded_marked,
|
||||
decoded_rewatermarked=decoded_rewatermarked,
|
||||
marked_detected=marked_detected,
|
||||
rewatermarked_detected_a=detected_a,
|
||||
rewatermarked_detected_b=detected_b,
|
||||
)
|
||||
|
||||
|
||||
def _run_videoseal(model: object, carrier: ContentFixture, clean_path: Path, artifacts: Path) -> dict[str, Any]:
|
||||
message_a = videoseal_oracle.message_bits(7)
|
||||
message_b = videoseal_oracle.message_bits(8)
|
||||
with Image.open(clean_path) as image:
|
||||
clean = np.asarray(image.convert("RGB"), dtype=np.uint8)
|
||||
marked_path = artifacts / f"{carrier.name}--videoseal-image--marked-a.png"
|
||||
marked = _save_png(_videoseal_embed(model, clean, message_a), marked_path)
|
||||
rewatermarked_path = artifacts / f"{carrier.name}--videoseal-image--rewatermarked-b.png"
|
||||
rewatermarked = _save_png(_videoseal_embed(model, marked, message_b), rewatermarked_path)
|
||||
decoded_marked = _videoseal_decode(model, marked)
|
||||
decoded_rewatermarked = _videoseal_decode(model, rewatermarked)
|
||||
return _case_row(
|
||||
scheme="videoseal-image-256b",
|
||||
carrier=carrier,
|
||||
message_a=message_a,
|
||||
message_b=message_b,
|
||||
clean=clean,
|
||||
marked_path=marked_path,
|
||||
rewatermarked_path=rewatermarked_path,
|
||||
marked=marked,
|
||||
rewatermarked=rewatermarked,
|
||||
decoded_marked=decoded_marked,
|
||||
decoded_rewatermarked=decoded_rewatermarked,
|
||||
marked_detected=bit_accuracy(decoded_marked, message_a) >= videoseal_oracle.DETECTION_BIT_ACCURACY_THRESHOLD,
|
||||
rewatermarked_detected_a=(
|
||||
bit_accuracy(decoded_rewatermarked, message_a) >= videoseal_oracle.DETECTION_BIT_ACCURACY_THRESHOLD
|
||||
),
|
||||
rewatermarked_detected_b=(
|
||||
bit_accuracy(decoded_rewatermarked, message_b) >= videoseal_oracle.DETECTION_BIT_ACCURACY_THRESHOLD
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def summarize(rows: Sequence[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""Aggregate only validated positive controls, while retaining all counts."""
|
||||
summary: dict[str, Any] = {}
|
||||
for scheme in sorted({str(row["scheme"]) for row in rows}):
|
||||
group = [row for row in rows if row["scheme"] == scheme]
|
||||
controls = [row for row in group if row["marked"]["detected_as_a"]]
|
||||
successes = [row for row in controls if row["attack_success"]]
|
||||
|
||||
def values(path: tuple[str, ...], source: Sequence[dict[str, Any]] = controls) -> list[float]:
|
||||
found: list[float] = []
|
||||
for row in source:
|
||||
value: Any = row
|
||||
for key in path:
|
||||
value = value[key]
|
||||
if value is not None:
|
||||
found.append(float(value))
|
||||
return found
|
||||
|
||||
def distribution(numbers: Sequence[float]) -> dict[str, float] | None:
|
||||
if not numbers:
|
||||
return None
|
||||
return {
|
||||
"min": min(numbers),
|
||||
"median": statistics.median(numbers),
|
||||
"max": max(numbers),
|
||||
"mean": statistics.fmean(numbers),
|
||||
}
|
||||
|
||||
summary[scheme] = {
|
||||
"cases": len(group),
|
||||
"valid_positive_controls": len(controls),
|
||||
"attack_successes": len(successes),
|
||||
"attack_success_rate_on_valid_controls": len(successes) / len(controls) if controls else None,
|
||||
"marked_bit_accuracy_vs_a": distribution(values(("marked", "bit_accuracy_vs_a"))),
|
||||
"rewatermarked_bit_accuracy_vs_a": distribution(values(("rewatermarked", "bit_accuracy_vs_a"))),
|
||||
"rewatermarked_bit_accuracy_vs_b": distribution(values(("rewatermarked", "bit_accuracy_vs_b"))),
|
||||
"rewatermarked_psnr_from_clean": distribution(values(("rewatermarked", "fidelity_from_clean", "psnr_db"))),
|
||||
"attack_psnr_from_marked": distribution(values(("rewatermarked", "fidelity_from_marked", "psnr_db"))),
|
||||
}
|
||||
return summary
|
||||
|
||||
|
||||
def _contact_sheet(rows: Sequence[dict[str, Any]], artifacts: Path, output: Path) -> None:
|
||||
selected: list[dict[str, Any]] = []
|
||||
for scheme in sorted({str(row["scheme"]) for row in rows}):
|
||||
candidates = [row for row in rows if row["scheme"] == scheme and row["marked"]["detected_as_a"]]
|
||||
if candidates:
|
||||
selected.append(min(candidates, key=lambda row: row["rewatermarked"]["fidelity_from_clean"]["psnr_db"]))
|
||||
if not selected:
|
||||
return
|
||||
thumb = 256
|
||||
label_height = 36
|
||||
sheet = Image.new("RGB", (thumb * 3, (thumb + label_height) * len(selected)), "white")
|
||||
draw = ImageDraw.Draw(sheet)
|
||||
for row_index, row in enumerate(selected):
|
||||
clean_path = artifacts / f"{row['carrier']}--clean.png"
|
||||
paths = (
|
||||
clean_path,
|
||||
artifacts / row["marked"]["artifact"]["path"],
|
||||
artifacts / row["rewatermarked"]["artifact"]["path"],
|
||||
)
|
||||
top = row_index * (thumb + label_height)
|
||||
for column, path in enumerate(paths):
|
||||
with Image.open(path) as image:
|
||||
thumbnail = image.convert("RGB").resize((thumb, thumb), Image.Resampling.LANCZOS)
|
||||
sheet.paste(thumbnail, (column * thumb, top))
|
||||
psnr = row["rewatermarked"]["fidelity_from_clean"]["psnr_db"]
|
||||
draw.text((4, top + thumb + 4), f"{row['scheme']} | {row['carrier']} | total PSNR {psnr:.2f} dB", fill="black")
|
||||
sheet.save(output, format="PNG")
|
||||
|
||||
|
||||
def run_study(output_dir: Path, *, pair_ids: Sequence[str], size: int, torch_threads: int) -> Path:
|
||||
if output_dir.exists():
|
||||
raise FileExistsError(f"refusing to overwrite existing output directory {output_dir}")
|
||||
carriers = engine_selection_manifest.load_content_manifest(
|
||||
REPO / "data" / "evaluations" / "engine-selection" / "content-manifest.csv"
|
||||
)
|
||||
selected = [carrier for carrier in carriers if carrier.pair_id in pair_ids]
|
||||
expected = len(pair_ids) * len({carrier.provider for carrier in carriers})
|
||||
if len(selected) != expected:
|
||||
raise ValueError(f"selected {len(selected)} carriers, expected {expected} for pair ids {pair_ids}")
|
||||
|
||||
import torch
|
||||
|
||||
torch.set_num_threads(torch_threads)
|
||||
output_dir.mkdir(parents=True)
|
||||
artifacts = output_dir / "artifacts"
|
||||
artifacts.mkdir()
|
||||
videoseal_model = videoseal_oracle.load_model()
|
||||
trustmark = trustmark_runtime()
|
||||
rows: list[dict[str, Any]] = []
|
||||
for index, carrier in enumerate(selected, start=1):
|
||||
log.info("Carrier %s/%s: %s", index, len(selected), carrier.name)
|
||||
clean_path = artifacts / f"{carrier.name}--clean.png"
|
||||
_save_png(_standardize(carrier, size), clean_path)
|
||||
rows.extend(_run_dwt(carrier, clean_path, artifacts))
|
||||
rows.append(_run_trustmark(trustmark, carrier, clean_path, artifacts))
|
||||
rows.append(_run_videoseal(videoseal_model, carrier, clean_path, artifacts))
|
||||
|
||||
cases_path = output_dir / "cases.jsonl"
|
||||
cases_path.write_text("".join(json.dumps(row, sort_keys=True) + "\n" for row in rows), encoding="utf-8")
|
||||
summary = summarize(rows)
|
||||
repo_state = repository_state()
|
||||
report = {
|
||||
"schema_version": 1,
|
||||
"study_version": STUDY_VERSION,
|
||||
"repository_head": repo_state["commit"],
|
||||
"tracked_tree_dirty": repo_state["dirty"],
|
||||
"study_script_sha256": sha256_file(Path(__file__)),
|
||||
"source_manifest_sha256": sha256_file(
|
||||
REPO / "data" / "evaluations" / "engine-selection" / "content-manifest.csv"
|
||||
),
|
||||
"videoseal_source_commit": videoseal_oracle.SOURCE_COMMIT,
|
||||
"videoseal_jit_sha256": videoseal_oracle.JIT_SHA256,
|
||||
"pair_ids": list(pair_ids),
|
||||
"carriers": len(selected),
|
||||
"size": [size, size],
|
||||
"torch_threads": torch_threads,
|
||||
"case_rows_sha256": sha256_file(cases_path),
|
||||
"summary": summary,
|
||||
}
|
||||
report_path = output_dir / "report.json"
|
||||
report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
_contact_sheet(rows, artifacts, output_dir / "contact-sheet-worst-valid.png")
|
||||
log.info("Wrote %s", report_path)
|
||||
return report_path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--output-dir", type=Path, required=True)
|
||||
parser.add_argument("--size", type=int, default=DEFAULT_SIZE)
|
||||
parser.add_argument("--pair-id", action="append", dest="pair_ids")
|
||||
parser.add_argument("--torch-threads", type=int, default=2)
|
||||
args = parser.parse_args()
|
||||
if args.size < 256 or args.size % 16:
|
||||
parser.error("--size must be at least 256 and divisible by 16")
|
||||
if args.torch_threads < 1:
|
||||
parser.error("--torch-threads must be positive")
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
||||
try:
|
||||
run_study(
|
||||
args.output_dir.resolve(),
|
||||
pair_ids=tuple(args.pair_ids or DEFAULT_PAIR_IDS),
|
||||
size=args.size,
|
||||
torch_threads=args.torch_threads,
|
||||
)
|
||||
except (FileExistsError, ImportError, OSError, RuntimeError, ValueError) as exc:
|
||||
parser.error(str(exc))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Prepare native-resolution, hash-bound SynthID re-watermarking candidates.
|
||||
|
||||
This local-only script first removes every detected visible AI mark and AI
|
||||
metadata, then makes one cleaned pixel control and one VideoSeal image-mode
|
||||
overlay for each of two publication-cleared, historically provider-verified
|
||||
SynthID originals. It does not contact a provider oracle.
|
||||
|
||||
uv run --extra dev python scripts/rewatermarking_synthid_candidates.py \
|
||||
--output-dir /path/to/.local-eval/rewatermarking-synthid-2026-09-27
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO / "scripts"))
|
||||
|
||||
import videoseal_oracle # noqa: E402
|
||||
from watermark_benchmark import sha256_file # noqa: E402
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
SELECTED = {
|
||||
"openai": "05b836ecfe40fd689177fda74384ae4fdcc446505bbc4281cd3cbb6523eb669e",
|
||||
"gemini": "4affd7f27767a445db6abf741355743ba8d95108ad922c9fff045feed8492236",
|
||||
}
|
||||
MESSAGE_SEED = 8
|
||||
|
||||
|
||||
def selected_sources() -> dict[str, Path]:
|
||||
"""Resolve only the two cleared and historically oracle-positive originals."""
|
||||
manifest = REPO / "data" / "synthid" / "manifest.csv"
|
||||
with manifest.open(newline="", encoding="utf-8") as stream:
|
||||
rows = list(csv.DictReader(stream))
|
||||
sources: dict[str, Path] = {}
|
||||
for provider, expected_hash in SELECTED.items():
|
||||
matches = [row for row in rows if row["sha256"] == expected_hash]
|
||||
if len(matches) != 1:
|
||||
raise ValueError(f"expected exactly one manifest row for {provider}")
|
||||
row = matches[0]
|
||||
required_verifier = "openai-verify" if provider == "openai" else "gemini-app"
|
||||
if row["label"] != "pos" or row["verified_via"] != required_verifier:
|
||||
raise ValueError(f"{provider} source lacks the required positive-oracle provenance")
|
||||
source = manifest.parent / "originals" / row["filename"]
|
||||
if not source.is_file() or sha256_file(source) != expected_hash:
|
||||
raise ValueError(f"{provider} source bytes differ from the manifest")
|
||||
sources[provider] = source
|
||||
return sources
|
||||
|
||||
|
||||
def bit_accuracy(decoded: tuple[int, ...], expected: tuple[int, ...]) -> float:
|
||||
if len(decoded) != len(expected):
|
||||
raise ValueError("decoded message length differs from the expected message")
|
||||
return sum(int(actual == wanted) for actual, wanted in zip(decoded, expected, strict=True)) / len(expected)
|
||||
|
||||
|
||||
def overlay(model: object, pixels: np.ndarray[Any, Any], message: tuple[int, ...]) -> np.ndarray[Any, Any]:
|
||||
"""Embed VideoSeal B in image mode and return 8-bit pixels."""
|
||||
marked = videoseal_oracle.embed_image(model, pixels.astype(np.float32) / 255.0, message)
|
||||
return np.rint(np.clip(marked, 0.0, 1.0) * 255.0).astype(np.uint8)
|
||||
|
||||
|
||||
def decoded_accuracy(model: object, pixels: np.ndarray[Any, Any], message: tuple[int, ...]) -> float:
|
||||
decoded = videoseal_oracle.decode_image(model, pixels.astype(np.float32) / 255.0)
|
||||
return bit_accuracy(decoded, message)
|
||||
|
||||
|
||||
def cleaned_control(
|
||||
source: Path,
|
||||
output: Path,
|
||||
*,
|
||||
required_visible_key: str | None = None,
|
||||
) -> tuple[np.ndarray[Any, Any], dict[str, Any]]:
|
||||
"""Remove visible AI marks and metadata before any SynthID oracle input."""
|
||||
from remove_ai_watermarks.api import remove_visible_detailed
|
||||
|
||||
with Image.open(source) as image:
|
||||
original = np.asarray(image.convert("RGB"), dtype=np.uint8)
|
||||
report = remove_visible_detailed(
|
||||
source,
|
||||
output,
|
||||
sensitivity="auto",
|
||||
backend="cv2",
|
||||
strip_metadata=True,
|
||||
write_noop=True,
|
||||
)
|
||||
if report.status in {"partial", "unvalidated"}:
|
||||
raise RuntimeError(f"visible-mark cleanup was not validated: {report.status}")
|
||||
removed_keys = [mark.key for mark in report.marks]
|
||||
if required_visible_key is not None and required_visible_key not in removed_keys:
|
||||
raise RuntimeError(f"required visible mark was not removed: {required_visible_key}")
|
||||
with Image.open(output) as image:
|
||||
cleaned = np.asarray(image.convert("RGB"), dtype=np.uint8)
|
||||
if cleaned.shape != original.shape:
|
||||
raise ValueError("visible-mark cleanup changed image dimensions")
|
||||
return cleaned, {
|
||||
"status": report.status,
|
||||
"removed_keys": removed_keys,
|
||||
"removed_labels": report.labels,
|
||||
"changed_pixel_fraction": float(np.mean(np.any(original != cleaned, axis=-1))),
|
||||
"output_sha256": sha256_file(output),
|
||||
}
|
||||
|
||||
|
||||
def run(output_dir: Path, *, torch_threads: int) -> Path:
|
||||
if output_dir.exists():
|
||||
raise FileExistsError(f"refusing to overwrite existing output directory {output_dir}")
|
||||
sources = selected_sources()
|
||||
import torch
|
||||
|
||||
torch.set_num_threads(torch_threads)
|
||||
model = videoseal_oracle.load_model()
|
||||
message = videoseal_oracle.message_bits(MESSAGE_SEED)
|
||||
output_dir.mkdir(parents=True)
|
||||
rows: list[dict[str, Any]] = []
|
||||
for provider, source in sources.items():
|
||||
log.info("Preparing %s candidate", provider)
|
||||
control = output_dir / f"{provider}-pixel-control.png"
|
||||
clean, visible_preflight = cleaned_control(
|
||||
source,
|
||||
control,
|
||||
required_visible_key="gemini" if provider == "gemini" else None,
|
||||
)
|
||||
candidate = output_dir / f"{provider}-videoseal-overlay.png"
|
||||
marked = overlay(model, clean, message)
|
||||
if marked.shape != clean.shape:
|
||||
raise ValueError(f"{provider} overlay changed image dimensions")
|
||||
Image.fromarray(marked, "RGB").save(candidate)
|
||||
with Image.open(candidate) as image:
|
||||
marked = np.asarray(image.convert("RGB"), dtype=np.uint8)
|
||||
accuracy = decoded_accuracy(model, marked, message)
|
||||
if accuracy < videoseal_oracle.DETECTION_BIT_ACCURACY_THRESHOLD:
|
||||
raise RuntimeError(f"{provider} VideoSeal message B did not survive serialization: {accuracy:.4f}")
|
||||
delta = np.subtract(marked, clean, dtype=np.float32)
|
||||
np.square(delta, out=delta)
|
||||
mse = float(np.mean(delta, dtype=np.float64))
|
||||
if mse <= 0.0:
|
||||
raise RuntimeError(f"{provider} overlay did not change pixels")
|
||||
rows.append(
|
||||
{
|
||||
"provider": provider,
|
||||
"source_sha256": sha256_file(source),
|
||||
"control": {"path": control.name, "sha256": sha256_file(control)},
|
||||
"candidate": {"path": candidate.name, "sha256": sha256_file(candidate)},
|
||||
"visible_preflight": visible_preflight,
|
||||
"size": [int(clean.shape[1]), int(clean.shape[0])],
|
||||
"videoseal_b_bit_accuracy": accuracy,
|
||||
"psnr_from_control_db": 20.0 * math.log10(255.0) - 10.0 * math.log10(mse),
|
||||
"changed_pixel_fraction": float(np.mean(np.any(clean != marked, axis=-1))),
|
||||
}
|
||||
)
|
||||
report = {
|
||||
"source_manifest_sha256": sha256_file(REPO / "data" / "synthid" / "manifest.csv"),
|
||||
"study_script_sha256": sha256_file(Path(__file__)),
|
||||
"videoseal_jit_sha256": videoseal_oracle.JIT_SHA256,
|
||||
"message_seed": MESSAGE_SEED,
|
||||
"message_sha256": hashlib.sha256("".join(str(bit) for bit in message).encode()).hexdigest(),
|
||||
"rows": rows,
|
||||
"note": "Local candidate preparation only; no production SynthID verdict has been requested.",
|
||||
}
|
||||
report_path = output_dir / "report.json"
|
||||
report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
return report_path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--output-dir", required=True, type=Path)
|
||||
parser.add_argument("--torch-threads", default=2, type=int)
|
||||
args = parser.parse_args()
|
||||
if args.torch_threads < 1:
|
||||
parser.error("--torch-threads must be positive")
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
||||
try:
|
||||
report_path = run(args.output_dir.resolve(), torch_threads=args.torch_threads)
|
||||
except (FileExistsError, ImportError, OSError, RuntimeError, ValueError) as exc:
|
||||
parser.error(str(exc))
|
||||
log.info("Wrote %s", report_path)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Measure VideoSeal message replacement on decoded, saved MP4 artifacts.
|
||||
|
||||
The pair of carriers is the synthetic benchmark gradient and a committed,
|
||||
publication-cleared video fixture. Each clip is encoded before it is used by
|
||||
the next stage, so detector and fidelity measurements refer to the retained
|
||||
MP4 bytes. Outputs belong outside the repository under ``.local-eval/``.
|
||||
|
||||
uv run --extra dev python scripts/rewatermarking_video_study.py \
|
||||
--output-dir /path/to/.local-eval/rewatermarking-video-2026-09-27
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
import statistics
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO / "src"))
|
||||
sys.path.insert(0, str(REPO / "scripts"))
|
||||
|
||||
import videoseal_oracle # noqa: E402
|
||||
from videoseal_temporal_study import real_carrier_frames # noqa: E402
|
||||
from watermark_benchmark import decode_video_artifact, repository_state, sha256_file # noqa: E402
|
||||
from watermark_benchmark_video_cohort import encode_clip, synth_carrier # noqa: E402
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def bit_accuracy(decoded: Sequence[int], expected: Sequence[int]) -> float:
|
||||
"""Return matching-bit fraction for equal-length messages."""
|
||||
if not decoded or len(decoded) != len(expected):
|
||||
raise ValueError("bit strings must be non-empty and have equal length")
|
||||
return sum(int(bit == want) for bit, want in zip(decoded, expected, strict=True)) / len(expected)
|
||||
|
||||
|
||||
def mean_video_psnr(reference: np.ndarray[Any, Any], artifact: np.ndarray[Any, Any]) -> float | None:
|
||||
"""Match the benchmark kernel's mean of per-frame decoded RGB PSNR."""
|
||||
if reference.shape != artifact.shape or reference.ndim != 4 or reference.shape[-1] != 3:
|
||||
raise ValueError("video frames must share a (T, H, W, 3) shape")
|
||||
per_frame_mse = np.empty(reference.shape[0], dtype=np.float64)
|
||||
for index, (reference_frame, artifact_frame) in enumerate(zip(reference, artifact, strict=True)):
|
||||
delta = np.subtract(artifact_frame, reference_frame, dtype=np.float32)
|
||||
np.square(delta, out=delta)
|
||||
per_frame_mse[index] = np.mean(delta, dtype=np.float64)
|
||||
finite = per_frame_mse[per_frame_mse > 0.0]
|
||||
if finite.size == 0:
|
||||
return None
|
||||
return float(np.mean(10.0 * np.log10(1.0 / finite)))
|
||||
|
||||
|
||||
def _measure(
|
||||
model: object,
|
||||
frames: np.ndarray[Any, Any],
|
||||
message_a: Sequence[int],
|
||||
message_b: Sequence[int],
|
||||
) -> dict[str, Any]:
|
||||
reading = videoseal_oracle.read(model, frames, message=message_a)
|
||||
accuracy_b = bit_accuracy(reading.decoded_bits, message_b)
|
||||
return {
|
||||
"bit_accuracy_vs_a": reading.bit_accuracy,
|
||||
"bit_accuracy_vs_b": accuracy_b,
|
||||
"detected_as_a": reading.bit_accuracy >= videoseal_oracle.DETECTION_BIT_ACCURACY_THRESHOLD,
|
||||
"detected_as_b": accuracy_b >= videoseal_oracle.DETECTION_BIT_ACCURACY_THRESHOLD,
|
||||
}
|
||||
|
||||
|
||||
def run_study(output_dir: Path, *, torch_threads: int = 2) -> Path:
|
||||
if output_dir.exists():
|
||||
raise FileExistsError(f"refusing to overwrite existing output directory {output_dir}")
|
||||
ffmpeg = shutil.which("ffmpeg")
|
||||
if ffmpeg is None:
|
||||
raise RuntimeError("ffmpeg is required for the video study")
|
||||
import torch
|
||||
|
||||
torch.set_num_threads(torch_threads)
|
||||
model = videoseal_oracle.load_model()
|
||||
message_a = videoseal_oracle.message_bits(7)
|
||||
message_b = videoseal_oracle.message_bits(8)
|
||||
output_dir.mkdir(parents=True)
|
||||
carriers = {
|
||||
"moving_gradient": (
|
||||
synth_carrier("moving_gradient")[:64],
|
||||
{"source": "deterministic synthetic benchmark carrier"},
|
||||
),
|
||||
"real_sora": real_carrier_frames("sora"),
|
||||
}
|
||||
rows: list[dict[str, Any]] = []
|
||||
for name, (source_frames, provenance) in carriers.items():
|
||||
log.info("Measuring %s", name)
|
||||
case_dir = output_dir / name
|
||||
case_dir.mkdir()
|
||||
clean_path = encode_clip(ffmpeg, case_dir / "clean.mp4", source_frames)
|
||||
clean, clean_sha256 = decode_video_artifact(clean_path)
|
||||
marked_path = encode_clip(
|
||||
ffmpeg,
|
||||
case_dir / "marked-a.mp4",
|
||||
np.asarray(videoseal_oracle.embed(model, clean, message_a)),
|
||||
)
|
||||
marked, marked_sha256 = decode_video_artifact(marked_path)
|
||||
rewatermarked_path = encode_clip(
|
||||
ffmpeg,
|
||||
case_dir / "rewatermarked-b.mp4",
|
||||
np.asarray(videoseal_oracle.embed(model, marked, message_b)),
|
||||
)
|
||||
rewatermarked, rewatermarked_sha256 = decode_video_artifact(rewatermarked_path)
|
||||
baseline = _measure(model, clean, message_a, message_b)
|
||||
marked_reading = _measure(model, marked, message_a, message_b)
|
||||
rewatermarked_reading = _measure(model, rewatermarked, message_a, message_b)
|
||||
row = {
|
||||
"carrier": name,
|
||||
"provenance": provenance,
|
||||
"frame_shape": list(clean.shape),
|
||||
"clean": {"sha256": clean_sha256, **baseline},
|
||||
"marked": {
|
||||
"sha256": marked_sha256,
|
||||
**marked_reading,
|
||||
"mean_psnr_from_clean_db": mean_video_psnr(clean, marked),
|
||||
},
|
||||
"rewatermarked": {
|
||||
"sha256": rewatermarked_sha256,
|
||||
**rewatermarked_reading,
|
||||
"mean_psnr_from_clean_db": mean_video_psnr(clean, rewatermarked),
|
||||
"mean_psnr_from_marked_db": mean_video_psnr(marked, rewatermarked),
|
||||
},
|
||||
"attack_success": (
|
||||
marked_reading["detected_as_a"]
|
||||
and not rewatermarked_reading["detected_as_a"]
|
||||
and rewatermarked_reading["detected_as_b"]
|
||||
),
|
||||
}
|
||||
rows.append(row)
|
||||
|
||||
cases_path = output_dir / "cases.jsonl"
|
||||
cases_path.write_text("".join(json.dumps(row, sort_keys=True) + "\n" for row in rows), encoding="utf-8")
|
||||
report = {
|
||||
"repository_head": repository_state()["commit"],
|
||||
"study_script_sha256": sha256_file(Path(__file__)),
|
||||
"case_rows_sha256": sha256_file(cases_path),
|
||||
"videoseal_jit_sha256": videoseal_oracle.JIT_SHA256,
|
||||
"torch_threads": torch_threads,
|
||||
"carriers": len(rows),
|
||||
"attack_successes": sum(int(row["attack_success"]) for row in rows),
|
||||
"mean_psnr_from_clean_db": statistics.fmean(row["rewatermarked"]["mean_psnr_from_clean_db"] for row in rows),
|
||||
}
|
||||
report_path = output_dir / "report.json"
|
||||
report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
log.info("Wrote %s", report_path)
|
||||
return report_path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--output-dir", type=Path, required=True)
|
||||
parser.add_argument("--torch-threads", type=int, default=2)
|
||||
args = parser.parse_args()
|
||||
if args.torch_threads < 1:
|
||||
parser.error("--torch-threads must be positive")
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
||||
try:
|
||||
run_study(args.output_dir.resolve(), torch_threads=args.torch_threads)
|
||||
except (FileExistsError, ImportError, OSError, RuntimeError, ValueError) as exc:
|
||||
parser.error(str(exc))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -127,7 +127,7 @@ def load_model() -> object:
|
||||
import torch
|
||||
|
||||
model = torch.jit.load(str(checkpoint_path()), map_location="cpu")
|
||||
model.eval()
|
||||
model.train(False)
|
||||
return model
|
||||
|
||||
|
||||
@@ -144,6 +144,33 @@ def embed(model: object, frames: object, message: Sequence[int]) -> object:
|
||||
return marked.permute(0, 2, 3, 1).numpy()
|
||||
|
||||
|
||||
def embed_image(model: object, pixels: object, message: Sequence[int]) -> object:
|
||||
"""Embed a message into one RGB image in [0, 1] (H, W, 3)."""
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
array = torch.from_numpy(np.ascontiguousarray(pixels, dtype=np.float32))
|
||||
tensor = array.permute(2, 0, 1).unsqueeze(0)
|
||||
msg = torch.tensor([list(message)], dtype=torch.float32)
|
||||
with torch.no_grad():
|
||||
marked = model.embed(tensor, msg, is_video=False)
|
||||
return marked[0].permute(1, 2, 0).numpy()
|
||||
|
||||
|
||||
def decode_image(model: object, pixels: object) -> tuple[int, ...]:
|
||||
"""Decode the message bits from one RGB image."""
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
array = torch.from_numpy(np.ascontiguousarray(pixels, dtype=np.float32))
|
||||
tensor = array.permute(2, 0, 1).unsqueeze(0)
|
||||
with torch.no_grad():
|
||||
predictions = model.detect(tensor, is_video=False)
|
||||
if isinstance(predictions, dict):
|
||||
predictions = predictions["preds"]
|
||||
return tuple(int(bit) for bit in (predictions[:, 1:] > 0).to(torch.int64).view(-1).tolist())
|
||||
|
||||
|
||||
def _aggregate_bit_preds(bit_preds: object, aggregation: str) -> object:
|
||||
"""Aggregate per-frame bit logits exactly as upstream ``extract_message``.
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import importlib
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
@@ -71,7 +72,9 @@ MEDIA_TYPES: tuple[Media, ...] = ("image", "audio", "video")
|
||||
ADAPTER_MEDIA: dict[str, Media] = {
|
||||
"dwt-dct": "image",
|
||||
"trustmark": "image",
|
||||
"pixelseal": "image",
|
||||
"audioseal": "audio",
|
||||
"perth": "audio",
|
||||
"videoseal": "video",
|
||||
}
|
||||
_FIELDS = {
|
||||
@@ -131,6 +134,8 @@ class DetectorOutcome:
|
||||
label: str | None
|
||||
error: str | None = None
|
||||
temporal: tuple[float, ...] | None = None
|
||||
message_bit_accuracy: float | None = None
|
||||
score: float | None = None
|
||||
|
||||
|
||||
class DetectorAdapter(Protocol):
|
||||
@@ -173,11 +178,66 @@ class VideoSealAdapter:
|
||||
def detect(self, path: Path, frames: NDArray[Any]) -> DetectorOutcome:
|
||||
from videoseal_oracle import read
|
||||
|
||||
reading = read(_videoseal_model(), frames)
|
||||
reading = read(_oracle_model("videoseal_oracle"), frames)
|
||||
return DetectorOutcome(
|
||||
status="detected" if reading.detected else "not_detected",
|
||||
label=reading.label if reading.detected else None,
|
||||
temporal=reading.per_frame_bit_accuracy,
|
||||
message_bit_accuracy=reading.bit_accuracy,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PerthAdapter:
|
||||
"""Adapter for Perth's implicit presence score."""
|
||||
|
||||
name: str
|
||||
source_file: Path
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Probe the optional stack only when a Perth row is evaluated."""
|
||||
from perth_oracle import available
|
||||
|
||||
return available()
|
||||
|
||||
def detect(self, path: Path, samples: NDArray[Any]) -> DetectorOutcome:
|
||||
from perth_oracle import read
|
||||
|
||||
reading = read(_oracle_model("perth_oracle"), samples)
|
||||
return DetectorOutcome(
|
||||
status="detected" if reading.detected else "not_detected",
|
||||
label="perth-v1" if reading.detected else None,
|
||||
score=reading.confidence,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PixelSealAdapter:
|
||||
"""Adapter for PixelSeal's fixed-message image verifier."""
|
||||
|
||||
name: str
|
||||
source_file: Path
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Probe the optional stack only when a PixelSeal row is evaluated."""
|
||||
from pixelseal_oracle import available
|
||||
|
||||
return available()
|
||||
|
||||
def detect(self, path: Path, image: NDArray[Any]) -> DetectorOutcome:
|
||||
import cv2
|
||||
from pixelseal_oracle import read
|
||||
|
||||
from remove_ai_watermarks.image_io import to_bgr
|
||||
|
||||
rgb = cv2.cvtColor(to_bgr(image), cv2.COLOR_BGR2RGB)
|
||||
reading = read(_oracle_model("pixelseal_oracle"), rgb)
|
||||
return DetectorOutcome(
|
||||
status="detected" if reading.detected else "not_detected",
|
||||
label=reading.label if reading.detected else None,
|
||||
message_bit_accuracy=reading.bit_accuracy,
|
||||
)
|
||||
|
||||
|
||||
@@ -409,11 +469,15 @@ def default_adapters() -> dict[str, DetectorAdapter]:
|
||||
if str(scripts_dir) not in sys.path:
|
||||
sys.path.insert(0, str(scripts_dir))
|
||||
import audioseal_oracle
|
||||
import perth_oracle
|
||||
import pixelseal_oracle
|
||||
import videoseal_oracle
|
||||
|
||||
dwt_source = Path(invisible_watermark.__file__).resolve()
|
||||
trustmark_source = Path(trustmark_detector.__file__).resolve()
|
||||
audio_oracle_source = Path(audioseal_oracle.__file__).resolve()
|
||||
perth_oracle_source = Path(perth_oracle.__file__).resolve()
|
||||
pixel_oracle_source = Path(pixelseal_oracle.__file__).resolve()
|
||||
video_oracle_source = Path(videoseal_oracle.__file__).resolve()
|
||||
return {
|
||||
"dwt-dct": FunctionAdapter(
|
||||
@@ -428,12 +492,20 @@ def default_adapters() -> dict[str, DetectorAdapter]:
|
||||
available=trustmark_detector.is_available(),
|
||||
detector=_detect_trustmark,
|
||||
),
|
||||
"pixelseal": PixelSealAdapter(
|
||||
name="pixelseal",
|
||||
source_file=pixel_oracle_source,
|
||||
),
|
||||
"audioseal": FunctionAdapter(
|
||||
name="audioseal",
|
||||
source_file=audio_oracle_source,
|
||||
available=audioseal_oracle.available(),
|
||||
detector=_detect_audioseal,
|
||||
),
|
||||
"perth": PerthAdapter(
|
||||
name="perth",
|
||||
source_file=perth_oracle_source,
|
||||
),
|
||||
"videoseal": VideoSealAdapter(
|
||||
name="videoseal",
|
||||
source_file=video_oracle_source,
|
||||
@@ -585,13 +657,13 @@ def _audioseal_detector() -> object:
|
||||
|
||||
|
||||
@cache
|
||||
def _videoseal_model() -> object:
|
||||
def _oracle_model(module_name: str) -> object:
|
||||
"""Load and cache one development-only oracle model."""
|
||||
scripts_dir = Path(__file__).resolve().parent
|
||||
if str(scripts_dir) not in sys.path:
|
||||
sys.path.insert(0, str(scripts_dir))
|
||||
from videoseal_oracle import load_model
|
||||
|
||||
return load_model()
|
||||
module = importlib.import_module(module_name)
|
||||
return module.load_model()
|
||||
|
||||
|
||||
def _image_fidelity(
|
||||
@@ -745,6 +817,10 @@ def _detection_record(
|
||||
"max_bit_accuracy": max(values) if values else None,
|
||||
"per_frame_bit_accuracy": [round(value, 6) for value in values],
|
||||
}
|
||||
if outcome.message_bit_accuracy is not None:
|
||||
record["message_bit_accuracy"] = float(outcome.message_bit_accuracy)
|
||||
if outcome.score is not None:
|
||||
record["score"] = float(outcome.score)
|
||||
if outcome.error is not None:
|
||||
record["error"] = outcome.error
|
||||
return record
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
||||
"name": "io.github.wiltodelta/remove-ai-watermarks",
|
||||
"title": "Remove AI Watermarks",
|
||||
"description": "Identify and remove AI watermarks and provenance metadata from your own images and video.",
|
||||
"version": "0.46.0",
|
||||
"repository": {
|
||||
"url": "https://github.com/wiltodelta/remove-ai-watermarks",
|
||||
"source": "github"
|
||||
},
|
||||
"websiteUrl": "https://github.com/wiltodelta/remove-ai-watermarks/blob/main/docs/mcp-server.md",
|
||||
"packages": [
|
||||
{
|
||||
"registryType": "pypi",
|
||||
"registryBaseUrl": "https://pypi.org",
|
||||
"identifier": "remove-ai-watermarks",
|
||||
"version": "0.46.0",
|
||||
"runtimeHint": "uvx",
|
||||
"transport": {
|
||||
"type": "stdio"
|
||||
},
|
||||
"packageArguments": [
|
||||
{
|
||||
"type": "positional",
|
||||
"value": "mcp"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "remove-ai-watermarks",
|
||||
"displayName": "Remove AI Watermarks",
|
||||
"version": "1.0.9",
|
||||
"version": "1.0.12",
|
||||
"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.9"
|
||||
version: "1.0.12"
|
||||
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`. 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
|
||||
@@ -125,7 +125,7 @@ remove-ai-watermarks video batch ./videos --mode all
|
||||
```
|
||||
|
||||
Video mark keys: `sora`, `veo`, `seedance`, `doubao`, `dola`, `hailuo`,
|
||||
`vidu`, `kling`. Default `--mark auto` picks the first temporally stable match in that
|
||||
`vidu`, `kling`; `vidu` is new (since CLI 0.43.0). Default `--mark auto` picks the first temporally stable match in that
|
||||
order.
|
||||
|
||||
`video invisible` and `video all --invisible` accept MP4, MOV, and M4V only.
|
||||
@@ -138,6 +138,13 @@ user names a device, and never to claim a device the probe did not report.
|
||||
Without `-o`, video commands write `<source>_clean` and keep the original.
|
||||
Output extension must match the input container.
|
||||
|
||||
## MCP server
|
||||
|
||||
`remove-ai-watermarks mcp` (since CLI 0.46.0) serves `identify`, `remove`
|
||||
and `batch` as MCP tools over stdin and stdout for MCP clients. It blocks
|
||||
waiting for protocol messages, so do not run it from this skill; call the
|
||||
commands above directly.
|
||||
|
||||
## Exit codes
|
||||
|
||||
| Code | Meaning |
|
||||
|
||||
@@ -32,7 +32,7 @@ _GPU_MARKERS = ("GPU ", "gpu:")
|
||||
# told an agent its CLI was current right before it typed a value that build
|
||||
# rejects. tests/test_agent_skill.py pins it against the package version and
|
||||
# against every "(since CLI X.Y.Z)" annotation in the skill.
|
||||
MIN_CLI_VERSION = (0, 40, 5)
|
||||
MIN_CLI_VERSION = (0, 46, 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.42.0"
|
||||
__version__ = "0.46.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: d5960248ab69c17dc194906d2ef7d11ad164be70
|
||||
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 = "d5960248ab69c17dc194906d2ef7d11ad164be70"
|
||||
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 "
|
||||
@@ -632,4 +632,15 @@ C2PA_SOFT_BINDING_ROWS: tuple[
|
||||
(),
|
||||
False,
|
||||
),
|
||||
(
|
||||
54,
|
||||
"be.immostory.videoseal.1",
|
||||
"watermark",
|
||||
("video",),
|
||||
("video/mp4",),
|
||||
"ImmoStory AI deployment of Meta FAIR's Video Seal 1.0 invisible video watermark",
|
||||
"2026-10-12T00:00:00.000Z",
|
||||
(),
|
||||
False,
|
||||
),
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -67,7 +67,7 @@ def load_headed_clip(checkpoint: Path, device: torch.device) -> HeadedCLIP:
|
||||
model = HeadedCLIP(clip)
|
||||
state = torch.load(checkpoint, map_location="cpu", weights_only=True)
|
||||
model.load_state_dict(state, strict=True)
|
||||
return model.to(device).eval()
|
||||
return model.to(device).train(False)
|
||||
|
||||
|
||||
def load_processor() -> Any:
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -7,7 +7,9 @@ without re-encoding, the image path drops matching boxes and emits the rest
|
||||
verbatim. The streaming MP4/MOV/M4A path instead preserves all offsets by
|
||||
retyping matching boxes as ``free`` and blanking their payloads in place. The
|
||||
codestream (``mdat`` for ISOBMFF, ``jxlc`` / ``jxlp`` for JPEG-XL) is untouched,
|
||||
so pixel, video, and audio data is preserved bit-for-bit.
|
||||
so pixel, video, and audio data is preserved bit-for-bit. The one exception is
|
||||
Kling's SEI tag inside ``mdat``: its bytes are blanked in place, which no decoder
|
||||
reads, so every decoded frame stays identical.
|
||||
|
||||
TC260-PG-20257A video metadata is nested instead:
|
||||
``moov.udta.meta.keys/ilst``. Its detector seeks through those boxes without
|
||||
@@ -43,8 +45,11 @@ from remove_ai_watermarks.metadata import (
|
||||
C2PA_UUID,
|
||||
IPTC_AI_FIELD_MARKERS,
|
||||
IPTC_AI_MARKERS,
|
||||
KLING_SEI_TAG,
|
||||
LOCAL_GENERATION_METADATA_KEYS,
|
||||
MAX_TC260_VALUE_BYTES,
|
||||
app_generator_from_metadata,
|
||||
app_provenance_from_metadata,
|
||||
parse_tc260_aigc_json,
|
||||
)
|
||||
|
||||
@@ -347,6 +352,55 @@ def _ai_metadata_regions(
|
||||
return regions
|
||||
|
||||
|
||||
def _app_metadata_regions(
|
||||
stream: BinaryIO,
|
||||
file_size: int,
|
||||
) -> list[tuple[tuple[int, int] | None, int, int]]:
|
||||
"""Locate keyed or keyless values carrying an app-export provenance object.
|
||||
|
||||
Dreamina writes its export JSON under ordinary keys (``LvMetaInfo`` and a
|
||||
second copy under ``com.apple.quicktime.artwork``). The file-level scan reads
|
||||
it from the raw head bytes, so removal must blank every item it would match.
|
||||
"""
|
||||
return [
|
||||
(key_span, value_start, value_end)
|
||||
for _name, key_span, value_start, value_end, value in _metadata_items(stream, file_size)
|
||||
if app_provenance_from_metadata(value) or app_generator_from_metadata(value)
|
||||
]
|
||||
|
||||
|
||||
def _copy_finding_kling_sei(
|
||||
source: BinaryIO,
|
||||
target: BinaryIO,
|
||||
mdat_ranges: list[tuple[int, int]],
|
||||
) -> list[int]:
|
||||
"""Copy ``source`` to ``target`` and return each Kling SEI tag offset in ``mdat``.
|
||||
|
||||
Detection reads the tag from the head window, but removal covers every copy in
|
||||
the media data. The search rides on the copy, so the file is read once. Each
|
||||
window carries one byte less than the tag from the previous chunk, so a copy
|
||||
straddling a read boundary is found exactly once.
|
||||
"""
|
||||
keep = len(KLING_SEI_TAG) - 1
|
||||
offsets: list[int] = []
|
||||
tail = b""
|
||||
pos = 0
|
||||
while chunk := source.read(_STREAM_COPY_BYTES):
|
||||
target.write(chunk)
|
||||
if mdat_ranges:
|
||||
window = tail + chunk
|
||||
base = pos - len(tail)
|
||||
found = window.find(KLING_SEI_TAG)
|
||||
while found != -1:
|
||||
start = base + found
|
||||
if any(low <= start and start + len(KLING_SEI_TAG) <= high for low, high in mdat_ranges):
|
||||
offsets.append(start)
|
||||
found = window.find(KLING_SEI_TAG, found + 1)
|
||||
tail = window[-keep:]
|
||||
pos += len(chunk)
|
||||
return offsets
|
||||
|
||||
|
||||
def ai_metadata_tags(path: str | Path, *, strict: bool = False) -> dict[str, str]:
|
||||
"""Read keyed AI-generation metadata from an ISOBMFF container."""
|
||||
try:
|
||||
@@ -601,15 +655,16 @@ def strip_isobmff_media_file(
|
||||
|
||||
The output retains every box size and byte offset. A top-level C2PA/JUMBF or
|
||||
AI-label box is converted to a ``free`` box and its payload is zeroed; native
|
||||
TC260 key/value spans are blanked in place. Keeping the original lengths is
|
||||
required because removing a pre-``mdat`` box would invalidate absolute media
|
||||
offsets in an existing sample table.
|
||||
TC260, generation-key and app-export key/value spans, and the Kling SEI tag in
|
||||
``mdat``, are blanked in place. Keeping the original lengths is required
|
||||
because removing a pre-``mdat`` box would invalidate absolute media offsets in
|
||||
an existing sample table.
|
||||
|
||||
The source is copied in bounded chunks to a sibling temporary file and
|
||||
atomically published only after all patches succeed. A malformed top-level
|
||||
walk is fail-safe: the input is copied unchanged.
|
||||
|
||||
Returns ``(provenance_boxes_blanked, native_tc260_keys_blanked)``.
|
||||
Returns ``(provenance_boxes_blanked, native_tags_blanked)``.
|
||||
"""
|
||||
from pathlib import Path as _Path
|
||||
|
||||
@@ -627,12 +682,23 @@ def strip_isobmff_media_file(
|
||||
)
|
||||
tc260_regions = _tc260_aigc_regions(stream, file_size) if targets is not None else []
|
||||
ai_metadata_regions = _ai_metadata_regions(stream, file_size) if targets is not None else []
|
||||
app_regions = _app_metadata_regions(stream, file_size) if targets is not None else []
|
||||
mdat_ranges = (
|
||||
[
|
||||
(payload, end)
|
||||
for _start, end, box_type, payload in iter_file_boxes(stream, 0, file_size)
|
||||
if box_type == b"mdat"
|
||||
]
|
||||
if targets is not None
|
||||
else []
|
||||
)
|
||||
tc260_key_spans = {region[0] for region in tc260_regions if region[0] is not None}
|
||||
ai_metadata_key_spans = {region[0] for region in ai_metadata_regions}
|
||||
app_key_spans = {region[0] for region in app_regions if region[0] is not None}
|
||||
|
||||
with atomic_video_output(output_path) as temporary_path:
|
||||
with source_path.open("rb") as source_stream, temporary_path.open("r+b") as temporary:
|
||||
shutil.copyfileobj(source_stream, temporary, length=_STREAM_COPY_BYTES)
|
||||
kling_sei_offsets = _copy_finding_kling_sei(source_stream, temporary, mdat_ranges)
|
||||
if targets is not None:
|
||||
for box_start, payload_start, box_end in targets:
|
||||
temporary.seek(box_start + 4)
|
||||
@@ -647,6 +713,15 @@ def strip_isobmff_media_file(
|
||||
temporary.seek(key_span[0])
|
||||
temporary.write(b"free" + b" " * (key_span[1] - key_span[0] - 4))
|
||||
_overwrite_range(temporary, value_start, value_end, byte=b" ")
|
||||
for key_span, value_start, value_end in app_regions:
|
||||
if key_span is not None:
|
||||
temporary.seek(key_span[0])
|
||||
temporary.write(b"free" + b" " * (key_span[1] - key_span[0] - 4))
|
||||
_overwrite_range(temporary, value_start, value_end, byte=b" ")
|
||||
# Spaces keep the SEI message well-formed: its length fields stand,
|
||||
# and a 0x20 run cannot form an emulation-prevention sequence.
|
||||
for offset in kling_sei_offsets:
|
||||
_overwrite_range(temporary, offset, offset + len(KLING_SEI_TAG), byte=b" ")
|
||||
temporary.flush()
|
||||
os.fsync(temporary.fileno())
|
||||
shutil.copymode(source_path, temporary_path)
|
||||
@@ -657,7 +732,8 @@ def strip_isobmff_media_file(
|
||||
source_path,
|
||||
)
|
||||
return 0, 0
|
||||
return len(targets), len(tc260_key_spans | ai_metadata_key_spans)
|
||||
# Every copy of the Kling tag is one tag, however many samples repeat it.
|
||||
return len(targets), len(tc260_key_spans | ai_metadata_key_spans | app_key_spans) + bool(kling_sei_offsets)
|
||||
|
||||
|
||||
def strip_c2pa_boxes(data: bytes) -> tuple[bytes, int]:
|
||||
|
||||
@@ -47,6 +47,12 @@ _LATENT_GRID = 8
|
||||
# The reference graph loads the Qwen LoRA at 0.8; carrying that number to a different
|
||||
# LoRA on a different architecture would be imitation, not parity.
|
||||
SDXL_STEPS = 4
|
||||
# Below this floor the global stack streams its weights from CPU layer by layer; at or
|
||||
# above it the stack stays resident. The fp16 UNet, Canny ControlNet, both text
|
||||
# encoders and the VAE are ~8.8 GiB of weights, and the UNet and the ControlNet run
|
||||
# together on every step, so model-level offload cannot get under an 8 GiB card.
|
||||
# Benchmark in docs/module-internals.md, "CPU offload".
|
||||
RESIDENT_SDXL_GLOBAL_MIN_VRAM_GIB = 12.0
|
||||
|
||||
|
||||
def sdxl_target_size(width: int, height: int) -> tuple[int, int]:
|
||||
@@ -54,6 +60,17 @@ def sdxl_target_size(width: int, height: int) -> tuple[int, int]:
|
||||
return _target_size(width, height, _LATENT_GRID)
|
||||
|
||||
|
||||
def resolve_sdxl_global_residency(
|
||||
requested: bool | None,
|
||||
*,
|
||||
total_memory_gib: float,
|
||||
) -> bool:
|
||||
"""Keep the SDXL global stack resident when explicitly requested or safely sized."""
|
||||
if requested is not None:
|
||||
return requested
|
||||
return total_memory_gib >= RESIDENT_SDXL_GLOBAL_MIN_VRAM_GIB
|
||||
|
||||
|
||||
@dataclass
|
||||
class SdxlZImagePipeline(TwoStageZImagePipeline):
|
||||
"""Lazy runtime for the SDXL global stage plus the inherited face stage."""
|
||||
@@ -64,6 +81,12 @@ class SdxlZImagePipeline(TwoStageZImagePipeline):
|
||||
super().__post_init__()
|
||||
self._sdxl_pipe: Any = None
|
||||
|
||||
def _keep_global_models_resident(self) -> bool:
|
||||
return resolve_sdxl_global_residency(
|
||||
self.keep_global_models_on_device,
|
||||
total_memory_gib=self._total_vram_gib(),
|
||||
)
|
||||
|
||||
def _load_global(self) -> Any:
|
||||
if self._sdxl_pipe is not None:
|
||||
return self._sdxl_pipe
|
||||
@@ -89,11 +112,19 @@ class SdxlZImagePipeline(TwoStageZImagePipeline):
|
||||
variant="fp16",
|
||||
add_watermarker=False,
|
||||
**token,
|
||||
).to(self.device)
|
||||
)
|
||||
resident = self._keep_global_models_resident()
|
||||
if resident:
|
||||
pipe = pipe.to(self.device)
|
||||
pipe.load_lora_weights(hf_hub_download(SDXL_LIGHTNING_MODEL_ID, SDXL_LIGHTNING_PATTERN, **token))
|
||||
pipe.fuse_lora()
|
||||
# SDXL-Lightning is distilled against trailing timestep spacing.
|
||||
pipe.scheduler = EulerDiscreteScheduler.from_config(pipe.scheduler.config, timestep_spacing="trailing")
|
||||
if not resident:
|
||||
# After the fuse: the offload hooks keep their own CPU copy of each weight
|
||||
# and stream that copy, so fusing later would never reach what runs.
|
||||
self._progress("Streaming the SDXL stack from CPU (card below the residency floor)...")
|
||||
pipe.enable_sequential_cpu_offload(device=self.device, gpu_id=torch.cuda.current_device())
|
||||
self._sdxl_pipe = pipe
|
||||
return pipe
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -823,7 +823,7 @@ class TwoStageZImagePipeline:
|
||||
torch_dtype=self._face_stage_dtype(),
|
||||
**kwargs,
|
||||
).to(self.device)
|
||||
model.eval()
|
||||
model.train(False)
|
||||
self._sam_model = model
|
||||
self._sam_processor = processor
|
||||
return model, processor
|
||||
|
||||
@@ -63,13 +63,13 @@ PROFILE_ADAPTIVE_POLISH = {
|
||||
|
||||
# Which profiles let ``--cpu-offload`` reach their GLOBAL stack. The face stage
|
||||
# honours the flag everywhere, because residency lives on the shared base, but the
|
||||
# global stack is each profile's own: only qwen-zimage streams it, through
|
||||
# ``_qwen_vram_config``. chroma-zimage and sdxl-zimage load theirs with a plain
|
||||
# ``.to(device)``, so on a card below the face-stage residency floor the flag has
|
||||
# nothing left to change and the run behaves as if it were absent. Data rather than
|
||||
# a subclass attribute, because the caller that must warn cannot import a profile
|
||||
# module without pulling in Diffusers.
|
||||
GLOBAL_OFFLOAD_PROFILES = frozenset({QWEN_ZIMAGE_PROFILE})
|
||||
# global stack is each profile's own: qwen-zimage streams it through
|
||||
# ``_qwen_vram_config`` and sdxl-zimage through Diffusers sequential offload.
|
||||
# chroma-zimage loads its stack with a plain ``.to(device)``, so on a card below the
|
||||
# face-stage residency floor the flag has nothing left to change there and the run
|
||||
# behaves as if it were absent. Data rather than a subclass attribute, because the
|
||||
# caller that must warn cannot import a profile module without pulling in Diffusers.
|
||||
GLOBAL_OFFLOAD_PROFILES = frozenset({QWEN_ZIMAGE_PROFILE, SDXL_ZIMAGE_PROFILE})
|
||||
|
||||
SDXL_LIGHTNING_MODEL_ID = "ByteDance/SDXL-Lightning"
|
||||
SDXL_LIGHTNING_PATTERN = "sdxl_lightning_4step_lora.safetensors"
|
||||
|
||||
@@ -175,9 +175,9 @@ class WatermarkRemover:
|
||||
return
|
||||
logger.warning(
|
||||
"--cpu-offload does not reach the global stack of the '%s' pipeline: only "
|
||||
"qwen-zimage streams its global model between CUDA calls. The face stage "
|
||||
"still honours the flag, so below the face-stage residency floor this run "
|
||||
"behaves as if --cpu-offload were absent.",
|
||||
"qwen-zimage and sdxl-zimage stream their global model between CUDA calls. "
|
||||
"The face stage still honours the flag, so below the face-stage residency "
|
||||
"floor this run behaves as if --cpu-offload were absent.",
|
||||
self.model_profile,
|
||||
)
|
||||
|
||||
|
||||
@@ -181,6 +181,7 @@ class TextMarkDetection:
|
||||
# ``footprint_mask`` reuses a threaded detection only when it matches the STRICT
|
||||
# level its own re-detect would have used -- see TextMarkEngine._strict_detection.
|
||||
provenance: bool = False
|
||||
template_asset: str | None = None
|
||||
|
||||
|
||||
def best_detection(*detections: TextMarkDetection) -> TextMarkDetection:
|
||||
@@ -665,7 +666,7 @@ class TextMarkEngine:
|
||||
def _verdict(self, scan: TextMarkScan, *, provenance: bool) -> TextMarkDetection:
|
||||
"""Apply the trust-level-dependent tail to a scan, as a fresh result object."""
|
||||
c = self.config
|
||||
det = TextMarkDetection(provenance=provenance)
|
||||
det = TextMarkDetection(provenance=provenance, template_asset=c.asset_name)
|
||||
if scan.loc is None or scan.box is None:
|
||||
return det
|
||||
det.region = scan.loc.bbox
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Combine independently gated text layouts and retain their mask geometry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import cv2
|
||||
|
||||
from remove_ai_watermarks._text_mark_engine import (
|
||||
BottomRightAnchoredEngine,
|
||||
TextMarkConfig,
|
||||
TextMarkDetection,
|
||||
TextMarkEngine,
|
||||
TextMarkLocation,
|
||||
best_detection,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from numpy.typing import NDArray
|
||||
|
||||
|
||||
class TextMarkVariantsMixin(TextMarkEngine):
|
||||
"""Add independently configured layouts to an existing text-mark engine."""
|
||||
|
||||
_variants: tuple[TextMarkEngine, ...] = ()
|
||||
config: TextMarkConfig
|
||||
|
||||
def detect_both(self, image: NDArray[Any] | None) -> tuple[TextMarkDetection, TextMarkDetection]:
|
||||
pairs = [super().detect_both(image), *(engine.detect_both(image) for engine in self._variants)]
|
||||
return best_detection(*(pair[0] for pair in pairs)), best_detection(*(pair[1] for pair in pairs))
|
||||
|
||||
def detect(self, image: NDArray[Any], *, provenance: bool = False) -> TextMarkDetection:
|
||||
return self.detect_both(image)[int(provenance)]
|
||||
|
||||
def variant_engine(self, detection: TextMarkDetection) -> TextMarkEngine:
|
||||
for engine in self._variants:
|
||||
if engine.config.asset_name == detection.template_asset:
|
||||
return engine
|
||||
return self
|
||||
|
||||
def footprint_mask(
|
||||
self,
|
||||
image: NDArray[Any] | None,
|
||||
*,
|
||||
force: bool = False,
|
||||
dilate: int | None = None,
|
||||
detection: TextMarkDetection | None = None,
|
||||
) -> NDArray[Any] | None:
|
||||
if image is None or image.size == 0 or force:
|
||||
return super().footprint_mask(image, force=force, dilate=dilate, detection=detection)
|
||||
det = detection if detection is not None else self.detect(image)
|
||||
engine = self.variant_engine(det)
|
||||
if engine is self:
|
||||
return super().footprint_mask(image, force=False, dilate=dilate, detection=det)
|
||||
return engine.footprint_mask(image, force=False, dilate=dilate, detection=det)
|
||||
|
||||
|
||||
class GlyphTextMarkEngine(TextMarkEngine):
|
||||
"""Use the accepted template's aligned strokes instead of a textured ROI."""
|
||||
|
||||
def footprint_mask(
|
||||
self,
|
||||
image: NDArray[Any] | None,
|
||||
*,
|
||||
force: bool = False,
|
||||
dilate: int | None = None,
|
||||
detection: TextMarkDetection | None = None,
|
||||
) -> NDArray[Any] | None:
|
||||
from remove_ai_watermarks._text_mark_engine import load_alpha_template
|
||||
|
||||
if force:
|
||||
return super().footprint_mask(image, force=True, dilate=dilate, detection=detection)
|
||||
if image is None or image.size == 0:
|
||||
return None
|
||||
det = detection if detection is not None else self.detect(image)
|
||||
alpha = load_alpha_template(self.config.asset_name)
|
||||
if alpha is None or not det.detected:
|
||||
return None
|
||||
return self._aligned_alpha_mask(
|
||||
image,
|
||||
det,
|
||||
alpha,
|
||||
alpha_floor=0.05,
|
||||
dilate=2 if dilate is None else max(0, dilate),
|
||||
)
|
||||
|
||||
|
||||
class BottomRightGlyphTextMarkEngine(GlyphTextMarkEngine, BottomRightAnchoredEngine):
|
||||
"""Glyph-mask variant whose accepted match must hug the bottom-right corner."""
|
||||
|
||||
_ANCHOR_MAX_RIGHT = 0.04
|
||||
_ANCHOR_MAX_BOTTOM = 0.04
|
||||
|
||||
def _ladder_best(
|
||||
self, image: NDArray[Any], loc: TextMarkLocation
|
||||
) -> tuple[float, tuple[int, int, int, int] | None]:
|
||||
"""Find the strongest match among placements that satisfy the anchor."""
|
||||
response = self._detect_response(image, loc)
|
||||
silhouette = self._glyph_silhouette()
|
||||
if response is None or silhouette is None:
|
||||
return 0.0, None
|
||||
frame_h, frame_w = image.shape[:2]
|
||||
anchor_base = min(frame_h, frame_w)
|
||||
base = self.scale_base(image)
|
||||
best_score = 0.0
|
||||
best_box: tuple[int, int, int, int] | None = None
|
||||
for scale in self.config.ladder:
|
||||
glyph_w = max(self.config.min_gw, int(self.config.alpha_width_frac * base * scale))
|
||||
glyph_h = max(4, int(self.config.alpha_height_frac * base * scale))
|
||||
if glyph_w >= response.shape[1] or glyph_h >= response.shape[0]:
|
||||
continue
|
||||
template = cv2.resize(silhouette, (glyph_w, glyph_h), interpolation=cv2.INTER_AREA)
|
||||
scores = cv2.matchTemplate(response, template, cv2.TM_CCOEFF_NORMED)
|
||||
min_x = max(0, round(frame_w - loc.x - glyph_w - self._ANCHOR_MAX_RIGHT * anchor_base))
|
||||
max_x = min(scores.shape[1] - 1, frame_w - loc.x - glyph_w)
|
||||
min_y = max(0, round(frame_h - loc.y - glyph_h - self._ANCHOR_MAX_BOTTOM * anchor_base))
|
||||
max_y = min(scores.shape[0] - 1, frame_h - loc.y - glyph_h)
|
||||
if min_x > max_x or min_y > max_y:
|
||||
continue
|
||||
anchored = scores[min_y : max_y + 1, min_x : max_x + 1]
|
||||
_, score, _, point = cv2.minMaxLoc(anchored)
|
||||
if score > best_score:
|
||||
x, y = min_x + int(point[0]), min_y + int(point[1])
|
||||
best_score = float(score)
|
||||
best_box = (x, y, x + glyph_w - 1, y + glyph_h - 1)
|
||||
return best_score, best_box
|
||||
|
||||
|
||||
def _box_footprint(
|
||||
engine: GlyphTextMarkEngine,
|
||||
image: NDArray[Any] | None,
|
||||
*,
|
||||
force: bool,
|
||||
dilate: int | None,
|
||||
detection: TextMarkDetection | None,
|
||||
pad_left_height: float,
|
||||
pad_right_height: float,
|
||||
) -> NDArray[Any] | None:
|
||||
"""Mask the full winning text rectangle for outlined or stacked layouts."""
|
||||
if image is None or image.size == 0 or force:
|
||||
return GlyphTextMarkEngine.footprint_mask(
|
||||
engine,
|
||||
image,
|
||||
force=force,
|
||||
dilate=dilate,
|
||||
detection=detection,
|
||||
)
|
||||
det = detection if detection is not None else engine.detect(image)
|
||||
if not det.detected or det.match_box is None:
|
||||
return None
|
||||
from remove_ai_watermarks import region_eraser
|
||||
|
||||
loc = engine.locate(image)
|
||||
x0, y0, x1, y1 = det.match_box
|
||||
extra_left = round((y1 - y0 + 1) * pad_left_height)
|
||||
extra_right = round((y1 - y0 + 1) * pad_right_height)
|
||||
pad = 2 if dilate is None else max(0, dilate)
|
||||
return region_eraser.boxes_to_mask(
|
||||
image.shape[:2],
|
||||
[
|
||||
(
|
||||
loc.x + x0 - extra_left,
|
||||
loc.y + y0,
|
||||
x1 - x0 + 1 + extra_left + extra_right,
|
||||
y1 - y0 + 1,
|
||||
)
|
||||
],
|
||||
dilate=pad,
|
||||
)
|
||||
|
||||
|
||||
class BoxTextMarkEngine(GlyphTextMarkEngine):
|
||||
"""Text detector with a solid detector-aligned removal footprint."""
|
||||
|
||||
_BOX_PAD_LEFT_HEIGHT = 0.0
|
||||
_BOX_PAD_RIGHT_HEIGHT = 0.0
|
||||
|
||||
def footprint_mask(
|
||||
self,
|
||||
image: NDArray[Any] | None,
|
||||
*,
|
||||
force: bool = False,
|
||||
dilate: int | None = None,
|
||||
detection: TextMarkDetection | None = None,
|
||||
) -> NDArray[Any] | None:
|
||||
return _box_footprint(
|
||||
self,
|
||||
image,
|
||||
force=force,
|
||||
dilate=dilate,
|
||||
detection=detection,
|
||||
pad_left_height=self._BOX_PAD_LEFT_HEIGHT,
|
||||
pad_right_height=self._BOX_PAD_RIGHT_HEIGHT,
|
||||
)
|
||||
|
||||
|
||||
class BottomRightBoxTextMarkEngine(BottomRightGlyphTextMarkEngine):
|
||||
"""Anchored text detector with a solid detector-aligned footprint."""
|
||||
|
||||
_BOX_PAD_LEFT_HEIGHT = 0.0
|
||||
_BOX_PAD_RIGHT_HEIGHT = 0.0
|
||||
|
||||
def footprint_mask(
|
||||
self,
|
||||
image: NDArray[Any] | None,
|
||||
*,
|
||||
force: bool = False,
|
||||
dilate: int | None = None,
|
||||
detection: TextMarkDetection | None = None,
|
||||
) -> NDArray[Any] | None:
|
||||
return _box_footprint(
|
||||
self,
|
||||
image,
|
||||
force=force,
|
||||
dilate=dilate,
|
||||
detection=detection,
|
||||
pad_left_height=self._BOX_PAD_LEFT_HEIGHT,
|
||||
pad_right_height=self._BOX_PAD_RIGHT_HEIGHT,
|
||||
)
|
||||
@@ -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 |
@@ -467,7 +467,7 @@ def _load_runtime(device: Any, backend: ClassifierBackend = "torch") -> _Runtime
|
||||
probe = np.load(_weight_path(folder, PROBE_FILE))
|
||||
detector = _build_detector()
|
||||
detector.load_state_dict(torch.load(_weight_path(folder, DETECTOR_FILE), map_location="cpu", weights_only=True))
|
||||
detector.to(device).eval()
|
||||
detector.to(device).train(False)
|
||||
packed = torch.load(_weight_path(folder, PROVIDER_FILE), map_location="cpu", weights_only=True)
|
||||
heads = {}
|
||||
for name in PROVIDER_LABELS:
|
||||
@@ -476,7 +476,7 @@ def _load_runtime(device: Any, backend: ClassifierBackend = "torch") -> _Runtime
|
||||
continue
|
||||
head = _build_provider_head()
|
||||
head.load_state_dict(packed[name])
|
||||
heads[name] = head.to(device).eval()
|
||||
heads[name] = head.to(device).train(False)
|
||||
ridge_threshold = float(probe["thr_oi_1pct"])
|
||||
mlp_threshold, provider_margin = _operating_point(folder)
|
||||
log.info("loaded photo-classify freeze from %s", folder)
|
||||
|
||||
@@ -308,8 +308,9 @@ _cpu_offload_option = click.option(
|
||||
"Offload model components to CPU between CUDA calls instead of keeping the "
|
||||
"whole pipeline in VRAM, at the cost of speed. Forces the face stack to "
|
||||
"offload instead of using automatic residency, on every profile. It reaches "
|
||||
"the global stack of qwen-zimage only: chroma-zimage and sdxl-zimage keep "
|
||||
"theirs resident either way."
|
||||
"the global stack of qwen-zimage and sdxl-zimage, which also stream theirs "
|
||||
"automatically on a card too small to hold it; chroma-zimage keeps its "
|
||||
"global stack resident either way."
|
||||
),
|
||||
)
|
||||
|
||||
@@ -914,6 +915,11 @@ def cmd_invisible(
|
||||
source = _validate_image(source)
|
||||
if output is None:
|
||||
output = source.with_stem(source.stem + "_clean")
|
||||
# The scrub gate reads an undecodable file as "no target", which would exit 2
|
||||
# (nothing to remove) for a file this command never looked at.
|
||||
if image_io.imread(source) is None:
|
||||
console.print(f"Error: Failed to read image: {source}")
|
||||
raise SystemExit(1)
|
||||
|
||||
# An explicit --vendor wins over detection (see the option help) and implies the
|
||||
# scrub runs: naming the cohort asserts the pixel watermark is present, so the
|
||||
@@ -1905,5 +1911,19 @@ def cmd_batch(
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
# ── MCP server ──
|
||||
@main.command("mcp")
|
||||
def cmd_mcp() -> None:
|
||||
"""Run a Model Context Protocol server on stdin/stdout.
|
||||
|
||||
Exposes identify, remove and batch as MCP tools for agent clients. Each tool
|
||||
runs one command of this CLI, so results and exit codes match it. Start it as
|
||||
`uvx remove-ai-watermarks mcp` from the client's server configuration.
|
||||
"""
|
||||
from remove_ai_watermarks.mcp_server import serve
|
||||
|
||||
serve()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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,
|
||||
|
||||