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