rtkit 0.7

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,290 @@
1
+ module RTKIT
2
+
3
+ # The ImageSeries class contains methods that are specific for the slice based image modalites (e.g. CT, MR).
4
+ #
5
+ # === Inheritance
6
+ #
7
+ # * ImageSeries inherits all methods and attributes from the Series class.
8
+ #
9
+ class ImageSeries < Series
10
+
11
+ include ImageParent
12
+
13
+ # The Frame (of Reference) which this ImageSeries belongs to.
14
+ attr_accessor :frame
15
+ # An array of Image references.
16
+ attr_reader :images
17
+ # A hash containing SOP Instance UIDs as key and Slice Positions as value.
18
+ attr_reader :slices
19
+ # A hash containing Slice Positions as key and SOP Instance UIDS as value.
20
+ attr_accessor :sop_uids
21
+ # An array of Structure Sets associated with this Image Series.
22
+ attr_reader :structs
23
+
24
+ # Creates a new ImageSeries instance by loading series information from the specified DICOM object.
25
+ # The Series' UID string value is used to uniquely identify an ImageSeries.
26
+ #
27
+ # === Parameters
28
+ #
29
+ # * <tt>dcm</tt> -- An instance of a DICOM object (DICOM::DObject) with an image type modality (e.g. CT or MR).
30
+ # * <tt>study</tt> -- The Study instance that this ImageSeries belongs to.
31
+ #
32
+ def self.load(dcm, study)
33
+ raise ArgumentError, "Invalid argument 'dcm'. Expected DObject, got #{dcm.class}." unless dcm.is_a?(DICOM::DObject)
34
+ raise ArgumentError, "Invalid argument 'study'. Expected Study, got #{study.class}." unless study.is_a?(Study)
35
+ raise ArgumentError, "Invalid argument 'dcm'. Expected DObject with an Image Series type modality, got #{dcm.value(MODALITY)}." unless IMAGE_SERIES.include?(dcm.value(MODALITY))
36
+ # Required attributes:
37
+ modality = dcm.value(MODALITY)
38
+ series_uid = dcm.value(SERIES_UID)
39
+ # Optional attributes:
40
+ class_uid = dcm.value(SOP_CLASS)
41
+ date = dcm.value(SERIES_DATE)
42
+ time = dcm.value(SERIES_TIME)
43
+ description = dcm.value(SERIES_DESCR)
44
+ # Check if a Frame with the given UID already exists, and if not, create one:
45
+ frame = study.patient.dataset.frame(dcm.value(FRAME_OF_REF)) || frame = study.patient.create_frame(dcm.value(FRAME_OF_REF), dcm.value(POS_REF_INDICATOR))
46
+ # Create the ImageSeries instance:
47
+ is = self.new(series_uid, modality, frame, study, :class_uid => class_uid, :date => date, :time => time, :description => description)
48
+ is.add(dcm)
49
+ # Add our ImageSeries instance to its corresponding Frame:
50
+ frame.add_series(is)
51
+ return is
52
+ end
53
+
54
+ # Creates a new ImageSeries instance.
55
+ #
56
+ # === Parameters
57
+ #
58
+ # * <tt>series_uid</tt> -- The Series Instance UID string.
59
+ # * <tt>modality</tt> -- The Modality string of the ImageSeries, e.g. 'CT' or 'MR'.
60
+ # * <tt>frame</tt> -- The Frame instance that this ImageSeries belongs to.
61
+ # * <tt>study</tt> -- The Study instance that this ImageSeries belongs to.
62
+ # * <tt>options</tt> -- A hash of parameters.
63
+ #
64
+ # === Options
65
+ #
66
+ # * <tt>:class_uid</tt> -- String. The SOP Class UID (DICOM tag '0008,0016').
67
+ # * <tt>:date</tt> -- String. The Series Date (DICOM tag '0008,0021').
68
+ # * <tt>:time</tt> -- String. The Series Time (DICOM tag '0008,0031').
69
+ # * <tt>:description</tt> -- String. The Series Description (DICOM tag '0008,103E').
70
+ #
71
+ def initialize(series_uid, modality, frame, study, options={})
72
+ raise ArgumentError, "Invalid argument 'series_uid'. Expected String, got #{series_uid.class}." unless series_uid.is_a?(String)
73
+ raise ArgumentError, "Invalid argument 'modality'. Expected String, got #{modality.class}." unless modality.is_a?(String)
74
+ raise ArgumentError, "Invalid argument 'frame'. Expected Frame, got #{frame.class}." unless frame.is_a?(Frame)
75
+ raise ArgumentError, "Invalid argument 'study'. Expected Study, got #{study.class}." unless study.is_a?(Study)
76
+ raise ArgumentError, "Invalid argument 'modality'. Expected an Image Series type modality, got #{modality}." unless IMAGE_SERIES.include?(modality)
77
+ # Pass attributes to Series initialization:
78
+ super(series_uid, modality, study, options)
79
+ # Key attributes:
80
+ @frame = frame
81
+ # Default attributes:
82
+ @slices = Hash.new
83
+ @sop_uids = Hash.new
84
+ @images = Array.new
85
+ @structs = Array.new
86
+ @image_positions = Hash.new
87
+ # A hash with the associated StructureSet's UID as key and the instance of the StructureSet that belongs to this ImageSeries as value:
88
+ @associated_structs = Hash.new
89
+ # Register ourselves with the study & frame:
90
+ @study.add_series(self)
91
+ @frame.add_series(self)
92
+ end
93
+
94
+ # Returns true if the argument is an instance with attributes equal to self.
95
+ #
96
+ def ==(other)
97
+ if other.respond_to?(:to_image_series)
98
+ other.send(:state) == state
99
+ end
100
+ end
101
+
102
+ alias_method :eql?, :==
103
+
104
+ # Adds a DICOM object (Image) to the ImageSeries, by creating a new Image instance linked to this ImageSeries.
105
+ #
106
+ def add(dcm)
107
+ Image.load(dcm, self)
108
+ end
109
+
110
+ # Adds an Image to this ImageSeries.
111
+ #
112
+ def add_image(image)
113
+ raise ArgumentError, "Invalid argument 'image'. Expected Image, got #{image.class}." unless image.is_a?(Image)
114
+ @images << image unless @frame.image(image.uid)
115
+ @slices[image.uid] = image.pos_slice
116
+ @sop_uids[image.pos_slice] = image.uid
117
+ # The link between image uid and image instance is kept in the Frame, instead of the ImageSeries:
118
+ @frame.add_image(image) unless @frame.image(image.uid)
119
+ end
120
+
121
+ # Adds a StructureSet to this ImageSeries.
122
+ #
123
+ def add_struct(struct)
124
+ raise ArgumentError, "Invalid argument 'struct'. Expected StructureSet, got #{struct.class}." unless struct.is_a?(StructureSet)
125
+ # Do not add it again if the struct already belongs to this instance:
126
+ @structs << struct unless @associated_structs[struct.uid]
127
+ @associated_structs[struct.uid] = struct
128
+ end
129
+
130
+ =begin
131
+ # Returns the array position in the sorted array of slices that is closest to the provided slice.
132
+ # If slice value is out of bounds (it is further from boundaries than the slice interval), false is returned.
133
+ def corresponding_slice(slice, slices)
134
+ above_pos = (0...slices.length).select{|x| slices[x]>=slice}.first
135
+ below_pos = (0...slices.length).select{|x| slices[x]<=slice}.last
136
+ # With Ruby 1.9 this can supposedly be simplified to: below_pos = slices.index{|x| x<=slice}
137
+ # Exact match or between two slices?
138
+ if above_pos == below_pos
139
+ # Exact match (both point to the same index).
140
+ slice_index = above_pos
141
+ else
142
+ # Value in between. Return the index of the value that is closest to our value.
143
+ if (slice-slices[above_pos]).abs < (slice-slices[below_pos]).abs
144
+ slice_index = above_pos
145
+ else
146
+ slice_index = below_pos
147
+ end
148
+ end
149
+ return slice_index
150
+ end
151
+ =end
152
+
153
+ # Generates a Fixnum hash value for this instance.
154
+ #
155
+ def hash
156
+ state.hash
157
+ end
158
+
159
+ # Returns the Image instance mathcing the specified SOP Instance UID (if an argument is used).
160
+ # If a specified UID doesn't match, nil is returned.
161
+ # If no argument is passed, the first Image instance associated with the ImageSeries is returned.
162
+ #
163
+ # === Parameters
164
+ #
165
+ # * <tt>uid</tt> -- String. The value of the SOP Instance UID element of the Image.
166
+ #
167
+ def image(*args)
168
+ raise ArgumentError, "Expected one or none arguments, got #{args.length}." unless [0, 1].include?(args.length)
169
+ if args.length == 1
170
+ if args.first.is_a?(Float)
171
+ # Presumably an image position:
172
+ return @image_positions[args.first]
173
+ else
174
+ # Presumably a uid string:
175
+ return @frame.image(args.first)
176
+ end
177
+ else
178
+ # No argument used, therefore we return the first Image instance:
179
+ return @images.first
180
+ end
181
+ end
182
+
183
+ # Analyses the Image instances belonging to this ImageSeries to determine
184
+ # if there is an Image which matches the specified Plane.
185
+ # Returns the Image if a match is found, nil if not.
186
+ #
187
+ # === Parameters
188
+ #
189
+ # * <tt>plane</tt> -- The Plane instance which images will be matched against.
190
+ #
191
+ def match_image(plane)
192
+ raise ArgumentError, "Invalid argument 'plane'. Expected Plane, got #{plane.class}." unless plane.is_a?(Plane)
193
+ matching_image = nil
194
+ planes_in_series = Array.new
195
+ @images.each do |image|
196
+ # Get three coordinates from the image:
197
+ col_indices = NArray.to_na([0,image.columns/2, image.columns-1])
198
+ row_indices = NArray.to_na([image.rows/2, image.rows-1, 0])
199
+ x, y, z = image.coordinates_from_indices(col_indices, row_indices)
200
+ coordinates = Array.new
201
+ x.length.times do |i|
202
+ coordinates << Coordinate.new(x[i], y[i], z[i])
203
+ end
204
+ # Determine the image plane:
205
+ planes_in_series << Plane.calculate(coordinates[0], coordinates[1], coordinates[2])
206
+ end
207
+ # Search for a match amongst the planes of this series:
208
+ index = plane.match(planes_in_series)
209
+ matching_image = @images[index] if index
210
+ return matching_image
211
+ end
212
+
213
+ # Returns all ROIs having the same Frame of Reference as this
214
+ # image series from the structure set(s) belonging to this series.
215
+ # Returns the ROIs in an Array. If no ROIs are matched, an empty array is returned.
216
+ #
217
+ def rois
218
+ frame_rois = Array.new
219
+ structs.each do |struct|
220
+ frame_rois << struct.rois_in_frame(@frame.uid)
221
+ end
222
+ return frame_rois.flatten
223
+ end
224
+
225
+ # Sets the resolution of all images in this image series.
226
+ # The images will either be expanded or cropped depending on whether
227
+ # the specified resolution is bigger or smaller than the existing one.
228
+ #
229
+ # === Parameters
230
+ #
231
+ # * <tt>columns</tt> -- Integer. The number of columns applied to the cropped/expanded image series.
232
+ # * <tt>rows</tt> -- Integer. The number of rows applied to the cropped/expanded image series.
233
+ #
234
+ # === Options
235
+ #
236
+ # * <tt>:hor</tt> -- Symbol. The side (in the horisontal image direction) to apply the crop/border (:left, :right or :even (default)).
237
+ # * <tt>:ver</tt> -- Symbol. The side (in the vertical image direction) to apply the crop/border (:bottom, :top or :even (default)).
238
+ #
239
+ def set_resolution(columns, rows, options={})
240
+ @images.each do |img|
241
+ img.set_resolution(columns, rows, options)
242
+ end
243
+ end
244
+
245
+ # Returns the StructureSet instance mathcing the specified SOP Instance UID (if an argument is used).
246
+ # If a specified UID doesn't match, nil is returned.
247
+ # If no argument is passed, the first StructureSet instance associated with the ImageSeries is returned.
248
+ #
249
+ # === Parameters
250
+ #
251
+ # * <tt>uid</tt> -- String. The value of the SOP Instance UID element.
252
+ #
253
+ def struct(*args)
254
+ raise ArgumentError, "Expected one or none arguments, got #{args.length}." unless [0, 1].include?(args.length)
255
+ if args.length == 1
256
+ raise ArgumentError, "Expected String (or nil), got #{args.first.class}." unless [String, NilClass].include?(args.first.class)
257
+ return @associated_structs[args.first]
258
+ else
259
+ # No argument used, therefore we return the first StructureSet instance:
260
+ return @structs.first
261
+ end
262
+ end
263
+
264
+ # Returns self.
265
+ #
266
+ def to_image_series
267
+ self
268
+ end
269
+
270
+ # Writes all images in this image series to DICOM files in the specified folder.
271
+ # The file names are set by the image's UID string, followed by a '.dcm' extension.
272
+ #
273
+ def write(path)
274
+ @images.each do |img|
275
+ img.write(path + img.uid + '.dcm')
276
+ end
277
+ end
278
+
279
+
280
+ private
281
+
282
+
283
+ # Returns the attributes of this instance in an array (for comparison purposes).
284
+ #
285
+ def state
286
+ [@images, @series_uid, @structs]
287
+ end
288
+
289
+ end
290
+ end
@@ -0,0 +1,158 @@
1
+ module RTKIT
2
+
3
+ # This module handles logging functionality.
4
+ #
5
+ # Logging functionality uses the Standard library's Logger class.
6
+ # To properly handle progname, which inside the RTKIT module is simply
7
+ # "RTKIT", in all cases, we use an implementation with a proxy class.
8
+ #
9
+ # === Examples
10
+ #
11
+ # require 'rtkit'
12
+ # include RTKIT
13
+ #
14
+ # # Logging to STDOUT with DEBUG level:
15
+ # RTKIT.logger = Logger.new(STDOUT)
16
+ # RTKIT.logger.level = Logger::DEBUG
17
+ #
18
+ # # Logging to a file:
19
+ # RTKIT.logger = Logger.new('my_logfile.log')
20
+ #
21
+ # # Combine an external logger with RTKIT:
22
+ # logger = Logger.new(STDOUT)
23
+ # logger.progname = "MY_APP"
24
+ # RTKIT.logger = logger
25
+ # # Now you can call the logger in the following ways:
26
+ # RTKIT.logger.info "Message" # => "RTKIT: Message"
27
+ # RTKIT.logger.info("MY_MODULE) {"Message"} # => "MY_MODULE: Message"
28
+ # logger.info "Message" # => "MY_APP: Message"
29
+ #
30
+ # For more information, please read the Standard library Logger documentation.
31
+ #
32
+ module Logging
33
+
34
+ require 'logger'
35
+
36
+ # Inclusion hook to make the ClassMethods available to whatever
37
+ # includes the Logging module, i.e. the RTKIT module.
38
+ #
39
+ def self.included(base)
40
+ base.extend(ClassMethods)
41
+ end
42
+
43
+ module ClassMethods
44
+
45
+ # We use our own ProxyLogger to achieve the features wanted for RTKIT logging,
46
+ # e.g. using RTKIT as progname for messages logged within the RTKIT module
47
+ # (for both the Standard logger as well as the Rails logger), while still allowing
48
+ # a custom progname to be used when the logger is called outside the RTKIT module.
49
+ #
50
+ class ProxyLogger
51
+
52
+ # Creating the ProxyLogger instance.
53
+ #
54
+ # === Parameters
55
+ #
56
+ # * <tt>target</tt> -- A Logger instance (e.g. Standard Logger or ActiveSupport::BufferedLogger).
57
+ #
58
+ def initialize(target)
59
+ @target = target
60
+ end
61
+
62
+ # Catches missing methods.
63
+ # In our case, the methods of interest are the typical logger methods,
64
+ # i.e. log, info, fatal, error, debug, where the arguments/block are
65
+ # redirected to the logger in a specific way so that our stated logger
66
+ # features are achieved (this behaviour depends on the logger
67
+ # (Rails vs Standard) and in the case of Standard logger,
68
+ # whether or not a block is given).
69
+ #
70
+ # === Examples
71
+ #
72
+ # # Inside the RTKIT module or an external class with 'include RTKIT::Logging':
73
+ # logger.info "message"
74
+ #
75
+ # # Calling from outside the RTKIT module:
76
+ # RTKIT.logger.info "message"
77
+ #
78
+ def method_missing(method_name, *args, &block)
79
+ if method_name.to_s =~ /(log|debug|info|warn|error|fatal)/
80
+ # Rails uses it's own buffered logger which does not
81
+ # work with progname + block as the standard logger does:
82
+ if defined?(Rails)
83
+ @target.send(method_name, "RTKIT: #{args.first}")
84
+ elsif block_given?
85
+ @target.send(method_name, *args) { yield }
86
+ else
87
+ @target.send(method_name, "RTKIT") { args.first }
88
+ end
89
+ else
90
+ @target.send(method_name, *args, &block)
91
+ end
92
+ end
93
+
94
+ end
95
+
96
+ # The logger class variable (must be initialized
97
+ # before it is referenced by the object setter).
98
+ #
99
+ @@logger = nil
100
+
101
+ # The logger object setter.
102
+ # This method is used to replace the default logger instance with
103
+ # a custom logger of your own.
104
+ #
105
+ # === Parameters
106
+ #
107
+ # * <tt>l</tt> -- A Logger instance (e.g. a custom standard Logger).
108
+ #
109
+ # === Examples
110
+ #
111
+ # # Create a logger which ages logfile once it reaches a certain size,
112
+ # # leaves 10 "old log files" with each file being about 1,024,000 bytes:
113
+ # RTKIT.logger = Logger.new('foo.log', 10, 1024000)
114
+ #
115
+ def logger=(l)
116
+ @@logger = ProxyLogger.new(l)
117
+ end
118
+
119
+ # The logger object getter.
120
+ # Returns the logger class variable, if defined.
121
+ # If not defined, sets up the Rails logger (if in a Rails environment),
122
+ # or a Standard logger if not.
123
+ #
124
+ # === Examples
125
+ #
126
+ # # Inside the RTKIT module (or a class with 'include RTKIT::Logging'):
127
+ # logger # => Logger instance
128
+ #
129
+ # # Accessing from outside the RTKIT module:
130
+ # RTKIT.logger # => Logger instance
131
+ #
132
+ def logger
133
+ @@logger ||= lambda {
134
+ if defined?(Rails)
135
+ ProxyLogger.new(Rails.logger)
136
+ else
137
+ l = Logger.new(STDOUT)
138
+ l.level = Logger::INFO
139
+ ProxyLogger.new(l)
140
+ end
141
+ }.call
142
+ end
143
+
144
+ end
145
+
146
+ # A logger object getter.
147
+ # Forwards the call to the logger class method of the Logging module.
148
+ #
149
+ def logger
150
+ self.class.logger
151
+ end
152
+
153
+ end
154
+
155
+ # Include the Logging module so we can use RTKIT.logger.
156
+ include Logging
157
+
158
+ end
@@ -0,0 +1,105 @@
1
+ module RTKIT
2
+
3
+ class << self
4
+
5
+ #--
6
+ # Module methods:
7
+ #++
8
+
9
+ # Finds all files contained in the specified folder or folders (including any sub-folders).
10
+ # Returns an array containing the discovered file strings.
11
+ #
12
+ # === Parameters
13
+ #
14
+ # * <tt>path_or_paths</tt> -- String or Array of strings. The path(s) in which to find all files.
15
+ #
16
+ def files(path_or_paths)
17
+ raise ArgumentError, "Invalid argument 'path_or_paths'. Expected String or Array, got #{paths.class}." unless [String, Array].include?(path_or_paths.class)
18
+ raise ArgumentError, "Invalid argument 'path_or_paths'. Expected Array to contain only strings, got #{path_or_paths.collect{|p| p.class}.uniq}." if path_or_paths.is_a?(Array) && path_or_paths.collect{|p| p.class}.uniq != [String]
19
+ paths = path_or_paths.is_a?(Array) ? path_or_paths : [path_or_paths]
20
+ files = Array.new
21
+ # Iterate the folders (and their subfolders) to extract all files:
22
+ for dir in paths
23
+ Find.find(dir) do |path|
24
+ if FileTest.directory?(path)
25
+ next
26
+ else
27
+ # Store the file in our array:
28
+ files << path
29
+ end
30
+ end
31
+ end
32
+ return files
33
+ end
34
+
35
+ # Generates and returns a random Frame Instance UID string.
36
+ #
37
+ def frame_uid
38
+ return self.generate_uids('9').first
39
+ end
40
+
41
+ # Generates one or several random UID strings.
42
+ # The UIDs are based on the RTKIT dicom_root attribute, a type prefix, a datetime part,
43
+ # a random number part, and an index part (when multiple UIDs are requested,
44
+ # e.g. for a SOP Instances in a Series).
45
+ # Returns the UIDs in a string array.
46
+ #
47
+ # === Parameters
48
+ #
49
+ # * <tt>prefix</tt> -- String. A (numerical) type string which sits between the dicom root and the random part of the UID.
50
+ # * <tt>instances</tt> -- Integer. The number of UIDs to generate. Defaults to 1.
51
+ #
52
+ def generate_uids(prefix, instances=1)
53
+ raise ArgumentError, "Invalid argument 'prefix'. Expected (integer) String, got #{prefix.class}." unless prefix.is_a?(String)
54
+ raise ArgumentError, "Invalid argument 'instances'. Expected Integer (when defined), got #{instances.class}." if instances && !instances.is_a?(Integer)
55
+ raise ArgumentError, "Invalid argument 'prefix'. Expected non-zero Integer (String), got #{prefix}." if prefix.to_i == 0
56
+ raise ArgumentError, "Invalid argument 'instances'. Expected positive Integer (when defined), got #{instances}." if instances && instances < 0
57
+ prefix = prefix.to_i
58
+ # NB! For UIDs, leading zeroes after a dot is not allowed, and must be removed:
59
+ date = Time.now.strftime("%Y%m%d").to_i.to_s
60
+ time = Time.now.strftime("%H%M%S").to_i.to_s
61
+ random = rand(99999) + 1 # (Minimum 1, max. 99999)
62
+ base_uid = [RTKIT.dicom_root, prefix, date, time, random].join('.')
63
+ uids = Array.new
64
+ if instances == 1
65
+ uids << base_uid
66
+ else
67
+ (1..instances).to_a.each do |i|
68
+ uids << "#{base_uid}.#{i}"
69
+ end
70
+ end
71
+ return uids
72
+ end
73
+
74
+ # Generates and returns a random Series Instance UID string.
75
+ #
76
+ def series_uid
77
+ return self.generate_uids('2').first
78
+ end
79
+
80
+ # Generates and returns a random SOP Instance UID string.
81
+ #
82
+ def sop_uid
83
+ return self.generate_uids('3').first
84
+ end
85
+
86
+ # Generates and returns a collection of random SOP Instance UID strings.
87
+ #
88
+ # === Parameters
89
+ #
90
+ # * <tt>instances</tt> -- Integer. The number of UIDs to generate.
91
+ #
92
+ def sop_uids(instances)
93
+ raise ArgumentError, "Invalid argument 'instances'. Expected Integer, got #{instances.class}." unless instances.is_a?(Integer)
94
+ return self.generate_uids('3', instances)
95
+ end
96
+
97
+ # Generates and returns a random Study Instance UID string.
98
+ #
99
+ def study_uid
100
+ return self.generate_uids('1').first
101
+ end
102
+
103
+ end
104
+
105
+ end