dicom 0.7 → 0.8
Sign up to get free protection for your applications and to get access to all the features.
- data/CHANGELOG +55 -0
- data/README +51 -29
- data/init.rb +1 -0
- data/lib/dicom.rb +35 -21
- data/lib/dicom/{Anonymizer.rb → anonymizer.rb} +178 -80
- data/lib/dicom/constants.rb +121 -0
- data/lib/dicom/d_client.rb +888 -0
- data/lib/dicom/d_library.rb +208 -0
- data/lib/dicom/d_object.rb +424 -0
- data/lib/dicom/d_read.rb +433 -0
- data/lib/dicom/d_server.rb +397 -0
- data/lib/dicom/d_write.rb +420 -0
- data/lib/dicom/data_element.rb +175 -0
- data/lib/dicom/{Dictionary.rb → dictionary.rb} +390 -398
- data/lib/dicom/elements.rb +82 -0
- data/lib/dicom/file_handler.rb +116 -0
- data/lib/dicom/item.rb +87 -0
- data/lib/dicom/{Link.rb → link.rb} +749 -388
- data/lib/dicom/ruby_extensions.rb +44 -35
- data/lib/dicom/sequence.rb +62 -0
- data/lib/dicom/stream.rb +493 -0
- data/lib/dicom/super_item.rb +696 -0
- data/lib/dicom/super_parent.rb +615 -0
- metadata +25 -18
- data/DOCUMENTATION +0 -469
- data/lib/dicom/DClient.rb +0 -584
- data/lib/dicom/DLibrary.rb +0 -194
- data/lib/dicom/DObject.rb +0 -1579
- data/lib/dicom/DRead.rb +0 -532
- data/lib/dicom/DServer.rb +0 -304
- data/lib/dicom/DWrite.rb +0 -410
- data/lib/dicom/FileHandler.rb +0 -50
- data/lib/dicom/Stream.rb +0 -354
data/lib/dicom/d_read.rb
ADDED
@@ -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
|