dicom 0.9.4 → 0.9.5
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/CHANGELOG.rdoc +44 -0
- data/CONTRIBUTING.rdoc +83 -0
- data/README.rdoc +3 -2
- data/dicom.gemspec +12 -13
- data/lib/dicom/anonymizer.rb +649 -670
- data/lib/dicom/audit_trail.rb +7 -0
- data/lib/dicom/constants.rb +1 -1
- data/lib/dicom/d_library.rb +46 -31
- data/lib/dicom/d_object.rb +38 -24
- data/lib/dicom/d_read.rb +20 -6
- data/lib/dicom/d_server.rb +3 -3
- data/lib/dicom/d_write.rb +7 -2
- data/lib/dicom/deprecated.rb +318 -0
- data/lib/dicom/image_item.rb +62 -42
- data/lib/dicom/image_processor.rb +2 -2
- data/lib/dicom/image_processor_mini_magick.rb +6 -6
- data/lib/dicom/image_processor_r_magick.rb +6 -6
- data/lib/dicom/link.rb +7 -11
- data/lib/dicom/logging.rb +2 -1
- data/lib/dicom/variables.rb +36 -1
- data/lib/dicom/version.rb +1 -1
- data/rakefile.rb +3 -1
- metadata +46 -63
data/lib/dicom/audit_trail.rb
CHANGED
@@ -97,7 +97,14 @@ module DICOM
|
|
97
97
|
# @param [String] file_name the path to be used for storing key/value pairs on disk
|
98
98
|
#
|
99
99
|
def write(file_name)
|
100
|
+
# Encode json string:
|
100
101
|
str = JSON.pretty_generate(@dictionary)
|
102
|
+
# Create directory if needed:
|
103
|
+
unless File.directory?(File.dirname(file_name))
|
104
|
+
require 'fileutils'
|
105
|
+
FileUtils.mkdir_p(File.dirname(file_name))
|
106
|
+
end
|
107
|
+
# Write to file:
|
101
108
|
File.open(file_name, 'w') {|f| f.write(str) }
|
102
109
|
end
|
103
110
|
|
data/lib/dicom/constants.rb
CHANGED
@@ -6,7 +6,7 @@ module DICOM
|
|
6
6
|
# Ruby DICOM's registered DICOM UID root (Implementation Class UID).
|
7
7
|
UID_ROOT = "1.2.826.0.1.3680043.8.641"
|
8
8
|
# Ruby DICOM name & version (max 16 characters).
|
9
|
-
NAME = "
|
9
|
+
NAME = "RUBY-DCM_" + DICOM::VERSION
|
10
10
|
|
11
11
|
# Item tag.
|
12
12
|
ITEM_TAG = "FFFE,E000"
|
data/lib/dicom/d_library.rb
CHANGED
@@ -24,15 +24,23 @@ module DICOM
|
|
24
24
|
@methods_from_names = Hash.new
|
25
25
|
@names_from_methods = Hash.new
|
26
26
|
# Load the elements dictionary:
|
27
|
-
|
28
|
-
load_element(record)
|
29
|
-
end
|
27
|
+
add_element_dictionary("#{ROOT_DIR}/dictionary/elements.txt")
|
30
28
|
# Load the unique identifiers dictionary:
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
29
|
+
add_uid_dictionary("#{ROOT_DIR}/dictionary/uids.txt")
|
30
|
+
end
|
31
|
+
|
32
|
+
# Adds a custom DictionaryElement to the ruby-dicom element dictionary.
|
33
|
+
#
|
34
|
+
# @param [DictionaryElement] element the custom dictionary element to be added
|
35
|
+
#
|
36
|
+
def add_element(element)
|
37
|
+
raise ArgumentError, "Invalid argument 'element'. Expected DictionaryElement, got #{element.class}" unless element.is_a?(DictionaryElement)
|
38
|
+
# We store the elements in a hash with tag as key and the element instance as value:
|
39
|
+
@elements[element.tag] = element
|
40
|
+
# Populate the method conversion hashes with element data:
|
41
|
+
method = element.name.to_element_method
|
42
|
+
@methods_from_names[element.name] = method
|
43
|
+
@names_from_methods[method] = element.name
|
36
44
|
end
|
37
45
|
|
38
46
|
# Adds a custom dictionary file to the ruby-dicom element dictionary.
|
@@ -40,11 +48,36 @@ module DICOM
|
|
40
48
|
# @note The format of the dictionary is a tab-separated text file with 5 columns:
|
41
49
|
# * Tag, Name, VR, VM & Retired status
|
42
50
|
# * For samples check out ruby-dicom's element dictionaries in the git repository
|
43
|
-
# @param [String] file
|
51
|
+
# @param [String] file the path to the dictionary file to be added
|
44
52
|
#
|
45
53
|
def add_element_dictionary(file)
|
46
|
-
File.open(file).each do |record|
|
47
|
-
|
54
|
+
File.open(file, :encoding => 'utf-8').each do |record|
|
55
|
+
fields = record.split("\t")
|
56
|
+
add_element(DictionaryElement.new(fields[0], fields[1], fields[2].split(","), fields[3].rstrip, fields[4].rstrip))
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
# Adds a custom uid (e.g. SOP Class, Transfer Syntax) to the ruby-dicom uid dictionary.
|
61
|
+
#
|
62
|
+
# @param [UID] uid the custom uid instance to be added
|
63
|
+
#
|
64
|
+
def add_uid(uid)
|
65
|
+
raise ArgumentError, "Invalid argument 'uid'. Expected UID, got #{uid.class}" unless uid.is_a?(UID)
|
66
|
+
# We store the uids in a hash with uid-value as key and the uid instance as value:
|
67
|
+
@uids[uid.value] = uid
|
68
|
+
end
|
69
|
+
|
70
|
+
# Adds a custom dictionary file to the ruby-dicom uid dictionary.
|
71
|
+
#
|
72
|
+
# @note The format of the dictionary is a tab-separated text file with 4 columns:
|
73
|
+
# * Value, Name, Type & Retired status
|
74
|
+
# * For samples check out ruby-dicom's uid dictionaries in the git repository
|
75
|
+
# @param [String] file the path to the dictionary file to be added
|
76
|
+
#
|
77
|
+
def add_uid_dictionary(file)
|
78
|
+
File.open(file, :encoding => 'utf-8').each do |record|
|
79
|
+
fields = record.split("\t")
|
80
|
+
add_uid(UID.new(fields[0], fields[1], fields[2].rstrip, fields[3].rstrip))
|
48
81
|
end
|
49
82
|
end
|
50
83
|
|
@@ -197,24 +230,6 @@ module DICOM
|
|
197
230
|
@uids[value]
|
198
231
|
end
|
199
232
|
|
200
|
-
|
201
|
-
private
|
202
|
-
|
203
|
-
|
204
|
-
# Loads an element to the dictionary from an element string record.
|
205
|
-
#
|
206
|
-
# @param [String] record a tab-separated string line as extracted from a dictionary file
|
207
|
-
#
|
208
|
-
def load_element(record)
|
209
|
-
fields = record.split("\t")
|
210
|
-
# Store the elements in a hash with tag as key and the element instance as value:
|
211
|
-
element = DictionaryElement.new(fields[0], fields[1], fields[2].split(","), fields[3].rstrip, fields[4].rstrip)
|
212
|
-
@elements[fields[0]] = element
|
213
|
-
# Populate the method conversion hashes with element data:
|
214
|
-
method = element.name.to_element_method
|
215
|
-
@methods_from_names[element.name] = method
|
216
|
-
@names_from_methods[method] = element.name
|
217
|
-
end
|
218
|
-
|
219
233
|
end
|
220
|
-
|
234
|
+
|
235
|
+
end
|
data/lib/dicom/d_object.rb
CHANGED
@@ -1,18 +1,4 @@
|
|
1
|
-
#
|
2
|
-
#
|
3
|
-
# * The retrieve file network functionality (#get_image in DClient class) has not been tested.
|
4
|
-
# * Make the networking code more intelligent in its handling of unexpected network communication.
|
5
|
-
# * Full support for compressed image data.
|
6
|
-
# * Read/Write 12 bit image data.
|
7
|
-
# * Full color support (RGB and PALETTE COLOR with #image already implemented).
|
8
|
-
# * Support for extraction of multiple encapsulated pixel data frames in #pixels and #narray.
|
9
|
-
# * Image handling currently ignores DICOM tags like Pixel Aspect Ratio, Image Orientation and (to some degree) Photometric Interpretation.
|
10
|
-
# * More robust and flexible options for reorienting extracted pixel arrays?
|
11
|
-
# * A curious observation: Creating a DLibrary instance is exceptionally slow on Ruby 1.9.1: 0.4 seconds versus ~0.01 seconds on Ruby 1.8.7!
|
12
|
-
# * Add these as github issues and remove this list!
|
13
|
-
|
14
|
-
|
15
|
-
# Copyright 2008-2012 Christoffer Lervag
|
1
|
+
# Copyright 2008-2013 Christoffer Lervag
|
16
2
|
#
|
17
3
|
# This program is free software: you can redistribute it and/or modify
|
18
4
|
# it under the terms of the GNU General Public License as published by
|
@@ -44,8 +30,12 @@ module DICOM
|
|
44
30
|
attr_reader :parent
|
45
31
|
# A boolean which is set as true if a DICOM file has been successfully read & parsed from a file (or binary string).
|
46
32
|
attr_accessor :read_success
|
33
|
+
# The source of the DObject (nil, :str or file name string).
|
34
|
+
attr_accessor :source
|
47
35
|
# The Stream instance associated with this DObject instance (this attribute is mostly used internally).
|
48
36
|
attr_reader :stream
|
37
|
+
# An attribute (used by e.g. DICOM.load) to indicate that a DObject-type instance was given to the load method (instead of e.g. a file).
|
38
|
+
attr_accessor :was_dcm_on_input
|
49
39
|
# A boolean which is set as true if a DObject instance has been successfully written to file (or successfully encoded).
|
50
40
|
attr_reader :write_success
|
51
41
|
|
@@ -91,6 +81,7 @@ module DICOM
|
|
91
81
|
dcm = self.new
|
92
82
|
dcm.read_success = false
|
93
83
|
end
|
84
|
+
dcm.source = link
|
94
85
|
return dcm
|
95
86
|
end
|
96
87
|
|
@@ -98,6 +89,7 @@ module DICOM
|
|
98
89
|
#
|
99
90
|
# @param [String] string an encoded binary string containing DICOM information
|
100
91
|
# @param [Hash] options the options to use for parsing the DICOM string
|
92
|
+
# @option options [Boolean] :overwrite for the rare case of a DICOM file containing duplicate elements, setting this as true instructs the parsing algorithm to overwrite the original element with duplicates
|
101
93
|
# @option options [Boolean] :signature if set as false, the parsing algorithm will not be looking for the DICOM header signature (defaults to true)
|
102
94
|
# @option options [String] :syntax if a syntax string is specified, the parsing algorithm will be forced to use this transfer syntax when decoding the binary string
|
103
95
|
# @example Parse a DICOM file that has already been loaded to a binary string
|
@@ -111,23 +103,26 @@ module DICOM
|
|
111
103
|
raise ArgumentError, "Invalid option :syntax. Expected String, got #{options[:syntax].class}." if options[:syntax] && !options[:syntax].is_a?(String)
|
112
104
|
signature = options[:signature].nil? ? true : options[:signature]
|
113
105
|
dcm = self.new
|
114
|
-
dcm.send(:read, string, signature, :syntax => options[:syntax])
|
106
|
+
dcm.send(:read, string, signature, :overwrite => options[:overwrite], :syntax => options[:syntax])
|
115
107
|
if dcm.read?
|
116
108
|
logger.debug("DICOM string successfully parsed.")
|
117
109
|
else
|
118
110
|
logger.warn("Failed to parse this string as DICOM.")
|
119
111
|
end
|
112
|
+
dcm.source = :str
|
120
113
|
return dcm
|
121
114
|
end
|
122
115
|
|
123
116
|
# Creates a DObject instance by reading and parsing a DICOM file.
|
124
117
|
#
|
125
118
|
# @param [String] file a string which specifies the path of the DICOM file to be loaded
|
119
|
+
# @param [Hash] options the options to use for reading the DICOM file
|
120
|
+
# @option options [Boolean] :overwrite for the rare case of a DICOM file containing duplicate elements, setting this as true instructs the parsing algorithm to overwrite the original element with duplicates
|
126
121
|
# @example Load a DICOM file
|
127
122
|
# require 'dicom'
|
128
123
|
# dcm = DICOM::DObject.read('test.dcm')
|
129
124
|
#
|
130
|
-
def self.read(file)
|
125
|
+
def self.read(file, options={})
|
131
126
|
raise ArgumentError, "Invalid argument 'file'. Expected String, got #{file.class}." unless file.is_a?(String)
|
132
127
|
# Read the file content:
|
133
128
|
bin = nil
|
@@ -150,12 +145,13 @@ module DICOM
|
|
150
145
|
end
|
151
146
|
# Parse the file contents and create the DICOM object:
|
152
147
|
if bin
|
153
|
-
dcm = self.parse(bin)
|
148
|
+
dcm = self.parse(bin, options)
|
154
149
|
# If reading failed, and no transfer syntax was detected, we will make another attempt at reading the file while forcing explicit (little endian) decoding.
|
155
150
|
# This will help for some rare cases where the DICOM file is saved (erroneously, Im sure) with explicit encoding without specifying the transfer syntax tag.
|
156
151
|
if !dcm.read? and !dcm.exists?("0002,0010")
|
157
152
|
logger.info("Attempting a second decode pass (assuming Explicit Little Endian transfer syntax).")
|
158
|
-
|
153
|
+
options[:syntax] = EXPLICIT_LITTLE_ENDIAN
|
154
|
+
dcm = self.parse(bin, options)
|
159
155
|
end
|
160
156
|
else
|
161
157
|
dcm = self.new
|
@@ -165,6 +161,7 @@ module DICOM
|
|
165
161
|
else
|
166
162
|
logger.warn("Reading DICOM file failed: #{file}")
|
167
163
|
end
|
164
|
+
dcm.source = file
|
168
165
|
return dcm
|
169
166
|
end
|
170
167
|
|
@@ -212,6 +209,14 @@ module DICOM
|
|
212
209
|
|
213
210
|
alias_method :eql?, :==
|
214
211
|
|
212
|
+
# Performs de-identification (anonymization) on the DICOM object.
|
213
|
+
#
|
214
|
+
# @param [Anonymizer] a an Anonymizer instance to use for the anonymization
|
215
|
+
#
|
216
|
+
def anonymize(a=Anonymizer.new)
|
217
|
+
a.to_anonymizer.anonymize(self)
|
218
|
+
end
|
219
|
+
|
215
220
|
# Encodes the DICOM object into a series of binary string segments with a specified maximum length.
|
216
221
|
#
|
217
222
|
# Returns the encoded binary strings in an array.
|
@@ -263,8 +268,17 @@ module DICOM
|
|
263
268
|
# System endian:
|
264
269
|
cpu = (CPU_ENDIAN ? "Big Endian" : "Little Endian")
|
265
270
|
sys_info << "Byte Order (CPU): #{cpu}"
|
266
|
-
#
|
267
|
-
|
271
|
+
# Source (file name):
|
272
|
+
if @source
|
273
|
+
if @source == :str
|
274
|
+
source = "Binary string #{@read_success ? '(successfully parsed)' : '(failed to parse)'}"
|
275
|
+
else
|
276
|
+
source = "File #{@read_success ? '(successfully read)' : '(failed to read)'}: #{@source}"
|
277
|
+
end
|
278
|
+
else
|
279
|
+
source = 'Created from scratch'
|
280
|
+
end
|
281
|
+
info << "Source: #{source}"
|
268
282
|
# Modality:
|
269
283
|
modality = (LIBRARY.uid(value('0008,0016')) ? LIBRARY.uid(value('0008,0016')).name : "SOP Class unknown or not specified!")
|
270
284
|
info << "Modality: #{modality}"
|
@@ -376,15 +390,15 @@ module DICOM
|
|
376
390
|
#
|
377
391
|
# @param [String] file_name the path of the DICOM file which is to be written to disk
|
378
392
|
# @param [Hash] options the options to use for writing the DICOM file
|
379
|
-
# @option options [Boolean] :add_meta <DEPRECATED> if set to false, no manipulation of the DICOM object's meta group will be performed before the DObject is written to file
|
380
393
|
# @option options [Boolean] :ignore_meta if true, no manipulation of the DICOM object's meta group will be performed before the DObject is written to file
|
394
|
+
# @option options [Boolean] :include_empty_parents if true, childless parents (sequences & items) are written to the DICOM file
|
381
395
|
# @example Encode a DICOM file from a DObject
|
382
396
|
# dcm.write('C:/dicom/test.dcm')
|
383
397
|
#
|
384
398
|
def write(file_name, options={})
|
385
|
-
logger.warn("Option :add_meta => false is deprecated. Use option :ignore_meta => true instead.") if options[:add_meta] == false
|
386
399
|
raise ArgumentError, "Invalid file_name. Expected String, got #{file_name.class}." unless file_name.is_a?(String)
|
387
|
-
|
400
|
+
@include_empty_parents = options[:include_empty_parents]
|
401
|
+
insert_missing_meta unless options[:ignore_meta]
|
388
402
|
write_elements(:file_name => file_name, :signature => true, :syntax => transfer_syntax)
|
389
403
|
end
|
390
404
|
|
data/lib/dicom/d_read.rb
CHANGED
@@ -88,8 +88,17 @@ module DICOM
|
|
88
88
|
# Create an Element from the gathered data:
|
89
89
|
if level_vr == "SQ" or tag == ITEM_TAG
|
90
90
|
if level_vr == "SQ"
|
91
|
-
#
|
92
|
-
|
91
|
+
# Check for duplicate and create sequence:
|
92
|
+
logger.warn("Duplicate Sequence (#{tag}) detected at level #{@current_parent.parent.is_a?(DObject) ? 'DObject' : @current_parent.parent.tag + ' => ' if @current_parent.parent}#{@current_parent.is_a?(DObject) ? 'DObject' : @current_parent.tag}") if @current_parent[tag]
|
93
|
+
unless @current_parent[tag] and !@overwrite
|
94
|
+
@current_element = Sequence.new(tag, :length => length, :name => name, :parent => @current_parent, :vr => vr)
|
95
|
+
else
|
96
|
+
# We have skipped a sequence. This means that any following children
|
97
|
+
# of this sequence must be skipped as well. We solve this by creating an 'orphaned'
|
98
|
+
# sequence that has a parent defined, but does not add itself to this parent:
|
99
|
+
@current_element = Sequence.new(tag, :length => length, :name => name, :vr => vr)
|
100
|
+
@current_element.set_parent(@current_parent)
|
101
|
+
end
|
93
102
|
elsif tag == ITEM_TAG
|
94
103
|
# Create an Item:
|
95
104
|
if @enc_image
|
@@ -118,10 +127,13 @@ module DICOM
|
|
118
127
|
# The occurance of such a tag indicates that a sequence or item has ended, and the parent must be changed:
|
119
128
|
@current_parent = @current_parent.parent
|
120
129
|
else
|
121
|
-
#
|
122
|
-
|
123
|
-
|
124
|
-
|
130
|
+
# Check for duplicate and create an ordinary data element:
|
131
|
+
logger.warn("Duplicate Element (#{tag}) detected at level #{@current_parent.parent.is_a?(DObject) ? 'DObject' : @current_parent.parent.tag + ' => ' if @current_parent.parent}#{@current_parent.is_a?(DObject) ? 'DObject' : @current_parent.tag}") if @current_parent[tag]
|
132
|
+
unless @current_parent[tag] and !@overwrite
|
133
|
+
@current_element = Element.new(tag, value, :bin => bin, :name => name, :parent => @current_parent, :vr => vr)
|
134
|
+
# Check that the data stream didn't end abruptly:
|
135
|
+
raise "The actual length of the value (#{@current_element.bin.length}) does not match its specified length (#{length}) for Data Element #{@current_element.tag}" if length != @current_element.bin.length
|
136
|
+
end
|
125
137
|
end
|
126
138
|
# Return true to indicate success:
|
127
139
|
return true
|
@@ -132,11 +144,13 @@ module DICOM
|
|
132
144
|
# @param [String] string a binary DICOM string to be parsed
|
133
145
|
# @param [Boolean] signature if true (default), the parsing algorithm will look for the DICOM header signature
|
134
146
|
# @param [Hash] options the options to use for parsing the DICOM string
|
147
|
+
# @option options [Boolean] :overwrite for the rare case of a DICOM file containing duplicate elements, setting this as true instructs the parsing algorithm to overwrite the original element with duplicates
|
135
148
|
# @option options [String] :syntax if a syntax string is specified, the parsing algorithm is forced to use this transfer syntax when decoding the string
|
136
149
|
#
|
137
150
|
def read(string, signature=true, options={})
|
138
151
|
# (Re)Set variables:
|
139
152
|
@str = string
|
153
|
+
@overwrite = options[:overwrite]
|
140
154
|
# Presence of the official DICOM signature:
|
141
155
|
@signature = false
|
142
156
|
# Default explicitness of start of DICOM string:
|
data/lib/dicom/d_server.rb
CHANGED
@@ -52,7 +52,7 @@ module DICOM
|
|
52
52
|
# @param [Integer] port the network port to be used
|
53
53
|
# @param [Hash] options the options to use for the DICOM server
|
54
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 '
|
55
|
+
# @option options [String] :host the hostname that the TCPServer binds to (defaults to '0.0.0.0')
|
56
56
|
# @option options [String] :host_ae the name of the server (application entity)
|
57
57
|
# @option options [String] :max_package_size the maximum allowed size of network packages (in bytes)
|
58
58
|
# @option options [String] :timeout the number of seconds the server will wait on an answer from a client before aborting the communication
|
@@ -60,7 +60,7 @@ module DICOM
|
|
60
60
|
# @example Create a server using default settings
|
61
61
|
# s = DICOM::DServer.new
|
62
62
|
# @example Create a server with a specific host name and a custom buildt file handler
|
63
|
-
#
|
63
|
+
# require_relative 'my_file_handler'
|
64
64
|
# server = DICOM::DServer.new(104, :host_ae => "RUBY_SERVER", :file_handler => DICOM::MyFileHandler)
|
65
65
|
#
|
66
66
|
def initialize(port=104, options={})
|
@@ -69,7 +69,7 @@ module DICOM
|
|
69
69
|
@port = port
|
70
70
|
# Optional parameters (and default values):
|
71
71
|
@file_handler = options[:file_handler] || FileHandler
|
72
|
-
@host = options[:host] || '
|
72
|
+
@host = options[:host] || '0.0.0.0'
|
73
73
|
@host_ae = options[:host_ae] || "RUBY_DICOM"
|
74
74
|
@max_package_size = options[:max_package_size] || 32768 # 16384
|
75
75
|
@timeout = options[:timeout] || 10 # seconds
|
data/lib/dicom/d_write.rb
CHANGED
@@ -155,14 +155,19 @@ module DICOM
|
|
155
155
|
write_data_element(element)
|
156
156
|
write_data_elements(element.children)
|
157
157
|
if @enc_image
|
158
|
-
|
158
|
+
# Write a delimiter for the pixel tag, but not for its items:
|
159
|
+
write_delimiter(element) if element.tag == PIXEL_TAG
|
159
160
|
else
|
160
161
|
write_delimiter(element)
|
161
162
|
end
|
162
163
|
else
|
163
|
-
#
|
164
|
+
# Parent is childless:
|
164
165
|
if element.bin
|
165
166
|
write_data_element(element) if element.bin.length > 0
|
167
|
+
elsif @include_empty_parents
|
168
|
+
# Only write empty/childless parents if specifically indicated:
|
169
|
+
write_data_element(element)
|
170
|
+
write_delimiter(element)
|
166
171
|
end
|
167
172
|
end
|
168
173
|
else
|
data/lib/dicom/deprecated.rb
CHANGED
@@ -1,2 +1,320 @@
|
|
1
1
|
module DICOM
|
2
|
+
|
3
|
+
class Anonymizer
|
4
|
+
|
5
|
+
# Adds an exception folder which will be avoided when anonymizing.
|
6
|
+
#
|
7
|
+
# @deprecated Use Anonymizer#anonymize instead.
|
8
|
+
# @param [String] path a path that will be avoided
|
9
|
+
# @example Adding a folder
|
10
|
+
# a.add_exception("/home/dicom/tutorials/")
|
11
|
+
#
|
12
|
+
def add_exception(path)
|
13
|
+
# Deprecation warning:
|
14
|
+
logger.warn("The '#add_exception' method of the Anonymization class has been deprecated! Please use the '#anonymize' method with a dataset argument instead.")
|
15
|
+
raise ArgumentError, "Expected String, got #{path.class}." unless path.is_a?(String)
|
16
|
+
if path
|
17
|
+
# Remove last character if the path ends with a file separator:
|
18
|
+
path.chop! if path[-1..-1] == File::SEPARATOR
|
19
|
+
@exceptions << path
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
# Adds a folder who's files will be anonymized.
|
24
|
+
#
|
25
|
+
# @deprecated Use Anonymizer#anonymize instead.
|
26
|
+
# @param [String] path a path that will be included in the anonymization
|
27
|
+
# @example Adding a folder
|
28
|
+
# a.add_folder("/home/dicom")
|
29
|
+
#
|
30
|
+
def add_folder(path)
|
31
|
+
# Deprecation warning:
|
32
|
+
logger.warn("The '#add_exception' method of the Anonymization class has been deprecated! Please use the '#anonymize' method with a dataset argument instead.")
|
33
|
+
raise ArgumentError, "Expected String, got #{path.class}." unless path.is_a?(String)
|
34
|
+
@folders << path
|
35
|
+
end
|
36
|
+
|
37
|
+
# Executes the anonymization process.
|
38
|
+
#
|
39
|
+
# This method is run when all settings have been finalized for the Anonymization instance.
|
40
|
+
#
|
41
|
+
# @deprecated Use Anonymizer#anonymize instead.
|
42
|
+
#
|
43
|
+
def execute
|
44
|
+
# Deprecation warning:
|
45
|
+
logger.warn("The '#execute' method of the Anonymization class has been deprecated! Please use the '#anonymize' method instead.")
|
46
|
+
# FIXME: This method has grown way too lengthy. It needs to be refactored one of these days.
|
47
|
+
# Search through the folders to gather all the files to be anonymized:
|
48
|
+
logger.info("Initiating anonymization process.")
|
49
|
+
start_time = Time.now.to_f
|
50
|
+
logger.info("Searching for files...")
|
51
|
+
load_files
|
52
|
+
logger.info("Done.")
|
53
|
+
if @files.length > 0
|
54
|
+
if @tags.length > 0
|
55
|
+
logger.info(@files.length.to_s + " files have been identified in the specified folder(s).")
|
56
|
+
if @write_path
|
57
|
+
# Determine the write paths, as anonymized files will be written to a separate location:
|
58
|
+
logger.info("Processing write paths...")
|
59
|
+
process_write_paths
|
60
|
+
logger.info("Done")
|
61
|
+
else
|
62
|
+
# Overwriting old files:
|
63
|
+
logger.warn("Separate write folder not specified. Existing DICOM files will be overwritten.")
|
64
|
+
@write_paths = @files
|
65
|
+
end
|
66
|
+
# If the user wants enumeration, we need to prepare variables for storing
|
67
|
+
# existing information associated with each tag:
|
68
|
+
create_enum_hash if @enumeration
|
69
|
+
# Start the read/update/write process:
|
70
|
+
logger.info("Initiating read/update/write process. This may take some time...")
|
71
|
+
# Monitor whether every file read/write was successful:
|
72
|
+
all_read = true
|
73
|
+
all_write = true
|
74
|
+
files_written = 0
|
75
|
+
files_failed_read = 0
|
76
|
+
begin
|
77
|
+
require 'progressbar'
|
78
|
+
pbar = ProgressBar.new("Anonymizing", @files.length)
|
79
|
+
rescue LoadError
|
80
|
+
pbar = nil
|
81
|
+
end
|
82
|
+
# Temporarily increase the log threshold to suppress messages from the DObject class:
|
83
|
+
anonymizer_level = logger.level
|
84
|
+
logger.level = Logger::FATAL
|
85
|
+
@files.each_index do |i|
|
86
|
+
pbar.inc if pbar
|
87
|
+
# Read existing file to DICOM object:
|
88
|
+
dcm = DObject.read(@files[i])
|
89
|
+
if dcm.read?
|
90
|
+
# Extract the data element parents to investigate for this DICOM object:
|
91
|
+
parents = element_parents(dcm)
|
92
|
+
parents.each do |parent|
|
93
|
+
# Anonymize the desired tags:
|
94
|
+
@tags.each_index do |j|
|
95
|
+
if parent.exists?(@tags[j])
|
96
|
+
element = parent[@tags[j]]
|
97
|
+
if element.is_a?(Element)
|
98
|
+
if @blank
|
99
|
+
value = ""
|
100
|
+
elsif @enumeration
|
101
|
+
old_value = element.value
|
102
|
+
# Only launch enumeration logic if there is an actual value to the data element:
|
103
|
+
if old_value
|
104
|
+
value = enumerated_value(old_value, j)
|
105
|
+
else
|
106
|
+
value = ""
|
107
|
+
end
|
108
|
+
else
|
109
|
+
# Use the value that has been set for this tag:
|
110
|
+
value = @values[j]
|
111
|
+
end
|
112
|
+
element.value = value
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
116
|
+
# Delete elements marked for deletion:
|
117
|
+
@delete.each_key do |tag|
|
118
|
+
parent.delete(tag) if parent.exists?(tag)
|
119
|
+
end
|
120
|
+
end
|
121
|
+
# General DICOM object manipulation:
|
122
|
+
# Add a Patient Identity Removed attribute (as per
|
123
|
+
# DICOM PS 3.15, Annex E, E.1.1 De-Identifier, point 6):
|
124
|
+
dcm.add(Element.new('0012,0062', 'YES'))
|
125
|
+
# Delete (and replace) the File Meta Information (as per
|
126
|
+
# DICOM PS 3.15, Annex E, E.1.1 De-Identifier, point 7):
|
127
|
+
dcm.delete_group('0002')
|
128
|
+
# Handle UIDs if requested:
|
129
|
+
replace_uids(parents) if @uid
|
130
|
+
# Delete private tags?
|
131
|
+
dcm.delete_private if @delete_private
|
132
|
+
# Write DICOM file:
|
133
|
+
dcm.write(@write_paths[i])
|
134
|
+
if dcm.written?
|
135
|
+
files_written += 1
|
136
|
+
else
|
137
|
+
all_write = false
|
138
|
+
end
|
139
|
+
else
|
140
|
+
all_read = false
|
141
|
+
files_failed_read += 1
|
142
|
+
end
|
143
|
+
end
|
144
|
+
pbar.finish if pbar
|
145
|
+
# Finished anonymizing files. Reset the log threshold:
|
146
|
+
logger.level = anonymizer_level
|
147
|
+
# Print elapsed time and status of anonymization:
|
148
|
+
end_time = Time.now.to_f
|
149
|
+
logger.info("Anonymization process completed!")
|
150
|
+
if all_read
|
151
|
+
logger.info("All files in the specified folder(s) were SUCCESSFULLY read to DICOM objects.")
|
152
|
+
else
|
153
|
+
logger.warn("Some files were NOT successfully read (#{files_failed_read} files). If some folder(s) contain non-DICOM files, this is expected.")
|
154
|
+
end
|
155
|
+
if all_write
|
156
|
+
logger.info("All DICOM objects were SUCCESSFULLY written as DICOM files (#{files_written} files).")
|
157
|
+
else
|
158
|
+
logger.warn("Some DICOM objects were NOT succesfully written to file. You are advised to investigate the result (#{files_written} files succesfully written).")
|
159
|
+
end
|
160
|
+
@audit_trail.write(@audit_trail_file) if @audit_trail
|
161
|
+
elapsed = (end_time-start_time).to_s
|
162
|
+
logger.info("Elapsed time: #{elapsed[0..elapsed.index(".")+1]} seconds")
|
163
|
+
else
|
164
|
+
logger.warn("No tags were selected for anonymization. Aborting.")
|
165
|
+
end
|
166
|
+
else
|
167
|
+
logger.warn("No files were found in specified folders. Aborting.")
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
# Prints to screen a list of which tags are currently selected for anonymization along with
|
172
|
+
# the replacement values that will be used and enumeration status.
|
173
|
+
#
|
174
|
+
def print
|
175
|
+
logger.warn("Anonymizer#print is deprecated.")
|
176
|
+
# Extract the string lengths which are needed to make the formatting nice:
|
177
|
+
names = Array.new
|
178
|
+
types = Array.new
|
179
|
+
tag_lengths = Array.new
|
180
|
+
name_lengths = Array.new
|
181
|
+
type_lengths = Array.new
|
182
|
+
value_lengths = Array.new
|
183
|
+
@tags.each_index do |i|
|
184
|
+
name, vr = LIBRARY.name_and_vr(@tags[i])
|
185
|
+
names << name
|
186
|
+
types << vr
|
187
|
+
tag_lengths[i] = @tags[i].length
|
188
|
+
name_lengths[i] = names[i].length
|
189
|
+
type_lengths[i] = types[i].length
|
190
|
+
value_lengths[i] = @values[i].to_s.length unless @blank
|
191
|
+
value_lengths[i] = '' if @blank
|
192
|
+
end
|
193
|
+
# To give the printed output a nice format we need to check the string lengths of some of these arrays:
|
194
|
+
tag_maxL = tag_lengths.max
|
195
|
+
name_maxL = name_lengths.max
|
196
|
+
type_maxL = type_lengths.max
|
197
|
+
value_maxL = value_lengths.max
|
198
|
+
# Format string array for print output:
|
199
|
+
lines = Array.new
|
200
|
+
@tags.each_index do |i|
|
201
|
+
# Configure empty spaces:
|
202
|
+
s = ' '
|
203
|
+
f1 = ' '*(tag_maxL-@tags[i].length+1)
|
204
|
+
f2 = ' '*(name_maxL-names[i].length+1)
|
205
|
+
f3 = ' '*(type_maxL-types[i].length+1)
|
206
|
+
f4 = ' ' if @blank
|
207
|
+
f4 = ' '*(value_maxL-@values[i].to_s.length+1) unless @blank
|
208
|
+
if @enumeration
|
209
|
+
enum = @enumerations[i]
|
210
|
+
else
|
211
|
+
enum = ''
|
212
|
+
end
|
213
|
+
if @blank
|
214
|
+
value = ''
|
215
|
+
else
|
216
|
+
value = @values[i]
|
217
|
+
end
|
218
|
+
tag = @tags[i]
|
219
|
+
lines << tag + f1 + names[i] + f2 + types[i] + f3 + value.to_s + f4 + enum.to_s
|
220
|
+
end
|
221
|
+
# Print to screen:
|
222
|
+
lines.each do |line|
|
223
|
+
puts line
|
224
|
+
end
|
225
|
+
end
|
226
|
+
|
227
|
+
|
228
|
+
private
|
229
|
+
|
230
|
+
|
231
|
+
# Finds the common path (if any) in the instance file path array, by performing a recursive search
|
232
|
+
# on the folders that make up the path of one such file.
|
233
|
+
#
|
234
|
+
# @param [Array<String>] str_arr an array of folder strings from the path of a select file
|
235
|
+
# @param [Fixnum] index the index of the folder in str_arr to check against all file paths
|
236
|
+
# @return [Fixnum] the index of the last folder in the path of the selected file that is common for all file paths
|
237
|
+
#
|
238
|
+
def common_path(str_arr, index=0)
|
239
|
+
common_folders = Array.new
|
240
|
+
# Find out how much of the path is similar for all files in @files array:
|
241
|
+
folder = str_arr[index]
|
242
|
+
all_match = true
|
243
|
+
@files.each do |f|
|
244
|
+
all_match = false unless f.include?(folder)
|
245
|
+
end
|
246
|
+
if all_match
|
247
|
+
# Need to check the next folder in the array:
|
248
|
+
result = common_path(str_arr, index + 1)
|
249
|
+
else
|
250
|
+
# Current folder did not match, which means last possible match is current index -1.
|
251
|
+
result = index - 1
|
252
|
+
end
|
253
|
+
return result
|
254
|
+
end
|
255
|
+
|
256
|
+
# Discovers all the files contained in the specified directory (all its sub-directories),
|
257
|
+
# and adds these files to the instance file array.
|
258
|
+
#
|
259
|
+
def load_files
|
260
|
+
# Load find library:
|
261
|
+
require 'find'
|
262
|
+
# Iterate through the folders (and its subfolders) to extract all files:
|
263
|
+
for dir in @folders
|
264
|
+
Find.find(dir) do |path|
|
265
|
+
if FileTest.directory?(path)
|
266
|
+
proceed = true
|
267
|
+
@exceptions.each do |e|
|
268
|
+
proceed = false if e == path
|
269
|
+
end
|
270
|
+
if proceed
|
271
|
+
next
|
272
|
+
else
|
273
|
+
Find.prune # Don't look any further into this directory.
|
274
|
+
end
|
275
|
+
else
|
276
|
+
@files << path # Store the file in our array
|
277
|
+
end
|
278
|
+
end
|
279
|
+
end
|
280
|
+
end
|
281
|
+
|
282
|
+
# Analyzes the write_path and the 'read' file path to determine if they have some common root.
|
283
|
+
# If there are parts of the file path that exists also in the write path, the common parts will
|
284
|
+
# not be added to the write_path. The processed paths are put in a write_path instance array.
|
285
|
+
#
|
286
|
+
def process_write_paths
|
287
|
+
@write_paths = Array.new
|
288
|
+
# First make sure @write_path ends with a file separator character:
|
289
|
+
last_character = @write_path[-1..-1]
|
290
|
+
@write_path = @write_path + File::SEPARATOR unless last_character == File::SEPARATOR
|
291
|
+
# Differing behaviour if we have one, or several files in our array:
|
292
|
+
if @files.length == 1
|
293
|
+
# Write path is requested write path + old file name:
|
294
|
+
str_arr = @files[0].split(File::SEPARATOR)
|
295
|
+
@write_paths << @write_path + str_arr.last
|
296
|
+
else
|
297
|
+
# Several files.
|
298
|
+
# Find out how much of the path they have in common, remove that and
|
299
|
+
# add the remaining to the @write_path:
|
300
|
+
str_arr = @files[0].split(File::SEPARATOR)
|
301
|
+
last_match_index = common_path(str_arr)
|
302
|
+
if last_match_index >= 0
|
303
|
+
# Remove the matching folders from the path that will be added to @write_path:
|
304
|
+
@files.each do |file|
|
305
|
+
arr = file.split(File::SEPARATOR)
|
306
|
+
part_to_write = arr[(last_match_index+1)..(arr.length-1)].join(File::SEPARATOR)
|
307
|
+
@write_paths << @write_path + part_to_write
|
308
|
+
end
|
309
|
+
else
|
310
|
+
# No common folders. Add all of original path to write path:
|
311
|
+
@files.each do |file|
|
312
|
+
@write_paths << @write_path + file
|
313
|
+
end
|
314
|
+
end
|
315
|
+
end
|
316
|
+
end
|
317
|
+
|
318
|
+
end
|
319
|
+
|
2
320
|
end
|