imogen 0.1.6 → 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: 1910c699380fcfbe8d180baf1c7ef25bbf521eba
4
- data.tar.gz: 0ff96a7186b1265d68cfe02fded732efe37daa14
2
+ SHA256:
3
+ metadata.gz: 92c92a32465d8ae59f5e40a31c0ffd3cc2de271a3593db0dac7da1bd2954dd31
4
+ data.tar.gz: 044b86d16c8c028d36864e3aa550b6aa871d9075c5d4223b198ca1ac59b511f2
5
5
  SHA512:
6
- metadata.gz: 89d7cccae25fe76829cee9e1a73877fe8637963723ec6c4b48659956bfb61b655e383daefa23e1b2e930ee88e60af80adf52068d6d281e2e845ccf31d0dbcc10
7
- data.tar.gz: be16bfdbd8fe83c64c0a6dabfc759e1af3a986aed72428f50cf62bb8c622a06b69db1bcbd49857fea375228f616b5d7d6e8f019083ac515ac1dcda3f7f2284eb
6
+ metadata.gz: dc4ae61808bb12ad97f4f3a279aa81fd048b20626422358ccfc64ed1c5706d221a97195a0807dcd882c32dbfbd8ced6fba06bfe9e361ae61abfd6a1a38aed5a2
7
+ data.tar.gz: d416dd8390d144135bf5d1e4d9dc5445d0f137a6e64ce097aef8fe2e5f895214a18a4f5fd064f874ead2928db969b5dbc92fbe5a86ac20c244e199c6bca93757
data/lib/imogen.rb CHANGED
@@ -1,107 +1,35 @@
1
1
  # encoding: UTF-8
2
- require 'ffi'
3
- require 'rbconfig'
4
- require 'free-image'
2
+ require 'vips'
5
3
  module Imogen
6
4
 
7
5
  def self.from(src_path)
8
- FreeImage::Bitmap.open(src_path) do |img|
9
- yield img
10
- end
6
+ yield Vips::Image.matload(src_path)
11
7
  end
12
8
  module Scaled
13
9
  def self.convert(img, dest_path, scale=1500, format = :jpeg)
14
10
  w = img.width
15
11
  h = img.height
16
12
  dims = (w > h) ? [scale, scale*h/w] : [scale*w/h, scale]
17
- img.rescale(dims[0], dims[1]) do |scaled|
18
- scaled = (scaled.color_type == :rgb) ? scaled.convert_to_24bits : scaled.convert_to_8bits
19
- dst = FreeImage::File.new(dest_path)
20
- dst.save(scaled, format)
21
- scaled.free
22
- end
13
+ img.thumbnail_image(dims[0], height: dims[1]).write_to_file(dest_path)
23
14
  end
24
15
  end
25
16
  module Cropped
26
17
  def self.convert(img, dest_path, edges, scale=nil, format=:jpeg)
18
+ img.crop(*edges).write_to_file(dest_path)
27
19
  end
28
20
  end
29
21
  require 'imogen/auto_crop'
30
22
  require 'imogen/zoomable'
31
23
  require 'imogen/iiif'
32
24
 
33
- def self.search_paths
34
- @search_paths ||= begin
35
- if ENV['FREE_IMAGE_LIBRARY_PATH']
36
- [ ENV['FREE_IMAGE_LIBRARY_PATH'] ]
37
- elsif FFI::Platform::IS_WINDOWS
38
- ENV['PATH'].split(File::PATH_SEPARATOR)
39
- else
40
- [ '/usr/local/{lib64,lib32,lib}', '/opt/local/{lib64,lib32,lib}', '/usr/{lib64,lib32,lib}' ]
41
- end
42
- end
43
- end
44
-
45
- def self.find_lib(lib)
46
- files = search_paths.inject(Array.new) do |array, path|
47
- file_name = File.expand_path(File.join(path, "#{lib}.#{FFI::Platform::LIBSUFFIX}"))
48
- array << Dir.glob(file_name)
49
- array
50
- end
51
- files.flatten.compact.first
52
- end
53
-
54
- def self.free_image_library_paths
55
- @free_image_library_paths ||= begin
56
- libs = %w{libfreeimage libfreeimage.3 FreeImage}
57
-
58
- libs.map do |lib|
59
- find_lib(lib)
60
- end.compact
61
- end
62
- end
63
-
64
- extend ::FFI::Library
65
-
66
- if free_image_library_paths.any?
67
- ffi_lib(*free_image_library_paths)
68
- elsif FFI::Platform.windows?
69
- ffi_lib("FreeImaged")
70
- else
71
- ffi_lib("freeimage")
72
- end
73
-
74
- ffi_convention :stdcall if FFI::Platform.windows?
75
-
76
25
  def self.format_from(image_path)
77
- result = FreeImage.FreeImage_GetFileType(image_path, 0)
78
- FreeImage.check_last_error
79
-
80
- if result == :unknown
81
- # Try to guess the file format from the file extension
82
- result = FreeImage.FreeImage_GetFIFFromFilename(image_path)
83
- FreeImage.check_last_error
84
- end
85
- result
26
+ raise "format from path not implemented"
86
27
  end
87
28
 
88
29
  def self.image(src_path, flags=0)
89
-
90
- fif = format_from(src_path)
91
- if ((fif != :unknown) and FreeImage.FreeImage_FIFSupportsReading(fif))
92
- ptr = FreeImage.FreeImage_Load(fif, src_path, flags)
93
- FreeImage.check_last_error(ptr)
94
- return FreeImage::Bitmap.new(ptr, nil)
95
- end
96
- return nil
30
+ Vips::Image.new_from_file(src_path)
97
31
  end
98
32
  def self.with_image(src_path, flags = 0, &block)
99
-
100
- fif = format_from(src_path)
101
- if ((fif != :unknown) and FreeImage.FreeImage_FIFSupportsReading(fif))
102
- ptr = FreeImage.FreeImage_Load(fif, src_path, flags)
103
- FreeImage.check_last_error(ptr)
104
- FreeImage::Bitmap.new(ptr, nil, &block)
105
- end
33
+ block.yield(image(src_path, flags))
106
34
  end
107
- end
35
+ end
@@ -1,20 +1,9 @@
1
1
  module Imogen
2
2
  module AutoCrop
3
- autoload :Edges, 'imogen/auto_crop/edges'
4
- autoload :Box, 'imogen/auto_crop/box'
5
- def self.convert(img, dest_path, scale=768, format=:jpeg)
6
- frame = Edges.new(img)
7
- edges = frame.get(scale)
8
- img.copy(*edges) do |crop|
9
- crop.rescale(scale, scale) do |thumb|
10
- dst = FreeImage::File.new(dest_path)
11
- t24 = (crop.color_type == :rgb) ? thumb.convert_to_24bits : thumb.convert_to_8bits
12
- dst.save(t24, format)
13
- t24.free
14
- thumb.free
15
- end
3
+ def self.convert(img, dest_path, scale=768, opts = {})
4
+ Imogen::Iiif::Region::Featured.convert(img, scale, opts) do |smartcrop|
5
+ smartcrop.write_to_file(dest_path)
16
6
  end
17
- frame.unlink
18
7
  end
19
8
  end
20
- end
9
+ end
data/lib/imogen/iiif.rb CHANGED
@@ -30,9 +30,9 @@ module Imogen
30
30
  def self.convert(img, quality)
31
31
  q = get(quality)
32
32
  if q == :grey
33
- img.convert_to_greyscale {|c| yield c}
33
+ yield img.copy(interpretation: :b_w)
34
34
  elsif q == :bitonal
35
- img.threshold(128) {|c| yield c}
35
+ yield img.copy(interpretation: :b_w) > 128
36
36
  else
37
37
  yield img
38
38
  end
@@ -53,13 +53,8 @@ module Imogen
53
53
  Size.convert(region, opts[:size]) do |size|
54
54
  Rotation.convert(size, opts[:rotation]) do |rotation|
55
55
  Quality.convert(rotation, opts[:quality]) do |quality|
56
- dst = FreeImage::File.new(dest_path)
57
- format = :jpeg if format == :jpg
58
- if (img.color_type == :rgb)
59
- quality.convert_to_24bits {|result| dst.save(result, format, (format == :jp2 ? 8 : 0))}
60
- else
61
- quality.convert_to_8bits {|result| dst.save(result, format, (format == :jp2 ? 8 : 0))}
62
- end
56
+ quality.write_to_file(dest_path)
57
+ yield quality if block_given?
63
58
  end
64
59
  end
65
60
  end
@@ -1,5 +1,4 @@
1
1
  #!ruby
2
-
3
2
  module Imogen
4
3
  module Iiif
5
4
  class Region < Transform
@@ -36,9 +35,6 @@ class Region < Transform
36
35
  end
37
36
  if (e[0] > @width or e[1] > @height)
38
37
  raise BadRequest.new("Invalid region (disjoint): #{region}")
39
- end
40
- if (e[2]) * (e[3]) < 100
41
- raise BadRequest.new("Region too small: #{region}")
42
38
  end
43
39
  return e
44
40
  end
@@ -48,16 +44,57 @@ class Region < Transform
48
44
  yield img
49
45
  else
50
46
  if edges == :featured
51
- frame = Imogen::AutoCrop::Edges.new(img)
52
- begin
53
- edges = frame.get([img.width, img.height,768].min)
54
- ensure
55
- frame.unlink
56
- end
47
+ side = [img.width, img.height,768].min
48
+ Featured.convert(img, side) { |x| yield x }
49
+ else
50
+ # edges are leftX, topY, rightX, bottomY
51
+ # Vips wants left, top, width, height
52
+ yield img.extract_area(edges[0], edges[1], edges[2] - edges[0], edges[3] - edges[1])
57
53
  end
58
- img.copy(*edges) {|crop| yield crop}
59
54
  end
60
55
  end
56
+ class Featured < Transform
57
+ SQUARISH = 5.to_f / 6
58
+ ONE_THIRD = 1.to_f / 3
59
+ def self.convert(img, scale = 768, opts = {})
60
+ middle_dims = [(img.width * 2 * ONE_THIRD).floor, (img.height * 2 * ONE_THIRD).floor]
61
+ x_offset = (img.width * ONE_THIRD/2).floor
62
+ y_offset = (img.height * ONE_THIRD/2).floor
63
+ crop_scale = middle_dims.min
64
+ smart_crop_opts = {interesting: (squarish?(img) ? :centre : :entropy)}.merge(opts)
65
+ window = img.extract_area(x_offset, y_offset, middle_dims[0], middle_dims[1])
66
+ smartcrop = window.smartcrop(crop_scale, crop_scale, smart_crop_opts)
67
+ # Vips counts with negative offsets from left and top
68
+ yield smartcrop.thumbnail_image(scale, height: scale)
69
+ end
70
+
71
+ # returns leftX, topY, rightX, bottomY
72
+ def self.get(img, scale = 768, opts = {})
73
+ middle_dims = [(img.width * 2 * ONE_THIRD).floor, (img.height * 2 * ONE_THIRD).floor]
74
+ x_offset = (img.width * ONE_THIRD/2).floor
75
+ y_offset = (img.height * ONE_THIRD/2).floor
76
+ crop_scale = middle_dims.min
77
+ smart_crop_opts = {interesting: (squarish?(img) ? :centre : :entropy)}.merge(opts)
78
+ window = img.extract_area(x_offset, y_offset, middle_dims[0], middle_dims[1])
79
+ smartcrop = window.smartcrop(crop_scale, crop_scale, smart_crop_opts)
80
+ # Vips counts with negative offsets from left and top
81
+ left = (window.xoffset + smartcrop.xoffset)*-1
82
+ top = (window.yoffset + smartcrop.yoffset)*-1
83
+ right = left + smartcrop.width
84
+ bottom = top + smartcrop.height
85
+ return [left, top, right, bottom]
86
+ end
87
+
88
+ def self.squarish?(img)
89
+ if img.is_a? Vips::Image
90
+ dims = [img.width, img.height]
91
+ ratio = dims.min.to_f / dims.max
92
+ return ratio >= Featured::SQUARISH
93
+ else
94
+ raise "#{img.class.name} is not a Vips::Image"
95
+ end
96
+ end
97
+ end
98
+ end
61
99
  end
62
100
  end
63
- end
@@ -1,26 +1,27 @@
1
1
  module Imogen
2
- module Iiif
3
- class Rotation < Transform
4
- RIGHT_ANGLES = [0,90,180,270]
5
- def get(rotate)
6
- if rotate.nil? or rotate.eql? '0'
7
- return nil
2
+ module Iiif
3
+ class Rotation < Transform
4
+ RIGHT_ANGLES = [0,90,180,270]
5
+ def get(rotate)
6
+ return [0, false] if rotate.nil?
7
+ original_rotate_value = rotate
8
+ rotate = rotate.to_s
9
+ raise BadRequest.new("bad rotate #{original_rotate_value}") unless rotate =~ /^!?-?\d+$/
10
+ flip = rotate.to_s.start_with?('!')
11
+ # libvips and IIIF spec counts clockwise
12
+ angle = rotate.sub(/^!/, '').to_i % 360
13
+ raise BadRequest.new("bad rotate #{original_rotate_value}") unless RIGHT_ANGLES.include?(angle)
14
+ return angle, flip
15
+ end
16
+
17
+ def self.convert(img, rotate)
18
+ angle, flip = Rotation.new(img).get(rotate)
19
+ # IIIF spec applies horizontal flip ("mirrored by reflection on the vertical axis") before rotation
20
+ img = img.fliphor if flip
21
+ # No need to rotate if angle is zero
22
+ img = img.rot("d#{angle}") unless angle.zero?
23
+ yield img
24
+ end
8
25
  end
9
- raise BadRequest.new("bad rotate #{rotate}") unless rotate =~ /^-?\d+$/
10
- # negate offset because IIIF spec counts clockwise, FreeImage counterclockwise
11
- r = (rotate.to_i * -1) % 360
12
- r = r + 360 if r < 0
13
- raise BadRequest.new("bad rotate #{rotate}") unless RIGHT_ANGLES.include? r
14
- return r > 0 ? r : nil
15
26
  end
16
- def self.convert(img, rotate)
17
- rotation = Rotation.new(img).get(rotate)
18
- if rotation
19
- img.rotate(rotation) {|crop| yield crop}
20
- else
21
- yield img
22
- end
23
- end
24
- end
25
27
  end
26
- end
@@ -35,7 +35,7 @@ class Size < Transform
35
35
  def self.convert(img, size)
36
36
  dims = Size.new(img).get(size)
37
37
  if dims
38
- img.rescale(*dims) {|crop| yield crop}
38
+ yield img.thumbnail_image(dims[0], height: dims[1])
39
39
  else
40
40
  yield img
41
41
  end
@@ -12,8 +12,7 @@ module Imogen
12
12
  return Math.log2(dims[0..1].max.to_f / tile_size).ceil
13
13
  end
14
14
  def self.convert(img, dest_path)
15
- dst = FreeImage::File.new(dest_path)
16
- dst.save(img, :jp2, 8)
15
+ raise "jp2 output conversion not implemented"
17
16
  end
18
17
  end
19
18
  end
Binary file
Binary file
@@ -0,0 +1,17 @@
1
+ require 'imogen'
2
+ require 'tmpdir'
3
+
4
+ describe Imogen::AutoCrop, vips: true do
5
+ describe "#convert" do
6
+ let(:output_file) { Dir.tmpdir + '/test-imogen-crop.jpg' }
7
+ it "should successfully convert the image" do
8
+ Imogen.with_image(fixture('sample.jpg').path) do |img|
9
+ Imogen::AutoCrop.convert(img, output_file, 150)
10
+ end
11
+ expect(File.exist?(output_file)).to be true
12
+ expect(File.size?(output_file)).to be > 0
13
+ ensure
14
+ File.delete(output_file) if File.exist?(output_file)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ require 'imogen'
2
+ require 'tmpdir'
3
+
4
+ describe Imogen::AutoCrop, vips: true do
5
+ describe "#convert" do
6
+ let(:output_file) { Dir.tmpdir + '/test-imogen-convert.jpg' }
7
+ it "should successfully convert the image" do
8
+ Imogen.with_image(fixture('sample.jpg').path) do |img|
9
+ Imogen::Iiif.convert(img, output_file, 'jpg', region: '50,60,500,800', size: '!100,100', quality: 'color', rotation: '!90')
10
+ end
11
+ expect(File.exist?(output_file)).to be true
12
+ expect(File.size?(output_file)).to be > 0
13
+ ensure
14
+ File.delete(output_file) if File.exist?(output_file)
15
+ end
16
+ end
17
+ end
data/spec/spec_helper.rb CHANGED
@@ -1,3 +1,104 @@
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
+ # The generated `.rspec` file contains `--require spec_helper` which will cause
4
+ # this file to always be loaded, without a need to explicitly require it in any
5
+ # files.
6
+ #
7
+ # Given that it is always loaded, you are encouraged to keep this file as
8
+ # light-weight as possible. Requiring heavyweight dependencies from this file
9
+ # will add to the boot time of your test suite on EVERY test run, even for an
10
+ # individual file that may not need all of that loaded. Instead, consider making
11
+ # a separate helper file that requires the additional dependencies and performs
12
+ # the additional setup, and require it from the spec files that actually need
13
+ # it.
14
+ #
15
+ # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
16
+ RSpec.configure do |config|
17
+ # rspec-expectations config goes here. You can use an alternate
18
+ # assertion/expectation library such as wrong or the stdlib/minitest
19
+ # assertions if you prefer.
20
+ config.expect_with :rspec do |expectations|
21
+ # This option will default to `true` in RSpec 4. It makes the `description`
22
+ # and `failure_message` of custom matchers include text for helper methods
23
+ # defined using `chain`, e.g.:
24
+ # be_bigger_than(2).and_smaller_than(4).description
25
+ # # => "be bigger than 2 and smaller than 4"
26
+ # ...rather than:
27
+ # # => "be bigger than 2"
28
+ expectations.include_chain_clauses_in_custom_matcher_descriptions = true
29
+ end
30
+
31
+ # rspec-mocks config goes here. You can use an alternate test double
32
+ # library (such as bogus or mocha) by changing the `mock_with` option here.
33
+ config.mock_with :rspec do |mocks|
34
+ # Prevents you from mocking or stubbing a method that does not exist on
35
+ # a real object. This is generally recommended, and will default to
36
+ # `true` in RSpec 4.
37
+ mocks.verify_partial_doubles = true
38
+ end
39
+
40
+ # This option will default to `:apply_to_host_groups` in RSpec 4 (and will
41
+ # have no way to turn it off -- the option exists only for backwards
42
+ # compatibility in RSpec 3). It causes shared context metadata to be
43
+ # inherited by the metadata hash of host groups and examples, rather than
44
+ # triggering implicit auto-inclusion in groups with matching metadata.
45
+ config.shared_context_metadata_behavior = :apply_to_host_groups
46
+
47
+ # The settings below are suggested to provide a good initial experience
48
+ # with RSpec, but feel free to customize to your heart's content.
49
+ =begin
50
+ # Allows RSpec to persist some state between runs in order to support
51
+ # the `--only-failures` and `--next-failure` CLI options. We recommend
52
+ # you configure your source control system to ignore this file.
53
+ config.example_status_persistence_file_path = "spec/examples.txt"
54
+
55
+ # Limits the available syntax to the non-monkey patched syntax that is
56
+ # recommended. For more details, see:
57
+ # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/
58
+ # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/
59
+ # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode
60
+ config.disable_monkey_patching!
61
+
62
+ # This setting enables warnings. It's recommended, but in some cases may
63
+ # be too noisy due to issues in dependencies.
64
+ config.warnings = true
65
+
66
+ # Many RSpec users commonly either run the entire suite or an individual
67
+ # file, and it's useful to allow more verbose output when running an
68
+ # individual spec file.
69
+ if config.files_to_run.one?
70
+ # Use the documentation formatter for detailed output,
71
+ # unless a formatter has already been configured
72
+ # (e.g. via a command-line flag).
73
+ config.default_formatter = "doc"
74
+ end
75
+
76
+ # Print the 10 slowest examples and example groups at the
77
+ # end of the spec run, to help surface which specs are running
78
+ # particularly slow.
79
+ config.profile_examples = 10
80
+
81
+ # Run specs in random order to surface order dependencies. If you find an
82
+ # order dependency and want to debug it, you can fix the order by providing
83
+ # the seed, which is printed after each run.
84
+ # --seed 1234
85
+ config.order = :random
86
+
87
+ # Seed global randomization in this process using the `--seed` CLI option.
88
+ # Setting this allows you to use `--seed` to deterministically reproduce
89
+ # test failures related to randomization by passing the same `--seed` value
90
+ # as the one that triggered the failure.
91
+ Kernel.srand config.seed
92
+ =end
93
+
94
+ # This allows you to limit a spec run to individual examples or groups
95
+ # you care about by tagging them with `:focus` metadata. When nothing
96
+ # is tagged with `:focus`, all examples get run. RSpec also provides
97
+ # aliases for `it`, `describe`, and `context` that include `:focus`
98
+ # metadata: `fit`, `fdescribe` and `fcontext`, respectively.
99
+ config.filter_run_when_matching :focus
100
+ end
101
+
1
102
  class ImageStub
2
103
  attr_reader :width, :height
3
104
  def initialize(width,height)
@@ -5,3 +106,13 @@ class ImageStub
5
106
  @height = height
6
107
  end
7
108
  end
109
+
110
+ def absolute_fixture_path(file)
111
+ File.realpath(File.join(File.dirname(__FILE__), '..','spec','fixtures', 'files', file))
112
+ end
113
+
114
+ def fixture(file)
115
+ path = absolute_fixture_path(file)
116
+ raise "No fixture file at #{path}" unless File.exist? path
117
+ File.new(path)
118
+ end
@@ -18,6 +18,8 @@ describe Imogen::Iiif::Region, type: :unit do
18
18
  describe "with an absolute region" do
19
19
  it "should calculate a contained region" do
20
20
  expect(subject.get("80,15,60,75")).to eql([80,15,140,90])
21
+ # small regions are the application's concern
22
+ expect(subject.get("1,2,3,4")).to eql([1,2,4,6])
21
23
  end
22
24
  it "should reject zero-dimension boxes" do
23
25
  expect{subject.get("101,15,0,15")}.to raise_error Imogen::Iiif::BadRequest
@@ -1,39 +1,77 @@
1
1
  require 'imogen/iiif'
2
2
  require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
3
3
  describe Imogen::Iiif::Rotation, type: :unit do
4
- before(:all) do
5
- @test_image = ImageStub.new(175,131)
6
- end
7
- subject {Imogen::Iiif::Rotation.new(@test_image)}
4
+ let(:image) { double("image", width: 175, height: 131) }
8
5
  describe "#get" do
6
+ subject { Imogen::Iiif::Rotation.new(image) }
9
7
  describe "with values mod 360 in 90 degree rotations" do
10
- it "should nil for 0 or nil" do
11
- expect(subject.get("360")).to be_nil
12
- expect(subject.get("-360")).to be_nil
13
- expect(subject.get("0")).to be_nil
14
- expect(subject.get(nil)).to be_nil
8
+ it "should return [0, false] angle and flip for 0 or '0' or nil" do
9
+ expect(subject.get(0)).to eq([0, false])
10
+ expect(subject.get(360)).to eq([0, false])
11
+ expect(subject.get("360")).to eq([0, false])
12
+ expect(subject.get("-360")).to eq([0, false])
13
+ expect(subject.get("0")).to eq([0, false])
14
+ expect(subject.get(nil)).to eq([0, false])
15
+ end
16
+ it "should return the expected angle and flip for positive values" do
17
+ expect(subject.get(90)).to eql([90, false])
18
+ expect(subject.get("90")).to eql([90, false])
19
+ expect(subject.get("180")).to eql([180, false])
20
+ expect(subject.get("270")).to eql([270, false])
21
+ expect(subject.get("450")).to eql([90, false])
15
22
  end
16
- # IIIF rotation is opposite FreeImage
17
- it "should calculate for positive values" do
18
- expect(subject.get("90")).to eql(270)
19
- expect(subject.get("180")).to eql(180)
20
- expect(subject.get("270")).to eql(90)
21
- expect(subject.get("450")).to eql(270)
23
+ it "should return the expected angle and flip for negative values" do
24
+ expect(subject.get(-90)).to eql([270, false])
25
+ expect(subject.get("-90")).to eql([270, false])
26
+ expect(subject.get("-180")).to eql([180, false])
27
+ expect(subject.get("-270")).to eql([90, false])
28
+ expect(subject.get("-450")).to eql([270, false])
22
29
  end
23
- # IIIF rotation is opposite FreeImage
24
- it "should calculate for negative values" do
25
- expect(subject.get("-90")).to eql(90)
26
- expect(subject.get("-180")).to eql(180)
27
- expect(subject.get("-270")).to eql(270)
28
- expect(subject.get("-450")).to eql(90)
30
+ it "should return the expected angle and flip for string values that start with an exclamation point" do
31
+ expect(subject.get("!0")).to eql([0, true])
32
+ expect(subject.get("!90")).to eql([90, true])
33
+ expect(subject.get("!180")).to eql([180, true])
34
+ expect(subject.get("!270")).to eql([270, true])
35
+ expect(subject.get("!-90")).to eql([270, true])
36
+ expect(subject.get("!-180")).to eql([180, true])
37
+ expect(subject.get("!-270")).to eql([90, true])
38
+ expect(subject.get("!-450")).to eql([270, true])
29
39
  end
30
40
  end
31
41
  it "should reject arbitrary integer and float values" do
32
- expect{subject.get("2")}.to raise_error Imogen::Iiif::BadRequest
33
- expect{subject.get("90.0")}.to raise_error Imogen::Iiif::BadRequest
42
+ expect{subject.get(2)}.to raise_error Imogen::Iiif::BadRequest
43
+ expect{subject.get("2")}.to raise_error Imogen::Iiif::BadRequest
44
+ expect{subject.get("90.0")}.to raise_error Imogen::Iiif::BadRequest
34
45
  end
35
46
  it "should reject bad values" do
36
- expect{subject.get("-2,")}.to raise_error Imogen::Iiif::BadRequest
47
+ expect{subject.get("-2,")}.to raise_error Imogen::Iiif::BadRequest
48
+ end
49
+ end
50
+ describe '.convert' do
51
+ let(:no_op) { Proc.new {|x| x} }
52
+ context 'at multiple of 360' do
53
+ it "does not rotate" do
54
+ expect(image).not_to receive(:rot)
55
+ expect(image).not_to receive(:fliphor)
56
+ (-2..2).each { |x| described_class.convert(image, (x*360).to_s, &no_op) }
57
+ end
58
+ end
59
+ context 'at right angle rotations not multiple of 360' do
60
+ it "does rotate" do
61
+ expect(image).not_to receive(:fliphor)
62
+ [-3, -2, -1, 1, 2, 3].each do |x|
63
+ tuple = Imogen::Iiif::Rotation.new(image).get((90*x).to_s)
64
+ param = "d#{tuple[0]}"
65
+ expect(image).to receive(:rot).with(param)
66
+ described_class.convert(image, (x*90).to_s, &no_op)
67
+ end
68
+ end
69
+ end
70
+ context 'with a bang param' do
71
+ it "flips horizontal" do
72
+ expect(image).to receive(:fliphor)
73
+ described_class.convert(image, "!0", &no_op)
74
+ end
37
75
  end
38
76
  end
39
- end
77
+ end
metadata CHANGED
@@ -1,17 +1,18 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: imogen
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.6
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ben Armintor
8
- autorequire:
8
+ - Eric O'Hanlon
9
+ autorequire:
9
10
  bindir: bin
10
11
  cert_chain: []
11
- date: 2015-03-09 00:00:00.000000000 Z
12
+ date: 2021-02-26 00:00:00.000000000 Z
12
13
  dependencies:
13
14
  - !ruby/object:Gem::Dependency
14
- name: ruby-opencv
15
+ name: ruby-vips
15
16
  requirement: !ruby/object:Gem::Requirement
16
17
  requirements:
17
18
  - - ">="
@@ -25,7 +26,7 @@ dependencies:
25
26
  - !ruby/object:Gem::Version
26
27
  version: '0'
27
28
  - !ruby/object:Gem::Dependency
28
- name: rspec
29
+ name: rake
29
30
  requirement: !ruby/object:Gem::Requirement
30
31
  requirements:
31
32
  - - ">="
@@ -38,7 +39,21 @@ dependencies:
38
39
  - - ">="
39
40
  - !ruby/object:Gem::Version
40
41
  version: '0'
41
- description:
42
+ - !ruby/object:Gem::Dependency
43
+ name: rspec
44
+ requirement: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - "~>"
47
+ - !ruby/object:Gem::Version
48
+ version: '3.9'
49
+ type: :development
50
+ prerelease: false
51
+ version_requirements: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - "~>"
54
+ - !ruby/object:Gem::Version
55
+ version: '3.9'
56
+ description:
42
57
  email: armintor@gmail.com
43
58
  executables: []
44
59
  extensions: []
@@ -46,8 +61,6 @@ extra_rdoc_files: []
46
61
  files:
47
62
  - lib/imogen.rb
48
63
  - lib/imogen/auto_crop.rb
49
- - lib/imogen/auto_crop/box.rb
50
- - lib/imogen/auto_crop/edges.rb
51
64
  - lib/imogen/dzi.rb
52
65
  - lib/imogen/iiif.rb
53
66
  - lib/imogen/iiif/region.rb
@@ -55,6 +68,10 @@ files:
55
68
  - lib/imogen/iiif/size.rb
56
69
  - lib/imogen/iiif/tiles.rb
57
70
  - lib/imogen/zoomable.rb
71
+ - lib/imogencv.bundle
72
+ - spec/fixtures/files/sample.jpg
73
+ - spec/integration/imogen_autocrop_spec.rb
74
+ - spec/integration/imogen_iiif_spec.rb
58
75
  - spec/spec_helper.rb
59
76
  - spec/unit/imogen_iiif_quality_spec.rb
60
77
  - spec/unit/imogen_iiif_region_spec.rb
@@ -64,7 +81,7 @@ files:
64
81
  homepage: https://github.com/cul/imogen
65
82
  licenses: []
66
83
  metadata: {}
67
- post_install_message:
84
+ post_install_message:
68
85
  rdoc_options: []
69
86
  require_paths:
70
87
  - lib
@@ -79,10 +96,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
79
96
  - !ruby/object:Gem::Version
80
97
  version: '0'
81
98
  requirements: []
82
- rubyforge_project:
83
- rubygems_version: 2.4.3
84
- signing_key:
99
+ rubygems_version: 3.0.8
100
+ signing_key:
85
101
  specification_version: 4
86
- summary: derivative generation via FreeImage and smart square thumbnail via OpenCV
102
+ summary: IIIF image derivative generation helpers for Vips
87
103
  test_files: []
88
- has_rdoc:
@@ -1,92 +0,0 @@
1
- #!ruby
2
- require 'opencv'
3
- module Imogen::AutoCrop::Box
4
- include OpenCV
5
- class Best
6
- def initialize(grayscale)
7
- @corners = grayscale.good_features_to_track(0.3, 1.0, block_size: 3, max: 20)
8
- if @corners.nil? or @corners.length == 0
9
- @center = Center.new(grayscale)
10
- else
11
- @center = nil
12
- end
13
- end
14
-
15
- def self.distance(p1, p2)
16
- dx = p1.x.to_i - p2.x.to_i
17
- dy = p1.y.to_i - p2.y.to_i
18
- return Math.sqrt((dx * dx) + (dy * dy))
19
- end
20
-
21
- def box()
22
- return @center.box unless @center.nil?
23
- c = median()
24
- cp = BoxInfo.new(c[0], c[1],0)
25
- total_distance = 0;
26
- features = @corners.collect {|corner| d = Best.distance(corner, cp); total_distance += d; {x: corner.x, y: corner.y, d: d}}
27
- mean_distance = total_distance/features.length
28
- sigma = features.inject(0) {|memo, feature| v = feature[:d] - mean_distance; memo += (v*v)}
29
- sigma = Math.sqrt(sigma.to_f/features.length)
30
- # 2 sigmas would capture > 95% of normally distributed features
31
- cp.radius = 2*sigma
32
- cp
33
- end
34
-
35
- def median()
36
- @median ||= begin
37
- xs = []
38
- ys = []
39
- @corners.each {|c| xs << c.x.to_i; ys << c.y.to_i}
40
- xs.sort!
41
- ys.sort!
42
- ix = 0
43
- if (@corners.length % 2 == 0)
44
- l = (@corners.length == 2) ? 0 : (@corners.length/2)
45
- x = ((xs[l] + xs[l+1]) /2).floor
46
- y = ((ys[l] + ys[l+1]) /2).floor
47
- [x,y]
48
- else
49
- r = (@corners.length/2).ceil
50
- [xs[r], ys[r]]
51
- end
52
- end
53
- end
54
- end
55
- class Center
56
- def initialize(grayscale)
57
- @center ||= [(grayscale.cols/2).floor, (grayscale.rows/2).floor]
58
- @radius = @center.min
59
- @ratio = @radius / @center.max
60
- end
61
- def box
62
- return BoxInfo.new(@center[0],@center[1],@radius)
63
- end
64
- end
65
- class BoxInfo
66
- attr_reader :x, :y
67
- attr_accessor :radius
68
- SQUARISH = 5.to_f / 6
69
- def initialize(x,y,r)
70
- @x = x
71
- @y = y
72
- @radius = r
73
- end
74
- end
75
- def self.squarish?(img)
76
- if img.is_a? FreeImage::Bitmap
77
- dims = [img.width, img.height]
78
- ratio = dims.min.to_f / dims.max
79
- return ratio >= BoxInfo::SQUARISH
80
- elsif img.is_a? OpenCV::CvMat
81
- dims = [img.cols, img.rows]
82
- ratio = dims.min.to_f / dims.max
83
- return ratio >= BoxInfo::SQUARISH
84
- else
85
- raise "#{img.class.name} is not a FreeImage::Bitmap"
86
- end
87
- end
88
- def self.info(grayscale)
89
- dims = [grayscale.cols, grayscale.rows]
90
- squarish?(grayscale) ? Center.new(grayscale).box() : Best.new(grayscale).box()
91
- end
92
- end
@@ -1,67 +0,0 @@
1
- #!ruby
2
- require 'opencv'
3
- require 'free-image'
4
- require 'tempfile'
5
-
6
- module Imogen::AutoCrop
7
- class Edges
8
- include OpenCV
9
- def initialize(src)
10
- @xoffset = 0
11
- @yoffset = 0
12
- if src.is_a? FreeImage::Bitmap
13
- img = src
14
- @xoffset = img.width.to_f/6
15
- @yoffset = img.height.to_f/6
16
- if Imogen::AutoCrop::Box.squarish? img
17
- @xoffset = @xoffset/2
18
- @yoffset = @yoffset/2
19
- end
20
- @tempfile = Tempfile.new(['crop','.png'])
21
-
22
- img.copy(@xoffset,@yoffset,img.width-@xoffset,img.height-@yoffset) do |crop|
23
- crop.save(@tempfile.path, :png)
24
- crop.free
25
- end
26
- else
27
- raise src.class.name
28
- end
29
- # use bigger features on bigger images
30
- @grayscale = CvMat.load(@tempfile.path, CV_LOAD_IMAGE_GRAYSCALE)
31
- @xrange = (0..@grayscale.cols)
32
- @yrange = (0..@grayscale.rows)
33
- end
34
-
35
- def bound_min(center)
36
- [center.x - @xrange.min, @xrange.max - center.x, center.y - @yrange.min, @yrange.max - center.y].min
37
- end
38
-
39
- # returns leftX, topY, rightX, bottomY
40
- def get(*args)
41
- c = Imogen::AutoCrop::Box.info(@grayscale)
42
- r = c.radius.floor
43
- # adjust the box
44
- coords = [c.x, c.y]
45
- min_rad = args.max/2
46
- unless r >= min_rad && r <= bound_min(c)
47
- # first adjust to the lesser of max (half short dimension) and min (half requested length) radius
48
- # this might require upscaling in rare situations to preserve bound safety
49
- r = min_rad if r < min_rad
50
- max_rad = [@xrange.max - @xrange.min, @yrange.max - @yrange.min].min / 2
51
- r = max_rad if r > max_rad
52
- # now move the center point minimally to accomodate the necessary radius
53
- coords[0] = @xrange.max - r if (coords[0] + r) > @xrange.max
54
- coords[0] = @xrange.min + r if (coords[0] - r) < @xrange.min
55
- coords[1] = @yrange.max - r if (coords[1] + r) > @yrange.max
56
- coords[1] = @yrange.min + r if (coords[1] - r) < @yrange.min
57
- end
58
- coords = [coords[0] + @xoffset, coords[1] + @yoffset].collect {|i| i.floor}
59
- c = coords
60
-
61
- return [c[0]-r, c[1]-r, c[0]+r, c[1] + r]
62
- end
63
- def unlink
64
- @tempfile.unlink
65
- end
66
- end
67
- end