From 9d29d2c777b35e24d1f1895c74e03575a49b8547 Mon Sep 17 00:00:00 2001 From: Victor Kuznetsov Date: Mon, 21 Sep 2026 21:49:46 -0700 Subject: [PATCH] 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 --- README.md | 4 +- docs/module-internals.md | 55 +++++ docs/supported-signals.md | 1 + scripts/build_openart_alpha.py | 93 ++++++++ .../references/commands.md | 6 +- src/remove_ai_watermarks/_text_mark_engine.py | 13 +- .../assets/openart_alpha.png | Bin 0 -> 2974 bytes src/remove_ai_watermarks/openart_engine.py | 141 ++++++++++++ .../watermark_registry.py | 18 ++ tests/test_identify.py | 15 ++ tests/test_openart_engine.py | 205 ++++++++++++++++++ tests/test_watermark_registry.py | 3 + 12 files changed, 547 insertions(+), 7 deletions(-) create mode 100644 scripts/build_openart_alpha.py create mode 100644 src/remove_ai_watermarks/assets/openart_alpha.png create mode 100644 src/remove_ai_watermarks/openart_engine.py create mode 100644 tests/test_openart_engine.py diff --git a/README.md b/README.md index de8a3f6..545fff5 100644 --- a/README.md +++ b/README.md @@ -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, diff --git a/docs/module-internals.md b/docs/module-internals.md index bca0cdb..07e355b 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -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 diff --git a/docs/supported-signals.md b/docs/supported-signals.md index 5112638..3dfc7ca 100644 --- a/docs/supported-signals.md +++ b/docs/supported-signals.md @@ -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 diff --git a/scripts/build_openart_alpha.py b/scripts/build_openart_alpha.py new file mode 100644 index 0000000..b6c6413 --- /dev/null +++ b/scripts/build_openart_alpha.py @@ -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() diff --git a/skills/remove-ai-watermarks/references/commands.md b/skills/remove-ai-watermarks/references/commands.md index f6ab2b4..35481d1 100644 --- a/skills/remove-ai-watermarks/references/commands.md +++ b/skills/remove-ai-watermarks/references/commands.md @@ -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 diff --git a/src/remove_ai_watermarks/_text_mark_engine.py b/src/remove_ai_watermarks/_text_mark_engine.py index 732469c..c083ab7 100644 --- a/src/remove_ai_watermarks/_text_mark_engine.py +++ b/src/remove_ai_watermarks/_text_mark_engine.py @@ -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) diff --git a/src/remove_ai_watermarks/assets/openart_alpha.png b/src/remove_ai_watermarks/assets/openart_alpha.png new file mode 100644 index 0000000000000000000000000000000000000000..53f565df547c14ac2a4f2886a47a1b6415565540 GIT binary patch literal 2974 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 I+Q2>LKR9V~{r~^~ literal 0 HcmV?d00001 diff --git a/src/remove_ai_watermarks/openart_engine.py b/src/remove_ai_watermarks/openart_engine.py new file mode 100644 index 0000000..318c41e --- /dev/null +++ b/src/remove_ai_watermarks/openart_engine.py @@ -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) diff --git a/src/remove_ai_watermarks/watermark_registry.py b/src/remove_ai_watermarks/watermark_registry.py index 28ae0c6..7e33f97 100644 --- a/src/remove_ai_watermarks/watermark_registry.py +++ b/src/remove_ai_watermarks/watermark_registry.py @@ -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", diff --git a/tests/test_identify.py b/tests/test_identify.py index 8291f3d..3f8e7a3 100644 --- a/tests/test_identify.py +++ b/tests/test_identify.py @@ -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: diff --git a/tests/test_openart_engine.py b/tests/test_openart_engine.py new file mode 100644 index 0000000..a6ee987 --- /dev/null +++ b/tests/test_openart_engine.py @@ -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 diff --git a/tests/test_watermark_registry.py b/tests/test_watermark_registry.py index 57cc3ee..74f2091 100644 --- a/tests/test_watermark_registry.py +++ b/tests/test_watermark_registry.py @@ -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", }