distant-frames 0.1.0__tar.gz → 0.3.1__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.
- {distant_frames-0.1.0 → distant_frames-0.3.1}/.github/workflows/publish.yml +2 -2
- {distant_frames-0.1.0 → distant_frames-0.3.1}/PKG-INFO +49 -16
- distant_frames-0.3.1/README.md +129 -0
- distant_frames-0.3.1/RELEASE_NOTES.md +126 -0
- distant_frames-0.3.1/distant_frames/cli.py +64 -0
- distant_frames-0.3.1/distant_frames/core.py +192 -0
- distant_frames-0.3.1/distant_frames/haarcascade_classifiers/haarcascade_eye.xml +12213 -0
- distant_frames-0.3.1/distant_frames/haarcascade_classifiers/haarcascade_frontalface_default.xml +33314 -0
- distant_frames-0.3.1/main.py +4 -0
- {distant_frames-0.1.0 → distant_frames-0.3.1}/pyproject.toml +8 -2
- {distant_frames-0.1.0 → distant_frames-0.3.1}/uv.lock +103 -2
- distant_frames-0.1.0/README.md +0 -98
- distant_frames-0.1.0/debug_import.py +0 -9
- distant_frames-0.1.0/distant_frames/cli.py +0 -15
- distant_frames-0.1.0/distant_frames/core.py +0 -134
- {distant_frames-0.1.0 → distant_frames-0.3.1}/.github/workflows/ci.yml +0 -0
- {distant_frames-0.1.0 → distant_frames-0.3.1}/.gitignore +0 -0
- {distant_frames-0.1.0 → distant_frames-0.3.1}/.python-version +0 -0
- {distant_frames-0.1.0 → distant_frames-0.3.1}/CONTRIBUTING.md +0 -0
- {distant_frames-0.1.0 → distant_frames-0.3.1}/LICENSE +0 -0
- {distant_frames-0.1.0 → distant_frames-0.3.1}/PUBLISHING.md +0 -0
- {distant_frames-0.1.0 → distant_frames-0.3.1}/distant_frames/__init__.py +0 -0
- {distant_frames-0.1.0 → distant_frames-0.3.1}/extracted_frames/.gitkeep +0 -0
- {distant_frames-0.1.0 → distant_frames-0.3.1}/generate_test_video.py +0 -0
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: distant-frames
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Smart video frame extraction tool
|
|
5
5
|
Project-URL: Homepage, https://github.com/yubraaj11/distant-frames
|
|
6
6
|
Project-URL: Repository, https://github.com/yubraaj11/distant-frames
|
|
7
7
|
Project-URL: Issues, https://github.com/yubraaj11/distant-frames/issues
|
|
8
|
+
Project-URL: ReleaseNotes, https://github.com/yubraaj11/distant-frames/blob/main/RELEASE_NOTES.md
|
|
8
9
|
Author-email: Yubraj Sigdel <yubrajsigdel1@gmail.com>
|
|
9
10
|
License: GPL-3.0-or-later
|
|
10
11
|
License-File: LICENSE
|
|
@@ -18,8 +19,15 @@ Classifier: Topic :: Multimedia :: Video
|
|
|
18
19
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
20
|
Requires-Python: >=3.12
|
|
20
21
|
Requires-Dist: opencv-python>=4.10.0
|
|
22
|
+
Requires-Dist: typer>=0.9.0
|
|
21
23
|
Description-Content-Type: text/markdown
|
|
22
24
|
|
|
25
|
+

|
|
26
|
+

|
|
27
|
+

|
|
28
|
+

|
|
29
|
+

|
|
30
|
+
|
|
23
31
|
# Distant Frames
|
|
24
32
|
|
|
25
33
|
**Distant Frames** is a smart video frame extraction tool designed to capture distinct visual moments from video files. Instead of simply saving every Nth frame, it analyzes the visual similarity between consecutive potential frames and only saves those that are sufficiently different.
|
|
@@ -30,6 +38,8 @@ Description-Content-Type: text/markdown
|
|
|
30
38
|
- **Histogram Correlation**: Uses HSV color space histogram comparison for robust similarity detection.
|
|
31
39
|
- **Configurable Threshold**: Fine-tune the sensitivity of frame dropping to suit your specific video content.
|
|
32
40
|
- **Efficient Processing**: Seeks directly to target timestamps (`CAP_PROP_POS_FRAMES`) for faster processing than frame-by-frame reading.
|
|
41
|
+
- **Custom Start Time**: Begin extraction from any point in the video using a timestamp in seconds.
|
|
42
|
+
- **Open Eyes Filter**: Optionally keep only frames where at least one face with both eyes open is detected, using local Haar cascade classifiers.
|
|
33
43
|
|
|
34
44
|
## 🛠️ Prerequisites
|
|
35
45
|
|
|
@@ -50,9 +60,9 @@ or
|
|
|
50
60
|
uv add distant-frames
|
|
51
61
|
```
|
|
52
62
|
|
|
53
|
-
### From Source (
|
|
63
|
+
### From Source (Local)
|
|
54
64
|
|
|
55
|
-
|
|
65
|
+
Clone the repo and run directly without installing the package:
|
|
56
66
|
|
|
57
67
|
1. **Clone the repository:**
|
|
58
68
|
```bash
|
|
@@ -60,18 +70,28 @@ For development or to use the latest unreleased features:
|
|
|
60
70
|
cd distant-frames
|
|
61
71
|
```
|
|
62
72
|
|
|
63
|
-
2. **Install
|
|
73
|
+
2. **Install dependencies:**
|
|
64
74
|
```bash
|
|
65
75
|
uv sync --frozen
|
|
66
76
|
```
|
|
67
77
|
|
|
78
|
+
3. **Run via `main.py`:**
|
|
79
|
+
```bash
|
|
80
|
+
uv run main.py path/to/video.mp4 -o output_dir -t 0.75
|
|
81
|
+
```
|
|
82
|
+
|
|
68
83
|
## 💻 Usage
|
|
69
84
|
|
|
70
|
-
###
|
|
71
|
-
|
|
85
|
+
### Installed package
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
distant-frames path/to/video.mp4 -o path/to/output -t 0.75
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Cloned repo (no install)
|
|
72
92
|
|
|
73
93
|
```bash
|
|
74
|
-
uv run
|
|
94
|
+
uv run main.py path/to/video.mp4 -o path/to/output -t 0.75
|
|
75
95
|
```
|
|
76
96
|
|
|
77
97
|
### Options
|
|
@@ -79,34 +99,47 @@ uv run distant_frames/cli.py path/to/your/video.mp4
|
|
|
79
99
|
| Argument | Description | Default |
|
|
80
100
|
|----------|-------------|---------|
|
|
81
101
|
| `video_path` | Path to the input video file (Required). | N/A |
|
|
82
|
-
| `--output` | Directory to save the extracted frames. | `extracted_frames` |
|
|
83
|
-
| `--threshold` | Similarity threshold (0.0 to 1.0). Frames with
|
|
102
|
+
| `--output`, `-o` | Directory to save the extracted frames. | `extracted_frames` |
|
|
103
|
+
| `--threshold`, `-t` | Similarity score threshold (0.0 to 1.0). Frames with a score **higher** than this value are discarded. | `0.65` |
|
|
104
|
+
| `--start`, `-s` | Timestamp in seconds to begin extraction from. | `0.0` |
|
|
105
|
+
| `--open-eyes` | When set, only saves frames where at least one face with both eyes open is detected. | Off |
|
|
84
106
|
|
|
85
107
|
### Examples
|
|
86
108
|
|
|
87
109
|
**Extract frames with default settings:**
|
|
88
110
|
```bash
|
|
89
|
-
|
|
111
|
+
distant-frames my_vacation.mp4
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
**Save to a custom folder with a stricter similarity check:**
|
|
115
|
+
```bash
|
|
116
|
+
distant-frames my_vacation.mp4 -o best_shots -t 0.95
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
**Start extraction from a specific timestamp (e.g. 1 minute 30 seconds in):**
|
|
120
|
+
```bash
|
|
121
|
+
distant-frames interview.mp4 -s 90
|
|
90
122
|
```
|
|
91
123
|
|
|
92
|
-
**
|
|
124
|
+
**Only keep frames where a person's eyes are open:**
|
|
93
125
|
```bash
|
|
94
|
-
|
|
126
|
+
distant-frames interview.mp4 --open-eyes -o key_frames
|
|
95
127
|
```
|
|
96
128
|
|
|
97
|
-
**
|
|
129
|
+
**Combine all options:**
|
|
98
130
|
```bash
|
|
99
|
-
|
|
131
|
+
distant-frames interview.mp4 -s 90 -t 0.80 --open-eyes -o key_frames
|
|
100
132
|
```
|
|
101
133
|
|
|
102
134
|
## 🔍 How It Works
|
|
103
135
|
|
|
104
|
-
1. **Sampling**: The script checks one frame every second (based on the video's FPS).
|
|
136
|
+
1. **Sampling**: The script checks one frame every second (based on the video's FPS), starting from `--start` if provided.
|
|
105
137
|
2. **Comparison**: It compares the current candidate frame against the **last successfully saved frame**.
|
|
106
138
|
3. **Algorithm**: It converts frames to HSV color space and calculates Normalized Histogram Correlation.
|
|
107
139
|
4. **Decision**:
|
|
108
|
-
- If similarity < `threshold`:
|
|
140
|
+
- If similarity < `threshold`: candidate for saving.
|
|
109
141
|
- If similarity >= `threshold`: **SKIP** (The scene is too similar).
|
|
142
|
+
5. **Open Eyes Filter** *(optional)*: If `--open-eyes` is set, a candidate frame is only saved if a face with two open eyes is detected using Haar cascade classifiers.
|
|
110
143
|
|
|
111
144
|
## 🧪 Testing
|
|
112
145
|
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+

|
|
2
|
+

|
|
3
|
+

|
|
4
|
+

|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
# Distant Frames
|
|
8
|
+
|
|
9
|
+
**Distant Frames** is a smart video frame extraction tool designed to capture distinct visual moments from video files. Instead of simply saving every Nth frame, it analyzes the visual similarity between consecutive potential frames and only saves those that are sufficiently different.
|
|
10
|
+
|
|
11
|
+
## 🚀 Features
|
|
12
|
+
|
|
13
|
+
- **Smart Deduplication**: Avoids saving redundant frames where the scene hasn't changed.
|
|
14
|
+
- **Histogram Correlation**: Uses HSV color space histogram comparison for robust similarity detection.
|
|
15
|
+
- **Configurable Threshold**: Fine-tune the sensitivity of frame dropping to suit your specific video content.
|
|
16
|
+
- **Efficient Processing**: Seeks directly to target timestamps (`CAP_PROP_POS_FRAMES`) for faster processing than frame-by-frame reading.
|
|
17
|
+
- **Custom Start Time**: Begin extraction from any point in the video using a timestamp in seconds.
|
|
18
|
+
- **Open Eyes Filter**: Optionally keep only frames where at least one face with both eyes open is detected, using local Haar cascade classifiers.
|
|
19
|
+
|
|
20
|
+
## 🛠️ Prerequisites
|
|
21
|
+
|
|
22
|
+
- **Python**: 3.12 or higher
|
|
23
|
+
- **Dependencies**: `opencv-python`
|
|
24
|
+
|
|
25
|
+
## 📦 Installation
|
|
26
|
+
|
|
27
|
+
### From PyPI (Recommended)
|
|
28
|
+
|
|
29
|
+
Install the latest stable release using pip:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install distant-frames
|
|
33
|
+
|
|
34
|
+
or
|
|
35
|
+
|
|
36
|
+
uv add distant-frames
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### From Source (Local)
|
|
40
|
+
|
|
41
|
+
Clone the repo and run directly without installing the package:
|
|
42
|
+
|
|
43
|
+
1. **Clone the repository:**
|
|
44
|
+
```bash
|
|
45
|
+
git clone git@github.com:yubraaj11/distant-frames.git
|
|
46
|
+
cd distant-frames
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
2. **Install dependencies:**
|
|
50
|
+
```bash
|
|
51
|
+
uv sync --frozen
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
3. **Run via `main.py`:**
|
|
55
|
+
```bash
|
|
56
|
+
uv run main.py path/to/video.mp4 -o output_dir -t 0.75
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## 💻 Usage
|
|
60
|
+
|
|
61
|
+
### Installed package
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
distant-frames path/to/video.mp4 -o path/to/output -t 0.75
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Cloned repo (no install)
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
uv run main.py path/to/video.mp4 -o path/to/output -t 0.75
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Options
|
|
74
|
+
|
|
75
|
+
| Argument | Description | Default |
|
|
76
|
+
|----------|-------------|---------|
|
|
77
|
+
| `video_path` | Path to the input video file (Required). | N/A |
|
|
78
|
+
| `--output`, `-o` | Directory to save the extracted frames. | `extracted_frames` |
|
|
79
|
+
| `--threshold`, `-t` | Similarity score threshold (0.0 to 1.0). Frames with a score **higher** than this value are discarded. | `0.65` |
|
|
80
|
+
| `--start`, `-s` | Timestamp in seconds to begin extraction from. | `0.0` |
|
|
81
|
+
| `--open-eyes` | When set, only saves frames where at least one face with both eyes open is detected. | Off |
|
|
82
|
+
|
|
83
|
+
### Examples
|
|
84
|
+
|
|
85
|
+
**Extract frames with default settings:**
|
|
86
|
+
```bash
|
|
87
|
+
distant-frames my_vacation.mp4
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
**Save to a custom folder with a stricter similarity check:**
|
|
91
|
+
```bash
|
|
92
|
+
distant-frames my_vacation.mp4 -o best_shots -t 0.95
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
**Start extraction from a specific timestamp (e.g. 1 minute 30 seconds in):**
|
|
96
|
+
```bash
|
|
97
|
+
distant-frames interview.mp4 -s 90
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
**Only keep frames where a person's eyes are open:**
|
|
101
|
+
```bash
|
|
102
|
+
distant-frames interview.mp4 --open-eyes -o key_frames
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
**Combine all options:**
|
|
106
|
+
```bash
|
|
107
|
+
distant-frames interview.mp4 -s 90 -t 0.80 --open-eyes -o key_frames
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## 🔍 How It Works
|
|
111
|
+
|
|
112
|
+
1. **Sampling**: The script checks one frame every second (based on the video's FPS), starting from `--start` if provided.
|
|
113
|
+
2. **Comparison**: It compares the current candidate frame against the **last successfully saved frame**.
|
|
114
|
+
3. **Algorithm**: It converts frames to HSV color space and calculates Normalized Histogram Correlation.
|
|
115
|
+
4. **Decision**:
|
|
116
|
+
- If similarity < `threshold`: candidate for saving.
|
|
117
|
+
- If similarity >= `threshold`: **SKIP** (The scene is too similar).
|
|
118
|
+
5. **Open Eyes Filter** *(optional)*: If `--open-eyes` is set, a candidate frame is only saved if a face with two open eyes is detected using Haar cascade classifiers.
|
|
119
|
+
|
|
120
|
+
## 🧪 Testing
|
|
121
|
+
|
|
122
|
+
You can generate a test video to verify the functionality:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
uv run generate_test_video.py
|
|
126
|
+
uv run main.py test_video.mp4
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
This will create a `test_video.mp4` with known scene changes and then extract frames from it, demonstrating the deduplication logic.
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# Release Notes
|
|
2
|
+
|
|
3
|
+
## Version 0.3.1
|
|
4
|
+
|
|
5
|
+
### Fix
|
|
6
|
+
|
|
7
|
+
- **Local entry point**: Added `main.py` at the repo root so users who clone the repository can run the tool directly with `uv run main.py` without installing the package.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Version 0.3.0
|
|
12
|
+
|
|
13
|
+
### New Features
|
|
14
|
+
|
|
15
|
+
- **Start timestamp** (`--start` / `-s`): Begin extraction from any point in the video by passing a timestamp in seconds. The video duration is validated upfront, and the startup log now shows duration and the effective start time.
|
|
16
|
+
|
|
17
|
+
- **Open-eyes filter** (`--open-eyes`): When enabled, only frames where at least one face with both eyes open is detected are saved. Detection uses a two-stage Haar cascade pipeline (face → eye ROI) and runs only on frames that have already passed the similarity check, so it adds no overhead on skipped frames.
|
|
18
|
+
|
|
19
|
+
- **Local Haar cascade classifiers**: Cascade XML files are now loaded from the project-local `haarcascade_classifiers/` directory (`haarcascade_frontalface_default.xml` and `haarcascade_eye.xml`) instead of the OpenCV bundle. No new dependencies are required.
|
|
20
|
+
|
|
21
|
+
### Usage Examples
|
|
22
|
+
|
|
23
|
+
**Start from 90 seconds in:**
|
|
24
|
+
```bash
|
|
25
|
+
distant-frames interview.mp4 -s 90
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
**Only keep frames with open eyes:**
|
|
29
|
+
```bash
|
|
30
|
+
distant-frames interview.mp4 --open-eyes -o key_frames
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**Combine all options:**
|
|
34
|
+
```bash
|
|
35
|
+
distant-frames interview.mp4 -s 90 -t 0.80 --open-eyes -o key_frames
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Version 0.2.1
|
|
41
|
+
|
|
42
|
+
### Updated File Name
|
|
43
|
+
|
|
44
|
+
- **Concise Filenames**: Extracted frames now use a shortened 8-character UUID (e.g., `_frame_a1b2c3d4.jpg`) instead of the full 32-character UUID. This ensures uniqueness while keeping filenames cleaner and easier to manage.
|
|
45
|
+
|
|
46
|
+
## Version 0.2.0
|
|
47
|
+
|
|
48
|
+
### 🎨 Enhanced CLI Interface
|
|
49
|
+
|
|
50
|
+
We've completely redesigned the command-line interface using **Typer** for a significantly improved user experience!
|
|
51
|
+
|
|
52
|
+
#### ✨ New Features
|
|
53
|
+
|
|
54
|
+
- **Rich Terminal Output**: Beautiful, formatted help text with tables and color-coded sections
|
|
55
|
+
- **Short Option Flags**: Added convenient shortcuts:
|
|
56
|
+
- `-o` for `--output`
|
|
57
|
+
- `-t` for `--threshold`
|
|
58
|
+
- **Automatic Validation**: Built-in input validation with helpful error messages
|
|
59
|
+
- File existence checking before processing
|
|
60
|
+
- Threshold range validation (0.0-1.0)
|
|
61
|
+
- Readable file verification
|
|
62
|
+
|
|
63
|
+
#### 🔧 Improvements
|
|
64
|
+
|
|
65
|
+
- **Better Help Messages**: Clear, comprehensive help text with detailed parameter descriptions
|
|
66
|
+
- **Type Safety**: Full type hints for all CLI parameters with automatic validation
|
|
67
|
+
- **Enhanced Error Handling**: User-friendly error messages with suggestions and proper formatting
|
|
68
|
+
- **Cleaner Code**: More maintainable and declarative CLI implementation
|
|
69
|
+
|
|
70
|
+
#### 📦 Dependencies
|
|
71
|
+
|
|
72
|
+
- Added `typer>=0.9.0` for improved CLI functionality
|
|
73
|
+
|
|
74
|
+
#### 🎯 Usage Examples
|
|
75
|
+
|
|
76
|
+
**Basic usage:**
|
|
77
|
+
```bash
|
|
78
|
+
distant-frames video.mp4
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
**With custom output directory:**
|
|
82
|
+
```bash
|
|
83
|
+
distant-frames video.mp4 -o my_frames
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**With custom threshold:**
|
|
87
|
+
```bash
|
|
88
|
+
distant-frames video.mp4 -t 0.8
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**Combined options:**
|
|
92
|
+
```bash
|
|
93
|
+
distant-frames video.mp4 -o output_frames -t 0.7
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
**View help:**
|
|
97
|
+
```bash
|
|
98
|
+
distant-frames --help
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
#### 🐛 Bug Fixes
|
|
102
|
+
|
|
103
|
+
- Improved error messages when video file is not found
|
|
104
|
+
- Better validation for threshold parameter values
|
|
105
|
+
|
|
106
|
+
#### ⚠️ Breaking Changes
|
|
107
|
+
|
|
108
|
+
None - the CLI interface remains backward compatible with existing usage patterns.
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## Version 0.1.2
|
|
113
|
+
|
|
114
|
+
### Features
|
|
115
|
+
|
|
116
|
+
- Smart frame extraction with similarity-based deduplication
|
|
117
|
+
- Fallback mechanism for gradual scene changes
|
|
118
|
+
- Verbose logging for debugging
|
|
119
|
+
- HSV-based histogram comparison for robust similarity detection
|
|
120
|
+
|
|
121
|
+
### Core Functionality
|
|
122
|
+
|
|
123
|
+
- Extract frames at 1-second intervals
|
|
124
|
+
- Skip similar frames based on configurable threshold
|
|
125
|
+
- Automatic output directory creation
|
|
126
|
+
- Detailed frame-by-frame logging
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing_extensions import Annotated
|
|
4
|
+
from distant_frames.core import extract_frames
|
|
5
|
+
|
|
6
|
+
app = typer.Typer(
|
|
7
|
+
help="Smart video frame extraction tool with similarity-based deduplication.",
|
|
8
|
+
add_completion=False,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
@app.command()
|
|
12
|
+
def main(
|
|
13
|
+
video_path: Annotated[
|
|
14
|
+
Path,
|
|
15
|
+
typer.Argument(
|
|
16
|
+
help="Path to the input video file",
|
|
17
|
+
exists=True,
|
|
18
|
+
file_okay=True,
|
|
19
|
+
dir_okay=False,
|
|
20
|
+
readable=True,
|
|
21
|
+
)
|
|
22
|
+
],
|
|
23
|
+
output: Annotated[
|
|
24
|
+
str,
|
|
25
|
+
typer.Option(
|
|
26
|
+
"--output", "-o",
|
|
27
|
+
help="Output directory for extracted frames"
|
|
28
|
+
)
|
|
29
|
+
] = "extracted_frames",
|
|
30
|
+
threshold: Annotated[
|
|
31
|
+
float,
|
|
32
|
+
typer.Option(
|
|
33
|
+
"--threshold", "-t",
|
|
34
|
+
min=0.0,
|
|
35
|
+
max=1.0,
|
|
36
|
+
help="Similarity threshold (0.0-1.0). Higher values mean stricter deduplication (fewer frames saved)."
|
|
37
|
+
)
|
|
38
|
+
] = 0.75,
|
|
39
|
+
start_time: Annotated[
|
|
40
|
+
float,
|
|
41
|
+
typer.Option(
|
|
42
|
+
"--start", "-s",
|
|
43
|
+
min=0.0,
|
|
44
|
+
help="Start extraction from this timestamp (in seconds). Defaults to 0 (beginning of video)."
|
|
45
|
+
)
|
|
46
|
+
] = 0.0,
|
|
47
|
+
open_eyes_only: Annotated[
|
|
48
|
+
bool,
|
|
49
|
+
typer.Option(
|
|
50
|
+
"--open-eyes",
|
|
51
|
+
help="Only save frames where at least one face with both eyes open is detected."
|
|
52
|
+
)
|
|
53
|
+
] = False,
|
|
54
|
+
):
|
|
55
|
+
"""
|
|
56
|
+
Extract distinct frames from a video file based on visual similarity.
|
|
57
|
+
|
|
58
|
+
The tool samples the video at 1-second intervals and compares consecutive frames.
|
|
59
|
+
Frames that are too similar to previously saved frames are automatically skipped.
|
|
60
|
+
"""
|
|
61
|
+
extract_frames(str(video_path), output, threshold, start_time, open_eyes_only)
|
|
62
|
+
|
|
63
|
+
if __name__ == "__main__":
|
|
64
|
+
app()
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import cv2
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import uuid
|
|
5
|
+
|
|
6
|
+
_CASCADES_DIR = Path(__file__).parent / "haarcascade_classifiers"
|
|
7
|
+
|
|
8
|
+
def _load_eye_cascades():
|
|
9
|
+
"""Load and return (face_cascade, eye_cascade) from the local haarcascade_classifiers/ directory."""
|
|
10
|
+
face_path = str(_CASCADES_DIR / "haarcascade_frontalface_default.xml")
|
|
11
|
+
eye_path = str(_CASCADES_DIR / "haarcascade_eye.xml")
|
|
12
|
+
face_cascade = cv2.CascadeClassifier(face_path)
|
|
13
|
+
eye_cascade = cv2.CascadeClassifier(eye_path)
|
|
14
|
+
if face_cascade.empty() or eye_cascade.empty():
|
|
15
|
+
raise RuntimeError(
|
|
16
|
+
f"Failed to load Haar cascade classifiers from {_CASCADES_DIR}. "
|
|
17
|
+
"Ensure haarcascade_frontalface_default.xml and haarcascade_eye.xml exist there."
|
|
18
|
+
)
|
|
19
|
+
return face_cascade, eye_cascade
|
|
20
|
+
|
|
21
|
+
def has_open_eyes(frame, face_cascade, eye_cascade):
|
|
22
|
+
"""Return True if at least one face with two detected (open) eyes is found in the frame."""
|
|
23
|
+
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
|
24
|
+
faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5, minSize=(30, 30))
|
|
25
|
+
for (x, y, w, h) in faces:
|
|
26
|
+
roi = gray[y:y + h, x:x + w]
|
|
27
|
+
eyes = eye_cascade.detectMultiScale(roi, scaleFactor=1.1, minNeighbors=5)
|
|
28
|
+
if len(eyes) >= 2:
|
|
29
|
+
return True
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
def calculate_similarity(frame1, frame2):
|
|
33
|
+
"""Calculates similarity between two frames using Histogram Correlation.
|
|
34
|
+
|
|
35
|
+
Converts frames to HSV color space and compares their normalized histograms.
|
|
36
|
+
This method is generally robust to lighting changes.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
frame1: The first video frame (BGR image/numpy array).
|
|
40
|
+
frame2: The second video frame (BGR image/numpy array).
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
float: A similarity score between 0.0 (distinct) and 1.0 (identical).
|
|
44
|
+
"""
|
|
45
|
+
# Convert to HSV for better color handling, or Gray if color doesn't matter much.
|
|
46
|
+
# HSV is generally more robust to lighting changes than BGR.
|
|
47
|
+
hsv1 = cv2.cvtColor(frame1, cv2.COLOR_BGR2HSV)
|
|
48
|
+
hsv2 = cv2.cvtColor(frame2, cv2.COLOR_BGR2HSV)
|
|
49
|
+
|
|
50
|
+
# Calculate histograms
|
|
51
|
+
# Channels 0 and 1 (Hue and Saturation) are usually enough
|
|
52
|
+
hist1 = cv2.calcHist([hsv1], [0, 1], None, [180, 256], [0, 180, 0, 256])
|
|
53
|
+
hist2 = cv2.calcHist([hsv2], [0, 1], None, [180, 256], [0, 180, 0, 256])
|
|
54
|
+
|
|
55
|
+
# Normalize histograms
|
|
56
|
+
cv2.normalize(hist1, hist1, 0, 1, cv2.NORM_MINMAX)
|
|
57
|
+
cv2.normalize(hist2, hist2, 0, 1, cv2.NORM_MINMAX)
|
|
58
|
+
|
|
59
|
+
# Compare histograms
|
|
60
|
+
similarity = cv2.compareHist(hist1, hist2, cv2.HISTCMP_CORREL)
|
|
61
|
+
return similarity
|
|
62
|
+
|
|
63
|
+
def extract_frames(video_path, output_folder, threshold=0.65, start_time=0.0, open_eyes_only=False):
|
|
64
|
+
"""Extracts distinct frames from a video file based on visual similarity.
|
|
65
|
+
|
|
66
|
+
The function samples the video at 1-second intervals starting from
|
|
67
|
+
`start_time`. It compares the current frame with the last saved frame.
|
|
68
|
+
If the similarity score is below the specified threshold, the frame is
|
|
69
|
+
considered distinct and saved.
|
|
70
|
+
|
|
71
|
+
If a frame is skipped (similar to the last saved frame), the next comparison
|
|
72
|
+
will be performed against the *previous* saved frame (if available) to ensure
|
|
73
|
+
robustness against gradual changes or local similarities.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
video_path (str): Path to the input video file.
|
|
77
|
+
output_folder (str): Directory where extracted frames will be saved.
|
|
78
|
+
The directory will be created if it does not exist.
|
|
79
|
+
threshold (float, optional): Similarity threshold (0.0 to 1.0).
|
|
80
|
+
Frames with similarity higher than this value regarding the last
|
|
81
|
+
saved frame will be dropped. Defaults to 0.65.
|
|
82
|
+
start_time (float, optional): Timestamp in seconds from which to begin
|
|
83
|
+
extraction. Defaults to 0.0 (beginning of video).
|
|
84
|
+
open_eyes_only (bool, optional): When True, only frames where at least
|
|
85
|
+
one face with two open eyes is detected are saved. Uses OpenCV
|
|
86
|
+
Haar cascade classifiers. Defaults to False.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
None
|
|
90
|
+
"""
|
|
91
|
+
if not os.path.exists(video_path):
|
|
92
|
+
print(f"Error: Video file not found at {video_path}")
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
if not os.path.exists(output_folder):
|
|
96
|
+
os.makedirs(output_folder)
|
|
97
|
+
|
|
98
|
+
face_cascade, eye_cascade = (_load_eye_cascades() if open_eyes_only else (None, None))
|
|
99
|
+
|
|
100
|
+
video_file_name = Path(video_path).stem
|
|
101
|
+
cap = cv2.VideoCapture(str(video_path))
|
|
102
|
+
if not cap.isOpened():
|
|
103
|
+
print("Error: Could not open video.")
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
fps = cap.get(cv2.CAP_PROP_FPS)
|
|
107
|
+
if fps == 0:
|
|
108
|
+
print("Error: Could not retrieve FPS.")
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
|
112
|
+
duration = total_frames / fps
|
|
113
|
+
if start_time >= duration:
|
|
114
|
+
print(f"Error: start_time ({start_time:.1f}s) is beyond the video duration ({duration:.1f}s).")
|
|
115
|
+
cap.release()
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
print(f"Video FPS: {fps} | Duration: {duration:.1f}s | Starting at: {start_time:.1f}s")
|
|
119
|
+
|
|
120
|
+
# We want to check frames every 1 second
|
|
121
|
+
frame_interval = int(fps)
|
|
122
|
+
|
|
123
|
+
current_frame_idx = int(start_time * fps)
|
|
124
|
+
saved_count = 0
|
|
125
|
+
last_saved_frame = None
|
|
126
|
+
last_saved_timestamp = None
|
|
127
|
+
skip_reference_frame = None
|
|
128
|
+
skip_reference_timestamp = None
|
|
129
|
+
|
|
130
|
+
while True:
|
|
131
|
+
# Set position to the next second
|
|
132
|
+
# Note: Setting pos_frames is faster than reading every frame
|
|
133
|
+
cap.set(cv2.CAP_PROP_POS_FRAMES, current_frame_idx)
|
|
134
|
+
|
|
135
|
+
ret, frame = cap.read()
|
|
136
|
+
if not ret:
|
|
137
|
+
break
|
|
138
|
+
|
|
139
|
+
timestamp = current_frame_idx / fps
|
|
140
|
+
should_save = False
|
|
141
|
+
|
|
142
|
+
if last_saved_frame is None:
|
|
143
|
+
should_save = True
|
|
144
|
+
similarity = 0.0 # No previous frame
|
|
145
|
+
print(f"[{timestamp:.1f}s] First frame → SAVE")
|
|
146
|
+
else:
|
|
147
|
+
# Determine reference frame
|
|
148
|
+
# If we have a skip reference (from previous skip), use that
|
|
149
|
+
# Otherwise use the last saved frame
|
|
150
|
+
if skip_reference_frame is not None:
|
|
151
|
+
reference_frame = skip_reference_frame
|
|
152
|
+
ref_timestamp = skip_reference_timestamp
|
|
153
|
+
ref_label = "skip_ref"
|
|
154
|
+
else:
|
|
155
|
+
reference_frame = last_saved_frame
|
|
156
|
+
ref_timestamp = last_saved_timestamp
|
|
157
|
+
ref_label = "last"
|
|
158
|
+
|
|
159
|
+
similarity = calculate_similarity(reference_frame, frame)
|
|
160
|
+
|
|
161
|
+
if similarity < threshold:
|
|
162
|
+
should_save = True
|
|
163
|
+
print(f"[{timestamp:.1f}s] vs {ref_label}@{ref_timestamp:.1f}s | sim={similarity:.3f} → SAVE")
|
|
164
|
+
# Clear skip reference when we save
|
|
165
|
+
skip_reference_frame = None
|
|
166
|
+
skip_reference_timestamp = None
|
|
167
|
+
else:
|
|
168
|
+
print(f"[{timestamp:.1f}s] vs {ref_label}@{ref_timestamp:.1f}s | sim={similarity:.3f} → SKIP")
|
|
169
|
+
# When we skip, set the skip reference to the last saved frame
|
|
170
|
+
# so next comparison uses this same reference
|
|
171
|
+
skip_reference_frame = last_saved_frame
|
|
172
|
+
skip_reference_timestamp = last_saved_timestamp
|
|
173
|
+
|
|
174
|
+
if should_save and open_eyes_only:
|
|
175
|
+
if not has_open_eyes(frame, face_cascade, eye_cascade):
|
|
176
|
+
print(f"[{timestamp:.1f}s] Open-eyes check failed → SKIP")
|
|
177
|
+
should_save = False
|
|
178
|
+
|
|
179
|
+
if should_save:
|
|
180
|
+
output_filename = os.path.join(output_folder, f"{video_file_name}_frame_{uuid.uuid4().hex[:8]}.jpg")
|
|
181
|
+
cv2.imwrite(output_filename, frame)
|
|
182
|
+
|
|
183
|
+
# Update last saved frame
|
|
184
|
+
last_saved_frame = frame
|
|
185
|
+
last_saved_timestamp = timestamp
|
|
186
|
+
|
|
187
|
+
saved_count += 1
|
|
188
|
+
|
|
189
|
+
current_frame_idx += frame_interval
|
|
190
|
+
|
|
191
|
+
cap.release()
|
|
192
|
+
print(f"Done. Extracted {saved_count} frames.")
|