mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-06 22:18:36 +02:00
Remove assume_ai, add tophat front-end and rival margin, fix two CLI defects
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
a8f3536d3e
commit
cfefd9d819
+1
-1
@@ -49,7 +49,7 @@ class TestRemoveVisibleArray:
|
||||
|
||||
def test_array_accepts_knobs(self):
|
||||
arr = np.zeros((256, 256, 3), np.uint8)
|
||||
result, removed = raiw.remove_visible(arr, sensitivity="assume_ai", backend="cv2")
|
||||
result, removed = raiw.remove_visible(arr, sensitivity="strict", backend="cv2")
|
||||
assert removed == []
|
||||
assert result.shape == arr.shape
|
||||
|
||||
|
||||
@@ -573,6 +573,31 @@ class TestMetadataCommand:
|
||||
assert result.exit_code == 0
|
||||
assert "stripped" in result.output
|
||||
|
||||
def test_metadata_remove_reports_failure_when_the_strip_was_a_no_op(self, runner, tmp_path):
|
||||
"""A file PIL cannot decode is copied through UNCHANGED by the fail-safe.
|
||||
|
||||
That is correct (never crash a worker on a partial upload) but the command used
|
||||
to print "AI metadata stripped ->" and exit 0 for it, so a caller could not tell
|
||||
a real strip from a no-op and the output still read as AI. Found on real Samsung
|
||||
Galaxy S22 C2PA PNGs during the corpus parity audit, 2026-07-19.
|
||||
"""
|
||||
# PNG signature + a C2PA (caBX) chunk, then garbage: the byte scanner sees the
|
||||
# marker, PIL cannot decode it.
|
||||
src = tmp_path / "undecodable.png"
|
||||
payload = b"c2pa" + b"\x00" * 32
|
||||
chunk = len(payload).to_bytes(4, "big") + b"caBX" + payload + b"\x00\x00\x00\x00"
|
||||
src.write_bytes(b"\x89PNG\r\n\x1a\n" + chunk + b"NOTAPNG" * 8)
|
||||
out = tmp_path / "cleaned.png"
|
||||
|
||||
result = runner.invoke(main, ["metadata", str(src), "--remove", "-o", str(out)])
|
||||
|
||||
from remove_ai_watermarks.metadata import get_ai_metadata
|
||||
|
||||
if not get_ai_metadata(src):
|
||||
pytest.skip("fixture does not register as an AI-metadata carrier")
|
||||
assert result.exit_code != 0, "a no-op strip must not report success"
|
||||
assert "stripped ->" not in result.output
|
||||
|
||||
def test_metadata_remove_in_place(self, runner, tmp_png_with_ai_metadata):
|
||||
"""With ``-o`` omitted, the strip overwrites the source in place (default
|
||||
output_path=None). Previously every test passed an explicit ``-o``."""
|
||||
|
||||
@@ -107,10 +107,10 @@ class TestPillRegistry:
|
||||
|
||||
class TestPillGate:
|
||||
"""Pill removal is gated (``_keep_pill``): the reliable bottom-right wordmark
|
||||
removes it unrestricted, the metadata (``"jimeng"`` provenance) / assume_ai arm
|
||||
removes it ONLY on a flat footprint (safe fill), Doubao/no-confirmation never
|
||||
remove it. Fakes each mark's detect so no image content is needed; cv2 backend so
|
||||
nothing downloads. Frame flatness matters, so tests pass a flat or textured frame."""
|
||||
removes it unrestricted, the metadata arm (``"jimeng"`` provenance) removes it ONLY
|
||||
on a flat footprint (safe fill), Doubao/no-confirmation never remove it. Fakes each
|
||||
mark's detect so no image content is needed; cv2 backend so nothing downloads. Frame
|
||||
flatness matters, so tests pass a flat or textured frame."""
|
||||
|
||||
@staticmethod
|
||||
def _fakes(monkeypatch: pytest.MonkeyPatch, keys: set[str]) -> None:
|
||||
@@ -149,16 +149,10 @@ class TestPillGate:
|
||||
_, removed = registry.remove_auto_marks(_textured_frame())
|
||||
assert "Jimeng AI生成 pill" in removed
|
||||
|
||||
def test_pill_kept_via_assume_ai_on_flat_footprint(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# assume_ai (no metadata) removes the pill on a flat footprint (safe fill)...
|
||||
def test_pill_dropped_on_textured_footprint(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# The metadata arm keeps the flatness guard: textured false fires visibly smear.
|
||||
self._fakes(monkeypatch, {"jimeng_pill"})
|
||||
_, removed = registry.remove_auto_marks(np.full((400, 300, 3), 150, np.uint8), sensitivity="assume_ai")
|
||||
assert "Jimeng AI生成 pill" in removed
|
||||
|
||||
def test_pill_dropped_via_assume_ai_on_textured_footprint(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# ...but even assume_ai keeps the flatness guard (textured false fires smear).
|
||||
self._fakes(monkeypatch, {"jimeng_pill"})
|
||||
_, removed = registry.remove_auto_marks(_textured_frame(), sensitivity="assume_ai")
|
||||
_, removed = registry.remove_auto_marks(_textured_frame(), provenance=frozenset({"jimeng"}))
|
||||
assert "Jimeng AI生成 pill" not in removed
|
||||
|
||||
def test_pill_dropped_without_metadata_or_wordmark(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Policy-level tests for the shared text-mark engine config.
|
||||
|
||||
These assert TUNING that was set by corpus measurement, not algorithm behaviour --
|
||||
they exist so a future edit cannot silently revert a calibrated constant back to a
|
||||
value that was measured to be wrong. The measurements themselves live in
|
||||
`docs/module-internals.md` and in the comment at
|
||||
`_text_mark_engine._DEFAULT_PROVENANCE_NCC_FACTOR`.
|
||||
"""
|
||||
|
||||
|
||||
class TestRivalMargin:
|
||||
"""Detection among same-corner marks is COMPETITIVE, not just absolute.
|
||||
|
||||
Doubao "豆包AI生成" and Jimeng "★ 即梦AI" both sit bottom-right in near-white CJK
|
||||
and survive binarization as similar blobs, so an absolute NCC gate cannot tell
|
||||
them apart -- 33 of jimeng's 68 false additions were Doubao marks. Measured
|
||||
separability scoring both templates on the SAME blob (n=40 jimeng / 75 doubao):
|
||||
absolute ncc_jimeng 0.96, ncc_jimeng MINUS ncc_doubao 0.99. Corpus effect of the
|
||||
margin gate: jimeng precision 38% -> 63% with genuine detections unchanged at 40
|
||||
(false fires 65 -> 23).
|
||||
"""
|
||||
|
||||
def test_jimeng_competes_against_doubao(self):
|
||||
from remove_ai_watermarks import jimeng_engine
|
||||
|
||||
assert "doubao_alpha.png" in jimeng_engine._CONFIG.rivals
|
||||
|
||||
def test_doubao_has_no_rival_margin(self):
|
||||
"""Asymmetric by measurement, not oversight: the symmetric gate cost Doubao 7
|
||||
genuine detections to prevent 5 false ones (1.4:1 against), while Jimeng gained
|
||||
25pp for free. Doubao's absolute detector is already 86% precise."""
|
||||
from remove_ai_watermarks import doubao_engine
|
||||
|
||||
assert doubao_engine._CONFIG.rivals == ()
|
||||
|
||||
def test_a_doubao_shaped_blob_loses_the_jimeng_margin(self):
|
||||
"""The decisive case: a blob matching Doubao BETTER than Jimeng must not be
|
||||
booked as a Jimeng wordmark, however high its absolute Jimeng score."""
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks import jimeng_engine
|
||||
from remove_ai_watermarks._text_mark_engine import glyph_silhouette
|
||||
|
||||
eng = jimeng_engine.JimengEngine()
|
||||
doubao_blob = glyph_silhouette("doubao_alpha.png")
|
||||
assert doubao_blob is not None
|
||||
canvas = np.zeros((doubao_blob.shape[0] + 20, doubao_blob.shape[1] + 20), np.uint8)
|
||||
canvas[10 : 10 + doubao_blob.shape[0], 10 : 10 + doubao_blob.shape[1]] = doubao_blob
|
||||
width = int(doubao_blob.shape[1] / jimeng_engine._CONFIG.alpha_width_frac)
|
||||
jimeng_score = eng._template_match_score(canvas, width)
|
||||
assert not eng._rival_margin_ok(jimeng_score, canvas, width)
|
||||
|
||||
|
||||
class TestPerMarkProvenanceRelaxation:
|
||||
"""The provenance NCC relaxation is PER MARK, not one shared multiplier.
|
||||
|
||||
Corpus-measured 2026-07-18 on the default `auto` path (4417 unique TC260
|
||||
carriers, blind hand-label, two-sided control): the single shared 0.7 ran at
|
||||
76% precision on doubao but 17% on jimeng, because jimeng's relaxed silhouette
|
||||
keys on "text in the bottom-right corner" rather than the wordmark -- 33 of its
|
||||
68 false additions were DOUBAO marks. Full table at
|
||||
`_text_mark_engine._DEFAULT_PROVENANCE_NCC_FACTOR`.
|
||||
"""
|
||||
|
||||
|
||||
class TestScaleBasis:
|
||||
"""Mark geometry scales with a PER-MARK image dimension, measured not assumed.
|
||||
|
||||
Every tuned fraction was calibrated on PORTRAIT captures, where width and short
|
||||
side coincide, so the basis was never exercised until landscape inputs were
|
||||
measured. Corpus-measured 2026-07-18 (2572 unique TC260 carriers): doubao
|
||||
detection was portrait 60% / square 41% / **landscape 0% of 435** -- a width-scaled
|
||||
box is inflated by the aspect ratio on a wide image and the glyph never lands in
|
||||
it. A short-side basis recovered 56% of the previously-undetected landscape set.
|
||||
The same switch broke JIMENG (labelled landscape positives 13/13 -> 0/13), whose
|
||||
wordmark tracks the width -- hence per-mark, not a house rule.
|
||||
"""
|
||||
|
||||
def test_doubao_scales_with_the_short_side(self):
|
||||
from remove_ai_watermarks import doubao_engine
|
||||
|
||||
assert doubao_engine._CONFIG.scale_basis == "short"
|
||||
|
||||
def test_jimeng_scales_with_width(self):
|
||||
"""Measured, not an oversight: the short-side basis took jimeng's labelled
|
||||
landscape positives from 13/13 to 0/13."""
|
||||
from remove_ai_watermarks import jimeng_engine
|
||||
|
||||
assert jimeng_engine._CONFIG.scale_basis == "width"
|
||||
|
||||
def test_samsung_keeps_width_because_it_is_unmeasured(self):
|
||||
"""1 addition corpus-wide, so there is no evidence either way; an unmeasured
|
||||
change is not an improvement."""
|
||||
from remove_ai_watermarks import samsung_engine
|
||||
|
||||
assert samsung_engine._CONFIG.scale_basis == "width"
|
||||
|
||||
def test_basis_only_differs_on_non_square_images(self):
|
||||
"""The basis is a no-op wherever width IS the short side, which is why the bug
|
||||
survived: every calibration capture was portrait."""
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks import doubao_engine, jimeng_engine
|
||||
|
||||
portrait = np.zeros((1600, 900, 3), np.uint8)
|
||||
landscape = np.zeros((900, 1600, 3), np.uint8)
|
||||
d, j = doubao_engine.DoubaoEngine(), jimeng_engine.JimengEngine()
|
||||
assert d.scale_base(portrait) == j.scale_base(portrait) == 900
|
||||
assert d.scale_base(landscape) == 900
|
||||
assert j.scale_base(landscape) == 1600
|
||||
|
||||
def test_landscape_box_stays_inside_the_frame(self):
|
||||
"""The concrete failure: a width-scaled box on a wide image overshoots the
|
||||
mark's real footprint. The short-side box must be proportionally smaller."""
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks import doubao_engine
|
||||
|
||||
landscape = np.zeros((900, 2400, 3), np.uint8)
|
||||
loc = doubao_engine.DoubaoEngine().locate(landscape)
|
||||
assert loc.w < int(2400 * doubao_engine._CONFIG.width_frac)
|
||||
assert loc.x + loc.w <= 2400
|
||||
assert loc.y + loc.h <= 900
|
||||
|
||||
|
||||
class TestTophatFrontend:
|
||||
"""Detection can correlate the CONTINUOUS top-hat instead of a binarized blob.
|
||||
|
||||
`extract_mask` thresholds the top-hat into a 0/255 glyph blob, which is fine for a
|
||||
mark stamped bold and opaque and destructive for a faint one -- a thin translucent
|
||||
overlay shatters into specks and no template can match a blob that is not there
|
||||
(measured: 千问 scored 0.170 mean vs doubao's 0.723 through the binary path, 0% over
|
||||
the gate). The `tophat` front-end never binarizes: the saturation/luma gates become
|
||||
weights, and the response is max-normalized so the score is contrast-invariant.
|
||||
|
||||
Corpus effect on the 240-image unbiased recall sample: doubao recall 89% -> 92% at
|
||||
an unchanged 99% precision.
|
||||
"""
|
||||
|
||||
def test_doubao_uses_the_continuous_frontend(self):
|
||||
from remove_ai_watermarks import doubao_engine
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
single threshold works for both. Binarizing is what loses the faint one."""
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks import doubao_engine
|
||||
|
||||
eng = doubao_engine.DoubaoEngine()
|
||||
h, w = 400, 900
|
||||
out = []
|
||||
for amplitude in (12, 90): # a barely-there overlay and a bold one
|
||||
img = np.full((h, w, 3), 100, np.uint8)
|
||||
loc = eng.locate(img)
|
||||
x, y, bw, bh = loc.bbox
|
||||
img[y + bh // 3 : y + 2 * bh // 3, x + bw // 4 : x + 3 * bw // 4] = 100 + amplitude
|
||||
resp = eng.tophat_response(img, loc)
|
||||
assert resp is not None
|
||||
out.append(resp)
|
||||
# max-normalized, so the two responses agree despite a 7.5x contrast difference
|
||||
assert abs(int(out[0].max()) - int(out[1].max())) <= 1
|
||||
|
||||
def test_flat_input_yields_no_response(self):
|
||||
"""A blank corner has no top-hat at all; the engine must return None rather
|
||||
than divide by a zero peak."""
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks import doubao_engine
|
||||
|
||||
eng = doubao_engine.DoubaoEngine()
|
||||
img = np.full((400, 900, 3), 128, np.uint8)
|
||||
assert eng.tophat_response(img, eng.locate(img)) is None
|
||||
|
||||
def test_threshold_is_frontend_specific(self):
|
||||
"""The continuous front-end scores higher overall (0.809 vs 0.723 mean on the
|
||||
same positives), so it needs its own gate; the binary-era 0.40 left the
|
||||
provenance-relaxed gate low enough to admit 8 false fires where 0.50 admits 1."""
|
||||
from remove_ai_watermarks import doubao_engine
|
||||
|
||||
assert doubao_engine._CONFIG.detect_ncc_threshold == 0.50
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Pure tests for the strength/steps profile helpers (no model, no torch needed)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from remove_ai_watermarks.noai.watermark_profiles import resolve_strength, viable_steps
|
||||
|
||||
|
||||
class TestViableSteps:
|
||||
"""Guards the crash found by the release smoke matrix on 2026-07-19.
|
||||
|
||||
diffusers derives its img2img timesteps as ``int(steps * strength)``. When that
|
||||
rounds to zero the pipeline builds an empty tensor and dies deep inside attention
|
||||
with "cannot reshape tensor of 0 elements into shape [0, -1, 1, 512]". At the
|
||||
default strength 0.15 that was every ``--steps`` below 7, reachable with entirely
|
||||
valid CLI arguments and no special flags.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("steps", "strength"),
|
||||
[(1, 0.15), (2, 0.15), (5, 0.15), (6, 0.15), (5, 0.10), (9, 0.10), (1, 0.5)],
|
||||
)
|
||||
def test_never_returns_a_count_that_denoises_zero_steps(self, steps: int, strength: float):
|
||||
assert int(viable_steps(steps, strength) * strength) >= 1
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("steps", "strength"),
|
||||
[(50, 0.15), (20, 0.15), (7, 0.15), (10, 0.10), (2, 0.5), (50, 1.0)],
|
||||
)
|
||||
def test_leaves_a_workable_count_untouched(self, steps: int, strength: float):
|
||||
assert viable_steps(steps, strength) == steps
|
||||
|
||||
def test_raises_only_to_the_minimum_needed(self):
|
||||
# strength 0.15 needs 7 (int(7*0.15)==1); it must not jump to some larger default.
|
||||
assert viable_steps(5, 0.15) == 7
|
||||
assert viable_steps(1, 0.10) == 10
|
||||
|
||||
def test_the_vendor_defaults_all_have_a_reachable_floor(self):
|
||||
for vendor in (None, "openai", "google"):
|
||||
strength = resolve_strength(None, vendor)
|
||||
assert int(viable_steps(1, strength) * strength) >= 1
|
||||
|
||||
@pytest.mark.parametrize("strength", [0.0, -0.1])
|
||||
def test_a_non_positive_strength_cannot_loop_or_divide_by_zero(self, strength: float):
|
||||
# No denoising is possible at all here; return the caller's value rather than
|
||||
# dividing by zero or spinning.
|
||||
assert viable_steps(20, strength) == 20
|
||||
@@ -113,24 +113,51 @@ class TestFill:
|
||||
|
||||
|
||||
class TestProvenanceGate:
|
||||
"""The Gemini trust gate relaxes from 0.5 to 0.35 when provenance confirms Google;
|
||||
tested deterministically by stubbing the engine's raw detection confidence."""
|
||||
"""The Gemini trust gate relaxes from GEMINI_SPARKLE_TRUST_CONF to
|
||||
_GEMINI_PROVENANCE_MIN_CONF when provenance confirms Google; tested
|
||||
deterministically by stubbing the engine's raw detection confidence."""
|
||||
|
||||
def _stub(self, monkeypatch: pytest.MonkeyPatch, conf: float) -> None:
|
||||
from remove_ai_watermarks.gemini_engine import DetectionResult
|
||||
|
||||
# `detected` mirrors the engine's own internal floor (0.35), which is
|
||||
# independent of the registry gate under test here.
|
||||
def fake_detect(image, force_size=None, *, trust_provenance=False):
|
||||
return DetectionResult(detected=conf >= 0.35, confidence=conf, region=(10, 10, 48, 48))
|
||||
|
||||
monkeypatch.setattr(reg._engine("gemini"), "detect_watermark", fake_detect)
|
||||
|
||||
def test_midband_conf_needs_provenance(self, monkeypatch: pytest.MonkeyPatch):
|
||||
# conf 0.42 sits in [0.35, 0.5): demoted without provenance, trusted with it.
|
||||
self._stub(monkeypatch, 0.42)
|
||||
# Comfortably inside the relaxed band: demoted without provenance, trusted with it.
|
||||
conf = (reg._GEMINI_PROVENANCE_MIN_CONF + reg.GEMINI_SPARKLE_TRUST_CONF) / 2
|
||||
self._stub(monkeypatch, conf)
|
||||
img = np.zeros((256, 256, 3), np.uint8)
|
||||
assert reg.get_mark("gemini").detect(img).detected is False
|
||||
assert reg.get_mark("gemini").detect(img, provenance=True).detected is True
|
||||
|
||||
def test_below_provenance_gate_rejected_even_with_provenance(self, monkeypatch: pytest.MonkeyPatch):
|
||||
"""Provenance relaxes the gate, it does not remove it.
|
||||
|
||||
Guards the 2026-07-18 raise of _GEMINI_PROVENANCE_MIN_CONF (0.35 -> 0.42).
|
||||
The engine still reports `detected` down at its own 0.35 floor, so without
|
||||
the registry gate this confidence would be accepted and inpainted. Measured
|
||||
precision just below the gate was 13% (n=30, 95% CI 5-30%) on real
|
||||
Google-metadata uploads -- i.e. ~7 of 8 accepts there destroy pixels on an
|
||||
image that never carried a sparkle, and report a removal that did not happen.
|
||||
"""
|
||||
# 0.38 is inside the measured 13%-precision band and above the engine's own
|
||||
# 0.35 floor, so the engine reports `detected` and only the registry gate can
|
||||
# reject it. Hardcoded on purpose: if the gate is ever lowered back under this
|
||||
# value, this test must fail on the BEHAVIOUR below, not on its own arithmetic.
|
||||
self._stub(monkeypatch, 0.38)
|
||||
img = np.zeros((256, 256, 3), np.uint8)
|
||||
assert reg.get_mark("gemini").detect(img).detected is False
|
||||
assert reg.get_mark("gemini").detect(img, provenance=True).detected is False
|
||||
|
||||
def test_provenance_gate_stays_below_the_strict_gate(self):
|
||||
"""The relaxed gate must actually relax, and must not collapse onto the floor."""
|
||||
assert 0.35 < reg._GEMINI_PROVENANCE_MIN_CONF < reg.GEMINI_SPARKLE_TRUST_CONF
|
||||
|
||||
def test_high_conf_detected_either_way(self, monkeypatch: pytest.MonkeyPatch):
|
||||
self._stub(monkeypatch, 0.72)
|
||||
img = np.zeros((256, 256, 3), np.uint8)
|
||||
@@ -175,19 +202,6 @@ class TestSensitivity:
|
||||
== "strict"
|
||||
)
|
||||
|
||||
def test_assume_ai_without_evidence_is_assumed_not_confirmed(self):
|
||||
# asserting the image is AI says nothing about WHICH vendor made it, so the mark
|
||||
# is relaxed on assumption only -- it must not inherit the confirmed-vendor bypass
|
||||
assert (
|
||||
reg.resolve_trust("gemini", sensitivity="assume_ai", provenance=frozenset(), strict_keys=set()) == "assumed"
|
||||
)
|
||||
|
||||
def test_assume_ai_with_metadata_is_confirmed(self):
|
||||
assert (
|
||||
reg.resolve_trust("gemini", sensitivity="assume_ai", provenance=frozenset({"gemini"}), strict_keys=set())
|
||||
== "confirmed"
|
||||
)
|
||||
|
||||
def test_auto_relaxes_on_own_metadata(self):
|
||||
assert (
|
||||
reg.resolve_trust("gemini", sensitivity="auto", provenance=frozenset({"gemini"}), strict_keys=set())
|
||||
@@ -210,34 +224,74 @@ class TestSensitivity:
|
||||
reg.resolve_trust("doubao", sensitivity="auto", provenance=frozenset(), strict_keys={"jimeng"}) == "strict"
|
||||
)
|
||||
|
||||
def test_assumed_floor_rejects_weak_sparkle_but_passes_strong(self):
|
||||
# the gate-bypassed sparkle detector fires on ~60% of ordinary photos at its bare
|
||||
# 0.35 threshold; only a match well clear of that floor is trustworthy on assumption
|
||||
assert reg.assumed_floor_ok("gemini", 0.35) is False
|
||||
assert reg.assumed_floor_ok("gemini", 0.50) is True
|
||||
|
||||
def test_assumed_floor_default_passes_for_unfloored_marks(self):
|
||||
# text marks relax cleanly (<1% bypassed false-fire), so they carry no floor
|
||||
assert reg.assumed_floor_ok("doubao", 0.36) is True
|
||||
|
||||
def test_remove_auto_marks_accepts_all_sensitivities(self):
|
||||
blank = np.zeros((256, 256, 3), np.uint8)
|
||||
for s in ("auto", "strict", "assume_ai"):
|
||||
for s in ("auto", "strict"):
|
||||
_, removed = reg.remove_auto_marks(blank, sensitivity=s, backend="cv2")
|
||||
assert removed == []
|
||||
|
||||
|
||||
class TestNoBlanketRelaxation:
|
||||
"""There is NO path that relaxes a mark's gate without same-product evidence.
|
||||
|
||||
``assume_ai`` was that path and was removed 2026-07-19: it bypassed every mark's
|
||||
false-positive gate on the caller's bare assertion that the image is AI, which says
|
||||
nothing about WHICH vendor or WHERE -- exactly what the bypass is contracted to
|
||||
require. Before it carried a confidence floor it filled a phantom sparkle on 59.8%
|
||||
of genuine camera photos. A user who can SEE a mark is served by `erase --region`
|
||||
(they supply the coordinates) or `--mark <name> --no-detect` for a text mark.
|
||||
"""
|
||||
|
||||
def test_sensitivity_has_exactly_two_levels(self):
|
||||
import typing
|
||||
|
||||
assert set(typing.get_args(reg.Sensitivity)) == {"auto", "strict"}
|
||||
|
||||
def test_trust_ladder_has_no_assumed_level(self):
|
||||
import typing
|
||||
|
||||
assert set(typing.get_args(reg.Trust)) == {"strict", "confirmed"}
|
||||
|
||||
def test_no_sensitivity_relaxes_without_same_product_evidence(self):
|
||||
for sens in ("auto", "strict"):
|
||||
assert reg.resolve_trust("gemini", sensitivity=sens, provenance=frozenset(), strict_keys=set()) == "strict"
|
||||
|
||||
def test_the_assumed_floor_helper_is_gone(self):
|
||||
"""It existed only to make the blanket relaxation tolerable."""
|
||||
assert not hasattr(reg, "assumed_floor_ok")
|
||||
assert not hasattr(reg, "_ASSUMED_CONF_FLOOR")
|
||||
|
||||
def test_the_removed_value_raises_instead_of_silently_meaning_auto(self):
|
||||
"""`Sensitivity` is a Literal and unenforced at runtime, so a 0.15 caller passing
|
||||
the removed value would quietly get `auto` -- a silent semantic change on exactly
|
||||
the release where they need to be told. The error names the replacement."""
|
||||
import pytest
|
||||
|
||||
with pytest.raises(ValueError, match="erase"):
|
||||
reg.validate_sensitivity("assume_ai")
|
||||
with pytest.raises(ValueError, match="unknown sensitivity"):
|
||||
reg.validate_sensitivity("aggressive")
|
||||
assert reg.validate_sensitivity("auto") == "auto"
|
||||
assert reg.validate_sensitivity("strict") == "strict"
|
||||
|
||||
def test_context_rejects_it_too(self):
|
||||
"""The arbiter's own entry point validates, so a direct `decide()` caller cannot
|
||||
smuggle the removed mode past the public API."""
|
||||
import pytest
|
||||
|
||||
with pytest.raises(ValueError, match=r"removed in 0\.16"):
|
||||
reg.Context(sensitivity="assume_ai")
|
||||
|
||||
|
||||
class TestArbiter:
|
||||
"""``decide`` is the PURE removal arbiter: (candidates, context) -> ordered
|
||||
winners, no image / no I/O. Tested in isolation by handing it fabricated
|
||||
Candidates -- this is the payoff of separating decision from perception."""
|
||||
|
||||
@staticmethod
|
||||
def _c(key, *, strict=False, relaxed=False, flat=False, relaxed_conf=1.0):
|
||||
# relaxed_conf defaults high so a test that does not care about the assumed-trust
|
||||
# confidence floor exercises the trust logic, not the floor.
|
||||
def _c(key, *, strict=False, relaxed=False, flat=False):
|
||||
feats = {"footprint_flat": 1.0} if flat else {}
|
||||
return reg.Candidate(key, f"L:{key}", strict, relaxed, relaxed_conf, feats)
|
||||
return reg.Candidate(key, f"L:{key}", strict, relaxed, feats)
|
||||
|
||||
def _keys(self, cands, ctx):
|
||||
return {d.candidate.key for d in reg.decide(cands, ctx)}
|
||||
@@ -249,35 +303,6 @@ class TestArbiter:
|
||||
# relaxed-only detection must NOT fire under strict
|
||||
assert self._keys([self._c("gemini", relaxed=True)], reg.Context(sensitivity="strict")) == set()
|
||||
|
||||
def test_assume_ai_uses_relaxed(self):
|
||||
fired = reg.decide([self._c("gemini", relaxed=True)], reg.Context(sensitivity="assume_ai"))
|
||||
assert [d.candidate.key for d in fired] == ["gemini"]
|
||||
assert fired[0].relax is True
|
||||
|
||||
def test_assume_ai_drops_sparkle_below_the_assumed_floor(self):
|
||||
# REGRESSION (2026-07-16): assume_ai passed trust_provenance=True to the engine,
|
||||
# bypassing the sparkle false-positive gate on the mere ASSERTION that the image is
|
||||
# AI -- but that flag is contracted to mean "metadata proved this vendor". The bare
|
||||
# bypassed gate (conf 0.35) fired on 59.8% of 256 genuine camera captures, so
|
||||
# `--sensitivity assume-ai` filled a phantom sparkle on ~6 of every 10 clean photos.
|
||||
weak = self._c("gemini", relaxed=True, relaxed_conf=0.40)
|
||||
assert reg.decide([weak], reg.Context(sensitivity="assume_ai")) == []
|
||||
|
||||
def test_assume_ai_keeps_sparkle_confirmed_by_metadata_below_the_floor(self):
|
||||
# the floor exists because the vendor is UNKNOWN; once metadata names Google the
|
||||
# bypass is contract-legal again, so a weak match is still trusted
|
||||
weak = self._c("gemini", relaxed=True, relaxed_conf=0.40)
|
||||
ctx = reg.Context(sensitivity="assume_ai", provenance=frozenset({"gemini"}))
|
||||
assert [d.candidate.key for d in reg.decide([weak], ctx)] == ["gemini"]
|
||||
|
||||
def test_assume_ai_is_monotonic_over_strict(self):
|
||||
# a mark the STRICT gate accepted must never be dropped by the assumed floor:
|
||||
# assume_ai only ever adds recall
|
||||
weak_but_strict = self._c("gemini", strict=True, relaxed=True, relaxed_conf=0.40)
|
||||
fired = reg.decide([weak_but_strict], reg.Context(sensitivity="assume_ai"))
|
||||
assert [d.candidate.key for d in fired] == ["gemini"]
|
||||
assert fired[0].relax is False # accepted on the strict verdict, so mask at strict
|
||||
|
||||
def test_auto_relaxes_on_provenance(self):
|
||||
c = [self._c("gemini", relaxed=True)]
|
||||
assert self._keys(c, reg.Context(provenance=frozenset({"gemini"}))) == {"gemini"}
|
||||
@@ -309,6 +334,35 @@ class TestArbiter:
|
||||
]
|
||||
assert "jimeng_pill" in self._keys(cands, reg.Context())
|
||||
|
||||
def test_weak_pill_detection_does_not_confirm_the_jimeng_wordmark(self):
|
||||
"""The pill is too false-fire-prone (~7%) to grant a sibling `confirmed` trust.
|
||||
|
||||
Corpus-measured defect (2026-07-18): a pill false fire on clean non-ByteDance
|
||||
content confirmed jimeng, relaxing its NCC gate 0.45 -> 0.3825; jimeng then
|
||||
false-fired, and _keep_pill's wordmark arm removed the pill UNRESTRICTED,
|
||||
skipping the flatness guard. Closed loop on the default `auto` path.
|
||||
"""
|
||||
# Only the pill is strictly detected. jimeng scores in the band that is
|
||||
# reachable ONLY via the relaxed gate.
|
||||
cands = [
|
||||
self._c("jimeng_pill", strict=True, relaxed=True, flat=False),
|
||||
self._c("jimeng", strict=False, relaxed=True),
|
||||
]
|
||||
fired = {d.candidate.key for d in reg.decide(cands, reg.Context("auto", frozenset()))}
|
||||
assert "jimeng" not in fired, "a weak pill hit must not relax the jimeng wordmark"
|
||||
# and with jimeng gone, the pill loses the wordmark arm too -- no unrestricted
|
||||
# removal of a textured footprint on content nothing confirmed.
|
||||
assert "jimeng_pill" not in fired
|
||||
|
||||
def test_real_jimeng_wordmark_still_corroborates_the_pill(self):
|
||||
"""The fix removes only the pill's TESTIMONY, not the wordmark's."""
|
||||
cands = [
|
||||
self._c("jimeng", strict=True, relaxed=True),
|
||||
self._c("jimeng_pill", strict=True, relaxed=True, flat=False),
|
||||
]
|
||||
fired = {d.candidate.key for d in reg.decide(cands, reg.Context("auto", frozenset()))}
|
||||
assert fired == {"jimeng", "jimeng_pill"}
|
||||
|
||||
|
||||
class TestProvenanceMaskThreading:
|
||||
"""Regression for the provenance-relaxed Gemini no-op (#1) and the false 'removed'
|
||||
|
||||
Reference in New Issue
Block a user