diff --git a/uni_ete_fingerprint_TP1.ipynb b/uni_ete_fingerprint_TP1.ipynb
deleted file mode 100644
index fad190949c8063efaa5d1b8154e6249f01f8cbbb..0000000000000000000000000000000000000000
--- a/uni_ete_fingerprint_TP1.ipynb
+++ /dev/null
@@ -1 +0,0 @@
-{"cells":[{"cell_type":"markdown","metadata":{"id":"I-9uxGnBMZoN"},"source":["# Installation\n","\n","- Install conda or mini-conda: https://docs.conda.io/en/latest/miniconda.html\n","- Install dependencies: conda install -c conda-forge -y opencv notebook ipywidgets matplotlib\n","- Place the file \"utils.py\" in the same location or add it to PYTHONPATH"]},{"cell_type":"code","source":["from google.colab import drive\n","drive.mount('/content/drive/')\n","\n","# Adapter ce chemin à votre hiérarchie\n","%cd /content/drive/MyDrive/uni-ete\n","%ls"],"metadata":{"id":"XLWOKNCuMcJh"},"execution_count":null,"outputs":[]},{"cell_type":"code","execution_count":null,"metadata":{"id":"g7Pxow1HRRin"},"outputs":[],"source":["import math\n","import numpy as np\n","import cv2 as cv\n","import matplotlib.pyplot as plt\n","from utils import *\n","from ipywidgets import interact"]},{"cell_type":"markdown","metadata":{"id":"QgMXnN9wSgrS"},"source":["# Step 1: Fingerprint segmentation"]},{"cell_type":"code","execution_count":null,"metadata":{"id":"E2gtenrUMZoP"},"outputs":[],"source":["fingerprint = cv.imread('samples/sample_1_1.png', cv.IMREAD_GRAYSCALE)\n","show(fingerprint, f'Fingerprint with size (w,h): {fingerprint.shape[::-1]}')"]},{"cell_type":"code","execution_count":null,"metadata":{"id":"wpgIAkrBsnvw"},"outputs":[],"source":["# Calculate the local gradient (using Sobel filters)\n","\"\"\"\n","    On applique le filtre de sobel pour mettre en évidence les contours de l'empreinte digitale.\n","\"\"\"\n","gx, gy = cv.Sobel(fingerprint, cv.CV_64F, 1, 0), cv.Sobel(fingerprint, cv.CV_64F, 0, 1)\n","show((gx, 'Gx'), (gy, 'Gy'))"]},{"cell_type":"code","execution_count":null,"metadata":{"id":"LQ1s0yjIs8j4"},"outputs":[],"source":["# Calculate the magnitude of the gradient for each pixel\n","gx2, gy2 = gx**2, gy**2\n","gm = np.sqrt(gx2 + gy2)\n","show((gx2, 'Gx**2'), (gy2, 'Gy**2'), (gm, 'Gradient magnitude'))"]},{"cell_type":"code","execution_count":null,"metadata":{"id":"GPhP0Ubks_nA"},"outputs":[],"source":["# Integral over a square window\n","window = (25,25)\n","sum_gm = cv.boxFilter(gm, -1, window)\n","show(sum_gm, 'Sum of the gradient magnitude')"]},{"cell_type":"code","execution_count":null,"metadata":{"id":"5YafVxEOtC_4"},"outputs":[],"source":["# Use a simple threshold for segmenting the fingerprint pattern\n","\n","\"\"\"\n","    On calcule un masque qui permettra de déterminer les contours extérieurs de l'empreinte\n","\"\"\"\n","\n","thr = np.max(sum_gm) * 0.2\n","mask = cv.threshold(sum_gm, thr, 255, cv.THRESH_BINARY)[1].astype(np.uint8)\n","show(fingerprint, mask, cv.merge((mask, fingerprint, fingerprint)))"]},{"cell_type":"markdown","metadata":{"id":"jJ2D5OHU6inW"},"source":["# Step 2: Estimation of local ridge orientation"]},{"cell_type":"markdown","metadata":{"id":"x_ajHsYAYwCD"},"source":["The ridge orientation is estimated as ortoghonal to the gradient orientation, averaged over a window $W$.  \n","\n","$G_{xx}=\\sum_W{G_x^2}$\n","\n","$G_{yy}=\\sum_W{G_y^2}$\n","\n","$G_{xy}=\\sum_W{G_xG_y}$\n","\n","$\\theta=\\frac{\\pi}{2} + \\frac{phase(G_{xx}-G_{yy}, 2G_{xy})}{2}$\n","\n","For each orientation, we will also calculate a confidence value (strength), which measures how much all gradients in $W$ share the same orientation.  \n","\n","$strength = \\frac{\\sqrt{(G_{xx}-G_{yy})^2+(2G_{xy})^2}}{G_{xx}+G_{yy}}$"]},{"cell_type":"code","execution_count":null,"metadata":{"id":"jnyEfap-tFSw"},"outputs":[],"source":["W = (23, 23)\n","\n","# gxx = cv.GaussianBlur(gx2, W, 0)\n","# gyy = cv.GaussianBlur(gy2, W, 0)\n","\n","# gxy = cv.GaussianBlur(gx * gy, W, 0)\n","\n","gxx = cv.blur(gx2, W)\n","gyy = cv.blur(gy2, W)\n","\n","gxy = cv.blur(gx * gy, W)\n","\n","orientations = (-1) * 0.5 * np.arctan2(2 * gxy, gxx - gyy) + np.pi / 2\n","strengths = np.sqrt((gxx - gyy) ** 2 + (2 * gxy) ** 2) / (gxx + gyy + 1e-5)\n","\n","show(draw_orientations(fingerprint, orientations, strengths, mask, 1, 16), 'Orientation image')"]},{"cell_type":"markdown","metadata":{"id":"SDHYsNmJ7TaV"},"source":["# Step 3: Estimation of local ridge frequency"]},{"cell_type":"code","execution_count":null,"metadata":{"id":"ezc3YDWXCTEm"},"outputs":[],"source":["# region = cv.bitwise_and(fingerprint, fingerprint, mask=mask\n","region = fingerprint[80:200, 100:220]\n","show(region)"]},{"cell_type":"code","execution_count":null,"metadata":{"id":"Pvv7fS0KQpJ3"},"outputs":[],"source":["# smoothed = cv.medianBlur(region, 5)\n","smoothed = cv.GaussianBlur(region, (5, 5), 0)\n","\n","xs = np.sum(smoothed, axis=1)\n","print(xs)"]},{"cell_type":"code","execution_count":null,"metadata":{"id":"8-hQrph0DSZO"},"outputs":[],"source":["x = np.arange(region.shape[0])\n","f, axarr = plt.subplots(1,2, sharey = True)\n","axarr[0].imshow(region,cmap='gray')\n","axarr[1].plot(xs, x)\n","# axarr[1].plot(x, xs)\n","axarr[1].set_ylim(region.shape[0]-1,0)\n","plt.show()"]},{"cell_type":"code","execution_count":null,"metadata":{"id":"zrM-B9JKRXth"},"outputs":[],"source":["# local_maxima = (np.diff(np.sign(np.diff(xs))) < 0).nonzero()[0] + 1\n","local_maxima = np.where((xs[:-2] < xs[1:-1]) & (xs[1:-1] > xs[2:]))[0] + 1\n","print(local_maxima)"]},{"cell_type":"code","execution_count":null,"metadata":{"id":"pasX1AILRQdq"},"outputs":[],"source":["x = np.arange(region.shape[0])\n","plt.plot(x, xs)\n","plt.xticks(local_maxima)\n","plt.grid(True, axis='x')\n","plt.show()"]},{"cell_type":"code","execution_count":null,"metadata":{"id":"w6CPgUQgtYCo"},"outputs":[],"source":["# sum = 0\n","# deltas = []\n","\n","# for i in range(len(local_maxima) - 1):\n","#   deltas.append(local_maxima[i + 1] - local_maxima[i])\n","#   sum += local_maxima[i + 1] - local_maxima[i]\n","\n","# distances = sum / len(deltas)\n","# print(distances)\n","\n","distances = local_maxima[1:] - local_maxima[:-1]\n","print(distances)"]},{"cell_type":"markdown","source":["<!--  -->"],"metadata":{"id":"SaobQYWyE_Oc"}},{"cell_type":"code","execution_count":null,"metadata":{"id":"QlfcrCgPVozS"},"outputs":[],"source":["ridge_period = distances.mean()\n","print(ridge_period)"]},{"cell_type":"markdown","metadata":{"id":"QShcuAYY7wd0"},"source":["# Step 4: Fingerprint enhancement"]},{"cell_type":"code","execution_count":null,"metadata":{"id":"htZDESSYtesf"},"outputs":[],"source":["# Create the filter bank\n","or_count = 8\n","gabor_bank = [gabor_kernel(ridge_period, o) for o in np.arange(0, np.pi, np.pi/or_count)]"]},{"cell_type":"code","execution_count":null,"metadata":{"id":"l7Ldq1VNtgEQ"},"outputs":[],"source":["show(*gabor_bank)"]},{"cell_type":"code","execution_count":null,"metadata":{"id":"gmeTXLIvthI_"},"outputs":[],"source":["\n","nf = 255-fingerprint\n","all_filtered = np.array([cv.filter2D(nf, cv.CV_32F, f) for f in gabor_bank])\n","show(nf, *all_filtered)"]},{"cell_type":"code","execution_count":null,"metadata":{"id":"usS93oHDt7VQ"},"outputs":[],"source":["y_coords, x_coords = np.indices(fingerprint.shape)\n","\n","orientation_idx = np.round(((orientations % np.pi) / np.pi) * or_count).astype(np.int32) % or_count\n","filtered = all_filtered[orientation_idx, y_coords, x_coords]\n","enhanced = mask & np.clip(filtered, 0, 255).astype(np.uint8)\n","show(fingerprint, filtered, enhanced)"]},{"cell_type":"markdown","metadata":{"id":"LQvHUusjyBfR"},"source":["# Step 5: Detection of minutiae positions"]},{"cell_type":"code","execution_count":null,"metadata":{"id":"jmCPCdMFuCMe"},"outputs":[],"source":["# Binarization\n","_, ridge_lines = cv.threshold(enhanced, 32, 255, cv.THRESH_BINARY)\n","show(enhanced, ridge_lines)"]},{"cell_type":"code","execution_count":null,"metadata":{"id":"OtEgLkx7uE_e"},"outputs":[],"source":["# Thinning\n","skeleton = cv.ximgproc.thinning(ridge_lines, thinningType = cv.ximgproc.THINNING_GUOHALL)\n","show(ridge_lines, skeleton)"]},{"cell_type":"code","source":["def compute_crossing_number(values):\n","    return np.count_nonzero(values < np.roll(values, -1))"],"metadata":{"id":"pIIvgsPueUj3"},"execution_count":null,"outputs":[]},{"cell_type":"code","source":["cn_filter = np.array([[  1,  2,  4],\n","                      [128,  0,  8],\n","                      [ 64, 32, 16]\n","                     ])"],"metadata":{"id":"5NYXa5ybeZbP"},"execution_count":null,"outputs":[]},{"cell_type":"code","source":["all_8_neighborhoods = [np.array([int(d) for d in f'{x:08b}'])[::-1] for x in range(256)]\n","cn_lut = np.array([compute_crossing_number(x) for x in all_8_neighborhoods]).astype(np.uint8)"],"metadata":{"id":"wwmjIaSUecxO"},"execution_count":null,"outputs":[]},{"cell_type":"code","source":["# Skeleton: from 0/255 to 0/1 values\n","skeleton01 = np.where(skeleton!=0, 1, 0).astype(np.uint8)\n","# Apply the filter to encode the 8-neighborhood of each pixel into a byte [0,255]\n","neighborhood_values = cv.filter2D(skeleton01, -1, cn_filter, borderType = cv.BORDER_CONSTANT)\n","# Apply the lookup table to obtain the crossing number of each pixel from the byte value of its neighborhood\n","cn = cv.LUT(neighborhood_values, cn_lut)\n","# Keep only crossing numbers on the skeleton\n","cn[skeleton==0] = 0"],"metadata":{"id":"nz7u55ZOef_r"},"execution_count":null,"outputs":[]},{"cell_type":"code","execution_count":null,"metadata":{"id":"4NzceQaJuMk_"},"outputs":[],"source":["# Find minutiae.\n","# List format: [(x: int, y: int, type_terminaison: bool), ...]\n","minutiae = [(x,y,cn[y,x]==1) for y, x in zip(*np.where(np.isin(cn, [1,3])))]"]},{"cell_type":"code","execution_count":null,"metadata":{"id":"_NUdi_JAuPVH"},"outputs":[],"source":["show(draw_minutiae(fingerprint, minutiae), skeleton, draw_minutiae(skeleton, minutiae))"]},{"cell_type":"code","execution_count":null,"metadata":{"id":"VLWwRQBZuQo_"},"outputs":[],"source":["# Create mask.\n","mask_edges = cv.distanceTransform(cv.copyMakeBorder(mask, 1, 1, 1, 1, cv.BORDER_CONSTANT), cv.DIST_C, 3)[1:-1,1:-1]\n","show(mask, mask_edges)"]},{"cell_type":"code","execution_count":null,"metadata":{"id":"4Sb6iyRFuUF_"},"outputs":[],"source":["# Filter minutiae.\n","filtered_minutiae = list(filter(lambda m: mask_edges[m[1], m[0]]>10, minutiae))"]},{"cell_type":"code","execution_count":null,"metadata":{"id":"99BiKiH2uV5H"},"outputs":[],"source":["show(draw_minutiae(fingerprint, filtered_minutiae), skeleton, draw_minutiae(skeleton, filtered_minutiae))"]},{"cell_type":"markdown","metadata":{"id":"_ZikeJ62zp-9"},"source":["# Step 6: Estimation of minutiae directions"]},{"cell_type":"code","source":["def compute_next_ridge_following_directions(previous_direction, values):\n","    next_positions = np.argwhere(values!=0).ravel().tolist()\n","    if len(next_positions) > 0 and previous_direction != 8:\n","        # There is a previous direction: return all the next directions, sorted according to the distance from it,\n","        #                                except the direction, if any, that corresponds to the previous position\n","        next_positions.sort(key = lambda d: 4 - abs(abs(d - previous_direction) - 4))\n","        if next_positions[-1] == (previous_direction + 4) % 8: # the direction of the previous position is the opposite one\n","            next_positions = next_positions[:-1] # removes it\n","    return next_positions"],"metadata":{"id":"0--AIiPl1tjo"},"execution_count":null,"outputs":[]},{"cell_type":"code","execution_count":null,"metadata":{"id":"X24IjohPzp--"},"outputs":[],"source":["r2 = 2**0.5 # sqrt(2)\n","\n","\"\"\"\n","Si nous sommes en p et que nous voulons aller en 7, nous bougeons que d'un pixel,\n","donc la valeur sera (-1, 0, 1).\n","Si nous sommes en p et que nous voulons aller en 4, nous bougeons de 2 pixel via diagonale,\n","donc la valeur sera (1,1, sqrt(2)).\n","\"\"\"\n","# The eight possible (x, y) offsets with each corresponding Euclidean distance\n","xy_steps = [(-1,-1,r2),( 0,-1,1),( 1,-1,r2),( 1, 0,1),( 1, 1,r2),( 0, 1,1),(-1, 1,r2),(-1, 0,1)]\n","\n","# TODO\n","# LUT: for each 8-neighborhood and each previous direction [0,8],\n","#      where 8 means \"none\", provides the list of possible directions\n","nd_lut = [[compute_next_ridge_following_directions(pd, x) for pd in range(9)] for x in all_8_neighborhoods]"]},{"cell_type":"code","source":["def follow_ridge_and_compute_angle(x, y, d = 8):\n","    px, py = x, y\n","    length = 0.0\n","    while length < 20: # max length followed\n","        next_directions = nd_lut[neighborhood_values[py,px]][d]\n","        if len(next_directions) == 0:\n","            break\n","        # Need to check ALL possible next directions\n","        if (any(cn[py + xy_steps[nd][1], px + xy_steps[nd][0]] != 2 for nd in next_directions)):\n","            break # another minutia found: we stop here\n","        # Only the first direction has to be followed\n","        d = next_directions[0]\n","        ox, oy, l = xy_steps[d]\n","        px += ox ; py += oy ; length += l\n","    # check if the minimum length for a valid direction has been reached\n","    return math.atan2(-py+y, px-x) if length >= 10 else None"],"metadata":{"id":"sXnx4-WE14hC"},"execution_count":null,"outputs":[]},{"cell_type":"code","execution_count":null,"metadata":{"id":"4T5fvHFMzp--"},"outputs":[],"source":["# List format: [(x: int, y: int, type_terminaison: bool, direction: int), ...]\n","valid_minutiae = []\n","for x, y, term in filtered_minutiae:\n","    d = None\n","    if term: # termination: simply follow and compute the direction\n","        d = follow_ridge_and_compute_angle(x, y)\n","    else: # bifurcation: follow each of the three branches\n","        dirs = nd_lut[neighborhood_values[y,x]][8] # 8 means: no previous direction\n","        if len(dirs)==3: # only if there are exactly three branches\n","            angles = [follow_ridge_and_compute_angle(x+xy_steps[d][0], y+xy_steps[d][1], d) for d in dirs]\n","            if all(a is not None for a in angles):\n","                a1, a2 = min(((angles[i], angles[(i+1)%3]) for i in range(3)), key=lambda t: angle_abs_difference(t[0], t[1]))\n","                d = angle_mean(a1, a2)\n","    if d is not None:\n","        valid_minutiae.append( (x, y, term, d) )"]},{"cell_type":"code","execution_count":null,"metadata":{"id":"8TccPjw_zp--"},"outputs":[],"source":["show(draw_minutiae(fingerprint, valid_minutiae))"]},{"cell_type":"markdown","metadata":{"id":"gAnKIS9pzp-_"},"source":["# Step 7: Creation of local structures"]},{"cell_type":"code","execution_count":null,"metadata":{"id":"0dm6Jlvizp-_"},"outputs":[],"source":["# Compute the cell coordinates of a generic local structure\n","mcc_radius = 70\n","mcc_size = 16\n","\n","g = 2 * mcc_radius / mcc_size\n","x = np.arange(mcc_size)*g - (mcc_size/2)*g + g/2\n","y = x[..., np.newaxis]\n","iy, ix = np.nonzero(x**2 + y**2 <= mcc_radius**2)\n","ref_cell_coords = np.column_stack((x[ix], x[iy]))"]},{"cell_type":"code","execution_count":null,"metadata":{"id":"g0-NqFYezp-_"},"outputs":[],"source":["mcc_sigma_s = 7.0\n","mcc_tau_psi = 400.0\n","mcc_mu_psi = 1e-2\n","\n","def Gs(t_sqr):\n","    \"\"\"Gaussian function with zero mean and mcc_sigma_s standard deviation, see eq. (7) in MCC paper\"\"\"\n","    return np.exp(-0.5 * t_sqr / (mcc_sigma_s**2)) / (math.tau**0.5 * mcc_sigma_s)\n","\n","def Psi(v):\n","    \"\"\"Sigmoid function that limits the contribution of dense minutiae clusters, see eq. (4)-(5) in MCC paper\"\"\"\n","    return 1. / (1. + np.exp(-mcc_tau_psi * (v - mcc_mu_psi)))"]},{"cell_type":"code","execution_count":null,"metadata":{"id":"zP32k1Drzp-_"},"outputs":[],"source":["# n: number of minutiae\n","# c: number of cells in a local structure\n","\n","xyd = np.array([(x,y,d) for x,y,_,d in valid_minutiae]) # matrix with all minutiae coordinates and directions (n x 3)\n","\n","# rot: n x 2 x 2 (rotation matrix for each minutia)\n","d_cos, d_sin = np.cos(xyd[:,2]).reshape((-1,1,1)), np.sin(xyd[:,2]).reshape((-1,1,1))\n","rot = np.block([[d_cos, d_sin], [-d_sin, d_cos]])\n","\n","# rot@ref_cell_coords.T : n x 2 x c\n","# xy : n x 2\n","xy = xyd[:,:2]\n","# cell_coords: n x c x 2 (cell coordinates for each local structure)\n","cell_coords = np.transpose(rot@ref_cell_coords.T + xy[:,:,np.newaxis],[0,2,1])\n","\n","# cell_coords[:,:,np.newaxis,:]      :  n x c  x 1 x 2\n","# xy                                 : (1 x 1) x n x 2\n","# cell_coords[:,:,np.newaxis,:] - xy :  n x c  x n x 2\n","# dists: n x c x n (for each cell of each local structure, the distance from all minutiae)\n","dists = np.sum((cell_coords[:,:,np.newaxis,:] - xy)**2, -1)\n","\n","# cs : n x c x n (the spatial contribution of each minutia to each cell of each local structure)\n","cs = Gs(dists)\n","diag_indices = np.arange(cs.shape[0])\n","cs[diag_indices,:,diag_indices] = 0 # remove the contribution of each minutia to its own cells\n","\n","# local_structures : n x c (cell values for each local structure)\n","local_structures = Psi(np.sum(cs, -1))"]},{"cell_type":"code","execution_count":null,"metadata":{"id":"GCn07G_0zp-_"},"outputs":[],"source":["@interact(i=(0,len(valid_minutiae)-1))\n","def test(i=0):\n","    show(draw_minutiae_and_cylinder(fingerprint, ref_cell_coords, valid_minutiae, local_structures, i))"]},{"cell_type":"markdown","metadata":{"id":"h9G3SosVzp-_"},"source":["# Step 8: Fingerprint comparison"]},{"cell_type":"code","execution_count":null,"metadata":{"id":"jPULw0vMzp_A"},"outputs":[],"source":["print(f\"\"\"Fingerprint image: {fingerprint.shape[1]}x{fingerprint.shape[0]} pixels\n","Minutiae: {len(valid_minutiae)}\n","Local structures: {local_structures.shape}\"\"\")"]},{"cell_type":"code","execution_count":null,"metadata":{"id":"GEENB_yvzp_A"},"outputs":[],"source":["f1, m1, ls1 = fingerprint, valid_minutiae, local_structures"]},{"cell_type":"code","execution_count":null,"metadata":{"id":"_n5L3L22zp_A"},"outputs":[],"source":["ofn = 'samples/sample_1_2' # Fingerprint of the same finger\n","#ofn = 'samples/sample_2' # Fingerprint of a different finger\n","# f2 = cv.imread(f'{ofn}.png', cv.IMREAD_GRAYSCALE)\n","# m2 = ???\n","# ls2 = ???\n","f2, (m2, ls2) = cv.imread(f'{ofn}.png', cv.IMREAD_GRAYSCALE), np.load(f'{ofn}.npz', allow_pickle=True).values()"]},{"cell_type":"code","execution_count":null,"metadata":{"id":"hoYOwyAJzp_B"},"outputs":[],"source":["# Compute\n","# Compute all pairwise normalized Euclidean distances between local structures in v1 and v2\n","# ls1                       : n1 x  c\n","# ls1[:,np.newaxis,:]       : n1 x  1 x c\n","# ls2                       : (1 x) n2 x c\n","# ls1[:,np.newaxis,:] - ls2 : n1 x  n2 x c\n","# dists                     : n1 x  n2\n","dists = np.linalg.norm(ls1[:,np.newaxis,:] - ls2, axis = -1)\n","dists /= np.linalg.norm(ls1, axis = 1)[:,np.newaxis] + np.linalg.norm(ls2, axis = 1) # Normalize as in eq. (17) of MCC paper"]},{"cell_type":"code","execution_count":null,"metadata":{"id":"lYSeRY7-zp_B"},"outputs":[],"source":["num_p = 5 # For simplicity: a fixed number of pairs\n","pairs = np.unravel_index(np.argpartition(dists, num_p, None)[:num_p], dists.shape)\n","score = 1 - np.mean(dists[pairs[0], pairs[1]]) # See eq. (23) in MCC paper\n","print(f'Comparison score: {score:.2f}')"]},{"cell_type":"code","source":["@interact(i = (0,len(pairs[0])-1), show_local_structures = False)\n","def show_pairs(i=0, show_local_structures = False):\n","    show(draw_match_pairs(f1, m1, ls1, f2, m2, ls2, ref_cell_coords, pairs, i, show_local_structures))"],"metadata":{"id":"QUO78cF3fwDs"},"execution_count":null,"outputs":[]}],"metadata":{"colab":{"private_outputs":true,"provenance":[]},"kernelspec":{"display_name":"Python 3 (ipykernel)","language":"python","name":"python3"},"language_info":{"codemirror_mode":{"name":"ipython","version":3},"file_extension":".py","mimetype":"text/x-python","name":"python","nbconvert_exporter":"python","pygments_lexer":"ipython3","version":"3.12.3"},"vscode":{"interpreter":{"hash":"ad2bdc8ecc057115af97d19610ffacc2b4e99fae6737bb82f5d7fb13d2f2c186"}}},"nbformat":4,"nbformat_minor":0}
\ No newline at end of file
diff --git a/uni_ete_fingerprint_iliya_saroukhanian.ipynb b/uni_ete_fingerprint_iliya_saroukhanian.ipynb
new file mode 100644
index 0000000000000000000000000000000000000000..0cd81c266f1e745089f48fd054e11a75cddee486
--- /dev/null
+++ b/uni_ete_fingerprint_iliya_saroukhanian.ipynb
@@ -0,0 +1,821 @@
+{
+  "cells": [
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "I-9uxGnBMZoN"
+      },
+      "source": [
+        "# Installation\n",
+        "\n",
+        "- Install conda or mini-conda: https://docs.conda.io/en/latest/miniconda.html\n",
+        "- Install dependencies: conda install -c conda-forge -y opencv notebook ipywidgets matplotlib\n",
+        "- Place the file \"utils.py\" in the same location or add it to PYTHONPATH"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "source": [
+        "# Uniquement nécessaire dans le cas d'utilisation de Google Collab\n",
+        "\n",
+        "# from google.colab import drive\n",
+        "# drive.mount('/content/drive/')\n",
+        "\n",
+        "# # Adapter ce chemin à votre hiérarchie\n",
+        "# %cd /content/drive/MyDrive/uni-ete\n",
+        "# %ls"
+      ],
+      "metadata": {
+        "id": "XLWOKNCuMcJh"
+      },
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "markdown",
+      "source": [
+        "# Source\n",
+        "\n",
+        "  Les démarches algorithmiques ainsi que réflectives ont été effectuées à l'aide\n",
+        "  du notebook [_Simple Fingerprint Recognition Example_](https://colab.research.google.com/drive/1u5X8Vg9nXWPEDFFtUwbkdbQxBh4hba_M).\n",
+        "  Celui-ci s'est avéré être d'une grande aide suite à la complexité mathématique\n",
+        "  et algorithmique inhérente au sujet de détection et reconnaissance d'empreintes"
+      ],
+      "metadata": {
+        "id": "7ErM7jbfwWrh"
+      }
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "g7Pxow1HRRin"
+      },
+      "outputs": [],
+      "source": [
+        "import math\n",
+        "import numpy as np\n",
+        "import cv2 as cv\n",
+        "import matplotlib.pyplot as plt\n",
+        "from utils import *\n",
+        "from ipywidgets import interact"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "source": [],
+      "metadata": {
+        "id": "RMEiFXHlwhwF"
+      }
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "QgMXnN9wSgrS"
+      },
+      "source": [
+        "# Step 1: Fingerprint segmentation"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "E2gtenrUMZoP"
+      },
+      "outputs": [],
+      "source": [
+        "fingerprint = cv.imread('samples/sample_2.png', cv.IMREAD_GRAYSCALE)\n",
+        "show(fingerprint, f'Fingerprint with size (w,h): {fingerprint.shape[::-1]}')"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "wpgIAkrBsnvw"
+      },
+      "outputs": [],
+      "source": [
+        "# Calculate the local gradient (using Sobel filters)\n",
+        "\"\"\"\n",
+        "    On applique le filtre de sobel pour mettre en évidence les contours de l'empreinte digitale.\n",
+        "\"\"\"\n",
+        "gx, gy = cv.Sobel(fingerprint, cv.CV_64F, 1, 0), cv.Sobel(fingerprint, cv.CV_64F, 0, 1)\n",
+        "show((gx, 'Gx'), (gy, 'Gy'))"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "LQ1s0yjIs8j4"
+      },
+      "outputs": [],
+      "source": [
+        "# Calculate the magnitude of the gradient for each pixel\n",
+        "gx2, gy2 = gx**2, gy**2\n",
+        "gm = np.sqrt(gx2 + gy2)\n",
+        "show((gx2, 'Gx**2'), (gy2, 'Gy**2'), (gm, 'Gradient magnitude'))"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "GPhP0Ubks_nA"
+      },
+      "outputs": [],
+      "source": [
+        "# Integral over a square window\n",
+        "window = (25,25)\n",
+        "sum_gm = cv.boxFilter(gm, -1, window)\n",
+        "show(sum_gm, 'Sum of the gradient magnitude')"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "5YafVxEOtC_4"
+      },
+      "outputs": [],
+      "source": [
+        "# Use a simple threshold for segmenting the fingerprint pattern\n",
+        "\n",
+        "\"\"\"\n",
+        "    On calcule un masque qui permettra de déterminer les contours extérieurs de l'empreinte\n",
+        "\"\"\"\n",
+        "\n",
+        "thr = np.max(sum_gm) * 0.2\n",
+        "mask = cv.threshold(sum_gm, thr, 255, cv.THRESH_BINARY)[1].astype(np.uint8)\n",
+        "show(fingerprint, mask, cv.merge((mask, fingerprint, fingerprint)))"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "jJ2D5OHU6inW"
+      },
+      "source": [
+        "# Step 2: Estimation of local ridge orientation"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "x_ajHsYAYwCD"
+      },
+      "source": [
+        "The ridge orientation is estimated as ortoghonal to the gradient orientation, averaged over a window $W$.  \n",
+        "\n",
+        "$G_{xx}=\\sum_W{G_x^2}$\n",
+        "\n",
+        "$G_{yy}=\\sum_W{G_y^2}$\n",
+        "\n",
+        "$G_{xy}=\\sum_W{G_xG_y}$\n",
+        "\n",
+        "$\\theta=\\frac{\\pi}{2} + \\frac{phase(G_{xx}-G_{yy}, 2G_{xy})}{2}$\n",
+        "\n",
+        "For each orientation, we will also calculate a confidence value (strength), which measures how much all gradients in $W$ share the same orientation.  \n",
+        "\n",
+        "$strength = \\frac{\\sqrt{(G_{xx}-G_{yy})^2+(2G_{xy})^2}}{G_{xx}+G_{yy}}$"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "jnyEfap-tFSw"
+      },
+      "outputs": [],
+      "source": [
+        "W = (23, 23)\n",
+        "\n",
+        "gxx = cv.blur(gx2, W)\n",
+        "gyy = cv.blur(gy2, W)\n",
+        "\n",
+        "gxy = cv.blur(gx * gy, W)\n",
+        "\n",
+        "orientations = (-1) * 0.5 * np.arctan2(2 * gxy, gxx - gyy) + np.pi / 2\n",
+        "strengths = np.sqrt((gxx - gyy) ** 2 + (2 * gxy) ** 2) / (gxx + gyy + 1e-5)\n",
+        "\n",
+        "show(draw_orientations(fingerprint, orientations, strengths, mask, 1, 16), 'Orientation image')"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "SDHYsNmJ7TaV"
+      },
+      "source": [
+        "# Step 3: Estimation of local ridge frequency"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "ezc3YDWXCTEm"
+      },
+      "outputs": [],
+      "source": [
+        "# region = cv.bitwise_and(fingerprint, fingerprint, mask=mask\n",
+        "region = fingerprint[80:200, 100:220]\n",
+        "show(region)"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "Pvv7fS0KQpJ3"
+      },
+      "outputs": [],
+      "source": [
+        "smoothed = cv.GaussianBlur(region, (5, 5), 0)\n",
+        "\n",
+        "xs = np.sum(smoothed, axis=1)\n",
+        "print(xs)"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "8-hQrph0DSZO"
+      },
+      "outputs": [],
+      "source": [
+        "x = np.arange(region.shape[0])\n",
+        "f, axarr = plt.subplots(1,2, sharey = True)\n",
+        "axarr[0].imshow(region,cmap='gray')\n",
+        "axarr[1].plot(xs, x)\n",
+        "axarr[1].set_ylim(region.shape[0]-1,0)\n",
+        "plt.show()"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "zrM-B9JKRXth"
+      },
+      "outputs": [],
+      "source": [
+        "# local_maxima = (np.diff(np.sign(np.diff(xs))) < 0).nonzero()[0] + 1\n",
+        "local_maxima = np.where((xs[:-2] < xs[1:-1]) & (xs[1:-1] > xs[2:]))[0] + 1\n",
+        "print(local_maxima)"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "pasX1AILRQdq"
+      },
+      "outputs": [],
+      "source": [
+        "x = np.arange(region.shape[0])\n",
+        "plt.plot(x, xs)\n",
+        "plt.xticks(local_maxima)\n",
+        "plt.grid(True, axis='x')\n",
+        "plt.show()"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "w6CPgUQgtYCo"
+      },
+      "outputs": [],
+      "source": [
+        "distances = local_maxima[1:] - local_maxima[:-1]\n",
+        "print(distances)"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "source": [
+        "<!--  -->"
+      ],
+      "metadata": {
+        "id": "SaobQYWyE_Oc"
+      }
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "QlfcrCgPVozS"
+      },
+      "outputs": [],
+      "source": [
+        "ridge_period = distances.mean()\n",
+        "print(ridge_period)"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "QShcuAYY7wd0"
+      },
+      "source": [
+        "# Step 4: Fingerprint enhancement"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "htZDESSYtesf"
+      },
+      "outputs": [],
+      "source": [
+        "\n",
+        "or_count = 8\n",
+        "gabor_bank = [gabor_kernel(ridge_period, o) for o in np.arange(0, np.pi, np.pi/or_count)]"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "l7Ldq1VNtgEQ"
+      },
+      "outputs": [],
+      "source": [
+        "show(*gabor_bank)"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "gmeTXLIvthI_"
+      },
+      "outputs": [],
+      "source": [
+        "nf = 255-fingerprint\n",
+        "all_filtered = np.array([cv.filter2D(nf, cv.CV_32F, f) for f in gabor_bank])\n",
+        "show(nf, *all_filtered)"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "usS93oHDt7VQ"
+      },
+      "outputs": [],
+      "source": [
+        "y_coords, x_coords = np.indices(fingerprint.shape)\n",
+        "\n",
+        "orientation_idx = np.round(((orientations % np.pi) / np.pi) * or_count).astype(np.int32) % or_count\n",
+        "filtered = all_filtered[orientation_idx, y_coords, x_coords]\n",
+        "enhanced = mask & np.clip(filtered, 0, 255).astype(np.uint8)\n",
+        "show(fingerprint, filtered, enhanced)"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "LQvHUusjyBfR"
+      },
+      "source": [
+        "# Step 5: Detection of minutiae positions"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "jmCPCdMFuCMe"
+      },
+      "outputs": [],
+      "source": [
+        "_, ridge_lines = cv.threshold(enhanced, 32, 255, cv.THRESH_BINARY)\n",
+        "show(enhanced, ridge_lines)"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "OtEgLkx7uE_e"
+      },
+      "outputs": [],
+      "source": [
+        "skeleton = cv.ximgproc.thinning(ridge_lines, thinningType = cv.ximgproc.THINNING_GUOHALL)\n",
+        "show(ridge_lines, skeleton)"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "source": [
+        "def compute_crossing_number(values):\n",
+        "    return np.count_nonzero(values < np.roll(values, -1))"
+      ],
+      "metadata": {
+        "id": "pIIvgsPueUj3"
+      },
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "code",
+      "source": [
+        "cn_filter = np.array([[  1,  2,  4],\n",
+        "                      [128,  0,  8],\n",
+        "                      [ 64, 32, 16]\n",
+        "                     ])"
+      ],
+      "metadata": {
+        "id": "5NYXa5ybeZbP"
+      },
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "code",
+      "source": [
+        "all_8_neighborhoods = [np.array([int(d) for d in f'{x:08b}'])[::-1] for x in range(256)]\n",
+        "cn_lut = np.array([compute_crossing_number(x) for x in all_8_neighborhoods]).astype(np.uint8)"
+      ],
+      "metadata": {
+        "id": "wwmjIaSUecxO"
+      },
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "code",
+      "source": [
+        "skeleton01 = np.where(skeleton!=0, 1, 0).astype(np.uint8)\n",
+        "neighborhood_values = cv.filter2D(skeleton01, -1, cn_filter, borderType = cv.BORDER_CONSTANT)\n",
+        "cn = cv.LUT(neighborhood_values, cn_lut)\n",
+        "cn[skeleton==0] = 0"
+      ],
+      "metadata": {
+        "id": "nz7u55ZOef_r"
+      },
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "4NzceQaJuMk_"
+      },
+      "outputs": [],
+      "source": [
+        "minutiae = [(x,y,cn[y,x]==1) for y, x in zip(*np.where(np.isin(cn, [1,3])))]"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "_NUdi_JAuPVH"
+      },
+      "outputs": [],
+      "source": [
+        "show(draw_minutiae(fingerprint, minutiae), skeleton, draw_minutiae(skeleton, minutiae))"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "VLWwRQBZuQo_"
+      },
+      "outputs": [],
+      "source": [
+        "mask_edges = cv.distanceTransform(cv.copyMakeBorder(mask, 1, 1, 1, 1, cv.BORDER_CONSTANT), cv.DIST_C, 3)[1:-1,1:-1]\n",
+        "show(mask, mask_edges)"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "4Sb6iyRFuUF_"
+      },
+      "outputs": [],
+      "source": [
+        "filtered_minutiae = list(filter(lambda m: mask_edges[m[1], m[0]]>10, minutiae))"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "99BiKiH2uV5H"
+      },
+      "outputs": [],
+      "source": [
+        "show(draw_minutiae(fingerprint, filtered_minutiae), skeleton, draw_minutiae(skeleton, filtered_minutiae))"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "_ZikeJ62zp-9"
+      },
+      "source": [
+        "# Step 6: Estimation of minutiae directions"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "source": [
+        "def compute_next_ridge_following_directions(previous_direction, values):\n",
+        "    next_positions = np.argwhere(values!=0).ravel().tolist()\n",
+        "    if len(next_positions) > 0 and previous_direction != 8:\n",
+        "\n",
+        "        next_positions.sort(key = lambda d: 4 - abs(abs(d - previous_direction) - 4))\n",
+        "        if next_positions[-1] == (previous_direction + 4) % 8:\n",
+        "            next_positions = next_positions[:-1]\n",
+        "    return next_positions"
+      ],
+      "metadata": {
+        "id": "0--AIiPl1tjo"
+      },
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "X24IjohPzp--"
+      },
+      "outputs": [],
+      "source": [
+        "r2 = 2**0.5\n",
+        "\n",
+        "\"\"\"\n",
+        "Si nous sommes en p et que nous voulons aller en 7, nous bougeons que d'un pixel,\n",
+        "donc la valeur sera (-1, 0, 1).\n",
+        "Si nous sommes en p et que nous voulons aller en 4, nous bougeons de 2 pixel via diagonale,\n",
+        "donc la valeur sera (1,1, sqrt(2)).\n",
+        "\"\"\"\n",
+        "\n",
+        "xy_steps = [(-1,-1,r2),( 0,-1,1),( 1,-1,r2),( 1, 0,1),( 1, 1,r2),( 0, 1,1),(-1, 1,r2),(-1, 0,1)]\n",
+        "\n",
+        "nd_lut = [[compute_next_ridge_following_directions(pd, x) for pd in range(9)] for x in all_8_neighborhoods]"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "source": [
+        "def follow_ridge_and_compute_angle(x, y, d = 8):\n",
+        "    px, py = x, y\n",
+        "    length = 0.0\n",
+        "    while length < 20:\n",
+        "        next_directions = nd_lut[neighborhood_values[py,px]][d]\n",
+        "        if len(next_directions) == 0:\n",
+        "            break\n",
+        "\n",
+        "        if (any(cn[py + xy_steps[nd][1], px + xy_steps[nd][0]] != 2 for nd in next_directions)):\n",
+        "            break\n",
+        "\n",
+        "        d = next_directions[0]\n",
+        "        ox, oy, l = xy_steps[d]\n",
+        "        px += ox ; py += oy ; length += l\n",
+        "\n",
+        "    return math.atan2(-py+y, px-x) if length >= 10 else None"
+      ],
+      "metadata": {
+        "id": "sXnx4-WE14hC"
+      },
+      "execution_count": null,
+      "outputs": []
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "4T5fvHFMzp--"
+      },
+      "outputs": [],
+      "source": [
+        "\n",
+        "valid_minutiae = []\n",
+        "for x, y, term in filtered_minutiae:\n",
+        "    d = None\n",
+        "    if term:\n",
+        "        d = follow_ridge_and_compute_angle(x, y)\n",
+        "    else:\n",
+        "        dirs = nd_lut[neighborhood_values[y,x]][8]\n",
+        "        if len(dirs)==3:\n",
+        "            angles = [follow_ridge_and_compute_angle(x+xy_steps[d][0], y+xy_steps[d][1], d) for d in dirs]\n",
+        "            if all(a is not None for a in angles):\n",
+        "                a1, a2 = min(((angles[i], angles[(i+1)%3]) for i in range(3)), key=lambda t: angle_abs_difference(t[0], t[1]))\n",
+        "                d = angle_mean(a1, a2)\n",
+        "    if d is not None:\n",
+        "        valid_minutiae.append( (x, y, term, d) )"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "8TccPjw_zp--"
+      },
+      "outputs": [],
+      "source": [
+        "show(draw_minutiae(fingerprint, valid_minutiae))"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "gAnKIS9pzp-_"
+      },
+      "source": [
+        "# Step 7: Creation of local structures"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "0dm6Jlvizp-_"
+      },
+      "outputs": [],
+      "source": [
+        "mcc_radius = 70\n",
+        "mcc_size = 16\n",
+        "\n",
+        "g = 2 * mcc_radius / mcc_size\n",
+        "x = np.arange(mcc_size)*g - (mcc_size/2)*g + g/2\n",
+        "y = x[..., np.newaxis]\n",
+        "iy, ix = np.nonzero(x**2 + y**2 <= mcc_radius**2)\n",
+        "ref_cell_coords = np.column_stack((x[ix], x[iy]))"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "g0-NqFYezp-_"
+      },
+      "outputs": [],
+      "source": [
+        "mcc_sigma_s = 7.0\n",
+        "mcc_tau_psi = 400.0\n",
+        "mcc_mu_psi = 1e-2\n",
+        "\n",
+        "def Gs(t_sqr):\n",
+        "    return np.exp(-0.5 * t_sqr / (mcc_sigma_s**2)) / (math.tau**0.5 * mcc_sigma_s)\n",
+        "\n",
+        "def Psi(v):\n",
+        "    return 1. / (1. + np.exp(-mcc_tau_psi * (v - mcc_mu_psi)))"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "zP32k1Drzp-_"
+      },
+      "outputs": [],
+      "source": [
+        "xyd = np.array([(x,y,d) for x,y,_,d in valid_minutiae])\n",
+        "\n",
+        "d_cos, d_sin = np.cos(xyd[:,2]).reshape((-1,1,1)), np.sin(xyd[:,2]).reshape((-1,1,1))\n",
+        "rot = np.block([[d_cos, d_sin], [-d_sin, d_cos]])\n",
+        "\n",
+        "xy = xyd[:,:2]\n",
+        "\n",
+        "cell_coords = np.transpose(rot@ref_cell_coords.T + xy[:,:,np.newaxis],[0,2,1])\n",
+        "\n",
+        "dists = np.sum((cell_coords[:,:,np.newaxis,:] - xy)**2, -1)\n",
+        "\n",
+        "cs = Gs(dists)\n",
+        "diag_indices = np.arange(cs.shape[0])\n",
+        "cs[diag_indices,:,diag_indices] = 0\n",
+        "\n",
+        "local_structures = Psi(np.sum(cs, -1))"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "GCn07G_0zp-_"
+      },
+      "outputs": [],
+      "source": [
+        "@interact(i=(0,len(valid_minutiae)-1))\n",
+        "def test(i=0):\n",
+        "    show(draw_minutiae_and_cylinder(fingerprint, ref_cell_coords, valid_minutiae, local_structures, i))"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {
+        "id": "h9G3SosVzp-_"
+      },
+      "source": [
+        "# Step 8: Fingerprint comparison"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "jPULw0vMzp_A"
+      },
+      "outputs": [],
+      "source": [
+        "print(f\"\"\"Fingerprint image: {fingerprint.shape[1]}x{fingerprint.shape[0]} pixels\n",
+        "Minutiae: {len(valid_minutiae)}\n",
+        "Local structures: {local_structures.shape}\"\"\")"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "GEENB_yvzp_A"
+      },
+      "outputs": [],
+      "source": [
+        "f1, m1, ls1 = fingerprint, valid_minutiae, local_structures"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "_n5L3L22zp_A"
+      },
+      "outputs": [],
+      "source": [
+        "ofn = 'samples/sample_2'\n",
+        "\n",
+        "f2, (m2, ls2) = cv.imread(f'{ofn}.png', cv.IMREAD_GRAYSCALE), np.load(f'{ofn}.npz', allow_pickle=True).values()"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "hoYOwyAJzp_B"
+      },
+      "outputs": [],
+      "source": [
+        "dists = np.linalg.norm(ls1[:,np.newaxis,:] - ls2, axis = -1)\n",
+        "dists /= np.linalg.norm(ls1, axis = 1)[:,np.newaxis] + np.linalg.norm(ls2, axis = 1)"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {
+        "id": "lYSeRY7-zp_B"
+      },
+      "outputs": [],
+      "source": [
+        "num_p = 5\n",
+        "pairs = np.unravel_index(np.argpartition(dists, num_p, None)[:num_p], dists.shape)\n",
+        "score = 1 - np.mean(dists[pairs[0], pairs[1]])\n",
+        "print(f'Comparison score: {score:.2f}')"
+      ]
+    }
+  ],
+  "metadata": {
+    "colab": {
+      "private_outputs": true,
+      "provenance": []
+    },
+    "kernelspec": {
+      "display_name": "Python 3 (ipykernel)",
+      "language": "python",
+      "name": "python3"
+    },
+    "language_info": {
+      "codemirror_mode": {
+        "name": "ipython",
+        "version": 3
+      },
+      "file_extension": ".py",
+      "mimetype": "text/x-python",
+      "name": "python",
+      "nbconvert_exporter": "python",
+      "pygments_lexer": "ipython3",
+      "version": "3.12.3"
+    },
+    "vscode": {
+      "interpreter": {
+        "hash": "ad2bdc8ecc057115af97d19610ffacc2b4e99fae6737bb82f5d7fb13d2f2c186"
+      }
+    }
+  },
+  "nbformat": 4,
+  "nbformat_minor": 0
+}
\ No newline at end of file