-
Notifications
You must be signed in to change notification settings - Fork 514
Expand file tree
/
Copy pathcli.py
More file actions
1910 lines (1664 loc) · 72.8 KB
/
Copy pathcli.py
File metadata and controls
1910 lines (1664 loc) · 72.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""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 functools
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,
VISIBLE_EXTRA,
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 Callable, 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 _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
Panel = _panel
Table = _Table
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 every profile, 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; chroma-zimage = "
"the same face stage on an Apache-2.0 Chroma1 global pass; auto = pick the engine "
"from the provenance (chroma-zimage for Microsoft, qwen-zimage for "
"OpenAI/Google/Meta/unknown). "
"All 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()}.",
)
# Explicit strength-cohort override. Auto-detection reads the C2PA issuer, so it
# covers OpenAI / Google / Microsoft; Meta Content Seal has no provenance signal
# (no C2PA; the IPTC tag is a standard code), and an unknown or stripped manifest
# also leaves the resolution-adaptive curve in charge -- this flag is the way to
# name the cohort when the user knows what the file does not say.
_vendor_option = click.option(
"--vendor",
type=click.Choice(["auto", "openai", "google", "microsoft", "meta"]),
default="auto",
help=(
"Strength cohort for the invisible-removal default, and it implies the scrub "
"runs even without a local signal: naming the cohort asserts the pixel "
"watermark is present. auto: derive from C2PA provenance, else "
"resolution-adaptive. Set explicitly when the source is known but unreadable "
"(e.g. meta for Muse Image Content Seal, which never carries C2PA)."
),
)
def _explicit_vendor(vendor: str | None) -> str | None:
"""Normalize --vendor's ``auto`` default to None for the engine/API seam.
One helper so the three diffusion commands cannot drift on the spelling."""
return None if vendor in (None, "auto") else vendor
_seed_option = click.option(
"--seed",
type=int,
default=None,
help="Random seed for reproducibility. Default 0: all 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="Hugging Face 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 -- this package has no local "
"SynthID pixel decoder)."
),
)
_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. Forces the face stack to "
"offload instead of using automatic residency, on every profile. It reaches "
"the global stack of qwen-zimage only: chroma-zimage and sdxl-zimage keep "
"theirs resident either way."
),
)
_text_manifest_option = click.option(
"--text-manifest",
type=click.Path(exists=True, dir_okay=False, path_type=Path),
default=None,
help=(
"Experimental verified-text restoration manifest. Requires qwen-zimage or "
"chroma-zimage, the text-restoration extra, native untiled geometry, and no "
"postprocessing."
),
)
_fidelity_anchor_option = click.option(
"--fidelity-anchor/--no-fidelity-anchor",
default=False,
help=(
"With --text-manifest: blend 15% of the Qwen-VAE donor across the whole "
"frame. Off by default - the global blend was measured to return "
"detector-visible OpenAI SynthID on poster-scale manifests."
),
)
_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 _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,
display_source: Path | None = 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`.
`display_source` is the file whose decode produced `bgr`; its ICC profile and EXIF
orientation ride along into the re-encoded output (issue #98)."""
output.parent.mkdir(parents=True, exist_ok=True)
if not image_io.write_bgr_with_alpha(output, bgr, alpha, display_tags_from=display_source):
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: Google does not publish the SynthID payload decoder, and this package
does not ship one, so a mark can still be present after its metadata proxy
is gone. Write no output and exit :data:`EXIT_NO_INVISIBLE_SIGNAL`;
``--force`` runs the scrub regardless.
"""
console.print(
" No supported invisible AI watermark detected (no provenance or 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: this package has no local SynthID\n"
" pixel decoder. If you know the image is AI-generated and want the pixels\n"
" 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, plus metadata provenance marks, from images and 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 _print_visible_validation(status: str | None, marks: tuple[Any, ...], labels: str) -> None:
"""Render one visible pass consistently across ``visible`` and ``all``."""
if status == "partial":
residuals = [mark.label for mark in marks if mark.status == "partial"]
console.print(
f" Partial: fill completed for {labels}; overlapping residual still detected for {', '.join(residuals)}."
)
elif status == "unvalidated":
unavailable = [mark.label for mark in marks if mark.status == "unvalidated"]
console.print(f" Filled: {labels}")
console.print(f" Post-removal validation unavailable for: {', '.join(unavailable)}.")
elif status == "cleaned":
console.print(f" Removed and validated: {labels}")
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..."):
report = api.remove_visible_detailed(
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
result, removed = report.image, report.labels
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)
_print_visible_validation(report.status, report.marks, ", ".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, display_source=source)
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
@_pixels_required
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
@_pixels_required
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, display_source=source)
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
@_vendor_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
@_text_manifest_option
@_fidelity_anchor_option
@click.pass_context
def cmd_invisible(
ctx: click.Context,
source: Path,
output: Path | None,
strength: float | None,
vendor: str | 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,
text_manifest: Path | None,
fidelity_anchor: bool,
) -> None:
"""Attempt to disrupt invisible AI watermarks through pixel regeneration.
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")
# An explicit --vendor wins over detection (see the option help) and implies the
# scrub runs: naming the cohort asserts the pixel watermark is present, so the
# no-signal gate must not skip it. Resolved BEFORE the gate for the same reason.
resolved_vendor = _explicit_vendor(vendor)
# 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 and an
# explicit --vendor override.
if _should_skip_invisible_scrub(force or resolved_vendor is not None, 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. An explicit
# --vendor override wins over detection: it names a cohort the file cannot prove
# (Meta Content Seal never carries C2PA; a stripped manifest proves nothing).
detected_vendor = vendor_for_strength(source) if resolved_vendor is None else None
vendor_label = resolved_vendor or detected_vendor
vendor_note = " (override)" if resolved_vendor else ""
console.print(f" Input: {source.name}")
console.print(f" Pipeline: {pipeline}")
console.print(
f" Strength: {_resolved_strength_for_display(source, strength, vendor_label, pipeline)}"
+ (f" [vendor: {vendor_label}{vendor_note}]" if vendor_label else "")
)
t0 = time.monotonic()
try:
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_label,
tile=tile,
tile_size=tile_size,
tile_overlap=tile_overlap,
text_manifest=text_manifest,
fidelity_anchor=fidelity_anchor,
)
except (OSError, RuntimeError, ValueError) as exc:
console.print(f" Error: {exc}")
raise SystemExit(1) from exc
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_not_a_clean_verdict() -> None:
"""Repeat the identify empty-scan limit on metadata check and strip success.
``metadata --check`` and ``metadata --remove`` answer a narrower question than
``identify``: they report embedded AI metadata only. A quiet result used to
stop at "No AI metadata found" / "AI metadata stripped", which readers treat
as a clean-image verdict. The pixel channel is unchanged, and this project has
no local SynthID decoder, so the command must say so in the same words
``identify`` already uses.
"""
console.print(
" This is not the same as 'clean': a pixel watermark such as SynthID cannot be\n"
" detected here once its metadata proxy is absent."
)