static-ghost 0.4.0__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.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
static_ghost/cli.py ADDED
@@ -0,0 +1,261 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+ import shutil
6
+ import sys
7
+ import tempfile
8
+ from pathlib import Path
9
+
10
+ from static_ghost.video_engine import probe, extract_sample_frames, extract_all_frames, merge
11
+ from static_ghost.detector import Region, detect_static_regions, save_preview
12
+ from static_ghost.mask_generator import create_mask
13
+ from static_ghost.inpainter import run as run_inpaint, check_iopaint
14
+ from static_ghost.fast_inpaint import fast_remove, fast_remove_streamed
15
+
16
+
17
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
18
+ parser = argparse.ArgumentParser(prog="static-ghost", description="Remove static watermarks from videos")
19
+ sub = parser.add_subparsers(dest="command", required=True)
20
+
21
+ parent = argparse.ArgumentParser(add_help=False)
22
+ parent.add_argument("video", help="Path to input video file")
23
+ parent.add_argument("--threshold", type=int, default=15, help="Detection sensitivity (default: 15)")
24
+
25
+ rm = sub.add_parser("remove", parents=[parent], help="Detect and remove watermarks")
26
+ rm.add_argument("--region", action="append", default=[], help="Manual region x,y,w,h (repeatable)")
27
+ rm.add_argument("--pick", action="store_true", help="Open browser to draw watermark region")
28
+ rm.add_argument("--device", default="cpu", choices=["cpu", "mps"], help="Compute device")
29
+ rm.add_argument("--dilation", type=int, default=5, help="Mask dilation pixels (default: 5)")
30
+ rm.add_argument("--keep-temp", action="store_true", help="Keep temporary files")
31
+ rm.add_argument("--stream", action="store_true", help="Stream mode: ~95%% less disk usage (no full-frame extraction)")
32
+ rm.add_argument("-o", "--output", help="Output video path")
33
+
34
+ sub.add_parser("detect", parents=[parent], help="Detect watermarks only (no removal)")
35
+
36
+ pick = sub.add_parser("pick", parents=[parent], help="Open browser to draw watermark region")
37
+ pick.add_argument("--device", default="cpu", choices=["cpu", "mps"])
38
+ pick.add_argument("--dilation", type=int, default=5)
39
+ pick.add_argument("--keep-temp", action="store_true")
40
+ pick.add_argument("-o", "--output", help="Output video path")
41
+
42
+ return parser.parse_args(argv)
43
+
44
+
45
+ def _preflight(video_path: str) -> None:
46
+ for tool in ("ffmpeg", "ffprobe"):
47
+ if shutil.which(tool) is None:
48
+ print(f"Error: '{tool}' not found in PATH.", file=sys.stderr)
49
+ sys.exit(1)
50
+ if not os.path.isfile(video_path):
51
+ print(f"Error: Video file not found: {video_path}", file=sys.stderr)
52
+ sys.exit(1)
53
+ try:
54
+ probe(video_path)
55
+ except Exception as e:
56
+ print(f"Error: Cannot read video: {e}", file=sys.stderr)
57
+ sys.exit(1)
58
+
59
+
60
+ def _parse_region(region_str: str) -> Region:
61
+ parts = region_str.split(",")
62
+ if len(parts) != 4:
63
+ print(f"Error: Invalid region format '{region_str}'. Expected x,y,w,h", file=sys.stderr)
64
+ sys.exit(1)
65
+ x, y, w, h = (int(p.strip()) for p in parts)
66
+ return Region(x=x, y=y, w=w, h=h, confidence=1.0)
67
+
68
+
69
+ def _warn_disk_space(video_path: str, meta: dict) -> None:
70
+ fps = meta["fps"]
71
+ duration = meta["duration"]
72
+ frame_count = int(fps * duration)
73
+ avg_frame_bytes = meta["width"] * meta["height"] * 3
74
+ estimated_bytes = frame_count * avg_frame_bytes * 2
75
+
76
+ stat = os.statvfs(os.path.dirname(os.path.abspath(video_path)))
77
+ available = stat.f_bavail * stat.f_frsize
78
+
79
+ if estimated_bytes > available:
80
+ est_gb = estimated_bytes / (1024**3)
81
+ avail_gb = available / (1024**3)
82
+ print(f"Warning: Estimated disk usage {est_gb:.1f} GB, available {avail_gb:.1f} GB", file=sys.stderr)
83
+
84
+
85
+ def _get_regions_interactive(video_path: str, tmp_root: str, threshold: int, use_picker: bool) -> list[Region] | None:
86
+ """Get watermark regions via auto-detect, picker, or user confirmation."""
87
+ if use_picker:
88
+ from static_ghost.picker import pick_region
89
+ print("Extracting a sample frame...")
90
+ samples_dir = os.path.join(tmp_root, "samples")
91
+ sample_paths = extract_sample_frames(video_path, n=1, output_dir=samples_dir)
92
+ if not sample_paths:
93
+ print("Error: Could not extract sample frame.", file=sys.stderr)
94
+ return None
95
+ region = pick_region(sample_paths[0])
96
+ if region:
97
+ print(f"Selected: x={region.x}, y={region.y}, w={region.w}, h={region.h}")
98
+ return [region]
99
+ print("No region selected.")
100
+ return None
101
+
102
+ # Auto-detect
103
+ print("Extracting sample frames for detection...")
104
+ samples_dir = os.path.join(tmp_root, "samples")
105
+ sample_paths = extract_sample_frames(video_path, n=30, output_dir=samples_dir)
106
+
107
+ regions = detect_static_regions(sample_paths, threshold=threshold)
108
+ if not regions:
109
+ print("No static watermark regions detected.")
110
+ print("Try: --threshold 25-50, or use --pick to draw the region, or --region x,y,w,h")
111
+ return None
112
+
113
+ print(f"\nDetected {len(regions)} region(s):")
114
+ for i, r in enumerate(regions):
115
+ print(f" #{i+1}: x={r.x}, y={r.y}, w={r.w}, h={r.h} (confidence={r.confidence:.2f})")
116
+
117
+ preview_path = os.path.join(tmp_root, "preview.png")
118
+ mid = sample_paths[len(sample_paths) // 2]
119
+ save_preview(mid, regions, preview_path)
120
+ print(f"\nPreview: {preview_path}")
121
+ print("Open with: open " + preview_path)
122
+
123
+ answer = input("\nProceed with these regions? [Y/n/edit/pick] ").strip().lower()
124
+ if answer == "n":
125
+ return None
126
+ elif answer == "edit":
127
+ region_str = input("Enter regions as x,y,w,h (semicolon-separated): ").strip()
128
+ return [_parse_region(r.strip()) for r in region_str.split(";")]
129
+ elif answer == "pick":
130
+ from static_ghost.picker import pick_region
131
+ region = pick_region(sample_paths[len(sample_paths) // 2])
132
+ return [region] if region else None
133
+
134
+ return regions
135
+
136
+
137
+ def cmd_detect(args: argparse.Namespace) -> None:
138
+ _preflight(args.video)
139
+
140
+ print("Extracting sample frames...")
141
+ with tempfile.TemporaryDirectory() as tmp:
142
+ samples_dir = os.path.join(tmp, "samples")
143
+ sample_paths = extract_sample_frames(args.video, n=30, output_dir=samples_dir)
144
+ print(f"Extracted {len(sample_paths)} sample frames.")
145
+
146
+ regions = detect_static_regions(sample_paths, threshold=args.threshold)
147
+
148
+ if not regions:
149
+ print("No static watermark regions detected.")
150
+ print("Try: --threshold 25-50, or use 'static-ghost pick' to draw the region.")
151
+ return
152
+
153
+ print(f"\nDetected {len(regions)} region(s):")
154
+ for i, r in enumerate(regions):
155
+ print(f" #{i+1}: x={r.x}, y={r.y}, w={r.w}, h={r.h} (confidence={r.confidence:.2f})")
156
+
157
+ preview_path = os.path.join(os.getcwd(), "preview.png")
158
+ save_preview(sample_paths[len(sample_paths) // 2], regions, preview_path)
159
+ print(f"\nPreview saved: {preview_path}")
160
+
161
+
162
+ def cmd_remove(args: argparse.Namespace) -> None:
163
+ _preflight(args.video)
164
+ check_iopaint()
165
+ meta = probe(args.video)
166
+ _warn_disk_space(args.video, meta)
167
+
168
+ tmp_root = tempfile.mkdtemp(prefix="static_ghost_")
169
+
170
+ try:
171
+ # Get regions
172
+ if args.region:
173
+ regions = [_parse_region(r) for r in args.region]
174
+ print(f"Using {len(regions)} manual region(s).")
175
+ else:
176
+ use_picker = getattr(args, "pick", False)
177
+ regions = _get_regions_interactive(args.video, tmp_root, args.threshold, use_picker)
178
+ if not regions:
179
+ return
180
+
181
+ output_path = args.output or _default_output_path(args.video)
182
+
183
+ if getattr(args, "stream", False):
184
+ # Stream mode: no full-frame extraction, ~95% less disk
185
+ print(f"Stream mode: processing directly to {output_path}...")
186
+ try:
187
+ fast_remove_streamed(
188
+ args.video, output_path, regions,
189
+ dilation=args.dilation, device=args.device,
190
+ )
191
+ except Exception as e:
192
+ print(f"\nProcessing failed: {e}", file=sys.stderr)
193
+ args.keep_temp = True
194
+ return
195
+ else:
196
+ # Classic mode: extract all frames to disk
197
+ print("Generating mask...")
198
+ mask_path = os.path.join(tmp_root, "mask.png")
199
+ create_mask(meta["width"], meta["height"], regions, mask_path, dilation=args.dilation)
200
+
201
+ print("Extracting all frames (this may take a moment)...")
202
+ frames_dir = os.path.join(tmp_root, "frames")
203
+ frame_count = extract_all_frames(args.video, frames_dir)
204
+ print(f"Extracted {frame_count} frames.")
205
+
206
+ print("Running IOPaint LaMa inpainting...")
207
+ output_frames_dir = os.path.join(tmp_root, "output")
208
+ os.makedirs(output_frames_dir, exist_ok=True)
209
+ try:
210
+ fast_remove(
211
+ frames_dir, output_frames_dir, regions,
212
+ dilation=args.dilation, device=args.device,
213
+ )
214
+ except Exception as e:
215
+ print(f"\nIOPaint failed: {e}", file=sys.stderr)
216
+ print(f"Temp files preserved at: {tmp_root}", file=sys.stderr)
217
+ args.keep_temp = True
218
+ return
219
+
220
+ print(f"Merging to {output_path}...")
221
+ try:
222
+ merge(output_frames_dir, args.video, output_path)
223
+ except Exception as e:
224
+ print(f"\nFFmpeg merge failed: {e}", file=sys.stderr)
225
+ print(f"Inpainted frames at: {output_frames_dir}", file=sys.stderr)
226
+ args.keep_temp = True
227
+ return
228
+
229
+ print(f"Done! Output: {output_path}")
230
+
231
+ except Exception as e:
232
+ print(f"\nUnexpected error: {e}", file=sys.stderr)
233
+ print(f"Temp files preserved at: {tmp_root}", file=sys.stderr)
234
+ args.keep_temp = True
235
+ finally:
236
+ if not args.keep_temp:
237
+ shutil.rmtree(tmp_root, ignore_errors=True)
238
+ else:
239
+ print(f"Temp files kept at: {tmp_root}")
240
+
241
+
242
+ def cmd_pick(args: argparse.Namespace) -> None:
243
+ """Pick region interactively then run removal."""
244
+ args.region = []
245
+ args.pick = True
246
+ cmd_remove(args)
247
+
248
+
249
+ def _default_output_path(video_path: str) -> str:
250
+ p = Path(video_path)
251
+ return str(p.with_stem(p.stem + "_clean"))
252
+
253
+
254
+ def main() -> None:
255
+ args = parse_args()
256
+ if args.command == "detect":
257
+ cmd_detect(args)
258
+ elif args.command == "remove":
259
+ cmd_remove(args)
260
+ elif args.command == "pick":
261
+ cmd_pick(args)
@@ -0,0 +1,115 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import NamedTuple
4
+
5
+ import cv2
6
+ import numpy as np
7
+
8
+
9
+ class Region(NamedTuple):
10
+ x: int
11
+ y: int
12
+ w: int
13
+ h: int
14
+ confidence: float
15
+
16
+
17
+ def detect_static_regions(
18
+ sample_paths: list[str],
19
+ threshold: int = 15,
20
+ *,
21
+ num_pairs: int = 50,
22
+ min_area: int = 100,
23
+ max_area_ratio: float = 0.3,
24
+ quorum: float = 0.7,
25
+ ) -> list[Region]:
26
+ """Detect static regions via multi-frame differencing.
27
+
28
+ A pixel is considered "static" if it stays below `threshold` difference
29
+ in at least `quorum` fraction of frame pairs (e.g., 0.7 = 70%).
30
+ This tolerates scene changes and lighting variations in real videos.
31
+
32
+ Returns regions sorted by area (largest first).
33
+ """
34
+ frames_gray = [cv2.imread(p, cv2.IMREAD_GRAYSCALE) for p in sample_paths]
35
+ if not frames_gray or frames_gray[0] is None:
36
+ return []
37
+ h, w = frames_gray[0].shape
38
+
39
+ rng = np.random.default_rng(0)
40
+ n = len(frames_gray)
41
+ max_possible = n * (n - 1) // 2
42
+ num_pairs = min(num_pairs, max_possible)
43
+
44
+ # Generate unique pairs efficiently
45
+ if num_pairs >= max_possible * 0.5:
46
+ # If we need most pairs, enumerate all and sample
47
+ all_possible = [(i, j) for i in range(n) for j in range(i + 1, n)]
48
+ indices = rng.choice(len(all_possible), size=num_pairs, replace=False)
49
+ pairs = [all_possible[k] for k in indices]
50
+ else:
51
+ pairs_set: set[tuple[int, int]] = set()
52
+ while len(pairs_set) < num_pairs:
53
+ batch = rng.choice(n, size=(num_pairs * 2, 2), replace=True)
54
+ for row in batch:
55
+ if row[0] != row[1]:
56
+ pairs_set.add((min(row[0], row[1]), max(row[0], row[1])))
57
+ if len(pairs_set) >= num_pairs:
58
+ break
59
+ pairs = list(pairs_set)[:num_pairs]
60
+
61
+ static_counts = np.zeros((h, w), dtype=np.int32)
62
+ total_pairs = len(pairs)
63
+
64
+ for i, j in pairs:
65
+ diff = cv2.absdiff(frames_gray[i], frames_gray[j])
66
+ static_counts += (diff < threshold).view(np.uint8)
67
+
68
+ # Majority vote: pixel is static if it passed in >= quorum of pairs
69
+ min_votes = int(total_pairs * quorum)
70
+ global_static = (static_counts >= min_votes).view(np.uint8) * 255
71
+
72
+ # Morphological cleanup: close small gaps, remove noise
73
+ kernel_close = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))
74
+ global_static = cv2.morphologyEx(global_static, cv2.MORPH_CLOSE, kernel_close)
75
+ kernel_open = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
76
+ global_static = cv2.morphologyEx(global_static, cv2.MORPH_OPEN, kernel_open)
77
+
78
+ contours, _ = cv2.findContours(global_static, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
79
+
80
+ total_area = h * w
81
+ regions = []
82
+ for cnt in contours:
83
+ area = cv2.contourArea(cnt)
84
+ if area < min_area:
85
+ continue
86
+ if area / total_area > max_area_ratio:
87
+ continue
88
+ bx, by, bw, bh = cv2.boundingRect(cnt)
89
+ # Use bounding rect ROI instead of full-frame mask for confidence calc
90
+ roi = static_counts[by:by + bh, bx:bx + bw]
91
+ roi_mask = np.zeros((bh, bw), dtype=np.uint8)
92
+ shifted_cnt = cnt - np.array([bx, by])
93
+ cv2.drawContours(roi_mask, [shifted_cnt], -1, 255, -1)
94
+ roi_pixels = roi_mask > 0
95
+ avg_votes = roi[roi_pixels].mean() if roi_pixels.any() else 0
96
+ confidence = round(avg_votes / total_pairs, 2)
97
+ regions.append(Region(x=bx, y=by, w=bw, h=bh, confidence=confidence))
98
+
99
+ regions.sort(key=lambda r: r.w * r.h, reverse=True)
100
+ return regions
101
+
102
+
103
+ def save_preview(
104
+ frame_path: str,
105
+ regions: list[Region],
106
+ output_path: str,
107
+ ) -> str:
108
+ """Draw red bounding boxes + labels on frame, save as preview PNG."""
109
+ frame = cv2.imread(frame_path)
110
+ for i, r in enumerate(regions):
111
+ cv2.rectangle(frame, (r.x, r.y), (r.x + r.w, r.y + r.h), (0, 0, 255), 2)
112
+ label = f"#{i+1} ({r.x},{r.y},{r.w}x{r.h}) conf={r.confidence:.2f}"
113
+ cv2.putText(frame, label, (r.x, r.y - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.4, (0, 0, 255), 1)
114
+ cv2.imwrite(output_path, frame)
115
+ return output_path