painfacenet 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.
- painfacenet-0.1.0/PKG-INFO +61 -0
- painfacenet-0.1.0/README.md +34 -0
- painfacenet-0.1.0/painface/__init__.py +11 -0
- painfacenet-0.1.0/painface/aus/__init__.py +1 -0
- painfacenet-0.1.0/painface/core.py +72 -0
- painfacenet-0.1.0/painface/detection/__init__.py +1 -0
- painfacenet-0.1.0/painface/features/__init__.py +1 -0
- painfacenet-0.1.0/painface/io/__init__.py +3 -0
- painfacenet-0.1.0/painface/io/loader.py +220 -0
- painfacenet-0.1.0/painface/landmarks/__init__.py +1 -0
- painfacenet-0.1.0/painface/pain/__init__.py +1 -0
- painfacenet-0.1.0/painface/utils/__init__.py +1 -0
- painfacenet-0.1.0/painfacenet.egg-info/PKG-INFO +61 -0
- painfacenet-0.1.0/painfacenet.egg-info/SOURCES.txt +19 -0
- painfacenet-0.1.0/painfacenet.egg-info/dependency_links.txt +1 -0
- painfacenet-0.1.0/painfacenet.egg-info/requires.txt +8 -0
- painfacenet-0.1.0/painfacenet.egg-info/top_level.txt +1 -0
- painfacenet-0.1.0/pyproject.toml +40 -0
- painfacenet-0.1.0/setup.cfg +4 -0
- painfacenet-0.1.0/setup.py +18 -0
- painfacenet-0.1.0/tests/test_io.py +174 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: painfacenet
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Open-source Python framework for facial pain estimation via Action Units (research only)
|
|
5
|
+
Author: Shariff Naveed
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/shariff-bit/PainFace
|
|
8
|
+
Project-URL: Repository, https://github.com/shariff-bit/PainFace
|
|
9
|
+
Keywords: pain,facial,action units,AU,computer vision,research
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Intended Audience :: Science/Research
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
Requires-Dist: numpy>=1.24
|
|
20
|
+
Requires-Dist: opencv-python-headless>=4.8
|
|
21
|
+
Requires-Dist: pandas>=2.0
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
24
|
+
Requires-Dist: build; extra == "dev"
|
|
25
|
+
Requires-Dist: twine; extra == "dev"
|
|
26
|
+
Dynamic: requires-python
|
|
27
|
+
|
|
28
|
+
# PainFace
|
|
29
|
+
|
|
30
|
+
Open-source Python framework for facial pain estimation via Action Units (AUs).
|
|
31
|
+
|
|
32
|
+
**Research tool only — not a medical device.**
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install painface
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Quick Start
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from painface import PainFace
|
|
44
|
+
|
|
45
|
+
pf = PainFace()
|
|
46
|
+
|
|
47
|
+
# Load an image
|
|
48
|
+
record = pf.load_image("face.jpg")
|
|
49
|
+
|
|
50
|
+
# Load a video and sample frames
|
|
51
|
+
meta = pf.load_video("patient_video.mp4")
|
|
52
|
+
frames = pf.sample_frames(meta, target_fps=5)
|
|
53
|
+
|
|
54
|
+
print(f"Got {len(frames)} frames")
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Current Version (v0.1.0)
|
|
58
|
+
- Image loading
|
|
59
|
+
- Video loading & frame sampling
|
|
60
|
+
|
|
61
|
+
More modules coming: face detection, landmarks, AU estimation, pain scoring.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# PainFace
|
|
2
|
+
|
|
3
|
+
Open-source Python framework for facial pain estimation via Action Units (AUs).
|
|
4
|
+
|
|
5
|
+
**Research tool only — not a medical device.**
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install painface
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from painface import PainFace
|
|
17
|
+
|
|
18
|
+
pf = PainFace()
|
|
19
|
+
|
|
20
|
+
# Load an image
|
|
21
|
+
record = pf.load_image("face.jpg")
|
|
22
|
+
|
|
23
|
+
# Load a video and sample frames
|
|
24
|
+
meta = pf.load_video("patient_video.mp4")
|
|
25
|
+
frames = pf.sample_frames(meta, target_fps=5)
|
|
26
|
+
|
|
27
|
+
print(f"Got {len(frames)} frames")
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Current Version (v0.1.0)
|
|
31
|
+
- Image loading
|
|
32
|
+
- Video loading & frame sampling
|
|
33
|
+
|
|
34
|
+
More modules coming: face detection, landmarks, AU estimation, pain scoring.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# painface.aus
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""
|
|
2
|
+
painface.core
|
|
3
|
+
-------------
|
|
4
|
+
PainFace — the main class.
|
|
5
|
+
|
|
6
|
+
This is the "manager" of the whole pipeline.
|
|
7
|
+
Eventually it will call face detection, landmarks, AUs, and pain estimation.
|
|
8
|
+
Right now (Phase 1) it only handles loading images and videos.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from painface.io.loader import load_image, load_video, sample_frames, FrameRecord, VideoMeta
|
|
12
|
+
from typing import List, Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PainFace:
|
|
16
|
+
"""
|
|
17
|
+
Main entry point for the PainFace pipeline.
|
|
18
|
+
|
|
19
|
+
Usage
|
|
20
|
+
-----
|
|
21
|
+
>>> pf = PainFace()
|
|
22
|
+
>>> record = pf.load_image("face.jpg")
|
|
23
|
+
>>> meta = pf.load_video("video.mp4")
|
|
24
|
+
>>> frames = pf.sample_frames(meta, target_fps=5)
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, target_fps: float = 5.0):
|
|
28
|
+
"""
|
|
29
|
+
Parameters
|
|
30
|
+
----------
|
|
31
|
+
target_fps : default frame rate used when sampling videos (default 5)
|
|
32
|
+
"""
|
|
33
|
+
self.target_fps = target_fps
|
|
34
|
+
print(f"[PainFace] Ready. Default sampling rate: {target_fps} fps")
|
|
35
|
+
|
|
36
|
+
def load_image(self, path: str) -> FrameRecord:
|
|
37
|
+
"""Load a single image. Returns a FrameRecord."""
|
|
38
|
+
record = load_image(path)
|
|
39
|
+
print(f"[PainFace] Loaded image: {record.image.shape[1]}x{record.image.shape[0]} px ← {path}")
|
|
40
|
+
return record
|
|
41
|
+
|
|
42
|
+
def load_video(self, path: str) -> VideoMeta:
|
|
43
|
+
"""Load video metadata (does not read frames yet)."""
|
|
44
|
+
meta = load_video(path)
|
|
45
|
+
print(
|
|
46
|
+
f"[PainFace] Loaded video: {meta.width}x{meta.height} | "
|
|
47
|
+
f"{meta.fps:.1f} fps | {meta.duration_seconds:.1f}s | "
|
|
48
|
+
f"{meta.total_frames} frames ← {path}"
|
|
49
|
+
)
|
|
50
|
+
return meta
|
|
51
|
+
|
|
52
|
+
def sample_frames(
|
|
53
|
+
self,
|
|
54
|
+
video: VideoMeta,
|
|
55
|
+
target_fps: Optional[float] = None,
|
|
56
|
+
start_sec: Optional[float] = None,
|
|
57
|
+
end_sec: Optional[float] = None,
|
|
58
|
+
) -> List[FrameRecord]:
|
|
59
|
+
"""
|
|
60
|
+
Extract frames from a video at the target rate.
|
|
61
|
+
|
|
62
|
+
Parameters
|
|
63
|
+
----------
|
|
64
|
+
video : VideoMeta from load_video()
|
|
65
|
+
target_fps : frames per second to extract (uses instance default if None)
|
|
66
|
+
start_sec : start time in seconds
|
|
67
|
+
end_sec : end time in seconds
|
|
68
|
+
"""
|
|
69
|
+
fps = target_fps or self.target_fps
|
|
70
|
+
frames = sample_frames(video, target_fps=fps, start_sec=start_sec, end_sec=end_sec)
|
|
71
|
+
print(f"[PainFace] Sampled {len(frames)} frames at {fps} fps")
|
|
72
|
+
return frames
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# painface.detection
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# painface.features
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""
|
|
2
|
+
painface.io.loader
|
|
3
|
+
------------------
|
|
4
|
+
Handles loading images and videos into a standard format
|
|
5
|
+
that the rest of the PainFace pipeline can work with.
|
|
6
|
+
|
|
7
|
+
Think of this as the "intake desk" — everything that comes
|
|
8
|
+
into the system goes through here first.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import numpy as np
|
|
13
|
+
import cv2
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import List, Optional
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ── Data structures ────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class FrameRecord:
|
|
23
|
+
"""
|
|
24
|
+
One frame of data moving through the pipeline.
|
|
25
|
+
|
|
26
|
+
frame_index : which frame number this is (0, 1, 2, ...)
|
|
27
|
+
timestamp : time in seconds from the start of the video
|
|
28
|
+
image : the actual pixel data as a NumPy array (H x W x 3, BGR)
|
|
29
|
+
source_path : where the original file came from
|
|
30
|
+
"""
|
|
31
|
+
frame_index: int
|
|
32
|
+
timestamp: float # seconds
|
|
33
|
+
image: np.ndarray # H x W x 3, uint8, BGR
|
|
34
|
+
source_path: str = ""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class VideoMeta:
|
|
39
|
+
"""
|
|
40
|
+
Basic information about a video file — before we process any frames.
|
|
41
|
+
"""
|
|
42
|
+
path: str
|
|
43
|
+
total_frames: int
|
|
44
|
+
fps: float # frames per second in the original file
|
|
45
|
+
width: int
|
|
46
|
+
height: int
|
|
47
|
+
duration_seconds: float
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ── Image loader ───────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
def load_image(path: str) -> FrameRecord:
|
|
53
|
+
"""
|
|
54
|
+
Load a single image file (JPG, PNG, etc.) and return a FrameRecord.
|
|
55
|
+
|
|
56
|
+
Parameters
|
|
57
|
+
----------
|
|
58
|
+
path : str
|
|
59
|
+
Full path to the image file.
|
|
60
|
+
|
|
61
|
+
Returns
|
|
62
|
+
-------
|
|
63
|
+
FrameRecord
|
|
64
|
+
frame_index=0, timestamp=0.0, image as BGR NumPy array.
|
|
65
|
+
|
|
66
|
+
Raises
|
|
67
|
+
------
|
|
68
|
+
FileNotFoundError
|
|
69
|
+
If the file does not exist.
|
|
70
|
+
ValueError
|
|
71
|
+
If the file cannot be read as an image.
|
|
72
|
+
|
|
73
|
+
Example
|
|
74
|
+
-------
|
|
75
|
+
>>> record = load_image("/path/to/face.jpg")
|
|
76
|
+
>>> record.image.shape # (height, width, 3)
|
|
77
|
+
"""
|
|
78
|
+
path = str(path)
|
|
79
|
+
if not os.path.exists(path):
|
|
80
|
+
raise FileNotFoundError(f"Image not found: {path}")
|
|
81
|
+
|
|
82
|
+
image = cv2.imread(path)
|
|
83
|
+
if image is None:
|
|
84
|
+
raise ValueError(f"Could not read image (unsupported format?): {path}")
|
|
85
|
+
|
|
86
|
+
return FrameRecord(
|
|
87
|
+
frame_index=0,
|
|
88
|
+
timestamp=0.0,
|
|
89
|
+
image=image,
|
|
90
|
+
source_path=path,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ── Video meta ─────────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
def load_video(path: str) -> VideoMeta:
|
|
97
|
+
"""
|
|
98
|
+
Open a video file and return metadata without loading any frames yet.
|
|
99
|
+
Actual frames are pulled lazily by sample_frames().
|
|
100
|
+
|
|
101
|
+
Parameters
|
|
102
|
+
----------
|
|
103
|
+
path : str
|
|
104
|
+
Full path to the video file (mp4, avi, mov, …).
|
|
105
|
+
|
|
106
|
+
Returns
|
|
107
|
+
-------
|
|
108
|
+
VideoMeta
|
|
109
|
+
fps, dimensions, duration, total frame count.
|
|
110
|
+
|
|
111
|
+
Raises
|
|
112
|
+
------
|
|
113
|
+
FileNotFoundError
|
|
114
|
+
If the file does not exist.
|
|
115
|
+
ValueError
|
|
116
|
+
If OpenCV cannot open it.
|
|
117
|
+
"""
|
|
118
|
+
path = str(path)
|
|
119
|
+
if not os.path.exists(path):
|
|
120
|
+
raise FileNotFoundError(f"Video not found: {path}")
|
|
121
|
+
|
|
122
|
+
cap = cv2.VideoCapture(path)
|
|
123
|
+
if not cap.isOpened():
|
|
124
|
+
raise ValueError(f"Could not open video: {path}")
|
|
125
|
+
|
|
126
|
+
fps = cap.get(cv2.CAP_PROP_FPS) or 25.0
|
|
127
|
+
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
|
128
|
+
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
|
129
|
+
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
|
130
|
+
duration = total_frames / fps if fps > 0 else 0.0
|
|
131
|
+
cap.release()
|
|
132
|
+
|
|
133
|
+
return VideoMeta(
|
|
134
|
+
path=path,
|
|
135
|
+
total_frames=total_frames,
|
|
136
|
+
fps=fps,
|
|
137
|
+
width=width,
|
|
138
|
+
height=height,
|
|
139
|
+
duration_seconds=duration,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ── Frame sampler ──────────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
def sample_frames(
|
|
146
|
+
video: VideoMeta,
|
|
147
|
+
target_fps: float = 5.0,
|
|
148
|
+
start_sec: Optional[float] = None,
|
|
149
|
+
end_sec: Optional[float] = None,
|
|
150
|
+
) -> List[FrameRecord]:
|
|
151
|
+
"""
|
|
152
|
+
Extract frames from a video at a chosen rate.
|
|
153
|
+
|
|
154
|
+
Why not use every frame?
|
|
155
|
+
------------------------
|
|
156
|
+
A typical video is 25-30 fps. Processing all frames is slow and mostly
|
|
157
|
+
redundant — a face doesn't change meaningfully between frame 1 and frame 2
|
|
158
|
+
at 30 fps. Sampling at 5 fps gives us one frame every 0.2 seconds, which
|
|
159
|
+
is plenty for pain analysis.
|
|
160
|
+
|
|
161
|
+
Parameters
|
|
162
|
+
----------
|
|
163
|
+
video : VideoMeta returned by load_video()
|
|
164
|
+
target_fps : how many frames per second to extract (default 5)
|
|
165
|
+
start_sec : start time in seconds (None = beginning)
|
|
166
|
+
end_sec : end time in seconds (None = end of video)
|
|
167
|
+
|
|
168
|
+
Returns
|
|
169
|
+
-------
|
|
170
|
+
List[FrameRecord]
|
|
171
|
+
One FrameRecord per sampled frame, in order.
|
|
172
|
+
|
|
173
|
+
Example
|
|
174
|
+
-------
|
|
175
|
+
>>> meta = load_video("patient_video.mp4")
|
|
176
|
+
>>> frames = sample_frames(meta, target_fps=5)
|
|
177
|
+
>>> len(frames)
|
|
178
|
+
# roughly duration_seconds * 5
|
|
179
|
+
"""
|
|
180
|
+
if target_fps <= 0:
|
|
181
|
+
raise ValueError("target_fps must be positive")
|
|
182
|
+
|
|
183
|
+
src_fps = video.fps
|
|
184
|
+
# How many source frames to skip between each sample
|
|
185
|
+
# e.g. src_fps=30, target_fps=5 → step=6 (take every 6th frame)
|
|
186
|
+
step = max(1, round(src_fps / target_fps))
|
|
187
|
+
|
|
188
|
+
start_sec = start_sec or 0.0
|
|
189
|
+
end_sec = end_sec or video.duration_seconds
|
|
190
|
+
|
|
191
|
+
start_frame = max(0, int(start_sec * src_fps))
|
|
192
|
+
end_frame = min(video.total_frames, int(end_sec * src_fps))
|
|
193
|
+
|
|
194
|
+
cap = cv2.VideoCapture(video.path)
|
|
195
|
+
if not cap.isOpened():
|
|
196
|
+
raise ValueError(f"Could not open video for reading: {video.path}")
|
|
197
|
+
|
|
198
|
+
records: List[FrameRecord] = []
|
|
199
|
+
frame_idx = 0
|
|
200
|
+
|
|
201
|
+
cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
|
|
202
|
+
|
|
203
|
+
for src_idx in range(start_frame, end_frame):
|
|
204
|
+
ret, frame = cap.read()
|
|
205
|
+
if not ret:
|
|
206
|
+
break
|
|
207
|
+
|
|
208
|
+
# Only keep this frame if it falls on our sampling interval
|
|
209
|
+
if (src_idx - start_frame) % step == 0:
|
|
210
|
+
timestamp = src_idx / src_fps
|
|
211
|
+
records.append(FrameRecord(
|
|
212
|
+
frame_index=frame_idx,
|
|
213
|
+
timestamp=round(timestamp, 4),
|
|
214
|
+
image=frame.copy(),
|
|
215
|
+
source_path=video.path,
|
|
216
|
+
))
|
|
217
|
+
frame_idx += 1
|
|
218
|
+
|
|
219
|
+
cap.release()
|
|
220
|
+
return records
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# painface.landmarks
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# painface.pain
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# painface.utils
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: painfacenet
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Open-source Python framework for facial pain estimation via Action Units (research only)
|
|
5
|
+
Author: Shariff Naveed
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/shariff-bit/PainFace
|
|
8
|
+
Project-URL: Repository, https://github.com/shariff-bit/PainFace
|
|
9
|
+
Keywords: pain,facial,action units,AU,computer vision,research
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Intended Audience :: Science/Research
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
Requires-Dist: numpy>=1.24
|
|
20
|
+
Requires-Dist: opencv-python-headless>=4.8
|
|
21
|
+
Requires-Dist: pandas>=2.0
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
24
|
+
Requires-Dist: build; extra == "dev"
|
|
25
|
+
Requires-Dist: twine; extra == "dev"
|
|
26
|
+
Dynamic: requires-python
|
|
27
|
+
|
|
28
|
+
# PainFace
|
|
29
|
+
|
|
30
|
+
Open-source Python framework for facial pain estimation via Action Units (AUs).
|
|
31
|
+
|
|
32
|
+
**Research tool only — not a medical device.**
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install painface
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Quick Start
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from painface import PainFace
|
|
44
|
+
|
|
45
|
+
pf = PainFace()
|
|
46
|
+
|
|
47
|
+
# Load an image
|
|
48
|
+
record = pf.load_image("face.jpg")
|
|
49
|
+
|
|
50
|
+
# Load a video and sample frames
|
|
51
|
+
meta = pf.load_video("patient_video.mp4")
|
|
52
|
+
frames = pf.sample_frames(meta, target_fps=5)
|
|
53
|
+
|
|
54
|
+
print(f"Got {len(frames)} frames")
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Current Version (v0.1.0)
|
|
58
|
+
- Image loading
|
|
59
|
+
- Video loading & frame sampling
|
|
60
|
+
|
|
61
|
+
More modules coming: face detection, landmarks, AU estimation, pain scoring.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
setup.py
|
|
4
|
+
painface/__init__.py
|
|
5
|
+
painface/core.py
|
|
6
|
+
painface/aus/__init__.py
|
|
7
|
+
painface/detection/__init__.py
|
|
8
|
+
painface/features/__init__.py
|
|
9
|
+
painface/io/__init__.py
|
|
10
|
+
painface/io/loader.py
|
|
11
|
+
painface/landmarks/__init__.py
|
|
12
|
+
painface/pain/__init__.py
|
|
13
|
+
painface/utils/__init__.py
|
|
14
|
+
painfacenet.egg-info/PKG-INFO
|
|
15
|
+
painfacenet.egg-info/SOURCES.txt
|
|
16
|
+
painfacenet.egg-info/dependency_links.txt
|
|
17
|
+
painfacenet.egg-info/requires.txt
|
|
18
|
+
painfacenet.egg-info/top_level.txt
|
|
19
|
+
tests/test_io.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
painface
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "painfacenet"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Open-source Python framework for facial pain estimation via Action Units (research only)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "Shariff Naveed" }
|
|
13
|
+
]
|
|
14
|
+
keywords = ["pain", "facial", "action units", "AU", "computer vision", "research"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Intended Audience :: Science/Research",
|
|
22
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
23
|
+
]
|
|
24
|
+
requires-python = ">=3.11"
|
|
25
|
+
dependencies = [
|
|
26
|
+
"numpy>=1.24",
|
|
27
|
+
"opencv-python-headless>=4.8",
|
|
28
|
+
"pandas>=2.0",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
dev = ["pytest>=7.0", "build", "twine"]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://github.com/shariff-bit/PainFace"
|
|
36
|
+
Repository = "https://github.com/shariff-bit/PainFace"
|
|
37
|
+
|
|
38
|
+
[tool.setuptools.packages.find]
|
|
39
|
+
where = ["."]
|
|
40
|
+
include = ["painface*"]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from setuptools import setup, find_packages
|
|
2
|
+
|
|
3
|
+
setup(
|
|
4
|
+
name="painfacenet",
|
|
5
|
+
version="0.1.0",
|
|
6
|
+
description="Open-source Python framework for facial pain estimation (research only)",
|
|
7
|
+
author="Shariff",
|
|
8
|
+
packages=find_packages(),
|
|
9
|
+
python_requires=">=3.11",
|
|
10
|
+
install_requires=[
|
|
11
|
+
"numpy",
|
|
12
|
+
"opencv-python",
|
|
13
|
+
"pandas",
|
|
14
|
+
"torch",
|
|
15
|
+
"scikit-learn",
|
|
16
|
+
"matplotlib",
|
|
17
|
+
],
|
|
18
|
+
)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for painface.io — image and video loading.
|
|
3
|
+
|
|
4
|
+
Run with:
|
|
5
|
+
cd /Users/mohammednaveedshariff/Shariff/Research/PainFace
|
|
6
|
+
python -m pytest tests/test_io.py -v
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
import numpy as np
|
|
11
|
+
import cv2
|
|
12
|
+
import os
|
|
13
|
+
import tempfile
|
|
14
|
+
from painface.io.loader import load_image, load_video, sample_frames, FrameRecord, VideoMeta
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ── Helpers to create test files ───────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
def make_test_image(path: str, width=320, height=240):
|
|
20
|
+
"""Write a small solid-colour JPG to disk."""
|
|
21
|
+
img = np.zeros((height, width, 3), dtype=np.uint8)
|
|
22
|
+
img[:] = (100, 150, 200) # a blue-ish colour
|
|
23
|
+
cv2.imwrite(path, img)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def make_test_video(path: str, n_frames=30, fps=15, width=320, height=240):
|
|
27
|
+
"""Write a short synthetic MP4 to disk."""
|
|
28
|
+
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
|
|
29
|
+
out = cv2.VideoWriter(path, fourcc, fps, (width, height))
|
|
30
|
+
for i in range(n_frames):
|
|
31
|
+
frame = np.full((height, width, 3), i * 8 % 255, dtype=np.uint8)
|
|
32
|
+
out.write(frame)
|
|
33
|
+
out.release()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ── Image tests ────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
class TestLoadImage:
|
|
39
|
+
|
|
40
|
+
def test_returns_frame_record(self, tmp_path):
|
|
41
|
+
img_path = str(tmp_path / "test.jpg")
|
|
42
|
+
make_test_image(img_path)
|
|
43
|
+
record = load_image(img_path)
|
|
44
|
+
assert isinstance(record, FrameRecord)
|
|
45
|
+
|
|
46
|
+
def test_correct_shape(self, tmp_path):
|
|
47
|
+
img_path = str(tmp_path / "test.jpg")
|
|
48
|
+
make_test_image(img_path, width=320, height=240)
|
|
49
|
+
record = load_image(img_path)
|
|
50
|
+
assert record.image.shape == (240, 320, 3)
|
|
51
|
+
|
|
52
|
+
def test_frame_index_is_zero(self, tmp_path):
|
|
53
|
+
img_path = str(tmp_path / "test.jpg")
|
|
54
|
+
make_test_image(img_path)
|
|
55
|
+
record = load_image(img_path)
|
|
56
|
+
assert record.frame_index == 0
|
|
57
|
+
|
|
58
|
+
def test_timestamp_is_zero(self, tmp_path):
|
|
59
|
+
img_path = str(tmp_path / "test.jpg")
|
|
60
|
+
make_test_image(img_path)
|
|
61
|
+
record = load_image(img_path)
|
|
62
|
+
assert record.timestamp == 0.0
|
|
63
|
+
|
|
64
|
+
def test_source_path_stored(self, tmp_path):
|
|
65
|
+
img_path = str(tmp_path / "test.jpg")
|
|
66
|
+
make_test_image(img_path)
|
|
67
|
+
record = load_image(img_path)
|
|
68
|
+
assert record.source_path == img_path
|
|
69
|
+
|
|
70
|
+
def test_missing_file_raises(self):
|
|
71
|
+
with pytest.raises(FileNotFoundError):
|
|
72
|
+
load_image("/this/does/not/exist.jpg")
|
|
73
|
+
|
|
74
|
+
def test_invalid_file_raises(self, tmp_path):
|
|
75
|
+
bad = str(tmp_path / "fake.jpg")
|
|
76
|
+
with open(bad, "w") as f:
|
|
77
|
+
f.write("this is not an image")
|
|
78
|
+
with pytest.raises(ValueError):
|
|
79
|
+
load_image(bad)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ── Video meta tests ───────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
class TestLoadVideo:
|
|
85
|
+
|
|
86
|
+
def test_returns_video_meta(self, tmp_path):
|
|
87
|
+
vid_path = str(tmp_path / "test.mp4")
|
|
88
|
+
make_test_video(vid_path, n_frames=30, fps=15)
|
|
89
|
+
meta = load_video(vid_path)
|
|
90
|
+
assert isinstance(meta, VideoMeta)
|
|
91
|
+
|
|
92
|
+
def test_fps_correct(self, tmp_path):
|
|
93
|
+
vid_path = str(tmp_path / "test.mp4")
|
|
94
|
+
make_test_video(vid_path, n_frames=30, fps=15)
|
|
95
|
+
meta = load_video(vid_path)
|
|
96
|
+
assert abs(meta.fps - 15.0) < 1.0 # allow small codec rounding
|
|
97
|
+
|
|
98
|
+
def test_dimensions_correct(self, tmp_path):
|
|
99
|
+
vid_path = str(tmp_path / "test.mp4")
|
|
100
|
+
make_test_video(vid_path, n_frames=30, fps=15, width=320, height=240)
|
|
101
|
+
meta = load_video(vid_path)
|
|
102
|
+
assert meta.width == 320
|
|
103
|
+
assert meta.height == 240
|
|
104
|
+
|
|
105
|
+
def test_duration_reasonable(self, tmp_path):
|
|
106
|
+
vid_path = str(tmp_path / "test.mp4")
|
|
107
|
+
make_test_video(vid_path, n_frames=30, fps=15)
|
|
108
|
+
meta = load_video(vid_path)
|
|
109
|
+
# 30 frames at 15 fps = 2 seconds
|
|
110
|
+
assert abs(meta.duration_seconds - 2.0) < 0.5
|
|
111
|
+
|
|
112
|
+
def test_missing_file_raises(self):
|
|
113
|
+
with pytest.raises(FileNotFoundError):
|
|
114
|
+
load_video("/no/such/video.mp4")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ── Frame sampler tests ────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
class TestSampleFrames:
|
|
120
|
+
|
|
121
|
+
def test_returns_list_of_frame_records(self, tmp_path):
|
|
122
|
+
vid_path = str(tmp_path / "test.mp4")
|
|
123
|
+
make_test_video(vid_path, n_frames=30, fps=15)
|
|
124
|
+
meta = load_video(vid_path)
|
|
125
|
+
frames = sample_frames(meta, target_fps=5)
|
|
126
|
+
assert isinstance(frames, list)
|
|
127
|
+
assert all(isinstance(f, FrameRecord) for f in frames)
|
|
128
|
+
|
|
129
|
+
def test_fewer_frames_than_original(self, tmp_path):
|
|
130
|
+
vid_path = str(tmp_path / "test.mp4")
|
|
131
|
+
make_test_video(vid_path, n_frames=30, fps=15)
|
|
132
|
+
meta = load_video(vid_path)
|
|
133
|
+
frames = sample_frames(meta, target_fps=5)
|
|
134
|
+
# At 5fps from a 15fps video we expect roughly 1/3 the frames
|
|
135
|
+
assert len(frames) < meta.total_frames
|
|
136
|
+
|
|
137
|
+
def test_frame_indices_are_sequential(self, tmp_path):
|
|
138
|
+
vid_path = str(tmp_path / "test.mp4")
|
|
139
|
+
make_test_video(vid_path, n_frames=30, fps=15)
|
|
140
|
+
meta = load_video(vid_path)
|
|
141
|
+
frames = sample_frames(meta, target_fps=5)
|
|
142
|
+
indices = [f.frame_index for f in frames]
|
|
143
|
+
assert indices == list(range(len(frames)))
|
|
144
|
+
|
|
145
|
+
def test_timestamps_are_increasing(self, tmp_path):
|
|
146
|
+
vid_path = str(tmp_path / "test.mp4")
|
|
147
|
+
make_test_video(vid_path, n_frames=30, fps=15)
|
|
148
|
+
meta = load_video(vid_path)
|
|
149
|
+
frames = sample_frames(meta, target_fps=5)
|
|
150
|
+
timestamps = [f.timestamp for f in frames]
|
|
151
|
+
assert all(t2 >= t1 for t1, t2 in zip(timestamps, timestamps[1:]))
|
|
152
|
+
|
|
153
|
+
def test_each_frame_has_correct_shape(self, tmp_path):
|
|
154
|
+
vid_path = str(tmp_path / "test.mp4")
|
|
155
|
+
make_test_video(vid_path, n_frames=30, fps=15, width=320, height=240)
|
|
156
|
+
meta = load_video(vid_path)
|
|
157
|
+
frames = sample_frames(meta, target_fps=5)
|
|
158
|
+
for f in frames:
|
|
159
|
+
assert f.image.shape == (240, 320, 3)
|
|
160
|
+
|
|
161
|
+
def test_invalid_fps_raises(self, tmp_path):
|
|
162
|
+
vid_path = str(tmp_path / "test.mp4")
|
|
163
|
+
make_test_video(vid_path, n_frames=10, fps=15)
|
|
164
|
+
meta = load_video(vid_path)
|
|
165
|
+
with pytest.raises(ValueError):
|
|
166
|
+
sample_frames(meta, target_fps=0)
|
|
167
|
+
|
|
168
|
+
def test_start_end_sec_respected(self, tmp_path):
|
|
169
|
+
vid_path = str(tmp_path / "test.mp4")
|
|
170
|
+
make_test_video(vid_path, n_frames=60, fps=15)
|
|
171
|
+
meta = load_video(vid_path)
|
|
172
|
+
full = sample_frames(meta, target_fps=5)
|
|
173
|
+
clipped = sample_frames(meta, target_fps=5, start_sec=0.0, end_sec=1.0)
|
|
174
|
+
assert len(clipped) < len(full)
|