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,184 @@
1
+ module RTKIT
2
+
3
+ # Contains DICOM data and methods related to an Image Slice, in which a set of contours are defined.
4
+ #
5
+ # === Relations
6
+ #
7
+ # * A Slice is characterized by a SOP Instance UID, which relates it to an Image.
8
+ # * A ROI has many Slices, as derived from the Structure Set.
9
+ # * A Slice has many Contours.
10
+ #
11
+ class Slice
12
+
13
+ # An array containing the Contours defined for this Slice.
14
+ attr_reader :contours
15
+ # The Slice's Image reference.
16
+ attr_reader :image
17
+ # The ROI that the Slice belongs to.
18
+ attr_reader :roi
19
+ # The Referenced SOP Instance UID.
20
+ attr_reader :uid
21
+
22
+ # Creates a new Slice instance from an array of contour items belonging to a single slice of a particular ROI.
23
+ # This method also creates and connects any child structures as indicated in the items (e.g. Contours).
24
+ # Returns the Slice.
25
+ #
26
+ # === Parameters
27
+ #
28
+ # * <tt>sop_uid</tt> -- The SOP Instance UID reference for this slice.
29
+ # * <tt>contour_item</tt> -- An array of contour items from the Contour Sequence in ROI Contour Sequence, belonging to the same slice.
30
+ # * <tt>roi</tt> -- The ROI instance that this Slice belongs to.
31
+ #
32
+ def self.create_from_items(sop_uid, contour_items, roi)
33
+ raise ArgumentError, "Invalid argument 'sop_uid'. Expected String, got #{sop_uid.class}." unless sop_uid.is_a?(String)
34
+ raise ArgumentError, "Invalid argument 'contour_items'. Expected Array, got #{contour_items.class}." unless contour_items.is_a?(Array)
35
+ raise ArgumentError, "Invalid argument 'roi'. Expected ROI, got #{roi.class}." unless roi.is_a?(ROI)
36
+ # Create the Slice instance:
37
+ slice = self.new(sop_uid, roi)
38
+ # Create the Contours belonging to the ROI in this Slice:
39
+ contour_items.each do |contour_item|
40
+ Contour.create_from_item(contour_item, slice)
41
+ end
42
+ return slice
43
+ end
44
+
45
+ # Creates a new Slice instance.
46
+ #
47
+ # === Parameters
48
+ #
49
+ # * <tt>sop_uid</tt> -- The SOP Instance UID reference for this slice.
50
+ # * <tt>contour_item</tt> -- An array of contour items from the Contour Sequence in ROI Contour Sequence, belonging to the same slice.
51
+ # * <tt>roi</tt> -- The ROI instance that this Slice belongs to.
52
+ #
53
+ def initialize(sop_uid, roi)
54
+ raise ArgumentError, "Invalid argument 'sop_uid'. Expected String, got #{sop_uid.class}." unless sop_uid.is_a?(String)
55
+ raise ArgumentError, "Invalid argument 'roi'. Expected ROI, got #{roi.class}." unless roi.is_a?(ROI)
56
+ # Key attributes:
57
+ @contours = Array.new
58
+ @uid = sop_uid
59
+ @roi = roi
60
+ # Set up the Image reference:
61
+ @image = roi.frame.image(@uid)
62
+ # Register ourselves with the ROI:
63
+ @roi.add_slice(self)
64
+ end
65
+
66
+ # Returns true if the argument is an instance with attributes equal to self.
67
+ #
68
+ def ==(other)
69
+ if other.respond_to?(:to_slice)
70
+ other.send(:state) == state
71
+ end
72
+ end
73
+
74
+ alias_method :eql?, :==
75
+
76
+ # Adds a Contour instance to this Slice.
77
+ #
78
+ def add_contour(contour)
79
+ raise ArgumentError, "Invalid argument 'contour'. Expected Contour, got #{contour.class}." unless contour.is_a?(Contour)
80
+ @contours << contour unless @contours.include?(contour)
81
+ end
82
+
83
+ # Calculates the area defined by the contours of this slice.
84
+ # Returns a float value, in units of millimeters squared.
85
+ #
86
+ def area
87
+ bin_image.area
88
+ end
89
+
90
+ # Attaches a Slice to an Image instance belonging to the specified ImageSeries,
91
+ # by setting the Image reference of the Slice to an Image instance which matches
92
+ # the coordinates of the Slice's Contour(s).
93
+ # Raises an exception if a suitable match is not found for the Slice.
94
+ #
95
+ # === Notes
96
+ #
97
+ # This method can be useful when you have multiple segmentations based on the same image series
98
+ # from multiple raters (perhaps as part of a comparison study), and the rater's software has modified
99
+ # the UIDs of the original image series, so that the references of the returned Structure Set does
100
+ # not match your original image series. This method uses coordinate information to calculate plane
101
+ # equations, which allows it to identify the corresponding image slice even in the case of
102
+ # slice geometry being non-perpendicular with respect to the patient geometry (direction cosine values != [0,1]).
103
+ #
104
+ def attach_to(series)
105
+ raise ArgumentError, "Invalid argument 'series'. Expected ImageSeries, got #{series.class}." unless series.is_a?(Series)
106
+ # Do not bother to attempt this change if we have an image reference and this image instance already belongs to the series:
107
+ if @image && !series.image(@image.uid) or !@image
108
+ # Query the ImageSeries for an Image instance that matches the Plane of this Slice:
109
+ matched_image = series.match_image(plane)
110
+ if matched_image
111
+ @image = matched_image
112
+ @uid = matched_image.uid
113
+ else
114
+ raise "No matching Image was found for this Slice."
115
+ end
116
+ end
117
+ end
118
+
119
+ # Creates a binary segmented image, from the contours defined for this slice, applied to the referenced Image instance.
120
+ # Returns an BinImage instance, containing a 2d NArray with dimensions: columns*rows
121
+ #
122
+ # === Parameters
123
+ #
124
+ # * <tt>source_image</tt> -- The image on which the binary volume will be applied (defaults to the referenced image, but may be e.g. a dose 'image').
125
+ #
126
+ def bin_image(source_image=@image)
127
+ raise "Referenced ROI Slice Image is missing from the dataset. Unable to construct image." unless @image
128
+ bin_img = BinImage.new(NArray.byte(source_image.columns, source_image.rows), source_image)
129
+ # Delineate and fill for each contour, then create the final image:
130
+ @contours.each_with_index do |contour, i|
131
+ x, y, z = contour.coords
132
+ bin_img.add(source_image.binary_image(x, y, z))
133
+ end
134
+ return bin_img
135
+ end
136
+
137
+ # Generates a Fixnum hash value for this instance.
138
+ #
139
+ def hash
140
+ state.hash
141
+ end
142
+
143
+ # Returns the Plane corresponding to this Slice.
144
+ # The plane is calculated from coordinates belonging to this instance,
145
+ # and an error is raised if not enough Coordinates are present (at least 3 required).
146
+ #
147
+ def plane
148
+ # Such a change is only possible if the Slice instance has a Contour with at least three Coordinates:
149
+ raise "This Slice does not contain a Contour. Plane determination is not possible." if @contours.length == 0
150
+ raise "This Slice does not contain a Contour with at least 3 Coordinates. Plane determination is not possible." if @contours.first.coordinates.length < 3
151
+ # Get three coordinates from our Contour:
152
+ contour = @contours.first
153
+ num_coords = contour.coordinates.length
154
+ c1 = contour.coordinates.first
155
+ c2 = contour.coordinates[num_coords / 3]
156
+ c3 = contour.coordinates[2 * num_coords / 3]
157
+ return Plane.calculate(c1, c2, c3)
158
+ end
159
+
160
+ # Returns the position of this slice, which in effect
161
+ # is the pos_slice attribute of the referenced image.
162
+ #
163
+ def pos
164
+ return @image ? @image.pos_slice : nil
165
+ end
166
+
167
+ # Returns self.
168
+ #
169
+ def to_slice
170
+ self
171
+ end
172
+
173
+
174
+ private
175
+
176
+
177
+ # Returns the attributes of this instance in an array (for comparison purposes).
178
+ #
179
+ def state
180
+ [@contours, @image, @uid]
181
+ end
182
+
183
+ end
184
+ end
@@ -0,0 +1,305 @@
1
+ module RTKIT
2
+
3
+ # The Staple class is used for simultaneously evaluating the performance of multiple volume segmentations
4
+ # (typically derived from a RT Structure Set) as well as establishing the hidden true segmentation based
5
+ # on probabilistic analysis of the supplied rater decisions. The determined true segmentation can easily
6
+ # be exported to a RT Structure Set for external use.
7
+ #
8
+ # === THEORY
9
+ #
10
+ # Complete data:
11
+ # (D, T)
12
+ # Probability mass function of the complete data:
13
+ # f(D,T | p,q)
14
+ # Task:
15
+ # Which performance level parameters (p,q) will maximize the complete data log likelihood function:
16
+ # (p',q') = arg max_pq ln f(D,T | p,q)
17
+ #
18
+ # Indices: D[i,j]
19
+ # Voxel nr: i
20
+ # Segmentation nr: j
21
+ # Iteration nr: k
22
+ #
23
+ # The Expectation-Maximization algorithm approaches the problem of maximizing the incomplete data log likelihood
24
+ # equation by proceeding iteratively with estimation and maximization of the complete data log likelihood function.
25
+ #
26
+ class Staple
27
+
28
+ # An NArray containing all rater decisions (dimensions n*r).
29
+ attr_reader :decisions
30
+ # The maximum number of iterations to use in the STAPLE algorithm.
31
+ attr_accessor :max_iterations
32
+ # Number of voxels in the volume to be evaluated.
33
+ attr_reader :n
34
+ # Sensitivity float vector (length r). Each index contains a score from 0 (worst) to 1 (all true voxels segmented by the rater).
35
+ attr_reader :p
36
+ # An NArray containing the results of the Staple analysis (dimensions 2*r).
37
+ attr_reader :phi
38
+ # Specificity float vector (length r). Each index contains a score from 0 (worst) to 1 (none of the true remaining voxels are segmented by the rater).
39
+ attr_reader :q
40
+ # Number of raters to be evaluated.
41
+ attr_reader :r
42
+ # An NArray containing the determined true segmentation (dimensions equal to that of the input volumes).
43
+ attr_reader :true_segmentation
44
+ # The decision vectors used (derived from the supplied volumes).
45
+ attr_reader :vectors
46
+ # A float vector containing the weights assigned to each voxel (when rounded becomes the true segmentation) (length n).
47
+ attr_reader :weights
48
+
49
+ # Creates a Staple instance for the provided segmented volumes.
50
+ #
51
+ # === Parameters
52
+ #
53
+ # * <tt>bin_matcher</tt> -- An BinMatcher instance containing at least two volumes.
54
+ # * <tt>options</tt> -- A hash of parameters.
55
+ #
56
+ # === Options
57
+ #
58
+ # * <tt>:max_iterations</tt> -- Integer. The maximum number of iterations to use in the STAPLE algorithm. Defaults to 25.
59
+ #
60
+ def initialize(bin_matcher, options={})
61
+ raise ArgumentError, "Invalid argument 'bin_matcher'. Expected BinMatcher, got #{bin_matcher.class}." unless bin_matcher.is_a?(BinMatcher)
62
+ raise ArgumentError, "Invalid argument 'bin_matcher'. Expected BinMatcher with at least 2 volumes, got #{bin_matcher.volumes.length}." unless bin_matcher.volumes.length > 1
63
+ # Verify that the volumes have equal dimensions (columns and rows):
64
+ volumes = bin_matcher.narrays(sort=false)
65
+ raise ArgumentError, "Invalid argument 'bin_matcher'. Expected BinMatcher with volumes having the same number of columns, got #{volumes.collect{|v| v.shape[1]}.uniq}." unless volumes.collect{|v| v.shape[1]}.uniq.length == 1
66
+ raise ArgumentError, "Invalid argument 'bin_matcher'. Expected BinMatcher with volumes having the same number of rows, got #{volumes.collect{|v| v.shape[2]}.uniq}." unless volumes.collect{|v| v.shape[2]}.uniq.length == 1
67
+ # Make sure the volumes of the BinMatcher instance are comparable:
68
+ bin_matcher.fill_blanks
69
+ bin_matcher.sort_volumes
70
+ @volumes = bin_matcher.narrays(sort=false)
71
+ @original_volumes = @volumes.dup
72
+ # Verify that the volumes have the same number of frames:
73
+ raise ArgumentError, "Invalid argument 'bin_matcher'. Expected BinMatcher with volumes having the same number of frames, got #{@volumes.collect{|v| v.shape[0]}.uniq}." unless @volumes.collect{|v| v.shape[0]}.uniq.length == 1
74
+ @bm = bin_matcher
75
+ # Options:
76
+ @max_iterations = options[:max_iterations] || 25
77
+ end
78
+
79
+ # Returns true if the argument is an instance with attributes equal to self.
80
+ #
81
+ def ==(other)
82
+ if other.respond_to?(:to_staple)
83
+ other.send(:state) == state
84
+ end
85
+ end
86
+
87
+ alias_method :eql?, :==
88
+
89
+ # Generates a Fixnum hash value for this instance.
90
+ #
91
+ def hash
92
+ state.hash
93
+ end
94
+
95
+ # Along each dimension of the input volume, removes any index (slice, column or row) which is empty in all volumes.
96
+ # The result is a reduced volume used for the analysis, yielding scores with better contrast on specificity.
97
+ # This implementation aims to be independent of the number of dimensions in the input segmentation.
98
+ #
99
+ def remove_empty_indices
100
+ # It only makes sense to run this volume reduction if the number of dimensions are 2 or more:
101
+ if @original_volumes.first.dim > 1
102
+ # To be able to reconstruct the volume later on, we need to keep track of the original indices
103
+ # of the indices that remain in the new, reduced volume:
104
+ @original_indices = Array.new(@original_volumes.first.dim)
105
+ # For a volume the typical meaning of the dimensions will be: slice, column, row
106
+ @original_volumes.first.shape.each_with_index do |size, dim_index|
107
+ extract = Array.new(@original_volumes.first.dim, true)
108
+ segmented_indices = Array.new
109
+ size.times do |i|
110
+ extract[dim_index] = i
111
+ segmented = false
112
+ @volumes.each do |volume|
113
+ segmented = true if volume[*extract].max > 0
114
+ end
115
+ segmented_indices << i if segmented
116
+ end
117
+ @original_indices[dim_index] = segmented_indices
118
+ extract[dim_index] = segmented_indices
119
+ # Iterate each volume and pull out segmented indices:
120
+ if segmented_indices.length < size
121
+ @volumes.collect!{|volume| volume = volume[*extract]}
122
+ end
123
+ end
124
+ end
125
+ end
126
+
127
+ # Applies the STAPLE algorithm to the dataset to determine the true hidden segmentation
128
+ # as well as scoring the various segmentations.
129
+ #
130
+ def solve
131
+ set_parameters
132
+ # Vectors holding the values used for calculating the weights:
133
+ a = NArray.float(@n)
134
+ b = NArray.float(@n)
135
+ # Set an initial estimate for the probabilities of true segmentation:
136
+ @n.times do |i|
137
+ @weights_current[i] = @decisions[i, true].mean
138
+ end
139
+ # Proceed iteratively until we have converged to a local optimum:
140
+ k = 0
141
+ while k < max_iterations do
142
+ # Copy weights:
143
+ @weights_previous = @weights_current.dup
144
+ # E-step: Estimation of the conditional expectation of the complete data log likelihood function.
145
+ # Deriving the estimator for the unobserved true segmentation (T).
146
+ @n.times do |i|
147
+ voxel_decisions = @decisions[i, true]
148
+ # Find the rater-indices for this voxel where the raters' decisions equals 1 and 0:
149
+ positive_indices, negative_indices = (voxel_decisions.eq 1).where2
150
+ # Determine ai:
151
+ # Multiply by corresponding sensitivity (or 1 - sensitivity):
152
+ a_decision1_factor = (positive_indices.length == 0 ? 1 : @p[positive_indices].prod)
153
+ a_decision0_factor = (negative_indices.length == 0 ? 1 : (1 - @p[negative_indices]).prod)
154
+ a[i] = @weights_previous[i] * a_decision1_factor * a_decision0_factor
155
+ # Determine bi:
156
+ # Multiply by corresponding specificity (or 1 - specificity):
157
+ b_decision0_factor = (negative_indices.length == 0 ? 1 : @q[negative_indices].prod)
158
+ b_decision1_factor = (positive_indices.length == 0 ? 1 : (1 - @q[positive_indices]).prod)
159
+ b[i] = @weights_previous[i] * b_decision0_factor * b_decision1_factor
160
+ # Determine Wi: (take care not to divide by zero)
161
+ if a[i] > 0 or b[i] > 0
162
+ @weights_current[i] = a[i] / (a[i] + b[i])
163
+ else
164
+ @weights_current[i] = 0
165
+ end
166
+ end
167
+ # M-step: Estimation of the performance parameters by maximization.
168
+ # Finding the values of the expert performance level parameters that maximize the conditional expectation
169
+ # of the complete data log likelihood function (phi - p,q).
170
+ @r.times do |j|
171
+ voxel_decisions = @decisions[true, j]
172
+ # Find the voxel-indices for this rater where the rater's decisions equals 1 and 0:
173
+ positive_indices, negative_indices = (voxel_decisions.eq 1).where2
174
+ # Determine sensitivity:
175
+ # Sum the weights for the indices where the rater's decision equals 1:
176
+ sum_positive = (positive_indices.length == 0 ? 0 : @weights_current[positive_indices].sum)
177
+ @p[j] = sum_positive / @weights_current.sum
178
+ # Determine specificity:
179
+ # Sum the weights for the indices where the rater's decision equals 0:
180
+ sum_negative = (negative_indices.length == 0 ? 0 : (1 - @weights_current[negative_indices]).sum)
181
+ @q[j] = sum_negative / (1 - @weights_current).sum
182
+ end
183
+ # Bump our iteration index:
184
+ k += 1
185
+ # Abort if we have reached the local optimum: (there is no change in the sum of weights)
186
+ if @weights_current.sum - @weights_previous.sum == 0
187
+ #puts "Iteration aborted as optimum solution was found!" if @verbose
188
+ #logger.info("Iteration aborted as optimum solution was found!")
189
+ break
190
+ end
191
+ end
192
+ # Set the true segmentation:
193
+ @true_segmentation_vector = @weights_current.round
194
+ # Set the weights attribute:
195
+ @weights = @weights_current
196
+ # As this vector doesn't make much sense to the user, it must be converted to a volume. If volume reduction has
197
+ # previously been performed, this must be taken into account when transforming it to a volume:
198
+ construct_segmentation_volume
199
+ # Construct a BinVolume instance for the true segmentation and add it as a master volume to the BinMatcher instance.
200
+ update_bin_matcher
201
+ # Set the phi variable:
202
+ @phi[0, true] = @p
203
+ @phi[1, true] = @q
204
+ end
205
+
206
+ # Returns self.
207
+ #
208
+ def to_staple
209
+ self
210
+ end
211
+
212
+
213
+ private
214
+
215
+
216
+ # Reshapes the true segmentation vector to a volume which is comparable with the input volumes for the
217
+ # Staple instance. If volume reduction has been peformed, this must be taken into account.
218
+ #
219
+ def construct_segmentation_volume
220
+ if @volumes.first.shape == @original_volumes.first.shape
221
+ # Just reshape the vector (and ensure that it remains byte type):
222
+ @true_segmentation = @true_segmentation_vector.reshape(*@original_volumes.first.shape).to_type(1)
223
+ else
224
+ # Need to take into account exactly which indices (slices, columns, rows) have been removed.
225
+ # To achieve a correct reconstruction, we will use the information on the original volume indices of our
226
+ # current volume, and apply it for each dimension.
227
+ @true_segmentation = NArray.byte(*@original_volumes.first.shape)
228
+ true_segmentation_in_reduced_volume = @true_segmentation_vector.reshape(*@volumes.first.shape)
229
+ @true_segmentation[*@original_indices] = true_segmentation_in_reduced_volume
230
+ end
231
+ end
232
+
233
+ # Sets the instance variables used by the STAPLE algorithm.
234
+ #
235
+ def set_parameters
236
+ # Convert the volumes to vectors:
237
+ @vectors = Array.new
238
+ @volumes.each {|volume| @vectors << volume.flatten}
239
+ verify_equal_vector_lengths
240
+ # Number of voxels:
241
+ @n = @vectors.first.length
242
+ # Number of raters:
243
+ @r = @vectors.length
244
+ # Decisions array:
245
+ @decisions = NArray.int(@n, @r)
246
+ # Sensitivity vector: (Def: true positive fraction, or relative frequency of Dij = 1 when Ti = 1)
247
+ # (If a rater includes all the voxels that are included in the true segmentation, his score is 1.0 on this parameter)
248
+ @p = NArray.float(@r)
249
+ # Specificity vector: (Def: true negative fraction, or relative frequency of Dij = 0 when Ti = 0)
250
+ # (If a rater has avoided to specify any voxels that are not specified in the true segmentation, his score is 1.0 on this parameter)
251
+ @q = NArray.float(@r)
252
+ # Set initial parameter values: (p0, q0) - when combined, called: phi0
253
+ @p.fill!(0.99999)
254
+ @q.fill!(0.99999)
255
+ # Combined scoring parameter:
256
+ @phi = NArray.float(2, @r)
257
+ # Fill the decisions matrix:
258
+ @vectors.each_with_index do |decision, j|
259
+ @decisions[true, j] = decision
260
+ end
261
+ # Indicator vector of the true (hidden) segmentation:
262
+ @true_segmentation = NArray.byte(@n)
263
+ # The estimate of the probability that the true segmentation at each voxel is Ti = 1: f(Ti=1)
264
+ @weights_previous = NArray.float(@n)
265
+ # Using the notation commom for EM algorithms and refering to this as the weight variable:
266
+ @weights_current = NArray.float(@n)
267
+ end
268
+
269
+ # Returns the attributes of this instance in an array (for comparison purposes).
270
+ #
271
+ def state
272
+ [@volumes.collect{|narr| narr.to_a}, @max_iterations]
273
+ end
274
+
275
+ # Updates the BinMatcher instance with information following the completion of the Staple analysis.
276
+ # * Creates a BinVolume instance for the true segmentation and inserts it as a master volume.
277
+ # * Updates the various volumes of the BinMatcher instance with their determined sensitivity and specificity scores.
278
+ #
279
+ def update_bin_matcher
280
+ # Create an empty BinVolume with no ROI reference:
281
+ staple = BinVolume.new(@bm.volumes.first.series)
282
+ # Add BinImages to the staple volume:
283
+ @true_segmentation.shape[0].times do |i|
284
+ image_ref = @bm.volumes.first.bin_images[i].image
285
+ staple.add(BinImage.new(@true_segmentation[i, true, true], image_ref))
286
+ end
287
+ # Set the staple volume as master volume:
288
+ @bm.master = staple
289
+ # Apply sensitivity & specificity score to the various volumes of the BinMatcher instance:
290
+ @bm.volumes.each_with_index do |bin_vol, i|
291
+ bin_vol.sensitivity = @p[i]
292
+ bin_vol.specificity = @q[i]
293
+ end
294
+ end
295
+
296
+ # The number of voxels must be the same for all segmentation vectors going into the STAPLE analysis.
297
+ # If it is not, an error is raised.
298
+ #
299
+ def verify_equal_vector_lengths
300
+ vector_lengths = @vectors.collect{|vector| vector.length}
301
+ raise IndexError, "Unexpected behaviour: The vectors going into the STAPLE analysis have different lengths." unless vector_lengths.uniq.length == 1
302
+ end
303
+
304
+ end
305
+ end