图像处理里做纹理分析,LBP(Local Binary Pattern,局部二值模式)是我这些年用得最顺手的一个特征算子。它不像SIFT那样计算量大,也不像深度学习特征那样需要一堆训练数据,几行代码就能把一张图的局部纹理结构编码成一个紧凑的直方图向量,特别适合人脸分析、表面缺陷检测、医学影像分类这类对纹理敏感的场景。我最早接触LBP是在一个工业表面检测的小项目里,当时试过灰度共生矩阵,速度慢、参数多,换成LBP之后单帧处理时间直接压到毫秒级,效果还更稳。这篇博文就把我用C++实现LBP特征的完整思路、代码细节和踩过的坑摊开讲清楚,从最基础的3x3邻域算子到圆形邻域扩展、旋转不变与等价模式,再到直方图特征的组织方式,都会给出可直接编译运行的实现。适合有一定C++基础、正在做图像特征工程或者想搞懂LBP底层原理的读者,小白也能跟着一步步跑通。
1. 为什么在C++里手写LBP而不是调库
1.1 从一次工业检测需求说起
先说清楚LBP到底解决什么问题。图像里有一类信息叫纹理,比如布料的编织纹路、金属表面的磨砂质感、皮肤毛孔的分布、木材的年轮走向。这些信息没法用单个像素的灰度值描述,也没法用边缘检测完整刻画,它本质上是“局部区域内像素灰度的相对变化模式”。LBP的核心思想非常朴素:取一个中心像素,把它周围一圈像素的灰度跟它比大小,比它大的记1,比它小的记0,然后按固定顺序把这串二进制数转成一个十进制数,这个数就是该中心点的LBP编码。一张图每个像素都算一遍,就得到一张LBP响应图,再统计这张图的直方图,就得到能描述整幅图纹理分布的特征向量。
我当年那个项目是检测金属零件表面的细微划痕和麻点,产线速度要求单帧处理在10毫秒以内,图像分辨率是1280x1024。一开始想用现成的计算机视觉库里的LBP接口,实测下来有两个问题:一是库的接口通常只输出直方图,中间响应图拿不到,调试时没法可视化看每个像素的编码对不对;二是默认参数固定,想改邻域半径和采样点数得重新编译整个库,灵活性差。后来索性自己用C++写了一套,代码量不大,但每个环节都可控,出了问题能直接定位到某一行。这也是我建议手写LBP的第一个理由:特征工程阶段,可控性比便利性更重要。
1.2 手写实现带来的三个实际收益
第一个收益是性能可调。LBP的计算本质是查表和位运算,手写版本可以针对具体图像做循环展开、指针偏移优化,甚至用SIMD指令批量处理。我实测过,同样一张1080p灰度图,手写优化版比通用库接口快将近一倍,因为通用库为了兼容各种输入类型做了大量分支判断。
第二个收益是中间结果可见。调试纹理特征时,最怕的就是“直方图看着不对但不知道哪一步错了”。手写实现可以把LBP响应图存成图片,肉眼检查某个区域的编码是否符合预期,比如平坦区域编码应该接近全0或全1,边缘区域编码应该有明显的跳变。这个排查手段在调参阶段价值极高。
第三个收益是算法变体自由组合。LBP有很多变体:圆形邻域、旋转不变、等价模式、多尺度融合。用库的话往往只能选它实现好的那几种,手写的话想怎么组合就怎么组合,比如把旋转不变模式和等价模式叠加使用,或者做多半径的直方图拼接,都是改几行代码的事。
提示:如果你的项目只是快速验证想法,用现成库没问题;但一旦进入调优和落地阶段,手写一份精简的LBP实现几乎是必选项,后面所有优化都建立在你能完全掌控代码的前提上。
2. LBP核心原理拆解与关键参数选择
2.1 基础3x3算子的计算过程
先把最经典的3x3邻域LBP讲透。假设中心像素坐标是(x, y),灰度值为g_c,它周围8个邻居按顺时针从左上角开始编号为g_0到g_7。对每个邻居做判断:如果g_i >= g_c,该位记1,否则记0。这样得到一个8位二进制串,比如10110010,转成十进制就是178,这个178就是中心点的LBP值。取值范围是0到255,正好一个字节装得下。
这里有个细节很多人忽略:比较时用>=还是>。两种写法在理论上有区别,用>=的话平坦区域(所有邻居等于中心)会得到全1即255,用>的话平坦区域得到全0即0。实际使用中两种都能工作,但要注意整个流程里保持一致,因为直方图的bin分布会因此不同。我习惯用>=,因为平坦区域编码为255在可视化时是白色,跟边缘区域区分明显,调试时一眼能看出来。
另一个细节是邻居的遍历顺序。顺序本身不影响最终编码的数值分布,但必须固定,否则同一张图两次计算结果不一致。标准做法是从左上角开始顺时针,代码里用一个固定的偏移数组表示8个邻居相对中心的位置,这样写起来清晰也不容易错。
2.2 圆形邻域:从固定3x3到任意半径和采样点
3x3算子的问题很明显:它只能描述固定尺度的纹理,遇到大纹理或小纹理就力不从心。于是有了圆形邻域LBP,记作LBP(P, R),P是采样点个数,R是邻域半径。采样点均匀分布在以中心为圆心、半径为R的圆上,坐标用三角函数算出来。问题来了:算出来的坐标通常是小数,而图像像素是整数网格,怎么办?标准做法是双线性插值,用周围四个整数像素的灰度加权得到采样点的灰度值。
这里有个参数选择的经验。P一般取8、16、24,R一般取1、2、3。P越大,能编码的纹理模式越丰富,但特征维度也越高(基础LBP的直方图维度是2^P)。P=8时维度256,很轻量;P=16时维度65536,直接做直方图就太大了,必须配合等价模式降维。R的选择跟实际纹理尺度相关,我通常的做法是先做多尺度实验:R取1、2、3各算一遍,看哪个尺度的直方图在分类任务上区分度最高,再定下来。如果拿不准,就做多尺度拼接,把三个半径的直方图连起来用,代价是特征维度翻三倍。
双线性插值的实现要注意边界处理。采样点坐标可能落在图像外,尤其是图像边缘的像素。常见做法是对坐标做钳制(clamp到图像范围内),或者只对内部像素计算LBP、边缘像素跳过。我倾向后者,因为钳制会让边缘像素的编码失真,而边缘像素本来数量就少,跳过对整体直方图影响很小。
2.3 旋转不变与等价模式:两个降维利器的取舍
基础LBP有个致命缺陷:图像旋转后,同一个纹理模式的编码会变。比如一条水平边缘和一条垂直边缘,在3x3算子下编码完全不同,但它们本质是同一种纹理结构。解决办法是旋转不变LBP:对一个编码做循环移位,取所有移位结果里的最小值作为最终编码。这样不管怎么旋转,编码都归一化到同一个值。代价是编码数量减少但分布变得不均匀,而且计算量增加(每个像素要做P次移位比较)。
另一个降维利器是等价模式(Uniform Pattern)。观察发现,绝大多数自然纹理的LBP编码里,0和1之间的跳变次数很少,通常不超过2次。所谓跳变,就是二进制串里相邻两位不同(包括首尾相接)的次数。跳变次数<=2的编码称为等价模式,统计下来P=8时只有58种,其余198种归为一类。这样直方图维度从256降到59,特征更紧凑,抗噪性也更好,因为非等价模式往往是噪声引起的。
这两个变体可以叠加:先算旋转不变,再判断是否等价。但要注意叠加后的维度计算方式会变,P=8时旋转不变的等价模式共10类(9种等价模式加1类非等价),维度降到10,非常紧凑。我的经验是:如果纹理方向不重要(比如布料、皮肤),用旋转不变;如果追求特征紧凑和抗噪,用等价模式;两者都想要就叠加,但要做好维度骤降后区分度可能不够的准备,必要时配合多尺度拼接补回来。
| 变体类型 | P=8时维度 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 基础LBP | 256 | 实现简单、信息完整 | 维度偏高、对旋转敏感 | 纹理方向固定的场景 |
| 旋转不变LBP | 36 | 抗旋转 | 分布不均、计算量增加 | 方向随机的纹理 |
| 等价模式LBP | 59 | 紧凑、抗噪 | 丢失部分细节模式 | 通用纹理分类 |
| 旋转不变等价LBP | 10 | 极紧凑 | 区分度可能不足 | 嵌入式、实时场景 |
3. C++实现完整流程与代码细节
3.1 环境准备与图像读取
实现环境很朴素:C++11以上编译器,加一个图像读写库就行。我用的是OpenCV做图像加载和显示,但LBP核心计算部分完全不依赖OpenCV,纯数组操作,这样移植到嵌入式平台时只需替换图像读取那几行。如果你不想引入OpenCV,用stb_image这类单头文件库读图也完全够用。
读图后第一步是转灰度。LBP本质是单通道算子,彩色图要先转灰度。转换公式用标准的加权平均:Gray = 0.299R + 0.587G + 0.114*B。这里有个坑:不同库的转灰度公式系数略有差异,如果训练和推理用了不同库,可能导致特征分布偏移。我的做法是统一自己写转换,不依赖库的默认行为。
#include <opencv2/opencv.hpp> #include <vector> #include <cmath> #include <cstdint> // 读取图像并转灰度,返回单通道矩阵 cv::Mat loadGray(const std::string& path) { cv::Mat img = cv::imread(path, cv::IMREAD_COLOR); if (img.empty()) { throw std::runtime_error("图像读取失败: " + path); } cv::Mat gray; cv::cvtColor(img, gray, cv::COLOR_BGR2GRAY); return gray; }3.2 基础3x3 LBP的逐像素实现
核心函数接收灰度图,输出同尺寸的LBP响应图(用uint8_t存储)。实现时用指针遍历比用at()访问快很多,这是图像处理的常识。8个邻居的偏移量预先定义成数组,循环里直接查表。
// 基础3x3 LBP,输出响应图 cv::Mat lbpBasic3x3(const cv::Mat& gray) { CV_Assert(gray.type() == CV_8UC1); int rows = gray.rows, cols = gray.cols; cv::Mat lbp = cv::Mat::zeros(rows, cols, CV_8UC1); // 8个邻居相对中心的偏移,从左上角顺时针 const int dx[8] = {-1, 0, 1, 1, 1, 0, -1, -1}; const int dy[8] = {-1, -1, -1, 0, 1, 1, 1, 0}; for (int y = 1; y < rows - 1; ++y) { const uint8_t* centerRow = gray.ptr<uint8_t>(y); uint8_t* outRow = lbp.ptr<uint8_t>(y); for (int x = 1; x < cols - 1; ++x) { uint8_t center = centerRow[x]; uint8_t code = 0; for (int k = 0; k < 8; ++k) { uint8_t neighbor = gray.ptr<uint8_t>(y + dy[k])[x + dx[k]]; if (neighbor >= center) { code |= (1 << k); } } outRow[x] = code; } } return lbp; }这段代码有几个值得说的点。第一,边界一圈像素没有计算,保持为0,因为3x3邻域在边界处不完整。第二,code |= (1 << k)这种位操作比字符串拼接快得多,是C++实现的标准手法。第三,内层循环里gray.ptr<uint8_t>(y + dy[k])每次都要取行指针,可以优化成预先取好8个邻居行指针,但为了代码可读性这里先保持直观,后面优化章节再讲。
3.3 圆形邻域LBP与双线性插值
圆形邻域的采样点坐标计算是重点。对第p个采样点,角度是2pip/P,坐标是(x + Rcos(angle), y - Rsin(angle)),注意y方向要减,因为图像坐标系y轴向下。算出来的浮点坐标用双线性插值取灰度。
// 双线性插值取灰度,坐标钳制到图像范围内 float bilinear(const cv::Mat& gray, float fx, float fy) { int x0 = (int)std::floor(fx); int y0 = (int)std::floor(fy); int x1 = x0 + 1, y1 = y0 + 1; // 钳制边界 x0 = std::max(0, std::min(x0, gray.cols - 1)); x1 = std::max(0, std::min(x1, gray.cols - 1)); y0 = std::max(0, std::min(y0, gray.rows - 1)); y1 = std::max(0, std::min(y1, gray.rows - 1)); float wx = fx - std::floor(fx); float wy = fy - std::floor(fy); float v00 = gray.ptr<uint8_t>(y0)[x0]; float v10 = gray.ptr<uint8_t>(y0)[x1]; float v01 = gray.ptr<uint8_t>(y1)[x0]; float v11 = gray.ptr<uint8_t>(y1)[x1]; return v00 * (1 - wx) * (1 - wy) + v10 * wx * (1 - wy) + v01 * (1 - wx) * wy + v11 * wx * wy; } // 圆形邻域LBP,P个采样点,半径R cv::Mat lbpCircular(const cv::Mat& gray, int P, float R) { int rows = gray.rows, cols = gray.cols; cv::Mat lbp = cv::Mat::zeros(rows, cols, CV_32SC1); // 编码可能超过255,用int // 预计算采样点偏移 std::vector<float> cosTable(P), sinTable(P); for (int p = 0; p < P; ++p) { float angle = 2.0f * CV_PI * p / P; cosTable[p] = R * std::cos(angle); sinTable[p] = R * std::sin(angle); } int margin = (int)std::ceil(R); for (int y = margin; y < rows - margin; ++y) { for (int x = margin; x < cols - margin; ++x) { uint8_t center = gray.ptr<uint8_t>(y)[x]; uint32_t code = 0; for (int p = 0; p < P; ++p) { float fx = x + cosTable[p]; float fy = y - sinTable[p]; float val = bilinear(gray, fx, fy); if (val >= center) { code |= (1u << p); } } lbp.ptr<int>(y)[x] = (int)code; } } return lbp; }注意这里响应图用CV_32SC1而不是CV_8UC1,因为P大于8时编码会超过255。这是很多人第一次写圆形LBP时踩的坑:用uint8_t存编码,P=16时高位被截断,直方图完全错乱。另外margin的计算用ceil(R),保证只处理邻域完全落在图像内的像素。
3.4 旋转不变与等价模式的编码变换
旋转不变的处理是对编码做循环右移,取P次移位中的最小值。等价模式的判断是统计跳变次数。这两个变换都在编码算出来之后做,可以串起来。
// 旋转不变:取所有循环移位的最小值 uint32_t toRotationInvariant(uint32_t code, int P) { uint32_t minVal = code; uint32_t mask = (P == 32) ? 0xFFFFFFFF : ((1u << P) - 1); for (int i = 1; i < P; ++i) { code = ((code >> 1) | ((code & 1) << (P - 1))) & mask; if (code < minVal) minVal = code; } return minVal; } // 计算跳变次数 int countTransitions(uint32_t code, int P) { int transitions = 0; for (int i = 0; i < P; ++i) { uint32_t bit1 = (code >> i) & 1; uint32_t bit2 = (code >> ((i + 1) % P)) & 1; if (bit1 != bit2) ++transitions; } return transitions; } // 等价模式映射表构建:跳变<=2的保留原值,其余映射到统一类别 std::vector<int> buildUniformMap(int P) { int total = 1 << P; std::vector<int> map(total, 0); int uniformCount = 0; // 先给等价模式编号 for (int code = 0; code < total; ++code) { if (countTransitions(code, P) <= 2) { map[code] = uniformCount++; } } // 非等价模式统一映射到最后一类 for (int code = 0; code < total; ++code) { if (countTransitions(code, P) > 2) { map[code] = uniformCount; } } return map; }这里有个性能优化点:等价模式映射表只需要构建一次,之后每个像素查表就行,不要在每个像素里重复计算跳变次数。我见过有实现把countTransitions放在像素循环里,P=16时慢得没法用。映射表构建的复杂度是2^P,P=16时是65536次,一次性开销可以接受。
3.5 直方图统计与特征向量输出
响应图算出来后,统计直方图。如果是基础LBP,bin数就是2^P;如果用了等价模式,bin数是等价模式数加1;如果叠加旋转不变,bin数要按旋转不变等价模式的类别数来定。统计时要注意归一化,否则图像尺寸不同导致直方图总数不同,没法比较。
// 统计直方图并归一化 std::vector<float> computeHistogram(const cv::Mat& lbpImg, int binCount, const std::vector<int>& uniformMap = {}) { std::vector<float> hist(binCount, 0.0f); int rows = lbpImg.rows, cols = lbpImg.cols; long total = 0; for (int y = 0; y < rows; ++y) { const int* row = lbpImg.ptr<int>(y); for (int x = 0; x < cols; ++x) { int code = row[x]; if (code == 0 && uniformMap.empty()) continue; // 跳过未计算区域 int bin = uniformMap.empty() ? code : uniformMap[code]; if (bin >= 0 && bin < binCount) { hist[bin] += 1.0f; ++total; } } } // L1归一化 if (total > 0) { for (auto& v : hist) v /= total; } return hist; }归一化用L1(除以总数)还是L2(除以平方和开根)要看后续用途。做卡方距离或直方图交集比较时用L1更自然;做SVM输入时L2归一化通常效果更好。我一般两个都试,看哪个在验证集上表现好。
4. 实操中的性能优化与踩坑记录
4.1 从朴素实现到可用的性能调优
前面给的代码是教学版,直接上产线会慢。我做过一轮系统优化,把1080p图像的处理时间从约45毫秒压到8毫秒左右,主要做了三件事。
第一件是预取邻居行指针。基础3x3版本里内层循环每次都调gray.ptr(y + dy[k]),这个函数内部有类型检查和乘法运算。优化后在外层循环把上下三行的指针取好,内层直接用。
// 优化版:预取三行指针 for (int y = 1; y < rows - 1; ++y) { const uint8_t* prev = gray.ptr<uint8_t>(y - 1); const uint8_t* curr = gray.ptr<uint8_t>(y); const uint8_t* next = gray.ptr<uint8_t>(y + 1); uint8_t* out = lbp.ptr<uint8_t>(y); for (int x = 1; x < cols - 1; ++x) { uint8_t c = curr[x]; uint8_t code = 0; code |= (prev[x-1] >= c) << 0; code |= (prev[x] >= c) << 1; code |= (prev[x+1] >= c) << 2; code |= (curr[x+1] >= c) << 3; code |= (next[x+1] >= c) << 4; code |= (next[x] >= c) << 5; code |= (next[x-1] >= c) << 6; code |= (curr[x-1] >= c) << 7; out[x] = code; } }第二件是查表替代三角函数。圆形邻域的采样点偏移只跟P和R有关,跟像素位置无关,完全可以预先算好存成数组,循环里直接加。这个前面代码已经做了,但很多人写的时候图省事在像素循环里调cos/sin,性能差一个数量级。
第三件是多线程分块。图像按行切成若干块,每块独立算LBP,最后合并直方图。用OpenMP的话加一行#pragma omp parallel for就行。注意直方图合并时要加锁或者每线程维护局部直方图最后汇总,后者更快。
| 优化手段 | 优化前耗时(1080p) | 优化后耗时 | 提升幅度 |
|---|---|---|---|
| 朴素实现 | 45ms | - | 基准 |
| 预取行指针 | 45ms | 28ms | 38% |
| 查表替代三角函数 | 28ms | 15ms | 46% |
| OpenMP多线程(4核) | 15ms | 8ms | 47% |
4.2 五个高频踩坑与排查方法
坑一:响应图全黑或全白。最常见原因是比较方向写反了,或者位操作移位方向错了。排查方法:打印某个平坦区域中心点的8个邻居值和最终编码,手工核对二进制。平坦区域用>=比较应该得到255,如果得到0说明比较符号写反。
坑二:直方图维度对不上。用了等价模式但bin数还按2^P算,导致越界或大量空bin。排查方法:打印映射表的最大值加一,确认等于bin数。P=8时等价模式bin数应该是59,不是58也不是60。
坑三:圆形邻域边缘出现异常编码。双线性插值的坐标钳制写错,或者margin算小了导致采样点越界。排查方法:把响应图可视化,看边缘一圈是否有明显的异常条带。正常情况边缘margin区域应该是0(未计算),如果出现非零且杂乱的编码,就是越界了。
坑四:多尺度拼接后特征维度爆炸。三个半径各256维拼起来768维,如果再加旋转不变和等价模式,维度计算容易出错。排查方法:写个辅助函数专门算维度,拼接前打印各段维度,确认总和符合预期。
坑五:训练和推理的归一化方式不一致。训练时用L1归一化,推理时忘了归一化或者用了L2,导致特征分布偏移,分类精度骤降。排查方法:把归一化逻辑封装成一个函数,训练和推理都调同一个,杜绝手写两遍。
注意:LBP对噪声比较敏感,尤其是基础3x3算子。如果输入图像噪声大,建议先做一次高斯模糊(3x3或5x5),再算LBP。实测下来这一步能把等价模式直方图的稳定性提升不少,代价只是增加不到1毫秒。
4.3 特征后处理与分类器搭配建议
LBP直方图算出来后,直接喂给分类器往往不够。我常用的后处理有两个:一是空间金字塔拼接,把图像分成1x1、2x2、4x4的网格,每个格子单独算LBP直方图再拼接,这样保留了空间分布信息,对人脸这类有固定结构的场景提升明显;二是卡方核映射,LBP直方图之间的卡方距离比欧氏距离更合适,如果分类器支持自定义核就用卡方核,不支持的话可以做显式的卡方特征映射再喂给线性分类器。
分类器选择上,小样本场景(几百张图)用SVM配卡方核最稳,我做过对比,同样特征下SVM比随机森林高3到5个百分点。样本量上千后可以试试梯度提升树,但要注意LBP特征维度高时树模型容易过拟合,需要控制树的深度。深度学习时代LBP依然有用武之地,常见做法是把LBP直方图作为额外特征拼到CNN的全连接层前面,或者用LBP响应图作为CNN的额外输入通道,在数据量不足时这种融合能带来稳定提升。
5. 多尺度融合与工程化封装
5.1 多半径直方图拼接的实操
单一尺度的LBP只能捕捉特定大小的纹理结构,实际场景里纹理往往是多尺度的。比如皮肤图像,毛孔是细尺度,皱纹是粗尺度,只用一个半径肯定漏信息。多尺度融合的做法很直接:R取1、2、3各算一遍LBP直方图,然后首尾拼接成一个长向量。
拼接时要注意各段的归一化。如果每段单独L1归一化再拼接,总和不等于1,但各段权重相等;如果拼接后整体归一化,长的那段权重会被稀释。我的经验是每段单独归一化再拼接,这样每个尺度贡献均等,后续如果发现某个尺度更重要,还可以给各段乘权重。
// 多尺度LBP特征提取 std::vector<float> multiScaleLBP(const cv::Mat& gray, const std::vector<float>& radii, int P) { std::vector<float> feature; auto uniformMap = buildUniformMap(P); int binCount = 0; for (int code = 0; code < (1 << P); ++code) { binCount = std::max(binCount, uniformMap[code] + 1); } for (float R : radii) { cv::Mat lbpImg = lbpCircular(gray, P, R); // 应用等价模式映射 cv::Mat mapped(lbpImg.size(), CV_32SC1); for (int y = 0; y < lbpImg.rows; ++y) { const int* src = lbpImg.ptr<int>(y); int* dst = mapped.ptr<int>(y); for (int x = 0; x < lbpImg.cols; ++x) { dst[x] = uniformMap[src[x]]; } } auto hist = computeHistogram(mapped, binCount); feature.insert(feature.end(), hist.begin(), hist.end()); } return feature; }这段代码里有个细节:等价模式映射在直方图统计前做,而不是统计后做。因为映射是把多个原始编码合并到一个bin,必须在计数前完成,否则计数就错了。这个顺序问题我见过有人搞反,结果直方图总数对不上。
5.2 封装成可复用的特征提取类
工程化时把LBP相关逻辑封装成一个类,对外只暴露配置和提取接口,内部管理映射表缓存、参数校验。这样调用方不用关心P和R怎么影响维度,也不会出现训练推理参数不一致的问题。
class LBPFeatureExtractor { public: struct Config { int P = 8; std::vector<float> radii = {1.0f, 2.0f, 3.0f}; bool rotationInvariant = false; bool uniformPattern = true; }; explicit LBPFeatureExtractor(const Config& cfg) : cfg_(cfg) { validateConfig(); if (cfg_.uniformPattern) { uniformMap_ = buildUniformMap(cfg_.P); binCount_ = 0; for (int code = 0; code < (1 << cfg_.P); ++code) { binCount_ = std::max(binCount_, uniformMap_[code] + 1); } } else { binCount_ = 1 << cfg_.P; } } int featureDim() const { return binCount_ * static_cast<int>(cfg_.radii.size()); } std::vector<float> extract(const cv::Mat& gray) const { CV_Assert(gray.type() == CV_8UC1); std::vector<float> feature; feature.reserve(featureDim()); for (float R : cfg_.radii) { cv::Mat lbpImg = lbpCircular(gray, cfg_.P, R); cv::Mat processed = applyTransforms(lbpImg); auto hist = computeHistogram(processed, binCount_); feature.insert(feature.end(), hist.begin(), hist.end()); } return feature; } private: void validateConfig() { if (cfg_.P < 4 || cfg_.P > 24) { throw std::invalid_argument("P建议在4到24之间"); } if (cfg_.radii.empty()) { throw std::invalid_argument("至少需要一个半径"); } } cv::Mat applyTransforms(const cv::Mat& lbpImg) const { cv::Mat result(lbpImg.size(), CV_32SC1); for (int y = 0; y < lbpImg.rows; ++y) { const int* src = lbpImg.ptr<int>(y); int* dst = result.ptr<int>(y); for (int x = 0; x < lbpImg.cols; ++x) { uint32_t code = static_cast<uint32_t>(src[x]); if (cfg_.rotationInvariant) { code = toRotationInvariant(code, cfg_.P); } dst[x] = cfg_.uniformPattern ? uniformMap_[code] : static_cast<int>(code); } } return result; } Config cfg_; std::vector<int> uniformMap_; int binCount_ = 0; };这个类的好处是配置和维度计算绑定在一起,调用方通过featureDim()拿到准确维度,不会出现特征向量长度和分类器输入不匹配的问题。另外映射表在构造时构建一次,多次提取复用,避免重复计算。
5.3 验证特征有效性的小实验
写完代码总得验证一下特征是不是真的有用。我常用的快速验证方法是:找两类纹理差异明显的图(比如粗糙砂纸和光滑金属),各取20张,提取LBP特征,算类内距离和类间距离的比值。比值明显小于1说明特征有区分度。更严谨一点就做个简单的最近邻分类,看准确率是否显著高于随机猜测。
另一个验证手段是直方图可视化。把两类图的平均LBP直方图画在一起,看哪些bin的分布差异大。如果两类直方图几乎重合,说明当前参数(P、R、是否等价模式)不适合这个任务,需要调整。这个手段比看准确率更直观,能指导参数调整方向。
我在实际项目里还遇到过一个情况:特征在训练集上区分度很好,换一批同场景的图就崩了。排查下来是两批图的光照条件不同,LBP虽然对光照变化有一定鲁棒性(因为比较的是相对大小),但极端光照下对比度压缩,编码分布还是会偏移。解决办法是在预处理阶段做直方图均衡化,把光照影响压下去再算LBP。这个经验说明LBP不是万能的,预处理环节不能省。
6. 常见问题速查与参数调优经验
6.1 参数选择速查表
调LBP参数时最常纠结的就是P、R和变体组合。我把这些年积累的经验整理成表,遇到新任务先按表里的默认值起步,再根据验证结果微调。
| 任务类型 | 推荐P | 推荐R | 变体组合 | 特征维度参考 |
|---|---|---|---|---|
| 人脸纹理分析 | 8 | 1,2 | 等价模式+多尺度 | 118 |
| 工业表面缺陷 | 8 | 1,2,3 | 等价模式+多尺度 | 177 |
| 医学影像纹理 | 16 | 2,3 | 旋转不变等价 | 20 |
| 布料分类 | 8 | 1,2,3 | 旋转不变+多尺度 | 108 |
| 实时嵌入式 | 8 | 1 | 旋转不变等价 | 10 |
表里的维度是按对应变体算出来的,实际使用时以代码里featureDim()的输出为准。P=16配旋转不变等价模式时维度只有18加1共19类(旋转不变等价模式在P=16时的类别数),非常紧凑,适合算力受限的场景。
6.2 直方图比较方法的选择
特征提取完,比较两张图相似度时用哪种距离也有讲究。LBP直方图是分布,用卡方距离最合适,公式是sum((a_i - b_i)^2 / (a_i + b_i))。直方图交集(sum(min(a_i, b_i)))计算更快,效果略差但差距不大。欧氏距离在直方图稀疏时表现不好,不推荐。
如果后续要接SVM,可以把卡方距离转成卡方核,或者用显式特征映射把直方图映射到高维空间再用线性SVM。显式映射的公式是sqrt(2*a_i/(a_i+b_i))这类变换,具体实现网上有成熟代码,这里不展开。我的经验是样本量小于一千时卡方核SVM最稳,样本量大时显式映射加线性SVM训练更快。
6.3 什么时候该放弃LBP
LBP不是万能特征,有几种情况我会果断换方案。一是纹理尺度变化极大且没有规律,多尺度LBP也覆盖不了,这时候考虑Gabor滤波器组或者可变形部件模型。二是图像分辨率极低(比如小于32x32),LBP邻域信息严重不足,编码基本是噪声,不如用简单的灰度统计特征。三是任务需要语义级理解而非纹理描述,比如区分猫和狗,LBP帮不上忙,得上深度学习。
判断方法很简单:提取LBP特征后做个快速分类实验,如果准确率跟随机猜测差不多,且换了几组参数都没改善,就别在LBP上耗时间了,换特征。我在一个低分辨率红外图像项目上就遇到过这种情况,最后换成了方向梯度直方图加局部对比度统计,效果立竿见影。
我个人在实际操作中的体会是,LBP最大的价值在于它的简单和可解释。每个编码位对应一个方向的灰度比较,出了问题能追溯到具体是哪个方向的比较异常。这种可解释性在工业检测这类需要定位问题根源的场景里,比黑盒深度特征更让人放心。代码写完之后,建议先拿几张典型图把响应图存下来肉眼看看,确认编码模式符合直觉,再进入批量实验。这个习惯帮我省过很多次返工的时间。