morandi 0.99.4 → 0.100.0

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
2
  SHA256:
3
- metadata.gz: 72cf19456503d11e3729ece721a4f8cf542ec674d5c35497d53efc8d333ebbd4
4
- data.tar.gz: 3d05af03a553609b7bb4901071b675273127a329b323fb45bd1d7c071fe8aed5
3
+ metadata.gz: 0ca1f4a637e59ee90de4f309dfecc40eae27b103f416ea301af36e8bd04c555f
4
+ data.tar.gz: 8ebeda3024e6275ea7e8b75c8357339523a59a24987e1ebd07142cba445edce7
5
5
  SHA512:
6
- metadata.gz: fa60e3b5ccefa7a76fa8e4f70d28f3ef1ad80e81a8f1d07c35a258e6748e9a4ce4e5923f82be682456a74c5ca8acc185030a783f925246bf04fad5776d20aed1
7
- data.tar.gz: 9bfff0edf6d1d52d2be6f836138b60d35c22a788fc337c46c7837a9b719f91ca6347ff746bd79f757737d6f6abba5615dd9f480e79ed07a8e9d67085cf6e3ad2
6
+ metadata.gz: 55308d1a2ae4626a81bf1faa9a6ae0535f816d777e31d25266e36e563bdc1dc60c55ef588ade477ce113a13bd20f80aefd046a52c1e4aaccffdc103e48d75cac
7
+ data.tar.gz: c10ab8cbe21121d50a2c0a10cdb6c0fefe595962d11cf936adeaef78e8291bd4688b37bb464577c7f455d8aa02e04def65d46930a86fb3cbf9c1aed4e9abf972
data/CHANGELOG.md CHANGED
@@ -4,7 +4,22 @@ All notable changes to this project will be documented in this file.
4
4
  The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
5
5
  and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
6
6
 
7
- ## [0.99.4] 18.11.2024
7
+ ## [0.100.0] 17.01.2024
8
+ ### Added
9
+ - Vips image processor (resizing)
10
+ - Vips colour filters
11
+ - Vips crop
12
+ - Vips rotate
13
+ - Vips straighten
14
+ - Vips gamma
15
+ - Vips stripping alpha
16
+ - Explicit error when trying to use VipsProcessor with unsupported options
17
+ - Vips cast to srgb when image uses a different colourspace
18
+
19
+ ### Removed
20
+ - [BREAKING] dropped support for a broken 'dominant' border colour
21
+
22
+ ## [0.99.4] 22.11.2024
8
23
  ### Added
9
24
  - Better test coverage for straighten operation
10
25
  - Support for visual image comparison in specs
Binary file
@@ -91,5 +91,39 @@ module Morandi
91
91
  end
92
92
  pixbuf
93
93
  end
94
+
95
+ def apply_crop_vips(img, x_coord, y_coord, width, height)
96
+ if x_coord.negative? ||
97
+ y_coord.negative? ||
98
+ ((x_coord + width) > img.width) ||
99
+ ((y_coord + height) > img.height)
100
+
101
+ extract_area_x = [0, x_coord].max
102
+ extract_area_y = [0, y_coord].max
103
+ area_to_copy = img.extract_area(extract_area_x, extract_area_y, img.width - extract_area_x,
104
+ img.height - extract_area_y)
105
+
106
+ fill_colour = [255, 255, 255]
107
+ pixel = (Vips::Image.black(1, 1).colourspace(:srgb) + fill_colour).cast(img.format)
108
+ canvas = pixel.embed 0, 0, width, height, extend: :copy
109
+
110
+ cropped = canvas.composite(area_to_copy, :over, x: [-x_coord, 0].max,
111
+ y: [-y_coord, 0].max,
112
+ compositing_space: area_to_copy.interpretation)
113
+
114
+ # Because image is drawn on an opaque white, alpha doesn't matter at this point anyway, so let's strip the
115
+ # alpha channel from the output. According to #composite docs, the resulting image always has alpha channel,
116
+ # but I added a guard to avoid regressions if that ever changes.
117
+ cropped = cropped.extract_band(0, n: cropped.bands - 1) if cropped.has_alpha?
118
+ cropped
119
+ else
120
+ x_coord = x_coord.clamp(0, img.width)
121
+ y_coord = y_coord.clamp(0, img.height)
122
+ width = width.clamp(1, img.width - x_coord)
123
+ height = height.clamp(1, img.height - y_coord)
124
+
125
+ img.crop(x_coord, y_coord, width, height)
126
+ end
127
+ end
94
128
  end
95
129
  end
@@ -7,7 +7,7 @@ module Morandi
7
7
  module Operation
8
8
  # Image Border operation
9
9
  # Supports retro (rounded) and square borders
10
- # Background colour (ie. border colour) can be white, black, dominant (ie. from image)
10
+ # Background colour (ie. border colour) can be white, black
11
11
  # @!visibility private
12
12
  class ImageBorder < ImageOperation
13
13
  attr_accessor :style, :colour, :crop, :size, :print_size, :shrink, :border_size
@@ -26,7 +26,7 @@ module Morandi
26
26
 
27
27
  @border_scale = [img_width, img_height].max.to_f / print_size.max.to_i
28
28
 
29
- draw_background(cr, img_height, img_width, pixbuf)
29
+ draw_background(cr, img_height, img_width)
30
30
 
31
31
  x = border_width
32
32
  y = border_width
@@ -75,7 +75,7 @@ module Morandi
75
75
  cr.paint(1.0)
76
76
  end
77
77
 
78
- def draw_background(cr, img_height, img_width, pixbuf)
78
+ def draw_background(cr, img_height, img_width)
79
79
  cr.save do
80
80
  cr.translate(-@crop[0], -@crop[1]) if @crop && ((@crop[0]).negative? || (@crop[1]).negative?)
81
81
 
@@ -86,12 +86,6 @@ module Morandi
86
86
 
87
87
  cr.rectangle(0, 0, img_width, img_height)
88
88
  case colour
89
- when 'dominant'
90
- pixbuf.scale_max(400).save(fn = "/tmp/hist-#{$PROCESS_ID}.#{Time.now.to_i}", 'jpeg')
91
- histogram = Colorscore::Histogram.new(fn)
92
- FileUtils.rm_f(fn)
93
- col = histogram.scores.first[1]
94
- cr.set_source_rgb col.red / 256.0, col.green / 256.0, col.blue / 256.0
95
89
  when 'retro'
96
90
  cr.set_source_rgb 1, 1, 0.8
97
91
  when 'black'
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Morandi
4
+ module Operation
5
+ # Straighten operation
6
+ # Does a small (ie. not 90,180,270 deg) rotation and zooms to avoid cropping
7
+ # @!visibility private
8
+ class VipsStraighten < ImageOperation
9
+ # Colour for filling background post-rotation. It can bleed into the edge pixels during resize.
10
+ # Setting it to gray minimises the average impact
11
+ ROTATION_BACKGROUND_FILL_COLOUR = 127
12
+ ROTATION_BACKGROUND_FILL_ALPHA = 255
13
+
14
+ def self.rotation_background_fill_colour(channels_count:, alpha:)
15
+ return [ROTATION_BACKGROUND_FILL_COLOUR] * channels_count unless alpha # Eg [127, 127, 127] for RGB
16
+
17
+ # Eg [127, 127, 127, 255] for RGBA
18
+ ([ROTATION_BACKGROUND_FILL_COLOUR] * (channels_count - 1)) + [ROTATION_BACKGROUND_FILL_ALPHA]
19
+ end
20
+
21
+ attr_accessor :angle
22
+
23
+ def call(img)
24
+ return img if angle.zero?
25
+
26
+ original_width = img.width
27
+ original_height = img.height
28
+
29
+ # It is possible to first rotate, then fetch width/height of resulting image to calculate scale,
30
+ # but that would make us lose precision which degrades cropping accuracy
31
+ rotation_value_rad = angle * (Math::PI / 180)
32
+ post_rotation_bounding_box_width = (img.height.to_f * Math.sin(rotation_value_rad).abs) +
33
+ (img.width.to_f * Math.cos(rotation_value_rad).abs)
34
+ post_rotation_bounding_box_height = (img.width.to_f * Math.sin(rotation_value_rad).abs) +
35
+ (img.height.to_f * Math.cos(rotation_value_rad).abs)
36
+
37
+ # Calculate scaling required to fit the original width/height within rotated image without including background
38
+ scale = [post_rotation_bounding_box_width / original_width,
39
+ post_rotation_bounding_box_height / original_height].max
40
+
41
+ background_fill_colour = self.class.rotation_background_fill_colour(channels_count: img.bands,
42
+ alpha: img.has_alpha?)
43
+ img = img.similarity(angle: angle, scale: scale, background: background_fill_colour)
44
+
45
+ # Better precision than img.width/img.height due to fractions preservation
46
+ post_scale_bounding_box_width = post_rotation_bounding_box_width * scale
47
+ post_scale_bounding_box_height = post_rotation_bounding_box_height * scale
48
+
49
+ width_diff = post_scale_bounding_box_width - original_width
50
+ height_diff = post_scale_bounding_box_height - original_height
51
+
52
+ # Round to nearest integer to reduce risk of ROTATION_BACKGROUND_FILL_COLOUR being visible in the corner
53
+ crop_x = (width_diff / 2).round
54
+ crop_y = (height_diff / 2).round
55
+
56
+ img.crop(crop_x, crop_y, original_width, original_height)
57
+ end
58
+ end
59
+ end
60
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Morandi
4
- VERSION = '0.99.4'
4
+ VERSION = '0.100.0'
5
5
  end
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'vips'
4
+
5
+ require 'morandi/srgb_conversion'
6
+ require 'morandi/operation/vips_straighten'
7
+
8
+ module Morandi
9
+ # An alternative to ImageProcessor which is based on libvips for concurrent and less memory-intensive processing
10
+ class VipsImageProcessor
11
+ # Colour filter related constants
12
+ RGB_LUMINANCE_EXTRACTION_FACTORS = [0.3086, 0.6094, 0.0820].freeze
13
+ SEPIA_MODIFIER = [25, 5, -25].freeze
14
+ BLUETONE_MODIFIER = [-10, 5, 25].freeze
15
+ COLOUR_FILTER_MODIFIERS = {
16
+ 'sepia' => SEPIA_MODIFIER,
17
+ 'bluetone' => BLUETONE_MODIFIER
18
+ }.freeze
19
+ SUPPORTED_FILTERS = COLOUR_FILTER_MODIFIERS.keys + ['greyscale']
20
+
21
+ def self.supports?(input, options)
22
+ return false unless input.is_a?(String)
23
+ return false if options['brighten'].to_f != 0
24
+ return false if options['contrast'].to_f != 0
25
+ return false if options['sharpen'].to_f != 0
26
+ return false if options['redeye']&.any?
27
+ return false if options['border-style']
28
+ return false if options['background-style']
29
+
30
+ true
31
+ end
32
+
33
+ # Vips options are global, this method sets them for yielding, then restores to original
34
+ def self.with_global_options(cache_max:, concurrency:)
35
+ previous_cache_max = Vips.cache_max
36
+ previous_concurrency = Vips.concurrency
37
+
38
+ Vips.cache_set_max(cache_max)
39
+ Vips.concurrency_set(concurrency)
40
+
41
+ yield
42
+ ensure
43
+ Vips.cache_set_max(previous_cache_max)
44
+ Vips.concurrency_set(previous_concurrency)
45
+ end
46
+
47
+ def initialize(path, user_options)
48
+ @path = path
49
+
50
+ @options = user_options
51
+
52
+ @size_limit_on_load_px = @options['output.max']
53
+ @output_width = @options['output.width']
54
+ @output_height = @options['output.height']
55
+ end
56
+
57
+ def process!
58
+ source_file_path = Morandi::SrgbConversion.perform(@path) || @path
59
+ begin
60
+ @img = Vips::Image.new_from_file(source_file_path)
61
+ rescue Vips::Error => e
62
+ # Match the known errors
63
+ raise UnknownTypeError if /is not a known file format/.match?(e.message)
64
+ raise CorruptImageError if /Premature end of JPEG file/.match?(e.message)
65
+
66
+ # Re-raise generic Error when unknown
67
+ raise Error, e.message
68
+ end
69
+ if @size_limit_on_load_px
70
+ @scale = @size_limit_on_load_px.to_f / [@img.width, @img.height].max
71
+ @img = @img.resize(@scale) if not_equal_to_one(@scale)
72
+ else
73
+ @scale = 1.0
74
+ end
75
+
76
+ apply_gamma!
77
+ apply_rotate!
78
+ apply_crop!
79
+ apply_filters!
80
+
81
+ if @options['output.limit'] && @output_width && @output_height
82
+ scale_factor = [@output_width, @output_height].max.to_f / [@img.width, @img.height].max
83
+ @img = @img.resize(scale_factor) if scale_factor < 1.0
84
+ end
85
+
86
+ strip_alpha!
87
+ ensure_srgb!
88
+ end
89
+
90
+ def write_to_png(_write_to, _orientation = :any)
91
+ raise 'not implemented'
92
+ end
93
+
94
+ def write_to_jpeg(target_path, quality = nil)
95
+ process!
96
+
97
+ quality ||= @options.fetch('quality', 97)
98
+
99
+ target_path_jpg = "#{target_path}.jpg" # Vips chooses format based on file extension, this ensures jpg
100
+ @img.write_to_file(target_path_jpg, Q: quality)
101
+ FileUtils.mv(target_path_jpg, target_path)
102
+ end
103
+
104
+ private
105
+
106
+ # Remove the alpha channel if present. Vips supports alpha, but the current Pixbuf processor happens to strip it in
107
+ # most cases (straighten and cropping beyond image bounds are exceptions)
108
+ #
109
+ # Alternatively, alpha can be left intact for more accurate processing and transparent output or merged into an
110
+ # image using Vips::Image#flatten for less resource-intensive processing
111
+ def strip_alpha!
112
+ @img = @img.extract_band(0, n: @img.bands - 1) if @img.has_alpha?
113
+ end
114
+
115
+ def apply_gamma!
116
+ return unless @options['gamma'] && not_equal_to_one(@options['gamma'])
117
+
118
+ @img = @img.gamma(exponent: @options['gamma'])
119
+ end
120
+
121
+ def angle
122
+ @options['angle'].to_i % 360
123
+ end
124
+
125
+ def apply_rotate!
126
+ @img = case angle
127
+ when 0 then @img
128
+ when 90 then @img.rot90
129
+ when 180 then @img.rot180
130
+ when 270 then @img.rot270
131
+ else raise('"angle" option only accepts multiples of 90')
132
+ end
133
+
134
+ unless @options['straighten'].to_f.zero?
135
+ @img = Morandi::Operation::VipsStraighten.new_from_hash(angle: @options['straighten'].to_f).call(@img)
136
+ end
137
+
138
+ @image_width = @img.width
139
+ @image_height = @img.height
140
+ end
141
+
142
+ def apply_crop!
143
+ crop = @options['crop']
144
+
145
+ return if crop.nil? && @options['image.auto-crop'].eql?(false)
146
+
147
+ crop = crop.split(',').map(&:to_i) if crop.is_a?(String) && crop =~ /^\d+,\d+,\d+,\d+/
148
+
149
+ crop = nil unless crop.is_a?(Array) && crop.size.eql?(4) && crop.all? do |i|
150
+ i.is_a?(Numeric)
151
+ end
152
+ # can't crop, won't crop
153
+ return if @output_width.nil? && @output_height.nil? && crop.nil?
154
+
155
+ crop = crop.map { |s| (s.to_f * @scale).floor } if crop && not_equal_to_one(@scale)
156
+ crop ||= Morandi::CropUtils.autocrop_coords(@img.width, @img.height, @output_width, @output_height)
157
+ @img = Morandi::CropUtils.apply_crop_vips(@img, crop[0], crop[1], crop[2], crop[3])
158
+ end
159
+
160
+ def apply_filters!
161
+ filter_name = @options['fx']
162
+ return unless SUPPORTED_FILTERS.include?(filter_name)
163
+
164
+ # The filter-related constants assume RGB colourspace, so it requires early conversion
165
+ ensure_srgb!
166
+
167
+ # Convert to greyscale using weights
168
+ rgb_factors = RGB_LUMINANCE_EXTRACTION_FACTORS
169
+ recombination_matrix = [rgb_factors, rgb_factors, rgb_factors]
170
+ if @img.has_alpha?
171
+ # Add "0" multiplier for alpha to ignore it for luminance calculation
172
+ recombination_matrix = recombination_matrix.map { |channel_multipliers| channel_multipliers + [0] }
173
+ # Add fourth row in the matrix to preserve unchanged alpha channel
174
+ recombination_matrix << [0, 0, 0, 1]
175
+ end
176
+ @img = @img.recomb(recombination_matrix)
177
+
178
+ return unless COLOUR_FILTER_MODIFIERS[filter_name]
179
+
180
+ # Apply colour adjustment based on the modifiers setup
181
+ colour_filter_modifier = COLOUR_FILTER_MODIFIERS[filter_name]
182
+ colour_filter_modifier += [0] if @img.has_alpha?
183
+ @img = @img.linear(1.0, colour_filter_modifier)
184
+ end
185
+
186
+ def not_equal_to_one(float)
187
+ (float - 1.0).abs >= Float::EPSILON
188
+ end
189
+
190
+ def ensure_srgb!
191
+ @img = @img.colourspace(:srgb) unless @img.interpretation == :srgb
192
+ end
193
+ end
194
+ end
data/lib/morandi.rb CHANGED
@@ -7,6 +7,7 @@ require 'morandi/cairo_ext'
7
7
  require 'morandi/pixbuf_ext'
8
8
  require 'morandi/errors'
9
9
  require 'morandi/image_processor'
10
+ require 'morandi/vips_image_processor'
10
11
  require 'morandi/redeye'
11
12
  require 'morandi/crop_utils'
12
13
 
@@ -22,12 +23,13 @@ module Morandi
22
23
  # @option options [Float] 'gamma' Gamma correct image
23
24
  # @option options [Integer] 'contrast' Change image contrast (-20..20)
24
25
  # @option options [Integer] 'sharpen' Sharpen (1..5) / Blur (-1..-5)
26
+ # @option options [Integer] 'straighten' Rotate by a small angle (in degrees) and zoom in to fill the size
25
27
  # @option options [Array[[Integer,Integer],...]] 'redeye' Apply redeye correction at point
26
- # @option options [Integer] 'angle' Rotate image clockwise by multiple of 90 (0, 90, 180, 270)
28
+ # @option options [Integer] 'angle' Rotate image clockwise by multiple of 90 degrees (0, 90, 180, 270)
27
29
  # @option options [Array[Integer,Integer,Integer,Integer]] 'crop' Crop image (x, y, width, height)
28
30
  # @option options [String] 'fx' Apply colour filters ('greyscale', 'sepia', 'bluetone')
29
31
  # @option options [String] 'border-style' Set border style ('square', 'retro')
30
- # @option options [String] 'background-style' Set border colour ('retro', 'black', 'white', 'dominant')
32
+ # @option options [String] 'background-style' Set border colour ('retro', 'black', 'white')
31
33
  # @option options [Integer] 'quality' (97) Set JPG compression value (1 to 100)
32
34
  # @option options [Integer] 'output.max' Downscales the image to fit within the square of given size before
33
35
  # processing to limit the required resources
@@ -42,9 +44,24 @@ module Morandi
42
44
  # @param target_path [String] target location for image
43
45
  # @param local_options [Hash] Hash of options other than desired transformations
44
46
  # @option local_options [String] 'path.icc' A path to store the input after converting to sRGB colour space
47
+ # @option local_options [String] 'processor' ('pixbuf') Name of the image processing library ('pixbuf', 'vips')
48
+ # NOTE: vips processor only handles subset of operations,
49
+ # see `Morandi::VipsImageProcessor.supports?` for details
45
50
  def process(source, options, target_path, local_options = {})
46
- pro = ImageProcessor.new(source, options, local_options)
47
- pro.result
48
- pro.write_to_jpeg(target_path)
51
+ case local_options['processor']
52
+ when 'vips'
53
+ raise(ArgumentError, 'Requested unsupported Vips operation') unless VipsImageProcessor.supports?(source, options)
54
+
55
+ # Cache saves time in expense of RAM when performing the same processing multiple times
56
+ # Cache is also created for files based on their names, which can lead to leaking files data, so in terms
57
+ # of security it feels prudent to disable it. Latest libvips supports "revalidate" option to prevent that risk
58
+ cache_max = 0
59
+ concurrency = 2 # Hardcoding to 2 for now to maintain some balance between resource usage and performance
60
+ VipsImageProcessor.with_global_options(cache_max: cache_max, concurrency: concurrency) do
61
+ VipsImageProcessor.new(source, options).write_to_jpeg(target_path)
62
+ end
63
+ else
64
+ ImageProcessor.new(source, options, local_options).tap(&:result).write_to_jpeg(target_path)
65
+ end
49
66
  end
50
67
  end
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: morandi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.99.4
4
+ version: 0.100.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - |+
@@ -11,7 +11,7 @@ authors:
11
11
  autorequire:
12
12
  bindir: bin
13
13
  cert_chain: []
14
- date: 2024-11-22 00:00:00.000000000 Z
14
+ date: 2025-01-17 00:00:00.000000000 Z
15
15
  dependencies:
16
16
  - !ruby/object:Gem::Dependency
17
17
  name: atk
@@ -97,6 +97,20 @@ dependencies:
97
97
  - - "~>"
98
98
  - !ruby/object:Gem::Version
99
99
  version: '1.2'
100
+ - !ruby/object:Gem::Dependency
101
+ name: ruby-vips
102
+ requirement: !ruby/object:Gem::Requirement
103
+ requirements:
104
+ - - ">="
105
+ - !ruby/object:Gem::Version
106
+ version: '0'
107
+ type: :runtime
108
+ prerelease: false
109
+ version_requirements: !ruby/object:Gem::Requirement
110
+ requirements:
111
+ - - ">="
112
+ - !ruby/object:Gem::Version
113
+ version: '0'
100
114
  description: Apply simple edits to images
101
115
  email:
102
116
  - git@intersect-uk.co.uk
@@ -128,11 +142,13 @@ files:
128
142
  - lib/morandi/operation/colourify.rb
129
143
  - lib/morandi/operation/image_border.rb
130
144
  - lib/morandi/operation/straighten.rb
145
+ - lib/morandi/operation/vips_straighten.rb
131
146
  - lib/morandi/pixbuf_ext.rb
132
147
  - lib/morandi/profiled_pixbuf.rb
133
148
  - lib/morandi/redeye.rb
134
149
  - lib/morandi/srgb_conversion.rb
135
150
  - lib/morandi/version.rb
151
+ - lib/morandi/vips_image_processor.rb
136
152
  - lib/morandi_native.so
137
153
  homepage: https://github.com/livelink/morandi-rb
138
154
  licenses: