Files
remove-ai-watermarks/tests/test_text_mark_faint_mask.py
T
Victor KuznetsovandClaude Opus 4.8 52eb40c2ca Mask faint text marks with the detector's match box, not a response threshold
The faint-mask fallback added for the tophat front-end thresholded the
max-normalized uint8 response at 0.5 -- which selects every non-zero pixel,
not "half the peak" as its comment claimed -- and filled ~120% of the corner
box on textured frames. Measured on 14 real faint-path frames (cv2 fill,
detector re-run after): the detector's own best-match box fills a 58.7%-median
corner box vs 120.9% for the threshold, both 100% detector-clean. Detection and
the mask now read one method, _tophat_best, whose score gates detection and
whose argmax box bounds the fill, so the two cannot drift by construction --
which is how the mismatch arose. The 0.5 constant is deleted.

Parity could not catch this (a mask that fills everything is trivially
detector-clean) and the regression test could not either: its flat fixture
gives every threshold the same box, so mutating the constant to 99.0 stayed
green. The fixture now carries texture and asserts the mask area is bounded,
not merely non-empty; it reproduces the corpus number (127% pre-fix).

Also lands the Tier B2 verification harnesses that found and bounded this:
- detector_response.py: response curves (detected AND maskable per cell); found
  the size response is a comb, contrast is near-irrelevant, no unmaskable cells.
- ladder_headroom.py: measured that a denser scale ladder recovers 7.6% of
  misses for a 2.52%->3.05% false-fire rise, and the one landscape rung that
  helps is a geometry shift that helps and hurts equally (1.7:1) -- do not add.
- cjk_tail_probe.py: a generic shared-tail (AI生成) template does not separate
  uncovered vendors from clean corners (0.407 vs clean p99 0.298).

Records the visible-parity re-run confirming the earlier front-end fix (doubao
91.8% -> 99.3%), and dedups the thrice-written stamp forward model into one
fill_quality.composite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 15:41:18 -07:00

142 lines
6.9 KiB
Python

"""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, textured: bool = False) -> np.ndarray:
"""A 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``.
``textured`` adds fine luminance noise to the background. It is not decoration: on a
FLAT frame the top-hat response is non-zero only on the glyph, so every threshold
yields the same bounding box and a test built on a flat fixture cannot see a wrong
threshold at all -- mutating the constant to an absurd value left the flat tests green.
Texture puts response outside the glyph, which is the condition under which the mask's
sizing actually matters, and is what real corner backgrounds look like.
"""
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)
if textured:
rng = np.random.default_rng(7)
noise = rng.normal(0, 14, (h, w, 1)).repeat(3, axis=2)
img = np.clip(img.astype(np.float32) + noise, 0, 255).astype(np.uint8)
img = cv2.GaussianBlur(img, (0, 0), sigmaX=1.2)
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
class TestFaintMaskStaysTight:
"""The faint fallback must cover the mark WITHOUT filling the whole corner.
Corpus-found 2026-07-20: the first version of the fallback thresholded the continuous
response at 0.5, but `tophat_response` returns uint8 0..255 -- so it selected every
non-zero pixel, not "half the peak" as its comment claimed. On 12 of 12 real frames
taking this path the resulting box covered 100% of the corner ROI, so removal inpainted
the entire corner instead of the glyph. Parity could not see it: parity asks whether
the detector is clean afterwards, and a mask that fills everything passes trivially.
The cost, not the outcome, was the defect.
"""
def test_mask_does_not_swallow_the_whole_corner_on_a_textured_frame(self):
eng = DoubaoEngine()
img = _faint_mark_image(alpha=0.10, textured=True)
# Asserted, not skipped: a skip here would silently stop guarding the moment the
# detector changed, which is exactly when this needs to be guarding.
assert eng.detect(img).detected, "fixture must reach the faint path to test it"
mask = eng.footprint_mask(img, force=False)
assert mask is not None, "a detected faint mark must still produce a mask"
area = int((mask > 0).sum())
loc = eng.locate(img)
roi = loc.w * loc.h
# The mark's own glyph box is ~40% of the corner ROI and the mask pads it, so a
# correct mask lands near 60%. The pre-fix behaviour measured 120.9% (the whole
# ROI plus padding), which this bound excludes.
assert area < 0.85 * roi, f"mask covers {100 * area / roi:.0f}% of the corner box"
def test_mask_still_covers_the_stamped_glyph(self):
"""Tightness is only a virtue if the mark is still inside. Guards the other way."""
eng = DoubaoEngine()
img = _faint_mark_image(alpha=0.10, textured=True)
assert eng.detect(img).detected, "fixture must reach the faint path to test it"
mask = eng.footprint_mask(img, force=False)
assert mask is not None
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))
x = loc.x + (loc.w - gw) // 2
y = loc.y + (loc.h - gh) // 2
covered = int(np.count_nonzero(mask[y : y + gh, x : x + gw])) / max(1, gw * gh)
assert covered > 0.6, f"mask covers only {100 * covered:.0f}% of the stamped glyph"