find_dark_intervals 0.1.0
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.
- checksums.yaml +7 -0
- data/.gitignore +8 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +22 -0
- data/README.md +45 -0
- data/Rakefile +2 -0
- data/bin/console +17 -0
- data/bin/setup +8 -0
- data/find_dark_intervals.gemspec +28 -0
- data/lib/find_dark_intervals.rb +5 -0
- data/lib/find_dark_intervals/intervals_finder.rb +162 -0
- data/lib/find_dark_intervals/version.rb +3 -0
- metadata +97 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: e35ab3371aeec2e94879a970e47ce498c6a8c92e
|
4
|
+
data.tar.gz: 324a5a1e3b5732a8a1861eef86aa66f7bb3f766b
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 1c1848211409fc90b4fbb0fc5c06c792b7ea0dfbacbff6fe90f37040d3baeaae698813f3c91bf8a2ce29c84896e5fc75cf34c8dc41c52a06619ebbfccd395d16
|
7
|
+
data.tar.gz: a29ba0598d74df833cd7e590b98e2242829958312b7b6566a7ef6b5cdf5adf775ff1c24d353a4be4b53caf5f8cfba5cd08b5e87819d2a3c717cb3cc3db93793b
|
data/.gitignore
ADDED
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
PATH
|
2
|
+
remote: .
|
3
|
+
specs:
|
4
|
+
find_dark_intervals (0.1.0)
|
5
|
+
descriptive_statistics (~> 2.5)
|
6
|
+
|
7
|
+
GEM
|
8
|
+
remote: https://rubygems.org/
|
9
|
+
specs:
|
10
|
+
descriptive_statistics (2.5.1)
|
11
|
+
rake (10.5.0)
|
12
|
+
|
13
|
+
PLATFORMS
|
14
|
+
ruby
|
15
|
+
|
16
|
+
DEPENDENCIES
|
17
|
+
bundler (~> 1.16)
|
18
|
+
find_dark_intervals!
|
19
|
+
rake (~> 10.0)
|
20
|
+
|
21
|
+
BUNDLED WITH
|
22
|
+
1.16.6
|
data/README.md
ADDED
@@ -0,0 +1,45 @@
|
|
1
|
+
# Find Dark Intervals
|
2
|
+
|
3
|
+
Finds sections in a video with sufficient darkness. Works without relying on
|
4
|
+
complex machine learning solutions.
|
5
|
+
|
6
|
+
## How it works
|
7
|
+
|
8
|
+
1. Takes an input video file and splits it into image frames using [ffmpeg](https://www.ffmpeg.org).
|
9
|
+
2. Computes a mean grayscale color value for each frame using [ImageMagick](https://www.imagemagick.org).
|
10
|
+
3. Identifies sufficiently dark frames using [standard score](https://en.wikipedia.org/wiki/Standard_score).
|
11
|
+
4. Computes start/end timeframes for dark segments and trims the original video.
|
12
|
+
|
13
|
+
## What can this be used for?
|
14
|
+
|
15
|
+
Quickly finding points of interest in a video that correspond to changes in
|
16
|
+
brightness.
|
17
|
+
|
18
|
+
## Installation
|
19
|
+
|
20
|
+
Install it yourself as:
|
21
|
+
|
22
|
+
```sh
|
23
|
+
gem install find_dark_intervals
|
24
|
+
```
|
25
|
+
|
26
|
+
## Usage
|
27
|
+
|
28
|
+
Make sure you have a recent version of `ffmpeg` and `convert` in your `PATH`.
|
29
|
+
|
30
|
+
On Mac, they can be installed using Homebrew:
|
31
|
+
|
32
|
+
```sh
|
33
|
+
brew update
|
34
|
+
brew install ffmpeg imagemagick
|
35
|
+
```
|
36
|
+
|
37
|
+
Then in `irb`, run:
|
38
|
+
|
39
|
+
```rb
|
40
|
+
require 'find_dark_intervals'
|
41
|
+
FindDarkIntervals::IntervalsFinder.new('your/video/path.mp4').run
|
42
|
+
```
|
43
|
+
|
44
|
+
After some time, you should see a new file `<video_name>_highlights.mp4` in the
|
45
|
+
same directory as the original video.
|
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "find_dark_intervals"
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
# require "pry"
|
11
|
+
# Pry.start
|
12
|
+
|
13
|
+
# require "irb"
|
14
|
+
# IRB.start(__FILE__)
|
15
|
+
|
16
|
+
filename = ARGV[0]
|
17
|
+
FindDarkIntervals::IntervalsFinder.new(filename).run
|
data/bin/setup
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
lib = File.expand_path("../lib", __FILE__)
|
2
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
3
|
+
require "find_dark_intervals/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |spec|
|
6
|
+
spec.name = "find_dark_intervals"
|
7
|
+
spec.version = FindDarkIntervals::VERSION
|
8
|
+
spec.authors = ["Maros Hluska"]
|
9
|
+
spec.email = ["mhluska@gmail.com"]
|
10
|
+
|
11
|
+
spec.summary = 'Find dark intervals within a video'
|
12
|
+
spec.description = 'Finds sections in a video with sufficient darkness using ffmpeg, ImageMagick and standard score'
|
13
|
+
spec.homepage = 'https://mhluska.com'
|
14
|
+
|
15
|
+
# Specify which files should be added to the gem when it is released.
|
16
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
17
|
+
spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
|
18
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
19
|
+
end
|
20
|
+
spec.bindir = "exe"
|
21
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
22
|
+
spec.require_paths = ["lib"]
|
23
|
+
|
24
|
+
spec.add_dependency "descriptive_statistics", "~> 2.5"
|
25
|
+
|
26
|
+
spec.add_development_dependency "bundler", "~> 1.16"
|
27
|
+
spec.add_development_dependency "rake", "~> 10.0"
|
28
|
+
end
|
@@ -0,0 +1,162 @@
|
|
1
|
+
require 'fileutils'
|
2
|
+
require 'json'
|
3
|
+
require 'descriptive_statistics/safe'
|
4
|
+
|
5
|
+
module FindDarkIntervals
|
6
|
+
class IntervalsFinder
|
7
|
+
def initialize(filepath)
|
8
|
+
raise 'No filepath given' unless filepath
|
9
|
+
raise 'File does not exist' unless File.exist?(filepath)
|
10
|
+
|
11
|
+
# TODO: Make a parameter for this.
|
12
|
+
@lossless = true
|
13
|
+
@filepath = filepath
|
14
|
+
end
|
15
|
+
|
16
|
+
def run
|
17
|
+
make_image_frames
|
18
|
+
colors = get_colors
|
19
|
+
intervals = get_intervals(get_colors)
|
20
|
+
generate_video(intervals)
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def make_image_frames
|
26
|
+
return if Dir.exist?(frames_dirname)
|
27
|
+
|
28
|
+
FileUtils.mkdir_p(frames_dirname)
|
29
|
+
run_command("ffmpeg -i #{@filepath} -r 1/1 #{frames_dirname}/\$filename%03d.jpg")
|
30
|
+
end
|
31
|
+
|
32
|
+
def get_colors
|
33
|
+
return parse_colors_file(colors_filepath) if File.exist?(colors_filepath)
|
34
|
+
|
35
|
+
colors = {}
|
36
|
+
|
37
|
+
Dir.foreach(frames_dirname) do |image_filename|
|
38
|
+
next if image_filename == '.' or image_filename == '..'
|
39
|
+
colors[no_ext(image_filename)] = run_command("convert #{frames_dirname}/#{image_filename} -colorspace Gray -format '%[fx:quantumrange*image.mean]' info:")
|
40
|
+
end
|
41
|
+
|
42
|
+
File.write(colors_filepath, JSON.dump(colors))
|
43
|
+
|
44
|
+
colors
|
45
|
+
end
|
46
|
+
|
47
|
+
def get_intervals(colors)
|
48
|
+
values = colors.values.extend(DescriptiveStatistics)
|
49
|
+
mean = values.mean
|
50
|
+
standard_deviation = values.standard_deviation
|
51
|
+
|
52
|
+
# 2.5 standard deviations towards mean color darkness.
|
53
|
+
# TODO: Make a parameter for this.
|
54
|
+
threshold_std = -2.5
|
55
|
+
|
56
|
+
sorted_frames = colors.keys.sort_by(&:to_i)
|
57
|
+
|
58
|
+
dark_frames = sorted_frames.select do |frame_number|
|
59
|
+
color = colors[frame_number]
|
60
|
+
z_score = z_score(color, mean, standard_deviation)
|
61
|
+
z_score <= threshold_std
|
62
|
+
end
|
63
|
+
|
64
|
+
intervals = find_sequence_boundaries(dark_frames.map(&:to_i))
|
65
|
+
|
66
|
+
intervals.map do |pair|
|
67
|
+
time_start = pair.first.to_f / sorted_frames.last.to_i * duration_minutes
|
68
|
+
time_end = pair.last.to_f / sorted_frames.last.to_i * duration_minutes
|
69
|
+
[time_start, time_end]
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def generate_video(intervals)
|
74
|
+
# Segment padding in seconds.
|
75
|
+
# TODO: Make a parameter for this.
|
76
|
+
pad = 3
|
77
|
+
start_time = intervals.first.first.to_i
|
78
|
+
output = []
|
79
|
+
|
80
|
+
intervals.each_with_index do |pair, index|
|
81
|
+
puts "Start: #{format_time(pair.first)}, End: #{format_time(pair.last)}"
|
82
|
+
|
83
|
+
ss = pair.first.to_i
|
84
|
+
to = pair.last.to_i
|
85
|
+
output << "[0:v]trim=#{ss - start_time - pad}:#{to - start_time + pad},setpts=PTS-STARTPTS[v#{index}]"
|
86
|
+
output << "[0:a]atrim=#{ss - start_time - pad}:#{to - start_time + pad},asetpts=PTS-STARTPTS[a#{index}]"
|
87
|
+
end
|
88
|
+
|
89
|
+
concat_inputs = intervals.length.times.map { |index| "[v#{index}][a#{index}]" }.join
|
90
|
+
|
91
|
+
output << "#{concat_inputs}concat=n=#{intervals.length}:v=1:a=1[out]"
|
92
|
+
|
93
|
+
# See https://superuser.com/a/522853/194670.
|
94
|
+
lossless_args = @lossless ? '-c:v libx264 -crf 0 -preset ultrafast -c:a libmp3lame -b:a 320k' : ''
|
95
|
+
|
96
|
+
filter_complex = output.join('; ')
|
97
|
+
|
98
|
+
run_command("ffmpeg -ss #{format_time(start_time - pad)} -i #{@filepath} -filter_complex '#{filter_complex}' #{lossless_args} -map '[out]' #{highlights_filepath}")
|
99
|
+
end
|
100
|
+
|
101
|
+
def duration_minutes
|
102
|
+
@duration_minutes ||= `ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 #{@filepath}`.to_f
|
103
|
+
end
|
104
|
+
|
105
|
+
# TODO: Move to utils.
|
106
|
+
def z_score(value, mean, standard_deviation)
|
107
|
+
standard_deviation.zero? ? 0 : (value - mean) / standard_deviation
|
108
|
+
end
|
109
|
+
|
110
|
+
# TODO: Move to utils.
|
111
|
+
def run_command(command)
|
112
|
+
puts "Running command: #{command}"
|
113
|
+
`#{command}`
|
114
|
+
end
|
115
|
+
|
116
|
+
# TODO: Move to utils.
|
117
|
+
def no_ext(filename)
|
118
|
+
File.basename(filename, File.extname(filename))
|
119
|
+
end
|
120
|
+
|
121
|
+
# TODO: Move to utils.
|
122
|
+
def format_time(seconds)
|
123
|
+
Time.at(seconds).utc.strftime("%H:%M:%S")
|
124
|
+
end
|
125
|
+
|
126
|
+
# TODO: Move to utils.
|
127
|
+
def find_sequence_boundaries(integer_sequence)
|
128
|
+
boundaries = []
|
129
|
+
|
130
|
+
integer_sequence.each_with_index do |number, index|
|
131
|
+
is_start = integer_sequence[index - 1] != number - 1
|
132
|
+
is_end = integer_sequence[index + 1] != number + 1
|
133
|
+
|
134
|
+
# NOTE: Exclusive results in exluding boundaries where the start is also
|
135
|
+
# the end aka single-frame boundaries.
|
136
|
+
boundaries << number if is_start ^ is_end
|
137
|
+
end
|
138
|
+
|
139
|
+
boundaries.each_slice(2).to_a
|
140
|
+
end
|
141
|
+
|
142
|
+
def parse_colors_file(colors_filepath)
|
143
|
+
JSON.parse(File.read(colors_filepath)).transform_values!(&:to_f)
|
144
|
+
end
|
145
|
+
|
146
|
+
def filename_no_ext
|
147
|
+
@filepath_no_ext || no_ext(@filepath)
|
148
|
+
end
|
149
|
+
|
150
|
+
def frames_dirname
|
151
|
+
File.join(File.dirname(@filepath), "#{filename_no_ext}_frames")
|
152
|
+
end
|
153
|
+
|
154
|
+
def highlights_filepath
|
155
|
+
File.join(File.dirname(@filepath), "#{filename_no_ext}_highlights.mp4")
|
156
|
+
end
|
157
|
+
|
158
|
+
def colors_filepath
|
159
|
+
File.join(frames_dirname, "#{filename_no_ext}_colors.json")
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
metadata
ADDED
@@ -0,0 +1,97 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: find_dark_intervals
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Maros Hluska
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2018-12-12 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: descriptive_statistics
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '2.5'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '2.5'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: bundler
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '1.16'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '1.16'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rake
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '10.0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '10.0'
|
55
|
+
description: Finds sections in a video with sufficient darkness using ffmpeg, ImageMagick
|
56
|
+
and standard score
|
57
|
+
email:
|
58
|
+
- mhluska@gmail.com
|
59
|
+
executables: []
|
60
|
+
extensions: []
|
61
|
+
extra_rdoc_files: []
|
62
|
+
files:
|
63
|
+
- ".gitignore"
|
64
|
+
- Gemfile
|
65
|
+
- Gemfile.lock
|
66
|
+
- README.md
|
67
|
+
- Rakefile
|
68
|
+
- bin/console
|
69
|
+
- bin/setup
|
70
|
+
- find_dark_intervals.gemspec
|
71
|
+
- lib/find_dark_intervals.rb
|
72
|
+
- lib/find_dark_intervals/intervals_finder.rb
|
73
|
+
- lib/find_dark_intervals/version.rb
|
74
|
+
homepage: https://mhluska.com
|
75
|
+
licenses: []
|
76
|
+
metadata: {}
|
77
|
+
post_install_message:
|
78
|
+
rdoc_options: []
|
79
|
+
require_paths:
|
80
|
+
- lib
|
81
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
82
|
+
requirements:
|
83
|
+
- - ">="
|
84
|
+
- !ruby/object:Gem::Version
|
85
|
+
version: '0'
|
86
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
87
|
+
requirements:
|
88
|
+
- - ">="
|
89
|
+
- !ruby/object:Gem::Version
|
90
|
+
version: '0'
|
91
|
+
requirements: []
|
92
|
+
rubyforge_project:
|
93
|
+
rubygems_version: 2.6.12
|
94
|
+
signing_key:
|
95
|
+
specification_version: 4
|
96
|
+
summary: Find dark intervals within a video
|
97
|
+
test_files: []
|