mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-10-14 21:40:47 +02:00
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:
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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user