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.
Files changed (24) hide show
  1. {distant_frames-0.1.0 → distant_frames-0.3.1}/.github/workflows/publish.yml +2 -2
  2. {distant_frames-0.1.0 → distant_frames-0.3.1}/PKG-INFO +49 -16
  3. distant_frames-0.3.1/README.md +129 -0
  4. distant_frames-0.3.1/RELEASE_NOTES.md +126 -0
  5. distant_frames-0.3.1/distant_frames/cli.py +64 -0
  6. distant_frames-0.3.1/distant_frames/core.py +192 -0
  7. distant_frames-0.3.1/distant_frames/haarcascade_classifiers/haarcascade_eye.xml +12213 -0
  8. distant_frames-0.3.1/distant_frames/haarcascade_classifiers/haarcascade_frontalface_default.xml +33314 -0
  9. distant_frames-0.3.1/main.py +4 -0
  10. {distant_frames-0.1.0 → distant_frames-0.3.1}/pyproject.toml +8 -2
  11. {distant_frames-0.1.0 → distant_frames-0.3.1}/uv.lock +103 -2
  12. distant_frames-0.1.0/README.md +0 -98
  13. distant_frames-0.1.0/debug_import.py +0 -9
  14. distant_frames-0.1.0/distant_frames/cli.py +0 -15
  15. distant_frames-0.1.0/distant_frames/core.py +0 -134
  16. {distant_frames-0.1.0 → distant_frames-0.3.1}/.github/workflows/ci.yml +0 -0
  17. {distant_frames-0.1.0 → distant_frames-0.3.1}/.gitignore +0 -0
  18. {distant_frames-0.1.0 → distant_frames-0.3.1}/.python-version +0 -0
  19. {distant_frames-0.1.0 → distant_frames-0.3.1}/CONTRIBUTING.md +0 -0
  20. {distant_frames-0.1.0 → distant_frames-0.3.1}/LICENSE +0 -0
  21. {distant_frames-0.1.0 → distant_frames-0.3.1}/PUBLISHING.md +0 -0
  22. {distant_frames-0.1.0 → distant_frames-0.3.1}/distant_frames/__init__.py +0 -0
  23. {distant_frames-0.1.0 → distant_frames-0.3.1}/extracted_frames/.gitkeep +0 -0
  24. {distant_frames-0.1.0 → distant_frames-0.3.1}/generate_test_video.py +0 -0
@@ -2,8 +2,8 @@ name: Publish to PyPI
2
2
 
3
3
  on:
4
4
  push:
5
- tags:
6
- - 'v*.*.*'
5
+ branches:
6
+ - main
7
7
 
8
8
  jobs:
9
9
  build-and-publish:
@@ -1,10 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: distant-frames
3
- Version: 0.1.0
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
+ ![PyPI - Version](https://img.shields.io/pypi/v/distant-frames)
26
+ ![PyPI - Status](https://img.shields.io/pypi/status/distant-frames)
27
+ ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/distant-frames)
28
+ ![PyPI - Downloads](https://img.shields.io/pypi/dm/distant-frames)
29
+ ![GitHub License](https://img.shields.io/github/license/yubraaj11/distant-frames)
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 (Development)
63
+ ### From Source (Local)
54
64
 
55
- For development or to use the latest unreleased features:
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 Dependencies:**
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
- ### Basic Command
71
- Run the script by providing the path to your video file:
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 distant_frames/cli.py path/to/your/video.mp4
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 similarity **higher** than this value compared to the last saved frame will be **dropped**. | `0.65` |
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
- uv run distant_frames/cli.py my_vacation.mp4
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
- **Save to a custom folder with a stricter similarity check (fewer frames):**
124
+ **Only keep frames where a person's eyes are open:**
93
125
  ```bash
94
- uv run distant_frames/cli.py my_vacation.mp4 --output best_shots --threshold 0.95
126
+ distant-frames interview.mp4 --open-eyes -o key_frames
95
127
  ```
96
128
 
97
- **Save more frames (looser similarity check):**
129
+ **Combine all options:**
98
130
  ```bash
99
- uv run distant_frames/cli.py my_vacation.mp4 --output all_shots --threshold 0.6
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`: **SAVE** (The scene has changed).
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
+ ![PyPI - Version](https://img.shields.io/pypi/v/distant-frames)
2
+ ![PyPI - Status](https://img.shields.io/pypi/status/distant-frames)
3
+ ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/distant-frames)
4
+ ![PyPI - Downloads](https://img.shields.io/pypi/dm/distant-frames)
5
+ ![GitHub License](https://img.shields.io/github/license/yubraaj11/distant-frames)
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.")