photographic_memory 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. checksums.yaml +7 -0
  2. data/lib/photographic_memory.rb +260 -0
  3. metadata +101 -0
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 1a68924057c21aeb4257048b809789a2c9ce0284
4
+ data.tar.gz: da5658dd3a42a345af1f9d76d1735f917b4d59fd
5
+ SHA512:
6
+ metadata.gz: 67b46e485cb4c9c9d491e8bf72e4b9cd5b7b639928248b498a2b514f3e99b974fe0264edbc04153e459286d3cff6a4f6390d2f30e5355e6fbb65b98eb0413f2c
7
+ data.tar.gz: dac138da3170cb38f4dbf0e397452826143d3165d83ca1060c14630c1c484fcc4bf98d3076bf0d1c0d10b338543bdee99d4cfbcb1904d198812c476fb0f95f3d
@@ -0,0 +1,260 @@
1
+ require "timeout"
2
+ require "open3"
3
+ require "digest"
4
+ require "rack/mime"
5
+ require "aws-sdk"
6
+ require "mini_exiftool"
7
+
8
+ ##
9
+ # An image processing client that uses ImageMagick's convert and an AWS S3-like API for storage.
10
+ #
11
+ # @example
12
+ #
13
+ # client = PhotographicMemory.new(@config)
14
+ # client.put file: image, id: 123
15
+ #
16
+ # @param [Hash] config
17
+ # @param [String] config[:environment] - The application environment. Is optional and only changes behavior with the string "test", which stubs S3 responses and prevents calls to Rekognition.
18
+ # @param [String] config[:s3_region] - The region to use for S3. Only relevant when actually using AWS S3.
19
+ # @param [String] config[:s3_endpoint] - The endpoint to use for S3 calls. Only required when using your own S3-compatible storage medium.
20
+ # @param [Boolean] config[:s3_force_path_style] - Forces path style for S3 API calls. Defaults to true.
21
+ # @param [String] config[:s3_access_key_id] - The access key ID for S3 calls.
22
+ # @param [String] config[:s3_secret_access_key] - The secret access key for S3 calls.
23
+ # @param [string] config[:s3_signature_version] - The signature version for S3 calls. Defaults to 's3'.
24
+ # @param [string] config[:rekognition_access_key_id] - The access key ID for Rekognition calls.
25
+ # @param [string] config[:rekognition_secret_access_key] - The secret access key for Rekognition calls.
26
+ # @param [string] config[:rekognition_region] - The region for Rekognition calls.
27
+ #
28
+ class PhotographicMemory
29
+
30
+ attr_accessor :config, :s3_client
31
+
32
+ class PhotographicMemoryError < StandardError; end
33
+
34
+ def initialize config={}
35
+ @config = config
36
+ options = {
37
+ region: config[:s3_region],
38
+ endpoint: config[:s3_endpoint],
39
+ force_path_style: config[:s3_force_path_style] || true,
40
+ credentials: Aws::Credentials.new(
41
+ config[:s3_access_key_id],
42
+ config[:s3_secret_access_key]
43
+ ),
44
+ stub_responses: config[:environment] === "test",
45
+ signature_version: config[:s3_signature_version] || "s3"
46
+ }.select{|k,v| !v.nil?}
47
+ @s3_client = Aws::S3::Client.new(options)
48
+ end
49
+
50
+ def put file:, id:, style_name:"original", convert_options: [], content_type:
51
+ unless (style_name == "original") || convert_options.empty?
52
+ if content_type.match "image/gif"
53
+ output = render_gif file, convert_options
54
+ else
55
+ output = render file, convert_options
56
+ end
57
+ else
58
+ output = file.read
59
+ end
60
+ file.rewind
61
+ original_digest = Digest::MD5.hexdigest(file.read)
62
+ rendered_digest = Digest::MD5.hexdigest(output)
63
+ output_fingerprint = (style_name == "original") ? "original" : rendered_digest
64
+ extension = Rack::Mime::MIME_TYPES.invert[content_type]
65
+ key = "#{id}_#{original_digest}_#{output_fingerprint}#{extension}"
66
+ @s3_client.put_object({
67
+ bucket: @config[:s3_bucket],
68
+ key: key,
69
+ body: output,
70
+ content_type: content_type
71
+ })
72
+ if style_name == "original" && config[:environment] != "test"
73
+ reference = StringIO.new render(file, ["-quality 10"])
74
+ # 👆 this is a low quality reference image we generate
75
+ # which is sufficient for classification purposes but
76
+ # saves bandwidth and overcomes the file size limit
77
+ # for Rekognition
78
+ keywords = detect_labels reference
79
+ gravity = detect_gravity reference
80
+ else
81
+ keywords = []
82
+ gravity = "Center"
83
+ end
84
+ {
85
+ fingerprint: rendered_digest,
86
+ metadata: exif(file),
87
+ extension: extension,
88
+ filename: key,
89
+ keywords: keywords,
90
+ gravity: gravity
91
+ }
92
+ end
93
+
94
+ def get key
95
+ @s3_client.get_object({
96
+ bucket: @config[:s3_bucket],
97
+ key: key
98
+ }).body
99
+ end
100
+
101
+ def delete key
102
+ @s3_client.delete_object({
103
+ bucket: @config[:s3_bucket],
104
+ key: key
105
+ })
106
+ end
107
+
108
+ private
109
+
110
+ def exif file
111
+ file.rewind
112
+ MiniExiftool.new(file, :replace_invalid_chars => "")
113
+ end
114
+
115
+ def render file, convert_options=[]
116
+ file.rewind
117
+ run_command ["convert", "-", convert_options, "jpeg:-"].flatten.join(" "), file.read.force_encoding("UTF-8")
118
+ end
119
+
120
+ def render_gif file, convert_options=[]
121
+ convert_options.concat(["-coalesce", "-repage 0x0", "+repage"])
122
+ convert_options.each do |option|
123
+ if option.match("-crop")
124
+ option.concat " +repage"
125
+ end
126
+ end
127
+ file.rewind
128
+ run_command ["convert", "-", convert_options, "gif:-"].flatten.join(" "), file.read.force_encoding("UTF-8")
129
+ end
130
+
131
+ def classify file
132
+ file.rewind
133
+ detect_labels file
134
+ rescue Aws::Rekognition::Errors::ServiceError, Aws::Errors::MissingRegionError, Seahorse::Client::NetworkingError
135
+ # This also is not worth crashing over
136
+ []
137
+ end
138
+
139
+ def detect_gravity file
140
+ file.rewind
141
+
142
+ boxes = detect_faces(file).map(&:bounding_box)
143
+
144
+ box = boxes.max_by{|b| b.width * b.height } # use the largest face in the photo
145
+
146
+ return "Center" if !box
147
+
148
+ x = nearest_fifth((box.width / 2) + ((box.left >= 0) ? box.left : 0))
149
+ y = nearest_fifth((box.height / 2) + ((box.top >= 0) ? box.top : 0))
150
+
151
+ gravity_table = {
152
+ 0.0 => {
153
+ 0.0 => "NorthWest",
154
+ 0.5 => "West",
155
+ 1.0 => "SouthWest"
156
+ },
157
+ 0.5 => {
158
+ 0.0 => "North",
159
+ 0.5 => "Center",
160
+ 1 => "South"
161
+ },
162
+ 1.0 => {
163
+ 0.0 => "NorthEast",
164
+ 0.5 => "East",
165
+ 1.0 => "SouthEast"
166
+ }
167
+ }
168
+
169
+ gravity_table[x][y]
170
+ end
171
+
172
+ def nearest_fifth num
173
+ (num * 2).round / 2.0
174
+ end
175
+
176
+ def detect_labels file
177
+ file.rewind
178
+ # get the original image from S3 and classify
179
+ client = Aws::Rekognition::Client.new({
180
+ region: @config[:rekognition_region],
181
+ credentials: Aws::Credentials.new(
182
+ @config[:rekognition_access_key_id],
183
+ @config[:rekognition_secret_access_key]
184
+ )
185
+ })
186
+ client.detect_labels({
187
+ image: {
188
+ bytes: file
189
+ },
190
+ max_labels: 123,
191
+ min_confidence: 73,
192
+ }).labels
193
+ rescue Aws::Rekognition::Errors::ServiceError, Aws::Errors::MissingRegionError, Seahorse::Client::NetworkingError => e
194
+ []
195
+ end
196
+
197
+ def detect_faces file
198
+ file.rewind
199
+ # get the original image from S3 and classify
200
+ client = Aws::Rekognition::Client.new({
201
+ region: @config[:rekognition_region],
202
+ credentials: Aws::Credentials.new(
203
+ @config[:rekognition_access_key_id],
204
+ @config[:rekognition_secret_access_key]
205
+ )
206
+ })
207
+ client.detect_faces({
208
+ image: {
209
+ bytes: file
210
+ },
211
+ attributes: ["ALL"]
212
+ }).face_details
213
+ rescue Aws::Rekognition::Errors::ServiceError, Aws::Errors::MissingRegionError, Seahorse::Client::NetworkingError => e
214
+ []
215
+ end
216
+
217
+ def run_command command, input
218
+ stdin, stdout, stderr, wait_thr = Open3.popen3(command)
219
+ pid = wait_thr.pid
220
+
221
+ Timeout.timeout(10) do # cancel in 10 seconds
222
+ stdin.write input
223
+ stdin.close
224
+
225
+ output_buffer = []
226
+ error_buffer = []
227
+
228
+ while (output_chunk = stdout.gets) || (error_chunk = stderr.gets)
229
+ output_buffer << output_chunk
230
+ error_buffer << error_chunk
231
+ end
232
+
233
+ output_buffer.compact!
234
+ error_buffer.compact!
235
+
236
+ output = output_buffer.any? ? output_buffer.join('') : nil
237
+ error = error_buffer.any? ? error_buffer.join('') : nil
238
+
239
+ unless error
240
+ raise PhotographicMemoryError, "No output received." if !output
241
+ else
242
+ raise PhotographicMemoryError, error
243
+ end
244
+ output
245
+ end
246
+ rescue Timeout::Error, Errno::EPIPE => e
247
+ raise PhotographicMemoryError, e.message
248
+ ensure
249
+ begin
250
+ Process.kill("KILL", pid) if pid
251
+ rescue Errno::ESRCH
252
+ # Process is already dead so do nothing.
253
+ end
254
+ stdin = nil
255
+ stdout = nil
256
+ stderr = nil
257
+ wait_thr.value if wait_thr # Process::Status object returned.
258
+ end
259
+ end
260
+
metadata ADDED
@@ -0,0 +1,101 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: photographic_memory
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.3
5
+ platform: ruby
6
+ authors:
7
+ - Ten Bitcomb
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2018-04-17 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: aws-sdk
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2'
27
+ - !ruby/object:Gem::Dependency
28
+ name: mini_exiftool
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 2.8.0
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 2.8.0
41
+ - !ruby/object:Gem::Dependency
42
+ name: rack
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: 2.0.3
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: 2.0.3
55
+ - !ruby/object:Gem::Dependency
56
+ name: minitest
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: 5.11.3
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: 5.11.3
69
+ description: Simple image processing and storage
70
+ email: tenbitcomb@gmail.com
71
+ executables: []
72
+ extensions: []
73
+ extra_rdoc_files: []
74
+ files:
75
+ - lib/photographic_memory.rb
76
+ homepage: http://github.com/scpr/photographic_memory
77
+ licenses:
78
+ - MIT
79
+ metadata: {}
80
+ post_install_message:
81
+ rdoc_options: []
82
+ require_paths:
83
+ - lib
84
+ required_ruby_version: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ required_rubygems_version: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - ">="
92
+ - !ruby/object:Gem::Version
93
+ version: '0'
94
+ requirements: []
95
+ rubyforge_project:
96
+ rubygems_version: 2.5.1
97
+ signing_key:
98
+ specification_version: 4
99
+ summary: Simple image processing and storage
100
+ test_files: []
101
+ has_rdoc: