morandi 0.99.4 → 0.100.0

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