dicoms 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,61 @@
1
+ require 'yaml'
2
+ require 'json'
3
+
4
+ class DicomS
5
+
6
+ # Shared files can be concurrently accessed by diferent processes.
7
+ # They should never be large files, and update operations (reading,
8
+ # then writing) should always be quick, because processes trying to
9
+ # read the file while an update is going on are blocked.
10
+ #
11
+ # Example
12
+ #
13
+ # counter = SharedFile.new('counter')
14
+ #
15
+ # counter.update do |contents|
16
+ # contents.to_i + 1
17
+ # end
18
+ #
19
+ # counter = counter.read.to_i
20
+ #
21
+ class SharedFile
22
+ def initialize(name, options = {})
23
+ @name = name
24
+ raise "A directory exists with that name" if File.directory?(@name)
25
+ end
26
+
27
+ attr_reader :name
28
+
29
+ def exists?
30
+ File.exists?(@name)
31
+ end
32
+
33
+ # Update a file safely.
34
+ def update(&blk)
35
+ File.open(@name, File::RDWR|File::CREAT, 0644) do |file|
36
+ file.flock File::LOCK_EX
37
+ if blk.arity == 1
38
+ new_contents = blk.call(file.read)
39
+ else
40
+ new_contents = blk.call
41
+ end
42
+ file.rewind
43
+ file.write new_contents
44
+ file.flush
45
+ file.truncate file.pos
46
+ end
47
+ end
48
+
49
+ # Read a file safely
50
+ def read
51
+ File.open(@name, "r") do |file|
52
+ file.flock File::LOCK_SH # this blocks until available
53
+ file.read
54
+ end
55
+ end
56
+
57
+ def write(contents)
58
+ update { contents }
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,111 @@
1
+ require 'yaml'
2
+ require 'json'
3
+
4
+ class DicomS
5
+
6
+ # Shared Settings are Settings stored in a SharedFile so
7
+ # they can be used concurrently from different processes.
8
+ class SharedSettings
9
+ def initialize(name, options = {})
10
+ @file = SharedFile.new(name)
11
+ @format = options[:format]
12
+ @compact = options[:compact]
13
+ unless @format
14
+ if File.extname(@file.name) == '.json'
15
+ @format = :json
16
+ else
17
+ @format = :yaml
18
+ end
19
+ end
20
+ contents = options[:initial_contents]
21
+ if contents && !@file.exists?
22
+ # Create with given initial contents
23
+ write contents
24
+ end
25
+ contents = options[:replace_contents]
26
+ write contents if contents
27
+ end
28
+
29
+ # Read a shared file and obtain a Settings object
30
+ #
31
+ # counter = shared_settings.read.counter
32
+ #
33
+ def read
34
+ decode @file.read
35
+ end
36
+
37
+ # To make sure contents are not changed between reading and writing
38
+ # use update:
39
+ #
40
+ # shared_settings.update do |data|
41
+ # # modify data and return modified data
42
+ # data.counter += 1
43
+ # data
44
+ # end
45
+ #
46
+ def update(&blk)
47
+ @file.update do |data|
48
+ encode blk.call(decode(data))
49
+ end
50
+ end
51
+
52
+ # Use this only if the contents written is independet of
53
+ # the previous content (i.e. no need to read, the change the data
54
+ # and write it back)
55
+ #
56
+ # shared_settings.write Setting[counter: 0
57
+ #
58
+ def write(data)
59
+ @file.write encode(data)
60
+ end
61
+
62
+ private
63
+
64
+ def encode(data)
65
+ if data.is_a?(Settings) || data.is_a?(Hash)
66
+ # Specially for YAML, we don't want to use symbols for
67
+ # hash keys because if read with languages other than
68
+ # Ruby that may cause some troubles.
69
+ data = stringify_keys(data.to_h)
70
+ end
71
+ case @format
72
+ when :json
73
+ if @compact
74
+ JSON.dump data
75
+ else
76
+ JSON.pretty_generate data
77
+ end
78
+ when :yaml
79
+ data.to_yaml
80
+ end
81
+ end
82
+
83
+ def decode(data)
84
+ case @format
85
+ when :json
86
+ data = JSON.load(data)
87
+ when :yaml
88
+ data = YAML.load(data)
89
+ end
90
+ if data.is_a?(Hash)
91
+ Settings[data]
92
+ else
93
+ data
94
+ end
95
+ end
96
+
97
+ # Convert symbolic keys to strings in a hash recursively
98
+ def stringify_keys(data)
99
+ if data.is_a?(Hash)
100
+ Hash[
101
+ data.map do |k, v|
102
+ [k.respond_to?(:to_sym) ? k.to_s : k, stringify_keys(v)]
103
+ end
104
+ ]
105
+ else
106
+ data
107
+ end
108
+ end
109
+ end
110
+
111
+ end
@@ -0,0 +1,30 @@
1
+ class DicomS
2
+ def stats(dicom_directory, options = {})
3
+ # TODO: compute histogram of levels
4
+ dicom_files = find_dicom_files(dicom_directory)
5
+ if dicom_files.empty?
6
+ raise "ERROR: no se han encontrado archivos DICOM en: \n #{dicom_directory}"
7
+ end
8
+
9
+ mins = []
10
+ maxs = []
11
+ next_mins = []
12
+ n = 0
13
+
14
+ dicom_files.each do |file|
15
+ n += 1
16
+ d = DICOM::DObject.read(file)
17
+ data = dicom_narray(d)
18
+ min = data.min
19
+ mins << min
20
+ maxs << data.max
21
+ next_mins << data[data > min].min
22
+ end
23
+ {
24
+ n: n,
25
+ min: mins.min,
26
+ next_min: next_mins.min,
27
+ max: maxs.max
28
+ }
29
+ end
30
+ end
@@ -0,0 +1,349 @@
1
+ require 'matrix'
2
+
3
+ class DicomS
4
+ USE_SLICE_Z = false
5
+ METADATA_TYPES = {
6
+ # Note: for axisx, axisy, axisz decode_vector should be used
7
+ dx: :to_f, dy: :to_f, dz: :to_f,
8
+ nx: :to_i, ny: :to_i, nz: :to_i,
9
+ max: :to_i, min: :to_i,
10
+ lim_min: :to_i, lim_max: :to_i,
11
+ rescaled: :to_i, # 0-false 1-true
12
+ slope: :to_f, intercept: :to_f,
13
+ bits: :to_i,
14
+ signed: :to_i, # 0-false 1-true
15
+ firstx: :to_i, firsty: :to_i, firstz: :to_i,
16
+ lastx: :to_i, lasty: :to_i, lastz: :to_i,
17
+ study_id: :to_s, series_id: :to_i,
18
+ x: :to_f, y: :to_f, z: :to_f,
19
+ slize_z: :to_f,
20
+ reverse_x: :to_i, # 0-false 1-true
21
+ reverse_y: :to_i, # 0-false 1-true
22
+ reverse_z: :to_i, # 0-false 1-true
23
+ axial_sx: :to_f,
24
+ axial_sy: :to_f,
25
+ coronal_sx: :to_f,
26
+ coronal_sy: :to_f,
27
+ sagittal_sx: :to_f,
28
+ sagittal_sy: :to_f
29
+ }
30
+
31
+ module Support
32
+ def cast_metadata(metadata)
33
+ metadata = Hash[metadata.to_h.to_a.map { |key, value|
34
+ key = key.to_s.downcase.to_sym
35
+ trans = METADATA_TYPES[key]
36
+ value = value.send(trans) if trans
37
+ [key, value]
38
+ }]
39
+ Settings[metadata]
40
+ end
41
+
42
+ # Code that use images should be wrapped with this.
43
+ #
44
+ # Reason: if RMagick is used by DICOM to handle images,
45
+ # then the first time it is needed, 'rmagick' will be required.
46
+ # This has the effect of placing the path of ImageMagick
47
+ # in front of the PATH.
48
+ # On Windows, ImageMagick includes FFMPeg in its path and we
49
+ # may require a later version than the bundled with IM,
50
+ # so we keep the original path rbefore RMagick alters it.
51
+ # We may be less dependant on the FFMpeg version is we avoid
52
+ # using the start_number option by renumbering the extracted
53
+ # images...
54
+ def keeping_path
55
+ path = ENV['PATH']
56
+ yield
57
+ ensure
58
+ ENV['PATH'] = path
59
+ end
60
+
61
+ # Replace ALT_SEPARATOR in pathname (Windows)
62
+ def normalized_path(path)
63
+ if File::ALT_SEPARATOR
64
+ path.gsub(File::ALT_SEPARATOR, File::SEPARATOR)
65
+ else
66
+ path
67
+ end
68
+ end
69
+
70
+ def dicom?(file)
71
+ ok = false
72
+ if File.file?(file)
73
+ File.open(file, 'rb') do |data|
74
+ data.seek 128, IO::SEEK_SET # skip preamble
75
+ ok = (data.read(4) == 'DICM')
76
+ end
77
+ end
78
+ ok
79
+ end
80
+
81
+ # Find DICOM files in a directory;
82
+ # Return the file names in an array.
83
+ # DICOM files with a numeric part in the name are returned first, ordered
84
+ # by the numeric value.
85
+ # DICOM files with non-numeric names are returned last ordered by name.
86
+ def find_dicom_files(dicom_directory)
87
+ # TODO: look recursively inside nested directories
88
+ if File.directory?(dicom_directory)
89
+ dicom_directory = normalized_path(dicom_directory)
90
+ files = Dir.glob(File.join(dicom_directory, '*')).select{|f| dicom?(f)}
91
+ elsif File.file?(dicom_directory) && dicom?(dicom_directory)
92
+ files = [dicom_directory]
93
+ else
94
+ files = []
95
+ end
96
+ non_numeric = []
97
+ numeric_files = []
98
+ files.each do |name|
99
+ base = File.basename(name)
100
+ match = /\d+/.match(base)
101
+ if match
102
+ number = match[0]
103
+ if base =~ /\AI\d\d\d\d\d\d\d\Z/
104
+ # funny scheme found in some DICOMS:
105
+ # the I is followed by the instance number (unpadded), then right
106
+ # padded with zeros, then increased (which affects the last digit)
107
+ # while it coincides with some prior value.
108
+ match = /I(\d\d\d\d)/.match(base)
109
+ number = match[1]
110
+ number = number[0...-1] while number.size > 1 && number[-1] == '0'
111
+ number_zeros = name[-1].to_i
112
+ number << '0'*number_zeros
113
+ end
114
+ numeric_files << [number, name]
115
+ else
116
+ non_numeric << name
117
+ end
118
+ end
119
+ numeric_files.sort_by{ |text, name| text.to_i }.map(&:last) + non_numeric.sort
120
+ end
121
+
122
+ def single_dicom_metadata(dicom)
123
+ # 0028,0030 Pixel Spacing:
124
+ dx, dy = dicom.pixel_spacing.value.split('\\').map(&:to_f)
125
+ # 0020,0032 Image Position (Patient):
126
+ x, y, z = dicom.image_position_patient.value.split('\\').map(&:to_f)
127
+ # 0020,0037 Image Orientation (Patient):
128
+ xx, xy, xz, yx, yy, yz = dicom.image_orientation_patient.value.split('\\').map(&:to_f)
129
+ if USE_SLICE_Z
130
+ # according to http://www.vtk.org/Wiki/VTK/FAQ#The_spacing_in_my_DICOM_files_are_wrong
131
+ # this is not reliable
132
+ # 0020,1041 Slice Location:
133
+ slice_z = dicom.slice_location.value.to_f
134
+ else
135
+ slice_z = z
136
+ end
137
+
138
+ # 0028,0011 Columns :
139
+ nx = dicom.num_cols # dicom.columns.value.to_i
140
+ # 0028,0010 Rows:
141
+ ny = dicom.num_rows # dicom.rows.value.to_i
142
+
143
+ unless dicom.samples_per_pixel.value.to_i == 1
144
+ raise "Invalid DICOM format"
145
+ end
146
+ Settings[
147
+ dx: dx, dy: dy, x: x, y: y, z: z,
148
+ slice_z: slice_z, nx: nx, ny: ny,
149
+ xaxis: encode_vector([xx,xy,xz]),
150
+ yaxis: encode_vector([yx,yy,yz])
151
+ # TODO: + min, max (original values corresponding to 0, 255)
152
+ ]
153
+ end
154
+
155
+ def encode_vector(v)
156
+ v.to_a*','
157
+ end
158
+
159
+ def decode_vector(v)
160
+ Vector[*v.split(',').map(&:to_f)]
161
+ end
162
+
163
+ def output_file_name(dir, prefix, name, ext = '.jpg')
164
+ File.join dir, "#{prefix}#{File.basename(name,'.dcm')}#{ext}"
165
+ end
166
+
167
+ def dicom_name_pattern(name, output_dir)
168
+ dir = File.dirname(name)
169
+ file = File.basename(name)
170
+ number_pattern = /\d+/
171
+ match = number_pattern.match(file)
172
+ raise "Invalid DICOM file name" unless match
173
+ number = match[0]
174
+ file = file.sub(number_pattern, "%d")
175
+ if match.begin(0) == 0
176
+ # ffmpeg has troubles with filename patterns starting with digits, so we'll add a prefix
177
+ prefix = "d-"
178
+ else
179
+ prefix = nil
180
+ end
181
+ pattern = output_file_name(output_dir, prefix, file)
182
+ [prefix, pattern, number]
183
+ end
184
+
185
+ def define_transfer(options, *defaults)
186
+ strategy, params = Array(options[:transfer])
187
+
188
+ unless defaults.first.is_a?(Hash)
189
+ default_strategy = defaults.shift.to_sym
190
+ end
191
+ defautl_strategy ||= :sample
192
+ default_params = defaults.shift || {}
193
+ raise "Invalid number of parametrs" unless defaults.empty?
194
+ Transfer.strategy strategy || default_strategy, default_params.merge((params || {}).to_h)
195
+ end
196
+
197
+ def pixel_value_range(num_bits, signed)
198
+ num_values = (1 << num_bits) # 2**num_bits
199
+ if signed
200
+ [-num_values/2, num_values/2-1]
201
+ else
202
+ [0, num_values-1]
203
+ end
204
+ end
205
+
206
+ def dicom_element_value(dicom, tag, options = {})
207
+ if dicom.exists?(tag)
208
+ value = dicom[tag].value
209
+ if options[:first]
210
+ if value.is_a?(String)
211
+ value = value.split('\\').first
212
+ elsif value.is_a?(Array)
213
+ value = value.first
214
+ end
215
+ end
216
+ value = value.send(options[:convert]) if options[:convert]
217
+ value
218
+ else
219
+ options[:default]
220
+ end
221
+ end
222
+
223
+ # WL (window level)
224
+ def dicom_window_center(dicom)
225
+ # dicom.window_center.value.to_i
226
+ dicom_element_value(dicom, '0028,1050', convert: :to_f, first: true)
227
+ end
228
+
229
+ # WW (window width)
230
+ def dicom_window_width(dicom)
231
+ # dicom.window_center.value.to_i
232
+ dicom_element_value(dicom, '0028,1051', convert: :to_f, first: true)
233
+ end
234
+
235
+ def dicom_rescale_intercept(dicom)
236
+ dicom_element_value(dicom, '0028,1052', convert: :to_f, default: 0)
237
+ end
238
+
239
+ def dicom_rescale_slope(dicom)
240
+ dicom_element_value(dicom, '0028,1053', convert: :to_f, default: 1)
241
+ end
242
+
243
+ def dicom_bit_depth(dicom)
244
+ # dicom.send(:bit_depth)
245
+ dicom_element_value dicom, '0028,0100', convert: :to_i
246
+ end
247
+
248
+ def dicom_signed?(dicom)
249
+ # dicom.send(:signed_pixels?)
250
+ case dicom_element_value(dicom, '0028,0103', convert: :to_i)
251
+ when 1
252
+ true
253
+ when 0
254
+ false
255
+ end
256
+ end
257
+
258
+ def dicom_stored_bits(dicom)
259
+ # dicom.bits_stored.value.to_i
260
+ dicom_element_value dicom, '0028,0101', convert: :to_i
261
+ end
262
+
263
+ def dicom_narray(dicom, options = {})
264
+ if dicom.compression?
265
+ img = dicom.image
266
+ pixels = dicom.export_pixels(img, dicom.send(:photometry))
267
+ na = NArray.to_na(pixels).reshape!(dicom.num_cols, dicom.num_rows)
268
+ bits = dicom_bit_depth(dicom)
269
+ signed = dicom_signed?(dicom)
270
+ stored_bits = dicom_stored_bits(dicom)
271
+ if stored_bits != Magick::MAGICKCORE_QUANTUM_DEPTH
272
+ use_float = stored_bits < Magick::MAGICKCORE_QUANTUM_DEPTH
273
+ if use_float
274
+ na = na.to_type(NArray::SFLOAT)
275
+ na.mul! 2.0**(stored_bits - Magick::MAGICKCORE_QUANTUM_DEPTH)
276
+ na = na.to_type(NArray::INT)
277
+ else
278
+ na.mul! (1 << (stored_bits - Magick::MAGICKCORE_QUANTUM_DEPTH))
279
+ end
280
+ end
281
+ if remap = options[:remap] || level = options[:level]
282
+ intercept = dicom_rescale_intercept(dicom)
283
+ slope = dicom_rescale_slope(dicom)
284
+ if intercept != 0 || slope != 1
285
+ na.mul! slope
286
+ na.add! intercept
287
+ end
288
+ if level
289
+ if level.is_a?(Array)
290
+ center, width = level
291
+ else
292
+ center = dicom_window_center(dicom)
293
+ width = dicom_window_width(dicom)
294
+ end
295
+ if center && width
296
+ low = center - width/2
297
+ high = center + width/2
298
+ na[na < low] = low
299
+ na[na > high] = high
300
+ end
301
+ end
302
+
303
+ # Now we limit the output values range.
304
+ # Note that we don't use:
305
+ # min, max = pixel_value_range(bits, signed)
306
+ # because thats the limits for the stored values, but not for
307
+ # the representation values we're computing here (which are
308
+ # typically signed even if the storage is unsigned)
309
+ # We coud use this, but that would have to be
310
+ # min, max = pixel_value_range(stored_bits, false)
311
+ # min = -max
312
+ # but that requires some reviewing.
313
+ # Maybe this shold be parameterized.
314
+ min, max = -65535, 65535
315
+ min_pixel_value = na.min
316
+ if min
317
+ if min_pixel_value < min
318
+ offset = min_pixel_value.abs
319
+ na.add! offset
320
+ end
321
+ end
322
+ max_pixel_value = na.max
323
+ if max
324
+ if max_pixel_value > max
325
+ factor = (max_pixel_value.to_f/max.to_f).ceil
326
+ na.div! factor
327
+ end
328
+ end
329
+ end
330
+
331
+ na
332
+ else
333
+ dicom.narray options
334
+ end
335
+ end
336
+
337
+ def assign_dicom_pixels(dicom, pixels)
338
+ if dicom.compression?
339
+ dicom.delete DICOM::PIXEL_TAG
340
+ end
341
+ dicom.pixels = pixels
342
+ end
343
+
344
+ def dicom_compression(dicom)
345
+ ts = DICOM::LIBRARY.uid(dicom.transfer_syntax)
346
+ ts.name if ts.compressed_pixels?
347
+ end
348
+ end
349
+ end