extractfaces-gui 0.1.0__tar.gz

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,29 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Rafael O. Ribeiro
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice,
10
+ this list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ 3. Neither the name of the copyright holder nor the names of its contributors
17
+ may be used to endorse or promote products derived from this software
18
+ without specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.4
2
+ Name: extractfaces-gui
3
+ Version: 0.1.0
4
+ Summary: GUI application to extract faces from videos
5
+ Author: Rafael O. Ribeiro
6
+ Author-email: Rafael O. Ribeiro <rafaeloliveiraribeiro@gmail.com>
7
+ License-Expression: BSD-3-Clause
8
+ License-File: LICENSE
9
+ Requires-Dist: pyside6>=6.10,<7
10
+ Requires-Dist: forensicface>=0.8,<0.9
11
+ Requires-Dist: onnxruntime-gpu>=1.25,<1.26 ; sys_platform == 'win32'
12
+ Requires-Python: >=3.14
13
+ Project-URL: Homepage, https://github.com/rafribeiro/extractfaces-gui
14
+ Project-URL: Repository, https://github.com/rafribeiro/extractfaces-gui
15
+ Project-URL: Issues, https://github.com/rafribeiro/extractfaces-gui/issues
16
+ Description-Content-Type: text/markdown
17
+
18
+ # Extract Faces
19
+
20
+ A Python desktop app built with Qt for extracting PNG face crops from videos
21
+ using [forensicface](https://github.com/rafribeiro/forensicface).
22
+ Play a video, draw a rectangular search area, choose an output folder, and
23
+ configure the detector and extraction settings. The app also displays video
24
+ dimensions, frame rate, duration, and media codecs.
25
+
26
+ ## Run locally
27
+
28
+ Requires Python 3.14 or newer. From this project folder:
29
+
30
+ ```powershell
31
+ uv sync
32
+ uv run extract-faces
33
+ ```
34
+
35
+ Alternatively, install with `pip install .`, then run `extract-faces` or
36
+ `python -m extract_faces`. Once published to PyPI, installation will be
37
+ `pip install extractfaces-gui`.
38
+
39
+ ## Face detector setup
40
+
41
+ The app uses forensicface's SCRFD detector with optional attributes and
42
+ embeddings disabled. Place its `det_10g.onnx` model at:
43
+
44
+ ```text
45
+ ~/.forensicface/models/detection/scrfd/det_10g.onnx
46
+ ```
47
+
48
+ On Windows, `~` means your user folder, such as `C:\Users\rafael`.
49
+ See [forensicface's model documentation](https://github.com/rafribeiro/forensicface#layout-de-modelos-por-tarefa-e-alias)
50
+ for the layout and model credits. The app does not download model weights.
51
+ CPU processing is the default; enable GPU only with a compatible GPU/runtime.
52
+ Forensicface installs CUDA dependencies even when using CPU, so installation
53
+ can be a large download. ONNX Runtime is constrained to the 1.25 series on
54
+ Windows because newer CUDA extras currently lack compatible Windows wheels.
55
+
56
+ ## Use the app
57
+
58
+ 1. Open a video with the file picker or drop a video file onto the app (the first
59
+ local file is opened if several are dropped). Playback is silent. Metadata includes dimensions, frame rate,
60
+ duration, and codecs when available.
61
+ 2. Pause or drag a rectangle on the preview. Detection searches that area in
62
+ every processed frame. Clear the region to search the full frame.
63
+ 3. Choose an empty output folder, crop size multiplier, frames to skip, and
64
+ start time. A multiplier of 2 doubles the detected box's dimensions;
65
+ skipping 4 frames processes every fifth frame.
66
+ Set **Detector size (det_size)** and **Detection threshold (det_thresh)**
67
+ before extraction to configure SCRFD. Defaults are 320 and 0.5. Larger
68
+ detector sizes take longer; lower thresholds may accept more false positives.
69
+ 4. Click **Extract faces**. Progress counts sampled frames. Cancel takes effect
70
+ after the current detector operation; crops already saved are kept.
71
+
72
+ Click **Show console output** to expand the console pane. It captures Python
73
+ standard output and errors, including forensicface initialization, progress,
74
+ and extraction tracebacks, even while collapsed. The most recent 2,000 lines
75
+ are kept. Native-library output written directly to the operating system's
76
+ console is not captured. Dropping another video is disabled during extraction.
77
+
78
+ Extraction reads the original video. The rectangle restricts detection;
79
+ added crop margins may extend beyond the rectangle, within the full frame.
80
+ Closing during extraction requests cancellation; close again after it stops.
81
+
82
+ ## Code layout
83
+
84
+ All UI and extraction code is in `src/extract_faces/app.py`:
85
+
86
+ - `VideoView` paints frames and handles the rectangle in image coordinates.
87
+ - `ExtractionWorker` runs forensicface in a background thread. A small adapter
88
+ crops the detector input and restores coordinates before forensicface saves
89
+ crops from the original frame.
90
+ - `MainWindow` creates the controls and connects their actions.
91
+
92
+ `__init__.py` is the installed command's entry point; `__main__.py` supports
93
+ `python -m extract_faces`. No separate services or application layers.
94
+
95
+ Run the tests with `uv run python -m unittest discover -s tests -v`.
96
+ They exercise the GUI without a visible window and use a fake detector with
97
+ forensicface's real video extraction method, so model weights are unnecessary.
@@ -0,0 +1,80 @@
1
+ # Extract Faces
2
+
3
+ A Python desktop app built with Qt for extracting PNG face crops from videos
4
+ using [forensicface](https://github.com/rafribeiro/forensicface).
5
+ Play a video, draw a rectangular search area, choose an output folder, and
6
+ configure the detector and extraction settings. The app also displays video
7
+ dimensions, frame rate, duration, and media codecs.
8
+
9
+ ## Run locally
10
+
11
+ Requires Python 3.14 or newer. From this project folder:
12
+
13
+ ```powershell
14
+ uv sync
15
+ uv run extract-faces
16
+ ```
17
+
18
+ Alternatively, install with `pip install .`, then run `extract-faces` or
19
+ `python -m extract_faces`. Once published to PyPI, installation will be
20
+ `pip install extractfaces-gui`.
21
+
22
+ ## Face detector setup
23
+
24
+ The app uses forensicface's SCRFD detector with optional attributes and
25
+ embeddings disabled. Place its `det_10g.onnx` model at:
26
+
27
+ ```text
28
+ ~/.forensicface/models/detection/scrfd/det_10g.onnx
29
+ ```
30
+
31
+ On Windows, `~` means your user folder, such as `C:\Users\rafael`.
32
+ See [forensicface's model documentation](https://github.com/rafribeiro/forensicface#layout-de-modelos-por-tarefa-e-alias)
33
+ for the layout and model credits. The app does not download model weights.
34
+ CPU processing is the default; enable GPU only with a compatible GPU/runtime.
35
+ Forensicface installs CUDA dependencies even when using CPU, so installation
36
+ can be a large download. ONNX Runtime is constrained to the 1.25 series on
37
+ Windows because newer CUDA extras currently lack compatible Windows wheels.
38
+
39
+ ## Use the app
40
+
41
+ 1. Open a video with the file picker or drop a video file onto the app (the first
42
+ local file is opened if several are dropped). Playback is silent. Metadata includes dimensions, frame rate,
43
+ duration, and codecs when available.
44
+ 2. Pause or drag a rectangle on the preview. Detection searches that area in
45
+ every processed frame. Clear the region to search the full frame.
46
+ 3. Choose an empty output folder, crop size multiplier, frames to skip, and
47
+ start time. A multiplier of 2 doubles the detected box's dimensions;
48
+ skipping 4 frames processes every fifth frame.
49
+ Set **Detector size (det_size)** and **Detection threshold (det_thresh)**
50
+ before extraction to configure SCRFD. Defaults are 320 and 0.5. Larger
51
+ detector sizes take longer; lower thresholds may accept more false positives.
52
+ 4. Click **Extract faces**. Progress counts sampled frames. Cancel takes effect
53
+ after the current detector operation; crops already saved are kept.
54
+
55
+ Click **Show console output** to expand the console pane. It captures Python
56
+ standard output and errors, including forensicface initialization, progress,
57
+ and extraction tracebacks, even while collapsed. The most recent 2,000 lines
58
+ are kept. Native-library output written directly to the operating system's
59
+ console is not captured. Dropping another video is disabled during extraction.
60
+
61
+ Extraction reads the original video. The rectangle restricts detection;
62
+ added crop margins may extend beyond the rectangle, within the full frame.
63
+ Closing during extraction requests cancellation; close again after it stops.
64
+
65
+ ## Code layout
66
+
67
+ All UI and extraction code is in `src/extract_faces/app.py`:
68
+
69
+ - `VideoView` paints frames and handles the rectangle in image coordinates.
70
+ - `ExtractionWorker` runs forensicface in a background thread. A small adapter
71
+ crops the detector input and restores coordinates before forensicface saves
72
+ crops from the original frame.
73
+ - `MainWindow` creates the controls and connects their actions.
74
+
75
+ `__init__.py` is the installed command's entry point; `__main__.py` supports
76
+ `python -m extract_faces`. No separate services or application layers.
77
+
78
+ Run the tests with `uv run python -m unittest discover -s tests -v`.
79
+ They exercise the GUI without a visible window and use a fake detector with
80
+ forensicface's real video extraction method, so model weights are unnecessary.
@@ -0,0 +1,32 @@
1
+ [project]
2
+ name = "extractfaces-gui"
3
+ version = "0.1.0"
4
+ description = "GUI application to extract faces from videos"
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ license = "BSD-3-Clause"
8
+ license-files = ["LICENSE"]
9
+ dependencies = [
10
+ "PySide6>=6.10,<7",
11
+ "forensicface>=0.8,<0.9",
12
+ "onnxruntime-gpu>=1.25,<1.26; sys_platform == 'win32'",
13
+ ]
14
+
15
+ [[project.authors]]
16
+ name = "Rafael O. Ribeiro"
17
+ email = "rafaeloliveiraribeiro@gmail.com"
18
+
19
+ [project.gui-scripts]
20
+ extract-faces = "extract_faces:main"
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/rafribeiro/extractfaces-gui"
24
+ Repository = "https://github.com/rafribeiro/extractfaces-gui"
25
+ Issues = "https://github.com/rafribeiro/extractfaces-gui/issues"
26
+
27
+ [build-system]
28
+ requires = ["uv_build>=0.12.23,<0.13.0"]
29
+ build-backend = "uv_build"
30
+
31
+ [tool.uv.build-backend]
32
+ module-name = "extract_faces"
@@ -0,0 +1,30 @@
1
+ [project]
2
+ name = "extractfaces-gui"
3
+ version = "0.1.0"
4
+ description = "GUI application to extract faces from videos"
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ license = "BSD-3-Clause"
8
+ license-files = ["LICENSE"]
9
+ authors = [{ name = "Rafael O. Ribeiro", email = "rafaeloliveiraribeiro@gmail.com" }]
10
+ dependencies = [
11
+ "PySide6>=6.10,<7",
12
+ "forensicface>=0.8,<0.9",
13
+ # Newer CUDA extras currently resolve to packages without Windows wheels.
14
+ "onnxruntime-gpu>=1.25,<1.26; sys_platform == 'win32'",
15
+ ]
16
+
17
+ [project.gui-scripts]
18
+ extract-faces = "extract_faces:main"
19
+
20
+ [project.urls]
21
+ Homepage = "https://github.com/rafribeiro/extractfaces-gui"
22
+ Repository = "https://github.com/rafribeiro/extractfaces-gui"
23
+ Issues = "https://github.com/rafribeiro/extractfaces-gui/issues"
24
+
25
+ [build-system]
26
+ requires = ["uv_build>=0.12.23,<0.13.0"]
27
+ build-backend = "uv_build"
28
+
29
+ [tool.uv.build-backend]
30
+ module-name = "extract_faces"
@@ -0,0 +1,4 @@
1
+ def main() -> None:
2
+ from .app import main as run_app
3
+
4
+ run_app()
@@ -0,0 +1,3 @@
1
+ from . import main
2
+
3
+ main()
@@ -0,0 +1,500 @@
1
+ """A small Qt window for previewing videos and extracting face crops."""
2
+
3
+ import math
4
+ import sys
5
+ import traceback
6
+ from pathlib import Path
7
+
8
+ import cv2
9
+ from PySide6.QtCore import QObject, QPointF, QRectF, Qt, QThread, QUrl, Signal, Slot
10
+ from PySide6.QtGui import QColor, QImage, QPainter, QPen, QTextCursor
11
+ from PySide6.QtMultimedia import QMediaMetaData, QMediaPlayer, QVideoSink
12
+ from PySide6.QtWidgets import (
13
+ QApplication, QCheckBox, QDoubleSpinBox, QFileDialog, QFormLayout,
14
+ QHBoxLayout, QLabel, QLineEdit, QMainWindow, QMessageBox, QProgressBar,
15
+ QPlainTextEdit, QPushButton, QSlider, QSpinBox, QVBoxLayout, QWidget,
16
+ )
17
+
18
+
19
+ class ConsoleStream(QObject):
20
+ """Send Python output to the GUI safely, including from the worker thread."""
21
+
22
+ text_written = Signal(str)
23
+ encoding = "utf-8"
24
+
25
+ def __init__(self, original):
26
+ super().__init__()
27
+ self.original = original
28
+
29
+ def write(self, text):
30
+ if self.original is not None:
31
+ self.original.write(text)
32
+ self.text_written.emit(text)
33
+ return len(text)
34
+
35
+ def flush(self):
36
+ if self.original is not None:
37
+ self.original.flush()
38
+
39
+ def isatty(self):
40
+ return False
41
+
42
+
43
+ class VideoView(QWidget):
44
+ """Paint the video and store the selection in original image pixels."""
45
+
46
+ selection_changed = Signal()
47
+ selection_started = Signal()
48
+
49
+ def __init__(self):
50
+ super().__init__()
51
+ self.image = QImage()
52
+ self.region = None
53
+ self.anchor = None
54
+ self.setMinimumSize(480, 270)
55
+
56
+ def set_frame(self, frame):
57
+ image = frame.toImage()
58
+ if not image.isNull():
59
+ self.image = image
60
+ self.update()
61
+
62
+ def image_rect(self):
63
+ if self.image.isNull():
64
+ return QRectF()
65
+ scale = min(self.width() / self.image.width(), self.height() / self.image.height())
66
+ width, height = self.image.width() * scale, self.image.height() * scale
67
+ return QRectF((self.width() - width) / 2, (self.height() - height) / 2, width, height)
68
+
69
+ def image_point(self, point):
70
+ rect = self.image_rect()
71
+ return QPointF(
72
+ max(0, min(self.image.width(), (point.x() - rect.x()) * self.image.width() / rect.width())),
73
+ max(0, min(self.image.height(), (point.y() - rect.y()) * self.image.height() / rect.height())),
74
+ )
75
+
76
+ def paintEvent(self, event):
77
+ painter = QPainter(self)
78
+ painter.fillRect(self.rect(), QColor("#202020"))
79
+ if self.image.isNull():
80
+ painter.setPen(Qt.GlobalColor.white)
81
+ painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, "Open a video to begin")
82
+ return
83
+ rect = self.image_rect()
84
+ painter.drawImage(rect, self.image)
85
+ if self.region is not None:
86
+ scale = rect.width() / self.image.width()
87
+ selection = QRectF(rect.x() + self.region.x() * scale,
88
+ rect.y() + self.region.y() * scale,
89
+ self.region.width() * scale, self.region.height() * scale)
90
+ painter.setPen(QPen(QColor("#00e5ff"), 2))
91
+ painter.drawRect(selection)
92
+
93
+ def mousePressEvent(self, event):
94
+ if event.button() == Qt.MouseButton.LeftButton and self.image_rect().contains(event.position()):
95
+ self.selection_started.emit()
96
+ self.anchor = self.image_point(event.position())
97
+ self.region = QRectF(self.anchor, self.anchor)
98
+
99
+ def mouseMoveEvent(self, event):
100
+ if self.anchor is not None:
101
+ self.region = QRectF(self.anchor, self.image_point(event.position())).normalized()
102
+ self.update()
103
+
104
+ def mouseReleaseEvent(self, event):
105
+ if self.anchor is not None:
106
+ self.mouseMoveEvent(event)
107
+ self.anchor = None
108
+ if self.region.width() < 2 or self.region.height() < 2:
109
+ self.region = None
110
+ self.selection_changed.emit()
111
+ self.update()
112
+
113
+ def clear_region(self):
114
+ self.region = None
115
+ self.selection_changed.emit()
116
+ self.update()
117
+
118
+ def region_pixels(self):
119
+ if self.region is None:
120
+ return None
121
+ return (math.floor(self.region.left()), math.floor(self.region.top()),
122
+ math.ceil(self.region.right()), math.ceil(self.region.bottom()))
123
+
124
+
125
+ class ExtractionCancelled(Exception):
126
+ pass
127
+
128
+
129
+ class ExtractionWorker(QThread):
130
+ """Run forensicface away from the GUI thread; report sampled frames."""
131
+
132
+ progress = Signal(int)
133
+ message = Signal(str)
134
+
135
+ def __init__(self, video, destination, region, every_n_frames, margin, start_from,
136
+ use_gpu, det_size=320, det_thresh=0.5):
137
+ super().__init__()
138
+ self.video = video
139
+ self.destination = destination
140
+ self.region = region
141
+ self.every_n_frames = every_n_frames
142
+ self.margin = margin
143
+ self.start_from = start_from
144
+ self.use_gpu = use_gpu
145
+ self.det_size = det_size
146
+ self.det_thresh = det_thresh
147
+
148
+ def run(self):
149
+ try:
150
+ # Import and load the detector here so the window remains responsive.
151
+ from forensicface.app import ForensicFace
152
+
153
+ detector = ForensicFace(detection="scrfd", embedding=None, pose=None,
154
+ gender=None, age=None, quality=None, use_gpu=self.use_gpu,
155
+ det_size=self.det_size, det_thresh=self.det_thresh)
156
+ original_process_image = detector.process_image
157
+ processed = 0
158
+
159
+ def process_region(frame, **kwargs):
160
+ nonlocal processed
161
+ if self.isInterruptionRequested():
162
+ raise ExtractionCancelled()
163
+ if self.region is None:
164
+ results = original_process_image(frame, **kwargs)
165
+ else:
166
+ x1, y1, x2, y2 = self.region
167
+ if not (0 <= x1 < x2 <= frame.shape[1] and 0 <= y1 < y2 <= frame.shape[0]):
168
+ raise ValueError("Video preview and extraction dimensions differ. Clear the region and retry.")
169
+ results = original_process_image(frame[y1:y2, x1:x2], **kwargs)
170
+ # extract_faces crops the full frame, so restore full-frame coordinates.
171
+ for result in results:
172
+ result["bbox"] = result["bbox"] + [x1, y1, x1, y1]
173
+ result["keypoints"] = result["keypoints"] + [x1, y1]
174
+ processed += 1
175
+ self.progress.emit(processed)
176
+ return results
177
+
178
+ # extract_faces has no ROI argument; adapt this instance's image processing.
179
+ detector.process_image = process_region
180
+ count = detector.extract_faces(
181
+ self.video, dest_folder=self.destination, every_n_frames=self.every_n_frames,
182
+ margin=self.margin, start_from=self.start_from,
183
+ )
184
+ self.message.emit(f"Finished: {count} face crops saved to {self.destination}")
185
+ except ExtractionCancelled:
186
+ self.message.emit("Extraction cancelled. Crops already saved remain in the output folder.")
187
+ except Exception as error:
188
+ traceback.print_exc()
189
+ self.message.emit(f"Extraction failed: {error}")
190
+
191
+
192
+ class MainWindow(QMainWindow):
193
+ def __init__(self):
194
+ super().__init__()
195
+ self.setWindowTitle("Extract Faces")
196
+ self.setAcceptDrops(True)
197
+ self.resize(1000, 800)
198
+ self.video_path = None
199
+ self.worker = None
200
+ self.duration_seconds = 0
201
+ self.fps = 0
202
+
203
+ self.player = QMediaPlayer(self)
204
+ self.sink = QVideoSink(self)
205
+ self.player.setVideoSink(self.sink)
206
+ self.view = VideoView()
207
+ self.sink.videoFrameChanged.connect(self.view.set_frame)
208
+ self.view.selection_started.connect(self.player.pause)
209
+
210
+ central = QWidget()
211
+ self.setCentralWidget(central)
212
+ layout = QVBoxLayout(central)
213
+ self.open_button = QPushButton("Open video…")
214
+ self.open_button.clicked.connect(self.open_video)
215
+ layout.addWidget(self.open_button)
216
+ layout.addWidget(self.view, 1)
217
+
218
+ playback = QHBoxLayout()
219
+ self.play_button = QPushButton("Play / Pause")
220
+ self.play_button.clicked.connect(self.toggle_playback)
221
+ playback.addWidget(self.play_button)
222
+ self.seek = QSlider(Qt.Orientation.Horizontal)
223
+ self.seek.sliderMoved.connect(self.player.setPosition)
224
+ self.player.positionChanged.connect(self.update_position)
225
+ self.player.durationChanged.connect(lambda duration: self.seek.setRange(0, duration))
226
+ playback.addWidget(self.seek, 1)
227
+ self.time_label = QLabel("0.00 s")
228
+ playback.addWidget(self.time_label)
229
+ layout.addLayout(playback)
230
+
231
+ self.metadata = QLabel("No video selected")
232
+ self.metadata.setWordWrap(True)
233
+ layout.addWidget(self.metadata)
234
+ self.player.metaDataChanged.connect(self.update_codecs)
235
+ self.player.errorOccurred.connect(lambda error, text: self.status.setText(f"Playback error: {text}"))
236
+
237
+ self.settings = QWidget()
238
+ form = QFormLayout(self.settings)
239
+ region_row = QHBoxLayout()
240
+ self.region_label = QLabel("Full frame — drag on the video to select a region")
241
+ region_row.addWidget(self.region_label, 1)
242
+ clear_button = QPushButton("Clear region")
243
+ clear_button.clicked.connect(self.view.clear_region)
244
+ region_row.addWidget(clear_button)
245
+ form.addRow("Search area", region_row)
246
+ self.view.selection_changed.connect(self.update_region)
247
+
248
+ folder_row = QHBoxLayout()
249
+ self.destination = QLineEdit()
250
+ self.destination.setAcceptDrops(False)
251
+ folder_row.addWidget(self.destination)
252
+ browse_button = QPushButton("Browse…")
253
+ browse_button.clicked.connect(self.choose_folder)
254
+ folder_row.addWidget(browse_button)
255
+ form.addRow("Output folder", folder_row)
256
+
257
+ self.margin = QDoubleSpinBox()
258
+ self.margin.setRange(1, 10)
259
+ self.margin.setSingleStep(0.1)
260
+ self.margin.setValue(2)
261
+ self.margin.setToolTip("1 keeps the detected box; 2 doubles its width and height.")
262
+ form.addRow("Crop size multiplier", self.margin)
263
+ self.skip = QSpinBox()
264
+ self.skip.setRange(0, 100000)
265
+ self.skip.setToolTip("0 processes every frame; 4 processes every fifth frame.")
266
+ form.addRow("Frames to skip", self.skip)
267
+ self.start = QDoubleSpinBox()
268
+ self.start.setRange(0, 864000)
269
+ self.start.setSuffix(" s")
270
+ start_row = QHBoxLayout()
271
+ start_row.addWidget(self.start)
272
+ current_button = QPushButton("Use current position")
273
+ current_button.clicked.connect(lambda: self.start.setValue(self.player.position() / 1000))
274
+ start_row.addWidget(current_button)
275
+ form.addRow("Start time", start_row)
276
+ self.det_size = QSpinBox()
277
+ self.det_size.setRange(32, 4096)
278
+ self.det_size.setSingleStep(32)
279
+ self.det_size.setValue(320)
280
+ self.det_size.setToolTip("Square detector input size in pixels. Larger sizes can detect smaller faces but take longer.")
281
+ form.addRow("Detector size (det_size)", self.det_size)
282
+ self.det_thresh = QDoubleSpinBox()
283
+ self.det_thresh.setRange(0, 1)
284
+ self.det_thresh.setSingleStep(0.05)
285
+ self.det_thresh.setValue(0.5)
286
+ self.det_thresh.setToolTip("Minimum detection confidence. Lower values accept more detections, including possible false positives.")
287
+ form.addRow("Detection threshold (det_thresh)", self.det_thresh)
288
+ self.gpu = QCheckBox("Use GPU (requires a compatible runtime)")
289
+ form.addRow(self.gpu)
290
+ layout.addWidget(self.settings)
291
+
292
+ actions = QHBoxLayout()
293
+ self.extract_button = QPushButton("Extract faces")
294
+ self.extract_button.setEnabled(False)
295
+ self.extract_button.clicked.connect(self.extract)
296
+ actions.addWidget(self.extract_button)
297
+ self.cancel_button = QPushButton("Cancel extraction")
298
+ self.cancel_button.setEnabled(False)
299
+ self.cancel_button.clicked.connect(self.cancel_extraction)
300
+ actions.addWidget(self.cancel_button)
301
+ layout.addLayout(actions)
302
+ self.progress = QProgressBar()
303
+ layout.addWidget(self.progress)
304
+ self.status = QLabel("Ready. Playback is silent; extraction uses the original video.")
305
+ self.status.setWordWrap(True)
306
+ self.status.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
307
+ layout.addWidget(self.status)
308
+
309
+ self.console_toggle = QPushButton("Show console output")
310
+ self.console_toggle.setCheckable(True)
311
+ layout.addWidget(self.console_toggle)
312
+ self.console = QPlainTextEdit()
313
+ self.console.setAcceptDrops(False)
314
+ self.console.setReadOnly(True)
315
+ self.console.setMaximumBlockCount(2000)
316
+ self.console.setMinimumHeight(120)
317
+ self.console.setMaximumHeight(240)
318
+ self.console.hide()
319
+ self.console_toggle.toggled.connect(self.toggle_console)
320
+ layout.addWidget(self.console)
321
+
322
+ def toggle_console(self, expanded):
323
+ self.console.setVisible(expanded)
324
+ self.console_toggle.setText("Hide console output" if expanded else "Show console output")
325
+
326
+ @Slot(str)
327
+ def append_console(self, text):
328
+ # tqdm uses carriage returns; show each update as a readable log line.
329
+ cursor = self.console.textCursor()
330
+ cursor.movePosition(QTextCursor.MoveOperation.End)
331
+ cursor.insertText(text.replace("\r", "\n"))
332
+ self.console.setTextCursor(cursor)
333
+ self.console.ensureCursorVisible()
334
+
335
+ def dropped_video(self, mime_data):
336
+ if self.worker is not None:
337
+ return None
338
+ for url in mime_data.urls():
339
+ if url.isLocalFile() and Path(url.toLocalFile()).is_file():
340
+ return url.toLocalFile()
341
+ return None
342
+
343
+ def dragEnterEvent(self, event):
344
+ if self.dropped_video(event.mimeData()):
345
+ event.acceptProposedAction()
346
+ else:
347
+ event.ignore()
348
+
349
+ def dropEvent(self, event):
350
+ path = self.dropped_video(event.mimeData())
351
+ if path:
352
+ event.acceptProposedAction()
353
+ self.load_video(path)
354
+ else:
355
+ event.ignore()
356
+
357
+ def open_video(self):
358
+ path, _ = QFileDialog.getOpenFileName(self, "Open video", "", "Video files (*.mp4 *.avi *.mov *.mkv *.webm *.m4v *.mpeg *.mts);;All files (*)")
359
+ if not path:
360
+ return
361
+ self.load_video(path)
362
+
363
+ def load_video(self, path):
364
+ if self.worker is not None:
365
+ return
366
+ capture = cv2.VideoCapture(path)
367
+ try:
368
+ if not capture.isOpened():
369
+ QMessageBox.warning(self, "Cannot open video", "The video could not be read.")
370
+ return
371
+ width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH))
372
+ height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT))
373
+ fps = capture.get(cv2.CAP_PROP_FPS)
374
+ frames = capture.get(cv2.CAP_PROP_FRAME_COUNT)
375
+ codec = int(capture.get(cv2.CAP_PROP_FOURCC))
376
+ fourcc = "".join(chr((codec >> (8 * i)) & 255) for i in range(4)).strip("\x00")
377
+ finally:
378
+ capture.release()
379
+ self.player.stop()
380
+ self.video_path = path
381
+ self.fps = fps if math.isfinite(fps) and fps > 0 else 0
382
+ self.duration_seconds = frames / self.fps if self.fps and math.isfinite(frames) else 0
383
+ self.base_metadata = f"{Path(path).name} | {width} × {height} | {self.fps:.3f} fps | {self.duration_seconds:.2f} s | Video: {fourcc or 'unknown'}"
384
+ self.metadata.setText(self.base_metadata)
385
+ self.view.image = QImage()
386
+ self.view.clear_region()
387
+ self.destination.setText(str(Path(path).with_name(Path(path).stem + "_faces")))
388
+ self.start.setValue(0)
389
+ self.start.setMaximum(max(0, self.duration_seconds - 0.01))
390
+ self.player.setSource(QUrl.fromLocalFile(path))
391
+ self.player.play()
392
+ self.extract_button.setEnabled(True)
393
+ self.status.setText("Pause or drag on the video to select the search area.")
394
+
395
+ def update_codecs(self):
396
+ if not self.video_path:
397
+ return
398
+ metadata = self.player.metaData()
399
+ video = metadata.stringValue(QMediaMetaData.Key.VideoCodec)
400
+ audio = metadata.stringValue(QMediaMetaData.Key.AudioCodec)
401
+ self.metadata.setText(self.base_metadata + f" | Media codecs: {video or 'unknown video'}, {audio or 'no audio / unknown'}")
402
+
403
+ def update_position(self, position):
404
+ if not self.seek.isSliderDown():
405
+ self.seek.setValue(position)
406
+ self.time_label.setText(f"{position / 1000:.2f} s")
407
+
408
+ def toggle_playback(self):
409
+ if self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState:
410
+ self.player.pause()
411
+ else:
412
+ self.player.play()
413
+
414
+ def update_region(self):
415
+ region = self.view.region_pixels()
416
+ self.region_label.setText(f"Pixels (x1, y1, x2, y2): {region}" if region else "Full frame — drag on the video to select a region")
417
+
418
+ def choose_folder(self):
419
+ folder = QFileDialog.getExistingDirectory(self, "Output folder", self.destination.text())
420
+ if folder:
421
+ self.destination.setText(folder)
422
+
423
+ def extract(self):
424
+ destination = self.destination.text().strip()
425
+ if not destination:
426
+ QMessageBox.warning(self, "Output folder required", "Choose a folder for the PNG crops.")
427
+ return
428
+ try:
429
+ folder = Path(destination).expanduser().resolve()
430
+ folder.mkdir(parents=True, exist_ok=True)
431
+ if any(folder.iterdir()):
432
+ QMessageBox.warning(self, "Choose an empty folder", "Choose an empty output folder to avoid overwriting previous crops.")
433
+ return
434
+ except OSError as error:
435
+ QMessageBox.warning(self, "Cannot use output folder", str(error))
436
+ return
437
+ self.player.pause()
438
+ self.settings.setEnabled(False)
439
+ self.view.setEnabled(False)
440
+ self.open_button.setEnabled(False)
441
+ self.extract_button.setEnabled(False)
442
+ self.cancel_button.setEnabled(True)
443
+ every_n = self.skip.value() + 1
444
+ estimated_frames = max(1, math.ceil((self.duration_seconds - self.start.value()) * self.fps / every_n))
445
+ self.progress.setRange(0, estimated_frames if self.fps else 0)
446
+ self.progress.setValue(0)
447
+ self.status.setText("Loading face detector…")
448
+ self.worker = ExtractionWorker(self.video_path, str(folder), self.view.region_pixels(),
449
+ every_n, self.margin.value(), self.start.value(), self.gpu.isChecked(),
450
+ det_size=self.det_size.value(), det_thresh=self.det_thresh.value())
451
+ self.worker.progress.connect(self.update_progress)
452
+ self.worker.message.connect(self.status.setText)
453
+ self.worker.finished.connect(self.extraction_finished)
454
+ self.worker.start()
455
+
456
+ def update_progress(self, processed):
457
+ if self.progress.maximum() > 0:
458
+ self.progress.setMaximum(max(self.progress.maximum(), processed))
459
+ self.progress.setValue(processed)
460
+ self.status.setText(f"Processed {processed} sampled frames…")
461
+
462
+ def cancel_extraction(self):
463
+ if self.worker:
464
+ self.worker.requestInterruption()
465
+ self.cancel_button.setEnabled(False)
466
+ self.status.setText("Cancelling after the current detector operation…")
467
+
468
+ def extraction_finished(self):
469
+ self.settings.setEnabled(True)
470
+ self.view.setEnabled(True)
471
+ self.open_button.setEnabled(True)
472
+ self.extract_button.setEnabled(True)
473
+ self.cancel_button.setEnabled(False)
474
+ self.worker.deleteLater()
475
+ self.worker = None
476
+
477
+ def closeEvent(self, event):
478
+ if self.worker and self.worker.isRunning():
479
+ self.cancel_extraction()
480
+ event.ignore()
481
+ else:
482
+ self.player.stop()
483
+ event.accept()
484
+
485
+
486
+ def main():
487
+ app = QApplication(sys.argv)
488
+ window = MainWindow()
489
+ original_stdout, original_stderr = sys.stdout, sys.stderr
490
+ stdout = ConsoleStream(original_stdout)
491
+ stderr = ConsoleStream(original_stderr)
492
+ stdout.text_written.connect(window.append_console)
493
+ stderr.text_written.connect(window.append_console)
494
+ sys.stdout, sys.stderr = stdout, stderr
495
+ try:
496
+ window.show()
497
+ exit_code = app.exec()
498
+ finally:
499
+ sys.stdout, sys.stderr = original_stdout, original_stderr
500
+ sys.exit(exit_code)