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,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