rtkit 0.7

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,40 @@
1
+ module RTKIT
2
+
3
+ # This is a mixin-module for the classes that are image 'parents',
4
+ # i.e. they contain a reference to an array of image instances.
5
+ #
6
+ # This module is mixed in by the ImageSeries and Volume classes.
7
+ #
8
+ module ImageParent
9
+
10
+ # Returns the slice spacing (a float value in units of mm), which describes
11
+ # the distance between two neighbouring images in this image series.
12
+ # NB! If the image series contains 0 or 1 image, a slice spacing can not be
13
+ # determined and nil is returned.
14
+ #
15
+ def slice_spacing
16
+ if @slice_spacing
17
+ # If the slice spacing has already been computed, return it instead of recomputing:
18
+ return @slice_spacing
19
+ else
20
+ if @images.length > 1
21
+ # Collect slice positions:
22
+ slice_positions = NArray.to_na(@images.collect{|image| image.pos_slice})
23
+ spacings = (slice_positions[1..-1] - slice_positions[0..-2]).abs
24
+ @slice_spacing = spacings.to_a.most_common_value
25
+ end
26
+ end
27
+ end
28
+
29
+ # Updates the position that is registered for the image for this series.
30
+ #
31
+ def update_image_position(image, new_pos)
32
+ raise ArgumentError, "Invalid argument 'image'. Expected Image, got #{image.class}." unless image.is_a?(Image)
33
+ # Remove old position key:
34
+ @image_positions.delete(image.pos_slice)
35
+ # Add the new position:
36
+ @image_positions[new_pos] = image
37
+ end
38
+
39
+ end
40
+ end
@@ -0,0 +1,229 @@
1
+ module RTKIT
2
+
3
+ # Contains the DICOM data and methods related to a patient.
4
+ #
5
+ # === Relations
6
+ #
7
+ # * A Patient has many Study instances.
8
+ #
9
+ class Patient
10
+
11
+ # The Patient's birth date.
12
+ attr_reader :birth_date
13
+ # The DataSet which this Patient belongs to.
14
+ attr_reader :dataset
15
+ # An array of Frame (of Reference) instances belonging to this Patient.
16
+ attr_reader :frames
17
+ # The Patient's ID (string).
18
+ attr_reader :id
19
+ # The Patient's name.
20
+ attr_reader :name
21
+ # The Patient's sex.
22
+ attr_reader :sex
23
+ # An array of Study references.
24
+ attr_reader :studies
25
+
26
+ # Creates a new Patient instance by loading patient information from the specified DICOM object.
27
+ # The Patient's ID string value is used to uniquely identify a patient.
28
+ #
29
+ # === Parameters
30
+ #
31
+ # * <tt>dcm</tt> -- An instance of a DICOM object (DICOM::DObject).
32
+ # * <tt>dataset</tt> -- The DataSet instance that this Patient belongs to.
33
+ #
34
+ def self.load(dcm, dataset)
35
+ raise ArgumentError, "Invalid argument 'dcm'. Expected DObject, got #{dcm.class}." unless dcm.is_a?(DICOM::DObject)
36
+ raise ArgumentError, "Invalid argument 'dataset'. Expected DataSet, got #{dataset.class}." unless dataset.is_a?(DataSet)
37
+ name = dcm.value(PATIENTS_NAME)
38
+ id = dcm.value(PATIENTS_ID)
39
+ birth_date = dcm.value(BIRTH_DATE)
40
+ sex = dcm.value(SEX)
41
+ pat = self.new(name, id, dataset, :birth_date => birth_date, :sex => sex)
42
+ pat.add(dcm)
43
+ return pat
44
+ end
45
+
46
+ # Creates a new Patient instance. The Patient's ID string is used to uniquely identify a patient.
47
+ #
48
+ # === Parameters
49
+ #
50
+ # * <tt>name</tt> -- String. The Name of the Patient.
51
+ # * <tt>id</tt> -- String. The ID of the Patient.
52
+ # * <tt>dataset</tt> -- The DataSet instance which the Patient is associated with.
53
+ # * <tt>options</tt> -- A hash of parameters.
54
+ #
55
+ # === Options
56
+ #
57
+ # * <tt>:birth_date</tt> -- A Date String of the Patient's birth date.
58
+ # * <tt>:sex</tt> -- A code string indicating the Patient's sex.
59
+ #
60
+ def initialize(name, id, dataset, options={})
61
+ raise ArgumentError, "Invalid argument 'name'. Expected String, got #{name.class}." unless name.is_a?(String)
62
+ raise ArgumentError, "Invalid argument 'id'. Expected String, got #{id.class}." unless id.is_a?(String)
63
+ raise ArgumentError, "Invalid argument 'dataset'. Expected DataSet, got #{dataset.class}." unless dataset.is_a?(DataSet)
64
+ raise ArgumentError, "Invalid option ':birth_date'. Expected String, got #{options[:birth_date].class}." if options[:birth_date] && !options[:birth_date].is_a?(String)
65
+ raise ArgumentError, "Invalid option ':sex'. Expected String, got #{options[:sex].class}." if options[:sex] && !options[:sex].is_a?(String)
66
+ # Key attributes:
67
+ @name = name
68
+ @id = id
69
+ @dataset = dataset
70
+ # Default attributes:
71
+ @frames = Array.new
72
+ @studies = Array.new
73
+ @associated_frames = Hash.new
74
+ @associated_studies = Hash.new
75
+ # Optional attributes:
76
+ @birth_date = options[:birth_date]
77
+ @sex = options[:sex]
78
+ # Register ourselves with the dataset:
79
+ @dataset.add_patient(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_patient)
86
+ other.send(:state) == state
87
+ end
88
+ end
89
+
90
+ alias_method :eql?, :==
91
+
92
+ # Adds a DICOM object to the patient, by adding it
93
+ # to an existing Study, or creating a new Study.
94
+ #
95
+ def add(dcm)
96
+ s = study(dcm.value(STUDY_UID))
97
+ if s
98
+ s.add(dcm)
99
+ else
100
+ add_study(Study.load(dcm, self))
101
+ end
102
+ end
103
+
104
+ # Adds a Frame to this Patient.
105
+ #
106
+ def add_frame(frame)
107
+ raise ArgumentError, "Invalid argument 'frame'. Expected Frame, got #{frame.class}." unless frame.is_a?(Frame)
108
+ # Do not add it again if the frame already belongs to this instance:
109
+ @frames << frame unless @associated_frames[frame.uid]
110
+ @associated_frames[frame.uid] = frame
111
+ end
112
+
113
+ # Adds a Study to this Patient.
114
+ #
115
+ def add_study(study)
116
+ raise ArgumentError, "Invalid argument 'study'. Expected Study, got #{study.class}." unless study.is_a?(Study)
117
+ # Do not add it again if the study already belongs to this instance:
118
+ @studies << study unless @associated_studies[study.uid]
119
+ @associated_studies[study.uid] = study
120
+ end
121
+
122
+ # Sets the birth_date attribute.
123
+ #
124
+ def birth_date=(value)
125
+ @birth_date = value && value.to_s
126
+ end
127
+
128
+ # Creates (and returns) a Frame instance added to this Patient.
129
+ #
130
+ # === Parameters
131
+ #
132
+ # * <tt>uid</tt> -- The Frame of Reference UID string.
133
+ # * <tt>indicator</tt> -- The Position Reference Indicator string. Defaults to nil.
134
+ #
135
+ def create_frame(uid, indicator=nil)
136
+ raise ArgumentError, "Invalid argument 'uid'. Expected String, got #{uid.class}." unless uid.is_a?(String)
137
+ raise ArgumentError, "Invalid argument 'indicator'. Expected String or nil, got #{indicator.class}." unless [String, NilClass].include?(indicator.class)
138
+ frame = Frame.new(uid, self, :indicator => indicator)
139
+ add_frame(frame)
140
+ @dataset.add_frame(frame)
141
+ return frame
142
+ end
143
+
144
+ # Returns the Frame instance mathcing the specified Frame Instance UID (if an argument is used).
145
+ # If a specified UID doesn't match, nil is returned.
146
+ # If no argument is passed, the first Frame instance associated with the Patient is returned.
147
+ #
148
+ # === Parameters
149
+ #
150
+ # * <tt>uid</tt> -- String. The value of the Frame Instance UID element.
151
+ #
152
+ def frame(*args)
153
+ raise ArgumentError, "Expected one or none arguments, got #{args.length}." unless [0, 1].include?(args.length)
154
+ if args.length == 1
155
+ raise ArgumentError, "Expected String (or nil), got #{args.first.class}." unless [String, NilClass].include?(args.first.class)
156
+ return @associated_frames[args.first]
157
+ else
158
+ # No argument used, therefore we return the first Frame instance:
159
+ return @frames.first
160
+ end
161
+ end
162
+
163
+ # Generates a Fixnum hash value for this instance.
164
+ #
165
+ def hash
166
+ state.hash
167
+ end
168
+
169
+ # Sets the id attribute.
170
+ #
171
+ def id=(value)
172
+ @id = value && value.to_s
173
+ end
174
+
175
+ # Sets the name attribute.
176
+ #
177
+ def name=(value)
178
+ @name = value && value.to_s
179
+ end
180
+
181
+ # Sets the sex attribute.
182
+ #
183
+ def sex=(value)
184
+ @sex = value && value.to_s
185
+ end
186
+
187
+ # Returns the Study instance mathcing the specified Study Instance UID (if an argument is used).
188
+ # If a specified UID doesn't match, nil is returned.
189
+ # If no argument is passed, the first Study instance associated with the Patient is returned.
190
+ #
191
+ # === Parameters
192
+ #
193
+ # * <tt>uid</tt> -- String. The value of the Study Instance UID element.
194
+ #
195
+ def study(*args)
196
+ raise ArgumentError, "Expected one or none arguments, got #{args.length}." unless [0, 1].include?(args.length)
197
+ if args.length == 1
198
+ raise ArgumentError, "Expected String (or nil), got #{args.first.class}." unless [String, NilClass].include?(args.first.class)
199
+ return @associated_studies[args.first]
200
+ else
201
+ # No argument used, therefore we return the first Study instance:
202
+ return @studies.first
203
+ end
204
+ end
205
+
206
+ # Returns self.
207
+ #
208
+ def to_patient
209
+ self
210
+ end
211
+
212
+ # Returns the unique identifier string, which for this class is the Patient's ID.
213
+ #
214
+ def uid
215
+ return @id
216
+ end
217
+
218
+
219
+ private
220
+
221
+
222
+ # Returns the attributes of this instance in an array (for comparison purposes).
223
+ #
224
+ def state
225
+ [@birth_date, @frames, @id, @name, @sex, @studies]
226
+ end
227
+
228
+ end
229
+ end
@@ -0,0 +1,237 @@
1
+ module RTKIT
2
+
3
+ # A collection of methods for dealing with pixel data in both 2D images and 3D volumes.
4
+ #
5
+ # === Inheritance
6
+ #
7
+ # These methods are available to instances of the following classes:
8
+ # * Image
9
+ # * Dose
10
+ #
11
+ class PixelData
12
+
13
+ # Converts from two NArrays of image X & Y indices to physical coordinates X, Y & Z (in mm).
14
+ # The X, Y & Z coordinates are returned in three NArrays of equal size as the input index NArrays.
15
+ # The image coordinates are calculated using the direction cosines of the Image Orientation (Patient) element (0020,0037).
16
+ #
17
+ # === Notes
18
+ #
19
+ # * For details about Image orientation, refer to the DICOM standard: PS 3.3 C.7.6.2.1.1
20
+ #
21
+ def coordinates_from_indices(column_indices, row_indices)
22
+ raise ArgumentError, "Invalid argument 'column_indices'. Expected NArray, got #{column_indices.class}." unless column_indices.is_a?(NArray)
23
+ raise ArgumentError, "Invalid argument 'row_indices'. Expected NArray, got #{row_indices.class}." unless row_indices.is_a?(NArray)
24
+ raise ArgumentError, "Invalid arguments. Expected NArrays of equal length, got #{column_indices.length} and #{row_indices.length}." unless column_indices.length == row_indices.length
25
+ raise "Invalid attribute 'cosines'. Expected a 6 element Array, got #{cosines.class} #{cosines.length if cosines.is_a?(Array)}." unless cosines.is_a?(Array) && cosines.length == 6
26
+ raise "Invalid attribute 'pos_x'. Expected Float, got #{pos_x.class}." unless pos_x.is_a?(Float)
27
+ raise "Invalid attribute 'pos_y'. Expected Float, got #{pos_y.class}." unless pos_y.is_a?(Float)
28
+ raise "Invalid attribute 'pos_slice'. Expected Float, got #{pos_slice.class}." unless pos_slice.is_a?(Float)
29
+ raise "Invalid attribute 'col_spacing'. Expected Float, got #{col_spacing.class}." unless col_spacing.is_a?(Float)
30
+ raise "Invalid attribute 'row_spacing'. Expected Float, got #{row_spacing.class}." unless row_spacing.is_a?(Float)
31
+ # Convert indices integers to floats:
32
+ column_indices = column_indices.to_f
33
+ row_indices = row_indices.to_f
34
+ # Calculate the coordinates by multiplying indices with the direction cosines and applying the image offset:
35
+ x = pos_x + (column_indices * col_spacing * cosines[0]) + (row_indices * row_spacing * cosines[3])
36
+ y = pos_y + (column_indices * col_spacing * cosines[1]) + (row_indices * row_spacing * cosines[4])
37
+ z = pos_slice + (column_indices * col_spacing * cosines[2]) + (row_indices * row_spacing * cosines[5])
38
+ return x, y, z
39
+ end
40
+
41
+ # Converts from three (float) NArrays of X, Y & Z physical coordinates (in mm) to image slice indices X & Y.
42
+ # The X & Y indices are returned in two NArrays of equal size as the input coordinate NArrays.
43
+ # The image indices are calculated using the direction cosines of the Image Orientation (Patient) element (0020,0037).
44
+ #
45
+ # === Notes
46
+ #
47
+ # * For details about Image orientation, refer to the DICOM standard: PS 3.3 C.7.6.2.1.1
48
+ #
49
+ def coordinates_to_indices(x, y, z)
50
+ raise ArgumentError, "Invalid argument 'x'. Expected NArray, got #{x.class}." unless x.is_a?(NArray)
51
+ raise ArgumentError, "Invalid argument 'y'. Expected NArray, got #{y.class}." unless y.is_a?(NArray)
52
+ raise ArgumentError, "Invalid argument 'z'. Expected NArray, got #{z.class}." unless z.is_a?(NArray)
53
+ raise ArgumentError, "Invalid arguments. Expected NArrays of equal length, got #{x.length}, #{y.length} and #{z.length}." unless [x.length, y.length, z.length].uniq.length == 1
54
+ raise "Invalid attribute 'cosines'. Expected a 6 element Array, got #{cosines.class} #{cosines.length if cosines.is_a?(Array)}." unless cosines.is_a?(Array) && cosines.length == 6
55
+ raise "Invalid attribute 'pos_x'. Expected Float, got #{pos_x.class}." unless pos_x.is_a?(Float)
56
+ raise "Invalid attribute 'pos_y'. Expected Float, got #{pos_y.class}." unless pos_y.is_a?(Float)
57
+ raise "Invalid attribute 'pos_slice'. Expected Float, got #{pos_slice.class}." unless pos_slice.is_a?(Float)
58
+ raise "Invalid attribute 'col_spacing'. Expected Float, got #{col_spacing.class}." unless col_spacing.is_a?(Float)
59
+ raise "Invalid attribute 'row_spacing'. Expected Float, got #{row_spacing.class}." unless row_spacing.is_a?(Float)
60
+ # Calculate the indices by multiplying coordinates with the direction cosines and applying the image offset:
61
+ column_indices = ((x-pos_x)/col_spacing*cosines[0] + (y-pos_y)/col_spacing*cosines[1] + (z-pos_slice)/col_spacing*cosines[2]).round
62
+ row_indices = ((x-pos_x)/row_spacing*cosines[3] + (y-pos_y)/row_spacing*cosines[4] + (z-pos_slice)/row_spacing*cosines[5]).round
63
+ return column_indices, row_indices
64
+ end
65
+
66
+ # Fills the provided image array with lines of a specified value, based on two vectors of column and row indices.
67
+ # The image is expected to be a (two-dimensional) NArray.
68
+ # Returns the processed image array.
69
+ #
70
+ def draw_lines(column_indices, row_indices, image, value)
71
+ raise ArgumentError, "Invalid argument 'column_indices'. Expected Array, got #{column_indices.class}." unless column_indices.is_a?(Array)
72
+ raise ArgumentError, "Invalid argument 'row_indices'. Expected Array, got #{row_indices.class}." unless row_indices.is_a?(Array)
73
+ raise ArgumentError, "Invalid arguments. Expected Arrays of equal length, got #{column_indices.length}, #{row_indices.length}." unless column_indices.length == row_indices.length
74
+ raise ArgumentError, "Invalid argument 'image'. Expected NArray, got #{image.class}." unless image.is_a?(NArray)
75
+ raise ArgumentError, "Invalid number of dimensions for argument 'image'. Expected 2, got #{image.shape.length}." unless image.shape.length == 2
76
+ raise ArgumentError, "Invalid argument 'value'. Expected Integer, got #{value.class}." unless value.is_a?(Integer)
77
+ column_indices.each_index do |i|
78
+ image = draw_line(column_indices[i-1], column_indices[i], row_indices[i-1], row_indices[i], image, value)
79
+ end
80
+ return image
81
+ end
82
+
83
+ # Iterative, queue based flood fill algorithm.
84
+ # Replaces all pixels of a specific value that are contained by pixels of different value.
85
+ # The replacement value along with the starting coordinates are passed as parameters to this method.
86
+ # It seems a recursive method is not suited for Ruby due to its limited stack space (a problem in general for scripting languages).
87
+ #
88
+ def flood_fill(col, row, image, fill_value)
89
+ existing_value = image[col, row]
90
+ queue = Array.new
91
+ queue.push([col, row])
92
+ until queue.empty?
93
+ col, row = queue.shift
94
+ if image[col, row] == existing_value
95
+ west_col, west_row = ff_find_border(col, row, existing_value, :west, image)
96
+ east_col, east_row = ff_find_border(col, row, existing_value, :east, image)
97
+ # Fill the line between the two border pixels (i.e. not touching the border pixels):
98
+ image[west_col..east_col, row] = fill_value
99
+ q = west_col
100
+ while q <= east_col
101
+ [:north, :south].each do |direction|
102
+ same_col, next_row = ff_neighbour(q, row, direction)
103
+ begin
104
+ queue.push([q, next_row]) if image[q, next_row] == existing_value
105
+ rescue
106
+ # Out of bounds. Do nothing.
107
+ end
108
+ end
109
+ q, same_row = ff_neighbour(q, row, :east)
110
+ end
111
+ end
112
+ end
113
+ return image
114
+ end
115
+
116
+ # Converts general image indices to specific column and row indices based on the
117
+ # provided image indices and the number of columns in the image.
118
+ #
119
+ def indices_general_to_specific(indices, n_cols)
120
+ if indices.is_a?(Array)
121
+ row_indices = indices.collect{|i| i/n_cols}
122
+ column_indices = [indices, row_indices].transpose.collect{|i| i[0] - i[1] * n_cols}
123
+ else
124
+ # Assume Fixnum or NArray:
125
+ row_indices = indices/n_cols # Values are automatically rounded down.
126
+ column_indices = indices-row_indices*n_cols
127
+ end
128
+ return column_indices, row_indices
129
+ end
130
+
131
+ # Converts specific x and y indices to general image indices based on the provided specific indices and x size of the NArray image.
132
+ #
133
+ def indices_specific_to_general(column_indices, row_indices, n_cols)
134
+ if column_indices.is_a?(Array)
135
+ indices = Array.new
136
+ column_indices.each_index {|i| indices << column_indices[i] + row_indices[i] * n_cols}
137
+ return indices
138
+ else
139
+ # Assume Fixnum or NArray:
140
+ return column_indices + row_indices * n_cols
141
+ end
142
+ end
143
+
144
+ # A convenience method for printing image information.
145
+ # NB! This has been used only for debugging, and will soon be removed.
146
+ #
147
+ def print_img(narr=@narray)
148
+ puts "Image dimensions: #{@columns}*#{@rows}"
149
+ narr.shape[0].times do |i|
150
+ puts narr[true, i].to_a.to_s
151
+ end
152
+ end
153
+
154
+
155
+ private
156
+
157
+
158
+ # Draws a single line in the (NArray) image matrix based on a start- and an end-point.
159
+ # The method uses an iterative Bresenham Line Algorithm.
160
+ # Returns the processed image array.
161
+ #
162
+ def draw_line(x0, x1, y0, y1, image, value)
163
+ steep = ((y1-y0).abs) > ((x1-x0).abs)
164
+ if steep
165
+ x0,y0 = y0,x0
166
+ x1,y1 = y1,x1
167
+ end
168
+ if x0 > x1
169
+ x0,x1 = x1,x0
170
+ y0,y1 = y1,y0
171
+ end
172
+ deltax = x1-x0
173
+ deltay = (y1-y0).abs
174
+ error = (deltax / 2).to_i
175
+ y = y0
176
+ ystep = nil
177
+ if y0 < y1
178
+ ystep = 1
179
+ else
180
+ ystep = -1
181
+ end
182
+ for x in x0..x1
183
+ if steep
184
+ begin
185
+ image[y,x] = value # (switching variables)
186
+ rescue
187
+ # Our line has gone outside the image. Do nothing for now, but the proper thing to do would be to at least return some status boolean indicating that this has occured.
188
+ end
189
+ else
190
+ begin
191
+ image[x,y] = value
192
+ rescue
193
+ # Our line has gone outside the image.
194
+ end
195
+ end
196
+ error -= deltay
197
+ if error < 0
198
+ y += ystep
199
+ error += deltax
200
+ end
201
+ end
202
+ return image
203
+ end
204
+
205
+ # Searches left and right to find the 'border' in a row of pixels the image array.
206
+ # Returns a column and row index. Used by the flood_fill() method.
207
+ #
208
+ def ff_find_border(col, row, existing_value, direction, image)
209
+ next_col, next_row = ff_neighbour(col, row, direction)
210
+ begin
211
+ while image[next_col, next_row] == existing_value
212
+ col, row = next_col, next_row
213
+ next_col, next_row = ff_neighbour(col, row, direction)
214
+ end
215
+ rescue
216
+ # Out of bounds. Do nothing.
217
+ end
218
+ col = 0 if col < 1
219
+ row = 0 if row < 1
220
+ return col, row
221
+ end
222
+
223
+ # Returns the neighbour index based on the specified direction.
224
+ # Used by the flood_fill() method and its dependency; the ff_find_border() method.
225
+ # :east => to the right when looking at an array image printout on the screen
226
+ #
227
+ def ff_neighbour(col, row, direction)
228
+ case direction
229
+ when :north then return col, row-1
230
+ when :south then return col, row+1
231
+ when :east then return col+1, row
232
+ when :west then return col-1, row
233
+ end
234
+ end
235
+
236
+ end
237
+ end