morandi 0.9.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 699363674b014c312fbf514929f5d9a68bc7710c
4
+ data.tar.gz: a3a8dc47dfb8e4b2a3c09904d44bd0ab32240043
5
+ SHA512:
6
+ metadata.gz: 522a4bda18dfe5f5c5326f56385c2bf45bc0e14f5b2dd2946234be72e1d322d5b835e8cd3487e69d824d674bdd31daf508d67389f35fbdb0d8842e25ece61b39
7
+ data.tar.gz: 06b08143b66b203c290e2b2493db345ac3d34ef82d95d663d5496fae9bcd965cef9b54dffb50ad9647ed349cc09f52dc5f474734ead26cad6da6f5feb56b3361
data/.gitignore ADDED
@@ -0,0 +1,17 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --color
2
+ --format progress
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in morandi.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,25 @@
1
+ Copyright (c) 2015 Geoff Youngs
2
+
3
+
4
+
5
+
6
+ MIT License
7
+
8
+ Permission is hereby granted, free of charge, to any person obtaining
9
+ a copy of this software and associated documentation files (the
10
+ "Software"), to deal in the Software without restriction, including
11
+ without limitation the rights to use, copy, modify, merge, publish,
12
+ distribute, sublicense, and/or sell copies of the Software, and to
13
+ permit persons to whom the Software is furnished to do so, subject to
14
+ the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be
17
+ included in all copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
20
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
21
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
22
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
23
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
24
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
25
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # Morandi
2
+
3
+ Library of simple image manipulations - replicating the behaviour of
4
+ morandi-js.
5
+
6
+ ## Installation
7
+
8
+ Add this line to your application's Gemfile:
9
+
10
+ gem 'morandi'
11
+
12
+ And then execute:
13
+
14
+ $ bundle
15
+
16
+ Or install it yourself as:
17
+
18
+ $ gem install morandi
19
+
20
+ ## Usage
21
+
22
+ ````
23
+ Morandi.process(in_file, settings, out_file)
24
+ ````
25
+ - in_file is a string
26
+ - settings is a hash
27
+ - out_file is a string
28
+
29
+ Settings Key | Values | Description
30
+ -------------|--------|---------------
31
+ brighten | Integer -20..20 | Change image brightness
32
+ gamma | Float | Gamma correct image
33
+ contrast | Integer -20..20 | Change image contrast
34
+ sharpen | Integer -5..5 | Sharpen / Blur (negative value)
35
+ redeye | Array[[Integer,Integer],...] | Apply redeye correction at point
36
+ angle | Integer 0,90,180,270 | Rotate image
37
+ crop | Array[Integer,Integer,Integer,Integer] | Crop image
38
+ fx | String greyscale,sepia,bluetone | Apply colour filters
39
+ border-style | String square,retro | Set border style
40
+ background-style | String retro,black,white | Set border colour
41
+
42
+
43
+ ## Contributing
44
+
45
+ 1. Fork it ( http://github.com/<my-github-username>/morandi/fork )
46
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
47
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
48
+ 4. Push to the branch (`git push origin my-new-feature`)
49
+ 5. Create new Pull Request
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
data/lib/morandi.rb ADDED
@@ -0,0 +1,252 @@
1
+ require "morandi/version"
2
+ require 'gdk_pixbuf2'
3
+ require 'cairo'
4
+ require 'pixbufutils'
5
+ require 'redeye'
6
+
7
+ require 'morandi/utils'
8
+ require 'morandi/image-ops'
9
+ require 'morandi/redeye'
10
+
11
+ module Morandi
12
+ # Your code goes here...
13
+ class ImageProcessor
14
+ attr_reader :options, :pb
15
+ attr_accessor :config
16
+
17
+ def valid_jpeg?(filename)
18
+ return false unless File.exist?(filename)
19
+ return false unless File.size(filename) > 0
20
+
21
+ type, _, _ = Gdk::Pixbuf.get_file_info(filename)
22
+
23
+ type.name == 'jpeg'
24
+ rescue
25
+ false
26
+ end
27
+
28
+ def initialize(file, scale_to, options)
29
+ @file = file
30
+ #@size = size
31
+ @scale_to = scale_to
32
+ @options = options || {}
33
+
34
+ @width, @height = options['output.width'], options['output.height']
35
+
36
+ load_file = @file
37
+ type, width, height = Gdk::Pixbuf.get_file_info(load_file)
38
+
39
+ if type.name.eql?('jpeg')
40
+ icc_file = "#{@file}.icc.jpg"
41
+ if valid_jpeg?(icc_file) || system("jpgicc", "-q97", @file, icc_file)
42
+ load_file = icc_file
43
+ end
44
+ end
45
+
46
+ if @scale_to
47
+ @pb = Gdk::Pixbuf.new(load_file, @scale_to, @scale_to)
48
+ @src_max = [width, height].max
49
+ @actual_max = [@pb.width, @pb.height].max
50
+ else
51
+ @pb = Gdk::Pixbuf.new(load_file)
52
+ @src_max = [@pb.width, @pb.height].max
53
+ @actual_max = [@pb.width, @pb.height].max
54
+ end
55
+
56
+ @scale = @actual_max / @src_max.to_f
57
+ end
58
+
59
+ def process!
60
+ # Apply Red-Eye corrections
61
+ apply_redeye!
62
+
63
+ # Apply contrast, brightness etc
64
+ apply_colour_manipulations!
65
+
66
+ # apply rotation
67
+ apply_rotate!
68
+
69
+ # apply crop
70
+ apply_crop!
71
+
72
+ # apply filter
73
+ apply_filters!
74
+
75
+ # add border
76
+ apply_decorations!
77
+
78
+ if options['output.limit']
79
+ @pb = @pb.scale_max([@width, @height].max)
80
+ end
81
+
82
+ @pb
83
+ end
84
+
85
+ SHARPEN = [
86
+ -1, -1, -1, -1, -1,
87
+ -1, 2, 2, 2, -1,
88
+ -1, 2, 8, 2, -1,
89
+ -1, 2, 2, 2, -1,
90
+ -1, -1, -1, -1, -1,
91
+ ]
92
+ BLUR = [
93
+ 0, 1, 1, 1, 0,
94
+ 1, 1, 1, 1, 1,
95
+ 1, 1, 1, 1, 1,
96
+ 1, 1, 1, 1, 1,
97
+ 0, 1, 1, 1, 0,
98
+ ]
99
+
100
+ def apply_colour_manipulations!
101
+ #STDERR.puts "FILTER: #{options.inspect}"
102
+ if options['brighten'].to_i.nonzero?
103
+ brighten = [ [ 5 * options['brighten'], -100 ].max, 100 ].min
104
+ STDERR.puts([:brighten, brighten].inspect)
105
+ @pb = PixbufUtils.brightness(@pb, brighten)
106
+ end
107
+ if options['gamma'] && (options['gamma'] != 1.0)
108
+ @pb = PixbufUtils.gamma(@pb, options['gamma'])
109
+ end
110
+ if options['contrast'].to_i.nonzero?
111
+ @pb = PixbufUtils.contrast(@pb, [ [ 5 * options['contrast'], -100 ].max, 100 ].min)
112
+ end
113
+ if options['sharpen'].to_i.nonzero?
114
+ if options['sharpen'] > 0
115
+ [options['sharpen'], 5].min.times do
116
+ @pb = PixbufUtils.filter(@pb, SHARPEN, SHARPEN.inject(0, &:+))
117
+ end
118
+ elsif options['sharpen'] < 0
119
+ [ (options['sharpen']*-1), 5].min.times do
120
+ @pb = PixbufUtils.filter(@pb, BLUR, BLUR.inject(0, &:+))
121
+ end
122
+ end
123
+ end
124
+ end
125
+
126
+ def apply_redeye!
127
+ for eye in options['redeye'] || []
128
+ @pb = Morandi::RedEye.tap_on(@pb, eye[0] * @scale, eye[1] * @scale)
129
+ end
130
+ end
131
+
132
+ def angle
133
+ a = options['angle'].to_i
134
+ if a
135
+ (360-a)%360
136
+ else
137
+ nil
138
+ end
139
+ end
140
+
141
+ # modifies @pb with any applied rotation
142
+ def apply_rotate!
143
+ a = angle()
144
+
145
+ unless (a%360).zero?
146
+ @pb = @pb.rotate(a)
147
+ end
148
+
149
+ unless options['straighten'].to_f.zero?
150
+ @pb = Morandi::Straighten.new(options['straighten'].to_f).call(nil, @pb)
151
+ end
152
+
153
+ @image_width = @pb.width
154
+ @image_height = @pb.height
155
+ end
156
+
157
+ DEFAULT_CONFIG = {
158
+ 'border-size-mm' => 5
159
+ }
160
+ def config_for(key)
161
+ return options[key] if options && options.has_key?(key)
162
+ return @config[key] if @config && @config.has_key?(key)
163
+ DEFAULT_CONFIG[key]
164
+ end
165
+
166
+ #
167
+ def apply_crop!
168
+ crop = options['crop']
169
+
170
+ if crop.nil? && config_for('image.auto-crop').eql?(false)
171
+ return
172
+ end
173
+
174
+ crop = nil unless crop.is_a?(Array) && crop.size.eql?(4) && crop.all? { |i|
175
+ i.kind_of?(Numeric)
176
+ }
177
+
178
+ # can't crop, won't crop
179
+ return if @width.nil? && @height.nil? && crop.nil?
180
+
181
+ if crop && @scale != 1.0
182
+ crop = crop.map { |s| (s.to_f * @scale).floor }
183
+ end
184
+
185
+ crop ||= Morandi::Utils.autocrop_coords(@pb.width, @pb.height, @width, @height)
186
+
187
+ @pb = Morandi::Utils.apply_crop(@pb, crop[0], crop[1], crop[2], crop[3])
188
+ end
189
+
190
+
191
+ def apply_filters!
192
+ filter = options['fx']
193
+
194
+ case filter
195
+ when 'greyscale'
196
+ op = Morandi::Colourify.new_from_hash('op' => filter)
197
+ when 'sepia', 'bluetone'
198
+ op = Morandi::Colourify.new_from_hash('op' => filter, 'alpha' => (0.85 * 255).to_i)
199
+ else
200
+ return
201
+ end
202
+ @pb = op.call(nil, @pb)
203
+ end
204
+
205
+ def apply_decorations!
206
+ style, colour = options['border-style'], options['background-style']
207
+
208
+ return if style.nil? or style.eql?('none')
209
+ return if colour.eql?('none')
210
+ colour ||= 'black'
211
+
212
+ crop = options['crop']
213
+ crop = crop.map { |s| (s.to_f * @scale).floor } if crop && @scale != 1.0
214
+
215
+ op = Morandi::ImageBorder.new_from_hash(data={
216
+ 'style' => style,
217
+ 'colour' => colour || '#000000',
218
+ 'crop' => crop,
219
+ 'size' => [@image_width, @image_height],
220
+ 'print_size' => [@width, @height],
221
+ 'shrink' => true,
222
+ 'border_size' => @scale * config_for('border-size-mm').to_i * 300 / 25.4 # 5mm at 300dpi
223
+ })
224
+
225
+ @pb = op.call(nil, @pb)
226
+ end
227
+
228
+ def write_to_png(fn, orientation=:any)
229
+ pb = @pb
230
+
231
+ case orientation
232
+ when :landscape
233
+ pb = @pb.rotate(90) if @pb.width < @pb.height
234
+ when :portrait
235
+ pb = @pb.rotate(90) if @pb.width > @pb.height
236
+ end
237
+ pb.save(fn, 'png')
238
+ end
239
+
240
+ def write_to_jpeg(fn, quality = 97)
241
+ @pb.save(fn, 'jpeg', :quality => quality)
242
+ end
243
+ end
244
+
245
+ module_function
246
+ def process(file_in, settings, out_file)
247
+ pro = ImageProcessor.new(file_in, settings['output.max'], settings)
248
+ pro.process!
249
+ pro.write_to_jpeg(out_file)
250
+ end
251
+
252
+ end
@@ -0,0 +1,332 @@
1
+ module Morandi
2
+ class ImageOp
3
+ class << self
4
+ def new_from_hash(hash)
5
+ op = allocate()
6
+ hash.each_pair do |key,val|
7
+ op.instance_variable_set("@#{key}", val) if op.respond_to?(key.intern)
8
+ end
9
+ op
10
+ end
11
+ end
12
+ def initialize()
13
+ end
14
+ def priority
15
+ 100
16
+ end
17
+ end
18
+ class Crop < ImageOp
19
+ attr_accessor :area
20
+ def initialize(area=nil)
21
+ super()
22
+ @area = area
23
+ end
24
+ def constrain(val,min,max)
25
+ if val < min
26
+ min
27
+ elsif val > max
28
+ max
29
+ else
30
+ val
31
+ end
32
+ end
33
+ def call(image, pixbuf)
34
+ if @area and (not @area.width.zero?) and (not @area.height.zero?)
35
+ # NB: Cheap - fast & shares memory
36
+ Gdk::Pixbuf.new(pixbuf, @area.x, @area.y,
37
+ @area.width, @area.height)
38
+ else
39
+ pixbuf
40
+ end
41
+ end
42
+ end
43
+ class Rotate < ImageOp
44
+ attr_accessor :angle
45
+ def initialize(angle=0)
46
+ super()
47
+ @angle = angle
48
+ end
49
+ def call(image, pixbuf)
50
+ if @angle.zero?
51
+ pixbuf
52
+ else
53
+ case @angle
54
+ when 0, 90, 180, 270
55
+ PixbufUtils::rotate(pixbuf, @angle)
56
+ else
57
+ raise "Not a valid angle"
58
+ end
59
+ end
60
+ end
61
+ end
62
+ class Straighten < ImageOp
63
+ attr_accessor :angle
64
+ def initialize(angle=0)
65
+ super()
66
+ @angle = angle
67
+ end
68
+ def call(image, pixbuf)
69
+ if @angle.zero?
70
+ pixbuf
71
+ else
72
+ surface = Cairo::ImageSurface.new(:rgb24, pixbuf.width, pixbuf.height)
73
+
74
+ rotationValueRad = @angle * (Math::PI/180)
75
+
76
+ ratio = pixbuf.width.to_f/pixbuf.height
77
+ rh = (pixbuf.height) / ((ratio * Math.sin(rotationValueRad.abs)) + Math.cos(rotationValueRad.abs))
78
+ scale = pixbuf.height / rh.to_f.abs
79
+
80
+ a_ratio = pixbuf.height.to_f/pixbuf.width
81
+ a_rh = (pixbuf.width) / ((a_ratio * Math.sin(rotationValueRad.abs)) + Math.cos(rotationValueRad.abs))
82
+ a_scale = pixbuf.width / a_rh.to_f.abs
83
+
84
+ scale = a_scale if a_scale > scale
85
+
86
+ cr = Cairo::Context.new(surface)
87
+ #p [@angle, rotationValueRad, rh, scale, pixbuf.height]
88
+
89
+ cr.translate(pixbuf.width / 2.0, pixbuf.height / 2.0)
90
+ cr.rotate(rotationValueRad)
91
+ cr.scale(scale, scale)
92
+ cr.translate(pixbuf.width / -2.0, pixbuf.height / - 2.0)
93
+ cr.set_source_pixbuf(pixbuf)
94
+
95
+ cr.rectangle(0, 0, pixbuf.width, pixbuf.height)
96
+ cr.paint(1.0)
97
+ final_pb = surface.to_pixbuf
98
+ cr.destroy
99
+ surface.destroy
100
+ return final_pb
101
+ end
102
+ end
103
+ end
104
+
105
+ class ImageCaption < ImageOp
106
+ attr_accessor :text, :font, :position
107
+ def initialize()
108
+ super()
109
+ end
110
+
111
+ def font
112
+ @font || "Open Sans Condensed Light #{ ([@pixbuf.width,@pixbuf.height].max/80.0).to_i }"
113
+ end
114
+
115
+ def position
116
+ @position ||
117
+ (@pixbuf ? ([ [@pixbuf.width,@pixbuf.height].max/20 ] * 2) : [100, 100])
118
+ end
119
+
120
+ def call(image, pixbuf)
121
+ @pixbuf = pixbuf
122
+ surface = Cairo::ImageSurface.new(:rgb24, pixbuf.width, pixbuf.height)
123
+ cr = Cairo::Context.new(surface)
124
+
125
+ cr.save do
126
+ cr.set_source_pixbuf(pixbuf)
127
+ #cr.rectangle(0, 0, pixbuf.width, pixbuf.height)
128
+ cr.paint(1.0)
129
+ cr.translate(*self.position)
130
+
131
+ layout = cr.create_pango_layout
132
+ layout.set_text(self.text)
133
+ fd = Pango::FontDescription.new(self.font)
134
+ layout.font_description = fd
135
+ layout.set_width((pixbuf.width - self.position[0] - 100)*Pango::SCALE)
136
+ layout.context_changed
137
+ ink, _ = layout.pixel_extents
138
+ cr.set_source_rgba(0, 0, 0, 0.3)
139
+ cr.rectangle(-25, -25, ink.width + 50, ink.height + 50)
140
+ cr.fill
141
+ cr.set_source_rgb(1, 1, 1)
142
+ cr.show_pango_layout(layout)
143
+ end
144
+
145
+
146
+ final_pb = surface.to_pixbuf
147
+ cr.destroy
148
+ surface.destroy
149
+ return final_pb
150
+ end
151
+ end
152
+
153
+ class ImageBorder < ImageOp
154
+ attr_accessor :style, :colour, :crop, :size, :print_size, :shrink, :border_size
155
+ def initialize(style='none', colour='white')
156
+ super()
157
+ @style = style
158
+ @colour = colour
159
+ end
160
+
161
+ def call(image, pixbuf)
162
+ return pixbuf unless %w[square retro].include? @style
163
+ surface = Cairo::ImageSurface.new(:rgb24, pixbuf.width, pixbuf.height)
164
+ cr = Cairo::Context.new(surface)
165
+
166
+ img_width = pixbuf.width
167
+ img_height = pixbuf.height
168
+
169
+ cr.save do
170
+ if @crop
171
+ if @crop[0] < 0 || @crop[1] < 0
172
+ img_width = size[0]
173
+ img_height = size[1]
174
+ cr.translate( - @crop[0], - @crop[1])
175
+ end
176
+ end
177
+
178
+ cr.save do
179
+ cr.set_operator :source
180
+ cr.set_source_rgb 1, 1, 1
181
+ cr.paint
182
+
183
+ cr.rectangle(0, 0, img_width, img_height)
184
+ case colour
185
+ when 'dominant'
186
+ pixbuf.scale_max(400).save(fn="/tmp/hist-#{$$}.#{Time.now.to_i}", 'jpeg')
187
+ hgram = Colorscore::Histogram.new(fn)
188
+ File.unlink(fn) rescue nil
189
+ col = hgram.scores.first[1]
190
+ cr.set_source_rgb col.red/256.0, col.green/256.0, col.blue/256.0
191
+ when 'retro'
192
+ cr.set_source_rgb 1, 1, 0.8
193
+ when 'black'
194
+ cr.set_source_rgb 0, 0, 0
195
+ else
196
+ cr.set_source_rgb 1, 1, 1
197
+ end
198
+ cr.fill
199
+ end
200
+ end
201
+
202
+ border_scale = [img_width,img_height].max.to_f / print_size.max.to_i
203
+ size = @border_size
204
+ size *= border_scale
205
+ x, y = size, size
206
+
207
+ # This biggest impact will be on the smallest side, so to avoid white
208
+ # edges between photo and border scale by the longest changed side.
209
+ longest_side = [pixbuf.width, pixbuf.height].max.to_f
210
+
211
+ # Should be less than 1
212
+ pb_scale = (longest_side - (size * 2)) / longest_side
213
+
214
+ if @crop
215
+ if @crop[0] < 0 || @crop[1] < 0
216
+ x -= @crop[0]
217
+ y -= @crop[1]
218
+ end
219
+ end
220
+
221
+ case style
222
+ when 'retro'
223
+ CairoUtils.rounded_rectangle(cr, x, y,
224
+ img_width + x - (size*2), img_height+y-(size*2), size)
225
+ when 'square'
226
+ cr.rectangle(x, y, img_width - (size*2), img_height - (size*2))
227
+ end
228
+ cr.clip()
229
+
230
+ if @shrink
231
+ cr.translate(size, size)
232
+ cr.scale(pb_scale, pb_scale)
233
+ end
234
+ cr.set_source_pixbuf(pixbuf)
235
+ cr.rectangle(0, 0, pixbuf.width, pixbuf.height)
236
+
237
+ cr.paint(1.0)
238
+ final_pb = surface.to_pixbuf
239
+ cr.destroy
240
+ surface.destroy
241
+ return final_pb
242
+ end
243
+ end
244
+
245
+ class Gamma < ImageOp
246
+ attr_reader :gamma
247
+ def initialize(gamma=1.0)
248
+ super()
249
+ @gamma = gamma
250
+ end
251
+ def call(image, pixbuf)
252
+ if @gamma == 1.0
253
+ pixbuf
254
+ else
255
+ PixbufUtils.gamma(pixbuf, @gamma)
256
+ end
257
+ end
258
+ def priority
259
+ 90
260
+ end
261
+ end
262
+
263
+ class RedEyeOp < ImageOp
264
+ attr_reader :x1, :y1, :x2, :y2, :sensitivity, :no_eyes
265
+ attr_accessor :order
266
+ def initialize(x1, y1, x2, y2, sensitivity, no_eyes)
267
+ super()
268
+ require 'redeye'
269
+ x1, x2 = x2, x1 if x1 > x2
270
+ y1, y2 = y2, y1 if y1 > y2
271
+ @x1, @y1, @x2, @y2, @sensitivity, @no_eyes =
272
+ x1, y1, x2, y2, sensitivity, no_eyes
273
+ end
274
+ def call(image, pixbuf)
275
+ @x2 = pixbuf.width if @x2 >= pixbuf.width
276
+ @y2 = pixbuf.height if @y2 >= pixbuf.height
277
+ @x1 = @x2 - 1 if @x1 >= @x2
278
+ @y1 = @y2 - 1 if @y1 >= @y2
279
+ redeye = ::RedEye.new(pixbuf.dup, @x1, @y1, @x2, @y2)
280
+ blobs = redeye.identify_blobs(@sensitivity).reject { |i|
281
+ i.noPixels < 2 or ! i.squareish?(0.5, 0.4)
282
+ }.sort_by { |i| i.noPixels }
283
+ biggest = blobs.size > @no_eyes ? blobs[-1 * @no_eyes..-1] : blobs
284
+ biggest.each { |blob| redeye.correct_blob(blob.id) }
285
+ redeye.pixbuf
286
+ end
287
+ def priority
288
+ 20 + @order
289
+ end
290
+ end
291
+
292
+ class Colourify < ImageOp
293
+ attr_reader :op
294
+ def initialize(op, alpha=255)
295
+ super()
296
+ @op = op
297
+ @alpha = alpha
298
+ end
299
+
300
+ def alpha
301
+ @alpha || 255
302
+ end
303
+
304
+ def sepia(pixbuf)
305
+ PixbufUtils.tint(pixbuf, 25, 5, -25, alpha)
306
+ end
307
+
308
+ def bluetone(pixbuf)
309
+ PixbufUtils.tint(pixbuf, -10, 5, 25, alpha)
310
+ end
311
+
312
+ def null(pixbuf)
313
+ pixbuf
314
+ end
315
+ alias :full :null # WebKiosk
316
+ alias :colour :null # WebKiosk
317
+
318
+ def greyscale(pixbuf)
319
+ PixbufUtils.tint(pixbuf, 0, 0, 0, alpha)
320
+ end
321
+ alias :bw :greyscale # WebKiosk
322
+
323
+ def call(image, pixbuf)
324
+ if @op and respond_to?(@op)
325
+ __send__(@op, pixbuf)
326
+ else
327
+ pixbuf # Default is nothing
328
+ end
329
+ end
330
+ end
331
+
332
+ end
@@ -0,0 +1,55 @@
1
+ module Morandi
2
+ module RedEye
3
+ module TapRedEye
4
+ module_function
5
+ def tap_on(pb, x, y)
6
+ n = ([pb.height,pb.width].max / 10)
7
+ x1 = [x - n, 0].max
8
+ x2 = [x + n, pb.width].min
9
+ y1 = [y - n, 0].max
10
+ y2 = [y + n, pb.height].min
11
+ return pb unless (x1 >= 0) && (x2 > x1) && (y1 >= 0) && (y2 > y1)
12
+ redeye = RedEye.new(pb, x1, y1, x2, y2)
13
+
14
+ sensitivity = 2
15
+ blobs = redeye.identify_blobs(sensitivity).reject { |i|
16
+ i.noPixels < 4 or ! i.squareish?(0.5, 0.4)
17
+ }.sort_by { |i|
18
+ i.area_min_x = x1
19
+ i.area_min_y = y1
20
+
21
+ # Higher is better
22
+ score = (i.noPixels) / (i.distance_from(x, y) ** 2)
23
+ }
24
+
25
+ #blobs.each do |blob|
26
+ # p [ [x, y], blob.centre(), blob.distance_from(x, y), blob]
27
+ #end
28
+
29
+ blob = blobs.last
30
+ redeye.correct_blob(blob.id) if blob
31
+ pb = redeye.pixbuf
32
+ end
33
+ end
34
+ end
35
+ end
36
+
37
+ class ::RedEye::Region
38
+ attr_accessor :area_min_x
39
+ attr_accessor :area_min_y
40
+ def centre
41
+ [@area_min_x.to_i + ((maxX + minX) >> 1),
42
+ @area_min_y.to_i + ((maxY + minY) >> 1)]
43
+ end
44
+
45
+ # Pythagorean
46
+ def distance_from(x,y)
47
+ cx,cy = centre()
48
+
49
+ dx = cx - x
50
+ dy = cy - y
51
+
52
+ Math.sqrt( (dx * dx) + (dy * dy) )
53
+ end
54
+ end
55
+
@@ -0,0 +1,106 @@
1
+ module Morandi
2
+ module Utils
3
+ module_function
4
+ def autocrop_coords(iw, ih, width, height)
5
+ return nil unless width
6
+ aspect = width.to_f / height.to_f
7
+ iaspect = iw.to_f / ih.to_f
8
+
9
+ if ih > iw
10
+ # Portrait image
11
+ # Check whether the aspect ratio is greater or smaller
12
+ # ie. where constraints will hit
13
+ aspect = height.to_f / width.to_f
14
+ end
15
+
16
+ # Landscape
17
+ if aspect > iaspect
18
+ # Width constraint - aspect-rect wider
19
+ crop_width = iw
20
+ crop_height = (crop_width / aspect).to_i
21
+ else
22
+ # Height constraint - aspect-rect wider
23
+ crop_height = ih
24
+ crop_width = (crop_height * aspect).to_i
25
+ end
26
+
27
+ [
28
+ ((iw - crop_width)>>1),
29
+ ((ih - crop_height)>>1),
30
+ crop_width,
31
+ crop_height
32
+ ].map { |i| i.to_i }
33
+ end
34
+
35
+ def constrain(val,min,max)
36
+ if val < min
37
+ min
38
+ elsif val > max
39
+ max
40
+ else
41
+ val
42
+ end
43
+ end
44
+
45
+ def apply_crop(pixbuf, x, y, w, h, fill_col = 0xffffffff)
46
+ if (x < 0) or (y < 0) || ((x+w) > pixbuf.width) || ((y+h) > pixbuf.height)
47
+ #tw, th = [w-x,w].max, [h-y,h].max
48
+ base_pixbuf = Gdk::Pixbuf.new(Gdk::Pixbuf::ColorSpace::RGB, false, 8, w, h)
49
+ base_pixbuf.fill!(fill_col)
50
+ dest_x = [x, 0].min
51
+ dest_y = [y, 0].min
52
+ #src_x = [x,0].max
53
+ #src_y = [y,0].max
54
+ dest_x = [-x,0].max
55
+ dest_y = [-y,0].max
56
+
57
+ #if x < 0
58
+ #else
59
+ #end
60
+ #if y < 0
61
+ # dest_h = [h-dest_y, pixbuf.height, base_pixbuf.height-dest_y].min
62
+ #else
63
+ # dest_h = [h,pixbuf.height].min
64
+ #end
65
+ # dest_w = [w-dest_x, pixbuf.width, base_pixbuf.width-dest_x].min
66
+
67
+ offset_x = [x,0].max
68
+ offset_y = [y,0].max
69
+ copy_w = [w, pixbuf.width - offset_x].min
70
+ copy_h = [h, pixbuf.height - offset_y].min
71
+
72
+ paste_x = [x, 0].min * -1
73
+ paste_y = [y, 0].min * -1
74
+
75
+ if copy_w + paste_x > base_pixbuf.width
76
+ copy_w = base_pixbuf.width - paste_x
77
+ end
78
+ if copy_h + paste_y > base_pixbuf.height
79
+ copy_h = base_pixbuf.height - paste_y
80
+ end
81
+
82
+ args = [pixbuf, paste_x, paste_y, copy_w, copy_h, paste_x - offset_x, paste_y - offset_y, 1, 1, Gdk::Pixbuf::INTERP_HYPER, 255]
83
+ #p args
84
+ base_pixbuf.composite!(*args)
85
+ pixbuf = base_pixbuf
86
+ else
87
+ x = constrain(x, 0, pixbuf.width)
88
+ y = constrain(y, 0, pixbuf.height)
89
+ w = constrain(w, 1, pixbuf.width - x)
90
+ h = constrain(h, 1, pixbuf.height - y)
91
+ #p [pixbuf, x, y, w, h]
92
+ pixbuf = Gdk::Pixbuf.new(pixbuf, x, y, w, h)
93
+ end
94
+ pixbuf
95
+ end
96
+ end
97
+ end
98
+
99
+ class Gdk::Pixbuf
100
+ def scale_max(max_size, interp = Gdk::Pixbuf::InterpType::BILINEAR, max_scale = 1.0)
101
+ mul = (max_size / [width,height].max.to_f)
102
+ mul = [max_scale = 1.0,mul].min
103
+ scale(width * mul, height * mul, interp)
104
+ end
105
+ end
106
+
@@ -0,0 +1,3 @@
1
+ module Morandi
2
+ VERSION = "0.9.0"
3
+ end
data/morandi.gemspec ADDED
@@ -0,0 +1,29 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'morandi/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "morandi"
8
+ spec.version = Morandi::VERSION
9
+ spec.authors = ["Geoff Youngs\n\n\n"]
10
+ spec.email = ["git@intersect-uk.co.uk"]
11
+ spec.summary = %q{Simple Image Edits}
12
+ spec.description = %q{Apply simple edits to images}
13
+ spec.homepage = ""
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files -z`.split("\x0")
17
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.add_dependency "gdk_pixbuf2"
22
+ spec.add_dependency "cairo"
23
+ spec.add_dependency "pixbufutils"
24
+ spec.add_dependency "redeye"
25
+
26
+ spec.add_development_dependency "bundler", "~> 1.5"
27
+ spec.add_development_dependency "rake"
28
+ spec.add_development_dependency "rspec"
29
+ end
data/sample/sample.jpg ADDED
Binary file
@@ -0,0 +1,64 @@
1
+ require 'morandi'
2
+
3
+ RSpec.describe Morandi, "#process_to_file" do
4
+ context "in command mode" do
5
+ it "should create ouptut" do
6
+ Morandi.process("sample/sample.jpg", {}, out="sample/out_plain.jpg")
7
+ expect(File.exist?(out))
8
+ end
9
+
10
+ it "should do rotation of images" do
11
+ original = Gdk::Pixbuf.get_file_info("sample/sample.jpg")
12
+ Morandi.process("sample/sample.jpg", {
13
+ 'angle' => 90
14
+ }, out="sample/out_rotate90.jpg")
15
+ expect(File.exist?(out))
16
+ _,w,h = Gdk::Pixbuf.get_file_info(out)
17
+ expect(original[1]).to eq(h)
18
+ expect(original[2]).to eq(w)
19
+ end
20
+
21
+ it "should do cropping of images" do
22
+ Morandi.process("sample/sample.jpg", {
23
+ 'crop' => [10,10,300,300]
24
+ }, out="sample/out_crop.jpg")
25
+ expect(File.exist?(out))
26
+ _,w,h = Gdk::Pixbuf.get_file_info(out)
27
+ expect(w).to eq(300)
28
+ expect(h).to eq(300)
29
+ end
30
+
31
+ it "should reduce the size of images" do
32
+ Morandi.process("sample/sample.jpg", {
33
+ 'output.max' => 200
34
+ }, out="sample/out_reduce.jpg")
35
+ expect(File.exist?(out))
36
+ _,w,h = Gdk::Pixbuf.get_file_info(out)
37
+ expect(w).to be <= 200
38
+ expect(h).to be <= 200
39
+ end
40
+
41
+ it "should reduce the size of images" do
42
+ Morandi.process("sample/sample.jpg", {
43
+ 'fx' => 'sepia'
44
+ }, out="sample/out_sepia.jpg")
45
+ expect(File.exist?(out))
46
+ _,w,h = Gdk::Pixbuf.get_file_info(out)
47
+ expect(_.name).to eq('jpeg')
48
+ end
49
+
50
+ it "should output at the specified size" do
51
+ Morandi.process("sample/sample.jpg", {
52
+ 'output.width' => 300,
53
+ 'output.height' => 200,
54
+ 'image.auto-crop' => true,
55
+ 'output.limit' => true
56
+ }, out="sample/out_at_size.jpg")
57
+ expect(File.exist?(out))
58
+ _,w,h = Gdk::Pixbuf.get_file_info(out)
59
+ expect(_.name).to eq('jpeg')
60
+ expect(h).to be <= 200
61
+ expect(w).to be <= 300
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,17 @@
1
+ # This file was generated by the `rspec --init` command. Conventionally, all
2
+ # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
3
+ # Require this file using `require "spec_helper"` to ensure that it is only
4
+ # loaded once.
5
+ #
6
+ # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
7
+ RSpec.configure do |config|
8
+ config.treat_symbols_as_metadata_keys_with_true_values = true
9
+ config.run_all_when_everything_filtered = true
10
+ config.filter_run :focus
11
+
12
+ # Run specs in random order to surface order dependencies. If you find an
13
+ # order dependency and want to debug it, you can fix the order by providing
14
+ # the seed, which is printed after each run.
15
+ # --seed 1234
16
+ config.order = 'random'
17
+ end
metadata ADDED
@@ -0,0 +1,162 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: morandi
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.9.0
5
+ platform: ruby
6
+ authors:
7
+ - |+
8
+ Geoff Youngs
9
+
10
+
11
+ autorequire:
12
+ bindir: bin
13
+ cert_chain: []
14
+ date: 2015-07-17 00:00:00.000000000 Z
15
+ dependencies:
16
+ - !ruby/object:Gem::Dependency
17
+ name: gdk_pixbuf2
18
+ requirement: !ruby/object:Gem::Requirement
19
+ requirements:
20
+ - - '>='
21
+ - !ruby/object:Gem::Version
22
+ version: '0'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - '>='
28
+ - !ruby/object:Gem::Version
29
+ version: '0'
30
+ - !ruby/object:Gem::Dependency
31
+ name: cairo
32
+ requirement: !ruby/object:Gem::Requirement
33
+ requirements:
34
+ - - '>='
35
+ - !ruby/object:Gem::Version
36
+ version: '0'
37
+ type: :runtime
38
+ prerelease: false
39
+ version_requirements: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - '>='
42
+ - !ruby/object:Gem::Version
43
+ version: '0'
44
+ - !ruby/object:Gem::Dependency
45
+ name: pixbufutils
46
+ requirement: !ruby/object:Gem::Requirement
47
+ requirements:
48
+ - - '>='
49
+ - !ruby/object:Gem::Version
50
+ version: '0'
51
+ type: :runtime
52
+ prerelease: false
53
+ version_requirements: !ruby/object:Gem::Requirement
54
+ requirements:
55
+ - - '>='
56
+ - !ruby/object:Gem::Version
57
+ version: '0'
58
+ - !ruby/object:Gem::Dependency
59
+ name: redeye
60
+ requirement: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - '>='
63
+ - !ruby/object:Gem::Version
64
+ version: '0'
65
+ type: :runtime
66
+ prerelease: false
67
+ version_requirements: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - '>='
70
+ - !ruby/object:Gem::Version
71
+ version: '0'
72
+ - !ruby/object:Gem::Dependency
73
+ name: bundler
74
+ requirement: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ~>
77
+ - !ruby/object:Gem::Version
78
+ version: '1.5'
79
+ type: :development
80
+ prerelease: false
81
+ version_requirements: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - ~>
84
+ - !ruby/object:Gem::Version
85
+ version: '1.5'
86
+ - !ruby/object:Gem::Dependency
87
+ name: rake
88
+ requirement: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - '>='
91
+ - !ruby/object:Gem::Version
92
+ version: '0'
93
+ type: :development
94
+ prerelease: false
95
+ version_requirements: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - '>='
98
+ - !ruby/object:Gem::Version
99
+ version: '0'
100
+ - !ruby/object:Gem::Dependency
101
+ name: rspec
102
+ requirement: !ruby/object:Gem::Requirement
103
+ requirements:
104
+ - - '>='
105
+ - !ruby/object:Gem::Version
106
+ version: '0'
107
+ type: :development
108
+ prerelease: false
109
+ version_requirements: !ruby/object:Gem::Requirement
110
+ requirements:
111
+ - - '>='
112
+ - !ruby/object:Gem::Version
113
+ version: '0'
114
+ description: Apply simple edits to images
115
+ email:
116
+ - git@intersect-uk.co.uk
117
+ executables: []
118
+ extensions: []
119
+ extra_rdoc_files: []
120
+ files:
121
+ - .gitignore
122
+ - .rspec
123
+ - Gemfile
124
+ - LICENSE.txt
125
+ - README.md
126
+ - Rakefile
127
+ - lib/morandi.rb
128
+ - lib/morandi/image-ops.rb
129
+ - lib/morandi/redeye.rb
130
+ - lib/morandi/utils.rb
131
+ - lib/morandi/version.rb
132
+ - morandi.gemspec
133
+ - sample/sample.jpg
134
+ - spec/morandi_spec.rb
135
+ - spec/spec_helper.rb
136
+ homepage: ''
137
+ licenses:
138
+ - MIT
139
+ metadata: {}
140
+ post_install_message:
141
+ rdoc_options: []
142
+ require_paths:
143
+ - lib
144
+ required_ruby_version: !ruby/object:Gem::Requirement
145
+ requirements:
146
+ - - '>='
147
+ - !ruby/object:Gem::Version
148
+ version: '0'
149
+ required_rubygems_version: !ruby/object:Gem::Requirement
150
+ requirements:
151
+ - - '>='
152
+ - !ruby/object:Gem::Version
153
+ version: '0'
154
+ requirements: []
155
+ rubyforge_project:
156
+ rubygems_version: 2.2.1
157
+ signing_key:
158
+ specification_version: 4
159
+ summary: Simple Image Edits
160
+ test_files:
161
+ - spec/morandi_spec.rb
162
+ - spec/spec_helper.rb