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.
- static_ghost/__init__.py +1 -0
- static_ghost/cli.py +261 -0
- static_ghost/detector.py +115 -0
- static_ghost/fast_inpaint.py +354 -0
- static_ghost/inpainter.py +33 -0
- static_ghost/mask_generator.py +34 -0
- static_ghost/picker.py +118 -0
- static_ghost/video_engine.py +123 -0
- static_ghost-0.4.0.dist-info/METADATA +308 -0
- static_ghost-0.4.0.dist-info/RECORD +14 -0
- static_ghost-0.4.0.dist-info/WHEEL +5 -0
- static_ghost-0.4.0.dist-info/entry_points.txt +2 -0
- static_ghost-0.4.0.dist-info/licenses/LICENSE +21 -0
- static_ghost-0.4.0.dist-info/top_level.txt +1 -0
static_ghost/__init__.py
ADDED
|
@@ -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)
|
static_ghost/detector.py
ADDED
|
@@ -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
|