rtkit 0.7
Sign up to get free protection for your applications and to get access to all the features.
- 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
@@ -0,0 +1,83 @@
|
|
1
|
+
module RTKIT
|
2
|
+
|
3
|
+
# Contains a X,Y,Z triplet, which along with other Coordinates, defines a Contour.
|
4
|
+
#
|
5
|
+
# === Relations
|
6
|
+
#
|
7
|
+
# * The Coordinate belongs to a Contour.
|
8
|
+
#
|
9
|
+
class Coordinate
|
10
|
+
|
11
|
+
# The Contour that the Coordinate belongs to.
|
12
|
+
attr_reader :contour
|
13
|
+
# The X location (in units of mm).
|
14
|
+
attr_reader :x
|
15
|
+
# The Y location (in units of mm).
|
16
|
+
attr_reader :y
|
17
|
+
# The Z location (in units of mm).
|
18
|
+
attr_reader :z
|
19
|
+
|
20
|
+
# Creates a new Coordinate instance.
|
21
|
+
#
|
22
|
+
# === Parameters
|
23
|
+
#
|
24
|
+
# * <tt>x</tt> -- Float. The location of the Contour point along the x-axis (in units of mm).
|
25
|
+
# * <tt>y</tt> -- Float. The location of the Contour point along the y-axis (in units of mm).
|
26
|
+
# * <tt>z</tt> -- Float. The location of the Contour point along the z-axis (in units of mm).
|
27
|
+
# * <tt>contour</tt> -- The Contour instance (if any) that this Coordinate belongs to.
|
28
|
+
#
|
29
|
+
def initialize(x, y, z, contour=nil)
|
30
|
+
raise ArgumentError, "Invalid argument 'x'. Expected Float, got #{x.class}." unless x.is_a?(Float)
|
31
|
+
raise ArgumentError, "Invalid argument 'y'. Expected Float, got #{y.class}." unless y.is_a?(Float)
|
32
|
+
raise ArgumentError, "Invalid argument 'z'. Expected Float, got #{z.class}." unless z.is_a?(Float)
|
33
|
+
raise ArgumentError, "Invalid argument 'contour'. Expected Contour (or nil), got #{contour.class}." if contour && !contour.is_a?(Contour)
|
34
|
+
@contour = contour
|
35
|
+
@x = x
|
36
|
+
@y = y
|
37
|
+
@z = z
|
38
|
+
# Register ourselves with the Contour:
|
39
|
+
@contour.add_coordinate(self) if contour
|
40
|
+
end
|
41
|
+
|
42
|
+
# Returns true if the argument is an instance with attributes equal to self.
|
43
|
+
#
|
44
|
+
def ==(other)
|
45
|
+
if other.respond_to?(:to_coordinate)
|
46
|
+
other.send(:state) == state
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
alias_method :eql?, :==
|
51
|
+
|
52
|
+
# Generates a Fixnum hash value for this instance.
|
53
|
+
#
|
54
|
+
def hash
|
55
|
+
state.hash
|
56
|
+
end
|
57
|
+
|
58
|
+
# Returns self.
|
59
|
+
#
|
60
|
+
def to_coordinate
|
61
|
+
self
|
62
|
+
end
|
63
|
+
|
64
|
+
|
65
|
+
# Returns a string where the x, y & z values are separated by a '\'.
|
66
|
+
#
|
67
|
+
def to_s
|
68
|
+
[@x, @y, @z].join("\\")
|
69
|
+
end
|
70
|
+
|
71
|
+
|
72
|
+
private
|
73
|
+
|
74
|
+
|
75
|
+
# Returns the attributes of this instance in an array (for comparison purposes).
|
76
|
+
#
|
77
|
+
def state
|
78
|
+
[@x, @y, @z]
|
79
|
+
end
|
80
|
+
|
81
|
+
end
|
82
|
+
|
83
|
+
end
|
@@ -0,0 +1,264 @@
|
|
1
|
+
# Copyright 2012 Christoffer Lervag
|
2
|
+
#
|
3
|
+
# This program is free software: you can redistribute it and/or modify
|
4
|
+
# it under the terms of the GNU General Public License as published by
|
5
|
+
# the Free Software Foundation, either version 3 of the License, or
|
6
|
+
# (at your option) any later version.
|
7
|
+
#
|
8
|
+
# This program is distributed in the hope that it will be useful,
|
9
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
10
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
11
|
+
# GNU General Public License for more details.
|
12
|
+
#
|
13
|
+
# You should have received a copy of the GNU General Public License
|
14
|
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
15
|
+
#
|
16
|
+
module RTKIT
|
17
|
+
|
18
|
+
# Handles the DICOM data found at a particular location, typically all files contained in a specific directory.
|
19
|
+
# A DataSet contains all DICOM objects read from the specified source,
|
20
|
+
# organized in a patient - study - series - image structure.
|
21
|
+
#
|
22
|
+
class DataSet
|
23
|
+
|
24
|
+
# An array of Frame instances loaded for this DataSet.
|
25
|
+
attr_reader :frames
|
26
|
+
# An array of Patient instances loaded for this DataSet.
|
27
|
+
attr_reader :patients
|
28
|
+
|
29
|
+
# Creates (and returns) a new DataSet instance from an array of DICOM objects.
|
30
|
+
#
|
31
|
+
# === Parameters
|
32
|
+
#
|
33
|
+
# * <tt>objects</tt> -- An array of DICOM::DObject instances which will be loaded into the DataSet.
|
34
|
+
#
|
35
|
+
def self.load(objects)
|
36
|
+
raise ArgumentError, "Invalid argument 'objects'. Expected Array, got #{objects.class}." unless objects.is_a?(Array)
|
37
|
+
raise ArgumentError, "Invalid argument 'objects'. Expected Array to contain only DObjects, got #{objects.collect{|dcm| dcm.class}.uniq}." if objects.collect{|dcm| dcm.class}.uniq != [DICOM::DObject]
|
38
|
+
# We will put the objects in arrays sorted by modality, to control
|
39
|
+
# the sequence in which they are loaded in our data structure:
|
40
|
+
images = Array.new
|
41
|
+
structs = Array.new
|
42
|
+
plans = Array.new
|
43
|
+
doses = Array.new
|
44
|
+
rtimages = Array.new
|
45
|
+
# Read and sort:
|
46
|
+
objects.each do |dcm|
|
47
|
+
# Find out which modality our DICOM file is and handle it accordingly:
|
48
|
+
modality = dcm.value("0008,0060")
|
49
|
+
case modality
|
50
|
+
when *IMAGE_SERIES
|
51
|
+
images << dcm
|
52
|
+
when 'RTSTRUCT'
|
53
|
+
structs << dcm
|
54
|
+
when 'RTPLAN'
|
55
|
+
plans << dcm
|
56
|
+
when 'RTDOSE'
|
57
|
+
doses << dcm
|
58
|
+
when 'RTIMAGE'
|
59
|
+
rtimages << dcm
|
60
|
+
else
|
61
|
+
#puts "Notice: Unsupported modality (ignored): #{modality}"
|
62
|
+
end
|
63
|
+
end
|
64
|
+
# Create the DataSet:
|
65
|
+
ds = DataSet.new
|
66
|
+
# Add the objects to our data structure in a specific sequence:
|
67
|
+
[images, structs, plans, doses, rtimages].each do |modality|
|
68
|
+
modality.each do |dcm|
|
69
|
+
ds.add(dcm)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
return ds
|
73
|
+
end
|
74
|
+
|
75
|
+
# Creates (and returns) a new DataSet instance from a specified path,
|
76
|
+
# by reading and loading the DICOM files found in this directory (including its sub-directories).
|
77
|
+
#
|
78
|
+
# === Parameters
|
79
|
+
#
|
80
|
+
# * <tt>path</tt> -- A path to the directory containing the DICOM files you want to load.
|
81
|
+
#
|
82
|
+
def self.read(path)
|
83
|
+
raise ArgumentError, "Invalid argument 'path'. Expected String, got #{path.class}." unless path.is_a?(String)
|
84
|
+
# Get the files:
|
85
|
+
files = RTKIT.files(path)
|
86
|
+
raise ArgumentError, "No files found in the specified folder: #{path}" unless files.length > 0
|
87
|
+
# Load DICOM objects:
|
88
|
+
objects = Array.new
|
89
|
+
failed = Array.new
|
90
|
+
files.each do |file|
|
91
|
+
dcm = DICOM::DObject.read(file)
|
92
|
+
if dcm.read?
|
93
|
+
objects << dcm
|
94
|
+
else
|
95
|
+
failed << file
|
96
|
+
#puts "Warning: A file was not successfully read as a DICOM object. (#{file})"
|
97
|
+
end
|
98
|
+
end
|
99
|
+
raise ArgumentError, "No DICOM files were successfully read from the specified folder: #{path}" unless objects.length > 0
|
100
|
+
# Create the DataSet:
|
101
|
+
return DataSet.load(objects)
|
102
|
+
end
|
103
|
+
|
104
|
+
# Creates a new DataSet instance.
|
105
|
+
#
|
106
|
+
def initialize
|
107
|
+
# Create instance variables:
|
108
|
+
@frames = Array.new
|
109
|
+
@patients = Array.new
|
110
|
+
@associated_frames = Hash.new
|
111
|
+
@associated_patients = Hash.new
|
112
|
+
end
|
113
|
+
|
114
|
+
# Returns true if the argument is an instance with attributes equal to self.
|
115
|
+
#
|
116
|
+
def ==(other)
|
117
|
+
if other.respond_to?(:to_data_set)
|
118
|
+
other.send(:state) == state
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
alias_method :eql?, :==
|
123
|
+
|
124
|
+
# Adds a DICOM object to the dataset, by adding it
|
125
|
+
# to an existing Patient, or creating a new Patient.
|
126
|
+
#
|
127
|
+
# === Restrictions
|
128
|
+
#
|
129
|
+
# To ensure a correct relationship between objects of different modality, please add
|
130
|
+
# DICOM objects in the specific order: images, structs, plans, doses, rtimages
|
131
|
+
# Alternatively, use the class method DataSet.load(objects), which handles this automatically.
|
132
|
+
#
|
133
|
+
def add(dcm)
|
134
|
+
id = dcm.value(PATIENTS_ID)
|
135
|
+
p = patient(id)
|
136
|
+
if p
|
137
|
+
p.add(dcm)
|
138
|
+
else
|
139
|
+
add_patient(Patient.load(dcm, self))
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
# Adds a Frame to this DataSet.
|
144
|
+
#
|
145
|
+
def add_frame(frame)
|
146
|
+
raise ArgumentError, "Invalid argument 'frame'. Expected Frame, got #{frame.class}." unless frame.is_a?(Frame)
|
147
|
+
# Do not add it again if the frame already belongs to this instance:
|
148
|
+
@frames << frame unless @associated_frames[frame.uid]
|
149
|
+
@associated_frames[frame.uid] = frame
|
150
|
+
end
|
151
|
+
|
152
|
+
# Adds a Patient to this DataSet.
|
153
|
+
#
|
154
|
+
def add_patient(patient)
|
155
|
+
raise ArgumentError, "Invalid argument 'patient'. Expected Patient, got #{patient.class}." unless patient.is_a?(Patient)
|
156
|
+
# Do not add it again if the patient already belongs to this instance:
|
157
|
+
@patients << patient unless @associated_patients[patient.id]
|
158
|
+
@associated_patients[patient.id] = patient
|
159
|
+
end
|
160
|
+
|
161
|
+
# Returns the Frame instance mathcing the specified Frame's UID (if an UID argument is used).
|
162
|
+
# If a specified UID doesn't match, nil is returned.
|
163
|
+
# If no argument is passed, the first Frame instance associated with the DataSet is returned.
|
164
|
+
#
|
165
|
+
# === Parameters
|
166
|
+
#
|
167
|
+
# * <tt>uid</tt> -- String. The Frame of Reference UID.
|
168
|
+
#
|
169
|
+
def frame(*args)
|
170
|
+
raise ArgumentError, "Expected one or none arguments, got #{args.length}." unless [0, 1].include?(args.length)
|
171
|
+
if args.length == 1
|
172
|
+
raise ArgumentError, "Expected String (or nil), got #{args.first.class}." unless [String, NilClass].include?(args.first.class)
|
173
|
+
return @associated_frames[args.first]
|
174
|
+
else
|
175
|
+
# No argument used, therefore we return the first Frame instance:
|
176
|
+
return @frames.first
|
177
|
+
end
|
178
|
+
end
|
179
|
+
|
180
|
+
# Generates a Fixnum hash value for this instance.
|
181
|
+
#
|
182
|
+
def hash
|
183
|
+
state.hash
|
184
|
+
end
|
185
|
+
|
186
|
+
# Returns the Patient instance mathcing the specified Patient's ID (if an ID argument is used).
|
187
|
+
# If a specified ID doesn't match, nil is returned.
|
188
|
+
# If no argument is passed, the first Patient instance associated with the DataSet is returned.
|
189
|
+
#
|
190
|
+
# === Parameters
|
191
|
+
#
|
192
|
+
# * <tt>id</tt> -- String. The value of the Patient's ID element.
|
193
|
+
#
|
194
|
+
def patient(*args)
|
195
|
+
raise ArgumentError, "Expected one or none arguments, got #{args.length}." unless [0, 1].include?(args.length)
|
196
|
+
if args.length == 1
|
197
|
+
raise ArgumentError, "Expected String (or nil), got #{args.first.class}." unless [String, NilClass].include?(args.first.class)
|
198
|
+
return @associated_patients[args.first]
|
199
|
+
else
|
200
|
+
# No argument used, therefore we return the first Patient instance:
|
201
|
+
return @patients.first
|
202
|
+
end
|
203
|
+
end
|
204
|
+
|
205
|
+
# Prints the nested structure of patient - study - series - images that have been loaded to the dataset instance.
|
206
|
+
#
|
207
|
+
def print
|
208
|
+
@patients.each do |p|
|
209
|
+
puts p.name
|
210
|
+
p.studies.each do |st|
|
211
|
+
puts " #{st.uid}"
|
212
|
+
st.series.each do |se|
|
213
|
+
puts " #{se.modality}"
|
214
|
+
if se.images
|
215
|
+
puts " (#{se.images.length} images)"
|
216
|
+
end
|
217
|
+
end
|
218
|
+
end
|
219
|
+
end
|
220
|
+
end
|
221
|
+
|
222
|
+
# Prints the nested structure of patient - study - modalities from
|
223
|
+
# a RT point of view, with image_series - ss - plan, etc.
|
224
|
+
#
|
225
|
+
def print_rt
|
226
|
+
@patients.each do |p|
|
227
|
+
puts p.name
|
228
|
+
p.studies.each do |st|
|
229
|
+
puts " #{st.uid}"
|
230
|
+
st.image_series.each do |is|
|
231
|
+
puts " #{is.modality} (#{is.images.length} images)"
|
232
|
+
is.structs.each do |struct|
|
233
|
+
puts " StructureSet"
|
234
|
+
struct.plans.each do |plan|
|
235
|
+
puts " RTPlan"
|
236
|
+
puts " RTDose" if plan.dose
|
237
|
+
puts " RTImage" if plan.rtimage
|
238
|
+
end
|
239
|
+
end
|
240
|
+
end
|
241
|
+
end
|
242
|
+
end
|
243
|
+
end
|
244
|
+
|
245
|
+
# Returns self.
|
246
|
+
#
|
247
|
+
def to_data_set
|
248
|
+
self
|
249
|
+
end
|
250
|
+
|
251
|
+
|
252
|
+
# Following methods are private:
|
253
|
+
private
|
254
|
+
|
255
|
+
|
256
|
+
# Returns the attributes of this instance in an array (for comparison purposes).
|
257
|
+
#
|
258
|
+
def state
|
259
|
+
[@frames, @patients]
|
260
|
+
end
|
261
|
+
|
262
|
+
end
|
263
|
+
|
264
|
+
end
|
data/lib/rtkit/dose.rb
ADDED
@@ -0,0 +1,70 @@
|
|
1
|
+
module RTKIT
|
2
|
+
|
3
|
+
# NB! The Dose class is as of yet just a concept, and not in actual use
|
4
|
+
# by RTKIT. It will probably be put to use in a future version.
|
5
|
+
#
|
6
|
+
# Contains data and methods related to a single Dose value.
|
7
|
+
# The Dose value may be an average, median, max/min, etc derived
|
8
|
+
# from a collection of Dose values as given in a DoseDistribution.
|
9
|
+
#
|
10
|
+
# === Relations
|
11
|
+
#
|
12
|
+
# * A Dose (value) belongs to the DoseDistribution from which it was created.
|
13
|
+
# * The Dose class can be considered a subclass of Float (although strictly
|
14
|
+
# speaking it is rather a Float delegated class.
|
15
|
+
#
|
16
|
+
require 'delegate'
|
17
|
+
class Dose < DelegateClass(Float)
|
18
|
+
|
19
|
+
# The DoseDistribution that the single Dose value is derived from.
|
20
|
+
attr_reader :distribution
|
21
|
+
# The Dose value.
|
22
|
+
attr_reader :value
|
23
|
+
|
24
|
+
# Creates a new Dose instance.
|
25
|
+
#
|
26
|
+
# === Parameters
|
27
|
+
#
|
28
|
+
# * <tt>value</tt> -- Float. A dose value.
|
29
|
+
# * <tt>distribution</tt> -- The DoseDistribution which this single Dose value belongs to.
|
30
|
+
#
|
31
|
+
def initialize(value, distribution)
|
32
|
+
raise ArgumentError, "Invalid argument 'distribution'. Expected DoseDistribution, got #{distribution.class}." unless distribution.is_a?(DoseDistribution)
|
33
|
+
super(value.to_f)
|
34
|
+
@value = value
|
35
|
+
@distribution = distribution
|
36
|
+
end
|
37
|
+
|
38
|
+
# Returns true if the argument is an instance with attributes equal to self.
|
39
|
+
#
|
40
|
+
def ==(other)
|
41
|
+
if other.respond_to?(:to_dose)
|
42
|
+
other.send(:state) == state
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
alias_method :eql?, :==
|
47
|
+
|
48
|
+
# Generates a Fixnum hash value for this instance.
|
49
|
+
#
|
50
|
+
def hash
|
51
|
+
state.hash
|
52
|
+
end
|
53
|
+
|
54
|
+
# Returns self.
|
55
|
+
#
|
56
|
+
def to_dose
|
57
|
+
self
|
58
|
+
end
|
59
|
+
|
60
|
+
|
61
|
+
private
|
62
|
+
|
63
|
+
# Returns the attributes of this instance in an array (for comparison purposes).
|
64
|
+
#
|
65
|
+
def state
|
66
|
+
[@value, @distribution]
|
67
|
+
end
|
68
|
+
|
69
|
+
end
|
70
|
+
end
|
@@ -0,0 +1,206 @@
|
|
1
|
+
module RTKIT
|
2
|
+
|
3
|
+
# Contains DICOM data and methods related to a DoseDistribution,
|
4
|
+
# a collection of dose points extracted from a dose volume.
|
5
|
+
#
|
6
|
+
# === Relations
|
7
|
+
#
|
8
|
+
# * A DoseDistribution belongs to the DoseVolume from which it was created.
|
9
|
+
# * A DoseDistribution contains various methods to return a Dose (point) instance.
|
10
|
+
#
|
11
|
+
class DoseDistribution
|
12
|
+
|
13
|
+
# The doses values belonging to this distribution (array of floats).
|
14
|
+
attr_reader :doses
|
15
|
+
# The DoseVolume that the DoseDistribution is derived from.
|
16
|
+
attr_reader :volume
|
17
|
+
|
18
|
+
# Creates a new DoseDistribution instance from a BinVolume.
|
19
|
+
# The BinVolume is typically defined from a ROI delineation against a DoseVolume.
|
20
|
+
# Returns the DoseDistribution instance.
|
21
|
+
#
|
22
|
+
# === Parameters
|
23
|
+
#
|
24
|
+
# * <tt>bin_volume</tt> -- A BinVolume, referencing a DoseVolume, from which to extract a DoseDistribution.
|
25
|
+
#
|
26
|
+
def self.create(bin_volume)
|
27
|
+
raise ArgumentError, "Invalid argument 'bin_volume'. Expected BinVolume, got #{bin_volume.class}." unless bin_volume.is_a?(BinVolume)
|
28
|
+
raise ArgumentError, "Invalid argument 'bin_volume'. It must reference a DoseVolume, got #{bin_volume.bin_images.first.image.series.class}." unless bin_volume.bin_images.first.image.series.is_a?(DoseVolume)
|
29
|
+
dose_volume = bin_volume.bin_images.first.image.series
|
30
|
+
# Extract a selection of pixel values from the dose images based on the provided binary volume:
|
31
|
+
dose_values = NArray.sfloat(0)
|
32
|
+
bin_volume.bin_images.each do |bin_image|
|
33
|
+
slice_pixel_values = bin_image.image.pixel_values(bin_image.selection)
|
34
|
+
slice_dose_values = slice_pixel_values.to_type(4) * bin_image.image.series.scaling
|
35
|
+
dose_values = NArray[*dose_values, *slice_dose_values]
|
36
|
+
end
|
37
|
+
# Create the DoseDistribution instance:
|
38
|
+
dd = self.new(dose_values, dose_volume)
|
39
|
+
return dd
|
40
|
+
end
|
41
|
+
|
42
|
+
# Creates a new DoseDistribution instance.
|
43
|
+
#
|
44
|
+
# === Parameters
|
45
|
+
#
|
46
|
+
# * <tt>doses</tt> -- An array/NArray of doses (floats).
|
47
|
+
# * <tt>volume</tt> -- The DoseVolume which this DoseDistribution belongs to.
|
48
|
+
#
|
49
|
+
def initialize(doses, volume)
|
50
|
+
#raise ArgumentError, "Invalid argument 'doses'. Expected Array, got #{doses.class}." unless doses.is_a?(Array)
|
51
|
+
raise ArgumentError, "Invalid argument 'volume'. Expected DoseVolume, got #{volume.class}." unless volume.is_a?(DoseVolume)
|
52
|
+
# Store doses as a sorted (float) NArray:
|
53
|
+
@doses = NArray.to_na(doses).sort.to_type(4)
|
54
|
+
# Set references:
|
55
|
+
@volume = volume
|
56
|
+
end
|
57
|
+
|
58
|
+
# Returns true if the argument is an instance with attributes equal to self.
|
59
|
+
#
|
60
|
+
def ==(other)
|
61
|
+
if other.respond_to?(:to_dose_distribution)
|
62
|
+
other.send(:state) == state
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
alias_method :eql?, :==
|
67
|
+
|
68
|
+
# Calculates the dose that at least the specified
|
69
|
+
# percentage of the volume receives.
|
70
|
+
# Returns a dose (Float) in units of Gy.
|
71
|
+
#
|
72
|
+
# === Parameters
|
73
|
+
#
|
74
|
+
# * <tt>percent</tt> -- Integer/Float. The percent of the volume which receives a dose higher than the returned dose.
|
75
|
+
#
|
76
|
+
# === Examples
|
77
|
+
#
|
78
|
+
# # Calculate the near minimum dose (e.g. up to 2 % of the volume receives a dose less than this):
|
79
|
+
# near_min = ptv_distribution.d(98)
|
80
|
+
# # Calculate the near maximum dose (e.g. at most 2 % of the volume receives a dose higher than this):
|
81
|
+
# near_max = ptv_distribution.d(2)
|
82
|
+
#
|
83
|
+
def d(percent)
|
84
|
+
raise RangeError, "Argument 'percent' must be in the range [0-100]." if percent.to_f < 0 or percent.to_f > 100
|
85
|
+
d_index = ((@doses.length - 1) * (1 - percent.to_f * 0.01)).round
|
86
|
+
return @doses[d_index]
|
87
|
+
end
|
88
|
+
|
89
|
+
# Generates a Fixnum hash value for this instance.
|
90
|
+
#
|
91
|
+
def hash
|
92
|
+
state.hash
|
93
|
+
end
|
94
|
+
|
95
|
+
# Calculates the homogeneity index of the dose distribution.
|
96
|
+
# A low (near zero) value corresponds to high homogeneity (e.q. 0.1).
|
97
|
+
# Returns the index value as a float.
|
98
|
+
#
|
99
|
+
# === Notes
|
100
|
+
#
|
101
|
+
# * The homogeneity index is defined as:
|
102
|
+
# HI = ( d(2) - d(98) ) / d(50)
|
103
|
+
# For more details, refer to ICRU Report No. 83, Chapter 3.7.1.
|
104
|
+
#
|
105
|
+
# === Examples
|
106
|
+
#
|
107
|
+
# # Calculate the homogeneity index of the dose distribution of a PTV ROI for a given plan:
|
108
|
+
# homogeneity_index = ptv_distribution.hindex
|
109
|
+
#
|
110
|
+
def hindex
|
111
|
+
return (d(2) - d(98)) / d(50).to_f
|
112
|
+
end
|
113
|
+
|
114
|
+
# Returns the number of dose values in the DoseDistribution.
|
115
|
+
#
|
116
|
+
def length
|
117
|
+
@doses.length
|
118
|
+
end
|
119
|
+
|
120
|
+
alias_method :size, :length
|
121
|
+
|
122
|
+
# Calculates the maximum dose.
|
123
|
+
#
|
124
|
+
def max
|
125
|
+
@doses.max
|
126
|
+
end
|
127
|
+
|
128
|
+
# Calculates the arithmethic mean (average) dose.
|
129
|
+
#
|
130
|
+
def mean
|
131
|
+
@doses.mean
|
132
|
+
end
|
133
|
+
|
134
|
+
# Calculates the median dose.
|
135
|
+
#
|
136
|
+
def median
|
137
|
+
@doses.median
|
138
|
+
end
|
139
|
+
|
140
|
+
# Calculates the minimum dose.
|
141
|
+
#
|
142
|
+
def min
|
143
|
+
@doses.min
|
144
|
+
end
|
145
|
+
|
146
|
+
# Calculates the root mean square deviation (the population standard deviation).
|
147
|
+
#
|
148
|
+
# === Notes
|
149
|
+
#
|
150
|
+
# * Uses N in the denominator for calculating the standard deviation of the sample.
|
151
|
+
#
|
152
|
+
def rmsdev
|
153
|
+
@doses.rmsdev
|
154
|
+
end
|
155
|
+
|
156
|
+
# Calculates the sample standard deviation of the dose distribution.
|
157
|
+
#
|
158
|
+
# === Notes
|
159
|
+
#
|
160
|
+
# * Uses Bessel's correction (N-1 in the denominator).
|
161
|
+
#
|
162
|
+
def stddev
|
163
|
+
@doses.stddev
|
164
|
+
end
|
165
|
+
|
166
|
+
# Returns self.
|
167
|
+
#
|
168
|
+
def to_dose_distribution
|
169
|
+
self
|
170
|
+
end
|
171
|
+
|
172
|
+
# Calculates the percentage of the volume that receives
|
173
|
+
# a dose higher than or equal to the specified dose.
|
174
|
+
# Returns a percentage (Float).
|
175
|
+
#
|
176
|
+
# === Parameters
|
177
|
+
#
|
178
|
+
# * <tt>dose</tt> -- Integer/Float. The dose threshold value to apply to the dose distribution.
|
179
|
+
#
|
180
|
+
# === Examples
|
181
|
+
#
|
182
|
+
# # Calculate the low dose spread (e.g. the percentage of the lung that receives a dose higher than 5 Gy):
|
183
|
+
# coverage_low = lung_distribution.v(5)
|
184
|
+
# # Calculate the high dose spread (e.g. the percentage of the lung that receives a dose higher than 20 Gy):
|
185
|
+
# coverage_high = lung_distribution.v(20)
|
186
|
+
#
|
187
|
+
def v(dose)
|
188
|
+
raise RangeError, "Argument 'dose' cannot be negative." if dose.to_f < 0
|
189
|
+
# How many dose values are higher than the threshold?
|
190
|
+
num_above = (@doses.ge dose.to_f).where.length
|
191
|
+
# Divide by total number of elements and convert to percentage:
|
192
|
+
return num_above / @doses.length.to_f * 100
|
193
|
+
end
|
194
|
+
|
195
|
+
|
196
|
+
private
|
197
|
+
|
198
|
+
|
199
|
+
# Returns the attributes of this instance in an array (for comparison purposes).
|
200
|
+
#
|
201
|
+
def state
|
202
|
+
[@doses.to_a, @volume]
|
203
|
+
end
|
204
|
+
|
205
|
+
end
|
206
|
+
end
|