Compare commits

...
4 Commits
21 changed files with 388 additions and 223 deletions

No files matched your search

+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
+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
+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. |
+10
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
+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`.
+4 -1
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
@@ -153,7 +156,7 @@ not a universal clean verdict.
| FLUX | None registered | Diffusion regeneration; optional open decoder | C2PA for supported sources |
| Adobe Firefly | None registered | No proprietary local decoder | C2PA; optional TrustMark decoder |
| 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.2"
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.2"
__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 (
+20 -1
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)
@@ -691,6 +693,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 +1113,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"))
+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]:
+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
Generated
+1 -1
View File
@@ -3331,7 +3331,7 @@ wheels = [
[[package]]
name = "remove-ai-watermarks"
version = "0.26.1"
version = "0.26.2"
source = { editable = "." }
dependencies = [
{ name = "c2pa-python" },