dicoms 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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