dicom 0.9.6 → 0.9.7
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +5 -13
- data/CHANGELOG.md +390 -376
- data/COPYING +674 -674
- data/Gemfile +2 -2
- data/Gemfile.lock +30 -28
- data/README.md +154 -152
- data/dicom.gemspec +30 -30
- data/lib/dicom/anonymizer.rb +677 -654
- data/lib/dicom/audit_trail.rb +109 -109
- data/lib/dicom/d_library.rb +269 -265
- data/lib/dicom/d_object.rb +465 -465
- data/lib/dicom/d_read.rb +21 -8
- data/lib/dicom/d_server.rb +329 -329
- data/lib/dicom/d_write.rb +355 -355
- data/lib/dicom/dictionary/elements.tsv +597 -86
- data/lib/dicom/dictionary/uids.tsv +4 -2
- data/lib/dicom/elemental_parent.rb +63 -63
- data/lib/dicom/extensions/array.rb +56 -56
- data/lib/dicom/extensions/hash.rb +30 -30
- data/lib/dicom/extensions/string.rb +125 -125
- data/lib/dicom/file_handler.rb +121 -121
- data/lib/dicom/general/constants.rb +210 -210
- data/lib/dicom/general/deprecated.rb +0 -320
- data/lib/dicom/general/logging.rb +155 -155
- data/lib/dicom/general/methods.rb +98 -82
- data/lib/dicom/general/variables.rb +28 -28
- data/lib/dicom/general/version.rb +5 -5
- data/lib/dicom/image_item.rb +836 -836
- data/lib/dicom/image_processor.rb +79 -79
- data/lib/dicom/image_processor_mini_magick.rb +71 -71
- data/lib/dicom/image_processor_r_magick.rb +106 -106
- data/lib/dicom/link.rb +1529 -1528
- data/rakefile.rb +29 -30
- metadata +43 -49
data/lib/dicom/d_read.rb
CHANGED
@@ -7,11 +7,12 @@ module DICOM
|
|
7
7
|
#
|
8
8
|
# @param [String] bin an encoded binary string containing DICOM information
|
9
9
|
# @param [String] syntax the transfer syntax to use when decoding the DICOM string
|
10
|
+
# @param [Boolean] switched indicating whether the transfer syntax 'switch' has occured in the data stream of this object
|
10
11
|
#
|
11
|
-
def parse(bin, syntax)
|
12
|
+
def parse(bin, syntax, switched=false, explicit=true)
|
12
13
|
raise ArgumentError, "Invalid argument 'bin'. Expected String, got #{bin.class}." unless bin.is_a?(String)
|
13
14
|
raise ArgumentError, "Invalid argument 'syntax'. Expected String, got #{syntax.class}." unless syntax.is_a?(String)
|
14
|
-
read(bin, signature=false, :syntax => syntax)
|
15
|
+
read(bin, signature=false, :syntax => syntax, :switched => switched, :explicit => explicit)
|
15
16
|
end
|
16
17
|
|
17
18
|
|
@@ -69,6 +70,11 @@ module DICOM
|
|
69
70
|
#
|
70
71
|
def process_data_element
|
71
72
|
# FIXME: This method has grown a bit messy and isn't very pleasant to read. Cleanup possible?
|
73
|
+
# After having been into a possible unknown sequence with undefined length, we may need to reset
|
74
|
+
# explicitness from implicit to explicit:
|
75
|
+
if !@original_explicit.nil? && @explicitness_reset_parent == @current_parent
|
76
|
+
@explicit = @original_explicit
|
77
|
+
end
|
72
78
|
# STEP 1:
|
73
79
|
# Attempt to read data element tag:
|
74
80
|
tag = read_tag
|
@@ -112,9 +118,16 @@ module DICOM
|
|
112
118
|
end
|
113
119
|
end
|
114
120
|
# Create an Element from the gathered data:
|
115
|
-
if
|
116
|
-
|
121
|
+
# if vr is UN ("unknown") and length is -1, treat as a sequence (sec. 6.2.2 of DICOM standard)
|
122
|
+
if level_vr == "SQ" or tag == ITEM_TAG or (level_vr == "UN" and length == -1)
|
123
|
+
if level_vr == "SQ" or (level_vr == "UN" and length == -1)
|
117
124
|
check_duplicate(tag, 'Sequence')
|
125
|
+
# If we get an unknown sequence with undefined length, we must switch to implicit for decoding its content:
|
126
|
+
if level_vr == "UN" and length == -1
|
127
|
+
@original_explicit = @explicit
|
128
|
+
@explicit = false
|
129
|
+
@explicitness_reset_parent = @current_parent
|
130
|
+
end
|
118
131
|
unless @current_parent[tag] and !@overwrite
|
119
132
|
@current_element = Sequence.new(tag, :length => length, :name => name, :parent => @current_parent, :vr => vr)
|
120
133
|
else
|
@@ -143,7 +156,7 @@ module DICOM
|
|
143
156
|
# If length is specified (no delimitation items), load a new DRead instance to read these child elements
|
144
157
|
# and load them into the current sequence. The exception is when we have a pixel data item.
|
145
158
|
if length > 0 and not @enc_image
|
146
|
-
@current_element.parse(bin, @transfer_syntax)
|
159
|
+
@current_element.parse(bin, @transfer_syntax, switched=@switched, @explicit)
|
147
160
|
@current_parent = @current_parent.parent
|
148
161
|
return false unless @read_success
|
149
162
|
end
|
@@ -177,8 +190,8 @@ module DICOM
|
|
177
190
|
@overwrite = options[:overwrite]
|
178
191
|
# Presence of the official DICOM signature:
|
179
192
|
@signature = false
|
180
|
-
# Default explicitness of start of DICOM string:
|
181
|
-
@explicit = true
|
193
|
+
# Default explicitness of start of DICOM string (if undefined it defaults to true):
|
194
|
+
@explicit = options[:explicit].nil? ? true : options[:explicit]
|
182
195
|
# Default endianness of start of DICOM string is little endian:
|
183
196
|
@str_endian = false
|
184
197
|
# A switch of endianness may occur after the initial meta group, an this needs to be monitored:
|
@@ -188,7 +201,7 @@ module DICOM
|
|
188
201
|
# Endianness of the remaining groups after the first group:
|
189
202
|
@rest_endian = false
|
190
203
|
# When the string switch from group 0002 to a later group we will update encoding values, and this switch will keep track of that:
|
191
|
-
@switched = false
|
204
|
+
@switched = options[:switched] ? options[:switched] : false
|
192
205
|
# Keeping track of the data element parent status while parsing the DICOM string:
|
193
206
|
@current_parent = self
|
194
207
|
# Keeping track of what is the current data element:
|
data/lib/dicom/d_server.rb
CHANGED
@@ -1,329 +1,329 @@
|
|
1
|
-
module DICOM
|
2
|
-
|
3
|
-
# This class contains code for setting up a Service Class Provider (SCP),
|
4
|
-
# which will act as a simple storage node (a DICOM server that receives images).
|
5
|
-
#
|
6
|
-
class DServer
|
7
|
-
include Logging
|
8
|
-
|
9
|
-
# Runs the server and takes a block for initializing.
|
10
|
-
#
|
11
|
-
# @param [Integer] port the network port to be used (defaults to 104)
|
12
|
-
# @param [String] path the directory where incoming DICOM files will be stored (defaults to './received/')
|
13
|
-
# @param [&block] block a block of code that will be run on the DServer instance, between creation and the launch of the SCP itself
|
14
|
-
#
|
15
|
-
# @example Run a server instance with a custom file handler
|
16
|
-
# require 'dicom'
|
17
|
-
# require 'my_file_handler'
|
18
|
-
# include DICOM
|
19
|
-
# DServer.run(104, 'c:/temp/') do |s|
|
20
|
-
# s.timeout = 100
|
21
|
-
# s.file_handler = MyFileHandler
|
22
|
-
# end
|
23
|
-
#
|
24
|
-
def self.run(port=104, path='./received/', &block)
|
25
|
-
server = DServer.new(port)
|
26
|
-
server.instance_eval(&block)
|
27
|
-
server.start_scp(path)
|
28
|
-
end
|
29
|
-
|
30
|
-
# A customized FileHandler class to use instead of the default FileHandler included with ruby-dicom.
|
31
|
-
attr_accessor :file_handler
|
32
|
-
# The hostname that the TCPServer binds to.
|
33
|
-
attr_accessor :host
|
34
|
-
# The name of the server (application entity).
|
35
|
-
attr_accessor :host_ae
|
36
|
-
# The maximum allowed size of network packages (in bytes).
|
37
|
-
attr_accessor :max_package_size
|
38
|
-
# The network port to be used.
|
39
|
-
attr_accessor :port
|
40
|
-
# The maximum period the server will wait on an answer from a client before aborting the communication.
|
41
|
-
attr_accessor :timeout
|
42
|
-
|
43
|
-
# A hash containing the abstract syntaxes that will be accepted.
|
44
|
-
attr_reader :accepted_abstract_syntaxes
|
45
|
-
# A hash containing the transfer syntaxes that will be accepted.
|
46
|
-
attr_reader :accepted_transfer_syntaxes
|
47
|
-
|
48
|
-
# Creates a DServer instance.
|
49
|
-
#
|
50
|
-
# @note To customize logging behaviour, refer to the Logging module documentation.
|
51
|
-
#
|
52
|
-
# @param [Integer] port the network port to be used
|
53
|
-
# @param [Hash] options the options to use for the DICOM server
|
54
|
-
# @option options [String] :file_handler a customized FileHandler class to use instead of the default FileHandler
|
55
|
-
# @option options [String] :host the hostname that the TCPServer binds to (defaults to '0.0.0.0')
|
56
|
-
# @option options [String] :host_ae the name of the server (application entity)
|
57
|
-
# @option options [String] :max_package_size the maximum allowed size of network packages (in bytes)
|
58
|
-
# @option options [String] :timeout the number of seconds the server will wait on an answer from a client before aborting the communication
|
59
|
-
#
|
60
|
-
# @example Create a server using default settings
|
61
|
-
# s = DICOM::DServer.new
|
62
|
-
# @example Create a server with a specific host name and a custom buildt file handler
|
63
|
-
# require_relative 'my_file_handler'
|
64
|
-
# server = DICOM::DServer.new(104, :host_ae => "RUBY_SERVER", :file_handler => DICOM::MyFileHandler)
|
65
|
-
#
|
66
|
-
def initialize(port=104, options={})
|
67
|
-
require 'socket'
|
68
|
-
# Required parameters:
|
69
|
-
@port = port
|
70
|
-
# Optional parameters (and default values):
|
71
|
-
@file_handler = options[:file_handler] || FileHandler
|
72
|
-
@host = options[:host] || '0.0.0.0'
|
73
|
-
@host_ae = options[:host_ae] || "RUBY_DICOM"
|
74
|
-
@max_package_size = options[:max_package_size] || 32768 # 16384
|
75
|
-
@timeout = options[:timeout] || 10 # seconds
|
76
|
-
@min_length = 12 # minimum number of bytes to expect in an incoming transmission
|
77
|
-
# Variables used for monitoring state of transmission:
|
78
|
-
@connection = nil # TCP connection status
|
79
|
-
@association = nil # DICOM Association status
|
80
|
-
@request_approved = nil # Status of our DICOM request
|
81
|
-
@release = nil # Status of received, valid release response
|
82
|
-
set_default_accepted_syntaxes
|
83
|
-
end
|
84
|
-
|
85
|
-
# Adds an abstract syntax to the list of abstract syntaxes that the server will accept.
|
86
|
-
#
|
87
|
-
# @param [String] uid an abstract syntax UID
|
88
|
-
#
|
89
|
-
def add_abstract_syntax(uid)
|
90
|
-
lib_uid = LIBRARY.uid(uid)
|
91
|
-
raise "Invalid/unknown UID: #{uid}" unless lib_uid
|
92
|
-
@accepted_abstract_syntaxes[uid] = lib_uid.name
|
93
|
-
end
|
94
|
-
|
95
|
-
# Adds a transfer syntax to the list of transfer syntaxes that the server will accept.
|
96
|
-
#
|
97
|
-
# @param [String] uid a transfer syntax UID
|
98
|
-
#
|
99
|
-
def add_transfer_syntax(uid)
|
100
|
-
lib_uid = LIBRARY.uid(uid)
|
101
|
-
raise "Invalid/unknown UID: #{uid}" unless lib_uid
|
102
|
-
@accepted_transfer_syntaxes[uid] = lib_uid.name
|
103
|
-
end
|
104
|
-
|
105
|
-
# Prints the list of accepted abstract syntaxes to the screen.
|
106
|
-
#
|
107
|
-
def print_abstract_syntaxes
|
108
|
-
# Determine length of longest key to ensure pretty print:
|
109
|
-
max_uid = @accepted_abstract_syntaxes.keys.collect{|k| k.length}.max
|
110
|
-
puts "Abstract syntaxes which are accepted by this SCP:"
|
111
|
-
@accepted_abstract_syntaxes.sort.each do |pair|
|
112
|
-
puts "#{pair[0]}#{' '*(max_uid-pair[0].length)} #{pair[1]}"
|
113
|
-
end
|
114
|
-
end
|
115
|
-
|
116
|
-
# Prints the list of accepted transfer syntaxes to the screen.
|
117
|
-
#
|
118
|
-
def print_transfer_syntaxes
|
119
|
-
# Determine length of longest key to ensure pretty print:
|
120
|
-
max_uid = @accepted_transfer_syntaxes.keys.collect{|k| k.length}.max
|
121
|
-
puts "Transfer syntaxes which are accepted by this SCP:"
|
122
|
-
@accepted_transfer_syntaxes.sort.each do |pair|
|
123
|
-
puts "#{pair[0]}#{' '*(max_uid-pair[0].length)} #{pair[1]}"
|
124
|
-
end
|
125
|
-
end
|
126
|
-
|
127
|
-
# Deletes a specific abstract syntax from the list of abstract syntaxes
|
128
|
-
# that the server will accept.
|
129
|
-
#
|
130
|
-
# @param [String] uid an abstract syntax UID
|
131
|
-
#
|
132
|
-
def delete_abstract_syntax(uid)
|
133
|
-
if uid.is_a?(String)
|
134
|
-
@accepted_abstract_syntaxes.delete(uid)
|
135
|
-
else
|
136
|
-
raise "Invalid type of UID. Expected String, got #{uid.class}!"
|
137
|
-
end
|
138
|
-
end
|
139
|
-
|
140
|
-
# Deletes a specific transfer syntax from the list of transfer syntaxes
|
141
|
-
# that the server will accept.
|
142
|
-
#
|
143
|
-
# @param [String] uid a transfer syntax UID
|
144
|
-
#
|
145
|
-
def delete_transfer_syntax(uid)
|
146
|
-
if uid.is_a?(String)
|
147
|
-
@accepted_transfer_syntaxes.delete(uid)
|
148
|
-
else
|
149
|
-
raise "Invalid type of UID. Expected String, got #{uid.class}!"
|
150
|
-
end
|
151
|
-
end
|
152
|
-
|
153
|
-
# Completely clears the list of abstract syntaxes that the server will accept.
|
154
|
-
#
|
155
|
-
# Following such a clearance, the user must ensure to add the specific
|
156
|
-
# abstract syntaxes that are to be accepted by the server.
|
157
|
-
#
|
158
|
-
def clear_abstract_syntaxes
|
159
|
-
@accepted_abstract_syntaxes = Hash.new
|
160
|
-
end
|
161
|
-
|
162
|
-
# Completely clears the list of transfer syntaxes that the server will accept.
|
163
|
-
#
|
164
|
-
# Following such a clearance, the user must ensure to add the specific
|
165
|
-
# transfer syntaxes that are to be accepted by the server.
|
166
|
-
#
|
167
|
-
def clear_transfer_syntaxes
|
168
|
-
@accepted_transfer_syntaxes = Hash.new
|
169
|
-
end
|
170
|
-
|
171
|
-
# Starts the Service Class Provider (SCP).
|
172
|
-
#
|
173
|
-
# This service acts as a simple storage node, which receives DICOM files
|
174
|
-
# and stores them in the specified folder.
|
175
|
-
#
|
176
|
-
# Customized storage actions can be set my modifying or replacing the FileHandler class.
|
177
|
-
#
|
178
|
-
# @param [String] path the directory where incoming files are to be saved
|
179
|
-
#
|
180
|
-
def start_scp(path='./received/')
|
181
|
-
if @accepted_abstract_syntaxes.size > 0 and @accepted_transfer_syntaxes.size > 0
|
182
|
-
logger.info("Started DICOM SCP server on port #{@port}.")
|
183
|
-
logger.info("Waiting for incoming transmissions...\n\n")
|
184
|
-
# Initiate server:
|
185
|
-
@scp = TCPServer.new(@host, @port)
|
186
|
-
# Use a loop to listen for incoming messages:
|
187
|
-
loop do
|
188
|
-
Thread.start(@scp.accept) do |session|
|
189
|
-
# Initialize the network package handler for this session:
|
190
|
-
link = Link.new(:host_ae => @host_ae, :max_package_size => @max_package_size, :timeout => @timeout, :file_handler => @file_handler)
|
191
|
-
link.set_session(session)
|
192
|
-
# Note who has contacted us:
|
193
|
-
logger.info("Connection established with: #{session.peeraddr[2]} (IP: #{session.peeraddr[3]})")
|
194
|
-
# Receive an incoming message:
|
195
|
-
segments = link.receive_multiple_transmissions
|
196
|
-
info = segments.first
|
197
|
-
# Interpret the received message:
|
198
|
-
if info[:valid]
|
199
|
-
association_error = check_association_request(info)
|
200
|
-
unless association_error
|
201
|
-
info, approved, rejected = process_syntax_requests(info)
|
202
|
-
link.handle_association_accept(info)
|
203
|
-
context = (LIBRARY.uid(info[:pc].first[:abstract_syntax]) ? LIBRARY.uid(info[:pc].first[:abstract_syntax]).name : 'Unknown UID!')
|
204
|
-
if approved > 0
|
205
|
-
if approved == 1
|
206
|
-
logger.info("Accepted the association request with context: #{context}")
|
207
|
-
else
|
208
|
-
if rejected == 0
|
209
|
-
logger.info("Accepted all #{approved} proposed contexts in the association request.")
|
210
|
-
else
|
211
|
-
logger.warn("Accepted only #{approved} of #{approved+rejected} of the proposed contexts in the association request.")
|
212
|
-
end
|
213
|
-
end
|
214
|
-
# Process the incoming data. This method will also take care of releasing the association:
|
215
|
-
success, messages = link.handle_incoming_data(path)
|
216
|
-
# Pass along any messages that has been recorded:
|
217
|
-
messages.each { |m| logger.public_send(m.first, m.last) } if messages.first
|
218
|
-
else
|
219
|
-
# No abstract syntaxes in the incoming request were accepted:
|
220
|
-
if rejected == 1
|
221
|
-
logger.warn("Rejected the association request with proposed context: #{context}")
|
222
|
-
else
|
223
|
-
logger.warn("Rejected all #{rejected} proposed contexts in the association request.")
|
224
|
-
end
|
225
|
-
# Since the requested abstract syntax was not accepted, the association must be released.
|
226
|
-
link.await_release
|
227
|
-
end
|
228
|
-
else
|
229
|
-
# The incoming association was not formally correct.
|
230
|
-
link.handle_rejection
|
231
|
-
end
|
232
|
-
else
|
233
|
-
# The incoming message was not recognised as a valid DICOM message. Abort:
|
234
|
-
link.handle_abort
|
235
|
-
end
|
236
|
-
# Terminate the connection:
|
237
|
-
link.stop_session
|
238
|
-
logger.info("Connection closed.\n\n")
|
239
|
-
end
|
240
|
-
end
|
241
|
-
else
|
242
|
-
raise "Unable to start SCP server as no accepted abstract syntaxes have been set!" if @accepted_abstract_syntaxes.length == 0
|
243
|
-
raise "Unable to start SCP server as no accepted transfer syntaxes have been set!" if @accepted_transfer_syntaxes.length == 0
|
244
|
-
end
|
245
|
-
end
|
246
|
-
|
247
|
-
|
248
|
-
private
|
249
|
-
|
250
|
-
|
251
|
-
# Checks if the association request is formally correct, by matching against an exact application context UID.
|
252
|
-
# Returns nil if valid, and an error code if it is not approved.
|
253
|
-
#
|
254
|
-
# === Notes
|
255
|
-
#
|
256
|
-
# Other things can potentially be checked here too, if we want to make the server more strict with regards to what information is received:
|
257
|
-
# * Application context name, calling AE title, called AE title
|
258
|
-
# * Description of error codes are given in the DICOM Standard, PS 3.8, Chapter 9.3.4 (Table 9-21).
|
259
|
-
#
|
260
|
-
# === Parameters
|
261
|
-
#
|
262
|
-
# * <tt>info</tt> -- An information hash from the received association request.
|
263
|
-
#
|
264
|
-
def check_association_request(info)
|
265
|
-
unless info[:application_context] == APPLICATION_CONTEXT
|
266
|
-
error = 2 # (application context name not supported)
|
267
|
-
logger.error("The application context in the incoming association request was not recognized: (#{info[:application_context]})")
|
268
|
-
else
|
269
|
-
error = nil
|
270
|
-
end
|
271
|
-
return error
|
272
|
-
end
|
273
|
-
|
274
|
-
# Checks if the requested abstract syntax & its transfer syntax(es) are supported by this server instance,
|
275
|
-
# and inserts a corresponding result code for each presentation context.
|
276
|
-
# Returns the modified association information hash, as well as the number of abstract syntaxes that were accepted and rejected.
|
277
|
-
#
|
278
|
-
# === Notes
|
279
|
-
#
|
280
|
-
# * Description of error codes are given in the DICOM Standard, PS 3.8, Chapter 9.3.3.2 (Table 9-18).
|
281
|
-
#
|
282
|
-
# === Parameters
|
283
|
-
#
|
284
|
-
# * <tt>info</tt> -- An information hash from the received association request.
|
285
|
-
#
|
286
|
-
def process_syntax_requests(info)
|
287
|
-
# A couple of variables used to analyse the properties of the association:
|
288
|
-
approved = 0
|
289
|
-
rejected = 0
|
290
|
-
# Loop through the presentation contexts:
|
291
|
-
info[:pc].each do |pc|
|
292
|
-
if @accepted_abstract_syntaxes[pc[:abstract_syntax]]
|
293
|
-
# Abstract syntax accepted. Proceed to check its transfer syntax(es):
|
294
|
-
proposed_transfer_syntaxes = pc[:ts].collect{|t| t[:transfer_syntax]}.sort
|
295
|
-
# Choose the first proposed transfer syntax that exists in our list of accepted transfer syntaxes:
|
296
|
-
accepted_transfer_syntax = nil
|
297
|
-
proposed_transfer_syntaxes.each do |proposed_ts|
|
298
|
-
if @accepted_transfer_syntaxes.include?(proposed_ts)
|
299
|
-
accepted_transfer_syntax = proposed_ts
|
300
|
-
break
|
301
|
-
end
|
302
|
-
end
|
303
|
-
if accepted_transfer_syntax
|
304
|
-
# Both abstract and transfer syntax has been approved:
|
305
|
-
pc[:result] = ACCEPTANCE
|
306
|
-
pc[:selected_transfer_syntax] = accepted_transfer_syntax
|
307
|
-
# Update our status variables:
|
308
|
-
approved += 1
|
309
|
-
else
|
310
|
-
# No transfer syntax was accepted for this particular presentation context:
|
311
|
-
pc[:result] = TRANSFER_SYNTAX_REJECTED
|
312
|
-
rejected += 1
|
313
|
-
end
|
314
|
-
else
|
315
|
-
# Abstract syntax rejected:
|
316
|
-
pc[:result] = ABSTRACT_SYNTAX_REJECTED
|
317
|
-
end
|
318
|
-
end
|
319
|
-
return info, approved, rejected
|
320
|
-
end
|
321
|
-
|
322
|
-
# Sets the default accepted abstract syntaxes and transfer syntaxes for this SCP.
|
323
|
-
#
|
324
|
-
def set_default_accepted_syntaxes
|
325
|
-
@accepted_transfer_syntaxes, @accepted_abstract_syntaxes = LIBRARY.extract_transfer_syntaxes_and_sop_classes
|
326
|
-
end
|
327
|
-
|
328
|
-
end
|
329
|
-
end
|
1
|
+
module DICOM
|
2
|
+
|
3
|
+
# This class contains code for setting up a Service Class Provider (SCP),
|
4
|
+
# which will act as a simple storage node (a DICOM server that receives images).
|
5
|
+
#
|
6
|
+
class DServer
|
7
|
+
include Logging
|
8
|
+
|
9
|
+
# Runs the server and takes a block for initializing.
|
10
|
+
#
|
11
|
+
# @param [Integer] port the network port to be used (defaults to 104)
|
12
|
+
# @param [String] path the directory where incoming DICOM files will be stored (defaults to './received/')
|
13
|
+
# @param [&block] block a block of code that will be run on the DServer instance, between creation and the launch of the SCP itself
|
14
|
+
#
|
15
|
+
# @example Run a server instance with a custom file handler
|
16
|
+
# require 'dicom'
|
17
|
+
# require 'my_file_handler'
|
18
|
+
# include DICOM
|
19
|
+
# DServer.run(104, 'c:/temp/') do |s|
|
20
|
+
# s.timeout = 100
|
21
|
+
# s.file_handler = MyFileHandler
|
22
|
+
# end
|
23
|
+
#
|
24
|
+
def self.run(port=104, path='./received/', &block)
|
25
|
+
server = DServer.new(port)
|
26
|
+
server.instance_eval(&block)
|
27
|
+
server.start_scp(path)
|
28
|
+
end
|
29
|
+
|
30
|
+
# A customized FileHandler class to use instead of the default FileHandler included with ruby-dicom.
|
31
|
+
attr_accessor :file_handler
|
32
|
+
# The hostname that the TCPServer binds to.
|
33
|
+
attr_accessor :host
|
34
|
+
# The name of the server (application entity).
|
35
|
+
attr_accessor :host_ae
|
36
|
+
# The maximum allowed size of network packages (in bytes).
|
37
|
+
attr_accessor :max_package_size
|
38
|
+
# The network port to be used.
|
39
|
+
attr_accessor :port
|
40
|
+
# The maximum period the server will wait on an answer from a client before aborting the communication.
|
41
|
+
attr_accessor :timeout
|
42
|
+
|
43
|
+
# A hash containing the abstract syntaxes that will be accepted.
|
44
|
+
attr_reader :accepted_abstract_syntaxes
|
45
|
+
# A hash containing the transfer syntaxes that will be accepted.
|
46
|
+
attr_reader :accepted_transfer_syntaxes
|
47
|
+
|
48
|
+
# Creates a DServer instance.
|
49
|
+
#
|
50
|
+
# @note To customize logging behaviour, refer to the Logging module documentation.
|
51
|
+
#
|
52
|
+
# @param [Integer] port the network port to be used
|
53
|
+
# @param [Hash] options the options to use for the DICOM server
|
54
|
+
# @option options [String] :file_handler a customized FileHandler class to use instead of the default FileHandler
|
55
|
+
# @option options [String] :host the hostname that the TCPServer binds to (defaults to '0.0.0.0')
|
56
|
+
# @option options [String] :host_ae the name of the server (application entity)
|
57
|
+
# @option options [String] :max_package_size the maximum allowed size of network packages (in bytes)
|
58
|
+
# @option options [String] :timeout the number of seconds the server will wait on an answer from a client before aborting the communication
|
59
|
+
#
|
60
|
+
# @example Create a server using default settings
|
61
|
+
# s = DICOM::DServer.new
|
62
|
+
# @example Create a server with a specific host name and a custom buildt file handler
|
63
|
+
# require_relative 'my_file_handler'
|
64
|
+
# server = DICOM::DServer.new(104, :host_ae => "RUBY_SERVER", :file_handler => DICOM::MyFileHandler)
|
65
|
+
#
|
66
|
+
def initialize(port=104, options={})
|
67
|
+
require 'socket'
|
68
|
+
# Required parameters:
|
69
|
+
@port = port
|
70
|
+
# Optional parameters (and default values):
|
71
|
+
@file_handler = options[:file_handler] || FileHandler
|
72
|
+
@host = options[:host] || '0.0.0.0'
|
73
|
+
@host_ae = options[:host_ae] || "RUBY_DICOM"
|
74
|
+
@max_package_size = options[:max_package_size] || 32768 # 16384
|
75
|
+
@timeout = options[:timeout] || 10 # seconds
|
76
|
+
@min_length = 12 # minimum number of bytes to expect in an incoming transmission
|
77
|
+
# Variables used for monitoring state of transmission:
|
78
|
+
@connection = nil # TCP connection status
|
79
|
+
@association = nil # DICOM Association status
|
80
|
+
@request_approved = nil # Status of our DICOM request
|
81
|
+
@release = nil # Status of received, valid release response
|
82
|
+
set_default_accepted_syntaxes
|
83
|
+
end
|
84
|
+
|
85
|
+
# Adds an abstract syntax to the list of abstract syntaxes that the server will accept.
|
86
|
+
#
|
87
|
+
# @param [String] uid an abstract syntax UID
|
88
|
+
#
|
89
|
+
def add_abstract_syntax(uid)
|
90
|
+
lib_uid = LIBRARY.uid(uid)
|
91
|
+
raise "Invalid/unknown UID: #{uid}" unless lib_uid
|
92
|
+
@accepted_abstract_syntaxes[uid] = lib_uid.name
|
93
|
+
end
|
94
|
+
|
95
|
+
# Adds a transfer syntax to the list of transfer syntaxes that the server will accept.
|
96
|
+
#
|
97
|
+
# @param [String] uid a transfer syntax UID
|
98
|
+
#
|
99
|
+
def add_transfer_syntax(uid)
|
100
|
+
lib_uid = LIBRARY.uid(uid)
|
101
|
+
raise "Invalid/unknown UID: #{uid}" unless lib_uid
|
102
|
+
@accepted_transfer_syntaxes[uid] = lib_uid.name
|
103
|
+
end
|
104
|
+
|
105
|
+
# Prints the list of accepted abstract syntaxes to the screen.
|
106
|
+
#
|
107
|
+
def print_abstract_syntaxes
|
108
|
+
# Determine length of longest key to ensure pretty print:
|
109
|
+
max_uid = @accepted_abstract_syntaxes.keys.collect{|k| k.length}.max
|
110
|
+
puts "Abstract syntaxes which are accepted by this SCP:"
|
111
|
+
@accepted_abstract_syntaxes.sort.each do |pair|
|
112
|
+
puts "#{pair[0]}#{' '*(max_uid-pair[0].length)} #{pair[1]}"
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
# Prints the list of accepted transfer syntaxes to the screen.
|
117
|
+
#
|
118
|
+
def print_transfer_syntaxes
|
119
|
+
# Determine length of longest key to ensure pretty print:
|
120
|
+
max_uid = @accepted_transfer_syntaxes.keys.collect{|k| k.length}.max
|
121
|
+
puts "Transfer syntaxes which are accepted by this SCP:"
|
122
|
+
@accepted_transfer_syntaxes.sort.each do |pair|
|
123
|
+
puts "#{pair[0]}#{' '*(max_uid-pair[0].length)} #{pair[1]}"
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
# Deletes a specific abstract syntax from the list of abstract syntaxes
|
128
|
+
# that the server will accept.
|
129
|
+
#
|
130
|
+
# @param [String] uid an abstract syntax UID
|
131
|
+
#
|
132
|
+
def delete_abstract_syntax(uid)
|
133
|
+
if uid.is_a?(String)
|
134
|
+
@accepted_abstract_syntaxes.delete(uid)
|
135
|
+
else
|
136
|
+
raise "Invalid type of UID. Expected String, got #{uid.class}!"
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
# Deletes a specific transfer syntax from the list of transfer syntaxes
|
141
|
+
# that the server will accept.
|
142
|
+
#
|
143
|
+
# @param [String] uid a transfer syntax UID
|
144
|
+
#
|
145
|
+
def delete_transfer_syntax(uid)
|
146
|
+
if uid.is_a?(String)
|
147
|
+
@accepted_transfer_syntaxes.delete(uid)
|
148
|
+
else
|
149
|
+
raise "Invalid type of UID. Expected String, got #{uid.class}!"
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
# Completely clears the list of abstract syntaxes that the server will accept.
|
154
|
+
#
|
155
|
+
# Following such a clearance, the user must ensure to add the specific
|
156
|
+
# abstract syntaxes that are to be accepted by the server.
|
157
|
+
#
|
158
|
+
def clear_abstract_syntaxes
|
159
|
+
@accepted_abstract_syntaxes = Hash.new
|
160
|
+
end
|
161
|
+
|
162
|
+
# Completely clears the list of transfer syntaxes that the server will accept.
|
163
|
+
#
|
164
|
+
# Following such a clearance, the user must ensure to add the specific
|
165
|
+
# transfer syntaxes that are to be accepted by the server.
|
166
|
+
#
|
167
|
+
def clear_transfer_syntaxes
|
168
|
+
@accepted_transfer_syntaxes = Hash.new
|
169
|
+
end
|
170
|
+
|
171
|
+
# Starts the Service Class Provider (SCP).
|
172
|
+
#
|
173
|
+
# This service acts as a simple storage node, which receives DICOM files
|
174
|
+
# and stores them in the specified folder.
|
175
|
+
#
|
176
|
+
# Customized storage actions can be set my modifying or replacing the FileHandler class.
|
177
|
+
#
|
178
|
+
# @param [String] path the directory where incoming files are to be saved
|
179
|
+
#
|
180
|
+
def start_scp(path='./received/')
|
181
|
+
if @accepted_abstract_syntaxes.size > 0 and @accepted_transfer_syntaxes.size > 0
|
182
|
+
logger.info("Started DICOM SCP server on port #{@port}.")
|
183
|
+
logger.info("Waiting for incoming transmissions...\n\n")
|
184
|
+
# Initiate server:
|
185
|
+
@scp = TCPServer.new(@host, @port)
|
186
|
+
# Use a loop to listen for incoming messages:
|
187
|
+
loop do
|
188
|
+
Thread.start(@scp.accept) do |session|
|
189
|
+
# Initialize the network package handler for this session:
|
190
|
+
link = Link.new(:host_ae => @host_ae, :max_package_size => @max_package_size, :timeout => @timeout, :file_handler => @file_handler)
|
191
|
+
link.set_session(session)
|
192
|
+
# Note who has contacted us:
|
193
|
+
logger.info("Connection established with: #{session.peeraddr[2]} (IP: #{session.peeraddr[3]})")
|
194
|
+
# Receive an incoming message:
|
195
|
+
segments = link.receive_multiple_transmissions
|
196
|
+
info = segments.first
|
197
|
+
# Interpret the received message:
|
198
|
+
if info[:valid]
|
199
|
+
association_error = check_association_request(info)
|
200
|
+
unless association_error
|
201
|
+
info, approved, rejected = process_syntax_requests(info)
|
202
|
+
link.handle_association_accept(info)
|
203
|
+
context = (LIBRARY.uid(info[:pc].first[:abstract_syntax]) ? LIBRARY.uid(info[:pc].first[:abstract_syntax]).name : 'Unknown UID!')
|
204
|
+
if approved > 0
|
205
|
+
if approved == 1
|
206
|
+
logger.info("Accepted the association request with context: #{context}")
|
207
|
+
else
|
208
|
+
if rejected == 0
|
209
|
+
logger.info("Accepted all #{approved} proposed contexts in the association request.")
|
210
|
+
else
|
211
|
+
logger.warn("Accepted only #{approved} of #{approved+rejected} of the proposed contexts in the association request.")
|
212
|
+
end
|
213
|
+
end
|
214
|
+
# Process the incoming data. This method will also take care of releasing the association:
|
215
|
+
success, messages = link.handle_incoming_data(path)
|
216
|
+
# Pass along any messages that has been recorded:
|
217
|
+
messages.each { |m| logger.public_send(m.first, m.last) } if messages.first
|
218
|
+
else
|
219
|
+
# No abstract syntaxes in the incoming request were accepted:
|
220
|
+
if rejected == 1
|
221
|
+
logger.warn("Rejected the association request with proposed context: #{context}")
|
222
|
+
else
|
223
|
+
logger.warn("Rejected all #{rejected} proposed contexts in the association request.")
|
224
|
+
end
|
225
|
+
# Since the requested abstract syntax was not accepted, the association must be released.
|
226
|
+
link.await_release
|
227
|
+
end
|
228
|
+
else
|
229
|
+
# The incoming association was not formally correct.
|
230
|
+
link.handle_rejection
|
231
|
+
end
|
232
|
+
else
|
233
|
+
# The incoming message was not recognised as a valid DICOM message. Abort:
|
234
|
+
link.handle_abort
|
235
|
+
end
|
236
|
+
# Terminate the connection:
|
237
|
+
link.stop_session
|
238
|
+
logger.info("Connection closed.\n\n")
|
239
|
+
end
|
240
|
+
end
|
241
|
+
else
|
242
|
+
raise "Unable to start SCP server as no accepted abstract syntaxes have been set!" if @accepted_abstract_syntaxes.length == 0
|
243
|
+
raise "Unable to start SCP server as no accepted transfer syntaxes have been set!" if @accepted_transfer_syntaxes.length == 0
|
244
|
+
end
|
245
|
+
end
|
246
|
+
|
247
|
+
|
248
|
+
private
|
249
|
+
|
250
|
+
|
251
|
+
# Checks if the association request is formally correct, by matching against an exact application context UID.
|
252
|
+
# Returns nil if valid, and an error code if it is not approved.
|
253
|
+
#
|
254
|
+
# === Notes
|
255
|
+
#
|
256
|
+
# Other things can potentially be checked here too, if we want to make the server more strict with regards to what information is received:
|
257
|
+
# * Application context name, calling AE title, called AE title
|
258
|
+
# * Description of error codes are given in the DICOM Standard, PS 3.8, Chapter 9.3.4 (Table 9-21).
|
259
|
+
#
|
260
|
+
# === Parameters
|
261
|
+
#
|
262
|
+
# * <tt>info</tt> -- An information hash from the received association request.
|
263
|
+
#
|
264
|
+
def check_association_request(info)
|
265
|
+
unless info[:application_context] == APPLICATION_CONTEXT
|
266
|
+
error = 2 # (application context name not supported)
|
267
|
+
logger.error("The application context in the incoming association request was not recognized: (#{info[:application_context]})")
|
268
|
+
else
|
269
|
+
error = nil
|
270
|
+
end
|
271
|
+
return error
|
272
|
+
end
|
273
|
+
|
274
|
+
# Checks if the requested abstract syntax & its transfer syntax(es) are supported by this server instance,
|
275
|
+
# and inserts a corresponding result code for each presentation context.
|
276
|
+
# Returns the modified association information hash, as well as the number of abstract syntaxes that were accepted and rejected.
|
277
|
+
#
|
278
|
+
# === Notes
|
279
|
+
#
|
280
|
+
# * Description of error codes are given in the DICOM Standard, PS 3.8, Chapter 9.3.3.2 (Table 9-18).
|
281
|
+
#
|
282
|
+
# === Parameters
|
283
|
+
#
|
284
|
+
# * <tt>info</tt> -- An information hash from the received association request.
|
285
|
+
#
|
286
|
+
def process_syntax_requests(info)
|
287
|
+
# A couple of variables used to analyse the properties of the association:
|
288
|
+
approved = 0
|
289
|
+
rejected = 0
|
290
|
+
# Loop through the presentation contexts:
|
291
|
+
info[:pc].each do |pc|
|
292
|
+
if @accepted_abstract_syntaxes[pc[:abstract_syntax]]
|
293
|
+
# Abstract syntax accepted. Proceed to check its transfer syntax(es):
|
294
|
+
proposed_transfer_syntaxes = pc[:ts].collect{|t| t[:transfer_syntax]}.sort
|
295
|
+
# Choose the first proposed transfer syntax that exists in our list of accepted transfer syntaxes:
|
296
|
+
accepted_transfer_syntax = nil
|
297
|
+
proposed_transfer_syntaxes.each do |proposed_ts|
|
298
|
+
if @accepted_transfer_syntaxes.include?(proposed_ts)
|
299
|
+
accepted_transfer_syntax = proposed_ts
|
300
|
+
break
|
301
|
+
end
|
302
|
+
end
|
303
|
+
if accepted_transfer_syntax
|
304
|
+
# Both abstract and transfer syntax has been approved:
|
305
|
+
pc[:result] = ACCEPTANCE
|
306
|
+
pc[:selected_transfer_syntax] = accepted_transfer_syntax
|
307
|
+
# Update our status variables:
|
308
|
+
approved += 1
|
309
|
+
else
|
310
|
+
# No transfer syntax was accepted for this particular presentation context:
|
311
|
+
pc[:result] = TRANSFER_SYNTAX_REJECTED
|
312
|
+
rejected += 1
|
313
|
+
end
|
314
|
+
else
|
315
|
+
# Abstract syntax rejected:
|
316
|
+
pc[:result] = ABSTRACT_SYNTAX_REJECTED
|
317
|
+
end
|
318
|
+
end
|
319
|
+
return info, approved, rejected
|
320
|
+
end
|
321
|
+
|
322
|
+
# Sets the default accepted abstract syntaxes and transfer syntaxes for this SCP.
|
323
|
+
#
|
324
|
+
def set_default_accepted_syntaxes
|
325
|
+
@accepted_transfer_syntaxes, @accepted_abstract_syntaxes = LIBRARY.extract_transfer_syntaxes_and_sop_classes
|
326
|
+
end
|
327
|
+
|
328
|
+
end
|
329
|
+
end
|