Compare commits

..
8 Commits
Author SHA1 Message Date
Victor Kuznetsov b1319b1d5a Release 0.26.3 2026-08-09 21:15:15 -07:00
Victor Kuznetsov 353bc5f12c Harden TrustMark detection with an official fixture 2026-08-09 21:08:09 -07:00
Victor KuznetsovandClaude Opus 5 370556ffe6 Ignore other harnesses' generated config before it appears
These directories are empty today, so git shows nothing and the gap is
invisible. The moment the sync writes into one, a routine running
git add -A commits another harness's generated config, and the harness
audit then fails after the fact rather than before it. Paths are the
canonical untrackedConsumerPaths list, anchored to the repo root so a
same-named source file deeper in the tree is not caught.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 17:04:05 -07:00
Victor KuznetsovandClaude Opus 5 14a27d224d Drop the settings.json that only repeats global defaults
All four plugins it enabled are enabled globally, and its allow list says
nothing under the global defaultMode: bypassPermissions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 16:51:37 -07:00
Victor Kuznetsov 015df40f49 Release 0.26.2 and document distribution surfaces 2026-08-09 00:27:10 -07:00
Victor Kuznetsov f9beef365f Remove conda distribution support 2026-08-08 21:18:10 -07:00
Victor Kuznetsov 9b656513f1 Improve C2PA attribution and ByteDance metadata handling 2026-08-08 19:34:40 -07:00
github-actions[bot] ab03ecc507 Sync conda recipe with v0.26.1 2026-08-07 01:21:28 +00:00
31 changed files with 540 additions and 274 deletions

No files matched your search

-14
View File
@@ -1,14 +0,0 @@
{
"permissions": {
"allow": [
"WebSearch",
"WebFetch"
]
},
"enabledPlugins": {
"pyright-lsp@claude-plugins-official": true,
"context7@claude-plugins-official": true,
"code-simplifier@claude-plugins-official": true,
"claude-md-management@claude-plugins-official": true
}
}
+2 -31
View File
@@ -1,9 +1,8 @@
name: Distribute on release
# Fans a published GitHub Release out to the channels that need a nudge.
# PyPI is handled by publish.yml; the conda-forge channel is handled by its
# autotick bot. This workflow updates the repository recipe and event-drives
# the three channels that would otherwise be manual:
# PyPI is handled by publish.yml. This workflow event-drives the three channels
# that would otherwise be manual:
# - Homebrew tap: rewrite the formula's url + sha256 to the new sdist.
# - HF Space: factory-rebuild so it reinstalls the latest sdist from PyPI.
# - ComfyUI: sync, test, and publish the node against the exact library release.
@@ -89,34 +88,6 @@ jobs:
git commit -m "remove-ai-watermarks $VERSION" || { echo "Formula already current"; exit 0; }
git push
conda-recipe:
needs: resolve
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout main
uses: actions/checkout@v7
with:
ref: main
- name: Bump the repository conda recipe
env:
SHA: ${{ needs.resolve.outputs.sha }}
VERSION: ${{ needs.resolve.outputs.version }}
run: |
set -euo pipefail
python scripts/sync_conda_recipe.py --version "$VERSION" --sha256 "$SHA"
git diff --check
if git diff --quiet -- packaging/conda/recipe.yaml; then
echo "Conda recipe already current"
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add packaging/conda/recipe.yaml
git commit -m "Sync conda recipe with v$VERSION"
git push
hf-space:
needs: resolve
runs-on: ubuntu-latest
+9
View File
@@ -48,3 +48,12 @@ gfpgan/
# Local evaluation data
.local-eval/
# Other harnesses' generated config. Claude's sync owns these; a committed copy
# is a second source nobody reconciles. Anchored so a same-named source file
# deeper in the tree is not caught.
/.agents/
/.codex/
/.opencode/
/opencode.json
/opencode.jsonc
+1 -1
View File
@@ -33,7 +33,7 @@ Optional features and installation groups are documented in [`docs/installation.
Command, gate, typing, and model-test invariants auto-load from [`.claude/rules/development.md`](.claude/rules/development.md). Environment recovery, CI behavior, and fixture policy live in [`docs/development.md`](docs/development.md).
Before a release, read [`docs/release-and-distribution.md`](docs/release-and-distribution.md). Keep the source-distribution public allowlist.
Before a release, read [`docs/release-and-distribution.md`](docs/release-and-distribution.md). Treat the release as complete only after PyPI, Homebrew, the Hugging Face Space, and the ComfyUI Registry are verified; conda is not published. Keep the source-distribution public allowlist.
## Module architecture
+4 -1
View File
@@ -6,6 +6,7 @@ Tracked data is organized by purpose:
data/
fixtures/
provenance/ Real format and provenance fixtures used by tests
(source records live in fixtures/README.md)
calibration/
<vendor>/ Minimal controlled inputs needed to rebuild detector assets
synthid/
@@ -31,7 +32,9 @@ data/
commands, hashes, and oracle verdicts instead of committing another corpus
copy. A small curated before-and-after example may live in `docs/images/`
when it is part of the public documentation.
6. Runtime detector assets belong in `src/remove_ai_watermarks/assets/`.
6. Keep third-party fixture license notices in `licenses/`, outside directories
that tests enumerate as media inputs.
7. Runtime detector assets belong in `src/remove_ai_watermarks/assets/`.
Unregistered research candidates belong in
`scripts/assets/visible-mark-candidates/` so they are not shipped in the
wheel.
+15
View File
@@ -0,0 +1,15 @@
# Provenance fixtures
These files exercise real container, metadata, and pixel-provenance formats.
They are test inputs, not evaluation corpora.
## Adobe TrustMark
`adobe-trustmark-p.png` is Adobe's official `images/ghost_P.png` Variant P
example from [`adobe/trustmark`](https://github.com/adobe/trustmark) at commit
`0ed40cbe8188f664fd9cbbeacd969807de27440a`.
- Source: `https://raw.githubusercontent.com/adobe/trustmark/0ed40cbe8188f664fd9cbbeacd969807de27440a/images/ghost_P.png`
- SHA-256: `e58c5825ed7e5d9fb04710ea541b61bd55879cad65554c0d46260aa24b3d0755`
- Expected signal: TrustMark Variant P, schema 1
- License: MIT, reproduced in `../licenses/adobe-trustmark-MIT.txt`
Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

+25
View File
@@ -0,0 +1,25 @@
Copyright 2023 Adobe
All Rights Reserved.
NOTICE: Adobe permits you to use, modify, and distribute this file in
accordance with the terms of the license agreement accompanying it.
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+7
View File
@@ -7,3 +7,10 @@ This page records notices required by source dependencies and licensed derivativ
- The DWT-DCT implementation derives from ShieldMnt's
[`invisible-watermark`](https://github.com/ShieldMnt/invisible-watermark), licensed
under MIT. Its notice ships in `src/remove_ai_watermarks/licenses/invisible-watermark-MIT.txt`.
## Licensed test fixtures
- `data/fixtures/provenance/adobe-trustmark-p.png` is Adobe's official
TrustMark Variant P example, licensed under MIT. Its source commit, digest,
and reproduced license are recorded beside the fixture in
`data/fixtures/README.md` and `data/licenses/adobe-trustmark-MIT.txt`.
+1 -1
View File
@@ -22,7 +22,7 @@ to run the tool. Use the maintainer references only when changing the code.
| [Development](development.md) | Environment setup, dependency recovery, CI behavior, and fixture policy. |
| [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, and release workflow. |
| [Release and distribution](release-and-distribution.md) | PyPI, Homebrew, Hugging Face Space, ComfyUI Registry, and the release workflow. |
| [Watermarking landscape](watermarking-landscape.md) | Vendor signals and detection approaches. |
| [SynthID technical reference](synthid.md) | Mechanism, detector access, robustness, and implications for this project. |
+5 -2
View File
@@ -344,8 +344,11 @@ Diffusion, SDXL, and FLUX workflows. That decoder is sensitive to the carrier
and transformations. A negative result is not a universal negative.
The `trustmark` extra adds Adobe TrustMark decoding. The implementation retains
an additional JPEG re-encode gate because isolated decoder hits can otherwise
be content noise.
an additional JPEG re-encode gate and requires the binary payload and schema to
remain identical because isolated decoder hits can otherwise be content noise.
It accepts Variant P schemas 0-2. Variant Q requires a different model, and
schema 3 is rejected at the measured precision threshold. The calibration
history is in [module internals](module-internals.md#metadata-and-provenance).
External AI versus real image classifiers are out of scope. The project
identifies concrete local provenance signals instead of shipping a generic
+17 -1
View File
@@ -367,6 +367,11 @@ for partial and synthetic fixtures that the official reader rejects.
Vendor attribution comes from the registry in
[`_internal/constants.py`](../src/remove_ai_watermarks/_internal/constants.py). Derived
issuer and platform maps should not be maintained separately.
For an AI C2PA claim, a recognized product in `claim_generator` takes precedence
over the certificate issuer: an application can sign through an upstream model
provider without becoming that provider's product. Only exact product mappings
receive this precedence; an unknown claim generator still falls back to issuer
attribution.
### Metadata scanning and stripping
@@ -387,6 +392,11 @@ Key contracts:
"the credentials could not be read", and the second silently downgrades a verdict.
- JPEG stripping walks metadata segments and preserves the entropy-coded image
scan.
- Exact app-export JSON disclosures in EXIF `ImageDescription` or `UserComment`
share one parser. Known AI-product provenance is removable without by itself
asserting that the pixels were generated; explicit `aigc_info` discriminator
values and Dreamina `exportType=generation` do assert AI origin. Ordinary
Aweme, retouch, and `lv` editor exports are preserved.
- ISOBMFF containers use
[`_internal/isobmff.py`](../src/remove_ai_watermarks/_internal/isobmff.py).
- Native MP4/MOV TC260 `AIGC` entries are read from
@@ -554,7 +564,13 @@ human-made verdict. `ai_source_kind` distinguishes fully generated content from
AI-enhanced composites when the source metadata provides that distinction.
TrustMark is reported as a watermark signal but does not by itself assert AI
origin because it can also protect human-authored content.
origin because it can also protect human-authored content. The decoder requests
binary mode, matching Adobe's Durable Content Credentials example, then requires
the same payload and schema after a quality-95 JPEG round-trip. Only Variant P
schemas 0-2 count as positives. Schema 3 is below the precision threshold: all
38 measured historical false-positive candidates used it, and six retained the
same false payload after re-encoding. The official Adobe Variant P schema-1
fixture in `data/fixtures/provenance/` is the positive regression control.
Regression coverage:
+17 -16
View File
@@ -3,6 +3,21 @@
This page describes the release behavior defined in this repository. External
registry state can change independently, so verify it during a release.
## Published surfaces
A release is complete only after all four published surfaces are verified:
| Surface | Published result | Automation |
|---|---|---|
| PyPI | The `remove-ai-watermarks` wheel and source distribution | `publish.yml` |
| Homebrew | The `remove-ai-watermarks` formula in `wiltodelta/homebrew-tap` | `distribute.yml` |
| Hugging Face Space | A factory rebuild of `wiltodelta/remove-ai-watermarks` against the new PyPI release | `distribute.yml` |
| ComfyUI Registry | A compatible release of `wiltodelta/ComfyUI-remove-ai-watermarks` with its own node version | `distribute.yml` and the node repository's workflows |
The GitHub Release is the trigger and release record for this flow. Conda is
not a supported publishing surface: this repository has no conda recipe or
conda publication job.
## Release sources of truth
The package version appears in:
@@ -43,25 +58,15 @@ PyPI API token from the repository.
waits for the matching source distribution to appear on PyPI, then:
- updates the Homebrew tap formula URL and SHA-256;
- updates the repository conda recipe version and source-distribution SHA-256;
- triggers a factory rebuild of the Hugging Face Space.
- triggers a factory rebuild of the Hugging Face Space;
- synchronizes, tests, versions, and publishes the ComfyUI nodes.
The workflow can also be started manually with an optional version input.
Conda-forge updates are outside this workflow.
If a distribution job fails because a repository or Hugging Face credential is
invalid, rotate the corresponding GitHub secret and rerun the failed job. A
manual Homebrew formula update is the fallback when its automation is blocked.
The conda job uses the published artifact rather than a locally built archive
as the hash source and commits the resulting recipe change to `main`. Runtime
dependency mapping remains review-controlled: keep it aligned with the default
metadata dependencies in `pyproject.toml`, do not copy optional pixel extras
into the default recipe, and document any conda-forge package that is
unavailable and must be omitted.
The optional `video` extra carries PyAV with Python-version-specific bounds; it
does not belong in the default metadata-focused conda recipe.
## Source distribution boundary
The wheel includes the package under `src/`.
@@ -83,8 +88,6 @@ The package uses hatchling through the unpinned `hatchling` build requirement in
## Other channels
The repository includes a conda recipe under `packaging/conda/recipe.yaml`.
The ComfyUI nodes are maintained and versioned in their own repository. After
the matching source distribution appears on PyPI, `distribute.yml` dispatches
that repository's sync workflow with the exact library version and waits for it
@@ -112,7 +115,5 @@ After publication, verify:
- the package version matches the tag;
- the Homebrew formula points to the new source distribution;
- the distribution workflow completed successfully;
- the repository's conda recipe matches the published version and source
distribution;
- the ComfyUI Registry node requires the new library version;
- a clean install can run `remove-ai-watermarks --version`.
+8 -3
View File
@@ -67,6 +67,9 @@ The inspection and stripping code handles signals in these groups:
- C2PA Content Credentials and supported cloud manifest references;
- EXIF and XMP generator fields;
- exact app-export provenance and AIGC disclosures from supported
ByteDance-family products, with product-only provenance excluded from the
generated-image verdict;
- IPTC AI disclosure fields;
- PNG text chunks and embedded generation parameters;
- China TC260 AIGC labels in supported image placements and the normative
@@ -77,7 +80,9 @@ The inspection and stripping code handles signals in these groups:
- Samsung AI editing markers;
- Hugging Face job metadata;
- open Stable Diffusion style DWT-DCT watermarks with the `detect` extra;
- Adobe TrustMark with the `trustmark` extra.
- Adobe TrustMark Variant P schemas 0-2 with the `trustmark` extra. Variant Q
needs a different model, while schema 3 is deliberately rejected because it
produced persistent false positives on unrelated generators.
`identify` combines detected signals into a `ProvenanceReport`. It reports
unknown when evidence is absent. It never treats missing metadata as proof that
@@ -151,9 +156,9 @@ not a universal clean verdict.
| OpenAI image generators | None registered | Diffusion regeneration for supported invisible signals | C2PA and generator provenance |
| Stable Diffusion and SDXL | None registered | Diffusion regeneration; optional open decoder | Embedded parameters and text metadata |
| FLUX | None registered | Diffusion regeneration; optional open decoder | C2PA for supported sources |
| Adobe Firefly | None registered | No proprietary local decoder | C2PA; optional TrustMark decoder |
| Adobe Firefly | None registered | Optional TrustMark Variant P decoder | C2PA |
| Midjourney | None registered | No registered pixel decoder | EXIF, XMP, and IPTC signals |
| ByteDance generators | Doubao and Jimeng marks | No registered pixel decoder | TC260 AIGC and supported C2PA signals |
| 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 | Kling image and video marks | No registered pixel decoder | TC260 AIGC |
| Hailuo / MiniMax video | Hailuo composite video label | No registered pixel decoder | TC260 AIGC where present |
+7
View File
@@ -22,8 +22,15 @@ icon-only variants need separate calibrated silhouettes.
**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)").
Some applications sign C2PA through an upstream model or infrastructure
provider. For AI claims, exact product mappings in `claim_generator` therefore
take precedence over issuer attribution. Supported mappings include Higgsfield
AI, Topaz Labs Image API, and TikTok Ad Creative Toolbox; an unknown claim
generator still falls back to the certificate issuer.
**ByteDance Volcano Engine (Volcengine)** — the cloud behind Doubao / Jimeng — signs its AI image output with a cert from `certificate_center@volcengine.com` + `trainedAlgorithmicMedia` (issuer `b"volcengine"` → "ByteDance (Volcano Engine)", platform "ByteDance (Doubao / Jimeng / Volcano Engine)"); note this is the C2PA-signed surface, distinct from the XMP/PNG TC260 `AIGC` label Doubao also uses. ByteDance's **international brand (BytePlus / Seedream / Seededit)** signs the same content as **"Byteplus Pte. Ltd."**. The bare `volcengine` needle missed it, so BytePlus output was mis-attributed to "Adobe Firefly" through an incidental "Adobe XMP" toolkit string. Issuer `b"Byteplus"` now maps directly to "BytePlus (ByteDance)". ByteDance's consumer app **Dreamina** (the international Jimeng brand) signs as **"Bytedance Pte. Ltd."** with a `Dreamina/x.y` claim generator but, unlike the Volcano Engine surface, ships **no `trainedAlgorithmicMedia`**. Issuer `b"Dreamina"` maps to "ByteDance (Dreamina)" with **`asserts_ai=True`**. Registering the broader **issuer** `b"Bytedance Pte"` was deliberately avoided because that same entity also signs non-AI CapCut edits; keying on the `Dreamina` generator token is precise.
- **EXIF/XMP/PNG-text generator tag (caught by `exif_generator`):** **Ideogram** writes EXIF `Make="Ideogram AI"` (collected 2026-05-24 — no C2PA, no SynthID, no imwatermark; the Make tag is the only signal). Additional verified generator stamps include **NovelAI** (`Software`, `Source`, and `Title` PNG text chunks), **Reve** (`Software` or XMP `CreatorTool` = `reve.com`), and **Aphrodite AI** (`Make` or `Software` = `Aphrodite AI`).
- **App-export provenance and AIGC JSON:** supported ByteDance-family exports can place a JSON object in EXIF `ImageDescription` or `UserComment`, independently of C2PA or TC260. Exact `product` values for Doubao, Xinghui, and Dreamina are removable product provenance, but do not alone prove that the pixels were generated. Dreamina additionally requires `exportType=generation` for that verdict. A nested Aweme `aigc_type=1` or private ByteDance `aigc_label_type=1` / `2` is an AIGC disclosure; `0` is inconclusive and can occur on a Dreamina generation export. Plain Aweme, retouch, and `lv` exports are preserved. The lower-case private field is deliberately not interpreted as the normative TC260 `Label`, whose values `1` / `2` / `3` mean generated / possibly generated / suspected generated under [GB 45438-2025](https://www.tc260.org.cn/upload/2025-03-15/1742009439794081593.pdf).
- **xAI / Grok — its own EXIF signature scheme, NOT C2PA (DETECTED by `metadata.xai_signature`, built 2026-05-26).**
Grok JPEG downloads (Aurora model) carry **no C2PA, no XMP, no SynthID, no IPTC** — only EXIF `Artist` = a UUID and EXIF `ImageDescription` = `Signature: <base64>` (a crypto signature, unverifiable locally without xAI's public key). This empirically kills the earlier unverified "xAI signs C2PA as xAI" lead — xAI is not even a C2PA member. `exif_generator` misses it (neither field holds an `AI_GENERATOR_TOKENS` token), so a dedicated detector `xai_signature(path)` matches the pair (`ImageDescription ~ ^Signature: [A-Za-z0-9+/=]{64,}` AND UUID `Artist`); wired into `has_ai_metadata`, `get_ai_metadata` (key `xai_signature`), and `identify` (signal `xai_signature`, platform "xAI (Grok / Aurora)").
-62
View File
@@ -1,62 +0,0 @@
schema_version: 1
context:
version: "0.26.0"
python_min: "3.10"
package:
name: remove-ai-watermarks
version: ${{ version }}
source:
url: https://pypi.org/packages/source/r/remove-ai-watermarks/remove_ai_watermarks-${{ version }}.tar.gz
sha256: 0a8c8126d74cd7f818e5595eef2f269db5cefb8d90febd424f991c5850c8025b
build:
noarch: python
number: 0
script: python -m pip install . -vv --no-deps --no-build-isolation
requirements:
host:
- python ${{ python_min }}.*
- pip
- hatchling
run:
- python >=${{ python_min }}
- pillow >=10.0.0
- piexif >=1.1.3
- click >=8.0.0
- python-dotenv >=1.0.0
# c2pa-python is a default PyPI dependency but is not packaged on conda-forge.
# The guarded import falls back to the built-in C2PA byte scanner when it is
# absent. Add it here once a c2pa-python feedstock exists.
tests:
- python:
imports:
- remove_ai_watermarks
- remove_ai_watermarks.identify
- remove_ai_watermarks.metadata
python_version: ${{ python_min }}.*
# pip_check defaults to true; disable it explicitly. `pip check` fails on
# the conda-forge piexif `py_2` build, whose stale 2019 metadata pip reads
# as "piexif 1.1.3 is not supported on this platform" -- even though the
# package installs, imports, and works. The imports test verifies loading.
pip_check: false
about:
homepage: https://github.com/wiltodelta/remove-ai-watermarks
summary: Inspect and strip AI provenance metadata from media
description: |
Inspect and strip AI-provenance metadata (C2PA, EXIF, IPTC, and PNG text
chunks) from images and supported video containers. Optional pip extras add
visible watermark removal, video processing, SynthID diffusion removal, and
additional invisible-watermark detectors.
license: Apache-2.0
license_file: LICENSE
repository: https://github.com/wiltodelta/remove-ai-watermarks
extra:
recipe-maintainers:
- wiltodelta
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "remove-ai-watermarks"
version = "0.26.1"
version = "0.26.3"
description = "AI watermark remover for visible, invisible, and provenance marks in images and video"
readme = "README.md"
requires-python = ">=3.10.1"
-53
View File
@@ -1,53 +0,0 @@
"""Update the conda recipe to a published package version and sdist hash."""
from __future__ import annotations
import argparse
import logging
import re
from pathlib import Path
log = logging.getLogger(__name__)
_VERSION_LINE = re.compile(r'^ version: "[^"]+"$', re.MULTILINE)
_SHA256_LINE = re.compile(r"^ sha256: [0-9a-f]{64}$", re.MULTILINE)
def update_recipe(text: str, *, version: str, sha256: str) -> str:
"""Return recipe text with exactly one context version and source hash updated."""
if not version or any(char.isspace() for char in version):
raise ValueError("version must be a non-empty value without whitespace")
if re.fullmatch(r"[0-9a-f]{64}", sha256) is None:
raise ValueError("sha256 must be 64 lowercase hexadecimal characters")
updated, version_count = _VERSION_LINE.subn(f' version: "{version}"', text)
updated, sha_count = _SHA256_LINE.subn(f" sha256: {sha256}", updated)
if version_count != 1 or sha_count != 1:
raise ValueError(
"expected exactly one context version and one source sha256 "
f"(found version={version_count}, sha256={sha_count})"
)
return updated
def main() -> None:
"""Update the selected recipe in place."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--version", required=True)
parser.add_argument("--sha256", required=True)
parser.add_argument(
"--recipe",
type=Path,
default=Path("packaging/conda/recipe.yaml"),
)
args = parser.parse_args()
original = args.recipe.read_text()
updated = update_recipe(original, version=args.version, sha256=args.sha256)
args.recipe.write_text(updated)
log.info("Updated %s to version %s", args.recipe, args.version)
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
main()
+1 -1
View File
@@ -32,7 +32,7 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
_warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*")
__version__ = "0.26.1"
__version__ = "0.26.3"
__all__ = [
"BatchSummary",
@@ -103,6 +103,15 @@ C2PA_AI_VENDORS: tuple[C2paAiVendor, ...] = (
C2PA_ISSUERS = {vendor.issuer: vendor.org for vendor in C2PA_AI_VENDORS}
C2PA_IDENTITY_AI_ORGS = frozenset(vendor.org for vendor in C2PA_AI_VENDORS if vendor.asserts_ai)
# Product-specific claim generators can sign through a different upstream issuer.
# Keep this attribution beside the issuer registry so every C2PA consumer has one
# canonical source rather than maintaining a derived product map in identify.py.
C2PA_CLAIM_GENERATOR_PLATFORMS: tuple[tuple[str, str], ...] = (
("higgsfield ai", "Higgsfield AI"),
("topaz labs image api", "Topaz Labs"),
("tiktok ad creative toolbox", "TikTok Ad Creative Toolbox"),
)
C2PA_AI_TOOLS = {
token.encode(): label
for token, label in (
+33 -11
View File
@@ -37,6 +37,7 @@ from remove_ai_watermarks._internal.c2pa import (
from remove_ai_watermarks._internal.constants import (
C2PA_AI_TOOLS,
C2PA_AI_VENDORS,
C2PA_CLAIM_GENERATOR_PLATFORMS,
C2PA_IDENTITY_AI_ORGS,
C2PA_ISSUERS,
)
@@ -260,6 +261,7 @@ def _external_exif_generator(pairs: list[tuple[str, Any]], scan: bytes) -> str |
"title",
"description",
"creatortool",
"usercomment",
}
candidates = [
_external_text(value)
@@ -448,10 +450,11 @@ class ProvenanceReport:
ai_source_kind: str | None = None
# True when the AI verdict rests on a metadata or embedded-invisible signal
# (C2PA AI issuer / SynthID provenance, IPTC, AIGC, local gen params, EXIF/xAI, or
# an open DWT-DCT / TrustMark decode) -- as opposed to a visible mark or a
# weak medium-confidence hint (hf-job, Samsung genAIType). It is exactly the
# set of signals an invisible/diffusion scrub targets: a visible-only or
# no-signal image has it False. Equivalent to ``confidence == "high"``;
# an open DWT-DCT decode) -- as opposed to a visible mark, provenance-only
# TrustMark, or a weak medium-confidence hint (hf-job, Samsung genAIType). This
# is exactly the set of signals an invisible/diffusion scrub targets: a
# visible-only or no-signal image has it False. Equivalent to
# ``confidence == "high"``;
# surfaced as a field so callers gate on intent, not on the string.
ai_from_metadata: bool = False
watermarks: list[str] = field(default_factory=list[str])
@@ -691,6 +694,14 @@ def _attribute_platform(issuers: list[str], *, is_ai: bool = True) -> str | None
return None
def _claim_generator_platform(generator: str | None) -> str | None:
"""Resolve a distinctive C2PA claim generator to its user-facing product."""
if not generator:
return None
lowered = generator.lower()
return next((platform for token, platform in C2PA_CLAIM_GENERATOR_PLATFORMS if token in lowered), None)
# Coarse origin-vendor normalization for integrity-clash detection. Two signals
# that resolve to the SAME key are consistent (a C2PA "Google (Gemini)" issuer
# and Google SynthID provenance, or Adobe Firefly + its Adobe TrustMark soft
@@ -1103,7 +1114,16 @@ def _identify_from_evidence(
# ASUS Gallery), with the issuer byte-scan only as fallback. The issuer scan
# alone mis-attributed real samples (Leica->Truepic timestamp authority,
# Nikon->Adobe namespace, Pixel->Google Gemini) -- the token scans fix that.
platform = (camera_label or signer_label or _attribute_platform(issuers, is_ai=c2pa_is_ai)) if has_c2pa else None
platform = (
(
camera_label
or signer_label
or (_claim_generator_platform(generator) if c2pa_is_ai else None)
or _attribute_platform(issuers, is_ai=c2pa_is_ai)
)
if has_c2pa
else None
)
if has_c2pa:
detail = ", ".join(filter(None, [", ".join(issuers), generator, info.get("source_type")]))
signals.append(Signal("c2pa", detail or "C2PA manifest present", "high"))
@@ -1412,13 +1432,15 @@ def has_invisible_target(image_path: Path) -> bool:
"""True when a locally-detectable invisible/metadata AI signal is present.
The decision gate for the diffusion scrub (``invisible`` / ``all`` / ``batch``):
regenerating pixels removes an invisible watermark (SynthID, open DWT-DCT,
TrustMark) but degrades a real photo, so it must not run when there is nothing
to remove. Runs :func:`identify` with ``check_visible=False`` -- a visible mark
is handled by the separate visible pass and is NOT a diffusion target -- and
``check_invisible=True`` so an open watermark counts. Returns
regenerating pixels removes an AI-specific invisible watermark (SynthID,
open DWT-DCT) but degrades a real photo, so it must not run when there is
nothing to remove. Runs :func:`identify` with ``check_visible=False`` -- a
visible mark is handled by the separate visible pass and is NOT a diffusion
target -- and ``check_invisible=True`` so an open watermark counts. Returns
``report.ai_from_metadata`` (C2PA AI issuer / SynthID provenance, IPTC, AIGC, local
gen params, EXIF/xAI, open DWT-DCT / TrustMark).
gen params, EXIF/xAI, or open DWT-DCT). TrustMark alone does not trigger the
scrub because it also protects human-authored work and therefore is not an AI
signal by itself.
IMPORTANT -- this cannot prove a pixel SynthID is absent: SynthID is detectable
only through its C2PA proxy, so a metadata-stripped AI image reads as no signal
+93 -3
View File
@@ -238,7 +238,8 @@ def _is_ai_value(value: str) -> bool:
from remove_ai_watermarks._internal.constants import AI_GENERATOR_TOKENS
value_lower = value.lower()
return any(token in value_lower for token in AI_GENERATOR_TOKENS)
provenance, generator = _app_metadata_evidence(value)
return provenance is not None or generator is not None or any(token in value_lower for token in AI_GENERATOR_TOKENS)
def _png_late_metadata(image_path: Path, window: int) -> bytes:
@@ -477,6 +478,9 @@ def has_ai_metadata(image_path: Path) -> bool:
for key in img.info:
if isinstance(key, str) and _is_ai_key(key):
return True
exif_bytes = img.info.get("exif")
if exif_bytes and any(_app_metadata_evidence(exif_bytes)):
return True
except Exception as exc:
logger.debug("PIL could not open %s for metadata scan: %s", image_path, exc)
@@ -500,6 +504,8 @@ def has_ai_metadata(image_path: Path) -> bool:
# IPTC 2025.1 AI-disclosure XMP properties (their presence flags AI content).
if any(marker in data for marker in IPTC_AI_FIELD_MARKERS):
return True
if any(_app_metadata_evidence(data)):
return True
# China TC260 AIGC label as a PNG text chunk (the byte scan above catches
# only the XMP form; the raw-JSON tEXt chunk needs the PIL-based parse).
if aigc_label(image_path) is not None:
@@ -830,16 +836,79 @@ def generator_from_metadata(candidates: Iterable[str], scan: bytes = b"") -> str
"""Return a known AI generator from collected EXIF, PNG, or XMP values."""
from remove_ai_watermarks._internal.constants import AI_GENERATOR_TOKENS
if app_generator := app_generator_from_metadata(scan):
return app_generator
creator_tools = (
match.group(1).decode("latin1", "replace")
for match in re.finditer(rb"CreatorTool[>\"'=\s]{1,4}([^<\"']{1,80})", scan)
)
for value in itertools.chain(candidates, creator_tools):
if app_generator := app_generator_from_metadata(value):
return app_generator
if any(token in value.lower() for token in AI_GENERATOR_TOKENS):
return value.strip()
return None
_APP_PROVENANCE_PRODUCTS: dict[str, str] = {
"doubao": "ByteDance Doubao",
"xinghui": "ByteDance Xinghui",
"dreamina": "ByteDance Dreamina",
"dreamina_oversea": "ByteDance Dreamina",
}
_APP_PRODUCT_RE = re.compile(r'"product"\s*:\s*"([a-z0-9_]+)"', re.IGNORECASE)
_APP_EXPORT_TYPE_RE = re.compile(r'"exportType"\s*:\s*"([a-z0-9_]+)"', re.IGNORECASE)
_APP_AIGC_LABEL_RE = re.compile(r'"aigc_label_type"\s*:\s*[12](?=\s*[,}])', re.IGNORECASE)
_APP_AIGC_TYPE_RE = re.compile(r'"aigc_type"\s*:\s*1(?=\s*[,}])', re.IGNORECASE)
def _normalized_app_metadata(value: str | bytes) -> str:
text = value.decode("latin-1", "ignore") if isinstance(value, bytes) else value
return text.replace('\\"', '"')
def _app_metadata_evidence(value: str | bytes) -> tuple[str | None, str | None]:
"""Return removable product provenance and stronger AI-origin evidence."""
normalized = _normalized_app_metadata(value)
products = tuple(match.group(1).lower() for match in _APP_PRODUCT_RE.finditer(normalized))
provenance = next(
(_APP_PROVENANCE_PRODUCTS[product] for product in products if product in _APP_PROVENANCE_PRODUCTS), None
)
export_types = {match.group(1).lower() for match in _APP_EXPORT_TYPE_RE.finditer(normalized)}
generator = None
if set(products).intersection({"dreamina", "dreamina_oversea"}) and "generation" in export_types:
generator = "ByteDance Dreamina"
elif '"aigc_info"' in normalized.lower():
if "aweme" in products and _APP_AIGC_TYPE_RE.search(normalized):
generator = "ByteDance Aweme AI"
elif _APP_AIGC_LABEL_RE.search(normalized):
generator = "Embedded app AIGC disclosure"
return provenance, generator
def app_provenance_from_metadata(value: str | bytes) -> str | None:
"""Return exact AI-product provenance that is safe to scrub.
An exporting product does not by itself prove that the pixels were generated.
That stronger interpretation stays in :func:`app_generator_from_metadata`.
"""
return _app_metadata_evidence(value)[0]
def app_generator_from_metadata(value: str | bytes) -> str | None:
"""Identify exact app-export AI disclosures embedded in JSON-shaped metadata.
ByteDance-family apps write a second provenance object beside C2PA or TC260.
Its keys are ordinary EXIF fields, so generic key matching misses it and a
metadata-preserving JPEG scrub keeps it. Only explicit AIGC discriminators or
a Dreamina generation export assert AI origin. Product provenance alone remains
removable without changing the image's origin verdict.
"""
return _app_metadata_evidence(value)[1]
def exif_generator(image_path: Path) -> str | None:
"""Return an AI-generator name from the EXIF ``Software`` / XMP ``CreatorTool``
field (or a PNG text chunk), if it matches a known generator (see
@@ -870,7 +939,9 @@ def exif_generator(image_path: Path) -> str | None:
if isinstance(value, str) and value:
candidates.append(value)
if exif_bytes:
tags = piexif.load(exif_bytes).get("0th", {})
loaded = piexif.load(exif_bytes)
tags = loaded.get("0th", {})
exif_tags: dict[int, Any] = loaded.get("Exif") or {}
# Make catches camera-style tags AI tools reuse (Ideogram writes
# Make="Ideogram AI"); real cameras put "Apple"/"Canon" there, which
# carry no AI token, so this stays low-false-positive.
@@ -883,6 +954,9 @@ def exif_generator(image_path: Path) -> str | None:
value = tags.get(tag)
if isinstance(value, bytes):
candidates.append(value.decode("latin1", "replace"))
user_comment = exif_tags.get(piexif.ExifIFD.UserComment)
if isinstance(user_comment, bytes):
candidates.append(user_comment.decode("latin1", "replace"))
except Exception as exc: # unopenable format / malformed EXIF
logger.debug("EXIF generator read failed for %s: %s", image_path, exc)
@@ -1009,6 +1083,15 @@ def _ai_exif_targets(loaded: dict[str, Any]) -> list[tuple[str, int, bytes, str]
add("0th", ifd0, piexif.ImageIFD.ImageDescription, "ImageDescription")
if _is_aigc_exif_value(ifde.get(piexif.ExifIFD.UserComment)):
add("Exif", ifde, piexif.ExifIFD.UserComment, "UserComment")
# (d) ByteDance-family app JSON. Exact AI-product provenance is removable even
# when it does not by itself assert generated pixels. Ordinary Aweme/retouch/lv
# exports remain untouched.
for ifd_key, ifd, tag, name in (
("0th", ifd0, piexif.ImageIFD.ImageDescription, "ImageDescription"),
("Exif", ifde, piexif.ExifIFD.UserComment, "UserComment"),
):
if any(_app_metadata_evidence(ifd.get(tag, b""))):
add(ifd_key, ifd, tag, name)
return targets
@@ -1087,6 +1170,13 @@ def get_ai_metadata(image_path: Path) -> dict[str, str]:
producer = aigc.get("ContentProducer", "")
result["aigc_label"] = f"China AIGC label (TC260){f'; producer {producer}' if producer else ''}"
app_scan = scan_head(image_path)
app_provenance, app_generator = _app_metadata_evidence(app_scan)
if app_provenance:
result.setdefault("app_provenance", f"App export provenance ({app_provenance})")
if app_generator:
result.setdefault("app_aigc", f"App AIGC disclosure ({app_generator})")
# xAI / Grok EXIF signature scheme (its only provenance signal).
if xai_signature(image_path):
result.setdefault("xai_signature", "xAI/Grok EXIF signature (Artist UUID + Signature blob)")
@@ -1181,7 +1271,7 @@ def _jpeg_app_carries_ai(marker: int, payload: bytes) -> bool:
# (detection<->removal parity). Skip APP1-EXIF (0xE1 ``Exif``): its camera tags are
# scrubbed tag-by-tag via piexif, not dropped wholesale.
if not (marker == 0xE1 and payload.startswith(b"Exif")):
return _is_aigc_exif_value(payload)
return _is_aigc_exif_value(payload) or any(_app_metadata_evidence(payload))
return False
+7 -2
View File
@@ -302,12 +302,14 @@ def _exif_pairs(info: dict[str, Any]) -> dict[str, str]:
try:
import piexif
tags = piexif.load(exif_bytes).get("0th", {})
loaded = piexif.load(exif_bytes)
tags = loaded.get("0th", {})
exif_tags = loaded.get("Exif", {})
except Exception as exc: # malformed EXIF
logger.debug("EXIF parse failed: %s", exc)
return {}
return {
pairs = {
name: text
for name, tag in (
("Software", piexif.ImageIFD.Software),
@@ -317,6 +319,9 @@ def _exif_pairs(info: dict[str, Any]) -> dict[str, str]:
)
if (text := exif_text(tags, tag))
}
if text := exif_text(exif_tags, piexif.ExifIFD.UserComment):
pairs["UserComment"] = text
return pairs
def _pil_info(info: dict[str, Any]) -> dict[str, str]:
+21 -12
View File
@@ -32,6 +32,9 @@ logger = logging.getLogger(__name__)
# Adobe ships Variant P in production (com.adobe.trustmark.P).
_MODEL_TYPE = "P"
# Schema 3 (BCH_3) is below the detector's measured precision threshold; the
# calibration history is canonical in docs/module-internals.md.
_SUPPORTED_SCHEMAS = frozenset({0, 1, 2})
# Lazily constructed singleton -- model load + first-use download is expensive.
# Guarded by a lock so concurrent callers don't double-construct/double-download.
_tm: Any = None
@@ -57,8 +60,9 @@ def _decoder() -> Any:
# JPEG quality for the false-positive durability gate (see detect_trustmark).
# Deliberately mild: a genuine TrustMark survives far harsher, while every
# observed false positive collapsed even at this quality.
# Deliberately mild: a genuine TrustMark survives far harsher. The round-trip
# still needs payload and schema validation because content-correlated false
# positives can survive this compression level.
_REENCODE_QUALITY = 95
@@ -79,8 +83,8 @@ def detect_trustmark(image_path: Path) -> str | None:
cannot carry Adobe's watermark, and decoded a random-bytes secret). A genuine
TrustMark is a *durable* soft binding engineered to survive re-encoding (that
is its entire purpose once C2PA is stripped), so we re-decode after a mild
JPEG round-trip and require the same schema both times. Every observed false
positive collapsed under this gate.
JPEG round-trip and require the same binary payload and schema both times.
Only the calibrated schemas 0-2 count as high-precision positives.
"""
if not is_available():
return None
@@ -90,10 +94,17 @@ def detect_trustmark(image_path: Path) -> str | None:
with Image.open(image_path) as img:
cover = img.convert("RGB")
decoder = _decoder()
_wm_secret, wm_present, wm_schema = decoder.decode(cover)
wm_secret, wm_present, wm_schema = decoder.decode(cover, "binary")
if not wm_present:
return None
if not _survives_reencode(decoder, cover, wm_schema):
if wm_schema not in _SUPPORTED_SCHEMAS:
logger.debug(
"TrustMark decode for %s used weak schema %s; treating as false positive",
image_path,
wm_schema,
)
return None
if not _survives_reencode(decoder, cover, wm_secret, wm_schema):
logger.debug("TrustMark decode for %s did not survive re-encode; treating as false positive", image_path)
return None
except Exception as exc: # model download / decode failure / unreadable image
@@ -102,10 +113,8 @@ def detect_trustmark(image_path: Path) -> str | None:
return f"Adobe TrustMark (variant {_MODEL_TYPE}, schema {wm_schema})"
def _survives_reencode(decoder: Any, cover: Any, schema: int) -> bool:
"""True if the watermark re-decodes with the same schema after a mild JPEG
round-trip -- the durability a genuine TrustMark guarantees, which a BCH
false positive (content noise) does not."""
def _survives_reencode(decoder: Any, cover: Any, payload: str, schema: int) -> bool:
"""True if the same watermark re-decodes after a mild JPEG round-trip."""
import io
from PIL import Image
@@ -114,5 +123,5 @@ def _survives_reencode(decoder: Any, cover: Any, schema: int) -> bool:
cover.save(buffer, "JPEG", quality=_REENCODE_QUALITY)
buffer.seek(0)
with Image.open(buffer) as reencoded:
_secret, present, reencoded_schema = decoder.decode(reencoded.convert("RGB"))
return bool(present) and reencoded_schema == schema
reencoded_payload, present, reencoded_schema = decoder.decode(reencoded.convert("RGB"), "binary")
return bool(present) and reencoded_schema == schema and reencoded_payload == payload
+10
View File
@@ -20,3 +20,13 @@ def test_sdist_has_explicit_public_boundary() -> None:
assert _array_values(sdist_config, "include") == {"/src", "/LICENSE", "/README.md", "/pyproject.toml"}
assert {"/data", "/tmp", "/.sc"} <= _array_values(sdist_config, "exclude")
def test_release_guide_names_every_published_surface() -> None:
"""A release checklist must not silently forget a downstream surface."""
root = Path(__file__).resolve().parents[1]
guide = (root / "docs" / "release-and-distribution.md").read_text(encoding="utf-8")
for surface in ("PyPI", "Homebrew", "Hugging Face Space", "ComfyUI Registry"):
assert surface in guide
assert re.search(r"Conda is\s+not a supported publishing surface", guide)
+32
View File
@@ -313,6 +313,38 @@ class TestIdentifyNonPng:
assert r.is_ai_generated is True
assert "ByteDance" in (r.platform or "")
@pytest.mark.parametrize(
("claim_generator", "platform"),
[
("Higgsfield AI", "Higgsfield AI"),
("Topaz Labs Image API", "Topaz Labs"),
("TIKTOK AD Creative Toolbox", "TikTok Ad Creative Toolbox"),
],
)
def test_claim_generator_wins_over_upstream_issuer(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, claim_generator: str, platform: str
):
path = tmp_path / "generated.png"
from PIL import Image
Image.new("RGB", (32, 32)).save(path)
monkeypatch.setattr(
"remove_ai_watermarks.identify.extract_c2pa_info",
lambda _path: {
"has_c2pa": True,
"issuer": "OpenAI",
"claim_generator": claim_generator,
"source_type": "trainedAlgorithmicMedia (AI-generated)",
"ai_source_kind": "generated",
},
)
report = identify(path, check_visible=False, check_invisible=False)
assert report.is_ai_generated is True
assert report.platform == platform
assert claim_generator in next(s.detail for s in report.signals if s.name == "c2pa")
def test_dreamina_attributed_without_source_type(self, tmp_path: Path):
# Dreamina (ByteDance's international Jimeng brand) signs C2PA as
# "Bytedance Pte. Ltd." with a "Dreamina/x.y" claim generator and NO
+153
View File
@@ -12,6 +12,7 @@ import pytest
from PIL import Image
from PIL.PngImagePlugin import PngInfo
from remove_ai_watermarks.identify import identify
from remove_ai_watermarks.metadata import (
C2PA_UUID,
_is_ai_key,
@@ -951,6 +952,158 @@ class TestXaiSignature:
class TestRemoveAiExif:
"""remove_ai_metadata scrubs AI-provenance EXIF tags but keeps genuine EXIF."""
@staticmethod
def _app_aigc_jpeg(tmp_path: Path, name: str, payload: dict) -> Path:
import json
exif = piexif.dump(
{
"0th": {
piexif.ImageIFD.ImageDescription: json.dumps(payload, separators=(",", ":")).encode(),
piexif.ImageIFD.Make: b"Canon",
},
"Exif": {},
"GPS": {},
"1st": {},
}
)
path = tmp_path / name
Image.new("RGB", (64, 64)).save(path, exif=exif)
return path
@pytest.mark.parametrize(
("payload", "generator"),
[
(
{
"data": {
"product": "dreamina_oversea",
"source_type": "dreamina_oversea",
"exportType": "generation",
}
},
"Dreamina",
),
(
{
"data": {
"product": "aweme",
"aigc_info": {
"aigc_type": 1,
"is_sticker_aigc": 0,
},
}
},
"Aweme",
),
({"data": {"aigc_info": {"aigc_label_type": 1}}}, "AIGC disclosure"),
({"data": {"aigc_info": {"aigc_label_type": 2}}}, "AIGC disclosure"),
],
)
def test_embedded_app_aigc_detected_and_stripped(self, tmp_path: Path, payload: dict, generator: str):
src = self._app_aigc_jpeg(tmp_path, "app-aigc.jpg", payload)
assert generator in (exif_generator(src) or "")
assert has_ai_metadata(src) is True
assert "app_aigc" in get_ai_metadata(src)
out = tmp_path / "clean.jpg"
_, remaining = strip_and_verify(src, out)
assert remaining == {}
assert exif_generator(out) is None
kept = piexif.load(Image.open(out).info["exif"])["0th"]
assert kept.get(piexif.ImageIFD.ImageDescription) is None
assert kept.get(piexif.ImageIFD.Make) == b"Canon"
@pytest.mark.parametrize(
"payload",
[
{"data": {"product": "aweme"}},
{"data": {"product": "retouch"}},
{"data": {"product": "lv", "exportType": "image_export"}},
{"data": {"aigc_info": {"aigc_label_type": 0}}},
{"data": {"aigc_info": {"aigc_label_type": 9}}},
{"data": {"aigc_info": {"aigc_label_type": 10}}},
{"data": {"product": "aweme", "aigc_info": {"aigc_type": 0}}},
{"data": {"product": "aweme", "aigc_info": {"aigc_type": 2}}},
{"data": {"product": "aweme", "aigc_info": {"aigc_type": 10}}},
],
)
def test_neighboring_app_metadata_is_preserved(self, tmp_path: Path, payload: dict):
src = self._app_aigc_jpeg(tmp_path, "ordinary-app.jpg", payload)
assert exif_generator(src) is None
assert has_ai_metadata(src) is False
assert get_ai_metadata(src) == {}
out = tmp_path / "kept.jpg"
remove_ai_metadata(src, out)
kept = piexif.load(Image.open(out).info["exif"])["0th"]
assert kept.get(piexif.ImageIFD.ImageDescription) is not None
assert kept.get(piexif.ImageIFD.Make) == b"Canon"
@pytest.mark.parametrize("product", ["doubao", "xinghui", "dreamina", "dreamina_oversea"])
def test_ai_product_provenance_is_stripped_without_asserting_generation(self, tmp_path: Path, product: str):
src = self._app_aigc_jpeg(tmp_path, "app-provenance.jpg", {"data": {"product": product}})
assert exif_generator(src) is None
assert has_ai_metadata(src) is True
assert "app_provenance" in get_ai_metadata(src)
assert identify(src, check_visible=False, check_invisible=False).is_ai_generated is None
out = tmp_path / "clean-provenance.jpg"
_, remaining = strip_and_verify(src, out)
assert remaining == {}
kept = piexif.load(Image.open(out).info["exif"])["0th"]
assert kept.get(piexif.ImageIFD.ImageDescription) is None
assert kept.get(piexif.ImageIFD.Make) == b"Canon"
@pytest.mark.parametrize("product", ["dreamina", "dreamina_oversea"])
def test_dreamina_requires_generation_export_for_ai_verdict(self, tmp_path: Path, product: str):
generated = self._app_aigc_jpeg(
tmp_path,
"dreamina-generation.jpg",
{"data": {"product": product, "exportType": "generation"}},
)
edited = self._app_aigc_jpeg(
tmp_path,
"dreamina-edit.jpg",
{"data": {"product": product, "exportType": "image_export"}},
)
assert "Dreamina" in (exif_generator(generated) or "")
assert identify(generated, check_visible=False, check_invisible=False).is_ai_generated is True
assert exif_generator(edited) is None
assert has_ai_metadata(edited) is True
def test_nested_app_aigc_user_comment_is_detected_and_stripped(self, tmp_path: Path):
import json
payload = {"data": {"aigc_info": json.dumps({"aigc_label_type": 2})}}
exif = piexif.dump(
{
"0th": {piexif.ImageIFD.Make: b"Canon"},
"Exif": {piexif.ExifIFD.UserComment: json.dumps(payload, separators=(",", ":")).encode()},
"GPS": {},
"1st": {},
}
)
src = tmp_path / "app-aigc-user-comment.jpg"
Image.new("RGB", (64, 64)).save(src, exif=exif)
assert has_ai_metadata(src) is True
out = tmp_path / "clean-user-comment.jpg"
_, remaining = strip_and_verify(src, out)
assert remaining == {}
kept = piexif.load(Image.open(out).info["exif"])
assert kept["Exif"].get(piexif.ExifIFD.UserComment) is None
assert kept["0th"].get(piexif.ImageIFD.Make) == b"Canon"
def test_grok_signature_stripped_on_jpeg_output(self, tmp_path: Path):
src = _grok_jpeg(tmp_path)
assert xai_signature(src) is True
+19
View File
@@ -89,6 +89,25 @@ class TestRecordReproducesTheFileVerdict:
class TestPlacementsTheRecordCouldDrop:
def test_app_aigc_user_comment_survives(self, tmp_path: Path):
"""The file and record paths must both inspect EXIF UserComment."""
import piexif
path = tmp_path / "app-aigc.jpg"
payload = json.dumps({"data": {"aigc_info": {"aigc_label_type": 1}}}, separators=(",", ":"))
exif = piexif.dump(
{
"0th": {piexif.ImageIFD.Make: b"Canon"},
"Exif": {piexif.ExifIFD.UserComment: payload.encode()},
"GPS": {},
"1st": {},
}
)
Image.fromarray(np.zeros((64, 64, 3), dtype=np.uint8)).save(path, "JPEG", exif=exif)
assert identify(path, check_visible=False, check_invisible=False).is_ai_generated is True
_assert_same_verdict(path)
def test_a_trailer_after_eoi_survives(self, tmp_path: Path):
"""Samsung Galaxy AI appends its marker past the JPEG EOI, and the value it is
gated on can sit further back still. A record that stopped at the last
-49
View File
@@ -1,49 +0,0 @@
"""Tests for the release-time conda recipe synchronizer."""
from __future__ import annotations
from pathlib import Path
import pytest
from scripts.sync_conda_recipe import update_recipe
_OLD_SHA = "a" * 64
_NEW_SHA = "b" * 64
_RECIPE = f"""\
schema_version: 1
context:
version: "1.2.3"
source:
sha256: {_OLD_SHA}
"""
def test_update_recipe_replaces_version_and_hash() -> None:
updated = update_recipe(_RECIPE, version="2.0.0", sha256=_NEW_SHA)
assert ' version: "2.0.0"' in updated
assert f" sha256: {_NEW_SHA}" in updated
assert "1.2.3" not in updated
assert _OLD_SHA not in updated
@pytest.mark.parametrize("sha256", ["", "A" * 64, "f" * 63, "not-a-hash"])
def test_update_recipe_rejects_invalid_hash(sha256: str) -> None:
with pytest.raises(ValueError, match="sha256"):
update_recipe(_RECIPE, version="2.0.0", sha256=sha256)
def test_update_recipe_rejects_ambiguous_recipe() -> None:
duplicate = _RECIPE + f"\nsource_two:\n sha256: {_OLD_SHA}\n"
with pytest.raises(ValueError, match="exactly one"):
update_recipe(duplicate, version="2.0.0", sha256=_NEW_SHA)
def test_repository_recipe_stays_metadata_only() -> None:
recipe = Path("packaging/conda/recipe.yaml").read_text(encoding="utf-8")
assert " - av >=16\n" not in recipe
+42 -9
View File
@@ -8,15 +8,19 @@ absent/error behaviour: detect must return None, never raise.
from __future__ import annotations
from typing import TYPE_CHECKING
import hashlib
from pathlib import Path
import pytest
from remove_ai_watermarks import trustmark_detector
from remove_ai_watermarks.identify import identify
from remove_ai_watermarks.trustmark_detector import detect_trustmark, is_available
if TYPE_CHECKING:
from pathlib import Path
_OFFICIAL_FIXTURE = (
Path(__file__).resolve().parent.parent / "data" / "fixtures" / "provenance" / "adobe-trustmark-p.png"
)
_OFFICIAL_FIXTURE_SHA256 = "e58c5825ed7e5d9fb04710ea541b61bd55879cad65554c0d46260aa24b3d0755"
class _FakeDecoder:
@@ -24,11 +28,12 @@ class _FakeDecoder:
``(secret, present, schema)`` tuples -- the first for the original image, the
second for the re-encoded copy used by the false-positive durability gate."""
def __init__(self, *results: tuple[bytes, bool, int]):
def __init__(self, *results: tuple[str, bool, int]):
self._results = list(results)
self.calls = 0
def decode(self, _img: object) -> tuple[bytes, bool, int]:
def decode(self, _img: object, mode: str = "binary") -> tuple[str, bool, int]:
assert mode == "binary"
result = self._results[min(self.calls, len(self._results) - 1)]
self.calls += 1
return result
@@ -64,7 +69,7 @@ class TestFalsePositiveGate:
monkeypatch.setattr(trustmark_detector, "_decoder", lambda: decoder)
def test_durable_watermark_survives_and_is_reported(self, monkeypatch, tmp_clean_png: Path):
decoder = _FakeDecoder((b"secret", True, 2), (b"secret", True, 2))
decoder = _FakeDecoder(("secret", True, 2), ("secret", True, 2))
self._patch_decoder(monkeypatch, decoder)
result = detect_trustmark(tmp_clean_png)
assert result == "Adobe TrustMark (variant P, schema 2)"
@@ -72,18 +77,46 @@ class TestFalsePositiveGate:
def test_false_positive_collapsing_on_reencode_is_dropped(self, monkeypatch, tmp_clean_png: Path):
# Present on the original, absent after re-encode -> content-noise FP.
decoder = _FakeDecoder((b"\x00\x01", True, 3), (b"", False, -1))
decoder = _FakeDecoder(("01", True, 2), ("", False, -1))
self._patch_decoder(monkeypatch, decoder)
assert detect_trustmark(tmp_clean_png) is None
def test_schema_drift_on_reencode_is_dropped(self, monkeypatch, tmp_clean_png: Path):
# Present both times but the schema changes -> not a stable watermark.
decoder = _FakeDecoder((b"\x00", True, 2), (b"\x00", True, 3))
decoder = _FakeDecoder(("01", True, 2), ("01", True, 3))
self._patch_decoder(monkeypatch, decoder)
assert detect_trustmark(tmp_clean_png) is None
def test_payload_drift_on_reencode_is_dropped(self, monkeypatch, tmp_clean_png: Path):
decoder = _FakeDecoder(("first", True, 2), ("second", True, 2))
self._patch_decoder(monkeypatch, decoder)
assert detect_trustmark(tmp_clean_png) is None
def test_weak_schema_three_is_dropped(self, monkeypatch, tmp_clean_png: Path):
decoder = _FakeDecoder(("secret", True, 3))
self._patch_decoder(monkeypatch, decoder)
assert detect_trustmark(tmp_clean_png) is None
assert decoder.calls == 1
def test_absent_skips_reencode(self, monkeypatch, tmp_clean_png: Path):
decoder = _FakeDecoder((b"", False, -1))
decoder = _FakeDecoder(("", False, -1))
self._patch_decoder(monkeypatch, decoder)
assert detect_trustmark(tmp_clean_png) is None
assert decoder.calls == 1 # no second decode when the first is absent
@pytest.mark.skipif(not is_available(), reason="trustmark not installed")
def test_official_adobe_variant_p_fixture():
assert detect_trustmark(_OFFICIAL_FIXTURE) == "Adobe TrustMark (variant P, schema 1)"
@pytest.mark.skipif(not is_available(), reason="trustmark not installed")
def test_official_adobe_variant_p_fixture_is_provenance_not_ai():
report = identify(_OFFICIAL_FIXTURE, check_visible=False)
assert report.is_ai_generated is None
assert report.ai_from_metadata is False
assert any(signal.name == "trustmark" for signal in report.signals)
def test_official_adobe_variant_p_fixture_digest():
assert hashlib.sha256(_OFFICIAL_FIXTURE.read_bytes()).hexdigest() == _OFFICIAL_FIXTURE_SHA256
Generated
+1 -1
View File
@@ -3331,7 +3331,7 @@ wheels = [
[[package]]
name = "remove-ai-watermarks"
version = "0.26.1"
version = "0.26.3"
source = { editable = "." }
dependencies = [
{ name = "c2pa-python" },