rtkit 0.7

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