Improve visible-mark evaluation and Samsung masking

This commit is contained in:
Victor Kuznetsov committed 2026-09-05 11:51:02 -07:00
1 parent 0fd139aac4
commit b8a8c3d3be
12 files changed
+273 -207

No files matched your search

+4 -3
View File
@@ -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
+4 -5
View File
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 MiB

After

Width:  |  Height:  |  Size: 3.5 MiB

+18 -1
View File
@@ -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
+38
View File
@@ -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
+4 -2
View File
@@ -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:
+6 -134
View File
@@ -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
+77 -38
View File
@@ -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
+26 -12
View File
@@ -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.
+45 -3
View File
@@ -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)
+28 -4
View File
@@ -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"),
[
+23 -5
View File
@@ -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)