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.
@@ -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,11 @@
1
+ """
2
+ PainFace — Open-source Python framework for facial pain estimation.
3
+ Research tool only. Not a medical device.
4
+ """
5
+
6
+ __version__ = "0.1.0"
7
+ __author__ = "Shariff"
8
+
9
+ from painface.core import PainFace
10
+
11
+ __all__ = ["PainFace"]
@@ -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,3 @@
1
+ from painface.io.loader import load_image, load_video, sample_frames
2
+
3
+ __all__ = ["load_image", "load_video", "sample_frames"]
@@ -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,8 @@
1
+ numpy>=1.24
2
+ opencv-python-headless>=4.8
3
+ pandas>=2.0
4
+
5
+ [dev]
6
+ pytest>=7.0
7
+ build
8
+ twine
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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)