dicoms 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,339 @@
1
+ class DicomS
2
+ # Base class for Transfer strategy classes that define how
3
+ # the values of DICOM pixels are scales to be used as image pixels
4
+ # or to be processed as data (generic presentation values).
5
+ #
6
+ # Different strategies determine how much of the original
7
+ # data dynamic range is preserved.
8
+ #
9
+ # All the Transfer-derived classes can pass an :output option to the base
10
+ # which changes output range limits from what is stored in the DICOM.
11
+ # Two values are supported:
12
+ #
13
+ # * :byte Output consist of single byte values (0-255)
14
+ # * :unsigned Output is always unsigned
15
+ #
16
+ # The method min_max(sequence) of each class returns the minimum and maximum
17
+ # values which are mapped to the output limits.
18
+ # These values may be raw or rescaled depending on the min_max_rescaled? method
19
+ #
20
+ class Transfer
21
+ USE_DATA = false
22
+
23
+ include Support
24
+ extend Support
25
+
26
+ def initialize(options = {})
27
+ @output = options[:output]
28
+ end
29
+
30
+ # Remapped DICOM pixel values as an Image
31
+ def image(dicom, min, max)
32
+ assign_dicom_pixels dicom, pixels(dicom, min, max)
33
+ dicom.image
34
+ end
35
+
36
+ # Remapped DICOM pixel values as an NArray
37
+ def pixels(dicom, min, max)
38
+ processed_data(dicom, min, max)
39
+ end
40
+
41
+ def self.strategy(strategy, options = {})
42
+ if strategy.is_a?(Array) && options.empty?
43
+ strategy, options = strategy
44
+ end
45
+ return nil if strategy.nil?
46
+ case strategy.to_sym
47
+ when :fixed
48
+ strategy_class = FixedTransfer
49
+ when :window
50
+ strategy_class = WindowTransfer
51
+ when :first
52
+ strategy_class = FirstTransfer
53
+ when :global
54
+ strategy_class = GlobalTransfer
55
+ when :sample
56
+ strategy_class = SampleTransfer
57
+ when :identity
58
+ strategy_class = IdentityTransfer
59
+ else
60
+ raise "INVALID: #{strategy.inspect}"
61
+ end
62
+ strategy_class.new options
63
+ end
64
+
65
+ # absolute output limits of the range (raw, not rescaled)
66
+ def min_max_limits(dicom)
67
+ case @output
68
+ when :byte
69
+ [0, 255]
70
+ when :unsigned
71
+ min, max = Transfer.min_max_limits(dicom)
72
+ if min < 0
73
+ min = 0
74
+ max -= min
75
+ end
76
+ [min, max]
77
+ else
78
+ Transfer.min_max_limits(dicom)
79
+ end
80
+ end
81
+
82
+ def self.min_max_limits(dicom)
83
+ num_bits = dicom_bit_depth(dicom)
84
+ signed = dicom_signed?(dicom)
85
+ pixel_value_range(num_bits, signed)
86
+ end
87
+
88
+ FLOAT_MAPPING = true
89
+
90
+ end
91
+
92
+ # Apply window-clipping; also
93
+ # always apply rescale (remap)
94
+ class WindowTransfer < Transfer
95
+
96
+ def initialize(options = {})
97
+ @center = options[:center]
98
+ @width = options[:width]
99
+ super options
100
+ end
101
+
102
+ def min_max(sequence)
103
+ # TODO: use options to sample/take first/take all?
104
+ dicom = sequence.first
105
+ data_range dicom
106
+ end
107
+
108
+ def min_max_rescaled?
109
+ true
110
+ end
111
+
112
+ def processed_data(dicom, min, max)
113
+ center = (min + max)/2
114
+ width = max - min
115
+ data = dicom_narray(dicom, level: [center, width])
116
+ map_to_output dicom, data, min, max
117
+ end
118
+
119
+ # def image(dicom, min, max)
120
+ # center = (min + max)/2
121
+ # width = max - min
122
+ # dicom.image(level: [center, width]).normalize
123
+ # end
124
+
125
+ private
126
+
127
+ def map_to_output(dicom, data, min, max)
128
+ output_min, output_max = min_max_limits(dicom)
129
+ output_range = output_max - output_min
130
+ input_range = max - min
131
+ float_arith = FLOAT_MAPPING || output_range < input_range
132
+ data_type = data.typecode
133
+ data = data.to_type(NArray::SFLOAT) if float_arith
134
+ data.sbt! min
135
+ data.mul! (output_range).to_f/(input_range)
136
+ data.add! output_min
137
+ data = data.to_type(data_type) if float_arith
138
+ data
139
+ end
140
+
141
+ def data_range(dicom)
142
+ if USE_DATA
143
+ if @center && @width
144
+ level = [@center, @width]
145
+ else
146
+ level = true
147
+ end
148
+ data = dicom_narray(dicom, level: level)
149
+ [data.min, data.max]
150
+ else
151
+ center = @center || dicom_window_center(dicom)
152
+ width = @width || dicom_window_width(dicom)
153
+ low = center - width/2
154
+ high = center + width/2
155
+ [low, high]
156
+ end
157
+ end
158
+
159
+ end
160
+
161
+ # These strategies
162
+ # have optional dropping of the lowest level (base),
163
+ # photometric rescaling, extension factor
164
+ # and map the minimum/maximum input values
165
+ # (determined by the particular strategy and files)
166
+ # to the minimum/maximum output levels (black/white)
167
+ #
168
+ # +:ignore_min+ is used to ignore the minimum level present in the data
169
+ # and look for the next minimum value. Usually the absolute minimum
170
+ # corresponds to parts of the image outside the registered area and has
171
+ # typically the value -2048 for CT images. The next minimum value
172
+ # usually corresponds to air and is what's taken as the image's minimum
173
+ # when this option is set to +true+.
174
+ #
175
+ class RangeTransfer < Transfer
176
+
177
+ def initialize(options = {})
178
+ options = { ignore_min: true }.merge(options)
179
+ @rescale = options[:rescale]
180
+ @ignore_min = options[:ignore_min]
181
+ @extension_factor = options[:extend] || 0.0
182
+ super options
183
+ end
184
+
185
+ def min_max(sequence)
186
+ v0 = minimum = maximum = nil
187
+ select_dicoms(sequence) do |d|
188
+ d_v0, d_min, d_max = data_range(d)
189
+ v0 ||= d_v0
190
+ minimum ||= d_min
191
+ maximum ||= d_max
192
+ v0 = d_v0 if v0 && d_v0 && v0 > d_v0
193
+ minimum = d_min if minimum > d_min
194
+ maximum = d_max if maximum < d_max
195
+ end
196
+ [minimum, maximum]
197
+ end
198
+
199
+ def min_max_rescaled?
200
+ @rescale
201
+ end
202
+
203
+ def processed_data(dicom, min, max)
204
+ output_min, output_max = min_max_limits(dicom)
205
+ output_range = output_max - output_min
206
+ input_range = max - min
207
+ float_arith = FLOAT_MAPPING || output_range < input_range
208
+ data = dicom_narray(dicom, level: false, remap: @rescale)
209
+ data_type = data.typecode
210
+ data = data.to_type(NArray::SFLOAT) if float_arith
211
+ data.sbt! min
212
+ data.mul! output_range/input_range.to_f
213
+ data.add! output_min
214
+ data[data < output_min] = output_min
215
+ data[data > output_max] = output_max
216
+ data = data.to_type(data_type) if float_arith
217
+ data
218
+ end
219
+
220
+ private
221
+
222
+ def data_range(dicom)
223
+ data = dicom_narray(dicom, level: false, remap: @rescale)
224
+ base = nil
225
+ minimum = data.min
226
+ maximum = data.max
227
+ if @ignore_min
228
+ base = minimum
229
+ minimum = (data[data > base].min)
230
+ end
231
+ if @extension_factor != 0
232
+ # extend the range
233
+ minimum, maximum = extend_data_range(@extension_factor, base, minimum, maximum)
234
+ end
235
+ [base, minimum, maximum]
236
+ end
237
+
238
+ def extend_data_range(k, base, minimum, maximum)
239
+ k += 1.0
240
+ c = (maximum + minimum)/2
241
+ minimum = (c + k*(minimum - c)).round
242
+ maximum = (c + k*(maximum - c)).round
243
+ if base
244
+ minimum = base + 1 if minimum <= base
245
+ end
246
+ [minimum, maximum]
247
+ end
248
+
249
+ end
250
+
251
+ class FixedTransfer < RangeTransfer
252
+
253
+ def initialize(options = {})
254
+ @fixed_min = options[:min] || -2048
255
+ @fixed_max = options[:max] || +2048
256
+ options[:ignore_min] = false
257
+ options[:extend] = nil
258
+ unless options.key?(:rescale)
259
+ options[:rescale] = true
260
+ end
261
+ super options
262
+ end
263
+
264
+ def min_max(sequence)
265
+ # TODO: set default min, max regarding dicom data type
266
+ [@fixed_min, @fixed_max]
267
+ end
268
+
269
+ end
270
+
271
+ class GlobalTransfer < RangeTransfer
272
+
273
+ private
274
+
275
+ def select_dicoms(sequence, &blk)
276
+ sequence.each &blk
277
+ end
278
+
279
+ end
280
+
281
+ class FirstTransfer < RangeTransfer
282
+
283
+ def initialize(options = {})
284
+ extend = options[:extend] || 0.3
285
+ super options.merge(extend: extend)
286
+ end
287
+
288
+ private
289
+
290
+ def select_dicoms(sequence, &blk)
291
+ blk[sequence.first] if sequence.size > 0
292
+ end
293
+
294
+ end
295
+
296
+ class SampleTransfer < RangeTransfer
297
+
298
+ def initialize(options = {})
299
+ @max_files = options[:max_files] || 8
300
+ super options
301
+ end
302
+
303
+ private
304
+
305
+ def select_dicoms(sequence, &blk)
306
+ n = [sequence.size, @max_files].min
307
+ (0...sequence.size).to_a.sample(n).sort.each do |i|
308
+ blk[sequence.dicom(i)]
309
+ end
310
+ end
311
+
312
+ end
313
+
314
+ # Preserve internal values.
315
+ # Can be used with the output: :unsinged option
316
+ # to convert signed values to unsinged.
317
+ class IdentityTransfer < FixedTransfer
318
+ def initialize(options = {})
319
+ super options
320
+ end
321
+
322
+ def min_max(sequence)
323
+ min_max_limits(sequence.first)
324
+ end
325
+
326
+ def map_to_output(dicom, data, min, max)
327
+ if @rescale
328
+ intercept = dicom_rescale_intercept(dicom)
329
+ slope = dicom_rescale_slope(dicom)
330
+ if slope != 1 || intercept != 0
331
+ return super
332
+ end
333
+ end
334
+ data = dicom_narray(dicom)
335
+ data.add! -min if min < 0
336
+ data
337
+ end
338
+ end
339
+ end
@@ -0,0 +1,209 @@
1
+ class DicomS
2
+ ELEMENTS_TO_REMOVE = %w(0028,2110 0028,2112 0018,1151 0018,1152 0028,1055 7FE0,0000 7FE0,0010)
3
+
4
+ # When generating DICOMs back:
5
+ # Elements that must be replaced (because image format is not preserved)
6
+ # 0002,0010 Transfer Syntax UID
7
+ # replace by 1.2.840.10008.1.2.1 Implicit VR Little Endian
8
+ # or maybe 1.2.840.10008.1.2: Implicit VR Little Endian: Default Transfer Syntax for DICOM
9
+ # these should be removed if present:
10
+ # 0028,2110 Lossy Image Compression
11
+ # 0028,2112 Lossy Image Compression Ratio
12
+ # we need to adjust metadata elements (which refer to first slice)
13
+ # to each slice.
14
+ # elements that vary from slice to slice but whose variation probably doesn't matter:
15
+ # 0008,0033 Content Time
16
+ # elements that vary and should be removed:
17
+ # 0018,1151 X-Ray Tube Current
18
+ # 0018,1152 Exposure
19
+ # elements that should be adjusted:
20
+ # 0020,0013 Instance Number # increment by 1 for each slice
21
+ # 0020,0032 Image Position (Patient) # should be computed from additional metadata (dz)
22
+ # 0020,1041 Slice Location # should be computed from additional metadata (dz)
23
+ # elements that may need adjusting depending on value restoration method:
24
+ # 0028,0100 Bits Allocated (keep if value size is maintained)
25
+ # 0028,0101 Bits Stored make = Bits Allocated
26
+ # 0028,0102 High Bit make = BIts Stored - 1
27
+ # 0028,0103 Pixel Representation 0-unsigned 1-signed
28
+ # 0028,1050 Window Center
29
+ # 0028,1051 Window Width
30
+ # 0028,1052 Rescale Intercept
31
+ # 0028,1053 Rescale Slope
32
+ # 0028,1054 Rescale Type
33
+ # 0028,1055 Window Center & Width Explanation - can be removed if present
34
+ # elements that shouldn't vary:
35
+ # 0028,0002 Samples per Pixel = 1
36
+ # 0028,0004 Photometric Interpretation = MONOCHROME2
37
+ # also, these element should be removed (because Pixel data is set by assigning an image)
38
+ # 7FE0,0000 Group Length - can be omitted
39
+ # 7FE0,0010 Pixel Data - will be assigned by assigning an image
40
+ # other varying elements that need further study: (vary in some studies)
41
+ # 0002,0000 File Meta Information Group Length # drop this and 0002,0001?
42
+ # 0002,0003 Media Storage SOP Instance UID
43
+ # 0008,0018 SOP Instance UID
44
+
45
+ def unpack(pack_file, options = {})
46
+ options = CommandOptions[options]
47
+
48
+ progress = Progress.new('unpacking', options)
49
+
50
+ unpack_dir = options.path_option(:output,
51
+ File.basename(pack_file, '.mkv')
52
+ )
53
+ FileUtils.mkdir_p unpack_dir
54
+
55
+ prefix = File.basename(pack_file, '.mkv')
56
+ output_file_pattern = File.join(unpack_dir, "#{prefix}-%3d.jpeg")
57
+
58
+ progress.begin_subprocess 'extracting_images', -70
59
+ ffmpeg = SysCmd.command('ffmpeg', @ffmpeg_options) do
60
+ option '-y' # overwrite existing files
61
+ option '-hide_banner'
62
+ option '-loglevel', 'quiet'
63
+ option '-i', file: pack_file
64
+ option '-q:v', 2
65
+ file output_file_pattern
66
+ end
67
+ ffmpeg.run error_output: :separate
68
+ check_command ffmpeg
69
+
70
+ progress.begin_subprocess 'extracting_metadata', -10
71
+ metadata_file = File.join(unpack_dir, 'metadata.txt')
72
+ ffmpeg = SysCmd.command('ffmpeg', @ffmpeg_options) do
73
+ option '-y' # overwrite existing files
74
+ option '-hide_banner'
75
+ option '-loglevel', 'quiet'
76
+ option '-i', file: pack_file
77
+ option '-f', 'ffmetadata'
78
+ file metadata_file
79
+ end
80
+ ffmpeg.run error_output: :separate
81
+ check_command ffmpeg
82
+
83
+ dicom_elements, metadata = meta_codec.read_metadata(metadata_file)
84
+ metadata = cast_metadata(metadata)
85
+
86
+ metadata_yaml = File.join(unpack_dir, 'metadata.yml')
87
+ File.open(metadata_yaml, 'w') do |yaml|
88
+ yaml.write metadata.to_yaml
89
+ end
90
+
91
+ if options[:dicom_output]
92
+ dicom_directory = options.path_option(:dicom_output,
93
+ 'DICOM'
94
+ )
95
+ img_files = File.join(unpack_dir, "#{prefix}-*.jpeg")
96
+ if options.roi
97
+ firstx, lastx, firsty, lasty, firstz, lastz = options.roi
98
+ columns = lastx - firstx + 1
99
+ rows = lasty - firsty + 1
100
+ else
101
+ rows = metadata.ny
102
+ columns = metadata.nx
103
+ end
104
+ progress.begin_subprocess 'generating_dicoms', 100, img_files.size
105
+ count = 0
106
+ pos = 0.0
107
+ slice_pos = 0.0
108
+ Dir[img_files].each do |fn, i|
109
+ count += 1
110
+ dicom_file = File.join(dicom_directory, File.basename(fn, '.jpeg')+'.dcm')
111
+ image = Magick::Image::read(fn).first
112
+ image_columns = image.columns
113
+ image_rows = image.rows
114
+ if image_columns != columns || image_rows != rows
115
+ raise "Inconsistent image size"
116
+ end
117
+ dicom = DICOM::DObject.new
118
+ dicom_elements.each do |element|
119
+ case element.tag
120
+ when '0002,0010'
121
+ # TODO: replace value by 1.2.840.10008.1.2.1
122
+ when *ELEMENTS_TO_REMOVE
123
+ element = nil
124
+ when '0020,0013'
125
+ element.value = count
126
+ when '0020,0032'
127
+ if count == 1
128
+ pos = element.value.split('\\').map(&:to_f)
129
+ else
130
+ pos[2] += metadata.dz
131
+ element.value = pos.join('\\')
132
+ end
133
+ when '0020,1041'
134
+ if count == 1
135
+ slice_pos = element.value.to_f
136
+ else
137
+ slice_pos += metadata.dz
138
+ element.value = slice_pos.to_f
139
+ end
140
+ when '0028,0010'
141
+ element.value = image_rows
142
+ when '0028,0011'
143
+ element.value = image_columns
144
+ when '0028,0101'
145
+ element.value = metadata.bits
146
+ when '0028,0102'
147
+ element.value = metadata.bits - 1
148
+ when '0028,0103'
149
+ element.value = metadata.signed
150
+ end
151
+ if element
152
+ DICOM::Element.new(element.tag, element.value, :parent => dicom)
153
+ end
154
+ end
155
+ dicom.pixels = image_to_dicom_pixels(metadata, image)
156
+ dicom.write dicom_file
157
+ progress.update_subprocess count
158
+ end
159
+ end
160
+ progress.finish
161
+ end
162
+
163
+ def image_to_dicom_pixels(metadata, image)
164
+ min_v = metadata.min # value assigned to black
165
+ max_v = metadata.max # value assigned to white
166
+ if metadata.rescaled.to_i == 1
167
+ slope = metadata.slope
168
+ intercept = metadata.intercept
169
+ if slope != 1 || intercept != 0
170
+ # unscale
171
+ min_v = (min_v - intercept)/slope
172
+ max_v = (max_v - intercept)/slope
173
+ end
174
+ end
175
+ pixels = image.export_pixels(0, 0, image.columns, image.rows, 'I')
176
+ pixels = NArray.to_na(pixels).reshape!(image.columns, image.rows)
177
+ pixels = pixels.to_type(NArray::SFLOAT)
178
+ q = Magick::MAGICKCORE_QUANTUM_DEPTH
179
+ min_p, max_p = pixel_value_range(q, false)
180
+ # min_p => min_v; max_p => max_v
181
+ # pixels.sbt! min_p # not needed, min_p should be 0
182
+ pixels.mul! (max_v - min_v).to_f/(max_p - min_p)
183
+ pixels.add! min_v
184
+ bits = metadata.bits # original bit depth, in accordance with '0028,0100' if dicom metatada present
185
+ signed = metadata.signed == 1 ? true : false # in accordance with 0028,0103 if dicom metadata
186
+ if true
187
+ pixels = pixels.to_i
188
+ else
189
+ if bits > 16
190
+ if signed
191
+ pixels = pixels.to_type(3) # sint (signed, four bytes)
192
+ else
193
+ pixels = pixels.to_type(4) # sfloat (single precision float)
194
+ end
195
+ elsif bits > 8
196
+ if signed
197
+ pixels = pixels.to_type(2) # int (signed, two bytes)
198
+ else
199
+ pixels = pixels.to_type(3) # sint (signed, four bytes)
200
+ end
201
+ elsif signed
202
+ pixels = pixels.to_type(2) # sint (signed, two bytes)
203
+ else
204
+ pixels = pixels.to_type(1) # byte (unsiged)
205
+ end
206
+ end
207
+ pixels
208
+ end
209
+ end