dicom 0.7 → 0.8

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,433 @@
1
+ # Copyright 2008-2010 Christoffer Lervag
2
+ #
3
+ # === Notes
4
+ #
5
+ # In addition to reading files that are compliant to DICOM 3 Part 10, the philosophy of the Ruby DICOM library is to feature maximum
6
+ # compatibility, and as such it will also successfully read many types of 'DICOM' files that deviate in some way from the standard.
7
+
8
+ module DICOM
9
+
10
+ # The DRead class parses the DICOM data from a binary string.
11
+ #
12
+ # The source of this binary string is typically either a DICOM file or a DICOM network transmission.
13
+ #
14
+ class DRead
15
+
16
+ # A boolean which reports the explicitness of the DICOM string, true if explicit and false if implicit.
17
+ attr_reader :explicit
18
+ # A boolean which reports the endianness of the post-meta group part of the DICOM string (true for big endian, false for little endian).
19
+ attr_reader :file_endian
20
+ # An array which records any status messages that are generated while parsing the DICOM string.
21
+ attr_reader :msg
22
+ # A DObject instance which the parsed data elements will be connected to.
23
+ attr_reader :obj
24
+ # A boolean which records whether the DICOM string contained the proper DICOM header signature of 128 bytes + 'DICM'.
25
+ attr_reader :signature
26
+ # A boolean which reports whether the DICOM string was parsed successfully (true) or not (false).
27
+ attr_reader :success
28
+
29
+ # Creates a DRead instance.
30
+ # Parses the DICOM string, builds data element objects and connects these with the DObject instance.
31
+ #
32
+ # === Parameters
33
+ #
34
+ # * <tt>obj</tt> -- A DObject instance which the parsed data elements will be connected to.
35
+ # * <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.
36
+ # * <tt>options</tt> -- A hash of parameters.
37
+ #
38
+ # === Options
39
+ #
40
+ # * <tt>:syntax</tt> -- String. If specified, the decoding of the DICOM string will be forced to use this transfer syntax.
41
+ # * <tt>:bin</tt> -- Boolean. If set to true, string parameter will be interpreted as a binary DICOM string, and not a path string, which is the default behaviour.
42
+ #
43
+ def initialize(obj, string=nil, options={})
44
+ # Set the DICOM object as an instance variable:
45
+ @obj = obj
46
+ # Some of the options need to be transferred to instance variables:
47
+ @transfer_syntax = options[:syntax]
48
+ # Initiate the variables that are used during file reading:
49
+ init_variables
50
+ # Are we going to read from a file, or read from a binary string?
51
+ if options[:bin]
52
+ # Read from the provided binary string:
53
+ @str = string
54
+ else
55
+ # Read from file:
56
+ open_file(string)
57
+ # Read the initial header of the file:
58
+ if @file == nil
59
+ # File is not readable, so we return:
60
+ @success = false
61
+ return
62
+ else
63
+ # Extract the content of the file to a binary string:
64
+ @str = @file.read
65
+ @file.close
66
+ end
67
+ end
68
+ # Create a Stream instance to handle the decoding of content from this binary string:
69
+ @stream = Stream.new(@str, @file_endian)
70
+ # Do not check for header information when supplied a (network) binary string:
71
+ unless options[:bin]
72
+ # Read and verify the DICOM header:
73
+ header = check_header
74
+ # If the file didnt have the expected header, we will attempt to read
75
+ # data elements from the very start file:
76
+ if header == false
77
+ @stream.skip(-132)
78
+ elsif header == nil
79
+ # Not a valid DICOM file, return:
80
+ @success = false
81
+ return
82
+ end
83
+ end
84
+ # Run a loop which parses Data Elements, one by one, until the end of the data string is reached:
85
+ data_element = true
86
+ while data_element do
87
+ # Using a rescue clause since processing Data Elements can cause errors when parsing an invalid DICOM string.
88
+ begin
89
+ # Extracting Data element information (nil is returned if end of file is encountered in a normal way).
90
+ data_element = process_data_element
91
+ rescue
92
+ # The parse algorithm crashed. Set data_element to false to break the loop and toggle the success boolean to indicate failure.
93
+ @msg << "Error! Failed to process a Data Element. This is probably the result of invalid or corrupt DICOM data."
94
+ @success = false
95
+ data_element = false
96
+ end
97
+ end
98
+ end
99
+
100
+
101
+ # Following methods are private:
102
+ private
103
+
104
+
105
+ # Checks for the official DICOM header signature.
106
+ # Returns true if the proper signature is present, false if it is not present,
107
+ # and nil if thestring was shorter then the length of the DICOM signature.
108
+ #
109
+ def check_header
110
+ # According to the official DICOM standard, a DICOM file shall contain 128 consequtive (zero) bytes,
111
+ # followed by 4 bytes that spell the string 'DICM'. Apparently, some providers seems to skip this in their DICOM files.
112
+ # Check that the file is long enough to contain a valid header:
113
+ if @str.length < 132
114
+ # This does not seem to be a valid DICOM file and so we return.
115
+ return nil
116
+ else
117
+ @stream.skip(128)
118
+ # Next 4 bytes should spell "DICM":
119
+ identifier = @stream.decode(4, "STR")
120
+ @header_length += 132
121
+ if identifier != "DICM" then
122
+ # Header signature is not valid (we will still try to read it is a DICOM file though):
123
+ @msg << "Warning: The specified file does not contain the official DICOM header. Will try to read the file anyway, as some sources are known to skip this header."
124
+ # As the file is not conforming to the DICOM standard, it is possible that it does not contain a
125
+ # transfer syntax element, and as such, we attempt to choose the most probable encoding values here:
126
+ @explicit = false
127
+ return false
128
+ else
129
+ # Header signature is valid:
130
+ @signature = true
131
+ return true
132
+ end
133
+ end
134
+ end
135
+
136
+ # Handles the process of reading a data element from the DICOM string, and creating an element object from the parsed data.
137
+ # Returns nil if end of file has been reached (in an expected way), false if the element parse failed, and true if an element was parsed successfully.
138
+ #
139
+ #--
140
+ # FIXME: This method has grown a bit messy and isn't very easy to follow. It would be nice if it could be cleaned up somewhat.
141
+ #
142
+ def process_data_element
143
+ # STEP 1:
144
+ # Attempt to read data element tag:
145
+ tag = read_tag
146
+ # Return nil if we have (naturally) reached the end of the data string.
147
+ return nil unless tag
148
+ # STEP 2:
149
+ # Access library to retrieve the data element name and VR from the tag we just read:
150
+ # (Note: VR will be overwritten in the next step if the DICOM file contains VR (explicit encoding))
151
+ name, vr = LIBRARY.get_name_vr(tag)
152
+ # STEP 3:
153
+ # Read VR (if it exists) and the length value:
154
+ vr, length = read_vr_length(vr,tag)
155
+ level_vr = vr
156
+ # STEP 4:
157
+ # Reading value of data element.
158
+ # Special handling needed for items in encapsulated image data:
159
+ if @enc_image and tag == ITEM_TAG
160
+ # The first item appearing after the image element is a 'normal' item, the rest hold image data.
161
+ # Note that the first item will contain data if there are multiple images, and so must be read.
162
+ vr = "OW" # how about alternatives like OB?
163
+ # Modify name of item if this is an item that holds pixel data:
164
+ if @current_element.tag != PIXEL_TAG
165
+ name = PIXEL_ITEM_NAME
166
+ end
167
+ end
168
+ # Read the binary string of the element:
169
+ bin = read_bin(length) if length > 0
170
+ # Read the value of the element (if it contains data, and it is not a sequence or ordinary item):
171
+ if length > 0 and vr != "SQ" and tag != ITEM_TAG
172
+ # Read the element's processed value:
173
+ value = read_value(vr, length)
174
+ else
175
+ # Data element has no value (data).
176
+ value = nil
177
+ # Special case: Check if pixel data element is sequenced:
178
+ if tag == PIXEL_TAG
179
+ # Change name and vr of pixel data element if it does not contain data itself:
180
+ name = ENCAPSULATED_PIXEL_NAME
181
+ level_vr = "SQ"
182
+ @enc_image = true
183
+ end
184
+ end
185
+ # Create an Element from the gathered data:
186
+ if level_vr == "SQ" or tag == ITEM_TAG
187
+ if level_vr == "SQ"
188
+ # Create a Sequence:
189
+ @current_element = Sequence.new(tag, :length => length, :name => name, :parent => @current_parent, :vr => vr)
190
+ elsif tag == ITEM_TAG
191
+ # Create an Item:
192
+ if @enc_image
193
+ @current_element = Item.new(:bin => bin, :length => length, :name => name, :parent => @current_parent, :vr => vr)
194
+ else
195
+ @current_element = Item.new(:length => length, :name => name, :parent => @current_parent, :vr => vr)
196
+ end
197
+ end
198
+ # Common operations on the two types of parent elements:
199
+ if length == 0 and @enc_image
200
+ # Set as parent. Exceptions when parent will not be set:
201
+ # Item/Sequence has zero length & Item is a pixel item (which contains pixels, not child elements).
202
+ @current_parent = @current_element
203
+ elsif length != 0
204
+ @current_parent = @current_element unless name == PIXEL_ITEM_NAME
205
+ end
206
+ # If length is specified (no delimitation items), load a new DRead instance to read these child elements
207
+ # and load them into the current sequence. The exception is when we have a pixel data item.
208
+ if length > 0 and not @enc_image
209
+ child_reader = DRead.new(@current_element, bin, :bin => true, :syntax => @transfer_syntax)
210
+ @current_parent = @current_parent.parent
211
+ @msg << child_reader.msg
212
+ @success = child_reader.success
213
+ return false unless @success
214
+ end
215
+ elsif DELIMITER_TAGS.include?(tag)
216
+ # We do not create an element for the delimiter items.
217
+ # The occurance of such a tag indicates that a sequence or item has ended, and the parent must be changed:
218
+ @current_parent = @current_parent.parent
219
+ else
220
+ # Create an ordinary Data Element:
221
+ @current_element = DataElement.new(tag, value, :bin => bin, :name => name, :parent => @current_parent, :vr => vr)
222
+ # Check that the data stream didnt end abruptly:
223
+ if length != @current_element.bin.length
224
+ set_abrupt_error
225
+ return false # (Failed)
226
+ end
227
+ end
228
+ # Return true to indicate success:
229
+ return true
230
+ end
231
+
232
+ # Reads and returns the data element's tag (the 4 first bytes of a data element).
233
+ # Returns nil if no tag could be read (end of string).
234
+ #
235
+ def read_tag
236
+ tag = @stream.decode_tag
237
+ if tag
238
+ # When we shift from group 0002 to another group we need to update our endian/explicitness variables:
239
+ if tag.group != META_GROUP and @switched == false
240
+ switch_syntax
241
+ # We may need to read our tag again if endian has switched (in which case it has been misread):
242
+ if @switched_endian
243
+ @stream.skip(-4)
244
+ tag = @stream.decode_tag
245
+ end
246
+ end
247
+ end
248
+ return tag
249
+ end
250
+
251
+ # Reads the data element's value representation (2 bytes), as well as the data element's length (varying length: 2-6 bytes).
252
+ # The decoding scheme to be applied depends on explicitness, data element type and vr.
253
+ # Returns vr and length.
254
+ #
255
+ # === Parameters
256
+ #
257
+ # * <tt>vr</tt> -- String. The value representation that was retrieved from the dictionary for the tag of this data element.
258
+ # * <tt>tag</tt> -- String. The tag of this data element.
259
+ #
260
+ def read_vr_length(vr, tag)
261
+ # Structure will differ, dependent on whether we have explicit or implicit encoding:
262
+ reserved = 0
263
+ bytes = 0
264
+ # *****EXPLICIT*****:
265
+ if @explicit == true
266
+ # Step 1: Read VR, 2 bytes (if it exists - which are all cases except for the item related elements)
267
+ vr = @stream.decode(2, "STR") unless ITEM_TAGS.include?(tag)
268
+ # Step 2: Read length
269
+ # Three possible structures for value length here, dependent on element vr:
270
+ case vr
271
+ when "OB","OW","OF","SQ","UN","UT"
272
+ # 6 bytes total (2 reserved bytes preceeding the 4 byte value length)
273
+ reserved = 2
274
+ bytes = 4
275
+ when ITEM_VR
276
+ # For the item elements: "FFFE,E000", "FFFE,E00D" and "FFFE,E0DD":
277
+ bytes = 4
278
+ else
279
+ # For all the other element vr, value length is 2 bytes:
280
+ bytes = 2
281
+ end
282
+ else
283
+ # *****IMPLICIT*****:
284
+ bytes = 4
285
+ end
286
+ # Handle skips and read out length value:
287
+ @stream.skip(reserved)
288
+ if bytes == 2
289
+ length = @stream.decode(bytes, "US") # (2)
290
+ else
291
+ length = @stream.decode(bytes, "SL") # (4)
292
+ end
293
+ if length%2 > 0 and length > 0
294
+ # According to the DICOM standard, all data element lengths should be an even number.
295
+ # If it is not, it may indicate a file that is not standards compliant or it might even not be a DICOM file.
296
+ @msg << "Warning: Odd number of bytes in data element's length occured. This is a violation of the DICOM standard, but program will attempt to read the rest of the file anyway."
297
+ end
298
+ return vr, length
299
+ end
300
+
301
+ # Reads and returns the data element's binary value string (varying length).
302
+ #
303
+ # === Parameters
304
+ #
305
+ # * <tt>length</tt> -- Fixnum. The length of the binary string that will be extracted.
306
+ #
307
+ def read_bin(length)
308
+ return @stream.extract(length)
309
+ end
310
+
311
+ # Decodes and returns the data element's value (varying length).
312
+ #
313
+ # === Notes
314
+ #
315
+ # * Data elements which have multiple numbers as value, will have these numbers joined to a string, separated by the \ character.
316
+ # * For some value representations (OW, OB, OF, UN), a value is not processed, and nil is returned.
317
+ # This means that for data like pixel data, compressed data, unknown data, a value is not available in the data element,
318
+ # and must be processed from the data element's binary variable.
319
+ #
320
+ # === Parameters
321
+ #
322
+ # * <tt>vr</tt> -- String. The value representation of the data element which the value to be decoded belongs to.
323
+ # * <tt>length</tt> -- Fixnum. The length of the binary string that will be extracted.
324
+ #
325
+ def read_value(vr, length)
326
+ unless vr == "OW" or vr == "OB" or vr == "OF" or vr == "UN"
327
+ # Since the binary string has already been extracted for this data element, we must first "rewind":
328
+ @stream.skip(-length)
329
+ # Decode data:
330
+ value = @stream.decode(length, vr)
331
+ # If the returned value is an array of multiple values, we will join these values to a string with the separator "\":
332
+ value = value.join("\\") if value.is_a?(Array)
333
+ else
334
+ # No decoded value:
335
+ value = nil
336
+ end
337
+ return value
338
+ end
339
+
340
+ # Tests if a file is readable, and if so, opens it.
341
+ #
342
+ # === Parameters
343
+ #
344
+ # * <tt>file</tt> -- A path/file string.
345
+ #
346
+ def open_file(file)
347
+ if File.exist?(file)
348
+ if File.readable?(file)
349
+ if not File.directory?(file)
350
+ if File.size(file) > 8
351
+ @file = File.new(file, "rb")
352
+ else
353
+ @msg << "Error! File is too small to contain DICOM information (#{file})."
354
+ end
355
+ else
356
+ @msg << "Error! File is a directory (#{file})."
357
+ end
358
+ else
359
+ @msg << "Error! File exists but I don't have permission to read it (#{file})."
360
+ end
361
+ else
362
+ @msg << "Error! The file you have supplied does not exist (#{file})."
363
+ end
364
+ end
365
+
366
+ # Registers an unexpected error by toggling a success boolean and recording an error message.
367
+ # The DICOM string ended abruptly because the data element's value was shorter than expected.
368
+ #
369
+ def set_abrupt_error
370
+ @msg << "Error! The parsed data of the last data element #{@current_element.tag} does not match its specified length value. This is probably the result of invalid or corrupt DICOM data."
371
+ @success = false
372
+ end
373
+
374
+ # Changes encoding variables as the file reading proceeds past the initial meta group part (0002,xxxx) of the DICOM file.
375
+ #
376
+ def switch_syntax
377
+ # Get the transfer syntax string, unless it has already been provided by keyword:
378
+ unless @transfer_syntax
379
+ ts_element = @obj["0002,0010"]
380
+ if ts_element
381
+ @transfer_syntax = ts_element.value
382
+ else
383
+ @transfer_syntax = IMPLICIT_LITTLE_ENDIAN
384
+ end
385
+ end
386
+ # Query the library with our particular transfer syntax string:
387
+ valid_syntax, @rest_explicit, @rest_endian = LIBRARY.process_transfer_syntax(@transfer_syntax)
388
+ unless valid_syntax
389
+ @msg << "Warning: Invalid/unknown transfer syntax! Will try reading the file, but errors may occur."
390
+ end
391
+ # We only plan to run this method once:
392
+ @switched = true
393
+ # Update endian, explicitness and unpack variables:
394
+ @switched_endian = true if @rest_endian != @file_endian
395
+ @file_endian = @rest_endian
396
+ @stream.endian = @rest_endian
397
+ @explicit = @rest_explicit
398
+ end
399
+
400
+
401
+ # Creates various instance variables that are used when parsing the DICOM string.
402
+ #
403
+ def init_variables
404
+ # Array that will holde any messages generated while reading the DICOM file:
405
+ @msg = Array.new
406
+ # Presence of the official DICOM signature:
407
+ @signature = false
408
+ # Default explicitness of start of DICOM file:
409
+ @explicit = true
410
+ # Default endianness of start of DICOM files is little endian:
411
+ @file_endian = false
412
+ # A switch of endianness may occur after the initial meta group, an this needs to be monitored:
413
+ @switched_endian = false
414
+ # Explicitness of the remaining groups after the initial 0002 group:
415
+ @rest_explicit = false
416
+ # Endianness of the remaining groups after the first group:
417
+ @rest_endian = false
418
+ # When the file switch from group 0002 to a later group we will update encoding values, and this switch will keep track of that:
419
+ @switched = false
420
+ # Keeping track of the data element parent status while parsing the DICOM string:
421
+ @current_parent = @obj
422
+ # Keeping track of what is the current data element:
423
+ @current_element = @obj
424
+ # Items contained under the pixel data element may contain data directly, so we need a variable to keep track of this:
425
+ @enc_image = false
426
+ # Assume header size is zero bytes until otherwise is determined:
427
+ @header_length = 0
428
+ # Assume file will be read successfully and toggle it later if we experience otherwise:
429
+ @success = true
430
+ end
431
+
432
+ end
433
+ end
@@ -0,0 +1,397 @@
1
+ # Copyright 2009-2010 Christoffer Lervag
2
+
3
+ module DICOM
4
+
5
+ # This class contains code for setting up a Service Class Provider (SCP),
6
+ # which will act as a simple storage node (a DICOM server that receives images).
7
+ #
8
+ class DServer
9
+
10
+ # Runs the server and takes a block for initializing.
11
+ #
12
+ # === Parameters
13
+ #
14
+ # * <tt>port</tt> -- Fixnum. The network port to be used. Defaults to 104.
15
+ # * <tt>path</tt> -- String. The path where incoming DICOM files will be stored. Defaults to "./received/".
16
+ # * <tt>&block</tt> -- A block of code that will be run on the DServer instance, between creation and the launch of the SCP itself.
17
+ #
18
+ # === Examples
19
+ #
20
+ # require 'dicom'
21
+ # require 'my_file_handler'
22
+ # include DICOM
23
+ # DServer.run(104, 'c:/temp/') do
24
+ # timeout = 100
25
+ # file_handler = MyFileHandler
26
+ # end
27
+ #
28
+ def self.run(port=104, path='./received/', &block)
29
+ server = DServer.new(port)
30
+ server.instance_eval(&block)
31
+ server.start_scp(path)
32
+ end
33
+
34
+ # A customized FileHandler class to use instead of the default FileHandler included with Ruby DICOM.
35
+ attr_accessor :file_handler
36
+ # The name of the server (application entity).
37
+ attr_accessor :host_ae
38
+ # The maximum allowed size of network packages (in bytes).
39
+ attr_accessor :max_package_size
40
+ # The network port to be used.
41
+ attr_accessor :port
42
+ # The maximum period the server will wait on an answer from a client before aborting the communication.
43
+ attr_accessor :timeout
44
+ # A boolean which defines if notices/warnings/errors will be printed to the screen (true) or not (false).
45
+ attr_accessor :verbose
46
+
47
+ # A hash containing the abstract syntaxes that will be accepted.
48
+ attr_reader :accepted_abstract_syntaxes
49
+ # A hash containing the transfer syntaxes that will be accepted.
50
+ attr_reader :accepted_transfer_syntaxes
51
+ # An array containing any error messages recorded.
52
+ attr_reader :errors
53
+ # An array containing any status messages recorded.
54
+ attr_reader :notices
55
+
56
+ # Creates a DServer instance.
57
+ #
58
+ # === Parameters
59
+ #
60
+ # * <tt>port</tt> -- Fixnum. The network port to be used. Defaults to 104.
61
+ # * <tt>options</tt> -- A hash of parameters.
62
+ #
63
+ # === Options
64
+ #
65
+ # * <tt>:file_handler</tt> -- A customized FileHandler class to use instead of the default FileHandler.
66
+ # * <tt>:host_ae</tt> -- String. The name of the server (application entity).
67
+ # * <tt>:max_package_size</tt> -- Fixnum. The maximum allowed size of network packages (in bytes).
68
+ # * <tt>:timeout</tt> -- Fixnum. The maximum period the server will wait on an answer from a client before aborting the communication.
69
+ # * <tt>:verbose</tt> -- Boolean. If set to false, the DServer instance will run silently and not output warnings and error messages to the screen. Defaults to true.
70
+ #
71
+ # === Examples
72
+ #
73
+ # # Create a server using default settings:
74
+ # s = DICOM::DServer.new
75
+ # # Create a server and specify a host name as well as a custom buildt file handler:
76
+ # require 'MyFileHandler'
77
+ # server = DICOM::DServer.new(104, :host_ae => "RUBY_SERVER", :file_handler => DICOM::MyFileHandler)
78
+ #
79
+ def initialize(port=104, options={})
80
+ require 'socket'
81
+ # Required parameters:
82
+ @port = port
83
+ # Optional parameters (and default values):
84
+ @file_handler = options[:file_handler] || FileHandler
85
+ @host_ae = options[:host_ae] || "RUBY_DICOM"
86
+ @max_package_size = options[:max_package_size] || 32768 # 16384
87
+ @timeout = options[:timeout] || 10 # seconds
88
+ @min_length = 12 # minimum number of bytes to expect in an incoming transmission
89
+ @verbose = options[:verbose]
90
+ @verbose = true if @verbose == nil # Default verbosity is 'on'.
91
+ # Other instance variables:
92
+ @errors = Array.new # errors and warnings are put in this array
93
+ @notices = Array.new # information on successful transmissions are put in this array
94
+ # Variables used for monitoring state of transmission:
95
+ @connection = nil # TCP connection status
96
+ @association = nil # DICOM Association status
97
+ @request_approved = nil # Status of our DICOM request
98
+ @release = nil # Status of received, valid release response
99
+ set_default_accepted_syntaxes
100
+ end
101
+
102
+ # Adds an abstract syntax to the list of abstract syntaxes that the server will accept.
103
+ #
104
+ # === Parameters
105
+ #
106
+ # * <tt>uid</tt> -- An abstract syntax UID string.
107
+ #
108
+ def add_abstract_syntax(uid)
109
+ if uid.is_a?(String)
110
+ name = LIBRARY.get_syntax_description(uid) || "Unknown UID"
111
+ @accepted_abstract_syntaxes[uid] = name
112
+ else
113
+ raise "Invalid type of UID. Expected String, got #{uid.class}!"
114
+ end
115
+ end
116
+
117
+ # Adds a transfer syntax to the list of transfer syntaxes that the server will accept.
118
+ #
119
+ #
120
+ # === Parameters
121
+ #
122
+ # * <tt>uid</tt> -- A transfer syntax UID string.
123
+ #
124
+ def add_transfer_syntax(uid)
125
+ if uid.is_a?(String)
126
+ name = LIBRARY.get_syntax_description(uid) || "Unknown UID"
127
+ @accepted_transfer_syntaxes[uid] = name
128
+ else
129
+ raise "Invalid type of UID. Expected String, got #{uid.class}!"
130
+ end
131
+ end
132
+
133
+ # Prints the list of accepted abstract syntaxes to the screen.
134
+ #
135
+ def print_abstract_syntaxes
136
+ # Determine length of longest key to ensure pretty print:
137
+ max_uid = @accepted_abstract_syntaxes.keys.collect{|k| k.length}.max
138
+ puts "Abstract syntaxes which are accepted by this SCP:"
139
+ @accepted_abstract_syntaxes.sort.each do |pair|
140
+ puts "#{pair[0]}#{' '*(max_uid-pair[0].length)} #{pair[1]}"
141
+ end
142
+ end
143
+
144
+ # Prints the list of accepted transfer syntaxes to the screen.
145
+ #
146
+ def print_transfer_syntaxes
147
+ # Determine length of longest key to ensure pretty print:
148
+ max_uid = @accepted_transfer_syntaxes.keys.collect{|k| k.length}.max
149
+ puts "Transfer syntaxes which are accepted by this SCP:"
150
+ @accepted_transfer_syntaxes.sort.each do |pair|
151
+ puts "#{pair[0]}#{' '*(max_uid-pair[0].length)} #{pair[1]}"
152
+ end
153
+ end
154
+
155
+ # Removes a specific abstract syntax from the list of abstract syntaxes that the server will accept.
156
+ #
157
+ #
158
+ # === Parameters
159
+ #
160
+ # * <tt>uid</tt> -- An abstract syntax UID string.
161
+ #
162
+ def remove_abstract_syntax(uid)
163
+ if uid.is_a?(String)
164
+ @accepted_abstract_syntaxes.delete(uid)
165
+ else
166
+ raise "Invalid type of UID. Expected String, got #{uid.class}!"
167
+ end
168
+ end
169
+
170
+ # Removes a specific transfer syntax from the list of transfer syntaxes that the server will accept.
171
+ #
172
+ # === Parameters
173
+ #
174
+ # * <tt>uid</tt> -- A transfer syntax UID string.
175
+ #
176
+ def remove_transfer_syntax(uid)
177
+ if uid.is_a?(String)
178
+ @accepted_transfer_syntaxes.delete(uid)
179
+ else
180
+ raise "Invalid type of UID. Expected String, got #{uid.class}!"
181
+ end
182
+ end
183
+
184
+ # Completely clears the list of abstract syntaxes that the server will accept.
185
+ #
186
+ # === Notes
187
+ #
188
+ # * Following such a removal, the user must ensure to add the specific abstract syntaxes that are to be accepted by the server.
189
+ #
190
+ def remove_all_abstract_syntaxes
191
+ @accepted_abstract_syntaxes = Hash.new
192
+ end
193
+
194
+ # Completely clears the list of transfer syntaxes that the server will accept.
195
+ #
196
+ # === Notes
197
+ #
198
+ # * Following such a removal, the user must ensure to add the specific transfer syntaxes that are to be accepted by the server.
199
+ #
200
+ def remove_all_transfer_syntaxes
201
+ @accepted_transfer_syntaxes = Hash.new
202
+ end
203
+
204
+ # Starts the Service Class Provider (SCP).
205
+ #
206
+ # === Notes
207
+ #
208
+ # * This service acts as a simple storage node, which receives DICOM files and stores them in a specified folder.
209
+ # * Customized storage actions can be set my modifying or replacing the FileHandler class.
210
+ #
211
+ # === Parameters
212
+ #
213
+ # * <tt>path</tt> -- The path where incoming files are to be saved.
214
+ #
215
+ def start_scp(path='./received/')
216
+ if @accepted_abstract_syntaxes.size > 0 and @accepted_transfer_syntaxes.size > 0
217
+ add_notice("Starting DICOM SCP server...")
218
+ add_notice("*********************************")
219
+ # Initiate server:
220
+ @scp = TCPServer.new(@port)
221
+ # Use a loop to listen for incoming messages:
222
+ loop do
223
+ Thread.start(@scp.accept) do |session|
224
+ # Initialize the network package handler for this session:
225
+ link = Link.new(:host_ae => @host_ae, :max_package_size => @max_package_size, :timeout => @timeout, :verbose => @verbose, :file_handler => @file_handler)
226
+ link.set_session(session)
227
+ # Note the time of reception as well as who has contacted us:
228
+ add_notice(Time.now.strftime("%Y-%m-%d %H:%M:%S"))
229
+ add_notice("Connection established with: #{session.peeraddr[2]} (IP: #{session.peeraddr[3]})")
230
+ # Receive an incoming message:
231
+ segments = link.receive_multiple_transmissions
232
+ info = segments.first
233
+ # Interpret the received message:
234
+ if info[:valid]
235
+ association_error = check_association_request(info)
236
+ unless association_error
237
+ info, approved, rejected = process_syntax_requests(info)
238
+ link.handle_association_accept(info)
239
+ if approved > 0
240
+ if approved == 1
241
+ add_notice("Accepted the association request with context: #{LIBRARY.get_syntax_description(info[:pc].first[:abstract_syntax])}")
242
+ else
243
+ if rejected == 0
244
+ add_notice("Accepted all #{approved} proposed contexts in the association request.")
245
+ else
246
+ add_notice("Accepted only #{approved} of #{approved+rejected} of the proposed contexts in the association request.")
247
+ end
248
+ end
249
+ # Process the incoming data. This method will also take care of releasing the association:
250
+ success, messages = link.handle_incoming_data(path)
251
+ if success
252
+ add_notice(messages) if messages.first
253
+ else
254
+ # Something has gone wrong:
255
+ add_error(messages) if messages.first
256
+ end
257
+ else
258
+ # No abstract syntaxes in the incoming request were accepted:
259
+ if rejected == 1
260
+ add_notice("Rejected the association request with proposed context: #{LIBRARY.get_syntax_description(info[:pc].first[:abstract_syntax])}")
261
+ else
262
+ add_notice("Rejected all #{rejected} proposed contexts in the association request.")
263
+ end
264
+ # Since the requested abstract syntax was not accepted, the association must be released.
265
+ link.await_release
266
+ end
267
+ else
268
+ # The incoming association was not formally correct.
269
+ link.handle_rejection
270
+ end
271
+ else
272
+ # The incoming message was not recognised as a valid DICOM message. Abort:
273
+ link.handle_abort
274
+ end
275
+ # Terminate the connection:
276
+ link.stop_session
277
+ add_notice("*********************************")
278
+ end
279
+ end
280
+ else
281
+ raise "Unable to start SCP server as no accepted abstract syntaxes have been set!" if @accepted_abstract_syntaxes.length == 0
282
+ raise "Unable to start SCP server as no accepted transfer syntaxes have been set!" if @accepted_transfer_syntaxes.length == 0
283
+ end
284
+ end
285
+
286
+
287
+ # Following methods are private:
288
+ private
289
+
290
+
291
+ # Adds a warning or error message to the instance array holding messages,
292
+ # and prints the information to the screen if verbose is set.
293
+ #
294
+ # === Parameters
295
+ #
296
+ # * <tt>error</tt> -- A single error message or an array of error messages.
297
+ #
298
+ def add_error(error)
299
+ if @verbose
300
+ puts error
301
+ end
302
+ @errors << error
303
+ end
304
+
305
+ # Adds a notice (information regarding progress or successful communications) to the instance array,
306
+ # and prints the information to the screen if verbose is set.
307
+ #
308
+ # === Parameters
309
+ #
310
+ # * <tt>notice</tt> -- A single status message or an array of status messages.
311
+ #
312
+ def add_notice(notice)
313
+ if @verbose
314
+ puts notice
315
+ end
316
+ @notices << notice
317
+ end
318
+
319
+ # Checks if the association request is formally correct, by matching against an exact application context UID.
320
+ # Returns nil if valid, and an error code if it is not approved.
321
+ #
322
+ # === Notes
323
+ #
324
+ # Other things can potentionally be checked here too, if we want to make the server more strict with regards to what information is received:
325
+ # * Application context name, calling AE title, called AE title
326
+ # * Description of error codes are given in the DICOM Standard, PS 3.8, Chapter 9.3.4 (Table 9-21).
327
+ #
328
+ # === Parameters
329
+ #
330
+ # * <tt>info</tt> -- An information hash from the received association request.
331
+ #
332
+ def check_association_request(info)
333
+ unless info[:application_context] == APPLICATION_CONTEXT
334
+ error = 2 # (application context name not supported)
335
+ add_error("Error: The application context in the incoming association request was not recognized: (#{info[:application_context]})")
336
+ else
337
+ error = nil
338
+ end
339
+ return error
340
+ end
341
+
342
+ # Checks if the requested abstract syntax & its transfer syntax(es) are supported by this server instance,
343
+ # and inserts a corresponding result code for each presentation context.
344
+ # Returns the modified association information hash, as well as the number of abstract syntaxes that were accepted and rejected.
345
+ #
346
+ # === Notes
347
+ #
348
+ # * Description of error codes are given in the DICOM Standard, PS 3.8, Chapter 9.3.3.2 (Table 9-18).
349
+ #
350
+ # === Parameters
351
+ #
352
+ # * <tt>info</tt> -- An information hash from the received association request.
353
+ #
354
+ def process_syntax_requests(info)
355
+ # A couple of variables used to analyse the properties of the association:
356
+ approved = 0
357
+ rejected = 0
358
+ # Loop through the presentation contexts:
359
+ info[:pc].each do |pc|
360
+ if @accepted_abstract_syntaxes[pc[:abstract_syntax]]
361
+ # Abstract syntax accepted. Proceed to check its transfer syntax(es):
362
+ proposed_transfer_syntaxes = pc[:ts].collect{|t| t[:transfer_syntax]}.sort
363
+ # Choose the first proposed transfer syntax that exists in our list of accepted transfer syntaxes:
364
+ accepted_transfer_syntax = nil
365
+ proposed_transfer_syntaxes.each do |proposed_ts|
366
+ if @accepted_transfer_syntaxes.include?(proposed_ts)
367
+ accepted_transfer_syntax = proposed_ts
368
+ break
369
+ end
370
+ end
371
+ if accepted_transfer_syntax
372
+ # Both abstract and transfer syntax has been approved:
373
+ pc[:result] = ACCEPTANCE
374
+ pc[:selected_transfer_syntax] = accepted_transfer_syntax
375
+ # Update our status variables:
376
+ approved += 1
377
+ else
378
+ # No transfer syntax was accepted for this particular presentation context:
379
+ pc[:result] = TRANSFER_SYNTAX_REJECTED
380
+ rejected += 1
381
+ end
382
+ else
383
+ # Abstract syntax rejected:
384
+ pc[:result] = ABSTRACT_SYNTAX_REJECTED
385
+ end
386
+ end
387
+ return info, approved, rejected
388
+ end
389
+
390
+ # Sets the default accepted abstract syntaxes and transfer syntaxes for this SCP.
391
+ #
392
+ def set_default_accepted_syntaxes
393
+ @accepted_transfer_syntaxes, @accepted_abstract_syntaxes = LIBRARY.extract_transfer_syntaxes_and_sop_classes
394
+ end
395
+
396
+ end
397
+ end