From 30b56f0ea36e56e7390908a0824b137d1199b569 Mon Sep 17 00:00:00 2001 From: Victor Kuznetsov Date: Fri, 12 Jun 2026 21:36:56 -0700 Subject: [PATCH] fix(cli): stop silent passthrough when visible finds no known mark When `visible --mark auto` (or an explicit `--mark` with detection on) found no registered mark, it exited 0 without writing output -- which a wrapping service reads as success and re-serves the unchanged input. ~74% of real uploads carry no registered visible mark, so this was the dominant "it didn't work" / NPS score-0 failure mode. Now it runs a cheap metadata-only identify, prints actionable guidance (route to `all` for an invisible/metadata mark, or `erase` for an arbitrary logo), writes no output file, and exits EXIT_NO_VISIBLE_MARK (2) -- distinct from success (0) and a hard error (1) so the caller can surface the message. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- README.md | 3 ++ src/remove_ai_watermarks/cli.py | 50 +++++++++++++++++++++++++++++---- tests/test_cli.py | 26 +++++++++++++++++ 4 files changed, 74 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6e66a7e..339d2c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ You are a **principal Python engineer** maintaining a CLI tool and library for r - `uv run remove-ai-watermarks all -o ` — full pipeline (visible + invisible + metadata). Same diffusion knobs as `invisible` below, plus the visible-pass `--inpaint/--no-inpaint`/`--inpaint-method`. **When the `[gpu]` extra is absent, step 2 (invisible/SynthID) is skipped** — `all` still writes an output (visible mark + metadata stripped) but prints a prominent end-of-run banner ("the invisible (SynthID) watermark was NOT removed") AND exits **non-zero** (1), so a skipped SynthID pass is not mistaken for a clean result (the recurring #14/#47 trap, where the old quiet inline warning was missed). `invisible` already hard-errors without the extra; only `all` continued, hence the loud end-banner. Regression-guarded by `tests/test_cli.py::TestAllCommand::test_all_loud_warning_and_nonzero_exit_when_gpu_missing`. **Test trap:** any `all` test that exercises the full pipeline MUST `patch("remove_ai_watermarks.invisible_engine.is_available", return_value=True)` — CI installs core+dev only (no `[gpu]`), so an unpatched `all` test takes the skip branch and now hits the non-zero exit. This passed locally (gpu present → `is_available()` True) but red-failed every matrix cell on the v0.11.0 commit (`test_all_basic`/`test_all_visible_step_uses_registry` asserted exit 0); both now patch `is_available` True. - `uv run remove-ai-watermarks invisible -o ` — diffusion SynthID removal. **Full knob set** (kept identical across `invisible`/`all`/`batch`): `--strength` (vendor-adaptive default), `--steps`, `--guidance-scale` (CFG, default 7.5), `--pipeline sdxl|controlnet` (default `controlnet`), `--controlnet-scale`, `--model` (HF model id, default SDXL base), `--device`, `--seed`, `--hf-token`, `--max-resolution`/`--min-resolution`, `--upscaler lanczos|esrgan`, `--humanize` (Analog Humanizer grain), `--unsharp` (final sharpen), and `--adaptive-polish/--no-adaptive-polish` (**ON by default**; detail-targeted polish that self-gates to a no-op where there is no deficit). `--auto` is deprecated and now a no-op that only warns (the polish it used to enable is ON by default). -- `uv run remove-ai-watermarks visible -o ` — known-visible-mark removal, CPU, no GPU. Reverse-alpha based: each mark is removed by inverting its captured alpha map. `--mark auto` (default) picks the strongest detected of the Gemini sparkle, the Doubao "豆包AI生成" text strip, the Jimeng "★ 即梦AI" wordmark, and the Samsung Galaxy AI "✦ Contenuti generati dall'AI" strip (bottom-LEFT, locale-specific — Italian variant calibrated); `--mark gemini` / `--mark doubao` / `--mark jimeng` / `--mark samsung` force one (choices come from the registry). Gemini/Doubao recover pixels exactly with no inpaint at native; **Jimeng and Samsung add an always-on thin residual inpaint over the glyph footprint** (their marks re-rasterize per image, so reverse-alpha alone leaves a faint outline). For arbitrary logos/objects use `erase`. +- `uv run remove-ai-watermarks visible -o ` — known-visible-mark removal, CPU, no GPU. Reverse-alpha based: each mark is removed by inverting its captured alpha map. `--mark auto` (default) picks the strongest detected of the Gemini sparkle, the Doubao "豆包AI生成" text strip, the Jimeng "★ 即梦AI" wordmark, and the Samsung Galaxy AI "✦ Contenuti generati dall'AI" strip (bottom-LEFT, locale-specific — Italian variant calibrated); `--mark gemini` / `--mark doubao` / `--mark jimeng` / `--mark samsung` force one (choices come from the registry). Gemini/Doubao recover pixels exactly with no inpaint at native; **Jimeng and Samsung add an always-on thin residual inpaint over the glyph footprint** (their marks re-rasterize per image, so reverse-alpha alone leaves a faint outline). For arbitrary logos/objects use `erase`. **When `--mark auto` finds no known mark (the common case — ~74% of real uploads carry no registered visible mark), the command does NOT silently re-serve the input as a finished result.** It runs a cheap metadata-only `identify`, prints actionable guidance (if the image carries an invisible/metadata mark, e.g. an OpenAI/Gemini C2PA image, it points to `all`; otherwise to `erase --region`), writes NO output file, and exits **`EXIT_NO_VISIBLE_MARK` (2)** — distinct from success (0) and a hard error (1) so a wrapping service (raiw.cc) can surface the message instead of treating the unchanged image as done (the production "it didn't work" / score-0 trap). Same handling for an explicit `--mark ` that is not detected. Helper `cli._no_visible_mark_exit`; regression-guarded by `tests/test_cli.py::TestVisibleCommand::test_visible_auto_no_mark_exits_two_with_eraser_hint` and `test_visible_auto_no_mark_routes_to_all_when_metadata`. `--no-detect` still forces the gemini fallback and proceeds (exit 0). - `uv run remove-ai-watermarks erase --region x,y,w,h -o ` — universal region eraser (any logo/object, any position). `--backend cv2` (default, no deps) or `--backend lama` (big-LaMa via onnxruntime, extra `lama`); `--region` is repeatable. - `uv run remove-ai-watermarks identify ` — provenance verdict (platform + watermark inventory + confidence); `--json` for machine output, `--no-visible` to skip the cv2 sparkle detector - `uv run remove-ai-watermarks metadata --check` — inspect AI metadata (C2PA, EXIF, PNG chunks) diff --git a/README.md b/README.md index 535d9ad..2a14436 100644 --- a/README.md +++ b/README.md @@ -333,6 +333,9 @@ remove-ai-watermarks identify image.png # strongest known mark (Gemini sparkle / Doubao "豆包AI生成" / Jimeng "即梦AI" / # Samsung Galaxy AI "Contenuti generati dall'AI"); force one with # --mark gemini / doubao / jimeng / samsung. Removed by reverse-alpha (true-pixel recovery). +# If no known visible mark is found, it writes no output and exits 2 (not 0), +# pointing you to `all` (for an invisible/metadata mark) or `erase` (for an +# arbitrary logo) instead of handing back the unchanged image. remove-ai-watermarks visible image.png -o clean.png # Erase arbitrary region(s) — universal, any logo/watermark/object, any position. diff --git a/src/remove_ai_watermarks/cli.py b/src/remove_ai_watermarks/cli.py index d72d56b..f90b5c7 100644 --- a/src/remove_ai_watermarks/cli.py +++ b/src/remove_ai_watermarks/cli.py @@ -13,7 +13,7 @@ import json import logging import time from pathlib import Path -from typing import TYPE_CHECKING, Any, Literal +from typing import TYPE_CHECKING, Any, Literal, NoReturn import click @@ -317,6 +317,45 @@ def _remove_visible_auto( return result, best.label +# Exit code for the standalone ``visible`` command when no visible mark was +# removed -- distinct from success (0) and a hard error (1) so a wrapping +# service can tell "nothing to do here" apart and surface guidance instead of +# re-serving the unchanged input as a finished result. +EXIT_NO_VISIBLE_MARK = 2 + + +def _no_visible_mark_exit(source: Path) -> NoReturn: + """Explain why no visible watermark was removed, then exit non-zero. + + The visible registry handles only known visual marks (the Gemini sparkle and + the Doubao/Jimeng/Samsung text strips). Most real uploads carry no such mark + -- frequently an invisible/metadata watermark instead (e.g. an OpenAI or + Gemini image whose only signal is C2PA + SynthID). Returning the input + unchanged with exit 0 reads as success to a caller and re-serves the + watermarked image -- the recurring "it didn't work" report. Instead, run a + cheap metadata-only :func:`identify`, tell the user what the image actually + carries and which command removes it, and exit + :data:`EXIT_NO_VISIBLE_MARK`. + """ + from remove_ai_watermarks.identify import identify + + report = identify(source, check_visible=False, check_invisible=False) + if report.is_ai_generated and report.watermarks: + plat = report.platform or "an unidentified platform" + console.print( + f" This image carries an invisible/metadata watermark ({plat}), not a visible mark,\n" + " so the 'visible' command cannot remove it. Run the full pipeline instead:\n" + f" remove-ai-watermarks all {source.name}" + ) + else: + console.print( + " No AI provenance signal found either. If there is a logo or object to remove,\n" + " target it directly with the region eraser:\n" + f" remove-ai-watermarks erase {source.name} --region x,y,w,h" + ) + raise SystemExit(EXIT_NO_VISIBLE_MARK) + + def _read_bgr_and_alpha(path: Path) -> tuple[NDArray[Any] | None, NDArray[Any] | None]: """Read an image preserving its alpha channel separately. @@ -450,10 +489,9 @@ def cmd_visible( if mark == "auto": best = registry.best_auto_mark(image) if best is None: - console.print(" Warning: No known visible mark detected (gemini / doubao).") + console.print(" No known visible mark detected (gemini / doubao / jimeng / samsung).") if detect: - console.print(" Skipping. Use --mark --no-detect to force.") - raise SystemExit(0) + _no_visible_mark_exit(source) target = "gemini" # forced (no-detect): fall back to the default mark else: target = best.key @@ -464,8 +502,8 @@ def cmd_visible( chosen = registry.get_mark(target) det = chosen.detect(image) if detect and not det.detected: - console.print(f" Warning: {chosen.label} not detected (conf {det.confidence:.2f}). Use --no-detect to force.") - raise SystemExit(0) + console.print(f" {chosen.label} not detected (conf {det.confidence:.2f}). Use --no-detect to force.") + _no_visible_mark_exit(source) if det.detected: console.print(f" {chosen.label} detected ({chosen.location}, conf {det.confidence:.2f})") diff --git a/tests/test_cli.py b/tests/test_cli.py index 9a32a87..9da3f6f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -125,6 +125,32 @@ class TestVisibleCommand: assert "visible AI watermark" in result.output assert "--mark" in result.output + def test_visible_auto_no_mark_exits_two_with_eraser_hint(self, runner, sample_png, tmp_path): + # No known visible mark and no AI provenance signal: the command must not + # re-serve the input as a finished result. It exits EXIT_NO_VISIBLE_MARK + # (2) -- distinct from success (0) and a hard error (1) -- writes no + # output file, and points the user at the region eraser. + output = tmp_path / "clean.png" + result = runner.invoke(main, ["visible", str(sample_png), "-o", str(output)]) + assert result.exit_code == 2, result.output + assert not output.exists() + assert "erase" in result.output + + def test_visible_auto_no_mark_routes_to_all_when_metadata(self, runner, tmp_path): + # An image whose only signal is an invisible/metadata watermark (here SD + # generation parameters) has no visible mark to remove; the command must + # exit 2 and upsell the full 'all' pipeline rather than the eraser. + img = Image.fromarray(np.random.default_rng(0).integers(0, 255, (200, 200, 3), dtype=np.uint8)) + pnginfo = PngInfo() + pnginfo.add_text("parameters", "Steps: 20, Sampler: Euler, a test landscape") + src = tmp_path / "ai.png" + img.save(src, pnginfo=pnginfo) + output = tmp_path / "clean.png" + result = runner.invoke(main, ["visible", str(src), "-o", str(output)]) + assert result.exit_code == 2, result.output + assert not output.exists() + assert "all" in result.output + def test_visible_basic(self, runner, sample_png, tmp_path): output = tmp_path / "clean.png" result = runner.invoke(