From 637395b8a6385108fdfc369d2811dc2684742489 Mon Sep 17 00:00:00 2001 From: Victor Kuznetsov Date: Tue, 1 Sep 2026 19:35:59 -0700 Subject: [PATCH] Name the missing pixel stack instead of tracebacking on it The default package installs without `pixels`, and the Homebrew formula ships exactly that build, so a first run that followed this project's own install instructions died mid-command on a bare `ModuleNotFoundError: No module named 'cv2'` -- the one moment the user needed the extra named, and the only moment they were not told it. `visible`, `erase`, `all` and `batch` now stop with the install hint; `remove_batch` lets the same error out instead of counting one absent package as an error per image, which turned a directory of 84 into 84 failures with no cause named. Ask `optional_deps.pixels_available()` rather than matching the exception's module name. An ImportError raised inside `cv2/__init__.py` carries `cv2.cv2` and a numpy ABI mismatch carries `None`, so a name match let exactly the tracebacks this guard exists for through. `identify` keeps its historical no-op when the visible arm cannot decode, but the report no longer stays silent about it: an install lacking the extra dropped the visible Gemini mark from a marked image and read exactly like a clean scan. The caveat names which silence happened, because an absent extra and an unreadable file need different fixes. `metadata --remove` reports surviving markers instead of crashing when the raster-normalization recovery cannot import cv2, and the video runtime's install hint is now shell-quoted like the others -- bare brackets are a glob in zsh. Co-Authored-By: Claude Opus 5 --- .../_internal/watermark_profiles.py | 6 +++ src/remove_ai_watermarks/api.py | 7 +++ src/remove_ai_watermarks/cli.py | 43 ++++++++++++++++++- src/remove_ai_watermarks/identify.py | 27 +++++++++++- src/remove_ai_watermarks/metadata.py | 12 +++++- src/remove_ai_watermarks/optional_deps.py | 17 ++++++++ src/remove_ai_watermarks/video.py | 9 +++- 7 files changed, 115 insertions(+), 6 deletions(-) diff --git a/src/remove_ai_watermarks/_internal/watermark_profiles.py b/src/remove_ai_watermarks/_internal/watermark_profiles.py index 8ac3f24..ced22e1 100644 --- a/src/remove_ai_watermarks/_internal/watermark_profiles.py +++ b/src/remove_ai_watermarks/_internal/watermark_profiles.py @@ -40,6 +40,12 @@ REMOVAL_MODULES = ("torch", "diffusers", "diffsynth") # ``pip install remove-ai-watermarks[qwen-zimage]`` dies with "no matches found" # before pip ever runs -- another install hint that does not install. INVISIBLE_EXTRA = "'remove-ai-watermarks[qwen-zimage]'" +# The pixel stack every visible-mark command needs. The default package ships without +# it -- Homebrew installs exactly that build -- so ``visible``, ``erase``, ``all`` and +# ``batch`` used to die on a bare ``ModuleNotFoundError: No module named 'cv2'`` +# traceback on a first run that followed the project's own install instructions. +PIXELS_MODULES = ("cv2", "numpy") +VISIBLE_EXTRA = "'remove-ai-watermarks[visible]'" # qwen-zimage's output already matches the input's detail level, so polishing it is a # no-op at best. sdxl-zimage's global pass leaves the softer output the polish exists diff --git a/src/remove_ai_watermarks/api.py b/src/remove_ai_watermarks/api.py index 614adef..7dd5238 100644 --- a/src/remove_ai_watermarks/api.py +++ b/src/remove_ai_watermarks/api.py @@ -659,6 +659,7 @@ def remove_batch( caller driving a progress bar can advance on that alone. """ from remove_ai_watermarks._internal.utils import is_supported_format + from remove_ai_watermarks.optional_deps import pixels_available src_dir, out_dir = Path(directory), Path(output_dir) out_dir.mkdir(parents=True, exist_ok=True) @@ -676,6 +677,12 @@ def remove_batch( try: result = _run_batch_one(img_path, out_path, mode, backend, sensitivity, invisible, force, engine, say) except Exception as exc: + # A missing pixel stack is a property of the ENVIRONMENT, not of this + # file, and per-file handling turned one absent package into an error + # per image -- 84 of them on a directory that needed one install. Let + # it out so the caller reports it once, with the extra named. + if isinstance(exc, ImportError) and not pixels_available(): + raise failed += 1 message = str(exc) errors.append((img_path, message)) diff --git a/src/remove_ai_watermarks/cli.py b/src/remove_ai_watermarks/cli.py index eb2cf1b..a169ed0 100644 --- a/src/remove_ai_watermarks/cli.py +++ b/src/remove_ai_watermarks/cli.py @@ -11,6 +11,7 @@ Provides commands for: from __future__ import annotations import contextlib +import functools import json import logging import time @@ -26,6 +27,7 @@ from remove_ai_watermarks._internal.watermark_profiles import ( DEFAULT_PROFILE, INVISIBLE_EXTRA, PROFILE_CHOICES, + VISIBLE_EXTRA, resolve_strength, strength_default_help, vendor_for_strength, @@ -39,7 +41,7 @@ from remove_ai_watermarks.video_synthid import ( ) if TYPE_CHECKING: - from collections.abc import Generator + from collections.abc import Callable, Generator from numpy.typing import NDArray @@ -414,6 +416,41 @@ def _parse_sensitivity(value: str) -> watermark_registry.Sensitivity: EXIT_NO_VISIBLE_MARK = 2 +def _pixels_required(func: Callable[..., None]) -> Callable[..., None]: + """Report a missing pixel stack as an install hint instead of a traceback. + + The default package installs WITHOUT ``pixels``, and the Homebrew formula ships + exactly that build, so a first run that followed this project's own install + instructions died mid-command on a bare ``ModuleNotFoundError: No module named + 'cv2'`` -- the one moment the user needed the extra named, and the only moment + they were not told it. ``identify`` and ``metadata`` still answer without the + stack, so the guard goes on the commands that cannot, not on the group. The video + commands are NOT in that set on purpose: ``video._require_video_runtime`` already + stops them and names the ``video`` extra, which is the one that actually makes + them work -- wrapping them here would answer with ``visible`` instead. + + Applied innermost, below the click decorators, so the wrapper is what carries + the options and what click invokes. + """ + + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> None: + try: + func(*args, **kwargs) + except ImportError as exc: + from remove_ai_watermarks.optional_deps import pixels_available + + if pixels_available(): + raise + console.print( + "Error: the visible-mark dependencies are not installed.\n" + f" Install them with: pip install {VISIBLE_EXTRA}" + ) + raise SystemExit(1) from exc + + return wrapper + + def _write_output_or_exit(output: Path, bgr: NDArray[Any], alpha: NDArray[Any] | None) -> None: """Write the final image, or fail with a readable error instead of a traceback. @@ -699,6 +736,7 @@ def _run_visible_explicit( @_visible_sensitivity_option @click.option("--strip-metadata/--keep-metadata", default=True, help="Strip AI metadata from output.") @click.pass_context +@_pixels_required def cmd_visible( ctx: click.Context, source: Path, @@ -780,6 +818,7 @@ def _parse_region(spec: str) -> tuple[int, int, int, int]: @click.option("--dilate", type=int, default=3, help="Grow the box by this many px before inpainting.") @click.option("--strip-metadata/--keep-metadata", default=True, help="Strip AI metadata from output.") @click.pass_context +@_pixels_required def cmd_erase( ctx: click.Context, source: Path, @@ -1608,6 +1647,7 @@ def cmd_classify(source: Path, as_json: bool) -> None: @_text_manifest_option @_fidelity_anchor_option @click.pass_context +@_pixels_required def cmd_all( ctx: click.Context, source: Path, @@ -1805,6 +1845,7 @@ def _batch_engine(mode: str, options: InvisibleOptions) -> object | None: @_force_option @_cpu_offload_option @click.pass_context +@_pixels_required def cmd_batch( ctx: click.Context, directory: Path, diff --git a/src/remove_ai_watermarks/identify.py b/src/remove_ai_watermarks/identify.py index ac3d5c9..a182669 100644 --- a/src/remove_ai_watermarks/identify.py +++ b/src/remove_ai_watermarks/identify.py @@ -157,6 +157,16 @@ _C2PA_CLOUD_CAVEAT = ( "It marks Content Credentials, not AI origin: the cloud manifest may describe a " "human edit, and reading it needs a network fetch this tool does not make." ) +_PIXEL_DETECTORS_MISSING_CAVEAT = ( + "The visible-mark detectors did NOT run: this install has no pixel dependencies, so " + "a visible AI label may be present and unreported. Install " + "'remove-ai-watermarks[visible]' and rerun before reading this scan as complete." +) +_PIXEL_DETECTORS_UNDECODABLE_CAVEAT = ( + "The visible-mark detectors did NOT run: the pixels could not be decoded, so a " + "visible AI label may be present and unreported. This says nothing about the image " + "itself -- only that this scan could not look at it." +) _SOFT_BINDING_CAVEAT = ( "Removing the embedded C2PA manifest does not break its soft binding: a named watermark may remain in the pixels " "or other media, while a content fingerprint may still be recomputed and re-link the asset to provenance." @@ -1085,6 +1095,11 @@ class _SharedDecode: return None return self._image + @property + def error(self) -> Exception | None: + """The decode failure, so a caller can tell an absent extra from a bad file.""" + return self._error + def _collect_visible_signals( image_path: Path, @@ -1092,6 +1107,7 @@ def _collect_visible_signals( watermarks: list[str], platform: str | None, decode: _SharedDecode, + caveats: list[str], ) -> str | None: """Append every trusted visible-mark signal and return platform. @@ -1101,6 +1117,15 @@ def _collect_visible_signals( """ image = decode.get_or_none() if image is None: + # The no-op is deliberate and the verdict is unchanged, but the REPORT used to + # stay silent, so a scan that could not look reads exactly like a scan that + # looked and found nothing. An absent extra and an unreadable file both land + # here and need different fixes, so the caveat names which one happened rather + # than sending the user to reinstall a working install. + from remove_ai_watermarks.optional_deps import pixels_available + + missing_stack = isinstance(decode.error, ImportError) and not pixels_available() + caveats.append(_PIXEL_DETECTORS_MISSING_CAVEAT if missing_stack else _PIXEL_DETECTORS_UNDECODABLE_CAVEAT) return platform sparkle_conf = _visible_sparkle(image_path, image=image) @@ -1518,7 +1543,7 @@ def _identify_from_evidence( ) if check_visible and pixel_path is not None: - platform = _collect_visible_signals(pixel_path, signals, watermarks, platform, decode) + platform = _collect_visible_signals(pixel_path, signals, watermarks, platform, decode, caveats) visible_only = any(s.name.startswith("visible_") for s in signals) and not ai_from_metadata hf_only = bool(hf_job) and not ai_from_metadata diff --git a/src/remove_ai_watermarks/metadata.py b/src/remove_ai_watermarks/metadata.py index 8d657bb..6fe187e 100644 --- a/src/remove_ai_watermarks/metadata.py +++ b/src/remove_ai_watermarks/metadata.py @@ -1420,9 +1420,17 @@ def strip_and_verify( if not remaining: return out, {} - import cv2 + try: + import cv2 - from remove_ai_watermarks import image_io + from remove_ai_watermarks import image_io + except ImportError: + # The raster normalization is a recovery step, not the strip itself, and the + # default package ships without the pixel stack. Reporting the survivors is the + # documented outcome of "could not normalize"; crashing here made a metadata-only + # install fail on exactly the files this branch exists to rescue. + logger.warning("AI metadata survived stripping and the pixel stack is absent: path=%s", out) + return out, remaining image = image_io.imread(out, cv2.IMREAD_UNCHANGED) if image is None: diff --git a/src/remove_ai_watermarks/optional_deps.py b/src/remove_ai_watermarks/optional_deps.py index 3071a65..d2b5cc9 100644 --- a/src/remove_ai_watermarks/optional_deps.py +++ b/src/remove_ai_watermarks/optional_deps.py @@ -26,3 +26,20 @@ def module_available(*names: str) -> bool: if spec is None or spec.loader is None: return False return True + + +def pixels_available() -> bool: + """True when this build can decode and inpaint pixels. + + The default package ships without the ``visible`` extra -- the Homebrew formula + installs exactly that build -- so every visible-mark command and the visible arm + of ``identify`` has to ask this. It is one predicate rather than three, because + the three places that needed it each grew their own ``exc.name in (...)`` test + and all three then agreed only by coincidence: an ``ImportError`` raised INSIDE + ``cv2/__init__.py`` carries ``name == "cv2.cv2"`` and a numpy ABI mismatch + carries ``name is None``, so a name match answered "that is some other import + error" and let the traceback through. + """ + from remove_ai_watermarks._internal.watermark_profiles import PIXELS_MODULES + + return module_available(*PIXELS_MODULES) diff --git a/src/remove_ai_watermarks/video.py b/src/remove_ai_watermarks/video.py index 3266c5e..4d65586 100644 --- a/src/remove_ai_watermarks/video.py +++ b/src/remove_ai_watermarks/video.py @@ -35,11 +35,16 @@ _EBML_MAGIC = b"\x1aE\xdf\xa3" def _require_video_runtime() -> None: - """Raise with the public install command when a video runtime is absent.""" + """Raise with the public install command when a video runtime is absent. + + Shell-quoted, for the same reason as the other install hints: a bare + ``pkg[extra]`` is a glob in zsh, so an unquoted copy-paste dies with + "no matches found" before pip ever runs. + """ from remove_ai_watermarks.optional_deps import module_available if not module_available("cv2", "numpy", "av"): - raise RuntimeError("Video pixel processing requires remove-ai-watermarks[video]") + raise RuntimeError("Video pixel processing requires 'remove-ai-watermarks[video]'") @dataclass(frozen=True)