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:
Victor KuznetsovandClaude Sonnet 5 committed 2026-09-21 21:51:46 -07:00
1 parent 7a6491fdc1
commit 9d29d2c777
12 files changed
+547 -7

No files matched your search

+3 -1
View File
@@ -368,7 +368,9 @@ Visible mark support includes:
Yuanbao, Baidu, LiblibAI's bottom-center wordmark and compact top-left pill, Yuanbao, Baidu, LiblibAI's bottom-center wordmark and compact top-left pill,
and RunningHub labels; and RunningHub labels;
- one calibrated Microsoft top-right white AI-badge variant; - 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 Metadata and provenance inspection covers C2PA, EXIF, XMP, IPTC, common
generator parameters in image and video containers, China TC260 AIGC labels, generator parameters in image and video containers, China TC260 AIGC labels,
+55
View File
@@ -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 and footprint both use the same best-match box. The separate one-line overlay
variant is not covered. 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) #### 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 `_keep_pill` never removes a metadata-bare pill, and the question "can a bare arm
+1
View File
@@ -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. | | `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. | | `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. | | `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. `--mark auto` evaluates all registered marks and removes every selected match.
Known marks are localized to a mask, then the selected fill backend reconstructs Known marks are localized to a mask, then the selected fill backend reconstructs
+93
View File
@@ -0,0 +1,93 @@
"""Build the PROVISIONAL synthetic OpenArt alpha asset.
Unlike ``visible_alpha_solve.py``, this does NOT recover an alpha map from a
controlled capture: OpenArt (openart.ai) requires an account to generate
images (no anonymous or API-key access, confirmed 2026-09-21 via its own
help center and MCP docs), and creating an account is outside what this
project's tooling is allowed to do on its own. No committed capture exists
under ``data/calibration/openart/``.
This script instead PROCEDURALLY DRAWS a reconstruction of OpenArt's own
brand mark (the bowtie/infinity icon followed by the "OpenArt" wordmark, as
shown on openart.ai's own marketing pages) as a semi-transparent white
overlay, matching the general rendering style (near-white, low-saturation,
anti-aliased) that every other bundled text-mark asset in this module was
solved from. It is a reconstruction from the public logo, not a recovered
watermark alpha map, and ``openart_engine.py`` documents that provenance
gap explicitly. Replace this asset (and re-run the engine's calibration)
the moment a real captured OpenArt export is available under
``data/calibration/openart/``.
Usage::
uv run python scripts/build_openart_alpha.py
"""
# cv2/numpy boundary: third-party libs ship no usable element types; relax the
# unknown-type rules for this file only (mirrors visible_alpha_solve.py).
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownParameterType=false, reportMissingTypeArgument=false, reportMissingTypeStubs=false, reportMissingImports=false, reportArgumentType=false, reportAssignmentType=false, reportReturnType=false, reportCallIssue=false, reportIndexIssue=false, reportOperatorIssue=false
from __future__ import annotations
import sys
from pathlib import Path
import cv2
import numpy as np
sys.path.insert(0, str(Path(__file__).parent.parent))
_ROOT = Path(__file__).resolve().parents[1]
_ASSET = _ROOT / "src" / "remove_ai_watermarks" / "assets" / "openart_alpha.png"
# Canvas sized to comfortably fit "OpenArt" set bold at this height plus the
# icon and inter-glyph gap, at a resolution close to the other bundled
# assets (doubao_alpha.png is 335x83).
_W, _H = 360, 90
_ALPHA_PEAK = 173 # matches doubao_alpha.png's measured peak (~68% opacity)
def _draw_infinity_icon(canvas: np.ndarray, cx: int, cy: int, size: int) -> None:
"""Draw the bowtie/infinity glyph left of the wordmark (two mirrored lobes)."""
half = size // 2
# Two overlapping filled triangles meeting at the center, echoing the
# angular "infinity" mark on openart.ai's own site header.
left_lobe = np.array(
[[cx - size, cy - half], [cx - size, cy + half], [cx, cy]],
dtype=np.int32,
)
right_lobe = np.array(
[[cx + size, cy - half], [cx + size, cy + half], [cx, cy]],
dtype=np.int32,
)
cv2.fillConvexPoly(canvas, left_lobe, (_ALPHA_PEAK,), lineType=cv2.LINE_AA)
cv2.fillConvexPoly(canvas, right_lobe, (_ALPHA_PEAK,), lineType=cv2.LINE_AA)
def build() -> np.ndarray:
canvas = np.zeros((_H, _W), np.uint8)
icon_cx, icon_cy, icon_size = 34, _H // 2, 22
_draw_infinity_icon(canvas, icon_cx, icon_cy, icon_size)
text_x = icon_cx + icon_size + 18
cv2.putText(
canvas,
"OpenArt",
(text_x, _H // 2 + 16),
cv2.FONT_HERSHEY_DUPLEX,
1.3,
(_ALPHA_PEAK,),
thickness=2,
lineType=cv2.LINE_AA,
)
return canvas
def main() -> None:
canvas = build()
_ASSET.parent.mkdir(parents=True, exist_ok=True)
cv2.imwrite(str(_ASSET), canvas)
print(f"Wrote {_ASSET} ({canvas.shape[1]}x{canvas.shape[0]}, peak={int(canvas.max())})")
if __name__ == "__main__":
main()
@@ -22,12 +22,14 @@ remove-ai-watermarks visible image.png --keep-metadata -o clean.png
Image mark keys: `gemini`, `doubao`, `jimeng`, `qwen`, `kling`, `yuanbao`, Image mark keys: `gemini`, `doubao`, `jimeng`, `qwen`, `kling`, `yuanbao`,
`samsung`, `runninghub`, `baidu`, `liblib`, `liblib_pill`, `microsoft`, `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 `--mark auto` removes every selected match. `microsoft` is the top-right AI
badge, separate from the invisible InvisMark watermark on the same vendor's badge, separate from the invisible InvisMark watermark on the same vendor's
images. `jimeng_pill` is a weak detector and needs corroboration. The compact images. `jimeng_pill` is a weak detector and needs corroboration. The compact
`liblib_pill` likewise requires LiblibAI metadata or the bottom-center wordmark `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 ```bash
remove-ai-watermarks erase image.png --region 1640,1930,400,100 -o clean.png 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") name: str # short label for log lines (e.g. "Doubao")
asset_name: str # bundled alpha PNG under assets/ (e.g. "doubao_alpha.png") asset_name: str # bundled alpha PNG under assets/ (e.g. "doubao_alpha.png")
corner: Literal[ corner: Literal[
"br", "bl", "tl", "tr", "bc" "br", "bl", "tl", "tr", "bc", "cc"
] # br (Doubao/Jimeng), bl (Samsung), tl (RunningHub), tr (Microsoft), bc (LiblibAI) ] # 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) 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()) # locate geometry (fraction of scale_base -- see scale_base())
width_frac: float width_frac: float
@@ -528,12 +528,17 @@ class TextMarkEngine:
margin_b = max(c.margin_floor, int(base * c.margin_bottom_frac)) margin_b = max(c.margin_floor, int(base * c.margin_bottom_frac))
if c.corner == "br" or c.corner == "tr": if c.corner == "br" or c.corner == "tr":
x = max(0, w - margin_x - wm_w) 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) x = max(0, (w - wm_w) // 2)
else: else:
x = min(margin_x, max(0, w - wm_w)) x = min(margin_x, max(0, w - wm_w))
# "tl"/"tr" anchor at the top instead: margin_bottom_frac is then the TOP margin. # "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_w = min(wm_w, w - x)
wm_h = min(wm_h, h - y) wm_h = min(wm_h, h - y)
return TextMarkLocation(x=x, y=y, w=wm_w, h=wm_h) return TextMarkLocation(x=x, y=y, w=wm_w, h=wm_h)
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

+141
View File
@@ -0,0 +1,141 @@
"""OpenArt visible watermark detector/localizer.
OpenArt (openart.ai) stamps free-plan generations with a semi-transparent white
"OpenArt" wordmark (a bowtie/infinity icon followed by the brand name) placed
**near the center of the frame**, not in a corner like every other text mark
this repository detects. Confirmed from one customer-reported production case
(raiw-app, 2026-09-11, 1528x2712 portrait output): the mark sat roughly centered
over the subject's torso, and neither C2PA, EXIF AI tags, nor the China AIGC/TC260
label were present -- OpenArt apparently ships no machine-readable provenance
signal alongside this visible mark, so a bare export was previously reported as
``platform: null`` with zero signals. OpenArt's own pricing page lists
"Watermark-free" as a paid-plan feature, consistent with every free-plan output
carrying this mark; its help center separately confirms free-plan video exports
carry a removable watermark toggle. No independent confirmation of placement
variance by aspect ratio, of a configurable/opt-out watermark on free tier, or
of a paid tier that still embeds C2PA/other metadata is available -- OpenArt
requires an account for image generation (no anonymous or API access), so this
was not surveyed across multiple generations.
**Calibration is PROVISIONAL, not measured.** Every other engine in this module
ships a recall/precision table from tens to hundreds of hand-labeled real
captures (see ``doubao_engine.py``). No such corpus exists for OpenArt: there is
no committed real capture (see ``scripts/build_openart_alpha.py`` for why), so
``DETECT_NCC_THRESHOLD`` below is set conservatively high rather than measured,
specifically because a center-frame locate box (see ``_text_mark_engine.corner
== "cc"``) sits over far more varied image content than a corner box, and an
uncalibrated false-fire there destroys pixels in the middle of the photo. Do
not lower this threshold or add OpenArt to any auto-scan precision claim
without a real measured corpus under ``data/calibration/openart/``.
Detection matches the bundled (procedurally reconstructed, not captured)
glyph silhouette against the center-frame candidate; removal is the shared
**localize -> fill**, using the default template-free rectangular footprint
(no override -- unlike Doubao, this mark does not sit near a frame edge where
a solid rectangular fill risks bleeding into un-inpaintable border texture).
This module shares :class:`remove_ai_watermarks._text_mark_engine.TextMarkEngine`
and supplies only OpenArt's tuned :class:`TextMarkConfig`.
"""
# pyright: reportUnusedFunction=false
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from remove_ai_watermarks import _text_mark_engine
from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkEngine
if TYPE_CHECKING:
from numpy.typing import NDArray
# Locate geometry as a fraction of the image SHORT side (portrait is the only
# observed carrier so far; "short" keeps the box a sane size on landscape
# inputs too, mirroring Doubao's rationale -- see scale_base). Generously
# oversized relative to the alpha template below so the scale ladder and NCC
# alignment search both have slack; unlike a corner mark this box floats free
# in the frame, so accuracy depends entirely on the NCC score, not the corner
# anchor.
WM_WIDTH_FRAC = 0.55
WM_HEIGHT_FRAC = 0.18
MARGIN_X_FRAC = 0.0 # unused: "cc" centers horizontally regardless of margin_x_frac
MARGIN_BOTTOM_FRAC = 0.0 # unused: "cc" centers vertically regardless of margin_bottom_frac
# Glyph appearance: a light, low-saturation near-white overlay (white top-hat),
# the same rendering class as every other bundled text mark.
MAX_SATURATION = 55
LOGO_MIN_LUMA = 150
TOPHAT_DELTA = 12
# Inert for the "tophat" front-end (see _text_mark_engine._scan: the coverage
# gate only guards the "binary" front-end), kept for the dataclass contract.
DETECT_MIN_COVERAGE = 0.02
# UNCALIBRATED -- see module docstring. Set well above Doubao/Jimeng's measured
# 0.45-0.50 specifically because this mark floats in the frame center rather
# than a corner, where an unrelated bright/textured subject is far more likely
# to produce an incidental top-hat blob than in a corner. Re-measure against a
# real captured corpus before relying on this number for a precision claim.
DETECT_NCC_THRESHOLD = 0.62
# No independent provenance signal is known for OpenArt (see module docstring),
# so the provenance-relaxed gate can never be legitimately reached today; keep
# it at parity with the strict gate rather than inventing a relaxation factor
# with nothing to calibrate it against.
PROVENANCE_NCC_FACTOR = 1.0
# Detection-silhouette geometry for the bundled (procedurally reconstructed)
# asset -- see scripts/build_openart_alpha.py. The asset canvas is 360x90
# (aspect ~4:1). Unlike a solved capture, there is no real photo resolution to
# anchor this to, so _ALPHA_NATIVE_WIDTH is simply the round basis the asset's
# own fractions are self-consistent against (360/1024 and 90/1024): at
# scale_base == _ALPHA_NATIVE_WIDTH the resized template comes back at the
# asset's own native pixel size.
_ALPHA_NATIVE_WIDTH = 1024
_ALPHA_WIDTH_FRAC = 360 / 1024
_ALPHA_HEIGHT_FRAC = 90 / 1024
_CONFIG = TextMarkConfig(
name="OpenArt",
asset_name="openart_alpha.png",
corner="cc",
margin_floor=4,
width_frac=WM_WIDTH_FRAC,
height_frac=WM_HEIGHT_FRAC,
margin_x_frac=MARGIN_X_FRAC,
margin_bottom_frac=MARGIN_BOTTOM_FRAC,
max_saturation=MAX_SATURATION,
logo_min_luma=LOGO_MIN_LUMA,
tophat_delta=TOPHAT_DELTA,
morph_open_size=5,
detect_min_coverage=DETECT_MIN_COVERAGE,
detect_ncc_threshold=DETECT_NCC_THRESHOLD,
provenance_ncc_factor=PROVENANCE_NCC_FACTOR,
detect_frontend="tophat",
scale_basis="short",
alpha_width_frac=_ALPHA_WIDTH_FRAC,
alpha_height_frac=_ALPHA_HEIGHT_FRAC,
min_gw=12,
)
def _alpha_template() -> NDArray[Any] | None:
"""The bundled (procedurally reconstructed) OpenArt alpha template (float [0,1]), or None."""
return _text_mark_engine.load_alpha_template(_CONFIG.asset_name)
def _glyph_silhouette() -> NDArray[Any] | None:
"""Binary "OpenArt" wordmark silhouette (255 = glyph) from the alpha map, or None."""
return _text_mark_engine.glyph_silhouette(_CONFIG.asset_name)
def _template_match_score(box_mask: NDArray[Any], scale_base: int) -> float:
"""TM_CCOEFF_NORMED of the OpenArt glyph silhouette against ``box_mask``."""
return _text_mark_engine.template_match_score(box_mask, scale_base, _CONFIG)
class OpenArtEngine(TextMarkEngine):
"""Detect/localize the visible, center-frame OpenArt wordmark (locate -> mask; mask feeds the fill)."""
def __init__(self) -> None:
super().__init__(_CONFIG)
@@ -31,6 +31,9 @@ Entries:
- ``liblib`` -- LiblibAI "LiblibAI" wordmark, bottom-center. - ``liblib`` -- LiblibAI "LiblibAI" wordmark, bottom-center.
- ``liblib_pill`` -- LiblibAI compact "AI生成" pill, top-left. - ``liblib_pill`` -- LiblibAI compact "AI生成" pill, top-left.
- ``microsoft`` -- one measured Microsoft white AI-badge variant, top-right. - ``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 from __future__ import annotations
@@ -450,6 +453,7 @@ _ENGINE_CLASS: dict[str, tuple[str, str]] = {
"liblib": ("liblib_engine", "LibLibEngine"), "liblib": ("liblib_engine", "LibLibEngine"),
"liblib_pill": ("liblib_engine", "LibLibPillEngine"), "liblib_pill": ("liblib_engine", "LibLibPillEngine"),
"microsoft": ("microsoft_engine", "MicrosoftEngine"), "microsoft": ("microsoft_engine", "MicrosoftEngine"),
"openart": ("openart_engine", "OpenArtEngine"),
} }
@@ -786,6 +790,20 @@ _REGISTRY: tuple[KnownMark, ...] = (
platform="Microsoft (visible top-right AI badge detected)", platform="Microsoft (visible top-right AI badge detected)",
provenance_platform_tokens=("microsoft",), 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. # Same product as the Jimeng wordmark -- the one pair that cross-relaxes.
KnownMark( KnownMark(
"jimeng_pill", "jimeng_pill",
+15
View File
@@ -1212,6 +1212,21 @@ class TestIdentifyVisibleTextMarks:
assert "Jimeng" in r.platform assert "Jimeng" in r.platform
assert any(s.name == "visible_jimeng" for s in r.signals) 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): def test_check_visible_false_skips_text_marks(self, tmp_clean_png: Path):
det = self._detection("doubao", "Doubao 豆包AI生成 text", 0.99) 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: with patch(_SPARKLE_TARGET, return_value=None), patch(_TEXT_MARKS_TARGET, return_value=[det]) as mock:
+205
View File
@@ -0,0 +1,205 @@
"""Tests for the OpenArt visible-watermark engine (localize -> fill).
No real captured OpenArt sample is committed (see ``openart_engine.py``'s module
docstring for why), so every fixture here is a synthetic composite of the
bundled, procedurally reconstructed alpha asset -- there is no
``TestRealSample`` analogue to ``test_doubao_engine.py``'s skip-if-absent class.
"""
from __future__ import annotations
import cv2
import numpy as np
import pytest
from remove_ai_watermarks import watermark_registry as registry
from remove_ai_watermarks.openart_engine import (
_ALPHA_HEIGHT_FRAC,
_ALPHA_NATIVE_WIDTH,
_ALPHA_WIDTH_FRAC,
DETECT_NCC_THRESHOLD,
OpenArtEngine,
_alpha_template,
_glyph_silhouette,
_template_match_score,
)
def _compose(w: int, h: int, bg: float = 100.0):
"""Composite the bundled alpha (scaled to the image's short side) onto a flat
bg, centered in the frame. Returns ``(watermarked_uint8, mark_bool_mask)``."""
img = np.full((h, w, 3), bg, np.float32)
at = _alpha_template()
base = min(w, h)
gw, gh = int(_ALPHA_WIDTH_FRAC * base), int(_ALPHA_HEIGHT_FRAC * base)
ax = (w - gw) // 2
ay = (h - gh) // 2
amap = np.zeros((h, w), np.float32)
amap[ay : ay + gh, ax : ax + gw] = cv2.resize(at, (gw, gh))
a3 = amap[:, :, None]
wm = (a3 * 255.0 + (1 - a3) * img).clip(0, 255).astype(np.uint8)
return wm, amap > 0.2
class TestLocate:
def test_box_centered_in_frame(self):
eng = OpenArtEngine()
img = np.zeros((2048, 2048, 3), np.uint8)
loc = eng.locate(img)
cx, cy = loc.x + loc.w / 2, loc.y + loc.h / 2
assert cx == pytest.approx(2048 / 2, abs=2048 * 0.02)
assert cy == pytest.approx(2048 / 2, abs=2048 * 0.02)
def test_box_scales_with_short_side(self):
eng = OpenArtEngine()
small = eng.locate(np.zeros((1024, 1024, 3), np.uint8))
large = eng.locate(np.zeros((2048, 2048, 3), np.uint8))
assert large.w == pytest.approx(small.w * 2, rel=0.1)
def test_box_stays_centered_on_portrait(self):
"""The one confirmed carrier is a portrait (1528x2712); the box must stay
horizontally AND vertically centered, not just anchored to one axis."""
eng = OpenArtEngine()
loc = eng.locate(np.zeros((2712, 1528, 3), np.uint8))
cx, cy = loc.x + loc.w / 2, loc.y + loc.h / 2
assert cx == pytest.approx(1528 / 2, abs=1528 * 0.02)
assert cy == pytest.approx(2712 / 2, abs=2712 * 0.02)
class TestDetect:
def test_clean_gradient_not_detected(self):
eng = OpenArtEngine()
ramp = np.tile(np.linspace(0, 255, 1024, dtype=np.uint8), (1024, 1))
img = cv2.cvtColor(ramp, cv2.COLOR_GRAY2BGR)
assert not eng.detect(img).detected
def test_solid_blob_center_not_detected(self):
"""A bright blob is not the glyph shape -> low correlation, not detected."""
eng = OpenArtEngine()
img = np.zeros((1024, 1024, 3), np.uint8)
x, y, bw, bh = eng.locate(img).bbox
img[y + bh // 4 : y + bh * 3 // 4, x : x + bw // 2] = 200
assert not eng.detect(img).detected
def test_silhouette_loads(self):
sil = _glyph_silhouette()
assert sil is not None
assert set(np.unique(sil)).issubset({0, 255})
def test_match_score_shape_sensitive(self):
"""The glyph silhouette correlates with itself, not with a filled block."""
sil = _glyph_silhouette()
h, w = sil.shape
box = np.zeros((h + 8, int(w / _ALPHA_WIDTH_FRAC * 0.2) + w), np.uint8)
box[4 : 4 + h, 4 : 4 + w] = sil
assert _template_match_score(box, _ALPHA_NATIVE_WIDTH) >= DETECT_NCC_THRESHOLD
solid = np.full_like(box, 255)
assert _template_match_score(solid, _ALPHA_NATIVE_WIDTH) < DETECT_NCC_THRESHOLD
def test_small_image_guarded_from_false_positive(self):
"""Below the shared minimum short side, detection is skipped outright
(``_MIN_DETECT_SHORT_SIDE`` in ``_text_mark_engine``) regardless of vendor."""
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
eng = OpenArtEngine()
assert eng.detect(wm).detected # native: synthetic mark detected
assert not eng.detect(cv2.resize(wm, (150, 150))).detected # below guard: suppressed
def test_random_textured_images_do_not_false_fire(self):
"""A frame-center locate box sits over far more varied content than a
corner box (see the module docstring's calibration caveat); guard against
the obvious failure mode on plain photographic texture."""
eng = OpenArtEngine()
rng = np.random.default_rng(0)
for _ in range(15):
h, w = int(rng.integers(1000, 2200)), int(rng.integers(1000, 2200))
noise = rng.integers(60, 200, (h, w, 3)).astype(np.uint8)
img = cv2.GaussianBlur(noise, (0, 0), sigmaX=8)
assert not eng.detect(img).detected
class TestAlphaAsset:
def test_alpha_asset_loads(self):
at = _alpha_template()
assert at is not None
assert at.dtype.kind == "f"
assert float(at.min()) >= 0.0
assert float(at.max()) <= 1.0
class TestFootprintMaskAndRemoval:
def test_footprint_mask_near_frame_center(self):
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
mask = OpenArtEngine().footprint_mask(wm)
assert mask is not None
assert mask.shape == wm.shape[:2]
ys, xs = np.where(mask > 0)
assert ys.mean() == pytest.approx(wm.shape[0] / 2, abs=wm.shape[0] * 0.15)
assert xs.mean() == pytest.approx(wm.shape[1] / 2, abs=wm.shape[1] * 0.15)
def test_removes_synthetic_mark(self):
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
assert OpenArtEngine().detect(wm).detected
out, region = registry.get_mark("openart").remove(wm, backend="cv2")
assert region is not None
assert not OpenArtEngine().detect(out).detected
@pytest.mark.parametrize(
("w", "h"),
[
(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH), # square
(1528, 2712), # the one confirmed carrier's exact aspect/size
],
)
def test_fill_removes_and_leaves_frame_edges(self, w, h):
"""The fill lowers re-detect confidence and leaves the untouched corners exact."""
wm, mark = _compose(w, h)
assert float(np.abs(wm.astype(np.float32)[mark] - 100.0).mean()) > 15 # mark visible
before = OpenArtEngine().detect(wm)
out, _ = registry.get_mark("openart").remove(wm, backend="cv2")
assert OpenArtEngine().detect(out).confidence < before.confidence
edge = max(4, min(w, h) // 8)
assert np.array_equal(out[:edge, :edge], wm[:edge, :edge])
assert np.array_equal(out[-edge:, -edge:], wm[-edge:, -edge:])
class TestDegenerateAndChannelInputs:
"""footprint_mask must not crash on degenerate sizes or non-3-channel inputs."""
@pytest.mark.parametrize(("w", "h"), [(2048, 1), (1, 2048), (2048, 8)])
def test_wide_short_does_not_raise(self, w, h):
eng = OpenArtEngine()
img = np.zeros((h, w, 3), np.uint8)
mask = eng.footprint_mask(img, force=True)
assert mask is None or mask.shape == (h, w)
def test_grayscale_2d_does_not_raise(self):
eng = OpenArtEngine()
gray = np.zeros((2048, 2048), np.uint8)
mask = eng.footprint_mask(gray, force=True)
assert mask is None or mask.shape == (2048, 2048)
def test_bgra_4channel_does_not_raise(self):
eng = OpenArtEngine()
bgra = np.zeros((2048, 2048, 4), np.uint8)
mask = eng.footprint_mask(bgra, force=True)
assert mask is None or mask.shape == (2048, 2048)
@pytest.mark.parametrize("shape", [(20, 20, 3), (10, 400, 3), (400, 10, 3), (1, 1, 3), (2000, 2000, 3)])
def test_locate_box_stays_in_bounds(self, shape):
"""locate() must clamp its geometry box inside the image for ANY size/aspect,
including the frame-center ("cc") corner -- alongside br/bl as in Doubao's test."""
from remove_ai_watermarks._text_mark_engine import TextMarkEngine
from remove_ai_watermarks.doubao_engine import _CONFIG as BR_CONFIG
from remove_ai_watermarks.openart_engine import _CONFIG as CC_CONFIG
from remove_ai_watermarks.samsung_engine import _CONFIG as BL_CONFIG
h, w = shape[:2]
img = np.zeros(shape, np.uint8)
for cfg in (BR_CONFIG, BL_CONFIG, CC_CONFIG):
loc = TextMarkEngine(cfg).locate(img)
assert loc.x >= 0
assert loc.y >= 0
assert loc.x + loc.w <= w
assert loc.y + loc.h <= h
assert loc.w > 0
assert loc.h > 0
+3
View File
@@ -27,6 +27,7 @@ class TestCatalog:
"liblib", "liblib",
"liblib_pill", "liblib_pill",
"microsoft", "microsoft",
"openart",
"jimeng_pill", "jimeng_pill",
] ]
@@ -49,6 +50,7 @@ class TestCatalog:
assert by_key["samsung"].location == "bottom-left" assert by_key["samsung"].location == "bottom-left"
assert by_key["liblib_pill"].location == "top-left" assert by_key["liblib_pill"].location == "top-left"
assert by_key["jimeng_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): def test_get_mark_unknown_raises(self):
with pytest.raises(KeyError): with pytest.raises(KeyError):
@@ -118,6 +120,7 @@ class TestScan:
"liblib", "liblib",
"liblib_pill", "liblib_pill",
"microsoft", "microsoft",
"openart",
"jimeng_pill", "jimeng_pill",
} }