rtkit 0.7
Sign up to get free protection for your applications and to get access to all the features.
- data/CHANGELOG.rdoc +10 -0
- data/COPYING +674 -0
- data/README.rdoc +107 -0
- data/lib/rtkit.rb +68 -0
- data/lib/rtkit/beam.rb +346 -0
- data/lib/rtkit/bin_image.rb +578 -0
- data/lib/rtkit/bin_matcher.rb +241 -0
- data/lib/rtkit/bin_volume.rb +263 -0
- data/lib/rtkit/collimator.rb +157 -0
- data/lib/rtkit/collimator_setup.rb +143 -0
- data/lib/rtkit/constants.rb +215 -0
- data/lib/rtkit/contour.rb +213 -0
- data/lib/rtkit/control_point.rb +371 -0
- data/lib/rtkit/coordinate.rb +83 -0
- data/lib/rtkit/data_set.rb +264 -0
- data/lib/rtkit/dose.rb +70 -0
- data/lib/rtkit/dose_distribution.rb +206 -0
- data/lib/rtkit/dose_volume.rb +280 -0
- data/lib/rtkit/frame.rb +164 -0
- data/lib/rtkit/image.rb +372 -0
- data/lib/rtkit/image_series.rb +290 -0
- data/lib/rtkit/logging.rb +158 -0
- data/lib/rtkit/methods.rb +105 -0
- data/lib/rtkit/mixins/image_parent.rb +40 -0
- data/lib/rtkit/patient.rb +229 -0
- data/lib/rtkit/pixel_data.rb +237 -0
- data/lib/rtkit/plan.rb +259 -0
- data/lib/rtkit/plane.rb +165 -0
- data/lib/rtkit/roi.rb +388 -0
- data/lib/rtkit/rt_dose.rb +237 -0
- data/lib/rtkit/rt_image.rb +179 -0
- data/lib/rtkit/ruby_extensions.rb +165 -0
- data/lib/rtkit/selection.rb +189 -0
- data/lib/rtkit/series.rb +77 -0
- data/lib/rtkit/setup.rb +198 -0
- data/lib/rtkit/slice.rb +184 -0
- data/lib/rtkit/staple.rb +305 -0
- data/lib/rtkit/structure_set.rb +442 -0
- data/lib/rtkit/study.rb +214 -0
- data/lib/rtkit/variables.rb +23 -0
- data/lib/rtkit/version.rb +6 -0
- metadata +159 -0
@@ -0,0 +1,578 @@
|
|
1
|
+
module RTKIT
|
2
|
+
|
3
|
+
# Contains the DICOM data and methods related to a binary image.
|
4
|
+
#
|
5
|
+
# === Inheritance
|
6
|
+
#
|
7
|
+
# * As the BinImage class inherits from the PixelData class, all PixelData methods are available to instances of BinImage.
|
8
|
+
#
|
9
|
+
class BinImage < PixelData
|
10
|
+
|
11
|
+
# The BinImage's Image reference.
|
12
|
+
attr_reader :image
|
13
|
+
# The binary numerical image array.
|
14
|
+
attr_reader :narray
|
15
|
+
# A narray containing pixel indices.
|
16
|
+
attr_reader :narray_indices
|
17
|
+
|
18
|
+
# Creates a new BinImage instance from an array of contours.
|
19
|
+
# The BinVolume is typically defined from a ROI delineation against an image series,
|
20
|
+
# but it may also be applied to an rtdose 'image' series.
|
21
|
+
# Returns the BinVolume instance.
|
22
|
+
#
|
23
|
+
# === Parameters
|
24
|
+
#
|
25
|
+
# * <tt>contours</tt> -- An array of contours from which to fill in a binary image.
|
26
|
+
# * <tt>image</tt> -- The image that this BinImage instance will be based on.
|
27
|
+
# * <tt>bin_volume</tt> -- The BinVolume instance that this bin_image belongs to.
|
28
|
+
#
|
29
|
+
def self.from_contours(contours, image, bin_volume)
|
30
|
+
raise ArgumentError, "Invalid argument 'contours'. Expected Array, got #{contours.class}." unless contours.is_a?(Array)
|
31
|
+
raise ArgumentError, "Invalid argument 'image'. Expected Image, got #{image.class}." unless image.is_a?(Image)
|
32
|
+
raise ArgumentError, "Invalid argument 'bin_volume'. Expected BinVolume, got #{bin_volume.class}." unless bin_volume.is_a?(BinVolume)
|
33
|
+
# Create the narray to be used:
|
34
|
+
narr = NArray.byte(image.columns, image.rows)
|
35
|
+
# Create the BinImage instance:
|
36
|
+
bi = self.new(narr, image)
|
37
|
+
# Delineate and fill for each contour:
|
38
|
+
contours.each do |contour|
|
39
|
+
x, y, z = contour.coords
|
40
|
+
bi.add(image.binary_image(x, y, z))
|
41
|
+
end
|
42
|
+
bin_volume.add(bi)
|
43
|
+
return bi
|
44
|
+
end
|
45
|
+
|
46
|
+
# Creates a new BinImage instance.
|
47
|
+
#
|
48
|
+
# === Parameters
|
49
|
+
#
|
50
|
+
# * <tt>narray</tt> -- A binary, two-dimensional NArray.
|
51
|
+
# * <tt>image</tt> -- The Image instance that this BinImage is associated with.
|
52
|
+
#
|
53
|
+
def initialize(narray, image)
|
54
|
+
raise ArgumentError, "Invalid argument 'narray'. Expected NArray, got #{narray.class}." unless narray.is_a?(NArray)
|
55
|
+
raise ArgumentError, "Invalid argument 'image'. Expected Image, got #{image.class}." unless image.is_a?(Image)
|
56
|
+
raise ArgumentError, "Invalid argument 'narray'. Expected two-dimensional NArray, got #{narray.shape.length} dimensions." unless narray.shape.length == 2
|
57
|
+
raise ArgumentError, "Invalid argument 'narray'. Expected NArray of element size 1 byte, got #{narray.element_size} bytes (per element)." unless narray.element_size == 1
|
58
|
+
raise ArgumentError, "Invalid argument 'narray'. Expected binary NArray with max value 1, got #{narray.max} as max." if narray.max > 1
|
59
|
+
self.narray = narray
|
60
|
+
@image = image
|
61
|
+
end
|
62
|
+
|
63
|
+
# Returns true if the argument is an instance with attributes equal to self.
|
64
|
+
#
|
65
|
+
def ==(other)
|
66
|
+
if other.respond_to?(:to_bin_image)
|
67
|
+
other.send(:state) == state
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
alias_method :eql?, :==
|
72
|
+
|
73
|
+
# Adds a binary image array to the image array of this instance.
|
74
|
+
# Any segmented pixels in the new array (value = 1), is added (value set eql to 1) to the instance array.
|
75
|
+
#
|
76
|
+
def add(pixels)
|
77
|
+
raise ArgumentError, "Invalid argument 'pixels'. Expected NArray, got #{pixels.class}." unless pixels.is_a?(NArray)
|
78
|
+
raise ArgumentError, "Invalid argument 'pixels'. Expected NArray of element size 1 byte, got #{pixels.element_size} bytes (per element)." unless pixels.element_size == 1
|
79
|
+
raise ArgumentError, "Invalid argument 'pixels'. Expected binary NArray with max value 1, got #{pixels.max} as max." if pixels.max > 1
|
80
|
+
raise ArgumentError, "Invalid argument 'pixels'. Expected NArray to have same dimension as the instance array. Got #{pixels.shape}, expected #{@narray.shape}." unless pixels.shape == @narray.shape
|
81
|
+
@narray[(pixels > 0).where] = 1
|
82
|
+
end
|
83
|
+
|
84
|
+
# Calculates the area defined by true/false (1/0) pixels.
|
85
|
+
# By default, the area of the true pixels are returned.
|
86
|
+
# Returns a float value, in units of millimeters squared.
|
87
|
+
#
|
88
|
+
# === Parameters
|
89
|
+
#
|
90
|
+
# * <tt>type</tt> -- Boolean. Pixel type of interest.
|
91
|
+
#
|
92
|
+
def area(type=true)
|
93
|
+
if type
|
94
|
+
number = (@narray.eq 1).where.length
|
95
|
+
else
|
96
|
+
number = (@narray.eq 0).where.length
|
97
|
+
end
|
98
|
+
# Total area is number of pixels times the area per pixel:
|
99
|
+
return number * @image.pixel_area
|
100
|
+
end
|
101
|
+
|
102
|
+
# Returns the col_spacing attribute from the Image reference.
|
103
|
+
# This attribute defines the physical distance (in millimeters) between columns in the pixel data (i.e. horisontal spacing).
|
104
|
+
#
|
105
|
+
def col_spacing
|
106
|
+
return @image.col_spacing
|
107
|
+
end
|
108
|
+
|
109
|
+
# Returns the number of columns in the binary array.
|
110
|
+
#
|
111
|
+
def columns
|
112
|
+
return @narray.shape[0]
|
113
|
+
end
|
114
|
+
|
115
|
+
# Applies the contour indices of this instance to an empty image (2D NArray)
|
116
|
+
# to create a 'contour image'.
|
117
|
+
# Each separate contour is indicated by individual integers (e.g. 1,2,3 etc).
|
118
|
+
#
|
119
|
+
def contour_image
|
120
|
+
img = NArray.byte(columns, rows)
|
121
|
+
contour_indices.each_with_index do |contour, i|
|
122
|
+
img[contour.indices] = i + 1
|
123
|
+
end
|
124
|
+
return img
|
125
|
+
end
|
126
|
+
|
127
|
+
# Extracts the contour indices of the (filled) structures contained in the BinImage,
|
128
|
+
# by performing a contour tracing algorithm on the binary image.
|
129
|
+
# Returns an array filled with contour Selection instances, with length
|
130
|
+
# equal to the number of separated structures in the image.
|
131
|
+
#
|
132
|
+
# === Notes
|
133
|
+
#
|
134
|
+
# * The contours are established using a contour tracing algorithm called "Radial Sweep":
|
135
|
+
# * http://www.imageprocessingplace.com/downloads_V3/root_downloads/tutorials/contour_tracing_Abeer_George_Ghuneim/ray.html
|
136
|
+
#
|
137
|
+
# === Restrictions
|
138
|
+
#
|
139
|
+
# * Does not detect inner contour of hollow structures (holes).
|
140
|
+
#
|
141
|
+
def contour_indices
|
142
|
+
# Create the array to be returned:
|
143
|
+
contours = Array.new
|
144
|
+
# Initialize the contour extraction process if indicated:
|
145
|
+
if @narray.segmented?
|
146
|
+
# Initialize some variables used by the contour algorithm:
|
147
|
+
initialize_contour_reorder_structures unless @reorder
|
148
|
+
# The contour algorithm needs the image to be padded with a border of zero-pixels:
|
149
|
+
original_image = @narray
|
150
|
+
padded_image = NArray.byte(columns + 2, rows + 2)
|
151
|
+
padded_image[1..-2, 1..-2] = @narray
|
152
|
+
# Temporarily replace our instance image with the padded image:
|
153
|
+
self.narray = padded_image
|
154
|
+
# Get the contours:
|
155
|
+
padded_contours = extract_contours
|
156
|
+
# Convert from padded indices to proper indices:
|
157
|
+
padded_contours.each do |padded_contour|
|
158
|
+
padded_contour.shift_and_crop(-1, -1)
|
159
|
+
contours << padded_contour
|
160
|
+
end
|
161
|
+
# Restore the instance image:
|
162
|
+
self.narray = original_image
|
163
|
+
end
|
164
|
+
return contours
|
165
|
+
end
|
166
|
+
|
167
|
+
# Returns the cosines attribute from the Image reference.
|
168
|
+
#
|
169
|
+
def cosines
|
170
|
+
return @image.cosines
|
171
|
+
end
|
172
|
+
|
173
|
+
# Generates a Fixnum hash value for this instance.
|
174
|
+
#
|
175
|
+
def hash
|
176
|
+
state.hash
|
177
|
+
end
|
178
|
+
|
179
|
+
# Sets a new binary array for this BinImage instance.
|
180
|
+
#
|
181
|
+
def narray=(image)
|
182
|
+
raise ArgumentError, "Invalid argument 'image'. Expected NArray, got #{image.class}." unless image.is_a?(NArray)
|
183
|
+
raise ArgumentError, "Invalid argument 'image'. Expected two-dimensional NArray, got #{image.shape.length} dimensions." unless image.shape.length == 2
|
184
|
+
raise ArgumentError, "Invalid argument 'image'. Expected NArray of element size 1 byte, got #{image.element_size} bytes (per element)." unless image.element_size == 1
|
185
|
+
raise ArgumentError, "Invalid argument 'image'. Expected binary NArray with max value 1, got #{image.max} as max." if image.max > 1
|
186
|
+
@narray = image
|
187
|
+
# Create a corresponding array of the image indices (used in image processing):
|
188
|
+
@narray_indices = NArray.int(columns, rows).indgen!
|
189
|
+
end
|
190
|
+
|
191
|
+
# Returns the pos_slice attribute from the Image reference.
|
192
|
+
# This attribute defines the physical position (in millimeters) of the image slice.
|
193
|
+
# Returns nil if there is no Image reference.
|
194
|
+
#
|
195
|
+
def pos_slice
|
196
|
+
return @image ? @image.pos_slice : nil
|
197
|
+
end
|
198
|
+
|
199
|
+
# Returns the pos_x attribute from the Image reference.
|
200
|
+
# This attribute defines the physical position (in millimeters) of the first (left) column in the pixel data.
|
201
|
+
#
|
202
|
+
def pos_x
|
203
|
+
return @image.pos_x
|
204
|
+
end
|
205
|
+
|
206
|
+
# Returns the pos_y attribute from the Image reference.
|
207
|
+
# This attribute defines the physical position (in millimeters) of the first (top) row in the pixel data.
|
208
|
+
#
|
209
|
+
def pos_y
|
210
|
+
return @image.pos_y
|
211
|
+
end
|
212
|
+
|
213
|
+
# Returns the row_spacing attribute from the Image reference.
|
214
|
+
# This attribute defines the physical distance (in millimeters) between rows in the pixel data (i.e. vertical spacing).
|
215
|
+
#
|
216
|
+
def row_spacing
|
217
|
+
return @image.row_spacing
|
218
|
+
end
|
219
|
+
|
220
|
+
# Returns the number of rows in the binary array.
|
221
|
+
#
|
222
|
+
def rows
|
223
|
+
return @narray.shape[1]
|
224
|
+
end
|
225
|
+
|
226
|
+
# Creates a Selection containing all 'segmented' indices of this instance, i.e.
|
227
|
+
# indices of all pixels with a value of 1.
|
228
|
+
# Returns the Selection instance.
|
229
|
+
#
|
230
|
+
def selection
|
231
|
+
s = Selection.new(self)
|
232
|
+
s.add_indices((@narray.eq 1).where)
|
233
|
+
return s
|
234
|
+
end
|
235
|
+
|
236
|
+
# Returns self.
|
237
|
+
#
|
238
|
+
def to_bin_image
|
239
|
+
self
|
240
|
+
end
|
241
|
+
|
242
|
+
# Converts the BinImage instance to a single image BinVolume instance.
|
243
|
+
#
|
244
|
+
# === Parameters
|
245
|
+
#
|
246
|
+
# * <tt>series</tt> -- The image series (e.g. ImageSeries or DoseVolume) which forms the reference data of the BinVolume.
|
247
|
+
# * <tt>source</tt> -- The object which is the source of the binary (segmented) data (i.e. ROI or Dose/Hounsfield threshold).
|
248
|
+
#
|
249
|
+
def to_bin_volume(series, source=nil)
|
250
|
+
bin_volume = BinVolume.new(series, :images => [self], :source => source)
|
251
|
+
end
|
252
|
+
|
253
|
+
# Creates an array of Contour instances from the segmentation of this BinImage.
|
254
|
+
# Returns the array of Contours.
|
255
|
+
# Returns an empty array if no Contours are created (empty BinImage).
|
256
|
+
#
|
257
|
+
# === Parameters
|
258
|
+
#
|
259
|
+
# * <tt>slice</tt> -- A Slice instance which the Contours will be connected to.
|
260
|
+
#
|
261
|
+
def to_contours(slice)
|
262
|
+
raise ArgumentError, "Invalid argument 'slice. Expected Slice, got #{slice.class}." unless slice.is_a?(Slice)
|
263
|
+
contours = Array.new
|
264
|
+
# Iterate the extracted collection of contour indices and convert to Contour instances:
|
265
|
+
contour_indices.each do |contour|
|
266
|
+
# Convert column and row indices to X, Y and Z coordinates:
|
267
|
+
x, y, z = coordinates_from_indices(NArray.to_na(contour.columns), NArray.to_na(contour.rows))
|
268
|
+
# Convert NArray to Array and round the coordinate floats:
|
269
|
+
x = x.to_a.collect {|f| f.round(1)}
|
270
|
+
y = y.to_a.collect {|f| f.round(1)}
|
271
|
+
z = z.to_a.collect {|f| f.round(3)}
|
272
|
+
contours << Contour.create_from_coordinates(x, y, z, slice)
|
273
|
+
end
|
274
|
+
return contours
|
275
|
+
end
|
276
|
+
|
277
|
+
# Dumps the BinImage instance to a DObject.
|
278
|
+
# This is achieved by copying the Elements of the DICOM object of the Image instance referenced by this BinImage,
|
279
|
+
# and replacing its pixel data with the NArray of this instance.
|
280
|
+
# Returns the DObject instance.
|
281
|
+
#
|
282
|
+
def to_dcm
|
283
|
+
# Use the original DICOM object as a starting point (keeping all non-sequence elements):
|
284
|
+
# Note: Something like dcm.dup doesn't work here because that only performs a shallow copy on the DObject instance.
|
285
|
+
dcm = DICOM::DObject.new
|
286
|
+
@image.dcm.each_element do |element|
|
287
|
+
# A bit of a hack to regenerate the DICOM elements:
|
288
|
+
begin
|
289
|
+
if element.value
|
290
|
+
# ATM this fails for tags with integer values converted to a backslash-separated string:
|
291
|
+
DICOM::Element.new(element.tag, element.value, :parent => dcm)
|
292
|
+
else
|
293
|
+
# Transfer the binary content as long as it is not the pixel data string:
|
294
|
+
DICOM::Element.new(element.tag, element.bin, :encoded => true, :parent => dcm)
|
295
|
+
end
|
296
|
+
rescue
|
297
|
+
DICOM::Element.new(element.tag, element.value.split("\\").collect {|val| val.to_i}, :parent => dcm) if element.value
|
298
|
+
end
|
299
|
+
end
|
300
|
+
dcm.delete_group('0002')
|
301
|
+
# Format the DICOM image ensure good contrast amongst the binary pixel values:
|
302
|
+
# Window Center:
|
303
|
+
DICOM::Element.new('0028,1050', '128', :parent => dcm)
|
304
|
+
# Window Width:
|
305
|
+
DICOM::Element.new('0028,1051', '256', :parent => dcm)
|
306
|
+
# Rescale Intercept:
|
307
|
+
DICOM::Element.new('0028,1052', '0', :parent => dcm)
|
308
|
+
# Rescale Slope:
|
309
|
+
DICOM::Element.new('0028,1053', '1', :parent => dcm)
|
310
|
+
# Pixel data:
|
311
|
+
dcm.pixels = @narray*255
|
312
|
+
return dcm
|
313
|
+
end
|
314
|
+
|
315
|
+
# Creates a Slice instance from the segmentation of this BinImage.
|
316
|
+
# This method also creates and connects any child structures as indicated in the item (e.g. Contours).
|
317
|
+
# Returns the Slice instance.
|
318
|
+
#
|
319
|
+
# === Parameters
|
320
|
+
#
|
321
|
+
# * <tt>roi</tt> -- A ROI instance which the Slice will be connected to.
|
322
|
+
#
|
323
|
+
def to_slice(roi)
|
324
|
+
raise ArgumentError, "Invalid argument 'roi'. Expected ROI, got #{roi.class}." unless roi.is_a?(ROI)
|
325
|
+
# Create the Slice:
|
326
|
+
s = Slice.new(@image.uid, roi)
|
327
|
+
# Create Contours:
|
328
|
+
to_contours(s)
|
329
|
+
return s
|
330
|
+
end
|
331
|
+
|
332
|
+
# Writes the BinImage to a DICOM file given by the specified file string.
|
333
|
+
#
|
334
|
+
def write(path)
|
335
|
+
dcm = to_dcm
|
336
|
+
dcm.write(path)
|
337
|
+
end
|
338
|
+
|
339
|
+
|
340
|
+
private
|
341
|
+
|
342
|
+
|
343
|
+
# Determines a set of pixel indices which enclose the structure.
|
344
|
+
#
|
345
|
+
# === Notes
|
346
|
+
#
|
347
|
+
# * Uses Roman Khudeevs algorithm: A New Flood-Fill Algorithm for Closed Contour
|
348
|
+
# * https://docs.google.com/viewer?a=v&q=cache:UZ6bo7pXRoIJ:file.lw23.com/file1/01611214.pdf+flood+fill+from+contour+coordinate&hl=no&gl=no&pid=bl&srcid=ADGEEShV4gbKYYq8cDagjT7poT677cIL44K0QW8SR0ODanFy-CD1CHEQi2RvHF8MND7_PXPGYRJMJAcMJO-NEXkM-vU4iA2rNljVetbzuARWuHtKLJKMTNjd3vaDWrIeSU4rKLCVwvff&sig=AHIEtbSAnH6fp584c0_Krv298n-tgpNcJw&pli=1
|
349
|
+
#
|
350
|
+
def external_contour
|
351
|
+
start_index = (@narray > 0).where[0] - 1
|
352
|
+
s_col, s_row = indices_general_to_specific(start_index, columns)
|
353
|
+
col, row = s_col, s_row
|
354
|
+
row_indices = Array.new(1, row)
|
355
|
+
col_indices = Array.new(1, col)
|
356
|
+
last_dir = :north # on first step, pretend we came from the south (going north)
|
357
|
+
directions = {
|
358
|
+
:north => {:dir => [:east, :north, :west, :south], :col => [1, 0, -1, 0], :row => [0, -1, 0, 1]},
|
359
|
+
:east => {:dir => [:south, :east, :north, :west], :col => [0, 1, 0, -1], :row => [1, 0, -1, 0]},
|
360
|
+
:south => {:dir => [:west, :south, :east, :north], :col => [-1, 0, 1, 0], :row => [0, 1, 0, -1]},
|
361
|
+
:west => {:dir => [:north, :west, :south, :east], :col => [0, -1, 0, 1], :row => [-1, 0, 1, 0]},
|
362
|
+
}
|
363
|
+
loop = true
|
364
|
+
while loop do
|
365
|
+
# Probe the neighbourhood pixels in a CCW order:
|
366
|
+
map = directions[last_dir]
|
367
|
+
4.times do |i|
|
368
|
+
# Find the first 'free' (zero) pixel, and make that index
|
369
|
+
# the next pixel of our external contour:
|
370
|
+
if @narray[col + map[:col][i], row + map[:row][i]] == 0
|
371
|
+
last_dir = map[:dir][i]
|
372
|
+
col = col + map[:col][i]
|
373
|
+
row = row + map[:row][i]
|
374
|
+
col_indices << col
|
375
|
+
row_indices << row
|
376
|
+
break
|
377
|
+
end
|
378
|
+
end
|
379
|
+
loop = false if col == s_col and row == s_row
|
380
|
+
end
|
381
|
+
return Selection.create_from_array(indices_specific_to_general(col_indices, row_indices, columns), self)
|
382
|
+
end
|
383
|
+
|
384
|
+
# This is a recursive method which extracts a contour, determines all pixels
|
385
|
+
# belonging to this contour, removes them from the binary image, then
|
386
|
+
# repeats collecting contours until there are no more pixels left.
|
387
|
+
# Returns an array of contour selections.
|
388
|
+
#
|
389
|
+
def extract_contours
|
390
|
+
contours = Array.new
|
391
|
+
if @narray.segmented?
|
392
|
+
# Get contours:
|
393
|
+
corners, continuous = extract_single_contour
|
394
|
+
# If we dont get at least 3 indices, there is no area to fill.
|
395
|
+
if continuous.indices.length < 3
|
396
|
+
# In this case we remove the pixels and do not record the contour indices:
|
397
|
+
roi = continuous
|
398
|
+
else
|
399
|
+
# Record the indices and get all indices of the structure:
|
400
|
+
contours << corners
|
401
|
+
# Flood fill the image to determine all pixels contained by the contoured structure:
|
402
|
+
roi = roi_indices(continuous)
|
403
|
+
# A precaution:
|
404
|
+
raise "Unexpected result: #{roi.indices.length}. Raising an error to avoid an infinite recursion!" if roi.indices.length < 3
|
405
|
+
end
|
406
|
+
# Reset the pixels belonging to the contoured structure from the image:
|
407
|
+
@narray[roi.indices] = 0
|
408
|
+
# Repeat with the 'cleaned' image to get any additional contours present:
|
409
|
+
contours += extract_contours
|
410
|
+
end
|
411
|
+
return contours
|
412
|
+
end
|
413
|
+
|
414
|
+
# Returns contour indices (Selection) from the first (if any) structure
|
415
|
+
# found in the binary image of this instance.
|
416
|
+
#
|
417
|
+
# FIXME: For now, a rather simple corner detection algorithm is integrated and used.
|
418
|
+
# At some stage this could be replaced/supplemented with a proper ('lossy') corner detection algorithm.
|
419
|
+
#
|
420
|
+
def extract_single_contour
|
421
|
+
# Set up an array to keep track of the pixels belonging to the current contour being analyzed by the algorithm:
|
422
|
+
current_indices = Array.new
|
423
|
+
# Also keep track of the direction we 'arrived from' (in addition to the pixel position):
|
424
|
+
contour_directions = Array.new
|
425
|
+
# First step of contour algorithm: Identify a border pixel which will be our start pixel.
|
426
|
+
# Traditionally this is achieved by scanning the image, row by row, left to right, until a foreground pixel is found.
|
427
|
+
# Instead of scanning, this implementation will extract all foreground indices and chose the first index as our start pixel.
|
428
|
+
indices = (@narray > 0).where
|
429
|
+
if indices.length > 0
|
430
|
+
current_indices << indices[0]
|
431
|
+
# Generally we will store the direction we came from along with the pixel position - but not for the first pixel.
|
432
|
+
# Specific indices for the first pixel:
|
433
|
+
p_col, p_row = indices_general_to_specific(current_indices.first, columns)
|
434
|
+
# Set up variables for the initial run of the contour algorithm loop:
|
435
|
+
arrived_from = :west
|
436
|
+
direction_from_corner = nil
|
437
|
+
continue = true
|
438
|
+
while continue do
|
439
|
+
# Radially sweep the 8 pixels surrounding the start pixel, until a foreground pixel is found.
|
440
|
+
# We do this by extracting a 3*3 array centered on our position. Based on the direction to the previous pixel,
|
441
|
+
# we then extract the neighbour pixels in such a way the pixel where we came from in the previous step is first in our vector.
|
442
|
+
local_pixels = @narray[(p_col-1)..(p_col+1), (p_row-1)..(p_row+1)].flatten
|
443
|
+
local_indices = @narray_indices[(p_col-1)..(p_col+1), (p_row-1)..(p_row+1)].flatten
|
444
|
+
neighbour_pixels = local_pixels[@reorder[arrived_from]]
|
445
|
+
neighbour_indices = local_indices[@reorder[arrived_from]]
|
446
|
+
neighbour_relative_indices = @relative_indices[@reorder[arrived_from]]
|
447
|
+
# The next border pixel is then the first foreground pixel in the extracted vector.
|
448
|
+
local_foreground = (neighbour_pixels > 0).where
|
449
|
+
if local_foreground.length > 0
|
450
|
+
# We identified another border pixel.
|
451
|
+
first_foreground_index = local_foreground[0]
|
452
|
+
current_pixel_index = neighbour_indices[first_foreground_index]
|
453
|
+
# Stopping criterion: If current pixel equals the second pixel, and the previous pixel equals the first pixel,
|
454
|
+
# then we can be absolutely sure that we have made a full contour, regardless of the connectivity of the border.
|
455
|
+
if current_pixel_index == current_indices[1] and current_indices.last == current_indices.first
|
456
|
+
# We have re-located the original start pixels. Remove the duplicate last element and abort the search.
|
457
|
+
current_indices.pop
|
458
|
+
#contour_directions.pop
|
459
|
+
continue = false
|
460
|
+
else
|
461
|
+
# Extract x and y index as well as the direction we arrived from:
|
462
|
+
p_col, p_row = indices_general_to_specific(current_pixel_index, columns)
|
463
|
+
arrived_from = @arrived_from_directions[neighbour_relative_indices[first_foreground_index]]
|
464
|
+
# Store pixel and continue the search, using the newly identified border pixel in the next step.
|
465
|
+
current_indices << current_pixel_index
|
466
|
+
contour_directions << arrived_from
|
467
|
+
end
|
468
|
+
else
|
469
|
+
# No foreground pixel was found, search finished.
|
470
|
+
continue = false
|
471
|
+
end
|
472
|
+
end
|
473
|
+
# Before reducing to corner indices, make a copy of the full set of indices:
|
474
|
+
all_indices = Selection.create_from_array(current_indices, self)
|
475
|
+
# We only want corner points: Remove all the indices that are between corner points:
|
476
|
+
img_contour_original = current_indices.dup
|
477
|
+
current_indices = Array.new(1, img_contour_original.first)
|
478
|
+
img_contour_original.delete_at(0)
|
479
|
+
original_direction = contour_directions.first
|
480
|
+
contour_directions.delete_at(0)
|
481
|
+
img_contour_original.each_index do |i|
|
482
|
+
if contour_directions[i] != original_direction
|
483
|
+
# Store pixel and set new direction:
|
484
|
+
current_indices << img_contour_original[i]
|
485
|
+
original_direction = contour_directions[i]
|
486
|
+
end
|
487
|
+
end
|
488
|
+
corner_indices = Selection.create_from_array(current_indices, self)
|
489
|
+
end
|
490
|
+
return corner_indices, all_indices
|
491
|
+
end
|
492
|
+
|
493
|
+
# Initializes a couple of instance variables containing directional information, which are used by the contour algorithm.
|
494
|
+
#
|
495
|
+
# The directional vectors of indices are used for extracting a vector of neighbour pixels from a 3*3 pixel array,
|
496
|
+
# where the resulting vector contains 7 neighbour pixels (the previous pixel is removed), in a clockwise order,
|
497
|
+
# where the first pixel is the neighbour pixel that is next to the previous pixel, following a clockwise rotation.
|
498
|
+
#
|
499
|
+
def initialize_contour_reorder_structures
|
500
|
+
@reorder = Hash.new
|
501
|
+
@reorder[:west] = NArray[0,1,2,5,8,7,6,3]
|
502
|
+
@reorder[:nw] = NArray[1,2,5,8,7,6,3,0]
|
503
|
+
@reorder[:north] = NArray[2,5,8,7,6,3,0,1]
|
504
|
+
@reorder[:ne] = NArray[5,8,7,6,3,0,1,2]
|
505
|
+
@reorder[:east] = NArray[8,7,6,3,0,1,2,5]
|
506
|
+
@reorder[:se] = NArray[7,6,3,0,1,2,5,8]
|
507
|
+
@reorder[:south] = NArray[6,3,0,1,2,5,8,7]
|
508
|
+
@reorder[:sw] = NArray[3,0,1,2,5,8,7,6]
|
509
|
+
@arrived_from_directions = Hash.new
|
510
|
+
@arrived_from_directions[0] = :se
|
511
|
+
@arrived_from_directions[1] = :south
|
512
|
+
@arrived_from_directions[2] = :sw
|
513
|
+
@arrived_from_directions[3] = :east
|
514
|
+
@arrived_from_directions[5] = :west
|
515
|
+
@arrived_from_directions[6] = :ne
|
516
|
+
@arrived_from_directions[7] = :north
|
517
|
+
@arrived_from_directions[8] = :nw
|
518
|
+
# Set up the index of pixels in a neighborhood image extract:
|
519
|
+
@relative_indices = NArray.int(3, 3).indgen!
|
520
|
+
end
|
521
|
+
|
522
|
+
# Determines all pixel indices which belongs to the specified (continuous) contour indices.
|
523
|
+
#
|
524
|
+
# === Notes
|
525
|
+
#
|
526
|
+
# This is achieved by applying an external contour (around the original contour),
|
527
|
+
# and identifying the indices that are enclosed by this external contour. This identification
|
528
|
+
# is carried out by scanning line by line, and marking pixels which lies between two external
|
529
|
+
# contour points.
|
530
|
+
#
|
531
|
+
# * Uses Roman Khudeevs algorithm: A New Flood-Fill Algorithm for Closed Contour
|
532
|
+
# * https://docs.google.com/viewer?a=v&q=cache:UZ6bo7pXRoIJ:file.lw23.com/file1/01611214.pdf+flood+fill+from+contour+coordinate&hl=no&gl=no&pid=bl&srcid=ADGEEShV4gbKYYq8cDagjT7poT677cIL44K0QW8SR0ODanFy-CD1CHEQi2RvHF8MND7_PXPGYRJMJAcMJO-NEXkM-vU4iA2rNljVetbzuARWuHtKLJKMTNjd3vaDWrIeSU4rKLCVwvff&sig=AHIEtbSAnH6fp584c0_Krv298n-tgpNcJw&pli=1
|
533
|
+
#
|
534
|
+
def roi_indices(contour)
|
535
|
+
raise ArgumentError, "Invalid argument 'contour'. Expected Selection, got #{contour.class}." unless contour.is_a?(Selection)
|
536
|
+
ext_value = 3
|
537
|
+
int_value = 2
|
538
|
+
roi_value = 1
|
539
|
+
# Determine external contour:
|
540
|
+
ext_contour = external_contour
|
541
|
+
row_indices = ext_contour.rows
|
542
|
+
# Apply the two contours to an image:
|
543
|
+
img = NArray.byte(columns, rows)
|
544
|
+
img[ext_contour.indices] = ext_value
|
545
|
+
img[contour.indices] = int_value
|
546
|
+
# Iterate row by row where the contour is defined:
|
547
|
+
(row_indices.min..row_indices.max).each do |row_index|
|
548
|
+
img_vector = img[true, row_index]
|
549
|
+
row_ext_indices = (img_vector.gt 0).where
|
550
|
+
# Iterate the column:
|
551
|
+
ext_left = nil
|
552
|
+
int_found = nil
|
553
|
+
(row_ext_indices.min..row_ext_indices.max).each do |col_index|
|
554
|
+
if img_vector[col_index] == ext_value and !int_found
|
555
|
+
ext_left = col_index
|
556
|
+
elsif img_vector[col_index] == int_value
|
557
|
+
int_found = true
|
558
|
+
elsif img_vector[col_index] == ext_value and int_found
|
559
|
+
# We have identified a span of pixels which belong to the internal contour:
|
560
|
+
img[(ext_left+1)..(col_index-1), row_index] = roi_value
|
561
|
+
# Reset our indicators:
|
562
|
+
ext_left = col_index
|
563
|
+
int_found = nil
|
564
|
+
end
|
565
|
+
end
|
566
|
+
end
|
567
|
+
# Extract and return our roi indices:
|
568
|
+
return Selection.create_from_array((img.eq roi_value).where.to_a, self)
|
569
|
+
end
|
570
|
+
|
571
|
+
# Returns the attributes of this instance in an array (for comparison purposes).
|
572
|
+
#
|
573
|
+
def state
|
574
|
+
[@narray]
|
575
|
+
end
|
576
|
+
|
577
|
+
end
|
578
|
+
end
|