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
data/lib/rtkit/plan.rb
ADDED
@@ -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
|
data/lib/rtkit/plane.rb
ADDED
@@ -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
|
data/lib/rtkit/roi.rb
ADDED
@@ -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
|