mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-10-14 05:20:46 +02:00
Add source pipeline classifier runtime
This commit is contained in:
21 files changed
+607
-70
No files matched your search
@@ -129,6 +129,10 @@ Do not classify an entire module as untestable because its main path downloads a
|
||||
- the photo-classify gate in `test_classify.py`: DEFINITELY is ridge AND MLP,
|
||||
POSSIBLY does not emit a provider, and `identify` does not import
|
||||
`remove_ai_watermarks.classify`.
|
||||
- the source-export classifier in `test_source_classify.py`: the Hub revision
|
||||
and artifact hash are immutable, the pickle-free schema and exact GELU are
|
||||
validated, threshold mutation changes the real decision, and `identify`
|
||||
never consumes the result as watermark evidence.
|
||||
- the `InvisibleOptions` defaults, in `test_api.py`. When one signature promises to
|
||||
mirror another, compare them field by field rather than pinning the values you happen
|
||||
to know about, so the next field added on one side and not the other fails at the
|
||||
|
||||
@@ -43,7 +43,7 @@ The published Agent Skill is [`skills/remove-ai-watermarks/`](skills/remove-ai-w
|
||||
|
||||
[`docs/module-internals.md`](docs/module-internals.md) is the canonical per-module map, including design decisions, thresholds, calibration history, incident records, and regression guards. Read the relevant section before changing a subsystem.
|
||||
|
||||
Research and current constraints are routed through [`docs/index.md`](docs/index.md), especially [`docs/known-limitations.md`](docs/known-limitations.md), [`docs/supported-signals.md`](docs/supported-signals.md), [`docs/synthid.md`](docs/synthid.md), and [`docs/watermarking-landscape.md`](docs/watermarking-landscape.md). Pixel photo classification is [`docs/photo-classify.md`](docs/photo-classify.md); it is not `identify`. Classifier research is split between general [`AI-generated image classifiers`](docs/ai-generated-image-classifiers.md) and [`SynthID source classifiers`](docs/synthid-classifiers.md). Other SynthID campaign logs are [`docs/synthid-detector-research.md`](docs/synthid-detector-research.md) and [`docs/synthid-removal-research.md`](docs/synthid-removal-research.md). The pre-split chronological archive is [`docs/synthid-detector-removal-plan.md`](docs/synthid-detector-removal-plan.md). The benchmark kernel and its pinned local oracles (AudioSeal, VideoSeal) cover the audio/video watermark cohorts and research studies: [`docs/benchmark-kernel.md`](docs/benchmark-kernel.md).
|
||||
Research and current constraints are routed through [`docs/index.md`](docs/index.md), especially [`docs/known-limitations.md`](docs/known-limitations.md), [`docs/supported-signals.md`](docs/supported-signals.md), [`docs/synthid.md`](docs/synthid.md), and [`docs/watermarking-landscape.md`](docs/watermarking-landscape.md). Pixel photo classification is [`docs/photo-classify.md`](docs/photo-classify.md); the separate OpenAI/Google/unknown export-pipeline signal is [`docs/source-classify.md`](docs/source-classify.md). Neither runs inside `identify` or constitutes SynthID detection. Classifier research is split between general [`AI-generated image classifiers`](docs/ai-generated-image-classifiers.md) and [`SynthID source classifiers`](docs/synthid-classifiers.md). Other SynthID campaign logs are [`docs/synthid-detector-research.md`](docs/synthid-detector-research.md) and [`docs/synthid-removal-research.md`](docs/synthid-removal-research.md). The pre-split chronological archive is [`docs/synthid-detector-removal-plan.md`](docs/synthid-detector-removal-plan.md). The benchmark kernel and its pinned local oracles (AudioSeal, VideoSeal) cover the audio/video watermark cohorts and research studies: [`docs/benchmark-kernel.md`](docs/benchmark-kernel.md).
|
||||
|
||||
## Data safety
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ specialized Python API can inspect and disrupt the validated local
|
||||
| --- | --- |
|
||||
| Metadata inspection and stripping | `remove-ai-watermarks` |
|
||||
| Photograph AI-versus-camera classification | `remove-ai-watermarks[classify]` |
|
||||
| OpenAI/Google/unknown source-export classification | `remove-ai-watermarks[source-classify]` |
|
||||
| Visible detection and removal | `remove-ai-watermarks[visible]` |
|
||||
| Visible video processing | `remove-ai-watermarks[video]` |
|
||||
| Video SynthID removal | `remove-ai-watermarks[video,diffusion]` |
|
||||
@@ -68,7 +69,7 @@ specialized Python API can inspect and disrupt the validated local
|
||||
| Every production feature available on the active Python | `remove-ai-watermarks[all]` |
|
||||
|
||||
Lower-level and specialized extras include `pixels`, `heif`, `trustmark`,
|
||||
`migan`, `lama`, `diffusion`, and `classify-onnx`. The
|
||||
`migan`, `lama`, `diffusion`, `classify-onnx`, and `source-classify`. The
|
||||
[installation guide](docs/installation.md#feature-extras) documents their exact
|
||||
dependency composition, Python compatibility, and model requirements.
|
||||
|
||||
@@ -96,6 +97,19 @@ remove-ai-watermarks classify image.png
|
||||
|
||||
Guide: [photo pixel classification](docs/photo-classify.md).
|
||||
|
||||
For a lightweight, abstaining OpenAI/Google/unknown source-export signal after
|
||||
metadata removal, use the separate Python API. It is not a SynthID detector:
|
||||
|
||||
```python
|
||||
import remove_ai_watermarks as raiw
|
||||
|
||||
result = raiw.classify_source("image.png")
|
||||
print(result.label, result.reason)
|
||||
```
|
||||
|
||||
Install `remove-ai-watermarks[source-classify]`. Guide:
|
||||
[source-pipeline classification](docs/source-classify.md).
|
||||
|
||||
Signed provenance is the supported route for SynthID and `identify` reads it.
|
||||
There is no local SynthID pixel detector in the package. Research on a
|
||||
periodic lattice expert is in `scripts/synthid_runtime/` and
|
||||
|
||||
@@ -13,6 +13,7 @@ to run the tool. Use the maintainer references only when changing the code.
|
||||
| [Supported signals](supported-signals.md) | You need to know which visible marks, metadata formats, and invisible signals are covered. |
|
||||
| [Known limitations](known-limitations.md) | You need the quality, device, format, or verification boundaries. |
|
||||
| [Photo pixel classification](photo-classify.md) | You want `classify` / `classify_pixels`: AI versus camera, optional provider. |
|
||||
| [Source-pipeline classification](source-classify.md) | You want an abstaining OpenAI/Google/unknown export-pipeline signal after metadata removal. |
|
||||
| [Photo classifier training](photo-classify-training.md) | You need the retrain pack, public sources, and what is not published. |
|
||||
| [Scope, safety, and legal notes](legal-and-safety.md) | You need the intended use and legal context. |
|
||||
|
||||
|
||||
@@ -129,6 +129,7 @@ application actually uses:
|
||||
| `trustmark` | Adobe TrustMark detection on Python 3.11-3.12 | trustmark | Yes |
|
||||
| `classify` | Metadata-free photo AI-versus-camera classifier plus gated provider | `pixels`, Torch, Transformers | Yes |
|
||||
| `classify-onnx` | Optional CPU-only ONNX vision runtime for `classify` | `classify`, ONNX Runtime | Yes |
|
||||
| `source-classify` | Abstaining OpenAI/Google/unknown source-export classifier, not SynthID detection | `pixels`, Hugging Face Hub | Model download, no Torch |
|
||||
| `diffusion` | Torch and Diffusers runtime; video SynthID regeneration | `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 |
|
||||
@@ -156,6 +157,7 @@ flowchart LR
|
||||
trustmark
|
||||
classify --> pixels
|
||||
onnx["classify-onnx"] --> classify
|
||||
source["source-classify"] --> pixels
|
||||
```
|
||||
|
||||
`heif`, `trustmark`, and `text-draft` are independent branches. Combine them
|
||||
|
||||
@@ -20,6 +20,14 @@ The command is never started by `identify`. A no-signal provenance result stays
|
||||
unknown. This is not a clean verdict and it does not run cleanup. Full guide:
|
||||
[photo pixel classification](photo-classify.md).
|
||||
|
||||
`classify_source` is a different, lightweight OpenAI/Google/unknown
|
||||
source-export classifier. It is useful only while the pixels still resemble an
|
||||
original export. A 95% resize reduced measured Google recall to 1%, and a JPEG
|
||||
quality-75 round trip made every public evaluation row abstain. Its provider
|
||||
label is not evidence that SynthID is present, and `unknown` is not evidence
|
||||
that SynthID is absent. It is also never started by `identify`. Full guide:
|
||||
[source-pipeline classification](source-classify.md).
|
||||
|
||||
## Visible removal
|
||||
|
||||
### Fill quality depends on the background
|
||||
|
||||
@@ -1116,6 +1116,30 @@ both backends explicitly (`tests/test_classify.py` pins the seam). User guide:
|
||||
[photo-classify.md](photo-classify.md). Hub card:
|
||||
[photo-classify-hf/README.md](photo-classify-hf/README.md).
|
||||
|
||||
### Source-pipeline classifier
|
||||
|
||||
[`source_classify.py`](../src/remove_ai_watermarks/source_classify.py) is a
|
||||
separate, lightweight OpenAI/Google/unknown source-export classifier. It fuses
|
||||
the existing 124-dimensional patch descriptor with the 768-dimensional
|
||||
phase-free spectrum in
|
||||
[`_internal/source_spectral.py`](../src/remove_ai_watermarks/_internal/source_spectral.py),
|
||||
then evaluates the frozen 892 -> 128 -> 64 -> 3 GELU MLP in NumPy. Provider
|
||||
labels clear independent calibrated margins; every other row is `unknown`.
|
||||
The NumPy decision treats values within `1e-5` of the stored margin as the
|
||||
inclusive training threshold, absorbing float32 BLAS differences; this matches
|
||||
the frozen training-runtime decisions on all 27,811 cached internal and
|
||||
external feature rows.
|
||||
|
||||
The runtime pins both the immutable Hub revision and the NPZ SHA-256, loads
|
||||
with `allow_pickle=False`, and rejects any field, shape, class, activation, or
|
||||
non-finite-value drift. `RAIW_SOURCE_CLASSIFY_WEIGHTS` is the offline override.
|
||||
The `source-classify` extra adds `pixels` and Hugging Face Hub but no Torch or
|
||||
Transformers. `identify` never imports or runs this path. Its output names a
|
||||
complete source/export pipeline and is not SynthID detection. Unit contracts
|
||||
and the threshold mutation guard live in `tests/test_source_classify.py`;
|
||||
evaluation and attack limits live in
|
||||
[`synthid-classifiers.md`](synthid-classifiers.md).
|
||||
|
||||
The CLIP loader suppresses discarded random parameter initialization with
|
||||
Transformers' `no_init_weights` context before applying the complete frozen
|
||||
state dict. Normal `CLIPModel(config)` construction initialized 427 million
|
||||
|
||||
+18
-1
@@ -6,12 +6,29 @@ 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`; pixel photo classification uses `classify`; invisible image removal uses `qwen-zimage` and an NVIDIA GPU; and
|
||||
uses `detect`; pixel photo classification uses `classify`; lightweight source-pipeline classification uses `source-classify`; invisible image removal uses `qwen-zimage` and an NVIDIA GPU; and
|
||||
visible video processing uses `video`. The video-pixel SynthID-removal profile is
|
||||
a separate VAE path that still runs on CPU and 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).
|
||||
|
||||
## Classify a source/export pipeline
|
||||
|
||||
Install `remove-ai-watermarks[source-classify]`, then call the explicit API:
|
||||
|
||||
```python
|
||||
import remove_ai_watermarks as raiw
|
||||
|
||||
result = raiw.classify_source("metadata-stripped.png")
|
||||
print(result.label) # "openai" | "google" | "unknown"
|
||||
print(result.reason) # "classified" | "abstained" | "feature_unavailable"
|
||||
```
|
||||
|
||||
This signal classifies a complete image-generation and export pipeline. It is
|
||||
not a SynthID detector and does not establish watermark presence or absence.
|
||||
See [source-pipeline classification](source-classify.md) for the result fields,
|
||||
model pin, evaluation boundary, and offline weights override.
|
||||
|
||||
## Remove visible marks
|
||||
|
||||
Install `remove-ai-watermarks[visible]` before using the visible-removal API.
|
||||
|
||||
@@ -100,7 +100,9 @@ and aggregate metrics under `docs/source-classify-hf/`. The private corpus,
|
||||
catalog, paths, hashes, embeddings, and feature caches are outside this flow.
|
||||
|
||||
The workflow is manual and requires the model-freeze release tag plus an
|
||||
explicit `publish` confirmation.
|
||||
explicit `publish` confirmation. Keep model-freeze GitHub releases marked as
|
||||
pre-releases so a model artifact cannot replace the latest package release in
|
||||
GitHub's release UI.
|
||||
|
||||
If a distribution job fails because a repository or Hugging Face credential is
|
||||
invalid, rotate the corresponding GitHub secret and rerun the failed job. A
|
||||
|
||||
@@ -62,3 +62,4 @@ should expose the abstention and the model's narrow source-pipeline claim.
|
||||
|
||||
Feature extraction and inference are versioned in the
|
||||
`remove-ai-watermarks` Python project rather than in this model repository.
|
||||
Use its explicit `classify_source` API; `identify` does not run this model.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Source-pipeline classification
|
||||
|
||||
`classify_source` is an opt-in, metadata-free pixel classifier for images that
|
||||
still resemble an original provider export. It returns `openai`, `google`, or
|
||||
an explicit `unknown` abstention.
|
||||
|
||||
This is not a SynthID detector. It does not decode or verify a watermark
|
||||
payload, and it must not be used to claim that SynthID is present or absent.
|
||||
The model describes the complete source/export pipeline, where renderer,
|
||||
geometry, encoding, post-processing, and watermark state can be confounded.
|
||||
|
||||
Install the lightweight runtime:
|
||||
|
||||
```bash
|
||||
uv add "remove-ai-watermarks[source-classify]"
|
||||
```
|
||||
|
||||
Call the Python API explicitly:
|
||||
|
||||
```python
|
||||
import remove_ai_watermarks as raiw
|
||||
|
||||
result = raiw.classify_source("metadata-stripped.png")
|
||||
print(result.label) # "openai" | "google" | "unknown"
|
||||
print(result.reason) # "classified" | "abstained" | "feature_unavailable"
|
||||
print(result.margins) # provider margins before thresholding
|
||||
print(result.thresholds) # frozen provider thresholds
|
||||
```
|
||||
|
||||
`feature_unavailable` is returned for images below the required 256x256
|
||||
geometry or for a degenerate feature vector. `abstained` means neither provider
|
||||
cleared its independent calibrated margin. Both are honest `unknown` results.
|
||||
|
||||
The first call downloads the exact pinned revision of
|
||||
[`wiltodelta/raiw-source-classify`](https://huggingface.co/wiltodelta/raiw-source-classify).
|
||||
Set `RAIW_SOURCE_CLASSIFY_WEIGHTS` to the NPZ itself or a directory containing
|
||||
`source-pipeline-mlp.npz` for an offline deployment.
|
||||
|
||||
The model is useful as a routing suggestion for a processing workflow or as one
|
||||
probabilistic signal in a broader forensic report. Do not use it as evidence of
|
||||
authorship, fraud, policy compliance, provenance, or watermark presence. The
|
||||
published model card records the evaluation and robustness limits.
|
||||
@@ -121,6 +121,10 @@ entries enter the watermark inventory, while a fingerprint remains a
|
||||
re-linkability warning, never selects pixel regeneration, and does not suppress
|
||||
independent SynthID evidence.
|
||||
|
||||
The opt-in `classify_source` API is not an `identify` signal. It can suggest an
|
||||
OpenAI or Google source/export pipeline after metadata removal, but it neither
|
||||
detects nor decodes a watermark and never changes the provenance verdict.
|
||||
|
||||
## File and container formats
|
||||
|
||||
Pixel based image commands discover these extensions:
|
||||
|
||||
@@ -144,7 +144,8 @@ metadata removal. It neither decodes nor detects SynthID, and its `unknown`
|
||||
label does not mean that SynthID is absent. The model card and aggregate
|
||||
metrics are tracked under
|
||||
[`docs/source-classify-hf/`](source-classify-hf/README.md). Runtime integration
|
||||
belongs in the library rather than in the model repository.
|
||||
is provided by the explicit `classify_source` library API and never feeds the
|
||||
`identify` watermark verdict.
|
||||
|
||||
Krawetz's Gemini-chat TPR critique is a verifier-quality claim, not a
|
||||
feature we can ship. [Lead Stories, 2026-07](https://leadstories.com/analysis/2026/07/google-gemini-synthid-detector-confuses-results-within-same-chat.html)
|
||||
|
||||
+9
-2
@@ -4,7 +4,7 @@
|
||||
# on it, including the ComfyUI node package. The console script below carries
|
||||
# the same weight, since users have it on PATH.
|
||||
name = "remove-ai-watermarks"
|
||||
version = "0.40.6"
|
||||
version = "0.41.0"
|
||||
description = "AI watermark remover for visible, invisible, and provenance marks in images and video"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11,<3.15"
|
||||
@@ -160,6 +160,13 @@ classify-onnx = [
|
||||
"remove-ai-watermarks[classify]",
|
||||
"onnxruntime>=1.24.0",
|
||||
]
|
||||
# Compact OpenAI/Google/unknown source-export classifier. This is intentionally
|
||||
# separate from the heavier photo AI-versus-camera classifier and is not a
|
||||
# SynthID detector. Weights download on first use.
|
||||
source-classify = [
|
||||
"remove-ai-watermarks[pixels]",
|
||||
"huggingface-hub>=0.20.0",
|
||||
]
|
||||
# Universal region eraser backend -- big-LaMa via onnxruntime (Carve/LaMa-ONNX,
|
||||
# Apache-2.0). CPU, no torch. Model (~200 MB) is downloaded on first use and
|
||||
# cached by huggingface_hub; it is never bundled in this repo. The default cv2
|
||||
@@ -205,7 +212,7 @@ dev = [
|
||||
]
|
||||
# ``qwen-zimage`` already pulls ``diffusion``; naming both would suggest diffusion is
|
||||
# independently sufficient for a removal, which it is not.
|
||||
all = ["remove-ai-watermarks[video,heif,detect,trustmark,text-restoration,migan,classify]"]
|
||||
all = ["remove-ai-watermarks[video,heif,detect,trustmark,text-restoration,migan,classify,source-classify]"]
|
||||
|
||||
[project.scripts]
|
||||
remove-ai-watermarks = "remove_ai_watermarks.cli:main"
|
||||
|
||||
@@ -30,6 +30,14 @@ import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
from PIL import Image, ImageFilter
|
||||
|
||||
from remove_ai_watermarks._internal import source_spectral as _spectral
|
||||
|
||||
SpectralGeometry = _spectral.SpectralGeometry
|
||||
make_geometry = _spectral.make_geometry
|
||||
phase_free_log_spectrum = _spectral.phase_free_log_spectrum
|
||||
spectral_feature = _spectral.spectral_feature
|
||||
_source_feature_from_image = _spectral.feature_from_image
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
IMAGE_SUFFIXES = frozenset({".jpg", ".jpeg", ".png", ".webp"})
|
||||
@@ -40,17 +48,6 @@ IntArray = NDArray[np.int64]
|
||||
BoolArray = NDArray[np.bool_]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SpectralGeometry:
|
||||
"""Precomputed coordinates for one phase-free spectral representation."""
|
||||
|
||||
size: int
|
||||
grid: int
|
||||
radius: NDArray[np.int32]
|
||||
radius_counts: IntArray
|
||||
passband: BoolArray
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FoldModel:
|
||||
"""One standardized dual-ridge model and its held-out rows."""
|
||||
@@ -62,51 +59,6 @@ class FoldModel:
|
||||
weights: FloatArray
|
||||
|
||||
|
||||
def make_geometry(size: int = 256, grid: int = 32) -> SpectralGeometry:
|
||||
"""Build the radial detrending and pooling geometry."""
|
||||
if size < 16 or grid < 2 or size % grid:
|
||||
raise ValueError("size must be at least 16 and divisible by grid")
|
||||
yy, xx = np.mgrid[:size, :size]
|
||||
radius = np.rint(np.hypot(yy - size // 2, xx - size // 2)).astype(np.int32)
|
||||
radius_counts = np.bincount(radius.ravel())
|
||||
passband = (radius >= 4) & (radius <= size * 0.48)
|
||||
return SpectralGeometry(size, grid, radius, radius_counts, passband)
|
||||
|
||||
|
||||
def phase_free_log_spectrum(channel: FloatArray) -> FloatArray:
|
||||
"""Return centered log FFT magnitude, deliberately discarding phase."""
|
||||
if channel.ndim != 2:
|
||||
raise ValueError("channel must be a two-dimensional array")
|
||||
centered = np.asarray(channel, dtype=np.float64) - float(np.mean(channel))
|
||||
return np.log1p(np.abs(np.fft.fftshift(np.fft.fft2(centered))))
|
||||
|
||||
|
||||
def spectral_feature(pixels: FloatArray, geometry: SpectralGeometry) -> FloatArray:
|
||||
"""Extract pooled radial-residual spectra from luma and opponent color."""
|
||||
if pixels.shape != (geometry.size, geometry.size, 3):
|
||||
raise ValueError(f"pixels must have shape {(geometry.size, geometry.size, 3)}, got {pixels.shape}")
|
||||
red, green, blue = np.moveaxis(np.asarray(pixels, dtype=np.float64), 2, 0)
|
||||
channels = (
|
||||
0.299 * red + 0.587 * green + 0.114 * blue,
|
||||
red - green,
|
||||
blue - (red + green) / 2.0,
|
||||
)
|
||||
block = geometry.size // geometry.grid
|
||||
features: list[FloatArray] = []
|
||||
for channel in channels:
|
||||
spectrum = phase_free_log_spectrum(channel)
|
||||
radial_sum = np.bincount(geometry.radius.ravel(), weights=spectrum.ravel())
|
||||
radial_mean = radial_sum / geometry.radius_counts
|
||||
residual = np.where(
|
||||
geometry.passband,
|
||||
spectrum - radial_mean[geometry.radius],
|
||||
0.0,
|
||||
)
|
||||
pooled = residual.reshape(geometry.grid, block, geometry.grid, block).mean(axis=(1, 3))
|
||||
features.append(pooled.ravel())
|
||||
return np.concatenate(features)
|
||||
|
||||
|
||||
def _apply_attack(image: Image.Image, attack: str) -> Image.Image:
|
||||
"""Apply one declared robustness transform before canonical resizing."""
|
||||
if attack == "original":
|
||||
@@ -140,9 +92,7 @@ def feature_from_path(path: Path, geometry: SpectralGeometry, attack: str) -> Fl
|
||||
def feature_from_image(image: Image.Image, geometry: SpectralGeometry, attack: str) -> FloatArray:
|
||||
"""Extract one attacked feature from an already decoded RGB image."""
|
||||
attacked = _apply_attack(image, attack)
|
||||
canonical = attacked.resize((geometry.size, geometry.size), Image.Resampling.LANCZOS)
|
||||
pixels = np.asarray(canonical, dtype=np.float64) / 255.0
|
||||
return spectral_feature(pixels, geometry)
|
||||
return _source_feature_from_image(attacked, geometry)
|
||||
|
||||
|
||||
def discover_grouped_paths(
|
||||
|
||||
@@ -14,6 +14,7 @@ High-level API (lazy, so ``import remove_ai_watermarks`` stays cheap)::
|
||||
raiw.remove_video_metadata("in.mp4", "out.mp4") # verified metadata strip
|
||||
raiw.remove_video_invisible("in.mp4", "out.mp4") # calibrated video-pixel regeneration
|
||||
raiw.remove_video_visible("in.mp4", "out.mp4") # stable visible video-mark removal
|
||||
raiw.classify_source("in.png") # source pipeline, not SynthID detection
|
||||
|
||||
For a provenance verdict use the ``identify`` submodule::
|
||||
|
||||
@@ -33,7 +34,7 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
|
||||
_warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*")
|
||||
|
||||
|
||||
__version__ = "0.40.6"
|
||||
__version__ = "0.41.0"
|
||||
|
||||
__all__ = [
|
||||
"BatchItemResult",
|
||||
@@ -41,8 +42,10 @@ __all__ = [
|
||||
"InvisibleOptions",
|
||||
"MetadataStripIncomplete",
|
||||
"RemoveAllResult",
|
||||
"SourceClassification",
|
||||
"VisibleRemovalResult",
|
||||
"__version__",
|
||||
"classify_source",
|
||||
"identify_video",
|
||||
"inspect_video_metadata",
|
||||
"remove_all",
|
||||
@@ -71,6 +74,7 @@ if TYPE_CHECKING:
|
||||
remove_visible_detailed,
|
||||
visible_provenance,
|
||||
)
|
||||
from remove_ai_watermarks.source_classify import SourceClassification, classify_source
|
||||
from remove_ai_watermarks.video import (
|
||||
identify_video,
|
||||
inspect_video_metadata,
|
||||
@@ -101,6 +105,10 @@ def __getattr__(name: str) -> object:
|
||||
from remove_ai_watermarks import api
|
||||
|
||||
return getattr(api, name)
|
||||
if name in ("SourceClassification", "classify_source"):
|
||||
from remove_ai_watermarks import source_classify
|
||||
|
||||
return getattr(source_classify, name)
|
||||
if name in (
|
||||
"identify_video",
|
||||
"inspect_video_metadata",
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Phase-free spectral features for source/export-pipeline classification."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from numpy.typing import NDArray
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SpectralGeometry:
|
||||
"""Precomputed coordinates for one phase-free spectral representation."""
|
||||
|
||||
size: int
|
||||
grid: int
|
||||
radius: NDArray[np.int32]
|
||||
radius_counts: NDArray[np.int64]
|
||||
passband: NDArray[np.bool_]
|
||||
|
||||
|
||||
@lru_cache(maxsize=4)
|
||||
def make_geometry(size: int = 256, grid: int = 16) -> SpectralGeometry:
|
||||
"""Build radial detrending and pooling geometry."""
|
||||
if size < 16 or grid < 2 or size % grid:
|
||||
raise ValueError("size must be at least 16 and divisible by grid")
|
||||
yy, xx = np.mgrid[:size, :size]
|
||||
radius = np.rint(np.hypot(yy - size // 2, xx - size // 2)).astype(np.int32)
|
||||
radius_counts = np.bincount(radius.ravel())
|
||||
passband = (radius >= 4) & (radius <= size * 0.48)
|
||||
return SpectralGeometry(size, grid, radius, radius_counts, passband)
|
||||
|
||||
|
||||
def phase_free_log_spectrum(channel: NDArray[np.float64]) -> NDArray[np.float64]:
|
||||
"""Return centered log FFT magnitude, deliberately discarding phase."""
|
||||
if channel.ndim != 2:
|
||||
raise ValueError("channel must be a two-dimensional array")
|
||||
centered = channel - float(np.mean(channel))
|
||||
return np.log1p(np.abs(np.fft.fftshift(np.fft.fft2(centered))))
|
||||
|
||||
|
||||
def spectral_feature(pixels: NDArray[np.float64], geometry: SpectralGeometry) -> NDArray[np.float64]:
|
||||
"""Extract pooled radial-residual spectra from luma and opponent color."""
|
||||
expected = (geometry.size, geometry.size, 3)
|
||||
if pixels.shape != expected:
|
||||
raise ValueError(f"pixels must have shape {expected}, got {pixels.shape}")
|
||||
red, green, blue = np.moveaxis(pixels, 2, 0)
|
||||
channels = (
|
||||
0.299 * red + 0.587 * green + 0.114 * blue,
|
||||
red - green,
|
||||
blue - (red + green) / 2.0,
|
||||
)
|
||||
block = geometry.size // geometry.grid
|
||||
features: list[NDArray[np.float64]] = []
|
||||
for channel in channels:
|
||||
spectrum = phase_free_log_spectrum(channel)
|
||||
radial_sum = np.bincount(geometry.radius.ravel(), weights=spectrum.ravel())
|
||||
radial_mean = radial_sum / geometry.radius_counts
|
||||
residual = np.where(
|
||||
geometry.passband,
|
||||
spectrum - radial_mean[geometry.radius],
|
||||
0.0,
|
||||
)
|
||||
pooled = residual.reshape(geometry.grid, block, geometry.grid, block).mean(axis=(1, 3))
|
||||
features.append(pooled.ravel())
|
||||
return np.concatenate(features)
|
||||
|
||||
|
||||
def feature_from_image(image: Image.Image, geometry: SpectralGeometry) -> NDArray[np.float64]:
|
||||
"""Resize an RGB image canonically and extract its phase-free feature."""
|
||||
canonical = image.resize((geometry.size, geometry.size), Image.Resampling.LANCZOS)
|
||||
pixels = np.asarray(canonical, dtype=np.float64) / 255.0
|
||||
return spectral_feature(pixels, geometry)
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Abstaining source/export-pipeline classification from image pixels.
|
||||
|
||||
This module does not detect or decode SynthID. It labels original-export-looking
|
||||
pixel pipelines as OpenAI, Google, or unknown after metadata may have been
|
||||
removed. Call it explicitly; ``identify`` never imports or runs it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from hashlib import sha256
|
||||
from importlib import import_module
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from numpy.typing import NDArray
|
||||
|
||||
SOURCE_CLASSIFY_EXTRA = "'remove-ai-watermarks[source-classify]'"
|
||||
WEIGHTS_ENV = "RAIW_SOURCE_CLASSIFY_WEIGHTS"
|
||||
WEIGHTS_REPO = "wiltodelta/raiw-source-classify"
|
||||
WEIGHTS_REVISION = "4b8b408d03dac20231d46217977fd164414b6b9e"
|
||||
MODEL_FILE = "source-pipeline-mlp.npz"
|
||||
MODEL_SHA256 = "6430b0738869ff07b6845b7c6149c2a83bc3f3785878082886c241c6f7ca0c4a"
|
||||
CLASSES = ("openai", "google", "unknown")
|
||||
PROVIDERS = CLASSES[:2]
|
||||
FEATURE_WIDTH = 892
|
||||
SPECTRAL_GRID = 16
|
||||
CLAIM = "source/export pipeline classification; not SynthID detection"
|
||||
# NumPy and the training runtime can differ by a few float32 ULPs at a matrix
|
||||
# boundary. Treat values within this tolerance as the stored threshold so the
|
||||
# exported runtime preserves the training runtime's >= decision.
|
||||
THRESHOLD_TOLERANCE = 1e-5
|
||||
|
||||
SourceLabel = Literal["openai", "google", "unknown"]
|
||||
SourceReason = Literal["classified", "abstained", "feature_unavailable"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceClassification:
|
||||
"""One narrow source-pipeline result with an explicit abstention."""
|
||||
|
||||
label: SourceLabel
|
||||
reason: SourceReason
|
||||
scores: dict[str, float]
|
||||
margins: dict[str, float]
|
||||
thresholds: dict[str, float]
|
||||
claim: str = CLAIM
|
||||
model_revision: str = WEIGHTS_REVISION
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"label": self.label,
|
||||
"reason": self.reason,
|
||||
"scores": self.scores,
|
||||
"margins": self.margins,
|
||||
"thresholds": self.thresholds,
|
||||
"claim": self.claim,
|
||||
"model_revision": self.model_revision,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Model:
|
||||
mean: NDArray[Any]
|
||||
scale: NDArray[Any]
|
||||
margins: NDArray[Any]
|
||||
layer0_weight: NDArray[Any]
|
||||
layer0_bias: NDArray[Any]
|
||||
layer1_weight: NDArray[Any]
|
||||
layer1_bias: NDArray[Any]
|
||||
output_weight: NDArray[Any]
|
||||
output_bias: NDArray[Any]
|
||||
|
||||
|
||||
def is_available() -> bool:
|
||||
"""Return whether the lightweight source-classify runtime is installed."""
|
||||
from remove_ai_watermarks.optional_deps import module_available
|
||||
|
||||
return module_available("numpy", "cv2", "huggingface_hub")
|
||||
|
||||
|
||||
def _model_path() -> Path:
|
||||
override = os.environ.get(WEIGHTS_ENV, "").strip()
|
||||
if override:
|
||||
path = Path(override).expanduser()
|
||||
candidate = path / MODEL_FILE if path.is_dir() else path
|
||||
if not candidate.is_file():
|
||||
raise RuntimeError(f"{WEIGHTS_ENV} does not contain {MODEL_FILE}: {path}")
|
||||
return candidate
|
||||
try:
|
||||
hub: Any = import_module("huggingface_hub")
|
||||
except ModuleNotFoundError as error:
|
||||
raise RuntimeError(f"Install {SOURCE_CLASSIFY_EXTRA} to download the source classifier") from error
|
||||
return Path(
|
||||
hub.hf_hub_download(
|
||||
repo_id=WEIGHTS_REPO,
|
||||
filename=MODEL_FILE,
|
||||
revision=WEIGHTS_REVISION,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=2)
|
||||
def _load_model(path: Path, expected_sha256: str | None = MODEL_SHA256) -> _Model:
|
||||
import numpy as np
|
||||
|
||||
if expected_sha256 is not None:
|
||||
digest = sha256(path.read_bytes()).hexdigest()
|
||||
if digest != expected_sha256:
|
||||
raise ValueError(f"model SHA-256 mismatch: expected {expected_sha256}, got {digest}")
|
||||
required = {
|
||||
"classes",
|
||||
"kind",
|
||||
"mean",
|
||||
"scale",
|
||||
"margins",
|
||||
"layer0_weight",
|
||||
"layer0_bias",
|
||||
"layer1_weight",
|
||||
"layer1_bias",
|
||||
"output_weight",
|
||||
"output_bias",
|
||||
"activation",
|
||||
}
|
||||
with np.load(path, allow_pickle=False) as payload:
|
||||
if set(payload.files) != required:
|
||||
raise ValueError(f"model fields must be exactly {sorted(required)}")
|
||||
if tuple(str(value) for value in payload["classes"]) != CLASSES:
|
||||
raise ValueError(f"model classes must be {CLASSES}")
|
||||
if str(payload["kind"]) != "fused":
|
||||
raise ValueError("model kind must be fused")
|
||||
if str(payload["activation"]) != "gelu_exact":
|
||||
raise ValueError("model activation must be gelu_exact")
|
||||
arrays = {
|
||||
name: np.asarray(
|
||||
payload[name],
|
||||
dtype=np.float64 if name in {"mean", "scale", "margins"} else np.float32,
|
||||
)
|
||||
for name in required - {"classes", "kind", "activation"}
|
||||
}
|
||||
shapes = {
|
||||
"mean": (FEATURE_WIDTH,),
|
||||
"scale": (FEATURE_WIDTH,),
|
||||
"margins": (len(PROVIDERS),),
|
||||
"layer0_weight": (128, FEATURE_WIDTH),
|
||||
"layer0_bias": (128,),
|
||||
"layer1_weight": (64, 128),
|
||||
"layer1_bias": (64,),
|
||||
"output_weight": (len(CLASSES), 64),
|
||||
"output_bias": (len(CLASSES),),
|
||||
}
|
||||
for name, expected in shapes.items():
|
||||
if arrays[name].shape != expected:
|
||||
raise ValueError(f"model field {name} must have shape {expected}")
|
||||
if not np.all(np.isfinite(arrays[name])):
|
||||
raise ValueError(f"model field {name} must be finite")
|
||||
if np.any(arrays["scale"] <= 0) or np.any(arrays["margins"] < 0):
|
||||
raise ValueError("model scales must be positive and margins non-negative")
|
||||
return _Model(**arrays)
|
||||
|
||||
|
||||
def _gelu_exact(values: NDArray[Any]) -> NDArray[Any]:
|
||||
import numpy as np
|
||||
|
||||
flat = values.ravel()
|
||||
errors = np.fromiter((math.erf(float(value) / math.sqrt(2.0)) for value in flat), dtype=np.float32)
|
||||
return (0.5 * flat * (1.0 + errors)).reshape(values.shape).astype(np.float32)
|
||||
|
||||
|
||||
def _scores(features: NDArray[Any], model: _Model) -> NDArray[Any]:
|
||||
import numpy as np
|
||||
|
||||
values = np.asarray((features - model.mean) / model.scale, dtype=np.float32)
|
||||
hidden0 = _gelu_exact(model.layer0_weight @ values + model.layer0_bias)
|
||||
hidden1 = _gelu_exact(model.layer1_weight @ hidden0 + model.layer1_bias)
|
||||
return np.asarray(model.output_weight @ hidden1 + model.output_bias, dtype=np.float64)
|
||||
|
||||
|
||||
def _result(raw_scores: NDArray[Any], thresholds: NDArray[Any]) -> SourceClassification:
|
||||
import numpy as np
|
||||
|
||||
margins = np.empty(len(PROVIDERS), dtype=np.float64)
|
||||
for index in range(len(PROVIDERS)):
|
||||
competitors = np.delete(raw_scores, index)
|
||||
margins[index] = raw_scores[index] - float(np.max(competitors))
|
||||
excess = margins - thresholds
|
||||
best = int(np.argmax(excess))
|
||||
accepted = bool(excess[best] >= -THRESHOLD_TOLERANCE)
|
||||
label: SourceLabel = PROVIDERS[best] if accepted else "unknown"
|
||||
return SourceClassification(
|
||||
label=label,
|
||||
reason="classified" if accepted else "abstained",
|
||||
scores={name: float(raw_scores[index]) for index, name in enumerate(CLASSES)},
|
||||
margins={name: float(margins[index]) for index, name in enumerate(PROVIDERS)},
|
||||
thresholds={name: float(thresholds[index]) for index, name in enumerate(PROVIDERS)},
|
||||
)
|
||||
|
||||
|
||||
def _unavailable(model: _Model) -> SourceClassification:
|
||||
return SourceClassification(
|
||||
label="unknown",
|
||||
reason="feature_unavailable",
|
||||
scores={},
|
||||
margins={},
|
||||
thresholds={name: float(model.margins[index]) for index, name in enumerate(PROVIDERS)},
|
||||
)
|
||||
|
||||
|
||||
def classify_source(source: str | os.PathLike[str]) -> SourceClassification:
|
||||
"""Classify an original-looking image export, abstaining as ``unknown``.
|
||||
|
||||
The result describes a complete source/export pipeline. It is not evidence
|
||||
that SynthID is present or absent.
|
||||
"""
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from remove_ai_watermarks import image_io
|
||||
from remove_ai_watermarks._internal.forensic_124d import image_features
|
||||
from remove_ai_watermarks._internal.source_spectral import feature_from_image, make_geometry
|
||||
|
||||
model = _load_model(_model_path(), expected_sha256=MODEL_SHA256)
|
||||
image_io._register_heif() # pyright: ignore[reportPrivateUsage]
|
||||
with Image.open(source) as opened:
|
||||
image = opened.convert("RGB")
|
||||
pixels = np.asarray(image, dtype=np.uint8)
|
||||
forensic = image_features(pixels)
|
||||
if forensic is None:
|
||||
return _unavailable(model)
|
||||
spectral = feature_from_image(image, make_geometry(grid=SPECTRAL_GRID))
|
||||
features = np.concatenate((forensic, spectral))
|
||||
if features.shape != (FEATURE_WIDTH,) or not np.all(np.isfinite(features)):
|
||||
return _unavailable(model)
|
||||
return _result(_scores(features, model), model.margins)
|
||||
@@ -23,6 +23,10 @@ class TestTopLevelExports:
|
||||
assert raiw.remove_visible_detailed is api.remove_visible_detailed
|
||||
assert raiw.VisibleRemovalResult is api.VisibleRemovalResult
|
||||
assert raiw.visible_provenance is api.visible_provenance
|
||||
from remove_ai_watermarks import source_classify
|
||||
|
||||
assert raiw.classify_source is source_classify.classify_source
|
||||
assert raiw.SourceClassification is source_classify.SourceClassification
|
||||
|
||||
def test_unknown_attribute_raises(self):
|
||||
with pytest.raises(AttributeError):
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Unit contracts for the lightweight source-pipeline runtime."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from hashlib import sha256
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from remove_ai_watermarks import source_classify
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_model_identity_is_immutable() -> None:
|
||||
assert re.fullmatch(r"[0-9a-f]{40}", source_classify.WEIGHTS_REVISION)
|
||||
assert re.fullmatch(r"[0-9a-f]{64}", source_classify.MODEL_SHA256)
|
||||
|
||||
|
||||
def _artifact(path: Path, *, activation: str = "gelu_exact", output_bias: tuple[float, ...] = (0.0, 0.0, 0.0)) -> None:
|
||||
np.savez_compressed(
|
||||
path,
|
||||
classes=np.asarray(source_classify.CLASSES),
|
||||
kind=np.asarray("fused"),
|
||||
mean=np.zeros(source_classify.FEATURE_WIDTH),
|
||||
scale=np.ones(source_classify.FEATURE_WIDTH),
|
||||
margins=np.asarray((0.5, 0.5)),
|
||||
layer0_weight=np.zeros((128, source_classify.FEATURE_WIDTH), dtype=np.float32),
|
||||
layer0_bias=np.zeros(128, dtype=np.float32),
|
||||
layer1_weight=np.zeros((64, 128), dtype=np.float32),
|
||||
layer1_bias=np.zeros(64, dtype=np.float32),
|
||||
output_weight=np.zeros((3, 64), dtype=np.float32),
|
||||
output_bias=np.asarray(output_bias, dtype=np.float32),
|
||||
activation=np.asarray(activation),
|
||||
)
|
||||
|
||||
|
||||
def test_loader_accepts_the_exact_pickle_free_schema(tmp_path: Path) -> None:
|
||||
path = tmp_path / source_classify.MODEL_FILE
|
||||
_artifact(path)
|
||||
|
||||
model = source_classify._load_model(path, expected_sha256=None)
|
||||
|
||||
assert model.mean.shape == (source_classify.FEATURE_WIDTH,)
|
||||
assert model.margins.tolist() == pytest.approx([0.5, 0.5])
|
||||
|
||||
|
||||
def test_loader_rejects_the_mislabeled_activation(tmp_path: Path) -> None:
|
||||
path = tmp_path / source_classify.MODEL_FILE
|
||||
_artifact(path, activation="gelu_tanh")
|
||||
|
||||
with pytest.raises(ValueError, match="activation must be gelu_exact"):
|
||||
source_classify._load_model(path, expected_sha256=None)
|
||||
|
||||
|
||||
def test_provider_threshold_mutation_changes_the_real_decision(tmp_path: Path) -> None:
|
||||
path = tmp_path / source_classify.MODEL_FILE
|
||||
_artifact(path, output_bias=(2.0, 0.0, 0.0))
|
||||
model = source_classify._load_model(path, expected_sha256=None)
|
||||
features = np.zeros(source_classify.FEATURE_WIDTH, dtype=np.float32)
|
||||
scores = source_classify._scores(features, model)
|
||||
|
||||
accepted = source_classify._result(scores, np.asarray((1.5, 0.5), dtype=np.float32))
|
||||
rejected = source_classify._result(scores, np.asarray((2.5, 0.5), dtype=np.float32))
|
||||
|
||||
assert accepted.label == "openai"
|
||||
assert accepted.reason == "classified"
|
||||
assert rejected.label == "unknown"
|
||||
assert rejected.reason == "abstained"
|
||||
|
||||
|
||||
def test_a_float32_threshold_tie_preserves_the_inclusive_training_decision() -> None:
|
||||
scores = np.asarray((2.0, 0.0, 0.0), dtype=np.float32)
|
||||
result = source_classify._result(scores, np.asarray((2.0, 0.5), dtype=np.float32))
|
||||
|
||||
assert result.label == "openai"
|
||||
assert result.reason == "classified"
|
||||
|
||||
|
||||
def test_small_image_returns_honest_unknown(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from PIL import Image
|
||||
|
||||
weights = tmp_path / "weights"
|
||||
weights.mkdir()
|
||||
_artifact(weights / source_classify.MODEL_FILE)
|
||||
monkeypatch.setenv(source_classify.WEIGHTS_ENV, str(weights))
|
||||
monkeypatch.setattr(
|
||||
source_classify,
|
||||
"MODEL_SHA256",
|
||||
sha256((weights / source_classify.MODEL_FILE).read_bytes()).hexdigest(),
|
||||
)
|
||||
image = tmp_path / "small.png"
|
||||
Image.new("RGB", (128, 128), "white").save(image)
|
||||
|
||||
result = source_classify.classify_source(image)
|
||||
|
||||
assert result.label == "unknown"
|
||||
assert result.reason == "feature_unavailable"
|
||||
assert result.scores == {}
|
||||
|
||||
|
||||
def test_model_hash_is_a_runtime_precondition(tmp_path: Path) -> None:
|
||||
path = tmp_path / source_classify.MODEL_FILE
|
||||
_artifact(path)
|
||||
|
||||
with pytest.raises(ValueError, match="model SHA-256 mismatch"):
|
||||
source_classify._load_model(path, expected_sha256="0" * 64)
|
||||
|
||||
|
||||
def test_spectral_feature_is_finite_and_has_the_frozen_width() -> None:
|
||||
from PIL import Image
|
||||
|
||||
from remove_ai_watermarks._internal.source_spectral import feature_from_image, make_geometry
|
||||
|
||||
rng = np.random.default_rng(20260916)
|
||||
pixels = rng.integers(0, 256, size=(300, 400, 3), dtype=np.uint8)
|
||||
feature = feature_from_image(Image.fromarray(pixels), make_geometry())
|
||||
|
||||
assert feature.shape == (768,)
|
||||
assert np.all(np.isfinite(feature))
|
||||
@@ -3449,7 +3449,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "remove-ai-watermarks"
|
||||
version = "0.40.6"
|
||||
version = "0.41.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "c2pa-python" },
|
||||
@@ -3584,6 +3584,13 @@ qwen-zimage = [
|
||||
{ name = "torchvision" },
|
||||
{ name = "transformers" },
|
||||
]
|
||||
source-classify = [
|
||||
{ name = "huggingface-hub" },
|
||||
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" },
|
||||
{ name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" },
|
||||
{ name = "opencv-python-headless", version = "4.11.0.86", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" },
|
||||
{ name = "opencv-python-headless", version = "4.14.0.94", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" },
|
||||
]
|
||||
text-draft = [
|
||||
{ name = "huggingface-hub" },
|
||||
{ name = "paddleocr" },
|
||||
@@ -3637,6 +3644,7 @@ requires-dist = [
|
||||
{ name = "huggingface-hub", marker = "extra == 'dev'", specifier = ">=0.20.0" },
|
||||
{ name = "huggingface-hub", marker = "extra == 'lama'", specifier = ">=0.20.0" },
|
||||
{ name = "huggingface-hub", marker = "extra == 'migan'", specifier = ">=0.20.0" },
|
||||
{ name = "huggingface-hub", marker = "extra == 'source-classify'", specifier = ">=0.20.0" },
|
||||
{ name = "huggingface-hub", marker = "extra == 'text-draft'", specifier = ">=0.20.0" },
|
||||
{ name = "invisible-watermark", marker = "extra == 'dev'", specifier = ">=0.2.0" },
|
||||
{ name = "numpy", marker = "python_full_version >= '3.13' and extra == 'pixels'", specifier = ">=2" },
|
||||
@@ -3665,10 +3673,11 @@ requires-dist = [
|
||||
{ name = "remove-ai-watermarks", extras = ["pixels"], marker = "extra == 'classify'" },
|
||||
{ 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 == 'source-classify'" },
|
||||
{ name = "remove-ai-watermarks", extras = ["pixels"], marker = "extra == 'visible'" },
|
||||
{ name = "remove-ai-watermarks", extras = ["qwen-zimage", "lama"], marker = "extra == 'text-restoration'" },
|
||||
{ name = "remove-ai-watermarks", extras = ["video"], marker = "extra == 'dev'" },
|
||||
{ name = "remove-ai-watermarks", extras = ["video", "heif", "detect", "trustmark", "text-restoration", "migan", "classify"], marker = "extra == 'all'" },
|
||||
{ name = "remove-ai-watermarks", extras = ["video", "heif", "detect", "trustmark", "text-restoration", "migan", "classify", "source-classify"], 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'" },
|
||||
@@ -3684,7 +3693,7 @@ requires-dist = [
|
||||
{ name = "transformers", marker = "extra == 'diffusion'", specifier = ">=5,<6" },
|
||||
{ name = "trustmark", marker = "python_full_version < '3.13' and extra == 'trustmark'", specifier = ">=0.8.0" },
|
||||
]
|
||||
provides-extras = ["pixels", "heif", "visible", "video", "detect", "diffusion", "qwen-zimage", "text-restoration", "text-draft", "trustmark", "classify", "classify-onnx", "lama", "migan", "dev", "all"]
|
||||
provides-extras = ["pixels", "heif", "visible", "video", "detect", "diffusion", "qwen-zimage", "text-restoration", "text-draft", "trustmark", "classify", "classify-onnx", "source-classify", "lama", "migan", "dev", "all"]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
|
||||
Reference in new issue
Block a user