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.
@@ -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,6 @@
1
+ """Entry point for running composite_image as a module."""
2
+
3
+ from dynamo_figures.composite_image import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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,6 @@
1
+ """Entry point for running blur_faces as a module: python -m dynamo_figures.blur_faces"""
2
+
3
+ from dynamo_figures.blur_faces.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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