mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-10-14 05:20:46 +02:00
Merge origin/main into docs/library-research-landscape
Main's claim_generator_platform and c2pa_vendor_platform now use the branch's whole-word registry match, which replaces claim_generator_word_platform. The branch's provenance veto covers every video mark and made main's Veo-over-Sora override unreachable, so the override is removed. Main's credential rule carries into the veto: an invalid credential vetoes nothing. Provenance that confirms the mark's own vendor also vetoes nothing. Over 78 local videos with a C2PA AI claim, the only verdict changes against main are Kling/Hailuo false positives on Veo and FLUX clips that the veto now removes. C2PA verdicts over 1,379 files are unchanged against the pre-merge branch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
77 files changed
+2475
-1488
No files matched your search
@@ -303,3 +303,7 @@ explicit unchanged decodes when comparing stored pixels. Execute Ubuntu Bash
|
||||
workflow harnesses only on POSIX hosts with Bash; keep their pure Python checks
|
||||
active on every platform. Round floating-point image output before uint8
|
||||
conversion when quantization must preserve constant values across OpenCV builds.
|
||||
OpenCV 5 draws Hershey `putText` glyphs smaller and heavier than 4.x, so a
|
||||
calibrated template drawn with `putText` ships as a committed PNG whose recipe
|
||||
refuses any OpenCV but 4.x (`scripts/render_video_hershey_templates.py`,
|
||||
`scripts/build_openart_alpha.py`); never render one at import time.
|
||||
@@ -6,6 +6,10 @@
|
||||
# Create and manage keys at: https://platform.openai.com/api-keys
|
||||
# OPENAI_API_KEY=
|
||||
|
||||
# GitHub token (optional; only for scripts/sync_c2pa_soft_bindings.py)
|
||||
# Raises the api.github.com rate limit from 60 to 5000 requests/hour. GH_TOKEN is read as a fallback.
|
||||
# GITHUB_TOKEN=
|
||||
|
||||
# Local photo-classifier weights (optional; otherwise fetched from Hugging Face)
|
||||
# Directory contents are documented in docs/photo-classify.md.
|
||||
# RAIW_CLASSIFY_WEIGHTS=/path/to/classifier-weights
|
||||
|
||||
@@ -14,18 +14,15 @@ updates:
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
# The `all` extra must remain jointly resolvable. These candidates violate
|
||||
# compatibility constraints: older supported TrustMark builds require 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.
|
||||
# The `all` extra must remain jointly resolvable. PaddleX 3.7 (the OCR
|
||||
# extra) requires NumPy <2.4, mirrored by `constraint-dependencies` in
|
||||
# pyproject.toml, 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"
|
||||
- ">=2.4"
|
||||
- dependency-name: "tokenizers"
|
||||
versions:
|
||||
- ">=0.23"
|
||||
|
||||
@@ -21,6 +21,8 @@ on:
|
||||
|
||||
jobs:
|
||||
resolve:
|
||||
# Model-only prereleases must not reach PyPI, Homebrew, Spaces, or ComfyUI.
|
||||
if: ${{ github.event_name != 'release' || (startsWith(github.event.release.tag_name, 'v') && !github.event.release.prerelease) }}
|
||||
runs-on: ubuntu-latest
|
||||
# Thirty attempts may spend up to 20 seconds each in the JSON request,
|
||||
# simple-index request, and inter-attempt delay. Leave bounded headroom.
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
name: Publish image-source classifier to Hugging Face
|
||||
|
||||
# Manual only. The derivative weights live in a GitHub model-freeze release;
|
||||
# private training images and caches never enter this repository.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
confirm:
|
||||
description: "Type publish"
|
||||
required: true
|
||||
weights_tag:
|
||||
description: "GitHub Release tag holding openai-google-source-v1.npz"
|
||||
required: true
|
||||
default: image-source-freeze-2026-09-25-v1
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
if: ${{ github.event.inputs.confirm == 'publish' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Download derivative weights
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ github.event.inputs.weights_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p weights
|
||||
gh release download "$TAG" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--pattern openai-google-source-v1.npz \
|
||||
--dir weights
|
||||
test -s weights/openai-google-source-v1.npz
|
||||
- name: Verify artifact hash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
expected="$(jq -r .artifact_sha256 docs/image-source-hf/metrics.json)"
|
||||
test "${#expected}" -eq 64
|
||||
echo "$expected weights/openai-google-source-v1.npz" | sha256sum --check
|
||||
- name: Publish to Hugging Face
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${HF_TOKEN:-}" ]; then
|
||||
echo "HF_TOKEN secret is not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
uv run --no-project --with huggingface-hub \
|
||||
python scripts/publish_image_source_hf.py \
|
||||
--src weights \
|
||||
--message "Image-source classifier freeze (GitHub ${SHA:0:7})"
|
||||
@@ -10,6 +10,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
# Model-only prereleases are not Python package releases.
|
||||
if: ${{ startsWith(github.event.release.tag_name, 'v') && !github.event.release.prerelease }}
|
||||
runs-on: ubuntu-latest
|
||||
environment: pypi
|
||||
steps:
|
||||
@@ -38,4 +40,4 @@ jobs:
|
||||
# is needed. uv's uploader also accepts Metadata-Version 2.5 -- the permanent
|
||||
# fix that lets the hatchling pin be relaxed (see pyproject [build-system]).
|
||||
- name: Publish to PyPI
|
||||
run: uv publish
|
||||
run: uv publish
|
||||
@@ -205,9 +205,12 @@ preserved through a timestamped in-memory NUT bridge instead of being flattened
|
||||
to the average frame rate. Non-zero source start timestamps are retained
|
||||
together with the copied audio offset. The default `--mark auto`
|
||||
scans all providers in one decode pass and selects the first stable match in
|
||||
the specificity order shown below. Pass an explicit mark to restrict detection
|
||||
to one provider.
|
||||
Sora covers the moving Sora 2 mascot and wordmark. Veo covers both the current
|
||||
the specificity order shown below. Valid Google AI-video provenance breaks a
|
||||
Sora/Veo cross-match in favor of Veo when the diamond is nested in the Sora
|
||||
region or Veo persists through the complete clip while Sora does not.
|
||||
Pass an explicit mark to restrict detection to one provider.
|
||||
Sora covers the moving Sora 2 mascot, with or without the wordmark; mascot-only
|
||||
matches also require the complete two-eye shape. Veo covers both the current
|
||||
four-point diamond and the legacy `Veo` text. Seedance covers the fixed boxed
|
||||
`AI` label, Dola covers the fixed `Dola AI` text, Hailuo AI covers the composite
|
||||
`MINIMAX | hailuo AI` label, and Kling AI covers the bottom-right `KLING AI` or
|
||||
|
||||
@@ -10,6 +10,8 @@ data/
|
||||
visible/ Per-mark example gallery: one synthetic canonical example per
|
||||
registered mark plus documented provider originals where available
|
||||
(see fixtures/visible/README.md)
|
||||
synthetic/ Frozen synthetic renders that unit tests stamp onto frames, each
|
||||
produced by the script named in the test that loads it
|
||||
captures/
|
||||
2026-09/ Provider outputs from the maintainer's own accounts, stored as
|
||||
served, indexed by manifest.csv (see its README)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
+7
-4
@@ -393,10 +393,11 @@ remove-ai-watermarks video visible hailuo.mp4 --mark hailuo -o hailuo_clean.mp4
|
||||
remove-ai-watermarks video visible kling.mp4 --mark kling -o kling_clean.mp4
|
||||
```
|
||||
|
||||
The command supports the moving Sora mascot and wordmark, two Veo
|
||||
The command supports the moving Sora mascot with or without its wordmark, two Veo
|
||||
corner variants, the Seedance boxed `AI` label, the `Dola AI` text label, the
|
||||
composite `MINIMAX | hailuo AI` label, and the bottom-right Kling AI label. Sora
|
||||
searches the whole frame at multiple scales. The other detectors search bounded
|
||||
searches the whole frame at multiple scales and verifies the complete mascot
|
||||
shape when the wordmark is absent. The other detectors search bounded
|
||||
lower-frame regions with separate synthetic silhouettes. Kling additionally
|
||||
requires its bright low-saturation label near the frame edge. Every mark
|
||||
requires a spatially recurring candidate across adjacent frames. Fixed marks
|
||||
@@ -407,8 +408,10 @@ provenance-aware marks; metadata alone never creates a detection.
|
||||
`--mark auto` is the default. It evaluates all providers in one decode pass and
|
||||
selects the first stable match in specificity order: Sora, Veo, Seedance,
|
||||
Doubao, Dola, Hailuo AI, then Kling AI. Their confidence scores are independently calibrated and
|
||||
are not compared across providers. Pass an explicit `--mark` to scan only that
|
||||
provider.
|
||||
are not compared across providers. Valid Google AI-video provenance resolves a
|
||||
stable Sora/Veo match in favor of Veo when their regions overlap or Veo spans
|
||||
the full clip while Sora does not; it does not create a visual detection.
|
||||
Pass an explicit `--mark` to scan only that provider.
|
||||
|
||||
The video stream is transcoded and the complete original audio stream is
|
||||
copied without truncating an audio tail that extends beyond the final video
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
license: apache-2.0
|
||||
library_name: numpy
|
||||
pipeline_tag: image-classification
|
||||
tags:
|
||||
- image-forensics
|
||||
- source-attribution
|
||||
- openai
|
||||
- google
|
||||
- open-set-classification
|
||||
---
|
||||
|
||||
# OpenAI and Google image source classifier
|
||||
|
||||
This opt-in model classifies decoded image pixels as a likely `openai` or
|
||||
`google` export pattern, or abstains as `unknown`. It is designed for images
|
||||
whose metadata was removed without deliberately erasing or regenerating the
|
||||
pixel pattern. It is **not a SynthID detector**: a provider label does not prove
|
||||
that SynthID is present, and `unknown` does not prove that it is absent.
|
||||
|
||||
## Artifact and runtime
|
||||
|
||||
The pickle-free `openai-google-source-v1.npz` contains only derivative
|
||||
spectral templates and numeric thresholds. It contains no training images,
|
||||
paths, image hashes, embeddings, catalog, or per-image predictions. Its SHA-256
|
||||
is `0e94e565c181416e4f63a2bfc06cd5179f4201befda7481ac510f6a168c612f4`.
|
||||
The corpus used to fit the templates is private and is not redistributed.
|
||||
|
||||
Install `remove-ai-watermarks[source-classify]` and call the explicit Python
|
||||
API `classify_image_source(path)`. The installed library pins this repository
|
||||
to a verified commit and verifies the artifact hash. It can use a local NPZ
|
||||
through `RAIW_IMAGE_SOURCE_WEIGHTS`. The old `classify_source` API is a separate,
|
||||
earlier model and is not silently replaced by this one.
|
||||
|
||||
The result exposes `label`, `reason`, numeric branch `scores`, and
|
||||
`watermark_truth="unknown"`. A simultaneous OpenAI and Google match is returned
|
||||
as label `unknown` with reason `conflict`, not guessed as one provider.
|
||||
|
||||
## Evidence and limits
|
||||
|
||||
On 600 public [Qwen Image Bench](https://huggingface.co/datasets/Qwen/Qwen-Image-Bench)
|
||||
originals from unused prompt IDs, the frozen model classified 198/200
|
||||
OpenAI-labeled, 196/200 Google-labeled, and abstained on 200/200 FLUX-labeled
|
||||
files. These are uploader source labels, not per-file authenticated provenance;
|
||||
this public route was opened during earlier model development, so it is a
|
||||
regression check, not an independent final test.
|
||||
|
||||
An additional post-freeze camera control selected 42 JPEG photographs with
|
||||
camera EXIF and pre-2022 capture dates from a pinned prefix of
|
||||
[lightcella/photo-corpus](https://huggingface.co/datasets/lightcella/photo-corpus).
|
||||
The model returned `unknown` for all 42 original decoded-pixel views and their
|
||||
JPEG95, JPEG75 and bilinear-1024 transformations. The photos come from one
|
||||
archive shard; EXIF and the dataset's Flickr-original description are not
|
||||
cryptographic proof of origin. No originals are redistributed here.
|
||||
|
||||
The model can be wrong on other providers, camera types, editing pipelines,
|
||||
resizes, crops and compression settings. Some non-target generator and
|
||||
photographic stress panels found false provider matches in related candidates.
|
||||
It must not be used as evidence of watermark presence, authorship, fraud,
|
||||
policy compliance, or provenance. Report the abstention and keep independent
|
||||
metadata/provenance findings separate from this source-pattern prediction.
|
||||
|
||||
The artifact is Apache-2.0 licensed. Input photographs and their respective
|
||||
licenses are not part of this repository.
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"claim": "OpenAI/Google export-source patterns; not SynthID detection",
|
||||
"artifact_sha256": "0e94e565c181416e4f63a2bfc06cd5179f4201befda7481ac510f6a168c612f4",
|
||||
"classes": ["openai", "google", "unknown"],
|
||||
"public_qwen_originals": {
|
||||
"dataset": "Qwen/Qwen-Image-Bench",
|
||||
"revision": "d2493deb153b020cf169c7e3f57d15e4dd697038",
|
||||
"label_provenance": "dataset uploader assertion, not per-file signed source",
|
||||
"selection_status": "opened before this candidate; regression, not independent validation",
|
||||
"openai": {"n": 200, "correct": 198},
|
||||
"google": {"n": 200, "correct": 196},
|
||||
"flux_unknown": {"n": 200, "correct": 200}
|
||||
},
|
||||
"camera_controls": {
|
||||
"dataset": "lightcella/photo-corpus",
|
||||
"revision": "8d960ee416e50a13f5c73d71f47dc37ee3c1da11",
|
||||
"shard": "oi/oi-0002.tar",
|
||||
"selection_status": "prefix and qualification fixed before scoring this model",
|
||||
"origin_caveat": "camera EXIF is not cryptographic human-origin proof",
|
||||
"n_sources": 42,
|
||||
"false_provider_matches": {
|
||||
"original": 0,
|
||||
"jpeg95_420": 0,
|
||||
"jpeg75_420": 0,
|
||||
"bilinear_max1024": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ The current behavior is defined by the code, tests, README, and user guides.
|
||||
- [Doubao reverse-alpha research](research-doubao-distillation.md)
|
||||
- [AI-generated image classifiers](ai-generated-image-classifiers.md) (photo AI-versus-camera freeze consumed by `classify_pixels`, not `identify`)
|
||||
- [Photo classifier Hugging Face card](photo-classify-hf/README.md) (Hub publication text of the 2026-08-31 freeze)
|
||||
- [New image-source classifier card](image-source-hf/README.md) (derivative weights, aggregate evaluation, and non-watermark claim)
|
||||
- [SynthID source classifiers](synthid-classifiers.md) (metadata-free OpenAI/Gemini source finding and provider-lineage experiments)
|
||||
- [Source-pipeline classifier model card](source-classify-hf/README.md) (published derivative weights, aggregate evaluation, and intended-use boundary)
|
||||
- [SynthID local detector research](synthid-detector-research.md)
|
||||
|
||||
@@ -71,7 +71,14 @@ Known examples:
|
||||
|
||||
- The Microsoft detector covers one calibrated top-right white pill. Microsoft's
|
||||
[documented feature](https://support.microsoft.com/en-us/topic/include-a-watermark-when-content-from-microsoft-365-is-ai-generated-b00a656e-ae61-4692-8086-67d004421030)
|
||||
can instead use a Copilot icon, `AI-Generated` text, or another position.
|
||||
can instead use a Copilot icon, `AI-Generated` text, or another position. A
|
||||
beige pill with dark `Made with AI` text is also not covered by the current
|
||||
white-pill silhouette.
|
||||
- The generic `AI生成` template misses some dark, boxed, and longer
|
||||
`内容由AI生成` labels. Metadata may still report AI provenance; these visible
|
||||
variants need independent calibration before automatic removal. Doubao's
|
||||
bottom-right template also remains specific to its measured glyph layout,
|
||||
although it now handles marks tinted by saturated backgrounds.
|
||||
- Samsung detection is calibrated for the Italian
|
||||
`Contenuti generati dall'AI` text variant.
|
||||
- The Jimeng top-left pill has a weak visual detector and is intentionally
|
||||
@@ -462,7 +469,7 @@ The `video metadata` command and high level video API inspect and strip
|
||||
supported AI provenance metadata without transcoding streams.
|
||||
|
||||
`video visible` and `remove_video_visible` additionally support the moving
|
||||
Sora 2 mascot and wordmark, the current Veo four-point diamond, the legacy
|
||||
Sora 2 mascot with or without its wordmark, the current Veo four-point diamond, the legacy
|
||||
`Veo` text, the Seedance boxed `AI` label, the fixed Doubao `豆包AI生成`
|
||||
label, the fixed `Dola AI` text, the Hailuo AI
|
||||
MINIMAX/Hailuo AI composite label, and the bottom-right Kling AI `KLING AI` or
|
||||
@@ -476,9 +483,18 @@ The default auto-router evaluates all detectors in one decode pass but does not
|
||||
rank their raw confidence values. Those scores are provider-specific and known
|
||||
to cross-match in some layouts, so the router applies the independent temporal
|
||||
policies and selects the first stable result in specificity order. Use an
|
||||
explicit mark when the provider is already known.
|
||||
explicit mark when the provider is already known. One narrow exception resolves
|
||||
a Sora/Veo cross-match in favor of a stable Veo diamond when valid Google
|
||||
AI-video provenance corroborates it, Sora provenance does not, and either the
|
||||
regions overlap or Veo spans the full clip while Sora does not.
|
||||
When Veo has no stable visual run, this exception does not suppress Sora.
|
||||
Mascot-only Sora candidates therefore also require a complete-shape match:
|
||||
the edge-only template previously accepted scene texture on a Google-signed
|
||||
clip and reappeared after Veo removal stripped the Google provenance. The
|
||||
measured clips now scan without that false Sora mark, but unmeasured scenes
|
||||
can still cross-match. Check the pixels before selecting an explicit mark.
|
||||
Historical Sora Turbo exports use a small OpenAI swirl in the corner rather
|
||||
than the moving mascot-and-wordmark design; that earlier variant is not
|
||||
than the moving Sora 2 mascot design; that earlier variant is not
|
||||
detected by the `sora` video mark. Hailuo AI and Kling AI coverage is specific to the
|
||||
verified lower-edge layouts; a new provider layout needs a separate calibrated
|
||||
silhouette. Other provider video labels are not supported yet. The `vidu`
|
||||
|
||||
@@ -359,17 +359,26 @@ detected while `0.15` did not.
|
||||
|
||||
[`video_visible.py`](../src/remove_ai_watermarks/video_visible.py) implements
|
||||
the first pixel stages for Sora, Veo, Seedance, Doubao, Dola, Hailuo AI, Vidu, and Kling AI. The
|
||||
Sora detector searches a normalized frame with a fully synthetic
|
||||
mascot-and-text silhouette at several scales. The Veo detector uses separate
|
||||
Sora detector searches a normalized frame with fully synthetic
|
||||
mascot-and-text and mascot-only silhouettes at several scales. The mascot-only
|
||||
candidate must also correlate with the complete grayscale shape, including the
|
||||
paired eyes; top-hat edge correlation alone can score a Veo diamond or scene
|
||||
texture above the Sora confidence floor. The shape floor is calibrated below
|
||||
the independently rendered mascot and above the measured false matches.
|
||||
The Veo detector uses separate
|
||||
synthetic silhouettes for the current four-point diamond and legacy `Veo`
|
||||
text. Seedance uses a synthetic rounded boxed-`AI` silhouette, while Dola uses
|
||||
an OpenCV-font `Dola AI` silhouette. Hailuo AI uses a synthetic waveform,
|
||||
an OpenCV Hershey-font `Dola AI` silhouette. Hailuo AI uses a synthetic waveform,
|
||||
MINIMAX/Hailuo AI text, separator, and ring. Vidu uses two outlined capsules
|
||||
leaning into a V plus a font-rendered `Vidu AI`, sized from the measured 1080p
|
||||
mark (232 x 50 px); its floors (0.52 weak, 0.58 strong) sit between the one real
|
||||
clip (0.72 at 480-1080p, 0.60 at 360p) and the maximum over the 956 other local
|
||||
videos (0.41), measured 2026-09-24. Kling AI combines synthetic font
|
||||
and capitalization variants with a ring approximation of its swirl; the logo path rescues
|
||||
and capitalization variants with a ring approximation of its swirl. The Dola and
|
||||
Kling Hershey renders are frozen PNG assets drawn by OpenCV 4
|
||||
(`scripts/render_video_hershey_templates.py`): OpenCV 5 draws the same `putText`
|
||||
call smaller and heavier, and with runtime rendering the Dola gallery clip was
|
||||
selected as Kling; the logo path rescues
|
||||
wordmarks whose version or font differs, while the edge and white-label gates
|
||||
reject recurring scene texture. All fixed-mark searches are bounded to the
|
||||
expected lower-frame area and calibrated independently. A strong relocated Veo
|
||||
@@ -385,6 +394,18 @@ takes the first stable result in specificity order (`sora`, `veo`, `seedance`,
|
||||
Kling detector also scores the Vidu wordmark. An explicit mark uses the same scan path with one
|
||||
candidate. Removal also collects authoritative per-frame timestamps for the
|
||||
encoder, while identification omits that unused ffprobe pass.
|
||||
Cross-matches between providers are settled by the provenance veto below: a
|
||||
Sora run on a clip with valid Google AI-video provenance and no Sora provenance
|
||||
is vetoed, so a stable Veo run selects Veo. Metadata alone never selects a
|
||||
visible mark. A narrower rule that switched to Veo only when the Veo diamond sat
|
||||
inside the Sora box, or Veo covered every frame and Sora did not, became
|
||||
unreachable once the veto covered every mark and was removed at the 2026-09-26
|
||||
merge; its credential rule survives in the veto.
|
||||
For metadata-only video identification, a recognized `claim_generator` takes
|
||||
precedence over certificate issuer names. Searching all marker values at once
|
||||
previously labeled a Google-generated video as OpenAI when both organizations
|
||||
appeared in its issuer field. Unknown generators still fall back to the
|
||||
existing issuer/marker registry.
|
||||
|
||||
Every per-frame result is untrusted. Each provider's floors, minimum-run policy,
|
||||
fill padding and mask style are one row in `VISIBLE_MARK_POLICIES`, and every mark
|
||||
@@ -410,7 +431,10 @@ rule came from a Veo 3.1 Lite clip whose wood texture matched Kling on every
|
||||
frame at 0.61, and was widened to every mark after a Gemini Omni 1.1 Flash clip
|
||||
from Runway (2026-09-25) matched Sora on a steam wisp at 0.61-0.67 for five
|
||||
frames, a stable run above the strict floor. A re-signing platform such as
|
||||
YouTube records no source type and vetoes nothing. Provenance cannot veto a clip that really is Kling, so the swirl arm of
|
||||
YouTube records no source type and vetoes nothing. An invalid credential
|
||||
(`c2pa_credential_level`) vetoes nothing, and provenance that also confirms the
|
||||
mark's own vendor (`confirms_video_provenance`: a `Sora` claim generator under
|
||||
a Google issuer) confirms instead of vetoing. Provenance cannot veto a clip that really is Kling, so the swirl arm of
|
||||
`detect_kling_frame` now passes the same `_reaches_kling_edge` test as the
|
||||
wordmark arm: on Higgsfield's Cinema Studio v2 (a Kling model, TC260 producer
|
||||
`kling`) the swirl matched wood grain at 0.52-0.56 with its box 11% short of the
|
||||
@@ -520,6 +544,9 @@ the official C2PA registry. Refresh it with
|
||||
upstream schema invariants, records the exact source revision and writes a
|
||||
deterministic module. Runtime inspection remains offline, and registry membership
|
||||
is name-only evidence rather than proof that a compatible decoder ships.
|
||||
The GitHub API revision lookup sends `GITHUB_TOKEN` (or `GH_TOKEN`) when set,
|
||||
because the unauthenticated 60 requests/hour per IP limit is exhausted on
|
||||
shared CI runners; `.env.example` documents the variable.
|
||||
|
||||
`com.microsoft.invismark.1` uses its block value as the
|
||||
pixel-watermark identifier in Microsoft Paint output. An InvisMark soft binding
|
||||
@@ -563,7 +590,7 @@ reach high confidence.
|
||||
Read `untrusted` here as a missing input, not a finding: nothing was checked,
|
||||
because there was nothing to check against. If a maintained trust bundle is ever
|
||||
configured, that stops being true and the confidence mapping in
|
||||
`_c2pa_credential_level` must be re-read, because only then does a failed trust
|
||||
`c2pa_credential_level` (in `_internal/c2pa.py`) must be re-read, because only then does a failed trust
|
||||
check mean the signer was rejected. Shipping or fetching an official trust bundle
|
||||
requires a separate update, provenance, and availability policy; do not silently
|
||||
convert `signingCredential.untrusted` into trusted based on a vendor-name match.
|
||||
@@ -683,7 +710,8 @@ metadata extraction from verdict logic:
|
||||
identifier, and not the SynthID vendor inference, in `identify` and in
|
||||
`metadata.synthid_source` alike; the verdict reads SynthID from the decoded
|
||||
C2PA info when the metadata dict lacks it. Claim-generator tokens also match as whole
|
||||
words (`suno` no longer matches `Sunoco`). The byte scans remain only for
|
||||
words (`claim_generator_platform`: `suno` no longer matches `Sunoco`), and so do
|
||||
the C2PA vendor needles the video path reads (`c2pa_vendor_platform`). The byte scans remain only for
|
||||
manifests the reader cannot open (1 of the 1,379), and verdicts over all 1,379
|
||||
were unchanged by the rule. Device tokens (`NIKON`, `Pixel Camera`, `sony.sig`)
|
||||
exist only in raw bytes, so they stay a byte scan, but of the manifest store:
|
||||
@@ -1217,6 +1245,18 @@ and the threshold mutation guard live in `tests/test_source_classify.py`;
|
||||
evaluation and attack limits live in
|
||||
[`synthid-classifiers.md`](synthid-classifiers.md).
|
||||
|
||||
`source_classify_v2.py` provides a separate, opt-in `classify_image_source`
|
||||
entry point. Its NPZ schema contains only complex spectral filters, spatial
|
||||
templates, and thresholds; the runtime pins the Hub revision and SHA-256,
|
||||
rejects unknown fields and invalid shapes, and never loads pickles. The result
|
||||
has `watermark_truth="unknown"` even when a provider pattern matches. A
|
||||
simultaneous provider match abstains. `PROVIDERS` exports only the calibrated
|
||||
provider labels for callers that route processing from a source hint. Tests in
|
||||
`tests/test_source_classify_v2.py` cover the public seam and changed-threshold
|
||||
behavior; the [model card](image-source-hf/README.md) records the frozen
|
||||
aggregate evidence and limits. This does not change the earlier
|
||||
`classify_source` API.
|
||||
|
||||
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
|
||||
@@ -1553,7 +1593,7 @@ measured against the cleared provider fixture (0.848) and 132 decoded tracked
|
||||
controls other than that positive (maximum 0.516 on 2026-09-10, zero fires).
|
||||
NCC is not portable across OpenCV builds: issue 108 reported 0.81 on
|
||||
`data/fixtures/provenance/chatgpt-1.png` (sand and denim, no glyph) while this
|
||||
tree's OpenCV 4.11 scores 0.437 on the same file. A fire also requires
|
||||
tree's OpenCV scores 0.437 on the same file (4.11, and again 5.0 on 2026-09-23). A fire also requires
|
||||
binarized IoU of the bright residual with the three-lobe silhouette at or
|
||||
above 0.70; the true fixture stays at or above 0.75 down to 512 px, and
|
||||
chatgpt-1 sits at 0.55. `provenance_ncc_factor` remains 1.0, so OpenAI C2PA
|
||||
@@ -1642,6 +1682,11 @@ with large triangular wedges. The aligned sparse mask keeps both frame edges
|
||||
untouched and still clears the detector. `force` cannot align a missing detection,
|
||||
so it retains the shared geometry-box fallback.
|
||||
|
||||
Doubao keeps the original low-saturation scan for pale corners. When enough of
|
||||
its corner is strongly colored and that scan falls below the strict gate, a
|
||||
wider color filter searches for white glyphs tinted by the background. The
|
||||
stronger scan wins, preserving previously accepted pale-background marks.
|
||||
|
||||
Kling keeps the pixel-derived footprint for release-specific strokes, then unions
|
||||
it with the synthetic alpha aligned to the detector's winning match box. This is
|
||||
necessary because its faint canonical overlay produced a blob covering only the
|
||||
@@ -1801,6 +1846,8 @@ public example image was reconstructed by hand. `scripts/build_openart_alpha.py`
|
||||
instead PROCEDURALLY DRAWS a reconstruction of OpenArt's own public brand mark
|
||||
(the bowtie icon + wordmark shown on openart.ai's own marketing pages) as a
|
||||
grayscale alpha PNG, the same bundled-asset contract every other engine uses.
|
||||
Its wordmark is a Hershey `putText` render, so like the video Hershey templates
|
||||
the recipe refuses anything but OpenCV 4.x, whose output the committed asset is.
|
||||
|
||||
**CALIBRATION IS PROVISIONAL, NOT MEASURED.** There is no recall/precision
|
||||
sweep like Doubao's or the generic-label engine's: no positive corpus exists
|
||||
|
||||
@@ -16,7 +16,13 @@ datasets:
|
||||
- detection-datasets/coco
|
||||
---
|
||||
|
||||
# Photo AI-versus-camera classifier
|
||||
# Photo AI-versus-camera classifier (archived)
|
||||
|
||||
> **Archived / legacy as of 2026-09-20. Not recommended for new integrations.**
|
||||
> This model repository is retained for reproducibility and compatibility with
|
||||
> existing users. Published weights and historical revisions remain available.
|
||||
> The documentation and measurements below describe the archived model, not
|
||||
> a current recommendation. No replacement model is linked at this time.
|
||||
|
||||
Two heads, one call. Model 1 decides whether a **photograph** looks generated
|
||||
or camera-like. Model 2 names a provider only after Model 1 is DEFINITELY AI.
|
||||
|
||||
+11
-1
@@ -29,6 +29,11 @@ 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.
|
||||
|
||||
For the newer, separately pinned model, call `raiw.classify_image_source(path)`.
|
||||
It returns `label`, `reason`, branch `scores`, and
|
||||
`watermark_truth="unknown"`. A simultaneous provider match abstains with
|
||||
`reason="conflict"`; it is never reported as SynthID evidence.
|
||||
|
||||
## Remove visible marks
|
||||
|
||||
Install `remove-ai-watermarks[visible]` before using the visible-removal API.
|
||||
@@ -547,6 +552,8 @@ It reports a recurring registered mark and supported AI metadata as positive
|
||||
signals. When neither is present, `is_ai_generated` is `None`, never `False`.
|
||||
The absence of a public local video SynthID decoder is included in `caveats`.
|
||||
Pass `check_visible=False` for a bounded metadata-only inspection.
|
||||
For metadata-only platform attribution, a recognized C2PA claim generator
|
||||
takes precedence over certificate issuers when a manifest names both.
|
||||
|
||||
For normal product integration, use the complete locally verifiable pipeline:
|
||||
|
||||
@@ -709,7 +716,10 @@ isolated lookalike in one frame is not enough to authorize inpainting.
|
||||
selects the first stable match in specificity order (`sora`, `veo`, `seedance`,
|
||||
`doubao`, `dola`, `hailuo`, `vidu`, `kling`). Provider confidence values are calibrated
|
||||
independently and are not compared across detectors. Pass one of those explicit
|
||||
values to restrict the scan to a single provider. The Veo detector recognizes
|
||||
values to restrict the scan to a single provider. A stable Veo diamond takes
|
||||
precedence over Sora only with valid Google AI-video provenance, no Sora
|
||||
provenance, and either substantial overlap or a full-clip Veo run against a
|
||||
partial Sora run. The Veo detector recognizes
|
||||
the current four-point diamond and the
|
||||
legacy `Veo` text. Seedance recognizes the boxed `AI` label, Doubao recognizes
|
||||
the fixed `豆包AI生成` label, and Dola recognizes
|
||||
|
||||
@@ -102,7 +102,17 @@ 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. Keep model-freeze GitHub releases marked as
|
||||
pre-releases so a model artifact cannot replace the latest package release in
|
||||
GitHub's release UI.
|
||||
GitHub's release UI. Package publication and distribution jobs run only for
|
||||
non-prerelease `v*` tags, so model-only prereleases cannot enter PyPI or the
|
||||
package fan-out. A draft release keeps its target commit when published; target
|
||||
the commit containing those guards before creating a model-only release, not
|
||||
merely the then-current `main` after the draft already exists.
|
||||
|
||||
The newer `wiltodelta/openai-google-image-source-classifier` uses the separate
|
||||
`publish-image-source-hf.yml` workflow. Its release asset is only the
|
||||
hash-verified derivative `openai-google-source-v1.npz`; the workflow uploads
|
||||
that artifact plus `docs/image-source-hf/README.md` and `metrics.json`. The
|
||||
training corpus does not enter the library checkout or this release.
|
||||
|
||||
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
|
||||
|
||||
@@ -8,7 +8,13 @@ tags:
|
||||
- open-set-classification
|
||||
---
|
||||
|
||||
# RAIW source-pipeline classifier
|
||||
# RAIW source-pipeline classifier (archived)
|
||||
|
||||
> **Archived / legacy as of 2026-09-20. Not recommended for new integrations.**
|
||||
> This model repository is retained for reproducibility and compatibility with
|
||||
> existing users. Published weights and historical revisions remain available.
|
||||
> The documentation and measurements below describe the archived model, not
|
||||
> a current recommendation. No replacement model is linked at this time.
|
||||
|
||||
This is a compact, abstaining pixel-forensics classifier for three labels:
|
||||
`openai`, `google`, and `unknown`. It is intended for original-export-looking
|
||||
|
||||
+21
-1
@@ -1,6 +1,6 @@
|
||||
# Source-pipeline classification
|
||||
|
||||
`classify_source` is an opt-in, metadata-free pixel classifier for images that
|
||||
`classify_source` is the earlier, opt-in, metadata-free pixel classifier for images that
|
||||
still resemble an original provider export. It returns `openai`, `google`, or
|
||||
an explicit `unknown` abstention.
|
||||
|
||||
@@ -40,3 +40,23 @@ 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.
|
||||
|
||||
## New image-source classifier
|
||||
|
||||
`classify_image_source` is a separate, frozen model that incorporates selected
|
||||
spectral and spatial evidence from the earlier research. It does not silently
|
||||
change `classify_source` or its thresholds. Call it explicitly:
|
||||
|
||||
```python
|
||||
result = raiw.classify_image_source("metadata-stripped.png")
|
||||
print(result.label) # "openai" | "google" | "unknown"
|
||||
print(result.reason) # "classified" | "abstained" | "conflict" | "feature_unavailable"
|
||||
print(result.watermark_truth) # always "unknown"
|
||||
```
|
||||
|
||||
Its first call downloads a hash-verified artifact from the immutable revision
|
||||
of [`wiltodelta/openai-google-image-source-classifier`](https://huggingface.co/wiltodelta/openai-google-image-source-classifier).
|
||||
For offline use, set `RAIW_IMAGE_SOURCE_WEIGHTS` to the NPZ file or its
|
||||
directory. It classifies likely export-source patterns in decoded pixels, not
|
||||
SynthID presence. The [model card](image-source-hf/README.md) records its
|
||||
aggregate tests and limitations.
|
||||
@@ -49,8 +49,8 @@ pill detector; the generic pill shape does not attribute LiblibAI on its own.
|
||||
|
||||
| Key | Mark | Motion | Important limit |
|
||||
| --- | --- | --- | --- |
|
||||
| `sora` | Sora 2 mascot and wordmark | Moves among frame positions | Requires a temporally recurring visual match; the older Sora Turbo corner swirl is a different unsupported mark. OpenAI discontinued the Sora app on 2026-04-26 and the Sora API on 2026-09-24, so this covers existing exports only. |
|
||||
| `veo` | Current four-point diamond and legacy `Veo` text | Fixed bottom-right corner | Uses separate silhouettes and requires a recurring match; learned fill is preferable on structured backgrounds. |
|
||||
| `sora` | Sora 2 mascot, with or without the wordmark | Moves among frame positions | Requires a temporally recurring visual match; mascot-only matches also require complete-shape correlation, which rejects a measured Veo-diamond cross-match. The older Sora Turbo corner swirl is a different unsupported mark. OpenAI discontinued the Sora app on 2026-04-26 and the Sora API on 2026-09-24, so this covers existing exports only. |
|
||||
| `veo` | Current four-point diamond and legacy `Veo` text | Fixed bottom-right corner | Uses separate silhouettes and requires a recurring match; a valid C2PA AI claim from another vendor (Google on a Sora match, for example) vetoes a mark its own vendor does not also confirm. Provenance alone does not create a visual detection. Learned fill is preferable on structured backgrounds. |
|
||||
| `seedance` | Boxed `AI` label | Fixed bottom-right corner | Requires an anchored recurring match; the full localized box is filled because a thinner synthetic shape mask leaves the real translucent rim behind. |
|
||||
| `doubao` | `豆包AI生成` text run | Fixed bottom-right corner | Reuses the image engine's synthetic alpha as the template; a stable run of at least 12 frames at confidence >=0.35 is required. Without Doubao TC260 confirmation, the run also needs a strong anchor at >=0.55. |
|
||||
| `dola` | `Dola AI` text | Fixed bottom-right corner | Requires an anchored recurring match; ByteDance or BytePlus provenance can relax only an existing visual run. |
|
||||
@@ -62,7 +62,8 @@ pill detector; the generic pill shape does not attribute LiblibAI on its own.
|
||||
same temporal arbiter. It is separate from the image registry because selection
|
||||
is made over a sequence rather than one raster. The default `auto` mode scans
|
||||
all seven entries in one decode pass and selects the first temporally stable
|
||||
match in table order; an explicit mark restricts the scan to that row.
|
||||
match in table order, subject to the Veo/Sora overlap exception above; an
|
||||
explicit mark restricts the scan to that row.
|
||||
Accepted fills are motion-aligned across adjacent frames by default. The prior
|
||||
fill contributes only where its warped mask covers the current removal mask and
|
||||
nearby source context agrees. Scene cuts or disjoint marks retain the
|
||||
@@ -136,7 +137,7 @@ detects nor decodes a watermark and never changes the provenance verdict.
|
||||
Pixel based image commands discover these extensions:
|
||||
|
||||
- PNG;
|
||||
- JPEG;
|
||||
- JPEG (`.jpg`, `.jpeg`, `.jpe`, and `.jfif`);
|
||||
- WebP;
|
||||
- HEIC and HEIF;
|
||||
- AVIF.
|
||||
|
||||
@@ -224,6 +224,12 @@ Repository = "https://github.com/wiltodelta/remove-ai-watermarks"
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.uv]
|
||||
# Lock-resolution only; not published metadata. PaddleX 3.7 (the `ocr` extra's
|
||||
# PaddleOCR) requires NumPy <2.4, and without this cap `uv lock --upgrade`
|
||||
# prefers the newest NumPy and silently downgrades PaddleOCR to 3.4.
|
||||
constraint-dependencies = ["numpy<2.4"]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/remove_ai_watermarks"]
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ explicitly names a tracked canonical result.
|
||||
| `retrain_photo_classify.py` | CPU-retrain the 2026-08-31 photo heads from a sha256-keyed cache pack, no images. |
|
||||
| `publish_photo_classify_hf.py` | Upload the photo-classify card and freeze weights to Hugging Face `wiltodelta/raiw-photo-classify`. Manual; the Action `publish-photo-classify-hf.yml` is the write-token path. |
|
||||
| `publish_source_classify_hf.py` | Upload the derivative source-pipeline model and its public card to Hugging Face `wiltodelta/raiw-source-classify`. Manual; the Action `publish-source-classify-hf.yml` is the write-token path. |
|
||||
| `publish_image_source_hf.py` | Upload only the hash-verified new image-source model and public card to `wiltodelta/openai-google-image-source-classifier`. Manual; `publish-image-source-hf.yml` is the write-token path. |
|
||||
| `verify_engine_selection_fixtures.py` | Verify hashes, dimensions, and prompt pairing in the tracked auto-engine content matrix. |
|
||||
|
||||
## Research and diagnostic prototypes
|
||||
|
||||
@@ -18,9 +18,14 @@ gap explicitly. Replace this asset (and re-run the engine's calibration)
|
||||
the moment a real captured OpenArt export is available under
|
||||
``data/calibration/openart/``.
|
||||
|
||||
The wordmark is a Hershey ``cv2.putText`` render, which OpenCV 5 draws with
|
||||
different glyphs than the OpenCV 4 build the committed asset came from, so the
|
||||
script refuses to run on any OpenCV other than 4.x.
|
||||
|
||||
Usage::
|
||||
|
||||
uv run python scripts/build_openart_alpha.py
|
||||
uv run --no-project --with 'opencv-python-headless>=4.8,<5' --with numpy \
|
||||
python scripts/build_openart_alpha.py
|
||||
"""
|
||||
|
||||
# cv2/numpy boundary: third-party libs ship no usable element types; relax the
|
||||
@@ -83,6 +88,8 @@ def build() -> np.ndarray:
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if not cv2.__version__.startswith("4."):
|
||||
raise SystemExit(f"OpenCV {cv2.__version__} draws different glyphs; run with OpenCV 4.x")
|
||||
canvas = build()
|
||||
_ASSET.parent.mkdir(parents=True, exist_ok=True)
|
||||
cv2.imwrite(str(_ASSET), canvas)
|
||||
|
||||
@@ -159,7 +159,7 @@ def load_pipeline(control: str, device: str, dtype: Any) -> Any:
|
||||
load_kwargs: dict[str, Any] = {"controlnet": controlnet, "torch_dtype": dtype}
|
||||
if dtype == torch.float16:
|
||||
# The stock SDXL VAE decodes to NaN/black in fp16; the fp16-fix VAE is the
|
||||
# same swap the production pipeline uses (_SDXL_FP16_VAE_ID).
|
||||
# same swap the sdxl-zimage profile makes (sdxl_zimage_pipeline.SDXL_VAE_MODEL_ID).
|
||||
load_kwargs["vae"] = AutoencoderKL.from_pretrained(FP16_VAE, torch_dtype=dtype)
|
||||
pipe = StableDiffusionXLControlNetImg2ImgPipeline.from_pretrained(BASE_MODEL, **load_kwargs)
|
||||
pipe = pipe.to(device)
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Publish the derivative image-source artifact with an explicit allowlist."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from hashlib import file_digest
|
||||
from pathlib import Path
|
||||
|
||||
from hf_publish import publish_model_folder
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
HUB_REPO = "wiltodelta/openai-google-image-source-classifier"
|
||||
MODEL_FILE = "openai-google-source-v1.npz"
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
CARD_DIR = REPO_ROOT / "docs" / "image-source-hf"
|
||||
PUBLIC_FILES = ("README.md", "metrics.json", MODEL_FILE)
|
||||
|
||||
|
||||
def stage_release(dest: Path, src: Path) -> None:
|
||||
"""Copy only the public card, metrics, and hash-verified derivative weights."""
|
||||
weights = src.expanduser().resolve() / MODEL_FILE
|
||||
sources = {
|
||||
"README.md": CARD_DIR / "README.md",
|
||||
"metrics.json": CARD_DIR / "metrics.json",
|
||||
MODEL_FILE: weights,
|
||||
}
|
||||
missing = [str(path) for path in sources.values() if not path.is_file()]
|
||||
if missing:
|
||||
raise ValueError(f"missing publication files: {', '.join(missing)}")
|
||||
expected = json.loads(sources["metrics.json"].read_text())["artifact_sha256"]
|
||||
with weights.open("rb") as source:
|
||||
actual = file_digest(source, "sha256").hexdigest()
|
||||
if actual != expected:
|
||||
raise ValueError("image-source artifact SHA-256 mismatch")
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
for name, path in sources.items():
|
||||
shutil.copy2(path, dest / name)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--src", type=Path, required=True)
|
||||
parser.add_argument("--message", default="Publish image-source classifier")
|
||||
args = parser.parse_args()
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
token = os.environ.get("HF_TOKEN", "").strip()
|
||||
if not token:
|
||||
raise SystemExit("HF_TOKEN is not set (write role required)")
|
||||
with tempfile.TemporaryDirectory(prefix="raiw-image-source-hf-") as tmp:
|
||||
stage = Path(tmp)
|
||||
stage_release(stage, args.src)
|
||||
revision = publish_model_folder(
|
||||
stage,
|
||||
repo_id=HUB_REPO,
|
||||
token=token,
|
||||
message=args.message,
|
||||
allow_patterns=PUBLIC_FILES,
|
||||
)
|
||||
log.info("published %s revision=%s", HUB_REPO, revision or "(unknown)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Render the frozen OpenCV Hershey-font video templates for Dola and Kling.
|
||||
|
||||
The video detectors were calibrated against these ``cv2.putText`` silhouettes as
|
||||
OpenCV 4.x draws them (byte-identical from 4.8 through 4.14). OpenCV 5 reworked
|
||||
text rendering, so the same call yields smaller, heavier glyphs, and the Dola
|
||||
gallery clip was then selected as Kling. The package therefore loads committed
|
||||
PNGs instead of rendering at import time; this script is the recipe that produced
|
||||
them. It refuses to run on any OpenCV other than 4.x.
|
||||
|
||||
It also renders the independent Dola test fixture: the same text in the other
|
||||
Hershey face at a smaller scale, which tests/test_video.py stamps onto a frame to
|
||||
check that the detector is not keyed to its own template's exact pixels.
|
||||
|
||||
Regenerate with:
|
||||
uv run --no-project --with 'opencv-python-headless>=4.8,<5' --with numpy \
|
||||
python scripts/render_video_hershey_templates.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[1]
|
||||
_ASSETS = _ROOT / "src" / "remove_ai_watermarks" / "assets"
|
||||
_FIXTURES = _ROOT / "data" / "fixtures" / "synthetic"
|
||||
|
||||
# Asset name -> (text, font, canvas width). Every template is drawn at scale 2.2,
|
||||
# thickness 3, anti-aliased, with the baseline origin at (2, 72) on a 100 px canvas.
|
||||
TEMPLATES = {
|
||||
"video_dola_hershey_duplex.png": ("Dola AI", cv2.FONT_HERSHEY_DUPLEX, 400),
|
||||
"video_kling_ai_hershey_simplex.png": ("KLING AI", cv2.FONT_HERSHEY_SIMPLEX, 500),
|
||||
"video_kling_ai_hershey_duplex.png": ("KLING AI", cv2.FONT_HERSHEY_DUPLEX, 500),
|
||||
"video_klingai_hershey_simplex.png": ("KlingAI", cv2.FONT_HERSHEY_SIMPLEX, 500),
|
||||
"video_klingai_hershey_duplex.png": ("KlingAI", cv2.FONT_HERSHEY_DUPLEX, 500),
|
||||
}
|
||||
|
||||
|
||||
def render(
|
||||
text: str,
|
||||
font: int,
|
||||
width: int,
|
||||
*,
|
||||
height: int = 100,
|
||||
origin: tuple[int, int] = (2, 72),
|
||||
scale: float = 2.2,
|
||||
thickness: int = 3,
|
||||
) -> np.ndarray:
|
||||
canvas = np.zeros((height, width), dtype=np.uint8)
|
||||
cv2.putText(canvas, text, origin, font, scale, 255, thickness, cv2.LINE_AA)
|
||||
ys, xs = np.nonzero(canvas)
|
||||
return canvas[ys.min() : ys.max() + 1, xs.min() : xs.max() + 1]
|
||||
|
||||
|
||||
def _write(path: Path, image: np.ndarray) -> None:
|
||||
if not cv2.imwrite(str(path), image):
|
||||
raise SystemExit(f"failed to write {path}")
|
||||
log.info("%s: %dx%d", path.relative_to(_ROOT), image.shape[1], image.shape[0])
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
if not cv2.__version__.startswith("4."):
|
||||
raise SystemExit(f"OpenCV {cv2.__version__} draws different glyphs; run with OpenCV 4.x")
|
||||
for name, (text, font, width) in TEMPLATES.items():
|
||||
_write(_ASSETS / name, render(text, font, width))
|
||||
fixture = render("Dola AI", cv2.FONT_HERSHEY_SIMPLEX, 150, height=40, origin=(2, 28), scale=0.9, thickness=2)
|
||||
_FIXTURES.mkdir(parents=True, exist_ok=True)
|
||||
_write(_FIXTURES / "dola_hershey_simplex.png", fixture)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -267,9 +267,8 @@ def _video_mark_frame(key: str, w: int, h: int) -> np.ndarray:
|
||||
if key == "sora":
|
||||
tmpl, scale, x, y = templates["sora-icon"], 0.10, int(w * 0.72), int(h * 0.90)
|
||||
elif key == "veo":
|
||||
# The legacy "Veo" TEXT form: a perfect synthetic diamond also matches the
|
||||
# Sora icon template (both are 4-point stars) and table order hands the
|
||||
# tie to Sora, so the gallery carries the discriminative text variant.
|
||||
# Keep the legacy text variant in the generated gallery. The provider
|
||||
# original and the cross-match regression exercise the current diamond.
|
||||
tmpl = templates["veo-text"]
|
||||
th = max(6, round(14 * short / 720))
|
||||
tw = max(1, round(tmpl.shape[1] * th / tmpl.shape[0]))
|
||||
|
||||
@@ -15,6 +15,7 @@ High-level API (lazy, so ``import remove_ai_watermarks`` stays cheap)::
|
||||
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
|
||||
raiw.classify_image_source("in.png") # newer source patterns, not SynthID detection
|
||||
|
||||
For a provenance verdict use the ``identify`` submodule::
|
||||
|
||||
@@ -39,12 +40,14 @@ __version__ = "0.42.0"
|
||||
__all__ = [
|
||||
"BatchItemResult",
|
||||
"BatchSummary",
|
||||
"ImageSourceClassification",
|
||||
"InvisibleOptions",
|
||||
"MetadataStripIncomplete",
|
||||
"RemoveAllResult",
|
||||
"SourceClassification",
|
||||
"VisibleRemovalResult",
|
||||
"__version__",
|
||||
"classify_image_source",
|
||||
"classify_source",
|
||||
"identify_video",
|
||||
"inspect_video_metadata",
|
||||
@@ -75,6 +78,7 @@ if TYPE_CHECKING:
|
||||
visible_provenance,
|
||||
)
|
||||
from remove_ai_watermarks.source_classify import SourceClassification, classify_source
|
||||
from remove_ai_watermarks.source_classify_v2 import ImageSourceClassification, classify_image_source
|
||||
from remove_ai_watermarks.video import (
|
||||
identify_video,
|
||||
inspect_video_metadata,
|
||||
@@ -109,6 +113,10 @@ def __getattr__(name: str) -> object:
|
||||
from remove_ai_watermarks import source_classify
|
||||
|
||||
return getattr(source_classify, name)
|
||||
if name in ("ImageSourceClassification", "classify_image_source"):
|
||||
from remove_ai_watermarks import source_classify_v2
|
||||
|
||||
return getattr(source_classify_v2, name)
|
||||
if name in (
|
||||
"identify_video",
|
||||
"inspect_video_metadata",
|
||||
|
||||
@@ -338,6 +338,41 @@ def c2pa_info_has_invalid_credential(info: dict[str, Any]) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def c2pa_credential_level(info: dict[str, Any]) -> str:
|
||||
"""Return invalid, verified, or unverified for provenance attribution.
|
||||
|
||||
``verified`` means the reader tied this manifest to these bytes: the hard binding
|
||||
matched and the claim signature validated. Signer trust is deliberately NOT a
|
||||
condition -- no trust anchors ship, so gating on it made this branch unreachable.
|
||||
Read the trust-anchor paragraph in docs/module-internals.md before changing this.
|
||||
"""
|
||||
if c2pa_info_has_invalid_credential(info):
|
||||
return "invalid"
|
||||
if info.get("c2pa_integrity") == "valid" and info.get("c2pa_signature") == "valid":
|
||||
return "verified"
|
||||
return "unverified"
|
||||
|
||||
|
||||
def claim_generator_platform(generator: str | None) -> str | None:
|
||||
"""The product a claim-generator string names by a whole-word registry token, else None."""
|
||||
if not generator:
|
||||
return None
|
||||
return _first_word_match(generator.casefold(), _CLAIM_GENERATOR_TOKENS)
|
||||
|
||||
|
||||
_CLAIM_GENERATOR_TOKENS = {token.encode(): platform for token, platform in C2PA_CLAIM_GENERATOR_PLATFORMS}
|
||||
_VENDOR_PLATFORM_NEEDLES = {
|
||||
vendor.needle.casefold().encode(): vendor.platform
|
||||
for vendor in C2PA_AI_VENDORS
|
||||
if vendor.platform and vendor.needle
|
||||
}
|
||||
|
||||
|
||||
def c2pa_vendor_platform(text: str) -> str | None:
|
||||
"""Platform of the first C2PA AI vendor ``text`` names as a whole word, case-insensitively."""
|
||||
return _first_word_match(text.casefold(), _VENDOR_PLATFORM_NEEDLES)
|
||||
|
||||
|
||||
def c2pa_info_has_invismark(info: dict[str, Any]) -> bool:
|
||||
"""Return whether parsed C2PA info declares Microsoft InvisMark."""
|
||||
soft_bindings = info.get("soft_binding_vendors")
|
||||
@@ -735,13 +770,9 @@ def registry_word_matches(text: str, registry: dict[bytes, str]) -> list[str]:
|
||||
)
|
||||
|
||||
|
||||
_CLAIM_GENERATOR_TOKENS = {token.encode(): platform for token, platform in C2PA_CLAIM_GENERATOR_PLATFORMS}
|
||||
|
||||
|
||||
def claim_generator_word_platform(generator: str) -> str | None:
|
||||
"""The product a claim-generator string names by a whole-word registry token, else None."""
|
||||
matches = registry_word_matches(generator.casefold(), _CLAIM_GENERATOR_TOKENS)
|
||||
return matches[0] if matches else None
|
||||
def _first_word_match(text: str, registry: dict[bytes, str]) -> str | None:
|
||||
"""The first registry label, in registry order, whose token is a whole word of ``text``."""
|
||||
return next((label for token, label in registry.items() if _word_pattern(token).search(text) is not None), None)
|
||||
|
||||
|
||||
@functools.cache
|
||||
|
||||
@@ -11,7 +11,8 @@ def _tokens(value: str) -> tuple[str, ...]:
|
||||
return tuple(value.split("|"))
|
||||
|
||||
|
||||
SUPPORTED_FORMATS = frozenset(_tokens(".png|.jpg|.jpeg|.webp|.heic|.heif|.avif"))
|
||||
JPEG_SUFFIXES = frozenset(_tokens(".jpg|.jpeg|.jpe|.jfif"))
|
||||
SUPPORTED_FORMATS = JPEG_SUFFIXES | frozenset(_tokens(".png|.webp|.heic|.heif|.avif"))
|
||||
AI_METADATA_KEYS = _tokens(
|
||||
"parameters|postprocessing|extras|workflow|prompt|Dream|SD:mode|StableDiffusionVersion|"
|
||||
"generation_time|Model|Model hash|Seed"
|
||||
|
||||
@@ -23,6 +23,7 @@ from PIL import Image
|
||||
from remove_ai_watermarks._internal.two_stage_pipeline import (
|
||||
_GLOBAL_NEGATIVE,
|
||||
_GLOBAL_PROMPT,
|
||||
DiffSynthVramConfig,
|
||||
TwoStageZImagePipeline,
|
||||
_cache_static_prompt_embeddings,
|
||||
_resize_to_target,
|
||||
@@ -104,7 +105,7 @@ class QwenZImagePipeline(TwoStageZImagePipeline):
|
||||
total_memory_gib=self._total_vram_gib(),
|
||||
)
|
||||
|
||||
def _qwen_vram_config(self) -> dict[str, Any]:
|
||||
def _qwen_vram_config(self) -> DiffSynthVramConfig:
|
||||
import torch
|
||||
|
||||
if self._keep_global_models_resident():
|
||||
|
||||
@@ -21,7 +21,7 @@ import tempfile
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, TypedDict
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
@@ -35,6 +35,20 @@ if TYPE_CHECKING:
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DiffSynthVramConfig(TypedDict):
|
||||
"""DiffSynth's per-model placement: a dtype (or ``"disk"``) and a device per phase."""
|
||||
|
||||
offload_dtype: Any
|
||||
offload_device: str
|
||||
onload_dtype: Any
|
||||
onload_device: str
|
||||
preparing_dtype: Any
|
||||
preparing_device: str
|
||||
computation_dtype: Any
|
||||
computation_device: str
|
||||
|
||||
|
||||
ZIMAGE_TURBO_MODEL_ID = "Tongyi-MAI/Z-Image-Turbo"
|
||||
SAM_MODEL_ID = "facebook/sam-vit-base"
|
||||
|
||||
@@ -150,19 +164,7 @@ def _pin_vram_managed_models(pipe: Any) -> None:
|
||||
if model is None:
|
||||
continue
|
||||
for module in model.modules():
|
||||
if not all(
|
||||
hasattr(module, attribute)
|
||||
for attribute in (
|
||||
"offload_dtype",
|
||||
"offload_device",
|
||||
"onload_dtype",
|
||||
"onload_device",
|
||||
"preparing_dtype",
|
||||
"preparing_device",
|
||||
"computation_dtype",
|
||||
"computation_device",
|
||||
)
|
||||
):
|
||||
if not all(hasattr(module, attribute) for attribute in DiffSynthVramConfig.__annotations__):
|
||||
continue
|
||||
module.offload_dtype = module.computation_dtype
|
||||
module.offload_device = module.computation_device
|
||||
@@ -456,13 +458,17 @@ def _yunet_model_path() -> Path:
|
||||
YUNET_MODEL_URL,
|
||||
headers={"User-Agent": "remove-ai-watermarks"},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=60) as response: # noqa: S310 - fixed HTTPS source
|
||||
payload = response.read()
|
||||
log.info(
|
||||
"YuNet download response: status=%s content_length=%s",
|
||||
getattr(response, "status", None),
|
||||
len(payload),
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=60) as response: # noqa: S310 - fixed HTTPS source
|
||||
payload = response.read()
|
||||
log.info(
|
||||
"YuNet download response: status=%s content_length=%s",
|
||||
getattr(response, "status", None),
|
||||
len(payload),
|
||||
)
|
||||
except OSError as exc:
|
||||
log.warning("YuNet download from %s to %s failed: %s", YUNET_MODEL_URL, model_path, exc)
|
||||
raise
|
||||
digest = hashlib.sha256(payload).hexdigest()
|
||||
if digest != YUNET_MODEL_SHA256:
|
||||
raise OSError(
|
||||
@@ -724,7 +730,7 @@ class TwoStageZImagePipeline:
|
||||
return cls._zimage_vram_config()["computation_dtype"]
|
||||
|
||||
@staticmethod
|
||||
def _zimage_vram_config() -> dict[str, Any]:
|
||||
def _zimage_vram_config() -> DiffSynthVramConfig:
|
||||
import torch
|
||||
|
||||
return {
|
||||
@@ -858,7 +864,14 @@ class TwoStageZImagePipeline:
|
||||
)
|
||||
return _clip_sam_masks_to_boxes(binary_masks, boxes, image.size)
|
||||
except Exception as exc:
|
||||
log.warning("SAM face-mask refinement failed (%s); using box-derived ellipse masks", exc)
|
||||
log.warning(
|
||||
"SAM face-mask refinement failed (%s, model %s on %s, %d boxes); using box-derived ellipse masks",
|
||||
exc,
|
||||
SAM_MODEL_ID,
|
||||
self.device,
|
||||
len(boxes),
|
||||
exc_info=True,
|
||||
)
|
||||
return _ellipse_masks(boxes, image.size)
|
||||
|
||||
def preload(self, *, global_only: bool = False) -> None:
|
||||
|
||||
@@ -4,17 +4,11 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from remove_ai_watermarks._internal.constants import SUPPORTED_FORMATS
|
||||
from remove_ai_watermarks._internal.constants import JPEG_SUFFIXES, SUPPORTED_FORMATS
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
_PIL_FORMAT_BY_SUFFIX = {
|
||||
".jpg": "JPEG",
|
||||
".jpeg": "JPEG",
|
||||
".png": "PNG",
|
||||
}
|
||||
|
||||
|
||||
def is_supported_format(file_path: Path) -> bool:
|
||||
"""Return whether ``file_path`` has a supported raster suffix."""
|
||||
@@ -27,4 +21,4 @@ def get_image_format(file_path: Path) -> str:
|
||||
The metadata writer only has specialized PNG and JPEG paths. Other accepted
|
||||
inputs therefore use its PNG fallback, matching the established API contract.
|
||||
"""
|
||||
return _PIL_FORMAT_BY_SUFFIX.get(file_path.suffix.casefold(), "PNG")
|
||||
return "JPEG" if file_path.suffix.casefold() in JPEG_SUFFIXES else "PNG"
|
||||
@@ -1,10 +1,11 @@
|
||||
"""Project-owned configuration for invisible-watermark regeneration profiles.
|
||||
|
||||
Three profiles remain, and all are CUDA-only: ``qwen-zimage`` (the default),
|
||||
``sdxl-zimage``. The older ``controlnet``, ``sdxl``, ``qwen`` and ``default`` profiles
|
||||
were removed rather than kept as a CPU path, because none of them matched the two-stage
|
||||
recipe's face preservation and keeping them implied a quality this library no longer
|
||||
offers. Removing invisible watermarks therefore needs a CUDA device; the visible-mark
|
||||
``sdxl-zimage`` and ``chroma-zimage``, plus the per-vendor ``auto`` selector. The
|
||||
older ``controlnet``, ``sdxl``, ``qwen`` and ``default`` profiles were removed rather
|
||||
than kept as a CPU path, because none of them matched the two-stage recipe's face
|
||||
preservation and keeping them implied a quality this library no longer offers.
|
||||
Removing invisible watermarks therefore needs a CUDA device; the visible-mark
|
||||
registry and every identify path still run anywhere.
|
||||
"""
|
||||
|
||||
@@ -75,7 +76,7 @@ SDXL_LIGHTNING_PATTERN = "sdxl_lightning_4step_lora.safetensors"
|
||||
|
||||
# All profiles are certified at a fixed seed because SynthID removal near the
|
||||
# strength floor is seed-dependent. The step count and CFG are not settable at all --
|
||||
# each stage owns them (``GLOBAL_STEPS`` / ``FACE_STEPS`` in qwen_zimage_pipeline).
|
||||
# each stage owns them (``GLOBAL_STEPS`` in qwen_zimage_pipeline, ``FACE_STEPS`` in two_stage_pipeline).
|
||||
PROFILE_SEED = 0
|
||||
|
||||
# sdxl-zimage runs the qwen-zimage recipe on an SDXL global stage, and strength is
|
||||
|
||||
@@ -102,7 +102,11 @@ def get_device() -> str:
|
||||
if torch.cuda.is_available() and _cuda_works(): # type: ignore[union-attr]
|
||||
return "cuda"
|
||||
if _has_nvidia_gpu():
|
||||
logger.warning("NVIDIA GPU detected, but the installed PyTorch build has no working CUDA backend")
|
||||
logger.warning(
|
||||
"NVIDIA GPU detected, but the installed PyTorch build (torch %s, CUDA %s) has no working CUDA backend",
|
||||
torch.__version__, # type: ignore[union-attr]
|
||||
torch.version.cuda, # type: ignore[union-attr]
|
||||
)
|
||||
return "cpu"
|
||||
|
||||
|
||||
|
||||
@@ -66,8 +66,9 @@ _MIN_DETECT_SHORT_SIDE = 200
|
||||
# Doubao stays at 0.70: both its bands return more true marks than false fills, so
|
||||
# tightening would cost 11 genuine recoveries to prevent 8 false ones.
|
||||
#
|
||||
# Jimeng moves to 0.85. Its relaxed detector does not key on the "★ 即梦AI" wordmark
|
||||
# any more -- it keys on "some text in the bottom-right corner": of 68 false
|
||||
# Jimeng moved to 0.85 (superseded: it is back at 0.70 now that the rival margin
|
||||
# handles the Doubao cross-fire; see jimeng_engine.PROVENANCE_NCC_FACTOR). Its relaxed detector does not
|
||||
# key on the "★ 即梦AI" wordmark any more -- it keys on "some text in the bottom-right corner": of 68 false
|
||||
# additions, 33 were DOUBAO marks and 17 were other vendors' AI labels (千问, 百度,
|
||||
# 星绘, 抖音). 45 of those 68 fill a corner nothing else would touch (the other 23
|
||||
# are harmless -- doubao fires strictly there and fills the same box anyway). At
|
||||
|
||||
@@ -172,10 +172,9 @@ def remove_visible(
|
||||
) -> tuple[NDArray[Any], list[str]]:
|
||||
"""Remove every detected known visible AI mark through localize then fill.
|
||||
|
||||
The registry currently covers the Gemini visible watermark; Doubao, Jimeng,
|
||||
Qwen text or symbol mark, Kling AI, Yuanbao, Samsung, RunningHub, and Baidu text marks; the
|
||||
LiblibAI wordmark and compact pill; one Microsoft top-right AI-badge variant;
|
||||
and the Jimeng pill. Returns ``(result_bgr, [labels removed])``.
|
||||
The registry covers the marks listed by ``known_marks()``: the Gemini visible
|
||||
watermark, vendor text marks, the brand-less ``AI生成`` label, pills and badges.
|
||||
Returns ``(result_bgr, [labels removed])``.
|
||||
|
||||
``source`` is a file path OR a BGR ndarray. For a PATH, metadata provenance is read
|
||||
automatically (so ``sensitivity="auto"`` recovers a moved/faint mark whenever the
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
@@ -7,8 +7,8 @@ two-component template (text + pill tag) was measured and REJECTED -- the solid
|
||||
white pill is a bright-blob magnet and both front-ends scored the clean arm at
|
||||
cohort levels (tophat clean p95 0.445 / gray clean p95 0.487 vs cohort ~0.5,
|
||||
2026-07-22). The text-only silhouette separates cleanly (below). The white tag
|
||||
is still removed with the mark: the fill blob covers both bright components in
|
||||
the corner box.
|
||||
is still removed with the mark: the fill is the detector's match box, stretched
|
||||
right to the corner end of the locate box (see :meth:`BaiduEngine._footprint_rect`).
|
||||
|
||||
Removal is the shared **localize -> fill** (:meth:`footprint_mask` ->
|
||||
``region_eraser``). This module supplies only Baidu's tuned
|
||||
@@ -17,7 +17,7 @@ synthetic silhouette from ``scripts/render_vendor_silhouettes.py``, never cut
|
||||
from an upload).
|
||||
|
||||
The detector uses a synthetic silhouette, short-side geometry, a strict
|
||||
confidence gate, and a Qwen rival margin. The footprint covers both the text
|
||||
confidence gate, and Doubao and Qwen rival margins. The footprint covers both the text
|
||||
run and its adjacent pill tag.
|
||||
"""
|
||||
# The module-level _alpha_template / _glyph_silhouette / _template_match_score below
|
||||
|
||||
@@ -22,10 +22,13 @@ supplies only Doubao's tuned :class:`TextMarkConfig` (bottom-right corner,
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks import _text_mark_engine, image_io
|
||||
from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkDetection, TextMarkEngine
|
||||
from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkDetection, TextMarkEngine, TextMarkScan
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from numpy.typing import NDArray
|
||||
@@ -38,9 +41,12 @@ WM_HEIGHT_FRAC = 0.075
|
||||
MARGIN_RIGHT_FRAC = 0.004
|
||||
MARGIN_BOTTOM_FRAC = 0.004
|
||||
|
||||
# Glyph appearance: a light, low-saturation gray rendered brighter than the local
|
||||
# background (white top-hat), so a white-paper document is left untouched.
|
||||
# Glyph appearance: a light glyph rendered brighter than the local background
|
||||
# (white top-hat). Preserve the original low-saturation front end, which can
|
||||
# score better on pale backgrounds than a wider color filter.
|
||||
MAX_SATURATION = 55 # max channel spread to count a pixel as "grayish"
|
||||
_TINTED_MAX_SATURATION = 100
|
||||
_TINTED_CORNER_FRACTION = 0.10
|
||||
LOGO_MIN_LUMA = 150 # glyphs are at least this bright in absolute terms
|
||||
TOPHAT_DELTA = 12 # glyph must exceed the local background by this many levels
|
||||
|
||||
@@ -104,6 +110,12 @@ _CONFIG = TextMarkConfig(
|
||||
min_gw=8,
|
||||
)
|
||||
|
||||
# A translucent white mark over yellow/orange imagery can retain enough of the
|
||||
# background color to miss the original 55-level filter. Search with a wider
|
||||
# filter only when the corner is substantially colorful. Keep already accepted
|
||||
# detections and otherwise retain the stronger of the two scans.
|
||||
_TINTED_ENGINE = TextMarkEngine(dataclasses.replace(_CONFIG, max_saturation=_TINTED_MAX_SATURATION))
|
||||
|
||||
|
||||
def _alpha_template() -> NDArray[Any] | None:
|
||||
"""The bundled Doubao alpha template (float [0,1]), or None."""
|
||||
@@ -126,6 +138,21 @@ class DoubaoEngine(TextMarkEngine):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(_CONFIG)
|
||||
|
||||
def _scan(self, image: NDArray[Any] | None) -> TextMarkScan:
|
||||
base = super()._scan(image)
|
||||
if base.loc is None or image is None or (base.score or 0) >= DETECT_NCC_THRESHOLD:
|
||||
return base
|
||||
|
||||
x, y, w, h = base.loc.bbox
|
||||
corner = image_io.to_bgr(image[y : y + h, x : x + w])
|
||||
channel_spread = corner.max(axis=2) - corner.min(axis=2)
|
||||
colored = np.count_nonzero(channel_spread >= _TINTED_MAX_SATURATION)
|
||||
if colored < _TINTED_CORNER_FRACTION * channel_spread.size:
|
||||
return base
|
||||
|
||||
tinted = _TINTED_ENGINE._scan(image)
|
||||
return tinted if (tinted.score or 0) > (base.score or 0) else base
|
||||
|
||||
def footprint_mask(
|
||||
self,
|
||||
image: NDArray[Any] | None,
|
||||
|
||||
@@ -17,7 +17,7 @@ import re
|
||||
import struct
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
from typing import Any, TypedDict, cast
|
||||
|
||||
import piexif
|
||||
from PIL import Image
|
||||
@@ -28,6 +28,7 @@ from remove_ai_watermarks._internal.constants import (
|
||||
PNG_METADATA_CHUNKS,
|
||||
RIFF_CODED_IMAGE_CHUNKS,
|
||||
RIFF_METADATA_CHUNKS,
|
||||
SUPPORTED_FORMATS,
|
||||
)
|
||||
from remove_ai_watermarks._internal.isobmff import (
|
||||
C2PA_BOX_TYPES,
|
||||
@@ -46,13 +47,7 @@ __all__ = [
|
||||
]
|
||||
|
||||
SUPPORTED_EXTENSIONS = {
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".webp",
|
||||
".heic",
|
||||
".heif",
|
||||
".avif",
|
||||
*SUPPORTED_FORMATS,
|
||||
".tif",
|
||||
".tiff",
|
||||
".bmp",
|
||||
@@ -65,6 +60,24 @@ SUPPORTED_EXTENSIONS = {
|
||||
".jxl",
|
||||
}
|
||||
|
||||
|
||||
class ContainerEntry(TypedDict, total=False):
|
||||
"""One PNG/RIFF chunk or ISOBMFF provenance box in the record, or a walker error."""
|
||||
|
||||
type: str
|
||||
length: int
|
||||
text: str
|
||||
kind: str
|
||||
time: str
|
||||
gamma: float
|
||||
rendering_intent: int
|
||||
profile_name: str
|
||||
base64: str
|
||||
apple_screenshot_marker: bool
|
||||
truncated: bool
|
||||
error: str
|
||||
|
||||
|
||||
FORENSIC_METADATA_SCHEMA_VERSION = 1
|
||||
FORENSIC_METADATA_RECORD_TYPE = "forensic_metadata"
|
||||
|
||||
@@ -196,10 +209,10 @@ def png_text_decode(ctype: str, body: bytes) -> str:
|
||||
return f"{keyword}\x00{tail.decode('utf-8', 'replace')}"
|
||||
|
||||
|
||||
def read_png_chunks(data: bytes) -> tuple[list[dict[str, Any]], bytes]:
|
||||
def read_png_chunks(data: bytes) -> tuple[list[ContainerEntry], bytes]:
|
||||
"""Every PNG chunk in order (type, length; text chunks decoded and
|
||||
inflated, binary chunks as base64) plus the post-IEND trailer bytes."""
|
||||
chunks: list[dict[str, Any]] = []
|
||||
chunks: list[ContainerEntry] = []
|
||||
post_iend = b""
|
||||
try:
|
||||
pos = 8
|
||||
@@ -207,7 +220,7 @@ def read_png_chunks(data: bytes) -> tuple[list[dict[str, Any]], bytes]:
|
||||
length = struct.unpack(">I", data[pos : pos + 4])[0]
|
||||
ctype = data[pos + 4 : pos + 8].decode("latin-1")
|
||||
body = data[pos + 8 : pos + 8 + length]
|
||||
entry: dict[str, Any] = {"type": ctype, "length": length}
|
||||
entry: ContainerEntry = {"type": ctype, "length": length}
|
||||
if ctype in ("tEXt", "zTXt", "iTXt"):
|
||||
entry["text"] = png_text_decode(ctype, body)
|
||||
if entry["text"].startswith("XML:com.adobe.xmp"):
|
||||
@@ -496,9 +509,9 @@ def _jpeg_forensics_bytes(data: bytes) -> dict[str, Any]:
|
||||
return out
|
||||
|
||||
|
||||
def read_webp_chunks(data: bytes) -> list[dict[str, Any]]:
|
||||
def read_webp_chunks(data: bytes) -> list[ContainerEntry]:
|
||||
"""WebP RIFF chunk inventory (VP8X/VP8/VP8L/EXIF/XMP/ICCP/ANIM...)."""
|
||||
chunks: list[dict[str, Any]] = []
|
||||
chunks: list[ContainerEntry] = []
|
||||
try:
|
||||
pos = 12
|
||||
declared_end = 8 + struct.unpack("<I", data[4:8])[0] if len(data) >= 12 else len(data)
|
||||
@@ -508,7 +521,7 @@ def read_webp_chunks(data: bytes) -> list[dict[str, Any]]:
|
||||
ctype = chunk_type.decode("latin-1")
|
||||
length = struct.unpack("<I", data[pos + 4 : pos + 8])[0]
|
||||
body = data[pos + 8 : min(pos + 8 + length, container_end)]
|
||||
entry: dict[str, Any] = {"type": ctype, "length": length}
|
||||
entry: ContainerEntry = {"type": ctype, "length": length}
|
||||
if ctype == "XMP ":
|
||||
entry["kind"] = "xmp"
|
||||
entry["text"] = body.decode("utf-8", "replace")
|
||||
@@ -523,9 +536,9 @@ def read_webp_chunks(data: bytes) -> list[dict[str, Any]]:
|
||||
return chunks
|
||||
|
||||
|
||||
def read_webp_late_metadata_path(path: Path, window: int = _RAW_SCAN_HEAD) -> list[dict[str, Any]]:
|
||||
def read_webp_late_metadata_path(path: Path, window: int = _RAW_SCAN_HEAD) -> list[ContainerEntry]:
|
||||
"""Stream metadata chunks after ``window`` while seeking over coded frames."""
|
||||
chunks: list[dict[str, Any]] = []
|
||||
chunks: list[ContainerEntry] = []
|
||||
try:
|
||||
file_size = path.stat().st_size
|
||||
with open(path, "rb") as handle:
|
||||
@@ -546,7 +559,7 @@ def read_webp_late_metadata_path(path: Path, window: int = _RAW_SCAN_HEAD) -> li
|
||||
if chunk_type in RIFF_METADATA_CHUNKS and start >= window:
|
||||
handle.seek(start)
|
||||
body = handle.read(min(safe_length, _B64_CAP))
|
||||
entry: dict[str, Any] = {
|
||||
entry: ContainerEntry = {
|
||||
"type": chunk_type.decode("latin-1"),
|
||||
"length": length,
|
||||
"base64": _b64(body),
|
||||
@@ -603,7 +616,7 @@ def read_isobmff_inventory(data: bytes) -> dict[str, Any]:
|
||||
|
||||
top = boxes(0, len(data))
|
||||
out["boxes"] = [t for t, _, _ in top]
|
||||
provenance_boxes: list[dict[str, Any]] = []
|
||||
provenance_boxes: list[ContainerEntry] = []
|
||||
for t, s, e in top:
|
||||
if t.encode("latin-1") in C2PA_BOX_TYPES:
|
||||
provenance_boxes.append(
|
||||
@@ -676,7 +689,7 @@ def read_isobmff_provenance_path(path: Path) -> dict[str, Any]:
|
||||
It seeks over media payloads instead of loading them into memory.
|
||||
"""
|
||||
out: dict[str, Any] = {"boxes": []}
|
||||
provenance_boxes: list[dict[str, Any]] = []
|
||||
provenance_boxes: list[ContainerEntry] = []
|
||||
collected = 0
|
||||
try:
|
||||
file_size = path.stat().st_size
|
||||
@@ -689,7 +702,7 @@ def read_isobmff_provenance_path(path: Path) -> dict[str, Any]:
|
||||
to_read = min(payload_length, _PROVENANCE_B64_CAP - collected)
|
||||
f.seek(payload_offset)
|
||||
payload = f.read(to_read)
|
||||
entry: dict[str, Any] = {
|
||||
entry: ContainerEntry = {
|
||||
"type": box_type,
|
||||
"length": payload_length,
|
||||
"base64": _b64(payload, cap=_PROVENANCE_B64_CAP),
|
||||
@@ -705,9 +718,9 @@ def read_isobmff_provenance_path(path: Path) -> dict[str, Any]:
|
||||
return out
|
||||
|
||||
|
||||
def read_png_late_metadata_path(path: Path, window: int = _RAW_SCAN_HEAD) -> list[dict[str, Any]]:
|
||||
def read_png_late_metadata_path(path: Path, window: int = _RAW_SCAN_HEAD) -> list[ContainerEntry]:
|
||||
"""Stream PNG metadata chunks whose payload starts after ``window``."""
|
||||
chunks: list[dict[str, Any]] = []
|
||||
chunks: list[ContainerEntry] = []
|
||||
try:
|
||||
file_size = path.stat().st_size
|
||||
with open(path, "rb") as f:
|
||||
@@ -724,7 +737,7 @@ def read_png_late_metadata_path(path: Path, window: int = _RAW_SCAN_HEAD) -> lis
|
||||
safe_length = max(0, min(length, file_size - data_start))
|
||||
if chunk_type in PNG_METADATA_CHUNKS and data_start >= window:
|
||||
body = f.read(min(safe_length, _B64_CAP))
|
||||
entry: dict[str, Any] = {
|
||||
entry: ContainerEntry = {
|
||||
"type": chunk_type.decode("latin-1"),
|
||||
"length": length,
|
||||
"base64": _b64(body),
|
||||
|
||||
@@ -47,9 +47,11 @@ CALIBRATION CAVEAT: ``DETECT_NCC_THRESHOLD`` below is fit on a SYNTHETIC corpus
|
||||
scored against generated clean negatives and the six existing brand marks' own
|
||||
composited examples as a false-positive/rival check) -- see
|
||||
``scripts/calibrate_generic_ai_label.py`` for the recall/precision table it produced.
|
||||
No real "AI生成"-generator screenshot (vivo/Xiaomi/other gallery output) was available
|
||||
to calibrate against. Treat this detector as WEAKER evidence than the brand-tuned
|
||||
engines until it is recalibrated on real captures; the ``rivals`` margin below exists
|
||||
At initial calibration, no real "AI生成"-generator screenshot was available. A
|
||||
later retained-corpus review confirmed dark, boxed, and longer-text labels that
|
||||
this template misses; they are not calibration positives for this silhouette.
|
||||
Treat this detector as WEAKER evidence than the brand-tuned engines until it is
|
||||
recalibrated on independently labeled real captures; the ``rivals`` margin below exists
|
||||
specifically so a genuine Doubao/Jimeng/Qwen/Baidu/Kling/Yuanbao mark -- every one of
|
||||
which literally contains the substring "AI生成" in its own glyphs -- is attributed to
|
||||
its own tuned engine instead of double-firing here.
|
||||
|
||||
@@ -14,7 +14,7 @@ Aggregates every locally-readable signal into a single :class:`ProvenanceReport`
|
||||
Hard limit: a stripped image (re-encoded, screenshotted, social-media upload)
|
||||
loses all metadata, and the SynthID *pixel* watermark is not locally decodable
|
||||
(proprietary decoder). Absence of signals is therefore reported as ``Unknown``,
|
||||
never as "clean". See CLAUDE.md "SynthID detection is metadata-only".
|
||||
never as "clean". See docs/synthid.md section 3, "Detectability and verifier access".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -27,12 +27,12 @@ from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from remove_ai_watermarks._internal.c2pa import (
|
||||
c2pa_credential_level,
|
||||
c2pa_info_from_manifest_store,
|
||||
c2pa_info_has_invalid_credential,
|
||||
c2pa_info_has_invismark,
|
||||
c2pa_info_has_removal_hint,
|
||||
cbor_text_after,
|
||||
claim_generator_word_platform,
|
||||
claim_generator_platform,
|
||||
extract_c2pa_info,
|
||||
soft_binding_labels,
|
||||
soft_binding_registry_entries_in,
|
||||
@@ -585,21 +585,6 @@ def _c2pa_validation(info: dict[str, Any]) -> dict[str, Any] | None:
|
||||
}
|
||||
|
||||
|
||||
def _c2pa_credential_level(info: dict[str, Any]) -> str:
|
||||
"""Return invalid, verified, or unverified for provenance attribution.
|
||||
|
||||
``verified`` means the reader tied this manifest to these bytes: the hard binding
|
||||
matched and the claim signature validated. Signer trust is deliberately NOT a
|
||||
condition -- no trust anchors ship, so gating on it made this branch unreachable.
|
||||
Read the trust-anchor paragraph in docs/module-internals.md before changing this.
|
||||
"""
|
||||
if c2pa_info_has_invalid_credential(info):
|
||||
return "invalid"
|
||||
if info.get("c2pa_integrity") == "valid" and info.get("c2pa_signature") == "valid":
|
||||
return "verified"
|
||||
return "unverified"
|
||||
|
||||
|
||||
def extract_provenance_evidence(image_path: Path) -> ProvenanceEvidence:
|
||||
"""Read all file-backed metadata needed by provenance verdict logic once."""
|
||||
return ProvenanceEvidence(
|
||||
@@ -827,13 +812,6 @@ def _attribute_platform(issuers: list[str], *, is_ai: bool = True) -> str | None
|
||||
return None
|
||||
|
||||
|
||||
def _claim_generator_platform(generator: str | None) -> str | None:
|
||||
"""Resolve a distinctive C2PA claim generator to its user-facing product."""
|
||||
if not generator:
|
||||
return None
|
||||
return claim_generator_word_platform(generator)
|
||||
|
||||
|
||||
# Coarse origin-vendor normalization for integrity-clash detection. Two signals
|
||||
# that resolve to the SAME key are consistent (a C2PA "Google (Gemini)" issuer
|
||||
# and Google SynthID provenance, or Adobe Firefly + its Adobe TrustMark soft
|
||||
@@ -1258,7 +1236,7 @@ def _identify_from_evidence(
|
||||
# ── C2PA Content Credentials ────────────────────────────────────
|
||||
has_c2pa = bool(info) or c2pa_marker_in(head)
|
||||
c2pa_validation = _c2pa_validation(info)
|
||||
c2pa_level = _c2pa_credential_level(info)
|
||||
c2pa_level = c2pa_credential_level(info)
|
||||
c2pa_usable = c2pa_level != "invalid"
|
||||
# The reader already named which failures moved a dimension; re-deriving that here
|
||||
# by substring made the displayed reason a second, looser rule than the verdict.
|
||||
@@ -1309,12 +1287,12 @@ def _identify_from_evidence(
|
||||
camera_label
|
||||
# Exact product generators are useful provenance even when the
|
||||
# signed operation is a non-AI edit (for example, CapCut).
|
||||
or _claim_generator_platform(generator)
|
||||
or claim_generator_platform(generator)
|
||||
# An AI product generator recorded under a generic re-signer beats the
|
||||
# signer's own label: Dreamina exports carry a "Dreamina/7.5.0"
|
||||
# ingredient under an active "ByteDance Media Transcode Service"
|
||||
# claim (measured 2026-09-24).
|
||||
or (_claim_generator_platform(str(info.get("ai_tool"))) if c2pa_is_ai and info.get("ai_tool") else None)
|
||||
or (claim_generator_platform(str(info.get("ai_tool"))) if c2pa_is_ai and info.get("ai_tool") else None)
|
||||
or signer_label
|
||||
or (
|
||||
_GOOGLE_PHOTOS_EDIT
|
||||
|
||||
@@ -25,7 +25,7 @@ import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from remove_ai_watermarks._internal.constants import PNG_SIGNATURE
|
||||
from remove_ai_watermarks._internal.constants import JPEG_SUFFIXES, PNG_SIGNATURE
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from numpy.typing import NDArray
|
||||
@@ -443,7 +443,7 @@ def _encode_params(ext: str) -> list[int]:
|
||||
build without the chroma/subsampling flags still gets quality 100."""
|
||||
import cv2
|
||||
|
||||
if ext in (".jpg", ".jpeg"):
|
||||
if ext in JPEG_SUFFIXES:
|
||||
params = [cv2.IMWRITE_JPEG_QUALITY, 100]
|
||||
cq = getattr(cv2, "IMWRITE_JPEG_CHROMA_QUALITY", None)
|
||||
if cq is not None:
|
||||
@@ -533,6 +533,8 @@ def imwrite(
|
||||
ext = (Path(path).suffix or ".png").lower()
|
||||
if ext in _HEIF_WRITE_EXTS:
|
||||
return _pil_write(path, img, icc_profile=icc, orientation=orient)
|
||||
if ext in JPEG_SUFFIXES:
|
||||
ext = ".jpg"
|
||||
try:
|
||||
ok, buf = cv2.imencode(ext, img, _encode_params(ext))
|
||||
except cv2.error:
|
||||
|
||||
@@ -62,8 +62,8 @@ def _target_size(width: int, height: int, max_resolution: int) -> tuple[int, int
|
||||
Set only to bound GPU/MPS memory on very large inputs (issue #10).
|
||||
|
||||
There was also a ``min_resolution`` floor that scaled small inputs UP toward
|
||||
SDXL's ~1024 training size. It went with the SDXL profiles: both surviving
|
||||
profiles run at native geometry, so the floor was forced to 0 on every path and
|
||||
SDXL's ~1024 training size. It went with the removed SDXL-only profiles: every
|
||||
surviving profile runs at native geometry, so the floor was forced to 0 on every path and
|
||||
could not fire.
|
||||
|
||||
Returns None when the cap does not apply (native resolution). Pure function so the
|
||||
@@ -117,7 +117,7 @@ def _apply_postprocessing(
|
||||
progress(f"Sharpening (unsharp mask: {unsharp})...")
|
||||
out_cv = humanizer.unsharp_mask(out_cv, amount=unsharp)
|
||||
|
||||
# Adaptive polish (CLI default): restore the input's detail level in the
|
||||
# Adaptive polish (per-profile default, see PROFILE_ADAPTIVE_POLISH): restore the input's detail level in the
|
||||
# softened output, sparing text/edges. Self-limiting where no deficit.
|
||||
if adaptive_polish:
|
||||
ref = reference()
|
||||
@@ -225,6 +225,8 @@ class InvisibleEngine:
|
||||
seed: Random seed for reproducibility. None resolves to 0, because all
|
||||
profiles are certified at a fixed seed.
|
||||
humanize: Intensity of Analog Humanizer film grain (0 = off).
|
||||
vendor: Provenance vendor that selects the strength cohort and, for the
|
||||
``auto`` profile, the engine.
|
||||
unsharp: Final unsharp-mask sharpening strength (0 = off, default).
|
||||
Applied after restoring the original resolution and before adaptive
|
||||
polish and humanize; ~0.5-0.8 is a safe range, higher risks edge halos.
|
||||
@@ -363,7 +365,7 @@ class InvisibleEngine:
|
||||
# single-write output is byte-identical.
|
||||
# Diffusers rounds native dimensions down to the latent grid (multiples
|
||||
# of 8), even when our own resolution policy did not resize the input.
|
||||
# Route those outputs through the same final resize so --no-polish does
|
||||
# Route those outputs through the same final resize so --no-adaptive-polish does
|
||||
# not silently change e.g. 1448x1086 into 1448x1080.
|
||||
needs_restore = target is not None or any(dimension % 8 for dimension in orig_size)
|
||||
if humanize > 0.0 or unsharp > 0.0 or adaptive_polish or needs_restore:
|
||||
|
||||
@@ -6,7 +6,8 @@ corner -- the locate box is horizontally centered). Current outputs can instead
|
||||
carry a compact top-left ``AI生成`` pill. The wordmark detector matches the
|
||||
bundled font-rendered "LiblibAI" silhouette (the triangle logo is NOT rendered
|
||||
-- logos vary, the wordmark discriminates); removal is the shared **localize ->
|
||||
fill** (the glyph blob covers logo + wordmark, both bright).
|
||||
fill** (the fill is the detector's match box, stretched left to take in the
|
||||
triangle logo).
|
||||
|
||||
This module supplies LiblibAI's tuned :class:`TextMarkConfig`
|
||||
(``assets/liblib_alpha.png`` from ``scripts/render_vendor_silhouettes.py``,
|
||||
|
||||
@@ -20,6 +20,7 @@ if TYPE_CHECKING:
|
||||
|
||||
from remove_ai_watermarks._internal.constants import (
|
||||
C2PA_CHUNK_TYPE,
|
||||
JPEG_SUFFIXES,
|
||||
PNG_METADATA_CHUNKS,
|
||||
PNG_SIGNATURE,
|
||||
RIFF_METADATA_CHUNKS,
|
||||
@@ -1362,7 +1363,7 @@ def get_ai_metadata(image_path: Path) -> dict[str, str]:
|
||||
|
||||
# PIL may not open AVIF/HEIF/JPEG-XL without optional plugins (and a
|
||||
# third-party plugin autoload can raise a non-OSError); fall through to the
|
||||
# C2PA/binary path on any open failure. See CLAUDE.md.
|
||||
# C2PA/binary path on any open failure.
|
||||
try:
|
||||
with Image.open(image_path) as img:
|
||||
for key, value in img.info.items():
|
||||
@@ -1828,7 +1829,7 @@ def _png_chunk_should_be_dropped(
|
||||
|
||||
# Fallback extension -> PIL save format, used only when the content sniff is
|
||||
# inconclusive (never for JPEG re-encode of lossless content).
|
||||
_EXT_TO_PIL_FORMAT = {".jpg": "JPEG", ".jpeg": "JPEG", ".webp": "WEBP", ".png": "PNG"}
|
||||
_EXT_TO_PIL_FORMAT = {**dict.fromkeys(JPEG_SUFFIXES, "JPEG"), ".webp": "WEBP", ".png": "PNG"}
|
||||
|
||||
|
||||
def _sniff_image_format(head: bytes) -> str | None:
|
||||
|
||||
@@ -388,7 +388,8 @@ def extract_pixel_evidence(image_path: Path, *, artifacts: bool = False, timings
|
||||
residual's sliding window and the ELA re-save are the two expensive steps and
|
||||
each family would otherwise redo them.
|
||||
|
||||
A family that fails or does not apply is left empty rather than raising: an
|
||||
A family that does not apply is left empty, and one that fails records
|
||||
``{"error": <exception type>}`` (status ``partial``), rather than raising: an
|
||||
undecodable file, or one too small for the block DCT, still returns a
|
||||
:class:`PixelEvidence` whose ``decoded`` / empty fields say so. Missing numpy is
|
||||
the one hard error, since then nothing can be measured at all.
|
||||
|
||||
@@ -224,7 +224,11 @@ def _get_session(name: str, repo_id: str, filename: str, label: str) -> object:
|
||||
import onnxruntime as ort
|
||||
from huggingface_hub import hf_hub_download
|
||||
|
||||
model_path = hf_hub_download(repo_id=repo_id, filename=filename)
|
||||
try:
|
||||
model_path = hf_hub_download(repo_id=repo_id, filename=filename)
|
||||
except Exception as exc:
|
||||
logger.warning("Downloading %s model %s from %s failed: %s", label, filename, repo_id, exc)
|
||||
raise
|
||||
logger.info("Loading %s model: %s", label, model_path)
|
||||
session = ort.InferenceSession(model_path, providers=["CPUExecutionProvider"])
|
||||
_sessions[name] = session
|
||||
|
||||
@@ -27,7 +27,7 @@ whose producer USCC names the entity, harvested 2026-07-22 by
|
||||
1536-wide frames) at ~0.008/0.006 x/y margins; the locate box below covers
|
||||
it with NCC slack.
|
||||
* ``alpha_height_frac`` comes from the silhouette aspect (0.128) at the
|
||||
measured width (0.27 * 1.25 rung ~= 0.3375 >= 0.32), per the standing rule
|
||||
measured 0.32 width (0.32 * 0.128 ~= 0.04), per the standing rule
|
||||
that it is measured, not inherited.
|
||||
* STRICT ONLY (``provenance_ncc_factor`` 1.0): raw gray NCC is
|
||||
contrast-DEPENDENT and the sub-gate band of a corner-anchored gray match is
|
||||
|
||||
@@ -56,7 +56,7 @@ DETECT_NCC_THRESHOLD = 0.40
|
||||
# Detection-silhouette geometry, solved by scripts/visible_alpha_solve.py from the flat
|
||||
# gray capture (native width 1086). Real photos are ~2958 wide, so the captured glyph is
|
||||
# upscaled; width-scale + NCC-align sizes the silhouette for the detection match (removal
|
||||
# is the template-free glyph-bbox footprint mask).
|
||||
# aligns this alpha to the detector's match box; see footprint_mask).
|
||||
_ALPHA_NATIVE_WIDTH = 1086
|
||||
_ALPHA_WIDTH_FRAC = 0.3195 # asset width / image width -- sizes the detection silhouette
|
||||
_ALPHA_HEIGHT_FRAC = 0.0378
|
||||
|
||||
@@ -7,6 +7,7 @@ removed. Call it explicitly; ``identify`` never imports or runs it.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
@@ -19,6 +20,8 @@ from typing import TYPE_CHECKING, Any, Literal
|
||||
if TYPE_CHECKING:
|
||||
from numpy.typing import NDArray
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
SOURCE_CLASSIFY_EXTRA = "'remove-ai-watermarks[source-classify]'"
|
||||
WEIGHTS_ENV = "RAIW_SOURCE_CLASSIFY_WEIGHTS"
|
||||
WEIGHTS_REPO = "wiltodelta/raiw-source-classify"
|
||||
@@ -95,13 +98,17 @@ def _model_path() -> Path:
|
||||
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,
|
||||
try:
|
||||
return Path(
|
||||
hub.hf_hub_download(
|
||||
repo_id=WEIGHTS_REPO,
|
||||
filename=MODEL_FILE,
|
||||
revision=WEIGHTS_REVISION,
|
||||
)
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
log.warning("Downloading %s from %s@%s failed: %s", MODEL_FILE, WEIGHTS_REPO, WEIGHTS_REVISION, exc)
|
||||
raise
|
||||
|
||||
|
||||
@lru_cache(maxsize=2)
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Opt-in OpenAI/Google export-source patterns in decoded image pixels.
|
||||
|
||||
This classifier does not decode SynthID or establish watermark presence.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from hashlib import file_digest
|
||||
from importlib import import_module
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Literal, get_args
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from numpy.typing import NDArray
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
WEIGHTS_REPO = "wiltodelta/openai-google-image-source-classifier"
|
||||
WEIGHTS_REVISION = "4563fb97e5572030ad8d6acbb6f20eaab7fc3009"
|
||||
WEIGHTS_ENV = "RAIW_IMAGE_SOURCE_WEIGHTS"
|
||||
MODEL_FILE = "openai-google-source-v1.npz"
|
||||
MODEL_SHA256 = "0e94e565c181416e4f63a2bfc06cd5179f4201befda7481ac510f6a168c612f4"
|
||||
MODEL_SCHEMA = "openai-google-source-v1"
|
||||
GROUPS = ("square", "portrait", "landscape")
|
||||
ROUTES = ("qwen", "mark", "direct")
|
||||
_FILTER_NAMES = (
|
||||
"old_google_default_photo_fit",
|
||||
"old_google_sigma24",
|
||||
"r152_google",
|
||||
"r152_openai",
|
||||
"r161_google",
|
||||
"r161_openai",
|
||||
*(f"r163_{group}" for group in GROUPS),
|
||||
)
|
||||
_THRESHOLD_NAMES = (
|
||||
"old_google_default_parent",
|
||||
"old_google_sigma24_parent",
|
||||
"r152_google",
|
||||
"r152_openai",
|
||||
"r161_google",
|
||||
"r161_openai",
|
||||
"r183_openai_gate",
|
||||
*(f"r163_{group}" for group in GROUPS),
|
||||
*(f"openai_{route}" for route in ROUTES),
|
||||
)
|
||||
CLAIM = "likely image source/export pattern; not SynthID detection"
|
||||
|
||||
SourceLabel = Literal["openai", "google", "unknown"]
|
||||
SourceReason = Literal["classified", "abstained", "conflict", "feature_unavailable"]
|
||||
PROVIDERS = tuple(label for label in get_args(SourceLabel) if label != "unknown")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ImageSourceClassification:
|
||||
"""Narrow source attribution with honest abstention and watermark unknown."""
|
||||
|
||||
label: SourceLabel
|
||||
reason: SourceReason
|
||||
scores: dict[str, float]
|
||||
watermark_truth: Literal["unknown"] = "unknown"
|
||||
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,
|
||||
"watermark_truth": self.watermark_truth,
|
||||
"claim": self.claim,
|
||||
"model_revision": self.model_revision,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Model:
|
||||
filters: dict[str, tuple[NDArray[Any], NDArray[Any]]]
|
||||
templates: dict[str, NDArray[Any]]
|
||||
thresholds: dict[str, float]
|
||||
|
||||
|
||||
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("Install remove-ai-watermarks[source-classify] for image-source weights") 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) -> _Model:
|
||||
import numpy as np
|
||||
|
||||
with path.open("rb") as source:
|
||||
actual_sha256 = file_digest(source, "sha256").hexdigest()
|
||||
if actual_sha256 != expected_sha256:
|
||||
raise ValueError("image-source model SHA-256 mismatch")
|
||||
required = {
|
||||
"schema",
|
||||
*(f"{name}_{field}" for name in _FILTER_NAMES for field in ("mean", "precision")),
|
||||
*(f"openai_{route}_unit" for route in ROUTES),
|
||||
*(f"threshold_{name}" for name in _THRESHOLD_NAMES),
|
||||
}
|
||||
with np.load(path, allow_pickle=False) as payload:
|
||||
if set(payload.files) != required or str(payload["schema"]) != MODEL_SCHEMA:
|
||||
raise ValueError("image-source model schema mismatch")
|
||||
loaded_filters: dict[str, tuple[NDArray[Any], NDArray[Any]]] = {}
|
||||
for name in _FILTER_NAMES:
|
||||
mean = payload[f"{name}_mean"]
|
||||
precision = payload[f"{name}_precision"]
|
||||
if mean.shape != (256, 256, 3) or mean.dtype != np.complex64:
|
||||
raise ValueError(f"invalid {name} mean")
|
||||
if precision.shape != mean.shape or precision.dtype != np.float32:
|
||||
raise ValueError(f"invalid {name} precision")
|
||||
if not np.all(np.isfinite(mean)) or not np.all(np.isfinite(precision)) or np.any(precision < 0):
|
||||
raise ValueError(f"nonfinite or negative {name} filter")
|
||||
loaded_filters[name] = (mean, precision)
|
||||
templates: dict[str, NDArray[Any]] = {}
|
||||
for route in ROUTES:
|
||||
unit = payload[f"openai_{route}_unit"]
|
||||
if unit.shape != (992, 992) or unit.dtype != np.float32 or not np.all(np.isfinite(unit)):
|
||||
raise ValueError(f"invalid {route} template")
|
||||
if not np.isclose(np.linalg.norm(unit), 1.0, rtol=1e-5):
|
||||
raise ValueError(f"nonunit {route} template")
|
||||
templates[route] = unit.ravel()
|
||||
limits: dict[str, float] = {}
|
||||
for name in _THRESHOLD_NAMES:
|
||||
value = payload[f"threshold_{name}"]
|
||||
if value.shape != () or value.dtype != np.float64 or not np.isfinite(value):
|
||||
raise ValueError(f"invalid {name} threshold")
|
||||
limits[name] = float(value)
|
||||
return _Model(loaded_filters, templates, limits)
|
||||
|
||||
|
||||
def _spectrum(rgb: NDArray[Any], sigma: float) -> NDArray[Any]:
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
small = np.asarray(Image.fromarray(rgb).resize((256, 256), Image.Resampling.BILINEAR))
|
||||
values = cv2.cvtColor(small.astype(np.float32) / 255, cv2.COLOR_RGB2YCrCb)
|
||||
high = values - cv2.GaussianBlur(values, (0, 0), sigma)
|
||||
energy = cv2.GaussianBlur(high * high, (0, 0), 3)
|
||||
high = np.clip(high / np.sqrt(energy + (1 / 255) ** 2), -4, 4)
|
||||
if sigma == 1.2:
|
||||
high -= high.mean(axis=(0, 1), keepdims=True)
|
||||
else:
|
||||
high -= high.mean(axis=(0, 1), keepdims=True, dtype=np.float64).astype(np.float32)
|
||||
high /= np.maximum(np.sqrt(np.sum(high * high, axis=(0, 1), keepdims=True)), 1e-12)
|
||||
return np.fft.fft2(high, axes=(0, 1), norm="ortho").astype(np.complex64)
|
||||
|
||||
|
||||
def _template_score(values: NDArray[Any], weights: tuple[NDArray[Any], NDArray[Any]]) -> float:
|
||||
import numpy as np
|
||||
|
||||
mean, precision = weights
|
||||
numerator = np.real(values * np.conj(mean) * precision).sum(axis=(0, 1))
|
||||
denominator = max(
|
||||
float(np.sqrt((np.abs(values) ** 2 * precision).sum() * (np.abs(mean) ** 2 * precision).sum())), 1e-12
|
||||
)
|
||||
return float(numerator.sum() / denominator)
|
||||
|
||||
|
||||
def _openai_template_scores(rgb: NDArray[Any], model: _Model) -> dict[str, float]:
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
if rgb.shape[:2] != (1024, 1024):
|
||||
return {}
|
||||
luminance = cv2.cvtColor(rgb, cv2.COLOR_RGB2YCrCb)[..., 0].astype(np.float32)
|
||||
high = luminance - cv2.GaussianBlur(luminance, (0, 0), 1.2, borderType=cv2.BORDER_REFLECT)
|
||||
energy = cv2.GaussianBlur(high * high, (0, 0), 3.0, borderType=cv2.BORDER_REFLECT)
|
||||
residual = np.clip(high / np.sqrt(energy + 1), -4, 4)[8:1000, 8:1000].ravel().astype(np.float32)
|
||||
residual -= residual.mean()
|
||||
norm = np.linalg.norm(residual)
|
||||
if norm <= 0:
|
||||
return {}
|
||||
return {route: float(np.dot(residual, unit) / norm) for route, unit in model.templates.items()}
|
||||
|
||||
|
||||
def _aspect(width: int, height: int) -> str:
|
||||
ratio = width / height
|
||||
if 0.87 <= ratio <= 1.15:
|
||||
return "square"
|
||||
if 0.55 <= ratio < 0.87:
|
||||
return "portrait"
|
||||
if 1.15 < ratio <= 1.82:
|
||||
return "landscape"
|
||||
return "other"
|
||||
|
||||
|
||||
def classify_image_source(source: str | os.PathLike[str]) -> ImageSourceClassification:
|
||||
"""Return likely OpenAI/Google source or abstain; never verify SynthID."""
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(source) as opened:
|
||||
if min(opened.size) < 256:
|
||||
return ImageSourceClassification("unknown", "feature_unavailable", {})
|
||||
rgb = np.asarray(opened.convert("RGB"), dtype=np.uint8)
|
||||
model = _load_model(_model_path(), MODEL_SHA256)
|
||||
height, width = rgb.shape[:2]
|
||||
regular = _spectrum(rgb, 1.2)
|
||||
old_default = _template_score(regular, model.filters["old_google_default_photo_fit"])
|
||||
old_sigma24 = _template_score(_spectrum(rgb, 2.4), model.filters["old_google_sigma24"])
|
||||
limits = model.thresholds
|
||||
old_google = (
|
||||
old_default / limits["old_google_default_parent"] + old_sigma24 / limits["old_google_sigma24_parent"]
|
||||
) / 2
|
||||
r152_google = _template_score(regular, model.filters["r152_google"])
|
||||
r152_openai = _template_score(regular, model.filters["r152_openai"])
|
||||
r161_google = _template_score(regular, model.filters["r161_google"])
|
||||
r161_openai = _template_score(regular, model.filters["r161_openai"])
|
||||
aspect = _aspect(width, height)
|
||||
r163_openai = _template_score(regular, model.filters[f"r163_{aspect}"]) if aspect in GROUPS else None
|
||||
old_openai_scores = _openai_template_scores(rgb, model)
|
||||
google = old_google >= 1.0 or r152_google >= limits["r152_google"] or r161_google >= limits["r161_google"]
|
||||
openai = (
|
||||
any(score >= limits[f"openai_{route}"] for route, score in old_openai_scores.items())
|
||||
or r152_openai >= limits["r152_openai"]
|
||||
or r161_openai >= limits["r161_openai"]
|
||||
or (
|
||||
r163_openai is not None
|
||||
and r163_openai >= limits[f"r163_{aspect}"]
|
||||
and r152_openai >= limits["r183_openai_gate"]
|
||||
)
|
||||
)
|
||||
scores = {
|
||||
"old_google": old_google,
|
||||
"r152_google": r152_google,
|
||||
"r152_openai": r152_openai,
|
||||
"r161_google": r161_google,
|
||||
"r161_openai": r161_openai,
|
||||
}
|
||||
if r163_openai is not None:
|
||||
scores["r163_openai"] = r163_openai
|
||||
scores.update({f"openai_{route}": score for route, score in old_openai_scores.items()})
|
||||
if google and openai:
|
||||
return ImageSourceClassification("unknown", "conflict", scores)
|
||||
if google:
|
||||
return ImageSourceClassification("google", "classified", scores)
|
||||
if openai:
|
||||
return ImageSourceClassification("openai", "classified", scores)
|
||||
return ImageSourceClassification("unknown", "abstained", scores)
|
||||
@@ -274,39 +274,22 @@ def _visible_removal_plan(
|
||||
"""Resolve one provider's stable frame regions and fill geometry.
|
||||
|
||||
Everything provider-specific -- the confidence floors, the run length, the fill
|
||||
padding and the mask style -- is data on ``VISIBLE_MARK_POLICIES``. The only thing
|
||||
left here is WHICH metadata predicate confirms which vendor, which genuinely is a
|
||||
mapping and not a tuning constant.
|
||||
padding and the mask style -- is data on ``VISIBLE_MARK_POLICIES``; which metadata
|
||||
confirms or contradicts which vendor lives beside the predicates in
|
||||
``video_visible``.
|
||||
"""
|
||||
from remove_ai_watermarks.video_visible import (
|
||||
VISIBLE_MARK_POLICIES,
|
||||
confirms_video_provenance,
|
||||
contradicts_video_provenance,
|
||||
has_bytedance_video_provenance,
|
||||
has_doubao_video_provenance,
|
||||
has_hailuo_video_provenance,
|
||||
has_sora_provenance,
|
||||
has_veo_provenance,
|
||||
has_vidu_video_provenance,
|
||||
stabilize_localizations,
|
||||
)
|
||||
|
||||
confirms = {
|
||||
"sora": has_sora_provenance,
|
||||
"veo": has_veo_provenance,
|
||||
"seedance": has_bytedance_video_provenance,
|
||||
"doubao": has_doubao_video_provenance,
|
||||
"dola": has_bytedance_video_provenance,
|
||||
"hailuo": has_hailuo_video_provenance,
|
||||
"vidu": has_vidu_video_provenance,
|
||||
}.get(selected_mark)
|
||||
policy = VISIBLE_MARK_POLICIES[selected_mark]
|
||||
if contradicts_video_provenance(selected_mark, markers):
|
||||
return [None] * len(selected_scan.detections), policy.padding_fraction, policy.mask_style
|
||||
regions = stabilize_localizations(
|
||||
selected_mark,
|
||||
selected_scan.detections,
|
||||
provenance=bool(confirms and confirms(markers)),
|
||||
)
|
||||
confirmed = confirms_video_provenance(selected_mark, markers)
|
||||
regions = stabilize_localizations(selected_mark, selected_scan.detections, provenance=confirmed)
|
||||
return regions, policy.padding_fraction, policy.mask_style
|
||||
|
||||
|
||||
@@ -324,22 +307,11 @@ def _select_stable_visible_mark(
|
||||
]
|
||||
| None
|
||||
):
|
||||
"""Select the first stable provider result in the public specificity order."""
|
||||
"""Select the first stable mark in specificity order that provenance does not veto."""
|
||||
for candidate_mark in candidate_marks:
|
||||
candidate_scan = scans[candidate_mark]
|
||||
candidate_regions, candidate_padding, candidate_mask_style = _visible_removal_plan(
|
||||
candidate_mark,
|
||||
candidate_scan,
|
||||
markers,
|
||||
)
|
||||
if any(region is not None for region in candidate_regions):
|
||||
return (
|
||||
candidate_mark,
|
||||
candidate_scan,
|
||||
candidate_regions,
|
||||
candidate_padding,
|
||||
candidate_mask_style,
|
||||
)
|
||||
regions, padding, mask_style = _visible_removal_plan(candidate_mark, scans[candidate_mark], markers)
|
||||
if any(region is not None for region in regions):
|
||||
return candidate_mark, scans[candidate_mark], regions, padding, mask_style
|
||||
return None
|
||||
|
||||
|
||||
@@ -371,7 +343,7 @@ def _video_markers_claim_ai(markers: dict[str, str]) -> bool:
|
||||
so only through an AI-generator identity: a registered AI signer or an AI
|
||||
product named as the claim generator.
|
||||
"""
|
||||
from remove_ai_watermarks._internal.c2pa import claim_generator_word_platform, registry_word_matches
|
||||
from remove_ai_watermarks._internal.c2pa import claim_generator_platform, registry_word_matches
|
||||
from remove_ai_watermarks._internal.constants import C2PA_AI_TOOLS, C2PA_IDENTITY_AI_ORGS
|
||||
|
||||
source_type = markers.get("source_type", "")
|
||||
@@ -383,26 +355,22 @@ def _video_markers_claim_ai(markers: dict[str, str]) -> bool:
|
||||
generator = markers.get("claim_generator", "")
|
||||
return (
|
||||
any(org in issuer for org in C2PA_IDENTITY_AI_ORGS)
|
||||
or claim_generator_word_platform(generator) is not None
|
||||
or claim_generator_platform(generator) is not None
|
||||
or bool(registry_word_matches(generator, C2PA_AI_TOOLS))
|
||||
)
|
||||
|
||||
|
||||
def _platform_from_video_metadata(markers: dict[str, str]) -> str | None:
|
||||
"""Map supported C2PA-derived marker text to its generating platform."""
|
||||
from remove_ai_watermarks._internal.c2pa import registry_word_matches
|
||||
from remove_ai_watermarks._internal.constants import C2PA_AI_VENDORS
|
||||
from remove_ai_watermarks._internal.c2pa import c2pa_vendor_platform, claim_generator_platform
|
||||
|
||||
marker_text = "\n".join(markers.values()).casefold()
|
||||
if not marker_text:
|
||||
return None
|
||||
needles = {
|
||||
vendor.needle.casefold().encode(): vendor.platform
|
||||
for vendor in C2PA_AI_VENDORS
|
||||
if vendor.platform is not None and vendor.needle is not None
|
||||
}
|
||||
if hits := registry_word_matches(marker_text, needles):
|
||||
return hits[0]
|
||||
generator = markers.get("claim_generator", "")
|
||||
if platform := (
|
||||
claim_generator_platform(generator)
|
||||
or c2pa_vendor_platform(generator)
|
||||
or c2pa_vendor_platform("\n".join(markers.values()))
|
||||
):
|
||||
return platform
|
||||
if "aigc_label" in markers:
|
||||
return _tc260_video_platform(markers.get("aigc_producer", "")) or (
|
||||
"China AIGC-labeled content (TC260 standard)"
|
||||
@@ -555,8 +523,10 @@ def remove_video_visible(
|
||||
"""Remove a supported visible AI wordmark from a video.
|
||||
|
||||
``mark="auto"`` scans every supported provider in one decode pass and selects
|
||||
the first stable match in specificity order. Explicit marks are ``sora``,
|
||||
``veo``, ``seedance``, ``dola``, ``hailuo``, and ``kling``. The full
|
||||
the first stable match in specificity order, except a Veo diamond nested in
|
||||
a Sora cross-match or spanning the full clip against a partial Sora run,
|
||||
with valid Google AI-video provenance. Explicit marks are
|
||||
``sora``, ``veo``, ``seedance``, ``doubao``, ``dola``, ``hailuo``, and ``kling``. The full
|
||||
sequence is scanned before pixels change, and only recurring candidates are
|
||||
accepted. Complete audio is copied without re-encoding; video is transcoded
|
||||
because the pixels change. ``temporal_consistency=True`` motion-aligns a
|
||||
|
||||
@@ -179,7 +179,7 @@ def _run_ffprobe(
|
||||
"""Run one ffprobe query with shared logging and failure handling."""
|
||||
ffprobe = shutil.which("ffprobe")
|
||||
if ffprobe is None:
|
||||
log.warning("ffprobe is unavailable; cannot inspect %s", purpose)
|
||||
log.warning("ffprobe is unavailable; cannot inspect %s of %s", purpose, source)
|
||||
return None
|
||||
command = [ffprobe, "-v", "error", *arguments, "-of", output_format, str(source)]
|
||||
result = subprocess.run( # noqa: S603
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Visible AI-watermark localization and removal for video.
|
||||
|
||||
Supported marks use fully synthetic silhouettes made from geometric primitives,
|
||||
OpenCV's built-in font, and Pillow's bundled font. Sora detection searches the
|
||||
frozen renders of OpenCV 4's built-in font, and Pillow's bundled font. Sora detection searches the
|
||||
full frame because the wordmark moves. Veo detection covers both the current
|
||||
four-point diamond and legacy ``Veo`` text. Seedance detects the boxed ``AI``
|
||||
label, Dola detects its compact text label, Hailuo AI detects the composite
|
||||
@@ -28,7 +28,7 @@ import logging
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, replace
|
||||
from fractions import Fraction
|
||||
from functools import lru_cache
|
||||
from functools import cache, lru_cache
|
||||
from itertools import pairwise
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
@@ -68,6 +68,7 @@ _SORA_RELATIVE_HEIGHTS = (0.065, 0.075, 0.085, 0.095, 0.105)
|
||||
_SORA_PROVENANCE_WEAK_CONFIDENCE = 0.58
|
||||
_SORA_STRICT_WEAK_CONFIDENCE = 0.60
|
||||
_SORA_STRONG_CONFIDENCE = 0.65
|
||||
_SORA_ICON_SHAPE_FLOOR = 0.50
|
||||
_VEO_PROVENANCE_WEAK_CONFIDENCE = 0.45
|
||||
_VEO_STRICT_WEAK_CONFIDENCE = 0.50
|
||||
_VEO_STRONG_CONFIDENCE = 0.55
|
||||
@@ -240,21 +241,14 @@ def _seedance_template() -> NDArray[Any]:
|
||||
return np.asarray(canvas, dtype=np.uint8)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _dola_template() -> NDArray[Any]:
|
||||
"""Return a synthetic Dola AI text silhouette using OpenCV's font."""
|
||||
canvas = np.zeros((100, 400), dtype=np.uint8)
|
||||
cv2.putText(
|
||||
canvas,
|
||||
"Dola AI",
|
||||
(2, 72),
|
||||
cv2.FONT_HERSHEY_DUPLEX,
|
||||
2.2,
|
||||
255,
|
||||
3,
|
||||
cv2.LINE_AA,
|
||||
)
|
||||
return _crop_nonzero(canvas)
|
||||
"""Return the synthetic Dola AI text silhouette drawn in OpenCV 4's Hershey font.
|
||||
|
||||
The Hershey templates are frozen assets, not rendered here: OpenCV 5 draws the
|
||||
same ``putText`` call with smaller, heavier glyphs than the ones the detectors were
|
||||
calibrated on. ``scripts/render_video_hershey_templates.py`` is the recipe.
|
||||
"""
|
||||
return _asset_template("video_dola_hershey_duplex.png")
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
@@ -291,7 +285,7 @@ def _hailuo_template() -> NDArray[Any]:
|
||||
def _kling_templates() -> tuple[NDArray[Any], ...]:
|
||||
"""Return synthetic font and capitalization variants for the Kling wordmark core."""
|
||||
templates: list[NDArray[Any]] = []
|
||||
for text in ("KLING AI", "KlingAI"):
|
||||
for text, asset_stem in (("KLING AI", "kling_ai"), ("KlingAI", "klingai")):
|
||||
canvas = Image.new("L", (430, 104), 0)
|
||||
ImageDraw.Draw(canvas).text(
|
||||
(2, 12),
|
||||
@@ -302,19 +296,8 @@ def _kling_templates() -> tuple[NDArray[Any], ...]:
|
||||
stroke_fill=255,
|
||||
)
|
||||
templates.append(_crop_nonzero(np.asarray(canvas, dtype=np.uint8)))
|
||||
for font in (cv2.FONT_HERSHEY_SIMPLEX, cv2.FONT_HERSHEY_DUPLEX):
|
||||
cv_template = np.zeros((100, 500), dtype=np.uint8)
|
||||
cv2.putText(
|
||||
cv_template,
|
||||
text,
|
||||
(2, 72),
|
||||
font,
|
||||
2.2,
|
||||
255,
|
||||
3,
|
||||
cv2.LINE_AA,
|
||||
)
|
||||
templates.append(_crop_nonzero(cv_template))
|
||||
# Frozen OpenCV 4 Hershey renders; see _dola_template for why.
|
||||
templates.extend(_asset_template(f"video_{asset_stem}_hershey_{font}.png") for font in ("simplex", "duplex"))
|
||||
return tuple(templates)
|
||||
|
||||
|
||||
@@ -370,7 +353,7 @@ def _top_hat(gray: NDArray[Any]) -> NDArray[Any]:
|
||||
return cv2.morphologyEx(gray, cv2.MORPH_TOPHAT, kernel)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
@cache
|
||||
def _asset_template(name: str) -> NDArray[Any]:
|
||||
"""Load a package asset as a video detect template (white glyph on black).
|
||||
|
||||
@@ -388,7 +371,6 @@ def _asset_template(name: str) -> NDArray[Any]:
|
||||
return at
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _doubao_template() -> NDArray[Any]:
|
||||
return _asset_template("doubao_alpha.png")
|
||||
|
||||
@@ -508,7 +490,7 @@ def detect_sora_frame(
|
||||
template_width = max(1, round(base_template.shape[1] * template_height / base_template.shape[0]))
|
||||
if template_height >= normalized_height or template_width >= normalized_width:
|
||||
continue
|
||||
_, template_feature = _resized_template_feature(
|
||||
template, template_feature = _resized_template_feature(
|
||||
template_key,
|
||||
template_width,
|
||||
template_height,
|
||||
@@ -518,6 +500,14 @@ def detect_sora_frame(
|
||||
_, confidence, _, location = cv2.minMaxLoc(scores)
|
||||
if confidence <= best_confidence:
|
||||
continue
|
||||
if icon_only:
|
||||
# Edge-only correlation also accepts diamonds and scene texture.
|
||||
# Confirm the full mascot silhouette, including its two eyes.
|
||||
x, y = location
|
||||
candidate = gray[y : y + template_height, x : x + template_width]
|
||||
shape_confidence = float(cv2.matchTemplate(candidate, template, cv2.TM_CCOEFF_NORMED)[0, 0])
|
||||
if shape_confidence < _SORA_ICON_SHAPE_FLOOR:
|
||||
continue
|
||||
best_confidence = float(confidence)
|
||||
best_region = _expanded_region(
|
||||
location,
|
||||
@@ -1010,15 +1000,15 @@ def detect_veo_frame(
|
||||
return FrameLocalization(frame_index, best_confidence, best_region)
|
||||
|
||||
|
||||
def _region_iou(left: Region, right: Region) -> float:
|
||||
def _region_intersection(left: Region, right: Region) -> int:
|
||||
lx, ly, lw, lh = left
|
||||
rx, ry, rw, rh = right
|
||||
x0 = max(lx, rx)
|
||||
y0 = max(ly, ry)
|
||||
x1 = min(lx + lw, rx + rw)
|
||||
y1 = min(ly + lh, ry + rh)
|
||||
intersection = max(0, x1 - x0) * max(0, y1 - y0)
|
||||
union = lw * lh + rw * rh - intersection
|
||||
return max(0, min(lx + lw, rx + rw) - max(lx, rx)) * max(0, min(ly + lh, ry + rh) - max(ly, ry))
|
||||
|
||||
|
||||
def _region_iou(left: Region, right: Region) -> float:
|
||||
intersection = _region_intersection(left, right)
|
||||
union = left[2] * left[3] + right[2] * right[3] - intersection
|
||||
return intersection / union if union > 0 else 0.0
|
||||
|
||||
|
||||
@@ -1679,6 +1669,20 @@ _VIDEO_MARK_ISSUER_TOKENS: dict[str, tuple[str, ...]] = {
|
||||
}
|
||||
|
||||
|
||||
def confirms_video_provenance(mark: str, markers: dict[str, str]) -> bool:
|
||||
"""Whether provenance names the visible mark's own vendor; Kling has no such predicate."""
|
||||
confirms = {
|
||||
"sora": has_sora_provenance,
|
||||
"veo": has_veo_provenance,
|
||||
"seedance": has_bytedance_video_provenance,
|
||||
"doubao": has_doubao_video_provenance,
|
||||
"dola": has_bytedance_video_provenance,
|
||||
"hailuo": has_hailuo_video_provenance,
|
||||
"vidu": has_vidu_video_provenance,
|
||||
}.get(mark)
|
||||
return bool(confirms and confirms(markers))
|
||||
|
||||
|
||||
def contradicts_video_provenance(mark: str, markers: dict[str, str]) -> bool:
|
||||
"""Whether provenance names a producer other than the visible mark's vendor.
|
||||
|
||||
@@ -1696,15 +1700,27 @@ def contradicts_video_provenance(mark: str, markers: dict[str, str]) -> bool:
|
||||
192-frame run; a Gemini Omni 1.1 Flash clip with Google's C2PA (Runway,
|
||||
2026-09-25) scored 0.61-0.67 for Sora on a steam wisp over five frames. A
|
||||
re-signing platform (YouTube) records no AI source type, so it vetoes nothing.
|
||||
An invalid credential vetoes nothing either, and provenance that also confirms
|
||||
the mark's own vendor (a Sora claim generator under a Google issuer) confirms
|
||||
instead of vetoing.
|
||||
"""
|
||||
if confirms_video_provenance(mark, markers):
|
||||
return False
|
||||
producer = markers.get("aigc_producer", "").strip()
|
||||
if mark == "kling" and producer:
|
||||
from remove_ai_watermarks.watermark_registry import get_mark
|
||||
|
||||
return not tc260_producer_in(producer, get_mark("kling").tc260_producer_codes)
|
||||
from remove_ai_watermarks._internal.c2pa import c2pa_credential_level
|
||||
|
||||
issuer = markers.get("issuer", "").casefold()
|
||||
claims_ai = "trainedalgorithmicmedia" in markers.get("source_type", "").casefold()
|
||||
return bool(issuer) and claims_ai and not any(token in issuer for token in _VIDEO_MARK_ISSUER_TOKENS[mark])
|
||||
return (
|
||||
bool(issuer)
|
||||
and claims_ai
|
||||
and c2pa_credential_level(markers) != "invalid"
|
||||
and not any(token in issuer for token in _VIDEO_MARK_ISSUER_TOKENS[mark])
|
||||
)
|
||||
|
||||
|
||||
def has_vidu_video_provenance(markers: dict[str, str]) -> bool:
|
||||
|
||||
@@ -620,8 +620,8 @@ def _text_mark(
|
||||
``product`` defaults to the key (one mark, one product); pass it only when two
|
||||
marks share a product. ``manufacturer`` is separate because unrelated companies
|
||||
use the same TC260 standard. ``label_regime`` and ``provenance_signals`` default
|
||||
to the China-AIGC label because every text mark registered so far except Samsung
|
||||
and Microsoft uses it.
|
||||
to the China-AIGC label because every text mark registered so far except Samsung,
|
||||
Microsoft, generic_ai_label and OpenArt uses it.
|
||||
"""
|
||||
return KnownMark(
|
||||
key,
|
||||
@@ -841,7 +841,8 @@ _REGISTRY: tuple[KnownMark, ...] = (
|
||||
label_regime=None,
|
||||
provenance_signals=(),
|
||||
),
|
||||
# Same product as the Jimeng wordmark -- the one pair that cross-relaxes.
|
||||
# Same product as the Jimeng wordmark, so a strict wordmark relaxes it (LiblibAI's
|
||||
# wordmark and pill share a product the same way).
|
||||
KnownMark(
|
||||
"jimeng_pill",
|
||||
"Jimeng AI生成 pill",
|
||||
@@ -1139,6 +1140,7 @@ def remove_auto_marks_detailed(
|
||||
resolved_backend,
|
||||
bbox,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
removals.append(
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
"""Regression tests for Dependabot compatibility constraints."""
|
||||
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
from packaging.requirements import Requirement
|
||||
|
||||
|
||||
def test_dependabot_numpy_ignore_mirrors_the_lock_constraint() -> None:
|
||||
constraints = tomllib.loads(Path("pyproject.toml").read_text())["tool"]["uv"]["constraint-dependencies"]
|
||||
(numpy,) = (r for r in map(Requirement, constraints) if r.name == "numpy")
|
||||
(upper,) = (spec.version for spec in numpy.specifier if spec.operator == "<")
|
||||
|
||||
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
|
||||
numpy_ignore = config.split('dependency-name: "numpy"', maxsplit=1)[1].split("dependency-name:", maxsplit=1)[0]
|
||||
assert f'- ">={upper}"' in numpy_ignore
|
||||
@@ -9,10 +9,12 @@ import numpy as np
|
||||
import pytest
|
||||
|
||||
from remove_ai_watermarks import watermark_registry as registry
|
||||
from remove_ai_watermarks._text_mark_engine import TextMarkEngine
|
||||
from remove_ai_watermarks.doubao_engine import (
|
||||
_ALPHA_HEIGHT_FRAC,
|
||||
_ALPHA_NATIVE_WIDTH,
|
||||
_ALPHA_WIDTH_FRAC,
|
||||
_CONFIG,
|
||||
DETECT_NCC_THRESHOLD,
|
||||
DoubaoEngine,
|
||||
_alpha_template,
|
||||
@@ -24,10 +26,11 @@ from remove_ai_watermarks.image_io import load_image_bgr
|
||||
SAMPLE = Path(__file__).resolve().parents[1] / "data" / "fixtures" / "provenance" / "doubao-1.png"
|
||||
|
||||
|
||||
def _compose(w: int, h: int, bg: float = 100.0):
|
||||
def _compose(w: int, h: int, bg: float | tuple[int, int, int] = 100.0):
|
||||
"""Composite the real alpha (scaled to width ``w``) onto a flat bg.
|
||||
Returns ``(watermarked_uint8, mark_bool_mask)``."""
|
||||
img = np.full((h, w, 3), bg, np.float32)
|
||||
img = np.empty((h, w, 3), np.float32)
|
||||
img[:] = bg
|
||||
at = _alpha_template()
|
||||
gw, gh = int(_ALPHA_WIDTH_FRAC * w), int(_ALPHA_HEIGHT_FRAC * w)
|
||||
margin = int(0.015 * w)
|
||||
@@ -59,6 +62,19 @@ class TestLocate:
|
||||
|
||||
|
||||
class TestDetect:
|
||||
def test_tinted_glyph_on_saturated_background(self):
|
||||
"""A translucent white glyph stays recognizable after a yellow background tints it."""
|
||||
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH, bg=(20, 190, 240))
|
||||
eng = DoubaoEngine()
|
||||
clean = np.empty_like(wm)
|
||||
clean[:] = (20, 190, 240)
|
||||
assert not eng.detect(clean).detected
|
||||
assert eng.detect(wm).detected
|
||||
out, region = registry.get_mark("doubao").remove(wm, backend="cv2")
|
||||
assert region is not None
|
||||
assert not eng.detect(out).detected
|
||||
assert np.array_equal(wm[:1024, :1024], out[:1024, :1024])
|
||||
|
||||
def test_clean_gradient_not_detected(self):
|
||||
eng = DoubaoEngine()
|
||||
ramp = np.tile(np.linspace(0, 255, 1024, dtype=np.uint8), (1024, 1))
|
||||
@@ -104,9 +120,11 @@ class TestDetect:
|
||||
@pytest.mark.skipif(not SAMPLE.exists(), reason="sample image not present")
|
||||
class TestRealSample:
|
||||
def test_detects_watermark(self):
|
||||
det = DoubaoEngine().detect(load_image_bgr(SAMPLE))
|
||||
image = load_image_bgr(SAMPLE)
|
||||
det = DoubaoEngine().detect(image)
|
||||
assert det.detected
|
||||
assert det.confidence >= DETECT_NCC_THRESHOLD
|
||||
assert det.confidence == TextMarkEngine(_CONFIG).detect(image).confidence
|
||||
|
||||
def test_fill_lowers_confidence(self):
|
||||
img = load_image_bgr(SAMPLE)
|
||||
|
||||
@@ -52,10 +52,18 @@ def _png_chunk(chunk_type: bytes, payload: bytes) -> bytes:
|
||||
|
||||
|
||||
def test_supported_extensions_are_media_not_documents():
|
||||
assert {".jpg", ".png", ".webp", ".heic", ".mp4"}.issubset(SUPPORTED_EXTENSIONS)
|
||||
assert {".jpg", ".jpeg", ".jpe", ".jfif", ".png", ".webp", ".heic", ".mp4"}.issubset(SUPPORTED_EXTENSIONS)
|
||||
assert ".pdf" not in SUPPORTED_EXTENSIONS
|
||||
|
||||
|
||||
@pytest.mark.parametrize("suffix", [".jpe", ".jfif"])
|
||||
def test_jpeg_alias_forensic_record_uses_jpeg_content(tmp_path: Path, suffix: str):
|
||||
path = _jpeg(tmp_path / f"photo{suffix}")
|
||||
record = collect_forensic_metadata(path)
|
||||
assert record["extension"] == suffix
|
||||
assert record["content_format"] == "jpeg"
|
||||
|
||||
|
||||
def test_json_helpers_and_format_sniffer():
|
||||
class BadString:
|
||||
def __str__(self):
|
||||
|
||||
@@ -1051,7 +1051,7 @@ class TestIdentifyRealSamples:
|
||||
assert any("only the signing time is unproven" in caveat for caveat in report.caveats)
|
||||
|
||||
def test_no_committed_fixture_reports_a_trusted_signer(self):
|
||||
"""The reachability guard for :func:`_c2pa_credential_level`.
|
||||
"""The reachability guard for :func:`c2pa_credential_level`.
|
||||
|
||||
The SDK ships no production trust anchors, so ``signingCredential.trusted``
|
||||
appears in no default installation. Gating high confidence on it made that branch
|
||||
@@ -2324,11 +2324,11 @@ class TestRegistryTokenMatching:
|
||||
assert report.platform != "Nikon (camera, C2PA capture)"
|
||||
|
||||
def test_claim_generator_tokens_are_whole_words(self):
|
||||
from remove_ai_watermarks.identify import _claim_generator_platform
|
||||
from remove_ai_watermarks._internal.c2pa import claim_generator_platform
|
||||
|
||||
assert _claim_generator_platform("Sunoco Studio 2.1") is None
|
||||
assert _claim_generator_platform("suno-v4 export") == "Suno"
|
||||
assert _claim_generator_platform("Adobe_Firefly") == "Adobe Firefly"
|
||||
assert claim_generator_platform("Sunoco Studio 2.1") is None
|
||||
assert claim_generator_platform("suno-v4 export") == "Suno"
|
||||
assert claim_generator_platform("Adobe_Firefly") == "Adobe Firefly"
|
||||
|
||||
def test_video_generator_tokens_are_whole_words(self):
|
||||
from remove_ai_watermarks.video import _video_markers_claim_ai
|
||||
|
||||
@@ -61,6 +61,21 @@ class TestUnicodeRoundTrip:
|
||||
assert np.array_equal(out, src)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("suffix", [".jpe", ".jfif"])
|
||||
def test_jpeg_aliases_are_supported_and_written_as_jpeg(tmp_path: Path, suffix: str) -> None:
|
||||
from remove_ai_watermarks._internal.utils import get_image_format, is_supported_format
|
||||
|
||||
path = tmp_path / f"marked{suffix}"
|
||||
source = np.full((32, 48, 3), (10, 120, 240), dtype=np.uint8)
|
||||
assert is_supported_format(path)
|
||||
assert get_image_format(path) == "JPEG"
|
||||
assert image_io.imwrite(path, source)
|
||||
assert path.read_bytes().startswith(b"\xff\xd8")
|
||||
decoded = image_io.imread(path)
|
||||
assert decoded is not None
|
||||
assert decoded.shape == source.shape
|
||||
|
||||
|
||||
class TestToBgr:
|
||||
def test_grayscale_2d_promoted_to_bgr(self) -> None:
|
||||
gray = np.full((4, 5), 120, dtype=np.uint8)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""The shared DCT basis must be the orthonormal DCT-II that OpenCV computes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from remove_ai_watermarks._internal.image_math import dct_matrix
|
||||
|
||||
|
||||
@pytest.mark.parametrize("size", [4, 8, 16])
|
||||
def test_dct_matrix_matches_opencv_dct(size: int) -> None:
|
||||
matrix = dct_matrix(np, size)
|
||||
|
||||
# DCT_ROWS transforms each basis vector e_i separately, so row i is column i of the basis.
|
||||
np.testing.assert_allclose(matrix, cv2.dct(np.eye(size), flags=cv2.DCT_ROWS).T, atol=1e-12)
|
||||
np.testing.assert_allclose(matrix @ matrix.T, np.eye(size), atol=1e-12)
|
||||
@@ -33,6 +33,18 @@ from remove_ai_watermarks.metadata import (
|
||||
SAMPLES_DIR = Path(__file__).resolve().parent.parent / "data" / "fixtures" / "provenance"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("suffix", [".jpe", ".jfif"])
|
||||
def test_jpeg_alias_metadata_strip_preserves_image_scan(tmp_path: Path, suffix: str) -> None:
|
||||
source = tmp_path / f"source{suffix}"
|
||||
output = tmp_path / f"clean{suffix}"
|
||||
Image.new("RGB", (48, 32), (20, 80, 160)).save(source, format="JPEG")
|
||||
original = source.read_bytes()
|
||||
|
||||
remove_ai_metadata(source, output)
|
||||
|
||||
assert output.read_bytes() == original
|
||||
|
||||
|
||||
def _png_chunk(kind: bytes, payload: bytes, *, corrupt_crc: bool = False) -> bytes:
|
||||
"""Encode one PNG chunk, optionally corrupting its CRC."""
|
||||
import zlib
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""The public model staging boundary accepts only verified derivatives."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
|
||||
import publish_image_source_hf as publisher
|
||||
|
||||
|
||||
def test_stage_release_rejects_changed_weights(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
card = tmp_path / "card"
|
||||
card.mkdir()
|
||||
(card / "README.md").write_text("# Public card\n")
|
||||
weights = tmp_path / "source"
|
||||
weights.mkdir()
|
||||
original = b"derivative weights"
|
||||
(weights / publisher.MODEL_FILE).write_bytes(original)
|
||||
(card / "metrics.json").write_text(json.dumps({"artifact_sha256": hashlib.sha256(original).hexdigest()}))
|
||||
monkeypatch.setattr(publisher, "CARD_DIR", card)
|
||||
|
||||
stage = tmp_path / "stage"
|
||||
publisher.stage_release(stage, weights)
|
||||
assert sorted(item.name for item in stage.iterdir()) == sorted(publisher.PUBLIC_FILES)
|
||||
|
||||
(weights / publisher.MODEL_FILE).write_bytes(b"changed weights")
|
||||
with pytest.raises(ValueError, match="SHA-256 mismatch"):
|
||||
publisher.stage_release(tmp_path / "second", weights)
|
||||
@@ -12,8 +12,14 @@ sys.path.insert(0, str(SCRIPTS))
|
||||
import publish_photo_classify_hf as publish # noqa: E402
|
||||
|
||||
|
||||
def test_hub_id_is_the_photo_classify_repo() -> None:
|
||||
assert publish.HUB_REPO == "wiltodelta/raiw-photo-classify"
|
||||
def test_hub_ids_match_the_runtime_loader() -> None:
|
||||
# The workflow runs this script with --no-project, so it cannot import the package constants.
|
||||
from remove_ai_watermarks.classify import RECEIPT_GATE_FILE, RECEIPT_GATE_STABLE_FILE, WEIGHTS_REPO
|
||||
|
||||
assert publish.HUB_REPO == WEIGHTS_REPO
|
||||
assert publish.GATE_FILE == RECEIPT_GATE_FILE
|
||||
assert publish.GATE_STABLE_FILE == RECEIPT_GATE_STABLE_FILE
|
||||
assert publish.GATE_SOURCE.is_file()
|
||||
|
||||
|
||||
def test_publish_freeze_includes_both_clip_runtimes() -> None:
|
||||
|
||||
@@ -13,8 +13,12 @@ sys.path.insert(0, str(SCRIPTS))
|
||||
import publish_source_classify_hf as publish # noqa: E402
|
||||
|
||||
|
||||
def test_hub_id_is_the_source_classify_repo() -> None:
|
||||
assert publish.HUB_REPO == "wiltodelta/raiw-source-classify"
|
||||
def test_hub_ids_match_the_runtime_loader() -> None:
|
||||
# The workflow runs this script with --no-project, so it cannot import the package constants.
|
||||
from remove_ai_watermarks.source_classify import MODEL_FILE, WEIGHTS_REPO
|
||||
|
||||
assert publish.HUB_REPO == WEIGHTS_REPO
|
||||
assert publish.MODEL_FILE == MODEL_FILE
|
||||
|
||||
|
||||
def test_stage_release_copies_only_public_artifacts(tmp_path: Path) -> None:
|
||||
|
||||
@@ -151,6 +151,13 @@ def test_global_stack_is_resident_on_a_large_card_and_streams_on_a_small_one():
|
||||
assert small["offload_device"] == "disk"
|
||||
assert small["onload_device"] == "cpu"
|
||||
|
||||
# The module pragmas silence return-type checks, so pin the key set here: every
|
||||
# config is splatted into ModelConfig and a misspelled key would load silently.
|
||||
from remove_ai_watermarks._internal.two_stage_pipeline import DiffSynthVramConfig
|
||||
|
||||
for config in (large, small, QwenZImagePipeline._zimage_vram_config()):
|
||||
assert list(config) == list(DiffSynthVramConfig.__annotations__)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("cpu_offload", "expected"),
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Contracts for the portable OpenAI/Google source-pattern runtime."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from hashlib import sha256
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image, PngImagePlugin
|
||||
|
||||
from remove_ai_watermarks import source_classify_v2 as model
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _synthetic_weights(path: Path, *, gate: float) -> None:
|
||||
zero_mean = np.zeros((256, 256, 3), dtype=np.complex64)
|
||||
zero_precision = np.zeros((256, 256, 3), dtype=np.float32)
|
||||
unit = np.zeros((992, 992), dtype=np.float32)
|
||||
unit[0, 0] = 1
|
||||
fields: dict[str, np.ndarray] = {"schema": np.asarray(model.MODEL_SCHEMA)}
|
||||
for name in model._FILTER_NAMES:
|
||||
fields[f"{name}_mean"] = zero_mean
|
||||
fields[f"{name}_precision"] = zero_precision
|
||||
for route in model.ROUTES:
|
||||
fields[f"openai_{route}_unit"] = unit
|
||||
limits = dict.fromkeys(model._THRESHOLD_NAMES, 1.0)
|
||||
limits["r183_openai_gate"] = gate
|
||||
limits.update({f"r163_{group}": -0.5 for group in model.GROUPS})
|
||||
fields.update({f"threshold_{name}": np.asarray(value, dtype=np.float64) for name, value in limits.items()})
|
||||
np.savez_compressed(path, **fields)
|
||||
|
||||
|
||||
def test_r163_gate_changes_real_prediction(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
image = tmp_path / "photo.png"
|
||||
Image.new("RGB", (512, 512), (80, 110, 145)).save(image)
|
||||
blocked = tmp_path / "blocked.npz"
|
||||
allowed = tmp_path / "allowed.npz"
|
||||
_synthetic_weights(blocked, gate=0.5)
|
||||
_synthetic_weights(allowed, gate=-0.5)
|
||||
|
||||
def predict(path: Path) -> model.ImageSourceClassification:
|
||||
monkeypatch.setenv(model.WEIGHTS_ENV, str(path))
|
||||
monkeypatch.setattr(model, "MODEL_SHA256", sha256(path.read_bytes()).hexdigest())
|
||||
return model.classify_image_source(image)
|
||||
|
||||
no_gate = predict(blocked)
|
||||
with_gate = predict(allowed)
|
||||
|
||||
assert no_gate.label == "unknown"
|
||||
assert no_gate.reason == "abstained"
|
||||
assert with_gate.label == "openai"
|
||||
assert with_gate.reason == "classified"
|
||||
assert with_gate.watermark_truth == "unknown"
|
||||
|
||||
|
||||
def test_top_level_api_is_lazy_and_points_to_new_runtime() -> None:
|
||||
import remove_ai_watermarks as raiw
|
||||
|
||||
assert raiw.classify_image_source is model.classify_image_source
|
||||
assert raiw.ImageSourceClassification is model.ImageSourceClassification
|
||||
assert model.PROVIDERS == ("openai", "google")
|
||||
assert len(model.WEIGHTS_REVISION) == 40
|
||||
assert all(character in "0123456789abcdef" for character in model.WEIGHTS_REVISION)
|
||||
|
||||
|
||||
def test_metadata_does_not_change_pixel_decision(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
weights = tmp_path / model.MODEL_FILE
|
||||
_synthetic_weights(weights, gate=-0.5)
|
||||
monkeypatch.setenv(model.WEIGHTS_ENV, str(weights))
|
||||
monkeypatch.setattr(model, "MODEL_SHA256", sha256(weights.read_bytes()).hexdigest())
|
||||
pixels = Image.new("RGB", (512, 512), (80, 110, 145))
|
||||
clean = tmp_path / "clean.png"
|
||||
tagged = tmp_path / "tagged.png"
|
||||
pixels.save(clean)
|
||||
metadata = PngImagePlugin.PngInfo()
|
||||
metadata.add_text("Comment", "arbitrary metadata")
|
||||
pixels.save(tagged, pnginfo=metadata)
|
||||
|
||||
first = model.classify_image_source(clean)
|
||||
second = model.classify_image_source(tagged)
|
||||
|
||||
assert first.label == second.label == "openai"
|
||||
assert first.scores == second.scores
|
||||
|
||||
|
||||
def test_rejects_wrong_artifact_hash(tmp_path: Path) -> None:
|
||||
weights = tmp_path / model.MODEL_FILE
|
||||
_synthetic_weights(weights, gate=0.5)
|
||||
|
||||
with pytest.raises(ValueError, match="SHA-256 mismatch"):
|
||||
model._load_model(weights, "0" * 64)
|
||||
|
||||
|
||||
def test_small_image_abstains_without_watermark_claim(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(model, "_model_path", lambda: pytest.fail("small image loaded weights"))
|
||||
image = tmp_path / "small.png"
|
||||
Image.new("RGB", (128, 128), "white").save(image)
|
||||
|
||||
result = model.classify_image_source(image)
|
||||
|
||||
assert result.label == "unknown"
|
||||
assert result.reason == "feature_unavailable"
|
||||
assert result.watermark_truth == "unknown"
|
||||
+144
-22
@@ -904,12 +904,56 @@ class TestVideoMetadataApi:
|
||||
|
||||
|
||||
class TestVideoProvenanceApi:
|
||||
@pytest.mark.parametrize(
|
||||
("generator", "issuer", "expected"),
|
||||
[
|
||||
(
|
||||
"Google C2PA Core Generator Library",
|
||||
"Google LLC, OpenAI",
|
||||
"Google (Gemini / Imagen)",
|
||||
),
|
||||
(
|
||||
"Dreamina/2.0",
|
||||
"OpenAI",
|
||||
"ByteDance Dreamina",
|
||||
),
|
||||
("Higgsfield AI", "BytePlus (ByteDance)", "Higgsfield AI"),
|
||||
("Higgsfield AI", "", "Higgsfield AI"),
|
||||
("FastVid", "", "FastVid"),
|
||||
],
|
||||
)
|
||||
def test_claim_generator_precedes_mixed_certificate_issuers(self, generator: str, issuer: str, expected: str):
|
||||
from remove_ai_watermarks.video import _platform_from_video_metadata
|
||||
|
||||
assert _platform_from_video_metadata({"claim_generator": generator, "issuer": issuer}) == expected
|
||||
|
||||
def test_c2pa_platform_keeps_the_bytedance_surface_name(self):
|
||||
from remove_ai_watermarks.video import _platform_from_video_metadata
|
||||
|
||||
assert _platform_from_video_metadata({"issuer": "BytePlus (ByteDance)"}) == "BytePlus (ByteDance)"
|
||||
assert _platform_from_video_metadata({"issuer": "ByteDance (Volcano Engine)"}) == "ByteDance Volcano Engine"
|
||||
|
||||
def test_metadata_only_identification_prefers_generator_to_mixed_issuer(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
from remove_ai_watermarks.video import identify_video
|
||||
|
||||
source = _video_with_c2pa(tmp_path / "source.mp4")
|
||||
monkeypatch.setattr(
|
||||
"remove_ai_watermarks.metadata.get_ai_metadata",
|
||||
lambda _path: {
|
||||
"claim_generator": "Google C2PA Core Generator Library",
|
||||
"issuer": "Google LLC, OpenAI",
|
||||
"source_type": "trainedAlgorithmicMedia (AI-generated)",
|
||||
},
|
||||
)
|
||||
|
||||
report = identify_video(source, check_visible=False)
|
||||
|
||||
assert report.platform == "Google (Gemini / Imagen)"
|
||||
assert report.visible_mark is None
|
||||
assert report.has_ai_metadata is True
|
||||
|
||||
def test_identifies_metadata_without_pixel_scan(self, tmp_path: Path):
|
||||
from remove_ai_watermarks.video import identify_video
|
||||
|
||||
@@ -1616,13 +1660,28 @@ class TestSoraFrameLocalization:
|
||||
assert detection.confidence == 0.0
|
||||
assert detection.region is None
|
||||
|
||||
def test_localizes_independently_rendered_mascot_without_wordmark(self):
|
||||
from remove_ai_watermarks.video_visible import detect_sora_frame
|
||||
|
||||
mark, _region = _independent_sora_mark()
|
||||
mascot = cv2.resize(mark[:, :45], (55, 55), interpolation=cv2.INTER_AREA)
|
||||
frame = np.full((720, 1280, 3), 36, dtype=np.uint8)
|
||||
_stamp_gray_mark(frame, mascot, x=900, y=500, opacity=0.78)
|
||||
|
||||
detection = detect_sora_frame(frame)
|
||||
|
||||
assert detection.confidence >= 0.60
|
||||
assert detection.region is not None
|
||||
assert abs(detection.region[0] - 900) <= 10
|
||||
assert abs(detection.region[1] - 500) <= 10
|
||||
|
||||
|
||||
class TestVeoFrameLocalization:
|
||||
def test_localizes_independently_rendered_diamond_at_relocated_position(self):
|
||||
from remove_ai_watermarks.video_visible import _region_iou, detect_veo_frame
|
||||
from remove_ai_watermarks.video_visible import _region_iou, detect_sora_frame, detect_veo_frame
|
||||
|
||||
frame = np.full((720, 1280, 3), 28, dtype=np.uint8)
|
||||
size = 48
|
||||
size = 55
|
||||
x, y = 1080, 570
|
||||
mark = Image.new("L", (size, size), 0)
|
||||
points = (
|
||||
@@ -1643,6 +1702,7 @@ class TestVeoFrameLocalization:
|
||||
assert detection.region is not None
|
||||
assert detection.confidence >= 0.70
|
||||
assert _region_iou(detection.region, (x, y, size, size)) >= 0.70
|
||||
assert detect_sora_frame(frame).confidence < 0.60
|
||||
|
||||
def test_localizes_independently_rendered_legacy_text(self):
|
||||
from remove_ai_watermarks.video_visible import _region_iou, detect_veo_frame
|
||||
@@ -1714,22 +1774,16 @@ class TestByteDanceFrameLocalization:
|
||||
assert _region_iou(detection.region, (x, y, 80, 60)) >= 0.75
|
||||
|
||||
def test_localizes_independently_rendered_dola_text(self):
|
||||
from pathlib import Path
|
||||
|
||||
from remove_ai_watermarks.video_visible import _region_iou, detect_dola_frame
|
||||
|
||||
frame = np.full((720, 1280, 3), 35, dtype=np.uint8)
|
||||
mark = np.zeros((40, 150), dtype=np.uint8)
|
||||
cv2.putText(
|
||||
mark,
|
||||
"Dola AI",
|
||||
(2, 28),
|
||||
cv2.FONT_HERSHEY_SIMPLEX,
|
||||
0.9,
|
||||
255,
|
||||
2,
|
||||
cv2.LINE_AA,
|
||||
)
|
||||
ys, xs = np.where(mark > 0)
|
||||
mark = mark[ys.min() : ys.max() + 1, xs.min() : xs.max() + 1]
|
||||
# A frozen OpenCV 4 render in the other Hershey face: OpenCV 5 draws the same
|
||||
# putText call smaller and heavier (scripts/render_video_hershey_templates.py).
|
||||
fixture = Path(__file__).resolve().parents[1] / "data/fixtures/synthetic/dola_hershey_simplex.png"
|
||||
mark = cv2.imread(str(fixture), cv2.IMREAD_GRAYSCALE)
|
||||
assert mark is not None
|
||||
mark_height, mark_width = mark.shape
|
||||
x = frame.shape[1] - mark_width - 18
|
||||
y = frame.shape[0] - mark_height - 14
|
||||
@@ -2782,6 +2836,73 @@ class TestVideoVisibleFullClip:
|
||||
|
||||
|
||||
class TestVideoVisibleApi:
|
||||
@pytest.mark.parametrize(
|
||||
("sora_box", "sora_frames", "integrity", "signature", "signer", "generator", "expected_mark"),
|
||||
[
|
||||
((1125, 584, 155, 69), 15, "valid", "valid", "unknown", "Google C2PA Core Generator Library", "veo"),
|
||||
# Valid Google AI provenance vetoes Sora wherever its box sits.
|
||||
((300, 200, 155, 69), 15, "valid", "valid", "unknown", "Google C2PA Core Generator Library", "veo"),
|
||||
((300, 200, 155, 69), 8, "valid", "valid", "unknown", "Google C2PA Core Generator Library", "veo"),
|
||||
((1125, 584, 155, 69), 15, "invalid", "valid", "unknown", "Google C2PA Core Generator Library", "sora"),
|
||||
((1125, 584, 155, 69), 15, "valid", "invalid", "unknown", "Google C2PA Core Generator Library", "sora"),
|
||||
# A revoked or expired signing credential is "invalid" to identify, so it
|
||||
# cannot veto either.
|
||||
((1125, 584, 155, 69), 15, "valid", "valid", "invalid", "Google C2PA Core Generator Library", "sora"),
|
||||
# Provenance that also names Sora confirms the mark instead of vetoing it.
|
||||
((1125, 584, 155, 69), 15, "valid", "valid", "unknown", "Sora export", "sora"),
|
||||
],
|
||||
)
|
||||
def test_google_ai_provenance_vetoes_sora_unless_invalid_or_sora_confirmed(
|
||||
self,
|
||||
sora_box: tuple[int, int, int, int],
|
||||
sora_frames: int,
|
||||
integrity: str,
|
||||
signature: str,
|
||||
signer: str,
|
||||
generator: str,
|
||||
expected_mark: str,
|
||||
):
|
||||
from remove_ai_watermarks.video import _select_stable_visible_mark
|
||||
from remove_ai_watermarks.video_visible import FrameLocalization, VideoScan
|
||||
|
||||
veo_box = (1132, 572, 56, 56)
|
||||
scans = {
|
||||
"sora": VideoScan(
|
||||
width=1280,
|
||||
height=720,
|
||||
fps=24.0,
|
||||
detections=tuple(
|
||||
FrameLocalization(
|
||||
index,
|
||||
0.66 if index < sora_frames else 0.0,
|
||||
sora_box if index < sora_frames else None,
|
||||
)
|
||||
for index in range(15)
|
||||
),
|
||||
),
|
||||
"veo": VideoScan(
|
||||
width=1280,
|
||||
height=720,
|
||||
fps=24.0,
|
||||
detections=tuple(FrameLocalization(index, 0.82, veo_box) for index in range(15)),
|
||||
),
|
||||
}
|
||||
google_markers = {
|
||||
"claim_generator": generator,
|
||||
"issuer": "Google LLC",
|
||||
"source_type": "trainedAlgorithmicMedia (AI-generated)",
|
||||
"c2pa_integrity": integrity,
|
||||
"c2pa_signature": signature,
|
||||
"c2pa_signer_validity": signer,
|
||||
}
|
||||
|
||||
selected = _select_stable_visible_mark(scans, google_markers, ("sora", "veo"))
|
||||
|
||||
assert selected is not None
|
||||
assert selected[0] == expected_mark
|
||||
assert selected[2] == [veo_box if expected_mark == "veo" else sora_box] * 15
|
||||
assert selected[4] == ("veo" if expected_mark == "veo" else "box")
|
||||
|
||||
def test_auto_prefers_specific_sora_run_over_hailuo_cross_match(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
@@ -3198,15 +3319,16 @@ class TestYouTubeReencodedSamples:
|
||||
def test_google_c2pa_vetoes_a_sora_match_on_steam(self):
|
||||
# Gemini Omni 1.1 Flash via Runway (2026-09-25): a steam wisp scored 0.61-0.67
|
||||
# on the Sora template for five frames, a stable run above the strict floor,
|
||||
# and the report named OpenAI Sora over the file's valid Google C2PA.
|
||||
from remove_ai_watermarks.video import _visible_removal_plan, identify_video
|
||||
from remove_ai_watermarks.video_visible import scan_video_marks, stabilize_localizations
|
||||
# and the report named OpenAI Sora over the file's valid Google C2PA. The
|
||||
# mascot-only shape check now rejects the wisp too; the veto stays as the
|
||||
# independent second guard.
|
||||
from remove_ai_watermarks.video import identify_video
|
||||
from remove_ai_watermarks.video_visible import contradicts_video_provenance
|
||||
|
||||
clip = self._DIR.parents[1] / "captures" / "2026-09" / "runway" / "gemini-omni-1-1-flash.mp4"
|
||||
scan = scan_video_marks(clip, ("sora",), collect_timestamps=False)["sora"]
|
||||
assert any(stabilize_localizations("sora", scan.detections))
|
||||
markers = identify_video(clip, check_visible=False).metadata_markers
|
||||
assert not any(_visible_removal_plan("sora", scan, markers)[0])
|
||||
report = identify_video(clip)
|
||||
assert report.visible_mark != "sora"
|
||||
assert contradicts_video_provenance("sora", report.metadata_markers)
|
||||
|
||||
def test_real_kling_and_hailuo_labels_still_pass_the_new_gates(self):
|
||||
from remove_ai_watermarks.video_visible import (
|
||||
|
||||
@@ -76,8 +76,8 @@ class TestVideoGallery:
|
||||
|
||||
def test_selection_accepts_each_example(self) -> None:
|
||||
# The shipped temporal selection (not just the per-frame detector) must
|
||||
# accept the clip: table order resolves cross-template ties, so the example
|
||||
# must carry the discriminative variant of its mark.
|
||||
# accept the clip, including the mascot-only Sora example and the
|
||||
# generated Veo text variant.
|
||||
for key in VIDEO_VISIBLE_MARKS:
|
||||
rep = identify_video(_GALLERY / key / "example.mp4", check_visible=True)
|
||||
assert rep.visible_mark == key, f"{key}: selection returned {rep.visible_mark!r}"
|
||||
|
||||
Reference in new issue
Block a user