feat(metadata): parse C2PA JUMBF manifest fields, add Images 2.0 sample, bump to 0.3.4

- metadata --check now shows claim_generator, c2pa_spec, digital_source_type,
  c2pa_actions, signer instead of empty table for C2PA-only files
- reuses existing extract_c2pa_chunk() from noai/c2pa.py — no more duplicate
  PNG chunk parsing or full-file reads
- adds data/samples/openai-images-2/amur-leopard.png: real gpt-image-2 output
  with C2PA manifest signed by OpenAI OpCo LLC / Trufo CA (spec 2.2.0)
- removes stale data/samples/nano-banana-1/2.png (no longer referenced)
- updates README: new Images 2.0 row in supported models table
- documents known text-degradation limitation in CLAUDE.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
test-user
2026-04-22 17:21:51 -07:00
parent d2efc75ec1
commit 87d02126e3
10 changed files with 135 additions and 3 deletions
+13
View File
@@ -5,8 +5,21 @@ You are a **principal Python engineer** maintaining a CLI tool and library for r
## How to run
- `uv run remove-ai-watermarks all <image.png> -o <output.png>`
- `uv run remove-ai-watermarks metadata <image.png> --check` — inspect AI metadata (C2PA, EXIF, PNG chunks)
- `uv run remove-ai-watermarks metadata <image.png> --remove -o <out.png>` — strip all AI metadata
## Configuration
- GPU/ML modules (invisible_engine, ctrlregen, watermark_remover) are optional — guard imports with `is_available()` checks
- Tests for ML modules are limited to availability checks (require multi-GB downloads)
## Key modules
- `noai/c2pa.py` — PNG chunk parser; use `extract_c2pa_chunk(path)` to get raw caBX payload, `has_c2pa_metadata(path)` to detect. Do not reimplement chunk parsing.
- `noai/constants.py` — PNG_SIGNATURE, C2PA_CHUNK_TYPE, C2PA_SIGNATURES constants
- `face_protector.py` — YOLO detect + soft-blend pattern; mirror this for any "protect region during diffusion" features
## Known limitations
- `invisible` pipeline downscales to 768 px before diffusion → degrades fine text in infographics. Tracked; fix is tile-based or skip-downscale approach.
- Pyright first run is slow (2-3 min) due to ML deps (torch/diffusers/transformers stubs)
+1
View File
@@ -29,6 +29,7 @@ Strips SynthID, C2PA Content Credentials, EXIF/XMP "Made with AI" labels, and vi
| --- | --- | --- | --- | --- |
| **Google Gemini / Nano Banana** | ✅ Sparkle logo | ✅ SynthID | ✅ C2PA + EXIF | Alpha reversal + diffusion + metadata strip |
| **OpenAI DALL-E 3 / ChatGPT** | — | — | ✅ C2PA manifest | Metadata strip |
| **OpenAI ChatGPT Images 2.0** (gpt-image-2) | — | ⚠️ imperceptible pixel watermark (no public detector yet) | ✅ C2PA manifest (verified) | Diffusion regeneration + metadata strip |
| **Stable Diffusion (AUTOMATIC1111, ComfyUI)** | — | ✅ DWT / steganographic | ✅ PNG text chunks | Diffusion regeneration + metadata strip |
| **Adobe Firefly** | — | — | ✅ Content Credentials (C2PA) | Metadata strip |
| **Midjourney** | — | — | ✅ EXIF + XMP (prompt, model, seed) | Metadata strip |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 MiB

+41
View File
@@ -0,0 +1,41 @@
# OpenAI ChatGPT Images 2.0 sample
Reference image generated by OpenAI's ChatGPT Images 2.0 (`gpt-image-2`, launched 2026-04-21). Kept in the repo so the C2PA manifest parser and invisible-watermark pipeline can be re-verified against a real production output.
## `amur-leopard.png`
- Resolution: 1055 x 1491, PNG, 3.0 MB
- Downloaded: 2026-04-22
- Content: AI-generated infographic about the Amur leopard (*Panthera pardus orientalis*), chosen to exercise the model's new accurate-text-rendering feature
### Embedded C2PA manifest (caBX chunk, 23607 bytes)
Parsed by `remove-ai-watermarks metadata --check`:
| Field | Value |
| --- | --- |
| `claim_generator` | GPT-4o |
| `c2pa_spec` | 2.2.0 |
| `digital_source_type` | http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia |
| `c2pa_actions` | created, converted |
| Signer | OpenAI OpCo, LLC / OpenAI Media Service API |
| Claim signing CA | Trufo C2PA Claim Signing CA (2025) -> Trufo C2PA Root CA (ECC P384) |
| Timestamp authority | OpenAI TSA Issuing CA -> OpenAI TSA Root CA |
The `trainedAlgorithmicMedia` tag is what triggers "Made with AI" labels on Instagram, Facebook, and X.
### Invisible pixel-level watermark
OpenAI's system card for Images 2.0 states the model embeds an "imperceptible, robust, and content-specific" pixel-level watermark alongside C2PA. No public detector exists, so bypass cannot be verified empirically for this sample.
## Reproducing the removal
```bash
# C2PA strip only
remove-ai-watermarks metadata amur-leopard.png --remove -o amur-leopard.clean.png
# Full pipeline (visible + diffusion regeneration + metadata)
remove-ai-watermarks all amur-leopard.png -o amur-leopard.all.png --device mps
```
Note: the diffusion step downscales to 768 px, which degrades fine text on text-heavy infographics like this one. Tracked as a known limitation; see project TODO.
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "remove-ai-watermarks"
version = "0.3.3"
version = "0.3.4"
description = "Remove visible and invisible AI watermarks from images (Gemini / Nano Banana, ChatGPT, Stable Diffusion)"
readme = "README.md"
requires-python = ">=3.10"
+1 -1
View File
@@ -1,3 +1,3 @@
"""Remove-AI-Watermarks: Unified tool for removing visible and invisible AI watermarks."""
__version__ = "0.3.3"
__version__ = "0.3.4"
+77
View File
@@ -116,6 +116,82 @@ def has_ai_metadata(image_path: Path) -> bool:
return False
def _scan_png_c2pa_chunk(image_path: Path) -> dict[str, str]:
"""Extract a human-readable summary of the C2PA manifest in a PNG file.
PIL does not expose the caBX JUMBF box via ``img.info``, so we delegate
chunk extraction to the existing ``extract_c2pa_chunk`` helper and pull
key fields from the JUMBF payload without a full CBOR parser.
"""
import re
from remove_ai_watermarks.noai.c2pa import extract_c2pa_chunk
raw = extract_c2pa_chunk(image_path)
if raw is None:
return {}
# extract_c2pa_chunk returns chunk_header (8 bytes) + data + crc (4 bytes).
payload = raw[8:-4]
result: dict[str, str] = {"c2pa_manifest": f"C2PA manifest ({len(payload)} bytes)"}
def _cbor_text_after(key: bytes) -> str | None:
"""Return the CBOR text-string immediately following ``key``.
Handles CBOR major-type 3 length prefixes: direct (0x60-0x77),
1-byte (0x78 NN), and 2-byte (0x79 NN NN).
"""
idx = payload.find(key)
if idx < 0:
return None
p = idx + len(key)
if p >= len(payload):
return None
head = payload[p]
if 0x60 <= head <= 0x77:
length, start = head - 0x60, p + 1
elif head == 0x78 and p + 1 < len(payload):
length, start = payload[p + 1], p + 2
elif head == 0x79 and p + 2 < len(payload):
length, start = (payload[p + 1] << 8) | payload[p + 2], p + 3
else:
return None
raw_str = payload[start : start + length]
try:
return raw_str.decode("utf-8")
except UnicodeDecodeError:
return raw_str.decode("latin1", errors="replace")
if generator := _cbor_text_after(b"name"):
result["claim_generator"] = generator
if spec := _cbor_text_after(b"specVersion"):
result["c2pa_spec"] = spec
dst_match = re.search(
rb"(http://cv\.iptc\.org/newscodes/digitalsourcetype/[A-Za-z0-9_-]+)",
payload,
)
if dst_match:
result["digital_source_type"] = dst_match.group(1).decode("latin1")
actions = sorted(
{m.decode("latin1") for m in re.findall(rb"c2pa\.(created|converted|edited|opened|placed)", payload)}
)
if actions:
result["c2pa_actions"] = ", ".join(actions)
# Scan cert DN printable strings for the signer org name.
signer_match = re.search(
rb"([A-Za-z][A-Za-z0-9 .,&'()\-]{2,48}OpenAI[A-Za-z0-9 .,&'()\-]{0,48})",
payload,
)
if signer_match:
result["signer"] = signer_match.group(1).decode("latin1").strip()
return result
def get_ai_metadata(image_path: Path) -> dict[str, str]:
"""Extract AI-related metadata from an image.
@@ -139,6 +215,7 @@ def get_ai_metadata(image_path: Path) -> dict[str, str]:
else:
result[key] = str(value)
result.update(_scan_png_c2pa_chunk(image_path))
return result
Generated
+1 -1
View File
@@ -2015,7 +2015,7 @@ wheels = [
[[package]]
name = "remove-ai-watermarks"
version = "0.3.3"
version = "0.3.4"
source = { editable = "." }
dependencies = [
{ name = "click" },