mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-10-14 05:20:46 +02:00
Improve visible-mark evaluation and Samsung masking
This commit is contained in:
12 files changed
+273
-207
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
@@ -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"),
|
||||
[
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user