diff --git a/CLAUDE.md b/CLAUDE.md index bb97681..7f84468 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 -o ` +- `uv run remove-ai-watermarks metadata --check` — inspect AI metadata (C2PA, EXIF, PNG chunks) +- `uv run remove-ai-watermarks metadata --remove -o ` — 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) diff --git a/README.md b/README.md index 4745041..42a87cf 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/data/samples/nano-banana-1.png b/data/samples/nano-banana-1.png deleted file mode 100644 index 0fe0472..0000000 Binary files a/data/samples/nano-banana-1.png and /dev/null differ diff --git a/data/samples/nano-banana-2.png b/data/samples/nano-banana-2.png deleted file mode 100644 index d58443c..0000000 Binary files a/data/samples/nano-banana-2.png and /dev/null differ diff --git a/data/samples/openai-images-2/README.md b/data/samples/openai-images-2/README.md new file mode 100644 index 0000000..5bd9063 --- /dev/null +++ b/data/samples/openai-images-2/README.md @@ -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. diff --git a/data/samples/openai-images-2/amur-leopard.png b/data/samples/openai-images-2/amur-leopard.png new file mode 100644 index 0000000..17757c9 Binary files /dev/null and b/data/samples/openai-images-2/amur-leopard.png differ diff --git a/pyproject.toml b/pyproject.toml index 2e93e25..a5e15aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/remove_ai_watermarks/__init__.py b/src/remove_ai_watermarks/__init__.py index 741c317..9efcc1c 100644 --- a/src/remove_ai_watermarks/__init__.py +++ b/src/remove_ai_watermarks/__init__.py @@ -1,3 +1,3 @@ """Remove-AI-Watermarks: Unified tool for removing visible and invisible AI watermarks.""" -__version__ = "0.3.3" +__version__ = "0.3.4" diff --git a/src/remove_ai_watermarks/metadata.py b/src/remove_ai_watermarks/metadata.py index b0ccfa0..e5ee92c 100644 --- a/src/remove_ai_watermarks/metadata.py +++ b/src/remove_ai_watermarks/metadata.py @@ -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 diff --git a/uv.lock b/uv.lock index 353f9ff..4a02ded 100644 --- a/uv.lock +++ b/uv.lock @@ -2015,7 +2015,7 @@ wheels = [ [[package]] name = "remove-ai-watermarks" -version = "0.3.3" +version = "0.3.4" source = { editable = "." } dependencies = [ { name = "click" },