dicoms 1.0.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.
@@ -0,0 +1,40 @@
1
+ class DicomS
2
+ class CommandOptions < Settings
3
+ def initialize(options)
4
+ @base_dir = nil
5
+ if settings_file = options.delete(:settings_io)
6
+ @settings_io = SharedSettings.new(settings_file)
7
+ else
8
+ settings_file = options.delete(:settings)
9
+ end
10
+ if settings_file
11
+ settings = SharedSettings.new(settings_file).read
12
+ options = settings.merge(options.to_h.reject{ |k, v| v.nil? })
13
+ @base_dir = File.dirname(settings_file)
14
+ else
15
+ @base_dir = nil
16
+ end
17
+ super options
18
+ end
19
+
20
+ def path_option(option, default = nil)
21
+ path = self[option.to_sym] || default
22
+ path = File.expand_path(path, @base_dir) if @base_dir && path
23
+ path
24
+ end
25
+
26
+ attr_reader :base_name
27
+
28
+ def self.[](options)
29
+ options.is_a?(CommandOptions) ? options : CommandOptions.new(options)
30
+ end
31
+
32
+ def save_settings(command, data)
33
+ if @settings_io
34
+ @settings_io.update do |settings|
35
+ settings.merge command.to_sym => data
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,87 @@
1
+ class DicomS
2
+ # extract the images of a set of DICOM files
3
+ def extract(dicom_directory, options = {})
4
+ options = CommandOptions[options]
5
+
6
+ progress = Progress.new('extracting', options)
7
+ progress.begin_subprocess 'reading_metadata', 2
8
+
9
+ strategy = define_transfer(options, :window)
10
+ sequence = Sequence.new(
11
+ dicom_directory,
12
+ transfer: strategy,
13
+ reorder: options[:reorder]
14
+ )
15
+
16
+ progress.begin_subprocess 'extracting_images', 100, sequence.size
17
+
18
+ extract_dir = options.path_option(
19
+ :output, File.join(File.expand_path(dicom_directory), 'images')
20
+ )
21
+ FileUtils.mkdir_p FileUtils.mkdir_p extract_dir
22
+ prefix = nil
23
+ min, max = sequence.metadata.min, sequence.metadata.max
24
+ sequence.each do |d, i, file|
25
+ unless prefix
26
+ prefix, name_pattern, start_number = dicom_name_pattern(file, extract_dir)
27
+ end
28
+ if options.raw
29
+ output_file = output_file_name(extract_dir, prefix, file, '.raw')
30
+ endianness = dicom_endianness(d, options)
31
+ sequence.metadata.endianness = endianness.to_s
32
+ bits = dicom_bit_depth(d)
33
+ signed = dicom_signed?(d)
34
+ fmt = pack_format(bits, signed, endianness)
35
+ File.open(output_file, 'wb') do |out|
36
+ out.write sequence.dicom_pixels(d).flatten.to_a.pack("#{fmt}*")
37
+ end
38
+ else
39
+ output_image = output_file_name(extract_dir, prefix, file)
40
+ sequence.save_jpg d, output_image
41
+ end
42
+ progress.update_subprocess i
43
+ end
44
+
45
+ metadata = cast_metadata(sequence.metadata)
46
+ metadata_yaml = File.join(extract_dir, 'metadata.yml')
47
+ File.open(metadata_yaml, 'w') do |yaml|
48
+ yaml.write metadata.to_yaml
49
+ end
50
+
51
+ progress.finish
52
+ end
53
+
54
+ def dicom_endianness(dicom, options = {})
55
+ if options[:big_endian]
56
+ :big
57
+ elsif options[:little_endian]
58
+ :little
59
+ elsif dicom.stream.str_endian
60
+ :big
61
+ else
62
+ :little
63
+ end
64
+ end
65
+
66
+ def pack_format(bits, signed, endianness)
67
+ if bits > 16
68
+ if signed
69
+ endianness == :little ? 'l<' : 'l>'
70
+ else
71
+ endianness == :little ? 'L<' : 'L>'
72
+ end
73
+ elsif bits > 8
74
+ if signed
75
+ endianness == :little ? 's<' : 's>'
76
+ else
77
+ endianness == :little ? 'S<' : 'S>'
78
+ end
79
+ else
80
+ if signed
81
+ "c"
82
+ else
83
+ "C"
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,131 @@
1
+ # Inserting/extracting DICOM metadata in video files with FFMpeg
2
+ #
3
+ # Example of use:
4
+ #
5
+ # # Add metadata to a file
6
+ # dicom = DICOM::DObject.read(dicom_file)
7
+ # meta_codec = MetaCodec.new(mode: :chunked)
8
+ # meta_file = 'ffmetadata'
9
+ # meta_codec.write_metadata(dicom, meta_file, dx: 111, dy: 222, dz: 333)
10
+ # input_file = 'video.mkv'
11
+ # output_file = 'video_with_metadata.mkv'
12
+ # `ffmpeg -i #{input_file} -i #{meta_file} -map_metadata 1 -codec copy #{output_file}`
13
+ #
14
+ # # Extract metadata from a file
15
+ # `ffmpeg -i #{output_file} -f ffmetadata #{meta_file}`
16
+ # dicom_elements, additional_values = meta_codec.read_metadata(meta_file)
17
+ #
18
+ class DicomS::MetaCodec
19
+
20
+ # Two encoding modes:
21
+ # * :chunked : use few metadata entries (dicom_0)
22
+ # that encode all the DICOM elements (several are used because there's a limit
23
+ # in the length of a single metadata entry)
24
+ # * :individual : use individual metadata entries for each DICOM tag
25
+ def initialize(options = {})
26
+ @mode = options[:mode] || :individual
27
+ end
28
+
29
+ def encode_metadata(dicom, additional_metadata = {}, &blk)
30
+ elements = dicom.elements.select{|e| !e.value.nil?}
31
+ elements = elements.select(&blk) if blk
32
+ elements = elements.map{|e| [e.tag, e.value]}
33
+ case @mode
34
+ when :chunked
35
+ txt = elements.map{|tag, value| "#{inner_escape(tag)}#{VALUE_SEPARATOR}#{inner_escape(value)}"}.join(PAIR_SEPARATOR)
36
+ chunks = in_chunks(txt, CHUNK_SIZE).map{|txt| escape(txt)}
37
+ metadata = Hash[chunks.each_with_index.to_a.map(&:reverse)]
38
+ else
39
+ pairs = elements.map { |tag, value|
40
+ group, tag = tag.split(',')
41
+ ["#{group}_#{tag}", escape(value)]
42
+ }
43
+ metadata = Hash[pairs]
44
+ end
45
+
46
+ metadata.merge(additional_metadata)
47
+ end
48
+
49
+ # Write DICOM metatada encoded for FFMpeg into a metadatafile
50
+ # The file can be attached to a video input_file with:
51
+ # `ffmpeg -i #{input_file} -i #{metadatafile} -map_metadata 1 -codec copy #{output_file}`
52
+ def write_metadata(dicom, metadatafile, additional_metadata = {}, &blk)
53
+ metadata = encode_metadata(dicom, additional_metadata, &blk)
54
+ File.open(metadatafile, 'w') do |file|
55
+ file.puts ";FFMETADATA1"
56
+ metadata.each do |name, value|
57
+ file.puts "dicom_#{name}=#{value}"
58
+ end
59
+ end
60
+ end
61
+
62
+ def decode_metadata(txt)
63
+ txt = unescape(txt)
64
+ data = txt.split(PAIR_SEPARATOR).map{|pair| pair.split(VALUE_SEPARATOR)}
65
+ data = data.map{|tag, value| [inner_unescape(tag), inner_unescape(value)]}
66
+ data.map{|tag, value|
67
+ DICOM::Element.new(tag, value)
68
+ }
69
+ end
70
+
71
+ # Can extract the metadatafile from a video input_file with:
72
+ # `ffmpeg -i #{input_file} -f ffmetadata #{metadatafile}`
73
+ def read_metadata(metadatafile)
74
+ lines = File.read(metadatafile).lines[1..-1]
75
+ lines = lines.reject { |line|
76
+ line = line.strip
77
+ line.empty? || line[0, 1] == '#' || line[0, 1] == ';'
78
+ }
79
+ chunks = []
80
+ elements = []
81
+ additional_metadata = {}
82
+ lines.each do |line|
83
+ key, value = line.strip.split('=')
84
+ key = key.downcase
85
+ if match = key.match(/\Adicom_(\d+)\Z/)
86
+ i = match[1].to_i
87
+ chunks << [i, value]
88
+ elsif match = key.match(/\Adicom_(\h+)_(\h+)\Z/)
89
+ group = match[1]
90
+ tag = match[2]
91
+ tag = "#{group},#{tag}"
92
+ elements << DICOM::Element.new(tag, unescape(value))
93
+ elsif match = key.match(/\Adicom_(.+)\Z/)
94
+ additional_metadata[match[1].downcase.to_sym] = value # TODO: type conversion?
95
+ end
96
+ end
97
+ if chunks.size > 0
98
+ elements += decode_metadata(chunks.sort_by(&:first).map(&:last).join)
99
+ end
100
+ [elements, additional_metadata]
101
+ end
102
+
103
+ private
104
+
105
+ def escape(txt)
106
+ txt.to_s.gsub('\\', Regexp.quote('\\\\')).gsub('=', '\\=').gsub(';', '\\;').gsub('#', '\\#').gsub('\n', '\\\n')
107
+ end
108
+
109
+ def unescape(txt)
110
+ txt.to_s.gsub('\\\\', Regexp.quote('\\')).gsub('\\=', '=').gsub('\\;', ';').gsub('\\#', '#').gsub('\\\n', '\n')
111
+ end
112
+
113
+ VALUE_SEPARATOR = '>'
114
+ PAIR_SEPARATOR = '<'
115
+
116
+ def inner_escape(txt)
117
+ txt.to_s.gsub(VALUE_SEPARATOR, '[[METACODEC_VSEP]]').gsub(PAIR_SEPARATOR, '[[METACODEC_PSEP]]')
118
+ end
119
+
120
+ def inner_unescape(txt)
121
+ txt.to_s.gsub('[[METACODEC_VSEP]]', VALUE_SEPARATOR).gsub('[[METACODEC_PSEP]]', PAIR_SEPARATOR)
122
+ end
123
+
124
+ CHUNK_SIZE = 800
125
+
126
+ def in_chunks(txt, max_size=CHUNK_SIZE)
127
+ # txt.chars.each_slice(max_size).map(&:join)
128
+ txt.scan(/.{1,#{max_size}}/)
129
+ end
130
+
131
+ end
@@ -0,0 +1,82 @@
1
+ class DicomS
2
+ def pack(dicom_directory, options = {})
3
+ # TODO: keep more metadata to restore the exact strategy+min,max and so
4
+ # be able to restore original DICOM values (and rescaling/window metadata)
5
+ # bit depth, signed/unsigned, rescale, window, data values corresponding
6
+ # to minimum (black) and maximum (white)
7
+ options = CommandOptions[options]
8
+
9
+ progress = Progress.new('packaging', options)
10
+ progress.begin_subprocess 'reading_metadata', 2
11
+
12
+ strategy = define_transfer(options, :sample)
13
+ sequence = Sequence.new(
14
+ dicom_directory,
15
+ transfer: strategy,
16
+ roi: options[:roi],
17
+ reorder: options[:reorder]
18
+ )
19
+
20
+ output_name = (options.path_option(:output) || File.basename(dicom_directory)) + '.mkv'
21
+ pack_dir = options.path_option(:tmp, 'dspack_tmp') # TODO: better default
22
+ FileUtils.mkdir_p pack_dir
23
+
24
+ name_pattern = start_number = prefix = nil
25
+
26
+ progress.begin_subprocess 'extracting_images', 60, sequence.size
27
+ image_files = []
28
+ keeping_path do
29
+ sequence.each do |d, i, file|
30
+ unless name_pattern
31
+ prefix, name_pattern, start_number = dicom_name_pattern(file, pack_dir)
32
+ end
33
+ output_image = output_file_name(pack_dir, prefix, file)
34
+ image_files << output_image
35
+ sequence.save_jpg d, output_image
36
+ progress.update_subprocess i
37
+ end
38
+ end
39
+ if options[:dicom_metadata]
40
+ metadata_file = File.join(pack_dir, 'ffmetadata')
41
+ meta_codec.write_metadata(sequence.first, metadata_file, sequence.metadata.to_h)
42
+ end
43
+ progress.begin_subprocess 'packing_images'
44
+ ffmpeg = SysCmd.command('ffmpeg', @ffmpeg_options) do
45
+ # global options
46
+ option '-y' # overwrite existing files
47
+ option '-loglevel', 'quiet'
48
+ option '-hide_banner'
49
+
50
+ # input
51
+ option '-start_number', start_number
52
+ option '-i', name_pattern
53
+
54
+ # metadata
55
+ if metadata_file
56
+ # additional input: metadata
57
+ option '-i', metadata_file
58
+ # use metadata from input file #1 (0 is the image sequence)
59
+ option '-map_metadata', 1
60
+ else
61
+ sequence.metadata.each do |key, value|
62
+ option '-metadata', "dicom_#{key}", equal_value: value
63
+ end
64
+ end
65
+
66
+ # output
67
+ option '-vcodec', 'mjpeg'
68
+ option '-q:v', '2.0'
69
+ file output_name
70
+ end
71
+ ffmpeg.run error_output: :separate
72
+ check_command ffmpeg
73
+ if File.expand_path(File.dirname(output_name)) == File.expand_path(pack_dir)
74
+ image_files.files.each do |file|
75
+ FileUtils.rm file
76
+ end
77
+ else
78
+ FileUtils.rm_rf pack_dir
79
+ end
80
+ progress.finish
81
+ end
82
+ end
@@ -0,0 +1,80 @@
1
+ class DicomS
2
+ class Progress
3
+ def initialize(description, options={})
4
+ options = CommandOptions[options]
5
+ filename = options.path_option(:progress)
6
+ @progress = options[:initial_progress] || 0
7
+ # TODO: if filename == :console, show progress on the console
8
+ if filename
9
+ @file = SharedSettings.new(
10
+ filename,
11
+ replace_contents: {
12
+ process: description,
13
+ subprocess: options[:subprocess],
14
+ progress: @progress
15
+ }
16
+ )
17
+ else
18
+ @file = nil
19
+ end
20
+ @subprocess_start = @subprocess_size = @subprocess_percent = nil
21
+ end
22
+
23
+ attr_reader :progress
24
+
25
+ def finished?
26
+ @progress >= 100
27
+ end
28
+
29
+ def update(value, subprocess = nil)
30
+ @progress = value
31
+ if @file
32
+ @file.update do |data|
33
+ data.progress = @progress
34
+ data.subprocess = subprocess if subprocess
35
+ data
36
+ end
37
+ end
38
+ end
39
+
40
+ def finish
41
+ update 100, 'finished'
42
+ end
43
+
44
+ # Begin a subprocess which represent `percent`
45
+ # of the total process. The subprocess will be measured
46
+ # with values up to `size`
47
+ def begin_subprocess(description, percent=nil, size=0)
48
+ end_subprocess if @subprocess_start
49
+ @subprocess_start = @progress
50
+ percent ||= 100
51
+ if percent < 0
52
+ # interpreted as percent of what's lef
53
+ percent = (100 - @progress)*(-percent)/100.0
54
+ end
55
+ percent = [percent, 100 - @progress].min
56
+ # @subprocess_end = @progress + percent
57
+ @subprocess_size = size.to_f
58
+ @subprocess_percent = percent
59
+ update @progress, description
60
+ end
61
+
62
+ def update_subprocess(value)
63
+ raise "Subprocess not started" unless @subprocess_start
64
+ sub_fraction = value/@subprocess_size
65
+ @progress = @subprocess_start + @subprocess_percent*sub_fraction
66
+ if @subprocess_size < 20 || (value % 10) == 0
67
+ # frequently updated processes don't update the file every
68
+ # fime to avoid the overhead (just 1 in 10 times)
69
+ update @progress
70
+ end
71
+ end
72
+
73
+ def end_subprocess
74
+ raise "Subprocess not started" unless @subprocess_start
75
+ @progress = @subprocess_start + @subprocess_percent
76
+ @subprocess_start = @subprocess_size = @subprocess_percent = nil
77
+ update @progress
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,422 @@
1
+ require 'rmagick'
2
+
3
+ class DicomS
4
+ NORMALIZE_PROJECTION_IMAGES = true
5
+ ASSIGN_IMAGE_PIXELS_AS_ARRAY = true
6
+ ADJUST_AAP_FOR_WIDTH = true
7
+
8
+ # extract projected images of a set of DICOM files
9
+ def projection(dicom_directory, options = {})
10
+ options = CommandOptions[options]
11
+
12
+ progress = Progress.new('projecting', options)
13
+ progress.begin_subprocess 'reading_metadata', 1
14
+
15
+ # We can save on memory use by using 8-bit processing, so it will be the default
16
+ strategy = define_transfer(options, :window, output: :byte)
17
+ sequence = Sequence.new(
18
+ dicom_directory,
19
+ transfer: strategy,
20
+ reorder: options[:reorder]
21
+ )
22
+
23
+ extract_dir = options.path_option(
24
+ :output, File.join(File.expand_path(dicom_directory), 'images')
25
+ )
26
+ FileUtils.mkdir_p extract_dir
27
+
28
+ if sequence.metadata.lim_max <= 255
29
+ bits = 8
30
+ else
31
+ bits = 16
32
+ end
33
+
34
+ scaling = projection_scaling(sequence.metadata, options)
35
+ sequence.metadata.merge! scaling
36
+ scaling = Settings[scaling]
37
+
38
+ reverse_x = sequence.metadata.reverse_x.to_i == 1
39
+ reverse_y = sequence.metadata.reverse_y.to_i == 1
40
+ reverse_z = sequence.metadata.reverse_z.to_i == 1
41
+
42
+ maxx = sequence.metadata.nx
43
+ maxy = sequence.metadata.ny
44
+ maxz = sequence.metadata.nz
45
+
46
+ # minimum and maximum slices with non-(almost)-blank contents
47
+ minx_contents = maxx
48
+ maxx_contents = 0
49
+ miny_contents = maxy
50
+ maxy_contents = 0
51
+ minz_contents = maxz
52
+ maxz_contents = 0
53
+
54
+ if full_projection?(options[:axial]) || full_projection?(options[:coronal]) || full_projection?(options[:sagittal])
55
+ percent = 65
56
+ else
57
+ percent = 90
58
+ end
59
+ progress.begin_subprocess 'generating_volume', percent, maxz
60
+
61
+ # Load all the slices into a (big) 3D array
62
+ if bits == 8
63
+ # TODO: support signed too
64
+ volume = NArray.byte(maxx, maxy, maxz)
65
+ else
66
+ # With type NArray::SINT instead of NArray::INT we would use up half the
67
+ # memory, but each slice to be converted to image would have to be
68
+ # convertd to INT...
69
+ # volume = NArray.sint(maxx, maxy, maxz)
70
+ volume = NArray.int(maxx, maxy, maxz)
71
+ end
72
+ keeping_path do
73
+ sequence.each do |dicom, z, file|
74
+ slice = sequence.dicom_pixels(dicom, unsigned: true)
75
+ volume[true, true, z] = slice
76
+ if center_slice_projection?(options[:axial])
77
+ minz_contents, maxz_contents = update_min_max_contents(
78
+ z, slice.max, maxy, minz_contents, maxz_contents
79
+ )
80
+ end
81
+ progress.update_subprocess z
82
+ end
83
+ end
84
+
85
+ if center_slice_projection?(options[:coronal])
86
+ (0...maxy).each do |y|
87
+ miny_contents, maxy_contents = update_min_max_contents(
88
+ y, volume[true, y, true].max, maxy, miny_contents, maxy_contents
89
+ )
90
+ end
91
+ end
92
+
93
+ if center_slice_projection?(options[:sagittal])
94
+ (0...maxz).each do |z|
95
+ minz_contents, maxz_contents = update_min_max_contents(
96
+ z, volume[true, true, z].max, maxz, minz_contents, maxz_contents
97
+ )
98
+ end
99
+ end
100
+
101
+ if single_slice_projection?(options[:axial])
102
+ axial_zs = [options[:axial].to_i]
103
+ elsif center_slice_projection?(options[:axial])
104
+ axial_zs = [[(minz_contents+maxz_contents)/2, 'c']]
105
+ elsif middle_slice_projection?(options[:axial])
106
+ axial_zs = [[maxz/2, 'm']]
107
+ elsif full_projection?(options[:axial])
108
+ axial_zs = (0...maxz)
109
+ else
110
+ axial_zs = []
111
+ end
112
+
113
+ if single_slice_projection?(options[:sagittal])
114
+ sagittal_xs = [options[:sagittal].to_i]
115
+ elsif center_slice_projection?(options[:sagittal])
116
+ sagittal_xs = [[(minx_contents+maxx_contents)/2, 'c']]
117
+ elsif middle_slice_projection?(options[:sagittal])
118
+ sagittal_xs = [[maxx/2, 'm']]
119
+ elsif full_projection?(options[:sagittal])
120
+ sagittal_xs = (0...maxx)
121
+ else
122
+ sagittal_xs = []
123
+ end
124
+
125
+ if single_slice_projection?(options[:coronal])
126
+ coronal_ys = [options[:coronal].to_i]
127
+ elsif center_slice_projection?(options[:coronal])
128
+ coronal_ys = [[(miny_contents+maxy_contents)/2, 'c']]
129
+ elsif middle_slice_projection?(options[:coronal])
130
+ coronal_ys = [[maxy/2, 'm']]
131
+ elsif full_projection?(options[:coronal])
132
+ coronal_ys = (0...maxx)
133
+ else
134
+ coronal_ys = []
135
+ end
136
+
137
+ n = axial_zs.size + sagittal_xs.size + coronal_ys.size
138
+
139
+ progress.begin_subprocess 'generating_slices', -70, n if n > 0
140
+ axial_zs.each_with_index do |(z, suffix), i|
141
+ slice = volume[true, true, z]
142
+ output_image = output_file_name(extract_dir, 'axial_', suffix || z.to_s)
143
+ save_pixels slice, output_image,
144
+ bit_depth: bits, reverse_x: reverse_x, reverse_y: reverse_y,
145
+ cols: scaling.scaled_nx, rows: scaling.scaled_ny,
146
+ normalize: NORMALIZE_PROJECTION_IMAGES
147
+ progress.update_subprocess i
148
+ end
149
+ sagittal_xs.each_with_index do |(x, suffix), i|
150
+ slice = volume[x, true, true]
151
+ output_image = output_file_name(extract_dir, 'sagittal_', suffix || x.to_s)
152
+ save_pixels slice, output_image,
153
+ bit_depth: bits, reverse_x: !reverse_y, reverse_y: !reverse_z,
154
+ cols: scaling.scaled_ny, rows: scaling.scaled_nz,
155
+ normalize: NORMALIZE_PROJECTION_IMAGES
156
+ progress.update_subprocess axial_zs.size + i
157
+ end
158
+ coronal_ys.each_with_index do |(y, suffix), i|
159
+ slice = volume[true, y, true]
160
+ output_image = output_file_name(extract_dir, 'coronal_', suffix || y.to_s)
161
+ save_pixels slice, output_image,
162
+ bit_depth: bits, reverse_x: reverse_x, reverse_y: !reverse_z,
163
+ cols: scaling.scaled_nx, rows: scaling.scaled_nz,
164
+ normalize: NORMALIZE_PROJECTION_IMAGES
165
+ progress.update_subprocess axial_zs.size + sagittal_xs.size + i
166
+ end
167
+
168
+ n = [:axial, :coronal, :sagittal].map{ |axis|
169
+ aggregate_projection?(options[axis]) ? 1 : 0
170
+ }.inject(&:+)
171
+ progress.begin_subprocess 'generating_projections', 100, n if n > 0
172
+ i = 0
173
+
174
+ float_v = nil
175
+ if options.to_h.values_at(:axial, :coronal, :sagittal).any?{ |sel|
176
+ aggregate_projection_includes?(sel, 'aap')
177
+ }
178
+ # It's gonna take memory... (a whole lot of precious memory)
179
+ float_v ||= volume.to_type(NArray::SFLOAT)
180
+ # To enhance result contrast we will apply a gamma of x**4
181
+ float_v.mul! 1.0/float_v.max
182
+ float_v.mul! float_v
183
+ float_v.mul! float_v
184
+ end
185
+ if aggregate_projection?(options[:axial])
186
+ views = []
187
+ if aggregate_projection_includes?(options[:axial], 'aap')
188
+ slice = accumulated_attenuation_projection(
189
+ float_v, Z_AXIS, sequence.metadata.lim_max, maxz
190
+ ).to_type(volume.typecode)
191
+ views << ['aap', slice]
192
+ end
193
+ if aggregate_projection_includes?(options[:axial], 'mip')
194
+ slice = maximum_intensity_projection(volume, Z_AXIS)
195
+ views << ['mip', slice]
196
+ end
197
+ views.each do |view, slice|
198
+ output_image = output_file_name(extract_dir, 'axial_', view)
199
+ save_pixels slice, output_image,
200
+ bit_depth: bits, reverse_x: reverse_x, reverse_y: reverse_y,
201
+ cols: scaling.scaled_nx, rows: scaling.scaled_ny,
202
+ normalize: NORMALIZE_PROJECTION_IMAGES
203
+ end
204
+ i += 1
205
+ progress.update_subprocess i
206
+ end
207
+ if aggregate_projection?(options[:coronal])
208
+ views = []
209
+ if aggregate_projection_includes?(options[:coronal], 'aap')
210
+ # It's gonna take memory... (a whole lot of precious memory)
211
+ float_v ||= volume.to_type(NArray::SFLOAT)
212
+ slice = accumulated_attenuation_projection(
213
+ float_v, Y_AXIS, sequence.metadata.lim_max, maxy
214
+ ).to_type(volume.typecode)
215
+ views << ['aap', slice]
216
+ end
217
+ if aggregate_projection_includes?(options[:coronal], 'mip')
218
+ slice = maximum_intensity_projection(volume, Y_AXIS)
219
+ views << ['mip', slice]
220
+ end
221
+ views.each do |view, slice|
222
+ output_image = output_file_name(extract_dir, 'coronal_', view)
223
+ save_pixels slice, output_image,
224
+ bit_depth: bits, reverse_x: reverse_x, reverse_y: !reverse_z,
225
+ cols: scaling.scaled_nx, rows: scaling.scaled_nz,
226
+ normalize: NORMALIZE_PROJECTION_IMAGES
227
+ end
228
+ i += 1
229
+ progress.update_subprocess i
230
+ end
231
+ if aggregate_projection?(options[:sagittal])
232
+ views = []
233
+ if aggregate_projection_includes?(options[:sagittal], 'aap')
234
+ # It's gonna take memory... (a whole lot of precious memory)
235
+ float_v ||= volume.to_type(NArray::SFLOAT)
236
+ slice = accumulated_attenuation_projection(
237
+ float_v, X_AXIS, sequence.metadata.lim_max, maxx
238
+ ).to_type(volume.typecode)
239
+ views << ['aap', slice]
240
+ end
241
+ if aggregate_projection_includes?(options[:sagittal], 'mip')
242
+ slice = maximum_intensity_projection(volume, X_AXIS)
243
+ views << ['mip', slice]
244
+ end
245
+ views.each do |view, slice|
246
+ output_image = output_file_name(extract_dir, 'sagittal_', view)
247
+ save_pixels slice, output_image,
248
+ bit_depth: bits, reverse_x: !reverse_y, reverse_y: !reverse_z,
249
+ cols: scaling.scaled_ny, rows: scaling.scaled_nz,
250
+ normalize: NORMALIZE_PROJECTION_IMAGES
251
+ end
252
+ i += 1
253
+ progress.update_subprocess i
254
+ end
255
+ float_v = nil
256
+ options.save_settings 'projection', sequence.metadata
257
+ progress.finish
258
+ end
259
+
260
+ private
261
+
262
+ X_AXIS = 0
263
+ Y_AXIS = 1
264
+ Z_AXIS = 2
265
+
266
+ def update_min_max_contents(pos, max, ref_max, current_min, current_max)
267
+ if max/ref_max.to_f >= 0.05
268
+ current_min = [current_min, pos].min
269
+ current_max = [current_max, pos].max
270
+ end
271
+ [current_min, current_max]
272
+ end
273
+
274
+ def maximum_intensity_projection(v, axis)
275
+ v.max(axis)
276
+ end
277
+
278
+ def accumulated_attenuation_projection(float_v, axis, max_output_level, max=500)
279
+ k = 0.02
280
+ if ADJUST_AAP_FOR_WIDTH
281
+ k *= 500.0/max
282
+ end
283
+ v = float_v.sum(axis)
284
+ v.mul! -k
285
+ v = NMath.exp(v)
286
+ # Invert result (from attenuation to transmission)
287
+ v.mul! -max_output_level
288
+ v.add! max_output_level
289
+ v
290
+ end
291
+
292
+ def axis_index(v, maxv, reverse)
293
+ reverse ? maxv - v : v
294
+ end
295
+
296
+ def aggregate_projection?(axis_selection)
297
+ axis_selection && axis_selection.split(',').any? { |sel|
298
+ ['*', 'mip', 'aap'].include?(sel)
299
+ }
300
+ end
301
+
302
+ def full_projection?(axis_selection)
303
+ axis_selection && axis_selection.split(',').any? { |sel| sel == '*' }
304
+ end
305
+
306
+ def single_slice_projection?(axis_selection)
307
+ axis_selection.is_a?(String) && /\A\d+\Z/i =~ axis_selection
308
+ end
309
+
310
+ def center_slice_projection?(axis_selection)
311
+ axis_selection && axis_selection.split(',').any? { |sel| sel.downcase == 'c' }
312
+ end
313
+
314
+ def middle_slice_projection?(axis_selection)
315
+ axis_selection && axis_selection.split(',').any? { |sel| sel.downcase == 'm' }
316
+ end
317
+
318
+ def aggregate_projection_includes?(axis_selection, projection)
319
+ axis_selection && axis_selection.split(',').any? { |sel|
320
+ sel == projection
321
+ }
322
+ end
323
+
324
+ def projection_scaling(data, options = {})
325
+ sx = sy = sz = 1
326
+
327
+ nx = data.nx
328
+ ny = data.ny
329
+ nz = data.nz
330
+ dx = data.dx
331
+ dy = data.dy
332
+ dz = data.dz
333
+
334
+ unless dx == dy && dy == dz
335
+ # need to cope with different scales in different axes.
336
+ # will always produce shrink factors (<1)
337
+ ref = [dx, dy, dz].max.to_f
338
+ sx = dx/ref
339
+ sy = dy/ref
340
+ sz = dz/ref
341
+ end
342
+
343
+ scaled_nx = (nx*sx).round
344
+ scaled_ny = (ny*sy).round
345
+ scaled_nz = (nz*sz).round
346
+
347
+ # further shrinking may be needed to avoid any projection
348
+ # to be larger thant the maximum image size
349
+ # Axis X is the columns of axial an coronal views
350
+ # Axis Y is the rows of axial and the columns of sagittal
351
+ # Axis Z is the rows of coronal and sagittal views
352
+ max_nx = [scaled_nx, options.max_x_pixels, options[:maxcols]].compact.min
353
+ if scaled_nx > max_nx
354
+ scaled_nx = max_nx
355
+ sx = scaled_nx/nx.to_f
356
+ end
357
+
358
+ max_ny = [scaled_ny, options.max_y_pixels, options[:maxcols], options[:maxrows]].compact.min
359
+ if scaled_ny > max_ny
360
+ scaled_ny = max_ny
361
+ sy = scaled_ny/ny.to_f
362
+ end
363
+
364
+ max_nz = [scaled_nz, options.max_z_pixels, options[:maxrows]].compact.min
365
+ if scaled_nz > max_nz
366
+ scaled_nz = max_nz
367
+ sz = scaled_nz/nz.to_f
368
+ end
369
+
370
+ {
371
+ scale_x: sx, scale_y: sy, scale_z: sz,
372
+ scaled_nx: scaled_nx, scaled_ny: scaled_ny, scaled_nz: scaled_nz
373
+ }
374
+ end
375
+
376
+ def save_pixels(pixels, output_image, options = {})
377
+ bits = options[:bit_depth] || 16
378
+ reverse_x = options[:reverse_x]
379
+ reverse_y = options[:reverse_y]
380
+ normalize = options[:normalize]
381
+
382
+ # max image size
383
+ scaled_columns = options[:cols]
384
+ scaled_rows = options[:rows]
385
+
386
+ columns, rows = pixels.shape
387
+
388
+ if ASSIGN_IMAGE_PIXELS_AS_ARRAY
389
+ # assign from array
390
+ if Magick::MAGICKCORE_QUANTUM_DEPTH != bits
391
+ if bits == 8
392
+ # scale up the data
393
+ pixels = pixels.to_type(NArray::INT)
394
+ pixels.mul! 256
395
+ else
396
+ # scale down
397
+ pixels.div! 256
398
+ pixels = pixels.to_type(NArray::BYTE) # FIXME: necessary?
399
+ end
400
+ end
401
+ image = Magick::Image.new(columns, rows).import_pixels(0, 0, columns, rows, 'I', pixels.flatten)
402
+ else
403
+ # Pack to a String (blob) and let Magick do the conversion
404
+ if bits == 8
405
+ rm_type = Magick::CharPixel
406
+ blob = pixels.flatten.to_a.pack('C*')
407
+ else
408
+ rm_type = Magick::ShortPixel
409
+ blob = pixels.flatten.to_a.pack('S<*')
410
+ end
411
+ image = Magick::Image.new(columns, rows).import_pixels(0, 0, columns, rows, 'I', blob, rm_type)
412
+ end
413
+ image.flip! if reverse_y
414
+ image.flop! if reverse_x
415
+ image = image.normalize if normalize
416
+ if scaled_columns != columns || scaled_rows != rows
417
+ image = image.resize(scaled_columns, scaled_rows)
418
+ end
419
+ image.write(output_image)
420
+ end
421
+
422
+ end