dynamo-figures 0.3.2__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.
- dynamo_figures/__init__.py +13 -0
- dynamo_figures/__main__.py +6 -0
- dynamo_figures/blur_faces/__init__.py +18 -0
- dynamo_figures/blur_faces/__main__.py +6 -0
- dynamo_figures/blur_faces/cli.py +150 -0
- dynamo_figures/blur_faces/core.py +277 -0
- dynamo_figures/blur_faces/detectors.py +220 -0
- dynamo_figures/blur_faces/models/face_detection_yunet_2023mar.onnx +0 -0
- dynamo_figures/blur_faces/obscure.py +115 -0
- dynamo_figures/blur_faces/tracking.py +125 -0
- dynamo_figures/blur_faces/video_io.py +153 -0
- dynamo_figures/composite_image.py +233 -0
- dynamo_figures/pic_from_video.py +217 -0
- dynamo_figures/qr_code.py +349 -0
- dynamo_figures/tex2img.py +409 -0
- dynamo_figures/video_to_gif.py +453 -0
- dynamo_figures-0.3.2.dist-info/METADATA +139 -0
- dynamo_figures-0.3.2.dist-info/RECORD +21 -0
- dynamo_figures-0.3.2.dist-info/WHEEL +5 -0
- dynamo_figures-0.3.2.dist-info/entry_points.txt +7 -0
- dynamo_figures-0.3.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""
|
|
2
|
+
dynamo_figures - A Python package for dynamic figure generation
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
__version__ = "0.3.2"
|
|
6
|
+
|
|
7
|
+
from dynamo_figures.composite_image import CompositeImage, CompositeMode
|
|
8
|
+
from dynamo_figures.video_to_gif import VideoToGif
|
|
9
|
+
from dynamo_figures.qr_code import QRCode
|
|
10
|
+
from dynamo_figures.blur_faces import FaceBlur
|
|
11
|
+
from dynamo_figures.tex2img import TexToImage
|
|
12
|
+
|
|
13
|
+
__all__ = ["CompositeImage", "CompositeMode", "VideoToGif", "QRCode", "FaceBlur", "TexToImage", "__version__"]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Package: dynamo_figures.blur_faces
|
|
3
|
+
Description: Anonymize faces in photos and videos, fully locally.
|
|
4
|
+
|
|
5
|
+
Modules:
|
|
6
|
+
core FaceBlur, the high-level image/video API
|
|
7
|
+
detectors YuNet detection on CPU (OpenCV) or GPU (ONNX Runtime)
|
|
8
|
+
tracking Frame-to-frame box smoothing and hold for videos
|
|
9
|
+
obscure Blur / pixelate / fill rendering
|
|
10
|
+
video_io Threaded frame reading and ffmpeg encoding
|
|
11
|
+
cli The dynamo-blur-faces command-line tool
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from dynamo_figures.blur_faces.core import FaceBlur
|
|
15
|
+
from dynamo_figures.blur_faces.detectors import gpu_provider
|
|
16
|
+
from dynamo_figures.blur_faces.tracking import FaceTracker
|
|
17
|
+
|
|
18
|
+
__all__ = ["FaceBlur", "FaceTracker", "gpu_provider"]
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""
|
|
2
|
+
File: cli.py
|
|
3
|
+
Description: Command-line interface for the dynamo-blur-faces tool.
|
|
4
|
+
Blurs, pixelates, or covers faces in a photo or video, fully locally.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from dynamo_figures.blur_faces.core import FaceBlur
|
|
12
|
+
|
|
13
|
+
IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.bmp', '.tif', '.tiff', '.webp'}
|
|
14
|
+
VIDEO_EXTENSIONS = {'.mp4', '.mov', '.avi', '.mkv', '.m4v', '.webm', '.wmv'}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main():
|
|
18
|
+
"""Main function for command-line interface."""
|
|
19
|
+
parser = argparse.ArgumentParser(
|
|
20
|
+
prog='dynamo-blur-faces',
|
|
21
|
+
description='Blur, pixelate, or cover faces in a photo or video. Runs fully locally.',
|
|
22
|
+
epilog='-'
|
|
23
|
+
)
|
|
24
|
+
parser.add_argument('--input', type=str, required=True,
|
|
25
|
+
help='path of input image or video file.')
|
|
26
|
+
parser.add_argument('--output', type=str, default=None,
|
|
27
|
+
help='output file path (default: same directory as input with _blurred suffix)')
|
|
28
|
+
parser.add_argument('--style', default='blur', choices=FaceBlur.STYLES,
|
|
29
|
+
help='how to obscure faces (default: blur)')
|
|
30
|
+
parser.add_argument('--shape', default='ellipse', choices=FaceBlur.SHAPES,
|
|
31
|
+
help='shape of the obscured region (default: ellipse)')
|
|
32
|
+
parser.add_argument('--padding', type=float, default=0.25,
|
|
33
|
+
help='fraction to enlarge each face box on every side (default: 0.25)')
|
|
34
|
+
parser.add_argument('--blur_strength', type=float, default=0.5,
|
|
35
|
+
help='blur kernel size as a fraction of face size, for --style blur (default: 0.5)')
|
|
36
|
+
parser.add_argument('--pixel_blocks', type=int, default=10,
|
|
37
|
+
help='number of mosaic blocks across each face, for --style pixelate (default: 10)')
|
|
38
|
+
parser.add_argument('--fill_color', type=str, default='black',
|
|
39
|
+
help='color name or hex code, for --style fill (default: black)')
|
|
40
|
+
parser.add_argument('--score_threshold', type=float, default=0.6,
|
|
41
|
+
help='minimum detection confidence 0..1; lower catches more faces but more false positives (default: 0.6)')
|
|
42
|
+
parser.add_argument('--nms_threshold', type=float, default=0.3,
|
|
43
|
+
help='overlap threshold for merging duplicate detections (default: 0.3)')
|
|
44
|
+
parser.add_argument('--detect_max_dim', type=int, default=2048,
|
|
45
|
+
help='downscale so the longest side is at most this before detection; '
|
|
46
|
+
'raise for tiny faces, lower for speed, 0 = full resolution (default: 2048)')
|
|
47
|
+
parser.add_argument('--device', default='auto', choices=FaceBlur.DEVICES,
|
|
48
|
+
help='detection device: gpu needs onnxruntime (CoreML) or onnxruntime-gpu (CUDA); '
|
|
49
|
+
'auto uses the GPU when available (default: auto)')
|
|
50
|
+
parser.add_argument('--batch_size', type=int, default=8,
|
|
51
|
+
help='video only: frames detected per batch (default: 8)')
|
|
52
|
+
parser.add_argument('--workers', type=int, default=None,
|
|
53
|
+
help='video only: CPU threads for detection/blurring (default: number of cores)')
|
|
54
|
+
parser.add_argument('--hold_frames', type=int, default=5,
|
|
55
|
+
help='video only: keep obscuring a face for this many frames after it is lost (default: 5)')
|
|
56
|
+
parser.add_argument('--smoothing', type=float, default=0.5,
|
|
57
|
+
help='video only: box smoothing between frames, 0..1 (0 = off; higher is steadier '
|
|
58
|
+
'but lags; the raw detection is always covered) (default: 0.5)')
|
|
59
|
+
parser.add_argument('--start_t', type=float, default=None,
|
|
60
|
+
help='video only: trim the output to start this many seconds into the input '
|
|
61
|
+
'(default: start of video)')
|
|
62
|
+
parser.add_argument('--end_t', type=float, default=None,
|
|
63
|
+
help='video only: trim the output to end this many seconds into the input '
|
|
64
|
+
'(default: end of video)')
|
|
65
|
+
parser.add_argument('--no_audio', action='store_true',
|
|
66
|
+
help='video only: drop the audio track')
|
|
67
|
+
parser.add_argument('--crf', type=int, default=18,
|
|
68
|
+
help='video only: H.264 quality, lower is better (default: 18)')
|
|
69
|
+
parser.add_argument('--draw_boxes', action='store_true',
|
|
70
|
+
help='draw detection boxes and scores instead of obscuring (for tuning)')
|
|
71
|
+
parser.add_argument('--disable_pbar', action='store_true',
|
|
72
|
+
help='disable progress bar when processing videos')
|
|
73
|
+
|
|
74
|
+
args = parser.parse_args()
|
|
75
|
+
|
|
76
|
+
input_path = Path(args.input)
|
|
77
|
+
if not input_path.exists():
|
|
78
|
+
print(f"Error: Input file '{input_path}' not found.")
|
|
79
|
+
sys.exit(1)
|
|
80
|
+
|
|
81
|
+
ext = input_path.suffix.lower()
|
|
82
|
+
if ext in IMAGE_EXTENSIONS:
|
|
83
|
+
is_video = False
|
|
84
|
+
elif ext in VIDEO_EXTENSIONS:
|
|
85
|
+
is_video = True
|
|
86
|
+
else:
|
|
87
|
+
print(f"Error: Unsupported file type '{ext}'.")
|
|
88
|
+
sys.exit(1)
|
|
89
|
+
|
|
90
|
+
if args.output:
|
|
91
|
+
output_path = args.output
|
|
92
|
+
else:
|
|
93
|
+
out_ext = '.mp4' if is_video else ext
|
|
94
|
+
output_path = str(input_path.parent / f'{input_path.stem}_blurred{out_ext}')
|
|
95
|
+
|
|
96
|
+
print(" -- Load Param: input", input_path)
|
|
97
|
+
print(" -- Load Param: output", output_path)
|
|
98
|
+
print(" -- Load Param: style", args.style)
|
|
99
|
+
print(" -- Load Param: shape", args.shape)
|
|
100
|
+
print(" -- Load Param: score_threshold", args.score_threshold)
|
|
101
|
+
print(" -- Load Param: device", args.device)
|
|
102
|
+
|
|
103
|
+
if not 0 <= args.smoothing < 1:
|
|
104
|
+
print("Error: --smoothing must be in [0, 1).")
|
|
105
|
+
sys.exit(1)
|
|
106
|
+
|
|
107
|
+
if not is_video and (args.start_t is not None or args.end_t is not None):
|
|
108
|
+
print("Error: --start_t/--end_t only apply to videos.")
|
|
109
|
+
sys.exit(1)
|
|
110
|
+
|
|
111
|
+
if args.start_t is not None and args.start_t < 0:
|
|
112
|
+
print("Error: --start_t must be >= 0.")
|
|
113
|
+
sys.exit(1)
|
|
114
|
+
|
|
115
|
+
if args.end_t is not None and args.end_t <= (args.start_t or 0.0):
|
|
116
|
+
print("Error: --end_t must be greater than --start_t.")
|
|
117
|
+
sys.exit(1)
|
|
118
|
+
|
|
119
|
+
blurrer = FaceBlur(
|
|
120
|
+
style=args.style,
|
|
121
|
+
shape=args.shape,
|
|
122
|
+
padding=args.padding,
|
|
123
|
+
score_threshold=args.score_threshold,
|
|
124
|
+
nms_threshold=args.nms_threshold,
|
|
125
|
+
detect_max_dim=args.detect_max_dim,
|
|
126
|
+
blur_strength=args.blur_strength,
|
|
127
|
+
pixel_blocks=args.pixel_blocks,
|
|
128
|
+
fill_color=args.fill_color,
|
|
129
|
+
hold_frames=args.hold_frames,
|
|
130
|
+
smoothing=args.smoothing,
|
|
131
|
+
draw_boxes=args.draw_boxes,
|
|
132
|
+
disable_pbar=args.disable_pbar,
|
|
133
|
+
device=args.device,
|
|
134
|
+
batch_size=args.batch_size,
|
|
135
|
+
workers=args.workers,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
if is_video:
|
|
139
|
+
success = blurrer.process_video(str(input_path), output_path,
|
|
140
|
+
keep_audio=not args.no_audio, crf=args.crf,
|
|
141
|
+
start_t=args.start_t, end_t=args.end_t)
|
|
142
|
+
else:
|
|
143
|
+
success = blurrer.process_image(str(input_path), output_path)
|
|
144
|
+
|
|
145
|
+
if not success:
|
|
146
|
+
sys.exit(1)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
if __name__ == "__main__":
|
|
150
|
+
main()
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""
|
|
2
|
+
File: core.py
|
|
3
|
+
Description: FaceBlur, the high-level API for anonymizing faces in images
|
|
4
|
+
and videos. Combines a detector (detectors.py), a tracker (tracking.py),
|
|
5
|
+
an obscurer (obscure.py), and threaded video I/O (video_io.py).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import tempfile
|
|
11
|
+
from collections import deque
|
|
12
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import cv2
|
|
16
|
+
from tqdm import tqdm
|
|
17
|
+
|
|
18
|
+
from dynamo_figures.blur_faces.detectors import DEVICES, create_detector
|
|
19
|
+
from dynamo_figures.blur_faces.obscure import SHAPES, STYLES, Obscurer
|
|
20
|
+
from dynamo_figures.blur_faces.tracking import FaceTracker
|
|
21
|
+
from dynamo_figures.blur_faces.video_io import FrameReader, open_writer
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class FaceBlur:
|
|
25
|
+
"""Class for detecting and obscuring faces in images and videos."""
|
|
26
|
+
|
|
27
|
+
STYLES = STYLES
|
|
28
|
+
SHAPES = SHAPES
|
|
29
|
+
DEVICES = DEVICES
|
|
30
|
+
|
|
31
|
+
def __init__(self, style='blur', shape='ellipse', padding=0.25,
|
|
32
|
+
score_threshold=0.6, nms_threshold=0.3, detect_max_dim=2048,
|
|
33
|
+
blur_strength=0.5, pixel_blocks=10, fill_color='black',
|
|
34
|
+
hold_frames=5, smoothing=0.5, draw_boxes=False, disable_pbar=False,
|
|
35
|
+
device='auto', batch_size=8, workers=None):
|
|
36
|
+
"""
|
|
37
|
+
Initialize FaceBlur.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
style: How to obscure faces: 'blur', 'pixelate', or 'fill'
|
|
41
|
+
shape: Region shape to obscure: 'ellipse' or 'rect'
|
|
42
|
+
padding: Fraction to enlarge each face box by on every side
|
|
43
|
+
score_threshold: Minimum detector confidence (0..1)
|
|
44
|
+
nms_threshold: Non-maximum suppression IoU threshold (0..1)
|
|
45
|
+
detect_max_dim: Downscale frames so the longest side is at most this
|
|
46
|
+
many pixels before detection (0 = full resolution)
|
|
47
|
+
blur_strength: Blur kernel size as a fraction of face size
|
|
48
|
+
pixel_blocks: Number of mosaic blocks across each face (pixelate)
|
|
49
|
+
fill_color: Color name or hex code for the 'fill' style
|
|
50
|
+
hold_frames: (Video) keep obscuring a face for this many frames
|
|
51
|
+
after the detector loses it
|
|
52
|
+
smoothing: (Video) box smoothing between frames (0..1, 0 = off)
|
|
53
|
+
draw_boxes: Draw detection boxes and scores instead of obscuring
|
|
54
|
+
disable_pbar: Disable the progress bar for videos
|
|
55
|
+
device: 'gpu' (ONNX Runtime with CUDA/CoreML), 'cpu' (OpenCV), or
|
|
56
|
+
'auto' (GPU if available, otherwise CPU)
|
|
57
|
+
batch_size: (Video) number of frames detected per batch
|
|
58
|
+
workers: (Video) number of CPU threads (default: number of cores)
|
|
59
|
+
"""
|
|
60
|
+
self.obscurer = Obscurer(style, shape, padding, blur_strength,
|
|
61
|
+
pixel_blocks, fill_color, draw_boxes)
|
|
62
|
+
self.tracker = FaceTracker(hold_frames, smoothing)
|
|
63
|
+
self.detector = create_detector(device, score_threshold, nms_threshold)
|
|
64
|
+
self.detect_max_dim = detect_max_dim
|
|
65
|
+
self.disable_pbar = disable_pbar
|
|
66
|
+
self.batch_size = max(1, batch_size)
|
|
67
|
+
self.workers = workers or os.cpu_count() or 4
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def device_name(self):
|
|
71
|
+
"""Human-readable name of the detection backend."""
|
|
72
|
+
return self.detector.name
|
|
73
|
+
|
|
74
|
+
def _downscale(self, image):
|
|
75
|
+
"""Resize an image for detection; returns (image, scale)."""
|
|
76
|
+
h, w = image.shape[:2]
|
|
77
|
+
if self.detect_max_dim and max(h, w) > self.detect_max_dim:
|
|
78
|
+
scale = self.detect_max_dim / max(h, w)
|
|
79
|
+
small = cv2.resize(image, (round(w * scale), round(h * scale)),
|
|
80
|
+
interpolation=cv2.INTER_AREA)
|
|
81
|
+
return small, scale
|
|
82
|
+
return image, 1.0
|
|
83
|
+
|
|
84
|
+
def detect_batch(self, images):
|
|
85
|
+
"""
|
|
86
|
+
Detect faces in a list of same-sized images.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
images: list of BGR images (numpy arrays) with identical shapes
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
list: one list of (x, y, w, h, score) tuples per image, in
|
|
93
|
+
original image coordinates
|
|
94
|
+
"""
|
|
95
|
+
scaled = [self._downscale(im) for im in images]
|
|
96
|
+
scale = scaled[0][1]
|
|
97
|
+
smalls = [s[0] for s in scaled]
|
|
98
|
+
if self.detector.batched and 1 < len(smalls) < self.batch_size:
|
|
99
|
+
# Pad partial batches so the GPU session for this batch size is reused
|
|
100
|
+
smalls = smalls + [smalls[-1]] * (self.batch_size - len(smalls))
|
|
101
|
+
detections = self.detector.detect_batch(smalls)[:len(images)]
|
|
102
|
+
return [[(x / scale, y / scale, w / scale, h / scale, s) for x, y, w, h, s in faces]
|
|
103
|
+
for faces in detections]
|
|
104
|
+
|
|
105
|
+
def detect(self, image):
|
|
106
|
+
"""
|
|
107
|
+
Detect faces in an image.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
image: BGR image (numpy array)
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
list: (x, y, w, h, score) tuples in original image coordinates
|
|
114
|
+
"""
|
|
115
|
+
return self.detect_batch([image])[0]
|
|
116
|
+
|
|
117
|
+
def apply(self, image, faces, inplace=False):
|
|
118
|
+
"""
|
|
119
|
+
Obscure (or annotate) the given faces in an image.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
image: BGR image (numpy array)
|
|
123
|
+
faces: list of (x, y, w, h, score) tuples
|
|
124
|
+
inplace: modify image directly instead of a copy
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
numpy array: the processed image
|
|
128
|
+
"""
|
|
129
|
+
return self.obscurer.apply(image, faces, inplace)
|
|
130
|
+
|
|
131
|
+
def process_image(self, input_path, output_path):
|
|
132
|
+
"""
|
|
133
|
+
Obscure faces in an image file and save the result.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
bool: True if successful, False otherwise
|
|
137
|
+
"""
|
|
138
|
+
image = cv2.imread(input_path)
|
|
139
|
+
if image is None:
|
|
140
|
+
print(f"Error: Could not read image '{input_path}'.")
|
|
141
|
+
return False
|
|
142
|
+
|
|
143
|
+
print(f" -- Image info: {image.shape[1]}x{image.shape[0]}")
|
|
144
|
+
faces = self.detect(image)
|
|
145
|
+
print(f" -- Detected {len(faces)} face(s)")
|
|
146
|
+
result = self.apply(image, faces, inplace=True)
|
|
147
|
+
|
|
148
|
+
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
|
149
|
+
if not cv2.imwrite(output_path, result):
|
|
150
|
+
print(f"Error: Could not save image to '{output_path}'")
|
|
151
|
+
return False
|
|
152
|
+
print(f" -- Output saved to: {output_path}")
|
|
153
|
+
return True
|
|
154
|
+
|
|
155
|
+
def process_video(self, input_path, output_path, keep_audio=True, crf=18,
|
|
156
|
+
start_t=None, end_t=None):
|
|
157
|
+
"""
|
|
158
|
+
Obscure faces in every frame of a video file and save the result.
|
|
159
|
+
|
|
160
|
+
Frames are decoded on a background thread, detected in batches,
|
|
161
|
+
tracked in order, obscured on a thread pool, and streamed to the
|
|
162
|
+
encoder in their original order.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
input_path: Path to the input video
|
|
166
|
+
output_path: Path for the output video
|
|
167
|
+
keep_audio: Copy the original audio track (requires ffmpeg)
|
|
168
|
+
crf: H.264 quality when encoding with ffmpeg (lower = better)
|
|
169
|
+
start_t: Trim the output to start at this many seconds in
|
|
170
|
+
(None or 0 = from the beginning)
|
|
171
|
+
end_t: Trim the output to end at this many seconds in, exclusive
|
|
172
|
+
(None = to the end of the video)
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
bool: True if successful, False otherwise
|
|
176
|
+
"""
|
|
177
|
+
if start_t is not None and start_t < 0:
|
|
178
|
+
print("Error: start_t must be >= 0.")
|
|
179
|
+
return False
|
|
180
|
+
if start_t is not None and end_t is not None and end_t <= start_t:
|
|
181
|
+
print("Error: end_t must be greater than start_t.")
|
|
182
|
+
return False
|
|
183
|
+
|
|
184
|
+
cap = cv2.VideoCapture(input_path)
|
|
185
|
+
if not cap.isOpened():
|
|
186
|
+
print(f"Error: Could not open video file '{input_path}'.")
|
|
187
|
+
return False
|
|
188
|
+
|
|
189
|
+
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
|
|
190
|
+
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
|
191
|
+
|
|
192
|
+
start_frame = round((start_t or 0.0) * fps)
|
|
193
|
+
if start_frame and not cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame):
|
|
194
|
+
print(f"Error: Could not seek to {start_t:g}s in '{input_path}'.")
|
|
195
|
+
cap.release()
|
|
196
|
+
return False
|
|
197
|
+
|
|
198
|
+
ret, first = cap.read()
|
|
199
|
+
if not ret:
|
|
200
|
+
where = f' at {start_t:g}s' if start_frame else ''
|
|
201
|
+
print(f"Error: Could not read frames from '{input_path}'{where}.")
|
|
202
|
+
cap.release()
|
|
203
|
+
return False
|
|
204
|
+
|
|
205
|
+
max_frames = None
|
|
206
|
+
if end_t is not None:
|
|
207
|
+
max_frames = max(1, round(end_t * fps) - start_frame)
|
|
208
|
+
# Use the decoded frame size, which already accounts for rotation metadata
|
|
209
|
+
height, width = first.shape[:2]
|
|
210
|
+
frame_count = max(0, total_frames - start_frame) or total_frames
|
|
211
|
+
if max_frames is not None and frame_count:
|
|
212
|
+
frame_count = min(frame_count, max_frames)
|
|
213
|
+
print(f" -- Video info: {width}x{height}, {total_frames} frames, {fps:.2f} FPS")
|
|
214
|
+
if start_frame or max_frames is not None:
|
|
215
|
+
end_label = f'{end_t:g}s' if end_t is not None else 'end'
|
|
216
|
+
print(f" -- Trimming to {(start_t or 0.0):g}s..{end_label} ({frame_count} frames)")
|
|
217
|
+
print(f" -- Detector: {self.device_name}, batch size {self.batch_size}, {self.workers} worker(s)")
|
|
218
|
+
|
|
219
|
+
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
|
220
|
+
tmp_dir = tempfile.mkdtemp(prefix='blur_faces_')
|
|
221
|
+
try:
|
|
222
|
+
writer = open_writer(output_path, width, height, fps, crf,
|
|
223
|
+
audio_source=input_path if keep_audio else None, log_dir=tmp_dir,
|
|
224
|
+
audio_start=start_frame / fps if (start_frame or max_frames is not None) else None)
|
|
225
|
+
except IOError as e:
|
|
226
|
+
print(f"Error: {e}")
|
|
227
|
+
cap.release()
|
|
228
|
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
229
|
+
return False
|
|
230
|
+
|
|
231
|
+
reader = FrameReader(cap, first, max_queue=self.batch_size * 2,
|
|
232
|
+
max_frames=max_frames).start()
|
|
233
|
+
|
|
234
|
+
# Many single-threaded workers beat OpenCV's own internal threading here
|
|
235
|
+
prev_threads = cv2.getNumThreads()
|
|
236
|
+
cv2.setNumThreads(1)
|
|
237
|
+
executor = ThreadPoolExecutor(self.workers)
|
|
238
|
+
self.detector.executor = executor
|
|
239
|
+
|
|
240
|
+
self.tracker.reset()
|
|
241
|
+
total_faces = 0
|
|
242
|
+
frames_with_faces = 0
|
|
243
|
+
pending = deque()
|
|
244
|
+
success = True
|
|
245
|
+
try:
|
|
246
|
+
with tqdm(total=frame_count, disable=self.disable_pbar, desc="Processing") as pbar:
|
|
247
|
+
for batch in reader.batches(self.batch_size):
|
|
248
|
+
for frame, detections in zip(batch, self.detect_batch(batch)):
|
|
249
|
+
total_faces += len(detections)
|
|
250
|
+
frames_with_faces += bool(detections)
|
|
251
|
+
# Tracking depends on frame order, so it stays on this thread
|
|
252
|
+
faces = self.tracker.update(detections)
|
|
253
|
+
pending.append(executor.submit(self.apply, frame, faces, True))
|
|
254
|
+
|
|
255
|
+
# Write finished frames in order, keeping a bounded backlog
|
|
256
|
+
while pending and (pending[0].done() or len(pending) > self.workers * 2):
|
|
257
|
+
writer.write(pending.popleft().result())
|
|
258
|
+
pbar.update(1)
|
|
259
|
+
while pending:
|
|
260
|
+
writer.write(pending.popleft().result())
|
|
261
|
+
pbar.update(1)
|
|
262
|
+
except BrokenPipeError:
|
|
263
|
+
success = False
|
|
264
|
+
finally:
|
|
265
|
+
reader.close()
|
|
266
|
+
cap.release()
|
|
267
|
+
executor.shutdown(wait=True)
|
|
268
|
+
cv2.setNumThreads(prev_threads)
|
|
269
|
+
self.detector.executor = None
|
|
270
|
+
success = writer.close() and success
|
|
271
|
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
272
|
+
|
|
273
|
+
print(f" -- Faces found in {frames_with_faces} frame(s), {total_faces} detection(s) total")
|
|
274
|
+
if not success:
|
|
275
|
+
return False
|
|
276
|
+
print(f" -- Output saved to: {output_path}")
|
|
277
|
+
return True
|