mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-09 23:50:40 +02:00
The CLI still advertised --model, --steps, --guidance-scale, --device and a deprecated --auto. Each pinned a value the two surviving profiles fix -- the model stack, the per-stage distilled schedule, CFG 1.0, CUDA -- so the only outcome any of them had was an error raised several frames below the caller, under a message naming an internal profile. A flag whose sole result is a refusal is worse than no flag: it advertises a capability that does not exist, and it lets a wrapper thread a value that will silently do nothing. They are gone from the parser, from InvisibleEngine, and from WatermarkRemover, so the failure is now a TypeError or a Click "No such option" at the point the caller can act on. The install hint was wrong in the same way. is_available() checked torch and diffusers, then told the user to install [diffusion] -- which contains neither DiffSynth nor the Z-Image face stage both profiles run. Following the advice produced a second, different failure. The module list and the extra name now live once in watermark_profiles (REMOVAL_MODULES, INVISIBLE_EXTRA) and are read by both the CLI gate and the remover's precondition, which cannot drift apart because they are the same tuple. The adaptive-polish default moved out of the argument parser. It was resolved by reading Click's parameter source, which put per-profile data in the CLI layer, left the engine declaring the opposite default (False vs True) so a library caller and a CLI caller on one profile got different output, and lost the polish entirely for anything that supplies the flag non-interactively. The flag is now tri-state (default=None) and resolve_adaptive_polish owns the per-profile answer. The seed follows the same rule: the CLI stopped pre-resolving it. Dead code removed with it: six scan_*_video wrappers and the _scan_video helper none of them had a caller for, PNG_METADATA_KEYS, feather_region_composite and the remover region path that was only reachable from a no-caller convenience wrapper, remove_watermark_batch on both layers, try_empty_device_cache, the _generate/_run_qwen_zimage pass-through pair, self.model_id, and the _internal PEP 562 shim that no caller ever went through. get_device now answers cuda or cpu only: mps and xpu travelled one frame to the same CUDA-only refusal while costing a device probe each, and that refusal now names the resolved device, so device=None on a CUDA-less host says 'cpu' rather than 'None'. The XPU wheel index went with them. Docs: README, cli, installation, python-api, supported-signals, known-limitations and module-internals all still described the removed profiles, the CPU/MPS/XPU ladder, a `default`->`sdxl` alias, and the wrong extra. known-limitations still listed the retired SDXL strength ladder as current. scripts/smoke_matrix.py and real_examples_e2e.py drove --device mps. Next release is 0.25.0, not a patch: this removes public parameters and narrows a published extra on top of the released 0.24.0. pre-commit: 1) maintain.sh - exit 0 (1091 tests, Pyright 0 errors, no vulnerabilities); 2) /simplify - 4 agents, 11 findings applied, 2 skipped (dropping the `device` parameter entirely, which raiw-app pins; folding diffsynth into the `diffusion` extra, which video-only callers do not need); 3) docs sync - grepped every removed identifier across README, docs/, scripts/, .claude/; updated 9 docs; 4) CLAUDE.md - added the no-error-only-knobs rule to .claude/rules/development.md Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
217 lines
8.7 KiB
Python
217 lines
8.7 KiB
Python
"""Project-native orchestration for diffusion-based pixel regeneration."""
|
|
|
|
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownParameterType=false, reportMissingTypeArgument=false, reportMissingTypeStubs=false, reportMissingImports=false, reportArgumentType=false, reportAssignmentType=false, reportReturnType=false, reportCallIssue=false, reportIndexIssue=false, reportOperatorIssue=false, reportOptionalMemberAccess=false, reportOptionalCall=false, reportOptionalSubscript=false, reportOptionalOperand=false, reportAttributeAccessIssue=false, reportPrivateImportUsage=false, reportPrivateUsage=false, reportInvalidTypeForm=false, reportConstantRedefinition=false, reportUnnecessaryComparison=false
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import subprocess
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from PIL import Image
|
|
|
|
from remove_ai_watermarks._internal.watermark_profiles import (
|
|
DEFAULT_PROFILE,
|
|
INVISIBLE_EXTRA,
|
|
PROFILE_CHOICES,
|
|
REMOVAL_MODULES,
|
|
SDXL_ZIMAGE_PROFILE,
|
|
normalize_profile,
|
|
resolve_seed,
|
|
resolve_strength,
|
|
)
|
|
from remove_ai_watermarks.optional_deps import module_available
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
from pathlib import Path
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
try:
|
|
import torch
|
|
|
|
_HAS_TORCH = True
|
|
except ImportError:
|
|
torch = None # type: ignore[assignment]
|
|
_HAS_TORCH = False
|
|
|
|
# Probed once at import. ``torch`` is imported above rather than probed because this
|
|
# module needs the object, not just the answer.
|
|
_HAS_REMOVAL_MODULES = module_available(*(name for name in REMOVAL_MODULES if name != "torch"))
|
|
|
|
|
|
def is_watermark_removal_available() -> bool:
|
|
"""Return whether the full removal runtime can be imported."""
|
|
return _HAS_TORCH and _HAS_REMOVAL_MODULES
|
|
|
|
|
|
def _ensure_watermark_deps() -> None:
|
|
if not is_watermark_removal_available():
|
|
raise ImportError(
|
|
f"Invisible watermark regeneration requires the 'qwen-zimage' extra: pip install {INVISIBLE_EXTRA}."
|
|
)
|
|
|
|
|
|
def _has_nvidia_gpu() -> bool:
|
|
try:
|
|
subprocess.run(
|
|
["nvidia-smi"],
|
|
check=True,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
except (FileNotFoundError, subprocess.CalledProcessError):
|
|
return False
|
|
return True
|
|
|
|
|
|
def _cuda_works() -> bool:
|
|
try:
|
|
probe = torch.tensor([1.0], device="cuda") # type: ignore[union-attr]
|
|
_ = probe + probe
|
|
except (AssertionError, RuntimeError):
|
|
return False
|
|
return True
|
|
|
|
|
|
def get_device() -> str:
|
|
"""Return ``"cuda"`` when a usable CUDA backend is present, else ``"cpu"``.
|
|
|
|
Deliberately binary. Both profiles are CUDA-only, so an XPU or MPS answer would
|
|
only travel one frame further to the same refusal in :class:`WatermarkRemover`,
|
|
while costing a probe on each. ``"cpu"`` here means "no CUDA", which is exactly
|
|
what that refusal reports.
|
|
"""
|
|
if not _HAS_TORCH:
|
|
return "cpu"
|
|
if torch.cuda.is_available() and _cuda_works(): # type: ignore[union-attr]
|
|
return "cuda"
|
|
if _has_nvidia_gpu():
|
|
logger.warning("NVIDIA GPU detected, but the installed PyTorch build has no working CUDA backend")
|
|
return "cpu"
|
|
|
|
|
|
class WatermarkRemover:
|
|
"""Load one regeneration profile and write a metadata-clean raster output."""
|
|
|
|
def __init__(
|
|
self,
|
|
device: str | None = None,
|
|
progress_callback: Callable[[str], None] | None = None,
|
|
hf_token: str | None = None,
|
|
pipeline: str = DEFAULT_PROFILE,
|
|
controlnet_conditioning_scale: float = 1.0,
|
|
cpu_offload: bool = False,
|
|
) -> None:
|
|
self.model_profile = normalize_profile(pipeline)
|
|
if self.model_profile not in PROFILE_CHOICES:
|
|
raise ValueError(f"Unsupported pipeline '{pipeline}'. Use one of: {', '.join(PROFILE_CHOICES)}.")
|
|
# There is no ``model_id`` parameter and no ``model_id`` attribute: each
|
|
# profile pins a fixed model stack, and the dtype below is bound to that
|
|
# stack's weights. Both used to be constructor overrides that existed only to
|
|
# be rejected or to break the run, and the attribute only existed to echo the
|
|
# rejected value back.
|
|
_ensure_watermark_deps()
|
|
selected_device = (device or get_device()).casefold()
|
|
self.device = get_device() if selected_device == "auto" else selected_device
|
|
# CUDA is a precondition of the object, not of the run. Both profiles raise on
|
|
# any other device, so accepting one here only defers a guaranteed failure to
|
|
# model-load time, several layers down and under the wrong profile's name.
|
|
if self.device != "cuda":
|
|
raise ValueError(
|
|
f"Invisible-watermark removal is CUDA-only, so '{self.device}' cannot run it. "
|
|
"Both remaining profiles need an NVIDIA GPU. Visible-mark removal and "
|
|
"every identify command still run on CPU."
|
|
)
|
|
|
|
if self.model_profile == SDXL_ZIMAGE_PROFILE:
|
|
# SDXL ships fp16 weights and an fp16-safe VAE; bf16 would give up the
|
|
# variant without buying anything on this architecture.
|
|
self.torch_dtype = torch.float16 # type: ignore[union-attr]
|
|
else:
|
|
self.torch_dtype = torch.bfloat16 # type: ignore[union-attr]
|
|
|
|
self.cpu_offload = cpu_offload
|
|
self.controlnet_conditioning_scale = controlnet_conditioning_scale
|
|
self.hf_token = hf_token or os.environ.get("HF_TOKEN")
|
|
self._progress_callback = progress_callback
|
|
self._qwen_zimage_pipeline: Any = None
|
|
|
|
def preload(self, *, global_only: bool = False) -> None:
|
|
"""Materialize the selected model stack before the first request."""
|
|
self._load_qwen_zimage_pipeline().preload(global_only=global_only)
|
|
|
|
def _load_qwen_zimage_pipeline(self) -> Any:
|
|
if self._qwen_zimage_pipeline is None:
|
|
if self.model_profile == SDXL_ZIMAGE_PROFILE:
|
|
from remove_ai_watermarks._internal.sdxl_zimage_pipeline import (
|
|
SdxlZImagePipeline as _Pipeline,
|
|
)
|
|
else:
|
|
from remove_ai_watermarks._internal.qwen_zimage_pipeline import (
|
|
QwenZImagePipeline as _Pipeline,
|
|
)
|
|
|
|
self._qwen_zimage_pipeline = _Pipeline(
|
|
device=self.device,
|
|
torch_dtype=self.torch_dtype,
|
|
hf_token=self.hf_token,
|
|
progress_callback=self._progress_callback,
|
|
controlnet_conditioning_scale=self.controlnet_conditioning_scale,
|
|
keep_face_models_on_device=False if self.cpu_offload else None,
|
|
keep_global_models_on_device=False if self.cpu_offload else None,
|
|
)
|
|
return self._qwen_zimage_pipeline
|
|
|
|
def _write_output(self, image: Image.Image, output_path: Path) -> None:
|
|
import numpy as np
|
|
|
|
from remove_ai_watermarks import image_io
|
|
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
bgr = np.ascontiguousarray(np.asarray(image.convert("RGB"))[:, :, ::-1])
|
|
if not image_io.imwrite(str(output_path), bgr):
|
|
image.save(output_path)
|
|
from remove_ai_watermarks.metadata import remove_ai_metadata
|
|
|
|
remove_ai_metadata(output_path, output_path, keep_standard=True)
|
|
|
|
def remove_watermark(
|
|
self,
|
|
image_path: Path,
|
|
output_path: Path | None = None,
|
|
strength: float | None = None,
|
|
seed: int | None = None,
|
|
vendor: str | None = None,
|
|
tile: bool = False,
|
|
tile_size: int = 1024,
|
|
tile_overlap: int = 128,
|
|
) -> Path:
|
|
"""Regenerate image pixels and write the result without AI metadata.
|
|
|
|
Step count and CFG are not parameters. Each stage of both profiles is a
|
|
distilled schedule that owns its own, so the only thing a caller-supplied
|
|
value could do is break the run or be rejected.
|
|
"""
|
|
if not image_path.exists():
|
|
raise FileNotFoundError(f"Image not found: {image_path}")
|
|
destination = output_path or image_path
|
|
with Image.open(image_path) as opened:
|
|
source = opened.convert("RGB")
|
|
|
|
resolved_strength = resolve_strength(strength, vendor, self.model_profile, size=source.size)
|
|
if not 0.0 <= resolved_strength <= 1.0:
|
|
raise ValueError(f"Strength must be between 0.0 and 1.0, got {resolved_strength}")
|
|
|
|
result = self._load_qwen_zimage_pipeline().run(
|
|
source,
|
|
strength=resolved_strength,
|
|
seed=resolve_seed(seed),
|
|
tile=tile,
|
|
tile_size=tile_size,
|
|
tile_overlap=tile_overlap,
|
|
)
|
|
self._write_output(result, destination)
|
|
return destination
|