From 0f54c6b54d9855f0b51a6f8bcc49e72f40ec007d Mon Sep 17 00:00:00 2001 From: Victor Kuznetsov Date: Fri, 26 Jun 2026 09:44:24 -0700 Subject: [PATCH] fix(identify): kill three visible-detector false positives Bright-background photos/renders and a tiny app icon were flagged as AI-generated by the visible detectors. Two failure modes: - Gemini sparkle on a bright background (snow+sky photo, white product render) scored ~0.51. The FP gate only demoted on a low core-ring brightness margin, which a bright background makes high. Add a gradient floor (_SPARKLE_FP_GRAD 0.55): a real sparkle is a crisp star (grad ~0.97-1.0), a smooth luminance blob that NCC-matches the diamond is not (the two FPs measured grad 0.105 / 0.463). The OR is a strict superset of the old margin-only demotion, so it cannot regress dark/mid (kept by margin) or white-bg (kept by confidence) real sparkles. - A 48x48 geometric icon matched the Doubao/Jimeng CJK silhouette at 0.41/0.47 NCC. Purely a small-size artifact (the same icon at >=256px collapses to ~0.06-0.10). Guard text-mark detection below a 200px short side (_MIN_DETECT_SHORT_SIDE); real marks ship on full-resolution renders (smallest captured sample 1086px). Corpus re-sweep flips only OpenAI content and already-cleaned outputs, all sub-0.5, so no provenance verdict changes. Add synthetic regression fixtures for both modes; docs/module-internals.md updated. Co-Authored-By: Claude Opus 4.8 --- docs/module-internals.md | 4 +-- src/remove_ai_watermarks/_text_mark_engine.py | 24 +++++++++++++ src/remove_ai_watermarks/gemini_engine.py | 35 +++++++++++++++---- tests/test_doubao_engine.py | 18 ++++++++++ tests/test_gemini_engine.py | 32 +++++++++++++++++ tests/test_jimeng_engine.py | 10 ++++++ tests/test_samsung_engine.py | 10 ++++++ 7 files changed, 125 insertions(+), 8 deletions(-) diff --git a/docs/module-internals.md b/docs/module-internals.md index 14644e7..53d38e8 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -91,7 +91,7 @@ The cost (mislabel ~8-33% of non-Gemini content as Gemini) outweighs the benefit **Under-subtraction (the symmetric case, fixed 2026-06-03):** some real Gemini sparkles are rendered MORE opaque than the captured ~0.51, so the fixed-alpha reverse blend UNDER-subtracts and leaves a bright sparkle residual the detector still fires on (measured on the spaces corpus: a visible-removal audit through the registry path left a detectable sparkle on a meaningful fraction of marks, all under-removals, NOT a background-brightness class — failures and successes had the same input confidence and the same background-luma distribution; the discriminator was the removal delta itself). `remove_watermark` now estimates a per-image alpha gain (`_estimate_alpha_gain`: effective sparkle opacity at the bright core vs the local background ring, `a_eff/a_cap`, clamped `[1.0, _ALPHA_GAIN_MAX` 1.94`]`) and scales the alpha to match before the over-sub/blend branch. The gain cleanly separates on the corpus (under-removed marks ~1.47, cleanly-removed ~1.00), and a deadband (`_ALPHA_GAIN_DEADBAND` 1.05) keeps a matching sparkle **byte-identical** to the pre-fix output, so the fix is purely additive (0 regressions on the audit set; the over-sub guard still runs on the scaled alpha as the safety net for an over-shooting estimate). Regression-guarded by `test_gemini_engine.py::TestUnderSubtractionGain` (composites a more-opaque-than-capture sparkle; **asserts on footprint pixels, NOT the detector** — the detector's NCC is degenerate on a flat synthetic background, so a re-detect conf is meaningless there; the real corpus removal drops the detector from ~0.80 to ~0.27). -**False-positive gate (added 2026-06-03):** `detect_watermark`'s shape-only NCC (`spatial*0.5 + gradient*0.3 + var*0.2`) fires on ornate/flat content (text strips, banners, hatching) that coincidentally matches the diamond shape — a real Gemini sparkle is a bright WHITE overlay, so its core sits above the local background, but the NCC is contrast-invariant and cannot see that. The fusion now **demotes** (caps confidence to 0.30) any match that is BOTH low-confidence (`< _SPARKLE_FP_CONF` 0.65) AND has a low core-ring brightness margin (`_core_ring_margin < _SPARKLE_FP_MARGIN` 5). Real sparkles escape via EITHER high confidence (white-bg sparkles score ≥0.79 despite a low margin — the NCC shape match is strong) OR high margin (dark/mid backgrounds, incl. the #36 faint-corner case, lift well clear), so BOTH must fail to demote. The gate is **monotonic** (only ever removes detections, never adds), so it cannot regress the verified-negative corpus (already 0 FPs). On the spaces corpus it demoted 16/495 flagged sparkles (13 carried no AI metadata = content FPs; the 3 AI-meta were visually FPs / a near-invisible white-on-white sparkle whose AI verdict is held by metadata anyway), and dropped the removal-audit failures 20→15 (post-removal flat footprints the NCC re-fired on). `_core_ring_margin` and `_estimate_alpha_gain` share the `_core_and_bg` helper (core 75th-pct brightness vs background-ring median). Regression-guarded by `test_gemini_engine.py::TestSparkleFalsePositiveGate`. +**False-positive gate (added 2026-06-03):** `detect_watermark`'s shape-only NCC (`spatial*0.5 + gradient*0.3 + var*0.2`) fires on ornate/flat content (text strips, banners, hatching) that coincidentally matches the diamond shape — a real Gemini sparkle is a bright WHITE overlay, so its core sits above the local background, but the NCC is contrast-invariant and cannot see that. The fusion now **demotes** (caps confidence to 0.30) any low-confidence (`< _SPARKLE_FP_CONF` 0.65) match that shows NEITHER real-sparkle signature: a bright core (`_core_ring_margin >= _SPARKLE_FP_MARGIN` 5) OR a crisp star silhouette (`gradient_score >= _SPARKLE_FP_GRAD` 0.55). I.e. demote when `low_margin OR low_grad`. Real sparkles escape via high confidence (white-bg sparkles score ≥0.79 despite a low margin — the NCC shape match is strong), high margin (dark/mid backgrounds, incl. the #36 faint-corner case, lift well clear), OR high gradient (a real sparkle is grad ~0.97–1.0). **The gradient condition (added 2026-06-26) closes the bright-background FP class** the margin check alone missed: a snow+sky photo and a white-background product render both scored ~0.51 at `identify`, because a bright background gives the match a HIGH core-ring margin (it genuinely IS brighter than its surroundings), so the brightness gate read it as a real overlay — but a smooth luminance blob that shape-NCC-matches the rough diamond has low gradient fidelity (the two FPs measured grad 0.105 and 0.463 vs ≥0.8 for real sparkles), so the gradient floor demotes them. The OR is **strictly a superset** of the old margin-only demotion (it only ADDS demotions on bright backgrounds, where a real sparkle keeps grad ~0.97), so it cannot regress a dark/mid sparkle (kept by margin) or a white-bg one (kept by confidence ≥ 0.65). The gate is **monotonic** (only ever removes detections, never adds), so it cannot regress the verified-negative corpus (already 0 FPs); the 2026-06-26 corpus re-sweep flipped only OpenAI/ChatGPT content (no Gemini sparkle exists there) and already-`cleaned/` outputs, all sub-0.5 (below the `identify` threshold), so no provenance verdict changed. On the spaces corpus the original gate demoted 16/495 flagged sparkles (13 carried no AI metadata = content FPs; the 3 AI-meta were visually FPs / a near-invisible white-on-white sparkle whose AI verdict is held by metadata anyway), and dropped the removal-audit failures 20→15 (post-removal flat footprints the NCC re-fired on). `_core_ring_margin` and `_estimate_alpha_gain` share the `_core_and_bg` helper (core 75th-pct brightness vs background-ring median). Regression-guarded by `test_gemini_engine.py::TestSparkleFalsePositiveGate` (incl. `test_bright_background_low_gradient_match_demoted`). **Self-verify repair (added 2026-06-04):** the gain estimate corrects most under-subtractions, but a tail of strong sparkles still survived reverse-alpha (position jitter, or a gain the `[1.0, 1.94]` clamp could not fully reach). After the reverse blend, `remove_watermark` re-detects via `_verify_and_repair`; when a sparkle at or above `_VERIFY_FALLBACK_CONF` 0.5 (the registry's real fail line) remains, it inpaints the footprint and **keeps that only when it lowers the re-detect confidence** — purely additive (the common clean removal re-detects below 0.5 and is returned untouched, so it can never regress). On the spaces corpus this rescued **4 of the 15 remaining gemini removal-audit failures** (15→11, doubao/jimeng still 0), verified through the registry/CLI path. Costs one extra `detect_watermark` per removal (two when the fallback fires). Regression-guarded by `test_gemini_engine.py::TestVerifyAndRepair` (stubs `detect_watermark` to drive the keep-best control flow, since the NCC is degenerate on flat synthetics). @@ -105,7 +105,7 @@ The 11 survivors are near-white ill-conditioning (reverse-alpha divides by `1-a` ## `_text_mark_engine.py` -`_text_mark_engine.py` — **shared base for the three reverse-alpha text-mark engines (Doubao/Jimeng/Samsung), extracted 2026-06-09** (they were ~90% byte-identical clones). `TextMarkEngine(config: TextMarkConfig)` owns the whole `locate → extract_mask → detect → _fixed/_aligned_alpha_map → _apply_reverse_alpha → remove_watermark_reverse_alpha` pipeline (+ the asset-keyed `load_alpha_template`/`glyph_silhouette`/`template_match_score` caches). Each engine module is now a thin subclass: it supplies only its `TextMarkConfig` (the tuned constants, the bundled asset, and the bounded structural deltas — `corner` br/bl, `margin_floor` 4/2, `morph_open_size` 5/3, `min_gw` 8/16) plus the test-facing module shims (`_alpha_template`/`_glyph_silhouette`/`_template_match_score` + the constants). Behavior is byte-exact vs the old per-engine code (the three engine test suites pass unchanged). Gemini stays a SEPARATE engine (its multi-size fixed-slot sparkle model is genuinely different). Add a new text mark = a new `TextMarkConfig` + a thin subclass + one registry `_text_mark(...)` row. The engine bullets below describe each mark's calibration history; the LOGIC lives here. +`_text_mark_engine.py` — **shared base for the three reverse-alpha text-mark engines (Doubao/Jimeng/Samsung), extracted 2026-06-09** (they were ~90% byte-identical clones). `TextMarkEngine(config: TextMarkConfig)` owns the whole `locate → extract_mask → detect → _fixed/_aligned_alpha_map → _apply_reverse_alpha → remove_watermark_reverse_alpha` pipeline (+ the asset-keyed `load_alpha_template`/`glyph_silhouette`/`template_match_score` caches). Each engine module is now a thin subclass: it supplies only its `TextMarkConfig` (the tuned constants, the bundled asset, and the bounded structural deltas — `corner` br/bl, `margin_floor` 4/2, `morph_open_size` 5/3, `min_gw` 8/16) plus the test-facing module shims (`_alpha_template`/`_glyph_silhouette`/`_template_match_score` + the constants). Behavior is byte-exact vs the old per-engine code (the three engine test suites pass unchanged). Gemini stays a SEPARATE engine (its multi-size fixed-slot sparkle model is genuinely different). Add a new text mark = a new `TextMarkConfig` + a thin subclass + one registry `_text_mark(...)` row. The engine bullets below describe each mark's calibration history; the LOGIC lives here. **Small-image detection guard (`_MIN_DETECT_SHORT_SIDE` 200, added 2026-06-26):** `detect` returns not-detected when the image short side is below 200px. Below that the glyph template degrades to the `min_gw` floor (~8px) and `TM_CCOEFF_NORMED` on a few pixels is noise, so an unrelated small geometric shape can spuriously correlate with the CJK silhouette — a 48×48 app-icon chevron scored Doubao 0.41 / Jimeng 0.47 (both above their thresholds), a pure small-size artifact (the same icon upscaled collapses to ~0.06–0.10 NCC at ≥256px). A real AI-generation label is stamped on a full-resolution render (the captured samples are 1086–2048px wide, the smallest positive test image is 1086px), so the floor sits far below any genuine mark while killing the icon/thumbnail band (≤96px); `identify` falls back to "unknown" (the safe default) and removal, gated on detection, is suppressed too. Regression-guarded by `test_{doubao,jimeng,samsung}_engine.py::TestDetect::test_small_image_guarded_from_false_positive`. **`_apply_reverse_alpha` runs on the glyph crop only:** the blend is a no-op outside the glyph `region` (x, y, w, h) (`(wm - 0)/(1 - 0) == wm`, and a uint8→float32→uint8 round-trip is exact). It copies the frame through and computes the reverse-alpha math on the `region` crop only — byte-identical to the old full-frame pass (verified: Doubao 130 + Jimeng 22 placements, 0 mismatches) but O(glyph) not O(image). The full-frame pass cost ~275 ms on a 12 MP frame for a glyph that is <0.1% of it, once per candidate placement (fixed + aligned ≈ 2×/removal); the crop drops that to ~2 ms. Mirror of the Gemini `_core_and_bg` crop. diff --git a/src/remove_ai_watermarks/_text_mark_engine.py b/src/remove_ai_watermarks/_text_mark_engine.py index 226a3f2..36b61ad 100644 --- a/src/remove_ai_watermarks/_text_mark_engine.py +++ b/src/remove_ai_watermarks/_text_mark_engine.py @@ -59,6 +59,19 @@ _OVERSUB_BODY_ALPHA_FLOOR = 0.15 # alpha above which a block pixel counts as gl _OVERSUB_INPAINT_DILATE = 9 _OVERSUB_INPAINT_RADIUS = 4 +# Minimum image short side (px) for text-mark DETECTION. Below this the glyph +# template degrades to the ``min_gw`` floor (~8 px) and TM_CCOEFF_NORMED on a few +# pixels is noise, so an unrelated small geometric shape can spuriously correlate +# with the CJK silhouette (2026-06-26 FP: a 48x48 app icon -- a blue chevron -- +# scored Doubao 0.41 / Jimeng 0.47, both above their thresholds). The FP is purely +# a small-size artifact: the same icon upscaled collapses to ~0.06-0.10 NCC at 256 +# px and above. A real AI-generation text label is stamped on a full-resolution +# render (the captured samples are 1086-2048 px wide), so 200 px sits far below any +# genuine mark while killing the icon/thumbnail noise band (<=96 px). Detection is +# skipped (verdict stays "unknown", the safe default) rather than risk a false +# positive; removal is gated on detection, so it is suppressed too. +_MIN_DETECT_SHORT_SIDE = 200 + @dataclass(frozen=True) class TextMarkConfig: @@ -256,6 +269,17 @@ class TextMarkEngine: det = TextMarkDetection() if image is None or image.size == 0: return det + # Guard against the small-image NCC-noise false positive (see + # _MIN_DETECT_SHORT_SIDE): an icon/thumbnail is too small to carry a real + # text label, and the degraded few-pixel template spuriously correlates. + if min(image.shape[:2]) < _MIN_DETECT_SHORT_SIDE: + logger.debug( + "%s detect: image short side %d < %d; too small to carry the mark, skipping.", + c.name, + min(image.shape[:2]), + _MIN_DETECT_SHORT_SIDE, + ) + return det loc = self.locate(image) box = self.extract_mask(image, loc) # box-sized mask (== old full-frame cropped to bbox) _x, _y, bw, bh = loc.bbox diff --git a/src/remove_ai_watermarks/gemini_engine.py b/src/remove_ai_watermarks/gemini_engine.py index 97385f6..e433098 100644 --- a/src/remove_ai_watermarks/gemini_engine.py +++ b/src/remove_ai_watermarks/gemini_engine.py @@ -185,6 +185,21 @@ class GeminiEngine: # fail to demote. _SPARKLE_FP_CONF = 0.65 _SPARKLE_FP_MARGIN = 5.0 + # Bright-background content false positives (2026-06-26 landing-page FPs: a snow+sky + # photo and a white-background product render both scored ~0.51). The margin gate + # above cannot catch them -- a bright background gives the "core" a HIGH core-ring + # margin (it is genuinely brighter than its surroundings), so the brightness check + # reads it as a real overlay. The discriminating signature is the GRADIENT NCC: a + # real white sparkle is a crisp star silhouette (grad ~0.97-1.0 on the synthetic + # composites, ~0.96 on the real #36 corner sparkle), while a smooth luminance blob + # that shape-NCC-matches the rough outline has low gradient fidelity (the two FPs + # measured 0.105 and 0.463). So ALSO demote a low-confidence match whose gradient + # NCC is below this floor, regardless of margin -- 0.55 sits well above the worst FP + # (0.463) and far below every real sparkle (>=0.8). This only ADDS demotions on + # bright backgrounds (a real bright-bg sparkle keeps grad ~0.97), so it cannot + # regress a dark/mid sparkle (already kept by margin) or a white-bg one (kept by + # confidence >= 0.65, above the gate). + _SPARKLE_FP_GRAD = 0.55 # Self-verify fallback. The gain estimate corrects most under-subtractions, but # on the spaces corpus a tail of strong sparkles still survived reverse-alpha @@ -399,16 +414,24 @@ class GeminiEngine: # best_fused is the selected candidate's spatial*0.5 + grad*0.3 + var*0.2. confidence = best_fused - # False-positive gate: a low-confidence shape match whose core is NOT brighter - # than its surroundings is a content false positive, not a white sparkle overlay. + # False-positive gate: a low-confidence match that shows NEITHER real-sparkle + # signature is a content false positive, not a white sparkle overlay. A real + # sparkle proves itself by a bright core (high core-ring margin, on dark/mid + # backgrounds) OR a crisp star silhouette (high gradient NCC, on any background + # incl. bright). Demote when both are weak -- this catches the dark/mid no-core + # FP (low margin) AND the bright-background smooth-blob FP (high margin but low + # gradient), which the margin check alone misses. See _SPARKLE_FP_GRAD. if confidence < self._SPARKLE_FP_CONF: margin = self._core_ring_margin(image, self.get_interpolated_alpha(best_scale), (pos_x, pos_y)) - if margin is not None and margin < self._SPARKLE_FP_MARGIN: + low_margin = margin is not None and margin < self._SPARKLE_FP_MARGIN + low_grad = grad_score < self._SPARKLE_FP_GRAD + if low_margin or low_grad: logger.debug( - "Sparkle FP gate: conf=%.3f, core-ring margin=%.1f < %.1f; demoting.", + "Sparkle FP gate: conf=%.3f, core-ring margin=%s, grad=%.3f < %.2f; demoting.", confidence, - margin, - self._SPARKLE_FP_MARGIN, + f"{margin:.1f}" if margin is not None else "n/a", + grad_score, + self._SPARKLE_FP_GRAD, ) confidence = min(confidence, 0.30) diff --git a/tests/test_doubao_engine.py b/tests/test_doubao_engine.py index d4d1787..d6013cc 100644 --- a/tests/test_doubao_engine.py +++ b/tests/test_doubao_engine.py @@ -76,6 +76,24 @@ class TestDetect: 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 minimum short side a tiny geometric shape spuriously NCC-matches + the CJK silhouette (2026-06-26 FP: a 48x48 app-icon chevron scored 0.41). The + size guard suppresses detection there. Bracket it: a real mark is detected at + native size, but the same content downscaled below the guard is not.""" + w = h = _ALPHA_NATIVE_WIDTH + at = _alpha_template() + gw, gh = int(_ALPHA_WIDTH_FRAC * w), int(_ALPHA_HEIGHT_FRAC * w) + ax = w - int(_ALPHA_MARGIN_RIGHT_FRAC * w) - gw + ay = h - int(_ALPHA_MARGIN_BOTTOM_FRAC * w) - gh + amap = np.zeros((h, w), np.float32) + amap[ay : ay + gh, ax : ax + gw] = cv2.resize(at, (gw, gh)) + a3 = amap[:, :, None] + wm = (a3 * np.array(_ALPHA_LOGO_BGR, np.float32) + (1 - a3) * 100.0).clip(0, 255).astype(np.uint8) + eng = DoubaoEngine() + assert eng.detect(wm).detected # native: real mark detected + assert not eng.detect(cv2.resize(wm, (150, 150))).detected # below guard: suppressed + @pytest.mark.skipif(not SAMPLE.exists(), reason="sample image not present") class TestRealSample: diff --git a/tests/test_gemini_engine.py b/tests/test_gemini_engine.py index 4aa64cb..7e13707 100644 --- a/tests/test_gemini_engine.py +++ b/tests/test_gemini_engine.py @@ -514,6 +514,38 @@ class TestSparkleFalsePositiveGate: assert det.confidence < 0.5 assert not det.detected + def test_bright_background_low_gradient_match_demoted(self): + """Bright-background content FP (2026-06-26 landing-page reports: a snow+sky + photo and a white product render scored ~0.51). A bright background gives the + match a HIGH core-ring margin, so the margin gate cannot demote it -- but the + smooth blob lacks the crisp star edges of a real sparkle, so its GRADIENT NCC + is low. The gradient gate (``_SPARKLE_FP_GRAD``) demotes it. Reproduces the + regime with a heavily-blurred bright sparkle: high margin, low gradient, and a + pre-gate fusion above the 0.5 promote bar (so it WOULD have been detected). + """ + size = 1400 + config = get_watermark_config(size, size) + x, y = config.get_position(size, size) + alpha = self.engine.get_alpha_map(WatermarkSize.LARGE) + ah, aw = alpha.shape[:2] + img = np.full((size, size, 3), 110, dtype=np.float32) + a = np.clip(alpha * 1.6, 0.0, 1.0)[:, :, None] + img[y : y + ah, x : x + aw] = a * 255.0 + (1.0 - a) * img[y : y + ah, x : x + aw] + img = cv2.GaussianBlur(np.clip(img, 0, 255).astype(np.uint8), (39, 39), 0) + det = self.engine.detect_watermark(img) + # Pre-gate fusion clears the 0.5 promote bar (would have been detected)... + pre = det.spatial_score * 0.5 + det.gradient_score * 0.3 + det.variance_score * 0.2 + assert pre > 0.5 + # ...the OLD margin gate cannot catch it (bright background -> high margin)... + margin = self.engine._core_ring_margin(img, self.engine.get_interpolated_alpha(det.region[2]), det.region[:2]) + assert margin is not None + assert margin >= self.engine._SPARKLE_FP_MARGIN + # ...but the gradient is low (smooth blob, not a crisp star)... + assert det.gradient_score < self.engine._SPARKLE_FP_GRAD + # ...so the gradient gate demotes it below the detection bar. + assert det.confidence < 0.5 + assert not det.detected + class TestCornerPromotion: """Issue #36: a small sparkle in the corner must not be lost to a larger decoy. diff --git a/tests/test_jimeng_engine.py b/tests/test_jimeng_engine.py index 587f9c1..53ca8c2 100644 --- a/tests/test_jimeng_engine.py +++ b/tests/test_jimeng_engine.py @@ -86,6 +86,16 @@ class TestDetect: 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 minimum short side a tiny geometric shape spuriously NCC-matches + the CJK silhouette (2026-06-26 FP: a 48x48 app-icon chevron scored 0.47). The + size guard suppresses detection there. Bracket it: a real mark is detected at + native size, but the same content downscaled below the guard is not.""" + wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH) + eng = JimengEngine() + assert eng.detect(wm).detected # native: real mark detected + assert not eng.detect(cv2.resize(wm, (150, 150))).detected # below guard: suppressed + def test_synthetic_mark_detected(self): """A watermark composed from the real alpha is detected at its threshold.""" eng = JimengEngine() diff --git a/tests/test_samsung_engine.py b/tests/test_samsung_engine.py index bebc6ee..e5172a2 100644 --- a/tests/test_samsung_engine.py +++ b/tests/test_samsung_engine.py @@ -87,6 +87,16 @@ class TestDetect: 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 minimum short side a tiny geometric shape spuriously NCC-matches + the glyph silhouette (the 2026-06-26 small-icon FP class). The size guard + suppresses detection there. Bracket it: a real mark is detected at native + size, but the same content downscaled below the guard is not.""" + wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, int(_ALPHA_NATIVE_WIDTH * 1.33)) + eng = SamsungEngine() + assert eng.detect(wm).detected # native: real mark detected + assert not eng.detect(cv2.resize(wm, (150, 150))).detected # below guard: suppressed + def test_synthetic_mark_detected(self): """A watermark composed from the real alpha is detected at its threshold.""" eng = SamsungEngine()