做指纹识别相关实验时,最容易卡住的地方往往不是公式推导,而是怎么把“图像预处理 → 特征点提取 → 匹配打分”串成一条完整可运行的流水线。网上很多资料只给一个孤立函数,跑起来连图像路径都对应不上,更别说换一张指纹图重新测试。
本文整理一套基于 Matlab 的指纹识别与特征点匹配源码,覆盖指纹图像增强、二值化、细化、细节点提取、特征点匹配与 1:N 指纹库检索,代码完整可运行,并且可以很方便地向指纹库中增加其它指纹图像进行对比。这套流程适合做图像处理课程设计、毕业设计、Matlab 大作业,也适合刚接触生物特征识别方向的同学用来理解特征点匹配算法。
1. 项目背景与核心概念
1.1 指纹识别系统是什么
指纹识别(Fingerprint Recognition)是应用最广泛的生物特征识别方式之一。指纹表面由凸起的脊线(Ridge)和凹陷的谷线(Valley)构成,这种纹理在人群中的区分度很高,且相对稳定,因此被大量用于门禁、考勤、手机解锁、刑侦鉴定等场景。
一个完整的指纹识别系统通常包含四个环节:
- 指纹图像采集:通过光学传感器、电容传感器等方式获取指纹图像。
- 图像预处理:对采集到的图像去噪、增强、二值化、细化,提高后续特征提取的准确性。
- 特征提取:从指纹纹路中提取稳定的关键特征,最常见的是细节点(Minutiae)。
- 特征匹配:将待识别指纹的特征点与指纹库中的特征点进行比对,输出相似度得分并给出识别结论。
在 Matlab 中实现上述流程并不复杂,关键是要理解每一步为什么要这样做,以及参数对最终效果的影响。
1.2 细节点与特征点匹配算法
细节点(Minutiae)是指纹脊线上的局部细节特征。最常见的两类细节点是:
- 端点(Ridge Ending):脊线突然终止的位置。
- 分叉点(Bifurcation):一条脊线分叉成两条脊线的位置。
除了端点和分叉点,理论上还有孤点、桥形、环点等类型,但在实际工程项目中,绝大多数匹配算法只使用端点和分叉点,因为这两类点数量足够多,且稳定性好。
特征点匹配算法的核心任务,是判断两幅指纹图像提取出的细节点集合是否来自同一根手指。直接对比整幅图像是低效且脆弱的,因为采集时手指会有平移、旋转、按压力度变化,会导致像素级内容明显不同。而细节点匹配只关心局部特征点的位置关系和相对结构,对平移旋转有更强的容忍度,匹配速度也更快。
本文采用的匹配思路是:
对两组特征点做质心平移对齐 -> 遍历第一组点,在第二组点中找距离最近的点 -> 若最近距离小于阈值,则算作一个匹配点对 -> 匹配点对数 / 较少点集数量 = 相似度得分这种算法属于“演示级”匹配方案,适合理解整个指纹识别流程。生产级系统还需要加入方向场计算、旋转对齐、细节点角度描述子、基于图结构的匹配等增强手段,这部分在文末会展开说明。
1.3 本文技术路线
本文实现的 Matlab 指纹识别系统技术路线如下:
原始指纹图像 → 灰度化 → 自适应直方图增强 → 中值滤波去噪 → 自适应二值化 → 形态学去噪 → 细化 → 交叉数法细节点提取 → 边缘特征过滤 → 质心对齐 + 最近邻匹配 → 输出相似度排名
这套流程会封装为以下几个文件:
| 文件 | 作用 |
|---|---|
| main_fingerprint.m | 主脚本,负责读取指纹库、调用各模块、输出匹配结果 |
| extractMinutiae.m | 预处理 + 细节点提取封装函数 |
| extractMinutiaeByCN.m | 基于交叉数法的细节点提取核心函数 |
| matchMinutiae.m | 特征点匹配打分函数 |
下面先从整体设计开始讲解。
2. 系统总体流程与设计
2.1 完整处理链路
为了便于理解,可以先看整个系统的处理链路:
原始指纹图 | v 灰度化:彩色图转单通道,降低计算量 | v 增强:adapthisteq 提升脊线对比度,中值滤波降噪 | v 二值化:脊线为1,背景为0 | v 形态学去噪:bwareaopen 删除小面积噪声连通域 | v 细化:bwmorph(thin) 得到单像素宽度的骨架 | v 细节点提取:交叉数法,提取端点和分叉点 | v 边缘过滤:去掉靠近图像边界的伪特征点 | v 特征点匹配:质心对齐 + 最近邻距离匹配 | v 输出相似度得分这个流程中,预处理和细化质量直接决定特征点提取的准确率。如果二值化参数不对,后续提取出的细节点会大量增加,匹配时误匹配率也会上升。
2.2 各环节作用说明
图像增强不是必须步骤,但在指纹图像偏暗、对比度不足时效果非常明显。Matlab 的adapthisteq是自适应直方图均衡化,它会对图像分块做对比度拉伸,比全局histeq更适合指纹这种纹理类图像。
中值滤波用来去除椒盐噪声和细小毛刺,大小通常取 3×3,太大会模糊脊线细节。
二值化是把灰度图转换成黑白图。指纹脊线常见的灰度特征是“脊线偏暗、背景偏亮”,因此代码中使用'ForegroundPolarity', 'dark'。如果你的图像是反过来的,需要改成'bright'或对结果取反。
细化是把二值纹路压缩成单像素宽度骨架。这一步非常重要,因为后续细节点提取方法依赖像素邻域结构,宽纹路会让交叉数计算失真。bwmorph(thin, Inf)会一直细化到不能细化为止。
特征点提取使用经典的交叉数法。该方法思路简单、实现容易、运行速度快,非常适合教学和快速原型验证。
3. 环境准备与工程结构
3.1 Matlab 版本与工具箱
本文示例代码基于 MATLAB R2021a 编写,使用了图像处理工具箱(Image Processing Toolbox)。R2016a 及以上版本基本都可以直接运行。
需要特别说明的是:
imbinarize函数是 R2016a 引入的。如果你的 Matlab 版本较老,可以使用graythresh + im2bw替代。adapthisteq、medfilt2、bwmorph、bwareaopen都属于图像处理工具箱。- 如果不确定当前环境是否安装了图像处理工具箱,可以在 Matlab 命令行执行
ver,查看列表里是否有 Image Processing Toolbox。
3.2 项目目录结构
建议按照下面的目录结构组织代码和测试图像:
matlab_fingerprint/ │ ├─ main_fingerprint.m ├─ extractMinutiae.m ├─ extractMinutiaeByCN.m ├─ matchMinutiae.m │ ├─ images/ │ ├─ query/ │ │ └─ finger_1.bmp │ └─ db/ │ ├─ finger_1.bmp │ ├─ finger_2.bmp │ ├─ finger_3.bmp │ └─ ... └─ results/images/query存放待识别的查询指纹图像。images/db存放指纹库图像,需要增加其它指纹图像时,直接放进这个目录即可。results目录用于保存后续扩展时的输出图片和结果文件。
4. 核心算法与源码实现
4.1 图像预处理模块
预处理模块的主要任务是把原始指纹图像转换成干净的细化图,为细节点提取做准备。
下面是extractMinutiae.m完整代码:
% 文件路径:matlab_fingerprint/extractMinutiae.m function [minutiae, info] = extractMinutiae(img, margin) % 指纹预处理 + 细节点提取主函数 % 输入: % img - 灰度或彩色指纹图像 % margin - 边缘排除区域(像素),用于去除边界伪特征点 % 输出: % minutiae - Nx2 矩阵,每行表示一个细节点坐标 [x, y] % info - 结构体,包含预处理中间结果和细节点类型 % 1. 彩色图转灰度图 if size(img, 3) == 3 grayImg = rgb2gray(img); else grayImg = img; end % 统一转为 double,便于后续计算 if ~isa(grayImg, 'double') grayImg = im2double(grayImg); end % 2. 增强:自适应直方图均衡化,提高脊线和谷线的对比度 enhanced = adapthisteq(grayImg, 'NumTiles', [8 8], 'ClipLimit', 0.02); % 中值滤波去噪 enhanced = medfilt2(enhanced, [3 3]); % 3. 二值化:指纹脊线通常比背景暗,所以前景极性设置为 dark bw = imbinarize(enhanced, 'adaptive', 'ForegroundPolarity', 'dark'); % 如果图像中脊线是亮色,可以使用下面这行并注释掉上一行 % bw = imbinarize(enhanced, 'adaptive', 'ForegroundPolarity', 'bright'); % 使用 Otsu 全局阈值也可以,但光照不均时效果不如自适应 % th = graythresh(enhanced); % bw = imbinarize(enhanced, th); % 4. 形态学去噪:删除面积小于30像素的孤立连通域 bw = bwareaopen(bw, 30); % 5. 细化:将脊线压缩为单像素宽度的骨架 thin = bwmorph(bw, 'thin', Inf); % 6. 去除细化过程中产生的短毛刺 thin = bwmorph(thin, 'spur', 5); % 7. 基于交叉数法提取细节点 [minutiae, types] = extractMinutiaeByCN(thin, margin); % 保存中间结果,方便主脚本可视化调试 info.enhanced = enhanced; info.bw = bw; info.thin = thin; info.types = types; end这段代码有几个地方值得详细解释:
为什么用自适应二值化?指纹采集时,手指不同位置按压力度不同,光照可能存在渐变,全局阈值很容易把局部噪点带入前景。自适应二值化会按局部邻域计算阈值,对光照不均更友好。
为什么细化用Inf?bwmorph(..., 'thin', Inf)会一直迭代到不再变化,保证脊线被处理成严格单像素宽的骨架。如果迭代次数太小,纹路可能残留多像素宽区域,影响交叉数判断。
为什么还要去毛刺?细化算法在一开始可能产生一些很短的毛刺分支,这些分支不是真实脊线,如果不处理,会被误判成分叉点或端点。spur操作可以删除这些短小分支。
4.2 细节点提取:交叉数法原理与实现
细节点提取的核心是交叉数法,也叫 Crossing Number,简称 CN。对于细化图中的某个前景像素 p,取其 3×3 邻域内的 8 个像素,按顺时针顺序记为 P1 到 P8,则交叉数计算公式为:
CN = 0.5 * ( |P2 - P1| + |P3 - P2| + ... + |P8 - P7| + |P1 - P8| )交叉数与细节点类型的关系如下:
| CN 值 | 含义 |
|---|---|
| 0 | 孤立点 |
| 1 | 端点(Ridge Ending) |
| 2 | 脊线内部点 |
| 3 | 分叉点(Bifurcation) |
| 4 及以上 | 交叉点或噪声点 |
CN 值表示该像素邻域内纹路方向变化的次数。端点在邻域内只有一条脊线进入,因此 CN 为 1;分叉点有三条脊线汇合,因此 CN 为 3。
下面是extractMinutiaeByCN.m完整实现:
% 文件路径:matlab_fingerprint/extractMinutiaeByCN.m function [minutiae, types] = extractMinutiaeByCN(thin, margin) % 基于交叉数(Crossing Number)的指纹细节点提取 % 输入: % thin - 细化后的二值图,前景(脊线)为1,背景为0 % margin - 边缘排除像素数 % 输出: % minutiae - Nx2 矩阵,每行 [x, y] % types - Nx1 向量,1表示端点,3表示分叉点 [H, W] = size(thin); % 边界填充0,方便在原图边缘位置计算3x3邻域 thinPad = false(H + 2, W + 2); thinPad(2:end-1, 2:end-1) = thin; minutiae = []; types = []; for y = 2:H+1 for x = 2:W+1 if ~thinPad(y, x) continue; end % 按顺时针顺序读取8邻域 P = [thinPad(y-1, x), thinPad(y-1, x+1), thinPad(y, x+1), ... thinPad(y+1, x+1), thinPad(y+1, x), thinPad(y+1, x-1), ... thinPad(y, x-1), thinPad(y-1, x-1)]; P = double(P); % 计算交叉数 diffSum = sum(abs(P([2:end 1]) - P)); CN = 0.5 * diffSum; % 还原到原图坐标:因为thinPad比thin多一圈,所以需要减1 px = x - 1; py = y - 1; % 过滤靠近图像边界的伪特征点 % 指纹图像边界处脊线会被截断,容易产生大量伪端点 if px < margin + 1 || px > W - margin || py < margin + 1 || py > H - margin continue; end if CN == 1 minutiae(end+1, :) = [px, py]; %#ok<AGROW> types(end+1, 1) = 1; % 端点 elseif CN == 3 minutiae(end+1, :) = [px, py]; %#ok<AGROW> types(end+1, 1) = 3; % 分叉点 end % CN大于3的情况通常属于噪声,这里直接忽略 end end % 如果没有任何特征点,返回空矩阵,避免调用方出错 if isempty(minutiae) minutiae = zeros(0, 2); types = zeros(0, 1); end end这里有一个关键细节:为什么要设置边缘排除区域 margin?指纹图像的边缘部分往往不是完整纹路,而是脊线被图像边界截断产生的“伪端点”。如果不加过滤,边界点会被当成大量特征点参与匹配,严重干扰匹配结果。代码中默认将距离图像边界小于 margin 像素的点全部忽略。
另一个细节是:为什么要 pad 一圈 0?因为图像左上角像素没有完整 8 邻域,如果不处理,循环到边界时需要分支判断。填充一圈 0 后,所有原图像像素都有完整邻域,代码更简洁,也不会越界。
4.3 指纹细节点匹配
细节点提取完成后,进入匹配环节。
本文使用的匹配算法分为两步:
- 质心平移对齐。两幅指纹图像即使来自同一根手指,采集时也可能有位置偏移。先把两组特征点各自的质心算出来,然后把第二组特征点平移到与第一组质心重合,可以减少平移带来的误匹配。
- 最近邻贪心匹配。遍历第一组每个特征点,在第二组中寻找距离最近且尚未被占用的特征点。如果最近距离小于阈值,则记为匹配点对。最后用匹配点对数除以两组点集中较少的数量,得到相似度得分。
下面是matchMinutiae.m完整实现:
% 文件路径:matlab_fingerprint/matchMinutiae.m function [score, matchPairs] = matchMinutiae(M1, M2, distTh) % 指纹细节点匹配 % 输入: % M1, M2 - Nx2 特征点坐标矩阵 % distTh - 匹配距离阈值(像素) % 输出: % score - 相似度得分,范围 [0, 1] % matchPairs - 匹配点对索引 [i, j] n1 = size(M1, 1); n2 = size(M2, 1); % 如果任一指纹特征点数量太少,直接返回0 if n1 < 2 || n2 < 2 score = 0; matchPairs = zeros(0, 2); return; end % 质心平移对齐:把M2整体平移到与M1质心重合 c1 = mean(M1, 1); c2 = mean(M2, 1); M2_aligned = M2 - c2 + c1; % 贪心匹配:每个M2点最多被匹配一次 used = false(n2, 1); matchPairs = zeros(0, 2); for i = 1:n1 % 当前M1点到所有M2点的欧氏距离 dists = sqrt(sum((M2_aligned - M1(i, :)).^2, 2)); [minDist, idx] = min(dists); % 如果距离在阈值内,且该M2点还没被匹配,则记为匹配对 if minDist <= distTh && ~used(idx) matchPairs(end+1, :) = [i, idx]; %#ok<AGROW> used(idx) = true; end end % 得分 = 匹配点对数 / 两组点集中较少的点数 matchCount = size(matchPairs, 1); score = matchCount / min(n1, n2); end这个匹配算法的优点是逻辑简单、易于理解,非常适合入门学习。它的局限性也很明显:
- 只考虑了平移对齐,没有处理旋转。如果同一手指按压时旋转角度较大,即使同一指纹,匹配分数也会下降。
- 没有使用细节点方向角。端点或分叉点除了坐标外,还有方向属性,加入角度信息可以大幅减少误匹配。
- 贪心匹配不一定是最优匹配。如果有两个 M1 点都想匹配同一个 M2 点,先遍历到的点会占用该 M2 点,后遍历的只能寻找其他点。更优的做法是使用匈牙利算法做全局最优匹配,但复杂度更高。
在实际工程项目中,通常会在匹配前先做旋转对齐,或者在特征描述阶段加入细节点方向角,然后使用基于柱形结构或拓扑图的方法进行匹配。
4.4 系统主脚本
主脚本负责把整个流程串起来,功能包括:
- 读取查询指纹图像。
- 遍历指纹库中的所有指纹图像。
- 对每张图像提取细节点。
- 调用匹配函数计算相似度。
- 输出排名和可视化结果。
main_fingerprint.m完整代码如下:
% 文件路径:matlab_fingerprint/main_fingerprint.m %% ========== 指纹识别系统主脚本 ========== clc; clear; close all; %% 参数设置 queryPath = 'images/query/finger_1.bmp'; % 待识别指纹 dbFolder = 'images/db'; % 指纹库目录 margin = 20; % 去除边缘特征点的边界像素数 matchDistTh = 12; % 匹配距离阈值(像素) %% 读取查询指纹并提取细节点 if ~exist(queryPath, 'file') error('查询指纹不存在:%s', queryPath); end queryImg = imread(queryPath); [queryMinu, queryInfo] = extractMinutiae(queryImg, margin); fprintf('查询指纹细节点数量:%d\n', size(queryMinu, 1)); %% 遍历指纹库 dbFiles = dir(fullfile(dbFolder, '*.bmp')); dbFiles = [dbFiles; dir(fullfile(dbFolder, '*.png'))]; if isempty(dbFiles) error('指纹库目录为空,请将指纹图像放入 %s 目录', dbFolder); end results = struct('name', {}, 'score', {}, 'matchNum', {}, ... 'queryNum', {}, 'dbNum', {}); for k = 1:length(dbFiles) dbPath = fullfile(dbFiles(k).folder, dbFiles(k).name); % 跳过与查询图像完全相同的文件(自匹配场景) if strcmp(dbPath, queryPath) continue; end dbImg = imread(dbPath); [dbMinu, ~] = extractMinutiae(dbImg, margin); [score, matchPairs] = matchMinutiae(queryMinu, dbMinu, matchDistTh); results(end+1).name = dbFiles(k).name; %#ok<SAGROW> results(end).score = score; results(end).matchNum = size(matchPairs, 1); results(end).queryNum = size(queryMinu, 1); results(end).dbNum = size(dbMinu, 1); fprintf('匹配文件: %-20s 相似度: %.2f%% 匹配点数: %d / %d\n', ... dbFiles(k).name, score * 100, size(matchPairs, 1), ... min(size(queryMinu, 1), size(dbMinu, 1))); end %% 按相似度降序排列 scores = [results.score]; [~, order] = sort(scores, 'descend'); fprintf('\n===== 匹配结果排名 =====\n'); for k = 1:length(order) r = results(order(k)); fprintf('%d. %-20s 相似度: %.2f%% 匹配点数: %d / %d\n', ... k, r.name, r.score * 100, r.matchNum, ... min(r.queryNum, r.dbNum)); end %% 显示查询指纹预处理过程 figure('Name', '查询指纹预处理与细节点'); subplot(2, 2, 1); imshow(queryImg); title('原始图像'); subplot(2, 2, 2); imshow(queryInfo.enhanced); title('增强结果'); subplot(2, 2, 3); imshow(queryInfo.thin); title('细化结果'); subplot(2, 2, 4); imshow(queryInfo.thin); hold on; endIdx = queryInfo.types == 1; plot(queryMinu(endIdx, 1), queryMinu(endIdx, 2), 'ro', 'MarkerSize', 5); plot(queryMinu(~endIdx, 1), queryMinu(~endIdx, 2), 'go', 'MarkerSize', 5); legend({'端点', '分叉点'}, 'Location', 'best'); title('细节点提取结果'); hold off; %% 显示 Top 3 匹配结果 topN = min(3, length(order)); if topN > 0 figure('Name', '1:N 匹配结果 Top 3'); subplot(2, 2, 1); imshow(queryImg); title('查询指纹'); for k = 1:topN idx = order(k); dbImg = imread(fullfile(dbFolder, results(idx).name)); subplot(2, 2, k + 1); imshow(dbImg); title(sprintf('%s (%.1f%%)', results(idx).name, results(idx).score * 100)); end end主脚本中需要注意一个细节:
为什么跳过与查询图像路径完全相同的文件?因为自匹配时两张图像完全相同,细节点也完全一致,相似度一定是 100%,作为比对结果没有参考价值。跳过自匹配后,排名更接近真实应用场景。
4.5 如何增加其它指纹图像
这套系统支持非常简单的指纹库扩展,操作步骤如下:
- 找到
images/db目录。 - 将新的指纹图像保存为
.bmp或.png格式,文件名不要包含中文和空格。 - 重新运行
main_fingerprint.m,脚本自动扫描目录下所有图片,并把新加入的图像纳入匹配排名。
如果希望加入其它格式,比如.jpg,可以在主脚本中继续叠加 dir 命令:
dbFiles = dir(fullfile(dbFolder, '*.bmp')); dbFiles = [dbFiles; dir(fullfile(dbFolder, '*.png'))]; dbFiles = [dbFiles; dir(fullfile(dbFolder, '*.jpg'))];如果指纹库很大,建议不要使用imread读取所有图像后一次性计算,而是分批处理并保存特征点结果,避免内存占用过高。
5. 运行验证与结果分析
5.1 预期输出
假设指纹库中放入三张指纹图,分别与查询指纹是同一手指和非同一手指,运行后命令行会输出类似下面的结果:
查询指纹细节点数量:42 匹配文件: finger_1.bmp 相似度: 85.71% 匹配点数: 36 / 42