dicom 0.9.3 → 0.9.4

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,539 +1,451 @@
1
- # === TODO:
2
- #
3
- # * The retrieve file network functionality (#get_image in DClient class) has not been tested.
4
- # * Make the networking code more intelligent in its handling of unexpected network communication.
5
- # * Full support for compressed image data.
6
- # * Read/Write 12 bit image data.
7
- # * Full color support (RGB and PALETTE COLOR with #image already implemented).
8
- # * Support for extraction of multiple encapsulated pixel data frames in #pixels and #narray.
9
- # * Image handling currently ignores DICOM tags like Pixel Aspect Ratio, Image Orientation and (to some degree) Photometric Interpretation.
10
- # * More robust and flexible options for reorienting extracted pixel arrays?
11
- # * A curious observation: Creating a DLibrary instance is exceptionally slow on Ruby 1.9.1: 0.4 seconds versus ~0.01 seconds on Ruby 1.8.7!
12
- # * Add these as github issues and remove this list!
13
-
14
-
15
- # Copyright 2008-2012 Christoffer Lervag
16
- #
17
- # This program is free software: you can redistribute it and/or modify
18
- # it under the terms of the GNU General Public License as published by
19
- # the Free Software Foundation, either version 3 of the License, or
20
- # (at your option) any later version.
21
- #
22
- # This program is distributed in the hope that it will be useful,
23
- # but WITHOUT ANY WARRANTY; without even the implied warranty of
24
- # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
25
- # GNU General Public License for more details.
26
- #
27
- # You should have received a copy of the GNU General Public License
28
- # along with this program. If not, see <http://www.gnu.org/licenses/>.
29
- #
30
- module DICOM
31
-
32
- # The DObject class is the main class for interacting with the DICOM object.
33
- # Reading from and writing to files is executed from instances of this class.
34
- #
35
- # === Inheritance
36
- #
37
- # As the DObject class inherits from the ImageItem class, which itself inherits from the Parent class,
38
- # all ImageItem and Parent methods are also available to instances of DObject.
39
- #
40
- class DObject < ImageItem
41
- include Logging
42
-
43
- # Creates a DObject instance by downloading a DICOM file
44
- # specified by a hyperlink, and parsing the retrieved file.
45
- #
46
- # === Restrictions
47
- #
48
- # * Highly experimental and un-tested!
49
- # * Designed for HTTP protocol only.
50
- # * Whether this method should be included or removed from ruby-dicom is up for debate.
51
- #
52
- # === Parameters
53
- #
54
- # * <tt>link</tt> -- A hyperlink string which specifies remote location of the DICOM file to be loaded.
55
- #
56
- def self.get(link)
57
- raise ArgumentError, "Invalid argument 'link'. Expected String, got #{link.class}." unless link.is_a?(String)
58
- raise ArgumentError, "Invalid argument 'link'. Expected a string starting with 'http', got #{link}." unless link.index('http') == 0
59
- require 'open-uri'
60
- bin = nil
61
- file = nil
62
- # Try to open the remote file using open-uri:
63
- retrials = 0
64
- begin
65
- file = open(link, 'rb') # binary encoding (ASCII-8BIT)
66
- rescue Exception => e
67
- if retrials > 3
68
- retrials = 0
69
- raise "Unable to retrieve the file. File does not exist?"
70
- else
71
- logger.warn("Exception in ruby-dicom when loading a dicom file from: #{file}")
72
- logger.debug("Retrying... #{retrials}")
73
- retrials += 1
74
- retry
75
- end
76
- end
77
- bin = File.open(file, "rb") { |f| f.read }
78
- # Parse the file contents and create the DICOM object:
79
- if bin
80
- dcm = self.parse(bin)
81
- else
82
- dcm = self.new
83
- dcm.read_success = false
84
- end
85
- return dcm
86
- end
87
-
88
- # Creates a DObject instance by parsing an encoded binary DICOM string.
89
- #
90
- # === Parameters
91
- #
92
- # * <tt>string</tt> -- An encoded binary string containing DICOM information.
93
- # * <tt>options</tt> -- A hash of parameters.
94
- #
95
- # === Options
96
- #
97
- # * <tt>:no_meta</tt> -- Boolean. If true, the parsing algorithm is instructed that the binary DICOM string contains no meta header.
98
- # * <tt>:syntax</tt> -- String. If a syntax string is specified, the parsing algorithm will be forced to use this transfer syntax when decoding the binary string.
99
- #
100
- def self.parse(string, options={})
101
- syntax = options[:syntax]
102
- no_header = options[:no_meta]
103
- raise ArgumentError, "Invalid argument 'string'. Expected String, got #{string.class}." unless string.is_a?(String)
104
- raise ArgumentError, "Invalid option :syntax. Expected String, got #{syntax.class}." if syntax && !syntax.is_a?(String)
105
- dcm = self.new
106
- dcm.read(string, :bin => true, :no_meta => no_header, :syntax => syntax)
107
- return dcm
108
- end
109
-
110
- # Creates a DObject instance by reading and parsing a DICOM file.
111
- #
112
- # === Parameters
113
- #
114
- # * <tt>file</tt> -- A string which specifies the path of the DICOM file to be loaded.
115
- #
116
- def self.read(file)
117
- raise ArgumentError, "Invalid argument 'file'. Expected String, got #{file.class}." unless file.is_a?(String)
118
- # Read the file content:
119
- bin = nil
120
- unless File.exist?(file)
121
- logger.error("Invalid (non-existing) file: #{file}")
122
- else
123
- unless File.readable?(file)
124
- logger.error("File exists but I don't have permission to read it: #{file}")
125
- else
126
- if File.directory?(file)
127
- logger.error("Expected a file, got a directory: #{file}")
128
- else
129
- if File.size(file) < 8
130
- logger.error("This file is too small to contain any DICOM information: #{file}.")
131
- else
132
- bin = File.open(file, "rb") { |f| f.read }
133
- end
134
- end
135
- end
136
- end
137
- # Parse the file contents and create the DICOM object:
138
- if bin
139
- dcm = self.parse(bin)
140
- else
141
- dcm = self.new
142
- dcm.read_success = false
143
- end
144
- return dcm
145
- end
146
-
147
- # A boolean set as false. This attribute is included to provide consistency with other object types for the internal methods which use it.
148
- attr_reader :parent
149
- # A boolean which is set as true if a DICOM file has been successfully read & parsed from a file (or binary string).
150
- attr_accessor :read_success
151
- # The Stream instance associated with this DObject instance (this attribute is mostly used internally).
152
- attr_reader :stream
153
- # A boolean which is set as true if a DObject instance has been successfully written to file (or successfully encoded).
154
- attr_reader :write_success
155
-
156
- alias_method :read?, :read_success
157
- alias_method :written?, :write_success
158
-
159
- # Creates a DObject instance (DObject is an abbreviation for "DICOM object").
160
- #
161
- # === Notes
162
- #
163
- # The DObject instance holds references to the different types of objects (Element, Item, Sequence)
164
- # that makes up a DICOM object. A DObject is typically buildt by reading and parsing a file or a
165
- # binary string, but can also be buildt from an empty state by the user.
166
- #
167
- # To customize logging behaviour, refer to the Logging module documentation.
168
- #
169
- # === Parameters
170
- #
171
- # * <tt>string</tt> -- (Deprecated) A string which specifies either the path of a DICOM file to be loaded, or a binary DICOM string to be parsed. The parameter defaults to nil, in which case an empty DObject instance is created.
172
- # * <tt>options</tt> -- A hash of parameters.
173
- #
174
- # === Options
175
- #
176
- # * <tt>:bin</tt> -- (Deprecated) Boolean. If true, the string parameter will be interpreted as a binary DICOM string instead of a path string.
177
- # * <tt>:syntax</tt> -- (Deprecated) String. If a syntax string is specified, the parsing algorithm will be forced to use this transfer syntax when decoding the file/binary string.
178
- #
179
- # === Examples
180
- #
181
- # # Load a DICOM file (Deprecated: please use DObject.read() instead):
182
- # require 'dicom'
183
- # dcm = DICOM::DObject.new("test.dcm")
184
- # # Read a DICOM file that has already been loaded into memory in a binary string (with a known transfer syntax):
185
- # # (Deprecated: please use DObject.parse() instead)
186
- # dcm = DICOM::DObject.new(binary_string, :bin => true, :syntax => string_transfer_syntax)
187
- # # Create an empty DICOM object
188
- # dcm = DICOM::DObject.new
189
- # # Increasing the log message threshold (default level is INFO):
190
- # DICOM.logger.level = Logger::WARN
191
- #
192
- def initialize(string=nil, options={})
193
- # Deprecation warning:
194
- logger.warn("Calling DOBject#new with a string argument is deprecated. Please use DObject#read (for reading files) or DObject#parse (for parsing strings) instead. Support for DObject#new with a string argument will be removed in a future version.") if string
195
- # Removal warning:
196
- logger.warn("The option :verbose no longer has any meaning. Please specify logger levels instead, e.g. DICOM.logger.level = Logger::WARN (refer to the documentation for more details).") if options[:verbose] == false
197
- # Initialization of variables that DObject share with other parent elements:
198
- initialize_parent
199
- # Structural information (default values):
200
- @explicit = true
201
- @file_endian = false
202
- # Control variables:
203
- @read_success = nil
204
- # Initialize a Stream instance which is used for encoding/decoding:
205
- @stream = Stream.new(nil, @file_endian)
206
- # The DObject instance is the top of the hierarchy and unlike other elements it has no parent:
207
- @parent = nil
208
- # For convenience, call the read method if a string has been supplied:
209
- if string.is_a?(String)
210
- @file = string unless options[:bin]
211
- read(string, options)
212
- elsif string
213
- raise ArgumentError, "Invalid argument. Expected String (or nil), got #{string.class}."
214
- end
215
- end
216
-
217
- # Returns true if the argument is an instance with attributes equal to self.
218
- #
219
- def ==(other)
220
- if other.respond_to?(:to_dcm)
221
- other.send(:state) == state
222
- end
223
- end
224
-
225
- alias_method :eql?, :==
226
-
227
- # Encodes the DICOM object into a series of binary string segments with a specified maximum length.
228
- #
229
- # Returns the encoded binary strings in an array.
230
- #
231
- # === Parameters
232
- #
233
- # * <tt>max_size</tt> -- An integer (Fixnum) which specifies the maximum allowed size of the binary data strings which will be encoded.
234
- # * <tt>transfer_syntax</tt> -- The transfer syntax string to be used when encoding the DICOM object to string segments. When this method is used for making network packets, the transfer_syntax is not part of the object, and thus needs to be specified. Defaults to the DObject's transfer syntax/Implicit little endian.
235
- #
236
- # === Examples
237
- #
238
- # encoded_strings = dcm.encode_segments(16384)
239
- #
240
- def encode_segments(max_size, transfer_syntax=transfer_syntax)
241
- raise ArgumentError, "Invalid argument. Expected an Integer, got #{max_size.class}." unless max_size.is_a?(Integer)
242
- raise ArgumentError, "Argument too low (#{max_size}), please specify a bigger Integer." unless max_size > 16
243
- raise "Can not encode binary segments for an empty DICOM object." if children.length == 0
244
- w = DWrite.new(self, transfer_syntax, file_name=nil)
245
- w.encode_segments(max_size)
246
- # Write process succesful?
247
- @write_success = w.success
248
- return w.segments
249
- end
250
-
251
- # Generates a Fixnum hash value for this instance.
252
- #
253
- def hash
254
- state.hash
255
- end
256
-
257
- # Prints information of interest related to the DICOM object.
258
- # Calls the print() method of Parent as well as the information() method of DObject.
259
- #
260
- def print_all
261
- puts ""
262
- print(:value_max => 30)
263
- summary
264
- end
265
-
266
- # Fills a DICOM object by reading and parsing the specified DICOM file,
267
- # and transfers the DICOM data to the DICOM object (self).
268
- #
269
- # === Notes
270
- #
271
- # * This method is called automatically when initializing the DObject class with a file parameter.
272
- # * In practice this method is rarely called by the user, and in fact it may be removed entirely at some stage.
273
- #
274
- # === Parameters
275
- #
276
- # * <tt>string</tt> -- A string which specifies either the path of a DICOM file to be loaded, or a binary DICOM string to be parsed.
277
- # * <tt>options</tt> -- A hash of parameters.
278
- #
279
- # === Options
280
- #
281
- # * <tt>:bin</tt> -- Boolean. If true, the string parameter will be interpreted as a binary DICOM string instead of a path string.
282
- # * <tt>:no_meta</tt> -- Boolean. If true, the parsing algorithm is instructed that the binary DICOM string contains no meta header.
283
- # * <tt>:syntax</tt> -- String. If a syntax string is specified, the parsing algorithm will be forced to use this transfer syntax when decoding the file/binary string.
284
- #
285
- def read(string, options={})
286
- raise ArgumentError, "Invalid argument 'string'. Expected String, got #{string.class}." unless string.is_a?(String)
287
- # Clear any existing DObject tags, then read:
288
- @tags = Hash.new
289
- r = DRead.new(self, string, options)
290
- # If reading failed, and no transfer syntax was detected, we will make another attempt at reading the file while forcing explicit (little endian) decoding.
291
- # This will help for some rare cases where the DICOM file is saved (erroneously, Im sure) with explicit encoding without specifying the transfer syntax tag.
292
- if !r.success and !exists?("0002,0010")
293
- logger.debug("First attempt at parsing the file failed.\nAttempting a second pass (assuming Explicit Little Endian transfer syntax).")
294
- # Clear the existing DObject tags:
295
- @tags = Hash.new
296
- r_explicit = DRead.new(self, string, :bin => options[:bin], :no_meta => options[:no_meta], :syntax => EXPLICIT_LITTLE_ENDIAN)
297
- # Only extract information from this new attempt if it was successful:
298
- r = r_explicit if r_explicit.success
299
- end
300
- # Pass along any messages that has been recorded:
301
- r.msg.each { |m| logger.public_send(m.first, m.last) }
302
- # Store the data to the instance variables if the readout was a success:
303
- if r.success
304
- logger.info("The DICOM file has been successfully parsed.")
305
- @read_success = true
306
- # Update instance variables based on the properties of the DICOM object:
307
- @explicit = r.explicit
308
- @file_endian = r.file_endian
309
- @signature = r.signature
310
- @stream.endian = @file_endian
311
- else
312
- logger.warn("Parsing the DICOM file has failed.")
313
- @read_success = false
314
- end
315
- end
316
-
317
- # Gathers key information about the DObject as well as some system data, and prints this information to the screen.
318
- #
319
- # This information includes properties like encoding, byte order, modality and various image properties.
320
- #
321
- #--
322
- # FIXME: Perhaps this method should be split up in one or two separate methods
323
- # which just builds the information arrays, and a third method for printing this to the screen.
324
- #
325
- def summary
326
- sys_info = Array.new
327
- info = Array.new
328
- # Version of Ruby DICOM used:
329
- sys_info << "Ruby DICOM version: #{VERSION}"
330
- # System endian:
331
- cpu = (CPU_ENDIAN ? "Big Endian" : "Little Endian")
332
- sys_info << "Byte Order (CPU): #{cpu}"
333
- # File path/name:
334
- info << "File: #{@file}"
335
- # Modality:
336
- modality = (exists?("0008,0016") ? LIBRARY.get_syntax_description(self["0008,0016"].value) : "SOP Class unknown or not specified!")
337
- info << "Modality: #{modality}"
338
- # Meta header presence (Simply check for the presence of the transfer syntax data element), VR and byte order:
339
- transfer_syntax = self["0002,0010"]
340
- if transfer_syntax
341
- syntax_validity, explicit, endian = LIBRARY.process_transfer_syntax(transfer_syntax.value)
342
- if syntax_validity
343
- meta_comment, explicit_comment, encoding_comment = "", "", ""
344
- else
345
- meta_comment = " (But unknown/invalid transfer syntax: #{transfer_syntax})"
346
- explicit_comment = " (Assumed)"
347
- encoding_comment = " (Assumed)"
348
- end
349
- explicitness = (explicit ? "Explicit" : "Implicit")
350
- encoding = (endian ? "Big Endian" : "Little Endian")
351
- meta = "Yes#{meta_comment}"
352
- else
353
- meta = "No"
354
- explicitness = (@explicit == true ? "Explicit" : "Implicit")
355
- encoding = (@file_endian == true ? "Big Endian" : "Little Endian")
356
- explicit_comment = " (Assumed)"
357
- encoding_comment = " (Assumed)"
358
- end
359
- info << "Meta Header: #{meta}"
360
- info << "Value Representation: #{explicitness}#{explicit_comment}"
361
- info << "Byte Order (File): #{encoding}#{encoding_comment}"
362
- # Pixel data:
363
- pixels = self[PIXEL_TAG]
364
- unless pixels
365
- info << "Pixel Data: No"
366
- else
367
- info << "Pixel Data: Yes"
368
- # Image size:
369
- cols = (exists?("0028,0011") ? self["0028,0011"].value : "Columns missing")
370
- rows = (exists?("0028,0010") ? self["0028,0010"].value : "Rows missing")
371
- info << "Image Size: #{cols}*#{rows}"
372
- # Frames:
373
- frames = value("0028,0008") || "1"
374
- unless frames == "1" or frames == 1
375
- # Encapsulated or 3D pixel data:
376
- if pixels.is_a?(Element)
377
- frames = frames.to_s + " (3D Pixel Data)"
378
- else
379
- frames = frames.to_s + " (Encapsulated Multiframe Image)"
380
- end
381
- end
382
- info << "Number of frames: #{frames}"
383
- # Color:
384
- colors = (exists?("0028,0004") ? self["0028,0004"].value : "Not specified")
385
- info << "Photometry: #{colors}"
386
- # Compression:
387
- if transfer_syntax
388
- compression = LIBRARY.get_compression(transfer_syntax.value)
389
- if compression
390
- compression = LIBRARY.get_syntax_description(transfer_syntax.value) || "Unknown UID!"
391
- else
392
- compression = "No"
393
- end
394
- else
395
- compression = "No (Assumed)"
396
- end
397
- info << "Compression: #{compression}"
398
- # Pixel bits (allocated):
399
- bits = (exists?("0028,0100") ? self["0028,0100"].value : "Not specified")
400
- info << "Bits per Pixel: #{bits}"
401
- end
402
- # Print the DICOM object's key properties:
403
- separator = "-------------------------------------------"
404
- puts "System Properties:"
405
- puts separator + "\n"
406
- puts sys_info
407
- puts "\n"
408
- puts "DICOM Object Properties:"
409
- puts separator
410
- puts info
411
- puts separator
412
- return info
413
- end
414
-
415
- # Returns self.
416
- #
417
- def to_dcm
418
- self
419
- end
420
-
421
- # Returns the transfer syntax string of the DObject.
422
- #
423
- # If a transfer syntax has not been defined in the DObject, a default tansfer syntax is assumed and returned.
424
- #
425
- def transfer_syntax
426
- return value("0002,0010") || IMPLICIT_LITTLE_ENDIAN
427
- end
428
-
429
- # Changes the transfer syntax Element of the DObject instance, and performs re-encoding of all
430
- # numerical values if a switch of endianness is implied.
431
- #
432
- # === Restrictions
433
- #
434
- # This method does not change the compressed state of the pixel data element. Changing the transfer syntax between
435
- # an uncompressed and compressed state will NOT change the pixel data accordingly (this must be taken care of manually).
436
- #
437
- # === Parameters
438
- #
439
- # * <tt>new_syntax</tt> -- The new transfer syntax string which will be applied to the DObject.
440
- #
441
- def transfer_syntax=(new_syntax)
442
- valid_ts, new_explicit, new_endian = LIBRARY.process_transfer_syntax(new_syntax)
443
- raise ArgumentError, "Invalid transfer syntax specified: #{new_syntax}" unless valid_ts
444
- # Get the old transfer syntax and write the new one to the DICOM object:
445
- old_syntax = transfer_syntax
446
- valid_ts, old_explicit, old_endian = LIBRARY.process_transfer_syntax(old_syntax)
447
- if exists?("0002,0010")
448
- self["0002,0010"].value = new_syntax
449
- else
450
- add(Element.new("0002,0010", new_syntax))
451
- end
452
- # Update our Stream instance with the new encoding:
453
- @stream.endian = new_endian
454
- # If endianness is changed, re-encode elements (only elements depending on endianness will actually be re-encoded):
455
- encode_children(old_endian) if old_endian != new_endian
456
- end
457
-
458
- # Passes the DObject to the DWrite class, which traverses the data element
459
- # structure and encodes a proper DICOM binary string, which is finally written to the specified file.
460
- #
461
- # === Parameters
462
- #
463
- # * <tt>file_name</tt> -- A string which identifies the path & name of the DICOM file which is to be written to disk.
464
- # * <tt>options</tt> -- A hash of parameters.
465
- #
466
- # === Options
467
- #
468
- # * <tt>:add_meta</tt> -- Boolean. If set to false, no manipulation of the DICOM object's meta group will be performed before the DObject is written to file.
469
- #
470
- # === Examples
471
- #
472
- # dcm.write(path + "test.dcm")
473
- #
474
- def write(file_name, options={})
475
- raise ArgumentError, "Invalid file_name. Expected String, got #{file_name.class}." unless file_name.is_a?(String)
476
- insert_missing_meta unless options[:add_meta] == false
477
- w = DWrite.new(self, transfer_syntax, file_name, options)
478
- w.write
479
- # Write process succesful?
480
- @write_success = w.success
481
- end
482
-
483
-
484
- # Following methods are private:
485
- private
486
-
487
-
488
- # Adds any missing meta group (0002,xxxx) data elements to the DICOM object,
489
- # to ensure that a valid DICOM object will be written to file.
490
- #
491
- def insert_missing_meta
492
- # File Meta Information Version:
493
- Element.new("0002,0001", [0,1], :parent => self) unless exists?("0002,0001")
494
- # Media Storage SOP Class UID:
495
- Element.new("0002,0002", value("0008,0016"), :parent => self) unless exists?("0002,0002")
496
- # Media Storage SOP Instance UID:
497
- Element.new("0002,0003", value("0008,0018"), :parent => self) unless exists?("0002,0003")
498
- # Transfer Syntax UID:
499
- Element.new("0002,0010", transfer_syntax, :parent => self) unless exists?("0002,0010")
500
- if !exists?("0002,0012") and !exists?("0002,0013")
501
- # Implementation Class UID:
502
- Element.new("0002,0012", UID, :parent => self)
503
- # Implementation Version Name:
504
- Element.new("0002,0013", NAME, :parent => self)
505
- end
506
- # Source Application Entity Title:
507
- Element.new("0002,0016", DICOM.source_app_title, :parent => self) unless exists?("0002,0016")
508
- # Group Length: Delete the old one (if it exists) before creating a new one.
509
- delete("0002,0000")
510
- Element.new("0002,0000", meta_group_length, :parent => self)
511
- end
512
-
513
- # Determines and returns the length of the meta group in the DObject instance.
514
- #
515
- def meta_group_length
516
- group_length = 0
517
- meta_elements = group(META_GROUP)
518
- tag = 4
519
- vr = 2
520
- meta_elements.each do |element|
521
- case element.vr
522
- when "OB","OW","OF","SQ","UN","UT"
523
- length = 6
524
- else
525
- length = 2
526
- end
527
- group_length += tag + vr + length + element.bin.length
528
- end
529
- return group_length
530
- end
531
-
532
- # Returns the attributes (children) of this instance (for comparison purposes).
533
- #
534
- def state
535
- @tags
536
- end
537
-
538
- end
539
- end
1
+ # === TODO:
2
+ #
3
+ # * The retrieve file network functionality (#get_image in DClient class) has not been tested.
4
+ # * Make the networking code more intelligent in its handling of unexpected network communication.
5
+ # * Full support for compressed image data.
6
+ # * Read/Write 12 bit image data.
7
+ # * Full color support (RGB and PALETTE COLOR with #image already implemented).
8
+ # * Support for extraction of multiple encapsulated pixel data frames in #pixels and #narray.
9
+ # * Image handling currently ignores DICOM tags like Pixel Aspect Ratio, Image Orientation and (to some degree) Photometric Interpretation.
10
+ # * More robust and flexible options for reorienting extracted pixel arrays?
11
+ # * A curious observation: Creating a DLibrary instance is exceptionally slow on Ruby 1.9.1: 0.4 seconds versus ~0.01 seconds on Ruby 1.8.7!
12
+ # * Add these as github issues and remove this list!
13
+
14
+
15
+ # Copyright 2008-2012 Christoffer Lervag
16
+ #
17
+ # This program is free software: you can redistribute it and/or modify
18
+ # it under the terms of the GNU General Public License as published by
19
+ # the Free Software Foundation, either version 3 of the License, or
20
+ # (at your option) any later version.
21
+ #
22
+ # This program is distributed in the hope that it will be useful,
23
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
24
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
25
+ # GNU General Public License for more details.
26
+ #
27
+ # You should have received a copy of the GNU General Public License
28
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
29
+ #
30
+ module DICOM
31
+
32
+ # The DObject class is the main class for interacting with the DICOM object.
33
+ # Reading from and writing to files is executed from instances of this class.
34
+ #
35
+ # === Inheritance
36
+ #
37
+ # As the DObject class inherits from the ImageItem class, which itself inherits from the Parent class,
38
+ # all ImageItem and Parent methods are also available to instances of DObject.
39
+ #
40
+ class DObject < ImageItem
41
+ include Logging
42
+
43
+ # An attribute set as nil. This attribute is included to provide consistency with the other element types which usually have a parent defined.
44
+ attr_reader :parent
45
+ # A boolean which is set as true if a DICOM file has been successfully read & parsed from a file (or binary string).
46
+ attr_accessor :read_success
47
+ # The Stream instance associated with this DObject instance (this attribute is mostly used internally).
48
+ attr_reader :stream
49
+ # A boolean which is set as true if a DObject instance has been successfully written to file (or successfully encoded).
50
+ attr_reader :write_success
51
+
52
+ alias_method :read?, :read_success
53
+ alias_method :written?, :write_success
54
+
55
+ # Creates a DObject instance by downloading a DICOM file
56
+ # specified by a hyperlink, and parsing the retrieved file.
57
+ #
58
+ # @note Highly experimental and un-tested!
59
+ # @note Designed for the HTTP protocol only.
60
+ # @note Whether this method should be included or removed from ruby-dicom is up for debate.
61
+ #
62
+ # @param [String] link a hyperlink string which specifies remote location of the DICOM file to be loaded.
63
+ # @return [DObject] the created DObject instance
64
+ #
65
+ def self.get(link)
66
+ raise ArgumentError, "Invalid argument 'link'. Expected String, got #{link.class}." unless link.is_a?(String)
67
+ raise ArgumentError, "Invalid argument 'link'. Expected a string starting with 'http', got #{link}." unless link.index('http') == 0
68
+ require 'open-uri'
69
+ bin = nil
70
+ file = nil
71
+ # Try to open the remote file using open-uri:
72
+ retrials = 0
73
+ begin
74
+ file = open(link, 'rb') # binary encoding (ASCII-8BIT)
75
+ rescue Exception => e
76
+ if retrials > 3
77
+ retrials = 0
78
+ raise "Unable to retrieve the file. File does not exist?"
79
+ else
80
+ logger.warn("Exception in ruby-dicom when loading a dicom file from: #{file}")
81
+ logger.debug("Retrying... #{retrials}")
82
+ retrials += 1
83
+ retry
84
+ end
85
+ end
86
+ bin = File.open(file, "rb") { |f| f.read }
87
+ # Parse the file contents and create the DICOM object:
88
+ if bin
89
+ dcm = self.parse(bin)
90
+ else
91
+ dcm = self.new
92
+ dcm.read_success = false
93
+ end
94
+ return dcm
95
+ end
96
+
97
+ # Creates a DObject instance by parsing an encoded binary DICOM string.
98
+ #
99
+ # @param [String] string an encoded binary string containing DICOM information
100
+ # @param [Hash] options the options to use for parsing the DICOM string
101
+ # @option options [Boolean] :signature if set as false, the parsing algorithm will not be looking for the DICOM header signature (defaults to true)
102
+ # @option options [String] :syntax if a syntax string is specified, the parsing algorithm will be forced to use this transfer syntax when decoding the binary string
103
+ # @example Parse a DICOM file that has already been loaded to a binary string
104
+ # require 'dicom'
105
+ # dcm = DICOM::DObject.parse(str)
106
+ # @example Parse a header-less DICOM string with explicit little endian transfer syntax
107
+ # dcm = DICOM::DObject.parse(str, :syntax => '1.2.840.10008.1.2.1')
108
+ #
109
+ def self.parse(string, options={})
110
+ raise ArgumentError, "Invalid argument 'string'. Expected String, got #{string.class}." unless string.is_a?(String)
111
+ raise ArgumentError, "Invalid option :syntax. Expected String, got #{options[:syntax].class}." if options[:syntax] && !options[:syntax].is_a?(String)
112
+ signature = options[:signature].nil? ? true : options[:signature]
113
+ dcm = self.new
114
+ dcm.send(:read, string, signature, :syntax => options[:syntax])
115
+ if dcm.read?
116
+ logger.debug("DICOM string successfully parsed.")
117
+ else
118
+ logger.warn("Failed to parse this string as DICOM.")
119
+ end
120
+ return dcm
121
+ end
122
+
123
+ # Creates a DObject instance by reading and parsing a DICOM file.
124
+ #
125
+ # @param [String] file a string which specifies the path of the DICOM file to be loaded
126
+ # @example Load a DICOM file
127
+ # require 'dicom'
128
+ # dcm = DICOM::DObject.read('test.dcm')
129
+ #
130
+ def self.read(file)
131
+ raise ArgumentError, "Invalid argument 'file'. Expected String, got #{file.class}." unless file.is_a?(String)
132
+ # Read the file content:
133
+ bin = nil
134
+ unless File.exist?(file)
135
+ logger.error("Invalid (non-existing) file: #{file}")
136
+ else
137
+ unless File.readable?(file)
138
+ logger.error("File exists but I don't have permission to read it: #{file}")
139
+ else
140
+ if File.directory?(file)
141
+ logger.error("Expected a file, got a directory: #{file}")
142
+ else
143
+ if File.size(file) < 8
144
+ logger.error("This file is too small to contain any DICOM information: #{file}.")
145
+ else
146
+ bin = File.open(file, "rb") { |f| f.read }
147
+ end
148
+ end
149
+ end
150
+ end
151
+ # Parse the file contents and create the DICOM object:
152
+ if bin
153
+ dcm = self.parse(bin)
154
+ # If reading failed, and no transfer syntax was detected, we will make another attempt at reading the file while forcing explicit (little endian) decoding.
155
+ # This will help for some rare cases where the DICOM file is saved (erroneously, Im sure) with explicit encoding without specifying the transfer syntax tag.
156
+ if !dcm.read? and !dcm.exists?("0002,0010")
157
+ logger.info("Attempting a second decode pass (assuming Explicit Little Endian transfer syntax).")
158
+ dcm = self.parse(bin, :syntax => EXPLICIT_LITTLE_ENDIAN)
159
+ end
160
+ else
161
+ dcm = self.new
162
+ end
163
+ if dcm.read?
164
+ logger.info("DICOM file successfully read: #{file}")
165
+ else
166
+ logger.warn("Reading DICOM file failed: #{file}")
167
+ end
168
+ return dcm
169
+ end
170
+
171
+ # Creates a DObject instance (DObject is an abbreviation for "DICOM object").
172
+ #
173
+ # The DObject instance holds references to the different types of objects (Element, Item, Sequence)
174
+ # that makes up a DICOM object. A DObject is typically buildt by reading and parsing a file or a binary
175
+ # string (with DObject::read or ::parse), but can also be buildt from an empty state by this method.
176
+ #
177
+ # To customize logging behaviour, refer to the Logging module documentation.
178
+ #
179
+ # @example Create an empty DICOM object
180
+ # require 'dicom'
181
+ # dcm = DICOM::DObject.new
182
+ # @example Increasing the log message threshold (default level is INFO)
183
+ # DICOM.logger.level = Logger::ERROR
184
+ #
185
+ def initialize
186
+ # Initialization of variables that DObject share with other parent elements:
187
+ initialize_parent
188
+ # Structural information (default values):
189
+ @explicit = true
190
+ @str_endian = false
191
+ # Control variables:
192
+ @read_success = nil
193
+ # Initialize a Stream instance which is used for encoding/decoding:
194
+ @stream = Stream.new(nil, @str_endian)
195
+ # The DObject instance is the top of the hierarchy and unlike other elements it has no parent:
196
+ @parent = nil
197
+ end
198
+
199
+ # Checks for equality.
200
+ #
201
+ # Other and self are considered equivalent if they are
202
+ # of compatible types and their attributes are equivalent.
203
+ #
204
+ # @param other an object to be compared with self.
205
+ # @return [Boolean] true if self and other are considered equivalent
206
+ #
207
+ def ==(other)
208
+ if other.respond_to?(:to_dcm)
209
+ other.send(:state) == state
210
+ end
211
+ end
212
+
213
+ alias_method :eql?, :==
214
+
215
+ # Encodes the DICOM object into a series of binary string segments with a specified maximum length.
216
+ #
217
+ # Returns the encoded binary strings in an array.
218
+ #
219
+ # @param [Integer] max_size the maximum allowed size of the binary data strings to be encoded
220
+ # @param [String] transfer_syntax the transfer syntax string to be used when encoding the DICOM object to string segments. When this method is used for making network packets, the transfer_syntax is not part of the object, and thus needs to be specified.
221
+ # @return [Array<String>] the encoded DICOM strings
222
+ # @example Encode the DObject to strings of max length 2^14 bytes
223
+ # encoded_strings = dcm.encode_segments(16384)
224
+ #
225
+ def encode_segments(max_size, transfer_syntax=IMPLICIT_LITTLE_ENDIAN)
226
+ raise ArgumentError, "Invalid argument. Expected an Integer, got #{max_size.class}." unless max_size.is_a?(Integer)
227
+ raise ArgumentError, "Argument too low (#{max_size}), please specify a bigger Integer." unless max_size > 16
228
+ raise "Can not encode binary segments for an empty DICOM object." if children.length == 0
229
+ encode_in_segments(max_size, :syntax => transfer_syntax)
230
+ end
231
+
232
+ # Computes a hash code for this object.
233
+ #
234
+ # @note Two objects with the same attributes will have the same hash code.
235
+ #
236
+ # @return [Fixnum] the object's hash code
237
+ #
238
+ def hash
239
+ state.hash
240
+ end
241
+
242
+ # Prints information of interest related to the DICOM object.
243
+ # Calls the Parent#print method as well as DObject#summary.
244
+ #
245
+ def print_all
246
+ puts ""
247
+ print(:value_max => 30)
248
+ summary
249
+ end
250
+
251
+ # Gathers key information about the DObject as well as some system data, and prints this information to the screen.
252
+ # This information includes properties like encoding, byte order, modality and various image properties.
253
+ #
254
+ # @return [Array<String>] strings describing the properties of the DICOM object
255
+ #
256
+ def summary
257
+ # FIXME: Perhaps this method should be split up in one or two separate methods
258
+ # which just builds the information arrays, and a third method for printing this to the screen.
259
+ sys_info = Array.new
260
+ info = Array.new
261
+ # Version of Ruby DICOM used:
262
+ sys_info << "Ruby DICOM version: #{VERSION}"
263
+ # System endian:
264
+ cpu = (CPU_ENDIAN ? "Big Endian" : "Little Endian")
265
+ sys_info << "Byte Order (CPU): #{cpu}"
266
+ # File path/name:
267
+ info << "File: #{@file}"
268
+ # Modality:
269
+ modality = (LIBRARY.uid(value('0008,0016')) ? LIBRARY.uid(value('0008,0016')).name : "SOP Class unknown or not specified!")
270
+ info << "Modality: #{modality}"
271
+ # Meta header presence (Simply check for the presence of the transfer syntax data element), VR and byte order:
272
+ ts_status = self['0002,0010'] ? '' : ' (Assumed)'
273
+ ts = LIBRARY.uid(transfer_syntax)
274
+ explicit = ts ? ts.explicit? : true
275
+ endian = ts ? ts.big_endian? : false
276
+ meta_comment = ts ? "" : " (But unknown/invalid transfer syntax: #{transfer_syntax})"
277
+ info << "Meta Header: #{self['0002,0010'] ? 'Yes' : 'No'}#{meta_comment}"
278
+ info << "Value Representation: #{explicit ? 'Explicit' : 'Implicit'}#{ts_status}"
279
+ info << "Byte Order (File): #{endian ? 'Big Endian' : 'Little Endian'}#{ts_status}"
280
+ # Pixel data:
281
+ pixels = self[PIXEL_TAG]
282
+ unless pixels
283
+ info << "Pixel Data: No"
284
+ else
285
+ info << "Pixel Data: Yes"
286
+ # Image size:
287
+ cols = (exists?("0028,0011") ? self["0028,0011"].value : "Columns missing")
288
+ rows = (exists?("0028,0010") ? self["0028,0010"].value : "Rows missing")
289
+ info << "Image Size: #{cols}*#{rows}"
290
+ # Frames:
291
+ frames = value("0028,0008") || "1"
292
+ unless frames == "1" or frames == 1
293
+ # Encapsulated or 3D pixel data:
294
+ if pixels.is_a?(Element)
295
+ frames = frames.to_s + " (3D Pixel Data)"
296
+ else
297
+ frames = frames.to_s + " (Encapsulated Multiframe Image)"
298
+ end
299
+ end
300
+ info << "Number of frames: #{frames}"
301
+ # Color:
302
+ colors = (exists?("0028,0004") ? self["0028,0004"].value : "Not specified")
303
+ info << "Photometry: #{colors}"
304
+ # Compression:
305
+ compression = (ts ? (ts.compressed_pixels? ? ts.name : 'No') : 'No' )
306
+ info << "Compression: #{compression}#{ts_status}"
307
+ # Pixel bits (allocated):
308
+ bits = (exists?("0028,0100") ? self["0028,0100"].value : "Not specified")
309
+ info << "Bits per Pixel: #{bits}"
310
+ end
311
+ # Print the DICOM object's key properties:
312
+ separator = "-------------------------------------------"
313
+ puts "System Properties:"
314
+ puts separator + "\n"
315
+ puts sys_info
316
+ puts "\n"
317
+ puts "DICOM Object Properties:"
318
+ puts separator
319
+ puts info
320
+ puts separator
321
+ return info
322
+ end
323
+
324
+ # Returns self.
325
+ #
326
+ # @return [DObject] self
327
+ #
328
+ def to_dcm
329
+ self
330
+ end
331
+
332
+ # Gives the transfer syntax string of the DObject.
333
+ #
334
+ # If a transfer syntax has not been defined in the DObject, a default tansfer syntax is assumed and returned.
335
+ #
336
+ # @return [String] the DObject's transfer syntax
337
+ #
338
+ def transfer_syntax
339
+ return value("0002,0010") || IMPLICIT_LITTLE_ENDIAN
340
+ end
341
+
342
+ # Changes the transfer syntax Element of the DObject instance, and performs re-encoding of all
343
+ # numerical values if a switch of endianness is implied.
344
+ #
345
+ # @note This method does not change the compressed state of the pixel data element. Changing
346
+ # the transfer syntax between an uncompressed and compressed state will NOT change the pixel
347
+ # data accordingly (this must be taken care of manually).
348
+ #
349
+ # @param [String] new_syntax the new transfer syntax string to be applied to the DObject
350
+ #
351
+ def transfer_syntax=(new_syntax)
352
+ # Verify old and new transfer syntax:
353
+ new_uid = LIBRARY.uid(new_syntax)
354
+ old_uid = LIBRARY.uid(transfer_syntax)
355
+ raise ArgumentError, "Invalid/unknown transfer syntax specified: #{new_syntax}" unless new_uid && new_uid.transfer_syntax?
356
+ raise ArgumentError, "Invalid/unknown existing transfer syntax: #{new_syntax} Unable to reliably handle byte order encoding. Modify the transfer syntax element directly instead." unless old_uid && old_uid.transfer_syntax?
357
+ # Set the new transfer syntax:
358
+ if exists?("0002,0010")
359
+ self["0002,0010"].value = new_syntax
360
+ else
361
+ add(Element.new("0002,0010", new_syntax))
362
+ end
363
+ # Update our Stream instance with the new encoding:
364
+ @stream.endian = new_uid.big_endian?
365
+ # If endianness is changed, re-encode elements (only elements depending on endianness will actually be re-encoded):
366
+ encode_children(old_uid.big_endian?) if old_uid.big_endian? != new_uid.big_endian?
367
+ end
368
+
369
+ # Writes the DICOM object to file.
370
+ #
371
+ # @note The goal of the Ruby DICOM library is to yield maximum conformance with the DICOM
372
+ # standard when outputting DICOM files. Therefore, when encoding the DICOM file, manipulation
373
+ # of items such as the meta group, group lengths and header signature may occur. Therefore,
374
+ # the file that is written may not be an exact bitwise copy of the file that was read, even if no
375
+ # DObject manipulation has been done by the user.
376
+ #
377
+ # @param [String] file_name the path of the DICOM file which is to be written to disk
378
+ # @param [Hash] options the options to use for writing the DICOM file
379
+ # @option options [Boolean] :add_meta <DEPRECATED> if set to false, no manipulation of the DICOM object's meta group will be performed before the DObject is written to file
380
+ # @option options [Boolean] :ignore_meta if true, no manipulation of the DICOM object's meta group will be performed before the DObject is written to file
381
+ # @example Encode a DICOM file from a DObject
382
+ # dcm.write('C:/dicom/test.dcm')
383
+ #
384
+ def write(file_name, options={})
385
+ logger.warn("Option :add_meta => false is deprecated. Use option :ignore_meta => true instead.") if options[:add_meta] == false
386
+ raise ArgumentError, "Invalid file_name. Expected String, got #{file_name.class}." unless file_name.is_a?(String)
387
+ insert_missing_meta unless options[:add_meta] == false or options[:ignore_meta]
388
+ write_elements(:file_name => file_name, :signature => true, :syntax => transfer_syntax)
389
+ end
390
+
391
+
392
+ private
393
+
394
+
395
+ # Adds any missing meta group (0002,xxxx) data elements to the DICOM object,
396
+ # to ensure that a valid DICOM object is encoded.
397
+ #
398
+ def insert_missing_meta
399
+ # File Meta Information Version:
400
+ Element.new("0002,0001", [0,1], :parent => self) unless exists?("0002,0001")
401
+ # Media Storage SOP Class UID:
402
+ Element.new("0002,0002", value("0008,0016"), :parent => self) unless exists?("0002,0002")
403
+ # Media Storage SOP Instance UID:
404
+ Element.new("0002,0003", value("0008,0018"), :parent => self) unless exists?("0002,0003")
405
+ # Transfer Syntax UID:
406
+ Element.new("0002,0010", transfer_syntax, :parent => self) unless exists?("0002,0010")
407
+ if !exists?("0002,0012") and !exists?("0002,0013")
408
+ # Implementation Class UID:
409
+ Element.new("0002,0012", UID_ROOT, :parent => self)
410
+ # Implementation Version Name:
411
+ Element.new("0002,0013", NAME, :parent => self)
412
+ end
413
+ # Source Application Entity Title:
414
+ Element.new("0002,0016", DICOM.source_app_title, :parent => self) unless exists?("0002,0016")
415
+ # Group Length: Delete the old one (if it exists) before creating a new one.
416
+ delete("0002,0000")
417
+ Element.new("0002,0000", meta_group_length, :parent => self)
418
+ end
419
+
420
+ # Determines the length of the meta group in the DObject instance.
421
+ #
422
+ # @return [Integer] the length of the file meta group string
423
+ #
424
+ def meta_group_length
425
+ group_length = 0
426
+ meta_elements = group(META_GROUP)
427
+ tag = 4
428
+ vr = 2
429
+ meta_elements.each do |element|
430
+ case element.vr
431
+ when "OB","OW","OF","SQ","UN","UT"
432
+ length = 6
433
+ else
434
+ length = 2
435
+ end
436
+ group_length += tag + vr + length + element.bin.length
437
+ end
438
+ return group_length
439
+ end
440
+
441
+ # Collects the attributes of this instance.
442
+ #
443
+ # @return [Array<Element, Sequence>] an array of elements and sequences
444
+ #
445
+ def state
446
+ @tags
447
+ end
448
+
449
+ end
450
+
451
+ end