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.
- extractfaces_gui-0.1.0/LICENSE +29 -0
- extractfaces_gui-0.1.0/PKG-INFO +97 -0
- extractfaces_gui-0.1.0/README.md +80 -0
- extractfaces_gui-0.1.0/pyproject.toml +32 -0
- extractfaces_gui-0.1.0/pyproject.toml.orig +30 -0
- extractfaces_gui-0.1.0/src/extract_faces/__init__.py +4 -0
- extractfaces_gui-0.1.0/src/extract_faces/__main__.py +3 -0
- extractfaces_gui-0.1.0/src/extract_faces/app.py +500 -0
|
@@ -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,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)
|