dicom 0.9.4 → 0.9.5
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 +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
|