assembly-image 1.7.7 → 1.7.8
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 +4 -4
- data/.circleci/config.yml +22 -0
- data/.github/pull_request_template.md +10 -0
- data/.gitignore +4 -1
- data/.rubocop.yml +150 -3
- data/.rubocop_todo.yml +68 -0
- data/README.md +1 -1
- data/assembly-image.gemspec +2 -0
- data/lib/assembly-image/image.rb +11 -156
- data/lib/assembly-image/jp2_creator.rb +182 -0
- data/lib/assembly-image/version.rb +2 -2
- data/spec/assembly/image/jp2_creator_spec.rb +58 -0
- data/spec/image_spec.rb +248 -244
- data/spec/images_spec.rb +20 -22
- data/spec/spec_helper.rb +84 -0
- metadata +28 -8
- data/.travis.yml +0 -37
@@ -0,0 +1,182 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'tempfile'
|
4
|
+
require 'English' # see https://github.com/rubocop-hq/rubocop/issues/1747 (not #MAGA related)
|
5
|
+
|
6
|
+
module Assembly
|
7
|
+
class Image
|
8
|
+
# Creates jp2 derivatives
|
9
|
+
class Jp2Creator # rubocop:disable Metrics/ClassLength
|
10
|
+
# Create a JP2 file for the current image.
|
11
|
+
# Important note: this will not work for multipage TIFFs.
|
12
|
+
#
|
13
|
+
# @return [Assembly::Image] object containing the generated JP2 file
|
14
|
+
#
|
15
|
+
# @param [Assembly::Image] the image file
|
16
|
+
# @param [Hash] params Optional parameters specified as a hash, using symbols for options:
|
17
|
+
# * :output => path to the output JP2 file (default: mirrors the source file name and path, but with a .jp2 extension)
|
18
|
+
# * :overwrite => if set to false, an existing JP2 file with the same name won't be overwritten (default: false)
|
19
|
+
# * :tmp_folder => the temporary folder to use when creating the jp2 (default: '/tmp'); also used by imagemagick
|
20
|
+
#
|
21
|
+
# Example:
|
22
|
+
# source_img = Assembly::Image.new('/input/path_to_file.tif')
|
23
|
+
# derivative_img = source_img.create_jp2(:overwrite=>true)
|
24
|
+
# puts derivative_img.mimetype # 'image/jp2'
|
25
|
+
# puts derivative_image.path # '/input/path_to_file.jp2'
|
26
|
+
def self.create(image, params = {})
|
27
|
+
new(image, params).create
|
28
|
+
end
|
29
|
+
|
30
|
+
def initialize(image, params)
|
31
|
+
@image = image
|
32
|
+
@output_path = params.fetch(:output, image.jp2_filename)
|
33
|
+
@tmp_folder = params[:tmp_folder]
|
34
|
+
@overwrite = params[:overwrite]
|
35
|
+
@params = params
|
36
|
+
end
|
37
|
+
|
38
|
+
attr_reader :image, :output_path, :tmp_folder, :tmp_path
|
39
|
+
|
40
|
+
# @return [Assembly::Image] object containing the generated JP2 file
|
41
|
+
def create
|
42
|
+
create_jp2_checks
|
43
|
+
|
44
|
+
# Using instance variable so that can check in tests.
|
45
|
+
@tmp_path = make_tmp_tiff(tmp_folder: tmp_folder)
|
46
|
+
|
47
|
+
jp2_command = jp2_create_command(source_path: @tmp_path, output: output_path)
|
48
|
+
result = `#{jp2_command}`
|
49
|
+
unless $CHILD_STATUS.success?
|
50
|
+
# Clean up any partial result
|
51
|
+
File.delete(output_path) if File.exist?(output_path)
|
52
|
+
raise "JP2 creation command failed: #{jp2_command} with result #{result}"
|
53
|
+
end
|
54
|
+
|
55
|
+
File.delete(@tmp_path) unless @tmp_path.nil?
|
56
|
+
|
57
|
+
# create output response object, which is an Assembly::Image type object
|
58
|
+
Image.new(output_path)
|
59
|
+
end
|
60
|
+
|
61
|
+
private
|
62
|
+
|
63
|
+
def overwrite?
|
64
|
+
@overwrite
|
65
|
+
end
|
66
|
+
|
67
|
+
def jp2_create_command(source_path:, output:)
|
68
|
+
options = []
|
69
|
+
options << '-jp2_space sRGB' if image.samples_per_pixel == 3
|
70
|
+
options += KDU_COMPRESS_DEFAULT_OPTIONS
|
71
|
+
options << "Clayers=#{layers}"
|
72
|
+
"kdu_compress #{options.join(' ')} -i '#{source_path}' -o '#{output}' 2>&1"
|
73
|
+
end
|
74
|
+
|
75
|
+
# Get the number of JP2 layers to generate
|
76
|
+
def layers
|
77
|
+
pixdem = [image.width, image.height].max
|
78
|
+
((Math.log(pixdem) / Math.log(2)) - (Math.log(96) / Math.log(2))).ceil + 1
|
79
|
+
end
|
80
|
+
|
81
|
+
KDU_COMPRESS_DEFAULT_OPTIONS = [
|
82
|
+
'-num_threads 2', # forces Kakadu to only use 2 threads
|
83
|
+
'-precise', # forces the use of 32-bit representations
|
84
|
+
'-no_weights', # minimization of the MSE over all reconstructed colour components
|
85
|
+
'-quiet', # suppress informative messages.
|
86
|
+
'Creversible=no', # Disable reversible compression
|
87
|
+
'Cmodes=BYPASS', #
|
88
|
+
'Corder=RPCL', # R=resolution P=position C=component L=layer
|
89
|
+
'Cblk=\\{64,64\\}', # code-block dimensions; 64x64 happens to also be the default
|
90
|
+
'Cprecincts=\\{256,256\\},\\{256,256\\},\\{128,128\\}', # Precinct dimensions; 256x256 for the 2 highest resolution levels, defaults to 128x128 for the rest
|
91
|
+
'ORGgen_plt=yes', # Insert packet length information
|
92
|
+
'-rate 1.5', # Ratio of compressed bits to the image size
|
93
|
+
'Clevels=5' # Number of wavelet decomposition levels, or stages
|
94
|
+
].freeze
|
95
|
+
|
96
|
+
# rubocop:disable Metrics/AbcSize
|
97
|
+
def create_jp2_checks
|
98
|
+
image.send(:check_for_file)
|
99
|
+
raise 'input file is not a valid image, or is the wrong mimetype' unless image.jp2able?
|
100
|
+
|
101
|
+
raise SecurityError, "output #{output_path} exists, cannot overwrite" if !overwrite? && File.exist?(output_path)
|
102
|
+
raise SecurityError, 'cannot recreate jp2 over itself' if overwrite? && image.mimetype == 'image/jp2' && output_path == image.path
|
103
|
+
end
|
104
|
+
|
105
|
+
# rubocop:disable Metrics/MethodLength
|
106
|
+
def profile_conversion_switch(profile, tmp_folder:)
|
107
|
+
path_to_profiles = File.join(Assembly::PATH_TO_IMAGE_GEM, 'profiles')
|
108
|
+
# eventually we may allow the user to specify the output_profile...when we do, you can just uncomment this code
|
109
|
+
# and update the tests that check for this
|
110
|
+
output_profile = 'sRGBIEC6196621' # params[:output_profile] || 'sRGBIEC6196621'
|
111
|
+
output_profile_file = File.join(path_to_profiles, "#{output_profile}.icc")
|
112
|
+
|
113
|
+
raise "output profile #{output_profile} invalid" unless File.exist?(output_profile_file)
|
114
|
+
|
115
|
+
return '' if image.profile.nil?
|
116
|
+
|
117
|
+
# if the input color profile exists, contract paths to the profile and setup the command
|
118
|
+
|
119
|
+
input_profile = profile.gsub(/[^[:alnum:]]/, '') # remove all non alpha-numeric characters, so we can get to a filename
|
120
|
+
|
121
|
+
# construct a path to the input profile, which might exist either in the gem itself or in the tmp folder
|
122
|
+
input_profile_file_gem = File.join(path_to_profiles, "#{input_profile}.icc")
|
123
|
+
input_profile_file_tmp = File.join(tmp_folder, "#{input_profile}.icc")
|
124
|
+
input_profile_file = File.exist?(input_profile_file_gem) ? input_profile_file_gem : input_profile_file_tmp
|
125
|
+
|
126
|
+
# if input profile was extracted and does not matches an existing known profile either in the gem or in the tmp folder,
|
127
|
+
# we'll issue an imagicmagick command to extract the profile to the tmp folder
|
128
|
+
unless File.exist?(input_profile_file)
|
129
|
+
input_profile_extract_command = "MAGICK_TEMPORARY_PATH=#{tmp_folder} convert '#{image.path}'[0] #{input_profile_file}" # extract profile from input image
|
130
|
+
result = `#{input_profile_extract_command} 2>&1`
|
131
|
+
raise "input profile extraction command failed: #{input_profile_extract_command} with result #{result}" unless $CHILD_STATUS.success?
|
132
|
+
# if extraction failed or we cannot write the file, throw exception
|
133
|
+
raise 'input profile is not a known profile and could not be extracted from input file' unless File.exist?(input_profile_file)
|
134
|
+
end
|
135
|
+
|
136
|
+
"-profile #{input_profile_file} -profile #{output_profile_file}"
|
137
|
+
end
|
138
|
+
|
139
|
+
# Bigtiff needs to be used if size of image exceeds 2^32 bytes.
|
140
|
+
def need_bigtiff?
|
141
|
+
image.image_data_size >= 2**32
|
142
|
+
end
|
143
|
+
|
144
|
+
def make_tmp_tiff(tmp_folder: nil)
|
145
|
+
tmp_folder ||= Dir.tmpdir
|
146
|
+
raise "tmp_folder #{tmp_folder} does not exists" unless File.exist?(tmp_folder)
|
147
|
+
|
148
|
+
# make temp tiff filename
|
149
|
+
tmp_tiff_file = Tempfile.new(['assembly-image', '.tif'], tmp_folder)
|
150
|
+
tmp_path = tmp_tiff_file.path
|
151
|
+
|
152
|
+
options = []
|
153
|
+
|
154
|
+
# Limit the amount of memory ImageMagick is able to use.
|
155
|
+
options << '-limit memory 1GiB -limit map 1GiB'
|
156
|
+
|
157
|
+
case image.samples_per_pixel
|
158
|
+
when 3
|
159
|
+
options << '-type TrueColor'
|
160
|
+
when 1
|
161
|
+
options << '-depth 8' # force the production of a grayscale access derivative
|
162
|
+
options << '-type Grayscale'
|
163
|
+
end
|
164
|
+
|
165
|
+
options << profile_conversion_switch(image.profile, tmp_folder: tmp_folder)
|
166
|
+
|
167
|
+
# The output in the covnert command needs to be prefixed by the image type. By default ImageMagick
|
168
|
+
# will assume TIFF: when the file extension is .tif/.tiff. TIFF64: Needs to be forced when image will
|
169
|
+
# exceed 2^32 bytes in size
|
170
|
+
tiff_type = need_bigtiff? ? 'TIFF64:' : ''
|
171
|
+
|
172
|
+
tiff_command = "MAGICK_TEMPORARY_PATH=#{tmp_folder} convert -quiet -compress none #{options.join(' ')} '#{image.path}[0]' #{tiff_type}'#{tmp_path}'"
|
173
|
+
result = `#{tiff_command} 2>&1`
|
174
|
+
raise "tiff convert command failed: #{tiff_command} with result #{result}" unless $CHILD_STATUS.success?
|
175
|
+
|
176
|
+
tmp_path
|
177
|
+
end
|
178
|
+
# rubocop:enable Metrics/MethodLength
|
179
|
+
# rubocop:enable Metrics/AbcSize
|
180
|
+
end
|
181
|
+
end
|
182
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
|
5
|
+
RSpec.describe Assembly::Image::Jp2Creator do
|
6
|
+
subject(:result) { creator.create }
|
7
|
+
|
8
|
+
let(:ai) { Assembly::Image.new(input_path) }
|
9
|
+
let(:input_path) { TEST_TIF_INPUT_FILE }
|
10
|
+
let(:creator) { described_class.new(ai, output: TEST_JP2_OUTPUT_FILE) }
|
11
|
+
|
12
|
+
after do
|
13
|
+
# after each test, empty out the input and output test directories
|
14
|
+
remove_files(TEST_INPUT_DIR)
|
15
|
+
remove_files(TEST_OUTPUT_DIR)
|
16
|
+
end
|
17
|
+
|
18
|
+
context 'when given an LZW compressed RGB tif' do
|
19
|
+
before do
|
20
|
+
generate_test_image(TEST_TIF_INPUT_FILE, compress: 'lzw')
|
21
|
+
end
|
22
|
+
|
23
|
+
it 'creates the jp2 with a temp file' do
|
24
|
+
expect(File).to exist TEST_TIF_INPUT_FILE
|
25
|
+
expect(File).not_to exist TEST_JP2_OUTPUT_FILE
|
26
|
+
expect(result).to be_a_kind_of Assembly::Image
|
27
|
+
expect(result.path).to eq TEST_JP2_OUTPUT_FILE
|
28
|
+
expect(TEST_JP2_OUTPUT_FILE).to be_a_jp2
|
29
|
+
|
30
|
+
# Indicates a temp tiff was not created.
|
31
|
+
expect(creator.tmp_path).not_to be_nil
|
32
|
+
expect(result.exif.colorspace).to eq 'sRGB'
|
33
|
+
jp2 = Assembly::Image.new(TEST_JP2_OUTPUT_FILE)
|
34
|
+
expect(jp2.height).to eq 100
|
35
|
+
expect(jp2.width).to eq 100
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
context 'when the input file is a JPEG' do
|
40
|
+
before do
|
41
|
+
generate_test_image(TEST_JPEG_INPUT_FILE)
|
42
|
+
end
|
43
|
+
|
44
|
+
let(:input_path) { TEST_JPEG_INPUT_FILE }
|
45
|
+
|
46
|
+
it 'creates jp2 when given a JPEG' do
|
47
|
+
expect(File).to exist TEST_JPEG_INPUT_FILE
|
48
|
+
expect(File).not_to exist TEST_JP2_OUTPUT_FILE
|
49
|
+
expect(result).to be_a_kind_of Assembly::Image
|
50
|
+
expect(result.path).to eq TEST_JP2_OUTPUT_FILE
|
51
|
+
expect(TEST_JP2_OUTPUT_FILE).to be_a_jp2
|
52
|
+
|
53
|
+
# Indicates a temp tiff was created.
|
54
|
+
expect(creator.tmp_path).not_to be_nil
|
55
|
+
expect(File).not_to exist creator.tmp_path
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|