rtkit 0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/CHANGELOG.rdoc +10 -0
- data/COPYING +674 -0
- data/README.rdoc +107 -0
- data/lib/rtkit.rb +68 -0
- data/lib/rtkit/beam.rb +346 -0
- data/lib/rtkit/bin_image.rb +578 -0
- data/lib/rtkit/bin_matcher.rb +241 -0
- data/lib/rtkit/bin_volume.rb +263 -0
- data/lib/rtkit/collimator.rb +157 -0
- data/lib/rtkit/collimator_setup.rb +143 -0
- data/lib/rtkit/constants.rb +215 -0
- data/lib/rtkit/contour.rb +213 -0
- data/lib/rtkit/control_point.rb +371 -0
- data/lib/rtkit/coordinate.rb +83 -0
- data/lib/rtkit/data_set.rb +264 -0
- data/lib/rtkit/dose.rb +70 -0
- data/lib/rtkit/dose_distribution.rb +206 -0
- data/lib/rtkit/dose_volume.rb +280 -0
- data/lib/rtkit/frame.rb +164 -0
- data/lib/rtkit/image.rb +372 -0
- data/lib/rtkit/image_series.rb +290 -0
- data/lib/rtkit/logging.rb +158 -0
- data/lib/rtkit/methods.rb +105 -0
- data/lib/rtkit/mixins/image_parent.rb +40 -0
- data/lib/rtkit/patient.rb +229 -0
- data/lib/rtkit/pixel_data.rb +237 -0
- data/lib/rtkit/plan.rb +259 -0
- data/lib/rtkit/plane.rb +165 -0
- data/lib/rtkit/roi.rb +388 -0
- data/lib/rtkit/rt_dose.rb +237 -0
- data/lib/rtkit/rt_image.rb +179 -0
- data/lib/rtkit/ruby_extensions.rb +165 -0
- data/lib/rtkit/selection.rb +189 -0
- data/lib/rtkit/series.rb +77 -0
- data/lib/rtkit/setup.rb +198 -0
- data/lib/rtkit/slice.rb +184 -0
- data/lib/rtkit/staple.rb +305 -0
- data/lib/rtkit/structure_set.rb +442 -0
- data/lib/rtkit/study.rb +214 -0
- data/lib/rtkit/variables.rb +23 -0
- data/lib/rtkit/version.rb +6 -0
- metadata +159 -0
@@ -0,0 +1,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
|