mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-10-15 14:00:54 +02:00
Add OpenArt visible watermark detection and removal
OpenArt.ai stamps free-plan generations with a semi-transparent white "OpenArt" wordmark (bowtie/infinity icon + brand name) placed near the CENTER of the frame rather than in a corner. Confirmed from a customer- reported raiw-app production case where the mark went completely undetected and unremoved, with no C2PA/EXIF/TC260 signal for identify to fall back on. - Add a new "cc" (frame-center) corner mode to the shared TextMarkConfig/TextMarkEngine base, alongside the existing corner/edge anchors. - Add openart_engine.py, following the existing text-mark engine pattern. No real capture exists (OpenArt requires an account, out of scope to create), so the bundled alpha asset is a procedural reconstruction of the public logo and the NCC threshold is an explicitly-labeled provisional guess, not a measured calibration. - Register the mark in watermark_registry.py, which auto-derives identify's platform attribution. - Add tests/test_openart_engine.py (synthetic composites only) plus registry/identify test coverage, and document the mark and its calibration caveats in README.md, docs/supported-signals.md, docs/module-internals.md, and the skill's commands.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7a6491fdc1
commit
9d29d2c777
12 files changed
+547
-7
No files matched your search
@@ -368,7 +368,9 @@ 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;
|
||||
- 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,
|
||||
|
||||
@@ -1603,6 +1603,61 @@ 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.
|
||||
|
||||
#### 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
|
||||
|
||||
@@ -23,6 +23,7 @@ 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. |
|
||||
| `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
|
||||
|
||||
@@ -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()
|
||||
@@ -22,12 +22,14 @@ 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
|
||||
`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). `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
|
||||
|
||||
@@ -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
|
||||
@@ -528,12 +528,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: 2.9 KiB |
@@ -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,9 @@ 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.
|
||||
- ``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 +453,7 @@ _ENGINE_CLASS: dict[str, tuple[str, str]] = {
|
||||
"liblib": ("liblib_engine", "LibLibEngine"),
|
||||
"liblib_pill": ("liblib_engine", "LibLibPillEngine"),
|
||||
"microsoft": ("microsoft_engine", "MicrosoftEngine"),
|
||||
"openart": ("openart_engine", "OpenArtEngine"),
|
||||
}
|
||||
|
||||
|
||||
@@ -786,6 +790,20 @@ _REGISTRY: tuple[KnownMark, ...] = (
|
||||
platform="Microsoft (visible top-right AI badge detected)",
|
||||
provenance_platform_tokens=("microsoft",),
|
||||
),
|
||||
# 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",
|
||||
|
||||
@@ -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
|
||||
@@ -27,6 +27,7 @@ class TestCatalog:
|
||||
"liblib",
|
||||
"liblib_pill",
|
||||
"microsoft",
|
||||
"openart",
|
||||
"jimeng_pill",
|
||||
]
|
||||
|
||||
@@ -49,6 +50,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 +120,7 @@ class TestScan:
|
||||
"liblib",
|
||||
"liblib_pill",
|
||||
"microsoft",
|
||||
"openart",
|
||||
"jimeng_pill",
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user