mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-10 08:00:32 +02:00
The visible-mark path had grown three copies of one ladder sweep, four
near-identical `detect` arms, and four hand-rolled `footprint_mask` overrides;
mark knowledge sat in five hand-maintained tables across three modules; and the
flagship `all`/`batch` pipeline existed only in cli.py, written twice with
divergent behavior.
Detection is now one measurement. `_ladder_best` replaces the three sweeps,
`_scan`/`_verdict` replace the four arms, and the winning box travels to the
mask on `TextMarkDetection.match_box` instead of being swept a second time.
`detect_both` returns the strict and relaxed verdicts from one scan, which
halves the arbiter's perception cost (260 -> 130 matchTemplate calls on a 2048²
image, verdicts identical field for field). A per-mark demotion goes in the new
`_post_gate` hook, never in a `detect` override -- an override is invisible to
the single-pass path, which is how the RunningHub and Yuanbao anchor gates
briefly stopped applying.
Everything about a mark is now one registry row: product, label regime, the
platform sentence `identify` reports, the metadata signals that confirm it, and
its TC260 producer codes. `identify._VISIBLE_MARK_PLATFORM`, the signal mapping
in `api.visible_provenance`, `_PRODUCT_OF` and the pill veto are derived from
those rows.
`api.remove_all` / `api.remove_batch` are the library form of the `all` and
`batch` commands; the CLI is a wrapper that owns console text and exit codes.
Progress is a `(stage, detail)` pair of stable tokens, so the CLI keys its
wording off structure rather than parsing the library's prose back.
Two intentional behavior changes, both verified against a recorded 811-image
sample of detector verdicts, removal-mask hashes, arbiter decisions and
`identify` reports:
* A TC260 label now relaxes the vendor its `ContentProducer` names rather than
ByteDance's pair on every China-AIGC image. 333 of 811 samples move; on 185
of them the previously relaxed pair was simply the wrong vendor, and the
mark actually present never reached the relaxed gate its own
`provenance_ncc_factor` was calibrated for.
* A confident LibLibAI detection suppresses the Jimeng pill, like every other
TC260 product's mark. It was registered alongside RunningHub and Baidu, both
of which were added to the hand-written veto list, and it was not. 1 sample
moves, and it is exactly the co-firing case.
Nothing else in that record changes: detector verdicts, mask hashes and
`identify` verdicts are byte-identical, and all 200 calibration constants are
untouched.
Also: `aigc_label` and friends plus `extract_c2pa_info` are memoized on
(path, mtime_ns, size) -- size because this package rewrites in place; the
native TC260 container readers route on magic bytes instead of the file
extension, so a mislabeled AVI or FLV is no longer invisible; `identify` shares
one pixel decode between the DWT-DCT and visible stages (TrustMark keeps its own
Pillow decode, which is not substitutable); and the six `stabilize_*` video
wrappers collapse into one policy table.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
835 lines
42 KiB
Python
835 lines
42 KiB
Python
"""Shared base for visible text-mark detectors and localizers.
|
|
|
|
Each mark supplies a :class:`TextMarkConfig` with its silhouette, expected area,
|
|
scale ladder, detector frontend, and calibrated gates. The shared engine locates the
|
|
candidate, scores the silhouette with normalized correlation, and builds a removal
|
|
footprint for the common fill backend. Individual engines may override detection or
|
|
footprint behavior when their measured variant requires it.
|
|
|
|
The removal path never performs reverse-alpha pixel recovery. Gemini and the Jimeng
|
|
pill remain separate engines because their geometry and gating differ from text marks.
|
|
"""
|
|
|
|
# cv2/numpy boundary: third-party libs ship no usable element types; relax the
|
|
# unknown-type rules for this file only.
|
|
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownParameterType=false, reportMissingTypeArgument=false, reportMissingTypeStubs=false, reportMissingImports=false, reportArgumentType=false, reportAssignmentType=false, reportReturnType=false, reportCallIssue=false, reportIndexIssue=false, reportOperatorIssue=false, reportOptionalMemberAccess=false, reportOptionalCall=false, reportOptionalSubscript=false, reportOptionalOperand=false, reportAttributeAccessIssue=false, reportPrivateImportUsage=false, reportPrivateUsage=false, reportInvalidTypeForm=false, reportConstantRedefinition=false, reportUnnecessaryComparison=false
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Any, Literal
|
|
|
|
import cv2
|
|
import numpy as np
|
|
|
|
from remove_ai_watermarks import image_io
|
|
|
|
if TYPE_CHECKING:
|
|
from numpy.typing import NDArray
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Minimum image short side (px) for text-mark DETECTION. Below this the glyph
|
|
# template degrades to the ``min_gw`` floor (~8 px) and TM_CCOEFF_NORMED on a few
|
|
# pixels is noise, so an unrelated small geometric shape can spuriously correlate
|
|
# with the CJK silhouette (2026-06-26 FP: a 48x48 app icon -- a blue chevron --
|
|
# scored Doubao 0.41 / Jimeng 0.47, both above their thresholds). The FP is purely
|
|
# a small-size artifact: the same icon upscaled collapses to ~0.06-0.10 NCC at 256
|
|
# px and above. A real AI-generation text label is stamped on a full-resolution
|
|
# render (the captured samples are 1086-2048 px wide), so 200 px sits far below any
|
|
# genuine mark while killing the icon/thumbnail noise band (<=96 px). Detection is
|
|
# skipped (verdict stays "unknown", the safe default) rather than risk a false
|
|
# positive; removal is gated on detection, so it is suppressed too.
|
|
_MIN_DETECT_SHORT_SIDE = 200
|
|
|
|
# Provenance-confirmed NCC relaxation. When external metadata already confirms the
|
|
# vendor (so the mark is present with high prior), a faint or slightly re-rendered
|
|
# glyph that scores just below the standard NCC gate is still trusted. The relaxed
|
|
# gate is ``detect_ncc_threshold * provenance_ncc_factor``; the coverage gate still
|
|
# applies on top.
|
|
#
|
|
# This used to be ONE shared 0.7 for every text mark. Measured 2026-07-18 on the
|
|
# `auto` path (the default -- no flag, driven by TC260 metadata), it turned out to
|
|
# mean two completely different things per mark. Blind hand-label of the ADDITIONS
|
|
# (accepted with provenance, rejected without) over a labelled TC260 evaluation set,
|
|
# two-sided control (labeller sensitivity 100%/96%, specificity 100%/100%):
|
|
#
|
|
# mark band precision 95% CI n
|
|
# doubao whole arm 76% 61-87% 42
|
|
# [0.280,0.340) 58% 36-77% 19
|
|
# [0.340,0.400) 91% 73-98% 23
|
|
# jimeng whole arm 17% 10-27% 82
|
|
# [0.315,0.383) 12% 6-22% 68
|
|
# [0.383,0.450) 43% 21-67% 14
|
|
#
|
|
# Doubao stays at 0.70: both its bands return more true marks than false fills, so
|
|
# tightening would cost 11 genuine recoveries to prevent 8 false ones.
|
|
#
|
|
# Jimeng moves to 0.85. Its relaxed detector does not key on the "★ 即梦AI" wordmark
|
|
# any more -- it keys on "some text in the bottom-right corner": of 68 false
|
|
# additions, 33 were DOUBAO marks and 17 were other vendors' AI labels (千问, 百度,
|
|
# 星绘, 抖音). 45 of those 68 fill a corner nothing else would touch (the other 23
|
|
# are harmless -- doubao fires strictly there and fills the same box anyway). At
|
|
# 0.85 the [0.315,0.383) band is dropped: 8 genuine recoveries lost, 60 false fills
|
|
# prevented (7.5:1). A false fill is the worse error -- it destroys pixels AND makes
|
|
# the caller report a removal that did not happen, while a miss leaves the image
|
|
# untouched.
|
|
#
|
|
# NOTE: 0.85 is a patch on a detector problem, not a fix. Jimeng's silhouette is not
|
|
# discriminative against Doubao's (same corner, same script, both ByteDance), and no
|
|
# threshold repairs that -- it needs a better detection silhouette.
|
|
_DEFAULT_PROVENANCE_NCC_FACTOR = 0.7
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TextMarkConfig:
|
|
"""All per-mark tuning for a text-mark detector/localizer."""
|
|
|
|
name: str # short label for log lines (e.g. "Doubao")
|
|
asset_name: str # bundled alpha PNG under assets/ (e.g. "doubao_alpha.png")
|
|
corner: Literal[
|
|
"br", "bl", "tl", "bc"
|
|
] # bottom-right (Doubao/Jimeng), bottom-left (Samsung), top-left (RunningHub), bottom-center (LibLibAI)
|
|
margin_floor: int # min margin in px for locate (4 for br marks, 2 for Samsung)
|
|
# locate geometry (fraction of scale_base -- see scale_base())
|
|
width_frac: float
|
|
height_frac: float
|
|
margin_x_frac: float # right margin (br) or left margin (bl)
|
|
margin_bottom_frac: float
|
|
# glyph appearance
|
|
max_saturation: float
|
|
logo_min_luma: float
|
|
tophat_delta: float
|
|
morph_open_size: int # MORPH_OPEN kernel side (5 for br marks, 3 for Samsung)
|
|
# detection
|
|
detect_min_coverage: float
|
|
detect_ncc_threshold: float
|
|
# alpha-map glyph geometry (fraction of scale_base) emitted by
|
|
# scripts/visible_alpha_solve.py, sizing the detection silhouette for
|
|
# template_match_score
|
|
alpha_width_frac: float
|
|
alpha_height_frac: float
|
|
min_gw: int # minimum glyph width for the template match (8 br, 16 Samsung)
|
|
# Asset names of RIVAL marks that occupy the same corner and can therefore be
|
|
# scored against the same glyph blob. Detection becomes COMPETITIVE: this mark's
|
|
# template must beat every rival's by `rival_margin`. See _rival_margin_ok.
|
|
# Detection front-end. "binary" thresholds the top-hat into a glyph blob and
|
|
# correlates a binary silhouette against it; "tophat" correlates the CONTINUOUS
|
|
# top-hat response against a soft template and never binarizes. See
|
|
# TextMarkEngine.tophat_response for the measurement that motivated the split.
|
|
# "gray" correlates the silhouette against the raw GRAYSCALE of the locate box:
|
|
# for a faint mid-gray mark (RunningHub) the top-hat's background-subtraction and
|
|
# max-normalization suppress the response to clean-arm levels (positives 0.16-0.23
|
|
# vs clean p99 0.31), while raw gray NCC separates (positives 0.38-0.54 vs clean
|
|
# p99 0.264 / max 0.304, measured 2026-07-22). Contrast-DEPENDENT, unlike tophat.
|
|
# "contrast" correlates against the ABSOLUTE local-luma residual. It is for a mark
|
|
# whose renderer switches between light-on-dark and dark-on-light while preserving
|
|
# one silhouette (Tencent Yuanbao); a one-polarity white top-hat misses the latter.
|
|
detect_frontend: Literal["binary", "tophat", "gray", "contrast"] = "binary"
|
|
# Gaussian sigma applied to the template in the "tophat" front-end (0 = none).
|
|
template_blur: float = 0.0
|
|
# Which image dimension the mark's size and margins scale with. VENDOR-SPECIFIC,
|
|
# measured, not assumed -- see TextMarkEngine.scale_base. "short" = min(h, w), "width" = w.
|
|
scale_basis: Literal["short", "width"] = "width"
|
|
# Scale rungs ``_ladder_best`` sweeps (the detection comb). PER-MARK: a vendor
|
|
# whose stamp sizes do not land on the shared 3-rung comb carries its own ladder
|
|
# (measured for 千问, whose marks sit in two size modes ~1.6x apart -- one fraction
|
|
# on 3 rungs covers only ~75% of them). Densifying the SHARED ladder for everyone
|
|
# was measured and rejected (false fire 2.52% -> 3.05%; see docs/verification-plan.md
|
|
# B2), so the default stays the shipped 3 rungs and a deviation must be calibrated
|
|
# per mark on real positives, never ported.
|
|
ladder: tuple[float, ...] = (0.8, 1.0, 1.25)
|
|
rivals: tuple[str, ...] = ()
|
|
rival_margin: float = 0.10
|
|
# Multiplier applied to detect_ncc_threshold when provenance confirms the vendor.
|
|
# Per-mark, NOT shared: see _DEFAULT_PROVENANCE_NCC_FACTOR for the measured
|
|
# precision that forced the split. Last field so it can carry a default.
|
|
provenance_ncc_factor: float = _DEFAULT_PROVENANCE_NCC_FACTOR
|
|
|
|
|
|
@dataclass
|
|
class TextMarkLocation:
|
|
"""Located watermark box, in absolute pixel coordinates."""
|
|
|
|
x: int
|
|
y: int
|
|
w: int
|
|
h: int
|
|
|
|
@property
|
|
def bbox(self) -> tuple[int, int, int, int]:
|
|
return self.x, self.y, self.w, self.h
|
|
|
|
|
|
@dataclass
|
|
class TextMarkDetection:
|
|
"""Result of visible text-mark detection."""
|
|
|
|
detected: bool = False
|
|
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.
|
|
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
|
|
# level its own re-detect would have used -- see TextMarkEngine._strict_detection.
|
|
provenance: bool = False
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TextMarkScan:
|
|
"""The trust-level-BLIND half of text-mark detection, reusable across both levels.
|
|
|
|
``loc is None`` means detection stopped before any scan (empty or too-small image).
|
|
``score is None`` means the binary front-end fell below its coverage gate, which is
|
|
a verdict of "not detected, confidence 0.0" without consulting the rival margin.
|
|
"""
|
|
|
|
loc: TextMarkLocation | None
|
|
box: NDArray[Any] | None # box-sized binary glyph mask
|
|
base: int # scale_base(image)
|
|
frame: tuple[int, int] = (0, 0) # (h, w) of the scanned image
|
|
coverage: float = 0.0
|
|
score: float | None = None
|
|
match_box: tuple[int, int, int, int] | None = None
|
|
|
|
|
|
# Alpha / silhouette templates, cached per asset name. This shared cache lets every
|
|
# text-mark engine reuse the loader without re-reading an asset. Only SUCCESSFUL loads
|
|
# are cached, so a missing asset is retried.
|
|
_alpha_cache: dict[str, NDArray[Any]] = {}
|
|
_silhouette_cache: dict[str, NDArray[Any]] = {}
|
|
|
|
|
|
def load_alpha_template(asset_name: str) -> NDArray[Any] | None:
|
|
"""Lazily load the bundled alpha template (float [0,1]) for ``asset_name``, or None."""
|
|
cached = _alpha_cache.get(asset_name)
|
|
if cached is not None:
|
|
return cached
|
|
path = Path(__file__).parent / "assets" / asset_name
|
|
img = image_io.imread(str(path), cv2.IMREAD_GRAYSCALE)
|
|
if img is None:
|
|
return None
|
|
_alpha_cache[asset_name] = img.astype(np.float32) / 255.0
|
|
return _alpha_cache[asset_name]
|
|
|
|
|
|
def glyph_silhouette(asset_name: str) -> NDArray[Any] | None:
|
|
"""Binary glyph silhouette (255 = glyph) from the bundled alpha map, or None."""
|
|
cached = _silhouette_cache.get(asset_name)
|
|
if cached is not None:
|
|
return cached
|
|
at = load_alpha_template(asset_name)
|
|
if at is None:
|
|
return None
|
|
_silhouette_cache[asset_name] = (at > 0.15).astype(np.uint8) * 255
|
|
return _silhouette_cache[asset_name]
|
|
|
|
|
|
_RIVAL_MODULES = {
|
|
"doubao_alpha.png": "remove_ai_watermarks.doubao_engine",
|
|
"jimeng_alpha.png": "remove_ai_watermarks.jimeng_engine",
|
|
"samsung_alpha.png": "remove_ai_watermarks.samsung_engine",
|
|
}
|
|
|
|
|
|
def _rival_config(asset_name: str, fallback: TextMarkConfig) -> TextMarkConfig:
|
|
"""The rival mark's own config, for scoring its template on a shared blob.
|
|
|
|
Looked up LAZILY by asset name: a rival's template geometry
|
|
(``alpha_*_frac`` / ``min_gw``) is its own, and scoring it with this mark's
|
|
geometry would compare a correctly-sized template against a mis-sized one and
|
|
hand the margin a free win. Lazy because the engine modules import this one.
|
|
"""
|
|
mod_path = _RIVAL_MODULES.get(asset_name)
|
|
if mod_path is None:
|
|
return fallback
|
|
from importlib import import_module
|
|
|
|
try:
|
|
return import_module(mod_path)._CONFIG
|
|
except Exception: # a missing/renamed engine must not break detection
|
|
logger.debug("rival config %s unavailable; skipping its margin check.", asset_name)
|
|
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``.
|
|
|
|
``TM_CCOEFF_NORMED`` keys on glyph SHAPE, not coverage, so a dense textured
|
|
corner does not score highly -- only the actual glyph shape does.
|
|
|
|
``scale_base`` is the mark's own scaling dimension (:meth:`TextMarkEngine.scale_base`),
|
|
not always the width: sizing the template on the wrong basis stretches it by the
|
|
aspect ratio on landscape inputs and the correlation collapses.
|
|
"""
|
|
sil = glyph_silhouette(config.asset_name)
|
|
if sil is None or box_mask.size == 0:
|
|
return 0.0
|
|
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
|
|
template = cv2.resize(sil, (gw, gh), interpolation=cv2.INTER_NEAREST)
|
|
return float(cv2.matchTemplate(box_mask, template, cv2.TM_CCOEFF_NORMED).max())
|
|
|
|
|
|
class TextMarkEngine:
|
|
"""Visible text-mark detector/localizer (locate -> mask -> detect; mask feeds the fill)."""
|
|
|
|
def __init__(self, config: TextMarkConfig) -> None:
|
|
self.config = config
|
|
|
|
# ── Templates (delegate to the asset-keyed module cache) ────────────
|
|
|
|
def _alpha_template(self) -> NDArray[Any] | None:
|
|
return load_alpha_template(self.config.asset_name)
|
|
|
|
def _glyph_silhouette(self) -> NDArray[Any] | None:
|
|
return glyph_silhouette(self.config.asset_name)
|
|
|
|
def _template_match_score(self, box_mask: NDArray[Any], scale_base: int) -> float:
|
|
return template_match_score(box_mask, scale_base, self.config)
|
|
|
|
def _rival_margin_ok(self, score: float, box_mask: NDArray[Any], scale_base: int) -> bool:
|
|
"""Whether this mark's template beats every same-corner RIVAL's on the SAME blob.
|
|
|
|
Detection was purely ABSOLUTE -- each engine scored its own template and
|
|
compared against its own threshold, so nothing ever asked the discriminative
|
|
question "does this blob look more like the neighbour's mark than like mine?".
|
|
Two marks sharing a corner and a script (Doubao "豆包AI生成" and Jimeng
|
|
"★ 即梦AI", both bottom-right, both near-white CJK) survive binarization into
|
|
very similar blobs, so an absolute gate cannot separate them -- and under the
|
|
provenance relaxation it stopped trying because many Jimeng false additions
|
|
were actually Doubao marks.
|
|
|
|
Measured separability on hand-labelled examples, scoring BOTH templates
|
|
against the same glyph blob:
|
|
|
|
feature separability (0.5 = useless, 1.0 = perfect)
|
|
absolute ncc_jimeng 0.96
|
|
ncc_jimeng MINUS ncc_doubao 0.99
|
|
|
|
A 0.10 margin separated Jimeng wordmarks from the rival and clean examples
|
|
without reducing recall, unlike raising the absolute threshold.
|
|
|
|
Marks with no same-corner rival declare `rivals=()` and are unaffected.
|
|
"""
|
|
c = self.config
|
|
if not c.rivals:
|
|
return True
|
|
for rival_asset in c.rivals:
|
|
rival = _rival_config(rival_asset, c)
|
|
if score - template_match_score(box_mask, scale_base, rival) < c.rival_margin:
|
|
logger.debug("%s detect: loses the %s rival margin; rejecting.", c.name, rival_asset)
|
|
return False
|
|
return True
|
|
|
|
# ── Locate ──────────────────────────────────────────────────────────
|
|
|
|
def _roi_fields(
|
|
self, image: NDArray[Any], loc: TextMarkLocation
|
|
) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
|
|
"""``(luma, saturation, local_background)`` for the located box, all float32.
|
|
|
|
The ROI is normalized to 3-channel BGR first (grayscale / BGRA would break
|
|
``axis=2``).
|
|
|
|
Local background model: a strong Gaussian blur (sigma ~ box height); the white
|
|
top-hat (``luma - local_bg``) lights up bright thin strokes regardless of the
|
|
absolute background level.
|
|
|
|
The callers each keep their own ``bh < 16 or bw < 16`` guard: they return three
|
|
different sentinels for a degenerate ROI, so the check cannot move in here.
|
|
"""
|
|
x, y, bw, bh = loc.bbox
|
|
roi = image_io.to_bgr(image[y : y + bh, x : x + bw]).astype(np.float32)
|
|
luma = roi.mean(axis=2)
|
|
sat = roi.max(axis=2) - roi.min(axis=2)
|
|
sigma = max(4.0, bh * 0.4) # 0.4 factor and 4.0 floor are calibrated; do not retune
|
|
return luma, sat, cv2.GaussianBlur(luma, (0, 0), sigmaX=sigma, sigmaY=sigma)
|
|
|
|
def tophat_response(self, image: NDArray[Any], loc: TextMarkLocation) -> NDArray[Any] | None:
|
|
"""The CONTINUOUS white top-hat in the located box -- the glyph signal, unbinarized.
|
|
|
|
:meth:`extract_mask` thresholds this same response into a 0/255 glyph blob. That
|
|
is fine for a mark stamped bold and opaque, and destructive for a faint one: a
|
|
thin translucent overlay shatters into specks under the threshold, and no
|
|
template can match a blob that is not there.
|
|
|
|
Calibration on hand-verified examples, scoring each mark with its own template:
|
|
|
|
front-end doubao clean neg AUC doubao/neg
|
|
binary 0.723 ~0.12 --
|
|
tophat 0.781 0.122 1.00
|
|
|
|
The gates that were hard cuts in the binary path (saturation, absolute luma)
|
|
become WEIGHTS here, so a faint stroke contributes in proportion to its strength
|
|
instead of being dropped at a threshold. The response is max-normalized, which
|
|
makes the score contrast-invariant -- the point of the exercise.
|
|
|
|
Kept per-mark (``detect_frontend``) rather than switched globally, because a
|
|
front-end change must be measured per mark before it ships.
|
|
"""
|
|
return self._residual_response(image, loc, absolute=False)
|
|
|
|
def _residual_response(self, image: NDArray[Any], loc: TextMarkLocation, *, absolute: bool) -> NDArray[Any] | None:
|
|
"""Max-normalized uint8 local-luma residual in the located box, saturation-weighted.
|
|
|
|
``absolute=False`` keeps only the POSITIVE side -- the white top-hat, for a mark
|
|
always rendered brighter than its background. ``absolute=True`` takes the
|
|
magnitude, for a renderer that switches between light-on-dark and dark-on-light
|
|
while preserving one silhouette; a one-polarity top-hat misses the latter.
|
|
"""
|
|
c = self.config
|
|
_x, _y, bw, bh = loc.bbox
|
|
if bh < 16 or bw < 16:
|
|
return None
|
|
luma, sat, local_bg = self._roi_fields(image, loc)
|
|
residual = luma - local_bg
|
|
resp = (np.abs(residual) if absolute else np.clip(residual, 0, None)) * (sat < c.max_saturation)
|
|
peak = float(resp.max())
|
|
if peak <= 1e-6:
|
|
return None
|
|
return (resp / peak * 255).astype(np.uint8)
|
|
|
|
def _detect_response(self, image: NDArray[Any], loc: TextMarkLocation) -> NDArray[Any] | None:
|
|
"""The uint8 image the ladder sweep correlates the silhouette against, chosen by
|
|
``TextMarkConfig.detect_frontend``. ``binary`` runs no sweep and never reaches here."""
|
|
frontend = self.config.detect_frontend
|
|
if frontend == "gray":
|
|
x, y, bw, bh = loc.bbox
|
|
if bh < 16 or bw < 16:
|
|
return None
|
|
return cv2.cvtColor(image_io.to_bgr(image[y : y + bh, x : x + bw]), cv2.COLOR_BGR2GRAY)
|
|
if frontend == "contrast":
|
|
return self._residual_response(image, loc, absolute=True)
|
|
if frontend == "tophat":
|
|
return self._residual_response(image, loc, absolute=False)
|
|
raise ValueError(f"{frontend!r} has no ladder response (binary runs no sweep)")
|
|
|
|
def _ladder_best(
|
|
self, image: NDArray[Any], loc: TextMarkLocation
|
|
) -> tuple[float, tuple[int, int, int, int] | None]:
|
|
"""Best TM_CCOEFF_NORMED of the mark's silhouette against its front-end response,
|
|
and the ROI-local box (x0, y0, x1, y1) where that best match sits.
|
|
|
|
Sweeps the mark's scale ladder: the nominal glyph size is derived from the mark's
|
|
geometry, but a vendor re-rasterization shifts it by a few percent and the
|
|
continuous response is sharp enough that an exact-size template would miss. The
|
|
ladder is per-mark (``TextMarkConfig.ladder``), defaulting to the shipped 3 rungs.
|
|
|
|
Detection and the removal mask BOTH read this one method -- the score gates
|
|
detection, the box bounds the fill. Sharing it is deliberate: the standing rule is
|
|
that detection and the mask use the same front-end, and the way that rule was last
|
|
broken was a drift between two separate implementations. One method makes the drift
|
|
impossible instead of merely discouraged -- which is why the three continuous
|
|
front-ends (tophat / contrast / gray) sweep here rather than in a copy each.
|
|
|
|
Tie-breaking is load-bearing: the ``0.0`` seed plus the STRICT ``>`` means the
|
|
EARLIEST ladder rung wins a tie, and a sweep whose maximum is not above 0.0
|
|
returns no box at all.
|
|
"""
|
|
c = self.config
|
|
resp = self._detect_response(image, loc)
|
|
sil = self._glyph_silhouette()
|
|
if resp is None or sil is None:
|
|
return (0.0, None)
|
|
base = self.scale_base(image)
|
|
best_score = 0.0
|
|
best_box: tuple[int, int, int, int] | None = None
|
|
for scale in c.ladder:
|
|
gw = max(c.min_gw, int(c.alpha_width_frac * base * scale))
|
|
gh = max(4, int(c.alpha_height_frac * base * scale))
|
|
if gw >= resp.shape[1] or gh >= resp.shape[0]:
|
|
continue
|
|
tmpl = cv2.resize(sil, (gw, gh), interpolation=cv2.INTER_AREA)
|
|
if c.detect_frontend == "tophat" and c.template_blur > 0:
|
|
tmpl = cv2.GaussianBlur(
|
|
tmpl.astype(np.float32), (0, 0), sigmaX=c.template_blur, sigmaY=c.template_blur
|
|
).astype(np.uint8)
|
|
result = cv2.matchTemplate(resp, tmpl, cv2.TM_CCOEFF_NORMED)
|
|
_, score, _, top_left = cv2.minMaxLoc(result)
|
|
if score > best_score:
|
|
tx, ty = int(top_left[0]), int(top_left[1])
|
|
best_score, best_box = float(score), (tx, ty, tx + gw - 1, ty + gh - 1)
|
|
return (best_score, best_box)
|
|
|
|
def scale_base(self, image: NDArray[Any]) -> int:
|
|
"""The image dimension this mark's geometry scales with.
|
|
|
|
Per-mark, and MEASURED -- a single shared basis is wrong. The tuned fractions
|
|
were all calibrated on PORTRAIT captures, where width and short side coincide,
|
|
so the basis was never exercised until landscape inputs were measured.
|
|
|
|
Detector calibration showed that a width-scaled box is inflated by the aspect
|
|
ratio on a wide image and can miss the glyph entirely. A short-side basis
|
|
recovered the affected Doubao landscape cases.
|
|
|
|
The same switch broke Jimeng landscape positives because its wordmark tracks
|
|
the WIDTH. Both marks are ByteDance and share a
|
|
corner, and they still scale differently -- so this is a per-mark measurement,
|
|
not a house rule to generalize. Samsung keeps ``width`` because it has not been
|
|
calibrated for a different basis, and an unmeasured change is not an improvement.
|
|
|
|
China's GB 45438-2025 clause 5.2(e) mandates glyph height >= 5% of "the
|
|
shortest side" for CN marks, which is why a short-side basis is the natural
|
|
prior -- but Jimeng's measured behaviour overrides the prior, and measurement
|
|
wins over the standard's wording.
|
|
"""
|
|
return min(image.shape[:2]) if self.config.scale_basis == "short" else image.shape[1]
|
|
|
|
def locate(self, image: NDArray[Any]) -> TextMarkLocation:
|
|
"""Anchor the watermark box in the configured corner, scaled by ``scale_basis``.
|
|
|
|
Every fraction is taken against ``scale_base(image)`` -- see
|
|
:data:`TextMarkConfig.scale_basis`, which is per-mark because the vendors
|
|
genuinely differ.
|
|
"""
|
|
c = self.config
|
|
h, w = image.shape[:2]
|
|
base = self.scale_base(image)
|
|
wm_w = max(40, int(base * c.width_frac))
|
|
wm_h = max(16, int(base * c.height_frac))
|
|
margin_x = max(c.margin_floor, int(base * c.margin_x_frac))
|
|
margin_b = max(c.margin_floor, int(base * c.margin_bottom_frac))
|
|
if c.corner == "br":
|
|
x = max(0, w - margin_x - wm_w)
|
|
elif c.corner == "bc": # bottom-center: horizontally centered, margin_x unused
|
|
x = max(0, (w - wm_w) // 2)
|
|
else:
|
|
x = min(margin_x, max(0, w - wm_w))
|
|
# "tl" anchors at the top instead: margin_bottom_frac is then the TOP margin.
|
|
y = min(margin_b, max(0, h - wm_h)) if c.corner == "tl" else max(0, h - margin_b - wm_h)
|
|
wm_w = min(wm_w, w - x)
|
|
wm_h = min(wm_h, h - y)
|
|
return TextMarkLocation(x=x, y=y, w=wm_w, h=wm_h)
|
|
|
|
# ── Mask ────────────────────────────────────────────────────────────
|
|
|
|
def extract_mask(self, image: NDArray[Any], loc: TextMarkLocation) -> NDArray[Any]:
|
|
"""Build a box-sized uint8 mask (255 = watermark glyph) for ``loc``.
|
|
|
|
Returns just the glyph mask of the located box (shape ``(loc.h, loc.w)``),
|
|
not a full-frame array: every caller immediately crops to ``loc.bbox``, so
|
|
allocating a full ``(h, w)`` mask and embedding the box was O(image) work
|
|
and memory for an O(box) result -- a wasted full-frame uint8 allocation on
|
|
each detect (~12 MB on a 12 MP frame, recomputed per text-mark detector on
|
|
the memory-tight identify path). The box mask is byte-identical to the old
|
|
full-frame mask cropped to ``loc.bbox``.
|
|
|
|
Polarity-aware: the mark is a light, low-saturation gray rendered brighter
|
|
than the local background (white top-hat), so a white-paper document is left
|
|
untouched (nothing brighter than its surroundings is masked there).
|
|
"""
|
|
c = self.config
|
|
x, y, bw, bh = loc.bbox
|
|
# A degenerate ROI (a sliver from an extremely wide/short image) cannot hold
|
|
# the mark and would feed cv2's GaussianBlur/morphology a ~1-px-tall array,
|
|
# which can fault native code on some platforms. Skip the cv2 pipeline.
|
|
if bh < 16 or bw < 16:
|
|
return np.zeros((bh, bw), np.uint8)
|
|
# Normalize the ROI to 3-channel BGR (grayscale / BGRA would break axis=2).
|
|
roi = image_io.to_bgr(image[y : y + bh, x : x + bw]).astype(np.float32)
|
|
|
|
luma = roi.mean(axis=2)
|
|
sat = roi.max(axis=2) - roi.min(axis=2)
|
|
grayish = sat < c.max_saturation
|
|
|
|
# Local background model: a strong Gaussian blur (sigma ~ box height); the
|
|
# white top-hat (luma - local_bg) lights up bright thin strokes regardless
|
|
# of the absolute background level.
|
|
sigma = max(4.0, bh * 0.4)
|
|
local_bg = cv2.GaussianBlur(luma, (0, 0), sigmaX=sigma, sigmaY=sigma)
|
|
tophat = luma - local_bg
|
|
|
|
cand = grayish & (tophat > c.tophat_delta) & (luma > c.logo_min_luma)
|
|
glyph = cand.astype(np.uint8) * 255
|
|
glyph = cv2.morphologyEx(glyph, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8))
|
|
k = c.morph_open_size
|
|
return cv2.morphologyEx(glyph, cv2.MORPH_OPEN, np.ones((k, k), np.uint8))
|
|
|
|
# ── Detect ──────────────────────────────────────────────────────────
|
|
|
|
def detect(self, image: NDArray[Any], *, provenance: bool = False) -> TextMarkDetection:
|
|
"""Detect the mark by matching the alpha-template glyph silhouette against
|
|
the corner candidate (``TM_CCOEFF_NORMED``); keys on glyph SHAPE, not coverage.
|
|
|
|
``provenance`` signals that external metadata already confirms this vendor
|
|
(China-AIGC / byteimg for Doubao/Jimeng, ``samsung_genai`` for Samsung); the
|
|
NCC gate exists to keep a corner texture on an UNRELATED image from matching
|
|
the glyph silhouette, so when provenance confirms the vendor it is relaxed by
|
|
the mark's own ``provenance_ncc_factor`` to recover a faint or slightly
|
|
re-rendered mark (per-mark, not shared -- see _DEFAULT_PROVENANCE_NCC_FACTOR).
|
|
"""
|
|
scan = self._scan(image)
|
|
return self._verdict(scan, provenance=provenance)
|
|
|
|
def detect_both(self, image: NDArray[Any] | None) -> tuple[TextMarkDetection, TextMarkDetection]:
|
|
"""``(strict, relaxed)`` from ONE scan of the image.
|
|
|
|
``provenance`` scales the acceptance THRESHOLD and nothing else -- the locate
|
|
box, the glyph mask, the coverage and the front-end ladder score are computed
|
|
identically at either trust level. Two ``detect`` calls therefore ran the same
|
|
expensive sweep twice to reach two verdicts, which is what the arbiter's
|
|
perception pass did for every mark on every image.
|
|
|
|
Returns two DISTINCT objects: subclasses demote a verdict by mutating it.
|
|
"""
|
|
scan = self._scan(image)
|
|
return self._verdict(scan, provenance=False), self._verdict(scan, provenance=True)
|
|
|
|
def _scan(self, image: NDArray[Any] | None) -> TextMarkScan:
|
|
"""Everything in detection that does not depend on the trust level.
|
|
|
|
Per-CALL only, never memoized on ``self``: ``remove_auto_marks`` re-invokes each
|
|
engine on a progressively cleaned frame inside one process, so a cached scan
|
|
would answer for the wrong pixels.
|
|
"""
|
|
c = self.config
|
|
if image is None or image.size == 0:
|
|
return TextMarkScan(None, None, 0)
|
|
# Guard against the small-image NCC-noise false positive (see
|
|
# _MIN_DETECT_SHORT_SIDE): an icon/thumbnail is too small to carry a real
|
|
# text label, and the degraded few-pixel template spuriously correlates.
|
|
if min(image.shape[:2]) < _MIN_DETECT_SHORT_SIDE:
|
|
logger.debug(
|
|
"%s detect: image short side %d < %d; too small to carry the mark, skipping.",
|
|
c.name,
|
|
min(image.shape[:2]),
|
|
_MIN_DETECT_SHORT_SIDE,
|
|
)
|
|
return TextMarkScan(None, None, 0)
|
|
loc = self.locate(image)
|
|
box = self.extract_mask(image, loc) # box-sized mask (== old full-frame cropped to bbox)
|
|
_x, _y, bw, bh = loc.bbox
|
|
coverage = float((box > 0).sum()) / float(max(1, bw * bh))
|
|
base = self.scale_base(image)
|
|
match_box: tuple[int, int, int, int] | None = None
|
|
if c.detect_frontend == "binary":
|
|
# The coverage gate is a blob-AREA heuristic, so it applies only to the
|
|
# 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
|
|
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)
|
|
|
|
def _verdict(self, scan: TextMarkScan, *, provenance: bool) -> TextMarkDetection:
|
|
"""Apply the trust-level-dependent tail to a scan, as a fresh result object."""
|
|
c = self.config
|
|
det = TextMarkDetection(provenance=provenance)
|
|
if scan.loc is None or scan.box is None:
|
|
return det
|
|
det.region = scan.loc.bbox
|
|
det.coverage = scan.coverage
|
|
det.match_box = scan.match_box
|
|
if scan.score is None: # binary front-end below the coverage gate
|
|
return det
|
|
threshold = c.detect_ncc_threshold * (c.provenance_ncc_factor if provenance else 1.0)
|
|
det.confidence = scan.score
|
|
# Short-circuit is load-bearing: _rival_margin_ok scores every rival template
|
|
# and logs its own rejection line, so it must stay unevaluated below threshold.
|
|
det.detected = scan.score >= threshold and self._rival_margin_ok(scan.score, scan.box, scan.base)
|
|
logger.debug(
|
|
"%s detect (%s): coverage=%.3f ncc=%.2f thr=%.2f detected=%s",
|
|
c.name,
|
|
c.detect_frontend,
|
|
scan.coverage,
|
|
scan.score,
|
|
threshold,
|
|
det.detected,
|
|
)
|
|
return self._post_gate(det, scan)
|
|
|
|
def _post_gate(self, det: TextMarkDetection, scan: TextMarkScan) -> TextMarkDetection:
|
|
"""Per-mark demotion applied after the shared threshold, for both trust levels.
|
|
|
|
OVERRIDABLE. It lives here rather than in a ``detect`` override so a mark's gate
|
|
cannot be silently skipped by the single-pass ``detect_both`` path -- which is
|
|
exactly what happened while the anchor demotions were ``detect`` overrides.
|
|
"""
|
|
return det
|
|
|
|
# ── Inpaint footprint (for the inpaint-fallback removal path) ────────
|
|
|
|
# Minimum glyph pixels for a template-free footprint. Below this the corner has
|
|
# no real wordmark (a few top-hat specks), so without ``force`` there is nothing
|
|
# to mask. A real strip covers hundreds of pixels.
|
|
_MIN_GLYPH_PIXELS = 20
|
|
|
|
def _strict_detection(self, image: NDArray[Any], detection: TextMarkDetection | None) -> TextMarkDetection:
|
|
"""The STRICT detection the footprint is bounded by.
|
|
|
|
A threaded detection is reused only when it was taken at the same strict level
|
|
this method would have used itself. A provenance-RELAXED detection is NOT
|
|
reused: a strict re-detect can demote a mark the relaxed gate accepted, which
|
|
for a continuous front-end means no mask at all. That is a MEASURED difference,
|
|
not a refactor, so the strict semantics stay.
|
|
|
|
Reuse is safe against ``remove_auto_marks`` chaining marks on a progressively
|
|
cleaned frame: the registry re-detects on that same cleaned array before
|
|
threading (``KnownMark.localize``), so a threaded detection is never stale.
|
|
"""
|
|
if detection is not None and not detection.provenance:
|
|
return detection
|
|
return self.detect(image) # polymorphic: a subclass gate must still apply
|
|
|
|
def _geometry_rect(self, loc: TextMarkLocation, frame: tuple[int, int]) -> tuple[int, int, int, int]:
|
|
"""The whole locate box, clamped to the frame -- the ``force`` footprint."""
|
|
bx, by, bw, bh = loc.bbox
|
|
h, w = frame
|
|
return (bx, by, min(w, bx + bw), min(h, by + bh))
|
|
|
|
def _extend_match_box(
|
|
self, box: tuple[int, int, int, int], loc: TextMarkLocation, frame: tuple[int, int]
|
|
) -> tuple[int, int, int, int]:
|
|
"""Grow an ROI-local box into the absolute fill rectangle by a symmetric pad.
|
|
|
|
OVERRIDABLE, and the override contract is specifically the DETECTOR'S MATCH BOX:
|
|
a mark whose removable footprint reaches beyond what the NCC localizes -- Baidu's
|
|
flat white tag right of the text run, LibLibAI's triangle logo left of the
|
|
wordmark -- supplies its own extension here and inherits the rest of the
|
|
footprint path. The blob-bbox branch never routes through an override.
|
|
"""
|
|
gx0, gy0, gx1, gy1 = box
|
|
bx, by, _bw, bh = loc.bbox
|
|
h, w = frame
|
|
pad = max(4, int(0.10 * bh))
|
|
return (
|
|
max(0, bx + gx0 - pad),
|
|
max(0, by + gy0 - pad),
|
|
min(w, bx + gx1 + 1 + pad),
|
|
min(h, by + gy1 + 1 + pad),
|
|
)
|
|
|
|
def _footprint_rect(
|
|
self,
|
|
image: NDArray[Any],
|
|
loc: TextMarkLocation,
|
|
*,
|
|
force: bool,
|
|
detection: TextMarkDetection | None,
|
|
) -> tuple[int, int, int, int] | None:
|
|
"""Default footprint policy: the binary glyph blob's bbox, else the detector's
|
|
own match box for the front-ends that under-segment, else the geometry box
|
|
under ``force``.
|
|
|
|
The gray front-end exists for marks the top-hat under-segments, so the binary
|
|
blob is NOT authoritative there: trusting it first bounded the fill by a PARTIAL
|
|
blob (the faint head glyphs dropped out) and left the leftmost "Runni" of
|
|
"RunningHub AI生成" unremoved (2026-07-22).
|
|
|
|
A dark-on-light Yuanbao mark has no WHITE top-hat blob at all, so the contrast
|
|
front-end is bounded by the polarity-independent detector's match box too.
|
|
|
|
A tophat mark found only by the CONTINUOUS front-end has no binary glyph blob to
|
|
bound, so the mask came back empty and removal was a silent no-op while
|
|
``identify`` still reported the mark. Use the DETECTOR'S OWN best-match box: the
|
|
correlation already located the mark at a position and scale, and thresholding
|
|
the response was a strictly worse proxy for that. An earlier fix thresholded the
|
|
max-normalized uint8 response at 0.5 -- which selects every non-zero pixel, not
|
|
"half the peak" as its comment claimed -- and filled ~120% of the corner box on
|
|
textured frames (measured: whole corner vs 58.7% for the match box, both
|
|
detector-clean). Gated on an actual detection: on a clean corner the box would
|
|
be spurious.
|
|
"""
|
|
ys, xs = np.where(self.extract_mask(image, loc) > 0)
|
|
blob = (
|
|
(int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max())) if xs.size >= self._MIN_GLYPH_PIXELS else None
|
|
)
|
|
frontend = self.config.detect_frontend
|
|
if frontend in ("gray", "contrast"):
|
|
det = self._strict_detection(image, detection)
|
|
box = det.match_box if det.detected else blob
|
|
elif blob is not None:
|
|
box = blob
|
|
elif frontend == "tophat":
|
|
det = self._strict_detection(image, detection)
|
|
box = det.match_box if det.detected else None
|
|
else:
|
|
box = None
|
|
if box is not None:
|
|
return self._extend_match_box(box, loc, image.shape[:2])
|
|
return self._geometry_rect(loc, image.shape[:2]) if force else None
|
|
|
|
def _match_box_rect(
|
|
self,
|
|
image: NDArray[Any],
|
|
loc: TextMarkLocation,
|
|
*,
|
|
force: bool,
|
|
detection: TextMarkDetection | None,
|
|
) -> tuple[int, int, int, int] | None:
|
|
"""Footprint policy for a mark whose fill must be bounded by the DETECTOR's match
|
|
box and never by the binary glyph blob.
|
|
|
|
Baidu's white tag has a flat interior a top-hat cannot answer, and LibLibAI's
|
|
blob bleeds up into background structure; in both cases the blob bbox is
|
|
measurably wrong and the NCC match box is right. ``force`` takes priority here,
|
|
unlike the default policy: a ``--no-detect`` caller named the mark, so the whole
|
|
geometry box is the honest footprint.
|
|
"""
|
|
if force:
|
|
return self._geometry_rect(loc, image.shape[:2])
|
|
det = self._strict_detection(image, detection)
|
|
if not det.detected or det.match_box is None:
|
|
return None
|
|
return self._extend_match_box(det.match_box, loc, image.shape[:2])
|
|
|
|
def footprint_mask(
|
|
self,
|
|
image: NDArray[Any] | None,
|
|
*,
|
|
force: bool = False,
|
|
dilate: int | None = None,
|
|
detection: TextMarkDetection | None = None,
|
|
) -> NDArray[Any] | None:
|
|
"""Full-frame uint8 mask (255 = mark) of the mark footprint, for the shared
|
|
fill removal path (cv2 / MI-GAN / LaMa), or None if no glyph is found.
|
|
|
|
Template-FREE: localize the glyph blob with the top-hat :meth:`extract_mask`,
|
|
take its bounding box in the corner, and fill that box solid (plus a small
|
|
margin + dilation). Filling the enclosing rectangle -- not the sparse glyph
|
|
strokes -- is what makes it robust: the top-hat under-segments individual
|
|
strokes (which used to leave a "三包"-style residual ghost when the strokes
|
|
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.
|
|
|
|
With ``force`` and no glyph found, falls back to the whole geometry box (the
|
|
``--no-detect`` path). The caller gates on detection.
|
|
|
|
``detection`` is the caller's already-computed detection, threaded in so the
|
|
footprint does not re-run a sweep the detector already ran. See
|
|
:meth:`_strict_detection` for when it is reused.
|
|
"""
|
|
if image is None or image.size == 0:
|
|
return None # guard before to_bgr (cvtColor raises on an empty Mat); mirror detect()
|
|
image = image_io.to_bgr(image)
|
|
h, w = image.shape[:2]
|
|
if h < 32 or w < 64:
|
|
return None
|
|
loc = self.locate(image)
|
|
rect = self._footprint_rect(image, loc, force=force, detection=detection)
|
|
if rect is None:
|
|
return None
|
|
rx1, ry1, rx2, ry2 = rect
|
|
if rx1 >= rx2 or ry1 >= ry2:
|
|
return None
|
|
# Rectangular footprint + dilation is exactly region_eraser.boxes_to_mask (the
|
|
# same primitive the shared fill uses); reuse it instead of re-inlining the
|
|
# zeros/fill/MORPH_ELLIPSE-dilate here.
|
|
from remove_ai_watermarks import region_eraser
|
|
|
|
d = dilate if dilate is not None else max(3, int(0.02 * loc.w))
|
|
return region_eraser.boxes_to_mask((h, w), [(rx1, ry1, rx2 - rx1, ry2 - ry1)], dilate=d)
|