mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-07-12 02:56:32 +02:00
fix(visible): thread detection into mask + guard removal/IO edge cases
Resolve 10 code-review findings on the v0.14.0 localize->fill path, several release-blocking: - gemini: build the removal mask from the decision's provenance-aware region instead of a strict internal re-detect. A relaxed/assume_ai sparkle was re-demoted by the FP gate into a None mask and reported removed while left in the image; this also drops the redundant double-detect. - registry: report a mark removed only when a fill actually happened (remove() returns a None region for an empty mask), so a no-op is never claimed. - api/cli: add write_noop so the CLI `visible` no-mark path writes nothing and cannot clobber a pre-existing -o file (was write-then-unlink -> data loss); create output.parent; skip the same-file copy (SameFileError on in-place). - cli: catch the missing migan/lama backend RuntimeError on the visible/all paths (matches `erase`); route the single-mark relaxation through the shared resolve_relax instead of an inline copy. - metadata: keep_standard=False no longer takes the AI-only lossless JPEG short-circuit (it left standard metadata); defer a malformed-marker JPEG to the PIL fallback instead of reporting a partial strip as complete. - invisible: register the HEIF opener before Image.open (HEIC --force) and RGB-convert before the PNG temp (CMYK JPEG). - pill: normalize via to_bgr so a 4-channel BGRA array cannot crash cvtColor. Regression tests for each; docs synced (resolve_relax, write_noop, best_auto_mark -> detect_marks). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -29,7 +29,7 @@ module.
|
||||
|
||||
**AI-generated vs AI-enhanced** (`ProvenanceReport.ai_source_kind`, roadmap item): the C2PA digital-source-type is split into `"generated"` (trainedAlgorithmicMedia, fully synthetic) vs `"enhanced"` (compositeWithTrainedAlgorithmicMedia, a real photo with an AI-composited region) — the two byte strings are unambiguous (`compositeWithTrainedAlgorithmicMedia` capitalizes the inner "Trained", so a lowercase `trainedAlgorithmicMedia` match is standalone full generation; full generation wins when both appear). `ai_source_kind` is set only when the AI verdict actually came from the C2PA source type (a non-C2PA AI signal — IPTC/AIGC/local gen/xAI — leaves it None). It lets a caller branch a full-frame scrub (`generated`) from a region-targeted clean that preserves the real photo (`enhanced`; see `noai/tiling.feather_region_composite`). The CLI verdict line reads "AI-generated (fully synthetic)" vs "AI-enhanced (real content with an AI-composited region)".
|
||||
|
||||
**Visible-mark detection** (`check_visible`, signals `visible_sparkle` / `visible_doubao` / `visible_jimeng` / `visible_samsung`): the Gemini sparkle keeps its own file-level path (`_visible_sparkle` → `gemini_engine.detect_sparkle_confidence`, promoted only at confidence ≥ `_SPARKLE_THRESHOLD`, which is the SHARED `watermark_registry.GEMINI_SPARKLE_TRUST_CONF` (0.5) — imported, not a private copy, so the provenance detect threshold and the removal `best_auto_mark` / `_gemini_detect` arbitration gate can never drift (the detect-vs-remove desync from roadmap P0#7; regression-guarded by `tests/test_identify.py::TestSparkleDetectRemoveAlignment`, which composites the real demo sparkle at borderline opacities and asserts identify and `best_auto_mark` AGREE on either side of the line). Lowering the gate to recover faint sub-0.5 sparkles was evaluated 2026-06-20 and REJECTED: a real Doubao text mark scores ~0.40-0.42 as a gemini match with a HIGHER core-ring brightness margin than a genuine faint sparkle, so neither confidence nor the brightness gate separates them in the [0.35, 0.5) band — lowering trades a rare miss for false-positive removals on clean images. Corpus-tuned to separate Gemini sparkles ≥0.56 from non-sparkle ≤0.49), while Doubao/Jimeng/Samsung reuse the registry detectors (`_visible_text_marks` → `watermark_registry`, iterating `_VISIBLE_MARK_PLATFORM`), each gated by its own engine NCC threshold via `MarkDetection.detected` (Doubao 0.4, Jimeng 0.45, Samsung 0.4). Doubao/Jimeng are normally also caught by the TC260 AIGC metadata label and Samsung by its C2PA + `genAIType` marker, so the visible path is their stripped-metadata fallback. Visible marks set `platform` only when no harder signal already did, and (like the sparkle) are excluded from integrity-clash vendor claims. The cv2 dependency lives in the engines, not here.
|
||||
**Visible-mark detection** (`check_visible`, signals `visible_sparkle` / `visible_doubao` / `visible_jimeng` / `visible_samsung`): the Gemini sparkle keeps its own file-level path (`_visible_sparkle` → `gemini_engine.detect_sparkle_confidence`, promoted only at confidence ≥ `_SPARKLE_THRESHOLD`, which is the SHARED `watermark_registry.GEMINI_SPARKLE_TRUST_CONF` (0.5) — imported, not a private copy, so the provenance detect threshold and the removal `detect_marks` / `_gemini_detect` arbitration gate can never drift (the detect-vs-remove desync from roadmap P0#7; regression-guarded by `tests/test_identify.py::TestSparkleDetectRemoveAlignment`, which composites the real demo sparkle at borderline opacities and asserts identify and `detect_marks` AGREE on either side of the line). Lowering the gate to recover faint sub-0.5 sparkles was evaluated 2026-06-20 and REJECTED: a real Doubao text mark scores ~0.40-0.42 as a gemini match with a HIGHER core-ring brightness margin than a genuine faint sparkle, so neither confidence nor the brightness gate separates them in the [0.35, 0.5) band — lowering trades a rare miss for false-positive removals on clean images. Corpus-tuned to separate Gemini sparkles ≥0.56 from non-sparkle ≤0.49), while Doubao/Jimeng/Samsung reuse the registry detectors (`_visible_text_marks` → `watermark_registry`, iterating `_VISIBLE_MARK_PLATFORM`), each gated by its own engine NCC threshold via `MarkDetection.detected` (Doubao 0.4, Jimeng 0.45, Samsung 0.4). Doubao/Jimeng are normally also caught by the TC260 AIGC metadata label and Samsung by its C2PA + `genAIType` marker, so the visible path is their stripped-metadata fallback. Visible marks set `platform` only when no harder signal already did, and (like the sparkle) are excluded from integrity-clash vendor claims. The cv2 dependency lives in the engines, not here.
|
||||
|
||||
**`import identify` is deliberately light** (~26 MB; ~36 MB with cv2 loaded by a visible-mark run, ~106 MB for a full `check_visible` run): it imports the `noai.c2pa`/`noai.constants` submodules, and `noai/__init__` is lazy (see "Test and lint"), so torch/diffusers are NOT pulled at import even in a full `gpu`/`detect` install — fits a 512 MB host. `noai.c2pa` does eagerly import the **c2pa-python** binary (Rust + cryptography, ~+5 MB RSS, no torch) for the primary `Reader` path — light enough to stay on the dependency-light host; a broken/absent wheel degrades to the byte-scan parser (`reader_available()` False). The heavy paths are opt-in: `check_invisible=True` needs the `detect`/`trustmark` extras (each pulls **torch**; TrustMark also **downloads weights**), so on a core-only deploy leave `check_invisible` off (it is a no-op there anyway). Before the lazy `__init__`, the mere presence of torch in the env inflated `import identify` to ~420 MB.
|
||||
|
||||
@@ -61,7 +61,7 @@ module.
|
||||
|
||||
`watermark_registry.py` — **single catalog of known visible watermarks**, the unified "find known marks in their usual places, recognize, remove" entry.
|
||||
|
||||
**Localize -> fill by policy (replaced reverse-alpha):** each mark is localized to a binary full-frame footprint mask (a `Localization`), and one shared, swappable fill inpaints that mask via `fill(image, mask, backend=...)` (delegates to `region_eraser.erase`). This replaced the old reverse-alpha removal (invert a captured alpha map, `original = (wm - a*logo)/(1-a)`, plus a thin residual inpaint) for ALL marks — gemini, doubao, jimeng, samsung, and jimeng_pill. **Why it changed:** reverse-alpha depended on a fixed captured alpha map at a fixed position, so it broke whenever a vendor moved or re-rendered its mark; and it was not colour-lossless even with the right map (it amplifies 8-bit quantization and JPEG-chroma error by `1/(1-a)`), which showed up as "the colour just changed, not removed" reports. Localize -> fill has a benign failure mode: a slightly-off localization just inpaints a small region near-losslessly instead of leaving a colour-shifted smear. The captured alpha maps are still used to DETECT the marks and to shape the mask (gemini's footprint), but NOT for pixel recovery. Fill backends: `cv2` (classical inpaint, no deps, the floor), `migan` (MI-GAN ONNX, light, the preferred default when the `migan` extra is installed), `lama` (big-LaMa ONNX, best quality, heavier, explicit opt-in); `auto` = MI-GAN if installed, else cv2. Each `KnownMark` ties a key to {usual `location`, `in_auto` flag, a `_detect` callable → uniform `MarkDetection`, a `_mask` callable → full-frame footprint mask}; `KnownMark.remove(image, *, backend="auto", provenance=False, force=False)`. Entries today: `gemini` (bottom-right sparkle), `doubao` (bottom-right "豆包AI生成"), `jimeng` (bottom-right "★ 即梦AI"), `samsung` (bottom-**LEFT** "✦ Contenuti generati dall'AI", Samsung Galaxy AI, Italian locale), and the capture-less `jimeng_pill` (top-left "AI生成"). `detect_marks(image, *, provenance=frozenset())` scans all (strict, for the identify verdict); `remove_auto_marks(image, *, sensitivity="auto", provenance=frozenset(), backend="auto")` removes every detected mark in one pass. **Sensitivity (`auto`/`strict`/`assume_ai`)** decides how hard a borderline mark is trusted: the visual detectors are pixel-based (no metadata needed) and the recall gain comes from relaxing the false-positive gate, not from metadata. `_resolve_relax` turns the policy + evidence into the per-mark relaxation boolean — `strict` never relaxes, `assume_ai` always relaxes (the caller asserts AI, e.g. a metadata-stripped screenshot; ~46% -> ~92% Gemini recall, at the cost of a small near-lossless fill on some clean corners), `auto` relaxes only on same-product evidence (metadata provenance for that vendor, or a confidently strict-detected sibling of the same `_PRODUCT_OF` — Doubao and Jimeng are both bottom-right ByteDance but distinct products, so they do NOT cross-relax). **Perception / decision / action are separated three ways** (the removal path only; `identify` keeps calling `KnownMark.detect` directly, so its verdict is untouched): `_build_candidates(image)` is PERCEPTION — it runs each detector at both trust levels and packages raw verdicts + the pill's flatness feature into `Candidate`s, no policy; `decide(candidates, Context(sensitivity, provenance)) -> [Decision]` is the pure DECISION arbiter — all keep/drop policy (`_resolve_relax` cross-mark corroboration + the pill gate) in one image-free, unit-testable function (`tests/test_watermark_registry.py::TestArbiter`); then `remove_auto_marks` does the ACTION, localizing -> filling each winner. The Gemini FP gate deliberately stays inside `gemini_engine` (not the arbiter) because `identify` reads that same gated confidence — pulling it out would drift the provenance verdict. Behavior is byte-identical to the pre-arbiter two-pass (corpus-verified: strict/auto 46%, assume_ai 92% Gemini recall unchanged).
|
||||
**Localize -> fill by policy (replaced reverse-alpha):** each mark is localized to a binary full-frame footprint mask (a `Localization`), and one shared, swappable fill inpaints that mask via `fill(image, mask, backend=...)` (delegates to `region_eraser.erase`). This replaced the old reverse-alpha removal (invert a captured alpha map, `original = (wm - a*logo)/(1-a)`, plus a thin residual inpaint) for ALL marks — gemini, doubao, jimeng, samsung, and jimeng_pill. **Why it changed:** reverse-alpha depended on a fixed captured alpha map at a fixed position, so it broke whenever a vendor moved or re-rendered its mark; and it was not colour-lossless even with the right map (it amplifies 8-bit quantization and JPEG-chroma error by `1/(1-a)`), which showed up as "the colour just changed, not removed" reports. Localize -> fill has a benign failure mode: a slightly-off localization just inpaints a small region near-losslessly instead of leaving a colour-shifted smear. The captured alpha maps are still used to DETECT the marks and to shape the mask (gemini's footprint), but NOT for pixel recovery. Fill backends: `cv2` (classical inpaint, no deps, the floor), `migan` (MI-GAN ONNX, light, the preferred default when the `migan` extra is installed), `lama` (big-LaMa ONNX, best quality, heavier, explicit opt-in); `auto` = MI-GAN if installed, else cv2. Each `KnownMark` ties a key to {usual `location`, `in_auto` flag, a `_detect` callable → uniform `MarkDetection`, a `_mask` callable → full-frame footprint mask}; `KnownMark.remove(image, *, backend="auto", provenance=False, force=False)`. Entries today: `gemini` (bottom-right sparkle), `doubao` (bottom-right "豆包AI生成"), `jimeng` (bottom-right "★ 即梦AI"), `samsung` (bottom-**LEFT** "✦ Contenuti generati dall'AI", Samsung Galaxy AI, Italian locale), and the capture-less `jimeng_pill` (top-left "AI生成"). `detect_marks(image, *, provenance=frozenset())` scans all (strict, for the identify verdict); `remove_auto_marks(image, *, sensitivity="auto", provenance=frozenset(), backend="auto")` removes every detected mark in one pass. **Sensitivity (`auto`/`strict`/`assume_ai`)** decides how hard a borderline mark is trusted: the visual detectors are pixel-based (no metadata needed) and the recall gain comes from relaxing the false-positive gate, not from metadata. `resolve_relax` turns the policy + evidence into the per-mark relaxation boolean — `strict` never relaxes, `assume_ai` always relaxes (the caller asserts AI, e.g. a metadata-stripped screenshot; ~46% -> ~92% Gemini recall, at the cost of a small near-lossless fill on some clean corners), `auto` relaxes only on same-product evidence (metadata provenance for that vendor, or a confidently strict-detected sibling of the same `_PRODUCT_OF` — Doubao and Jimeng are both bottom-right ByteDance but distinct products, so they do NOT cross-relax). **Perception / decision / action are separated three ways** (the removal path only; `identify` keeps calling `KnownMark.detect` directly, so its verdict is untouched): `_build_candidates(image)` is PERCEPTION — it runs each detector at both trust levels and packages raw verdicts + the pill's flatness feature into `Candidate`s, no policy; `decide(candidates, Context(sensitivity, provenance)) -> [Decision]` is the pure DECISION arbiter — all keep/drop policy (`resolve_relax` cross-mark corroboration + the pill gate) in one image-free, unit-testable function (`tests/test_watermark_registry.py::TestArbiter`); then `remove_auto_marks` does the ACTION, localizing -> filling each winner. The Gemini FP gate deliberately stays inside `gemini_engine` (not the arbiter) because `identify` reads that same gated confidence — pulling it out would drift the provenance verdict. Behavior is byte-identical to the pre-arbiter two-pass (corpus-verified: strict/auto 46%, assume_ai 92% Gemini recall unchanged).
|
||||
|
||||
**Provenance prior:** when local metadata already confirms the vendor, the mark's detection trust gate is relaxed (a confirmed vendor means the mark is present with high prior, so a mark the conservative detector would demote as a content false positive is trusted). `detect_marks` / `remove_auto_marks` take a `provenance` frozenset and `KnownMark.remove` a `provenance` flag. Mapping: a Google/Gemini C2PA issuer relaxes gemini (skips its false-positive gate and lowers the trust threshold from 0.5 to 0.35); a China-AIGC (TC260) label relaxes doubao/jimeng; `samsung_genai` relaxes samsung. Corpus finding: on Google-C2PA images, Gemini sparkle recall rose from ~46% (plain detector) to ~90% with the provenance prior (recovering marks the vendor moved or re-rendered). The localizer is cheap CPU (cv2/numpy), so a memory-tight caller runs it anywhere; the heavy MI-GAN/LaMa fill is opt-in and chosen by the caller.
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ def remove_visible(
|
||||
sensitivity: Sensitivity = "auto",
|
||||
backend: Backend = "auto",
|
||||
strip_metadata: bool = True,
|
||||
write_noop: bool = True,
|
||||
) -> tuple[NDArray[Any], list[str]]:
|
||||
"""Remove every detected known visible AI mark (Gemini sparkle, Doubao/Jimeng/
|
||||
Samsung text, the Jimeng pill) via localize -> fill, returning ``(result_bgr,
|
||||
@@ -81,6 +82,11 @@ def remove_visible(
|
||||
also strips AI provenance metadata (C2PA/EXIF/XMP/IPTC) from the written output via
|
||||
the lossless :func:`metadata.remove_ai_metadata`, so a library call does exactly
|
||||
what the CLI does. Only applies when ``output`` is given.
|
||||
|
||||
``write_noop`` (default True) controls whether ``output`` is written when NOTHING was
|
||||
removed: True writes a clean passthrough copy (an idempotent clean); False leaves the
|
||||
output path untouched, so a caller that treats "no mark" as "produce nothing" (the CLI
|
||||
``visible`` no-mark contract) does not clobber a pre-existing file at that path.
|
||||
"""
|
||||
from remove_ai_watermarks import image_io, watermark_registry
|
||||
|
||||
@@ -98,19 +104,24 @@ def remove_visible(
|
||||
result, removed = watermark_registry.remove_auto_marks(
|
||||
bgr, sensitivity=sensitivity, provenance=provenance, backend=backend
|
||||
)
|
||||
if output is not None:
|
||||
same_format = isinstance(source, (str, Path)) and Path(source).suffix.lower() == Path(output).suffix.lower()
|
||||
if output is not None and (removed or write_noop):
|
||||
out_path = Path(output)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
same_format = isinstance(source, (str, Path)) and Path(source).suffix.lower() == out_path.suffix.lower()
|
||||
if not removed and same_format:
|
||||
# Nothing was removed: copy the ORIGINAL bytes verbatim instead of a lossy
|
||||
# re-encode of its decode, so the pixels stay bit-identical (the metadata
|
||||
# strip below is lossless, so it does not disturb them either).
|
||||
import shutil
|
||||
# strip below is lossless, so it does not disturb them either). Skip the copy
|
||||
# for an in-place call (output == source): the bytes are already there, and
|
||||
# shutil.copyfile would raise SameFileError.
|
||||
if Path(source).resolve() != out_path.resolve(): # type: ignore[arg-type]
|
||||
import shutil
|
||||
|
||||
shutil.copyfile(source, output) # type: ignore[arg-type]
|
||||
shutil.copyfile(source, out_path) # type: ignore[arg-type]
|
||||
else:
|
||||
image_io.write_bgr_with_alpha(output, result, alpha)
|
||||
image_io.write_bgr_with_alpha(out_path, result, alpha)
|
||||
if strip_metadata:
|
||||
from remove_ai_watermarks import metadata
|
||||
|
||||
metadata.remove_ai_metadata(Path(output), Path(output))
|
||||
metadata.remove_ai_metadata(out_path, out_path)
|
||||
return result, removed
|
||||
|
||||
@@ -381,7 +381,13 @@ def _remove_visible_auto(
|
||||
bk: watermark_registry.Backend = backend # type: ignore[assignment]
|
||||
sens = _parse_sensitivity(sensitivity)
|
||||
provenance = _visible_provenance(source_path)
|
||||
result, removed = watermark_registry.remove_auto_marks(image, sensitivity=sens, provenance=provenance, backend=bk)
|
||||
try:
|
||||
result, removed = watermark_registry.remove_auto_marks(
|
||||
image, sensitivity=sens, provenance=provenance, backend=bk
|
||||
)
|
||||
except RuntimeError as e: # e.g. a selected migan/lama backend whose extra is absent
|
||||
console.print(f" Error: {e}")
|
||||
raise SystemExit(1) from e
|
||||
if not removed:
|
||||
return image, None
|
||||
return result, ", ".join(removed)
|
||||
@@ -576,15 +582,25 @@ def cmd_visible(
|
||||
from remove_ai_watermarks import api
|
||||
|
||||
t0 = time.monotonic()
|
||||
with console.status("Detecting & removing visible marks..."):
|
||||
result, removed = api.remove_visible(
|
||||
str(source), str(output), sensitivity=sens, backend=bk, strip_metadata=strip_metadata
|
||||
)
|
||||
try:
|
||||
with console.status("Detecting & removing visible marks..."):
|
||||
result, removed = api.remove_visible(
|
||||
str(source),
|
||||
str(output),
|
||||
sensitivity=sens,
|
||||
backend=bk,
|
||||
strip_metadata=strip_metadata,
|
||||
write_noop=False,
|
||||
)
|
||||
except RuntimeError as e: # e.g. a selected migan/lama backend whose extra is absent
|
||||
console.print(f" Error: {e}")
|
||||
raise SystemExit(1) from e
|
||||
elapsed = time.monotonic() - t0
|
||||
h, w = result.shape[:2]
|
||||
console.print(f" Input: {source.name} ({w}x{h})")
|
||||
if not removed:
|
||||
output.unlink(missing_ok=True) # api wrote a no-op copy; the no-mark contract writes nothing
|
||||
# write_noop=False means nothing was written, so a pre-existing file at the
|
||||
# output path is left intact (the no-mark contract writes nothing).
|
||||
console.print(" No known visible mark detected (gemini / doubao / jimeng / jimeng-pill / samsung).")
|
||||
_no_visible_mark_exit(source, sensitivity=sens)
|
||||
console.print(f" Removed: {', '.join(removed)}")
|
||||
@@ -603,9 +619,10 @@ def cmd_visible(
|
||||
provenance = _visible_provenance(source)
|
||||
target = "gemini" if mark == "auto" else mark # --no-detect auto: gemini fallback
|
||||
chosen = registry.get_mark(target)
|
||||
# A single explicit mark has no cross-mark pass, so its relaxation is just the
|
||||
# sensitivity + provenance decision (no sibling corroboration).
|
||||
prov = sens == "assume_ai" or (sens == "auto" and chosen.key in provenance)
|
||||
# A single explicit mark has no cross-mark pass (no sibling corroboration), so use the
|
||||
# canonical arbiter policy with an empty strict-sibling set instead of re-deriving it
|
||||
# inline (keeps this in lockstep with `decide`).
|
||||
prov = registry.resolve_relax(chosen.key, sensitivity=sens, provenance=provenance, strict_keys=set())
|
||||
det = chosen.detect(image, provenance=prov)
|
||||
if detect and not det.detected:
|
||||
console.print(f" {chosen.label} not detected (conf {det.confidence:.2f}). Use --no-detect to force.")
|
||||
@@ -613,8 +630,12 @@ def cmd_visible(
|
||||
if det.detected:
|
||||
console.print(f" {chosen.label} detected ({chosen.location}, conf {det.confidence:.2f})")
|
||||
t0 = time.monotonic()
|
||||
with console.status(f"Removing {chosen.label}... ({resolved_backend})"):
|
||||
result, _ = chosen.remove(image, backend=bk, provenance=prov, force=not detect)
|
||||
try:
|
||||
with console.status(f"Removing {chosen.label}... ({resolved_backend})"):
|
||||
result, _ = chosen.remove(image, backend=bk, provenance=prov, force=not detect)
|
||||
except RuntimeError as e: # e.g. a selected migan/lama backend whose extra is absent
|
||||
console.print(f" Error: {e}")
|
||||
raise SystemExit(1) from e
|
||||
elapsed = time.monotonic() - t0
|
||||
|
||||
# Save (rejoins the original alpha plane unchanged) + strip metadata.
|
||||
|
||||
@@ -498,7 +498,12 @@ class GeminiEngine:
|
||||
_MASK_DILATE_FRAC = 0.18 # dilation radius as a fraction of the sparkle scale
|
||||
|
||||
def footprint_mask(
|
||||
self, image: NDArray[Any], *, force: bool = False, dilate: int | None = None
|
||||
self,
|
||||
image: NDArray[Any],
|
||||
*,
|
||||
force: bool = False,
|
||||
dilate: int | None = None,
|
||||
region: tuple[int, int, int, int] | None = None,
|
||||
) -> NDArray[Any] | None:
|
||||
"""Full-frame uint8 mask (255 = sparkle) of the sparkle footprint, for the
|
||||
shared fill removal path (cv2 / MI-GAN / LaMa), or None.
|
||||
@@ -506,20 +511,29 @@ class GeminiEngine:
|
||||
The footprint is the interpolated captured alpha at the detected scale,
|
||||
thresholded LOW so the faint halo is included, then dilated by a
|
||||
sparkle-relative margin. When ``force`` and nothing is detected, falls back to
|
||||
the default sparkle slot for the image size (the ``--no-detect`` path). The
|
||||
caller gates on the trust-confidence detection.
|
||||
the default sparkle slot for the image size (the ``--no-detect`` path).
|
||||
|
||||
``region`` is the already-resolved ``(x, y, scale)`` from the caller's detection
|
||||
(the registry passes the decision's provenance-aware region). When given, the
|
||||
mask is built from it directly WITHOUT a second internal detect -- otherwise a
|
||||
provenance/assume-relaxed sparkle would be re-demoted by the strict re-detect and
|
||||
yield no mask (reported-removed-but-unchanged). Absent ``region``, direct callers
|
||||
keep the detect-then-force behavior.
|
||||
"""
|
||||
image = image_io.to_bgr(image)
|
||||
h, w = image.shape[:2]
|
||||
det = self.detect_watermark(image)
|
||||
if det.detected:
|
||||
x, y, scale = det.region[0], det.region[1], det.region[2]
|
||||
elif force:
|
||||
cfg = get_watermark_config(w, h)
|
||||
x, y = cfg.get_position(w, h)
|
||||
scale = cfg.logo_size
|
||||
if region is not None:
|
||||
x, y, scale = region[0], region[1], region[2]
|
||||
else:
|
||||
return None
|
||||
det = self.detect_watermark(image)
|
||||
if det.detected:
|
||||
x, y, scale = det.region[0], det.region[1], det.region[2]
|
||||
elif force:
|
||||
cfg = get_watermark_config(w, h)
|
||||
x, y = cfg.get_position(w, h)
|
||||
scale = cfg.logo_size
|
||||
else:
|
||||
return None
|
||||
alpha = self.get_interpolated_alpha(scale)
|
||||
fp = self._footprint_indices(alpha, (x, y), image.shape)
|
||||
if fp is None:
|
||||
|
||||
@@ -229,6 +229,12 @@ class InvisibleEngine:
|
||||
# SDXL img2img runs near its ~1024 training size instead of distorting on a
|
||||
# tiny latent (a 381x512 portrait wrecks at native -- issue #36 follow-up).
|
||||
# The output is restored to orig_size below, so the floor is transparent.
|
||||
# Register the HEIF/AVIF opener so a .heic/.avif input (now a SUPPORTED_FORMAT)
|
||||
# decodes here too. The --force skip path bypasses image_io.imread, which is
|
||||
# what would otherwise register it, so a bare Image.open would fail on HEIC.
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
image_io._register_heif()
|
||||
image = Image.open(image_path)
|
||||
image = ImageOps.exif_transpose(image)
|
||||
orig_size = image.size # (width, height)
|
||||
@@ -261,7 +267,9 @@ class InvisibleEngine:
|
||||
# Cleaned up in the finally block via _tmp_path.
|
||||
_tmp_fd, _tmp_str = tempfile.mkstemp(suffix=".png")
|
||||
_tmp_path = Path(_tmp_str)
|
||||
image.save(_tmp_path)
|
||||
# Convert to RGB before the PNG temp: the diffusion pass is RGB anyway, and a
|
||||
# non-RGB source mode (e.g. a CMYK JPEG) cannot be written as PNG and would raise.
|
||||
image.convert("RGB").save(_tmp_path)
|
||||
os.close(_tmp_fd)
|
||||
image_path = _tmp_path
|
||||
|
||||
|
||||
@@ -895,8 +895,7 @@ def _strip_jpeg_metadata_lossless(source_path: Path, output_path: Path) -> bool:
|
||||
i, n = 2, len(data)
|
||||
while i + 1 < n:
|
||||
if data[i] != 0xFF:
|
||||
out += data[i:] # malformed stream: copy the remainder verbatim
|
||||
break
|
||||
return False # malformed marker boundary: defer to the PIL re-encode fallback
|
||||
marker = data[i + 1]
|
||||
if marker in (0xDA, 0xD9): # SOS / EOI -> the coded scan follows; copy verbatim
|
||||
out += data[i:]
|
||||
@@ -906,13 +905,11 @@ def _strip_jpeg_metadata_lossless(source_path: Path, output_path: Path) -> bool:
|
||||
i += 2
|
||||
continue
|
||||
if i + 4 > n:
|
||||
out += data[i:]
|
||||
break
|
||||
return False # truncated segment header: defer to the PIL re-encode fallback
|
||||
seg_len = int.from_bytes(data[i + 2 : i + 4], "big")
|
||||
seg_end = i + 2 + seg_len
|
||||
if seg_len < 2 or seg_end > n:
|
||||
out += data[i:]
|
||||
break
|
||||
return False # malformed segment length: defer to the PIL re-encode fallback
|
||||
if not _jpeg_app_carries_ai(marker, data[i + 4 : seg_end]):
|
||||
out += data[i:seg_end]
|
||||
i = seg_end
|
||||
@@ -998,8 +995,14 @@ def remove_ai_metadata(
|
||||
# re-encoded. The PIL open+save path below is lossy for JPEG (a q95 re-encode that
|
||||
# would undo the quality-preserving writes of the removal pipelines); this keeps a
|
||||
# JPEG bit-identical outside its APP metadata segments. Falls through on a
|
||||
# non-parseable JPEG.
|
||||
if output_path.suffix.lower() in (".jpg", ".jpeg") and _strip_jpeg_metadata_lossless(source_path, output_path):
|
||||
# non-parseable JPEG. Only when keep_standard: the lossless walk drops AI segments
|
||||
# but preserves standard ones, so a keep_standard=False caller (strip EVERYTHING)
|
||||
# must use the full re-encode path below instead.
|
||||
if (
|
||||
keep_standard
|
||||
and output_path.suffix.lower() in (".jpg", ".jpeg")
|
||||
and _strip_jpeg_metadata_lossless(source_path, output_path)
|
||||
):
|
||||
return output_path
|
||||
|
||||
# Read image and filter metadata
|
||||
|
||||
@@ -98,7 +98,7 @@ class PillEngine:
|
||||
h, w = image.shape[:2]
|
||||
if h < 64 or w < 64:
|
||||
return None
|
||||
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if image.ndim == 3 else image
|
||||
gray = cv2.cvtColor(image_io.to_bgr(image), cv2.COLOR_BGR2GRAY)
|
||||
rh, rw = int(h * _ROI_H_FRAC), int(w * _ROI_W_FRAC)
|
||||
roi = gray[0:rh, 0:rw]
|
||||
tw = max(24, int(_WIDTH_FRAC * w))
|
||||
@@ -138,7 +138,7 @@ class PillEngine:
|
||||
return 0.0
|
||||
x0, y0, x1, y1 = box
|
||||
crop = image[y0:y1, x0:x1]
|
||||
gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY) if crop.ndim == 3 else crop
|
||||
gray = cv2.cvtColor(image_io.to_bgr(crop), cv2.COLOR_BGR2GRAY)
|
||||
tw = 220
|
||||
gray = cv2.resize(gray, (tw, max(1, int(gray.shape[0] * tw / gray.shape[1])))).astype(np.float32)
|
||||
gx = cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize=3)
|
||||
|
||||
@@ -47,7 +47,7 @@ Backend = Literal["auto", "cv2", "migan", "lama"]
|
||||
# a clean corner). Lowest recall on faint/moved marks.
|
||||
# * ``auto`` (default): relax a mark's gate ONLY when the image carries same-product
|
||||
# evidence the mark is there -- metadata provenance for that vendor, or a confidently
|
||||
# detected sibling mark of the same product (see ``_resolve_relax``). No evidence ->
|
||||
# detected sibling mark of the same product (see ``resolve_relax``). No evidence ->
|
||||
# stays strict. Safe: it only escalates where the mark is corroborated.
|
||||
# * ``assume_ai``: relax every mark's gate regardless of evidence -- the caller asserts
|
||||
# the image is AI and wants the mark gone (e.g. a metadata-stripped screenshot uploaded
|
||||
@@ -179,7 +179,10 @@ class KnownMark:
|
||||
det = self.detect(image, provenance=provenance)
|
||||
if not (det.detected or force):
|
||||
return Localization(det.detected, det.confidence, det.region, None)
|
||||
mask = self._mask(image, force=force)
|
||||
# Pass the (provenance-aware) detection to the mask builder so it does NOT
|
||||
# re-detect at a different trust level -- a relaxed sparkle must not be
|
||||
# re-demoted into a None mask (reported-removed-but-unchanged).
|
||||
mask = self._mask(image, force=force, detection=det)
|
||||
return Localization(det.detected, det.confidence, det.region, mask)
|
||||
|
||||
def remove(
|
||||
@@ -315,8 +318,14 @@ def _gemini_detect(image: NDArray[Any], *, provenance: bool = False) -> MarkDete
|
||||
return MarkDetection("gemini", "Google Gemini sparkle", "bottom-right", detected, d.confidence, d.region)
|
||||
|
||||
|
||||
def _gemini_mask(image: NDArray[Any], *, force: bool = False) -> NDArray[Any] | None:
|
||||
return _engine("gemini").footprint_mask(image, force=force)
|
||||
def _gemini_mask(
|
||||
image: NDArray[Any], *, force: bool = False, detection: MarkDetection | None = None
|
||||
) -> NDArray[Any] | None:
|
||||
# Reuse the decision's provenance-aware region (skip the strict re-detect that would
|
||||
# otherwise re-demote a relaxed sparkle to None); None region -> footprint_mask
|
||||
# falls back to its own detect-then-force path (direct/--no-detect callers).
|
||||
region = detection.region if (detection is not None and detection.detected) else None
|
||||
return _engine("gemini").footprint_mask(image, force=force, region=region)
|
||||
|
||||
|
||||
# The three text-mark engines (Doubao/Jimeng/Samsung) share the TextMarkEngine
|
||||
@@ -333,7 +342,12 @@ def _text_mark_detect(key: str, label: str, location: str) -> Callable[..., Mark
|
||||
|
||||
|
||||
def _text_mark_mask(key: str) -> Callable[..., NDArray[Any] | None]:
|
||||
def mask(image: NDArray[Any], *, force: bool = False) -> NDArray[Any] | None:
|
||||
def mask(
|
||||
image: NDArray[Any], *, force: bool = False, detection: MarkDetection | None = None
|
||||
) -> NDArray[Any] | None:
|
||||
# Text masks rebuild the glyph blob template-free (no trust gate to re-apply), so
|
||||
# the detection is not needed here; accepted for the uniform _mask signature.
|
||||
del detection
|
||||
return _engine(key).footprint_mask(image, force=force)
|
||||
|
||||
return mask
|
||||
@@ -354,7 +368,12 @@ def _pill_detect(image: NDArray[Any], *, provenance: bool = False) -> MarkDetect
|
||||
return MarkDetection("jimeng_pill", "Jimeng AI生成 pill", "top-left", d.detected, d.confidence, d.region)
|
||||
|
||||
|
||||
def _pill_mask(image: NDArray[Any], *, force: bool = False) -> NDArray[Any] | None:
|
||||
def _pill_mask(
|
||||
image: NDArray[Any], *, force: bool = False, detection: MarkDetection | None = None
|
||||
) -> NDArray[Any] | None:
|
||||
# The pill mask is a fixed top-left geometry box, independent of the detection;
|
||||
# accepted for the uniform _mask signature.
|
||||
del detection
|
||||
return _engine("jimeng_pill").footprint_mask(image, force=force)
|
||||
|
||||
|
||||
@@ -406,7 +425,7 @@ def detect_marks(
|
||||
return [m.detect(image, provenance=m.key in provenance) for m in _REGISTRY if include_explicit or m.in_auto]
|
||||
|
||||
|
||||
def _resolve_relax(
|
||||
def resolve_relax(
|
||||
key: str,
|
||||
*,
|
||||
sensitivity: Sensitivity,
|
||||
@@ -487,14 +506,14 @@ def decide(candidates: list[Candidate], context: Context) -> list[Decision]:
|
||||
"""The removal ARBITER: a pure function turning perception + context into the
|
||||
ordered list of marks to remove (and the trust level each was accepted at).
|
||||
|
||||
All policy lives here, in one place: per-mark relaxation (:func:`_resolve_relax`,
|
||||
All policy lives here, in one place: per-mark relaxation (:func:`resolve_relax`,
|
||||
which needs the strict-detected siblings for ``auto`` cross-mark corroboration) and
|
||||
the capture-less pill gate (:func:`_keep_pill`). No image, no I/O -- so it is
|
||||
unit-testable in isolation and the same decision drives every caller."""
|
||||
strict_keys = {c.key for c in candidates if c.detected_strict}
|
||||
fired: list[Decision] = []
|
||||
for c in candidates:
|
||||
relax = _resolve_relax(
|
||||
relax = resolve_relax(
|
||||
c.key, sensitivity=context.sensitivity, provenance=context.provenance, strict_keys=strict_keys
|
||||
)
|
||||
if c.detected_relaxed if relax else c.detected_strict:
|
||||
@@ -538,6 +557,10 @@ def remove_auto_marks(
|
||||
result = image
|
||||
labels: list[str] = []
|
||||
for d in decide(_build_candidates(image), context):
|
||||
result, _ = get_mark(d.candidate.key).remove(result, backend=backend, provenance=d.relax, force=False)
|
||||
labels.append(d.candidate.label)
|
||||
result, region = get_mark(d.candidate.key).remove(result, backend=backend, provenance=d.relax, force=False)
|
||||
# Only report the mark as removed when a fill actually happened: remove() returns
|
||||
# a None region when the localized mask came back empty, and reporting it anyway
|
||||
# would claim a removal that left the pixels unchanged.
|
||||
if region is not None:
|
||||
labels.append(d.candidate.label)
|
||||
return result, labels
|
||||
|
||||
@@ -103,3 +103,26 @@ class TestVisibleProvenance:
|
||||
|
||||
def test_unreadable_path_is_empty(self, tmp_path):
|
||||
assert raiw.visible_provenance(tmp_path / "missing.png") == frozenset()
|
||||
|
||||
|
||||
class TestRemoveVisibleOutputPath:
|
||||
"""Output-path robustness: in-place clean (#3) and a missing output dir (#4)."""
|
||||
|
||||
def _write_clean(self, p: Path) -> None:
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
image_io.imwrite(str(p), np.full((128, 128, 3), 200, np.uint8))
|
||||
|
||||
def test_inplace_clean_no_crash(self, tmp_path: Path):
|
||||
p = tmp_path / "clean.png"
|
||||
self._write_clean(p)
|
||||
_, removed = raiw.remove_visible(str(p), str(p), backend="cv2")
|
||||
assert removed == []
|
||||
assert p.exists()
|
||||
|
||||
def test_creates_missing_output_dir(self, tmp_path: Path):
|
||||
src = tmp_path / "in.png"
|
||||
self._write_clean(src)
|
||||
out = tmp_path / "sub" / "out.png"
|
||||
raiw.remove_visible(str(src), str(out), backend="cv2")
|
||||
assert out.exists()
|
||||
|
||||
@@ -866,3 +866,24 @@ class TestEraseCommand:
|
||||
assert result.exit_code != 0
|
||||
assert "onnxruntime" in result.output.lower()
|
||||
assert not output.exists()
|
||||
|
||||
|
||||
def test_visible_backend_runtime_error_exits_cleanly(runner, tmp_path, monkeypatch):
|
||||
# A backend whose extra is missing raises RuntimeError in region_eraser.erase;
|
||||
# the visible --mark auto path must surface it cleanly, not as a raw traceback (#9).
|
||||
from pathlib import Path
|
||||
|
||||
from remove_ai_watermarks import region_eraser
|
||||
|
||||
doubao = Path(__file__).resolve().parent.parent / "data" / "samples" / "doubao-1.png"
|
||||
if not doubao.exists():
|
||||
pytest.skip("doubao sample not present")
|
||||
|
||||
def boom(*_a, **_k):
|
||||
raise RuntimeError("MI-GAN backend requires onnxruntime. Install the extra: ...")
|
||||
|
||||
monkeypatch.setattr(region_eraser, "erase", boom)
|
||||
out = tmp_path / "out.png"
|
||||
result = runner.invoke(main, ["visible", str(doubao), "-o", str(out), "--backend", "migan"])
|
||||
assert result.exit_code == 1
|
||||
assert not isinstance(result.exception, RuntimeError), "RuntimeError leaked as a traceback"
|
||||
|
||||
@@ -1359,3 +1359,28 @@ class TestC2paCloudManifest:
|
||||
assert r.is_ai_generated is None
|
||||
assert any("Durable Content Credentials" in w for w in r.watermarks)
|
||||
assert any(s.name == "c2pa_cloud" for s in r.signals)
|
||||
|
||||
|
||||
def test_remove_all_bypasses_lossless_jpeg(tmp_path, monkeypatch):
|
||||
# keep_standard=False (--remove-all) must NOT take the AI-only lossless JPEG path,
|
||||
# which leaves standard (non-AI) metadata in place; it must fall through to the full
|
||||
# re-encode strip (#7).
|
||||
import remove_ai_watermarks.metadata as md
|
||||
|
||||
calls: list[int] = []
|
||||
real = md._strip_jpeg_metadata_lossless
|
||||
|
||||
def spy(s, o):
|
||||
calls.append(1)
|
||||
return real(s, o)
|
||||
|
||||
monkeypatch.setattr(md, "_strip_jpeg_metadata_lossless", spy)
|
||||
src = tmp_path / "x.jpg"
|
||||
Image.new("RGB", (32, 32), (128, 128, 128)).save(src, "JPEG")
|
||||
out = tmp_path / "o.jpg"
|
||||
|
||||
md.remove_ai_metadata(src, out, keep_standard=True)
|
||||
assert calls, "lossless JPEG strip should run when keeping standard metadata"
|
||||
calls.clear()
|
||||
md.remove_ai_metadata(src, out, keep_standard=False)
|
||||
assert not calls, "keep_standard=False must bypass the AI-only lossless JPEG path"
|
||||
|
||||
@@ -167,7 +167,20 @@ class TestPillGate:
|
||||
assert "Jimeng AI生成 pill" not in removed
|
||||
|
||||
def test_pill_dropped_on_doubao_even_with_metadata(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# doubao is faked as detected, which drives the pill gate (pill never rides on a
|
||||
# Doubao detection). The same flat + jimeng-metadata setup WITHOUT doubao keeps the
|
||||
# pill (test_pill_kept_with_metadata_on_flat_footprint), so doubao is the
|
||||
# differentiator. Doubao itself is not asserted in `removed` here: this synthetic
|
||||
# frame is flat with no real glyph, so the text mask has nothing to fill (its real
|
||||
# removal is covered by TestRealSample on the committed doubao sample).
|
||||
self._fakes(monkeypatch, {"doubao", "jimeng_pill"})
|
||||
_, removed = registry.remove_auto_marks(np.full((400, 300, 3), 150, np.uint8), provenance=frozenset({"jimeng"}))
|
||||
assert "Doubao 豆包AI生成 text" in removed
|
||||
assert "Jimeng AI生成 pill" not in removed
|
||||
|
||||
|
||||
def test_detect_bgra_no_crash() -> None:
|
||||
# A 4-channel BGRA array must be normalized, not crash cv2.cvtColor(BGR2GRAY) (#10).
|
||||
bgra = np.zeros((256, 256, 4), np.uint8)
|
||||
det = PillEngine().detect(bgra)
|
||||
assert det.detected in (True, False)
|
||||
assert PillEngine().footprint_texture(bgra) >= 0.0
|
||||
|
||||
@@ -130,38 +130,36 @@ class TestLocalizeFill:
|
||||
|
||||
|
||||
class TestSensitivity:
|
||||
"""``_resolve_relax`` turns the sensitivity policy + evidence into the per-mark
|
||||
"""``resolve_relax`` turns the sensitivity policy + evidence into the per-mark
|
||||
relaxation boolean the engines consume."""
|
||||
|
||||
def test_strict_never_relaxes(self):
|
||||
# even with metadata provenance, strict keeps the conservative gate
|
||||
assert (
|
||||
reg._resolve_relax("gemini", sensitivity="strict", provenance=frozenset({"gemini"}), strict_keys=set())
|
||||
reg.resolve_relax("gemini", sensitivity="strict", provenance=frozenset({"gemini"}), strict_keys=set())
|
||||
is False
|
||||
)
|
||||
|
||||
def test_assume_ai_always_relaxes(self):
|
||||
assert reg._resolve_relax("gemini", sensitivity="assume_ai", provenance=frozenset(), strict_keys=set()) is True
|
||||
assert reg.resolve_relax("gemini", sensitivity="assume_ai", provenance=frozenset(), strict_keys=set()) is True
|
||||
|
||||
def test_auto_relaxes_on_own_metadata(self):
|
||||
assert (
|
||||
reg._resolve_relax("gemini", sensitivity="auto", provenance=frozenset({"gemini"}), strict_keys=set())
|
||||
is True
|
||||
reg.resolve_relax("gemini", sensitivity="auto", provenance=frozenset({"gemini"}), strict_keys=set()) is True
|
||||
)
|
||||
|
||||
def test_auto_strict_without_evidence(self):
|
||||
assert reg._resolve_relax("gemini", sensitivity="auto", provenance=frozenset(), strict_keys=set()) is False
|
||||
assert reg.resolve_relax("gemini", sensitivity="auto", provenance=frozenset(), strict_keys=set()) is False
|
||||
|
||||
def test_auto_cross_mark_same_product(self):
|
||||
# a detected Jimeng wordmark relaxes the Jimeng pill (same product, other corner)
|
||||
assert (
|
||||
reg._resolve_relax("jimeng_pill", sensitivity="auto", provenance=frozenset(), strict_keys={"jimeng"})
|
||||
is True
|
||||
reg.resolve_relax("jimeng_pill", sensitivity="auto", provenance=frozenset(), strict_keys={"jimeng"}) is True
|
||||
)
|
||||
|
||||
def test_auto_no_cross_mark_across_products(self):
|
||||
# a detected Jimeng wordmark must NOT relax Doubao (distinct products, same corner)
|
||||
assert reg._resolve_relax("doubao", sensitivity="auto", provenance=frozenset(), strict_keys={"jimeng"}) is False
|
||||
assert reg.resolve_relax("doubao", sensitivity="auto", provenance=frozenset(), strict_keys={"jimeng"}) is False
|
||||
|
||||
def test_remove_auto_marks_accepts_all_sensitivities(self):
|
||||
blank = np.zeros((256, 256, 3), np.uint8)
|
||||
@@ -225,3 +223,39 @@ class TestArbiter:
|
||||
self._c("jimeng_pill", strict=True, relaxed=True, flat=False),
|
||||
]
|
||||
assert "jimeng_pill" in self._keys(cands, reg.Context())
|
||||
|
||||
|
||||
class TestProvenanceMaskThreading:
|
||||
"""Regression for the provenance-relaxed Gemini no-op (#1) and the false 'removed'
|
||||
label (#2). Before the fix, footprint_mask re-detected WITHOUT trust_provenance, the
|
||||
FP gate demoted the sparkle to detected=False, the mask came back None, yet
|
||||
remove_auto_marks still reported the mark as removed."""
|
||||
|
||||
def test_relaxed_sparkle_yields_mask(self, monkeypatch: pytest.MonkeyPatch):
|
||||
# A sparkle a strict re-detect would demote (detected False) but a
|
||||
# provenance-relaxed detect accepts must still produce a removal mask.
|
||||
from remove_ai_watermarks.gemini_engine import DetectionResult
|
||||
|
||||
def fake(image, force_size=None, *, trust_provenance=False):
|
||||
return DetectionResult(
|
||||
detected=trust_provenance, confidence=0.42 if trust_provenance else 0.30, region=(400, 400, 60)
|
||||
)
|
||||
|
||||
monkeypatch.setattr(reg._engine("gemini"), "detect_watermark", fake)
|
||||
img = np.full((512, 512, 3), 90, np.uint8)
|
||||
assert reg.get_mark("gemini").localize(img, provenance=True).mask is not None
|
||||
assert reg.get_mark("gemini").localize(img, provenance=False).mask is None
|
||||
|
||||
def test_no_label_when_mask_none(self, monkeypatch: pytest.MonkeyPatch):
|
||||
# A decided mark whose mask comes back None must NOT be reported as removed.
|
||||
from remove_ai_watermarks.gemini_engine import DetectionResult
|
||||
|
||||
eng = reg._engine("gemini")
|
||||
monkeypatch.setattr(
|
||||
eng,
|
||||
"detect_watermark",
|
||||
lambda image, force_size=None, *, trust_provenance=False: DetectionResult(True, 0.9, (10, 10, 40)),
|
||||
)
|
||||
monkeypatch.setattr(eng, "footprint_mask", lambda image, *, force=False, region=None, dilate=None: None)
|
||||
_, removed = reg.remove_auto_marks(np.zeros((256, 256, 3), np.uint8), sensitivity="strict", backend="cv2")
|
||||
assert "Google Gemini sparkle" not in removed
|
||||
|
||||
Reference in New Issue
Block a user