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.
- data/CHANGELOG.rdoc +10 -0
- data/COPYING +674 -0
- data/README.rdoc +107 -0
- data/lib/rtkit.rb +68 -0
- data/lib/rtkit/beam.rb +346 -0
- data/lib/rtkit/bin_image.rb +578 -0
- data/lib/rtkit/bin_matcher.rb +241 -0
- data/lib/rtkit/bin_volume.rb +263 -0
- data/lib/rtkit/collimator.rb +157 -0
- data/lib/rtkit/collimator_setup.rb +143 -0
- data/lib/rtkit/constants.rb +215 -0
- data/lib/rtkit/contour.rb +213 -0
- data/lib/rtkit/control_point.rb +371 -0
- data/lib/rtkit/coordinate.rb +83 -0
- data/lib/rtkit/data_set.rb +264 -0
- data/lib/rtkit/dose.rb +70 -0
- data/lib/rtkit/dose_distribution.rb +206 -0
- data/lib/rtkit/dose_volume.rb +280 -0
- data/lib/rtkit/frame.rb +164 -0
- data/lib/rtkit/image.rb +372 -0
- data/lib/rtkit/image_series.rb +290 -0
- data/lib/rtkit/logging.rb +158 -0
- data/lib/rtkit/methods.rb +105 -0
- data/lib/rtkit/mixins/image_parent.rb +40 -0
- data/lib/rtkit/patient.rb +229 -0
- data/lib/rtkit/pixel_data.rb +237 -0
- data/lib/rtkit/plan.rb +259 -0
- data/lib/rtkit/plane.rb +165 -0
- data/lib/rtkit/roi.rb +388 -0
- data/lib/rtkit/rt_dose.rb +237 -0
- data/lib/rtkit/rt_image.rb +179 -0
- data/lib/rtkit/ruby_extensions.rb +165 -0
- data/lib/rtkit/selection.rb +189 -0
- data/lib/rtkit/series.rb +77 -0
- data/lib/rtkit/setup.rb +198 -0
- data/lib/rtkit/slice.rb +184 -0
- data/lib/rtkit/staple.rb +305 -0
- data/lib/rtkit/structure_set.rb +442 -0
- data/lib/rtkit/study.rb +214 -0
- data/lib/rtkit/variables.rb +23 -0
- data/lib/rtkit/version.rb +6 -0
- metadata +159 -0
data/lib/rtkit/slice.rb
ADDED
@@ -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
|
data/lib/rtkit/staple.rb
ADDED
@@ -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
|