Mask faint text marks the tophat front-end detects but binarization loses

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Victor Kuznetsov
2026-07-20 10:57:08 -07:00
co-authored by Claude Opus 4.8
parent c150180acf
commit 836d87ed68
3 changed files with 105 additions and 1 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
@@ -90,6 +90,13 @@ _MIN_DETECT_SHORT_SIDE = 200
# threshold repairs that -- it needs a better detection silhouette.
_DEFAULT_PROVENANCE_NCC_FACTOR = 0.7
# Level (fraction of the response's own peak) at which the CONTINUOUS top-hat is
# turned into a glyph blob, used only when the binarized path found nothing on a
# mark the detector did fire on. Half the peak keeps the stroke cores and drops the
# halo; the enclosing rectangle is what gets filled anyway, so this only has to be
# good enough to BOUND the mark, not to segment it.
_FAINT_GLYPH_LEVEL = 0.5
@dataclass(frozen=True)
class TextMarkConfig:
@@ -550,6 +557,19 @@ class TextMarkEngine:
bx, by, bw, bh = loc.bbox
glyph = self.extract_mask(image, loc) # box-sized, 255 = glyph
ys, xs = np.where(glyph > 0)
faint = xs.size < self._MIN_GLYPH_PIXELS and self.config.detect_frontend == "tophat"
# A mark found only by the CONTINUOUS front-end has no binary glyph blob to bound,
# so the mask came back empty and removal was a silent no-op while `identify` still
# reported the mark (corpus-measured 2026-07-20: 57 of 60 sampled still-detected
# Doubao marks were untouched, ~8% of its detections). Fall back to the same
# response the DETECTOR scored, thresholded relative to its own peak. Gated on an
# actual detection: the response is max-normalized, so on a clean corner it would
# normalize NOISE up to 1.0 and mask a random patch -- the detector's verdict is
# what separates signal from noise here.
if faint and self.detect(image).detected:
resp = self.tophat_response(image, loc)
if resp is not None:
ys, xs = np.where(resp >= _FAINT_GLYPH_LEVEL)
if xs.size >= self._MIN_GLYPH_PIXELS:
pad = max(4, int(0.10 * bh))
rx1 = max(0, bx + int(xs.min()) - pad)
+84
View File
@@ -0,0 +1,84 @@
"""A mark the `tophat` front-end DETECTS must also be MASKABLE.
Corpus-found 2026-07-20: Doubao's detection moved to the continuous `tophat` front-end
(which does not binarize, and that is where its recall 89% -> 92% came from), but the
removal mask still came from the BINARIZED glyph blob. A mark faint enough to be found
only by the continuous response therefore produced an empty binary blob, `localize`
returned mask=None, and `remove()` was a silent no-op: `identify` reported
`visible_doubao` while `visible` said "no visible mark" on the same file. Measured on the
full corpus parity sweep: 57 of 60 sampled still-detected Doubao marks were untouched
no-ops, ~8% of all Doubao detections.
"""
from __future__ import annotations
import cv2
import numpy as np
import pytest
from remove_ai_watermarks.doubao_engine import DoubaoEngine
def _faint_mark_image(w: int = 900, h: int = 1200, alpha: float = 0.06) -> np.ndarray:
"""A mid-gray frame carrying the REAL Doubao glyph shape at very low opacity.
The shape has to be genuine or the NCC detector will not fire and the test would be
exercising nothing; the low ``alpha`` is what keeps the binarizing path from finding
a blob. Composited with the same forward model the marks use:
``stamped = (1-a)*bg + a*white``.
"""
from remove_ai_watermarks._text_mark_engine import load_alpha_template
tmpl = load_alpha_template("doubao_alpha.png")
if tmpl is None:
pytest.skip("doubao alpha asset unavailable")
img = np.full((h, w, 3), 120, np.uint8)
eng = DoubaoEngine()
loc = eng.locate(img)
base = eng.scale_base(img)
gw = max(eng.config.min_gw, int(eng.config.alpha_width_frac * base))
gh = max(4, int(eng.config.alpha_height_frac * base))
a = cv2.resize(tmpl, (gw, gh), interpolation=cv2.INTER_AREA).astype(np.float32) * alpha
x = loc.x + (loc.w - gw) // 2
y = loc.y + (loc.h - gh) // 2
roi = img[y : y + gh, x : x + gw].astype(np.float32)
a3 = a[..., None]
img[y : y + gh, x : x + gw] = np.clip(roi * (1 - a3) + 255.0 * a3, 0, 255).astype(np.uint8)
return img
class TestFaintMarkIsMaskable:
def test_binary_glyph_blob_is_empty_on_a_faint_mark(self):
"""The premise: this is the input class the binarizing path cannot segment."""
eng = DoubaoEngine()
img = _faint_mark_image()
loc = eng.locate(img)
glyph = eng.extract_mask(img, loc)
assert int((glyph > 0).sum()) < eng._MIN_GLYPH_PIXELS
def test_footprint_mask_is_not_empty_when_the_continuous_response_has_signal(self):
"""The fix: a faint mark must still yield a removal mask, without --no-detect.
Without it `localize` returns None and removal silently does nothing while
`identify` keeps reporting the mark.
"""
eng = DoubaoEngine()
img = _faint_mark_image()
mask = eng.footprint_mask(img, force=False)
assert mask is not None, "a detectable faint mark produced no removal mask"
assert int((mask > 0).sum()) > 0
def test_a_clean_frame_still_produces_no_mask(self):
"""The guard: the fallback must not turn every flat corner into a fill."""
eng = DoubaoEngine()
clean = cv2.GaussianBlur(np.full((1200, 900, 3), 120, np.uint8), (5, 5), 0)
assert eng.footprint_mask(clean, force=False) is None
@pytest.mark.parametrize("alpha", [0.5, 0.9])
def test_a_bold_mark_is_unaffected(self, alpha: float):
"""A mark the binary path already segments must keep its tight glyph box."""
eng = DoubaoEngine()
img = _faint_mark_image(alpha=alpha)
mask = eng.footprint_mask(img, force=False)
assert mask is not None
assert int((mask > 0).sum()) > 0