imogen 0.1.6 → 0.2.1

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 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