Coverage for src/bob/bio/vein/extractor/MaximumCurvature.py: 79%

117 statements  

« prev     ^ index     » next       coverage.py v7.6.0, created at 2024-07-12 23:27 +0200

1#!/usr/bin/env python 

2# vim: set fileencoding=utf-8 : 

3 

4import math 

5 

6import numpy 

7import scipy.ndimage 

8 

9from bob.bio.base.extractor import Extractor 

10 

11 

12class MaximumCurvature(Extractor): 

13 """ 

14 MiuraMax feature extractor. 

15 

16 Based on N. Miura, A. Nagasaka, and T. Miyatake, Extraction of Finger-Vein 

17 Pattern Using Maximum Curvature Points in Image Profiles. Proceedings on IAPR 

18 conference on machine vision applications, 9 (2005), pp. 347--350. 

19 

20 

21 Parameters: 

22 

23 sigma (:py:class:`int`, optional): standard deviation for the gaussian 

24 smoothing kernel used to denoise the input image. The width of the 

25 gaussian kernel will be set automatically to 4x this value (in pixels). 

26 

27 """ 

28 

29 def __init__(self, sigma=5): 

30 Extractor.__init__(self, sigma=sigma) 

31 self.sigma = sigma 

32 

33 def detect_valleys(self, image, mask): 

34 r"""Detects valleys on the image respecting the mask 

35 

36 This step corresponds to Step 1-1 in the original paper. The objective is, 

37 for all 4 cross-sections (z) of the image (horizontal, vertical, 45 and -45 

38 diagonals), to compute the following proposed valley detector as defined in 

39 Equation 1, page 348: 

40 

41 .. math:: 

42 

43 \kappa(z) = \\frac{d^2P_f(z)/dz^2}{(1 + (dP_f(z)/dz)^2)^\\frac{3}{2}} 

44 

45 

46 We start the algorithm by smoothing the image with a 2-dimensional gaussian 

47 filter. The equation that defines the kernel for the filter is: 

48 

49 .. math:: 

50 

51 \mathcal{N}(x,y)=\\frac{1}{2\pi\sigma^2}e^\\frac{-(x^2+y^2)}{2\sigma^2} 

52 

53 

54 This is done to avoid noise from the raw data (from the sensor). The 

55 maximum curvature method then requires we compute the first and second 

56 derivative of the image for all cross-sections, as per the equation above. 

57 

58 We instead take the following equivalent approach: 

59 

60 1. construct a gaussian filter 

61 2. take the first (dh/dx) and second (d^2/dh^2) deritivatives of the filter 

62 3. calculate the first and second derivatives of the smoothed signal using 

63 the results from 3. This is done for all directions we're interested in: 

64 horizontal, vertical and 2 diagonals. First and second derivatives of a 

65 convolved signal 

66 

67 .. note:: 

68 

69 Item 3 above is only possible thanks to the steerable filter property of 

70 the gaussian kernel. See "The Design and Use of Steerable Filters" from 

71 Freeman and Adelson, IEEE Transactions on Pattern Analysis and Machine 

72 Intelligence, Vol. 13, No. 9, September 1991. 

73 

74 

75 Parameters: 

76 

77 image (numpy.ndarray): an array of 64-bit floats containing the input 

78 image 

79 mask (numpy.ndarray): an array, of the same size as ``image``, containing 

80 a mask (booleans) indicating where the finger is on ``image``. 

81 

82 

83 Returns: 

84 

85 numpy.ndarray: a 3-dimensional array of 64-bits containing $\kappa$ for 

86 all considered directions. $\kappa$ has the same shape as ``image``, 

87 except for the 3rd. dimension, which provides planes for the 

88 cross-section valley detections for each of the contemplated directions, 

89 in this order: horizontal, vertical, +45 degrees, -45 degrees. 

90 

91 """ 

92 

93 # 1. constructs the 2D gaussian filter "h" given the window size, 

94 # extrapolated from the "sigma" parameter (4x) 

95 # N.B.: This is a text-book gaussian filter definition 

96 winsize = numpy.ceil(4 * self.sigma) # enough space for the filter 

97 window = numpy.arange(-winsize, winsize + 1) 

98 X, Y = numpy.meshgrid(window, window) 

99 G = 1.0 / (2 * math.pi * self.sigma**2) 

100 G *= numpy.exp(-(X**2 + Y**2) / (2 * self.sigma**2)) 

101 

102 # 2. calculates first and second derivatives of "G" with respect to "X" 

103 # (0), "Y" (90 degrees) and 45 degrees (?) 

104 G1_0 = (-X / (self.sigma**2)) * G 

105 G2_0 = ((X**2 - self.sigma**2) / (self.sigma**4)) * G 

106 G1_90 = G1_0.T 

107 G2_90 = G2_0.T 

108 hxy = ((X * Y) / (self.sigma**4)) * G 

109 

110 # 3. calculates derivatives w.r.t. to all directions of interest 

111 # stores results in the variable "k". The entries (last dimension) in k 

112 # correspond to curvature detectors in the following directions: 

113 # 

114 # [0] horizontal 

115 # [1] vertical 

116 # [2] diagonal \ (45 degrees rotation) 

117 # [3] diagonal / (-45 degrees rotation) 

118 image_g1_0 = scipy.ndimage.convolve(image, G1_0, mode="nearest") 

119 image_g2_0 = scipy.ndimage.convolve(image, G2_0, mode="nearest") 

120 image_g1_90 = scipy.ndimage.convolve(image, G1_90, mode="nearest") 

121 image_g2_90 = scipy.ndimage.convolve(image, G2_90, mode="nearest") 

122 fxy = scipy.ndimage.convolve(image, hxy, mode="nearest") 

123 

124 # support calculation for diagonals, given the gaussian kernel is 

125 # steerable. To calculate the derivatives for the "\" diagonal, we first 

126 # **would** have to rotate the image 45 degrees counter-clockwise (so the 

127 # diagonal lies on the horizontal axis). Using the steerable property, we 

128 # can evaluate the first derivative like this: 

129 # 

130 # image_g1_45 = cos(45)*image_g1_0 + sin(45)*image_g1_90 

131 # = sqrt(2)/2*fx + sqrt(2)/2*fx 

132 # 

133 # to calculate the first derivative for the "/" diagonal, we first 

134 # **would** have to rotate the image -45 degrees "counter"-clockwise. 

135 # Therefore, we can calculate it like this: 

136 # 

137 # image_g1_m45 = cos(-45)*image_g1_0 + sin(-45)*image_g1_90 

138 # = sqrt(2)/2*image_g1_0 - sqrt(2)/2*image_g1_90 

139 # 

140 

141 image_g1_45 = 0.5 * numpy.sqrt(2) * (image_g1_0 + image_g1_90) 

142 image_g1_m45 = 0.5 * numpy.sqrt(2) * (image_g1_0 - image_g1_90) 

143 

144 # NOTE: You can't really get image_g2_45 and image_g2_m45 from the theory 

145 # of steerable filters. In contact with B.Ton, he suggested the following 

146 # material, where that is explained: Chapter 5.2.3 of van der Heijden, F. 

147 # (1994) Image based measurement systems: object recognition and parameter 

148 # estimation. John Wiley & Sons Ltd, Chichester. ISBN 978-0-471-95062-2 

149 

150 # This also shows the same result: 

151 # http://www.mif.vu.lt/atpazinimas/dip/FIP/fip-Derivati.html (look for 

152 # SDGD) 

153 

154 # He also suggested to look at slide 75 of the following presentation 

155 # indicating it is self-explanatory: http://slideplayer.com/slide/5084635/ 

156 

157 image_g2_45 = 0.5 * image_g2_0 + fxy + 0.5 * image_g2_90 

158 image_g2_m45 = 0.5 * image_g2_0 - fxy + 0.5 * image_g2_90 

159 

160 # ###################################################################### 

161 # [Step 1-1] Calculation of curvature profiles 

162 # ###################################################################### 

163 

164 # Peak detection (k or kappa) calculation as per equation (1) page 348 on 

165 # Miura's paper 

166 finger_mask = mask.astype("float64") 

167 

168 return numpy.dstack( 

169 [ 

170 (image_g2_0 / ((1 + image_g1_0**2) ** (1.5))) * finger_mask, 

171 (image_g2_90 / ((1 + image_g1_90**2) ** (1.5))) * finger_mask, 

172 (image_g2_45 / ((1 + image_g1_45**2) ** (1.5))) * finger_mask, 

173 (image_g2_m45 / ((1 + image_g1_m45**2) ** (1.5))) 

174 * finger_mask, 

175 ] 

176 ) 

177 

178 def eval_vein_probabilities(self, k): 

179 r"""Evaluates joint vein centre probabilities from cross-sections 

180 

181 This function will take $\kappa$ and will calculate the vein centre 

182 probabilities taking into consideration valley widths and depths. It 

183 aggregates the following steps from the paper: 

184 

185 * [Step 1-2] Detection of the centres of veins 

186 * [Step 1-3] Assignment of scores to the centre positions 

187 * [Step 1-4] Calculation of all the profiles 

188 

189 Once the arrays of curvatures (concavities) are calculated, here is how 

190 detection works: The code scans the image in a precise direction (vertical, 

191 horizontal, diagonal, etc). It tries to find a concavity on that direction 

192 and measure its width (see Wr on Figure 3 on the original paper). It then 

193 identifies the centers of the concavity and assign a value to it, which 

194 depends on its width (Wr) and maximum depth (where the peak of darkness 

195 occurs) in such a concavity. This value is accumulated on a variable (Vt), 

196 which is re-used for all directions. Vt represents the vein probabilites 

197 from the paper. 

198 

199 

200 Parameters: 

201 

202 k (numpy.ndarray): a 3-dimensional array of 64-bits containing $\kappa$ 

203 for all considered directions. $\kappa$ has the same shape as 

204 ``image``, except for the 3rd. dimension, which provides planes for the 

205 cross-section valley detections for each of the contemplated 

206 directions, in this order: horizontal, vertical, +45 degrees, -45 

207 degrees. 

208 

209 

210 Returns: 

211 

212 numpy.ndarray: The un-accumulated vein centre probabilities ``V``. This 

213 is a 3D array with 64-bit floats with the same dimensions of the input 

214 array ``k``. You must accumulate (sum) over the last dimension to 

215 retrieve the variable ``V`` from the paper. 

216 

217 """ 

218 

219 V = numpy.zeros(k.shape[:2], dtype="float64") 

220 

221 def _prob_1d(a): 

222 """Finds "vein probabilities" in a 1-D signal 

223 

224 This function efficiently counts the width and height of concavities in 

225 the cross-section (1-D) curvature signal ``s``. 

226 

227 It works like this: 

228 

229 1. We create a 1-shift difference between the thresholded signal and 

230 itself 

231 2. We compensate for starting and ending regions 

232 3. For each sequence of start/ends, we compute the maximum in the 

233 original signal 

234 

235 Example (mixed with pseudo-code): 

236 

237 a = 0 1 2 3 2 1 0 -1 0 0 1 2 5 2 2 2 1 

238 b = a > 0 (as type int) 

239 b = 0 1 1 1 1 1 0 0 0 0 1 1 1 1 1 1 1 

240 

241 0 1 1 1 1 1 0 0 0 0 1 1 1 1 1 1 1 

242 0 1 1 1 1 1 0 0 0 0 1 1 1 1 1 1 1 (-) 

243 ------------------------------------------- 

244 X 1 0 0 0 0 -1 0 0 0 1 0 0 0 0 0 0 X (length is smaller than orig.) 

245 

246 starts = numpy.where(diff > 0) 

247 ends = numpy.where(diff < 0) 

248 

249 -> now the number of starts and ends should match, otherwise, we must 

250 compensate 

251 

252 -> case 1: b starts with 1: add one start in begin of "starts" 

253 -> case 2: b ends with 1: add one end in the end of "ends" 

254 

255 -> iterate over the sequence of starts/ends and find maximums 

256 

257 

258 Parameters: 

259 

260 a (numpy.ndarray): 1D signal with curvature to explore 

261 

262 

263 Returns: 

264 

265 numpy.ndarray: 1D container with the vein centre probabilities 

266 

267 """ 

268 

269 b = (a > 0).astype(int) 

270 diff = b[1:] - b[:-1] 

271 starts = numpy.argwhere(diff > 0) 

272 starts += 1 # compensates for shifted different 

273 ends = numpy.argwhere(diff < 0) 

274 ends += 1 # compensates for shifted different 

275 if b[0]: 

276 starts = numpy.insert(starts, 0, 0) 

277 if b[-1]: 

278 ends = numpy.append(ends, len(a)) 

279 

280 z = numpy.zeros_like(a) 

281 

282 if starts.size == 0 and ends.size == 0: 

283 return z 

284 

285 for start, end in zip(starts, ends): 

286 maximum = numpy.argmax(a[int(start) : int(end)]) 

287 z[start + maximum] = a[start + maximum] * (end - start) 

288 

289 return z 

290 

291 # Horizontal direction 

292 for index in range(k.shape[0]): 

293 V[index, :] += _prob_1d(k[index, :, 0]) 

294 

295 # Vertical direction 

296 for index in range(k.shape[1]): 

297 V[:, index] += _prob_1d(k[:, index, 1]) 

298 

299 # Direction: 45 degrees (\) 

300 curv = k[:, :, 2] 

301 i, j = numpy.indices(curv.shape) 

302 for index in range(-curv.shape[0] + 1, curv.shape[1]): 

303 V[i == (j - index)] += _prob_1d(curv.diagonal(index)) 

304 

305 # Direction: -45 degrees (/) 

306 # NOTE: due to the way the access to the diagonals are implemented, in this 

307 # loop, we operate bottom-up. To match this behaviour, we also address V 

308 # through Vud. 

309 curv = numpy.flipud( 

310 k[:, :, 3] 

311 ) # required so we get "/" diagonals correctly 

312 Vud = numpy.flipud(V) # match above inversion 

313 for index in reversed(range(curv.shape[1] - 1, -curv.shape[0], -1)): 

314 Vud[i == (j - index)] += _prob_1d(curv.diagonal(index)) 

315 

316 return V 

317 

318 def connect_centres(self, V): 

319 r"""Connects vein centres by filtering vein probabilities ``V`` 

320 

321 This function does the equivalent of Step 2 / Equation 4 at Miura's paper. 

322 

323 The operation is applied on a row from the ``V`` matrix, which may be 

324 acquired horizontally, vertically or on a diagonal direction. The pixel 

325 value is then reset in the center of a windowing operation (width = 5) with 

326 the following value: 

327 

328 .. math:: 

329 

330 b[w] = min(max(a[w+1], a[w+2]) + max(a[w-1], a[w-2])) 

331 

332 

333 Parameters: 

334 

335 V (numpy.ndarray): The accumulated vein centre probabilities ``V``. This 

336 is a 2D array with 64-bit floats and is defined by Equation (3) on the 

337 paper. 

338 

339 

340 Returns: 

341 

342 numpy.ndarray: A 3-dimensional 64-bit array ``Cd`` containing the result 

343 of the filtering operation for each of the directions. ``Cd`` has the 

344 dimensions of $\kappa$ and $V_i$. Each of the planes correspond to the 

345 horizontal, vertical, +45 and -45 directions. 

346 

347 """ 

348 

349 def _connect_1d(a): 

350 """Connects centres in the given vector 

351 

352 The strategy we use to vectorize this is to shift a twice to the left and 

353 twice to the right and apply a vectorized operation to compute the above. 

354 

355 

356 Parameters: 

357 

358 a (numpy.ndarray): Input 1D array which will be window scanned 

359 

360 

361 Returns: 

362 

363 numpy.ndarray: Output 1D array (must be writeable), in which we will 

364 set the corrected pixel values after the filtering above. Notice that, 

365 given the windowing operation, the returned array size would be 4 short 

366 of the input array. 

367 

368 """ 

369 

370 return numpy.amin( 

371 [ 

372 numpy.amax([a[3:-1], a[4:]], axis=0), 

373 numpy.amax([a[1:-3], a[:-4]], axis=0), 

374 ], 

375 axis=0, 

376 ) 

377 

378 Cd = numpy.zeros(V.shape + (4,), dtype="float64") 

379 

380 # Horizontal direction 

381 for index in range(V.shape[0]): 

382 Cd[index, 2:-2, 0] = _connect_1d(V[index, :]) 

383 

384 # Vertical direction 

385 for index in range(V.shape[1]): 

386 Cd[2:-2, index, 1] = _connect_1d(V[:, index]) 

387 

388 # Direction: 45 degrees (\) 

389 i, j = numpy.indices(V.shape) 

390 border = numpy.zeros((2,), dtype="float64") 

391 for index in range(-V.shape[0] + 5, V.shape[1] - 4): 

392 # NOTE: hstack **absolutately** necessary here as double indexing after 

393 # array indexing is **not** possible with numpy (it returns a copy) 

394 Cd[:, :, 2][i == (j - index)] = numpy.hstack( 

395 [border, _connect_1d(V.diagonal(index)), border] 

396 ) 

397 

398 # Direction: -45 degrees (/) 

399 Vud = numpy.flipud(V) 

400 Cdud = numpy.flipud(Cd[:, :, 3]) 

401 for index in reversed(range(V.shape[1] - 5, -V.shape[0] + 4, -1)): 

402 # NOTE: hstack **absolutately** necessary here as double indexing after 

403 # array indexing is **not** possible with numpy (it returns a copy) 

404 Cdud[:, :][i == (j - index)] = numpy.hstack( 

405 [border, _connect_1d(Vud.diagonal(index)), border] 

406 ) 

407 

408 return Cd 

409 

410 def binarise(self, G): 

411 """Binarise vein images using a threshold assuming distribution is diphasic 

412 

413 This function implements Step 3 of the paper. It binarises the 2-D array 

414 ``G`` assuming its histogram is mostly diphasic and using a median value. 

415 

416 

417 Parameters: 

418 

419 G (numpy.ndarray): A 2-dimensional 64-bit array ``G`` containing the 

420 result of the filtering operation. ``G`` has the dimensions of the 

421 original image. 

422 

423 

424 Returns: 

425 

426 numpy.ndarray: A 2-dimensional 64-bit float array with the same 

427 dimensions of the input image, but containing its vein-binarised version. 

428 The output of this function corresponds to the output of the method. 

429 

430 """ 

431 

432 median = numpy.median(G[G > 0]) 

433 Gbool = G > median 

434 return Gbool.astype(numpy.float64) 

435 

436 def _view_four(self, k, suptitle): 

437 """Display four plots using matplotlib""" 

438 

439 import matplotlib.pyplot as plt 

440 

441 k[k <= 0] = 0 

442 k /= k.max() 

443 

444 plt.subplot(2, 2, 1) 

445 plt.imshow(k[..., 0], cmap="gray") 

446 plt.title("Horizontal") 

447 

448 plt.subplot(2, 2, 2) 

449 plt.imshow(k[..., 1], cmap="gray") 

450 plt.title("Vertical") 

451 

452 plt.subplot(2, 2, 3) 

453 plt.imshow(k[..., 2], cmap="gray") 

454 plt.title("+45 degrees") 

455 

456 plt.subplot(2, 2, 4) 

457 plt.imshow(k[..., 3], cmap="gray") 

458 plt.title("-45 degrees") 

459 

460 plt.suptitle(suptitle) 

461 plt.tight_layout() 

462 plt.show() 

463 

464 def _view_single(self, k, title): 

465 """Displays a single plot using matplotlib""" 

466 

467 import matplotlib.pyplot as plt 

468 

469 plt.imshow(k, cmap="gray") 

470 plt.title(title) 

471 plt.tight_layout() 

472 plt.show() 

473 

474 def __call__(self, image): 

475 finger_image = image[0].astype("float64") 

476 finger_mask = image[1] 

477 

478 # import time 

479 # start = time.time() 

480 

481 kappa = self.detect_valleys(finger_image, finger_mask) 

482 

483 # self._view_four(kappa, "Valley Detectors - $\kappa$") 

484 

485 # print('filtering took %.2f seconds' % (time.time() - start)) 

486 # start = time.time() 

487 

488 V = self.eval_vein_probabilities(kappa) 

489 

490 # self._view_single(V, "Accumulated Probabilities - V") 

491 

492 # print('probabilities took %.2f seconds' % (time.time() - start)) 

493 # start = time.time() 

494 

495 Cd = self.connect_centres(V) 

496 

497 # self._view_four(Cd, "Connected Centers - $C_{di}$") 

498 # self._view_single(numpy.amax(Cd, axis=2), "Connected Centers - G") 

499 

500 # print('connections took %.2f seconds' % (time.time() - start)) 

501 # start = time.time() 

502 

503 retval = self.binarise(numpy.amax(Cd, axis=2)) 

504 

505 # self._view_single(retval, "Final Binarised Image") 

506 

507 # print('binarization took %.2f seconds' % (time.time() - start)) 

508 

509 return retval