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 +5 -5
- data/lib/imogen.rb +8 -80
- data/lib/imogen/auto_crop.rb +4 -15
- data/lib/imogen/iiif.rb +4 -9
- data/lib/imogen/iiif/region.rb +49 -12
- data/lib/imogen/iiif/rotation.rb +23 -22
- data/lib/imogen/iiif/size.rb +1 -1
- data/lib/imogen/zoomable.rb +1 -2
- data/lib/imogencv.bundle +0 -0
- data/spec/fixtures/files/sample.jpg +0 -0
- data/spec/integration/imogen_autocrop_spec.rb +17 -0
- data/spec/integration/imogen_iiif_spec.rb +17 -0
- data/spec/spec_helper.rb +111 -0
- data/spec/unit/imogen_iiif_region_spec.rb +2 -0
- data/spec/unit/imogen_iiif_rotate_spec.rb +63 -25
- metadata +29 -14
- data/lib/imogen/auto_crop/box.rb +0 -92
- data/lib/imogen/auto_crop/edges.rb +0 -67
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
|
-
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 92c92a32465d8ae59f5e40a31c0ffd3cc2de271a3593db0dac7da1bd2954dd31
|
4
|
+
data.tar.gz: 044b86d16c8c028d36864e3aa550b6aa871d9075c5d4223b198ca1ac59b511f2
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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 '
|
3
|
-
require 'rbconfig'
|
4
|
-
require 'free-image'
|
2
|
+
require 'vips'
|
5
3
|
module Imogen
|
6
4
|
|
7
5
|
def self.from(src_path)
|
8
|
-
|
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.
|
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
|
-
|
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
|
data/lib/imogen/auto_crop.rb
CHANGED
@@ -1,20 +1,9 @@
|
|
1
1
|
module Imogen
|
2
2
|
module AutoCrop
|
3
|
-
|
4
|
-
|
5
|
-
|
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.
|
33
|
+
yield img.copy(interpretation: :b_w)
|
34
34
|
elsif q == :bitonal
|
35
|
-
img.
|
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
|
-
|
57
|
-
|
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
|
data/lib/imogen/iiif/region.rb
CHANGED
@@ -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
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
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
|
data/lib/imogen/iiif/rotation.rb
CHANGED
@@ -1,26 +1,27 @@
|
|
1
1
|
module Imogen
|
2
|
-
module Iiif
|
3
|
-
class Rotation < Transform
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
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
|
data/lib/imogen/iiif/size.rb
CHANGED
data/lib/imogen/zoomable.rb
CHANGED
data/lib/imogencv.bundle
ADDED
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
|
-
|
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
|
11
|
-
expect(subject.get(
|
12
|
-
expect(subject.get(
|
13
|
-
expect(subject.get("
|
14
|
-
expect(subject.get(
|
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
|
-
|
17
|
-
|
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
|
-
|
24
|
-
|
25
|
-
expect(subject.get("
|
26
|
-
expect(subject.get("
|
27
|
-
expect(subject.get("
|
28
|
-
expect(subject.get("
|
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
|
-
|
33
|
-
|
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
|
-
|
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
|
4
|
+
version: 0.2.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Ben Armintor
|
8
|
-
|
8
|
+
- Eric O'Hanlon
|
9
|
+
autorequire:
|
9
10
|
bindir: bin
|
10
11
|
cert_chain: []
|
11
|
-
date:
|
12
|
+
date: 2021-02-26 00:00:00.000000000 Z
|
12
13
|
dependencies:
|
13
14
|
- !ruby/object:Gem::Dependency
|
14
|
-
name: ruby-
|
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:
|
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
|
-
|
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
|
-
|
83
|
-
|
84
|
-
signing_key:
|
99
|
+
rubygems_version: 3.0.8
|
100
|
+
signing_key:
|
85
101
|
specification_version: 4
|
86
|
-
summary: derivative generation
|
102
|
+
summary: IIIF image derivative generation helpers for Vips
|
87
103
|
test_files: []
|
88
|
-
has_rdoc:
|
data/lib/imogen/auto_crop/box.rb
DELETED
@@ -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
|