mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-06 22:18:36 +02:00
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:
co-authored by
Claude Opus 4.8
parent
c150180acf
commit
836d87ed68
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user