资讯动态

LBP局部二值模式:原理、C++实现与纹理特征工程实战

发布时间:2026/10/26 6:47:26 来源:尧图企业网站定制
LBP这个算子我最早是在做工业表面缺陷检测的时候被它救过一次场。当时产线上光照条件极不稳定用灰度阈值和边缘算子做出来的结果飘得厉害后来换成LBP纹理特征误检率一下就压下去了。这件事让我对看起来简单、实际很能打的传统特征算法有了新的认识。LBPLocal Binary Pattern局部二值模式本质上就是拿每个像素和它周围一圈邻居比大小比出来的0/1串编码成一个整数用这个整数去描述局部纹理。它计算量小、对单调光照变化天然免疫、还不需要训练特别适合嵌入式视觉、缺陷检测、人脸分析、医学图像纹理分类这类场景。这篇内容我会从原理拆到C实现把我在工程里踩过的坑、调过的参数、做过的加速都摊开讲适合刚接触图像处理想搞懂纹理特征的同学也适合手上有个C项目正愁特征选型的老手。1. 为什么在C项目里还值得认真对待LBP1.1 一个被低估的传统特征现在一提到图像特征很多人第一反应是丢进深度网络里学。但在实际工程里尤其是C写的、跑在工控机或者嵌入式板子上的项目模型体积、推理延迟、标注成本这三座山压下来传统特征往往才是那个能落地的选项。LBP就是其中的典型代表它的核心运算只有比较和移位没有浮点乘除一个3x3邻域算下来就是8次比较加几次位操作在ARM Cortex-A系列上单帧1080p图像做全图LBP优化得当可以压到几毫秒级别。我之所以在C项目里反复用LBP是因为它解决了一个很具体的问题纹理描述。灰度直方图只能告诉你图里有多少亮多少暗丢掉了空间结构边缘算子能告诉你哪里有边但对边怎么排列不敏感。LBP恰好卡在中间——它编码的是局部邻域内的相对明暗关系也就是纹理的微观模式。布料有没有织疵、金属表面有没有麻点、纸张有没有起皱这些在LBP编码后的直方图分布上会呈现出明显差异。1.2 LBP到底免疫什么、敏感什么这一点必须讲清楚否则用起来会莫名其妙。LBP的比较是中心像素 vs 邻居像素只要整体亮度发生单调变化比如光照整体变亮或变暗所有比较的相对结果不变编码就不变。这就是它对单调光照变化免疫的根源。但反过来如果光照变化是非单调的比如局部打了高光、出现了阴影梯度那比较结果就会翻转编码就变了。所以LBP不是抗光照的万能药它抗的是全局单调光照。它对噪声敏感。因为比较是逐像素的一个邻居像素被噪声扰动跨过了中心值那一位就翻转了。这也是为什么工程里几乎没人直接对原始图算LBP前面基本都会跟一个高斯平滑或者中值滤波。我在一个低照度相机项目里就吃过这个亏没做预平滑LBP直方图的类间距离被噪声拉得很近分类器怎么调都上不去加了3x3高斯之后准确率直接涨了六七个点。1.3 适合谁、不适合谁LBP适合的场景很明确纹理分类、表面缺陷检测、人脸表情与年龄的粗粒度分析、医学图像的纹理区分比如组织切片、材质识别。这些场景的共同点是局部微观模式的统计分布能区分目标。它不适合的场景同样明确需要精确位置或形状的任务比如关键点定位、轮廓提取以及纹理本身极其平滑、几乎没有局部变化的图像比如纯色背景上的几何图形检测这时候LBP编码几乎全是同一个值信息量为零。2. 把LBP的数学掰开从3x3到圆形邻域2.1 原始LBP的编码过程最经典的LBP是3x3窗口。取中心像素的灰度值作为阈值周围8个邻居逐个比较邻居大于等于中心记1小于记0。然后从某个固定起点通常取左上角或正上方开始按顺时针方向把这8个bit串成一个8位二进制数再转成十进制这个十进制数就是中心像素的LBP值范围0到255。举个具体例子。假设3x3邻域灰度如下6 5 2 7 6 1 9 8 7中心是6。从左上角开始顺时针比较66记156记026记016记076记186记196记176记1。得到的二进制串按起点到终点顺序是10001111转十进制是143。这个143就是中心像素的LBP编码。这里有个细节很多人第一次实现会搞错bit的拼接顺序必须全局一致。因为LBP值本身没有绝对意义它的意义在于同一套编码规则下不同区域的分布差异。如果你在训练阶段用一种顺序推理阶段换了另一种顺序直方图就对不上了。我在代码里习惯把起点定在正上方12点钟方向顺时针走这样和大多数文献的图示一致方便对照。2.2 圆形邻域与双线性插值3x3的方形邻域有个硬伤它只能覆盖半径固定为1的8个点尺度不可调。而且方形邻域在对角线方向的采样距离是√2和轴向的1不一致导致编码对方向有偏。为了解决这个后来提出了圆形LBP给定半径R和采样点数P在半径为R的圆上均匀取P个点。问题来了圆上的采样点坐标通常不是整数落在像素之间。这时候必须做双线性插值取灰度值。以半径R2、采样点P8为例第p个采样点的角度是2πp/P坐标是(x R·cos θ, y - R·sin θ)注意图像坐标系y轴向下所以是减。拿到浮点坐标后取它周围四个整数像素按距离加权求和。双线性插值的公式不复杂但实现时要注意边界处理。采样点落到图像外怎么办常见做法有两种一是对图像做边界填充比如复制边缘像素二是直接跳过边界像素不计算LBP。我在工程里倾向第二种因为填充会引入虚假纹理边界那一圈本来也不该参与统计。代价是输出图像会比原图小一圈尺寸是(rows - 2R, cols - 2R)这个在后续拼接时要留意。2.3 等价模式把256维砍到59维原始LBP的直方图是256维8位。维度不算高但有个问题这256种模式里绝大多数在自然图像中出现频率极低而且很多模式只是同一个纹理的旋转或轻微扰动。更关键的是256维里有大量冗余对分类器的区分贡献很小反而稀释了有效信息。等价模式Uniform Pattern就是来解决这个的。它的定义是把LBP的二进制串首尾相接看成一个环环上0和1之间的跳变次数不超过2次的称为等价模式。比如00000000跳变0次00011100跳变2次0→1一次1→0一次都是等价模式而01010101跳变8次就不是。统计下来8位LBP的等价模式一共58种加上所有非等价模式归为一类总共59维。这个降维不是拍脑袋的。研究表明自然图像中等价模式能覆盖绝大多数通常90%以上的局部纹理非等价模式往往是噪声或者极端结构。所以59维直方图既保留了主要信息又大幅降低了维度还顺带提升了抗噪性。我在实际项目里默认就用等价模式除非有特殊理由才用全256维。模式类型维度特点适用场景原始LBP256信息全但冗余多纹理极复杂、样本充足等价模式59抗噪、维度低绝大多数工程场景旋转不变等价10对旋转不敏感纹理方向随机如砂纸2.4 旋转不变性什么时候需要什么时候是坑纹理如果会旋转比如传送带上的零件朝向不固定那普通LBP的直方图会随旋转变化分类就不稳。旋转不变LBP的做法是对一个二进制串做所有可能的循环移位取其中最小的那个值作为编码。这样同一个纹理无论怎么转编码都归一化到同一个值。但这里有个大坑我得提醒旋转不变性会损失方向信息。如果你的任务恰恰需要区分方向比如判断织物纹理是横纹还是竖纹用了旋转不变LBP反而把关键信息抹掉了。我在一个方向性纹理分类的项目里就犯过这个错一开始图省事用了旋转不变结果横竖两类完全分不开换成普通等价模式后立刻正常。所以选不选旋转不变取决于你的纹理有没有方向这个语义。3. C实现从朴素版本到可用的工程代码3.1 朴素3x3版本的完整实现先把最基础的版本写出来不依赖任何图像库的LBP函数纯手写这样你能看清每一步。假设输入是单通道灰度图用std::vectorunsigned char按行优先存储宽高分别是w和h。#include vector #include cstdint // 朴素3x3 LBP边界一圈不处理 std::vectoruint8_t lbp3x3(const std::vectoruint8_t gray, int w, int h) { std::vectoruint8_t out(w * h, 0); // 8邻域偏移从正上方开始顺时针 const int dx[8] { 0, 1, 1, 1, 0, -1, -1, -1}; const int dy[8] {-1, -1, 0, 1, 1, 1, 0, -1}; for (int y 1; y h - 1; y) { for (int x 1; x w - 1; x) { uint8_t center gray[y * w x]; uint8_t code 0; for (int k 0; k 8; k) { uint8_t nb gray[(y dy[k]) * w (x dx[k])]; if (nb center) code | (1 k); } out[y * w x] code; } } return out; }这段代码有几个点值得说。第一dx/dy数组的顺序决定了bit的权重我这里k0对应正上方权重是10也就是最低位。这个顺序一旦定了就不能改训练推理必须一致。第二边界一圈直接留0因为3x3窗口在边界会越界。第三比较用的是而不是这个也有讲究——用时平坦区域邻居全等于中心会编码成全1255用时全编码成0。两种约定都有人用但必须全局统一否则直方图对不上。我习惯用因为平坦区域编码成255在直方图里是个很明显的峰便于观察。3.2 圆形LBP与双线性插值的实现细节圆形LBP的代码要复杂一些核心是预计算采样点的偏移和插值权重。因为对同一组(R, P)每个像素用的偏移和权重都一样所以可以提前算好避免在双重循环里反复算三角函数。struct CircularLBP { int R, P; std::vectorfloat cosTable, sinTable; CircularLBP(int r, int p) : R(r), P(p) { cosTable.resize(P); sinTable.resize(P); for (int i 0; i P; i) { float ang 2.0f * 3.14159265f * i / P; cosTable[i] std::cos(ang); sinTable[i] std::sin(ang); } } // 双线性插值取灰度 float sample(const std::vectoruint8_t g, int w, int h, float fx, float fy) { int x0 (int)std::floor(fx), y0 (int)std::floor(fy); int x1 x0 1, y1 y0 1; float tx fx - x0, ty fy - y0; auto at [](int x, int y) - float { x std::min(std::max(x, 0), w - 1); y std::min(std::max(y, 0), h - 1); return (float)g[y * w x]; }; float v00 at(x0, y0), v10 at(x1, y0); float v01 at(x0, y1), v11 at(x1, y1); return v00 * (1 - tx) * (1 - ty) v10 * tx * (1 - ty) v01 * (1 - tx) * ty v11 * tx * ty; } };这里at函数做了边界钳制clamp这是最省事的边界处理方式虽然会引入一点边缘重复但对统计直方图影响很小。如果你追求严格可以在外层循环时把边界R圈直接跳过。插值权重tx/ty是浮点整个采样过程是浮点运算这是圆形LBP比3x3慢的主要原因。实测下来同样1080p图像3x3版本比R2/P8的圆形版本快大概3到4倍所以如果尺度不是刚需优先用3x3。3.3 直方图统计与归一化LBP算出来的是每个像素一个编码真正喂给分类器的是直方图。统计直方图时等价模式的映射需要一张查找表对每个可能的编码值0到255预先算好它属于哪个bin0到58非等价归到58。// 预计算等价模式查找表 std::vectorint buildUniformTable() { std::vectorint table(256, 58); // 默认非等价 int idx 0; for (int code 0; code 256; code) { int transitions 0; for (int i 0; i 8; i) { int cur (code i) 1; int nxt (code ((i 1) % 8)) 1; if (cur ! nxt) transitions; } if (transitions 2) table[code] idx; } return table; }注意这里idx的递增顺序决定了bin的编号只要训练和推理用同一张表就没问题。统计完直方图后一定要归一化通常除以总像素数让直方图各bin之和为1。这样不同尺寸的图像块之间才有可比性。我在项目里还习惯对直方图做一次开方sqrt或者对数变换目的是压缩大bin、拉伸小bin让分布更平滑对后续的卡方距离或SVM都更友好。3.4 分块直方图空间信息的补救全局直方图有个致命问题它丢掉了空间信息。一张图里左边是布纹、右边是麻点和左右反过来全局LBP直方图是一样的。解决办法是分块统计把图像切成m×n个网格每块单独算直方图最后拼接起来。这样特征维度是m×n×59空间信息就回来了。分块粒度是个需要调的参数。块太大空间信息弱块太小每块像素少直方图统计不稳尤其59维块内像素最好不少于几千。我一般从3×3或4×4起步根据图像分辨率和纹理尺度微调。有个经验块的大小最好不小于纹理基元的2到3倍否则块内可能连一个完整纹理周期都覆盖不到统计出来的分布没有代表性。4. 工程实战里那些文档不会写的事4.1 预处理顺序平滑、归一化、LBP谁先谁后这个顺序错了效果会差很多。正确的顺序是先平滑再可选光照归一化最后算LBP。平滑是为了压噪声前面说过LBP对噪声敏感。光照归一化比如直方图均衡或者局部对比度归一化是为了让不同图像的整体亮度分布一致虽然LBP对单调光照免疫但极端欠曝或过曝会让大量像素挤在0或255附近比较结果失真。但这里有个反直觉的点不要做锐化。锐化会放大高频噪声而LBP恰恰吃高频噪声的亏。我在一个项目里见过有人为了增强纹理先做了USM锐化再算LBP结果直方图被噪声主导分类器学到的全是噪声模式。平滑的核也别太大3x3高斯足够5x5以上会把细纹理抹平LBP编码就退化了。4.2 参数选择的经验法则半径R和采样点P的选择直接决定LBP描述的是哪个尺度的纹理。R小捕捉细纹理R大捕捉粗纹理。P决定角度分辨率P越大方向分辨越细但计算量也越大。我的经验法则细纹理织物、纸张、金属拉丝R1或2P8中等纹理木材、石材、皮肤R2或3P8或16粗纹理大颗粒材料、宏观图案R4以上P16或24P一般取8的倍数因为8位编码最自然也方便用uint8_t存。P超过8就得用uint16_t甚至更大直方图维度也会涨等价模式下P16是243维要权衡。我做过一个对比实验同一批石材样本R2/P8和R3/P16的分类准确率差了不到2个点但后者计算时间是前者的2.5倍所以除非准确率卡在瓶颈否则R2/P8是性价比最高的默认值。4.3 多尺度融合一个便宜又好用的技巧单一尺度的LBP只能描述一个尺度的纹理。如果目标纹理的尺度不确定比如零件大小不一单尺度就会漏。多尺度融合的做法是对同一张图算多个(R, P)组合的LBP直方图拼接成一个长向量。比如R1/P8、R2/P16、R3/P24三组拼起来维度是59243555具体看等价模式维度虽然维度高了但描述能力显著增强。这个技巧的代价是计算量和特征维度都上去了所以适合离线训练、在线推理对延迟不敏感的场景。在线场景我一般只用单尺度或者用两个尺度R1和R2折中。融合时记得每个尺度的直方图都要单独归一化再拼接否则大尺度的bin数值会压过小尺度。4.4 加速从查表到SIMD朴素实现的瓶颈在双重循环里的逐像素比较。几个加速手段按投入产出比排序第一预计算偏移索引。把(ydy)*w(xdx)这种乘法提前算成相对偏移循环里只做加法。这个改动几乎零成本能快10%到20%。第二展开内层循环。8次比较手动展开去掉循环开销编译器也更容易做指令级并行。这个能再快15%左右。第三用查找表替代比较。对3x3版本可以预先把中心值和8个邻居的相对关系编码成一个索引但8个邻居每个都可能大于或小于中心组合太多查找表不现实。所以这条路对LBP不通别浪费时间。第四SIMD。用SSE/AVX对多个像素并行做比较和位拼接理论上有4到8倍加速。但LBP的位拼接是串行的每个邻居占一位SIMD化需要巧妙的位操作重排实现复杂度高。我在一个对实时性要求极高的项目里做过AVX2版本确实快但调试花了两天而且可移植性差。除非你的场景真的卡在性能上否则不建议一上来就上SIMD。第五多线程。按行分块每个线程处理一段最后合并直方图。这个最简单用OpenMP一行#pragma omp parallel for就能搞定加速比接近核数。直方图合并时注意用线程局部直方图再归并避免锁竞争。加速手段实现难度典型加速比建议预计算偏移低1.1~1.2x必做循环展开低1.15x必做多线程低接近核数图像大时必做SIMD高3~6x性能瓶颈时考虑4.5 一个真实的排查链路分类准确率上不去说个我印象最深的排查过程。有个表面缺陷分类的项目LBP特征加SVM准确率卡在78%上不去怎么调SVM的C和gamma都没用。我按下面的链路一步步查第一步先怀疑特征本身有没有区分度。我把两类样本的LBP直方图画出来对比发现两类直方图几乎重合只有个别bin有差异。这说明问题不在分类器在特征。第二步查预处理。发现原始图没做平滑而且相机在低照度下噪声很大。加了3x3高斯后两类直方图的差异明显了一些准确率到82%。第三步查尺度。缺陷的尺寸大概十几个像素而我用的是R1捕捉的是更细的纹理缺陷本身的宏观模式没被描述到。换成R3/P16后准确率到87%。第四步查分块。缺陷只出现在图像局部全局直方图被大量正常区域稀释了。改成分块4x4网格后准确率到91%。第五步查归一化。发现分块后有些块几乎全是背景直方图很平这些块的噪声特征干扰了分类。加了块内方差筛选方差过低的块直接跳过不参与拼接准确率到93%。这个链路说明一个道理LBP效果不好八成不是LBP本身的问题而是预处理、尺度、空间组织、归一化这些外围环节没调对。每次只改一个变量观察准确率变化是最靠谱的排查方式。5. 和其他特征怎么配合以及什么时候该换掉它5.1 LBP 梯度特征的互补LBP描述的是局部明暗相对关系它对梯度方向不敏感。而HOG方向梯度直方图恰恰描述梯度方向分布。两者是天然互补的LBP管纹理的微观模式HOG管边缘的方向结构。我在做材质分类时把LBP直方图和HOG特征拼接准确率比单用任何一个都高5到8个点。拼接时要注意量纲。LBP直方图归一化后各bin在0到1之间HOG通常也做了块归一化两者量级接近直接拼问题不大。但如果某一方没归一化就得先做标准化减均值除标准差否则量级大的那方会主导距离计算。5.2 什么时候该果断放弃LBP有三种情况我会果断换掉LBP。第一纹理极其平滑局部几乎没有灰度变化LBP编码退化成单一值信息量为零这时候该用颜色特征或者形状特征。第二目标需要精确的语义分割而不是图像级分类LBP给的是统计分布没有像素级定位能力这时候该上语义分割网络。第三样本量极大且标注充足深度网络能学到比手工特征更丰富的表示LBP的优势无需训练、计算快就不明显了。但反过来说样本量小、需要可解释性、部署环境算力受限这三个条件只要占一个LBP就仍然值得作为基线方案先跑起来。我个人的习惯是任何纹理相关的任务先花半天用LBP搭个基线看看准确率大概在什么水平再决定要不要上更重的方案。这个基线往往比想象中能打有时候直接就用它交付了。5.3 特征工程上的两个小技巧第一个是直方图交叉核。如果下游是SVM别用RBF核用直方图交叉核Histogram Intersection Kernel它对直方图类特征更合适实测比RBF稳定且不需要仔细调gamma。第二个是空间金字塔。比单纯分块更进一步做多级分块比如1x1、2x2、4x4三层每层直方图加权拼接权重按层递减。这个思路来自空间金字塔匹配对纹理的空间分布建模更细代价是维度涨得快要配合降维比如PCA使用。代码上直方图交叉核就是两个直方图逐bin取min再求和实现简单double histIntersection(const std::vectorfloat a, const std::vectorfloat b) { double s 0; for (size_t i 0; i a.size(); i) s std::min(a[i], b[i]); return s; }这个核函数的值在0到1之间归一化直方图下可以直接当相似度用也可以喂给核SVM。我在小样本场景下经常直接用它做最近邻分类简单粗暴但有效。6. 收尾前再聊几句实操体会LBP这个算法代码量不大原理也不复杂但真正用好它功夫全在那些外围上——预处理做不做平滑、尺度选多大、要不要分块、直方图怎么归一化、和什么特征拼。我见过太多人把LBP当黑盒调不通就换算法其实往往是某个环节的参数没对上。我自己的习惯是每换一个数据集先把LBP直方图可视化出来看一眼两类样本的分布有没有肉眼可见的差异如果没有那后面分类器再怎么调都是白搭得回头查特征和预处理。还有一个我踩过的坑值得提训练和推理的编码规则必须严格一致。包括bit的拼接顺序、比较用还是、等价模式的查找表、分块的方式、归一化的方法任何一处不一致特征分布就会偏移模型直接失效。我的做法是把这些规则全部封装进一个配置结构体训练时序列化存盘推理时加载从根上杜绝不一致。这个习惯帮我省过好几次线上事故。如果你的项目正好在做纹理相关的特征不妨先拿LBP搭个基线跑跑看成本很低半天就能出结果。跑通了再考虑要不要上更复杂的方案很多时候这个基线就够用了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑