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:
Victor KuznetsovandClaude Opus 5.5 committed 2026-09-26 23:52:29 -07:00
commit f855c4700f
77 files changed
+2475 -1488

No files matched your search

+4
View File
@@ -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.
+4
View File
@@ -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
+6 -9
View File
@@ -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"
+2
View File
@@ -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})"
+3 -1
View File
@@ -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
+6 -3
View File
@@ -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
+2
View File
@@ -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
View File
@@ -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
+64
View File
@@ -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.
+28
View File
@@ -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
}
}
}
+1
View File
@@ -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)
+20 -4
View File
@@ -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`
+55 -8
View File
@@ -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
+7 -1
View File
@@ -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
View File
@@ -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
+11 -1
View File
@@ -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
+7 -1
View File
@@ -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
View File
@@ -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.
+5 -4
View File
@@ -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.
+6
View File
@@ -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"]
+1
View File
@@ -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
+8 -1
View File
@@ -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)
+1 -1
View File
@@ -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)
+71
View File
@@ -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())
+78
View File
@@ -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()
+2 -3
View File
@@ -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]))
+8
View File
@@ -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",
+38 -7
View File
@@ -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:
+2 -8
View File
@@ -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
+3 -4
View File
@@ -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

+3 -3
View File
@@ -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
+30 -3
View File
@@ -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,
+36 -23
View File
@@ -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.
+6 -28
View File
@@ -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
+4 -2
View File
@@ -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:
+6 -4
View File
@@ -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:
+2 -1
View File
@@ -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``,
+3 -2
View File
@@ -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:
+2 -1
View File
@@ -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.
+5 -1
View File
@@ -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
+1 -1
View File
@@ -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
+13 -6
View File
@@ -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)
+24 -54
View File
@@ -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
+1 -1
View File
@@ -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
+57 -41
View File
@@ -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(
+10 -5
View File
@@ -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
+21 -3
View File
@@ -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)
+9 -1
View File
@@ -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):
+5 -5
View File
@@ -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
+15
View File
@@ -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)
+18
View File
@@ -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)
+12
View File
@@ -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
+33
View File
@@ -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)
+8 -2
View File
@@ -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:
+6 -2
View File
@@ -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:
+7
View File
@@ -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"),
+106
View File
@@ -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
View File
@@ -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 (
+2 -2
View File
@@ -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}"
Generated
+1025 -1173
View File
File diff suppressed because it is too large. Load diff