first commit
Browse files- LICENSE +21 -0
- README.md +63 -13
- app.py +201 -0
- eval.txt +93 -0
- framevis.py +571 -0
- images/FrameVis_Banner.jpg +0 -0
- requirements.txt +5 -0
LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
MIT License
|
2 |
+
|
3 |
+
Copyright (c) 2019 David Madison
|
4 |
+
|
5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6 |
+
of this software and associated documentation files (the "Software"), to deal
|
7 |
+
in the Software without restriction, including without limitation the rights
|
8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9 |
+
copies of the Software, and to permit persons to whom the Software is
|
10 |
+
furnished to do so, subject to the following conditions:
|
11 |
+
|
12 |
+
The above copyright notice and this permission notice shall be included in all
|
13 |
+
copies or substantial portions of the Software.
|
14 |
+
|
15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21 |
+
SOFTWARE.
|
README.md
CHANGED
@@ -1,13 +1,63 @@
|
|
1 |
-
|
2 |
-
|
3 |
-
|
4 |
-
|
5 |
-
|
6 |
-
|
7 |
-
|
8 |
-
|
9 |
-
|
10 |
-
|
11 |
-
|
12 |
-
|
13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
![FrameVis Banner](images/FrameVis_Banner.jpg)
|
2 |
+
|
3 |
+
FrameVis is a Python script for generating video frame visualizations, also known as "movie barcodes". These visualizations are composed of frames taken from a video file at a regular interval, resized, and then stacked together to show the compressed color palette of the video and how it changes over time.
|
4 |
+
|
5 |
+
For more information, see [the blog post on PartsNotIncluded.com](http://www.partsnotincluded.com/programming/framevis/).
|
6 |
+
|
7 |
+
## Basic Usage
|
8 |
+
|
9 |
+
```bash
|
10 |
+
python framevis.py source_video.mkv result.png -n 1600
|
11 |
+
```
|
12 |
+
|
13 |
+
To use the script, invoke it from the command line and pass positional arguments for the source and destination file paths (respectively). You will also need to provide either the number of frames to use (`-n`), or a capture interval in seconds (`-i`). The script will then process the video and save the result to the file specified.
|
14 |
+
|
15 |
+
## Installation
|
16 |
+
|
17 |
+
To use FrameVis, you will need a copy of [Python 3](https://www.python.org/downloads/) installed and added to your computer's path. You will also need a copy of the OpenCV library for Python 3, which can either be built from source ([Windows](https://docs.opencv.org/master/d5/de5/tutorial_py_setup_in_windows.html), [Ubuntu](https://docs.opencv.org/master/d2/de6/tutorial_py_setup_in_ubuntu.html), [Fedora](https://docs.opencv.org/master/dd/dd5/tutorial_py_setup_in_fedora.html)) or installed using [the **unofficial** binaries](https://pypi.org/project/opencv-python/) available via pip:
|
18 |
+
|
19 |
+
```python
|
20 |
+
pip install opencv-python
|
21 |
+
```
|
22 |
+
|
23 |
+
Test that both OpenCV (`cv2`) and NumPy (`numpy`) successfully import into Python before trying the script for the first time. Note that this script was developed using Python 3.6.4 and OpenCV version 3.4.1. More recent versions may not work properly.
|
24 |
+
|
25 |
+
## Command Line Arguments
|
26 |
+
|
27 |
+
### source (positional)
|
28 |
+
|
29 |
+
The first positional argument is the file path for the video file to be visualized. Works with all OpenCV compatible video codecs and wrappers.
|
30 |
+
|
31 |
+
### destination (positional)
|
32 |
+
|
33 |
+
The second positional argument is the file path to save the final, visualized image. [Compatible file formats](https://docs.opencv.org/2.4/modules/highgui/doc/reading_and_writing_images_and_video.html?highlight=imread#imread) include jpeg, png, bmp, and tiff. A proper file extension *must* be included in the path or saving will fail.
|
34 |
+
|
35 |
+
### (n)frames and (i)nterval
|
36 |
+
|
37 |
+
One of these two arguments is required to set the number of frames to use in the visualization. You can either set the number of frames directly with `--(n)frames`, or indirectly by setting a capture `--(i)nterval` in seconds. Captured frames with either method are spaced throughout the entire video.
|
38 |
+
|
39 |
+
## Optional Arguments
|
40 |
+
|
41 |
+
### (h)eight and (w)idth
|
42 |
+
|
43 |
+
The number of pixels to use for height and width *per frame*. If unset, these default to 1 px in the concatenated direction and the full size of the video in the other.
|
44 |
+
|
45 |
+
### (d)irection
|
46 |
+
|
47 |
+
The direction in which to concatenate the video frames, either "horizontal" or "vertical". Defaults to "horizontal".
|
48 |
+
|
49 |
+
### (t)rim
|
50 |
+
|
51 |
+
Setting this flag attempts to automatically remove hard matting present in the video file ([letterboxing / pillarboxing](https://en.wikipedia.org/wiki/Letterboxing_(filming))) before resizing. Off by default.
|
52 |
+
|
53 |
+
### (a)verage
|
54 |
+
|
55 |
+
Postprocess effect that averages all of the colors in each frame. Off by default, mutually exclusive with [(b)lur](#blur). It's recommended to enable trimming when using this option, otherwise colors will be excessively darkened.
|
56 |
+
|
57 |
+
### (b)lur
|
58 |
+
|
59 |
+
Postprocess effect that blurs each frame to smooth the final image. The value is the kernel size used for [convolution](https://en.wikipedia.org/wiki/Kernel_(image_processing)). If the flag is set and no value is passed, defaults to 100. Off by default, mutually exclusive with [(a)verage](#average). It's recommended to enable trimming when using this option, otherwise frame colors will bleed into any present matting.
|
60 |
+
|
61 |
+
## License
|
62 |
+
|
63 |
+
This script is licensed under the terms of the [MIT license](https://opensource.org/licenses/MIT). See the [LICENSE](LICENSE) file for more information.
|
app.py
ADDED
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import gradio as gr
|
2 |
+
import cv2
|
3 |
+
import numpy as np
|
4 |
+
import tempfile
|
5 |
+
import os
|
6 |
+
from framevis import FrameVis
|
7 |
+
import json
|
8 |
+
|
9 |
+
class InteractiveFrameVis(FrameVis):
|
10 |
+
"""Extended FrameVis class that tracks frame positions"""
|
11 |
+
|
12 |
+
def visualize(self, source, nframes, height=None, width=None, direction="horizontal", trim=False, quiet=True):
|
13 |
+
"""Extended visualize method that returns both the visualization and frame data"""
|
14 |
+
video = cv2.VideoCapture(source)
|
15 |
+
if not video.isOpened():
|
16 |
+
raise FileNotFoundError("Source Video Not Found")
|
17 |
+
|
18 |
+
# Calculate frame positions and timestamps
|
19 |
+
total_frames = video.get(cv2.CAP_PROP_FRAME_COUNT)
|
20 |
+
fps = video.get(cv2.CAP_PROP_FPS)
|
21 |
+
keyframe_interval = total_frames / nframes
|
22 |
+
|
23 |
+
# Get the visualization
|
24 |
+
output_image = super().visualize(source, nframes, height, width, direction, trim, quiet)
|
25 |
+
|
26 |
+
# Calculate frame positions and timestamps
|
27 |
+
frame_data = []
|
28 |
+
img_height, img_width = output_image.shape[:2]
|
29 |
+
|
30 |
+
for i in range(nframes):
|
31 |
+
frame_pos = int(keyframe_interval * (i + 0.5)) # Same calculation as in visualize
|
32 |
+
timestamp = frame_pos / fps
|
33 |
+
|
34 |
+
if direction == "horizontal":
|
35 |
+
x_start = (i * img_width) // nframes
|
36 |
+
x_end = ((i + 1) * img_width) // nframes
|
37 |
+
frame_info = {
|
38 |
+
"frame": frame_pos,
|
39 |
+
"time": timestamp,
|
40 |
+
"x_start": int(x_start),
|
41 |
+
"x_end": int(x_end),
|
42 |
+
"y_start": 0,
|
43 |
+
"y_end": img_height
|
44 |
+
}
|
45 |
+
else: # vertical
|
46 |
+
y_start = (i * img_height) // nframes
|
47 |
+
y_end = ((i + 1) * img_height) // nframes
|
48 |
+
frame_info = {
|
49 |
+
"frame": frame_pos,
|
50 |
+
"time": timestamp,
|
51 |
+
"x_start": 0,
|
52 |
+
"x_end": img_width,
|
53 |
+
"y_start": int(y_start),
|
54 |
+
"y_end": int(y_end)
|
55 |
+
}
|
56 |
+
frame_data.append(frame_info)
|
57 |
+
|
58 |
+
video.release()
|
59 |
+
return output_image, frame_data
|
60 |
+
|
61 |
+
def extract_frame(video_path, frame_number):
|
62 |
+
"""Extract a specific frame from the video"""
|
63 |
+
if not video_path:
|
64 |
+
return None
|
65 |
+
|
66 |
+
cap = cv2.VideoCapture(video_path)
|
67 |
+
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_number)
|
68 |
+
ret, frame = cap.read()
|
69 |
+
cap.release()
|
70 |
+
|
71 |
+
if ret:
|
72 |
+
return cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
73 |
+
return None
|
74 |
+
|
75 |
+
def process_video(video_path, nframes, height, width, direction, trim, average, blur_amount):
|
76 |
+
"""Process video using FrameVis and return the visualization with frame data"""
|
77 |
+
try:
|
78 |
+
fv = InteractiveFrameVis()
|
79 |
+
|
80 |
+
# Process the video
|
81 |
+
output_image, frame_data = fv.visualize(
|
82 |
+
video_path,
|
83 |
+
nframes=nframes,
|
84 |
+
height=height if height > 0 else None,
|
85 |
+
width=width if width > 0 else None,
|
86 |
+
direction=direction,
|
87 |
+
trim=trim,
|
88 |
+
quiet=False
|
89 |
+
)
|
90 |
+
|
91 |
+
# Apply post-processing if requested
|
92 |
+
if average:
|
93 |
+
output_image = fv.average_image(output_image, direction)
|
94 |
+
elif blur_amount > 0:
|
95 |
+
output_image = fv.motion_blur(output_image, direction, blur_amount)
|
96 |
+
|
97 |
+
# Convert from BGR to RGB for Gradio
|
98 |
+
output_image = cv2.cvtColor(output_image, cv2.COLOR_BGR2RGB)
|
99 |
+
|
100 |
+
# Store frame data in a temporary file
|
101 |
+
temp_dir = tempfile.gettempdir()
|
102 |
+
data_path = os.path.join(temp_dir, "frame_data.json")
|
103 |
+
with open(data_path, "w") as f:
|
104 |
+
json.dump({"video_path": video_path, "frames": frame_data}, f)
|
105 |
+
|
106 |
+
return output_image, data_path
|
107 |
+
|
108 |
+
except Exception as e:
|
109 |
+
raise gr.Error(str(e))
|
110 |
+
|
111 |
+
def on_mouse_move(evt: gr.EventData, frame_data_path):
|
112 |
+
"""Handle mouseover on the visualization image"""
|
113 |
+
if not frame_data_path:
|
114 |
+
return None
|
115 |
+
|
116 |
+
try:
|
117 |
+
# Load frame data
|
118 |
+
with open(frame_data_path) as f:
|
119 |
+
data = json.load(f)
|
120 |
+
|
121 |
+
video_path = data["video_path"]
|
122 |
+
frames = data["frames"]
|
123 |
+
|
124 |
+
# Get mouse coordinates
|
125 |
+
x, y = evt.index[0], evt.index[1] # Extract x, y from index
|
126 |
+
|
127 |
+
# Find which frame was hovered
|
128 |
+
for frame in frames:
|
129 |
+
if (frame["x_start"] <= x <= frame["x_end"] and
|
130 |
+
frame["y_start"] <= y <= frame["y_end"]):
|
131 |
+
# Extract and return the frame
|
132 |
+
preview = extract_frame(video_path, frame["frame"])
|
133 |
+
if preview is not None:
|
134 |
+
return preview, f"Frame {frame['frame']} (Time: {frame['time']:.2f}s)"
|
135 |
+
|
136 |
+
except Exception as e:
|
137 |
+
print(f"Error handling mouseover: {e}")
|
138 |
+
return None, ""
|
139 |
+
|
140 |
+
# Create the Gradio interface
|
141 |
+
with gr.Blocks(title="FrameVis - Video Frame Visualizer") as demo:
|
142 |
+
gr.Markdown("""
|
143 |
+
# 🎬 FrameVis - Video Frame Visualizer
|
144 |
+
Upload a video to create a beautiful visualization of its frames. The tool will extract frames at regular intervals
|
145 |
+
and combine them into a single image. **Move your mouse over the visualization to see the original frames!**
|
146 |
+
""")
|
147 |
+
|
148 |
+
with gr.Row():
|
149 |
+
with gr.Column(scale=1):
|
150 |
+
# Input components
|
151 |
+
video_input = gr.Video(label="Upload Video")
|
152 |
+
with gr.Row():
|
153 |
+
nframes = gr.Slider(minimum=1, maximum=500, value=100, step=1,
|
154 |
+
label="Number of Frames")
|
155 |
+
direction = gr.Radio(["horizontal", "vertical"], value="horizontal",
|
156 |
+
label="Direction")
|
157 |
+
|
158 |
+
with gr.Row():
|
159 |
+
height = gr.Number(value=0, label="Frame Height (0 for auto)")
|
160 |
+
width = gr.Number(value=0, label="Frame Width (0 for auto)")
|
161 |
+
|
162 |
+
with gr.Row():
|
163 |
+
trim = gr.Checkbox(label="Auto-trim black bars")
|
164 |
+
average = gr.Checkbox(label="Average colors")
|
165 |
+
blur_amount = gr.Slider(minimum=0, maximum=200, value=0, step=1,
|
166 |
+
label="Motion Blur Amount")
|
167 |
+
|
168 |
+
process_btn = gr.Button("Generate Visualization", variant="primary")
|
169 |
+
|
170 |
+
with gr.Column(scale=2):
|
171 |
+
# Output components
|
172 |
+
frame_data = gr.State() # Hidden component to store frame data
|
173 |
+
output_image = gr.Image(label="Visualization Result", interactive=True, height=300)
|
174 |
+
frame_info = gr.Markdown("Hover over the visualization to see frame details")
|
175 |
+
preview_frame = gr.Image(label="Frame Preview", interactive=False, height=300)
|
176 |
+
|
177 |
+
# Handle processing
|
178 |
+
result = process_btn.click(
|
179 |
+
fn=process_video,
|
180 |
+
inputs=[
|
181 |
+
video_input,
|
182 |
+
nframes,
|
183 |
+
height,
|
184 |
+
width,
|
185 |
+
direction,
|
186 |
+
trim,
|
187 |
+
average,
|
188 |
+
blur_amount
|
189 |
+
],
|
190 |
+
outputs=[output_image, frame_data]
|
191 |
+
)
|
192 |
+
|
193 |
+
# Handle mouseover events
|
194 |
+
output_image.mouseover(
|
195 |
+
fn=on_mouse_move,
|
196 |
+
inputs=[frame_data],
|
197 |
+
outputs=[preview_frame, frame_info]
|
198 |
+
)
|
199 |
+
|
200 |
+
if __name__ == "__main__":
|
201 |
+
demo.launch()
|
eval.txt
ADDED
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
I'll analyze the FrameVis script and break down its key functionality.
|
2 |
+
|
3 |
+
### Core Purpose
|
4 |
+
FrameVis is a Python script that creates a visual representation of a video by extracting frames at regular intervals and combining them into a single image. It's essentially creating what's sometimes called a "movie barcode."
|
5 |
+
|
6 |
+
### Key Components
|
7 |
+
|
8 |
+
1. **Main Class: FrameVis**
|
9 |
+
- Primary class that handles the video frame extraction and visualization
|
10 |
+
- Default settings for frame dimensions and concatenation direction
|
11 |
+
- Main method: `visualize()` which does the heavy lifting
|
12 |
+
|
13 |
+
2. **Supporting Classes:**
|
14 |
+
- `MatteTrimmer`: Handles detection and removal of black bars (letterboxing/pillarboxing)
|
15 |
+
- `ProgressBar`: Provides console feedback during processing
|
16 |
+
|
17 |
+
### Core Workflow
|
18 |
+
|
19 |
+
1. **Input Processing**
|
20 |
+
```python
|
21 |
+
# Takes key parameters:
|
22 |
+
- source: video file path
|
23 |
+
- nframes: number of frames to extract
|
24 |
+
- height/width: dimensions for output frames
|
25 |
+
- direction: horizontal/vertical concatenation
|
26 |
+
- trim: whether to remove black bars
|
27 |
+
```
|
28 |
+
|
29 |
+
2. **Frame Extraction Logic**
|
30 |
+
```python
|
31 |
+
# Calculates frame interval
|
32 |
+
video_total_frames = video.get(cv2.CAP_PROP_FRAME_COUNT)
|
33 |
+
keyframe_interval = video_total_frames / nframes
|
34 |
+
|
35 |
+
# Frames are extracted at regular intervals:
|
36 |
+
next_keyframe = keyframe_interval / 2 # Starts from middle of first interval
|
37 |
+
```
|
38 |
+
|
39 |
+
3. **Frame Processing**
|
40 |
+
- Reads frames using OpenCV
|
41 |
+
- Optionally trims black bars
|
42 |
+
- Resizes frames to specified dimensions
|
43 |
+
- Concatenates frames either horizontally or vertically
|
44 |
+
|
45 |
+
4. **Post-Processing Options**
|
46 |
+
- `average_image()`: Creates a color average across frames
|
47 |
+
- `motion_blur()`: Applies directional blur effect
|
48 |
+
|
49 |
+
### Special Features
|
50 |
+
|
51 |
+
1. **Matte Detection**
|
52 |
+
- Automatically detects and removes letterboxing/pillarboxing
|
53 |
+
- Samples multiple frames to ensure consistent detection
|
54 |
+
- Uses threshold-based detection for black bars
|
55 |
+
|
56 |
+
2. **Flexible Output**
|
57 |
+
- Can output horizontally (traditional movie barcode)
|
58 |
+
- Can output vertically (stacked frames)
|
59 |
+
- Supports automatic dimension calculation
|
60 |
+
|
61 |
+
3. **Time-Based Frame Selection**
|
62 |
+
```python
|
63 |
+
# Can specify frames either by:
|
64 |
+
- Total number of frames (-n/--nframes)
|
65 |
+
- Time interval between frames (-i/--interval)
|
66 |
+
```
|
67 |
+
|
68 |
+
### Command Line Interface
|
69 |
+
```python
|
70 |
+
# Supports various arguments:
|
71 |
+
-n/--nframes: Number of frames
|
72 |
+
-i/--interval: Time between frames
|
73 |
+
-h/--height: Frame height
|
74 |
+
-w/--width: Frame width
|
75 |
+
-d/--direction: Concatenation direction
|
76 |
+
-t/--trim: Auto-trim black bars
|
77 |
+
-a/--average: Average colors
|
78 |
+
-b/--blur: Apply motion blur
|
79 |
+
-q/--quiet: Suppress output
|
80 |
+
```
|
81 |
+
|
82 |
+
### Error Handling
|
83 |
+
- Validates input parameters
|
84 |
+
- Checks for file existence
|
85 |
+
- Ensures frame counts are valid
|
86 |
+
- Handles video reading errors
|
87 |
+
|
88 |
+
### Performance Considerations
|
89 |
+
- Uses OpenCV for efficient video processing
|
90 |
+
- Maintains float precision for frame intervals
|
91 |
+
- Only loads required frames rather than entire video
|
92 |
+
|
93 |
+
This is a well-structured script that provides a robust set of features for video frame visualization while maintaining good error handling and user feedback through progress bars and console output.
|
framevis.py
ADDED
@@ -0,0 +1,571 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
#
|
2 |
+
# Project FrameVis - Video Frame Visualizer Script
|
3 |
+
# @author David Madison
|
4 |
+
# @link github.com/dmadison/FrameVis
|
5 |
+
# @version v1.0.1
|
6 |
+
# @license MIT - Copyright (c) 2019 David Madison
|
7 |
+
#
|
8 |
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
9 |
+
# of this software and associated documentation files (the "Software"), to deal
|
10 |
+
# in the Software without restriction, including without limitation the rights
|
11 |
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
12 |
+
# copies of the Software, and to permit persons to whom the Software is
|
13 |
+
# furnished to do so, subject to the following conditions:
|
14 |
+
#
|
15 |
+
# The above copyright notice and this permission notice shall be included in
|
16 |
+
# all copies or substantial portions of the Software.
|
17 |
+
#
|
18 |
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
19 |
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
20 |
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
21 |
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
22 |
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
23 |
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
24 |
+
# THE SOFTWARE.
|
25 |
+
#
|
26 |
+
|
27 |
+
import cv2
|
28 |
+
import numpy as np
|
29 |
+
import argparse
|
30 |
+
from enum import Enum, auto
|
31 |
+
import time
|
32 |
+
|
33 |
+
|
34 |
+
class FrameVis:
|
35 |
+
"""
|
36 |
+
Reads a video file and outputs an image comprised of n resized frames, spread evenly throughout the file.
|
37 |
+
"""
|
38 |
+
|
39 |
+
default_frame_height = None # auto, or in pixels
|
40 |
+
default_frame_width = None # auto, or in pixels
|
41 |
+
default_concat_size = 1 # size of concatenated frame if automatically calculated, in pixels
|
42 |
+
default_direction = "horizontal" # left to right
|
43 |
+
|
44 |
+
def visualize(self, source, nframes, height=default_frame_height, width=default_frame_width, \
|
45 |
+
direction=default_direction, trim=False, quiet=True):
|
46 |
+
"""
|
47 |
+
Reads a video file and outputs an image comprised of n resized frames, spread evenly throughout the file.
|
48 |
+
|
49 |
+
Parameters:
|
50 |
+
source (str): filepath to source video file
|
51 |
+
nframes (int): number of frames to process from the video
|
52 |
+
height (int): height of each frame, in pixels
|
53 |
+
width (int): width of each frame, in pixels
|
54 |
+
direction (str): direction to concatenate frames ("horizontal" or "vertical")
|
55 |
+
quiet (bool): suppress console messages
|
56 |
+
|
57 |
+
Returns:
|
58 |
+
visualization image as numpy array
|
59 |
+
"""
|
60 |
+
|
61 |
+
video = cv2.VideoCapture(source) # open video file
|
62 |
+
if not video.isOpened():
|
63 |
+
raise FileNotFoundError("Source Video Not Found")
|
64 |
+
|
65 |
+
if not quiet:
|
66 |
+
print("") # create space from script call line
|
67 |
+
|
68 |
+
# calculate keyframe interval
|
69 |
+
video_total_frames = video.get(cv2.CAP_PROP_FRAME_COUNT) # retrieve total frame count from metadata
|
70 |
+
if not isinstance(nframes, int) or nframes < 1:
|
71 |
+
raise ValueError("Number of frames must be a positive integer")
|
72 |
+
elif nframes > video_total_frames:
|
73 |
+
raise ValueError("Requested frame count larger than total available ({})".format(video_total_frames))
|
74 |
+
keyframe_interval = video_total_frames / nframes # calculate number of frames between captures
|
75 |
+
|
76 |
+
# grab frame for dimension calculations
|
77 |
+
success,image = video.read() # get first frame
|
78 |
+
if not success:
|
79 |
+
raise IOError("Cannot read from video file")
|
80 |
+
|
81 |
+
# calculate letterbox / pillarbox trimming, if specified
|
82 |
+
matte_type = 0
|
83 |
+
if trim == True:
|
84 |
+
if not quiet:
|
85 |
+
print("Trimming enabled, checking matting... ", end="", flush=True)
|
86 |
+
|
87 |
+
# 10 frame samples, seen as matted if an axis has all color channels at 3 / 255 or lower (avg)
|
88 |
+
success, cropping_bounds = MatteTrimmer.determine_video_bounds(source, 10, 3)
|
89 |
+
|
90 |
+
matte_type = 0
|
91 |
+
if success: # only calculate cropping if bounds are valid
|
92 |
+
crop_width = cropping_bounds[1][0] - cropping_bounds[0][0] + 1
|
93 |
+
crop_height = cropping_bounds[1][1] - cropping_bounds[0][1] + 1
|
94 |
+
|
95 |
+
if crop_height != image.shape[0]: # letterboxing
|
96 |
+
matte_type += 1
|
97 |
+
if crop_width != image.shape[1]: # pillarboxing
|
98 |
+
matte_type +=2
|
99 |
+
|
100 |
+
if not quiet:
|
101 |
+
if matte_type == 0:
|
102 |
+
print("no matting detected")
|
103 |
+
elif matte_type == 1:
|
104 |
+
print("letterboxing detected, cropping {} px from the top and bottom".format(int((image.shape[0] - crop_height) / 2)))
|
105 |
+
elif matte_type == 2:
|
106 |
+
print("pillarboxing detected, trimming {} px from the sides".format(int((image.shape[1] - crop_width) / 2)))
|
107 |
+
elif matte_type == 3:
|
108 |
+
print("multiple matting detected - cropping ({}, {}) to ({}, {})".format(image.shape[1], image.shape[0], crop_width, crop_height))
|
109 |
+
|
110 |
+
# calculate height
|
111 |
+
if height is None: # auto-calculate
|
112 |
+
if direction == "horizontal": # non-concat, use video size
|
113 |
+
if matte_type & 1 == 1: # letterboxing present
|
114 |
+
height = crop_height
|
115 |
+
else:
|
116 |
+
height = image.shape[0] # save frame height
|
117 |
+
else: # concat, use default value
|
118 |
+
height = FrameVis.default_concat_size
|
119 |
+
elif not isinstance(height, int) or height < 1:
|
120 |
+
raise ValueError("Frame height must be a positive integer")
|
121 |
+
|
122 |
+
# calculate width
|
123 |
+
if width is None: # auto-calculate
|
124 |
+
if direction == "vertical": # non-concat, use video size
|
125 |
+
if matte_type & 2 == 2: # pillarboxing present
|
126 |
+
width = crop_width
|
127 |
+
else:
|
128 |
+
width = image.shape[1] # save frame width
|
129 |
+
else: # concat, use default value
|
130 |
+
width = FrameVis.default_concat_size
|
131 |
+
elif not isinstance(width, int) or width < 1:
|
132 |
+
raise ValueError("Frame width must be a positive integer")
|
133 |
+
|
134 |
+
# assign direction function and calculate output size
|
135 |
+
if direction == "horizontal":
|
136 |
+
concatenate = cv2.hconcat
|
137 |
+
output_width = width * nframes
|
138 |
+
output_height = height
|
139 |
+
elif direction == "vertical":
|
140 |
+
concatenate = cv2.vconcat
|
141 |
+
output_width = width
|
142 |
+
output_height = height * nframes
|
143 |
+
else:
|
144 |
+
raise ValueError("Invalid direction specified")
|
145 |
+
|
146 |
+
if not quiet:
|
147 |
+
aspect_ratio = output_width / output_height
|
148 |
+
print("Visualizing \"{}\" - {} by {} ({:.2f}), from {} frames (every {:.2f} seconds)"\
|
149 |
+
.format(source, output_width, output_height, aspect_ratio, nframes, FrameVis.interval_from_nframes(source, nframes)))
|
150 |
+
|
151 |
+
# set up for the frame processing loop
|
152 |
+
next_keyframe = keyframe_interval / 2 # frame number for the next frame grab, starting evenly offset from start/end
|
153 |
+
finished_frames = 0 # counter for number of processed frames
|
154 |
+
output_image = None
|
155 |
+
progress = ProgressBar("Processing:")
|
156 |
+
|
157 |
+
while True:
|
158 |
+
if finished_frames == nframes:
|
159 |
+
break # done!
|
160 |
+
|
161 |
+
video.set(cv2.CAP_PROP_POS_FRAMES, int(next_keyframe)) # move cursor to next sampled frame
|
162 |
+
success,image = video.read() # read the next frame
|
163 |
+
|
164 |
+
if not success:
|
165 |
+
raise IOError("Cannot read from video file (frame {} out of {})".format(int(next_keyframe), video_total_frames))
|
166 |
+
|
167 |
+
if matte_type != 0: # crop out matting, if specified and matting is present
|
168 |
+
image = MatteTrimmer.crop_image(image, cropping_bounds)
|
169 |
+
|
170 |
+
image = cv2.resize(image, (width, height)) # resize to output size
|
171 |
+
|
172 |
+
# save to output image
|
173 |
+
if output_image is None:
|
174 |
+
output_image = image
|
175 |
+
else:
|
176 |
+
output_image = concatenate([output_image, image]) # concatenate horizontally from left -> right
|
177 |
+
|
178 |
+
finished_frames += 1
|
179 |
+
next_keyframe += keyframe_interval # set next frame capture time, maintaining floats
|
180 |
+
|
181 |
+
if not quiet:
|
182 |
+
progress.write(finished_frames / nframes) # print progress bar to the console
|
183 |
+
|
184 |
+
video.release() # close video capture
|
185 |
+
|
186 |
+
return output_image
|
187 |
+
|
188 |
+
@staticmethod
|
189 |
+
def average_image(image, direction):
|
190 |
+
"""
|
191 |
+
Averages the colors in an axis across an entire image
|
192 |
+
|
193 |
+
Parameters:
|
194 |
+
image (arr x.y.c): image as 3-dimensional numpy array
|
195 |
+
direction (str): direction to average frames ("horizontal" or "vertical")
|
196 |
+
|
197 |
+
Returns:
|
198 |
+
image, with pixel data averaged along provided axis
|
199 |
+
"""
|
200 |
+
|
201 |
+
height, width, depth = image.shape
|
202 |
+
|
203 |
+
if direction == "horizontal":
|
204 |
+
scale_height = 1
|
205 |
+
scale_width = width
|
206 |
+
elif direction == "vertical":
|
207 |
+
scale_height = height
|
208 |
+
scale_width = 1
|
209 |
+
else:
|
210 |
+
raise ValueError("Invalid direction specified")
|
211 |
+
|
212 |
+
image = cv2.resize(image, (scale_width, scale_height)) # scale down to '1', averaging values
|
213 |
+
image = cv2.resize(image, (width, height)) # scale back up to size
|
214 |
+
|
215 |
+
return image
|
216 |
+
|
217 |
+
@staticmethod
|
218 |
+
def motion_blur(image, direction, blur_amount):
|
219 |
+
"""
|
220 |
+
Blurs the pixels in a given axis across an entire image.
|
221 |
+
|
222 |
+
Parameters:
|
223 |
+
image (arr x.y.c): image as 3-dimensional numpy array
|
224 |
+
direction (str): direction of stacked images for blurring ("horizontal" or "vertical")
|
225 |
+
blur_amount (int): how much to blur the image, as the convolution kernel size
|
226 |
+
|
227 |
+
Returns:
|
228 |
+
image, with pixel data blurred along provided axis
|
229 |
+
"""
|
230 |
+
|
231 |
+
kernel = np.zeros((blur_amount, blur_amount)) # create convolution kernel
|
232 |
+
|
233 |
+
# fill group with '1's
|
234 |
+
if direction == "horizontal":
|
235 |
+
kernel[:, int((blur_amount - 1)/2)] = np.ones(blur_amount) # fill center column (blurring vertically for horizontal concat)
|
236 |
+
elif direction == "vertical":
|
237 |
+
kernel[int((blur_amount - 1)/2), :] = np.ones(blur_amount) # fill center row (blurring horizontally for vertical concat)
|
238 |
+
else:
|
239 |
+
raise ValueError("Invalid direction specified")
|
240 |
+
|
241 |
+
kernel /= blur_amount # normalize kernel matrix
|
242 |
+
|
243 |
+
return cv2.filter2D(image, -1, kernel) # filter using kernel with same depth as source
|
244 |
+
|
245 |
+
@staticmethod
|
246 |
+
def nframes_from_interval(source, interval):
|
247 |
+
"""
|
248 |
+
Calculates the number of frames available in a video file for a given capture interval
|
249 |
+
|
250 |
+
Parameters:
|
251 |
+
source (str): filepath to source video file
|
252 |
+
interval (float): capture frame every i seconds
|
253 |
+
|
254 |
+
Returns:
|
255 |
+
number of frames per time interval (int)
|
256 |
+
"""
|
257 |
+
video = cv2.VideoCapture(source) # open video file
|
258 |
+
if not video.isOpened():
|
259 |
+
raise FileNotFoundError("Source Video Not Found")
|
260 |
+
|
261 |
+
frame_count = video.get(cv2.CAP_PROP_FRAME_COUNT) # total number of frames
|
262 |
+
fps = video.get(cv2.CAP_PROP_FPS) # framerate of the video
|
263 |
+
duration = frame_count / fps # duration of the video, in seconds
|
264 |
+
|
265 |
+
video.release() # close video capture
|
266 |
+
|
267 |
+
return int(round(duration / interval)) # number of frames per interval
|
268 |
+
|
269 |
+
@staticmethod
|
270 |
+
def interval_from_nframes(source, nframes):
|
271 |
+
"""
|
272 |
+
Calculates the capture interval, in seconds, for a video file given the
|
273 |
+
number of frames to capture
|
274 |
+
|
275 |
+
Parameters:
|
276 |
+
source (str): filepath to source video file
|
277 |
+
nframes (int): number of frames to capture from the video file
|
278 |
+
|
279 |
+
Returns:
|
280 |
+
time interval (seconds) between frame captures (float)
|
281 |
+
"""
|
282 |
+
video = cv2.VideoCapture(source) # open video file
|
283 |
+
if not video.isOpened():
|
284 |
+
raise FileNotFoundError("Source Video Not Found")
|
285 |
+
|
286 |
+
frame_count = video.get(cv2.CAP_PROP_FRAME_COUNT) # total number of frames
|
287 |
+
fps = video.get(cv2.CAP_PROP_FPS) # framerate of the video
|
288 |
+
keyframe_interval = frame_count / nframes # calculate number of frames between captures
|
289 |
+
|
290 |
+
video.release() # close video capture
|
291 |
+
|
292 |
+
return keyframe_interval / fps # seconds between captures
|
293 |
+
|
294 |
+
|
295 |
+
class MatteTrimmer:
|
296 |
+
"""
|
297 |
+
Functions for finding and removing black mattes around video frames
|
298 |
+
"""
|
299 |
+
|
300 |
+
@staticmethod
|
301 |
+
def find_matrix_edges(matrix, threshold):
|
302 |
+
"""
|
303 |
+
Finds the start and end points of a 1D array above a given threshold
|
304 |
+
|
305 |
+
Parameters:
|
306 |
+
matrix (arr, 1.x): 1D array of data to check
|
307 |
+
threshold (value): valid data is above this trigger level
|
308 |
+
|
309 |
+
Returns:
|
310 |
+
tuple with the array indices of data bounds, start and end
|
311 |
+
"""
|
312 |
+
|
313 |
+
if not isinstance(matrix, (list, tuple, np.ndarray)) or len(matrix.shape) != 1:
|
314 |
+
raise ValueError("Provided matrix is not the right size (must be 1D)")
|
315 |
+
|
316 |
+
data_start = None
|
317 |
+
data_end = None
|
318 |
+
|
319 |
+
for value_id, value in enumerate(matrix):
|
320 |
+
if value > threshold:
|
321 |
+
if data_start is None:
|
322 |
+
data_start = value_id
|
323 |
+
data_end = value_id
|
324 |
+
|
325 |
+
return (data_start, data_end)
|
326 |
+
|
327 |
+
@staticmethod
|
328 |
+
def find_larger_bound(first, second):
|
329 |
+
"""
|
330 |
+
Takes two sets of diagonal rectangular boundary coordinates and determines
|
331 |
+
the set of rectangular boundary coordinates that contains both
|
332 |
+
|
333 |
+
Parameters:
|
334 |
+
first (arr, 1.2.2): pair of rectangular coordinates, in the form [(X,Y), (X,Y)]
|
335 |
+
second (arr, 1.2.2): pair of rectangular coordinates, in the form [(X,Y), (X,Y)]
|
336 |
+
|
337 |
+
Where for both arrays the first coordinate is in the top left-hand corner,
|
338 |
+
and the second coordinate is in the bottom right-hand corner.
|
339 |
+
|
340 |
+
Returns:
|
341 |
+
numpy coordinate matrix containing both of the provided boundaries
|
342 |
+
"""
|
343 |
+
left_edge = first[0][0] if first[0][0] <= second[0][0] else second[0][0]
|
344 |
+
right_edge = first[1][0] if first[1][0] >= second[1][0] else second[1][0]
|
345 |
+
|
346 |
+
top_edge = first[0][1] if first[0][1] <= second[0][1] else second[0][1]
|
347 |
+
bottom_edge = first[1][1] if first[1][1] >= second[1][1] else second[1][1]
|
348 |
+
|
349 |
+
return np.array([[left_edge, top_edge], [right_edge, bottom_edge]])
|
350 |
+
|
351 |
+
@staticmethod
|
352 |
+
def valid_bounds(bounds):
|
353 |
+
"""
|
354 |
+
Checks if the frame bounds are a valid format
|
355 |
+
|
356 |
+
Parameters:
|
357 |
+
bounds (arr, 1.2.2): pair of rectangular coordinates, in the form [(X,Y), (X,Y)]
|
358 |
+
|
359 |
+
Returns:
|
360 |
+
True or False
|
361 |
+
"""
|
362 |
+
|
363 |
+
for x, x_coordinate in enumerate(bounds):
|
364 |
+
for y, y_coordinate in enumerate(bounds):
|
365 |
+
if bounds[x][y] is None:
|
366 |
+
return False # not a number
|
367 |
+
|
368 |
+
if bounds[0][0] > bounds[1][0] or \
|
369 |
+
bounds[0][1] > bounds[1][1]:
|
370 |
+
return False # left > right or top > bottom
|
371 |
+
|
372 |
+
return True
|
373 |
+
|
374 |
+
@staticmethod
|
375 |
+
def determine_image_bounds(image, threshold):
|
376 |
+
"""
|
377 |
+
Determines if there are any hard mattes (black bars) surrounding
|
378 |
+
an image on either the top (letterboxing) or the sides (pillarboxing)
|
379 |
+
|
380 |
+
Parameters:
|
381 |
+
image (arr, x.y.c): image as 3-dimensional numpy array
|
382 |
+
threshold (8-bit int): min color channel value to judge as 'image present'
|
383 |
+
|
384 |
+
Returns:
|
385 |
+
success (bool): True or False if the bounds are valid
|
386 |
+
image_bounds: numpy coordinate matrix with the two opposite corners of the
|
387 |
+
image bounds, in the form [(X,Y), (X,Y)]
|
388 |
+
"""
|
389 |
+
|
390 |
+
height, width, depth = image.shape
|
391 |
+
|
392 |
+
# check for letterboxing
|
393 |
+
horizontal_sums = np.sum(image, axis=(1,2)) # sum all color channels across all rows
|
394 |
+
hthreshold = (threshold * width * depth) # must be below every pixel having a value of "threshold" in every channel
|
395 |
+
vertical_edges = MatteTrimmer.find_matrix_edges(horizontal_sums, hthreshold)
|
396 |
+
|
397 |
+
# check for pillarboxing
|
398 |
+
vertical_sums = np.sum(image, axis=(0,2)) # sum all color channels across all columns
|
399 |
+
vthreshold = (threshold * height * depth) # must be below every pixel having a value of "threshold" in every channel
|
400 |
+
horizontal_edges = MatteTrimmer.find_matrix_edges(vertical_sums, vthreshold)
|
401 |
+
|
402 |
+
image_bounds = np.array([[horizontal_edges[0], vertical_edges[0]], [horizontal_edges[1], vertical_edges[1]]])
|
403 |
+
|
404 |
+
return MatteTrimmer.valid_bounds(image_bounds), image_bounds
|
405 |
+
|
406 |
+
@staticmethod
|
407 |
+
def determine_video_bounds(source, nsamples, threshold):
|
408 |
+
"""
|
409 |
+
Determines if any matting exists in a video source
|
410 |
+
|
411 |
+
Parameters:
|
412 |
+
source (str): filepath to source video file
|
413 |
+
nsamples (int): number of frames from the video to determine bounds,
|
414 |
+
evenly spaced throughout the video
|
415 |
+
threshold (8-bit int): min color channel value to judge as 'image present'
|
416 |
+
|
417 |
+
Returns:
|
418 |
+
success (bool): True or False if the bounds are valid
|
419 |
+
video_bounds: numpy coordinate matrix with the two opposite corners of the
|
420 |
+
video bounds, in the form [(X,Y), (X,Y)]
|
421 |
+
"""
|
422 |
+
video = cv2.VideoCapture(source) # open video file
|
423 |
+
if not video.isOpened():
|
424 |
+
raise FileNotFoundError("Source Video Not Found")
|
425 |
+
|
426 |
+
video_total_frames = video.get(cv2.CAP_PROP_FRAME_COUNT) # retrieve total frame count from metadata
|
427 |
+
if not isinstance(nsamples, int) or nsamples < 1:
|
428 |
+
raise ValueError("Number of samples must be a positive integer")
|
429 |
+
keyframe_interval = video_total_frames / nsamples # calculate number of frames between captures
|
430 |
+
|
431 |
+
# open video to make results consistent with visualizer
|
432 |
+
# (this also GREATLY increases the read speed? no idea why)
|
433 |
+
success,image = video.read() # get first frame
|
434 |
+
if not success:
|
435 |
+
raise IOError("Cannot read from video file")
|
436 |
+
|
437 |
+
next_keyframe = keyframe_interval / 2 # frame number for the next frame grab, starting evenly offset from start/end
|
438 |
+
video_bounds = None
|
439 |
+
|
440 |
+
for frame_number in range(nsamples):
|
441 |
+
video.set(cv2.CAP_PROP_POS_FRAMES, int(next_keyframe)) # move cursor to next sampled frame
|
442 |
+
success,image = video.read() # read the next frame
|
443 |
+
|
444 |
+
if not success:
|
445 |
+
raise IOError("Cannot read from video file")
|
446 |
+
|
447 |
+
success, frame_bounds = MatteTrimmer.determine_image_bounds(image, threshold)
|
448 |
+
|
449 |
+
if not success:
|
450 |
+
continue # don't compare bounds, frame bounds are invalid
|
451 |
+
|
452 |
+
video_bounds = frame_bounds if video_bounds is None else MatteTrimmer.find_larger_bound(video_bounds, frame_bounds)
|
453 |
+
next_keyframe += keyframe_interval # set next frame capture time, maintaining floats
|
454 |
+
|
455 |
+
video.release() # close video capture
|
456 |
+
|
457 |
+
return MatteTrimmer.valid_bounds(video_bounds), video_bounds
|
458 |
+
|
459 |
+
@staticmethod
|
460 |
+
def crop_image(image, bounds):
|
461 |
+
"""
|
462 |
+
Crops a provided image by the coordinate bounds pair provided.
|
463 |
+
|
464 |
+
Parameters:
|
465 |
+
image (arr, x.y.c): image as 3-dimensional numpy array
|
466 |
+
second (arr, 1.2.2): pair of rectangular coordinates, in the form [(X,Y), (X,Y)]
|
467 |
+
|
468 |
+
Returns:
|
469 |
+
image as 3-dimensional numpy array, cropped to the coordinate bounds
|
470 |
+
"""
|
471 |
+
return image[bounds[0][1]:bounds[1][1], bounds[0][0]:bounds[1][0]]
|
472 |
+
|
473 |
+
class ProgressBar:
|
474 |
+
"""
|
475 |
+
Generates a progress bar for the console output
|
476 |
+
|
477 |
+
Args:
|
478 |
+
pre (str): string to prepend before the progress bar
|
479 |
+
bar_length (int): length of the progress bar itself, in characters
|
480 |
+
print_elapsed (bool): option to print time elapsed or not
|
481 |
+
|
482 |
+
Attributes:
|
483 |
+
pre (str): string to prepend before the progress bar
|
484 |
+
bar_length (int): length of the progress bar itself, in characters
|
485 |
+
print_time (bool): option to print time elapsed or not
|
486 |
+
print_elapsed (int): starting time for the progress bar, in unix seconds
|
487 |
+
|
488 |
+
"""
|
489 |
+
|
490 |
+
def __init__(self, pre="", bar_length=25, print_elapsed=True):
|
491 |
+
pre = (pre + '\t') if pre != "" else pre # append separator if string present
|
492 |
+
self.pre = pre
|
493 |
+
self.bar_length = bar_length
|
494 |
+
self.print_elapsed = print_elapsed
|
495 |
+
if self.print_elapsed:
|
496 |
+
self.__start_time = time.time() # store start time as unix
|
497 |
+
|
498 |
+
def write(self, percent):
|
499 |
+
"""Prints a progress bar to the console based on the input percentage (float)."""
|
500 |
+
term_char = '\r' if percent < 1.0 else '\n' # rewrite the line unless finished
|
501 |
+
|
502 |
+
filled_size = int(round(self.bar_length * percent)) # number of 'filled' characters in the bar
|
503 |
+
progress_bar = "#" * filled_size + " " * (self.bar_length - filled_size) # progress bar characters, as a string
|
504 |
+
|
505 |
+
time_string = ""
|
506 |
+
if self.print_elapsed:
|
507 |
+
time_elapsed = time.time() - self.__start_time
|
508 |
+
time_string = "\tTime Elapsed: {}".format(time.strftime("%H:%M:%S", time.gmtime(time_elapsed)))
|
509 |
+
|
510 |
+
print("{}[{}]\t{:.2%}{}".format(self.pre, progress_bar, percent, time_string), end=term_char, flush=True)
|
511 |
+
|
512 |
+
|
513 |
+
|
514 |
+
def main():
|
515 |
+
parser = argparse.ArgumentParser(description="video frame visualizer and movie barcode generator", add_help=False) # removing help so I can use '-h' for height
|
516 |
+
|
517 |
+
parser.add_argument("source", help="file path for the video file to be visualized", type=str)
|
518 |
+
parser.add_argument("destination", help="file path output for the final image", type=str)
|
519 |
+
parser.add_argument("-n", "--nframes", help="the number of frames in the visualization", type=int)
|
520 |
+
parser.add_argument("-i", "--interval", help="interval between frames for the visualization", type=float)
|
521 |
+
parser.add_argument("-h", "--height", help="the height of each frame, in pixels", type=int, default=FrameVis.default_frame_height)
|
522 |
+
parser.add_argument("-w", "--width", help="the output width of each frame, in pixels", type=int, default=FrameVis.default_frame_width)
|
523 |
+
parser.add_argument("-d", "--direction", help="direction to concatenate frames, horizontal or vertical", type=str, \
|
524 |
+
choices=["horizontal", "vertical"], default=FrameVis.default_direction)
|
525 |
+
parser.add_argument("-t", "--trim", help="detect and trim any hard matting (letterboxing or pillarboxing)", action='store_true', default=False)
|
526 |
+
parser.add_argument("-a", "--average", help="average colors for each frame", action='store_true', default=False)
|
527 |
+
parser.add_argument("-b", "--blur", help="apply motion blur to the frames (kernel size)", type=int, nargs='?', const=100, default=0)
|
528 |
+
parser.add_argument("-q", "--quiet", help="mute console outputs", action='store_true', default=False)
|
529 |
+
parser.add_argument("--help", action="help", help="show this help message and exit")
|
530 |
+
|
531 |
+
args = parser.parse_args()
|
532 |
+
|
533 |
+
# check number of frames arguments
|
534 |
+
if args.nframes is None:
|
535 |
+
if args.interval is not None: # calculate nframes from interval
|
536 |
+
args.nframes = FrameVis.nframes_from_interval(args.source, args.interval)
|
537 |
+
else:
|
538 |
+
parser.error("You must provide either an --(n)frames or --(i)nterval argument")
|
539 |
+
|
540 |
+
# check postprocessing arguments
|
541 |
+
if args.average is True and args.blur != 0:
|
542 |
+
parser.error("Cannot (a)verage and (b)lur, you must choose one or the other")
|
543 |
+
|
544 |
+
fv = FrameVis()
|
545 |
+
|
546 |
+
output_image = fv.visualize(args.source, args.nframes, height=args.height, width=args.width, \
|
547 |
+
direction=args.direction, trim=args.trim, quiet=args.quiet)
|
548 |
+
|
549 |
+
# postprocess
|
550 |
+
if args.average or args.blur != 0:
|
551 |
+
if args.average:
|
552 |
+
if not args.quiet:
|
553 |
+
print("Averaging frame colors... ", end="", flush=True)
|
554 |
+
output_image = fv.average_image(output_image, args.direction)
|
555 |
+
|
556 |
+
if args.blur != 0:
|
557 |
+
if not args.quiet:
|
558 |
+
print("Adding motion blur to final frame... ", end="", flush=True)
|
559 |
+
output_image = fv.motion_blur(output_image, args.direction, args.blur)
|
560 |
+
|
561 |
+
if not args.quiet:
|
562 |
+
print("done")
|
563 |
+
|
564 |
+
cv2.imwrite(args.destination, output_image) # save visualization to file
|
565 |
+
|
566 |
+
if not args.quiet:
|
567 |
+
print("Visualization saved to {}".format(args.destination))
|
568 |
+
|
569 |
+
|
570 |
+
if __name__ == "__main__":
|
571 |
+
main()
|
images/FrameVis_Banner.jpg
ADDED
requirements.txt
ADDED
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
gradio>=3.50.2
|
2 |
+
opencv-python>=4.8.0
|
3 |
+
numpy>=1.24.0
|
4 |
+
fastapi>=0.115.0
|
5 |
+
python-multipart>=0.0.6
|