diff --git a/README.md b/README.md index 0ea542a..0f1fe5a 100644 --- a/README.md +++ b/README.md @@ -344,7 +344,8 @@ Visible mark support includes: - Google Gemini and Nano Banana visible sparkle watermark; - Doubao, the Jimeng wordmark and top-left `AI生成` pill, Qwen, Kling AI, - Yuanbao, Baidu, LiblibAI, and RunningHub labels; + 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. diff --git a/data/fixtures/visible/README.md b/data/fixtures/visible/README.md index 2abe0eb..7447842 100644 --- a/data/fixtures/visible/README.md +++ b/data/fixtures/visible/README.md @@ -20,9 +20,8 @@ engines' recorded calibration evidence. The Qwen, Kling, Yuanbao, Baidu, Jimeng, LiblibAI, Microsoft, Sora, Hailuo, and Veo directories additionally contain real provider-marked sources. These files retain the source bytes and names used here are deliberately distinct from the -generated canonical examples. The current LiblibAI output is evidence of a -provider format change, not a positive example for the registered historical -wordmark detector: +generated canonical examples. The current LiblibAI output exercises the +metadata-corroborated compact-pill path, not the historical wordmark detector: | File | Source | SHA-256 | Expected detector | | --- | --- | --- | --- | @@ -31,7 +30,7 @@ wordmark detector: | `yuanbao/provider-original.png` | Authenticated Tencent Yuanbao AI Image generation, downloaded 2026-09-05 | `e4fd856565d0434ef06deb78ff2088e597c4fec98640ad737213c7c0a917c82d` | Yuanbao, confidence 0.49 | | `baidu/provider-original.jpeg` | OpenNoMark `examples/baidu/baidu_01.jpeg`, commit `9455d2d` | `52852c528f1a7b8918b665b2fcf883737ecc5016481d9c7683ebe07e152cc9d2` | Baidu, confidence 0.66 | | `jimeng_pill/provider-published-example.jpg` | JPEG embedded on page 29 of Zhonglun W&D's 2026 *Bank Finance Legal Newsletter* | `1182fba74d3753785345f8a35d09a8c9b52ea8383ebe9ee999d0d027437f03fd` | Jimeng AI pill, confidence 0.28; Jimeng wordmark, confidence 0.61 | -| `liblib/current-provider-output.png` | Authenticated LiblibAI Smart Image V2 generation, downloaded 2026-09-05 | `60398d4f06fd50e047d3321c62405dae0a1c4a7070e77ac5e0548c516257d2e3` | No `liblib` detection: current top-left `AI生成` pill differs from the registered bottom-center `LiblibAI` wordmark | +| `liblib/current-provider-output.png` | Authenticated LiblibAI Smart Image V2 generation, downloaded 2026-09-05 | `60398d4f06fd50e047d3321c62405dae0a1c4a7070e77ac5e0548c516257d2e3` | `liblib_pill`, confidence 0.216 with LiblibAI metadata corroboration; no `liblib` wordmark | | `microsoft/provider-original.png` | Authenticated Microsoft Copilot generation, downloaded 2026-09-03 | `0e76fb8aa0540b18fee4d63ac4b5fd63c7eee310474e896d21d27e668403afe7` | Microsoft, confidence 0.54 | | `sora/provider-frame.jpg` | Sora 2 video frame from LLinked's Apache-2.0 `sora-watermark-dataset` | `bbcf70b737c30568668572a5bb23e70c52c9687808b0714ca98f9fac073f12c4` | Sora frame localization, confidence 0.69 | | `sora/provider-original.mp4` | LLinked's Apache-2.0 `SoraWatermarkCleaner` fixture `resources/puppies.mp4`, commit `ad35ce9` | `7dd8a9d43d43cd5b901946c7ba14e8018e18d47e75f0f774157327ba7e9181c5` | Sora, 300/300 stable frames; Sora C2PA claim | @@ -141,10 +140,10 @@ The LiblibAI image is the original 2048 x 2048 RGBA PNG generated with Smart Image V2 for 18 introductory points from the same neutral prompt. It carries a top-left `AI生成` pill and a TC260 AIGC field whose producer code contains LiblibAI's registered USCC, but it does not carry the historical bottom-center -`LiblibAI` wordmark that the registered `liblib` engine detects. This file -therefore records the current provider behavior without falsely closing the -historical positive-example gap. The maintainer generated and explicitly -cleared the output for use under the repository license. +`LiblibAI` wordmark that the `liblib` engine detects. It is the real-provider +regression for the metadata-corroborated `liblib_pill` path. The maintainer +generated and explicitly cleared this output for use under the repository +license. - LiblibAI user agreement (generated-output ownership and watermark choices): @@ -182,6 +181,7 @@ detect on its own example, so regeneration is the fix point for drift. Special cases: `gemini` composites the sparkle alpha map at the provider's configured position; `jimeng_pill` uses the measured 3:4 portrait geometry; +`liblib_pill` uses the shorter compact silhouette and requires product evidence; `microsoft` is the opaque white pill with dark text holes (the discriminator its detector keys on). Video examples composite the detector's own synthetic template on every frame; where two marks share a diff --git a/data/fixtures/visible/liblib_pill/example.png b/data/fixtures/visible/liblib_pill/example.png new file mode 100644 index 0000000..3a6800f Binary files /dev/null and b/data/fixtures/visible/liblib_pill/example.png differ diff --git a/docs/known-limitations.md b/docs/known-limitations.md index 1e5cbf3..8e65f58 100644 --- a/docs/known-limitations.md +++ b/docs/known-limitations.md @@ -68,6 +68,9 @@ Known examples: `Contenuti generati dall'AI` text variant. - The Jimeng top-left pill has a weak visual detector and is intentionally subject to additional product and background checks. +- The compact LiblibAI top-left pill is generic on its own. Automatic removal + requires LiblibAI metadata or the bottom-center wordmark and a flat footprint; + `--sensitivity strict` leaves this corroboration-only component untouched. - Kling AI support covers the calibrated variants rather than every Kling AI label. Use `erase --region` when you can see and select an unsupported or missed mark. diff --git a/docs/module-internals.md b/docs/module-internals.md index 541e64b..21f25ef 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -1297,6 +1297,17 @@ be represented by the shared base: - [`liblib_engine.py`](../src/remove_ai_watermarks/liblib_engine.py) - [`microsoft_engine.py`](../src/remove_ai_watermarks/microsoft_engine.py) +LiblibAI has two registry entries under one product. `liblib` retains the +historical bottom-center wordmark detector. `liblib_pill` covers the compact +top-left `AI生成` pill and never fires at strict trust: the shape is generic, so +automatic removal requires either LiblibAI TC260 metadata or a confident +bottom-center sibling detection. The cleared current provider fixture scores +`0.216367` against the reused synthetic pill silhouette, above the corroborated +`0.20` gate but below the standalone Jimeng pill's `0.22` gate. A flat-footprint +check remains mandatory. The pill is a separate action rather than a distant +component unioned into the bottom mask, keeping LaMa and MI-GAN crops local to +each mark. + Kling keeps two independently gated silhouettes under one registry key. The older measured cohort uses `可灵AI 3.0`; a direct authenticated IMAGE 3.0 export on 2026-09-04 uses `KlingAI 3.0`. The Latin template scored 0.429 on that 1024 x diff --git a/docs/supported-signals.md b/docs/supported-signals.md index a21235d..eea9cc4 100644 --- a/docs/supported-signals.md +++ b/docs/supported-signals.md @@ -19,7 +19,8 @@ The `visible` command registers these mark keys: | `samsung` | `✦ Contenuti generati dall'AI` | Bottom left | Calibrated for the Italian text variant. | | `runninghub` | `RunningHub AI生成` | Top left | Strict visual and position gates. | | `baidu` | `百度 AI生成` | Bottom right | Detector and extended removal footprint. | -| `liblib` | `LiblibAI` | Bottom center | Includes a minimum image size gate. A 2026-09-05 Smart Image V2 provider original instead carries a smaller top-left `AI生成` pill and is not detected by this historical-wordmark engine. | +| `liblib` | `LiblibAI` | Bottom center | Historical wordmark variant; includes a minimum image size gate. | +| `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. | @@ -38,8 +39,8 @@ originals exercise vendor raster fidelity. Provider formats can change independently of the registered key. In particular, the current LiblibAI sample in the gallery preserves a real new-format output -and its LiblibAI TC260 producer metadata, but is deliberately not listed as a -positive detector fixture. +and its LiblibAI TC260 producer metadata. That metadata corroborates the compact +pill detector; the generic pill shape does not attribute LiblibAI on its own. ### Visible video marks @@ -258,7 +259,7 @@ not a universal clean verdict. | Kling AI | Kling AI image and video marks | No registered pixel decoder | TC260 AIGC | | Hailuo AI / MiniMax video | Hailuo AI composite video label | No registered pixel decoder | TC260 AIGC where present | | Baidu | Baidu mark | No registered pixel decoder | TC260 AIGC | -| LiblibAI | LiblibAI mark | No registered pixel decoder | TC260 AIGC | +| LiblibAI | LiblibAI wordmark and compact pill | No registered pixel decoder | TC260 AIGC | | RunningHub | RunningHub mark | No registered pixel decoder | TC260 AIGC | | Samsung Galaxy AI | One locale specific mark | No registered pixel decoder | C2PA and Samsung markers | diff --git a/scripts/render_visible_examples.py b/scripts/render_visible_examples.py index 5211c26..60c3ff5 100644 --- a/scripts/render_visible_examples.py +++ b/scripts/render_visible_examples.py @@ -45,6 +45,7 @@ _DEFAULT_STRENGTH = 238 _SIZE: dict[str, tuple[int, int]] = { "qwen": (1536, 1536), "liblib": (1152, 1536), + "liblib_pill": (1152, 1536), "samsung": (2048, 1536), "jimeng_pill": (1152, 1536), } @@ -169,18 +170,42 @@ def _stamp_pill( *, size_mult: float, alpha_mult: float, + width_frac: float = 0.161, + height_frac: float | None = None, + margin_frac: float = 0.03, ) -> tuple[np.ndarray, tuple[int, int, int, int]] | None: h, w = base.shape[:2] at = _glyph_asset("jimeng_pill.png") - pw = max(24, int(0.161 * w * size_mult)) - ph = max(8, int(pw * at.shape[0] / at.shape[1])) - x, y = int(0.03 * w), int(0.03 * h) + pw = max(24, int(width_frac * w * size_mult)) + ph = ( + max(8, int(height_frac * w * size_mult)) + if height_frac is not None + else max(8, int(pw * at.shape[0] / at.shape[1])) + ) + x, y = int(margin_frac * w), int(margin_frac * h) if x + pw > w or y + ph > h: return None alpha = cv2.resize(at, (pw, ph)) * alpha_mult return _composite_light(base, alpha, x, y, 232), (x, y, pw, ph) +def _stamp_liblib_pill( + base: np.ndarray, + *, + size_mult: float, + alpha_mult: float, +) -> tuple[np.ndarray, tuple[int, int, int, int]] | None: + """Composite LiblibAI's shorter top-left pill variant.""" + return _stamp_pill( + base, + size_mult=size_mult, + alpha_mult=alpha_mult, + width_frac=0.15, + height_frac=0.05, + margin_frac=0.025, + ) + + def stamp_image_mark( key: str, base: np.ndarray, @@ -193,6 +218,8 @@ def stamp_image_mark( return _stamp_gemini(base, size_mult=size_mult, alpha_mult=alpha_mult) if key == "jimeng_pill": return _stamp_pill(base, size_mult=size_mult, alpha_mult=alpha_mult) + if key == "liblib_pill": + return _stamp_liblib_pill(base, size_mult=size_mult, alpha_mult=alpha_mult) return _stamp_text_mark(key, base, size_mult=size_mult, alpha_mult=alpha_mult) @@ -323,7 +350,7 @@ def main() -> None: out_dir.mkdir(parents=True, exist_ok=True) path = out_dir / "example.png" cv2.imwrite(str(path), img) - det = wr.get_mark(key).detect(imread(str(path)), provenance=False) + det = wr.get_mark(key).detect(imread(str(path)), provenance=key == "liblib_pill") status = "OK " if det.detected else "MISS" print(f"{status} {key:12s} conf={det.confidence:.3f} -> {path.relative_to(_ROOT)}") if not det.detected: diff --git a/skills/remove-ai-watermarks/references/commands.md b/skills/remove-ai-watermarks/references/commands.md index f791883..c00e08f 100644 --- a/skills/remove-ai-watermarks/references/commands.md +++ b/skills/remove-ai-watermarks/references/commands.md @@ -21,10 +21,12 @@ remove-ai-watermarks visible image.png --keep-metadata -o clean.png ``` Image mark keys: `gemini`, `doubao`, `jimeng`, `qwen`, `kling`, `yuanbao`, -`samsung`, `runninghub`, `baidu`, `liblib`, `microsoft`, `jimeng_pill`. Default +`samsung`, `runninghub`, `baidu`, `liblib`, `liblib_pill`, `microsoft`, +`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. +images. `jimeng_pill` is a weak detector and needs corroboration. The compact +`liblib_pill` likewise requires LiblibAI metadata or the bottom-center wordmark. ```bash remove-ai-watermarks erase image.png --region 1640,1930,400,100 -o clean.png diff --git a/src/remove_ai_watermarks/api.py b/src/remove_ai_watermarks/api.py index 31e8809..3351343 100644 --- a/src/remove_ai_watermarks/api.py +++ b/src/remove_ai_watermarks/api.py @@ -173,9 +173,9 @@ def remove_visible( """Remove every detected known visible AI mark through localize then fill. The registry currently covers the Gemini visible watermark; Doubao, Jimeng, - Qwen, Kling AI, Yuanbao, Samsung, RunningHub, Baidu, and LiblibAI text marks; - one Microsoft top-right AI-badge variant; and the Jimeng pill. Returns - ``(result_bgr, [labels removed])``. + Qwen, Kling AI, Yuanbao, Samsung, RunningHub, and Baidu text marks; the + LiblibAI wordmark and compact pill; one Microsoft top-right AI-badge variant; + and the Jimeng pill. Returns ``(result_bgr, [labels removed])``. ``source`` is a file path OR a BGR ndarray. For a PATH, metadata provenance is read automatically (so ``sensitivity="auto"`` recovers a moved/faint mark whenever the diff --git a/src/remove_ai_watermarks/liblib_engine.py b/src/remove_ai_watermarks/liblib_engine.py index 0eee574..bb38695 100644 --- a/src/remove_ai_watermarks/liblib_engine.py +++ b/src/remove_ai_watermarks/liblib_engine.py @@ -2,14 +2,15 @@ LiblibAI (哩布哩布AI, USCC 91110105MACJ6K1C8A) stamps its generations with a white triangle logo + "LiblibAI" Latin wordmark at **bottom-center** (not a -corner -- the locate box is horizontally centered). Detection matches the +corner -- the locate box is horizontally centered). Current outputs can instead +carry a compact top-left ``AI生成`` pill. The wordmark detector matches the bundled font-rendered "LiblibAI" silhouette (the triangle logo is NOT rendered -- logos vary, the wordmark discriminates); removal is the shared **localize -> fill** (the glyph blob covers logo + wordmark, both bright). -This module supplies only LiblibAI's tuned :class:`TextMarkConfig` +This module supplies LiblibAI's tuned :class:`TextMarkConfig` (``assets/liblib_alpha.png`` from ``scripts/render_vendor_silhouettes.py``, -never cut from an upload). +never cut from an upload) and the corroboration-only compact-pill adapter. The detector uses an Arial-class synthetic silhouette, width-based geometry, a strict confidence gate, and a minimum image size. The footprint includes both @@ -32,6 +33,7 @@ from remove_ai_watermarks._text_mark_engine import ( TextMarkLocation, TextMarkScan, ) +from remove_ai_watermarks.pill_engine import PillDetection, PillEngine if TYPE_CHECKING: from numpy.typing import NDArray @@ -63,6 +65,16 @@ _ALPHA_HEIGHT_FRAC = 0.026 # Tight ladder: the NCC comb is sharp in size (see runninghub_engine). _LADDER = (0.9, 1.0, 1.1) +# LiblibAI's compact top-left ``AI生成`` pill is a separate current-output +# variant. Its shape scores just below the standalone Jimeng pill gate because +# the Liblib render is shorter. LiblibAI metadata or the reliable wordmark is the +# product anchor here, so this lower component gate never acts on its own. +_TOP_LEFT_PILL_MIN_CONFIDENCE = 0.20 +_TOP_LEFT_PILL_X_FRAC = 0.015 +_TOP_LEFT_PILL_Y_FRAC = 0.015 +_TOP_LEFT_PILL_WIDTH_FRAC = 0.17 +_TOP_LEFT_PILL_HEIGHT_FRAC = 0.08 + _CONFIG = TextMarkConfig( name="LiblibAI", asset_name="liblib_alpha.png", @@ -151,3 +163,60 @@ class LibLibEngine(TextMarkEngine): min(w, bx + gx1 + 1 + pad), min(h, by + gy1 + 1 + pad), ) + + +class LibLibPillEngine: + """Detect the compact top-left pill only when LiblibAI is corroborated.""" + + def __init__(self) -> None: + self._pill = PillEngine() + + def _raw(self, image: NDArray[Any]) -> PillDetection: + return self._pill.detect(image) + + def _relaxed_detected(self, image: NDArray[Any], confidence: float) -> bool: + return confidence >= _TOP_LEFT_PILL_MIN_CONFIDENCE and self._pill.footprint_is_flat(image) + + def detect(self, image: NDArray[Any], *, provenance: bool = False) -> PillDetection: + """Return a verdict; the pill is actionable only with LiblibAI evidence.""" + raw = self._raw(image) + detected = provenance and self._relaxed_detected(image, raw.confidence) + return PillDetection(detected, raw.confidence, raw.region) + + def detect_both(self, image: NDArray[Any]) -> tuple[PillDetection, PillDetection]: + """Return strict and LiblibAI-corroborated verdicts from one raw match.""" + raw = self._raw(image) + strict = PillDetection(False, raw.confidence, raw.region) + relaxed = PillDetection(self._relaxed_detected(image, raw.confidence), raw.confidence, raw.region) + return strict, relaxed + + def footprint_mask( + self, + image: NDArray[Any], + *, + force: bool = False, + detection: PillDetection | None = None, + ) -> NDArray[Any] | None: + """Mask the compact pill's fixed top-left footprint.""" + if not image.size: + return None + if detection is None and not force: + raw = self._raw(image) + if not raw.detected: + return None + + frame_h, frame_w = int(image.shape[0]), int(image.shape[1]) + x = int(_TOP_LEFT_PILL_X_FRAC * frame_w) + y = int(_TOP_LEFT_PILL_Y_FRAC * frame_h) + width = int(_TOP_LEFT_PILL_WIDTH_FRAC * frame_w) + height = int(_TOP_LEFT_PILL_HEIGHT_FRAC * frame_w) + if width <= 0 or height <= 0: + return None + + from remove_ai_watermarks import region_eraser + + return region_eraser.boxes_to_mask( + (frame_h, frame_w), + [(x, y, width, height)], + dilate=0, + ) diff --git a/src/remove_ai_watermarks/watermark_registry.py b/src/remove_ai_watermarks/watermark_registry.py index 456490f..4e6a6d7 100644 --- a/src/remove_ai_watermarks/watermark_registry.py +++ b/src/remove_ai_watermarks/watermark_registry.py @@ -29,6 +29,7 @@ Entries: - ``runninghub`` -- RunningHub "RunningHub AI生成" text, top-left (gray front-end). - ``baidu`` -- Baidu "百度 AI生成" text + white tag, bottom-right. - ``liblib`` -- LiblibAI "LiblibAI" wordmark, bottom-center. + - ``liblib_pill`` -- LiblibAI compact "AI生成" pill, top-left. - ``microsoft`` -- one measured Microsoft white AI-badge variant, top-right. """ @@ -85,31 +86,6 @@ Trust = Literal["strict", "confirmed"] # ``_PRODUCT_OF`` is derived from it right after ``_REGISTRY`` is built. -# Marks whose own detection is too weak to serve as EVIDENCE for a sibling of the -# same product, even though they share one. Sibling corroboration grants ``confirmed`` -# trust, which bypasses the sibling's false-positive gate outright -- so a detector -# that false-fires often must not be able to hand that bypass to anyone. -# -# The pill detector has a meaningful raw false-fire rate. Letting it corroborate -# produced a closed loop -# on the DEFAULT auto path, no user flag involved: -# pill false-fires on a clean non-ByteDance image -# -> _PRODUCT_OF maps it to "jimeng", so jimeng resolves to `confirmed` -# -> jimeng's NCC gate drops 0.45 -> 0.3825 and it false-fires too -# -> _keep_pill now sees "jimeng" in keys and takes the WORDMARK arm, which -# removes the pill unrestricted -- skipping the flatness guard that exists -# precisely to stop the fill smearing a textured corner. -# Calibration reproduced the full loop, including a textured footprint. Cutting the -# pill out of corroboration removed the loop without reducing Jimeng detections, so -# this is a defect fix, not a recall trade. -# -# `_keep_pill` already encodes the same distrust for the pill's own ACTION; this -# closes the gap that its TESTIMONY was never gated. -# Regression: tests/test_watermark_registry.py::TestArbiter:: -# test_weak_pill_detection_does_not_confirm_the_jimeng_wordmark -_CANNOT_CORROBORATE: frozenset[str] = frozenset({"jimeng_pill"}) - - @dataclass(frozen=True) class MarkDetection: """Uniform detection result for a known mark (across heterogeneous engines).""" @@ -317,6 +293,9 @@ class KnownMark: # Optional single-pass dual verdict for the arbiter's perception stage (see # `detect_both`). None = fall back to two `_detect` calls. _detect_both: Callable[..., tuple[MarkDetection, MarkDetection]] | None = None + # False for a weak detector that may be acted on after corroboration but must not + # itself grant a sibling the relaxed trust level. + can_corroborate: bool = True def features(self, image: NDArray[Any]) -> dict[str, float]: """Physical features the mark reports for the arbiter's gate (empty if none).""" @@ -467,6 +446,7 @@ _ENGINE_CLASS: dict[str, tuple[str, str]] = { "runninghub": ("runninghub_engine", "RunningHubEngine"), "baidu": ("baidu_engine", "BaiduEngine"), "liblib": ("liblib_engine", "LibLibEngine"), + "liblib_pill": ("liblib_engine", "LibLibPillEngine"), "microsoft": ("microsoft_engine", "MicrosoftEngine"), } @@ -573,13 +553,9 @@ def _gemini_mask( return _engine("gemini").footprint_mask(image, force=force, region=region) -# The text-mark engines share the TextMarkEngine interface, so one parameterized -# adapter pair drives all of them -- a new -# text mark is one `_text_mark(...)` row below, not another copy-paste of these -# bodies. Detection matches the glyph silhouette; the default mask is the -# template-free glyph-bbox footprint (see TextMarkEngine.footprint_mask), while an -# engine may override it when the mark has a measured safer footprint. -def _text_mark_detect(key: str, label: str, location: str) -> Callable[..., MarkDetection]: +# Engines with the standard detect/detect_both/footprint_mask interface share +# parameterized adapters. Each engine still owns its footprint implementation. +def _engine_mark_detect(key: str, label: str, location: str) -> Callable[..., MarkDetection]: def detect(image: NDArray[Any], *, provenance: bool = False) -> MarkDetection: d = _engine(key).detect(image, provenance=provenance) return MarkDetection(key, label, location, d.detected, d.confidence, d.region, engine_detection=d) @@ -587,7 +563,7 @@ def _text_mark_detect(key: str, label: str, location: str) -> Callable[..., Mark return detect -def _text_mark_detect_both(key: str, label: str, location: str) -> Callable[..., tuple[MarkDetection, MarkDetection]]: +def _engine_mark_detect_both(key: str, label: str, location: str) -> Callable[..., tuple[MarkDetection, MarkDetection]]: def detect_both(image: NDArray[Any]) -> tuple[MarkDetection, MarkDetection]: strict, relaxed = _engine(key).detect_both(image) return ( @@ -602,15 +578,13 @@ def _text_mark_detect_both(key: str, label: str, location: str) -> Callable[..., return detect_both -def _text_mark_mask(key: str) -> Callable[..., NDArray[Any] | None]: +def _engine_mark_mask(key: str) -> Callable[..., NDArray[Any] | None]: def mask( image: NDArray[Any], *, force: bool = False, detection: MarkDetection | None = None ) -> NDArray[Any] | None: - # Thread the engine's OWN detection into the mask builder: the footprint is - # bounded by the ladder sweep the detector already ran, so re-detecting here - # repeated locate + extract_mask + an identical sweep. footprint_mask still - # re-detects when nothing is threaded (a direct or --no-detect caller) and when - # the threaded detection was taken at a relaxed trust level. + # Thread the engine's OWN accepted detection into the mask builder. This avoids + # repeating its scan and preserves the trust verdict selected by the arbiter. + # A direct or --no-detect caller still reaches the engine's fallback path. return _engine(key).footprint_mask( image, force=force, detection=detection.engine_detection if detection is not None else None ) @@ -645,12 +619,12 @@ def _text_mark( product or key, label_regime, platform, - _text_mark_detect(key, label, location), - _text_mark_mask(key), + _engine_mark_detect(key, label, location), + _engine_mark_mask(key), provenance_signals=provenance_signals, tc260_producer_codes=tc260_producer_codes, provenance_platform_tokens=provenance_platform_tokens, - _detect_both=_text_mark_detect_both(key, label, location), + _detect_both=_engine_mark_detect_both(key, label, location), ) @@ -768,6 +742,22 @@ _REGISTRY: tuple[KnownMark, ...] = ( platform="LiblibAI (visible LiblibAI mark detected)", tc260_producer_codes=("91110105MACJ6K1C8A",), ), + KnownMark( + "liblib_pill", + "LiblibAI AI生成 pill", + "top-left", + True, + "liblib", + "tc260", + # The compact pill is generic on its own; the bottom wordmark or LiblibAI + # metadata is what makes the relaxed verdict actionable. + None, + _engine_mark_detect("liblib_pill", "LiblibAI AI生成 pill", "top-left"), + _engine_mark_mask("liblib_pill"), + provenance_signals=("aigc",), + _detect_both=_engine_mark_detect_both("liblib_pill", "LiblibAI AI生成 pill", "top-left"), + can_corroborate=False, + ), # One measured Microsoft visible-mark variant: a white top-right pill with # dark internal shapes. Microsoft's documented feature also permits other # icon, text, and placement variants, which this detector does not cover. @@ -795,6 +785,7 @@ _REGISTRY: tuple[KnownMark, ...] = ( _pill_mask, _pill_features, _detect_both=_pill_detect_both, + can_corroborate=False, ), ) @@ -802,6 +793,17 @@ _REGISTRY: tuple[KnownMark, ...] = ( # edit. See KnownMark.product for why Doubao and Jimeng must not cross-relax. _PRODUCT_OF: dict[str, str] = {m.key: m.product for m in _REGISTRY} +# Weak pill detectors cannot vouch for sibling marks. Letting the Jimeng pill do so +# produced a closed false-positive loop: a clean-image pill fire relaxed the wordmark, +# and that wordmark then bypassed the pill's flatness guard. Keep this policy on each +# registry row so adding another weak companion cannot silently omit the safeguard. +_CANNOT_CORROBORATE: frozenset[str] = frozenset(m.key for m in _REGISTRY if not m.can_corroborate) + + +def _provenance_confirms_product(product: str, provenance: frozenset[str]) -> bool: + """Whether external provenance names any registered mark of ``product``.""" + return any(_PRODUCT_OF.get(key) == product for key in provenance) + def known_marks() -> tuple[KnownMark, ...]: """All registered known visible watermarks.""" @@ -832,8 +834,13 @@ def detect_marks( Returns one MarkDetection per scanned mark (``detected`` flags which fired). ``include_explicit=False`` scans only the ``in_auto`` marks -- the set used by ``--mark auto``. ``provenance`` names the vendor keys that external metadata - already confirms, so each named mark's detector may relax its trust gate.""" - return [m.detect(image, provenance=m.key in provenance) for m in _REGISTRY if include_explicit or m.in_auto] + already confirms, so every mark belonging to that product may relax its trust + gate.""" + return [ + m.detect(image, provenance=_provenance_confirms_product(m.product, provenance)) + for m in _REGISTRY + if include_explicit or m.in_auto + ] def resolve_trust( @@ -847,15 +854,15 @@ def resolve_trust( The single place that turns the ``sensitivity`` policy + evidence into a per-mark level (which the engines consume as ``provenance = level != "strict"``). ``strict`` - never relaxes. A mark is ``confirmed`` only on same-product evidence -- the vendor - confirmed by metadata (``key in provenance``) or a confidently strict-detected - sibling of the same product (``_PRODUCT_OF``, minus the marks too weak to vouch, + never relaxes. A mark is ``confirmed`` only on same-product evidence -- metadata + naming any mark of that product, or a confidently strict-detected sibling + (``_PRODUCT_OF``, minus the marks too weak to vouch, :data:`_CANNOT_CORROBORATE`). Without that evidence a mark stays ``strict``: there is no path that relaxes a gate on anything less than same-product evidence.""" if sensitivity == "strict": return "strict" product = _PRODUCT_OF[key] - confirmed = key in provenance or any( + confirmed = _provenance_confirms_product(product, provenance) or any( _PRODUCT_OF[k] == product for k in strict_keys if k != key and k not in _CANNOT_CORROBORATE ) return "confirmed" if confirmed else "strict" diff --git a/tests/test_api.py b/tests/test_api.py index efdca58..aa6d126 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -175,10 +175,10 @@ class TestVisibleProvenance: keys = set(mark_keys()) assert set(tc260_producer_vendors().values()) <= keys assert keys >= TC260_FALLBACK_VENDORS - # Every TC260 mark should name its producer, or it silently falls back to the - # ByteDance pair on an image carrying that mark. + # Primary TC260 marks name their producer. Companion pills inherit the primary + # mark's product-level provenance rather than duplicating an external identity. unmapped = {m.key for m in known_marks() if m.label_regime == "tc260" and not m.tc260_producer_codes} - assert unmapped == {"jimeng_pill"}, f"TC260 marks with no producer code: {unmapped}" + assert unmapped == {"jimeng_pill", "liblib_pill"}, f"TC260 marks with no producer code: {unmapped}" @pytest.mark.skipif(not CHATGPT.exists(), reason="chatgpt sample not present") def test_openai_image_has_no_visible_vendor(self): diff --git a/tests/test_liblib_engine.py b/tests/test_liblib_engine.py index 227040b..88981f6 100644 --- a/tests/test_liblib_engine.py +++ b/tests/test_liblib_engine.py @@ -1,13 +1,15 @@ """Tests for the LiblibAI ("LiblibAI" wordmark) visible-watermark engine. -Every tuned constant in ``liblib_engine`` was measured on the 15-frame vendor -cohort (2026-07-22); these tests pin the load-bearing ones: the bottom-CENTER -anchor, the strict-only gate, and the match-box footprint (the blob bbox both -bled into background structure and did not own the triangle logo). +The historical wordmark constants were measured on the 15-frame vendor cohort +(2026-07-22); these tests pin the bottom-CENTER anchor, strict-only gate, and +match-box footprint. They also cover the newer corroboration-only compact pill +against its cleared provider output and a synthetic wordmark-plus-pill pair. """ from __future__ import annotations +from pathlib import Path + import cv2 import numpy as np import pytest @@ -16,11 +18,17 @@ from remove_ai_watermarks import watermark_registry as registry from remove_ai_watermarks.liblib_engine import ( _ALPHA_HEIGHT_FRAC, _ALPHA_WIDTH_FRAC, + _TOP_LEFT_PILL_MIN_CONFIDENCE, LibLibEngine, _alpha_template, ) +from remove_ai_watermarks.pill_engine import PillEngine +from scripts.render_visible_examples import stamp_image_mark _MARK_FRAC = 0.10 # measured wordmark width, fraction of the frame WIDTH +_CURRENT_PROVIDER_OUTPUT = ( + Path(__file__).resolve().parents[1] / "data" / "fixtures" / "visible" / "liblib" / "current-provider-output.png" +) def _compose(w: int, h: int, bg: float = 100.0): @@ -42,6 +50,14 @@ def _compose(w: int, h: int, bg: float = 100.0): return wm, (ax, ay, gw, gh, lx0) +def _compose_with_compact_pill(w: int = 1200, h: int = 1200) -> np.ndarray: + """Add the compact top-left AI-generated pill used beside the LiblibAI mark.""" + image, _ = _compose(w, h, bg=180.0) + stamped = stamp_image_mark("liblib_pill", image, alpha_mult=0.5) + assert stamped is not None + return stamped[0] + + class TestLocate: def test_box_horizontally_centered(self): eng = LibLibEngine() @@ -79,6 +95,9 @@ class TestConfig: mark = registry.get_mark("liblib") assert mark.location == "bottom-center" assert mark.in_auto + pill = registry.get_mark("liblib_pill") + assert pill.location == "top-left" + assert pill.product == mark.product class TestDetectAndMask: @@ -106,6 +125,38 @@ class TestDetectAndMask: assert xs.max() >= ax + gw - int(0.05 * gw) # covers the wordmark's right edge assert ys.min() >= ay - gh # does not bleed far above the mark + def test_mask_also_covers_a_compact_top_left_pill(self): + image = _compose_with_compact_pill() + pill = PillEngine().detect(image) + assert not pill.detected, "the fixture must reproduce the strict pill-detector miss" + assert pill.confidence >= _TOP_LEFT_PILL_MIN_CONFIDENCE + + report = registry.remove_auto_marks_detailed(image, backend="cv2") + + assert [mark.key for mark in report.marks] == ["liblib", "liblib_pill"] + bottom, top = report.marks + assert bottom.mask_bbox[1] > image.shape[0] // 2 + assert top.mask_bbox[1] < image.shape[0] // 4 + assert top.mask_bbox[3] < image.shape[0] // 4 + strict_report = registry.remove_auto_marks_detailed(image, sensitivity="strict", backend="cv2") + assert [mark.key for mark in strict_report.marks] == ["liblib"] + + def test_current_provider_pill_is_removed_with_liblib_metadata(self): + from remove_ai_watermarks import api, image_io + + image = image_io.imread(_CURRENT_PROVIDER_OUTPUT, cv2.IMREAD_UNCHANGED) + provenance = api.visible_provenance(_CURRENT_PROVIDER_OUTPUT) + strict = registry.get_mark("liblib_pill").detect(image) + corroborated = registry.get_mark("liblib_pill").detect(image, provenance=True) + + assert provenance == frozenset({"liblib"}) + assert not strict.detected + assert corroborated.detected + detected = {d.key for d in registry.detect_marks(image, provenance=provenance) if d.detected} + assert detected == {"liblib_pill"} + report = registry.remove_auto_marks_detailed(image, provenance=provenance, backend="cv2") + assert [(mark.key, mark.status) for mark in report.marks] == [("liblib_pill", "cleaned")] + def test_no_mask_on_clean_frame(self): eng = LibLibEngine() img = np.full((2400, 1792, 3), 100, np.uint8) diff --git a/tests/test_visible_examples.py b/tests/test_visible_examples.py index 541e53d..b9498bf 100644 --- a/tests/test_visible_examples.py +++ b/tests/test_visible_examples.py @@ -51,7 +51,7 @@ class TestGallery: def test_engine_detects_its_own_example(self, key: str) -> None: img = imread(str(_GALLERY / key / "example.png")) assert img is not None, key - det = wr.get_mark(key).detect(img, provenance=False) + det = wr.get_mark(key).detect(img, provenance=key == "liblib_pill") assert det.detected, f"{key}: confidence {det.confidence:.3f} on its own example" def test_gallery_has_no_stray_directories(self) -> None: diff --git a/tests/test_visible_fill_quality.py b/tests/test_visible_fill_quality.py index 6242c1c..4b84eb8 100644 --- a/tests/test_visible_fill_quality.py +++ b/tests/test_visible_fill_quality.py @@ -27,6 +27,7 @@ _ALTERNATE_SIZE = { "runninghub": (1280, 960), "baidu": (1280, 960), "liblib": (960, 1280), + "liblib_pill": (960, 1280), "microsoft": (1280, 960), "jimeng_pill": (960, 1280), } @@ -102,7 +103,8 @@ def test_cv2_mask_quality_smoke(key: str, case: str) -> None: assert np.any(changed), key mark = wr.get_mark(key) - before = mark.detect(marked, provenance=False) + corroborated = key == "liblib_pill" + before = mark.detect(marked, provenance=corroborated) assert before.detected, f"{key}/{case}: confidence {before.confidence:.3f}" localization = mark.localize(marked, force=False, detection=before) assert localization.mask is not None, f"{key}/{case}: no mask" @@ -114,7 +116,7 @@ def test_cv2_mask_quality_smoke(key: str, case: str) -> None: assert region is not None, key assert np.array_equal(filled[localization.mask == 0], marked[localization.mask == 0]), key - after = mark.detect(filled, provenance=False) + after = mark.detect(filled, provenance=corroborated) assert not after.detected, f"{key}/{case}: residual confidence {after.confidence:.3f}" filled_psnr, filled_ssim = _score_box(clean, filled, changed) assert filled_psnr >= _MIN_FILLED_PSNR, f"{key}/{case}: PSNR {filled_psnr:.2f} dB" diff --git a/tests/test_watermark_registry.py b/tests/test_watermark_registry.py index 7c4e737..6b75bc5 100644 --- a/tests/test_watermark_registry.py +++ b/tests/test_watermark_registry.py @@ -25,6 +25,7 @@ class TestCatalog: "runninghub", "baidu", "liblib", + "liblib_pill", "microsoft", "jimeng_pill", ] @@ -46,6 +47,7 @@ class TestCatalog: assert by_key["jimeng"].location == "bottom-right" assert by_key["yuanbao"].location == "bottom-right" assert by_key["samsung"].location == "bottom-left" + assert by_key["liblib_pill"].location == "top-left" assert by_key["jimeng_pill"].location == "top-left" def test_get_mark_unknown_raises(self): @@ -114,6 +116,7 @@ class TestScan: "runninghub", "baidu", "liblib", + "liblib_pill", "microsoft", "jimeng_pill", } @@ -406,6 +409,18 @@ class TestSensitivity: == "confirmed" ) + def test_auto_relaxes_liblib_pill_via_wordmark(self): + assert ( + reg.resolve_trust("liblib_pill", sensitivity="auto", provenance=frozenset(), strict_keys={"liblib"}) + == "confirmed" + ) + + def test_auto_relaxes_liblib_pill_via_product_metadata(self): + assert ( + reg.resolve_trust("liblib_pill", sensitivity="auto", provenance=frozenset({"liblib"}), strict_keys=set()) + == "confirmed" + ) + def test_auto_no_cross_mark_across_products(self): # a detected Jimeng wordmark must NOT relax Doubao (distinct products, same corner) assert ( @@ -563,6 +578,12 @@ class TestArbiter: # removal of a textured footprint on content nothing confirmed. assert "jimeng_pill" not in fired + def test_weak_liblib_pill_does_not_confirm_the_wordmark(self): + assert ( + reg.resolve_trust("liblib", sensitivity="auto", provenance=frozenset(), strict_keys={"liblib_pill"}) + == "strict" + ) + def test_real_jimeng_wordmark_still_corroborates_the_pill(self): """The fix removes only the pill's TESTIMONY, not the wordmark's.""" cands = [ @@ -655,10 +676,14 @@ class TestMarkKnowledgeIsOnTheRow: assert {m.key: m.platform for m in reg.known_marks() if m.platform is not None} == _VISIBLE_MARK_PLATFORM - def test_the_platformless_marks_are_the_two_with_their_own_paths(self): + def test_platformless_marks_do_not_attribute_a_product_on_their_own(self): """Gemini has the higher-confidence sparkle path; the pill is too weak to attribute. Everything else must name a platform or `identify` reports none.""" - assert {m.key for m in reg.known_marks() if m.platform is None} == {"gemini", "jimeng_pill"} + assert {m.key for m in reg.known_marks() if m.platform is None} == { + "gemini", + "jimeng_pill", + "liblib_pill", + } def test_platform_scan_order_follows_the_registry(self): """`identify` takes the FIRST platform match, so the order is load-bearing and @@ -717,6 +742,7 @@ class TestPillSuppressors: def test_product_map_is_derived_from_the_rows(self): assert {m.key: m.product for m in reg.known_marks()} == reg._PRODUCT_OF assert reg._PRODUCT_OF["jimeng_pill"] == "jimeng" # the one shared product + assert {m.key for m in reg.known_marks() if not m.can_corroborate} == reg._CANNOT_CORROBORATE class TestProvenanceMaskThreading: