rtkit 0.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.
@@ -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