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.
- checksums.yaml +7 -0
- data/.gitignore +15 -0
- data/.ruby-version +1 -0
- data/.travis.yml +4 -0
- data/CODE_OF_CONDUCT.md +13 -0
- data/Gemfile +4 -0
- data/LICENSE.md +598 -0
- data/README.md +48 -0
- data/Rakefile +22 -0
- data/bin/console +14 -0
- data/bin/setup +7 -0
- data/dicoms.gemspec +33 -0
- data/exe/dicoms +8 -0
- data/lib/dicoms.rb +67 -0
- data/lib/dicoms/cli.rb +241 -0
- data/lib/dicoms/command_options.rb +40 -0
- data/lib/dicoms/extract.rb +87 -0
- data/lib/dicoms/meta_codec.rb +131 -0
- data/lib/dicoms/pack.rb +82 -0
- data/lib/dicoms/progress.rb +80 -0
- data/lib/dicoms/projection.rb +422 -0
- data/lib/dicoms/remap.rb +46 -0
- data/lib/dicoms/sequence.rb +415 -0
- data/lib/dicoms/shared_files.rb +61 -0
- data/lib/dicoms/shared_settings.rb +111 -0
- data/lib/dicoms/stats.rb +30 -0
- data/lib/dicoms/support.rb +349 -0
- data/lib/dicoms/transfer.rb +339 -0
- data/lib/dicoms/unpack.rb +209 -0
- data/lib/dicoms/version.rb +3 -0
- metadata +200 -0
data/lib/dicoms/remap.rb
ADDED
@@ -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
|