GenAIJake commited on
Commit
d80a719
1 Parent(s): 29ea756

first commit

Browse files
Files changed (7) hide show
  1. LICENSE +21 -0
  2. README.md +63 -13
  3. app.py +201 -0
  4. eval.txt +93 -0
  5. framevis.py +571 -0
  6. images/FrameVis_Banner.jpg +0 -0
  7. 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
- title: FrameVis
3
- emoji: 👀
4
- colorFrom: red
5
- colorTo: red
6
- sdk: gradio
7
- sdk_version: 5.6.0
8
- app_file: app.py
9
- pinned: false
10
- short_description: Movie Barcode Poster Maker
11
- ---
12
-
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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