dicoms 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,46 @@
1
+ class DicomS
2
+ # remap the dicom values of a set of images to maximize dynamic range
3
+ # and avoid negative values
4
+ def remap(dicom_directory, options = {})
5
+ options = CommandOptions[options]
6
+
7
+ progress = Progress.new('remapping', options)
8
+ progress.begin_subprocess 'reading_metadata', 2
9
+
10
+ output_dir = options.path_option(:output,
11
+ File.join(File.dirname(File.expand_path(dicom_directory)), File.basename(dicom_directory)+'_remapped')
12
+ )
13
+ FileUtils.mkdir_p output_dir
14
+
15
+ strategy = define_transfer(options, :identity)
16
+ sequence = Sequence.new(dicom_directory, transfer: strategy)
17
+
18
+ dd_hack = options[:even_size]
19
+ # Hack to solve problem with some DICOMS having different header size
20
+ # (incovenient for some tests) due to differing 0008,2111 element
21
+
22
+ dd = nil if dd_hack
23
+
24
+ progress.begin_subprocess 'remapping_slices', 100, sequence.size
25
+ sequence.each do |dicom, i, file|
26
+ if dd_hack
27
+ dd ||= dicom.derivation_description
28
+ dicom.derivation_description = dd
29
+ end
30
+ data = sequence.dicom_pixels(dicom)
31
+ lim_min, lim_max = strategy.min_max_limits(dicom)
32
+ if lim_min >= 0
33
+ dicom.pixel_representation = 0
34
+ else
35
+ dicom.pixel_representation = 1
36
+ end
37
+ dicom.window_center = (lim_max + lim_min) / 2
38
+ dicom.window_width = (lim_max - lim_min)
39
+ assign_dicom_pixels dicom, data
40
+ output_file = File.join(output_dir, File.basename(file))
41
+ dicom.write output_file
42
+ progress.update_subprocess i
43
+ end
44
+ progress.finish
45
+ end
46
+ end
@@ -0,0 +1,415 @@
1
+ require 'matrix'
2
+
3
+ class DicomS
4
+ # DICOM series containing a sequence of CT/MRI image slices.
5
+ #
6
+ # The following metadata about the series is computed:
7
+ #
8
+ # * min: pixel value corresponding to the minimum output level
9
+ # * max: pixel value corresponding to the maximum output level
10
+ # * rescaled: 1 means that min and max are photometrically rescaled
11
+ # values; 0 means they are internal raw values.
12
+ # * slope, intercept: photometric rescaling parameters.
13
+ # * lim_min, lim_max: (raw) output enconding range limits
14
+ # * bits: bit-depth of the original pixel values
15
+ # * signed: 1-pixels values are signed 0-unsigned
16
+ # * firstx: first X coordinate (slice number) of the ROI
17
+ # * lastx: lastt X coordinate (slice number) of the ROI
18
+ # * firsty: first Y coordinate of the ROI
19
+ # * lasty: last Y coordinate of the ROI
20
+ # * lastz:lastt Z coordinate of the ROI
21
+ # * study_id, series_id: identification of the patient's study/series
22
+ # * dx, dy, dz: voxel spacing (dx, dy is pixel spacing; dz is the slice spacing)
23
+ # * x, y, z: image position (in RCS; see below)
24
+ # * xaxis, yaxis, zaxis: each is a three-component vector defining an axis
25
+ # of the image orientation (see below) (encoded as comma-separated string)
26
+ # * slice_z: slice position
27
+ # * nx: number of columns
28
+ # * ny: number of rows
29
+ # * nz: number of slices
30
+ # * reverse_x 1 means the X axis is reversed (from RCS)
31
+ # * reverse_y 1 means the Y axis is reversed (from RCS)
32
+ # * reverse_< 1 means the Z axis is reversed (from RCS)
33
+ # * axial_sx scale factor: horizontal reduction of axial projections
34
+ # * axial_sy scale factor: vertical reduction of axial projections
35
+ # * coronal_sx scale factor: horizontal reduction of coronal projections
36
+ # * coronal_sy scale factor: vertical reduction of coronal projections
37
+ # * sagittal_sx scale factor: horizontal reduction of sagittal projections
38
+ # * sagittal_sy scale factor: vertical reduction of sagittal projections
39
+ #
40
+ # Orientation of RCS
41
+ # (patient coordinate system) in relation to the DICOM sequence
42
+ # reference. This is given as three vectors xaxis yaxis and zaxis.
43
+ #
44
+ # The RCS system consists of the axes X, Y, Z:
45
+ #
46
+ # * X increases from Right to Left of the patient
47
+ # * Y increases from the Anterior to the Posterior side
48
+ # * Z increases from the Inferior to the Superior side
49
+ #
50
+ # (Inferior/Superior are sometimes referred to as Bottom/Top or Feet/Head)
51
+ #
52
+ # The xaxis vector is an unitary vector in the X direction
53
+ # projected in the DICOM reference system x, y, z axes.
54
+ # Similarly, yaxis and zaxis are unitary vectors in the Y and
55
+ # Z directions projected into the DICO reference system.
56
+ #
57
+ # The DICOM reference uses these axes:
58
+ #
59
+ # * x left to right pixel matrix column
60
+ # * y top to bottom pixel matrix row
61
+ # * z first to last slice
62
+ #
63
+ # The most common orientation for CT is:
64
+ #
65
+ #  * xaxis: 1,0,0
66
+ # * yaxis: 0,1,0
67
+ # * zaxis: 0,0,-1
68
+ #
69
+ # In this case, the X and Y axes are coincident with x an y of the
70
+ # DICOM reference and slices are ordered in decreasing Z value.
71
+ #
72
+ class Sequence
73
+ def initialize(dicom_directory, options = {})
74
+ @roi = options[:roi]
75
+ @files = find_dicom_files(dicom_directory)
76
+ @visitors = Array(options[:visit])
77
+ @visited = Array.new(@files.size)
78
+ @metadata = nil
79
+ @strategy = options[:transfer]
80
+ @metadata = Settings[version: 'DSPACK1']
81
+
82
+ # TODO: reuse existing metadata in options (via settings)
83
+
84
+ if options[:reorder]
85
+ # don't trust the file ordering; use the instance number to ordering
86
+ # this requires reading all the files in advance
87
+ compute_metadata! true
88
+ else
89
+ # if information about the series size (nx, ny, nz)
90
+ # and the axis orientation (reverse_x, reverse_y, reverse_z)
91
+ # is available here we can set the @roi now and avoid
92
+ # processing slices outside it. (in that case the
93
+ # metadata won't consider those slices, e.g. for minimum/maximum
94
+ # pixel values)
95
+ if options[:nx] && options[:ny] && options[:nz] &&
96
+ options.to_h.has_key?(:reverse_x) &&
97
+ options.to_h.has_key?(:reverse_y) &&
98
+ options.to_h.has_key?(:reverse_z)
99
+ @metadata.merge!(
100
+ nx: options[:nx], ny: options[:ny], nz: options[:nz],
101
+ reverse_x: options[:reverse_x],
102
+ reverse_y: options[:reverse_y],
103
+ reverse_z: options[:reverse_z]
104
+ )
105
+ set_cropping_volume!
106
+ cropping_set = true
107
+ end
108
+ compute_metadata!
109
+ end
110
+
111
+ set_cropping_volume! unless cropping_set
112
+ end
113
+
114
+ attr_reader :files, :strategy
115
+ attr_accessor :metadata
116
+ attr_reader :image_cropping
117
+
118
+ def transfer
119
+ @strategy
120
+ end
121
+
122
+ include Support
123
+
124
+ def size
125
+ @files.size
126
+ end
127
+
128
+ def dicom(i)
129
+ # TODO: support caching strategies for reading as DICOM objects:
130
+ # no-caching, max-size cache, ...
131
+ dicom = DICOM::DObject.read(@files[i])
132
+ sop_class = dicom['0002,0002'].value
133
+ unless sop_class == '1.2.840.10008.5.1.4.1.1.2'
134
+ raise "Unsopported SOP Class #{sop_class}"
135
+ end
136
+ # TODO: require known SOP Class:
137
+ # (in tag 0002,0002, Media Storage SOP Class UID)
138
+
139
+ visit dicom, i, *@visitors
140
+ dicom
141
+ end
142
+
143
+ def first
144
+ dicom(0)
145
+ end
146
+
147
+ def last
148
+ dicom(size-1)
149
+ end
150
+
151
+ def each(&blk)
152
+ (0...@files.size).each do |i|
153
+ dicom = dicom(i)
154
+ visit dicom, i, blk
155
+ end
156
+ end
157
+
158
+ def save_jpg(dicom, filename)
159
+ keeping_path do
160
+ image = dicom_image(dicom)
161
+ image.write(filename)
162
+ end
163
+ end
164
+
165
+ def dicom_image(dicom)
166
+ if dicom.is_a?(Magick::Image)
167
+ image = dicom
168
+ else
169
+ if @strategy
170
+ image = @strategy.image(dicom, metadata.min, metadata.max)
171
+ else
172
+ image = dicom.image
173
+ end
174
+ end
175
+ if DICOM.image_processor == :mini_magick
176
+ image.format('jpg')
177
+ end
178
+ if @image_cropping
179
+ @image_cropping.inspect
180
+ firstx, lastx, firsty, lasty = @image_cropping
181
+ image.crop! firstx, firsty, lastx-firstx+1, lasty-firsty+1
182
+ end
183
+ image
184
+ end
185
+
186
+ # To use the pixels to be directly saved to an image,
187
+ # use the `:unsigned` option to obtain usigned intensity values.
188
+ # If the pixels are to be assigned as Dicom pixels
189
+ # ('Dicom#pixels=') they don't need to be unsigned.
190
+ def dicom_pixels(dicom, options = {})
191
+ if @strategy
192
+ pixels = @strategy.pixels(dicom, metadata.min, metadata.max)
193
+ else
194
+ pixels = dicom_narray(dicom, level: false, remap: true)
195
+ end
196
+ if @image_cropping
197
+ firstx, lastx, firsty, lasty = @image_cropping
198
+ pixels = pixels[firstx..lastx, firsty..lasty]
199
+ end
200
+ if options[:unsigned] && metadata.lim_min < 0
201
+ pixels.add! -metadata.lim_min
202
+ end
203
+ pixels
204
+ end
205
+
206
+ # Check if the images belong to a single series
207
+ def check_series
208
+ visit_all
209
+ @visited.reject { |name, study, series, instance| study == @metadata.study_id && series == @metadata.series_id }.empty?
210
+ end
211
+
212
+ def reorder!
213
+ # This may invalidate dz and the cropped selection of slices
214
+ visit_all
215
+ @visited.sort_by! { |name, study, series, instance| [study, series, instance] }
216
+ @files = @visited.map { |name, study, series, instance| name }
217
+ end
218
+
219
+ def needs_reordering?
220
+ visit_all
221
+ @visited.sort_by! { |name, study, series, instance| [study, series, instance] }
222
+ @files != @visited.map { |name, study, series, instance| name }
223
+ end
224
+
225
+ private
226
+
227
+ def compute_metadata!(all = false)
228
+ first_i = nil
229
+ last_i = nil
230
+ first_md = last_md = nil
231
+ lim_min = lim_max = nil
232
+ study_id = series_id = nil
233
+ bits = signed = nil
234
+ slope = intercept = nil
235
+
236
+ @visitors.push -> (dicom, i, filename) {
237
+ unless @visited[i]
238
+ unless lim_min
239
+ if @strategy
240
+ lim_min, lim_max = @strategy.min_max_limits(dicom)
241
+ else
242
+ lim_min, lim_max = Transfer.min_max_limits(dicom)
243
+ end
244
+ end
245
+ if bits
246
+ if bits != dicom_bit_depth(dicom) || signed != dicom_signed?(dicom)
247
+ raise "Inconsistent slices"
248
+ end
249
+ else
250
+ bits = dicom_bit_depth(dicom)
251
+ signed = dicom_signed?(dicom)
252
+ end
253
+ if slope
254
+ if slope != dicom_rescale_slope(dicom) || intercept != dicom_rescale_intercept(dicom)
255
+ raise "Inconsitent slices"
256
+ end
257
+ else
258
+ slope = dicom_rescale_slope(dicom)
259
+ intercept = dicom_rescale_intercept(dicom)
260
+ end
261
+ slice_study_id = dicom.study_id.value # 0020,0010 SH (short string)
262
+ slice_series_id = dicom.series_number.value.to_i # 0020,0011 IS (integer string)
263
+ slice_image_id = dicom.instance_number.value.to_i # 0020,0013 IS (integer string)
264
+ study_id ||= slice_study_id
265
+ series_id ||= slice_series_id
266
+ @visited[i] = [filename, slice_study_id, slice_series_id, slice_image_id]
267
+ if !first_i || @visited[first_i].last > slice_image_id
268
+ first_i = i
269
+ first_md = single_dicom_metadata(dicom)
270
+ elsif !last_i || @visited[last_i].last < slice_image_id
271
+ last_i = i
272
+ last_md = single_dicom_metadata(dicom)
273
+ end
274
+ unless last_i
275
+ last_i = first_i
276
+ last_md = first_md
277
+ end
278
+ unless first_i
279
+ first_i = last_i
280
+ first_md = first_md
281
+ end
282
+ end
283
+ }
284
+
285
+ if @strategy
286
+ min, max = @strategy.min_max(self)
287
+ rescaled = @strategy.min_max_rescaled?
288
+ else
289
+ min, max = [lim_min, lim_max]
290
+ rescaled = false
291
+ end
292
+
293
+ @metadata.merge! min: min, max: max, rescaled: rescaled ? 1 : 0
294
+ @metadata.merge! bits: bits, signed: signed ? 1 : 0
295
+ @metadata.merge! slope: slope, intercept: intercept
296
+
297
+ if all
298
+ reorder!
299
+ first_i = 0
300
+ lsat_i = @files.size - 1
301
+ end
302
+
303
+ # make sure at least two different DICOM files are visited
304
+ if first_i == last_i
305
+ last
306
+ if first_i == last_i
307
+ first
308
+ end
309
+ end
310
+ # TODO: remove slice_z from @metadata...
311
+
312
+ if false
313
+ # always visit first and last slice
314
+ first unless first_i == 0
315
+ last unless last_i == size - 1
316
+ # TODO: change @metadata :x, :y, :z by max-min ranges or remove
317
+ end
318
+
319
+ @metadata.merge! study_id: study_id, series_id: series_id
320
+ @metadata.lim_min = lim_min
321
+ @metadata.lim_max = lim_max
322
+
323
+ total_n = size
324
+
325
+ n = last_i - first_i
326
+
327
+ xaxis = decode_vector(first_md.xaxis)
328
+ yaxis = decode_vector(first_md.yaxis)
329
+ # assert xaxis == decode_vector(last_md.xaxis)
330
+ # assert yaxis == decode_vector(last_md.yaxis)
331
+ first_pos = Vector[*first_md.to_h.values_at(:x, :y, :z)]
332
+ last_pos = Vector[*last_md.to_h.values_at(:x, :y, :z)]
333
+ zaxis = xaxis.cross_product(yaxis)
334
+ d = last_pos - first_pos
335
+ zaxis = -zaxis if zaxis.inner_product(d) < 0
336
+
337
+ @metadata.merge! Settings[first_md]
338
+ @metadata.zaxis = encode_vector zaxis
339
+ @metadata.nz = total_n
340
+ @metadata.dz = (last_md.slice_z - first_md.slice_z).abs/n
341
+
342
+ if xaxis[0].abs != 1 || xaxis[1] != 0 || xaxis[2] != 0 ||
343
+ yaxis[0] != 0 || yaxis[1] != 1 || yaxis[2] != 0 ||
344
+ zaxis[0] != 0 || zaxis[1] != 0 || zaxis[2].abs != 1
345
+ raise Error, "Unsupported orientation"
346
+ end
347
+ @metadata.reverse_x = (xaxis[0] < 0) ? 1 : 0
348
+ @metadata.reverse_y = (yaxis[1] < 0) ? 1 : 0
349
+ @metadata.reverse_z = (zaxis[2] < 0) ? 1 : 0
350
+ end
351
+
352
+ def set_cropping_volume!
353
+ if @roi
354
+ if @roi.size == 6
355
+ first_x, last_x, first_y, last_y, first_z, last_z = @roi.map(&:round)
356
+ else
357
+ xrange, yrange, zrange = @roi
358
+ first_x = xrange.first
359
+ last_x = xrange.last
360
+ last_x -= 1 if xrange.exclude_end?
361
+ first_y = yrange.first
362
+ last_y = yrange.last
363
+ last_y -= 1 if yrange.exclude_end?
364
+ first_z = zrange.first
365
+ last_z = zrange.last
366
+ last_z -= 1 if zrange.exclude_end?
367
+ end
368
+ x1, x2 = first_x, last_x
369
+ y1, y2 = first_y, last_y
370
+ z1, z2 = first_z, last_z
371
+ if @metadata.reverse_x == 1
372
+ x1, x2 = [x1, x2].map { |x| metadata.nx - x }.sort
373
+ end
374
+ if @metadata.reverse_y == 1
375
+ y1, y2 = [y1, y2].map { |y| metadata.ny - y }.sort
376
+ end
377
+ if @metadata.reverse_z == 1
378
+ z1, z2 = [z1, z2].map { |z| metadata.nz - z }.sort
379
+ end
380
+ @image_cropping = [x1, x2, y1, y2]
381
+ @selected_slices = (z1..z2)
382
+ @files = @files[@selected_slices]
383
+ @visited = @visited[@selected_slices]
384
+ @metadata.merge!(
385
+ firstx: first_x, lastx: last_x,
386
+ firsty: first_y, lasty: last_y,
387
+ firstz: first_z, lastz: last_z
388
+ )
389
+ end
390
+ end
391
+
392
+ def visit_all
393
+ @visited.each_with_index do |data, i|
394
+ dicom(i) unless data
395
+ end
396
+ end
397
+
398
+ def visit(dicom, i, *visitors)
399
+ if dicom
400
+ visitors.each do |visitor|
401
+ if visitor
402
+ if visitor.arity == 1
403
+ visitor[dicom]
404
+ elsif visitor.arity == 2
405
+ visitor[dicom, i]
406
+ elsif visitor.arity == 3
407
+ visitor[dicom, i, @files[i]]
408
+ end
409
+ end
410
+ end
411
+ end
412
+ end
413
+
414
+ end
415
+ end