dicom 0.9.6 → 0.9.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|