mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-10-21 00:11:14 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f629a4ea6 | ||
|
|
e3bbe88155 | ||
|
|
be8f24c7cb | ||
|
|
9990f3e6ca | ||
|
|
83be3a8926 | ||
|
|
9d29d2c777 | ||
|
|
7a6491fdc1 | ||
|
|
37431c9aeb |
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
@@ -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,
|
||||
)
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user