diff --git a/data/calibration/samsung/README.md b/data/calibration/samsung/README.md index 7955c90..87c066d 100644 --- a/data/calibration/samsung/README.md +++ b/data/calibration/samsung/README.md @@ -2,9 +2,10 @@ > **Status (built 2026-06-05):** flat black and gray Samsung Galaxy AI captures > were obtained and the detection asset was solved. The asset now supports -> detection and mask geometry only. Current removal localizes the wordmark and -> uses the shared fill backend. The text below records the capture plan and its -> locale and resolution limits. +> detection and detector-aligned alpha-mask geometry. Current removal aligns the +> solved alpha to the detector's winning template box and uses the shared fill +> backend. The text below records the capture plan and its locale and resolution +> limits. Goal: capture the Samsung Galaxy AI "✦ Contenuti generati dall'AI" visible wordmark over known flat backgrounds so we can rebuild and validate the silhouette used diff --git a/data/fixtures/visible/README.md b/data/fixtures/visible/README.md index d0905e0..a7f0694 100644 --- a/data/fixtures/visible/README.md +++ b/data/fixtures/visible/README.md @@ -8,11 +8,10 @@ does an engine that stops detecting its own example. ## What these files are Every example is SYNTHETIC: `scripts/render_visible_examples.py` composites the -mark's committed silhouette (the same font-rendered asset the detector matches) -onto a deterministic generated base photo at the engine's measured geometry. -The silhouettes are repository-owned font renders generated by -`scripts/render_vendor_silhouettes.py`; the gallery does not copy vendor raster -assets. +mark's committed alpha or silhouette onto a deterministic generated base photo at +the engine's measured geometry. Controlled provider captures were used to solve +some alpha assets, including Samsung's measured opacity; the remaining shapes are +repository-owned renders rather than copied vendor rasters. The examples demonstrate DETECTION geometry and house style, not vendor raster fidelity; real-world variants (fonts, opacities, sizes) are covered by the diff --git a/data/fixtures/visible/samsung/example.png b/data/fixtures/visible/samsung/example.png index b301d1a..64f5061 100644 Binary files a/data/fixtures/visible/samsung/example.png and b/data/fixtures/visible/samsung/example.png differ diff --git a/docs/module-internals.md b/docs/module-internals.md index 57a984e..09affd1 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -1326,7 +1326,7 @@ box grows), not the whole `footprint_mask`. Baidu extends right to the corner ta and LiblibAI extends left to the triangle logo; both inherit every guard around that arithmetic. -Doubao and Kling are deliberate sparse-mask exceptions because their alpha assets +Doubao, Kling, and Samsung are deliberate sparse-mask exceptions because their alpha assets supply more information than a rectangle. Doubao's continuous top-hat response locates the mark, then `DoubaoEngine.footprint_mask` resizes the alpha to that same winning `match_box` and masks only the glyphs. The canonical 2048-pixel fixture has bright @@ -1349,6 +1349,23 @@ 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. + +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 +1086-pixel capture width, the mask fell from about 19.7k pixels to 4.9k while one +fill still cleared the detector. Against the old rectangle, regional PSNR improved +on 55/60 images for OpenCV, 55/60 for MI-GAN, and 57/60 for LaMa; median paired +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. + 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 5f49f96..2df5c5a 100644 --- a/docs/verification-plan.md +++ b/docs/verification-plan.md @@ -158,10 +158,48 @@ corpus would make the gate expensive and nondeterministic. Construct marked images from clean local references, compare each backend against the known original inside the affected footprint, and keep the report under `.local-eval/`. +#### Public clean-corpus run, 2026-09-04 + +The local source pool combined 186 Unsplash Lite photos and 235 Openverse Flickr +Creative Commons images. All 421 manifest entries were unique, every referenced +binary existed, and every SHA-256 matched before sampling. The report records only +source filenames and measurements under `.local-eval/`; no image bytes or private +corpus facts enter the repository. + +The 60-source, three-backend run produced 1,980 valid measurements for the 11 +non-Samsung marks. Every mark/backend pair had positive median PSNR recovery over +the stamped input. In paired comparisons, LaMa exceeded OpenCV by a median 1.20 dB +on 610 finite pairs and moved in the favorable direction on 432 versus 175 pairs +(two-sided sign-test `p=4.86e-26`). MI-GAN exceeded OpenCV by 0.31 dB in PSNR, but +its median SSIM was 0.0136 lower; backend preference is therefore metric- and +content-dependent rather than a universal ordering. + +The first Samsung rows from that run are rejected evidence: the forward model +multiplied the already-solved alpha asset by 0.38 a second time and composited +toward the generic 238 luma rather than the measured pure-white glyph. After fixing +that construction, a factorial ablation changed only the removal mask on the same +60 sources. The detector-aligned sparse mask improved PSNR over the old rectangle +on 55/60 OpenCV pairs, 55/60 MI-GAN pairs, and 57/60 LaMa pairs, with median gains +of 14.98, 10.38, and 14.87 dB (`p=1.04e-11`, `1.04e-11`, and `6.25e-14`). Relative +to the correctly stamped damaged input, median recovery was +13.27 dB for OpenCV, ++9.31 dB for MI-GAN, and +15.49 dB for LaMa. These are constructed-reference mask +quality results, not production recall. + ### B2. Detector response curves Use `scripts/detector_response.py` to sweep mark size, opacity, background, and aspect. Report detection and maskability separately. Generated reports stay under `.local-eval/`. +The corrected 2026-09-04 run contains 28,800 stamped cells and 1,800 matched clean +controls over 150 public clean backgrounds. Every mark had 0/150 control fires +(per-mark 95% Wilson upper bound 2.5%), and every detected cell was maskable. Nominal +constructed detection ranged from 54.0% for the Jimeng pill to 96.7% for Yuanbao +among the non-Samsung marks. Samsung reached 20.0%; the earlier 0.7% value is invalid +because it came from the double-attenuated renderer. Samsung still falls sharply on +busy synthetic backgrounds, so this grid identifies a detector-recall follow-up; it +does not justify lowering the threshold because the grid is adversarial and is not a +production-recall sample. The corrected report is +`.local-eval/detector-response-corrected-n150.jsonl`. + ### 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/detector_response.py b/scripts/detector_response.py index 5e1aac2..31289be 100644 --- a/scripts/detector_response.py +++ b/scripts/detector_response.py @@ -82,7 +82,9 @@ REPO = Path(__file__).resolve().parents[1] sys.path.insert(0, str(REPO / "src")) sys.path.insert(0, str(REPO / "scripts")) -from fill_quality import SLOT_STAMPABLE, STAMPABLE, clean_sources, stamp_any, texture_of # noqa: E402 +from fill_quality import clean_sources, stamp_any, texture_of # noqa: E402 + +from remove_ai_watermarks import watermark_registry as wr # noqa: E402 OUT = REPO / ".local-eval" / "detector-response.jsonl" @@ -91,7 +93,7 @@ OUT = REPO / ".local-eval" / "detector-response.jsonl" # front-end was introduced for) and above it (a larger re-render, or an upscaled upload). SIZES = (0.6, 0.8, 1.0, 1.3) ALPHAS = (0.3, 0.5, 0.75, 1.0) -MARKS = (*STAMPABLE, *SLOT_STAMPABLE) +MARKS = tuple(wr.mark_keys()) def aspect_of(image: np.ndarray) -> str: diff --git a/scripts/fill_quality.py b/scripts/fill_quality.py index bfe7632..fc738bf 100644 --- a/scripts/fill_quality.py +++ b/scripts/fill_quality.py @@ -53,25 +53,6 @@ from invisible_quality_audit import _ssim # noqa: E402 -- path selects this wor CORPUS = REPO / ".local-eval" / "originals" OUT = REPO / ".local-eval" / "fill-quality.jsonl" -# Text marks: a bundled alpha PNG plus the engine's own corner geometry. -STAMPABLE = ( - "doubao", - "jimeng", - "qwen", - "kling", - "yuanbao", - "samsung", - "runninghub", - "baidu", - "liblib", - "microsoft", -) -# Marks with no bundled alpha asset. Both expose a default footprint via -# `footprint_mask(force=True)`, so they are stamped by fitting their own alpha source -# into that slot: Gemini's alpha is derived from its background captures, the pill's -# from its synthetic font-rendered silhouette. -SLOT_STAMPABLE = ("gemini", "jimeng_pill") - def psnr(a: np.ndarray, b: np.ndarray) -> float: mse = float(np.mean((a.astype(np.float64) - b.astype(np.float64)) ** 2)) @@ -86,122 +67,13 @@ def texture_of(box: np.ndarray) -> float: return float(np.median(cv2.magnitude(gx, gy))) -def engine_for(mark_key: str) -> Any: - """The TextMarkEngine instance for a mark key (the registry does not expose one).""" - import importlib - - mod = importlib.import_module(f"remove_ai_watermarks.{mark_key}_engine") - cls = next( - obj - for name, obj in vars(mod).items() - # Exclude the imported base class: it takes a `config` arg, the subclasses do not. - if isinstance(obj, type) and name.endswith("Engine") and name != "TextMarkEngine" - ) - return cls() - - -def composite(image: np.ndarray, alpha: np.ndarray, x: int, y: int) -> np.ndarray: - """The single forward model every stamp uses: ``stamped = (1-a)*bg + a*white``. - - ``alpha`` is float and clipped to [0,1] here, so a caller may pre-multiply it by an - opacity factor without worrying about overflow. Shared by ``stamp``/``stamp_slot`` - here and by ``scripts/detector_response.py`` (which imports it) so the model is - written ONCE. - """ - gh, gw = alpha.shape[:2] - out = image.copy() - roi = out[y : y + gh, x : x + gw].astype(np.float32) - a3 = np.clip(alpha, 0.0, 1.0)[..., None] - out[y : y + gh, x : x + gw] = np.clip(roi * (1 - a3) + 255.0 * a3, 0, 255).astype(np.uint8) - return out - - -def stamp( - image: np.ndarray, mark_key: str, *, size_mult: float = 1.0, alpha_mult: float = 1.0 -) -> tuple[np.ndarray, tuple[int, int, int, int]] | None: - """Composite a mark's alpha glyph into its canonical corner. Returns (stamped, bbox). - - ``size_mult`` scales the glyph box off the engine's nominal geometry and ``alpha_mult`` - its opacity -- both default to 1.0 (the geometry the engine assumes), and - `scripts/detector_response.py` sweeps them to build response curves. - """ - from remove_ai_watermarks._text_mark_engine import load_alpha_template - - engine = engine_for(mark_key) - cfg = engine.config - alpha = load_alpha_template(cfg.asset_name) - if alpha is None: - return None - - loc = engine.locate(image) - base = engine.scale_base(image) - gw = max(cfg.min_gw, int(cfg.alpha_width_frac * base * size_mult)) - gh = max(4, int(cfg.alpha_height_frac * base * size_mult)) - if gw < 8 or gh < 4 or gw > loc.w or gh > loc.h: - return None - - a = cv2.resize(alpha, (gw, gh), interpolation=cv2.INTER_AREA).astype(np.float32) * alpha_mult - x = loc.x + (loc.w - gw) // 2 - y = loc.y + (loc.h - gh) // 2 - return composite(image, a, x, y), (x, y, gw, gh) - - -def _slot_alpha(mark_key: str) -> np.ndarray | None: - """The alpha source for a mark that ships no bundled alpha PNG.""" - if mark_key == "gemini": - from remove_ai_watermarks.gemini_engine import _shared_engine - - # _shared_engine is the package's own lru_cache singleton: constructing - # GeminiEngine() per image reloads its captures, recomputes both alpha maps and - # rebuilds the whole template ladder -- the exact cost that singleton exists to - # avoid. Private attribute access is deliberate (no accessor exists); a - # measurement script may reach in, product code must not. - return np.asarray(_shared_engine()._alpha_large, dtype=np.float32) - if mark_key == "jimeng_pill": - from remove_ai_watermarks._text_mark_engine import load_alpha_template - - return load_alpha_template("jimeng_pill.png") - return None - - -def stamp_slot( - image: np.ndarray, mark_key: str, *, size_mult: float = 1.0, alpha_mult: float = 1.0 -) -> tuple[np.ndarray, tuple[int, int, int, int]] | None: - """Stamp a mark into its OWN default footprint slot (the `--no-detect` geometry). - - Used for gemini and the pill, which have no bundled alpha asset with corner - fractions. The mark is fitted into the middle of its slot so the fill has to - recover the same kind of region it would in production. ``size_mult``/``alpha_mult`` - match ``stamp`` (both default to 1.0). - """ - from remove_ai_watermarks.watermark_registry import get_mark - - alpha = _slot_alpha(mark_key) - if alpha is None: - return None - loc = get_mark(mark_key).localize(image, force=True) - if loc.mask is None: - return None - ys, xs = np.nonzero(loc.mask) - if ys.size == 0: - return None - y0, y1, x0, x1 = int(ys.min()), int(ys.max()), int(xs.min()), int(xs.max()) - bw, bh = x1 - x0 + 1, y1 - y0 + 1 - gw, gh = max(8, int(bw * 0.7 * size_mult)), max(8, int(bh * 0.7 * size_mult)) - a = cv2.resize(alpha, (gw, gh), interpolation=cv2.INTER_AREA).astype(np.float32) * alpha_mult - x, y = x0 + (bw - gw) // 2, y0 + (bh - gh) // 2 - # A large size_mult can grow the glyph past its slot and push x/y negative; guard so - # composite never writes out of bounds (numpy would wrap a negative index silently). - if x < 0 or y < 0 or x + gw > image.shape[1] or y + gh > image.shape[0]: - return None - return composite(image, a, x, y), (x, y, gw, gh) - - def stamp_any( image: np.ndarray, mark_key: str, *, size_mult: float = 1.0, alpha_mult: float = 1.0 ) -> tuple[np.ndarray, tuple[int, int, int, int]] | None: - fn = stamp if mark_key in STAMPABLE else stamp_slot - return fn(image, mark_key, size_mult=size_mult, alpha_mult=alpha_mult) + """Stamp via the same forward model that produces the verified gallery.""" + from render_visible_examples import stamp_image_mark + + return stamp_image_mark(mark_key, image, size_mult=size_mult, alpha_mult=alpha_mult) def clean_sources(n: int, seed: int = 11) -> list[Path]: @@ -241,7 +113,7 @@ def main() -> None: from remove_ai_watermarks.image_io import imread from remove_ai_watermarks.region_eraser import lama_available, migan_available - from remove_ai_watermarks.watermark_registry import fill, get_mark + from remove_ai_watermarks.watermark_registry import fill, get_mark, mark_keys backends = ["cv2"] + (["migan"] if migan_available() else []) + (["lama"] if lama_available() else []) print(f"backends under test: {backends}") @@ -255,7 +127,7 @@ def main() -> None: base = imread(str(src)) if base is None: continue - for key in (*STAMPABLE, *SLOT_STAMPABLE): + for key in mark_keys(): st = stamp_any(base, key) if st is None: continue diff --git a/scripts/render_visible_examples.py b/scripts/render_visible_examples.py index 7a97c3a..5211c26 100644 --- a/scripts/render_visible_examples.py +++ b/scripts/render_visible_examples.py @@ -5,8 +5,9 @@ sample of everything it supports and a canary test can hold both sides to it (mark registered without example; engine regressed on its canonical example). Every example is SYNTHETIC: a deterministic generated base photo with the mark's -own committed silhouette composited at the engine's measured geometry. The -silhouettes are repository-owned font renders, not copied vendor raster assets. +own committed alpha or silhouette composited at the engine's measured geometry. +Some alpha assets were solved from controlled provider captures; the other shapes +are repository-owned renders rather than copied vendor rasters. Regenerate with: uv run python scripts/render_visible_examples.py @@ -16,37 +17,31 @@ from __future__ import annotations import sys from pathlib import Path -from typing import TYPE_CHECKING import cv2 import numpy as np -if TYPE_CHECKING: - from collections.abc import Callable - _ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(_ROOT / "src")) from remove_ai_watermarks import watermark_registry as wr # noqa: E402 from remove_ai_watermarks.image_io import imread # noqa: E402 -ASSETS = _ROOT / "src" / "remove_ai_watermarks" / "assets" OUT = _ROOT / "data" / "fixtures" / "visible" # Composite strength per key: glyph target luma for the light-overlay class. -# Kling is a thin light-gray run (not near-white); Samsung is a faint overlay -# expressed by SCALING ITS ALPHA to 0.38 toward full white (see below); everything -# else is the bold near-white house style. +# Kling is a thin light-gray run. Samsung's solved asset already stores its faint +# measured opacity and was solved against pure white, so it must use 255 without a +# second attenuation. Everything else uses the bold near-white house style. _STRENGTH: dict[str, int] = { "kling": 208, + "samsung": 255, } _DEFAULT_STRENGTH = 238 # Base geometry: one canonical size per mark where the engine's size modes or # calibrated orientation matter, otherwise a plain 3:2 landscape frame. -# Samsung keeps the larger base: its overlay is faint (peak alpha ~0.38) and -# the real marks live on ~2958px phone photos -- at 1536 the example falls to 0.39, -# just under the engine's 0.40 gate. +# Samsung keeps the larger base because real marks live on ~2958px phone photos. _SIZE: dict[str, tuple[int, int]] = { "qwen": (1536, 1536), "liblib": (1152, 1536), @@ -73,22 +68,31 @@ def base_photo(w: int, h: int, seed: int = 7) -> np.ndarray: def _glyph_asset(name: str) -> np.ndarray: - at = imread(str(ASSETS / name), cv2.IMREAD_GRAYSCALE) + from remove_ai_watermarks._text_mark_engine import load_alpha_template + + at = load_alpha_template(name) if at is None: raise RuntimeError(f"missing silhouette asset: {name}") - return at.astype(np.float32) / 255.0 + return at def _composite_light(base: np.ndarray, alpha: np.ndarray, x: int, y: int, strength: int) -> np.ndarray: out = base.copy() h, w = alpha.shape[:2] roi = out[y : y + h, x : x + w].astype(np.float32) + alpha = np.clip(alpha, 0.0, 1.0) a3 = alpha[:, :, None] if alpha.ndim == 2 else alpha out[y : y + h, x : x + w] = np.clip(roi * (1 - a3) + strength * a3, 0, 255).astype(np.uint8) return out -def _text_mark_example(key: str, base: np.ndarray) -> np.ndarray: +def _stamp_text_mark( + key: str, + base: np.ndarray, + *, + size_mult: float, + alpha_mult: float, +) -> tuple[np.ndarray, tuple[int, int, int, int]] | None: engine = wr._engine(key) # the generator drives the engine's own config cfg = engine.config h, w = base.shape[:2] @@ -96,22 +100,27 @@ def _text_mark_example(key: str, base: np.ndarray) -> np.ndarray: # Opaque white pill with dark text/sparkle holes, at the measured inset. at = _glyph_asset("microsoft_alpha.png") long_side = max(w, h) - pw = int(0.152 * long_side) + pw = int(0.152 * long_side * size_mult) ph = max(4, int(pw / (at.shape[1] / at.shape[0]))) pad = int(0.010 * long_side) + if pw > w - pad or ph > h - pad: + return None pill = cv2.resize(at, (pw, ph)) x, y = w - pad - pw, pad out = base.copy() bright = (pill > 0.6)[:, :, None] - out[y : y + ph, x : x + pw] = np.where(bright, 245.0, 46.0).astype(np.uint8) - return out + target = np.where(bright, 245.0, 46.0).astype(np.float32) + blend = float(np.clip(alpha_mult, 0.0, 1.0)) + roi = out[y : y + ph, x : x + pw].astype(np.float32) + out[y : y + ph, x : x + pw] = np.clip(roi * (1 - blend) + target * blend, 0, 255).astype(np.uint8) + return out, (x, y, pw, ph) base_dim = {"short": min(w, h), "width": w, "long": max(w, h)}[cfg.scale_basis] # Size the glyph ON a ladder rung: the continuous front ends sweep only the # configured rungs, and a glyph sized between rungs collapses the NCC (the # comb-collapse qwen's own two-rung ladder exists to avoid). rung = max(cfg.ladder) if cfg.detect_frontend != "binary" else 1.0 - gw = int(cfg.alpha_width_frac * base_dim * rung) - gh = max(4, int(cfg.alpha_height_frac * base_dim * rung)) + gw = max(cfg.min_gw, int(cfg.alpha_width_frac * base_dim * rung * size_mult)) + gh = max(4, int(cfg.alpha_height_frac * base_dim * rung * size_mult)) loc = engine.locate(base) # Corner-hugging placement: the yuanbao/runninghub anchor gates demote a match # that does not hug the corner, and the real marks sit flush on the box's @@ -124,39 +133,67 @@ def _text_mark_example(key: str, base: np.ndarray) -> np.ndarray: x = loc.x y = loc.y if cfg.corner in ("tl", "tr") else loc.y + loc.h - gh x, y = max(0, x), max(0, y) + if x + gw > w or y + gh > h: + return None at = _glyph_asset(f"{key}_alpha.png") alpha = cv2.resize(at, (gw, gh)) - if key == "samsung": # faint overlay: peak alpha 0.38 toward FULL white - alpha = alpha * 0.38 - return _composite_light(base, alpha, x, y, _STRENGTH.get(key, _DEFAULT_STRENGTH)) + alpha = alpha * alpha_mult + return _composite_light(base, alpha, x, y, _STRENGTH.get(key, _DEFAULT_STRENGTH)), (x, y, gw, gh) -def _gemini_example(base: np.ndarray) -> np.ndarray: - from remove_ai_watermarks.gemini_engine import GeminiEngine, get_watermark_config, get_watermark_size +def _stamp_gemini( + base: np.ndarray, + *, + size_mult: float, + alpha_mult: float, +) -> tuple[np.ndarray, tuple[int, int, int, int]] | None: + from remove_ai_watermarks.gemini_engine import _shared_engine, get_watermark_config, get_watermark_size h, w = base.shape[:2] - eng = GeminiEngine() + eng = _shared_engine() size = get_watermark_size(w, h) alpha = eng.get_alpha_map(size) cfg = get_watermark_config(w, h) x, y = cfg.get_position(w, h) - return _composite_light(base, alpha.astype(np.float32), x, y, 255) + ah, aw = alpha.shape[:2] + gw, gh = max(8, int(aw * size_mult)), max(8, int(ah * size_mult)) + x, y = x + aw - gw, y + ah - gh + if x < 0 or y < 0 or x + gw > w or y + gh > h: + return None + resized = cv2.resize(alpha, (gw, gh), interpolation=cv2.INTER_AREA).astype(np.float32) * alpha_mult + return _composite_light(base, resized, x, y, 255), (x, y, gw, gh) -def _pill_example(base: np.ndarray) -> np.ndarray: +def _stamp_pill( + base: np.ndarray, + *, + size_mult: float, + alpha_mult: float, +) -> tuple[np.ndarray, tuple[int, int, int, int]] | None: h, w = base.shape[:2] at = _glyph_asset("jimeng_pill.png") - pw = max(24, int(0.161 * w)) + pw = max(24, int(0.161 * w * size_mult)) ph = max(8, int(pw * at.shape[0] / at.shape[1])) x, y = int(0.03 * w), int(0.03 * h) - alpha = cv2.resize(at, (pw, ph)) - return _composite_light(base, alpha, x, y, 232) + if x + pw > w or y + ph > h: + return None + alpha = cv2.resize(at, (pw, ph)) * alpha_mult + return _composite_light(base, alpha, x, y, 232), (x, y, pw, ph) -_BUILDERS: dict[str, Callable[[np.ndarray], np.ndarray]] = { - "gemini": _gemini_example, - "jimeng_pill": _pill_example, -} +def stamp_image_mark( + key: str, + base: np.ndarray, + *, + size_mult: float = 1.0, + alpha_mult: float = 1.0, +) -> tuple[np.ndarray, tuple[int, int, int, int]] | None: + """Composite one image mark using the gallery's verified forward model.""" + if key == "gemini": + return _stamp_gemini(base, size_mult=size_mult, alpha_mult=alpha_mult) + if key == "jimeng_pill": + return _stamp_pill(base, size_mult=size_mult, alpha_mult=alpha_mult) + return _stamp_text_mark(key, base, size_mult=size_mult, alpha_mult=alpha_mult) def build( @@ -177,8 +214,10 @@ def build_pair( """Return the clean synthetic source and the matching marked image.""" size = size or _SIZE.get(key, (1536, 1152)) clean = base_photo(*size, seed=seed) - builder = _BUILDERS.get(key) - marked = builder(clean) if builder is not None else _text_mark_example(key, clean) + stamped = stamp_image_mark(key, clean) + if stamped is None: + raise RuntimeError(f"could not stamp {key} at its nominal geometry") + marked, _box = stamped return clean, marked diff --git a/src/remove_ai_watermarks/_text_mark_engine.py b/src/remove_ai_watermarks/_text_mark_engine.py index 97e23e6..2ad671b 100644 --- a/src/remove_ai_watermarks/_text_mark_engine.py +++ b/src/remove_ai_watermarks/_text_mark_engine.py @@ -170,10 +170,11 @@ class TextMarkDetection: confidence: float = 0.0 region: tuple[int, int, int, int] = (0, 0, 0, 0) coverage: float = 0.0 # fraction of the box occupied by glyph pixels - # ROI-local (x0, y0, x1, y1) of the ladder sweep's best match, in the LOCATED BOX's - # coordinates. None for the ``binary`` front-end (it runs no sweep) and whenever no - # rung matched. ``footprint_mask`` bounds the fill with it, so carrying it here is - # what stops the mask path re-running a sweep the detector already ran. + # ROI-local (x0, y0, x1, y1) of the template's best match, in the LOCATED BOX's + # coordinates. Continuous front ends get it from the ladder sweep; ``binary`` gets + # it from its single nominal-size match. None whenever no template matched. + # ``footprint_mask`` bounds the fill with it, so carrying it here is what stops the + # mask path re-running a search the detector already ran. match_box: tuple[int, int, int, int] | None = None # The trust level this detection was taken at, mirroring detect()'s ``provenance``. # ``footprint_mask`` reuses a threaded detection only when it matches the STRICT @@ -258,9 +259,10 @@ def _rival_config(asset_name: str, fallback: TextMarkConfig) -> TextMarkConfig: return fallback -def template_match_score(box_mask: NDArray[Any], scale_base: int, config: TextMarkConfig) -> float: - """Zero-mean normalized correlation of the alpha-template glyph silhouette - (scaled to the mark's expected size) against the candidate ``box_mask``. +def _template_match_best( + box_mask: NDArray[Any], scale_base: int, config: TextMarkConfig +) -> tuple[float, tuple[int, int, int, int] | None]: + """Best nominal-size template score and its inclusive ROI-local box. ``TM_CCOEFF_NORMED`` keys on glyph SHAPE, not coverage, so a dense textured corner does not score highly -- only the actual glyph shape does. @@ -271,13 +273,21 @@ def template_match_score(box_mask: NDArray[Any], scale_base: int, config: TextMa """ sil = glyph_silhouette(config.asset_name) if sil is None or box_mask.size == 0: - return 0.0 + return (0.0, None) gw = min(box_mask.shape[1] - 1, max(config.min_gw, int(config.alpha_width_frac * scale_base))) gh = min(box_mask.shape[0] - 1, max(4, int(config.alpha_height_frac * scale_base))) if gw < config.min_gw or gh < 4: - return 0.0 + return (0.0, None) template = cv2.resize(sil, (gw, gh), interpolation=cv2.INTER_NEAREST) - return float(cv2.matchTemplate(box_mask, template, cv2.TM_CCOEFF_NORMED).max()) + result = cv2.matchTemplate(box_mask, template, cv2.TM_CCOEFF_NORMED) + _, score, _, top_left = cv2.minMaxLoc(result) + x, y = int(top_left[0]), int(top_left[1]) + return (float(score), (x, y, x + gw - 1, y + gh - 1)) + + +def template_match_score(box_mask: NDArray[Any], scale_base: int, config: TextMarkConfig) -> float: + """Zero-mean normalized correlation of the expected glyph against ``box_mask``.""" + return _template_match_best(box_mask, scale_base, config)[0] class TextMarkEngine: @@ -626,7 +636,10 @@ class TextMarkEngine: # front-end that binarizes; the continuous ones never build a blob. Below # the gate the detection stays at confidence 0.0 and the rival margin is # never consulted, so the score stays None here. - score = self._template_match_score(box, base) if coverage >= c.detect_min_coverage else None + if coverage >= c.detect_min_coverage: + score, match_box = _template_match_best(box, base, c) + else: + score = None else: score, match_box = self._ladder_best(image, loc) return TextMarkScan(loc, box, base, frame=image.shape[:2], coverage=coverage, score=score, match_box=match_box) @@ -850,7 +863,8 @@ class TextMarkEngine: themselves were the mask), but the inpaint reconstructs the whole wordmark rectangle from its surroundings, so a stroke missed by the top-hat is still covered. This drops the fixed alpha-template dependency, so a re-rendered or - differently-localized mark (e.g. a non-Italian Samsung string) is still masked. + differently-rendered mark can still be masked by engines that keep this default + rectangular policy. Engines with measured alpha footprints override this method. With ``force`` and no glyph found, falls back to the whole geometry box (the ``--no-detect`` path). The caller gates on detection. diff --git a/src/remove_ai_watermarks/samsung_engine.py b/src/remove_ai_watermarks/samsung_engine.py index 062431a..a418095 100644 --- a/src/remove_ai_watermarks/samsung_engine.py +++ b/src/remove_ai_watermarks/samsung_engine.py @@ -8,7 +8,7 @@ It is a faint, near-white semi-transparent overlay, the same overlay class as th Doubao/Jimeng marks but bottom-left. Detection matches the bundled glyph silhouette against the corner; removal is the -shared **localize -> fill** (the glyph-bbox :meth:`footprint_mask` feeds +shared **localize -> fill** (a detector-aligned alpha :meth:`footprint_mask` feeds ``region_eraser``), NOT reverse-alpha. This module shares :class:`remove_ai_watermarks._text_mark_engine.TextMarkEngine` and supplies only Samsung's tuned :class:`TextMarkConfig` (bottom-LEFT corner, a lower glyph @@ -27,8 +27,8 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any -from remove_ai_watermarks import _text_mark_engine -from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkEngine +from remove_ai_watermarks import _text_mark_engine, image_io +from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkDetection, TextMarkEngine if TYPE_CHECKING: from numpy.typing import NDArray @@ -59,6 +59,13 @@ _ALPHA_NATIVE_WIDTH = 1086 _ALPHA_WIDTH_FRAC = 0.3195 # asset width / image width -- sizes the detection silhouette _ALPHA_HEIGHT_FRAC = 0.0378 +# The solved alpha carries a wide low-opacity halo. A 0.05 floor plus one-pixel +# dilation covered the visible strokes on both retained provider captures while +# reducing the fill from ~19.7k pixels to ~4.9k at 1086 px width. It also covers +# >=94% of every changed pixel on both deterministic gallery backgrounds. +_FOOTPRINT_ALPHA_FLOOR = 0.05 +_FOOTPRINT_DILATE = 1 + _CONFIG = TextMarkConfig( name="Samsung Galaxy AI", asset_name="samsung_alpha.png", @@ -100,3 +107,38 @@ class SamsungEngine(TextMarkEngine): def __init__(self) -> None: super().__init__(_CONFIG) + + def footprint_mask( + self, + image: NDArray[Any] | None, + *, + force: bool = False, + dilate: int | None = None, + detection: TextMarkDetection | None = None, + ) -> NDArray[Any] | None: + """Return the detector-aligned Samsung glyph footprint. + + 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; + align the solved alpha to that box and mask its strokes only. Explicit + ``force`` has no trustworthy alignment and retains the shared geometry box. + """ + if force or image is None or image.size == 0: + return super().footprint_mask(image, force=force, dilate=dilate, detection=detection) + + image = image_io.to_bgr(image) + det = detection if detection is not None else self.detect(image) + alpha = _alpha_template() + if alpha is not None: + radius = _FOOTPRINT_DILATE if dilate is None else max(0, dilate) + aligned = self._aligned_alpha_mask( + image, + det, + alpha, + alpha_floor=_FOOTPRINT_ALPHA_FLOOR, + dilate=radius, + ) + if aligned is not None: + return aligned + return super().footprint_mask(image, force=False, dilate=dilate, detection=det) diff --git a/tests/test_samsung_engine.py b/tests/test_samsung_engine.py index d87b434..b895983 100644 --- a/tests/test_samsung_engine.py +++ b/tests/test_samsung_engine.py @@ -1,18 +1,20 @@ """Tests for the Samsung Galaxy AI visible-watermark engine. -No real Samsung sample is committed (the real-photo captures are gitignored, repo -is public), so detection/removal is exercised against a watermark synthesized from -the bundled alpha asset itself -- self-consistent and download-free. The mark is -anchored bottom-LEFT (unlike the bottom-right Doubao/Jimeng marks). +Detection and removal use both controlled flat provider captures and marks synthesized +from the bundled alpha asset. The mark is anchored bottom-LEFT (unlike the bottom-right +Doubao/Jimeng marks). """ from __future__ import annotations +from pathlib import Path + import cv2 import numpy as np import pytest from remove_ai_watermarks import watermark_registry as registry +from remove_ai_watermarks.region_eraser import erase_cv2 from remove_ai_watermarks.samsung_engine import ( _ALPHA_HEIGHT_FRAC, _ALPHA_NATIVE_WIDTH, @@ -130,6 +132,28 @@ class TestAlphaAssetAndRemoval: assert region is not None assert not SamsungEngine().detect(out).detected + def test_real_gray_capture_uses_a_sparse_detector_aligned_footprint(self): + """The faint real glyph must not turn its whole text rectangle into a hole.""" + path = Path(__file__).parents[1] / "data/calibration/samsung/samsung_gray_1.png" + image = cv2.imread(str(path), cv2.IMREAD_COLOR) + eng = SamsungEngine() + det = eng.detect(image) + + assert det.detected + assert det.match_box is not None + mask = eng.footprint_mask(image, detection=det) + assert mask is not None + assert np.count_nonzero(mask) < 10_000 + + out = erase_cv2(image, mask) + loc = eng.locate(image) + roi = np.s_[loc.y : loc.y + loc.h, loc.x : loc.x + loc.w] + clean_color = np.median(image[: image.shape[0] // 2].reshape(-1, 3), axis=0) + before_mae = float(np.mean(np.abs(image[roi].astype(np.float32) - clean_color))) + after_mae = float(np.mean(np.abs(out[roi].astype(np.float32) - clean_color))) + assert after_mae < before_mae * 0.25 + assert not eng.detect(out).detected + @pytest.mark.parametrize( ("w", "h"), [ diff --git a/tests/test_visible_fill_quality.py b/tests/test_visible_fill_quality.py index 0fc5143..78fa2c6 100644 --- a/tests/test_visible_fill_quality.py +++ b/tests/test_visible_fill_quality.py @@ -7,9 +7,10 @@ import numpy as np import pytest from remove_ai_watermarks import watermark_registry as wr -from scripts.fill_quality import SLOT_STAMPABLE, STAMPABLE, psnr, stamp_any +from scripts.detector_response import ALPHAS, SIZES +from scripts.fill_quality import psnr, stamp_any from scripts.invisible_quality_audit import _ssim -from scripts.render_visible_examples import build_pair +from scripts.render_visible_examples import _glyph_asset, build_pair, stamp_image_mark _IMAGE_KEYS = tuple(mark.key for mark in wr.known_marks()) @@ -49,11 +50,28 @@ def _score_box(clean: np.ndarray, filled: np.ndarray, changed: np.ndarray) -> tu ) -def test_full_quality_harness_covers_every_registered_image_mark() -> None: - assert {*STAMPABLE, *SLOT_STAMPABLE} == set(_IMAGE_KEYS) +def test_samsung_stamp_uses_the_solved_alpha_exactly_once() -> None: + """The asset already stores the measured ~0.38 peak opacity.""" + clean = np.full((1448, 1086, 3), 100, np.uint8) + stamped = stamp_image_mark("samsung", clean) + assert stamped is not None + marked, (x, y, w, h) = stamped + alpha = cv2.resize(_glyph_asset("samsung_alpha.png"), (w, h), interpolation=cv2.INTER_LINEAR) + expected = np.clip(100.0 * (1.0 - alpha) + 255.0 * alpha, 0, 255).astype(np.uint8) + assert np.array_equal(marked[y : y + h, x : x + w, 0], expected) + + +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: clean, _marked = build_pair(key) - assert stamp_any(clean, key) is not None, key + for size_mult in SIZES: + for alpha_mult in ALPHAS: + assert stamp_any(clean, key, size_mult=size_mult, alpha_mult=alpha_mult) is not None, ( + key, + size_mult, + alpha_mult, + ) @pytest.mark.parametrize("key", _IMAGE_KEYS)