rtkit 0.7

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,241 @@
1
+ module RTKIT
2
+
3
+ # Contains the DICOM data and methods related to the comparison of binary volumes (i.e. segmented volumes).
4
+ #
5
+ class BinMatcher
6
+
7
+ # The reference (master) BinVolume.
8
+ attr_reader :master
9
+ # An array of BinVolumes.
10
+ attr_reader :volumes
11
+
12
+ # Creates a new BinMatcher instance.
13
+ #
14
+ # === Parameters
15
+ #
16
+ # * <tt>volumes</tt> -- An array of BinVolume instances to be matched.
17
+ # * <tt>master</tt> -- A master BinVolume which the other volumes will be compared against.
18
+ #
19
+ def initialize(volumes=nil, master=nil)
20
+ raise ArgumentError, "Invalid argument 'volumes'. Expected Array, got #{volumes.class}." if volumes && volumes.class != Array
21
+ raise ArgumentError, "Invalid argument 'master'. Expected BinVolume, got #{master.class}." if master && master.class != BinVolume
22
+ raise ArgumentError, "Invalid argument 'volumes'. Expected array to contain only BinVolume instances, got #{volumes.collect{|i| i.class}.uniq}." if volumes && volumes.collect{|i| i.class}.uniq != [BinVolume]
23
+ @volumes = volumes || Array.new
24
+ @master = master
25
+ end
26
+
27
+ # Returns true if the argument is an instance with attributes equal to self.
28
+ #
29
+ def ==(other)
30
+ if other.respond_to?(:to_bin_matcher)
31
+ other.send(:state) == state
32
+ end
33
+ end
34
+
35
+ alias_method :eql?, :==
36
+
37
+ # Adds a BinVolume instance to the matcher.
38
+ #
39
+ def add(volume)
40
+ raise ArgumentError, "Invalid argument 'volume'. Expected BinVolume, got #{volume.class}." unless volume.is_a?(BinVolume)
41
+ @volumes << volume
42
+ end
43
+
44
+ # Returns an array of volumes, (reversly) sorted by their sensitivity score.
45
+ # The volumes are sorted in such a way that the best scoring volume (highest sensitivity) appears first.
46
+ # If no volumes are defined, an empty array is returned.
47
+ #
48
+ def by_sensitivity
49
+ return (@volumes.sort_by {|v| v.sensitivity}).reverse
50
+ end
51
+
52
+ # Returns an array of volumes, (reversly) sorted by their specificity score.
53
+ # The volumes are sorted in such a way that the best scoring volume (highest specificity) appears first.
54
+ # If no volumes are defined, an empty array is returned.
55
+ #
56
+ def by_specificity
57
+ return (@volumes.sort_by {|v| v.specificity}).reverse
58
+ end
59
+
60
+ # Ensures that a valid comparison can be done by making sure that every volume
61
+ # has a BinImage for any image that is referenced among the BinVolumes.
62
+ # If one or more BinVolumes are missing one or more BinImages,
63
+ # empty BinImages will be created for these BinVolumes.
64
+ #
65
+ # === Notes
66
+ #
67
+ # * The master volume (if present) is also processed in this method.
68
+ #
69
+ def fill_blanks
70
+ if @volumes.length > 0
71
+ # Register all unique images referenced by the various volumes:
72
+ images = Set.new
73
+ # Include the master volume (if it is present):
74
+ [@volumes, @master].flatten.compact.each do |volume|
75
+ volume.bin_images.each do |bin_image|
76
+ images << bin_image.image unless images.include?(bin_image.image)
77
+ end
78
+ end
79
+ # Check if any of the volumes have images missing, and if so, create empty images:
80
+ images.each do |image|
81
+ [@volumes, @master].flatten.compact.each do |volume|
82
+ match = false
83
+ volume.bin_images.each do |bin_image|
84
+ match = true if bin_image.image == image
85
+ end
86
+ unless match
87
+ # Create BinImage:
88
+ bin_img = BinImage.new(NArray.byte(image.columns, image.rows), image)
89
+ volume.add(bin_img)
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
95
+
96
+ # Generates a Fixnum hash value for this instance.
97
+ #
98
+ def hash
99
+ state.hash
100
+ end
101
+
102
+ # Sets the master (reference) BinVolume.
103
+ #
104
+ def master=(volume)
105
+ raise ArgumentError, "Invalid argument 'volume'. Expected BinVolume, got #{volume.class}." unless volume.is_a?(BinVolume)
106
+ @master = volume
107
+ end
108
+
109
+ # Returns an array of NArrays from the BinVolumes of this instance.
110
+ # Returns an empty array if no volumes are defined.
111
+ #
112
+ # === Notes
113
+ #
114
+ # * Only the volumes are returned. The master volume (if present) is not returned with this method.
115
+ #
116
+ def narrays(sort_slices=true)
117
+ n = Array.new
118
+ @volumes.each do |volume|
119
+ n << volume.narray(sort_slices)
120
+ end
121
+ return n
122
+ end
123
+
124
+ =begin
125
+ # Along each dimension of the input binary volume(s), removes any index (i.e. slice, column or row)
126
+ # which is empty in all volumes (including the master volume, if specified). This method results
127
+ # in reduced volumes, corresponding to a clipbox which tightly encloses the defined (positive) volume
128
+ # (indices with pixel value 1 in at least one of the volumes). Such a reduced volume may yield e.g.
129
+ # specificity scores with better contrast.
130
+ #
131
+ def remove_empty_indices
132
+ # It only makes sense to run this volume reduction if the number of dimensions are 2 or more:
133
+ volumes = [*@volumes, master].compact
134
+ # For each volume the indices typically correspond to the following
135
+ # descriptions: slice, column & row
136
+ volumes.first.narray.shape.each_with_index do |size, dim_index|
137
+ extract = Array.new(volumes.first.narray.dim, true)
138
+ positive_indices = Array.new
139
+ size.times do |i|
140
+ extract[dim_index] = i
141
+ positive = false
142
+ volumes.each do |volume|
143
+ positive = true if volume.narray[*extract].max > 0
144
+ end
145
+ positive_indices << i if positive
146
+ end
147
+ extract[dim_index] = positive_indices
148
+ if positive_indices.length < size
149
+ volumes.each do |volume|
150
+ volume.narr = volume.narr[*extract]
151
+ end
152
+ end
153
+ end
154
+ volumes.each {|vol| puts vol.narr.inspect}
155
+ volumes.each {|vol| puts vol.narr.shape}
156
+ @volumes.each {|vol| puts vol.narr.shape}
157
+ end
158
+ =end
159
+
160
+ # Scores the volumes of the BinMatcher instance against the reference (master) volume,
161
+ # by using Dice's coeffecient.
162
+ #
163
+ # For more information, see:
164
+ # http://en.wikipedia.org/wiki/Dice%27s_coefficient
165
+ #
166
+ def score_dice
167
+ if @master
168
+ # Find the voxel-indices for the master where the decisions are 1 and 0:
169
+ pos_indices_master = (@master.narray.eq 1).where
170
+ @volumes.each do |bin_vol|
171
+ pos_indices_vol = (bin_vol.narray.eq 1).where
172
+ num_common = (@master.narray[pos_indices_vol].eq 1).where.length
173
+ bin_vol.dice = 2 * num_common / (pos_indices_master.length + pos_indices_vol.length).to_f
174
+ end
175
+ end
176
+ end
177
+
178
+ # Scores the volumes of the BinMatcher instance against the reference (master) volume,
179
+ # by using Sensitivity and Specificity.
180
+ #
181
+ # For more information, see:
182
+ # http://en.wikipedia.org/wiki/Sensitivity_and_specificity
183
+ #
184
+ def score_ss
185
+ if @master
186
+ # Find the voxel-indices for the master where the decisions are 1 and 0:
187
+ pos_indices, neg_indices = (@master.narray.eq 1).where2
188
+ @volumes.each do |bin_vol|
189
+ narr = bin_vol.narray
190
+ bin_vol.sensitivity = (narr[pos_indices].eq 1).where.length / pos_indices.length.to_f
191
+ bin_vol.specificity = (narr[neg_indices].eq 0).where.length / neg_indices.length.to_f
192
+ end
193
+ end
194
+ end
195
+
196
+ # Rearranges the BinImages belonging to the BinVolumes of this instance,
197
+ # by matching the BinImages by their Image instance references,
198
+ # to ensure that the NArrays extracted from these volumes are truly comparable.
199
+ #
200
+ # === Notes
201
+ #
202
+ # * The master volume (if present) is not processed in this method.
203
+ # * Raises an exception if any irregularities in number of BinImages or Image references occurs.
204
+ #
205
+ def sort_volumes
206
+ # It only makes sense to sort if we have at least two volumes:
207
+ if @volumes.length > 1
208
+ raise "All volumes of the BinMatcher isntance must have the same number of BinImages (got lengths #{@volumes.collect {|v| v.bin_images.length}})." if @volumes.collect {|v| v.bin_images.length}.uniq.length > 1
209
+ # Collect the Image references of the BinImage's of the first volume:
210
+ desired_image_order = Array.new
211
+ @volumes.first.bin_images.collect {|bin_image| desired_image_order << bin_image.image}
212
+ raise "One (or more) Image references were nil in the first BinVolume instance of the BinMatcher. Unable to sort BinImages when they lack Image reference." if desired_image_order.compact.length != desired_image_order.length
213
+ # Sort the BinImages of the remaining volumes so that their order of images are equal to that of the first volume:
214
+ (1...@volumes.length).each do |i|
215
+ current_image_order = Array.new
216
+ @volumes[i].bin_images.collect {|bin_image| current_image_order << bin_image.image}
217
+ sort_order = current_image_order.compare_with(desired_image_order)
218
+ @volumes[i].bin_images.sort_by_order!(sort_order)
219
+ #@volumes[i].reorder_images(sort_order)
220
+ end
221
+ end
222
+ end
223
+
224
+ # Returns self.
225
+ #
226
+ def to_bin_matcher
227
+ self
228
+ end
229
+
230
+
231
+ private
232
+
233
+
234
+ # Returns the attributes of this instance in an array (for comparison purposes).
235
+ #
236
+ def state
237
+ [@volumes, @master]
238
+ end
239
+
240
+ end
241
+ end
@@ -0,0 +1,263 @@
1
+ module RTKIT
2
+
3
+ # Contains the DICOM data and methods related to a binary volume.
4
+ #
5
+ # === Inheritance
6
+ #
7
+ # * As the BinVolume class inherits from the PixelData class, all PixelData methods are available to instances of BinVolume.
8
+ #
9
+ class BinVolume < PixelData
10
+
11
+ # An array of BinImage references.
12
+ attr_reader :bin_images
13
+ # Dice's Coeffecient.
14
+ attr_accessor :dice
15
+ # A NArray associated with this BinVolume (NB! This is a cached copy. If you want to ensure to
16
+ # extract an narray which corresponds to the BinVolume's Images, use the narray method instead.
17
+ attr_accessor :narr
18
+ # A score (fraction) of the segmented volume compared to a master volume, ranging from 0 (worst) to 1 (all true voxels segmented in this volume).
19
+ attr_accessor :sensitivity
20
+ # The BinVolume's series reference (e.g. ImageSeries or DoseVolume).
21
+ attr_reader :series
22
+ # A reference to the source of this BinaryVolume (ROI or Dose instance).
23
+ attr_reader :source
24
+ # A score (fraction) of the segmented volume compared to a master volume, ranging from 0 (worst) to 1 (none of the true remaining voxels are segmented in this volume).
25
+ attr_accessor :specificity
26
+
27
+ # Creates a new BinVolume instance from a DoseVolume.
28
+ # The BinVolume is typically defined from a ROI delineation against an image series,
29
+ # but it may also be applied to an rtdose 'image' series.
30
+ # Returns the BinVolume instance.
31
+ #
32
+ # === Notes
33
+ #
34
+ # * Even though listed as optional parameters, at least one of the min and max
35
+ # options must be specified in order to construct a valid binary volume.
36
+ #
37
+ # === Parameters
38
+ #
39
+ # * <tt>image_volume</tt> -- The image volume which the binary volume will be based on (a DoseVolume or an ImageSeries).
40
+ # * <tt>min</tt> -- Float. An optional lower dose limit from which to define the the binary volume.
41
+ # * <tt>max</tt> -- Float. An optional upper dose limit from which to define the the binary volume.
42
+ #
43
+ def self.from_dose(dose_volume, min=nil, max=nil, image_volume)
44
+ raise ArgumentError, "Invalid argument 'dose_volume'. Expected DoseVolume, got #{dose_volume.class}." unless dose_volume.class == DoseVolume
45
+ raise ArgumentError, "Invalid argument 'image_volume'. Expected ImageSeries or DoseVolume, got #{image_volume.class}." unless [ImageSeries, DoseVolume].include?(image_volume.class)
46
+ raise ArgumentError "Need at least one dose limit parameter. Neither min nor max was specified." unless min or max
47
+ # Create the BinVolume instance:
48
+ bv = self.new(dose_volume) # FIXME: Specify dose limit somehow here?!
49
+ # Add BinImages for each of the DoseVolume's images:
50
+ dose_narr = dose_volume.dose_arr
51
+ dose_volume.images.each_index do |i|
52
+ #ref_image = image_volume.image(dose_img.pos_slice)
53
+ ref_image = image_volume.images[i]
54
+ dose_image = dose_narr[i, true, true]
55
+ # Create the bin narray:
56
+ narr = NArray.byte(ref_image.columns, ref_image.rows)
57
+ if !min
58
+ # Only the max limit is specified:
59
+ marked_indices = (dose_image.le max.to_f)
60
+ elsif !max
61
+ # Only the min limit is specified:
62
+ marked_indices = (dose_image.ge min.to_f)
63
+ else
64
+ # Both min and max limits are specified:
65
+ smaller = (dose_image.le max.to_f)
66
+ bigger = (dose_image.ge min.to_f)
67
+ marked_indices = smaller.and bigger
68
+ end
69
+ narr[marked_indices] = 1
70
+ bin_img = BinImage.new(narr, ref_image)
71
+ bv.add(bin_img)
72
+ end
73
+ return bv
74
+ end
75
+
76
+ # Creates a new BinVolume instance from a ROI.
77
+ # The BinVolume is typically defined from a ROI delineation against an image series,
78
+ # but it may also be applied to an rtdose 'image' series.
79
+ # Returns the BinVolume instance.
80
+ #
81
+ # === Parameters
82
+ #
83
+ # * <tt>roi</tt> -- A ROI from which to define the binary volume.
84
+ # * <tt>image_volume</tt> -- The image volume which the binary volume will be based on (an ImageSeries or a DoseVolume).
85
+ #
86
+ def self.from_roi(roi, image_volume)
87
+ raise ArgumentError, "Invalid argument 'roi'. Expected ROI, got #{roi.class}." unless roi.is_a?(ROI)
88
+ raise ArgumentError, "Invalid argument 'image_volume'. Expected ImageSeries or DoseVolume, got #{image_volume.class}." unless [ImageSeries, DoseVolume].include?(image_volume.class)
89
+ # Create the BinVolume instance:
90
+ bv = self.new(roi.image_series, :source => roi)
91
+ # Add BinImages for each of the ROIs slices:
92
+ roi.slices.each do |slice|
93
+ image = image_volume.image(slice.pos)
94
+ BinImage.from_contours(slice.contours, image, bv)
95
+ #bv.add(slice.bin_image)
96
+ end
97
+ return bv
98
+ end
99
+
100
+ # Creates a new BinVolume instance from an image series (i.e. ImageSeries or DoseVolume).
101
+ # A BinVolume created this way specified the entire volume: i.e. the Binvolume
102
+ # has the same dimensions as the image series, and all pixels are 1.
103
+ # Returns the BinVolume instance.
104
+ #
105
+ # === Parameters
106
+ #
107
+ # * <tt>image_volume</tt> -- The image volume which the binary volume will be based on (an ImageSeries or a DoseVolume).
108
+ #
109
+ def self.from_volume(image_volume)
110
+ raise ArgumentError, "Invalid argument 'image_volume'. Expected ImageSeries or DoseVolume, got #{image_volume.class}." unless [ImageSeries, DoseVolume].include?(image_volume.class)
111
+ # Create the BinVolume instance:
112
+ bv = self.new(image_volume)
113
+ # Add BinImages for each of the ROIs slices:
114
+ image_volume.images.each do |image|
115
+ # Make an NArray of proper size filled with ones:
116
+ narr = NArray.byte(image.columns, image.rows).fill(1)
117
+ # Create the BinImage instance and add it to the BinVolume:
118
+ bin_img = BinImage.new(narr, image)
119
+ bv.add(bin_img)
120
+ end
121
+ return bv
122
+ end
123
+
124
+ # Creates a new BinVolume instance.
125
+ #
126
+ # === Parameters
127
+ #
128
+ # * <tt>series</tt> -- The image series (e.g. ImageSeries or DoseVolume) which forms the reference data of the BinVolume.
129
+ # * <tt>options</tt> -- A hash of parameters.
130
+ #
131
+ # === Options
132
+ #
133
+ # * <tt>:images</tt> -- An array of BinImage instances that is assigned to this BinVolume.
134
+ # * <tt>:source</tt> -- The object which is the source of the binary (segmented) data (i.e. ROI or Dose/Hounsfield threshold).
135
+ #
136
+ def initialize(series, options={})
137
+ raise ArgumentError, "Invalid argument 'series'. Expected ImageSeries or DoseVolume, got #{series.class}." unless [ImageSeries, DoseVolume].include?(series.class)
138
+ raise ArgumentError, "Invalid option 'images'. Expected Array, got #{options[:images].class}." if options[:images] && options[:images].class != Array
139
+ raise ArgumentError, "Invalid option 'source'. Expected ROI or RTDose, got #{options[:source].class}." if options[:source] && ![ROI, RTDose].include?(options[:source].class)
140
+ raise ArgumentError, "Invalid option 'images'. Expected only BinImage instances in the array, got #{options[:images].collect{|i| i.class}.uniq}." if options[:images] && options[:images].collect{|i| i.class}.uniq.length > 1
141
+ @series = series
142
+ @bin_images = options[:images] || Array.new
143
+ @source = options[:source]
144
+ end
145
+
146
+ # Returns true if the argument is an instance with attributes equal to self.
147
+ #
148
+ def ==(other)
149
+ if other.respond_to?(:to_bin_volume)
150
+ other.send(:state) == state
151
+ end
152
+ end
153
+
154
+ alias_method :eql?, :==
155
+
156
+ # Adds a BinImage instance to the volume.
157
+ #
158
+ def add(bin_image)
159
+ raise ArgumentError, "Invalid argument 'bin_image'. Expected BinImage, got #{bin_image.class}." unless bin_image.is_a?(BinImage)
160
+ @bin_images << bin_image
161
+ end
162
+
163
+ # Returns the number of columns in the images of the volume.
164
+ #
165
+ def columns
166
+ return @bin_images.first.columns if @bin_images.first
167
+ end
168
+
169
+ # Returns the number of frames (slices) in the set of images that makes up this volume.
170
+ #
171
+ def frames
172
+ return @bin_images.length
173
+ end
174
+
175
+ # Generates a Fixnum hash value for this instance.
176
+ #
177
+ def hash
178
+ state.hash
179
+ end
180
+
181
+ # Returns 3d volume array consisting of the 2d Narray images from the BinImage instances that makes up this volume.
182
+ # Returns nil if no BinImage instances are connected to this BinVolume.
183
+ #
184
+ def narray(sort_slices=true)
185
+ if @bin_images.length > 0
186
+ # Determine the slice position of each BinImage:
187
+ locations = Array.new
188
+ if sort_slices
189
+ @bin_images.collect {|image| locations << image.pos_slice}
190
+ images = @bin_images.sort_by_order(locations.sort_order)
191
+ else
192
+ images = @bin_images
193
+ end
194
+ # Create volume array and fill in the images:
195
+ volume = NArray.byte(frames, columns, rows)
196
+ images.each_with_index do |sorted_image, i|
197
+ volume[i, true, true] = sorted_image.narray
198
+ end
199
+ @narr = volume
200
+ end
201
+ end
202
+
203
+ def reorder_images(order)
204
+ @bin_images = @bin_images.sort_by_order(order)
205
+ end
206
+
207
+ # Returns the number of rows in the images of the volume.
208
+ #
209
+ def rows
210
+ return @bin_images.first.rows if @bin_images.first
211
+ end
212
+
213
+ # Returns self.
214
+ #
215
+ def to_bin_volume
216
+ self
217
+ end
218
+
219
+ # Creates a ROI instance from the segmentation of this BinVolume.
220
+ # Returns the ROI instance.
221
+ #
222
+ # === Parameters
223
+ #
224
+ # * <tt>struct</tt> -- A StructureSet instance which the ROI will be connected to.
225
+ # * <tt>options</tt> -- A hash of parameters.
226
+ #
227
+ # === Options
228
+ #
229
+ # * <tt>:algorithm</tt> -- String. The ROI Generation Algorithm. Defaults to 'Automatic'.
230
+ # * <tt>:name</tt> -- String. The ROI Name. Defaults to 'BinVolume'.
231
+ # * <tt>:number</tt> -- Integer. The ROI Number. Defaults to the first available ROI Number in the StructureSet.
232
+ # * <tt>:interpreter</tt> -- String. The ROI Interpreter. Defaults to 'RTKIT'.
233
+ # * <tt>:type</tt> -- String. The ROI Interpreted Type. Defaults to 'CONTROL'.
234
+ #
235
+ def to_roi(struct, options={})
236
+ raise ArgumentError, "Invalid argument 'struct'. Expected StructureSet, got #{struct.class}." unless struct.is_a?(StructureSet)
237
+ # Set values:
238
+ algorithm = options[:algorithm]
239
+ name = options[:name] || 'BinVolume'
240
+ number = options[:number]
241
+ interpreter = options[:interpreter]
242
+ type = options[:type]
243
+ # Create the ROI:
244
+ roi = struct.create_roi(@series.frame, :algorithm => algorithm, :name => name, :number => number, :interpreter => interpreter, :type => type)
245
+ # Create Slices:
246
+ @bin_images.each do |bin_image|
247
+ bin_image.to_slice(roi)
248
+ end
249
+ return roi
250
+ end
251
+
252
+
253
+ private
254
+
255
+
256
+ # Returns the attributes of this instance in an array (for comparison purposes).
257
+ #
258
+ def state
259
+ [@bin_images, @dice, @narr, @sensitivity, @specificity]
260
+ end
261
+
262
+ end
263
+ end