mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-19 12:07:13 +02:00
1713 lines
64 KiB
Python
1713 lines
64 KiB
Python
"""Unified CLI for remove-ai-watermarks.
|
|
|
|
Provides commands for:
|
|
- Visible watermark removal (Gemini sparkle) - works offline, fast
|
|
- Invisible watermark removal (SynthID etc.) - requires GPU/diffusion models
|
|
- AI metadata stripping - lightweight, no ML deps needed
|
|
- Video identification, visible-wordmark removal, and metadata stripping
|
|
- Oracle-certified video SynthID removal
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import json
|
|
import logging
|
|
import time
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Any, Literal, NoReturn
|
|
|
|
import click
|
|
|
|
from remove_ai_watermarks import __version__, image_io, watermark_registry
|
|
from remove_ai_watermarks._internal.constants import SUPPORTED_FORMATS
|
|
from remove_ai_watermarks._internal.utils import is_supported_format
|
|
from remove_ai_watermarks._internal.watermark_profiles import (
|
|
DEFAULT_PROFILE,
|
|
INVISIBLE_EXTRA,
|
|
PROFILE_CHOICES,
|
|
resolve_strength,
|
|
strength_default_help,
|
|
vendor_for_strength,
|
|
)
|
|
from remove_ai_watermarks.video import VIDEO_VISIBLE_MARKS
|
|
from remove_ai_watermarks.video_synthid import (
|
|
DEFAULT_VIDEO_SYNTHID_FPS,
|
|
DEFAULT_VIDEO_SYNTHID_LONG_SIDE,
|
|
DEFAULT_VIDEO_SYNTHID_NOISE_STD,
|
|
VIDEO_SYNTHID_LATENT_MULTIPLE,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Generator
|
|
|
|
from numpy.typing import NDArray
|
|
|
|
from remove_ai_watermarks.api import InvisibleOptions
|
|
|
|
|
|
# ── plain-text output layer (replaces rich: no colors, no markup, no boxes) ──
|
|
|
|
|
|
class _Table:
|
|
"""Plain-text stand-in for rich.Table."""
|
|
|
|
def __init__(self, *args: Any, title: str | None = None, **kwargs: Any) -> None:
|
|
self._title = title
|
|
self._headers: list[str] = []
|
|
self._rows: list[list[str]] = []
|
|
|
|
def add_column(self, header: str = "", *args: Any, **kwargs: Any) -> None:
|
|
self._headers.append(str(header))
|
|
|
|
def add_row(self, *cells: Any) -> None:
|
|
self._rows.append([str(c) for c in cells])
|
|
|
|
def render(self) -> str:
|
|
lines: list[str] = []
|
|
if self._title:
|
|
lines.append(self._title)
|
|
if any(self._headers):
|
|
lines.append(" ".join(self._headers))
|
|
lines.extend(" ".join(row) for row in self._rows)
|
|
return "\n".join(f" {line}" for line in lines)
|
|
|
|
|
|
class _Progress:
|
|
"""No-op stand-in for rich.Progress; results are printed directly instead."""
|
|
|
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
pass
|
|
|
|
def __enter__(self) -> _Progress:
|
|
return self
|
|
|
|
def __exit__(self, *exc: object) -> Literal[False]:
|
|
# Literal[False], not bool: a plain `bool` tells a type checker this context
|
|
# manager MAY suppress an exception, which makes every name bound inside a
|
|
# `with` block conditionally bound afterwards. It never suppresses.
|
|
return False
|
|
|
|
def add_task(self, *args: Any, **kwargs: Any) -> int:
|
|
return 0
|
|
|
|
def advance(self, *args: Any, **kwargs: Any) -> None:
|
|
pass
|
|
|
|
def update(self, *args: Any, **kwargs: Any) -> None:
|
|
pass
|
|
|
|
|
|
class _Console:
|
|
"""Minimal plain-text replacement for rich.Console."""
|
|
|
|
def print(self, *objects: Any, **kwargs: Any) -> None:
|
|
click.echo(" ".join(o.render() if isinstance(o, _Table) else str(o) for o in objects))
|
|
|
|
@contextlib.contextmanager
|
|
def status(self, message: str = "", **kwargs: Any) -> Generator[None, None, None]:
|
|
if message:
|
|
click.echo(message)
|
|
yield
|
|
|
|
|
|
def _panel(text: str = "", *args: Any, **kwargs: Any) -> str:
|
|
return text
|
|
|
|
|
|
def _column(*args: Any, **kwargs: Any) -> None:
|
|
return None
|
|
|
|
|
|
Panel = _panel
|
|
Table = _Table
|
|
Progress = _Progress
|
|
SpinnerColumn = BarColumn = TextColumn = TimeElapsedColumn = _column
|
|
console = _Console()
|
|
|
|
|
|
def _setup_logging(verbose: bool) -> None:
|
|
level = logging.DEBUG if verbose else logging.WARNING
|
|
logging.basicConfig(
|
|
level=level,
|
|
format="%(name)s | %(message)s",
|
|
handlers=[logging.StreamHandler()],
|
|
)
|
|
|
|
|
|
def _banner() -> None:
|
|
console.print(
|
|
Panel(
|
|
f"Remove-AI-Watermarks v{__version__}\nVisible & invisible watermark removal",
|
|
border_style="cyan",
|
|
padding=(0, 2),
|
|
)
|
|
)
|
|
|
|
|
|
def _validate_image(path: Path) -> Path:
|
|
if not path.exists():
|
|
console.print(f"Error: File not found: {path}")
|
|
raise SystemExit(1)
|
|
if not is_supported_format(path):
|
|
console.print(f"Warning: {path.suffix} may not be supported (expected: {', '.join(SUPPORTED_FORMATS)})")
|
|
return path
|
|
|
|
|
|
def _resolved_strength_for_display(
|
|
source: Path,
|
|
strength: float | None,
|
|
vendor: str | None,
|
|
pipeline: str,
|
|
) -> float:
|
|
"""Resolve the same profile-specific strength the engine will execute.
|
|
|
|
One call for both profiles, so the printed value cannot drift from the executed
|
|
one; the size is what qwen-zimage derives its strength from.
|
|
"""
|
|
from PIL import Image
|
|
|
|
with Image.open(source) as image:
|
|
return resolve_strength(strength, vendor, pipeline, size=image.size)
|
|
|
|
|
|
# -o/--output is the most-repeated option in this module. The image commands and the
|
|
# video commands differ only in the default they describe, so there are two decorators
|
|
# rather than one -- same reason as every other shared option here: define it once so
|
|
# the help text cannot drift between commands.
|
|
_output_option = click.option(
|
|
"-o", "--output", type=click.Path(path_type=Path), default=None, help="Output path (default: <source>_clean.<ext>)."
|
|
)
|
|
|
|
_video_output_option = click.option(
|
|
"-o",
|
|
"--output",
|
|
type=click.Path(path_type=Path),
|
|
default=None,
|
|
help="Output path (default: <source>_clean with the same container).",
|
|
)
|
|
|
|
|
|
# Shared option decorator for commands that run the invisible-watermark pipeline.
|
|
# Both cmd_invisible and cmd_all expose this flag; defining it once avoids
|
|
# copy-paste drift.
|
|
_controlnet_scale_option = click.option(
|
|
"--controlnet-scale",
|
|
type=float,
|
|
default=1.0,
|
|
help="Canny ControlNet conditioning scale on the global stage "
|
|
"(structure/text preservation strength). Higher = closer to original structure.",
|
|
)
|
|
|
|
_unsharp_option = click.option(
|
|
"--unsharp", type=float, default=0.0, help="Unsharp-mask sharpening strength (0 = off, typical: 0.3-0.8)."
|
|
)
|
|
|
|
_adaptive_polish_option = click.option(
|
|
"--adaptive-polish/--no-adaptive-polish",
|
|
default=None,
|
|
help="Restore the input's detail level after removal (capped unsharp + edge-masked grain "
|
|
"targeting the input's sharpness, sparing text), countering the over-smoothed look. "
|
|
"Unset follows the profile: ON for sdxl-zimage, OFF for qwen-zimage, whose "
|
|
"upstream-matching output is left unchanged. It self-limits where there is no detail "
|
|
"deficit (text/flat graphics). Independent of --unsharp/--humanize.",
|
|
)
|
|
|
|
|
|
# Tiled-diffusion knobs, shared by the diffusion commands (invisible/all/batch).
|
|
# Tiling avoids an explicit resolution cap for large inputs that OOM on MPS/GPU:
|
|
# it regenerates overlapping tiles at the input's native dimensions.
|
|
def _tile_options(f: Any) -> Any:
|
|
"""Apply the --tile / --tile-size / --tile-overlap options to a command."""
|
|
f = click.option(
|
|
"--tile-overlap",
|
|
type=int,
|
|
default=128,
|
|
help="Overlap between adjacent tiles in px (feather-blended, no seam). Default 128.",
|
|
)(f)
|
|
f = click.option(
|
|
"--tile-size",
|
|
type=int,
|
|
default=1024,
|
|
help="Tile dimension in px for --tile. Default 1024.",
|
|
)(f)
|
|
return click.option(
|
|
"--tile/--no-tile",
|
|
default=False,
|
|
help="Process large images in overlapping tiles instead of one forward pass. This keeps "
|
|
"the input's native dimensions instead of applying --max-resolution, but still regenerates "
|
|
"every tile. Engages only when the long side exceeds --tile-size. Default off.",
|
|
)(f)
|
|
|
|
|
|
# There is deliberately no --model, --steps, --guidance-scale or --device option.
|
|
# Each profile pins a fixed model stack, a distilled per-stage schedule, CFG 1.0 and
|
|
# CUDA; every one of those knobs existed only so the library could reject it several
|
|
# layers down. A flag whose sole outcome is an error is worse than no flag at all --
|
|
# it advertises a capability that does not exist.
|
|
|
|
# The two-stage profiles are the only ones left. The former controlnet, sdxl, qwen and
|
|
# default profiles were removed rather than kept as a CPU path: none matched this
|
|
# recipe's face preservation, so offering them implied a quality the library no longer
|
|
# delivers. BOTH remaining profiles are CUDA-only.
|
|
_PIPELINE_CHOICES = list(PROFILE_CHOICES)
|
|
_PIPELINE_HELP = (
|
|
"Pipeline profile. qwen-zimage (DEFAULT) = Qwen-Image-2512 + Lightning + Canny, "
|
|
"followed by SAM-masked Z-Image face repair; sdxl-zimage = the same recipe and the "
|
|
"same face stage on an SDXL global pass, which needs more denoise. Both are "
|
|
"CUDA-ONLY -- install the qwen-zimage extra. There is no CPU or MPS profile for "
|
|
"invisible-watermark removal."
|
|
)
|
|
|
|
# Shared --pipeline / --strength decorators so the three diffusion commands
|
|
# (invisible/all/batch) keep an identical surface and the strength help can never
|
|
# drift from the watermark_profiles constants (strength_default_help derives it).
|
|
_pipeline_option = click.option(
|
|
"--pipeline",
|
|
type=click.Choice(_PIPELINE_CHOICES),
|
|
default=DEFAULT_PROFILE,
|
|
help=_PIPELINE_HELP,
|
|
)
|
|
_strength_option = click.option(
|
|
"--strength",
|
|
type=float,
|
|
default=None,
|
|
help=f"Denoising strength (0.0-1.0). Default: {strength_default_help()}.",
|
|
)
|
|
_seed_option = click.option(
|
|
"--seed",
|
|
type=int,
|
|
default=None,
|
|
help="Random seed for reproducibility. Default 0: both profiles are certified "
|
|
"at a fixed seed, because SynthID removal near the strength floor is seed-dependent.",
|
|
)
|
|
_hf_token_option = click.option("--hf-token", type=str, default=None, help="HuggingFace API token.")
|
|
_humanize_option = click.option(
|
|
"--humanize", type=float, default=0.0, help="Analog Humanizer film grain intensity (0 = off, typical: 2.0-6.0)."
|
|
)
|
|
_max_resolution_option = click.option(
|
|
"--max-resolution",
|
|
type=int,
|
|
default=0,
|
|
help="Cap long side (px) before diffusion; 0 = native and preserves the most detail. Raise only on GPU OOM.",
|
|
)
|
|
_force_option = click.option(
|
|
"--force/--no-force",
|
|
default=False,
|
|
help=(
|
|
"Run the diffusion scrub even when no invisible AI watermark is locally "
|
|
"detectable. Default: skip it (regeneration only degrades a clean image; a "
|
|
"skip never claims the image is watermark-free -- a pixel SynthID is "
|
|
"undetectable once its metadata proxy is gone)."
|
|
),
|
|
)
|
|
_cpu_offload_option = click.option(
|
|
"--cpu-offload/--no-cpu-offload",
|
|
default=False,
|
|
help=(
|
|
"Offload model components to CPU between CUDA calls instead of keeping the "
|
|
"whole pipeline in VRAM, at the cost of speed. For qwen-zimage, forces the "
|
|
"face stack to offload instead of using automatic residency."
|
|
),
|
|
)
|
|
|
|
|
|
_visible_backend_option = click.option(
|
|
"--backend",
|
|
"backend",
|
|
type=click.Choice(["auto", "cv2", "migan", "lama"]),
|
|
default="auto",
|
|
help="Fill backend for visible-mark removal (localize -> fill). auto: best available, "
|
|
"LaMa > MI-GAN > cv2 (a learned backend needs the 'lama' or 'migan' extra; else cv2, "
|
|
"with a warning). cv2: classical inpaint (no model download, smears texture). migan: MI-GAN ONNX "
|
|
"(light, ~1 GB, the memory-tight pick). lama: big-LaMa ONNX (best quality, ~4.7 GB).",
|
|
)
|
|
|
|
|
|
_visible_sensitivity_option = click.option(
|
|
"--sensitivity",
|
|
"sensitivity",
|
|
type=click.Choice(["auto", "strict"]),
|
|
default="auto",
|
|
help="How hard to trust a borderline mark. auto: relax a mark only when metadata "
|
|
"or a same-product sibling mark corroborates it (safe; clean images untouched). "
|
|
"strict: high-precision visual gate only, never relaxed. To act on a mark YOU can "
|
|
"see but the detector missed, use 'erase --region' or '--mark <name> --no-detect' "
|
|
"rather than a blanket relaxation.",
|
|
)
|
|
|
|
|
|
def _visible_provenance(path: Path | None) -> frozenset[str]:
|
|
"""Vendor keys local metadata confirms, the EVIDENCE that drives ``auto``
|
|
sensitivity. Thin wrapper over the public :func:`api.visible_provenance` (one
|
|
implementation for the CLI and the library), with a None-path guard."""
|
|
if path is None:
|
|
return frozenset()
|
|
from remove_ai_watermarks.api import visible_provenance
|
|
|
|
return visible_provenance(path)
|
|
|
|
|
|
def _parse_sensitivity(value: str) -> watermark_registry.Sensitivity:
|
|
"""Map the CLI ``--sensitivity`` choice to the registry literal.
|
|
|
|
A pass-through since ``assume-ai`` was removed (2026-07-19); kept as the single
|
|
conversion point so a future kebab-cased choice has an obvious home.
|
|
"""
|
|
return "strict" if value == "strict" else "auto"
|
|
|
|
|
|
# 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 _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.
|
|
|
|
`image_io.imwrite` is contractually NON-RAISING: it returns False when the codec
|
|
rejects the image or the path cannot be written. Every caller here follows its write
|
|
with `output.stat()` to report the size, so a silently-failed write (read-only
|
|
directory, full disk) died with a bare `FileNotFoundError` traceback pointing at the
|
|
stat, not at the write. Found by the Tier E adversarial sweep 2026-07-20.
|
|
Regression: `tests/test_cli_robustness.py::TestFailedWriteIsReported`.
|
|
"""
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
if not image_io.write_bgr_with_alpha(output, bgr, alpha):
|
|
console.print(f" Error: failed to write output (is the destination writable?): {output}")
|
|
raise SystemExit(1)
|
|
|
|
|
|
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. Most images carry no
|
|
registered mark and may instead have an invisible or metadata watermark.
|
|
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`.
|
|
|
|
When the user can SEE a mark the detector missed, the honest next step is one that
|
|
executes their instruction rather than guessing harder. This used to recommend
|
|
``--sensitivity assume-ai``, which did the opposite -- it relaxed every mark's gate
|
|
on a blanket assumption -- and that mode is gone (2026-07-19).
|
|
|
|
The advice is per-mark, because the forced paths are not equally reliable
|
|
(measured 2026-07-19):
|
|
* ``erase --region`` is always sound: the user supplies the coordinates, so there
|
|
is nothing to guess. This is the primary recommendation.
|
|
* ``--mark <text-mark> --no-detect`` is reasonable for the TEXT marks: the forced
|
|
mask is built from the actual glyph blob, non-empty on 13/13 real marks the
|
|
detector missed.
|
|
* ``--mark gemini --no-detect`` is NOT recommended and is deliberately not
|
|
suggested here: with no detection it falls back to a fixed default sparkle slot,
|
|
which covered the real sparkle on only **31% of 97** genuine sparkles the strict
|
|
gate missed (median offset 63px up-and-left). The other 69% fill a clean corner
|
|
AND report a removal that did not happen -- the worst outcome the tool has.
|
|
"""
|
|
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 visible mark and no readable AI provenance signal. This does not prove\n"
|
|
" the image is clean: an invisible pixel watermark such as SynthID cannot be\n"
|
|
" detected here once the metadata proxy is absent (it may have been stripped\n"
|
|
" earlier). If the image is AI-generated, regenerate the pixels with:\n"
|
|
f" remove-ai-watermarks all {source.name}\n"
|
|
" If instead there is a logo or object to remove, target it with the region eraser:\n"
|
|
f" remove-ai-watermarks erase {source.name} --region x,y,w,h"
|
|
)
|
|
console.print(
|
|
" If you can SEE a mark here that was not detected, point at it directly --\n"
|
|
" that removes what you actually see instead of guessing:\n"
|
|
f" remove-ai-watermarks erase {source.name} --region x,y,w,h\n"
|
|
" For a known CJK text mark you can also force it by name:\n"
|
|
f" remove-ai-watermarks visible {source.name} --mark doubao --no-detect"
|
|
)
|
|
raise SystemExit(EXIT_NO_VISIBLE_MARK)
|
|
|
|
|
|
# Same value as EXIT_NO_VISIBLE_MARK (2): a distinct-from-success / distinct-from-
|
|
# error code that tells a wrapping service "the diffusion scrub was skipped because
|
|
# no invisible watermark was locally detectable", so it can surface the message
|
|
# instead of treating an unchanged image as a completed removal.
|
|
EXIT_NO_INVISIBLE_SIGNAL = 2
|
|
|
|
|
|
def _no_invisible_signal_exit(source: Path) -> NoReturn:
|
|
"""Explain why the diffusion scrub was skipped, then exit non-zero.
|
|
|
|
The ``invisible`` command regenerates pixels to remove SynthID / open
|
|
watermarks; that regeneration also degrades a real photo. When
|
|
:func:`identify` finds no locally-detectable invisible AI signal, running it
|
|
anyway would damage a clean image for nothing -- the dominant paid score-0
|
|
cause on no-watermark uploads. So skip it, but do NOT imply the image is
|
|
clean: a pixel SynthID is undetectable here once its metadata proxy is gone.
|
|
Write no output and exit :data:`EXIT_NO_INVISIBLE_SIGNAL`; ``--force`` runs
|
|
the scrub regardless.
|
|
"""
|
|
console.print(
|
|
" No invisible AI watermark detected (no C2PA/SynthID provenance, no open\n"
|
|
" watermark). Skipped the diffusion scrub -- regenerating the pixels would\n"
|
|
" only degrade the image with nothing to remove, so no output was written.\n"
|
|
" This does NOT prove the image is clean: a pixel watermark such as SynthID\n"
|
|
" cannot be detected here once its metadata proxy is absent (it may have\n"
|
|
" been stripped earlier). If you know the image is AI-generated and want the\n"
|
|
" pixels regenerated regardless, re-run with --force:\n"
|
|
f" remove-ai-watermarks invisible {source.name} --force"
|
|
)
|
|
raise SystemExit(EXIT_NO_INVISIBLE_SIGNAL)
|
|
|
|
|
|
def _should_skip_invisible_scrub(force: bool, image_path: Path) -> bool:
|
|
"""True when the diffusion scrub should be skipped for *image_path*.
|
|
|
|
The shared no-signal gate for ``invisible`` / ``all`` / ``batch``: skip when
|
|
``--force`` is not set AND no invisible AI watermark is locally detectable
|
|
(regenerating pixels would only degrade a clean image -- the dominant paid
|
|
score-0 cause). Centralizes the condition + the lazy ``has_invisible_target``
|
|
import so the three call sites cannot drift. ``--force`` short-circuits the
|
|
detection entirely.
|
|
"""
|
|
if force:
|
|
return False
|
|
from remove_ai_watermarks.identify import has_invisible_target
|
|
|
|
return not has_invisible_target(image_path)
|
|
|
|
|
|
# ── Main group ──
|
|
@click.group(invoke_without_command=True)
|
|
@click.version_option(__version__, prog_name="remove-ai-watermarks")
|
|
@click.option("-v", "--verbose", is_flag=True, help="Enable verbose logging.")
|
|
@click.pass_context
|
|
def main(ctx: click.Context, verbose: bool) -> None:
|
|
"""Remove visible and invisible AI watermarks from images, plus provenance metadata from video."""
|
|
from dotenv import load_dotenv
|
|
|
|
load_dotenv() # Load .env (e.g. HF_TOKEN)
|
|
|
|
ctx.ensure_object(dict)
|
|
ctx.obj["verbose"] = verbose
|
|
_setup_logging(verbose)
|
|
|
|
if ctx.invoked_subcommand is None:
|
|
_banner()
|
|
click.echo(ctx.get_help())
|
|
|
|
|
|
# ── Visible (Gemini) watermark removal ──
|
|
def _run_visible_auto(
|
|
source: Path,
|
|
output: Path,
|
|
*,
|
|
backend: watermark_registry.Backend,
|
|
sensitivity: watermark_registry.Sensitivity,
|
|
strip_metadata: bool,
|
|
) -> None:
|
|
"""Run the registry-wide visible pass and render its CLI result."""
|
|
from remove_ai_watermarks import api
|
|
|
|
t0 = time.monotonic()
|
|
try:
|
|
with console.status("Detecting & removing visible marks..."):
|
|
result, removed = api.remove_visible(
|
|
str(source),
|
|
str(output),
|
|
sensitivity=sensitivity,
|
|
backend=backend,
|
|
strip_metadata=strip_metadata,
|
|
write_noop=False,
|
|
)
|
|
except RuntimeError as e: # selected migan/lama backend whose extra is absent
|
|
console.print(f" Error: {e}")
|
|
raise SystemExit(1) from e
|
|
except (ValueError, OSError) as e:
|
|
# Covers BOTH an unreadable input and an unwritable output, so the message must
|
|
# not assert which: it used to say "cannot read image <input>" while quoting the
|
|
# OUTPUT path, blaming the wrong file (Tier E, 2026-07-20).
|
|
console.print(f" Error: {e}")
|
|
raise SystemExit(1) from e
|
|
|
|
elapsed = time.monotonic() - t0
|
|
h, w = result.shape[:2]
|
|
console.print(f" Input: {source.name} ({w}x{h})")
|
|
if not removed:
|
|
# write_noop=False means nothing was written, so a pre-existing output is intact.
|
|
console.print(f" No known visible mark detected. Checked: {', '.join(watermark_registry.mark_keys())}.")
|
|
_no_visible_mark_exit(source)
|
|
console.print(f" Removed: {', '.join(removed)}")
|
|
size_kb = output.stat().st_size / 1024
|
|
console.print(f" Saved: {output} ({size_kb:.0f} KB, {elapsed:.2f}s)")
|
|
|
|
|
|
def _run_visible_explicit(
|
|
ctx: click.Context,
|
|
source: Path,
|
|
output: Path,
|
|
*,
|
|
detect: bool,
|
|
mark: str,
|
|
backend: watermark_registry.Backend,
|
|
sensitivity: watermark_registry.Sensitivity,
|
|
resolved_backend: str,
|
|
strip_metadata: bool,
|
|
) -> None:
|
|
"""Run one explicitly selected visible-mark detector/remover."""
|
|
image, alpha = image_io.read_bgr_and_alpha(source)
|
|
if image is None:
|
|
console.print(f"Error: Failed to read image: {source}")
|
|
raise SystemExit(1)
|
|
h, w = image.shape[:2]
|
|
console.print(f" Input: {source.name} ({w}x{h})")
|
|
|
|
provenance = _visible_provenance(source)
|
|
target = "gemini" if mark == "auto" else mark # --no-detect auto: gemini fallback
|
|
chosen = watermark_registry.get_mark(target)
|
|
# A single explicit mark has no sibling corroboration. Keep its trust resolution
|
|
# aligned with the registry arbiter.
|
|
trust = watermark_registry.resolve_trust(
|
|
chosen.key,
|
|
sensitivity=sensitivity,
|
|
provenance=provenance,
|
|
strict_keys=set(),
|
|
)
|
|
relax = trust != "strict"
|
|
detection = chosen.detect(image, provenance=relax)
|
|
if detect and not detection.detected:
|
|
console.print(f" {chosen.label} not detected (conf {detection.confidence:.2f}). Use --no-detect to force.")
|
|
_no_visible_mark_exit(source)
|
|
if detection.detected:
|
|
console.print(f" {chosen.label} detected ({chosen.location}, conf {detection.confidence:.2f})")
|
|
|
|
t0 = time.monotonic()
|
|
try:
|
|
with console.status(f"Removing {chosen.label}... ({resolved_backend})"):
|
|
# Reuse the detection printed above instead of re-detecting inside remove():
|
|
# nothing has touched `image` since, and the trust level is the same one.
|
|
result, _ = chosen.remove(image, backend=backend, provenance=relax, force=not detect, detection=detection)
|
|
except RuntimeError as e: # selected migan/lama backend whose extra is absent
|
|
console.print(f" Error: {e}")
|
|
raise SystemExit(1) from e
|
|
elapsed = time.monotonic() - t0
|
|
|
|
_write_output_or_exit(output, result, alpha)
|
|
if strip_metadata:
|
|
try:
|
|
from remove_ai_watermarks.metadata import remove_ai_metadata
|
|
|
|
remove_ai_metadata(output, output)
|
|
except Exception as e:
|
|
if ctx.obj.get("verbose"):
|
|
console.print(f" Warning: Failed to strip metadata: {e}")
|
|
|
|
size_kb = output.stat().st_size / 1024
|
|
console.print(f" Saved: {output} ({size_kb:.0f} KB, {elapsed:.2f}s)")
|
|
|
|
|
|
@main.command("visible")
|
|
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
@_output_option
|
|
@click.option("--detect/--no-detect", default=True, help="Detect watermark before removal.")
|
|
@click.option(
|
|
"--mark",
|
|
type=click.Choice(["auto", *watermark_registry.mark_keys()]),
|
|
default="auto",
|
|
help="Which known visible mark to target (auto picks every detected mark). "
|
|
"The fill backend is chosen by --backend (default auto).",
|
|
)
|
|
@_visible_backend_option
|
|
@_visible_sensitivity_option
|
|
@click.option("--strip-metadata/--keep-metadata", default=True, help="Strip AI metadata from output.")
|
|
@click.pass_context
|
|
def cmd_visible(
|
|
ctx: click.Context,
|
|
source: Path,
|
|
output: Path | None,
|
|
detect: bool,
|
|
mark: str,
|
|
backend: str,
|
|
sensitivity: str,
|
|
strip_metadata: bool,
|
|
) -> None:
|
|
"""Remove a known visible AI watermark from an image.
|
|
|
|
Finds registered marks in their expected areas and removes them by localizing
|
|
each mark to a mask, then filling that mask with the selected ``--backend``.
|
|
``--mark auto`` removes every detected registry entry in one pass. Run
|
|
``--help`` to see the current mark keys. For arbitrary logos and objects, use
|
|
``erase``.
|
|
"""
|
|
_banner()
|
|
source = _validate_image(source)
|
|
|
|
if output is None:
|
|
output = source.with_stem(source.stem + "_clean")
|
|
|
|
bk: watermark_registry.Backend = backend # type: ignore[assignment]
|
|
sens = _parse_sensitivity(sensitivity)
|
|
resolved_backend = watermark_registry.resolve_backend(bk)
|
|
if resolved_backend == "cv2" and not watermark_registry.inpaint_model_available():
|
|
console.print(" Note: using cv2 fill (install the 'migan' extra for a lightweight ONNX model).")
|
|
|
|
# ``auto`` removes EVERY detected in_auto mark in one pass (a Jimeng-basic image
|
|
# carries the top-left pill AND the bottom-right wordmark). Delegate the whole
|
|
# read -> provenance -> localize/fill -> write -> metadata-strip to the library
|
|
# entry point, so the CLI and the library go through ONE path (no drift).
|
|
if mark == "auto" and detect:
|
|
_run_visible_auto(source, output, backend=bk, sensitivity=sens, strip_metadata=strip_metadata)
|
|
return
|
|
|
|
_run_visible_explicit(
|
|
ctx,
|
|
source,
|
|
output,
|
|
detect=detect,
|
|
mark=mark,
|
|
backend=bk,
|
|
sensitivity=sens,
|
|
resolved_backend=resolved_backend,
|
|
strip_metadata=strip_metadata,
|
|
)
|
|
|
|
|
|
# ── Universal region eraser ──
|
|
def _parse_region(spec: str) -> tuple[int, int, int, int]:
|
|
"""Parse an ``x,y,w,h`` region string into a 4-int tuple."""
|
|
parts = spec.replace(" ", "").split(",")
|
|
if len(parts) != 4:
|
|
raise click.BadParameter(f"region must be 'x,y,w,h', got: {spec!r}")
|
|
try:
|
|
x, y, w, h = (int(p) for p in parts)
|
|
except ValueError as e:
|
|
raise click.BadParameter(f"region values must be integers: {spec!r}") from e
|
|
if w <= 0 or h <= 0:
|
|
raise click.BadParameter(f"region width/height must be positive: {spec!r}")
|
|
return x, y, w, h
|
|
|
|
|
|
@main.command("erase")
|
|
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
@click.option("--region", "regions", multiple=True, required=True, help="x,y,w,h box to erase (repeatable).")
|
|
@_output_option
|
|
@click.option(
|
|
"--backend",
|
|
type=click.Choice(["cv2", "migan", "lama"]),
|
|
default="cv2",
|
|
help="Inpaint backend. cv2: instant, no model download. migan: light ONNX MI-GAN, ~1 GB RAM, "
|
|
"near-LaMa quality (extra 'migan'). lama: big-LaMa, best quality but ~4.7 GB RAM (extra 'lama').",
|
|
)
|
|
@click.option("--inpaint-method", type=click.Choice(["telea", "ns"]), default="telea", help="cv2 inpaint method.")
|
|
@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
|
|
def cmd_erase(
|
|
ctx: click.Context,
|
|
source: Path,
|
|
regions: tuple[str, ...],
|
|
output: Path | None,
|
|
backend: Literal["cv2", "migan", "lama"],
|
|
inpaint_method: str,
|
|
dilate: int,
|
|
strip_metadata: bool,
|
|
) -> None:
|
|
"""Erase arbitrary region(s) from an image via inpainting.
|
|
|
|
Universal and position-agnostic: removes any logo / watermark / object inside
|
|
the boxes you pass, regardless of color or location. Runs on CPU. Use this
|
|
for marks the dedicated ``visible`` registry does not cover.
|
|
"""
|
|
from remove_ai_watermarks.region_eraser import erase
|
|
|
|
_banner()
|
|
source = _validate_image(source)
|
|
if output is None:
|
|
output = source.with_stem(source.stem + "_clean")
|
|
|
|
boxes = [_parse_region(r) for r in regions]
|
|
|
|
image, alpha = image_io.read_bgr_and_alpha(source)
|
|
if image is None:
|
|
console.print(f"Error: Failed to read image: {source}")
|
|
raise SystemExit(1)
|
|
h, w = image.shape[:2]
|
|
console.print(f" Input: {source.name} ({w}x{h}) {len(boxes)} region(s), backend={backend}")
|
|
|
|
t0 = time.monotonic()
|
|
method: Literal["telea", "ns"] = "ns" if inpaint_method == "ns" else "telea"
|
|
try:
|
|
with console.status(f"Erasing ({backend})..."):
|
|
result = erase(image, boxes=boxes, backend=backend, dilate=dilate, cv2_method=method)
|
|
except RuntimeError as e:
|
|
console.print(f" Error: {e}")
|
|
raise SystemExit(1) from e
|
|
elapsed = time.monotonic() - t0
|
|
|
|
_write_output_or_exit(output, result, alpha)
|
|
|
|
if strip_metadata:
|
|
try:
|
|
from remove_ai_watermarks.metadata import remove_ai_metadata
|
|
|
|
remove_ai_metadata(output, output)
|
|
except Exception as e:
|
|
if ctx.obj.get("verbose"):
|
|
console.print(f" Warning: Failed to strip metadata: {e}")
|
|
|
|
size_kb = output.stat().st_size / 1024
|
|
console.print(f" Erased {len(boxes)} region(s) -> {output} ({size_kb:.0f} KB, {elapsed:.2f}s)")
|
|
|
|
|
|
# ── Invisible watermark removal ──
|
|
@main.command("invisible")
|
|
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
@_output_option
|
|
@_strength_option
|
|
@_pipeline_option
|
|
@_seed_option
|
|
@_hf_token_option
|
|
@_humanize_option
|
|
@_max_resolution_option
|
|
@_controlnet_scale_option
|
|
@_unsharp_option
|
|
@_adaptive_polish_option
|
|
@_tile_options
|
|
@_force_option
|
|
@_cpu_offload_option
|
|
@click.pass_context
|
|
def cmd_invisible(
|
|
ctx: click.Context,
|
|
source: Path,
|
|
output: Path | None,
|
|
strength: float | None,
|
|
pipeline: str,
|
|
seed: int | None,
|
|
hf_token: str | None,
|
|
humanize: float,
|
|
unsharp: float,
|
|
max_resolution: int,
|
|
controlnet_scale: float,
|
|
adaptive_polish: bool | None,
|
|
tile: bool,
|
|
tile_size: int,
|
|
tile_overlap: int,
|
|
force: bool,
|
|
cpu_offload: bool,
|
|
) -> None:
|
|
"""Remove invisible AI watermarks (SynthID, StableSignature, TreeRing).
|
|
|
|
Regenerates the pixels with the two-stage diffusion profile. CUDA-only:
|
|
pip install 'remove-ai-watermarks[qwen-zimage]'
|
|
"""
|
|
from remove_ai_watermarks.invisible_engine import is_available as invisible_available
|
|
|
|
if not invisible_available():
|
|
console.print(
|
|
"Error: the invisible-removal dependencies are not installed.\n"
|
|
f" Install them with: pip install {INVISIBLE_EXTRA}"
|
|
)
|
|
raise SystemExit(1)
|
|
|
|
from remove_ai_watermarks.invisible_engine import InvisibleEngine
|
|
|
|
source = _validate_image(source)
|
|
if output is None:
|
|
output = source.with_stem(source.stem + "_clean")
|
|
|
|
# Gate BEFORE building the engine: skip the destructive regeneration when no
|
|
# invisible AI watermark is locally detectable (it would only degrade a clean
|
|
# image -- dominant paid score-0 cause), so the common skip path pays nothing for
|
|
# engine construction. A skip never claims the image is clean; --force overrides.
|
|
if _should_skip_invisible_scrub(force, source):
|
|
_no_invisible_signal_exit(source)
|
|
|
|
def progress_cb(msg: str) -> None:
|
|
console.print(f" {msg}")
|
|
|
|
engine = InvisibleEngine(
|
|
pipeline=pipeline,
|
|
hf_token=hf_token,
|
|
progress_callback=progress_cb,
|
|
controlnet_conditioning_scale=controlnet_scale,
|
|
cpu_offload=cpu_offload,
|
|
)
|
|
|
|
# Detect the SynthID vendor from the ORIGINAL (before processing strips C2PA) so the
|
|
# displayed and executed strength agree on the vendor-adaptive default.
|
|
vendor = vendor_for_strength(source)
|
|
console.print(f" Input: {source.name}")
|
|
console.print(f" Pipeline: {pipeline}")
|
|
console.print(f" Strength: {_resolved_strength_for_display(source, strength, vendor, pipeline)}")
|
|
|
|
t0 = time.monotonic()
|
|
result_path = engine.remove_watermark(
|
|
image_path=source,
|
|
output_path=output,
|
|
strength=strength,
|
|
seed=seed,
|
|
humanize=humanize,
|
|
unsharp=unsharp,
|
|
adaptive_polish=adaptive_polish,
|
|
max_resolution=max_resolution,
|
|
vendor=vendor,
|
|
tile=tile,
|
|
tile_size=tile_size,
|
|
tile_overlap=tile_overlap,
|
|
)
|
|
elapsed = time.monotonic() - t0
|
|
|
|
size_kb = result_path.stat().st_size / 1024
|
|
console.print(f"\n Saved: {result_path} ({size_kb:.0f} KB, {elapsed:.1f}s)")
|
|
|
|
|
|
# ── Metadata operations ──
|
|
def _print_metadata_report(source: Path, has_ai: bool, metadata: dict[str, str]) -> None:
|
|
"""Render one metadata inspection result for the generic and video commands."""
|
|
if not has_ai:
|
|
console.print(f" No AI metadata found in {source.name}")
|
|
return
|
|
|
|
console.print(f" Warning: AI metadata detected in {source.name}:")
|
|
if synthid := metadata.get("synthid_watermark"):
|
|
console.print(f" Warning: SynthID watermark {synthid}")
|
|
table = Table(show_header=True, header_style="bold")
|
|
table.add_column("Key", style="cyan")
|
|
table.add_column("Value")
|
|
for key, value in metadata.items():
|
|
table.add_row(key, str(value)[:80])
|
|
console.print(table)
|
|
|
|
|
|
@main.command("metadata")
|
|
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
@click.option("--check", is_flag=True, help="Check for AI metadata (don't modify).")
|
|
@click.option("--remove", is_flag=True, help="Remove AI metadata.")
|
|
@click.option(
|
|
"-o", "--output", type=click.Path(path_type=Path), default=None, help="Output path (default: overwrite source)."
|
|
)
|
|
@click.option("--keep-standard/--remove-all", default=True, help="Keep standard metadata (Author, Title, etc.).")
|
|
@click.pass_context
|
|
def cmd_metadata(
|
|
ctx: click.Context,
|
|
source: Path,
|
|
check: bool,
|
|
remove: bool,
|
|
output: Path | None,
|
|
keep_standard: bool,
|
|
) -> None:
|
|
"""Check or remove AI-generation metadata (images, video, and audio).
|
|
|
|
Strips EXIF AI tags, PNG text chunks, C2PA provenance manifests, and the
|
|
China TC260 AIGC label. Beyond images (PNG/JPEG/WebP/AVIF/HEIF/JXL) it also
|
|
strips provenance metadata from MP4/MOV/M4V/M4A containers and, via ffmpeg,
|
|
from WebM/MKV/AVI/FLV/MP3/WAV/FLAC/OGG. The coded image, audio, and video
|
|
data are left untouched.
|
|
"""
|
|
from remove_ai_watermarks.metadata import get_ai_metadata, has_ai_metadata, strip_and_verify
|
|
|
|
# No _validate_image() here: unlike the image-only commands, metadata also
|
|
# accepts video/audio containers, so the image-format warning would misfire.
|
|
# click's `exists=True` on the argument already enforces the file exists.
|
|
_banner()
|
|
|
|
if check or (not remove):
|
|
has_ai = has_ai_metadata(source)
|
|
metadata = get_ai_metadata(source) if has_ai else {}
|
|
_print_metadata_report(source, has_ai, metadata)
|
|
|
|
if not remove:
|
|
return
|
|
|
|
# Remove
|
|
try:
|
|
out, leftover = strip_and_verify(source, output, keep_standard=keep_standard)
|
|
except (OSError, ValueError) as e: # unreadable / truncated / non-image (PIL raises OSError subclasses)
|
|
console.print(f" Error: cannot process {source.name}: {e}")
|
|
raise SystemExit(1) from e
|
|
|
|
if leftover:
|
|
console.print(f" FAILED: {len(leftover)} AI metadata marker(s) survived in {out}")
|
|
console.print(f" still present: {', '.join(sorted(leftover))}")
|
|
console.print(" the file could not be decoded, so it was copied through unchanged")
|
|
raise SystemExit(1)
|
|
console.print(f" AI metadata stripped -> {out}")
|
|
|
|
|
|
# ── Video pipeline ──
|
|
def _video_visible_options(f: Any) -> Any:
|
|
"""Apply the shared visible-video detector and fill options."""
|
|
f = click.option(
|
|
"--temporal-consistency/--no-temporal-consistency",
|
|
default=True,
|
|
help="Motion-align adjacent accepted fills to reduce frame-to-frame flicker.",
|
|
)(f)
|
|
f = click.option(
|
|
"--backend",
|
|
type=click.Choice(["auto", "cv2", "migan", "lama"]),
|
|
default="cv2",
|
|
help="Per-frame visible-fill backend.",
|
|
)(f)
|
|
return click.option(
|
|
"--mark",
|
|
type=click.Choice(["auto", *VIDEO_VISIBLE_MARKS]),
|
|
default="auto",
|
|
help="Visible AI mark to remove. Auto scans every supported provider in one decode pass.",
|
|
)(f)
|
|
|
|
|
|
def _video_invisible_options(f: Any) -> Any:
|
|
"""Apply the shared invisible-video removal options."""
|
|
f = click.option(
|
|
"--device",
|
|
type=click.Choice(["auto", "cuda", "mps", "cpu"]),
|
|
default="auto",
|
|
show_default=True,
|
|
help="VAE inference device.",
|
|
)(f)
|
|
f = click.option("--seed", type=int, default=0, show_default=True)(f)
|
|
f = click.option("--batch-size", type=click.IntRange(min=1), default=4, show_default=True)(f)
|
|
f = click.option(
|
|
"--fps",
|
|
type=click.FloatRange(min=1.0),
|
|
default=DEFAULT_VIDEO_SYNTHID_FPS,
|
|
show_default=True,
|
|
help="Output frame rate, capped at the source frame rate.",
|
|
)(f)
|
|
f = click.option(
|
|
"--long-side",
|
|
type=click.IntRange(min=VIDEO_SYNTHID_LATENT_MULTIPLE),
|
|
default=DEFAULT_VIDEO_SYNTHID_LONG_SIDE,
|
|
show_default=True,
|
|
help="Regenerated video long side in pixels.",
|
|
)(f)
|
|
return click.option(
|
|
"--noise-std",
|
|
type=click.FloatRange(min=0.0, max=1.0),
|
|
default=DEFAULT_VIDEO_SYNTHID_NOISE_STD,
|
|
show_default=True,
|
|
help="Shared latent-noise strength. Higher values change more detail.",
|
|
)(f)
|
|
|
|
|
|
@main.group("video")
|
|
def cmd_video() -> None:
|
|
"""Process AI watermarks in video files."""
|
|
|
|
|
|
@cmd_video.command("identify")
|
|
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
@click.option("--no-visible", is_flag=True, help="Skip visible-mark detection; inspect metadata only.")
|
|
@click.option("--json", "as_json", is_flag=True, help="Emit the report as JSON.")
|
|
def cmd_video_identify(source: Path, no_visible: bool, as_json: bool) -> None:
|
|
"""Identify supported provenance and visible AI marks in video."""
|
|
from dataclasses import asdict
|
|
|
|
from remove_ai_watermarks.video import identify_video
|
|
|
|
try:
|
|
report = identify_video(source, check_visible=not no_visible)
|
|
except (OSError, RuntimeError, ValueError) as e:
|
|
raise click.ClickException(str(e)) from e
|
|
|
|
if as_json:
|
|
click.echo(json.dumps(asdict(report), default=str, indent=2))
|
|
return
|
|
|
|
_banner()
|
|
verdict = "AI-generated" if report.is_ai_generated else "unknown"
|
|
console.print(f" Verdict: {verdict} (confidence: {report.confidence})")
|
|
console.print(f" Platform: {report.platform or 'undetermined'}")
|
|
if report.visible_mark is not None:
|
|
console.print(
|
|
f" Visible mark: {report.visible_mark} "
|
|
f"({report.visible_detected_frames}/{report.total_frames} stable frames)"
|
|
)
|
|
else:
|
|
console.print(" Visible mark: none found" if not no_visible else " Visible mark: not checked")
|
|
if report.metadata_markers:
|
|
console.print(f" AI metadata markers: {', '.join(sorted(report.metadata_markers))}")
|
|
else:
|
|
console.print(" AI metadata markers: none found")
|
|
if report.caveats:
|
|
console.print(" Caveats:")
|
|
for caveat in report.caveats:
|
|
console.print(f" - {caveat}")
|
|
|
|
|
|
@cmd_video.command("metadata")
|
|
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
@click.option("--check", is_flag=True, help="Check for AI metadata (don't modify).")
|
|
@click.option("--remove", is_flag=True, help="Remove AI metadata.")
|
|
@_video_output_option
|
|
@click.option("--keep-standard/--remove-all", default=True, help="Keep standard metadata.")
|
|
def cmd_video_metadata(
|
|
source: Path,
|
|
check: bool,
|
|
remove: bool,
|
|
output: Path | None,
|
|
keep_standard: bool,
|
|
) -> None:
|
|
"""Check or remove AI metadata without transcoding video streams."""
|
|
from remove_ai_watermarks.video import inspect_video_metadata, remove_video_metadata
|
|
|
|
_banner()
|
|
try:
|
|
report = inspect_video_metadata(source)
|
|
except (OSError, ValueError) as e:
|
|
raise click.ClickException(str(e)) from e
|
|
|
|
if check or not remove:
|
|
_print_metadata_report(source, report.has_ai_metadata, report.markers)
|
|
|
|
if not remove:
|
|
return
|
|
|
|
try:
|
|
result = remove_video_metadata(source, output, keep_standard=keep_standard)
|
|
except (OSError, RuntimeError, ValueError) as e:
|
|
raise click.ClickException(str(e)) from e
|
|
|
|
if result.remaining:
|
|
console.print(f" FAILED: {len(result.remaining)} AI metadata marker(s) survived in {result.output}")
|
|
console.print(f" still present: {', '.join(sorted(result.remaining))}")
|
|
raise SystemExit(1)
|
|
console.print(f" AI metadata stripped -> {result.output}")
|
|
|
|
|
|
@cmd_video.command("invisible")
|
|
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
@_video_output_option
|
|
@_video_invisible_options
|
|
def cmd_video_invisible(
|
|
source: Path,
|
|
output: Path | None,
|
|
noise_std: float,
|
|
long_side: int,
|
|
fps: float,
|
|
batch_size: int,
|
|
seed: int,
|
|
device: str,
|
|
) -> None:
|
|
"""Remove video SynthID with the oracle-certified VAE profile."""
|
|
from remove_ai_watermarks.video import remove_video_invisible
|
|
|
|
_banner()
|
|
console.print(f" Regenerating {source.name} with temporally shared VAE noise...")
|
|
try:
|
|
result = remove_video_invisible(
|
|
source,
|
|
output,
|
|
noise_std=noise_std,
|
|
long_side=long_side,
|
|
fps=fps,
|
|
batch_size=batch_size,
|
|
seed=seed,
|
|
device=device,
|
|
)
|
|
except (OSError, RuntimeError, ValueError) as e:
|
|
raise click.ClickException(str(e)) from e
|
|
|
|
if result.remaining_metadata:
|
|
console.print(f" FAILED: {len(result.remaining_metadata)} AI metadata marker(s) survived in {result.output}")
|
|
raise SystemExit(1)
|
|
console.print(
|
|
f" SynthID removal complete: {result.width}x{result.height}, "
|
|
f"{result.total_frames} frames at {result.fps:.4g} fps -> {result.output}"
|
|
)
|
|
|
|
|
|
@cmd_video.command("visible")
|
|
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
@_video_output_option
|
|
@_video_visible_options
|
|
@click.option("--strip-metadata/--keep-metadata", default=True, help="Strip AI metadata from the transcoded output.")
|
|
def cmd_video_visible(
|
|
source: Path,
|
|
output: Path | None,
|
|
mark: str,
|
|
backend: str,
|
|
temporal_consistency: bool,
|
|
strip_metadata: bool,
|
|
) -> None:
|
|
"""Remove a temporally stable visible AI wordmark from video."""
|
|
from remove_ai_watermarks.video import remove_video_visible
|
|
|
|
_banner()
|
|
console.print(f" Scanning {source.name} for a temporally stable {mark} mark...")
|
|
try:
|
|
result = remove_video_visible(
|
|
source,
|
|
output,
|
|
mark=mark,
|
|
backend=backend,
|
|
strip_metadata=strip_metadata,
|
|
temporal_consistency=temporal_consistency,
|
|
)
|
|
except (OSError, RuntimeError, ValueError) as e:
|
|
raise click.ClickException(str(e)) from e
|
|
|
|
if result.output is None:
|
|
console.print(f" No stable {mark} watermark detected; no output written")
|
|
raise SystemExit(EXIT_NO_VISIBLE_MARK)
|
|
if result.remaining_metadata:
|
|
console.print(f" FAILED: {len(result.remaining_metadata)} AI metadata marker(s) survived in {result.output}")
|
|
raise SystemExit(1)
|
|
console.print(
|
|
f" Removed {result.mark} watermark from "
|
|
f"{result.removed_frames}/{result.total_frames} frames -> {result.output}"
|
|
)
|
|
|
|
|
|
@cmd_video.command("all")
|
|
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
@_video_output_option
|
|
@_video_visible_options
|
|
@click.option(
|
|
"--invisible/--no-invisible",
|
|
default=False,
|
|
help="Opt into oracle-certified lossy video SynthID removal.",
|
|
)
|
|
@_video_invisible_options
|
|
def cmd_video_all(
|
|
source: Path,
|
|
output: Path | None,
|
|
mark: str,
|
|
backend: str,
|
|
temporal_consistency: bool,
|
|
invisible: bool,
|
|
noise_std: float,
|
|
long_side: int,
|
|
fps: float,
|
|
batch_size: int,
|
|
seed: int,
|
|
device: str,
|
|
) -> None:
|
|
"""Remove stable visible marks and AI metadata from video."""
|
|
from remove_ai_watermarks.video import remove_video_all
|
|
|
|
_banner()
|
|
stages = "visible marks + SynthID + verified AI metadata" if invisible else "visible marks + verified AI metadata"
|
|
console.print(f" Cleaning {source.name}: {stages}...")
|
|
try:
|
|
result = remove_video_all(
|
|
source,
|
|
output,
|
|
mark=mark,
|
|
backend=backend,
|
|
temporal_consistency=temporal_consistency,
|
|
include_invisible=invisible,
|
|
noise_std=noise_std,
|
|
long_side=long_side,
|
|
fps=fps,
|
|
batch_size=batch_size,
|
|
seed=seed,
|
|
device=device,
|
|
)
|
|
except (OSError, RuntimeError, ValueError) as e:
|
|
raise click.ClickException(str(e)) from e
|
|
|
|
if result.remaining_metadata:
|
|
console.print(f" FAILED: {len(result.remaining_metadata)} AI metadata marker(s) survived in {result.output}")
|
|
raise SystemExit(1)
|
|
if result.visible_mark is None:
|
|
detail = "" if result.invisible_removed else "; pixels preserved"
|
|
console.print(f" Visible mark: none found{detail}")
|
|
else:
|
|
console.print(
|
|
f" Visible mark: removed {result.visible_mark} from "
|
|
f"{result.visible_removed_frames}/{result.total_frames} frames"
|
|
)
|
|
console.print(f" AI metadata: stripped -> {result.output}")
|
|
if result.invisible_removed:
|
|
console.print(" SynthID: removed with the oracle-certified VAE profile")
|
|
|
|
|
|
@cmd_video.command("batch")
|
|
@click.argument("directory", type=click.Path(exists=True, file_okay=False, path_type=Path))
|
|
@click.option(
|
|
"-o",
|
|
"--output-dir",
|
|
type=click.Path(path_type=Path),
|
|
default=None,
|
|
help="Output directory (default: <directory>_clean).",
|
|
)
|
|
@click.option(
|
|
"--mode",
|
|
type=click.Choice(["all", "visible", "metadata"]),
|
|
default="all",
|
|
show_default=True,
|
|
help="Video processing mode.",
|
|
)
|
|
@_video_visible_options
|
|
@click.option(
|
|
"--invisible/--no-invisible",
|
|
default=False,
|
|
help="Opt into oracle-certified lossy SynthID removal in all mode.",
|
|
)
|
|
@_video_invisible_options
|
|
def cmd_video_batch(
|
|
directory: Path,
|
|
output_dir: Path | None,
|
|
mode: str,
|
|
mark: str,
|
|
backend: str,
|
|
temporal_consistency: bool,
|
|
invisible: bool,
|
|
noise_std: float,
|
|
long_side: int,
|
|
fps: float,
|
|
batch_size: int,
|
|
seed: int,
|
|
device: str,
|
|
) -> None:
|
|
"""Process every supported video in a directory."""
|
|
from remove_ai_watermarks.video import remove_video_batch
|
|
|
|
_banner()
|
|
console.print(f" Processing video directory {directory} in {mode} mode...")
|
|
try:
|
|
result = remove_video_batch(
|
|
directory,
|
|
output_dir,
|
|
mode=mode, # type: ignore[arg-type]
|
|
mark=mark,
|
|
backend=backend,
|
|
temporal_consistency=temporal_consistency,
|
|
include_invisible=invisible,
|
|
noise_std=noise_std,
|
|
long_side=long_side,
|
|
fps=fps,
|
|
batch_size=batch_size,
|
|
seed=seed,
|
|
device=device,
|
|
)
|
|
except (OSError, RuntimeError, ValueError) as e:
|
|
raise click.ClickException(str(e)) from e
|
|
|
|
for item in result.items:
|
|
if item.error is not None:
|
|
console.print(f" FAILED {item.source.name}: {item.error}")
|
|
elif item.changed:
|
|
detail = f" ({item.visible_mark})" if item.visible_mark is not None else ""
|
|
console.print(f" Processed {item.source.name}{detail} -> {item.output}")
|
|
elif item.mode == "visible":
|
|
console.print(f" Copied {item.source.name} byte-for-byte -> {item.output}")
|
|
else:
|
|
console.print(f" Completed {item.source.name}; no supported signal found -> {item.output}")
|
|
console.print(
|
|
f" Batch complete: {result.processed} processed, {result.failed} failed -> {result.output_directory}"
|
|
)
|
|
if result.invisible_removed:
|
|
console.print(f" SynthID: removed from {result.invisible_removed} file(s)")
|
|
if result.failed:
|
|
raise SystemExit(1)
|
|
|
|
|
|
# ── Provenance identification ──
|
|
@main.command("identify")
|
|
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
@click.option(
|
|
"--no-visible",
|
|
is_flag=True,
|
|
help="Skip pixel-domain detectors (visible sparkle + invisible watermark); metadata-only.",
|
|
)
|
|
@click.option("--json", "as_json", is_flag=True, help="Emit the report as JSON instead of a table.")
|
|
@click.pass_context
|
|
def cmd_identify(ctx: click.Context, source: Path, no_visible: bool, as_json: bool) -> None:
|
|
"""Identify where an image was made and what watermarks it carries.
|
|
|
|
Aggregates supported C2PA, IPTC, EXIF, XMP, generator, visible-mark, and
|
|
optional invisible-watermark signals into one provenance verdict. Absence of
|
|
signals is reported as "unknown", never as "clean" because stripped metadata
|
|
leaves no local proof.
|
|
"""
|
|
from dataclasses import asdict
|
|
|
|
from remove_ai_watermarks.identify import identify
|
|
|
|
source = _validate_image(source)
|
|
report = identify(source, check_visible=not no_visible, check_invisible=not no_visible)
|
|
|
|
if as_json:
|
|
click.echo(json.dumps(asdict(report), default=str, indent=2))
|
|
return
|
|
|
|
_banner()
|
|
verdict = {True: "AI-generated", False: "not AI", None: "unknown"}[report.is_ai_generated]
|
|
# Sharpen the True verdict when the C2PA source type says the image is a real
|
|
# photo with an AI-composited region rather than a full AI generation, so the
|
|
# caller (and the user) can tell "scrub the whole frame" from "scrub the AI region".
|
|
if report.is_ai_generated and report.ai_source_kind == "enhanced":
|
|
verdict = "AI-enhanced (real content with an AI-composited region)"
|
|
elif report.is_ai_generated and report.ai_source_kind == "generated":
|
|
verdict = "AI-generated (fully synthetic)"
|
|
console.print(f"\n Verdict: {verdict} (confidence: {report.confidence})")
|
|
console.print(f" Platform: {report.platform or 'undetermined'}")
|
|
|
|
if report.is_ai_generated is None:
|
|
console.print(
|
|
" No locally-readable AI signal found. This is not the same as 'clean': "
|
|
"metadata is often stripped by re-encoding, screenshots, or upload, and SynthID-class "
|
|
"pixel watermarks (Gemini / Nano Banana / gpt-image) have no local detector. "
|
|
"See caveats below."
|
|
)
|
|
|
|
if report.integrity_clashes:
|
|
console.print("\n Warning: Integrity clash (provenance signals contradict each other)")
|
|
for clash in report.integrity_clashes:
|
|
console.print(f" - {clash}")
|
|
|
|
if report.watermarks:
|
|
table = Table(show_header=True, header_style="bold", title="Watermarks / provenance markers")
|
|
table.add_column("Marker", style="cyan")
|
|
for wm in report.watermarks:
|
|
table.add_row(wm)
|
|
console.print(table)
|
|
else:
|
|
console.print(" No watermarks or provenance markers found.")
|
|
|
|
if report.caveats:
|
|
console.print("\n Caveats:")
|
|
for c in report.caveats:
|
|
console.print(f" - {c}")
|
|
|
|
|
|
# ── Combined "all" mode ──
|
|
@main.command("all")
|
|
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
@_output_option
|
|
@_visible_backend_option
|
|
@_visible_sensitivity_option
|
|
@_strength_option
|
|
@_pipeline_option
|
|
@_seed_option
|
|
@_hf_token_option
|
|
@_humanize_option
|
|
@_max_resolution_option
|
|
@_controlnet_scale_option
|
|
@_unsharp_option
|
|
@_adaptive_polish_option
|
|
@_tile_options
|
|
@_force_option
|
|
@_cpu_offload_option
|
|
@click.pass_context
|
|
def cmd_all(
|
|
ctx: click.Context,
|
|
source: Path,
|
|
output: Path | None,
|
|
backend: str,
|
|
sensitivity: str,
|
|
strength: float | None,
|
|
pipeline: str,
|
|
seed: int | None,
|
|
hf_token: str | None,
|
|
humanize: float,
|
|
unsharp: float,
|
|
max_resolution: int,
|
|
controlnet_scale: float,
|
|
adaptive_polish: bool | None,
|
|
tile: bool,
|
|
tile_size: int,
|
|
tile_overlap: int,
|
|
force: bool,
|
|
cpu_offload: bool,
|
|
) -> None:
|
|
"""Remove ALL watermarks: visible + invisible + metadata.
|
|
|
|
Runs the full pipeline in order:
|
|
1. Visible watermark removal (Gemini sparkle / text marks, localize -> fill)
|
|
2. Invisible watermark removal (SynthID etc., diffusion regeneration)
|
|
3. AI metadata stripping (EXIF, PNG text, C2PA)
|
|
|
|
If invisible watermark deps are not installed, skips step 2 with a warning.
|
|
"""
|
|
_banner()
|
|
source = _validate_image(source)
|
|
|
|
if output is None:
|
|
output = source.with_stem(source.stem + "_clean")
|
|
|
|
t0 = time.monotonic()
|
|
|
|
from remove_ai_watermarks.api import InvisibleOptions, MetadataStripIncomplete, remove_all
|
|
|
|
stage_labels = {
|
|
"visible": "\n 1) Visible watermark removal",
|
|
"invisible": "\n 2) Invisible watermark removal",
|
|
"metadata": "\n 3) AI metadata stripping",
|
|
}
|
|
# The library reports WHAT happened as a (stage, detail) pair of stable tokens; the
|
|
# console wording is the CLI's business. These two skips in particular carry guidance
|
|
# a library caller does not need but a user very much does.
|
|
stage_text = {
|
|
("invisible", "no-signal"): (
|
|
"Skipped (no invisible AI watermark detected; pixels left intact).\n"
|
|
" Not a clean-image guarantee: a pixel SynthID is undetectable once its\n"
|
|
" metadata proxy is gone. Re-run with --force to scrub regardless."
|
|
),
|
|
("invisible", "unavailable"): (
|
|
f"Warning: Skipped - GPU dependencies not installed.\n Install them with: pip install {INVISIBLE_EXTRA}"
|
|
),
|
|
("invisible", "removed"): "Invisible watermark removed",
|
|
("metadata", "stripped"): "AI metadata stripped",
|
|
}
|
|
seen: set[str] = set()
|
|
|
|
def progress(stage: str, detail: str) -> None:
|
|
if stage in stage_labels and stage not in seen:
|
|
seen.add(stage)
|
|
console.print(stage_labels[stage])
|
|
if (text := stage_text.get((stage, detail))) is not None:
|
|
console.print(f" {text}")
|
|
elif stage == "visible":
|
|
console.print(
|
|
f" Visible watermark removed ({detail})" if detail else " Skipped (no visible watermark detected)"
|
|
)
|
|
elif detail.startswith("strength="):
|
|
console.print(f" Strength: {detail.removeprefix('strength=')}")
|
|
else:
|
|
console.print(f" {detail}")
|
|
|
|
try:
|
|
outcome = remove_all(
|
|
source,
|
|
output,
|
|
backend=backend, # type: ignore[arg-type]
|
|
sensitivity=_parse_sensitivity(sensitivity),
|
|
invisible=InvisibleOptions(
|
|
strength=strength,
|
|
pipeline=pipeline,
|
|
seed=seed,
|
|
hf_token=hf_token,
|
|
humanize=humanize,
|
|
unsharp=unsharp,
|
|
adaptive_polish=adaptive_polish,
|
|
max_resolution=max_resolution,
|
|
controlnet_conditioning_scale=controlnet_scale,
|
|
cpu_offload=cpu_offload,
|
|
tile=tile,
|
|
tile_size=tile_size,
|
|
tile_overlap=tile_overlap,
|
|
),
|
|
force=force,
|
|
progress=progress,
|
|
)
|
|
except MetadataStripIncomplete as e:
|
|
console.print(f" Error: metadata stripping was incomplete; {', '.join(sorted(e.surviving))} survived")
|
|
raise SystemExit(1) from e
|
|
except ValueError as e:
|
|
console.print(f"Error: {e}")
|
|
raise SystemExit(1) from e
|
|
except RuntimeError as e: # a selected migan/lama backend whose extra is absent
|
|
console.print(f" Error: {e}")
|
|
raise SystemExit(1) from e
|
|
except OSError as e:
|
|
console.print(f" Error: {e}")
|
|
raise SystemExit(1) from e
|
|
|
|
# ── Done ──
|
|
elapsed = time.monotonic() - t0
|
|
size_kb = output.stat().st_size / 1024
|
|
console.print(f"\n Done: {output} ({size_kb:.0f} KB, {elapsed:.1f}s total)")
|
|
|
|
# A skipped invisible step is the single most common "it didn't work" report:
|
|
# the output looks processed but still carries the SynthID watermark. Make that
|
|
# impossible to miss -- a prominent banner plus a non-zero exit so scripts and
|
|
# batch callers can detect the incomplete run instead of trusting the file.
|
|
if outcome.invisible == "unavailable":
|
|
console.print(
|
|
"\n =====================================================================\n"
|
|
" WARNING: the invisible (SynthID) watermark was NOT removed.\n"
|
|
" Step 2 was skipped because the GPU dependencies are not installed,\n"
|
|
" so this output still carries the invisible watermark -- only the\n"
|
|
" visible mark and metadata were stripped.\n"
|
|
"\n"
|
|
" Install the extra and rerun to remove it:\n"
|
|
f" pip install {INVISIBLE_EXTRA}\n"
|
|
" ====================================================================="
|
|
)
|
|
raise SystemExit(1)
|
|
|
|
|
|
# ── Batch command ──
|
|
def _batch_engine(mode: str, options: InvisibleOptions) -> object | None:
|
|
"""Build the ONE invisible engine this batch reuses, or None.
|
|
|
|
Called once per run, not per image: ``--pipeline`` is a single CLI value, constant
|
|
across the batch, so building the model here and threading it down is what keeps the
|
|
diffusion stack from reloading for every file. Modes that never scrub get None, so
|
|
nothing is loaded at all.
|
|
"""
|
|
if mode not in ("all", "invisible"):
|
|
return None
|
|
from remove_ai_watermarks.invisible_engine import InvisibleEngine, is_available
|
|
|
|
if not is_available():
|
|
return None
|
|
return InvisibleEngine(
|
|
pipeline=options.pipeline,
|
|
hf_token=options.hf_token,
|
|
controlnet_conditioning_scale=options.controlnet_conditioning_scale,
|
|
cpu_offload=options.cpu_offload,
|
|
)
|
|
|
|
|
|
@main.command("batch")
|
|
@click.argument("directory", type=click.Path(exists=True, file_okay=False, path_type=Path))
|
|
@click.option(
|
|
"-o",
|
|
"--output-dir",
|
|
type=click.Path(path_type=Path),
|
|
default=None,
|
|
help="Output directory (default: <dir>_clean/).",
|
|
)
|
|
@click.option(
|
|
"--mode", type=click.Choice(["visible", "invisible", "metadata", "all"]), default="visible", help="Processing mode."
|
|
)
|
|
@_strength_option
|
|
@_visible_backend_option
|
|
@_visible_sensitivity_option
|
|
@_humanize_option
|
|
@_pipeline_option
|
|
@_seed_option
|
|
@_hf_token_option
|
|
@_max_resolution_option
|
|
@_unsharp_option
|
|
@_controlnet_scale_option
|
|
@_adaptive_polish_option
|
|
@_tile_options
|
|
@_force_option
|
|
@_cpu_offload_option
|
|
@click.pass_context
|
|
def cmd_batch(
|
|
ctx: click.Context,
|
|
directory: Path,
|
|
mode: str,
|
|
output_dir: Path | None,
|
|
strength: float | None,
|
|
pipeline: str,
|
|
seed: int | None,
|
|
hf_token: str | None,
|
|
backend: str,
|
|
sensitivity: str,
|
|
humanize: float,
|
|
unsharp: float,
|
|
max_resolution: int,
|
|
controlnet_scale: float,
|
|
adaptive_polish: bool | None,
|
|
tile: bool,
|
|
tile_size: int,
|
|
tile_overlap: int,
|
|
force: bool,
|
|
cpu_offload: bool,
|
|
) -> None:
|
|
"""Process all images in a directory."""
|
|
_banner()
|
|
|
|
if output_dir is None:
|
|
output_dir = directory.parent / (directory.name + "_clean")
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
images = sorted(p for p in directory.iterdir() if is_supported_format(p))
|
|
|
|
if not images:
|
|
console.print(f"No supported images found in {directory}")
|
|
return
|
|
|
|
console.print(f" Found {len(images)} images in {directory}")
|
|
console.print(f" Output -> {output_dir}")
|
|
console.print(f" Mode: {mode}")
|
|
from remove_ai_watermarks.api import InvisibleOptions
|
|
from remove_ai_watermarks.api import remove_batch as api_remove_batch
|
|
|
|
invisible_options = InvisibleOptions(
|
|
strength=strength,
|
|
pipeline=pipeline,
|
|
seed=seed,
|
|
hf_token=hf_token,
|
|
humanize=humanize,
|
|
unsharp=unsharp,
|
|
adaptive_polish=adaptive_polish,
|
|
max_resolution=max_resolution,
|
|
controlnet_conditioning_scale=controlnet_scale,
|
|
cpu_offload=cpu_offload,
|
|
tile=tile,
|
|
tile_size=tile_size,
|
|
tile_overlap=tile_overlap,
|
|
)
|
|
|
|
with Progress(
|
|
SpinnerColumn(),
|
|
TextColumn("[progress.description]{task.description}"),
|
|
BarColumn(),
|
|
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
|
TimeElapsedColumn(),
|
|
console=console,
|
|
) as progress:
|
|
task = progress.add_task("Processing...", total=len(images))
|
|
done: set[str] = set()
|
|
|
|
def on_progress(img: Path, stage: str, detail: str) -> None:
|
|
# `remove_batch` emits exactly one terminal stage per image in EVERY mode,
|
|
# so the bar advances on that and never on a mode-specific line.
|
|
progress.update(task, description=img.name)
|
|
if stage in ("done", "failed") and img.name not in done:
|
|
done.add(img.name)
|
|
progress.advance(task)
|
|
if ctx.obj.get("verbose"):
|
|
console.print(f" {img.name}: {stage}{f' {detail}' if detail else ''}")
|
|
|
|
summary = api_remove_batch(
|
|
directory,
|
|
output_dir,
|
|
mode=mode, # type: ignore[arg-type]
|
|
backend=backend, # type: ignore[arg-type]
|
|
sensitivity=_parse_sensitivity(sensitivity),
|
|
invisible=invisible_options,
|
|
force=force,
|
|
engine=_batch_engine(mode, invisible_options),
|
|
progress=on_progress,
|
|
)
|
|
progress.update(task, completed=len(images))
|
|
|
|
processed, errors = summary.processed, summary.failed
|
|
synthid_skipped_count = len(summary.invisible_unavailable)
|
|
|
|
if errors and ctx.obj.get("verbose"):
|
|
for failed_path, message in summary.errors:
|
|
console.print(f" {failed_path.name}: {message}")
|
|
|
|
console.print(f"\n {processed} processed" + (f" {errors} errors" if errors else ""))
|
|
|
|
if synthid_skipped_count:
|
|
# Mirror the single `all` command: a silently retained SynthID watermark is the
|
|
# #1 "it didn't work" report, so make the skipped scrub impossible to miss.
|
|
console.print(
|
|
f"\n WARNING: the invisible (SynthID) watermark was NOT removed on "
|
|
f"{synthid_skipped_count} image(s) -- the GPU dependencies are not installed, "
|
|
f"so those outputs still carry the invisible watermark.\n"
|
|
f" Install the extra and rerun: pip install {INVISIBLE_EXTRA}"
|
|
)
|
|
|
|
# Non-zero exit so a wrapping service detects an incomplete/failed run (batch used
|
|
# to always exit 0, hiding both per-image errors and skipped SynthID scrubs).
|
|
if errors or synthid_skipped_count:
|
|
raise SystemExit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|