Compare commits

..
8 Commits
Author SHA1 Message Date
Victor Kuznetsov 6f629a4ea6 Release 0.42.0 2026-09-22 09:25:21 -07:00
Victor KuznetsovandClaude Sonnet 5 e3bbe88155 Authenticate C2PA registry-sync GitHub API calls to fix CI rate-limiting
scripts/sync_c2pa_soft_bindings.py --check hits api.github.com to resolve
the latest upstream commit SHA, unauthenticated. That endpoint's 60/hour
rate limit is per source IP, which GitHub Actions runners exhaust fast
since many workflows share the runner IP pool -- this was failing the
lint job with "403: rate limit exceeded" on every push regardless of
whether the change touched anything related.

Send an Authorization: Bearer header built from GITHUB_TOKEN/GH_TOKEN
when set, scoped to api.github.com only (raw.githubusercontent.com is a
separate, much higher-limit CDN and doesn't need it). Wire the workflow's
own GITHUB_TOKEN secret into that step's env so CI actually has one to
send, raising the limit to 5000/hour.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 08:11:48 -07:00
Victor Kuznetsov be8f24c7cb Raise OpenAI Qwen strength floor 2026-09-21 23:02:49 -07:00
Victor KuznetsovandClaude Sonnet 5 9990f3e6ca Fix missing OpenArt gallery fixture and alternate-size entry
The OpenArt visible-mark engine (commit 9d29d2c) was registered without
a data/fixtures/visible/openart/example.png gallery canary and without
an _ALTERNATE_SIZE entry in test_visible_fill_quality.py, breaking
TestGallery::test_every_registered_mark_has_an_example,
TestGallery::test_engine_detects_its_own_example[openart], and
test_cv2_mask_quality_smoke[alternate-openart] on every CI platform.
Generate the fixture via render_visible_examples.py (self-detects at
conf=0.968) and add the missing size entry.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:37:21 -07:00
Victor KuznetsovandClaude Sonnet 5 83be3a8926 Add generic bare "AI生成" text-mark detector (brand-less TC260 fallback)
Every existing text-mark engine (Doubao, Jimeng, Qwen, Baidu, Kling,
Yuanbao) is tuned to one vendor's exact wordmark silhouette, so none of
them match a bare "AI生成" strip with no brand name in front of it.
Confirmed on raiw-app production: a free `visible` run left a bottom-right
"AI生成" watermark completely unremoved because no detector fired.

Research found the wording is not tied to one dominant generator: vivo's
own support documentation confirms its Gallery stamps a bare "AI生成"
label after AI消除/AI扩图/大片模式 editing, and independent reporting
documents the same watermark on Xiaomi Gallery's AI-edit output. Since
the label is a GB 45438-2025/TC260 compliance stamp rather than one
vendor's brand mark, this adds one generic fallback (`generic_ai_label`)
instead of another per-OEM tuned config.

The detection silhouette is font-rendered (no logo element to capture)
via the shared scripts/render_vendor_silhouettes.py pipeline already
used for Qwen/Baidu/Kling/Yuanbao's own text-only marks. Calibration is
synthetic-only (no real captured generator screenshot was available);
the module docstring and DETECT_NCC_THRESHOLD comment carry that caveat
explicitly and this should be recalibrated once real captures exist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:01:10 -07:00
Victor KuznetsovandClaude Sonnet 5 9d29d2c777 Add OpenArt visible watermark detection and removal
OpenArt.ai stamps free-plan generations with a semi-transparent white
"OpenArt" wordmark (bowtie/infinity icon + brand name) placed near the
CENTER of the frame rather than in a corner. Confirmed from a customer-
reported raiw-app production case where the mark went completely
undetected and unremoved, with no C2PA/EXIF/TC260 signal for identify
to fall back on.

- Add a new "cc" (frame-center) corner mode to the shared
  TextMarkConfig/TextMarkEngine base, alongside the existing
  corner/edge anchors.
- Add openart_engine.py, following the existing text-mark engine
  pattern. No real capture exists (OpenArt requires an account, out of
  scope to create), so the bundled alpha asset is a procedural
  reconstruction of the public logo and the NCC threshold is an
  explicitly-labeled provisional guess, not a measured calibration.
- Register the mark in watermark_registry.py, which auto-derives
  identify's platform attribution.
- Add tests/test_openart_engine.py (synthetic composites only) plus
  registry/identify test coverage, and document the mark and its
  calibration caveats in README.md, docs/supported-signals.md,
  docs/module-internals.md, and the skill's commands.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 21:51:46 -07:00
Victor KuznetsovandClaude Sonnet 5 7a6491fdc1 Document Gemini sparkle decorative-glyph false positive, add observability
A decorative sparkle/glint glyph in stylized typography (e.g. a book-cover
title) can clear the visible Gemini sparkle's false-positive gate at
confidence just under _SPARKLE_FP_CONF, since it shares the same bright,
near-white, sharp four-point signature the gate uses to recognize a genuine
sparkle. Confirmed against a raiw-app production incident (2026-08-27) where
this caused the removal step to permanently smudge unrelated cover art.

Investigated tightening _SPARKLE_FP_CONF and the margin/gradient/saturation
checks (plus a prototyped local edge-density signal); each was disproved by
a constructed counter-example showing a genuine weak Gemini sparkle over a
noisy/textured photo background lands in the same confidence band with the
same signature, so no safe targeted fix exists without a real labeled
corpus. No detection/removal behavior changed.

Adds DEBUG logging at the exact FP-gate bypass path for future calibration,
a regression test (TestDecorativeGlyphFalsePositive) pinning the confirmed
false positive and the blocking counter-example, and a docs record of the
finding in the Gemini sparkle section of module-internals.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 21:41:59 -07:00
Victor Kuznetsov 37431c9aeb Pin Web oracle proxy sessions per batch 2026-09-16 16:17:36 -07:00
42 changed files with 1559 additions and 62 deletions

No files matched your search

+4
View File
@@ -47,6 +47,10 @@ real browser. Proxy credentials are read only through a slot's named environment
and never enter a manifest or log. Dotenv interpolation may derive routed proxy URLs from
one secret key without mutating the process environment. A selected proxy context may
accept that proxy's TLS interception certificate; direct contexts keep normal TLS checks.
ThorData rotating endpoints are pinned to one manifest-derived sticky session for the
life of a Web batch so one browser context does not change exit identity between page
requests. This is route continuity, not account or IP failover; a refusal still ends on
the selected slot and never selects another route automatically.
Upload readiness is surface-specific: OpenAI reaches it at page load but keeps background
requests alive, while Microsoft and Meta require network idle. Preserve exact provider
errors as indeterminate evidence rather than retrying or interpreting them as clean.
+8
View File
@@ -88,6 +88,14 @@ OpenAI, Microsoft, and Meta Web slots may each name one of them through
verify egress before relying on it, and never automatically change route after
a refusal. Bound a batch before starting.
ThorData user-and-password endpoints without `sessid` rotate the exit on each
request, which breaks a browser page whose resources must share one network
identity. The runner therefore derives one sticky `sessid` from the immutable
manifest hash and keeps it for that batch. A new prepared batch gets a new
session; rerunning or resuming the same batch keeps its session while the proxy
retains it. An explicit `sessid` in the configured route is preserved. This is
session continuity, not permission to retry a refused batch on another route.
### OpenAI API
Use `check-openai` with one explicit upload acknowledgement. Keep named API keys
+1
View File
@@ -34,6 +34,7 @@
# ThorData residential proxies (optional; only for explicitly selected Web oracle routes)
# Generate these URLs in the ThorData endpoint generator; do not commit credentials.
# The Web runner pins a rotating user-and-password route to one sticky session per manifest.
# THORDATA_ROUTE_US=http://td-customer-USERNAME-country-US:PASSWORD@HOST.pr.thordata.net:9999
# THORDATA_ROUTE_DE=http://td-customer-USERNAME-country-DE:PASSWORD@HOST.pr.thordata.net:9999
# THORDATA_ROUTE_GB=http://td-customer-USERNAME-country-GB:PASSWORD@HOST.pr.thordata.net:9999
+2
View File
@@ -21,6 +21,8 @@ jobs:
- name: Sync dev environment
run: uv sync --frozen --extra dev
- name: Check C2PA soft-binding registry snapshot
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: uv run python scripts/sync_c2pa_soft_bindings.py --check
- name: Ruff lint
run: uv run ruff check
+7 -1
View File
@@ -368,7 +368,13 @@ Visible mark support includes:
Yuanbao, Baidu, LiblibAI's bottom-center wordmark and compact top-left pill,
and RunningHub labels;
- one calibrated Microsoft top-right white AI-badge variant;
- one calibrated Samsung Galaxy AI label variant.
- one calibrated Samsung Galaxy AI label variant;
- a generic brand-less bottom-right `AI生成` fallback for OS/gallery-level
AI-edit stamps that carry no vendor wordmark of their own (vivo and Xiaomi
Gallery confirmed); synthetic-only calibration, weaker evidence than the
vendor-tuned detectors above;
- the OpenArt wordmark, placed at the frame center rather than a corner;
uncalibrated (one confirmed real case, no captured corpus).
Metadata and provenance inspection covers C2PA, EXIF, XMP, IPTC, common
generator parameters in image and video containers, China TC260 AIGC labels,
+1 -1
View File
@@ -64,7 +64,7 @@ the calibration rows below is the race-free variant.
## Strength floor calibration (qwen-zimage, seed 0)
The library resolves strength per vendor with measured floors (OpenAI
0.07675 / Google 0.27 / Microsoft InvisMark 0.15 in
0.15625 / Google 0.35 / Microsoft InvisMark 0.15 in
`_internal/watermark_profiles.py`). Meta Content Seal had no floor before this
calibration; these rows measure one by the same methodology: independent
generations, each one's first-clean boundary, floor = worst boundary plus the
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

+17 -1
View File
@@ -506,7 +506,8 @@ This invalidates both the 0.09 explicit Chroma floor and the OpenAI auto route.
For explicit Chroma, the face-bearing cohort rule gives
`0.1375 + (0.1375 - 0.075) = 0.20`; that candidate was subsequently verified
clean three times on both holdout carriers. `auto` uses `qwen-zimage` for OpenAI
at 0.07675; Chroma remains the Microsoft route.
at its separately calibrated floor; Chroma remains the Microsoft route. The
then-current Qwen floor was 0.07675, superseded by the source-fresh expansion below.
## OpenAI zero-face holdout: the content split does not survive real carriers (2026-09-08)
@@ -548,6 +549,21 @@ wider. A narrow fixture spread produces a small worst-clean-plus-one-spread marg
and a false impression of homogeneity, and it was that impression - not any
measurement - that made a face split look justified here twice.
## Qwen OpenAI source-fresh expansion (2026-09-21)
The same under-sampling defect existed in Qwen's OpenAI floor: two sources first
cleared at 0.06225 and 0.0695, so their 0.00725 spread produced the shipped 0.07675
margin. A later production miss made that n=2 estimate untenable.
Six fresh `gpt-image-2` API generations - three YuNet-positive and three
YuNet-zero-face - all retained SynthID in decoded-RGB-identical metadata-stripped
controls. Their complete Qwen seed-0 ladders expanded the n=8 first-clean range to
0.03125-0.09375, so the standard rule gives
`0.09375 + (0.09375 - 0.03125) = 0.15625`. The face and no-face groups each span
0.05 internally while their worst boundaries differ by only 0.0125, so OpenAI
keeps one flat Qwen floor. The per-source table and exact protocol are recorded in
[`Measured provider boundaries for qwen-zimage`](module-internals.md#measured-provider-boundaries-for-qwen-zimage).
## Content-balanced engine selection check (2026-08-31)
The earlier tables were intentionally carrier-focused and therefore small. A
+6 -4
View File
@@ -268,9 +268,11 @@ The profiles resolve an unset strength differently, because different things
were measured for each.
`qwen-zimage` reads unknown content from the resolution-adaptive denoise curve.
Measured provider cohorts instead take flat operating points: OpenAI `0.07675`,
Measured provider cohorts instead take flat operating points: OpenAI `0.15625`,
Google `0.35`, and Microsoft InvisMark `0.15`. OpenAI and Microsoft add one full
observed cross-source boundary spread to the worst clean source; Microsoft's three
observed cross-source boundary spread to the worst clean source. OpenAI's eight-source
cohort spans first-clean boundaries from `0.03125` to `0.09375`, giving `0.15625`;
the face and no-face halves each span `0.05`, so there is no face split. Microsoft's three
first-clean boundaries were `0.04125`, `0.055`, and `0.095`, giving `0.14875` before
rounding up. Google's source-fresh candidate cleared three independent CJK carrier
types after the original content matrix found CJK and Cyrillic to be the limiting
@@ -293,8 +295,8 @@ established for that stage.
- OpenAI: `0.20`. Two withheld carriers expanded the Chroma first-clean boundary
to `0.10625` and `0.1375`; adding the face-bearing cohort spread to the worst
boundary gives `0.20`, verified clean three times on both, while qwen-zimage
cleared both at its existing `0.07675` operating point;
boundary gives `0.20`, verified clean three times on both. Qwen cleared those
two at its then-current `0.07675`; its later n=8 calibration set `0.15625`;
- Microsoft InvisMark: `0.125` (below qwen's `0.15`);
- Google: `0.50`. A source-fresh CJK carrier remained detected at `0.40` and
cleared at `0.50`; that candidate then cleared independent busy-scene,
+160 -5
View File
@@ -1410,9 +1410,31 @@ shared fill backend.
`detect_sparkle_confidence` uses a process-wide shared engine because its loaded
assets and template ladder are immutable.
A decorative sparkle/glint/star glyph in stylized typography -- a bright,
near-white four-point accent, the same silhouette family the engine's own
template captures -- is a known, unresolved false-positive class. Incident
2026-08-27 (raiw-app, Spaces uid 7e01ef8cb99949bba14ab4607a66ae06): a book-cover
title's decorative diamond accent inside a neon-glow letterform scored 0.64, just
under `_SPARKLE_FP_CONF`, and the removal step painted a permanent smudge over
the artwork. Investigation (margin, gradient, saturation, and a prototyped local
surrounding-edge-density check) found no signal that separates this class from a
genuine weak Gemini sparkle over a noisy/textured photo background -- a
synthetic counter-example lands in the same confidence band with the same
clean-margin, clean-gradient, near-white-core signature that the
`_SPARKLE_KEEP_CONF` rescue exists to protect (see
`test_bright_white_blurred_sparkle_rescued`). Tightening any of those signals to
exclude the glyph excludes the legitimate case too. Closing this gap needs a
real labeled corpus of decorative-glyph occurrences, the way
`watermark_registry.GEMINI_SPARKLE_TRUST_CONF` was calibrated against 954 Google-
metadata uploads, not another synthetic guess. `_apply_false_positive_gate` logs
`DEBUG` on the exact bypass path this incident hit, to build that corpus from
production occurrences.
Regression coverage:
- [`test_gemini_engine.py`](../tests/test_gemini_engine.py)
- [`test_gemini_engine.py`](../tests/test_gemini_engine.py), including
`TestDecorativeGlyphFalsePositive` pinning the confirmed false positive and its
blocking counter-example.
### Text mark engines
@@ -1433,6 +1455,7 @@ be represented by the shared base:
- [`baidu_engine.py`](../src/remove_ai_watermarks/baidu_engine.py)
- [`liblib_engine.py`](../src/remove_ai_watermarks/liblib_engine.py)
- [`microsoft_engine.py`](../src/remove_ai_watermarks/microsoft_engine.py)
- [`generic_ai_label_engine.py`](../src/remove_ai_watermarks/generic_ai_label_engine.py)
Qwen is the one image registry row that currently covers two layout families.
The shared text engine handles `千问AI生成`; `QwenEngine` also scans a square
@@ -1581,6 +1604,115 @@ two-line mark can be light on dark scenes or dark on light scenes. Its detector
and footprint both use the same best-match box. The separate one-line overlay
variant is not covered.
#### Generic bare "AI生成" label
Registered 2026-09-21 for a confirmed production miss: a bottom-right "AI生成"
text watermark with no brand wordmark in front of it was left completely
unremoved because none of the brand-tuned detectors above match a bare,
brand-less corner stamp -- `identify` found only the TC260 metadata label, with
nothing for `suggested_mode=visible` to localize. No customer image bytes exist
for that case in any repository; this engine is built from research plus
synthetic fixtures.
Research found the bare wording is not tied to one dominant generator. vivo's
own support documentation confirms its Gallery app stamps a bare "AI生成"
watermark after AI消除/AI扩图/大片模式 editing (togglable under Album Settings),
and independent reporting documents the same bare watermark on Xiaomi Gallery's
AI-edit output. Community reporting suggests Samsung's China-locale Galaxy AI
tools render "AI生成" too, distinct from the Italian string the `samsung` engine
targets. No independent confirmation of OPPO/Huawei's exact wording was found.
Because the label is a compliance stamp mandated by GB 45438-2025 rather than a
brand mark, several OEM gallery apps plausibly share the same wording with only
styling differences -- which is why this is one generic fallback rather than
another per-OEM tuned config.
Unlike a captured brand mark, there is no vendor screenshot to solve an alpha
map from: the mark has no logo element, only literal text, so it shares
`scripts/render_vendor_silhouettes.py` -- the same capture-less font-rendering
pipeline that already produces Qwen/Baidu/Kling/Yuanbao's own text-only
silhouettes -- which renders "AI生成" directly (white on black, already the
alpha map) and crops to the glyph body, following `visible_alpha_solve.py`'s
asset contract (a grayscale PNG under `assets/`) so it plugs into the same
`TextMarkConfig`/`TextMarkEngine` machinery as every captured mark.
Every brand-specific rival mark in this family literally contains the
substring "AI生成" in its own glyphs (Doubao, Qwen, Baidu, Kling, Yuanbao --
Jimeng's "★ 即梦AI" does not and is excluded), so this generic template is at
real risk of firing a second time on an already-attributed mark. At this
asset's glyph shape the usual 0.10 `rival_margin` already separates it from
Qwen's own binarized glyph blob once the absolute NCC gate clears 0.45, so
`generic_ai_label_engine.py` keeps the shared default rather than widening it
per mark.
CALIBRATION IS SYNTHETIC ONLY. `scripts/calibrate_generic_ai_label.py` sweeps
the gate and rival margin over 36 positives (the rendered glyph composited onto
generated backgrounds at 4 sizes, 3 scale rungs, 3 opacities), 40 generated
clean/textured negatives, and 8 rival marks' own committed gallery examples. At
gate 0.45 or 0.50 / the default 0.10 margin the sweep reads the same 67% recall,
100% precision, zero rival cross-fires; a lower 0.35-0.40 gate reaches the same
67% recall but admits one Qwen cross-fire. 0.50 is the shipped choice: it costs
nothing on this corpus and clears a faint inpainting residual the library's own
removal leaves behind, which otherwise re-fires detection at 0.45. No real
"AI生成"-generator screenshot was available to validate either number against
actual vendor output -- treat this
detector as weaker evidence than the brand-tuned engines above until it is
recalibrated on real captures.
#### OpenArt wordmark
Registered 2026-09-21 for a confirmed production miss reported through raiw-app:
a free `visible`-mode run (2026-09-11, 1528x2712 portrait, Spaces uid
`aefb1e07306f4a7280615a8daea01a39`) left a large, prominent OpenArt wordmark
(a bowtie/infinity icon followed by "OpenArt" in bold sans-serif, semi-
transparent white) completely unremoved -- pixel-identical before and after.
`identify` found nothing at all for that case: no C2PA, EXIF AI tag, or China
AIGC/TC260 label, so the visible mark is the only known attribution route for
a bare OpenArt export. The user rated the result 0 ("Didn't work").
**This is the only registered mark placed at the FRAME CENTER rather than a
corner.** Every other engine in this family anchors to one of the four corners
or a bottom/top edge midpoint; `_text_mark_engine.py` gained a new `"cc"`
corner literal (both axes centered) specifically for this row, following the
same per-vendor-measured-geometry precedent as the existing `"bc"` (LiblibAI)
and `"tr"`/`"long"`-basis (Microsoft) additions. A frame-center locate box sits
over far more varied image content than a corner box, which is why its NCC
gate (0.62) is set well above Doubao/Jimeng's measured 0.45-0.50 rather than
matching them.
OpenArt (openart.ai) requires an OAuth-authenticated account for image
generation -- there is no anonymous or API-key access (confirmed via its own
help center and MCP/CLI docs, 2026-09-21) -- so this repository's tooling
cannot generate a second real capture to survey placement variance by aspect
ratio, watermark configurability, or whether a paid tier still embeds C2PA;
only the one customer-reported case above is confirmed. OpenArt's own pricing
page lists "Watermark-free" as a paid-plan-only feature, consistent with every
free-plan output carrying this mark, and its help center separately confirms
free-plan VIDEO exports carry a removable watermark toggle at download --
independent evidence that a visible free-tier watermark is real product
behavior, not one-off.
Unlike every captured brand mark in this file, there is no committed real
capture to solve an alpha map from (see above -- no account access) and no
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.
**CALIBRATION IS PROVISIONAL, NOT MEASURED.** There is no recall/precision
sweep like Doubao's or the generic-label engine's: no positive corpus exists
at all beyond the one production case, real or synthetic-composited. The 0.62
NCC threshold is a conservative guess, not a measured operating point; a quick
false-fire check against 15 random Gaussian-blurred noise images at plausible
aspect ratios and sizes found zero false fires, which is reassurance, not
calibration. Do not cite this mark in any auto-scan precision claim, and do
not lower its threshold, without a real captured corpus under
`data/calibration/openart/`.
`openart_engine.py`'s module docstring carries the same caveats in code. Its
tests (`tests/test_openart_engine.py`) mirror `test_doubao_engine.py`'s
structure entirely on synthetic composites of the bundled asset -- there is no
`TestRealSample` analogue.
#### Bare-upload pill arm: measured impossible with the current detector (2026-08-28)
`_keep_pill` never removes a metadata-bare pill, and the question "can a bare arm
@@ -2000,9 +2132,10 @@ first-clean is still 0.10; botanical seeds 1 and 2 stay DETECTED at
that rung, which is the margin the 0.17 floor already holds. A later OpenAI
holdout invalidated the flat 0.09 conclusion: two carriers first cleared under
Chroma at 0.10625 and 0.1375, while qwen-zimage cleared both at its existing
0.07675 operating point. Explicit Chroma now uses the spread-derived 0.20,
verified clean three times on both holdout carriers, and `auto` routes
OpenAI to qwen-zimage.
0.07675 operating point. A later source-fresh n=8 Qwen calibration raised its
OpenAI floor to 0.15625; that does not change the engine decision. Explicit
Chroma uses the spread-derived 0.20, verified clean three times on both holdout
carriers, and `auto` routes OpenAI to qwen-zimage.
A separate content-balanced check on 2026-08-31 tested the exact production
OpenAI and Meta floors over 19 prompt-matched image strata per provider. Each
@@ -2300,13 +2433,35 @@ A source-fresh GPT Image 2.5 check on 2026-09-15 exercised the complete shipped
profile rather than only a strength sweep. One 1024 x 1024 API generation from
Flare and one from Sunburst both validated as current OpenAI C2PA, routed to the
flat OpenAI strength, and remained OpenAI SynthID-positive after pixel-identical
metadata removal. The complete `qwen-zimage` profile at 0.07675, seed 0, cleared
metadata removal. The complete `qwen-zimage` profile at the then-current
0.07675, seed 0, cleared
both in the official OpenAI Content Provenance API. The Flare output measured
37.48 dB PSNR / 0.9329 SSIM and the Sunburst output 37.01 dB / 0.9179 against
their sources. Those fidelity numbers describe two neutral studio-photo
carriers, not a content-wide quality estimate, and the source-positive/output-negative
oracle pairs do not replace the wider OpenAI calibration that set the floor.
The source-fresh 2026-09-21 expansion supplied that wider calibration. Six
independent `gpt-image-2` API generations - three YuNet-positive and three
YuNet-zero-face - each retained SynthID after metadata stripping with decoded RGB
unchanged. Per-file Qwen ladders, seed 0, were bracketed to 0.00625 against the
official API:
| content | detected through | first clean |
|---|---:|---:|
| 1 face, studio portrait | 0.0375 | 0.04375 |
| 7 faces, group portrait | 0.0875 | 0.09375 |
| 3 faces plus text | 0.05625 | 0.0625 |
| zero-face architecture | 0.0375 | 0.04375 |
| zero-face typography | 0.025 | 0.03125 |
| zero-face watercolor | 0.075 | 0.08125 |
Together with the earlier first-cleans at 0.06225 and 0.0695, the cohort is n=8.
The observed extrema are 0.03125 and 0.09375, so the established rule gives
`0.09375 + (0.09375 - 0.03125) = 0.15625`. Both face strata span 0.05 internally,
while their worst boundaries differ by 0.0125; face presence therefore does not
support separate floors.
| oracle | fixture size | detected at | clean from |
|---|---|---|---|
| openai.com/verify | 1.57 MP | 0.06 | 0.08 |
+21
View File
@@ -386,6 +386,16 @@ navigation before each upload, does not retry, and stores the settled page text.
Each completed verdict is persisted immediately with its own observation
timestamp. A later navigation or upload failure preserves those completed
rows and leaves unobserved rows unresolved.
ThorData [user-and-password endpoints without a `sessid` rotate the exit on each
request](https://doc.thordata.com/doc/proxies/residential-proxies/session-control).
That is unsuitable inside one browser context: page resources can
arrive through different exits and trigger the provider's bot checks. For a
ThorData hostname, the runner derives a non-secret sticky session id from the
immutable manifest hash and appends a 90-minute session window when the
configured username has no explicit session. The same batch therefore keeps
one route while the provider retains it, a new prepared batch gets a distinct
route, and an operator-supplied `sessid` remains unchanged. This pins a selected
route; it does not retry or fail over after a refusal.
For a selected proxy route it enables certificate handling in that isolated
Chromium launch and context; direct launches keep Playwright's normal TLS
verification. A 2026-09-07 preflight sent one read-only request through each
@@ -423,6 +433,17 @@ for the same processed source hash. Keep each route in its own immutable batch;
do not splice an isolated-runner failure and a first-party browser or API verdict into
one response.
On 2026-09-16, a rotating ThorData browser batch demonstrated the failure mode:
one upload reached OpenAI's generic error, four encountered Cloudflare, and ten
became unreachable after the browser context closed. Five fresh connections to
the same rotating endpoint had five distinct exits, while five connections with
one sticky session had one exit. After session pinning, one direct route and
five independently prepared country routes all reached the upload handler with
the same prepared test image, but every route returned OpenAI's exact generic
error. Record those responses as `indeterminate`. The result distinguishes the
fixed session-continuity defect from the remaining anonymous-Web availability
problem; further route changes are not a substitute for an API verdict.
## OpenAI API
The one automated path calls the hardened metadata-independent adapter already
+2
View File
@@ -23,6 +23,8 @@ The `visible` command registers these mark keys:
| `liblib_pill` | Compact `AI生成` pill | Top left | Auto removal requires LiblibAI metadata or the bottom-center wordmark, plus a flat background under the pill. |
| `microsoft` | One Microsoft white AI-badge variant | Top right | Strict uses the visual gate; auto can use Microsoft provenance for the measured [relaxed gate](module-internals.md#visible-mark-removal). Other documented icon, text, and position variants are not covered. |
| `jimeng_pill` | `AI生成` pill | Top left | Weak detector with additional product and background gates. |
| `generic_ai_label` | Bare `AI生成` text, no vendor wordmark | Bottom right | Brand-less TC260 compliance-stamp fallback (vivo and Xiaomi Gallery AI-edit output confirmed; see [module-internals.md](module-internals.md#generic-bare-ai生成-label)). Font-rendered synthetic asset; **calibrated on a synthetic corpus only**, not a real captured generator screenshot -- treat as weaker evidence than the vendor-tuned detectors above. |
| `openart` | `OpenArt` wordmark (bowtie/infinity icon + brand name) | Frame center, not a corner | Confirmed from one customer-reported production case; no known metadata signal, so the visible mark is the only attribution route. **UNCALIBRATED**: procedurally reconstructed asset, no captured corpus -- see [module-internals.md](module-internals.md#openart-wordmark). |
`--mark auto` evaluates all registered marks and removes every selected match.
Known marks are localized to a mask, then the selected fill backend reconstructs
+5 -2
View File
@@ -783,11 +783,14 @@ old OpenAI C2PA remains an origin signal without becoming a SynthID claim.
OpenAI provenance plus SynthID, `has_invisible_target` returned true, and the
automatic strength cohort resolved to OpenAI for both files. After C2PA was
removed without changing decoded pixels, the official OpenAI Content Provenance
API still returned SynthID `detected` for both sources. The complete shipped
API still returned SynthID `detected` for both sources. The complete then-shipped
`qwen-zimage` profile at strength 0.07675 and seed 0 then produced two outputs
for which the same metadata-independent API returned SynthID `not_detected`.
This is one controlled source per model and proves the current routing and
operating point on those two sources; it is not a provider-wide removal rate.
then-current operating point on those two sources; it is not a provider-wide
removal rate. A six-source `gpt-image-2` expansion on 2026-09-21 subsequently
expanded the Qwen first-clean range to 0.03125-0.09375 and raised the flat OpenAI
floor to 0.15625 by the established full-spread rule.
### 4.3 Other vendors
+1 -1
View File
@@ -4,7 +4,7 @@
# on it, including the ComfyUI node package. The console script below carries
# the same weight, since users have it on PATH.
name = "remove-ai-watermarks"
version = "0.41.1"
version = "0.42.0"
description = "AI watermark remover for visible, invisible, and provenance marks in images and video"
readme = "README.md"
requires-python = ">=3.11,<3.15"
+93
View File
@@ -0,0 +1,93 @@
"""Build the PROVISIONAL synthetic OpenArt alpha asset.
Unlike ``visible_alpha_solve.py``, this does NOT recover an alpha map from a
controlled capture: OpenArt (openart.ai) requires an account to generate
images (no anonymous or API-key access, confirmed 2026-09-21 via its own
help center and MCP docs), and creating an account is outside what this
project's tooling is allowed to do on its own. No committed capture exists
under ``data/calibration/openart/``.
This script instead PROCEDURALLY DRAWS a reconstruction of OpenArt's own
brand mark (the bowtie/infinity icon followed by the "OpenArt" wordmark, as
shown on openart.ai's own marketing pages) as a semi-transparent white
overlay, matching the general rendering style (near-white, low-saturation,
anti-aliased) that every other bundled text-mark asset in this module was
solved from. It is a reconstruction from the public logo, not a recovered
watermark alpha map, and ``openart_engine.py`` documents that provenance
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/``.
Usage::
uv run python scripts/build_openart_alpha.py
"""
# cv2/numpy boundary: third-party libs ship no usable element types; relax the
# unknown-type rules for this file only (mirrors visible_alpha_solve.py).
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownParameterType=false, reportMissingTypeArgument=false, reportMissingTypeStubs=false, reportMissingImports=false, reportArgumentType=false, reportAssignmentType=false, reportReturnType=false, reportCallIssue=false, reportIndexIssue=false, reportOperatorIssue=false
from __future__ import annotations
import sys
from pathlib import Path
import cv2
import numpy as np
sys.path.insert(0, str(Path(__file__).parent.parent))
_ROOT = Path(__file__).resolve().parents[1]
_ASSET = _ROOT / "src" / "remove_ai_watermarks" / "assets" / "openart_alpha.png"
# Canvas sized to comfortably fit "OpenArt" set bold at this height plus the
# icon and inter-glyph gap, at a resolution close to the other bundled
# assets (doubao_alpha.png is 335x83).
_W, _H = 360, 90
_ALPHA_PEAK = 173 # matches doubao_alpha.png's measured peak (~68% opacity)
def _draw_infinity_icon(canvas: np.ndarray, cx: int, cy: int, size: int) -> None:
"""Draw the bowtie/infinity glyph left of the wordmark (two mirrored lobes)."""
half = size // 2
# Two overlapping filled triangles meeting at the center, echoing the
# angular "infinity" mark on openart.ai's own site header.
left_lobe = np.array(
[[cx - size, cy - half], [cx - size, cy + half], [cx, cy]],
dtype=np.int32,
)
right_lobe = np.array(
[[cx + size, cy - half], [cx + size, cy + half], [cx, cy]],
dtype=np.int32,
)
cv2.fillConvexPoly(canvas, left_lobe, (_ALPHA_PEAK,), lineType=cv2.LINE_AA)
cv2.fillConvexPoly(canvas, right_lobe, (_ALPHA_PEAK,), lineType=cv2.LINE_AA)
def build() -> np.ndarray:
canvas = np.zeros((_H, _W), np.uint8)
icon_cx, icon_cy, icon_size = 34, _H // 2, 22
_draw_infinity_icon(canvas, icon_cx, icon_cy, icon_size)
text_x = icon_cx + icon_size + 18
cv2.putText(
canvas,
"OpenArt",
(text_x, _H // 2 + 16),
cv2.FONT_HERSHEY_DUPLEX,
1.3,
(_ALPHA_PEAK,),
thickness=2,
lineType=cv2.LINE_AA,
)
return canvas
def main() -> None:
canvas = build()
_ASSET.parent.mkdir(parents=True, exist_ok=True)
cv2.imwrite(str(_ASSET), canvas)
print(f"Wrote {_ASSET} ({canvas.shape[1]}x{canvas.shape[0]}, peak={int(canvas.max())})")
if __name__ == "__main__":
main()
+138
View File
@@ -0,0 +1,138 @@
"""Synthetic recall/precision sweep for generic_ai_label_engine's detection gate.
SYNTHETIC-ONLY CALIBRATION. There is no captured real "AI生成"-generator screenshot
in this repository to calibrate against (see the confirmed bug report this engine was
built for: the customer's original image bytes do not exist in any repo). This script
instead:
* builds POSITIVES by compositing the engine's own rendered glyph asset
(``assets/generic_ai_label_alpha.png``) onto ``render_visible_examples.py``'s
generated base photos, at several sizes / scale rungs / opacities;
* builds CLEAN negatives from the same generated base photos (some with unrelated
Latin/digit text stamped in the corner, the classical false-positive class for
this detector family);
* builds RIVAL negatives from the six other bottom-right/br CJK text marks' own
committed gallery examples (``data/fixtures/visible/<key>/example.png``) -- every
one of Doubao/Qwen/Baidu/Kling/Yuanbao's marks contains the literal substring
"AI生成" (Jimeng's "★ 即梦AI" does not and is excluded), so a generic "AI生成"
template is at real risk of firing a SECOND time on an already brand-attributed
mark unless the rival margin separates them.
Prints a gate x rival-margin sweep. The value currently shipped in
``generic_ai_label_engine.py`` (``DETECT_NCC_THRESHOLD = 0.50``, at the shared default
0.10 rival margin) was chosen from this sweep's 100%-precision / best-recall corner,
with headroom above 0.45 left for the library's own removal residual (see that
constant's comment); re-run and re-choose after any change to the rendered asset or
the shared tophat gating constants.
Usage::
uv run python scripts/calibrate_generic_ai_label.py
"""
from __future__ import annotations
import logging
import sys
from dataclasses import replace
from pathlib import Path
import cv2
import numpy as np
_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(_ROOT / "src"))
sys.path.insert(0, str(_ROOT / "scripts"))
import render_visible_examples as rve # noqa: E402
from remove_ai_watermarks._text_mark_engine import TextMarkEngine # noqa: E402
from remove_ai_watermarks.generic_ai_label_engine import _CONFIG # noqa: E402
log = logging.getLogger(__name__)
_GALLERY = _ROOT / "data" / "fixtures" / "visible"
_RIVAL_KEYS = ["doubao", "jimeng", "qwen", "baidu", "kling", "yuanbao", "samsung", "runninghub"]
_SIZES = [(1536, 1152), (2048, 2048), (1080, 1920), (2048, 1536)]
_SCALE_RUNGS = (0.85, 1.0, 1.15)
_OPACITIES = (0.65, 0.8, 1.0)
_N_CLEAN = 40
def _build_positives() -> list[np.ndarray]:
"""The rendered glyph composited at varied size/scale/opacity."""
positives: list[np.ndarray] = []
for i, (w, h) in enumerate(_SIZES):
base = rve.base_photo(w, h, seed=100 + i)
for scale_mult in _SCALE_RUNGS:
for alpha_mult in _OPACITIES:
out = rve._stamp_text_mark("generic_ai_label", base, size_mult=scale_mult, alpha_mult=alpha_mult)
if out is not None:
positives.append(out[0])
return positives
def _build_clean_negatives(rng: np.random.Generator) -> list[np.ndarray]:
"""Clean generated photos, a third with unrelated corner text (Latin/digits)."""
negatives: list[np.ndarray] = []
for i in range(_N_CLEAN):
w, h = int(rng.uniform(900, 2200)), int(rng.uniform(900, 2200))
seed = 500 + i
img = rve.base_photo(w, h, seed=seed)
if i % 3 == 0:
r = np.random.default_rng(seed)
txt = f"{r.integers(1000, 9999)} PM"
x, y = int(w * 0.75), int(h * 0.90)
cv2.putText(img, txt, (x, y), cv2.FONT_HERSHEY_SIMPLEX, w / 1400, (235, 235, 235), 2, cv2.LINE_AA)
negatives.append(img)
return negatives
def _load_rival_examples() -> dict[str, np.ndarray]:
rivals: dict[str, np.ndarray] = {}
for key in _RIVAL_KEYS:
img = cv2.imread(str(_GALLERY / key / "example.png"))
if img is not None:
rivals[key] = img
return rivals
def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(message)s")
rng = np.random.default_rng(42)
positives = _build_positives()
clean_negatives = _build_clean_negatives(rng)
rival_examples = _load_rival_examples()
log.info(
"corpus: %d synthetic positives, %d clean negatives, %d rival-mark examples",
len(positives),
len(clean_negatives),
len(rival_examples),
)
log.info("%6s %8s %10s %10s %4s %9s %9s", "gate", "margin", "recall", "precision", "TP", "clean_fp", "rival_fp")
for gate in (0.35, 0.40, 0.45, 0.50, 0.55, 0.60, 0.65):
for margin in (0.10, 0.25):
cfg = replace(_CONFIG, detect_ncc_threshold=gate, rival_margin=margin)
eng = TextMarkEngine(cfg)
tp = sum(1 for im in positives if eng.detect(im).detected)
clean_fp = sum(1 for im in clean_negatives if eng.detect(im).detected)
rival_fp = [k for k, im in rival_examples.items() if eng.detect(im).detected]
fp = clean_fp + len(rival_fp)
recall = tp / len(positives) if positives else float("nan")
precision = tp / (tp + fp) if (tp + fp) else float("nan")
log.info(
"%6.2f %8.2f %9.1f%% %9.1f%% %4d %9d %9d %s",
gate,
margin,
recall * 100,
precision * 100,
tp,
clean_fp,
len(rival_fp),
rival_fp,
)
if __name__ == "__main__":
main()
+16 -3
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import logging
import os
import re
from dataclasses import dataclass
from datetime import UTC, datetime
from time import monotonic
@@ -125,8 +126,10 @@ def parse_provider_result(surface: str, page_text: str) -> WebVerdict:
def proxy_settings(
slot: oracles.ExecutionSlot,
environ: Mapping[str, str] = os.environ,
*,
sticky_session_id: str | None = None,
) -> ProxySettings | None:
"""Resolve a secret proxy URL without putting it in a manifest or log."""
"""Resolve a secret proxy URL and pin rotating ThorData routes per batch."""
variable = slot.get("proxy_url_env")
if variable is None:
return None
@@ -142,7 +145,13 @@ def proxy_settings(
server = f"{server}:{parsed.port}"
settings: ProxySettings = {"server": server}
if parsed.username is not None:
settings["username"] = unquote(parsed.username)
username = unquote(parsed.username)
is_thordata = parsed.hostname.endswith((".thordata.net", ".thordata.online"))
if is_thordata and sticky_session_id is not None and "-sessid-" not in username.casefold():
if re.fullmatch(r"[A-Za-z0-9]{1,32}", sticky_session_id) is None:
raise ValueError("sticky_session_id must be 1-32 ASCII letters or digits")
username = f"{username}-sessid-{sticky_session_id}-sesstime-90"
settings["username"] = username
if parsed.password is not None:
settings["password"] = unquote(parsed.password)
return settings
@@ -287,7 +296,11 @@ def run_web_batch(
if slot is None:
raise ValueError("Playwright batches require an explicit execution slot")
secret_values = dict(environ) if env_file is None else oracles.secret_environment(env_file, environ=environ)
proxy = proxy_settings(slot, secret_values)
proxy = proxy_settings(
slot,
secret_values,
sticky_session_id=f"oracle{results['manifest_sha256'][:16]}",
)
pending = [
(manifest_row, result_row)
for manifest_row, result_row in zip(manifest["rows"], results["rows"], strict=True)
+4
View File
@@ -32,6 +32,10 @@ _CANDIDATE_ASSETS = _ROOT / "scripts" / "assets" / "visible-mark-candidates"
_FONT = "/System/Library/Fonts/STHeiti Medium.ttc"
MARKS = {
# Brand-less TC260 fallback (no vendor wordmark exists to tune against -- see
# generic_ai_label_engine.py's module docstring for the vivo/Xiaomi/Samsung
# evidence). Bare glyph, no prefix, unlike every other row below.
"generic_ai_label_alpha.png": "AI生成",
"qwen_alpha.png": "千问AI生成",
"xinghui_alpha.png": "星绘AI生成",
# Yuanbao's stamp is a TWO-LINE block (元宝 over AI生成), left-aligned, tightly
+13 -1
View File
@@ -7,6 +7,7 @@ import argparse
import hashlib
import json
import logging
import os
import re
import sys
from dataclasses import dataclass
@@ -54,7 +55,18 @@ class RegistryRow:
def _read_bytes(source: str) -> bytes:
parsed = urlparse(source)
if parsed.scheme in {"http", "https"}:
request = Request(source, headers={"User-Agent": "remove-ai-watermarks-c2pa-registry-sync"})
headers = {"User-Agent": "remove-ai-watermarks-c2pa-registry-sync"}
# api.github.com rate-limits unauthenticated requests to 60/hour PER SOURCE IP,
# which CI runners exhaust fast since many workflows across many repos share
# GitHub's runner IP pool -- a 403 "rate limit exceeded" here is that, not an
# outage. A GITHUB_TOKEN (Actions injects one into every run; export it as an
# env var to reach this process) raises the same endpoint to 5000/hour.
# raw.githubusercontent.com is a separate, much higher-limit CDN and does not
# need this.
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
if token and parsed.hostname == "api.github.com":
headers["Authorization"] = f"Bearer {token}"
request = Request(source, headers=headers)
with urlopen(request, timeout=30) as response:
return response.read()
return Path(source).read_bytes()
@@ -22,12 +22,16 @@ remove-ai-watermarks visible image.png --keep-metadata -o clean.png
Image mark keys: `gemini`, `doubao`, `jimeng`, `qwen`, `kling`, `yuanbao`,
`samsung`, `runninghub`, `baidu`, `liblib`, `liblib_pill`, `microsoft`,
`jimeng_pill`. Default
`generic_ai_label`, `openart`, `jimeng_pill`. Default
`--mark auto` removes every selected match. `microsoft` is the top-right AI
badge, separate from the invisible InvisMark watermark on the same vendor's
images. `jimeng_pill` is a weak detector and needs corroboration. The compact
`liblib_pill` likewise requires LiblibAI metadata or the bottom-center wordmark
(since CLI 0.39.0).
(since CLI 0.39.0). `generic_ai_label` is a brand-less bare "AI生成" fallback for
OS/gallery-level AI-edit stamps with no vendor wordmark; synthetic-only
calibration, weaker evidence than the vendor-tuned marks above. `openart` sits
in the FRAME CENTER, not a corner, and is UNCALIBRATED (one confirmed real
case, no captured corpus) -- see `openart_engine.py`'s module docstring.
```bash
remove-ai-watermarks erase image.png --region 1640,1930,400,100 -o clean.png
+1 -1
View File
@@ -34,7 +34,7 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
_warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*")
__version__ = "0.41.1"
__version__ = "0.42.0"
__all__ = [
"BatchItemResult",
@@ -100,9 +100,15 @@ SDXL_ZIMAGE_UNKNOWN_STRENGTH = SDXL_ZIMAGE_GEMINI_STRENGTH
# carriers, so 0.35 is the content-agnostic operating floor.
QWEN_ZIMAGE_GOOGLE_STRENGTH = 0.35
# The two OpenAI sources first cleared at 0.06225 and 0.0695. Add one full observed
# cross-source spread (0.00725) to the worst clean boundary: 0.0695 + 0.00725.
QWEN_ZIMAGE_OPENAI_STRENGTH = 0.07675
# OpenAI source-fresh expansion (official Content Provenance API, 2026-09-21):
# six current gpt-image-2 carriers first cleared at 0.04375 / 0.09375 / 0.0625
# (YuNet faces) and 0.04375 / 0.03125 / 0.08125 (no faces), each bracketed to
# 0.00625 after an RGB-identical metadata-stripped positive control. Together
# with the earlier 0.06225 / 0.0695 sources this is n=8. Face and no-face ranges
# both span 0.05, while their worst boundaries differ by only 0.0125, so there
# is no content split. Add one full observed cross-source spread to the worst
# clean boundary: 0.09375 + (0.09375 - 0.03125) = 0.15625.
QWEN_ZIMAGE_OPENAI_STRENGTH = 0.15625
# Microsoft's public detector (https://ai.azure.com/nextgen/validate) returned
# Inconclusive rather than an API-level watermark-negative verdict. Three valid
@@ -151,8 +157,9 @@ _SDXL_ZIMAGE_STRENGTH_BY_VENDOR: dict[str, float] = {
# 0.1375. The face-bearing cohort rule adds its observed spread to the worst
# boundary: 0.1375 + (0.1375 - 0.075) = 0.20. Explicit Chroma uses that
# operating point, verified clean three times on both withheld carriers.
# Qwen cleared both withheld carriers at its existing 0.07675 operating point,
# so auto routes OpenAI to Qwen rather than paying Chroma's higher fidelity cost.
# Qwen cleared both withheld carriers at its then-existing 0.07675 operating
# point; the later n=8 Qwen expansion raised its floor to 0.15625 without
# changing the route, which still avoids Chroma's higher fidelity cost.
# Three further withheld carriers, all zero-face by the same YuNet detector the
# adaptive arms gate on, first cleared at 0.045 / 0.06 / 0.11 -> a zero-face
# operating point of 0.11 + (0.11 - 0.045) = 0.175, which 0.20 already covers.
+13 -4
View File
@@ -89,8 +89,8 @@ class TextMarkConfig:
name: str # short label for log lines (e.g. "Doubao")
asset_name: str # bundled alpha PNG under assets/ (e.g. "doubao_alpha.png")
corner: Literal[
"br", "bl", "tl", "tr", "bc"
] # br (Doubao/Jimeng), bl (Samsung), tl (RunningHub), tr (Microsoft), bc (LiblibAI)
"br", "bl", "tl", "tr", "bc", "cc"
] # br (Doubao/Jimeng), bl (Samsung), tl (RunningHub), tr (Microsoft), bc (LiblibAI), cc (OpenArt)
margin_floor: int # min margin in px for locate (4 for br marks, 2 for Samsung)
# locate geometry (fraction of scale_base -- see scale_base())
width_frac: float
@@ -242,6 +242,10 @@ _RIVAL_MODULES = {
"jimeng_alpha.png": "remove_ai_watermarks.jimeng_engine",
"qwen_alpha.png": "remove_ai_watermarks.qwen_engine",
"samsung_alpha.png": "remove_ai_watermarks.samsung_engine",
"baidu_alpha.png": "remove_ai_watermarks.baidu_engine",
"kling_alpha.png": "remove_ai_watermarks.kling_engine",
"yuanbao_alpha.png": "remove_ai_watermarks.yuanbao_engine",
"generic_ai_label_alpha.png": "remove_ai_watermarks.generic_ai_label_engine",
}
@@ -528,12 +532,17 @@ class TextMarkEngine:
margin_b = max(c.margin_floor, int(base * c.margin_bottom_frac))
if c.corner == "br" or c.corner == "tr":
x = max(0, w - margin_x - wm_w)
elif c.corner == "bc": # bottom-center: horizontally centered, margin_x unused
elif c.corner in ("bc", "cc"): # horizontally centered, margin_x unused
x = max(0, (w - wm_w) // 2)
else:
x = min(margin_x, max(0, w - wm_w))
# "tl"/"tr" anchor at the top instead: margin_bottom_frac is then the TOP margin.
y = min(margin_b, max(0, h - wm_h)) if c.corner in ("tl", "tr") else max(0, h - margin_b - wm_h)
if c.corner in ("tl", "tr"):
y = min(margin_b, max(0, h - wm_h))
elif c.corner == "cc": # frame-center: vertically centered, margin_bottom_frac unused
y = max(0, (h - wm_h) // 2)
else:
y = max(0, h - margin_b - wm_h)
wm_w = min(wm_w, w - x)
wm_h = min(wm_h, h - y)
return TextMarkLocation(x=x, y=y, w=wm_w, h=wm_h)
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

+12
View File
@@ -287,6 +287,18 @@ class GeminiEngine:
low_margin = margin is not None and margin < self._SPARKLE_FP_MARGIN
low_gradient = candidate.gradient < self._SPARKLE_FP_GRAD
if not low_margin and not low_gradient:
# A bright core AND a sharp shape match clear the gate without further
# scrutiny -- including for a decorative sparkle-shaped glyph, not just a
# genuine sparkle. See "Gemini sparkle" in docs/module-internals.md
# (incident 2026-08-27) for why margin/gradient/saturation can't tell
# them apart, and TestDecorativeGlyphFalsePositive for the regression.
# Logged so a future fix can be calibrated against real occurrences.
logger.debug(
"Sparkle candidate cleared FP gate without scrutiny: confidence=%.3f, margin=%s, gradient=%.3f",
confidence,
margin,
candidate.gradient,
)
return confidence
saturation = self._core_saturation(image, alpha, position)
neutral_core = not low_margin and saturation is not None and saturation <= self._SPARKLE_WHITE_SAT
@@ -0,0 +1,227 @@
"""Generic bare "AI生成" text-mark detector/localizer (brand-less TC260 fallback).
Every other text-mark engine in this package is tuned to one vendor's exact wordmark
silhouette ("豆包AI生成" for Doubao, "★ 即梦AI" for Jimeng, and so on). This engine
instead catches a BARE "AI生成" (or "AI generated") corner strip with **no brand
wordmark in front of it** -- the China GB 45438-2025 / TC260 explicit-AIGC label
stamped by OS- and gallery-level AI-edit tools that do not carry a distinct generator
brand of their own, as opposed to a named generator product like Doubao or Kling.
Research evidence (2026-09-21, see the library's task history; no dedicated brand
wordmark exists to tune a config against, so this is deliberately the generic
catch-all rather than one more brand-pinned engine):
* vivo's own support documentation confirms its Gallery app stamps a bare "AI生成"
watermark after AI消除 (AI object removal) / AI扩图 (AI expand) / 大片模式 (AI
scene effects) editing, user-togglable under Album Settings -> "AI编辑后显示AI
标识" ("Show AI label after AI edit") -- https://www.vivo.com.cn/service/questions/all?categoryId=170&questionId=2104
* Independent reporting (今日头条 2026) documents the same bare "AI生成" watermark
on Xiaomi's Gallery AI-edit output, with users asking how to remove it.
* Community reporting suggests Samsung's China-locale Galaxy AI gallery tools stamp
"AI生成" too, distinct from the Italian "Contenuti generati dall'AI" string this
library's ``samsung`` engine already targets (different locale render of the same
product, not covered here).
* OPPO/Huawei almost certainly ship an equivalent compliance stamp under the same
mandate, but no independent confirmation of the exact wording was found.
The label is compliance-driven, not brand-driven, so it is PLAUSIBLE that several
manufacturers' gallery apps render the exact same "AI生成" string with only styling
differences (font, position, background pill) -- which is exactly why this is one
generic fallback keyed on the bare text, not a family of near-duplicate per-OEM
configs.
Detection reuses the shared physical assumption every other text mark in this family
already encodes (``MAX_SATURATION`` / ``LOGO_MIN_LUMA`` / ``TOPHAT_DELTA``): a light,
low-saturation glyph rendered brighter than its local background, mandated by GB
45438-2025's own "light-colored watermark" house style, not a vendor-specific
measurement. Everything else here is UNMEASURED and synthetic: there is no captured
vendor screenshot to solve an alpha map from (the standing ``visible_alpha_solve.py``
approach), because the mark has no logo element to capture -- only literal text, so
``scripts/render_vendor_silhouettes.py`` -- the shared capture-less font-rendering
pipeline already used for Qwen/Baidu/Kling/Yuanbao's own text-only marks -- renders
"AI生成" directly as the alpha-template asset (``assets/generic_ai_label_alpha.png``),
which plugs into the same :class:`TextMarkConfig` machinery every other mark uses.
CALIBRATION CAVEAT: ``DETECT_NCC_THRESHOLD`` below is fit on a SYNTHETIC corpus only
(the rendered glyph composited onto generated backgrounds at varied scale/opacity,
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
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.
"""
# The module-level _alpha_template / _glyph_silhouette / _template_match_score below
# are thin test-facing shims (imported by tests/), so pyright's src-only pass sees them
# as unused; the use is cross-module.
# pyright: reportUnusedFunction=false
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from remove_ai_watermarks import _text_mark_engine, image_io
from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkDetection, TextMarkEngine
if TYPE_CHECKING:
from numpy.typing import NDArray
# Locate geometry as a fraction of image WIDTH (unmeasured basis -- the default every
# unconfirmed vendor starts from; see scale_basis below). The box must comfortably
# exceed the glyph at the TOP of the search ladder (alpha_*_frac * max(ladder)) or the
# search crop clips the glyph before NCC ever sees it -- at this asset's shape that
# floor is 0.1724*1.25=0.216 / 0.0625*1.25=0.078; these fractions keep ~1.3x headroom
# above it, matching the other bottom-right marks' convention.
WM_WIDTH_FRAC = 0.28
WM_HEIGHT_FRAC = 0.10
MARGIN_RIGHT_FRAC = 0.010
MARGIN_BOTTOM_FRAC = 0.010
# Glyph appearance: shared TC260 "light watermark" house style (see module docstring),
# not a vendor-specific measurement -- identical to Doubao/Jimeng/Kling.
MAX_SATURATION = 55
LOGO_MIN_LUMA = 150
TOPHAT_DELTA = 12
# Shape-consistent detection via the continuous top-hat front-end (TM_CCOEFF_NORMED
# against the rendered "AI生成" silhouette). Calibrated SYNTHETICALLY (see the module
# docstring's caveat) against the scripts/render_vendor_silhouettes.py asset: the
# rendered glyph composited onto generated backgrounds at 4 base sizes x 3 scale rungs
# x 3 opacities (36 positives), scored against 40 generated clean/textured negatives
# and 8 rival marks' own committed examples (each containing "AI生成" or similar CJK
# text, so a real risk of double-firing on an already-attributed mark):
#
# gate margin recall precision TP clean_fp rival_fp
# 0.35 0.10 67% 96% 24 0 1 (qwen)
# 0.40 0.10 67% 96% 24 0 1 (qwen)
# 0.45 0.10 67% 100% 24 0 0
# 0.50 0.10 67% 100% 24 0 0 <- chosen
# 0.55 0.10 42% 100% 15 0 0
#
# 0.50 over the tied 0.45 (identical recall/precision): a synthetic clean-background
# removal leaves a faint TELEA-inpainting residual that still correlates ~0.45-0.46
# against this template (see test_removes_synthetic_mark), so 0.45 leaves detection
# re-firing on the library's OWN removal output with no headroom. 0.50 costs nothing
# on this corpus and clears that residual.
#
# The plain margin=0.10 shared by every other mark already separates this template
# from Qwen's own "千问AI生成" binarized glyph blob once the absolute gate clears
# 0.45, so no widened rival margin is needed.
DETECT_MIN_COVERAGE = 0.04
DETECT_NCC_THRESHOLD = 0.50
# Never relaxed under provenance: this mark cannot be attributed to one vendor, so
# there is no producer identity that could confirm it the way a TC260 USCC confirms
# Doubao/Jimeng/etc, and the synthetic-only calibration has no measured sub-gate band
# to relax into safely.
PROVENANCE_NCC_FACTOR = 1.0
# Detection-silhouette geometry, emitted by scripts/render_vendor_silhouettes.py
# (font-rendered, not captured) at this reference width.
_ALPHA_NATIVE_WIDTH = 2048
_ALPHA_WIDTH_FRAC = 0.1724
_ALPHA_HEIGHT_FRAC = 0.0625
# The rendered alpha is also the removal footprint after the detector aligns it,
# mirroring Doubao/Kling: a solid enclosing rectangle risks reaching frame edges on
# textured backgrounds (see doubao_engine's _FOOTPRINT_ALPHA_FLOOR note), so the sparse
# glyph footprint is safer for an as-yet-uncalibrated mark.
_FOOTPRINT_ALPHA_FLOOR = 0.05
_FOOTPRINT_DILATE = 1
# Rival marks sharing the bottom-right corner whose OWN glyphs contain "AI生成" as a
# substring (Doubao "豆包AI生成", Jimeng's product is a distinct wordmark without that
# substring and is excluded on purpose -- "★ 即梦AI" scores low against this template
# and does not need the margin. Baidu, Kling, Qwen and Yuanbao's Chinese text runs do
# carry the same substring). Without this margin, this generic detector would fire a
# SECOND time on every one of their marks, which are already correctly attributed by
# their own tuned engines. The plain default margin (0.10, TextMarkConfig's own
# default) already separates every rival at DETECT_NCC_THRESHOLD -- see the sweep
# table above -- so no per-mark override is needed here.
_RIVALS = ("doubao_alpha.png", "qwen_alpha.png", "baidu_alpha.png", "kling_alpha.png", "yuanbao_alpha.png")
_CONFIG = TextMarkConfig(
name="Generic AI-generated label",
asset_name="generic_ai_label_alpha.png",
corner="br",
margin_floor=4,
width_frac=WM_WIDTH_FRAC,
height_frac=WM_HEIGHT_FRAC,
margin_x_frac=MARGIN_RIGHT_FRAC,
margin_bottom_frac=MARGIN_BOTTOM_FRAC,
max_saturation=MAX_SATURATION,
logo_min_luma=LOGO_MIN_LUMA,
tophat_delta=TOPHAT_DELTA,
morph_open_size=5,
detect_min_coverage=DETECT_MIN_COVERAGE,
detect_ncc_threshold=DETECT_NCC_THRESHOLD,
detect_frontend="tophat",
scale_basis="width",
rivals=_RIVALS,
alpha_width_frac=_ALPHA_WIDTH_FRAC,
alpha_height_frac=_ALPHA_HEIGHT_FRAC,
min_gw=8,
provenance_ncc_factor=PROVENANCE_NCC_FACTOR,
)
def _alpha_template() -> NDArray[Any] | None:
"""The bundled generic-label alpha template (float [0,1]), or None."""
return _text_mark_engine.load_alpha_template(_CONFIG.asset_name)
def _glyph_silhouette() -> NDArray[Any] | None:
"""Binary "AI生成" silhouette (255 = glyph) from the alpha map, or None."""
return _text_mark_engine.glyph_silhouette(_CONFIG.asset_name)
def _template_match_score(box_mask: NDArray[Any], scale_base: int) -> float:
"""TM_CCOEFF_NORMED of the generic-label glyph silhouette against ``box_mask``."""
return _text_mark_engine.template_match_score(box_mask, scale_base, _CONFIG)
class GenericAiLabelEngine(TextMarkEngine):
"""Detect/localize a brand-less bare "AI生成" text mark (locate -> mask; mask feeds the fill)."""
def __init__(self) -> None:
super().__init__(_CONFIG)
def footprint_mask(
self,
image: NDArray[Any] | None,
*,
force: bool = False,
dilate: int | None = None,
detection: TextMarkDetection | None = None,
) -> NDArray[Any] | None:
"""Return the detector-aligned generic-label glyph footprint.
Mirrors :meth:`DoubaoEngine.footprint_mask`: the continuous top-hat response
locates the mark, but the rendered alpha resized onto the detector's winning
template box is the safer removal mask on textured backgrounds. ``force`` falls
back to the shared geometry-box behavior (no trustworthy alignment).
"""
if force:
return super().footprint_mask(image, force=True, dilate=dilate, detection=detection)
if image is None or image.size == 0:
return None
image = image_io.to_bgr(image)
det = detection if detection is not None else self.detect(image)
if not det.detected or det.match_box is None:
return super().footprint_mask(image, force=False, dilate=dilate, detection=det)
alpha = _alpha_template()
if alpha is None:
return super().footprint_mask(image, force=False, dilate=dilate, detection=det)
radius = _FOOTPRINT_DILATE if dilate is None else max(0, dilate)
return self._aligned_alpha_mask(
image,
det,
alpha,
alpha_floor=_FOOTPRINT_ALPHA_FLOOR,
dilate=radius,
)
+141
View File
@@ -0,0 +1,141 @@
"""OpenArt visible watermark detector/localizer.
OpenArt (openart.ai) stamps free-plan generations with a semi-transparent white
"OpenArt" wordmark (a bowtie/infinity icon followed by the brand name) placed
**near the center of the frame**, not in a corner like every other text mark
this repository detects. Confirmed from one customer-reported production case
(raiw-app, 2026-09-11, 1528x2712 portrait output): the mark sat roughly centered
over the subject's torso, and neither C2PA, EXIF AI tags, nor the China AIGC/TC260
label were present -- OpenArt apparently ships no machine-readable provenance
signal alongside this visible mark, so a bare export was previously reported as
``platform: null`` with zero signals. OpenArt's own pricing page lists
"Watermark-free" as a paid-plan feature, consistent with every free-plan output
carrying this mark; its help center separately confirms free-plan video exports
carry a removable watermark toggle. No independent confirmation of placement
variance by aspect ratio, of a configurable/opt-out watermark on free tier, or
of a paid tier that still embeds C2PA/other metadata is available -- OpenArt
requires an account for image generation (no anonymous or API access), so this
was not surveyed across multiple generations.
**Calibration is PROVISIONAL, not measured.** Every other engine in this module
ships a recall/precision table from tens to hundreds of hand-labeled real
captures (see ``doubao_engine.py``). No such corpus exists for OpenArt: there is
no committed real capture (see ``scripts/build_openart_alpha.py`` for why), so
``DETECT_NCC_THRESHOLD`` below is set conservatively high rather than measured,
specifically because a center-frame locate box (see ``_text_mark_engine.corner
== "cc"``) sits over far more varied image content than a corner box, and an
uncalibrated false-fire there destroys pixels in the middle of the photo. Do
not lower this threshold or add OpenArt to any auto-scan precision claim
without a real measured corpus under ``data/calibration/openart/``.
Detection matches the bundled (procedurally reconstructed, not captured)
glyph silhouette against the center-frame candidate; removal is the shared
**localize -> fill**, using the default template-free rectangular footprint
(no override -- unlike Doubao, this mark does not sit near a frame edge where
a solid rectangular fill risks bleeding into un-inpaintable border texture).
This module shares :class:`remove_ai_watermarks._text_mark_engine.TextMarkEngine`
and supplies only OpenArt's tuned :class:`TextMarkConfig`.
"""
# pyright: reportUnusedFunction=false
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from remove_ai_watermarks import _text_mark_engine
from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkEngine
if TYPE_CHECKING:
from numpy.typing import NDArray
# Locate geometry as a fraction of the image SHORT side (portrait is the only
# observed carrier so far; "short" keeps the box a sane size on landscape
# inputs too, mirroring Doubao's rationale -- see scale_base). Generously
# oversized relative to the alpha template below so the scale ladder and NCC
# alignment search both have slack; unlike a corner mark this box floats free
# in the frame, so accuracy depends entirely on the NCC score, not the corner
# anchor.
WM_WIDTH_FRAC = 0.55
WM_HEIGHT_FRAC = 0.18
MARGIN_X_FRAC = 0.0 # unused: "cc" centers horizontally regardless of margin_x_frac
MARGIN_BOTTOM_FRAC = 0.0 # unused: "cc" centers vertically regardless of margin_bottom_frac
# Glyph appearance: a light, low-saturation near-white overlay (white top-hat),
# the same rendering class as every other bundled text mark.
MAX_SATURATION = 55
LOGO_MIN_LUMA = 150
TOPHAT_DELTA = 12
# Inert for the "tophat" front-end (see _text_mark_engine._scan: the coverage
# gate only guards the "binary" front-end), kept for the dataclass contract.
DETECT_MIN_COVERAGE = 0.02
# UNCALIBRATED -- see module docstring. Set well above Doubao/Jimeng's measured
# 0.45-0.50 specifically because this mark floats in the frame center rather
# than a corner, where an unrelated bright/textured subject is far more likely
# to produce an incidental top-hat blob than in a corner. Re-measure against a
# real captured corpus before relying on this number for a precision claim.
DETECT_NCC_THRESHOLD = 0.62
# No independent provenance signal is known for OpenArt (see module docstring),
# so the provenance-relaxed gate can never be legitimately reached today; keep
# it at parity with the strict gate rather than inventing a relaxation factor
# with nothing to calibrate it against.
PROVENANCE_NCC_FACTOR = 1.0
# Detection-silhouette geometry for the bundled (procedurally reconstructed)
# asset -- see scripts/build_openart_alpha.py. The asset canvas is 360x90
# (aspect ~4:1). Unlike a solved capture, there is no real photo resolution to
# anchor this to, so _ALPHA_NATIVE_WIDTH is simply the round basis the asset's
# own fractions are self-consistent against (360/1024 and 90/1024): at
# scale_base == _ALPHA_NATIVE_WIDTH the resized template comes back at the
# asset's own native pixel size.
_ALPHA_NATIVE_WIDTH = 1024
_ALPHA_WIDTH_FRAC = 360 / 1024
_ALPHA_HEIGHT_FRAC = 90 / 1024
_CONFIG = TextMarkConfig(
name="OpenArt",
asset_name="openart_alpha.png",
corner="cc",
margin_floor=4,
width_frac=WM_WIDTH_FRAC,
height_frac=WM_HEIGHT_FRAC,
margin_x_frac=MARGIN_X_FRAC,
margin_bottom_frac=MARGIN_BOTTOM_FRAC,
max_saturation=MAX_SATURATION,
logo_min_luma=LOGO_MIN_LUMA,
tophat_delta=TOPHAT_DELTA,
morph_open_size=5,
detect_min_coverage=DETECT_MIN_COVERAGE,
detect_ncc_threshold=DETECT_NCC_THRESHOLD,
provenance_ncc_factor=PROVENANCE_NCC_FACTOR,
detect_frontend="tophat",
scale_basis="short",
alpha_width_frac=_ALPHA_WIDTH_FRAC,
alpha_height_frac=_ALPHA_HEIGHT_FRAC,
min_gw=12,
)
def _alpha_template() -> NDArray[Any] | None:
"""The bundled (procedurally reconstructed) OpenArt alpha template (float [0,1]), or None."""
return _text_mark_engine.load_alpha_template(_CONFIG.asset_name)
def _glyph_silhouette() -> NDArray[Any] | None:
"""Binary "OpenArt" wordmark silhouette (255 = glyph) from the alpha map, or None."""
return _text_mark_engine.glyph_silhouette(_CONFIG.asset_name)
def _template_match_score(box_mask: NDArray[Any], scale_base: int) -> float:
"""TM_CCOEFF_NORMED of the OpenArt glyph silhouette against ``box_mask``."""
return _text_mark_engine.template_match_score(box_mask, scale_base, _CONFIG)
class OpenArtEngine(TextMarkEngine):
"""Detect/localize the visible, center-frame OpenArt wordmark (locate -> mask; mask feeds the fill)."""
def __init__(self) -> None:
super().__init__(_CONFIG)
@@ -31,6 +31,11 @@ Entries:
- ``liblib`` -- LiblibAI "LiblibAI" wordmark, bottom-center.
- ``liblib_pill`` -- LiblibAI compact "AI生成" pill, top-left.
- ``microsoft`` -- one measured Microsoft white AI-badge variant, top-right.
- ``generic_ai_label`` -- brand-less bare "AI生成" TC260 label (no vendor wordmark),
bottom-right; the fallback for a compliance stamp not tied to any tuned mark above.
- ``openart`` -- OpenArt "OpenArt" wordmark (bowtie/infinity icon + brand name),
frame-center; UNCALIBRATED (one confirmed real case, no captured corpus), see
``openart_engine.py``.
"""
from __future__ import annotations
@@ -450,6 +455,8 @@ _ENGINE_CLASS: dict[str, tuple[str, str]] = {
"liblib": ("liblib_engine", "LibLibEngine"),
"liblib_pill": ("liblib_engine", "LibLibPillEngine"),
"microsoft": ("microsoft_engine", "MicrosoftEngine"),
"generic_ai_label": ("generic_ai_label_engine", "GenericAiLabelEngine"),
"openart": ("openart_engine", "OpenArtEngine"),
}
@@ -786,6 +793,41 @@ _REGISTRY: tuple[KnownMark, ...] = (
platform="Microsoft (visible top-right AI badge detected)",
provenance_platform_tokens=("microsoft",),
),
# Brand-less fallback: a bare "AI生成" TC260 label with no vendor wordmark in
# front of it (OS/gallery-level AI-edit compliance stamps -- vivo and Xiaomi
# Gallery confirmed, see generic_ai_label_engine's module docstring for sources).
# label_regime=None and provenance_signals=(): unlike every other `_text_mark`
# row, this mark can NEVER declare a tc260_producer_code (there is no one
# producer to name), and test_api.py's TestVisibleProvenance invariant requires
# every "tc260"-regime mark to declare one except the two companion pills that
# inherit a sibling's identity. This mark is neither -- it genuinely cannot be
# attributed -- so it opts out of the "tc260" regime tag the way Samsung and
# Microsoft already do for their own non-attributable device stamps, and out of
# "aigc" provenance relaxation (mirrors provenance_ncc_factor=1.0 in the engine:
# never relaxed).
_text_mark(
"generic_ai_label",
"Generic bare AI生成 text (no vendor wordmark)",
"bottom-right",
platform="Generic AI-generated content label (bare 'AI生成' mark; specific vendor undetermined)",
manufacturer="unknown",
label_regime=None,
provenance_signals=(),
),
# Frame-center, not a corner -- see the "cc" corner and openart_engine.py's module
# docstring for the single confirmed carrier and why this row is UNCALIBRATED.
# No China TC260 label and no other known metadata signal: label_regime=None,
# provenance_signals=() mirror Microsoft's non-attributable-device shape rather
# than the TC260 default every other _text_mark row above inherits.
_text_mark(
"openart",
"OpenArt wordmark",
"frame-center",
platform="OpenArt (visible OpenArt mark detected)",
manufacturer="openart",
label_regime=None,
provenance_signals=(),
),
# Same product as the Jimeng wordmark -- the one pair that cross-relaxes.
KnownMark(
"jimeng_pill",
+34
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import re
from unittest.mock import MagicMock, patch
import pytest
@@ -112,3 +113,36 @@ def test_packaged_snapshot_preserves_published_resolution_apis():
f"upstream softbinding-algorithm-list resolution-API set changed: {sorted(with_apis)}; "
"revisit docs/c2pa-resolution-research.md before regenerating"
)
class TestReadBytesAuth:
"""api.github.com rate-limits unauthenticated requests to 60/hour PER SOURCE IP,
which CI runners exhaust fast since many workflows share GitHub's runner IP pool
(observed: --check failing with 403 on every platform in one push). A GITHUB_TOKEN
raises that to 5000/hour; these pin that the header is sent to api.github.com when
a token is present, withheld when it is not, and never sent to the unrelated raw
CDN host even with a token set."""
def _urlopen_headers(self, source: str) -> dict[str, str]:
response = MagicMock()
response.__enter__.return_value.read.return_value = b"{}"
with patch("scripts.sync_c2pa_soft_bindings.urlopen", return_value=response) as mock_urlopen:
sync._read_bytes(source)
request = mock_urlopen.call_args[0][0]
return dict(request.headers)
def test_sends_bearer_token_to_github_api(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("GITHUB_TOKEN", "test-token")
headers = self._urlopen_headers("https://api.github.com/repos/example/example/commits")
assert headers.get("Authorization") == "Bearer test-token"
def test_omits_authorization_without_a_token(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
monkeypatch.delenv("GH_TOKEN", raising=False)
headers = self._urlopen_headers("https://api.github.com/repos/example/example/commits")
assert "Authorization" not in headers
def test_never_sends_the_token_to_the_raw_cdn_host(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("GITHUB_TOKEN", "test-token")
headers = self._urlopen_headers("https://raw.githubusercontent.com/example/example/main/x.json")
assert "Authorization" not in headers
+5 -2
View File
@@ -80,11 +80,14 @@ def test_web_results_survive_later_browser_failure(monkeypatch, tmp_path):
"slot": {},
"rows": [{"artifact_id": "first", "upload_path": "one"}, {"artifact_id": "second", "upload_path": "two"}],
}
results = {"rows": [{"watermark_result": None}, {"watermark_result": None}]}
results = {
"manifest_sha256": "0" * 64,
"rows": [{"watermark_result": None}, {"watermark_result": None}],
}
saved = []
monkeypatch.setattr(web.oracles, "verify_batch", lambda *a, **k: {})
monkeypatch.setattr(web.oracles, "load_batch", lambda *a: (manifest, results))
monkeypatch.setattr(web, "proxy_settings", lambda *a: None)
monkeypatch.setattr(web, "proxy_settings", lambda *a, **k: None)
monkeypatch.setattr(web.oracles, "record_result", lambda *a, **k: saved.append(k))
def submit(*args, **kwargs):
+80 -8
View File
@@ -113,18 +113,22 @@ class TestGeminiEngine:
# ── Removal: localize -> fill ───────────────────────────────────────
def _composite_sparkle(engine, bg_value: int = 160, size: int = 1400):
"""Composite the captured sparkle at the configured position on a textured
mid-tone image (texture so the filled footprint no longer NCC-matches the
template). Returns ``(watermarked_uint8, (x, y, w, h))``."""
yy, xx = np.mgrid[0:size, 0:size].astype(np.float32)
base = bg_value + 18 * np.sin(xx / 40.0) + 14 * np.cos(yy / 55.0)
img = np.clip(np.stack([base, base * 0.97, base * 1.03], axis=-1), 0, 255)
def _composite_sparkle(engine, bg_value: int = 160, size: int = 1400, *, alpha_scale: float = 1.0, background=None):
"""Composite the captured sparkle at the configured position. Defaults to a
textured mid-tone image (texture so the filled footprint no longer NCC-matches
the template); pass ``background`` to blend onto an arbitrary image instead
(e.g. photographic noise). Returns ``(watermarked_uint8, (x, y, w, h))``."""
if background is not None:
img = background.astype(np.float32)
else:
yy, xx = np.mgrid[0:size, 0:size].astype(np.float32)
base = bg_value + 18 * np.sin(xx / 40.0) + 14 * np.cos(yy / 55.0)
img = np.clip(np.stack([base, base * 0.97, base * 1.03], axis=-1), 0, 255)
config = get_watermark_config(size, size)
x, y = config.get_position(size, size)
alpha = engine.get_interpolated_alpha(config.logo_size)
ah, aw = alpha.shape[:2]
a = alpha[:, :, None]
a = np.clip(alpha * alpha_scale, 0.0, 1.0)[:, :, None]
roi = img[y : y + ah, x : x + aw]
img[y : y + ah, x : x + aw] = a * 255.0 + (1.0 - a) * roi
return np.clip(img, 0, 255).astype(np.uint8), (x, y, aw, ah)
@@ -338,6 +342,74 @@ class TestSparkleFalsePositiveGate:
assert not det.detected
class TestDecorativeGlyphFalsePositive:
"""Incident 2026-08-27, documented in full in ``docs/module-internals.md``
(Gemini sparkle section): a decorative glyph in stylized typography clears the
FP gate the same way a genuine weak sparkle does, and no computed signal
(margin, gradient, saturation) currently separates the two -- see the
counter-example below. This is pinned as a KNOWN, unresolved false-positive
class rather than silently "fixed" with an untested threshold nudge.
"""
@pytest.fixture(autouse=True)
def _setup_engine(self):
self.engine = GeminiEngine()
def _decorative_glyph_scene(self, size: int = 1200) -> np.ndarray:
img = np.full((size, size, 3), 25, dtype=np.float32)
config = get_watermark_config(size, size)
x, y = config.get_position(size, size)
s = config.logo_size
cx, cy = x + s // 2, y + s // 2
# A neon-glow letterform stroke passing close to the glyph, as in the
# reported book-cover title.
cv2.ellipse(img, (cx, cy), (35, 60), 20, 0, 360, (60, 220, 60), thickness=14)
img = cv2.GaussianBlur(img, (0, 0), 3)
# A sharp four-point diamond/glint accent: same silhouette family as the
# engine's own sparkle template, bright near-white core.
canvas = np.zeros((size, size), dtype=np.float32)
outer, inner = 18.0, 18.0 * 0.28
points = []
for i in range(8):
angle = np.pi / 2 * (i // 2) + (0 if i % 2 == 0 else np.pi / 4)
radius = outer if i % 2 == 0 else inner
points.append((cx + radius * np.cos(angle), cy + radius * np.sin(angle)))
hull = cv2.convexHull(np.array(points, dtype=np.int32))
cv2.fillConvexPoly(canvas, hull, 1.0)
canvas = cv2.GaussianBlur(canvas, (0, 0), 1.5)
alpha = np.clip(canvas, 0.0, 1.0)[:, :, None]
img = alpha * 255.0 + (1.0 - alpha) * img
return np.clip(img, 0, 255).astype(np.uint8)
def test_decorative_glyph_scores_just_under_the_fp_gate(self):
"""Pins the confirmed incident signature: a borderline score that the FP
gate is not currently able to demote. If this starts failing because the
score moved, re-derive the fixture rather than loosening the assertion --
the incident value was 0.64."""
image = self._decorative_glyph_scene()
det = self.engine.detect_watermark(image)
assert 0.55 <= det.confidence < self.engine._SPARKLE_FP_CONF
assert det.detected # confirmed false positive: not demoted by the gate
def test_naive_tightening_would_also_reject_a_genuine_weak_sparkle(self):
"""Counter-example blocking a naive fix: a genuine weak Gemini sparkle
composited over noisy, textured (photographic) content lands in the same
confidence band with the same clean-margin, clean-gradient, near-white-core
signature as the decorative glyph above. Any tightening that demotes the
glyph fixture must not demote this one too without a real precision/recall
measurement backing it (see the class docstring and ``docs/module-internals.md``).
"""
size = 1400
rng = np.random.default_rng(1)
noise = rng.normal(140, 75, (size, size, 3)).astype(np.float32)
noise = cv2.GaussianBlur(noise, (0, 0), 1.2)
image, _ = _composite_sparkle(self.engine, size=size, alpha_scale=0.7, background=noise)
det = self.engine.detect_watermark(image)
assert 0.55 <= det.confidence < self.engine._SPARKLE_FP_CONF
assert det.detected # a real (weak) sparkle correctly kept, not demoted
class TestCornerPromotion:
"""Issue #36: a small sparkle in the corner must not be lost to a larger decoy.
+201
View File
@@ -0,0 +1,201 @@
"""Tests for the generic bare "AI生成" text-mark engine (brand-less TC260 fallback).
No real sample of this mark is committed -- the confirmed production bug this engine
was built for (a bottom-right "AI生成" strip with no vendor wordmark, left completely
unremoved because no existing tuned detector matched it) carries no customer image
bytes in any repository. Detection/removal is exercised against a watermark
synthesized from the engine's own font-rendered alpha asset, mirroring every other
text-mark engine's test pattern (see test_doubao_engine.py / test_jimeng_engine.py).
"""
from __future__ import annotations
import cv2
import numpy as np
import pytest
from remove_ai_watermarks import watermark_registry as registry
from remove_ai_watermarks.generic_ai_label_engine import (
_ALPHA_HEIGHT_FRAC,
_ALPHA_NATIVE_WIDTH,
_ALPHA_WIDTH_FRAC,
DETECT_NCC_THRESHOLD,
GenericAiLabelEngine,
_alpha_template,
_glyph_silhouette,
_template_match_score,
)
def _compose(w: int, h: int, bg: float = 100.0):
"""Composite the rendered alpha (scaled to width ``w``) onto a flat bg.
Returns ``(watermarked_uint8, mark_bool_mask)``."""
img = np.full((h, w, 3), bg, np.float32)
at = _alpha_template()
gw, gh = int(_ALPHA_WIDTH_FRAC * w), int(_ALPHA_HEIGHT_FRAC * w)
margin = int(0.015 * w)
ax = w - margin - gw
ay = h - margin - gh
amap = np.zeros((h, w), np.float32)
amap[ay : ay + gh, ax : ax + gw] = cv2.resize(at, (gw, gh))
a3 = amap[:, :, None]
wm = (a3 * 255.0 + (1 - a3) * img).clip(0, 255).astype(np.uint8)
return wm, amap > 0.2
class TestLocate:
def test_box_anchored_bottom_right(self):
eng = GenericAiLabelEngine()
img = np.zeros((2048, 2048, 3), np.uint8)
loc = eng.locate(img)
assert 2048 - (loc.x + loc.w) < int(2048 * 0.05)
assert 2048 - (loc.y + loc.h) < int(2048 * 0.05)
def test_box_scales_with_width(self):
eng = GenericAiLabelEngine()
small = eng.locate(np.zeros((1024, 1024, 3), np.uint8))
large = eng.locate(np.zeros((2048, 2048, 3), np.uint8))
assert large.w == pytest.approx(small.w * 2, rel=0.1)
class TestDetect:
def test_clean_gradient_not_detected(self):
eng = GenericAiLabelEngine()
ramp = np.tile(np.linspace(0, 255, 1024, dtype=np.uint8), (1024, 1))
img = cv2.cvtColor(ramp, cv2.COLOR_GRAY2BGR)
assert not eng.detect(img).detected
def test_solid_blob_corner_not_detected(self):
"""A bright blob is not the glyph shape -> low correlation, not detected."""
eng = GenericAiLabelEngine()
img = np.zeros((1024, 1024, 3), np.uint8)
x, y, bw, bh = eng.locate(img).bbox
img[y + bh // 4 : y + bh * 3 // 4, x : x + bw // 2] = 200
assert not eng.detect(img).detected
def test_silhouette_loads(self):
sil = _glyph_silhouette()
assert sil is not None
assert set(np.unique(sil)).issubset({0, 255})
def test_match_score_shape_sensitive(self):
"""The rendered glyph silhouette correlates with itself, not with a filled block."""
sil = _glyph_silhouette()
h, w = sil.shape
box = np.zeros((h + 8, int(w / _ALPHA_WIDTH_FRAC * 0.2) + w), np.uint8)
box[4 : 4 + h, 4 : 4 + w] = sil
assert _template_match_score(box, _ALPHA_NATIVE_WIDTH) >= DETECT_NCC_THRESHOLD
solid = np.full_like(box, 255)
assert _template_match_score(solid, _ALPHA_NATIVE_WIDTH) < DETECT_NCC_THRESHOLD
def test_detects_synthetic_mark(self):
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
eng = GenericAiLabelEngine()
det = eng.detect(wm)
assert det.detected
assert det.confidence >= DETECT_NCC_THRESHOLD
def test_small_image_guarded_from_false_positive(self):
"""Below the shared minimum short side, detection is skipped outright (the
same small-image NCC-noise guard every text mark inherits from
TextMarkEngine._scan)."""
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
eng = GenericAiLabelEngine()
assert eng.detect(wm).detected # native: real mark detected
assert not eng.detect(cv2.resize(wm, (150, 150))).detected # below guard: suppressed
class TestRivalMarks:
"""The generic template must not double-fire on a mark already attributed to its
own tuned brand engine -- every rival below contains the literal substring
"AI生成" in its own glyphs, which is exactly the collision the ``rivals`` guard
exists to prevent. See generic_ai_label_engine's DETECT_NCC_THRESHOLD comment for
the calibration this pins."""
@pytest.mark.parametrize("key", ["doubao", "qwen", "baidu", "kling", "yuanbao"])
def test_does_not_fire_on_rival_brand_mark(self, key: str):
from remove_ai_watermarks import watermark_registry as wr
marked = wr.get_mark(key)
# Build the rival's own canonical composite via the shared example generator,
# so this test does not need its own copy of each mark's geometry.
from scripts.render_visible_examples import base_photo, stamp_image_mark
base = base_photo(1536, 1152, seed=13)
out = stamp_image_mark(key, base)
assert out is not None, key
img, _box = out
assert marked.detect(img).detected, f"{key}: sanity check -- its own engine should still fire"
assert not GenericAiLabelEngine().detect(img).detected, f"generic engine double-fired on {key}"
class TestAlphaAsset:
def test_alpha_asset_loads(self):
at = _alpha_template()
assert at is not None
assert at.dtype.kind == "f"
assert float(at.min()) >= 0.0
assert float(at.max()) <= 1.0
class TestFootprintMaskAndRemoval:
def test_footprint_mask_in_bottom_right(self):
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
mask = GenericAiLabelEngine().footprint_mask(wm)
assert mask is not None
assert mask.shape == wm.shape[:2]
ys, xs = np.where(mask > 0)
assert ys.mean() > wm.shape[0] / 2
assert xs.mean() > wm.shape[1] / 2
def test_removes_synthetic_mark(self):
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
assert GenericAiLabelEngine().detect(wm).detected
out, region = registry.get_mark("generic_ai_label").remove(wm, backend="cv2")
assert region is not None
assert not GenericAiLabelEngine().detect(out).detected
def test_far_region_untouched(self):
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
out, _ = registry.get_mark("generic_ai_label").remove(wm, backend="cv2")
h, w = wm.shape[:2]
assert np.array_equal(wm[: h // 2, : w // 2], out[: h // 2, : w // 2])
class TestDegenerateAndChannelInputs:
"""footprint_mask must not crash on degenerate sizes or non-3-channel inputs."""
@pytest.mark.parametrize(("w", "h"), [(2048, 1), (1, 2048), (2048, 8)])
def test_wide_short_does_not_raise(self, w, h):
eng = GenericAiLabelEngine()
img = np.zeros((h, w, 3), np.uint8)
mask = eng.footprint_mask(img, force=True)
assert mask is None or mask.shape == (h, w)
def test_grayscale_2d_does_not_raise(self):
eng = GenericAiLabelEngine()
gray = np.zeros((2048, 2048), np.uint8)
mask = eng.footprint_mask(gray, force=True)
assert mask is None or mask.shape == (2048, 2048)
def test_bgra_4channel_does_not_raise(self):
eng = GenericAiLabelEngine()
bgra = np.zeros((2048, 2048, 4), np.uint8)
mask = eng.footprint_mask(bgra, force=True)
assert mask is None or mask.shape == (2048, 2048)
class TestRegistryWiring:
"""The mark must actually be reachable through the shared registry, the same way
identify/remove_auto_marks reach every other mark."""
def test_registered_with_generic_key(self):
mark = registry.get_mark("generic_ai_label")
assert mark.key == "generic_ai_label"
assert mark.in_auto
def test_participates_in_detect_marks(self):
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
results = registry.detect_marks(wm)
hit = next(r for r in results if r.key == "generic_ai_label")
assert hit.detected
+15
View File
@@ -1212,6 +1212,21 @@ class TestIdentifyVisibleTextMarks:
assert "Jimeng" in r.platform
assert any(s.name == "visible_jimeng" for s in r.signals)
def test_openart_promotes_to_medium(self, tmp_clean_png: Path):
"""OpenArt has no known TC260/C2PA/EXIF signal (see openart_engine.py's
module docstring), so the visible mark is the ONLY route to a platform
attribution -- this is the exact production gap the mark closes: a bare
OpenArt export previously reported `platform: null` with zero signals."""
det = self._detection("openart", "OpenArt wordmark", 0.85)
with patch(_SPARKLE_TARGET, return_value=None), patch(_TEXT_MARKS_TARGET, return_value=[det]):
r = identify(tmp_clean_png, check_visible=True)
assert r.is_ai_generated is True
assert r.confidence == "medium"
assert r.platform is not None
assert "OpenArt" in r.platform
signal = next(s for s in r.signals if s.name == "visible_openart")
assert signal.confidence == "medium"
def test_check_visible_false_skips_text_marks(self, tmp_clean_png: Path):
det = self._detection("doubao", "Doubao 豆包AI生成 text", 0.99)
with patch(_SPARKLE_TARGET, return_value=None), patch(_TEXT_MARKS_TARGET, return_value=[det]) as mock:
+205
View File
@@ -0,0 +1,205 @@
"""Tests for the OpenArt visible-watermark engine (localize -> fill).
No real captured OpenArt sample is committed (see ``openart_engine.py``'s module
docstring for why), so every fixture here is a synthetic composite of the
bundled, procedurally reconstructed alpha asset -- there is no
``TestRealSample`` analogue to ``test_doubao_engine.py``'s skip-if-absent class.
"""
from __future__ import annotations
import cv2
import numpy as np
import pytest
from remove_ai_watermarks import watermark_registry as registry
from remove_ai_watermarks.openart_engine import (
_ALPHA_HEIGHT_FRAC,
_ALPHA_NATIVE_WIDTH,
_ALPHA_WIDTH_FRAC,
DETECT_NCC_THRESHOLD,
OpenArtEngine,
_alpha_template,
_glyph_silhouette,
_template_match_score,
)
def _compose(w: int, h: int, bg: float = 100.0):
"""Composite the bundled alpha (scaled to the image's short side) onto a flat
bg, centered in the frame. Returns ``(watermarked_uint8, mark_bool_mask)``."""
img = np.full((h, w, 3), bg, np.float32)
at = _alpha_template()
base = min(w, h)
gw, gh = int(_ALPHA_WIDTH_FRAC * base), int(_ALPHA_HEIGHT_FRAC * base)
ax = (w - gw) // 2
ay = (h - gh) // 2
amap = np.zeros((h, w), np.float32)
amap[ay : ay + gh, ax : ax + gw] = cv2.resize(at, (gw, gh))
a3 = amap[:, :, None]
wm = (a3 * 255.0 + (1 - a3) * img).clip(0, 255).astype(np.uint8)
return wm, amap > 0.2
class TestLocate:
def test_box_centered_in_frame(self):
eng = OpenArtEngine()
img = np.zeros((2048, 2048, 3), np.uint8)
loc = eng.locate(img)
cx, cy = loc.x + loc.w / 2, loc.y + loc.h / 2
assert cx == pytest.approx(2048 / 2, abs=2048 * 0.02)
assert cy == pytest.approx(2048 / 2, abs=2048 * 0.02)
def test_box_scales_with_short_side(self):
eng = OpenArtEngine()
small = eng.locate(np.zeros((1024, 1024, 3), np.uint8))
large = eng.locate(np.zeros((2048, 2048, 3), np.uint8))
assert large.w == pytest.approx(small.w * 2, rel=0.1)
def test_box_stays_centered_on_portrait(self):
"""The one confirmed carrier is a portrait (1528x2712); the box must stay
horizontally AND vertically centered, not just anchored to one axis."""
eng = OpenArtEngine()
loc = eng.locate(np.zeros((2712, 1528, 3), np.uint8))
cx, cy = loc.x + loc.w / 2, loc.y + loc.h / 2
assert cx == pytest.approx(1528 / 2, abs=1528 * 0.02)
assert cy == pytest.approx(2712 / 2, abs=2712 * 0.02)
class TestDetect:
def test_clean_gradient_not_detected(self):
eng = OpenArtEngine()
ramp = np.tile(np.linspace(0, 255, 1024, dtype=np.uint8), (1024, 1))
img = cv2.cvtColor(ramp, cv2.COLOR_GRAY2BGR)
assert not eng.detect(img).detected
def test_solid_blob_center_not_detected(self):
"""A bright blob is not the glyph shape -> low correlation, not detected."""
eng = OpenArtEngine()
img = np.zeros((1024, 1024, 3), np.uint8)
x, y, bw, bh = eng.locate(img).bbox
img[y + bh // 4 : y + bh * 3 // 4, x : x + bw // 2] = 200
assert not eng.detect(img).detected
def test_silhouette_loads(self):
sil = _glyph_silhouette()
assert sil is not None
assert set(np.unique(sil)).issubset({0, 255})
def test_match_score_shape_sensitive(self):
"""The glyph silhouette correlates with itself, not with a filled block."""
sil = _glyph_silhouette()
h, w = sil.shape
box = np.zeros((h + 8, int(w / _ALPHA_WIDTH_FRAC * 0.2) + w), np.uint8)
box[4 : 4 + h, 4 : 4 + w] = sil
assert _template_match_score(box, _ALPHA_NATIVE_WIDTH) >= DETECT_NCC_THRESHOLD
solid = np.full_like(box, 255)
assert _template_match_score(solid, _ALPHA_NATIVE_WIDTH) < DETECT_NCC_THRESHOLD
def test_small_image_guarded_from_false_positive(self):
"""Below the shared minimum short side, detection is skipped outright
(``_MIN_DETECT_SHORT_SIDE`` in ``_text_mark_engine``) regardless of vendor."""
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
eng = OpenArtEngine()
assert eng.detect(wm).detected # native: synthetic mark detected
assert not eng.detect(cv2.resize(wm, (150, 150))).detected # below guard: suppressed
def test_random_textured_images_do_not_false_fire(self):
"""A frame-center locate box sits over far more varied content than a
corner box (see the module docstring's calibration caveat); guard against
the obvious failure mode on plain photographic texture."""
eng = OpenArtEngine()
rng = np.random.default_rng(0)
for _ in range(15):
h, w = int(rng.integers(1000, 2200)), int(rng.integers(1000, 2200))
noise = rng.integers(60, 200, (h, w, 3)).astype(np.uint8)
img = cv2.GaussianBlur(noise, (0, 0), sigmaX=8)
assert not eng.detect(img).detected
class TestAlphaAsset:
def test_alpha_asset_loads(self):
at = _alpha_template()
assert at is not None
assert at.dtype.kind == "f"
assert float(at.min()) >= 0.0
assert float(at.max()) <= 1.0
class TestFootprintMaskAndRemoval:
def test_footprint_mask_near_frame_center(self):
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
mask = OpenArtEngine().footprint_mask(wm)
assert mask is not None
assert mask.shape == wm.shape[:2]
ys, xs = np.where(mask > 0)
assert ys.mean() == pytest.approx(wm.shape[0] / 2, abs=wm.shape[0] * 0.15)
assert xs.mean() == pytest.approx(wm.shape[1] / 2, abs=wm.shape[1] * 0.15)
def test_removes_synthetic_mark(self):
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
assert OpenArtEngine().detect(wm).detected
out, region = registry.get_mark("openart").remove(wm, backend="cv2")
assert region is not None
assert not OpenArtEngine().detect(out).detected
@pytest.mark.parametrize(
("w", "h"),
[
(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH), # square
(1528, 2712), # the one confirmed carrier's exact aspect/size
],
)
def test_fill_removes_and_leaves_frame_edges(self, w, h):
"""The fill lowers re-detect confidence and leaves the untouched corners exact."""
wm, mark = _compose(w, h)
assert float(np.abs(wm.astype(np.float32)[mark] - 100.0).mean()) > 15 # mark visible
before = OpenArtEngine().detect(wm)
out, _ = registry.get_mark("openart").remove(wm, backend="cv2")
assert OpenArtEngine().detect(out).confidence < before.confidence
edge = max(4, min(w, h) // 8)
assert np.array_equal(out[:edge, :edge], wm[:edge, :edge])
assert np.array_equal(out[-edge:, -edge:], wm[-edge:, -edge:])
class TestDegenerateAndChannelInputs:
"""footprint_mask must not crash on degenerate sizes or non-3-channel inputs."""
@pytest.mark.parametrize(("w", "h"), [(2048, 1), (1, 2048), (2048, 8)])
def test_wide_short_does_not_raise(self, w, h):
eng = OpenArtEngine()
img = np.zeros((h, w, 3), np.uint8)
mask = eng.footprint_mask(img, force=True)
assert mask is None or mask.shape == (h, w)
def test_grayscale_2d_does_not_raise(self):
eng = OpenArtEngine()
gray = np.zeros((2048, 2048), np.uint8)
mask = eng.footprint_mask(gray, force=True)
assert mask is None or mask.shape == (2048, 2048)
def test_bgra_4channel_does_not_raise(self):
eng = OpenArtEngine()
bgra = np.zeros((2048, 2048, 4), np.uint8)
mask = eng.footprint_mask(bgra, force=True)
assert mask is None or mask.shape == (2048, 2048)
@pytest.mark.parametrize("shape", [(20, 20, 3), (10, 400, 3), (400, 10, 3), (1, 1, 3), (2000, 2000, 3)])
def test_locate_box_stays_in_bounds(self, shape):
"""locate() must clamp its geometry box inside the image for ANY size/aspect,
including the frame-center ("cc") corner -- alongside br/bl as in Doubao's test."""
from remove_ai_watermarks._text_mark_engine import TextMarkEngine
from remove_ai_watermarks.doubao_engine import _CONFIG as BR_CONFIG
from remove_ai_watermarks.openart_engine import _CONFIG as CC_CONFIG
from remove_ai_watermarks.samsung_engine import _CONFIG as BL_CONFIG
h, w = shape[:2]
img = np.zeros(shape, np.uint8)
for cfg in (BR_CONFIG, BL_CONFIG, CC_CONFIG):
loc = TextMarkEngine(cfg).locate(img)
assert loc.x >= 0
assert loc.y >= 0
assert loc.x + loc.w <= w
assert loc.y + loc.h <= h
assert loc.w > 0
assert loc.h > 0
+5 -1
View File
@@ -238,6 +238,7 @@ class TestResolveStrength:
assert resolve_strength(None, "openai", "qwen-zimage", size=(2000, 1850)) == pytest.approx(
QWEN_ZIMAGE_OPENAI_STRENGTH
)
assert pytest.approx(0.15625) == QWEN_ZIMAGE_OPENAI_STRENGTH
assert resolve_strength(None, None, "qwen-zimage", size=(600, 500)) == pytest.approx(0.084)
assert resolve_strength(None, "google", "qwen-zimage", size=(600, 500)) == QWEN_ZIMAGE_GOOGLE_STRENGTH
assert QWEN_ZIMAGE_GOOGLE_STRENGTH == 0.35
@@ -249,7 +250,10 @@ class TestResolveStrength:
with pytest.raises(ValueError, match="size is required"):
resolve_strength(None, "openai", "qwen-zimage")
@pytest.mark.parametrize(("vendor", "expected"), [("microsoft", 0.15), ("openai", 0.07675), ("meta", 0.1)])
@pytest.mark.parametrize(
("vendor", "expected"),
[("microsoft", 0.15), ("openai", QWEN_ZIMAGE_OPENAI_STRENGTH), ("meta", 0.1)],
)
def test_qwen_zimage_measured_vendors_use_flat_cross_source_margins(self, vendor, expected):
"""Measured providers must not fall below their operating points on small files."""
assert resolve_strength(None, vendor, "qwen-zimage", size=(600, 500)) == pytest.approx(expected)
+42 -13
View File
@@ -13,6 +13,18 @@ if TYPE_CHECKING:
from pathlib import Path
def _thordata_slot() -> oracles.ExecutionSlot:
return {
"name": "openai-thordata-us",
"surface": "openai-web",
"account_label": None,
"browser_profile": None,
"google_account_index": None,
"network_label": "thordata-us-residential",
"proxy_url_env": "THORDATA_ROUTE_US",
}
@pytest.mark.parametrize(
("surface", "page_text", "watermark", "provenance"),
[
@@ -171,6 +183,31 @@ def test_proxy_settings_are_loaded_from_an_environment_reference() -> None:
}
def test_thordata_proxy_settings_pin_one_exit_for_the_browser_session() -> None:
proxy = web.proxy_settings(
_thordata_slot(),
{"THORDATA_ROUTE_US": "http://route:secret@gateway.pr.thordata.net:9999"},
sticky_session_id="oracle0123456789abcdef",
)
assert proxy == {
"server": "http://gateway.pr.thordata.net:9999",
"username": "route-sessid-oracle0123456789abcdef-sesstime-90",
"password": "secret",
}
def test_thordata_proxy_settings_preserve_an_explicit_session() -> None:
proxy = web.proxy_settings(
_thordata_slot(),
{"THORDATA_ROUTE_US": ("http://route-sessid-manual-sesstime-15:secret@gateway.pr.thordata.net:9999")},
sticky_session_id="oracle0123456789abcdef",
)
assert proxy is not None
assert proxy["username"] == "route-sessid-manual-sesstime-15"
def test_proxy_settings_do_not_treat_a_missing_secret_as_direct_access() -> None:
slot: oracles.ExecutionSlot = {
"name": "openai-west",
@@ -290,27 +327,18 @@ def test_run_web_batch_loads_the_selected_proxy_from_dotenv(
tmp_path: Path,
tmp_clean_png: Path,
) -> None:
slot: oracles.ExecutionSlot = {
"name": "openai-thordata-us",
"surface": "openai-web",
"account_label": None,
"browser_profile": None,
"google_account_index": None,
"network_label": "thordata-us-residential",
"proxy_url_env": "THORDATA_ROUTE_US",
}
manifest_path = oracles.prepare_batch(
"openai-web",
[tmp_clean_png],
output_dir=tmp_path / "oracle-batch",
repository_root=oracles.REPOSITORY_ROOT,
slot=slot,
slot=_thordata_slot(),
)
env_path = tmp_path / ".env"
env_path.write_text(
"THORDATA_PASSWORD=test-thordata-password\n"
"THORDATA_ROUTE_US=http://td-customer-user-country-US:${THORDATA_PASSWORD}"
"@proxy.thordata.test:9999\n",
"@proxy.pr.thordata.net:9999\n",
encoding="utf-8",
)
proxies: list[dict[str, str] | None] = []
@@ -339,10 +367,11 @@ def test_run_web_batch_loads_the_selected_proxy_from_dotenv(
)
assert report["watermark_results"] == {"not_detected": 1}
manifest_sha256 = json.loads((manifest_path.parent / "results.json").read_text(encoding="utf-8"))["manifest_sha256"]
assert proxies == [
{
"server": "http://proxy.thordata.test:9999",
"username": "td-customer-user-country-US",
"server": "http://proxy.pr.thordata.net:9999",
"username": (f"td-customer-user-country-US-sessid-oracle{manifest_sha256[:16]}-sesstime-90"),
"password": "test-thordata-password",
}
]
+11 -6
View File
@@ -1069,6 +1069,7 @@ def test_chroma_zimage_strength_uses_measured_flat_floors():
CHROMA_ZIMAGE_MICROSOFT_STRENGTH,
CHROMA_ZIMAGE_OPENAI_STRENGTH,
CHROMA_ZIMAGE_UNKNOWN_STRENGTH,
QWEN_ZIMAGE_OPENAI_STRENGTH,
normalize_profile,
resolve_strength,
)
@@ -1087,7 +1088,9 @@ def test_chroma_zimage_strength_uses_measured_flat_floors():
assert CHROMA_ZIMAGE_UNKNOWN_STRENGTH == CHROMA_ZIMAGE_GOOGLE_STRENGTH
# An explicit strength still wins, and other profiles are untouched.
assert resolve_strength(0.2, "google", "chroma-zimage") == pytest.approx(0.2)
assert resolve_strength(None, "openai", "qwen-zimage", size=(2000, 1850)) == pytest.approx(0.07675)
assert resolve_strength(None, "openai", "qwen-zimage", size=(2000, 1850)) == pytest.approx(
QWEN_ZIMAGE_OPENAI_STRENGTH
)
def test_chroma_zimage_inherits_the_shared_stages_and_only_swaps_the_global():
@@ -1331,15 +1334,16 @@ def test_auto_profile_routes_to_the_measured_engine_per_vendor():
def test_auto_strength_uses_the_effective_vendor_profile():
"""Displayed and executed auto strengths must use the same engine policy."""
from remove_ai_watermarks._internal.watermark_profiles import resolve_strength
from remove_ai_watermarks._internal.watermark_profiles import QWEN_ZIMAGE_OPENAI_STRENGTH, resolve_strength
assert resolve_strength(None, "google", "auto", size=(64, 48)) == pytest.approx(0.50)
assert resolve_strength(None, "microsoft", "auto", size=(64, 48)) == pytest.approx(0.125)
assert resolve_strength(None, "openai", "auto", size=(64, 48)) == pytest.approx(0.07675)
assert resolve_strength(None, "openai", "auto", size=(64, 48)) == pytest.approx(QWEN_ZIMAGE_OPENAI_STRENGTH)
def test_auto_profile_strength_uses_the_resolved_engine_floors(tmp_path, monkeypatch):
"""Auto resolves OpenAI to Qwen and uses its oracle-verified 0.07675 floor."""
"""Auto resolves OpenAI to Qwen and uses its oracle-verified floor."""
from remove_ai_watermarks._internal.watermark_profiles import QWEN_ZIMAGE_OPENAI_STRENGTH
from remove_ai_watermarks._internal.watermark_remover import WatermarkRemover
_mock_watermark_runtime_deps(monkeypatch)
@@ -1358,7 +1362,7 @@ def test_auto_profile_strength_uses_the_resolved_engine_floors(tmp_path, monkeyp
assert remover.model_profile == "qwen-zimage"
_, kwargs = runtime.run.call_args
assert kwargs["strength"] == pytest.approx(0.07675)
assert kwargs["strength"] == pytest.approx(QWEN_ZIMAGE_OPENAI_STRENGTH)
def test_auto_google_switches_to_the_sdxl_floor_and_dtype(tmp_path, monkeypatch):
@@ -1406,6 +1410,7 @@ def test_auto_profile_text_manifest_uses_the_measured_qwen_engine_once(tmp_path,
def test_auto_profile_is_resolved_fresh_for_each_image(tmp_path, monkeypatch):
"""A Google image must not pin the next OpenAI image to SDXL."""
from remove_ai_watermarks._internal.watermark_profiles import QWEN_ZIMAGE_OPENAI_STRENGTH
from remove_ai_watermarks._internal.watermark_remover import WatermarkRemover
_mock_watermark_runtime_deps(monkeypatch)
@@ -1428,4 +1433,4 @@ def test_auto_profile_is_resolved_fresh_for_each_image(tmp_path, monkeypatch):
remover.remove_watermark(source, tmp_path / "openai.png", vendor="openai")
assert remover.model_profile == "qwen-zimage"
assert runtimes[-1].run.call_args.kwargs["strength"] == pytest.approx(0.07675)
assert runtimes[-1].run.call_args.kwargs["strength"] == pytest.approx(QWEN_ZIMAGE_OPENAI_STRENGTH)
+2
View File
@@ -29,6 +29,8 @@ _ALTERNATE_SIZE = {
"liblib": (960, 1280),
"liblib_pill": (960, 1280),
"microsoft": (1280, 960),
"generic_ai_label": (1280, 960),
"openart": (1280, 960),
"jimeng_pill": (960, 1280),
}
+5
View File
@@ -27,6 +27,8 @@ class TestCatalog:
"liblib",
"liblib_pill",
"microsoft",
"generic_ai_label",
"openart",
"jimeng_pill",
]
@@ -49,6 +51,7 @@ class TestCatalog:
assert by_key["samsung"].location == "bottom-left"
assert by_key["liblib_pill"].location == "top-left"
assert by_key["jimeng_pill"].location == "top-left"
assert by_key["openart"].location == "frame-center"
def test_get_mark_unknown_raises(self):
with pytest.raises(KeyError):
@@ -118,6 +121,8 @@ class TestScan:
"liblib",
"liblib_pill",
"microsoft",
"generic_ai_label",
"openart",
"jimeng_pill",
}
Generated
+1 -1
View File
@@ -3449,7 +3449,7 @@ wheels = [
[[package]]
name = "remove-ai-watermarks"
version = "0.41.1"
version = "0.42.0"
source = { editable = "." }
dependencies = [
{ name = "c2pa-python" },