rtkit 0.7

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -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