demark 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
demark/__init__.py ADDED
@@ -0,0 +1,45 @@
1
+ """
2
+ DeMark — Automatic image watermark detection and removal toolkit.
3
+
4
+ Implements multiple methods from classic CV to deep learning, so the user
5
+ can pick whichever suits the watermark type (semi-transparent logo, tiled
6
+ repeating pattern, solid-color text, known template, etc.).
7
+
8
+ Public API:
9
+ remove_watermark — auto-detect + remove (method auto or specified)
10
+ detect_watermark — detect watermark mask only
11
+ Methods enum — all available removal strategies
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from .__version__ import __version__
16
+
17
+ __all__ = [
18
+ "__version__",
19
+ "ToolResult",
20
+ "remove_watermark",
21
+ "detect_watermark",
22
+ "Methods",
23
+ "harmonic_loss",
24
+ "coordinate_descent",
25
+ "grid_search",
26
+ ]
27
+
28
+
29
+ def __getattr__(name: str):
30
+ if name == "ToolResult":
31
+ from .api import ToolResult
32
+ return ToolResult
33
+ if name == "remove_watermark":
34
+ from .api import remove_watermark
35
+ return remove_watermark
36
+ if name == "detect_watermark":
37
+ from .api import detect_watermark
38
+ return detect_watermark
39
+ if name == "Methods":
40
+ from .core import Methods
41
+ return Methods
42
+ if name in ("harmonic_loss", "coordinate_descent", "grid_search"):
43
+ from . import harmonic
44
+ return getattr(harmonic, name)
45
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
demark/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
demark/__version__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.0.1"
demark/api.py ADDED
@@ -0,0 +1,227 @@
1
+ """
2
+ Unified Python API for DeMark.
3
+
4
+ Every function returns ToolResult so callers never need try/except.
5
+ Path inputs accept both str and pathlib.Path.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import time
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import Any, Optional
14
+
15
+ from .__version__ import __version__
16
+
17
+
18
+ @dataclass
19
+ class ToolResult:
20
+ success: bool
21
+ data: Any = None
22
+ error: Optional[str] = None
23
+ metadata: dict = field(default_factory=dict)
24
+
25
+ def to_dict(self) -> dict:
26
+ return {
27
+ "success": self.success,
28
+ "data": self.data,
29
+ "error": self.error,
30
+ "metadata": self.metadata,
31
+ }
32
+
33
+
34
+ def remove_watermark(
35
+ *,
36
+ input_path: str,
37
+ output_path: str,
38
+ method: str = "auto",
39
+ mask_path: Optional[str] = None,
40
+ template_path: Optional[str] = None,
41
+ watermark_path: Optional[str] = None,
42
+ watermark_text: Optional[str] = None,
43
+ region: str = "bottom-right",
44
+ ratio: float = 0.3,
45
+ alpha: Optional[float] = None,
46
+ inpaint_radius: int = 5,
47
+ bbox: Optional[tuple] = None,
48
+ char_bboxes: Optional[list] = None,
49
+ scale_levels: int = 3,
50
+ harmonic_auto: bool = True,
51
+ ) -> ToolResult:
52
+ """Remove watermark from an image and save the result.
53
+
54
+ Args:
55
+ input_path: Path to input image (jpg/png/bmp/webp).
56
+ output_path: Path to save the de-watermarked image.
57
+ method: Removal strategy — one of: auto, cv_telea, cv_ns,
58
+ fft_notch, color_mask, edge_inpaint, template,
59
+ alpha_invert, lama, sd_inpaint, fuse, background_aware,
60
+ precise. ``precise`` requires ``bbox`` (x1,y1,x2,y2).
61
+ mask_path: Optional pre-made mask image (white=watermark).
62
+ If None, DeMark auto-detects.
63
+ template_path: Path to watermark template image (for method=template).
64
+ watermark_path: Path to watermark overlay image (for method=alpha_invert).
65
+ watermark_text: Watermark text content (e.g. "千问AI生成") for text-based
66
+ detection. DeMark renders the text and template-matches it.
67
+ region: Region hint for detection — "bottom-right", "full", "bottom", etc.
68
+ ratio: Region size fraction (0.3 = bottom 30%).
69
+ alpha: Alpha transparency value 0-1 (for method=alpha_invert).
70
+ If None, auto-estimated.
71
+ inpaint_radius: Inpaint radius in pixels (for CV methods).
72
+
73
+ Returns:
74
+ ToolResult with data={"method": str, "output": str, "elapsed": float}
75
+ and metadata={"version": str}.
76
+
77
+ >>> from demark import remove_watermark
78
+ >>> r = remove_watermark(input_path="watermarked.jpg", output_path="clean.jpg")
79
+ >>> r.success
80
+ True
81
+ >>> r.data["method"]
82
+ 'edge_inpaint'
83
+ """
84
+ try:
85
+ import cv2
86
+ import numpy as np
87
+ from .core import Methods, remove, detect_watermark_smart, detect_auto
88
+
89
+ inp = str(Path(input_path).resolve())
90
+ outp = str(Path(output_path).resolve())
91
+
92
+ if not os.path.exists(inp):
93
+ return ToolResult(success=False, error=f"Input not found: {inp}",
94
+ metadata={"version": __version__})
95
+
96
+ img = cv2.imread(inp)
97
+ if img is None:
98
+ return ToolResult(success=False, error=f"Cannot read image: {inp}",
99
+ metadata={"version": __version__})
100
+
101
+ mask = None
102
+ if mask_path and os.path.exists(mask_path):
103
+ mask = cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE)
104
+ elif method == "auto" or method in ("cv_telea", "cv_ns", "edge_inpaint",
105
+ "color_mask", "fft_notch"):
106
+ # smart detection: text-based if watermark_text given, else region
107
+ mask = detect_watermark_smart(img, watermark_text, region, ratio)
108
+
109
+ # build kwargs for the specific method
110
+ kwargs = {}
111
+ if method == "template" and template_path:
112
+ kwargs["template_path"] = template_path
113
+ if method == "alpha_invert":
114
+ if watermark_path:
115
+ kwargs["watermark_img"] = watermark_path
116
+ if alpha is not None:
117
+ kwargs["alpha"] = alpha
118
+ if method in ("cv_telea", "cv_ns", "color_mask", "edge_inpaint", "template"):
119
+ kwargs["inpaint_radius"] = inpaint_radius
120
+ if method == "precise":
121
+ if bbox is None:
122
+ return ToolResult(success=False,
123
+ error="precise method requires bbox=(x1,y1,x2,y2) pixel coords",
124
+ metadata={"version": __version__})
125
+ kwargs["bbox"] = bbox
126
+ if char_bboxes is not None:
127
+ kwargs["char_bboxes"] = char_bboxes
128
+ if method == "lama_precise":
129
+ if bbox is None:
130
+ return ToolResult(success=False,
131
+ error="lama_precise method requires bbox=(x1,y1,x2,y2) pixel coords",
132
+ metadata={"version": __version__})
133
+ kwargs["bbox"] = bbox
134
+ if method == "harmonic":
135
+ if bbox is None:
136
+ return ToolResult(success=False,
137
+ error="harmonic method requires bbox=(x1,y1,x2,y2) pixel coords",
138
+ metadata={"version": __version__})
139
+ kwargs["bbox"] = bbox
140
+ kwargs["scale_levels"] = scale_levels
141
+ kwargs["auto"] = harmonic_auto
142
+
143
+ try:
144
+ method_enum = Methods(method)
145
+ except ValueError:
146
+ return ToolResult(success=False,
147
+ error=f"Unknown method: {method}. Valid: {[m.value for m in Methods]}",
148
+ metadata={"version": __version__})
149
+
150
+ result = remove(img, method_enum, mask=mask, **kwargs)
151
+
152
+ os.makedirs(os.path.dirname(outp) or ".", exist_ok=True)
153
+ cv2.imwrite(outp, result.image)
154
+
155
+ # also save the mask alongside output for inspection
156
+ mask_out = outp.rsplit(".", 1)[0] + "_mask.png"
157
+ cv2.imwrite(mask_out, result.mask)
158
+
159
+ return ToolResult(
160
+ success=True,
161
+ data={
162
+ "method": result.method,
163
+ "output": outp,
164
+ "mask": mask_out,
165
+ "elapsed": round(result.elapsed, 3),
166
+ },
167
+ metadata={"version": __version__, "image_size": img.shape[:2]},
168
+ )
169
+ except Exception as e:
170
+ return ToolResult(success=False, error=str(e),
171
+ metadata={"version": __version__})
172
+
173
+
174
+ def detect_watermark(
175
+ *,
176
+ input_path: str,
177
+ output_path: str,
178
+ method: str = "auto",
179
+ ) -> ToolResult:
180
+ """Detect watermark mask and save it as a binary image.
181
+
182
+ Args:
183
+ input_path: Path to input image.
184
+ output_path: Path to save the mask (white=watermark, black=clean).
185
+ method: Detection method — auto, edge, color, or otsu.
186
+
187
+ Returns:
188
+ ToolResult with data={"output": str, "coverage": float}.
189
+
190
+ >>> from demark import detect_watermark
191
+ >>> r = detect_watermark(input_path="img.jpg", output_path="mask.png")
192
+ >>> r.success
193
+ True
194
+ >>> r.data["coverage"] < 1.0
195
+ True
196
+ """
197
+ try:
198
+ import cv2
199
+ import numpy as np
200
+ from .core import detect_only
201
+
202
+ inp = str(Path(input_path).resolve())
203
+ outp = str(Path(output_path).resolve())
204
+
205
+ if not os.path.exists(inp):
206
+ return ToolResult(success=False, error=f"Input not found: {inp}",
207
+ metadata={"version": __version__})
208
+
209
+ img = cv2.imread(inp)
210
+ if img is None:
211
+ return ToolResult(success=False, error=f"Cannot read image: {inp}",
212
+ metadata={"version": __version__})
213
+
214
+ mask = detect_only(img, method)
215
+ os.makedirs(os.path.dirname(outp) or ".", exist_ok=True)
216
+ cv2.imwrite(outp, mask)
217
+
218
+ coverage = float(np.count_nonzero(mask)) / mask.size
219
+
220
+ return ToolResult(
221
+ success=True,
222
+ data={"output": outp, "coverage": round(coverage, 4)},
223
+ metadata={"version": __version__, "image_size": img.shape[:2]},
224
+ )
225
+ except Exception as e:
226
+ return ToolResult(success=False, error=str(e),
227
+ metadata={"version": __version__})
demark/cli.py ADDED
@@ -0,0 +1,188 @@
1
+ """
2
+ CLI for DeMark with unified flags and remove/detect subcommands.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import logging
9
+ import sys
10
+
11
+ from .__version__ import __version__
12
+
13
+ log = logging.getLogger("demark")
14
+
15
+
16
+ def build_parser() -> argparse.ArgumentParser:
17
+ parser = argparse.ArgumentParser(
18
+ prog="demark",
19
+ description="Automatic image watermark detection and removal toolkit.",
20
+ formatter_class=argparse.RawDescriptionHelpFormatter,
21
+ epilog="""
22
+ Examples:
23
+ demark remove watermarked.jpg clean.jpg
24
+ demark remove watermarked.jpg clean.jpg --method fft_notch
25
+ demark remove watermarked.jpg clean.jpg --method template --template logo.png
26
+ demark remove watermarked.jpg clean.jpg --method alpha_invert --watermark wm.png --alpha 0.4
27
+ demark detect watermarked.jpg mask.png
28
+ demark --json remove watermarked.jpg clean.jpg
29
+ """,
30
+ )
31
+ parser.add_argument("-V", "--version", action="version", version=f"demark {__version__}")
32
+ parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
33
+ parser.add_argument("-o", "--output", type=str, default=None, help="Output path (for JSON results)")
34
+ parser.add_argument("--json", action="store_true", dest="json_output", help="Output results as JSON")
35
+ parser.add_argument("-q", "--quiet", action="store_true", help="Suppress non-essential output")
36
+
37
+ subparsers = parser.add_subparsers(dest="command", required=True)
38
+
39
+ rm_p = subparsers.add_parser("remove", help="Remove watermark from image")
40
+ rm_p.add_argument("input", help="Input image path")
41
+ rm_p.add_argument("output", help="Output image path")
42
+ rm_p.add_argument("--method", type=str, default="auto",
43
+ choices=[m.value for m in __import__("demark.core", fromlist=["Methods"]).Methods],
44
+ help="Removal method (default: auto)")
45
+ rm_p.add_argument("--mask", type=str, default=None, help="Pre-made mask image (white=watermark)")
46
+ rm_p.add_argument("--template", type=str, default=None, help="Watermark template image (for method=template)")
47
+ rm_p.add_argument("--watermark", type=str, default=None, help="Watermark overlay image (for method=alpha_invert)")
48
+ rm_p.add_argument("--region", type=str, default="bottom-right",
49
+ choices=["bottom-right", "bottom-left", "top-right", "top-left", "bottom", "top", "full"],
50
+ help="Region to detect watermark in (default: bottom-right)")
51
+ rm_p.add_argument("--ratio", type=float, default=0.3, help="Region size fraction (default 0.3)")
52
+ rm_p.add_argument("--bbox", type=int, nargs=4, default=None,
53
+ metavar=("X1","Y1","X2","Y2"),
54
+ help="Precise pixel bbox for --method precise (e.g. 1056 1249 1304 1305)")
55
+ rm_p.add_argument("--watermark-text", type=str, default="千问AI生成",
56
+ help="Watermark text content (default: 千问AI生成)")
57
+ rm_p.add_argument("--char-widths", type=int, nargs='+', default=None,
58
+ metavar="W",
59
+ help="Width in pixels of each character, e.g. --char-widths 50 48 35 12 48 48")
60
+ rm_p.add_argument("--alpha", type=float, default=None, help="Alpha transparency 0-1 (for method=alpha_invert)")
61
+ rm_p.add_argument("--radius", type=int, default=5, help="Inpaint radius in pixels (default 5)")
62
+ rm_p.add_argument("--scale-levels", type=int, default=3,
63
+ help="Pyramid levels for method=harmonic (default 3; 1 disables multi-scale)")
64
+ rm_p.add_argument("--no-harmonic-auto", action="store_true",
65
+ help="Disable auto-optimisation for method=harmonic (uses v57 defaults)")
66
+
67
+ det_p = subparsers.add_parser("detect", help="Detect watermark mask only")
68
+ det_p.add_argument("input", help="Input image path")
69
+ det_p.add_argument("output", help="Output mask path")
70
+ det_p.add_argument("--method", type=str, default="auto",
71
+ choices=["auto", "edge", "color", "otsu"],
72
+ help="Detection method (default: auto)")
73
+
74
+ subparsers.add_parser("list-methods", help="List all available removal methods")
75
+
76
+ return parser
77
+
78
+
79
+ def setup_logging(verbose: bool, quiet: bool):
80
+ level = logging.WARNING if quiet else (logging.DEBUG if verbose else logging.INFO)
81
+ logging.basicConfig(level=level, format="%(levelname)s: %(message)s")
82
+
83
+
84
+ def main():
85
+ parser = build_parser()
86
+ args = parser.parse_args()
87
+ setup_logging(args.verbose, args.quiet)
88
+
89
+ from . import api
90
+ from .core import Methods
91
+
92
+ try:
93
+ if args.command == "remove":
94
+ # Auto-build char_bboxes from --watermark-text + --char-widths (if precise)
95
+ char_bboxes = None
96
+ if args.method in ("precise", "lama_precise") and args.bbox and args.watermark_text:
97
+ bx1, by1, bx2, by2 = args.bbox
98
+ if args.char_widths and len(args.char_widths) == len(args.watermark_text):
99
+ xs = bx1
100
+ char_bboxes = []
101
+ for w in args.char_widths:
102
+ char_bboxes.append((xs, by1, xs + w, by2))
103
+ xs += w
104
+ else:
105
+ defaults = {"千问AI生成": [50, 48, 35, 12, 48, 48]}
106
+ if args.watermark_text in defaults:
107
+ xs = bx1
108
+ char_bboxes = []
109
+ for w in defaults[args.watermark_text]:
110
+ char_bboxes.append((xs, by1, xs + w, by2))
111
+ xs += w
112
+ result = api.remove_watermark(
113
+ input_path=args.input,
114
+ output_path=args.output,
115
+ method=args.method,
116
+ mask_path=args.mask,
117
+ template_path=args.template,
118
+ watermark_path=args.watermark,
119
+ watermark_text=args.watermark_text,
120
+ region=args.region,
121
+ ratio=args.ratio,
122
+ alpha=args.alpha,
123
+ inpaint_radius=args.radius,
124
+ bbox=tuple(args.bbox) if args.bbox else None,
125
+ char_bboxes=char_bboxes,
126
+ scale_levels=args.scale_levels,
127
+ harmonic_auto=not args.no_harmonic_auto,
128
+ )
129
+ elif args.command == "detect":
130
+ result = api.detect_watermark(
131
+ input_path=args.input,
132
+ output_path=args.output,
133
+ method=args.method,
134
+ )
135
+ elif args.command == "list-methods":
136
+ methods_info = []
137
+ for m in Methods:
138
+ desc = {
139
+ "auto": "Auto-detect + best method selection",
140
+ "cv_telea": "OpenCV Telea fast-marching inpaint [6]",
141
+ "cv_ns": "OpenCV Navier-Stokes inpaint [7]",
142
+ "fft_notch": "FFT notch filtering for repeating watermarks",
143
+ "color_mask": "HSV color segmentation + inpaint",
144
+ "edge_inpaint": "Canny edge + morphological dilation + inpaint [5]",
145
+ "template": "Template matching for known watermark + inpaint",
146
+ "alpha_invert": "Alpha matting inversion (lossless if W+alpha known) [8]",
147
+ "lama": "LaMa deep learning inpainting [1]",
148
+ "sd_inpaint": "Stable Diffusion inpainting (generative fill)",
149
+ "fuse": "Multi-method fusion + iterative optimization (best quality)",
150
+ "precise": "Column-interp background + alpha-inversion (needs --bbox)",
151
+ "harmonic": "Scale-adaptive multi-scale + reference-free auto-optimisation (needs --bbox)",
152
+ }
153
+ methods_info.append(f" {m.value:15s} — {desc.get(m.value, '')}")
154
+ print("\n".join(methods_info))
155
+ sys.exit(0)
156
+ else:
157
+ parser.print_help()
158
+ sys.exit(2)
159
+
160
+ if args.json_output:
161
+ print(json.dumps(result.to_dict(), ensure_ascii=False, indent=2))
162
+ elif result.success:
163
+ if args.command == "remove":
164
+ d = result.data
165
+ print(f"Removed watermark ({d['method']}) → {d['output']}")
166
+ print(f" Mask: {d['mask']}")
167
+ print(f" Time: {d['elapsed']}s")
168
+ elif args.command == "detect":
169
+ d = result.data
170
+ print(f"Detected mask → {d['output']}")
171
+ print(f" Coverage: {d['coverage']*100:.1f}%")
172
+ else:
173
+ print(f"Error: {result.error}", file=sys.stderr)
174
+ sys.exit(1)
175
+
176
+ if args.output and result.success and args.json_output:
177
+ with open(args.output, "w") as f:
178
+ json.dump(result.to_dict(), f, ensure_ascii=False, indent=2)
179
+
180
+ sys.exit(0)
181
+ except KeyboardInterrupt:
182
+ sys.exit(1)
183
+ except Exception as e:
184
+ if args.json_output:
185
+ print(json.dumps({"success": False, "error": str(e)}, ensure_ascii=False))
186
+ else:
187
+ print(f"Error: {e}", file=sys.stderr)
188
+ sys.exit(1)