rtkit 0.7

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