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,259 @@
1
+ module RTKIT
2
+
3
+ # The Plan class contains methods that are specific for this modality (RTPLAN).
4
+ #
5
+ # === Inheritance
6
+ #
7
+ # * Plan inherits all methods and attributes from the Series class.
8
+ #
9
+ class Plan < Series
10
+
11
+ # An array of radiotherapy beams belonging to this Plan.
12
+ attr_reader :beams
13
+ # The DObject instance of this Plan.
14
+ attr_reader :dcm
15
+ # The patient position.
16
+ attr_reader :patient_position
17
+ # An array of RTDose instances associated with this Plan.
18
+ attr_reader :rt_doses
19
+ # An array of RTImage (series) instances associated with this Plan.
20
+ attr_reader :rt_images
21
+ # The referenced patient Setup instance.
22
+ attr_reader :setup
23
+ # The SOP Instance UID.
24
+ attr_reader :sop_uid
25
+ # The StructureSet that this Plan belongs to.
26
+ attr_reader :struct
27
+
28
+ # Creates a new Plan instance by loading the relevant information from the specified DICOM object.
29
+ # The SOP Instance UID string value is used to uniquely identify a Plan instance.
30
+ #
31
+ # === Parameters
32
+ #
33
+ # * <tt>dcm</tt> -- An instance of a DICOM object (DICOM::DObject) with modality 'RTPLAN'.
34
+ # * <tt>study</tt> -- The Study instance that this RTPlan belongs to.
35
+ #
36
+ def self.load(dcm, study)
37
+ raise ArgumentError, "Invalid argument 'dcm'. Expected DObject, got #{dcm.class}." unless dcm.is_a?(DICOM::DObject)
38
+ raise ArgumentError, "Invalid argument 'study'. Expected Study, got #{study.class}." unless study.is_a?(Study)
39
+ raise ArgumentError, "Invalid argument 'dcm'. Expected DObject with modality 'RTPLAN', got #{dcm.value(MODALITY)}." unless dcm.value(MODALITY) == 'RTPLAN'
40
+ # Required attributes:
41
+ sop_uid = dcm.value(SOP_UID)
42
+ # Optional attributes:
43
+ class_uid = dcm.value(SOP_CLASS)
44
+ date = dcm.value(SERIES_DATE)
45
+ time = dcm.value(SERIES_TIME)
46
+ description = dcm.value(SERIES_DESCR)
47
+ series_uid = dcm.value(SERIES_UID)
48
+ # Get the corresponding StructureSet:
49
+ struct = self.structure_set(dcm, study)
50
+ # Create the Plan instance:
51
+ plan = self.new(sop_uid, struct, :class_uid => class_uid, :date => date, :time => time, :description => description, :series_uid => series_uid)
52
+ plan.add(dcm)
53
+ return plan
54
+ end
55
+
56
+ # Identifies the StructureSet that the Plan object belongs to.
57
+ # If the referenced instances (StructureSet, ImageSeries & Frame) does not exist, they are created by this method.
58
+ #
59
+ def self.structure_set(dcm, study)
60
+ raise ArgumentError, "Invalid argument 'dcm'. Expected DObject, got #{dcm.class}." unless dcm.is_a?(DICOM::DObject)
61
+ raise ArgumentError, "Invalid argument 'study'. Expected Study, got #{study.class}." unless study.is_a?(Study)
62
+ raise ArgumentError, "Invalid argument 'dcm'. Expected DObject with modality 'RTPLAN', got #{dcm.value(MODALITY)}." unless dcm.value(MODALITY) == 'RTPLAN'
63
+ # Extract the Frame of Reference UID:
64
+ begin
65
+ frame_of_ref = dcm.value(FRAME_OF_REF)
66
+ rescue
67
+ frame_of_ref = nil
68
+ end
69
+ # Extract referenced Structure Set SOP Instance UID:
70
+ begin
71
+ ref_struct_uid = dcm[REF_STRUCT_SQ][0].value(REF_SOP_UID)
72
+ rescue
73
+ ref_struct_uid = nil
74
+ end
75
+ # Create the Frame if it doesn't exist:
76
+ f = study.patient.dataset.frame(frame_of_ref)
77
+ f = Frame.new(frame_of_ref, study.patient) unless f
78
+ # Create the StructureSet & ImageSeries if the StructureSet doesn't exist:
79
+ struct = study.fseries(ref_struct_uid)
80
+ unless struct
81
+ # Create ImageSeries (assuming modality CT):
82
+ is = ImageSeries.new(RTKIT.series_uid, 'CT', f, study)
83
+ study.add_series(is)
84
+ # Create StructureSet:
85
+ struct = StructureSet.new(ref_struct_uid, is)
86
+ study.add_series(struct)
87
+ end
88
+ return struct
89
+ end
90
+
91
+ # Creates a new Plan instance.
92
+ #
93
+ # === Parameters
94
+ #
95
+ # * <tt>sop_uid</tt> -- The SOP Instance UID string.
96
+ # * <tt>struct</tt> -- The StructureSet that this Plan belongs to.
97
+ # * <tt>options</tt> -- A hash of parameters.
98
+ #
99
+ # === Options
100
+ #
101
+ # * <tt>:date</tt> -- String. The Series Date (DICOM tag '0008,0021').
102
+ # * <tt>:time</tt> -- String. The Series Time (DICOM tag '0008,0031').
103
+ # * <tt>:description</tt> -- String. The Series Description (DICOM tag '0008,103E').
104
+ # * <tt>:series_uid</tt> -- String. The Series Instance UID (DICOM tag '0020,000E').
105
+ #
106
+ def initialize(sop_uid, struct, options={})
107
+ raise ArgumentError, "Invalid argument 'sop_uid'. Expected String, got #{sop_uid.class}." unless sop_uid.is_a?(String)
108
+ raise ArgumentError, "Invalid argument 'struct'. Expected StructureSet, got #{struct.class}." unless struct.is_a?(StructureSet)
109
+ # Pass attributes to Series initialization:
110
+ options[:class_uid] = '1.2.840.10008.5.1.4.1.1.481.5' # RT Plan Storage
111
+ # Get a randomized Series UID unless it has been defined in the options hash:
112
+ series_uid = options[:series_uid] || RTKIT.series_uid
113
+ super(series_uid, 'RTPLAN', struct.study, options)
114
+ @sop_uid = sop_uid
115
+ @struct = struct
116
+ # Default attributes:
117
+ @beams = Array.new
118
+ @rt_doses = Array.new
119
+ @rt_images = Array.new
120
+ @associated_rt_doses = Hash.new
121
+ # Register ourselves with the StructureSet:
122
+ @struct.add_plan(self)
123
+ end
124
+
125
+ # Returns true if the argument is an instance with attributes equal to self.
126
+ #
127
+ def ==(other)
128
+ if other.respond_to?(:to_plan)
129
+ other.send(:state) == state
130
+ end
131
+ end
132
+
133
+ alias_method :eql?, :==
134
+
135
+ # Registers a DICOM Object to the Plan, and processes it
136
+ # to create (and reference) the fields contained in the object.
137
+ #
138
+ def add(dcm)
139
+ raise ArgumentError, "Invalid argument 'dcm'. Expected DObject, got #{dcm.class}." unless dcm.is_a?(DICOM::DObject)
140
+ @dcm = dcm
141
+ #load_patient_setup
142
+ load_beams
143
+ end
144
+
145
+ # Adds a Beam to this Plan.
146
+ # Note: Intended for internal use in the library only.
147
+ #
148
+ def add_beam(beam)
149
+ raise ArgumentError, "Invalid argument 'beam'. Expected Beam, got #{beam.class}." unless beam.is_a?(Beam)
150
+ @beams << beam unless @beams.include?(beam)
151
+ end
152
+
153
+ # Adds a RTDose series to this Plan.
154
+ # Note: Intended for internal use in the library only.
155
+ #
156
+ def add_rt_dose(rt_dose)
157
+ raise ArgumentError, "Invalid argument 'rt_dose'. Expected RTDose, got #{rt_dose.class}." unless rt_dose.is_a?(RTDose)
158
+ @rt_doses << rt_dose unless @associated_rt_doses[rt_dose.uid]
159
+ @associated_rt_doses[rt_dose.uid] = rt_dose
160
+ end
161
+
162
+ # Adds a RTImage Series to this Plan.
163
+ # Note: Intended for internal use in the library only.
164
+ #
165
+ def add_rt_image(rt_image)
166
+ raise ArgumentError, "Invalid argument 'rt_image'. Expected RTImage, got #{rt_image.class}." unless rt_image.is_a?(RTImage)
167
+ @rt_images << rt_image unless @rt_images.include?(rt_image)
168
+ end
169
+
170
+ # Sets the Setup reference for this Plan.
171
+ # Note: Intended for internal use in the library only.
172
+ #
173
+ def add_setup(setup)
174
+ raise ArgumentError, "Invalid argument 'setup'. Expected Setup, got #{setup.class}." unless setup.is_a?(Setup)
175
+ @setup = setup
176
+ end
177
+
178
+ # Generates a Fixnum hash value for this instance.
179
+ #
180
+ def hash
181
+ state.hash
182
+ end
183
+
184
+ # Returns the RTDose instance mathcing the specified Series Instance UID (if an argument is used).
185
+ # If a specified UID doesn't match, nil is returned.
186
+ # If no argument is passed, the first RTDose instance associated with the Plan is returned.
187
+ #
188
+ # === Parameters
189
+ #
190
+ # * <tt>uid</tt> -- String. The value of the Series Instance UID element.
191
+ #
192
+ def rt_dose(*args)
193
+ raise ArgumentError, "Expected one or none arguments, got #{args.length}." unless [0, 1].include?(args.length)
194
+ if args.length == 1
195
+ raise ArgumentError, "Expected String (or nil), got #{args.first.class}." unless [String, NilClass].include?(args.first.class)
196
+ return @associated_rt_doses[args.first]
197
+ else
198
+ # No argument used, therefore we return the first RTDose instance:
199
+ return @rt_doses.first
200
+ end
201
+ end
202
+
203
+ # Returns self.
204
+ #
205
+ def to_plan
206
+ self
207
+ end
208
+
209
+
210
+ private
211
+
212
+
213
+ =begin
214
+ # Registers this Plan instance with the StructureSet(s) that it references.
215
+ #
216
+ def connect_to_struct
217
+ # Find out which Structure Set is referenced:
218
+ @dcm[REF_STRUCT_SQ].each do |struct_item|
219
+ ref_sop_uid = struct_item.value(REF_SOP_UID)
220
+ matched_struct = @study.associated_instance_uids[ref_sop_uid]
221
+ if matched_struct
222
+ # The referenced series exists in our dataset. Proceed with setting up the references:
223
+ matched_struct.add_plan(self)
224
+ @structs << matched_struct
225
+ @stuct = matched_struct unless @struct
226
+ end
227
+ end
228
+ end
229
+ =end
230
+
231
+ # Loads the Beam Items contained in the RTPlan and creates Beam instances.
232
+ #
233
+ def load_beams
234
+ # Load the patient position.
235
+ # NB! (FIXME) We assume that there is only one patient setup sequence item!
236
+ Setup.create_from_item(@dcm[PATIENT_SETUP_SQ][0], self)
237
+ # Load the information in a nested hash:
238
+ item_group = Hash.new
239
+ # NB! (FIXME) We assume there is only one fraction group!
240
+ @dcm[FRACTION_GROUP_SQ][0][REF_BEAM_SQ].each do |fg_item|
241
+ item_group[fg_item.value(REF_BEAM_NUMBER)] = {:meterset => fg_item.value(BEAM_METERSET).to_f}
242
+ end
243
+ @dcm[BEAM_SQ].each do |beam_item|
244
+ item_group[beam_item.value(BEAM_NUMBER)][:beam] = beam_item
245
+ end
246
+ # Create a Beam instance for each set of items:
247
+ item_group.each_value do |beam_items|
248
+ Beam.create_from_item(beam_items[:beam], beam_items[:meterset], self)
249
+ end
250
+ end
251
+
252
+ # Returns the attributes of this instance in an array (for comparison purposes).
253
+ #
254
+ def state
255
+ [@beams, @patient_position, @rt_doses, @rt_images, @setup, @sop_uid]
256
+ end
257
+
258
+ end
259
+ end
@@ -0,0 +1,165 @@
1
+ module RTKIT
2
+
3
+ # A Plane describes a flat, two-dimensional surface.
4
+ # A Plane can be defined in several ways, e.g. by:
5
+ # * A point and a normal vector.
6
+ # * A point and two vectors lying on it.
7
+ # * 3 non-colinear points.
8
+ #
9
+ # We will describe the plane in the form of a plane equation:
10
+ # ax + by +cz +d = 0
11
+ #
12
+ # === Notes
13
+ #
14
+ # * For more information on Planes, refer to: http://en.wikipedia.org/wiki/Plane_(geometry)
15
+ #
16
+ # === Relations
17
+ #
18
+ # Since an image slice is located in a specific plane, the Plane class may be used to relate instances of such classes.
19
+ #
20
+ class Plane
21
+
22
+ # The a parameter of the plane equation.
23
+ attr_reader :a
24
+ # The b parameter of the plane equation.
25
+ attr_reader :b
26
+ # The c parameter of the plane equation.
27
+ attr_reader :c
28
+ # The d parameter of the plane equation.
29
+ attr_reader :d
30
+
31
+ # Calculates a plane equation from the 3 specified coordinates.
32
+ # Returns a Plane instance.
33
+ #
34
+ def self.calculate(c1, c2, c3)
35
+ raise ArgumentError, "Invalid argument 'c1'. Expected Coordinate, got #{c1.class}" unless c1.is_a?(Coordinate)
36
+ raise ArgumentError, "Invalid argument 'c2'. Expected Coordinate, got #{c2.class}" unless c2.is_a?(Coordinate)
37
+ raise ArgumentError, "Invalid argument 'c3'. Expected Coordinate, got #{c3.class}" unless c3.is_a?(Coordinate)
38
+ x1, y1, z1 = c1.x.to_r, c1.y.to_r, c1.z.to_r
39
+ x2, y2, z2 = c2.x.to_r, c2.y.to_r, c2.z.to_r
40
+ x3, y3, z3 = c3.x.to_r, c3.y.to_r, c3.z.to_r
41
+ raise ArgumentError, "Got at least two Coordinates that are equal. Expected unique Coordinates." unless [[x1, y1, z1], [x2, y2, z2], [x3, y3, z3]].uniq.length == 3
42
+ det = Matrix.rows([[x1, y1, z1], [x2, y2, z2], [x3, y3, z3]]).determinant
43
+ if det == 0
44
+ # Haven't experienced this case yet. Just raise an error to avoid unexpected behaviour.
45
+ raise "The determinant was zero (which means the plane passes through the origin). Not able to calculate variables: a,b,c"
46
+ #puts "Determinant was zero. Plane passes through origin. Find direction cosines instead?"
47
+ else
48
+ det = det.to_f
49
+ # Find parameters a,b,c.
50
+ a_m = Matrix.rows([[1, y1, z1], [1, y2, z2], [1, y3, z3]])
51
+ b_m = Matrix.rows([[x1, 1, z1], [x2, 1, z2], [x3, 1, z3]])
52
+ c_m = Matrix.rows([[x1, y1, 1], [x2, y2, 1], [x3, y3, 1]])
53
+ d = Plane.d
54
+ a = -d / det * a_m.determinant
55
+ b = -d / det * b_m.determinant
56
+ c = -d / det * c_m.determinant
57
+ return self.new(a, b, c)
58
+ end
59
+ end
60
+
61
+ # The custom plane parameter d:
62
+ # This constant can be equal to any non-zero number.
63
+ # Returns the float value chosen in this implementation: 500.0
64
+ #
65
+ def self.d
66
+ 500.0
67
+ end
68
+
69
+ # Creates a new Plane instance.
70
+ #
71
+ # === Parameters
72
+ #
73
+ # * <tt>a</tt> -- Float. The a parameter of the plane equation.
74
+ # * <tt>b</tt> -- Float. The b parameter of the plane equation.
75
+ # * <tt>c</tt> -- Float. The c parameter of the plane equation.
76
+ #
77
+ def initialize(a, b, c)
78
+ raise ArgumentError, "Invalid argument 'a'. Expected Float, got #{a.class}." unless a.is_a?(Float)
79
+ raise ArgumentError, "Invalid argument 'b'. Expected Float, got #{b.class}." unless b.is_a?(Float)
80
+ raise ArgumentError, "Invalid argument 'c'. Expected Float, got #{c.class}." unless c.is_a?(Float)
81
+ @a = a
82
+ @b = b
83
+ @c = c
84
+ @d = Plane.d
85
+ end
86
+
87
+ # Returns true if the argument is an instance with attributes equal to self.
88
+ #
89
+ def ==(other)
90
+ if other.respond_to?(:to_plane)
91
+ other.send(:state) == state
92
+ end
93
+ end
94
+
95
+ alias_method :eql?, :==
96
+
97
+ # Generates a Fixnum hash value for this instance.
98
+ #
99
+ def hash
100
+ state.hash
101
+ end
102
+
103
+ # Compares the Plane instance with an array of planes, and returns the index of the Plane
104
+ # who's Plane equation is closest to the plane equation of self.
105
+ # Returns nil if no suitable match is found.
106
+ #
107
+ def match(planes)
108
+ raise ArgumentError, "Invalid argument 'planes'. Expected Array, got #{planes.class}." unless planes.is_a?(Array)
109
+ raise ArgumentError, "Invalid argument 'planes'. Expected Array containing only Planes, got #{planes.collect{|p| p.class}.uniq}." unless planes.collect{|p| p.class}.uniq == [Plane]
110
+ # I don't really have a feeling for what a reasonable threshold should be here. Setting it at 0.01.
111
+ # (So far, matched planes have been observed having a deviation in the order of 10e-5 to 10e-7,
112
+ # while some obviously different planes have had values in the range of 8-21)
113
+ deviation_threshold = 0.01
114
+ # Since the 'd' parameter is just a constant selected by us when determining plane equations,
115
+ # the comparison is carried out against the a, b & c parameters.
116
+ # Register deviation for each parameter for each plane:
117
+ a_deviations = NArray.float(planes.length)
118
+ b_deviations = NArray.float(planes.length)
119
+ c_deviations = NArray.float(planes.length)
120
+ planes.each_index do |i|
121
+ # Calculate absolute deviation for each of the three parameters:
122
+ a_deviations[i] = (planes[i].a - @a).abs
123
+ b_deviations[i] = (planes[i].b - @b).abs
124
+ c_deviations[i] = (planes[i].c - @c).abs
125
+ end
126
+ # Compare the deviations of each parameter with the average deviation for that parameter,
127
+ # taking care to adress the case where all deviations for a certain parameter may be 0:
128
+ a_relatives = a_deviations.mean == 0 ? a_deviations : a_deviations / a_deviations.mean
129
+ b_relatives = b_deviations.mean == 0 ? b_deviations : b_deviations / b_deviations.mean
130
+ c_relatives = c_deviations.mean == 0 ? c_deviations : c_deviations / c_deviations.mean
131
+ # Sum the relative deviations for each parameter, and find the index with the lowest summed relative deviation:
132
+ deviations = NArray.float(planes.length)
133
+ planes.each_index do |i|
134
+ deviations[i] = a_relatives[i] + b_relatives[i] + c_relatives[i]
135
+ end
136
+ index = (deviations.eq deviations.min).where[0]
137
+ deviation = a_deviations[index] + b_deviations[index] + c_deviations[index]
138
+ index = nil if deviation > deviation_threshold
139
+ return index
140
+ end
141
+
142
+ # Returns self.
143
+ #
144
+ def to_plane
145
+ self
146
+ end
147
+
148
+ # Converts the Plane instance to a readable string (containing the parameters a, b & c).
149
+ #
150
+ def to_s
151
+ return "a: #{@a.round(2)} b: #{@b.round(2)} c: #{@c.round(2)}"
152
+ end
153
+
154
+
155
+ private
156
+
157
+
158
+ # Returns the attributes of this instance in an array (for comparison purposes).
159
+ #
160
+ def state
161
+ [@a, @b, @c, @d]
162
+ end
163
+
164
+ end
165
+ end
@@ -0,0 +1,388 @@
1
+ module RTKIT
2
+
3
+ # Contains DICOM data and methods related to a Region of Interest, defined in a Structure Set.
4
+ #
5
+ # === Relations
6
+ #
7
+ # * An image series has many ROIs, defined through a Structure Set.
8
+ # * An image slice has only the ROIs which are contoured in that particular slice in the Structure Set.
9
+ # * A ROI has many Slices.
10
+ #
11
+ class ROI
12
+
13
+ # ROI Generation Algorithm.
14
+ attr_reader :algorithm
15
+ # ROI Display Color.
16
+ attr_reader :color
17
+ # The Frame which this ROI belongs to.
18
+ attr_reader :frame
19
+ # ROI Interpreter.
20
+ attr_reader :interpreter
21
+ # ROI Name.
22
+ attr_reader :name
23
+ # ROI Number (Integer).
24
+ attr_reader :number
25
+ # An array containing the Slices that the ROI is defined in.
26
+ attr_reader :slices
27
+ # The StructureSet that the ROI is defined in.
28
+ attr_reader :struct
29
+ # RT ROI Interpreted Type.
30
+ attr_reader :type
31
+
32
+ # Creates a new ROI instance from the three items of the structure set
33
+ # which contains the information related to a particular ROI.
34
+ # This method also creates and connects any child structures as indicated in the items (e.g. Slices).
35
+ # Returns the ROI instance.
36
+ #
37
+ # === Parameters
38
+ #
39
+ # * <tt>roi_item</tt> -- The ROI's Item from the Structure Set ROI Sequence in the DObject of a Structure Set.
40
+ # * <tt>contour_item</tt> -- The ROI's Item from the ROI Contour Sequence in the DObject of a Structure Set.
41
+ # * <tt>rt_item</tt> -- The ROI's Item from the RT ROI Observations Sequence in the DObject of a Structure Set.
42
+ # * <tt>struct</tt> -- The StructureSet instance that this ROI belongs to.
43
+ #
44
+ def self.create_from_items(roi_item, contour_item, rt_item, struct)
45
+ raise ArgumentError, "Invalid argument 'roi_item'. Expected DICOM::Item, got #{roi_item.class}." unless roi_item.is_a?(DICOM::Item)
46
+ raise ArgumentError, "Invalid argument 'contour_item'. Expected DICOM::Item, got #{contour_item.class}." unless contour_item.is_a?(DICOM::Item)
47
+ raise ArgumentError, "Invalid argument 'rt_item'. Expected DICOM::Item, got #{rt_item.class}." unless rt_item.is_a?(DICOM::Item)
48
+ raise ArgumentError, "Invalid argument 'struct'. Expected StructureSet, got #{struct.class}." unless struct.is_a?(StructureSet)
49
+ # Values from the Structure Set ROI Sequence Item:
50
+ number = roi_item.value(ROI_NUMBER).to_i
51
+ frame_of_ref = roi_item.value(REF_FRAME_OF_REF)
52
+ name = roi_item.value(ROI_NAME) || ''
53
+ algorithm = roi_item.value(ROI_ALGORITHM) || ''
54
+ # Values from the RT ROI Observations Sequence Item:
55
+ type = rt_item.value(ROI_TYPE) || ''
56
+ interpreter = rt_item.value(ROI_INTERPRETER) || ''
57
+ # Values from the ROI Contour Sequence Item:
58
+ color = contour_item.value(ROI_COLOR)
59
+ # Get the frame:
60
+ frame = struct.study.patient.dataset.frame(frame_of_ref)
61
+ # If the frame didnt exist, create it (assuming the frame belongs to our patient):
62
+ frame = struct.study.patient.create_frame(frame_of_ref) unless frame
63
+ # Create the ROI instance:
64
+ roi = self.new(name, number, frame, struct, :algorithm => algorithm, :color => color, :interpreter => interpreter, :type => type)
65
+ # Create the Slices in which the ROI has contours defined:
66
+ roi.create_slices(contour_item[CONTOUR_SQ]) if contour_item[CONTOUR_SQ]
67
+ return roi
68
+ end
69
+
70
+ # Creates a new ROI instance.
71
+ #
72
+ # === Parameters
73
+ #
74
+ # * <tt>name</tt> -- String. The ROI Name.
75
+ # * <tt>number</tt> -- Integer. The ROI Number.
76
+ # * <tt>frame</tt> -- The Frame instance that this ROI belongs to.
77
+ # * <tt>struct</tt> -- The StructureSet instance that this ROI belongs to.
78
+ # * <tt>options</tt> -- A hash of parameters.
79
+ #
80
+ # === Options
81
+ #
82
+ # * <tt>:algorithm</tt> -- String. The ROI Generation Algorithm. Defaults to 'Automatic'.
83
+ # * <tt>:color</tt> -- String. The ROI Display Color. Defaults to a random color string (format: 'x\y\z' where [x,y,z] is a byte (0-255)).
84
+ # * <tt>:interpreter</tt> -- String. The ROI Interpreter. Defaults to 'RTKIT'.
85
+ # * <tt>:type</tt> -- String. The ROI Interpreted Type. Defaults to 'CONTROL'.
86
+ #
87
+ def initialize(name, number, frame, struct, options={})
88
+ raise ArgumentError, "Invalid argument 'name'. Expected String, got #{name.class}." unless name.is_a?(String)
89
+ raise ArgumentError, "Invalid argument 'number'. Expected Integer, got #{number.class}." unless number.is_a?(Integer)
90
+ raise ArgumentError, "Invalid argument 'frame'. Expected Frame, got #{frame.class}." unless frame.is_a?(Frame)
91
+ raise ArgumentError, "Invalid argument 'struct'. Expected StructureSet, got #{struct.class}." unless struct.is_a?(StructureSet)
92
+ raise ArgumentError, "Invalid option :algorithm. Expected String, got #{options[:algorithm].class}." if options[:algorithm] && !options[:algorithm].is_a?(String)
93
+ raise ArgumentError, "Invalid option :color. Expected String, got #{options[:color].class}." if options[:color] && !options[:color].is_a?(String)
94
+ raise ArgumentError, "Invalid option :interpreter. Expected String, got #{options[:interpreter].class}." if options[:interpreter] && !options[:interpreter].is_a?(String)
95
+ raise ArgumentError, "Invalid option :type. Expected String, got #{options[:type].class}." if options[:type] && !options[:type].is_a?(String)
96
+ @slices = Array.new
97
+ @associated_instance_uids = Hash.new
98
+ # Set values:
99
+ @number = number
100
+ @name = name
101
+ @algorithm = options[:algorithm] || 'Automatic'
102
+ @type = options[:type] || 'CONTROL'
103
+ @interpreter = options[:interpreter] || 'RTKIT'
104
+ @color = options[:color] || random_color
105
+ # Set references:
106
+ @frame = frame
107
+ @struct = struct
108
+ # Register ourselves with the Frame and StructureSet:
109
+ @frame.add_roi(self)
110
+ @struct.add_roi(self)
111
+ end
112
+
113
+ # Returns true if the argument is an instance with attributes equal to self.
114
+ #
115
+ def ==(other)
116
+ if other.respond_to?(:to_roi)
117
+ other.send(:state) == state
118
+ end
119
+ end
120
+
121
+ alias_method :eql?, :==
122
+
123
+ # Adds a Slice instance to this ROI.
124
+ #
125
+ def add_slice(slice)
126
+ raise ArgumentError, "Invalid argument 'slice'. Expected Slice, got #{slice.class}." unless slice.is_a?(Slice)
127
+ @slices << slice unless @associated_instance_uids[slice.uid]
128
+ @associated_instance_uids[slice.uid] = slice
129
+ end
130
+
131
+ # Sets the algorithm attribute.
132
+ #
133
+ def algorithm=(value)
134
+ @algorithm = value && value.to_s
135
+ end
136
+
137
+ # Attaches a ROI to a specified ImageSeries, by setting the ROIs frame reference to the
138
+ # Frame which the ImageSeries belongs to, and setting the Image reference of each of the Slices
139
+ # belonging to the ROI to an Image instance which matches the coordinates of the Slice's Contour(s).
140
+ # Raises an exception if a suitable match is not found for any Slice.
141
+ #
142
+ # === Notes
143
+ #
144
+ # This method can be useful when you have multiple segmentations based on the same image series
145
+ # from multiple raters (perhaps as part of a comparison study), and the rater's software has modified
146
+ # the UIDs of the original image series, so that the references of the returned Structure Set does
147
+ # not match your original image series. This method uses coordinate information to calculate plane
148
+ # equations, which allows it to identify the corresponding image slice even in the case of
149
+ # slice geometry being non-perpendicular with respect to the patient geometry (direction cosine values != [0,1]).
150
+ #
151
+ def attach_to(series)
152
+ raise ArgumentError, "Invalid argument 'series'. Expected ImageSeries, got #{series.class}." unless series.is_a?(Series)
153
+ # Change struct association if indicated:
154
+ if series.struct != @struct
155
+ @struct.remove_roi(self)
156
+ series.struct.add_roi(self)
157
+ @struct = series.struct
158
+ end
159
+ # Change Frame if different:
160
+ if @frame != series.frame
161
+ @frame = series.frame
162
+ end
163
+ # Update slices:
164
+ @slices.each do |slice|
165
+ slice.attach_to(series)
166
+ end
167
+ end
168
+
169
+ # Creates a binary volume object consisting of a series of binary (segmented) images,
170
+ # extracted from the contours defined for the slices of this ROI.
171
+ # Returns a BinVolume instance with binary image references equal to
172
+ # the number of slices defined for this ROI.
173
+ #
174
+ # === Parameters
175
+ #
176
+ # * <tt>image_volume</tt> -- By default the BinVolume is created against the ImageSeries of the ROI's StructureSet. Optionally, a DoseVolume can be specified.
177
+ #
178
+ def bin_volume(image_volume=@struct.image_series.first)
179
+ return BinVolume.from_roi(self, image_volume)
180
+ end
181
+
182
+ # Sets a new color for this ROI.
183
+ #
184
+ # === Parameters
185
+ #
186
+ # * <tt>col</tt> -- String. A proper color string (3 integers 0-255, each separated by a '\').
187
+ #
188
+ def color=(col)
189
+ raise ArgumentError, "Invalid argument 'col'. Expected String, got #{col.class}." unless col.is_a?(String)
190
+ colors = col.split("\\")
191
+ raise ArgumentError, "Invalid argument 'col'. Expected 3 color values, got #{colors.length}." unless colors.length == 3
192
+ colors.each do |str|
193
+ c = str.to_i
194
+ raise ArgumentError, "Invalid argument 'col'. Expected valid integer (0-255), got #{str}." if c < 0 or c > 255
195
+ raise ArgumentError, "Invalid argument 'col'. Expected an integer, got #{str}." if c == 0 and str != "0"
196
+ end
197
+ @color = col
198
+ end
199
+
200
+ # Creates and returns a ROI Contour Sequence Item from the attributes of the ROI.
201
+ #
202
+ def contour_item
203
+ item = DICOM::Item.new
204
+ item.add(DICOM::Element.new(ROI_COLOR, @color))
205
+ s = DICOM::Sequence.new(CONTOUR_SQ)
206
+ item.add(s)
207
+ item.add(DICOM::Element.new(REF_ROI_NUMBER, @number.to_s))
208
+ # Add Contour items to the Contour Sequence (one or several items per Slice):
209
+ @slices.each do |slice|
210
+ slice.contours.each do |contour|
211
+ s.add_item(contour.to_item)
212
+ end
213
+ end
214
+ return item
215
+ end
216
+
217
+ # Iterates the Contour Sequence Items, collects Contour Items for each slice and passes them along to the Slice class.
218
+ #
219
+ def create_slices(contour_sequence)
220
+ raise ArgumentError, "Invalid argument 'contour_sequence'. Expected DICOM::Sequence, got #{contour_sequence.class}." unless contour_sequence.is_a?(DICOM::Sequence)
221
+ # Sort the contours by slices:
222
+ slice_collection = Hash.new
223
+ contour_sequence.each do |slice_contour_item|
224
+ sop_uid = slice_contour_item[CONTOUR_IMAGE_SQ][0].value(REF_SOP_UID)
225
+ slice_collection[sop_uid] = Array.new unless slice_collection[sop_uid]
226
+ slice_collection[sop_uid] << slice_contour_item
227
+ end
228
+ # Create slices:
229
+ slice_collection.each_pair do |sop_uid, items|
230
+ Slice.create_from_items(sop_uid, items, self)
231
+ end
232
+ end
233
+
234
+ # Creates a DoseDistribution based on the delineation of this ROI in the
235
+ # specified RTDose series.
236
+ #
237
+ # === Parameters
238
+ #
239
+ # * <tt>dose_volume</tt> -- The DoseVolume to extract the dose distribution from. Defaults to the sum of the dose volumes of the first RTDose of the first plan of the parent StructureSet.
240
+ #
241
+ def distribution(dose_volume=@struct.plan.rt_dose.sum)
242
+ raise ArgumentError, "Invalid argument 'dose_volume'. Expected DoseVolume, got #{dose_volume.class}." unless dose_volume.is_a?(DoseVolume)
243
+ raise ArgumentError, "Invalid argument 'dose_volume'. The specified DoseVolume does not belong to this ROI's StructureSet." unless dose_volume.dose_series.plan.struct == @struct
244
+ # Extract a binary volume from the ROI, based on the dose data:
245
+ bin_vol = bin_volume(dose_volume)
246
+ # Create a DoseDistribution from the BinVolume:
247
+ dose_distribution = DoseDistribution.create(bin_vol)
248
+ return dose_distribution
249
+ end
250
+
251
+ # Generates a Fixnum hash value for this instance.
252
+ #
253
+ def hash
254
+ state.hash
255
+ end
256
+
257
+ # Returns the ImageSeries instance that this ROI is defined in.
258
+ #
259
+ def image_series
260
+ return @struct.image_series.first
261
+ end
262
+
263
+ # Sets the interpreter attribute.
264
+ #
265
+ def interpreter=(value)
266
+ @interpreter = value && value.to_s
267
+ end
268
+
269
+ # Sets the name attribute.
270
+ #
271
+ def name=(value)
272
+ @name = value && value.to_s
273
+ end
274
+
275
+ # Returns the number of Contours belonging to this ROI through its Slices.
276
+ #
277
+ def num_contours
278
+ num = 0
279
+ @slices.each do |slice|
280
+ num += slice.contours.length
281
+ end
282
+ return num
283
+ end
284
+
285
+ # Sets the number attribute.
286
+ #
287
+ def number=(value)
288
+ @number = value.to_i
289
+ end
290
+
291
+ # Creates and returns a RT ROI Obervations Sequence Item from the attributes of the ROI.
292
+ #
293
+ def obs_item
294
+ item = DICOM::Item.new
295
+ item.add(DICOM::Element.new(OBS_NUMBER, @number.to_s))
296
+ item.add(DICOM::Element.new(REF_ROI_NUMBER, @number.to_s))
297
+ item.add(DICOM::Element.new(ROI_TYPE, @type))
298
+ item.add(DICOM::Element.new(ROI_INTERPRETER, @interpreter))
299
+ return item
300
+ end
301
+
302
+ # Removes the parent references of the ROI (StructureSet and Frame).
303
+ #
304
+ def remove_references
305
+ @frame = nil
306
+ @struct = nil
307
+ end
308
+
309
+ # Calculates the size (volume) of the ROI by evaluating the ROI's
310
+ # delination in the referenced image series.
311
+ # Returns a float, giving the volume in units of cubic centimeters,
312
+ #
313
+ def size
314
+ volume = 0.0
315
+ # Iterate each slice:
316
+ @slices.each_index do |i|
317
+ # Get the contoured area in this slice, convert it to volume and add to our total.
318
+ # If the slice is the first or last, only multiply by half of the slice thickness:
319
+ if i == 0 or i == @slices.length-1
320
+ volume += slice.area * image_series.slice_spacing * 0.5
321
+ else
322
+ volume += slice.area * image_series.slice_spacing
323
+ end
324
+ end
325
+ # Convert from mm^3 to cm^3:
326
+ return volume / 1000.0
327
+ end
328
+
329
+ # Returns the Slice instance mathcing the specified SOP Instance UID (if an argument is used).
330
+ # If a specified UID doesn't match, nil is returned.
331
+ # If no argument is passed, the first Slice instance associated with the ROI is returned.
332
+ #
333
+ # === Parameters
334
+ #
335
+ # * <tt>uid</tt> -- String. The value of the SOP Instance UID element.
336
+ #
337
+ def slice(*args)
338
+ raise ArgumentError, "Expected one or none arguments, got #{args.length}." unless [0, 1].include?(args.length)
339
+ if args.length == 1
340
+ raise ArgumentError, "Invalid argument 'uid'. Expected String (or nil), got #{args.first.class}." unless [String, NilClass].include?(args.first.class)
341
+ return @associated_instance_uids[args.first]
342
+ else
343
+ # No argument used, therefore we return the first Image instance:
344
+ return @slices.first
345
+ end
346
+ end
347
+
348
+ # Creates and returns a Structure Set ROI Sequence Item from the attributes of the ROI.
349
+ #
350
+ def ss_item
351
+ item = DICOM::Item.new
352
+ item.add(DICOM::Element.new(ROI_NUMBER, @number.to_s))
353
+ item.add(DICOM::Element.new(REF_FRAME_OF_REF, @frame.uid))
354
+ item.add(DICOM::Element.new(ROI_NAME, @name))
355
+ item.add(DICOM::Element.new(ROI_ALGORITHM, @algorithm))
356
+ return item
357
+ end
358
+
359
+ # Returns self.
360
+ #
361
+ def to_roi
362
+ self
363
+ end
364
+
365
+ # Sets the type attribute.
366
+ #
367
+ def type=(value)
368
+ @type = value && value.to_s
369
+ end
370
+
371
+
372
+ private
373
+
374
+
375
+ # Creates and returns a random color string (used for the ROI Display Color element).
376
+ #
377
+ def random_color
378
+ return "#{rand(256).to_i}\\#{rand(256).to_i}\\#{rand(256).to_i}"
379
+ end
380
+
381
+ # Returns the attributes of this instance in an array (for comparison purposes).
382
+ #
383
+ def state
384
+ [@algorithm, @color, @interpreter, @name, @number, @slices, @type]
385
+ end
386
+
387
+ end
388
+ end