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.
- 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
|