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