mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-09 23:50:40 +02:00
Merge main into video watermark pipeline
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
---
|
||||
globs: ["src/**/*.py", "tests/**/*.py", "scripts/**/*.py", "pyproject.toml", "uv.lock", "maintain.sh", ".github/workflows/*.yml"]
|
||||
description: Command contracts, project gate, typing boundaries, and model-adjacent test invariants.
|
||||
---
|
||||
|
||||
# Development invariants
|
||||
|
||||
## Command contracts
|
||||
|
||||
Every single-image command declares `source` with `dir_okay=False`; `batch` declares its directory with `file_okay=False`. Keep `tests/test_cli_robustness.py::TestDirectoryInputIsRejected` as the regression guard.
|
||||
|
||||
Exit-code and no-signal behavior is a public contract. Read the command-line section of [`../../docs/module-internals.md`](../../docs/module-internals.md) before changing it.
|
||||
|
||||
## Local gate
|
||||
|
||||
Run `bash maintain.sh` from the repository root. The authoritative type gate is scoped to `src/`; full-project Pyright can exhaust Node memory on the ML dependency graph.
|
||||
|
||||
Boundary modules for cv2, Torch, and Diffusers may carry narrow per-file relaxations for unknown third-party types. Keep pure-logic files strict, preserve the local piexif stub, and fix real errors before widening a pragma.
|
||||
|
||||
## Model-adjacent tests
|
||||
|
||||
Do not classify an entire module as untestable because its main path downloads a model. Keep pure behavior covered without downloads, including:
|
||||
|
||||
- target-size selection in `test_invisible_engine.py`;
|
||||
- unsharp and adaptive-polish helpers in `test_humanizer.py`;
|
||||
- mocked device fallback in `test_img2img_runner.py`;
|
||||
- tiling geometry and blending in `test_tiling.py`.
|
||||
|
||||
Use availability checks only for paths that actually load large models.
|
||||
|
||||
Environment setup, dependency recovery, CI behavior, and fixture policy: [`../../docs/development.md`](../../docs/development.md).
|
||||
@@ -14,6 +14,21 @@ updates:
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
# The `all` extra must remain jointly resolvable. These candidates violate
|
||||
# upstream constraints: TrustMark still requires NumPy 1.x, OpenCV 4.12+
|
||||
# requires NumPy 2 on newer Python versions, and this project keeps tokenizers
|
||||
# below 0.23 for the stable Transformers path. Keep Dependabot on the
|
||||
# compatible lines until those constraints move.
|
||||
ignore:
|
||||
- dependency-name: "numpy"
|
||||
versions:
|
||||
- ">=2"
|
||||
- dependency-name: "opencv-python-headless"
|
||||
versions:
|
||||
- ">=4.12"
|
||||
- dependency-name: "tokenizers"
|
||||
versions:
|
||||
- ">=0.23"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
|
||||
@@ -43,9 +43,26 @@ removal.
|
||||
| Run visible, invisible, and metadata removal | `all` | Recommended |
|
||||
| Process a directory | `batch` | Depends on mode |
|
||||
|
||||
## Installation modes
|
||||
|
||||
| Need | Install |
|
||||
| --- | --- |
|
||||
| Metadata inspection and stripping | `remove-ai-watermarks` |
|
||||
| Visible detection and removal | `remove-ai-watermarks[visible]` |
|
||||
| Visible video processing | `remove-ai-watermarks[video]` |
|
||||
| Video SynthID removal | `remove-ai-watermarks[video,diffusion]` |
|
||||
| Torch-free DWT-DCT detection | `remove-ai-watermarks[detect]` |
|
||||
| Diffusion removal | `remove-ai-watermarks[diffusion]` |
|
||||
| Every production feature | `remove-ai-watermarks[all]` |
|
||||
|
||||
Lower-level and specialized extras include `pixels`, `heif`, `trustmark`,
|
||||
`migan`, `lama`, `esrgan`, and `qwen-zimage`. The
|
||||
[installation guide](docs/installation.md#feature-extras) documents their exact
|
||||
dependency composition and model requirements.
|
||||
|
||||
## Quick start
|
||||
|
||||
Install the core CLI:
|
||||
Install the metadata-focused default CLI:
|
||||
|
||||
```bash
|
||||
uv tool install remove-ai-watermarks
|
||||
@@ -57,7 +74,13 @@ Inspect an image:
|
||||
remove-ai-watermarks identify image.png
|
||||
```
|
||||
|
||||
Remove a known visible mark and AI metadata:
|
||||
For visible watermark removal, install the pixel dependencies:
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[visible]"
|
||||
```
|
||||
|
||||
Then remove a known visible mark and AI metadata:
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks visible image.png -o clean.png
|
||||
@@ -89,6 +112,7 @@ copy for removal.
|
||||
Use the product-oriented video path to identify or clean a file:
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[video]"
|
||||
remove-ai-watermarks video identify input.mp4
|
||||
remove-ai-watermarks video all input.mp4 -o clean.mp4
|
||||
```
|
||||
@@ -144,7 +168,7 @@ than silently reduced through OpenCV's 8-bit BGR boundary.
|
||||
Remove video SynthID:
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[gpu]"
|
||||
uv tool install --force "remove-ai-watermarks[video,diffusion]"
|
||||
remove-ai-watermarks video invisible input.mp4 -o clean.mp4
|
||||
```
|
||||
|
||||
@@ -159,7 +183,7 @@ is not a product result state.
|
||||
For invisible watermark removal, install the diffusion dependencies:
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[gpu]"
|
||||
uv tool install --force "remove-ai-watermarks[diffusion]"
|
||||
remove-ai-watermarks invisible image.png -o clean.png
|
||||
```
|
||||
|
||||
@@ -227,8 +251,9 @@ remove-ai-watermarks erase image.png \
|
||||
|
||||
### Use a learned fill backend
|
||||
|
||||
The core install uses OpenCV inpainting when no learned backend is installed.
|
||||
For more difficult backgrounds:
|
||||
The `visible` extra uses OpenCV inpainting when no learned backend is installed.
|
||||
For more difficult backgrounds, the learned-backend extras include the same
|
||||
pixel dependencies automatically:
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[migan]"
|
||||
@@ -296,6 +321,8 @@ See [supported signals](docs/supported-signals.md) and
|
||||
|
||||
## Python API
|
||||
|
||||
The visible-removal API requires `remove-ai-watermarks[visible]`.
|
||||
|
||||
```python
|
||||
import remove_ai_watermarks as raiw
|
||||
|
||||
@@ -362,7 +389,7 @@ a matched re-encode control plus VAE-regenerated candidates and leaves the
|
||||
verifier verdict blank:
|
||||
|
||||
```bash
|
||||
uv run --extra gpu python scripts/video_synthid_sweep.py input.mp4 -o sweep/
|
||||
uv run --extra video --extra diffusion python scripts/video_synthid_sweep.py input.mp4 -o sweep/
|
||||
```
|
||||
|
||||
The control must still be SynthID-positive before a negative candidate can
|
||||
|
||||
+44
-6
@@ -9,15 +9,38 @@ remove-ai-watermarks [OPTIONS] COMMAND [ARGS]
|
||||
Run `remove-ai-watermarks COMMAND --help` for the complete option list and
|
||||
defaults. This page focuses on choosing the right command.
|
||||
|
||||
## Command dependency map
|
||||
|
||||
| Command or signal | Required installation |
|
||||
| --- | --- |
|
||||
| `metadata` and metadata-only `identify` | Default package |
|
||||
| Visible signals in `identify` | `remove-ai-watermarks[visible]` (`pixels` is the minimal runtime) |
|
||||
| Open DWT-DCT signals in `identify` | `remove-ai-watermarks[detect]` |
|
||||
| Adobe TrustMark signals in `identify` | `remove-ai-watermarks[trustmark]` |
|
||||
| `visible` and `erase` with OpenCV | `remove-ai-watermarks[visible]` (`pixels` is the minimal runtime) |
|
||||
| `visible` or `erase` with MI-GAN | `remove-ai-watermarks[migan]` |
|
||||
| `visible` or `erase` with big-LaMa | `remove-ai-watermarks[lama]` |
|
||||
| `invisible` | `remove-ai-watermarks[diffusion]` |
|
||||
| `invisible --pipeline qwen-zimage` | `remove-ai-watermarks[qwen-zimage]` |
|
||||
| `video metadata` and `video identify --no-visible` | Default package |
|
||||
| `video identify`, `video visible`, and visible/all batch modes | `remove-ai-watermarks[video]` |
|
||||
| `video invisible` and `video all --invisible` | `remove-ai-watermarks[video,diffusion]` |
|
||||
| HEIC/HEIF/AVIF pixel input | Add `remove-ai-watermarks[heif]` |
|
||||
| Every production command and backend | `remove-ai-watermarks[all]` |
|
||||
|
||||
`batch` requires the same extra as its selected mode. Extras can be combined in
|
||||
one installation, for example `remove-ai-watermarks[visible,detect,heif]`.
|
||||
|
||||
## Inspect an image
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks identify image.png
|
||||
```
|
||||
|
||||
`identify` combines supported metadata and pixel signals into one provenance
|
||||
report. When no signal is found, it reports the origin as unknown. It does not
|
||||
claim the image is clean.
|
||||
`identify` always inspects supported metadata. When pixel extras are installed,
|
||||
it also evaluates supported visible and invisible pixel signals. When no signal
|
||||
is found, it reports the origin as unknown. It does not claim the image is
|
||||
clean.
|
||||
|
||||
Machine readable output:
|
||||
|
||||
@@ -36,6 +59,8 @@ invisible pixel detectors. Metadata inspection still runs.
|
||||
|
||||
## Remove known visible marks
|
||||
|
||||
Install `remove-ai-watermarks[visible]` before using `visible` or `erase`.
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks visible image.png -o clean.png
|
||||
```
|
||||
@@ -127,6 +152,13 @@ non-ISOBMFF audio and video path.
|
||||
|
||||
## Identify and clean video
|
||||
|
||||
Install the video pixel and timestamp runtime for visible identification,
|
||||
removal, and the complete pipeline:
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[video]"
|
||||
```
|
||||
|
||||
Inspect every locally supported video signal:
|
||||
|
||||
```bash
|
||||
@@ -210,7 +242,7 @@ command.
|
||||
## Remove video SynthID
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[gpu]"
|
||||
uv tool install --force "remove-ai-watermarks[video,diffusion]"
|
||||
remove-ai-watermarks video invisible input.mp4 -o clean.mp4
|
||||
```
|
||||
|
||||
@@ -311,7 +343,7 @@ failed encode does not overwrite an existing result.
|
||||
Install the diffusion dependencies first:
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[gpu]"
|
||||
uv tool install --force "remove-ai-watermarks[diffusion]"
|
||||
```
|
||||
|
||||
Then run:
|
||||
@@ -375,6 +407,12 @@ It is a memory strategy, not a guarantee of better quality.
|
||||
|
||||
## Run the full pipeline
|
||||
|
||||
The `all` command and the `all` installation extra are separate concepts. The
|
||||
command runs every applicable stage. Installing `remove-ai-watermarks[all]`
|
||||
makes every production backend available; a smaller installation such as
|
||||
`remove-ai-watermarks[visible,diffusion]` can also run the command with fewer
|
||||
optional backends.
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks all image.png -o clean.png
|
||||
```
|
||||
@@ -387,7 +425,7 @@ The command runs:
|
||||
|
||||
The visible options and diffusion options are also available on `all`.
|
||||
|
||||
If diffusion is required but the `gpu` extra is unavailable, `all` still
|
||||
If diffusion is required but the `diffusion` extra is unavailable, `all` still
|
||||
writes the result of the visible and metadata stages, prints a prominent
|
||||
warning, and exits with code 1. This prevents a partial result from being
|
||||
reported as complete.
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Development
|
||||
|
||||
Read this reference for environment setup, dependency recovery, CI behavior, and fixture policy. The always-loaded invariants remain in [`.claude/rules/development.md`](../.claude/rules/development.md).
|
||||
|
||||
## Local environment
|
||||
|
||||
- Use `uv sync --frozen --extra dev` and add only the feature extras needed for the task.
|
||||
- Do not use `uv pip install` for development tools. It can re-resolve `uv.lock` outside the compatible ML dependency set.
|
||||
- A default-only sync removes every pixel and model package by design. Package imports remain light through lazy exports.
|
||||
- On an unreliable connection, sync `dev` plus only the required feature extras, such as `diffusion`, and run the checks directly instead of downloading every optional learned backend.
|
||||
- Run `uv` from the repository root or it may create a bare environment without the project dependencies.
|
||||
|
||||
The optional TrustMark decoder downloads weights into its installed package directory. After pruning that extra, a leftover weights directory can make availability checks see an empty namespace package. If Pyright reports an unknown `TrustMark` import and `find_spec("trustmark")` returns a loader-less spec, remove that regenerable remnant from the active virtual environment and resync.
|
||||
|
||||
## CI
|
||||
|
||||
`.github/workflows/test.yml` runs Ruff and a cross-platform supported-Python test matrix with default plus development dependencies. Diffusion and model-running tests skip in that matrix; metadata, identification, visible removal, the DWT-DCT decoder, and the OpenCV eraser remain covered across operating systems.
|
||||
|
||||
Keep `uv.lock` compatible with `uv sync --frozen`. Dependency pull-request checks use GitHub's merge result against current `main`; if `main` moves, merge it locally and rerun the full gate because a newer linter can expose stale directives in later code.
|
||||
|
||||
Release and distribution behavior is canonical in [`release-and-distribution.md`](release-and-distribution.md).
|
||||
|
||||
## Fixture and data policy
|
||||
|
||||
[`../data/README.md`](../data/README.md) is the source of truth:
|
||||
|
||||
- executable provenance fixtures live under `data/fixtures/`;
|
||||
- minimal controlled detector inputs live under `data/calibration/`;
|
||||
- canonical provider-oracle originals and their manifests live under `data/synthid/`;
|
||||
- evaluation-only ground truth lives under `data/evaluations/`;
|
||||
- runtime detector assets live in the package; unregistered research candidates remain outside the shipped wheel.
|
||||
|
||||
Store each binary once. Point tests and manifests at its canonical path. Keep generated and cleaned outputs outside the repository and retain only reproducible public records allowed by the data policy.
|
||||
|
||||
Use synthetic byte blobs for unsupported format paths and deterministic generated negatives where a real negative fixture is unnecessary. Detection and removal tests must preserve their format-specific invariants.
|
||||
+106
-22
@@ -2,15 +2,17 @@
|
||||
|
||||
Python 3.10.1 or newer is required.
|
||||
|
||||
## Core install
|
||||
## Default metadata mode
|
||||
|
||||
The core package provides:
|
||||
The default package provides:
|
||||
|
||||
- provenance inspection;
|
||||
- visible watermark removal with OpenCV;
|
||||
- manual region erasing with OpenCV;
|
||||
- AI metadata inspection and removal.
|
||||
|
||||
It installs Pillow, piexif, and c2pa-python for reading metadata directly from
|
||||
files. It does not install NumPy, OpenCV, pillow-heif, Torch, diffusion models,
|
||||
or invisible-watermark decoders.
|
||||
|
||||
Install it as an isolated command with uv:
|
||||
|
||||
```bash
|
||||
@@ -29,12 +31,44 @@ You can also install the Homebrew package on macOS or Linux:
|
||||
brew install wiltodelta/tap/remove-ai-watermarks
|
||||
```
|
||||
|
||||
## Invisible watermark removal
|
||||
## Visible watermark removal
|
||||
|
||||
Diffusion based removal needs the `gpu` extra:
|
||||
Visible mark detection, OpenCV inpainting, and manual region erasing need the
|
||||
`visible` extra:
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[gpu]"
|
||||
uv tool install --force "remove-ai-watermarks[visible]"
|
||||
```
|
||||
|
||||
Add `heif` only when the pixel path must decode HEIC, HEIF, or AVIF:
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[visible,heif]"
|
||||
```
|
||||
|
||||
## Video processing
|
||||
|
||||
Video metadata inspection and stripping work with the default package. Stable
|
||||
visible-mark identification and removal, full video cleaning, and visible/all
|
||||
batch modes need the `video` extra:
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[video]"
|
||||
```
|
||||
|
||||
The extra includes the visible pixel runtime and PyAV for preserving variable
|
||||
frame timestamps. Video SynthID regeneration also needs the diffusion stack:
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[video,diffusion]"
|
||||
```
|
||||
|
||||
## Invisible watermark removal
|
||||
|
||||
Diffusion based removal needs the `diffusion` extra:
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[diffusion]"
|
||||
```
|
||||
|
||||
The code supports CUDA, XPU, MPS, and CPU devices. A GPU is recommended because
|
||||
@@ -46,28 +80,78 @@ For the CUDA only Qwen Image plus Z-Image profile:
|
||||
uv tool install --force "remove-ai-watermarks[qwen-zimage]"
|
||||
```
|
||||
|
||||
The `qwen-zimage` extra includes the normal `gpu` dependencies.
|
||||
The `qwen-zimage` extra includes the normal `diffusion` dependencies.
|
||||
|
||||
## Optional features
|
||||
## Feature extras
|
||||
|
||||
Install only what you need:
|
||||
Extras are composable. Install only the capabilities and file formats the
|
||||
application actually uses:
|
||||
|
||||
| Extra | Adds |
|
||||
| --- | --- |
|
||||
| `migan` | MI-GAN ONNX fill backend |
|
||||
| `lama` | big-LaMa ONNX fill backend |
|
||||
| `detect` | Open DWT-DCT watermark decoder used by `identify` |
|
||||
| `trustmark` | Adobe TrustMark decoder |
|
||||
| `esrgan` | Real-ESRGAN upscaling before diffusion |
|
||||
| `qwen-zimage` | CUDA only Qwen Image plus Z-Image pipeline |
|
||||
| Extra | Capability | Automatically includes | Torch or model download |
|
||||
| --- | --- | --- | --- |
|
||||
| `pixels` | Shared BGR array and image-processing runtime | NumPy, headless OpenCV | No |
|
||||
| `heif` | HEIC, HEIF, and AVIF pixel decoding | pillow-heif | No |
|
||||
| `visible` | Visible mark detection, OpenCV inpainting, and manual erasing | `pixels` | No |
|
||||
| `video` | Visible video identification/removal and timestamp preservation | `visible`, PyAV | No |
|
||||
| `detect` | Open DWT-DCT detection for Stable Diffusion, SDXL, and FLUX | `pixels`, PyWavelets | No |
|
||||
| `trustmark` | Adobe TrustMark detection | trustmark | Yes |
|
||||
| `diffusion` | Diffusion-based invisible watermark removal | `pixels`, Torch, Diffusers | Yes |
|
||||
| `migan` | MI-GAN ONNX fill backend | `visible`, ONNX Runtime | Model download, no Torch |
|
||||
| `lama` | big-LaMa ONNX fill backend | `visible`, ONNX Runtime | Model download, no Torch |
|
||||
| `esrgan` | Real-ESRGAN upscaling before diffusion | `pixels`, spandrel | Yes |
|
||||
| `qwen-zimage` | CUDA-only Qwen Image plus Z-Image pipeline | `diffusion`, DiffSynth | Yes |
|
||||
| `all` | Every production feature | All rows above | Yes |
|
||||
| `dev` | Tests, linting, typing, and upstream parity checks | `visible`, `detect`, upstream invisible-watermark | Yes, for parity tests |
|
||||
|
||||
Example:
|
||||
Dependency composition:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
visible --> pixels
|
||||
video --> visible
|
||||
detect --> pixels
|
||||
diffusion --> pixels
|
||||
migan --> visible
|
||||
lama --> visible
|
||||
esrgan --> pixels
|
||||
qwen["qwen-zimage"] --> diffusion
|
||||
heif
|
||||
trustmark
|
||||
```
|
||||
|
||||
`heif` and `trustmark` are independent branches. Combine them explicitly with
|
||||
another feature when required. The `all` bundle contains every production
|
||||
branch but never includes `dev`.
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
# Metadata plus torch-free DWT-DCT detection
|
||||
uv tool install --force "remove-ai-watermarks[detect]"
|
||||
|
||||
# Visible removal with HEIC/AVIF support and MI-GAN
|
||||
uv tool install --force "remove-ai-watermarks[migan,heif]"
|
||||
|
||||
# Visible video removal with preserved timestamps
|
||||
uv tool install --force "remove-ai-watermarks[video]"
|
||||
|
||||
# DWT-DCT and TrustMark detection without diffusion removal
|
||||
uv tool install --force "remove-ai-watermarks[detect,trustmark]"
|
||||
|
||||
# Every production capability
|
||||
uv tool install --force "remove-ai-watermarks[all]"
|
||||
|
||||
# An arbitrary minimal combination
|
||||
uv tool install --force "remove-ai-watermarks[migan,detect]"
|
||||
```
|
||||
|
||||
Some optional models download their weights on first use.
|
||||
`heif` stays independent so applications that only process PNG, JPEG, or WebP
|
||||
do not install libheif. `detect` uses the in-tree torch-free decoder and does
|
||||
not install the upstream `invisible-watermark` package. Optional models download
|
||||
their weights on first use.
|
||||
|
||||
The old `gpu` and `remove` aliases are intentionally not provided. Use
|
||||
`diffusion` and `visible` respectively.
|
||||
|
||||
## Install from the repository
|
||||
|
||||
@@ -81,7 +165,7 @@ Add the feature groups required for your work:
|
||||
|
||||
```bash
|
||||
uv sync --frozen --extra dev
|
||||
uv sync --frozen --extra dev --extra gpu
|
||||
uv sync --frozen --extra dev --extra diffusion
|
||||
```
|
||||
|
||||
Run commands from the repository root:
|
||||
@@ -133,4 +217,4 @@ found. A missing signal does not prove that the image is clean. If you know the
|
||||
image came from a relevant generator, use `--force`.
|
||||
|
||||
If the CLI reports that diffusion dependencies are unavailable, install the
|
||||
`gpu` extra.
|
||||
`diffusion` extra. Video SynthID removal needs both `video` and `diffusion`.
|
||||
|
||||
@@ -11,7 +11,8 @@ superseded experiments live in the research archive listed in
|
||||
Visible removal changes only the selected mask, but the hidden pixels still
|
||||
have to be reconstructed.
|
||||
|
||||
- OpenCV is fast and dependency free. It works well on flat backgrounds but
|
||||
- OpenCV is fast and requires no model download. It works well on flat
|
||||
backgrounds but
|
||||
can smear texture or repeated structure.
|
||||
- MI-GAN is a lighter learned backend. It can improve natural texture but may
|
||||
ghost or invent structure.
|
||||
@@ -200,11 +201,13 @@ The metadata path recognizes JPEG XL containers, but the visible and diffusion
|
||||
image paths do not list `.jxl` as a supported pixel format because the package
|
||||
does not include a JPEG XL pixel decoder.
|
||||
|
||||
### HEIC, HEIF, and AVIF use a Pillow fallback
|
||||
### HEIC, HEIF, and AVIF pixel decoding uses an optional Pillow fallback
|
||||
|
||||
OpenCV does not decode these formats in the project. `image_io.imread` falls
|
||||
back to Pillow with `pillow-heif`. A corrupt or truncated file may still fail to
|
||||
decode.
|
||||
back to Pillow with `pillow-heif` when the `heif` extra is installed alongside
|
||||
a pixel feature. The
|
||||
default metadata path scans these containers without that plugin. A corrupt or
|
||||
truncated file may still fail to decode.
|
||||
|
||||
### Some metadata removal requires ffmpeg
|
||||
|
||||
|
||||
@@ -309,9 +309,23 @@ Regression coverage:
|
||||
|
||||
### Provenance report
|
||||
|
||||
[`identify.py`](../src/remove_ai_watermarks/identify.py) combines metadata,
|
||||
registered visible marks, and optional open invisible-watermark decoders into a
|
||||
`ProvenanceReport`.
|
||||
[`identify.py`](../src/remove_ai_watermarks/identify.py) separates file-backed
|
||||
metadata extraction from verdict logic:
|
||||
|
||||
- `extract_provenance_evidence` reads the supported metadata signals into
|
||||
`ProvenanceEvidence`.
|
||||
- `evidence_from_metadata_record` normalizes an externally collected nested
|
||||
metadata record into the same evidence type without file access. Diagnostic
|
||||
values under `error` and `kind` are excluded from evidence while nested raw
|
||||
bytes remain available through encoded binary fields.
|
||||
- `identify_from_evidence` evaluates that evidence without reopening the source.
|
||||
- `identify` preserves the path-based API and adds the optional registered
|
||||
visible-mark and open invisible-watermark decoders after extraction.
|
||||
|
||||
The `detect` extra composes the shared `pixels` runtime with PyWavelets. Its
|
||||
in-tree [`dwt_dct.py`](../src/remove_ai_watermarks/dwt_dct.py) decoder preserves
|
||||
the upstream matrix algorithm without installing Torch or non-headless OpenCV.
|
||||
The upstream MIT notice ships inside the wheel under `licenses/`.
|
||||
|
||||
`is_ai_generated` is `True` or `None`; absence of evidence is not reported as a
|
||||
human-made verdict. `ai_source_kind` distinguishes fully generated content from
|
||||
@@ -548,7 +562,8 @@ Contracts:
|
||||
- `to_bgr` normalizes grayscale and alpha-bearing arrays.
|
||||
- `read_bgr_and_alpha` and `write_bgr_with_alpha` preserve the alpha plane.
|
||||
- `imwrite` returns a success flag; every caller must check it.
|
||||
- HEIC, HEIF, and AVIF fall back to Pillow plus `pillow-heif`.
|
||||
- HEIC, HEIF, and AVIF pixel reads fall back to Pillow plus `pillow-heif` from
|
||||
the independent `heif` extra. Metadata scanning does not require that plugin.
|
||||
- A visible no-op can preserve the original file bytes.
|
||||
|
||||
Regression coverage:
|
||||
|
||||
+65
-2
@@ -3,8 +3,18 @@
|
||||
Use the high level API for normal application integration. Low level detector
|
||||
and pipeline modules are intended for maintainers and specialized workflows.
|
||||
|
||||
Dependency groups are identical for the CLI and Python API. The default install
|
||||
covers metadata extraction, normalization, verdict logic, and stripping.
|
||||
Array/pixel APIs use `pixels`; visible removal uses `visible`; DWT-DCT detection
|
||||
uses `detect`; diffusion removal uses `diffusion`; and visible video processing
|
||||
uses `video`. Video SynthID removal combines `video` and `diffusion`. Add `heif`
|
||||
independently when path-based pixel APIs must decode HEIC, HEIF, or AVIF. See
|
||||
the complete [feature-extra matrix](installation.md#feature-extras).
|
||||
|
||||
## Remove visible marks
|
||||
|
||||
Install `remove-ai-watermarks[visible]` before using the visible-removal API.
|
||||
|
||||
```python
|
||||
import remove_ai_watermarks as raiw
|
||||
|
||||
@@ -68,6 +78,9 @@ result, removed = raiw.remove_visible(image, backend="cv2")
|
||||
|
||||
## Inspect provenance
|
||||
|
||||
The default installation evaluates file metadata. Add `visible`, `detect`, or
|
||||
`trustmark` to enable the corresponding optional pixel signals.
|
||||
|
||||
Get the vendor keys used by visible removal:
|
||||
|
||||
```python
|
||||
@@ -88,8 +101,8 @@ print(report.platform)
|
||||
print(report.signals)
|
||||
```
|
||||
|
||||
Use `check_visible=False` and `check_invisible=False` for metadata only
|
||||
inspection:
|
||||
Use `check_visible=False` and `check_invisible=False` for metadata-only
|
||||
inspection through the compatible path-based API:
|
||||
|
||||
```python
|
||||
report = identify(
|
||||
@@ -99,6 +112,48 @@ report = identify(
|
||||
)
|
||||
```
|
||||
|
||||
Extraction and detection are also available as separate steps. This is useful
|
||||
when a file-reading worker collects the metadata once and another component
|
||||
evaluates the resulting evidence:
|
||||
|
||||
```python
|
||||
from remove_ai_watermarks.identify import (
|
||||
extract_provenance_evidence,
|
||||
identify_from_evidence,
|
||||
)
|
||||
|
||||
evidence = extract_provenance_evidence(Path("input.png"))
|
||||
report = identify_from_evidence(evidence)
|
||||
```
|
||||
|
||||
If metadata was collected by another component, normalize its nested record
|
||||
without reopening the original file:
|
||||
|
||||
```python
|
||||
from remove_ai_watermarks.identify import (
|
||||
evidence_from_metadata_record,
|
||||
identify_from_evidence,
|
||||
)
|
||||
|
||||
record = {
|
||||
"pil": {"info:parameters": "Steps: 20, Sampler: Euler"},
|
||||
"exif": {"0th": {"Software": "Stable Diffusion"}},
|
||||
}
|
||||
evidence = evidence_from_metadata_record(record, path=Path("input.png"))
|
||||
report = identify_from_evidence(evidence)
|
||||
```
|
||||
|
||||
The normalizer recursively preserves text and byte values. It also decodes
|
||||
strings prefixed with `hex:` and fields named `base64` or ending in
|
||||
`_base64`. Diagnostic values under `error` and `kind` are ignored because they
|
||||
describe the collector rather than the source file. Pass a C2PA manifest-store
|
||||
dictionary in `record["c2pa_store"]`, or through the explicit
|
||||
`c2pa_manifest_store` argument.
|
||||
|
||||
`identify_from_evidence` does not reopen the source file. It evaluates metadata
|
||||
only; registered visible marks and pixel-backed invisible watermarks remain in
|
||||
the path-based `identify` call.
|
||||
|
||||
## Strip metadata
|
||||
|
||||
```python
|
||||
@@ -131,6 +186,8 @@ as proof that metadata was removed.
|
||||
## Identify and clean video
|
||||
|
||||
The high level video API supports MP4, MOV, M4V, WebM, MKV, AVI, and FLV:
|
||||
metadata-only calls work with the default install, while visible identification,
|
||||
removal, and the complete pipeline require `remove-ai-watermarks[video]`.
|
||||
|
||||
```python
|
||||
import remove_ai_watermarks as raiw
|
||||
@@ -212,6 +269,9 @@ removal path.
|
||||
|
||||
## Remove video SynthID
|
||||
|
||||
Install `remove-ai-watermarks[video,diffusion]` before using the video SynthID
|
||||
API.
|
||||
|
||||
```python
|
||||
import remove_ai_watermarks as raiw
|
||||
|
||||
@@ -320,6 +380,9 @@ to 8-bit SDR.
|
||||
|
||||
## Remove invisible watermarks
|
||||
|
||||
Install `remove-ai-watermarks[diffusion]` for the standard pipelines or
|
||||
`remove-ai-watermarks[qwen-zimage]` for the CUDA-only high-fidelity profile.
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -55,11 +55,12 @@ 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 core
|
||||
dependencies in `pyproject.toml`, and document any conda-forge package that is
|
||||
unavailable and must be omitted. PyPI's version-split PyAV dependency maps to
|
||||
`av >=16` in conda: the solver selects the Python-3.10-compatible build or the
|
||||
current line according to the environment.
|
||||
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
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ independent frame fill.
|
||||
|
||||
| Backend | Install | Behavior |
|
||||
| --- | --- | --- |
|
||||
| `cv2` | Core package | Classical OpenCV inpainting |
|
||||
| `cv2` | `remove-ai-watermarks[visible]` | Classical OpenCV inpainting |
|
||||
| `migan` | `remove-ai-watermarks[migan]` | MI-GAN through ONNX Runtime; practical learned CPU video tier |
|
||||
| `lama` | `remove-ai-watermarks[lama]` | big-LaMa through ONNX Runtime; offline video quality tier |
|
||||
| `auto` | Depends on installed extras | Selects LaMa, then MI-GAN, then OpenCV |
|
||||
@@ -93,6 +93,9 @@ Pixel based image commands discover these extensions:
|
||||
- HEIC and HEIF;
|
||||
- AVIF.
|
||||
|
||||
HEIC, HEIF, and AVIF pixel decoding requires the independent `heif` extra in
|
||||
addition to the selected pixel feature. Metadata scanning does not.
|
||||
|
||||
Metadata inspection and removal additionally have container paths for:
|
||||
|
||||
- JPEG XL metadata;
|
||||
|
||||
@@ -224,7 +224,7 @@ from the test set + this doc).
|
||||
|
||||
## 6. Integration cost (rough)
|
||||
|
||||
- New deps: `diffusers` already in the gpu extra; PhotoMaker ships as a `.bin`
|
||||
- New deps: `diffusers` already in the diffusion extra; PhotoMaker ships as a `.bin`
|
||||
loaded via `pipeline.load_photomaker_adapter(...)`. The OpenCLIP encoder is the
|
||||
same one diffusers already pulls. No new heavy pip dep.
|
||||
- Weight download: PhotoMaker-V1 weights are ~3 GB. Add to the Modal HF volume
|
||||
|
||||
@@ -33,7 +33,7 @@ Grok JPEG downloads (Aurora model) carry **no C2PA, no XMP, no SynthID, no IPTC*
|
||||
**Stripped on removal too:** `remove_ai_metadata` calls `_scrub_ai_exif` on
|
||||
JPEG EXIF, which deletes the xAI Signature and UUID Artist pair plus supported
|
||||
AI generator values while retaining unrelated camera and editor EXIF. The
|
||||
shared `_is_xai_signature_pair` helper is the single source of truth for the
|
||||
shared `xai_signature_pair` helper is the single source of truth for the
|
||||
pair. On the ISOBMFF path, `blank_ai_exif_tokens` provides the corresponding
|
||||
in-place scrub for supported EXIF values, TC260 AIGC blocks, and the xAI pair.
|
||||
- **China TC260 AIGC label (caught by `AIGC_MARKERS` / `metadata.aigc_label`, surfaced by `identify` as the `aigc` signal):** China-served generators embed an XMP `<TC260:AIGC>{"Label":"1","ContentProducer":...}` block — China's mandatory AI-content labeling (TC260 namespace `tc260.org.cn/ns/AIGC`).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
schema_version: 1
|
||||
|
||||
context:
|
||||
version: "0.20.2"
|
||||
version: "0.22.0"
|
||||
python_min: "3.10"
|
||||
|
||||
package:
|
||||
@@ -10,7 +10,7 @@ package:
|
||||
|
||||
source:
|
||||
url: https://pypi.org/packages/source/r/remove-ai-watermarks/remove_ai_watermarks-${{ version }}.tar.gz
|
||||
sha256: 99a3991d30c80e1719122464a26befd2f372cfa8e3b1b3909f2869fce35e0f24
|
||||
sha256: 00549927083c2b60444dd8404830c7333899a9345aacc41d43b21a77c63b8dd0
|
||||
|
||||
build:
|
||||
noarch: python
|
||||
@@ -25,17 +25,10 @@ requirements:
|
||||
run:
|
||||
- python >=${{ python_min }}
|
||||
- pillow >=10.0.0
|
||||
- pillow-heif >=0.13.0
|
||||
- piexif >=1.1.3
|
||||
- numpy >=1.24.0
|
||||
- py-opencv >=4.8.0
|
||||
- click >=8.0.0
|
||||
- python-dotenv >=1.0.0
|
||||
# PyAV packetizes processed VFR frames with explicit PTS before system
|
||||
# ffmpeg encodes them. Conda's Python constraint selects the compatible
|
||||
# 16.x line on Python 3.10 and the current line on newer interpreters.
|
||||
- av >=16
|
||||
# c2pa-python is a core PyPI dependency but is not packaged on conda-forge.
|
||||
# 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.
|
||||
|
||||
@@ -54,14 +47,12 @@ tests:
|
||||
|
||||
about:
|
||||
homepage: https://github.com/wiltodelta/remove-ai-watermarks
|
||||
summary: Remove visible and invisible AI watermarks from images
|
||||
summary: Inspect and strip AI provenance metadata from media
|
||||
description: |
|
||||
Detect and remove registered visible AI-provenance marks and strip
|
||||
AI-provenance metadata (C2PA, EXIF, IPTC, and PNG text chunks) from images
|
||||
and supported video containers. The core package covers the identify,
|
||||
metadata, visible, erase, and product-oriented video command surface. Optional
|
||||
pip extras add SynthID diffusion removal and additional invisible-watermark
|
||||
detectors.
|
||||
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
|
||||
|
||||
+40
-32
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "remove-ai-watermarks"
|
||||
version = "0.20.2"
|
||||
description = "Remove visible and invisible AI watermarks from images and provenance metadata from media containers"
|
||||
version = "0.22.0"
|
||||
description = "AI watermark remover for visible, invisible, and provenance marks in images and video"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10.1"
|
||||
license = {text = "Apache-2.0"}
|
||||
@@ -46,15 +46,7 @@ classifiers = [
|
||||
]
|
||||
dependencies = [
|
||||
"pillow>=10.0.0",
|
||||
# HEIC/AVIF pixel decode for the removal path (iPhone photos, modern exports):
|
||||
# OpenCV cannot decode these containers, so image_io.imread falls back to Pillow
|
||||
# and pillow-heif (bundled libheif, prebuilt wheels) registers the HEIF+AVIF
|
||||
# openers. The metadata path already handles them via a plugin-free binary scan;
|
||||
# this closes the same gap for the pixel path so `visible`/`all` work on them.
|
||||
"pillow-heif>=0.13.0",
|
||||
"piexif>=1.1.3",
|
||||
"numpy>=1.24.0",
|
||||
"opencv-python-headless>=4.8.0",
|
||||
"click>=8.0.0",
|
||||
"python-dotenv>=1.0.0",
|
||||
# Official C2PA reader (Content Authenticity Initiative, MIT/Apache-2.0). The
|
||||
@@ -64,16 +56,36 @@ dependencies = [
|
||||
# import is light (no torch/numpy) so it fits the dependency-light identify
|
||||
# host. Prebuilt wheels cover the full CI matrix (linux/macos/windows).
|
||||
"c2pa-python>=0.35.0",
|
||||
# Timestamped NUT bridge for processed video frames. Rawvideo pipes carry no
|
||||
# per-frame PTS, so this is what lets the existing system-ffmpeg encoder
|
||||
# preserve VFR timing without buffering a frame sequence on disk. PyAV 18
|
||||
# requires Python 3.11; the 16.x wheel line still covers Python 3.10.
|
||||
"av>=16,<17; python_version < '3.11'",
|
||||
"av>=18,<19; python_version >= '3.11'",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
gpu = [
|
||||
pixels = [
|
||||
"numpy>=1.24.0",
|
||||
"opencv-python-headless>=4.8.0",
|
||||
]
|
||||
# Optional HEIC/AVIF pixel decode. Metadata scanning handles these containers
|
||||
# without this plugin; combine `heif` with any pixel feature only when needed.
|
||||
heif = [
|
||||
"pillow-heif>=0.13.0",
|
||||
]
|
||||
visible = ["remove-ai-watermarks[pixels]"]
|
||||
# Video visible removal uses the shared pixel runtime. PyAV packetizes processed
|
||||
# VFR frames with explicit PTS before system ffmpeg encodes them. PyAV 18 requires
|
||||
# Python 3.11; the 16.x wheel line still covers Python 3.10.
|
||||
video = [
|
||||
"remove-ai-watermarks[visible]",
|
||||
"av>=16,<17; python_version < '3.11'",
|
||||
"av>=18,<19; python_version >= '3.11'",
|
||||
]
|
||||
# Open DWT-DCT watermarks used by Stable Diffusion / SDXL / FLUX. The in-tree
|
||||
# decoder avoids the upstream invisible-watermark package's mandatory torch and
|
||||
# non-headless OpenCV dependencies.
|
||||
detect = [
|
||||
"remove-ai-watermarks[pixels]",
|
||||
"PyWavelets>=1.1.1",
|
||||
]
|
||||
diffusion = [
|
||||
"remove-ai-watermarks[pixels]",
|
||||
"torch>=2.0.0",
|
||||
# The default PyPI torch wheel is a CPU/CUDA build. To drive an Intel GPU
|
||||
# (Arc / Data Center) via ``--device xpu`` you need an XPU-enabled torch
|
||||
@@ -81,7 +93,7 @@ gpu = [
|
||||
# XPU build). Install that build first, then this extra (torch is then
|
||||
# already satisfied and won't be re-pulled):
|
||||
# pip install torch --index-url https://download.pytorch.org/whl/xpu
|
||||
# pip install 'remove-ai-watermarks[gpu]'
|
||||
# pip install 'remove-ai-watermarks[diffusion]'
|
||||
# uv users can target the ``pytorch-xpu`` index declared under [tool.uv]:
|
||||
# uv pip install torch --index-url https://download.pytorch.org/whl/xpu
|
||||
"diffusers>=0.38.0",
|
||||
@@ -101,23 +113,13 @@ gpu = [
|
||||
]
|
||||
# Full two-stage high-fidelity profile: Qwen-Image-2512 Lightning + DiffSynth
|
||||
# Canny ControlNet for the frame, then SAM-masked Z-Image Turbo face repair.
|
||||
# CUDA-only and intentionally separate from the normal gpu extra because the
|
||||
# CUDA-only and intentionally separate from the normal diffusion extra because the
|
||||
# additional model stack and DiffSynth runtime are large.
|
||||
qwen-zimage = [
|
||||
"remove-ai-watermarks[gpu]",
|
||||
"remove-ai-watermarks[diffusion]",
|
||||
"diffsynth>=2.0.17,<3",
|
||||
"torchvision>=0.20.0",
|
||||
]
|
||||
# Open invisible-watermark (imwatermark) decoder for detecting the DWT-DCT
|
||||
# watermarks embedded by Stable Diffusion / SDXL / FLUX. Optional because it
|
||||
# pulls non-headless opencv AND torch (invisible-watermark declares torch a hard
|
||||
# dependency, and WatermarkDecoder eagerly imports rivaGan -> torch at import
|
||||
# time, so the dwtDct-only detect path still needs torch present even though it
|
||||
# never runs on GPU). So `detect` alone pulls torch -- no need to add `gpu` for
|
||||
# detection. identify() guards the import and skips the signal when absent.
|
||||
detect = [
|
||||
"invisible-watermark>=0.2.0",
|
||||
]
|
||||
# Adobe TrustMark decoder -- the open, keyless watermark behind Adobe Durable
|
||||
# Content Credentials (soft-binding alg ``com.adobe.trustmark.P``). Optional
|
||||
# because it pulls torch and downloads model weights on first use. identify()
|
||||
@@ -130,6 +132,7 @@ trustmark = [
|
||||
# cached by huggingface_hub; it is never bundled in this repo. The default cv2
|
||||
# eraser backend needs none of this.
|
||||
lama = [
|
||||
"remove-ai-watermarks[visible]",
|
||||
"onnxruntime>=1.16.0",
|
||||
"huggingface-hub>=0.20.0",
|
||||
]
|
||||
@@ -139,6 +142,7 @@ lama = [
|
||||
# memory-tight learned tier (vs big-LaMa's ~4.7 GB). Select it explicitly when
|
||||
# LaMa, the quality-first `auto` choice, is too large. Same runtime as `lama`.
|
||||
migan = [
|
||||
"remove-ai-watermarks[visible]",
|
||||
"onnxruntime>=1.16.0",
|
||||
"huggingface-hub>=0.20.0",
|
||||
]
|
||||
@@ -152,12 +156,16 @@ migan = [
|
||||
# weights are fetched with torch.hub (bundled with spandrel's torch), so no extra
|
||||
# download dependency is needed.
|
||||
esrgan = [
|
||||
"remove-ai-watermarks[pixels]",
|
||||
"spandrel>=0.3.0",
|
||||
]
|
||||
dev = [
|
||||
"remove-ai-watermarks[video]",
|
||||
"remove-ai-watermarks[detect]",
|
||||
"pytest>=8.0.0",
|
||||
"pytest-cov>=4.1.0",
|
||||
"pytest-xdist>=3.5.0",
|
||||
"packaging>=24.0",
|
||||
"ruff>=0.4.0",
|
||||
"pyright>=1.1.0",
|
||||
"invisible-watermark>=0.2.0",
|
||||
@@ -166,11 +174,11 @@ dev = [
|
||||
"uv-outdated>=0.1.0; python_version >= '3.12'",
|
||||
"uv-secure>=0.12.0; python_version >= '3.12'",
|
||||
]
|
||||
all = ["remove-ai-watermarks[gpu,detect,trustmark,lama,migan,dev]"]
|
||||
all = ["remove-ai-watermarks[video,heif,detect,trustmark,diffusion,qwen-zimage,lama,migan,esrgan]"]
|
||||
|
||||
# PyTorch Intel-GPU (XPU) wheel index. ``explicit = true`` keeps it inert for
|
||||
# the default CPU/CUDA install: uv consults it only when a torch install
|
||||
# explicitly targets it (see the ``gpu`` extra comment), so it does not alter
|
||||
# explicitly targets it (see the ``diffusion`` extra comment), so it does not alter
|
||||
# the locked CPU/CUDA resolution. Linux/Windows only -- no macOS XPU build.
|
||||
[[tool.uv.index]]
|
||||
name = "pytorch-xpu"
|
||||
|
||||
@@ -32,7 +32,7 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
|
||||
_warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*")
|
||||
|
||||
|
||||
__version__ = "0.20.2"
|
||||
__version__ = "0.22.0"
|
||||
|
||||
__all__ = [
|
||||
"__version__",
|
||||
|
||||
@@ -192,7 +192,7 @@ _upscaler_option = click.option(
|
||||
"--upscaler",
|
||||
type=click.Choice(["lanczos", "esrgan"]),
|
||||
default="lanczos",
|
||||
help="How to upscale a small input to the --min-resolution floor: lanczos (default, cv2, no deps) or "
|
||||
help="How to upscale a small input to the --min-resolution floor: lanczos (default, cv2, no model) or "
|
||||
"esrgan (Real-ESRGAN via the 'esrgan' extra; better detail, slower on CPU). Best for photo/texture "
|
||||
"content -- as a generic GAN with no face/glyph prior it can degrade faces (diffusion mitigates) and "
|
||||
"thin text, so lanczos stays the default. Falls back to lanczos if the extra is absent. Only when upscaling.",
|
||||
@@ -340,7 +340,7 @@ _visible_backend_option = click.option(
|
||||
default="auto",
|
||||
help="Fill backend for visible-mark removal (localize -> fill). auto: best available, "
|
||||
"LaMa > MI-GAN > cv2 (a learned backend needs the 'lama' or 'migan' extra; else cv2, "
|
||||
"with a warning). cv2: classical inpaint (no deps, smears texture). migan: MI-GAN ONNX "
|
||||
"with a warning). cv2: classical inpaint (no model download, smears texture). migan: MI-GAN ONNX "
|
||||
"(light, ~1 GB, the memory-tight pick). lama: big-LaMa ONNX (best quality, ~4.7 GB).",
|
||||
)
|
||||
|
||||
@@ -809,7 +809,7 @@ def _parse_region(spec: str) -> tuple[int, int, int, int]:
|
||||
"--backend",
|
||||
type=click.Choice(["cv2", "migan", "lama"]),
|
||||
default="cv2",
|
||||
help="Inpaint backend. cv2: instant, no deps. migan: light ONNX MI-GAN, ~1 GB RAM, "
|
||||
help="Inpaint backend. cv2: instant, no model download. migan: light ONNX MI-GAN, ~1 GB RAM, "
|
||||
"near-LaMa quality (extra 'migan'). lama: big-LaMa, best quality but ~4.7 GB RAM (extra 'lama').",
|
||||
)
|
||||
@click.option("--inpaint-method", type=click.Choice(["telea", "ns"]), default="telea", help="cv2 inpaint method.")
|
||||
@@ -950,13 +950,14 @@ def cmd_invisible(
|
||||
"""Remove invisible AI watermarks (SynthID, StableSignature, TreeRing).
|
||||
|
||||
Uses diffusion-based regeneration. Requires GPU for reasonable speed.
|
||||
Requires the [gpu] extra: pip install 'remove-ai-watermarks[gpu]'
|
||||
Requires the [diffusion] extra: pip install 'remove-ai-watermarks[diffusion]'
|
||||
"""
|
||||
from remove_ai_watermarks.invisible_engine import is_available as invisible_available
|
||||
|
||||
if not invisible_available():
|
||||
console.print(
|
||||
"Error: GPU dependencies not installed.\n Install them with: pip install 'remove-ai-watermarks[gpu]'"
|
||||
"Error: Diffusion dependencies not installed.\n"
|
||||
" Install them with: pip install 'remove-ai-watermarks[diffusion]'"
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
@@ -1707,7 +1708,7 @@ def cmd_all(
|
||||
synthid_skipped = True
|
||||
console.print(
|
||||
" Warning: Skipped - GPU dependencies not installed.\n"
|
||||
" Install them with: pip install 'remove-ai-watermarks[gpu]'"
|
||||
" Install them with: pip install 'remove-ai-watermarks[diffusion]'"
|
||||
)
|
||||
elif _should_skip_invisible_scrub(force, source):
|
||||
# No locally-detectable invisible watermark -> skip the destructive
|
||||
@@ -1813,7 +1814,7 @@ def cmd_all(
|
||||
" visible mark and metadata were stripped.\n"
|
||||
"\n"
|
||||
" Install the extra and rerun to remove it:\n"
|
||||
" pip install 'remove-ai-watermarks[gpu]'\n"
|
||||
" pip install 'remove-ai-watermarks[diffusion]'\n"
|
||||
" ====================================================================="
|
||||
)
|
||||
raise SystemExit(1)
|
||||
@@ -2175,7 +2176,7 @@ def cmd_batch(
|
||||
f"\n WARNING: the invisible (SynthID) watermark was NOT removed on "
|
||||
f"{synthid_skipped_count} image(s) -- the GPU dependencies are not installed, "
|
||||
f"so those outputs still carry the invisible watermark.\n"
|
||||
f" Install the extra and rerun: pip install 'remove-ai-watermarks[gpu]'"
|
||||
f" Install the extra and rerun: pip install 'remove-ai-watermarks[diffusion]'"
|
||||
)
|
||||
|
||||
# Non-zero exit so a wrapping service detects an incomplete/failed run (batch used
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""DWT-DCT decoder compatible with invisible-watermark's ``dwtDct`` path.
|
||||
|
||||
Derived from ShieldMnt/invisible-watermark ``imwatermark/maxDct.py`` (MIT),
|
||||
trimmed to the matrix path used by Stable Diffusion, SDXL, and FLUX.
|
||||
|
||||
Copyright (c) 2021 ShieldMnt
|
||||
|
||||
The complete upstream license is distributed in
|
||||
``licenses/invisible-watermark-MIT.txt``.
|
||||
"""
|
||||
|
||||
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportMissingTypeStubs=false
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pywt
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from numpy.typing import NDArray
|
||||
|
||||
_DEFAULT_SCALES = (0, 36, 36)
|
||||
_DEFAULT_BLOCK = 4
|
||||
|
||||
|
||||
class _DecodeMaxDct:
|
||||
"""Extract frequency-domain bits using the upstream matrix algorithm."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
wm_lengths: tuple[int, ...],
|
||||
scales: tuple[int, int, int] = _DEFAULT_SCALES,
|
||||
block: int = _DEFAULT_BLOCK,
|
||||
) -> None:
|
||||
self._wm_lengths = wm_lengths
|
||||
self._scales = scales
|
||||
self._block = block
|
||||
|
||||
def decode(self, bgr: NDArray[Any]) -> dict[int, NDArray[Any]]:
|
||||
row, col, _channels = bgr.shape
|
||||
yuv = cv2.cvtColor(bgr, cv2.COLOR_BGR2YUV)
|
||||
|
||||
scores_by_length = {wm_len: ([0] * wm_len, [0] * wm_len) for wm_len in self._wm_lengths}
|
||||
for channel in range(2):
|
||||
if self._scales[channel] <= 0:
|
||||
continue
|
||||
ca1, _detail = pywt.dwt2(yuv[: row // 4 * 4, : col // 4 * 4, channel], "haar")
|
||||
self._decode_frame(ca1, self._scales[channel], scores_by_length)
|
||||
|
||||
return {
|
||||
wm_len: np.asarray(sums) * 255 > np.asarray(counts) * 127
|
||||
for wm_len, (sums, counts) in scores_by_length.items()
|
||||
}
|
||||
|
||||
def _decode_frame(
|
||||
self,
|
||||
frame: NDArray[Any],
|
||||
scale: int,
|
||||
scores_by_length: dict[int, tuple[list[int], list[int]]],
|
||||
) -> None:
|
||||
row, col = frame.shape
|
||||
bit_index = 0
|
||||
for i in range(row // self._block):
|
||||
for j in range(col // self._block):
|
||||
block = frame[
|
||||
i * self._block : i * self._block + self._block,
|
||||
j * self._block : j * self._block + self._block,
|
||||
]
|
||||
inferred = self._infer_bit(block, scale)
|
||||
for wm_len, (sums, counts) in scores_by_length.items():
|
||||
bucket = bit_index % wm_len
|
||||
sums[bucket] += inferred
|
||||
counts[bucket] += 1
|
||||
bit_index += 1
|
||||
|
||||
def _infer_bit(self, block: NDArray[Any], scale: int) -> int:
|
||||
position = int(np.argmax(np.abs(block.flatten()[1:]))) + 1
|
||||
i, j = position // self._block, position % self._block
|
||||
value = abs(float(block[i][j]))
|
||||
return int((value % scale) > 0.5 * scale)
|
||||
|
||||
|
||||
def decode_dwt_dct(bgr: NDArray[Any], wm_len: int) -> NDArray[Any]:
|
||||
"""Extract ``wm_len`` watermark bits from a BGR image."""
|
||||
return decode_dwt_dct_lengths(bgr, (wm_len,))[wm_len]
|
||||
|
||||
|
||||
def decode_dwt_dct_lengths(bgr: NDArray[Any], wm_lengths: tuple[int, ...]) -> dict[int, NDArray[Any]]:
|
||||
"""Extract several watermark lengths with one DWT and block scan."""
|
||||
if bgr.size == 0 or min(bgr.shape[:2]) * max(bgr.shape[:2]) < 256 * 256:
|
||||
raise RuntimeError("image too small, should be larger than 256x256")
|
||||
if not wm_lengths or any(wm_len <= 0 for wm_len in wm_lengths):
|
||||
raise ValueError("watermark lengths must be positive")
|
||||
return _DecodeMaxDct(wm_lengths=tuple(dict.fromkeys(wm_lengths))).decode(bgr)
|
||||
@@ -19,10 +19,11 @@ never as "clean". See CLAUDE.md "SynthID detection is metadata-only".
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import itertools
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from remove_ai_watermarks.metadata import (
|
||||
AI_METADATA_KEYS,
|
||||
@@ -30,17 +31,27 @@ from remove_ai_watermarks.metadata import (
|
||||
IPTC_AI_FIELD_MARKERS,
|
||||
IPTC_AI_MARKERS,
|
||||
aigc_label,
|
||||
aigc_label_from_metadata,
|
||||
c2pa_cloud_manifest_in,
|
||||
c2pa_marker_in,
|
||||
exif_generator,
|
||||
generator_from_metadata,
|
||||
get_ai_metadata,
|
||||
huggingface_job,
|
||||
iptc_ai_system,
|
||||
iptc_ai_system_in,
|
||||
samsung_genai,
|
||||
samsung_genai_in,
|
||||
scan_head,
|
||||
xai_signature,
|
||||
xai_signature_pair,
|
||||
)
|
||||
from remove_ai_watermarks.noai.c2pa import (
|
||||
c2pa_info_from_manifest_store,
|
||||
cbor_text_after,
|
||||
extract_c2pa_info,
|
||||
soft_binding_vendors_in,
|
||||
)
|
||||
from remove_ai_watermarks.noai.c2pa import cbor_text_after, extract_c2pa_info, soft_binding_vendors_in
|
||||
from remove_ai_watermarks.noai.constants import (
|
||||
C2PA_AI_TOOLS,
|
||||
C2PA_AI_VENDORS,
|
||||
@@ -51,7 +62,6 @@ from remove_ai_watermarks.watermark_registry import GEMINI_SPARKLE_TRUST_CONF
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from numpy.typing import NDArray
|
||||
|
||||
@@ -132,6 +142,213 @@ class Signal:
|
||||
confidence: str # "high" | "medium"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProvenanceEvidence:
|
||||
"""Extracted metadata evidence used by provenance detection.
|
||||
|
||||
Extraction is intentionally separate from verdict logic so a caller can
|
||||
collect the file-backed evidence once and evaluate it without reopening the
|
||||
source. Pixel-backed visible and invisible watermark checks remain part of
|
||||
:func:`identify`.
|
||||
"""
|
||||
|
||||
path: Path
|
||||
c2pa_info: dict[str, Any]
|
||||
ai_metadata: dict[str, str]
|
||||
scan: bytes
|
||||
iptc_ai_system: str | None
|
||||
aigc_label: dict[str, str] | None
|
||||
exif_generator: str | None
|
||||
xai_signature: bool
|
||||
huggingface_job: str | None
|
||||
samsung_genai: int | None
|
||||
|
||||
|
||||
def _external_metadata(value: Any) -> tuple[list[tuple[str, Any]], bytes]:
|
||||
"""Index nested metadata and recover common encoded binary values in one pass."""
|
||||
pairs: list[tuple[str, Any]] = []
|
||||
parts: list[bytes] = []
|
||||
diagnostic_keys = {"error", "kind"}
|
||||
|
||||
def visit(item: Any) -> None:
|
||||
if isinstance(item, dict):
|
||||
mapping = cast("dict[object, Any]", item)
|
||||
for key, nested in mapping.items():
|
||||
key_text = str(key)
|
||||
pairs.append((key_text, nested))
|
||||
parts.append(key_text.encode("utf-8", "replace"))
|
||||
if key_text.lower() in diagnostic_keys:
|
||||
continue
|
||||
if isinstance(nested, str) and (key_text == "base64" or key_text.endswith("_base64")):
|
||||
encoded = nested.split("...TRUNCATED", 1)[0]
|
||||
try:
|
||||
parts.append(base64.b64decode(encoded, validate=True))
|
||||
continue
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
visit(nested)
|
||||
elif isinstance(item, (list, tuple)):
|
||||
sequence = cast("list[Any] | tuple[Any, ...]", item)
|
||||
for nested in sequence:
|
||||
visit(nested)
|
||||
elif isinstance(item, bytes):
|
||||
parts.append(item)
|
||||
elif isinstance(item, str):
|
||||
if item.startswith("hex:"):
|
||||
try:
|
||||
parts.append(bytes.fromhex(item[4:]))
|
||||
return
|
||||
except ValueError:
|
||||
pass
|
||||
parts.append(item.encode("utf-8", "replace"))
|
||||
elif item is not None:
|
||||
parts.append(str(item).encode("utf-8", "replace"))
|
||||
|
||||
visit(value)
|
||||
return pairs, b"\n".join(parts)
|
||||
|
||||
|
||||
def _external_text(value: Any) -> str:
|
||||
if isinstance(value, bytes):
|
||||
return value.decode("latin-1", "replace").strip()
|
||||
if not isinstance(value, str):
|
||||
return str(value).strip()
|
||||
if value.startswith("hex:"):
|
||||
try:
|
||||
return bytes.fromhex(value[4:]).decode("latin-1", "replace").strip()
|
||||
except ValueError:
|
||||
pass
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _external_exif_generator(pairs: list[tuple[str, Any]], scan: bytes) -> str | None:
|
||||
candidate_keys = {
|
||||
"software",
|
||||
"make",
|
||||
"artist",
|
||||
"imagedescription",
|
||||
"source",
|
||||
"title",
|
||||
"description",
|
||||
"creatortool",
|
||||
}
|
||||
candidates = [
|
||||
_external_text(value)
|
||||
for key, value in pairs
|
||||
if key.lower().removeprefix("info:") in candidate_keys and isinstance(value, (str, bytes))
|
||||
]
|
||||
return generator_from_metadata(candidates, scan)
|
||||
|
||||
|
||||
def evidence_from_metadata_record(
|
||||
record: dict[str, Any], *, path: Path, c2pa_manifest_store: str | dict[str, Any] | None = None
|
||||
) -> ProvenanceEvidence:
|
||||
"""Normalize an externally collected metadata record into provenance evidence.
|
||||
|
||||
The record may contain arbitrary nested dictionaries and lists. Text, bytes,
|
||||
hexadecimal values prefixed with ``hex:``, and fields named ``base64`` or
|
||||
ending in ``_base64`` are included in the shared byte scan. No source file is
|
||||
opened.
|
||||
"""
|
||||
pairs, scan = _external_metadata(record)
|
||||
store = c2pa_manifest_store
|
||||
if store is None:
|
||||
candidate = record.get("c2pa_store")
|
||||
store = (
|
||||
cast("dict[str, Any]", candidate)
|
||||
if isinstance(candidate, dict)
|
||||
else candidate
|
||||
if isinstance(candidate, str)
|
||||
else None
|
||||
)
|
||||
c2pa_info = c2pa_info_from_manifest_store(store) if store is not None else {}
|
||||
|
||||
ai_metadata: dict[str, str] = {}
|
||||
pil_info = record.get("pil")
|
||||
pil_pairs = cast("dict[str, Any]", pil_info).items() if isinstance(pil_info, dict) else ()
|
||||
for key, value in pil_pairs:
|
||||
normalized_key = key.lower().removeprefix("info:")
|
||||
if normalized_key not in AI_METADATA_KEYS or isinstance(value, (dict, list, tuple)):
|
||||
continue
|
||||
text = value.decode("utf-8", "replace") if isinstance(value, bytes) else str(value)
|
||||
ai_metadata.setdefault(normalized_key, text[:200] + ("…" if len(text) > 200 else ""))
|
||||
for key, value in pairs:
|
||||
if key != "text" or not isinstance(value, str) or "\x00" not in value:
|
||||
continue
|
||||
metadata_key, metadata_value = value.split("\x00", 1)
|
||||
normalized_key = metadata_key.lower()
|
||||
if normalized_key in AI_METADATA_KEYS:
|
||||
ai_metadata.setdefault(
|
||||
normalized_key,
|
||||
metadata_value[:200] + ("…" if len(metadata_value) > 200 else ""),
|
||||
)
|
||||
for key in (
|
||||
"c2pa_manifest",
|
||||
"claim_generator",
|
||||
"c2pa_spec",
|
||||
"issuer",
|
||||
"source_type",
|
||||
"actions",
|
||||
"synthid_watermark",
|
||||
"soft_binding",
|
||||
):
|
||||
if key in c2pa_info:
|
||||
ai_metadata.setdefault(key, str(c2pa_info[key]))
|
||||
|
||||
iptc_system = iptc_ai_system_in(scan)
|
||||
|
||||
values_by_key: dict[str, str] = {}
|
||||
for key, value in pairs:
|
||||
if isinstance(value, (bytes, str)):
|
||||
values_by_key.setdefault(key.lower(), _external_text(value))
|
||||
description = values_by_key.get("imagedescription", "")
|
||||
artist = values_by_key.get("artist", "")
|
||||
xai = xai_signature_pair(description, artist)
|
||||
|
||||
hf_job = next(
|
||||
(
|
||||
str(value).strip()
|
||||
for key, value in pairs
|
||||
if key.lower().removeprefix("info:") == "hf-job-id" and str(value).strip()
|
||||
),
|
||||
None,
|
||||
)
|
||||
samsung = samsung_genai_in(scan)
|
||||
|
||||
aigc_candidates = tuple(
|
||||
value for key, value in pairs if key.lower().removeprefix("info:") == "aigc" and isinstance(value, str)
|
||||
)
|
||||
aigc = aigc_label_from_metadata(scan, aigc_candidates)
|
||||
exif_gen = _external_exif_generator(pairs, scan)
|
||||
if aigc is not None:
|
||||
producer = aigc.get("ContentProducer", "")
|
||||
ai_metadata.setdefault(
|
||||
"aigc_label",
|
||||
f"China AIGC label (TC260){f'; producer {producer}' if producer else ''}",
|
||||
)
|
||||
if xai:
|
||||
ai_metadata.setdefault("xai_signature", "xAI/Grok EXIF signature (Artist UUID + Signature blob)")
|
||||
if iptc_system:
|
||||
ai_metadata.setdefault("ai_system", f"IPTC 2025.1 AI disclosure ({iptc_system})")
|
||||
if hf_job:
|
||||
ai_metadata.setdefault("huggingface_job", f"HuggingFace-hosted job ({hf_job})")
|
||||
if samsung is not None:
|
||||
ai_metadata.setdefault("samsung_genai", f"Samsung Galaxy AI editing marker (genAIType={samsung})")
|
||||
|
||||
return ProvenanceEvidence(
|
||||
path=path,
|
||||
c2pa_info=c2pa_info,
|
||||
ai_metadata=ai_metadata,
|
||||
scan=scan,
|
||||
iptc_ai_system=iptc_system,
|
||||
aigc_label=aigc,
|
||||
exif_generator=exif_gen,
|
||||
xai_signature=xai,
|
||||
huggingface_job=hf_job,
|
||||
samsung_genai=samsung,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProvenanceReport:
|
||||
"""Aggregated provenance verdict for one image."""
|
||||
@@ -166,6 +383,22 @@ class ProvenanceReport:
|
||||
integrity_clashes: list[str] = field(default_factory=list[str])
|
||||
|
||||
|
||||
def extract_provenance_evidence(image_path: Path) -> ProvenanceEvidence:
|
||||
"""Read all file-backed metadata needed by provenance verdict logic once."""
|
||||
return ProvenanceEvidence(
|
||||
path=image_path,
|
||||
c2pa_info=extract_c2pa_info(image_path),
|
||||
ai_metadata=get_ai_metadata(image_path),
|
||||
scan=scan_head(image_path, _SCAN_BYTES),
|
||||
iptc_ai_system=iptc_ai_system(image_path),
|
||||
aigc_label=aigc_label(image_path),
|
||||
exif_generator=exif_generator(image_path),
|
||||
xai_signature=xai_signature(image_path),
|
||||
huggingface_job=huggingface_job(image_path),
|
||||
samsung_genai=samsung_genai(image_path),
|
||||
)
|
||||
|
||||
|
||||
def _issuers_in(data: bytes) -> list[str]:
|
||||
"""C2PA issuer names whose signature byte appears in ``data`` (binary scan)."""
|
||||
return sorted({name for sig, name in C2PA_ISSUERS.items() if sig in data})
|
||||
@@ -494,8 +727,8 @@ def _visible_text_marks(image_path: Path, *, image: NDArray[Any] | None = None)
|
||||
def _invisible_watermark(image_path: Path) -> str | None:
|
||||
"""Open invisible-watermark scheme name (SD/SDXL/FLUX) or None.
|
||||
|
||||
Optional: needs the imwatermark decoder (extra ``detect``). Returns None if
|
||||
it is not installed or no known watermark decodes.
|
||||
Optional: needs the torch-free DWT-DCT decoder (extra ``detect``). Returns
|
||||
None if it is not installed or no known watermark decodes.
|
||||
"""
|
||||
from remove_ai_watermarks.invisible_watermark import detect_invisible_watermark
|
||||
|
||||
@@ -533,6 +766,9 @@ def _collect_visible_signals(
|
||||
image = imread(image_path)
|
||||
except Exception as exc: # cv2 missing - detectors fall back / no-op
|
||||
logger.debug("visible-mark decode unavailable: %s", exc)
|
||||
return platform
|
||||
if image is None:
|
||||
return platform
|
||||
|
||||
sparkle_conf = _visible_sparkle(image_path, image=image)
|
||||
if sparkle_conf is not None and sparkle_conf >= _SPARKLE_THRESHOLD:
|
||||
@@ -549,29 +785,25 @@ def _collect_visible_signals(
|
||||
return platform
|
||||
|
||||
|
||||
def identify(image_path: Path, *, check_visible: bool = True, check_invisible: bool = True) -> ProvenanceReport:
|
||||
"""Identify an image's origin platform and watermark inventory.
|
||||
def _identify_from_evidence(
|
||||
evidence: ProvenanceEvidence,
|
||||
*,
|
||||
image_path: Path | None = None,
|
||||
check_visible: bool = False,
|
||||
check_invisible: bool = False,
|
||||
) -> ProvenanceReport:
|
||||
"""Build a provenance verdict from extracted evidence.
|
||||
|
||||
Args:
|
||||
image_path: Path to the image (PNG, JPEG, WebP, or ISOBMFF container).
|
||||
check_visible: Also run the registered visible-mark detectors through cv2.
|
||||
Set False for a pure-metadata, dependency-light scan.
|
||||
check_invisible: Also decode open invisible watermarks (SD/SDXL/FLUX) via
|
||||
the optional imwatermark library. No-op when it is not installed.
|
||||
|
||||
Returns:
|
||||
A :class:`ProvenanceReport`. ``is_ai_generated`` is True when any AI
|
||||
signal is found and None (unknown) when none is -- it is never asserted
|
||||
False, because stripped metadata leaves no local proof of a clean origin.
|
||||
``image_path`` is supplied only by :func:`identify` for optional pixel
|
||||
detectors. Metadata-only callers leave it unset and never reopen the source.
|
||||
"""
|
||||
info = extract_c2pa_info(image_path) # PNG-structured; {} for other formats
|
||||
meta = get_ai_metadata(image_path) # PNG text + EXIF + C2PA fields + synthid
|
||||
if (check_visible or check_invisible) and image_path is None:
|
||||
raise ValueError("Pixel-backed checks require image_path")
|
||||
pixel_path = image_path
|
||||
|
||||
# First MB covers C2PA (PNG caBX, JPEG APP11, AVIF/HEIF/JXL uuid box) and
|
||||
# IPTC markers for the non-PNG path where extract_c2pa_info returns {}.
|
||||
# scan_head also seeks out late ISOBMFF provenance boxes (manifest after a
|
||||
# large mdat in a streaming MP4) that a fixed first-MB read would miss.
|
||||
head = scan_head(image_path, _SCAN_BYTES)
|
||||
info = evidence.c2pa_info
|
||||
meta = evidence.ai_metadata
|
||||
head = evidence.scan
|
||||
|
||||
signals: list[Signal] = []
|
||||
watermarks: list[str] = []
|
||||
@@ -688,7 +920,7 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
|
||||
# ── IPTC 2025.1 AI-disclosure fields (Iptc4xmpExt:AISystemUsed etc.) ─
|
||||
iptc_ai = any(m in head for m in IPTC_AI_FIELD_MARKERS)
|
||||
if iptc_ai:
|
||||
system = iptc_ai_system(image_path)
|
||||
system = evidence.iptc_ai_system
|
||||
named = bool(system) and system != "fields present"
|
||||
signals.append(
|
||||
Signal("iptc_ai_system", f"IPTC AI disclosure ({system})" if named else "IPTC AI disclosure fields", "high")
|
||||
@@ -704,7 +936,7 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
|
||||
# URL, present in XMP and as a laundering tell even when the JSON payload is
|
||||
# truncated) OR the parsed label, which additionally catches the raw-JSON
|
||||
# PNG ``AIGC`` tEXt chunk that carries no namespaced marker at all.
|
||||
aigc_data = aigc_label(image_path)
|
||||
aigc_data = evidence.aigc_label
|
||||
aigc = aigc_data is not None or any(m in head for m in AIGC_MARKERS)
|
||||
if aigc:
|
||||
producer = (aigc_data or {}).get("ContentProducer", "")
|
||||
@@ -725,7 +957,7 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
|
||||
# ── EXIF Software / XMP CreatorTool / PNG-text generator (cross-format) ─
|
||||
# Catches a generator tag (incl. inside AVIF/HEIF/JXL and PNG text chunks)
|
||||
# when there is no C2PA.
|
||||
if generator_tag := exif_generator(image_path):
|
||||
if generator_tag := evidence.exif_generator:
|
||||
signals.append(Signal("exif_generator", f"Embedded generator tag: {generator_tag}", "high"))
|
||||
watermarks.append(f"Embedded generator tag: {generator_tag}")
|
||||
if platform is None:
|
||||
@@ -737,7 +969,7 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
|
||||
# Grok's only provenance signal: EXIF ImageDescription "Signature: <base64>"
|
||||
# + a UUID Artist. Distinct from exif_generator (which matches generator
|
||||
# tokens); verified stable across 3 generations. See CLAUDE.md.
|
||||
if xai_signature(image_path):
|
||||
if evidence.xai_signature:
|
||||
signals.append(Signal("xai_signature", "EXIF Signature blob + UUID Artist", "high"))
|
||||
watermarks.append("xAI/Grok EXIF signature")
|
||||
if platform is None:
|
||||
@@ -748,7 +980,7 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
|
||||
# Marks the hosting job, not a model -- medium confidence (commonly diffusion
|
||||
# output). Like the visible sparkle, it lifts an otherwise-Unknown verdict to
|
||||
# a tentative AI, but never overrides a high-confidence metadata signal.
|
||||
hf_job = huggingface_job(image_path)
|
||||
hf_job = evidence.huggingface_job
|
||||
if hf_job:
|
||||
signals.append(Signal("hf_job", f"HuggingFace job {hf_job}", "medium"))
|
||||
watermarks.append("HuggingFace-hosted job (hf-job-id)")
|
||||
@@ -764,7 +996,7 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
|
||||
# verdict, but the field is undocumented, so it never overrides a high-
|
||||
# confidence signal. The platform is usually already "Samsung Galaxy" via the
|
||||
# signer-token scan; the fallback covers a future file without the cert org.
|
||||
samsung_genai_type = samsung_genai(image_path)
|
||||
samsung_genai_type = evidence.samsung_genai
|
||||
if samsung_genai_type is not None:
|
||||
signals.append(Signal("samsung_genai", f"Samsung genAIType={samsung_genai_type}", "medium"))
|
||||
watermarks.append("Samsung Galaxy AI editing marker (genAIType)")
|
||||
@@ -774,7 +1006,7 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
|
||||
|
||||
# ── Open invisible watermark (SD / SDXL / FLUX, dwtDct) ──────────
|
||||
# Public decoder, no key -- a definitive embedded signal on pristine files.
|
||||
if check_invisible and (scheme := _invisible_watermark(image_path)) is not None:
|
||||
if check_invisible and pixel_path is not None and (scheme := _invisible_watermark(pixel_path)) is not None:
|
||||
signals.append(Signal("invisible_watermark", scheme, "high"))
|
||||
watermarks.append(f"Open invisible watermark: {scheme}")
|
||||
caveats.append(_INVISIBLE_WM_CAVEAT)
|
||||
@@ -785,7 +1017,7 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
|
||||
# The watermark behind Adobe Durable Content Credentials. Decoded locally,
|
||||
# but it binds provenance for human-authored content too, so it enriches the
|
||||
# watermark inventory without by itself asserting AI origin.
|
||||
if check_invisible and (tm_scheme := _trustmark(image_path)) is not None:
|
||||
if check_invisible and pixel_path is not None and (tm_scheme := _trustmark(pixel_path)) is not None:
|
||||
signals.append(Signal("trustmark", tm_scheme, "high"))
|
||||
watermarks.append(f"Adobe TrustMark invisible watermark ({tm_scheme})")
|
||||
if platform is None:
|
||||
@@ -806,8 +1038,8 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
|
||||
or xai_sig
|
||||
)
|
||||
|
||||
if check_visible:
|
||||
platform = _collect_visible_signals(image_path, signals, watermarks, platform)
|
||||
if check_visible and pixel_path is not None:
|
||||
platform = _collect_visible_signals(pixel_path, signals, watermarks, platform)
|
||||
|
||||
visible_only = any(s.name.startswith("visible_") for s in signals) and not ai_from_metadata
|
||||
hf_only = bool(hf_job) and not ai_from_metadata
|
||||
@@ -831,7 +1063,7 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
|
||||
caveats = list(dict.fromkeys(caveats))
|
||||
|
||||
return ProvenanceReport(
|
||||
path=image_path,
|
||||
path=evidence.path,
|
||||
is_ai_generated=is_ai,
|
||||
platform=platform,
|
||||
confidence=confidence,
|
||||
@@ -846,6 +1078,45 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
|
||||
)
|
||||
|
||||
|
||||
def identify_from_evidence(evidence: ProvenanceEvidence) -> ProvenanceReport:
|
||||
"""Build a metadata-only provenance verdict without reopening the source."""
|
||||
return _identify_from_evidence(evidence)
|
||||
|
||||
|
||||
def identify(
|
||||
image_path: Path,
|
||||
*,
|
||||
check_visible: bool = True,
|
||||
check_invisible: bool = True,
|
||||
) -> ProvenanceReport:
|
||||
"""Identify an image's origin platform and watermark inventory.
|
||||
|
||||
Args:
|
||||
image_path: Path to the image (PNG, JPEG, WebP, or ISOBMFF container).
|
||||
check_visible: Also run the registered visible-mark detectors through cv2.
|
||||
Set False for a metadata-only, dependency-light scan.
|
||||
check_invisible: Also decode optional open invisible watermarks
|
||||
(SD/SDXL/FLUX). No-op when the decoder extra is not installed.
|
||||
|
||||
File-backed metadata extraction runs first. The extracted evidence is then
|
||||
evaluated independently, followed by the optional pixel-backed visible and
|
||||
invisible watermark checks.
|
||||
|
||||
Returns:
|
||||
A :class:`ProvenanceReport`. ``is_ai_generated`` is True when any AI
|
||||
signal is found and None (unknown) when none is found. It is never
|
||||
asserted False because stripped metadata leaves no local proof of a
|
||||
clean origin.
|
||||
"""
|
||||
evidence = extract_provenance_evidence(image_path)
|
||||
return _identify_from_evidence(
|
||||
evidence,
|
||||
image_path=image_path,
|
||||
check_visible=check_visible,
|
||||
check_invisible=check_invisible,
|
||||
)
|
||||
|
||||
|
||||
def has_invisible_target(image_path: Path) -> bool:
|
||||
"""True when a locally-detectable invisible/metadata AI signal is present.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Wraps the vendored noai-watermark code for removing invisible AI watermarks
|
||||
(SynthID, StableSignature, TreeRing) via diffusion-based regeneration.
|
||||
|
||||
This module requires the 'gpu' extra dependencies:
|
||||
uv pip install 'remove-ai-watermarks[gpu]'
|
||||
uv pip install 'remove-ai-watermarks[diffusion]'
|
||||
"""
|
||||
|
||||
# cv2/torch boundary: this engine wraps cv2 (resize/imwrite/cvtColor) and the
|
||||
@@ -226,7 +226,7 @@ class InvisibleEngine:
|
||||
input size, so this is a transparent quality boost; it adds time
|
||||
and memory on small inputs. Ignored on a min > max misconfig.
|
||||
upscaler: How to upscale a small input to the ``min_resolution`` floor:
|
||||
``"lanczos"`` (default, cv2, no deps) or ``"esrgan"`` (Real-ESRGAN
|
||||
``"lanczos"`` (default, cv2, no model download) or ``"esrgan"`` (Real-ESRGAN
|
||||
via the ``esrgan`` extra). Only applies when UPscaling (the floor
|
||||
case); a ``max_resolution`` downscale always uses Lanczos. Falls back
|
||||
to Lanczos if the extra is absent.
|
||||
|
||||
@@ -14,21 +14,20 @@ source:
|
||||
|
||||
The watermark is fragile: it does NOT survive JPEG re-encoding or resizing
|
||||
(verified -- gone after JPEG q90), so detection works only on pristine PNG
|
||||
originals. Absence is never proof. Requires the optional ``invisible-watermark``
|
||||
package (extra: ``detect``); ``detect_invisible_watermark`` returns None when it
|
||||
is not installed.
|
||||
originals. Absence is never proof. Requires the optional ``detect`` extra;
|
||||
``detect_invisible_watermark`` returns None when it is not installed.
|
||||
"""
|
||||
|
||||
# imwatermark ships no type stubs (like cv2); its decoder returns are Unknown.
|
||||
# Relax the untyped-library diagnostics for this thin wrapper module only.
|
||||
# The optional numeric libraries do not provide complete types for this path.
|
||||
# pyright: reportMissingTypeStubs=false, reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, cast
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -49,10 +48,10 @@ _MATCH_SD1_FRAC = 0.92 # fraction of the 136 string bits that must match
|
||||
|
||||
|
||||
def is_available() -> bool:
|
||||
"""True if the optional imwatermark decoder is installed."""
|
||||
"""True when all dependencies for the optional DWT-DCT decoder exist."""
|
||||
from .optional_deps import module_available
|
||||
|
||||
return module_available("imwatermark")
|
||||
return module_available("cv2", "numpy", "pywt")
|
||||
|
||||
|
||||
def _bits_match(value: int, ref: int, width: int = 48) -> int:
|
||||
@@ -68,6 +67,20 @@ def _bytes_match_frac(a: bytes, b: bytes) -> float:
|
||||
return 1.0 - diff / (8 * len(b))
|
||||
|
||||
|
||||
def _bits_to_int(bits: Iterable[object]) -> int:
|
||||
value = 0
|
||||
for bit in bits:
|
||||
value = (value << 1) | int(bool(bit))
|
||||
return value
|
||||
|
||||
|
||||
def _bits_to_bytes(bits: Iterable[object], nbytes: int) -> bytes:
|
||||
import numpy as np
|
||||
|
||||
packed = np.packbits([int(bool(bit)) for bit in bits])
|
||||
return bytes(int(value) for value in packed[:nbytes])
|
||||
|
||||
|
||||
def detect_invisible_watermark(image_path: Path) -> str | None:
|
||||
"""Return the embedding scheme name if a known open watermark is decoded.
|
||||
|
||||
@@ -78,32 +91,26 @@ def detect_invisible_watermark(image_path: Path) -> str | None:
|
||||
"""
|
||||
if not is_available():
|
||||
return None
|
||||
from imwatermark import WatermarkDecoder
|
||||
|
||||
from remove_ai_watermarks import image_io
|
||||
from remove_ai_watermarks.dwt_dct import decode_dwt_dct_lengths
|
||||
|
||||
img = image_io.imread(image_path)
|
||||
if img is None:
|
||||
return None
|
||||
|
||||
# 48-bit fixed-message watermarks (SDXL, FLUX.2).
|
||||
try:
|
||||
bits = WatermarkDecoder("bits", 48).decode(img, "dwtDct")
|
||||
value = 0
|
||||
for bit in bits:
|
||||
value = (value << 1) | (1 if bit else 0)
|
||||
for name, ref in _BITS_48.items():
|
||||
if _bits_match(value, ref) >= _MATCH_48:
|
||||
return name
|
||||
decoded = decode_dwt_dct_lengths(img, (48, 8 * len(_SD1_STRING)))
|
||||
except Exception as exc: # decode can fail on tiny images
|
||||
logger.debug("48-bit watermark decode failed for %s: %s", image_path, exc)
|
||||
logger.debug("watermark decode failed for %s: %s", image_path, exc)
|
||||
return None
|
||||
|
||||
# 136-bit default string watermark (SD 1.x / 2.x).
|
||||
try:
|
||||
raw = cast("bytes", WatermarkDecoder("bytes", 8 * len(_SD1_STRING)).decode(img, "dwtDct"))
|
||||
if _bytes_match_frac(raw, _SD1_STRING) >= _MATCH_SD1_FRAC:
|
||||
return "Stable Diffusion 1.x / 2.x"
|
||||
except Exception as exc:
|
||||
logger.debug("string watermark decode failed for %s: %s", image_path, exc)
|
||||
value = _bits_to_int(decoded[48])
|
||||
for name, ref in _BITS_48.items():
|
||||
if _bits_match(value, ref) >= _MATCH_48:
|
||||
return name
|
||||
|
||||
raw = _bits_to_bytes(decoded[8 * len(_SD1_STRING)], len(_SD1_STRING))
|
||||
if _bytes_match_frac(raw, _SD1_STRING) >= _MATCH_SD1_FRAC:
|
||||
return "Stable Diffusion 1.x / 2.x"
|
||||
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 ShieldMnt
|
||||
|
||||
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.
|
||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import functools
|
||||
import itertools
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
@@ -17,6 +18,7 @@ import struct
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -374,6 +376,53 @@ def has_ai_metadata(image_path: Path) -> bool:
|
||||
return xai_signature(image_path)
|
||||
|
||||
|
||||
def aigc_label_from_metadata(data: bytes, candidates: tuple[str, ...] = ()) -> dict[str, str] | None:
|
||||
"""Parse a China TC260 AI-labeling block from already collected metadata."""
|
||||
import html
|
||||
import json
|
||||
from typing import cast
|
||||
|
||||
def _parse(text: str, *, require_tc260_field: bool) -> dict[str, str] | None:
|
||||
if require_tc260_field:
|
||||
return parse_tc260_aigc_json(text.encode("utf-8"))
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except ValueError:
|
||||
return None
|
||||
if not isinstance(parsed, dict):
|
||||
return None
|
||||
return {str(k): str(v) for k, v in cast("dict[object, object]", parsed).items()}
|
||||
|
||||
for candidate in candidates:
|
||||
if result := _parse(candidate, require_tc260_field=True):
|
||||
return result
|
||||
|
||||
match = re.search(
|
||||
rb'<TC260:AIGC>(.*?)</TC260:AIGC>|TC260:AIGC\s*=\s*"(.*?)"',
|
||||
data,
|
||||
re.DOTALL,
|
||||
)
|
||||
if match:
|
||||
body = match.group(1) if match.group(1) is not None else match.group(2)
|
||||
return _parse(html.unescape(body.decode("utf-8", "replace")), require_tc260_field=False)
|
||||
|
||||
text = data.decode("latin-1")
|
||||
for needle in ('"AIGC"', "AIGC{"):
|
||||
start = text.find(needle)
|
||||
if start == -1:
|
||||
continue
|
||||
brace = text.find("{", start)
|
||||
if brace == -1:
|
||||
continue
|
||||
try:
|
||||
_, end = json.JSONDecoder().raw_decode(text, brace)
|
||||
except ValueError:
|
||||
continue
|
||||
if result := _parse(text[brace:end], require_tc260_field=True):
|
||||
return result
|
||||
return None
|
||||
|
||||
|
||||
def aigc_label(image_path: Path) -> dict[str, str] | None:
|
||||
"""Parse a China TC260 AI-labeling block, if present.
|
||||
|
||||
@@ -383,6 +432,7 @@ def aigc_label(image_path: Path) -> dict[str, str] | None:
|
||||
(as written by Doubao / ByteDance), read via PIL;
|
||||
- a native MP4/MOV ``AIGC`` key in ``moov.udta.meta.keys`` whose matching
|
||||
``ilst`` item carries the raw JSON object;
|
||||
- a native MKV/WebM ``AIGC`` simple tag carrying the raw JSON object;
|
||||
- a native AVI ``LIST/INFO/AIGC`` chunk or FLV
|
||||
``script.onMetaData.AIGC`` string carrying the raw JSON object;
|
||||
- an XMP ``<TC260:AIGC>{...}</TC260:AIGC>`` block (HTML-entity encoded text),
|
||||
@@ -400,21 +450,6 @@ def aigc_label(image_path: Path) -> dict[str, str] | None:
|
||||
if they carry at least one known TC260 field (``TC260_AIGC_FIELDS``); the
|
||||
namespaced XMP element is unambiguous, so any JSON object is accepted.
|
||||
"""
|
||||
import html
|
||||
|
||||
def _parse(text: str, *, require_tc260_field: bool) -> dict[str, str] | None:
|
||||
if require_tc260_field:
|
||||
return parse_tc260_aigc_json(text.encode("utf-8"))
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except ValueError:
|
||||
return None
|
||||
if not isinstance(parsed, dict):
|
||||
return None
|
||||
return {str(k): str(v) for k, v in cast("dict[object, object]", parsed).items()}
|
||||
|
||||
# PNG tEXt chunk keyed "AIGC" with raw JSON (Doubao and other China gens).
|
||||
# The key is generic, so require a TC260 field to avoid a false positive.
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
@@ -423,7 +458,8 @@ def aigc_label(image_path: Path) -> dict[str, str] | None:
|
||||
except Exception as exc:
|
||||
logger.debug("PIL could not open %s for AIGC chunk scan: %s", image_path, exc)
|
||||
value = None
|
||||
if isinstance(value, str) and (result := _parse(value, require_tc260_field=True)):
|
||||
|
||||
if isinstance(value, str) and (result := aigc_label_from_metadata(b"", (value,))):
|
||||
return result
|
||||
|
||||
# Native MP4/MOV TC260 metadata (TC260-PG-20257A): the ``AIGC`` key lives
|
||||
@@ -432,18 +468,18 @@ def aigc_label(image_path: Path) -> dict[str, str] | None:
|
||||
# ``mdat`` is found without loading or scanning the media payload.
|
||||
from remove_ai_watermarks.noai.isobmff import tc260_aigc_payloads
|
||||
|
||||
for payload in tc260_aigc_payloads(image_path):
|
||||
if result := _parse(payload.decode("utf-8", "replace"), require_tc260_field=True):
|
||||
return result
|
||||
isobmff_candidates = tuple(payload.decode("utf-8", "replace") for payload in tc260_aigc_payloads(image_path))
|
||||
if result := aigc_label_from_metadata(b"", isobmff_candidates):
|
||||
return result
|
||||
|
||||
# Native MKV/WebM TC260 metadata: ``Segment.Tags.Tag.SimpleTag`` carries
|
||||
# ``TagName=AIGC`` and the raw JSON in ``TagString``. The EBML walker seeks
|
||||
# over clusters and reads only bounded metadata values.
|
||||
from remove_ai_watermarks.noai.ebml import tc260_aigc_payloads as ebml_tc260_aigc_payloads
|
||||
|
||||
for payload in ebml_tc260_aigc_payloads(image_path):
|
||||
if result := _parse(payload.decode("utf-8", "replace"), require_tc260_field=True):
|
||||
return result
|
||||
ebml_candidates = tuple(payload.decode("utf-8", "replace") for payload in ebml_tc260_aigc_payloads(image_path))
|
||||
if result := aigc_label_from_metadata(b"", ebml_candidates):
|
||||
return result
|
||||
|
||||
# Native AVI and FLV TC260 metadata. Both readers walk their container
|
||||
# structures and skip media payloads instead of relying on a raw substring
|
||||
@@ -457,51 +493,12 @@ def aigc_label(image_path: Path) -> dict[str, str] | None:
|
||||
from remove_ai_watermarks.noai.flv import tc260_aigc_payloads as flv_tc260_aigc_payloads
|
||||
|
||||
legacy_payloads = flv_tc260_aigc_payloads(image_path)
|
||||
for payload in legacy_payloads:
|
||||
if result := _parse(payload.decode("utf-8", "replace"), require_tc260_field=True):
|
||||
return result
|
||||
legacy_candidates = tuple(payload.decode("utf-8", "replace") for payload in legacy_payloads)
|
||||
if result := aigc_label_from_metadata(b"", legacy_candidates):
|
||||
return result
|
||||
|
||||
# XMP TC260:AIGC, namespaced (unambiguous) in either serialization RDF allows:
|
||||
# an element <TC260:AIGC>{...}</TC260:AIGC> or an attribute TC260:AIGC="{...}"
|
||||
# (the attribute form is what PicWish writes). Both are HTML-entity encoded.
|
||||
data = scan_head(image_path)
|
||||
match = re.search(
|
||||
rb'<TC260:AIGC>(.*?)</TC260:AIGC>|TC260:AIGC\s*=\s*"(.*?)"',
|
||||
data,
|
||||
re.DOTALL,
|
||||
)
|
||||
if match:
|
||||
body = match.group(1) if match.group(1) is not None else match.group(2)
|
||||
return _parse(html.unescape(body.decode("utf-8", "replace")), require_tc260_field=False)
|
||||
|
||||
# Generic raw-JSON forms the PNG-chunk and XMP paths above both miss, each
|
||||
# gated on a TC260 field: the ``"AIGC":{...}`` key wrapper (as written into
|
||||
# JPEG EXIF UserComment) and the bare ``AIGC{...}`` blob (the label glued
|
||||
# straight to its JSON, no key wrapper, in a JPEG APP segment near the JFIF
|
||||
# header). `raw_decode` brace-matches the inner object (respecting nested
|
||||
# braces / quoted strings); `_parse` applies the same dict coercion + TC260
|
||||
# gate as the PNG-chunk path. A non-matching hit (no TC260 field, or an
|
||||
# undecodable brace) must FALL THROUGH to the next form, never short-circuit:
|
||||
# a quoted ``"AIGC"`` can appear later in an XMP packet while the real label
|
||||
# is a bare ``AIGC{...}`` blob earlier in the file, so an unconditional return
|
||||
# on the quoted form would shadow the bare form.
|
||||
text = data.decode("latin-1")
|
||||
for needle in ('"AIGC"', "AIGC{"):
|
||||
start = text.find(needle)
|
||||
if start == -1:
|
||||
continue
|
||||
# First brace at/after the needle: the object brace for ``"AIGC":{`` and
|
||||
# the glued brace (at start+4) for the bare ``AIGC{`` -- one search covers both.
|
||||
brace = text.find("{", start)
|
||||
if brace == -1:
|
||||
continue
|
||||
try:
|
||||
_, end = json.JSONDecoder().raw_decode(text, brace)
|
||||
except ValueError:
|
||||
continue
|
||||
if result := _parse(text[brace:end], require_tc260_field=True):
|
||||
return result
|
||||
return None
|
||||
return aigc_label_from_metadata(data)
|
||||
|
||||
|
||||
# C2PA "Durable Content Credentials" manifest repositories (C2PA 2.4). When the
|
||||
@@ -595,6 +592,16 @@ def _read_file_tail(image_path: Path, size: int) -> bytes:
|
||||
return b""
|
||||
|
||||
|
||||
def samsung_genai_in(data: bytes) -> int | None:
|
||||
"""Return Samsung's non-zero ``genAIType`` from collected metadata bytes."""
|
||||
if _SAMSUNG_EDITOR_MARKER not in data:
|
||||
return None
|
||||
match = _SAMSUNG_GENAI_RE.search(data)
|
||||
if match is None:
|
||||
return None
|
||||
return int(match.group(1)) or None
|
||||
|
||||
|
||||
def samsung_genai(image_path: Path) -> int | None:
|
||||
"""Return Samsung's non-zero ``genAIType`` value if the image carries the
|
||||
Galaxy AI editing marker, else None.
|
||||
@@ -619,12 +626,17 @@ def samsung_genai(image_path: Path) -> int | None:
|
||||
oversize = False
|
||||
if oversize:
|
||||
data = _read_file_tail(image_path, _QUICK_SCAN_BYTES)
|
||||
if _SAMSUNG_EDITOR_MARKER not in data:
|
||||
return samsung_genai_in(data)
|
||||
|
||||
|
||||
def iptc_ai_system_in(data: bytes) -> str | None:
|
||||
"""Return an IPTC 2025.1 AI-disclosure note from collected metadata bytes."""
|
||||
if not any(marker in data for marker in IPTC_AI_FIELD_MARKERS):
|
||||
return None
|
||||
m = _SAMSUNG_GENAI_RE.search(data)
|
||||
if m is None:
|
||||
return None
|
||||
return int(m.group(1)) or None
|
||||
match = re.search(rb"AISystemUsed[=:\s]*[\"'>]\s*([^<\"']{1,120})", data)
|
||||
if match and (value := match.group(1).decode("utf-8", "replace").strip()):
|
||||
return value
|
||||
return "fields present"
|
||||
|
||||
|
||||
def iptc_ai_system(image_path: Path) -> str | None:
|
||||
@@ -637,13 +649,7 @@ def iptc_ai_system(image_path: Path) -> str | None:
|
||||
extractable, otherwise the literal ``"fields present"``. Container-agnostic
|
||||
raw-byte scan; handles both XMP element and attribute serializations.
|
||||
"""
|
||||
data = scan_head(image_path)
|
||||
if not any(marker in data for marker in IPTC_AI_FIELD_MARKERS):
|
||||
return None
|
||||
match = re.search(rb"AISystemUsed[=:\s]*[\"'>]\s*([^<\"']{1,120})", data)
|
||||
if match and (value := match.group(1).decode("utf-8", "replace").strip()):
|
||||
return value
|
||||
return "fields present"
|
||||
return iptc_ai_system_in(scan_head(image_path))
|
||||
|
||||
|
||||
def synthid_source(image_path: Path) -> str | None:
|
||||
@@ -686,6 +692,20 @@ def synthid_source(image_path: Path) -> str | None:
|
||||
return ", ".join(matched) if matched else None
|
||||
|
||||
|
||||
def generator_from_metadata(candidates: Iterable[str], scan: bytes = b"") -> str | None:
|
||||
"""Return a known AI generator from collected EXIF, PNG, or XMP values."""
|
||||
from remove_ai_watermarks.noai.constants import AI_GENERATOR_TOKENS
|
||||
|
||||
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 any(token in value.lower() for token in AI_GENERATOR_TOKENS):
|
||||
return value.strip()
|
||||
return None
|
||||
|
||||
|
||||
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
|
||||
@@ -698,10 +718,6 @@ def exif_generator(image_path: Path) -> str | None:
|
||||
chunks rather than EXIF. Only AI tokens match, so ordinary editors (plain
|
||||
"Adobe Photoshop", "GIMP") are not flagged.
|
||||
"""
|
||||
import re
|
||||
|
||||
from remove_ai_watermarks.noai.constants import AI_GENERATOR_TOKENS
|
||||
|
||||
candidates: list[str] = []
|
||||
|
||||
# EXIF Software / Artist / ImageDescription (0th IFD) via PIL exif bytes,
|
||||
@@ -736,18 +752,12 @@ def exif_generator(image_path: Path) -> str | None:
|
||||
except Exception as exc: # unopenable format / malformed EXIF
|
||||
logger.debug("EXIF generator read failed for %s: %s", image_path, exc)
|
||||
|
||||
# XMP CreatorTool: text, container-agnostic (covers HEIF/JXL via raw scan).
|
||||
try:
|
||||
head = scan_head(image_path)
|
||||
for match in re.finditer(rb"CreatorTool[>\"'=\s]{1,4}([^<\"']{1,80})", head):
|
||||
candidates.append(match.group(1).decode("latin1", "replace"))
|
||||
except Exception as exc:
|
||||
logger.debug("XMP CreatorTool scan failed for %s: %s", image_path, exc)
|
||||
|
||||
for value in candidates:
|
||||
if any(token in value.lower() for token in AI_GENERATOR_TOKENS):
|
||||
return value.strip()
|
||||
return None
|
||||
head = b""
|
||||
return generator_from_metadata(candidates, head)
|
||||
|
||||
|
||||
# xAI / Grok EXIF signature scheme. A 64+ char base64 blob after "Signature:"
|
||||
@@ -757,7 +767,7 @@ _XAI_SIGNATURE_RE = re.compile(r"Signature:\s*[A-Za-z0-9+/=]{64,}")
|
||||
_UUID_RE = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", re.IGNORECASE)
|
||||
|
||||
|
||||
def _is_xai_signature_pair(description: str, artist: str) -> bool:
|
||||
def xai_signature_pair(description: str, artist: str) -> bool:
|
||||
"""True if an EXIF (ImageDescription, Artist) pair is xAI/Grok's scheme."""
|
||||
return _XAI_SIGNATURE_RE.match(description) is not None and _UUID_RE.fullmatch(artist) is not None
|
||||
|
||||
@@ -793,7 +803,7 @@ def xai_signature(image_path: Path) -> bool:
|
||||
logger.debug("xAI-signature EXIF read failed for %s: %s", image_path, exc)
|
||||
return False
|
||||
|
||||
return _is_xai_signature_pair(
|
||||
return xai_signature_pair(
|
||||
_exif_text(tags, piexif.ImageIFD.ImageDescription), _exif_text(tags, piexif.ImageIFD.Artist)
|
||||
)
|
||||
|
||||
@@ -848,9 +858,7 @@ def _ai_exif_targets(loaded: dict[str, Any]) -> list[tuple[str, int, bytes, str]
|
||||
targets.append((ifd_key, tag, value, name))
|
||||
|
||||
# (a) xAI / Grok: the Signature blob and the UUID Artist go together.
|
||||
if _is_xai_signature_pair(
|
||||
_exif_text(ifd0, piexif.ImageIFD.ImageDescription), _exif_text(ifd0, piexif.ImageIFD.Artist)
|
||||
):
|
||||
if xai_signature_pair(_exif_text(ifd0, piexif.ImageIFD.ImageDescription), _exif_text(ifd0, piexif.ImageIFD.Artist)):
|
||||
add("0th", ifd0, piexif.ImageIFD.ImageDescription, "ImageDescription")
|
||||
add("0th", ifd0, piexif.ImageIFD.Artist, "Artist")
|
||||
# (b) known AI generator token in a 0th text tag.
|
||||
|
||||
@@ -7,7 +7,7 @@ is exposed **lazily** via PEP 562 ``__getattr__``: importing a light submodule
|
||||
(e.g. ``noai.c2pa`` / ``noai.constants`` from ``identify``) must NOT eagerly pull
|
||||
``watermark_remover``, which imports torch + diffusers at module top. Keeping this
|
||||
lazy is what lets ``import remove_ai_watermarks.identify`` stay cheap (~36 MB, no
|
||||
torch) even in a full install where the ``gpu``/``detect`` extras are present --
|
||||
torch) even in a full install where the ``diffusion`` extra is present --
|
||||
otherwise the mere presence of torch in the env inflated identify to ~420 MB and
|
||||
risked OOM on a 512 MB host.
|
||||
"""
|
||||
|
||||
@@ -43,7 +43,7 @@ from remove_ai_watermarks.noai.constants import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Official C2PA reader (c2pa-python, a core dependency). It is the primary,
|
||||
# Official C2PA reader (c2pa-python, a default dependency). It is the primary,
|
||||
# spec-tracking manifest parser; the hand-rolled caBX/CBOR scanner below stays as
|
||||
# a fallback for synthetic/partial blobs the validator rejects. The import is
|
||||
# guarded so a partially-broken install degrades to the byte-scan rather than
|
||||
@@ -189,9 +189,8 @@ def _active_manifest(store: dict[str, Any]) -> dict[str, Any]:
|
||||
return cast("dict[str, Any]", active) if isinstance(active, dict) else {}
|
||||
|
||||
|
||||
def _info_from_store_json(store_json: str) -> dict[str, Any]:
|
||||
"""Build the C2PA info dict from a c2pa-python manifest-store JSON string."""
|
||||
store_bytes = store_json.encode("utf-8")
|
||||
def _info_from_store(store: dict[str, Any], store_bytes: bytes) -> dict[str, Any]:
|
||||
"""Build normalized C2PA info from one parsed manifest store."""
|
||||
c2pa_info: dict[str, Any] = {
|
||||
"has_c2pa": True,
|
||||
"type": "C2PA (Coalition for Content Provenance and Authenticity)",
|
||||
@@ -202,13 +201,6 @@ def _info_from_store_json(store_json: str) -> dict[str, Any]:
|
||||
# registry scan that runs on the raw caBX chunk applies unchanged here.
|
||||
_populate_registry_fields(store_bytes, c2pa_info)
|
||||
|
||||
try:
|
||||
parsed: Any = json.loads(store_json)
|
||||
except (ValueError, TypeError):
|
||||
return c2pa_info
|
||||
if not isinstance(parsed, dict):
|
||||
return c2pa_info
|
||||
store = cast("dict[str, Any]", parsed)
|
||||
if generator := _claim_generator_from_store(store):
|
||||
c2pa_info["claim_generator"] = generator
|
||||
sig: Any = _active_manifest(store).get("signature_info")
|
||||
@@ -217,6 +209,44 @@ def _info_from_store_json(store_json: str) -> dict[str, Any]:
|
||||
return c2pa_info
|
||||
|
||||
|
||||
def _info_from_store_json(store_json: str) -> dict[str, Any]:
|
||||
"""Build the C2PA info dict from a c2pa-python manifest-store JSON string."""
|
||||
store_bytes = store_json.encode("utf-8")
|
||||
try:
|
||||
parsed: Any = json.loads(store_json)
|
||||
except (ValueError, TypeError):
|
||||
parsed = {}
|
||||
store = cast("dict[str, Any]", parsed) if isinstance(parsed, dict) else {}
|
||||
return _info_from_store(store, store_bytes)
|
||||
|
||||
|
||||
def c2pa_info_from_manifest_store(store: str | dict[str, Any]) -> dict[str, Any]:
|
||||
"""Build normalized C2PA evidence from an externally collected manifest store.
|
||||
|
||||
``store`` may be the JSON string returned by ``c2pa.Reader.json()`` or its
|
||||
decoded dictionary form. This is the non-file-backed counterpart to
|
||||
:func:`extract_c2pa_info`.
|
||||
"""
|
||||
if isinstance(store, dict):
|
||||
parsed = store
|
||||
try:
|
||||
store_json = json.dumps(store, ensure_ascii=False)
|
||||
except (TypeError, ValueError):
|
||||
return {}
|
||||
else:
|
||||
store_json = store
|
||||
try:
|
||||
decoded: Any = json.loads(store_json)
|
||||
except (TypeError, ValueError):
|
||||
return {}
|
||||
if not isinstance(decoded, dict):
|
||||
return {}
|
||||
parsed = cast("dict[str, Any]", decoded)
|
||||
if not store_json or not parsed or parsed.get("error"):
|
||||
return {}
|
||||
return _info_from_store(parsed, store_json.encode("utf-8"))
|
||||
|
||||
|
||||
def extract_c2pa_info(image_path: Path) -> dict[str, Any]:
|
||||
"""
|
||||
Extract C2PA metadata information from an image.
|
||||
|
||||
@@ -7,7 +7,7 @@ so adding a new AI tool or metadata key requires updating only this file.
|
||||
from typing import NamedTuple
|
||||
|
||||
# Supported image formats for the pixel/removal path (CLI input validation + batch
|
||||
# discovery). PNG/JPEG/WebP decode+encode via cv2; HEIC/HEIF/AVIF via the core
|
||||
# discovery). PNG/JPEG/WebP decode+encode via cv2; HEIC/HEIF/AVIF via the optional
|
||||
# pillow-heif dep (image_io.imread Pillow fallback + imwrite _pil_write), so batch
|
||||
# now picks them up and the CLI no longer warns on an iPhone HEIC. JPEG-XL is left
|
||||
# out on purpose -- it is metadata/strip-only (no pixel decoder without pillow-jxl).
|
||||
|
||||
@@ -476,8 +476,9 @@ class WatermarkRemover:
|
||||
"""Turn off the diffusers default invisible watermarker on an SDXL pipeline.
|
||||
|
||||
diffusers embeds an open "Stable Diffusion XL" DWT-DCT invisible watermark on
|
||||
EVERY SDXL output whenever ``invisible-watermark`` is installed (the ``detect``
|
||||
extra). A watermark REMOVER must not re-stamp a detectable AI watermark, or the
|
||||
EVERY SDXL output whenever ``invisible-watermark`` is installed (kept as a
|
||||
development parity dependency). A watermark REMOVER must not re-stamp a
|
||||
detectable AI watermark, or the
|
||||
cleaned output re-reads as AI (``identify`` -> "Open invisible watermark: Stable
|
||||
Diffusion XL"). Shared by both SDXL loaders; the ``ControlNetModel`` sub-model
|
||||
and the Qwen loader never call it (only the pipeline accepts the kwarg).
|
||||
|
||||
@@ -4,7 +4,7 @@ Mirrors ``region_eraser``'s optional-backend pattern: ``is_available()`` guards
|
||||
``spandrel`` import, a lazy singleton (double-checked lock) holds the loaded model, and
|
||||
the weights download on first use (cached by ``torch.hub``) -- they are never bundled.
|
||||
|
||||
The DEFAULT upscaler stays Lanczos (cv2, no deps); this is opt-in via the ``esrgan``
|
||||
The DEFAULT upscaler stays Lanczos (cv2, no model download); this is opt-in via the ``esrgan``
|
||||
extra and feeds the ``--upscaler esrgan`` path. ``spandrel`` is a pure model-loader
|
||||
(MIT) with NO basicsr dependency -- it pulls only torch/torchvision/safetensors/numpy/
|
||||
einops -- so it sidesteps the basicsr / ``torchvision.transforms.functional_tensor``
|
||||
|
||||
@@ -34,6 +34,14 @@ _REGENERATED_VIDEO_EXTENSIONS: frozenset[str] = _ISOBMFF_VIDEO_EXTENSIONS
|
||||
_EBML_MAGIC = b"\x1aE\xdf\xa3"
|
||||
|
||||
|
||||
def _require_video_runtime() -> None:
|
||||
"""Raise with the public install command when a video runtime is absent."""
|
||||
from remove_ai_watermarks.optional_deps import module_available
|
||||
|
||||
if not module_available("cv2", "numpy", "av"):
|
||||
raise RuntimeError("Video pixel processing requires remove-ai-watermarks[video]")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VideoMetadataReport:
|
||||
"""AI metadata found in one supported video container."""
|
||||
@@ -345,7 +353,6 @@ def identify_video(
|
||||
watermarks such as video SynthID have no public local decoder.
|
||||
"""
|
||||
from remove_ai_watermarks.metadata import get_ai_metadata
|
||||
from remove_ai_watermarks.video_visible import scan_video_marks
|
||||
|
||||
source_path = _video_source(source)
|
||||
markers = get_ai_metadata(source_path)
|
||||
@@ -354,6 +361,9 @@ def identify_video(
|
||||
total_frames: int | None = None
|
||||
|
||||
if check_visible:
|
||||
_require_video_runtime()
|
||||
from remove_ai_watermarks.video_visible import scan_video_marks
|
||||
|
||||
scans = scan_video_marks(
|
||||
source_path,
|
||||
VIDEO_VISIBLE_MARKS,
|
||||
@@ -438,6 +448,8 @@ def remove_video_visible(
|
||||
published atomically. When no stable mark is found, no output is written
|
||||
and ``output`` in the result is ``None``.
|
||||
"""
|
||||
_require_video_runtime()
|
||||
|
||||
from remove_ai_watermarks.metadata import get_ai_metadata
|
||||
from remove_ai_watermarks.video_visible import encode_clean_video, scan_video_marks
|
||||
from remove_ai_watermarks.watermark_registry import resolve_backend
|
||||
@@ -526,6 +538,7 @@ def remove_video_all(
|
||||
if include_invisible and source_path.suffix.lower() not in _REGENERATED_VIDEO_EXTENSIONS:
|
||||
supported = ", ".join(sorted(_REGENERATED_VIDEO_EXTENSIONS))
|
||||
raise ValueError(f"Video SynthID regeneration requires one of: {supported}")
|
||||
_require_video_runtime()
|
||||
detected_metadata = get_ai_metadata(source_path)
|
||||
|
||||
with TemporaryDirectory(prefix=f".{source_path.stem}-video-all-", dir=source_path.parent) as temp_dir:
|
||||
@@ -619,6 +632,8 @@ def remove_video_batch(
|
||||
raise ValueError("Unsupported fill backend; expected auto, cv2, migan, or lama")
|
||||
if include_invisible and mode != "all":
|
||||
raise ValueError("The invisible video stage is available only in all mode")
|
||||
if mode != "metadata":
|
||||
_require_video_runtime()
|
||||
|
||||
output_path = (
|
||||
Path(output_directory)
|
||||
@@ -768,12 +783,14 @@ def remove_video_invisible(
|
||||
rechecked with Google's verifier when the caller needs a per-file verdict.
|
||||
"""
|
||||
from remove_ai_watermarks.metadata import get_ai_metadata
|
||||
from remove_ai_watermarks.video_invisible import regenerate_video_candidate
|
||||
|
||||
source_path = _video_source(source)
|
||||
if source_path.suffix.lower() not in _REGENERATED_VIDEO_EXTENSIONS:
|
||||
supported = ", ".join(sorted(_REGENERATED_VIDEO_EXTENSIONS))
|
||||
raise ValueError(f"Video SynthID regeneration requires one of: {supported}")
|
||||
_require_video_runtime()
|
||||
from remove_ai_watermarks.video_invisible import regenerate_video_candidate
|
||||
|
||||
clean_output = Path(output) if output is not None else source_path.with_stem(source_path.stem + "_clean")
|
||||
output_path = _video_output(
|
||||
source_path,
|
||||
|
||||
@@ -120,7 +120,7 @@ def load_video_vae_runtime(
|
||||
if device not in {"auto", "cuda", "mps", "cpu"}:
|
||||
raise ValueError("device must be auto, cuda, mps, or cpu")
|
||||
if not is_available():
|
||||
raise RuntimeError("Video SynthID regeneration requires the gpu extra")
|
||||
raise RuntimeError("Video SynthID regeneration requires the diffusion extra")
|
||||
|
||||
import torch
|
||||
from diffusers import AutoencoderKL
|
||||
|
||||
+5
-5
@@ -542,7 +542,7 @@ class TestAllCommand:
|
||||
result = runner.invoke(main, ["all", str(sample_png), "-o", str(output)])
|
||||
assert result.exit_code != 0, result.output
|
||||
assert "NOT removed" in result.output
|
||||
assert "remove-ai-watermarks[gpu]" in result.output
|
||||
assert "remove-ai-watermarks[diffusion]" in result.output
|
||||
assert output.exists() # visible + metadata still produced a file
|
||||
|
||||
def test_all_reports_metadata_that_survived_stripping(self, runner, sample_png, tmp_path):
|
||||
@@ -933,21 +933,21 @@ class TestBatchCommand:
|
||||
|
||||
|
||||
class TestGpuHintMarkup:
|
||||
"""The GPU-extra install hint must reach the user with the ``[gpu]`` token
|
||||
"""The diffusion install hint must reach the user with the ``[diffusion]`` token
|
||||
intact (plain output prints it verbatim, with no markup parsing)."""
|
||||
|
||||
def test_invisible_install_hint_keeps_gpu_extra(self, runner, sample_png):
|
||||
with patch("remove_ai_watermarks.invisible_engine.is_available", return_value=False):
|
||||
result = runner.invoke(main, ["invisible", str(sample_png)])
|
||||
assert result.exit_code != 0
|
||||
assert "remove-ai-watermarks[gpu]" in result.output
|
||||
assert "remove-ai-watermarks[diffusion]" in result.output
|
||||
|
||||
def test_all_install_hint_keeps_gpu_extra(self, runner, sample_png):
|
||||
# The `all` pipeline skips the invisible step with a warning that carries
|
||||
# the same hint; it must keep the [gpu] extra too.
|
||||
# the same hint; it must keep the [diffusion] extra too.
|
||||
with patch("remove_ai_watermarks.invisible_engine.is_available", return_value=False):
|
||||
result = runner.invoke(main, ["all", str(sample_png)])
|
||||
assert "remove-ai-watermarks[gpu]" in result.output
|
||||
assert "remove-ai-watermarks[diffusion]" in result.output
|
||||
|
||||
|
||||
class TestEraseCommand:
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Regression tests for Dependabot compatibility constraints."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_dependabot_blocks_opencv_releases_that_require_numpy_2() -> None:
|
||||
config = Path(".github/dependabot.yml").read_text()
|
||||
|
||||
opencv_ignore = config.split('dependency-name: "opencv-python-headless"', maxsplit=1)[1]
|
||||
assert '"<4.12"' not in opencv_ignore
|
||||
assert '- ">=4.12"' in opencv_ignore
|
||||
+138
-1
@@ -6,6 +6,7 @@ against the real committed C2PA / IPTC fixtures in data/fixtures/provenance/.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -16,14 +17,18 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
from remove_ai_watermarks.identify import (
|
||||
ProvenanceEvidence,
|
||||
ProvenanceReport,
|
||||
_ai_tools_in,
|
||||
_attribute_platform,
|
||||
_integrity_clashes,
|
||||
_issuers_in,
|
||||
_vendor_of,
|
||||
evidence_from_metadata_record,
|
||||
extract_provenance_evidence,
|
||||
has_invisible_target,
|
||||
identify,
|
||||
identify_from_evidence,
|
||||
)
|
||||
from remove_ai_watermarks.watermark_registry import GEMINI_SPARKLE_TRUST_CONF
|
||||
|
||||
@@ -33,6 +38,129 @@ _SPARKLE_TARGET = "remove_ai_watermarks.gemini_engine.detect_sparkle_confidence"
|
||||
SAMPLES_DIR = Path(__file__).resolve().parent.parent / "data" / "fixtures" / "provenance"
|
||||
|
||||
|
||||
class TestProvenanceEvidence:
|
||||
def test_external_metadata_record_builds_equivalent_evidence(self, tmp_path: Path):
|
||||
path = tmp_path / "external.jpg"
|
||||
signature = "A" * 64
|
||||
artist = "c8045292-06d2-4c7d-b4f0-4f93b94e4801"
|
||||
record = {
|
||||
"pil": {"info:parameters": "Steps: 20, Sampler: Euler"},
|
||||
"exif": {
|
||||
"0th": {
|
||||
"ImageDescription": f"Signature: {signature}",
|
||||
"Artist": artist,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
evidence = evidence_from_metadata_record(record, path=path)
|
||||
report = identify_from_evidence(evidence)
|
||||
|
||||
assert evidence.path == path
|
||||
assert evidence.ai_metadata["parameters"] == "Steps: 20, Sampler: Euler"
|
||||
assert evidence.xai_signature is True
|
||||
assert report.is_ai_generated is True
|
||||
assert {signal.name for signal in report.signals} >= {"gen_params", "xai_signature"}
|
||||
|
||||
def test_external_scanner_diagnostics_do_not_create_c2pa_evidence(self, tmp_path: Path):
|
||||
path = tmp_path / "plain.jpg"
|
||||
record = {
|
||||
"c2pa_store": {"error": "ManifestNotFound: no JUMBF data found"},
|
||||
"jpeg": {
|
||||
"segments": [
|
||||
{
|
||||
"marker": "APP11",
|
||||
"kind": "c2pa_or_jumbf",
|
||||
"base64": "AAA=",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
report = identify_from_evidence(evidence_from_metadata_record(record, path=path))
|
||||
|
||||
assert report.is_ai_generated is None
|
||||
assert report.signals == []
|
||||
assert report.watermarks == []
|
||||
|
||||
def test_external_scanner_raw_bytes_still_create_c2pa_evidence(self, tmp_path: Path):
|
||||
path = tmp_path / "signed.jpg"
|
||||
manifest = b"jumb c2pa OpenAI trainedAlgorithmicMedia"
|
||||
record = {
|
||||
"jpeg": {
|
||||
"segments": [
|
||||
{
|
||||
"marker": "APP11",
|
||||
"kind": "c2pa_or_jumbf",
|
||||
"base64": base64.b64encode(manifest).decode(),
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
report = identify_from_evidence(evidence_from_metadata_record(record, path=path))
|
||||
|
||||
assert report.is_ai_generated is True
|
||||
assert report.platform == "OpenAI (ChatGPT / gpt-image / DALL-E / Sora)"
|
||||
assert [signal.name for signal in report.signals] == ["c2pa"]
|
||||
|
||||
def test_external_generator_bytes_are_normalized(self, tmp_path: Path):
|
||||
evidence = evidence_from_metadata_record(
|
||||
{"exif": {"0th": {"Software": b"NovelAI"}}},
|
||||
path=tmp_path / "external.png",
|
||||
)
|
||||
|
||||
assert evidence.exif_generator == "NovelAI"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"filename",
|
||||
[
|
||||
"chatgpt-1.png",
|
||||
"chatgpt-2.png",
|
||||
"doubao-1.png",
|
||||
"firefly-1.png",
|
||||
"flux-1.jpg",
|
||||
"flux-1.png",
|
||||
"grok-1.jpg",
|
||||
"mj-1.png",
|
||||
],
|
||||
)
|
||||
def test_metadata_only_identify_matches_extracted_evidence(self, filename: str):
|
||||
path = SAMPLES_DIR / filename
|
||||
|
||||
direct = identify(path, check_visible=False, check_invisible=False)
|
||||
evidence = extract_provenance_evidence(path)
|
||||
extracted = identify_from_evidence(evidence)
|
||||
|
||||
assert isinstance(evidence, ProvenanceEvidence)
|
||||
assert extracted == direct
|
||||
|
||||
def test_identify_from_evidence_does_not_read_the_source(self, monkeypatch, tmp_path: Path):
|
||||
path = tmp_path / "generated.jpg"
|
||||
path.write_bytes(b"\xff\xd8\xff\xe1jumbc2paOpenAI DALL-E trainedAlgorithmicMedia\xff\xd9")
|
||||
evidence = extract_provenance_evidence(path)
|
||||
|
||||
def fail_if_called(*args, **kwargs):
|
||||
raise AssertionError("identify_from_evidence must not read the source file")
|
||||
|
||||
monkeypatch.setattr("remove_ai_watermarks.identify.extract_c2pa_info", fail_if_called)
|
||||
monkeypatch.setattr("remove_ai_watermarks.identify.get_ai_metadata", fail_if_called)
|
||||
monkeypatch.setattr("remove_ai_watermarks.identify.scan_head", fail_if_called)
|
||||
monkeypatch.setattr("remove_ai_watermarks.identify.iptc_ai_system", fail_if_called)
|
||||
monkeypatch.setattr("remove_ai_watermarks.identify.aigc_label", fail_if_called)
|
||||
monkeypatch.setattr("remove_ai_watermarks.identify.exif_generator", fail_if_called)
|
||||
monkeypatch.setattr("remove_ai_watermarks.identify.xai_signature", fail_if_called)
|
||||
monkeypatch.setattr("remove_ai_watermarks.identify.huggingface_job", fail_if_called)
|
||||
monkeypatch.setattr("remove_ai_watermarks.identify.samsung_genai", fail_if_called)
|
||||
monkeypatch.setattr("builtins.open", fail_if_called)
|
||||
monkeypatch.setattr(Path, "open", fail_if_called)
|
||||
|
||||
report = identify_from_evidence(evidence)
|
||||
|
||||
assert report.is_ai_generated is True
|
||||
assert any(signal.name == "c2pa" for signal in report.signals)
|
||||
|
||||
|
||||
# ── Pure attribution logic (no file IO) ─────────────────────────────
|
||||
|
||||
|
||||
@@ -662,6 +790,15 @@ class TestIdentifyVisibleTextMarks:
|
||||
identify(tmp_clean_png, check_visible=True, check_invisible=False)
|
||||
assert mock_imread.call_count == 1
|
||||
|
||||
def test_missing_pixel_extra_preserves_metadata_verdict(self, tmp_png_with_ai_metadata: Path):
|
||||
import remove_ai_watermarks.image_io as image_io
|
||||
|
||||
with patch.object(image_io, "imread", side_effect=ModuleNotFoundError("No module named 'cv2'")):
|
||||
report = identify(tmp_png_with_ai_metadata, check_visible=True, check_invisible=False)
|
||||
|
||||
assert report.is_ai_generated is True
|
||||
assert report.confidence == "high"
|
||||
|
||||
|
||||
# ── Caveats and serialization ───────────────────────────────────────
|
||||
|
||||
@@ -869,7 +1006,7 @@ class TestIdentifyC2paDevice:
|
||||
from remove_ai_watermarks.invisible_watermark import is_available as _wm_available # noqa: E402
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _wm_available(), reason="invisible-watermark not installed")
|
||||
@pytest.mark.skipif(not _wm_available(), reason="detect extra not installed")
|
||||
class TestIdentifyInvisibleWatermark:
|
||||
def _sdxl_watermarked(self, tmp_path: Path) -> Path:
|
||||
import cv2
|
||||
|
||||
@@ -18,9 +18,9 @@ class TestIsAvailable:
|
||||
assert isinstance(result, bool)
|
||||
|
||||
def test_available_reflects_dependencies(self):
|
||||
"""is_available() is True iff torch + diffusers (the gpu extra) import.
|
||||
"""is_available() is True iff torch + diffusers (the diffusion extra) import.
|
||||
|
||||
Must not assume the full stack: the core+dev CI env has no diffusers.
|
||||
Must not assume the full stack: the default+dev CI env has no diffusers.
|
||||
"""
|
||||
import importlib.util
|
||||
|
||||
@@ -212,7 +212,7 @@ class TestCannyControlImage:
|
||||
|
||||
def test_edge_map_is_3channel_rgb(self):
|
||||
if not is_available():
|
||||
pytest.skip("gpu extra (torch/diffusers) not installed")
|
||||
pytest.skip("diffusion extra (torch/diffusers) not installed")
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks.noai.watermark_remover import WatermarkRemover
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"""Tests for open invisible-watermark (imwatermark) detection.
|
||||
"""Tests for open DWT-DCT watermark detection.
|
||||
|
||||
Each known scheme is round-tripped: embed its exact upstream pattern with the
|
||||
encoder, then assert the detector names it. Skipped entirely if the optional
|
||||
``invisible-watermark`` package is not installed.
|
||||
The upstream encoder supplies known watermarks, while the in-tree decoder must
|
||||
both identify them and match the upstream decoder bit for bit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -25,7 +24,7 @@ from remove_ai_watermarks.invisible_watermark import (
|
||||
is_available,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.skipif(not is_available(), reason="invisible-watermark not installed")
|
||||
pytestmark = pytest.mark.skipif(not is_available(), reason="detect extra not installed")
|
||||
|
||||
|
||||
def _base_image() -> np.ndarray:
|
||||
@@ -61,6 +60,20 @@ class TestHelpers:
|
||||
|
||||
|
||||
class TestDetect:
|
||||
def test_in_tree_decoder_matches_upstream(self, tmp_path: Path):
|
||||
from imwatermark import WatermarkDecoder
|
||||
|
||||
from remove_ai_watermarks.dwt_dct import decode_dwt_dct
|
||||
from remove_ai_watermarks.image_io import imread
|
||||
|
||||
path = _write_bits_watermark(tmp_path, _BITS_48["Stable Diffusion XL"])
|
||||
image = imread(path)
|
||||
assert image is not None
|
||||
|
||||
upstream = np.asarray(WatermarkDecoder("bits", 48).decode(image, "dwtDct"), dtype=bool)
|
||||
ours = np.asarray(decode_dwt_dct(image, wm_len=48), dtype=bool)
|
||||
assert np.array_equal(ours, upstream)
|
||||
|
||||
def test_detects_sdxl(self, tmp_path: Path):
|
||||
path = _write_bits_watermark(tmp_path, _BITS_48["Stable Diffusion XL"])
|
||||
assert detect_invisible_watermark(path) == "Stable Diffusion XL"
|
||||
|
||||
+2
-2
@@ -52,8 +52,8 @@ class TestConstants:
|
||||
assert ".jpg" in SUPPORTED_FORMATS
|
||||
|
||||
def test_supported_formats_include_heic_avif(self):
|
||||
# HEIC/AVIF are first-class on the pixel path now (read+write via pillow-heif),
|
||||
# so batch discovers them and the CLI does not warn.
|
||||
# HEIC/AVIF are first-class when the visible pixel extra is installed
|
||||
# (read+write via pillow-heif), so batch discovers them without a warning.
|
||||
assert {".heic", ".heif", ".avif"} <= SUPPORTED_FORMATS
|
||||
|
||||
def test_supported_formats_exclude_jpeg_xl(self):
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Published dependency boundaries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib.metadata import metadata, requires
|
||||
|
||||
from packaging.requirements import Requirement
|
||||
from packaging.utils import canonicalize_name
|
||||
|
||||
|
||||
def _requirement_names(extra: str | None = None) -> set[str]:
|
||||
selected_extra = extra or ""
|
||||
parsed = (Requirement(value) for value in requires("remove-ai-watermarks") or [])
|
||||
return {
|
||||
canonicalize_name(requirement.name)
|
||||
for requirement in parsed
|
||||
if requirement.marker is None or requirement.marker.evaluate({"extra": selected_extra})
|
||||
}
|
||||
|
||||
|
||||
def test_default_install_is_metadata_focused():
|
||||
default = _requirement_names()
|
||||
|
||||
assert {
|
||||
"c2pa-python",
|
||||
"click",
|
||||
"piexif",
|
||||
"pillow",
|
||||
"python-dotenv",
|
||||
} <= default
|
||||
assert {
|
||||
"av",
|
||||
"invisible-watermark",
|
||||
"numpy",
|
||||
"opencv-python-headless",
|
||||
"pillow-heif",
|
||||
"torch",
|
||||
"trustmark",
|
||||
}.isdisjoint(default)
|
||||
|
||||
|
||||
def test_pixels_extra_owns_shared_numeric_dependencies():
|
||||
assert {
|
||||
"numpy",
|
||||
"opencv-python-headless",
|
||||
} <= _requirement_names("pixels")
|
||||
|
||||
|
||||
def test_video_extra_owns_timestamp_dependency():
|
||||
assert "av" in _requirement_names("video")
|
||||
|
||||
|
||||
def test_file_format_and_detector_dependencies_are_independent():
|
||||
assert "pillow-heif" in _requirement_names("heif")
|
||||
assert "pywavelets" in _requirement_names("detect")
|
||||
|
||||
|
||||
def test_extras_use_capability_names_without_legacy_aliases():
|
||||
extras = set(metadata("remove-ai-watermarks").get_all("Provides-Extra") or [])
|
||||
|
||||
assert {"pixels", "heif", "visible", "video", "detect", "diffusion"} <= extras
|
||||
assert {"gpu", "remove", "detect-pywavelets"}.isdisjoint(extras)
|
||||
|
||||
|
||||
def test_production_all_does_not_include_development_tools():
|
||||
assert {
|
||||
"pyright",
|
||||
"pytest",
|
||||
"pytest-cov",
|
||||
"pytest-xdist",
|
||||
"ruff",
|
||||
}.isdisjoint(_requirement_names("all"))
|
||||
@@ -238,7 +238,7 @@ class TestQwenKwargs:
|
||||
"""_build_qwen_kwargs is pure (no torch); guards the Qwen-Image call shape.
|
||||
|
||||
watermark_remover imports torch under a try/except, so the module (and this pure
|
||||
helper) imports fine in the core+dev CI env where torch is absent.
|
||||
helper) imports fine in the default+dev CI env where torch is absent.
|
||||
"""
|
||||
|
||||
def test_uses_true_cfg_not_guidance_scale(self):
|
||||
@@ -431,7 +431,7 @@ class TestAvailability:
|
||||
|
||||
def test_watermark_removal_available(self):
|
||||
# Reflects the actual environment: True iff torch + diffusers (the gpu
|
||||
# extra) are importable. The core+dev CI env has no diffusers, so this
|
||||
# extra) are importable. The default+dev CI env has no diffusers, so this
|
||||
# must not assume the full stack is present.
|
||||
import importlib.util
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ def test_update_recipe_rejects_ambiguous_recipe() -> None:
|
||||
update_recipe(duplicate, version="2.0.0", sha256=_NEW_SHA)
|
||||
|
||||
|
||||
def test_repository_recipe_includes_timestamp_bridge() -> None:
|
||||
recipe = Path("packaging/conda/recipe.yaml").read_text()
|
||||
def test_repository_recipe_stays_metadata_only() -> None:
|
||||
recipe = Path("packaging/conda/recipe.yaml").read_text(encoding="utf-8")
|
||||
|
||||
assert " - av >=16\n" in recipe
|
||||
assert " - av >=16\n" not in recipe
|
||||
|
||||
@@ -501,6 +501,17 @@ def _regeneration_metrics(
|
||||
)
|
||||
|
||||
|
||||
class TestVideoDependencies:
|
||||
def test_visible_runtime_reports_video_extra(self, monkeypatch):
|
||||
from remove_ai_watermarks import optional_deps
|
||||
from remove_ai_watermarks.video import _require_video_runtime
|
||||
|
||||
monkeypatch.setattr(optional_deps, "module_available", lambda *_names: False)
|
||||
|
||||
with pytest.raises(RuntimeError, match=r"remove-ai-watermarks\[video\]"):
|
||||
_require_video_runtime()
|
||||
|
||||
|
||||
class TestVideoMetadataApi:
|
||||
def test_top_level_api_is_lazy_exported(self):
|
||||
import remove_ai_watermarks as raiw
|
||||
|
||||
@@ -3292,80 +3292,104 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "remove-ai-watermarks"
|
||||
version = "0.20.2"
|
||||
version = "0.22.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "av", version = "16.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "av", version = "18.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "c2pa-python" },
|
||||
{ name = "click" },
|
||||
{ name = "numpy" },
|
||||
{ name = "opencv-python-headless" },
|
||||
{ name = "piexif" },
|
||||
{ name = "pillow" },
|
||||
{ name = "pillow-heif" },
|
||||
{ name = "python-dotenv" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
all = [
|
||||
{ name = "accelerate" },
|
||||
{ name = "av", version = "16.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "av", version = "18.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "diffsynth" },
|
||||
{ name = "diffusers" },
|
||||
{ name = "huggingface-hub" },
|
||||
{ name = "invisible-watermark" },
|
||||
{ name = "numpy" },
|
||||
{ name = "onnxruntime", version = "1.24.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "onnxruntime", version = "1.27.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "pyright" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "pytest-xdist" },
|
||||
{ name = "ruff" },
|
||||
{ name = "opencv-python-headless" },
|
||||
{ name = "pillow-heif" },
|
||||
{ name = "pywavelets", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "pywavelets", version = "1.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "safetensors" },
|
||||
{ name = "spandrel" },
|
||||
{ name = "tokenizers" },
|
||||
{ name = "torch" },
|
||||
{ name = "torchvision" },
|
||||
{ name = "transformers" },
|
||||
{ name = "trustmark" },
|
||||
{ name = "uv-outdated", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "uv-secure", marker = "python_full_version >= '3.12'" },
|
||||
]
|
||||
detect = [
|
||||
{ name = "invisible-watermark" },
|
||||
{ name = "numpy" },
|
||||
{ name = "opencv-python-headless" },
|
||||
{ name = "pywavelets", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "pywavelets", version = "1.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
]
|
||||
dev = [
|
||||
{ name = "av", version = "16.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "av", version = "18.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "invisible-watermark" },
|
||||
{ name = "numpy" },
|
||||
{ name = "opencv-python-headless" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pyright" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "pytest-xdist" },
|
||||
{ name = "pywavelets", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "pywavelets", version = "1.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "ruff" },
|
||||
{ name = "uv-outdated", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "uv-secure", marker = "python_full_version >= '3.12'" },
|
||||
]
|
||||
esrgan = [
|
||||
{ name = "spandrel" },
|
||||
]
|
||||
gpu = [
|
||||
diffusion = [
|
||||
{ name = "accelerate" },
|
||||
{ name = "diffusers" },
|
||||
{ name = "numpy" },
|
||||
{ name = "opencv-python-headless" },
|
||||
{ name = "safetensors" },
|
||||
{ name = "tokenizers" },
|
||||
{ name = "torch" },
|
||||
{ name = "transformers" },
|
||||
]
|
||||
esrgan = [
|
||||
{ name = "numpy" },
|
||||
{ name = "opencv-python-headless" },
|
||||
{ name = "spandrel" },
|
||||
]
|
||||
heif = [
|
||||
{ name = "pillow-heif" },
|
||||
]
|
||||
lama = [
|
||||
{ name = "huggingface-hub" },
|
||||
{ name = "numpy" },
|
||||
{ name = "onnxruntime", version = "1.24.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "onnxruntime", version = "1.27.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "opencv-python-headless" },
|
||||
]
|
||||
migan = [
|
||||
{ name = "huggingface-hub" },
|
||||
{ name = "numpy" },
|
||||
{ name = "onnxruntime", version = "1.24.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "onnxruntime", version = "1.27.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "opencv-python-headless" },
|
||||
]
|
||||
pixels = [
|
||||
{ name = "numpy" },
|
||||
{ name = "opencv-python-headless" },
|
||||
]
|
||||
qwen-zimage = [
|
||||
{ name = "accelerate" },
|
||||
{ name = "diffsynth" },
|
||||
{ name = "diffusers" },
|
||||
{ name = "numpy" },
|
||||
{ name = "opencv-python-headless" },
|
||||
{ name = "safetensors" },
|
||||
{ name = "tokenizers" },
|
||||
{ name = "torch" },
|
||||
@@ -3375,46 +3399,66 @@ qwen-zimage = [
|
||||
trustmark = [
|
||||
{ name = "trustmark" },
|
||||
]
|
||||
video = [
|
||||
{ name = "av", version = "16.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "av", version = "18.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "numpy" },
|
||||
{ name = "opencv-python-headless" },
|
||||
]
|
||||
visible = [
|
||||
{ name = "numpy" },
|
||||
{ name = "opencv-python-headless" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "accelerate", marker = "extra == 'gpu'", specifier = ">=0.25.0" },
|
||||
{ name = "av", marker = "python_full_version < '3.11'", specifier = ">=16,<17" },
|
||||
{ name = "av", marker = "python_full_version >= '3.11'", specifier = ">=18,<19" },
|
||||
{ name = "accelerate", marker = "extra == 'diffusion'", specifier = ">=0.25.0" },
|
||||
{ name = "av", marker = "python_full_version >= '3.11' and extra == 'video'", specifier = ">=18,<19" },
|
||||
{ name = "av", marker = "python_full_version < '3.11' and extra == 'video'", specifier = ">=16,<17" },
|
||||
{ name = "c2pa-python", specifier = ">=0.35.0" },
|
||||
{ name = "click", specifier = ">=8.0.0" },
|
||||
{ name = "diffsynth", marker = "extra == 'qwen-zimage'", specifier = ">=2.0.17,<3" },
|
||||
{ name = "diffusers", marker = "extra == 'gpu'", specifier = ">=0.38.0" },
|
||||
{ name = "diffusers", marker = "extra == 'diffusion'", specifier = ">=0.38.0" },
|
||||
{ name = "huggingface-hub", marker = "extra == 'lama'", specifier = ">=0.20.0" },
|
||||
{ name = "huggingface-hub", marker = "extra == 'migan'", specifier = ">=0.20.0" },
|
||||
{ name = "invisible-watermark", marker = "extra == 'detect'", specifier = ">=0.2.0" },
|
||||
{ name = "invisible-watermark", marker = "extra == 'dev'", specifier = ">=0.2.0" },
|
||||
{ name = "numpy", specifier = ">=1.24.0" },
|
||||
{ name = "numpy", marker = "extra == 'pixels'", specifier = ">=1.24.0" },
|
||||
{ name = "onnxruntime", marker = "extra == 'lama'", specifier = ">=1.16.0" },
|
||||
{ name = "onnxruntime", marker = "extra == 'migan'", specifier = ">=1.16.0" },
|
||||
{ name = "opencv-python-headless", specifier = ">=4.8.0" },
|
||||
{ name = "opencv-python-headless", marker = "extra == 'pixels'", specifier = ">=4.8.0" },
|
||||
{ name = "packaging", marker = "extra == 'dev'", specifier = ">=24.0" },
|
||||
{ name = "piexif", specifier = ">=1.1.3" },
|
||||
{ name = "pillow", specifier = ">=10.0.0" },
|
||||
{ name = "pillow-heif", specifier = ">=0.13.0" },
|
||||
{ name = "pillow-heif", marker = "extra == 'heif'", specifier = ">=0.13.0" },
|
||||
{ name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
|
||||
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" },
|
||||
{ name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=3.5.0" },
|
||||
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
||||
{ name = "remove-ai-watermarks", extras = ["gpu"], marker = "extra == 'qwen-zimage'" },
|
||||
{ name = "remove-ai-watermarks", extras = ["gpu", "detect", "trustmark", "lama", "migan", "dev"], marker = "extra == 'all'" },
|
||||
{ name = "pywavelets", marker = "extra == 'detect'", specifier = ">=1.1.1" },
|
||||
{ name = "remove-ai-watermarks", extras = ["detect"], marker = "extra == 'dev'" },
|
||||
{ name = "remove-ai-watermarks", extras = ["diffusion"], marker = "extra == 'qwen-zimage'" },
|
||||
{ name = "remove-ai-watermarks", extras = ["pixels"], marker = "extra == 'detect'" },
|
||||
{ name = "remove-ai-watermarks", extras = ["pixels"], marker = "extra == 'diffusion'" },
|
||||
{ name = "remove-ai-watermarks", extras = ["pixels"], marker = "extra == 'esrgan'" },
|
||||
{ name = "remove-ai-watermarks", extras = ["pixels"], marker = "extra == 'visible'" },
|
||||
{ name = "remove-ai-watermarks", extras = ["video"], marker = "extra == 'dev'" },
|
||||
{ name = "remove-ai-watermarks", extras = ["video", "heif", "detect", "trustmark", "diffusion", "qwen-zimage", "lama", "migan", "esrgan"], marker = "extra == 'all'" },
|
||||
{ name = "remove-ai-watermarks", extras = ["visible"], marker = "extra == 'lama'" },
|
||||
{ name = "remove-ai-watermarks", extras = ["visible"], marker = "extra == 'migan'" },
|
||||
{ name = "remove-ai-watermarks", extras = ["visible"], marker = "extra == 'video'" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4.0" },
|
||||
{ name = "safetensors", marker = "extra == 'gpu'" },
|
||||
{ name = "safetensors", marker = "extra == 'diffusion'" },
|
||||
{ name = "spandrel", marker = "extra == 'esrgan'", specifier = ">=0.3.0" },
|
||||
{ name = "tokenizers", marker = "extra == 'gpu'", specifier = ">=0.22,<0.23" },
|
||||
{ name = "torch", marker = "extra == 'gpu'", specifier = ">=2.0.0" },
|
||||
{ name = "tokenizers", marker = "extra == 'diffusion'", specifier = ">=0.22,<0.23" },
|
||||
{ name = "torch", marker = "extra == 'diffusion'", specifier = ">=2.0.0" },
|
||||
{ name = "torchvision", marker = "extra == 'qwen-zimage'", specifier = ">=0.20.0" },
|
||||
{ name = "transformers", marker = "extra == 'gpu'", specifier = ">=5,<6" },
|
||||
{ name = "transformers", marker = "extra == 'diffusion'", specifier = ">=5,<6" },
|
||||
{ name = "trustmark", marker = "extra == 'trustmark'", specifier = ">=0.8.0" },
|
||||
{ name = "uv-outdated", marker = "python_full_version >= '3.12' and extra == 'dev'", specifier = ">=0.1.0" },
|
||||
{ name = "uv-secure", marker = "python_full_version >= '3.12' and extra == 'dev'", specifier = ">=0.12.0" },
|
||||
]
|
||||
provides-extras = ["gpu", "qwen-zimage", "detect", "trustmark", "lama", "migan", "esrgan", "dev", "all"]
|
||||
provides-extras = ["pixels", "heif", "visible", "video", "detect", "diffusion", "qwen-zimage", "trustmark", "lama", "migan", "esrgan", "dev", "all"]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
|
||||
Reference in New Issue
Block a user