diff --git a/docs/module-internals.md b/docs/module-internals.md index 09affd1..f33bffe 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -1349,13 +1349,25 @@ seven, and regressed on one. The union is deliberate: replacing the old footprin with the synthetic core alone would overfit the font render and could discard a real variant stroke the pixel mask already found. -Samsung's binary front end now carries the location of its nominal-size template -match as well as the score. `SamsungEngine.footprint_mask` aligns the captured alpha -to that same box and masks only pixels above the measured 0.05 alpha floor, followed -by one pixel of dilation. The asset already contains the solved opacity, whose peak -is about 0.38; research renderers must composite it toward pure white exactly once. -Multiplying it by 0.38 again produced an unrealistically faint synthetic mark and -invalidated the first Samsung response curve. +Samsung uses the continuous top-hat front end with an exact `(1.0,)` scale ladder. +The three retained real positives (two controlled captures and one real-content +photo) scored 0.82-0.90, while 421 public clean controls reached at most 0.38, so +the existing 0.40 threshold did not need to move. The exact rung matches the measured +width at both 1086 and 2958 pixels and avoids giving clean corners extra scale trials. +Samsung is strict-only under provenance: the inherited 0.70 factor would lower the +gate to 0.28 and admitted 14/421 clean controls without recovering any retained real +positive. +On the same 421 backgrounds, nominal constructed detection rose from 81/421 through +the binary front end to 321/421 through continuous top-hat, with no control fires in +either arm. This constructed arm measures the known failure mode on textured content; +it is not a production-recall estimate. + +The winning template location travels with the score. `SamsungEngine.footprint_mask` +aligns the captured alpha to that same box and masks only pixels above the measured +0.05 alpha floor, followed by one pixel of dilation. The asset already contains the +solved opacity, whose peak is about 0.38; research renderers must composite it toward +pure white exactly once. Multiplying it by 0.38 again produced an unrealistically +faint synthetic mark and invalidated the first Samsung response curve. The mask geometry was checked on both retained flat provider captures and by a paired 60-image constructed-reference ablation on 2026-09-04. At the native @@ -1366,6 +1378,11 @@ gains were 14.98, 10.38, and 14.87 dB respectively (two-sided sign-test `p=1.04e-11`, `1.04e-11`, and `6.25e-14`). Explicit `force` has no detected box to align and deliberately retains the conservative geometry fallback. +For the 321 newly detected constructed marks, the winning box matched the stamped +box exactly and every detection produced a non-empty mask. One OpenCV fill remained +detectable and is surfaced by post-removal validation; the detector does not trigger +an automatic retry. + Yuanbao uses the polarity-independent `contrast` front end because its standard two-line mark can be light on dark scenes or dark on light scenes. Its detector and footprint both use the same best-match box. The separate one-line overlay diff --git a/docs/verification-plan.md b/docs/verification-plan.md index 2df5c5a..d3d0add 100644 --- a/docs/verification-plan.md +++ b/docs/verification-plan.md @@ -200,6 +200,27 @@ does not justify lowering the threshold because the grid is adversarial and is n production-recall sample. The corrected report is `.local-eval/detector-response-corrected-n150.jsonl`. +The 2026-09-05 Samsung follow-up changed the front end, not the threshold. Continuous +top-hat with an exact `(1.0,)` ladder scored the three retained real positives at +0.82-0.90 and produced 0/421 fires on the full public clean pool (maximum 0.38; 95% +Wilson upper bound 0.9%). The provenance factor is 1.0: the inherited 0.70 relaxation +would admit 14/421 controls at its 0.28 gate and recovered no retained real positive. +On correctly stamped nominal marks over those same controls, +detection improved from 81/421 (19.2%) for binary to 321/421 (76.2%) for top-hat. All +321 boxes exactly matched the known stamp geometry and were maskable. After one OpenCV +fill, 320/321 no longer detected; the remaining result is reported by post-removal +validation rather than retried. The constructed rate is a controlled failure-mode +measurement, not production recall. + +The complete B2 harness was then rerun on the same deterministic 150-source selection +into `.local-eval/detector-response-samsung-tophat-n150.jsonl`: 28,800 stamped cells +plus 1,800 controls, with all 204 expected rows present for every source. Samsung's +nominal cell improved from 30/150 (20.0%) to 120/150 (80.0%); all 120 were maskable, +and controls remained 0/150 (maximum score 0.3543). Across the full adversarial grid, +399/2,400 Samsung cells detected and all 399 were maskable. The key sets matched the +previous report, and all 28,050 non-Samsung rows were identical field for field, so the +measured change is isolated to Samsung. + ### B3. Invisible round-trip, positive-control gated The open DWT-DCT detector is positive-only and carrier-fragile: "not found" on a fragile diff --git a/scripts/real_examples_e2e.py b/scripts/real_examples_e2e.py index 19c6ac0..8cfdb05 100644 --- a/scripts/real_examples_e2e.py +++ b/scripts/real_examples_e2e.py @@ -172,9 +172,10 @@ def check_visible(res: Results, tmp: Path) -> None: deliberately NOT removed, so `visible` correctly writes nothing and exits 2. That is the gate working, not a miss -- checked by asking `remove_auto_marks` whether it chose to act. - * Samsung is the faintest mark (peak alpha ~0.38) at a razor-thin 0.40 gate, so a - borderline positive can be reduced yet re-detect just above threshold. Reported as - the measured before->after confidence, not a bare pass/fail. + * Samsung is the faintest mark (peak alpha ~0.38). Its continuous top-hat detector + now separates retained positives from clean controls at the unchanged 0.40 gate, + but a fill can still leave a detectable residual. Report the measured before->after + confidence, not a bare pass/fail. """ from remove_ai_watermarks.image_io import imread from remove_ai_watermarks.watermark_registry import detect_marks, get_mark, remove_auto_marks diff --git a/src/remove_ai_watermarks/samsung_engine.py b/src/remove_ai_watermarks/samsung_engine.py index a418095..de6e445 100644 --- a/src/remove_ai_watermarks/samsung_engine.py +++ b/src/remove_ai_watermarks/samsung_engine.py @@ -46,8 +46,10 @@ MAX_SATURATION = 55 LOGO_MIN_LUMA = 110 TOPHAT_DELTA = 8 -# Shape-consistent detection. Threshold 0.40; real marks ~0.79, and Doubao/Jimeng score -# 0.0 here (and Samsung 0.0 on theirs) -- no cross-fire (the corner also differs). +# Shape-consistent detection. The continuous top-hat scored the three retained real +# positives at 0.82-0.90 and 421 public clean controls no higher than 0.38, so the +# binary-era 0.40 threshold stays unchanged. The exact one-rung ladder matches both +# measured widths and avoids handing clean corners two unneeded scale trials. DETECT_MIN_COVERAGE = 0.01 DETECT_NCC_THRESHOLD = 0.40 @@ -81,6 +83,9 @@ _CONFIG = TextMarkConfig( morph_open_size=3, detect_min_coverage=DETECT_MIN_COVERAGE, detect_ncc_threshold=DETECT_NCC_THRESHOLD, + detect_frontend="tophat", + ladder=(1.0,), + provenance_ncc_factor=1.0, alpha_width_frac=_ALPHA_WIDTH_FRAC, alpha_height_frac=_ALPHA_HEIGHT_FRAC, min_gw=16, @@ -120,7 +125,7 @@ class SamsungEngine(TextMarkEngine): Samsung's peak opacity is only about 0.38, so filling the enclosing wordmark rectangle destroys substantially more real content than the overlay damaged. - The binary detector already found the captured silhouette at one exact box; + The continuous detector already found the captured silhouette at one exact box; align the solved alpha to that box and mask its strokes only. Explicit ``force`` has no trustworthy alignment and retains the shared geometry box. """ diff --git a/tests/test_qwen_engine.py b/tests/test_qwen_engine.py index c6cdc3d..fcb5af8 100644 --- a/tests/test_qwen_engine.py +++ b/tests/test_qwen_engine.py @@ -68,15 +68,15 @@ class TestLocate: class TestConfig: def test_per_mark_ladder_and_shared_default_untouched(self): - # Qwen's two size modes need their own 2-rung ladder; every other mark must - # keep the shipped 3-rung default (the field's whole point is per-mark). + # Qwen's two size modes need their own 2-rung ladder. Doubao and Jimeng keep + # the shared default; Samsung's separately measured exact rung is pinned in + # TestTophatFrontend. assert _LADDER == (0.78, 1.27) assert QwenEngine().config.ladder == (0.78, 1.27) from remove_ai_watermarks.doubao_engine import _CONFIG as db from remove_ai_watermarks.jimeng_engine import _CONFIG as jm - from remove_ai_watermarks.samsung_engine import _CONFIG as ss - assert db.ladder == jm.ladder == ss.ladder == (0.8, 1.0, 1.25) + assert db.ladder == jm.ladder == (0.8, 1.0, 1.25) def test_strict_only_no_provenance_relaxation(self): # The sub-gate band is dominated by non-Qwen banners on same-cohort frames, diff --git a/tests/test_text_mark_engine.py b/tests/test_text_mark_engine.py index c715283..73b8e15 100644 --- a/tests/test_text_mark_engine.py +++ b/tests/test_text_mark_engine.py @@ -56,6 +56,12 @@ class TestPerMarkProvenanceRelaxation: Jimeng because its relaxed silhouette confuses other bottom-right text marks. """ + def test_samsung_stays_strict_with_provenance(self): + """Its three real positives clear 0.40; relaxing to 0.28 admits clean texture.""" + from remove_ai_watermarks import samsung_engine + + assert samsung_engine._CONFIG.provenance_ncc_factor == 1.0 + class TestScaleBasis: """Mark geometry scales with a PER-MARK image dimension, measured not assumed. @@ -132,13 +138,18 @@ class TestTophatFrontend: assert doubao_engine._CONFIG.detect_frontend == "tophat" - def test_other_marks_stay_binary_until_measured(self): - """A front-end switch must be measured per mark before it ships; jimeng and - samsung have no such measurement yet.""" - from remove_ai_watermarks import jimeng_engine, samsung_engine + def test_samsung_uses_the_continuous_frontend_after_measurement(self): + """Samsung's own real-positive and clean-control study supports top-hat.""" + from remove_ai_watermarks import samsung_engine + + assert samsung_engine._CONFIG.detect_frontend == "tophat" + assert samsung_engine._CONFIG.ladder == (1.0,) + + def test_jimeng_stays_binary_until_measured(self): + """A front-end switch must be measured per mark before it ships.""" + from remove_ai_watermarks import jimeng_engine assert jimeng_engine._CONFIG.detect_frontend == "binary" - assert samsung_engine._CONFIG.detect_frontend == "binary" def test_response_is_contrast_invariant(self): """The whole point: a faint mark and a bold one produce the same response, so a diff --git a/tests/test_visible_fill_quality.py b/tests/test_visible_fill_quality.py index 78fa2c6..6242c1c 100644 --- a/tests/test_visible_fill_quality.py +++ b/tests/test_visible_fill_quality.py @@ -61,6 +61,23 @@ def test_samsung_stamp_uses_the_solved_alpha_exactly_once() -> None: assert np.array_equal(marked[y : y + h, x : x + w, 0], expected) +def test_samsung_detects_its_faint_mark_on_a_textured_background() -> None: + """Continuous top-hat preserves a mark that binary thresholding shatters.""" + height, width = 800, 600 + rng = np.random.default_rng(0) + xx = np.arange(width, dtype=np.float32)[None, :] + texture = 120 + 25 * np.sin(xx / 9) + texture = texture + cv2.GaussianBlur(rng.normal(0, 35, (height, width)).astype(np.float32), (0, 0), 2) + clean = cv2.cvtColor(np.clip(texture, 0, 255).astype(np.uint8), cv2.COLOR_GRAY2BGR) + stamped = stamp_image_mark("samsung", clean) + assert stamped is not None + + marked, _box = stamped + detection = wr.get_mark("samsung").detect(marked) + assert detection.detected + assert detection.confidence >= 0.80 + + def test_detector_response_can_construct_every_declared_grid_cell() -> None: """An out-of-search-range mark is a measured miss, not an omitted row.""" for key in _IMAGE_KEYS: