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
@@ -0,0 +1,280 @@
|
|
1
|
+
module RTKIT
|
2
|
+
|
3
|
+
# Contains the DICOM data and methods related to a pixel dose volume.
|
4
|
+
#
|
5
|
+
# === Inheritance
|
6
|
+
#
|
7
|
+
# * DoseVolume inherits all methods and attributes from the Series class.
|
8
|
+
#
|
9
|
+
class DoseVolume < Series
|
10
|
+
|
11
|
+
include ImageParent
|
12
|
+
|
13
|
+
# The DObject instance of this dose Volume.
|
14
|
+
attr_reader :dcm
|
15
|
+
# The DoseSeries that this dose Volume belongs to.
|
16
|
+
attr_reader :dose_series
|
17
|
+
# The Frame (of Reference) which this DoseVolume belongs to.
|
18
|
+
attr_accessor :frame
|
19
|
+
# An array of dose pixel Image instances (frames) associated with this dose Volume.
|
20
|
+
attr_reader :images
|
21
|
+
# The Dose Grid Scaling factor (float).
|
22
|
+
attr_reader :scaling
|
23
|
+
# The SOP Instance UID.
|
24
|
+
attr_reader :sop_uid
|
25
|
+
|
26
|
+
# Creates a new Volume instance by loading image information from the specified DICOM object.
|
27
|
+
# The volume object's SOP Instance UID string value is used to uniquely identify a volume.
|
28
|
+
#
|
29
|
+
# === Parameters
|
30
|
+
#
|
31
|
+
# * <tt>dcm</tt> -- An instance of a DICOM object (DObject).
|
32
|
+
# * <tt>series</tt> -- The Series instance that this Volume belongs to.
|
33
|
+
#
|
34
|
+
def self.load(dcm, series)
|
35
|
+
raise ArgumentError, "Invalid argument 'dcm'. Expected DObject, got #{dcm.class}." unless dcm.is_a?(DICOM::DObject)
|
36
|
+
raise ArgumentError, "Invalid argument 'series'. Expected Series, got #{series.class}." unless series.is_a?(Series)
|
37
|
+
raise ArgumentError, "Invalid argument 'dcm'. Expected an image related modality, got #{dcm.value(MODALITY)}." unless IMAGE_MODALITIES.include?(dcm.value(MODALITY))
|
38
|
+
sop_uid = dcm.value(SOP_UID)
|
39
|
+
# Check if a Frame with the given UID already exists, and if not, create one:
|
40
|
+
frame = series.study.patient.dataset.frame(dcm.value(FRAME_OF_REF)) || frame = series.study.patient.create_frame(dcm.value(FRAME_OF_REF), dcm.value(POS_REF_INDICATOR))
|
41
|
+
# Create the RTDose instance:
|
42
|
+
volume = self.new(sop_uid, frame, series)
|
43
|
+
volume.add(dcm)
|
44
|
+
return volume
|
45
|
+
end
|
46
|
+
|
47
|
+
# Creates a new Volume instance. The SOP Instance UID tag value is used to uniquely identify a volume.
|
48
|
+
#
|
49
|
+
# === Parameters
|
50
|
+
#
|
51
|
+
# * <tt>sop_uid</tt> -- The SOP Instance UID string.
|
52
|
+
# * <tt>frame</tt> -- The Frame instance that this DoseVolume belongs to.
|
53
|
+
# * <tt>series</tt> -- The Series instance that this Image belongs to.
|
54
|
+
# * <tt>options</tt> -- A hash of parameters.
|
55
|
+
#
|
56
|
+
# === Options
|
57
|
+
#
|
58
|
+
# * <tt>:sum</tt> -- Boolean. If true, the DoseVolume will not be added as a (beam) volume to the parent RTDose.
|
59
|
+
#
|
60
|
+
def initialize(sop_uid, frame, series, options={})
|
61
|
+
raise ArgumentError, "Invalid argument 'sop_uid'. Expected String, got #{sop_uid.class}." unless sop_uid.is_a?(String)
|
62
|
+
raise ArgumentError, "Invalid argument 'frame'. Expected Frame, got #{frame.class}." unless frame.is_a?(Frame)
|
63
|
+
raise ArgumentError, "Invalid argument 'series'. Expected Series, got #{series.class}." unless series.is_a?(Series)
|
64
|
+
raise ArgumentError, "Invalid argument 'series'. Expected Series to have an image related modality, got #{series.modality}." unless IMAGE_MODALITIES.include?(series.modality)
|
65
|
+
# Pass attributes to Series initialization:
|
66
|
+
super(series.uid, 'RTDOSE', series.study)
|
67
|
+
# Key attributes:
|
68
|
+
@sop_uid = sop_uid
|
69
|
+
@frame = frame
|
70
|
+
@dose_series = series
|
71
|
+
# Default attributes:
|
72
|
+
@images = Array.new
|
73
|
+
@associated_images = Hash.new
|
74
|
+
@image_positions = Hash.new
|
75
|
+
# Register ourselves with the DoseSeries:
|
76
|
+
@dose_series.add_volume(self) unless options[:sum]
|
77
|
+
# Register ourselves with the study & frame:
|
78
|
+
#@study.add_series(self)
|
79
|
+
@frame.add_series(self)
|
80
|
+
end
|
81
|
+
|
82
|
+
# Returns true if the argument is an instance with attributes equal to self.
|
83
|
+
#
|
84
|
+
def ==(other)
|
85
|
+
if other.respond_to?(:to_dose_volume)
|
86
|
+
other.send(:state) == state
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
alias_method :eql?, :==
|
91
|
+
|
92
|
+
# Registers a DICOM Object to the dose Volume, and processes it
|
93
|
+
# to create (and reference) a (dose) Image instance (frame) linked to this dose Volume.
|
94
|
+
#
|
95
|
+
def add(dcm)
|
96
|
+
raise ArgumentError, "Invalid argument 'dcm'. Expected DObject, got #{dcm.class}." unless dcm.is_a?(DICOM::DObject)
|
97
|
+
@dcm = dcm
|
98
|
+
self.scaling = dcm.value(DOSE_GRID_SCALING)
|
99
|
+
pixels = dcm.narray
|
100
|
+
rows = dcm.value(ROWS)
|
101
|
+
cols = dcm.value(COLUMNS)
|
102
|
+
image_position = dcm.value(IMAGE_POSITION).split("\\")
|
103
|
+
pos_x = image_position[0].to_f
|
104
|
+
pos_y = image_position[1].to_f
|
105
|
+
frame_origin = image_position[2].to_f
|
106
|
+
cosines = dcm.value(IMAGE_ORIENTATION).split("\\").collect {|val| val.to_f} if dcm.value(IMAGE_ORIENTATION)
|
107
|
+
spacing = dcm.value(SPACING).split("\\")
|
108
|
+
col_spacing = spacing[1].to_f
|
109
|
+
row_spacing = spacing[0].to_f
|
110
|
+
nr_frames = dcm.value(NR_FRAMES).to_i
|
111
|
+
frame_offsets = dcm.value(GRID_FRAME_OFFSETS).split("\\").collect {|value| value.to_f}
|
112
|
+
sop_uids = RTKIT.sop_uids(nr_frames)
|
113
|
+
# Iterate each frame and create dose images:
|
114
|
+
nr_frames.times do |i|
|
115
|
+
# Create an Image instance (using an arbitrary UID, as individual dose frames don't really have UIDs in DICOM):
|
116
|
+
img = Image.new(sop_uids[i], self)
|
117
|
+
# Fill in image information:
|
118
|
+
img.columns = cols
|
119
|
+
img.rows = rows
|
120
|
+
img.pos_x = pos_x
|
121
|
+
img.pos_y = pos_y
|
122
|
+
img.pos_slice = frame_origin + frame_offsets[i]
|
123
|
+
img.col_spacing = col_spacing
|
124
|
+
img.row_spacing = row_spacing
|
125
|
+
img.cosines = cosines
|
126
|
+
# Fill in the pixel frame data:
|
127
|
+
img.narray = pixels[i, true, true]
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
# Adds an Image to this Volume.
|
132
|
+
#
|
133
|
+
def add_image(image)
|
134
|
+
raise ArgumentError, "Invalid argument 'image'. Expected Image, got #{image.class}." unless image.is_a?(Image)
|
135
|
+
@images << image unless @associated_images[image.uid]
|
136
|
+
@associated_images[image.uid] = image
|
137
|
+
@image_positions[image.pos_slice] = image
|
138
|
+
end
|
139
|
+
|
140
|
+
# Creates a binary volume object consisting of a series of binary
|
141
|
+
# (dose thresholded) images, extracted from this dose volume.
|
142
|
+
# Returns a BinVolume instance with binary image references equal to
|
143
|
+
# the number of dose images defined for this DoseVolume.
|
144
|
+
#
|
145
|
+
# === Notes
|
146
|
+
#
|
147
|
+
# * Even though listed as optional parameters, at least one of the :min and :max
|
148
|
+
# options must be specified in order to construct a valid binary volume.
|
149
|
+
#
|
150
|
+
# === Parameters
|
151
|
+
#
|
152
|
+
# * <tt>options</tt> -- A hash of parameters.
|
153
|
+
#
|
154
|
+
# === Options
|
155
|
+
#
|
156
|
+
# * <tt>:min</tt> -- Float. The lower dose threshold for dose elements to be included in the resulting dose bin volume.
|
157
|
+
# * <tt>:max</tt> -- Float. The upper dose threshold for dose elements to be included in the resulting dose bin volume.
|
158
|
+
# * <tt>:volume</tt> -- By default the BinVolume is created against the images of this DoseVolume. Optionally, an ImageSeries used by the ROI's of this Study can be specified.
|
159
|
+
#
|
160
|
+
def bin_volume(options={})
|
161
|
+
raise ArgumentError, "Need at least one dose limit parameter. Neither :min nor :max was specified." unless options[:min] or options[:max]
|
162
|
+
volume = options[:volume] || self
|
163
|
+
return BinVolume.from_dose(self, options[:min], options[:max], volume)
|
164
|
+
end
|
165
|
+
|
166
|
+
# Returns the dose distribution for a specified ROI (or entire volume)
|
167
|
+
# and a specified beam (or all beams).
|
168
|
+
#
|
169
|
+
# === Parameters
|
170
|
+
#
|
171
|
+
# * <tt>roi</tt> -- A specific ROI for which to evalute the dose in (if omitted, the entire volume is evaluted).
|
172
|
+
#
|
173
|
+
def distribution(roi=nil)
|
174
|
+
raise ArgumentError, "Invalid argument 'roi'. Expected ROI, got #{roi.class}." if roi && !roi.is_a?(ROI)
|
175
|
+
raise ArgumentError, "Invalid argument 'roi'. The specified ROI does not have the same StructureSet parent as this DoseVolume." if roi && roi.struct != @dose_series.plan.struct
|
176
|
+
if roi
|
177
|
+
# Extract a binary volume from the ROI, based on this DoseVolume:
|
178
|
+
bin_vol = roi.bin_volume(self)
|
179
|
+
else
|
180
|
+
# Create a binary volume which marks the entire dose volume:
|
181
|
+
bin_vol = BinVolume.from_volume(self)
|
182
|
+
end
|
183
|
+
# Create a DoseDistribution from the BinVolume:
|
184
|
+
dose_distribution = DoseDistribution.create(bin_vol)
|
185
|
+
end
|
186
|
+
|
187
|
+
# Returns the 3D dose pixel NArray retrieved from the #narray method,
|
188
|
+
# multiplied with the scaling coefficient, which in effect yields
|
189
|
+
# a 3D dose array.
|
190
|
+
#
|
191
|
+
def dose_arr
|
192
|
+
# Convert integer array to float array and multiply:
|
193
|
+
return narray.to_type(4) * @scaling
|
194
|
+
end
|
195
|
+
|
196
|
+
# Generates a Fixnum hash value for this instance.
|
197
|
+
#
|
198
|
+
def hash
|
199
|
+
state.hash
|
200
|
+
end
|
201
|
+
|
202
|
+
# Returns the Image instance mathcing the specified SOP Instance UID (if an argument is used).
|
203
|
+
# If a specified UID doesn't match, nil is returned.
|
204
|
+
# If no argument is passed, the first Image instance associated with the Volume is returned.
|
205
|
+
#
|
206
|
+
# === Parameters
|
207
|
+
#
|
208
|
+
# * <tt>uid_or_pos</tt> -- String/Float. The value of the SOP Instance UID element or the image position.
|
209
|
+
#
|
210
|
+
def image(*args)
|
211
|
+
raise ArgumentError, "Expected one or none arguments, got #{args.length}." unless [0, 1].include?(args.length)
|
212
|
+
if args.length == 1
|
213
|
+
if args.first.is_a?(Float)
|
214
|
+
# Presumably an image position:
|
215
|
+
return @image_positions[args.first]
|
216
|
+
else
|
217
|
+
# Presumably a uid string:
|
218
|
+
return @associated_images[args.first && args.first.to_s]
|
219
|
+
end
|
220
|
+
else
|
221
|
+
# No argument used, therefore we return the first Image instance:
|
222
|
+
return @images.first
|
223
|
+
end
|
224
|
+
end
|
225
|
+
|
226
|
+
# Builds a 3D dose pixel NArray from the dose images
|
227
|
+
# belonging to this DoseVolume. The array has shape [frames, columns, rows]
|
228
|
+
# and contains pixel values. To convert to dose values, the array must be
|
229
|
+
# multiplied with the scaling attribute.
|
230
|
+
#
|
231
|
+
def narray
|
232
|
+
if @images.length > 0
|
233
|
+
narr = NArray.int(@images.length, @images.first.columns, @images.first.rows)
|
234
|
+
@images.each_index do |i|
|
235
|
+
narr[i, true, true] = @images[i].narray
|
236
|
+
end
|
237
|
+
return narr
|
238
|
+
end
|
239
|
+
end
|
240
|
+
|
241
|
+
# Sets a new dose grid scaling.
|
242
|
+
#
|
243
|
+
# === Parameters
|
244
|
+
#
|
245
|
+
# * <tt>value</tt> -- Float. The dose grid scaling (3004,000E).
|
246
|
+
#
|
247
|
+
def scaling=(value)
|
248
|
+
@scaling = value && value.to_f
|
249
|
+
end
|
250
|
+
|
251
|
+
# Returns self.
|
252
|
+
#
|
253
|
+
def to_dose_volume
|
254
|
+
self
|
255
|
+
end
|
256
|
+
|
257
|
+
=begin
|
258
|
+
# Updates the position that is registered for the image for this series.
|
259
|
+
#
|
260
|
+
def update_image_position(image, new_pos)
|
261
|
+
raise ArgumentError, "Invalid argument 'image'. Expected Image, got #{image.class}." unless image.is_a?(Image)
|
262
|
+
# Remove old position key:
|
263
|
+
@image_positions.delete(image.pos)
|
264
|
+
# Add the new position:
|
265
|
+
@image_positions[new_pos] = image
|
266
|
+
end
|
267
|
+
=end
|
268
|
+
|
269
|
+
|
270
|
+
private
|
271
|
+
|
272
|
+
|
273
|
+
# Returns the attributes of this instance in an array (for comparison purposes).
|
274
|
+
#
|
275
|
+
def state
|
276
|
+
[@images, @scaling, @sop_uid]
|
277
|
+
end
|
278
|
+
|
279
|
+
end
|
280
|
+
end
|
data/lib/rtkit/frame.rb
ADDED
@@ -0,0 +1,164 @@
|
|
1
|
+
module RTKIT
|
2
|
+
|
3
|
+
# Contains the DICOM data and methods related to a Frame of Reference.
|
4
|
+
#
|
5
|
+
# === Relations
|
6
|
+
#
|
7
|
+
# * A Frame of Reference belongs to a Patient.
|
8
|
+
# * A Frame of Reference can have many ImageSeries.
|
9
|
+
#
|
10
|
+
class Frame
|
11
|
+
|
12
|
+
# An array of ImageSeries belonging to this Frame.
|
13
|
+
attr_reader :image_series
|
14
|
+
# The Position Reference Indicator (an optional annotation indicating the anatomical reference location).
|
15
|
+
attr_reader :indicator
|
16
|
+
# The Patient which this Frame of Reference belongs to.
|
17
|
+
attr_reader :patient
|
18
|
+
# An array of ROI instances belonging to this Frame.
|
19
|
+
attr_reader :rois
|
20
|
+
# The Frame of Reference UID.
|
21
|
+
attr_reader :uid
|
22
|
+
|
23
|
+
# Creates a new Frame instance. The Frame of Reference UID tag value uniquely identifies the Frame.
|
24
|
+
#
|
25
|
+
# === Parameters
|
26
|
+
#
|
27
|
+
# * <tt>uid</tt> -- The Frame of Reference UID string.
|
28
|
+
# * <tt>patient</tt> -- The Patient instance that this Frame belongs to.
|
29
|
+
# * <tt>options</tt> -- A hash of parameters.
|
30
|
+
#
|
31
|
+
# === Options
|
32
|
+
#
|
33
|
+
# * <tt>:indicator</tt> -- The Position Reference Indicator string.
|
34
|
+
#
|
35
|
+
def initialize(uid, patient, options={})
|
36
|
+
raise ArgumentError, "Invalid argument 'uid'. Expected String, got #{uid.class}." unless uid.is_a?(String)
|
37
|
+
raise ArgumentError, "Invalid argument 'patient'. Expected Patient, got #{patient.class}." unless patient.is_a?(Patient)
|
38
|
+
raise ArgumentError, "Invalid option :indicator. Expected String, got #{indicator.class}." if options[:indicator] && !options[:indicator].is_a?(String)
|
39
|
+
@associated_series = Hash.new
|
40
|
+
@associated_instance_uids = Hash.new
|
41
|
+
@image_series = Array.new
|
42
|
+
@rois = Array.new
|
43
|
+
@uid = uid
|
44
|
+
@patient = patient
|
45
|
+
@indicator = options[:indicator]
|
46
|
+
# Register ourselves with the patient and its dataset:
|
47
|
+
@patient.add_frame(self)
|
48
|
+
@patient.dataset.add_frame(self)
|
49
|
+
end
|
50
|
+
|
51
|
+
# Returns true if the argument is an instance with attributes equal to self.
|
52
|
+
#
|
53
|
+
def ==(other)
|
54
|
+
if other.respond_to?(:to_frame)
|
55
|
+
other.send(:state) == state
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
alias_method :eql?, :==
|
60
|
+
|
61
|
+
# Adds an Image to this Frame.
|
62
|
+
#
|
63
|
+
def add_image(image)
|
64
|
+
raise ArgumentError, "Invalid argument 'image'. Expected Image, got #{image.class}." unless image.is_a?(Image)
|
65
|
+
#@images << image
|
66
|
+
@associated_instance_uids[image.uid] = image
|
67
|
+
# If the ImageSeries of an added Image is not connected to this Frame yet, do so:
|
68
|
+
add_series(image.series) unless series(image.series.uid)
|
69
|
+
end
|
70
|
+
|
71
|
+
# Adds a ROI to this Frame.
|
72
|
+
#
|
73
|
+
def add_roi(roi)
|
74
|
+
raise ArgumentError, "Invalid argument 'roi'. Expected ROI, got #{roi.class}." unless roi.is_a?(ROI)
|
75
|
+
@rois << roi unless @rois.include?(roi)
|
76
|
+
end
|
77
|
+
|
78
|
+
# Adds an ImageSeries to this Frame.
|
79
|
+
#
|
80
|
+
def add_series(series)
|
81
|
+
raise ArgumentError, "Invalid argument 'series' Expected ImageSeries or DoseVolume, got #{series.class}." unless [ImageSeries, DoseVolume].include?(series.class)
|
82
|
+
@image_series << series
|
83
|
+
@associated_series[series.uid] = series
|
84
|
+
end
|
85
|
+
|
86
|
+
# Generates a Fixnum hash value for this instance.
|
87
|
+
#
|
88
|
+
def hash
|
89
|
+
state.hash
|
90
|
+
end
|
91
|
+
|
92
|
+
# Returns the Image instance mathcing the specified SOP Instance UID (if an argument is used).
|
93
|
+
# If a specified UID doesn't match, nil is returned.
|
94
|
+
# If no argument is passed, the first Image of the first ImageSeries instance associated with the Frame is returned.
|
95
|
+
#
|
96
|
+
# === Parameters
|
97
|
+
#
|
98
|
+
# * <tt>uid</tt> -- String. The value of the Series Instance UID element.
|
99
|
+
#
|
100
|
+
def image(*args)
|
101
|
+
raise ArgumentError, "Expected one or none arguments, got #{args.length}." unless [0, 1].include?(args.length)
|
102
|
+
if args.length == 1
|
103
|
+
raise ArgumentError, "Expected String (or nil), got #{args.first.class}." unless [String, NilClass].include?(args.first.class)
|
104
|
+
return @associated_instance_uids[args.first]
|
105
|
+
else
|
106
|
+
# No argument used, therefore we return the first Image of the first ImageSeries instance:
|
107
|
+
return @image_series.first.image
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
# Returns a ROI that matches the specified number or name.
|
112
|
+
# Returns nil if no match is found.
|
113
|
+
#
|
114
|
+
def roi(name_or_number)
|
115
|
+
raise ArgumentError, "Invalid argument 'name_or_number'. Expected String or Integer, got #{name_or_number.class}." unless [String, Integer, Fixnum].include?(name_or_number.class)
|
116
|
+
if name_or_number.is_a?(String)
|
117
|
+
@rois.each do |r|
|
118
|
+
return r if r.name == name_or_number
|
119
|
+
end
|
120
|
+
else
|
121
|
+
@rois.each do |r|
|
122
|
+
return r if r.number == name_or_number
|
123
|
+
end
|
124
|
+
end
|
125
|
+
return nil
|
126
|
+
end
|
127
|
+
|
128
|
+
# Returns the ImageSeries instance mathcing the specified Series Instance UID (if an argument is used).
|
129
|
+
# If a specified UID doesn't match, nil is returned.
|
130
|
+
# If no argument is passed, the first Series instance associated with the Frame is returned.
|
131
|
+
#
|
132
|
+
# === Parameters
|
133
|
+
#
|
134
|
+
# * <tt>uid</tt> -- String. The value of the Series Instance UID element.
|
135
|
+
#
|
136
|
+
def series(*args)
|
137
|
+
raise ArgumentError, "Expected one or none arguments, got #{args.length}." unless [0, 1].include?(args.length)
|
138
|
+
if args.length == 1
|
139
|
+
raise ArgumentError, "Expected String (or nil), got #{args.first.class}." unless [String, NilClass].include?(args.first.class)
|
140
|
+
return @associated_series[args.first]
|
141
|
+
else
|
142
|
+
# No argument used, therefore we return the first Study instance:
|
143
|
+
return @image_series.first
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
# Returns self.
|
148
|
+
#
|
149
|
+
def to_frame
|
150
|
+
self
|
151
|
+
end
|
152
|
+
|
153
|
+
|
154
|
+
private
|
155
|
+
|
156
|
+
|
157
|
+
# Returns the attributes of this instance in an array (for comparison purposes).
|
158
|
+
#
|
159
|
+
def state
|
160
|
+
[@frame_uid, @image_series, @indicator]
|
161
|
+
end
|
162
|
+
|
163
|
+
end
|
164
|
+
end
|
data/lib/rtkit/image.rb
ADDED
@@ -0,0 +1,372 @@
|
|
1
|
+
module RTKIT
|
2
|
+
|
3
|
+
# Contains the DICOM data and methods related to an image.
|
4
|
+
#
|
5
|
+
# === Inheritance
|
6
|
+
#
|
7
|
+
# * As the Image class inherits from the PixelData class, all PixelData methods are available to instances of Image.
|
8
|
+
#
|
9
|
+
class Image < PixelData
|
10
|
+
|
11
|
+
# The physical distance (in millimeters) between columns in the pixel data (i.e. horisontal spacing).
|
12
|
+
attr_reader :col_spacing
|
13
|
+
# The number of columns in the pixel data.
|
14
|
+
attr_reader :columns
|
15
|
+
# The values of the Image Orientation (Patient) element.
|
16
|
+
attr_reader :cosines
|
17
|
+
# The Instance Creation Date.
|
18
|
+
attr_reader :date
|
19
|
+
# The DICOM object of this Image instance.
|
20
|
+
attr_reader :dcm
|
21
|
+
# The 2d NArray holding the pixel data of this Image instance.
|
22
|
+
attr_reader :narray
|
23
|
+
# The physical position (in millimeters) of the image slice.
|
24
|
+
attr_reader :pos_slice
|
25
|
+
# The physical position (in millimeters) of the first (left) column in the pixel data.
|
26
|
+
attr_reader :pos_x
|
27
|
+
# The physical position (in millimeters) of the first (top) row in the pixel data.
|
28
|
+
attr_reader :pos_y
|
29
|
+
# The physical distance (in millimeters) between rows in the pixel data (i.e. vertical spacing).
|
30
|
+
attr_reader :row_spacing
|
31
|
+
# The number of rows in the pixel data.
|
32
|
+
attr_reader :rows
|
33
|
+
# The Image's Series (volume) reference.
|
34
|
+
attr_reader :series
|
35
|
+
# The Instance Creation Time.
|
36
|
+
attr_reader :time
|
37
|
+
# The SOP Instance UID.
|
38
|
+
attr_reader :uid
|
39
|
+
|
40
|
+
# Creates a new Image instance by loading image information from the specified DICOM object.
|
41
|
+
# The Image object's SOP Instance UID string value is used to uniquely identify an image.
|
42
|
+
#
|
43
|
+
# === Parameters
|
44
|
+
#
|
45
|
+
# * <tt>dcm</tt> -- An instance of a DICOM object (DObject).
|
46
|
+
# * <tt>series</tt> -- The Series instance that this Image belongs to.
|
47
|
+
#
|
48
|
+
def self.load(dcm, series)
|
49
|
+
raise ArgumentError, "Invalid argument 'dcm'. Expected DObject, got #{dcm.class}." unless dcm.is_a?(DICOM::DObject)
|
50
|
+
raise ArgumentError, "Invalid argument 'series'. Expected Series, got #{series.class}." unless series.is_a?(Series)
|
51
|
+
raise ArgumentError, "Invalid argument 'dcm'. Expected an image related modality, got #{dcm.value(MODALITY)}." unless IMAGE_MODALITIES.include?(dcm.value(MODALITY))
|
52
|
+
sop_uid = dcm.value(SOP_UID)
|
53
|
+
image = self.new(sop_uid, series)
|
54
|
+
image.load_pixel_data(dcm)
|
55
|
+
return image
|
56
|
+
end
|
57
|
+
|
58
|
+
# Creates a new Image instance. The SOP Instance UID tag value is used to uniquely identify an image.
|
59
|
+
#
|
60
|
+
# === Parameters
|
61
|
+
#
|
62
|
+
# * <tt>sop_uid</tt> -- The SOP Instance UID string.
|
63
|
+
# * <tt>series</tt> -- The Series instance that this Image belongs to.
|
64
|
+
#
|
65
|
+
def initialize(sop_uid, series)
|
66
|
+
raise ArgumentError, "Invalid argument 'sop_uid'. Expected String, got #{sop_uid.class}." unless sop_uid.is_a?(String)
|
67
|
+
raise ArgumentError, "Invalid argument 'series'. Expected Series, got #{series.class}." unless series.is_a?(Series)
|
68
|
+
raise ArgumentError, "Invalid argument 'series'. Expected Series to have an image related modality, got #{series.modality}." unless IMAGE_MODALITIES.include?(series.modality)
|
69
|
+
# Key attributes:
|
70
|
+
@uid = sop_uid
|
71
|
+
@series = series
|
72
|
+
# Register ourselves with the ImageSeries:
|
73
|
+
@series.add_image(self)
|
74
|
+
end
|
75
|
+
|
76
|
+
# Returns true if the argument is an instance with attributes equal to self.
|
77
|
+
#
|
78
|
+
def ==(other)
|
79
|
+
if other.respond_to?(:to_image)
|
80
|
+
other.send(:state) == state
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
alias_method :eql?, :==
|
85
|
+
|
86
|
+
# Creates and returns a filled, binary NArray image (a 'segmented' image) based on the provided contour coordinates.
|
87
|
+
#
|
88
|
+
# === Parameters
|
89
|
+
#
|
90
|
+
# * <tt>coords_x</tt> -- An Array/NArray of a contour's X coordinates. Must have at least 3 elements.
|
91
|
+
# * <tt>coords_y</tt> -- An Array/NArray of a contour's Y coordinates. Must have at least 3 elements.
|
92
|
+
# * <tt>coords_z</tt> -- An Array/NArray of a contour's Z coordinates. Must have at least 3 elements.
|
93
|
+
#
|
94
|
+
def binary_image(coords_x, coords_y, coords_z)
|
95
|
+
raise ArgumentError, "Invalid argument 'coords_x'. Expected at least 3 elements, got #{coords_x.length}" unless coords_x.length >= 3
|
96
|
+
raise ArgumentError, "Invalid argument 'coords_y'. Expected at least 3 elements, got #{coords_y.length}" unless coords_y.length >= 3
|
97
|
+
raise ArgumentError, "Invalid argument 'coords_z'. Expected at least 3 elements, got #{coords_z.length}" unless coords_z.length >= 3
|
98
|
+
# Values that will be used for image geometry:
|
99
|
+
empty_value = 0
|
100
|
+
line_value = 1
|
101
|
+
fill_value = 2
|
102
|
+
# Convert physical coordinates to image indices:
|
103
|
+
column_indices, row_indices = coordinates_to_indices(NArray.to_na(coords_x), NArray.to_na(coords_y), NArray.to_na(coords_z))
|
104
|
+
# Create an empty array and fill in the gathered points:
|
105
|
+
empty_array = NArray.byte(@columns, @rows)
|
106
|
+
delineated_array = draw_lines(column_indices.to_a, row_indices.to_a, empty_array, line_value)
|
107
|
+
# Establish starting point indices for the coming flood fill algorithm:
|
108
|
+
# (Using a rather simple approach by finding the average column and row index among the selection of indices)
|
109
|
+
start_col = column_indices.mean
|
110
|
+
start_row = row_indices.mean
|
111
|
+
# Perform a flood fill to enable us to extract all pixels contained in a specific ROI:
|
112
|
+
filled_array = flood_fill(start_col, start_row, delineated_array, fill_value)
|
113
|
+
# Extract the indices of 'ROI pixels':
|
114
|
+
if filled_array[0,0] != fill_value
|
115
|
+
# ROI has been filled as expected. Extract indices of value line_value and fill_value:
|
116
|
+
filled_array[(filled_array.eq line_value).where] = fill_value
|
117
|
+
indices = (filled_array.eq fill_value).where
|
118
|
+
else
|
119
|
+
# An inversion has occured. The entire image except our ROI has been filled. Extract indices of value line_value and empty_value:
|
120
|
+
filled_array[(filled_array.eq line_value).where] = empty_value
|
121
|
+
indices = (filled_array.eq empty_value).where
|
122
|
+
end
|
123
|
+
# Create binary image:
|
124
|
+
bin_image = NArray.byte(@columns, @rows)
|
125
|
+
bin_image[indices] = 1
|
126
|
+
return bin_image
|
127
|
+
end
|
128
|
+
|
129
|
+
# Sets the col_spacing attribute.
|
130
|
+
#
|
131
|
+
def col_spacing=(space)
|
132
|
+
@col_spacing = space && space.to_f
|
133
|
+
end
|
134
|
+
|
135
|
+
# Sets the columns attribute.
|
136
|
+
#
|
137
|
+
def columns=(cols)
|
138
|
+
#raise ArgumentError, "Invalid argument 'cols'. Expected a positive integer, got #{cols}" unless cols > 0
|
139
|
+
@columns = cols && cols.to_i
|
140
|
+
end
|
141
|
+
|
142
|
+
# Sets the cosines attribute.
|
143
|
+
#
|
144
|
+
def cosines=(cos)
|
145
|
+
#raise ArgumentError, "Invalid argument 'cos'. Expected 6 elements, got #{cos.length}" unless cos.length == 6
|
146
|
+
@cosines = cos && cos.to_a.collect! {|val| val.to_f}
|
147
|
+
end
|
148
|
+
|
149
|
+
# Generates a Fixnum hash value for this instance.
|
150
|
+
#
|
151
|
+
def hash
|
152
|
+
state.hash
|
153
|
+
end
|
154
|
+
|
155
|
+
# Transfers the pixel data, as well as the related image properties and the DObject instance itself,
|
156
|
+
# to the Image instance.
|
157
|
+
#
|
158
|
+
# === Parameters
|
159
|
+
#
|
160
|
+
# * <tt>dcm</tt> -- A DICOM object containing image data that will be applied to the Image instance.
|
161
|
+
#
|
162
|
+
def load_pixel_data(dcm)
|
163
|
+
raise ArgumentError, "Invalid argument 'dcm'. Expected DObject, got #{dcm.class}." unless dcm.is_a?(DICOM::DObject)
|
164
|
+
raise ArgumentError, "Invalid argument 'dcm'. Expected an image related modality, got #{dcm.value(MODALITY)}." unless IMAGE_MODALITIES.include?(dcm.value(MODALITY))
|
165
|
+
# Set attributes common for all image modalities, i.e. CT, MR, RTDOSE & RTIMAGE:
|
166
|
+
@dcm = dcm
|
167
|
+
@narray = dcm.narray
|
168
|
+
@date = dcm.value(IMAGE_DATE)
|
169
|
+
@time = dcm.value(IMAGE_TIME)
|
170
|
+
@uid = dcm.value(SOP_UID)
|
171
|
+
@columns = dcm.value(COLUMNS)
|
172
|
+
@rows = dcm.value(ROWS)
|
173
|
+
# Some difference in where we pick our values depending on if we have an RTIMAGE or another type:
|
174
|
+
if @series.modality == 'RTIMAGE'
|
175
|
+
image_position = dcm.value(RT_IMAGE_POSITION).split("\\")
|
176
|
+
raise "Invalid DICOM image: 2 basckslash-separated values expected for RT Image Position (Patient), got: #{image_position}" unless image_position.length == 2
|
177
|
+
@pos_x = image_position[0].to_f
|
178
|
+
@pos_y = image_position[1].to_f
|
179
|
+
@pos_slice = nil
|
180
|
+
spacing = dcm.value(IMAGE_PLANE_SPACING).split("\\")
|
181
|
+
raise "Invalid DICOM image: 2 basckslash-separated values expected for Image Plane Pixel Spacing, got: #{spacing}" unless spacing.length == 2
|
182
|
+
@col_spacing = spacing[1].to_f
|
183
|
+
@row_spacing = spacing[0].to_f
|
184
|
+
else
|
185
|
+
image_position = dcm.value(IMAGE_POSITION).split("\\")
|
186
|
+
raise "Invalid DICOM image: 3 basckslash-separated values expected for Image Position (Patient), got: #{image_position}" unless image_position.length == 3
|
187
|
+
@pos_x = image_position[0].to_f
|
188
|
+
@pos_y = image_position[1].to_f
|
189
|
+
self.pos_slice = image_position[2].to_f
|
190
|
+
spacing = dcm.value(SPACING).split("\\")
|
191
|
+
raise "Invalid DICOM image: 2 basckslash-separated values expected for Pixel Spacing, got: #{spacing}" unless spacing.length == 2
|
192
|
+
@col_spacing = spacing[1].to_f
|
193
|
+
@row_spacing = spacing[0].to_f
|
194
|
+
raise "Invalid DICOM image: Direction cosines missing (DICOM tag '#{IMAGE_ORIENTATION}')." unless dcm.exists?(IMAGE_ORIENTATION)
|
195
|
+
@cosines = dcm.value(IMAGE_ORIENTATION).split("\\").collect {|val| val.to_f} if dcm.value(IMAGE_ORIENTATION)
|
196
|
+
raise "Invalid DICOM image: 6 values expected for direction cosines (DICOM tag '#{IMAGE_ORIENTATION}'), got #{@cosines.length}." unless @cosines.length == 6
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
# Sets the pixels attribute (as well as the columns
|
201
|
+
# and rows attributes - derived from the pixel array - not ATM!!!).
|
202
|
+
#
|
203
|
+
def narray=(narr)
|
204
|
+
raise ArgumentError, "Invalid argument 'narray'. Expected NArray, got #{narr.class}" unless narr.is_a?(NArray)
|
205
|
+
raise ArgumentError, "Invalid argument 'narray'. Expected two-dimensional NArray matching @columns & @rows [#{@columns}, #{@rows}], got #{narr.shape}" unless narr.shape == [@columns, @rows]
|
206
|
+
@narray = narr
|
207
|
+
end
|
208
|
+
|
209
|
+
# Calculates the area of a single pixel of this image.
|
210
|
+
# Returns a float value, in units of millimeters squared.
|
211
|
+
#
|
212
|
+
def pixel_area
|
213
|
+
return @row_spacing * @col_spacing
|
214
|
+
end
|
215
|
+
|
216
|
+
# Extracts pixel values from the image based on the given indices.
|
217
|
+
#
|
218
|
+
def pixel_values(selection)
|
219
|
+
raise ArgumentError, "Invalid argument 'selection'. Expected Selection, got #{selection.class}" unless selection.is_a?(Selection)
|
220
|
+
return @narray[selection.indices]
|
221
|
+
end
|
222
|
+
|
223
|
+
# Sets the pos_slice attribute.
|
224
|
+
#
|
225
|
+
def pos_slice=(pos)
|
226
|
+
@series.update_image_position(self, pos)
|
227
|
+
@pos_slice = pos && pos.to_f
|
228
|
+
end
|
229
|
+
|
230
|
+
# Sets the pos_x attribute.
|
231
|
+
#
|
232
|
+
def pos_x=(pos)
|
233
|
+
@pos_x = pos && pos.to_f
|
234
|
+
end
|
235
|
+
|
236
|
+
# Sets the pos_y attribute.
|
237
|
+
#
|
238
|
+
def pos_y=(pos)
|
239
|
+
@pos_y = pos && pos.to_f
|
240
|
+
end
|
241
|
+
|
242
|
+
# Sets the row_spacing attribute.
|
243
|
+
#
|
244
|
+
def row_spacing=(space)
|
245
|
+
@row_spacing = space && space.to_f
|
246
|
+
end
|
247
|
+
|
248
|
+
# Sets the rows attribute.
|
249
|
+
#
|
250
|
+
def rows=(rows)
|
251
|
+
#raise ArgumentError, "Invalid argument 'rows'. Expected a positive integer, got #{rows}" unless rows.to_i > 0
|
252
|
+
@rows = rows && rows.to_i
|
253
|
+
end
|
254
|
+
|
255
|
+
# Sets the resolution of the image. This modifies the pixel data
|
256
|
+
# (in the specified way) and the column/row attributes as well.
|
257
|
+
# The image will either be expanded or cropped depending on whether
|
258
|
+
# the specified resolution is bigger or smaller than the existing one.
|
259
|
+
#
|
260
|
+
# === Parameters
|
261
|
+
#
|
262
|
+
# * <tt>columns</tt> -- Integer. The number of columns applied to the cropped/expanded image.
|
263
|
+
# * <tt>rows</tt> -- Integer. The number of rows applied to the cropped/expanded image.
|
264
|
+
#
|
265
|
+
# === Options
|
266
|
+
#
|
267
|
+
# * <tt>:hor</tt> -- Symbol. The side (in the horisontal image direction) to apply the crop/border (:left, :right or :even (default)).
|
268
|
+
# * <tt>:ver</tt> -- Symbol. The side (in the vertical image direction) to apply the crop/border (:bottom, :top or :even (default)).
|
269
|
+
#
|
270
|
+
def set_resolution(columns, rows, options={})
|
271
|
+
options[:hor] = :even unless options[:hor]
|
272
|
+
options[:ver] = :even unless options[:ver]
|
273
|
+
old_cols = @narray.shape[0]
|
274
|
+
old_rows = @narray.shape[1]
|
275
|
+
if @narray
|
276
|
+
# Modify the width only if changed:
|
277
|
+
if columns != old_cols
|
278
|
+
self.columns = columns.to_i
|
279
|
+
old_arr = @narray.dup
|
280
|
+
@narray = NArray.int(@columns, @rows)
|
281
|
+
if @columns > old_cols
|
282
|
+
# New array is larger:
|
283
|
+
case options[:hor]
|
284
|
+
when :left then @narray[(@columns-old_cols)..(@columns-1), true] = old_arr
|
285
|
+
when :right then @narray[0..(old_cols-1), true] = old_arr
|
286
|
+
when :even then @narray[((@columns-old_cols)/2+(@columns-old_cols).remainder(2))..(@columns-1-(@columns-old_cols)/2), true] = old_arr
|
287
|
+
end
|
288
|
+
else
|
289
|
+
# New array is smaller:
|
290
|
+
case options[:hor]
|
291
|
+
when :left then @narray = old_arr[(old_cols-@columns)..(old_cols-1), true]
|
292
|
+
when :right then @narray = old_arr[0..(@columns-1), true]
|
293
|
+
when :even then @narray = old_arr[((old_cols-@columns)/2+(old_cols-@columns).remainder(2))..(old_cols-1-(old_cols-@columns)/2), true]
|
294
|
+
end
|
295
|
+
end
|
296
|
+
end
|
297
|
+
# Modify the height only if changed:
|
298
|
+
if rows != old_rows
|
299
|
+
self.rows = rows.to_i
|
300
|
+
old_arr = @narray.dup
|
301
|
+
@narray = NArray.int(@columns, @rows)
|
302
|
+
if @rows > old_rows
|
303
|
+
# New array is larger:
|
304
|
+
case options[:ver]
|
305
|
+
when :top then @narray[true, (@rows-old_rows)..(@rows-1)] = old_arr
|
306
|
+
when :bottom then @narray[true, 0..(old_rows-1)] = old_arr
|
307
|
+
when :even then @narray[true, ((@rows-old_rows)/2+(@rows-old_rows).remainder(2))..(@rows-1-(@rows-old_rows)/2)] = old_arr
|
308
|
+
end
|
309
|
+
else
|
310
|
+
# New array is smaller:
|
311
|
+
case options[:ver]
|
312
|
+
when :top then @narray = old_arr[true, (old_rows-@rows)..(old_rows-1)]
|
313
|
+
when :bottom then @narray = old_arr[true, 0..(@rows-1)]
|
314
|
+
when :even then @narray = old_arr[true, ((old_rows-@rows)/2+(old_rows-@rows).remainder(2))..(old_rows-1-(old_rows-@rows)/2)]
|
315
|
+
end
|
316
|
+
end
|
317
|
+
end
|
318
|
+
end
|
319
|
+
end
|
320
|
+
|
321
|
+
# Dumps the Image instance to a DObject.
|
322
|
+
# This overwrites the dcm instance attribute.
|
323
|
+
# Returns the DObject instance.
|
324
|
+
#
|
325
|
+
def to_dcm
|
326
|
+
# Use the original DICOM object as a starting point,
|
327
|
+
# and update all image related parameters:
|
328
|
+
@dcm.add(DICOM::Element.new(IMAGE_DATE, @date))
|
329
|
+
@dcm.add(DICOM::Element.new(IMAGE_TIME, @time))
|
330
|
+
@dcm.add(DICOM::Element.new(SOP_UID, @uid))
|
331
|
+
@dcm.add(DICOM::Element.new(COLUMNS, @columns))
|
332
|
+
@dcm.add(DICOM::Element.new(ROWS, @rows))
|
333
|
+
if @series.modality == 'RTIMAGE'
|
334
|
+
@dcm.add(DICOM::Element.new(RT_IMAGE_POSITION, [@pos_x, @pos_y].join("\\")))
|
335
|
+
@dcm.add(DICOM::Element.new(IMAGE_PLANE_SPACING, [@row_spacing, @col_spacing].join("\\")))
|
336
|
+
else
|
337
|
+
@dcm.add(DICOM::Element.new(IMAGE_POSITION, [@pos_x, @pos_y, @pos_slice].join("\\")))
|
338
|
+
@dcm.add(DICOM::Element.new(SPACING, [@row_spacing, @col_spacing].join("\\")))
|
339
|
+
@dcm.add(DICOM::Element.new(IMAGE_ORIENTATION, [@cosines].join("\\")))
|
340
|
+
end
|
341
|
+
# Write pixel data:
|
342
|
+
@dcm.pixels = @narray
|
343
|
+
return @dcm
|
344
|
+
end
|
345
|
+
|
346
|
+
# Returns self.
|
347
|
+
#
|
348
|
+
def to_image
|
349
|
+
self
|
350
|
+
end
|
351
|
+
|
352
|
+
# Writes the Image to a DICOM file given by the specified file string.
|
353
|
+
#
|
354
|
+
def write(file_name)
|
355
|
+
to_dcm
|
356
|
+
@dcm.write(file_name)
|
357
|
+
end
|
358
|
+
|
359
|
+
|
360
|
+
private
|
361
|
+
|
362
|
+
|
363
|
+
# Returns the attributes of this instance in an array (for comparison purposes).
|
364
|
+
#
|
365
|
+
def state
|
366
|
+
[@col_spacing, @columns, @cosines, @date, @narray.to_a, @pos_x, @pos_y,
|
367
|
+
@pos_slice, @row_spacing, @rows, @time, @uid
|
368
|
+
]
|
369
|
+
end
|
370
|
+
|
371
|
+
end
|
372
|
+
end
|