mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-07-04 23:47:49 +02:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user