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 <noreply@anthropic.com>
This commit is contained in:
Victor KuznetsovandClaude Opus 5 committed 2026-09-05 11:27:16 -07:00
1 parent 1e71f0523d
commit 637395b8a6
7 files changed
+115 -6

No files matched your search

@@ -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
+7
View File
@@ -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))
+42 -1
View File
@@ -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,
+26 -1
View File
@@ -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
+10 -2
View File
@@ -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:
+17
View File
@@ -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)
+7 -2
View File
@@ -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)