diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a1c67f..ff003d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Vips colour filters - Vips crop - Vips rotate +- Vips straighten ### Removed - [BREAKING] dropped support for a broken 'dominant' border colour diff --git a/lib/morandi/operation/vips_straighten.rb b/lib/morandi/operation/vips_straighten.rb new file mode 100644 index 0000000..129a252 --- /dev/null +++ b/lib/morandi/operation/vips_straighten.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'vips' + +module Morandi + module Operation + # Straighten operation + # Does a small (ie. not 90,180,270 deg) rotation and zooms to avoid cropping + # @!visibility private + class VipsStraighten < ImageOperation + # Colour for filling background post-rotation. It can bleed into the edge pixels during resize. + # Setting it to gray minimises the average impact + ROTATION_BACKGROUND_FILL_COLOUR = 127 + ROTATION_BACKGROUND_FILL_ALPHA = 255 + + def self.rotation_background_fill_colour(channels_count:, alpha:) + return [ROTATION_BACKGROUND_FILL_COLOUR] * channels_count unless alpha # Eg [127, 127, 127] for RGB + + # Eg [127, 127, 127, 255] for RGBA + ([ROTATION_BACKGROUND_FILL_COLOUR] * (channels_count - 1)) + [ROTATION_BACKGROUND_FILL_ALPHA] + end + + attr_accessor :angle + + def call(img) + return img if angle.zero? + + original_width = img.width + original_height = img.height + + # It is possible to first rotate, then fetch width/height of resulting image to calculate scale, + # but that would make us lose precision which degrades cropping accuracy + rotation_value_rad = angle * (Math::PI / 180) + post_rotation_bounding_box_width = (img.height.to_f * Math.sin(rotation_value_rad).abs) + + (img.width.to_f * Math.cos(rotation_value_rad).abs) + post_rotation_bounding_box_height = (img.width.to_f * Math.sin(rotation_value_rad).abs) + + (img.height.to_f * Math.cos(rotation_value_rad).abs) + + # Calculate scaling required to fit the original width/height within rotated image without including background + scale = [post_rotation_bounding_box_width / original_width, + post_rotation_bounding_box_height / original_height].max + + background_fill_colour = self.class.rotation_background_fill_colour(channels_count: img.bands, + alpha: img.has_alpha?) + img = img.similarity(angle: angle, scale: scale, background: background_fill_colour) + + # Better precision than img.width/img.height due to fractions preservation + post_scale_bounding_box_width = post_rotation_bounding_box_width * scale + post_scale_bounding_box_height = post_rotation_bounding_box_height * scale + + width_diff = post_scale_bounding_box_width - original_width + height_diff = post_scale_bounding_box_height - original_height + + # Round to nearest integer to reduce risk of ROTATION_BACKGROUND_FILL_COLOUR being visible in the corner + crop_x = (width_diff / 2).round + crop_y = (height_diff / 2).round + + img.crop(crop_x, crop_y, original_width, original_height) + end + end + end +end diff --git a/lib/morandi/vips_image_processor.rb b/lib/morandi/vips_image_processor.rb index 6b09993..170b4ef 100644 --- a/lib/morandi/vips_image_processor.rb +++ b/lib/morandi/vips_image_processor.rb @@ -3,6 +3,7 @@ require 'vips' require 'morandi/srgb_conversion' +require 'morandi/operation/vips_straighten' module Morandi # An alternative to ImageProcessor which is based on libvips for concurrent and less memory-intensive processing @@ -97,6 +98,10 @@ def apply_rotate! else raise('"angle" option only accepts multiples of 90') end + unless @options['straighten'].to_f.zero? + @img = Morandi::Operation::VipsStraighten.new_from_hash(angle: @options['straighten'].to_f).call(@img) + end + @image_width = @img.width @image_height = @img.height end diff --git a/spec/fixtures/reference_images/vips/plasma-straighten-negative-20.jpg b/spec/fixtures/reference_images/vips/plasma-straighten-negative-20.jpg new file mode 100644 index 0000000..5ace882 Binary files /dev/null and b/spec/fixtures/reference_images/vips/plasma-straighten-negative-20.jpg differ diff --git a/spec/fixtures/reference_images/vips/plasma-straighten-on-vertical-image.jpg b/spec/fixtures/reference_images/vips/plasma-straighten-on-vertical-image.jpg new file mode 100644 index 0000000..a8069cc Binary files /dev/null and b/spec/fixtures/reference_images/vips/plasma-straighten-on-vertical-image.jpg differ diff --git a/spec/fixtures/reference_images/vips/plasma-straighten-positive-5.jpg b/spec/fixtures/reference_images/vips/plasma-straighten-positive-5.jpg new file mode 100644 index 0000000..d70d504 Binary files /dev/null and b/spec/fixtures/reference_images/vips/plasma-straighten-positive-5.jpg differ diff --git a/spec/morandi_spec.rb b/spec/morandi_spec.rb index a6fb63e..b6ef542 100644 --- a/spec/morandi_spec.rb +++ b/spec/morandi_spec.rb @@ -239,7 +239,7 @@ end end - describe 'when given a straighten option', vips_wip: processor_name == 'vips' do + describe 'when given a straighten option' do let(:options) { { 'straighten' => 5 } } it 'straightens images' do @@ -248,7 +248,7 @@ expect(File).to exist(file_out) expect(processed_image_type).to eq('jpeg') - expect(file_out).to match_reference_image('plasma-straighten-positive-5') + expect(file_out).to match_reference_image(reference_image_prefix, 'plasma-straighten-positive-5') end context 'with a negative straighten value' do @@ -260,7 +260,7 @@ expect(File).to exist(file_out) expect(processed_image_type).to eq('jpeg') - expect(file_out).to match_reference_image('plasma-straighten-negative-20') + expect(file_out).to match_reference_image(reference_image_prefix, 'plasma-straighten-negative-20') end end @@ -274,7 +274,7 @@ expect(File).to exist(file_out) expect(processed_image_type).to eq('jpeg') - expect(file_out).to match_reference_image('plasma-straighten-on-vertical-image') + expect(file_out).to match_reference_image(reference_image_prefix, 'plasma-straighten-on-vertical-image') end end end